Files
ngzz-mc/cmd/client/client_gl.go
2026-09-19 14:21:08 +08:00

2252 lines
63 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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("客户端已退出")
}