diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 3f924394..82defb9b 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -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), diff --git a/cmd/client/input.go b/cmd/client/input.go index 27b312a3..4f658047 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -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 } } diff --git a/internal/game/game.go b/internal/game/game.go index e24d816d..497ca796 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -50,6 +50,10 @@ type Session struct { // 创造模式(跳过进度直接破坏) Creative bool + + // 工作台状态(工作台与合成表.md §5:3×3 网格 + 输出格) + craftOpen bool + craftGrid [9]item.Stack } // NewSession 创建会话。 @@ -242,6 +246,133 @@ func (s *Session) overlapsPlayer(x, y, z int32) bool { float64(z)+1 > pz-hw && float64(z) < pz+hw } +// ---- 工作台合成(工作台与合成表.md §5)---- + +// craftingTableID 工作台方块 ID(注册表查询缓存值)。 +func (s *Session) craftingTableID() uint16 { + id, ok := s.Reg.ID("crafting_table") + if !ok { + return 0 + } + return id +} + +// TryOpenCrafting 视线 5 格内命中工作台则打开合成界面(右键触发)。 +// 返回 true 表示已打开(调用方不应再执行放置)。 +func (s *Session) TryOpenCrafting() bool { + h, ok := s.Cast(5) + if !ok { + return false + } + if s.World.Block(h.X, h.Y, h.Z).ID() != s.craftingTableID() { + return false + } + s.craftOpen = true + return true +} + +// OpenCrafting 直接打开合成界面(开发测试/后续按键绑定用)。 +func (s *Session) OpenCrafting() { s.craftOpen = true } + +// CraftingOpen 合成界面是否打开。 +func (s *Session) CraftingOpen() bool { return s.craftOpen } + +// CraftSlot 读网格槽(只读,UI 用)。 +func (s *Session) CraftSlot(i int) item.Stack { + if i < 0 || i >= 9 { + return item.Stack{} + } + return s.craftGrid[i] +} + +// CraftingSwap 网格槽与手持栈交换/合并(同物品合并到上限),返回新的手持栈。 +func (s *Session) CraftingSwap(i int, held item.Stack) item.Stack { + if i < 0 || i >= 9 { + return held + } + cur := s.craftGrid[i] + switch { + case held.Empty(): + s.craftGrid[i] = item.Stack{} + return cur + case cur.Empty(): + s.craftGrid[i] = held + return item.Stack{} + case cur.Name == held.Name: + merged, remain := inventory.Merge(held.Limit(s.Items), cur, held) + s.craftGrid[i] = merged + return remain + default: + s.craftGrid[i] = held + return cur + } +} + +// craftResult 按网格匹配配方(有序优先,再无序),返回产物。 +func (s *Session) craftResult() (item.Stack, bool) { + var g [3][3]string + counts := make(map[string]int) + for i := 0; i < 9; i++ { + st := s.craftGrid[i] + if st.Empty() { + continue + } + g[i/3][i%3] = st.Name + counts[st.Name]++ + } + if len(counts) == 0 { + return item.Stack{}, false + } + // 有序配方优先(避免无序规则误吞有序网格,工作台与合成表.md §4) + if rc, ok := s.Recipes.MatchShaped(g); ok { + return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true + } + if rc, ok := s.Recipes.MatchShapeless(counts); ok { + return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true + } + return item.Stack{}, false +} + +// CraftingResult 当前网格的合成产物(无则 ok=false,UI 输出格用)。 +func (s *Session) CraftingResult() (item.Stack, bool) { return s.craftResult() } + +// CraftingTakeResult 执行一次合成:消耗网格材料并返回产物。 +// 网格匹配失败返回 false(材料不动)。 +func (s *Session) CraftingTakeResult() (item.Stack, bool) { + res, ok := s.craftResult() + if !ok { + return item.Stack{}, false + } + for i := range s.craftGrid { + if s.craftGrid[i].Empty() { + continue + } + if s.craftGrid[i].Count <= 1 { + s.craftGrid[i] = item.Stack{} + } else { + s.craftGrid[i].Count-- + } + } + return res, true +} + +// CloseCrafting 关闭合成界面:网格剩余材料退回背包(放不下掉落地面)。 +func (s *Session) CloseCrafting() { + if !s.craftOpen { + return + } + for i := range s.craftGrid { + if s.craftGrid[i].Empty() { + continue + } + if rem := s.Inv.Add(s.craftGrid[i]); !rem.Empty() { + s.Entities.SpawnDrop(rem.Name, int(rem.Count), s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) + } + s.craftGrid[i] = item.Stack{} + } + s.craftOpen = false +} + // tickBlockEntities 熔炉烧炼并产出(物品与背包.md §6)。 func (s *Session) tickBlockEntities() { for _, be := range s.BlockEntities { diff --git a/internal/game/game_test.go b/internal/game/game_test.go index 095ff652..dd2e60ff 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -169,3 +169,107 @@ func mustID(t *testing.T, reg *block.Registry, name string) uint16 { } return id } + +// fillGrid 往合成网格第 i 槽放入 1 个物品(测试辅助)。 +func fillGrid(s *Session, i int, name string) { + s.CraftingSwap(i, item.Stack{Name: name, Count: 1}) +} + +// TestCraftingShaped 工作台有序合成:2×2 木板 → 工作台(工作台与合成表.md §4.1/§5)。 +func TestCraftingShaped(t *testing.T) { + s := newTestSession(t) + s.OpenCrafting() + if !s.CraftingOpen() { + t.Fatal("应已打开合成界面") + } + // 空网格无产物 + if _, ok := s.CraftingResult(); ok { + t.Fatal("空网格不应有产物") + } + // 2×2 木板 + fillGrid(s, 0, "oak_planks") + fillGrid(s, 1, "oak_planks") + fillGrid(s, 3, "oak_planks") + fillGrid(s, 4, "oak_planks") + res, ok := s.CraftingResult() + if !ok || res.Name != "crafting_table" || res.Count != 1 { + t.Fatalf("2×2 木板应产出工作台×1,实际 %+v ok=%v", res, ok) + } + // 执行合成:材料消耗、产物返回 + taken, ok := s.CraftingTakeResult() + if !ok || taken.Name != "crafting_table" { + t.Fatalf("TakeResult 应返回工作台,实际 %+v", taken) + } + for _, i := range []int{0, 1, 3, 4} { + if !s.CraftSlot(i).Empty() { + t.Fatalf("合成后网格槽 %d 应清空,实际 %+v", i, s.CraftSlot(i)) + } + } + if _, ok := s.CraftingResult(); ok { + t.Fatal("材料耗尽后不应再有产物") + } +} + +// TestCraftingShapeless 无序合成:1 原木 → 4 木板(工作台与合成表.md §4.2)。 +func TestCraftingShapeless(t *testing.T) { + s := newTestSession(t) + s.OpenCrafting() + fillGrid(s, 4, "oak_log") + res, ok := s.CraftingResult() + if !ok || res.Name != "oak_planks" || res.Count != 4 { + t.Fatalf("原木应产出 4 木板,实际 %+v ok=%v", res, ok) + } + taken, ok := s.CraftingTakeResult() + if !ok || taken.Count != 4 { + t.Fatalf("应产出 4 木板,实际 %+v", taken) + } +} + +// TestCraftingTools 工具配方:3 木板 + 2 木棍 → 木镐(工作台与合成表.md §4.1 多符号)。 +func TestCraftingTools(t *testing.T) { + s := newTestSession(t) + s.OpenCrafting() + // 顶行 3 木板,中/下行第 2 列木棍 + for i := 0; i < 3; i++ { + fillGrid(s, i, "oak_planks") + } + fillGrid(s, 4, "stick") + fillGrid(s, 7, "stick") + res, ok := s.CraftingResult() + if !ok || res.Name != "wooden_pickaxe" { + t.Fatalf("木板+木棍应产出木镐,实际 %+v ok=%v", res, ok) + } +} + +// TestCraftingCloseReturn 关闭界面:网格剩余材料退回背包,放不下掉落(工作台与合成表.md §5)。 +func TestCraftingCloseReturn(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{} // 清空 1 号槽方便计数 + s.OpenCrafting() + fillGrid(s, 0, "oak_planks") + fillGrid(s, 0, "oak_planks") // 同槽合并 → 2 个 + fillGrid(s, 8, "stick") + s.CloseCrafting() + if s.CraftingOpen() { + t.Fatal("关闭后不应仍处于打开状态") + } + // 网格清空 + for i := 0; i < 9; i++ { + if !s.CraftSlot(i).Empty() { + t.Fatalf("关闭后网格槽 %d 应清空", i) + } + } + // 背包收回 + havePlanks, haveStick := 0, 0 + for _, st := range s.Inv.Slots { + if st.Name == "oak_planks" { + havePlanks += int(st.Count) + } + if st.Name == "stick" { + haveStick += int(st.Count) + } + } + if havePlanks < 2 || haveStick < 1 { + t.Fatalf("材料应退回背包,实际 planks=%d stick=%d", havePlanks, haveStick) + } +} diff --git a/internal/ui/crafting.go b/internal/ui/crafting.go new file mode 100644 index 00000000..a5f56183 --- /dev/null +++ b/internal/ui/crafting.go @@ -0,0 +1,134 @@ +//go:build gl + +// 工作台界面(UI还原.md §3.4、工作台与合成表.md §5): +// 176×166 窗口(gui/container/crafting_table.png)+ 3×3 合成网格 (30,17) +// + 输出格 (124,35) + 热键栏行 (7,141);点击槽位与手持栈交换/合并。 +package ui + +import ( + "fmt" + + "mc/internal/item" +) + +// CraftingScreen 工作台合成界面。 +type CraftingScreen struct { + bgTex uint32 // crafting_table.png(256×256,实际内容 176×166) + items *item.Registry + itemTex uint32 + itemUV map[string][4]float32 + // 方块物品图标回退来源(原版 block/* 图标在 blocks 图集) + blockTex uint32 + blockUV map[string][4]float32 + + // 布局(逻辑 427×240 基准,UI还原.md §3.4) + winW, winH float32 // 176×166 +} + +// NewCraftingScreen 创建工作台界面。 +func NewCraftingScreen(bgTex uint32, items *item.Registry) *CraftingScreen { + return &CraftingScreen{bgTex: bgTex, items: items, + itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, winW: 176, winH: 166} +} + +// SetItemAtlas 设置物品图标纹理与 UV 表(与 HUD 共用 items 图集)。 +func (s *CraftingScreen) SetItemAtlas(tex uint32, uv map[string][4]float32) { + s.itemTex = tex + s.itemUV = uv +} + +// SetBlockAtlas 设置方块图集(方块物品图标回退来源)。 +func (s *CraftingScreen) SetBlockAtlas(tex uint32, uv map[string][4]float32) { + s.blockTex = tex + s.blockUV = uv +} + +// 合成台窗口槽位(相对窗口左上角,UI还原.md §3.4)。 +var craftSlots = [10][2]float32{ + {30, 17}, {48, 17}, {66, 17}, + {30, 35}, {48, 35}, {66, 35}, + {30, 53}, {48, 53}, {66, 53}, // 3×3 网格 + {124, 35}, // 输出格 +} + +// HitTest 命中检测:返回 "grid0".."grid8" / "result" / "hot0".."hot8" / ""。 +func (s *CraftingScreen) HitTest(mx, my float32, screenW, screenH, scale float32) string { + bx, by := screenW/2-s.winW/2*scale, screenH/2-s.winH/2*scale + hit := func(px, py float32) bool { + return mx >= bx+px*scale && mx < bx+(px+18)*scale && + my >= by+py*scale && my < by+(py+18)*scale + } + for i, p := range craftSlots { + if hit(p[0], p[1]) { + if i < 9 { + return "grid" + string(rune('0'+i)) + } + return "result" + } + } + // 热键栏行(窗口内底部,与物品栏窗口同规格) + for i := 0; i < 9; i++ { + if hit(7+float32(i)*18, 141) { + return "hot" + string(rune('0'+i)) + } + } + return "" +} + +// Draw 绘制工作台界面(调用方已 Begin;mx/my 为鼠标像素坐标,held 为手持栈)。 +func (s *CraftingScreen) Draw(r *Renderer, grid [9]item.Stack, result item.Stack, hasResult bool, hotbar []item.Stack, held item.Stack, mx, my, scale float32) { + w, h := float32(r.w), float32(r.h) + // 半透明遮罩(原版容器界面背景压暗) + r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.35}) + // 窗口背景(纹理 256×256,内容区 176×166,UI还原.md §3.2) + bx, by := w/2-s.winW/2*scale, h/2-s.winH/2*scale + if s.bgTex != 0 { + r.pushQuad(quad{x: bx, y: by, w: s.winW * scale, h: s.winH * scale, + u0: 0, v0: 0, u1: 176.0 / 256.0, v1: 166.0 / 256.0, + r: 1, g: 1, b: 1, a: 1, tex: s.bgTex}) + } + // 3×3 网格 + 输出格 + for i, p := range craftSlots { + var st item.Stack + if i < 9 { + st = grid[i] + } else if hasResult { + st = result + } + s.drawSlot(r, st, bx+p[0]*scale+1*scale, by+p[1]*scale+1*scale, scale) + } + // 热键栏行 + for i := 0; i < 9 && i < len(hotbar); i++ { + s.drawSlot(r, hotbar[i], bx+(7+float32(i)*18)*scale+1*scale, by+141*scale+1*scale, scale) + } + // 手持栈跟随鼠标 + if !held.Empty() { + s.drawSlot(r, held, mx-8*scale, my-8*scale, scale) + } +} + +// drawSlot 在指定位置绘制物品图标(16×16 逻辑像素)。 +func (s *CraftingScreen) drawSlot(r *Renderer, st item.Stack, x, y, scale float32) { + if st.Empty() || s.items == nil { + return + } + def, ok := s.items.Get(st.Name) + if !ok { + return + } + // 原版约定:block/* 图标在 blocks 图集,item/* 在 items 图集 + tex := s.itemTex + uv, found := s.itemUV[def.Texture] + if !found { + uv, found = s.blockUV[def.Texture] + tex = s.blockTex + } + if found && tex != 0 { + r.pushQuad(quad{x: x, y: y, w: 16 * scale, h: 16 * scale, + u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], + r: 1, g: 1, b: 1, a: 1, tex: tex}) + } + if st.Count > 1 { + r.DrawText(x+10*scale, y+8*scale, fmt.Sprintf("%d", st.Count), scale, [4]float32{1, 1, 1, 1}) + } +} diff --git a/internal/ui/hud.go b/internal/ui/hud.go index 6e068279..f8d3e6a2 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -15,9 +15,11 @@ type HUD struct { sess *game.Session items *item.Registry - // 物品图标:items 图集纹理与 UV - itemTex uint32 - itemUV map[string][4]float32 + // 物品图标:items 图集纹理与 UV(item/*);方块物品回退 blocks 图集(block/*) + itemTex uint32 + itemUV map[string][4]float32 + blockTex uint32 + blockUV map[string][4]float32 // 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*) crossTex uint32 // crosshair.png 15×15 @@ -40,7 +42,7 @@ type HUD struct { // NewHUD 创建 HUD。 func NewHUD(sess *game.Session, items *item.Registry, baseW, baseH, scale float32) *HUD { - return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale} + return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale} } // SetItemAtlas 设置物品图标纹理与 UV 表(items 图集)。 @@ -49,6 +51,13 @@ func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) { h.itemUV = uv } +// SetBlockAtlas 设置方块图集(方块物品图标的回退来源,设计.md §1.3 原版 +// block/* 图标在 blocks 图集而非 items 图集)。 +func (h *HUD) SetBlockAtlas(tex uint32, uv map[string][4]float32) { + h.blockTex = tex + h.blockUV = uv +} + // SetSprites 设置原版 HUD 精灵纹理(跨 package 上传后注入)。 func (h *HUD) SetSprites(cross, hotbar, hotbarSel, heartFull, heartHalf, heartCont, foodFull, foodHalf uint32) { h.crossTex, h.hotbarTex, h.hotbarSel = cross, hotbar, hotbarSel @@ -172,10 +181,17 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { if !ok { return } - if uv, found := h.itemUV[def.Texture]; found && h.itemTex != 0 { + // 原版约定:block/* 图标在 blocks 图集,item/* 在 items 图集 + tex := h.itemTex + uv, found := h.itemUV[def.Texture] + if !found { + uv, found = h.blockUV[def.Texture] + tex = h.blockTex + } + if found && tex != 0 { r.pushQuad(quad{x: x, y: y, w: 16 * s, h: 16 * s, u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], - r: 1, g: 1, b: 1, a: 1, tex: h.itemTex}) + r: 1, g: 1, b: 1, a: 1, tex: tex}) } if stack.Count > 1 { r.DrawText(x+10*s, y+8*s, fmt.Sprintf("%d", stack.Count), 1*s, [4]float32{1, 1, 1, 1}) diff --git a/文档/场景/UI还原.md b/文档/场景/UI还原.md index be39007b..d934f1a3 100644 --- a/文档/场景/UI还原.md +++ b/文档/场景/UI还原.md @@ -109,6 +109,7 @@ - **初版默认素材**:嵌入一套原版风格 GUI 纹理(窗口框架、按钮、槽位、心/鸡腿/经验条、热键栏、图标、准星、标题 logo),随客户端 `go:embed` 嵌入(设计.md §1.2)。 - **素材包覆盖**:任意 GUI 纹理可用素材包(`type: "resource"`)覆盖 → 天然支持换肤(设计.md §1.3,投放 `mods/`)。 - GUI 纹理统一打包进 **GUI 纹理图集** 减少 draw call(渲染.md §4 同套路);字体图集单独管理。 +- **物品图标双图集**:`item/*` 图标取 items 图集;`block/*` 图标(方块物品,如木板/工作台)取 **blocks 图集**——原版 items 图集不收录 block 纹理,单图集会漏图标。 - 纹理规范:16px 基准、PNG、透明按 cutout/translucent 分类(设计.md §2.1)。 ## 8. 验收标准(截图对比清单) diff --git a/文档/场景/工作台与合成表.md b/文档/场景/工作台与合成表.md index 4ac6550e..2c25ed97 100644 --- a/文档/场景/工作台与合成表.md +++ b/文档/场景/工作台与合成表.md @@ -97,3 +97,4 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:配方模型、索引、匹配算法、工作台交互、防刷、踩坑清单 | — | +| (合成界面) | 客户端工作台 3×3 界面落地:右键命中工作台打开(TryOpenCrafting)、网格槽位与手持栈交换/合并(CraftingSwap)、输出格取产物消耗材料(CraftingTakeResult)、关闭退回材料(CloseCrafting);UI 规格按 UI还原.md §3.4(176×166 窗口 + 网格 (30,17) + 输出 (124,35));Shift 批量合成与配方书后置 | — | diff --git a/文档/索引.md b/文档/索引.md index d92f1452..eec8bab9 100644 --- a/文档/索引.md +++ b/文档/索引.md @@ -206,3 +206,4 @@ | (原生素材包+命名) | 素材包原生 pack.mcmeta 格式直接支持(设计.md §1.2/§1.3);资源路径对齐原版命名;项目名「年糕历险记」+ NSIS 中文安装(构建与发布.md §8) | — | | (实现里程碑) | M0–M3、M5 完成,M3.5/M4/M6 进行中:GL 客户端构建运行、Mod 热更新、多人同步 MVP、中文 UI(详见 路线图.md 状态) | — | | (客户端渲染修复) | 渲染.md 新增 §4.1 植被染色(原版灰度草/叶图集染色)、§5.2 覆盖层渲染(破坏裂纹/手持方块)、§5.3 实体广告牌;修复相机视图矩阵俯仰符号翻转(视角不跟随鼠标根因);HUD 换原版心/鸡腿/热键栏精灵 | — | +| (工作台界面) | 客户端工作台 3×3 合成界面落地(工作台与合成表.md §5):右键命中工作台打开、槽位交换/合并、输出格合成、关闭退回材料;UI还原.md §7 补物品图标双图集规则(block/* 取 blocks 图集) | — | diff --git a/文档/路线图.md b/文档/路线图.md index eb53061e..24097f54 100644 --- a/文档/路线图.md +++ b/文档/路线图.md @@ -11,10 +11,10 @@ | M1 世界 | 区块、确定性生成、光照、存档 | 已完成 | | M2 渲染 | 网格、图集、透明排序、视锥剔除 | 已完成 | | M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(破坏裂纹叠加与手持方块渲染已补齐;合成台/熔炉 GUI 后置) | -| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据/客户端实体渲染已完成;村民交易与动物繁殖后置) | +| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染已完成;村民交易与动物繁殖后置) | | M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) | | M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 | -| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/UI 精灵与主菜单/光影包/存档/安装脚本已完成;合成台与背包界面、性能池化与安装包构建后置) | +| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/UI 精灵与主菜单/光影包/存档/安装脚本/合成台界面已完成;背包与熔炉界面、性能池化与安装包构建后置) | ## M0 骨架