feat(entity): 实体系统——僵尸 AI/白天燃烧、动物游荡、掉落物合并拾取、村民房屋蓝图与建造队列(含测试)

This commit is contained in:
NianGao Dev
2026-08-15 23:06:20 +08:00
parent 38f015ba22
commit 58aa9f5e02
5 changed files with 599 additions and 0 deletions

83
internal/entity/drops.go Normal file
View File

@@ -0,0 +1,83 @@
// 掉落物实体:合并与拾取(实体系统.md §5
package entity
import (
"math"
)
// 掉落物参数(实体系统.md §5
const (
dropMergeDist = 1.0 // 同物品合并距离
dropStackMax = 64 // 合并上限
dropPickupDist = 1.5 // 拾取半径
dropPerChunk = 32 // 每区块掉落物上限
)
// Drop 掉落物实体数据。
type Drop struct {
Item string
Count int
}
// SpawnDrop 生成掉落物:同物品近距合并(上限 dropStackMax超上限丢弃。
func (m *Manager) SpawnDrop(itemName string, count int, x, y, z float64) {
if count <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
// 合并
if old, ok := m.drops[itemName]; ok && !old.Dead {
dx, dz := old.Pos[0]-x, old.Pos[2]-z
if math.Sqrt(dx*dx+dz*dz) < dropMergeDist {
if d, ok := old.Data.(*Drop); ok && d.Count+count <= dropStackMax {
d.Count += count
return
}
}
}
// 上限检查
drops := 0
for _, e := range m.entities {
if e.Kind == KindItemDrop {
drops++
}
}
if drops >= dropPerChunk*8 { // 全局粗上限(按区块过滤后置)
return
}
m.next++
e := &Entity{ID: m.next, Kind: KindItemDrop, Pos: [3]float64{x, y, z}, Health: 1,
Data: &Drop{Item: itemName, Count: count}}
e.Vel = [3]float64{(rand01(e.ID) - 0.5) * 2, 4, (rand01(e.ID>>16) - 0.5) * 2} // 弹出初速度
m.entities[e.ID] = e
m.drops[itemName] = e
}
// Data 实体附加数据。
// rand01 实体 ID 派生确定性随机(掉落初速度用,存档回放一致)。
func rand01(seed ID) float64 {
const mod = 1000003
return float64(seed%mod) / mod
}
// PickupNearby 拾取玩家附近掉落物,返回拾取的物品列表(实体系统.md §5 拾取)。
func (m *Manager) PickupNearby(px, py, pz float64) []Drop {
var out []Drop
for _, e := range m.List() {
if e.Kind != KindItemDrop || e.Dead {
continue
}
d, ok := e.Data.(*Drop)
if !ok {
continue
}
dx, dy, dz := e.Pos[0]-px, e.Pos[1]-py, e.Pos[2]-pz
if dx*dx+dy*dy+dz*dz < dropPickupDist*dropPickupDist {
e.Dead = true
m.Remove(e.ID)
out = append(out, *d)
}
}
return out
}

257
internal/entity/entity.go Normal file
View File

