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,46 @@
# combat_kit.ludic — the shooter kit over ludic.gameplay: Dash (a dodge with i-frames),
# Melee (an arc swing with knockback and MeleeHit), build stats on Stats applied by
# Combat.damage (damage_pct, crit_pct, thorns; a Crit event), Stats.add, and the
# Dungeon package (arena, exits, entry points). Prints:
# 6846 153196 31 107110 6000 5002 1 80 97 11 10 700 110
program CombatKit {
import "ludic.core/components.ludic"
import "ludic.shooter/shooter.ludic"
import "ludic.dungeon/dungeon.ludic"
import "ludic.prefs/prefs.ludic"
property Foe { n: int = 0 }
model Hero { Position, Body, Collider, TopDown, Weapon, Faction, Stats, Dash, Melee }
model Creature { Position, Body, Collider, Faction, Stats, Foe }
model WorldRules { Solids }
var frames: int = 0
var hero: int = -1
@On(MeleeHit) handler H { print(5000 + target) }
@On(Crit) handler C { print(6000) }
handler Tick phase Update {
frames += 1
if frames == 1 {
Map.size(20, 15)
Dungeon.arena(Dungeon.random_style(), '#', '.')
Dungeon.set_exit(Side.North, 'D')
spawn WorldRules { Solids { tile: 16, wall: '#', solid2: 'D' } }
print(Map.get(x: 9, y: 0) * 100 + Map.get(x: 9, y: 7)) # 6846: the sealed door, the open lane
let e = Dungeon.entry_point(Side.South, 24, 14, 20)
print(e.x * 1000 + e.y) # 153196
print(Dungeon.opposite(Side.West) * 10 + Dungeon.at_edge(IVec2.make(0, 3))) # 31
Weapon.def(name: "p", fire_rate: 10, damage: 10, speed: 8, spread: 0, pellets: 1, pattern: 0)
hero = spawn Hero { Position { x: 100, y: 100 }, Collider { w: 14, h: 20 }, Body { policy: 1 }, TopDown { move_speed: 4, aim_mode: AimMode.Manual, aim_angle: 0 }, Faction { id: 1 },
Stats { hp: 100, max_hp: 100 }, Dash { frames: 3, speed: 9, cooldown_frames: 5 }, Melee { range: 30, damage: 7, knockback: 4 } }
Stats.add(hero, StatKind.CritPct, 100)
Stats.add(hero, StatKind.DamagePct, 50)
let c = Collider.center(hero); print(c.x * 1000 + c.y) # 107110
spawn Creature { Position { x: 120, y: 100 }, Collider { w: 14, h: 20 }, Body { policy: 1 }, Faction { id: 2 }, Stats { hp: 100, max_hp: 100, thorns: 3 }, Foe { n: 1 } }
Faction.set(1, 2, 2)
print(Melee.swing(hero)) # 1 hit (after 6000 and 5002)
for (s, f) in query [Stats, Foe] { print(s.hp) } # 80: 7 * 150% doubled by the crit
print(Stats.of(hero).hp) # 97: thorns
print(Dash.start(hero, 0, 0) * 10 + Dash.active(hero)) # 11
print(Prefs.max("best", 5) * 10 + Prefs.max("best", 3)) # 10
}
if frames == 7 { print(700 + Dash.active(hero)); print(Position.of(hero).x); quit() } # 700 110
}
}

View file

@ -0,0 +1,57 @@
# component_access.ludic — typed component access from an entity handle:
# `Prop.of(e)` reads and writes fields, `Prop.has(e)` is safe for -1 and dead
# handles, `Prop.count()` counts, `Prop.despawn_all()` clears; `countdown` fields
# tick toward 0 each Update; `Enum.Variant` typed vars and machines. Prints:
# 9 15 10 33 1 0 0 1 0 1 101 100 102 1 2 1 0
program ComponentAccess {
import "ludic.core/components.ludic"
property Hero { roll_frames: int = 0, iframes: int = 0, flash: countdown = 0 }
property Tag { n: int = 0 }
model Player { Position, Hero }
model Thing { Position, Tag }
enum HeroState { Idle, Rolling, Swinging }
var hero_state: HeroState = HeroState.Rolling
var player: int = -1
var frames: int = 0
function step() -> void {
machine hero_state {
state Idle { print(100); become Swinging }
state Rolling { print(101); become Idle }
state Swinging { print(102); become Rolling }
}
}
handler Tick phase Update {
frames += 1
if frames == 1 {
player = spawn Player { Position { x: 3, y: 4 }, Hero { roll_frames: 9, flash: 3 } }
spawn Thing { Position { x: 30, y: 40 }, Tag { n: 7 } }
print(Hero.of(player).roll_frames) # 9
Hero.of(player).iframes = 20
Hero.of(player).iframes -= 5
print(Hero.of(player).iframes) # 15
let hero = Hero.of(player)
hero.roll_frames = hero.roll_frames + 1
print(Hero.of(player).roll_frames) # 10
print(Position.of(player).x + Position.of(1).x) # 33
print(Hero.has(player)) # 1
print(Hero.has(1)) # 0
print(Hero.has(-1)) # 0
print(Tag.has(1)) # 1
despawn 1
print(Tag.has(1)) # 0
print(Position.count()) # 1: the player
spawn Thing { Position { x: 1 } }
}
if frames == 2 {
step(); step(); step() # 101 100 102
print(hero_state) # 1
print(Position.count()) # 2: the player and the thing spawned after
}
if frames == 3 {
print(Hero.of(player).flash) # ticked twice: 1
Position.despawn_all()
print(Position.count()) # 0
quit()
}
}
}

View file

@ -0,0 +1,42 @@
# managers.ludic — the engine managers: the Map cell API, Sprite facing / run strip /
# flash / blink, IVec2 geometry, List.sample, Input.move_i, Fx, the audio bank,
# Camera.shake_for, timed Assets. Deterministic; prints:
# 35046 46 251 53 900 -1 20 324 0 10 102
program Managers {
import "ludic.core/components.ludic"
import "ludic.shooter/shooter.ludic"
property Tag { n: int = 0 }
model Thing { Position, Body, Sprite, Tag, TopDown }
model WorldRules { Solids }
var frames: int = 0
handler Tick phase Update {
frames += 1
if frames == 1 {
Map.size(10, 8)
Map.fill(glyph: '.')
Map.border(glyph: '#')
Map.rect(x: 4, y: 3, width: 2, height: 2, glyph: '#')
spawn WorldRules { Solids { tile: 16, wall: '#' } }
print(Map.get(x: 0, y: 0) * 1000 + Map.get(x: 5, y: 5)) # 35046: '#', '.'
let cell = Map.random_cell(glyph: '.')
print(Map.get(x: cell.x, y: cell.y)) # 46
let a = IVec2.make(0, 0); let b = IVec2.make(3, 4)
print(IVec2.distance2(a, b) * 10 + IVec2.within(a, b, 5)) # 251
print(IVec2.heading(a, b)) # 53
let p = IVec2.along(a, 0, 10); print(p.x * 100 + p.y) # 900 (the sine table rounds down)
print(IVec2.step(180).x) # -1
print(Angle.diff_degrees(a: 350, b: 10)) # 20
let pool = new []int; push(pool, 7); push(pool, 8); push(pool, 9)
let picks = List.sample(pool, 3); print(len(picks) * 100 + picks[0] + picks[1] + picks[2]) # 324
let m = Input.move_i(); print(m.x + m.y) # 0
Audio.define(name: "hit", path: "nope.wav")
Audio.play(name: "hit")
Camera.shake_for(amount: 3, frames: 2)
Fx.sparks(x: 10, y: 10, color: 0xffffff, count: 4)
Fx.number(x: 20, y: 20, value: 42, color: 0xffff00)
spawn Thing { Position { x: 20, y: 20 }, Sprite { id: 5, move_id: 9, face: 1, flash: 3, blink: 6 }, TopDown { want_x: -1 } }
}
if frames == 3 { print(Map.is_solid(x: 0, y: 0) * 10 + Map.is_solid_at(x: 40, y: 40)) } # 10
if frames == 5 { for (s, t) in query [Sprite, Tag] { print(s.flip * 100 + s.flash * 10 + s.blink) }; quit() } # 102
}
}

View file

@ -0,0 +1,25 @@
# prefabs.ludic — prefabs: a model with preset fields, chained (`Grunt: Foe: Creature`),
# spawned by name with overrides, `spawn` as an expression, `Prefab.spawn` / `spawn_at`
# by a runtime name, and `self()` inside @OnSpawn. Deterministic; prints:
# 230159 30 0 7 44 -1 2
program Prefabs {
import "ludic.core/components.ludic"
property Enemy { kind: int = 0, hp: int = 10, team: int = 0 }
model Creature { Position, Enemy }
prefab Foe: Creature { Enemy { team: 2, hp: 1 }, Position { y: 9 } }
prefab Grunt: Foe { Enemy { kind: 1, hp: 30 } }
var last: int = -1
@OnSpawn(Creature) handler Remember { last = self() }
entry {
let g = spawn Grunt { Position { x: 5 } } # Foe's presets, Grunt's, then this
print(Enemy.of(g).team * 100000 + Enemy.of(g).hp * 1000 + Enemy.of(g).kind * 100 + Position.of(g).x * 10 + Position.of(g).y)
let n = Prefab.spawn(name: "Grunt")
print(Enemy.of(n).hp)
print(g) # the first entity
let placed = Prefab.spawn_at(name: "Foe", at: IVec2.make(7, 44))
print(Position.of(placed).x)
print(Position.of(placed).y)
print(Prefab.spawn(name: "Nope")) # no such prefab
print(last) # self() in the hook: the last spawn
}
}

