feat(game): 物品注册表、背包规则表、配方索引化匹配(含测试)

This commit is contained in:
NianGao Dev
2026-08-15 22:45:13 +08:00
parent f1e0f6cb8f
commit 471cb131eb
6 changed files with 565 additions and 1 deletions

View File

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

View File

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

75
internal/item/item.go Normal file
View File

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

View File

@@ -57,4 +57,4 @@ func TestCastNegative(t *testing.T) {
if h.X != -1 || h.Z != -1 {
t.Fatalf("负坐标命中异常: %+v", h)
}
}
}

237
internal/recipe/recipe.go Normal file
View File

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

View File

@@ -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("不存在的熔炼输入不应匹配")
}
}