incanto 0.66.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 (80) 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 +41 -4
  11. package/dist/2d.js +3 -3
  12. package/dist/3d.d.ts +86 -14
  13. package/dist/3d.js +6 -6
  14. package/dist/{audio-player-_UAcHxnC.d.ts → audio-player-BNUHo0JF.d.ts} +1 -1
  15. package/dist/{behavior-BXNLfIJk.d.ts → behavior-ZnfzKDo3.d.ts} +39 -1
  16. package/dist/{create-game-Dd7H4bJV.js → create-game-DMetd3vR.js} +14 -8
  17. package/dist/{create-game-DYJCIzO0.js → create-game-FAXdHta3.js} +77 -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-BlPsEmq6.js → environment-presets-C-5g2pMh.js} +46 -6
  23. package/dist/{gameplay-D1RADWu3.js → gameplay-CGSv-9XR.js} +28 -5
  24. package/dist/gameplay.d.ts +25 -4
  25. package/dist/gameplay.js +1 -1
  26. package/dist/index.d.ts +50 -6
  27. package/dist/index.js +7 -6
  28. package/dist/{loader-Cga7FVP4.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-Cji5A6sX.js → physics-2d-_B6zj3zU.js} +29 -2
  33. package/dist/{physics-3d-BkHjwJgI.js → physics-3d-i1IeAY0F.js} +30 -3
  34. package/dist/react.d.ts +1 -1
  35. package/dist/react.js +1 -1
  36. package/dist/{register-BmuqYTiY.js → register-Cs53CY7s.js} +81 -16
  37. package/dist/{register-D0CxCveZ.js → register-vIxU3Xbv.js} +3 -3
  38. package/dist/rendering-options-ByAdi8PF.js +184 -0
  39. package/dist/{replay-IsZbNu6d.js → replay-BkpDV51O.js} +4 -3
  40. package/dist/{replay-BHoB6fCU.d.ts → replay-D0GcEHYU.d.ts} +1 -1
  41. package/dist/{split-screen-eJUFjhi1.d.ts → split-screen-DD5eYIgW.d.ts} +2 -2
  42. package/dist/{split-screen-D_i7GRcY.js → split-screen-DS8RaUV_.js} +2 -2
  43. package/dist/{src-BTLbXFPZ.js → src-BLlsGJW9.js} +1 -1
  44. package/dist/{teardown-byR9USax.js → teardown-LB9cTnXJ.js} +2 -2
  45. package/dist/{test-DgrD0jHD.js → test-81uZ967a.js} +127 -24
  46. package/dist/test.d.ts +7 -5
  47. package/dist/test.js +2 -2
  48. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  49. package/dist/vite.d.ts +0 -7
  50. package/dist/vite.js +13 -3
  51. package/editor/assets/{agent8-9N-Pd_YS.js → agent8-C2jtNKbx.js} +1 -1
  52. package/editor/assets/debug-DoU3KeqS.js +3 -0
  53. package/editor/assets/{index-CeDhIPTC.js → index-CNqHojcC.js} +92 -92
  54. package/editor/index.html +1 -1
  55. package/package.json +1 -1
  56. package/schemas/scene.schema.json +1 -1
  57. package/skills/incanto-building-3d-games.md +19 -0
  58. package/skills/incanto-editor.md +5 -1
  59. package/skills/incanto-environment.md +2 -2
  60. package/skills/incanto-gameplay-behaviors.md +1 -1
  61. package/skills/incanto-hud.md +14 -1
  62. package/skills/incanto-multiplayer.md +14 -0
  63. package/skills/incanto-node-reference.md +1 -1
  64. package/skills/incanto-performance.md +35 -0
  65. package/skills/incanto-playtesting.md +15 -1
  66. package/skills/incanto-save-slots.md +21 -1
  67. package/skills/incanto-verifying-your-game.md +19 -0
  68. package/skills/incanto-your-first-game.md +4 -4
  69. package/templates-app/beacon-isle-3d/package.json +1 -1
  70. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  71. package/templates-app/platformer-2d/package.json +1 -1
  72. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  73. package/templates-app/star-survivor/package.json +1 -1
  74. package/templates-app/star-survivor/src/game.scene.json +31 -17
  75. package/templates-app/tps-3d/package.json +1 -1
  76. package/templates-app/tps-3d/src/game.scene.json +14 -8
  77. package/templates-app/village-quest-3d/package.json +1 -1
  78. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  79. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  80. package/editor/assets/debug-CkbJICYp.js +0 -3
@@ -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({
@@ -2436,7 +2456,7 @@ var Engine = class {
2436
2456
  * reset on reload. Reading it here costs nothing (localStorage, once) and
2437
2457
  * `bindAudio` makes the volumes write themselves.
2438
2458
  */
2439
- settings = new Settings();
2459
+ settings;
2440
2460
  /**
2441
2461
  * Translations, and the locale in force. English is the base and the default;
2442
2462
  * anything a locale does not declare falls back to it silently.
@@ -2510,6 +2530,7 @@ var Engine = class {
2510
2530
  this.scheduler = opts.scheduler ?? rafScheduler;
2511
2531
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
2512
2532
  this.maxFps = opts.maxFps ?? 0;
2533
+ this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
2513
2534
  this.sfx._setBuses(this.audio);
2514
2535
  this.music.onTrackError = (src, reason) => {
2515
2536
  diagnose(this, "error", `[incanto] music '${src}' will not play — ${reason}.`);
@@ -2636,8 +2657,9 @@ var Engine = class {
2636
2657
  captureState() {
2637
2658
  const root = this.scene?.root;
2638
2659
  if (!root) return {};
2639
- const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
2660
+ const { state, unaddressable, unrecordable } = captureBehaviors(root, this.scene?.source);
2640
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().`);
2641
2663
  return state;
2642
2664
  }
2643
2665
  /** Give it back, after the scene is loaded and onReady has run. */
@@ -3187,6 +3209,7 @@ var UiDialogue = class extends HudWidgetBase {
3187
3209
  ];
3188
3210
  static props = {
3189
3211
  ...HudWidgetBase.props,
3212
+ focusable: { default: true },
3190
3213
  anchor: {
3191
3214
  default: "bottom",
3192
3215
  options: [
@@ -3236,6 +3259,49 @@ var UiDialogue = class extends HudWidgetBase {
3236
3259
  });
3237
3260
  }
3238
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
+ /**
3239
3305
  * Player pressed "next": mid-typing reveals the whole line; a fully-typed
3240
3306
  * line without choices advances (choice lines wait for `choose`).
3241
3307
  */
@@ -3318,12 +3384,11 @@ var UiDialogue = class extends HudWidgetBase {
3318
3384
  this.revealed = Math.round(progress * this.current.text.length);
3319
3385
  if (this.speakerEl) this.speakerEl.textContent = this.current.speaker;
3320
3386
  }
3321
- const wasTyping = this.revealed < this.current.text.length;
3322
- 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);
3323
3388
  const doneTyping = this.revealed >= this.current.text.length;
3324
3389
  if (this.textEl) this.textEl.textContent = this.showing;
3325
3390
  if (this.hintEl) this.hintEl.style.visibility = doneTyping && !this.current.choices ? "visible" : "hidden";
3326
- 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) => {
3327
3392
  const b = document.createElement("button");
3328
3393
  b.type = "button";
3329
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-BmuqYTiY.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 { F as localFromWorld, I as worldPosition } from "./gameplay-D1RADWu3.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
@@ -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-BmuqYTiY.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-BXNLfIJk.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-BXNLfIJk.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-Cga7FVP4.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-BmuqYTiY.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.66.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-BmuqYTiY.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. */