incanto 0.28.0 → 0.29.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 (58) hide show
  1. package/README.md +44 -0
  2. package/bin/incanto-editor.mjs +118 -1
  3. package/dist/2d.d.ts +38 -5
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +319 -15
  6. package/dist/3d.js +5 -5
  7. package/dist/{behavior-CPibUfnH.d.ts → behavior-BAc0erXF.d.ts} +21 -0
  8. package/dist/{create-game-BRgWpNsa.js → create-game-CNKXGfpr.js} +270 -47
  9. package/dist/{create-game-71TJjW1T.js → create-game-CbuLWorm.js} +53 -14
  10. package/dist/debug.d.ts +51 -6
  11. package/dist/debug.js +242 -19
  12. package/dist/editor-switch-B0wB_DSr.d.ts +59 -0
  13. package/dist/editor-switch-CAKlJMIY.js +161 -0
  14. package/dist/editor.d.ts +49 -0
  15. package/dist/editor.js +8439 -0
  16. package/dist/{gameplay-DEG-TP7D.js → gameplay-L05WgWd1.js} +207 -37
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +22 -3
  20. package/dist/index.js +2 -2
  21. package/dist/{loader-DILt9PGC.d.ts → loader-B242FF6N.d.ts} +1 -1
  22. package/dist/net.d.ts +1 -1
  23. package/dist/net.js +2 -2
  24. package/dist/{pathfinding-RWYkNKx9.d.ts → pathfinding-BwD974Ss.d.ts} +1 -1
  25. package/dist/{physics-2d-DiVFFlH3.js → physics-2d-CCVTrKOd.js} +62 -2
  26. package/dist/{physics-3d--y5clE2j.js → physics-3d-BZZLtwJu.js} +439 -8
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-nObreUQR.js → register-BTg0EM7s.js} +35 -3
  30. package/dist/{register-6R75AC7-.js → register-C44aSduO.js} +5550 -5073
  31. package/dist/{register-CvpSUU3O.js → register-DWcWq4QG.js} +22 -2
  32. package/dist/{register-BFFE1Mh1.js → register-MelqEdza.js} +1 -1
  33. package/dist/test.d.ts +35 -3
  34. package/dist/test.js +104 -10
  35. package/dist/vite.d.ts +53 -1
  36. package/dist/vite.js +94 -2
  37. package/editor/assets/{agent8-CojUfCXN.js → agent8-DEVkEa3d.js} +1 -1
  38. package/editor/assets/debug-zGAtpDF0.js +2 -0
  39. package/editor/assets/index-Bx4UtWYY.js +10586 -0
  40. package/editor/index.html +3 -157
  41. package/package.json +3 -2
  42. package/schemas/scene.schema.json +102 -4
  43. package/skills/incanto-3d-models.md +38 -0
  44. package/skills/incanto-assets.md +13 -0
  45. package/skills/incanto-building-3d-games.md +83 -6
  46. package/skills/incanto-editor.md +151 -0
  47. package/skills/incanto-environment.md +65 -1
  48. package/skills/incanto-node-reference.md +33 -1
  49. package/skills/incanto-physics-and-input.md +45 -6
  50. package/skills/incanto-verifying-your-game.md +51 -2
  51. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +5 -0
  52. package/templates-app/beacon-isle-3d/package.json +1 -1
  53. package/templates-app/tps-3d/docs/project-3d-rules.md +5 -0
  54. package/templates-app/tps-3d/package.json +1 -1
  55. package/templates-app/village-quest-3d/docs/project-3d-rules.md +5 -0
  56. package/templates-app/village-quest-3d/package.json +1 -1
  57. package/editor/assets/index-D6RQgROR.js +0 -8330
  58. package/editor/assets/index-D8QvwvOm.css +0 -1
package/dist/debug.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { t as jsonClone } from "./json-BLk7H2Qa.js";
2
2
  import { s as mergeStaticProps } from "./registry-BVJ2HbCn.js";
3
+ import { t as debugSources } from "./debug-draw-CZmOYjL2.js";
3
4
  //#region src/debug/panel.ts
4
5
  /** Keep a panel rect on screen and above the minimum usable size. */
