2252 lines
63 KiB
Go
2252 lines
63 KiB
Go
//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("客户端已退出")
|
||
}
|