feat(game): 相机矩阵与游戏会话(破坏进度/放置/工具速度,含测试)

This commit is contained in:
NianGao Dev
2026-08-15 22:47:21 +08:00
parent 471cb131eb
commit 82954c9001
5 changed files with 440 additions and 0 deletions

72
internal/camera/camera.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

192
internal/game/game.go Normal file
View File

@@ -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
}

125
internal/game/game_test.go Normal file
View File

@@ -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
}

View File

@@ -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()