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

@ -26,8 +26,10 @@ program Name {
# plain `new`-allocated record; its use decides which
model ... # a named entity KIND (bundle of properties)
const ... # compile-time constants
fn ... # functions
extern fn ... # bind a C library symbol (FFI)
function ... # functions
extern function … # bind a C library symbol (FFI)
enum ... # a named set of integer values
namespace ... # a block of functions with export / internal visibility
handler ... # behavior, grouped into phases
}
```
@ -42,7 +44,9 @@ program ChronoRift {
}
```
An imported file is a **fragment**: bare declarations, no `program` wrapper. Its
`import "emberdepths/*.ludic"` imports every `.ludic` file of a directory, in name
order — a game lists its modules once. An imported file is a **fragment**: bare
declarations, no `program` wrapper. Its
declarations are spliced into the importing program. Imports may appear inside
the `program` block or before it, they may nest (a fragment may import fragments),
and each resolved path is **include-guarded**, so importing the same file twice
@ -76,6 +80,27 @@ Use `{Name}` (tag position) to filter a query by model — an model can't
be *bound* to a variable since it has no fields of its own. Entity kind is part
of the saved snapshot.
### Prefabs
A **`prefab`** is a model with preset component fields, the Unity prefab in
miniature. `spawn` takes a prefab name like a model name, and the spawn's own
fields override the presets. Prefabs chain, so what several share lives once:
```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 } }
prefab Boss: Foe { Stats { hp: 400, max_hp: 400 }, Sprite { scale: 4 } }
spawn Grunt { Position { x: 40, y: 60 } } # Foe's presets, Grunt's, then this
let boss = spawn Boss { Position { x: 160, y: 40 } } # spawn is also an expression: the entity
let e = Prefab.spawn(name: kind_name) # chosen at runtime by name (-1 if none)
```
Field values in a prefab are ordinary expressions evaluated at each spawn, so a
preset may read a global (`Sprite { id: art.orc }`). `@OnSpawn(Model)` runs for a
prefab spawn as for any spawn of its model.
## Text, fonts & images
The 5×7 bitmap `text` stays for zero-asset programs. For real typography, load a
@ -113,6 +138,8 @@ ui MainMenu {
}
```
A widget inherits `font`, `size`, `fg` and `align` from the nearest ancestor that
sets them, so a panel states a menu's look once and a label only says what differs.
Widget types: `panel` (container + optional skin/bg/border), `col` / `row`
(pure stacks), `label`, `button` (focusable), `image`, `spacer`. Props are
evaluated at build time, so `font: reg(R_FONT)` reads a value the program set first.
@ -133,26 +160,47 @@ handler Nav phase Update {
handler Draw phase Render { clear(0x0e0e16); ui_render(); present() }
```
See `examples/games/menu.ludic` for a complete title screen.
The frame loop ticks navigation on its own, and an activation fires the
`UiClicked { id }` event, so a menu is usually handled by one listener that can
change scene directly:
```ludic
# doc-check: skip — illustrative
@On(UiClicked) handler MenuActions {
if id == UI_Play { become Play }
else if id == UI_Quit { quit() }
}
```
`Ui.open(id: UI_Menu)` activates a menu, `Ui.close()` deactivates it, and
`Ui.set_text(id: UI_Label, text: s)` updates a label. See `examples/games/menu.ludic`
for a complete title screen.
## Types
| Type | Meaning | LLVM IR type |
|------|---------|--------------|
| `int` | 32-bit integer | `i32` |
| `countdown` | an `int` component field the engine steps toward 0 once per Update | `i32` |
| a bare `enum` | its variants, as an `int` | `i32` |
| `IVec2` | an integer (x, y) pair by value — `v.x`, `v.y`, `IVec2.make/add/sub/…` | `i64` |
| `fixed` | Q16.16 fixed-point | `i32` |
| `bool` | boolean | `i32` |
| `entity` | entity handle | `i32` |
| `str` | string literal | `ptr` |
| `ptr` | raw address (runtime/FFI) | `ptr` |
| `byte` | pointer to bytes — `p[i]` reads/writes one byte | `ptr` |
| `words` | pointer to 32-bit words — `w[i]` reads/writes an `int` | `ptr` |
| `string` | text (a string literal, an interpolation, a concatenation) | `ptr` |
| `pointer` | raw address (runtime/FFI, records, anything untyped) | `ptr` |
| `byte` | one byte value (what `p[i]` on a `bytes` buffer reads) | `i8` |
| `bytes` | buffer of bytes — `b[i]` reads/writes one byte | `ptr` |
| `words` | buffer of 32-bit words — `w[i]` reads/writes an `int` | `ptr` |
| `fixeds` | buffer of `fixed` values — `f[i]` reads/writes a `fixed` | `ptr` |
| `ptrs` | buffer of pointers — `p[i]` reads/writes a `ptr` | `ptr` |
| `pointers` | buffer of pointers — `p[i]` reads/writes a `pointer` | `ptr` |
Allocate raw buffers with `bytes(n)` (n bytes) or `words(n)` (n 32-bit words);
both return a pointer you index with `buf[i]` — retype the binding (`words` /
`fixeds` / `ptrs`) to pick the element size.
`fixeds` / `pointers`) to pick the element size. Use `string` for text and
`pointer` for an opaque address: the compiler treats both as one pointer type
(it is the operand kinds, not the name, that select string concatenation and
content comparison), so the name is documentation for the reader.
Numeric literals: `42` and `0x1affff` are `int`; a literal with a decimal
point (`1.5`) is `fixed`. Arithmetic on two `fixed` values lowers to
@ -180,6 +228,57 @@ for (a, b) in query [Pos, Vel] where a.x > 0 { ... } # one var per non-tag term
Entities are integer handles; property storage and slot reuse are generated per
program. `self()` yields the entity of the innermost `query` loop.
### A component by entity handle: `Prop.of(e)` / `Prop.has(e)`
A query binds components for the entities it visits. When the handle is already
in a variable — the player, a boss, the `target` of a damage event — `Prop.of(e)`
gives the same typed binding without a loop, and its fields read and assign like
any record's:
```ludic
# doc-check: skip — composite: declarations plus statements using them
property Hero { iframes: int = 0, roll_cooldown: int = 0 }
var player: int = -1
Hero.of(player).iframes = 20 # assign a field
Hero.of(player).roll_cooldown -= 1 # compound-assign one
let hero = Hero.of(player) # or bind the component once
if hero.iframes > 0 { hero.iframes -= 1 }
```
`Prop.of(e)` is unchecked, like a query binding: on an entity that does not carry
the property it reads that entity's zeroed slot. Guard with **`Prop.has(e)`**,
which is true only when `e` is a valid handle, alive, and carries the property —
so `-1` (no entity) and a despawned handle are both simply `false`:
```ludic
# doc-check: skip — illustrative
if Stats.has(target) { Stats.of(target).hp -= amount }
```
**`Prop.count()`** is the number of live entities carrying `Prop` — the
"are there foes left?" question without a counting loop — and
**`Prop.despawn_all()`** despawns every one of them (a room teardown:
`Position.despawn_all()` clears the world and keeps the config entities).
**Timers are a field type.** A component field declared **`countdown`** is an
`int` the engine steps toward 0 once per Update, for every live entity carrying
the component, never below 0. Set it, then test it; no handler counts it down:
```ludic
# doc-check: skip — illustrative
property Roll { frames_left: countdown = 0, cooldown: countdown = 0 }
Roll.of(player).cooldown = 35 # …and 35 frames later it reads 0
if Roll.of(player).cooldown == 0 { start_roll() }
```
Both `Prop.of` and `Prop.has` are the typed, compile-time form of the by-name reflection ABI
(`World.prop_id` / `World.field_id` / `World.get` / `World.set`), which remains
the tool for code that does not know the property name until runtime (mods,
engine systems). A package that declares a real `prop_of` / `prop_has` function
under `@Namespace(Prop)` keeps it — the sugar only applies where no such function
exists.
## Handlers & phases
```ludic
@ -301,6 +400,26 @@ exponent, `Light.soft` shadows (penumbra), `Light.gel` colour cookies,
normal-mapped surfaces (`Light.normal` + `Light.height`), and a
`Light.time_of_day` day/night ramp — every one deterministic.
### Managers: the engine owns the small stuff
Beyond the component systems, a few engine-owned managers cover what every game
otherwise hand-rolls — each a namespace, nothing to declare:
| Manager | What it owns |
|---|---|
| `Fx.sparks` / `Fx.number` / `Fx.clear` | transient sparks and floating numbers: moved, aged, drawn after the sprites, dropped when done |
| `Audio.define(name:, path:)` then `Audio.play(name:)` / `Audio.play_music(name:)` | a sound bank by name; the handle form still works |
| `Camera.shake_for(amount:, frames:)` | a timed screen shake the engine decays |
| `Assets.enqueue` / `pump` / `progress` / `ready`, `Assets.get`, `Audio.play(name:)`, `Assets.font` | one preload queue for images, sounds and fonts, sorted by extension |
| `Prefab.spawn(name:)` | spawning a prefab chosen at runtime |
| `Map.get/set/fill/rect/border/random_cell/random_cell_far/to_tile/is_solid/is_solid_at` | the tilemap edited in place, and what is solid per the `Solids` config (projectiles die on it too) |
| `Stats { damage_pct, crit_pct, leech_pct, thorns, fire_rate_pct }`, `Stats.add`, `Stats.scale_hp` | the build stats every action game bolts on, applied by `Combat.damage` and the weapon system |
| `Dash`, `Melee` (ludic.shooter), `Dungeon.*` (ludic.dungeon), `Brain { hunt_blind }` (ludic.npcai) | the dodge roll with i-frames, the arc swing with knockback, arena rooms with exits, relentless pursuit |
| `PadButton.A/B/X/Y/…`, `CursorMode.*` | names for the input runtime's numbers |
| `TopDown { reticle }`, `Weapon.set_color`, `Sprite.draw_meter`, `Collider.center`, `Prefs.max`, `Assets.enqueue_dir` | the aim line, engine-drawn shots, icon meters, box centres, high scores, a whole asset directory |
| `Sprite { move_id, face, flash, blink }` | the run strip while moving, facing by movement, a white hit flash and an invulnerability blink — all engine-driven |
| `IVec2.distance2/within/heading/along/step`, `Angle.diff_degrees`, `List.sample`, `Input.move_i`, `Screen.bar` | the geometry, sampling, movement intent and meters every action game rewrites |
### Input actions & deterministic replay
Beyond the raw `Input.key()` (this frame's key code), gameplay can read **named
@ -388,7 +507,8 @@ boot ── @OnStart ─▶ spawn ── @OnAttach(P), @OnSpawn(M) ─▶ …
the `Start` phase); `@OnQuit` runs once at shutdown, after the frame loop stops
and before the process exits — the place to `save()` or clean up.
- **`@OnSpawn(Model)` / `@OnDespawn(Model)`** — an *entity*. Both bind the model's
properties by name; `@OnSpawn` is a constructor, `@OnDespawn` a destructor.
properties by name, and `self()` is that entity; `@OnSpawn` is a constructor
(`@OnSpawn(Hero) handler Remember { player = self() }`), `@OnDespawn` a destructor.
Despawn doesn't statically know an entity's model, so despawn hooks compile to
functions dispatched on the entity's kind. `@OnDespawn` may take an optional
**reason**: `@OnDespawn(Enemy, reason: r)` binds `r` to an `EndReason` the
@ -632,6 +752,25 @@ fields: `toks[i].kind = T_ID` is a single address computation.
```ludic
function heal(amount: int) -> int { return amount * 2 }
```
A call passes arguments **positionally or by name**. A named argument is the
parameter's name, a colon, and the value; named arguments may come in any order
and are reordered to the declaration at compile time. A call is either all
positional or all named — the two do not mix. This works for every callable:
bare functions, `namespace` and `@Namespace` functions, externs, and the
builtin namespaces (`Screen.*`, `Input.*`, …):
```ludic
# doc-check: skip — composite: a declaration plus its uses
function define_weapon(name: string, fire_rate: int, damage: int) -> int { … }
define_weapon("pistol", 9, 14) # positional
define_weapon(name: "pistol", fire_rate: 9, damage: 14) # named, reads as a table row
Weapon.def(damage: 14, name: "pistol", fire_rate: 9) # any order, on a namespace too
```
```ludic
extern function c_hypot(a: fixed, b: fixed) -> fixed = "hypot_fx" # bind a C symbol
```
@ -660,6 +799,22 @@ at the top level it is module state).
loop accumulators and anything that genuinely changes.
- **`const NAME = e`** — a compile-time constant (folded, no storage).
A program-scope `var` may be initialized with **any expression** — a literal, an
`Enum.Variant`, a `new Record`, a call. What the compiler can fold becomes the
global's initial value; the rest runs once at startup, in declaration order,
after the runtime boots and before the `Start` phase:
```ludic
# doc-check: skip — illustrative globals
var run: Progress = new Progress # allocated before Start
var origin: IVec2 = IVec2.zero()
var mode: HeroState = HeroState.Idle # folded
```
Declaring the same `var` twice is an error — including a name the spliced engine
runtime already uses, which the message says (`variable ui_font is also a
variable of the engine runtime; choose another name`).
Immutability is of the **binding**, not the object. A `let` that holds a record
or slice still lets you mutate *through* it — the reference itself just cannot be
repointed:
@ -725,6 +880,28 @@ is still accepted when a state needs a specific value.) A `machine <reg>` reads
value>)`. Both lower to plain branches (and `match` runs on the native LLVM
backend too).
The store is usually a program-scope `var`. Declare it with an **enum type** and
the machine's states are that enum's variants, matched by name — so the rest of
the program compares the store against `HeroState.Rolling` and the machine needs
no `= value` on any state:
```ludic
# doc-check: skip — composite: declarations plus a machine over them
enum HeroState { Idle, Rolling, Swinging }
var hero_state: HeroState = HeroState.Idle
machine hero_state {
state Idle { if wants_roll { become Rolling } } # HeroState.Idle
state Rolling { if done { become Idle } } # HeroState.Rolling
state Swinging { … }
}
if hero_state == HeroState.Rolling { … } # readable from anywhere
```
A state that names no variant of the store's enum is a compile error. A bare
(payload-free) enum is an `int`-sized type wherever a type is written — a `var`,
a parameter, a field, a return.
## Enums
`enum` names a set of related integer values so a magic-number space — a menu
@ -818,7 +995,7 @@ literal; test any pointer/record/slice with `x == null` / `x != null` (an unset
# text_ttf(font,x,y,utf8,color,px) text_w(font,utf8,px)->int text_h(font,px)->int
# images image_load(path)->id draw_image(id,x,y) draw_image_scaled(id,x,y,w,h)
# draw_9slice(id,x,y,w,h,inset)
# UI ui_build() ui_open(id) ui_tick(key) ui_render()
# UI ui_build() ui_open(id) ui_close() ui_tick(key) ui_render()
# ui_clicked(id)->bool ui_set_text(id,str) ui_set_int(id,n)
# ui_focus(id) ui_focused()->int ui_visible(id,bool)
# assets png_load(path)->id (decodes a PNG; returns a 16x16 sprite id)
@ -963,7 +1140,27 @@ scene Overworld {
- `become Name` transitions: the current scene's `on exit` runs, the active scene
becomes `Name`, and its `on enter` runs. Inside a layer handler the compiler
knows which scene is leaving, so a transition costs two direct calls and a
store — there is no dispatch table.
store. From code no scene owns — a global handler, an `@On(Event)` listener, a
plain function — `become` runs the *live* scene's `on exit` through one
generated dispatch (`@L_scene_leave`), so a menu can react to `UiClicked` and
`become Play` from a listener.
- **`scene Title shows TitleMenu { … }`** — the scene owns a `ui` block: the
engine frees the cursor and opens the menu on enter, draws it last in the
`Overlay` phase, and closes it on exit. The scene's own handlers stay for the
rest (`Ui.set_text` in `on enter`, a `Hud.draw()` under an overlay menu).
- **`scene Splash lasts 110 then Title { … }`** — a timed scene: the engine counts
the frames and moves on. **`scene Loading start loads then Title { … }`** — a
loading scene: the engine pumps the `Assets` queue each frame, draws a default
progress bar, fires `AssetsReady` once, and moves on when everything is in.
- **`button id: Resume text: "Resume" goto: Play`** in a `ui` block — a click
changes scene; no listener to write for the plain navigation buttons.
- Handler names inside a scene's layers are qualified by the scene (`Play_Draw`),
so two scenes may both have a `Draw`; `enable` / `disable` by the bare name
still resolves inside that scene.
- A layer handler may carry **`@Queries`** (and only that annotation), so a scene
owns its per-entity systems: `@Queries(these: [Particle]) handler AgeSparks
phase Update { … }` runs once per matching entity, only while the scene is
active.
- **The active scene is snapshotted per phase.** A `become` mid-phase runs its
`on exit`/`on enter` immediately, but the switch of *which layers dispatch*
takes effect at the next phase boundary — so exactly one scene's layers run in

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Ai.seek` + path-aware `brain_seek` — when a Solids tilemap is present the NPC-AI routes a blocked straight line around obstacles with `Grid.a_star`, so foes flow around pillars instead of getting stuck.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
A windowed build that reaches the audio runtime indirectly — through the atlas / `Assets.*` preload queue (which feeds `.wav`/`.mp3` into the sound bank) or the engine sprite-render system, without any `Audio.*` call in the game — now links the native audio backend (`audio.ll` + AVFoundation). Previously `ludicc -o` failed at link with undefined `snd_*` symbols for any windowed game declaring a `Sprite` component; the import of `runtime/native/audio.ludic` now flags the backend link itself.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
`become Scene` now works from an `@On(Event)` listener, a global handler, or a plain function (#91). Code outside a scene's own layers cannot know the leaving scene at compile time, so the compiler emits `@L_scene_leave()` — a dispatch on the live scene id that runs its `on exit` — and calls it there. Previously a listener's `become` reused the last emitted handler's scene (or crashed), and a global handler's `become` skipped the leaving scene's `on exit` entirely.

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Prop.of(entity)` and `Prop.has(entity)` — typed access to one entity's component from an entity handle, the same binding a query loop makes. `Hero.of(player).iframes = 20` reads and writes fields directly (no `World.prop_id` / `World.field_id` / `World.get` reflection chain); `Prop.has(e)` is true when `e` is in range, alive, and carries the property, so `-1` is a safe "no entity". A package that declares a real `prop_of` / `prop_has` function keeps it.

3
changes/countdown.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
**`countdown` fields.** A component field declared `frames_left: countdown = 0` is an `int` the engine steps toward 0 once per Update for every live entity carrying the component (never below 0). Roll timers, invulnerability frames, hit flashes and cooldowns need no hand-written "decrement each frame" handler: set the field, test it.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
cursor `mode 3` (confined) now keeps the OS cursor **associated** (absolute position preserved) and hidden, instead of dissociating it like `mode 2` (lock/relative). Only true-lock `mode 2` uses relative deltas now; `win_mouse` reports the absolute position on `mode 3` and clamps it to the framebuffer. And `mode 3` now **physically confines** the cursor: each frame the platform layer warps it back to the window's content rect (`CGWarpMouseCursorPosition`) whenever it strays past the edge, so clicks can't land outside and the window keeps focus. This lets a top-down game hide + confine the cursor while `aim_mode 0` (mouse aim) keeps resolving to where the reticle points — previously any confine/lock mode silently broke absolute mouse aim, and a confined cursor still escaped the window (#89 follow-up).

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
A `var` declared twice — including a game `var` whose name the spliced engine runtime already uses (`ui_font`, `grid`, …) — is now a compile error naming the variable and, when it is the runtime's, saying so (`variable ui_font is also a variable of the engine runtime; choose another name`). Previously the two became one LLVM global and clang reported a redefinition in generated IR. The same check covers `property` names (`property Cell is also a property of the engine runtime; choose another name`); before, the first declaration silently won and field lookups failed with a confusing message.

3
changes/engine-enums.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
The engine's numeric parameters have names. ludic.core: `BodyPolicy { Platformer, TopDown }`, `BoundsPolicy { Clamp, Wrap, Bounce, Kill }`, `AnimMode { Loop, Once, PingPong }`; ludic.shooter: `AimMode { Mouse, RightStick, MoveDirection, NearestEnemy }`, `WeaponPattern { Single, Cone, Ring, Spiral }`; ludic.npcai: `BrainModel { StateMachine, Utility, BehaviourTree }`, `AiState { Patrol, Chase, Attack, Flee }`; ludic.gameplay: `StatKind { MaxHp, Attack, Defense, Speed }`, `ModifyOp { Flat, Percent }`; ludic.rpg: `StatusKind { Poison, Regen }`; the input runtime: `CursorMode { Normal, Hidden, Locked, Confined }`. `Body { policy: BodyPolicy.TopDown }` reads as what it is; the old integers still work. The input runtime also names the gamepad buttons: `PadButton { A, B, X, Y, LeftShoulder, RightShoulder, Back, Start }` for `Input.bind_pad(button:)`.

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
engine-driven retained UI — a program with a `ui` block has its navigation ticked by the frame loop automatically (from the frame key) and an activation now emits a `UiClicked { id }` event, so scenes react with `@On(UiClicked)` instead of polling `Ui.clicked`. New `Ui.*` namespace (`Ui.open/tick/clicked/set_text/render/build`).

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
A `machine` over an enum-typed store maps its states to the enum by name: with `enum HeroState { Idle, Rolling }` and `var hero_state: HeroState = HeroState.Idle`, `machine hero_state { state Idle { … } state Rolling { … } }` dispatches on `HeroState.Idle` / `HeroState.Rolling` — no `state Idle = HeroState.Idle` repetition, and a state that names no variant is a compile error. A bare enum is now a first-class `int`-sized type for `var`, params, fields and returns (`llty`), and `Enum.Variant` folds in a global initializer.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
`ludic-fmt` no longer glues an opening parenthesis to a preceding operator: `let moving = (a or b)` stays as written instead of becoming `let moving =(a or b)`.

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
A program-scope `var` may be initialized with any expression: `var run: Progress = new Progress`, `var speed: int = BASE_SPEED * 2`, `var origin: IVec2 = IVec2.zero()`. Initializers the compiler cannot fold run once at startup (`@L_init_globals`, after the runtime boots and before the `Start` phase), in declaration order. Previously such an initializer was silently replaced by `0` / `null`.

3
changes/half-the-game.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
The second "write less" round, all generic. **ludic.gameplay**: `Stats` carries the build stats every action game bolts on — `damage_pct`, `crit_pct`, `leech_pct` / `leech_hp`, `thorns`, `fire_rate_pct` — and `Combat.damage` applies them itself (a `Crit` event fires; thorns never reflect thorns); `Stats.add(e, stat, amount)` changes a base stat in place and `Stats.scale_hp(e, percent)` scales hp and max_hp; `StatKind` names every code. **ludic.shooter**: `Dash { frames, speed, cooldown_frames }` with `Dash.start(e, dx, dy)` / `Dash.active(e)` — a dodge roll with i-frames the package guards; `Melee { range, half_arc, damage, knockback, frames, cooldown_frames, arc }` with `Melee.swing(e)` (hits every hostile in the arc, knocks back, fires `MeleeHit`) / `Melee.ready` / `Melee.active`; projectiles drawn by the engine in their weapon's colour (`Weapon.set_color`); `TopDown { reticle, reticle_length }` draws the aim line and a mouse cross; the weapon system honours `Stats.fire_rate_pct`. **ludic.dungeon** (new package): `Dungeon.arena / open_arena / random_style / set_exit / entry_point / opposite / at_edge`, with `Side` and `RoomStyle` — arena rooms with mirrored cover, door lanes and exits by side, built into the engine tilemap. **Compiler**: `scene Splash lasts N then Next` (a timed scene), `button … goto: Scene` (a click changes scene, no listener to write). **Runtime**: `Map.random_cell_far`, `Sprite.draw_meter` (hearts / pips), `Assets.enqueue_dir`, `Collider.center`, `Prefs.max`. Also `scene X loads then Y` (the loading scene: pumped, drawn, `AssetsReady` fired), `Prop.despawn_all()`, `Map.to_tile`, and `Brain { hunt_blind }` in ludic.npcai (seek the nearest hostile without line of sight). `Prefab.spawn_at(name:, at:)` spawns and places; `Weapon.reset(id)` restores a definition (no pierce, no homing, its fire rate). Five regression examples cover the additions (`examples/library/prefabs`, `component_access`, `scene_menus`, `managers`, `combat_kit`). `Random.weighted(weights:)` draws an index by weight; `ui` widgets inherit `font` / `size` / `fg` / `align` from their panel; the input runtime names `MouseButton { Left, Right, Middle }`.

3
changes/ivec2-members.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`v.x` and `v.y` read the components of an `IVec2` value (a local, a global, a record field, or a call result) — the readable form of `IVec2.x(v)` / `IVec2.y(v)`. The compiler's static typing now also follows function return types, namespace calls, `Prop.of(e)` and record fields, so `@Computed` fields expand in those positions too.

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Key.*` compile-time key constants (`Key.Space`, `Key.Escape`, `Key.A`, `Key.Up`, ...), folded like `Color.*`; and `Font.*` / `Ui.* / File.*` namespaces so `png_load`/`font_load`/file I/O are namespaced.

3
changes/layer-queries.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
A handler inside a scene `layer` may carry `@Queries(these: […], on: Model)`, so a scene can own its per-entity systems (`@Queries(these: [Particle]) handler AgeSparks phase Update { … }` runs once per matching entity only while that scene is active). Any other annotation on a layer handler is reported.

4
changes/less-to-write.md Normal file
View file

@ -0,0 +1,4 @@
bump: minor
type: feat
Less to write for a game. **`Map` cell API** — `Map.get/set/fill/rect/border/random_cell/is_solid/is_solid_at/width/height`: a game edits the engine's tilemap in place and asks it what is solid (from the `Solids` config) instead of keeping its own grid. **`Sprite` does the small animation work**: `move_id` is the strip drawn while the entity moves, `face: 1` turns it toward its movement, and the `flash` / `blink` countdowns give a white hit flash and an invulnerability blink with no handler. **`scene X shows Menu`** — the engine opens the menu (and frees the cursor) on enter, draws it last in Overlay, and closes it on exit. **`import "dir/*.ludic"`** imports a directory in name order. **`IVec2.distance2/within/heading/along/step`** and **`Angle.diff_degrees`** cover the geometry every action game rewrites; **`List.sample`** draws distinct random picks; **`Input.move_i`** is the standard top-down movement intent; **`AimMode.Auto`** aims with the mouse, or the right stick while a pad is connected; **`Weapon.set_rate` / `Weapon.rate`** change a fire rate in place; projectiles now die on `Solids` tiles by themselves; **`Screen.bar`** draws a meter.
Handlers inside a scene's layers are scene-qualified (`Play_Draw`), so two scenes may both name a handler `Draw`; `enable` / `disable` of a scene's own handler by its bare name still works from inside that scene.

3
changes/managers.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Engine managers for what every action game hand-rolls: **`Fx.sparks` / `Fx.number` / `Fx.clear`** — engine-owned sparks and floating damage numbers, moved and aged each Update and drawn after the sprites, with no component, model, handler or draw call in the game; **`Audio.define(name:, path:)` + `Audio.play(name:)` / `Audio.play_music(name:)` / `Audio.named`** — a sound bank by name (the handle form still works); **`Camera.shake_for(amount:, frames:)`** — a timed shake the engine decays; **`Assets.enqueue`** now loads `.wav` / `.mp3` into the sound bank and `.ttf` / `.ttc` into a font table (**`Assets.font(name:)`**) alongside images, so one loading scene covers everything; **`Prop.count()`** — how many live entities carry a component.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
Named arguments now work on namespace functions (`@Namespace(Foo)` and `namespace Foo { export function … }`), not only on builtins and bare functions: `Weapon.def(name: "pistol", fire_rate: 9, damage: 14, speed: 8, spread: 0, pellets: 1, pattern: 0)` reorders to the declared parameter order like any other call. Previously every named call on a namespace function failed with "wrong number of arguments".

3
changes/overlay-phase.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Overlay` render phase — runs after the engine Render systems (sprites, lights) and before present, so a game's HUD / menus are never painted under an actor. Byte-identical when unused.

3
changes/prefabs.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
**Prefabs.** `prefab Grunt: Creature { Stats { hp: 30 }, Weapon { def_id: 1 } }` names a model with preset fields; `spawn Grunt { Position { x: 40 } }` spawns it, the spawn's own fields winning over the presets. Prefabs chain (`prefab Grunt: Foe` where `Foe` is a prefab) so shared presets live once. `spawn` is now also an expression yielding the new entity (`let e = spawn Grunt { … }`), and `Prefab.spawn(name: "Grunt")` spawns one chosen at runtime by name (-1 when none matches).

3
changes/prefs-package.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`ludic.prefs` package — `Prefs.*`, a human-readable `key=value` text store for scores / options (the right tool for "remember my best run"; a whole-world `Save.write` is not).

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
reserved words (`new`, `match`, `spawn`, ...) can no longer name a function — the compiler errors instead of miscompiling.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
`self()` inside an `@OnSpawn(Model)` or `@OnAttach(Property)` body is now the entity being constructed. Previously it was the entity of the innermost query loop — or the constant 0 when the spawn happened outside any loop — so a hook such as `@OnSpawn(Hero) handler Remember { player = self() }` silently recorded entity 0.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
the shooter aims / homes / fires from a body's **centre** (Position + Collider offset + half-size) instead of the Position anchor, so auto-aim and homing target what is drawn, not a corner.

3
changes/solids-solid2.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Solids.solid2` — an optional second solid glyph (e.g. a closed door) the move system also blocks.

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Sprite` component draws atlas ids (#90) — `Sprite.atlas = 1` routes `esys_sprite` through `atlas_draw_ex` (scale/flip/tint) so atlas cells / multi-cell spans (tall characters) use the engine sprite-render system, not just the 16x16 table.

3
changes/sprite-strip.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
`Sprite.strip(sheet, col, row, count, rows)` — register N consecutive animation frames in one call (the base id for `SpriteAnim`).

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
engine tilemap-render system (`TileSkin`, shipped from ludic.core) — one entity per glyph paints the whole `Map.*` grid each Render frame *before* sprites, so a game stops hand-looping the map. `esys_tileskin` registered ahead of `esys_sprite`.

3
changes/ui-close.md Normal file
View file

@ -0,0 +1,3 @@
bump: patch
type: feat
`Ui.close()` — deactivate the retained UI (no menu open); the readable form of `Ui.open(id: -1)`.

View file

@ -0,0 +1,3 @@
bump: patch
type: fix
A `UI_Name` handle can be read from any code — a plain function, an `@On(UiClicked)` listener, a global initializer — not only from handlers and scene hooks. The widget table it indexes is now built on first use instead of when `@ui_build` is emitted, which came after functions and listeners and crashed the compiler on such a reference.

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())
}
```

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)
}
}

View file

@ -22,6 +22,7 @@ property Position { x: int = 0, y: int = 0 }
# on_ground / hit_wall / hit_ceiling and the optional contact normal hit_nx/hit_ny
# are OUTPUTS: read them in a handler to raise your own landing / bonk events
# (the SpriteAnim.event_fired pattern — polled flags, no engine coupling).
enum BodyPolicy { Platformer, TopDown } # Body.policy: gravity applies / no gravity
property Body {
vx: fixed = 0.0, vy: fixed = 0.0,
gravity: fixed = 0.0, max_fall: fixed = 0.0,
@ -48,15 +49,46 @@ property Collider {
# Position anchor, scale is an integer zoom (0/1 = 1:1), flip mirrors horizontally,
# tint (non-zero) draws every opaque pixel in one colour (hit flash), hidden = 1
# skips it. Draw order is spawn order; a game wanting custom draw omits Sprite.
property Sprite { id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, flip: int = 0, tint: int = 0, hidden: int = 0 }
# atlas = 1 marks `id` as an atlas sprite (Sprite.cell / Sprite.cell_span — any size,
# including multi-cell characters); 0 = the 16x16 sprite table (png_load). (#90)
# move_id: the strip drawn while the entity moves (0 = always `id`); face = 1 turns
# the sprite toward its movement (TopDown intent, else Body velocity); flash and
# blink are countdowns: white tint while flash > 0, hidden every other pair of
# frames while blink > 0 (a hit flash and invulnerability blink, no handler).
property Sprite {
id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, flip: int = 0, tint: int = 0, hidden: int = 0, atlas: int = 0,
move_id: int = 0, face: int = 0, flash: countdown = 0, blink: countdown = 0
}
# Collider.center(e): the middle of an entity's box, from its Position and Collider
@Namespace(Collider) function collider_center(e: int) -> IVec2 {
let PP = World.prop_id("Position")
let PC = World.prop_id("Collider")
var x = 0; var y = 0; var w = 0; var h = 0
if (PP >= 0) and (World.has(e, PP) != 0) { x = World.get(e, PP, World.field_id(PP, "x")); y = World.get(e, PP, World.field_id(PP, "y")) }
if (PC >= 0) and (World.has(e, PC) != 0) { w = World.get(e, PC, World.field_id(PC, "w")); h = World.get(e, PC, World.field_id(PC, "h")) }
return IVec2.make(x + w / 2, y + h / 2)
}
# Optional single config entity that turns the tile-grid broadphase on: solids are
# read from the Map.* tilemap. tile > 0 sets the tile size in px; wall is the solid
# glyph; oneway is an optional one-way-platform glyph (0 = none).
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0 }
# solid2 is an optional second solid glyph (0 = none) — e.g. a closed door drawn with
# its own TileSkin while it blocks movement.
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0, solid2: int = 0 }
# The engine-advanced animation clock (esys_spriteanim): `frame` is the current cell
# (0..frames-1) and the sprite system adds it to Sprite.id. mode 0 loop, 1 once, 2 pingpong.
enum AnimMode { Loop, Once, PingPong } # SpriteAnim.mode
property SpriteAnim { ticks: int = 0, fps: int = 8, frames: int = 4, mode: int = 0, frame: int = 0 }
# One entity per map glyph: the engine tilemap-render system paints every Map.* cell
# carrying `glyph` with `sprite` (atlas id when atlas = 1), before sprites are drawn.
property TileSkin { glyph: int = 0, sprite: int = 0, atlas: int = 1, size: int = 16, scale: int = 1 }
# Optional single config entity defining a world boundary / play area (#84): a rect
# (x, y, w, h) and a policy the engine applies to every moving Body each frame —
# 0 clamp (walls), 1 wrap (toroidal), 2 bounce (flip velocity), 3 kill (despawn when
# fully outside). Off by default (no Bounds entity = open world).
enum BoundsPolicy { Clamp, Wrap, Bounce, Kill } # Bounds.policy
property Bounds { x: int = 0, y: int = 0, w: int = 0, h: int = 0, policy: int = 0 }

View file

@ -22,3 +22,5 @@ provides "Collider"
provides "Solids"
provides "Sprite"
provides "Bounds"
provides "SpriteAnim"
provides "TileSkin"

View file

@ -0,0 +1,124 @@
# dungeon.ludic — `Dungeon.*`: arena rooms for a room-to-room roguelite, built
# straight into the engine tilemap (Map.*).
#
# Dungeon.arena(style, wall, floor) a walled room with mirrored cover in one of
# the RoomStyles, and the door lanes (two cells
# wide, through the centre) always open
# Dungeon.open_arena(wall, floor) the walled room alone (a boss arena)
# Dungeon.random_style() a style roll (Scatter twice as likely)
# Dungeon.set_exit(side, glyph) the two centre cells of one edge
# Dungeon.entry_point(side, inset, w, h) where a w x h body starts after entering
# Dungeon.opposite(side) the side across the room
# Dungeon.at_edge(tile) is a tile on the border? (an open doorway is
# the only border cell a body can stand in)
#
# Cover never touches the two-tile margin, and every style mirrors across both axes,
# so every quadrant stays reachable. Deterministic: the seeded RNG drives it all.
enum Side { North, South, West, East } # opposite = side ^ 1
enum RoomStyle { Pillars, Columns, CenterSlab, Bars, Scatter }
const DUNGEON_MARGIN: int = 2
var dungeon_wall: int = '#'
var dungeon_floor: int = '.'
function dungeon_columns() -> int { return Map.width() }
function dungeon_rows() -> int { return Map.height() }
function dungeon_door_column() -> int { return Map.width() / 2 - 1 } # the left of the two door cells
function dungeon_door_row() -> int { return Map.height() / 2 } # the top of the two door cells
# a wall, but never in the margin so rooms stay traversable
function dungeon_place(x: int, y: int) -> void {
if (x < DUNGEON_MARGIN) or (x > dungeon_columns() - 1 - DUNGEON_MARGIN) { return }
if (y < DUNGEON_MARGIN) or (y > dungeon_rows() - 1 - DUNGEON_MARGIN) { return }
Map.set(x: x, y: y, glyph: dungeon_wall)
}
function dungeon_mirrored(x: int, y: int) -> void {
dungeon_place(x, y)
dungeon_place(dungeon_columns() - 1 - x, y)
dungeon_place(x, dungeon_rows() - 1 - y)
dungeon_place(dungeon_columns() - 1 - x, dungeon_rows() - 1 - y)
}
function dungeon_pillar(x: int, y: int) -> void { # 2x2, mirrored four ways
dungeon_mirrored(x, y)
dungeon_mirrored(x + 1, y)
dungeon_mirrored(x, y + 1)
dungeon_mirrored(x + 1, y + 1)
}
function dungeon_style(style: int) -> void {
let cols = dungeon_columns()
let rows = dungeon_rows()
match style {
RoomStyle.Pillars => {
let x = Random.range(low: 3, high: 5)
let y = Random.range(low: 3, high: 4)
dungeon_pillar(x, y)
if Random.chance(percent: 60) { dungeon_pillar(x + 3, y + 2) }
}
RoomStyle.Columns => {
let x = Random.range(low: 4, high: 6)
for y in 3 .. rows - 3 { dungeon_mirrored(x, y) }
}
RoomStyle.CenterSlab => { Map.rect(x: 6, y: 5, width: cols - 12, height: rows - 10, glyph: dungeon_wall) }
RoomStyle.Bars => {
let y = Random.range(low: 3, high: 4)
for x in 4 .. cols - 4 { dungeon_mirrored(x, y) }
}
_ => {
for i in 0 .. Random.range(low: 3, high: 5) {
let x = Random.range(low: 3, high: cols / 2 - 1)
dungeon_place(x, Random.range(low: 3, high: rows - 4))
dungeon_place(cols - 1 - x, Random.range(low: 3, high: rows - 4))
}
}
}
}
@Namespace(Dungeon) function dungeon_open_arena(wall: int, floor: int) -> void {
dungeon_wall = wall
dungeon_floor = floor
Map.fill(glyph: floor)
Map.border(glyph: wall)
}
@Namespace(Dungeon) function dungeon_arena(style: int, wall: int, floor: int) -> void {
dungeon_open_arena(wall, floor)
dungeon_style(style)
Map.rect(x: dungeon_door_column(), y: 1, width: 2, height: dungeon_rows() - 2, glyph: floor) # the door lanes
Map.rect(x: 1, y: dungeon_door_row(), width: dungeon_columns() - 2, height: 2, glyph: floor)
}
@Namespace(Dungeon) function dungeon_random_style() -> int { return Random.range(low: 0, high: 5) }
# the first of an exit's two cells, and the step to the second
function dungeon_exit_cell(side: int) -> IVec2 {
match side {
Side.North => { return IVec2.make(dungeon_door_column(), 0) }
Side.South => { return IVec2.make(dungeon_door_column(), dungeon_rows() - 1) }
Side.West => { return IVec2.make(0, dungeon_door_row()) }
_ => { return IVec2.make(dungeon_columns() - 1, dungeon_door_row()) }
}
}
@Namespace(Dungeon) function dungeon_set_exit(side: int, glyph: int) -> void {
let first = dungeon_exit_cell(side)
var second = IVec2.add(first, IVec2.make(0, 1))
if side <= Side.South { second = IVec2.add(first, IVec2.make(1, 0)) }
Map.set(x: first.x, y: first.y, glyph: glyph)
Map.set(x: second.x, y: second.y, glyph: glyph)
}
# where a body of w x h px starts after entering from `side`, `inset` px inside the edge
@Namespace(Dungeon) function dungeon_entry_point(side: int, inset: int, w: int, h: int) -> IVec2 {
let tile = rt_map_tile_px
let across_x = (dungeon_door_column() + 1) * tile - w / 2
let across_y = dungeon_door_row() * tile + (2 * tile - h) / 2
match side {
Side.North => { return IVec2.make(across_x, inset) }
Side.South => { return IVec2.make(across_x, dungeon_rows() * tile - inset - h) }
Side.West => { return IVec2.make(inset, across_y) }
_ => { return IVec2.make(dungeon_columns() * tile - inset - w, across_y) }
}
}
@Namespace(Dungeon) function dungeon_opposite(side: int) -> int { return side ^ 1 }
@Namespace(Dungeon) function dungeon_at_edge(tile: IVec2) -> bool {
return (tile.x == 0) or (tile.y == 0) or (tile.x == dungeon_columns() - 1) or (tile.y == dungeon_rows() - 1)
}

View file

@ -0,0 +1,6 @@
# ludic.dungeon — arena rooms for a room-to-room roguelite, built into the engine
# tilemap: mirrored cover styles, door lanes, exits by side, entry points.
package "ludic.dungeon"
version "0.1.0"
kind source
provides "Dungeon"

View file

@ -21,6 +21,9 @@ event cancellable DamageAboutToApply { target: int = 0, src: int = 0, amount: in
event Damaged { target: int = 0, src: int = 0, amount: int = 0 }
event Died { e: int = 0, src: int = 0 }
event Healed { e: int = 0, amount: int = 0 }
event Crit { target: int = 0, src: int = 0 } # the attacker's crit_pct fired
var combat_reflecting: bool = false # thorns never reflect thorns
# the mutable channel for lever 6: listeners rewrite the pending damage here.
var combat_pending: int = 0
@ -47,7 +50,11 @@ var combat_pending: int = 0
let fhp = World.field_id(P, "hp")
if fhp < 0 { return 0 }
combat_pending = combat_mitigate(target, amount)
# the attacker's build: damage_pct, then a crit_pct chance to double
var raw = amount * stats_field(src, "damage_pct", 100) / 100
let crit = stats_field(src, "crit_pct", 0)
if (crit > 0) and Random.chance(percent: crit) { raw = raw * 2; emit Crit(target: target, src: src) }
combat_pending = combat_mitigate(target, raw)
if emit DamageAboutToApply(target: target, src: src, amount: combat_pending) != 0 { return 0 }
var dmg = combat_pending
if dmg < 0 { dmg = 0 }
@ -57,6 +64,17 @@ var combat_pending: int = 0
World.set(target, P, fhp, hp)
emit Damaged(target: target, src: src, amount: dmg)
if hp <= 0 { emit Died(e: target, src: src) }
# the attacker's leech, and the defender's thorns (never reflected back again)
if dmg > 0 {
let leech = stats_field(src, "leech_pct", 0)
if (leech > 0) and Random.chance(percent: leech) { combat_heal(src, stats_field(src, "leech_hp", 1)) }
let thorns = stats_field(target, "thorns", 0)
if (thorns > 0) and (src != target) and (not combat_reflecting) and (World.has(src, P) != 0) {
combat_reflecting = true
combat_damage(src, target, thorns)
combat_reflecting = false
}
}
return dmg
}

View file

@ -10,11 +10,58 @@
# Stat codes (the `stat` argument everywhere below):
# 0 = max_hp 1 = atk 2 = def 3 = spd 4 = max_mp
enum StatKind { MaxHp, Attack, Defense, Speed, MaxMp, DamagePct, CritPct, LeechPct, Thorns, FireRatePct } # Stats.* stat codes
enum ModifyOp { Flat, Percent } # Stats.modify op
# The build stats Combat.damage applies by itself: the attacker's damage_pct and
# crit_pct (a crit doubles), its leech_pct chance to heal leech_hp on a hit, the
# defender's thorns dealt back, and fire_rate_pct the weapon system reads.
property Stats {
hp: int = 0, max_hp: int = 0,
mp: int = 0, max_mp: int = 0,
atk: int = 0, def: int = 0, spd: int = 0,
level: int = 1, xp: int = 0
level: int = 1, xp: int = 0,
damage_pct: int = 100, crit_pct: int = 0, leech_pct: int = 0, leech_hp: int = 1, thorns: int = 0, fire_rate_pct: int = 100
}
# a Stats field of an entity by name, or `fallback` when absent
function stats_field(e: int, name: pointer, fallback: int) -> int {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return fallback }
let f = World.field_id(P, name)
if f < 0 { return fallback }
return World.get(e, P, f)
}
# the Stats field a stat code names
function stats_field_name(stat: int) -> pointer {
match stat {
StatKind.MaxHp => { return "max_hp" }
StatKind.Attack => { return "atk" }
StatKind.Defense => { return "def" }
StatKind.Speed => { return "spd" }
StatKind.MaxMp => { return "max_mp" }
StatKind.DamagePct => { return "damage_pct" }
StatKind.CritPct => { return "crit_pct" }
StatKind.LeechPct => { return "leech_pct" }
StatKind.Thorns => { return "thorns" }
_ => { return "fire_rate_pct" }
}
}
# Stats.add(e, stat, amount): change a base stat in place (a permanent upgrade)
@Namespace(Stats) function stats_add(e: int, stat: int, amount: int) -> void {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return }
let f = World.field_id(P, stats_field_name(stat))
if f >= 0 { World.set(e, P, f, World.get(e, P, f) + amount) }
}
# Stats.scale_hp(e, percent): hp and max_hp scaled (a floor's difficulty)
@Namespace(Stats) function stats_scale_hp(e: int, percent: int) -> void {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return }
let fhp = World.field_id(P, "hp")
let fmax = World.field_id(P, "max_hp")
let hp = World.get(e, P, fhp) * percent / 100
World.set(e, P, fhp, hp)
World.set(e, P, fmax, hp)
}
# a single timed modifier, carried on its own entity so the stack is dynamic.

View file

@ -23,11 +23,14 @@ property Vision { range: int = 140, fov: int = 360, scan: int = 6, scan_t: int =
property Memory { has_target: int = 0, target: int = 0, last_x: int = 0, last_y: int = 0, alertness: int = 0, ttl: int = 0 }
# model: 0 FSM, 1 utility, 2 behaviour-tree. state (FSM): 0 patrol,1 chase,2 attack,3 flee.
enum BrainModel { StateMachine, Utility, BehaviourTree } # Brain.model
enum AiState { Patrol, Chase, Attack, Flee } # Brain.state / DecisionMade.action
property Brain {
model: int = 0, state: int = 0,
attack_range: int = 40, flee_pct: int = 0,
intent_x: int = 0, intent_y: int = 0, want_fire: int = 0,
home_x: int = 0, home_y: int = 0, seed: int = 1
home_x: int = 0, home_y: int = 0, seed: int = 1,
hunt_blind: int = 0 # 1 = with no target in sight, seek the nearest hostile anyway (no idle patrol)
}
property Follower { leader: int = 0 - 1, distance: int = 40, mode: int = 0 } # mode 0 follow,1 guard,2 aggressive
property Steering { separate: int = 0, cohere: int = 0, align: int = 0, radius: int = 48 }
@ -147,9 +150,46 @@ function brain_set_state(e: int, st: int) -> void {
}
# steer the Brain intent toward / away from a point (8-way signed intent).
# the Solids config (tile size / wall glyph) when the game declared one, else 0
function ai_solid_tile() -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 0 }
let se = World.query_next(ps, 0)
if se < 0 { return 0 }
let ft = World.field_id(ps, "tile")
if ft < 0 { return 0 }
return World.get(se, ps, ft)
}
function ai_solid_wall() -> int {
let ps = World.prop_id("Solids")
let se = World.query_next(ps, 0)
return World.get(se, ps, World.field_id(ps, "wall"))
}
# move toward (tx,ty). With a tilemap, a blocked straight line is routed around
# obstacles with Grid.a_star on the tile grid: the intent points at the next
# waypoint, so bodies flow around pillars instead of pushing into them.
function brain_seek(e: int, tx: int, ty: int) -> void {
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
let ts = ai_solid_tile()
if ts > 0 {
let wall = ai_solid_wall()
let cx = px / ts; let cy = py / ts
let gx = tx / ts; let gy = ty / ts
if (cx != gx) or (cy != gy) {
if not Grid.line_of_sight(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall) {
let path = Grid.a_star(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall)
if len(path) > 1 {
let nx = path[1].x * ts + ts / 2
let ny = path[1].y * ts + ts / 2
ai_set("Brain", e, "intent_x", sign_i(nx - (px + ts / 2)))
ai_set("Brain", e, "intent_y", sign_i(ny - (py + ts / 2)))
return
}
}
}
}
ai_set("Brain", e, "intent_x", sign_i(tx - px))
ai_set("Brain", e, "intent_y", sign_i(ty - py))
}
@ -225,9 +265,36 @@ function ai_decide_fsm(e: int) -> void {
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, tx, ty) }
}
}
} else {
let prey = brain_nearest_hostile(e)
if (ai_get("Brain", e, "hunt_blind") == 1) and (prey >= 0) {
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, ai_get("Position", prey, "x"), ai_get("Position", prey, "y")) }
} else {
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
}
}
}
# the nearest entity hostile to e (by Faction) that has a Position, or -1
function brain_nearest_hostile(e: int) -> int {
let PF = World.prop_id("Faction")
let PP = World.prop_id("Position")
if (PF < 0) or (PP < 0) { return 0 - 1 }
let fac = Faction.id_of(e)
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
var best = 0 - 1
var bestd = 0
var o = World.query_next(PF, 0)
while o >= 0 {
if (o != e) and (World.has(o, PP) != 0) and (Faction.hostile(fac, Faction.id_of(o)) == 1) {
let dx = ai_get("Position", o, "x") - px
let dy = ai_get("Position", o, "y") - py
let d = dx * dx + dy * dy
if (best < 0) or (d < bestd) { best = o; bestd = d }
}
o = World.query_next(PF, o + 1)
}
return best
}
# --- (b) utility AI: score each action, act on the highest ---
@ -386,6 +453,7 @@ function ai_decide_bt(e: int) -> void {
# Ai.* convenience
# ---------------------------------------------------------------------------
@Namespace(Ai) function ai_set_model(e: int, model: int) -> void { ai_set("Brain", e, "model", model) }
@Namespace(Ai) function ai_seek(e: int, tx: int, ty: int) -> void { brain_seek(e, tx, ty) } # path-aware move-toward
@Namespace(Ai) function ai_state(e: int) -> int { return ai_get("Brain", e, "state") }
@Namespace(Ai) function ai_target(e: int) -> int {
if ai_get("Memory", e, "has_target") == 1 { return ai_get("Memory", e, "target") }

Some files were not shown because too many files have changed in this diff Show more