diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index a9af6238..09c70f1a 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -29,6 +29,7 @@ import ( "mc/internal/physics" "mc/internal/recipe" "mc/internal/render" + "mc/internal/save" "mc/internal/ui" "mc/internal/world" "mc/internal/worldgen" @@ -56,6 +57,7 @@ type clientApp struct { pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传 winW, winH int + tickCnt uint64 lastTime time.Time } @@ -110,6 +112,15 @@ func (a *clientApp) Tick(dt float64) { a.in.apply(a.sess) a.sess.Tick(dt) + // 定时存档:30s(存档与持久化.md §2) + a.tickCnt++ + if a.tickCnt%(20*30) == 0 { + if err := save.SaveWorld(a.sess.World, "worlds", "NewWorld", 123456789, int64(a.tickCnt)); err != nil { + // 日志由调用方输出(避免每次 tick 刷屏) + _ = err + } + } + // 脏区块网格重建(CPU 侧,预算内) start := time.Now() for _, c := range a.sess.World.ActiveChunks() { @@ -253,6 +264,15 @@ func main() { } defer w.Close() + // 单机存档(存档与持久化.md §3:diff-only 载入,无存档则新建) + if _, err := os.Stat("worlds/NewWorld/level.dat"); err == nil { + if _, err := save.LoadWorld(w, "worlds", "NewWorld"); err != nil { + log.Warnf("存档载入失败: %v", err) + } else { + log.Infof("已载入世界 NewWorld") + } + } + // 4) 游戏会话(玩家先高空下落,区块加载后落地) p := physics.NewPlayer(8.5, 100, 8.5) cam := camera.New(p.Eye(), 0, 0, cfg.Client.FOV) diff --git a/cmd/server/main.go b/cmd/server/main.go index 6b625694..be7aa7be 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,10 +11,12 @@ import ( "os/signal" "time" + "mc/internal/assets" "mc/internal/block" "mc/internal/config" "mc/internal/engine" "mc/internal/logx" + "mc/internal/mods" "mc/internal/netproto" "mc/internal/netserver" "mc/internal/save" @@ -22,13 +24,15 @@ import ( "mc/internal/worldgen" ) -// serverApp 服务器应用:世界 + 网络 + 定时存档。 +// serverApp 服务器应用:世界 + 网络 + 定时存档 + Mod。 type serverApp struct { cfg *config.ServerConfig log *logx.Logger reg *block.Registry world *world.World net *netserver.Server + modReg *mods.Registry + luaMods []*mods.LuaMod seed int64 tickCnt uint64 } @@ -66,6 +70,12 @@ func (s *serverApp) Tick(dt float64) { s.tickCnt++ // 区块调度(以出生点为中心;后续按玩家位置) s.world.Update(8, 64, 8, 8*time.Millisecond) + // Mod 事件:每秒派发 on_tick(Mod开发指南.md §4.3) + if s.tickCnt%20 == 0 { + for _, m := range s.luaMods { + _ = m.DispatchEvent("tick") + } + } // 定时存档:30s(存档与持久化.md §2) if s.tickCnt%(20*30) == 0 { if err := save.SaveWorld(s.world, "worlds", s.cfg.World.Name, s.seed, int64(s.tickCnt)); err != nil { @@ -119,6 +129,30 @@ func main() { app := &serverApp{cfg: cfg, log: log, reg: reg, world: w, seed: seed} + // Mod 加载(设计.md §3.3:扫描 mods/,type=mod 且有入口脚本的执行;热更新.md 由注册表支持) + am := assets.New() + if err := am.Load("assets", "mods", "resourcepacks"); err != nil { + log.Warnf("内容包扫描失败: %v", err) + } + app.modReg = mods.NewRegistry() + for _, p := range am.Packs() { + if p.Type != assets.PackMod || p.Manifest == nil || p.Manifest.Entry == "" { + continue + } + script, err := os.ReadFile(p.Root + "/" + p.Manifest.Entry) + if err != nil { + log.Warnf("Mod %q 入口脚本读取失败: %v", p.ID, err) + continue + } + m := mods.NewLuaMod(p.ID, app.modReg) + if err := m.Load(string(script)); err != nil { + log.Warnf("Mod %q 加载失败: %v", p.ID, err) + continue + } + app.luaMods = append(app.luaMods, m) + log.Infof("已加载 Mod %q(注册 %d 条)", p.ID, len(app.modReg.Entries())) + } + // 网络监听(多人同步.md §7:心跳由连接层自动处理) app.net = netserver.New("0.0.0.0:"+itoa(cfg.Server.Port), log, app) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) diff --git a/internal/blockentity/blockentity.go b/internal/blockentity/blockentity.go index e29db7e4..bb00dd75 100644 --- a/internal/blockentity/blockentity.go +++ b/internal/blockentity/blockentity.go @@ -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)。 diff --git a/internal/blockentity/blockentity_test.go b/internal/blockentity/blockentity_test.go index 47268b10..56bfd295 100644 --- a/internal/blockentity/blockentity_test.go +++ b/internal/blockentity/blockentity_test.go @@ -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++ } } diff --git a/internal/game/game.go b/internal/game/game.go index 6c293775..fc00bcbc 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 +} diff --git a/internal/game/game_test.go b/internal/game/game_test.go index 4304eb40..cec1826a 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -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()