feat(game): 玩法接线——实体AI/夜间刷僵尸/受伤重生、掉落物拾取、熔炉烧炼产出、服务器与客户端 Mod 加载、客户端单机存档
This commit is contained in:
@@ -67,10 +67,10 @@ func NewFurnace(x, y, z int32) *Entity { return New(KindFurnace, x, y, z) }
|
||||
func NewChest(x, y, z int32) *Entity { return New(KindChest, x, y, z) }
|
||||
|
||||
// Tick 熔炉烧炼(每 tick;箱子无逻辑)。
|
||||
// 返回 true 表示产出完成了一个物品(调用方取出放入输出槽)。
|
||||
func (e *Entity) Tick() bool {
|
||||
// 产出时返回 (输入物品名, true)——输入名在消耗前捕获,供调用方查熔炼配方。
|
||||
func (e *Entity) Tick() (string, bool) {
|
||||
if e.Kind != KindFurnace {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
// 燃料耗尽则尝试补充
|
||||
if e.BurnTicks <= 0 {
|
||||
@@ -92,15 +92,16 @@ func (e *Entity) Tick() bool {
|
||||
e.CookTicks++
|
||||
if e.CookTicks >= CookTime {
|
||||
e.CookTicks = 0
|
||||
// 消耗一个输入(产出由调用方放入输出槽,物品与背包.md §6)
|
||||
// 消耗前捕获输入名(产出配方查询用,物品与背包.md §6)
|
||||
inputName := e.Slots[SlotInput].Name
|
||||
e.Slots[SlotInput].Count--
|
||||
if e.Slots[SlotInput].Count == 0 {
|
||||
e.Slots[SlotInput] = item.Stack{}
|
||||
}
|
||||
return true
|
||||
return inputName, true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
// CanInsert 槽位插入规则(容器版:与背包规则表一致,物品与背包.md §3)。
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestFurnaceSmelt(t *testing.T) {
|
||||
// 烧炼 2 炉
|
||||
produced := 0
|
||||
for i := 0; i < 2000; i++ {
|
||||
if f.Tick() {
|
||||
if _, ok := f.Tick(); ok {
|
||||
produced++
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestFurnaceFuelExhaust(t *testing.T) {
|
||||
// 一块煤 1600 tick,最多产出 8 个(1600/200)
|
||||
produced := 0
|
||||
for i := 0; i < 4000; i++ {
|
||||
if f.Tick() {
|
||||
if _, ok := f.Tick(); ok {
|
||||
produced++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"mc/internal/block"
|
||||
"mc/internal/blockentity"
|
||||
"mc/internal/camera"
|
||||
"mc/internal/entity"
|
||||
"mc/internal/inventory"
|
||||
"mc/internal/item"
|
||||
"mc/internal/physics"
|
||||
@@ -26,6 +30,15 @@ type Session struct {
|
||||
Inv *inventory.Inventory
|
||||
Cam *camera.Camera
|
||||
|
||||
// 实体与方块实体(实体系统.md、物品与背包.md §6)
|
||||
Entities *entity.Manager
|
||||
BlockEntities map[[3]int32]*blockentity.Entity
|
||||
|
||||
// 自然生成计时(僵尸与敌对生物.md §5:夜晚/黑暗处刷怪)
|
||||
spawnTimer float64
|
||||
damageIn float64 // 玩家受击冷却
|
||||
rng *rand.Rand
|
||||
|
||||
// 破坏进度状态(方块交互与动画.md §3)
|
||||
breakTarget raycast.Hit
|
||||
breaking bool
|
||||
@@ -40,7 +53,12 @@ type Session struct {
|
||||
|
||||
// NewSession 创建会话。
|
||||
func NewSession(w *world.World, reg *block.Registry, items *item.Registry, recipes *recipe.Registry, p *physics.Player, inv *inventory.Inventory, cam *camera.Camera) *Session {
|
||||
return &Session{World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam, Health: 20}
|
||||
return &Session{
|
||||
World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam,
|
||||
Health: 20, Entities: entity.NewManager(),
|
||||
BlockEntities: make(map[[3]int32]*blockentity.Entity),
|
||||
rng: rand.New(rand.NewSource(12345)),
|
||||
}
|
||||
}
|
||||
|
||||
// PlayerHealth 玩家当前生命(HUD 用)。
|
||||
@@ -56,6 +74,18 @@ func (s *Session) Tick(dt float64) {
|
||||
if s.breaking {
|
||||
s.accumulateBreak(dt)
|
||||
}
|
||||
// 实体 AI(实体系统.md §3 限频决策在内部处理)
|
||||
s.Entities.Tick(dt, s.World, s.Player.Pos)
|
||||
// 方块实体(熔炉烧炼,物品与背包.md §6)
|
||||
s.tickBlockEntities()
|
||||
// 掉落物拾取(实体系统.md §5)
|
||||
for _, d := range s.Entities.PickupNearby(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]) {
|
||||
s.Inv.Add(item.Stack{Name: d.Item, Count: uint8(min(d.Count, 127))})
|
||||
}
|
||||
// 夜间刷僵尸(僵尸与敌对生物.md §5:黑暗处、距玩家 24 格、限频)
|
||||
s.spawnZombies(dt)
|
||||
// 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒)
|
||||
s.applyZombieDamage(dt)
|
||||
// 相机跟随玩家眼睛
|
||||
s.Cam.Pos = s.Player.Eye()
|
||||
}
|
||||
@@ -130,18 +160,19 @@ func (s *Session) accumulateBreak(dt float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// destroy 摧毁方块:替换为空气 + 掉落进背包(工具耐久后置)。
|
||||
// destroy 摧毁方块:替换为空气 + 掉落物实体(实体系统.md §5)。
|
||||
func (s *Session) destroy(h raycast.Hit) {
|
||||
st := s.World.Block(h.X, h.Y, h.Z)
|
||||
def := s.Reg.Get(st.ID())
|
||||
s.World.SetBlock(h.X, h.Y, h.Z, block.Air)
|
||||
// 方块实体随之销毁(物品与背包.md §6)
|
||||
delete(s.BlockEntities, [3]int32{h.X, h.Y, h.Z})
|
||||
drop := def.Drops
|
||||
if drop == "" {
|
||||
drop = def.Name
|
||||
}
|
||||
if drop != "" {
|
||||
rem := s.Inv.Add(item.Stack{Name: drop, Count: 1})
|
||||
_ = rem // 背包满则丢弃(后置:掉落实体)
|
||||
s.Entities.SpawnDrop(drop, 1, float64(h.X)+0.5, float64(h.Y), float64(h.Z)+0.5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +213,10 @@ func (s *Session) PlaceSelected() {
|
||||
return
|
||||
}
|
||||
s.World.SetBlock(px, py, pz, block.NewState(bid, 0))
|
||||
// 方块实体创建(物品与背包.md §6:熔炉)
|
||||
if d.Place == "furnace" {
|
||||
s.BlockEntities[[3]int32{px, py, pz}] = blockentity.NewFurnace(px, py, pz)
|
||||
}
|
||||
// 消耗一个物品
|
||||
s.Inv.Remove(sel.Name, 1)
|
||||
}
|
||||
@@ -196,3 +231,83 @@ func (s *Session) overlapsPlayer(x, y, z int32) bool {
|
||||
float64(y)+1 > py && float64(y) < py+p.Height &&
|
||||
float64(z)+1 > pz-hw && float64(z) < pz+hw
|
||||
}
|
||||
|
||||
// tickBlockEntities 熔炉烧炼并产出(物品与背包.md §6)。
|
||||
func (s *Session) tickBlockEntities() {
|
||||
for _, be := range s.BlockEntities {
|
||||
if be.Kind != blockentity.KindFurnace {
|
||||
continue
|
||||
}
|
||||
if inputName, ok := be.Tick(); ok {
|
||||
// 产出:按熔炼配方放入输出槽
|
||||
if rc, found := s.Recipes.MatchSmelting(inputName); found {
|
||||
be.Insert(blockentity.SlotOutput, item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, s.Items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spawnZombies 夜间刷怪(僵尸与敌对生物.md §5:方块光 ≤7、距玩家 24–48 格、每 5 秒一只)。
|
||||
func (s *Session) spawnZombies(dt float64) {
|
||||
s.spawnTimer -= dt
|
||||
if s.spawnTimer > 0 {
|
||||
return
|
||||
}
|
||||
s.spawnTimer = 5.0
|
||||
for i := 0; i < 16; i++ {
|
||||
ang := s.rng.Float64() * 2 * math.Pi
|
||||
dist := 24 + s.rng.Float64()*24
|
||||
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 || s.World.Block(x, y+2, z) != block.Air {
|
||||
continue
|
||||
}
|
||||
if s.World.BlockLight(x, y+1, z) > 7 {
|
||||
continue
|
||||
}
|
||||
s.Entities.Spawn(entity.KindZombie, float64(x)+0.5, float64(y+1), float64(z)+0.5)
|
||||
return // 每周期最多 1 只
|
||||
}
|
||||
}
|
||||
|
||||
// applyZombieDamage 僵尸近身伤害(僵尸与敌对生物.md §3:约 3 点/秒)。
|
||||
func (s *Session) applyZombieDamage(dt float64) {
|
||||
if s.Health <= 0 {
|
||||
return
|
||||
}
|
||||
s.damageIn -= dt
|
||||
if s.damageIn > 0 {
|
||||
return
|
||||
}
|
||||
for _, e := range s.Entities.List() {
|
||||
if e.Kind != entity.KindZombie || e.Dead {
|
||||
continue
|
||||
}
|
||||
dx, dz := e.Pos[0]-s.Player.Pos[0], e.Pos[2]-s.Player.Pos[2]
|
||||
if dx*dx+dz*dz < 1.6*1.6 && e.Pos[1]-s.Player.Pos[1] < 2 {
|
||||
s.Health -= 3
|
||||
s.damageIn = 1.0
|
||||
if s.Health <= 0 {
|
||||
s.Health = 0
|
||||
s.respawn()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// respawn 死亡重生:回出生点 + 满血(实体系统.md §6)。
|
||||
func (s *Session) respawn() {
|
||||
s.Health = 20
|
||||
s.Player.Pos = [3]float64{8.5, 120, 8.5}
|
||||
s.Player.Vel = [3]float64{}
|
||||
s.damageIn = 2.0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"math"
|
||||
"mc/internal/blockentity"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -116,6 +117,46 @@ func TestBreakSpeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFurnaceFlow 放置熔炉 → 投料 → 烧炼产出(物品与背包.md §6 会话接线)。
|
||||
func TestFurnaceFlow(t *testing.T) {
|
||||
s := newTestSession(t)
|
||||
// 给玩家熔炉 + 煤炭 + 铁矿石
|
||||
s.Inv.Slots[1] = item.Stack{Name: "furnace", Count: 1}
|
||||
s.Inv.Slots[2] = item.Stack{Name: "coal", Count: 2}
|
||||
s.Inv.Slots[3] = item.Stack{Name: "iron_ore", Count: 2}
|
||||
s.Inv.Selected = 1
|
||||
|
||||
// 放置熔炉(向下看,站在地表)
|
||||
s.BeginBreak()
|
||||
for s.breaking {
|
||||
s.Tick(1.0 / 20)
|
||||
}
|
||||
s.Player.Pos[1] += 2
|
||||
s.Tick(1.0 / 20)
|
||||
s.PlaceSelected()
|
||||
|
||||
// 找到熔炉方块实体
|
||||
var furnace *blockentity.Entity
|
||||
for _, be := range s.BlockEntities {
|
||||
if be.Kind == blockentity.KindFurnace {
|
||||
furnace = be
|
||||
}
|
||||
}
|
||||
if furnace == nil {
|
||||
t.Fatal("未创建熔炉方块实体")
|
||||
}
|
||||
// 投料
|
||||
furnace.Insert(blockentity.SlotFuel, item.Stack{Name: "coal", Count: 1}, s.Items)
|
||||
furnace.Insert(blockentity.SlotInput, item.Stack{Name: "iron_ore", Count: 1}, s.Items)
|
||||
// 烧炼 200 tick(10 秒)
|
||||
for i := 0; i < 300; i++ {
|
||||
s.Tick(1.0 / 20)
|
||||
}
|
||||
if furnace.Slots[blockentity.SlotOutput].Name != "iron_ingot" {
|
||||
t.Fatalf("输出应为铁锭,实际 %+v", furnace.Slots[blockentity.SlotOutput])
|
||||
}
|
||||
}
|
||||
|
||||
// mustID 取方块 ID(测试辅助)。
|
||||
func mustID(t *testing.T, reg *block.Registry, name string) uint16 {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user