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

@ -0,0 +1,19 @@
---
id: angle-diff_degrees
name: Angle.diff_degrees
category: angle
kind: namespace-method
tokens: Angle.diff_degrees
sig: Angle.diff_degrees(a: int, b: int) -> int
tip: The signed difference between two headings in whole degrees.
order: 20
ns: Angle
member: diff_degrees
---
<code>b - a</code> wrapped into -180 .. 180, for integer-degree headings such as <code>TopDown.aim_angle</code> and <code>IVec2.heading</code>.
```ludic
# doc-check: skip — illustrative
if abs(Angle.diff_degrees(a: aim, b: IVec2.heading(hero, foe))) <= 50 { hit() }
```

View file

@ -0,0 +1,21 @@
---
id: assets-enqueue_dir
name: Assets.enqueue_dir
category: assets
kind: namespace-method
tokens: Assets.enqueue_dir
sig: Assets.enqueue_dir(path: dir)
tip: Enqueue every file of a directory, named by its file name.
order: 10
ns: Assets
member: enqueue_dir
---
Each file is enqueued under its name without the extension (<code>assets/audio/hit.wav</code> → <code>hit</code>); the queue sorts it by extension as usual, so a sound directory becomes the sound bank in one line.
```ludic
# doc-check: skip — illustrative
Assets.enqueue_dir(path: "assets/audio")
…
Audio.play(name: "hit")
```

View file

@ -0,0 +1,21 @@
---
id: assets-font
name: Assets.font
category: assets
kind: namespace-method
tokens: Assets.font
sig: Assets.font(name: s) -> int
tip: A font loaded through the preload queue, by name.
order: 9
ns: Assets
member: font
---
The font handle for a <code>.ttf</code> / <code>.ttc</code> that <code>Assets.enqueue</code> loaded under <code>name</code>; 0 while it is not loaded yet. The queue sorts by extension: images become sprites (<code>Assets.get</code>), sounds go to the audio bank (<code>Audio.play(name:)</code>), fonts here.
```ludic
# doc-check: skip — illustrative
Assets.enqueue(name: "menu", path: "assets/fonts/PixelOperator.ttf")
…
menu_font = Assets.font(name: "menu")
```

View file

@ -0,0 +1,20 @@
---
id: audio-define
name: Audio.define
category: audio
kind: namespace-method
tokens: Audio.define
sig: Audio.define(name: s, path: p) -> int
tip: Load a sound into the bank under a name.
order: 20
ns: Audio
member: define
---
Loads a sound file and registers it under <code>name</code>, so <code>Audio.play(name: …)</code> and <code>Audio.play_music(name: …)</code> can fire it without a handle in the game's own state. Returns the handle (0 headless). <code>Assets.enqueue</code> does the same for a <code>.wav</code> / <code>.mp3</code> path.
```ludic
# doc-check: skip — illustrative
Audio.define(name: "hit", path: "assets/audio/hit.wav")
Audio.play(name: "hit")
```

View file

@ -0,0 +1,19 @@
---
id: audio-named
name: Audio.named
category: audio
kind: namespace-method
tokens: Audio.named
sig: Audio.named(name: s) -> int
tip: The handle registered under a name, or 0.
order: 21
ns: Audio
member: named
---
Looks a sound up in the bank by the name <code>Audio.define</code> (or <code>Assets.enqueue</code>) gave it.
```ludic
# doc-check: skip — illustrative
let hit = Audio.named(name: "hit")
```

View file

@ -0,0 +1,19 @@
---
id: camera-shake_for
name: Camera.shake_for
category: camera
kind: namespace-method
tokens: Camera.shake_for
sig: Camera.shake_for(amount: px, frames: n)
tip: Shake for a number of frames, then stop, with no bookkeeping.
order: 5
ns: Camera
member: shake_for
---
Shakes the camera by up to ±<code>amount</code> pixels for <code>frames</code> frames; the engine re-rolls the offset each frame and clears it when the time is up. A bigger amount replaces one in flight. <code>Camera.shake(amount: 0)</code> still cancels the offset for the rest of a frame — useful before a HUD pass that must stay steady.
```ludic
# doc-check: skip — illustrative
@On(Damaged) handler Hurt { if target == player { Camera.shake_for(amount: 4, frames: 8) } }
```

View file

@ -0,0 +1,7 @@
---
id: file
title: File
order: 44
---
Raw file access over the C stdio calls, for programs that read and write their own formats. <a href="file-open"><code>File.open</code></a> returns a handle (or <code>null</code>), <a href="file-read"><code>File.read</code></a> / <a href="file-write"><code>File.write</code></a> move bytes through a <code>bytes</code> buffer, <a href="file-seek"><code>File.seek</code></a> / <a href="file-tell"><code>File.tell</code></a> position the cursor, and <a href="file-close"><code>File.close</code></a> releases the handle. For text and structured data prefer the higher-level <code>Fs</code>, <code>Json</code> and <code>Prefs</code> APIs.

View file

@ -0,0 +1,19 @@
---
id: file-close
name: File.close
category: file
kind: namespace-method
tokens: File.close
sig: File.close(file: f)
tip: Close a handle from File.open.
order: 6
ns: File
member: close
---
Flushes and releases the handle. Every successful <code>File.open</code> pairs with one <code>File.close</code>.
```ludic
# doc-check: skip — illustrative
File.close(file: f)
```

View file

@ -0,0 +1,20 @@
---
id: file-open
name: File.open
category: file
kind: namespace-method
tokens: File.open
sig: File.open(path: p, mode: m) -> pointer
tip: Open a file (fopen); null when it cannot be opened.
order: 1
ns: File
member: open
---
Opens <code>path</code> with a C <code>fopen</code> mode string (<code>"rb"</code>, <code>"wb"</code>, <code>"ab"</code>). Returns the handle, or <code>null</code> when the file cannot be opened — always test for it.
```ludic
# doc-check: skip — illustrative
let f = File.open(path: "save/best.txt", mode: "rb")
if f == null { return }
```

View file

@ -0,0 +1,20 @@
---
id: file-read
name: File.read
category: file
kind: namespace-method
tokens: File.read
sig: File.read(file: f, buffer: b, count: n) -> int
tip: Read up to n bytes into a buffer; returns the bytes read.
order: 2
ns: File
member: read
---
Reads at most <code>count</code> bytes from the handle into <code>buffer</code> (a <code>bytes</code> allocation) and returns how many arrived; fewer than asked means the end of the file.
```ludic
# doc-check: skip — illustrative
let buf = bytes(size + 1)
let got = File.read(file: f, buffer: buf, count: size)
```

View file

@ -0,0 +1,21 @@
---
id: file-seek
name: File.seek
category: file
kind: namespace-method
tokens: File.seek
sig: File.seek(file: f, offset: o, whence: w)
tip: Move the file cursor (0 start, 1 current, 2 end).
order: 4
ns: File
member: seek
---
Positions the cursor at <code>offset</code> bytes from <code>whence</code>: 0 the start, 1 the current position, 2 the end. Seeking to the end and reading <code>File.tell</code> measures a file.
```ludic
# doc-check: skip — illustrative
File.seek(file: f, offset: 0, whence: 2)
let size = File.tell(file: f)
File.seek(file: f, offset: 0, whence: 0)
```

View file

@ -0,0 +1,19 @@
---
id: file-tell
name: File.tell
category: file
kind: namespace-method
tokens: File.tell
sig: File.tell(file: f) -> int
tip: The cursor position in bytes.
order: 5
ns: File
member: tell
---
Returns the current cursor position in bytes from the start of the file.
```ludic
# doc-check: skip — illustrative
let size = File.tell(file: f)
```

View file

@ -0,0 +1,20 @@
---
id: file-write
name: File.write
category: file
kind: namespace-method
tokens: File.write
sig: File.write(file: f, buffer: b, count: n) -> int
tip: Write n bytes from a buffer; returns the bytes written.
order: 3
ns: File
member: write
---
Writes <code>count</code> bytes of <code>buffer</code> (a string or a <code>bytes</code> allocation) to the handle and returns how many were written.
```ludic
# doc-check: skip — illustrative
let line = "best_floor=3\n"
File.write(file: f, buffer: line, count: len(line))
```

View file

@ -0,0 +1,7 @@
---
id: font
title: Font
order: 45
---
TrueType fonts for the retained UI and for <code>Screen.draw_text</code>-style drawing. <a href="font-load"><code>Font.load</code></a> reads a <code>.ttf</code> / <code>.ttc</code> file and returns a handle that a <code>ui</code> block's <code>font:</code> property or a text call takes.

