@energy8platform/game-engine 0.33.4 → 0.33.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scene.esm.js CHANGED
@@ -270,22 +270,47 @@ function evalVisibleWhen(expr, vars) {
270
270
  // schema (validation + inspector autogen) and an `agentDoc` (the plugin's documentation
271
271
  // and the agent's prompt are the same text).
272
272
  /** Merge built-ins with plugin contributions; later plugins may override earlier kinds. */
273
- function createSceneRegistry(plugins = [], builtins = []) {
273
+ function createSceneRegistry(plugins = [], builtins = [], builtinFilters = []) {
274
274
  const nodeTypes = new Map();
275
275
  const prefabs = new Map();
276
+ const filterKinds = new Map();
276
277
  for (const contribution of builtins)
277
278
  nodeTypes.set(contribution.kind, contribution);
279
+ for (const contribution of builtinFilters)
280
+ filterKinds.set(contribution.kind, contribution);
278
281
  for (const plugin of plugins) {
279
282
  for (const contribution of plugin.nodeTypes ?? [])
280
283
  nodeTypes.set(contribution.kind, contribution);
281
284
  for (const prefab of plugin.prefabs ?? [])
282
285
  prefabs.set(prefab.name, prefab);
286
+ for (const contribution of plugin.filterKinds ?? [])
287
+ filterKinds.set(contribution.kind, contribution);
283
288
  }
284
289
  return {
285
290
  nodeType: (kind) => nodeTypes.get(kind),
286
291
  prefab: (name) => prefabs.get(name),
292
+ filterKind: (kind) => filterKinds.get(kind),
287
293
  kinds: () => [...nodeTypes.keys()],
288
294
  prefabNames: () => [...prefabs.keys()],
295
+ filterKindNames: () => [...filterKinds.keys()],
296
+ palette() {
297
+ const entries = [];
298
+ for (const c of nodeTypes.values()) {
299
+ if (!c.defaults)
300
+ continue; // hide dispatch-only kinds (prefab)
301
+ entries.push({ kind: c.kind, label: c.defaults.label ?? c.kind, isPrefab: false, agentDoc: c.agentDoc, defaults: c.defaults });
302
+ }
303
+ for (const p of prefabs.values()) {
304
+ entries.push({
305
+ kind: p.name,
306
+ label: p.defaults?.label ?? p.name,
307
+ isPrefab: true,
308
+ agentDoc: p.agentDoc,
309
+ defaults: p.defaults ?? {},
310
+ });
311
+ }
312
+ return entries;
313
+ },
289
314
  };
290
315
  }
291
316
 
@@ -2923,13 +2948,14 @@ function emptyBoard(cfg) {
2923
2948
  // Built-in node contributions: the core vocabulary every scene doc can rely on.
2924
2949
  // Everything else (ropes, meters, particles, game HUDs) arrives as a ScenePlugin through
2925
2950
  // the same contribution shape — built-ins get no special powers.
2926
- const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
2951
+ const num$1 = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
2927
2952
  const str = (v, fallback) => (typeof v === 'string' ? v : fallback);
2928
2953
  // ── container / layer ───────────────────────────────────────────────────────────────
2929
2954
  function containerContribution(kind) {
2930
2955
  return {
2931
2956
  kind,
2932
2957
  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.',
2958
+ defaults: { label: kind === 'layer' ? 'Layer' : 'Container', props: {} },
2933
2959
  create(node) {
2934
2960
  const view = new Container();
2935
2961
  return {
@@ -2941,7 +2967,7 @@ function containerContribution(kind) {
2941
2967
  : { width: 0, height: 0 };
2942
2968
  },
2943
2969
  applyProps(props) {
2944
- view.alpha = num(props.alpha, 1);
2970
+ view.alpha = num$1(props.alpha, 1);
2945
2971
  },
2946
2972
  };
2947
2973
  },
@@ -2951,18 +2977,23 @@ function containerContribution(kind) {
2951
2977
  const rectContribution = {
2952
2978
  kind: 'rect',
2953
2979
  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}.',
2980
+ defaults: {
2981
+ label: 'Rectangle',
2982
+ props: { width: 200, height: 120, fill: '#3355aa', radius: 12 },
2983
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, anchor: [0.5, 0.5] },
2984
+ },
2954
2985
  create(node) {
2955
2986
  const g = new Graphics();
2956
2987
  let size = { width: 0, height: 0 };
2957
2988
  const draw = (props) => {
2958
- const width = num(props.width, 100);
2959
- const height = num(props.height, 100);
2960
- const radius = num(props.radius, 0);
2989
+ const width = num$1(props.width, 100);
2990
+ const height = num$1(props.height, 100);
2991
+ const radius = num$1(props.radius, 0);
2961
2992
  size = { width, height };
2962
2993
  g.clear();
2963
2994
  g.roundRect(0, 0, width, height, radius).fill({
2964
2995
  color: str(props.fill, '#ffffff'),
2965
- alpha: num(props.alpha, 1),
2996
+ alpha: num$1(props.alpha, 1),
2966
2997
  });
2967
2998
  const stroke = props.stroke;
2968
2999
  if (stroke) {
@@ -3002,7 +3033,7 @@ function spriteInstance(node, ctx) {
3002
3033
  // Visual props go on the wrapper (the node's view) so tint/alpha are observable and
3003
3034
  // patchable on the node itself; the inner sprite only carries texture + flip.
3004
3035
  wrapper.tint = p.tint ?? 0xffffff;
3005
- wrapper.alpha = num(p.alpha, 1);
3036
+ wrapper.alpha = num$1(p.alpha, 1);
3006
3037
  const flip = p.flipX === true;
3007
3038
  sprite.scale.x = flip ? -1 : 1;
3008
3039
  sprite.position.x = flip ? sprite.texture.width : 0;
@@ -3017,6 +3048,11 @@ function spriteInstance(node, ctx) {
3017
3048
  const spriteContribution = {
3018
3049
  kind: 'sprite',
3019
3050
  agentDoc: 'Static art. Props: src (texture alias), tint, alpha. Natural size = texture size; use layout to place/scale.',
3051
+ defaults: {
3052
+ label: 'Sprite',
3053
+ props: {},
3054
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, anchor: [0.5, 0.5] },
3055
+ },
3020
3056
  create: spriteInstance,
3021
3057
  };
3022
3058
  const reelFrameContribution = {
@@ -3028,17 +3064,22 @@ const reelFrameContribution = {
3028
3064
  const textContribution = {
3029
3065
  kind: 'text',
3030
3066
  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.',
3067
+ defaults: {
3068
+ label: 'Text',
3069
+ props: { text: 'Text', fontSize: 48, fill: '#ffffff', fontWeight: 'bold' },
3070
+ layout: { mode: 'viewport-fraction', xFrac: 0.5, yFrac: 0.5, anchor: [0.5, 0.5] },
3071
+ },
3031
3072
  create(node) {
3032
3073
  const props = node.props ?? {};
3033
3074
  const view = new Text({
3034
3075
  text: str(props.text, ''),
3035
3076
  style: {
3036
3077
  fontFamily: str(props.fontFamily, 'Arial, sans-serif'),
3037
- fontSize: num(props.fontSize, 32),
3078
+ fontSize: num$1(props.fontSize, 32),
3038
3079
  fill: props.fill ?? '#ffffff',
3039
3080
  fontWeight: props.fontWeight ?? 'normal',
3040
3081
  align: props.align ?? 'left',
3041
- letterSpacing: num(props.letterSpacing, 0),
3082
+ letterSpacing: num$1(props.letterSpacing, 0),
3042
3083
  },
3043
3084
  });
3044
3085
  return {
@@ -3048,9 +3089,9 @@ const textContribution = {
3048
3089
  if (p.text !== undefined)
3049
3090
  view.text = str(p.text, '');
3050
3091
  view.style.fill = p.fill ?? '#ffffff';
3051
- view.style.fontSize = num(p.fontSize, 32);
3052
- view.style.letterSpacing = num(p.letterSpacing, 0);
3053
- view.alpha = num(p.alpha, 1);
3092
+ view.style.fontSize = num$1(p.fontSize, 32);
3093
+ view.style.letterSpacing = num$1(p.letterSpacing, 0);
3094
+ view.alpha = num$1(p.alpha, 1);
3054
3095
  },
3055
3096
  };
3056
3097
  },
@@ -3066,8 +3107,8 @@ const animatedSpriteContribution = {
3066
3107
  const apply = (p) => {
3067
3108
  const sheet = p.sheet;
3068
3109
  const alias = str(sheet?.alias, '');
3069
- const cols = num(sheet?.cols, 1);
3070
- const rows = num(sheet?.rows, 1);
3110
+ const cols = num$1(sheet?.cols, 1);
3111
+ const rows = num$1(sheet?.rows, 1);
3071
3112
  const full = alias ? ctx.texture(alias) : Texture.EMPTY;
3072
3113
  const frames = [];
3073
3114
  if (full !== Texture.EMPTY && cols > 0 && rows > 0) {
@@ -3081,14 +3122,14 @@ const animatedSpriteContribution = {
3081
3122
  }
3082
3123
  animated?.destroy();
3083
3124
  animated = new AnimatedSprite(frames.length ? frames : [Texture.EMPTY]);
3084
- animated.animationSpeed = num(p.fps, 12) / 60;
3125
+ animated.animationSpeed = num$1(p.fps, 12) / 60;
3085
3126
  animated.loop = true;
3086
- const frame = Math.min(num(p.frame, 0), animated.totalFrames - 1);
3127
+ const frame = Math.min(num$1(p.frame, 0), animated.totalFrames - 1);
3087
3128
  if (p.playing === true)
3088
3129
  animated.gotoAndPlay(Math.max(0, frame));
3089
3130
  else
3090
3131
  animated.gotoAndStop(Math.max(0, frame));
3091
- animated.alpha = num(p.alpha, 1);
3132
+ animated.alpha = num$1(p.alpha, 1);
3092
3133
  view.removeChildren();
3093
3134
  view.addChild(animated);
3094
3135
  size = { width: animated.textures.length ? animated.textures[0].width : 0, height: animated.textures.length ? animated.textures[0].height : 0 };
@@ -3129,17 +3170,21 @@ const reelGridContribution = {
3129
3170
  system.setBoard(board.map((column) => column.map((cell) => (typeof cell === 'string' ? { symbol: cell } : cell))));
3130
3171
  };
3131
3172
  setBoard(props.board);
3132
- // The reel system's local content does not start at (0,0) (cells/decoration extend
3133
- // around the geometry origin), so align it inside a wrapper: measure() re-reads the
3134
- // real local bounds each layout and keeps the wrapper's (0,0) = content top-left.
3173
+ // The reel system's local content does not start at (0,0) (cells center on geometry
3174
+ // positions), so align it inside a wrapper. The origin comes from the STATIC geometry
3175
+ // (first cell center half cell), never from getLocalBounds animated cells hang
3176
+ // far outside the window mid-spin and a bounds-based origin would drift the grid.
3135
3177
  const wrapper = new Container();
3136
3178
  wrapper.addChild(system.view);
3137
- let contentOrigin = { x: 0, y: 0 };
3179
+ const contentOrigin = { x: 0, y: 0 };
3138
3180
  const syncContent = () => {
3139
- const lb = system.view.getLocalBounds();
3140
- contentOrigin = { x: lb.x, y: lb.y };
3141
- system.view.position.set(-lb.x, -lb.y);
3142
- return { width: lb.width, height: lb.height };
3181
+ const geom = system.grid.geometry;
3182
+ const first = system.grid.cellPosition(0, 0);
3183
+ const cell = system.config.grid.cellSize;
3184
+ contentOrigin.x = first.x - cell / 2;
3185
+ contentOrigin.y = first.y - cell / 2;
3186
+ system.view.position.set(-contentOrigin.x, -contentOrigin.y);
3187
+ return { width: geom.gridW, height: geom.gridH };
3143
3188
  };
3144
3189
  const instance = {
3145
3190
  view: wrapper,
@@ -3170,6 +3215,19 @@ const prefabContribution = {
3170
3215
  return prefab.create(node.props ?? {}, ctx);
3171
3216
  },
3172
3217
  };
3218
+ /** Core filter kinds; heavier looks (bloom etc.) arrive as plugins carrying their deps. */
3219
+ const BUILTIN_FILTER_KINDS = [
3220
+ {
3221
+ kind: 'blur',
3222
+ agentDoc: 'Gaussian blur: {kind:"blur", strength, quality}. Shadow/glow layers idiom.',
3223
+ create(spec) {
3224
+ return new BlurFilter({
3225
+ strength: typeof spec.strength === 'number' ? spec.strength : 4,
3226
+ quality: typeof spec.quality === 'number' ? spec.quality : 3,
3227
+ });
3228
+ },
3229
+ },
3230
+ ];
3173
3231
  const BUILTIN_NODE_TYPES = [
3174
3232
  containerContribution('container'),
3175
3233
  containerContribution('layer'),
@@ -3261,7 +3319,7 @@ function validateSceneDoc(doc, registry) {
3261
3319
  const MAX_LAYOUT_PASSES = 5;
3262
3320
  function createSceneFromDoc(doc, opts = {}) {
3263
3321
  const log = opts.log ?? ((msg) => console.warn(`[scene] ${msg}`));
3264
- const registry = createSceneRegistry(opts.plugins ?? [], BUILTIN_NODE_TYPES);
3322
+ const registry = createSceneRegistry(opts.plugins ?? [], BUILTIN_NODE_TYPES, BUILTIN_FILTER_KINDS);
3265
3323
  const errors = validateSceneDoc(doc, registry);
3266
3324
  if (errors.length > 0) {
3267
3325
  const detail = errors.map((e) => (e.nodeId ? `[${e.nodeId}] ${e.message}` : e.message)).join('\n ');
@@ -3296,6 +3354,27 @@ function createSceneFromDoc(doc, opts = {}) {
3296
3354
  throw new Error(`scene node "${node.id}" (${node.type}) failed to create: ${err.message}`);
3297
3355
  }
3298
3356
  parentView.addChild(instance.view);
3357
+ if (node.filters?.length) {
3358
+ const filters = node.filters
3359
+ .map((spec) => {
3360
+ const contribution = registry.filterKind(spec.kind);
3361
+ if (!contribution) {
3362
+ warnOnce(`filter:${spec.kind}`, `unknown filter kind "${spec.kind}" on "${node.id}" — no plugin contributes it`);
3363
+ return null;
3364
+ }
3365
+ try {
3366
+ return contribution.create(spec);
3367
+ }
3368
+ catch (err) {
3369
+ // Filters need a GPU context — degrade gracefully in headless runs.
3370
+ warnOnce(`filter-fail:${spec.kind}`, `filter "${spec.kind}" failed to create: ${err.message}`);
3371
+ return null;
3372
+ }
3373
+ })
3374
+ .filter((f) => f !== null);
3375
+ if (filters.length)
3376
+ instance.view.filters = filters;
3377
+ }
3299
3378
  const runtime = {
3300
3379
  node,
3301
3380
  instance,
@@ -3464,8 +3543,15 @@ function createSceneFromDoc(doc, opts = {}) {
3464
3543
  const eff = effectiveNode(runtime.node, orientation, runtime.state);
3465
3544
  const natural = runtime.instance.measure();
3466
3545
  const rule = eff.layout;
3546
+ // `space:'viewport'` resolves against the live viewport even inside a nested
3547
+ // space, then converts into the space's coordinates (screen-pin at a space depth).
3548
+ const viewportSpace = rule?.space === 'viewport' && frame !== null;
3549
+ if (viewportSpace && !frame.bounds) {
3550
+ next.push(runtime); // need the space root's global rect first
3551
+ continue;
3552
+ }
3467
3553
  const resolution = rule
3468
- ? resolveLayoutRule(rule, natural, layoutCtx(frame))
3554
+ ? resolveLayoutRule(rule, natural, layoutCtx(viewportSpace ? null : frame))
3469
3555
  : {
3470
3556
  placement: {
3471
3557
  x: parentOriginX,
@@ -3480,7 +3566,21 @@ function createSceneFromDoc(doc, opts = {}) {
3480
3566
  next.push(runtime);
3481
3567
  continue;
3482
3568
  }
3483
- const p = resolution.placement;
3569
+ let p = resolution.placement;
3570
+ if (viewportSpace) {
3571
+ if (frameOf(frame) !== null) {
3572
+ warnOnce(`vspace:${runtime.node.id}`, `space:'viewport' inside a nested space is unsupported ("${runtime.node.id}")`);
3573
+ }
3574
+ else {
3575
+ p = {
3576
+ ...p,
3577
+ x: (p.x - frame.bounds.x) / frame.worldScaleX,
3578
+ y: (p.y - frame.bounds.y) / frame.worldScaleY,
3579
+ scaleX: p.scaleX / frame.worldScaleX,
3580
+ scaleY: p.scaleY / frame.worldScaleY,
3581
+ };
3582
+ }
3583
+ }
3484
3584
  const view = runtime.instance.view;
3485
3585
  view.position.set((p.x - parentOriginX) / parentScaleX, (p.y - parentOriginY) / parentScaleY);
3486
3586
  view.scale.set(p.scaleX / parentScaleX, p.scaleY / parentScaleY);
@@ -3538,6 +3638,61 @@ function createSceneFromDoc(doc, opts = {}) {
3538
3638
  }
3539
3639
  return undefined;
3540
3640
  };
3641
+ /** Locate a node's parent doc-node + its index in that parent's children. */
3642
+ const locate = (id) => {
3643
+ const walk = (node) => {
3644
+ const kids = node.children ?? [];
3645
+ for (let i = 0; i < kids.length; i++) {
3646
+ if (kids[i].id === id)
3647
+ return { parent: node, index: i };
3648
+ const hit = walk(kids[i]);
3649
+ if (hit)
3650
+ return hit;
3651
+ }
3652
+ return undefined;
3653
+ };
3654
+ return walk(doc.root);
3655
+ };
3656
+ const collectIds = (node, out = []) => {
3657
+ out.push(node.id);
3658
+ for (const child of node.children ?? [])
3659
+ collectIds(child, out);
3660
+ return out;
3661
+ };
3662
+ /** Deep-clone a subtree, suffixing every id/anchorName so it stays unique in the doc. */
3663
+ const cloneWithSuffix = (node, suffix) => {
3664
+ const clone = JSON.parse(JSON.stringify(node));
3665
+ const remap = (n) => {
3666
+ n.id = `${n.id}${suffix}`;
3667
+ if (n.anchorName)
3668
+ n.anchorName = `${n.anchorName}${suffix}`;
3669
+ n.children?.forEach(remap);
3670
+ };
3671
+ remap(clone);
3672
+ return clone;
3673
+ };
3674
+ /** Build a freshly-inserted subtree under an existing parent runtime, at a child index. */
3675
+ const buildInto = (node, parentRuntime, index) => {
3676
+ const before = parentRuntime.instance.view.children.length;
3677
+ build(node, parentRuntime, parentRuntime.instance.view);
3678
+ // build appended to the end — move the new view to the requested child index.
3679
+ const view = runtimes.get(node.id).instance.view;
3680
+ const clampedIndex = Math.max(0, Math.min(index, before));
3681
+ parentRuntime.instance.view.setChildIndex(view, clampedIndex);
3682
+ };
3683
+ const destroyRuntimeTree = (id) => {
3684
+ const runtime = runtimes.get(id);
3685
+ if (!runtime)
3686
+ return;
3687
+ for (const descendantId of collectIds(runtime.node)) {
3688
+ const r = runtimes.get(descendantId);
3689
+ if (r) {
3690
+ r.instance.destroy?.();
3691
+ runtimes.delete(descendantId);
3692
+ }
3693
+ }
3694
+ runtime.instance.view.removeFromParent();
3695
+ };
3541
3696
  const setVar = (name, value) => {
3542
3697
  vars[name] = value;
3543
3698
  relayout();
@@ -3581,6 +3736,72 @@ function createSceneFromDoc(doc, opts = {}) {
3581
3736
  return setState(patch.id, patch.state);
3582
3737
  case 'set-var':
3583
3738
  return setVar(patch.name, patch.value);
3739
+ case 'add-node': {
3740
+ const parentRuntime = runtimes.get(patch.parent);
3741
+ if (!parentRuntime)
3742
+ return warnOnce(`add:${patch.parent}`, `add-node: unknown parent "${patch.parent}"`);
3743
+ const clash = collectIds(patch.node).find((id) => runtimes.has(id));
3744
+ if (clash)
3745
+ return warnOnce(`add-clash:${clash}`, `add-node: id "${clash}" already exists`);
3746
+ if (!registry.nodeType(patch.node.type)) {
3747
+ return warnOnce(`add-kind:${patch.node.type}`, `add-node: unknown type "${patch.node.type}"`);
3748
+ }
3749
+ (parentRuntime.node.children ??= []).splice(patch.index ?? parentRuntime.node.children.length, 0, patch.node);
3750
+ buildInto(patch.node, parentRuntime, patch.index ?? Number.MAX_SAFE_INTEGER);
3751
+ wireMasks();
3752
+ break;
3753
+ }
3754
+ case 'remove-node': {
3755
+ if (patch.id === doc.root.id)
3756
+ return warnOnce('rm-root', 'remove-node: cannot remove the root');
3757
+ const loc = locate(patch.id);
3758
+ if (!loc)
3759
+ return warnOnce(`rm:${patch.id}`, `remove-node: unknown node "${patch.id}"`);
3760
+ destroyRuntimeTree(patch.id);
3761
+ loc.parent.children.splice(loc.index, 1);
3762
+ wireMasks();
3763
+ break;
3764
+ }
3765
+ case 'move-node': {
3766
+ if (patch.id === doc.root.id)
3767
+ return warnOnce('mv-root', 'move-node: cannot move the root');
3768
+ const loc = locate(patch.id);
3769
+ const source = findNode(doc.root, patch.id);
3770
+ const parentRuntime = runtimes.get(patch.parent);
3771
+ if (!loc || !source)
3772
+ return warnOnce(`mv:${patch.id}`, `move-node: unknown node "${patch.id}"`);
3773
+ if (!parentRuntime)
3774
+ return warnOnce(`mv-p:${patch.parent}`, `move-node: unknown parent "${patch.parent}"`);
3775
+ if (collectIds(source).includes(patch.parent)) {
3776
+ return warnOnce(`mv-cycle:${patch.id}`, `move-node: cannot move "${patch.id}" into itself/its descendant "${patch.parent}"`);
3777
+ }
3778
+ // `index` is the FINAL position in the destination's children (post-removal).
3779
+ const [node] = loc.parent.children.splice(loc.index, 1);
3780
+ destroyRuntimeTree(patch.id); // rebuild the moved subtree under the new parent
3781
+ const dest = (parentRuntime.node.children ??= []);
3782
+ const targetIndex = Math.max(0, Math.min(patch.index ?? dest.length, dest.length));
3783
+ dest.splice(targetIndex, 0, node);
3784
+ buildInto(node, parentRuntime, targetIndex);
3785
+ wireMasks();
3786
+ break;
3787
+ }
3788
+ case 'duplicate-node': {
3789
+ if (patch.id === doc.root.id)
3790
+ return warnOnce('dup-root', 'duplicate-node: cannot duplicate the root');
3791
+ const loc = locate(patch.id);
3792
+ const source = findNode(doc.root, patch.id);
3793
+ if (!loc || !source)
3794
+ return warnOnce(`dup:${patch.id}`, `duplicate-node: unknown node "${patch.id}"`);
3795
+ let suffix = '-copy';
3796
+ while (collectIds(source).some((id) => runtimes.has(`${id}${suffix}`)))
3797
+ suffix += '2';
3798
+ const clone = cloneWithSuffix(source, suffix);
3799
+ const parentRuntime = runtimes.get(loc.parent.id);
3800
+ loc.parent.children.splice(loc.index + 1, 0, clone);
3801
+ buildInto(clone, parentRuntime, loc.index + 1);
3802
+ wireMasks();
3803
+ break;
3804
+ }
3584
3805
  }
3585
3806
  relayout();
3586
3807
  };
@@ -3601,6 +3822,12 @@ function createSceneFromDoc(doc, opts = {}) {
3601
3822
  setState,
3602
3823
  patch: applyPatch,
3603
3824
  doc: () => doc,
3825
+ palette: () => registry.palette(),
3826
+ resetProps() {
3827
+ for (const runtime of runtimes.values())
3828
+ runtime.appliedProps = 'stale';
3829
+ relayout();
3830
+ },
3604
3831
  destroy() {
3605
3832
  for (const runtime of runtimes.values())
3606
3833
  runtime.instance.destroy?.();
@@ -3610,5 +3837,1107 @@ function createSceneFromDoc(doc, opts = {}) {
3610
3837
  };
3611
3838
  }
3612
3839
 
3613
- export { BUILTIN_NODE_TYPES, createSceneFromDoc, createSceneRegistry, dependencyOf, edgePoint, effectiveNode, evalVisibleWhen, orientationOf, placementBounds, resolveLayoutRule, validateSceneDoc };
3840
+ // Inner-hollow calibrator gizmo: drag the four edges of a frame's declared hollow
3841
+ // (props.inner fractions) directly on the canvas. Every drag is a set-props ScenePatch,
3842
+ // so the grid bound with frame-fraction use:'inner' re-fits LIVE while you drag — this
3843
+ // replaces the hand-measured FRAME_INNER/FRAME_CUT constants every studied game carries.
3844
+ const MIN_FRAC = 0.05;
3845
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
3846
+ /** Pure edge-drag math: `frac` is the pointer position normalized to the frame rect axis. */
3847
+ function dragInnerEdge(inner, edge, frac) {
3848
+ const right = inner.left + inner.width;
3849
+ const bottom = inner.top + inner.height;
3850
+ switch (edge) {
3851
+ case 'left': {
3852
+ const left = clamp(frac, 0, right - MIN_FRAC);
3853
+ return { ...inner, left, width: right - left };
3854
+ }
3855
+ case 'right': {
3856
+ const r = clamp(frac, inner.left + MIN_FRAC, 1);
3857
+ return { ...inner, width: r - inner.left };
3858
+ }
3859
+ case 'top': {
3860
+ const top = clamp(frac, 0, bottom - MIN_FRAC);
3861
+ return { ...inner, top, height: bottom - top };
3862
+ }
3863
+ case 'bottom': {
3864
+ const b = clamp(frac, inner.top + MIN_FRAC, 1);
3865
+ return { ...inner, height: b - inner.top };
3866
+ }
3867
+ }
3868
+ }
3869
+ const HANDLE = 14;
3870
+ function createInnerCalibrator(opts) {
3871
+ const { handle, nodeId } = opts;
3872
+ const applyPatch = opts.applyPatch ?? handle.patch;
3873
+ const view = new Container();
3874
+ const g = new Graphics();
3875
+ view.addChild(g);
3876
+ const handles = new Map();
3877
+ for (const edge of ['left', 'right', 'top', 'bottom']) {
3878
+ const h = new Graphics();
3879
+ h.eventMode = 'static';
3880
+ h.cursor = edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
3881
+ h.on('pointerdown', (e) => {
3882
+ e.stopPropagation();
3883
+ dragging = edge;
3884
+ // Capture moves everywhere only WHILE dragging — otherwise stay click-through.
3885
+ view.hitArea = new Rectangle(-1e6, -1e6, 2e6, 2e6);
3886
+ });
3887
+ handles.set(edge, h);
3888
+ view.addChild(h);
3889
+ }
3890
+ let dragging = null;
3891
+ const docInner = () => {
3892
+ const walk = (n) => {
3893
+ if (n.id === nodeId)
3894
+ return n;
3895
+ for (const c of n.children ?? []) {
3896
+ const hit = walk(c);
3897
+ if (hit)
3898
+ return hit;
3899
+ }
3900
+ return undefined;
3901
+ };
3902
+ const inner = walk(handle.doc().root)?.props?.inner;
3903
+ return inner && typeof inner.left === 'number' ? inner : undefined;
3904
+ };
3905
+ const frameBounds = () => {
3906
+ const target = handle.node(nodeId);
3907
+ if (!target)
3908
+ return undefined;
3909
+ const b = target.getBounds();
3910
+ return new Rectangle(b.x, b.y, b.width, b.height);
3911
+ };
3912
+ const refresh = () => {
3913
+ g.clear();
3914
+ const bounds = frameBounds();
3915
+ const inner = docInner();
3916
+ if (!bounds || !inner) {
3917
+ for (const h of handles.values())
3918
+ h.clear();
3919
+ return;
3920
+ }
3921
+ const rx = bounds.x + inner.left * bounds.width;
3922
+ const ry = bounds.y + inner.top * bounds.height;
3923
+ const rw = inner.width * bounds.width;
3924
+ const rh = inner.height * bounds.height;
3925
+ g.rect(bounds.x, bounds.y, bounds.width, bounds.height).stroke({ color: 0xffa02f, width: 1, alpha: 0.5 });
3926
+ g.rect(rx, ry, rw, rh).fill({ color: 0x4dd0ff, alpha: 0.08 });
3927
+ g.rect(rx, ry, rw, rh).stroke({ color: 0x4dd0ff, width: 2, alpha: 0.9 });
3928
+ const mid = {
3929
+ left: [rx, ry + rh / 2],
3930
+ right: [rx + rw, ry + rh / 2],
3931
+ top: [rx + rw / 2, ry],
3932
+ bottom: [rx + rw / 2, ry + rh],
3933
+ };
3934
+ for (const [edge, h] of handles) {
3935
+ const [cx, cy] = mid[edge];
3936
+ h.clear();
3937
+ h.rect(cx - HANDLE / 2, cy - HANDLE / 2, HANDLE, HANDLE).fill({ color: 0x4dd0ff });
3938
+ h.rect(cx - HANDLE / 2, cy - HANDLE / 2, HANDLE, HANDLE).stroke({ color: 0x06283d, width: 2 });
3939
+ h.hitArea = new Rectangle(cx - HANDLE, cy - HANDLE, HANDLE * 2, HANDLE * 2);
3940
+ }
3941
+ };
3942
+ view.eventMode = 'static';
3943
+ view.on('pointermove', (e) => {
3944
+ if (!dragging)
3945
+ return;
3946
+ const bounds = frameBounds();
3947
+ const inner = docInner();
3948
+ if (!bounds || !inner)
3949
+ return;
3950
+ const frac = dragging === 'left' || dragging === 'right'
3951
+ ? (e.global.x - bounds.x) / bounds.width
3952
+ : (e.global.y - bounds.y) / bounds.height;
3953
+ const next = dragInnerEdge(inner, dragging, Math.round(frac * 1000) / 1000);
3954
+ applyPatch({ op: 'set-props', id: nodeId, props: { inner: next } });
3955
+ opts.onChange?.(next);
3956
+ refresh();
3957
+ });
3958
+ const endDrag = () => {
3959
+ dragging = null;
3960
+ view.hitArea = null;
3961
+ };
3962
+ view.on('pointerup', endDrag);
3963
+ view.on('pointerupoutside', endDrag);
3964
+ refresh();
3965
+ return {
3966
+ view,
3967
+ refresh,
3968
+ destroy() {
3969
+ view.destroy({ children: true });
3970
+ },
3971
+ };
3972
+ }
3973
+
3974
+ // Pure transform math for the canvas gizmo: turn a drag into a new LayoutRule, writing
3975
+ // the RIGHT field for the rule's kind — the thing the user asked for ("resize on the
3976
+ // canvas, not just the sidebar"). Resize maps to a rule's natural size lever:
3977
+ // • widthFrac/heightFrac (viewport-fraction) or wFrac/hFrac (frame-fraction) — per-axis;
3978
+ // • otherwise a uniform `scale` (absolute / cover / grid-cell / pin / plain fraction).
3979
+ // The engine re-places the node by its own anchor after the lever changes, so the anchor
3980
+ // stays pinned for free — no position juggling here. Rotation writes `rotation`.
3981
+ /** Which size lever a rule exposes — decides how a resize drag is interpreted. */
3982
+ function sizeLever(rule) {
3983
+ if (rule.mode === 'viewport-fraction' && (rule.widthFrac !== undefined || rule.heightFrac !== undefined))
3984
+ return 'wh-frac';
3985
+ if (rule.mode === 'frame-fraction' && (rule.wFrac !== undefined || rule.hFrac !== undefined))
3986
+ return 'wh-box';
3987
+ if (rule.mode === 'frame-fraction')
3988
+ return 'none'; // point placement inside a frame — no size
3989
+ return 'scale';
3990
+ }
3991
+ const clampFactor = (f) => (Number.isFinite(f) && f > 0.01 ? f : 0.01);
3992
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
3993
+ /**
3994
+ * Multiply a rule's size lever by (factorX, factorY). Corners pass equal factors (uniform);
3995
+ * edges pass 1 on the untouched axis. Uniform levers (`scale`) use factorX.
3996
+ */
3997
+ function resizeRule(rule, factorX, factorY) {
3998
+ const fx = clampFactor(factorX);
3999
+ const fy = clampFactor(factorY);
4000
+ const next = JSON.parse(JSON.stringify(rule));
4001
+ switch (sizeLever(rule)) {
4002
+ case 'wh-frac': {
4003
+ const r = next;
4004
+ if (r.widthFrac !== undefined)
4005
+ r.widthFrac = round4(r.widthFrac * fx);
4006
+ if (r.heightFrac !== undefined)
4007
+ r.heightFrac = round4(r.heightFrac * fy);
4008
+ break;
4009
+ }
4010
+ case 'wh-box': {
4011
+ const r = next;
4012
+ if (r.wFrac !== undefined)
4013
+ r.wFrac = round4(r.wFrac * fx);
4014
+ if (r.hFrac !== undefined)
4015
+ r.hFrac = round4(r.hFrac * fy);
4016
+ break;
4017
+ }
4018
+ case 'scale': {
4019
+ const r = next;
4020
+ r.scale = round4((r.scale ?? 1) * fx);
4021
+ break;
4022
+ }
4023
+ }
4024
+ return next;
4025
+ }
4026
+ function setRuleRotation(rule, radians) {
4027
+ const next = JSON.parse(JSON.stringify(rule));
4028
+ next.rotation = round4(radians);
4029
+ return next;
4030
+ }
4031
+ /** Nudge a rule's final pixel offset by a screen delta already converted to design units. */
4032
+ function nudgeRule(rule, dxDesign, dyDesign) {
4033
+ const next = JSON.parse(JSON.stringify(rule));
4034
+ next.pxNudge = { x: Math.round((next.pxNudge?.x ?? 0) + dxDesign), y: Math.round((next.pxNudge?.y ?? 0) + dyDesign) };
4035
+ return next;
4036
+ }
4037
+ /** Screen position of each handle for a bounding box. */
4038
+ function handlePoint(box, id) {
4039
+ const { x, y, width: w, height: h } = box;
4040
+ const cx = x + w / 2;
4041
+ const cy = y + h / 2;
4042
+ switch (id) {
4043
+ case 'nw': return { x, y };
4044
+ case 'n': return { x: cx, y };
4045
+ case 'ne': return { x: x + w, y };
4046
+ case 'e': return { x: x + w, y: cy };
4047
+ case 'se': return { x: x + w, y: y + h };
4048
+ case 's': return { x: cx, y: y + h };
4049
+ case 'sw': return { x, y: y + h };
4050
+ case 'w': return { x, y: cy };
4051
+ case 'rotate': return { x: cx, y: y - 28 };
4052
+ }
4053
+ }
4054
+ /**
4055
+ * Resize factors from dragging a handle, using the OPPOSITE edge/corner as the fixed
4056
+ * reference — the intuitive "grab a corner, drag out, it grows" feel.
4057
+ */
4058
+ function resizeFactors(startBox, handle, pointer) {
4059
+ const left = startBox.x;
4060
+ const right = startBox.x + startBox.width;
4061
+ const top = startBox.y;
4062
+ const bottom = startBox.y + startBox.height;
4063
+ const w = startBox.width || 1;
4064
+ const h = startBox.height || 1;
4065
+ const westX = () => Math.abs(pointer.x - right) / w; // dragging the west side, east fixed
4066
+ const eastX = () => Math.abs(pointer.x - left) / w;
4067
+ const northY = () => Math.abs(pointer.y - bottom) / h;
4068
+ const southY = () => Math.abs(pointer.y - top) / h;
4069
+ switch (handle) {
4070
+ case 'w': return { factorX: westX(), factorY: 1 };
4071
+ case 'e': return { factorX: eastX(), factorY: 1 };
4072
+ case 'n': return { factorX: 1, factorY: northY() };
4073
+ case 's': return { factorX: 1, factorY: southY() };
4074
+ case 'nw': {
4075
+ const f = uniform(westX(), northY());
4076
+ return { factorX: f, factorY: f };
4077
+ }
4078
+ case 'ne': {
4079
+ const f = uniform(eastX(), northY());
4080
+ return { factorX: f, factorY: f };
4081
+ }
4082
+ case 'sw': {
4083
+ const f = uniform(westX(), southY());
4084
+ return { factorX: f, factorY: f };
4085
+ }
4086
+ case 'se': {
4087
+ const f = uniform(eastX(), southY());
4088
+ return { factorX: f, factorY: f };
4089
+ }
4090
+ default: return { factorX: 1, factorY: 1 };
4091
+ }
4092
+ }
4093
+ /** Corner resize keeps aspect — use the diagonal ratio of the two per-axis factors. */
4094
+ function uniform(a, b) {
4095
+ return Math.sqrt(Math.max(0.0001, a) * Math.max(0.0001, b));
4096
+ }
4097
+
4098
+ // Transform gizmo — the on-canvas resize / rotate / move manipulator for the selected
4099
+ // node. Generalizes the frame calibrator: 8 resize handles + a rotate handle, and a body
4100
+ // drag to move. Every gesture emits ONE set-layout ScenePatch (orientation-aware), so a
4101
+ // mouse transform and an agent/inspector transform are the same operation. Live bounds are
4102
+ // re-read each frame, so the gizmo follows the node as the engine relayouts.
4103
+ const CORNERS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
4104
+ const HS = 9; // handle size
4105
+ function createTransformGizmo(opts) {
4106
+ const { handle, app } = opts;
4107
+ const applyPatch = opts.applyPatch ?? handle.patch;
4108
+ const getOrientation = opts.getOrientation ?? (() => 'landscape');
4109
+ // pxNudge lives in the placement (parent/space) coordinate system, which maps to screen
4110
+ // by the parent's world scale — so screen delta ÷ parent-world-scale = design units.
4111
+ const dpp = (id) => {
4112
+ if (opts.designPerPixel)
4113
+ return opts.designPerPixel(id);
4114
+ const parent = handle.node(id)?.parent;
4115
+ const s = parent?.worldTransform?.a ?? 1;
4116
+ return s !== 0 ? 1 / s : 1;
4117
+ };
4118
+ const layer = new Container();
4119
+ layer.eventMode = 'static';
4120
+ app.stage.addChild(layer);
4121
+ const frame = new Graphics();
4122
+ layer.addChild(frame);
4123
+ const handleGfx = new Map();
4124
+ for (const id of [...CORNERS, 'rotate']) {
4125
+ const g = new Graphics();
4126
+ g.eventMode = 'static';
4127
+ g.cursor = id === 'rotate' ? 'grab' : cursorFor(id);
4128
+ g.on('pointerdown', (e) => onDown(id, e));
4129
+ handleGfx.set(id, g);
4130
+ layer.addChild(g);
4131
+ }
4132
+ // A transparent body catcher for move-drags (below the handles).
4133
+ const body = new Graphics();
4134
+ body.eventMode = 'static';
4135
+ body.cursor = 'move';
4136
+ body.on('pointerdown', (e) => onDown('body', e));
4137
+ layer.addChildAt(body, 1);
4138
+ let nodeId = null;
4139
+ let drag = null;
4140
+ const findDocNode = (id, node = handle.doc().root) => {
4141
+ if (node.id === id)
4142
+ return node;
4143
+ for (const child of node.children ?? []) {
4144
+ const hit = findDocNode(id, child);
4145
+ if (hit)
4146
+ return hit;
4147
+ }
4148
+ return undefined;
4149
+ };
4150
+ /** Effective layout rule for the current orientation (base or portrait/landscape override). */
4151
+ const effectiveRule = (id) => {
4152
+ const node = findDocNode(id);
4153
+ if (!node)
4154
+ return {};
4155
+ const o = getOrientation();
4156
+ const override = node.responsive?.[o]?.layout;
4157
+ return override ? { rule: override, orientation: o } : { rule: node.layout };
4158
+ };
4159
+ const boxOf = (id) => {
4160
+ const view = handle.node(id);
4161
+ if (!view || !view.visible)
4162
+ return null;
4163
+ const b = view.getBounds();
4164
+ return { x: b.x, y: b.y, width: b.width, height: b.height };
4165
+ };
4166
+ const onDown = (h, e) => {
4167
+ e.stopPropagation();
4168
+ if (!nodeId)
4169
+ return;
4170
+ const box = boxOf(nodeId);
4171
+ const { rule, orientation } = effectiveRule(nodeId);
4172
+ if (!box || !rule)
4173
+ return;
4174
+ const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
4175
+ drag = {
4176
+ handle: h,
4177
+ startBox: box,
4178
+ baseRule: JSON.parse(JSON.stringify(rule)),
4179
+ orientation,
4180
+ start: { x: e.global.x, y: e.global.y },
4181
+ center,
4182
+ startAngle: Math.atan2(e.global.y - center.y, e.global.x - center.x),
4183
+ };
4184
+ };
4185
+ const onMove = (e) => {
4186
+ if (!drag || !nodeId)
4187
+ return;
4188
+ let rule;
4189
+ if (drag.handle === 'body') {
4190
+ const s = dpp(nodeId);
4191
+ rule = nudgeRule(drag.baseRule, (e.global.x - drag.start.x) * s, (e.global.y - drag.start.y) * s);
4192
+ }
4193
+ else if (drag.handle === 'rotate') {
4194
+ const angle = Math.atan2(e.global.y - drag.center.y, e.global.x - drag.center.x);
4195
+ rule = setRuleRotation(drag.baseRule, (drag.baseRule.rotation ?? 0) + (angle - drag.startAngle));
4196
+ }
4197
+ else {
4198
+ if (sizeLever(drag.baseRule) === 'none')
4199
+ return; // point-placed frame-fraction: nothing to size
4200
+ const { factorX, factorY } = resizeFactors(drag.startBox, drag.handle, { x: e.global.x, y: e.global.y });
4201
+ rule = resizeRule(drag.baseRule, factorX, factorY);
4202
+ }
4203
+ applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.orientation });
4204
+ };
4205
+ const onUp = () => {
4206
+ drag = null;
4207
+ };
4208
+ app.stage.on('pointermove', onMove);
4209
+ app.stage.on('pointerup', onUp);
4210
+ app.stage.on('pointerupoutside', onUp);
4211
+ const redraw = () => {
4212
+ const box = nodeId ? boxOf(nodeId) : null;
4213
+ frame.clear();
4214
+ body.clear();
4215
+ if (!box) {
4216
+ for (const g of handleGfx.values())
4217
+ g.clear();
4218
+ body.hitArea = null;
4219
+ return;
4220
+ }
4221
+ frame.rect(box.x, box.y, box.width, box.height).stroke({ color: 0x4dd0ff, width: 1.5, alpha: 0.9 });
4222
+ // rotate stem
4223
+ const rot = handlePoint(box, 'rotate');
4224
+ frame.moveTo(box.x + box.width / 2, box.y).lineTo(rot.x, rot.y).stroke({ color: 0x4dd0ff, width: 1, alpha: 0.6 });
4225
+ body.rect(box.x, box.y, box.width, box.height).fill({ color: 0xffffff, alpha: 0.001 });
4226
+ body.hitArea = new Rectangle(box.x, box.y, box.width, box.height);
4227
+ const sizable = sizeLever(nodeId ? effectiveRule(nodeId).rule ?? drag?.baseRule ?? {} : {}) !== 'none';
4228
+ for (const id of [...CORNERS, 'rotate']) {
4229
+ const g = handleGfx.get(id);
4230
+ g.clear();
4231
+ if (id !== 'rotate' && !sizable)
4232
+ continue; // hide resize handles when the rule can't size
4233
+ const p = handlePoint(box, id);
4234
+ if (id === 'rotate')
4235
+ g.circle(p.x, p.y, HS / 1.6).fill({ color: 0x4dd0ff }).stroke({ color: 0x06283d, width: 2 });
4236
+ else
4237
+ g.rect(p.x - HS / 2, p.y - HS / 2, HS, HS).fill({ color: 0x4dd0ff }).stroke({ color: 0x06283d, width: 2 });
4238
+ g.hitArea = new Rectangle(p.x - HS, p.y - HS, HS * 2, HS * 2);
4239
+ }
4240
+ };
4241
+ app.ticker.add(redraw);
4242
+ const fakeEvent = (x, y) => ({ global: { x, y }, stopPropagation() { } });
4243
+ return {
4244
+ attach(id) {
4245
+ nodeId = id;
4246
+ redraw();
4247
+ },
4248
+ refresh: redraw,
4249
+ drag(h, from, to) {
4250
+ onDown(h, fakeEvent(from.x, from.y));
4251
+ onMove(fakeEvent(to.x, to.y));
4252
+ onUp();
4253
+ },
4254
+ destroy() {
4255
+ app.ticker.remove(redraw);
4256
+ app.stage.off('pointermove', onMove);
4257
+ app.stage.off('pointerup', onUp);
4258
+ app.stage.off('pointerupoutside', onUp);
4259
+ layer.destroy({ children: true });
4260
+ },
4261
+ };
4262
+ }
4263
+ function cursorFor(id) {
4264
+ switch (id) {
4265
+ case 'nw':
4266
+ case 'se': return 'nwse-resize';
4267
+ case 'ne':
4268
+ case 'sw': return 'nesw-resize';
4269
+ case 'n':
4270
+ case 's': return 'ns-resize';
4271
+ case 'e':
4272
+ case 'w': return 'ew-resize';
4273
+ default: return 'default';
4274
+ }
4275
+ }
4276
+
4277
+ /**
4278
+ * Base class for all scenes.
4279
+ * Provides a root PixiJS Container and lifecycle hooks.
4280
+ *
4281
+ * @example
4282
+ * ```ts
4283
+ * class MenuScene extends Scene {
4284
+ * async onEnter() {
4285
+ * const bg = Sprite.from('menu-bg');
4286
+ * this.container.addChild(bg);
4287
+ * }
4288
+ *
4289
+ * onUpdate(dt: number) {
4290
+ * // per-frame logic
4291
+ * }
4292
+ *
4293
+ * onResize(width: number, height: number) {
4294
+ * // reposition UI
4295
+ * }
4296
+ * }
4297
+ * ```
4298
+ */
4299
+ class Scene {
4300
+ container;
4301
+ constructor() {
4302
+ this.container = new Container();
4303
+ this.container.label = this.constructor.name;
4304
+ }
4305
+ }
4306
+
4307
+ // Flow step registry — the same contribution shape as scene node types (§6.2): kind +
4308
+ // runtime executor + schema/agentDoc for tooling. Core steps are built-ins registered
4309
+ // through it; games and plugins add their own `do` kinds without touching the runner.
4310
+ function createFlowStepRegistry(plugins = [], builtins = []) {
4311
+ const steps = new Map();
4312
+ for (const contribution of builtins)
4313
+ steps.set(contribution.kind, contribution);
4314
+ for (const plugin of plugins) {
4315
+ for (const contribution of plugin.steps ?? [])
4316
+ steps.set(contribution.kind, contribution);
4317
+ }
4318
+ return {
4319
+ step: (kind) => steps.get(kind),
4320
+ kinds: () => [...steps.keys()],
4321
+ };
4322
+ }
4323
+
4324
+ // Built-in flow steps. Presentation writes (tween/setProps/countUp) target live
4325
+ // instances/views — never the scene doc (the doc stays the persistable SSOT); runtime
4326
+ // state changes (setVar/setState) go through the scene handle like any agent patch.
4327
+ const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
4328
+ function formatValue(value, format) {
4329
+ const rounded = Math.round(value);
4330
+ if (format === 'space')
4331
+ return String(rounded).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
4332
+ return String(rounded);
4333
+ }
4334
+ const tweenStep = {
4335
+ kind: 'tween',
4336
+ 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.',
4337
+ async run(raw, rt) {
4338
+ const step = raw;
4339
+ const view = rt.view(step.node);
4340
+ if (!view)
4341
+ return rt.log(`tween: unknown node "${step.node}"`);
4342
+ const to = {};
4343
+ for (const [key, value] of Object.entries(step.to))
4344
+ to[key] = num(rt.resolve(value), 0);
4345
+ const applyFinal = () => {
4346
+ for (const [key, value] of Object.entries(to)) {
4347
+ if (key === 'scale')
4348
+ view.scale.set(value);
4349
+ else
4350
+ view[key] = value;
4351
+ }
4352
+ };
4353
+ if (rt.skipped || rt.instant)
4354
+ return applyFinal();
4355
+ const { scale, ...rest } = to;
4356
+ const jobs = [];
4357
+ if (Object.keys(rest).length > 0) {
4358
+ jobs.push(Tween.to(view, rest, step.ms / rt.turbo, easingByName(step.ease)));
4359
+ }
4360
+ if (scale !== undefined) {
4361
+ jobs.push(Tween.to(view.scale, { x: scale, y: scale }, step.ms / rt.turbo, easingByName(step.ease)));
4362
+ }
4363
+ // A skip during flight must land on the end state: race the tween against skip-release.
4364
+ await Promise.race([Promise.all(jobs), rt.wait(step.ms + 50)]);
4365
+ if (rt.skipped) {
4366
+ Tween.killTweensOf(view);
4367
+ Tween.killTweensOf(view.scale);
4368
+ applyFinal();
4369
+ }
4370
+ },
4371
+ };
4372
+ const soundStep = {
4373
+ kind: 'sound',
4374
+ agentDoc: 'Play/stop a cue from flow.cues (rotation/jitter handled by the runner). Never reference audio files directly.',
4375
+ run(raw, rt) {
4376
+ const step = raw;
4377
+ rt.playCue(step.cue, step.action ?? 'play');
4378
+ },
4379
+ };
4380
+ const setStateStep = {
4381
+ kind: 'setState',
4382
+ agentDoc: "Switch a node's named state (frame anticipation/bonus look). Same operation the inspector and agent use.",
4383
+ run(raw, rt) {
4384
+ const step = raw;
4385
+ rt.scene.setState(step.node, step.state);
4386
+ },
4387
+ };
4388
+ const setVarStep = {
4389
+ kind: 'setVar',
4390
+ agentDoc: "Set a runtime var driving visibleWhen (e.g. mode). Use for mode transitions ('setVar mode free_spins').",
4391
+ run(raw, rt) {
4392
+ const step = raw;
4393
+ rt.scene.setVar(step.name, rt.resolve(step.value));
4394
+ },
4395
+ };
4396
+ const setPropsStep = {
4397
+ kind: 'setProps',
4398
+ agentDoc: 'Transient prop write on the live instance (badge values, board swaps during presentation). The scene doc is NOT modified.',
4399
+ run(raw, rt) {
4400
+ const step = raw;
4401
+ const instance = rt.scene.instance(step.node);
4402
+ if (!instance?.applyProps)
4403
+ return rt.log(`setProps: node "${step.node}" has no applyProps`);
4404
+ const props = {};
4405
+ for (const [key, value] of Object.entries(step.props))
4406
+ props[key] = rt.resolve(value);
4407
+ const node = findDocNode(rt, step.node);
4408
+ instance.applyProps({ ...(node?.props ?? {}), ...props });
4409
+ },
4410
+ };
4411
+ function findDocNode(rt, id) {
4412
+ const walk = (n) => {
4413
+ if (n.id === id)
4414
+ return n;
4415
+ for (const child of n.children ?? []) {
4416
+ const hit = walk(child);
4417
+ if (hit)
4418
+ return hit;
4419
+ }
4420
+ return undefined;
4421
+ };
4422
+ return walk(rt.scene.doc().root);
4423
+ }
4424
+ const countUpStep = {
4425
+ kind: 'countUp',
4426
+ 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.",
4427
+ async run(raw, rt) {
4428
+ const step = raw;
4429
+ const instance = rt.scene.instance(step.node);
4430
+ if (!instance?.applyProps)
4431
+ return rt.log(`countUp: node "${step.node}" has no applyProps`);
4432
+ const node = findDocNode(rt, step.node);
4433
+ const prop = step.prop ?? 'value';
4434
+ const from = num(rt.resolve(step.from), 0);
4435
+ const to = num(rt.resolve(step.to), 0);
4436
+ const ms = num(step.ms, 1000) / rt.turbo;
4437
+ const apply = (value) => instance.applyProps({ ...(node?.props ?? {}), [prop]: formatValue(value, step.format) });
4438
+ if (rt.skipped || rt.instant || ms <= 0)
4439
+ return apply(to);
4440
+ const start = performance.now();
4441
+ while (!rt.skipped) {
4442
+ const k = Math.min(1, (performance.now() - start) / ms);
4443
+ apply(from + (to - from) * (1 - Math.pow(1 - k, 2)));
4444
+ if (k >= 1)
4445
+ return;
4446
+ await rt.wait(16);
4447
+ }
4448
+ apply(to);
4449
+ },
4450
+ };
4451
+ const waitStep = {
4452
+ kind: 'wait',
4453
+ agentDoc: "Pause: {ms} (turbo-scaled) or {until:'tap'} (released by tap or skip). Prefer explicit waits over baking delays into tweens.",
4454
+ async run(raw, rt) {
4455
+ const step = raw;
4456
+ if (step.until === 'tap')
4457
+ return rt.waitTap();
4458
+ return rt.wait(num(step.ms, 0));
4459
+ },
4460
+ };
4461
+ const ifStep = {
4462
+ kind: 'if',
4463
+ agentDoc: "Branch on the fire() ctx: {when:'win >= 100', then:[…], else:[…]}. Bare name = truthy check.",
4464
+ async run(raw, rt) {
4465
+ const step = raw;
4466
+ await rt.run(rt.when(step.when) ? step.then : (step.else ?? []));
4467
+ },
4468
+ };
4469
+ const parallelStep = {
4470
+ kind: 'parallel',
4471
+ agentDoc: 'Run tracks concurrently and await them all: {steps: [[…], […]]}. Each track is an independent sequence.',
4472
+ async run(raw, rt) {
4473
+ const step = raw;
4474
+ await Promise.all(step.steps.map((track) => rt.run(track)));
4475
+ },
4476
+ };
4477
+ const seqStep = {
4478
+ kind: 'seq',
4479
+ agentDoc: 'Nested sequence (grouping inside parallel tracks).',
4480
+ async run(raw, rt) {
4481
+ const step = raw;
4482
+ await rt.run(step.steps);
4483
+ },
4484
+ };
4485
+ const forEachStep = {
4486
+ kind: 'forEach',
4487
+ 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.",
4488
+ async run(raw, rt) {
4489
+ const step = raw;
4490
+ const items = rt.resolve(step.items);
4491
+ if (!Array.isArray(items))
4492
+ return rt.log(`forEach: items did not resolve to an array (${String(step.items)})`);
4493
+ const as = step.as ?? 'item';
4494
+ const stagger = num(step.staggerMs, 0);
4495
+ const runtimes = items.map((item, index) => rt.child({ [as]: item, [`${as}Index`]: index }));
4496
+ if ((step.mode ?? 'sequential') === 'parallel') {
4497
+ await Promise.all(runtimes.map(async (child, index) => {
4498
+ if (stagger > 0 && index > 0)
4499
+ await rt.wait(stagger * index);
4500
+ await child.run(step.steps);
4501
+ }));
4502
+ }
4503
+ else {
4504
+ for (let index = 0; index < runtimes.length; index++) {
4505
+ if (stagger > 0 && index > 0)
4506
+ await rt.wait(stagger);
4507
+ await runtimes[index].run(step.steps);
4508
+ }
4509
+ }
4510
+ },
4511
+ };
4512
+ const codeStep = {
4513
+ kind: 'code',
4514
+ agentDoc: 'Escape hatch: named choreography registered in createFlowRunner({code}). Use when the step vocabulary genuinely cannot express it — then consider a plugin step.',
4515
+ async run(raw, rt) {
4516
+ const step = raw;
4517
+ const handler = rt.codeRef(step.ref);
4518
+ if (!handler)
4519
+ return rt.log(`code: unknown ref "${step.ref}"`);
4520
+ await handler(rt, step.args ?? {});
4521
+ },
4522
+ };
4523
+ const BUILTIN_FLOW_STEPS = [
4524
+ tweenStep,
4525
+ soundStep,
4526
+ setStateStep,
4527
+ setVarStep,
4528
+ setPropsStep,
4529
+ countUpStep,
4530
+ waitStep,
4531
+ ifStep,
4532
+ parallelStep,
4533
+ seqStep,
4534
+ forEachStep,
4535
+ codeStep,
4536
+ ];
4537
+
4538
+ // Flow-IR interpreter: createFlowRunner(flowDoc, { scene, … }).fire(event, ctx) executes
4539
+ // the event's steps and returns a Trace.
4540
+ //
4541
+ // Skip semantics (first-class, docs/slot-ide.md §1.7): skip() does NOT cancel a run — it
4542
+ // makes the remaining steps complete in zero time. Waits resolve instantly, tweens and
4543
+ // count-ups jump to their end values, taps release. The flow always reaches its settled
4544
+ // state; three of the studied games lacked exactly this. Turbo divides every duration.
4545
+ const noopAudio = { play: () => { }, stop: () => { } };
4546
+ function validateFlowDoc(doc, registry) {
4547
+ const errors = [];
4548
+ if (doc.version !== 1)
4549
+ errors.push(`unsupported flow doc version ${String(doc.version)}`);
4550
+ const visit = (step, path) => {
4551
+ if (!registry.step(step.do)) {
4552
+ errors.push(`${path}: unknown step "${step.do}" — no plugin contributes it. Registered: ${registry.kinds().join(', ')}`);
4553
+ return;
4554
+ }
4555
+ if (step.do === 'if') {
4556
+ step.then.forEach((s, i) => visit(s, `${path}.then[${i}]`));
4557
+ (step.else ?? []).forEach((s, i) => visit(s, `${path}.else[${i}]`));
4558
+ }
4559
+ else if (step.do === 'parallel') {
4560
+ step.steps.forEach((track, ti) => track.forEach((s, i) => visit(s, `${path}[${ti}][${i}]`)));
4561
+ }
4562
+ else if (step.do === 'seq' || step.do === 'forEach') {
4563
+ step.steps.forEach((s, i) => visit(s, `${path}.steps[${i}]`));
4564
+ }
4565
+ else if (step.do === 'sound') {
4566
+ const cue = step.cue;
4567
+ if (!doc.cues?.[cue])
4568
+ errors.push(`${path}: sound cue "${cue}" is not declared in flow.cues`);
4569
+ }
4570
+ };
4571
+ for (const [event, steps] of Object.entries(doc.on ?? {})) {
4572
+ steps.forEach((step, i) => visit(step, `on.${event}[${i}]`));
4573
+ }
4574
+ return errors;
4575
+ }
4576
+ function createFlowRunner(doc, opts) {
4577
+ const log = opts.log ?? ((msg) => console.warn(`[flow] ${msg}`));
4578
+ const registry = createFlowStepRegistry(opts.plugins ?? [], BUILTIN_FLOW_STEPS);
4579
+ const errors = validateFlowDoc(doc, registry);
4580
+ if (errors.length > 0)
4581
+ throw new Error(`flow doc "${doc.id}" is invalid:\n ${errors.join('\n ')}`);
4582
+ const audio = opts.audio ?? noopAudio;
4583
+ let turbo = 1;
4584
+ const rotation = new Map();
4585
+ const tapWaiters = new Set();
4586
+ const active = new Set();
4587
+ const lastCtx = new Map();
4588
+ const resolveCue = (name) => {
4589
+ const def = doc.cues?.[name];
4590
+ if (!def)
4591
+ return undefined;
4592
+ const sources = Array.isArray(def.src) ? def.src : [def.src];
4593
+ let index = 0;
4594
+ if (def.rotate && sources.length > 1) {
4595
+ index = (rotation.get(name) ?? 0) % sources.length;
4596
+ rotation.set(name, index + 1);
4597
+ }
4598
+ const jitter = def.jitter ?? 0;
4599
+ return {
4600
+ cue: name,
4601
+ src: sources[index],
4602
+ channel: def.channel ?? 'sfx',
4603
+ loop: def.loop ?? false,
4604
+ rate: jitter ? 1 + (Math.random() * 2 - 1) * jitter : 1,
4605
+ volume: def.volume ?? 1,
4606
+ };
4607
+ };
4608
+ const fire = async (event, ctx = {}, fireOpts) => {
4609
+ const steps = doc.on[event];
4610
+ const start = performance.now();
4611
+ const entries = [];
4612
+ const replay = fireOpts?.replay;
4613
+ const run = {
4614
+ skipped: false,
4615
+ instant: !!replay,
4616
+ haltAfter: replay?.untilEntry ?? -1,
4617
+ halted: false,
4618
+ releases: new Set(),
4619
+ };
4620
+ active.add(run);
4621
+ if (!replay)
4622
+ lastCtx.set(event, ctx);
4623
+ const trace = (entry) => {
4624
+ const full = { t: Math.round(performance.now() - start), ...entry };
4625
+ entries.push(full);
4626
+ if (run.haltAfter >= 0 && entries.length - 1 >= run.haltAfter)
4627
+ run.halted = true;
4628
+ if (!run.instant)
4629
+ opts.onTrace?.(event, full);
4630
+ };
4631
+ const makeRuntime = (ctxLocal) => {
4632
+ const rtLocal = {
4633
+ scene: opts.scene,
4634
+ ctx: ctxLocal,
4635
+ audio,
4636
+ get skipped() {
4637
+ return run.skipped;
4638
+ },
4639
+ get instant() {
4640
+ return run.instant;
4641
+ },
4642
+ get turbo() {
4643
+ return turbo;
4644
+ },
4645
+ wait(ms) {
4646
+ // Instant runs still yield a macrotask — a plugin polling `while(!done) await wait()`
4647
+ // must not starve the event loop the animation it awaits runs on.
4648
+ if (run.instant)
4649
+ return new Promise((resolve) => setTimeout(resolve, 0));
4650
+ if (run.skipped || ms <= 0)
4651
+ return Promise.resolve();
4652
+ return new Promise((resolve) => {
4653
+ const timer = setTimeout(() => {
4654
+ run.releases.delete(release);
4655
+ resolve();
4656
+ }, ms / turbo);
4657
+ const release = () => {
4658
+ clearTimeout(timer);
4659
+ resolve();
4660
+ };
4661
+ run.releases.add(release);
4662
+ });
4663
+ },
4664
+ waitTap() {
4665
+ if (run.skipped || run.instant)
4666
+ return Promise.resolve();
4667
+ return new Promise((resolve) => {
4668
+ const release = () => {
4669
+ tapWaiters.delete(release);
4670
+ run.releases.delete(release);
4671
+ resolve();
4672
+ };
4673
+ tapWaiters.add(release);
4674
+ run.releases.add(release);
4675
+ });
4676
+ },
4677
+ async run(list) {
4678
+ for (const step of list) {
4679
+ if (run.halted)
4680
+ return;
4681
+ const contribution = registry.step(step.do);
4682
+ if (!contribution)
4683
+ continue; // validated; defensive
4684
+ trace({
4685
+ do: step.do,
4686
+ node: typeof step.node === 'string' ? step.node : undefined,
4687
+ });
4688
+ // trace() may flip `halted` on THIS entry — the halting step still executes
4689
+ // (scrub = "the state right after step N"); the loop stops before the next one.
4690
+ await contribution.run(step, rtLocal);
4691
+ }
4692
+ },
4693
+ resolve(value) {
4694
+ if (typeof value === 'string' && value.startsWith('$')) {
4695
+ return value
4696
+ .slice(1)
4697
+ .split('.')
4698
+ .reduce((acc, key) => acc?.[key], ctxLocal);
4699
+ }
4700
+ return value;
4701
+ },
4702
+ when(expr) {
4703
+ const bare = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expr);
4704
+ if (bare)
4705
+ return Boolean(ctxLocal[bare[1]]);
4706
+ const result = evalVisibleWhen(expr, ctxLocal);
4707
+ if (result === undefined) {
4708
+ log(`unsupported when "${expr}" — treated as false`);
4709
+ return false;
4710
+ }
4711
+ return result;
4712
+ },
4713
+ child: (ctxPatch) => makeRuntime({ ...ctxLocal, ...ctxPatch }),
4714
+ trace,
4715
+ view: (id) => opts.scene.node(id),
4716
+ log,
4717
+ codeRef: (ref) => opts.code?.[ref],
4718
+ playCue(name, action) {
4719
+ if (run.instant)
4720
+ return; // scrub replays stay silent
4721
+ if (action === 'stop')
4722
+ return audio.stop(name);
4723
+ const resolved = resolveCue(name);
4724
+ if (resolved)
4725
+ audio.play(resolved);
4726
+ },
4727
+ };
4728
+ return rtLocal;
4729
+ };
4730
+ const rt = makeRuntime(ctx);
4731
+ try {
4732
+ if (!steps)
4733
+ log(`fire("${event}") — no steps declared`);
4734
+ else
4735
+ await rt.run(steps);
4736
+ }
4737
+ finally {
4738
+ active.delete(run);
4739
+ }
4740
+ return {
4741
+ event,
4742
+ turbo,
4743
+ skipped: run.skipped,
4744
+ ...(replay ? { haltedAtEntry: Math.min(run.haltAfter, entries.length - 1) } : {}),
4745
+ durationMs: Math.round(performance.now() - start),
4746
+ entries,
4747
+ };
4748
+ };
4749
+ return {
4750
+ fire,
4751
+ replay(event, untilEntry) {
4752
+ const ctx = lastCtx.get(event);
4753
+ if (!ctx)
4754
+ log(`replay("${event}") before any fire — running with an empty ctx`);
4755
+ // Scrub = doc baseline + steps 0..N. Without the reset, leftovers of the previous
4756
+ // full run (steps > N) would bleed into the pose.
4757
+ opts.scene.resetProps();
4758
+ return fire(event, ctx ?? {}, { replay: { untilEntry } });
4759
+ },
4760
+ skip() {
4761
+ for (const run of active) {
4762
+ run.skipped = true;
4763
+ for (const release of [...run.releases])
4764
+ release();
4765
+ run.releases.clear();
4766
+ }
4767
+ },
4768
+ setTurbo(factor) {
4769
+ turbo = Math.max(0.1, factor);
4770
+ },
4771
+ tap() {
4772
+ for (const release of [...tapWaiters])
4773
+ release();
4774
+ },
4775
+ events: () => Object.keys(doc.on ?? {}),
4776
+ destroy() {
4777
+ this.skip();
4778
+ active.clear();
4779
+ },
4780
+ };
4781
+ }
4782
+
4783
+ // DocScene — the host bridge: a Scene + SlotSceneController whose presentation is
4784
+ // entirely doc-driven. It builds the display tree from scene.json, lays out on the
4785
+ // host's design-unit resize, and translates every host lifecycle hook into a flow-IR
4786
+ // event (serialized, so spinStart/result/enterMode never interleave). Sound cues route
4787
+ // to the REAL SceneApi.audio. With this class a game's `scenes` list entry becomes
4788
+ // { key: 'game', scene: createDocScene({ scene: sceneJson, flow: flowJson, … }) }
4789
+ // and the game ships no GameScene code for whatever the docs express.
4790
+ /** Build the createSlotGame `scenes` list from a StageDoc — every entry a DocScene. */
4791
+ function buildDocScenes(stage, shared) {
4792
+ return stage.scenes.map((entry) => ({
4793
+ key: entry.key,
4794
+ scene: new DocScene({ ...shared, scene: entry.scene, flow: entry.flow, advanceOnTap: entry.advanceOnTap }),
4795
+ skipOnReplay: entry.skipOnReplay,
4796
+ }));
4797
+ }
4798
+ /** Cue channel 'music' routes to playMusic/stopMusic; everything else to play(). */
4799
+ function sceneAudioAdapter(api, log) {
4800
+ const musicCues = new Set();
4801
+ return {
4802
+ play(cue) {
4803
+ const audio = api()?.audio;
4804
+ if (!audio)
4805
+ return log(`audio cue "${cue.cue}" before onCreate — dropped`);
4806
+ if (cue.channel === 'music') {
4807
+ musicCues.add(cue.cue);
4808
+ audio.playMusic(cue.src);
4809
+ }
4810
+ else {
4811
+ audio.play(cue.src, { loop: cue.loop, speed: cue.rate, volume: cue.volume });
4812
+ }
4813
+ },
4814
+ stop(cueName) {
4815
+ const audio = api()?.audio;
4816
+ if (!audio)
4817
+ return;
4818
+ if (musicCues.has(cueName))
4819
+ audio.stopMusic();
4820
+ // Looped sfx stop is not addressable through SceneAudio yet — flows should prefer
4821
+ // short one-shots for sfx and music channel for anything long-lived.
4822
+ },
4823
+ };
4824
+ }
4825
+ class DocScene extends Scene {
4826
+ opts;
4827
+ api = null;
4828
+ handle_ = null;
4829
+ runner_ = null;
4830
+ chain = Promise.resolve();
4831
+ lastSize = { width: 0, height: 0 };
4832
+ log;
4833
+ constructor(opts) {
4834
+ super();
4835
+ this.opts = opts;
4836
+ this.log = opts.log ?? ((m) => console.warn(`[doc-scene] ${m}`));
4837
+ }
4838
+ /** The live scene handle (inspector/agent attachment). Null before onEnter. */
4839
+ get scene() {
4840
+ return this.handle_;
4841
+ }
4842
+ get flow() {
4843
+ return this.runner_;
4844
+ }
4845
+ // ── Scene lifecycle ────────────────────────────────────────────────────────
4846
+ onEnter(data) {
4847
+ const goto = data?.goto;
4848
+ const sceneOpts = {
4849
+ plugins: this.opts.plugins,
4850
+ resolveSymbol: this.opts.resolveSymbol,
4851
+ vars: { mode: 'BASE', ...(this.opts.vars ?? {}) },
4852
+ log: this.log,
4853
+ };
4854
+ if (this.opts.texture)
4855
+ sceneOpts.texture = this.opts.texture;
4856
+ this.handle_ = createSceneFromDoc(this.opts.scene, sceneOpts);
4857
+ this.container.addChild(this.handle_.view);
4858
+ if (this.lastSize.width > 0)
4859
+ this.handle_.layout(this.lastSize.width, this.lastSize.height);
4860
+ if (this.opts.flow) {
4861
+ this.runner_ = createFlowRunner(this.opts.flow, {
4862
+ scene: this.handle_,
4863
+ plugins: this.opts.flowPlugins,
4864
+ code: this.opts.code,
4865
+ audio: sceneAudioAdapter(() => this.api, this.log),
4866
+ onTrace: this.opts.onTrace,
4867
+ log: this.log,
4868
+ });
4869
+ }
4870
+ // Taps feed `wait until:'tap'` flows; with advanceOnTap they also switch scenes.
4871
+ this.container.eventMode = 'static';
4872
+ this.container.on('pointertap', () => {
4873
+ this.runner_?.tap();
4874
+ if (this.opts.advanceOnTap)
4875
+ goto?.(this.opts.advanceOnTap);
4876
+ });
4877
+ if (this.opts.flow?.on['enter'])
4878
+ void this.fire('enter');
4879
+ }
4880
+ onResize(width, height) {
4881
+ this.lastSize = { width, height };
4882
+ this.handle_?.layout(width, height);
4883
+ this.container.hitArea = { contains: (x, y) => x >= 0 && y >= 0 && x <= width && y <= height };
4884
+ }
4885
+ onDestroy() {
4886
+ this.runner_?.destroy();
4887
+ this.handle_?.destroy();
4888
+ this.runner_ = null;
4889
+ this.handle_ = null;
4890
+ }
4891
+ // ── SlotSceneController → flow events (serialized) ─────────────────────────
4892
+ fire(event, ctx) {
4893
+ const next = this.chain.then(() => this.runner_?.fire(event, ctx)).catch((err) => {
4894
+ this.log(`flow "${event}" failed: ${String(err)}`);
4895
+ });
4896
+ this.chain = next;
4897
+ return next;
4898
+ }
4899
+ ctxOf(result, ctx) {
4900
+ if (this.opts.resultCtx)
4901
+ return this.opts.resultCtx(result, ctx);
4902
+ return {
4903
+ ...result,
4904
+ win: result.totalWin,
4905
+ mode: ctx.mode,
4906
+ action: ctx.action,
4907
+ bet: ctx.bet,
4908
+ };
4909
+ }
4910
+ onCreate(api) {
4911
+ this.api = api;
4912
+ this.runner_?.setTurbo((this.opts.turboFactor ?? ((l) => 1 + l))(api.turbo));
4913
+ }
4914
+ onSpinStart() {
4915
+ void this.fire('spinStart');
4916
+ }
4917
+ async onSpin(result, ctx) {
4918
+ await this.fire('result', this.ctxOf(result, ctx));
4919
+ }
4920
+ async onEnterMode(result, ctx) {
4921
+ this.handle_?.setVar('mode', ctx.mode);
4922
+ await this.fire('enterMode', this.ctxOf(result, ctx));
4923
+ }
4924
+ async onExitMode(result, ctx) {
4925
+ await this.fire('exitMode', this.ctxOf(result, ctx));
4926
+ this.handle_?.setVar('mode', 'BASE');
4927
+ }
4928
+ onSpinEnd(result, ctx) {
4929
+ void this.fire('spinEnd', this.ctxOf(result, ctx));
4930
+ }
4931
+ onSkip() {
4932
+ this.runner_?.skip();
4933
+ }
4934
+ onTurboChanged(level) {
4935
+ this.runner_?.setTurbo((this.opts.turboFactor ?? ((l) => 1 + l))(level));
4936
+ }
4937
+ }
4938
+ function createDocScene(opts) {
4939
+ return new DocScene(opts);
4940
+ }
4941
+
4942
+ 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 };
3614
4943
  //# sourceMappingURL=scene.esm.js.map