- 会话层(工作台与合成表.md §5):TryOpenCrafting 右键命中工作台开台; CraftingSwap 网格槽与手持栈交换/合并;CraftingTakeResult 匹配配方 (有序优先)消耗材料取产物;CloseCrafting 材料退回背包(放不下掉落) - 客户端屏幕状态 3:gui/container/crafting_table.png 176×166 窗口、 3×3 网格 (30,17) + 输出格 (124,35) + 热键栏行 (7,141)(UI还原.md §3.4); ESC 关闭并回收手持栈;界面打开时释放光标、关闭恢复捕获并重置鼠标基准 - 物品图标双图集:block/* 图标回退 blocks 图集(原版 items 图集不收录 block 纹理,此前方块物品图标全部缺失) - 单测:shaped 2×2 木板→工作台、shapeless 原木→木板、木镐多符号、 关闭退回材料;dev 参数 -crafttest 全链路截图验证
905 lines
29 KiB
Go
905 lines
29 KiB
Go
//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(验证暂停菜单与光标释放链路)")
|
||
craftTest = flag.Bool("crafttest", false, "dev 测试:截图前打开工作台并预填 2×2 木板(验证合成界面)")
|
||
)
|
||
|
||
// 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 工作台(UI还原.md §2)
|
||
screen int
|
||
titleScreen *ui.TitleScreen
|
||
pauseScreen *ui.PauseScreen
|
||
craftScreen *ui.CraftingScreen
|
||
held item.Stack // 合成界面手持栈(跟随鼠标)
|
||
|
||
// 联机(多人同步.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 已注入
|
||
craftShot bool // dev 测试:-crafttest 已注入
|
||
craftClickShot bool // dev 测试:-crafttest 点击输出格已注入
|
||
}
|
||
|
||
// 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
|
||
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
|
||
}
|
||
// 游戏中
|
||
if a.in.escPressed {
|
||
a.screen = 2
|
||
a.requestCursor(false)
|
||
a.in.escPressed = false
|
||
return
|
||
}
|
||
a.in.apply(a.sess)
|
||
// 右键命中工作台 → 打开合成界面(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)
|
||
|
||
// 联机:上报移动(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)
|
||
}
|
||
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 木板")
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// closeCrafting 关闭合成界面:材料退回背包、手持栈放回背包。
|
||
func (a *clientApp) closeCrafting() {
|
||
a.sess.CloseCrafting()
|
||
if !a.held.Empty() {
|
||
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{}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
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 测试:截图前注入破坏裂纹/实体/ESC/工作台(必须在 3D 绘制之前,截图帧才能拍到)
|
||
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest) {
|
||
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 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;上传必须是渲染线程)
|
||
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()
|
||
}
|
||
}
|
||
if a.screen == 1 {
|
||
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)
|
||
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)
|
||
}
|
||
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)
|
||
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)
|
||
|
||
// 6) 输入(鼠标捕获 + 键鼠回调)
|
||
in := newInputState(rend.Window())
|
||
// 光标初始位置 = 窗口中心(捕获模式下 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,
|
||
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("客户端已退出")
|
||
}
|