feat(game): 动物自然生成(草面/白天/限频)与繁殖冷却/成长;村民交易表数据驱动(含测试)

This commit is contained in:
NianGao Dev
2026-08-16 00:01:20 +08:00
parent 4755fd88fc
commit e70314fb3f
5 changed files with 274 additions and 9 deletions

View File

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

131
internal/entity/breeding.go Normal file
View File

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

View File

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

View File

@@ -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、距玩家 1632、限频
s.spawnAnimals(dt)
// 僵尸近身伤害(僵尸与敌对生物.md §33 点/秒)
s.applyZombieDamage(dt)
// 相机跟随玩家眼睛
@@ -278,6 +281,49 @@ func (s *Session) spawnZombies(dt float64) {
}
}
// spawnAnimals 白天刷动物(动物体系.md §6草面、天空光 ≥9、距玩家 1632、每 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 {

View File

@@ -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 草案)