@moku-labs/game 0.0.1 → 0.0.2
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/README.md +424 -37
- package/dist/assets.d.mts +161 -0
- package/dist/assets.mjs +4729 -0
- package/dist/component-DGg5DqKK.mjs +44 -0
- package/dist/control.d.mts +162 -0
- package/dist/control.mjs +758 -0
- package/dist/define-sFoO3y6X.d.mts +263 -0
- package/dist/headless-CvamCUcR.mjs +186 -0
- package/dist/headless-KaSWcd0s.d.mts +181 -0
- package/dist/index.d.mts +1204 -50
- package/dist/index.mjs +20978 -2010
- package/dist/inspect.d.mts +109 -0
- package/dist/inspect.mjs +519 -0
- package/dist/jsx-dev-runtime.d.mts +2 -0
- package/dist/jsx-dev-runtime.mjs +2 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +2 -0
- package/dist/memory-CvgdnsQO.mjs +259 -0
- package/dist/registry-DlpRCibU.mjs +384 -0
- package/dist/runtime-DRlwxkIv.mjs +182 -0
- package/dist/runtime-DiOTkDZz.d.mts +77 -0
- package/dist/session-DmAxY6Ll.mjs +145 -0
- package/dist/testing.d.mts +23 -111
- package/dist/testing.mjs +12 -289
- package/dist/types-BxkNNYul.d.mts +2840 -0
- package/dist/types-CTPS9GBu.d.mts +742 -0
- package/dist/types-DD-QrG_z.d.mts +588 -0
- package/dist/types-DWILGrPn.d.mts +622 -0
- package/dist/types-DYLnSgMI.d.mts +570 -0
- package/dist/types-JNc_UQBo.d.mts +2896 -0
- package/dist/types-yg_ywtT-.d.mts +1859 -0
- package/dist/visual-BDUHSRvf.mjs +734 -0
- package/package.json +26 -4
- package/dist/registry-DWV5C0Mf.mjs +0 -666
- package/dist/types-BfsmUzLC.d.mts +0 -1908
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.
|
|
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.
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
<br/>
|
|
17
17
|
|
|
18
|
-
[Why](#why-moku-labsgame) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Plugins](#plugins) · [Events](#events) · [Configuration](#configuration) · [Development](#development) · [Requirements](#requirements) · [Docs](#docs)
|
|
18
|
+
[Why](#why-moku-labsgame) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Plugins](#plugins) · [Interface in JSX](#interface-in-jsx) · [Doors for the editor](#doors-for-the-editor) · [Events](#events) · [Configuration](#configuration) · [Development](#development) · [Requirements](#requirements) · [Docs](#docs)
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -25,23 +25,23 @@
|
|
|
25
25
|
- **State commits only on edges.** A node works on drafts. The runner commits them when the node returns an outcome. A node that throws changes nothing.
|
|
26
26
|
- **Position is data.** Node path, input and state describe the whole game. That gives checkpoints, rollback, bookmarks, fast walk and repro runs.
|
|
27
27
|
- **Input and world events are answers.** A rest node waits. A player answer comes through the gate, a world event comes through the inbox. Nothing else moves the graph.
|
|
28
|
-
- **The screen is a projection, not the game.** Rendering reads committed state and never owns it.
|
|
28
|
+
- **The screen is a projection, not the game.** Rendering reads committed state and never owns it. A projection is one pure `view(item)` function; the engine diffs the components and plays enter, exit, change and settle motions. The same game plays whole headless.
|
|
29
29
|
- **Deterministic by construction.** Time is an input named `now`. Randomness is a persisted `rng` stream. Lint rule L3 refuses `Date.now` and `Math.random` in the logic set.
|
|
30
30
|
|
|
31
31
|
## Status
|
|
32
32
|
|
|
33
|
-
|
|
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.
|
|
34
34
|
|
|
35
35
|
| Milestone | State | Scope | Exit criterion |
|
|
36
36
|
|---|---|---|---|
|
|
37
37
|
| V1 | built | `time`, `lifecycle`, `model`, `clock`, `flow`, the `@moku-labs/game/testing` entry | A fixture game is played to the end headless |
|
|
38
|
-
| V2 |
|
|
39
|
-
| V3 |
|
|
40
|
-
| V4 |
|
|
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
|
+
| 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
|
+
| V4 | doors built | `/inspect` and `/control` entries | External tools can read and drive a game |
|
|
41
41
|
| V5 | planned | `effects`, production mode of `assets`, visual test helpers | Not defined yet |
|
|
42
42
|
| V6 | planned | `platform` | A template game runs on a phone |
|
|
43
43
|
|
|
44
|
-
Rendering
|
|
44
|
+
Rendering: WebGPU is preferred, with Pixi's WebGL fallback and an honest "unsupported device" screen when neither exists.
|
|
45
45
|
|
|
46
46
|
## Install
|
|
47
47
|
|
|
@@ -53,7 +53,7 @@ bun add @moku-labs/game pixi.js
|
|
|
53
53
|
> **Status: `0.0.0`, not published yet.** The package is not on npm. The command above is the intended install line. `pixi.js` `^8.0.0` is a peer dependency. No V1 code imports it.
|
|
54
54
|
|
|
55
55
|
> [!IMPORTANT]
|
|
56
|
-
> Bun only. ESM only. `"sideEffects": false`. There is no CJS build.
|
|
56
|
+
> Bun only. ESM only. `"sideEffects": false`. There is no CJS build. `yoga-layout` is a dependency the `ui` plugin loads lazily; nothing imports it before `onStart`.
|
|
57
57
|
|
|
58
58
|
## Quick start
|
|
59
59
|
|
|
@@ -149,6 +149,8 @@ export const createGame = (seed: "from-save" | number = "from-save") =>
|
|
|
149
149
|
});
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
A hook that throws never stops the game. The engine writes it to the log as the error entry `"game: a hook failed"`; read it with `app.log.trace()`. A game can add its own `onError: (error, ctx) => …` to `createApp`; the kernel calls both.
|
|
153
|
+
|
|
152
154
|
**5. Play it headless.** `createHeadless` returns a game object once the graph rests at its first
|
|
153
155
|
rest node. Its `walk` method plays a route.
|
|
154
156
|
|
|
@@ -181,7 +183,7 @@ it("plays two rolls and a reset without a screen", async () => {
|
|
|
181
183
|
In a live game the same answer comes from the screen: `app.flow.gate.answer({ intent: "roll" })`.
|
|
182
184
|
|
|
183
185
|
> [!TIP]
|
|
184
|
-
> Types reach a game through one namespace per plugin: `import type { Flow, Model, Clock, Lifecycle, Time } from "@moku-labs/game"`, then `Flow.RouteStep`, `Model.PlayerStateProvider`, `Time.Phase`.
|
|
186
|
+
> Types reach a game through one namespace per plugin: `import type { Flow, Model, Clock, Lifecycle, Time } from "@moku-labs/game"`, then `Flow.RouteStep`, `Model.PlayerStateProvider`, `Time.Phase`. The screen and interface plugins follow the same rule: `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`. `Text` is the component, so its type namespace is `TextTypes`.
|
|
185
187
|
|
|
186
188
|
> [!TIP]
|
|
187
189
|
> A larger worked example lives in [`tests/integration/merge-game/`](./tests/integration/merge-game). It is a small game written on the public API only, with sub-flows, a slot, a feature and timers. It is an internal test fixture and is not published. Its scenario is [`tests/integration/template-merge.test.ts`](./tests/integration/template-merge.test.ts).
|
|
@@ -196,7 +198,7 @@ flowchart LR
|
|
|
196
198
|
N --> E["Edge<br/>commit, journal, flow:edge"]
|
|
197
199
|
E --> R
|
|
198
200
|
E --> M["model<br/>committed state and save"]
|
|
199
|
-
M --> S["Screen<br/>projection,
|
|
201
|
+
M --> S["Screen<br/>projection, JSX"]
|
|
200
202
|
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
|
|
201
203
|
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
|
|
202
204
|
class P,W,S u
|
|
@@ -223,7 +225,7 @@ flowchart LR
|
|
|
223
225
|
|
|
224
226
|
## Plugins
|
|
225
227
|
|
|
226
|
-
|
|
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`.
|
|
227
229
|
|
|
228
230
|
### Built
|
|
229
231
|
|
|
@@ -233,7 +235,17 @@ Seven plugins are on every app today. `log` and `env` come from [`@moku-labs/com
|
|
|
233
235
|
| [`lifecycle`](./src/plugins/lifecycle/README.md) | Standard | The stack of pause reasons. Pauses `time` by a direct call | `push(reason)`, `pop(reason)`, `reasons()`, `isPaused()` |
|
|
234
236
|
| [`model`](./src/plugins/model/README.md) | Very Complex | The `session` tree and the save document `{ player, rng }`, transactions, rest-point rollback, rng streams | `store.load()`, `store.snapshot()`, `store.begin()`, `store.markRest()`, `store.markBarrier(txId)`, `store.rollback()`, `store.restore(input)`, `store.flush()`, `rng.peek(id)` |
|
|
235
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()` |
|
|
236
|
-
| [`flow`](./src/plugins/flow/README.md) | Very Complex | The graph: runner, gate, inbox, effects gateway, features registry | `run()`, `
|
|
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
|
+
| [`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()` |
|
|
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
|
+
| [`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
|
+
| [`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)` |
|
|
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
|
+
| [`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()` |
|
|
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()` |
|
|
237
249
|
|
|
238
250
|
```mermaid
|
|
239
251
|
flowchart LR
|
|
@@ -243,13 +255,47 @@ flowchart LR
|
|
|
243
255
|
F --> M["model"]
|
|
244
256
|
F --> C["clock"]
|
|
245
257
|
L --> T
|
|
258
|
+
W["world"] --> T
|
|
259
|
+
W --> M
|
|
260
|
+
W --> F
|
|
261
|
+
R["renderer"] --> T
|
|
262
|
+
R --> L
|
|
263
|
+
R --> W
|
|
264
|
+
I["input"] --> F
|
|
265
|
+
I --> W
|
|
266
|
+
I --> R
|
|
267
|
+
A["assets"] --> F
|
|
268
|
+
A --> R
|
|
269
|
+
S["scenes"] --> F
|
|
270
|
+
S --> W
|
|
271
|
+
S --> A
|
|
272
|
+
AN["anim"] --> F
|
|
273
|
+
AN --> W
|
|
274
|
+
AN --> R
|
|
275
|
+
N["i18n"] --> F
|
|
276
|
+
X["text"] --> W
|
|
277
|
+
X --> R
|
|
278
|
+
X --> A
|
|
279
|
+
X --> N
|
|
280
|
+
U["ui"] --> I
|
|
281
|
+
U --> AN
|
|
282
|
+
U --> X
|
|
283
|
+
AU["audio"] --> L
|
|
284
|
+
AU --> M
|
|
285
|
+
AU --> F
|
|
286
|
+
AU --> A
|
|
287
|
+
AU --> S
|
|
246
288
|
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
|
|
247
289
|
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
|
|
290
|
+
classDef s fill:#5c940d,stroke:#3d6208,color:#fff;
|
|
291
|
+
classDef v fill:#862e9c,stroke:#5f1f70,color:#fff;
|
|
248
292
|
class G u
|
|
249
293
|
class F,T,L,M,C m
|
|
294
|
+
class W,R,I,A,S s
|
|
295
|
+
class AN,N,X,U,AU v
|
|
250
296
|
```
|
|
251
297
|
|
|
252
|
-
An arrow means "depends on". `time`, `model` and `clock` depend on nothing. The plugins are registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`.
|
|
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.
|
|
253
299
|
|
|
254
300
|
### Planned
|
|
255
301
|
|
|
@@ -257,16 +303,6 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
|
|
|
257
303
|
|
|
258
304
|
| Plugin | Milestone | Tier | Depends on | Will own |
|
|
259
305
|
|---|---|---|---|---|
|
|
260
|
-
| `world` | V2 | Very Complex | `time`, `model`, `flow` | ECS world and the projections of the model |
|
|
261
|
-
| `renderer` | V2 | Very Complex | `time`, `lifecycle`, `world` | The Pixi host, sync and viewport. Pixi is loaded lazily |
|
|
262
|
-
| `input` | V2 | Standard | `time`, `flow`, `world`, `renderer` | Pointer listeners on the canvas |
|
|
263
|
-
| `assets` | V2 | Complex | `flow`, `renderer` | Manifest, tiers, preload, budget |
|
|
264
|
-
| `scenes` | V2 | Standard | `flow`, `world`, `assets` | Scenes as data, switched through `flow.onEnter` |
|
|
265
|
-
| `anim` | V3 | Complex | `flow`, `world` | Tweens and motion |
|
|
266
|
-
| `i18n` | V3 | Standard | `flow`, `world`, `assets` | String tables per locale |
|
|
267
|
-
| `text` | V3 | Complex | `world`, `renderer`, `assets`, `i18n` | Text rendering and the text field |
|
|
268
|
-
| `ui` | V3 | Very Complex | `flow`, `world`, `renderer`, `anim`, `i18n`, `text` | JSX components, styles, layout |
|
|
269
|
-
| `audio` | V3 | Standard | `lifecycle`, `flow`, `assets`, `scenes` | Audio context and unlock |
|
|
270
306
|
| `effects` | V5 | Complex | `flow`, `world`, `renderer` | Particles, filters, frames |
|
|
271
307
|
| `platform` | V6 | Standard | `lifecycle`, `flow` | The native provider: background, system dialogs |
|
|
272
308
|
|
|
@@ -276,14 +312,32 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
|
|
|
276
312
|
|---|---|---|
|
|
277
313
|
| `createApp` | function | Creates a game application |
|
|
278
314
|
| `createPlugin` | function | Creates a game plugin bound to the engine's config and events |
|
|
279
|
-
| `defineGame` | function | Returns
|
|
280
|
-
| `defineFeature` | function | Turns a feature description into a plugin |
|
|
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` |
|
|
281
317
|
| `type`, `exit`, `to`, `slot` | functions | Type tag of a payload, and the three graph helpers for edge targets and slots |
|
|
282
318
|
| `schedule`, `guide`, `hint` | functions | Effect descriptors: next due moment, tutorial narrowing of the gate, cosmetic hint |
|
|
283
319
|
| `SaveUnreadableError` | class | Thrown by `model.store.load()` when the save cannot be read |
|
|
284
|
-
| `
|
|
285
|
-
| `
|
|
286
|
-
| `
|
|
320
|
+
| `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree` | functions and components | The ECS vocabulary of `world` and the projection helper |
|
|
321
|
+
| `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite` | components | The display components of `renderer` |
|
|
322
|
+
| `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
|
+
| `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 |
|
|
325
|
+
| `tr` | function | `tr(key, params?)` builds a frozen `Message`; no locale is read at the call site |
|
|
326
|
+
| `Text`, `label`, `defineTextStyles` | component and functions | Words on the screen and the text styles a feature registers |
|
|
327
|
+
| `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite` | functions and components | Interface components, the popup effect, the style vocabulary, the rect of an element |
|
|
328
|
+
| `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 |
|
|
331
|
+
|
|
332
|
+
### Other entries
|
|
333
|
+
|
|
334
|
+
| Entry | Runs in | Exports |
|
|
335
|
+
|---|---|---|
|
|
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 |
|
|
338
|
+
| `@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
|
+
| `@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
|
+
| `@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 |
|
|
287
341
|
|
|
288
342
|
### Testing entry
|
|
289
343
|
|
|
@@ -300,6 +354,293 @@ Not built. Names are reserved: `defineFeature` refuses them as feature names. Sc
|
|
|
300
354
|
|
|
301
355
|
A `HeadlessGame` has `walk(route)`, `answer(answer)`, `state()`, `history()` and `stop()`.
|
|
302
356
|
|
|
357
|
+
## Interface in JSX
|
|
358
|
+
|
|
359
|
+
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.
|
|
360
|
+
|
|
361
|
+
```jsonc
|
|
362
|
+
// tsconfig.json of the game
|
|
363
|
+
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@moku-labs/game" } }
|
|
364
|
+
```
|
|
365
|
+
|
|
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.
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
// features/hud/view.tsx — the HUD, a reward popup and the choreography they share
|
|
370
|
+
import type { Anim } from "@moku-labs/game";
|
|
371
|
+
import { Transform, mark, parallel, sequence, sfx, tween, type } from "@moku-labs/game";
|
|
372
|
+
import { defineAnimation, defineComponent, defineFeature, defineTextStyles, projection, tr } from "../../state";
|
|
373
|
+
|
|
374
|
+
export const hud = projection({
|
|
375
|
+
name: "hud",
|
|
376
|
+
layer: "ui",
|
|
377
|
+
from: player => ({ coins: player.coins }),
|
|
378
|
+
view: hud => (
|
|
379
|
+
<row key="bar" style={{ gap: 16, padding: { top: "safeArea.top", left: 24, right: 24 }, width: "100%", height: 120 }}>
|
|
380
|
+
<text key="coins" style="hud.digits" content={tr("hud.coins", { n: hud.coins })} />
|
|
381
|
+
<button key="settings" intent="openSettings" style={{ width: 96, height: 96 }}>
|
|
382
|
+
<icon name="hud.gear" />
|
|
383
|
+
</button>
|
|
384
|
+
</row>
|
|
385
|
+
)
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
export const RewardPopup = defineComponent("RewardPopup", {
|
|
389
|
+
outcomes: { claim: type<{ orderId: string }>() },
|
|
390
|
+
view: (props: { orderId: string; gold: number }) => (
|
|
391
|
+
<panel key="reward" style={{ nineSlice: "ui.panel", direction: "column", gap: 16, padding: 32, width: 600, height: 400, fit: "contain" }}>
|
|
392
|
+
<text key="title" content={tr("orders.complete")} />
|
|
393
|
+
<text key="gold" style="hud.digits" content={String(props.gold)} />
|
|
394
|
+
<button key="claim" intent="claim" payload={{ orderId: props.orderId }} style={{ width: 240, height: 88 }}>
|
|
395
|
+
<text content={tr("common.claim")} />
|
|
396
|
+
</button>
|
|
397
|
+
</panel>
|
|
398
|
+
)
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
export const popCoins = defineAnimation("hud.popCoins", {
|
|
402
|
+
slots: { coins: type<Anim.Target>() },
|
|
403
|
+
build: ({ coins }) =>
|
|
404
|
+
sequence(
|
|
405
|
+
tween(coins, Transform, { scale: 1.2 }, { ms: 120 }),
|
|
406
|
+
parallel(tween(coins, Transform, { scale: 1 }, { ms: 200, ease: "outCubic" }), sfx("hud.coins")),
|
|
407
|
+
mark("done")
|
|
408
|
+
)
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
export const hudFeature = defineFeature("hud", {
|
|
412
|
+
projections: [hud],
|
|
413
|
+
ui: [RewardPopup],
|
|
414
|
+
animations: [popCoins],
|
|
415
|
+
textStyles: defineTextStyles({ "hud.digits": { font: "ui.font-digits", size: 40, fill: 0xffe082, digits: true } }),
|
|
416
|
+
strings: { en: () => import("../../generated/strings.en") }
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A popup is an effect. The node awaits it, the gate opens for the outcomes of the component, and the promise resolves with the intent of the button the player pressed. The choreography is played the same way.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
// features/orders/deliver.ts
|
|
424
|
+
import { play, sfx, type } from "@moku-labs/game";
|
|
425
|
+
import { defineNode, popup } from "../../state";
|
|
426
|
+
import { popCoins, RewardPopup } from "../hud/view";
|
|
427
|
+
|
|
428
|
+
export const deliver = defineNode({
|
|
429
|
+
outcomes: { claimed: type<{ orderId: string }>() },
|
|
430
|
+
run: async ({ player, fx, out }) => {
|
|
431
|
+
fx(sfx("orders.complete"));
|
|
432
|
+
const answer = (await fx(popup(RewardPopup, { orderId: "o1", gold: 5 }))) as { intent: "claim"; payload: { orderId: string } };
|
|
433
|
+
|
|
434
|
+
player.coins += 5;
|
|
435
|
+
await fx(play(popCoins, { coins: { projection: "hud", key: "coins" } }));
|
|
436
|
+
return out.claimed({ orderId: answer.payload.orderId });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Headless the same node runs to the end: `popup` resolves through the gate, `play` finishes at once in fast mode, and `sfx` without `audio` resolves `undefined`. The strings behind `tr` come from `features/*/strings/<locale>.json`; `bun run assets:keys` compiles them next to the asset keys, and `strings: Strings` in `defineGame` makes a wrong key or a missing parameter a compile error.
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
// game.ts — sound is opt-in, the buses follow the committed player
|
|
445
|
+
createApp({
|
|
446
|
+
plugins: [...screen, audioPlugin, hudFeature],
|
|
447
|
+
pluginConfigs: {
|
|
448
|
+
renderer: { mount: "#game" },
|
|
449
|
+
audio: { volumes: player => (player as Player).settings.audio }
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`volumes` receives the committed player as `Json`, so the game names its own type once. `player.settings.audio` is `{ master?, music?, sfx? }`, committed by a settings node like any other state and applied on every `model:committed`.
|
|
455
|
+
|
|
456
|
+
> [!TIP]
|
|
457
|
+
> `app.ui.tree()` answers the live screen as plain data, `app.ui.find(key)` the entity of a keyed element, and `app.ui.lint()` the tap targets under `tapTargetPt`, the text that overflows in some locale and the absolute elements without a `reason`. The example app of the `ui` tests, [`src/plugins/ui/__tests__/app.tsx`](./src/plugins/ui/__tests__/app.tsx), is a whole HUD with a settings component, a scrolling list and a popup, run in plain Bun.
|
|
458
|
+
|
|
459
|
+
## Doors for the editor
|
|
460
|
+
|
|
461
|
+
The editor, MCP tools and e2e scripts reach a running game through two subpaths. Neither is on the root.
|
|
462
|
+
|
|
463
|
+
| Subpath | Exports | In a production build |
|
|
464
|
+
|---|---|---|
|
|
465
|
+
| `@moku-labs/game/inspect` | `read`, `watch`, `defineSource`, the catalogue `sources`, the types `Source`, `InputSchema`, `InputOf` | Safe. Every source only reads. The door reaches no command module, so its bundle carries no command |
|
|
466
|
+
| `@moku-labs/game/control` | `run`, `defineCommand`, `controlRefused`, the catalogue `commands`, the types `Command`, `Ran` | Dev only. `run` throws unless the dev flag is `true`, and a `define` of `false` drops every command body |
|
|
467
|
+
|
|
468
|
+
A source or a command is data: an id, a title, an input schema and one function. `sources` and `commands` are frozen objects keyed by a short name, so an editor registers `Object.values(sources)` and `Object.values(commands)`. Each descriptor lives in the plugin that owns its data, in that plugin's `inspect.ts` or `control.ts`; the machinery lives in `flow`, see its [README](./src/plugins/flow/README.md#doors-for-the-editor).
|
|
469
|
+
|
|
470
|
+
The dice game of the [Quick start](#quick-start), driven through both doors:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
// dice.test.ts
|
|
474
|
+
import { commands, run } from "@moku-labs/game/control";
|
|
475
|
+
import { read, sources, watch } from "@moku-labs/game/inspect";
|
|
476
|
+
import { createHeadless } from "@moku-labs/game/testing";
|
|
477
|
+
import { it, vi } from "vitest";
|
|
478
|
+
import { createGame } from "./game";
|
|
479
|
+
|
|
480
|
+
it("rolls, bookmarks and restores through the doors", async () => {
|
|
481
|
+
vi.stubGlobal("__MOKU_GAME_DEV__", true);
|
|
482
|
+
const app = createGame(42);
|
|
483
|
+
const game = await createHeadless(app);
|
|
484
|
+
|
|
485
|
+
read(app, sources.position); // { path: "home", flow: "main", node: "home", waiting: ["roll", "reset"] }
|
|
486
|
+
|
|
487
|
+
const seen: string[] = [];
|
|
488
|
+
const stop = watch(app, sources.position, undefined, position => {
|
|
489
|
+
seen.push(position.path);
|
|
490
|
+
});
|
|
491
|
+
app.time.step(16);
|
|
492
|
+
seen; // ["home"]: a headless watch reads on the frames a test steps
|
|
493
|
+
|
|
494
|
+
const walked = await run(app, commands.walk, { route: [{ at: "home", intent: "roll" }] });
|
|
495
|
+
walked.value.path; // "home"
|
|
496
|
+
walked.state; // { path: "home", frame: 1, tainted: false }: a walk goes through the graph
|
|
497
|
+
read(app, sources.model).session; // { rolls: 1 }
|
|
498
|
+
|
|
499
|
+
const { value: mark } = await run(app, commands.bookmark);
|
|
500
|
+
await run(app, commands.restore, { bookmark: mark });
|
|
501
|
+
read(app, sources.tainted); // true: a restore replaces the state outside the graph
|
|
502
|
+
read(app, sources.cheats); // [{ id: "game.restore", input: { bookmark: { path: "home", ... } }, frame: 1 }]
|
|
503
|
+
|
|
504
|
+
stop();
|
|
505
|
+
await game.stop();
|
|
506
|
+
});
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
- **Input.** A schema maps field names to `"string"`, `"number"`, `"boolean"` or `"json"`. A kind with a trailing `?` is optional. The input may be left out when every field is optional. The descriptor function gets it typed.
|
|
510
|
+
- **`read(app, source, input?)`** calls the source once and returns what it reads.
|
|
511
|
+
- **`watch(app, source, input, fn)`** reads on the first frame, in the `signals` phase, and again when the source's change key moved: `frame` every frame, `commit` when `model.store.snapshot()` is a new object, `edge` when `flow.state()` is. It returns the stop function. It runs on the frame loop, so a headless test steps frames with `app.time.step(16)`.
|
|
512
|
+
- **`run(app, command, input?)`** resolves `{ value, state }`. `value` is what the command returned. `state` is the envelope `{ path, frame, tainted }`, read after the command.
|
|
513
|
+
- **Screen sources need the screen.** `Source` and `Command` name the app they need. `read(app, sources.rect, { key: "play" })` on an app without `ui` and `renderer` is a compile error.
|
|
514
|
+
|
|
515
|
+
### Base sources
|
|
516
|
+
|
|
517
|
+
From `@moku-labs/game/inspect`. `Changes` is when `watch` reads the source again.
|
|
518
|
+
|
|
519
|
+
| Key in `sources` | id | Input | Changes | Reads |
|
|
520
|
+
|---|---|---|---|---|
|
|
521
|
+
| `graph` | `game.graph` | none | edge | `flow.describe()`: flows, nodes with their flags and outcomes, edges, slots |
|
|
522
|
+
| `position` | `game.position` | none | edge | `{ path, flow, node, waiting }` from `flow.state()`. `waiting` lists the intents the gate waits for |
|
|
523
|
+
| `history` | `game.history` | `{ last: "number?" }` | edge | `flow.history()`, the edges since the last checkpoint: all of them, or the last `last` |
|
|
524
|
+
| `tainted` | `game.tainted` | none | frame | Whether a `cheat` or `raw` command ran on this app |
|
|
525
|
+
| `cheats` | `game.cheats` | none | frame | The journal of `cheat` and `raw` commands, `{ id, input, frame }`, oldest first, the last 500 |
|
|
526
|
+
| `model` | `game.model` | none | commit | `model.store.snapshot()`: the committed `{ player, session, rng }` |
|
|
527
|
+
| `entities` | `game.entities` | `{ owner: "string?", component: "string?" }` | frame | `world.ecs.snapshot().entities`: all, the ones whose owner has that name, the ones that carry that component, or both |
|
|
528
|
+
| `projections` | `game.projections` | none | commit | Projection name to key to entity, through `world.projection.keyOf` |
|
|
529
|
+
| `ui` | `game.ui` | none | frame | `ui.tree()`: the live screen as plain data |
|
|
530
|
+
| `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 }` |
|
|
532
|
+
| `sounds` | `game.sounds` | `{ last: "number?" }` | frame | `audio.journal()`: all, or the last `last`. Empty unless `pluginConfigs.audio.journal` is above 0 |
|
|
533
|
+
| `assets` | `game.assets` | none | frame | `assets.usage()`: `{ textureMb, budgetMb, bundles }` |
|
|
534
|
+
| `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 |
|
|
535
|
+
|
|
536
|
+
### Base commands
|
|
537
|
+
|
|
538
|
+
From `@moku-labs/game/control`. Every command runs in dev builds only and leaves a `moku:dev` debug entry in the log.
|
|
539
|
+
|
|
540
|
+
| Key in `commands` | id | Input | Effect | Does |
|
|
541
|
+
|---|---|---|---|---|
|
|
542
|
+
| `answer` | `game.answer` | `{ intent: "string", payload: "json?" }` | route | `flow.gate.answer({ intent, payload })`. Value: whether the gate took the answer |
|
|
543
|
+
| `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
|
+
| `drag` | `game.drag` | `{ from: "json", to: "json" }` | route | `input.drag(from, to)`, both `{ projection, key }`. Value: whether the gate took the answer |
|
|
545
|
+
| `key` | `game.key` | `{ key: "string", shift: "boolean?" }` | route | `input.pressKey(key, { shift })`. Value: whether a listener handled the key |
|
|
546
|
+
| `walk` | `game.walk` | `{ route: "json" }` | route | `flow.walk(route)`. Value: the flow state after the walk |
|
|
547
|
+
| `bookmark` | `game.bookmark` | none | read | `flow.bookmark()`. Value: the bookmark, plain JSON |
|
|
548
|
+
| `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 |
|
|
549
|
+
| `step` | `game.step` | `{ frames: "number", deltaMs: "number?" }` | cosmetic | `time.step(deltaMs)` `frames` times, also while paused. `deltaMs` is 1000/60 by default. Value: `time.snapshot()` |
|
|
550
|
+
| `pause` | `game.pause` | none | cosmetic | `lifecycle.push("devtools")`. Value: `lifecycle.isPaused()` |
|
|
551
|
+
| `resume` | `game.resume` | none | cosmetic | `lifecycle.pop("devtools")`. Value: `lifecycle.isPaused()`, still true while another reason holds |
|
|
552
|
+
| `capture` | `game.capture` | none | read | `renderer.capture()`: a PNG data URL of the canvas after the next drawn frame. `undefined` while the renderer is inert |
|
|
553
|
+
| `debug` | `game.debug` | `{ nineSlice: "boolean" }` | cosmetic | `renderer.sync.debug.nineSlice(on)`. Value: the debug switches |
|
|
554
|
+
| `reducedMotion` | `game.reducedMotion` | `{ on: "boolean" }` | cosmetic | `anim.setReducedMotion(on)`. Value: `anim.reducedMotion()` |
|
|
555
|
+
|
|
556
|
+
### Effects, taint and the cheat journal
|
|
557
|
+
|
|
558
|
+
| Effect | Means |
|
|
559
|
+
|---|---|
|
|
560
|
+
| `read` | Takes something out of the game and changes nothing |
|
|
561
|
+
| `route` | Goes through the graph or the input, the way a player does. The session stays clean |
|
|
562
|
+
| `cosmetic` | Changes how the game runs or looks. The session stays clean |
|
|
563
|
+
| `cheat` | Changes the state outside the rules. Taints the session and is journaled |
|
|
564
|
+
| `raw` | Replaces the state outside the graph. Taints the session and is journaled |
|
|
565
|
+
|
|
566
|
+
`run` journals a `cheat` or `raw` command as `{ id, input, frame }` before it runs, so a failing one still counts. The session and the journal belong to one app object: two apps in one process never share them. Read them with `sources.tainted` and `sources.cheats` (`game.cheats`), and in `state.tainted` of every `run`. Of the base commands only `game.restore` is `raw`; none is `cheat`.
|
|
567
|
+
|
|
568
|
+
### Turn the dev build on
|
|
569
|
+
|
|
570
|
+
`__MOKU_GAME_DEV__` is a global the engine reads and never sets. Only `true` turns the dev build on; undefined means production, and `run` throws `[game] Control commands run in dev builds only.` The package ships its declaration, `var __MOKU_GAME_DEV__: boolean | undefined`. A game never re-declares it.
|
|
571
|
+
|
|
572
|
+
**Preferred: a bundler `define`**, `true` in dev and `false` in production.
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
// build.ts of the game
|
|
576
|
+
const production = Bun.argv.includes("--production");
|
|
577
|
+
|
|
578
|
+
await Bun.build({
|
|
579
|
+
entrypoints: ["web/main.ts"],
|
|
580
|
+
outdir: "dist",
|
|
581
|
+
minify: true,
|
|
582
|
+
define: { __MOKU_GAME_DEV__: production ? "false" : "true" }
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**Or set the global** in a module the dev entry imports before the engine. The fixture page does this in [`tests/integration/merge-game/web/dev.ts`](./tests/integration/merge-game/web/dev.ts):
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
// web/dev.ts, the first import of web/main.ts
|
|
590
|
+
globalThis.__MOKU_GAME_DEV__ = true;
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
A test sets it with `vi.stubGlobal("__MOKU_GAME_DEV__", true)`.
|
|
594
|
+
|
|
595
|
+
**What the `define` strips.** Every command body starts with the inline guard `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`. A `define` of `false` folds the condition, and the minifier drops the body behind it. [`tests/integration/doors-build.test.ts`](./tests/integration/doors-build.test.ts) proves it with a minified Bun build of each door: with `false` no command body of `/control` is left, with `true` every body is there, and an `/inspect` bundle carries no command id at all. The descriptors stay, so an editor can still list the commands. Without a `define` the bodies stay in the bundle, and `run` still refuses them while the flag is undefined.
|
|
596
|
+
|
|
597
|
+
### A game's own sources and commands
|
|
598
|
+
|
|
599
|
+
`defineSource` and `defineCommand` take the same shape as the base catalogue. The id is camelCase words joined by dots, at least two (`dice.rolls`); any other id throws. Keep them in `.dev` modules that only the dev entry and the tests import, so a production bundle never reaches them. `defineCommand` adds no guard: a command writes the inline guard itself, because Bun does not inline a guard function across modules.
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
// dice.dev.ts
|
|
603
|
+
import { controlRefused, defineCommand } from "@moku-labs/game/control";
|
|
604
|
+
import { defineSource } from "@moku-labs/game/inspect";
|
|
605
|
+
import type { createGame } from "./game";
|
|
606
|
+
|
|
607
|
+
export const rolls = defineSource({
|
|
608
|
+
id: "dice.rolls",
|
|
609
|
+
title: "Rolls",
|
|
610
|
+
input: {},
|
|
611
|
+
changes: "commit",
|
|
612
|
+
read: (app: ReturnType<typeof createGame>) => app.model.store.snapshot().session
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
export const rollTwice = defineCommand({
|
|
616
|
+
id: "dice.rollTwice",
|
|
617
|
+
title: "Roll twice",
|
|
618
|
+
input: {},
|
|
619
|
+
effect: "route",
|
|
620
|
+
run: app => {
|
|
621
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
622
|
+
return app.flow.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
export const giveCoins = defineCommand({
|
|
627
|
+
id: "dice.giveCoins",
|
|
628
|
+
title: "Give coins",
|
|
629
|
+
input: { coins: "number" },
|
|
630
|
+
effect: "cheat",
|
|
631
|
+
run: (app, { coins }) => {
|
|
632
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
633
|
+
return app.flow.restore({ ...app.flow.bookmark(), player: { coins, lastRoll: 0 } });
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// On a fresh headless game, before any frame:
|
|
638
|
+
// (await run(app, rollTwice)).state; // { path: "home", frame: 0, tainted: false }
|
|
639
|
+
// read(app, rolls); // { rolls: 2 }
|
|
640
|
+
// (await run(app, giveCoins, { coins: 100 })).state.tainted; // true
|
|
641
|
+
// read(app, sources.cheats); // [{ id: "dice.giveCoins", input: { coins: 100 }, frame: 0 }]
|
|
642
|
+
```
|
|
643
|
+
|
|
303
644
|
## Events
|
|
304
645
|
|
|
305
646
|
Global events are empty: every event belongs to a plugin. `time` and `clock` emit nothing.
|
|
@@ -311,6 +652,15 @@ Global events are empty: every event belongs to a plugin. `time` and `clock` emi
|
|
|
311
652
|
| `flow:edge` | `flow` | `{ flow: string; node: string; outcome: string; payload: Json; next: string; patches: { doc: Patch[]; session: Patch[] }; index: number; now: number }` | After the commit of an edge |
|
|
312
653
|
| `flow:rest` | `flow` | `{ path: string; checkpoint: boolean }` | The graph entered a rest node |
|
|
313
654
|
| `flow:error` | `flow` | `{ path: string; error: unknown; rolledBackTo: string; retry: boolean }` | A node failed and the graph rolled back |
|
|
655
|
+
| `world:reconciled` | `world` | counts per reconcile | Dev only, behind `reconciledEvent` |
|
|
656
|
+
| `renderer:device-lost` | `renderer` | `{ kind, reason }` | The GPU device or context was lost; `lifecycle` is pushed |
|
|
657
|
+
| `assets:bundle-loaded`, `assets:bundle-unloaded` | `assets` | `{ bundle, tier, mb, reason }` | A bundle entered or left memory |
|
|
658
|
+
| `scenes:changed` | `scenes` | `{ from, to, music }` | The scene switched on entering a node |
|
|
659
|
+
| `anim:mark` | `anim` | `{ animation, mark }` | A `mark` step was reached, or jumped by `finish()` |
|
|
660
|
+
| `anim:finished` | `anim` | `{ animation }` | A timeline ended or was finished. Never on `cancel()` |
|
|
661
|
+
| `i18n:locale-changed` | `i18n` | `{ locale }` | The module of the new locale is loaded; `text` re-resolves. Never at start |
|
|
662
|
+
|
|
663
|
+
`text`, `ui` and `audio` emit nothing.
|
|
314
664
|
|
|
315
665
|
```ts
|
|
316
666
|
import { createPlugin, flowPlugin } from "@moku-labs/game";
|
|
@@ -330,13 +680,14 @@ export const edgeLog = createPlugin("edgeLog", {
|
|
|
330
680
|
### Global
|
|
331
681
|
|
|
332
682
|
```ts
|
|
333
|
-
createApp({ config: { orientation: "landscape", referenceSide: 1080 } });
|
|
683
|
+
createApp({ config: { orientation: "landscape", referenceSide: 1080, referenceLong: 1920 } });
|
|
334
684
|
```
|
|
335
685
|
|
|
336
686
|
| Key | Type | Default | Meaning |
|
|
337
687
|
|---|---|---|---|
|
|
338
688
|
| `orientation` | `"portrait" \| "landscape"` | `"portrait"` | Screen orientation the game is designed for |
|
|
339
689
|
| `referenceSide` | `number` | `1080` | Short side of the reference resolution in pixels |
|
|
690
|
+
| `referenceLong` | `number` | `1920` | The long side, in reference units, the layout needs inside the safe area. The viewport scale is `min(short / referenceSide, safeLong / referenceLong)`, so a wide screen gives the layout more width instead of shrinking it |
|
|
340
691
|
|
|
341
692
|
### Per plugin
|
|
342
693
|
|
|
@@ -346,6 +697,8 @@ Set with `createApp({ pluginConfigs: { <plugin>: { ... } } })`.
|
|
|
346
697
|
|---|---|---|---|---|
|
|
347
698
|
| `time` | `maxFps` | `30 \| 60 \| 120` | `60` | Frame rate cap |
|
|
348
699
|
| `time` | `maxDeltaMs` | `number` | `50` | Upper bound of one frame's delta in milliseconds |
|
|
700
|
+
| `time` | `idleFps` | `0 \| 30` | `30` | Frame rate cap of an idle screen. `0` turns the idle cap off. Any plugin lifts it with `wake()` |
|
|
701
|
+
| `time` | `idleAfterMs` | `number` | `2000` | Unscaled milliseconds without a `wake()` after which the loop drops to `idleFps` |
|
|
349
702
|
| `lifecycle` | none | | | The plugin has no config |
|
|
350
703
|
| `model` | `playerProvider` | `PlayerStateProvider \| undefined` | `undefined` | The save seam. `undefined` means an in-memory provider: the save lives as long as the app does |
|
|
351
704
|
| `model` | `initialPlayer` | `Json` | `{}` | Player state of a new player. Deep-cloned |
|
|
@@ -359,13 +712,37 @@ Set with `createApp({ pluginConfigs: { <plugin>: { ... } } })`.
|
|
|
359
712
|
| `flow` | `retries` | `number` | `1` | Retries of a failed transition before `safeNode` |
|
|
360
713
|
| `flow` | `settleTimeoutMs` | `number` | `2000` | How long `onStop` waits for the active node to settle after abort |
|
|
361
714
|
| `flow` | `journalLimit` | `number` | `500` | Journal entries kept between checkpoints |
|
|
715
|
+
| `world` | `settleMs` | `number` | `350` | Length of the default settle motion |
|
|
716
|
+
| `world` | `reconciledEvent` | `boolean` | `false` | Emit `world:reconciled` after every reconcile (dev tools) |
|
|
717
|
+
| `renderer` | `mount` | `string \| undefined` | `undefined` | Selector of the mount element. `undefined` keeps the renderer inert |
|
|
718
|
+
| `renderer` | `preference` | `"webgpu" \| "webgl"` | `"webgpu"` | Preferred backend; Pixi falls back to WebGL |
|
|
719
|
+
| `renderer` | `background`, `antialias`, `maxResolution`, `aspect`, `poolLimit`, `unsupportedMessage`, `loadPixi` | | see the plugin README | Host, viewport and pool settings; `loadPixi` is the lazy loader, a test passes a fake |
|
|
720
|
+
| `renderer` | `debug` | `{ nineSlice: boolean }` | `{ nineSlice: false }` | Outline every nine-slice from the start; `sync.debug.nineSlice(on)` switches it live |
|
|
721
|
+
| `input` | `tapSlopPx`, `longPressMs`, `dragStartPx`, `swipeMinPx`, `swipeMaxMs` | `number` | `12`, `450`, `8`, `48`, `300` | Gesture thresholds in reference px and ms |
|
|
722
|
+
| `input` | `cursor` | `{ control: string; idle: string }` | `{ control: "pointer", idle: "" }` | CSS cursor over a control and elsewhere |
|
|
723
|
+
| `assets` | `manifest` | `string \| Manifest \| undefined` | `undefined` | Manifest URL, or the parsed file in a test |
|
|
724
|
+
| `assets` | `textureBudgetMb` | `number` | `192` | Texture memory budget for the LRU unload |
|
|
725
|
+
| `assets` | `preloadDepth` | `number` | `2` | Graph edges walked for the preload at a rest node |
|
|
726
|
+
| `assets` | `baseUrl`, `io` | | `undefined` | The CDN seam and the fetch/decode/texture seam a test replaces |
|
|
727
|
+
| `anim` | `maxTracks` | `number` | `2000` | Dev guard: one warning each time the running track count rises past it. Durations live in the steps, never here |
|
|
728
|
+
| `i18n` | `locale` | `string` | `"en"` | The locale at start |
|
|
729
|
+
| `i18n` | `fallback` | `string` | `"en"` | The locale a missing key is read from before it is reported missing |
|
|
730
|
+
| `i18n` | `locales` | `Record<string, module \| loader>` | `{}` | Compiled modules outside features, per locale |
|
|
731
|
+
| `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
|
+
| `ui` | `tapTargetPt` | `number` | `44` | The smallest tap target `lint()` accepts |
|
|
734
|
+
| `ui` | `breakpoints` | `{ tall, wide }` | `{ tall: 2, wide: 1.5 }` | Aspect thresholds of the `when` style variants |
|
|
735
|
+
| `audio` | `buses` | `{ master, music, sfx }` | `{ master: 1, music: 0.6, sfx: 1 }` | Start gain of each bus, 0..1 |
|
|
736
|
+
| `audio` | `musicFadeMs` | `number` | `600` | Cross-fade of a music switch, in real milliseconds |
|
|
737
|
+
| `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
|
+
| `audio` | `context` | `() => AudioContext \| undefined` | `undefined` | The context factory, a test seam. Absent: `new AudioContext()` where the global exists |
|
|
362
739
|
|
|
363
740
|
## Development
|
|
364
741
|
|
|
365
742
|
### Scripts
|
|
366
743
|
|
|
367
744
|
```sh
|
|
368
|
-
bun run build # build with tsdown: dist/index.mjs
|
|
745
|
+
bun run build # build with tsdown: dist/index.mjs, testing.mjs, assets.mjs, inspect.mjs, control.mjs, jsx-runtime.mjs, jsx-dev-runtime.mjs
|
|
369
746
|
bun run typecheck # tsc --noEmit
|
|
370
747
|
bun run lint # biome check . && eslint .
|
|
371
748
|
bun run lint:fix # biome check --write . && eslint --fix .
|
|
@@ -384,7 +761,7 @@ bun run release # moku-release
|
|
|
384
761
|
|
|
385
762
|
| Path | Holds |
|
|
386
763
|
|---|---|
|
|
387
|
-
| `tests/unit/` | Framework-level unit tests: root index, setup
|
|
764
|
+
| `tests/unit/` | Framework-level unit tests: root index, setup |
|
|
388
765
|
| `tests/integration/` | Framework-level scenarios across plugins |
|
|
389
766
|
| `tests/integration/merge-game/` | The fixture game, written on the public API only. Not published |
|
|
390
767
|
| `src/plugins/<name>/__tests__/unit/` | Unit tests of one plugin |
|
|
@@ -393,7 +770,7 @@ bun run release # moku-release
|
|
|
393
770
|
|
|
394
771
|
Plugin tests never go into the root `tests/` folder. Coverage thresholds are 90% for lines, functions, branches and statements.
|
|
395
772
|
|
|
396
|
-
### Lint rules L1 to
|
|
773
|
+
### Lint rules L1 to L9
|
|
397
774
|
|
|
398
775
|
The project rules live in [`eslint.config.ts`](./eslint.config.ts).
|
|
399
776
|
|
|
@@ -403,14 +780,18 @@ The project rules live in [`eslint.config.ts`](./eslint.config.ts).
|
|
|
403
780
|
| L2 | No static import of `pixi.js` or `yoga-layout`. They are loaded lazily with `import()` | `src/**` |
|
|
404
781
|
| L3 | Determinism: no `Date.now`, `performance.now`, `new Date`, `Math.random`, `setTimeout`, `setInterval` | `model`, `flow`, `clock` except `clock/system.ts`, and the rules of the fixture game |
|
|
405
782
|
| L4 | The rules of the fixture game import only their siblings | `tests/integration/merge-game/rules/` |
|
|
406
|
-
| L5 | No module-scope state: no top-level `let`, no top-level `Map`, `Set`, `WeakMap`, `WeakSet`.
|
|
407
|
-
| L6 | Plugin wiring files need no JSDoc on small inline arrows. Every
|
|
783
|
+
| L5 | No module-scope state: no top-level `let`, no top-level `Map`, `Set`, `WeakMap`, `WeakSet`. No allowlist | `src/**` |
|
|
784
|
+
| L6 | Plugin wiring files need no JSDoc on small inline arrows. Every function declaration and every exported type needs JSDoc with description, params and returns | `src/plugins/*/index.ts` |
|
|
785
|
+
| L7 | The public contract carries the docs: every member of a `…Api` type in `types.ts` has JSDoc and a scenario `@example` (when it is called, literal arguments, the result). A member another plugin calls is shown from that plugin's point of view; there is no private tier and no exemption. The implementation of an API method has no JSDoc. Elsewhere an example is allowed, never required | `src/plugins/**/types.ts` |
|
|
786
|
+
| L8 | No signature echo: an `@example` whose whole body is one call with bare identifiers is an error | `src/**` |
|
|
787
|
+
| L9 | The JSX runtime module is reached only through `src/jsx-runtime.ts` and `src/jsx-dev-runtime.ts`, and those two import nothing else | `src/**` outside `ui` |
|
|
408
788
|
|
|
409
789
|
## Requirements
|
|
410
790
|
|
|
411
791
|
- **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only, never npm, yarn or pnpm.
|
|
412
792
|
- **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
|
|
413
|
-
- **`pixi.js` `^8.0.0`** as a peer dependency.
|
|
793
|
+
- **`pixi.js` `^8.0.0`** as a peer dependency. **`yoga-layout`** is a dependency, loaded lazily by `ui`. The string compiler on the `assets` door uses `@formatjs/icu-messageformat-parser`; no parser ships to the browser.
|
|
794
|
+
- **JSX**: `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"` in the game's `tsconfig.json`. Screens are `.tsx` files.
|
|
414
795
|
- **[`@moku-labs/core`](https://github.com/moku-labs/core)** is the kernel: plugins, lifecycle, events. **[`@moku-labs/common`](https://github.com/moku-labs/common)** brings `log` and `env`.
|
|
415
796
|
|
|
416
797
|
## Docs
|
|
@@ -419,7 +800,13 @@ The project rules live in [`eslint.config.ts`](./eslint.config.ts).
|
|
|
419
800
|
- [`lifecycle`](./src/plugins/lifecycle/README.md): pause reasons
|
|
420
801
|
- [`model`](./src/plugins/model/README.md): store, rng, provider seam, migrations
|
|
421
802
|
- [`clock`](./src/plugins/clock/README.md): `now`, `scheduleAt`, `fakeClock`
|
|
422
|
-
- [`flow`](./src/plugins/flow/README.md): runner, gate, inbox, fx, features
|
|
803
|
+
- [`flow`](./src/plugins/flow/README.md): runner, gate, inbox, fx, features, the door machinery
|
|
804
|
+
- [`world`](./src/plugins/world/README.md), [`renderer`](./src/plugins/renderer/README.md), [`input`](./src/plugins/input/README.md), [`assets`](./src/plugins/assets/README.md), [`scenes`](./src/plugins/scenes/README.md): the screen
|
|
805
|
+
- [`anim`](./src/plugins/anim/README.md): tracks, timelines, `defineMotion`, the driver
|
|
806
|
+
- [`i18n`](./src/plugins/i18n/README.md): messages, parts, the compiler, supported ICU
|
|
807
|
+
- [`text`](./src/plugins/text/README.md): styles, tags, measurement, fonts
|
|
808
|
+
- [`ui`](./src/plugins/ui/README.md): the JSX runtime, components, styles, layout, popups
|
|
809
|
+
- [`audio`](./src/plugins/audio/README.md): buses, the unlock, pause, memory
|
|
423
810
|
- [`llms.txt`](./llms.txt): overview for an LLM that writes a game on this engine
|
|
424
811
|
- [Moku Core specification](https://github.com/moku-labs/core/tree/main/specification)
|
|
425
812
|
|