@moku-labs/game 0.1.0 → 0.2.0
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 +119 -775
- package/bin/moku-game-assets.mjs +4 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +32 -12
- package/fonts/LICENSE.txt +105 -0
- package/fonts/font-body.fnt +372 -0
- package/fonts/font-body.png +0 -0
- package/llms.txt +536 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @moku-labs/game
|
|
2
2
|
|
|
3
|
-
**A 2D puzzle
|
|
3
|
+
**A 2D puzzle engine where the game is a graph, and the screen is just an opinion about it.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
You write small nodes and edge tables. The engine runs them, saves on the edges and replays the whole game without a screen. It ships no genre rules.
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/@moku-labs/game)
|
|
10
10
|
[](#requirements)
|
|
11
11
|
[](#requirements)
|
|
12
12
|
[](#requirements)
|
|
@@ -15,33 +15,34 @@
|
|
|
15
15
|
|
|
16
16
|
<br/>
|
|
17
17
|
|
|
18
|
-
[Why](#why
|
|
18
|
+
[Why](#why) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Plugins](#plugins) · [Docs](#docs)
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
## Why
|
|
22
|
+
## Why
|
|
23
23
|
|
|
24
|
-
- **The game is a graph.** Flows are edge tables
|
|
24
|
+
- **The game is a graph.** Flows are edge tables. Nodes are small functions with plain `await` bodies. One node is active at a time.
|
|
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
|
-
- **Position is data.** Node path, input and state describe the whole game.
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
26
|
+
- **Position is data.** Node path, input and state describe the whole game. Checkpoints, rollback, bookmarks and repro runs come for free.
|
|
27
|
+
- **The screen is a projection.** Rendering reads committed state and never owns it. The same game plays whole in plain Bun, with no screen at all.
|
|
28
|
+
- **Deterministic by construction.** Time is an input named `now`. Randomness is a saved `rng` stream. A lint rule refuses `Date.now` and `Math.random` in game logic.
|
|
29
|
+
- **Data, not classes.** Animations, strings, styles, scenes and effects are frozen data. An AI agent can write them. So can you, on a bad day.
|
|
30
30
|
|
|
31
31
|
## Status
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
> [!NOTE]
|
|
34
|
+
> **`0.1.0`: the first working release.** It runs on our machines, two emulators and one very patient fixture game. The API may still change before `1.0`.
|
|
34
35
|
|
|
35
|
-
| Milestone |
|
|
36
|
-
|
|
37
|
-
| V1 |
|
|
38
|
-
| V2 |
|
|
39
|
-
| V3 |
|
|
40
|
-
| V4 | doors
|
|
41
|
-
| V5 |
|
|
42
|
-
| V6 |
|
|
36
|
+
| Milestone | What it added |
|
|
37
|
+
|---|---|
|
|
38
|
+
| V1 | The logic: `time`, `lifecycle`, `model`, `clock`, `flow`. A game plays to the end headless. |
|
|
39
|
+
| V2 | The screen: an own small ECS, a lazy Pixi v8 renderer, gestures, typed assets, scenes. |
|
|
40
|
+
| V3 | The interface: animations, strings, MSDF text, screens in JSX, sound. |
|
|
41
|
+
| V4 | Two doors for the editor: read a running game, drive a dev build. The editor itself lives elsewhere. |
|
|
42
|
+
| V5 | Particles, WGSL filters, sprite frames, the production asset pack, a text field, visual tests. |
|
|
43
|
+
| V6 | `platform`: Back button, haptics, keep-awake. Filters on WebGL. The game as a Tauri app on iOS and Android. |
|
|
43
44
|
|
|
44
|
-
Rendering
|
|
45
|
+
Rendering is WebGPU first, with Pixi's WebGL fallback. A device with neither gets an honest "unsupported device" screen.
|
|
45
46
|
|
|
46
47
|
## Install
|
|
47
48
|
|
|
@@ -49,144 +50,72 @@ Rendering: WebGPU is preferred, with Pixi's WebGL fallback and an honest "unsupp
|
|
|
49
50
|
bun add @moku-labs/game pixi.js
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
> [!NOTE]
|
|
53
|
-
> **Status: `0.1.0` on npm.** The first working release; the API may still change before `1.0`. `pixi.js` `^8.0.0` is a peer dependency.
|
|
54
|
-
|
|
55
53
|
> [!IMPORTANT]
|
|
56
|
-
> Bun only. ESM only.
|
|
54
|
+
> Bun only. ESM only. There is no CJS build. `pixi.js` `^8.0.0` is a peer dependency. Screens are `.tsx`, so a game sets `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"`.
|
|
57
55
|
|
|
58
56
|
## Quick start
|
|
59
57
|
|
|
60
|
-
A
|
|
61
|
-
|
|
62
|
-
**1. Bind the helpers to the types of the game.** `defineNode` and `defineFlow` come from `defineGame`. They are not root exports.
|
|
58
|
+
A dice game in three files: one rest node, one working node, one flow.
|
|
63
59
|
|
|
64
60
|
```ts
|
|
65
|
-
// state.ts
|
|
61
|
+
// state.ts: bind the helpers to the types of the game
|
|
66
62
|
import { defineGame } from "@moku-labs/game";
|
|
67
63
|
|
|
68
|
-
export
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
export const { defineNode, defineFlow, defineFeature } = defineGame<{
|
|
72
|
-
player: Player;
|
|
73
|
-
session: Session;
|
|
64
|
+
export const { defineNode, defineFlow } = defineGame<{
|
|
65
|
+
player: { coins: number };
|
|
66
|
+
session: { rolls: number };
|
|
74
67
|
assets: string;
|
|
75
68
|
strings: Record<string, unknown>;
|
|
76
69
|
}>();
|
|
77
70
|
```
|
|
78
71
|
|
|
79
|
-
**2. Write the nodes.** A rest node without a body is a pure wait. The intent of the answer names the outcome.
|
|
80
|
-
|
|
81
72
|
```ts
|
|
82
|
-
//
|
|
73
|
+
// flow.ts: nodes and the edge table, checked by the compiler
|
|
83
74
|
import { type } from "@moku-labs/game";
|
|
84
|
-
import { defineNode } from "./state";
|
|
75
|
+
import { defineFlow, defineNode } from "./state";
|
|
85
76
|
|
|
86
|
-
|
|
87
|
-
outcomes: { roll: type(), reset: type() },
|
|
88
|
-
rest: true,
|
|
89
|
-
checkpoint: true
|
|
90
|
-
});
|
|
77
|
+
const home = defineNode({ outcomes: { roll: type() }, rest: true, checkpoint: true });
|
|
91
78
|
|
|
92
|
-
|
|
79
|
+
const roll = defineNode({
|
|
93
80
|
outcomes: { done: type() },
|
|
94
81
|
run: ({ player, session, rng, out }) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
player.coins += face;
|
|
98
|
-
player.lastRoll = face;
|
|
82
|
+
player.coins += rng.stream("dice").range(1, 6);
|
|
99
83
|
session.rolls += 1;
|
|
100
84
|
return out.done();
|
|
101
85
|
}
|
|
102
86
|
});
|
|
103
87
|
|
|
104
|
-
export const reset = defineNode({
|
|
105
|
-
outcomes: { done: type() },
|
|
106
|
-
run: ({ player, out }) => {
|
|
107
|
-
player.coins = 0;
|
|
108
|
-
return out.done();
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
**3. Wire the flow.** The edge table is checked by the compiler. A missing edge or an unknown target is a compile error with a sentence.
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
// flow.ts
|
|
117
|
-
import { defineFlow } from "./state";
|
|
118
|
-
import { home, reset, roll } from "./nodes";
|
|
119
|
-
|
|
120
88
|
export const mainFlow = defineFlow("main", {
|
|
121
|
-
nodes: { home, roll
|
|
89
|
+
nodes: { home, roll },
|
|
122
90
|
start: "home",
|
|
123
|
-
edges: {
|
|
124
|
-
home: { roll: "roll", reset: "reset" },
|
|
125
|
-
roll: { done: "home" },
|
|
126
|
-
reset: { done: "home" }
|
|
127
|
-
}
|
|
91
|
+
edges: { home: { roll: "roll" }, roll: { done: "home" } }
|
|
128
92
|
});
|
|
129
93
|
```
|
|
130
94
|
|
|
131
|
-
**4. Create the app.** The graph is started by the game, never by the plugin.
|
|
132
|
-
|
|
133
95
|
```ts
|
|
134
|
-
// game.ts
|
|
96
|
+
// game.test.ts: play it with no screen
|
|
135
97
|
import { createApp } from "@moku-labs/game";
|
|
136
|
-
import { mainFlow } from "./flow";
|
|
137
|
-
|
|
138
|
-
export const createGame = (seed: "from-save" | number = "from-save") =>
|
|
139
|
-
createApp({
|
|
140
|
-
pluginConfigs: {
|
|
141
|
-
model: { initialPlayer: { coins: 0, lastRoll: 0 }, initialSession: { rolls: 0 }, seed },
|
|
142
|
-
flow: { mainFlow, safeNode: "home" }
|
|
143
|
-
},
|
|
144
|
-
onStart: ctx => {
|
|
145
|
-
ctx.flow.run().catch((error: unknown) => {
|
|
146
|
-
ctx.log.error("game: the graph failed", { error });
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
```
|
|
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
|
-
|
|
154
|
-
**5. Play it headless.** `createHeadless` returns a game object once the graph rests at its first
|
|
155
|
-
rest node. Its `walk` method plays a route.
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
// game.test.ts
|
|
159
|
-
import type { Flow } from "@moku-labs/game";
|
|
160
98
|
import { createHeadless } from "@moku-labs/game/testing";
|
|
161
|
-
import {
|
|
162
|
-
import { createGame } from "./game";
|
|
163
|
-
|
|
164
|
-
const rollOnce: Flow.RouteStep = { at: "home", intent: "roll" };
|
|
165
|
-
|
|
166
|
-
it("plays two rolls and a reset without a screen", async () => {
|
|
167
|
-
const app = createGame(42);
|
|
168
|
-
const game = await createHeadless(app);
|
|
169
|
-
|
|
170
|
-
const state = await game.walk([rollOnce, rollOnce]);
|
|
171
|
-
|
|
172
|
-
expect(state.path).toBe("home");
|
|
173
|
-
expect(app.model.store.snapshot().session).toEqual({ rolls: 2 });
|
|
174
|
-
|
|
175
|
-
await game.walk([{ at: "home", intent: "reset" }]);
|
|
176
|
-
|
|
177
|
-
expect(app.model.store.snapshot().player).toMatchObject({ coins: 0 });
|
|
99
|
+
import { mainFlow } from "./flow";
|
|
178
100
|
|
|
179
|
-
|
|
101
|
+
const app = createApp({
|
|
102
|
+
pluginConfigs: {
|
|
103
|
+
model: { initialPlayer: { coins: 0 }, initialSession: { rolls: 0 }, seed: 42 },
|
|
104
|
+
flow: { mainFlow, safeNode: "home" }
|
|
105
|
+
},
|
|
106
|
+
onStart: ctx => {
|
|
107
|
+
ctx.flow.run().catch((error: unknown) => ctx.log.error("game: the graph failed", { error }));
|
|
108
|
+
}
|
|
180
109
|
});
|
|
181
|
-
```
|
|
182
110
|
|
|
183
|
-
|
|
111
|
+
const game = await createHeadless(app);
|
|
112
|
+
await game.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
|
|
113
|
+
app.model.store.snapshot().session; // { rolls: 2 }
|
|
114
|
+
```
|
|
184
115
|
|
|
185
|
-
|
|
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`.
|
|
116
|
+
The full version, with a reset node and a vitest file, is in [docs/quick-start.md](./docs/quick-start.md). A bigger game lives in [`tests/integration/merge-game/`](./tests/integration/merge-game). It is a test fixture and is not published.
|
|
187
117
|
|
|
188
|
-
|
|
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).
|
|
118
|
+
A game with a screen gets two more things from the package. The body font `fonts/font-body.*` (Pangolin, SIL OFL 1.1) is the default of the built-in text style `body` once copied into `src/features/ui/assets/`. The bin `moku-game-assets` writes the typed asset keys, the manifest and the strings. It runs under bun; with node only, run `bun node_modules/@moku-labs/game/dist/assets.mjs`. Both are in [docs/quick-start.md](./docs/quick-start.md#a-screen-the-body-font-and-the-asset-keys).
|
|
190
119
|
|
|
191
120
|
## How it works
|
|
192
121
|
|
|
@@ -205,686 +134,101 @@ flowchart LR
|
|
|
205
134
|
class R,N,E,M m
|
|
206
135
|
```
|
|
207
136
|
|
|
208
|
-
|
|
137
|
+
A rest node waits. A player answer comes through the gate. A world event comes through the inbox. Nothing else moves the graph.
|
|
209
138
|
|
|
210
139
|
| Term | Meaning |
|
|
211
140
|
|---|---|
|
|
212
|
-
| Node | `defineNode({ input?, outcomes, run })`.
|
|
213
|
-
|
|
|
214
|
-
| Rest node | `rest: true`. The graph waits here. Without `run` it is a pure wait for a gate answer or an inbox event. Entering it marks a rest point. |
|
|
141
|
+
| Node | `defineNode({ input?, outcomes, run })`. Returns `out.name(data)`. |
|
|
142
|
+
| Rest node | `rest: true`. The graph waits here. Entering it marks a rest point. |
|
|
215
143
|
| `checkpoint` | A rest node where the journal is compacted. `safeNode` points at one. |
|
|
216
|
-
| `barrier` |
|
|
217
|
-
|
|
|
218
|
-
|
|
|
219
|
-
|
|
|
220
|
-
|
|
|
221
|
-
|
|
|
222
|
-
|
|
|
223
|
-
| Effect | `await fx(descriptor)` for awaited effects, `fx.emit(hint(kind, payload))` for cosmetic ones. Hints are released after the commit and dropped in fast mode. |
|
|
224
|
-
| Transition | Rest node to rest node. It is a transaction. An error rolls back to the last rest point, retries, then enters `safeNode`. |
|
|
144
|
+
| `barrier` | The save is written durably after this node. Rollback cannot cross it. |
|
|
145
|
+
| Flow | `defineFlow(id, { nodes, start, edges })`. A flow with `outcomes` is a node of another flow. |
|
|
146
|
+
| Edge target | A node name, `exit("outcome")` to leave a sub-flow, or `to("node", mapper)`. |
|
|
147
|
+
| Slot | `slot("name")`. Features contribute sub-flows to it. |
|
|
148
|
+
| Feature | `defineFeature(name, { nodes?, flows?, contribute? })`. A plugin that adds to the graph. |
|
|
149
|
+
| Effect | `await fx(descriptor)` for awaited effects, `fx.emit(hint(...))` for cosmetic ones. |
|
|
150
|
+
| Transition | Rest node to rest node, as one transaction. An error rolls back and retries. |
|
|
225
151
|
|
|
226
|
-
|
|
152
|
+
The full contract, with the node context, `over` and `inbox`, is in [docs/quick-start.md](./docs/quick-start.md#the-contract).
|
|
227
153
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
### Built
|
|
231
|
-
|
|
232
|
-
| Plugin | Tier | Owns | Key API |
|
|
233
|
-
|---|---|---|---|
|
|
234
|
-
| [`time`](./src/plugins/time/README.md) | Standard | The single `requestAnimationFrame` loop, six frame phases, the `Time` resource | `onFrame(phase, callback)`, `snapshot()`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)` |
|
|
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()` |
|
|
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)` |
|
|
237
|
-
| [`clock`](./src/plugins/clock/README.md) | Standard | Trusted time as an input: monotonic `now()` and one `elapsed` signal at the next due moment | `now()`, `scheduleAt(moment)`, `onElapsed(listener)`, `poke()`, `dueAt()` |
|
|
238
|
-
| [`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`), the frame numbers: render passes in every build, draw calls in a dev build | `host.ready()`, `host.kind()`, `host.canvas()`, `host.device()`, `sync.hitTest(x, y, accept)`, `sync.textures.provide(fn)`, `sync.filters.set(entity, slots)`, `sync.displayOf(entity)`, `viewport.toReference(x, y)`, `viewport.size()`, `stats()` |
|
|
241
|
-
| [`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; the `Frames` component, a frame loop that walks a sprite's textures for its whole life | `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`; every tag takes `components`, extra component values such as a filter; the `input` tag is a text field whose text lives in `local` | `tree()`, `find(key)`, `lint()`, `fill(key, value)` |
|
|
248
|
-
| [`audio`](./src/plugins/audio/README.md) | Standard | Opt-in. Buses `master`, `music`, `sfx`; `sfx()` descriptors of `anim` and `music()` descriptors handled here; the scene's `music`; volumes read from the committed player through `volumes` | `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `unlocked()` |
|
|
249
|
-
| [`effects`](./src/plugins/effects/README.md) | Complex | Opt-in. Particles: `defineEmitter` and the `Emitter` component, one Pixi `ParticleContainer` per instance stepped by the engine clock. Filters: `defineFilter` turns a WGSL fragment body and its GLSL twin into a flat component that `tween` drives, so a custom filter draws on WebGPU and on WebGL; `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha` ship built in. Budgets warn once per crossing; a dev build compiles each kind before its first instance, WGSL on WebGPU and GLSL on WebGL. Headless it draws nothing | `stats()` |
|
|
250
|
-
| [`platform`](./src/plugins/platform/README.md) | Standard | Opt-in, last in the array. The phone as a provider: the game passes a `PlatformProvider`; its pause and resume become the `"background"` reason of `lifecycle`, its Back press runs the Back chain (Escape, then the intent `back`, then `exit()`), the `haptic` effect reaches `provider.haptic`, and `keepAwake` keeps the screen on while the game runs. Inert without a provider | `back()` |
|
|
251
|
-
|
|
252
|
-
```mermaid
|
|
253
|
-
flowchart LR
|
|
254
|
-
G["Game<br/>createApp, features"] --> F["flow"]
|
|
255
|
-
F --> T["time"]
|
|
256
|
-
F --> L["lifecycle"]
|
|
257
|
-
F --> M["model"]
|
|
258
|
-
F --> C["clock"]
|
|
259
|
-
L --> T
|
|
260
|
-
W["world"] --> T
|
|
261
|
-
W --> M
|
|
262
|
-
W --> F
|
|
263
|
-
R["renderer"] --> T
|
|
264
|
-
R --> L
|
|
265
|
-
R --> W
|
|
266
|
-
I["input"] --> F
|
|
267
|
-
I --> W
|
|
268
|
-
I --> R
|
|
269
|
-
A["assets"] --> F
|
|
270
|
-
A --> R
|
|
271
|
-
S["scenes"] --> F
|
|
272
|
-
S --> W
|
|
273
|
-
S --> A
|
|
274
|
-
AN["anim"] --> F
|
|
275
|
-
AN --> W
|
|
276
|
-
AN --> R
|
|
277
|
-
N["i18n"] --> F
|
|
278
|
-
X["text"] --> W
|
|
279
|
-
X --> R
|
|
280
|
-
X --> A
|
|
281
|
-
X --> N
|
|
282
|
-
U["ui"] --> I
|
|
283
|
-
U --> AN
|
|
284
|
-
U --> X
|
|
285
|
-
AU["audio"] --> L
|
|
286
|
-
AU --> M
|
|
287
|
-
AU --> F
|
|
288
|
-
AU --> A
|
|
289
|
-
AU --> S
|
|
290
|
-
EF["effects"] --> F
|
|
291
|
-
EF --> W
|
|
292
|
-
EF --> R
|
|
293
|
-
EF --> A
|
|
294
|
-
EF --> AN
|
|
295
|
-
PL["platform"] --> L
|
|
296
|
-
PL --> F
|
|
297
|
-
PL --> I
|
|
298
|
-
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
|
|
299
|
-
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
|
|
300
|
-
classDef s fill:#5c940d,stroke:#3d6208,color:#fff;
|
|
301
|
-
classDef v fill:#862e9c,stroke:#5f1f70,color:#fff;
|
|
302
|
-
class G u
|
|
303
|
-
class F,T,L,M,C m
|
|
304
|
-
class W,R,I,A,S s
|
|
305
|
-
class AN,N,X,U,AU,EF,PL v
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
An arrow means "depends on"; from `renderer` on, most edges to `time` and `clock` and most 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 particles and filters appends `effectsPlugin`, one that wants sound appends `audioPlugin`, and one in a native shell appends `platformPlugin` last. A game without `effectsPlugin` carries none of its systems, filters or WGSL in its bundle. Without a document the screen plugins are inert: the same app starts in plain Bun, Yoga included.
|
|
309
|
-
|
|
310
|
-
### Planned
|
|
311
|
-
|
|
312
|
-
None at the moment: `platform`, the last planned plugin, is built. `defineFeature` refuses every engine plugin name as a feature name.
|
|
313
|
-
|
|
314
|
-
### Root exports
|
|
315
|
-
|
|
316
|
-
| Export | Kind | Purpose |
|
|
317
|
-
|---|---|---|
|
|
318
|
-
| `createApp` | function | Creates a game application |
|
|
319
|
-
| `createPlugin` | function | Creates a game plugin bound to the engine's config and events |
|
|
320
|
-
| `defineGame` | function | Returns the authoring helpers typed with the game's `player`, `session`, `assets`, `bundles`, `strings`, `textStyles` and `emitters`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, from V3 `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `defineAnimation`, `frames`, `sfx`, `play`, `music`, and from V5 `Frames`, `defineEmitter`, `Emitter`, `Displacement`: a texture key the game does not have or an effect id it did not declare does not compile |
|
|
321
|
-
| `defineFeature` | function | Turns a feature description into a plugin. V3 keys: `projections`, `animations`, `ui`, `strings`, `textStyles`. V5 keys: `emitters`, `filters` |
|
|
322
|
-
| `type`, `exit`, `to`, `slot` | functions | Type tag of a payload, and the three graph helpers for edge targets and slots |
|
|
323
|
-
| `schedule`, `guide`, `hint` | functions | Effect descriptors: next due moment, tutorial narrowing of the gate, cosmetic hint |
|
|
324
|
-
| `SaveUnreadableError` | class | Thrown by `model.store.load()` when the save cannot be read |
|
|
325
|
-
| `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree` | functions and components | The ECS vocabulary of `world` and the projection helper |
|
|
326
|
-
| `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite` | components | The display components of `renderer` |
|
|
327
|
-
| `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) |
|
|
328
|
-
| `defineBundles`, `load`, `defineScene` | functions | Bundle and scene declarations |
|
|
329
|
-
| `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames` | functions and components | Choreography as frozen data. `play(animation, slots)` is the effect a node awaits; `spawn` makes a temporary entity (flying coins, a toast sign) that the timeline despawns when it ends; `sfx` and `haptic` are descriptors `audio` and `platform` handle; `external` throws until Spine arrives. `Frames({ keys, fps, loop, playing })` walks a sprite through its textures for the entity's whole life, a spinning coin |
|
|
330
|
-
| `tr` | function | `tr(key, params?)` builds a frozen `Message`; no locale is read at the call site |
|
|
331
|
-
| `Text`, `label`, `defineTextStyles` | component and functions | Words on the screen and the text styles a feature registers |
|
|
332
|
-
| `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite` | functions and components | Interface components, the popup effect, the style vocabulary, the rect of an element |
|
|
333
|
-
| `music` | function | `music(key \| null, { fadeMs? })`, the awaited effect that switches the music track |
|
|
334
|
-
| `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha` | functions and components | Effects as data, drawn by `effectsPlugin`. `defineEmitter(id, config)` describes a particle effect that a feature lists under `emitters`; `Emitter({ effect, active })` runs it on its entity. `defineFilter(id, { wgsl, glsl, uniforms, passes?, padding? })` returns a filter component type that a feature lists under `filters`; a filter on a ui element goes in its `components` prop |
|
|
335
|
-
| `PlatformProvider`, `BackResult`, `HapticKind`, `HAPTIC_KINDS` | types and a constant | The seam a game fills for `platformPlugin`, what one Back press ends in, and the seven haptic kinds as a type and as a frozen list |
|
|
336
|
-
| `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin`, `platformPlugin` | plugin instances | For `depends` and `ctx.require` in game plugins; `screen` is the list of the nine screen plugins |
|
|
337
|
-
| `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`, `Effects`, `Platform` | type namespaces | All public types of one plugin |
|
|
338
|
-
|
|
339
|
-
### Other entries
|
|
340
|
-
|
|
341
|
-
| Entry | Runs in | Exports |
|
|
342
|
-
|---|---|---|
|
|
343
|
-
| `@moku-labs/game/testing` | node and bun only | The headless helpers and the visual tests, see below. The visual runner reads and writes baseline files |
|
|
344
|
-
| `@moku-labs/game/assets` | node and bun only | `scanAssets`, `emitKeys`, `emitManifest`, `compileStrings`, `checkStrings`, `packAssets`, `runCli`. A game runs it as `bun run assets:keys`: it writes the manifest, the typed asset keys and, next to them, `generated/strings.ts` with one `strings.<locale>.ts` per locale; `--check` fails when any of them is out of date; `--pack <dir>` writes the production build: WebP atlas pages, content-hashed names and a v2 manifest |
|
|
345
|
-
| `@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) |
|
|
346
|
-
| `@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) |
|
|
347
|
-
| `@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 |
|
|
348
|
-
|
|
349
|
-
### Testing entry
|
|
350
|
-
|
|
351
|
-
`@moku-labs/game/testing` re-exports the headless helpers and the visual tests.
|
|
352
|
-
|
|
353
|
-
| Export | Signature | Purpose |
|
|
354
|
-
|---|---|---|
|
|
355
|
-
| `createHeadless` | `(app: HeadlessApp) => Promise<HeadlessGame>` | Sets flow mode `"fast"`, starts the app, starts `flow.run()` unless the app already did, and waits for the first rest point |
|
|
356
|
-
| `runRepro` | `(app: HeadlessApp, repro: Repro) => Promise<ReproResult>` | Restores a state and a checkpoint, then walks a route |
|
|
357
|
-
| `stepFrames` | `(app: HeadlessApp, count: number, deltaMs: number) => void` | Calls `app.time.step(deltaMs)` `count` times |
|
|
358
|
-
| `fakeClock` | `(start = 0) => FakeClock` | A `ClockSource` with `advance(ms)` and `set(moment)` |
|
|
359
|
-
| `memory` | `(fixture?: { state: SaveDoc; version: number }) => PlayerStateProvider & { calls: ProviderCall[] }` | In-memory save provider. It keeps what it was committed and records every call |
|
|
360
|
-
| `saveOf` | `(player: Json, seed?: number) => SaveDoc` | Builds a save document for a fixture |
|
|
361
|
-
| `defineVisualTest` | `(name: string, test: { start: VisualStart; steps: readonly VisualStep[]; webgl?: boolean }) => VisualTest` | A visual test as frozen data: where it starts, its steps, and whether it also runs in the WebGL leg |
|
|
362
|
-
| `runVisualTests` | `(setup: VisualSetup, tests: readonly VisualTest[], options?: VisualOptions) => Promise<VisualReport>` | Plays the tests and compares every checkpoint with its baseline files |
|
|
363
|
-
| `parseVisualArgv` | `(argv: readonly string[]) => { update?; pixels?; only?; dir?; renderer? }` | Reads `--update`, `--no-pixels`, `--webgl`, `--only <name>` and `--dir <path>` from a command line. `--webgl` runs only the tests with `webgl: true`, on the page with `?renderer=webgl`: such a test writes its picture to `screen.webgl.webp` and shares `state.json` and `describe.json` with the WebGPU leg |
|
|
364
|
-
|
|
365
|
-
A `HeadlessGame` has `walk(route)`, `answer(answer)`, `state()`, `history()` and `stop()`.
|
|
366
|
-
|
|
367
|
-
### Visual tests
|
|
368
|
-
|
|
369
|
-
A visual test is data: where the game starts, then steps. A step is a `/control` command by its short name (`answer`, `tap`, `drag`, `key`, `fill`, `walk`, `restore`, `step`, `pause`, `resume`, `reducedMotion`) with that command's input, or a checkpoint.
|
|
370
|
-
|
|
371
|
-
```ts
|
|
372
|
-
// tests/visual/reward-popup.visual.ts
|
|
373
|
-
export const rewardPopup = defineVisualTest("reward-popup", {
|
|
374
|
-
start: { player: fixtures.ready, checkpoint: "home" },
|
|
375
|
-
steps: [
|
|
376
|
-
{ tap: { key: "play" } },
|
|
377
|
-
{ answer: { intent: "deliver", payload: { orderId: "o1" } } },
|
|
378
|
-
{ checkpoint: "open" },
|
|
379
|
-
{ tap: { key: "claim" } },
|
|
380
|
-
{ checkpoint: "claimed" }
|
|
381
|
-
]
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// tests/visual/run.ts, `bun tests/visual/run.ts --update`
|
|
385
|
-
const report = await runVisualTests({ app: () => createScreenGame().app, page: { url: "http://localhost:3000/" } }, [rewardPopup]);
|
|
386
|
-
process.exitCode = report.ok ? 0 : 1;
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
A checkpoint settles the motions and saves three baseline files next to the test: `<dir>/<test>/<checkpoint>/state.json`, `describe.json` and `screen.webp`. A missing file is written; `--update` rewrites them; any other file is compared. The headless leg plays every test in plain Bun and compares `state.json` and `describe.json` exactly, so it runs in `bun run test`. The pixel leg plays the same steps on the dev page in Chrome with WebGPU, or with WebGL under `--webgl`, on a Mac only, and compares `screen.webp` with a tolerance; a pixel difference with the same state and describe is reported as a rendering regression. The page is the contract: a dev build that sets `globalThis.game` to the app and `globalThis.doors` to `{ read, watch, sources, run, commands }`. No CI job runs pixels.
|
|
390
|
-
|
|
391
|
-
#### Pixels
|
|
392
|
-
|
|
393
|
-
The pixel leg runs on a Mac only. `pixels` is on by default when the setup has a `page` and the platform is `darwin`, and off everywhere else. No CI job runs it: a Linux runner has no WebGPU worth trusting.
|
|
394
|
-
|
|
395
|
-
- **`playwright-core`** is an optional peer dependency. The leg loads it with a dynamic import, so a game that never runs pixels never installs it. Add it with `bun add -d playwright-core`. Without it the run stops with "The pixel leg needs playwright-core"; `--no-pixels` runs the headless leg alone.
|
|
396
|
-
- **The browser.** `page.browser` when the setup names one (`{ channel }` or `{ executablePath }`). Otherwise Playwright's own Chromium, whose version `playwright-core` pins, so a Chrome update never moves the baselines: install it with `bunx playwright-core install chromium`. Otherwise the system Google Chrome. Chrome starts with WebGPU on Metal; a page that draws with a renderer other than the requested one fails the leg.
|
|
397
|
-
- **The page.** 390 CSS px wide at a device scale of 2, as tall as the game's inert frame: 520 for a 4:3 portrait game, so `screen.webp` is 780 × 1040.
|
|
398
|
-
- **Game time.** The leg drives the clock itself, so two runs draw the same pixels: a dash phase, a pulse or a particle reads the same time. It waits until the page has loaded what it asked for (no request for half a second), runs `pause`, then steps the game to 5 s of game time, the last frame shorter so it lands exactly. From there every frame of the restore, of every settle and of a checkpoint is a `step` of 1000/60 ms, the frame of the headless leg; the browser's own frames move no game time. The picture is the stage after the last stepped frame. A `resume` step hands the clock back to the browser.
|
|
399
|
-
- **The picture.** Lossless WebP: the page encodes it with `convertToBlob({ type: "image/webp", quality: 1 })`, which Chrome encodes without loss, so the baseline holds the captured pixels exactly.
|
|
400
|
-
- **`--update`** rewrites `state.json`, `describe.json` and `screen.webp` of every test it runs. A pixel difference writes `screen.actual.webp` and `screen.diff.webp` beside the baseline, red where a pixel differs. Git ignores both.
|
|
401
|
-
- **Tolerance.** A pixel differs when one of its channels moves by more than 24. A checkpoint differs when more than 0.1% of its pixels do. Both are `tolerance` in the options.
|
|
402
|
-
|
|
403
|
-
The fixture game keeps its visual tests in `tests/visual/`: one `*.visual.ts` per test, the list in `tests.ts`, the script in `run.ts` and the baselines next to them. The script imports the runner from `src/testing`, so a stale `dist/` never writes baselines. `bun run test` runs the headless leg through `tests/integration/visual-headless.test.ts`. Both legs run against the dev page:
|
|
404
|
-
|
|
405
|
-
```sh
|
|
406
|
-
# terminal 1: the dev page of the fixture on a free port
|
|
407
|
-
cd tests/integration/merge-game && bun ./web/serve.ts --port 4173
|
|
408
|
-
|
|
409
|
-
# terminal 2: from the root of the repository
|
|
410
|
-
bun run fixture:visual --url http://localhost:4173/ # compare with the baselines
|
|
411
|
-
bun run fixture:visual --url http://localhost:4173/ --update # write the baselines again
|
|
412
|
-
bun run fixture:visual --only gift-popup --no-pixels # one test, headless only
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
Without `--url` the page is `http://localhost:3000/`. The exit code is 1 when a checkpoint differs.
|
|
416
|
-
|
|
417
|
-
The test of the browser leg itself opens a real Chrome only on request: `MOKU_VISUAL_BROWSER=1 bunx vitest run tests/unit/visual/browser.test.ts`. It also proves the WebP is lossless: random opaque pixels come back byte for byte. Without the variable it skips with its reason, so `bun run test` opens no browser.
|
|
418
|
-
|
|
419
|
-
## Interface in JSX
|
|
420
|
-
|
|
421
|
-
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.
|
|
422
|
-
|
|
423
|
-
```jsonc
|
|
424
|
-
// tsconfig.json of the game
|
|
425
|
-
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@moku-labs/game" } }
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
The tags are `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel`, `image`, `icon`, `text`, `button`, `scroll` and `input`. A `button` either names an `intent` for the gate or writes `local` state of its nearest component, never both. An `input` is a text field: its text lives in the `local` field it names, every keystroke writes it, and Enter answers its `submit` intent with `{ [local]: value }`, the only thing the gate hears: `<input key="nameField" local="name" maxLength={16} submit="save" placeholder={tr("rename.hint")} />`. A `text` takes a string or a `Message` from `tr`; its size comes from a text style key, not from the layout style. Every tag takes `components`, a list of extra component values for the element's entity: `<button key="claim" intent="claim" components={[Glow({ strength: 2 })]} />` glows, and the glow covers the button and its label.
|
|
429
|
-
|
|
430
|
-
```tsx
|
|
431
|
-
// features/hud/view.tsx — the HUD, a reward popup and the choreography they share
|
|
432
|
-
import type { Anim } from "@moku-labs/game";
|
|
433
|
-
import { Transform, mark, parallel, sequence, sfx, tween, type } from "@moku-labs/game";
|
|
434
|
-
import { defineAnimation, defineComponent, defineFeature, defineTextStyles, projection, tr } from "../../state";
|
|
435
|
-
|
|
436
|
-
export const hud = projection({
|
|
437
|
-
name: "hud",
|
|
438
|
-
layer: "ui",
|
|
439
|
-
from: player => ({ coins: player.coins }),
|
|
440
|
-
view: hud => (
|
|
441
|
-
<row key="bar" style={{ gap: 16, padding: { top: "safeArea.top", left: 24, right: 24 }, width: "100%", height: 120 }}>
|
|
442
|
-
<text key="coins" style="hud.digits" content={tr("hud.coins", { n: hud.coins })} />
|
|
443
|
-
<button key="settings" intent="openSettings" style={{ width: 96, height: 96 }}>
|
|
444
|
-
<icon name="hud.gear" />
|
|
445
|
-
</button>
|
|
446
|
-
</row>
|
|
447
|
-
)
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
export const RewardPopup = defineComponent("RewardPopup", {
|
|
451
|
-
outcomes: { claim: type<{ orderId: string }>() },
|
|
452
|
-
view: (props: { orderId: string; gold: number }) => (
|
|
453
|
-
<panel key="reward" style={{ nineSlice: "ui.panel", direction: "column", gap: 16, padding: 32, width: 600, height: 400, fit: "contain" }}>
|
|
454
|
-
<text key="title" content={tr("orders.complete")} />
|
|
455
|
-
<text key="gold" style="hud.digits" content={String(props.gold)} />
|
|
456
|
-
<button key="claim" intent="claim" payload={{ orderId: props.orderId }} style={{ width: 240, height: 88 }}>
|
|
457
|
-
<text content={tr("common.claim")} />
|
|
458
|
-
</button>
|
|
459
|
-
</panel>
|
|
460
|
-
)
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
export const popCoins = defineAnimation("hud.popCoins", {
|
|
464
|
-
slots: { coins: type<Anim.Target>() },
|
|
465
|
-
build: ({ coins }) =>
|
|
466
|
-
sequence(
|
|
467
|
-
tween(coins, Transform, { scale: 1.2 }, { ms: 120 }),
|
|
468
|
-
parallel(tween(coins, Transform, { scale: 1 }, { ms: 200, ease: "outCubic" }), sfx("hud.coins")),
|
|
469
|
-
mark("done")
|
|
470
|
-
)
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
export const hudFeature = defineFeature("hud", {
|
|
474
|
-
projections: [hud],
|
|
475
|
-
ui: [RewardPopup],
|
|
476
|
-
animations: [popCoins],
|
|
477
|
-
textStyles: defineTextStyles({ "hud.digits": { font: "ui.font-digits", size: 40, fill: 0xffe082, digits: true } }),
|
|
478
|
-
strings: { en: () => import("../../generated/strings.en") }
|
|
479
|
-
});
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
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.
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
// features/orders/deliver.ts
|
|
486
|
-
import { play, sfx, type } from "@moku-labs/game";
|
|
487
|
-
import { defineNode, popup } from "../../state";
|
|
488
|
-
import { popCoins, RewardPopup } from "../hud/view";
|
|
489
|
-
|
|
490
|
-
export const deliver = defineNode({
|
|
491
|
-
outcomes: { claimed: type<{ orderId: string }>() },
|
|
492
|
-
run: async ({ player, fx, out }) => {
|
|
493
|
-
fx(sfx("orders.complete"));
|
|
494
|
-
const answer = (await fx(popup(RewardPopup, { orderId: "o1", gold: 5 }))) as { intent: "claim"; payload: { orderId: string } };
|
|
495
|
-
|
|
496
|
-
player.coins += 5;
|
|
497
|
-
await fx(play(popCoins, { coins: { projection: "hud", key: "coins" } }));
|
|
498
|
-
return out.claimed({ orderId: answer.payload.orderId });
|
|
499
|
-
}
|
|
500
|
-
});
|
|
501
|
-
```
|
|
154
|
+
## Plugins
|
|
502
155
|
|
|
503
|
-
|
|
156
|
+
Five logic plugins run in every app. The nine screen plugins come as one list, `screen`. Three more are opt-in.
|
|
504
157
|
|
|
505
158
|
```ts
|
|
506
|
-
|
|
507
|
-
createApp({
|
|
508
|
-
plugins: [...screen, audioPlugin, hudFeature],
|
|
509
|
-
pluginConfigs: {
|
|
510
|
-
renderer: { mount: "#game" },
|
|
511
|
-
audio: { volumes: player => (player as Player).settings.audio }
|
|
512
|
-
}
|
|
513
|
-
});
|
|
159
|
+
createApp({ plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin] });
|
|
514
160
|
```
|
|
515
161
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
> [!TIP]
|
|
519
|
-
> `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.
|
|
520
|
-
|
|
521
|
-
## Doors for the editor
|
|
522
|
-
|
|
523
|
-
The editor, MCP tools and e2e scripts reach a running game through two subpaths. Neither is on the root.
|
|
524
|
-
|
|
525
|
-
| Subpath | Exports | In a production build |
|
|
162
|
+
| Plugin | Set | What it does |
|
|
526
163
|
|---|---|---|
|
|
527
|
-
|
|
|
528
|
-
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
164
|
+
| [`time`](./src/plugins/time/README.md) | logic | One frame loop, six phases, `step` for tests |
|
|
165
|
+
| [`lifecycle`](./src/plugins/lifecycle/README.md) | logic | A stack of pause reasons |
|
|
166
|
+
| [`model`](./src/plugins/model/README.md) | logic | Player and session state, the save, `rng` streams, migrations |
|
|
167
|
+
| [`clock`](./src/plugins/clock/README.md) | logic | Trusted `now()` and timers stored as moments |
|
|
168
|
+
| [`flow`](./src/plugins/flow/README.md) | logic | The graph: runner, gate, inbox, effects, features |
|
|
169
|
+
| [`world`](./src/plugins/world/README.md) | screen | A small ECS and projections from committed state |
|
|
170
|
+
| [`renderer`](./src/plugins/renderer/README.md) | screen | Pixi v8, loaded lazily, synced from the ECS |
|
|
171
|
+
| [`input`](./src/plugins/input/README.md) | screen | Gestures as data: tap, press, drag, swipe |
|
|
172
|
+
| [`assets`](./src/plugins/assets/README.md) | screen | Manifest, load tiers, typed keys, the production pack |
|
|
173
|
+
| [`scenes`](./src/plugins/scenes/README.md) | screen | A scene as a declaration: bundle, layers, music |
|
|
174
|
+
| [`anim`](./src/plugins/anim/README.md) | screen | Choreography as data: tweens, timelines, motions |
|
|
175
|
+
| [`i18n`](./src/plugins/i18n/README.md) | screen | Strings as data, ICU messages, compiled per locale |
|
|
176
|
+
| [`text`](./src/plugins/text/README.md) | screen | MSDF text, styles, inline tags and icons |
|
|
177
|
+
| [`ui`](./src/plugins/ui/README.md) | screen | Screens in JSX, Yoga layout, popups, styles |
|
|
178
|
+
| [`effects`](./src/plugins/effects/README.md) | opt-in | Particles and filters as components, WGSL and GLSL |
|
|
179
|
+
| [`audio`](./src/plugins/audio/README.md) | opt-in | Music and sound effects on three buses |
|
|
180
|
+
| [`platform`](./src/plugins/platform/README.md) | opt-in | The phone: Back button, haptics, keep-awake |
|
|
181
|
+
|
|
182
|
+
`log` and `env` come from [`@moku-labs/common`](https://github.com/moku-labs/common). The dependency graph and every root export are in [docs/plugins.md](./docs/plugins.md).
|
|
183
|
+
|
|
184
|
+
### Entry points
|
|
185
|
+
|
|
186
|
+
| Import | Runs in | For |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `@moku-labs/game` | anywhere | The engine: plugins, helpers, components |
|
|
189
|
+
| `@moku-labs/game/testing` | Node and Bun | Headless games and visual tests |
|
|
190
|
+
| `@moku-labs/game/assets` | Node and Bun | Asset keys, the manifest, strings, the pack |
|
|
191
|
+
| `@moku-labs/game/inspect` | anywhere | Read a running game |
|
|
192
|
+
| `@moku-labs/game/control` | dev builds | Drive a running game |
|
|
193
|
+
| `@moku-labs/game/jsx-runtime`, `/jsx-dev-runtime` | anywhere | The JSX runtime. A game never imports it by hand. |
|
|
533
194
|
|
|
534
|
-
|
|
535
|
-
// dice.test.ts
|
|
536
|
-
import { commands, run } from "@moku-labs/game/control";
|
|
537
|
-
import { read, sources, watch } from "@moku-labs/game/inspect";
|
|
538
|
-
import { createHeadless } from "@moku-labs/game/testing";
|
|
539
|
-
import { it, vi } from "vitest";
|
|
540
|
-
import { createGame } from "./game";
|
|
541
|
-
|
|
542
|
-
it("rolls, bookmarks and restores through the doors", async () => {
|
|
543
|
-
vi.stubGlobal("__MOKU_GAME_DEV__", true);
|
|
544
|
-
const app = createGame(42);
|
|
545
|
-
const game = await createHeadless(app);
|
|
546
|
-
|
|
547
|
-
read(app, sources.position); // { path: "home", flow: "main", node: "home", waiting: ["roll", "reset"] }
|
|
548
|
-
|
|
549
|
-
const seen: string[] = [];
|
|
550
|
-
const stop = watch(app, sources.position, undefined, position => {
|
|
551
|
-
seen.push(position.path);
|
|
552
|
-
});
|
|
553
|
-
app.time.step(16);
|
|
554
|
-
seen; // ["home"]: a headless watch reads on the frames a test steps
|
|
555
|
-
|
|
556
|
-
const walked = await run(app, commands.walk, { route: [{ at: "home", intent: "roll" }] });
|
|
557
|
-
walked.value.path; // "home"
|
|
558
|
-
walked.state; // { path: "home", frame: 1, tainted: false }: a walk goes through the graph
|
|
559
|
-
read(app, sources.model).session; // { rolls: 1 }
|
|
560
|
-
|
|
561
|
-
const { value: mark } = await run(app, commands.bookmark);
|
|
562
|
-
await run(app, commands.restore, { bookmark: mark });
|
|
563
|
-
read(app, sources.tainted); // true: a restore replaces the state outside the graph
|
|
564
|
-
read(app, sources.cheats); // [{ id: "game.restore", input: { bookmark: { path: "home", ... } }, frame: 1 }]
|
|
565
|
-
|
|
566
|
-
stop();
|
|
567
|
-
await game.stop();
|
|
568
|
-
});
|
|
569
|
-
```
|
|
195
|
+
## Docs
|
|
570
196
|
|
|
571
|
-
|
|
572
|
-
- **`read(app, source, input?)`** calls the source once and returns what it reads.
|
|
573
|
-
- **`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)`.
|
|
574
|
-
- **`run(app, command, input?)`** resolves `{ value, state }`. `value` is what the command returned. `state` is the envelope `{ path, frame, tainted }`, read after the command.
|
|
575
|
-
- **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.
|
|
576
|
-
|
|
577
|
-
### Base sources
|
|
578
|
-
|
|
579
|
-
From `@moku-labs/game/inspect`. `Changes` is when `watch` reads the source again.
|
|
580
|
-
|
|
581
|
-
| Key in `sources` | id | Input | Changes | Reads |
|
|
582
|
-
|---|---|---|---|---|
|
|
583
|
-
| `graph` | `game.graph` | none | edge | `flow.describe()`: flows, nodes with their flags and outcomes, edges, slots |
|
|
584
|
-
| `position` | `game.position` | none | edge | `{ path, flow, node, waiting }` from `flow.state()`. `waiting` lists the intents the gate waits for |
|
|
585
|
-
| `history` | `game.history` | `{ last: "number?" }` | edge | `flow.history()`, the edges since the last checkpoint: all of them, or the last `last` |
|
|
586
|
-
| `tainted` | `game.tainted` | none | frame | Whether a `cheat` or `raw` command ran on this app |
|
|
587
|
-
| `cheats` | `game.cheats` | none | frame | The journal of `cheat` and `raw` commands, `{ id, input, frame }`, oldest first, the last 500 |
|
|
588
|
-
| `model` | `game.model` | none | commit | `model.store.snapshot()`: the committed `{ player, session, rng }` |
|
|
589
|
-
| `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 |
|
|
590
|
-
| `projections` | `game.projections` | none | commit | Projection name to key to entity, through `world.projection.keyOf` |
|
|
591
|
-
| `ui` | `game.ui` | none | frame | `ui.tree()`: the live screen as plain data |
|
|
592
|
-
| `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 |
|
|
593
|
-
| `render` | `game.render` | none | frame | `renderer.stats()`: `{ fps, frameMs, textures, textureMb, views, pooled, renderPasses }`, and `drawCalls` in a dev build. `renderPasses` is 1 for the frame plus, for every view with an enabled filter, 1 for its content and the passes of its filters |
|
|
594
|
-
| `effects` | `game.effects` | none | frame | `effects.stats()`: `{ particles, emitters, filters, renderPasses }`. Only in a game that composes `effectsPlugin` |
|
|
595
|
-
| `sounds` | `game.sounds` | `{ last: "number?" }` | frame | `audio.journal()`: all, or the last `last`. Empty unless `pluginConfigs.audio.journal` is above 0 |
|
|
596
|
-
| `assets` | `game.assets` | none | frame | `assets.usage()`: `{ textureMb, budgetMb, bundles }` |
|
|
597
|
-
| `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 |
|
|
598
|
-
|
|
599
|
-
### Base commands
|
|
600
|
-
|
|
601
|
-
From `@moku-labs/game/control`. Every command runs in dev builds only and leaves a `moku:dev` debug entry in the log.
|
|
602
|
-
|
|
603
|
-
| Key in `commands` | id | Input | Effect | Does |
|
|
604
|
-
|---|---|---|---|---|
|
|
605
|
-
| `answer` | `game.answer` | `{ intent: "string", payload: "json?" }` | route | `flow.gate.answer({ intent, payload })`. Value: whether the gate took the answer |
|
|
606
|
-
| `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 |
|
|
607
|
-
| `drag` | `game.drag` | `{ from: "json", to: "json" }` | route | `input.drag(from, to)`, both `{ projection, key }`. Value: whether the gate took the answer |
|
|
608
|
-
| `key` | `game.key` | `{ key: "string", shift: "boolean?" }` | route | `input.pressKey(key, { shift })`. Value: whether a listener handled the key |
|
|
609
|
-
| `fill` | `game.fill` | `{ key: "string", value: "string" }` | route | `ui.fill(key, value)`: types into the `input` with that `key`, cut to its `maxLength`. Value: whether the field took the text |
|
|
610
|
-
| `walk` | `game.walk` | `{ route: "json" }` | route | `flow.walk(route)`. Value: the flow state after the walk |
|
|
611
|
-
| `bookmark` | `game.bookmark` | none | read | `flow.bookmark()`. Value: the bookmark, plain JSON |
|
|
612
|
-
| `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 |
|
|
613
|
-
| `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()` |
|
|
614
|
-
| `pause` | `game.pause` | none | cosmetic | `lifecycle.push("devtools")`. Value: `lifecycle.isPaused()` |
|
|
615
|
-
| `resume` | `game.resume` | none | cosmetic | `lifecycle.pop("devtools")`. Value: `lifecycle.isPaused()`, still true while another reason holds |
|
|
616
|
-
| `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 |
|
|
617
|
-
| `debug` | `game.debug` | `{ nineSlice: "boolean" }` | cosmetic | `renderer.sync.debug.nineSlice(on)`. Value: the debug switches |
|
|
618
|
-
| `reducedMotion` | `game.reducedMotion` | `{ on: "boolean" }` | cosmetic | `anim.setReducedMotion(on)`. Value: `anim.reducedMotion()` |
|
|
619
|
-
|
|
620
|
-
### Effects, taint and the cheat journal
|
|
621
|
-
|
|
622
|
-
| Effect | Means |
|
|
197
|
+
| Page | What is inside |
|
|
623
198
|
|---|---|
|
|
624
|
-
|
|
|
625
|
-
|
|
|
626
|
-
|
|
|
627
|
-
|
|
|
628
|
-
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
### Turn the dev build on
|
|
633
|
-
|
|
634
|
-
`__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.
|
|
635
|
-
|
|
636
|
-
**Preferred: a bundler `define`**, `true` in dev and `false` in production.
|
|
637
|
-
|
|
638
|
-
```ts
|
|
639
|
-
// build.ts of the game
|
|
640
|
-
const production = Bun.argv.includes("--production");
|
|
641
|
-
|
|
642
|
-
await Bun.build({
|
|
643
|
-
entrypoints: ["web/main.ts"],
|
|
644
|
-
outdir: "dist",
|
|
645
|
-
minify: true,
|
|
646
|
-
define: { __MOKU_GAME_DEV__: production ? "false" : "true" }
|
|
647
|
-
});
|
|
648
|
-
```
|
|
649
|
-
|
|
650
|
-
**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):
|
|
651
|
-
|
|
652
|
-
```ts
|
|
653
|
-
// web/dev.ts, the first import of web/main.ts
|
|
654
|
-
globalThis.__MOKU_GAME_DEV__ = true;
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
A test sets it with `vi.stubGlobal("__MOKU_GAME_DEV__", true)`.
|
|
658
|
-
|
|
659
|
-
**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.
|
|
660
|
-
|
|
661
|
-
### A game's own sources and commands
|
|
662
|
-
|
|
663
|
-
`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.
|
|
664
|
-
|
|
665
|
-
```ts
|
|
666
|
-
// dice.dev.ts
|
|
667
|
-
import { controlRefused, defineCommand } from "@moku-labs/game/control";
|
|
668
|
-
import { defineSource } from "@moku-labs/game/inspect";
|
|
669
|
-
import type { createGame } from "./game";
|
|
670
|
-
|
|
671
|
-
export const rolls = defineSource({
|
|
672
|
-
id: "dice.rolls",
|
|
673
|
-
title: "Rolls",
|
|
674
|
-
input: {},
|
|
675
|
-
changes: "commit",
|
|
676
|
-
read: (app: ReturnType<typeof createGame>) => app.model.store.snapshot().session
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
export const rollTwice = defineCommand({
|
|
680
|
-
id: "dice.rollTwice",
|
|
681
|
-
title: "Roll twice",
|
|
682
|
-
input: {},
|
|
683
|
-
effect: "route",
|
|
684
|
-
run: app => {
|
|
685
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
686
|
-
return app.flow.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
export const giveCoins = defineCommand({
|
|
691
|
-
id: "dice.giveCoins",
|
|
692
|
-
title: "Give coins",
|
|
693
|
-
input: { coins: "number" },
|
|
694
|
-
effect: "cheat",
|
|
695
|
-
run: (app, { coins }) => {
|
|
696
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
697
|
-
return app.flow.restore({ ...app.flow.bookmark(), player: { coins, lastRoll: 0 } });
|
|
698
|
-
}
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
// On a fresh headless game, before any frame:
|
|
702
|
-
// (await run(app, rollTwice)).state; // { path: "home", frame: 0, tainted: false }
|
|
703
|
-
// read(app, rolls); // { rolls: 2 }
|
|
704
|
-
// (await run(app, giveCoins, { coins: 100 })).state.tainted; // true
|
|
705
|
-
// read(app, sources.cheats); // [{ id: "dice.giveCoins", input: { coins: 100 }, frame: 0 }]
|
|
706
|
-
```
|
|
707
|
-
|
|
708
|
-
## Events
|
|
709
|
-
|
|
710
|
-
Global events are empty: every event belongs to a plugin. `time` and `clock` emit nothing.
|
|
711
|
-
|
|
712
|
-
| Event | Emitted by | Payload | When |
|
|
713
|
-
|---|---|---|---|
|
|
714
|
-
| `lifecycle:changed` | `lifecycle` | `{ reason: PauseReason; action: "push" \| "pop"; reasons: readonly PauseReason[]; paused: boolean; resumed: boolean }` | The pause stack really changed. `resumed` is true only on the change that emptied the stack |
|
|
715
|
-
| `model:committed` | `model` | `{ roots: readonly Root[]; cause: "edge" \| "rollback" \| "restore" \| "load" }` | Committed state changed. `Root` is `"player" \| "session" \| "rng"` |
|
|
716
|
-
| `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 |
|
|
717
|
-
| `flow:rest` | `flow` | `{ path: string; checkpoint: boolean }` | The graph entered a rest node |
|
|
718
|
-
| `flow:error` | `flow` | `{ path: string; error: unknown; rolledBackTo: string; retry: boolean }` | A node failed and the graph rolled back |
|
|
719
|
-
| `world:reconciled` | `world` | counts per reconcile | Dev only, behind `reconciledEvent` |
|
|
720
|
-
| `renderer:device-lost` | `renderer` | `{ kind, reason }` | The GPU device or context was lost; `lifecycle` is pushed |
|
|
721
|
-
| `assets:bundle-loaded`, `assets:bundle-unloaded` | `assets` | `{ bundle, tier, mb, reason }` | A bundle entered or left memory |
|
|
722
|
-
| `scenes:changed` | `scenes` | `{ from, to, music }` | The scene switched on entering a node |
|
|
723
|
-
| `anim:mark` | `anim` | `{ animation, mark }` | A `mark` step was reached, or jumped by `finish()` |
|
|
724
|
-
| `anim:finished` | `anim` | `{ animation }` | A timeline ended or was finished. Never on `cancel()` |
|
|
725
|
-
| `i18n:locale-changed` | `i18n` | `{ locale }` | The module of the new locale is loaded; `text` re-resolves. Never at start |
|
|
726
|
-
|
|
727
|
-
`text`, `ui`, `audio`, `effects` and `platform` emit nothing; the `effects` budgets are log warnings.
|
|
199
|
+
| [Quick start](./docs/quick-start.md) | The full dice game, step by step |
|
|
200
|
+
| [Plugins](./docs/plugins.md) | The dependency graph, the plugin table, every root export |
|
|
201
|
+
| [Interface in JSX](./docs/jsx.md) | Tags, components, styles, popups |
|
|
202
|
+
| [Doors for the editor](./docs/doors.md) | `/inspect` and `/control`, base sources and commands |
|
|
203
|
+
| [Events](./docs/events.md) | Every event and its payload |
|
|
204
|
+
| [Configuration](./docs/configuration.md) | Every config field and its default |
|
|
205
|
+
| [Testing](./docs/testing.md) | Headless and visual tests, all scripts, test layout, lint rules |
|
|
206
|
+
| [`llms.txt`](./llms.txt) | The engine in one page, for an AI that writes a game |
|
|
728
207
|
|
|
729
|
-
|
|
730
|
-
import { createPlugin, flowPlugin } from "@moku-labs/game";
|
|
731
|
-
|
|
732
|
-
export const edgeLog = createPlugin("edgeLog", {
|
|
733
|
-
depends: [flowPlugin],
|
|
734
|
-
hooks: ctx => ({
|
|
735
|
-
"flow:edge": payload => {
|
|
736
|
-
ctx.log.info("edge", { node: payload.node, outcome: payload.outcome });
|
|
737
|
-
}
|
|
738
|
-
})
|
|
739
|
-
});
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
## Configuration
|
|
743
|
-
|
|
744
|
-
### Global
|
|
745
|
-
|
|
746
|
-
```ts
|
|
747
|
-
createApp({ config: { orientation: "landscape", referenceSide: 1080, referenceLong: 1920 } });
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
| Key | Type | Default | Meaning |
|
|
751
|
-
|---|---|---|---|
|
|
752
|
-
| `orientation` | `"portrait" \| "landscape"` | `"portrait"` | Screen orientation the game is designed for |
|
|
753
|
-
| `referenceSide` | `number` | `1080` | Short side of the reference resolution in pixels |
|
|
754
|
-
| `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 |
|
|
755
|
-
|
|
756
|
-
### Per plugin
|
|
757
|
-
|
|
758
|
-
Set with `createApp({ pluginConfigs: { <plugin>: { ... } } })`.
|
|
759
|
-
|
|
760
|
-
| Plugin | Key | Type | Default | Meaning |
|
|
761
|
-
|---|---|---|---|---|
|
|
762
|
-
| `time` | `maxFps` | `30 \| 60 \| 120` | `60` | Frame rate cap |
|
|
763
|
-
| `time` | `maxDeltaMs` | `number` | `50` | Upper bound of one frame's delta in milliseconds |
|
|
764
|
-
| `time` | `idleFps` | `0 \| 30` | `30` | Frame rate cap of an idle screen. `0` turns the idle cap off. Any plugin lifts it with `wake()` |
|
|
765
|
-
| `time` | `idleAfterMs` | `number` | `2000` | Unscaled milliseconds without a `wake()` after which the loop drops to `idleFps` |
|
|
766
|
-
| `lifecycle` | none | | | The plugin has no config |
|
|
767
|
-
| `model` | `playerProvider` | `PlayerStateProvider \| undefined` | `undefined` | The save seam. `undefined` means an in-memory provider: the save lives as long as the app does |
|
|
768
|
-
| `model` | `initialPlayer` | `Json` | `{}` | Player state of a new player. Deep-cloned |
|
|
769
|
-
| `model` | `initialSession` | `Json` | `{}` | Session state at every start. Deep-cloned |
|
|
770
|
-
| `model` | `seed` | `"from-save" \| number` | `"from-save"` | `"from-save"`: a new player gets a random seed once. A number fixes it for tests |
|
|
771
|
-
| `model` | `schemaVersion` | `number` | `1` | Version of the save schema this build writes |
|
|
772
|
-
| `model` | `migrations` | `readonly Migration[]` | `[]` | Ordered chain. `up` of `from: n` produces version `n + 1` |
|
|
773
|
-
| `clock` | `source` | `ClockSource \| undefined` | `undefined` | Time source. `undefined` means the system source. Tests pass `fakeClock()` |
|
|
774
|
-
| `flow` | `mainFlow` | `AnyFlow \| undefined` | `undefined` | The top-level flow. Required before `run()` |
|
|
775
|
-
| `flow` | `safeNode` | `string \| undefined` | `undefined` | Path of the checkpoint entered after a failed retry. `undefined` means the main flow's `start` |
|
|
776
|
-
| `flow` | `retries` | `number` | `1` | Retries of a failed transition before `safeNode` |
|
|
777
|
-
| `flow` | `settleTimeoutMs` | `number` | `2000` | How long `onStop` waits for the active node to settle after abort |
|
|
778
|
-
| `flow` | `journalLimit` | `number` | `500` | Journal entries kept between checkpoints |
|
|
779
|
-
| `world` | `settleMs` | `number` | `350` | Length of the default settle motion |
|
|
780
|
-
| `world` | `reconciledEvent` | `boolean` | `false` | Emit `world:reconciled` after every reconcile (dev tools) |
|
|
781
|
-
| `renderer` | `mount` | `string \| undefined` | `undefined` | Selector of the mount element. `undefined` keeps the renderer inert |
|
|
782
|
-
| `renderer` | `preference` | `"webgpu" \| "webgl"` | `"webgpu"` | Preferred backend; Pixi falls back to WebGL |
|
|
783
|
-
| `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 |
|
|
784
|
-
| `renderer` | `debug` | `{ nineSlice: boolean }` | `{ nineSlice: false }` | Outline every nine-slice from the start; `sync.debug.nineSlice(on)` switches it live |
|
|
785
|
-
| `input` | `tapSlopPx`, `longPressMs`, `dragStartPx`, `swipeMinPx`, `swipeMaxMs` | `number` | `12`, `450`, `8`, `48`, `300` | Gesture thresholds in reference px and ms |
|
|
786
|
-
| `input` | `cursor` | `{ control: string; idle: string }` | `{ control: "pointer", idle: "" }` | CSS cursor over a control and elsewhere |
|
|
787
|
-
| `assets` | `manifest` | `string \| Manifest \| undefined` | `undefined` | Manifest URL, or the parsed file in a test |
|
|
788
|
-
| `assets` | `textureBudgetMb` | `number` | `192` | Texture memory budget for the LRU unload |
|
|
789
|
-
| `assets` | `preloadDepth` | `number` | `2` | Graph edges walked for the preload at a rest node |
|
|
790
|
-
| `assets` | `baseUrl`, `io` | | `undefined` | The CDN seam and the fetch/decode/texture seam a test replaces |
|
|
791
|
-
| `anim` | `maxTracks` | `number` | `2000` | Dev guard: one warning each time the running track count rises past it. Durations live in the steps, never here |
|
|
792
|
-
| `i18n` | `locale` | `string` | `"en"` | The locale at start |
|
|
793
|
-
| `i18n` | `fallback` | `string` | `"en"` | The locale a missing key is read from before it is reported missing |
|
|
794
|
-
| `i18n` | `locales` | `Record<string, module \| loader>` | `{}` | Compiled modules outside features, per locale |
|
|
795
|
-
| `text` | `fonts` | `{ body, digits }` | `{ body: "ui.font-body", digits: "ui.font-digits" }` | The two boot fonts behind the built-in styles `body` and `digits` |
|
|
796
|
-
| `ui` | `tapTargetPt` | `number` | `44` | The smallest tap target `lint()` accepts |
|
|
797
|
-
| `ui` | `breakpoints` | `{ tall, wide }` | `{ tall: 2, wide: 1.5 }` | Aspect thresholds of the `when` style variants |
|
|
798
|
-
| `audio` | `buses` | `{ master, music, sfx }` | `{ master: 1, music: 0.6, sfx: 1 }` | Start gain of each bus, 0..1 |
|
|
799
|
-
| `audio` | `musicFadeMs` | `number` | `600` | Cross-fade of a music switch, in real milliseconds |
|
|
800
|
-
| `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` |
|
|
801
|
-
| `audio` | `context` | `() => AudioContext \| undefined` | `undefined` | The context factory, a test seam. Absent: `new AudioContext()` where the global exists |
|
|
802
|
-
| `effects` | `maxParticles` | `number` | `3000` | Live particles over every instance above which `effects:particle-budget` warns once per crossing |
|
|
803
|
-
| `effects` | `maxPasses` | `number` | `24` | Render passes per frame above which `effects:pass-budget` warns once per crossing |
|
|
804
|
-
| `effects` | `phone` | `boolean \| "auto"` | `"auto"` | Whether the device is a phone. `"auto"`: a coarse pointer and a short side of at most 820 CSS px, read once at start |
|
|
805
|
-
| `effects` | `blur` | `{ quality, phoneResolution }` | `{ quality: 2, phoneResolution: 0.5 }` | What a `Blur` with `quality: 0` and `resolution: 0` resolves to |
|
|
806
|
-
| `platform` | `provider` | `PlatformProvider \| undefined` | `undefined` | The provider the game builds, usually from `@moku-labs/system`. Absent: the plugin is inert and `back()` answers `"none"` |
|
|
807
|
-
| `platform` | `keepAwake` | `boolean` | `false` | Keep the screen on while the game runs; released while it is paused and on stop |
|
|
208
|
+
Each plugin has its own README, linked in the table above. The kernel is specified in the [Moku Core specification](https://github.com/moku-labs/core/tree/main/specification).
|
|
808
209
|
|
|
809
210
|
## Development
|
|
810
211
|
|
|
811
|
-
### Scripts
|
|
812
|
-
|
|
813
212
|
```sh
|
|
814
|
-
bun run build
|
|
815
|
-
bun run typecheck
|
|
816
|
-
bun run lint
|
|
817
|
-
bun run
|
|
818
|
-
bun run
|
|
819
|
-
bun run
|
|
820
|
-
bun run
|
|
821
|
-
bun run
|
|
822
|
-
bun run
|
|
823
|
-
bun run validate # publint and attw with the esm-only profile
|
|
824
|
-
bun run fixture:pack # pack the fixture game into tests/integration/merge-game/dist/assets
|
|
825
|
-
bun run fixture:visual # the fixture's visual tests, both legs: --url <dev page>, --update, --only <name>, --no-pixels, --webgl
|
|
826
|
-
bun run fixture:native # the fixture as a native app on @moku-labs/native: <target> [--simulator] [--page <html>]
|
|
827
|
-
bun run release:setup # moku-release setup
|
|
828
|
-
bun run release:doctor # moku-release doctor
|
|
829
|
-
bun run release # moku-release
|
|
213
|
+
bun run build # tsdown into dist/
|
|
214
|
+
bun run typecheck # tsc --noEmit
|
|
215
|
+
bun run lint # biome check . && eslint .
|
|
216
|
+
bun run test # vitest, unit and integration
|
|
217
|
+
bun run test:coverage # with the 90% threshold
|
|
218
|
+
bun run validate # publint and attw
|
|
219
|
+
bun run fixture:visual # visual tests of the fixture game
|
|
220
|
+
bun run fixture:native # the fixture as a native app
|
|
221
|
+
bun run release # moku-release
|
|
830
222
|
```
|
|
831
223
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
| Path | Holds |
|
|
835
|
-
|---|---|
|
|
836
|
-
| `tests/unit/` | Framework-level unit tests: root index, setup |
|
|
837
|
-
| `tests/integration/` | Framework-level scenarios across plugins |
|
|
838
|
-
| `tests/integration/merge-game/` | The fixture game, written on the public API only. Not published |
|
|
839
|
-
| `tests/visual/` | The visual tests of the fixture game and their baselines: `<test>/<checkpoint>/state.json`, `describe.json`, `screen.webp` |
|
|
840
|
-
| `src/plugins/<name>/__tests__/unit/` | Unit tests of one plugin |
|
|
841
|
-
| `src/plugins/<name>/__tests__/integration/` | Integration tests of one plugin |
|
|
842
|
-
| `src/plugins/flow/__tests__/types/` | Type-level tests of the graph typing |
|
|
843
|
-
|
|
844
|
-
Plugin tests never go into the root `tests/` folder. Coverage thresholds are 90% for lines, functions, branches and statements.
|
|
845
|
-
|
|
846
|
-
### Lint rules
|
|
847
|
-
|
|
848
|
-
The project rules live in [`eslint.config.ts`](./eslint.config.ts).
|
|
849
|
-
|
|
850
|
-
| Rule | Says | Applies to |
|
|
851
|
-
|---|---|---|
|
|
852
|
-
| L1 | A module imports a sibling module only as `import type` from its `types.ts`. The plugin `index.ts` injects sibling APIs | Modules of `model` and `flow` |
|
|
853
|
-
| L2 | No static import of `pixi.js` or `yoga-layout`. They are loaded lazily with `import()` | `src/**` |
|
|
854
|
-
| 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 |
|
|
855
|
-
| L4 | The rules of the fixture game import only their siblings | `tests/integration/merge-game/rules/` |
|
|
856
|
-
| L5 | No module-scope state: no top-level `let`, no top-level `Map`, `Set`, `WeakMap`, `WeakSet`. No allowlist | `src/**` |
|
|
857
|
-
| 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` |
|
|
858
|
-
| 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` |
|
|
859
|
-
| L8 | No signature echo: an `@example` whose whole body is one call with bare identifiers is an error | `src/**` |
|
|
860
|
-
| 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` |
|
|
861
|
-
| L13 | No import of `@moku-labs/system`, `@moku-labs/native` or `@tauri-apps/*`, type imports included. The game builds the `PlatformProvider` in its own layer | every file under `src/`, tests included |
|
|
224
|
+
Every script is in [docs/testing.md](./docs/testing.md#scripts).
|
|
862
225
|
|
|
863
226
|
## Requirements
|
|
864
227
|
|
|
865
|
-
- **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only
|
|
228
|
+
- **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only.
|
|
866
229
|
- **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
|
|
867
|
-
- **`pixi.js` `^8.0.0`** as a peer dependency.
|
|
868
|
-
- **
|
|
869
|
-
- **[`@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`.
|
|
870
|
-
|
|
871
|
-
## Docs
|
|
872
|
-
|
|
873
|
-
- [`time`](./src/plugins/time/README.md): frame loop, phases, `step`
|
|
874
|
-
- [`lifecycle`](./src/plugins/lifecycle/README.md): pause reasons
|
|
875
|
-
- [`model`](./src/plugins/model/README.md): store, rng, provider seam, migrations
|
|
876
|
-
- [`clock`](./src/plugins/clock/README.md): `now`, `scheduleAt`, `fakeClock`
|
|
877
|
-
- [`flow`](./src/plugins/flow/README.md): runner, gate, inbox, fx, features, the door machinery
|
|
878
|
-
- [`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
|
|
879
|
-
- [`anim`](./src/plugins/anim/README.md): tracks, timelines, `defineMotion`, the driver
|
|
880
|
-
- [`i18n`](./src/plugins/i18n/README.md): messages, parts, the compiler, supported ICU
|
|
881
|
-
- [`text`](./src/plugins/text/README.md): styles, tags, measurement, fonts
|
|
882
|
-
- [`ui`](./src/plugins/ui/README.md): the JSX runtime, components, styles, layout, popups
|
|
883
|
-
- [`audio`](./src/plugins/audio/README.md): buses, the unlock, pause, memory
|
|
884
|
-
- [`effects`](./src/plugins/effects/README.md): particles, filters, WGSL and GLSL twins, budgets
|
|
885
|
-
- [`platform`](./src/plugins/platform/README.md): the provider seam, the Back chain, haptics, keep-awake
|
|
886
|
-
- [`llms.txt`](./llms.txt): overview for an LLM that writes a game on this engine
|
|
887
|
-
- [Moku Core specification](https://github.com/moku-labs/core/tree/main/specification)
|
|
230
|
+
- **`pixi.js` `^8.0.0`** as a peer dependency. `playwright-core` for visual tests and `sharp` for the asset pack are optional peers. `yoga-layout` is loaded lazily by `ui`. The string compiler uses `@formatjs/icu-messageformat-parser` at build time only.
|
|
231
|
+
- **[`@moku-labs/core`](https://github.com/moku-labs/core)** is the kernel. **[`@moku-labs/common`](https://github.com/moku-labs/common)** brings `log` and `env`.
|
|
888
232
|
|
|
889
233
|
## License
|
|
890
234
|
|