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