From 471cb131ebf90c937f9a942a3298199329b71748 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 22:45:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E7=89=A9=E5=93=81=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E3=80=81=E8=83=8C=E5=8C=85=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=A1=A8=E3=80=81=E9=85=8D=E6=96=B9=E7=B4=A2=E5=BC=95=E5=8C=96?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=EF=BC=88=E5=90=AB=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/inventory/inventory.go | 122 ++++++++++++++ internal/inventory/inventory_test.go | 69 ++++++++ internal/item/item.go | 75 +++++++++ internal/raycast/raycast_test.go | 2 +- internal/recipe/recipe.go | 237 +++++++++++++++++++++++++++ internal/recipe/recipe_test.go | 61 +++++++ 6 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 internal/inventory/inventory.go create mode 100644 internal/inventory/inventory_test.go create mode 100644 internal/item/item.go create mode 100644 internal/recipe/recipe.go create mode 100644 internal/recipe/recipe_test.go diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go new file mode 100644 index 00000000..eb69575c --- /dev/null +++ b/internal/inventory/inventory.go @@ -0,0 +1,122 @@ +// Package inventory 背包模型与规则表(场景/物品与背包.md §3、UI与输入.md §4)。 +// +// 规则表(CanPlace / CanMerge / Merge)为**单一来源**:客户端与服务器共用同一实现, +// 防双端不一致(多人同步.md §5)。 +package inventory + +import ( + "mc/internal/item" +) + +// 背包槽位常量(UI还原.md §3.3:27 主区 + 9 热键栏)。 +const ( + HotbarSize = 9 // 热键栏槽数 + MainSize = 27 // 主物品区槽数 + TotalSize = HotbarSize + MainSize +) + +// Inventory 背包(主线程所有权,无需锁——架构.md §4)。 +type Inventory struct { + Slots []item.Stack // 36 槽 + Selected int // 热键栏选中索引 0–8 + reg *item.Registry +} + +// New 创建空背包。 +func New(reg *item.Registry) *Inventory { + return &Inventory{Slots: make([]item.Stack, TotalSize), reg: reg} +} + +// CanMerge 两栈是否可合并:同名、同耐久、数量未满(物品与背包.md §3)。 +func CanMerge(a, b item.Stack) bool { + if a.Empty() || b.Empty() || a.Name != b.Name { + return false + } + if a.Durability != b.Durability { + return false + } + return int(a.Count)+int(b.Count) <= 127 +} + +// Merge 合并两栈:返回合并结果与剩余量(调用方负责写回槽位)。 +func Merge(limit uint8, a, b item.Stack) (item.Stack, item.Stack) { + total := int(a.Count) + int(b.Count) + if total <= int(limit) { + a.Count = uint8(total) + return a, item.Stack{} + } + a.Count = limit + b.Count = uint8(total - int(limit)) + return a, b +} + +// Add 把物品栈放入背包(先热键栏选中槽,再顺序找空/可合并槽),返回剩余。 +func (inv *Inventory) Add(s item.Stack) item.Stack { + if s.Empty() { + return s + } + limit := s.Limit(inv.reg) + // 第一遍:合并到已有同类 + for i := range inv.Slots { + if inv.Slots[i].Empty() { + continue + } + if inv.Slots[i].Name == s.Name && inv.Slots[i].Durability == s.Durability && inv.Slots[i].Count < limit { + merged, remain := Merge(limit, inv.Slots[i], s) + inv.Slots[i] = merged + s = remain + if s.Empty() { + return s + } + } + } + // 第二遍:放入空槽 + for i := range inv.Slots { + if inv.Slots[i].Empty() { + inv.Slots[i] = s + return item.Stack{} + } + } + return s // 放不下:返回剩余 +} + +// Remove 从背包移除指定物品 count 个;不足返回 false。 +func (inv *Inventory) Remove(name string, count int) bool { + if count <= 0 { + return false + } + have := 0 + for i := range inv.Slots { + if inv.Slots[i].Name == name { + have += int(inv.Slots[i].Count) + } + } + if have < count { + return false + } + left := count + for i := range inv.Slots { + if inv.Slots[i].Name != name { + continue + } + if int(inv.Slots[i].Count) <= left { + left -= int(inv.Slots[i].Count) + inv.Slots[i] = item.Stack{} + } else { + inv.Slots[i].Count -= uint8(left) + left = 0 + } + if left == 0 { + return true + } + } + return true +} + +// SelectedStack 返回选中槽(只读)。 +func (inv *Inventory) SelectedStack() item.Stack { + if inv.Selected < 0 || inv.Selected >= HotbarSize { + return item.Stack{} + } + return inv.Slots[inv.Selected] +} diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go new file mode 100644 index 00000000..7ddcea57 --- /dev/null +++ b/internal/inventory/inventory_test.go @@ -0,0 +1,69 @@ +package inventory + +import ( + "path/filepath" + "testing" + + "mc/internal/item" +) + +// loadItemReg 加载物品注册表。 +func loadItemReg(t *testing.T) *item.Registry { + t.Helper() + r, err := item.Load(filepath.Join("..", "..", "assets", "config", "items.json")) + if err != nil { + t.Fatalf("加载物品注册表失败: %v", err) + } + return r +} + +// TestMerge 验证合并规则(物品与背包.md §3 规则表)。 +func TestMerge(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + + // 放入 30 个木板 → 合并到 34 个 + rem := inv.Add(item.Stack{Name: "oak_planks", Count: 30}) + if !rem.Empty() { + t.Fatal("30 个木板应全部放入") + } + rem = inv.Add(item.Stack{Name: "oak_planks", Count: 34}) + if !rem.Empty() { + t.Fatal("34 个木板应合并入同一槽") + } + // 64 上限:再放 10 应进入新槽(槽 0 已满 64) + rem = inv.Add(item.Stack{Name: "oak_planks", Count: 10}) + if !rem.Empty() { + t.Fatalf("剩余期望空,实际 %d", rem.Count) + } + if inv.Slots[1].Name != "oak_planks" || inv.Slots[1].Count != 10 { + t.Fatalf("槽 1 期望 10 个木板,实际 %+v", inv.Slots[1]) + } +} + +// TestRemove 验证取出物品。 +func TestRemove(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + inv.Add(item.Stack{Name: "stick", Count: 16}) + if !inv.Remove("stick", 5) { + t.Fatal("应能取出 5 个") + } + if inv.Remove("stick", 20) { + t.Fatal("不足 20 个不应成功") + } + if !inv.Remove("stick", 11) { + t.Fatal("应能取出剩余 11 个") + } +} + +// TestSelected 验证选中槽。 +func TestSelected(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + inv.Slots[3] = item.Stack{Name: "torch", Count: 8} + inv.Selected = 3 + if s := inv.SelectedStack(); s.Name != "torch" || s.Count != 8 { + t.Fatalf("选中槽异常: %+v", s) + } +} diff --git a/internal/item/item.go b/internal/item/item.go new file mode 100644 index 00000000..127bdb2c --- /dev/null +++ b/internal/item/item.go @@ -0,0 +1,75 @@ +// Package item 物品注册表与物品栈(场景/物品与背包.md §2)。 +package item + +import ( + "encoding/json" + "fmt" + "os" +) + +// Def 物品定义(items.json 条目)。 +type Def struct { + Name string `json:"-"` + Stack int `json:"stack"` + Texture string `json:"texture"` + Place string `json:"place"` // 可放置的方块 ID + Tool string `json:"tool"` // pickaxe / shovel / axe + Tier int `json:"tier"` // 工具等级门槛(挖矿与矿物.md §3) + Speed float64 `json:"speed"` // 挖掘速度倍率 + Durability int `json:"durability"` +} + +// Registry 物品注册表。 +type Registry struct { + byName map[string]*Def +} + +// Load 解析 items.json。 +func Load(path string) (*Registry, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("item.Load %s: %w", path, err) + } + var doc struct { + Items map[string]Def `json:"items"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("item.Load %s: %w", path, err) + } + r := &Registry{byName: make(map[string]*Def, len(doc.Items))} + for name, d := range doc.Items { + d.Name = name + if d.Stack <= 0 { + d.Stack = 64 + } + cp := d + r.byName[name] = &cp + } + return r, nil +} + +// Get 按名称取定义。 +func (r *Registry) Get(name string) (*Def, bool) { + d, ok := r.byName[name] + return d, ok +} + +// Stack 物品栈:空栈 = Name == "" 且 Count == 0(物品与背包.md §2:禁止 nil 栈)。 +type Stack struct { + Name string + Count uint8 + Durability int32 +} + +// Empty 是否空栈。 +func (s Stack) Empty() bool { return s.Name == "" || s.Count == 0 } + +// Limit 堆叠上限。 +func (s Stack) Limit(reg *Registry) uint8 { + if d, ok := reg.Get(s.Name); ok { + if d.Stack > 0 && d.Stack < 255 { + return uint8(d.Stack) + } + } + return 64 +} diff --git a/internal/raycast/raycast_test.go b/internal/raycast/raycast_test.go index 93913323..ba2de522 100644 --- a/internal/raycast/raycast_test.go +++ b/internal/raycast/raycast_test.go @@ -57,4 +57,4 @@ func TestCastNegative(t *testing.T) { if h.X != -1 || h.Z != -1 { t.Fatalf("负坐标命中异常: %+v", h) } -} \ No newline at end of file +} diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go new file mode 100644 index 00000000..28c26ab9 --- /dev/null +++ b/internal/recipe/recipe.go @@ -0,0 +1,237 @@ +// Package recipe 配方注册表与匹配(场景/工作台与合成表.md §2–§4)。 +// +// 索引化匹配:启动时按输入物品建索引,运行时不做全表扫描。 +package recipe + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +// Result 配方产物。 +type Result struct { + Item string `json:"item"` + Count int `json:"count"` +} + +// Recipe 配方条目(assets/config/recipes.json)。 +type Recipe struct { + Type string `json:"type"` // crafting_shaped / crafting_shapeless / smelting + Pattern []string `json:"pattern"` + Key map[string]string `json:"key"` + Ingredients []string `json:"ingredients"` + Input string `json:"input"` + Result Result `json:"result"` +} + +// Registry 配方注册表(含索引)。 +type Registry struct { + shaped []*Recipe + shapeless []*Recipe + smelting map[string]*Recipe + index map[string][]*Recipe // 输入物品 → 候选配方(§3 索引) +} + +// Load 解析 recipes.json 并构建索引。 +func Load(path string) (*Registry, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("recipe.Load %s: %w", path, err) + } + var doc struct { + Recipes []Recipe `json:"recipes"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("recipe.Load %s: %w", path, err) + } + r := &Registry{smelting: make(map[string]*Recipe), index: make(map[string][]*Recipe)} + for i := range doc.Recipes { + rc := &doc.Recipes[i] + switch rc.Type { + case "crafting_shaped": + r.shaped = append(r.shaped, rc) + for _, k := range rc.Key { + r.index[k] = append(r.index[k], rc) + } + case "crafting_shapeless": + r.shapeless = append(r.shapeless, rc) + for _, ing := range rc.Ingredients { + r.index[ing] = append(r.index[ing], rc) + } + case "smelting": + r.smelting[rc.Input] = rc + default: + return nil, fmt.Errorf("recipe.Load: 未知配方类型 %q", rc.Type) + } + } + return r, nil +} + +// MatchSmelting 熔炼匹配(单输入键)。 +func (r *Registry) MatchSmelting(input string) (*Recipe, bool) { + rc, ok := r.smelting[input] + return rc, ok +} + +// MatchShapeless 无序匹配:多重集比对(工作台与合成表.md §4.2 计数不能只判包含)。 +func (r *Registry) MatchShapeless(counts map[string]int) (*Recipe, bool) { + for _, rc := range r.shapeless { + if multisetEq(counts, rc.Ingredients) { + return rc, true + } + } + return nil, false +} + +// multisetEq 输入多重集与配方配料多重集是否完全一致。 +func multisetEq(counts map[string]int, ingredients []string) bool { + if len(counts) != len(ingredients) { + return false + } + want := make(map[string]int, len(ingredients)) + for _, ing := range ingredients { + want[ing]++ + } + for k, v := range want { + if counts[k] != v { + return false + } + } + return true +} + +// MatchShaped 有序匹配:pattern 归一化后与 3×3 网格逐偏移比对(工作台与合成表.md §4.1)。 +// grid 按行主序:grid[y][x](y=0 为最上行)。 +func (r *Registry) MatchShaped(grid [3][3]string) (*Recipe, bool) { + // 网格归一化:去掉空行/空列 + g := normalizeGrid(grid) + for _, rc := range r.shaped { + if shapedMatches(rc, g) { + return rc, true + } + } + return nil, false +} + +// gridT 归一化后的网格(行 × 列,均为非空)。 +type gridT struct { + w, h int + cell [][]string +} + +// normalizeGrid 去除全空行与全空列。 +func normalizeGrid(g [3][3]string) gridT { + var out gridT + for y := 0; y < 3; y++ { + var row []string + for x := 0; x < 3; x++ { + row = append(row, g[y][x]) + } + out.cell = append(out.cell, row) + } + // 裁空行 + var rows [][]string + for _, row := range out.cell { + nonEmpty := false + for _, c := range row { + if c != "" { + nonEmpty = true + } + } + if nonEmpty { + rows = append(rows, row) + } + } + if len(rows) == 0 { + return gridT{} + } + out.cell = rows + out.h = len(rows) + // 裁空列 + minCol, maxCol := 3, -1 + for x := 0; x < 3; x++ { + for y := 0; y < out.h; y++ { + if out.cell[y][x] != "" { + if x < minCol { + minCol = x + } + if x > maxCol { + maxCol = x + } + } + } + } + if minCol > maxCol { + return gridT{} + } + var trimmed [][]string + for y := 0; y < out.h; y++ { + trimmed = append(trimmed, out.cell[y][minCol:maxCol+1]) + } + out.cell = trimmed + out.w = maxCol - minCol + 1 + return out +} + +// shapedMatches 配方 pattern(同样归一化)与网格全等。 +func shapedMatches(rc *Recipe, g gridT) bool { + if rc == nil || len(rc.Pattern) == 0 { + return false + } + if len(rc.Pattern) != g.h { + return false + } + for y, row := range rc.Pattern { + if len(row) != g.w { + return false + } + for x := 0; x < g.w; x++ { + sym := string(row[x]) + if sym == " " { + if g.cell[y][x] != "" { + return false + } + continue + } + if g.cell[y][x] != rc.Key[sym] { + return false + } + } + } + return true +} + +// GridFromStrings 把行主序字符串切片转网格(" X " 表示一行)。 +func GridFromStrings(rows []string) [3][3]string { + var g [3][3]string + for y := 0; y < 3 && y < len(rows); y++ { + row := rows[y] + for x := 0; x < 3 && x < len(row); x++ { + if row[x] != ' ' { + g[y][x] = string(row[x]) + } + } + } + return g +} + +// KeysSorted 返回已排序键列表(工具函数)。 +func KeysSorted(m map[string]int) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} + +// TrimNS 去掉命名空间前缀(minecraft:stick → stick)。 +func TrimNS(s string) string { + if i := strings.IndexByte(s, ':'); i >= 0 { + return s[i+1:] + } + return s +} diff --git a/internal/recipe/recipe_test.go b/internal/recipe/recipe_test.go new file mode 100644 index 00000000..91eb5d74 --- /dev/null +++ b/internal/recipe/recipe_test.go @@ -0,0 +1,61 @@ +package recipe + +import ( + "path/filepath" + "testing" +) + +// loadRecipes 加载配方注册表。 +func loadRecipes(t *testing.T) *Registry { + t.Helper() + r, err := Load(filepath.Join("..", "..", "assets", "config", "recipes.json")) + if err != nil { + t.Fatalf("加载配方失败: %v", err) + } + return r +} + +// TestMatchShaped 有序配方:火把 = 煤+木棍(工作台与合成表.md §4.1)。 +// 注意:网格内容为物品名(不是 pattern 符号)。 +func TestMatchShaped(t *testing.T) { + r := loadRecipes(t) + var g [3][3]string + g[0][0] = "coal" + g[1][0] = "stick" + rc, ok := r.MatchShaped(g) + if !ok || rc.Result.Item != "torch" { + t.Fatalf("火把配方匹配失败: %+v", rc) + } + // 图案外空格容错:整体右移一列也能匹配(归一化) + var g2 [3][3]string + g2[0][1] = "coal" + g2[1][1] = "stick" + if _, ok := r.MatchShaped(g2); !ok { + t.Fatal("归一化后应仍匹配火把配方") + } +} + +// TestMatchShapeless 无序配方:木板(多重集比对,§4.2)。 +func TestMatchShapeless(t *testing.T) { + r := loadRecipes(t) + rc, ok := r.MatchShapeless(map[string]int{"oak_log": 1}) + if !ok || rc.Result.Item != "oak_planks" { + t.Fatalf("木板配方匹配失败: %+v", rc) + } + // 数量不符不匹配 + if _, ok := r.MatchShapeless(map[string]int{"oak_log": 2}); ok { + t.Fatal("2 个原木不应匹配木板配方") + } +} + +// TestMatchSmelting 熔炼配方:铁矿石 → 铁锭(§4.3)。 +func TestMatchSmelting(t *testing.T) { + r := loadRecipes(t) + rc, ok := r.MatchSmelting("iron_ore") + if !ok || rc.Result.Item != "iron_ingot" { + t.Fatalf("熔炼配方匹配失败: %+v", rc) + } + if _, ok := r.MatchSmelting("not_exist"); ok { + t.Fatal("不存在的熔炼输入不应匹配") + } +}