View file

@ -0,0 +1,23 @@
---
id: font-load
name: Font.load
category: font
kind: namespace-method
tokens: Font.load
sig: Font.load(path: p) -> int
tip: Load a TrueType font; returns a font handle.
order: 1
ns: Font
member: load
---
Loads a TrueType font file and returns its handle. Load it before <code>Ui.build</code> so the menus can measure their text; the handle is what a <code>ui</code> block's <code>font:</code> reads.
```ludic
# doc-check: skip — illustrative
var menu_font: int = 0
handler Boot phase Start {
menu_font = Font.load(path: "assets/fonts/PixelOperator.ttf")
Ui.build()
}
```

View file

@ -0,0 +1,7 @@
---
id: fx
title: Fx
order: 46
---
Engine-owned transient effects. A game asks for a burst of sparks or a floating number and the engine owns the rest: it moves and ages them every Update, draws them every Render after the sprites (through the camera, shake and clip), and drops them when they expire. Nothing is an entity, so a hit effect needs no component, model, handler or draw call. Velocities and lifetimes come from the seeded RNG, so a replay produces the same sparks.

View file

@ -0,0 +1,22 @@
---
id: fx-clear
name: Fx.clear
category: fx
kind: namespace-method
tokens: Fx.clear
sig: Fx.clear()
tip: Drop every spark and number at once.
order: 3
ns: Fx
member: clear
---
Removes every effect in flight — call it when the world changes under them (a room change, a new run).
```ludic
# doc-check: skip — illustrative
function clear_room() -> void {
for (p) in query [Position] { despawn self() }
Fx.clear()
}
```

View file

@ -0,0 +1,19 @@
---
id: fx-number
name: Fx.number
category: fx
kind: namespace-method
tokens: Fx.number
sig: Fx.number(x: px, y: px, value: n, color: c)
tip: A number that floats up from a point and fades.
order: 2
ns: Fx
member: number
---
Shows <code>value</code> above (x, y) for 30 frames, rising one pixel every other frame — the damage number of every action game.
```ludic
# doc-check: skip — illustrative
@On(Damaged) handler ShowHit { Fx.number(x: center(target).x, y: center(target).y, value: amount, color: Color.Lemon) }
```

View file

@ -0,0 +1,19 @@
---
id: fx-sparks
name: Fx.sparks
category: fx
kind: namespace-method
tokens: Fx.sparks
sig: Fx.sparks(x: px, y: px, color: c, count: n)
tip: A burst of sparks flying out of a point.
order: 1
ns: Fx
member: sparks
---
Spawns <code>count</code> sparks at (x, y), each with a random velocity of up to 3px per frame on each axis and a lifetime of 18 to 26 frames; they are drawn as 1 to 3 pixel squares in <code>color</code>.
```ludic
# doc-check: skip — illustrative
@On(Died) handler Burst { Fx.sparks(x: center(e).x, y: center(e).y, color: Color.Crimson, count: 10) }
```

View file

@ -0,0 +1,20 @@
---
id: input-move_i
name: Input.move_i
category: input
kind: namespace-method
tokens: Input.move_i
sig: Input.move_i() -> IVec2
tip: The standard top-down movement intent, -1/0/1 per axis.
order: 60
ns: Input
member: move_i
---
WASD or the arrow keys, and the left stick of pad 0 (past a 0.35 deadzone) when one is connected, as an <code>IVec2</code> of -1, 0 or 1 per axis — what a top-down mover feeds <code>TopDown.move</code>.
```ludic
# doc-check: skip — illustrative
let move = Input.move_i()
TopDown.move(player, move.x, move.y)
```

View file

@ -0,0 +1,19 @@
---
id: ivec2-along
name: IVec2.along
category: ivec2
kind: namespace-method
tokens: IVec2.along
sig: IVec2.along(origin, degrees, distance) -> IVec2
tip: The point a distance along a heading.
order: 23
ns: IVec2
member: along
---
The point <code>distance</code> pixels from <code>origin</code> along <code>degrees</code> — the tip of an aim line, the centre of a melee arc.
```ludic
# doc-check: skip — illustrative
let tip = IVec2.along(hero, aim, 18)
```

View file

