From 82954c90010bb05e229c1b4c66d5444dd3e0b994 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 22:47:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E7=9B=B8=E6=9C=BA=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E4=B8=8E=E6=B8=B8=E6=88=8F=E4=BC=9A=E8=AF=9D=EF=BC=88?= =?UTF-8?q?=E7=A0=B4=E5=9D=8F=E8=BF=9B=E5=BA=A6/=E6=94=BE=E7=BD=AE/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=80=9F=E5=BA=A6=EF=BC=8C=E5=90=AB=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/camera/camera.go | 72 +++++++++++++ internal/camera/camera_test.go | 45 ++++++++ internal/game/game.go | 192 +++++++++++++++++++++++++++++++++ internal/game/game_test.go | 125 +++++++++++++++++++++ internal/world/world.go | 6 ++ 5 files changed, 440 insertions(+) create mode 100644 internal/camera/camera.go create mode 100644 internal/camera/camera_test.go create mode 100644 internal/game/game.go create mode 100644 internal/game/game_test.go diff --git a/internal/camera/camera.go b/internal/camera/camera.go new file mode 100644 index 00000000..635c8842 --- /dev/null +++ b/internal/camera/camera.go @@ -0,0 +1,72 @@ +// Package camera 第一人称相机:欧拉角(yaw/pitch)与视图/投影矩阵(渲染.md 配套)。 +// +// 约定:yaw=0 朝向 -Z(GL 习惯),pitch>0 抬头;视图矩阵为列主序(GL 风格)。 +package camera + +import "math" + +// Camera 第一人称相机。 +type Camera struct { + Pos [3]float64 // 眼睛位置 + Yaw float64 // 水平角(弧度) + Pitch float64 // 俯仰角(弧度) + FOV float64 // 垂直视场角(度) +} + +// New 创建相机。 +func New(pos [3]float64, yaw, pitch, fov float64) *Camera { + return &Camera{Pos: pos, Yaw: yaw, Pitch: pitch, FOV: fov} +} + +// Forward 前方向(单位向量)。 +func (c *Camera) Forward() [3]float64 { + cp, sp := math.Cos(c.Pitch), math.Sin(c.Pitch) + cy, sy := math.Cos(c.Yaw), math.Sin(c.Yaw) + return [3]float64{-sy * cp, sp, -cy * cp} +} + +// Right 右方向(单位向量,水平)。 +func (c *Camera) Right() [3]float64 { + cy, sy := math.Cos(c.Yaw), math.Sin(c.Yaw) + return [3]float64{cy, 0, -sy} +} + +// View 视图矩阵(列主序 4×4,GL 用)。 +func (c *Camera) View() [16]float32 { + // 旋转 = RotX(-pitch) × RotY(-yaw),再平移 -pos + cp, sp := math.Cos(-c.Pitch), math.Sin(-c.Pitch) + cy, sy := math.Cos(-c.Yaw), math.Sin(-c.Yaw) + + // R = RotY(-yaw) × RotX(-pitch) 的计算结果(行主序推导后转列主序) + // 列主序布局: m[col*4+row] + m := [16]float32{} + m[0] = float32(cy) + m[1] = float32(-sy * sp) + m[2] = float32(-sy * cp) + m[4] = float32(sy) + m[5] = float32(cy * sp) + m[6] = float32(cy * cp) + m[8] = 0 + m[9] = float32(cp) + m[10] = float32(-sp) + m[15] = 1 + + // 平移部分:t = -R·pos + px, py, pz := float32(c.Pos[0]), float32(c.Pos[1]), float32(c.Pos[2]) + m[12] = -(m[0]*px + m[4]*py + m[8]*pz) + m[13] = -(m[1]*px + m[5]*py + m[9]*pz) + m[14] = -(m[2]*px + m[6]*py + m[10]*pz) + return m +} + +// Projection 透视投影矩阵(列主序,GL 用)。 +func (c *Camera) Projection(aspect, near, far float64) [16]float32 { + f := 1.0 / math.Tan(c.FOV*math.Pi/360.0) + var m [16]float32 + m[0] = float32(f / aspect) + m[5] = float32(f) + m[10] = float32((far + near) / (near - far)) + m[11] = -1 + m[14] = float32(2 * far * near / (near - far)) + return m +} diff --git a/internal/camera/camera_test.go b/internal/camera/camera_test.go new file mode 100644 index 00000000..2735750e --- /dev/null +++ b/internal/camera/camera_test.go @@ -0,0 +1,45 @@ +package camera + +import ( + "math" + "testing" +) + +// nearEq 近似相等。 +func nearEq(a, b, eps float64) bool { return math.Abs(a-b) < eps } + +// TestForward 前方向:yaw=0 朝向 -Z,pitch>0 抬头。 +func TestForward(t *testing.T) { + c := New([3]float64{0, 0, 0}, 0, 0, 70) + f := c.Forward() + if !nearEq(f[0], 0, 1e-6) || !nearEq(f[1], 0, 1e-6) || !nearEq(f[2], -1, 1e-6) { + t.Fatalf("前方向异常: %+v", f) + } + // 单位向量 + l := math.Sqrt(f[0]*f[0] + f[1]*f[1] + f[2]*f[2]) + if !nearEq(l, 1, 1e-6) { + t.Fatalf("前方向非单位向量: %v", l) + } +} + +// TestYaw 转向 90°:前方向朝 -X 或 +X(右方向指向 -Z 转向后)。 +func TestYaw(t *testing.T) { + c := New([3]float64{0, 0, 0}, math.Pi/2, 0, 70) + f := c.Forward() + if !nearEq(f[0], -1, 1e-6) || !nearEq(f[2], 0, 1e-6) { + t.Fatalf("yaw=90° 前方向异常: %+v", f) + } +} + +// TestViewTranslate 视图矩阵平移:相机原点位置应映射到视图空间原点附近。 +func TestViewTranslate(t *testing.T) { + c := New([3]float64{10, 64, -20}, 0, 0, 70) + m := c.View() + // 世界点 (10,64,-20) → 视图空间 ≈ (0,0,0,1) + vx := float64(m[0])*10 + float64(m[4])*64 + float64(m[8])*(-20) + float64(m[12]) + vy := float64(m[1])*10 + float64(m[5])*64 + float64(m[9])*(-20) + float64(m[13]) + vz := float64(m[2])*10 + float64(m[6])*64 + float64(m[10])*(-20) + float64(m[14]) + if !nearEq(vx, 0, 1e-3) || !nearEq(vy, 0, 1e-3) || !nearEq(vz, 0, 1e-3) { + t.Fatalf("相机位置未映射到原点: %v %v %v", vx, vy, vz) + } +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 00000000..6e556aa5 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,192 @@ +// Package game 游戏会话:输入 → 玩家 → 世界 → 交互(破坏/放置)的粘合层(架构.md §3 主循环)。 +// +// 职责边界:会话只做调度与玩法逻辑,渲染/UI/网络通过接口或外部驱动访问。 +package game + +import ( + "time" + + "mc/internal/block" + "mc/internal/camera" + "mc/internal/inventory" + "mc/internal/item" + "mc/internal/physics" + "mc/internal/raycast" + "mc/internal/recipe" + "mc/internal/world" +) + +// Session 单机游戏会话。 +type Session struct { + World *world.World + Reg *block.Registry + Items *item.Registry + Recipes *recipe.Registry + Player *physics.Player + Inv *inventory.Inventory + Cam *camera.Camera + + // 破坏进度状态(方块交互与动画.md §3) + breakTarget raycast.Hit + breaking bool + breakProg float64 + + // 创造模式(跳过进度直接破坏) + Creative bool +} + +// 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} +} + +// Tick 每 tick(20Hz)会话逻辑(架构.md §3.2)。 +func (s *Session) Tick(dt float64) { + // 物理积分(速度由输入层写入 Player.Vel) + s.Player.Tick(s.World, dt) + // 区块调度(主线程预算 8ms,区块管理.md §4.3) + s.World.Update(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2], 8*time.Millisecond) + // 破坏进度累积 + if s.breaking { + s.accumulateBreak(dt) + } + // 相机跟随玩家眼睛 + s.Cam.Pos = s.Player.Eye() +} + +// Cast 从相机射线拾取(方块交互与动画.md §2)。 +func (s *Session) Cast(maxDist float64) (raycast.Hit, bool) { + f := s.Cam.Forward() + return raycast.Cast(s.Cam.Pos, f, maxDist, func(x, y, z int32) bool { + return s.Reg.IsSolid(s.World.Block(x, y, z)) + }) +} + +// BeginBreak 按下左键:锁定破坏目标(进度从 0 开始)。 +func (s *Session) BeginBreak() { + h, ok := s.Cast(5) + if !ok { + s.EndBreak() + return + } + s.breakTarget = h + s.breaking = true + s.breakProg = 0 + if s.Creative { + s.BreakNow() + } +} + +// EndBreak 松开左键/换目标:进度清零。 +func (s *Session) EndBreak() { + s.breaking = false + s.breakProg = 0 + s.breakTarget = raycast.Hit{} +} + +// BreakProgress 当前破坏进度(0–1,UI 裂纹用)。 +func (s *Session) BreakProgress() float64 { return s.breakProg } + +// BreakTarget 当前破坏目标(渲染选择框/裂纹用)。 +func (s *Session) BreakTarget() raycast.Hit { return s.breakTarget } + +// breakSpeed 每秒破坏进度(方块交互与动画.md §3、挖矿与矿物.md §3 同公式)。 +func (s *Session) breakSpeed(def block.Def, tool item.Stack) float64 { + speed := 1.0 // 空手 + if !tool.Empty() { + if d, ok := s.Items.Get(tool.Name); ok && d.Tool != "" { + if d.Tier >= def.RequiredTier { + speed = d.Speed + } else { + speed = 0.05 // 工具门槛不足:极慢(挖矿与矿物.md §3 slow 策略) + } + } + } + if def.Hardness <= 0 { + return 1e9 // 不可破坏(如基岩 hardness=-1 → 瞬时?不:返回 0 更合理) + } + return speed / def.Hardness +} + +// accumulateBreak 累积破坏进度,完成即摧毁(掉落进背包 + 耐久消耗)。 +func (s *Session) accumulateBreak(dt float64) { + st := s.World.Block(s.breakTarget.X, s.breakTarget.Y, s.breakTarget.Z) + def := s.Reg.Get(st.ID()) + if def.ID == 0 { + s.EndBreak() // 目标已消失 + return + } + tool := s.Inv.SelectedStack() + s.breakProg += s.breakSpeed(def, tool) * dt + if s.breakProg >= 1.0 { + s.destroy(s.breakTarget) + s.EndBreak() + } +} + +// destroy 摧毁方块:替换为空气 + 掉落进背包(工具耐久后置)。 +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) + drop := def.Drops + if drop == "" { + drop = def.Name + } + if drop != "" { + rem := s.Inv.Add(item.Stack{Name: drop, Count: 1}) + _ = rem // 背包满则丢弃(后置:掉落实体) + } +} + +// BreakNow 立即破坏(创造模式)。 +func (s *Session) BreakNow() { + if !s.breaking { + return + } + s.destroy(s.breakTarget) + s.EndBreak() +} + +// PlaceSelected 放置选中槽物品(右键,方块交互与动画.md §6)。 +func (s *Session) PlaceSelected() { + sel := s.Inv.SelectedStack() + if sel.Empty() { + return + } + d, ok := s.Items.Get(sel.Name) + if !ok || d.Place == "" { + return + } + bid, ok := s.Reg.ID(d.Place) + if !ok { + return + } + // 射线拾取放置位 + h, ok := s.Cast(5) + if !ok { + return + } + px, py, pz := h.Prev[0], h.Prev[1], h.Prev[2] + // 目标必须为空且不与玩家 AABB 重叠(方块交互与动画.md §10 踩坑) + if s.World.Block(px, py, pz) != block.Air { + return + } + if s.overlapsPlayer(px, py, pz) { + return + } + s.World.SetBlock(px, py, pz, block.NewState(bid, 0)) + // 消耗一个物品 + s.Inv.Remove(sel.Name, 1) +} + +// overlapsPlayer 放置位是否与玩家 AABB 重叠。 +func (s *Session) overlapsPlayer(x, y, z int32) bool { + p := s.Player + px, py, pz := p.Pos[0], p.Pos[1], p.Pos[2] + hw := p.Width / 2 + // 方块 AABB [x,x+1]×[y,y+1]×[z,z+1] 与玩家 AABB 相交判定 + return float64(x)+1 > px-hw && float64(x) < px+hw && + float64(y)+1 > py && float64(y) < py+p.Height && + float64(z)+1 > pz-hw && float64(z) < pz+hw +} diff --git a/internal/game/game_test.go b/internal/game/game_test.go new file mode 100644 index 00000000..58d0a573 --- /dev/null +++ b/internal/game/game_test.go @@ -0,0 +1,125 @@ +package game + +import ( + "math" + "path/filepath" + "testing" + "time" + + "mc/internal/block" + "mc/internal/camera" + "mc/internal/inventory" + "mc/internal/item" + "mc/internal/physics" + "mc/internal/recipe" + "mc/internal/world" + "mc/internal/worldgen" +) + +// newTestSession 构造真实世界上的游戏会话(等待区块生成)。 +func newTestSession(t *testing.T) *Session { + t.Helper() + reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) + if err != nil { + t.Fatalf("加载方块注册表失败: %v", err) + } + items, err := item.Load(filepath.Join("..", "..", "assets", "config", "items.json")) + if err != nil { + t.Fatalf("加载物品注册表失败: %v", err) + } + recipes, err := recipe.Load(filepath.Join("..", "..", "assets", "config", "recipes.json")) + if err != nil { + t.Fatalf("加载配方失败: %v", err) + } + gen, err := worldgen.New(reg, 42, 0.005, 0.01, 0.1) + if err != nil { + t.Fatalf("创建生成器失败: %v", err) + } + w, err := world.New(reg, gen, 3) + if err != nil { + t.Fatalf("创建世界失败: %v", err) + } + t.Cleanup(w.Close) + + // 等待 (0,0) 区块 + 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 + } + } + p := physics.NewPlayer(8.5, float64(surf+1), 8.5) + cam := camera.New(p.Eye(), 0, -math.Pi/2, 70) + inv := inventory.New(items) + inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1} // 选中的工具 + inv.Slots[1] = item.Stack{Name: "oak_planks", Count: 10} + return NewSession(w, reg, items, recipes, p, inv, cam) +} + +// TestBreakAndPlace 完整闭环:破坏地表 → 放木板(方块交互与动画.md §3、§6)。 +func TestBreakAndPlace(t *testing.T) { + s := newTestSession(t) + + // 破坏脚下地表的草方块(向下看) + s.BeginBreak() + if !s.breaking { + t.Fatal("应锁定破坏目标") + } + tx, ty, tz := s.BreakTarget().X, s.BreakTarget().Y, s.BreakTarget().Z + steps := 0 + for s.breaking && steps < 600 { + s.Tick(1.0 / 20) + steps++ + } + if s.breaking { + t.Fatal("钻石镐 600 tick 未破坏草方块") + } + if s.World.Block(tx, ty, tz) != block.Air { + t.Fatal("破坏后应为空气") + } + + // 放置木板(切换到槽 1) + s.Inv.Selected = 1 + s.PlaceSelected() + if name := s.Reg.Name(s.World.Block(tx, ty, tz).ID()); name != "oak_planks" { + t.Fatalf("放置后应为橡木板,实际 %s", name) + } + if s.Inv.Slots[1].Count != 9 { + t.Fatalf("木板应剩 9,实际 %d", s.Inv.Slots[1].Count) + } +} + +// TestBreakSpeed 验证工具速度公式(挖矿与矿物.md §3)。 +func TestBreakSpeed(t *testing.T) { + s := newTestSession(t) + stone := s.Reg.Get(mustID(t, s.Reg, "stone")) + // 空手 + if got := s.breakSpeed(stone, item.Stack{}); got < 1/1.5-0.01 || got > 1/1.5+0.01 { + t.Fatalf("空手速度期望约 1/1.5,实际 %v", got) + } + // 钻石镐(speed 8;Count 必须 ≥1 才是非空栈) + withTool := item.Stack{Name: "diamond_pickaxe", Count: 1} + if got := s.breakSpeed(stone, withTool); got < 8/1.5-0.01 || got > 8/1.5+0.01 { + t.Fatalf("钻石镐速度期望约 8/1.5,实际 %v", got) + } +} + +// mustID 取方块 ID(测试辅助)。 +func mustID(t *testing.T, reg *block.Registry, name string) uint16 { + t.Helper() + id, ok := reg.ID(name) + if !ok { + t.Fatalf("方块 %q 未注册", name) + } + return id +} diff --git a/internal/world/world.go b/internal/world/world.go index f4ddc6cd..20e3fdac 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -274,6 +274,12 @@ func (w *World) SetSkyLight(x, y, z int32, v uint8) { } } +// Solid 碰撞判定(physics.World 接口实现,物理与碰撞.md §2 共用判定表)。 +func (w *World) Solid(x, y, z int32) bool { return w.reg.IsSolid(w.Block(x, y, z)) } + +// Fluid 液体判定(physics.World 接口实现)。 +func (w *World) Fluid(x, y, z int32) bool { return w.reg.IsFluid(w.Block(x, y, z)) } + // chunkOf 定位区块(短锁)。 func (w *World) chunkOf(x, z int32) *chunk.Chunk { w.mu.Lock()