@moku-labs/game 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +89 -21
  2. package/dist/assets.d.mts +58 -6
  3. package/dist/assets.mjs +1630 -234
  4. package/dist/commands-BOxqlv7w.d.mts +99 -0
  5. package/dist/control.d.mts +3 -92
  6. package/dist/control.mjs +3 -757
  7. package/dist/define-BMo0pHe_.d.mts +51 -0
  8. package/dist/{registry-DlpRCibU.mjs → dev-CcwFrwok.mjs} +30 -1
  9. package/dist/index.d.mts +217 -29
  10. package/dist/index.mjs +6179 -727
  11. package/dist/inspect.d.mts +10 -8
  12. package/dist/inspect.mjs +2 -518
  13. package/dist/jsx-dev-runtime.d.mts +1 -1
  14. package/dist/jsx-runtime.d.mts +1 -1
  15. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  16. package/dist/run-tlcn1w7y.mjs +944 -0
  17. package/dist/{runtime-DiOTkDZz.d.mts → runtime-BstIkrhH.d.mts} +1 -1
  18. package/dist/sources-BooL2MsX.mjs +545 -0
  19. package/dist/testing.d.mts +305 -3
  20. package/dist/testing.mjs +2056 -2
  21. package/dist/types-BTycq8_Z.d.mts +1196 -0
  22. package/dist/{types-yg_ywtT-.d.mts → types-C0SLQsx0.d.mts} +230 -31
  23. package/dist/types-Cjf3ywai.d.mts +5982 -0
  24. package/dist/{define-sFoO3y6X.d.mts → types-D0uYTTC3.d.mts} +180 -51
  25. package/dist/{types-DWILGrPn.d.mts → types-DgwbzDLP.d.mts} +87 -19
  26. package/dist/types-KlE_GL1z.d.mts +1444 -0
  27. package/dist/{visual-BDUHSRvf.mjs → visual-NPrDkvUk.mjs} +19 -13
  28. package/package.json +17 -2
  29. package/dist/headless-CvamCUcR.mjs +0 -186
  30. package/dist/headless-KaSWcd0s.d.mts +0 -181
  31. package/dist/types-BxkNNYul.d.mts +0 -2840
  32. package/dist/types-CTPS9GBu.d.mts +0 -742
  33. package/dist/types-DD-QrG_z.d.mts +0 -588
  34. package/dist/types-DYLnSgMI.d.mts +0 -570
  35. package/dist/types-JNc_UQBo.d.mts +0 -2896
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **A 2D puzzle game engine where the game is a deterministic graph of business logic.**
4
4
 
5
- `@moku-labs/game` is a Layer-2 framework on [`@moku-labs/core`](https://github.com/moku-labs/core), written in TypeScript, with PixiJS v8 as a peer dependency. You write small nodes and edge tables. The engine runs them, commits state on the edges, saves at rest points and replays the same game without a screen. It is not a general-purpose engine and it ships no genre rules: no match-3, no merge, no physics. V1 is the logic half; V2 adds the screen: an own small ECS, projections from committed state, a Pixi v8 renderer loaded lazily, gestures as data components, typed asset keys and scenes as declarations. V3 adds the interface: choreographies as data, strings as data, text from MSDF fonts, screens written in JSX and laid out by Yoga, and sound as an effect a node awaits. V4 adds two doors for the editor: sources that read a running game and dev-only commands that drive it.
5
+ `@moku-labs/game` is a Layer-2 framework on [`@moku-labs/core`](https://github.com/moku-labs/core), written in TypeScript, with PixiJS v8 as a peer dependency. You write small nodes and edge tables. The engine runs them, commits state on the edges, saves at rest points and replays the same game without a screen. It is not a general-purpose engine and it ships no genre rules: no match-3, no merge, no physics. V1 is the logic half; V2 adds the screen: an own small ECS, projections from committed state, a Pixi v8 renderer loaded lazily, gestures as data components, typed asset keys and scenes as declarations. V3 adds the interface: choreographies as data, strings as data, text from MSDF fonts, screens written in JSX and laid out by Yoga, and sound as an effect a node awaits. V4 adds two doors for the editor: sources that read a running game and dev-only commands that drive it. V5 adds cosmetic effects as data: particles and WGSL filters as components, a frame loop for sprites, and the render numbers that say what they cost; then the production pack of the art, icons that wrap with their words, a text field, and visual tests.
6
6
 
7
7
  <br/>
8
8
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## Status
32
32
 
33
- V1, V2 and V3 are built. V4 has its engine side: the two doors described in [Doors for the editor](#doors-for-the-editor). The editor that uses them is a separate package. Everything after V4 is a plan and may change.
33
+ V1, V2 and V3 are built. V4 has its engine side: the two doors described in [Doors for the editor](#doors-for-the-editor). The editor that uses them is a separate package. V5 is built: the `effects` plugin, the `Frames` component, the `components` prop of the JSX tags and the render numbers; the production pack of `assets` (`--pack`, a v2 manifest with WebP atlas pages); icons inside wrapped text; the `input` tag, a text field; and visual tests in `@moku-labs/game/testing`. Everything after that is a plan and may change.
34
34
 
35
35
  | Milestone | State | Scope | Exit criterion |
36
36
  |---|---|---|---|
@@ -38,7 +38,7 @@ V1, V2 and V3 are built. V4 has its engine side: the two doors described in [Doo
38
38
  | V2 | built | `world`, `renderer`, `input`, `assets`, `scenes`, the `@moku-labs/game/assets` entry | A board is visible and items merge by drag; the same game still plays to the end headless |
39
39
  | V3 | built | `anim`, `i18n`, `text`, `ui`, `audio`, the `@moku-labs/game/jsx-runtime` entry | Popup, HUD and buttons with sound; the same game still plays to the end headless |
40
40
  | V4 | doors built | `/inspect` and `/control` entries | External tools can read and drive a game |
41
- | V5 | planned | `effects`, production mode of `assets`, visual test helpers | Not defined yet |
41
+ | V5 | built | `effects`, `Frames`, render passes and draw calls; the production pack of `assets`, icons in wrapped text, the `input` tag, visual tests in `/testing` | Not defined yet |
42
42
  | V6 | planned | `platform` | A template game runs on a phone |
43
43
 
44
44
  Rendering: WebGPU is preferred, with Pixi's WebGL fallback and an honest "unsupported device" screen when neither exists.
@@ -225,7 +225,7 @@ flowchart LR
225
225
 
226
226
  ## Plugins
227
227
 
228
- Five logic plugins are on every app; the nine screen plugins are the list `screen` a game spreads in; `audio` is opt-in, `[...screen, audioPlugin]`. `log` and `env` come from [`@moku-labs/common`](https://github.com/moku-labs/common) and sit on every plugin context as `ctx.log` and `ctx.env`.
228
+ Five logic plugins are on every app; the nine screen plugins are the list `screen` a game spreads in; `audio` and `effects` are opt-in, `[...screen, audioPlugin, effectsPlugin]`. `log` and `env` come from [`@moku-labs/common`](https://github.com/moku-labs/common) and sit on every plugin context as `ctx.log` and `ctx.env`.
229
229
 
230
230
  ### Built
231
231
 
@@ -237,15 +237,16 @@ Five logic plugins are on every app; the nine screen plugins are the list `scree
237
237
  | [`clock`](./src/plugins/clock/README.md) | Standard | Trusted time as an input: monotonic `now()` and one `elapsed` signal at the next due moment | `now()`, `scheduleAt(moment)`, `onElapsed(listener)`, `poke()`, `dueAt()` |
238
238
  | [`flow`](./src/plugins/flow/README.md) | Very Complex | The graph: runner, gate, inbox, effects gateway, features registry | `run()`, `onEnter(stage, callback)`, `walk(route, options?)`, `bookmark()`, `restore(bookmark)`, `describe()`, `state()`, `history()`, `setMode(mode)`, `gate.answer(answer)`, `gate.pointer(active)`, `gate.state()`, `inbox.post(event)`, `fx.handle(kind, handler, options?)`, `fx.dispatch(descriptor)`, `features.register(name, description)`, `features.all()`, `features.contributions(slotName)` |
239
239
  | [`world`](./src/plugins/world/README.md) | Very Complex | A zero-dependency ECS (`ecs`) and the projection from committed state to entities (`projection`): keyed reconcile of one `view(item)` function, retarget motions, a despawn queue, named layers | `ecs.spawn(owner, components)`, `ecs.query(...Components)`, `ecs.system(def)`, `ecs.set(entity, Component, patch)`, `ecs.changed(Component)`, `ecs.snapshot()`, `ecs.mode()`, `projection.mount(names, owner)`, `projection.setLayers(list)`, `projection.settle(entity)`, `projection.keyOf(entity)`, `projection.entityOf(projection, key)` |
240
- | [`renderer`](./src/plugins/renderer/README.md) | Very Complex | The Pixi v8 host loaded lazily (`host`), one `sync` system that owns every display object, the reference viewport fitted to `referenceSide` and `referenceLong` (`viewport`) | `host.ready()`, `host.kind()`, `host.canvas()`, `sync.hitTest(x, y, accept)`, `sync.textures.provide(fn)`, `sync.displayOf(entity)`, `viewport.toReference(x, y)`, `viewport.size()` |
240
+ | [`renderer`](./src/plugins/renderer/README.md) | Very Complex | The Pixi v8 host loaded lazily (`host`), one `sync` system that owns every display object, the reference viewport fitted to `referenceSide` and `referenceLong` (`viewport`), the frame numbers: render passes in every build, draw calls in a dev build | `host.ready()`, `host.kind()`, `host.canvas()`, `host.device()`, `sync.hitTest(x, y, accept)`, `sync.textures.provide(fn)`, `sync.filters.set(entity, slots)`, `sync.displayOf(entity)`, `viewport.toReference(x, y)`, `viewport.size()`, `stats()` |
241
241
  | [`input`](./src/plugins/input/README.md) | Standard | Gestures as data components: `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`; the drop target names the intent that reaches `flow.gate` | `tap(target)`, `press(target)`, `drag(from, to)`, `swipe(target, direction)` |
242
242
  | [`assets`](./src/plugins/assets/README.md) | Complex | The manifest, five load tiers, graph-driven preload, a texture budget with LRU unload; typed keys from the `assets` entry | `load(bundle)`, `unload(bundle)`, `isLoaded(bundle)`, `texture(key)`, `usage()` |
243
243
  | [`scenes`](./src/plugins/scenes/README.md) | Standard | A scene as a declaration: bundle, layers, projections, `music`. A node names its scene; the runner switches through `flow.onEnter` | `current()` |
244
- | [`anim`](./src/plugins/anim/README.md) | Complex | The one tween core, installed into `world.projection` as the `TweenDriver`; timelines as frozen data built from typed slots; `defineMotion` sugar for the enter, exit and change hooks | `play(animation, slots)`, `finishAll()`, `active()`, `onMark(fn)` |
244
+ | [`anim`](./src/plugins/anim/README.md) | Complex | The one tween core, installed into `world.projection` as the `TweenDriver`; timelines as frozen data built from typed slots; `defineMotion` sugar for the enter, exit and change hooks; the `Frames` component, a frame loop that walks a sprite's textures for its whole life | `play(animation, slots)`, `finishAll()`, `active()`, `onMark(fn)` |
245
245
  | [`i18n`](./src/plugins/i18n/README.md) | Complex | Strings as data: `tr(key, params)` is a `Message`, ICU MessageFormat compiled to plain functions by `compileStrings` on the `assets` door, `Part[]` at run time, never a joined string | `locale()`, `setLocale(locale)`, `format(message, locale?)`, `plain(message)`, `has(key)`, `locales()` |
246
246
  | [`text`](./src/plugins/text/README.md) | Complex | The `Text` component, `label()`, `defineTextStyles()`, the tags `<b> <i> <color=#hex> <icon=key>`, measurement from the font's advance table, BitmapText from the MSDF fonts of a bundle | `measure(content, style)`, `styles()` |
247
- | [`ui`](./src/plugins/ui/README.md) | Very Complex | A screen is a projection whose `view` returns JSX; the tree is reconciled by identity into entities, laid out by one Yoga solve per change, `Box` is the rest pose; `defineComponent` with `local` and `outcomes`, `popup` as an effect, `defineStyle`, `defineTokens` | `tree()`, `find(key)`, `lint()` |
247
+ | [`ui`](./src/plugins/ui/README.md) | Very Complex | A screen is a projection whose `view` returns JSX; the tree is reconciled by identity into entities, laid out by one Yoga solve per change, `Box` is the rest pose; `defineComponent` with `local` and `outcomes`, `popup` as an effect, `defineStyle`, `defineTokens`; every tag takes `components`, extra component values such as a filter; the `input` tag is a text field whose text lives in `local` | `tree()`, `find(key)`, `lint()`, `fill(key, value)` |
248
248
  | [`audio`](./src/plugins/audio/README.md) | Standard | Opt-in. Buses `master`, `music`, `sfx`; `sfx()` descriptors of `anim` and `music()` descriptors handled here; the scene's `music`; volumes read from the committed player through `volumes` | `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `unlocked()` |
249
+ | [`effects`](./src/plugins/effects/README.md) | Complex | Opt-in. Particles: `defineEmitter` and the `Emitter` component, one Pixi `ParticleContainer` per instance stepped by the engine clock. Filters: `defineFilter` turns a WGSL fragment body into a flat component that `tween` drives; `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha` ship built in. Budgets warn once per crossing; a dev build compiles each WGSL kind before its first instance. Headless it draws nothing | `stats()` |
249
250
 
250
251
  ```mermaid
251
252
  flowchart LR
@@ -285,6 +286,10 @@ flowchart LR
285
286
  AU --> F
286
287
  AU --> A
287
288
  AU --> S
289
+ EF["effects"] --> F
290
+ EF --> W
291
+ EF --> R
292
+ EF --> A
288
293
  classDef u fill:#0b7285,stroke:#08525f,color:#fff;
289
294
  classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
290
295
  classDef s fill:#5c940d,stroke:#3d6208,color:#fff;
@@ -292,10 +297,10 @@ flowchart LR
292
297
  class G u
293
298
  class F,T,L,M,C m
294
299
  class W,R,I,A,S s
295
- class AN,N,X,U,AU v
300
+ class AN,N,X,U,AU,EF v
296
301
  ```
297
302
 
298
- An arrow means "depends on"; for the V3 plugins the edges to `time` and the edges a nearer plugin already implies are left out for space, the plugin READMEs list them in full. `time`, `model` and `clock` depend on nothing. The logic plugins are registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; the screen set `screen` follows as `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`; a game that wants sound appends `audioPlugin`. Without a document the screen plugins are inert: the same app starts in plain Bun, Yoga included.
303
+ An arrow means "depends on"; for the V3 plugins the edges to `time` and the edges a nearer plugin already implies are left out for space, the plugin READMEs list them in full. `time`, `model` and `clock` depend on nothing. The logic plugins are registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; the screen set `screen` follows as `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`; a game that wants sound appends `audioPlugin`, and one that wants particles and filters appends `effectsPlugin`. A game without `effectsPlugin` carries none of its systems, filters or WGSL in its bundle. Without a document the screen plugins are inert: the same app starts in plain Bun, Yoga included.
299
304
 
300
305
  ### Planned
301
306
 
@@ -303,7 +308,6 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
303
308
 
304
309
  | Plugin | Milestone | Tier | Depends on | Will own |
305
310
  |---|---|---|---|---|
306
- | `effects` | V5 | Complex | `flow`, `world`, `renderer` | Particles, filters, frames |
307
311
  | `platform` | V6 | Standard | `lifecycle`, `flow` | The native provider: background, system dialogs |
308
312
 
309
313
  ### Root exports
@@ -312,8 +316,8 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
312
316
  |---|---|---|
313
317
  | `createApp` | function | Creates a game application |
314
318
  | `createPlugin` | function | Creates a game plugin bound to the engine's config and events |
315
- | `defineGame` | function | Returns the authoring helpers typed with the game's `player`, `session`, `assets`, `bundles`, `strings` and `textStyles`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, and from V3 `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `defineAnimation`, `frames`, `sfx`, `play`, `music` |
316
- | `defineFeature` | function | Turns a feature description into a plugin. V3 keys: `projections`, `animations`, `ui`, `strings`, `textStyles` |
319
+ | `defineGame` | function | Returns the authoring helpers typed with the game's `player`, `session`, `assets`, `bundles`, `strings`, `textStyles` and `emitters`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, from V3 `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `defineAnimation`, `frames`, `sfx`, `play`, `music`, and from V5 `Frames`, `defineEmitter`, `Emitter`, `Displacement`: a texture key the game does not have or an effect id it did not declare does not compile |
320
+ | `defineFeature` | function | Turns a feature description into a plugin. V3 keys: `projections`, `animations`, `ui`, `strings`, `textStyles`. V5 keys: `emitters`, `filters` |
317
321
  | `type`, `exit`, `to`, `slot` | functions | Type tag of a payload, and the three graph helpers for edge targets and slots |
318
322
  | `schedule`, `guide`, `hint` | functions | Effect descriptors: next due moment, tutorial narrowing of the gate, cosmetic hint |
319
323
  | `SaveUnreadableError` | class | Thrown by `model.store.load()` when the save cannot be read |
@@ -321,27 +325,28 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
321
325
  | `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite` | components | The display components of `renderer` |
322
326
  | `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer` | components | Gestures as data, from `input`; `PointerOver` marks the view under an idle mouse or pen (the `hover` style state) |
323
327
  | `defineBundles`, `load`, `defineScene` | functions | Bundle and scene declarations |
324
- | `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation` | functions and a component | Choreography as frozen data. `play(animation, slots)` is the effect a node awaits; `spawn` makes a temporary entity (flying coins, a toast sign) that the timeline despawns when it ends; `sfx` and `haptic` are descriptors `audio` and `platform` handle; `external` throws until Spine arrives |
328
+ | `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames` | functions and components | Choreography as frozen data. `play(animation, slots)` is the effect a node awaits; `spawn` makes a temporary entity (flying coins, a toast sign) that the timeline despawns when it ends; `sfx` and `haptic` are descriptors `audio` and `platform` handle; `external` throws until Spine arrives. `Frames({ keys, fps, loop, playing })` walks a sprite through its textures for the entity's whole life, a spinning coin |
325
329
  | `tr` | function | `tr(key, params?)` builds a frozen `Message`; no locale is read at the call site |
326
330
  | `Text`, `label`, `defineTextStyles` | component and functions | Words on the screen and the text styles a feature registers |
327
331
  | `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite` | functions and components | Interface components, the popup effect, the style vocabulary, the rect of an element |
328
332
  | `music` | function | `music(key \| null, { fadeMs? })`, the awaited effect that switches the music track |
329
- | `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin` | plugin instances | For `depends` and `ctx.require` in game plugins; `screen` is the list of the nine screen plugins |
330
- | `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio` | type namespaces | All public types of one plugin |
333
+ | `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha` | functions and components | Effects as data, drawn by `effectsPlugin`. `defineEmitter(id, config)` describes a particle effect that a feature lists under `emitters`; `Emitter({ effect, active })` runs it on its entity. `defineFilter(id, { wgsl, uniforms, passes?, padding? })` returns a filter component type that a feature lists under `filters`; a filter on a ui element goes in its `components` prop |
334
+ | `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin` | plugin instances | For `depends` and `ctx.require` in game plugins; `screen` is the list of the nine screen plugins |
335
+ | `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`, `Effects` | type namespaces | All public types of one plugin |
331
336
 
332
337
  ### Other entries
333
338
 
334
339
  | Entry | Runs in | Exports |
335
340
  |---|---|---|
336
- | `@moku-labs/game/testing` | anywhere | The headless helpers, see below |
337
- | `@moku-labs/game/assets` | node and bun only | `scanAssets`, `emitKeys`, `emitManifest`, `compileStrings`, `checkStrings`, `runCli`. A game runs it as `bun run assets:keys`: it writes the manifest, the typed asset keys and, next to them, `generated/strings.ts` with one `strings.<locale>.ts` per locale; `--check` fails when any of them is out of date |
341
+ | `@moku-labs/game/testing` | node and bun only | The headless helpers and the visual tests, see below. The visual runner reads and writes baseline files |
342
+ | `@moku-labs/game/assets` | node and bun only | `scanAssets`, `emitKeys`, `emitManifest`, `compileStrings`, `checkStrings`, `packAssets`, `runCli`. A game runs it as `bun run assets:keys`: it writes the manifest, the typed asset keys and, next to them, `generated/strings.ts` with one `strings.<locale>.ts` per locale; `--check` fails when any of them is out of date; `--pack <dir>` writes the production build: WebP atlas pages, content-hashed names and a v2 manifest |
338
343
  | `@moku-labs/game/inspect` | anywhere, production included | `read`, `watch`, `defineSource`, the catalogue `sources` and the types `Source`, `InputSchema`, `InputOf`. See [Doors for the editor](#doors-for-the-editor) |
339
344
  | `@moku-labs/game/control` | dev builds only | `run`, `defineCommand`, `controlRefused`, the catalogue `commands` and the types `Command`, `Ran`. See [Doors for the editor](#doors-for-the-editor) |
340
345
  | `@moku-labs/game/jsx-runtime`, `@moku-labs/game/jsx-dev-runtime` | anywhere | `jsx`, `jsxs`, `jsxDEV`, `Fragment` and the `JSX` namespace that `"jsxImportSource": "@moku-labs/game"` resolves to. A game never imports them by hand |
341
346
 
342
347
  ### Testing entry
343
348
 
344
- `@moku-labs/game/testing` re-exports the headless helpers.
349
+ `@moku-labs/game/testing` re-exports the headless helpers and the visual tests.
345
350
 
346
351
  | Export | Signature | Purpose |
347
352
  |---|---|---|
@@ -351,9 +356,64 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
351
356
  | `fakeClock` | `(start = 0) => FakeClock` | A `ClockSource` with `advance(ms)` and `set(moment)` |
352
357
  | `memory` | `(fixture?: { state: SaveDoc; version: number }) => PlayerStateProvider & { calls: ProviderCall[] }` | In-memory save provider. It keeps what it was committed and records every call |
353
358
  | `saveOf` | `(player: Json, seed?: number) => SaveDoc` | Builds a save document for a fixture |
359
+ | `defineVisualTest` | `(name: string, test: { start: VisualStart; steps: readonly VisualStep[] }) => VisualTest` | A visual test as frozen data: where it starts and its steps |
360
+ | `runVisualTests` | `(setup: VisualSetup, tests: readonly VisualTest[], options?: VisualOptions) => Promise<VisualReport>` | Plays the tests and compares every checkpoint with its baseline files |
361
+ | `parseVisualArgv` | `(argv: readonly string[]) => { update?; pixels?; only?; dir? }` | Reads `--update`, `--no-pixels`, `--only <name>` and `--dir <path>` from a command line |
354
362
 
355
363
  A `HeadlessGame` has `walk(route)`, `answer(answer)`, `state()`, `history()` and `stop()`.
356
364
 
365
+ ### Visual tests
366
+
367
+ A visual test is data: where the game starts, then steps. A step is a `/control` command by its short name (`answer`, `tap`, `drag`, `key`, `fill`, `walk`, `restore`, `step`, `pause`, `resume`, `reducedMotion`) with that command's input, or a checkpoint.
368
+
369
+ ```ts
370
+ // tests/visual/reward-popup.visual.ts
371
+ export const rewardPopup = defineVisualTest("reward-popup", {
372
+ start: { player: fixtures.ready, checkpoint: "home" },
373
+ steps: [
374
+ { tap: { key: "play" } },
375
+ { answer: { intent: "deliver", payload: { orderId: "o1" } } },
376
+ { checkpoint: "open" },
377
+ { tap: { key: "claim" } },
378
+ { checkpoint: "claimed" }
379
+ ]
380
+ });
381
+
382
+ // tests/visual/run.ts, `bun tests/visual/run.ts --update`
383
+ const report = await runVisualTests({ app: () => createScreenGame().app, page: { url: "http://localhost:3000/" } }, [rewardPopup]);
384
+ process.exitCode = report.ok ? 0 : 1;
385
+ ```
386
+
387
+ A checkpoint settles the motions and saves three baseline files next to the test: `<dir>/<test>/<checkpoint>/state.json`, `describe.json` and `screen.webp`. A missing file is written; `--update` rewrites them; any other file is compared. The headless leg plays every test in plain Bun and compares `state.json` and `describe.json` exactly, so it runs in `bun run test`. The pixel leg plays the same steps on the dev page in Chrome with WebGPU, on a Mac only, and compares `screen.webp` with a tolerance; a pixel difference with the same state and describe is reported as a rendering regression. The page is the contract: a dev build that sets `globalThis.game` to the app and `globalThis.doors` to `{ read, watch, sources, run, commands }`. No CI job runs pixels.
388
+
389
+ #### Pixels
390
+
391
+ The pixel leg runs on a Mac only. `pixels` is on by default when the setup has a `page` and the platform is `darwin`, and off everywhere else. No CI job runs it: a Linux runner has no WebGPU worth trusting.
392
+
393
+ - **`playwright-core`** is an optional peer dependency. The leg loads it with a dynamic import, so a game that never runs pixels never installs it. Add it with `bun add -d playwright-core`. Without it the run stops with "The pixel leg needs playwright-core"; `--no-pixels` runs the headless leg alone.
394
+ - **The browser.** `page.browser` when the setup names one (`{ channel }` or `{ executablePath }`). Otherwise Playwright's own Chromium, whose version `playwright-core` pins, so a Chrome update never moves the baselines: install it with `bunx playwright-core install chromium`. Otherwise the system Google Chrome. Chrome starts with WebGPU on Metal; a page that draws with anything else fails the leg.
395
+ - **The page.** 390 CSS px wide at a device scale of 2, as tall as the game's inert frame: 520 for a 4:3 portrait game, so `screen.webp` is 780 × 1040.
396
+ - **Game time.** The leg drives the clock itself, so two runs draw the same pixels: a dash phase, a pulse or a particle reads the same time. It waits until the page has loaded what it asked for (no request for half a second), runs `pause`, then steps the game to 5 s of game time, the last frame shorter so it lands exactly. From there every frame of the restore, of every settle and of a checkpoint is a `step` of 1000/60 ms, the frame of the headless leg; the browser's own frames move no game time. The picture is the stage after the last stepped frame. A `resume` step hands the clock back to the browser.
397
+ - **The picture.** Lossless WebP: the page encodes it with `convertToBlob({ type: "image/webp", quality: 1 })`, which Chrome encodes without loss, so the baseline holds the captured pixels exactly.
398
+ - **`--update`** rewrites `state.json`, `describe.json` and `screen.webp` of every test it runs. A pixel difference writes `screen.actual.webp` and `screen.diff.webp` beside the baseline, red where a pixel differs. Git ignores both.
399
+ - **Tolerance.** A pixel differs when one of its channels moves by more than 24. A checkpoint differs when more than 0.1% of its pixels do. Both are `tolerance` in the options.
400
+
401
+ The fixture game keeps its visual tests in `tests/visual/`: one `*.visual.ts` per test, the list in `tests.ts`, the script in `run.ts` and the baselines next to them. The script imports the runner from `src/testing`, so a stale `dist/` never writes baselines. `bun run test` runs the headless leg through `tests/integration/visual-headless.test.ts`. Both legs run against the dev page:
402
+
403
+ ```sh
404
+ # terminal 1: the dev page of the fixture on a free port
405
+ cd tests/integration/merge-game && bun ./web/serve.ts --port 4173
406
+
407
+ # terminal 2: from the root of the repository
408
+ bun run fixture:visual --url http://localhost:4173/ # compare with the baselines
409
+ bun run fixture:visual --url http://localhost:4173/ --update # write the baselines again
410
+ bun run fixture:visual --only gift-popup --no-pixels # one test, headless only
411
+ ```
412
+
413
+ Without `--url` the page is `http://localhost:3000/`. The exit code is 1 when a checkpoint differs.
414
+
415
+ The test of the browser leg itself opens a real Chrome only on request: `MOKU_VISUAL_BROWSER=1 bunx vitest run tests/unit/visual/browser.test.ts`. It also proves the WebP is lossless: random opaque pixels come back byte for byte. Without the variable it skips with its reason, so `bun run test` opens no browser.
416
+
357
417
  ## Interface in JSX
358
418
 
359
419
  The interface is one more projection. A screen is a projection whose `view` returns JSX; `ui` reconciles the tree by identity into entities it owns and lays them out with one Yoga solve per change. There is no DOM and no React: the runtime builds plain description nodes, and `"jsxImportSource": "@moku-labs/game"` is the only setup.
@@ -363,7 +423,7 @@ The interface is one more projection. A screen is a projection whose `view` retu
363
423
  { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@moku-labs/game" } }
364
424
  ```
365
425
 
366
- The tags are `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel`, `image`, `icon`, `text`, `button` and `scroll`. A `button` either names an `intent` for the gate or writes `local` state of its nearest component, never both. A `text` takes a string or a `Message` from `tr`; its size comes from a text style key, not from the layout style.
426
+ The tags are `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel`, `image`, `icon`, `text`, `button`, `scroll` and `input`. A `button` either names an `intent` for the gate or writes `local` state of its nearest component, never both. An `input` is a text field: its text lives in the `local` field it names, every keystroke writes it, and Enter answers its `submit` intent with `{ [local]: value }`, the only thing the gate hears: `<input key="nameField" local="name" maxLength={16} submit="save" placeholder={tr("rename.hint")} />`. A `text` takes a string or a `Message` from `tr`; its size comes from a text style key, not from the layout style. Every tag takes `components`, a list of extra component values for the element's entity: `<button key="claim" intent="claim" components={[Glow({ strength: 2 })]} />` glows, and the glow covers the button and its label.
367
427
 
368
428
  ```tsx
369
429
  // features/hud/view.tsx — the HUD, a reward popup and the choreography they share
@@ -528,7 +588,8 @@ From `@moku-labs/game/inspect`. `Changes` is when `watch` reads the source again
528
588
  | `projections` | `game.projections` | none | commit | Projection name to key to entity, through `world.projection.keyOf` |
529
589
  | `ui` | `game.ui` | none | frame | `ui.tree()`: the live screen as plain data |
530
590
  | `rect` | `game.rect` | `{ key: "string" }` | frame | Where the element with that `key` is on the page, in CSS px; in reference units while the renderer is inert. `undefined` when it is not on screen |
531
- | `render` | `game.render` | none | frame | `renderer.stats()`: `{ fps, frameMs, textures, textureMb, views, pooled }` |
591
+ | `render` | `game.render` | none | frame | `renderer.stats()`: `{ fps, frameMs, textures, textureMb, views, pooled, renderPasses }`, and `drawCalls` in a dev build. `renderPasses` is 1 for the frame plus, for every view with an enabled filter, 1 for its content and the passes of its filters |
592
+ | `effects` | `game.effects` | none | frame | `effects.stats()`: `{ particles, emitters, filters, renderPasses }`. Only in a game that composes `effectsPlugin` |
532
593
  | `sounds` | `game.sounds` | `{ last: "number?" }` | frame | `audio.journal()`: all, or the last `last`. Empty unless `pluginConfigs.audio.journal` is above 0 |
533
594
  | `assets` | `game.assets` | none | frame | `assets.usage()`: `{ textureMb, budgetMb, bundles }` |
534
595
  | `log` | `game.log` | `{ level: "string?" }` | frame | `log.trace()`: every entry, or the entries at `level` and above. A level other than `debug`, `info`, `warn`, `error` throws |
@@ -543,6 +604,7 @@ From `@moku-labs/game/control`. Every command runs in dev builds only and leaves
543
604
  | `tap` | `game.tap` | `{ key: "string?", target: "json?" }`, exactly one | route | `input.tap` on the ui element with that `key`, or on the view `target: { projection, key }`. Value: whether the gate took the answer |
544
605
  | `drag` | `game.drag` | `{ from: "json", to: "json" }` | route | `input.drag(from, to)`, both `{ projection, key }`. Value: whether the gate took the answer |
545
606
  | `key` | `game.key` | `{ key: "string", shift: "boolean?" }` | route | `input.pressKey(key, { shift })`. Value: whether a listener handled the key |
607
+ | `fill` | `game.fill` | `{ key: "string", value: "string" }` | route | `ui.fill(key, value)`: types into the `input` with that `key`, cut to its `maxLength`. Value: whether the field took the text |
546
608
  | `walk` | `game.walk` | `{ route: "json" }` | route | `flow.walk(route)`. Value: the flow state after the walk |
547
609
  | `bookmark` | `game.bookmark` | none | read | `flow.bookmark()`. Value: the bookmark, plain JSON |
548
610
  | `restore` | `game.restore` | `{ bookmark: "json?", repro: "json?" }`, exactly one | raw | `flow.restore(bookmark)`, or a `/testing` repro: its state at its checkpoint, then `flow.walk(repro.route)`. Value: the flow state |
@@ -729,13 +791,16 @@ Set with `createApp({ pluginConfigs: { <plugin>: { ... } } })`.
729
791
  | `i18n` | `fallback` | `string` | `"en"` | The locale a missing key is read from before it is reported missing |
730
792
  | `i18n` | `locales` | `Record<string, module \| loader>` | `{}` | Compiled modules outside features, per locale |
731
793
  | `text` | `fonts` | `{ body, digits }` | `{ body: "ui.font-body", digits: "ui.font-digits" }` | The two boot fonts behind the built-in styles `body` and `digits` |
732
- | `text` | `missingGlyph` | `string` | `"□"` | Drawn for a glyph the font lacks |
733
794
  | `ui` | `tapTargetPt` | `number` | `44` | The smallest tap target `lint()` accepts |
734
795
  | `ui` | `breakpoints` | `{ tall, wide }` | `{ tall: 2, wide: 1.5 }` | Aspect thresholds of the `when` style variants |
735
796
  | `audio` | `buses` | `{ master, music, sfx }` | `{ master: 1, music: 0.6, sfx: 1 }` | Start gain of each bus, 0..1 |
736
797
  | `audio` | `musicFadeMs` | `number` | `600` | Cross-fade of a music switch, in real milliseconds |
737
798
  | `audio` | `volumes` | `(player) => Partial<Record<Bus, number>> \| undefined` | `undefined` | Reads the player's choice from the committed player on every `model:committed`. Absent: the buses stay at `buses` |
738
799
  | `audio` | `context` | `() => AudioContext \| undefined` | `undefined` | The context factory, a test seam. Absent: `new AudioContext()` where the global exists |
800
+ | `effects` | `maxParticles` | `number` | `3000` | Live particles over every instance above which `effects:particle-budget` warns once per crossing |
801
+ | `effects` | `maxPasses` | `number` | `24` | Render passes per frame above which `effects:pass-budget` warns once per crossing |
802
+ | `effects` | `phone` | `boolean \| "auto"` | `"auto"` | Whether the device is a phone. `"auto"`: a coarse pointer and a short side of at most 820 CSS px, read once at start |
803
+ | `effects` | `blur` | `{ quality, phoneResolution }` | `{ quality: 2, phoneResolution: 0.5 }` | What a `Blur` with `quality: 0` and `resolution: 0` resolves to |
739
804
 
740
805
  ## Development
741
806
 
@@ -752,6 +817,8 @@ bun run test:unit # vitest project "unit"
752
817
  bun run test:integration # vitest project "integration"
753
818
  bun run test:coverage # both projects with coverage, 90% thresholds
754
819
  bun run validate # publint and attw with the esm-only profile
820
+ bun run fixture:pack # pack the fixture game into tests/integration/merge-game/dist/assets
821
+ bun run fixture:visual # the fixture's visual tests, both legs: --url <dev page>, --update, --only <name>, --no-pixels
755
822
  bun run release:setup # moku-release setup
756
823
  bun run release:doctor # moku-release doctor
757
824
  bun run release # moku-release
@@ -764,6 +831,7 @@ bun run release # moku-release
764
831
  | `tests/unit/` | Framework-level unit tests: root index, setup |
765
832
  | `tests/integration/` | Framework-level scenarios across plugins |
766
833
  | `tests/integration/merge-game/` | The fixture game, written on the public API only. Not published |
834
+ | `tests/visual/` | The visual tests of the fixture game and their baselines: `<test>/<checkpoint>/state.json`, `describe.json`, `screen.webp` |
767
835
  | `src/plugins/<name>/__tests__/unit/` | Unit tests of one plugin |
768
836
  | `src/plugins/<name>/__tests__/integration/` | Integration tests of one plugin |
769
837
  | `src/plugins/flow/__tests__/types/` | Type-level tests of the graph typing |
package/dist/assets.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { l as Manifest } from "./types-DWILGrPn.mjs";
1
+ import { l as Manifest } from "./types-DgwbzDLP.mjs";
2
2
 
3
3
  //#region src/plugins/assets/scan/cli.d.ts
4
4
  /**
@@ -26,17 +26,22 @@ type StringsCompiler = (root: string, out: string, options: {
26
26
  }) => Promise<StringsReport>;
27
27
  /**
28
28
  * Runs the asset key scanner: walk the features, write the manifest and the key module, then
29
- * compile the feature strings next to the key module, or check that all of it is current.
29
+ * compile the feature strings next to the key module, or check that all of it is current. With
30
+ * `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run).
30
31
  * Nothing here calls `process.exit`; the caller does.
31
32
  *
32
33
  * @param argv - The arguments after the script name.
33
34
  * @param compile - The strings compiler, `compileStrings` of i18n.
34
35
  * @param ui - Where the lines go. The branded console by default.
35
- * @returns The exit code: `1` when the scan or the compile failed or `--check` found a difference, else `0`.
36
+ * @returns The exit code: `1` when the scan, the compile or the pack failed or `--check` found a
37
+ * difference, else `0`.
36
38
  * @example
37
39
  * ```ts
38
40
  * await runCli(["--root", "src", "--manifest", "public/assets/manifest.json", "--check"], compileStrings);
39
41
  * // 1 when public/assets/manifest.json is older than the files in src/features
42
+ *
43
+ * await runCli(["--root", "src", "--keys", "src/generated/assets.ts", "--pack", "dist/assets"], compileStrings);
44
+ * // 0, and dist/assets holds the pages, the loose files and the packed manifest.json
40
45
  * ```
41
46
  */
42
47
  declare function runCli(argv: string[], compile: StringsCompiler, ui?: ScanUi): Promise<number>;
@@ -92,8 +97,9 @@ declare function checkStrings(root: string, out: string): Promise<boolean>;
92
97
  //#endregion
93
98
  //#region src/plugins/assets/scan/emit.d.ts
94
99
  /**
95
- * Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name and
96
- * files sorted by key.
100
+ * Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name, pages
101
+ * by id and files by key. The version is written as given: `1` from the scanner, `2` from the
102
+ * packer.
97
103
  *
98
104
  * @param manifest - What the scan produced.
99
105
  * @returns The text of the file.
@@ -118,6 +124,52 @@ declare function emitManifest(manifest: Manifest): string;
118
124
  */
119
125
  declare function emitKeys(manifest: Manifest): string;
120
126
  //#endregion
127
+ //#region src/plugins/assets/scan/pack/pack.d.ts
128
+ /**
129
+ * What one pack is told: where the sources are, what the scan found and where everything goes.
130
+ */
131
+ type PackOptions = {
132
+ /** Path of the game source root: the `path` of every file of `manifest` is relative to it. */root: string; /** The manifest the scan produced (version 1, loose files). */
133
+ manifest: Manifest; /** The pack folder. It belongs to the command: created, written and pruned. */
134
+ out: string; /** Where the packed manifest (version 2) goes. */
135
+ manifestFile: string; /** The cache folder, or `false` for a run that neither reads nor writes the cache. */
136
+ cache: string | false;
137
+ };
138
+ /**
139
+ * What a pack wrote.
140
+ */
141
+ type PackResult = {
142
+ /** The packed manifest, version 2, as written. */manifest: Manifest; /** How many atlas pages the pack folder holds. */
143
+ pages: number; /** How many textures stayed files of their own. */
144
+ loose: number; /** The bytes of every file the manifest references. */
145
+ bytes: number; /** How many of the pages were replayed from the cache. */
146
+ cacheHits: number;
147
+ };
148
+ /**
149
+ * Packs the art of a game for production: per bundle, the textures of the `fx` group and of the
150
+ * `main` group go onto WebP atlas pages (2048 px at most, 2 px padding and border, never trimmed
151
+ * or rotated), a texture with a side above 512 px and a group of one stay loose files, fonts and
152
+ * sounds are copied, every file name carries the hash of its bytes, and the packed manifest
153
+ * (version 2) names it all. Nothing is written while a problem is open; afterwards the pack folder
154
+ * holds exactly what the manifest references, plus `manifest.json`. Two runs on the same tree and
155
+ * the same `sharp` write the same bytes.
156
+ *
157
+ * @param options - The sources, the scanned manifest, the pack folder, the manifest file, the cache.
158
+ * @returns The packed manifest and what was written.
159
+ * @throws {Error} One error that lists every problem, when `sharp` is missing, or when the pack
160
+ * folder holds the sources.
161
+ * @example
162
+ * ```ts
163
+ * // After `scanAssets` read the features of the game. `cache: false` skips the cache.
164
+ * const { manifest, pages, cacheHits } = await packAssets({
165
+ * root: "src", manifest: scanned, cache: "node_modules/.cache/moku-game-pack",
166
+ * out: "dist/assets", manifestFile: "dist/assets/manifest.json"
167
+ * });
168
+ * // manifest.version: 2; on a second run with the same art, cacheHits equals pages
169
+ * ```
170
+ */
171
+ declare function packAssets(options: PackOptions): Promise<PackResult>;
172
+ //#endregion
121
173
  //#region src/plugins/assets/scan/scan.d.ts
122
174
  /**
123
175
  * What one scan is told: where the features are and where the two generated files go.
@@ -158,4 +210,4 @@ type ScanResult = {
158
210
  */
159
211
  declare function scanAssets(options: ScanOptions): Promise<ScanResult>;
160
212
  //#endregion
161
- export { type CompileOptions, type CompileReport, type ScanOptions, type ScanResult, type ScanUi, type StringsCompiler, type StringsReport, checkStrings, compileStrings, emitKeys, emitManifest, runCli, scanAssets };
213
+ export { type CompileOptions, type CompileReport, type PackOptions, type PackResult, type ScanOptions, type ScanResult, type ScanUi, type StringsCompiler, type StringsReport, checkStrings, compileStrings, emitKeys, emitManifest, packAssets, runCli, scanAssets };