Files
ngzz-mc/internal/game/game_test.go
NianGao Dev 768a9b8096 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 全链路截图验证
2026-08-16 10:23:01 +08:00

276 lines
8.1 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.
package game
import (
"math"
"mc/internal/blockentity"
"path/filepath"
"testing"
"time"
"mc/internal/block"
"mc/internal/camera"
"mc/internal/inventory"
"mc/internal/item"
"mc/internal/physics"
"mc/internal/recipe"
"mc/internal/world"
"mc/internal/worldgen"
)
// newTestSession 构造真实世界上的游戏会话(等待区块生成)。
func newTestSession(t *testing.T) *Session {
t.Helper()
reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json"))
if err != nil {
t.Fatalf("加载方块注册表失败: %v", err)
}
items, err := item.Load(filepath.Join("..", "..", "assets", "config", "items.json"))
if err != nil {
t.Fatalf("加载物品注册表失败: %v", err)
}
recipes, err := recipe.Load(filepath.Join("..", "..", "assets", "config", "recipes.json"))
if err != nil {
t.Fatalf("加载配方失败: %v", err)
}
gen, err := worldgen.New(reg, 42, 0.005, 0.01, 0.1)
if err != nil {
t.Fatalf("创建生成器失败: %v", err)
}
w, err := world.New(reg, gen, 3)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
t.Cleanup(w.Close)
// 等待 (0,0) 区块
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
w.Update(8, 64, 8, 8*time.Millisecond)
if w.Block(8, 0, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
// 找地表
var surf int32
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+1), 8.5)
cam := camera.New(p.Eye(), 0, -math.Pi/2, 70)
inv := inventory.New(items)
inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1} // 选中的工具
inv.Slots[1] = item.Stack{Name: "oak_planks", Count: 10}
sess := NewSession(w, reg, items, recipes, p, inv, cam)
// 测试环境禁用自然刷怪(僵尸生成依赖区块加载时序,会造成 flake)
sess.spawnTimer = 1e9
return sess
}
// TestBreakAndPlace 完整闭环:破坏地表 → 放木板(方块交互与动画.md §3、§6)。
func TestBreakAndPlace(t *testing.T) {
s := newTestSession(t)
// 破坏脚下地表的草方块(向下看)
s.BeginBreak()
if !s.breaking {
t.Fatal("应锁定破坏目标")
}
tx, ty, tz := s.BreakTarget().X, s.BreakTarget().Y, s.BreakTarget().Z
steps := 0
for s.breaking && steps < 600 {
s.Tick(1.0 / 20)
steps++
}
if s.breaking {
t.Fatal("钻石镐 600 tick 未破坏草方块")
}
if s.World.Block(tx, ty, tz) != block.Air {
t.Fatal("破坏后应为空气")
}
// 放置木板(切换到槽 1):先抬升玩家脱离坑位(否则放置位与玩家 AABB 重叠会被正确拒绝)
s.Inv.Selected = 1
s.Player.Pos[1] += 2
s.Tick(1.0 / 20) // 相机跟随
s.PlaceSelected()
if name := s.Reg.Name(s.World.Block(tx, ty, tz).ID()); name != "oak_planks" {
t.Fatalf("放置后应为橡木板,实际 %s", name)
}
if s.Inv.Slots[1].Count != 9 {
t.Fatalf("木板应剩 9,实际 %d", s.Inv.Slots[1].Count)
}
}
// TestBreakSpeed 验证工具速度公式(挖矿与矿物.md §3)。
func TestBreakSpeed(t *testing.T) {
s := newTestSession(t)
stone := s.Reg.Get(mustID(t, s.Reg, "stone"))
// 空手
if got := s.breakSpeed(stone, item.Stack{}); got < 1/1.5-0.01 || got > 1/1.5+0.01 {
t.Fatalf("空手速度期望约 1/1.5,实际 %v", got)
}
// 钻石镐(speed 8;Count 必须 ≥1 才是非空栈)
withTool := item.Stack{Name: "diamond_pickaxe", Count: 1}
if got := s.breakSpeed(stone, withTool); got < 8/1.5-0.01 || got > 8/1.5+0.01 {
t.Fatalf("钻石镐速度期望约 8/1.5,实际 %v", got)
}
}
// TestFurnaceFlow 放置熔炉 → 投料 → 烧炼产出(物品与背包.md §6 会话接线)。
func TestFurnaceFlow(t *testing.T) {
s := newTestSession(t)
// 给玩家熔炉 + 煤炭 + 铁矿石
s.Inv.Slots[1] = item.Stack{Name: "furnace", Count: 1}
s.Inv.Slots[2] = item.Stack{Name: "coal", Count: 2}
s.Inv.Slots[3] = item.Stack{Name: "iron_ore", Count: 2}
s.Inv.Selected = 1
// 放置熔炉(向下看,站在地表)
s.BeginBreak()
for s.breaking {
s.Tick(1.0 / 20)
}
s.Player.Pos[1] += 2
s.Tick(1.0 / 20)
s.PlaceSelected()
// 找到熔炉方块实体
var furnace *blockentity.Entity
for _, be := range s.BlockEntities {
if be.Kind == blockentity.KindFurnace {
furnace = be
}
}
if furnace == nil {
t.Fatal("未创建熔炉方块实体")
}
// 投料
furnace.Insert(blockentity.SlotFuel, item.Stack{Name: "coal", Count: 1}, s.Items)
furnace.Insert(blockentity.SlotInput, item.Stack{Name: "iron_ore", Count: 1}, s.Items)
// 烧炼 200 tick(10 秒)
for i := 0; i < 300; i++ {
s.Tick(1.0 / 20)
}
if furnace.Slots[blockentity.SlotOutput].Name != "iron_ingot" {
t.Fatalf("输出应为铁锭,实际 %+v", furnace.Slots[blockentity.SlotOutput])
}
}
// mustID 取方块 ID(测试辅助)。
func mustID(t *testing.T, reg *block.Registry, name string) uint16 {
t.Helper()
id, ok := reg.ID(name)
if !ok {
t.Fatalf("方块 %q 未注册", name)
}
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)
}
}