feat(controllers): #58 platformer — ludic.platformer package (base + extensible)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Successful in 2m8s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 28s

The reference six-lever controller: Platformer component (jump feel as data),
decomposed input/move/gravity/jump/anim engine sub-systems (each disable-able),
JumpRequested/Landed/StateChanged events, gravity policy enum, and the opt-in
scaffolding (moving/crumble platforms w/ rider carry, pickups+score, springs,
hazards+Life, checkpoints/goal). Reuses the shared esys_move collision.

Also fixes a latent SSA-name collision in ludic_sweep_entity that triggered
once a program declared >=11 events. Reseeded; C-free fixpoint holds.
3 new regression cases (100 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 16:41:34 +03:00
parent ed385efa7b
commit dd5cb5817b
9 changed files with 892 additions and 3 deletions

View file

@ -0,0 +1,112 @@
# platformer_demo.ludic — the ludic.platformer controller (#58) exercised
# headlessly and deterministically over the shared engine collision (entity
# solids): gravity + landing, jump apex height, coyote-time tracking, double jump,
# the JumpRequested veto hook, and a horizontal run into a wall.
#
# Movement intent is written directly (the AI / custom-input path: the input system
# is switched off with `disable system esys_platformer_input`), so the run needs no
# key feed and is fully reproducible. A full run prints:
# 1 1 1 1 1 1 1 1
#
# Build (from the repo root):
# LUDIC_MODULES=packages ludicc --headless examples/games/platformer_demo.ludic
program PlatformerDemo {
import "ludic.platformer/platformer.ludic"
property Position { x: int = 0, y: int = 0 }
property Body {
vx: fixed = 0.0, vy: fixed = 0.0, gravity: fixed = 0.0, max_fall: fixed = 0.0,
rx: fixed = 0.0, ry: fixed = 0.0, policy: int = 0,
on_ground: int = 0, hit_wall: int = 0, hit_ceiling: int = 0
}
property Collider {
w: int = 0, h: int = 0, offx: int = 0, offy: int = 0,
is_trigger: int = 0, one_way: int = 0, layer: int = 0, mask: int = 0,
hit: int = 0, entered: int = 0, exited: int = 0
}
model Hero { Position, Body, Collider, Platformer }
model Floor { Position, Collider }
model Wall { Position, Collider }
@OnSpawn(Hero) handler HeroInit { }
@OnSpawn(Floor) handler FloorInit { }
@OnSpawn(Wall) handler WallInit { }
var jumps: int = 0
@On(JumpPerformed) handler CountJumps { jumps = jumps + 1 }
# a game rule: forbid the air (double) jump unless a "power" flag is set — pure
# lever-4 veto, no controller edit.
var have_power: int = 0
var vetoed_air: int = 0
@On(JumpRequested) handler GateAir { if (kind == 2) and (have_power == 0) { vetoed_air = 1; cancel } }
function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } }
function bi(b: bool) -> int { if b { return 1 }; return 0 }
function pf_coyote(e: int) -> int {
let P = World.prop_id("Platformer")
return World.get(e, P, World.field_id(P, "coyote_t"))
}
entry {
let PB = World.prop_id("Body")
let PP = World.prop_id("Position")
let PL = World.prop_id("Platformer")
let fy = World.field_id(PP, "y")
let fx = World.field_id(PP, "x")
let fgrd = World.field_id(PB, "on_ground")
# a solid floor (top at y=192) and a wall at x=480; both plain solid colliders,
# so the platformer rides the same esys_move sweep the physics tests use.
spawn Floor { Position { x: 0, y: 192 }, Collider { w: 640, h: 32 } }
spawn Wall { Position { x: 480, y: 0 }, Collider { w: 16, h: 192 } }
# the input system is off — drive intent directly for a reproducible test.
disable system esys_platformer_input
spawn Hero { Position { x: 80, y: 40 }, Collider { w: 12, h: 16 },
Platformer { move_speed: 3, jump_height: 4, apex_frames: 14, air_jumps: 1, coyote_frames: 6 } }
let h = World.query_next(PL, 0)
# ---- A. gravity + landing ----------------------------------------------
tick_n(90)
let floor_y = 192 - 16 # feet on the floor top
print(bi(World.get(h, PP, fy) == floor_y)) # 1
print(bi(World.get(h, PB, fgrd) == 1)) # 1
# ---- B. jump apex height ~= jump_height tiles (held for full height) ----
Platformer.hold(h, 1) # hold jump: full-height arc
Platformer.jump(h)
var top = World.get(h, PP, fy)
var i = 0
while i < 20 { tick_fixed(); let y = World.get(h, PP, fy); if y < top { top = y }; i = i + 1 }
let rise = floor_y - top # px risen at apex (target 64 = 4 tiles)
print(bi((rise >= 56) and (rise <= 72))) # 1
Platformer.hold(h, 0)
tick_n(40)
print(bi(World.get(h, PB, fgrd) == 1)) # 1 — landed again
# ---- C. coyote time is tracked (refilled while grounded) ---------------
print(bi(pf_coyote(h) == 6)) # 1
# ---- D. double jump gated by the veto hook -----------------------------
have_power = 0
Platformer.jump(h); tick_n(3) # ground jump -> airborne
let after_ground = jumps
Platformer.jump(h); tick_n(2) # air jump attempt -> vetoed
print(bi((jumps == after_ground) and (vetoed_air == 1))) # 1
have_power = 1
let before_power = jumps
Platformer.jump(h); tick_n(2) # air jump now allowed
print(bi(jumps == before_power + 1)) # 1
tick_n(60) # settle on the floor
# ---- E. horizontal run into the wall (collision reuse) -----------------
Platformer.move(h, 1) # hold right
tick_n(200)
let hx = World.get(h, PP, fx)
print(bi((hx >= 460) and (hx < 480))) # 1 — stopped at the wall (right edge ~468)
quit()
}
}