The package keeps the rules and hands them over each step: speed and heading, whether a jump leaves, CHAR_STEP and the boots' slope limit. The walker (Jolt CharacterVirtual) does gravity, landing, steps, slopes and following the ground down. Too steep is ground too steep facing the way the body wanted to go; stepping off an edge is not. ludic.physics: phys_walker_move (follow the body if something else moved it, then step), the slope limit, the jump always honoured. The character's tests run on a real Jolt walker over the same fake world: 15 pass, a knee-high boulder now a step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
15 KiB
Text
318 lines
15 KiB
Text
# character_test.ludic - a body on a fake world: flat meadow, a 45 cm deck, a gentle rise and a cliff,
|
|
# a boulder with a step on top, and a lake that shelves. The step against the slope at every frame
|
|
# rate, wading into a swim and out, the dive and the breath, the jump, sitting, holds, the safe
|
|
# spot, riding and the camera. The walk is a real Jolt walker (ludic.physics) on the same world.
|
|
import "ludic.character"
|
|
import "ludic.base"
|
|
import "ludic.physics"
|
|
program CharacterTest {
|
|
numbers float
|
|
|
|
# the world: x in 4..8 is a deck 45 cm up; z < -10 a cliff; z > 10 a gentle rise; x > 20 the lake
|
|
function fk_height(x: float, z: float) -> float {
|
|
if x > 4.0 and x < 8.0 and z > -2.0 and z < 2.0 { return 0.45 }
|
|
return fk_land(x, z)
|
|
}
|
|
function fk_land(x: float, z: float) -> float {
|
|
if x > 20.0 { return -(x - 20.0) * 0.5 }
|
|
if z < -10.0 { return (-10.0 - z) * 2.0 }
|
|
if z > 10.0 { return (z - 10.0) * 0.3 }
|
|
return 0.0
|
|
}
|
|
function fk_water(x: float, z: float) -> float {
|
|
if x > 19.0 { return 0.0 }
|
|
return -10000.0
|
|
}
|
|
# a boulder at (0, 6), a metre round and 40 cm high, and a pillar at (-6, 0)
|
|
state CharacterTestState {
|
|
px: float = 0.0
|
|
pz: float = 0.0
|
|
riding: bool = false
|
|
rides: int = 0
|
|
sneaking: bool = false
|
|
pace: float = 1.0
|
|
mz: float = 0.0
|
|
boots: float = 1.05
|
|
walker: int = -1
|
|
wx: float = 0.0
|
|
wy: float = 0.0
|
|
wz: float = 0.0
|
|
}
|
|
function fk_ring(character_test_st: mut CharacterTestState, x: float, z: float, cx: float, cz: float, r: float) -> bool {
|
|
let dx = x - cx
|
|
let dz = z - cz
|
|
let d = Math.sqrt(dx * dx + dz * dz)
|
|
if d >= r { return false }
|
|
let k = r / Math.max(d, 0.001)
|
|
character_test_st.px = cx + dx * k
|
|
character_test_st.pz = cz + dz * k
|
|
return true
|
|
}
|
|
function fk_push(character_test_st: mut CharacterTestState, x: float, z: float, r: float, y0: float, y1: float) -> bool {
|
|
if y0 < 0.4 and fk_ring(character_test_st, x, z, 0.0, 6.0, 1.0 + r) { return true }
|
|
return fk_ring(character_test_st, x, z, -6.0, 0.0, 0.5 + r)
|
|
}
|
|
function fk_px(character_test_st: CharacterTestState) -> float { return character_test_st.px }
|
|
function fk_pz(character_test_st: CharacterTestState) -> float { return character_test_st.pz }
|
|
function fk_top(x: float, z: float, r: float, from_y: float, step: float, base: float) -> float {
|
|
let dx = x
|
|
let dz = z - 6.0
|
|
if dx * dx + dz * dz < 1.0 and 0.4 - from_y <= step { return 0.4 }
|
|
return base
|
|
}
|
|
|
|
let eyes: bool = true
|
|
function fk_riding(character_test_st: CharacterTestState) -> bool { return character_test_st.riding }
|
|
function fk_ride(character_test_st: mut CharacterTestState, ix: float, iz: float, run: bool, dt: float) -> void { character_test_st.rides += 1 }
|
|
function fk_sneak(character_test_st: CharacterTestState) -> bool { return character_test_st.sneaking }
|
|
function fk_pace(character_test_st: CharacterTestState) -> float { return character_test_st.pace }
|
|
function fk_eyes() -> bool { return eyes }
|
|
|
|
function fk_move_z(character_test_st: CharacterTestState) -> float { return character_test_st.mz }
|
|
|
|
# the same world in Jolt: the land as a heightfield, the deck a box, the boulder a 40 cm drum and
|
|
# the pillar a post
|
|
function fk_jolt(physics_st: mut PhysicsState) -> int {
|
|
phys_open(physics_st, 256, 1)
|
|
let n = 128
|
|
let h = floats(n * n)
|
|
for j in 0 .. n { for i in 0 .. n { h[j * n + i] = fk_land(float(i) - 40.0, float(j) - 60.0) } }
|
|
phys_ground_add(physics_st, phys_heightfield(physics_st, h, n, -40.0, -60.0, 1.0))
|
|
phys_static_add(physics_st, phys_offset(physics_st, phys_box(physics_st, 2.0, 0.225, 2.0), 0.0, 0.225, 0.0, 0.0), 6.0, 0.0, 0.0, 0.0)
|
|
phys_static_add(physics_st, phys_offset(physics_st, phys_cylinder(physics_st, 0.2, 1.0), 0.0, 0.2, 0.0, 0.0), 0.0, 0.0, 6.0, 0.0)
|
|
phys_static_add(physics_st, phys_offset(physics_st, phys_cylinder(physics_st, 3.0, 0.5), 0.0, 3.0, 0.0, 0.0), -6.0, 0.0, 0.0, 0.0)
|
|
phys_settle(physics_st)
|
|
return phys_walker_add(physics_st, 0.35, CHAR_BODY, 0.0, 0.0, 0.0, 50.0, 70.0)
|
|
}
|
|
function fk_walk(physics_st: mut PhysicsState, character_test_st: mut CharacterTestState, x: float, y: float, z: float, vx: float, vz: float, jump: float, step: float, slope: float, dt: float) -> int {
|
|
if character_test_st.walker < 0 { character_test_st.walker = fk_jolt(physics_st) }
|
|
let g = phys_walker_move(physics_st, character_test_st.walker, x, y, z, vx, vz, jump, step, slope, dt)
|
|
let p = phys_walker_pose(physics_st, character_test_st.walker)
|
|
character_test_st.wx = p.x
|
|
character_test_st.wy = p.y
|
|
character_test_st.wz = p.z
|
|
return g
|
|
}
|
|
function fk_wx(character_test_st: CharacterTestState) -> float { return character_test_st.wx }
|
|
function fk_wy(character_test_st: CharacterTestState) -> float { return character_test_st.wy }
|
|
function fk_wz(character_test_st: CharacterTestState) -> float { return character_test_st.wz }
|
|
function fk_boots(character_test_st: CharacterTestState) -> float { return character_test_st.boots }
|
|
|
|
bind CharacterGround { height: fn fk_height, water: fn fk_water, push: fn fk_push, pushed_x: fn fk_px, pushed_z: fn fk_pz, top_at: fn fk_top, walk: fn fk_walk, walked_x: fn fk_wx, walked_y: fn fk_wy, walked_z: fn fk_wz, slope_limit: fn fk_boots }
|
|
bind CharacterBody { riding: fn fk_riding, ride: fn fk_ride, sneaking: fn fk_sneak, pace: fn fk_pace, eyes_ok: fn fk_eyes }
|
|
bind CharacterInput { move_z: fn fk_move_z }
|
|
|
|
function fresh(character_st: mut CharacterState, x: float, z: float, yaw: float) -> void {
|
|
char_reset(character_st)
|
|
char_release(character_st, CHAR_HOLD_SCREEN)
|
|
char_teleport(character_st, x, z, yaw)
|
|
}
|
|
function facts(character_st: mut CharacterState) -> []CharacterFact { return q_drain(char_facts(character_st)) }
|
|
function count(fs: []CharacterFact, what: int) -> int {
|
|
var n = 0
|
|
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
|
|
return n
|
|
}
|
|
# walk the stick straight ahead (the camera looks where the body faces) for `secs`
|
|
function walk(character_st: mut CharacterState, secs: float, hz: float, run: bool) -> void {
|
|
let n = int(secs * hz)
|
|
for i in 0 .. n { char_update(character_st, 0.0, 1.0, run, false, 0.0, 0.0, 1.0 / hz) }
|
|
}
|
|
# yaw 0 faces -z; the heading of a walk toward +x is -pi/2
|
|
const EAST: float = -1.5707963
|
|
const SOUTH: float = 3.14159265
|
|
|
|
test "a 45 cm deck is a step at 60 Hz and at 240" (character_st: mut CharacterState) {
|
|
fresh(character_st, 1.0, 0.0, EAST)
|
|
walk(character_st, 2.0, 60.0, false)
|
|
expect(body_x(character_st) > 5.0)
|
|
expect_near(body_y(character_st), 0.45, 0.01)
|
|
fresh(character_st, 1.0, 0.0, EAST)
|
|
walk(character_st, 2.0, 240.0, false)
|
|
expect(body_x(character_st) > 5.0)
|
|
expect_near(body_y(character_st), 0.45, 0.01)
|
|
expect_eq(count(facts(character_st), CHAR_TOO_STEEP), 0)
|
|
}
|
|
|
|
test "a cliff is refused at the same place whatever the frame rate, and said once" (character_st: mut CharacterState) {
|
|
fresh(character_st, 0.0, -8.0, 0.0)
|
|
walk(character_st, 3.0, 30.0, false)
|
|
let at30 = body_z(character_st)
|
|
expect(body_steep(character_st) > 0.0)
|
|
expect_eq(count(facts(character_st), CHAR_TOO_STEEP), 1)
|
|
fresh(character_st, 0.0, -8.0, 0.0)
|
|
walk(character_st, 3.0, 240.0, false)
|
|
expect_near(body_z(character_st), at30, 0.25)
|
|
expect(body_z(character_st) > -10.0)
|
|
expect(body_y(character_st) < 0.3)
|
|
}
|
|
|
|
test "a gentle rise is walked up" (character_st: mut CharacterState) {
|
|
fresh(character_st, 0.0, 8.0, SOUTH)
|
|
walk(character_st, 3.0, 60.0, false)
|
|
expect(body_z(character_st) > 14.0)
|
|
expect(body_y(character_st) > 1.0)
|
|
}
|
|
|
|
test "the boots set the limit: a rise they cannot take is refused" (character_st: mut CharacterState, character_test_st: mut CharacterTestState) {
|
|
character_test_st.boots = 0.2
|
|
fresh(character_st, 0.0, 8.0, SOUTH)
|
|
walk(character_st, 3.0, 60.0, false)
|
|
expect(body_z(character_st) < 11.5)
|
|
expect(body_y(character_st) < 0.45)
|
|
expect(count(facts(character_st), CHAR_TOO_STEEP) >= 1)
|
|
}
|
|
|
|
test "the walk reaches the walk speed, the run the run speed, a sneak its own" (character_st: mut CharacterState, character_test_st: mut CharacterTestState) {
|
|
fresh(character_st, 0.0, 0.0, SOUTH)
|
|
walk(character_st, 2.0, 60.0, false)
|
|
expect_near(body_speed(character_st), 3.2, 0.05)
|
|
fresh(character_st, 0.0, 0.0, SOUTH)
|
|
walk(character_st, 2.0, 60.0, true)
|
|
expect_near(body_speed(character_st), 6.8, 0.05)
|
|
character_test_st.pace = 0.5
|
|
fresh(character_st, 0.0, 0.0, SOUTH)
|
|
walk(character_st, 2.0, 60.0, false)
|
|
expect_near(body_speed(character_st), 1.6, 0.05)
|
|
character_test_st.pace = 1.0
|
|
character_test_st.sneaking = true
|
|
fresh(character_st, 0.0, 0.0, SOUTH)
|
|
walk(character_st, 2.0, 60.0, true)
|
|
expect_near(body_speed(character_st), 1.3, 0.05)
|
|
}
|
|
|
|
test "a knee-high boulder is a step onto its top, and a post is a wall" (character_st: mut CharacterState) {
|
|
fresh(character_st, 0.0, 3.0, SOUTH)
|
|
walk(character_st, 1.3, 60.0, false)
|
|
expect(body_z(character_st) > 5.2)
|
|
expect_near(body_y(character_st), 0.4, 0.03)
|
|
fresh(character_st, -3.0, 0.0, -EAST)
|
|
walk(character_st, 2.0, 60.0, false)
|
|
expect(body_x(character_st) > -5.2)
|
|
expect_near(char_stand_at(0.0, 6.0, 0.0), 0.4, 0.001)
|
|
expect_near(char_stand_at(3.0, 6.0, 0.0), 0.0, 0.001)
|
|
# and the world's own ground never includes it
|
|
expect_near(fk_height(0.0, 6.0), 0.0, 0.001)
|
|
}
|
|
|
|
test "wading shelves into a swim, and the shallows give the ground back" (character_st: mut CharacterState) {
|
|
fresh(character_st, 18.0, 0.0, EAST)
|
|
walk(character_st, 3.0, 60.0, false)
|
|
let fs = facts(character_st)
|
|
expect(body_swimming(character_st))
|
|
expect_eq(count(fs, CHAR_STARTED_SWIMMING), 1)
|
|
expect_near(body_y(character_st), -0.30, 0.06)
|
|
expect(body_breath(character_st) > 40.0)
|
|
char_face(character_st, -EAST)
|
|
char_look(character_st, -EAST, 0.0)
|
|
walk(character_st, 8.0, 60.0, false)
|
|
expect(not body_swimming(character_st))
|
|
expect_eq(count(facts(character_st), CHAR_LEFT_WATER), 1)
|
|
}
|
|
|
|
test "the jump held dives, the air runs out, and the body surfaces" (character_st: mut CharacterState) {
|
|
fresh(character_st, 30.0, 0.0, 0.0)
|
|
char_resume_swim(character_st, false, 0.0, 2.0)
|
|
for i in 0 .. 600 { char_update(character_st, 0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0) }
|
|
let fs = facts(character_st)
|
|
expect(count(fs, CHAR_OUT_OF_AIR) > 0)
|
|
for i in 0 .. 300 { char_update(character_st, 0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
|
|
expect(body_depth(character_st) < 0.05)
|
|
expect(body_breath(character_st) > 10.0)
|
|
}
|
|
|
|
test "a jump leaves the ground, falls, lands, and says both" (character_st: mut CharacterState) {
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
char_update(character_st, 0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0)
|
|
expect(body_airborne(character_st))
|
|
var guard = 0
|
|
while body_airborne(character_st) and guard < 200 {
|
|
char_update(character_st, 0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0)
|
|
guard += 1
|
|
}
|
|
let fs = facts(character_st)
|
|
expect(not body_airborne(character_st))
|
|
expect_eq(count(fs, CHAR_JUMPED), 1)
|
|
expect_eq(count(fs, CHAR_LANDED), 1)
|
|
expect(guard > 30)
|
|
char_allow_run(character_st, false)
|
|
char_update(character_st, 0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0)
|
|
expect(not body_airborne(character_st))
|
|
}
|
|
|
|
test "the safe spot clears a collider, keeps out of deep water, and allows a wade when asked" (character_st: mut CharacterState) {
|
|
expect(char_safe_spot(character_st, -6.0, 0.0, 0.0, 0.0))
|
|
let dx = body_safe_x(character_st) + 6.0
|
|
let dz = body_safe_z(character_st)
|
|
expect(dx * dx + dz * dz > 0.81)
|
|
expect(not char_safe_spot(character_st, 40.0, 0.0, 0.0, 0.0))
|
|
expect(char_safe_spot(character_st, 20.5, 0.0, 0.0, 1.25))
|
|
expect(char_safe_spot(character_st, 15.0, 0.0, 0.0, 0.0))
|
|
expect_near(body_safe_x(character_st), 15.0, 0.001)
|
|
}
|
|
|
|
test "sitting where you stand, and the refusals" (character_st: mut CharacterState, character_test_st: mut CharacterTestState) {
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
expect_eq(char_sit_here(character_st), CHAR_SAT)
|
|
for i in 0 .. 30 { char_update(character_st, 0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
|
|
expect(body_sitting(character_st))
|
|
expect_near(body_sit_t(character_st), 1.0, 0.01)
|
|
expect_eq(char_sit_here(character_st), CHAR_STOOD)
|
|
expect(not body_sitting(character_st))
|
|
fresh(character_st, 0.0, -14.0, 0.0)
|
|
expect_eq(char_sit_here(character_st), CHAR_NOT_STEEP)
|
|
fresh(character_st, 24.0, 0.0, 0.0)
|
|
char_resume_swim(character_st, false, 0.0, 10.0)
|
|
expect_eq(char_sit_here(character_st), CHAR_NOT_WET)
|
|
character_test_st.riding = true
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
expect_eq(char_sit_here(character_st), CHAR_NOT_RIDING)
|
|
character_test_st.riding = false
|
|
}
|
|
|
|
test "a hold stops the stick, and only its own reason lets it go" (character_st: mut CharacterState, character_test_st: mut CharacterTestState) {
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
character_test_st.mz = 1.0
|
|
char_hold(character_st, CHAR_HOLD_STAGE)
|
|
char_hold(character_st, CHAR_HOLD_SCREEN)
|
|
for i in 0 .. 60 { char_tick(character_st, 1.0 / 60.0) }
|
|
expect_near(body_z(character_st), 0.0, 0.001)
|
|
char_release(character_st, CHAR_HOLD_SCREEN)
|
|
char_release(character_st, CHAR_HOLD_SCREEN)
|
|
expect(body_held(character_st))
|
|
char_release(character_st, CHAR_HOLD_STAGE)
|
|
for i in 0 .. 60 { char_tick(character_st, 1.0 / 60.0) }
|
|
expect(body_z(character_st) < -1.0)
|
|
character_test_st.mz = 0.0
|
|
}
|
|
|
|
test "riding hands the stick over and leaves the body where the ride puts it" (character_st: mut CharacterState, character_test_st: mut CharacterTestState) {
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
character_test_st.riding = true
|
|
character_test_st.rides = 0
|
|
char_board(character_st, 3.0, 3.0, 0.0)
|
|
for i in 0 .. 10 { char_update(character_st, 0.0, 1.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
|
|
expect_eq(character_test_st.rides, 10)
|
|
char_ride_at(character_st, 4.0, 1.2, 3.0, 0.0, 2.0)
|
|
expect_near(body_x(character_st), 4.0, 0.001)
|
|
expect_near(body_y(character_st), 1.2, 0.001)
|
|
character_test_st.riding = false
|
|
char_step_off(character_st, 5.0, 0.0)
|
|
expect_near(body_y(character_st), 0.45, 0.001)
|
|
}
|
|
|
|
test "the orbit sits behind and above the ground; the eyes at head height, refused when sat" (character_st: mut CharacterState) {
|
|
fresh(character_st, 0.0, 0.0, 0.0)
|
|
char_camera(character_st, 1.0)
|
|
expect(body_eye_z(character_st) > 1.0)
|
|
expect(body_eye_y(character_st) > 0.55)
|
|
expect(not body_eyes_now(character_st))
|
|
expect_eq(char_fps_toggle(character_st), CHAR_EYES_ON)
|
|
char_camera(character_st, 1.0)
|
|
expect(body_eyes_now(character_st))
|
|
expect_near(body_eye_y(character_st), 1.62, 0.05)
|
|
expect_eq(char_fps_toggle(character_st), CHAR_EYES_OFF)
|
|
char_sit(character_st, 0.0, 0.0, 0.0, CHAR_SIT_SEAT)
|
|
expect_eq(char_fps_toggle(character_st), CHAR_EYES_SITTING)
|
|
}
|
|
}
|