Repository-cleanup / DX pass folding three tracker items into one coherent change, verified green end to end (`bin/x test` 49/0, `bin/x selfhost-test` 29/0, `bin/x test-tools` 29/0). #28 — curate & categorise examples/ - 42 flat entries regrouped into intent-revealing subdirs: games/, rendering/, ecs/, events/, networking/, lang/, library/ (was lib/). - chronorift dir-vs-file duplication resolved: the entry file and its import modules now live together under games/chronorift(.ludic). - Every path reference updated repo-wide (test runner, editor-tool drivers, docs/site, design docs). - New examples/README.md indexes the whole set with run commands. - Showcase examples without a self-asserting entry (hello, events, net_rt) now get a compile-only rot guard in `bin/x test`, so nothing here rots silently. #30 — text-diffable golden baseline - The 4 binary selfhost/golden/*.ppm blobs are replaced by a single selfhost/golden/renders.sha256 manifest (SHA-256 per render). Hashes are byte-identical to the old PPMs, so the baseline is unchanged — only its form. - game_case now compares framebuffer hashes; a regression shows as a changed hex line in review, not "binary files differ". - New `bin/x golden` regenerates the manifest deliberately (review with `git diff selfhost/golden/renders.sha256`). #27 — PPM & asset handling - Headless renders now write build/out.ppm, never the repo root; `x app`, `x clean`, messaging and .gitignore updated to match. Nothing is written to the working root any more. - Redundant local Kenney .zip archives removed (the art ships extracted; .gitignore already excludes *.zip). CC0 License.txt files retained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0bc5d76952
commit
fb728bbefe
73 changed files with 364 additions and 194 deletions
37
examples/lang/annotations.ludic
Normal file
37
examples/lang/annotations.ludic
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# annotations.ludic — the annotation-first style. Behaviour is expressed with
|
||||
# @-decorators that desugar to plain handlers, queries and expressions, so the
|
||||
# properties stay pure data and the data-oriented model is untouched.
|
||||
|
||||
@Handles(Move)
|
||||
program RPG2D {
|
||||
property Transform { x: int = 0, y: int = 0, scale: int = 1 }
|
||||
property Velocity {
|
||||
dx: int = 0
|
||||
dy: int = 0
|
||||
@Computed speed2: int = dx * dx + dy * dy # derived; no storage, inlined at use
|
||||
}
|
||||
model Actor { Transform, Velocity }
|
||||
|
||||
# A constructor: every Actor starts visible. Runs at spawn with the model's
|
||||
# properties bound by name — no per-frame cost, no observer machinery.
|
||||
@OnSpawn(Actor)
|
||||
handler InitActor { Transform.scale = 2 }
|
||||
|
||||
handler Spawn phase Start {
|
||||
spawn Actor { Transform { x: 0 } Velocity { dx: 3, dy: 4 } }
|
||||
spawn Actor { Transform { x: 0 } Velocity { dx: 0, dy: 0 } }
|
||||
}
|
||||
|
||||
# Move every Actor whose scale is positive and that is actually moving. The
|
||||
# query is the decorator; each bound property is addressed by its own name.
|
||||
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
|
||||
handler Move phase Update {
|
||||
Transform.x = Transform.x + Velocity.dx
|
||||
Transform.y = Transform.y + Velocity.dy
|
||||
}
|
||||
|
||||
handler Report phase Render {
|
||||
for (t, v) in query [Transform, Velocity] { print(t.x); print(v.speed2) }
|
||||
quit()
|
||||
}
|
||||
}
|
||||
38
examples/lang/detach.ludic
Normal file
38
examples/lang/detach.ludic
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# detach.ludic — the structural attach/detach pair and its @OnAttach / @OnDetach
|
||||
# hooks. `attach P on e` adds a property to a LIVE entity (seeding its fields and
|
||||
# firing @OnAttach); `detach P on e` removes it (firing @OnDetach, which still
|
||||
# reads the outgoing value before the has-flag clears). This is the structural
|
||||
# counterpart to enable/disable — attach/detach create and destroy the property's
|
||||
# presence, whereas disable/enable only pause it while keeping the data.
|
||||
#
|
||||
# Running it prints: 15 1 25 0
|
||||
# 15 @OnAttach(Shield): amount seeded to 5, prints 5 + 10
|
||||
# 1 one live Shield now matches the query
|
||||
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
|
||||
# 0 the Shield is gone — nothing matches
|
||||
#
|
||||
# bin/x game-build bin/ludicc examples/lang/detach.ludic /tmp/detach
|
||||
# /tmp/detach </dev/null
|
||||
program Detach {
|
||||
property Tag { v: int = 0 }
|
||||
property Shield { amount: int = 0 }
|
||||
model Unit { Tag }
|
||||
|
||||
@OnAttach(Shield) handler Up { print(Shield.amount + 10) } # structural: property born
|
||||
@OnDetach(Shield) handler Down { print(Shield.amount + 20) } # structural: property dies
|
||||
|
||||
handler Seed phase Start { spawn Unit { Tag { v: 1 } } }
|
||||
|
||||
handler Run phase Render {
|
||||
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # @OnAttach -> 15
|
||||
var n = 0
|
||||
for (s) in query [Shield] { n += 1 }
|
||||
print(n) # 1
|
||||
|
||||
for (s) in query [Shield] { detach Shield on self() } # @OnDetach -> 25
|
||||
n = 0
|
||||
for (s) in query [Shield] { n += 1 }
|
||||
print(n) # 0
|
||||
quit()
|
||||
}
|
||||
}
|
||||
28
examples/lang/lifecycle.ludic
Normal file
28
examples/lang/lifecycle.ludic
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# lifecycle.ludic — a game's whole lifecycle as @-hooks. Each fires at one point
|
||||
# on the timeline and reduces to ordinary code, so nothing is hidden and the
|
||||
# data-oriented model is untouched. Running it prints: 1 700 50 950 2 — one line
|
||||
# per hook, in the order the timeline reaches them.
|
||||
#
|
||||
# boot ── @OnStart ──▶ spawn ── @OnAttach, @OnSpawn ──▶ … ── @OnDespawn ──▶ quit ── @OnQuit
|
||||
program Life {
|
||||
property Health { hp: int = 0, max: int = 100 }
|
||||
property Sprite { id: int = 0 }
|
||||
model Enemy { Health, Sprite }
|
||||
|
||||
# ---- program lifecycle -----------------------------------------------------
|
||||
@OnStart handler Boot { print(1) } # once, at boot
|
||||
@OnQuit handler Bye { print(2) } # once, at shutdown
|
||||
|
||||
# ---- property / entity lifecycle -------------------------------------------
|
||||
@OnAttach(Sprite) handler Load { print(Sprite.id + 700) } # when a Sprite is attached
|
||||
@OnSpawn(Enemy) handler Init { Health.hp = Health.max } # constructor
|
||||
@OnDespawn(Enemy) handler Clean { print(Health.hp + 900) } # destructor (dispatched by kind)
|
||||
|
||||
handler Seed phase Start { spawn Enemy { Health { max: 50 } } }
|
||||
|
||||
handler Run phase Render {
|
||||
for (h) in query [Health] { print(h.hp) } # 50 — Init set hp to max
|
||||
for (e) in query [Health] { despawn self() } # Clean fires: 50 + 900 = 950
|
||||
quit()
|
||||
}
|
||||
}
|
||||
29
examples/lang/offline_rewards.ludic
Normal file
29
examples/lang/offline_rewards.ludic
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# offline_rewards.ludic — issue #9's worked example: an idle/farming game grants
|
||||
# "you were away for N hours" rewards on load, using the Time/Date/Duration and
|
||||
# the deterministic Clock. Everything is integer seconds, so the result replays
|
||||
# identically — no wall clock, no floating point.
|
||||
#
|
||||
# bin/ludic examples/lang/offline_rewards.ludic # prints 13 / 650 / 2026-08-30 / 0
|
||||
program OfflineRewards {
|
||||
entry {
|
||||
# A save records when the player last quit. It is hardcoded here so the demo
|
||||
# is deterministic; a real game writes Time.now() (or Clock.now()) at save.
|
||||
let last_seen = DateTime.from(2026, 8, 29, 18, 30, 0)
|
||||
|
||||
# On load, set the game clock to "now". We drive it explicitly (rather than
|
||||
# Time.now) so gameplay stays replay-safe and this sample is reproducible.
|
||||
Clock.set(DateTime.from(2026, 8, 30, 7, 45, 0))
|
||||
|
||||
let away = Clock.now() - last_seen # a Duration in seconds (47700)
|
||||
let hours = Duration.as_hours(away) # whole hours away
|
||||
|
||||
# Reward: 50 coins per full hour, capped at a day's worth.
|
||||
let capped = Math.min(hours, 24)
|
||||
let coins = capped * 50
|
||||
|
||||
print(hours) # 13
|
||||
print(coins) # 650
|
||||
print(DateTime.format(Clock.now(), "YYYY-MM-DD")) # 2026-08-30 (the day they returned)
|
||||
print(DateTime.weekday(Clock.now())) # 0 = Sunday
|
||||
}
|
||||
}
|
||||
37
examples/lang/qdecl.ludic
Normal file
37
examples/lang/qdecl.ludic
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# ============================================================================
|
||||
# qdecl.ludic — a handler whose query lives in a @Queries annotation.
|
||||
#
|
||||
# @Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
|
||||
# handler CleanBattle phase LateUpdate { … }
|
||||
#
|
||||
# is the same thing as writing `for (Battle, Pos) in query [Battle, Pos, {Foe}]
|
||||
# where Battle.hp <= 0 and Battle.side == 1 { … }` around the whole body — the
|
||||
# loop header moves into the annotation. Each property binds by name, the body
|
||||
# runs once per matching entity, and self() is that entity.
|
||||
# ============================================================================
|
||||
program QueryDecl {
|
||||
property Battle { hp: int = 0, side: int = 0 }
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Foe { Battle, Pos }
|
||||
|
||||
handler Seed phase Start {
|
||||
spawn Foe { Battle { hp: 3, side: 1 } }
|
||||
spawn Foe { Battle { hp: 0, side: 1 } }
|
||||
spawn Foe { Battle { hp: -2, side: 0 } }
|
||||
}
|
||||
|
||||
# a per-property constraint `Battle{...}` qualifies its bare fields to that
|
||||
# property; `on: Foe` adds the {Foe} kind filter.
|
||||
@Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
|
||||
handler CleanBattle phase LateUpdate {
|
||||
print(Pos.x)
|
||||
despawn self()
|
||||
}
|
||||
|
||||
@Queries(these: [Battle])
|
||||
handler Census phase Render {
|
||||
print(Battle.hp)
|
||||
}
|
||||
|
||||
handler Bye phase Render { quit() }
|
||||
}
|
||||
38
examples/lang/reason.ludic
Normal file
38
examples/lang/reason.ludic
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# reason.ludic — LC1 reason-carrying teardown. One @OnDespawn hook, but it knows
|
||||
# *why* the entity is ending: `reason: r` binds an EndReason the compiler passes
|
||||
# at each teardown site. An in-world `despawn` passes EndReason.Despawned; program
|
||||
# shutdown passes EndReason.Quit (every still-live entity's hook fires at exit — no
|
||||
# silent deaths). The body branches on the reason, exactly as Unreal's
|
||||
# EndPlay(reason) / Erlang's terminate(Reason) do.
|
||||
#
|
||||
# Running it prints: 503 1009
|
||||
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500
|
||||
# 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
|
||||
#
|
||||
# bin/x game-build bin/ludicc examples/lang/reason.ludic /tmp/reason
|
||||
# /tmp/reason </dev/null
|
||||
program Reasons {
|
||||
property Health { hp: int = 0 }
|
||||
property Loot { gold: int = 0 }
|
||||
model Enemy { Health, Loot }
|
||||
|
||||
@OnDespawn(Enemy, reason: r) handler Clean {
|
||||
match r {
|
||||
EndReason.Quit => { print(Health.hp + 1000) } # app closing — don't bother dropping loot
|
||||
_ => { print(Loot.gold + 500) } # died in-world — drop the loot
|
||||
}
|
||||
}
|
||||
|
||||
handler Seed phase Start {
|
||||
spawn Enemy { Health { hp: 7 }, Loot { gold: 3 } } # A
|
||||
spawn Enemy { Health { hp: 9 }, Loot { gold: 4 } } # B
|
||||
}
|
||||
|
||||
handler Run phase Render {
|
||||
var first = 0
|
||||
for (e) in query [Health] {
|
||||
if first == 0 { despawn self(); first = 1 } # despawn A -> Despawned -> 3 + 500 = 503
|
||||
}
|
||||
quit() # B survives -> Quit -> 9 + 1000 = 1009
|
||||
}
|
||||
}
|
||||
13
examples/lang/rng_demo.ludic
Normal file
13
examples/lang/rng_demo.ludic
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# rng_demo.ludic — exercises the Random.* extensions (value/int/sign) with a
|
||||
# fixed seed, so the sequence is deterministic for the regression suite.
|
||||
program RngDemo {
|
||||
handler Seed phase Start { Random.seed(value: 1) }
|
||||
handler Once phase Update {
|
||||
print(Random.int(100))
|
||||
print(Random.int(100))
|
||||
print(Math.floor(Random.value() * 10.0))
|
||||
print(Random.sign())
|
||||
print(Random.int(0)) # 0 (max <= 0)
|
||||
quit()
|
||||
}
|
||||
}
|
||||
48
examples/lang/scenes.ludic
Normal file
48
examples/lang/scenes.ludic
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# scenes — one active scene at a time, each grouping handlers into layers behind
|
||||
# an implicit active-scene register (see LANGUAGE.md §"Scenes & layers").
|
||||
#
|
||||
# Running it (feed a few keystrokes so the loop ticks) prints:
|
||||
# 1000 1 101 102 2 3 900 201 900 202 900
|
||||
# 1000 Boot (a global handler) runs once at Start
|
||||
# 1 Title is `start`; its `on enter` fires at boot
|
||||
# 101 frame 1 Update: only Title.Main.Tick runs (Play is not active)
|
||||
# 102 frame 2 Update: Tick reaches 2 -> `become Play`…
|
||||
# 2 3 …which runs Title's `on exit` then Play's `on enter`
|
||||
# 900 Play renders the same frame it is entered (Hud.Draw)
|
||||
# 201 900 frame 3: Play.World.Step, then Hud.Draw
|
||||
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
|
||||
#
|
||||
# bin/x game-build bin/ludicc examples/lang/scenes.ludic /tmp/scenes
|
||||
# printf 'aaaa' | /tmp/scenes
|
||||
program SceneDemo {
|
||||
var counter: int = 0
|
||||
|
||||
handler Boot phase Start { counter = 0; print(1000) }
|
||||
|
||||
scene Title start {
|
||||
on enter { print(1) }
|
||||
on exit { print(2) }
|
||||
layer Main {
|
||||
handler Tick phase Update {
|
||||
counter = counter + 1
|
||||
print(100 + counter)
|
||||
if counter >= 2 { become Play }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene Play {
|
||||
on enter { print(3); counter = 0 }
|
||||
on exit { print(4) }
|
||||
layer World {
|
||||
handler Step phase Update {
|
||||
counter = counter + 1
|
||||
print(200 + counter)
|
||||
if counter >= 2 { quit() }
|
||||
}
|
||||
}
|
||||
layer Hud {
|
||||
handler Draw phase Render { print(900) }
|
||||
}
|
||||
}
|
||||
}
|
||||
31
examples/lang/strings.ludic
Normal file
31
examples/lang/strings.ludic
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# strings.ludic — strings are values: compare with ==/!=, join with +, or (best)
|
||||
# interpolate with `backticks {expr}`. Running it prints: 1 2 3 4 5 6 7
|
||||
program Strings {
|
||||
entry {
|
||||
let greeting = "hello"
|
||||
if greeting == "hello" { print(1) } # content comparison
|
||||
if greeting != "world" { print(2) }
|
||||
|
||||
let who = "Ludic"
|
||||
let msg = greeting + ", " + who + "!" # concatenation chains
|
||||
if msg == "hello, Ludic!" { print(3) }
|
||||
|
||||
# build up a string in a loop
|
||||
var line = ""
|
||||
var i = 0
|
||||
while i < 3 { line = line + "ab"; i = i + 1 }
|
||||
if line == "ababab" { print(4) }
|
||||
|
||||
if (greeting + who) != greeting { print(5) }
|
||||
|
||||
# interpolation: `{expr}` embeds any expression (numbers become text)
|
||||
let n = 42
|
||||
if `{greeting}, {who}!` == "hello, Ludic!" { print(6) }
|
||||
if `n={n}, next={n + 1}` == "n=42, next=43" { print(7) }
|
||||
|
||||
# slicing: s[a..b] is the substring of bytes [a, b), and len(s) its length
|
||||
let full = "hello world"
|
||||
if full[0..5] == "hello" { print(8) }
|
||||
if full[6..len(full)] == "world" { print(9) }
|
||||
}
|
||||
}
|
||||
11
examples/lang/time_demo.ludic
Normal file
11
examples/lang/time_demo.ludic
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
program TimeDemo {
|
||||
handler Tick phase Update {
|
||||
let f = Time.frame()
|
||||
print(f)
|
||||
if f >= 3 {
|
||||
print(Math.floor(Time.elapsed() * 100.0))
|
||||
print(Math.floor(Time.delta() * 1000.0))
|
||||
quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
examples/lang/toggle.ludic
Normal file
37
examples/lang/toggle.ludic
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# toggle.ludic — enable/disable at the three ECS scopes, each a plain statement.
|
||||
# Disabling never destroys data: a property's values persist in storage, so a
|
||||
# later `enable` restores them. Queries already skip a cleared flag, so nothing
|
||||
# else in the language needs to know. Running it prints: 6 0 7 1 0
|
||||
#
|
||||
# disable P on e clears one entity's has-flag (@OnDisable / @OnEnable fire)
|
||||
# disable Model flips the model's enabled flag (its entities drop from queries)
|
||||
# disable Handler flips the handler's enabled flag (it stops running each phase)
|
||||
program Toggles {
|
||||
property Health { hp: int = 0, max: int = 100 }
|
||||
property Shield { amount: int = 0 }
|
||||
model Player { Health, Shield }
|
||||
|
||||
# hooks run at the toggle point with the property's data bound by name
|
||||
@OnDisable(Shield) handler Down { print(Shield.amount + 1) } # 5 + 1
|
||||
@OnEnable(Shield) handler Up { print(Shield.amount + 2) } # 5 + 2
|
||||
|
||||
handler Seed phase Start { spawn Player { Health { max: 50 }, Shield { amount: 5 } } }
|
||||
|
||||
handler Run phase Render {
|
||||
for (e) in query [Player] { disable Shield on self() } # @OnDisable -> 6
|
||||
var n = 0
|
||||
for (s) in query [Shield] { n += 1 }
|
||||
print(n) # 0 — no live Shield now
|
||||
|
||||
for (e) in query [Player] { enable Shield on self() } # @OnEnable -> 7, data intact
|
||||
n = 0
|
||||
for (s) in query [Shield] { n += 1 }
|
||||
print(n) # 1 — amount is still 5
|
||||
|
||||
disable Player # whole model off
|
||||
n = 0
|
||||
for (p) in query [Player] { n += 1 }
|
||||
print(n) # 0 — model disabled
|
||||
quit()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue