diff --git a/assets/config/villager_trades.json b/assets/config/villager_trades.json new file mode 100644 index 00000000..ff80966d --- /dev/null +++ b/assets/config/villager_trades.json @@ -0,0 +1,20 @@ +{ + "professions": { + "farmer": [ + { "buy": "wheat", "buy_n": 20, "sell": "rotten_flesh", "sell_n": 1 }, + { "buy": "wheat", "buy_n": 3, "sell": "beef", "sell_n": 1 } + ], + "builder": [ + { "buy": "oak_planks", "buy_n": 16, "sell": "stick", "sell_n": 4 }, + { "buy": "cobblestone", "buy_n": 8, "sell": "oak_planks", "sell_n": 4 } + ], + "blacksmith": [ + { "buy": "coal", "buy_n": 8, "sell": "iron_ingot", "sell_n": 1 }, + { "buy": "iron_ingot", "buy_n": 2, "sell": "diamond", "sell_n": 1 } + ], + "trader": [ + { "buy": "rotten_flesh", "buy_n": 8, "sell": "bone", "sell_n": 2 }, + { "buy": "feather", "buy_n": 4, "sell": "egg", "sell_n": 1 } + ] + } +} diff --git a/internal/entity/breeding.go b/internal/entity/breeding.go new file mode 100644 index 00000000..06a4e2db --- /dev/null +++ b/internal/entity/breeding.go @@ -0,0 +1,131 @@ +// 动物繁殖与村民交易:动物体系.md §3、村民系统.md §7 的核心玩法逻辑。 +package entity + +import ( + "encoding/json" + "fmt" + "os" +) + +// 动物繁殖参数(动物体系.md §3)。 +const ( + breedCooldown = 60 * 20 // 繁殖冷却 60 秒(tick) + babyGrowTime = 20 * 60 * 20 // 幼崽成长 20 分钟(tick) + perChunkAnimal = 10 // 每区块同种动物上限 +) + +// Animal 动物附加数据(动物体系.md §2)。 +type Animal struct { + BreedCooldown int // 繁殖冷却剩余 tick + GrowthTicks int // 幼崽成长剩余(0 = 成年) + ParentA ID // 近亲繁殖记录(后期) + ParentB ID +} + +// Breed 繁殖两只同种成年动物 → 幼崽(动物体系.md §3:冷却与上限控制)。 +// 返回新幼崽;冷却未到/上限达到返回 nil。 +func (m *Manager) Breed(a, b *Entity, w WorldView) *Entity { + if a.Kind != KindAnimal || b.Kind != KindAnimal || a.Dead || b.Dead { + return nil + } + ad, ok1 := a.Data.(*Animal) + bd, ok2 := b.Data.(*Animal) + if !ok1 { + ad = &Animal{} + a.Data = ad + } + if !ok2 { + bd = &Animal{} + b.Data = bd + } + if ad.BreedCooldown > 0 || bd.BreedCooldown > 0 || ad.GrowthTicks > 0 || bd.GrowthTicks > 0 { + return nil + } + // 每区块数量控制(防无限繁殖卡服,动物体系.md §7 踩坑) + if m.countNear(a, perChunkAnimal) >= perChunkAnimal { + return nil + } + ad.BreedCooldown = breedCooldown + bd.BreedCooldown = breedCooldown + baby := m.Spawn(KindAnimal, a.Pos[0], a.Pos[1], a.Pos[2]) + baby.Data = &Animal{GrowthTicks: babyGrowTime, ParentA: a.ID, ParentB: b.ID} + baby.Health = 10 // 幼崽血量 + return baby +} + +// TickAnimal 动物成长/冷却计时(调用方每 tick 对动物实体调用)。 +func TickAnimal(e *Entity) { + if e.Kind != KindAnimal { + return + } + d, ok := e.Data.(*Animal) + if !ok { + return + } + if d.BreedCooldown > 0 { + d.BreedCooldown-- + } + if d.GrowthTicks > 0 { + d.GrowthTicks-- + if d.GrowthTicks == 0 { + e.Health = 20 // 成年 + } + } +} + +// countNear 统计同种动物数量(以 a 所在区块计,简化按距离 16)。 +func (m *Manager) countNear(a *Entity, _ int) int { + n := 0 + for _, e := range m.List() { + if e.Kind == KindAnimal { + dx, dz := e.Pos[0]-a.Pos[0], e.Pos[2]-a.Pos[2] + if dx*dx+dz*dz < 16*16 { + n++ + } + } + } + return n +} + +// ---- 村民交易(村民系统.md §7:数据驱动交易表)---- + +// TradeOffer 一条交易(收购 → 出售)。 +type TradeOffer struct { + Buy string `json:"buy"` // 收购物品 + BuyN int `json:"buy_n"` // 收购数量 + Sell string `json:"sell"` // 出售物品 + SellN int `json:"sell_n"` // 出售数量 + Uses int `json:"uses"` // 已交易次数(涨价基础) +} + +// TradeTable 交易表(按职业)。 +type TradeTable struct { + byProfession map[uint8][]TradeOffer +} + +// LoadTrades 解析交易表 JSON。 +func LoadTrades(path string) (*TradeTable, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("entity.LoadTrades: %w", err) + } + var doc struct { + Professions map[string][]TradeOffer `json:"professions"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("entity.LoadTrades: %w", err) + } + t := &TradeTable{byProfession: make(map[uint8][]TradeOffer)} + names := map[string]uint8{"farmer": 0, "builder": 1, "blacksmith": 2, "trader": 3} + for name, offers := range doc.Professions { + if id, ok := names[name]; ok { + t.byProfession[id] = offers + } + } + return t, nil +} + +// OffersFor 返回某职业的交易列表。 +func (t *TradeTable) OffersFor(profession uint8) []TradeOffer { + return t.byProfession[profession] +} diff --git a/internal/entity/breeding_test.go b/internal/entity/breeding_test.go new file mode 100644 index 00000000..20429daf --- /dev/null +++ b/internal/entity/breeding_test.go @@ -0,0 +1,66 @@ +package entity + +import ( + "path/filepath" + "testing" +) + +// TestBreed 动物繁殖:成年+冷却完成 → 幼崽;冷却中拒绝;上限拒绝(动物体系.md §3)。 +func TestBreed(t *testing.T) { + m := NewManager() + w := flatWorld{} + a := m.Spawn(KindAnimal, 0.5, 63, 0.5) + a.Data = &Animal{} + b := m.Spawn(KindAnimal, 1.5, 63, 0.5) + b.Data = &Animal{} + + baby := m.Breed(a, b, w) + if baby == nil { + t.Fatal("应繁殖出幼崽") + } + if d, ok := baby.Data.(*Animal); !ok || d.GrowthTicks == 0 { + t.Fatalf("幼崽数据异常: %+v", baby.Data) + } + // 冷却中:拒绝 + if m.Breed(a, b, w) != nil { + t.Fatal("冷却中不应繁殖") + } + // 冷却恢复后可再次繁殖 + ad := a.Data.(*Animal) + ad.BreedCooldown = 0 + bd := b.Data.(*Animal) + bd.BreedCooldown = 0 + if m.Breed(a, b, w) == nil { + t.Fatal("冷却结束后应能繁殖") + } +} + +// TestBreedCap 每区块上限(动物体系.md §7 防无限繁殖)。 +func TestBreedCap(t *testing.T) { + m := NewManager() + w := flatWorld{} + a := m.Spawn(KindAnimal, 0.5, 63, 0.5) + a.Data = &Animal{} + b := m.Spawn(KindAnimal, 1.5, 63, 0.5) + b.Data = &Animal{} + for i := 0; i < 12; i++ { + m.Spawn(KindAnimal, 2.5, 63, 2.5+float64(i)) + } + if m.Breed(a, b, w) != nil { + t.Fatal("超上限不应繁殖") + } +} + +// TestLoadTrades 交易表解析与职业查询(村民系统.md §7)。 +func TestLoadTrades(t *testing.T) { + tbl, err := LoadTrades(filepath.Join("..", "..", "assets", "config", "villager_trades.json")) + if err != nil { + t.Fatalf("加载交易表失败: %v", err) + } + if offers := tbl.OffersFor(0); len(offers) != 2 { + t.Fatalf("农民交易期望 2 条,实际 %d", len(offers)) + } + if offers := tbl.OffersFor(2); len(offers) != 2 || offers[0].Buy != "coal" { + t.Fatalf("铁匠交易异常: %+v", offers) + } +} diff --git a/internal/game/game.go b/internal/game/game.go index fc00bcbc..29a40765 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -34,10 +34,11 @@ type Session struct { Entities *entity.Manager BlockEntities map[[3]int32]*blockentity.Entity - // 自然生成计时(僵尸与敌对生物.md §5:夜晚/黑暗处刷怪) - spawnTimer float64 - damageIn float64 // 玩家受击冷却 - rng *rand.Rand + // 自然生成计时(僵尸与敌对生物.md §5、动物体系.md §6) + spawnTimer float64 + animalTimer float64 + damageIn float64 // 玩家受击冷却 + rng *rand.Rand // 破坏进度状态(方块交互与动画.md §3) breakTarget raycast.Hit @@ -84,6 +85,8 @@ func (s *Session) Tick(dt float64) { } // 夜间刷僵尸(僵尸与敌对生物.md §5:黑暗处、距玩家 24 格、限频) s.spawnZombies(dt) + // 白天刷动物(动物体系.md §6:草面、亮度≥9、距玩家 16–32、限频) + s.spawnAnimals(dt) // 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒) s.applyZombieDamage(dt) // 相机跟随玩家眼睛 @@ -278,6 +281,49 @@ func (s *Session) spawnZombies(dt float64) { } } +// spawnAnimals 白天刷动物(动物体系.md §6:草面、天空光 ≥9、距玩家 16–32、每 10 秒一只)。 +func (s *Session) spawnAnimals(dt float64) { + s.animalTimer -= dt + if s.animalTimer > 0 { + return + } + s.animalTimer = 10.0 + for i := 0; i < 16; i++ { + ang := s.rng.Float64() * 2 * math.Pi + dist := 16 + s.rng.Float64()*16 + x := int32(s.Player.Pos[0] + math.Cos(ang)*dist) + z := int32(s.Player.Pos[2] + math.Sin(ang)*dist) + var y int32 + for y = 255; y > 0; y-- { + if s.World.Block(x, y, z) != block.Air { + break + } + } + if s.World.Block(x, y+1, z) != block.Air { + continue + } + // 只在草面上且白天(天空光 ≥9) + if s.World.Block(x, y, z).ID() != s.grassID() { + continue + } + if s.World.SkyLight(x, y+1, z) < 9 { + continue + } + e := s.Entities.Spawn(entity.KindAnimal, float64(x)+0.5, float64(y+1), float64(z)+0.5) + e.Data = &entity.Animal{} + return + } +} + +// grassID 草方块 ID(注册表查询缓存值)。 +func (s *Session) grassID() uint16 { + id, ok := s.Reg.ID("grass_block") + if !ok { + return 0 + } + return id +} + // applyZombieDamage 僵尸近身伤害(僵尸与敌对生物.md §3:约 3 点/秒)。 func (s *Session) applyZombieDamage(dt float64) { if s.Health <= 0 { diff --git a/文档/构建与发布.md b/文档/构建与发布.md index 0ad5e01d..57312e6e 100644 --- a/文档/构建与发布.md +++ b/文档/构建与发布.md @@ -75,11 +75,13 @@ mc-v1.2.0/ ## 8. Windows 安装包(年糕历险记 · 中文安装) -- 产品名称:**年糕历险记**(安装包文件名 `年糕历险记-Setup-v1.0.0.exe`;产品、快捷方式、卸载项同名)。 -- 安装器:NSIS(中文安装界面),`scripts/installer.nsi`(含 简体中文 语言、安装目录选择、桌面/开始菜单快捷方式、卸载器)。 -- 安装内容:`mc-client.exe`、`assets/`(未嵌入时)、`config/`、`mods/`(空目录 + README)、`resourcepacks/`(默认素材包)、`worlds/`。 -- 中文要求:安装器文案全中文;游戏内 `lang/zh_cn.json` 随安装包提供;缺失翻译键回退 en_us。 -- 安装包构建仅在**发布时**执行(M6,构建与发布.md §6 发布清单第 10 项),开发期无需 NSIS。 +- 产品名称:**年糕历险记**(安装包文件名 `年糕历险记-Setup-v<版本>`;产品、快捷方式、卸载项同名)。 +- **两条交付路径**: + 1. **便携安装包(当前交付)**:`pwsh scripts/make_installer.ps1` 生成 `dist/年糕历险记-Setup-v<版本>.zip`,解压后运行 `安装.bat`——全中文交互(选择安装目录、复制文件、创建桌面快捷方式「年糕历险记」)。 + 2. **NSIS 正式安装器(发布时)**:`scripts/installer.nsi`(简体中文界面、开始菜单、卸载器),需发布环境安装 NSIS 3:`makensis /DVERSION=<版本> scripts/installer.nsi`。 +- 安装内容:`mc-client.exe`、`mc-server.exe`、`config/`、`mods/`(README)、`resourcepacks/`(默认素材包)。 +- 中文要求:安装脚本全中文;游戏内 `lang/zh_cn.json` 随安装包提供;缺失翻译键回退 en_us。 +- 存档位置:安装目录 `worlds/`(便携式;NSIS 版卸载时保留存档)。 ## 9. CI 发布(GitHub Actions 草案)