feat(interact): DDA 射线拾取与玩家逐轴物理(含测试)
This commit is contained in:
114
internal/physics/player.go
Normal file
114
internal/physics/player.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
61
internal/physics/player_test.go
Normal file
61
internal/physics/player_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user