incanto 0.65.0 → 0.67.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 (88) hide show
  1. package/bin/_behaviors-loader.mjs +87 -0
  2. package/bin/incanto-editor.mjs +39 -2
  3. package/bin/incanto-feel.mjs +4 -1
  4. package/bin/incanto-frame.mjs +52 -3
  5. package/bin/incanto-logs.mjs +33 -3
  6. package/bin/incanto-multiplay.mjs +50 -1
  7. package/bin/incanto-play.mjs +3 -1
  8. package/bin/incanto-playtest.mjs +3 -25
  9. package/bin/incanto-verify.mjs +15 -2
  10. package/dist/2d.d.ts +76 -4
  11. package/dist/2d.js +3 -3
  12. package/dist/3d.d.ts +213 -14
  13. package/dist/3d.js +6 -6
  14. package/dist/{audio-player-BrEHwbK1.d.ts → audio-player-BNUHo0JF.d.ts} +1 -1
  15. package/dist/{behavior-CmMuQ6CL.d.ts → behavior-ZnfzKDo3.d.ts} +58 -1
  16. package/dist/{create-game-DIyh5Zy9.js → create-game-DMetd3vR.js} +14 -8
  17. package/dist/{create-game-W2xzTJhs.js → create-game-FAXdHta3.js} +138 -11
  18. package/dist/debug.d.ts +1 -1
  19. package/dist/debug.js +3 -2
  20. package/dist/{duplicate-CqSAtdrh.js → duplicate-B8QnX2-r.js} +1 -1
  21. package/dist/editor.js +7 -2
  22. package/dist/{environment-presets-BvHs6HEk.js → environment-presets-C-5g2pMh.js} +113 -9
  23. package/dist/{gameplay-D-H986A4.js → gameplay-CGSv-9XR.js} +295 -12
  24. package/dist/gameplay.d.ts +102 -6
  25. package/dist/gameplay.js +2 -2
  26. package/dist/index.d.ts +50 -6
  27. package/dist/index.js +7 -6
  28. package/dist/{loader-CTB441z4.d.ts → loader-DyT30ArB.d.ts} +1 -1
  29. package/dist/{loader-lQDCwNag.js → loader-KZESOZmE.js} +148 -1
  30. package/dist/net.d.ts +2 -2
  31. package/dist/net.js +1 -1
  32. package/dist/{physics-2d-CmnunaR2.js → physics-2d-_B6zj3zU.js} +57 -4
  33. package/dist/{physics-3d-DULB1lD1.js → physics-3d-i1IeAY0F.js} +74 -5
  34. package/dist/{quiet-rapier-BAJ4K94N.js → quiet-rapier-C6fW4zcW.js} +14 -1
  35. package/dist/react.d.ts +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/{register-CgeR4ckF.js → register-Cs53CY7s.js} +110 -16
  38. package/dist/{register-DO5_Qp0Q.js → register-vIxU3Xbv.js} +38 -4
  39. package/dist/rendering-options-ByAdi8PF.js +184 -0
  40. package/dist/{replay-CEw2xAhG.js → replay-BkpDV51O.js} +4 -3
  41. package/dist/{replay-CThvwkcQ.d.ts → replay-D0GcEHYU.d.ts} +1 -1
  42. package/dist/{split-screen-DxZzC1nm.d.ts → split-screen-DD5eYIgW.d.ts} +2 -2
  43. package/dist/{split-screen-gb0c3xdb.js → split-screen-DS8RaUV_.js} +2 -2
  44. package/dist/{src-DYffzHbd.js → src-BLlsGJW9.js} +1 -1
  45. package/dist/{teardown-BordHL9o.js → teardown-LB9cTnXJ.js} +2 -2
  46. package/dist/{test-CXZuXs8k.js → test-81uZ967a.js} +127 -24
  47. package/dist/test.d.ts +7 -5
  48. package/dist/test.js +2 -2
  49. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  50. package/dist/vite.d.ts +0 -7
  51. package/dist/vite.js +13 -3
  52. package/editor/assets/{agent8-DO5qzIOD.js → agent8-C2jtNKbx.js} +1 -1
  53. package/editor/assets/debug-DoU3KeqS.js +3 -0
  54. package/editor/assets/{index-CqEUVaPN.js → index-CNqHojcC.js} +92 -92
  55. package/editor/index.html +1 -1
  56. package/package.json +1 -1
  57. package/schemas/scene.schema.json +20 -1
  58. package/skills/incanto-building-3d-games.md +19 -0
  59. package/skills/incanto-editor.md +5 -1
  60. package/skills/incanto-environment.md +2 -2
  61. package/skills/incanto-gameplay-behaviors.md +86 -7
  62. package/skills/incanto-hud.md +14 -1
  63. package/skills/incanto-multiplayer.md +14 -0
  64. package/skills/incanto-node-reference.md +60 -2
  65. package/skills/incanto-performance.md +35 -0
  66. package/skills/incanto-physics-and-input.md +69 -0
  67. package/skills/incanto-playtesting.md +15 -1
  68. package/skills/incanto-save-slots.md +21 -1
  69. package/skills/incanto-verifying-your-game.md +19 -0
  70. package/skills/incanto-web-integration.md +24 -3
  71. package/skills/incanto-your-first-game.md +4 -4
  72. package/templates-app/beacon-isle-3d/package.json +1 -1
  73. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  76. package/templates-app/star-survivor/package.json +1 -1
  77. package/templates-app/star-survivor/src/game.scene.json +31 -17
  78. package/templates-app/tps-3d/package.json +1 -1
  79. package/templates-app/tps-3d/src/game.scene.json +14 -8
  80. package/templates-app/village-quest-3d/package.json +1 -1
  81. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  82. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  83. package/editor/assets/debug-DntG9CVD.js +0 -3
  84. package/templates-app/beacon-isle-3d/coverage.json +0 -9
  85. package/templates-app/platformer-2d/coverage.json +0 -5
  86. package/templates-app/star-survivor/coverage.json +0 -5
  87. package/templates-app/tps-3d/coverage.json +0 -5
  88. package/templates-app/village-quest-3d/coverage.json +0 -9
