From 4e6019fa3092f07fe7f69810c1965be702535538 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 22:42:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(interact):=20DDA=20=E5=B0=84=E7=BA=BF?= =?UTF-8?q?=E6=8B=BE=E5=8F=96=E4=B8=8E=E7=8E=A9=E5=AE=B6=E9=80=90=E8=BD=B4?= =?UTF-8?q?=E7=89=A9=E7=90=86=EF=BC=88=E5=90=AB=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/mesh/mesh.go | 1 + internal/physics/player.go | 114 +++++++++++++++++++++++++++++++ internal/physics/player_test.go | 61 +++++++++++++++++ internal/raycast/raycast.go | 74 ++++++++++++++++++++ internal/raycast/raycast_test.go | 60 ++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 internal/physics/player.go create mode 100644 internal/physics/player_test.go create mode 100644 internal/raycast/raycast.go create mode 100644 internal/raycast/raycast_test.go diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 0ed4591a..eaad3720 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -16,6 +16,7 @@ type Vertex struct { } // ChunkMesh 一个区块的三类网格(渲染.md §6 三 pass)。 +// 每面 4 顶点(四边形),渲染层用索引缓冲扩为 2 三角形(顺序 0-1-2-2-3-0)。 type ChunkMesh struct { Opaque []Vertex // 不透明(主 pass) Cutout []Vertex // alpha test(树叶/玻璃) diff --git a/internal/physics/player.go b/internal/physics/player.go new file mode 100644 index 00000000..ebeff1fd --- /dev/null +++ b/internal/physics/player.go @@ -0,0 +1,114 @@ +// Package physics 玩家 AABB 碰撞与逐轴移动(场景/物理与碰撞.md §2)。 +// +// 核心:分轴解算(X → Z → Y),每轴移动后检测 AABB 与固体方块重叠,重叠则回退该轴。 +// 不用物理引擎:体素世界自己写最快、最可控。 +package physics + +import "math" + +// World 物理层所需的世界接口(与渲染/交互共用同一张 solid 判定表)。 +type World interface { + // Solid 是否碰撞(台阶/栅栏等多 AABB 形状后置,MVP 按整方块判定)。 + Solid(x, y, z int32) bool + // Fluid 是否液体(游泳/浮力判定)。 + Fluid(x, y, z int32) bool +} + +// Player 玩家实体(设计.md §7.1:宽 0.6、高 1.8、眼高 1.62)。 +type Player struct { + Pos [3]float64 // 脚底坐标 + Vel [3]float64 // 速度(方块/秒) + + Width float64 + Height float64 + EyeHeight float64 + + OnGround bool // 落地 + Submerged bool // 在液体中 + Flying bool // 飞行模式 + + // 累积的位移余量(亚步进精度,防边界抖动) + rem [3]float64 +} + +// NewPlayer 创建默认玩家(站立尺寸)。 +func NewPlayer(x, y, z float64) *Player { + return &Player{ + Pos: [3]float64{x, y, z}, + Width: 0.6, + Height: 1.8, + EyeHeight: 1.62, + } +} + +// Move 逐轴移动(物理与碰撞.md §2.2):X → Z → Y,每轴独立碰撞回退。 +func (p *Player) Move(w World, dx, dy, dz float64) { + p.OnGround = false + deltas := [3]float64{dx, dy, dz} + for _, axis := range [3]int{0, 2, 1} { + p.Pos[axis] += deltas[axis] + if p.Collides(w) { + p.Pos[axis] -= deltas[axis] + p.Vel[axis] = 0 + if axis == 1 && deltas[axis] < 0 { + p.OnGround = true // 垂直下落被阻挡 = 落地 + } + } + } + // 液体状态更新(头部/脚部任一在水里) + headY := p.Pos[1] + p.Height*0.6 + p.Submerged = w.Fluid(int32(math.Floor(p.Pos[0])), int32(math.Floor(p.Pos[1]+0.3)), int32(math.Floor(p.Pos[2]))) || + w.Fluid(int32(math.Floor(p.Pos[0])), int32(math.Floor(headY)), int32(math.Floor(p.Pos[2]))) +} + +// Collides 当前位置 AABB 是否与固体方块重叠(含边界 epsilon 防抖动)。 +func (p *Player) Collides(w World) bool { + const eps = 1e-6 + minX := int32(math.Floor(p.Pos[0] - p.Width/2 + eps)) + maxX := int32(math.Floor(p.Pos[0] + p.Width/2 - eps)) + minY := int32(math.Floor(p.Pos[1] + eps)) + maxY := int32(math.Floor(p.Pos[1] + p.Height - eps)) + minZ := int32(math.Floor(p.Pos[2] - p.Width/2 + eps)) + maxZ := int32(math.Floor(p.Pos[2] + p.Width/2 - eps)) + for x := minX; x <= maxX; x++ { + for y := minY; y <= maxY; y++ { + for z := minZ; z <= maxZ; z++ { + if w.Solid(x, y, z) { + return true + } + } + } + } + return false +} + +// Eye 返回眼睛位置(相机位置)。 +func (p *Player) Eye() [3]float64 { + return [3]float64{p.Pos[0], p.Pos[1] + p.EyeHeight, p.Pos[2]} +} + +// Tick 重力与液体阻力积分(dt 秒;调用方随后调用 Move 应用位移)。 +// 飞行/游泳模式由调用方在 Move 前覆写速度。 +func (p *Player) Tick(w World, dt float64) { + const gravity = 32.0 // 方块/秒² + if !p.Flying { + if p.Submerged { + p.Vel[1] -= gravity * 0.3 * dt // 水中浮力近似 + p.Vel[1] *= 1 - 2.0*dt // 阻力 + } else { + p.Vel[1] -= gravity * dt + } + if p.Vel[1] < -64 { + p.Vel[1] = -64 // 终端速度 + } + } + p.Move(w, p.Vel[0]*dt, p.Vel[1]*dt, p.Vel[2]*dt) +} + +// Jump 起跳(仅落地时生效;约 1.25 方块高度)。 +func (p *Player) Jump() { + if p.OnGround { + p.Vel[1] = 9.0 + p.OnGround = false + } +} diff --git a/internal/physics/player_test.go b/internal/physics/player_test.go new file mode 100644 index 00000000..f44ebda4 --- /dev/null +++ b/internal/physics/player_test.go @@ -0,0 +1,61 @@ +package physics + +import "testing" + +// planeWorld 测试世界:y < 63 全固体,y >= 63 空气;x == 5 一堵墙。 +type planeWorld struct{} + +func (planeWorld) Solid(x, y, z int32) bool { + return y < 63 || x == 5 +} +func (planeWorld) Fluid(x, y, z int32) bool { return false } + +// TestFallOntoGround 下落 → 停在 63 层顶部(浮点余量内),OnGround 置位。 +func TestFallOntoGround(t *testing.T) { + w := planeWorld{} + p := NewPlayer(0.5, 80, 0.5) + for i := 0; i < 200; i++ { + p.Tick(w, 1.0/20) + } + if p.Pos[1] < 63 || p.Pos[1] >= 63.5 { + t.Fatalf("脚底期望 [63, 63.5),实际 %v", p.Pos[1]) + } + if !p.OnGround { + t.Fatal("应判定落地") + } +} + +// TestWallBlock 水平移动撞墙不穿墙(物理与碰撞.md §8 踩坑)。 +func TestWallBlock(t *testing.T) { + w := planeWorld{} + p := NewPlayer(4.0, 63, 0.5) // 墙在 x=5,玩家宽 0.6(右缘 4.3) + p.Vel[0] = 10 + p.Tick(w, 1.0/20) + if p.Pos[0]+p.Width/2 > 5 { + t.Fatalf("穿墙:右缘 %v 超过墙 x=5", p.Pos[0]+p.Width/2) + } +} + +// TestJump 起跳与回落。 +func TestJump(t *testing.T) { + w := planeWorld{} + p := NewPlayer(0.5, 63, 0.5) + p.Tick(w, 1.0/20) // 先落地判定 + if !p.OnGround { + t.Fatal("初始应在地面") + } + p.Jump() + peak := 0.0 + for i := 0; i < 100; i++ { + p.Tick(w, 1.0/20) + if p.Pos[1] > peak { + peak = p.Pos[1] + } + if p.OnGround && i > 5 { + break + } + } + if peak < 64.0 { + t.Fatalf("起跳高度过低: %v", peak) + } +} diff --git a/internal/raycast/raycast.go b/internal/raycast/raycast.go new file mode 100644 index 00000000..e8b79fee --- /dev/null +++ b/internal/raycast/raycast.go @@ -0,0 +1,74 @@ +// Package raycast 体素射线拾取:DDA 步进(场景/方块交互与动画.md §2)。 +// +// 返回三元组:hitBlock(被击中方块)、faceNormal(击中面法线)、prevBlock(放置位)。 +// 客户端与服务器共用同一实现,保证双端判定一致。 +package raycast + +import "math" + +// Hit 射线命中结果。 +type Hit struct { + X, Y, Z int32 // 被击中的方块坐标 + Face [3]int32 // 击中面法线(±1 单位向量) + Prev [3]int32 // 放置位 = 被击中方块 - 面法线 + Dist float64 // 命中距离 +} + +// Solid 方块可交互判定回调(用注册表 IsSolid 等)。 +type Solid func(x, y, z int32) bool + +// Cast 从 origin 沿 dir 做 DDA 体素步进,最大距离 maxDist,返回首个 solid 命中。 +// dir 应为单位向量;分量接近 0 时对应轴 tDelta 取 +Inf(不会在该轴步进)。 +func Cast(origin, dir [3]float64, maxDist float64, solid Solid) (Hit, bool) { + x, y, z := int32(math.Floor(origin[0])), int32(math.Floor(origin[1])), int32(math.Floor(origin[2])) + // 基准取整值(负数安全:不能用 int32() 截断,-0.5 会被截成 0) + fx, fy, fz := math.Floor(origin[0]), math.Floor(origin[1]), math.Floor(origin[2]) + + var step [3]int32 + var tDelta, tMax [3]float64 + for i := 0; i < 3; i++ { + base := [3]float64{fx, fy, fz}[i] + switch { + case dir[i] > 0: + step[i] = 1 + tDelta[i] = 1 / dir[i] + tMax[i] = (base + 1 - origin[i]) / dir[i] + case dir[i] < 0: + step[i] = -1 + tDelta[i] = -1 / dir[i] + tMax[i] = (origin[i] - base) / (-dir[i]) + default: + step[i] = 0 + tDelta[i] = math.Inf(1) + tMax[i] = math.Inf(1) + } + } + + var face [3]int32 + dist := 0.0 + for dist <= maxDist { + if solid(x, y, z) { + // 当前格命中:面法线 = 上一步进入方向 + return Hit{X: x, Y: y, Z: z, Face: face, Prev: [3]int32{x - face[0], y - face[1], z - face[2]}, Dist: dist}, true + } + // 选择 tMax 最小的轴步进 + switch { + case tMax[0] < tMax[1] && tMax[0] < tMax[2]: + dist = tMax[0] + x += step[0] + tMax[0] += tDelta[0] + face = [3]int32{-step[0], 0, 0} + case tMax[1] < tMax[2]: + dist = tMax[1] + y += step[1] + tMax[1] += tDelta[1] + face = [3]int32{0, -step[1], 0} + default: + dist = tMax[2] + z += step[2] + tMax[2] += tDelta[2] + face = [3]int32{0, 0, -step[2]} + } + } + return Hit{}, false +} diff --git a/internal/raycast/raycast_test.go b/internal/raycast/raycast_test.go new file mode 100644 index 00000000..93913323 --- /dev/null +++ b/internal/raycast/raycast_test.go @@ -0,0 +1,60 @@ +package raycast + +import "testing" + +// solidAt 只在指定方块返回 solid。 +func solidAt(sx, sy, sz int32) Solid { + return func(x, y, z int32) bool { return x == sx && y == sy && z == sz } +} + +// TestCastDown 垂直向下命中顶面:face=(0,1,0),prev 在方块上方。 +func TestCastDown(t *testing.T) { + h, ok := Cast([3]float64{0.5, 70, 0.5}, [3]float64{0, -1, 0}, 100, solidAt(0, 64, 0)) + if !ok { + t.Fatal("应命中方块") + } + if h.X != 0 || h.Y != 64 || h.Z != 0 { + t.Fatalf("命中坐标异常: %+v", h) + } + if h.Face != [3]int32{0, 1, 0} { + t.Fatalf("面法线期望 (0,1,0),实际 %+v", h.Face) + } + if h.Prev != [3]int32{0, 65, 0} { + t.Fatalf("放置位期望 (0,65,0),实际 %+v", h.Prev) + } +} + +// TestCastMiss 未命中(距离超限)。 +func TestCastMiss(t *testing.T) { + if _, ok := Cast([3]float64{0.5, 70, 0.5}, [3]float64{1, 0, 0}, 3, solidAt(100, 70, 100)); ok { + t.Fatal("不应命中") + } +} + +// TestCastDiagonal 沿 +z 直线命中方块北面(face=(0,0,-1))。 +func TestCastDiagonal(t *testing.T) { + h, ok := Cast([3]float64{1.0, 0.5, -5}, [3]float64{0, 0, 1}, 20, solidAt(1, 0, 1)) + if !ok { + t.Fatal("应命中") + } + if h.X != 1 || h.Z != 1 { + t.Fatalf("命中坐标异常: %+v", h) + } + if h.Face != [3]int32{0, 0, -1} { + t.Fatalf("面法线期望 (0,0,-1),实际 %+v", h.Face) + } + if h.Prev != [3]int32{1, 0, 0} { + t.Fatalf("放置位期望 (1,0,0),实际 %+v", h.Prev) + } +} + +// TestCastNegative 负坐标区域命中(负坐标安全回归)。 +func TestCastNegative(t *testing.T) { + h, ok := Cast([3]float64{-0.5, 70.5, -0.5}, [3]float64{0, -1, 0}, 100, solidAt(-1, 64, -1)) + if !ok { + t.Fatal("负坐标应命中") + } + if h.X != -1 || h.Z != -1 { + t.Fatalf("负坐标命中异常: %+v", h) + } +} \ No newline at end of file