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:
parent
e9c2c51bc3
commit
ad548840c7
139 changed files with 58981 additions and 43671 deletions
223
LANGUAGE.md
223
LANGUAGE.md
|
|
@ -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
|
||||
|
|
|
|||
3
changes/ai-seek-pathing.md
Normal file
3
changes/ai-seek-pathing.md
Normal 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.
|
||||
3
changes/audio-link-windowed.md
Normal file
3
changes/audio-link-windowed.md
Normal 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.
|
||||
3
changes/become-anywhere.md
Normal file
3
changes/become-anywhere.md
Normal 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.
|
||||
3
changes/component-access.md
Normal file
3
changes/component-access.md
Normal 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
3
changes/countdown.md
Normal 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.
|
||||
3
changes/cursor-confine-mode3.md
Normal file
3
changes/cursor-confine-mode3.md
Normal 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).
|
||||
3
changes/duplicate-global-diagnostic.md
Normal file
3
changes/duplicate-global-diagnostic.md
Normal 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
3
changes/engine-enums.md
Normal 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:)`.
|
||||
3
changes/engine-ui-events.md
Normal file
3
changes/engine-ui-events.md
Normal 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`).
|
||||
3
changes/enum-typed-machines.md
Normal file
3
changes/enum-typed-machines.md
Normal 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.
|
||||
3
changes/fmt-paren-spacing.md
Normal file
3
changes/fmt-paren-spacing.md
Normal 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)`.
|
||||
3
changes/global-initializers.md
Normal file
3
changes/global-initializers.md
Normal 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
3
changes/half-the-game.md
Normal 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
3
changes/ivec2-members.md
Normal 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.
|
||||
3
changes/key-constants-namespaces.md
Normal file
3
changes/key-constants-namespaces.md
Normal 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
3
changes/layer-queries.md
Normal 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
4
changes/less-to-write.md
Normal 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
3
changes/managers.md
Normal 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.
|
||||
3
changes/namespace-named-args.md
Normal file
3
changes/namespace-named-args.md
Normal 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
3
changes/overlay-phase.md
Normal 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
3
changes/prefabs.md
Normal 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
3
changes/prefs-package.md
Normal 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).
|
||||
3
changes/reserved-word-functions.md
Normal file
3
changes/reserved-word-functions.md
Normal 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.
|
||||
3
changes/self-in-spawn-hooks.md
Normal file
3
changes/self-in-spawn-hooks.md
Normal 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.
|
||||
3
changes/shooter-aim-centre.md
Normal file
3
changes/shooter-aim-centre.md
Normal 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
3
changes/solids-solid2.md
Normal 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.
|
||||
3
changes/sprite-atlas-ids.md
Normal file
3
changes/sprite-atlas-ids.md
Normal 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
3
changes/sprite-strip.md
Normal 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`).
|
||||
3
changes/tileskin-system.md
Normal file
3
changes/tileskin-system.md
Normal 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
3
changes/ui-close.md
Normal 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)`.
|
||||
3
changes/ui-ids-anywhere.md
Normal file
3
changes/ui-ids-anywhere.md
Normal 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.
|
||||
19
docs/language/angle/angle-diff_degrees.md
Normal file
19
docs/language/angle/angle-diff_degrees.md
Normal 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() }
|
||||
```
|
||||
21
docs/language/assets/assets-enqueue_dir.md
Normal file
21
docs/language/assets/assets-enqueue_dir.md
Normal 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")
|
||||
```
|
||||
21
docs/language/assets/assets-font.md
Normal file
21
docs/language/assets/assets-font.md
Normal 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")
|
||||
```
|
||||
20
docs/language/audio/audio-define.md
Normal file
20
docs/language/audio/audio-define.md
Normal 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")
|
||||
```
|
||||
19
docs/language/audio/audio-named.md
Normal file
19
docs/language/audio/audio-named.md
Normal 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")
|
||||
```
|
||||
19
docs/language/camera/camera-shake_for.md
Normal file
19
docs/language/camera/camera-shake_for.md
Normal 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) } }
|
||||
```
|
||||
7
docs/language/file/_section.md
Normal file
7
docs/language/file/_section.md
Normal 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.
|
||||
19
docs/language/file/file-close.md
Normal file
19
docs/language/file/file-close.md
Normal 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)
|
||||
```
|
||||
20
docs/language/file/file-open.md
Normal file
20
docs/language/file/file-open.md
Normal 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 }
|
||||
```
|
||||
20
docs/language/file/file-read.md
Normal file
20
docs/language/file/file-read.md
Normal 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)
|
||||
```
|
||||
21
docs/language/file/file-seek.md
Normal file
21
docs/language/file/file-seek.md
Normal 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)
|
||||
```
|
||||
19
docs/language/file/file-tell.md
Normal file
19
docs/language/file/file-tell.md
Normal 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)
|
||||
```
|
||||
20
docs/language/file/file-write.md
Normal file
20
docs/language/file/file-write.md
Normal 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))
|
||||
```
|
||||
7
docs/language/font/_section.md
Normal file
7
docs/language/font/_section.md
Normal 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.
|
||||
23
docs/language/font/font-load.md
Normal file
23
docs/language/font/font-load.md
Normal 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()
|
||||
}
|
||||
```
|
||||
7
docs/language/fx/_section.md
Normal file
7
docs/language/fx/_section.md
Normal 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.
|
||||
22
docs/language/fx/fx-clear.md
Normal file
22
docs/language/fx/fx-clear.md
Normal 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()
|
||||
}
|
||||
```
|
||||
19
docs/language/fx/fx-number.md
Normal file
19
docs/language/fx/fx-number.md
Normal 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) }
|
||||
```
|
||||
19
docs/language/fx/fx-sparks.md
Normal file
19
docs/language/fx/fx-sparks.md
Normal 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) }
|
||||
```
|
||||
20
docs/language/input/input-move_i.md
Normal file
20
docs/language/input/input-move_i.md
Normal 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)
|
||||
```
|
||||
19
docs/language/ivec2/ivec2-along.md
Normal file
19
docs/language/ivec2/ivec2-along.md
Normal 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)
|
||||
```
|
||||
19
docs/language/ivec2/ivec2-distance2.md
Normal file
19
docs/language/ivec2/ivec2-distance2.md
Normal 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 { … }
|
||||
```
|
||||
19
docs/language/ivec2/ivec2-heading.md
Normal file
19
docs/language/ivec2/ivec2-heading.md
Normal 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)
|
||||
```
|
||||
19
docs/language/ivec2/ivec2-step.md
Normal file
19
docs/language/ivec2/ivec2-step.md
Normal 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)
|
||||
```
|
||||
19
docs/language/ivec2/ivec2-within.md
Normal file
19
docs/language/ivec2/ivec2-within.md
Normal 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() }
|
||||
```
|
||||
19
docs/language/list/list-sample.md
Normal file
19
docs/language/list/list-sample.md
Normal 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)
|
||||
```
|
||||
20
docs/language/map/map-border.md
Normal file
20
docs/language/map/map-border.md
Normal 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: '#')
|
||||
```
|
||||
20
docs/language/map/map-fill.md
Normal file
20
docs/language/map/map-fill.md
Normal 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: '.')
|
||||
```
|
||||
19
docs/language/map/map-get.md
Normal file
19
docs/language/map/map-get.md
Normal 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) == '.' { … }
|
||||
```
|
||||
19
docs/language/map/map-height.md
Normal file
19
docs/language/map/map-height.md
Normal 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() { … }
|
||||
```
|
||||
19
docs/language/map/map-is_solid.md
Normal file
19
docs/language/map/map-is_solid.md
Normal 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 }
|
||||
```
|
||||
19
docs/language/map/map-is_solid_at.md
Normal file
19
docs/language/map/map-is_solid_at.md
Normal 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 }
|
||||
```
|
||||
20
docs/language/map/map-random_cell.md
Normal file
20
docs/language/map/map-random_cell.md
Normal 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))
|
||||
```
|
||||
19
docs/language/map/map-random_cell_far.md
Normal file
19
docs/language/map/map-random_cell_far.md
Normal 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)
|
||||
```
|
||||
19
docs/language/map/map-rect.md
Normal file
19
docs/language/map/map-rect.md
Normal 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
|
||||
```
|
||||
19
docs/language/map/map-set.md
Normal file
19
docs/language/map/map-set.md
Normal 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
|
||||
```
|
||||
19
docs/language/map/map-to_tile.md
Normal file
19
docs/language/map/map-to_tile.md
Normal 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))
|
||||
```
|
||||
19
docs/language/map/map-width.md
Normal file
19
docs/language/map/map-width.md
Normal 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() { … }
|
||||
```
|
||||
7
docs/language/prefab/_section.md
Normal file
7
docs/language/prefab/_section.md
Normal 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.
|
||||
20
docs/language/prefab/prefab-spawn.md
Normal file
20
docs/language/prefab/prefab-spawn.md
Normal 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
|
||||
```
|
||||
19
docs/language/prefab/prefab-spawn_at.md
Normal file
19
docs/language/prefab/prefab-spawn_at.md
Normal 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))
|
||||
```
|
||||
19
docs/language/random/random-weighted.md
Normal file
19
docs/language/random/random-weighted.md
Normal 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)]
|
||||
```
|
||||
19
docs/language/screen/screen-bar.md
Normal file
19
docs/language/screen/screen-bar.md
Normal 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)
|
||||
```
|
||||
19
docs/language/sprite/sprite-draw_meter.md
Normal file
19
docs/language/sprite/sprite-draw_meter.md
Normal 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)
|
||||
```
|
||||
20
docs/language/sprite/sprite-strip.md
Normal file
20
docs/language/sprite/sprite-strip.md
Normal 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 } }
|
||||
```
|
||||
21
docs/language/structure/kw-prefab.md
Normal file
21
docs/language/structure/kw-prefab.md
Normal 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")
|
||||
```
|
||||
20
docs/language/types/type-countdown.md
Normal file
20
docs/language/types/type-countdown.md
Normal 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() }
|
||||
```
|
||||
7
docs/language/ui/_section.md
Normal file
7
docs/language/ui/_section.md
Normal 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.
|
||||
21
docs/language/ui/ui-build.md
Normal file
21
docs/language/ui/ui-build.md
Normal 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()
|
||||
}
|
||||
```
|
||||
21
docs/language/ui/ui-clicked.md
Normal file
21
docs/language/ui/ui-clicked.md
Normal 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 }
|
||||
}
|
||||
```
|
||||
21
docs/language/ui/ui-close.md
Normal file
21
docs/language/ui/ui-close.md
Normal 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() }
|
||||
}
|
||||
```
|
||||
21
docs/language/ui/ui-open.md
Normal file
21
docs/language/ui/ui-open.md
Normal 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) }
|
||||
}
|
||||
```
|
||||
21
docs/language/ui/ui-render.md
Normal file
21
docs/language/ui/ui-render.md
Normal 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() }
|
||||
}
|
||||
```
|
||||
19
docs/language/ui/ui-set_text.md
Normal file
19
docs/language/ui/ui-set_text.md
Normal 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}`)
|
||||
```
|
||||
21
docs/language/ui/ui-tick.md
Normal file
21
docs/language/ui/ui-tick.md
Normal 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())
|
||||
}
|
||||
```
|
||||
46
examples/library/combat_kit.ludic
Normal file
46
examples/library/combat_kit.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
57
examples/library/component_access.ludic
Normal file
57
examples/library/component_access.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
42
examples/library/managers.ludic
Normal file
42
examples/library/managers.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
25
examples/library/prefabs.ludic
Normal file
25
examples/library/prefabs.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
29
examples/library/scene_menus.ludic
Normal file
29
examples/library/scene_menus.ludic
Normal 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() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
51
examples/library/sprite_atlas.ludic
Normal file
51
examples/library/sprite_atlas.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -22,3 +22,5 @@ provides "Collider"
|
|||
provides "Solids"
|
||||
provides "Sprite"
|
||||
provides "Bounds"
|
||||
provides "SpriteAnim"
|
||||
provides "TileSkin"
|
||||
|
|
|
|||
124
packages/ludic.dungeon/dungeon.ludic
Normal file
124
packages/ludic.dungeon/dungeon.ludic
Normal 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)
|
||||
}
|
||||
6
packages/ludic.dungeon/package.ludic
Normal file
6
packages/ludic.dungeon/package.ludic
Normal 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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,10 +265,37 @@ 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 ---
|
||||
function ai_decide_utility(e: int) -> void {
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue