feat(game): 动物自然生成(草面/白天/限频)与繁殖冷却/成长;村民交易表数据驱动(含测试)
This commit is contained in:
20
assets/config/villager_trades.json
Normal file
20
assets/config/villager_trades.json
Normal 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
131
internal/entity/breeding.go
Normal 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]
|
||||||
|
}
|
||||||
66
internal/entity/breeding_test.go
Normal file
66
internal/entity/breeding_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,11 @@ type Session struct {
|
|||||||
Entities *entity.Manager
|
Entities *entity.Manager
|
||||||
BlockEntities map[[3]int32]*blockentity.Entity
|
BlockEntities map[[3]int32]*blockentity.Entity
|
||||||
|
|
||||||
// 自然生成计时(僵尸与敌对生物.md §5:夜晚/黑暗处刷怪)
|
// 自然生成计时(僵尸与敌对生物.md §5、动物体系.md §6)
|
||||||
spawnTimer float64
|
spawnTimer float64
|
||||||
damageIn float64 // 玩家受击冷却
|
animalTimer float64
|
||||||
rng *rand.Rand
|
damageIn float64 // 玩家受击冷却
|
||||||
|
rng *rand.Rand
|
||||||
|
|
||||||
// 破坏进度状态(方块交互与动画.md §3)
|
// 破坏进度状态(方块交互与动画.md §3)
|
||||||
breakTarget raycast.Hit
|
breakTarget raycast.Hit
|
||||||
@@ -84,6 +85,8 @@ func (s *Session) Tick(dt float64) {
|
|||||||
}
|
}
|
||||||
// 夜间刷僵尸(僵尸与敌对生物.md §5:黑暗处、距玩家 24 格、限频)
|
// 夜间刷僵尸(僵尸与敌对生物.md §5:黑暗处、距玩家 24 格、限频)
|
||||||
s.spawnZombies(dt)
|
s.spawnZombies(dt)
|
||||||
|
// 白天刷动物(动物体系.md §6:草面、亮度≥9、距玩家 16–32、限频)
|
||||||
|
s.spawnAnimals(dt)
|
||||||
// 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒)
|
// 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒)
|
||||||
s.applyZombieDamage(dt)
|
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 点/秒)。
|
// applyZombieDamage 僵尸近身伤害(僵尸与敌对生物.md §3:约 3 点/秒)。
|
||||||
func (s *Session) applyZombieDamage(dt float64) {
|
func (s *Session) applyZombieDamage(dt float64) {
|
||||||
if s.Health <= 0 {
|
if s.Health <= 0 {
|
||||||
|
|||||||
12
文档/构建与发布.md
12
文档/构建与发布.md
@@ -75,11 +75,13 @@ mc-v1.2.0/
|
|||||||
|
|
||||||
## 8. Windows 安装包(年糕历险记 · 中文安装)
|
## 8. Windows 安装包(年糕历险记 · 中文安装)
|
||||||
|
|
||||||
- 产品名称:**年糕历险记**(安装包文件名 `年糕历险记-Setup-v1.0.0.exe`;产品、快捷方式、卸载项同名)。
|
- 产品名称:**年糕历险记**(安装包文件名 `年糕历险记-Setup-v<版本>`;产品、快捷方式、卸载项同名)。
|
||||||
- 安装器:NSIS(中文安装界面),`scripts/installer.nsi`(含 简体中文 语言、安装目录选择、桌面/开始菜单快捷方式、卸载器)。
|
- **两条交付路径**:
|
||||||
- 安装内容:`mc-client.exe`、`assets/`(未嵌入时)、`config/`、`mods/`(空目录 + README)、`resourcepacks/`(默认素材包)、`worlds/`。
|
1. **便携安装包(当前交付)**:`pwsh scripts/make_installer.ps1` 生成 `dist/年糕历险记-Setup-v<版本>.zip`,解压后运行 `安装.bat`——全中文交互(选择安装目录、复制文件、创建桌面快捷方式「年糕历险记」)。
|
||||||
- 中文要求:安装器文案全中文;游戏内 `lang/zh_cn.json` 随安装包提供;缺失翻译键回退 en_us。
|
2. **NSIS 正式安装器(发布时)**:`scripts/installer.nsi`(简体中文界面、开始菜单、卸载器),需发布环境安装 NSIS 3:`makensis /DVERSION=<版本> scripts/installer.nsi`。
|
||||||
- 安装包构建仅在**发布时**执行(M6,构建与发布.md §6 发布清单第 10 项),开发期无需 NSIS。
|
- 安装内容:`mc-client.exe`、`mc-server.exe`、`config/`、`mods/`(README)、`resourcepacks/`(默认素材包)。
|
||||||
|
- 中文要求:安装脚本全中文;游戏内 `lang/zh_cn.json` 随安装包提供;缺失翻译键回退 en_us。
|
||||||
|
- 存档位置:安装目录 `worlds/`(便携式;NSIS 版卸载时保留存档)。
|
||||||
|
|
||||||
## 9. CI 发布(GitHub Actions 草案)
|
## 9. CI 发布(GitHub Actions 草案)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user