@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/llms.txt
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
# @moku-labs/game
|
|
2
|
+
|
|
3
|
+
> 2D puzzle game engine on TypeScript, built as a Layer-2 framework on @moku-labs/core. The game is a deterministic graph of business logic. State commits only on edges. Position is data. Player input and world events are answers a node waits for. The screen is a projection of committed state. V1 is the logic plugins `time`, `lifecycle`, `model`, `clock`, `flow` and a headless testing entry; V2 adds the screen plugins `world`, `renderer`, `input`, `assets`, `scenes`, a Pixi v8 renderer loaded lazily, and the `@moku-labs/game/assets` entry that generates typed asset keys. V3 adds the interface plugins `anim`, `i18n`, `text`, `ui` (`screen` lists all nine) and the opt-in `audio`: choreographies as data, strings as data, text from MSDF fonts, screens in JSX laid out by Yoga, sound as an effect a node awaits. V4 adds two doors for the editor: `@moku-labs/game/inspect` reads a running game, `@moku-labs/game/control` drives a dev build. V5 adds the opt-in plugin `effects`: particles and WGSL/GLSL filters as components. V6 adds the opt-in plugin `platform`: the Back button, haptics and keep-awake through a provider the game passes. The package is published on npm as `@moku-labs/game`, and this file ships inside it.
|
|
4
|
+
|
|
5
|
+
Facts: Bun only. ESM only. `"sideEffects": false`. `pixi.js` `^8.0.0` is a peer dependency, no V1 code imports it. `playwright-core` (visual tests) and `sharp` (the asset pack) are optional peers. Node >= 24, Bun >= 1.3.14. TypeScript strict with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. The engine ships no genre rules: no merge, no match-3, no physics. A game brings its own pure rule functions.
|
|
6
|
+
|
|
7
|
+
## Entry points
|
|
8
|
+
|
|
9
|
+
- `@moku-labs/game`: everything a game imports.
|
|
10
|
+
- `@moku-labs/game/testing`: headless play, repro runs, fake clock, in-memory save provider.
|
|
11
|
+
- `@moku-labs/game/assets` (node and bun only): the asset key scanner, the production packer and the string compiler. The package bin `moku-game-assets` runs it.
|
|
12
|
+
- `@moku-labs/game/inspect`: the read door of the editor, safe in a production build. See "Doors for the editor".
|
|
13
|
+
- `@moku-labs/game/control`: the write door of the editor, dev builds only. See "Doors for the editor".
|
|
14
|
+
- `@moku-labs/game/fonts/*`: the shipped MSDF body font and its licence. See "Fonts".
|
|
15
|
+
- `@moku-labs/game/jsx-runtime` and `@moku-labs/game/jsx-dev-runtime`: what `"jsxImportSource": "@moku-labs/game"` resolves to. A game never imports them by hand.
|
|
16
|
+
|
|
17
|
+
Root exports:
|
|
18
|
+
- Functions: `createApp`, `createPlugin`, `defineGame`, `defineFeature`, `type`, `exit`, `to`, `slot`, `schedule`, `guide`, `hint`.
|
|
19
|
+
- Class: `SaveUnreadableError`.
|
|
20
|
+
- Plugin instances: `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin`, `platformPlugin`; the list `screen` = `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`. `effects`, `audio` and `platform` are opt-in: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]`, `platform` last.
|
|
21
|
+
- Screen helpers from the root: `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree`, `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite`, `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer`, `defineBundles`, `load`, `defineScene`.
|
|
22
|
+
- Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`; `tr`; `Text`, `label`, `defineTextStyles`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
|
|
23
|
+
- Effects helpers from the root (V5): `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha`.
|
|
24
|
+
- Platform from the root (V6): the types `PlatformProvider`, `BackResult`, `HapticKind` and the frozen list `HAPTIC_KINDS`.
|
|
25
|
+
- `defineGame<Types>()` returns, typed by the game's `player`, `session`, `assets`, `bundles`, `scenes`, `strings` and `textStyles`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, `defineAnimation`, `frames`, `sfx`, `play`, `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `music`, `Frames`, `defineEmitter`, `Emitter`, `Displacement`. `Types` is `{ player; session; assets: string; bundles?: string; scenes?: string; strings: Record<string, unknown>; textStyles?: string; emitters?: string }`.
|
|
26
|
+
- `@moku-labs/game/assets`: `scanAssets(options)`, `emitKeys`, `emitManifest`, `compileStrings(root, out, options?)`, `checkStrings`, `packAssets(options)`, `runCli(argv, compileStrings)`. Asset key = feature + "." + path inside `assets/`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles `features/*/strings/<locale>.json` into `generated/strings.ts` (the `Strings` type for `defineGame`) and one `generated/strings.<locale>.ts` per locale; `--check` fails when any output is out of date; `--pack <dir>` writes the production build. The package bin `moku-game-assets` is this CLI: a game's script is `"assets:keys": "moku-game-assets --root src --manifest public/assets/manifest.json --keys src/generated/assets.ts"`.
|
|
27
|
+
- Type namespaces: `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`, `Effects`, `Platform`. Use `import type { Flow } from "@moku-labs/game"` and then `Flow.RouteStep`, `Flow.NodeContext`, `Model.Json`, `Model.PlayerStateProvider`, `Clock.ClockSource`, `Time.Phase`, `Lifecycle.PauseReason`, `Anim.Target`, `I18n.Message`. `Text` is the component, so the text types are `TextTypes`.
|
|
28
|
+
- `defineNode` and `defineFlow` are NOT root exports. They come from `defineGame<Types>()`.
|
|
29
|
+
|
|
30
|
+
## Concepts
|
|
31
|
+
|
|
32
|
+
- Node: a small function with a plain `await` body. It gets one context object and returns `out.<outcome>(payload)`. A bare string is not accepted.
|
|
33
|
+
- Flow: an id, a `nodes` table, a `start` node and an `edges` table. One entry per outcome of every node. The compiler checks the table.
|
|
34
|
+
- Exactly one node is active. The runner opens one model transaction per node run and commits it on the edge. A node that throws changes nothing.
|
|
35
|
+
- Transit node: has `run`, no `rest`. Rest node: `rest: true`. The graph waits there. Without `run` it is a pure wait.
|
|
36
|
+
- A pure wait ends in two ways. A gate answer whose `intent` is an outcome name: the answer payload becomes the outcome payload. Or a world event whose `type` is listed in the node's `inbox`: the event payload becomes the outcome payload.
|
|
37
|
+
- Transition: rest node to rest node. It is a transaction. On an error the runner discards, rolls back to the last rest point, retries `retries` times, then enters `safeNode`. A failure inside `safeNode` rejects `run()`.
|
|
38
|
+
- Entering a rest node calls `model.store.markRest()`: the save provider gets the patches. Leaving a `barrier` node calls `markBarrier(txId)`: a durable write, and rollback cannot cross it.
|
|
39
|
+
- `checkpoint: true` on a rest node: the journal is compacted there. `safeNode` must be a checkpoint.
|
|
40
|
+
- `over: true`: entering the node waits while a pointer is down.
|
|
41
|
+
- Path: node names joined by `/` through sub-flows and slots. Examples: `"home"`, `"board/awaitIntent"`, `"afterBonus/thanks"`.
|
|
42
|
+
- Edge target: a node name, `exit(...)` or `to(...)`. A payload must fit the input of the target. A node without input accepts any payload.
|
|
43
|
+
- Sub-flow: a flow that declares `outcomes` is used as a node of another flow. `exit("name")` leaves it with that outcome.
|
|
44
|
+
- Slot: `slot("name")` is a node with the single outcome `done`. Features contribute sub-flows to it. They run in `order`. `when(snapshot)` can skip one; it always reads the committed state, also what an earlier contribution of the same slot wrote. A contribution may hold a slot of its own. The slot ends with its own `done` edge after the last contribution.
|
|
45
|
+
- Feature: `defineFeature(name, description)` makes an ordinary plugin that registers into `flow.features` in `onInit`. Pass it in `createApp({ plugins: [...] })`. `feature.logicOnly` is the same plugin with the V1 keys only, for headless tests.
|
|
46
|
+
- Modes: `"live"` and `"fast"`. In fast mode effects answer instantly, hints are dropped and only `runInFast` handlers run. All node logic runs for real.
|
|
47
|
+
|
|
48
|
+
## Authoring helpers, with signatures
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
function defineGame<Types extends Flow.GameTypes>(); // returns the helpers listed under "Root exports", typed by Types
|
|
52
|
+
// GameTypes = { player: Json; session: Json; assets: string; strings: Record<string, unknown>;
|
|
53
|
+
// bundles?: string; scenes?: string; textStyles?: string; emitters?: string }
|
|
54
|
+
|
|
55
|
+
function type<Payload = void>(): TypeTag<Payload>;
|
|
56
|
+
|
|
57
|
+
// From the kit. Input and Tags are inferred from the spec.
|
|
58
|
+
defineNode(spec: {
|
|
59
|
+
input?: TypeTag<Input>;
|
|
60
|
+
outcomes: Tags; // { done: type(), failed: type<{ reason: string }>() }
|
|
61
|
+
rest?: boolean; // run is required unless rest: true
|
|
62
|
+
over?: boolean;
|
|
63
|
+
checkpoint?: boolean;
|
|
64
|
+
barrier?: boolean;
|
|
65
|
+
inbox?: readonly (keyof Tags & string)[];
|
|
66
|
+
run?: (ctx: NodeContext) => Result | Promise<Result>;
|
|
67
|
+
}): NodeDefinition;
|
|
68
|
+
|
|
69
|
+
defineFlow(id: string, spec: {
|
|
70
|
+
nodes: Nodes; // node, sub-flow or slot per name
|
|
71
|
+
start: string; // a node whose input accepts the flow input
|
|
72
|
+
edges: Table; // edges[node][outcome] = target
|
|
73
|
+
input?: TypeTag<Input>;
|
|
74
|
+
outcomes?: FlowTags; // declare them to use the flow as a node
|
|
75
|
+
}): FlowDefinition;
|
|
76
|
+
|
|
77
|
+
function exit<const Name extends string>(outcome: Name): Exit<Name>;
|
|
78
|
+
function to<const TargetName extends string, Payload, Output>(
|
|
79
|
+
target: TargetName,
|
|
80
|
+
map: (payload: Payload) => Output // annotate the parameter type yourself
|
|
81
|
+
): Mapped<TargetName, Payload, Output>;
|
|
82
|
+
function slot(name: string): SlotNode;
|
|
83
|
+
|
|
84
|
+
function defineFeature(name: string, description: FeatureDescription): FeaturePlugin;
|
|
85
|
+
// FeatureDescription = {
|
|
86
|
+
// nodes?: readonly AnyNode[];
|
|
87
|
+
// flows?: readonly AnyFlow[];
|
|
88
|
+
// contribute?: Record<string, { flow: AnyFlow; order: number; when?: (snapshot: Snapshot) => boolean }>;
|
|
89
|
+
// projections?, systems?, components?, scenes?, assets?; // V2 keys, read by world, scenes and assets
|
|
90
|
+
// animations?: readonly AnimationDefinition[]; // V3, read by anim
|
|
91
|
+
// ui?: readonly ComponentDefinition[]; // V3, read by ui (defineComponent results)
|
|
92
|
+
// strings?: Record<locale, CompiledMessages | (() => Promise<unknown>)>; // V3, read by i18n
|
|
93
|
+
// textStyles?: ReturnType<typeof defineTextStyles>; // V3, read by text
|
|
94
|
+
// emitters?: readonly ReturnType<typeof defineEmitter>[]; // V5, read by effects
|
|
95
|
+
// filters?: readonly ReturnType<typeof defineFilter>[]; // V5, read by effects
|
|
96
|
+
// }
|
|
97
|
+
|
|
98
|
+
function schedule(moment: number | undefined): Descriptor; // awaited, asks the clock for one `elapsed`
|
|
99
|
+
function guide(options: GuideOptions): Descriptor; // awaited, narrows the gate to one answer
|
|
100
|
+
function hint(kind: string, payload?: Json): Hint; // for fx.emit, cosmetic
|
|
101
|
+
// GuideOptions = { allow: { intent: string; payload?: Json }; highlight?: readonly string[]; hand?: "tap" | "drag"; text?: string }
|
|
102
|
+
// Descriptor = { kind: string; payload?: Json; answers?: readonly string[]; cosmetic?: boolean }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Node context
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
type NodeContext = {
|
|
109
|
+
input: Input; // payload of the edge that led here
|
|
110
|
+
player: Player; // draft of the saved player tree, mutate it
|
|
111
|
+
session: Session; // draft of the session tree, never saved
|
|
112
|
+
rng: RngView; // rng.stream(id) gives a deterministic stream
|
|
113
|
+
fx: NodeFx; // await fx(descriptor), fx.emit(hint(...))
|
|
114
|
+
out: Out; // one method per declared outcome
|
|
115
|
+
signal: AbortSignal; // aborted on stop, restore and inbox delivery
|
|
116
|
+
now: number; // clock.now() read once at node entry
|
|
117
|
+
};
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`RngStream` methods, integers only: `int(maxExclusive)`, `range(min, maxInclusive)`, `pick(items)`, `weighted(table)` with `{ weight: number }` entries, `chance(numerator, denominator)`.
|
|
121
|
+
|
|
122
|
+
## Rules of determinism
|
|
123
|
+
|
|
124
|
+
- Never call `Date.now()`, `performance.now()` or `new Date()` in a node or in rule functions. Use `now` from the node context.
|
|
125
|
+
- Never call `Math.random()`. Use `rng.stream("some-id")`. Use one id per source, for example `"chest:42"`. Stream state is part of the save, so a draw commits and rolls back with its transaction.
|
|
126
|
+
- Never call `setTimeout` or `setInterval` in game logic. Store the due moment in `player`, then `await fx(schedule(moment))` and wait for `elapsed` at a rest node with `inbox: ["elapsed"]`.
|
|
127
|
+
- State is plain JSON only. No classes, no `Map`, no `Date`, no `undefined` values in trees.
|
|
128
|
+
- Change state only through `player` and `session` of the node context. Do not keep game state in module scope or closures.
|
|
129
|
+
- Keep rules as pure functions `(state, input, tables) => result`. The node calls the rule and writes the result into the draft.
|
|
130
|
+
- A node never calls `app.model`, `app.clock` or `app.time`. Everything outside logic goes through `fx`.
|
|
131
|
+
- A timer is a moment stored in player state. How long is left is computed from `now`.
|
|
132
|
+
|
|
133
|
+
## Minimal game
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// state.ts
|
|
137
|
+
import { defineGame } from "@moku-labs/game";
|
|
138
|
+
|
|
139
|
+
export type Player = { coins: number; lastRoll: number };
|
|
140
|
+
export type Session = { rolls: number };
|
|
141
|
+
|
|
142
|
+
export const { defineNode, defineFlow, defineFeature } = defineGame<{
|
|
143
|
+
player: Player;
|
|
144
|
+
session: Session;
|
|
145
|
+
assets: string;
|
|
146
|
+
strings: Record<string, unknown>;
|
|
147
|
+
}>();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// nodes.ts
|
|
152
|
+
import { type } from "@moku-labs/game";
|
|
153
|
+
import { defineNode } from "./state";
|
|
154
|
+
|
|
155
|
+
export const home = defineNode({
|
|
156
|
+
outcomes: { roll: type() },
|
|
157
|
+
rest: true,
|
|
158
|
+
checkpoint: true
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const roll = defineNode({
|
|
162
|
+
outcomes: { done: type() },
|
|
163
|
+
run: ({ player, session, rng, out }) => {
|
|
164
|
+
const face = rng.stream("dice").range(1, 6);
|
|
165
|
+
|
|
166
|
+
player.coins += face;
|
|
167
|
+
player.lastRoll = face;
|
|
168
|
+
session.rolls += 1;
|
|
169
|
+
return out.done();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// flow.ts
|
|
176
|
+
import { defineFlow } from "./state";
|
|
177
|
+
import { home, roll } from "./nodes";
|
|
178
|
+
|
|
179
|
+
export const mainFlow = defineFlow("main", {
|
|
180
|
+
nodes: { home, roll },
|
|
181
|
+
start: "home",
|
|
182
|
+
edges: {
|
|
183
|
+
home: { roll: "roll" },
|
|
184
|
+
roll: { done: "home" }
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// game.ts
|
|
191
|
+
import { createApp } from "@moku-labs/game";
|
|
192
|
+
import { mainFlow } from "./flow";
|
|
193
|
+
|
|
194
|
+
export const createGame = (seed: "from-save" | number = "from-save") =>
|
|
195
|
+
createApp({
|
|
196
|
+
pluginConfigs: {
|
|
197
|
+
model: { initialPlayer: { coins: 0, lastRoll: 0 }, initialSession: { rolls: 0 }, seed },
|
|
198
|
+
flow: { mainFlow, safeNode: "home" }
|
|
199
|
+
},
|
|
200
|
+
onStart: ctx => {
|
|
201
|
+
ctx.flow.run().catch((error: unknown) => {
|
|
202
|
+
ctx.log.error("game: the graph failed", { error });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The game calls `flow.run()` itself, once, from `onStart`. Do not await it there: it resolves only when the app stops. The screen sends answers with `app.flow.gate.answer({ intent: "roll" })`.
|
|
209
|
+
|
|
210
|
+
## More patterns
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { exit, hint, schedule, slot, to, type } from "@moku-labs/game";
|
|
214
|
+
import { defineFeature, defineFlow, defineNode } from "./state";
|
|
215
|
+
|
|
216
|
+
// Awaited effect with answers. The gate opens for these intents. The promise resolves with the answer.
|
|
217
|
+
export const confirmReset = defineNode({
|
|
218
|
+
outcomes: { yes: type(), no: type() },
|
|
219
|
+
over: true,
|
|
220
|
+
run: async ({ fx, out }) => {
|
|
221
|
+
const answer = (await fx({ kind: "popup", payload: { name: "reset" }, answers: ["yes", "no"] })) as {
|
|
222
|
+
intent: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return answer.intent === "yes" ? out.yes() : out.no();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Timer: arm the clock, emit a cosmetic hint, then wait for `elapsed` at a rest node.
|
|
230
|
+
export const arm = defineNode({
|
|
231
|
+
outcomes: { armed: type() },
|
|
232
|
+
run: async ({ now, fx, out }) => {
|
|
233
|
+
await fx(schedule(now + 60_000));
|
|
234
|
+
fx.emit(hint("sparkle", { target: "dice" }));
|
|
235
|
+
return out.armed();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export const waiting = defineNode({
|
|
240
|
+
outcomes: { elapsed: type<{ now: number }>(), skip: type() },
|
|
241
|
+
rest: true,
|
|
242
|
+
inbox: ["elapsed"]
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Sub-flow with a mapped edge and two exits.
|
|
246
|
+
const pick = defineNode({ outcomes: { picked: type<{ face: number }>(), back: type() }, rest: true });
|
|
247
|
+
const apply = defineNode({
|
|
248
|
+
input: type<{ value: number }>(),
|
|
249
|
+
outcomes: { done: type() },
|
|
250
|
+
run: ({ input, player, out }) => {
|
|
251
|
+
player.coins += input.value;
|
|
252
|
+
return out.done();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const bonusFlow = defineFlow("bonus", {
|
|
257
|
+
nodes: { pick, apply },
|
|
258
|
+
start: "pick",
|
|
259
|
+
outcomes: { finished: type(), left: type() },
|
|
260
|
+
edges: {
|
|
261
|
+
pick: { picked: to("apply", (payload: { face: number }) => ({ value: payload.face * 2 })), back: exit("left") },
|
|
262
|
+
apply: { done: exit("finished") }
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Main flow with the sub-flow as a node and a slot.
|
|
267
|
+
const lobby = defineNode({ outcomes: { bonus: type() }, rest: true, checkpoint: true });
|
|
268
|
+
|
|
269
|
+
export const main = defineFlow("main", {
|
|
270
|
+
nodes: { lobby, bonus: bonusFlow, afterBonus: slot("afterBonus") },
|
|
271
|
+
start: "lobby",
|
|
272
|
+
edges: {
|
|
273
|
+
lobby: { bonus: "bonus" },
|
|
274
|
+
bonus: { finished: "afterBonus", left: "lobby" },
|
|
275
|
+
afterBonus: { done: "lobby" }
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Feature: contributes a sub-flow to the slot. A feature name must differ from every flow id.
|
|
280
|
+
const thanks = defineNode({ outcomes: { ok: type() }, rest: true, over: true });
|
|
281
|
+
const thanksFlow = defineFlow("thanksPopup", {
|
|
282
|
+
nodes: { thanks },
|
|
283
|
+
start: "thanks",
|
|
284
|
+
outcomes: { done: type() },
|
|
285
|
+
edges: { thanks: { ok: exit("done") } }
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
export const thanksFeature = defineFeature("thanks", {
|
|
289
|
+
flows: [thanksFlow],
|
|
290
|
+
contribute: { afterBonus: { flow: thanksFlow, order: 10 } }
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
A tutorial step is `await fx(guide({ allow: { intent: "roll" }, hand: "tap", text: "Tap the dice" }))`. Only that answer passes the gate until the node exits.
|
|
295
|
+
|
|
296
|
+
## Graph rules checked at `run()`
|
|
297
|
+
|
|
298
|
+
- Every outcome of every node has an edge. Every edge names a node of the same flow or a declared flow outcome.
|
|
299
|
+
- The start node is in `nodes`. Every node is reachable from the start node.
|
|
300
|
+
- A cycle needs at least one rest node.
|
|
301
|
+
- Every `inbox` type is an outcome of its node. A rest node has outcomes or a `run` body.
|
|
302
|
+
- Every edge of a `barrier` node leads to a rest node of the same flow.
|
|
303
|
+
- Flow ids are unique. A feature name is not a flow id. It is also not a plugin name: `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `effects`, `audio`, `platform`, `log`, `env`.
|
|
304
|
+
- Two contributions of one slot need different `order` values.
|
|
305
|
+
- `safeNode` is the path of a checkpoint node.
|
|
306
|
+
- A flow above 15 nodes is a warning. Split it into sub-flows of 7 to 15 nodes.
|
|
307
|
+
|
|
308
|
+
## Plugins
|
|
309
|
+
|
|
310
|
+
Registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; then the list `screen` a game spreads in: `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`; then the opt-in `effects`, `audio` and `platform` when the game adds them, `platform` last. `log` and `env` from `@moku-labs/common` are on every context as `ctx.log` and `ctx.env`. Configure with `createApp({ pluginConfigs: { <name>: { ... } } })`. Global config: `createApp({ config: { orientation, referenceSide } })`, defaults `"portrait"` and `1080`.
|
|
311
|
+
|
|
312
|
+
### time (Standard, no dependencies, no events)
|
|
313
|
+
|
|
314
|
+
The single `requestAnimationFrame` loop. Six phases in this order: `input`, `animate`, `layout`, `sync`, `signals`, `render`.
|
|
315
|
+
- API: `onFrame(phase, callback): () => void`, `snapshot(): Readonly<Time>`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)`, `wake()`.
|
|
316
|
+
- `Time` is `{ delta, elapsed, scale, frame, idle }` in scaled milliseconds.
|
|
317
|
+
- Idle cap: after `idleAfterMs` unscaled milliseconds without a `wake()` the loop runs at `idleFps` and `snapshot().idle` is true. `wake()` lifts it at once; `input`, `anim`, `flow`, `assets`, `scenes`, `text` and `ui` call it. `step(deltaMs)` is never capped.
|
|
318
|
+
- Config: `maxFps: 30 | 60 | 120` default `60`, `maxDeltaMs: number` default `50`, `idleFps: 0 | 30` default `30` (`0` turns the idle cap off), `idleAfterMs: number` default `2000`.
|
|
319
|
+
- In plain Bun there is no frame source. `isRunning()` is false and tests drive frames with `step`.
|
|
320
|
+
|
|
321
|
+
### lifecycle (Standard, depends on time)
|
|
322
|
+
|
|
323
|
+
The stack of pause reasons. The game is paused while the stack is not empty.
|
|
324
|
+
- API: `push(reason)`, `pop(reason)`, `reasons(): readonly PauseReason[]`, `isPaused()`.
|
|
325
|
+
- Named reasons: `"background"`, `"devtools"`, `"system-dialog"`, `"device-lost"`. Any other string is allowed.
|
|
326
|
+
- Event `lifecycle:changed`: `{ reason, action: "push" | "pop", reasons, paused, resumed }`. `resumed` is true only on the change that emptied the stack.
|
|
327
|
+
- No config.
|
|
328
|
+
|
|
329
|
+
### model (Very Complex, no dependencies)
|
|
330
|
+
|
|
331
|
+
The `session` tree and the save document `{ player, rng }`. Modules `store` and `rng`.
|
|
332
|
+
- `store`: `load()`, `snapshot(): { player, session, rng }` frozen, `begin(): Transaction`, `markRest()`, `markBarrier(txId)`, `rollback()`, `restore({ player, session?, rng? })`, `flush()`.
|
|
333
|
+
- `rng`: `peek(id): number | undefined`.
|
|
334
|
+
- `load()` hands the whole document to `provider.commit()` at once when it rewrote the save: a new player and a migrated save are stored before the first node runs. A save at the current version produces no commit.
|
|
335
|
+
- A game reads `app.model.store.snapshot()`. The runner calls the rest. A node never calls the model.
|
|
336
|
+
- Event `model:committed`: `{ roots: readonly ("player" | "session" | "rng")[]; cause: "edge" | "rollback" | "restore" | "load" }`.
|
|
337
|
+
- Config: `playerProvider: PlayerStateProvider | undefined` default `undefined` which means in-memory, `initialPlayer: Json` default `{}`, `initialSession: Json` default `{}`, `seed: "from-save" | number` default `"from-save"`, `schemaVersion: number` default `1`, `migrations: readonly Migration[]` default `[]`.
|
|
338
|
+
- `PlayerStateProvider`: `load(): Promise<{ state: Json; version: number } | null>`, `commit(patches, version): void`, `commitDurable(patches, txId, version): Promise<void>`, `flush(): Promise<void>`.
|
|
339
|
+
- `Migration` is `{ from: number; up(state: Json): Json }`. `up` of `from: n` produces version `n + 1`. It works on the whole save document.
|
|
340
|
+
- `SaveUnreadableError` has `savedVersion` and `schemaVersion`. `flow.run()` rejects with it when the save is newer, a step is missing, a step throws or the shape is wrong.
|
|
341
|
+
|
|
342
|
+
### clock (Standard, no dependencies, no events)
|
|
343
|
+
|
|
344
|
+
Trusted time as an input. Monotonic `now()` and one `elapsed` signal at the next due moment.
|
|
345
|
+
- API: `now()`, `scheduleAt(moment | undefined)`, `onElapsed(listener): () => void`, `poke()`, `dueAt()`.
|
|
346
|
+
- Config: `source: ClockSource | undefined` default `undefined` which means the system clock. `ClockSource` is `{ now(), setTimer(callback, delayMs), clearTimer(handle) }`.
|
|
347
|
+
- A node never touches the clock. It awaits `fx(schedule(moment))`. `flow` posts `{ type: "elapsed", payload: { now } }` into its inbox when the moment arrives and when the game resumes.
|
|
348
|
+
|
|
349
|
+
### flow (Very Complex, depends on time, lifecycle, model, clock)
|
|
350
|
+
|
|
351
|
+
The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods sit on the plugin root.
|
|
352
|
+
- Runner: `run(): Promise<void>`, `onEnter(stage: "load" | "scene", callback): () => void`, `walk(route, options?: { from?: Bookmark }): Promise<FlowState>`, `bookmark(): Bookmark`, `restore(bookmark): Promise<void>`, `describe(): FlowGraph`, `state(): FlowState`, `history(): readonly JournalEntry[]`, `setMode("live" | "fast")`.
|
|
353
|
+
- `gate.answer({ intent, payload? }): boolean` is the one entry of player answers. `gate.pointer(active)`. `gate.state(): { open, allowed, narrowed }`.
|
|
354
|
+
- `inbox.post({ type, payload? })` queues a world event. One entry per type. It is delivered only to a rest node whose `inbox` lists the type.
|
|
355
|
+
- `fx.handle(kind, handler, options?: { runInFast?: boolean }): () => void` registers the single handler of one effect kind. `fx.dispatch(descriptor)` is fire-and-forget. An awaited effect without a handler resolves `undefined` at once.
|
|
356
|
+
- `features.register(name, description)`, `features.all()`, `features.contributions(slotName)`.
|
|
357
|
+
- `FlowState` is `{ running, path, stack, pending: { fx?, gate? }, mode }`.
|
|
358
|
+
- `Bookmark` is `{ path, input, player, session, rng, graph }`. Serialisable. `restore` needs a running graph and the path of a rest node. A checkpoint is always accepted. Any other rest node is accepted only when the graph did not change since the bookmark.
|
|
359
|
+
- Events: `flow:edge` `{ flow, node, outcome, payload, next, patches: { doc, session }, index, now }` after the commit of an edge. `flow:rest` `{ path, checkpoint }`. `flow:error` `{ path, error, rolledBackTo, retry }`.
|
|
360
|
+
- Config: `mainFlow` default `undefined` and required before `run()`, `safeNode: string | undefined` default `undefined` which means the main flow's `start`, `retries` default `1`, `settleTimeoutMs` default `2000`, `journalLimit` default `500`.
|
|
361
|
+
|
|
362
|
+
### Screen plugins (V2, built)
|
|
363
|
+
|
|
364
|
+
`world`, `renderer`, `input`, `assets`, `scenes`. Each has a README at `src/plugins/<name>/README.md`; the root README lists their APIs, events and config. Rendering: WebGPU preferred, with Pixi's WebGL fallback.
|
|
365
|
+
|
|
366
|
+
### Interface plugins (V3, built)
|
|
367
|
+
|
|
368
|
+
A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` sets `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"`; screens are `.tsx` files. There is no DOM and no React.
|
|
369
|
+
|
|
370
|
+
- Tags: `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel` (swallows taps), `image` (`texture`, `fit?: "contain" | "cover" | "fill"`), `icon` (`name`, `fit?`), `text` (`content: string | Message`, or `bind: { component, field }`; `style` is a text style key), `button` (`intent` + `payload`, or `local`, never both), `scroll` (`axis: "y"` only in V3), `input` (a text field; its text lives in the component's `local`, see the ui README). Common props: `key`, `style`, `state: { active?, disabled?, selected? }`, `motion`, `components` (extra component values for the element's entity, such as a filter: `components={[Glow({ strength: 2 })]}`); containers take `hosts?: string[]` (projections drawn inside the element). Style adds `nineSlice`, `tint`, `offsetX`, `offsetY`, `scale`, `origin`, `fit: "contain"`; state variants `is: { hover, pressed, disabled, active, selected, covered }`. `popup(Component, props, { over })` stacks a popup over a kept one.
|
|
371
|
+
- Style vocabulary (`defineStyle`, plain data): flow `direction`, `wrap`, `justify`, `align`, `alignSelf`, `gap`, `grow`, `shrink`; box `padding`, `margin`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `aspect`, `overflow`; position `position`, `left`, `top`, `right`, `bottom`, `reason`; visual `fill`, `stroke`, `strokeWidth`, `radius`, `alpha`; variants `is: { pressed?, disabled?, active?, selected? }` and `when: { portrait?, landscape?, tall?, wide? }`, each a partial style merged in when the flag holds. Lengths are reference units or `"safeArea.top" | "safeArea.right" | "safeArea.bottom" | "safeArea.left"`. No font size here: it comes from the text style.
|
|
372
|
+
- `defineComponent(name, { local?, outcomes?, view(props, local) })`. A component with `outcomes` is a popup: `await fx(popup(Component, props))` opens the gate for its outcomes and resolves with `{ intent, payload }` of the pressed button. A button with `local` writes into the nearest component instance; it never reaches the graph.
|
|
373
|
+
- `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `features/<feature>/strings/<locale>.json` as ICU MessageFormat 1 (`{n, plural, one {# order} other {# orders}}`, `select`, `selectordinal`, `number`, `date`, `time`; no skeletons, no `currency`). `bun run assets:keys` compiles them. `app.i18n`: `locale()`, `setLocale(locale)` (async, emits `i18n:locale-changed`), `format(message, locale?)` returns `Part[]`, `plain(message)` returns a string, `has(key)`, `locales()`. Config: `locale: "en"`, `fallback: "en"`, `locales: {}`.
|
|
374
|
+
- Text: `Text` component, `label({ text, style, at, anchor? })` for board text in a projection view, `defineTextStyles({ "hud.digits": { font, size, fill, bold?, italic?, stroke?, strokeWidth?, letterSpacing?, align?, wrap?, digits? } })` under the feature's `textStyles` key. Tags inside a string: `<b>`, `<i>`, `<color=#rrggbb>`, `<icon=key>`, `\<`, `\n`. Built-in styles `body` and `digits` from config `fonts: { body: "ui.font-body", digits: "ui.font-digits" }`. `app.text.measure(content, style)` gives the same numbers in a browser and in Bun.
|
|
375
|
+
- Animation is data: `defineAnimation(id, { slots: { name: type<Anim.Target>() }, build: (slots, { at }) => Step })`. Steps: `sequence(...)`, `parallel(...)`, `stagger(items, ms, item => Step)`, `tween(target, Component, to, { ms, ease?, delayMs?, additive? })`, `set(target, Component, patch)`, `wait(ms)`, `mark(name)`, `frames(target, { keys, fps, loop? })`, `sfx(key, { bus? })`, `haptic(kind)`, `use(animation, slots, tools?)`, `spawn` (a temporary entity, flying coins, that the timeline despawns when it ends; see the anim README). `Frames({ keys, fps, loop, playing })` is a component that walks a sprite through its textures for the entity's whole life. A target is `{ projection, key }` or an entity. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? } })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag. Events `anim:mark` `{ animation, mark }` and `anim:finished` `{ animation }`. Config `maxTracks: 2000`.
|
|
376
|
+
- Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`. Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
|
|
377
|
+
- `app.ui`: `tree()` the live screen as plain data, `find(key)` the entity of a keyed element, `fill(key, value)` types into the text field with that `key`, `lint()` tap targets under `tapTargetPt` (default `44`), text that overflows in some locale, absolute elements without `reason`. Config: `tapTargetPt: 44`, `breakpoints: { tall: 2, wide: 1.5 }`. `ui` emits nothing.
|
|
378
|
+
|
|
379
|
+
```tsx
|
|
380
|
+
// features/hud/view.tsx
|
|
381
|
+
import type { Anim } from "@moku-labs/game";
|
|
382
|
+
import { Transform, mark, sequence, sfx, tween, type } from "@moku-labs/game";
|
|
383
|
+
import { defineAnimation, defineComponent, defineFeature, defineTextStyles, projection, tr } from "../../state";
|
|
384
|
+
|
|
385
|
+
export const hud = projection({
|
|
386
|
+
name: "hud",
|
|
387
|
+
layer: "ui",
|
|
388
|
+
from: player => ({ coins: player.coins }),
|
|
389
|
+
view: hud => (
|
|
390
|
+
<row key="bar" style={{ gap: 16, padding: { top: "safeArea.top", left: 24, right: 24 }, width: "100%", height: 120 }}>
|
|
391
|
+
<text key="coins" style="hud.digits" content={tr("hud.coins", { n: hud.coins })} />
|
|
392
|
+
<button key="settings" intent="openSettings" style={{ width: 96, height: 96 }}><icon name="hud.gear" /></button>
|
|
393
|
+
</row>
|
|
394
|
+
)
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
export const RewardPopup = defineComponent("RewardPopup", {
|
|
398
|
+
outcomes: { claim: type<{ orderId: string }>() },
|
|
399
|
+
view: (props: { orderId: string; gold: number }) => (
|
|
400
|
+
<panel key="reward" style={{ nineSlice: "ui.panel", direction: "column", gap: 16, padding: 32, width: 600, height: 400, fit: "contain" }}>
|
|
401
|
+
<text key="gold" style="hud.digits" content={String(props.gold)} />
|
|
402
|
+
<button key="claim" intent="claim" payload={{ orderId: props.orderId }} style={{ width: 240, height: 88 }}>
|
|
403
|
+
<text content={tr("common.claim")} />
|
|
404
|
+
</button>
|
|
405
|
+
</panel>
|
|
406
|
+
)
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
export const popCoins = defineAnimation("hud.popCoins", {
|
|
410
|
+
slots: { coins: type<Anim.Target>() },
|
|
411
|
+
build: ({ coins }) => sequence(tween(coins, Transform, { scale: 1.2 }, { ms: 120 }), tween(coins, Transform, { scale: 1 }, { ms: 200 }), sfx("hud.coins"), mark("done"))
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
export const hudFeature = defineFeature("hud", {
|
|
415
|
+
projections: [hud],
|
|
416
|
+
ui: [RewardPopup],
|
|
417
|
+
animations: [popCoins],
|
|
418
|
+
textStyles: defineTextStyles({ "hud.digits": { font: "ui.font-digits", size: 40, fill: 0xffe082, digits: true } }),
|
|
419
|
+
strings: { en: () => import("../../generated/strings.en") }
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// features/orders/deliver.ts — the popup and the choreography are effects the node awaits
|
|
423
|
+
import { play, type } from "@moku-labs/game";
|
|
424
|
+
import { defineNode, popup } from "../../state";
|
|
425
|
+
import { popCoins, RewardPopup } from "../hud/view";
|
|
426
|
+
|
|
427
|
+
export const deliver = defineNode({
|
|
428
|
+
outcomes: { claimed: type<{ orderId: string }>() },
|
|
429
|
+
run: async ({ player, fx, out }) => {
|
|
430
|
+
const answer = (await fx(popup(RewardPopup, { orderId: "o1", gold: 5 }))) as { intent: "claim"; payload: { orderId: string } };
|
|
431
|
+
|
|
432
|
+
player.coins += 5;
|
|
433
|
+
await fx(play(popCoins, { coins: { projection: "hud", key: "coins" } }));
|
|
434
|
+
return out.claimed({ orderId: answer.payload.orderId });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### effects (V5, Complex, opt-in)
|
|
440
|
+
|
|
441
|
+
Cosmetic rendering extras as data, drawn by `effectsPlugin`: `plugins: [...screen, effectsPlugin]`. Nothing here enters `model`, the journal or a save. Headless it draws nothing. The full reference is `src/plugins/effects/README.md`.
|
|
442
|
+
- Particles: `defineEmitter(id, config)` returns frozen data a feature lists under `emitters`. Required: `textures` (1 to 16 asset keys of one atlas page) and exactly one of `burst` (an integer, emitted once) and `rate` (particles per second while `active`). Optional with defaults: `lifeMs`, `speed`, `angle`, `gravity`, `drag`, `spin`, `shape` (`point`, `circle`, `rect`, `ring`), `scale`, `alpha`, `tint` (each `{ from, to }`), `space` (`"world"` or `"local"`), `prewarmMs`, `maxParticles` (`256`), `blend` (`"normal"` or `"add"`). A mistake throws at definition.
|
|
443
|
+
- The `Emitter({ effect, active })` component on an entity runs an emitter: `generator: item => [Sprite({ texture: "board.generator" }), Emitter({ effect: "fx.steam" })]`. `effect: ""` draws nothing. Particles step in the `animate` phase, so they freeze while paused and a fast walk emits nothing. Their random source is seeded, never `Math.random` and never the model's rng.
|
|
444
|
+
- Filters: `defineFilter(id, { wgsl, glsl, uniforms?, passes?, padding? })` returns a component type a feature lists under `filters`. Both bodies are required: `wgsl` is the WGSL `fn mainFragment` and reads a uniform as `fu.amount`; `glsl` is GLSL ES 3.0 `void main()` that sets `finalColor` and reads a uniform as `amount`. A uniform is a number (tweenable), `{ color: 0xffd700 }` or an array of 2 to 4 numbers. Every filter component also has `enabled: true` and `order: 0`. A filter covers the entity's subtree; on a ui element it goes in the `components` prop.
|
|
445
|
+
- Built-in filters: `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement` (`map` is an asset key), `Alpha`. A motion hook tweens them like any component: `view.tween(Glow, { strength: 0 }, { ms: 200 })`.
|
|
446
|
+
- API: `app.effects.stats()` returns `{ particles, emitters, filters, renderPasses }`; headless every number is 0.
|
|
447
|
+
- Config: `maxParticles: 3000` and `maxPasses: 24`, each warns once per crossing; `phone: "auto"`; `blur: { quality: 2, phoneResolution: 0.5 }`.
|
|
448
|
+
- With `emitters: "fx.starsBurst" | "fx.steam"` in the `defineGame` types, the `defineEmitter` id and `Emitter.effect` are checked by the compiler; `textures` and `Displacement.map` are checked against the asset keys. No events. Depends on `flow`, `world`, `renderer`, `assets`, `anim`.
|
|
449
|
+
|
|
450
|
+
### platform (V6, Standard, opt-in)
|
|
451
|
+
|
|
452
|
+
The phone as a provider, last in the array: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]` with `pluginConfigs: { platform: { provider, keepAwake: true } }`. The engine never imports a native package: the game builds the provider in its own layer, usually from `@moku-labs/system`. The full reference is `src/plugins/platform/README.md`.
|
|
453
|
+
- `PlatformProvider` has six members: `onPause(fn)`, `onResume(fn)`, `onBack(fn)` (each returns its remover), `haptic(kind)`, `keepAwake(on)`, `exit()`. Pause and resume push and pop the `"background"` reason of `lifecycle`. A provider method that throws is logged, never thrown into the frame.
|
|
454
|
+
- Back chain, `app.platform.back(): BackResult`. First an Escape press: a popup with an `escape` button closes, `"popup"`. Then `flow.gate.answer({ intent: "back" })` when the resting node lists `back`, `"intent"`. Then `provider.exit()`, `"exit"`. Without a provider `"none"`.
|
|
455
|
+
- Haptics: `await fx(haptic("success"))` in a node, or `haptic("light")` as a timeline step. The kinds are `HAPTIC_KINDS`: `light`, `medium`, `heavy`, `selection`, `success`, `warning`, `error`. A fast walk is silent.
|
|
456
|
+
- Config: `provider: undefined`, which leaves the plugin inert: a `haptic` effect resolves at once, so the same node runs on the web and headless. `keepAwake: false`.
|
|
457
|
+
- No events. Depends on `lifecycle`, `flow`, `input`.
|
|
458
|
+
|
|
459
|
+
## Game plugins and events
|
|
460
|
+
|
|
461
|
+
A game plugin is `createPlugin(name, { depends: [flowPlugin], hooks: ctx => ({ "flow:edge": payload => { ... } }) })`. The root README has the full example in its Events section.
|
|
462
|
+
|
|
463
|
+
A plugin that owns a resource stores it in its state and frees it in `onStop: ({ state }) => …` (core 1.6: `onStop` receives `{ global, config, state }`). A throwing `onStop` makes `app.stop()` reject. A hook that throws never stops the game: the engine writes the error entry `"game: a hook failed"` to the log (`app.log.trace()`), through the framework `onError(error, core)` of core 1.7. A game can pass its own `onError: (error, ctx) => …` to `createApp`; the kernel calls both.
|
|
464
|
+
|
|
465
|
+
## Doors for the editor
|
|
466
|
+
|
|
467
|
+
Two subpaths, neither on the root (V4). An editor, MCP tools and e2e scripts reach a running game through them; `docs/doors.md` in the engine repository has the full catalogue.
|
|
468
|
+
|
|
469
|
+
- `@moku-labs/game/inspect`: `read(app, source, input?)` reads once; `watch(app, source, input, fn)` reads on the `signals` phase when the source changes and returns the stop function; `defineSource`; the frozen catalogue `sources`; the types `Source`, `InputSchema`, `InputOf`. Every source only reads, so the door is safe in a production build. Keys of `sources`: `graph`, `position`, `history`, `tainted`, `cheats`, `model`, `entities`, `projections`, `ui`, `rect`, `render`, `effects`, `sounds`, `assets`, `log`; their ids are `game.<key>`.
|
|
470
|
+
- `@moku-labs/game/control`: `run(app, command, input?)` resolves `{ value, state }`, `state` being `{ path, frame, tainted }`; `defineCommand`; `controlRefused`; the frozen catalogue `commands`; the types `Command`, `Ran`. Keys of `commands`: `answer`, `tap`, `drag`, `key`, `fill`, `walk`, `bookmark`, `restore`, `step`, `pause`, `resume`, `capture`, `debug`, `reducedMotion`.
|
|
471
|
+
- Commands run in dev builds only. Only `__MOKU_GAME_DEV__ === true` turns the dev build on: a bundler `define` (`true` in dev, `false` in production), `globalThis.__MOKU_GAME_DEV__ = true` in the dev entry, or `vi.stubGlobal("__MOKU_GAME_DEV__", true)` in a test. Otherwise `run` throws `[game] Control commands run in dev builds only.`
|
|
472
|
+
- A command's effect is `read`, `route`, `cosmetic`, `cheat` or `raw`. A `cheat` or `raw` command taints the session and is journaled; read it with `sources.tainted` and `sources.cheats`. Of the base commands only `restore` is `raw`.
|
|
473
|
+
- A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run })`. The id is camelCase words joined by dots, at least two: `dice.rolls`. An input schema maps field names to `"string"`, `"number"`, `"boolean"` or `"json"`; a trailing `?` makes one optional. A command body starts with `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`. Keep them in `.dev` modules that only the dev entry and the tests import.
|
|
474
|
+
|
|
475
|
+
## Fonts
|
|
476
|
+
|
|
477
|
+
The package ships one MSDF body font: `fonts/font-body.fnt` and `fonts/font-body.png` (Pangolin Regular) with `fonts/LICENSE.txt` (SIL Open Font License 1.1). Copy it into the game; a path in `node_modules` is not an asset key, the scanner reads keys only from `features/<feature>/assets/`. The licence goes next to `assets/`, not inside it.
|
|
478
|
+
|
|
479
|
+
```bash
|
|
480
|
+
mkdir -p src/features/ui/assets
|
|
481
|
+
cp node_modules/@moku-labs/game/fonts/font-body.* src/features/ui/assets/
|
|
482
|
+
cp node_modules/@moku-labs/game/fonts/LICENSE.txt src/features/ui/LICENSE-fonts.txt
|
|
483
|
+
bun run assets:keys
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The scanner keys it `ui.font-body`, which is the `text` config default `fonts.body`, so the built-in style `body` works with no config. No `digits` font ships: a game that uses the style `digits` brings `ui.font-digits` itself.
|
|
487
|
+
|
|
488
|
+
## Testing entry
|
|
489
|
+
|
|
490
|
+
Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`, and the visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` (see `docs/testing.md` in the engine repository).
|
|
491
|
+
|
|
492
|
+
- `createHeadless(app): Promise<HeadlessGame>`. Pass an app that is not started. It sets mode `"fast"`, awaits `app.start()`, starts `flow.run()` unless the app's `onStart` already did, and resolves when the loop rests for the first time: `game.state().path` already names the first rest node. A fatal error of `run()` rejects the call.
|
|
493
|
+
- `HeadlessGame`: `walk(route): Promise<FlowState>`, `answer(answer): boolean`, `state()`, `history()`, `stop()`. `walk` and `stop` throw a fatal error of the loop again when the headless runner owns `run()`.
|
|
494
|
+
- `Flow.RouteStep` is `{ at: string; intent: string; payload?: Json }` or `{ at: string; result: { outcome: string; payload?: Json } }`. `at` is the path of a rest node. The `result` form substitutes the result of a sub-flow at that path.
|
|
495
|
+
- `runRepro(app, { player, session?, rng?, checkpoint?, route }): Promise<{ path: string[]; player; session }>`. The app keeps running. Stop it with `app.stop()`.
|
|
496
|
+
- `stepFrames(app, count, deltaMs)` calls `app.time.step(deltaMs)` `count` times.
|
|
497
|
+
- `fakeClock(start = 0)`: a `ClockSource` with `advance(ms)` and `set(moment)`. `advance` fires due timers synchronously.
|
|
498
|
+
- `memory(fixture?)`: in-memory `PlayerStateProvider` with `calls`, the list of every call. It applies the patches it is committed to the document it holds, so a second app over the same instance reads what the first one wrote. `saveOf(player, seed = 1)` builds a `SaveDoc`.
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import type { Flow } from "@moku-labs/game";
|
|
502
|
+
import { createHeadless } from "@moku-labs/game/testing";
|
|
503
|
+
import { expect, it } from "vitest";
|
|
504
|
+
import { createGame } from "./game";
|
|
505
|
+
|
|
506
|
+
const rollOnce: Flow.RouteStep = { at: "home", intent: "roll" };
|
|
507
|
+
|
|
508
|
+
it("plays two rolls without a screen", async () => {
|
|
509
|
+
const app = createGame(42);
|
|
510
|
+
const game = await createHeadless(app);
|
|
511
|
+
|
|
512
|
+
const state = await game.walk([rollOnce, rollOnce]);
|
|
513
|
+
|
|
514
|
+
expect(state.path).toBe("home");
|
|
515
|
+
expect(app.model.store.snapshot().session).toEqual({ rolls: 2 });
|
|
516
|
+
|
|
517
|
+
await game.stop();
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
For timers, pass `clock: { source: fakeClock(1_000_000) }` in `pluginConfigs`, call `clock.advance(ms)`, then yield the microtask queue before reading the state. For a fixed game, pass `model: { seed: 42 }`. In tests, compose features as `plugins: [feature.logicOnly]`.
|
|
522
|
+
|
|
523
|
+
## File layout of a game
|
|
524
|
+
|
|
525
|
+
```
|
|
526
|
+
src/
|
|
527
|
+
state.ts Player and Session types, starting state, the defineGame call
|
|
528
|
+
tables.ts balance data as plain objects
|
|
529
|
+
rules/ pure functions, no engine import, no clock, no random
|
|
530
|
+
nodes/ one file per node: boot.ts, home.ts, await-intent.ts
|
|
531
|
+
flows/ main.ts, one file per sub-flow, one file per feature
|
|
532
|
+
game.ts the single createApp call
|
|
533
|
+
tests/ headless scenarios with createHeadless and walk
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
This is the layout of the worked example in the engine repository: `tests/integration/merge-game/` with `kit.ts`, `state.ts`, `tables.ts`, `rules/`, `nodes/`, `flows/`, `game.ts`. It is an internal test fixture written on the public API only. It is not in the npm package. Its scenario is `tests/integration/template-merge.test.ts`. A game with a screen adds `features/<name>/view.tsx` (projections and components), `features/<name>/animations.ts`, `features/<name>/strings/<locale>.json` and the `generated/` folder `bun run assets:keys` writes. Each plugin has a README: `src/plugins/<name>/README.md` for `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `audio`, `effects`, `platform`. Moku Core specification: https://github.com/moku-labs/core/tree/main/specification
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moku-labs/game",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -34,11 +34,18 @@
|
|
|
34
34
|
"./jsx-dev-runtime": {
|
|
35
35
|
"types": "./dist/jsx-dev-runtime.d.mts",
|
|
36
36
|
"default": "./dist/jsx-dev-runtime.mjs"
|
|
37
|
-
}
|
|
37
|
+
},
|
|
38
|
+
"./fonts/*": "./fonts/*"
|
|
39
|
+
},
|
|
40
|
+
"bin": {
|
|
41
|
+
"moku-game-assets": "./bin/moku-game-assets.mjs"
|
|
38
42
|
},
|
|
39
43
|
"license": "MIT",
|
|
40
44
|
"files": [
|
|
41
45
|
"dist",
|
|
46
|
+
"bin",
|
|
47
|
+
"fonts",
|
|
48
|
+
"llms.txt",
|
|
42
49
|
"LICENSE",
|
|
43
50
|
"README.md"
|
|
44
51
|
],
|