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
3
changes/controllers-platformer.md
Normal file
3
changes/controllers-platformer.md
Normal file
|
|
@ -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 <fn>`. 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).
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/ludic.platformer/package.ludic
Normal file
14
packages/ludic.platformer/package.ludic
Normal file
|
|
@ -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"
|
||||||
314
packages/ludic.platformer/platformer.ludic
Normal file
314
packages/ludic.platformer/platformer.ludic
Normal file
|
|
@ -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 <fn>` (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", ' ')
|
||||||
|
}
|
||||||
327
packages/ludic.platformer/scaffolding.ludic
Normal file
327
packages/ludic.platformer/scaffolding.ludic
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(" %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(" store ptr null, ptr %swlp"); emit(sk); emit("\n br label %swx"); emit(sk); emit("\n")
|
||||||
emit("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")
|
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") }
|
if (e2 + 1) < len(g_events) { emit(" br label %sw"); emit(itoa(e2 + 1)); emit("\n") }
|
||||||
else { emit(" ret void\n") }
|
else { emit(" ret void\n") }
|
||||||
|
|
|
||||||
|
|
@ -8995,9 +8995,9 @@ declare void @hs_free(i32)
|
||||||
@.str8826 = private unnamed_addr constant [2 x i8] c"\0A\00"
|
@.str8826 = private unnamed_addr constant [2 x i8] c"\0A\00"
|
||||||
@.str8827 = private unnamed_addr constant [4 x i8] c"swx\00"
|
@.str8827 = private unnamed_addr constant [4 x i8] c"swx\00"
|
||||||
@.str8828 = private unnamed_addr constant [3 x i8] c":\0A\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"
|
@.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"
|
@.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"
|
@.str8833 = private unnamed_addr constant [2 x i8] c"\0A\00"
|
||||||
@.str8834 = private unnamed_addr constant [4 x i8] c"swd\00"
|
@.str8834 = private unnamed_addr constant [4 x i8] c"swd\00"
|
||||||
|
|
|
||||||
|
|
@ -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}]`) }
|
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
|
# a "does it still compile" smoke test (parse -> lower -> link), no run
|
||||||
function qsmoke(path: pointer) -> void {
|
function qsmoke(path: pointer) -> void {
|
||||||
let nm = flat(path)
|
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/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", "", "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)")
|
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/testing", "== 6 passed, 0 failed ==")
|
||||||
spec_case("library/coverage", "== 3 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)")
|
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue