feat(engine): #90 atlas-aware Sprite component, #91 become from listeners, 0.3.x ergonomics batch
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 32s
ci / build-and-test (push) Successful in 2m49s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 30s

Closes the two open issues and lands the pending unreleased batch:

- #90: `Sprite { atlas: 1 }` routes esys_sprite through atlas_draw_ex
  (scale/flip/tint), so cell / cell_span / strip ids of any size draw
  through the engine sprite-render system. examples/library/sprite_atlas
  is the pixel-readback regression.
- #91: `become` from an @On(Event) listener / global handler / plain
  function no longer segfaults the compiler; it emits @L_scene_leave()
  (a dispatch on the live scene id) so the leaving scene's on-exit runs.
  UI_* handles are readable from any code (widget table built on first
  use). examples/library/scene_menus covers it.
- fix: a windowed `ludicc -o` build that reaches the audio runtime only
  through the atlas/Assets preload import now links audio.ll +
  AVFoundation (the audio backend link was gated on a game-level
  Audio.* call, so any windowed game declaring Sprite failed to link).
- the hand-written "Unreleased" CHANGELOG section is converted to
  changesets under changes/ so `x release` generates it.
- plus the batch: engine-driven retained UI + UiClicked event, Overlay
  phase, TileSkin tilemap-render system, Key.* constants, Font/Ui/File
  namespaces, Sprite.strip, prefabs, managers, countdown fields,
  enum-typed machines, layer @Queries, ludic.prefs / ludic.dungeon
  packages, Ai.seek pathing, Solids.solid2, cursor confine (mode 3)
  fix, shooter centre-aim fix, reserved-word function diagnostic.

Verified: x test (124/124), x test-tools, check-impl, check-vocabulary,
check-docs, docs-gen + docs-check, bootstrap-cfree (seed is a fixpoint).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-04 01:36:08 +03:00
parent e9c2c51bc3
commit ad548840c7
139 changed files with 58981 additions and 43671 deletions

View file

