incanto 0.53.0 → 0.55.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 (57) hide show
  1. package/bin/incanto-check.mjs +56 -1
  2. package/bin/incanto-verify.mjs +46 -0
  3. package/dist/2d.d.ts +46 -3
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +105 -6
  6. package/dist/3d.js +10 -6
  7. package/dist/{behavior-Do0Da56m.d.ts → behavior-CyQoSu4n.d.ts} +114 -10
  8. package/dist/{create-game-9D87XaiX.js → create-game-BRt6XKmP.js} +10 -6
  9. package/dist/{create-game-CDJ1lVqK.js → create-game-CzK9_pzg.js} +10 -6
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/debug.js +4 -2
  12. package/dist/editor.js +7 -0
  13. package/dist/{environment-presets-SkGanr2s.js → environment-presets-DAbEdEwh.js} +326 -11
  14. package/dist/{gameplay-CRYw_Q-T.js → gameplay-CZ2yq37J.js} +30 -21
  15. package/dist/gameplay.d.ts +8 -3
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +5 -5
  19. package/dist/{loader-Cu_7kJDy.d.ts → loader-DhI1jFW_.d.ts} +1 -1
  20. package/dist/net.d.ts +2 -2
  21. package/dist/net.js +1 -1
  22. package/dist/{particle-sim-BzJ1yxoE.d.ts → particle-sim-C5OfBbmU.d.ts} +11 -0
  23. package/dist/{pathfinding-HNGFqGUZ.d.ts → pathfinding-CXGCpRQe.d.ts} +1 -1
  24. package/dist/{physics-2d-BhZZ-HAp.js → physics-2d-CfWAggJ1.js} +1 -1
  25. package/dist/{physics-3d-DmyO2oaN.js → physics-3d-C_ZJ6f_d.js} +2 -2
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-DROK2l7J.js → register-en63AEZO.js} +163 -4
  29. package/dist/{register-BTomIiYG.js → register-p48lHE2o.js} +130 -13
  30. package/dist/{replay-9Fy6C10F.d.ts → replay-ePMz26jw.d.ts} +1 -1
  31. package/dist/{replay-DYNUL4BU.js → replay-t1pP0gQg.js} +1 -1
  32. package/dist/{split-screen-DNmcX1Pz.d.ts → split-screen-BsdOHbzP.d.ts} +1 -1
  33. package/dist/{split-screen-CaF9hO7g.js → split-screen-CSb_uZ6W.js} +1 -1
  34. package/dist/{sprite-animation-D_p28jwU.js → sprite-animation-7qvUxF6Z.js} +19 -0
  35. package/dist/{src-C9xyZW7M.js → src-BVOVHRL0.js} +1 -1
  36. package/dist/{teardown-Bw2aeyGI.js → teardown-C7uVSJvx.js} +1 -1
  37. package/dist/{test-CJRsciFk.js → test-Ca5eqELC.js} +49 -14
  38. package/dist/test.d.ts +20 -4
  39. package/dist/test.js +2 -2
  40. package/dist/vite.js +2 -2
  41. package/editor/assets/{agent8-PlFHzJsh.js → agent8-Cz4oHPtm.js} +1 -1
  42. package/editor/assets/{debug-CebV7CDW.js → debug-r-Fi9nZh.js} +2 -2
  43. package/editor/assets/{index-CdbsY31G.js → index-BfqmvXYD.js} +91 -91
  44. package/editor/index.html +1 -1
  45. package/package.json +1 -1
  46. package/schemas/scene.schema.json +185 -0
  47. package/skills/incanto-assets.md +45 -2
  48. package/skills/incanto-building-2d-games.md +35 -0
  49. package/skills/incanto-building-3d-games.md +28 -3
  50. package/skills/incanto-environment.md +3 -1
  51. package/skills/incanto-gameplay-behaviors.md +51 -4
  52. package/skills/incanto-node-reference.md +31 -0
  53. package/skills/incanto-performance.md +51 -0
  54. package/skills/incanto-verifying-your-game.md +38 -0
  55. package/templates-app/beacon-isle-3d/package.json +1 -1
  56. package/templates-app/tps-3d/package.json +1 -1
  57. package/templates-app/village-quest-3d/package.json +1 -1
@@ -58,6 +58,61 @@ function collectScenes(entry) {
58
58
  return out;
59
59
  }
60
60
 
