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