# ============================================================================ # systems.ludic — engine-owned systems that run automatically over user # components, each frame, without the game wiring a handler. # # This is the ECS hook issues #43 and #47 named as their real dependency: until # now every system was a `handler` the game wrote. These `esys_*` functions are # systems the *engine* owns — the compiler splices this file and inserts a call # to each one at the right phase of the frame loop (see emit_engine_systems_for_ # phase in emit_game.ludic), so a component the game merely *declares and carries* # is ticked for free. # # They stand entirely on the reflection ABI (World.prop_id / field_id / get / # set / query_next — the same by-name world table a mod reads), so they never # compile against a fixed field layout: a system resolves its fields by name and # no-ops cleanly when the component (or a field) is absent. Everything is integer # and deterministic — the frame clock ticks at a fixed 60/s — so replays and # lockstep netcode reproduce animation and motion exactly. # # The opt-in is declaring the well-known component. A game that wants sprite # animation declares `property SpriteAnim { ticks, fps, frames, mode, frame }` # and puts it on a model; the engine advances `frame` every tick. No component, # no system emitted — a game without them is byte-for-byte unchanged. # ============================================================================ # floor of a/b for non-negative a (the frame clock only ever counts up). function esys_div(a: int, b: int) -> int { if b <= 0 { return 0 } return a / b } # ---- named clip registry (#48) --------------------------------------------- # A game registers spritesheet clips by name — Anim.clip("run", 6, 12, 0) — and # plays one with Anim.play(entity, "run"), so gameplay names a motion instead of # hand-writing fps/frames/mode into a component. A small name-keyed table, the # same shape the input action map uses; string names compare by byte-pointer # identity (a string literal interns to one pointer per program). const ANIM_MAX_CLIPS: int = 32 var anim_clip_names: pointers = null # clip name per slot var anim_clip_fps: words = null var anim_clip_frames: words = null var anim_clip_mode: words = null var anim_nclips: int = 0 function anim_clip_init() -> void { if anim_clip_names == null { anim_clip_names = bytes(ANIM_MAX_CLIPS * 8) # a pointer (8 bytes) per slot anim_clip_fps = words(ANIM_MAX_CLIPS) anim_clip_frames = words(ANIM_MAX_CLIPS) anim_clip_mode = words(ANIM_MAX_CLIPS) } } # register (or update) a named clip. mode is the SpriteAnim mode: 0 loop, 1 once, # 2 ping-pong. function anim_clip(name: pointer, frames: int, fps: int, mode: int) -> void { anim_clip_init() var i = 0 while i < anim_nclips { if anim_clip_names[i] == name { anim_clip_frames[i] = frames; anim_clip_fps[i] = fps; anim_clip_mode[i] = mode return } i = i + 1 } if anim_nclips >= ANIM_MAX_CLIPS { return } # silently ignore past capacity let s = anim_nclips anim_clip_names[s] = name anim_clip_frames[s] = frames anim_clip_fps[s] = fps anim_clip_mode[s] = mode anim_nclips = anim_nclips + 1 } function anim_clip_find(name: pointer) -> int { anim_clip_init() var i = 0 while i < anim_nclips { if anim_clip_names[i] == name { return i } i = i + 1 } return 0 - 1 } # ---- Anim.play / Anim.on_frame / Anim.fired (#48) -------------------------- # Ergonomic writes over the SpriteAnim component through the reflection ABI, so a # game restarts or swaps a clip with one call instead of setting five fields by # hand. All no-op cleanly if the entity has no SpriteAnim (or a field is absent). # write one SpriteAnim int field on entity e (by name), if present. function anim_set(e: int, field: pointer, v: int) -> void { let p = World.prop_id("SpriteAnim") if p < 0 { return } let f = World.field_id(p, field) if f >= 0 { World.set(e, p, f, v) } } # start / restart a clip on entity e: set fps/frames/mode and rewind ticks to 0 # so the clip plays from its first cell this tick. function anim_play(e: int, fps: int, frames: int, mode: int) -> void { anim_set(e, "fps", fps) anim_set(e, "frames", frames) anim_set(e, "mode", mode) anim_set(e, "ticks", 0) anim_set(e, "event_fired", 0) } # start a registered clip by name (a no-op if the name is unknown). function anim_play_named(e: int, name: pointer) -> void { let c = anim_clip_find(name) if c < 0 { return } anim_play(e, anim_clip_fps[c], anim_clip_frames[c], anim_clip_mode[c]) } # arm a frame event: the engine flags SpriteAnim.event_fired = 1 on the tick the # clip first lands on `frame` (a footstep, a hitbox going live). The game reads # the flag in its own handler and reacts (emit its own event, spawn, …) — the # engine detects the boundary, gameplay owns the reaction, so it stays within the # no-runtime-dispatch event model. function anim_on_frame(e: int, frame: int) -> void { anim_set(e, "event_frame", frame) } # did entity e's clip land on its armed event frame this tick? function anim_fired(e: int) -> bool { let p = World.prop_id("SpriteAnim") if p < 0 { return false } let f = World.field_id(p, "event_fired") if f < 0 { return false } return World.get(e, p, f) != 0 } # ---- Motion.to (#48) ------------------------------------------------------- # Start a value tween on entity e over the Motion component: from -> to over `dur` # ticks with easing `ease`, rewinding ticks so it plays from the start. A no-op # if the entity has no Motion. function motion_to(e: int, from: int, to: int, dur: int, ease: int) -> void { let p = World.prop_id("Motion") if p < 0 { return } motion_set(e, p, "from", from) motion_set(e, p, "to", to) motion_set(e, p, "dur", dur) motion_set(e, p, "ease", ease) motion_set(e, p, "ticks", 0) motion_set(e, p, "done", 0) motion_set(e, p, "value", from) } function motion_set(e: int, p: int, field: pointer, v: int) -> void { let f = World.field_id(p, field) if f >= 0 { World.set(e, p, f, v) } } # ---- SpriteAnim: spritesheet frame advance (#43) --------------------------- # Component contract — `property SpriteAnim { ticks: int, fps: int, frames: int, # mode: int, frame: int }`: # ticks engine frames elapsed since the clip started (the engine advances it) # fps playback rate in frames per second # frames number of cells in the clip # mode 0 = loop, 1 = once (clamp on last), 2 = pingpong (bounce) # frame OUTPUT: the cell index to draw this frame # elapsed = ticks * fps / 60 whole animation frames; `mode` maps that back into # 0..frames-1. Pure integer, so the same tick count always yields the same cell. function esys_spriteanim() -> void { let p = World.prop_id("SpriteAnim") if p < 0 { return } let f_ticks = World.field_id(p, "ticks") let f_fps = World.field_id(p, "fps") let f_frames = World.field_id(p, "frames") let f_mode = World.field_id(p, "mode") let f_frame = World.field_id(p, "frame") let f_evfr = World.field_id(p, "event_frame") # optional: the frame to flag let f_evfd = World.field_id(p, "event_fired") # optional: OUTPUT, 1 on the landing tick if f_ticks < 0 { return } if f_frame < 0 { return } var e = World.query_next(p, 0) while e >= 0 { let prev = World.get(e, p, f_frame) # last tick's cell (for the frame-event edge) let ticks = World.get(e, p, f_ticks) + 1 World.set(e, p, f_ticks, ticks) let fps = World.get(e, p, f_fps) var frames = World.get(e, p, f_frames) let mode = World.get(e, p, f_mode) if frames < 1 { frames = 1 } let elapsed = esys_div(ticks * fps, 60) # whole animation frames elapsed var fr = 0 if mode == 1 { # once: clamp on the last frame fr = elapsed if fr > frames - 1 { fr = frames - 1 } } else { if mode == 2 { # pingpong: bounce 0..frames-1..0 let period = max(1, frames * 2 - 2) let m = elapsed % period if m < frames { fr = m } else { fr = period - m } } else { # loop (mode 0 / default) fr = elapsed % frames } } World.set(e, p, f_frame, fr) # frame event: flag the tick the clip first lands on its armed frame (edge). if (f_evfr >= 0) and (f_evfd >= 0) { let evfr = World.get(e, p, f_evfr) var fired = 0 if (fr == evfr) and (prev != evfr) { fired = 1 } World.set(e, p, f_evfd, fired) } e = World.query_next(p, e + 1) } } # ---- Motion: value tween advance (#43) ------------------------------------- # Component contract — `property Motion { ticks: int, dur: int, from: int, # to: int, ease: int, value: int, done: int }`: # ticks engine frames elapsed since the tween started (engine advances it) # dur duration in engine frames (ticks); dur <= 0 snaps straight to `to` # from,to the interpolation endpoints (integer game units — position, alpha…) # ease 0 = linear, 1 = in (t^2), 2 = out, 3 = in-out # value OUTPUT: the interpolated value this frame # done OUTPUT: 1 once ticks has reached dur, else 0 # progress is carried in 0..1024 so easing curves are exact in integer math. function esys_motion_ease(t: int, ease: int) -> int { if ease == 1 { return t * t / 1024 } # ease-in: t^2 if ease == 2 { # ease-out: 1-(1-t)^2 let u = 1024 - t return 1024 - (u * u / 1024) } if ease == 3 { # ease-in-out if t < 512 { return (t * t / 1024) * 2 } let u = 1024 - t return 1024 - (u * u / 1024) * 2 } return t # linear } function esys_motion() -> void { let p = World.prop_id("Motion") if p < 0 { return } let f_ticks = World.field_id(p, "ticks") let f_dur = World.field_id(p, "dur") let f_from = World.field_id(p, "from") let f_to = World.field_id(p, "to") let f_ease = World.field_id(p, "ease") let f_value = World.field_id(p, "value") let f_done = World.field_id(p, "done") if f_ticks < 0 { return } if f_value < 0 { return } var e = World.query_next(p, 0) while e >= 0 { let dur = World.get(e, p, f_dur) var ticks = World.get(e, p, f_ticks) + 1 if ticks > dur { ticks = dur } # clamp so `value` rests at `to` World.set(e, p, f_ticks, ticks) let from = World.get(e, p, f_from) let to = World.get(e, p, f_to) let ease = World.get(e, p, f_ease) var t = 1024 if dur > 0 { t = esys_div(ticks * 1024, dur) } if t > 1024 { t = 1024 } let te = esys_motion_ease(t, ease) let value = from + (to - from) * te / 1024 World.set(e, p, f_value, value) if f_done >= 0 { var d = 0 if ticks >= dur { d = 1 } World.set(e, p, f_done, d) } e = World.query_next(p, e + 1) } }