61
+ /**
62
+ * Local art the project does not contain.
63
+ *
64
+ * A scene asset pointing at a file nobody copied loads fine, validates fine and
65
+ * draws NOTHING — the failure exists only in a browser, in `assetErrors()`,
66
+ * which needs a running game and someone to look at it. This command has the
67
+ * scene's path and runs after every edit, and the file is either there or it is
68
+ * not. A WARNING, not an error: a url can legitimately be produced at runtime,
69
+ * and refusing to load a scene over a guess would be worse than the bug.
70
+ */
71
+ function missingArt(file, json) {
72
+ const assets = json && typeof json === 'object' ? json.assets : null;
73
+ if (!assets || typeof assets !== 'object') return [];
74
+ const sceneDir = dirname(file);
75
+ const root = projectRoot(sceneDir);
76
+ const out = [];
77
+ for (const [key, decl] of Object.entries(assets)) {
78
+ const url = decl && typeof decl === 'object' ? decl.url : null;
79
+ if (typeof url !== 'string' || url === '') continue;
80
+ // Remote and inline are not this command's business to guess about.
81
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//')) continue;
82
+ const rel = url.replace(/^\//, '');
83
+ const candidates = [
84
+ join(root, 'public', rel),
85
+ join(root, rel),
86
+ join(sceneDir, rel),
87
+ join(sceneDir, '..', rel),
88
+ ];
89
+ if (candidates.some((p) => existsSync(p))) continue;
90
+ out.push(
91
+ `$${key} → ${url} is not in the project ` +
92
+ `(looked in ${relativeish(root, join(root, 'public'))}, ${relativeish(root, root)}` +
93
+ `, and beside the scene). It will draw nothing.`,
94
+ );
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Nearest ancestor with a package.json, else the scene's own directory. */
100
+ function projectRoot(from) {
101
+ let dir = resolve(from);
102
+ for (let i = 0; i < 8; i++) {
103
+ if (existsSync(join(dir, 'package.json'))) return dir;
104
+ const up = dirname(dir);
105
+ if (up === dir) break;
106
+ dir = up;
107
+ }
108
+ return resolve(from);
109
+ }
110
+
111
+ function relativeish(root, p) {
112
+ const r = p.slice(root.length).replace(/^[\\/]/, '');
113
+ return r === '' ? './' : `${r}/`;
114
+ }
115
+
61
116
  const roots = args.files.length > 0 ? args.files : ['.'];
62
117
  for (const r of roots) {
63
118
  if (!existsSync(resolve(r))) {
@@ -84,7 +139,7 @@ const results = files.map((file) => {
84
139
  strictBehaviors: args.strict,
85
140
  resolveScene: (p) => JSON.parse(readFileSync(resolve(dirname(file), p), 'utf-8')),
86
141
  });
87
- if (res.ok) return { file, ok: true, warnings: auditScene(json) };
142
+ if (res.ok) return { file, ok: true, warnings: [...auditScene(json), ...missingArt(file, json)] };
88
143
  return {
89
144
  file,
90
145
  ok: false,
@@ -122,12 +122,16 @@ const rungs = [];
122
122
  );
123
123
  }
124
124
 
125
+ /** The playtest's JSON, shared by the `plays` and `feels` rungs. */
126
+ let playtestReport = null;
127
+
125
128
  // ---- plays ---------------------------------------------------------------
126
129
  if (rungs[0].status === 'pass') {
127
130
  const args = [scene, '--json', '--runs', '8'];
128
131
  if (behaviors) args.push('--behaviors', behaviors);
129
132
  const r = run('playtest', args);
130
133
  const out = safeJson(r.stdout);
134
+ playtestReport = out;
131
135
  const won = out?.runs?.filter((x) => x.outcome === 'won').length ?? 0;
132
136
  const total = out?.runs?.length ?? 0;
133
137
  // A scene that declares no win is not a scene that cannot be won: a
@@ -158,6 +162,48 @@ if (rungs[0].status === 'pass') {
158
162
  rungs.push({ name: 'plays', status: 'skipped', summary: 'not run — the scene does not load' });
159
163
  }
160
164
 
165
+ // ---- feels ---------------------------------------------------------------
166
+ // Sound and effects, from the same playtest that just ran. A game whose
167
+ // feedback is wired but never triggered plays perfectly and feels dead, and
168
+ // every other rung here calls it healthy.
169
+ {
170
+ const fb = playtestReport?.feedback;
171
+ const declared = (fb?.declaredAudio?.length ?? 0) + (fb?.declaredEffects?.length ?? 0);
172
+ const fired = new Set([...(fb?.heard ?? []), ...(fb?.shown ?? [])]);
173
+ if (!fb) {
174
+ rungs.push({
175
+ name: 'feels',
176
+ status: 'skipped',
177
+ summary: 'not run — the playtest did not report',
178
+ });
179
+ } else if (declared === 0) {
180
+ rungs.push({
181
+ name: 'feels',
182
+ status: 'skipped',
183
+ summary: 'this game declares no sound and no effects',
184
+ });
185
+ } else if (fired.size === 0) {
186
+ rungs.push({
187
+ name: 'feels',
188
+ status: 'fail',
189
+ summary: `${declared} sound/effect node(s) declared, and not one fired in any run`,
190
+ fix: 'connect them — a signal to `play` on an AudioPlayer, or `replay()` on a one-shot; `engine.audio.recent()` and `engine.effects.recent()` say what did fire',
191
+ });
192
+ } else {
193
+ const silent = [...(fb.declaredAudio ?? []), ...(fb.declaredEffects ?? [])].filter(
194
+ (path) => !fired.has(path),
195
+ );
196
+ rungs.push({
197
+ name: 'feels',
198
+ status: 'pass',
199
+ summary:
200
+ silent.length === 0
201
+ ? `every one of the ${declared} sound/effect nodes fired`
202
+ : `${fired.size} of ${declared} fired — silent: ${silent.slice(0, 3).join(', ')}${silent.length > 3 ? ` +${silent.length - 3}` : ''}`,
203
+ });
204
+ }
205
+ }
206
+
161
207
  // ---- draws ---------------------------------------------------------------
162
208
  {
163
209
  const r = run('frame', ['--json']);
package/dist/2d.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { Dt as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-Do0Da56m.js";
1
+ import { At as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-CyQoSu4n.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
- import { t as LoadSceneOptions } from "./loader-Cu_7kJDy.js";
4
+ import { t as LoadSceneOptions } from "./loader-DhI1jFW_.js";
5
5
  import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
6
- import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
+ import { r as ParticleView, t as ParticleSim } from "./particle-sim-C5OfBbmU.js";
7
7
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
8
8
  import * as RapierNs from "@dimforge/rapier2d-compat";
9
9
 
@@ -517,6 +517,16 @@ declare class AnimatedSprite2D extends Sprite2D {
517
517
  stop(): void;
518
518
  override onReady(): void;
519
519
  override update(dt: number): void;
520
+ /** Sheets already reported, so a bad grid says so ONCE, not every frame. */
521
+ private gridChecked;
522
+ /**
523
+ * Tell the author their grid does not fit, the first time it is drawn.
524
+ *
525
+ * Both halves of this were silent: a frame size that does not divide the
526
+ * sheet slices every row further off centre, and an animation naming a frame
527
+ * past the end of the grid draws whatever is at the wrong end of the image.
528
+ */
529
+ private checkGrid;
520
530
  protected override resolveTexture(assets: AssetStore2D | null): ResolvedSpriteTexture | null;
521
531
  }
522
532
  //#endregion
@@ -699,6 +709,18 @@ declare class Particles2D extends Node2D {
699
709
  static override readonly props: PropSchema;
700
710
  preset: string;
701
711
  emitting: boolean;
712
+ /**
713
+ * Where a particle LIVES once it is born.
714
+ *
715
+ * `false` (default) — the emitter's local space: the plume moves with the
716
+ * node, which is what a torch on a moving platform wants.
717
+ *
718
+ * `true` — the world: a particle keeps the place it was born, so dust is left
719
+ * BEHIND a running player instead of glued to it, and moving a one-shot to
720
+ * the next explosion (the documented `p.position = …; p.replay()` recipe)
721
+ * stops dragging the previous one across the level.
722
+ */
723
+ worldSpace: boolean;
702
724
  rate: number;
703
725
  burst: number;
704
726
  lifetime: number[];
@@ -728,9 +750,30 @@ declare class Particles2D extends Node2D {
728
750
  private finishedEmitted;
729
751
  /** @internal Lazily applies the preset + builds the deterministic sim. */
730
752
  _ensureSim(): ParticleSim;
753
+ /** Particles alive this frame — an emitter that is running, told from one
754
+ * that is not. `framing()` says where it IS; this says whether it is going. */
755
+ get aliveCount(): number;
756
+ /** A snapshot of the live particles — positions are in whichever space
757
+ * `worldSpace` selects, relative to the emitter. */
758
+ particles(): ParticleView[];
759
+ /** The emitter's world position last frame, for world-space counter-motion. */
760
+ private lastWorld;
761
+ /** Last frame's `emitting`, so turning one ON is a reportable event. */
762
+ private wasEmitting;
731
763
  override update(dt: number): void;
764
+ /**
765
+ * Undo the emitter's own motion on everything already alive.
766
+ *
767
+ * Ancestor-summed like the physics helpers, so it follows a moving PARENT
768
+ * (dust under a player) as well as the node's own position.
769
+ */
770
+ private holdWorldSpace;
771
+ /** Tell the engine an effect fired — the visual half of the audio record. */
772
+ private report;
732
773
  /** Restart a one-shot (re-burst + allow finished to fire again). */
733
774
  replay(): void;
775
+ /** `replay()` asked for this burst, so `emitting: false` does not hold it. */
776
+ private armedByReplay;
734
777
  private mesh;
735
778
  /** Parsed `paletteColors`, reused; rebuilt only when the hex list changes. */
736
779
  private paletteCache;
package/dist/2d.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-9D87XaiX.js";
3
- import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-DROK2l7J.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-BhZZ-HAp.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-BRt6XKmP.js";
3
+ import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-en63AEZO.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-CfWAggJ1.js";
5
5
  //#region src/2d/library-sprite.ts
6
6
  /**
7
7
  * What a `CharacterController2D`/`3D` will ask a skin to play, and the clip in
package/dist/3d.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { D as QualityCaps, Dt as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-Do0Da56m.js";
1
+ import { At as Node, D as QualityCaps, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-CyQoSu4n.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson$1, s as JsonObject } from "./schema-CFeioQRE.js";
4
- import { t as LoadSceneOptions } from "./loader-Cu_7kJDy.js";
5
- import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
4
+ import { t as LoadSceneOptions } from "./loader-DhI1jFW_.js";
5
+ import { r as ParticleView, t as ParticleSim } from "./particle-sim-C5OfBbmU.js";
6
6
  import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-DZ70IY26.js";
7
- import { n as PathGrid, s as SpatialPose } from "./pathfinding-HNGFqGUZ.js";
7
+ import { n as PathGrid, s as SpatialPose } from "./pathfinding-CXGCpRQe.js";
8
8
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
9
9
  import { VRM } from "@pixiv/three-vrm";
10
10
  import { Sky } from "three/examples/jsm/objects/Sky.js";
@@ -61,6 +61,15 @@ declare class TextureCache3D {
61
61
  */
62
62
  acquireOwn(url: string, spec?: TextureSpec): Texture | null;
63
63
  /**
64
+ * File a failure this store did not load itself.
65
+ *
66
+ * `Tree3D` keeps its own cache on purpose (it decodes off-thread and its
67
+ * page-session lifetime is load-bearing for the editor), which left its bark
68
+ * and leaf textures outside the one place `assetErrors()` reads. A 404'd leaf
69
+ * texture is a forest of bare branches and no message anywhere.
70
+ */
71
+ noteError(url: string, error: unknown): void;
72
+ /**
64
73
  * What 404'd, by URL — reported raw, never by cache key, because an agent
65
74
  * greps this against the URL it wrote.
66
75
  */
@@ -1859,6 +1868,15 @@ declare class LoftMesh3D extends Node3D {
1859
1868
  private geometryKey;
1860
1869
  protected override _createObject3D(): Object3D;
1861
1870
  override _syncObject3D(): void;
1871
+ /**
1872
+ * Hand the GPU back.
1873
+ *
1874
+ * Every other node that builds geometry disposes it here; these four did not,
1875
+ * so a scene swap left their buffers and materials resident and a game with
1876
+ * level transitions climbed until the tab died — with `stats().geometries` the
1877
+ * only witness, and only if someone was watching it.
1878
+ */
1879
+ override free(): void;
1862
1880
  }
1863
1881
  //#endregion
1864
1882
  //#region src/3d/nodes/model-instance-3d.d.ts
@@ -2030,6 +2048,18 @@ declare class Particles3D extends Node3D {
2030
2048
  static override readonly props: PropSchema;
2031
2049
  preset: string;
2032
2050
  emitting: boolean;
2051
+ /**
2052
+ * Where a particle LIVES once it is born.
2053
+ *
2054
+ * `false` (default) — the emitter's local space: the plume moves with the
2055
+ * node, which is what a torch on a moving platform wants.
2056
+ *
2057
+ * `true` — the world: a particle keeps the place it was born, so dust is left
2058
+ * BEHIND a running player instead of glued to it, and moving a one-shot to
2059
+ * the next explosion (the documented `p.position = …; p.replay()` recipe)
2060
+ * stops dragging the previous one across the level.
2061
+ */
2062
+ worldSpace: boolean;
2033
2063
  rate: number;
2034
2064
  burst: number;
2035
2065
  lifetime: number[];
@@ -2065,9 +2095,30 @@ declare class Particles3D extends Node3D {
2065
2095
  private finishedEmitted;
2066
2096
  /** @internal */
2067
2097
  _ensureSim(): ParticleSim;
2098
+ /** Particles alive this frame — an emitter that is running, told from one
2099
+ * that is not. `framing()` says where it IS; this says whether it is going. */
2100
+ get aliveCount(): number;
2101
+ /** A snapshot of the live particles — positions are in whichever space
2102
+ * `worldSpace` selects, relative to the emitter. */
2103
+ particles(): ParticleView[];
2104
+ /** The emitter's world position last frame, for world-space counter-motion. */
2105
+ private lastWorld;
2106
+ /** Last frame's `emitting`, so turning one ON is a reportable event. */
2107
+ private wasEmitting;
2068
2108
  override update(dt: number): void;
2109
+ /**
2110
+ * Undo the emitter's own motion on everything already alive.
2111
+ *
2112
+ * Ancestor-summed like the physics helpers, so it follows a moving PARENT
2113
+ * (dust under a player) as well as the node's own position.
2114
+ */
2115
+ private holdWorldSpace;
2116
+ /** Tell the engine an effect fired — the visual half of the audio record. */
2117
+ private report;
2069
2118
  /** Restart a one-shot (re-burst + allow finished to fire again). */
2070
2119
  replay(): void;
2120
+ /** `replay()` asked for this burst, so `emitting: false` does not hold it. */
2121
+ private armedByReplay;
2071
2122
  private points;
2072
2123
  private positions;
2073
2124
  private colors;
@@ -2080,6 +2131,15 @@ declare class Particles3D extends Node3D {
2080
2131
  * uses the colorStart→colorEnd ramp. */
2081
2132
  private refreshPalette;
2082
2133
  private syncParticles;
2134
+ /**
2135
+ * Hand the GPU back.
2136
+ *
2137
+ * Every other node that builds geometry disposes it here; these four did not,
2138
+ * so a scene swap left their buffers and materials resident and a game with
2139
+ * level transitions climbed until the tab died — with `stats().geometries` the
2140
+ * only witness, and only if someone was watching it.
2141
+ */
2142
+ override free(): void;
2083
2143
  }
2084
2144
  //#endregion
2085
2145
  //#region src/3d/terrain/heightmap.d.ts
@@ -2756,10 +2816,30 @@ declare class Trail3D extends Node3D {
2756
2816
  private mesh;
2757
2817
  private lastColor;
2758
2818
  protected override _createObject3D(): Object3D;
2819
+ /** Last frame's `emitting`, so laying ribbon is a reportable event. */
2820
+ private wasEmitting;
2759
2821
  override update(dt: number): void;
2760
2822
  _onRender3D(ctx: RenderContext3D): void;
2761
- /** Test hook: recorded point count. */
2823
+ /**
2824
+ * How many points the ribbon is made of.
2825
+ *
2826
+ * ZERO with no renderer, and that is not a bug: the ribbon is laid in the
2827
+ * render sync, because each point needs the node's real world transform (a
2828
+ * sword arc is a ROTATING parent, which an ancestor position sum cannot see).
2829
+ * Assert it from a browser check; read a headless 0 as "nothing rendered"
2830
+ * rather than "my trail is broken", and use the `trail` effect record for the
2831
+ * wiring question.
2832
+ */
2762
2833
  get pointCount(): number;
2834
+ /**
2835
+ * Hand the GPU back.
2836
+ *
2837
+ * Every other node that builds geometry disposes it here; these four did not,
2838
+ * so a scene swap left their buffers and materials resident and a game with
2839
+ * level transitions climbed until the tab died — with `stats().geometries` the
2840
+ * only witness, and only if someone was watching it.
2841
+ */
2842
+ override free(): void;
2763
2843
  }
2764
2844
  //#endregion
2765
2845
  //#region src/3d/vegetation/tree-blueprint.d.ts
@@ -2859,7 +2939,17 @@ declare class Tree3D extends Node3D {
2859
2939
  /** @internal Every grown variant (medium/high tiers; empty for 'simple'). */
2860
2940
  _variantMeshes(): TreeVariant[];
2861
2941
  override update(dt: number): void;
2862
- override _syncObject3D(): void;
2942
+ /** The scene's asset store, so a 404'd bark or leaf reaches assetErrors(). */
2943
+ private assets;
2944
+ override _syncObject3D(alpha?: number, assets?: AssetStore3D | null): void;
2945
+ /**
2946
+ * File a texture failure where the ladder reads it.
2947
+ *
2948
+ * This node keeps its own cache on purpose, and that left its textures
2949
+ * outside the one place `assetErrors()` looks — so a leaf URL that 404s was a
2950
+ * forest of bare branches with no message anywhere.
2951
+ */
2952
+ private noteTextureError;
2863
2953
  /** Re-grow only when a grove-shaping prop changed (the JSON IS the grove). */
2864
2954
  private rebuildIfNeeded;
2865
2955
  /** Resolve the drape target + cache the grove's world position for `bedY`. */
@@ -2930,6 +3020,15 @@ declare class VoxelGrid3D extends Node3D {
2930
3020
  blocks(): VoxelBlock[];
2931
3021
  protected override _createObject3D(): Object3D;
2932
3022
  override update(): void;
3023
+ /**
3024
+ * Hand the GPU back.
3025
+ *
3026
+ * Every other node that builds geometry disposes it here; these four did not,
3027
+ * so a scene swap left their buffers and materials resident and a game with
3028
+ * level transitions climbed until the tab died — with `stats().geometries` the
3029
+ * only witness, and only if someone was watching it.
3030
+ */
3031
+ override free(): void;
2933
3032
  }
2934
3033
  //#endregion
2935
3034
  //#region src/3d/water/interaction.d.ts
package/dist/3d.js CHANGED
@@ -1,9 +1,9 @@
1
- import { F as CharacterBody3D, H as WATER_MAX_RIPPLES, I as PhysicsBody3D, L as RigidBody3D, M as WATER_CUTOUT_MAX, N as WaterCutout3D, P as Area3D, R as StaticBody3D, j as Water3D, z as Node3D } from "./gameplay-CRYw_Q-T.js";
2
- import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-SkGanr2s.js";
3
- import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-CDJ1lVqK.js";
1
+ import { F as CharacterBody3D, H as WATER_MAX_RIPPLES, I as PhysicsBody3D, L as RigidBody3D, M as WATER_CUTOUT_MAX, N as WaterCutout3D, P as Area3D, R as StaticBody3D, j as Water3D, z as Node3D } from "./gameplay-CZ2yq37J.js";
2
+ import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-DAbEdEwh.js";
3
+ import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-CzK9_pzg.js";
4
4
  import { a as frameSignature, n as diffSignatures, o as frameStats, r as diffText, s as frameText, t as SIGNATURE_GRID } from "./frame-report-njybhZon.js";
5
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-DmyO2oaN.js";
6
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-C_ZJ6f_d.js";
7
7
  //#region src/3d/model-verdict.ts
8
8
  /** Mixamo exports every bone as `mixamorigX`; the retargeter binds by that name. */
9
9
  const MIXAMO = /^mixamorig[:_]?/i;
@@ -50,6 +50,10 @@ const BODY = {
50
50
  radius: .34,
51
51
  height: 1
52
52
  };
53
+ /** Three decimals — enough for millimetres, and no float dust in pasted JSON. */
54
+ function round3(n) {
55
+ return Math.round(n * 1e3) / 1e3;
56
+ }
53
57
  /**
54
58
  * A found character, as a thing that walks.
55
59
  *
@@ -122,10 +126,10 @@ function characterJson(target) {
122
126
  type: "ModelInstance3D",
123
127
  props: {
124
128
  model: `$${target.key}`,
125
- targetHeight: BODY.height + 2 * BODY.radius,
129
+ targetHeight: round3(BODY.height + 2 * BODY.radius),
126
130
  position: [
127
131
  0,
128
- -(BODY.height / 2 + BODY.radius),
132
+ round3(-(BODY.height / 2 + BODY.radius)),
129
133
  0
130
134
  ],
131
135
  castShadow: true
@@ -718,6 +718,57 @@ declare class SfxEngine {
718
718
  play(params: SfxParams, gain: number, opts?: SfxPlayOptions): void;
719
719
  }
720
720
  //#endregion
721
+ //#region src/core/effects-log.d.ts
722
+ /**
723
+ * What SHOWED — the visual half of the audio record.
724
+ *
725
+ * A game's feedback is sound and vision, and only one of them could be checked.
726
+ * `engine.audio.countOf('coin')` answers "did the coin sound fire?"; nothing
727
+ * answered "did the explosion?". `framing()` says where an emitter IS, which is
728
+ * a different question — a particle system that never fired and one that fired
729
+ * a hundred times sit at the same coordinates.
730
+ *
731
+ * Nothing here renders, so nothing here is measured in pixels. It records that
732
+ * the effect WAS ASKED FOR, which is the wiring question a game actually has:
733
+ * the burst that never happened because the signal was never connected looks
734
+ * exactly like the burst that happened off-screen, and this tells them apart.
735
+ *
736
+ * ```ts
737
+ * session.engine.effects.clearLog();
738
+ * smashTheCrystal();
739
+ * session.step(200);
740
+ * session.engine.effects.countOf('explosion'); // 1
741
+ * session.engine.effects.countFrom('/Game/Crystal/Boom'); // 1
742
+ * ```
743
+ */
744
+ /** The kinds of thing a game does to say "that happened". */
745
+ type EffectKind = "burst" | "emit" | "trail" | "shake" | "flash" | "hitstop";
746
+ /** One effect that fired. */
747
+ interface EffectEvent {
748
+ kind: EffectKind;
749
+ /** The preset name, the flash colour, or `''` when the effect has no name. */
750
+ name: string;
751
+ /** Node path, or `engine` for the screen-wide ones. */
752
+ from: string;
753
+ /** Particles in the burst, shake magnitude, seconds frozen — the amount. */
754
+ amount: number;
755
+ /** `engine.time` when it fired, in seconds. */
756
+ at: number;
757
+ }
758
+ declare class EffectLog {
759
+ private readonly events;
760
+ /** @internal Called by the effects themselves; games read `recent()`. */
761
+ record(event: EffectEvent): void;
762
+ /** The last `limit` effects, oldest first. */
763
+ recent(limit?: number): EffectEvent[];
764
+ /** How many times an effect with this name fired (`explosion`, `#ff0000`). */
765
+ countOf(name: string): number;
766
+ /** How many effects this node fired — the question a wiring check has. */
767
+ countFrom(path: string): number;
768
+ /** Forget what has fired so far (between scenes, or between assertions). */
769
+ clearLog(): void;
770
+ }
771
+ //#endregion
721
772
  //#region src/core/input.d.ts
722
773
  /**
723
774
  * Declarative input from scene JSON `input{}` — DOM-free logic (feed key state
@@ -1404,12 +1455,36 @@ interface QualityCaps {
1404
1455
  minReflectionIntervalMs: number;
1405
1456
  }
1406
1457
  //#endregion
1407
- //#region src/core/stats.d.ts
1458
+ //#region src/core/phase-timing.d.ts
1408
1459
  /**
1409
- * Performance-stats shapes pure data, importable everywhere (core stays
1410
- * three-free; the renderer adapters fill RendererStats from three's
1411
- * `renderer.info`).
1460
+ * Where the frame went the breakdown `stats()` could not give.
1461
+ *
1462
+ * fps and frameMs are a TOTAL, and a total has no next question. This engine
1463
+ * has already lost a day to that: a frame drop everyone attributed to the GPU
1464
+ * turned out to be garbage collection, and nothing on screen could have said
1465
+ * so. Three of a frame's four parts are the engine's own work, and the fourth
1466
+ * is what is left — which is exactly where GC and browser work hide.
1467
+ *
1468
+ * Means over the same rolling window as `frameMs`, so the numbers are
1469
+ * comparable to each other and to the whole.
1412
1470
  */
1471
+ /** One frame's own accounting, in milliseconds. */
1472
+ interface FramePhases {
1473
+ /** Fixed steps: physics, and anything on `fixedUpdate`. */
1474
+ fixedMs: number;
1475
+ /** The variable update: behaviors, node logic, tweens. */
1476
+ updateMs: number;
1477
+ /** Whatever renders, as reported by it. 0 when nothing does (headless). */
1478
+ renderMs: number;
1479
+ /**
1480
+ * The frame minus the three above. GC, browser layout, input handling,
1481
+ * anything outside the engine's own loop — and the one to look at when the
1482
+ * others are small and the frame is not.
1483
+ */
1484
+ otherMs: number;
1485
+ }
1486
+ //#endregion
1487
+ //#region src/core/stats.d.ts
1413
1488
  /** Loop-side counters from `engine.stats()` — queryable at any time. */
1414
1489
  interface EngineStats {
1415
1490
  /** Frames per second over the last ~60 REAL ticks (headless runs: 0). */
@@ -1427,6 +1502,11 @@ interface EngineStats {
1427
1502
  * this counter exists for.
1428
1503
  */
1429
1504
  errors: number;
1505
+ /**
1506
+ * Where the frame went: fixed (physics), update (behaviors), render, and
1507
+ * everything else. Means over the same window as `frameMs`.
1508
+ */
1509
+ phases: FramePhases;
1430
1510
  }
1431
1511
  /** GPU-side counters from `renderer.stats()` — the LAST rendered frame. */
1432
1512
  interface RendererStats {
@@ -1491,6 +1571,12 @@ declare class Engine {
1491
1571
  * routes its volume through this; games set it for global volume/mute. */
1492
1572
  readonly audio: AudioBuses;
1493
1573
  /**
1574
+ * What SHOWED — the visual half of the audio record. Particle bursts, camera
1575
+ * shake, screen flash and freeze frames land here, so "did the explosion
1576
+ * fire?" is answerable in a run with no renderer.
1577
+ */
1578
+ readonly effects: EffectLog;
1579
+ /**
1494
1580
  * Persisted player settings — volume, quality, sensitivity.
1495
1581
  *
1496
1582
  * `createSaveStore` was good and had zero callers, so every game's volume
@@ -1533,6 +1619,10 @@ declare class Engine {
1533
1619
  private accumulator;
1534
1620
  private disposeScheduler;
1535
1621
  private readonly frameStats;
1622
+ /** Where each frame's time went — the breakdown `frameMs` alone cannot give. */
1623
+ private readonly phaseTimings;
1624
+ /** This frame's render cost, as reported by whoever renders. */
1625
+ private renderMs;
1536
1626
  /**
1537
1627
  * Game-time multiplier: 1 = realtime, 0.5 = slow motion, 0 = frozen
1538
1628
  * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
@@ -1626,6 +1716,15 @@ declare class Engine {
1626
1716
  erroredNodes(): Node[];
1627
1717
  /** Un-skip every quarantined node — the "try again" after you fix the code. */
1628
1718
  resumeErroredNodes(): number;
1719
+ /**
1720
+ * How long the last render took, in ms — called by whoever renders.
1721
+ *
1722
+ * The engine does not render, so this is the one slice it cannot measure for
1723
+ * itself; without it the cost lands in `otherMs` beside the GC and the two
1724
+ * are indistinguishable, which is the confusion this whole breakdown exists
1725
+ * to end.
1726
+ */
1727
+ noteRenderMs(ms: number): void;
1629
1728
  stop(): void;
1630
1729
  /**
1631
1730
  * Live performance counters, queryable at ANY time. fps/frameMs come from a
@@ -1651,12 +1750,17 @@ declare class Engine {
1651
1750
  dispose(): void;
1652
1751
  /**
1653
1752
  * Advance exactly ONE fixed step and one variable update (both dt = the
1654
- * fixed step), bypassing the wall-clock accumulator entirely. Drift-free
1655
- * by construction — the unit of time for headless harnesses (incanto/test
1656
- * runScript): every input edge is visible to BOTH fixedUpdate and update
1657
- * exactly once.
1753
+ * fixed step scaled by `timeScale`), bypassing the wall-clock accumulator
1754
+ * entirely. Drift-free by construction — the unit of time for headless
1755
+ * harnesses (incanto/test runScript): every input edge is visible to BOTH
1756
+ * fixedUpdate and update exactly once.
1757
+ *
1758
+ * `ignorePause: true` steps a frame at full scale even while the game is
1759
+ * paused — the debug overlay's single-step button, and nothing else.
1658
1760
  */
1659
- step(): void;
1761
+ step(opts?: {
1762
+ ignorePause?: boolean;
1763
+ }): void;
1660
1764
  /** Advance the loop manually. First call after (re)start only primes the clock. */
1661
1765
  tick(nowMs: number): void;
1662
1766
  }
@@ -1824,4 +1928,4 @@ declare function registeredBehaviors(): string[];
1824
1928
  /** Test isolation helper. */
1825
1929
  declare function clearBehaviors(): void;
1826
1930
  //#endregion
1827
- export { T_PREFIX as $, SettingsValues as A, LogEntry as At, captureBehaviors as B, EngineStats as C, MusicTrack as Ct, QualityCaps as D, Node as Dt, DeviceHints as E, BusName as Et, BehaviorState as F, ORDER_GROUP_BASE as G, savesWithoutUid as H, RestoreReport as I, effectiveOrder as J, OrderGroup as K, SaveSlot as L, readDeviceHints as M, LogManager as Mt, suggestQuality as N, Signal as Nt, QualityTier as O, NodeLifecycle as Ot, Scene as P, SignalListener as Pt, Localization as Q, SaveSlots as R, Scheduler as S, MusicManager as St, RendererStats as T, AudioBuses as Tt, SaveStore as U, restoreBehaviors as V, createSaveStore as W, BASE_LOCALE as X, resolveOrderGroups as Y, LocaleTables as Z, mergeStaticSignals as _, SfxParams as _t, clearBehaviors as a, isAudioContextAvailable as at, Engine as b, synthSfx as bt, registeredBehaviors as c, Listener as ct, PropSchema as d, SpatialParams as dt, suggestLocale as et, clearRegistry as f, Vec3 as ft, getNodeType as g, SFX_PRESET_NAMES as gt, getNodeSignals as h, SFX_PRESETS as ht, behaviorSignals as i, SfxPlayOptions as it, qualityEnvironment as j, LogLevel as jt, Settings as k, SceneTree as kt, NodeCtor as l, ROLLOFF_MODELS as lt, getNodeSchema as m, spatialPan as mt, BehaviorCtor as n, InputMap as nt, getBehavior as o, Voice as ot, createNode as p, spatialGain as pt, OrderGroupTable as q, behaviorSchema as r, SfxEngine as rt, registerBehavior as s, VoicePreset as st, Behavior as t, translationKey as tt, PropDef as u, RolloffModel as ut, registerNode as v, SfxWave as vt, GameStats as w, PlayMusicOptions as wt, EngineOptions as x, MusicBackend as xt, registeredTypes as y, SynthOptions as yt, behaviorsWithoutSave as z };
1931
+ export { T_PREFIX as $, SettingsValues as A, Node as At, captureBehaviors as B, EngineStats as C, synthSfx as Ct, QualityCaps as D, PlayMusicOptions as Dt, DeviceHints as E, MusicTrack as Et, BehaviorState as F, LogManager as Ft, ORDER_GROUP_BASE as G, savesWithoutUid as H, RestoreReport as I, Signal as It, effectiveOrder as J, OrderGroup as K, SaveSlot as L, SignalListener as Lt, readDeviceHints as M, SceneTree as Mt, suggestQuality as N, LogEntry as Nt, QualityTier as O, AudioBuses as Ot, Scene as P, LogLevel as Pt, Localization as Q, SaveSlots as R, Scheduler as S, SynthOptions as St, RendererStats as T, MusicManager as Tt, SaveStore as U, restoreBehaviors as V, createSaveStore as W, BASE_LOCALE as X, resolveOrderGroups as Y, LocaleTables as Z, mergeStaticSignals as _, spatialPan as _t, clearBehaviors as a, EffectLog as at, Engine as b, SfxParams as bt, registeredBehaviors as c, isAudioContextAvailable as ct, PropSchema as d, Listener as dt, suggestLocale as et, clearRegistry as f, ROLLOFF_MODELS as ft, getNodeType as g, spatialGain as gt, getNodeSignals as h, Vec3 as ht, behaviorSignals as i, EffectKind as it, qualityEnvironment as j, NodeLifecycle as jt, Settings as k, BusName as kt, NodeCtor as l, Voice as lt, getNodeSchema as m, SpatialParams as mt, BehaviorCtor as n, InputMap as nt, getBehavior as o, SfxEngine as ot, createNode as p, RolloffModel as pt, OrderGroupTable as q, behaviorSchema as r, EffectEvent as rt, registerBehavior as s, SfxPlayOptions as st, Behavior as t, translationKey as tt, PropDef as u, VoicePreset as ut, registerNode as v, SFX_PRESETS as vt, GameStats as w, MusicBackend as wt, EngineOptions as x, SfxWave as xt, registeredTypes as y, SFX_PRESET_NAMES as yt, behaviorsWithoutSave as z };
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { A as registerBehavior, C as diagnose, n as loadScene, o as computeViewport, s as resolveViewport } from "./loader-BTkHYrQn.js";
3
- import { b as AudioPlayer, x as Engine } from "./register-BTomIiYG.js";
3
+ import { b as AudioPlayer, x as Engine } from "./register-p48lHE2o.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { i as resolveRendering, n as attachTouchControls } from "./touch-BnMyy9tr.js";
6
- import { a as openBundledEditor, c as audioErrors, i as devServerLibrary, n as pauseWhenHidden, r as crossFade, s as claimCanvasGestures, t as teardown } from "./teardown-Bw2aeyGI.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-CRYw_Q-T.js";
8
- import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-DROK2l7J.js";
6
+ import { a as openBundledEditor, c as audioErrors, i as devServerLibrary, n as pauseWhenHidden, r as crossFade, s as claimCanvasGestures, t as teardown } from "./teardown-C7uVSJvx.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-CZ2yq37J.js";
8
+ import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-en63AEZO.js";
9
9
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
10
- import { n as enablePhysics2D } from "./physics-2d-BhZZ-HAp.js";
10
+ import { n as enablePhysics2D } from "./physics-2d-CfWAggJ1.js";
11
11
  import { Box3, BufferAttribute, BufferGeometry, Color, LineBasicMaterial, LineSegments, LinearFilter, NearestFilter, OrthographicCamera, Raycaster, SRGBColorSpace, Scene, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
12
12
  //#region src/2d/assets.ts
13
13
  /**
@@ -333,7 +333,11 @@ var Renderer2D = class {
333
333
  this.selectionLines.renderOrder = 1e4;
334
334
  this.selectionLines.visible = false;
335
335
  this.worldScene.add(this.selectionLines);
336
- this.disconnect = this.engine.updated.connect(() => this.render());
336
+ this.disconnect = this.engine.updated.connect(() => {
337
+ const started = performance.now();
338
+ this.render();
339
+ this.engine.noteRenderMs(performance.now() - started);
340
+ });
337
341
  }
338
342
  debugLines;
339
343
  selectionLines;