//go:build gl // 年糕历险记 —— 客户端入口(GL 构建)。 // // 装配顺序(架构.md §3.1 启动流程):配置 → 日志 → 内容包/图集 → 注册表 → 世界 // → 游戏会话(玩家/背包/相机)→ 渲染器 → 主循环(tick 20Hz + render)。 package main import ( "context" "flag" "fmt" "image" "image/color" "image/png" "math" "os" "os/signal" "sort" "sync" "sync/atomic" "time" "mc/internal/assets" "mc/internal/atlas" "mc/internal/audio" "mc/internal/block" "mc/internal/camera" "mc/internal/chunk" "mc/internal/config" "mc/internal/crash" "mc/internal/engine" "mc/internal/entity" "mc/internal/font" "mc/internal/game" "mc/internal/inventory" "mc/internal/item" "mc/internal/logx" "mc/internal/mesh" "mc/internal/netclient" "mc/internal/netproto" "mc/internal/paths" "mc/internal/physics" "mc/internal/recipe" "mc/internal/render" "mc/internal/save" "mc/internal/ui" "mc/internal/world" "mc/internal/worldgen" ) var devMode = flag.Bool("dev", devDefault, "开发模式:直接读取 assets/(默认随构建 tag)") // 开发测试参数(dev 测试通过后再打包) var ( 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(验证暂停菜单与光标释放链路)") craftTest = flag.Bool("crafttest", false, "dev 测试:截图前打开工作台并预填 2×2 木板(验证合成界面)") optTest = flag.Bool("optionstest", false, "dev 测试:截图前打开设置界面") invTest = flag.Bool("invtest", false, "dev 测试:截图前打开物品栏(E 键界面)并放入若干物品") miniDump = flag.String("minimapdump", "", "dev 诊断:保存一次小地图图像到该路径") waterTest = flag.Bool("underwatertest", false, "dev 测试:截图前置水下渲染状态(验证蓝色叠加)") digTest = flag.Bool("digtest", false, "dev 测试:截图前在视线前方挖一个 2×2×2 的坑(验证挖洞后洞壁光照)") swingTest = flag.Bool("swingtest", false, "dev 测试:截图帧强制手部挥动(验证挖掘挥动动画)") ) // clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 type clientApp struct { sess *game.Session rend *render.Renderer reg *block.Registry in *inputState uiR *ui.Renderer hud *ui.HUD // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置 / 5 物品栏(UI还原.md §2) screen int titleScreen *ui.TitleScreen pauseScreen *ui.PauseScreen craftScreen *ui.CraftingScreen optionsScr *ui.OptionsScreen invScreen *ui.InventoryScreen optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) held item.Stack // 合成界面手持栈(跟随鼠标) audio *audio.Manager // 设置值(设置界面滑条读写) sensVal float64 volVal float64 // 联机(多人同步.md §2:无连接时为单机) net *netclient.Client log *logx.Logger lang *assets.Lang // 其他玩家位置(多人同步.md §3;渲染插值后置) remoteMu sync.Mutex remotePlayers map[uint32][3]float64 // 世界参数(client.yaml world.seed/name,存档与种子联动) worldSeed int64 worldName string 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 handTint [3]float32 // 手持颜色乘数(空手肤色拳 / 持方块 1,1,1) handItemHas bool // 手持非放置物品(图标四边形,实体图集 UV) handItemUV [4]float32 swing float64 // 挥动动画剩余量(左/右键触发,渲染线程衰减) swingTimer float64 // 按住挖掘时周期性挥动的节拍器 // 水下状态(渲染线程读;tick 更新) underwater bool // 准星指向方块名(tick 光栅化 CJK → 渲染线程上传,UI还原.md §3.4) targetName string targetImg image.Image targetDirty bool // 小地图(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) miniImg image.Image miniDirty bool blockColors []color.RGBA // 方块 ID → 图集中心色(启动时构建) 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 已注入 craftShot bool // dev 测试:-crafttest 已注入 craftClickShot bool // dev 测试:-crafttest 点击输出格已注入 optShot bool // dev 测试:-optionstest 已注入 invShot bool // dev 测试:-invtest 已注入 miniDumped bool // dev 诊断:-minimapdump 已保存 waterShot bool // dev 测试:-underwatertest 已注入 digShot bool // dev 测试:-digtest 已注入 } // worldMeshJob 待上传网格。 type worldMeshJob struct { cx, cz int32 m *mesh.ChunkMesh } // Tick 每 tick(20Hz):输入 → 玩家 → 会话 → 网格构建(渲染.md §3.3 dirty 重建)。 func (a *clientApp) Tick(dt float64) { // 屏幕状态机(UI还原.md §2:主菜单/暂停不处理游戏输入) switch a.screen { case 0: // 主菜单 // dev 测试:-auto 自动进入游戏(跳过点击) if *autoEnter { a.screen = 1 a.requestCursor(true) return } if a.in.clicked { switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) { case "play": a.screen = 1 a.requestCursor(true) case "options": a.screen = 4 a.optionsRet = 0 case "multi": // 联机:连接本机服务器(多人同步.md §2) if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil { a.logf("连接服务器失败: %v", err) } else { a.screen = 1 a.requestCursor(true) a.hud.OnlineCount = func() int { a.remoteMu.Lock() defer a.remoteMu.Unlock() return len(a.remotePlayers) + 1 // 含自己 } a.logf("已连接服务器") } case "quit": a.rend.Window().SetShouldClose(true) } a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 return case 2: // 暂停 if a.in.escPressed { a.screen = 1 a.requestCursor(true) } if a.in.clicked { switch a.pauseScreen.HitTest(a.in.clickX, a.in.clickY) { case "resume": a.screen = 1 a.requestCursor(true) case "options": a.screen = 4 a.optionsRet = 2 case "quit": a.rend.Window().SetShouldClose(true) } a.in.clicked = false } a.in.escPressed = false return case 3: // 工作台(工作台与合成表.md §5:点击槽位交换、ESC 关闭) // dev 测试:crafttest 第二阶段——合成输出格(tick 线程,确定性早于截图) if *craftTest && !a.craftClickShot { a.craftClickShot = true a.handleCraftClick("result") a.logf("crafttest 第二阶段: 合成输出格(2×2 木板 → 工作台)") } if a.in.escPressed { a.closeCrafting() a.screen = 1 a.requestCursor(true) a.in.escPressed = false return } if a.in.clicked { a.handleCraftClick(a.craftScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), 3)) a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 // 界面打开时不转动视角 a.sess.Tick(dt) return case 4: // 设置(灵敏度/音量滑条 + 按键教程,UI与输入.md §6) if a.in.escPressed { a.leaveOptions() a.in.escPressed = false return } if a.in.clicked { switch a.optionsScr.HitTest(a.in.clickX, a.in.clickY) { case "sens-": a.sensVal = clampFloat(a.sensVal-0.0005, 0.0005, 0.01) a.in.SetSensitivity(a.sensVal) case "sens+": a.sensVal = clampFloat(a.sensVal+0.0005, 0.0005, 0.01) a.in.SetSensitivity(a.sensVal) case "vol-": a.volVal = clampFloat(a.volVal-0.1, 0, 1) a.audio.SetVolumes(a.volVal, 1, 1, 1) case "vol+": a.volVal = clampFloat(a.volVal+0.1, 0, 1) a.audio.SetVolumes(a.volVal, 1, 1, 1) case "done": a.leaveOptions() } a.in.clicked = false } return case 5: // 物品栏(E 打开,UI还原.md §3.3:27 主区 + 热键栏,点击交换) if a.in.escPressed || a.in.toggleInv { a.in.escPressed = false a.in.toggleInv = false a.returnHeld() a.screen = 1 a.requestCursor(true) return } if a.in.clicked { a.handleInvClick(a.invScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), 3)) a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 a.sess.Tick(dt) return } // 游戏中 if a.in.escPressed { a.screen = 2 a.requestCursor(false) a.in.escPressed = false return } // E 打开物品栏(UI还原.md §3.3) if a.in.toggleInv { a.in.toggleInv = false a.screen = 5 a.requestCursor(false) return } // 挥动动画触发(打击感:左/右键按下时手部挥动) if a.in.breakDown || a.in.placeDown { a.swing = 1.0 } // 按住挖掘时周期性挥动(原版挖掘节奏,约每 0.3 秒一次) if a.in.breakHeld { a.swingTimer -= dt if a.swingTimer <= 0 { a.swingTimer = 0.3 a.swing = 1.0 } } else { a.swingTimer = 0 } a.in.apply(a.sess) // 水下检测(相机所在方块为水 → 水下渲染效果) camB := a.sess.World.Block(int32(a.sess.Cam.Pos[0]), int32(a.sess.Cam.Pos[1]), int32(a.sess.Cam.Pos[2])) a.underwater = a.sess.Reg.IsFluid(camB) || *waterTest // 右键命中工作台 → 打开合成界面(apply 内 TryOpenCrafting 已置状态) if a.sess.CraftingOpen() && a.screen == 1 { a.screen = 3 a.requestCursor(false) a.logf("打开工作台合成界面") } // 手持方块 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) // 音频监听器跟随玩家(音频.md §3 距离衰减基准) if a.audio != nil { a.audio.SetListener(a.sess.Player.Pos[0], a.sess.Player.Pos[1], a.sess.Player.Pos[2]) } // 准星指向方块名(每 15 tick 更新;CJK 光栅化在 tick 线程,上传在渲染线程) if a.tickCnt%15 == 0 { a.updateTargetName() } // 小地图(每 30 tick 重建地表色图,UI还原.md §3.5) if a.tickCnt%30 == 0 { a.updateMinimap() } // 联机:上报移动(20Hz,多人同步.md §3) if a.net != nil { p := a.sess.Player a.net.SendMove(netproto.PlayerMove{ X: float32(p.Pos[0]), Y: float32(p.Pos[1]), Z: float32(p.Pos[2]), Yaw: float32(a.sess.Cam.Yaw), Pitch: float32(a.sess.Cam.Pitch), OnGround: p.OnGround, }) } // 定时存档:30s(存档与持久化.md §2) a.tickCnt++ if a.tickCnt%(20*30) == 0 && a.net == nil { if err := save.SaveWorld(a.sess.World, paths.Join("worlds"), a.worldName, a.worldSeed, int64(a.tickCnt)); err != nil { // 日志由调用方输出(避免每次 tick 刷屏) _ = err } } // 脏区块网格重建(CPU 侧,预算内;按距玩家距离排序,先建视野中心—— // 随机顺序会致出生区块迟迟无网格,玩家脚下的地面"透明",看到相邻区块地下横截面(黑)。 start := time.Now() px, pz := a.sess.Player.Pos[0], a.sess.Player.Pos[2] type dirtyJob struct { c *chunk.Chunk d float64 } var dirty []dirtyJob for _, c := range a.sess.World.ActiveChunks() { if !c.Dirty.Load() { continue } dx := float64(c.CX)*16 + 8 - px dz := float64(c.CZ)*16 + 8 - pz dirty = append(dirty, dirtyJob{c: c, d: dx*dx + dz*dz}) } sort.Slice(dirty, func(i, j int) bool { return dirty[i].d < dirty[j].d }) for _, dj := range dirty { if time.Since(start) > 6*time.Millisecond { break // 帧预算(区块管理.md §4.3.2) } // 整个构建持一次读锁:邻居查询免逐次加锁(渲染.md §3.3) a.sess.World.WithReadLock(func() { m := a.meshBuilder.Build(dj.c, func(x, y, z int32) block.State { return a.sess.World.BlockUnlocked(x, y, z) }) a.mu.Lock() a.pendingMesh = append(a.pendingMesh, &worldMeshJob{cx: dj.c.CX, cz: dj.c.CZ, m: m}) a.mu.Unlock() }) dj.c.Dirty.Store(false) } } // 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) case "drop": a.sess.Entities.SpawnDrop("cobblestone", 3, float64(ex)+0.5, ey, float64(ez)+0.5) } a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez) } if *craftTest && !a.craftShot { a.craftShot = true // 预填 2×2 木板(槽 0/1/3/4)→ 产物为工作台(工作台与合成表.md §5 合成链路) for _, i := range []int{0, 1, 3, 4} { a.sess.CraftingSwap(i, item.Stack{Name: "oak_planks", Count: 1}) } // 热键栏放 16 木板供界面展示与后续合成 a.sess.Inv.Slots[0] = item.Stack{Name: "oak_planks", Count: 16} a.sess.OpenCrafting() a.screen = 3 a.requestCursor(false) a.logf("crafttest 注入: 打开工作台并预填 2×2 木板") } if *optTest && !a.optShot { a.optShot = true a.screen = 4 a.optionsRet = 1 a.requestCursor(false) a.logf("optionstest 注入: 打开设置界面") } if *invTest && !a.invShot { a.invShot = true // 放入若干展示物品(工具/方块/掉落物) demo := []string{"oak_planks", "crafting_table", "furnace", "torch", "iron_pickaxe", "cobblestone", "beef", "stick", "diamond"} for i, name := range demo { a.sess.Inv.Slots[i+9] = item.Stack{Name: name, Count: uint8(1 + i*3)} } a.sess.Inv.Slots[0] = item.Stack{Name: "oak_planks", Count: 32} a.screen = 5 a.requestCursor(false) a.logf("invtest 注入: 打开物品栏") } if *waterTest && !a.waterShot { a.waterShot = true a.underwater = true a.logf("underwatertest 注入: 水下渲染状态") } } // handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。 func (a *clientApp) handleCraftClick(slot string) { switch { case slot == "": return case slot == "result": // 输出格:空手持取走产物;同名手持合并产物继续合成(工作台与合成表.md §5) res, ok := a.sess.CraftingResult() if !ok { return } if a.held.Empty() { if r, ok := a.sess.CraftingTakeResult(); ok { a.held = r } } else if a.held.Name == res.Name && a.held.Count < a.held.Limit(a.sess.Items) { if r, ok := a.sess.CraftingTakeResult(); ok { merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), a.held, r) a.held = merged if !remain.Empty() { if rem := a.sess.Inv.Add(remain); !rem.Empty() { a.sess.Entities.SpawnDrop(rem.Name, int(rem.Count), a.sess.Player.Pos[0], a.sess.Player.Pos[1]+1, a.sess.Player.Pos[2]) } } } } case len(slot) == 5 && slot[:4] == "grid": i := int(slot[4] - '0') a.held = a.sess.CraftingSwap(i, a.held) case len(slot) == 4 && slot[:3] == "hot": i := int(slot[3] - '0') if i >= 0 && i < inventory.HotbarSize { cur := a.sess.Inv.Slots[i] switch { case a.held.Empty(): a.sess.Inv.Slots[i] = item.Stack{} a.held = cur case cur.Empty(): a.sess.Inv.Slots[i] = a.held a.held = item.Stack{} case cur.Name == a.held.Name: merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held) a.sess.Inv.Slots[i] = merged a.held = remain default: a.sess.Inv.Slots[i] = a.held a.held = cur } } } } // handleInvClick 物品栏界面点击:主区/热键栏槽与手持栈交换/合并。 func (a *clientApp) handleInvClick(slot string) { var idx int switch { case len(slot) == 5 && slot[:4] == "main": fmt.Sscanf(slot[4:], "%d", &idx) idx += 9 // 主物品区 = 槽 9..35 case len(slot) == 4 && slot[:3] == "hot": fmt.Sscanf(slot[3:], "%d", &idx) default: return } if idx < 0 || idx >= inventory.TotalSize { return } cur := a.sess.Inv.Slots[idx] switch { case a.held.Empty(): a.sess.Inv.Slots[idx] = item.Stack{} a.held = cur case cur.Empty(): a.sess.Inv.Slots[idx] = a.held a.held = item.Stack{} case cur.Name == a.held.Name: merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held) a.sess.Inv.Slots[idx] = merged a.held = remain default: a.sess.Inv.Slots[idx] = a.held a.held = cur } } // returnHeld 手持栈放回背包(放不下掉落地面)。 func (a *clientApp) returnHeld() { if a.held.Empty() { return } if rem := a.sess.Inv.Add(a.held); !rem.Empty() { a.sess.Entities.SpawnDrop(rem.Name, int(rem.Count), a.sess.Player.Pos[0], a.sess.Player.Pos[1]+1, a.sess.Player.Pos[2]) } a.held = item.Stack{} } // closeCrafting 关闭合成界面:材料退回背包、手持栈放回背包。 func (a *clientApp) closeCrafting() { a.sess.CloseCrafting() a.returnHeld() } // updateTargetName 计算准星指向方块的显示名(lang 本地化 → CJK 光栅化,UI还原.md §3.4)。 // 只在目标变化时重算;CPU 光栅化在 tick 线程,GL 上传由渲染线程消费 targetDirty。 func (a *clientApp) updateTargetName() { name := "" if h, ok := a.sess.Cast(6); ok { name = a.sess.Reg.Name(a.sess.World.Block(h.X, h.Y, h.Z).ID()) } if name == a.targetName { return } a.targetName = name a.mu.Lock() defer a.mu.Unlock() if name == "" { a.targetImg = nil a.targetDirty = true return } label := name if a.lang != nil { if s := a.lang.Get("block.minecraft." + name); s != "block.minecraft."+name { label = s } } if r := loadCJK(14); r != nil { a.targetImg = r.Draw(label, color.RGBA{255, 255, 255, 255}) } a.targetDirty = true } // leaveOptions 离开设置界面:返回来源屏幕(暂停 → 重新捕获光标)。 func (a *clientApp) leaveOptions() { a.screen = a.optionsRet if a.optionsRet == 1 || a.optionsRet == 2 { a.requestCursor(true) } } // clampFloat 数值夹取。 func clampFloat(v, lo, hi float64) float64 { if v < lo { return lo } if v > hi { return hi } return v } // updateMinimap 重建小地图地表色图(96×96 像素 = 玩家周围 96×96 方块)。 // 每列取最上方非空气/非树叶方块的颜色(图集中心色表);tick 线程 CPU 构建。 func (a *clientApp) updateMinimap() { const n = 96 img := image.NewRGBA(image.Rect(0, 0, n, n)) px, pz := int32(a.sess.Player.Pos[0]), int32(a.sess.Player.Pos[2]) half := int32(n / 2) sky := color.RGBA{135, 207, 235, 255} a.sess.World.WithReadLock(func() { for dz := int32(0); dz < n; dz++ { wz := pz - half + dz for dx := int32(0); dx < n; dx++ { wx := px - half + dx var s block.State for y := int32(200); y >= 0; y-- { bs := a.sess.World.BlockUnlocked(wx, y, wz) if bs == block.Air { continue } if a.reg.Name(bs.ID()) == "oak_leaves" { continue // 树叶透视到地表 } s = bs break } c := sky if s != block.Air && int(s.ID()) < len(a.blockColors) { c = a.blockColors[s.ID()] } img.SetRGBA(int(dx), int(dz), c) } } }) // 玩家标记:烘焙进地图图(中心 3×3 白点 + 黑描边), // 避免 UI 批处理下覆盖层 quad 的颜色异常(见 HUD 小地图注释) drawMarker := func(cx, cy int) { img.SetRGBA(cx-1, cy-1, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx, cy-1, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx+1, cy-1, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx-1, cy, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx, cy, color.RGBA{255, 255, 255, 255}) img.SetRGBA(cx+1, cy, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx-1, cy+1, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx, cy+1, color.RGBA{0, 0, 0, 255}) img.SetRGBA(cx+1, cy+1, color.RGBA{0, 0, 0, 255}) } drawMarker(n/2, n/2) // dev 诊断:-minimapdump 保存一次地图图 if *miniDump != "" && !a.miniDumped { a.miniDumped = true if f, err := os.Create(*miniDump); err == nil { _ = png.Encode(f, img) _ = f.Close() a.logf("minimapdump 已保存: %s", *miniDump) } } a.mu.Lock() a.miniImg = img a.miniDirty = true a.mu.Unlock() } // buildBlockColors 构建方块 ID → 图集中心色表(小地图配色,渲染.md §4)。 func buildBlockColors(reg *block.Registry, atlasImg image.Image, uvRects map[string]image.Rectangle) []color.RGBA { defs := reg.All() out := make([]color.RGBA, len(defs)) for id := range defs { if id == 0 { continue } st := block.NewState(uint16(id), 0) key := reg.Texture(st, "up") rc, ok := uvRects[key] if !ok { continue } cx, cy := rc.Min.X+rc.Dx()/2, rc.Min.Y+rc.Dy()/2 pr, pg, pb, _ := atlasImg.At(cx, cy).RGBA() out[id] = color.RGBA{uint8(pr >> 8), uint8(pg >> 8), uint8(pb >> 8), 255} } return out } // 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 sel := a.sess.Inv.SelectedStack() if sel.Empty() { a.computeFistHand() return } d, ok := a.sess.Items.Get(sel.Name) if !ok { a.computeFistHand() return } if d.Place != "" { a.computeBlockHand(d.Place) return } // 非放置物品(工具/食物等):实体图集中含全部物品贴图,手持图标四边形 if uv, found := a.rend.EntityUV(d.Texture); found { a.mu.Lock() a.handItemHas = true a.handItemUV = uv a.handHas = false a.handDirty = true a.mu.Unlock() return } a.computeFistHand() } // computeBlockHand 手持可放置方块:六面贴图立方体(渲染.md §5.2)。 func (a *clientApp) computeBlockHand(place string) { bid, ok := a.reg.ID(place) if !ok { a.computeFistHand() 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.handItemHas = false a.handTint = [3]float32{1, 1, 1} a.handDirty = true a.mu.Unlock() } // newMeshBuilder 创建网格构建器:面光照取邻格光照(原版光照模型, // 光照.md 顶点光照——方块自身 sky=0,用自身光照会让悬崖/矿洞立面全黑)。 func newMeshBuilder(reg *block.Registry, uvFn func(string) (u0, v0, u1, v1 float32, ok bool), sess *game.Session) *mesh.Builder { b := mesh.NewBuilder(reg, uvFn) b.SetLight(func(x, y, z int32) (uint8, uint8) { return sess.World.SkyLightUnlocked(x, y, z), sess.World.BlockLightUnlocked(x, y, z) }) return b } // computeFistHand 空手/非方块物品:肤色拳头(白羊毛纹理 × 肤色 tint,渲染.md §5.2)。 func (a *clientApp) computeFistHand() { u0, v0, u1, v1, found := a.uv("block/white_wool") if !found { u0, v0, u1, v1 = 0, 0, 1, 1 } uvs := [4]float32{u0, v0, u1, v1} a.mu.Lock() a.handUVs = [6][4]float32{uvs, uvs, uvs, uvs, uvs, uvs} a.handHas = true a.handItemHas = false a.handTint = [3]float32{0.85, 0.65, 0.5} // 肤色 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) if req == 1 { // 重新捕获:光标跳回窗口中心,重置基准防视角瞬跳(UI与输入.md §6) a.in.lastX, a.in.lastY = float64(a.winW)/2, float64(a.winH)/2 a.in.firstMouse = true } a.cursorReq.Store(-1) } // dev 测试:挖坑类测试提前注入(留足网格重建时间);其余截图前注入 if a.frames == 100 && *digTest && !a.digShot { a.digShot = true if h, ok := a.sess.Cast(5); ok { for dy := int32(0); dy < 2; dy++ { for dx := int32(-1); dx <= 1; dx++ { for dz := int32(-1); dz <= 1; dz++ { a.sess.World.SetBlock(h.X+dx, h.Y-dy, h.Z+dz, block.Air) } } } a.logf("digtest 注入: 挖坑 (%d,%d,%d)", h.X, h.Y, h.Z) } } // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置/物品栏/水下/挖坑(必须在 3D 绘制之前,截图帧才能拍到) if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest || *invTest || *waterTest) { a.injectDevVisuals() } // 上传 tick 构建好的网格(GL 调用必须在渲染线程) a.mu.Lock() pending := a.pendingMesh a.pendingMesh = nil a.mu.Unlock() for _, j := range pending { a.rend.UploadChunk(j.cx, j.cz, j.m) mesh.Release(j.m) // 上传完毕归还顶点池(性能与内存.md §2) } // 视图投影 cam := a.sess.Cam aspect := float64(a.winW) / float64(a.winH) a.rend.SetViewProj(cam.View(), cam.Projection(aspect, 0.05, 1024)) // 清屏 + 绘制(视锥剔除简化为全量绘制,后置 LOD) 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, variant := entityVisual(e, a.sess.Items) if key == "" { continue } if uv, ok := a.rend.EntityUV(key); ok { if variant >= 0 { // 动物:方块组合模型,始终面朝相机(原版观感) yaw := float32(math.Atan2(a.sess.Cam.Pos[0]-e.Pos[0], a.sess.Cam.Pos[2]-e.Pos[2])) a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, animalModel(variant), uv, e.HitFlash > 0) } else if e.Kind == entity.KindItemDrop { a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv, false) } else { a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv, e.HitFlash > 0) } } } // 破坏裂纹叠加(方块交互与动画.md §4:progress→destroy_stage 0–9) if a.screen == 1 { 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;上传必须是渲染线程) // 挥动动画:左/右键后手部斜向下摆,逐帧重传立方体 if *swingTest && a.frames >= 198 { a.swing = 1.0 // dev 测试:截图帧强制挥动 } if a.swing > 0 { a.swing -= 0.12 if a.swing < 0 { a.swing = 0 } } a.mu.Lock() handDirty, handHas := a.handDirty, a.handHas handItemHas, handItemUV := a.handItemHas, a.handItemUV a.handDirty = false var handUVs [6][4]float32 var handTint [3]float32 if handHas { handUVs = a.handUVs handTint = a.handTint } a.mu.Unlock() swingPos := [3]float32{0.46, -0.34, -0.66} if a.swing > 0 { swingPos[0] -= float32(a.swing) * 0.15 // 挥动:向屏幕中心下方摆动(保持手部可见) swingPos[1] -= float32(a.swing) * 0.10 handDirty = true // 挥动期间逐帧重传位置 } if handDirty && !handItemHas { if handHas { a.rend.SetHandCube(handUVs, swingPos, 0.16) } else { a.rend.ClearHand() } } if a.screen == 1 { if handItemHas { // 手持非放置物品:物品图标四边形(随挥动位置移动) a.rend.DrawHandItem(handItemUV, swingPos, 0.22) } else { a.rend.DrawHand(handTint) } } // 准星指向方块名上传(tick 光栅化 → 渲染线程上传,UI还原.md §3.4) a.mu.Lock() targetDirty, targetImg := a.targetDirty, a.targetImg a.targetDirty = false a.mu.Unlock() if targetDirty { if targetImg != nil { tex := a.uiR.UploadImage(targetImg) a.hud.SetTarget(tex, float32(targetImg.Bounds().Dx()), float32(targetImg.Bounds().Dy())) } else { a.hud.SetTarget(0, 0, 0) } } // 小地图上传(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) a.mu.Lock() miniDirty, miniImg := a.miniDirty, a.miniImg a.miniDirty = false a.mu.Unlock() if miniDirty && miniImg != nil { a.hud.SetMinimap(a.uiR.UploadImage(miniImg)) } // UI pass(UI与输入.md §2:3D 之后、状态隔离) a.uiR.Begin() a.hud.SetUnderwater(a.underwater) mx, my := float32(a.in.cursorX), float32(a.in.cursorY) switch a.screen { case 0: a.titleScreen.Draw(a.uiR, mx, my) case 1: a.hud.Draw(a.uiR) case 2: a.hud.Draw(a.uiR) // 暂停时世界仍可见(UI还原.md §2) a.pauseScreen.Draw(a.uiR, mx, my) case 3: // 工作台合成界面(UI还原.md §3.4) var grid [9]item.Stack for i := range grid { grid[i] = a.sess.CraftSlot(i) } res, hasRes := a.sess.CraftingResult() a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots[:inventory.HotbarSize], a.held, mx, my, 3) case 4: // 设置界面 a.optionsScr.Draw(a.uiR, mx, my, float32(a.sensVal/0.01), float32(a.volVal)) case 5: // 物品栏界面 a.invScreen.Draw(a.uiR, a.sess.Inv.Slots, a.held, mx, my, 3) } a.uiR.Flush() a.rend.EndFrame() // 截图(dev 测试:游戏内等玩家所在区块 5×5 网格就绪再拍,避免视野空洞导致误判) a.frames++ if *shotPath != "" && a.frames >= 200 { ready := a.screen != 1 // 非游戏画面(标题/暂停)无需等区块 if !ready { ready = true pcx, pcz := int32(a.sess.Player.Pos[0])>>4, int32(a.sess.Player.Pos[2])>>4 for cx := pcx - 2; cx <= pcx+2 && ready; cx++ { for cz := pcz - 2; cz <= pcz+2; cz++ { if !a.rend.HasChunk(cx, cz) { ready = false break } } } } if ready || a.frames > 2400 { // 就绪即拍;48 秒兜底 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 { a.logf("截图已保存: %s", *shotPath) } a.rend.Window().SetShouldClose(true) } } } // ShouldClose 窗口关闭判定。 func (a *clientApp) ShouldClose() bool { return a.rend.ShouldClose() } // logf 客户端日志(联机回调使用)。 func (a *clientApp) logf(format string, args ...any) { if a.log != nil { a.log.Infof(format, args...) } } func main() { crash.SetLogPath(paths.Join("logs", "crash.log")) // 全局崩溃日志(含 goroutine 兜底) defer crash.Guard(paths.Join("logs", "crash.log")) // 主线程崩溃兜底:闪退可诊断 flag.Parse() log, err := logx.New(paths.Join("logs", "client.log"), logx.LevelInfo) if err != nil { panic(err) } cfg, err := config.Load(paths.Join("config", "client.yaml"), config.DefaultClient()) if err != nil { log.Warnf("配置加载失败,使用默认值:%v", err) } log.Infof("%s 客户端启动:%dx%d,语言 %s,视距 %d", cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight, cfg.Client.Language, cfg.Client.RenderDistance) // 1) 内容包与图集(设计.md §1.3:扫描 mods/ + resourcepacks/) am := assets.New() if err := am.Load(paths.Join("assets"), paths.Join("mods"), paths.Join("resourcepacks")); err != nil { log.Warnf("内容包加载失败:%v", err) } log.Infof("已加载 %d 个内容包", len(am.Packs())) keys, err := am.ExpandAtlas("blocks") if err != nil { panic(err) } atlasImg, uvRects, err := atlas.Build(am.ResolveTexture, keys, 16) if err != nil { panic(err) } log.Infof("blocks 图集:%d 纹理,%dx%d", len(uvRects), atlasImg.Bounds().Dx(), atlasImg.Bounds().Dy()) // UV 函数(渲染.md §4:inset 防渗色) uvFn := func(texKey string) (u0, v0, u1, v1 float32, ok bool) { rect, found := uvRects[texKey] if !found { return 0, 0, 1, 1, false } W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy()) const inset = 0.5 u0 = (float32(rect.Min.X) + inset) / W v0 = (float32(rect.Min.Y) + inset) / H u1 = (float32(rect.Max.X) - inset) / W v1 = (float32(rect.Max.Y) - inset) / H return u0, v0, u1, v1, true } // items 图集(HUD 热键栏图标,UI还原.md §7) itemKeys, err := am.ExpandAtlas("items") if err != nil { log.Warnf("items 图集展开失败: %v", err) } itemImg, itemUVs, err := atlas.Build(am.ResolveTexture, itemKeys, 16) if err != nil { log.Warnf("items 图集构建失败: %v", err) } // 字体(UI还原.md §4:素材包位图字体) fontAtlas, err := font.Build(am, "default") if err != nil { log.Warnf("字体构建失败: %v", err) } // 2) 注册表(设计.md §6.1/§6.2 数据驱动) reg, err := block.Load(paths.Join("assets", "config", "blocks.json")) if err != nil { panic(err) } items, err := item.Load(paths.Join("assets", "config", "items.json")) if err != nil { panic(err) } recipes, err := recipe.Load(paths.Join("assets", "config", "recipes.json")) if err != nil { panic(err) } // 植被染色(渲染.md §4.1):原版材质包草顶/树叶为灰度图, // 绿色来自染色表——图集构建后对带 tint 标记的纹理乘色,否则草地/树叶全灰。 if tinted := reg.TintTextureKeys(); len(tinted) > 0 { atlasImg = applyAtlasTint(atlasImg, uvRects, am, tinted) log.Infof("图集植被染色:%v", tinted) } // 小地图配色表(方块 ID → 图集中心色,UI还原.md §3.5) blockColors := buildBlockColors(reg, atlasImg, uvRects) // 裂纹图集(方块交互与动画.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", } // 掉落物图标:全部物品纹理并入实体图集(16px 最近邻放大到 64px tile) seen := make(map[string]struct{}, len(entityKeys)+64) for _, k := range entityKeys { seen[k] = struct{}{} } for _, name := range items.Names() { if d, ok := items.Get(name); ok { if _, dup := seen[d.Texture]; !dup { seen[d.Texture] = struct{}{} entityKeys = append(entityKeys, d.Texture) } } } entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64) if err != nil { log.Warnf("实体图集构建失败:%v", err) } // 3) 世界与生成器(区块管理.md §4 异步流水线;种子来自 client.yaml world.seed) worldSeed := cfg.Client.World.Seed worldName := cfg.Client.World.Name if worldName == "" { worldName = "NewWorld" } log.Infof("世界种子 %d,世界名 %s", worldSeed, worldName) gen, err := worldgen.New(reg, worldSeed, 0.005, 0.01, 0.1) if err != nil { panic(err) } w, err := world.New(reg, gen, cfg.Client.RenderDistance) if err != nil { panic(err) } defer w.Close() // 单机存档(存档与持久化.md §3:diff-only 载入,无存档则新建) if _, err := os.Stat(paths.Join("worlds", worldName, "level.dat")); err == nil { if _, err := save.LoadWorld(w, paths.Join("worlds"), worldName); err != nil { log.Warnf("存档载入失败: %v", err) } else { log.Infof("已载入世界 %s", worldName) } } // 4) 游戏会话(出生逻辑:先等出生区块加载,再定位地表出生—— // 否则玩家从高空坠落会穿过未加载区块掉进地下,游戏内一片漆黑) var surf int32 = 120 for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); { w.Update(8.5, 100, 8.5, 8*time.Millisecond) if w.Block(8, 80, 8) != block.Air { break } time.Sleep(2 * time.Millisecond) } for y := int32(255); y > 0; y-- { if w.Block(8, y, 8) != block.Air { surf = y break } } 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) // 音频(音频.md §2:事件驱动 + 3D 衰减;无音频设备自动降级静默) audioMgr := audio.New() defer audioMgr.Close() audioMgr.SetVolumes(cfg.Client.Audio.Volume, 1, 1, 1) sess.Sound = func(key string, x, y, z float64) { audioMgr.Play(key, x, y, z) } // 5) 渲染器(GL 线程亲和在其内部锁定) rend, err := render.New(cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight) if err != nil { panic(err) } defer rend.Shutdown() rend.SetAtlas(atlasImg) // 窗口图标:素材包 pack.png(原版 logo,构建与发布.md §8) for _, p := range am.Packs() { iconPath := paths.Join(p.Root, "pack.png") if f, err := os.Open(iconPath); err == nil { if img, err := png.Decode(f); err == nil { rend.SetWindowIcon(img) } f.Close() break } } // 主菜单状态:鼠标可见(仅游戏内捕获,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 { log.Warnf("光影包加载失败(使用默认光影):%v", err) } else if sp != nil { if err := rend.ApplyShaderPack(sp); err != nil { log.Warnf("光影包应用失败(使用默认光影):%v", err) } else { log.Infof("已加载光影包 %q", sp.ID) } } // UI 层(UI还原.md §3.1:427×240 基准 × 3 缩放 = 1280×720) guiScale := float32(3) uiR, err := ui.New(cfg.Client.WindowWidth, cfg.Client.WindowHeight, guiScale) if err != nil { panic(err) } if fontAtlas != nil { uiR.SetFont(fontAtlas) } hud := ui.NewHUD(sess, items, 427, 240, guiScale) var itemTex uint32 itemUV := make(map[string][4]float32, len(itemUVs)) if itemImg != nil { itemTex = uiR.UploadImage(itemImg) for k, rect := range itemUVs { W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy()) itemUV[k] = [4]float32{ float32(rect.Min.X) / W, float32(rect.Min.Y) / H, float32(rect.Max.X) / W, float32(rect.Max.Y) / H, } } hud.SetItemAtlas(itemTex, itemUV) } // 方块物品图标回退 blocks 图集(原版 block/* 图标不在 items 图集) blockUITex := uiR.UploadImage(atlasImg) blockUV := make(map[string][4]float32, len(uvRects)) for k, rect := range uvRects { W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy()) blockUV[k] = [4]float32{ float32(rect.Min.X) / W, float32(rect.Min.Y) / H, float32(rect.Max.X) / W, float32(rect.Max.Y) / H, } } hud.SetBlockAtlas(blockUITex, blockUV) // 原版 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) if err != nil { log.Warnf("语言加载失败: %v", err) } titleScreen, pauseScreen := buildScreens(uiR, am, lang) // 工作台合成界面(UI还原.md §3.4:gui/container/crafting_table.png + 3×3 网格) craftScreen := ui.NewCraftingScreen(loadTex("gui/container/crafting_table"), items) craftScreen.SetItemAtlas(itemTex, itemUV) craftScreen.SetBlockAtlas(blockUITex, blockUV) // 设置界面(灵敏度/音量/种子/按键教程,UI与输入.md §6) optionsScr := buildOptionsScreen(uiR, lang, loadTex("gui/sprites/widget/button"), loadTex("gui/sprites/widget/button_highlighted"), worldSeed) // 物品栏界面(UI还原.md §3.3:E 键打开) invScreen := ui.NewInventoryScreen(loadTex("gui/container/inventory"), items) invScreen.SetItemAtlas(itemTex, itemUV) invScreen.SetBlockAtlas(blockUITex, blockUV) // 6) 输入(鼠标捕获 + 键鼠回调) in := newInputState(rend.Window()) in.SetSensitivity(cfg.Client.MouseSensitivity) // 光标初始位置 = 窗口中心(捕获模式下 GLFW 会把光标重置到中心; // 合成界面手持栈跟随光标,初始化为中心避免出现在左上角) in.cursorX, in.cursorY = float64(cfg.Client.WindowWidth)/2, float64(cfg.Client.WindowHeight)/2 app := &clientApp{ sess: sess, rend: rend, reg: reg, in: in, uiR: uiR, hud: hud, titleScreen: titleScreen, pauseScreen: pauseScreen, craftScreen: craftScreen, optionsScr: optionsScr, invScreen: invScreen, audio: audioMgr, worldSeed: worldSeed, worldName: worldName, sensVal: cfg.Client.MouseSensitivity, volVal: cfg.Client.Audio.Volume, blockColors: blockColors, log: log, lang: lang, remotePlayers: make(map[uint32][3]float64), meshBuilder: newMeshBuilder(reg, uvFn, sess), uv: uvFn, handSlot: -1, winW: cfg.Client.WindowWidth, winH: cfg.Client.WindowHeight, lastTime: time.Now(), } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() log.Infof("进入游戏主循环") engine.Run(ctx, app, app) log.Infof("客户端已退出") }