@energy8platform/game-engine 0.33.6 → 0.33.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { Container, Graphics, Rectangle } from 'pixi.js';
2
+
1
3
  // Pure helpers behind the scene inspector — kept renderer/DOM-free so the control
2
4
  // inference and layout-field enumeration are unit-testable and reusable (the same
3
5
  // mapping later feeds schema-driven autogen and the agent's docs).
@@ -80,7 +82,7 @@ function setRulePath(rule, path, value) {
80
82
  // a game's preview page, later a harness panel) mount it into any container element.
81
83
  // Every edit leaves through a ScenePatch, so a mouse edit and an agent edit are the
82
84
  // same operation on the same doc.
83
- const STYLE = `
85
+ const STYLE$2 = `
84
86
  .e8si { font: 12px/1.45 system-ui, sans-serif; color: #dfe6f5; display: flex; flex-direction: column; height: 100%; min-height: 0; }
85
87
  .e8si h3 { margin: 0; padding: 8px 10px 6px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: #7d8ab0; }
86
88
  .e8si-tree { overflow: auto; max-height: 42%; border-bottom: 1px solid #1c2540; padding-bottom: 6px; }
@@ -112,13 +114,17 @@ function createSceneInspector(opts) {
112
114
  const root = document.createElement('div');
113
115
  root.className = 'e8si';
114
116
  const style = document.createElement('style');
115
- style.textContent = STYLE;
117
+ style.textContent = STYLE$2;
116
118
  root.appendChild(style);
119
+ const showTree = opts.showTree ?? true;
117
120
  const treeHost = document.createElement('div');
118
121
  treeHost.className = 'e8si-tree';
119
122
  const inspHost = document.createElement('div');
120
123
  inspHost.className = 'e8si-insp';
121
- root.append(treeHost, inspHost);
124
+ if (showTree)
125
+ root.append(treeHost, inspHost);
126
+ else
127
+ root.append(inspHost);
122
128
  container.appendChild(root);
123
129
  let selectedId = null;
124
130
  // Edits from our own inputs schedule a refresh that must not rebuild the form under
@@ -149,6 +155,8 @@ function createSceneInspector(opts) {
149
155
  };
150
156
  // ── Outliner ────────────────────────────────────────────────────────────────
151
157
  const renderTree = () => {
158
+ if (!showTree)
159
+ return;
152
160
  treeHost.textContent = '';
153
161
  const title = document.createElement('h3');
154
162
  title.textContent = 'Scene';
@@ -213,6 +221,13 @@ function createSceneInspector(opts) {
213
221
  meta.textContent = `${node.type}${node.name ? ` · ${node.name}` : ''}${node.anchorName ? ` · @${node.anchorName}` : ''}${node.visibleWhen ? ` · when: ${node.visibleWhen}` : ''}`;
214
222
  head.append(idEl, meta);
215
223
  inspHost.appendChild(head);
224
+ if (opts.onCalibrate && node.props?.inner && typeof node.props.inner.left === 'number') {
225
+ const btn = document.createElement('button');
226
+ btn.className = 'e8si-apply';
227
+ btn.textContent = '⊞ Calibrate inner (drag edges on canvas)';
228
+ btn.addEventListener('click', () => opts.onCalibrate(id));
229
+ inspHost.appendChild(btn);
230
+ }
216
231
  // State selector (named prop variants).
217
232
  const stateNames = Object.keys(node.states ?? {});
218
233
  if (stateNames.length > 0) {
@@ -368,5 +383,751 @@ function createSceneInspector(opts) {
368
383
  };
369
384
  }
370
385
 
371
- export { createSceneInspector, layoutFieldSpecs, propControlKind, setRulePath };
386
+ // Flow trace timeline the visual half of the first-class Trace. Rows are step
387
+ // beats positioned proportionally on the run's time axis; the same data the agent
388
+ // asserts on, rendered for the human's timing eye ("фил" тайминга, §3.5).
389
+ const STYLE$1 = `
390
+ .e8ft { font: 11px/1.5 ui-monospace, monospace; color: #dfe6f5; }
391
+ .e8ft h3 { margin: 0; padding: 8px 10px 4px; font: 11px/1 system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; color: #7d8ab0; }
392
+ .e8ft-meta { padding: 0 10px 6px; color: #8fa0c9; font-family: system-ui, sans-serif; }
393
+ .e8ft-row { display: flex; align-items: center; gap: 6px; padding: 1px 10px; white-space: nowrap; }
394
+ .e8ft-t { flex: 0 0 52px; text-align: right; color: #7d8ab0; }
395
+ .e8ft-track { flex: 1; position: relative; height: 12px; background: #0d1428; border-radius: 3px; overflow: hidden; }
396
+ .e8ft-dot { position: absolute; top: 1px; width: 10px; height: 10px; border-radius: 2px; }
397
+ .e8ft-label { flex: 0 0 46%; overflow: hidden; text-overflow: ellipsis; }
398
+ .e8ft-label .n { color: #8fa0c9; }
399
+ `;
400
+ const KIND_COLORS = {
401
+ tween: '#4dd0ff',
402
+ sound: '#ffd24a',
403
+ wait: '#55617f',
404
+ countUp: '#6ad55a',
405
+ setProps: '#c97fff',
406
+ setState: '#ff8a5a',
407
+ setVar: '#ff8a5a',
408
+ if: '#9b9bc9',
409
+ parallel: '#9b9bc9',
410
+ seq: '#9b9bc9',
411
+ code: '#ff4d8d',
412
+ };
413
+ const colorFor = (kind) => {
414
+ if (KIND_COLORS[kind])
415
+ return KIND_COLORS[kind];
416
+ let hash = 0;
417
+ for (const ch of kind)
418
+ hash = (hash * 31 + ch.charCodeAt(0)) % 360;
419
+ return `hsl(${hash} 70% 60%)`;
420
+ };
421
+ function createFlowTimeline(opts) {
422
+ const root = document.createElement('div');
423
+ root.className = 'e8ft';
424
+ const style = document.createElement('style');
425
+ style.textContent = STYLE$1;
426
+ root.appendChild(style);
427
+ const body = document.createElement('div');
428
+ root.appendChild(body);
429
+ opts.container.appendChild(root);
430
+ return {
431
+ show(trace) {
432
+ body.textContent = '';
433
+ const title = document.createElement('h3');
434
+ title.textContent = `Flow: ${trace.event}`;
435
+ const meta = document.createElement('div');
436
+ meta.className = 'e8ft-meta';
437
+ meta.textContent = `${trace.entries.length} steps · ${trace.durationMs}ms · turbo ×${trace.turbo}${trace.skipped ? ' · SKIPPED→settle' : ''}`;
438
+ body.append(title, meta);
439
+ if (opts.onScrub) {
440
+ const hint = document.createElement('div');
441
+ hint.className = 'e8ft-meta';
442
+ hint.textContent = 'click a beat to scrub the scene to that moment';
443
+ body.appendChild(hint);
444
+ }
445
+ const total = Math.max(1, trace.durationMs);
446
+ trace.entries.forEach((entry, index) => {
447
+ const row = document.createElement('div');
448
+ row.className = 'e8ft-row';
449
+ if (opts.onScrub) {
450
+ row.style.cursor = 'pointer';
451
+ row.addEventListener('click', () => opts.onScrub(trace.event, index));
452
+ row.addEventListener('mouseenter', () => (row.style.background = '#141c36'));
453
+ row.addEventListener('mouseleave', () => (row.style.background = ''));
454
+ }
455
+ const t = document.createElement('span');
456
+ t.className = 'e8ft-t';
457
+ t.textContent = `+${entry.t}`;
458
+ const track = document.createElement('span');
459
+ track.className = 'e8ft-track';
460
+ const dot = document.createElement('span');
461
+ dot.className = 'e8ft-dot';
462
+ dot.style.left = `${Math.min(98, (entry.t / total) * 100)}%`;
463
+ dot.style.background = colorFor(entry.do);
464
+ track.appendChild(dot);
465
+ const label = document.createElement('span');
466
+ label.className = 'e8ft-label';
467
+ label.innerHTML = '';
468
+ label.textContent = entry.do;
469
+ if (entry.node || entry.info) {
470
+ const n = document.createElement('span');
471
+ n.className = 'n';
472
+ n.textContent = ` ${entry.node ?? ''}${entry.info ? ` — ${entry.info}` : ''}`;
473
+ label.appendChild(n);
474
+ }
475
+ row.append(t, track, label);
476
+ body.appendChild(row);
477
+ });
478
+ },
479
+ clear() {
480
+ body.textContent = '';
481
+ },
482
+ destroy() {
483
+ root.remove();
484
+ },
485
+ };
486
+ }
487
+
488
+ // Flow doc editor — the low-code half of goal 3. Edits the LIVE FlowDoc object in place
489
+ // (the runner reads doc.on at fire-time, so the very next spin plays the edited flow);
490
+ // the host persists via its /__write route. Scalar fields get typed inputs, structured
491
+ // fields fall back to JSON — the same schema-less inference as the scene inspector,
492
+ // upgraded automatically once step contributions carry schemas.
493
+ const STYLE = `
494
+ .e8fe { font: 12px/1.5 system-ui, sans-serif; color: #dfe6f5; }
495
+ .e8fe h3 { margin: 0; padding: 8px 10px 4px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: #7d8ab0; }
496
+ .e8fe-ev { padding: 4px 10px 2px; color: #9fb0d8; font-weight: 700; }
497
+ .e8fe-step { display: flex; align-items: center; gap: 4px; padding: 1px 10px 1px 18px; cursor: pointer; white-space: nowrap; }
498
+ .e8fe-step:hover { background: #141c36; }
499
+ .e8fe-step.sel { background: #1d2b55; }
500
+ .e8fe-step .k { color: #4dd0ff; }
501
+ .e8fe-step .s { color: #8fa0c9; overflow: hidden; text-overflow: ellipsis; }
502
+ .e8fe-step .sp { flex: 1; }
503
+ .e8fe-step button { background: none; border: none; color: #7d8ab0; cursor: pointer; padding: 0 3px; font: inherit; }
504
+ .e8fe-step button:hover { color: #dfe6f5; }
505
+ .e8fe-form { padding: 2px 10px 6px 18px; border-left: 2px solid #1d2b55; margin-left: 14px; }
506
+ .e8fe-field { display: flex; align-items: center; gap: 6px; padding: 1px 0; }
507
+ .e8fe-field label { flex: 0 0 84px; color: #9fb0d8; }
508
+ .e8fe-field input, .e8fe-field textarea { flex: 1; min-width: 0; background: #0d1428; color: #dfe6f5; border: 1px solid #2b3866; border-radius: 6px; padding: 2px 6px; font: inherit; }
509
+ .e8fe-add { display: flex; gap: 6px; padding: 3px 10px 8px 18px; }
510
+ .e8fe-add select, .e8fe-add button { background: #18213d; color: #dfe6f5; border: 1px solid #2b3866; border-radius: 6px; padding: 2px 8px; font: inherit; cursor: pointer; }
511
+ `;
512
+ function summarize(step) {
513
+ const s = step;
514
+ const bits = [];
515
+ for (const key of ['node', 'cue', 'name', 'state', 'when', 'ref', 'ms', 'to']) {
516
+ if (s[key] !== undefined && typeof s[key] !== 'object')
517
+ bits.push(`${key}:${String(s[key])}`);
518
+ }
519
+ if (step.do === 'parallel')
520
+ bits.push(`${s.steps.length} tracks`);
521
+ if (step.do === 'seq' || step.do === 'if')
522
+ bits.push('…');
523
+ return bits.join(' ');
524
+ }
525
+ const DEFAULT_KINDS = ['wait', 'sound', 'tween', 'setState', 'setVar', 'setProps', 'countUp', 'if', 'parallel', 'seq', 'forEach', 'code'];
526
+ function createFlowEditor(opts) {
527
+ const { doc } = opts;
528
+ const root = document.createElement('div');
529
+ root.className = 'e8fe';
530
+ const style = document.createElement('style');
531
+ style.textContent = STYLE;
532
+ root.appendChild(style);
533
+ const body = document.createElement('div');
534
+ root.appendChild(body);
535
+ opts.container.appendChild(root);
536
+ let selected = null;
537
+ const changed = () => {
538
+ opts.onChange?.();
539
+ render();
540
+ };
541
+ const stepForm = (step) => {
542
+ const form = document.createElement('div');
543
+ form.className = 'e8fe-form';
544
+ const record = step;
545
+ for (const [key, value] of Object.entries(record)) {
546
+ if (key === 'do')
547
+ continue;
548
+ const field = document.createElement('div');
549
+ field.className = 'e8fe-field';
550
+ const label = document.createElement('label');
551
+ label.textContent = key;
552
+ field.appendChild(label);
553
+ const kind = propControlKind(value);
554
+ if (kind === 'json') {
555
+ const area = document.createElement('textarea');
556
+ area.rows = 3;
557
+ area.value = JSON.stringify(value);
558
+ area.addEventListener('change', () => {
559
+ try {
560
+ record[key] = JSON.parse(area.value);
561
+ changed();
562
+ }
563
+ catch {
564
+ area.style.borderColor = '#ff4d8d';
565
+ }
566
+ });
567
+ field.appendChild(area);
568
+ }
569
+ else if (kind === 'boolean') {
570
+ const input = document.createElement('input');
571
+ input.type = 'checkbox';
572
+ input.checked = value === true;
573
+ input.addEventListener('change', () => {
574
+ record[key] = input.checked;
575
+ changed();
576
+ });
577
+ field.appendChild(input);
578
+ }
579
+ else {
580
+ const input = document.createElement('input');
581
+ input.type = kind === 'number' ? 'number' : 'text';
582
+ input.value = String(value);
583
+ input.addEventListener('change', () => {
584
+ const raw = kind === 'number' ? Number(input.value) : input.value;
585
+ if (kind === 'number' && !Number.isFinite(raw))
586
+ return;
587
+ record[key] = raw;
588
+ changed();
589
+ });
590
+ field.appendChild(input);
591
+ }
592
+ form.appendChild(field);
593
+ }
594
+ return form;
595
+ };
596
+ const render = () => {
597
+ body.textContent = '';
598
+ const title = document.createElement('h3');
599
+ title.textContent = 'Flow steps';
600
+ body.appendChild(title);
601
+ for (const [event, steps] of Object.entries(doc.on)) {
602
+ const header = document.createElement('div');
603
+ header.className = 'e8fe-ev';
604
+ header.textContent = `on ${event}`;
605
+ body.appendChild(header);
606
+ steps.forEach((step, index) => {
607
+ const row = document.createElement('div');
608
+ row.className = `e8fe-step${selected?.event === event && selected.index === index ? ' sel' : ''}`;
609
+ const k = document.createElement('span');
610
+ k.className = 'k';
611
+ k.textContent = step.do;
612
+ const s = document.createElement('span');
613
+ s.className = 's';
614
+ s.textContent = summarize(step);
615
+ const spacer = document.createElement('span');
616
+ spacer.className = 'sp';
617
+ row.append(k, s, spacer);
618
+ const button = (text, title, fn) => {
619
+ const b = document.createElement('button');
620
+ b.textContent = text;
621
+ b.title = title;
622
+ b.addEventListener('click', (e) => {
623
+ e.stopPropagation();
624
+ fn();
625
+ });
626
+ row.appendChild(b);
627
+ };
628
+ button('↑', 'move up', () => {
629
+ if (index === 0)
630
+ return;
631
+ [steps[index - 1], steps[index]] = [steps[index], steps[index - 1]];
632
+ changed();
633
+ });
634
+ button('↓', 'move down', () => {
635
+ if (index >= steps.length - 1)
636
+ return;
637
+ [steps[index + 1], steps[index]] = [steps[index], steps[index + 1]];
638
+ changed();
639
+ });
640
+ button('✕', 'delete step', () => {
641
+ steps.splice(index, 1);
642
+ if (selected?.event === event && selected.index === index)
643
+ selected = null;
644
+ changed();
645
+ });
646
+ row.addEventListener('click', () => {
647
+ selected = selected?.event === event && selected.index === index ? null : { event, index };
648
+ render();
649
+ });
650
+ body.appendChild(row);
651
+ if (selected?.event === event && selected.index === index)
652
+ body.appendChild(stepForm(step));
653
+ });
654
+ const add = document.createElement('div');
655
+ add.className = 'e8fe-add';
656
+ const select = document.createElement('select');
657
+ for (const kind of opts.kinds ?? DEFAULT_KINDS) {
658
+ const option = document.createElement('option');
659
+ option.value = kind;
660
+ option.textContent = kind;
661
+ select.appendChild(option);
662
+ }
663
+ const addBtn = document.createElement('button');
664
+ addBtn.textContent = '+ step';
665
+ addBtn.addEventListener('click', () => {
666
+ const kind = select.value;
667
+ const skeletons = {
668
+ wait: { do: 'wait', ms: 300 },
669
+ sound: { do: 'sound', cue: Object.keys(doc.cues ?? {})[0] ?? 'cue' },
670
+ tween: { do: 'tween', node: 'node', to: { alpha: 1 }, ms: 300 },
671
+ setState: { do: 'setState', node: 'node', state: null },
672
+ setVar: { do: 'setVar', name: 'mode', value: 'BASE' },
673
+ setProps: { do: 'setProps', node: 'node', props: {} },
674
+ countUp: { do: 'countUp', node: 'node', to: '$win', ms: 1000 },
675
+ if: { do: 'if', when: 'win >= 1', then: [], else: [] },
676
+ parallel: { do: 'parallel', steps: [[], []] },
677
+ seq: { do: 'seq', steps: [] },
678
+ forEach: { do: 'forEach', items: '$items', as: 'item', mode: 'parallel', staggerMs: 90, steps: [] },
679
+ code: { do: 'code', ref: 'ref' },
680
+ };
681
+ steps.push(skeletons[kind] ?? { do: kind });
682
+ changed();
683
+ });
684
+ add.append(select, addBtn);
685
+ body.appendChild(add);
686
+ }
687
+ };
688
+ render();
689
+ return {
690
+ refresh: render,
691
+ destroy() {
692
+ root.remove();
693
+ },
694
+ };
695
+ }
696
+
697
+ // Pure transform math for the canvas gizmo: turn a drag into a new LayoutRule, writing
698
+ // the RIGHT field for the rule's kind — the thing the user asked for ("resize on the
699
+ // canvas, not just the sidebar"). Resize maps to a rule's natural size lever:
700
+ // • widthFrac/heightFrac (viewport-fraction) or wFrac/hFrac (frame-fraction) — per-axis;
701
+ // • otherwise a uniform `scale` (absolute / cover / grid-cell / pin / plain fraction).
702
+ // The engine re-places the node by its own anchor after the lever changes, so the anchor
703
+ // stays pinned for free — no position juggling here. Rotation writes `rotation`.
704
+ /** Which size lever a rule exposes — decides how a resize drag is interpreted. */
705
+ function sizeLever(rule) {
706
+ if (rule.mode === 'viewport-fraction' && (rule.widthFrac !== undefined || rule.heightFrac !== undefined))
707
+ return 'wh-frac';
708
+ if (rule.mode === 'frame-fraction' && (rule.wFrac !== undefined || rule.hFrac !== undefined))
709
+ return 'wh-box';
710
+ if (rule.mode === 'frame-fraction')
711
+ return 'none'; // point placement inside a frame — no size
712
+ return 'scale';
713
+ }
714
+ const clampFactor = (f) => (Number.isFinite(f) && f > 0.01 ? f : 0.01);
715
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
716
+ /**
717
+ * Multiply a rule's size lever by (factorX, factorY). Corners pass equal factors (uniform);
718
+ * edges pass 1 on the untouched axis. Uniform levers (`scale`) use factorX.
719
+ */
720
+ function resizeRule(rule, factorX, factorY) {
721
+ const fx = clampFactor(factorX);
722
+ const fy = clampFactor(factorY);
723
+ const next = JSON.parse(JSON.stringify(rule));
724
+ switch (sizeLever(rule)) {
725
+ case 'wh-frac': {
726
+ const r = next;
727
+ if (r.widthFrac !== undefined)
728
+ r.widthFrac = round4(r.widthFrac * fx);
729
+ if (r.heightFrac !== undefined)
730
+ r.heightFrac = round4(r.heightFrac * fy);
731
+ break;
732
+ }
733
+ case 'wh-box': {
734
+ const r = next;
735
+ if (r.wFrac !== undefined)
736
+ r.wFrac = round4(r.wFrac * fx);
737
+ if (r.hFrac !== undefined)
738
+ r.hFrac = round4(r.hFrac * fy);
739
+ break;
740
+ }
741
+ case 'scale': {
742
+ const r = next;
743
+ r.scale = round4((r.scale ?? 1) * fx);
744
+ break;
745
+ }
746
+ }
747
+ return next;
748
+ }
749
+ function setRuleRotation(rule, radians) {
750
+ const next = JSON.parse(JSON.stringify(rule));
751
+ next.rotation = round4(radians);
752
+ return next;
753
+ }
754
+ /** Nudge a rule's final pixel offset by a screen delta already converted to design units. */
755
+ function nudgeRule(rule, dxDesign, dyDesign) {
756
+ const next = JSON.parse(JSON.stringify(rule));
757
+ next.pxNudge = { x: Math.round((next.pxNudge?.x ?? 0) + dxDesign), y: Math.round((next.pxNudge?.y ?? 0) + dyDesign) };
758
+ return next;
759
+ }
760
+ /** Screen position of each handle for a bounding box. */
761
+ function handlePoint(box, id) {
762
+ const { x, y, width: w, height: h } = box;
763
+ const cx = x + w / 2;
764
+ const cy = y + h / 2;
765
+ switch (id) {
766
+ case 'nw': return { x, y };
767
+ case 'n': return { x: cx, y };
768
+ case 'ne': return { x: x + w, y };
769
+ case 'e': return { x: x + w, y: cy };
770
+ case 'se': return { x: x + w, y: y + h };
771
+ case 's': return { x: cx, y: y + h };
772
+ case 'sw': return { x, y: y + h };
773
+ case 'w': return { x, y: cy };
774
+ case 'rotate': return { x: cx, y: y - 28 };
775
+ }
776
+ }
777
+ /**
778
+ * Resize factors from dragging a handle, using the OPPOSITE edge/corner as the fixed
779
+ * reference — the intuitive "grab a corner, drag out, it grows" feel.
780
+ */
781
+ function resizeFactors(startBox, handle, pointer) {
782
+ const left = startBox.x;
783
+ const right = startBox.x + startBox.width;
784
+ const top = startBox.y;
785
+ const bottom = startBox.y + startBox.height;
786
+ const w = startBox.width || 1;
787
+ const h = startBox.height || 1;
788
+ const westX = () => Math.abs(pointer.x - right) / w; // dragging the west side, east fixed
789
+ const eastX = () => Math.abs(pointer.x - left) / w;
790
+ const northY = () => Math.abs(pointer.y - bottom) / h;
791
+ const southY = () => Math.abs(pointer.y - top) / h;
792
+ switch (handle) {
793
+ case 'w': return { factorX: westX(), factorY: 1 };
794
+ case 'e': return { factorX: eastX(), factorY: 1 };
795
+ case 'n': return { factorX: 1, factorY: northY() };
796
+ case 's': return { factorX: 1, factorY: southY() };
797
+ case 'nw': {
798
+ const f = uniform(westX(), northY());
799
+ return { factorX: f, factorY: f };
800
+ }
801
+ case 'ne': {
802
+ const f = uniform(eastX(), northY());
803
+ return { factorX: f, factorY: f };
804
+ }
805
+ case 'sw': {
806
+ const f = uniform(westX(), southY());
807
+ return { factorX: f, factorY: f };
808
+ }
809
+ case 'se': {
810
+ const f = uniform(eastX(), southY());
811
+ return { factorX: f, factorY: f };
812
+ }
813
+ default: return { factorX: 1, factorY: 1 };
814
+ }
815
+ }
816
+ /** Corner resize keeps aspect — use the diagonal ratio of the two per-axis factors. */
817
+ function uniform(a, b) {
818
+ return Math.sqrt(Math.max(0.0001, a) * Math.max(0.0001, b));
819
+ }
820
+
821
+ // Transform gizmo — the on-canvas resize / rotate / move manipulator for the selected
822
+ // node. Generalizes the frame calibrator: 8 resize handles + a rotate handle, and a body
823
+ // drag to move. Every gesture emits ONE set-layout ScenePatch (orientation-aware), so a
824
+ // mouse transform and an agent/inspector transform are the same operation. Live bounds are
825
+ // re-read each frame, so the gizmo follows the node as the engine relayouts.
826
+ const CORNERS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
827
+ const HS = 9; // handle size
828
+ function createTransformGizmo(opts) {
829
+ const { handle, app } = opts;
830
+ const applyPatch = opts.applyPatch ?? handle.patch;
831
+ const getOrientation = opts.getOrientation ?? (() => 'landscape');
832
+ // pxNudge lives in the placement (parent/space) coordinate system, which maps to screen
833
+ // by the parent's world scale — so screen delta ÷ parent-world-scale = design units.
834
+ const dpp = (id) => {
835
+ if (opts.designPerPixel)
836
+ return opts.designPerPixel(id);
837
+ const parent = handle.node(id)?.parent;
838
+ const s = parent?.worldTransform?.a ?? 1;
839
+ return s !== 0 ? 1 / s : 1;
840
+ };
841
+ const layer = new Container();
842
+ layer.eventMode = 'static';
843
+ app.stage.addChild(layer);
844
+ const frame = new Graphics();
845
+ layer.addChild(frame);
846
+ const handleGfx = new Map();
847
+ for (const id of [...CORNERS, 'rotate']) {
848
+ const g = new Graphics();
849
+ g.eventMode = 'static';
850
+ g.cursor = id === 'rotate' ? 'grab' : cursorFor(id);
851
+ g.on('pointerdown', (e) => onDown(id, e));
852
+ handleGfx.set(id, g);
853
+ layer.addChild(g);
854
+ }
855
+ // A transparent body catcher for move-drags (below the handles).
856
+ const body = new Graphics();
857
+ body.eventMode = 'static';
858
+ body.cursor = 'move';
859
+ body.on('pointerdown', (e) => onDown('body', e));
860
+ layer.addChildAt(body, 1);
861
+ let nodeId = null;
862
+ let drag = null;
863
+ const findDocNode = (id, node = handle.doc().root) => {
864
+ if (node.id === id)
865
+ return node;
866
+ for (const child of node.children ?? []) {
867
+ const hit = findDocNode(id, child);
868
+ if (hit)
869
+ return hit;
870
+ }
871
+ return undefined;
872
+ };
873
+ /** Effective layout rule for the current orientation (base or portrait/landscape override). */
874
+ const effectiveRule = (id) => {
875
+ const node = findDocNode(id);
876
+ if (!node)
877
+ return {};
878
+ const o = getOrientation();
879
+ const override = node.responsive?.[o]?.layout;
880
+ return override ? { rule: override, orientation: o } : { rule: node.layout };
881
+ };
882
+ const boxOf = (id) => {
883
+ const view = handle.node(id);
884
+ if (!view || !view.visible)
885
+ return null;
886
+ const b = view.getBounds();
887
+ return { x: b.x, y: b.y, width: b.width, height: b.height };
888
+ };
889
+ const onDown = (h, e) => {
890
+ e.stopPropagation();
891
+ if (!nodeId)
892
+ return;
893
+ const box = boxOf(nodeId);
894
+ const { rule, orientation } = effectiveRule(nodeId);
895
+ if (!box || !rule)
896
+ return;
897
+ const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
898
+ drag = {
899
+ handle: h,
900
+ startBox: box,
901
+ baseRule: JSON.parse(JSON.stringify(rule)),
902
+ orientation,
903
+ start: { x: e.global.x, y: e.global.y },
904
+ center,
905
+ startAngle: Math.atan2(e.global.y - center.y, e.global.x - center.x),
906
+ };
907
+ };
908
+ const onMove = (e) => {
909
+ if (!drag || !nodeId)
910
+ return;
911
+ let rule;
912
+ if (drag.handle === 'body') {
913
+ const s = dpp(nodeId);
914
+ rule = nudgeRule(drag.baseRule, (e.global.x - drag.start.x) * s, (e.global.y - drag.start.y) * s);
915
+ }
916
+ else if (drag.handle === 'rotate') {
917
+ const angle = Math.atan2(e.global.y - drag.center.y, e.global.x - drag.center.x);
918
+ rule = setRuleRotation(drag.baseRule, (drag.baseRule.rotation ?? 0) + (angle - drag.startAngle));
919
+ }
920
+ else {
921
+ if (sizeLever(drag.baseRule) === 'none')
922
+ return; // point-placed frame-fraction: nothing to size
923
+ const { factorX, factorY } = resizeFactors(drag.startBox, drag.handle, { x: e.global.x, y: e.global.y });
924
+ rule = resizeRule(drag.baseRule, factorX, factorY);
925
+ }
926
+ applyPatch({ op: 'set-layout', id: nodeId, layout: rule, orientation: drag.orientation });
927
+ };
928
+ const onUp = () => {
929
+ drag = null;
930
+ };
931
+ app.stage.on('pointermove', onMove);
932
+ app.stage.on('pointerup', onUp);
933
+ app.stage.on('pointerupoutside', onUp);
934
+ const redraw = () => {
935
+ const box = nodeId ? boxOf(nodeId) : null;
936
+ frame.clear();
937
+ body.clear();
938
+ if (!box) {
939
+ for (const g of handleGfx.values())
940
+ g.clear();
941
+ body.hitArea = null;
942
+ return;
943
+ }
944
+ frame.rect(box.x, box.y, box.width, box.height).stroke({ color: 0x4dd0ff, width: 1.5, alpha: 0.9 });
945
+ // rotate stem
946
+ const rot = handlePoint(box, 'rotate');
947
+ frame.moveTo(box.x + box.width / 2, box.y).lineTo(rot.x, rot.y).stroke({ color: 0x4dd0ff, width: 1, alpha: 0.6 });
948
+ body.rect(box.x, box.y, box.width, box.height).fill({ color: 0xffffff, alpha: 0.001 });
949
+ body.hitArea = new Rectangle(box.x, box.y, box.width, box.height);
950
+ const sizable = sizeLever(nodeId ? effectiveRule(nodeId).rule ?? drag?.baseRule ?? {} : {}) !== 'none';
951
+ for (const id of [...CORNERS, 'rotate']) {
952
+ const g = handleGfx.get(id);
953
+ g.clear();
954
+ if (id !== 'rotate' && !sizable)
955
+ continue; // hide resize handles when the rule can't size
956
+ const p = handlePoint(box, id);
957
+ if (id === 'rotate')
958
+ g.circle(p.x, p.y, HS / 1.6).fill({ color: 0x4dd0ff }).stroke({ color: 0x06283d, width: 2 });
959
+ else
960
+ g.rect(p.x - HS / 2, p.y - HS / 2, HS, HS).fill({ color: 0x4dd0ff }).stroke({ color: 0x06283d, width: 2 });
961
+ g.hitArea = new Rectangle(p.x - HS, p.y - HS, HS * 2, HS * 2);
962
+ }
963
+ };
964
+ app.ticker.add(redraw);
965
+ const fakeEvent = (x, y) => ({ global: { x, y }, stopPropagation() { } });
966
+ return {
967
+ attach(id) {
968
+ nodeId = id;
969
+ redraw();
970
+ },
971
+ refresh: redraw,
972
+ drag(h, from, to) {
973
+ onDown(h, fakeEvent(from.x, from.y));
974
+ onMove(fakeEvent(to.x, to.y));
975
+ onUp();
976
+ },
977
+ destroy() {
978
+ app.ticker.remove(redraw);
979
+ app.stage.off('pointermove', onMove);
980
+ app.stage.off('pointerup', onUp);
981
+ app.stage.off('pointerupoutside', onUp);
982
+ layer.destroy({ children: true });
983
+ },
984
+ };
985
+ }
986
+ function cursorFor(id) {
987
+ switch (id) {
988
+ case 'nw':
989
+ case 'se': return 'nwse-resize';
990
+ case 'ne':
991
+ case 'sw': return 'nesw-resize';
992
+ case 'n':
993
+ case 's': return 'ns-resize';
994
+ case 'e':
995
+ case 'w': return 'ew-resize';
996
+ default: return 'default';
997
+ }
998
+ }
999
+
1000
+ // IDE bridge — the game-side half of the standalone editor. The game runs in an iframe;
1001
+ // the IDE app is the parent window. They speak a tiny postMessage RPC (`e8:ide:*`):
1002
+ // parent → child { type:'e8:ide:req', id, method, params }
1003
+ // child → parent { type:'e8:ide:res', id, ok, result | error }
1004
+ // child → parent { type:'e8:ide:evt', event, payload } (unsolicited: selection, ready)
1005
+ //
1006
+ // The whole editor is doc-driven: the IDE never reaches into Pixi — it reads a snapshot
1007
+ // (doc/tree/palette) and sends ScenePatches, exactly what the agent does. Canvas
1008
+ // click-to-select is owned here so no game writes selection code. Save goes through the
1009
+ // same-origin idePlugin `/__ide/write`.
1010
+ const REQ = 'e8:ide:req';
1011
+ const RES = 'e8:ide:res';
1012
+ const EVT = 'e8:ide:evt';
1013
+ function mountIdeBridge(opts) {
1014
+ const { handle, app } = opts;
1015
+ const origin = opts.targetOrigin ?? '*';
1016
+ let selectedId = null;
1017
+ const post = (msg) => window.parent?.postMessage(msg, origin);
1018
+ const emit = (event, payload) => post({ type: EVT, event, payload });
1019
+ const pushSnapshot = () => emit('snapshot', snapshot());
1020
+ const snapshot = () => ({
1021
+ docId: handle.doc().id,
1022
+ doc: handle.doc(),
1023
+ tree: handle.tree(),
1024
+ palette: handle.palette(),
1025
+ sceneFile: opts.sceneFile,
1026
+ selectedId,
1027
+ });
1028
+ // Transform gizmo (resize/rotate/move on canvas) — owns the selection visuals too, so
1029
+ // no game writes selection code. Its patches round-trip a fresh snapshot to the IDE.
1030
+ const gizmo = createTransformGizmo({
1031
+ handle,
1032
+ app,
1033
+ getOrientation: opts.getOrientation,
1034
+ designPerPixel: opts.designPerPixel,
1035
+ applyPatch: (patch) => {
1036
+ handle.patch(patch);
1037
+ pushSnapshot();
1038
+ },
1039
+ });
1040
+ const select = (id) => {
1041
+ selectedId = id;
1042
+ gizmo.attach(id);
1043
+ emit('selected', { id });
1044
+ };
1045
+ // Click any node on the canvas → select it (bubbles the id up to the IDE).
1046
+ const wireHitAreas = () => {
1047
+ const walk = (node) => {
1048
+ if (node.id !== handle.doc().root.id) {
1049
+ const view = handle.node(node.id);
1050
+ if (view) {
1051
+ view.eventMode = 'static';
1052
+ view.cursor = 'pointer';
1053
+ const lb = view.getLocalBounds();
1054
+ view.hitArea = new Rectangle(lb.x, lb.y, lb.width, lb.height);
1055
+ view.removeAllListeners('pointerdown');
1056
+ view.on('pointerdown', (e) => {
1057
+ e.stopPropagation();
1058
+ select(node.id);
1059
+ });
1060
+ }
1061
+ }
1062
+ node.children?.forEach(walk);
1063
+ };
1064
+ walk(handle.doc().root);
1065
+ };
1066
+ app.stage.eventMode = 'static';
1067
+ app.stage.hitArea = app.screen;
1068
+ app.stage.on('pointerdown', () => select(null));
1069
+ wireHitAreas();
1070
+ const afterStructural = () => {
1071
+ opts.relayout?.();
1072
+ wireHitAreas();
1073
+ };
1074
+ // ── RPC ───────────────────────────────────────────────────────────────────────
1075
+ const methods = {
1076
+ snapshot: () => snapshot(),
1077
+ patch: (params) => {
1078
+ const patch = params;
1079
+ handle.patch(patch);
1080
+ if (patch.op === 'add-node' || patch.op === 'remove-node' || patch.op === 'move-node' || patch.op === 'duplicate-node') {
1081
+ afterStructural();
1082
+ }
1083
+ return snapshot();
1084
+ },
1085
+ select: (params) => {
1086
+ select(params.id);
1087
+ return { selectedId };
1088
+ },
1089
+ save: async (params) => {
1090
+ const file = params?.file ?? opts.sceneFile;
1091
+ if (!file)
1092
+ throw new Error('no sceneFile configured');
1093
+ const res = await fetch('/__ide/write', {
1094
+ method: 'POST',
1095
+ headers: { 'content-type': 'application/json' },
1096
+ body: JSON.stringify({ file, content: handle.doc() }),
1097
+ });
1098
+ if (!res.ok)
1099
+ throw new Error(await res.text());
1100
+ return { saved: file };
1101
+ },
1102
+ };
1103
+ const onMessage = async (e) => {
1104
+ const data = e.data;
1105
+ if (data?.type !== REQ || typeof data.id !== 'number' || !data.method)
1106
+ return;
1107
+ const fn = methods[data.method];
1108
+ if (!fn)
1109
+ return post({ type: RES, id: data.id, ok: false, error: `unknown method "${data.method}"` });
1110
+ try {
1111
+ const result = await fn(data.params);
1112
+ post({ type: RES, id: data.id, ok: true, result });
1113
+ }
1114
+ catch (err) {
1115
+ post({ type: RES, id: data.id, ok: false, error: String(err?.message ?? err) });
1116
+ }
1117
+ };
1118
+ window.addEventListener('message', onMessage);
1119
+ // Announce readiness (the IDE may mount before or after the game boots).
1120
+ emit('ready', { docId: handle.doc().id });
1121
+ return {
1122
+ refresh: pushSnapshot,
1123
+ select,
1124
+ gizmo,
1125
+ destroy() {
1126
+ window.removeEventListener('message', onMessage);
1127
+ gizmo.destroy();
1128
+ },
1129
+ };
1130
+ }
1131
+
1132
+ export { createFlowEditor, createFlowTimeline, createSceneInspector, layoutFieldSpecs, mountIdeBridge, propControlKind, setRulePath };
372
1133
  //# sourceMappingURL=scene-devtools.esm.js.map