@ -0,0 +1,19 @@
---
id: ivec2-distance2
name: IVec2.distance2
category: ivec2
kind: namespace-method
tokens: IVec2.distance2
sig: IVec2.distance2(a, b) -> int
tip: The squared distance between two points.
order: 20
ns: IVec2
member: distance2
---
<code>dx*dx + dy*dy</code> — compare it against a squared radius to avoid a square root.
```ludic
# doc-check: skip — illustrative
if IVec2.distance2(hero, foe) < 24 * 24 { … }
```

View file

@ -0,0 +1,19 @@
---
id: ivec2-heading
name: IVec2.heading
category: ivec2
kind: namespace-method
tokens: IVec2.heading
sig: IVec2.heading(from, to) -> int
tip: The direction from one point to another, in degrees.
order: 22
ns: IVec2
member: heading
---
The angle from <code>from</code> to <code>to</code> in whole degrees: 0 is +x, 90 is +y (screen y grows downward). Pair it with <code>Angle.diff_degrees</code> for an arc test.
```ludic
# doc-check: skip — illustrative
let to_foe = IVec2.heading(hero, foe)
```

View file

@ -0,0 +1,19 @@
---
id: ivec2-step
name: IVec2.step
category: ivec2
kind: namespace-method
tokens: IVec2.step
sig: IVec2.step(degrees) -> IVec2
tip: A -1/0/1 unit step along a heading.
order: 24
ns: IVec2
member: step
---
The heading snapped to the eight compass steps: each component is -1, 0 or 1. A roll or knockback direction from an aim angle.
```ludic
# doc-check: skip — illustrative
let direction = IVec2.step(TopDown.of(player).aim_angle)
```

View file

@ -0,0 +1,19 @@
---
id: ivec2-within
name: IVec2.within
category: ivec2
kind: namespace-method
tokens: IVec2.within
sig: IVec2.within(a, b, radius) -> bool
tip: Are two points within a radius of each other?
order: 21
ns: IVec2
member: within
---
True when the distance between <code>a</code> and <code>b</code> is at most <code>radius</code> (compared squared, no square root).
```ludic
# doc-check: skip — illustrative
if IVec2.within(hero, heart, 14) { pick_up() }
```

View file

@ -0,0 +1,19 @@
---
id: list-sample
name: List.sample
category: list
kind: namespace-method
tokens: List.sample
sig: List.sample(pool, count) -> []int
tip: Random picks from an int slice, distinct while it can.
order: 30
ns: List
member: sample
---
Returns <code>count</code> picks from an <code>[]int</code>, drawn from the seeded RNG. Picks are distinct while the pool has enough elements; with fewer, the spare picks repeat a valid element. An empty pool yields zeros. A relic offer, a loot roll, a random enemy roster.
```ludic
# doc-check: skip — illustrative
relic_offer = List.sample(Relics.unowned(), 3)
```

View file

@ -0,0 +1,20 @@
---
id: map-border
name: Map.border
category: map
kind: namespace-method
tokens: Map.border
sig: Map.border(glyph: g)
tip: The outermost ring of cells.
order: 14
ns: Map
member: border
---
Writes <code>glyph</code> along the map's edge — the arena wall around a generated room.
```ludic
# doc-check: skip — illustrative
Map.fill(glyph: '.')
Map.border(glyph: '#')
```

View file

@ -0,0 +1,20 @@
---
id: map-fill
name: Map.fill
category: map
kind: namespace-method
tokens: Map.fill
sig: Map.fill(glyph: g)
tip: Every cell becomes the glyph.
order: 12
ns: Map
member: fill
---
Fills the whole map with one glyph — the first stroke of a generated room.
```ludic
# doc-check: skip — illustrative
Map.size(20, 15)
Map.fill(glyph: '.')
```

View file

@ -0,0 +1,19 @@
---
id: map-get
name: Map.get
category: map
kind: namespace-method
tokens: Map.get
sig: Map.get(x: tx, y: ty) -> int
tip: The glyph at a cell ('#' outside the map).
order: 10
ns: Map
member: get
---
Reads one cell of the tilemap by tile coordinates; outside the map it answers <code>'#'</code>, so the edge of the world reads as wall.
```ludic
# doc-check: skip — illustrative
if Map.get(x: 3, y: 4) == '.' { … }
```

View file