@@ -1,8 +1,9 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { C as ORDER_GROUP_BASE, E as InputMap, O as Node, S as createSaveStore, T as resolveOrderGroups, V as Signal, b as restoreBehaviors, k as diagnose, y as captureBehaviors } from "./loader-lQDCwNag.js";
2
+ import { C as ORDER_GROUP_BASE, E as InputMap, O as Node, S as createSaveStore, T as resolveOrderGroups, V as Signal, b as restoreBehaviors, k as diagnose, y as captureBehaviors } from "./loader-KZESOZmE.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { l as registerNode } from "./registry-CF70EArN.js";
6
+ import { r as capturePointer } from "./touch-DEAmqGdf.js";
6
7
  //#region src/core/audio/buses.ts
7
8
  const AUDIO_LOG_LIMIT = 200;
8
9
  var AudioBuses = class {
@@ -1339,11 +1340,29 @@ var HudLayer = class extends Node {
1339
1340
  }
1340
1341
  }
1341
1342
  }
1342
- /** Focusable widgets under this layer, in tree order. */
1343
+ /**
1344
+ * Focusable widgets under this layer, in tree order — **on screen only**.
1345
+ *
1346
+ * This filtered on each widget's OWN `visible` and recursed into hidden
1347
+ * containers anyway, so arrow-keys walked into closed panels and Enter really
1348
+ * pressed what it found. Measured on a title screen: 23 focus stops, 21 of
1349
+ * them under an ancestor with `visible = false`, and
1350
+ *
1351
+ * gold before = 150 -> Enter on the hidden Shop/Row0/Buy -> gold 120,
1352
+ * screen still 'title'
1353
+ *
1354
+ * The layer's own visibility was not a gate either. It is now: a HUD nobody
1355
+ * can see has no ring, and a subtree nobody can see is not walked.
1356
+ *
1357
+ * PUBLIC, because a game that wants to know what its ring contains could not
1358
+ * ask — and "why is focus on that" is exactly the question this answers.
1359
+ */
1343
1360
  focusables() {
1344
1361
  const out = [];
1362
+ if (!this.visible) return out;
1345
1363
  const walk = (node) => {
1346
- if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
1364
+ if (node instanceof HudWidgetBase && !node.visible) return;
1365
+ if (node instanceof HudWidgetBase && node.focusable) out.push(node);
1347
1366
  for (const child of node.children) walk(child);
1348
1367
  };
1349
1368
  walk(this);
@@ -1513,7 +1532,7 @@ var HudWidgetBase = class extends Node {
1513
1532
  el.addEventListener("pointerdown", (event) => {
1514
1533
  if (!this.draggable) return;
1515
1534
  event.preventDefault();
1516
- el.setPointerCapture?.(event.pointerId);
1535
+ capturePointer(el, event.pointerId);
1517
1536
  dragging = this;
1518
1537
  draggingFrom = dropTargetFromElement(el.parentElement);
1519
1538
  el.style.opacity = "0.6";
@@ -1553,10 +1572,16 @@ var HudWidgetBase = class extends Node {
1553
1572
  this._mountWidget();
1554
1573
  if (!this._element) return;
1555
1574
  }
1556
- this._element.style.display = this.visible ? "" : "none";
1557
1575
  this._followAnchor();
1558
1576
  this._sync();
1577
+ if (this._element.style.display !== "none") this._shownDisplay = this._element.style.display;
1578
+ this._element.style.display = this.visible ? this._shownDisplay : "none";
1559
1579
  }
1580
+ /**
1581
+ * The display this widget's own css asks for while shown — `grid` for a grid
1582
+ * panel, `flex` for a row, `''` for a widget that never says.
1583
+ */
1584
+ _shownDisplay = "";
1560
1585
  /** Which anchor slot this widget was mounted into. */
1561
1586
  mountedAnchor = null;
1562
1587
  /**
@@ -2280,14 +2305,9 @@ function qualityEnvironment(tier) {
2280
2305
  };
2281
2306
  if (tier === "medium") return {
2282
2307
  shadows: { static: true },
2283
- bloom: {},
2284
2308
  post: null
2285
2309
  };
2286
- return {
2287
- shadows: true,
2288
- bloom: {},
2289
- post: {}
2290
- };
2310
+ return { shadows: true };
2291
2311
  }
2292
2312
  const QUALITY_CAPS = Object.freeze({
2293
2313
  low: Object.freeze({
@@ -2393,6 +2413,33 @@ var Engine = class {
2393
2413
  * and cleared on dispose; null when nothing renders (headless).
2394
2414
  */
2395
2415
  picker = null;
2416
+ /** Frames the engine has stepped — monotonic, and the pick cache's key. */
2417
+ frameNumber = 0;
2418
+ /** @internal frame number + answer, so N Clickables cost ONE raycast. */
2419
+ pickCacheFrame = -1;
2420
+ pickCacheKey = "";
2421
+ pickCacheHit = null;
2422
+ /**
2423
+ * The node under (x, y), answered at most ONCE per frame per cursor position.
2424
+ *
2425
+ * `Clickable` asks every frame, and a board game has one per cell: 42 pads
2426
+ * measured **420 picker calls over ten frames** — one GPU raycast per pad per
2427
+ * frame, all answering the same question about the same pixel. At 60 fps that
2428
+ * is ~2500 raycasts a second to find out what the cursor is on, and a 200-tile
2429
+ * board was unusable.
2430
+ *
2431
+ * Cached on (frame, x, y), so a game that picks at two different points in one
2432
+ * frame still gets two real answers.
2433
+ */
2434
+ pickAt(x, y) {
2435
+ if (!this.picker) return null;
2436
+ const key = `${x},${y}`;
2437
+ if (this.pickCacheFrame === this.frameNumber && this.pickCacheKey === key) return this.pickCacheHit;
2438
+ this.pickCacheFrame = this.frameNumber;
2439
+ this.pickCacheKey = key;
2440
+ this.pickCacheHit = this.picker(x, y);
2441
+ return this.pickCacheHit;
2442
+ }
2396
2443
  /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
2397
2444
  * routes its volume through this; games set it for global volume/mute. */
2398
2445
  audio = new AudioBuses();
@@ -2409,7 +2456,7 @@ var Engine = class {
2409
2456
  * reset on reload. Reading it here costs nothing (localStorage, once) and
2410
2457
  * `bindAudio` makes the volumes write themselves.
2411
2458
  */
2412
- settings = new Settings();
2459
+ settings;
2413
2460
  /**
2414
2461
  * Translations, and the locale in force. English is the base and the default;
2415
2462
  * anything a locale does not declare falls back to it silently.
@@ -2483,6 +2530,7 @@ var Engine = class {
2483
2530
  this.scheduler = opts.scheduler ?? rafScheduler;
2484
2531
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
2485
2532
  this.maxFps = opts.maxFps ?? 0;
2533
+ this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
2486
2534
  this.sfx._setBuses(this.audio);
2487
2535
  this.music.onTrackError = (src, reason) => {
2488
2536
  diagnose(this, "error", `[incanto] music '${src}' will not play — ${reason}.`);
@@ -2609,8 +2657,9 @@ var Engine = class {
2609
2657
  captureState() {
2610
2658
  const root = this.scene?.root;
2611
2659
  if (!root) return {};
2612
- const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
2660
+ const { state, unaddressable, unrecordable } = captureBehaviors(root, this.scene?.source);
2613
2661
  for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
2662
+ if (unrecordable.length > 0) diagnose(this, "error", `[incanto] save: ${unrecordable.length} authored node(s) this run consumed have no uid, so nothing records that they are gone and LOADING THIS SAVE BRINGS THEM BACK: ${unrecordable.slice(0, 8).join(", ")}${unrecordable.length > 8 ? `, +${unrecordable.length - 8} more` : ""}. Give each one a "uid" from newUid().`);
2614
2663
  return state;
2615
2664
  }
2616
2665
  /** Give it back, after the scene is loaded and onReady has run. */
@@ -2728,6 +2777,7 @@ var Engine = class {
2728
2777
  scene.tree.fixedUpdate(dt);
2729
2778
  this.fixedUpdated.emit(dt);
2730
2779
  const startedUpdate = performance.now();
2780
+ this.frameNumber += 1;
2731
2781
  scene.tree.update(dt);
2732
2782
  this.music.tick(dt);
2733
2783
  this.updated.emit(dt);
@@ -2762,6 +2812,7 @@ var Engine = class {
2762
2812
  }
2763
2813
  if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
2764
2814
  const startedUpdate = performance.now();
2815
+ this.frameNumber += 1;
2765
2816
  scene.tree.update(dt);
2766
2817
  this.music.tick(dt);
2767
2818
  this.updated.emit(dt);
@@ -3158,6 +3209,7 @@ var UiDialogue = class extends HudWidgetBase {
3158
3209
  ];
3159
3210
  static props = {
3160
3211
  ...HudWidgetBase.props,
3212
+ focusable: { default: true },
3161
3213
  anchor: {
3162
3214
  default: "bottom",
3163
3215
  options: [
@@ -3207,6 +3259,49 @@ var UiDialogue = class extends HudWidgetBase {
3207
3259
  });
3208
3260
  }
3209
3261
  /**
3262
+ * A dialogue takes focus while it is up.
3263
+ *
3264
+ * It paints a focus ring and the generated reference advertises the prop, and
3265
+ * it was inert: choices are raw DOM buttons inside the widget, so
3266
+ * `HudLayer.focusables()` can never see them, and this class — unlike
3267
+ * `UiButton` a hundred lines above it — never overrode `_activate`. Eleven
3268
+ * keys and eight pad codes did nothing to a waiting choice while the same
3269
+ * Enter pressed a sibling button. Native Tab worked, and is closed by any
3270
+ * game that binds Tab; a pad has no Tab at all.
3271
+ */
3272
+ focusable = true;
3273
+ /** Which choice the keyboard/pad is on. Reset by every new line. */
3274
+ cursor = 0;
3275
+ /**
3276
+ * Enter / A, and left / right between the options.
3277
+ *
3278
+ * `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
3279
+ * uses — so one key layout drives a menu, a slider and a conversation.
3280
+ */
3281
+ _activate(dir) {
3282
+ if (!this.active || !this.current) return;
3283
+ const choices = this.current.choices;
3284
+ if (!choices || choices.length === 0) {
3285
+ this.advance();
3286
+ return;
3287
+ }
3288
+ if (dir !== 0) {
3289
+ this.cursor = (this.cursor + dir + choices.length) % choices.length;
3290
+ this.paintCursor();
3291
+ return;
3292
+ }
3293
+ this.choose(this.cursor);
3294
+ }
3295
+ /** Show which option the keyboard is on — a ring nobody can see is not one. */
3296
+ paintCursor() {
3297
+ const buttons = this.choicesEl?.children;
3298
+ if (!buttons) return;
3299
+ for (let i = 0; i < buttons.length; i++) {
3300
+ const el = buttons[i];
3301
+ if (el) el.style.outline = i === this.cursor ? "2px solid #fff" : "none";
3302
+ }
3303
+ }
3304
+ /**
3210
3305
  * Player pressed "next": mid-typing reveals the whole line; a fully-typed
3211
3306
  * line without choices advances (choice lines wait for `choose`).
3212
3307
  */
@@ -3289,12 +3384,11 @@ var UiDialogue = class extends HudWidgetBase {
3289
3384
  this.revealed = Math.round(progress * this.current.text.length);
3290
3385
  if (this.speakerEl) this.speakerEl.textContent = this.current.speaker;
3291
3386
  }
3292
- const wasTyping = this.revealed < this.current.text.length;
3293
- if (wasTyping) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
3387
+ if (this.revealed < this.current.text.length) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
3294
3388
  const doneTyping = this.revealed >= this.current.text.length;
3295
3389
  if (this.textEl) this.textEl.textContent = this.showing;
3296
3390
  if (this.hintEl) this.hintEl.style.visibility = doneTyping && !this.current.choices ? "visible" : "hidden";
3297
- if (doneTyping && wasTyping && this.current.choices && this.choicesEl) this.current.choices.forEach((label, i) => {
3391
+ if (doneTyping && this.current.choices && this.choicesEl && this.choicesEl.children.length === 0) this.current.choices.forEach((label, i) => {
3298
3392
  const b = document.createElement("button");
3299
3393
  b.type = "button";
3300
3394
  b.textContent = label;
@@ -1,10 +1,10 @@
1
- import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-lQDCwNag.js";
2
- import { L as translateOn, t as registerCoreNodes } from "./register-CgeR4ckF.js";
1
+ import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-KZESOZmE.js";
2
+ import { L as translateOn, t as registerCoreNodes } from "./register-Cs53CY7s.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { i as getNodeSchema, l as registerNode } from "./registry-CF70EArN.js";
6
6
  import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimationAliases, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-CY-mrr1L.js";
7
- import { N as localFromWorld, P as worldPosition } from "./gameplay-D-H986A4.js";
7
+ import { F as localFromWorld, I as worldPosition } from "./gameplay-CGSv-9XR.js";
8
8
  import { t as checkSheetGrid } from "./sheet-grid-BT6N_Bjs.js";
9
9
  import { AdditiveBlending, BufferAttribute, BufferGeometry, CanvasTexture, Color, DynamicDrawUsage, Group, InstancedMesh, MathUtils, Matrix4, Mesh, MeshBasicMaterial, NearestFilter, NormalBlending, Object3D, PlaneGeometry, SRGBColorSpace } from "three";
10
10
  //#region src/2d/physics/collider-validate.ts
@@ -223,6 +223,14 @@ var StaticBody2D = class extends PhysicsBody2D {
223
223
  */
224
224
  var Area2D = class extends PhysicsBody2D {
225
225
  static typeName = "Area2D";
226
+ /**
227
+ * Who is inside RIGHT NOW — the standing answer behind the enter/exit pair.
228
+ * See `Area3D.overlapping`. Empty in a scene with no physics world.
229
+ */
230
+ overlapping(group) {
231
+ const all = this._physics?.overlapping(this) ?? [];
232
+ return group === void 0 ? all : all.filter((n) => n.isInGroup(group));
233
+ }
226
234
  };
227
235
  /** Dynamic simulated body. */
228
236
  var RigidBody2D = class extends PhysicsBody2D {
@@ -334,7 +342,8 @@ var Joint2D = class extends Node2D {
334
342
  targetAnchor: { default: [0, 0] },
335
343
  length: { default: 0 },
336
344
  stiffness: { default: 50 },
337
- damping: { default: 5 }
345
+ damping: { default: 5 },
346
+ collide: { default: null }
338
347
  };
339
348
  type = "revolute";
340
349
  target = "";
@@ -344,6 +353,31 @@ var Joint2D = class extends Node2D {
344
353
  length = 0;
345
354
  stiffness = 50;
346
355
  damping = 5;
356
+ /**
357
+ * Do the two linked bodies still collide with EACH OTHER?
358
+ *
359
+ * `null` (default) decides from the joint type, because the two families want
360
+ * opposite answers:
361
+ *
362
+ * - **pivots** (`fixed`, `spherical`, `revolute`) → **off**. A hinge's bodies
363
+ * overlap at the pivot by construction, so the contact solver fights the
364
+ * joint and flings them. A puzzle builder's first hinge produced a bar that
365
+ * spun chaotically forever and threw a 40 kg box thirty metres, and they
366
+ * nearly filed "spherical joints inject energy":
367
+ *
368
+ * ```
369
+ * t= 0 Plank r=[0,0,0] Box [ 0.00, 5.50, 2.40]
370
+ * t=3000 Plank r=[-66,24,64] Box [-29.70, 0.40, -4.91]
371
+ * ```
372
+ *
373
+ * - **tethers** (`rope`, `spring`) → **on**. These anchor a thing to the world,
374
+ * and the thing has to rest on what it is tethered to. Defaulting these off
375
+ * drops a barrel roped to the floor straight through it — measured, in this
376
+ * repo's own conformance room.
377
+ *
378
+ * Set `true`/`false` to say it outright.
379
+ */
380
+ collide = null;
347
381
  onEnterTree() {
348
382
  if (!(this.parent instanceof PhysicsBody2D)) throw new IncantoError("TREE_VIOLATION", `Joint2D '${this.name}' must be a child of a physics body (its body A); parent is '${this.parent?.name ?? "none"}'.`);
349
383
  if (this.target === "") throw new IncantoError("BAD_FORMAT", `Joint2D '${this.name}' needs a "target" node path to the body it links to.`);
@@ -0,0 +1,184 @@
1
+ //#region src/core/drive-script.ts
2
+ const num = (raw) => {
3
+ if (raw === void 0) return null;
4
+ const n = Number(raw);
5
+ return Number.isFinite(n) ? n : null;
6
+ };
7
+ /**
8
+ * `vector move 0 1; step 2000` → steps, or an error naming the bad command.
9
+ *
10
+ * Semicolons and newlines both separate, so a whole plan fits in one shell
11
+ * argument and a longer one fits in a file. `#` starts a comment.
12
+ *
13
+ * A command it does not know REFUSES THE WHOLE SCRIPT. Skipping it would leave
14
+ * an agent looking at a frame that never moved, believing it had walked
15
+ * somewhere — the failure mode this exists to prevent.
16
+ */
17
+ function parseDrive(text) {
18
+ const steps = [];
19
+ let totalMs = 0;
20
+ const lines = text.split(/[;\n]/).map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean);
21
+ for (const line of lines) {
22
+ const [word, ...rest] = line.split(/\s+/);
23
+ const bad = (why) => ({
24
+ steps: [],
25
+ error: `${why}: "${line}"`,
26
+ totalMs: 0
27
+ });
28
+ switch (word) {
29
+ case "press":
30
+ case "release": {
31
+ const action = rest[0];
32
+ if (!action) return bad(`${word} needs an action name`);
33
+ steps.push(word === "press" ? {
34
+ kind: "press",
35
+ action
36
+ } : {
37
+ kind: "release",
38
+ action
39
+ });
40
+ break;
41
+ }
42
+ case "vector": {
43
+ const action = rest[0];
44
+ const x = num(rest[1]);
45
+ const y = num(rest[2]);
46
+ if (!action || x === null || y === null) return bad("vector needs an action and two numbers");
47
+ steps.push({
48
+ kind: "vector",
49
+ action,
50
+ x,
51
+ y
52
+ });
53
+ break;
54
+ }
55
+ case "key": {
56
+ const code = rest[0];
57
+ const dir = rest[1];
58
+ if (!code || dir !== "down" && dir !== "up") return bad("key needs a code and down|up");
59
+ steps.push({
60
+ kind: "key",
61
+ code,
62
+ down: dir === "down"
63
+ });
64
+ break;
65
+ }
66
+ case "pointer": {
67
+ const dx = num(rest[0]);
68
+ const dy = num(rest[1]);
69
+ if (dx === null || dy === null) return bad("pointer needs two numbers");
70
+ steps.push({
71
+ kind: "pointer",
72
+ dx,
73
+ dy
74
+ });
75
+ break;
76
+ }
77
+ case "step": {
78
+ const ms = num(rest[0]);
79
+ if (ms === null || ms < 0) return bad("step needs a duration in ms");
80
+ steps.push({
81
+ kind: "step",
82
+ ms
83
+ });
84
+ totalMs += ms;
85
+ break;
86
+ }
87
+ default: return bad("not a command");
88
+ }
89
+ }
90
+ return {
91
+ steps,
92
+ error: null,
93
+ totalMs
94
+ };
95
+ }
96
+ //#endregion
97
+ //#region src/core/log-report.ts
98
+ const RANK = {
99
+ debug: 0,
100
+ info: 1,
101
+ warn: 2,
102
+ error: 3
103
+ };
104
+ /** Under this, with the loop running, is a frame rate somebody can feel. */
105
+ const SLOW_FPS = 50;
106
+ /** How many distinct messages to carry. Beyond this it is a log file, not a report. */
107
+ const MAX_GROUPS = 20;
108
+ /** The engine logs values, not strings — format them the way a console would. */
109
+ function format(parts) {
110
+ return parts.map((p) => {
111
+ if (typeof p === "string") return p;
112
+ if (p instanceof Error) return p.message;
113
+ try {
114
+ return JSON.stringify(p);
115
+ } catch {
116
+ return String(p);
117
+ }
118
+ }).join(" ");
119
+ }
120
+ function logReport(input) {
121
+ const counts = {
122
+ debug: 0,
123
+ info: 0,
124
+ warn: 0,
125
+ error: 0
126
+ };
127
+ const byMessage = /* @__PURE__ */ new Map();
128
+ for (const e of input.entries) {
129
+ counts[e.level] += 1;
130
+ const message = format(e.parts);
131
+ const key = `${e.level}\0${message}`;
132
+ const seen = byMessage.get(key);
133
+ if (seen) {
134
+ seen.count += 1;
135
+ seen.lastMs = Math.max(seen.lastMs, e.timeMs);
136
+ } else byMessage.set(key, {
137
+ level: e.level,
138
+ message,
139
+ count: 1,
140
+ lastMs: e.timeMs
141
+ });
142
+ }
143
+ const grouped = [...byMessage.values()].sort((a, b) => RANK[b.level] - RANK[a.level] || b.lastMs - a.lastMs).slice(0, MAX_GROUPS);
144
+ const hidden = input.hidden === true;
145
+ const slow = !hidden && input.stats.running && input.stats.fps > 0 && input.stats.fps < SLOW_FPS;
146
+ return {
147
+ hidden,
148
+ ok: counts.error === 0 && input.stats.errors === 0 && input.assetErrors.length === 0,
149
+ slow,
150
+ counts,
151
+ grouped,
152
+ worst: grouped.find((g) => g.level === "error") ?? grouped[0] ?? null,
153
+ stats: input.stats,
154
+ assetErrors: input.assetErrors
155
+ };
156
+ }
157
+ /** The report as a person or an agent reads it: what is wrong, then the numbers. */
158
+ function logText(r) {
159
+ const lines = [];
160
+ if (r.stats.errors > 0) lines.push(`${r.stats.errors} error${r.stats.errors === 1 ? "" : "s"} were swallowed to keep the game alive — it is running, and something in it is broken`);
161
+ for (const a of r.assetErrors) lines.push(`asset ${a.ref} never loaded: ${a.error} (${a.url})`);
162
+ for (const g of r.grouped) {
163
+ if (g.level === "debug") continue;
164
+ lines.push(`[${g.level}] ${g.message}${g.count > 1 ? ` ×${g.count}` : ""}`);
165
+ }
166
+ if (lines.length === 0) lines.push("nothing wrong — no warnings, no errors, no failed assets");
167
+ if (!r.stats.running) lines.push(`not running — the loop is stopped (${r.stats.nodes} nodes in the scene)`);
168
+ else if (r.hidden) lines.push(`${r.stats.nodes} nodes · frame rate not measurable — the page is hidden (a window covered by another counts), and the browser throttles it. Bring it to the front`);
169
+ else lines.push(`${Math.round(r.stats.fps)} fps · ${r.stats.frameMs.toFixed(1)}ms per frame · ${r.stats.nodes} nodes${r.slow ? " — slow enough to feel" : ""}`);
170
+ return lines.join("\n");
171
+ }
172
+ //#endregion
173
+ //#region src/core/rendering-options.ts
174
+ function resolveRendering(environment, fallback, devicePixelRatio, explicit) {
175
+ const rendering = environment?.rendering ?? {};
176
+ const scenePixelRatio = rendering.pixelRatio === "device" ? devicePixelRatio : rendering.pixelRatio;
177
+ return {
178
+ antialias: explicit?.antialias ?? rendering.antialias ?? fallback.antialias,
179
+ pixelRatio: explicit?.pixelRatio ?? scenePixelRatio ?? fallback.pixelRatio,
180
+ preserveDrawingBuffer: explicit?.preserveDrawingBuffer ?? rendering.preserveDrawingBuffer ?? fallback.preserveDrawingBuffer
181
+ };
182
+ }
183
+ //#endregion
184
+ export { parseDrive as i, logReport as n, logText as r, resolveRendering as t };
@@ -1,5 +1,5 @@
1
- import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-lQDCwNag.js";
2
- import { R as translationKey, k as HudWidgetBase } from "./register-CgeR4ckF.js";
1
+ import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-KZESOZmE.js";
2
+ import { R as translationKey, k as HudWidgetBase } from "./register-Cs53CY7s.js";
3
3
  import { o as getNodeType } from "./registry-CF70EArN.js";
4
4
  //#region src/core/audit.ts
5
5
  const ANIMATED_TYPES = new Set([
@@ -166,7 +166,8 @@ function auditScene(scene, opts = {}) {
166
166
  if (cameraDrivers.length > 0 && followedCameras.length > 0) warnings.push(`${followedCameras.join(", ")}: a FollowCamera writes this camera's position every frame, and so does the CharacterController3D at ${cameraDrivers.join(", ")} — the controller owns the camera in every view it has. Drop one of them: use the controller's own \`view\`/\`camDistance\`, or take the controller off the camera duty.`);
167
167
  if (cameras > 0 && currentCameras === 0) warnings.push("no camera has \"current\": true — the screen renders from a default view, probably not what you framed.");
168
168
  if (currentCameras > 1) warnings.push(`${currentCameras} cameras claim "current": true — only the first found wins.`);
169
- if (scene.dimension === "3d" && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene — no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" }.");
169
+ const isFragment = cameras === 0;
170
+ if (scene.dimension === "3d" && !isFragment && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene — no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" }.");
170
171
  if (damageGroups.length > 0) {
171
172
  const hurtable = hurtableGroups(root);
172
173
  for (const { path, group } of damageGroups) {
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-CmMuQ6CL.js";
1
+ import { b as Engine } from "./behavior-ZnfzKDo3.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
@@ -1,6 +1,6 @@
1
- import { Lt as Signal, b as Engine, jt as Node } from "./behavior-CmMuQ6CL.js";
1
+ import { Lt as Signal, b as Engine, jt as Node } from "./behavior-ZnfzKDo3.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
- import { t as LoadSceneOptions } from "./loader-CTB441z4.js";
3
+ import { t as LoadSceneOptions } from "./loader-DyT30ArB.js";
4
4
 
5
5
  //#region src/net/types.d.ts
6
6
  type Unsubscribe = () => void;
@@ -1,5 +1,5 @@
1
- import { O as Node, V as Signal, k as diagnose, n as loadScene, t as buildNodeJson } from "./loader-lQDCwNag.js";
2
- import { t as registerCoreNodes, v as Engine } from "./register-CgeR4ckF.js";
1
+ import { O as Node, V as Signal, k as diagnose, n as loadScene, t as buildNodeJson } from "./loader-KZESOZmE.js";
2
+ import { t as registerCoreNodes, v as Engine } from "./register-Cs53CY7s.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
5
  import { l as registerNode } from "./registry-CF70EArN.js";
@@ -214,6 +214,6 @@ function newUid() {
214
214
  //#endregion
215
215
  //#region src/index.ts
216
216
  /** Engine version. Kept in sync with package.json by the release pipeline. */
217
- const VERSION = "0.65.0";
217
+ const VERSION = "0.67.0";
218
218
  //#endregion
219
219
  export { preloadUrls as a, preloadSceneAssets as i, newUid as n, findPath as o, assetUrls as r, gridFromRows as s, VERSION as t };
@@ -1,5 +1,5 @@
1
- import { _ as AudioPlayer } from "./register-CgeR4ckF.js";
2
- import { a as logReport, s as parseDrive } from "./touch-DESwnpOc.js";
1
+ import { _ as AudioPlayer } from "./register-Cs53CY7s.js";
2
+ import { i as parseDrive, n as logReport } from "./rendering-options-ByAdi8PF.js";
3
3
  import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-BSMny7oe.js";
4
4
  //#region src/core/audio-errors.ts
5
5
  /** Every AudioPlayer under `root` whose source could not be played. */