5
6
  function clampPanelRect(x, y, w, h, viewW, viewH, minW = 180, minH = 120) {
@@ -152,13 +153,52 @@ var FloatingPanel = class {
152
153
  };
153
154
  //#endregion
154
155
  //#region src/debug/index.ts
156
+ /**
157
+ * incanto/debug — the runtime debug overlay: play the game WITH X-ray vision.
158
+ *
159
+ * A ☰ debug menu (top-left) toggles three floating, draggable, resizable
160
+ * panels over the running game:
161
+ * - Explorer — the live scene tree; click a node to inspect it
162
+ * - Inspector — the selected node's current state; simple values editable
163
+ * - Logs — engine.log tail + optional browser-console capture, level filters
164
+ * plus a Stats chip — a fixed always-on-top perf readout pinned top-right
165
+ * (fps · frame ms / nodes · tris · draw calls) — and a Colliders switch that
166
+ * draws every physics shape as wireframe over the live game.
167
+ *
168
+ * Enable via `createGame({ debug: true })`, gated to dev (e.g.
169
+ * `debug: import.meta.env.VITE_INCANTO_DEBUG === '1'`). There is NO URL/query
170
+ * toggle — a deployed build must not be switchable on by end users.
171
+ */
172
+ /** Longest serialized value the inspector shows inline, un-folded. */
173
+ const DETAIL_INLINE_MAX = 96;
174
+ /** Expanded detail is capped — a 100k-float vertices array must not be the DOM. */
175
+ const DETAIL_MAX_ROWS = 200;
176
+ const DETAIL_MAX_CHARS = 8192;
177
+ /** One line saying what the folded value is and how much of it there is. */
178
+ function summarizeValue(value) {
179
+ if (Array.isArray(value)) return `Array(${value.length})`;
180
+ return `Object(${Object.keys(value).length} keys)`;
181
+ }
182
+ /** The unfolded body: one row per element where that reads better than a blob. */
183
+ function detailText(value, json) {
184
+ if (Array.isArray(value) && value.some((v) => typeof v === "object" && v !== null)) {
185
+ const rows = value.slice(0, DETAIL_MAX_ROWS).map((v) => JSON.stringify(v));
186
+ const extra = value.length - DETAIL_MAX_ROWS;
187
+ return rows.join("\n") + (extra > 0 ? `\n… ${extra} more` : "");
188
+ }
189
+ return json.length > DETAIL_MAX_CHARS ? `${json.slice(0, DETAIL_MAX_CHARS)} … (${json.length - DETAIL_MAX_CHARS} more chars)` : json;
190
+ }
191
+ /** A JSON object (not an array, not null) — the shape that becomes a block. */
192
+ function isPlainObject(value) {
193
+ return typeof value === "object" && value !== null && !Array.isArray(value);
194
+ }
155
195
  /** Attach the overlay; null when no DOM is available (headless). */
156
196
  function attachDebugOverlay(engine, opts = {}) {
157
197
  const doc = opts.doc ?? (typeof document !== "undefined" ? document : null);
158
198
  if (!doc) return null;
159
199
  const container = opts.container ?? (typeof document !== "undefined" ? document.body : null);
160
200
  if (!container || typeof container.appendChild !== "function") return null;
161
- return new DebugOverlay(engine, container, doc, opts.statsSource);
201
+ return new DebugOverlay(engine, container, doc, opts.statsSource, opts.actions);
162
202
  }
163
203
  const MAX_LOG_ROWS = 300;
164
204
  const CONSOLE_LEVELS = [
@@ -173,6 +213,7 @@ var DebugOverlay = class {
173
213
  container;
174
214
  doc;
175
215
  statsSource;
216
+ actions;
176
217
  panels = /* @__PURE__ */ new Map();
177
218
  cleanups = [];
178
219
  menuButton;
@@ -192,11 +233,16 @@ var DebugOverlay = class {
192
233
  consolePatched = [];
193
234
  frame = 0;
194
235
  editing = 0;
195
- constructor(engine, container, doc, statsSource) {
236
+ /** Which bulky values are unfolded, per node ('transforms', 'collider.vertices'). */
237
+ detailOpen = /* @__PURE__ */ new WeakMap();
238
+ /** Pointer inside the inspector — it stops refreshing under your hand. */
239
+ hovering = false;
240
+ constructor(engine, container, doc, statsSource, actions = []) {
196
241
  this.engine = engine;
197
242
  this.container = container;
198
243
  this.doc = doc;
199
244
  this.statsSource = statsSource;
245
+ this.actions = actions;
200
246
  this.menuButton = doc.createElement("div");
201
247
  this.menuButton.textContent = "☰ debug";
202
248
  applyStyle(this.menuButton, {
@@ -226,6 +272,7 @@ var DebugOverlay = class {
226
272
  }));
227
273
  this.cleanups.push(engine.sceneChanged.connect(() => {
228
274
  this.selected = null;
275
+ this.applyColliderScope();
229
276
  this.renderExplorer();
230
277
  this.renderInspector();
231
278
  }));
@@ -234,12 +281,34 @@ var DebugOverlay = class {
234
281
  if (this.frame % 30 !== 0) return;
235
282
  this.renderExplorer();
236
283
  this.renderStats();
237
- if (this.editing === 0) this.renderInspector();
284
+ if (this.editing === 0 && !this.hovering) this.renderInspector();
238
285
  }));
239
286
  }
240
287
  isOpen(id) {
288
+ if (id === "colliders") return this.colliderMode !== "off";
241
289
  return id === "stats" ? this.statsChip !== null : this.panels.has(id);
242
290
  }
291
+ /** How much of the physics world the wireframes show. */
292
+ colliderMode = "off";
293
+ /**
294
+ * Physics wireframes, from the menu instead of from the console. What you see
295
+ * is the shape data the solver holds — the fastest way to catch a collider
296
+ * that disagrees with the art it belongs to.
297
+ *
298
+ * `'selected'` narrows it to the Explorer's selection, because `'all'` on a
299
+ * real map is a terrain grid burying the one shape you came to look at.
300
+ */
301
+ setColliders(mode) {
302
+ this.colliderMode = mode;
303
+ this.applyColliderScope();
304
+ }
305
+ applyColliderScope() {
306
+ const mode = this.colliderMode;
307
+ for (const source of debugSources("2d").concat(debugSources("3d"))) {
308
+ source.debugDraw = mode !== "off";
309
+ source.debugScope = mode === "selected" ? this.selected : null;
310
+ }
311
+ }
243
312
  open(id) {
244
313
  if (id === "stats") {
245
314
  this.openStatsChip();
@@ -271,12 +340,21 @@ var DebugOverlay = class {
271
340
  }
272
341
  }[id]);
273
342
  panel.onClose = () => this.close(id);
343
+ if (id === "inspector") {
344
+ panel.body.addEventListener("pointerenter", () => {
345
+ this.hovering = true;
346
+ });
347
+ panel.body.addEventListener("pointerleave", () => {
348
+ this.hovering = false;
349
+ });
350
+ }
274
351
  this.panels.set(id, panel);
275
352
  if (id === "explorer") this.renderExplorer();
276
353
  if (id === "inspector") this.renderInspector();
277
354
  if (id === "logs") this.renderLogs();
278
355
  }
279
356
  close(id) {
357
+ if (id === "inspector") this.hovering = false;
280
358
  if (id === "stats") {
281
359
  this.statsChip?.remove();
282
360
  this.statsChip = null;
@@ -288,6 +366,14 @@ var DebugOverlay = class {
288
366
  this.panels.delete(id);
289
367
  }
290
368
  toggle(id) {
369
+ if (id === "colliders") {
370
+ this.setColliders({
371
+ off: "all",
372
+ all: "selected",
373
+ selected: "off"
374
+ }[this.colliderMode]);
375
+ return;
376
+ }
291
377
  if (this.isOpen(id)) this.close(id);
292
378
  else this.open(id);
293
379
  }
@@ -329,6 +415,7 @@ var DebugOverlay = class {
329
415
  }
330
416
  dispose() {
331
417
  this.engine.debugSelection = null;
418
+ this.setColliders("off");
332
419
  this.setConsoleCapture(false);
333
420
  for (const cleanup of this.cleanups) cleanup();
334
421
  for (const id of [...this.panels.keys()]) this.close(id);
@@ -337,6 +424,11 @@ var DebugOverlay = class {
337
424
  this.dropdown = null;
338
425
  this.menuButton.remove();
339
426
  }
427
+ /** `✓ Colliders · selected` — check for on, suffix for the collider mode. */
428
+ menuLabel(id, label) {
429
+ const suffix = id === "colliders" && this.colliderMode !== "off" ? ` · ${this.colliderMode}` : "";
430
+ return `${this.isOpen(id) ? "✓ " : ""}${label}${suffix}`;
431
+ }
340
432
  toggleDropdown() {
341
433
  if (this.dropdown) {
342
434
  this.dropdown.remove();
@@ -361,10 +453,11 @@ var DebugOverlay = class {
361
453
  ["explorer", "Explorer"],
362
454
  ["inspector", "Inspector"],
363
455
  ["logs", "Logs"],
364
- ["stats", "Stats"]
456
+ ["stats", "Stats"],
457
+ ["colliders", "Colliders"]
365
458
  ]) {
366
459
  const item = this.doc.createElement("div");
367
- item.textContent = `${this.isOpen(id) ? "✓ " : ""}${label}`;
460
+ item.textContent = this.menuLabel(id, label);
368
461
  applyStyle(item, {
369
462
  padding: "6px 14px",
370
463
  cursor: "pointer",
@@ -372,11 +465,32 @@ var DebugOverlay = class {
372
465
  });
373
466
  item.addEventListener("click", () => {
374
467
  this.toggle(id);
468
+ if (id === "colliders") {
469
+ item.textContent = this.menuLabel(id, label);
470
+ return;
471
+ }
375
472
  this.dropdown?.remove();
376
473
  this.dropdown = null;
377
474
  });
378
475
  menu.appendChild(item);
379
476
  }
477
+ for (const action of this.actions) {
478
+ const item = this.doc.createElement("div");
479
+ item.textContent = action.label;
480
+ applyStyle(item, {
481
+ padding: "6px 14px",
482
+ cursor: "pointer",
483
+ userSelect: "none",
484
+ borderTop: "1px solid rgba(255,255,255,0.14)",
485
+ color: "rgba(158,232,220,0.95)"
486
+ });
487
+ item.addEventListener("click", () => {
488
+ this.dropdown?.remove();
489
+ this.dropdown = null;
490
+ action.run();
491
+ });
492
+ menu.appendChild(item);
493
+ }
380
494
  this.container.appendChild(menu);
381
495
  this.dropdown = menu;
382
496
  }
@@ -418,9 +532,13 @@ var DebugOverlay = class {
418
532
  renderExplorer() {
419
533
  const panel = this.panels.get("explorer");
420
534
  if (!panel) return;
535
+ const scroll = panel.body.scrollTop;
421
536
  clear(panel.body);
422
537
  const root = this.engine.scene?.root;
423
- if (!root) return;
538
+ if (!root) {
539
+ panel.body.scrollTop = scroll;
540
+ return;
541
+ }
424
542
  const renderNode = (node, container) => {
425
543
  const ctor = node.constructor;
426
544
  const hasKids = node.children.length > 0;
@@ -473,6 +591,7 @@ var DebugOverlay = class {
473
591
  row.addEventListener("click", () => {
474
592
  this.selected = node;
475
593
  this.engine.debugSelection = node;
594
+ this.applyColliderScope();
476
595
  this.open("inspector");
477
596
  this.renderExplorer();
478
597
  this.renderInspector();
@@ -490,15 +609,18 @@ var DebugOverlay = class {
490
609
  }
491
610
  };
492
611
  renderNode(root, panel.body);
612
+ panel.body.scrollTop = scroll;
493
613
  }
494
614
  renderInspector() {
495
615
  const panel = this.panels.get("inspector");
496
616
  if (!panel) return;
617
+ const scroll = panel.body.scrollTop;
497
618
  clear(panel.body);
498
619
  let node = this.selected;
499
620
  if (node && node.tree === null) {
500
621
  this.selected = null;
501
622
  this.engine.debugSelection = null;
623
+ this.applyColliderScope();
502
624
  node = null;
503
625
  }
504
626
  if (!node) {
@@ -527,11 +649,37 @@ var DebugOverlay = class {
527
649
  panel.body.appendChild(groups);
528
650
  }
529
651
  const schema = mergeStaticProps(ctor);
530
- for (const key of Object.keys(schema)) this.renderPropRow(panel.body, node, key, schema[key]?.options);
531
- }
532
- renderPropRow(body, node, key, schemaOptions) {
533
652
  const record = node;
534
- const value = record[key];
653
+ for (const key of Object.keys(schema)) {
654
+ const def = schema[key];
655
+ this.renderValueRow(panel.body, node, key, {
656
+ read: () => record[key],
657
+ write: (next) => {
658
+ record[key] = next;
659
+ },
660
+ options: def?.options,
661
+ variants: def?.variants
662
+ });
663
+ }
664
+ panel.body.scrollTop = scroll;
665
+ }
666
+ /**
667
+ * One editable row. `read`/`write` are closures rather than a (node, key)
668
+ * pair so the SAME renderer serves a node prop and a key INSIDE an object
669
+ * prop — `collider.radius` gets a real number field instead of being a
670
+ * character in an ellipsised JSON dump.
671
+ */
672
+ renderValueRow(body, node, key, access) {
673
+ const value = access.read();
674
+ const schemaOptions = access.options;
675
+ const record = {
676
+ get [key]() {
677
+ return access.read();
678
+ },
679
+ set [key](next) {
680
+ access.write(next);
681
+ }
682
+ };
535
683
  const row = this.doc.createElement("div");
536
684
  applyStyle(row, {
537
685
  display: "flex",
@@ -551,6 +699,7 @@ var DebugOverlay = class {
551
699
  input.type = "number";
552
700
  input.value = String(current);
553
701
  applyStyle(input, inputStyle("70px"));
702
+ this.trackEditing(input);
554
703
  input.addEventListener("change", () => {
555
704
  const next = Number(input.value);
556
705
  if (Number.isFinite(next)) commit(next);
@@ -596,20 +745,94 @@ var DebugOverlay = class {
596
745
  record[key] = input.value;
597
746
  });
598
747
  row.appendChild(input);
599
- } else if (Array.isArray(value) && value.every((v) => typeof v === "number")) for (let i = 0; i < value.length; i++) row.appendChild(numberInput(value[i], (next) => {
748
+ } else if (Array.isArray(value) && value.length <= 8 && value.every((v) => typeof v === "number")) for (let i = 0; i < value.length; i++) row.appendChild(numberInput(value[i], (next) => {
600
749
  const arr = [...record[key]];
601
750
  arr[i] = next;
602
751
  record[key] = arr;
603
752
  }));
604
- else {
605
- const readout = this.doc.createElement("div");
606
- readout.textContent = JSON.stringify(jsonClone(value));
607
- applyStyle(readout, {
608
- opacity: "0.65",
609
- overflow: "hidden",
610
- textOverflow: "ellipsis"
753
+ else if (isPlainObject(value)) {
754
+ const nest = this.doc.createElement("div");
755
+ applyStyle(nest, {
756
+ marginLeft: "10px",
757
+ paddingLeft: "8px",
758
+ borderLeft: "1px solid rgba(255,255,255,0.14)"
611
759
  });
612
- row.appendChild(readout);
760
+ const tag = access.variants?.tag;
761
+ const keys = Object.keys(value);
762
+ if (tag && !keys.includes(tag)) keys.unshift(tag);
763
+ const basePath = access.path ?? key;
764
+ for (const k of keys) {
765
+ const isTag = k === tag;
766
+ this.renderValueRow(nest, node, k, {
767
+ path: `${basePath}.${k}`,
768
+ read: () => access.read()[k] ?? (isTag ? "" : null),
769
+ write: (next) => {
770
+ if (isTag && access.variants) {
771
+ const preset = access.variants.byTag[String(next)];
772
+ if (preset !== void 0) {
773
+ access.write(jsonClone(preset));
774
+ return;
775
+ }
776
+ }
777
+ access.write({
778
+ ...access.read(),
779
+ [k]: next
780
+ });
781
+ },
782
+ options: isTag && access.variants ? Object.keys(access.variants.byTag) : void 0
783
+ });
784
+ }
785
+ body.appendChild(row);
786
+ body.appendChild(nest);
787
+ return;
788
+ } else {
789
+ const json = JSON.stringify(jsonClone(value));
790
+ if (json.length <= DETAIL_INLINE_MAX) {
791
+ const readout = this.doc.createElement("div");
792
+ readout.textContent = json;
793
+ applyStyle(readout, {
794
+ opacity: "0.65",
795
+ whiteSpace: "pre-wrap",
796
+ wordBreak: "break-all"
797
+ });
798
+ row.appendChild(readout);
799
+ } else {
800
+ const path = access.path ?? key;
801
+ const open = this.detailOpen.get(node)?.has(path) ?? false;
802
+ const toggle = this.doc.createElement("div");
803
+ toggle.textContent = `${open ? "▾" : "▸"} ${summarizeValue(value)}`;
804
+ applyStyle(toggle, {
805
+ opacity: "0.75",
806
+ cursor: "pointer",
807
+ userSelect: "none"
808
+ });
809
+ toggle.addEventListener("click", () => {
810
+ let set = this.detailOpen.get(node);
811
+ if (!set) {
812
+ set = /* @__PURE__ */ new Set();
813
+ this.detailOpen.set(node, set);
814
+ }
815
+ if (open) set.delete(path);
816
+ else set.add(path);
817
+ this.renderInspector();
818
+ });
819
+ row.appendChild(toggle);
820
+ body.appendChild(row);
821
+ if (open) {
822
+ const detail = this.doc.createElement("div");
823
+ detail.textContent = detailText(value, json);
824
+ applyStyle(detail, {
825
+ opacity: "0.6",
826
+ whiteSpace: "pre-wrap",
827
+ wordBreak: "break-all",
828
+ marginLeft: "10px",
829
+ paddingLeft: "8px",
830
+ borderLeft: "1px solid rgba(255,255,255,0.14)"
831
+ });
832
+ body.appendChild(detail);
833
+ }
834
+ return;
835
+ }
613
836
  }
614
837
  body.appendChild(row);
615
838
  }
@@ -0,0 +1,59 @@
1
+ import { s as JsonObject } from "./schema-CcoWb32N.js";
2
+
3
+ //#region src/core/editor-switch.d.ts
4
+ /** Where a camera is and what it looks at, in world space. */
5
+ interface CameraPose {
6
+ position: [number, number, number];
7
+ target: [number, number, number];
8
+ }
9
+ /** What the editor module needs to know to take over the page. */
10
+ interface EditorOpenOptions {
11
+ /** The authored scene to edit. */
12
+ scene: JsonObject;
13
+ /** Start the edit view here (continuity out of the running game). */
14
+ camera?: CameraPose;
15
+ /** Called when the editor hands the page back — boot the game from `working`. */
16
+ play(working: JsonObject, camera?: CameraPose): void | Promise<void>;
17
+ /** Called when the user leaves the editor without playing. */
18
+ exit?(): void | Promise<void>;
19
+ /** Persist edits (from `editor.save`). Omitted: no save button. */
20
+ save?(working: JsonObject): void | Promise<void>;
21
+ /** Asset-library search (from `editor.library`). Omitted: no 📚 buttons. */
22
+ library?: LibrarySearch;
23
+ }
24
+ /**
25
+ * How a page opens its editor.
26
+ *
27
+ * Default: `import('incanto/editor')`, so `editor: true` is all a game needs.
28
+ * Apps that alias the engine to source (the examples, the playground) map that
29
+ * specifier themselves, or pass `open` and skip the resolution entirely.
30
+ */
31
+ interface EditorSwitchOptions {
32
+ open?(opts: EditorOpenOptions): void | Promise<void>;
33
+ /** Persist edits. Omitted: the editor's save button is hidden. */
34
+ save?(working: JsonObject): void | Promise<void>;
35
+ /**
36
+ * Search the agent8 asset library from inside the game's editor.
37
+ *
38
+ * `true` means "my dev server serves `/api/library`" — add
39
+ * `incantoLibrary()` from `incanto/vite` and this is the whole opt-in. A
40
+ * function is your own transport. Omitted: the 📚 buttons are not rendered,
41
+ * because a page with no catalog should show no button rather than one that
42
+ * fails when pressed.
43
+ */
44
+ library?: true | LibrarySearch;
45
+ }
46
+ /** Search the asset catalog: the shape the editor's browser consumes. */
47
+ type LibrarySearch = (query: {
48
+ tags: string[];
49
+ keyword?: string;
50
+ page?: number;
51
+ limit?: number;
52
+ }) => Promise<{
53
+ items: unknown[];
54
+ page: number;
55
+ hasNext: boolean;
56
+ total?: number;
57
+ }>;
58
+ //#endregion
59
+ export { EditorSwitchOptions as t };
@@ -0,0 +1,161 @@
1
+ //#region src/core/editor-switch.ts
2
+ /**
3
+ * Cover the page while it changes what it is.
4
+ *
5
+ * A mode switch tears down a whole game and builds a whole IDE in its place;
6
+ * between those two the window shows a dead canvas, a flash of white body, and
7
+ * then a fully-formed editor. Every one of those frames is honest and none of
8
+ * them is pleasant. A veil makes the crossing read as one movement: fade to the
9
+ * editor's own background, do the work behind it, fade back.
10
+ *
11
+ * Deliberately CSS-only and dependency-free — it has to work in a game page
12
+ * whose styles we know nothing about, so it sits at the top of the stacking
13
+ * order, eats pointer events (nobody clicks a UI that is being replaced), and
14
+ * removes itself completely. In a headless boot there is no document and this
15
+ * is a pair of no-ops.
16
+ */
17
+ const VEIL_MS = 160;
18
+ function raiseVeil(label = "", background = "#0b0d14") {
19
+ if (typeof document === "undefined") return { lift: () => {} };
20
+ const el = document.createElement("div");
21
+ el.dataset.incantoSwitch = "";
22
+ el.style.cssText = `position:fixed;inset:0;z-index:2147483646;background:${background};display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity ${VEIL_MS}ms ease;pointer-events:auto;`;
23
+ if (label) {
24
+ const text = document.createElement("div");
25
+ text.textContent = label;
26
+ text.style.cssText = "font:13px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#5d6680;letter-spacing:0.04em;";
27
+ el.appendChild(text);
28
+ }
29
+ document.body.appendChild(el);
30
+ requestAnimationFrame(() => {
31
+ el.style.opacity = "1";
32
+ });
33
+ let lifted = false;
34
+ return { lift() {
35
+ if (lifted) return;
36
+ lifted = true;
37
+ requestAnimationFrame(() => {
38
+ el.style.opacity = "0";
39
+ setTimeout(() => el.remove(), 200);
40
+ });
41
+ } };
42
+ }
43
+ /** Hold the veil up for at least one paint so the fade is visible at all. */
44
+ async function settle(ms = VEIL_MS) {
45
+ if (typeof requestAnimationFrame !== "function") return;
46
+ await new Promise((resolve) => setTimeout(resolve, ms));
47
+ }
48
+ /**
49
+ * The `/api/library` route `incanto/vite`'s `incantoLibrary()` (and the
50
+ * `incanto-editor` server) serve. Used when a game passes `library: true`.
51
+ */
52
+ function devServerLibrary(token) {
53
+ return async (query) => {
54
+ const params = new URLSearchParams({
55
+ tags: query.tags.join(","),
56
+ page: String(query.page ?? 1),
57
+ limit: String(query.limit ?? 24)
58
+ });
59
+ if (query.keyword?.trim()) params.set("keyword", query.keyword.trim());
60
+ const res = await fetch(`/api/library?${params}`, { headers: token ? { "x-incanto-v8-token": token } : {} });
61
+ if (res.status === 401) {
62
+ const body = await res.json().catch(() => ({}));
63
+ const error = new Error(body.error ?? "the asset library needs a Verse8 access token");
64
+ error.needsToken = true;
65
+ throw error;
66
+ }
67
+ if (!res.ok) throw new Error(`asset library: HTTP ${res.status}`);
68
+ return await res.json();
69
+ };
70
+ }
71
+ /**
72
+ * The default opener: pull `incanto/editor` in on demand and mount it.
73
+ *
74
+ * It is a dynamic import so the editor — an IDE's worth of UI — never enters a
75
+ * production bundle. A build that cannot resolve it fails HERE, with the two
76
+ * ways to fix it, rather than silently offering a menu item that does nothing.
77
+ */
78
+ async function openBundledEditor(opts) {
79
+ let mod;
80
+ try {
81
+ mod = await import("incanto/editor");
82
+ } catch (error) {
83
+ throw new Error(`createGame3D editor: could not load 'incanto/editor'. If this app aliases incanto to the engine SOURCE (the examples and playground do), add an alias for 'incanto/editor' → packages/editor/src/main.ts — or pass editor: { open } and load it yourself. (${error instanceof Error ? error.message : String(error)})`);
84
+ }
85
+ let handle = null;
86
+ handle = await mod.mountEditor({
87
+ ...opts.camera ? { camera: opts.camera } : {},
88
+ api: {
89
+ meta: async () => ({
90
+ version: "live",
91
+ mode: "single",
92
+ root: "",
93
+ input: null,
94
+ output: null
95
+ }),
96
+ loadScene: async () => opts.scene,
97
+ ...opts.save ? { saveScene: async (json) => void opts.save?.(json) } : {},
98
+ ...opts.library ? { library: opts.library } : {}
99
+ },
100
+ onPlay: (choice, working) => {
101
+ if (choice !== "debug") return void 0;
102
+ const camera = handle?.camera();
103
+ return crossFade(async () => {
104
+ handle?.dispose();
105
+ handle = null;
106
+ await opts.play(working, camera);
107
+ }, "starting the game…");
108
+ },
109
+ ...opts.exit ? { onExit: () => crossFade(async () => {
110
+ handle?.dispose();
111
+ handle = null;
112
+ await opts.exit?.();
113
+ }, "back to the game…") } : {}
114
+ });
115
+ }
116
+ /**
117
+ * Run a mode change behind the veil: cover, swap, reveal.
118
+ *
119
+ * The reveal waits for the new page to have PAINTED (two frames — one for the
120
+ * layout the swap just produced, one for it to reach the screen), otherwise the
121
+ * fade-out races the boot and shows the very flash it exists to hide.
122
+ */
123
+ async function crossFade(swap, label = "") {
124
+ const veil = raiseVeil(label);
125
+ await settle();
126
+ try {
127
+ await swap();
128
+ } finally {
129
+ await settle(0);
130
+ veil.lift();
131
+ }
132
+ }
133
+ /**
134
+ * The camera the player is actually looking through, as a pose.
135
+ *
136
+ * Reads the renderer's LAST rendered camera rather than the scene's Camera3D:
137
+ * a third-person rig, a cutscene camera and a debug free-fly all end up in the
138
+ * same place, and it is the view on screen — the thing continuity is about.
139
+ */
140
+ function poseFromRenderer(renderer, fallbackDistance = 8) {
141
+ const basis = renderer.cameraBasis;
142
+ const posOf = renderer.lastCameraPosition;
143
+ if (typeof basis !== "function" || typeof posOf !== "function") return void 0;
144
+ const eye = posOf.call(renderer);
145
+ if (!eye) return void 0;
146
+ const { forward } = basis.call(renderer);
147
+ return {
148
+ position: [
149
+ eye.x,
150
+ eye.y,
151
+ eye.z
152
+ ],
153
+ target: [
154
+ eye.x + forward.x * fallbackDistance,
155
+ eye.y + forward.y * fallbackDistance,
156
+ eye.z + forward.z * fallbackDistance
157
+ ]
158
+ };
159
+ }
160
+ //#endregion
161
+ export { poseFromRenderer as i, devServerLibrary as n, openBundledEditor as r, crossFade as t };
@@ -0,0 +1,49 @@
1
+ // Generated by packages/editor/scripts/emit-types.mjs — do not edit.
2
+ type Json = Record<string, unknown>;
3
+
4
+ export interface EditorMeta {
5
+ version: string;
6
+ mode: 'single' | 'project';
7
+ root: string;
8
+ input: string | null;
9
+ output: string | null;
10
+ }
11
+
12
+ export interface SceneEntry {
13
+ rel: string;
14
+ abs: string;
15
+ }
16
+
17
+ /** Where a mounted editor reads and writes scenes. */
18
+ export interface EditorApi {
19
+ meta(): Promise<EditorMeta>;
20
+ loadScene(file?: string): Promise<Json>;
21
+ saveScene?(json: Json, file?: string): Promise<void>;
22
+ scenes?(): Promise<SceneEntry[]>;
23
+ createScene?(path: string): Promise<SceneEntry>;
24
+ }
25
+
26
+ export type PlayChoice = 'preview' | 'debug';
27
+
28
+ export interface MountEditorOptions {
29
+ host?: HTMLElement;
30
+ api?: EditorApi;
31
+ camera?: { position: readonly number[]; target: readonly number[] };
32
+ onPlay?(choice: PlayChoice, working: Json): void | Promise<void>;
33
+ onExit?(): void | Promise<void>;
34
+ }
35
+
36
+ export interface EditorHandle {
37
+ dispose(): void;
38
+ working(): Json;
39
+ camera(): { position: [number, number, number]; target: [number, number, number] };
40
+ }
41
+
42
+ /**
43
+ * Mount the scene editor over the page. It OWNS the window while it is up —
44
+ * dispose() puts the page back exactly as it was.
45
+ */
46
+ export declare function mountEditor(opts?: MountEditorOptions): Promise<EditorHandle>;
47
+
48
+ /** The `incanto-editor` server's /api routes, as an EditorApi. */
49
+ export declare const httpEditorApi: EditorApi;