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