@ -0,0 +1,19 @@
---
id: map-height
name: Map.height
category: map
kind: namespace-method
tokens: Map.height
sig: Map.height() -> int
tip: The map's height in cells.
order: 19
ns: Map
member: height
---
The height set by <code>Map.size</code>.
```ludic
# doc-check: skip — illustrative
for y in 0 .. Map.height() { … }
```

View file

@ -0,0 +1,19 @@
---
id: map-is_solid
name: Map.is_solid
category: map
kind: namespace-method
tokens: Map.is_solid
sig: Map.is_solid(x: tx, y: ty) -> bool
tip: Is a cell solid, per the Solids config?
order: 16
ns: Map
member: is_solid
---
True when the cell holds one of the solid glyphs the <code>Solids</code> configuration names (<code>wall</code>, <code>solid2</code>), or lies outside the map. With no <code>Solids</code> entity nothing is solid. The move system and the projectiles use the same answer.
```ludic
# doc-check: skip — illustrative
if Map.is_solid(x: tile.x, y: tile.y) { continue }
```

View file

@ -0,0 +1,19 @@
---
id: map-is_solid_at
name: Map.is_solid_at
category: map
kind: namespace-method
tokens: Map.is_solid_at
sig: Map.is_solid_at(x: px, y: py) -> bool
tip: Is the cell under a pixel position solid?
order: 17
ns: Map
member: is_solid_at
---
<code>Map.is_solid</code> for a pixel position, using the tile size from the <code>Solids</code> config.
```ludic
# doc-check: skip — illustrative
if not Map.is_solid_at(x: pushed.x, y: pushed.y) { Position.x = pushed.x }
```

View file

@ -0,0 +1,20 @@
---
id: map-random_cell
name: Map.random_cell
category: map
kind: namespace-method
tokens: Map.random_cell
sig: Map.random_cell(glyph: g) -> IVec2
tip: A random cell holding the glyph (seeded RNG).
order: 15
ns: Map
member: random_cell
---
Picks a random cell whose glyph matches: random tries first, then a sweep, so a map with any such cell always yields one; <code>(-1, -1)</code> when none exists. Deterministic, from the seeded RNG.
```ludic
# doc-check: skip — illustrative
let tile = Map.random_cell(glyph: '.')
Spawn.enemy(kind, IVec2.scale(tile, 16))
```

View file

@ -0,0 +1,19 @@
---
id: map-random_cell_far
name: Map.random_cell_far
category: map
kind: namespace-method
tokens: Map.random_cell_far
sig: Map.random_cell_far(glyph:, from:, min_tiles:) -> IVec2
tip: A random cell with the glyph, at least a distance from a point.
order: 20
ns: Map
member: random_cell_far
---
Like <code>Map.random_cell</code>, but at least <code>min_tiles</code> from <code>from</code> when it can find one — a spawn point away from the hero.
```ludic
# doc-check: skip — illustrative
let tile = Map.random_cell_far(glyph: '.', from: hero_tile, min_tiles: 6)
```

View file

@ -0,0 +1,19 @@
---
id: map-rect
name: Map.rect
category: map
kind: namespace-method
tokens: Map.rect
sig: Map.rect(x: tx, y: ty, width: w, height: h, glyph: g)
tip: Fill a rectangle of cells.
order: 13
ns: Map
member: rect
---
Writes <code>glyph</code> into every cell of the rectangle; cells outside the map are skipped. A wall slab, a carved corridor, a cleared lane.
```ludic
# doc-check: skip — illustrative
Map.rect(x: 9, y: 1, width: 2, height: 13, glyph: '.') # a lane the cover never blocks
```

View file

@ -0,0 +1,19 @@
---
id: map-set
name: Map.set
category: map
kind: namespace-method
tokens: Map.set
sig: Map.set(x: tx, y: ty, glyph: g)
tip: Write one cell in place.
order: 11
ns: Map
member: set
---
Writes one cell of the tilemap. A game edits the engine's grid directly — carving a door, placing a pillar — instead of keeping its own copy and re-stamping rows.
```ludic
# doc-check: skip — illustrative
Map.set(x: 9, y: 0, glyph: '.') # open the north door
```

View file

