Files
ngzz-mc/cmd/client/client_gl.go
NianGao Dev b0b632df05 feat(client): 手持物品渲染 + 动物方块模型 + 实体贴图 alpha test
- 手持非放置物品(工具/食物):实体图集物品图标小四边形(原版倾斜角,
  双面、alpha test),随挥动动画平移;可放置方块仍为六面立方体
- 动物(牛/猪/羊/鸡)改为方块组合模型(身体+头+腿),按原版皮肤布局
  取子矩形贴图,水平面向相机;修复动物 VAO 未捕获 EBO 导致的驱动崩溃
- 僵尸/村民/掉落物广告牌开启 alpha test(透明区域不再显黑块)
2026-08-16 23:23:30 +08:00

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