@moku-labs/game 0.15.28 → 0.15.30

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/dist/app/page.d.mts +1 -1
  2. package/dist/app/page.mjs +1 -1
  3. package/dist/app/system.d.mts +3 -3
  4. package/dist/app.d.mts +3 -3
  5. package/dist/app.mjs +1 -1
  6. package/dist/assets.d.mts +1 -1
  7. package/dist/{commands-BJ4gG5WZ.d.mts → commands-BUfZJUx6.d.mts} +31 -31
  8. package/dist/control.d.mts +4 -4
  9. package/dist/control.mjs +2 -2
  10. package/dist/{define-CVYadV_I.d.mts → define-CE1uxbe9.d.mts} +2 -2
  11. package/dist/{headless-DBYmnluy.d.mts → headless-f31cEZ7f.d.mts} +1 -1
  12. package/dist/{index-Dn_v0Fqs.d.mts → index-B2GejbLf.d.mts} +26 -23
  13. package/dist/index.d.mts +4 -4
  14. package/dist/index.mjs +1 -1
  15. package/dist/inspect.d.mts +16 -16
  16. package/dist/inspect.mjs +2 -2
  17. package/dist/jsx-dev-runtime.d.mts +1 -1
  18. package/dist/jsx-runtime.d.mts +1 -1
  19. package/dist/{run-DRcc2SKF.mjs → run-DyL8rl2P.mjs} +15 -43
  20. package/dist/{runtime-DnjJ4Zo9.d.mts → runtime-HwTBjkIL.d.mts} +1 -1
  21. package/dist/{session-CeKgO2hy.mjs → session-DuZfGZWk.mjs} +20 -1
  22. package/dist/{sources-ncFMQIus.mjs → sources-DM5Zcl4o.mjs} +8 -17
  23. package/dist/{src-DPvSTM8O.mjs → src-B4JjsuEy.mjs} +1704 -603
  24. package/dist/testing.d.mts +4 -4
  25. package/dist/testing.mjs +1 -1
  26. package/dist/{types-bEJEga5m.d.mts → types-BAp9HdR4.d.mts} +10 -2
  27. package/dist/{types-Bsa1nFyU.d.mts → types-BWRJcZ3n.d.mts} +4 -4
  28. package/dist/{types-DTetCcBC.d.mts → types-CgE5L_7f.d.mts} +2 -2
  29. package/dist/{types-C_nC_M4p.d.mts → types-D27I4Q7b.d.mts} +312 -22
  30. package/dist/{types-C76NQm-h.d.mts → types-DjQkfbfZ.d.mts} +2 -2
  31. package/dist/{visual-BMRHt4IB.d.mts → visual-f6mtELhG.d.mts} +6 -6
  32. package/dist/visual.d.mts +1 -1
  33. package/dist/visual.mjs +2 -2
  34. package/llms.txt +12 -11
  35. package/package.json +1 -1
package/llms.txt CHANGED
@@ -27,7 +27,7 @@ Root exports:
27
27
  - Class: `SaveUnreadableError`.
28
28
  - Plugin instances: `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin`, `platformPlugin`; the list `screen` = `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`. `effects`, `audio` and `platform` are opt-in: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]`, `platform` last.
29
29
  - Screen helpers from the root: `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree`, `Now`, `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `Overlay`, `HudPart`, `sprite`, `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Traceable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer`, `Traced`, `defineBundles`, `load`, `defineScene`.
30
- - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`, the resource `AnimPlayer`; `tr`, `messageArgument`, `messageDuration`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`, `DebugTrigger`, `ScrollState`; `music`.
30
+ - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`, the resource `AnimPlayer`; `tr`, `messageArgument`, `messageDuration`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`, `DebugTrigger`, `ScrollState`; `music`. Also `stopSfx(id, { fadeMs? })` (ends a named sound, see audio) and `cubicBezier(x1, y1, x2, y2)` (a CSS-style easing curve for `tween`'s `ease`).
31
31
  - Effects helpers from the root (V5): `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha`.
32
32
  - Platform from the root (V6): the types `PlatformProvider`, `BackResult`, `HapticKind` and the frozen list `HAPTIC_KINDS`.
33
33
  - `defineGame<Types>()` returns, typed by the game's `player`, `session`, `assets`, `bundles`, `scenes`, `strings` and `textStyles`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, `defineAnimation`, `frames`, `sfx`, `play`, `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `music`, `Frames`, `defineEmitter`, `Emitter`, `Displacement`, and `system` (its `ctx.snapshot` typed by `player` and `session`), `volumes` (the `audio` config's reader of the typed player), `key` (string keys from data, below). `Types` is `{ player; session; assets: string; bundles?: string; scenes?: string; strings: Record<string, unknown>; textStyles?: string; emitters?: string }`.
@@ -457,7 +457,7 @@ Registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; then th
457
457
 
458
458
  The single `requestAnimationFrame` loop. Six phases in this order: `input`, `animate`, `layout`, `sync`, `signals`, `render`.