@ -0,0 +1,19 @@
---
id: map-to_tile
name: Map.to_tile
category: map
kind: namespace-method
tokens: Map.to_tile
sig: Map.to_tile(pixel: IVec2) -> IVec2
tip: The tile under a pixel position.
order: 21
ns: Map
member: to_tile
---
Divides a pixel position by the tile size of the <code>Solids</code> config.
```ludic
# doc-check: skip — illustrative
let tile = Map.to_tile(pixel: Collider.center(player))
```

View file

@ -0,0 +1,19 @@
---
id: map-width
name: Map.width
category: map
kind: namespace-method
tokens: Map.width
sig: Map.width() -> int
tip: The map's width in cells.
order: 18
ns: Map
member: width
---
The width set by <code>Map.size</code>.
```ludic
# doc-check: skip — illustrative
for x in 0 .. Map.width() { … }
```

View file

@ -0,0 +1,7 @@
---
id: prefab
title: Prefab
order: 47
---
A <code>prefab</code> is a model with preset component fields — <code>prefab Grunt: Creature { Stats { hp: 30 }, Weapon { def_id: 1 } }</code> — spawned with <code>spawn Grunt { Position { x: 40 } }</code>, where the spawn's own fields override the presets. Prefabs chain (<code>prefab Grunt: Foe</code>, where <code>Foe</code> is itself a prefab) so shared presets live once. <code>spawn</code> is also an expression yielding the new entity (<code>let e = spawn Grunt { … }</code>), and <a href="prefab-spawn"><code>Prefab.spawn</code></a> spawns a prefab chosen at runtime by name.

View file

@ -0,0 +1,20 @@
---
id: prefab-spawn
name: Prefab.spawn
category: prefab
kind: namespace-method
tokens: Prefab.spawn
sig: Prefab.spawn(name: s) -> entity
tip: Spawn a prefab chosen by name at runtime; -1 if none has that name.
order: 1
ns: Prefab
member: spawn
---
Spawns the prefab whose name matches the string, with all its presets, and returns the new entity — or -1 when no prefab has that name. Set what varies per spawn afterwards through <code>Prop.of(entity)</code>.
```ludic
# doc-check: skip — illustrative
let e = Prefab.spawn(name: roster.roll())
Position.of(e).x = tile.x * 16
```

View file

@ -0,0 +1,19 @@
---
id: prefab-spawn_at
name: Prefab.spawn_at
category: prefab
kind: namespace-method
tokens: Prefab.spawn_at
sig: Prefab.spawn_at(name: s, at: IVec2) -> entity
tip: Spawn a prefab by name and place it.
order: 2
ns: Prefab
member: spawn_at
---
`Prefab.spawn` followed by writing the new entity's `Position` — the common case in one call.
```ludic
# doc-check: skip — illustrative
let foe = Prefab.spawn_at(name: "Grunt", at: IVec2.make(60, 60))
```

View file

@ -0,0 +1,19 @@
---
id: random-weighted
name: Random.weighted
category: random
kind: namespace-method
tokens: Random.weighted
sig: Random.weighted(weights: []int) -> int
tip: An index drawn in proportion to its weight.
order: 10
ns: Random
member: weighted
---
Picks an index into `weights` with probability proportional to the weight (a weight of 0 is never picked); -1 when every weight is 0. Deterministic, from the seeded RNG. A loot table, a spawn roster, a random event.
```ludic
# doc-check: skip — illustrative
let pick = roster[Random.weighted(weights: weights)]
```

View file

@ -0,0 +1,19 @@
---
id: screen-bar
name: Screen.bar
category: screen
kind: namespace-method
tokens: Screen.bar
sig: Screen.bar(x:, y:, width:, height:, value:, max:, color:, back:)
tip: A filled meter: value of max over a track.
order: 40
ns: Screen
member: bar
---
Draws a <code>back</code>-coloured track and fills <code>value / max</code> of its width in <code>color</code>. Health bars, cooldowns, loading progress.
```ludic
# doc-check: skip — illustrative
Screen.bar(x: 80, y: 228, width: 160, height: 6, value: hp, max: max_hp, color: Color.Crimson, back: Color.RaisinBlack)
```

View file

