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

2252 lines
63 KiB
Go
Raw 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 + Loadingscreen=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 测试截图前把破坏进度视觉设置为该阶段09")
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
// 槽位悬停 tooltiptick 光栅化 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 每 tick20Hz输入 → 玩家 → 会话 → 网格构建(渲染.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.327 主区 + 热键栏,点击交换)
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 按窗口像素取 14 整数缩放UI还原.md §3.1427×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 §4progress→destroy_stage 09
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 passUI与输入.md §23D 之后、状态隔离)
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("客户端已退出")
}