@energy8platform/game-engine 0.34.2 → 0.35.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.
Files changed (45) hide show
  1. package/dist/audio.cjs.js +114 -59
  2. package/dist/audio.cjs.js.map +1 -1
  3. package/dist/audio.d.ts +25 -0
  4. package/dist/audio.esm.js +114 -59
  5. package/dist/audio.esm.js.map +1 -1
  6. package/dist/core.cjs.js +222 -66
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.d.ts +25 -0
  9. package/dist/core.esm.js +223 -67
  10. package/dist/core.esm.js.map +1 -1
  11. package/dist/flow.cjs.js +246 -0
  12. package/dist/flow.cjs.js.map +1 -1
  13. package/dist/flow.d.ts +192 -33
  14. package/dist/flow.esm.js +238 -1
  15. package/dist/flow.esm.js.map +1 -1
  16. package/dist/host.cjs.js +343 -82
  17. package/dist/host.cjs.js.map +1 -1
  18. package/dist/host.d.ts +82 -2
  19. package/dist/host.esm.js +344 -83
  20. package/dist/host.esm.js.map +1 -1
  21. package/dist/index.cjs.js +222 -66
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +72 -0
  24. package/dist/index.esm.js +223 -67
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/scene-devtools.cjs.js +529 -115
  27. package/dist/scene-devtools.cjs.js.map +1 -1
  28. package/dist/scene-devtools.d.ts +187 -34
  29. package/dist/scene-devtools.esm.js +529 -115
  30. package/dist/scene-devtools.esm.js.map +1 -1
  31. package/dist/scene.cjs.js +704 -46
  32. package/dist/scene.cjs.js.map +1 -1
  33. package/dist/scene.d.ts +228 -41
  34. package/dist/scene.esm.js +698 -47
  35. package/dist/scene.esm.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/audio/AudioManager.ts +111 -53
  38. package/src/core/GameApplication.ts +47 -5
  39. package/src/host/buildConfig.ts +17 -4
  40. package/src/host/createSlotGame.ts +114 -12
  41. package/src/host/index.ts +3 -0
  42. package/src/host/types.ts +58 -0
  43. package/src/loading/LoadingScene.ts +76 -2
  44. package/src/loading/index.ts +6 -0
  45. package/src/types.ts +2 -0
package/dist/scene.cjs.js CHANGED
@@ -2,6 +2,248 @@
2
2
 
3
3
  var pixi_js = require('pixi.js');
4
4
 
5
+ // Scene composition: one scene doc extending another, and the diff between two resolved
6
+ // docs expressed as patches.
7
+ //
8
+ // Why this exists: each game mode gets its OWN scene (base, free spins, a bonus screen), so
9
+ // you open it in the editor and lay it out directly instead of guessing at overrides. But a
10
+ // survey of nine shipped games found ~86% of modes are "the same board, re-skinned" — the
11
+ // delta is usually a background texture, a music track and one or two HUD widgets. Without
12
+ // inheritance every free-spins scene would be a copy of the base scene, and the two would
13
+ // drift apart the first time anyone moved the reels.
14
+ //
15
+ // So: `free-spins.scene.json` declares `extends: "base.scene.json"` and carries only what
16
+ // differs. And because no real game rebuilds its board when a mode starts — the reel system
17
+ // is constructed once and lives for the whole scene — switching modes applies the DIFF to
18
+ // the live scene rather than tearing it down.
19
+ /** A node in an extending doc may delete an inherited node instead of overriding it. */
20
+ const REMOVE_MARKER = '$remove';
21
+ const clone = (v) => JSON.parse(JSON.stringify(v));
22
+ /**
23
+ * Merge a child node over an inherited one. Only the fields the child states are replaced,
24
+ * so a free-spins scene that changes a background's texture keeps its layout, filters and
25
+ * everything else. `children` merge by id (see mergeChildren).
26
+ */
27
+ function mergeNode(base, over) {
28
+ const out = { ...base };
29
+ if (over.type !== undefined && over.type !== base.type)
30
+ out.type = over.type;
31
+ if (over.name !== undefined)
32
+ out.name = over.name;
33
+ // Props merge key-wise, so `{ src: 'bg-fs' }` does not wipe a tint set in the base.
34
+ if (over.props)
35
+ out.props = { ...(base.props ?? {}), ...over.props };
36
+ if (over.layout !== undefined)
37
+ out.layout = over.layout;
38
+ if (over.responsive)
39
+ out.responsive = { ...(base.responsive ?? {}), ...over.responsive };
40
+ if (over.states)
41
+ out.states = { ...(base.states ?? {}), ...over.states };
42
+ if (over.mask !== undefined)
43
+ out.mask = over.mask;
44
+ if (over.filters !== undefined)
45
+ out.filters = over.filters;
46
+ if (over.visibleWhen !== undefined)
47
+ out.visibleWhen = over.visibleWhen;
48
+ if (over.anchorName !== undefined)
49
+ out.anchorName = over.anchorName;
50
+ out.children = mergeChildren(base.children, over.children);
51
+ if (out.children === undefined)
52
+ delete out.children;
53
+ return out;
54
+ }
55
+ /**
56
+ * Merge child lists by id. An id present in both is merged; an id only in the child is
57
+ * appended (so a mode can add a spins counter); `{id, type: '$remove'}` deletes an
58
+ * inherited node. Base order is preserved — z-order is structural, not incidental.
59
+ */
60
+ function mergeChildren(base, over) {
61
+ if (!over)
62
+ return base ? clone(base) : undefined;
63
+ const byId = new Map();
64
+ for (const node of over)
65
+ byId.set(node.id, node);
66
+ const out = [];
67
+ for (const node of base ?? []) {
68
+ const patch = byId.get(node.id);
69
+ byId.delete(node.id);
70
+ if (patch?.type === REMOVE_MARKER)
71
+ continue;
72
+ out.push(patch ? mergeNode(clone(node), patch) : clone(node));
73
+ }
74
+ // Anything the child introduced that the base did not have, in the order it declared it.
75
+ for (const node of over) {
76
+ if (byId.has(node.id) && node.type !== REMOVE_MARKER)
77
+ out.push(clone(node));
78
+ }
79
+ return out.length ? out : undefined;
80
+ }
81
+ /**
82
+ * Resolve a doc's `extends` chain into a single self-contained doc. Docs without `extends`
83
+ * are returned as-is (cloned), so this is safe to call on everything.
84
+ */
85
+ function resolveExtends(doc, opts) {
86
+ const maxDepth = opts.maxDepth ?? 8;
87
+ const chain = [];
88
+ const seen = new Set();
89
+ let cursor = doc;
90
+ while (cursor) {
91
+ chain.unshift(cursor);
92
+ const ref = cursor.extends;
93
+ if (!ref)
94
+ break;
95
+ if (seen.has(ref))
96
+ throw new Error(`scene "${doc.id}": extends cycle at "${ref}"`);
97
+ seen.add(ref);
98
+ if (chain.length > maxDepth)
99
+ throw new Error(`scene "${doc.id}": extends chain deeper than ${maxDepth}`);
100
+ const parent = opts.load(ref);
101
+ if (!parent)
102
+ throw new Error(`scene "${doc.id}": cannot resolve extends "${ref}"`);
103
+ cursor = parent;
104
+ }
105
+ // Fold base → … → child.
106
+ let out = clone(chain[0]);
107
+ for (let i = 1; i < chain.length; i++) {
108
+ const over = chain[i];
109
+ out = {
110
+ ...out,
111
+ id: over.id,
112
+ design: over.design ?? out.design,
113
+ root: mergeNode(out.root, over.root),
114
+ };
115
+ }
116
+ delete out.extends;
117
+ return out;
118
+ }
119
+ function index(doc) {
120
+ const map = new Map();
121
+ const walk = (node, parentId, i) => {
122
+ map.set(node.id, { node, parentId, index: i });
123
+ (node.children ?? []).forEach((c, ci) => walk(c, node.id, ci));
124
+ };
125
+ walk(doc.root, null, 0);
126
+ return map;
127
+ }
128
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
129
+ /**
130
+ * The patches that turn `from` into `to`. Used to switch game modes without rebuilding: a
131
+ * node both docs share and agree on is never touched, so the reel system, its symbols and
132
+ * any running animation survive the transition.
133
+ *
134
+ * Order matters — removals first (ids can be reused), then additions parent-before-child,
135
+ * then property updates.
136
+ */
137
+ function diffDocs(from, to) {
138
+ const a = index(from);
139
+ const b = index(to);
140
+ const patches = [];
141
+ // Removed: only the topmost node of a removed subtree — removing it takes the rest.
142
+ for (const [id, loc] of a) {
143
+ if (b.has(id))
144
+ continue;
145
+ const parentGone = loc.parentId !== null && !b.has(loc.parentId);
146
+ if (!parentGone)
147
+ patches.push({ op: 'remove-node', id });
148
+ }
149
+ // Added, parents before children so the parent exists when the child lands.
150
+ const addedRoots = [];
151
+ for (const [id, loc] of b) {
152
+ if (a.has(id))
153
+ continue;
154
+ if (loc.parentId !== null && !a.has(loc.parentId) && !b.get(loc.parentId))
155
+ continue;
156
+ // Skip nodes whose parent is itself new — they ride along inside the parent's subtree.
157
+ if (loc.parentId !== null && !a.has(loc.parentId))
158
+ continue;
159
+ addedRoots.push({ id, loc });
160
+ }
161
+ for (const { loc } of addedRoots) {
162
+ patches.push({ op: 'add-node', parent: loc.parentId, node: clone(loc.node), index: loc.index });
163
+ }
164
+ // Changed, for nodes present in both.
165
+ for (const [id, next] of b) {
166
+ const prev = a.get(id);
167
+ if (!prev)
168
+ continue;
169
+ if (!same(prev.node.props, next.node.props)) {
170
+ // Keys the new doc dropped must be cleared, or they would linger from the old mode.
171
+ const props = {};
172
+ for (const key of Object.keys(prev.node.props ?? {}))
173
+ props[key] = undefined;
174
+ Object.assign(props, next.node.props ?? {});
175
+ patches.push({ op: 'set-props', id, props });
176
+ }
177
+ if (!same(prev.node.layout, next.node.layout)) {
178
+ patches.push({ op: 'set-layout', id, layout: (next.node.layout ?? null) });
179
+ }
180
+ if (!same(prev.node.name, next.node.name)) {
181
+ patches.push({ op: 'set-name', id, name: next.node.name });
182
+ }
183
+ }
184
+ return patches;
185
+ }
186
+
187
+ // Field schemas — the authoring contract for a node type's props.
188
+ //
189
+ // Before this existed the inspector inferred a control from the CURRENT value, which meant
190
+ // a prop that wasn't set yet had no row and no way to be added: a freshly added sprite had
191
+ // `props:{}` and therefore no way to choose a texture at all. It also had no notion of an
192
+ // enum, an asset or a node reference, and it round-tripped booleans through text.
193
+ //
194
+ // A schema is declared once by the contribution that owns the kind, and is used three ways:
195
+ // the inspector renders from it, validation can check against it, and the agent reads the
196
+ // same text as documentation (`doc`) — the plugin's docs and the agent's prompt stay one
197
+ // thing, per docs/slot-ide.md §6.2.
198
+ /** Shared field definitions, so every kind describes `alpha` the same way. */
199
+ const ALPHA_FIELD = {
200
+ kind: 'number',
201
+ doc: 'Opacity, 0 = invisible, 1 = opaque.',
202
+ default: 1,
203
+ min: 0,
204
+ max: 1,
205
+ step: 0.05,
206
+ order: 90,
207
+ };
208
+ /** Fractions of a texture (0..1), used for both `region` (crop) and `inner` (frame hollow). */
209
+ const fracBoxFields = (doc) => ({
210
+ kind: 'object',
211
+ doc,
212
+ order: 60,
213
+ fields: {
214
+ left: { kind: 'number', default: 0, min: 0, max: 1, step: 0.01 },
215
+ top: { kind: 'number', default: 0, min: 0, max: 1, step: 0.01 },
216
+ width: { kind: 'number', default: 1, min: 0, max: 1, step: 0.01 },
217
+ height: { kind: 'number', default: 1, min: 0, max: 1, step: 0.01 },
218
+ },
219
+ });
220
+ function schemaFieldRows(schema, props) {
221
+ const rows = [];
222
+ if (!schema)
223
+ return rows;
224
+ const visit = (fields, prefix, container) => {
225
+ const entries = Object.entries(fields).sort((a, b) => (a[1].order ?? 1000) - (b[1].order ?? 1000) || a[0].localeCompare(b[0]));
226
+ for (const [key, field] of entries) {
227
+ const path = prefix ? `${prefix}.${key}` : key;
228
+ const parent = container;
229
+ const value = parent && typeof parent === 'object' ? parent[key] : undefined;
230
+ if (field.kind === 'object' && field.fields) {
231
+ visit(field.fields, path, value);
232
+ }
233
+ else {
234
+ rows.push({ path, label: field.label ?? path, schema: field, value, unset: value === undefined });
235
+ }
236
+ }
237
+ };
238
+ visit(schema, '', props);
239
+ return rows;
240
+ }
241
+ /** Props present on the node that the schema doesn't describe (plugin extras, hand-written). */
242
+ function extraPropKeys(schema, props) {
243
+ const known = new Set(Object.keys(schema ?? {}));
244
+ return Object.keys(props).filter((k) => !known.has(k));
245
+ }
246
+
5
247
  // Pure layout core for scene-IR.