@ -0,0 +1,19 @@
---
id: sprite-draw_meter
name: Sprite.draw_meter
category: sprite
kind: namespace-method
tokens: Sprite.draw_meter
sig: Sprite.draw_meter(x:, y:, value:, max:, per_icon:, spacing:, full:, half:, empty:)
tip: A value as a row of full / half / empty icons.
order: 10
ns: Sprite
member: draw_meter
---
Draws <code>max / per_icon</code> icons, each full, half or empty according to <code>value</code> — hearts, stars, ammo pips.
```ludic
# doc-check: skip — illustrative
Sprite.draw_meter(x: 8, y: 8, value: hp, max: max_hp, per_icon: 20, spacing: 15, full: heart, half: half_heart, empty: empty_heart)
```

View file

@ -0,0 +1,20 @@
---
id: sprite-strip
name: Sprite.strip
category: sprite
kind: namespace-method
tokens: Sprite.strip
sig: Sprite.strip(sheet, col, row, count, rows) -> int
tip: A run of animation frames as consecutive sprite ids.
order: 9
ns: Sprite
member: strip
---
Registers <code>count</code> frames starting at cell (<code>col</code>, <code>row</code>), each <code>rows</code> cells tall, as consecutive ids and returns the first. With a <code>SpriteAnim</code> on the entity the engine adds the current frame to <code>Sprite.id</code>, so the strip's first id is all a spawn needs.
```ludic
# doc-check: skip — illustrative
let hero_run = Sprite.strip(sheet: sheet, col: 8, row: 2, count: 8, rows: 2)
spawn Hero { Sprite { id: hero_run, atlas: 1 }, SpriteAnim { fps: 8, frames: 4 } }
```

View file

@ -0,0 +1,21 @@
---
id: kw-prefab
name: prefab
category: structure
kind: keyword
tokens: prefab
sig: prefab Name: Model { Comp { field: value }, … }
tip: A model with preset component fields — spawn it, override what varies.
order: 9
---
A <code>prefab</code> names a model together with preset field values, so what every spawn of a kind shares is written once: <code>prefab Grunt: Creature { Stats { hp: 30 }, Weapon { def_id: 1 } }</code>. <code>spawn Grunt { Position { x: 40 } }</code> spawns the model with the prefab's presets and then the spawn's own fields on top. Prefabs chain — <code>prefab Grunt: Foe</code> where <code>Foe</code> is itself a prefab — so shared presets live in one place. Field values are ordinary expressions evaluated at each spawn (a preset may read a global such as a loaded sprite id), <code>@OnSpawn(Model)</code> runs as for any spawn of the model, <code>spawn</code> is also an expression yielding the new entity, and <code>Prefab.spawn(name:)</code> picks a prefab by name at runtime.
```ludic
# doc-check: skip — composite: prefabs plus their spawns
prefab Foe: Creature { Faction { id: 2 }, Body { policy: BodyPolicy.TopDown } }
prefab Grunt: Foe { Stats { hp: 30, max_hp: 30 }, Weapon { def_id: WeaponId.Bite } }
let grunt = spawn Grunt { Position { x: 40, y: 60 } }
let other = Prefab.spawn(name: "Grunt")
```

View file

@ -0,0 +1,20 @@
---
id: type-countdown
name: countdown
category: types
kind: type
tokens: countdown
sig: countdown
tip: An int component field the engine steps toward 0 once per Update.
order: 9
---
`countdown` is an `int` for a component field that the engine counts down: once per Update, for every live entity carrying the component, a `countdown` field above 0 loses one (it never goes below 0). It is the timer idiom — roll frames, invulnerability frames, a hit flash, a cooldown — as a type: set the field, then test it, and no handler decrements it. Everywhere else it is an ordinary `int`.
```ludic
# doc-check: skip — illustrative
property Roll { frames_left: countdown = 0, cooldown: countdown = 0 }
Roll.of(player).cooldown = 35 # 35 frames later it reads 0
if Roll.of(player).cooldown == 0 { start_roll() }
```

View file

@ -0,0 +1,7 @@
---
id: ui
title: Ui
order: 43
---
The retained-mode menu API over a <code>ui</code> block. <a href="ui-build"><code>Ui.build</code></a> constructs every declared widget tree once (after fonts and skins are loaded); <a href="ui-open"><code>Ui.open</code></a> makes one menu active and <a href="ui-close"><code>Ui.close</code></a> deactivates it; the frame loop ticks navigation on its own, an activation fires the <code>UiClicked</code> event, and <a href="ui-clicked"><code>Ui.clicked</code></a> is the polled form; <a href="ui-set_text"><code>Ui.set_text</code></a> updates a label or button, and <a href="ui-render"><code>Ui.render</code></a> draws the active menu (call it from an <code>Overlay</code> handler so it paints over the world). Every <code>id: Name</code> in a <code>ui</code> block mints a <code>UI_Name</code> handle.