View file

@ -0,0 +1,29 @@
# scene_menus.ludic — scenes: `become` from an @On listener, @Queries handlers inside
# a layer, `scene X shows Menu` (opened / closed by the engine), `lasts N then Y`,
# `goto:` buttons, and scene-qualified handler names. Keys drive frames. Prints:
# 100 200 201 1 202 300 6 7 8
program SceneMenus {
import "ludic.core/components.ludic"
property Pos { x: int = 0 }
model Thing { Pos }
var menu_font: int = 0
var frames: int = 0
ui Menu {
panel id: Root w: 100 pad: 4 bg: Color.Gunmetal border: Color.SlateGray align: center {
button id: Go text: "Go" font: menu_font size: 16 w: 80 goto: Play
}
}
scene Splash start lasts 1 then Title { on enter { print(100) } }
scene Title shows Menu {
on enter { print(200) }
on exit { print(202) }
layer L { handler Tick phase Update { frames += 1; if frames == 1 { print(201); emit UiClicked(id: UI_Go) } } }
}
@On(UiClicked) handler Report { print(id) } # UI_Go is widget 1 (the root panel is 0)
scene Play {
on enter { print(300); spawn Thing { Pos { x: 5 } } }
layer Sim {
@Queries(these: [Pos]) handler Tick phase Update { Pos.x += 1; print(Pos.x); if Pos.x >= 8 { quit() } }
}
}
}

View file

@ -0,0 +1,51 @@
# sprite_atlas.ludic — the engine sprite-render system draws atlas ids (#90).
# `Sprite { id, atlas: 1 }` marks `id` as a Sprite.cell / Sprite.cell_span sprite
# (any size, here a 16x32 two-cell character) and esys_sprite routes it through
# atlas_draw_ex with scale / flip / tint, so the #81 atlas API and the #85 engine
# sprite system compose. atlas: 0 (the default) keeps `id` a 16x16 table sprite.
# Verified by pixel readback after one frame.
#
# Driven headless by one stdin key ('q' quits after the frame renders).
# printf 'q' | LUDIC_MODULES=packages bin/ludic examples/library/sprite_atlas.ludic -> 32 1 1 1 0
program SpriteAtlas {
import "ludic.core/components.ludic"
var TALL: int = 0
function bi(b: bool) -> int { if b { return 1 }; return 0 }
@OnStart handler Boot {
let sheet = Sprite.sheet("assets/kenney/tiny-dungeon/Tilemap/tilemap_packed.png", 16, 16)
TALL = Sprite.cell_span(sheet, 0, 0, 1, 2) # a 16x32 sprite: two cells tall
spawn Tall { Position { x: 40, y: 40 }, Sprite { id: TALL, atlas: 1 } }
spawn Tinted { Position { x: 100, y: 40 }, Sprite { id: TALL, atlas: 1, tint: 0xff0000 } }
spawn Table { Position { x: 160, y: 40 }, Sprite { id: TALL } } # atlas: 0 -> the (empty) 16x16 table
}
# An empty Render handler: the engine sprite system draws the Sprite entities.
handler Draw phase Render { }
@OnQuit handler Report {
print(Sprite.height(TALL)) # 32 — the span is two cells tall
var top = 0
for yy in 40 .. 56 { for xx in 40 .. 56 { if Screen.pixel(xx, yy) != 0 { top = 1 } } }
print(top) # 1 — the upper cell drew
var bottom = 0
for yy in 56 .. 72 { for xx in 40 .. 56 { if Screen.pixel(xx, yy) != 0 { bottom = 1 } } }
print(bottom) # 1 — the lower cell drew too (a table sprite stops at 16px)
var tinted = 1
var seen = 0
for yy in 40 .. 72 { for xx in 100 .. 116 {
let c = Screen.pixel(xx, yy)
if c != 0 { seen = 1; if c != 0xff0000 { tinted = 0 } }
} }
print(bi((seen == 1) and (tinted == 1))) # 1 — every opaque pixel took the tint
var table = 0
for yy in 40 .. 72 { for xx in 160 .. 176 { if Screen.pixel(xx, yy) != 0 { table = 1 } } }
print(table) # 0 — without atlas: 1 the id is a table id (nothing loaded there)
}
}