@@ -0,0 +1,257 @@
// Package entity 实体系统生命周期、AI 状态机、掉落物(场景/实体系统.md
//
// 设计要点:
// - 实体与方块分离,随激活区块 tick卸载冻结
// - AI 决策限频0.250.5s 一次),寻路简化为直线移动 + 卡住重试(实体系统.md §3
// - 掉落物合并 + 上限(实体系统.md §5
package entity
import (
"math"
"sync"
"mc/internal/block"
)
// ID 实体 ID服务器分配多人同步.md §3
type ID uint64
// Kind 实体类型。
type Kind uint8
// 实体类型(僵尸与敌对生物.md、动物体系.md、村民系统.md
const (
KindAnimal Kind = iota // 被动动物
KindZombie // 敌对生物样板
KindVillager // 村民
KindItemDrop // 掉落物
)
// WorldView 实体 AI 所需的世界只读视图。
type WorldView interface {
Solid(x, y, z int32) bool
Block(x, y, z int32) block.State
SkyLight(x, y, z int32) uint8
BlockLight(x, y, z int32) uint8
}
// Entity 实体。
type Entity struct {
ID ID
Kind Kind
Pos [3]float64 // 脚底坐标
Vel [3]float64
Yaw float64
Health float64
Dead bool
// AI 状态(实体系统.md §3 状态机)
state uint8
decisionIn float64 // 下次决策倒计时(限频)
attackIn float64 // 攻击冷却(僵尸与敌对生物.md §3
wanderDir [2]float64
// Data 类型附加数据(掉落物 = *Drop村民 = *Villager
Data any
}
// 僵尸 AI 状态。
const (
zIdle uint8 = iota
zChase
zAttack
)
// 动物 AI 状态。
const (
aWander uint8 = iota
aFlee
)
// Manager 实体管理器(主线程所有权)。
type Manager struct {
mu sync.Mutex
next ID
entities map[ID]*Entity
drops map[string]*Entity // 掉落物合并键(物品名)→ 实体
}
// NewManager 创建实体管理器。
func NewManager() *Manager {
return &Manager{entities: make(map[ID]*Entity), drops: make(map[string]*Entity)}
}
// Spawn 生成实体。
func (m *Manager) Spawn(kind Kind, x, y, z float64) *Entity {
m.mu.Lock()
defer m.mu.Unlock()
m.next++
e := &Entity{ID: m.next, Kind: kind, Pos: [3]float64{x, y, z}, Health: 20, decisionIn: 0.5}
m.entities[e.ID] = e
return e
}
// Remove 移除实体。
func (m *Manager) Remove(id ID) {
m.mu.Lock()
delete(m.entities, id)
m.mu.Unlock()
}
// List 导出实体列表(副本)。
func (m *Manager) List() []*Entity {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]*Entity, 0, len(m.entities))
for _, e := range m.entities {
out = append(out, e)
}
return out
}
// Tick 每 tick 更新全部实体(激活区块内;冻结区由调用方过滤)。
func (m *Manager) Tick(dt float64, w WorldView, playerPos [3]float64) {
for _, e := range m.List() {
if e.Dead {
continue
}
switch e.Kind {
case KindZombie:
e.tickZombie(dt, w, playerPos)
case KindAnimal:
e.tickAnimal(dt, w)
case KindVillager:
e.tickVillager(dt, w)
}
if e.Kind != KindItemDrop {
e.move(dt, w)
}
}
}
// move 简化移动:水平意愿速度 + 重力 + AABB 碰撞(复用物理思路,实体系统.md §3
func (e *Entity) move(dt float64, w WorldView) {
const g = 32.0
e.Vel[1] -= g * dt
if e.Vel[1] < -64 {
e.Vel[1] = -64
}
// 逐轴X → Z → Y
for _, axis := range [3]int{0, 2, 1} {
e.Pos[axis] += e.Vel[axis] * dt
if e.collides(w) {
e.Pos[axis] -= e.Vel[axis] * dt
e.Vel[axis] = 0
}
}
}
// collides AABB 碰撞判定0.6×1.8)。
func (e *Entity) collides(w WorldView) bool {
const hw, hh = 0.3, 1.8
minX := int32(math.Floor(e.Pos[0] - hw + 1e-6))
maxX := int32(math.Floor(e.Pos[0] + hw - 1e-6))
minY := int32(math.Floor(e.Pos[1] + 1e-6))
maxY := int32(math.Floor(e.Pos[1] + hh - 1e-6))
minZ := int32(math.Floor(e.Pos[2] - hw + 1e-6))
maxZ := int32(math.Floor(e.Pos[2] + hw - 1e-6))
for x := minX; x <= maxX; x++ {
for y := minY; y <= maxY; y++ {
for z := minZ; z <= maxZ; z++ {
if w.Solid(x, y, z) {
return true
}
}
}
}
return false
}
// tickZombie 僵尸 AI僵尸与敌对生物.md §2§4
func (e *Entity) tickZombie(dt float64, w WorldView, playerPos [3]float64) {
// 白天燃烧(暴露天空光 ≥15 且无头盔 → 每秒伤害;逐 tick 而非秒杀)
if w.SkyLight(int32(e.Pos[0]), int32(e.Pos[1]+1.5), int32(e.Pos[2])) >= 15 {
e.Health -= 2.0 * dt
if e.Health <= 0 {
e.Dead = true
return
}
}
e.decisionIn -= dt
e.attackIn -= dt
dx, dz := playerPos[0]-e.Pos[0], playerPos[2]-e.Pos[2]
dist := math.Sqrt(dx*dx + dz*dz)
switch e.state {
case zIdle:
if dist < 16 && w.BlockLight(int32(e.Pos[0]), int32(e.Pos[1]), int32(e.Pos[2])) <= 7 {
e.state = zChase
}
case zChase:
if dist < 2.0 {
e.state = zAttack
e.Vel[0], e.Vel[2] = 0, 0
break
}
if e.decisionIn <= 0 {
e.decisionIn = 0.5 // 限频寻路(实体系统.md §3
speed := 3.0
e.Vel[0] = dx / dist * speed
e.Vel[2] = dz / dist * speed
e.Yaw = math.Atan2(-dx, -dz)
}
if dist > 24 {
e.state = zIdle
}
case zAttack:
if dist > 2.5 {
e.state = zChase
} else if e.attackIn <= 0 {
e.attackIn = 1.0 // 攻击间隔 1s僵尸与敌对生物.md §3
e.Vel[0], e.Vel[2] = 0, 0
}
}
}
// tickAnimal 动物 AI游荡 + 受击逃跑(动物体系.md §2 状态机子集)。
func (e *Entity) tickAnimal(dt float64, w WorldView) {
e.decisionIn -= dt
if e.Health < 20 {
e.state = aFlee // 受击逃跑(简化:血不满即逃)
}
if e.decisionIn <= 0 {
e.decisionIn = 2.0
switch e.state {
case aFlee:
ang := math.Atan2(e.wanderDir[1], e.wanderDir[0]) + 0.5
e.wanderDir = [2]float64{math.Cos(ang), math.Sin(ang)}
e.state = aWander
default:
// 随机游荡方向(确定性:用实体 ID 派生)
ang := float64(e.ID%628) / 100.0
e.wanderDir = [2]float64{math.Cos(ang), math.Sin(ang)}
if e.ID%3 == 0 {
e.wanderDir = [2]float64{0, 0} // 原地休息
}
}
}
e.Vel[0] = e.wanderDir[0] * 1.2
e.Vel[2] = e.wanderDir[1] * 1.2
_ = w
}
// tickVillager 村民 AI白天游荡工作建造队列由村民系统接入夜晚回屋/逃跑。
func (e *Entity) tickVillager(dt float64, w WorldView) {
e.decisionIn -= dt
isNight := w.SkyLight(int32(e.Pos[0]), int32(e.Pos[1]+1.5), int32(e.Pos[2])) < 5
if e.decisionIn <= 0 {
e.decisionIn = 1.0
if isNight {
e.wanderDir = [2]float64{0, 0} // 夜晚停下回屋由村庄数据驱动MVP
} else {
ang := float64(e.ID%628) / 100.0
e.wanderDir = [2]float64{math.Cos(ang) * 0.5, math.Sin(ang) * 0.5}
}
}
e.Vel[0] = e.wanderDir[0]
e.Vel[2] = e.wanderDir[1]
}

