feat(game): 物品注册表、背包规则表、配方索引化匹配(含测试)
This commit is contained in:
122
internal/inventory/inventory.go
Normal file
122
internal/inventory/inventory.go
Normal 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]
|
||||
}
|
||||
69
internal/inventory/inventory_test.go
Normal file
69
internal/inventory/inventory_test.go
Normal 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
75
internal/item/item.go
Normal 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
|
||||
}
|
||||
@@ -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
237
internal/recipe/recipe.go
Normal 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
|
||||
}
|
||||
61
internal/recipe/recipe_test.go
Normal file
61
internal/recipe/recipe_test.go
Normal 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("不存在的熔炼输入不应匹配")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user