@energy8platform/game-engine 0.34.2 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/audio.cjs.js +114 -59
  2. package/dist/audio.cjs.js.map +1 -1
  3. package/dist/audio.d.ts +25 -0
  4. package/dist/audio.esm.js +114 -59
  5. package/dist/audio.esm.js.map +1 -1
  6. package/dist/core.cjs.js +222 -66
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.d.ts +25 -0
  9. package/dist/core.esm.js +223 -67
  10. package/dist/core.esm.js.map +1 -1
  11. package/dist/flow.cjs.js +246 -0
  12. package/dist/flow.cjs.js.map +1 -1
  13. package/dist/flow.d.ts +192 -33
  14. package/dist/flow.esm.js +238 -1
  15. package/dist/flow.esm.js.map +1 -1
  16. package/dist/host.cjs.js +343 -82
  17. package/dist/host.cjs.js.map +1 -1
  18. package/dist/host.d.ts +82 -2
  19. package/dist/host.esm.js +344 -83
  20. package/dist/host.esm.js.map +1 -1
  21. package/dist/index.cjs.js +222 -66
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +72 -0
  24. package/dist/index.esm.js +223 -67
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/scene-devtools.cjs.js +529 -115
  27. package/dist/scene-devtools.cjs.js.map +1 -1
  28. package/dist/scene-devtools.d.ts +187 -34
  29. package/dist/scene-devtools.esm.js +529 -115
  30. package/dist/scene-devtools.esm.js.map +1 -1
  31. package/dist/scene.cjs.js +704 -46
  32. package/dist/scene.cjs.js.map +1 -1
  33. package/dist/scene.d.ts +228 -41
  34. package/dist/scene.esm.js +698 -47
  35. package/dist/scene.esm.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/audio/AudioManager.ts +111 -53
  38. package/src/core/GameApplication.ts +47 -5
  39. package/src/host/buildConfig.ts +17 -4
  40. package/src/host/createSlotGame.ts +114 -12
  41. package/src/host/index.ts +3 -0
  42. package/src/host/types.ts +58 -0
  43. package/src/loading/LoadingScene.ts +76 -2
  44. package/src/loading/index.ts +6 -0
  45. package/src/types.ts +2 -0
@@ -2,6 +2,45 @@
2
2
 
3
3
  var pixi_js = require('pixi.js');
4
4
 
5
+ // Field schemas — the authoring contract for a node type's props.
6
+ //
7
+ // Before this existed the inspector inferred a control from the CURRENT value, which meant
8
+ // a prop that wasn't set yet had no row and no way to be added: a freshly added sprite had
9
+ // `props:{}` and therefore no way to choose a texture at all. It also had no notion of an
10
+ // enum, an asset or a node reference, and it round-tripped booleans through text.
11
+ //
12
+ // A schema is declared once by the contribution that owns the kind, and is used three ways:
13
+ // the inspector renders from it, validation can check against it, and the agent reads the
14
+ // same text as documentation (`doc`) — the plugin's docs and the agent's prompt stay one
15
+ // thing, per docs/slot-ide.md §6.2.
16
+ /** Shared field definitions, so every kind describes `alpha` the same way. */
17
+ function schemaFieldRows(schema, props) {
18
+ const rows = [];
19
+ if (!schema)
20
+ return rows;
21
+ const visit = (fields, prefix, container) => {
22
+ const entries = Object.entries(fields).sort((a, b) => (a[1].order ?? 1000) - (b[1].order ?? 1000) || a[0].localeCompare(b[0]));
23
+ for (const [key, field] of entries) {
24
+ const path = prefix ? `${prefix}.${key}` : key;
25
+ const parent = container;
26
+ const value = parent && typeof parent === 'object' ? parent[key] : undefined;
27
+ if (field.kind === 'object' && field.fields) {
28
+ visit(field.fields, path, value);
29
+ }
30
+ else {
31
+ rows.push({ path, label: field.label ?? path, schema: field, value, unset: value === undefined });
32
+ }
33
+ }
34
+ };
35
+ visit(schema, '', props);
36
+ return rows;
37
+ }
38
+ /** Props present on the node that the schema doesn't describe (plugin extras, hand-written). */
39
+ function extraPropKeys(schema, props) {
40
+ const known = new Set(Object.keys(schema ?? {}));
41
+ return Object.keys(props).filter((k) => !known.has(k));
42
+ }
43
+
5
44
  // Pure helpers behind the scene inspector — kept renderer/DOM-free so the control
6
45
  // inference and layout-field enumeration are unit-testable and reusable (the same
7
46
  // mapping later feeds schema-driven autogen and the agent's docs).
@@ -37,7 +76,9 @@ function layoutFieldSpecs(rule) {
37
76
  out.push(path === 'mode' ? { path, label, kind: 'readonly', value } : { path, label, kind: 'text', value });
38
77
  }
39
78
  else if (typeof value === 'boolean') {
40
- out.push({ path, label, kind: 'text', value: String(value) });
79
+ // Must stay a real boolean: the engine tests rules with `=== false`
80
+ // (e.g. scaleWithGrid), so writing back the string "false" silently inverts behaviour.
81
+ out.push({ path, label, kind: 'boolean', value });
41
82
  }