@ -47,6 +47,8 @@ var at_nname: int = 0
# bounded number per frame (Assets.pump) so a loading scene stays responsive and
# a game only enters play once Assets.ready(). Deterministic: the same enqueue +
# pump order loads the same assets in the same order on every run.
import "audio.ludic" # the preload queue feeds .wav/.mp3 into the sound bank
const ATLAS_MAX_QUEUE: int = 512
var at_q_name: pointers = null # name to register the loaded sprite under
var at_q_path: pointers = null # file path to load
@ -228,10 +230,94 @@ function atlas_draw_scaled(id: int, dx: int, dy: int, sc: int) -> void {
}
}
# blit an atlas sprite with the full Sprite-component treatment (#90): integer scale,
# horizontal flip, and a solid tint (non-zero = every opaque pixel in that colour).
# This is what the engine sprite-render system calls for `Sprite { atlas: 1 }`.
function atlas_draw_ex(id: int, dx: int, dy: int, sc0: int, flip: int, tint: int) -> void {
atlas_init()
if (id < 0) or (id >= at_nspr) { return }
var sc = sc0
if sc < 1 { sc = 1 }
let sheet = at_spr_sheet[id]
let img = at_sheet_img[sheet]
let iw = img_w[img]
let ih = img_h[img]
let s: words = img_px[img]
let sx = at_spr_sx[id]
let sy = at_spr_sy[id]
let w = at_spr_w[id]
let h = at_spr_h[id]
var y = 0
while y < h {
let srcy = sy + y
if (srcy >= 0) and (srcy < ih) {
var x = 0
while x < w {
var rx = x
if flip != 0 { rx = w - 1 - x }
let srcx = sx + rx
if (srcx >= 0) and (srcx < iw) {
let px = s[srcy * iw + srcx]
if ((px >> 24) & 255) >= 128 {
var col = px & 16777215
if tint != 0 { col = tint & 16777215 }
if sc == 1 { rt_put_px(dx + x, dy + y, col) }
else { rt_fill_rect(dx + x * sc, dy + y * sc, sc, sc, col) }
}
}
x = x + 1
}
}
y = y + 1
}
}
# a strip of `count` frames starting at (col,row), each `rows` cells tall, registered
# as consecutive ids so `first + SpriteAnim.frame` addresses the current frame.
function atlas_strip(sheet: int, col: int, row: int, count: int, rows: int) -> int {
var first = 0 - 1
var i = 0
while i < count {
let id = atlas_cell_span(sheet, col + i, row, 1, rows)
if first < 0 { first = id }
i = i + 1
}
return first
}
# an atlas sprite's pixel size — handy for centering / layout.
function atlas_width(id: int) -> int { atlas_init(); if (id < 0) or (id >= at_nspr) { return 0 }; return at_spr_w[id] }
function atlas_height(id: int) -> int { atlas_init(); if (id < 0) or (id >= at_nspr) { return 0 }; return at_spr_h[id] }
# Sprite.draw_meter: `value` of `max` as a row of icons — full, half or empty per
# `per_icon` points (hearts, stars, ammo pips), `spacing` px apart
function atlas_draw_meter(x: int, y: int, value: int, max: int, per_icon: int, spacing: int, full: int, half: int, empty: int) -> void {
if per_icon <= 0 { return }
var i = 0
while i < max / per_icon {
var id = empty
if value >= i * per_icon + per_icon / 2 { id = half }
if value >= (i + 1) * per_icon { id = full }
atlas_draw(id, x + i * spacing, y)
i = i + 1
}
}
# Assets.enqueue_dir: every file of a directory, named by its file name without the
# extension (assets/audio/hit.wav -> "hit"); the queue sorts by extension as usual
function assets_enqueue_dir(dir: pointer) -> void {
let names = Fs.list(dir)
if names == null { return }
var i = 0
while i < len(names) {
let file = names[i]
var dot = len(file) - 1
while (dot > 0) and (file[dot] != 46) { dot = dot - 1 }
if dot > 0 { assets_enqueue(file[0..dot], dir + ("/") + file) }
i = i + 1
}
}
# ---- incremental preload (#82) --------------------------------------------
# Enqueue a named image file to load later (does not load it now).
function assets_enqueue(name: pointer, path: pointer) -> void {
@ -245,17 +331,70 @@ function assets_enqueue(name: pointer, path: pointer) -> void {
# Load up to `max` queued assets this frame, registering each under its name, and
# return how many were loaded by this call. Call it each frame in a loading scene
# (a small `max` keeps the frame short); Assets.ready() flips true when done.
event AssetsReady { } # fired once, the frame the queue finishes
var at_q_announced: bool = false
function assets_pump(max: int) -> int {
atlas_init()
var done = 0
while (done < max) and (at_q_pos < at_q_n) {
let id = atlas_image(at_q_path[at_q_pos])
atlas_register_name(at_q_name[at_q_pos], id)
assets_load_one(at_q_name[at_q_pos], at_q_path[at_q_pos])
at_q_pos = at_q_pos + 1
done = done + 1
}
if (at_q_pos >= at_q_n) and (not at_q_announced) { at_q_announced = true; emit AssetsReady() }
return done
}
# the default loading bar of a `scene X loads then Y`: centred, a fifth of the screen wide
function assets_draw_progress() -> void {
let w = rt_fbw / 2
let x = rt_fbw / 4
let y = rt_fbh / 2 - 4
rt_fill_rect(x, y, w, 8, 0x303030)
rt_fill_rect(x, y, w * assets_progress() / 100, 8, 0xffcc44)
}
# what kind of asset a path is, by its extension: sounds and fonts go to their own
# managers (Audio.play(name:), Assets.font(name)); everything else is an image
function path_has_suffix(path: pointer, suffix: pointer) -> bool {
let n = len(path)
let m = len(suffix)
if m > n { return false }
var i = 0
while i < m {
if path[n - m + i] != suffix[i] { return false }
i = i + 1
}
return true
}
const ATLAS_MAX_FONT: int = 16
var at_font_name: pointers = null
var at_font_id: words = null
var at_font_n: int = 0
function assets_load_one(name: pointer, path: pointer) -> void {
if path_has_suffix(path, ".wav") or path_has_suffix(path, ".mp3") or path_has_suffix(path, ".ogg") {
audio_define(name, path)
return
}
if path_has_suffix(path, ".ttf") or path_has_suffix(path, ".ttc") {
if at_font_name == null { at_font_name = bytes(ATLAS_MAX_FONT * 8); at_font_id = words(ATLAS_MAX_FONT) }
if at_font_n < ATLAS_MAX_FONT {
at_font_name[at_font_n] = name
at_font_id[at_font_n] = rt_font_load(path)
at_font_n = at_font_n + 1
}
return
}
atlas_register_name(name, atlas_image(path))
}
# the font handle registered under `name` (Assets.font), or 0
function assets_font(name: pointer) -> int {
var i = 0
while i < at_font_n {
if at_font_name[i] == name { return at_font_id[i] }
i = i + 1
}
return 0
}
function assets_total() -> int { atlas_init(); return at_q_n }
function assets_loaded() -> int { atlas_init(); return at_q_pos }

View file

@ -24,14 +24,45 @@ var snd_tab: pointers = null # handle (1-based) -> AVAudioPlayer pointer
var snd_master: fixed = 1.0 # master volume, applied to every play
var snd_rate: fixed = 1.0 # playback rate / pitch (1.0 = normal)
var snd_music: int = 0 # the handle currently playing as music (0 = none)
# the sound bank: sounds registered by name (Audio.define), played by name (Audio.play(name:))
var snd_bank_name: pointers = null
var snd_bank_id: words = null
var snd_bank_n: int = 0
function audio_init() -> void {
if snd_ready { return }
snd_tab = bytes(AUDIO_CAP * 8) # one 8-byte pointer slot per handle
fill(snd_tab, 0, AUDIO_CAP * 8) # malloc does not zero; empty slots must read null
snd_bank_name = bytes(AUDIO_CAP * 8)
snd_bank_id = words(AUDIO_CAP)
snd_ready = true
}
# ---- the sound bank: Audio.define(name, path) then Audio.play(name: "…") ------
# Load a sound and remember it under a name; returns the handle (0 headless).
function audio_define(name: pointer, path: pointer) -> int {
audio_init()
let id = audio_load(path)
if snd_bank_n < AUDIO_CAP {
snd_bank_name[snd_bank_n] = name
snd_bank_id[snd_bank_n] = id
snd_bank_n = snd_bank_n + 1
}
return id
}
# the handle registered under `name`, or 0
function audio_named(name: pointer) -> int {
audio_init()
var i = 0
while i < snd_bank_n {
if snd_bank_name[i] == name { return snd_bank_id[i] }
i = i + 1
}
return 0
}
function audio_play_named(name: pointer) -> void { audio_play(audio_named(name)) }
function audio_play_music_named(name: pointer) -> void { audio_play_music(audio_named(name)) }
# Resolve a 1-based handle to its player pointer (null if out of range / empty).
function audio_get(id: int) -> pointer {
audio_init()

View file

@ -45,6 +45,7 @@ declare i32 @usleep(i32)
; mouse-association / relative-delta calls.
declare i32 @CGAssociateMouseAndMouseCursorPosition(i32)
declare void @CGGetLastMouseDelta(ptr, ptr)
declare i32 @CGWarpMouseCursorPosition(%NSPoint)
@.c_app = private unnamed_addr constant [14 x i8] c"NSApplication\00"
@.c_win = private unnamed_addr constant [9 x i8] c"NSWindow\00"
@ -54,6 +55,10 @@ declare void @CGGetLastMouseDelta(ptr, ptr)
@.c_gctx = private unnamed_addr constant [18 x i8] c"NSGraphicsContext\00"
@.c_ludic = private unnamed_addr constant [10 x i8] c"LudicView\00"
@.c_cursor = private unnamed_addr constant [9 x i8] c"NSCursor\00"
@.c_screen = private unnamed_addr constant [9 x i8] c"NSScreen\00"
@.s_cpts = private unnamed_addr constant [22 x i8] c"convertPointToScreen:\00"
@.s_main = private unnamed_addr constant [11 x i8] c"mainScreen\00"
@.s_frame = private unnamed_addr constant [6 x i8] c"frame\00"
@.s_hide = private unnamed_addr constant [5 x i8] c"hide\00"
@.s_unhide = private unnamed_addr constant [7 x i8] c"unhide\00"
@.s_iskey = private unnamed_addr constant [12 x i8] c"isKeyWindow\00"
@ -570,12 +575,14 @@ entry:
; #51 — read the live cursor position from the window (points, origin
; bottom-left) and convert to framebuffer pixels, y-flipped so row 0 is the
; top, matching the framebuffer the game draws into.
; #89 — in a relative mode (locked / confined) the OS cursor is dissociated, so
; #89 — only mode 2 (true lock / FPS mouselook) dissociates the OS cursor, so its
; mouseLocation is frozen; accumulate CGGetLastMouseDelta into a virtual cursor
; (clamped to the framebuffer) so Input.mouse_dx/dy report the motion and
; Input.mouse_x/y give a reticle position.
; Input.mouse_x/y give a reticle position. Mode 3 (confined) keeps the cursor
; ASSOCIATED, so it stays on the absolute path below — absolute mouse aim
; (aim_mode 0) works in a confined-cursor game, which is the whole point.
%cmode = load i32, ptr @W_cursor_mode
%isrel = icmp sge i32 %cmode, 2
%isrel = icmp eq i32 %cmode, 2
br i1 %isrel, label %reldelta, label %abspos
reldelta:
%dxp = alloca i32
@ -620,8 +627,21 @@ qpos:
%fx = fdiv double %px, %scd
%fyy = fdiv double %py, %scd
%fy = fsub double %fbhd, %fyy
%ix = fptosi double %fx to i32
%iy = fptosi double %fy to i32
%ix0 = fptosi double %fx to i32
%iy0 = fptosi double %fy to i32
; clamp the reported position to [0, fbw-1] x [0, fbh-1] so the reticle stays in
; the play area even when the (hidden) OS cursor strays past the window edge.
%fbwa = load i32, ptr @W_fbw
%fbwam = sub i32 %fbwa, 1
%fbham = sub i32 %fbh, 1
%axlo = icmp slt i32 %ix0, 0
%ix1 = select i1 %axlo, i32 0, i32 %ix0
%axhi = icmp sgt i32 %ix1, %fbwam
%ix = select i1 %axhi, i32 %fbwam, i32 %ix1
%aylo = icmp slt i32 %iy0, 0
%iy1 = select i1 %aylo, i32 0, i32 %iy0
%ayhi = icmp sgt i32 %iy1, %fbham
%iy = select i1 %ayhi, i32 %fbham, i32 %iy1
store i32 %ix, ptr @W_mx
store i32 %iy, ptr @W_my
br label %emit
@ -697,18 +717,93 @@ release:
call void @cursor_apply(i32 0, i32 1)
ret void
keyed:
; want_hidden when mode is 1 (hidden) or 2 (locked)
; want_hidden for every capture mode: 1 (hidden), 2 (locked) and 3 (confined).
; A confined-cursor game hides the OS cursor and draws its own reticle.
%h1 = icmp eq i32 %mode, 1
%h2 = icmp eq i32 %mode, 2
%wh = or i1 %h1, %h2
%h3 = icmp eq i32 %mode, 3
%hor = or i1 %h1, %h2
%wh = or i1 %hor, %h3
%whi = zext i1 %wh to i32
; dissociate (relative) when mode is 2 (locked) or 3 (confined)
; dissociate (relative deltas) ONLY for mode 2 (true lock / FPS mouselook).
; Mode 3 (confined) stays associated so its absolute position stays valid.
%a2 = icmp eq i32 %mode, 2
%a3 = icmp eq i32 %mode, 3
%dis = or i1 %a2, %a3
%wa = xor i1 %dis, true
%wa = xor i1 %a2, true
%wai = zext i1 %wa to i32
call void @cursor_apply(i32 %whi, i32 %wai)
; mode 3 (confined): physically hold the cursor inside the window each frame.
%ism3 = icmp eq i32 %mode, 3
br i1 %ism3, label %confine, label %noconf
confine:
call void @win_cursor_confine()
br label %noconf
noconf:
ret void
}
; #89 — confine the (hidden) cursor to the content area in mode 3. Because mode 3
; keeps the cursor ASSOCIATED (so absolute mouse aim works), the OS cursor is still
; free to leave the window; without this a click outside would land outside and drop
; focus. Each frame we read the window-local cursor point, and if it has strayed past
; the content rect we warp it back to the nearest inside point. Only warps when the
; cursor is actually out, so an in-window cursor is untouched.
define void @win_cursor_confine() {
entry:
%win = load ptr, ptr @W_win
%nowin = icmp eq ptr %win, null
br i1 %nowin, label %done, label %go
go:
%sel_ml = call ptr @sel_registerName(ptr @.s_mloc)
%pt = call %NSPoint (ptr, ptr) @objc_msgSend(ptr %win, ptr %sel_ml)
%px = extractvalue %NSPoint %pt, 0
%py = extractvalue %NSPoint %pt, 1
; content bounds in points: [0, fbw*scale] x [0, fbh*scale]
%sc = load i32, ptr @W_scale
%scd = sitofp i32 %sc to double
%fbw = load i32, ptr @W_fbw
%fbh = load i32, ptr @W_fbh
%fbwd = sitofp i32 %fbw to double
%fbhd = sitofp i32 %fbh to double
%cw = fmul double %fbwd, %scd
%ch = fmul double %fbhd, %scd
%cwm = fsub double %cw, 1.000000e+00
%chm = fsub double %ch, 1.000000e+00
; clamp px to [1, cw-1]
%pxlo = fcmp olt double %px, 1.000000e+00
%px1 = select i1 %pxlo, double 1.000000e+00, double %px
%pxhi = fcmp ogt double %px1, %cwm
%cpx = select i1 %pxhi, double %cwm, double %px1
; clamp py to [1, ch-1]
%pylo = fcmp olt double %py, 1.000000e+00
%py1 = select i1 %pylo, double 1.000000e+00, double %py
%pyhi = fcmp ogt double %py1, %chm
%cpy = select i1 %pyhi, double %chm, double %py1
; warp only if the cursor actually strayed
%dx = fcmp one double %cpx, %px
%dy = fcmp one double %cpy, %py
%need = or i1 %dx, %dy
br i1 %need, label %warp, label %done
warp:
; clamped window-local point -> global screen coords (bottom-left origin, y up)
%cp0 = insertvalue %NSPoint undef, double %cpx, 0
%cp = insertvalue %NSPoint %cp0, double %cpy, 1
%sel_cps = call ptr @sel_registerName(ptr @.s_cpts)
%gp = call %NSPoint (ptr, ptr, %NSPoint) @objc_msgSend(ptr %win, ptr %sel_cps, %NSPoint %cp)
%gx = extractvalue %NSPoint %gp, 0
%gy = extractvalue %NSPoint %gp, 1
; main screen height for the flip to CG global coords (top-left origin, y down)
%scrcls = call ptr @objc_getClass(ptr @.c_screen)
%sel_main = call ptr @sel_registerName(ptr @.s_main)
%scr = call ptr (ptr, ptr) @objc_msgSend(ptr %scrcls, ptr %sel_main)
%sel_fr = call ptr @sel_registerName(ptr @.s_frame)
%fr = call %CGRect (ptr, ptr) @objc_msgSend(ptr %scr, ptr %sel_fr)
%sh = extractvalue %CGRect %fr, 3
%warpy = fsub double %sh, %gy
%wp0 = insertvalue %NSPoint undef, double %gx, 0
%wp = insertvalue %NSPoint %wp0, double %warpy, 1
%rc = call i32 @CGWarpMouseCursorPosition(%NSPoint %wp)
br label %done
done:
ret void
}

View file

@ -90,6 +90,7 @@ function rt_screen_w() -> int { return rt_fbw }
function rt_screen_h() -> int { return rt_fbh }
function rt_clear(c: int) -> void {
rt_camera_tick() # a timed Camera.shake_for advances once per frame
let n = rt_fbw * rt_fbh
for i in 0 .. n {
rt_fb[i] = c
@ -328,6 +329,24 @@ function rt_camera_shake(amount: int) -> void {
rt_shake_y = rt_rng_range(0 - amount, amount)
}
# Camera.shake_for(amount, frames): shake by up to +/- amount pixels for `frames`
# frames, then stop — the engine re-rolls the offset at the start of every frame
# (rt_camera_tick, from the frame clear) so no handler has to count it down. A
# later call restarts the shake; a bigger amount wins over a smaller one in flight.
var rt_shake_amount: int = 0
var rt_shake_left: int = 0
function rt_camera_shake_for(amount: int, frames: int) -> void {
if amount >= rt_shake_amount { rt_shake_amount = amount; rt_shake_left = frames }
}
function rt_camera_tick() -> void {
if rt_shake_left > 0 {
rt_shake_left = rt_shake_left - 1
rt_camera_shake(rt_shake_amount)
return
}
if rt_shake_amount > 0 { rt_shake_amount = 0; rt_camera_shake(0) }
}
# Restrict drawing to a screen-space rectangle (x, y, width, height).
function rt_clip(x: int, y: int, width: int, height: int) -> void {
rt_clip_x0 = x
@ -601,6 +620,139 @@ function rt_map_row(y: int, s: string) -> void {
}
}
# ---- the cell API: a game edits the grid in place instead of keeping its own copy
function rt_map_set(x: int, y: int, glyph: int) -> void {
if (x < 0) or (y < 0) or (x >= rt_mapw) or (y >= rt_maph) { return }
rt_map[y * 96 + x] = glyph
}
function rt_map_fill(glyph: int) -> void {
var y = 0
while y < rt_maph { var x = 0; while x < rt_mapw { rt_map[y * 96 + x] = glyph; x = x + 1 }; y = y + 1 }
}
# every cell of the rectangle (x, y, w, h)
function rt_map_rect(x: int, y: int, w: int, h: int, glyph: int) -> void {
var yy = y
while yy < y + h { var xx = x; while xx < x + w { rt_map_set(xx, yy, glyph); xx = xx + 1 }; yy = yy + 1 }
}
# the outermost ring of cells
function rt_map_border(glyph: int) -> void {
rt_map_rect(0, 0, rt_mapw, 1, glyph)
rt_map_rect(0, rt_maph - 1, rt_mapw, 1, glyph)
rt_map_rect(0, 0, 1, rt_maph, glyph)
rt_map_rect(rt_mapw - 1, 0, 1, rt_maph, glyph)
}
# a random cell holding `glyph` (seeded RNG): random tries, then a sweep; (-1, -1) if none
function rt_map_random_cell(glyph: int) -> IVec2 {
var tries = 0
while tries < 64 {
let x = rt_rng_range(0, rt_mapw - 1)
let y = rt_rng_range(0, rt_maph - 1)
if rt_tile(x, y) == glyph { return IVec2.make(x, y) }
tries = tries + 1
}
var y2 = 0
while y2 < rt_maph { var x2 = 0; while x2 < rt_mapw { if rt_tile(x2, y2) == glyph { return IVec2.make(x2, y2) }; x2 = x2 + 1 }; y2 = y2 + 1 }
return IVec2.make(0 - 1, 0 - 1)
}
# a random cell holding `glyph` at least `min_tiles` from `from` (tiles); falls back to any such cell
function rt_map_random_cell_far(glyph: int, from: IVec2, min_tiles: int) -> IVec2 {
var tile = rt_map_random_cell(glyph)
var tries = 0
while tries < 40 {
if not IVec2.within(tile, from, min_tiles - 1) { return tile }
tile = rt_map_random_cell(glyph)
tries = tries + 1
}
return tile
}
# the solid glyphs, as the move system read them from the Solids config (0 = none)
var rt_map_solid1: int = 0
var rt_map_solid2: int = 0
var rt_map_tile_px: int = 16
function rt_map_is_solid(x: int, y: int) -> bool {
if rt_map_solid1 == 0 { return false } # no Solids config: nothing is solid
let g = rt_tile(x, y)
if (x < 0) or (y < 0) or (x >= rt_mapw) or (y >= rt_maph) { return true }
if (rt_map_solid1 != 0) and (g == rt_map_solid1) { return true }
if (rt_map_solid2 != 0) and (g == rt_map_solid2) { return true }
return false
}
function rt_map_is_solid_at(px: int, py: int) -> bool { return rt_map_is_solid(px / rt_map_tile_px, py / rt_map_tile_px) }
function rt_map_to_tile(pixel: IVec2) -> IVec2 { return IVec2.make(pixel.x / rt_map_tile_px, pixel.y / rt_map_tile_px) }
function rt_map_width() -> int { return rt_mapw }
function rt_map_height() -> int { return rt_maph }
# IVec2.heading / along / step and Angle.diff_degrees — integer-degree geometry
function rt_ivec_heading(a: IVec2, b: IVec2) -> int {
return floor(Math.rad_to_deg(Math.atan2(fixed(b.y - a.y), fixed(b.x - a.x))))
}
function rt_ivec_along(origin: IVec2, degrees: int, distance: int) -> IVec2 {
let r = Math.deg_to_rad(fixed(degrees))
return IVec2.make(origin.x + floor(Math.cos(r) * fixed(distance)), origin.y + floor(Math.sin(r) * fixed(distance)))
}
function rt_ivec_step(degrees: int) -> IVec2 {
let p = rt_ivec_along(IVec2.zero(), degrees, 10)
return IVec2.make(Math.sign(p.x), Math.sign(p.y))
}
function rt_angle_diff_degrees(a: int, b: int) -> int {
var d = (b - a) % 360
if d > 180 { d = d - 360 }
if d <= 0 - 180 { d = d + 360 }
return d
}
# Screen.bar: a filled meter — `value` of `max` in `color` over a `back` track
function rt_bar(x: int, y: int, w: int, h: int, value: int, max: int, color: int, back: int) -> void {
rt_fill_rect(x, y, w, h, back)
var filled = 0
if max > 0 { filled = clamp(value, 0, max) * w / max }
if filled > 0 { rt_fill_rect(x, y, filled, h, color) }
}
# Random.weighted(weights): an index drawn in proportion to its weight (0 = never);
# -1 when every weight is 0. Deterministic, from the seeded RNG.
function rt_rng_weighted(weights: []int) -> int {
var total = 0
var i = 0
while i < len(weights) { if weights[i] > 0 { total = total + weights[i] }; i = i + 1 }
if total <= 0 { return 0 - 1 }
var roll = rt_rng_range(0, total - 1)
i = 0
while i < len(weights) {
if weights[i] > 0 {
if roll < weights[i] { return i }
roll = roll - weights[i]
}
i = i + 1
}
return 0 - 1
}
# List.sample(pool, count): `count` picks from an int slice, distinct while the
# pool has enough, repeating a valid pick when it does not; empty in -> zeros
function rt_list_sample(pool: []int, count: int) -> []int {
let out = new []int
let n = len(pool)
var i = 0
while i < count {
if n == 0 { push(out, 0); i = i + 1; continue }
var pick = rt_rng_range(0, n - 1)
var distinct = n > i
var tries = 0
while distinct and (tries < 64) {
var seen = false
var j = 0
while j < len(out) { if out[j] == pool[pick] { seen = true }; j = j + 1 }
if not seen { break }
pick = rt_rng_range(0, n - 1)
tries = tries + 1
}
push(out, pool[pick])
i = i + 1
}
return out
}
function rt_tile(x: int, y: int) -> int {
if x < 0 { return 35 }
if y < 0 { return 35 }

124
runtime/native/fx.ludic Normal file
View file

@ -0,0 +1,124 @@
# ============================================================================
# fx.ludic — Fx.*: engine-owned transient effects. A game asks for a burst of
# sparks or a floating number and the engine owns the rest: it ages and moves
# them every Update, draws them every Render (after the sprites, before a game's
# Overlay pass, through the camera / shake / clip chokepoints), and drops them
# when they expire. Nothing is an entity, so nothing needs a component, a model,
# a handler or a draw call. Deterministic: velocities and lifetimes come from the
# seeded RNG.
#
# Fx.sparks(x, y, color, count) a burst of `count` sparks flying out of (x, y)
# Fx.number(x, y, value, color) a number that floats up from (x, y) and fades
# Fx.clear() drop every effect (a room change, a new run)
# ============================================================================
const FX_MAX_SPARKS: int = 512
const FX_MAX_NUMBERS: int = 64
const FX_SPARK_LIFE: int = 18 # frames, plus up to FX_SPARK_LIFE_JITTER
const FX_SPARK_LIFE_JITTER: int = 8
const FX_SPARK_SPEED: int = 3 # px per frame, each axis, either way
const FX_NUMBER_LIFE: int = 30 # frames; a number rises one pixel every other frame
var fx_ready: bool = false
var fx_sx: words = null
var fx_sy: words = null
var fx_svx: words = null
var fx_svy: words = null
var fx_slife: words = null
var fx_scolor: words = null
var fx_ssize: words = null
var fx_sn: int = 0
var fx_nx: words = null
var fx_ny: words = null
var fx_nvalue: words = null
var fx_nlife: words = null
var fx_ncolor: words = null
var fx_nn: int = 0
function fx_init() -> void {
if fx_ready { return }
fx_sx = words(FX_MAX_SPARKS); fx_sy = words(FX_MAX_SPARKS)
fx_svx = words(FX_MAX_SPARKS); fx_svy = words(FX_MAX_SPARKS)
fx_slife = words(FX_MAX_SPARKS); fx_scolor = words(FX_MAX_SPARKS); fx_ssize = words(FX_MAX_SPARKS)
fx_nx = words(FX_MAX_NUMBERS); fx_ny = words(FX_MAX_NUMBERS)
fx_nvalue = words(FX_MAX_NUMBERS); fx_nlife = words(FX_MAX_NUMBERS); fx_ncolor = words(FX_MAX_NUMBERS)
fx_ready = true
}
function fx_sparks(x: int, y: int, color: int, count: int) -> void {
fx_init()
var i = 0
while i < count {
if fx_sn >= FX_MAX_SPARKS { return }
fx_sx[fx_sn] = x
fx_sy[fx_sn] = y
fx_svx[fx_sn] = rt_rng_range(0 - FX_SPARK_SPEED, FX_SPARK_SPEED)
fx_svy[fx_sn] = rt_rng_range(0 - FX_SPARK_SPEED, FX_SPARK_SPEED)
fx_slife[fx_sn] = FX_SPARK_LIFE + rt_rng_range(0, FX_SPARK_LIFE_JITTER)
fx_scolor[fx_sn] = color
fx_ssize[fx_sn] = 1 + rt_rng_range(0, 2)
fx_sn = fx_sn + 1
i = i + 1
}
}
function fx_number(x: int, y: int, value: int, color: int) -> void {
fx_init()
if fx_nn >= FX_MAX_NUMBERS { return }
fx_nx[fx_nn] = x
fx_ny[fx_nn] = y
fx_nvalue[fx_nn] = value
fx_nlife[fx_nn] = FX_NUMBER_LIFE
fx_ncolor[fx_nn] = color
fx_nn = fx_nn + 1
}
function fx_clear() -> void { fx_sn = 0; fx_nn = 0 }
# drop spark i by moving the last one into its slot
function fx_drop_spark(i: int) -> void {
let last = fx_sn - 1
fx_sx[i] = fx_sx[last]; fx_sy[i] = fx_sy[last]
fx_svx[i] = fx_svx[last]; fx_svy[i] = fx_svy[last]
fx_slife[i] = fx_slife[last]; fx_scolor[i] = fx_scolor[last]; fx_ssize[i] = fx_ssize[last]
fx_sn = last
}
function fx_drop_number(i: int) -> void {
let last = fx_nn - 1
fx_nx[i] = fx_nx[last]; fx_ny[i] = fx_ny[last]
fx_nvalue[i] = fx_nvalue[last]; fx_nlife[i] = fx_nlife[last]; fx_ncolor[i] = fx_ncolor[last]
fx_nn = last
}
# once per Update: move and age everything, dropping what expired
function fx_tick() -> void {
if not fx_ready { return }
var i = 0
while i < fx_sn {
fx_sx[i] = fx_sx[i] + fx_svx[i]
fx_sy[i] = fx_sy[i] + fx_svy[i]
fx_slife[i] = fx_slife[i] - 1
if fx_slife[i] <= 0 { fx_drop_spark(i) } else { i = i + 1 }
}
i = 0
while i < fx_nn {
fx_nlife[i] = fx_nlife[i] - 1
if fx_nlife[i] % 2 == 0 { fx_ny[i] = fx_ny[i] - 1 }
if fx_nlife[i] <= 0 { fx_drop_number(i) } else { i = i + 1 }
}
}
# once per Render, after the sprites: everything goes through the camera chokepoints
function fx_draw() -> void {
if not fx_ready { return }
var i = 0
while i < fx_sn {
rt_fill_rect(fx_sx[i], fx_sy[i], fx_ssize[i], fx_ssize[i], fx_scolor[i])
i = i + 1
}
i = 0
while i < fx_nn {
rt_text_int(fx_nx[i] - 4, fx_ny[i] - 10, fx_nvalue[i], fx_ncolor[i], 1)
i = i + 1
}
}

View file

@ -381,6 +381,22 @@ function input_axis_i(neg: int, pos: int) -> int {
if in_bit_get(in_held, neg) { v = v - 1 }
return v
}
# The standard top-down movement intent as -1/0/1 per axis: WASD or the arrow
# keys, and the left stick of pad 0 (past a 0.35 deadzone) when one is connected.
function input_move_i() -> IVec2 {
in_init()
var x = input_axis_i(Key.A, Key.D) + input_axis_i(Key.Left, Key.Right)
var y = input_axis_i(Key.W, Key.S) + input_axis_i(Key.Up, Key.Down)
if input_pad_connected(0) {
let sx = input_pad_axis(0, 0)
let sy = input_pad_axis(0, 1)
if sx > 0.35 { x = 1 }
if sx < -0.35 { x = 0 - 1 }
if sy > 0.35 { y = 1 }
if sy < -0.35 { y = 0 - 1 }
}
return IVec2.make(clamp(x, 0 - 1, 1), clamp(y, 0 - 1, 1))
}
# A 2D vector from four direction keys, normalized so a diagonal is not faster.
function input_vector(left: int, right: int, up: int, down: int) -> Vector {
in_init()
@ -412,6 +428,9 @@ function input_strength(name: pointer) -> fixed {
# 3 confined — dissociated but visible; the mouse cannot leave the window.
# The platform auto-releases (shows + reconnects) while the window is not key
# (Cmd-Tab) and on close. Headless / non-windowed: a no-op.
enum CursorMode { Normal, Hidden, Locked, Confined } # Input.cursor_mode(mode:)
enum PadButton { A, B, X, Y, LeftShoulder, RightShoulder, Back, Start } # Input.bind_pad(button:) / pad_button
enum MouseButton { Left, Right, Middle } # Input.mouse_down(button:)
function input_cursor_mode(mode: int) -> void {
if is_windowed() { win_cursor_mode(mode) }
}

View file

@ -74,6 +74,7 @@ var pm_c_layer: int = 0 - 1
var pm_c_mask: int = 0 - 1
var phys_ts: int = 0 # tile size px (0 = grid off)
var phys_wall: int = 0 # solid glyph
var phys_wall2: int = 0 # optional second solid glyph (Solids.solid2), e.g. a closed door
var phys_oneway: int = 0 # one-way platform glyph (0 = none)
# the moving body's own layer/mask, so the solid scan can filter by them
@ -106,7 +107,9 @@ function phys_tile_solid(c: int, r: int) -> bool {
if r < 0 { return true }
if c >= rt_mapw { return true }
if r >= rt_maph { return true }
return rt_tile(c, r) == phys_wall
let g = rt_tile(c, r)
if g == phys_wall { return true }
return (phys_wall2 != 0) and (g == phys_wall2)
}
# a one-way platform tile (in bounds only — the arena edge is a full wall above).
function phys_tile_oneway(c: int, r: int) -> bool {
@ -395,7 +398,7 @@ function esys_move() -> void {
pm_c_layer = World.field_id(pm_col, "layer")
pm_c_mask = World.field_id(pm_col, "mask")
}
phys_ts = 0; phys_wall = 0; phys_oneway = 0
phys_ts = 0; phys_wall = 0; phys_wall2 = 0; phys_oneway = 0
let ps = World.prop_id("Solids")
if ps >= 0 {
let se = World.query_next(ps, 0)
@ -406,6 +409,10 @@ function esys_move() -> void {
if f_tile >= 0 { phys_ts = World.get(se, ps, f_tile) }
if f_wall >= 0 { phys_wall = World.get(se, ps, f_wall) }
if f_ow >= 0 { phys_oneway = World.get(se, ps, f_ow) }
let f_w2 = World.field_id(ps, "solid2")
if f_w2 >= 0 { phys_wall2 = World.get(se, ps, f_w2) }
rt_map_solid1 = phys_wall; rt_map_solid2 = phys_wall2 # Map.is_solid reads the same config
if phys_ts > 0 { rt_map_tile_px = phys_ts }
}
}

View file

@ -23,11 +23,13 @@
# flip : int = 0 # 1 = mirror horizontally
# tint : int = 0 # 0 = sprite's own colours; non-zero = solid tint (hit flash)
# hidden : int = 0 # 1 = skip drawing this entity
# atlas : int = 0 # 1 = `id` is an atlas sprite (Sprite.cell / cell_span), any size (#90)
# }
#
# Draw order is entity/query order (spawn order). Everything integer + through the
# camera/zoom/clip chokepoints, so it stays deterministic and headless-identical.
# ============================================================================
import "atlas.ludic" # #90: atlas sprites draw through atlas_draw_ex
# read entity `e`'s world coordinate from a Position component (x when axis==0,
# y when axis==1), or 0 if it has none.
@ -52,17 +54,44 @@ function esys_sprite() -> void {
let f_flip = World.field_id(P, "flip")
let f_tint = World.field_id(P, "tint")
let f_hidden = World.field_id(P, "hidden")
let f_atlas = World.field_id(P, "atlas")
let SA = World.prop_id("SpriteAnim")
var f_frame = 0 - 1
if SA >= 0 { f_frame = World.field_id(SA, "frame") }
let f_move = World.field_id(P, "move_id")
let f_face = World.field_id(P, "face")
let f_flash = World.field_id(P, "flash")
let f_blink = World.field_id(P, "blink")
let TD = World.prop_id("TopDown")
var f_wx = 0 - 1; var f_wy = 0 - 1
if TD >= 0 { f_wx = World.field_id(TD, "want_x"); f_wy = World.field_id(TD, "want_y") }
let PB = World.prop_id("Body")
var f_vx = 0 - 1; var f_vy = 0 - 1
if PB >= 0 { f_vx = World.field_id(PB, "vx"); f_vy = World.field_id(PB, "vy") }
var e = World.query_next(P, 0)
while e >= 0 {
var hidden = 0
if f_hidden >= 0 { hidden = World.get(e, P, f_hidden) }
if (f_blink >= 0) and (World.get(e, P, f_blink) > 0) and (Time.frame() % 4 < 2) { hidden = 1 }
if hidden == 0 {
var id = World.get(e, P, f_id)
# movement, from the TopDown intent or the Body velocity: picks the move strip and the facing
var moving = 0
var dir = 0
if (TD >= 0) and (f_wx >= 0) and (World.has(e, TD) != 0) {
let wx = World.get(e, TD, f_wx); let wy = World.get(e, TD, f_wy)
if (wx != 0) or (wy != 0) { moving = 1 }; dir = wx
} else { if (PB >= 0) and (f_vx >= 0) and (World.has(e, PB) != 0) {
let vx = World.get(e, PB, f_vx); let vy = World.get(e, PB, f_vy)
if (vx != 0) or (vy != 0) { moving = 1 }; dir = vx
} }
if (moving == 1) and (f_move >= 0) { let mid = World.get(e, P, f_move); if mid > 0 { id = mid } }
if (f_face >= 0) and (World.get(e, P, f_face) != 0) and (dir != 0) {
var fl = 0; if dir < 0 { fl = 1 }
World.set(e, P, f_flip, fl) # written back so an idle sprite keeps facing
}
# add the current animation frame when the entity also carries SpriteAnim.
if (SA >= 0) and (f_frame >= 0) {
if World.has(e, SA) != 0 { id = id + World.get(e, SA, f_frame) }
@ -77,7 +106,11 @@ function esys_sprite() -> void {
if f_flip >= 0 { flip = World.get(e, P, f_flip) }
var tint = 0
if f_tint >= 0 { tint = World.get(e, P, f_tint) }
rt_draw_sprite_ex(id, x, y, sc, flip, tint)
if (f_flash >= 0) and (World.get(e, P, f_flash) > 0) { tint = 0xffffff }
var is_atlas = 0
if f_atlas >= 0 { is_atlas = World.get(e, P, f_atlas) }
if is_atlas != 0 { atlas_draw_ex(id, x, y, sc, flip, tint) } # #90 atlas cells / spans
else { rt_draw_sprite_ex(id, x, y, sc, flip, tint) }
}
e = World.query_next(P, e + 1)
}

View file

@ -0,0 +1,52 @@
# ============================================================================
# systems_tileskin.ludic — the engine-owned tilemap-render system.
#
# A game that builds its world with Map.row(...) used to draw it by hand — a loop over
# every cell calling a sprite draw per glyph. Declare one `TileSkin` entity per glyph
# (from ludic.core) and the engine paints the whole Map.* grid each Render frame,
# BEFORE the sprite system, so actors land on top of the floor and walls:
#
# spawn Wall { TileSkin { glyph: '#', sprite: wall_id, atlas: 1 } }
#
# Cells whose glyph has no skin are left to the clear colour (a flat floor is free).
# Runs through the same draw chokepoints as sprites (camera / zoom / clip), integer,
# deterministic. Spliced only when a game declares TileSkin.
#
# property TileSkin { glyph: int, sprite: int, atlas: int = 1, size: int = 16, scale: int = 1 }
# ============================================================================
import "atlas.ludic"
function esys_tileskin() -> void {
let P = World.prop_id("TileSkin")
if P < 0 { return }
let f_glyph = World.field_id(P, "glyph")
let f_sprite = World.field_id(P, "sprite")
let f_atlas = World.field_id(P, "atlas")
let f_size = World.field_id(P, "size")
let f_scale = World.field_id(P, "scale")
if (f_glyph < 0) or (f_sprite < 0) { return }
var e = World.query_next(P, 0)
while e >= 0 {
let glyph = World.get(e, P, f_glyph)
let sprite = World.get(e, P, f_sprite)
var is_atlas = 1
if f_atlas >= 0 { is_atlas = World.get(e, P, f_atlas) }
var size = 16
if f_size >= 0 { let sv = World.get(e, P, f_size); if sv > 0 { size = sv } }
var sc = 1
if f_scale >= 0 { let cv = World.get(e, P, f_scale); if cv > 1 { sc = cv } }
var ty = 0
while ty < rt_maph {
var tx = 0
while tx < rt_mapw {
if rt_tile(tx, ty) == glyph {
if is_atlas != 0 { atlas_draw_ex(sprite, tx * size, ty * size, sc, 0, 0) }
else { rt_draw_sprite_ex(sprite, tx * size, ty * size, sc, 0, 0) }
}
tx = tx + 1
}
ty = ty + 1
}
e = World.query_next(P, e + 1)
}
}

View file

@ -164,6 +164,13 @@ function ui_focusables() -> int {
return n
}
# a focused control was activated (Enter / Space / pad A): the event-driven form of
# ui_clicked — `@On(UiClicked) handler { if id == UI_Play { ... } }`.
event UiClicked { id: int = 0 }
# Ui.close(): no menu is active (the same as opening root -1)
function rt_ui_close() -> void { rt_ui_open(0 - 1) }
function rt_ui_open(root: int) -> void {
ui_active = root
let n = ui_focusables()
@ -172,8 +179,8 @@ function rt_ui_open(root: int) -> void {
}
function rt_ui_tick(k: int) -> void {
if ui_active < 0 { return }
for i in 0 .. ui_n { ui_fired[i] = 0 }
for i in 0 .. ui_n { ui_fired[i] = 0 } # a closed UI clears clicks too, so a stale
if ui_active < 0 { return } # activation never leaks to the next frame/scene
ui_layout()
let n = ui_focusables()
if n == 0 {
@ -196,9 +203,11 @@ function rt_ui_tick(k: int) -> void {
cur = (cur + 1) % n
ui_focus = ui_fl[cur]
}
if k == 32 { ui_fired[ui_focus] = 1 }
if k == 10 { ui_fired[ui_focus] = 1 }
if k == 13 { ui_fired[ui_focus] = 1 }
var fire = 0
if k == 32 { fire = 1 }
if k == 10 { fire = 1 }
if k == 13 { fire = 1 }
if fire == 1 { ui_fired[ui_focus] = 1; emit UiClicked(id: ui_focus) }
}
function rt_ui_render() -> void {