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

@@ -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 {

View File

@@ -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)
}
}

134
internal/ui/crafting.go Normal file
View File

@@ -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})
}
}

View File

@@ -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})