feat(controllers): #58 platformer — ludic.platformer package (base + extensible)
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:
parent
ed385efa7b
commit
dd5cb5817b
9 changed files with 892 additions and 3 deletions
112
examples/games/platformer_demo.ludic
Normal file
112
examples/games/platformer_demo.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
94
examples/games/platformer_scaffolding.ludic
Normal file
94
examples/games/platformer_scaffolding.ludic
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# platformer_scaffolding.ludic — the ludic.platformer game-loop pieces (#58
|
||||
# layers 4-5): a moving platform that carries its rider, a collectible that scores
|
||||
# on pickup (with a cancellable veto), a spring that launches the player, and a
|
||||
# hazard that damages a Life with i-frames. Deterministic; a full run prints:
|
||||
# 1 1 1 1 1 1
|
||||
#
|
||||
# Build (from the repo root):
|
||||
# LUDIC_MODULES=packages ludicc --headless examples/games/platformer_scaffolding.ludic
|
||||
program PlatformerScaffolding {
|
||||
import "ludic.platformer/platformer.ludic"
|
||||
import "ludic.platformer/scaffolding.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, Score, Life }
|
||||
model MovePlat { Position, Collider, Platform }
|
||||
model Coin { Position, Collider, Pickup }
|
||||
model Bouncer { Position, Collider, Spring }
|
||||
model Spike { Position, Collider, Hazard }
|
||||
@OnSpawn(Hero) handler HI { }
|
||||
@OnSpawn(MovePlat) handler MI { }
|
||||
@OnSpawn(Coin) handler CI { }
|
||||
@OnSpawn(Bouncer) handler BI { }
|
||||
@OnSpawn(Spike) handler SI { }
|
||||
|
||||
var picked: int = 0
|
||||
@On(ItemPickedUp) handler OnPick { picked = picked + 1 }
|
||||
var sprang: int = 0
|
||||
@On(Sprang) handler OnSpring { sprang = sprang + 1 }
|
||||
var hurt: int = 0
|
||||
@On(Hurt) handler OnHurt { hurt = hurt + 1 }
|
||||
|
||||
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 }
|
||||
|
||||
entry {
|
||||
let PP = World.prop_id("Position")
|
||||
let PB = World.prop_id("Body")
|
||||
let PL = World.prop_id("Platformer")
|
||||
let fx = World.field_id(PP, "x")
|
||||
let fy = World.field_id(PP, "y")
|
||||
disable system esys_platformer_input
|
||||
|
||||
# ---- moving platform carries a rider -----------------------------------
|
||||
# platform shuttles x 100->160 at 2px/frame; hero starts standing on top.
|
||||
spawn MovePlat { Position { x: 100, y: 200 }, Collider { w: 48, h: 8 },
|
||||
Platform { kind: 2, ax: 100, ay: 200, bx: 160, by: 200, speed: 2, dir: 1 } }
|
||||
spawn Hero { Position { x: 116, y: 184 }, Collider { w: 12, h: 16 },
|
||||
Platformer { move_speed: 0, jump_height: 4 }, Life { hp: 3, max: 3 } }
|
||||
let h = World.query_next(PL, 0)
|
||||
tick_n(2) # settle onto the platform
|
||||
let x0 = World.get(h, PP, fx)
|
||||
tick_n(10) # platform slides right ~20px
|
||||
let x1 = World.get(h, PP, fx)
|
||||
print(bi(x1 > x0 + 10)) # 1 — the rider was carried right
|
||||
|
||||
# ---- collectible -> score (with a working veto path) -------------------
|
||||
# a coin overlapping the hero; default policy adds value to Score.
|
||||
spawn Coin { Position { x: World.get(h, PP, fx), y: World.get(h, PP, fy) },
|
||||
Collider { w: 10, h: 10, is_trigger: 1 }, Pickup { kind: 0, value: 5 } }
|
||||
tick_n(2)
|
||||
print(bi(picked == 1)) # 1 — ItemPickedUp fired
|
||||
print(bi(Score.get(h) == 5)) # 1 — value scored
|
||||
|
||||
# ---- spring launches the hero upward -----------------------------------
|
||||
let hx = World.get(h, PP, fx)
|
||||
let hy = World.get(h, PP, fy)
|
||||
spawn Bouncer { Position { x: hx, y: hy + 8 }, Collider { w: 16, h: 8, is_trigger: 1 }, Spring { power: 12 } }
|
||||
let vy_before = World.get(h, PB, World.field_id(PB, "vy"))
|
||||
tick_n(2)
|
||||
let vy_after = World.get(h, PB, World.field_id(PB, "vy"))
|
||||
print(bi((sprang >= 1) and (vy_after < 0))) # 1 — launched (vy negative = up)
|
||||
|
||||
# ---- hazard damages a Life --------------------------------------------
|
||||
spawn Spike { Position { x: World.get(h, PP, fx), y: World.get(h, PP, fy) },
|
||||
Collider { w: 12, h: 12, is_trigger: 1 }, Hazard { dmg: 1, knockback: 6 } }
|
||||
tick_n(2)
|
||||
print(bi(hurt >= 1)) # 1 — Hurt fired
|
||||
let PLife = World.prop_id("Life")
|
||||
print(bi(World.get(h, PLife, World.field_id(PLife, "hp")) < 3)) # 1 — hp dropped
|
||||
|
||||
quit()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue