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