diff --git a/changes/controllers-platformer.md b/changes/controllers-platformer.md new file mode 100644 index 00000000..b55f6512 --- /dev/null +++ b/changes/controllers-platformer.md @@ -0,0 +1,3 @@ +bump: minor +type: feat +Builtin Platformer controller (#58) — the reference implementation of the six-lever extensibility contract, shipped as the source package **ludic.platformer**. Movement feel is all defaulted POD data (`Platformer { move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy, … }`); the controller is decomposed into small engine-owned sub-systems — input (Input phase), move/gravity/jump (FixedUpdate, before the shared `esys_move` sweep) and animation-state (LateUpdate, after it) — each independently switch-off-able with `disable system `. It owns movement *policy* only and reuses the engine Body/Collider swept-AABB collision. Jump feel derives gravity + impulse from height/apex, with coyote time, jump buffering, variable jump height and multi-jump; every jump decision emits a `cancellable JumpRequested` / `JumpPerformed` / `Landed` / `StateChanged` event, and a gravity `policy` enum (asymmetric / symmetric / floaty) is the formula hook. Ships the opt-in game-loop layer too (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Deterministic integer Q16.16 throughout. Examples: `examples/games/platformer_demo.ludic`, `examples/games/platformer_scaffolding.ludic`. Also fixes a latent codegen bug in `ludic_sweep_entity` (an SSA register name collided once a program declared ≥11 events). diff --git a/examples/games/platformer_demo.ludic b/examples/games/platformer_demo.ludic new file mode 100644 index 00000000..506a30f4 --- /dev/null +++ b/examples/games/platformer_demo.ludic @@ -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() + } +} diff --git a/examples/games/platformer_scaffolding.ludic b/examples/games/platformer_scaffolding.ludic new file mode 100644 index 00000000..3e83ad59 --- /dev/null +++ b/examples/games/platformer_scaffolding.ludic @@ -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() + } +} diff --git a/packages/ludic.platformer/package.ludic b/packages/ludic.platformer/package.ludic new file mode 100644 index 00000000..0dbf627b --- /dev/null +++ b/packages/ludic.platformer/package.ludic @@ -0,0 +1,14 @@ +# ludic.platformer — the builtin Platformer controller (#58). +# +# The reference implementation of the six-lever extensibility contract (#57): +# movement feel as data, decomposed engine sub-systems (each `disable system`-able), +# a cancellable/observable event at every jump decision, and a gravity policy enum. +# Builds on the engine's Body/Collider + esys_move (swept-AABB tile collision) — it +# owns movement *policy*, never re-implements collision. Deterministic (integer +# Q16.16), so replay / lockstep / world_save all hold. +package "ludic.platformer" +version "0.1.0" +kind source +provides "Platformer" +provides "Score" +provides "Life" diff --git a/packages/ludic.platformer/platformer.ludic b/packages/ludic.platformer/platformer.ludic new file mode 100644 index 00000000..d394bc2d --- /dev/null +++ b/packages/ludic.platformer/platformer.ludic @@ -0,0 +1,314 @@ +# platformer.ludic — the builtin Platformer controller (#58), the reference +# implementation of the six-lever extensibility contract (#57). +# +# It is *policy on top of mechanism*: the shared engine `esys_move` (Body/Collider, +# runtime/native/systems_move.ludic) owns integration + swept-AABB tile collision; +# this package owns movement feel. The controller is decomposed into small +# engine-owned sub-systems, each registered with @EngineSystem and therefore +# independently switch-off-able with `disable system ` (lever 5): +# +# Input phase esys_platformer_input input actions -> intent fields +# FixedUpdate phase esys_platformer_move accel/friction/air-control -> Body.vx +# esys_platformer_gravity apex/fall-mul/variable-jump -> Body.gravity +# esys_platformer_jump coyote + buffer + air-jumps -> Body.vy +# (Update phase esys_move the sweep — core engine system) +# LateUpdate phase esys_platformer_anim grounded/vx/vy -> state + events +# +# Running the control systems *before* the Update sweep (Input/FixedUpdate) and the +# animation state *after* it (LateUpdate) is how the ordering lever (3) is realised +# with today's phases: a game handler in Update runs after the controller set vx +# but before the sweep; a handler in LateUpdate runs after collision resolved. +# +# Everything is integer Q16.16 (velocities are raw fixed bits, same as esys_move), +# so replay, lockstep and world_save all hold. Nothing here is a float. + +const PLAT_Q: int = 65536 # Q16.16 one whole pixel + +# --------------------------------------------------------------------------- +# The controller component. Every "feel" number is a defaulted POD field (lever +# 1: tuning is data). `want_*` are the intent fields the input system fills — or +# that an AI / a game writes directly after `disable system esys_platformer_input` +# (this is exactly how #61 drives an NPC through the same controller). The rest is +# engine-owned state the sub-systems keep between frames. +# --------------------------------------------------------------------------- +property Platformer { + # --- tuning (lever 1) --- + move_speed: int = 3, # ground max speed, px/frame + accel: int = 60, # ground responsiveness, % of the speed gap closed / frame + air_control: int = 40, # airborne responsiveness, % + friction: int = 50, # ground stopping, % of speed shed / frame when idle + jump_height: int = 4, # tiles; gravity + impulse are derived from this + apex + apex_frames: int = 14, # frames to the top of the jump (defines gravity — feel) + fall_gravity_mul: int = 160, # 100 = symmetric; >100 = snappier fall (Celeste) + coyote_frames: int = 6, # grace frames to still jump after leaving a ledge + jump_buffer_frames: int = 6, # grace frames to buffer a jump pressed before landing + max_fall: int = 8, # terminal fall speed, px/frame + air_jumps: int = 0, # extra mid-air jumps (1 = double jump, N = multi) + policy: int = 0, # gravity profile (lever 6): 0 asymmetric, 1 symmetric, 2 floaty + # --- intent (input system OR ai/game writes these) --- + want_x: int = 0, # -1 left, 0 none, 1 right + want_jump: int = 0, # 1 = jump pressed THIS frame (edge) + hold_jump: int = 0, # 1 = jump held (for variable jump height) + # --- engine-owned state --- + coyote_t: int = 0, + buffer_t: int = 0, + jumps_left: int = 0, + state: int = 0, # 0 idle, 1 run, 2 rise, 3 fall + face: int = 1, # -1 / 1 last horizontal facing + air_t: int = 0 # frames spent airborne (fall timer for Landed) +} + +# jump-feel events, one at every decision (lever 4). The cancellable ones are the +# veto points; the rest are pure observation. +event cancellable JumpRequested { e: int = 0, kind: int = 0 } # kind 0 ground/coyote, 2 air +event JumpPerformed { e: int = 0, kind: int = 0 } +event Landed { e: int = 0, fall_frames: int = 0 } +event StateChanged { e: int = 0, from: int = 0, to: int = 0 } + +# ---- small helpers --------------------------------------------------------- +# the tile size drives the derived jump maths; read it from the Solids config +# entity (as esys_move does) so a jump_height in "tiles" means the same thing. +function plat_tile() -> int { + let ps = World.prop_id("Solids") + if ps < 0 { return 16 } + let se = World.query_next(ps, 0) + if se < 0 { return 16 } + let ft = World.field_id(ps, "tile") + if ft < 0 { return 16 } + let t = World.get(se, ps, ft) + if t <= 0 { return 16 } + return t +} + +# derived jump kinematics, in raw Q16.16 px/frame. From peak height H and time to +# apex T: v0 = 2H/T, g = 2H/T^2 (the standard feel-first parameterisation). +function plat_v0(jh: int, apex: int, tile: int) -> int { + let h = jh * tile + if apex <= 0 { return 0 } + return (2 * h * PLAT_Q) / apex +} +function plat_g(jh: int, apex: int, tile: int) -> int { + let h = jh * tile + if apex <= 0 { return 0 } + return (2 * h * PLAT_Q) / (apex * apex) +} + +# reflected Platformer field accessors (small, so the systems read clean) +function pf_get(e: int, name: pointer) -> int { + let P = World.prop_id("Platformer") + let f = World.field_id(P, name) + if f < 0 { return 0 } + return World.get(e, P, f) +} +function pf_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Platformer") + let f = World.field_id(P, name) + if f >= 0 { World.set(e, P, f, v) } +} +function bd_get(e: int, name: pointer) -> int { + let P = World.prop_id("Body") + let f = World.field_id(P, name) + if f < 0 { return 0 } + return World.get(e, P, f) +} +function bd_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Body") + let f = World.field_id(P, name) + if f >= 0 { World.set(e, P, f, v) } +} + +# =========================================================================== +# esys_platformer_input (Input) — bind the action map once (Input.bind), and this +# fills the intent fields from it every frame. A game rebinds keys/gamepad through +# the same Input.* action map (so record/replay is free); an AI-driven body simply +# `disable system esys_platformer_input` and writes want_x/want_jump itself. +# =========================================================================== +@EngineSystem(Platformer, Input) function esys_platformer_input() -> void { + let P = World.prop_id("Platformer") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + var wx = 0 + if Input.down("move_right") { wx = wx + 1 } + if Input.down("move_left") { wx = wx - 1 } + pf_set(e, "want_x", wx) + var wj = 0; if Input.pressed("jump") { wj = 1 } + pf_set(e, "want_jump", wj) + var hj = 0; if Input.down("jump") { hj = 1 } + pf_set(e, "hold_jump", hj) + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# esys_platformer_move (FixedUpdate) — accelerate Body.vx toward the intended +# speed, with less authority in the air and friction when idle. Runs before the +# Update sweep, which then moves and resolves the body. +# =========================================================================== +@EngineSystem(Platformer, FixedUpdate) function esys_platformer_move() -> void { + let P = World.prop_id("Platformer") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let wx = pf_get(e, "want_x") + let speed = pf_get(e, "move_speed") + let grounded = bd_get(e, "on_ground") + var vx = bd_get(e, "vx") + let tvx = wx * speed * PLAT_Q + var rate = 0 + if wx != 0 { + if grounded == 1 { rate = pf_get(e, "accel") } else { rate = pf_get(e, "air_control") } + } else { + rate = pf_get(e, "friction") + } + if rate > 100 { rate = 100 } + vx = vx + (tvx - vx) * rate / 100 + bd_set(e, "vx", vx) + if wx != 0 { pf_set(e, "face", wx) } + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# esys_platformer_gravity (FixedUpdate) — set Body.gravity for this frame from the +# derived apex gravity and the active gravity profile (lever 6), plus variable +# jump height: a rising body whose jump was released falls faster (a short hop). +# esys_move applies Body.gravity to vy and clamps to Body.max_fall. +# =========================================================================== +@EngineSystem(Platformer, FixedUpdate) function esys_platformer_gravity() -> void { + let P = World.prop_id("Platformer") + if P < 0 { return } + let tile = plat_tile() + var e = World.query_next(P, 0) + while e >= 0 { + let jh = pf_get(e, "jump_height") + let apex = pf_get(e, "apex_frames") + var g = plat_g(jh, apex, tile) + let pol = pf_get(e, "policy") + if pol == 2 { g = g / 2 } # floaty + let vy = bd_get(e, "vy") + if vy > 0 { # falling + var fm = pf_get(e, "fall_gravity_mul") + if pol == 1 { fm = 100 } # symmetric profile ignores the fall multiplier + g = g * fm / 100 + } else { + if pf_get(e, "hold_jump") == 0 { g = g * 2 } # released while rising -> short hop + } + bd_set(e, "gravity", g) + bd_set(e, "max_fall", pf_get(e, "max_fall") * PLAT_Q) + e = World.query_next(P, e + 1) + } +} + +# do one jump: announce it (veto-able), and if allowed apply the upward impulse. +function plat_do_jump(e: int, kind: int) -> bool { + if emit JumpRequested(e: e, kind: kind) != 0 { return false } + let tile = plat_tile() + let v0 = plat_v0(pf_get(e, "jump_height"), pf_get(e, "apex_frames"), tile) + bd_set(e, "vy", 0 - v0) + pf_set(e, "buffer_t", 0) + pf_set(e, "coyote_t", 0) + emit JumpPerformed(e: e, kind: kind) + return true +} + +# =========================================================================== +# esys_platformer_jump (FixedUpdate) — the coyote-time + jump-buffer + air-jump +# state machine. Grounded state comes from last frame's sweep (Body.on_ground). +# =========================================================================== +@EngineSystem(Platformer, FixedUpdate) function esys_platformer_jump() -> void { + let P = World.prop_id("Platformer") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let grounded = bd_get(e, "on_ground") + # coyote + air-jump refill on the ground + if grounded == 1 { + pf_set(e, "coyote_t", pf_get(e, "coyote_frames")) + pf_set(e, "jumps_left", pf_get(e, "air_jumps")) + } else { + let c = pf_get(e, "coyote_t") + if c > 0 { pf_set(e, "coyote_t", c - 1) } + } + # jump buffer + if pf_get(e, "want_jump") == 1 { + pf_set(e, "buffer_t", pf_get(e, "jump_buffer_frames")) + } else { + let b = pf_get(e, "buffer_t") + if b > 0 { pf_set(e, "buffer_t", b - 1) } + } + pf_set(e, "want_jump", 0) # consume the edge + # resolve a buffered jump + if pf_get(e, "buffer_t") > 0 { + if pf_get(e, "coyote_t") > 0 { + plat_do_jump(e, 0) # ground / coyote + } else { + let jl = pf_get(e, "jumps_left") + if jl > 0 { + if plat_do_jump(e, 2) { pf_set(e, "jumps_left", jl - 1) } # air jump + } + } + } + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# esys_platformer_anim (LateUpdate) — runs after the sweep, so grounded/vx/vy are +# settled. Derives the animation state (idle/run/rise/fall), emits StateChanged on +# a transition and Landed on touchdown (carrying how long the body fell, for squash +# / fall-damage). It does NOT call into the renderer: a game maps state -> a clip in +# one @On(StateChanged) listener (see the example), keeping this sub-system pure and +# the anim mapping fully replaceable. +# =========================================================================== +@EngineSystem(Platformer, LateUpdate) function esys_platformer_anim() -> void { + let P = World.prop_id("Platformer") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let grounded = bd_get(e, "on_ground") + let vx = bd_get(e, "vx") + let vy = bd_get(e, "vy") + # fall timer + Landed edge + if grounded == 1 { + let at = pf_get(e, "air_t") + if at > 0 { emit Landed(e: e, fall_frames: at) } + pf_set(e, "air_t", 0) + } else { + pf_set(e, "air_t", pf_get(e, "air_t") + 1) + } + # state + var st = 0 + if grounded == 1 { + var ax = vx; if ax < 0 { ax = 0 - ax } + if ax > (PLAT_Q / 4) { st = 1 } else { st = 0 } # running vs idle (>0.25 px/frame) + } else { + if vy < 0 { st = 2 } else { st = 3 } # rise vs fall + } + let prev = pf_get(e, "state") + if st != prev { + pf_set(e, "state", st) + emit StateChanged(e: e, from: prev, to: st) + } + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# Platformer.* convenience namespace — thin helpers for games/AI. +# =========================================================================== +# make a body jump from code (used by AI, cutscenes, springs): buffers a jump so +# the normal coyote/air-jump rules in esys_platformer_jump still apply next frame. +@Namespace(Platformer) function platformer_jump(e: int) -> void { pf_set(e, "want_jump", 1) } +# hold/release the jump button (drives variable jump height): 1 = held. +@Namespace(Platformer) function platformer_hold(e: int, held: int) -> void { pf_set(e, "hold_jump", held) } +# set the horizontal intent directly (an AI or a custom input source). +@Namespace(Platformer) function platformer_move(e: int, dir: int) -> void { pf_set(e, "want_x", dir) } +@Namespace(Platformer) function platformer_state(e: int) -> int { return pf_get(e, "state") } +@Namespace(Platformer) function platformer_grounded(e: int) -> int { return bd_get(e, "on_ground") } +# bind the default action map (call once at boot); a game may bind its own instead. +@Namespace(Platformer) function platformer_default_binds() -> void { + Input.bind("move_left", 'a') + Input.bind("move_right", 'd') + Input.bind("jump", ' ') +} diff --git a/packages/ludic.platformer/scaffolding.ludic b/packages/ludic.platformer/scaffolding.ludic new file mode 100644 index 00000000..58354bab --- /dev/null +++ b/packages/ludic.platformer/scaffolding.ludic @@ -0,0 +1,327 @@ +# scaffolding.ludic — the composable game-loop pieces a platformer usually wants +# (#58 layers 4-5), each opt-in: moving / crumbling platform blocks with rider +# carry, collectibles + score, springs, hazards + a simple life/lives model, and +# checkpoint / goal triggers. Every piece is a POD component + one engine system + +# events, so a game adopts only what it needs and extends each via the same six +# levers. Deterministic integer maths throughout. +# +# Depends only on the engine Body/Collider (esys_move) and this package's own +# helpers; not on ludic.gameplay, so a pure platformer stays self-contained. + +const SCAF_Q: int = 65536 + +# reflected Body/Position/Collider helpers (scaffolding reads several) +function sc_body(e: int, name: pointer) -> int { + let P = World.prop_id("Body"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function sc_body_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Body"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function sc_pos(e: int, name: pointer) -> int { + let P = World.prop_id("Position"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function sc_pos_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Position"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function sc_col(e: int, name: pointer) -> int { + let P = World.prop_id("Collider"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} + +# =========================================================================== +# Moving / crumbling platform blocks. A Platform is a solid (or one-way) Collider +# entity the engine already collides against; this system adds motion and crumble. +# kind: 0 solid, 1 one_way, 2 moving, 3 crumble +# Moving platforms shuttle between (ax,ay) and (bx,by) at `speed` px/frame and +# carry a body resting on top (standard parent-velocity transport). Crumble blocks +# start a countdown the first time a body stands on them, then despawn. +# =========================================================================== +property Platform { + kind: int = 0, + ax: int = 0, ay: int = 0, bx: int = 0, by: int = 0, # moving: endpoints + speed: int = 1, + dir: int = 1, # +1 toward B, -1 toward A + delay: int = 30, # crumble: frames after first contact + t: int = 0, # crumble: countdown (0 = untriggered) + triggered: int = 0 +} +event PlatformCrumbled { e: int = 0 } + +# a body is riding platform `p` if it is grounded and its feet sit on the +# platform's top edge within the horizontal span. +function plat_rider_on(p: int) -> int { + let PB = World.prop_id("Body") + if PB < 0 { return 0 - 1 } + let px = sc_pos(p, "x") + sc_col(p, "offx") + let py = sc_pos(p, "y") + sc_col(p, "offy") + let pw = sc_col(p, "w") + var b = World.query_next(PB, 0) + while b >= 0 { + if b != p { + let PC = World.prop_id("Collider") + if (PC >= 0) and (World.has(b, PC) != 0) { + let bx = sc_pos(b, "x") + sc_col(b, "offx") + let by = sc_pos(b, "y") + sc_col(b, "offy") + let bw = sc_col(b, "w") + let bh = sc_col(b, "h") + if (bx < px + pw) and (px < bx + bw) { + let feet = by + bh + if (feet >= py - 2) and (feet <= py + 2) { return b } + } + } + } + b = World.query_next(PB, b + 1) + } + return 0 - 1 +} + +@EngineSystem(Platform, FixedUpdate) function esys_platform() -> void { + let P = World.prop_id("Platform") + if P < 0 { return } + let fk = World.field_id(P, "kind") + var e = World.query_next(P, 0) + while e >= 0 { + var nxt = World.query_next(P, e + 1) + let kind = World.get(e, P, fk) + if kind == 2 { # moving + let ax = World.get(e, P, World.field_id(P, "ax")) + let ay = World.get(e, P, World.field_id(P, "ay")) + let bx = World.get(e, P, World.field_id(P, "bx")) + let by = World.get(e, P, World.field_id(P, "by")) + let speed = World.get(e, P, World.field_id(P, "speed")) + var dir = World.get(e, P, World.field_id(P, "dir")) + let ox = sc_pos(e, "x") + let oy = sc_pos(e, "y") + # target endpoint by direction + var tx = bx; var ty = by + if dir < 0 { tx = ax; ty = ay } + var dx = 0; var dy = 0 + if ox < tx { dx = speed } else { if ox > tx { dx = 0 - speed } } + if oy < ty { dy = speed } else { if oy > ty { dy = 0 - speed } } + # clamp the step so we do not overshoot the endpoint + if dx > 0 { if ox + dx > tx { dx = tx - ox } } + if dx < 0 { if ox + dx < tx { dx = tx - ox } } + if dy > 0 { if oy + dy > ty { dy = ty - oy } } + if dy < 0 { if oy + dy < ty { dy = ty - oy } } + # carry a rider first (before moving the platform out from under it) + let rider = plat_rider_on(e) + sc_pos_set(e, "x", ox + dx) + sc_pos_set(e, "y", oy + dy) + if rider >= 0 { + sc_pos_set(rider, "x", sc_pos(rider, "x") + dx) + sc_pos_set(rider, "y", sc_pos(rider, "y") + dy) + } + # flip direction at an endpoint + if (sc_pos(e, "x") == tx) and (sc_pos(e, "y") == ty) { + World.set(e, P, World.field_id(P, "dir"), 0 - dir) + } + } + if kind == 3 { # crumble + let ftrig = World.field_id(P, "triggered") + let ft = World.field_id(P, "t") + if World.get(e, P, ftrig) == 0 { + if plat_rider_on(e) >= 0 { + World.set(e, P, ftrig, 1) + World.set(e, P, ft, World.get(e, P, World.field_id(P, "delay"))) + } + } else { + let t = World.get(e, P, ft) - 1 + if t <= 0 { emit PlatformCrumbled(e: e); despawn e } else { World.set(e, P, ft, t) } + } + } + e = nxt + } +} + +# =========================================================================== +# Score + combo. Score.add(e, n) adds n * mult (mult >= 1) and emits ScoreChanged. +# =========================================================================== +property Score { value: int = 0, combo: int = 0, mult: int = 1 } +event ScoreChanged { e: int = 0, value: int = 0, delta: int = 0 } + +@Namespace(Score) function score_add(e: int, n: int) -> void { + let P = World.prop_id("Score") + if P < 0 { return } + if World.has(e, P) == 0 { return } + let fv = World.field_id(P, "value") + let fm = World.field_id(P, "mult") + var mult = 1; if fm >= 0 { mult = World.get(e, P, fm) } + if mult < 1 { mult = 1 } + let delta = n * mult + let v = World.get(e, P, fv) + delta + World.set(e, P, fv, v) + emit ScoreChanged(e: e, value: v, delta: delta) +} +@Namespace(Score) function score_get(e: int) -> int { + let P = World.prop_id("Score") + if P < 0 { return 0 } + let fv = World.field_id(P, "value") + if fv < 0 { return 0 } + return World.get(e, P, fv) +} + +# =========================================================================== +# Collectibles. A Pickup carries a trigger Collider; when a body overlaps it, the +# system emits ItemPickedUp (cancellable — a game may veto, e.g. inventory full) +# and, by default, adds `value` to the picker's Score if it has one. +# =========================================================================== +property Pickup { kind: int = 0, value: int = 1, taken: int = 0 } +event cancellable ItemPickedUp { picker: int = 0, pickup: int = 0, kind: int = 0, value: int = 0 } + +@EngineSystem(Pickup, LateUpdate) function esys_pickup() -> void { + let P = World.prop_id("Pickup") + if P < 0 { return } + let PC = World.prop_id("Collider") + if PC < 0 { return } + let fhit = World.field_id(PC, "hit") + if fhit < 0 { return } + let ftaken = World.field_id(P, "taken") + let fkind = World.field_id(P, "kind") + let fval = World.field_id(P, "value") + var e = World.query_next(P, 0) + while e >= 0 { + var nxt = World.query_next(P, e + 1) + if World.get(e, P, ftaken) == 0 { + let picker = World.get(e, PC, fhit) + if picker >= 0 { + let kind = World.get(e, P, fkind) + let val = World.get(e, P, fval) + if emit ItemPickedUp(picker: picker, pickup: e, kind: kind, value: val) == 0 { + Score.add(picker, val) # default policy: value -> score + World.set(e, P, ftaken, 1) + despawn e + } + } + } + e = nxt + } +} + +# =========================================================================== +# Springs / bounce pads: a trigger that launches an overlapping body upward. +# =========================================================================== +property Spring { power: int = 10 } +event Sprang { body: int = 0, spring: int = 0, power: int = 0 } + +@EngineSystem(Spring, LateUpdate) function esys_spring() -> void { + let P = World.prop_id("Spring") + if P < 0 { return } + let PC = World.prop_id("Collider") + if PC < 0 { return } + let fhit = World.field_id(PC, "hit") + let fpow = World.field_id(P, "power") + var e = World.query_next(P, 0) + while e >= 0 { + let body = World.get(e, PC, fhit) + if body >= 0 { + let power = World.get(e, P, fpow) + sc_body_set(body, "vy", 0 - power * SCAF_Q) + sc_body_set(body, "ry", 0) + emit Sprang(body: body, spring: e, power: power) + } + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# A simple life / lives model + hazards. (A full RPG uses ludic.gameplay Stats; +# platformers usually want this lighter hp/lives/i-frames version.) +# =========================================================================== +property Life { hp: int = 3, max: int = 3, lives: int = 3, iframes: int = 0 } +event cancellable Hurt { e: int = 0, src: int = 0, dmg: int = 0 } +event Death { e: int = 0 } + +@Namespace(Life) function life_hurt(e: int, src: int, dmg: int) -> int { + let P = World.prop_id("Life") + if P < 0 { return 0 } + if World.has(e, P) == 0 { return 0 } + let fhp = World.field_id(P, "hp") + let fif = World.field_id(P, "iframes") + if World.get(e, P, fif) > 0 { return 0 } # invulnerable + if emit Hurt(e: e, src: src, dmg: dmg) != 0 { return 0 } + var hp = World.get(e, P, fhp) - dmg + if hp < 0 { hp = 0 } + World.set(e, P, fhp, hp) + if hp <= 0 { emit Death(e: e) } + return dmg +} + +property Hazard { dmg: int = 1, knockback: int = 6 } +@EngineSystem(Hazard, LateUpdate) function esys_hazard() -> void { + let P = World.prop_id("Hazard") + if P < 0 { return } + let PC = World.prop_id("Collider") + if PC < 0 { return } + let fhit = World.field_id(PC, "hit") + let fdmg = World.field_id(P, "dmg") + let fkb = World.field_id(P, "knockback") + var e = World.query_next(P, 0) + while e >= 0 { + let body = World.get(e, PC, fhit) + if body >= 0 { + let dmg = World.get(e, P, fdmg) + if life_hurt(body, e, dmg) > 0 { + let kb = World.get(e, P, fkb) + sc_body_set(body, "vy", 0 - kb * SCAF_Q) # pop up on hit + } + } + e = World.query_next(P, e + 1) + } +} + +# i-frames tick down for any Life carrier (small engine system, own component). +@EngineSystem(Life, Update) function esys_life() -> void { + let P = World.prop_id("Life") + if P < 0 { return } + let fif = World.field_id(P, "iframes") + if fif < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let f = World.get(e, P, fif) + if f > 0 { World.set(e, P, fif, f - 1) } + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# Checkpoint / goal triggers (level flow). +# =========================================================================== +property Checkpoint { id: int = 0 } +property Goal { } +event CheckpointReached { body: int = 0, id: int = 0 } +event LevelComplete { body: int = 0 } + +@EngineSystem(Checkpoint, LateUpdate) function esys_checkpoint() -> void { + let P = World.prop_id("Checkpoint") + if P < 0 { return } + let PC = World.prop_id("Collider") + if PC < 0 { return } + let fhit = World.field_id(PC, "hit") + let fid = World.field_id(P, "id") + var e = World.query_next(P, 0) + while e >= 0 { + let body = World.get(e, PC, fhit) + if body >= 0 { emit CheckpointReached(body: body, id: World.get(e, P, fid)) } + e = World.query_next(P, e + 1) + } +} +@EngineSystem(Goal, LateUpdate) function esys_goal() -> void { + let P = World.prop_id("Goal") + if P < 0 { return } + let PC = World.prop_id("Collider") + if PC < 0 { return } + let fhit = World.field_id(PC, "hit") + var e = World.query_next(P, 0) + while e >= 0 { + let body = World.get(e, PC, fhit) + if body >= 0 { emit LevelComplete(body: body) } + e = World.query_next(P, e + 1) + } +} diff --git a/selfhost/backend/game/emit_game.ludic b/selfhost/backend/game/emit_game.ludic index 869ed727..f1dad4d8 100644 --- a/selfhost/backend/game/emit_game.ludic +++ b/selfhost/backend/game/emit_game.ludic @@ -524,7 +524,7 @@ function emit_event_fns() -> void { emit(" %swlp"); emit(sk); emit(" = getelementptr inbounds ["); emit(capS); emit(" x ptr], ptr @evL_"); emit(en2); emit(", i32 0, i32 %swi"); emit(sk); emit("\n") emit(" store ptr null, ptr %swlp"); emit(sk); emit("\n br label %swx"); emit(sk); emit("\n") emit("swx"); emit(sk); emit(":\n") - emit(" %swi1"); emit(sk); emit(" = add i32 %swi"); emit(sk); emit(", 1\n store i32 %swi1"); emit(sk); emit(", ptr %ci\n br label %sw"); emit(sk); emit("\n") + emit(" %swinc"); emit(sk); emit(" = add i32 %swi"); emit(sk); emit(", 1\n store i32 %swinc"); emit(sk); emit(", ptr %ci\n br label %sw"); emit(sk); emit("\n") emit("swd"); emit(sk); emit(":\n store i32 0, ptr %ci\n") if (e2 + 1) < len(g_events) { emit(" br label %sw"); emit(itoa(e2 + 1)); emit("\n") } else { emit(" ret void\n") } diff --git a/selfhost/ludicc.seed.ll b/selfhost/ludicc.seed.ll index 30ed2f7a..bde9d361 100644 --- a/selfhost/ludicc.seed.ll +++ b/selfhost/ludicc.seed.ll @@ -8995,9 +8995,9 @@ declare void @hs_free(i32) @.str8826 = private unnamed_addr constant [2 x i8] c"\0A\00" @.str8827 = private unnamed_addr constant [4 x i8] c"swx\00" @.str8828 = private unnamed_addr constant [3 x i8] c":\0A\00" -@.str8829 = private unnamed_addr constant [8 x i8] c" %swi1\00" +@.str8829 = private unnamed_addr constant [9 x i8] c" %swinc\00" @.str8830 = private unnamed_addr constant [16 x i8] c" = add i32 %swi\00" -@.str8831 = private unnamed_addr constant [22 x i8] c", 1\0A store i32 %swi1\00" +@.str8831 = private unnamed_addr constant [23 x i8] c", 1\0A store i32 %swinc\00" @.str8832 = private unnamed_addr constant [25 x i8] c", ptr %ci\0A br label %sw\00" @.str8833 = private unnamed_addr constant [2 x i8] c"\0A\00" @.str8834 = private unnamed_addr constant [4 x i8] c"swd\00" diff --git a/tools/x/test.ludic b/tools/x/test.ludic index 70a85820..066d41da 100644 --- a/tools/x/test.ludic +++ b/tools/x/test.ludic @@ -20,6 +20,27 @@ function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) - if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) } } +# a feature example that consumes an in-repo controller package (packages/): same +# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a +# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine +# runtime. This is exactly how `x app` / an external project builds against a +# fetched source package (the do_import module-root fallback, #63). +function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void { + let nm = flat(path) + let root = capture_line("pwd") + let ll = `/tmp/x_c_{nm}.ll` + if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>/tmp/x_gb.err`) { + bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return + } + if not shq(`{cc()} -O2 {ll} -o /tmp/x_c_{nm} 2>/tmp/x_gb.err`) { + bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return + } + var got = "" + if (keys == "") { got = capture_line(`/tmp/x_c_{nm} < /dev/null`) } + else { got = capture_line(`printf '{keys}' | /tmp/x_c_{nm}`) } + if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) } +} + # a "does it still compile" smoke test (parse -> lower -> link), no run function qsmoke(path: pointer) -> void { let nm = flat(path) @@ -218,6 +239,10 @@ function cmd_test() -> int { feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)") feat_case("library/physics", "", "184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1", "physics.ludic (Body + Collider + esys_move: gravity/land, wall stop, sub-pixel, entity platform, one-way, triggers — entity broadphase; issue #65)") feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)") + # builtin gameplay controllers, consumed as in-repo packages (issues #57-#61) + controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)") + controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)") + controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)") spec_case("library/testing", "== 6 passed, 0 failed ==") spec_case("library/coverage", "== 3 passed, 0 failed ==") feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")