@moku-labs/game 0.15.7 → 0.15.9
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.
- package/dist/app/page.d.mts +1 -1
- package/dist/app/page.mjs +1 -1
- package/dist/app/system.d.mts +3 -3
- package/dist/app.d.mts +3 -3
- package/dist/app.mjs +1 -1
- package/dist/assets.d.mts +1 -1
- package/dist/{commands-C8aWbThW.d.mts → commands-5woFUxUr.d.mts} +13 -5
- package/dist/control.d.mts +4 -4
- package/dist/control.mjs +1 -1
- package/dist/{define-Bebctfvs.d.mts → define-B8A5IoeS.d.mts} +2 -2
- package/dist/{headless-DobgwTKK.d.mts → headless-CrxofjLt.d.mts} +1 -1
- package/dist/{index-CU4Oc2Qv.d.mts → index-DT9aYVGd.d.mts} +7 -6
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +3 -3
- package/dist/inspect.d.mts +6 -6
- package/dist/inspect.mjs +1 -1
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/{run-DlxDjgOO.mjs → run-BqYmcNo7.mjs} +38 -5
- package/dist/{runtime-Bm_guAJR.d.mts → runtime-BZQQPq56.d.mts} +1 -1
- package/dist/{sources-OvHvyeJS.mjs → sources-QCowxSpC.mjs} +1 -1
- package/dist/{src-nJYt0LMr.mjs → src-Cs1bP3v7.mjs} +1044 -184
- package/dist/testing.d.mts +4 -4
- package/dist/testing.mjs +1 -1
- package/dist/{types-CZGuMhV8.d.mts → types-D8roM1u3.d.mts} +127 -9
- package/dist/{types-BkcgFOoB.d.mts → types-DDmfkONF.d.mts} +59 -7
- package/dist/{types-MocH_igX.d.mts → types-DK-IYk-s.d.mts} +4 -4
- package/dist/{types-Mj75CDO6.d.mts → types-YXykgTOp.d.mts} +2 -2
- package/dist/{types-C4D9UVgb.d.mts → types-i77Nrrv-.d.mts} +29 -16
- package/dist/{visual-BV2A3kpr.d.mts → visual-1doiBOD3.d.mts} +6 -6
- package/dist/{visual-2m4wJL_z.mjs → visual-CIUx49C0.mjs} +26 -6
- package/dist/visual.d.mts +1 -1
- package/dist/visual.mjs +2 -2
- package/llms.txt +8 -7
- 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`; `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`.
|
|
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 }`.
|
|
@@ -521,7 +521,8 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
|
|
|
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
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.
|
|
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 })` 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; 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
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" }>()`.
|
|
526
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.
|
|
527
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`.
|
|
@@ -540,7 +541,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
|
|
|
540
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.
|
|
541
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`.
|
|
542
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.
|
|
543
|
-
- `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)`). Distance-field text uses device pixels for its edge: sharper at a resolution above 1.
|
|
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. Distance-field text uses device pixels for its edge: sharper at a resolution above 1.
|
|
544
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 }`.
|
|
545
546
|
- `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); }`.
|
|
546
547
|
|
|
@@ -548,15 +549,15 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
|
|
|
548
549
|
|
|
549
550
|
A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` sets `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"`; screens are `.tsx` files. There is no DOM and no React.
|
|
550
551
|
|
|
551
|
-
- Tags: `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel` (swallows taps), `image` (`texture`, `fit?: "contain" | "cover" | "fill"`), `icon` (`name`, `fit?`), `text` (`content: string | Message`, or `bind={bind(Counter, "value")}` with `components={[Counter({ value })]}`; formats `int`, `mm:ss`, `h:mm:ss`, `duration`; `Countdown({ until })` with `bind(Countdown, "left", { format: "mm:ss" })` counts down to a `clock` moment; `style` is a text style key), `button` (`intent` + `payload`, or `local`, never both), `scroll` (`axis
|
|
552
|
-
- Style vocabulary (`defineStyle`, plain data): flow `direction`, `wrap`, `justify`, `align`, `alignSelf`, `gap`, `grow`, `shrink`; box `padding`, `margin`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `aspect`, `overflow`; position `position`, `left`, `top`, `right`, `bottom`, `reason`; visual `fill`, `stroke`, `strokeWidth`, `radius`, `alpha`; variants `is: { pressed?, disabled?, active?, selected? }` and `when: { portrait?, landscape?, tall?, wide? }`, each a partial style merged in when the flag holds. Lengths are reference units or `"safeArea.top" | "safeArea.right" | "safeArea.bottom" | "safeArea.left"`. No font size here: it comes from the text style.
|
|
552
|
+
- Tags: `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel` (swallows taps), `image` (`texture`, `fit?: "contain" | "cover" | "fill"`), `icon` (`name`, `fit?`), `text` (`content: string | Message`, or `bind={bind(Counter, "value")}` with `components={[Counter({ value })]}`; formats `int`, `mm:ss`, `h:mm:ss`, `duration`; `Countdown({ until })` with `bind(Countdown, "left", { format: "mm:ss" })` counts down to a `clock` moment; `style` is a text style key), `button` (`intent` + `payload`, or `local`, never both), `scroll` (`axis?: "x" | "y"` (`"y"`), fixed at spawn, another one later warns `ui:scroll-axis-fixed`; children for a short list, laid out as a column on `y` and a row on `x`, or the windowed form `rows`, `rowHeight`, `overscan?` (5), `row: index => node`, on `y` only: `axis: "x"` with `rows` throws `[game] A windowed scroll moves on y only.`; row state belongs in the model, a row that leaves the window loses its local state; `snap?: number[]` stops in offsets, kept inside `0..max`: a release glides to the stop nearest `offset + v × 260` (v in units a ms) on `1 − (1 − p)³` over `min(620, 260 + distance × 1.1)` ms; without `snap` a release flings, `v ×= 0.998` a ms until under 0.02, 0 when the finger rested over 50 ms; `offset?: number` (0) where it stands from the start, drawn there in its first frame, a new value glides there, the same value does nothing; hard edges, nothing passes on to a parent scroll; a press on a child that leaves the tap slop along the scroll's axis goes to the scroll and the child answers nothing, a finger on a moving scroll only stops it; every number in `time.delta` ms, so a time scale applies; the public component `ScrollState { offset, max, moving }` on its entity, whole units `0..max`, written when it changes and once with `moving: false` when it stops, read by a system and by `game.explain`; `app.ui.scroll(key, to)` and the visual step `{ scroll: { key, to } }` set it at once), `input` (a text field; its text lives in the component's `local`, see the ui README). Common props: `key`, `style`, `state: { active?, disabled?, selected? }`, `motion`, `components` (extra component values for the element's entity, such as a filter: `components={[Glow({ strength: 2 })]}`, or `DebugTrigger()`: five taps within 3000 ms of wall time on that view call `app.debug.overlay.open()` on a page with the agent flag, nothing elsewhere; put it on a `button` naming nothing); containers take `hosts?: string[]` (projections drawn inside the element: world views hang under it from the slot's first frame; a JSX projection is laid out in the slot's content box). Children draw and are hit in markup order at equal `zIndex`; the roots of a scene draw in the order of its `projections` (the `ui` layer sorts by `Order`; `ui: { sort: "none" }` keeps insertion order). `defineMotion({ when: "change" })` plays its `enter` and `exit` only for a commit or a popup, never when its screen mounts or unmounts; declare a motion at module level (`ui:motion-rebuilt` names a loop rebuilt in a view). Style adds `nineSlice`, `tint`, `offsetX`, `offsetY`, `scale`, `origin`, `fit: "contain"`; state variants `is: { hover, pressed, disabled, active, selected, covered }`. `popup(Component, props, { over })` stacks a popup over a kept one.
|
|
553
|
+
- Style vocabulary (`defineStyle`, plain data): flow `direction`, `wrap`, `justify`, `align`, `alignSelf`, `gap`, `grow`, `shrink`; box `padding`, `margin`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `aspect`, `overflow`; position `position`, `left`, `top`, `right`, `bottom`, `reason`; visual `fill`, `stroke`, `strokeWidth`, `radius`, `alpha`, `shape: "rect" | "triangle" | "arc"` (`"rect"`; a triangle fills the box pointing right; an arc is only a `stroke` of `strokeWidth` on the circle inscribed in the box, `min(width, height) − strokeWidth` across, from 12 o'clock clockwise over `sweep`), `dash` (dash length of the stroke, gaps of half a dash; not on an arc), `sweep` (0..1, default 1, clamped; 0 draws nothing, 1 a closed ring), `cap: "round" | "butt"` (`"round"`; the ends of an open arc); variants `is: { pressed?, disabled?, active?, selected? }` and `when: { portrait?, landscape?, tall?, wide? }`, each a partial style merged in when the flag holds. Lengths are reference units or `"safeArea.top" | "safeArea.right" | "safeArea.bottom" | "safeArea.left"`. No font size here: it comes from the text style.
|
|
553
554
|
- `defineComponent(name, { local?, outcomes?, view(props, local) })`. A component with `outcomes` is a popup: `await fx(popup(Component, props))` opens the gate for its outcomes and resolves with `{ intent, payload }` of the pressed button. A button with `local` writes into the nearest component instance; it never reaches the graph.
|
|
554
555
|
- `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `strings/<locale>.json` of any feature or sub-feature, as ICU MessageFormat 1 (`{n, plural, one {# order} other {# orders}}`, `select`, `selectordinal`, `number`, `date`, `time`; no skeletons, no `currency`). `bun run assets:keys` compiles them. The keys of the top-level features and the layers go into `generated/strings.<locale>.ts`, which one feature registers; a sub-feature's keys start with `<name>.` (`ep01.title`) and go into `generated/strings.<name>.<locale>.ts`, which the sub-feature registers lazily, `strings: { en: () => import("@generated/strings.ep01.en"), ru: () => import("@generated/strings.ep01.ru") }`. With `assets` composed those modules load and unload with the bundle named like the sub-feature: every way the bundle comes (a node entry, a scene's `bundles`, `load`, a restore, the `safeNode`) brings its strings, a locale switch brings them in the new locale, and while the bundle is not loaded a key of it gives `⟦key⟧` and `has(key)` is false. A compiled module calls the root helpers `messageArgument(value, intl)` (an element stays an element part, a list goes through `Intl.ListFormat`, a number through `Intl.NumberFormat`, else text) and `messageDuration(ms)` (`95_000` gives `{ minutes: 1, seconds: 35 }`). `app.i18n`: `locale()`, `setLocale(locale)` (async, emits `i18n:locale-changed`), `format(message, locale?)` returns `Part[]`, `plain(message)` returns a string, `has(key)`, `isKey(value)`, `locales()`, `replace(locale, messages)` (dev hot swap of a generated strings file). Config: `locale: "en"`, `fallback: "en"`, `locales: {}`.
|
|
555
556
|
- 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.
|
|
556
557
|
- 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?, 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)` gives the same numbers in a browser and in Bun. `app.text.replaceStyles(styles)` is the dev hot swap of a saved `defineTextStyles` value.
|
|
557
558
|
- 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`.
|
|
558
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.
|
|
559
|
-
- `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`, `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".
|
|
560
|
+
- `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".
|
|
560
561
|
|
|
561
562
|
```tsx
|
|
562
563
|
// features/hud/view.tsx
|
|
@@ -654,7 +655,7 @@ A plugin that owns a resource stores it in its state and frees it in `onStop: ({
|
|
|
654
655
|
Two subpaths, neither on the root (V4). An editor, MCP tools and e2e scripts reach a running game through them; `docs/doors.md` in the engine repository has the full catalogue.
|
|
655
656
|
|
|
656
657
|
- `@moku-labs/game/inspect`: `read(app, source, input?)` reads once; `watch(app, source, input, fn)` reads on the `signals` phase when the source changes and returns the stop function; `defineSource`; the frozen catalogue `sources`; the types `Source`, `InputSchema`, `InputOf`. Every source only reads, so the door is safe in a production build. Keys of `sources`: `graph`, `position`, `history`, `tainted`, `cheats`, `model`, `entities`, `projections`, `ui`, `locate`, `render`, `sounds`, `audioMuted`, `effects`, `filters`, `assets`, `log`, `explain`, `diff`, `schema`, `at`; their ids are `game.<key>`. `filters` answers `{ kinds, off }` of `effects.diagnostics`. `locate` takes `{ key }` (a ui element) or `{ target }` (a view by projection key) and answers its box in CSS px, the numbers Playwright clicks. `at` takes `{ x, y }` in CSS px and lists the views under that point, topmost first. `explain` takes `{ entity }` and answers that entity in full, `undefined` for a stale id. `diff` takes `{ from, to }` frames and answers what changed per entity; it throws in a production build and for a frame outside the last 120. `schema` lists every component and tag type the world met, with the JSON kind of each field. `audioMuted` answers whether the master bus is muted. `sounds` throws `[game] The source game.sounds needs audioPlugin.`, `audioMuted` throws `[game] The source game.audioMuted needs audioPlugin.` and `effects` and `filters` throw `[game] The source game.effects needs effectsPlugin.` (`game.filters` for `filters`) when that plugin is not installed; the editor lists such a source as not installed.
|
|
657
|
-
- `@moku-labs/game/control`: `run(app, command, input?)` resolves `{ value, state }`, `state` being `{ path, frame, tainted }`; `defineCommand`; `controlRefused`; `controlReadOnly`; the frozen catalogue `commands`; the types `Command`, `Ran`, `ControlReadOnlyError`. Keys of `commands`: `answer`, `tap`, `drag`, `key`, `fill`, `walk`, `bookmark`, `restore`, `saveExport`, `saveImport`, `step`, `pause`, `resume`, `capture`, `debug`, `reducedMotion`, `timeScale`, `frameRate`, `mute`, `trace`, `filter`. `capture` resolves `{ png }`, and `{ png, legend }` with `legend: true`; `undefined` while the renderer is inert. `timeScale` takes `{ scale }`; 0 freezes game time while the frames keep running. `frameRate` takes `{ fps? }`, calls `time.setRate(fps ?? "auto")` and answers `time.rate()`. `mute` takes `{ muted }`, mutes the master bus and answers the flag. Without `audioPlugin` it throws `[game] The command game.mute needs audioPlugin.` `trace` takes `{ path }`, a list of views by projection key, and answers whether the gate took the answer. `filter` takes `{ kind, on }`, switches the filter kind through `effects.diagnostics` (cosmetic) and answers the off kinds; an unknown kind throws `[game] No filter kind "fx.nope".` naming the known ones; without `effectsPlugin` it throws `[game] The command game.filter needs effectsPlugin.`
|
|
658
|
+
- `@moku-labs/game/control`: `run(app, command, input?)` resolves `{ value, state }`, `state` being `{ path, frame, tainted }`; `defineCommand`; `controlRefused`; `controlReadOnly`; the frozen catalogue `commands`; the types `Command`, `Ran`, `ControlReadOnlyError`. Keys of `commands`: `answer`, `tap`, `drag`, `key`, `fill`, `scroll`, `walk`, `bookmark`, `restore`, `saveExport`, `saveImport`, `step`, `pause`, `resume`, `capture`, `debug`, `reducedMotion`, `timeScale`, `frameRate`, `mute`, `trace`, `filter`. `capture` resolves `{ png }`, and `{ png, legend }` with `legend: true`; `undefined` while the renderer is inert. `timeScale` takes `{ scale }`; 0 freezes game time while the frames keep running. `frameRate` takes `{ fps? }`, calls `time.setRate(fps ?? "auto")` and answers `time.rate()`. `mute` takes `{ muted }`, mutes the master bus and answers the flag. Without `audioPlugin` it throws `[game] The command game.mute needs audioPlugin.` `trace` takes `{ path }`, a list of views by projection key, and answers whether the gate took the answer. `scroll` takes `{ key, to }`, calls `ui.scroll(key, to)` and answers what it answers; a visual test writes it as the step `{ scroll: { key, to } }`. `filter` takes `{ kind, on }`, switches the filter kind through `effects.diagnostics` (cosmetic) and answers the off kinds; an unknown kind throws `[game] No filter kind "fx.nope".` naming the known ones; without `effectsPlugin` it throws `[game] The command game.filter needs effectsPlugin.`
|
|
658
659
|
- Commands run in dev builds and debug builds. `__MOKU_GAME_DEV__ === true` (the dev page, a test's `vi.stubGlobal("__MOKU_GAME_DEV__", true)`) or `__MOKU_GAME_CONTROL__ === true` (`--debug`) runs every command; `__MOKU_GAME_AGENT__ === true` alone (`--debug=read`) runs the `read` and `cosmetic` ones and `run` refuses the others with `controlReadOnly(id)`, an error with `reason: "read-only"`: `[game] The command game.tap changes the game: this debug build runs read and cosmetic commands only.`; no flag: `run` throws `[game] Control commands run in dev builds only.` `moku-game build` defines the three flags `false`; a game without the shell uses a bundler `define` and should add `__MOKU_GAME_AGENT__: "false"` and `__MOKU_GAME_CONTROL__: "false"`. `saveExport` answers the committed `{ player, session, rng }` (read); `saveImport` takes `{ save }` of `saveExport`, restores it at the main flow's start and flushes the store (raw: not on a `--debug=read` page).
|
|
659
660
|
- A command's effect is `read`, `route`, `cosmetic`, `cheat` or `raw`. A `cheat` or `raw` command taints the session and is journaled; read it with `sources.tainted` and `sources.cheats`. Of the base commands `restore` and `saveImport` are `raw`; `capture` with `diff` writes the state too, so a `--debug=read` page refuses it. `bookmark` answers `flow.bookmark()` plus `scene`, the mounted scene; `restore` calls `scenes.expect(scene)` before `flow.restore`, so a bookmark at a popup restores the scene under it, also on a page that shows another scene. A bookmark taken while an effect that takes answers waits names the waiting transit node (`info/show` in the mini game) and carries `rest`; `restore` of it answers once that node's gate is open, with `pending.gate` in its value, and the popup shows on the next frame.
|
|
660
661
|
- A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run, overlay?, value? })`: `overlay: "toggle"` (input exactly `{ on: "boolean" }` and `value: app => boolean`, the switch's state) or `overlay: "button"` (no required input, no value) lists the command in the editor's overlay, which shows only such commands; any other combination is a type error. The id is camelCase words joined by dots, at least two: `dice.rolls`. An input schema maps field names to `"string"`, `"number"`, `"boolean"` or `"json"`; a trailing `?` makes one optional. A command body starts with `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`: it runs in dev and is refused on a debug page. A `.dev` command meant for a debug page writes the agent form for a read or cosmetic effect, `if ((typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) && (typeof __MOKU_GAME_AGENT__ === "undefined" || !__MOKU_GAME_AGENT__)) throw controlRefused();`, and the control form (`__MOKU_GAME_CONTROL__` in place of `__MOKU_GAME_AGENT__`) for the others. Keep them in `.dev` modules that only the dev page, a debug page and the tests import: the editor's dev page (`preparePage` with agents) and `moku-game build --debug` import every `**/*.dev.ts`, `moku-game build` none.
|