@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/flow.cjs.js +155 -86
- package/dist/flow.cjs.js.map +1 -1
- package/dist/flow.d.ts +92 -3
- package/dist/flow.esm.js +155 -86
- package/dist/flow.esm.js.map +1 -1
- package/dist/host.cjs.js +24 -17
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.esm.js +24 -17
- package/dist/host.esm.js.map +1 -1
- package/dist/scene-devtools.cjs.js +767 -3
- package/dist/scene-devtools.cjs.js.map +1 -1
- package/dist/scene-devtools.d.ts +256 -3
- package/dist/scene-devtools.esm.js +765 -4
- package/dist/scene-devtools.esm.js.map +1 -1
- package/dist/scene.cjs.js +1371 -29
- package/dist/scene.cjs.js.map +1 -1
- package/dist/scene.d.ts +608 -4
- package/dist/scene.esm.js +1359 -30
- package/dist/scene.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/shellConfig.ts +21 -14
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
|
|
3133
|
-
//
|
|
3134
|
-
//
|
|
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
|
-
|
|
3179
|
+
const contentOrigin = { x: 0, y: 0 };
|
|
3138
3180
|
const syncContent = () => {
|
|
3139
|
-
const
|
|
3140
|
-
|
|
3141
|
-
system.
|
|
3142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|