feat(crafting): 工作台 3×3 合成界面——右键开台、槽位交换、输出格合成、关闭退回

- 会话层(工作台与合成表.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 全链路截图验证
This commit is contained in:
NianGao Dev
2026-08-16 10:23:01 +08:00
parent 8bc64d220a
commit 768a9b8096
10 changed files with 559 additions and 24 deletions

View File

@@ -59,6 +59,7 @@ var (
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)。
@@ -70,10 +71,12 @@ type clientApp struct {
uiR *ui.Renderer
hud *ui.HUD
// 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停(UI还原.md §2)
// 屏幕状态机: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
@@ -106,10 +109,12 @@ type clientApp struct {
frames int // 渲染帧计数(截图时机用)
lastTime time.Time
lookInjected bool // dev 测试:-lookdeg 已注入
crackShot bool // dev 测试:-crackstage 已注入
spawnShot bool // dev 测试:-spawntest 已注入
escShot bool // dev 测试:-esctest 已注入
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 待上传网格。
@@ -172,6 +177,27 @@ func (a *clientApp) Tick(dt float64) {
}
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 {
@@ -181,6 +207,12 @@ func (a *clientApp) Tick(dt float64) {
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()
@@ -192,7 +224,7 @@ func (a *clientApp) Tick(dt float64) {
a.sess.Cam.Pitch = *pitchDeg * math.Pi / 180.0
}
if *lookDeg != 0 {
dx := float64(*lookDeg) * math.Pi / 180.0 / 0.0025 // 目标弧度 → 像素位移
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)
@@ -293,6 +325,82 @@ func (a *clientApp) injectDevVisuals() {
}
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 写入,渲染线程消费——
@@ -353,10 +461,15 @@ 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) {
// dev 测试:截图前注入破坏裂纹/实体/ESC/工作台(必须在 3D 绘制之前,截图帧才能拍到)
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest) {
a.injectDevVisuals()
}
// 上传 tick 构建好的网格(GL 调用必须在渲染线程)
@@ -394,9 +507,11 @@ func (a *clientApp) Render() {
}
// 破坏裂纹叠加(方块交互与动画.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))
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()
@@ -414,7 +529,9 @@ func (a *clientApp) Render() {
a.rend.ClearHand()
}
}
a.rend.DrawHand()
if a.screen == 1 {
a.rend.DrawHand()
}
// UI pass(UI与输入.md §2:3D 之后、状态隔离)
a.uiR.Begin()
@@ -427,6 +544,13 @@ func (a *clientApp) Render() {
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()
@@ -681,9 +805,10 @@ func main() {
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)
itemUV := make(map[string][4]float32, len(itemUVs))
itemTex = uiR.UploadImage(itemImg)
for k, rect := range itemUVs {
W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy())
itemUV[k] = [4]float32{
@@ -693,6 +818,17 @@ func main() {
}
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)
@@ -728,8 +864,16 @@ func main() {
}
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,
@@ -740,6 +884,7 @@ func main() {
hud: hud,
titleScreen: titleScreen,
pauseScreen: pauseScreen,
craftScreen: craftScreen,
log: log,
remotePlayers: make(map[uint32][3]float64),
meshBuilder: mesh.NewBuilder(reg, uvFn),

View File

@@ -204,9 +204,11 @@ func (in *inputState) apply(s *game.Session) {
s.EndBreak()
}
// 放置
// 放置(右键;命中工作台则打开合成界面,工作台与合成表.md §5)
if in.placeDown {
s.PlaceSelected()
if !s.TryOpenCrafting() {
s.PlaceSelected()
}
in.placeDown = false
}
}