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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue