//go:build gl // 年糕历险记 —— 客户端入口(GL 构建)。 // // 装配顺序:配置/日志 → 尽早 GLFW + Loading(screen=13)→ 图集/世界 → 主循环。 package main import ( "context" "errors" "flag" "fmt" "image" "image/color" "image/png" "math" "os" "os/signal" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "mc/internal/assets" "mc/internal/audio" "mc/internal/block" "mc/internal/blockentity" "mc/internal/camera" "mc/internal/chunk" "mc/internal/config" "mc/internal/crash" "mc/internal/engine" "mc/internal/entity" "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 物品栏 // / 6 选择世界 / 7 创建世界 / 8 熔炉 / 9 箱子 / 10 创造物品栏 / 11 死亡 / 12 交易 / 13 Loading screen int titleScreen *ui.TitleScreen pauseScreen *ui.PauseScreen loadingScreen *ui.LoadingScreen deathScreen *ui.DeathScreen tradeScreen *ui.TradeScreen craftScreen *ui.CraftingScreen optionsScr *ui.OptionsScreen invScreen *ui.InventoryScreen furnaceScreen *ui.FurnaceScreen chestScreen *ui.ChestScreen creativeScreen *ui.CreativeScreen selectWorld *ui.SelectWorldScreen createWorld *ui.CreateWorldScreen optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) held item.Stack // 合成界面手持栈(跟随鼠标) gameMode string // survival / creative drag slotDrag lastClickSlot string lastClickAt time.Time uiText string // book / creative lastHotSel int flies []pickupFly armWarned bool audio *audio.Manager am *assets.Manager audioLoaded bool persistOnExit bool saveHintUntil time.Time saveHintTex uint32 saveHintW float32 saveHintH float32 // 设置值(设置界面滑条读写) 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 viewDist int fov float64 items *item.Registry recipes *recipe.Registry clearChunks atomic.Bool uiListDirty atomic.Bool uiCreateDirty atomic.Bool retireWorld *world.World lastSlotAt time.Time lastSlot int 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 captionSig string captionImgs []image.Image captionDirty bool // 槽位悬停 tooltip(tick 光栅化 CJK → 渲染线程上传) tipName string tipImg image.Image tipDirty bool // 小地图(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) miniImg image.Image miniDirty bool blockColors []color.RGBA // 方块 ID → 图集中心色(启动时构建) savePool *save.SavePool pendingRemove [][2]int32 winW, winH int guiScale float32 menuTime float64 spawnY float64 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: // 主菜单 a.in.textMode = false a.ensureAudio() // 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 "select": a.uiListDirty.Store(true) a.screen = 6 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": if a.persistOnExit { a.persistWorldWait() } a.rend.Window().SetShouldClose(true) } a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 a.tickMenuBackdrop(dt) 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 "save": a.persistWorldWait() a.saveHintUntil = time.Now().Add(2 * time.Second) case "saveQuit": a.persistWorldWait() a.screen = 0 a.requestCursor(false) case "quitWorld": a.discardWorldToTitle() } 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", false, false) a.logf("crafttest 第二阶段: 合成输出格(2×2 木板 → 工作台)") } a.in.textMode = a.uiText == "book" a.applySearchTyping(&a.craftScreen.BookQuery) if a.craftScreen.BookOpen { if d := a.consumeUIScroll(); d != 0 { a.craftScreen.BookScroll += d if a.craftScreen.BookScroll < 0 { a.craftScreen.BookScroll = 0 } } } else { a.consumeUIScroll() } if a.tickSlotUI(dt, &a.craftScreen.OpenT, a.handleCraftClick) { a.closeCrafting() a.screen = 1 a.requestCursor(true) return } a.updateSlotTooltip(a.craftHoverName()) 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 主区 + 热键栏,点击交换) a.consumeUIScroll() if a.tickSlotUI(dt, &a.invScreen.OpenT, a.handleInvClick) { a.closeInventory() a.screen = 1 a.requestCursor(true) return } a.updateSlotTooltip(a.invHoverName()) return case 8: // 熔炉 a.consumeUIScroll() if a.tickSlotUI(dt, &a.furnaceScreen.OpenT, a.handleFurnaceClick) { a.closeContainerUI() a.screen = 1 a.requestCursor(true) return } a.updateSlotTooltip(a.furnaceHoverName()) return case 9: // 箱子 a.consumeUIScroll() if a.tickSlotUI(dt, &a.chestScreen.OpenT, a.handleChestClick) { a.closeContainerUI() a.screen = 1 a.requestCursor(true) return } a.updateSlotTooltip(a.chestHoverName()) return case 10: // 创造物品栏 a.in.textMode = a.uiText == "creative" a.applySearchTyping(&a.creativeScreen.Query) if d := a.consumeUIScroll(); d != 0 { a.creativeScreen.Scroll += d if a.creativeScreen.Scroll < 0 { a.creativeScreen.Scroll = 0 } } if a.tickSlotUI(dt, &a.creativeScreen.OpenT, a.handleCreativeClick) { a.returnHeld() a.endDrag() a.screen = 1 a.requestCursor(true) return } a.updateSlotTooltip(a.creativeHoverName()) return case 6: // 选择世界(UI还原.md §3.6) a.in.textMode = false if a.in.escPressed { a.screen = 0 a.selectWorld.Confirm = false a.in.escPressed = false a.tickMenuBackdrop(dt) return } if a.in.clicked { a.handleSelectWorld(a.selectWorld.HitTest(a.in.clickX, a.in.clickY)) a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 a.tickMenuBackdrop(dt) return case 7: // 创建世界 a.in.textMode = true if a.in.escPressed { a.in.textMode = false a.screen = 6 a.in.escPressed = false a.tickMenuBackdrop(dt) return } a.applyCreateTyping() if a.in.clicked { switch a.createWorld.HitTest(a.in.clickX, a.in.clickY) { case "focus-name": a.createWorld.Focus = 0 a.uiCreateDirty.Store(true) case "focus-seed": a.createWorld.Focus = 1 a.uiCreateDirty.Store(true) case "mode": if a.createWorld.GameMode == "creative" { a.createWorld.GameMode = "survival" } else { a.createWorld.GameMode = "creative" } case "create": a.in.textMode = false a.finishCreateWorld() case "cancel": a.in.textMode = false a.screen = 6 } a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 a.tickMenuBackdrop(dt) return case 11: // 死亡(实体系统.md §6:世界继续 Tick) a.deathScreen.Cause = a.sess.DeathReason() if a.in.clicked { switch a.deathScreen.HitTest(a.in.clickX, a.in.clickY) { case "respawn": a.sess.Respawn() a.screen = 1 a.requestCursor(true) case "title": a.persistWorldWait() a.screen = 0 a.requestCursor(false) } a.in.clicked = false } a.in.escPressed = false a.sess.Tick(dt) a.flushUnloaded() return case 12: // 村民交易 if a.tradeScreen.OpenT < 1 { a.tradeScreen.OpenT += float32(dt) * 8 if a.tradeScreen.OpenT > 1 { a.tradeScreen.OpenT = 1 } } if a.in.escPressed { a.sess.CloseTrade() a.screen = 1 a.requestCursor(true) a.in.escPressed = false return } if a.in.clicked { slot := a.tradeScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), a.guiScale) if strings.HasPrefix(slot, "offer") { i, _ := strconv.Atoi(slot[5:]) a.sess.ExecuteTrade(i) } a.in.clicked = false } return } // 游戏中 if a.sess.IsDead() { a.deathScreen.Cause = a.sess.DeathReason() a.screen = 11 a.requestCursor(false) a.sess.Tick(dt) a.flushUnloaded() return } if a.in.escPressed { a.screen = 2 a.requestCursor(false) a.in.escPressed = false return } // E 打开物品栏(创造模式为创造栏) if a.in.toggleInv { a.in.toggleInv = false if a.sess.Creative { a.creativeScreen.OpenT = 0 a.creativeScreen.Query = "" a.uiText = "" a.screen = 10 } else { a.invScreen.OpenT = 0 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) // 水下检测(相机所在方块为水 → 水下渲染效果) a.underwater = a.sess.Player.EyeInWater || *waterTest // 右键命中工作台 / 熔炉 / 箱子 if a.screen == 1 && a.sess.CraftingOpen() { a.sess.DiscoverCraftable() a.craftScreen.OpenT = 0 a.screen = 3 a.requestCursor(false) a.logf("打开工作台合成界面") } if a.screen == 1 && a.sess.ContainerOpen() == 1 { a.furnaceScreen.OpenT = 0 a.screen = 8 a.requestCursor(false) a.logf("打开熔炉") } if a.screen == 1 && a.sess.ContainerOpen() == 2 { a.chestScreen.OpenT = 0 a.screen = 9 a.requestCursor(false) a.logf("打开箱子") } if a.screen == 1 && a.sess.TradeOpen() { a.tradeScreen.OpenT = 0 a.screen = 12 a.requestCursor(false) a.logf("打开村民交易") } if a.sess.Inv.Selected != a.lastHotSel { a.lastHotSel = a.sess.Inv.Selected a.hud.Punch(a.lastHotSel) } a.hud.TickAnims(float32(dt)) a.tickFlies(dt) // 手持方块 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) a.flushUnloaded() // 音频监听器跟随玩家(音频.md §3 距离衰减基准) if a.audio != nil { a.audio.SetListener(a.sess.Player.Pos[0], a.sess.Player.Pos[1], a.sess.Player.Pos[2], a.sess.Cam.Yaw) } // 准星指向方块名(每 15 tick 更新;CJK 光栅化在 tick 线程,上传在渲染线程) if a.tickCnt%15 == 0 { a.updateTargetName() } a.updateCaptions() // 小地图(每 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 { a.persistWorld() } a.rebuildDirtyMeshes(a.sess.Player.Pos[0], a.sess.Player.Pos[2]) } func (a *clientApp) tickMenuBackdrop(dt float64) { a.menuTime += dt py := a.spawnY if py < 8 { py = 80 } a.sess.World.Update(8.5, py, 8.5, 8*time.Millisecond) a.flushUnloaded() a.rebuildDirtyMeshes(8.5, 8.5) } func (a *clientApp) rebuildDirtyMeshes(px, pz float64) { start := time.Now() 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 } 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) } } func (a *clientApp) persistWorldWait() { a.persistWorld() if a.savePool != nil { a.savePool.Wait() } } func (a *clientApp) persistWorld() { if a.sess == nil || a.net != nil || !a.persistOnExit { return } pd := a.snapshotPlayer() if pd == nil { if old, err := save.ReadLevel(paths.Join("worlds"), a.worldName); err == nil && playerPosOK(old.Player) { pd = old.Player } } mode := a.gameMode if mode == "" { mode = "survival" } lv := save.LevelData{ Version: 1, Seed: a.worldSeed, Time: int64(a.tickCnt), GameMode: mode, Spawn: a.sess.Spawn, Player: pd, BlockEntities: a.sess.ExportBlockEntities(), } regionDir := paths.Join("worlds", a.worldName, "region") if err := save.EnqueueDirty(a.sess.World, a.savePool, regionDir); err != nil { a.logf("存档失败: %v", err) return } if lv.Version == 0 { lv.Version = 1 } if err := save.WriteLevel(paths.Join("worlds"), a.worldName, lv); err != nil { a.logf("存档失败: %v", err) return } a.logf("已保存世界 %s", a.worldName) } func (a *clientApp) flushUnloaded() { if a.sess == nil { return } regionDir := paths.Join("worlds", a.worldName, "region") for _, c := range a.sess.World.DrainUnloaded() { if c.SaveDirty.Load() && a.net == nil && a.savePool != nil { a.savePool.Enqueue(save.ChunkJob{RegionDir: regionDir, CX: c.CX, CZ: c.CZ, Data: save.EncodeChunk(c)}) c.SaveDirty.Store(false) } a.mu.Lock() a.pendingRemove = append(a.pendingRemove, [2]int32{c.CX, c.CZ}) a.mu.Unlock() } } func (a *clientApp) rasterize(s string, size float64, c color.RGBA) (uint32, float32, float32) { cjk := loadCJK(size) if cjk == nil || s == "" || a.uiR == nil { return 0, 0, 0 } img := cjk.Draw(s, c) return a.uiR.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) } func (a *clientApp) drawSaveHint() { if a.uiR == nil || time.Now().After(a.saveHintUntil) { return } if a.saveHintTex == 0 { label := "已保存" if a.lang != nil { if s := a.lang.Get("menu.saved"); s != "menu.saved" { label = s } } a.saveHintTex, a.saveHintW, a.saveHintH = a.rasterize(label, 16, color.RGBA{180, 255, 180, 255}) } if a.saveHintTex == 0 { return } w, h := float32(a.winW), float32(a.winH) x := w/2 - a.saveHintW/2 y := h * 0.72 a.uiR.DrawRect(x-8, y-4, a.saveHintW+16, a.saveHintH+8, [4]float32{0, 0, 0, 0.55}) a.uiR.DrawImage(a.saveHintTex, x, y, a.saveHintW, a.saveHintH) } func (a *clientApp) refreshWorldList() { if a.selectWorld == nil { return } list, err := save.ListWorlds(paths.Join("worlds")) if err != nil { a.logf("列出存档失败: %v", err) list = nil } white := color.RGBA{255, 255, 255, 255} gray := color.RGBA{200, 200, 200, 255} rows := make([]ui.WorldRow, 0, len(list)) for _, w := range list { mode := w.GameMode if a.lang != nil { if s := a.lang.Get("world.gameMode." + mode); s != "world.gameMode."+mode { mode = s } } label, lw, lh := a.rasterize(w.Name, 16, white) meta, mw, mh := a.rasterize(fmt.Sprintf("种子 %d %s", w.Seed, mode), 12, gray) rows = append(rows, ui.WorldRow{ Name: w.Name, Folder: w.Name, Seed: w.Seed, LabelTex: label, LabelW: lw, LabelH: lh, MetaTex: meta, MetaW: mw, MetaH: mh, }) } a.selectWorld.Rows = rows a.selectWorld.Confirm = false if a.selectWorld.Selected >= len(rows) { a.selectWorld.Selected = len(rows) - 1 } if a.selectWorld.Selected < 0 && len(rows) > 0 { a.selectWorld.Selected = 0 } } func (a *clientApp) handleSelectWorld(act string) { if strings.HasPrefix(act, "slot:") { i, _ := strconv.Atoi(strings.TrimPrefix(act, "slot:")) now := time.Now() if i == a.selectWorld.Selected && i == a.lastSlot && now.Sub(a.lastSlotAt) < 400*time.Millisecond { a.playSelectedWorld() return } a.selectWorld.Selected = i a.lastSlot, a.lastSlotAt = i, now return } switch act { case "play": a.playSelectedWorld() case "create": a.openCreateWorld() case "delete": if a.selectWorld.Selected >= 0 && a.selectWorld.Selected < len(a.selectWorld.Rows) { a.selectWorld.Confirm = true } case "cancel": a.screen = 0 case "confirm-yes": a.deleteSelectedWorld() case "confirm-no": a.selectWorld.Confirm = false } } func (a *clientApp) playSelectedWorld() { if a.selectWorld.Selected < 0 || a.selectWorld.Selected >= len(a.selectWorld.Rows) { return } name := a.selectWorld.Rows[a.selectWorld.Selected].Folder if name == a.worldName { a.persistOnExit = true a.screen = 1 a.requestCursor(true) return } a.persistWorld() lv, err := save.ReadLevel(paths.Join("worlds"), name) if err != nil { a.logf("读存档失败: %v", err) return } a.switchWorld(name, lv.Seed, true, lv.Player) } func (a *clientApp) deleteSelectedWorld() { if a.selectWorld.Selected < 0 || a.selectWorld.Selected >= len(a.selectWorld.Rows) { return } name := a.selectWorld.Rows[a.selectWorld.Selected].Folder if err := save.DeleteWorld(paths.Join("worlds"), name); err != nil { a.logf("删除存档失败: %v", err) a.selectWorld.Confirm = false return } a.logf("已删除世界 %s", name) if name == a.worldName { fresh := save.UniqueWorldName(paths.Join("worlds"), "NewWorld") a.switchWorld(fresh, time.Now().UnixNano(), false, nil) a.screen = 6 a.requestCursor(false) } a.uiListDirty.Store(true) } func (a *clientApp) openCreateWorld() { name := save.UniqueWorldName(paths.Join("worlds"), "新的世界") a.createWorld.Name = name a.createWorld.Seed = "" a.createWorld.Focus = 0 if a.createWorld.GameMode == "" { a.createWorld.GameMode = "survival" } a.uiCreateDirty.Store(true) a.screen = 7 } func (a *clientApp) refreshCreateFields() { white := color.RGBA{255, 255, 255, 255} shown := a.createWorld.Name if a.createWorld.Focus == 0 { shown += "|" } a.createWorld.NameValTex, a.createWorld.NameValW, a.createWorld.NameValH = a.rasterize(shown, 16, white) ss := a.createWorld.Seed if ss == "" { ss = "(空则随机)" } if a.createWorld.Focus == 1 { ss += "|" } a.createWorld.SeedValTex, a.createWorld.SeedValW, a.createWorld.SeedValH = a.rasterize(ss, 16, white) } func (a *clientApp) applyCreateTyping() { changed := false if a.in.backspace { a.in.backspace = false if a.createWorld.Focus == 0 && len(a.createWorld.Name) > 0 { r := []rune(a.createWorld.Name) a.createWorld.Name = string(r[:len(r)-1]) changed = true } else if a.createWorld.Focus == 1 && len(a.createWorld.Seed) > 0 { r := []rune(a.createWorld.Seed) a.createWorld.Seed = string(r[:len(r)-1]) changed = true } } if len(a.in.chars) > 0 { for _, ch := range a.in.chars { if ch < 32 { continue } if a.createWorld.Focus == 0 && len([]rune(a.createWorld.Name)) < 32 { a.createWorld.Name += string(ch) changed = true } else if a.createWorld.Focus == 1 && len([]rune(a.createWorld.Seed)) < 20 { a.createWorld.Seed += string(ch) changed = true } } a.in.chars = a.in.chars[:0] } if changed { a.uiCreateDirty.Store(true) } } func (a *clientApp) finishCreateWorld() { dir := paths.Join("worlds") name, err := save.SanitizeWorldName(a.createWorld.Name) if err != nil { name = save.UniqueWorldName(dir, "新的世界") } else { name = save.UniqueWorldName(dir, name) } seed := save.ParseWorldSeed(a.createWorld.Seed) mode := a.createWorld.GameMode if mode == "" { mode = "survival" } a.persistWorld() if err := save.WriteLevel(dir, name, save.LevelData{Version: 1, Seed: seed, GameMode: mode}); err != nil { a.logf("创建存档失败: %v", err) return } a.gameMode = mode a.switchWorld(name, seed, false, nil) if a.sess != nil { a.sess.Creative = mode == "creative" } } func (a *clientApp) switchWorld(name string, seed int64, loadExisting bool, player *save.PlayerData) { a.replaceWorld(name, seed, loadExisting, player) } func (a *clientApp) replaceWorld(name string, seed int64, loadExisting bool, player *save.PlayerData) { if a.reg == nil || a.items == nil || a.recipes == nil { return } vd := a.viewDist if vd < 2 { vd = 8 } gen, err := worldgen.New(a.reg, seed, 0.005, 0.01, 0.1) if err != nil { a.logf("生成器失败: %v", err) return } w, err := world.New(a.reg, gen, vd) if err != nil { a.logf("创建世界失败: %v", err) return } if loadExisting { if _, err := save.LoadWorld(w, paths.Join("worlds"), name); err != nil { a.logf("载入世界失败: %v", err) } } surf := waitSpawnSurface(w) p := physics.NewPlayer(8.5, float64(surf+2), 8.5) fov := a.fov if fov == 0 { fov = 70 } cam := camera.New(p.Eye(), 0, -0.35, fov) inv := inventory.New(a.items) sess := game.NewSession(w, a.reg, a.items, a.recipes, p, inv, cam) old := a.sess.World a.sess = sess a.bindSessionHooks() if a.hud != nil { a.hud.SetSession(sess) } a.meshBuilder = newMeshBuilder(a.reg, a.uv, sess) a.mu.Lock() a.pendingMesh = nil a.retireWorld = old a.mu.Unlock() a.clearChunks.Store(true) a.worldName, a.worldSeed = name, seed a.spawnY = float64(surf) if loadExisting { if lv, err := save.ReadLevel(paths.Join("worlds"), name); err == nil { a.sess.ImportBlockEntities(lv.BlockEntities) a.gameMode = lv.GameMode a.sess.Creative = lv.GameMode == "creative" if lv.Spawn[1] != 0 { a.sess.Spawn = lv.Spawn } if player == nil { player = lv.Player } } } else if a.gameMode == "" { a.gameMode = "survival" } else { a.sess.Creative = a.gameMode == "creative" } if player != nil { a.restorePlayer(player) } a.lastHotSel = a.sess.Inv.Selected a.persistOnExit = true a.ensureAudio() a.screen = 1 a.requestCursor(true) a.logf("进入世界 %s 种子 %d", name, seed) } func (a *clientApp) discardWorldToTitle() { if a.sess == nil { a.screen = 0 a.requestCursor(false) return } name, seed := a.worldName, a.worldSeed var player *save.PlayerData if lv, err := save.ReadLevel(paths.Join("worlds"), name); err == nil { player = lv.Player seed = lv.Seed } a.persistOnExit = false a.replaceWorld(name, seed, true, player) a.persistOnExit = false a.screen = 0 a.requestCursor(false) a.logf("已丢弃未保存改动,返回标题") } func (a *clientApp) ensureAudio() { if a.audioLoaded || a.audio == nil || a.am == nil { return } a.audioLoaded = true if jsPath, ok := a.am.ResolveFile("", "sounds", ".json"); ok { if raw, err := os.ReadFile(jsPath); err == nil { a.audio.LoadSounds(raw, func(name string) (string, bool) { return a.am.ResolveFile("sounds", name, ".ogg") }) a.logf("已加载音效事件 %d 个", a.audio.EventCount()) } } } func waitSpawnSurface(w *world.World) int32 { var surf int32 = 120 deadline := time.Now().Add(3 * time.Second) for 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 { return y } } return surf } func playerPosOK(pd *save.PlayerData) bool { return pd != nil && pd.Y >= 1 } func (a *clientApp) snapshotPlayer() *save.PlayerData { p := a.sess.Player if p.Pos[1] < 1 { return nil } pd := &save.PlayerData{ X: p.Pos[0], Y: p.Pos[1], Z: p.Pos[2], Yaw: a.sess.Cam.Yaw, Pitch: a.sess.Cam.Pitch, Health: float64(a.sess.Health), Hunger: a.sess.Hunger, Saturation: a.sess.Saturation, Selected: a.sess.Inv.Selected, } pd.Slots = make([]save.InvSlot, len(a.sess.Inv.Slots)) for i, st := range a.sess.Inv.Slots { pd.Slots[i] = save.InvSlot{Name: st.Name, Count: st.Count, Durability: st.Durability} } pd.UnlockedRecipes = a.sess.ExportUnlocked() return pd } func (a *clientApp) restorePlayer(pd *save.PlayerData) { if pd == nil { return } if playerPosOK(pd) { a.sess.Player.Pos = [3]float64{pd.X, pd.Y, pd.Z} a.sess.Cam.Pos = a.sess.Player.Eye() a.sess.Cam.Yaw, a.sess.Cam.Pitch = pd.Yaw, pd.Pitch } else { a.logf("忽略无效玩家坐标 (%.1f, %.1f, %.1f)", pd.X, pd.Y, pd.Z) } if pd.Health > 0 { a.sess.Health = int(pd.Health) } if pd.Hunger > 0 { a.sess.Hunger = pd.Hunger } a.sess.Saturation = pd.Saturation if pd.Selected >= 0 && pd.Selected < 9 { a.sess.Inv.Selected = pd.Selected } for i := 0; i < len(pd.Slots) && i < len(a.sess.Inv.Slots); i++ { s := pd.Slots[i] a.sess.Inv.Slots[i] = item.Stack{Name: s.Name, Count: s.Count, Durability: s.Durability} } a.sess.ImportUnlocked(pd.UnlockedRecipes) } func (a *clientApp) menuOrbitCam() *camera.Camera { sy := a.spawnY if sy < 8 { sy = 64 } ang := a.menuTime * 0.12 tx, tz := 8.5, 8.5 ty := sy + 2 cx := tx + math.Sin(ang)*22 cz := tz + math.Cos(ang)*22 cy := sy + 10 dx, dy, dz := tx-cx, ty-cy, tz-cz yaw := math.Atan2(-dx, -dz) pitch := math.Atan2(dy, math.Sqrt(dx*dx+dz*dz)) return camera.New([3]float64{cx, cy, cz}, yaw, pitch, 70) } // 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 工作台点击:左/右/Shift(工作台与合成表.md §5、UI与输入.md §4)。 func (a *clientApp) handleCraftClick(slot string, right, shift bool) { switch { case slot == "": return case slot == "book": a.craftScreen.BookOpen = !a.craftScreen.BookOpen if !a.craftScreen.BookOpen { a.uiText = "" } case slot == "search": a.uiText = "book" case strings.HasPrefix(slot, "recipe"): var i int fmt.Sscanf(slot[6:], "%d", &i) list := ui.FilterRecipes(a.sess.Recipes.CraftingRecipes(), a.craftScreen.BookQuery, a.craftScreen.Labels) if i >= 0 && i < len(list) && a.sess.RecipeUnlocked(list[i].Result.Item) { a.sess.CraftingFillRecipe(list[i]) } case slot == "result": if shift { a.sess.CraftingTakeResultAll() return } a.takeCraftResult(right) case strings.HasPrefix(slot, "grid"): i := int(slot[len(slot)-1] - '0') if shift && !right { a.sess.CraftingShiftGrid(i) return } a.held = a.sess.CraftingClick(i, a.held, right) case strings.HasPrefix(slot, "main"), strings.HasPrefix(slot, "hot"): idx := a.invIndex(slot) if idx < 0 { return } if shift && !right { a.sess.CraftingShiftInv(idx) return } a.held = a.sess.Inv.ClickSlot(idx, a.held, right) } } func (a *clientApp) takeCraftResult(right bool) { res, ok := a.sess.CraftingResult() if !ok { return } if a.held.Empty() { if r, ok := a.sess.CraftingTakeResult(); ok { a.held = r } return } if a.held.Name != res.Name || a.held.Count >= a.held.Limit(a.sess.Items) { return } 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]) } } } _ = right } func (a *clientApp) invIndex(slot string) int { var idx int switch { case strings.HasPrefix(slot, "main"): fmt.Sscanf(slot[4:], "%d", &idx) return idx + 9 case strings.HasPrefix(slot, "hot"): fmt.Sscanf(slot[3:], "%d", &idx) if idx >= 0 && idx < inventory.HotbarSize { return idx } case strings.HasPrefix(slot, "armor"): fmt.Sscanf(slot[5:], "%d", &idx) if idx >= 0 && idx < 4 { return inventory.SlotHelmet + idx } case slot == "offhand": return inventory.SlotOffhand } return -1 } // handleInvClick 物品栏点击:存储/盔甲/副手/2×2/输出(UI还原.md §3.3)。 func (a *clientApp) handleInvClick(slot string, right, shift bool) { switch { case slot == "": return case slot == "result": if shift { a.sess.InvCraftTakeResultAll() return } res, ok := a.sess.InvCraftResult() if !ok { return } if a.held.Empty() { if r, ok := a.sess.InvCraftTakeResult(); 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.InvCraftTakeResult(); 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 strings.HasPrefix(slot, "craft"): var i int fmt.Sscanf(slot[5:], "%d", &i) if shift && !right { a.sess.InvCraftShift(i) return } a.held = a.sess.InvCraftClick(i, a.held, right) default: idx := a.invIndex(slot) if idx < 0 { return } if shift && !right { a.sess.Inv.ShiftFromInv(idx) return } a.held = a.sess.Inv.ClickSlot(idx, a.held, right) } } // 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() a.endDrag() a.uiText = "" a.in.textMode = false if a.craftScreen != nil { a.craftScreen.BookOpen = false } } func (a *clientApp) bindSessionHooks() { if a.sess == nil { return } if a.audio != nil { a.sess.Sound = func(key string, x, y, z float64) { a.audio.Play(key, x, y, z) } } a.sess.Pickup = func(name string, x, y, z float64, slot int) { a.mu.Lock() a.flies = append(a.flies, pickupFly{name: name, wx: x, wy: y, wz: z, slot: slot}) a.mu.Unlock() } if tbl, err := entity.LoadTrades(paths.Join("assets", "config", "villager_trades.json")); err == nil { a.sess.Trades = tbl } } func (a *clientApp) tickFlies(dt float64) { a.mu.Lock() n := 0 for _, f := range a.flies { f.age += float32(dt) if f.age < 0.4 { a.flies[n] = f n++ } else if a.hud != nil { a.hud.Punch(f.slot) } } a.flies = a.flies[:n] a.mu.Unlock() } func (a *clientApp) drawPickupFlies() { a.mu.Lock() flies := append([]pickupFly(nil), a.flies...) a.mu.Unlock() if len(flies) == 0 { return } aspect := float64(a.winW) / float64(a.winH) view, proj := a.sess.Cam.View(), a.sess.Cam.Projection(aspect, 0.05, 1024) w, h := float32(a.winW), float32(a.winH) for _, f := range flies { t := f.age / 0.4 if t > 1 { t = 1 } sx, sy, ok := projectWorld(view, proj, f.wx, f.wy, f.wz, w, h) dx, dy := a.hud.HotbarIconPos(f.slot, w, h) x := sx + (dx-sx)*t y := sy + (dy-sy)*t if !ok { x, y = dx, dy } size := (1.2 - 0.6*t) * a.guiScale a.hud.DrawIconAt(a.uiR, f.name, x, y, 16*size) } } func projectWorld(view, proj [16]float32, x, y, z float64, w, h float32) (sx, sy float32, ok bool) { fx, fy, fz := float32(x), float32(y), float32(z) vx := view[0]*fx + view[4]*fy + view[8]*fz + view[12] vy := view[1]*fx + view[5]*fy + view[9]*fz + view[13] vz := view[2]*fx + view[6]*fy + view[10]*fz + view[14] vw := view[3]*fx + view[7]*fy + view[11]*fz + view[15] cx := proj[0]*vx + proj[4]*vy + proj[8]*vz + proj[12]*vw cy := proj[1]*vx + proj[5]*vy + proj[9]*vz + proj[13]*vw cw := proj[3]*vx + proj[7]*vy + proj[11]*vz + proj[15]*vw if cw <= 0.01 { return 0, 0, false } ndcX, ndcY := cx/cw, cy/cw return (ndcX*0.5 + 0.5) * w, (0.5 - ndcY*0.5) * h, true } // closeInventory 关闭物品栏:2×2 余料与手持栈退回背包。 func (a *clientApp) closeInventory() { a.sess.CloseInvCraft() 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 } func (a *clientApp) updateCaptions() { if a.audio == nil { return } caps := a.audio.Captions() labels := make([]string, 0, len(caps)) for _, c := range caps { lab := c.Subtitle if lab == "" { lab = c.Event } if a.lang != nil { if s := a.lang.Get(lab); s != lab { lab = s } } labels = append(labels, lab) } sig := strings.Join(labels, "\n") if sig == a.captionSig { return } a.captionSig = sig a.mu.Lock() defer a.mu.Unlock() a.captionImgs = nil if r := loadCJK(12); r != nil { for _, lab := range labels { a.captionImgs = append(a.captionImgs, r.Draw(lab, color.RGBA{230, 230, 230, 255})) } } a.captionDirty = true } func (a *clientApp) itemDisplayName(name string) string { if name == "" { return "" } if a.lang != nil { if s := a.lang.Get("item.minecraft." + name); s != "item.minecraft."+name { return s } if s := a.lang.Get("block.minecraft." + name); s != "block.minecraft."+name { return s } } return name } func (a *clientApp) stackAtInvHit(slot string) item.Stack { switch { case slot == "result": st, _ := a.sess.InvCraftResult() return st case strings.HasPrefix(slot, "craft"): var i int fmt.Sscanf(slot[5:], "%d", &i) return a.sess.InvCraftSlot(i) default: idx := a.invIndex(slot) if idx >= 0 && idx < len(a.sess.Inv.Slots) { return a.sess.Inv.Slots[idx] } } return item.Stack{} } func (a *clientApp) invHoverName() string { slot := a.invScreen.HitTest(float32(a.in.cursorX), float32(a.in.cursorY), float32(a.winW), float32(a.winH), a.guiScale) return a.stackAtInvHit(slot).Name } func (a *clientApp) craftHoverName() string { slot := a.craftScreen.HitTest(float32(a.in.cursorX), float32(a.in.cursorY), float32(a.winW), float32(a.winH), a.guiScale) switch { case slot == "result": st, _ := a.sess.CraftingResult() return st.Name case strings.HasPrefix(slot, "grid"): i := int(slot[len(slot)-1] - '0') return a.sess.CraftSlot(i).Name case strings.HasPrefix(slot, "recipe"): var i int fmt.Sscanf(slot[6:], "%d", &i) list := ui.FilterRecipes(a.sess.Recipes.CraftingRecipes(), a.craftScreen.BookQuery, a.craftScreen.Labels) if i >= 0 && i < len(list) { return list[i].Result.Item } default: idx := a.invIndex(slot) if idx >= 0 && idx < len(a.sess.Inv.Slots) { return a.sess.Inv.Slots[idx].Name } } return "" } func (a *clientApp) updateSlotTooltip(name string) { if name == a.tipName { return } a.tipName = name a.mu.Lock() defer a.mu.Unlock() if name == "" { a.tipImg = nil a.tipDirty = true return } label := a.itemDisplayName(name) if r := loadCJK(12); r != nil { a.tipImg = r.Draw(label, color.RGBA{255, 255, 255, 255}) } a.tipDirty = 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.clearHeldItem() return } d, ok := a.sess.Items.Get(sel.Name) if !ok { a.clearHeldItem() 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.clearHeldItem() } // computeBlockHand 手持可放置方块:六面贴图立方体(渲染.md §5.2)。 func (a *clientApp) computeBlockHand(place string) { bid, ok := a.reg.ID(place) if !ok { a.clearHeldItem() 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 } // clearHeldItem 空手:只画皮肤手臂,不再铺白羊毛拳头(渲染.md §5.2)。 func (a *clientApp) clearHeldItem() { a.mu.Lock() a.handHas = false a.handItemHas = false a.handDirty = true a.mu.Unlock() } // integerGUIScale 按窗口像素取 1–4 整数缩放(UI还原.md §3.1:427×240 基准)。 func integerGUIScale(w, h int) float32 { s := w / 427 if h/240 < s { s = h / 240 } if s < 1 { s = 1 } if s > 4 { s = 4 } return float32(s) } // applyResize 同步 viewport 之后的 UI / 投影尺寸(渲染.md §5.4)。 func (a *clientApp) applyResize(w, h int) { if w < 1 { w = 1 } if h < 1 { h = 1 } a.winW, a.winH = w, h a.guiScale = integerGUIScale(w, h) if a.uiR != nil { a.uiR.Resize(w, h, a.guiScale) } if a.hud != nil { a.hud.SetScale(a.guiScale) } if a.titleScreen != nil { a.titleScreen.Layout(float32(w), float32(h), a.guiScale) } if a.pauseScreen != nil { a.pauseScreen.Layout(float32(w), float32(h), a.guiScale) } if a.deathScreen != nil { a.deathScreen.Layout(float32(w), float32(h), a.guiScale) } if a.selectWorld != nil { a.selectWorld.Layout(float32(w), float32(h), a.guiScale) } if a.createWorld != nil { a.createWorld.Layout(float32(w), float32(h), a.guiScale) } if a.optionsScr != nil { a.optionsScr.Layout(float32(w)) } } // Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。 func (a *clientApp) Render() { if a.in.toggleFS { a.in.toggleFS = false a.rend.ToggleFullscreen() } // 消费光标模式请求(主线程执行 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) } if a.uiListDirty.CompareAndSwap(true, false) { a.refreshWorldList() } if a.uiCreateDirty.CompareAndSwap(true, false) { a.refreshCreateFields() } if a.clearChunks.CompareAndSwap(true, false) { a.rend.ClearAllChunks() a.mu.Lock() old := a.retireWorld a.retireWorld = nil a.mu.Unlock() if old != nil { old.Close() } } a.mu.Lock() rm := a.pendingRemove a.pendingRemove = nil a.mu.Unlock() for _, p := range rm { a.rend.RemoveChunk(p[0], p[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) } // 清屏 + 绘制(视锥剔除简化为全量绘制,后置 LOD) a.rend.BeginFrame() fw, fh := a.rend.FramebufferSize() if fw != a.winW || fh != a.winH { a.applyResize(fw, fh) } cam := a.sess.Cam if a.screen == 0 || a.screen == 6 || a.screen == 7 { cam = a.menuOrbitCam() } aspect := float64(a.winW) / float64(a.winH) a.rend.SetViewProj(cam.View(), cam.Projection(aspect, 0.05, 1024)) 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 variant >= 0 { uv, ok := firstUV(a.rend, animalSkinKeys(variant)...) if !ok { uv, ok = a.rend.EntityUV("entity/missing") } if ok { yaw := float32(e.Yaw) a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, render.AnimalModelByVariant(variant), uv, e.HitFlash > 0) if variant == 2 { if fur, furOK := firstUV(a.rend, "entity/sheep/sheep_fur", "entity/sheep/sheep_wool"); furOK { a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, render.SheepWoolModel(), fur, e.HitFlash > 0) } } } continue } uv, ok := a.rend.EntityUV(key) if !ok { uv, ok = a.rend.EntityUV("entity/missing") } if !ok { continue } 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.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), float32(e.Yaw), render.HumanoidModel(), 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() handHas := a.handHas handItemHas, handItemUV := a.handItemHas, a.handItemUV var handUVs [6][4]float32 if handHas { handUVs = a.handUVs } a.handDirty = false a.mu.Unlock() if a.screen == 1 { armUV, hasSkin := firstUV(a.rend, "entity/player/wide/steve", "entity/steve") if !hasSkin && !a.armWarned { a.armWarned = true a.logf("WARN 玩家皮肤缺失(entity/player/wide/steve、entity/steve),第一人称手臂使用肤色回退") } a.rend.DrawFirstPerson(armUV, hasSkin, float32(a.swing), handUVs, handHas, handItemUV, handItemHas) } // 准星指向方块名上传(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()), a.targetName) } else { a.hud.SetTarget(0, 0, 0, "") } } a.mu.Lock() tipDirty, tipImg := a.tipDirty, a.tipImg a.tipDirty = false a.mu.Unlock() if tipDirty { if tipImg != nil { tex := a.uiR.UploadImage(tipImg) tw, th := float32(tipImg.Bounds().Dx()), float32(tipImg.Bounds().Dy()) a.invScreen.SetTooltip(tex, tw, th) a.craftScreen.SetTooltip(tex, tw, th) if a.furnaceScreen != nil { a.furnaceScreen.SetTooltip(tex, tw, th) } if a.chestScreen != nil { a.chestScreen.SetTooltip(tex, tw, th) } if a.creativeScreen != nil { a.creativeScreen.SetTooltip(tex, tw, th) } } else { a.invScreen.SetTooltip(0, 0, 0) a.craftScreen.SetTooltip(0, 0, 0) if a.furnaceScreen != nil { a.furnaceScreen.SetTooltip(0, 0, 0) } if a.chestScreen != nil { a.chestScreen.SetTooltip(0, 0, 0) } if a.creativeScreen != nil { a.creativeScreen.SetTooltip(0, 0, 0) } } } a.mu.Lock() capDirty, capImgs := a.captionDirty, a.captionImgs a.captionDirty = false a.mu.Unlock() if capDirty { var tex []uint32 var cw, ch []float32 for _, img := range capImgs { if img == nil { continue } tex = append(tex, a.uiR.UploadImage(img)) b := img.Bounds() cw = append(cw, float32(b.Dx())) ch = append(ch, float32(b.Dy())) } a.hud.SetCaptions(tex, cw, ch) } // 小地图上传(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.SetWorldBg(true) a.titleScreen.Draw(a.uiR, mx, my) case 1: a.hud.Draw(a.uiR) a.drawPickupFlies() case 2: a.hud.Draw(a.uiR) // 暂停时世界仍可见(UI还原.md §2) a.pauseScreen.Draw(a.uiR, mx, my) a.drawSaveHint() case 13: if a.loadingScreen != nil { a.loadingScreen.Draw(a.uiR) } case 11: a.hud.Draw(a.uiR) a.deathScreen.Cause = a.sess.DeathReason() a.deathScreen.Draw(a.uiR, mx, my) case 12: a.tradeScreen.Draw(a.uiR, a.sess.TradeOffers(), mx, my, a.guiScale) 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, a.held, a.sess.Recipes.CraftingRecipes(), a.sess.Unlocked, mx, my, a.guiScale) case 4: // 设置界面 a.optionsScr.Draw(a.uiR, mx, my, float32(a.sensVal/0.01), float32(a.volVal)) case 5: // 物品栏界面 var cg [4]item.Stack for i := range cg { cg[i] = a.sess.InvCraftSlot(i) } ires, ihas := a.sess.InvCraftResult() a.invScreen.Draw(a.uiR, a.sess.Inv.Slots, cg, ires, ihas, a.held, mx, my, a.guiScale) case 8: be := a.sess.ContainerEntity() var in, fuel, out item.Stack var burn, cook float32 if be != nil && len(be.Slots) > 2 { in, fuel, out = be.Slots[1], be.Slots[0], be.Slots[2] if be.BurnMaxTicks > 0 { burn = float32(be.BurnTicks) / float32(be.BurnMaxTicks) } cook = float32(be.CookTicks) / float32(blockentity.CookTime) } a.furnaceScreen.Draw(a.uiR, in, fuel, out, burn, cook, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) case 9: var box []item.Stack if be := a.sess.ContainerEntity(); be != nil { box = be.Slots } a.chestScreen.Draw(a.uiR, box, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) case 10: a.creativeScreen.Draw(a.uiR, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) case 6: a.titleScreen.SetWorldBg(true) a.selectWorld.Draw(a.uiR, mx, my) case 7: a.titleScreen.SetWorldBg(true) a.createWorld.Draw(a.uiR, mx, my) } 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")) defer crash.Guard(paths.Join("logs", "crash.log")) flag.Parse() log, err := logx.NewFile(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) app, err := bootClient(log, cfg) if err != nil { if errors.Is(err, errBootClosed) { log.Infof("启动中关闭窗口") return } panic(err) } defer app.rend.Shutdown() defer app.audio.Close() if app.sess != nil && app.sess.World != nil { defer app.sess.World.Close() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() log.Infof("进入游戏主循环") engine.Run(ctx, app, app) if app.persistOnExit { app.persistWorldWait() } app.savePool.Close() log.Infof("客户端已退出") }