42
83
  else if (Array.isArray(value)) {
43
84
  value.forEach((item, i) => visit(item, `${path}.${i}`, `${label}[${i}]`));
@@ -107,6 +148,12 @@ const STYLE$2 = `
107
148
  .e8si-json { display: block; width: calc(100% - 20px); margin: 2px 10px; min-height: 52px; font: 11px/1.4 ui-monospace, monospace; }
108
149
  .e8si-apply { margin: 4px 10px; padding: 3px 10px; background: #18213d; color: #dfe6f5; border: 1px solid #2b3866; border-radius: 6px; cursor: pointer; }
109
150
  .e8si-empty { padding: 10px; color: #7d8ab0; }
151
+ .e8si-unset label { opacity: 0.6; font-style: italic; }
152
+ .e8si-unset input, .e8si-unset select { opacity: 0.75; }
153
+ .e8si-clear { flex: none; background: none; border: 0; color: #55617f; cursor: pointer; font: inherit; padding: 0 2px; }
154
+ .e8si-clear:hover { color: #dfe6f5; }
155
+ .e8si-thumb { flex: none; width: 30px; height: 24px; object-fit: contain; border-radius: 4px;
156
+ background: repeating-conic-gradient(#161d33 0% 25%, #0d1428 0% 50%) 0 / 8px 8px; }
110
157
  .e8si-badge { display: inline-block; padding: 0 6px; border: 1px solid #2b3866; border-radius: 8px; color: #9fb0d8; font-size: 10px; }
111
158
  `;
112
159
  function createSceneInspector(opts) {
@@ -273,12 +320,17 @@ function createSceneInspector(opts) {
273
320
  }
274
321
  else {
275
322
  const input = document.createElement('input');
276
- input.type = spec.kind === 'number' ? 'number' : 'text';
323
+ const isBool = spec.kind === 'boolean';
324
+ input.type = isBool ? 'checkbox' : spec.kind === 'number' ? 'number' : 'text';
277
325
  if (spec.step !== undefined)
278
326
  input.step = String(spec.step);
279
- input.value = String(spec.value);
327
+ if (isBool)
328
+ input.checked = spec.value === true;
329
+ else
330
+ input.value = String(spec.value);
280
331
  input.addEventListener('change', () => {
281
- const raw = spec.kind === 'number' ? Number(input.value) : input.value;
332
+ // Booleans must round-trip as booleans the engine compares with `=== false`.
333
+ const raw = isBool ? input.checked : spec.kind === 'number' ? Number(input.value) : input.value;
282
334
  if (spec.kind === 'number' && !Number.isFinite(raw))
283
335
  return;
284
336
  patchAndRefresh({
@@ -293,76 +345,225 @@ function createSceneInspector(opts) {
293
345
  inspHost.appendChild(wrap);
294
346
  }
295
347
  }
296
- // Props — base props, schema-less control inference.
348
+ // Props — driven by the kind's schema, so EVERY authorable field has a row (including
349
+ // ones not set yet) with the right control: an asset picker for textures, a node picker
350
+ // for references, a select for enums. Props the schema doesn't know about still show up,
351
+ // inferred from their value, so hand-written and plugin extras stay editable.
297
352
  const props = node.props ?? {};
298
- const propKeys = Object.keys(props);
299
- if (propKeys.length > 0) {
353
+ const schemaKey = node.type === 'prefab' ? `prefab:${String(props.prefab ?? '')}` : node.type;
354
+ const schema = handle.schemas()[schemaKey];
355
+ const rows = schemaFieldRows(schema, props);
356
+ const extras = extraPropKeys(schema, props).filter((k) => !(node.type === 'prefab' && k === 'prefab'));
357
+ if (rows.length > 0 || extras.length > 0) {
300
358
  const propsTitle = document.createElement('h3');
301
359
  propsTitle.textContent = 'Props';
302
360
  propsTitle.className = 'e8si-sec';
303
361
  inspHost.appendChild(propsTitle);
304
- for (const key of propKeys) {
305
- const value = props[key];
306
- const kind = propControlKind(value);
307
- if (kind === 'json') {
308
- const wrap = field(key);
309
- inspHost.appendChild(wrap);
310
- const area = document.createElement('textarea');
311
- area.className = 'e8si-json';
312
- area.value = JSON.stringify(value, null, 1);
313
- const apply = document.createElement('button');
314
- apply.className = 'e8si-apply';
315
- apply.textContent = `apply ${key}`;
316
- apply.addEventListener('click', () => {
317
- try {
318
- patchAndRefresh({ op: 'set-props', id, props: { [key]: JSON.parse(area.value) } });
319
- }
320
- catch {
321
- apply.textContent = `apply ${key} — invalid JSON`;
322
- }
323
- });
324
- inspHost.append(area, apply);
325
- continue;
362
+ /** Write a (possibly nested) prop path. set-props merges at the top level only. */
363
+ const writePath = (path, value) => {
364
+ const parts = path.split('.');
365
+ if (parts.length === 1) {
366
+ patchAndRefresh({ op: 'set-props', id, props: { [path]: value } });
367
+ return;
326
368
  }
327
- const wrap = field(key);
328
- if (kind === 'boolean') {
329
- const input = document.createElement('input');
330
- input.type = 'checkbox';
331
- input.checked = value === true;
332
- input.addEventListener('change', () => patchAndRefresh({ op: 'set-props', id, props: { [key]: input.checked } }));
333
- wrap.appendChild(input);
369
+ const [head, ...rest] = parts;
370
+ const root = { ...(props[head] ?? {}) };
371
+ let cursor = root;
372
+ for (let i = 0; i < rest.length - 1; i++) {
373
+ cursor[rest[i]] = { ...(cursor[rest[i]] ?? {}) };
374
+ cursor = cursor[rest[i]];
334
375
  }
335
- else if (kind === 'color') {
336
- const color = document.createElement('input');
337
- color.type = 'color';
338
- color.value = String(value).slice(0, 7);
339
- const text = document.createElement('input');
340
- text.type = 'text';
341
- text.value = String(value);
342
- const push = (v) => patchAndRefresh({ op: 'set-props', id, props: { [key]: v } });
343
- color.addEventListener('input', () => {
344
- text.value = color.value;
345
- push(color.value);
346
- });
347
- text.addEventListener('change', () => push(text.value));
348
- wrap.append(color, text);
376
+ cursor[rest[rest.length - 1]] = value;
377
+ patchAndRefresh({ op: 'set-props', id, props: { [head]: root } });
378
+ };
379
+ for (const row of rows) {
380
+ const wrap = field(row.label);
381
+ const label = wrap.querySelector('label');
382
+ if (label && row.schema.doc)
383
+ label.title = `${row.path} ${row.schema.doc}`;
384
+ // An unset field shows its default, dimmed: "not set" must not read as "zero".
385
+ if (row.unset)
386
+ wrap.classList.add('e8si-unset');
387
+ const shown = row.unset ? row.schema.default : row.value;
388
+ const control = buildControl(row.schema, shown, (v) => writePath(row.path, v));
389
+ wrap.append(...control);
390
+ if (!row.unset && row.schema.default !== undefined) {
391
+ const clear = document.createElement('button');
392
+ clear.className = 'e8si-clear';
393
+ clear.textContent = '\u00d7';
394
+ clear.title = `Reset to the default (${JSON.stringify(row.schema.default)})`;
395
+ clear.addEventListener('click', () => writePath(row.path, undefined));
396
+ wrap.appendChild(clear);
349
397
  }
350
- else {
351
- const input = document.createElement('input');
352
- input.type = kind === 'number' ? 'number' : 'text';
353
- input.value = String(value);
354
- input.addEventListener('change', () => {
355
- const raw = kind === 'number' ? Number(input.value) : input.value;
356
- if (kind === 'number' && !Number.isFinite(raw))
357
- return;
358
- patchAndRefresh({ op: 'set-props', id, props: { [key]: raw } });
359
- });
360
- wrap.appendChild(input);
398
+ inspHost.appendChild(wrap);
399
+ }
400
+ for (const key of extras)
401
+ renderInferred(key, props[key]);
402
+ }
403
+ /** Build the control(s) for one schema field. */
404
+ function buildControl(spec, value, commit) {
405
+ if (spec.kind === 'asset') {
406
+ const list = (opts.assets?.() ?? []).filter((a) => {
407
+ if (spec.accept === 'audio')
408
+ return a.kind === 'audio';
409
+ if (spec.accept === undefined)
410
+ return true;
411
+ return a.kind !== 'audio'; // image / spritesheet are both textures
412
+ });
413
+ const select = document.createElement('select');
414
+ const blank = document.createElement('option');
415
+ blank.value = '';
416
+ blank.textContent = list.length ? '\u2014 pick asset \u2014' : '(no asset manifest)';
417
+ select.appendChild(blank);
418
+ for (const a of list) {
419
+ const o = document.createElement('option');
420
+ o.value = a.alias;
421
+ o.textContent = a.alias;
422
+ select.appendChild(o);
423
+ }
424
+ const current = value === undefined ? '' : String(value);
425
+ if (current && !list.some((a) => a.alias === current)) {
426
+ const o = document.createElement('option');
427
+ o.value = current;
428
+ o.textContent = `${current} (missing)`;
429
+ select.appendChild(o);
430
+ }
431
+ select.value = current;
432
+ select.addEventListener('change', () => commit(select.value === '' ? undefined : select.value));
433
+ const out = [select];
434
+ const hit = list.find((a) => a.alias === current);
435
+ if (hit && opts.assetUrl) {
436
+ const img = document.createElement('img');
437
+ img.className = 'e8si-thumb';
438
+ img.src = opts.assetUrl(hit.path);
439
+ img.alt = current;
440
+ out.push(img);
441
+ }
442
+ return out;
443
+ }
444
+ if (spec.kind === 'nodeRef') {
445
+ const select = document.createElement('select');
446
+ const blank = document.createElement('option');
447
+ blank.value = '';
448
+ blank.textContent = '\u2014 pick node \u2014';
449
+ select.appendChild(blank);
450
+ const walk = (n) => {
451
+ if (n.id !== id && (!spec.ofType || n.type === spec.ofType)) {
452
+ const o = document.createElement('option');
453
+ o.value = n.id;
454
+ o.textContent = `${n.id} (${n.type})`;
455
+ select.appendChild(o);
456
+ }
457
+ n.children.forEach(walk);
458
+ };
459
+ walk(handle.tree());
460
+ select.value = value === undefined ? '' : String(value);
461
+ select.addEventListener('change', () => commit(select.value === '' ? undefined : select.value));
462
+ return [select];
463
+ }
464
+ if (spec.kind === 'enum') {
465
+ const select = document.createElement('select');
466
+ for (const opt of spec.options ?? []) {
467
+ const o = document.createElement('option');
468
+ o.value = opt;
469
+ o.textContent = opt;
470
+ select.appendChild(o);
361
471
  }
472
+ select.value = value === undefined ? '' : String(value);
473
+ select.addEventListener('change', () => commit(select.value));
474
+ return [select];
475
+ }
476
+ if (spec.kind === 'boolean') {
477
+ const input = document.createElement('input');
478
+ input.type = 'checkbox';
479
+ input.checked = value === true;
480
+ input.addEventListener('change', () => commit(input.checked));
481
+ return [input];
482
+ }
483
+ if (spec.kind === 'color') {
484
+ const color = document.createElement('input');
485
+ color.type = 'color';
486
+ color.value = normalizeHex(value);
487
+ const text = document.createElement('input');
488
+ text.type = 'text';
489
+ text.value = value === undefined ? '' : String(value);
490
+ color.addEventListener('input', () => {
491
+ text.value = color.value;
492
+ commit(color.value);
493
+ });
494
+ text.addEventListener('change', () => commit(text.value));
495
+ return [color, text];
496
+ }
497
+ if (spec.kind === 'json') {
498
+ const area = document.createElement('textarea');
499
+ area.className = 'e8si-json';
500
+ area.value = value === undefined ? '' : JSON.stringify(value, null, 1);
501
+ area.addEventListener('change', () => {
502
+ const raw = area.value.trim();
503
+ if (raw === '')
504
+ return commit(undefined);
505
+ try {
506
+ commit(JSON.parse(raw));
507
+ area.style.borderColor = '';
508
+ }
509
+ catch {
510
+ area.style.borderColor = '#ff4d8d';
511
+ }
512
+ });
513
+ return [area];
514
+ }
515
+ const input = document.createElement('input');
516
+ input.type = spec.kind === 'number' ? 'number' : 'text';
517
+ if (spec.step !== undefined)
518
+ input.step = String(spec.step);
519
+ if (spec.min !== undefined)
520
+ input.min = String(spec.min);
521
+ if (spec.max !== undefined)
522
+ input.max = String(spec.max);
523
+ input.value = value === undefined ? '' : String(value);
524
+ input.addEventListener('change', () => {
525
+ if (input.value === '')
526
+ return commit(undefined);
527
+ const raw = spec.kind === 'number' ? Number(input.value) : input.value;
528
+ if (spec.kind === 'number' && !Number.isFinite(raw))
529
+ return;
530
+ commit(raw);
531
+ });
532
+ return [input];
533
+ }
534
+ /** Fallback for props the schema doesn't describe (plugin extras, hand-written keys). */
535
+ function renderInferred(key, value) {
536
+ const kind = propControlKind(value);
537
+ if (kind === 'json') {
538
+ const wrap = field(key);
362
539
  inspHost.appendChild(wrap);
540
+ const area = document.createElement('textarea');
541
+ area.className = 'e8si-json';
542
+ area.value = JSON.stringify(value, null, 1);
543
+ const apply = document.createElement('button');
544
+ apply.className = 'e8si-apply';
545
+ apply.textContent = `apply ${key}`;
546
+ apply.addEventListener('click', () => {
547
+ try {
548
+ patchAndRefresh({ op: 'set-props', id, props: { [key]: JSON.parse(area.value) } });
549
+ }
550
+ catch {
551
+ apply.textContent = `apply ${key} — invalid JSON`;
552
+ }
553
+ });
554
+ inspHost.append(area, apply);
555
+ return;
363
556
  }
557
+ const wrap = field(key);
558
+ const control = buildControl({ kind: kind === 'color' ? 'color' : kind }, value, (v) => patchAndRefresh({ op: 'set-props', id, props: { [key]: v } }));
559
+ wrap.append(...control);
560
+ inspHost.appendChild(wrap);
364
561
  }
365
562
  };
563
+ function normalizeHex(value) {
564
+ const s = typeof value === 'string' ? value : '';
565
+ return /^#[0-9a-f]{6}/i.test(s) ? s.slice(0, 7) : '#000000';
566
+ }
366
567
  const select = (id) => {
367
568
  selectedId = id;
368
569
  renderTree();
@@ -862,7 +1063,10 @@ function createTransformGizmo(opts) {
862
1063
  layer.addChildAt(body, 1);
863
1064
  let nodeId = null;
864
1065
  let drag = null;
865
- const getMode = () => opts.getMode?.() ?? null;
1066
+ /** Press origin, to tell a click apart from a drag on pointerup. */
1067
+ let downAt = null;
1068
+ let moved = false;
1069
+ const DRAG_SLOP = 4; // px before a press counts as a drag
866
1070
  const findDocNode = (id, node = handle.doc().root) => {
867
1071
  if (node.id === id)
868
1072
  return node;
@@ -874,18 +1078,13 @@ function createTransformGizmo(opts) {
874
1078
  return undefined;
875
1079
  };
876
1080
  /**
877
- * Effective layout rule + which override an edit should target. Editing in a non-base
878
- * mode targets that mode's override (so base and free_spins are edited independently);
879
- * otherwise the current orientation override, else base.
1081
+ * Effective layout rule + which override an edit should target: the current orientation's
1082
+ * override when one exists, else the base rule.
880
1083
  */
881
1084
  const effectiveRule = (id) => {
882
1085
  const node = findDocNode(id);
883
1086
  if (!node)
884
1087
  return { target: {} };
885
- const mode = getMode();
886
- if (mode && mode !== 'base') {
887
- return { rule: node.modes?.[mode]?.layout ?? node.layout, target: { mode } };
888
- }
889
1088
  const o = getOrientation();
890
1089
  const override = node.responsive?.[o]?.layout;
891
1090
  return override ? { rule: override, target: { orientation: o } } : { rule: node.layout, target: {} };
@@ -906,6 +1105,9 @@ function createTransformGizmo(opts) {
906
1105
  if (!box || !rule)
907
1106
  return;
908
1107
  const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
1108
+ downAt = { x: e.global.x, y: e.global.y, handle: h, alt: e.altKey };
1109
+ moved = false;
1110
+ opts.onGestureStart?.();
909
1111
  drag = {
910
1112
  handle: h,
911
1113
  startBox: box,
@@ -919,6 +1121,11 @@ function createTransformGizmo(opts) {
919
1121
  const onMove = (e) => {
920
1122
  if (!drag || !nodeId)
921
1123
  return;
1124
+ if (!moved && downAt && (Math.abs(e.global.x - downAt.x) > DRAG_SLOP || Math.abs(e.global.y - downAt.y) > DRAG_SLOP)) {
1125
+ moved = true;
1126
+ }
1127
+ if (!moved)
1128
+ return; // a press that has not travelled yet is still a potential click
922
1129
  let rule;
923
1130
  if (drag.handle === 'body') {
924
1131
  const s = dpp(nodeId);
@@ -934,10 +1141,20 @@ function createTransformGizmo(opts) {
934
1141
  const { factorX, factorY } = resizeFactors(drag.startBox, drag.handle, { x: e.global.x, y: e.global.y });
935
1142
  rule = resizeRule(drag.baseRule, factorX, factorY);
936
1143
  }
937
- applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.target.orientation, mode: drag.target.mode });
1144
+ applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.target.orientation });
938
1145
  };
939
1146
  const onUp = () => {
1147
+ if (drag)
1148
+ opts.onGestureEnd?.();
940
1149
  drag = null;
1150
+ // Body press that never moved = a selection click, not a move.
1151
+ if (downAt && !moved && downAt.handle === 'body') {
1152
+ const { x, y, alt } = downAt;
1153
+ downAt = null;
1154
+ opts.onBodyClick?.(x, y, alt);
1155
+ return;
1156
+ }
1157
+ downAt = null;
941
1158
  };
942
1159
  app.stage.on('pointermove', onMove);
943
1160
  app.stage.on('pointerup', onUp);
@@ -980,6 +1197,24 @@ function createTransformGizmo(opts) {
980
1197
  redraw();
981
1198
  },
982
1199
  refresh: redraw,
1200
+ nudge(dxScreen, dyScreen) {
1201
+ if (!nodeId)
1202
+ return;
1203
+ const { rule, target } = effectiveRule(nodeId);
1204
+ if (!rule)
1205
+ return;
1206
+ // Bracketed as a gesture so it lands on the undo stack as exactly one step.
1207
+ opts.onGestureStart?.();
1208
+ const s = dpp(nodeId);
1209
+ applyPatch({
1210
+ op: 'set-layout',
1211
+ id: nodeId,
1212
+ layout: nudgeRule(rule, dxScreen * s, dyScreen * s),
1213
+ orientation: target.orientation,
1214
+ });
1215
+ opts.onGestureEnd?.();
1216
+ redraw();
1217
+ },
983
1218
  drag(h, from, to) {
984
1219
  onDown(h, fakeEvent(from.x, from.y));
985
1220
  onMove(fakeEvent(to.x, to.y));
@@ -1028,88 +1263,222 @@ function mountIdeBridge(opts) {
1028
1263
  const post = (msg) => window.parent?.postMessage(msg, origin);
1029
1264
  const emit = (event, payload) => post({ type: EVT, event, payload });
1030
1265
  const pushSnapshot = () => emit('snapshot', snapshot());
1031
- // Current authoring mode — the view the canvas shows AND the override edits target.
1032
- let editMode = null;
1033
1266
  const snapshot = () => ({
1034
1267
  docId: handle.doc().id,
1035
1268
  doc: handle.doc(),
1036
1269
  tree: handle.tree(),
1037
1270
  palette: handle.palette(),
1271
+ // Per-kind props schemas: the editor lives in another origin and cannot reach the
1272
+ // registry, so the authoring contract travels with the snapshot.
1273
+ schemas: handle.schemas(),
1038
1274
  sceneFile: opts.sceneFile,
1039
- modes: handle.doc().modes ?? [],
1040
- mode: editMode,
1041
1275
  selectedId,
1042
1276
  });
1277
+ // Undo bookkeeping. Every applied patch reports the patch that undoes it; the editor
1278
+ // keeps the stack. A gizmo drag emits a patch per pointer frame, so while a gesture is in
1279
+ // flight we keep only the FIRST inverse — undoing a drag must rewind the whole gesture,
1280
+ // not one mouse-move.
1281
+ let gesture = null;
1282
+ const recordEdit = (result) => {
1283
+ if (result.ok !== true || !result.inverse)
1284
+ return;
1285
+ if (gesture) {
1286
+ gesture.inverse ??= result.inverse;
1287
+ return;
1288
+ }
1289
+ emit('edit', { inverse: result.inverse });
1290
+ };
1043
1291
  // Transform gizmo (resize/rotate/move on canvas) — owns the selection visuals too, so
1044
1292
  // no game writes selection code. Its patches round-trip a fresh snapshot to the IDE.
1045
1293
  const gizmo = createTransformGizmo({
1046
1294
  handle,
1047
1295
  app,
1048
1296
  getOrientation: opts.getOrientation,
1049
- getMode: () => editMode,
1050
1297
  designPerPixel: opts.designPerPixel,
1051
1298
  applyPatch: (patch) => {
1052
- handle.patch(patch);
1299
+ recordEdit(handle.patch(patch));
1053
1300
  pushSnapshot();
1054
1301
  },
1302
+ onGestureStart: () => {
1303
+ gesture = {};
1304
+ // Pixi dispatches to the gizmo's own graphics before our canvas listener runs; this
1305
+ // stops a handle press from also re-picking underneath the gizmo.
1306
+ gizmoBusy = true;
1307
+ setTimeout(() => {
1308
+ gizmoBusy = false;
1309
+ }, 0);
1310
+ },
1311
+ onGestureEnd: () => {
1312
+ const pending = gesture?.inverse;
1313
+ gesture = null;
1314
+ if (pending)
1315
+ emit('edit', { inverse: pending });
1316
+ },
1317
+ // A press inside the selection box that never became a drag is a click, not a move:
1318
+ // forward it so overlapping siblings and deeper children stay reachable.
1319
+ onBodyClick: (x, y, alt) => pickAt(x, y, alt),
1055
1320
  });
1056
1321
  const select = (id) => {
1057
1322
  selectedId = id;
1058
1323
  gizmo.attach(id);
1059
1324
  emit('selected', { id });
1060
1325
  };
1061
- // Click any node on the canvas → select it (bubbles the id up to the IDE).
1062
- const wireHitAreas = () => {
1063
- const walk = (node) => {
1064
- if (node.id !== handle.doc().root.id) {
1065
- const view = handle.node(node.id);
1066
- if (view) {
1067
- view.eventMode = 'static';
1068
- view.cursor = 'pointer';
1069
- const lb = view.getLocalBounds();
1070
- view.hitArea = new pixi_js.Rectangle(lb.x, lb.y, lb.width, lb.height);
1071
- view.removeAllListeners('pointerdown');
1072
- view.on('pointerdown', (e) => {
1073
- e.stopPropagation();
1074
- select(node.id);
1075
- });
1076
- }
1326
+ /**
1327
+ * Canvas picking. Every node used to carry its own `hitArea` + a stopPropagation
1328
+ * pointerdown, which meant (a) the click rect went stale the moment anything resized,
1329
+ * and (b) you landed on whatever node happened to be deepest with no way to go up or
1330
+ * down. Picking is now computed at click time from live bounds, and yields the whole
1331
+ * ancestor chain under the cursor so the user can drill.
1332
+ */
1333
+ const hitChain = (globalX, globalY) => {
1334
+ const rootId = handle.doc().root.id;
1335
+ let best = [];
1336
+ const walk = (node, ancestors) => {
1337
+ const view = handle.node(node.id);
1338
+ if (!view || !view.visible || view.alpha === 0)
1339
+ return;
1340
+ const b = view.getBounds();
1341
+ const inside = globalX >= b.x && globalX <= b.x + b.width && globalY >= b.y && globalY <= b.y + b.height;
1342
+ const chain = inside && node.id !== rootId ? [...ancestors, node.id] : ancestors;
1343
+ // Document order IS draw order (parents before children, siblings in order), so the
1344
+ // LAST hit is the one drawn on top. Preferring the deepest instead would make a node
1345
+ // added on top of the reels lose to the grid underneath it.
1346
+ if (inside && chain.length > 0)
1347
+ best = chain;
1348
+ for (const child of node.children ?? [])
1349
+ walk(child, chain);
1350
+ };
1351
+ walk(handle.doc().root, []);
1352
+ return best; // outermost → innermost
1353
+ };
1354
+ /** Where the last pick happened, so clicking the same spot again drills one level in. */
1355
+ let lastPick = null;
1356
+ const pickAt = (globalX, globalY, deepest = false) => {
1357
+ const chain = hitChain(globalX, globalY);
1358
+ if (chain.length === 0) {
1359
+ lastPick = null;
1360
+ select(null);
1361
+ return;
1362
+ }
1363
+ const sameSpot = lastPick !== null &&
1364
+ Math.abs(lastPick.x - globalX) < 5 &&
1365
+ Math.abs(lastPick.y - globalY) < 5 &&
1366
+ lastPick.chain.join() === chain.join();
1367
+ const index = deepest
1368
+ ? chain.length - 1
1369
+ : sameSpot
1370
+ ? Math.min(lastPick.index + 1, chain.length - 1)
1371
+ : 0;
1372
+ lastPick = { x: globalX, y: globalY, chain, index };
1373
+ select(chain[index]);
1374
+ };
1375
+ /** Select the parent of the current selection (Esc on the canvas, or the IDE's key). */
1376
+ const selectParent = () => {
1377
+ if (!selectedId)
1378
+ return;
1379
+ const rootId = handle.doc().root.id;
1380
+ const findParent = (node, parent) => {
1381
+ if (node.id === selectedId)
1382
+ return parent;
1383
+ for (const c of node.children ?? []) {
1384
+ const hit = findParent(c, node);
1385
+ if (hit)
1386
+ return hit;
1077
1387
  }
1078
- node.children?.forEach(walk);
1388
+ return null;
1079
1389
  };
1080
- walk(handle.doc().root);
1390
+ const parent = findParent(handle.doc().root, null);
1391
+ if (parent && parent.id !== rootId) {
1392
+ // Keep drilling consistent: stepping out moves the cursor back up the same chain.
1393
+ if (lastPick)
1394
+ lastPick.index = Math.max(0, lastPick.chain.indexOf(parent.id));
1395
+ select(parent.id);
1396
+ }
1397
+ else {
1398
+ lastPick = null;
1399
+ select(null);
1400
+ }
1081
1401
  };
1082
- app.stage.eventMode = 'static';
1083
- app.stage.hitArea = app.screen;
1084
- app.stage.on('pointerdown', () => select(null));
1085
- wireHitAreas();
1402
+ // Picking listens on the CANVAS ELEMENT rather than the Pixi stage: a stage-level
1403
+ // `pointerdown` is only delivered when the stage itself wins the hit test, which depends
1404
+ // on event modes set elsewhere in the host — node-level handlers fired while the stage's
1405
+ // did not. The DOM event always arrives, and converting it is one rectangle of maths.
1406
+ const canvasEl = (app.canvas ?? app.view);
1407
+ /** True while the gizmo is handling this press (its own handles/body take priority). */
1408
+ let gizmoBusy = false;
1409
+ const onCanvasDown = (e) => {
1410
+ if (gizmoBusy)
1411
+ return;
1412
+ const rect = canvasEl.getBoundingClientRect();
1413
+ if (rect.width === 0 || rect.height === 0)
1414
+ return;
1415
+ // CSS pixels → renderer screen units (the space Pixi bounds are reported in).
1416
+ const x = ((e.clientX - rect.left) / rect.width) * app.screen.width;
1417
+ const y = ((e.clientY - rect.top) / rect.height) * app.screen.height;
1418
+ pickAt(x, y, e.altKey);
1419
+ };
1420
+ canvasEl.addEventListener('pointerdown', onCanvasDown);
1086
1421
  const afterStructural = () => {
1087
1422
  opts.relayout?.();
1088
- wireHitAreas();
1089
1423
  };
1090
1424
  // ── RPC ───────────────────────────────────────────────────────────────────────
1091
1425
  const methods = {
1092
1426
  snapshot: () => snapshot(),
1093
1427
  patch: (params) => {
1094
1428
  const patch = params;
1095
- handle.patch(patch);
1096
- if (patch.op === 'add-node' || patch.op === 'remove-node' || patch.op === 'move-node' || patch.op === 'duplicate-node') {
1097
- afterStructural();
1098
- }
1099
- return snapshot();
1429
+ // A rejected patch used to warn into the game's console and still answer "ok" —
1430
+ // throw so the editor learns why nothing changed.
1431
+ const result = handle.patch(patch);
1432
+ if (result && result.ok === false)
1433
+ throw new Error(result.error);
1434
+ // Geometry may have moved (a swapped texture resizes a sprite just as a layout edit
1435
+ // does); picking reads live bounds, so only the host relayout hook is needed.
1436
+ afterStructural();
1437
+ // Broadcast too: patches can arrive from outside the editor (an agent driving the
1438
+ // bridge directly), and those used to leave the editor mirroring a stale doc.
1439
+ pushSnapshot();
1440
+ // The inverse rides back with the response rather than as an event, so the caller
1441
+ // records exactly the edits it made — no double-recording, no message ordering race.
1442
+ return { ...snapshot(), inverse: result.ok === true ? result.inverse : undefined };
1443
+ },
1444
+ /** The aliases `props.src` may hold — authoritative, unlike the public/assets file scan. */
1445
+ assetAliases: () => ({ aliases: opts.assetAliases?.() ?? null }),
1446
+ /**
1447
+ * Run a gizmo handle drag programmatically — the same path the mouse takes (down, move,
1448
+ * up), so tests and agents exercise gesture coalescing rather than a single patch.
1449
+ */
1450
+ /** Arrow-key move of the selection, in screen px (one undo step per call). */
1451
+ nudge: (params) => {
1452
+ const p = params;
1453
+ gizmo.nudge(p.dx, p.dy);
1454
+ return { selectedId };
1455
+ },
1456
+ gizmoDrag: (params) => {
1457
+ const p = params;
1458
+ gizmo.drag(p.handle, p.from, p.to);
1459
+ return { ok: true };
1100
1460
  },
1101
1461
  select: (params) => {
1102
- select(params.id);
1462
+ const id = params.id;
1463
+ // A selection made in the tree restarts the drill cursor for the next canvas click.
1464
+ lastPick = null;
1465
+ select(id);
1103
1466
  return { selectedId };
1104
1467
  },
1105
- // Switch the authoring mode: drives the runtime `mode` var (so visibleWhen + mode
1106
- // overlays render the mode's view) AND becomes the target of subsequent edits.
1107
- setMode: (params) => {
1108
- const mode = params.mode;
1109
- editMode = mode && mode !== 'base' ? mode : null;
1110
- handle.setVar('mode', editMode ?? 'base');
1111
- pushSnapshot();
1112
- return { mode: editMode };
1468
+ /** Step out one level (Esc) the counterpart to click-again-to-drill-in. */
1469
+ selectParent: () => {
1470
+ selectParent();
1471
+ return { selectedId };
1472
+ },
1473
+ /**
1474
+ * Pick at a canvas point exactly as a click does — same drill-in cursor. Exposed so
1475
+ * tests and agents can select without synthesising pointer events (headless renderers
1476
+ * do not deliver them), and so the chain is inspectable.
1477
+ */
1478
+ pick: (params) => {
1479
+ const p = params;
1480
+ pickAt(p.x, p.y, p.deepest === true);
1481
+ return { selectedId, chain: hitChain(p.x, p.y) };
1113
1482
  },
1114
1483
  save: async (params) => {
1115
1484
  const file = params?.file ?? opts.sceneFile;
@@ -1160,7 +1529,9 @@ function mountIdeBridge(opts) {
1160
1529
  file: f.file,
1161
1530
  events: Object.keys(f.doc.on ?? {}),
1162
1531
  cues: Object.keys(f.doc.cues ?? {}),
1163
- stepKinds: f.stepKinds ?? [],
1532
+ // Prefer what the runner actually accepts, with each kind's authoring schema; the
1533
+ // editor is cross-origin and cannot reach the registry itself.
1534
+ stepKinds: f.runner.stepKinds?.() ?? (f.stepKinds ?? []).map((kind) => ({ kind })),
1164
1535
  ctx: f.ctx ?? [],
1165
1536
  };
1166
1537
  },
@@ -1208,12 +1579,54 @@ function mountIdeBridge(opts) {
1208
1579
  requireFlow().runner.skip();
1209
1580
  return { ok: true };
1210
1581
  },
1582
+ // ── stage surface (multi-scene graph) — present only if opts.stage was given ─────
1583
+ stageMeta: () => {
1584
+ const s = opts.stage;
1585
+ if (!s)
1586
+ return { enabled: false };
1587
+ return { enabled: true, manifest: s.manifest, activeKey: s.activeKey, file: s.file, sceneParam: s.sceneParam ?? 'scene' };
1588
+ },
1589
+ stageSetManifest: (params) => {
1590
+ requireStage().manifest = params;
1591
+ return { ok: true };
1592
+ },
1593
+ stageSave: async () => {
1594
+ const s = requireStage();
1595
+ if (!s.file)
1596
+ throw new Error('no stage file configured');
1597
+ const res = await fetch('/__ide/write', {
1598
+ method: 'POST',
1599
+ headers: { 'content-type': 'application/json' },
1600
+ body: JSON.stringify({ file: s.file, content: s.manifest }),
1601
+ });
1602
+ if (!res.ok)
1603
+ throw new Error(await res.text());
1604
+ return { saved: s.file };
1605
+ },
1606
+ // Create a scene doc file (blank or a copy) so a new/duplicated scene has a real doc.
1607
+ stageCreateScene: async (params) => {
1608
+ const { file, from } = params;
1609
+ const content = from ?? { version: 1, id: file, design: handle.doc().design, root: { id: 'root', type: 'container', children: [] } };
1610
+ const res = await fetch('/__ide/write', {
1611
+ method: 'POST',
1612
+ headers: { 'content-type': 'application/json' },
1613
+ body: JSON.stringify({ file, content }),
1614
+ });
1615
+ if (!res.ok)
1616
+ throw new Error(await res.text());
1617
+ return { file };
1618
+ },
1211
1619
  };
1212
1620
  const requireFlow = () => {
1213
1621
  if (!opts.flow)
1214
1622
  throw new Error('this game exposed no flow surface');
1215
1623
  return opts.flow;
1216
1624
  };
1625
+ const requireStage = () => {
1626
+ if (!opts.stage)
1627
+ throw new Error('this game exposed no stage surface');
1628
+ return opts.stage;
1629
+ };
1217
1630
  /** Topmost sprite-like node (props.src) whose laid-out bounds contain the point. */
1218
1631
  const topSpriteAt = (x, y) => {
1219
1632
  let best = null;
@@ -1262,13 +1675,14 @@ function mountIdeBridge(opts) {
1262
1675
  };
1263
1676
  window.addEventListener('message', onMessage);
1264
1677
  // Announce readiness (the IDE may mount before or after the game boots).
1265
- emit('ready', { docId: handle.doc().id, hasFlow: !!opts.flow });
1678
+ emit('ready', { docId: handle.doc().id, hasFlow: !!opts.flow, hasStage: !!opts.stage });
1266
1679
  return {
1267
1680
  refresh: pushSnapshot,
1268
1681
  select,
1269
1682
  gizmo,
1270
1683
  destroy() {
1271
1684
  window.removeEventListener('message', onMessage);
1685
+ canvasEl.removeEventListener('pointerdown', onCanvasDown);
1272
1686
  gizmo.destroy();
1273
1687
  },
1274
1688
  };