Files
ngzz-mc/cmd/client/client_gl.go
NianGao Dev d52bf1126f feat(client): 植被染色、破坏裂纹叠加、手持方块与实体广告牌渲染;原版 HUD 精灵
- 植被染色(渲染.md §4.1):blocks.json 增 tint 标记,图集构建后按
  colormap/grass|foliage 中心色乘色——原版材质包草顶/树叶为灰度图,不染色全灰
- 破坏裂纹(方块交互与动画.md §4):destroy_stage_0..9 裂纹盒叠加破坏目标方块
- 第一人称手持方块(渲染.md §5.2):选中槽放置方块六面 UV 视图空间立方体
- 实体广告牌(渲染.md §5.3):僵尸/村民/动物按贴图渲染,动物按 ID 轮换外观
- HUD 换原版精灵:crosshair/hotbar/hotbar_selection/heart/food(UI还原.md §3.4)
- 主菜单标题/按钮标签按请求字号光栅化(修复 24px 固定字号);版本角标 CJK
- 恢复背面剔除(相机修复后 '脚下黑块' 误诊解除);ESC 光标请求原子化防竞态
- dev 测试参数:-give/-crackstage/-spawntest/-esctest
2026-08-16 10:08:40 +08:00

760 lines
24 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/png"
"math"
"os"
"os/signal"
"sort"
"sync"
"sync/atomic"
"time"
"mc/internal/assets"
"mc/internal/atlas"
"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(验证暂停菜单与光标释放链路)")
)
// 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 暂停(UI还原.md §2)
screen int
titleScreen *ui.TitleScreen
pauseScreen *ui.PauseScreen
// 联机(多人同步.md §2:无连接时为单机)
net *netclient.Client
log *logx.Logger
// 其他玩家位置(多人同步.md §3;渲染插值后置)
remoteMu sync.Mutex
remotePlayers map[uint32][3]float64
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
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 已注入
}
// 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 "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 "quit":
a.rend.Window().SetShouldClose(true)
}
a.in.clicked = false
}
a.in.escPressed = false
return
}
// 游戏中
if a.in.escPressed {
a.screen = 2
a.requestCursor(false)
a.in.escPressed = false
return
}
a.in.apply(a.sess)
// 手持方块 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)
// 联机:上报移动(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"), "NewWorld", 123456789, 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)
}
a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez)
}
}
// 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
setEmpty := func() {
a.mu.Lock()
a.handHas = false
a.handDirty = true
a.mu.Unlock()
}
sel := a.sess.Inv.SelectedStack()
if sel.Empty() {
setEmpty()
return
}
d, ok := a.sess.Items.Get(sel.Name)
if !ok || d.Place == "" {
setEmpty()
return
}
bid, ok := a.reg.ID(d.Place)
if !ok {
setEmpty()
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.handDirty = true
a.mu.Unlock()
}
// Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。
func (a *clientApp) Render() {
// 消费光标模式请求(主线程执行 GLFW 调用)
if req := a.cursorReq.Load(); req >= 0 {
a.rend.SetCursorMode(req == 1)
a.cursorReq.Store(-1)
}
// dev 测试:截图前注入破坏裂纹/实体/ESC(必须在 3D 绘制之前,截图帧才能拍到)
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest) {
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 := entityVisual(e)
if key == "" {
continue
}
if uv, ok := a.rend.EntityUV(key); ok {
a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv)
}
}
// 破坏裂纹叠加(方块交互与动画.md §4:progress→destroy_stage 0–9)
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;上传必须是渲染线程)
a.mu.Lock()
handDirty, handHas := a.handDirty, a.handHas
a.handDirty = false
var handUVs [6][4]float32
if handHas {
handUVs = a.handUVs
}
a.mu.Unlock()
if handDirty {
if handHas {
a.rend.SetHandCube(handUVs, [3]float32{0.46, -0.44, -0.66}, 0.16)
} else {
a.rend.ClearHand()
}
}
a.rend.DrawHand()
// UI pass(UI与输入.md §2:3D 之后、状态隔离)
a.uiR.Begin()
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)
}
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)
}
// 裂纹图集(方块交互与动画.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",
}
entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64)
if err != nil {
log.Warnf("实体图集构建失败:%v", err)
}
// 3) 世界与生成器(区块管理.md §4 异步流水线)
gen, err := worldgen.New(reg, 123456789, 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", "NewWorld", "level.dat")); err == nil {
if _, err := save.LoadWorld(w, paths.Join("worlds"), "NewWorld"); err != nil {
log.Warnf("存档载入失败: %v", err)
} else {
log.Infof("已载入世界 NewWorld")
}
}
// 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)
// 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)
// 主菜单状态:鼠标可见(仅游戏内捕获,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)
if itemImg != nil {
itemTex := uiR.UploadImage(itemImg)
itemUV := make(map[string][4]float32, len(itemUVs))
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)
}
// 原版 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)
// 6) 输入(鼠标捕获 + 键鼠回调)
in := newInputState(rend.Window())
app := &clientApp{
sess: sess,
rend: rend,
reg: reg,
in: in,
uiR: uiR,
hud: hud,
titleScreen: titleScreen,
pauseScreen: pauseScreen,
log: log,
remotePlayers: make(map[uint32][3]float64),
meshBuilder: mesh.NewBuilder(reg, uvFn),
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("客户端已退出")
}