View file

@ -0,0 +1,21 @@
---
id: ui-build
name: Ui.build
category: ui
kind: namespace-method
tokens: Ui.build
sig: Ui.build()
tip: Construct every declared ui block (loads skins, measures fonts).
order: 1
ns: Ui
member: build
---
Builds the widget trees of every <code>ui</code> block. Call it once, after the fonts and images the blocks reference are loaded — a loading scene's last step is the usual place.
```ludic
# doc-check: skip — illustrative
handler Boot phase Start {
Ui.build()
}
```

View file

@ -0,0 +1,21 @@
---
id: ui-clicked
name: Ui.clicked
category: ui
kind: namespace-method
tokens: Ui.clicked
sig: Ui.clicked(id: UI_Name) -> bool
tip: Was this control activated this frame? (polled form of UiClicked)
order: 5
ns: Ui
member: clicked
---
True on the frame a focused control with that id was activated (Enter, Space, or pad A). The event form is <code>@On(UiClicked) handler … { if id == UI_Name { … } }</code>; either can <code>become</code> another scene.
```ludic
# doc-check: skip — illustrative
handler Menu phase Update {
if Ui.clicked(id: UI_Play) { become Play }
}
```

View file

@ -0,0 +1,21 @@
---
id: ui-close
name: Ui.close
category: ui
kind: namespace-method
tokens: Ui.close
sig: Ui.close()
tip: Deactivate the menu: no menu is open.
order: 3
ns: Ui
member: close
---
Closes whatever menu is active, so nothing is drawn by <code>Ui.render</code> and no click can fire. A play scene opens with it so a menu left over from the title never lingers.
```ludic
# doc-check: skip — illustrative
scene Play {
on enter { Ui.close() }
}
```

View file

@ -0,0 +1,21 @@
---
id: ui-open
name: Ui.open
category: ui
kind: namespace-method
tokens: Ui.open
sig: Ui.open(id: UI_Name)
tip: Make one menu active and focus its first button.
order: 2
ns: Ui
member: open
---
Activates the menu whose root has that id and moves keyboard focus to its first focusable control. Only one menu is active at a time; opening another replaces it.
```ludic
# doc-check: skip — illustrative
scene Title {
on enter { Ui.open(id: UI_TitleMenu) }
}
```

View file

@ -0,0 +1,21 @@
---
id: ui-render
name: Ui.render
category: ui
kind: namespace-method
tokens: Ui.render
sig: Ui.render()
tip: Draw the active menu.
order: 7
ns: Ui
member: render
---
Draws the active menu at its laid-out position. Call it from a handler in the <code>Overlay</code> phase so the menu is painted over the engine-drawn world; it draws nothing while no menu is open.
```ludic
# doc-check: skip — illustrative
layer Menu {
handler Draw phase Overlay { Ui.render() }
}
```

View file

@ -0,0 +1,19 @@
---
id: ui-set_text
name: Ui.set_text
category: ui
kind: namespace-method
tokens: Ui.set_text
sig: Ui.set_text(id: UI_Name, text: s)
tip: Replace a label's or button's text.
order: 6
ns: Ui
member: set_text
---
Sets the text of the label or button with that id; the menu re-lays itself out on its next tick. Interpolated strings make run stats one line.
```ludic
# doc-check: skip — illustrative
Ui.set_text(id: UI_Stats, text: `floor {run.floor} kills {run.kills}`)
```

View file

@ -0,0 +1,21 @@
---
id: ui-tick
name: Ui.tick
category: ui
kind: namespace-method
tokens: Ui.tick
sig: Ui.tick(key: k)
tip: Advance navigation by one key (the frame loop does this for you).
order: 4
ns: Ui
member: tick
---
Moves focus and activates controls from one key code. The frame loop calls it every frame with the frame key, so a game normally never calls it; it remains for programs that drive their own loop from <code>entry</code>.
```ludic
# doc-check: skip — illustrative
entry {
Ui.tick(key: Input.key())
}
```