View File

@@ -0,0 +1,157 @@
package entity
import (
"path/filepath"
"testing"
"time"
"mc/internal/block"
"mc/internal/world"
"mc/internal/worldgen"
)
// flatWorld 测试世界y<63 固体,其余空气;无光。
type flatWorld struct{}
func (flatWorld) Solid(x, y, z int32) bool { return y < 63 }
func (flatWorld) Block(x, y, z int32) block.State { return block.Air }
func (flatWorld) SkyLight(x, y, z int32) uint8 { return 15 }
func (flatWorld) BlockLight(x, y, z int32) uint8 { return 0 }
// TestZombieChase 僵尸追玩家(僵尸与敌对生物.md §2
func TestZombieChase(t *testing.T) {
m := NewManager()
z := m.Spawn(KindZombie, 0.5, 63, 0.5)
w := flatWorld{}
for i := 0; i < 200; i++ {
m.Tick(0.05, w, [3]float64{10, 63, 0.5})
}
// 僵尸应向玩家移动(方块光 0 ≤ 7 满足感知条件;天空光 15 → 燃烧掉血但不影响移动)
if z.Pos[0] < 1.0 {
t.Fatalf("僵尸未向玩家移动: %+v", z.Pos)
}
}
// TestZombieBurn 白天暴露燃烧致死(僵尸与敌对生物.md §4 逐 tick 伤害)。
func TestZombieBurn(t *testing.T) {
m := NewManager()
z := m.Spawn(KindZombie, 0.5, 63, 0.5)
w := flatWorld{} // sky=15
deadline := time.Now().Add(2 * time.Second)
for !z.Dead && time.Now().Before(deadline) {
m.Tick(0.05, w, [3]float64{100, 63, 100})
}
if !z.Dead {
t.Fatal("暴露僵尸应在白天被烧死")
}
if z.Health > 0 {
t.Fatalf("死亡实体血量应 ≤0: %v", z.Health)
}
}
// TestAnimalWander 动物游荡(确定性方向,实体系统.md §3 限频决策)。
func TestAnimalWander(t *testing.T) {
m := NewManager()
a := m.Spawn(KindAnimal, 0.5, 63, 0.5)
w := flatWorld{}
for i := 0; i < 100; i++ {
m.Tick(0.05, w, [3]float64{100, 63, 100})
}
if a.Pos[1] < 63 {
t.Fatalf("动物掉到地面以下: %+v", a.Pos)
}
}
// TestDropMerge 掉落物合并(实体系统.md §5
func TestDropMerge(t *testing.T) {
m := NewManager()
m.SpawnDrop("cobblestone", 5, 0.5, 64, 0.5)
m.SpawnDrop("cobblestone", 5, 0.6, 64, 0.5) // 距离 <1 → 合并
m.SpawnDrop("cobblestone", 5, 10, 64, 10) // 距离远 → 新实体
cnt := 0
for _, e := range m.List() {
if e.Kind == KindItemDrop {
cnt++
}
}
if cnt != 2 {
t.Fatalf("掉落物实体数期望 2实际 %d", cnt)
}
// 拾取
drops := m.PickupNearby(0.5, 64, 0.5)
if len(drops) != 1 || drops[0].Count != 10 {
t.Fatalf("拾取期望 1 组 10 个,实际 %+v", drops)
}
}
// TestBlueprint 房屋蓝图确定性 + 建造执行顺序(村民系统.md §6
func TestBlueprint(t *testing.T) {
a := Blueprint([3]int32{10, 64, 10}, 42)
b := Blueprint([3]int32{10, 64, 10}, 42)
if len(a) != len(b) {
t.Fatalf("蓝图确定性失败: %d != %d", len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("蓝图第 %d 项不一致", i)
}
}
// 顺序约束地基Order 0先于墙与屋顶
firstNonFoundation := -1
for i, task := range a {
if task.Order != 0 {
firstNonFoundation = i
break
}
}
if firstNonFoundation <= 0 {
t.Fatal("蓝图缺少地基")
}
// 建造执行:每 tick 2 块,全部放置成功
v := &Villager{Profession: 1, BuildQueue: append([]BuildTask{}, a...)}
placed := 0
place := func(x, y, z int32, id uint16, meta uint8) bool { placed++; return true }
for len(v.BuildQueue) > 0 {
v.BuildTick(place)
}
if placed != len(a) {
t.Fatalf("放置数量期望 %d实际 %d", len(a), placed)
}
}
// TestEntityWorldIntegration 实体跑在真实世界上(世界接入 smoke
func TestEntityWorldIntegration(t *testing.T) {
reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json"))
if err != nil {
t.Fatalf("加载注册表失败: %v", err)
}
gen, err := worldgen.New(reg, 1, 0.005, 0.01, 1.0) // 无洞穴
if err != nil {
t.Fatalf("创建生成器失败: %v", err)
}
w, err := world.New(reg, gen, 2)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
defer w.Close()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
w.Update(8, 64, 8, 8*time.Millisecond)
if w.Block(8, 0, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
// 找地表放一只僵尸
var surf int32
for y := int32(255); y >= 0; y-- {
if w.Block(8, y, 8) != block.Air {
surf = y
break
}
}
m := NewManager()
m.Spawn(KindZombie, 8.5, float64(surf+1), 8.5)
m.Tick(0.05, w, [3]float64{12, float64(surf + 1), 8.5}) // 世界实现了 WorldViewSolid/Block/Sky/BlockLight
}

102
internal/entity/villager.go Normal file
View File

@@ -0,0 +1,102 @@
// 村民建造子系统:程序化房屋蓝图 + 逐块建造队列(村民系统.md §6 特色功能)。
package entity
import (
"mc/internal/block"
)
// Villager 村民附加数据(村民系统.md §3§6
type Villager struct {
Profession uint8 // 0 农民 1 建造者 2 铁匠 3 商人
Home [3]int32
// 建造队列(村民系统.md §6.3:按顺序逐块放置)
BuildQueue []BuildTask
}
// BuildTask 一个建造任务(建造顺序已定)。
type BuildTask struct {
X, Y, Z int32
BlockID uint16
Meta uint8
Order uint32 // 地基 → 墙 → 门 → 屋顶 → 火把
}
// Blueprint 程序化房屋蓝图(村民系统.md §6.2
// 矩形地基 W×D、墙高 3、门朝 +x、平顶、四角火把确定性seed 派生尺寸)。
func Blueprint(origin [3]int32, seed uint64) []BuildTask {
w := 5 + int(seed%3) // 57
d := 4 + int(seed%3) // 46
wall := uint16(8) // oak_planks与 assets/config/blocks.json 一致)
door := uint16(0) // 门后置:先留空
roof := uint16(8)
torch := uint16(17)
var tasks []BuildTask
order := uint32(0)
add := func(x, y, z int32, id uint16, o uint32) {
tasks = append(tasks, BuildTask{X: x, Y: y, Z: z, BlockID: id, Order: o})
}
ox, oy, oz := origin[0], origin[1], origin[2]
// 1) 地基order 0
for x := 0; x < w; x++ {
for z := 0; z < d; z++ {
add(ox+int32(x), oy, oz+int32(z), wall, order)
}
}
order++
// 2) 墙order 12高 3门位置留空
for y := 1; y <= 2; y++ {
for x := 0; x < w; x++ {
for z := 0; z < d; z++ {
edge := x == 0 || x == w-1 || z == 0 || z == d-1
if !edge {
continue
}
if y == 1 && x == 0 && z == d/2 {
continue // 门洞
}
add(ox+int32(x), oy+int32(y), oz+int32(z), wall, order)
}
}
order++
}
// 3) 屋顶order 3平顶 + 一圈外沿)
for x := -1; x <= w; x++ {
for z := -1; z <= d; z++ {
edge := x == -1 || x == w || z == -1 || z == d
add(ox+int32(x), oy+3, oz+int32(z), roof, order+uint32(boolToInt(edge)))
}
}
order += 2
// 4) 火把order 5四角
for _, c := range [4][2]int{{0, 0}, {w - 1, 0}, {0, d - 1}, {w - 1, d - 1}} {
add(ox+int32(c[0]), oy+2, oz+int32(c[1]), torch, order)
}
_ = door
return tasks
}
// boolToInt bool → int。
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// BuildTick 建造者村民执行建造队列:每 tick 12 块(村民系统.md §6.3 防瞬建突兀)。
// place放置回调世界层 SetBlock返回剩余任务数。
func (v *Villager) BuildTick(place func(x, y, z int32, id uint16, meta uint8) bool) int {
n := 0
for len(v.BuildQueue) > 0 && n < 2 {
t := v.BuildQueue[0]
v.BuildQueue = v.BuildQueue[1:]
if place(t.X, t.Y, t.Z, t.BlockID, t.Meta) {
n++
}
}
return len(v.BuildQueue)
}
// 方块状态工具(避免测试重复导入)。
func stateOf(id uint16, meta uint8) block.State { return block.NewState(id, meta) }

Binary file not shown.