diff --git a/.gitignore b/.gitignore index 65bf33cb..36df6a54 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ tools/mingw64/ *.exe *.tmp *.log +# 本地构建缓存(沙箱环境下 GOCACHE/zig cache 重定向到工作区) +.gocache/ +.zigcache/ diff --git a/assets/config/blocks.json b/assets/config/blocks.json index de483f2c..986879e5 100644 --- a/assets/config/blocks.json +++ b/assets/config/blocks.json @@ -4,7 +4,7 @@ "granite": { "id": 2, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/granite" } }, "diorite": { "id": 3, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diorite" } }, "andesite": { "id": 4, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/andesite" } }, - "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" } }, + "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass" }, "dirt": { "id": 6, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/dirt" } }, "cobblestone": { "id": 7, "hardness": 2.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/cobblestone" } }, "oak_planks": { "id": 8, "hardness": 2.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/oak_planks" } }, @@ -12,7 +12,7 @@ "sand": { "id": 10, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/sand" } }, "gravel": { "id": 11, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gravel" } }, "oak_log": { "id": 12, "hardness": 2.0, "resistance": 2.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/oak_log_top", "down": "block/oak_log_top", "side": "block/oak_log" } }, - "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true }, + "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage" }, "glass": { "id": 14, "hardness": 0.3, "resistance": 1.5, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/glass" }, "cutout": true }, "water": { "id": 15, "hardness": 100.0, "resistance": 500.0, "light": 0, "transparent": true, "solid": false, "textures": { "all": "block/water_still" }, "translucent": true, "fluid": true }, "lava": { "id": 16, "hardness": 100.0, "resistance": 500.0, "light": 15, "transparent": true, "solid": false, "textures": { "all": "block/lava_still" }, "translucent": true, "fluid": true }, diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index f0cd80e4..3f924394 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -9,10 +9,15 @@ package main import ( "context" "flag" + "fmt" + "image" + "image/png" + "math" "os" "os/signal" "sort" "sync" + "sync/atomic" "time" "mc/internal/assets" @@ -23,6 +28,7 @@ import ( "mc/internal/config" "mc/internal/crash" "mc/internal/engine" + "mc/internal/entity" "mc/internal/font" "mc/internal/game" "mc/internal/inventory" @@ -45,8 +51,14 @@ var devMode = flag.Bool("dev", devDefault, "开发模式:直接读取 assets/ // 开发测试参数(dev 测试通过后再打包) var ( - autoEnter = flag.Bool("auto", false, "自动进入游戏(跳过主菜单点击)") - shotPath = flag.String("shot", "", "截图路径:运行约 2 秒后保存 PNG 并退出(dev 测试用)") + autoEnter = flag.Bool("auto", false, "自动进入游戏(跳过主菜单点击)") + shotPath = flag.String("shot", "", "截图路径:运行约 2 秒后保存 PNG 并退出(dev 测试用)") + lookDeg = flag.Float64("lookdeg", 0, "dev 测试:进游戏后注入一次合成鼠标移动(角度),验证视角跟随链路") + pitchDeg = flag.Float64("pitchdeg", 0, "dev 测试:进游戏后把俯仰角直接设置为该值(度)") + giveItem = flag.String("give", "", "dev 测试:启动时给热键栏 0 号槽放入 64 个该物品(如 oak_planks/torch)") + crackStage = flag.Int("crackstage", -1, "dev 测试:截图前把破坏进度视觉设置为该阶段(0–9)") + spawnTest = flag.String("spawntest", "", "dev 测试:截图前在视线前方 5 格生成一个实体(zombie/animal/villager)") + escTest = flag.Bool("esctest", false, "dev 测试:截图前注入一次 ESC(验证暂停菜单与光标释放链路)") ) // clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 @@ -72,14 +84,32 @@ type clientApp struct { remotePlayers map[uint32][3]float64 meshBuilder *mesh.Builder + uv func(string) (u0, v0, u1, v1 float32, ok bool) mu sync.Mutex pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传 + // 光标模式请求(-1 无请求 / 0 释放 / 1 捕获):由 tick 写入、渲染线程消费。 + // GLFW 调用必须发生在主线程(渲染线程),从 tick goroutine 直接调用 + // SetInputMode 属线程违规——症状:ESC 后鼠标仍锁在窗口中心无法自由移动。 + cursorReq atomic.Int32 + + // 手持方块(tick 计算 UV → 渲染线程上传,渲染.md §5.2) + handInit bool + handSlot int + handHas bool + handDirty bool + handUVs [6][4]float32 + winW, winH int tickCnt uint64 frames int // 渲染帧计数(截图时机用) lastTime time.Time + + lookInjected bool // dev 测试:-lookdeg 已注入 + crackShot bool // dev 测试:-crackstage 已注入 + spawnShot bool // dev 测试:-spawntest 已注入 + escShot bool // dev 测试:-esctest 已注入 } // worldMeshJob 待上传网格。 @@ -96,21 +126,21 @@ func (a *clientApp) Tick(dt float64) { // dev 测试:-auto 自动进入游戏(跳过点击) if *autoEnter { a.screen = 1 - a.rend.SetCursorMode(true) + a.requestCursor(true) return } if a.in.clicked { switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) { case "play": a.screen = 1 - a.rend.SetCursorMode(true) + a.requestCursor(true) case "multi": // 联机:连接本机服务器(多人同步.md §2) if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil { a.logf("连接服务器失败: %v", err) } else { a.screen = 1 - a.rend.SetCursorMode(true) + a.requestCursor(true) a.hud.OnlineCount = func() int { a.remoteMu.Lock() defer a.remoteMu.Unlock() @@ -128,13 +158,13 @@ func (a *clientApp) Tick(dt float64) { case 2: // 暂停 if a.in.escPressed { a.screen = 1 - a.rend.SetCursorMode(true) + a.requestCursor(true) } if a.in.clicked { switch a.pauseScreen.HitTest(a.in.clickX, a.in.clickY) { case "resume": a.screen = 1 - a.rend.SetCursorMode(true) + a.requestCursor(true) case "quit": a.rend.Window().SetShouldClose(true) } @@ -146,11 +176,28 @@ func (a *clientApp) Tick(dt float64) { // 游戏中 if a.in.escPressed { a.screen = 2 - a.rend.SetCursorMode(false) + a.requestCursor(false) a.in.escPressed = false return } a.in.apply(a.sess) + // 手持方块 UV(选中槽变化时重算;渲染线程消费,渲染.md §5.2) + if !a.handInit || a.sess.Inv.Selected != a.handSlot { + a.computeHand() + } + // dev 测试:注入一次合成鼠标移动(走真实 cursorCallback,验证视角跟随链路) + if (*lookDeg != 0 || *pitchDeg != 0) && !a.lookInjected { + a.lookInjected = true + if *pitchDeg != 0 { + a.sess.Cam.Pitch = *pitchDeg * math.Pi / 180.0 + } + if *lookDeg != 0 { + dx := float64(*lookDeg) * math.Pi / 180.0 / 0.0025 // 目标弧度 → 像素位移 + a.in.cursorCallback(nil, float64(a.winW)/2, float64(a.winH)/2) // 复位基准 + a.in.cursorCallback(nil, float64(a.winW)/2+dx, float64(a.winH)/2) + a.logf("lookdeg 注入完成: +%.1f° 水平移动", *lookDeg) + } + } a.sess.Tick(dt) // 联机:上报移动(20Hz,多人同步.md §3) @@ -207,8 +254,111 @@ func (a *clientApp) Tick(dt float64) { } } +// injectDevVisuals dev 测试注入(渲染线程,3D 绘制之前调用): +// -crackstage 设置破坏裂纹阶段(视线前方方块);-spawntest 生成实体; +// -esctest 注入 ESC(下一 tick 转暂停并请求释放光标)。 +func (a *clientApp) injectDevVisuals() { + if *escTest && !a.escShot { + a.escShot = true + a.in.escPressed = true + a.logf("esctest 注入: ESC 按下(暂停菜单 + 光标释放链路)") + } + if *crackStage >= 0 && !a.crackShot { + a.crackShot = true + if h, ok := a.sess.Cast(4); ok { + a.sess.SetBreakVisual(h.X, h.Y, h.Z, float64(*crackStage)/10.0+0.05) + a.in.breakHeld = true // 防止 tick 的 EndBreak 清掉注入的裂纹视觉 + a.logf("crackstage 注入: 方块(%d,%d,%d) 阶段 %d", h.X, h.Y, h.Z, *crackStage) + } + } + if *spawnTest != "" && !a.spawnShot { + a.spawnShot = true + f := a.sess.Cam.Forward() + ex, ez := int32(a.sess.Player.Pos[0]+f[0]*5), int32(a.sess.Player.Pos[2]+f[2]*5) + gy := int32(a.sess.Player.Pos[1]) + for ; gy > 0; gy-- { + if a.sess.World.Block(ex, gy, ez) != block.Air { + break + } + } + ey := float64(gy + 1) + switch *spawnTest { + case "zombie": + a.sess.Entities.Spawn(entity.KindZombie, float64(ex)+0.5, ey, float64(ez)+0.5) + case "animal": + e := a.sess.Entities.Spawn(entity.KindAnimal, float64(ex)+0.5, ey, float64(ez)+0.5) + e.Data = &entity.Animal{} + case "villager": + a.sess.Entities.Spawn(entity.KindVillager, float64(ex)+0.5, ey, float64(ez)+0.5) + } + a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez) + } +} + +// requestCursor 请求切换光标捕获模式(由 tick 写入,渲染线程消费—— +// GLFW 调用必须发生在渲染/主线程,UI与输入.md §6)。 +func (a *clientApp) requestCursor(captured bool) { + if captured { + a.cursorReq.Store(1) + } else { + a.cursorReq.Store(0) + } +} + +// computeHand 计算手持方块的六面 UV(选中槽的放置方块)。 +// 顺序:顶/底/东/西/南/北(与 render.cubeFaces 一致)。 +func (a *clientApp) computeHand() { + a.handInit = true + a.handSlot = a.sess.Inv.Selected + setEmpty := func() { + a.mu.Lock() + a.handHas = false + a.handDirty = true + a.mu.Unlock() + } + sel := a.sess.Inv.SelectedStack() + if sel.Empty() { + setEmpty() + return + } + d, ok := a.sess.Items.Get(sel.Name) + if !ok || d.Place == "" { + setEmpty() + return + } + bid, ok := a.reg.ID(d.Place) + if !ok { + setEmpty() + return + } + st := block.NewState(bid, 0) + faces := [6]string{"up", "down", "side", "side", "side", "side"} + var uvs [6][4]float32 + for i, f := range faces { + u0, v0, u1, v1, found := a.uv(a.reg.Texture(st, f)) + if !found { + u0, v0, u1, v1 = 0, 0, 1, 1 + } + uvs[i] = [4]float32{u0, v0, u1, v1} + } + a.mu.Lock() + a.handUVs = uvs + a.handHas = true + a.handDirty = true + a.mu.Unlock() +} + // Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。 func (a *clientApp) Render() { + // 消费光标模式请求(主线程执行 GLFW 调用) + if req := a.cursorReq.Load(); req >= 0 { + a.rend.SetCursorMode(req == 1) + a.cursorReq.Store(-1) + } + // dev 测试:截图前注入破坏裂纹/实体/ESC(必须在 3D 绘制之前,截图帧才能拍到) + if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest) { + a.injectDevVisuals() + } // 上传 tick 构建好的网格(GL 调用必须在渲染线程) a.mu.Lock() pending := a.pendingMesh @@ -228,6 +378,44 @@ func (a *clientApp) Render() { a.rend.BeginFrame() chunks := a.sess.World.ActiveChunks() a.rend.DrawChunks(func(i int) (int32, int32) { return chunks[i].CX, chunks[i].CZ }, len(chunks)) + + // 实体广告牌(渲染.md §5.3:僵尸/村民/动物) + for _, e := range a.sess.Entities.List() { + if e.Dead { + continue + } + key, h := entityVisual(e) + if key == "" { + continue + } + if uv, ok := a.rend.EntityUV(key); ok { + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv) + } + } + + // 破坏裂纹叠加(方块交互与动画.md §4:progress→destroy_stage 0–9) + if prog := a.sess.BreakProgress(); prog > 0 { + t := a.sess.BreakTarget() + a.rend.DrawCrackOverlay(float32(t.X), float32(t.Y), float32(t.Z), int(prog*10)) + } + // 手持方块上传与绘制(渲染.md §5.2;上传必须是渲染线程) + a.mu.Lock() + handDirty, handHas := a.handDirty, a.handHas + a.handDirty = false + var handUVs [6][4]float32 + if handHas { + handUVs = a.handUVs + } + a.mu.Unlock() + if handDirty { + if handHas { + a.rend.SetHandCube(handUVs, [3]float32{0.46, -0.44, -0.66}, 0.16) + } else { + a.rend.ClearHand() + } + } + a.rend.DrawHand() + // UI pass(UI与输入.md §2:3D 之后、状态隔离) a.uiR.Begin() mx, my := float32(a.in.cursorX), float32(a.in.cursorY) @@ -263,6 +451,7 @@ func (a *clientApp) Render() { if !ready { a.logf("截图兜底:视野未完全就绪(frame=%d)", a.frames) } + a.logf("shot cam: pos=%v yaw=%.2f pitch=%.2f chunks=%d", a.sess.Cam.Pos, a.sess.Cam.Yaw, a.sess.Cam.Pitch, len(a.sess.World.ActiveChunks())) if err := a.saveScreenshot(*shotPath); err != nil { a.logf("截图失败: %v", err) } else { @@ -360,6 +549,37 @@ func main() { panic(err) } + // 植被染色(渲染.md §4.1):原版材质包草顶/树叶为灰度图, + // 绿色来自染色表——图集构建后对带 tint 标记的纹理乘色,否则草地/树叶全灰。 + if tinted := reg.TintTextureKeys(); len(tinted) > 0 { + atlasImg = applyAtlasTint(atlasImg, uvRects, am, tinted) + log.Infof("图集植被染色:%v", tinted) + } + + // 裂纹图集(方块交互与动画.md §4:destroy_stage_0..9,破坏进度叠加用) + crackKeys := make([]string, 10) + for i := range crackKeys { + crackKeys[i] = fmt.Sprintf("block/destroy_stage_%d", i) + } + crackImg, crackRectMap, err := atlas.Build(am.ResolveTexture, crackKeys, 16) + if err != nil { + log.Warnf("裂纹图集构建失败:%v", err) + } + + // 实体贴图图集(渲染.md §5.3:僵尸/村民/动物的广告牌贴图) + entityKeys := []string{ + "entity/zombie/zombie", + "entity/villager/villager", + "entity/cow/cow_temperate", + "entity/pig/pig_temperate", + "entity/sheep/sheep", + "entity/chicken/chicken_temperate", + } + entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64) + if err != nil { + log.Warnf("实体图集构建失败:%v", err) + } + // 3) 世界与生成器(区块管理.md §4 异步流水线) gen, err := worldgen.New(reg, 123456789, 0.005, 0.01, 0.1) if err != nil { @@ -399,6 +619,14 @@ func main() { p := physics.NewPlayer(8.5, float64(surf+2), 8.5) cam := camera.New(p.Eye(), 0, -0.35, cfg.Client.FOV) // 略微俯视,视野内以地形为主 inv := inventory.New(items) + // dev 测试:给 0 号槽物品(验证手持方块渲染) + if *giveItem != "" { + if inv.Add(item.Stack{Name: *giveItem, Count: 64}).Count != 0 { + log.Warnf("give 失败(物品不存在或已满): %s", *giveItem) + } else { + log.Infof("dev give: %s ×64 → 热键栏 0 号槽", *giveItem) + } + } sess := game.NewSession(w, reg, items, recipes, p, inv, cam) // 5) 渲染器(GL 线程亲和在其内部锁定) @@ -410,6 +638,27 @@ func main() { rend.SetAtlas(atlasImg) // 主菜单状态:鼠标可见(仅游戏内捕获,UI与输入.md §6) rend.SetCursorMode(false) + // 裂纹图集上传(方块交互与动画.md §4) + if crackImg != nil { + var crackRects [10]image.Rectangle + for i := 0; i < 10; i++ { + crackRects[i] = crackRectMap[crackKeys[i]] + } + rend.SetCrackAtlas(crackImg, crackRects) + } + // 实体贴图上传(渲染.md §5.3) + if entityImg != nil { + entityUV := make(map[string][4]float32, len(entityKeys)) + W, H := float32(entityImg.Bounds().Dx()), float32(entityImg.Bounds().Dy()) + for _, k := range entityKeys { + rc := entityRectMap[k] + entityUV[k] = [4]float32{ + float32(rc.Min.X) / W, float32(rc.Min.Y) / H, + float32(rc.Max.X) / W, float32(rc.Max.Y) / H, + } + } + rend.SetEntityAtlas(entityImg, entityUV) + } // 光影包(渲染.md §9:未安装 → 默认光影;坏包 → 回退默认) if sp, err := render.LoadShaderPack(am); err != nil { @@ -444,6 +693,33 @@ func main() { } hud.SetItemAtlas(itemTex, itemUV) } + // 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*) + loadTex := func(key string) uint32 { + p, ok := am.ResolveTexture(key) + if !ok { + return 0 + } + f, err := os.Open(p) + if err != nil { + return 0 + } + img, err := png.Decode(f) + _ = f.Close() + if err != nil { + return 0 + } + return uiR.UploadImage(img) + } + hud.SetSprites( + loadTex("gui/sprites/hud/crosshair"), + loadTex("gui/sprites/hud/hotbar"), + loadTex("gui/sprites/hud/hotbar_selection"), + loadTex("gui/sprites/hud/heart/full"), + loadTex("gui/sprites/hud/heart/half"), + loadTex("gui/sprites/hud/heart/container"), + loadTex("gui/sprites/hud/food_full"), + loadTex("gui/sprites/hud/food_half"), + ) // 界面屏幕(原版复刻:土质背景/原版按钮/中文标题,UI还原.md §2) lang, err := am.LoadLang(cfg.Client.Language) @@ -467,6 +743,8 @@ func main() { log: log, remotePlayers: make(map[uint32][3]float64), meshBuilder: mesh.NewBuilder(reg, uvFn), + uv: uvFn, + handSlot: -1, winW: cfg.Client.WindowWidth, winH: cfg.Client.WindowHeight, lastTime: time.Now(), diff --git a/cmd/client/entities_gl.go b/cmd/client/entities_gl.go new file mode 100644 index 00000000..a4e01b46 --- /dev/null +++ b/cmd/client/entities_gl.go @@ -0,0 +1,30 @@ +//go:build gl + +// 实体可视映射:实体类型 → 广告牌贴图键与高度(渲染.md §5.3)。 +package main + +import "mc/internal/entity" + +// entityVisual 返回实体的贴图键与身高(米)。 +// 动物按 ID 轮换四种外观(cow/pig/sheep/chicken)。 +func entityVisual(e *entity.Entity) (key string, height float32) { + switch e.Kind { + case entity.KindZombie: + return "entity/zombie/zombie", 1.95 + case entity.KindVillager: + return "entity/villager/villager", 1.95 + case entity.KindAnimal: + switch e.ID % 4 { + case 0: + return "entity/cow/cow_temperate", 1.4 + case 1: + return "entity/pig/pig_temperate", 0.9 + case 2: + return "entity/sheep/sheep", 1.3 + default: + return "entity/chicken/chicken_temperate", 0.7 + } + default: + return "", 0 // 掉落物等后置接入 + } +} diff --git a/cmd/client/input.go b/cmd/client/input.go index bbee1f30..27b312a3 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -109,12 +109,6 @@ func (in *inputState) cursorCallback(_ *glfw.Window, x, y float64) { const sens = 0.0025 in.dyaw += dx * sens in.dpitch -= dy * sens - if in.dpitch > math.Pi/2-0.01 { - in.dpitch = math.Pi/2 - 0.01 - } - if in.dpitch < -math.Pi/2+0.01 { - in.dpitch = -math.Pi/2 + 0.01 - } } // scrollCallback 滚轮切热键栏(UI与输入.md §6)。 @@ -131,12 +125,21 @@ func (in *inputState) apply(s *game.Session) { cam := s.Cam p := s.Player - // 视角 + // 视角(俯仰钳制在相机上,而非增量上——旧实现钳增量,抬头可翻转超过 ±90°) if in.dyaw != 0 || in.dpitch != 0 { cam.Yaw += in.dyaw cam.Pitch += in.dpitch in.dyaw, in.dpitch = 0, 0 + const maxPitch = math.Pi/2 - 0.01 + if cam.Pitch > maxPitch { + cam.Pitch = maxPitch + } + if cam.Pitch < -maxPitch { + cam.Pitch = -maxPitch + } } + // 游戏中左键=破坏,不是 UI 点击:清除遗留点击状态,防止暂停菜单收到过期点击 + in.clicked = false // 水平移动(物理与碰撞.md §3:走/冲刺/潜行) f := cam.Forward() diff --git a/cmd/client/screens_gl.go b/cmd/client/screens_gl.go index d2d07948..b1ab72d6 100644 --- a/cmd/client/screens_gl.go +++ b/cmd/client/screens_gl.go @@ -24,10 +24,17 @@ var cjkPaths = []string{ } // loadCJK 加载系统 CJK 字体(失败返回 nil → 中文标签退化不显示)。 -func loadCJK() *cjkfont.Renderer { +// 按字号缓存:不同字号(标题 96 / splash 24 / 按钮 18)需要各自的光栅化实例。 +var cjkCache = map[float64]*cjkfont.Renderer{} + +func loadCJK(size float64) *cjkfont.Renderer { + if r, ok := cjkCache[size]; ok { + return r + } for _, p := range cjkPaths { if _, err := os.Stat(p); err == nil { - if r, err := cjkfont.Load(p, 24); err == nil { + if r, err := cjkfont.Load(p, size); err == nil { + cjkCache[size] = r return r } } @@ -37,8 +44,6 @@ func loadCJK() *cjkfont.Renderer { // buildScreens 构建主菜单与暂停菜单(原版布局与控件)。 func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.TitleScreen, *ui.PauseScreen) { - cjk := loadCJK() - // 原版按钮控件(widget/button.png 200×20 与悬停高亮) loadPng := func(key string) (uint32, bool) { p, ok := am.ResolveTexture(key) @@ -62,8 +67,9 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti // 土质背景:16px dirt 平铺 1280×720(原版菜单背景质感) bgTex := tileBackground(r, am) - // 中文文本光栅化 + // 中文文本光栅化(按请求字号取缓存渲染器) text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) if cjk == nil || s == "" { return 0, 0, 0 } @@ -94,6 +100,13 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti Version: "年糕历险记 v0.1.0", Copyright: "Copyright NianGao Studio. 请勿分发!", } + // 版本/版权角标:中文走 CJK 光栅(ASCII 位图字体缺中文字形) + if vTex, vw, vh := tr("menu.version", "年糕历险记 v0.1.0", 12, color.RGBA{220, 220, 220, 255}); vTex != 0 { + title.VersionTex, title.VersionW, title.VersionH = vTex, vw, vh + } + if cTex, cw, ch := text("Copyright NianGao Studio. 请勿分发!", 12, color.RGBA{220, 220, 220, 255}); cTex != 0 { + title.CopyrightTex, title.CopyrightW, title.CopyrightH = cTex, cw, ch + } title.Buttons = []ui.Button{ mkBtn("menu.singleplayer", "单人游戏", "play", 390), mkBtn("menu.multiplayer", "多人游戏", "multi", 414), diff --git a/cmd/client/tint_gl.go b/cmd/client/tint_gl.go new file mode 100644 index 00000000..85a20877 --- /dev/null +++ b/cmd/client/tint_gl.go @@ -0,0 +1,70 @@ +//go:build gl + +// 图集植被染色(渲染.md §4.1): +// 原版材质包的草顶/树叶纹理是灰度图,绿色来自生物群系染色表 +// (textures/colormap/grass.png、foliage.png)。 +// MVP:取染色表中心色(平原绿)对图集内对应纹理乘色;按群系逐顶点着色后置。 +package main + +import ( + "image" + "image/color" + "image/draw" + "image/png" + "os" + + "mc/internal/assets" +) + +// applyAtlasTint 给图集内带染色标记的纹理乘上染色表中心色。 +// 返回新图集图像(原地复制后修改);染色表缺失的键保持原样。 +func applyAtlasTint(src image.Image, uv map[string]image.Rectangle, am *assets.Manager, tinted map[string][]string) image.Image { + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + + for tintName, keys := range tinted { + tr, tg, tb, ok := colormapCenter(am, tintName) + if !ok { + continue + } + for _, key := range keys { + rect, found := uv[key] + if !found { + continue + } + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + pr, pg, pb, pa := src.At(x, y).RGBA() + dst.SetRGBA(x, y, color.RGBA{ + R: uint8(uint32(uint8(pr>>8)) * tr / 255), + G: uint8(uint32(uint8(pg>>8)) * tg / 255), + B: uint8(uint32(uint8(pb>>8)) * tb / 255), + A: uint8(pa >> 8), + }) + } + } + } + } + return dst +} + +// colormapCenter 读取染色表中心像素(0–255)。 +func colormapCenter(am *assets.Manager, name string) (r, g, b uint32, ok bool) { + p, found := am.ResolveTexture("colormap/" + name) + if !found { + return 0, 0, 0, false + } + f, err := os.Open(p) + if err != nil { + return 0, 0, 0, false + } + defer f.Close() + img, err := png.Decode(f) + if err != nil { + return 0, 0, 0, false + } + cb := img.Bounds() + r, g, b, _ = img.At(cb.Min.X+cb.Dx()/2, cb.Min.Y+cb.Dy()/2).RGBA() + return r >> 8, g >> 8, b >> 8, true +} diff --git a/go.mod b/go.mod index 8af28bc2..ea6da824 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.19.2 github.com/ojrac/opensimplex-go v1.0.2 github.com/yuin/gopher-lua v1.1.2 + golang.org/x/image v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,7 +20,6 @@ require ( github.com/hajimehoshi/oto v0.7.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect - golang.org/x/image v0.45.0 // indirect golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/internal/block/registry.go b/internal/block/registry.go index 60a5dc84..3c3d37da 100644 --- a/internal/block/registry.go +++ b/internal/block/registry.go @@ -42,6 +42,7 @@ type Def struct { RequiredTier int `json:"required_tier"` Drops string `json:"drops"` Textures map[string]string `json:"textures"` + Tint string `json:"tint"` // 植被染色表:grass/foliage(渲染.md §4.1) } // Registry 方块注册表。 @@ -140,3 +141,23 @@ func (r *Registry) Texture(s State, face string) string { } return "block/" + d.Name } + +// TintTextureKeys 返回「染色表名 → 涉及纹理键」:图集构建后按染色表给这些 +// 纹理乘色(渲染.md §4.1 植被染色)。无染色标记的方块不出现。 +func (r *Registry) TintTextureKeys() map[string][]string { + out := make(map[string][]string) + for _, d := range r.byID { + if d.ID == 0 || d.Tint == "" { + continue + } + seen := make(map[string]struct{}) + for _, k := range d.Textures { + if _, dup := seen[k]; dup { + continue + } + seen[k] = struct{}{} + out[d.Tint] = append(out[d.Tint], k) + } + } + return out +} diff --git a/internal/game/game.go b/internal/game/game.go index 29a40765..e24d816d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -129,6 +129,13 @@ func (s *Session) BreakProgress() float64 { return s.breakProg } // BreakTarget 当前破坏目标(渲染选择框/裂纹用)。 func (s *Session) BreakTarget() raycast.Hit { return s.breakTarget } +// SetBreakVisual 直接设置破坏进度显示状态(客户端渲染层裂纹叠加/开发测试用; +// 不修改世界数据,不驱动 accumulateBreak)。 +func (s *Session) SetBreakVisual(x, y, z int32, prog float64) { + s.breakTarget = raycast.Hit{X: x, Y: y, Z: z} + s.breakProg = prog +} + // breakSpeed 每秒破坏进度(方块交互与动画.md §3、挖矿与矿物.md §3 同公式)。 func (s *Session) breakSpeed(def block.Def, tool item.Stack) float64 { speed := 1.0 // 空手 diff --git a/internal/render/entity_gl.go b/internal/render/entity_gl.go new file mode 100644 index 00000000..fc9c20e5 --- /dev/null +++ b/internal/render/entity_gl.go @@ -0,0 +1,112 @@ +//go:build gl + +// 实体广告牌渲染(实体系统.md §5、渲染.md §5.3): +// 以相机朝向的四边形绘制实体贴图(zombie/villager/动物)。 +// 复用 chunk 着色器:aPos 为单位四边形局部坐标(x∈[-0.5,0.5],y∈[0,1], +// y=0 为脚底),顶点着色器按 uCamRight/uCamUp 展开成世界坐标; +// UV 经 uUVMin/uUVMax 重映射到图集内该实体的格子。 +package render + +import ( + "image" + "unsafe" + + "mc/internal/mesh" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +// SetEntityAtlas 上传实体贴图图集并记录 UV(键 → 归一化 UV)。 +func (r *Renderer) SetEntityAtlas(img image.Image, uv map[string][4]float32) { + if r.entityTex == 0 { + gl.GenTextures(1, &r.entityTex) + } + gl.BindTexture(gl.TEXTURE_2D, r.entityTex) + b := img.Bounds() + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytes(img))) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + r.entityUV = uv +} + +// EntityUV 返回实体贴图 UV(不存在返回 false)。 +func (r *Renderer) EntityUV(key string) ([4]float32, bool) { + uv, ok := r.entityUV[key] + return uv, ok +} + +// ensureBillQuad 惰性构建单位广告牌四边形(pos+uv+light 顶点格式,CW 绕序正面朝相机)。 +// aUV.y=0 对应纹理底部(脚底),1 对应顶部(头)——顶点序按此翻转 v。 +func (r *Renderer) ensureBillQuad() { + if r.billVao != 0 { + return + } + verts := []mesh.Vertex{ + {X: -0.5, Y: 0, Z: 0, U: 0, V: 1, Sky: 15, Block: 15}, // 脚底左(uv v=1) + {X: -0.5, Y: 1, Z: 0, U: 0, V: 0, Sky: 15, Block: 15}, // 头左(uv v=0) + {X: 0.5, Y: 1, Z: 0, U: 1, V: 0, Sky: 15, Block: 15}, // 头右 + {X: 0.5, Y: 0, Z: 0, U: 1, V: 1, Sky: 15, Block: 15}, // 脚底右 + } + idx := []uint32{0, 3, 1, 3, 2, 1} // 窗口坐标下 CW(FrontFace=CW 时为正面向相机) + + gl.GenVertexArrays(1, &r.billVao) + gl.GenBuffers(1, &r.billVbo) + gl.GenBuffers(1, &r.billIbo) + gl.BindVertexArray(r.billVao) + gl.BindBuffer(gl.ARRAY_BUFFER, r.billVbo) + gl.BufferData(gl.ARRAY_BUFFER, len(verts)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(verts), gl.STATIC_DRAW) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, r.billIbo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.STATIC_DRAW) + stride := int32(unsafe.Sizeof(mesh.Vertex{})) + gl.EnableVertexAttribArray(0) + gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0)) + gl.EnableVertexAttribArray(1) + gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4)) + gl.EnableVertexAttribArray(2) + gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4)) + gl.BindVertexArray(0) +} + +// DrawBillboard 在 (x,y,z)(脚底)绘制 h 高 × w 宽的实体广告牌,贴图 uv。 +func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32) { + if r.entityTex == 0 { + return + } + r.ensureBillQuad() + + gl.UseProgram(r.prog) + gl.ActiveTexture(gl.TEXTURE0) + gl.BindTexture(gl.TEXTURE_2D, r.entityTex) + vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")) + var vp [16]float32 + multiply4(&vp, &r.proj, &r.view) + gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0]) + offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00")) + gl.Uniform3f(offLoc, x, y, z) + cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) + gl.Uniform1f(cutLoc, 0) + // 相机基向量(视图矩阵列 0/1 即世界系下的 right/up) + rightLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamRight\x00")) + upLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamUp\x00")) + gl.Uniform3f(rightLoc, r.view[0], r.view[1], r.view[2]) + gl.Uniform3f(upLoc, r.view[4], r.view[5], r.view[6]) + billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00")) + gl.Uniform2f(billLoc, w, h) + uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")) + uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) + gl.Uniform2f(uvMinLoc, uv[0], uv[1]) + gl.Uniform2f(uvMaxLoc, uv[2], uv[3]) + + gl.Disable(gl.CULL_FACE) // 广告牌永远朝向相机,无需剔除 + gl.BindVertexArray(r.billVao) + gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindVertexArray(0) + gl.Enable(gl.CULL_FACE) + + // 复位广告牌状态(避免影响后续裂纹/手持/UI) + gl.Uniform2f(billLoc, 1, 0) // uBill.y=0 → 顶点着色器走普通路径 + gl.Uniform2f(uvMinLoc, 0, 0) + gl.Uniform2f(uvMaxLoc, 1, 1) +} diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go index c6d8bca6..b6a42d22 100644 --- a/internal/render/mesh_gl.go +++ b/internal/render/mesh_gl.go @@ -102,6 +102,14 @@ func (r *Renderer) DrawChunks(get func(i int) (int32, int32), n int) { vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")) cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0]) + offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00")) + gl.Uniform3f(offLoc, 0, 0, 0) // 区块网格无偏移(覆盖层在 DrawChunks 后自行设置) + billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00")) + gl.Uniform2f(billLoc, 1, 0) // 非广告牌路径 + uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")) + uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) + gl.Uniform2f(uvMinLoc, 0, 0) // 区块顶点 UV 直接使用 + gl.Uniform2f(uvMaxLoc, 1, 1) // pass 1+2:不透明与 cutout(alpha test,不排序) gl.Uniform1f(cutLoc, 0) diff --git a/internal/render/overlay_gl.go b/internal/render/overlay_gl.go new file mode 100644 index 00000000..507577f4 --- /dev/null +++ b/internal/render/overlay_gl.go @@ -0,0 +1,231 @@ +//go:build gl + +// 覆盖层渲染:破坏裂纹叠加(destroy_stage_0..9)与第一人称手持方块 +// (方块交互与动画.md §4、渲染.md §5.2)。 +// 与区块网格共用顶点格式(pos+uv+light)与着色器,通过 uOffset 平移定位。 +package render + +import ( + "image" + "unsafe" + + "mc/internal/mesh" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +// cubeFaces 单位立方体 6 面角顺序(与 mesh.faceDef 一致,CW 绕序为正面)。 +var cubeFaces = [6][4][3]float32{ + {{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}, // 顶 + {{0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {0, 0, 0}}, // 底 + {{1, 0, 0}, {1, 0, 1}, {1, 1, 1}, {1, 1, 0}}, // 东(+x) + {{0, 0, 1}, {0, 0, 0}, {0, 1, 0}, {0, 1, 1}}, // 西(-x) + {{1, 0, 1}, {0, 0, 1}, {0, 1, 1}, {1, 1, 1}}, // 南(+z) + {{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}, // 北(-z) +} + +// cubeVerts 按六面 UV 构建单位立方体顶点(mesh.Vertex 格式:pos+uv+light 字节, +// 光照满亮——覆盖层不受世界光照影响)。inflate 沿角外扩(裂纹盒防 z-fighting)。 +func cubeVerts(uvs [6][4]float32, inflate float32) []mesh.Vertex { + var verts []mesh.Vertex + for fi, face := range cubeFaces { + u0, v0, u1, v1 := uvs[fi][0], uvs[fi][1], uvs[fi][2], uvs[fi][3] + for _, c := range face { + // 角 UV 权重(同 mesh.cornerUV) + var cu, cv float32 + switch fi { + case 0, 1: // 顶/底:x→u,z→v + cu, cv = c[0], c[2] + case 2, 3: // 东/西:z→u,y→v(v 翻转) + cu, cv = c[2], 1-c[1] + default: // 南/北:x→u,y→v(v 翻转) + cu, cv = c[0], 1-c[1] + } + verts = append(verts, mesh.Vertex{ + X: (c[0]-0.5)*(1+inflate) + 0.5, + Y: (c[1]-0.5)*(1+inflate) + 0.5, + Z: (c[2]-0.5)*(1+inflate) + 0.5, + U: u0 + (u1-u0)*cu, V: v0 + (v1-v0)*cv, + Sky: 15, Block: 15, + }) + } + } + return verts +} + +// cubeIndices 24 顶点 → 36 索引(每面 2 三角形)。 +func cubeIndices() []uint32 { + idx := make([]uint32, 0, 36) + for base := 0; base < 24; base += 4 { + b := uint32(base) + idx = append(idx, b, b+1, b+2, b+2, b+3, b) + } + return idx +} + +// uploadMesh 上传顶点/索引并绑定顶点属性(chunk 顶点布局,渲染.md §5)。 +func uploadMesh(verts []mesh.Vertex, idx []uint32) (vao, vbo, ibo uint32) { + gl.GenVertexArrays(1, &vao) + gl.GenBuffers(1, &vbo) + gl.GenBuffers(1, &ibo) + gl.BindVertexArray(vao) + gl.BindBuffer(gl.ARRAY_BUFFER, vbo) + gl.BufferData(gl.ARRAY_BUFFER, len(verts)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(verts), gl.STATIC_DRAW) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.STATIC_DRAW) + stride := int32(unsafe.Sizeof(mesh.Vertex{})) + gl.EnableVertexAttribArray(0) + gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0)) + gl.EnableVertexAttribArray(1) + gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4)) + gl.EnableVertexAttribArray(2) + gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4)) + gl.BindVertexArray(0) + return +} + +// buildCubeVAO 构建立方体 VAO(顶点格式与 chunk 一致:pos+uv+light)。 +// uvs 六面 UV(顺序同 cubeFaces);inflate 沿角外扩;立方体锚点为原点,边长为 1。 +func buildCubeVAO(uvs [6][4]float32, inflate float32) uint32 { + vao, _, _ := uploadMesh(cubeVerts(uvs, inflate), cubeIndices()) + return vao +} + +// SetCrackAtlas 上传裂纹图集(10 阶段 destroy_stage)并记录各阶段 UV。 +// img 为调用方打包好的图集,rects[i] 为阶段 i 的像素矩形(图集坐标系)。 +func (r *Renderer) SetCrackAtlas(img image.Image, rects [10]image.Rectangle) { + if r.crackTex == 0 { + gl.GenTextures(1, &r.crackTex) + } + gl.BindTexture(gl.TEXTURE_2D, r.crackTex) + b := img.Bounds() + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytes(img))) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + + W, H := float32(b.Dx()), float32(b.Dy()) + const inset = 0.5 // 防渗色(渲染.md §4) + for i, rc := range rects { + r.crackUVs[i] = [4]float32{ + (float32(rc.Min.X) + inset) / W, (float32(rc.Min.Y) + inset) / H, + (float32(rc.Max.X) - inset) / W, (float32(rc.Max.Y) - inset) / H, + } + } +} + +// DrawCrackOverlay 在目标方块 (x,y,z) 叠加 stage 阶段裂纹盒 +// (方块交互与动画.md §4:0–9,六面同图,盒体略外扩防 z-fighting)。 +func (r *Renderer) DrawCrackOverlay(x, y, z float32, stage int) { + if r.crackTex == 0 { + return + } + if stage < 0 { + stage = 0 + } + if stage > 9 { + stage = 9 + } + if r.crackCubes[stage] == 0 { + uv := r.crackUVs[stage] + r.crackCubes[stage] = buildCubeVAO([6][4]float32{uv, uv, uv, uv, uv, uv}, 0.004) + } + + gl.UseProgram(r.prog) + gl.ActiveTexture(gl.TEXTURE0) + gl.BindTexture(gl.TEXTURE_2D, r.crackTex) + vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")) + var vp [16]float32 + multiply4(&vp, &r.proj, &r.view) + gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0]) + offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00")) + gl.Uniform3f(offLoc, x, y, z) + cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) + gl.Uniform1f(cutLoc, 0) + billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00")) + gl.Uniform2f(billLoc, 1, 0) // 非广告牌路径 + uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")) + uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) + gl.Uniform2f(uvMinLoc, 0, 0) + gl.Uniform2f(uvMaxLoc, 1, 1) + + gl.Enable(gl.POLYGON_OFFSET_FILL) + gl.PolygonOffset(-1, -1) + gl.BindVertexArray(r.crackCubes[stage]) + gl.DrawElements(gl.TRIANGLES, 36, gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindVertexArray(0) + gl.Disable(gl.POLYGON_OFFSET_FILL) + gl.Uniform3f(offLoc, 0, 0, 0) // 复位平移(后续绘制依赖 0 偏移) +} + +// SetHandCube 上传/更新手持方块立方体(六面 UV 顺序同 cubeFaces)。 +// pos/scale 为视图空间下的立方体中心与边长(渲染.md §5.2)。 +func (r *Renderer) SetHandCube(uvs [6][4]float32, pos [3]float32, scale float32) { + r.ClearHand() + // 直接按视图空间坐标构建顶点(顶点含 pos 偏移) + var verts []mesh.Vertex + for fi, face := range cubeFaces { + u0, v0, u1, v1 := uvs[fi][0], uvs[fi][1], uvs[fi][2], uvs[fi][3] + for _, c := range face { + var cu, cv float32 + switch fi { + case 0, 1: + cu, cv = c[0], c[2] + case 2, 3: + cu, cv = c[2], 1-c[1] + default: + cu, cv = c[0], 1-c[1] + } + verts = append(verts, mesh.Vertex{ + X: pos[0] + (c[0]-0.5)*scale, + Y: pos[1] + (c[1]-0.5)*scale, + Z: pos[2] + (c[2]-0.5)*scale, + U: u0 + (u1-u0)*cu, V: v0 + (v1-v0)*cv, + Sky: 15, Block: 15, + }) + } + } + r.handVao, r.handVbo, r.handIbo = uploadMesh(verts, cubeIndices()) +} + +// ClearHand 释放手持方块网格(手持物品为空时调用)。 +func (r *Renderer) ClearHand() { + if r.handVao != 0 { + gl.DeleteVertexArrays(1, &r.handVao) + r.handVao = 0 + } + if r.handVbo != 0 { + gl.DeleteBuffers(1, &r.handVbo) + r.handVbo = 0 + } + if r.handIbo != 0 { + gl.DeleteBuffers(1, &r.handIbo) + r.handIbo = 0 + } +} + +// DrawHand 绘制手持方块(顶点已是视图空间,仅乘投影矩阵)。 +func (r *Renderer) DrawHand() { + if r.handVao == 0 { + return + } + gl.UseProgram(r.prog) + gl.ActiveTexture(gl.TEXTURE0) + gl.BindTexture(gl.TEXTURE_2D, r.atlas) + vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")) + gl.UniformMatrix4fv(vpLoc, 1, false, &r.proj[0]) + offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00")) + gl.Uniform3f(offLoc, 0, 0, 0) + cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) + gl.Uniform1f(cutLoc, 0) + billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00")) + gl.Uniform2f(billLoc, 1, 0) + uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")) + uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) + gl.Uniform2f(uvMinLoc, 0, 0) + gl.Uniform2f(uvMaxLoc, 1, 1) + gl.BindVertexArray(r.handVao) + gl.DrawElements(gl.TRIANGLES, 36, gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindVertexArray(0) +} diff --git a/internal/render/renderer.go b/internal/render/renderer.go index 52501dab..1c69d948 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -27,6 +27,19 @@ type Renderer struct { // 区块网格缓存:key → VAO/VBO/索引数 chunks map[uint64]*chunkGL + // 裂纹覆盖层(方块交互与动画.md §4) + crackTex uint32 + crackUVs [10][4]float32 + crackCubes [10]uint32 + + // 第一人称手持方块(渲染.md §5.2) + handVao, handVbo, handIbo uint32 + + // 实体广告牌(渲染.md §5.3) + entityTex uint32 + entityUV map[string][4]float32 + billVao, billVbo, billIbo uint32 + proj [16]float32 view [16]float32 } @@ -217,11 +230,21 @@ layout(location = 0) in vec3 aPos; layout(location = 1) in vec2 aUV; layout(location = 2) in vec2 aLight; // x=天空光 y=方块光(4-bit) uniform mat4 uViewProj; +uniform vec3 uOffset; // 覆盖层/广告牌锚点平移(区块绘制时为 0) +uniform vec3 uCamRight; // 相机右向量(世界系,广告牌展开用) +uniform vec3 uCamUp; // 相机上向量(世界系) +uniform vec2 uBill; // 广告牌缩放:x=右 y=上;y=0 时走普通路径 +uniform vec2 uUVMin; // UV 重映射下限(区块/普通立方体为 0,0) +uniform vec2 uUVMax; // UV 重映射上限(区块/普通立方体为 1,1) out vec2 vUV; out vec2 vLight; void main() { - gl_Position = uViewProj * vec4(aPos, 1.0); - vUV = aUV; + vec3 pos = aPos + uOffset; + if (uBill.y > 0.001) { + pos = uOffset + vec3(aPos.x * uCamRight * uBill.x + aPos.y * uCamUp * uBill.y); + } + gl_Position = uViewProj * vec4(pos, 1.0); + vUV = uUVMin + aUV * (uUVMax - uUVMin); vLight = aLight; } ` diff --git a/internal/ui/hud.go b/internal/ui/hud.go index 202fa045..6e068279 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -19,6 +19,17 @@ type HUD struct { itemTex uint32 itemUV map[string][4]float32 + // 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*) + crossTex uint32 // crosshair.png 15×15 + hotbarTex uint32 // hotbar.png 182×22 + hotbarSel uint32 // hotbar_selection.png 24×24 + heartFull uint32 // heart/full.png 9×9 + heartHalf uint32 // heart/half.png + heartCont uint32 // heart/container.png + foodFull uint32 // food_full.png 9×9 + foodHalf uint32 // food_half.png + haveSprites bool + // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) baseW, baseH float32 scale float32 @@ -38,61 +49,119 @@ func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) { h.itemUV = uv } +// SetSprites 设置原版 HUD 精灵纹理(跨 package 上传后注入)。 +func (h *HUD) SetSprites(cross, hotbar, hotbarSel, heartFull, heartHalf, heartCont, foodFull, foodHalf uint32) { + h.crossTex, h.hotbarTex, h.hotbarSel = cross, hotbar, hotbarSel + h.heartFull, h.heartHalf, h.heartCont = heartFull, heartHalf, heartCont + h.foodFull, h.foodHalf = foodFull, foodHalf + h.haveSprites = cross != 0 +} + // Draw 每帧绘制 HUD(调用方已 Begin)。 func (h *HUD) Draw(r *Renderer) { w, hgt := float32(r.w), float32(r.h) s := h.scale - white := [4]float32{1, 1, 1, 1} - // 准星(UI还原.md §3.4:屏幕正中 15×15 十字) + // 准星(原版 crosshair.png 15×15,UI还原.md §3.4) cx, cy := w/2, hgt/2 - r.DrawRect(cx-1*s, cy-8*s, 2*s, 16*s, white) - r.DrawRect(cx-8*s, cy-1*s, 16*s, 2*s, white) + if h.haveSprites { + h.blit(r, h.crossTex, cx-7.5*s, cy-7.5*s, 15*s, 15*s) + } else { + white := [4]float32{1, 1, 1, 1} + r.DrawRect(cx-1*s, cy-8*s, 2*s, 16*s, white) + r.DrawRect(cx-8*s, cy-1*s, 16*s, 2*s, white) + } - // 热键栏(UI还原.md §3.4:182×22,底部居中) + // 热键栏(原版 hotbar.png 182×22,底部居中,UI还原.md §3.4) barW, barH := 182*s, 22*s bx, by := cx-barW/2, hgt-barH - r.DrawRect(bx, by, barW, barH, [4]float32{0, 0, 0, 0.5}) + if h.haveSprites { + h.blit(r, h.hotbarTex, bx, by, barW, barH) + } else { + r.DrawRect(bx, by, barW, barH, [4]float32{0, 0, 0, 0.5}) + } for i := 0; i < 9; i++ { - sx := bx + float32(i)*20*s + 1*s - sy := by + 3*s if i == h.sess.Inv.Selected { - r.DrawRect(sx-2*s, sy-2*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9}) // 选中高亮框 + if h.haveSprites { + h.blit(r, h.hotbarSel, bx+float32(i)*20*s-2*s, by-1*s, 24*s, 24*s) + } else { + r.DrawRect(bx+float32(i)*20*s+1*s, by+3*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9}) + } } - h.drawItemIcon(r, h.sess.Inv.Slots[i], sx, sy) + h.drawItemIcon(r, h.sess.Inv.Slots[i], bx+float32(i)*20*s+3*s, by+3*s) } - // 生命:10 心(每心 9×9,屏幕左下,UI还原.md §3.4) - hearts := int(20 / 2) + // 生命:10 心(原版 9×9 图标,屏幕左下,UI还原.md §3.4 布局: + // 饥饿行 y=h-39,生命行在其上 y=h-49) hp := h.sess.PlayerHealth() - hx, hy := w/2-91*s, hgt-30*s - for i := 0; i < hearts; i++ { - color := [4]float32{0.2, 0.2, 0.2, 0.6} // 空 - if i < hp/2 { - color = [4]float32{1, 0.1, 0.1, 1} // 满 - } - r.DrawRect(hx+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, color) - } - - // 饥饿:10 鸡腿(右侧偏移) + hx := w/2 - 91*s + hy := hgt - 49*s for i := 0; i < 10; i++ { - r.DrawRect(w/2-91*s+100*s+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1}) + x := hx + float32(i)*8*s + // 底:容器(空槽) + if h.haveSprites { + h.blit(r, h.heartCont, x, hy, 9*s, 9*s) + } + switch { + case hp >= (i+1)*2: // 满心 + if h.haveSprites { + h.blit(r, h.heartFull, x, hy, 9*s, 9*s) + } else { + r.DrawRect(x, hy, 7*s, 7*s, [4]float32{1, 0.1, 0.1, 1}) + } + case hp == i*2+1: // 半心 + if h.haveSprites { + h.blit(r, h.heartHalf, x, hy, 9*s, 9*s) + } else { + r.DrawRect(x, hy, 3.5*s, 7*s, [4]float32{1, 0.1, 0.1, 1}) + } + default: // 空 + if !h.haveSprites { + r.DrawRect(x, hy, 7*s, 7*s, [4]float32{0.2, 0.2, 0.2, 0.6}) + } + } } - // 破坏进度(方块交互与动画.md §4:HUD 中央进度) - if prog := h.sess.BreakProgress(); prog > 0 { - r.DrawRect(cx-40*s, cy+20*s, 80*s, 4*s, [4]float32{0, 0, 0, 0.7}) - r.DrawRect(cx-40*s, cy+20*s, 80*s*float32(prog), 4*s, [4]float32{1, 1, 1, 0.9}) + // 饥饿:10 鸡腿(原版 food_full,生命行下方) + food := 20 // 饥饿值(饥饿系统后置,恒定 20) + fy := hgt - 39*s + for i := 0; i < 10; i++ { + x := hx + float32(i)*8*s + switch { + case food >= (i+1)*2: + if h.haveSprites { + h.blit(r, h.foodFull, x, fy, 9*s, 9*s) + } else { + r.DrawRect(x, fy, 7*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1}) + } + case food == i*2+1: + if h.haveSprites { + h.blit(r, h.foodHalf, x, fy, 9*s, 9*s) + } else { + r.DrawRect(x, fy, 3.5*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1}) + } + } } + // 破坏进度(方块交互与动画.md §4:3D 裂纹由渲染层叠加,HUD 不画进度条) + // 坐标调试(后置移除) - r.DrawText(w/2, hgt-40*s, fmt.Sprintf("XYZ %.1f / %.1f / %.1f", h.sess.Player.Pos[0], h.sess.Player.Pos[1], h.sess.Player.Pos[2]), 1*s, white) + r.DrawText(w/2, hgt-70*s, fmt.Sprintf("XYZ %.1f / %.1f / %.1f", h.sess.Player.Pos[0], h.sess.Player.Pos[1], h.sess.Player.Pos[2]), 1*s, [4]float32{1, 1, 1, 1}) // 联机在线人数(多人同步.md §3) if h.OnlineCount != nil { - r.DrawText(w/2, hgt-56*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, white) + r.DrawText(w/2, hgt-82*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, [4]float32{1, 1, 1, 1}) } } +// blit 以纹理原比例绘制一个四边形(UI 精灵)。 +func (h *HUD) blit(r *Renderer, tex uint32, x, y, w, hgt float32) { + if tex == 0 { + return + } + r.pushQuad(quad{x: x, y: y, w: w, h: hgt, u0: 0, v0: 0, u1: 1, v1: 1, + r: 1, g: 1, b: 1, a: 1, tex: tex}) +} + // drawItemIcon 在槽位绘制物品图标 + 数量。 func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { s := h.scale @@ -104,7 +173,7 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { return } if uv, found := h.itemUV[def.Texture]; found && h.itemTex != 0 { - r.pushQuad(quad{x: x + 2*s, y: y + 2*s, w: 16 * s, h: 16 * s, + r.pushQuad(quad{x: x, y: y, w: 16 * s, h: 16 * s, u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], r: 1, g: 1, b: 1, a: 1, tex: h.itemTex}) } diff --git a/internal/ui/screens.go b/internal/ui/screens.go index b9916108..4deabaa8 100644 --- a/internal/ui/screens.go +++ b/internal/ui/screens.go @@ -27,8 +27,14 @@ type TitleScreen struct { BtnHover uint32 // 悬停高亮(button_highlighted.png) Buttons []Button - Version string // 左下角版本(原版角标) - Copyright string // 右下角版权 + Version string // 左下角版本(原版角标) + Copyright string // 右下角版权 + VersionTex uint32 // 版本/版权 CJK 光栅纹理(中文字形) + VersionW float32 + VersionH float32 + CopyrightTex uint32 + CopyrightW float32 + CopyrightH float32 } // HitTest 命中检测(按钮区域)。 @@ -78,11 +84,17 @@ func (s *TitleScreen) Draw(r *Renderer, mx, my float32) { u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex}) } } - // 版本角标(左下)与版权(右下,原版样式) - if s.Version != "" { + // 版本角标(左下)与版权(右下,原版样式;中文用预光栅纹理) + if s.VersionTex != 0 { + r.pushQuad(quad{x: 4, y: h - s.VersionH - 4, w: s.VersionW, h: s.VersionH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.VersionTex}) + } else if s.Version != "" { r.DrawText(8, h-12, s.Version, 1, [4]float32{1, 1, 1, 1}) } - if s.Copyright != "" { + if s.CopyrightTex != 0 { + r.pushQuad(quad{x: w - s.CopyrightW - 4, y: h - s.CopyrightH - 4, w: s.CopyrightW, h: s.CopyrightH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.CopyrightTex}) + } else if s.Copyright != "" { r.DrawText(w-r.TextWidth(s.Copyright, 1)-8, h-12, s.Copyright, 1, [4]float32{1, 1, 1, 1}) } }