6
248
  //
7
249
  // `resolveLayoutRule` turns one LayoutRule into a Placement given a context of already
@@ -12,11 +254,10 @@ function orientationOf(width, height, portraitFactor = 1) {
12
254
  return width < height * portraitFactor ? 'portrait' : 'landscape';
13
255
  }
14
256
  /** Base node + orientation override + active state override (state wins), for one frame. */
15
- function effectiveNode(node, orientation, state, mode) {
16
- // Layer order: base → orientation → mode → active state (later wins).
257
+ function effectiveNode(node, orientation, state) {
258
+ // Layer order: base → orientation → active state (later wins).
17
259
  const layers = [
18
260
  node.responsive?.[orientation],
19
- mode ? node.modes?.[mode] : undefined,
20
261
  state ? node.states?.[state] : undefined,
21
262
  ];
22
263
  let layout = node.layout;
@@ -2955,12 +3196,51 @@ function emptyBoard(cfg) {
2955
3196
  // Everything else (ropes, meters, particles, game HUDs) arrives as a ScenePlugin through
2956
3197
  // the same contribution shape — built-ins get no special powers.
2957
3198
  const num$1 = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
3199
+ /** Word wrapping: without it a caption runs straight off the card it belongs to. */
3200
+ function wrapStyle(p) {
3201
+ const width = p.wrapWidth;
3202
+ if (typeof width !== 'number' || width <= 0)
3203
+ return {};
3204
+ return {
3205
+ wordWrap: true,
3206
+ wordWrapWidth: width,
3207
+ breakWords: p.breakWords === true,
3208
+ ...(typeof p.lineHeight === 'number' ? { lineHeight: p.lineHeight } : {}),
3209
+ };
3210
+ }
3211
+ /** Pixi's text drop shadow, from the doc's `dropShadow` object (absent → no shadow). */
3212
+ function dropShadowStyle(spec) {
3213
+ if (!spec || typeof spec !== 'object')
3214
+ return {};
3215
+ const d = spec;
3216
+ return {
3217
+ dropShadow: {
3218
+ color: typeof d.color === 'string' ? d.color : '#000000',
3219
+ blur: num$1(d.blur, 4),
3220
+ distance: num$1(d.distance, 3),
3221
+ angle: num$1(d.angle, Math.PI / 6),
3222
+ alpha: num$1(d.alpha, 1),
3223
+ },
3224
+ };
3225
+ }
2958
3226
  const str = (v, fallback) => (typeof v === 'string' ? v : fallback);
2959
3227
  // ── container / layer ───────────────────────────────────────────────────────────────
2960
3228
  function containerContribution(kind) {
2961
3229
  return {
2962
3230
  kind,
2963
3231
  agentDoc: 'Grouping node; order of children = z-order. Keep it un-laid-out (identity) unless you move a whole group. With props.space {width,height} it becomes a nested coordinate space: descendants lay out inside that fixed design space and its own rule places the space (cover → the diorama idiom). Natural size = space size, else 0×0.',
3232
+ schema: {
3233
+ space: {
3234
+ kind: 'object',
3235
+ doc: 'Turn this container into a nested coordinate space of that design size; descendants lay out inside it and this node\'s own rule places the space (cover → the diorama idiom).',
3236
+ order: 10,
3237
+ fields: {
3238
+ width: { kind: 'number', default: 1920, min: 1, step: 1 },
3239
+ height: { kind: 'number', default: 1080, min: 1, step: 1 },
3240
+ },
3241
+ },
3242
+ alpha: ALPHA_FIELD,
3243
+ },
2964
3244
  defaults: { label: kind === 'layer' ? 'Layer' : 'Container', props: {} },
2965
3245
  create(node) {
2966
3246
  const view = new pixi_js.Container();
@@ -2983,6 +3263,22 @@ function containerContribution(kind) {
2983
3263
  const rectContribution = {
2984
3264
  kind: 'rect',
2985
3265
  agentDoc: 'Solid rounded rectangle: plates behind text, dimmers, and mask targets (reference its id from another node\'s `mask`). Props: width, height, fill, alpha, radius, stroke {color,width}.',
3266
+ schema: {
3267
+ width: { kind: 'number', doc: 'Width in this node\'s coordinate space.', default: 100, min: 0, step: 1, order: 10 },
3268
+ height: { kind: 'number', doc: 'Height in this node\'s coordinate space.', default: 100, min: 0, step: 1, order: 11 },
3269
+ fill: { kind: 'color', doc: 'Fill colour.', default: '#ffffff', order: 20 },
3270
+ radius: { kind: 'number', doc: 'Corner radius in px (0 = square).', default: 0, min: 0, step: 1, order: 30 },
3271
+ stroke: {
3272
+ kind: 'object',
3273
+ doc: 'Optional outline.',
3274
+ order: 40,
3275
+ fields: {
3276
+ color: { kind: 'color', default: '#000000' },
3277
+ width: { kind: 'number', default: 1, min: 0, step: 1 },
3278
+ },
3279
+ },
3280
+ alpha: ALPHA_FIELD,
3281
+ },
2986
3282
  defaults: {
2987
3283
  label: 'Rectangle',
2988
3284
  props: { width: 200, height: 120, fill: '#3355aa', radius: 12 },
@@ -3051,11 +3347,22 @@ function spriteInstance(node, ctx) {
3051
3347
  applyProps: apply,
3052
3348
  };
3053
3349
  }
3350
+ const SPRITE_SCHEMA = {
3351
+ src: { kind: 'asset', accept: 'image', doc: 'Texture alias from the asset manifest.', order: 1 },
3352
+ tint: { kind: 'color', doc: 'Multiplies the texture colour; white = untinted.', default: '#ffffff', order: 20 },
3353
+ flipX: { kind: 'boolean', doc: 'Mirror horizontally.', default: false, order: 30 },
3354
+ region: fracBoxFields('Crop to a sub-rectangle of the texture, in fractions (0..1) — used to slice one atlas into left/right halves.'),
3355
+ alpha: ALPHA_FIELD,
3356
+ };
3054
3357
  const spriteContribution = {
3055
3358
  kind: 'sprite',
3056
3359
  agentDoc: 'Static art. Props: src (texture alias), tint, alpha. Natural size = texture size; use layout to place/scale.',
3360
+ schema: SPRITE_SCHEMA,
3057
3361
  defaults: {
3058
3362
  label: 'Sprite',
3363
+ // Deliberately empty: a fresh sprite is invisible until it has a texture, and the fix
3364
+ // for that is that `src` is now a picker in the inspector the moment it is selected.
3365
+ // (A placeholder tint would only survive to discolour the art the user then picks.)
3059
3366
  props: {},
3060
3367
  layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, anchor: [0.5, 0.5] },
3061
3368
  },
@@ -3064,12 +3371,46 @@ const spriteContribution = {
3064
3371
  const reelFrameContribution = {
3065
3372
  kind: 'reelFrame',
3066
3373
  agentDoc: 'The reel frame art. Declare where its hollow is once in props.inner {left,top,width,height} (fractions of the texture); the grid then binds with layout {mode:"frame-fraction", frame:<this id>, use:"inner"} — never restate the numbers.',
3374
+ schema: {
3375
+ ...SPRITE_SCHEMA,
3376
+ inner: fracBoxFields('Where the frame\'s hollow is, in fractions of the texture. Grids bind to it with {mode:"frame-fraction", use:"inner"} instead of restating numbers.'),
3377
+ },
3378
+ defaults: {
3379
+ label: 'Reel frame',
3380
+ props: { inner: { left: 0.06, top: 0.08, width: 0.88, height: 0.84 } },
3381
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, widthFrac: 0.8, anchor: [0.5, 0.5] },
3382
+ },
3067
3383
  create: spriteInstance,
3068
3384
  };
3069
3385
  // ── text ────────────────────────────────────────────────────────────────────────────
3070
3386
  const textContribution = {
3071
3387
  kind: 'text',
3072
3388
  agentDoc: 'Vector text (labels, logos-as-type). Props: text, fontSize, fill, fontFamily, fontWeight, align, letterSpacing. For rolling win numbers prefer a bitmapNumber/prefab, not text.',
3389
+ schema: {
3390
+ text: { kind: 'text', doc: 'The string to draw.', default: '', order: 1 },
3391
+ fontSize: { kind: 'number', doc: 'Size in this node\'s coordinate space.', default: 32, min: 1, step: 1, order: 10 },
3392
+ fill: { kind: 'color', doc: 'Text colour.', default: '#ffffff', order: 11 },
3393
+ fontFamily: { kind: 'text', doc: 'CSS font stack. The font must be preloaded by the game.', default: 'Arial, sans-serif', order: 20 },
3394
+ fontWeight: { kind: 'enum', options: ['normal', 'bold'], default: 'normal', order: 21 },
3395
+ align: { kind: 'enum', options: ['left', 'center', 'right'], default: 'left', order: 22 },
3396
+ letterSpacing: { kind: 'number', default: 0, step: 1, order: 30 },
3397
+ wrapWidth: { kind: 'number', doc: 'Wrap the text at this width (design px). Unset = one line.', min: 0, step: 10, order: 32 },
3398
+ lineHeight: { kind: 'number', doc: 'Line spacing when wrapped.', min: 0, step: 1, order: 33 },
3399
+ breakWords: { kind: 'boolean', doc: 'Allow breaking inside a long word.', default: false, order: 34 },
3400
+ dropShadow: {
3401
+ kind: 'object',
3402
+ doc: 'Soft shadow behind the glyphs — what lifts a prompt off a busy background.',
3403
+ order: 40,
3404
+ fields: {
3405
+ color: { kind: 'color', default: '#000000' },
3406
+ blur: { kind: 'number', default: 4, min: 0, step: 1 },
3407
+ distance: { kind: 'number', default: 3, min: 0, step: 1 },
3408
+ angle: { kind: 'number', doc: 'Radians; default is down-right.', default: Math.PI / 6, step: 0.1 },
3409
+ alpha: { kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
3410
+ },
3411
+ },
3412
+ alpha: ALPHA_FIELD,
3413
+ },
3073
3414
  defaults: {
3074
3415
  label: 'Text',
3075
3416
  props: { text: 'Text', fontSize: 48, fill: '#ffffff', fontWeight: 'bold' },
@@ -3086,6 +3427,8 @@ const textContribution = {
3086
3427
  fontWeight: props.fontWeight ?? 'normal',
3087
3428
  align: props.align ?? 'left',
3088
3429
  letterSpacing: num$1(props.letterSpacing, 0),
3430
+ ...dropShadowStyle(props.dropShadow),
3431
+ ...wrapStyle(props),
3089
3432
  },
3090
3433
  });
3091
3434
  return {
@@ -3097,6 +3440,15 @@ const textContribution = {
3097
3440
  view.style.fill = p.fill ?? '#ffffff';
3098
3441
  view.style.fontSize = num$1(p.fontSize, 32);
3099
3442
  view.style.letterSpacing = num$1(p.letterSpacing, 0);
3443
+ const shadow = dropShadowStyle(p.dropShadow);
3444
+ view.style.dropShadow = shadow.dropShadow ?? false;
3445
+ const wrap = wrapStyle(p);
3446
+ view.style.wordWrap = wrap.wordWrap ?? false;
3447
+ if (wrap.wordWrapWidth !== undefined)
3448
+ view.style.wordWrapWidth = wrap.wordWrapWidth;
3449
+ if (wrap.lineHeight !== undefined)
3450
+ view.style.lineHeight = wrap.lineHeight;
3451
+ view.style.breakWords = wrap.breakWords ?? false;
3100
3452
  view.alpha = num$1(p.alpha, 1);
3101
3453
  },
3102
3454
  };
@@ -3106,6 +3458,27 @@ const textContribution = {
3106
3458
  const animatedSpriteContribution = {
3107
3459
  kind: 'animatedSprite',
3108
3460
  agentDoc: 'Frame animation from a grid spritesheet. Props: sheet {alias, cols, rows}, frame (static frame index, default 0), fps + playing:true for an ambient loop. Choreographed playback belongs to flow/animation clips, not here.',
3461
+ schema: {
3462
+ sheet: {
3463
+ kind: 'object',
3464
+ doc: 'A grid spritesheet: one image cut into cols x rows equal frames.',
3465
+ order: 1,
3466
+ fields: {
3467
+ alias: { kind: 'asset', accept: 'spritesheet', doc: 'Sheet texture alias.' },
3468
+ cols: { kind: 'number', default: 1, min: 1, step: 1 },
3469
+ rows: { kind: 'number', default: 1, min: 1, step: 1 },
3470
+ },
3471
+ },
3472
+ frame: { kind: 'number', doc: 'Static frame shown when not playing.', default: 0, min: 0, step: 1, order: 10 },
3473
+ fps: { kind: 'number', doc: 'Playback rate when playing.', default: 12, min: 0, step: 1, order: 11 },
3474
+ playing: { kind: 'boolean', doc: 'Loop continuously. Choreographed playback belongs in flow, not here.', default: false, order: 12 },
3475
+ alpha: ALPHA_FIELD,
3476
+ },
3477
+ defaults: {
3478
+ label: 'Animated sprite',
3479
+ props: { sheet: { cols: 1, rows: 1 }, frame: 0, fps: 12 },
3480
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, anchor: [0.5, 0.5] },
3481
+ },
3109
3482
  create(node, ctx) {
3110
3483
  const view = new pixi_js.Container();
3111
3484
  let animated = null;
@@ -3152,6 +3525,21 @@ const animatedSpriteContribution = {
3152
3525
  // ── reelGrid ────────────────────────────────────────────────────────────────────────
3153
3526
  const reelGridContribution = {
3154
3527
  kind: 'reelGrid',
3528
+ schema: {
3529
+ preset: {
3530
+ kind: 'enum',
3531
+ doc: 'Named reel-system preset; `config` layers on top of it.',
3532
+ options: Object.keys(PRESETS),
3533
+ order: 1,
3534
+ },
3535
+ config: { kind: 'json', doc: 'Partial ReelSystemConfig override (columns, rows, spin motion, anticipation, tumble...).', order: 10 },
3536
+ board: { kind: 'json', doc: 'Symbol ids as string[][], column-major — the statically shown board.', order: 20 },
3537
+ },
3538
+ defaults: {
3539
+ label: 'Reel grid',
3540
+ props: { preset: Object.keys(PRESETS)[0] },
3541
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, widthFrac: 0.6, anchor: [0.5, 0.5] },
3542
+ },
3155
3543
  agentDoc: 'The reel window, delegating to createReelSystem. Props: preset (named ReelSystemConfig preset), config (DeepPartial override), board (string[][] of symbol ids, column-major). Bind into the frame hollow with {mode:"frame-fraction", frame:"<frameId>", use:"inner"}. Needs `resolveSymbol` passed to createSceneFromDoc.',
3156
3544
  create(node, ctx) {
3157
3545
  if (!ctx.resolveSymbol) {
@@ -3225,6 +3613,10 @@ const prefabContribution = {
3225
3613
  const BUILTIN_FILTER_KINDS = [
3226
3614
  {
3227
3615
  kind: 'blur',
3616
+ schema: {
3617
+ strength: { kind: 'number', doc: 'Blur radius.', default: 8, min: 0, step: 1 },
3618
+ quality: { kind: 'number', doc: 'Passes — higher is smoother and costlier.', default: 4, min: 1, step: 1 },
3619
+ },
3228
3620
  agentDoc: 'Gaussian blur: {kind:"blur", strength, quality}. Shadow/glow layers idiom.',
3229
3621
  create(spec) {
3230
3622
  return new pixi_js.BlurFilter({
@@ -3440,10 +3832,9 @@ function createSceneFromDoc(doc, opts = {}) {
3440
3832
  lastWidth = width;
3441
3833
  lastHeight = height;
3442
3834
  const orientation = orientationOf(width, height, opts.portraitFactor ?? 1);
3443
- const mode = typeof vars.mode === 'string' ? vars.mode : null;
3444
- // Phase 1 — effective props (base + orientation + mode + state), applied before measuring.
3835
+ // Phase 1 effective props (base + orientation + state), applied before measuring.
3445
3836
  for (const runtime of [...runtimes.values()]) {
3446
- const eff = effectiveNode(runtime.node, orientation, runtime.state, mode);
3837
+ const eff = effectiveNode(runtime.node, orientation, runtime.state);
3447
3838
  runtime.visibleBase = eff.visible;
3448
3839
  const space = eff.props.space;
3449
3840
  runtime.spaceSize =
@@ -3547,7 +3938,7 @@ function createSceneFromDoc(doc, opts = {}) {
3547
3938
  const parentOriginY = parentIsFrame ? 0 : (runtime.parent?.bounds?.y ?? 0);
3548
3939
  const parentScaleX = parentIsFrame ? 1 : (runtime.parent?.worldScaleX ?? 1);
3549
3940
  const parentScaleY = parentIsFrame ? 1 : (runtime.parent?.worldScaleY ?? 1);
3550
- const eff = effectiveNode(runtime.node, orientation, runtime.state, mode);
3941
+ const eff = effectiveNode(runtime.node, orientation, runtime.state);
3551
3942
  const natural = runtime.instance.measure();
3552
3943
  const rule = eff.layout;
3553
3944
  // `space:'viewport'` resolves against the live viewport even inside a nested
@@ -3714,29 +4105,48 @@ function createSceneFromDoc(doc, opts = {}) {
3714
4105
  runtime.state = state;
3715
4106
  relayout();
3716
4107
  };
4108
+ /** Warn (once per key) and report the rejection back to the caller. */
4109
+ const reject = (key, msg) => {
4110
+ warnOnce(key, msg);
4111
+ return { ok: false, error: msg };
4112
+ };
3717
4113
  const applyPatch = (patch) => {
4114
+ // Each case fills this with the patch that puts the doc back exactly as it was — the
4115
+ // editor's undo stack simply replays it. Captured BEFORE the mutation, necessarily.
4116
+ let inverse;
3718
4117
  switch (patch.op) {
3719
4118
  case 'set-props': {
3720
4119
  const node = findNode(doc.root, patch.id);
3721
4120
  if (!node)
3722
- return warnOnce(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
3723
- if (patch.mode) {
3724
- node.modes = { ...(node.modes ?? {}) };
3725
- const over = node.modes[patch.mode] ?? {};
3726
- node.modes[patch.mode] = { ...over, props: { ...(over.props ?? {}), ...patch.props } };
3727
- }
3728
- else {
3729
- node.props = { ...(node.props ?? {}), ...patch.props };
3730
- }
4121
+ return reject(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
4122
+ // Undo restores each touched key to what it held — `undefined` for keys that were
4123
+ // absent, which the prop readers treat as "not set" and JSON.stringify drops.
4124
+ const before = node.props ?? {};
4125
+ const prev = {};
4126
+ for (const key of Object.keys(patch.props))
4127
+ prev[key] = before[key];
4128
+ inverse = { op: 'set-props', id: patch.id, props: prev };
4129
+ node.props = { ...(node.props ?? {}), ...patch.props };
3731
4130
  break;
3732
4131
  }
3733
4132
  case 'set-layout': {
3734
4133
  const node = findNode(doc.root, patch.id);
3735
4134
  if (!node)
3736
- return warnOnce(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
3737
- if (patch.mode) {
3738
- node.modes = { ...(node.modes ?? {}) };
3739
- node.modes[patch.mode] = { ...(node.modes[patch.mode] ?? {}), layout: patch.layout };
4135
+ return reject(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
4136
+ // Undo puts back the rule that occupied this exact slot — `null` when the slot did
4137
+ // not exist, so undoing an edit that CREATED an override removes it again.
4138
+ const prevRule = patch.orientation ? node.responsive?.[patch.orientation]?.layout : node.layout;
4139
+ inverse = {
4140
+ op: 'set-layout',
4141
+ id: patch.id,
4142
+ layout: prevRule ? JSON.parse(JSON.stringify(prevRule)) : null,
4143
+ orientation: patch.orientation,
4144
+ };
4145
+ if (patch.layout === null) {
4146
+ if (patch.orientation)
4147
+ delete node.responsive?.[patch.orientation]?.layout;
4148
+ else
4149
+ delete node.layout;
3740
4150
  }
3741
4151
  else if (patch.orientation) {
3742
4152
  node.responsive = { ...(node.responsive ?? {}) };
@@ -3750,31 +4160,57 @@ function createSceneFromDoc(doc, opts = {}) {
3750
4160
  }
3751
4161
  break;
3752
4162
  }
3753
- case 'set-state':
3754
- return setState(patch.id, patch.state);
3755
- case 'set-var':
3756
- return setVar(patch.name, patch.value);
4163
+ case 'set-name': {
4164
+ const node = findNode(doc.root, patch.id);
4165
+ if (!node)
4166
+ return reject(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
4167
+ inverse = { op: 'set-name', id: patch.id, name: node.name };
4168
+ if (patch.name === undefined || patch.name === '')
4169
+ delete node.name;
4170
+ else
4171
+ node.name = patch.name;
4172
+ break;
4173
+ }
4174
+ case 'set-state': {
4175
+ // Runtime-only, so the previous value lives on the runtime, not in the doc.
4176
+ const prevState = runtimes.get(patch.id)?.state ?? null;
4177
+ setState(patch.id, patch.state);
4178
+ return { ok: true, inverse: { op: 'set-state', id: patch.id, state: prevState } };
4179
+ }
4180
+ case 'set-var': {
4181
+ const prevVar = vars[patch.name];
4182
+ setVar(patch.name, patch.value);
4183
+ return { ok: true, inverse: { op: 'set-var', name: patch.name, value: prevVar } };
4184
+ }
3757
4185
  case 'add-node': {
3758
4186
  const parentRuntime = runtimes.get(patch.parent);
3759
4187
  if (!parentRuntime)
3760
- return warnOnce(`add:${patch.parent}`, `add-node: unknown parent "${patch.parent}"`);
4188
+ return reject(`add:${patch.parent}`, `add-node: unknown parent "${patch.parent}"`);
3761
4189
  const clash = collectIds(patch.node).find((id) => runtimes.has(id));
3762
4190
  if (clash)
3763
- return warnOnce(`add-clash:${clash}`, `add-node: id "${clash}" already exists`);
4191
+ return reject(`add-clash:${clash}`, `add-node: id "${clash}" already exists`);
3764
4192
  if (!registry.nodeType(patch.node.type)) {
3765
- return warnOnce(`add-kind:${patch.node.type}`, `add-node: unknown type "${patch.node.type}"`);
4193
+ return reject(`add-kind:${patch.node.type}`, `add-node: unknown type "${patch.node.type}"`);
3766
4194
  }
3767
4195
  (parentRuntime.node.children ??= []).splice(patch.index ?? parentRuntime.node.children.length, 0, patch.node);
3768
4196
  buildInto(patch.node, parentRuntime, patch.index ?? Number.MAX_SAFE_INTEGER);
3769
4197
  wireMasks();
4198
+ inverse = { op: 'remove-node', id: patch.node.id };
3770
4199
  break;
3771
4200
  }
3772
4201
  case 'remove-node': {
3773
4202
  if (patch.id === doc.root.id)
3774
- return warnOnce('rm-root', 'remove-node: cannot remove the root');
4203
+ return reject('rm-root', 'remove-node: cannot remove the root');
3775
4204
  const loc = locate(patch.id);
3776
4205
  if (!loc)
3777
- return warnOnce(`rm:${patch.id}`, `remove-node: unknown node "${patch.id}"`);
4206
+ return reject(`rm:${patch.id}`, `remove-node: unknown node "${patch.id}"`);
4207
+ // The whole subtree goes with it, so undo needs a deep copy taken before the splice.
4208
+ inverse = {
4209
+ op: 'add-node',
4210
+ parent: loc.parent.id,
4211
+ node: JSON.parse(JSON.stringify(loc.parent.children[loc.index])),
4212
+ index: loc.index,
4213
+ };
3778
4214
  destroyRuntimeTree(patch.id);
3779
4215
  loc.parent.children.splice(loc.index, 1);
3780
4216
  wireMasks();
@@ -3782,17 +4218,18 @@ function createSceneFromDoc(doc, opts = {}) {
3782
4218
  }
3783
4219
  case 'move-node': {
3784
4220
  if (patch.id === doc.root.id)
3785
- return warnOnce('mv-root', 'move-node: cannot move the root');
4221
+ return reject('mv-root', 'move-node: cannot move the root');
3786
4222
  const loc = locate(patch.id);
3787
4223
  const source = findNode(doc.root, patch.id);
3788
4224
  const parentRuntime = runtimes.get(patch.parent);
3789
4225
  if (!loc || !source)
3790
- return warnOnce(`mv:${patch.id}`, `move-node: unknown node "${patch.id}"`);
4226
+ return reject(`mv:${patch.id}`, `move-node: unknown node "${patch.id}"`);
3791
4227
  if (!parentRuntime)
3792
- return warnOnce(`mv-p:${patch.parent}`, `move-node: unknown parent "${patch.parent}"`);
4228
+ return reject(`mv-p:${patch.parent}`, `move-node: unknown parent "${patch.parent}"`);
3793
4229
  if (collectIds(source).includes(patch.parent)) {
3794
- return warnOnce(`mv-cycle:${patch.id}`, `move-node: cannot move "${patch.id}" into itself/its descendant "${patch.parent}"`);
4230
+ return reject(`mv-cycle:${patch.id}`, `move-node: cannot move "${patch.id}" into itself/its descendant "${patch.parent}"`);
3795
4231
  }
4232
+ inverse = { op: 'move-node', id: patch.id, parent: loc.parent.id, index: loc.index };
3796
4233
  // `index` is the FINAL position in the destination's children (post-removal).
3797
4234
  const [node] = loc.parent.children.splice(loc.index, 1);
3798
4235
  destroyRuntimeTree(patch.id); // rebuild the moved subtree under the new parent
@@ -3805,11 +4242,11 @@ function createSceneFromDoc(doc, opts = {}) {
3805
4242
  }
3806
4243
  case 'duplicate-node': {
3807
4244
  if (patch.id === doc.root.id)
3808
- return warnOnce('dup-root', 'duplicate-node: cannot duplicate the root');
4245
+ return reject('dup-root', 'duplicate-node: cannot duplicate the root');
3809
4246
  const loc = locate(patch.id);
3810
4247
  const source = findNode(doc.root, patch.id);
3811
4248
  if (!loc || !source)
3812
- return warnOnce(`dup:${patch.id}`, `duplicate-node: unknown node "${patch.id}"`);
4249
+ return reject(`dup:${patch.id}`, `duplicate-node: unknown node "${patch.id}"`);
3813
4250
  let suffix = '-copy';
3814
4251
  while (collectIds(source).some((id) => runtimes.has(`${id}${suffix}`)))
3815
4252
  suffix += '2';
@@ -3818,10 +4255,12 @@ function createSceneFromDoc(doc, opts = {}) {
3818
4255
  loc.parent.children.splice(loc.index + 1, 0, clone);
3819
4256
  buildInto(clone, parentRuntime, loc.index + 1);
3820
4257
  wireMasks();
4258
+ inverse = { op: 'remove-node', id: clone.id };
3821
4259
  break;
3822
4260
  }
3823
4261
  }
3824
4262
  relayout();
4263
+ return { ok: true, inverse };
3825
4264
  };
3826
4265
  return {
3827
4266
  view: rootView,
@@ -3841,6 +4280,20 @@ function createSceneFromDoc(doc, opts = {}) {
3841
4280
  patch: applyPatch,
3842
4281
  doc: () => doc,
3843
4282
  palette: () => registry.palette(),
4283
+ schemas() {
4284
+ const out = {};
4285
+ for (const kind of registry.kinds()) {
4286
+ const schema = registry.nodeType(kind)?.schema;
4287
+ if (schema)
4288
+ out[kind] = schema;
4289
+ }
4290
+ for (const name of registry.prefabNames()) {
4291
+ const schema = registry.prefab(name)?.schema;
4292
+ if (schema)
4293
+ out[`prefab:${name}`] = schema;
4294
+ }
4295
+ return out;
4296
+ },
3844
4297
  resetProps() {
3845
4298
  for (const runtime of runtimes.values())
3846
4299
  runtime.appliedProps = 'stale';
@@ -4155,7 +4608,10 @@ function createTransformGizmo(opts) {
4155
4608
  layer.addChildAt(body, 1);
4156
4609
  let nodeId = null;
4157
4610
  let drag = null;
4158
- const getMode = () => opts.getMode?.() ?? null;
4611
+ /** Press origin, to tell a click apart from a drag on pointerup. */
4612
+ let downAt = null;
4613
+ let moved = false;
4614
+ const DRAG_SLOP = 4; // px before a press counts as a drag
4159
4615
  const findDocNode = (id, node = handle.doc().root) => {
4160
4616
  if (node.id === id)
4161
4617
  return node;
@@ -4167,18 +4623,13 @@ function createTransformGizmo(opts) {
4167
4623
  return undefined;
4168
4624
  };
4169
4625
  /**
4170
- * Effective layout rule + which override an edit should target. Editing in a non-base
4171
- * mode targets that mode's override (so base and free_spins are edited independently);
4172
- * otherwise the current orientation override, else base.
4626
+ * Effective layout rule + which override an edit should target: the current orientation's
4627
+ * override when one exists, else the base rule.
4173
4628
  */
4174
4629
  const effectiveRule = (id) => {
4175
4630
  const node = findDocNode(id);
4176
4631
  if (!node)
4177
4632
  return { target: {} };
4178
- const mode = getMode();
4179
- if (mode && mode !== 'base') {
4180
- return { rule: node.modes?.[mode]?.layout ?? node.layout, target: { mode } };
4181
- }
4182
4633
  const o = getOrientation();
4183
4634
  const override = node.responsive?.[o]?.layout;
4184
4635
  return override ? { rule: override, target: { orientation: o } } : { rule: node.layout, target: {} };
@@ -4199,6 +4650,9 @@ function createTransformGizmo(opts) {
4199
4650
  if (!box || !rule)
4200
4651
  return;
4201
4652
  const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
4653
+ downAt = { x: e.global.x, y: e.global.y, handle: h, alt: e.altKey };
4654
+ moved = false;
4655
+ opts.onGestureStart?.();
4202
4656
  drag = {
4203
4657
  handle: h,
4204
4658
  startBox: box,
@@ -4212,6 +4666,11 @@ function createTransformGizmo(opts) {
4212
4666
  const onMove = (e) => {
4213
4667
  if (!drag || !nodeId)
4214
4668
  return;
4669
+ if (!moved && downAt && (Math.abs(e.global.x - downAt.x) > DRAG_SLOP || Math.abs(e.global.y - downAt.y) > DRAG_SLOP)) {
4670
+ moved = true;
4671
+ }
4672
+ if (!moved)
4673
+ return; // a press that has not travelled yet is still a potential click
4215
4674
  let rule;
4216
4675
  if (drag.handle === 'body') {
4217
4676
  const s = dpp(nodeId);
@@ -4227,10 +4686,20 @@ function createTransformGizmo(opts) {
4227
4686
  const { factorX, factorY } = resizeFactors(drag.startBox, drag.handle, { x: e.global.x, y: e.global.y });
4228
4687
  rule = resizeRule(drag.baseRule, factorX, factorY);
4229
4688
  }
4230
- applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.target.orientation, mode: drag.target.mode });
4689
+ applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.target.orientation });
4231
4690
  };
4232
4691
  const onUp = () => {
4692
+ if (drag)
4693
+ opts.onGestureEnd?.();
4233
4694
  drag = null;
4695
+ // Body press that never moved = a selection click, not a move.
4696
+ if (downAt && !moved && downAt.handle === 'body') {
4697
+ const { x, y, alt } = downAt;
4698
+ downAt = null;
4699
+ opts.onBodyClick?.(x, y, alt);
4700
+ return;
4701
+ }
4702
+ downAt = null;
4234
4703
  };
4235
4704
  app.stage.on('pointermove', onMove);
4236
4705
  app.stage.on('pointerup', onUp);
@@ -4273,6 +4742,24 @@ function createTransformGizmo(opts) {
4273
4742
  redraw();
4274
4743
  },
4275
4744
  refresh: redraw,
4745
+ nudge(dxScreen, dyScreen) {
4746
+ if (!nodeId)
4747
+ return;
4748
+ const { rule, target } = effectiveRule(nodeId);
4749
+ if (!rule)
4750
+ return;
4751
+ // Bracketed as a gesture so it lands on the undo stack as exactly one step.
4752
+ opts.onGestureStart?.();
4753
+ const s = dpp(nodeId);
4754
+ applyPatch({
4755
+ op: 'set-layout',
4756
+ id: nodeId,
4757
+ layout: nudgeRule(rule, dxScreen * s, dyScreen * s),
4758
+ orientation: target.orientation,
4759
+ });
4760
+ opts.onGestureEnd?.();
4761
+ redraw();
4762
+ },
4276
4763
  drag(h, from, to) {
4277
4764
  onDown(h, fakeEvent(from.x, from.y));
4278
4765
  onMove(fakeEvent(to.x, to.y));
@@ -4360,6 +4847,12 @@ function formatValue(value, format) {
4360
4847
  }
4361
4848
  const tweenStep = {
4362
4849
  kind: 'tween',
4850
+ schema: {
4851
+ node: { kind: 'nodeRef', doc: 'Scene node to animate.', order: 1 },
4852
+ to: { kind: 'json', doc: 'Target numeric view props, e.g. {"alpha":1,"y":40}.', order: 10 },
4853
+ ms: { kind: 'number', doc: 'Duration; scaled by turbo, zero under skip.', default: 300, min: 0, step: 10, order: 20 },
4854
+ ease: { kind: 'text', doc: 'Easing name from the Easing table.', default: 'linear', order: 30 },
4855
+ },
4363
4856
  agentDoc: 'Animate numeric view properties of a scene node (alpha, x, y, rotation; scale animates both axes). Transient — the doc is untouched, and any relayout (setVar/setState/resize) re-asserts layout-owned props (x/y/scale/rotation) from the doc. Persist a move via a set-layout patch, not a tween. Turbo divides ms; skip jumps to the end values.',
4364
4857
  async run(raw, rt) {
4365
4858
  const step = raw;
@@ -4398,6 +4891,10 @@ const tweenStep = {
4398
4891
  };
4399
4892
  const soundStep = {
4400
4893
  kind: 'sound',
4894
+ schema: {
4895
+ cue: { kind: 'enum', doc: 'A cue declared in the doc\'s `cues`.', options: [], order: 1 },
4896
+ action: { kind: 'enum', options: ['play', 'stop'], default: 'play', order: 10 },
4897
+ },
4401
4898
  agentDoc: 'Play/stop a cue from flow.cues (rotation/jitter handled by the runner). Never reference audio files directly.',
4402
4899
  run(raw, rt) {
4403
4900
  const step = raw;
@@ -4406,6 +4903,10 @@ const soundStep = {
4406
4903
  };
4407
4904
  const setStateStep = {
4408
4905
  kind: 'setState',
4906
+ schema: {
4907
+ node: { kind: 'nodeRef', doc: 'Node whose named variant changes.', order: 1 },
4908
+ state: { kind: 'text', doc: 'A state declared on that node; empty clears it.', order: 10 },
4909
+ },
4409
4910
  agentDoc: "Switch a node's named state (frame anticipation/bonus look). Same operation the inspector and agent use.",
4410
4911
  run(raw, rt) {
4411
4912
  const step = raw;
@@ -4414,6 +4915,10 @@ const setStateStep = {
4414
4915
  };
4415
4916
  const setVarStep = {
4416
4917
  kind: 'setVar',
4918
+ schema: {
4919
+ name: { kind: 'text', doc: 'Runtime var name (drives visibleWhen).', order: 1 },
4920
+ value: { kind: 'text', doc: 'Literal, or a $ctx reference.', order: 10 },
4921
+ },
4417
4922
  agentDoc: "Set a runtime var driving visibleWhen (e.g. mode). Use for mode transitions ('setVar mode free_spins').",
4418
4923
  run(raw, rt) {
4419
4924
  const step = raw;
@@ -4422,6 +4927,10 @@ const setVarStep = {
4422
4927
  };
4423
4928
  const setPropsStep = {
4424
4929
  kind: 'setProps',
4930
+ schema: {
4931
+ node: { kind: 'nodeRef', order: 1 },
4932
+ props: { kind: 'json', doc: 'Props to merge onto the node.', order: 10 },
4933
+ },
4425
4934
  agentDoc: 'Transient prop write on the live instance (badge values, board swaps during presentation). The scene doc is NOT modified.',
4426
4935
  run(raw, rt) {
4427
4936
  const step = raw;
@@ -4450,6 +4959,14 @@ function findDocNode(rt, id) {
4450
4959
  }
4451
4960
  const countUpStep = {
4452
4961
  kind: 'countUp',
4962
+ schema: {
4963
+ node: { kind: 'nodeRef', doc: 'Node holding the number (a text or a prefab).', order: 1 },
4964
+ prop: { kind: 'text', default: 'value', order: 5 },
4965
+ from: { kind: 'text', doc: 'Start value; literal or $ctx. Defaults to the current one.', order: 10 },
4966
+ to: { kind: 'text', doc: 'End value; literal or $ctx (e.g. $win).', order: 11 },
4967
+ ms: { kind: 'number', default: 1000, min: 0, step: 50, order: 20 },
4968
+ format: { kind: 'enum', options: ['int', 'space'], order: 30 },
4969
+ },
4453
4970
  agentDoc: "Animated number roll on an instance prop (default 'value' — badge prefabs). `to` may be '$win' (ctx ref). Turbo shortens, skip jumps to the final value.",
4454
4971
  async run(raw, rt) {
4455
4972
  const step = raw;
@@ -4477,6 +4994,10 @@ const countUpStep = {
4477
4994
  };
4478
4995
  const waitStep = {
4479
4996
  kind: 'wait',
4997
+ schema: {
4998
+ ms: { kind: 'number', doc: 'Pause; scaled by turbo, released by skip.', default: 300, min: 0, step: 10, order: 1 },
4999
+ until: { kind: 'enum', doc: "'tap' waits for the player instead of a duration.", options: ['tap'], order: 10 },
5000
+ },
4480
5001
  agentDoc: "Pause: {ms} (turbo-scaled) or {until:'tap'} (released by tap or skip). Prefer explicit waits over baking delays into tweens.",
4481
5002
  async run(raw, rt) {
4482
5003
  const step = raw;
@@ -4487,6 +5008,9 @@ const waitStep = {
4487
5008
  };
4488
5009
  const ifStep = {
4489
5010
  kind: 'if',
5011
+ schema: {
5012
+ when: { kind: 'text', doc: 'Guard: `<ctxField> <op> <literal>`, e.g. `win >= 20`.', order: 1 },
5013
+ },
4490
5014
  agentDoc: "Branch on the fire() ctx: {when:'win >= 100', then:[…], else:[…]}. Bare name = truthy check.",
4491
5015
  async run(raw, rt) {
4492
5016
  const step = raw;
@@ -4495,6 +5019,7 @@ const ifStep = {
4495
5019
  };
4496
5020
  const parallelStep = {
4497
5021
  kind: 'parallel',
5022
+ schema: {},
4498
5023
  agentDoc: 'Run tracks concurrently and await them all: {steps: [[…], […]]}. Each track is an independent sequence.',
4499
5024
  async run(raw, rt) {
4500
5025
  const step = raw;
@@ -4503,6 +5028,7 @@ const parallelStep = {
4503
5028
  };
4504
5029
  const seqStep = {
4505
5030
  kind: 'seq',
5031
+ schema: {},
4506
5032
  agentDoc: 'Nested sequence (grouping inside parallel tracks).',
4507
5033
  async run(raw, rt) {
4508
5034
  const step = raw;
@@ -4511,6 +5037,12 @@ const seqStep = {
4511
5037
  };
4512
5038
  const forEachStep = {
4513
5039
  kind: 'forEach',
5040
+ schema: {
5041
+ items: { kind: 'text', doc: 'A $ctx array to walk, e.g. $cascadeSteps.', order: 1 },
5042
+ as: { kind: 'text', doc: 'Name each element binds to inside ($item by default).', default: 'item', order: 10 },
5043
+ mode: { kind: 'enum', options: ['sequential', 'parallel'], default: 'sequential', order: 20 },
5044
+ staggerMs: { kind: 'number', doc: 'Delay between elements.', default: 0, min: 0, step: 10, order: 30 },
5045
+ },
4514
5046
  agentDoc: "Fan out over a ctx collection: {items:'$jars', as:'jar', mode:'parallel', staggerMs:90, steps:[…]}. Nested steps read `$jar` / `$jarIndex`. THE primitive for staggered jar flights, per-cell transmutes, cascade histories — validated on 3 games.",
4515
5047
  async run(raw, rt) {
4516
5048
  const step = raw;
@@ -4538,6 +5070,10 @@ const forEachStep = {
4538
5070
  };
4539
5071
  const codeStep = {
4540
5072
  kind: 'code',
5073
+ schema: {
5074
+ ref: { kind: 'text', doc: 'Key in the host-provided `code` map — the escape hatch.', order: 1 },
5075
+ args: { kind: 'json', order: 10 },
5076
+ },
4541
5077
  agentDoc: 'Escape hatch: named choreography registered in createFlowRunner({code}). Use when the step vocabulary genuinely cannot express it — then consider a plugin step.',
4542
5078
  async run(raw, rt) {
4543
5079
  const step = raw;
@@ -4562,6 +5098,73 @@ const BUILTIN_FLOW_STEPS = [
4562
5098
  codeStep,
4563
5099
  ];
4564
5100
 
5101
+ // Flow as a graph: stable step ids, and the layout that turns a flow event into nodes and
5102
+ // edges for the canvas editor.
5103
+ //
5104
+ // The IR is a TREE (a step list, with `seq`/`parallel`/`if`/`forEach` nesting more lists),
5105
+ // not a free DAG — control flow is strictly structural, there are no jumps. So the canvas
5106
+ // draws a nested flow diagram rather than a general node soup: consecutive steps chain
5107
+ // left to right, and a container is a box its children are laid out inside. That is the
5108
+ // honest picture of what actually runs; a flat DAG would have to invent joins the runtime
5109
+ // does not have.
5110
+ //
5111
+ // Layout is automatic by default so an existing flow.json opens as a readable graph with
5112
+ // no migration. A step only gets `ui: {x, y}` once someone drags it.
5113
+ /** Child lists a container step owns, by kind. `parallel` holds a list PER track. */
5114
+ function containerTracks(step) {
5115
+ const kind = step.do;
5116
+ if (kind === 'parallel') {
5117
+ const tracks = step.steps;
5118
+ return Array.isArray(tracks) && Array.isArray(tracks[0]) ? tracks : [];
5119
+ }
5120
+ if (kind === 'seq' || kind === 'forEach') {
5121
+ const list = step.steps;
5122
+ return Array.isArray(list) ? [list] : [];
5123
+ }
5124
+ if (kind === 'if') {
5125
+ const s = step;
5126
+ return [s.then ?? [], s.else ?? []];
5127
+ }
5128
+ return [];
5129
+ }
5130
+ /**
5131
+ * Give every step a stable id, in place-ish (returns the same doc object). Ids are only
5132
+ * assigned where missing, so re-running is a no-op and saved ids survive.
5133
+ */
5134
+ function ensureStepIds(doc, prefix = 's') {
5135
+ const used = new Set();
5136
+ const collect = (list) => {
5137
+ for (const step of list) {
5138
+ if (typeof step.id === 'string')
5139
+ used.add(step.id);
5140
+ for (const track of containerTracks(step))
5141
+ collect(track);
5142
+ }
5143
+ };
5144
+ for (const list of Object.values(doc.on))
5145
+ collect(list);
5146
+ let n = 0;
5147
+ const fresh = () => {
5148
+ let id;
5149
+ do {
5150
+ id = `${prefix}${++n}`;
5151
+ } while (used.has(id));
5152
+ used.add(id);
5153
+ return id;
5154
+ };
5155
+ const assign = (list) => {
5156
+ for (const step of list) {
5157
+ if (typeof step.id !== 'string' || step.id === '')
5158
+ step.id = fresh();
5159
+ for (const track of containerTracks(step))
5160
+ assign(track);
5161
+ }
5162
+ };
5163
+ for (const list of Object.values(doc.on))
5164
+ assign(list);
5165
+ return doc;
5166
+ }
5167
+
4565
5168
  // Flow-IR interpreter: createFlowRunner(flowDoc, { scene, … }).fire(event, ctx) executes
4566
5169
  // the event's steps and returns a Trace.
4567
5170
  //
@@ -4603,6 +5206,8 @@ function validateFlowDoc(doc, registry) {
4603
5206
  function createFlowRunner(doc, opts) {
4604
5207
  const log = opts.log ?? ((msg) => console.warn(`[flow] ${msg}`));
4605
5208
  const registry = createFlowStepRegistry(opts.plugins ?? [], BUILTIN_FLOW_STEPS);
5209
+ // Hand-written docs omit ids; assign them once so traces and the canvas can address steps.
5210
+ ensureStepIds(doc);
4606
5211
  const errors = validateFlowDoc(doc, registry);
4607
5212
  if (errors.length > 0)
4608
5213
  throw new Error(`flow doc "${doc.id}" is invalid:\n ${errors.join('\n ')}`);
@@ -4710,6 +5315,9 @@ function createFlowRunner(doc, opts) {
4710
5315
  continue; // validated; defensive
4711
5316
  trace({
4712
5317
  do: step.do,
5318
+ // Which step this came from, so the canvas can highlight it and a scrub can
5319
+ // point at it — `do` alone cannot tell two identical tweens apart.
5320
+ stepId: typeof step.id === 'string' ? step.id : undefined,
4713
5321
  node: typeof step.node === 'string' ? step.node : undefined,
4714
5322
  });
4715
5323
  // trace() may flip `halted` on THIS entry — the halting step still executes
@@ -4775,6 +5383,10 @@ function createFlowRunner(doc, opts) {
4775
5383
  };
4776
5384
  return {
4777
5385
  fire,
5386
+ stepKinds: () => registry.kinds().map((kind) => {
5387
+ const c = registry.step(kind);
5388
+ return { kind, schema: c?.schema, agentDoc: c?.agentDoc };
5389
+ }),
4778
5390
  replay(event, untilEntry) {
4779
5391
  const ctx = lastCtx.get(event);
4780
5392
  if (!ctx)
@@ -4816,9 +5428,16 @@ function createFlowRunner(doc, opts) {
4816
5428
  // and the game ships no GameScene code for whatever the docs express.
4817
5429
  /** Build the createSlotGame `scenes` list from a StageDoc — every entry a DocScene. */
4818
5430
  function buildDocScenes(stage, shared) {
4819
- return stage.scenes.map((entry) => ({
5431
+ // Mode views are not host scenes they belong to the scene they extend.
5432
+ const modeScenes = {};
5433
+ for (const entry of stage.scenes)
5434
+ if (entry.mode)
5435
+ modeScenes[entry.mode] = entry.scene;
5436
+ return stage.scenes
5437
+ .filter((entry) => !entry.mode)
5438
+ .map((entry) => ({
4820
5439
  key: entry.key,
4821
- scene: new DocScene({ ...shared, scene: entry.scene, flow: entry.flow, advanceOnTap: entry.advanceOnTap }),
5440
+ scene: new DocScene({ ...shared, scene: entry.scene, modeScenes, flow: entry.flow, advanceOnTap: entry.advanceOnTap }),
4822
5441
  skipOnReplay: entry.skipOnReplay,
4823
5442
  }));
4824
5443
  }
@@ -4941,16 +5560,48 @@ class DocScene extends Scene {
4941
5560
  onSpinStart() {
4942
5561
  void this.fire('spinStart');
4943
5562
  }
5563
+ /** The resolved doc currently on screen, so a mode switch diffs from the right baseline. */
5564
+ activeDoc_ = null;
5565
+ activeDocId_ = null;
4944
5566
  async onSpin(result, ctx) {
4945
5567
  await this.fire('result', this.ctxOf(result, ctx));
4946
5568
  }
4947
5569
  async onEnterMode(result, ctx) {
4948
5570
  this.handle_?.setVar('mode', ctx.mode);
5571
+ this.applySceneFor(ctx.mode);
4949
5572
  await this.fire('enterMode', this.ctxOf(result, ctx));
4950
5573
  }
4951
5574
  async onExitMode(result, ctx) {
4952
5575
  await this.fire('exitMode', this.ctxOf(result, ctx));
4953
5576
  this.handle_?.setVar('mode', 'BASE');
5577
+ this.applySceneFor(null); // back to the base scene
5578
+ }
5579
+ /**
5580
+ * Move the live scene to the doc for `mode` (null = the base scene) by applying the diff.
5581
+ * A no-op when the mode has no scene of its own, which is the common case: most modes
5582
+ * differ only in what flow does.
5583
+ */
5584
+ applySceneFor(mode) {
5585
+ const target = mode ? this.opts.modeScenes?.[mode] : this.opts.scene;
5586
+ if (!target || !this.handle_)
5587
+ return;
5588
+ const resolved = this.resolveDoc(target);
5589
+ if (resolved.id === this.activeDocId_)
5590
+ return;
5591
+ for (const patch of diffDocs(this.activeDoc_ ?? this.opts.scene, resolved)) {
5592
+ const result = this.handle_.patch(patch);
5593
+ if (result.ok === false)
5594
+ this.opts.log?.(`mode scene "${resolved.id}": ${result.error}`);
5595
+ }
5596
+ this.activeDoc_ = resolved;
5597
+ this.activeDocId_ = resolved.id;
5598
+ }
5599
+ /** Fold a doc's `extends` chain, looking parents up among the scenes this game declared. */
5600
+ resolveDoc(doc) {
5601
+ const pool = { [this.opts.scene.id]: this.opts.scene, ...(this.opts.modeScenes ?? {}) };
5602
+ return resolveExtends(doc, {
5603
+ load: (ref) => pool[ref] ?? Object.values(pool).find((d) => d.id === ref),
5604
+ });
4954
5605
  }
4955
5606
  onSpinEnd(result, ctx) {
4956
5607
  void this.fire('spinEnd', this.ctxOf(result, ctx));
@@ -4966,9 +5617,11 @@ function createDocScene(opts) {
4966
5617
  return new DocScene(opts);
4967
5618
  }
4968
5619
 
5620
+ exports.ALPHA_FIELD = ALPHA_FIELD;
4969
5621
  exports.BUILTIN_FILTER_KINDS = BUILTIN_FILTER_KINDS;
4970
5622
  exports.BUILTIN_NODE_TYPES = BUILTIN_NODE_TYPES;
4971
5623
  exports.DocScene = DocScene;
5624
+ exports.REMOVE_MARKER = REMOVE_MARKER;
4972
5625
  exports.buildDocScenes = buildDocScenes;
4973
5626
  exports.createDocScene = createDocScene;
4974
5627
  exports.createInnerCalibrator = createInnerCalibrator;
@@ -4976,17 +5629,22 @@ exports.createSceneFromDoc = createSceneFromDoc;
4976
5629
  exports.createSceneRegistry = createSceneRegistry;
4977
5630
  exports.createTransformGizmo = createTransformGizmo;
4978
5631
  exports.dependencyOf = dependencyOf;
5632
+ exports.diffDocs = diffDocs;
4979
5633
  exports.dragInnerEdge = dragInnerEdge;
4980
5634
  exports.edgePoint = edgePoint;
4981
5635
  exports.effectiveNode = effectiveNode;
4982
5636
  exports.evalVisibleWhen = evalVisibleWhen;
5637
+ exports.extraPropKeys = extraPropKeys;
5638
+ exports.fracBoxFields = fracBoxFields;
4983
5639
  exports.handlePoint = handlePoint;
4984
5640
  exports.nudgeRule = nudgeRule;
4985
5641
  exports.orientationOf = orientationOf;
4986
5642
  exports.placementBounds = placementBounds;
4987
5643
  exports.resizeFactors = resizeFactors;
4988
5644
  exports.resizeRule = resizeRule;
5645
+ exports.resolveExtends = resolveExtends;
4989
5646
  exports.resolveLayoutRule = resolveLayoutRule;
5647
+ exports.schemaFieldRows = schemaFieldRows;
4990
5648
  exports.setRuleRotation = setRuleRotation;
4991
5649
  exports.sizeLever = sizeLever;
4992
5650
  exports.validateSceneDoc = validateSceneDoc;