feat(game): 相机矩阵与游戏会话(破坏进度/放置/工具速度,含测试)
This commit is contained in:
72
internal/camera/camera.go
Normal file
72
internal/camera/camera.go
Normal 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
|
||||
}
|
||||
45
internal/camera/camera_test.go
Normal file
45
internal/camera/camera_test.go
Normal 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
192
internal/game/game.go
Normal 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
125
internal/game/game_test.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user