459
459
  - API: `onFrame(phase, callback): () => void`, `snapshot(): Readonly<Time>`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)`, `wake()`, and for an agent build (dev pages and `--debug` builds) `measure(listener): () => void` (every frame's `FrameMeasure`: gap, skipped ticks, the six phase durations, work, `rateMs`), `agentClock()` (the page clock, `undefined` without the flag) and `chargeAgent(ms)` (for engine plugins: the editor's own work left out of a frame). Every build: `rate(): Readonly<Time.FrameRate>` (`{ fps, mode, reason, displayHz, ladder, thermal }`, one frozen object until it changes), `setRate(choice: "auto" | 30 | 60 | 120)` (a number forces the cap, never above `maxFps`; `"auto"` gives it back; answers `rate()`), and for engine plugins `frameDrawn()` and `frameProbed()` (the renderer reports a drawn frame and a warm-up frame).
460
- - Adaptive frame rate: `time: { maxFps: 120, adaptive: { floor: 60 } }` runs at the highest divisor of the display rate at or below `maxFps` (120 on a 120 Hz panel, 60 on a 60 Hz one, 72 on 144 Hz), drops one step when at least `max(8, 10 %)` of the last second's drawn intervals came late, returns one step after the dwell (5 s, doubling up to 120 s after a failed return) with 90 steady drawn frames in 3 s, never below `floor`; thermal `moderate` drops a step, `severe` to the floor. Only drawn frames count. Every change emits `time:rate` (`Time.RateChange`: `from`, `to`, `reason`, `mode`, `displayHz`, `thermal`, `window`, `dwellMs`) and logs `time: frame rate`. Off by default, in a headless app and on a fake clock.
460
+ - Adaptive frame rate: `time: { maxFps: 120, adaptive: { floor: 60 } }` runs at the highest divisor of the display rate at or below `maxFps` (120 on a 120 Hz panel, 60 on a 60 Hz one, 72 on 144 Hz), drops one step when at least `max(8, 10 %)` of the last second's drawn intervals came late, returns one step after the dwell (5 s, doubling up to 120 s after a failed return) with 90 steady drawn frames in 3 s, never below `floor`; thermal `moderate` drops a step, `severe` to the floor. Only drawn frames count. Every change emits `time:rate` (`Time.RateChange`: `from`, `to`, `reason`, `mode`, `displayHz`, `thermal`, `window`, `dwellMs`) and logs `time: frame rate`. Off by default, in a headless app and on a fake clock. The render scale it may lower first is `pluginConfigs.renderer.adaptiveScale`, of type `AdaptiveScale` (`{ floor: 0.75, devices: "phone" }`).
461
461
  - Native frame rate: on a page with `__MOKU_NATIVE__` of version 1, `time` asks the panel for the loop's rate at start and on every change, and hands it back at stop, every build; with `adaptive` the answer's `maxHz` is the display rate. In an agent build or with `adaptive` its one `moku-native:thermal` listener emits `time:thermal` with `Time.ThermalReading` (`{ status, headroom, at, android }`).
462
462
  - `Time` is `{ delta, elapsed, scale, frame, idle }` in scaled milliseconds.
463
463
  - Idle cap: after `idleAfterMs` unscaled milliseconds without a `wake()` the loop runs at `idleFps` and `snapshot().idle` is true. `wake()` lifts it at once; `input`, `anim`, `flow`, `assets`, `scenes`, `text` and `ui` call it. `step(deltaMs)` is never capped.
@@ -495,7 +495,7 @@ Trusted time as an input. Monotonic `now()` and one `elapsed` signal at the next
495
495
  ### debug (Micro, no dependencies, no events)
496
496
 
497
497
  The editor's overlay from the game, in every app. The plugin name `debug` is reserved.
498
- - API: `app.debug.overlay.open({ mode?: "pill" | "card" })` (the card by default), `close()`, `toggle()` (hidden and the last visible mode), `isOpen()`. Each reads `globalThis.__MOKU_GAME_OVERLAY__` (set by the editor's page agent) at call time, else the 0.10 `editor.overlay`, and answers `true` when an overlay took the call; without the agent flag (a release build, a test) every member is a no-op answering `false`. A throwing channel logs `debug:overlay-failed`.
498
+ - API: `app.debug.overlay.open({ mode?: "pill" | "card" })` (the card by default), `close()`, `toggle()` (hidden and the last visible mode), `isOpen()`. Each reads `globalThis.__MOKU_GAME_OVERLAY__` (set by the editor's page agent) at call time, else the 0.10 `editor.overlay`, and answers `true` when an overlay took the call; without the agent flag (a release build, a test) every member is a no-op answering `false`. A throwing channel logs `debug:overlay-failed`. The plugin instance is `debugPlugin`; it is always registered, a game never lists it.
499
499
 
500
500
  ### flow (Very Complex, depends on time, lifecycle, model, clock)
501
501
 
@@ -510,7 +510,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
510
510
  - After a rollback (a node failed) a view of a projection that the node moved and that still stands off its rest pose is settled, also when its item did not change. Ui elements are not settled by a rollback, and a timeline the failed node started without `await` runs on.
511
511
  - A loop adds its sway on top of the value under it. The world compares that value with the rest pose, so a looping view that stands at home is never settled, and one a node moved is brought home while its loop runs on. A custom tween driver may report what its additive tracks add with the optional `offsetsOf(entity, component)`; without it the stored value is compared as it stands.
512
512
  - The `loop` hook of a view runs while the world is live and `anim` is installed: a view that entered on a load, a mount or a restore gets its loop from the next live frame. `anim.finishAll()` ends it; the next flush of the world (a load, a restore, a commit in a paused or fast world) owes it again.
513
- - For a JSX screen ui compares the description prop by prop, by value: inline styles are fine, an equal description costs nothing (no style pass, no layout, no write), hooks stay built once (a function compares by identity). A style object changed in place is not seen: build a new one.
513
+ - For a JSX screen ui compares the description prop by prop, by value: inline styles are fine, an equal description costs nothing (no style pass, no layout, no write), hooks stay built once (a function compares by identity). A style object changed in place is not seen: build a new one. One node of a description, as the JSX runtime builds it, is a `DescriptionNode`.
514
514
  - A projection may build a new item object on every commit. A view whose components did not change keeps its running motions; only a real component change cancels them. No frozen item tables are needed.
515
515
  - After a `restore` or a load the picture is the one of the restored state, whatever played before: every view of a mounted projection stands on its rest pose, its `loop` starts again on the next live frame and no other motion runs, and every ui root is built again through the mount path, so `enter` hooks run and a loop starts as after a fresh mount. Scroll offsets, local state, hover and focus start anew. Look an element up again with `ui.find(key)` after a restore. A rest node names the scene it is shown on (`scene: "splash"`): a restore into a node without `scene` keeps the mounted scene.
516
516
  - `restore` of a rest node resolves when the node is entered. `restore` of a transit node runs it again from its start, so its effects before the gate play again, and resolves once its gate is open. The open gate is not the draw: the popup shows on the next frame. `restore` also resolves when the loop ends before the bookmark was entered, after a stop or a fatal error, and nothing is entered then.
@@ -520,9 +520,9 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
520
520
  ### Screen plugins (V2, built)
521
521
 
522
522
  `world`, `renderer`, `input`, `assets`, `scenes`. Each has a README at `src/plugins/<name>/README.md`; the root README lists their APIs, events and config. Rendering: WebGPU preferred, with Pixi's WebGL fallback.
523
- - `app.scenes`: `current()` the mounted scene id, `expect(id)` the scene the next node without its own `scene` mounts, a rest node or a transit node in live mode (the restore door calls it; an unknown id throws). An `over` node takes it the same way, also over another mounted scene.
523
+ - `app.scenes`: `current()` the mounted scene id, `expect(id)` the scene the next node without its own `scene` mounts, a rest node or a transit node in live mode (the restore door calls it; an unknown id throws). An `over` node takes it the same way, also over another mounted scene. A live switch, before it builds the scene, warms the bundles the scene names (`assets.warm`): their textures go to the GPU `renderer.uploads` a drawn frame while the old scene is still up, so the first frame of the new scene uploads none of them; textures already there are not waited for, and a fast walk or a headless app never waits. A bundle the scene does not name (a `core` bundle of portraits the board shows) is not warmed: name it in the scene's `bundles` or call `app.assets.warm`. Then, with a drawing renderer, the next scene is built aside: its logic runs in one block, the renderer makes its Pixi views over the next frames off the stage in a budget per frame, config `scenes.buildMs: number | "auto"` (default `"auto"`, half the frame interval: 8.33 ms at 60 Hz, 4.17 ms at 120 Hz; `0` throws), and it is swapped in one frame. Until the swap `current()` answers the old scene, nothing of the new one is drawn, hit or found by `ui.find`, its `enter` motions wait and the gate stays closed, so the first tap lands on a complete scene; `scenes:changed` is emitted at the swap. A fast walk, a world that is not live and a headless app mount in one block as before.
524
524
  - `Shape({ kind, w, h, fill, fillAlpha, alpha, radius, stroke, strokeWidth, dash, clip, sweep?, cap? })` (`kind: "rect"`; `sweep` and `cap` are optional, `1` and `"round"` when left out; only an arc reads them; a rectangle or a triangle ignores them, also when an earlier arc left them on the `Shape`): a rounded rectangle, `kind: "triangle"` (fills its box pointing right) or `kind: "arc"`: a stroke on the circle inscribed in the box (`min(w, h) − strokeWidth` across, centred) from 12 o'clock clockwise over `sweep` (0..1, clamped; 0 draws nothing, 1 a closed ring), ended by `cap` (`"round" | "butt"`); an arc draws no fill and ignores `radius` and `dash`. The path is built again only when a field changes: a still arc costs nothing a frame. `Shape({ kind: "arc", w: 28, h: 28, stroke: 0xc8a15a, strokeWidth: 2.2, sweep: 0.42 })` is a slot timer's ring with 42 % left.
525
- - Overlays: `Overlay({ dim, dimColor, cover, hud, fade, hold })` on the root of an overlay (in `components`) keeps everything drawn before it as one picture, retaken in the frame anything in it changes, and with `cover: "opaque"` draws none of it once landed at alpha 1; `hold: true` (default `false`) holds the endless loops under it while it stands, as under a cover or a blurred picture (their phase follows time), so a dimmed sheet over a sharp scene draws no frame at rest; the engine draws the dim (`dim × level`, right under the root); the level follows the root's own enter and exit motion, `fade` (250 ms) only when it has none; level and landing are read through `app.renderer.sync.debug.overlays()`. A `backdrop: true` filter inside the kept range runs no pass while the picture stands (its look is in the picture) and is drawn again in the frame the overlay is gone.
525
+ - Overlays: `Overlay({ dim, dimColor, cover, hud, fade, hold })` on the root of an overlay (in `components`) keeps everything drawn before it as one picture, retaken in the frame anything in it changes, and with `cover: "opaque"` draws none of it once landed at alpha 1; `hold: true` (default `false`) holds the endless loops under it while it stands, as under a cover or a blurred picture (their phase follows time), so a dimmed sheet over a sharp scene draws no frame at rest; the engine draws the dim (`dim × level`, right under the root); the level follows the root's own enter and exit motion, `fade` (250 ms) only when it has none; level and landing are read through `app.renderer.sync.debug.overlays()`. A `backdrop: true` filter inside the kept range runs no pass while the picture stands (its look is in the picture) and is drawn again in the frame the overlay is gone. Types: `OverlayValue` (the component's value) and `HudPartValue` (a part of the HUD an overlay keeps shown or lets fade).
526
526
  - HUD: a scene layer `{ above: "overlays" }` is the HUD layer, drawn above every overlay and never in a picture; a scene with a HUD layer and no `ui` gets `ui` right under it. Its roots never take the keyboard: Escape and the Back press reach the top sheet under it. `HudPart({ key })` tags a HUD view; an overlay's `hud` (`"all"`, `"none"` or keys) names the parts that stay shown, the others fade with its level and take no tap. Keys are typed by `defineGame<{ …, hud: "energy" | "gems" }>()`.
527
527
  - Endless loops of views under an opaque or blurred overlay write nothing (`renderer.sync.loopsHeld(entity)`, the seam anim asks before an endless loop writes); their phase follows time.
528
528
  - A scene names bundles on top of its `bundle` from the committed state: `defineScene("ward", { bundle: "episodes-kit", bundles: ({ session }) => [session.episode], layers: {}, projections: [wardRoom] })`. `bundles` is read each time a node enters the scene, and every way in (a live entry, a restore, the `safeNode` recovery) waits for all of them before the scene is mounted and the node's gate opens. The keys are `BundleKey`s of `generated/assets.ts`.
@@ -536,13 +536,14 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
536
536
  - `Draggable({ payload, carry?, aim? })`: `aim: { x, y }`, a point of the view in its own units, moves the hover and the drop lookup from the finger to that point of the view in the hand; `null` (default) is the finger.
537
537
  - `pluginConfigs.renderer.onDemand: true` draws a frame only when something visible changed (a written view, a filter value, a resize, a wake, a capture); `app.renderer.stats().drawn` is the frames drawn per second (`fps` without it); a game that writes into its own `Display` object calls `app.renderer.invalidate(entity)` before the write; `renderer.sync.debug.verifyFrames(true)` (dev/debug) checks skipped frames by pixels and logs `renderer:missed-frame`; a dev build warns `renderer:missed-mark` for a write without the call.
538
538
  - `app.renderer.stats()` adds `textureMbSplit: { art, targets, kept }` (MiB). `app.renderer.host.resolution()` is the resolution drawn at: `override ?? min(devicePixelRatio, maxResolution)`; `maxResolution` is a number (default 2), a change of `devicePixelRatio` is followed from the next frame, and `app.renderer.monitor.setResolution(n | undefined)` overrides it in every build, the way to apply a player's graphics setting from an effect (0.5 to 4, else a `RangeError`).
539
- - `app.renderer.monitor` (agent builds; nothing otherwise): `stream(listener)` the performance log, one frozen JSON line at a time (`PerfLogLine`, `v` = `PERF_LOG_VERSION` = 1, `t` page epoch ms, `build`): `header`, `startup`, a `sample` a second, `spike` (a frame over its budget, with its passes, GPU time, systems, compiles, filters per kind, long frames), `longFrame`, `event`, `mark`, `thermal`; `frames()` the frame ring (typed-array columns, `read(into, cursor)` copies without allocating; `frameAt(ring, age)` gives one record); `mark(label)`; `event(name, detail?)`; `beginAgent()`/`endAgent()` for engine plugins. Config `renderer.monitor: { budgetMs: "rate", frames: 1024 }`: `"rate"` makes the spike budget one frame of the frame's rate (8.33 ms at 120 fps, 16.7 at 60). The engine sends nothing anywhere.
539
+ - `app.renderer.monitor` (agent builds; nothing otherwise): `stream(listener)` the performance log, one frozen JSON line at a time (`PerfLogLine`, `v` = `PERF_LOG_VERSION` = 1, `t` page epoch ms, `build`): `header`, `startup`, a `sample` a second, `spike` (a frame over its budget, with its passes, GPU time, systems, compiles, filters per kind, long frames), `longFrame`, `event`, `mark`, `thermal`; `frames()` the frame ring (typed-array columns, `read(into, cursor)` copies without allocating; `frameAt(ring, age)` gives one record); `mark(label)`; `event(name, detail?)`; `beginAgent()`/`endAgent()` for engine plugins. Config `renderer.monitor: { budgetMs: "rate", frames: 1024 }`: `"rate"` makes the spike budget one frame of the frame's rate (8.33 ms at 120 fps, 16.7 at 60). The engine sends nothing anywhere. The types of the log and of its ring are exported from the root for tools that read it: `PerfLogLine` and its lines `PerfHeaderLine`, `PerfStartupLine`, `PerfSampleLine`, `PerfSpikeLine`, `PerfLongFrameLine`, `PerfEventLine`, `PerfMarkLine`, `PerfThermalLine`, `PerfCollectorLine`, with `PerfEventName`, `PerfChange`, `PerfChangeEntry`, `PerfCause`, `PerfDisplay`, `PerfMeasured`, `LongFrameEntry`, `PassKind`, `PassRecord`, `ProblemRule`, `ShaderWhat`, `OverlayWhat`, `OverlayEventDetail`, `PoolTrimDetail`, `WarmDetail`; the ring `FrameRing`, `FrameRecord`, `FrameColumns`, `FrameReadout`, `FrameReadResult`; the sections `MonitorSections`, `MonitorCounts`, `MonitorConfig`; and the constant lists `PERF_CAUSES` (bit order of the `causes` column), `PERF_GAP_BUCKETS`, `MONITOR_SECTIONS` and `DEMAND_REASONS` (the `reason` column's index into it).
540
540
  - The telemetry of a debug build (0.15, PerfLog still `v: 1`, every field additive): the header carries `app: { id }` (`__MOKU_GAME_APP__`, `"com.mokulabs.timber.debug"` in a native debug build, else `null`) and `debug: { overdraw }`. The pass ring (`frames().passes`) gains `owner` (the entity whose filter drew the pass, 0 none), `kind2` (the filter kind, 1-based: `frames().kindNames[kind2 - 1]`, 0 none), `drawsAt` (the frame's draws when the pass began; WebGPU), `format` and `resolution` of its target; `frames().systemNames` names `sysIdx` (a new array when a system registers). A spike's `passes[]` add `owner`, `view` (the owner's view label), `filter`, `draws`, `format`, `resolution`, `kept`. Root exports `PASS_KINDS` (`passes.kind` by index), `SHADER_WHATS`, `PROBLEM_RULES`, types `ShaderEventDetail`, `ProblemEventDetail`, `PerfCounts`. Event `shader` `{ what: "compile" | "module", label, ms, frame, boot, during: { pass, filter, owner, view } }`, one per pipeline or shader module, written at the flush, boot compiles included (`boot: true`); the old `compile { frame, count }` stays. Event `problem` once per crossing, judged once a second: `filter-still` (a filtered view the kept cache does not keep stood still `stillFrames` drawn frames, filtered in every one over `stillMpx`; `reason: "budget" | "not-eligible"`), `target-churn` (render targets made and dropped `churnPerSecond` a second for `churnSeconds`, or made while idle, or within `churnAfterTrimMs` after a pool trim; `reason: "rate" | "idle" | "after-trim"`), `retakes` (a kept view taken again 3 times in 2 s; `value` per second); `{ rule, cleared: true, owner, view }` when it stops. Thresholds are flat keys of `renderer.monitor`: `stillFrames: 60, stillMpx: 0.25, churnPerSecond: 4, churnSeconds: 3, churnAfterTrimMs: 1000`. A `sample` adds `counts: { entities, views, timers, frameListeners, clockListeners, …registered }` (`timers` = `clock.pending()`; input registers `inputListeners`) and `targets: { created, destroyed }` (WebGPU, else `null`); `stats().counts` reads the same. `monitor.counts.register(name, read)` adds a count (agent builds; at most 16, the 17th throws `[game] Monitor count table is full (16).`; the renderer's `onStop` drops them). Event `debug { overdraw }` marks the overdraw switch. The editor's collector writes `telemetry` and `gap` lines into its copy of a log (`PerfTelemetryLine`, `PerfGapLine`); the engine never does.
541
541
  - Overdraw heat map (dev and debug builds): `app.renderer.sync.debug.overdraw(true)` (or `game.debug { overdraw: true }`) draws every frame so a pixel's brightness counts the layers drawn there: 1/16 white a layer, white at sixteen. While on, every frame is drawn (also with `onDemand`), kept pictures draw live and filters are off; only the stage pass changes, the tree between frames stays the game's, and off draws the game again from the next frame. Texels a texture leaves transparent count nothing.
542
542
  - `app.renderer.viewport.size()` answers one frozen object, replaced only when the frame changes (a resize, a rotation, new insets, a new scale): compare frames with `===`; a write into it throws a `TypeError`.
543
543
  - `pluginConfigs.renderer.warm` (default `1`, 0 to 16) is the pipeline warm-up: while a game loads, each drawn frame first draws that many invisible probes (sprites, graphics, text, particles, every filter kind and stage `effects` registers, on the canvas and on a texture, with and without a clip), wiped by the stage pass, so no screen creates a GPU pipeline in the frame it opens in; `0` turns it off. `app.renderer.sync.filters.warm(filter)` warms a game's own filter (answers `true` once drawn). An agent build writes a `warm` event `{ draws }` when the queue empties.
544
- - `defineFilter(id, { area: value => [x, y, width, height] })` declares the box, in the entity's own units, outside of which the filter draws nothing: Pixi renders and fills only that box (a lens over a full-screen cover fills its disc, not the screen). The view gets the union of its enabled filters' areas, none when an enabled filter declares none (`renderer.sync.filters.area(entity, area)`). The area also clips the view's own children: Pixi draws nothing of the view outside it, so a shadow or a badge that overhangs the box is cut; make the box hold them. The full-screen filter budget and the kept-picture budget count a view with an area by that area, not by its box. Distance-field text uses device pixels for its edge: sharper at a resolution above 1.
545
- - `pluginConfigs.renderer.poolTrim` (default `{ restMs: 1000, aboveMb: 16 }`) gives back the idle render targets Pixi's texture pool keeps, strictly between frames: after `restMs` of a still screen (`onDemand`), `restMs` after a scene change (each when the targets grew by `aboveMb` MiB), and on a pause (whatever grew); `false` keeps them. `stats().textureMbSplit.targets` shows the drop; an agent build writes a `pool-trim` event `{ reason, freedMb }`.
544
+ - `defineFilter(id, { area: value => [x, y, width, height] })` declares the box, in the entity's own units, outside of which the filter draws nothing: Pixi renders and fills only that box (a lens over a full-screen cover fills its disc, not the screen). The view gets the union of its enabled filters' areas, none when an enabled filter declares none (`renderer.sync.filters.area(entity, area)`). The area also clips the view's own children: Pixi draws nothing of the view outside it, so a shadow or a badge that overhangs the box is cut; make the box hold them. The full-screen filter budget and the kept-picture budget count a view with an area by that area, not by its box. Distance-field text uses device pixels for its edge: sharper at a resolution above 1. The type of that box is `FilterArea`.
545
+ - `pluginConfigs.renderer.poolTrim` (default `{ restMs: 1000, aboveMb: 16 }`) gives back the idle render targets Pixi's texture pool keeps, strictly between frames: after `restMs` of a still screen (`onDemand`), `restMs` after a scene change (each when the targets grew by `aboveMb` MiB), and on a pause (whatever grew); `false` keeps them. `stats().textureMbSplit.targets` shows the drop; an agent build writes a `pool-trim` event `{ reason, freedMb }`. Its type is `PoolTrim`.
546
+ - `blend` on `Sprite` and `NineSlice` (optional, absent by default, type `SpriteBlend`): `"screen"` lightens what is under the view (CSS `mix-blend-mode: screen` on a dark background), `"add"` adds its colours (brighter), `"normal"` covers by alpha as without the field. Any other string draws normally and warns `renderer:blend-unknown` once. It goes on the view itself, so a glow's children draw normally; both are Pixi's native batch blends, no filter and no pass, and each switch between blends in draw order costs one draw call. In `ui`, `style.blend` on an `image`, an `icon` or a nine-slice style writes it (`style={{ blend: "screen" }}` for a soft glow, `"add"` for a spark); an element that never blended carries no `blend` field, so describe and saves stay as they were.
546
547
  - `app.renderer.viewport.size().safeArea` is read from four CSS custom properties on the page, `--moku-safe-top`, `--moku-safe-right`, `--moku-safe-bottom`, `--moku-safe-left`, each falling back to `env(safe-area-inset-*, 0px)`. Any CSS length works (`max()`, `calc()`, `env()`); a negative one counts as 0. A change of a property or of the page's insets, and the window event `moku-native:insets`, is laid out at the next frame. There is no `setInsets`: set the properties, e.g. `:root { --moku-safe-top: max(env(safe-area-inset-top, 0px), 24px); }`.
547
548
 
548
549
  ### Interface plugins (V3, built)
@@ -556,7 +557,7 @@ A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` se
556
557
  - Keys that come from data: `I18n.TextKey<Strings>` is the keys of the generated `Strings` without parameters (nothing new is generated). A data table types its keys `TextKey` or builds them with the kit's `key("board.hint.red")`; the kit's `tr` never takes a plain `string`. A key read back from a save is checked where the app is (an effect handler, app or page code, loading a save) with one `isKey(value)`: `defineGameApp({ ..., strings: type<Strings>() })` types the game's `app.i18n`, so a true `app.i18n.isKey(value)` narrows the value to `TextKey`; `ctx.require(i18nPlugin).isKey(value)` in a plugin narrows to `string`. `app.i18n.isKey(value)` answers true for a key of the loaded tables of the current locale or the fallback, and for the `<feature>.` prefix of a sub-feature whose strings are not loaded yet; a key that takes parameters answers true too.
557
558
  - Text: `Text` component, `label({ text, style, at, anchor? })` for board text in a projection view, `defineTextStyles({ "hud.digits": { font, size, fill, bold?, italic?, stroke?, strokeWidth?, letterSpacing?, align?, wrap? (a width, "none" or "box": wrap at the laid-out width of its ui element), digits? } })` under the feature's `textStyles` key. Tags inside a string: `<b>`, `<i>`, `<color=#rrggbb>`, `<icon=key>`, `\<`, `\n`. Built-in styles `body` and `digits` from config `fonts: { body: "ui.font-body", digits: "ui.font-digits" }`. `app.text.measure(content, style, width?)` gives the same numbers in a browser and in Bun. `app.text.replaceStyles(styles)` is the dev hot swap of a saved `defineTextStyles` value.
558
559
  - Animation is data: `defineAnimation(id, { slots: { name: type<Anim.Target>() }, build: (slots, { at }) => Step })`. Steps: `sequence(...)`, `parallel(...)`, `stagger(items, ms, item => Step)`, `tween(target, Component, to, { ms, ease?, delayMs?, additive?, space?, reduced? })`, `set(target, Component, patch)`, `wait(ms)`, `mark(name)`, `frames(target, { keys, fps, loop? })`, `sfx(key, { bus? })`, `haptic(kind)`, `use(animation, slots, tools?)`, `spawn` (a temporary entity, flying coins, that the timeline despawns when it ends; see the anim README). `Frames({ keys, fps, loop, playing })` is a component that walks a sprite through its textures for the entity's whole life. A target is `{ projection, key }` or an entity. A timeline resolves each `{ projection, key }` target once, when it starts: every step writes the view that held the key then, also after a commit removed it and while its exit plays, until it despawns; a view spawned again under the same key is not followed, and a key nothing held at the start is looked up at its step. `reduced: "keep"` (on a `tween` step, `transition` and `loop` of `defineMotion`, `TweenOptions`, `RestOptions`) plays a track as written under reduced motion, a fade for one; the default `"cut"` lands it at once or holds a loop on its first key. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? }, when? })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag; `when: "change"` plays enter and exit only for a commit, so an element that comes or goes with its screen stands at rest and leaves without a motion (default `"always"`). Events `anim:mark` `{ animation, mark }` and `anim:finished` `{ animation }`. `app.anim.replace(definition)` is the dev hot swap: the next `play` builds the new tree. A system cannot `require` a plugin, so it plays through the resource `AnimPlayer`: `res(AnimPlayer).play(animation, slots)` returns the same handle as `app.anim.play`, and the registered animation of `animation.id` wins, so a hot swap reaches the system. Play on a change, never every frame; from phase `input` the tree moves in `animate` of the same frame. Before `anim` starts it throws `[game] AnimPlayer is not ready: anim has not started.` Config `maxTracks: 2000`.
559
- - Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `muted(bus)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`, `music: "decode" | "stream"` default `"decode"` (gapless buffer; `"stream"` is an `<audio>` element, less memory, a gap at every loop), `session: "ambient" | "playback" | "auto"` default `"ambient"` (iOS only: ambient mixes with other apps and obeys the silent switch). The context is asked to run at start: where the shell lets audio play (a native webview with the gesture requirement off) `unlocked()` is true with no touch; a browser that wants a gesture unlocks on the first `pointerdown` or `touchend`. Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
560
+ - Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `muted(bus)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`, `music: "decode" | "stream"` default `"decode"` (gapless buffer; `"stream"` is an `<audio>` element, less memory, a gap at every loop), `session: "ambient" | "playback" | "auto"` default `"ambient"` (iOS only: ambient mixes with other apps and obeys the silent switch). The context is asked to run at start: where the shell lets audio play (a native webview with the gesture requirement off) `unlocked()` is true with no touch; a browser that wants a gesture unlocks on the first `pointerdown` or `touchend`. Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound. Named sounds: `sfx(key, { bus?, id?, fadeMs? })` with an `id` plays through its own gain, and the next sound with the same `id` fades the old one out over `fadeMs`, else config `sfxFadeMs` (default `120`, real milliseconds); `fx.start(stopSfx(id, { fadeMs? }))` and `app.audio.stop(id, { fadeMs? })` fade it out and end it (`fadeMs: 0` cuts it); an `id` nothing plays under changes nothing, and a sound of that `id` still decoding never starts. Without `id` an `sfx` plays to its end as before.
560
561
  - `app.ui`: `tree()` the live screen as plain data (a text node carries `content`, the words it draws after `tr()` and `bind`; a text field carries `value`), `find(key)` the entity of a keyed element, `fill(key, value)` types into the text field with that `key`, `scroll(key, to)` puts the `scroll` with that `key` at `to` from its start at once, inside `0..max`, its fling or glide stopped (`false` and `ui:scroll-without-scroll` for no live `scroll`, `false` and `ui:scroll-before-solve` for a `scroll` its first solve has not placed yet), `lint()` tap targets under `tapTargetPt` (default `44`), text that overflows in some locale, absolute elements without `reason`. Config: `tapTargetPt: 44`, `breakpoints: { tall: 2, wide: 1.5 }`. `ui` emits only the dev global event `ui:hot-swap`, see "Hot swap".
561
562
 
562
563
  ```tsx
@@ -623,7 +624,7 @@ export const deliver = defineNode({
623
624
 
624
625
  Cosmetic rendering extras as data, drawn by `effectsPlugin`: `plugins: [...screen, effectsPlugin]`. Nothing here enters `model`, the journal or a save. Headless it draws nothing. The full reference is `src/plugins/effects/README.md`.
625
626
  - Particles: `defineEmitter(id, config)` returns frozen data a feature lists under `emitters`. Required: `textures` (1 to 16 asset keys of one atlas page) and exactly one of `burst` (an integer, emitted once) and `rate` (particles per second while `active`). Optional with defaults: `lifeMs`, `speed`, `angle`, `gravity`, `drag`, `spin`, `shape` (`point`, `circle`, `rect`, `ring`), `scale`, `alpha`, `tint` (each `{ from, to }`), `space` (`"world"` or `"local"`), `prewarmMs`, `maxParticles` (`256`), `blend` (`"normal"` or `"add"`). A mistake throws at definition.
626
- - The `Emitter({ effect, active })` component on an entity runs an emitter: `generator: item => [Sprite({ texture: "board.generator" }), Emitter({ effect: "fx.steam" })]`. `effect: ""` draws nothing. Particles step in the `animate` phase, so they freeze while paused and a fast walk emits nothing. Their random source is seeded, never `Math.random` and never the model's rng.
627
+ - The `Emitter({ effect, active })` component on an entity runs an emitter: `generator: item => [Sprite({ texture: "board.generator" }), Emitter({ effect: "fx.steam" })]`. `effect: ""` draws nothing. Particles step in the `animate` phase, so they freeze while paused and a fast walk emits nothing. Their random source is seeded, never `Math.random` and never the model's rng. In `defineGame<Types>()`'s `Emitter`, `effect` takes the game's emitter ids and `""`; `defineEmitter` never takes `""`.
627
628
  - Filters: `defineFilter(id, { wgsl, glsl, uniforms?, passes?, padding?, inputs?, fallback? })` returns a component type a feature lists under `filters`. Both bodies are required: `wgsl` is the WGSL `fn mainFragment` and reads a uniform as `fu.amount`; `glsl` is GLSL ES 3.0 `void main()` that sets `finalColor` and reads a uniform as `amount`. A uniform is a number (tweenable), `{ color: 0xffd700 }` or an array of 2 to 4 numbers. Every filter component also has `enabled: true` and `order: 0`. A filter covers the entity's subtree; on a ui element it goes in the `components` prop.
628
629
  - Kept pictures: a still filtered view is drawn as one quad from an engine-owned picture, also while it moves (`renderer.keptMb`, default 64; `0` turns it off; `renderer.sync.debug.kept(false)` draws everything live). `defineFilter(id, { cache?: "local" | "placed" | false })`, default `"local"`; a body that reads `uOutputFrame.xy`, `uGlobalFrame`, `gl_FragCoord`, `@builtin(position)` or `uOutputTexture` and declares nothing is drawn live (dev warns `effects:filter-reads-screen`); write `canvasPixel(uv)` for the pixel's canvas position (in WGSL and GLSL) and it is kept; `cache: false` draws it live always. `app.renderer.stats()` measures `renderPasses` on the GPU path in every build and adds `renderPassesMax` (the most of one frame in the last second) and `kept: { pictures, mb, retakes }`.
629
630
  - Filter inputs: `inputs: { blur: { stages: [FrostBox4, FrostGauss], chain: (value, { resolution }) => [{ stage, scale, uniforms? }] } }` gives the body `sampleBlur(uv)` in both languages: the last texture of a chain of stages (each a `defineFilter`, drawing alone) drawn from the view's input into pooled textures at `resolution × scale`. The chain runs only when a uniform or the resolution changed; a step naming a stage not in `stages` breaks the filter. The engine counts the passes (leave `passes` out). `fallback: OtherFilter` (a subset of the uniforms) draws when the filter is broken.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/game",
3
- "version": "0.15.28",
3
+ "version": "0.15.30",
4
4
  "description": "2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.",
5
5
  "type": "module",
6
6
  "sideEffects": [