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
@@ -1,15 +1,15 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { A as registerBehavior, C as diagnose, n as loadScene } from "./loader-BTkHYrQn.js";
3
- import { C as qualityCaps, T as qualityRendering, b as AudioPlayer, x as Engine } from "./register-BTomIiYG.js";
3
+ import { C as qualityCaps, T as qualityRendering, b as AudioPlayer, x as Engine } from "./register-p48lHE2o.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { r as parseDrive, t as logReport } from "./log-report-CPFm4OXf.js";
6
6
  import { i as resolveRendering, n as attachTouchControls } from "./touch-BnMyy9tr.js";
7
- import { a as openBundledEditor, c as audioErrors, i as devServerLibrary, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, s as claimCanvasGestures, t as teardown } from "./teardown-Bw2aeyGI.js";
8
- import { I as PhysicsBody3D, V as createCausticsQuad, n as registerGameplayBehaviors, z as Node3D } from "./gameplay-CRYw_Q-T.js";
7
+ import { a as openBundledEditor, c as audioErrors, i as devServerLibrary, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, s as claimCanvasGestures, t as teardown } from "./teardown-C7uVSJvx.js";
8
+ import { I as PhysicsBody3D, V as createCausticsQuad, n as registerGameplayBehaviors, z as Node3D } from "./gameplay-CZ2yq37J.js";
9
9
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
10
- import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-SkGanr2s.js";
10
+ import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-DAbEdEwh.js";
11
11
  import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-njybhZon.js";
12
- import { n as enablePhysics3D } from "./physics-3d-DmyO2oaN.js";
12
+ import { n as enablePhysics3D } from "./physics-3d-C_ZJ6f_d.js";
13
13
  import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
14
14
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
15
15
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
@@ -1670,7 +1670,11 @@ var Renderer3D = class {
1670
1670
  this.selectionLines.renderOrder = 1e4;
1671
1671
  this.selectionLines.visible = false;
1672
1672
  this.threeScene.add(this.selectionLines);
1673
- this.disconnect = this.engine.updated.connect(() => this.render());
1673
+ this.disconnect = this.engine.updated.connect(() => {
1674
+ const started = performance.now();
1675
+ this.render();
1676
+ this.engine.noteRenderMs(performance.now() - started);
1677
+ });
1674
1678
  }
1675
1679
  /** The pixel ratio the scene ASKED for — the governor scales this, never replaces it. */
1676
1680
  basePixelRatio;
package/dist/debug.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { T as RendererStats, b as Engine, jt as LogLevel } from "./behavior-Do0Da56m.js";
1
+ import { Pt as LogLevel, T as RendererStats, b as Engine } from "./behavior-CyQoSu4n.js";
2
2
 
3
3
  //#region src/debug/panel.d.ts
4
4
  /** Minimal document surface the overlay needs (injectable for tests). */
package/dist/debug.js CHANGED
@@ -1164,7 +1164,9 @@ var DebugOverlay = class {
1164
1164
  ...gpu.triangles !== void 0 ? [`tris ${compactCount(gpu.triangles)}`] : [],
1165
1165
  ...gpu.drawCalls !== void 0 ? [`calls ${gpu.drawCalls}`] : []
1166
1166
  ].join(" · ");
1167
- chip.textContent = `${Math.round(stats.fps)} fps · ${stats.frameMs.toFixed(1)} ms\n${line2}`;
1167
+ const p = stats.phases;
1168
+ const line3 = `\nphys ${p.fixedMs.toFixed(1)} · logic ${p.updateMs.toFixed(1)} · draw ${p.renderMs.toFixed(1)} · other ${p.otherMs.toFixed(1)}`;
1169
+ chip.textContent = `${Math.round(stats.fps)} fps · ${stats.frameMs.toFixed(1)} ms\n${line2}${line3}`;
1168
1170
  }
1169
1171
  renderExplorer() {
1170
1172
  const panel = this.panels.get("explorer");
@@ -1600,7 +1602,7 @@ var DebugOverlay = class {
1600
1602
  */
1601
1603
  nextFrame() {
1602
1604
  if (!this.paused) this.setPaused(true);
1603
- this.engine.step();
1605
+ this.engine.step({ ignorePause: true });
1604
1606
  this.syncTime();
1605
1607
  }
1606
1608
  /** Push the engine's current time state into the panel, without rebuilding it. */
package/dist/editor.js CHANGED
@@ -1181,6 +1181,13 @@ var kt = {
1181
1181
  en: "Options are the locales the scene declares in \"strings\", each labeled with its own endonym. Picking one switches the game live and persists it. English is the base: any key a locale omits falls back to it, on purpose.",
1182
1182
  ko: "옵션은 씬의 \"strings\"가 선언한 로케일이며, 각 언어의 자기 이름으로 표시됩니다. 선택하면 즉시 바뀌고 저장됩니다. 영어가 기준이라 로케일이 빠뜨린 키는 의도적으로 영어로 표시됩니다."
1183
1183
  }),
1184
+ L("Label3D", {
1185
+ en: "Text standing in the world — a nameplate, a sign, a damage number.",
1186
+ ko: "월드에 서 있는 텍스트 — 이름표, 표지판, 데미지 숫자."
1187
+ }, {
1188
+ en: "height is the cap height in METRES and the quad follows what the text measures, so a sign is as wide as its words. Faces the camera by default (a damage number read from behind is a bug) and carries a dark outline so it stays readable over anything. Everything else a sprite knows — anchor, tint, opacity, renderOrder — it inherits. For a damage number: set text, then float it up and fade opacity to 0 and queueFree().",
1189
+ ko: "height는 글자 높이(미터)이고 가로 폭은 텍스트 실측을 따라갑니다. 기본으로 카메라를 향하며(뒤에서 읽히는 데미지 숫자는 버그니까요) 어두운 외곽선이 있어 어떤 배경에서도 읽힙니다. 앵커·틴트·투명도·renderOrder 등 스프라이트의 나머지 기능은 그대로 상속합니다. 데미지 숫자는 text를 넣고 위로 띄우며 opacity를 0으로 보낸 뒤 queueFree() 하면 됩니다."
1190
+ }),
1184
1191
  L("UiVolumeSlider", {
1185
1192
  en: "A volume slider, already wired to a bus.",
1186
1193
  ko: "볼륨 슬라이더 — 버스에 이미 연결되어 있습니다."
@@ -1,10 +1,10 @@
1
- import { t as registerCoreNodes } from "./register-BTomIiYG.js";
1
+ import { t as registerCoreNodes } from "./register-p48lHE2o.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
4
  import { i as getNodeSchema, l as registerNode } from "./registry-C7u42TID.js";
5
5
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
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-D_p28jwU.js";
7
- import { F as CharacterBody3D, I as PhysicsBody3D, L as RigidBody3D, N as WaterCutout3D, P as Area3D, R as StaticBody3D, U as colliderFootDrop, j as Water3D, z as Node3D } from "./gameplay-CRYw_Q-T.js";
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-7qvUxF6Z.js";
7
+ import { F as CharacterBody3D, I as PhysicsBody3D, L as RigidBody3D, N as WaterCutout3D, P as Area3D, R as StaticBody3D, U as colliderFootDrop, j as Water3D, z as Node3D } from "./gameplay-CZ2yq37J.js";
8
8
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
9
9
  import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
10
10
  import { clone } from "three/addons/utils/SkeletonUtils.js";
@@ -104,6 +104,25 @@ var TextureCache3D = class {
104
104
  return master ? master.clone() : null;
105
105
  }
106
106
  /**
107
+ * File a failure this store did not load itself.
108
+ *
109
+ * `Tree3D` keeps its own cache on purpose (it decodes off-thread and its
110
+ * page-session lifetime is load-bearing for the editor), which left its bark
111
+ * and leaf textures outside the one place `assetErrors()` reads. A 404'd leaf
112
+ * texture is a forest of bare branches and no message anywhere.
113
+ */
114
+ noteError(url, error) {
115
+ const key = `external|${url}`;
116
+ if (this.entries.has(key)) return;
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ this.entries.set(key, {
119
+ url,
120
+ texture: null,
121
+ error: message
122
+ });
123
+ this.diagnostics?.warn(`texture failed to load: ${url} — ${message}`);
124
+ }
125
+ /**
107
126
  * What 404'd, by URL — reported raw, never by cache key, because an agent
108
127
  * greps this against the URL it wrote.
109
128
  */
@@ -5666,6 +5685,109 @@ const colorScratchB$1 = new Color();
5666
5685
  const colorScratchC$1 = new Color();
5667
5686
  const tuftDryScratch = new Color();
5668
5687
  //#endregion
5688
+ //#region src/3d/nodes/label-3d.ts
5689
+ /**
5690
+ * Text standing in the world — a damage number, a nameplate, a sign.
5691
+ *
5692
+ * Every action game has these, and the answer used to be a sentence in the 3D
5693
+ * skill: *"There is no 3D text node — a nameplate or damage number is a
5694
+ * canvas-rendered texture on a child Sprite3D."* True, and a real afternoon of
5695
+ * work per game: measure the text, size the canvas, keep the device pixel ratio
5696
+ * honest, build the texture, work out the quad's aspect, re-rasterise when the
5697
+ * number changes, and dispose the old texture so a damage number does not leak
5698
+ * one per hit.
5699
+ *
5700
+ * It IS a canvas-rendered texture on a Sprite3D — which is exactly why it
5701
+ * should be a node. Everything a sprite already knows (billboarding, anchor,
5702
+ * tint, opacity, depth, render order) it inherits, so this only adds the text.
5703
+ *
5704
+ * ```json
5705
+ * { "name": "Name", "type": "Label3D",
5706
+ * "props": { "text": "Goblin", "height": 0.25, "position": [0, 2, 0] } }
5707
+ * ```
5708
+ *
5709
+ * A damage number is this plus two lines of behavior: set `text`, then move it
5710
+ * up and fade `opacity` to 0 over ~0.6 s and `queueFree()`.
5711
+ */
5712
+ var Label3D = class extends Sprite3D {
5713
+ static typeName = "Label3D";
5714
+ static props = {
5715
+ ...Sprite3D.props,
5716
+ text: { default: "" },
5717
+ /** Cap height of the text in METRES — the one number a sign is sized by. */
5718
+ height: { default: .3 },
5719
+ color: { default: "#ffffff" },
5720
+ font: { default: "sans-serif" },
5721
+ /** Outline colour; `""` turns it off. On by default: white text over a
5722
+ * bright wall is unreadable, and a damage number lands anywhere. */
5723
+ outline: { default: "#000000" },
5724
+ /** A damage number read from behind is a bug, so these face the camera. */
5725
+ billboard: {
5726
+ default: "full",
5727
+ options: [
5728
+ "y",
5729
+ "full",
5730
+ "none"
5731
+ ]
5732
+ }
5733
+ };
5734
+ text = "";
5735
+ height = .3;
5736
+ color = "#ffffff";
5737
+ font = "sans-serif";
5738
+ outline = "#000000";
5739
+ billboard = "full";
5740
+ canvas = null;
5741
+ /** The rasterised text. Named apart from Sprite3D's `texture` URL prop, which
5742
+ * this node ignores — the text IS the picture. */
5743
+ canvasTexture = null;
5744
+ lastKey = "";
5745
+ resolveTexture(_assets) {
5746
+ if (this.text === "" || typeof document === "undefined") return null;
5747
+ const key = `${this.text}\0${this.height}\0${this.color}\0${this.font}\0${this.outline}`;
5748
+ if (key !== this.lastKey) {
5749
+ this.lastKey = key;
5750
+ this.rasterize();
5751
+ }
5752
+ return this.canvasTexture;
5753
+ }
5754
+ /** Draw the text once, and size the quad to what it measured. */
5755
+ rasterize() {
5756
+ const fontPx = Math.max(8, Math.round(this.height * 256));
5757
+ this.canvas ??= document.createElement("canvas");
5758
+ const ctx = this.canvas.getContext("2d");
5759
+ if (!ctx) return;
5760
+ const stroke = Math.max(1, Math.round(fontPx * .14));
5761
+ const fontSpec = `bold ${fontPx}px ${this.font}`;
5762
+ ctx.font = fontSpec;
5763
+ const width = Math.max(1, Math.ceil(ctx.measureText(this.text).width) + stroke * 2);
5764
+ const lineHeight = Math.ceil(fontPx * 1.35) + stroke * 2;
5765
+ this.canvas.width = width;
5766
+ this.canvas.height = lineHeight;
5767
+ ctx.font = fontSpec;
5768
+ ctx.textBaseline = "middle";
5769
+ ctx.textAlign = "center";
5770
+ if (this.outline !== "") {
5771
+ ctx.lineWidth = stroke;
5772
+ ctx.lineJoin = "round";
5773
+ ctx.strokeStyle = this.outline;
5774
+ ctx.strokeText(this.text, width / 2, lineHeight / 2);
5775
+ }
5776
+ ctx.fillStyle = this.color;
5777
+ ctx.fillText(this.text, width / 2, lineHeight / 2);
5778
+ this.canvasTexture?.dispose();
5779
+ const texture = new CanvasTexture(this.canvas);
5780
+ texture.colorSpace = SRGBColorSpace;
5781
+ this.canvasTexture = texture;
5782
+ this.size = [width / lineHeight * this.height * 1.35, this.height * 1.35];
5783
+ }
5784
+ free() {
5785
+ this.canvasTexture?.dispose();
5786
+ this.canvasTexture = null;
5787
+ super.free();
5788
+ }
5789
+ };
5790
+ //#endregion
5669
5791
  //#region src/3d/nodes/lights-3d.ts
5670
5792
  /** Sun-style light. Direction comes from the node's rotation. */
5671
5793
  var DirectionalLight3D = class extends Node3D {
@@ -5806,6 +5928,25 @@ var LoftMesh3D = class extends Node3D {
5806
5928
  m.castShadow = this.castShadow;
5807
5929
  m.receiveShadow = this.receiveShadow;
5808
5930
  }
5931
+ /**
5932
+ * Hand the GPU back.
5933
+ *
5934
+ * Every other node that builds geometry disposes it here; these four did not,
5935
+ * so a scene swap left their buffers and materials resident and a game with
5936
+ * level transitions climbed until the tab died — with `stats().geometries` the
5937
+ * only witness, and only if someone was watching it.
5938
+ */
5939
+ free() {
5940
+ const object = this._builtObject3D;
5941
+ if (object) object.traverse((child) => {
5942
+ const mesh = child;
5943
+ mesh.geometry?.dispose();
5944
+ const material = mesh.material;
5945
+ const list = Array.isArray(material) ? material : material ? [material] : [];
5946
+ for (const m of list) m.dispose?.();
5947
+ });
5948
+ super.free();
5949
+ }
5809
5950
  };
5810
5951
  function validateSections(sections, name) {
5811
5952
  const fail = (message) => {
@@ -6764,6 +6905,8 @@ var Particles3D = class Particles3D extends Node3D {
6764
6905
  options: ["custom", ...PARTICLE_PRESET_NAMES]
6765
6906
  },
6766
6907
  emitting: { default: true },
6908
+ /** Particles keep the place they were born instead of following the node. */
6909
+ worldSpace: { default: false },
6767
6910
  rate: { default: 40 },
6768
6911
  burst: { default: 0 },
6769
6912
  lifetime: { default: [.6, 1.2] },
@@ -6789,6 +6932,18 @@ var Particles3D = class Particles3D extends Node3D {
6789
6932
  };
6790
6933
  preset = "custom";
6791
6934
  emitting = true;
6935
+ /**
6936
+ * Where a particle LIVES once it is born.
6937
+ *
6938
+ * `false` (default) — the emitter's local space: the plume moves with the
6939
+ * node, which is what a torch on a moving platform wants.
6940
+ *
6941
+ * `true` — the world: a particle keeps the place it was born, so dust is left
6942
+ * BEHIND a running player instead of glued to it, and moving a one-shot to
6943
+ * the next explosion (the documented `p.position = …; p.replay()` recipe)
6944
+ * stops dragging the previous one across the level.
6945
+ */
6946
+ worldSpace = false;
6792
6947
  rate = 40;
6793
6948
  burst = 0;
6794
6949
  lifetime = [.6, 1.2];
@@ -6855,12 +7010,38 @@ var Particles3D = class Particles3D extends Node3D {
6855
7010
  }
6856
7011
  return this.sim;
6857
7012
  }
7013
+ /** Particles alive this frame — an emitter that is running, told from one
7014
+ * that is not. `framing()` says where it IS; this says whether it is going. */
7015
+ get aliveCount() {
7016
+ return this.sim?.count ?? 0;
7017
+ }
7018
+ /** A snapshot of the live particles — positions are in whichever space
7019
+ * `worldSpace` selects, relative to the emitter. */
7020
+ particles() {
7021
+ const out = [];
7022
+ this.sim?.forEach((p) => {
7023
+ out.push({ ...p });
7024
+ });
7025
+ return out;
7026
+ }
7027
+ /** The emitter's world position last frame, for world-space counter-motion. */
7028
+ lastWorld = null;
7029
+ /** Last frame's `emitting`, so turning one ON is a reportable event. */
7030
+ wasEmitting = false;
6858
7031
  update(dt) {
6859
7032
  const sim = this._ensureSim();
7033
+ this.holdWorldSpace(sim);
6860
7034
  sim.setRate(this.emitting ? this.rate : 0);
6861
- if (this.burst > 0 && !this.bursted) {
7035
+ if (this.burst > 0 && !this.bursted && (this.emitting || this.armedByReplay)) {
6862
7036
  this.bursted = true;
7037
+ this.armedByReplay = false;
6863
7038
  sim.burst(this.burst);
7039
+ this.report("burst", this.burst);
7040
+ }
7041
+ const on = this.emitting && this.rate > 0;
7042
+ if (on !== this.wasEmitting) {
7043
+ this.wasEmitting = on;
7044
+ if (on) this.report("emit", this.rate);
6864
7045
  }
6865
7046
  sim.update(dt);
6866
7047
  if ((!this.emitting || this.rate === 0) && this.bursted && sim.done && !this.finishedEmitted) {
@@ -6868,11 +7049,52 @@ var Particles3D = class Particles3D extends Node3D {
6868
7049
  this.emit("finished");
6869
7050
  }
6870
7051
  }
7052
+ /**
7053
+ * Undo the emitter's own motion on everything already alive.
7054
+ *
7055
+ * Ancestor-summed like the physics helpers, so it follows a moving PARENT
7056
+ * (dust under a player) as well as the node's own position.
7057
+ */
7058
+ holdWorldSpace(sim) {
7059
+ if (!this.worldSpace) {
7060
+ this.lastWorld = null;
7061
+ return;
7062
+ }
7063
+ let x = 0;
7064
+ let y = 0;
7065
+ let z = 0;
7066
+ for (let n = this; n; n = n.parent) if (n instanceof Node3D) {
7067
+ x += n.position[0] ?? 0;
7068
+ y += n.position[1] ?? 0;
7069
+ z += n.position[2] ?? 0;
7070
+ }
7071
+ const prev = this.lastWorld;
7072
+ if (prev) sim.translateAll(prev[0] - x, prev[1] - y, prev[2] - z);
7073
+ this.lastWorld = [
7074
+ x,
7075
+ y,
7076
+ z
7077
+ ];
7078
+ }
7079
+ /** Tell the engine an effect fired — the visual half of the audio record. */
7080
+ report(kind, amount) {
7081
+ const engine = this.tree?.engine;
7082
+ engine?.effects.record({
7083
+ kind,
7084
+ name: this.preset,
7085
+ from: this.getPath(),
7086
+ amount,
7087
+ at: engine.time
7088
+ });
7089
+ }
6871
7090
  /** Restart a one-shot (re-burst + allow finished to fire again). */
6872
7091
  replay() {
6873
7092
  this.bursted = false;
6874
7093
  this.finishedEmitted = false;
7094
+ this.armedByReplay = true;
6875
7095
  }
7096
+ /** `replay()` asked for this burst, so `emitting: false` does not hold it. */
7097
+ armedByReplay = false;
6876
7098
  points = null;
6877
7099
  positions = null;
6878
7100
  colors = null;
@@ -6958,6 +7180,25 @@ var Particles3D = class Particles3D extends Node3D {
6958
7180
  geometry.getAttribute("position").needsUpdate = true;
6959
7181
  geometry.getAttribute("color").needsUpdate = true;
6960
7182
  }
7183
+ /**
7184
+ * Hand the GPU back.
7185
+ *
7186
+ * Every other node that builds geometry disposes it here; these four did not,
7187
+ * so a scene swap left their buffers and materials resident and a game with
7188
+ * level transitions climbed until the tab died — with `stats().geometries` the
7189
+ * only witness, and only if someone was watching it.
7190
+ */
7191
+ free() {
7192
+ const object = this._builtObject3D;
7193
+ if (object) object.traverse((child) => {
7194
+ const mesh = child;
7195
+ mesh.geometry?.dispose();
7196
+ const material = mesh.material;
7197
+ const list = Array.isArray(material) ? material : material ? [material] : [];
7198
+ for (const m of list) m.dispose?.();
7199
+ });
7200
+ super.free();
7201
+ }
6961
7202
  };
6962
7203
  const colorScratchA = new Color();
6963
7204
  const colorScratchB = new Color();
@@ -9008,8 +9249,21 @@ var Trail3D = class extends Node3D {
9008
9249
  group.add(this.mesh);
9009
9250
  return group;
9010
9251
  }
9252
+ /** Last frame's `emitting`, so laying ribbon is a reportable event. */
9253
+ wasEmitting = false;
9011
9254
  update(dt) {
9012
9255
  this.clock += dt;
9256
+ if (this.emitting !== this.wasEmitting) {
9257
+ this.wasEmitting = this.emitting;
9258
+ const engine = this.tree?.engine;
9259
+ if (this.emitting && engine) engine.effects.record({
9260
+ kind: "trail",
9261
+ name: this.color,
9262
+ from: this.getPath(),
9263
+ amount: this.seconds,
9264
+ at: engine.time
9265
+ });
9266
+ }
9013
9267
  while (this.points.length > 0 && this.clock - this.points[0].t > this.seconds) this.points.shift();
9014
9268
  }
9015
9269
  _onRender3D(ctx) {
@@ -9066,10 +9320,38 @@ var Trail3D = class extends Node3D {
9066
9320
  }
9067
9321
  geometry.setDrawRange(0, (n - 1) * 6);
9068
9322
  }
9069
- /** Test hook: recorded point count. */
9323
+ /**
9324
+ * How many points the ribbon is made of.
9325
+ *
9326
+ * ZERO with no renderer, and that is not a bug: the ribbon is laid in the
9327
+ * render sync, because each point needs the node's real world transform (a
9328
+ * sword arc is a ROTATING parent, which an ancestor position sum cannot see).
9329
+ * Assert it from a browser check; read a headless 0 as "nothing rendered"
9330
+ * rather than "my trail is broken", and use the `trail` effect record for the
9331
+ * wiring question.
9332
+ */
9070
9333
  get pointCount() {
9071
9334
  return this.points.length;
9072
9335
  }
9336
+ /**
9337
+ * Hand the GPU back.
9338
+ *
9339
+ * Every other node that builds geometry disposes it here; these four did not,
9340
+ * so a scene swap left their buffers and materials resident and a game with
9341
+ * level transitions climbed until the tab died — with `stats().geometries` the
9342
+ * only witness, and only if someone was watching it.
9343
+ */
9344
+ free() {
9345
+ const object = this._builtObject3D;
9346
+ if (object) object.traverse((child) => {
9347
+ const mesh = child;
9348
+ mesh.geometry?.dispose();
9349
+ const material = mesh.material;
9350
+ const list = Array.isArray(material) ? material : material ? [material] : [];
9351
+ for (const m of list) m.dispose?.();
9352
+ });
9353
+ super.free();
9354
+ }
9073
9355
  };
9074
9356
  const worldScratch = new Vector3();
9075
9357
  const dirScratch = new Vector3();
@@ -10863,10 +11145,23 @@ var Tree3D = class Tree3D extends Node3D {
10863
11145
  this.leafFadeUniform.end.value = this.leafFadeEnd;
10864
11146
  }
10865
11147
  }
10866
- _syncObject3D() {
10867
- super._syncObject3D();
11148
+ /** The scene's asset store, so a 404'd bark or leaf reaches assetErrors(). */
11149
+ assets = null;
11150
+ _syncObject3D(alpha = 1, assets = null) {
11151
+ super._syncObject3D(alpha, assets);
11152
+ this.assets = assets;
10868
11153
  this.rebuildIfNeeded();
10869
11154
  }
11155
+ /**
11156
+ * File a texture failure where the ladder reads it.
11157
+ *
11158
+ * This node keeps its own cache on purpose, and that left its textures
11159
+ * outside the one place `assetErrors()` looks — so a leaf URL that 404s was a
11160
+ * forest of bare branches with no message anywhere.
11161
+ */
11162
+ noteTextureError = (url, error) => {
11163
+ this.assets?.textures.noteError(url, error);
11164
+ };
10870
11165
  /** Re-grow only when a grove-shaping prop changed (the JSON IS the grove). */
10871
11166
  rebuildIfNeeded() {
10872
11167
  if (!this.grove) {
@@ -10975,7 +11270,7 @@ var Tree3D = class Tree3D extends Node3D {
10975
11270
  if (loader) material.map = loadSharedTexture(loader, url, (texture) => {
10976
11271
  texture.colorSpace = SRGBColorSpace;
10977
11272
  texture.flipY = false;
10978
- }, (texture) => greenMatte(texture));
11273
+ }, (texture) => greenMatte(texture), this.noteTextureError);
10979
11274
  wireLeafWind(material, this.swayUniform, LEAF_ENV_CUT, this.leafFadeUniform ?? void 0);
10980
11275
  leaves = new InstancedMesh(geometry.leaves, material, instances);
10981
11276
  leaves.userData.incantoWaterPassSkip = true;
@@ -11040,7 +11335,7 @@ var Tree3D = class Tree3D extends Node3D {
11040
11335
  texture.anisotropy = 4;
11041
11336
  texture.flipY = false;
11042
11337
  if (srgb) texture.colorSpace = SRGBColorSpace;
11043
- });
11338
+ }, void 0, this.noteTextureError);
11044
11339
  }
11045
11340
  /** Per-instance placement + jitter — ONE rng draw order for both paths. */
11046
11341
  scatter(rng, place) {
@@ -11098,7 +11393,7 @@ var Tree3D = class Tree3D extends Node3D {
11098
11393
  * grove color VARIATION lives on the material tint, not the shared map.)
11099
11394
  */
11100
11395
  const sharedTextures = /* @__PURE__ */ new Map();
11101
- function loadSharedTexture(loader, url, configure, onReady) {
11396
+ function loadSharedTexture(loader, url, configure, onReady, onError) {
11102
11397
  const cached = sharedTextures.get(url);
11103
11398
  if (cached) return cached;
11104
11399
  const texture = new Texture();
@@ -11107,7 +11402,7 @@ function loadSharedTexture(loader, url, configure, onReady) {
11107
11402
  texture.image = bitmap;
11108
11403
  if (onReady) onReady(texture);
11109
11404
  else texture.needsUpdate = true;
11110
- });
11405
+ }, void 0, (error) => onError?.(url, error));
11111
11406
  sharedTextures.set(url, texture);
11112
11407
  return texture;
11113
11408
  }
@@ -12169,6 +12464,25 @@ var VoxelGrid3D = class extends Node3D {
12169
12464
  this.mesh.count = i;
12170
12465
  this.mesh.instanceMatrix.needsUpdate = true;
12171
12466
  }
12467
+ /**
12468
+ * Hand the GPU back.
12469
+ *
12470
+ * Every other node that builds geometry disposes it here; these four did not,
12471
+ * so a scene swap left their buffers and materials resident and a game with
12472
+ * level transitions climbed until the tab died — with `stats().geometries` the
12473
+ * only witness, and only if someone was watching it.
12474
+ */
12475
+ free() {
12476
+ const object = this._builtObject3D;
12477
+ if (object) object.traverse((child) => {
12478
+ const mesh = child;
12479
+ mesh.geometry?.dispose();
12480
+ const material = mesh.material;
12481
+ const list = Array.isArray(material) ? material : material ? [material] : [];
12482
+ for (const m of list) m.dispose?.();
12483
+ });
12484
+ super.free();
12485
+ }
12172
12486
  };
12173
12487
  const FACE_ATTRS = [
12174
12488
  "colorFront",
@@ -12225,6 +12539,7 @@ function registerNodes3D() {
12225
12539
  registerNode(MeshInstance3D);
12226
12540
  registerNode(LoftMesh3D);
12227
12541
  registerNode(Sprite3D);
12542
+ registerNode(Label3D);
12228
12543
  registerNode(AnimatedSprite3D);
12229
12544
  registerNode(Billboard3D);
12230
12545
  registerNode(CharacterController3D);
@@ -5242,6 +5242,16 @@ var Cooldown = class {
5242
5242
  }
5243
5243
  };
5244
5244
  const SHAKE_FALLOFF_SECONDS = .28;
5245
+ /** File the effect with the engine, if this one is attached to a running game. */
5246
+ function recordEffect(engine, kind, name, from, amount) {
5247
+ engine?.effects.record({
5248
+ kind,
5249
+ name,
5250
+ from,
5251
+ amount,
5252
+ at: engine.time
5253
+ });
5254
+ }
5245
5255
  /**
5246
5256
  * Camera shake as a standalone behavior for cameras WITHOUT another script.
5247
5257
  * (FollowCamera has this built in — one script per node.) Composes with any
@@ -5273,6 +5283,7 @@ var CameraShake = class extends Behavior {
5273
5283
  if (this.engine?.settings.get("reduceMotion") === true) return;
5274
5284
  this.magnitude = Math.max(this.magnitude, magnitude);
5275
5285
  this.t = seconds ?? this.falloff;
5286
+ recordEffect(this.engine, "shake", "", this.node?.getPath() ?? "engine", magnitude);
5276
5287
  }
5277
5288
  update(dt) {
5278
5289
  const node = requirePosition(this);
@@ -5300,9 +5311,15 @@ var CameraShake = class extends Behavior {
5300
5311
  };
5301
5312
  /**
5302
5313
  * Full-screen color flash (damage red, pickup white). DOM overlay above
5303
- * everything; headless no-op. Repeated calls restart the fade.
5314
+ * everything; headless it draws nothing but still REPORTS, so a damage flash
5315
+ * can be checked in a run with no screen (`engine.effects.countOf('#ff0000')`).
5316
+ *
5317
+ * The engine is the first argument because it is the only juice primitive that
5318
+ * did not take one — which is also why nothing in the repo called it, and why a
5319
+ * flash was the one piece of feedback no test could see.
5304
5320
  */
5305
- function screenFlash(color = "#ffffff", opacity = .35, seconds = .25) {
5321
+ function screenFlash(engine, color = "#ffffff", opacity = .35, seconds = .25) {
5322
+ recordEffect(engine, "flash", color, "engine", opacity);
5306
5323
  if (typeof document === "undefined") return;
5307
5324
  const id = "incanto-screen-flash";
5308
5325
  let el = document.getElementById(id);
@@ -5327,36 +5344,28 @@ function screenFlash(color = "#ffffff", opacity = .35, seconds = .25) {
5327
5344
  * previous timeScale. Stacking calls extend the freeze instead of fighting.
5328
5345
  */
5329
5346
  function hitStop(engine, seconds = .08) {
5347
+ recordEffect(engine, "hitstop", "", "engine", seconds);
5330
5348
  const state = hitStops.get(engine);
5331
5349
  if (state) {
5332
- state.until = Math.max(state.until, realNow() + seconds * 1e3);
5350
+ state.until = Math.max(state.until, engine.unscaledTime + seconds);
5333
5351
  return;
5334
5352
  }
5335
5353
  const restore = engine.timeScale;
5336
5354
  const entry = {
5337
- until: realNow() + seconds * 1e3,
5338
- restore
5355
+ until: engine.unscaledTime + seconds,
5356
+ restore,
5357
+ release: () => {}
5339
5358
  };
5340
5359
  hitStops.set(engine, entry);
5341
5360
  engine.timeScale = 0;
5342
- const pump = () => {
5343
- if (realNow() >= entry.until) {
5344
- engine.timeScale = entry.restore;
5345
- hitStops.delete(engine);
5346
- return;
5347
- }
5348
- schedule(pump);
5349
- };
5350
- schedule(pump);
5361
+ entry.release = engine.updated.connect(() => {
5362
+ if (engine.unscaledTime < entry.until) return;
5363
+ engine.timeScale = entry.restore;
5364
+ entry.release();
5365
+ hitStops.delete(engine);
5366
+ });
5351
5367
  }
5352
5368
  const hitStops = /* @__PURE__ */ new Map();
5353
- function realNow() {
5354
- return typeof performance !== "undefined" ? performance.now() : Date.now();
5355
- }
5356
- function schedule(fn) {
5357
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn);
5358
- else setTimeout(fn, 16);
5359
- }
5360
5369
  //#endregion
5361
5370
  //#region src/gameplay/lifetime.ts
5362
5371
  /**
@@ -1,4 +1,4 @@
1
- import { Dt as Node, b as Engine, d as PropSchema, n as BehaviorCtor, t as Behavior } from "./behavior-Do0Da56m.js";
1
+ import { At as Node, b as Engine, d as PropSchema, n as BehaviorCtor, t as Behavior } from "./behavior-CyQoSu4n.js";
2
2
  import { c as JsonValue, s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/gameplay/chase.d.ts
@@ -813,9 +813,14 @@ declare class CameraShake extends Behavior {
813
813
  }
814
814
  /**
815
815
  * Full-screen color flash (damage red, pickup white). DOM overlay above
816
- * everything; headless no-op. Repeated calls restart the fade.
816
+ * everything; headless it draws nothing but still REPORTS, so a damage flash
817
+ * can be checked in a run with no screen (`engine.effects.countOf('#ff0000')`).
818
+ *
819
+ * The engine is the first argument because it is the only juice primitive that
820
+ * did not take one — which is also why nothing in the repo called it, and why a
821
+ * flash was the one piece of feedback no test could see.
817
822
  */
818
- declare function screenFlash(color?: string, opacity?: number, seconds?: number): void;
823
+ declare function screenFlash(engine: Engine, color?: string, opacity?: number, seconds?: number): void;
819
824
  /**
820
825
  * Hit-stop: freeze game time for `seconds` of REAL time, then restore the
821
826
  * previous timeScale. Stacking calls extend the freeze instead of fighting.
package/dist/gameplay.js CHANGED
@@ -1,2 +1,2 @@
1
- import { A as Chase, C as restartScene, D as DamageOnContact, E as phaseOf, O as Health, S as goToScene, T as DayNight, _ as Cooldown, a as Wander, b as Interactable, c as SavePoint, d as Patrol, f as PathFollow, g as CameraShake, h as Lifetime, i as WaveSpawner, k as Collector, l as Projectile, m as MoveTo, n as registerGameplayBehaviors, o as Spawner, p as Oscillate, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Pickup, v as hitStop, w as FollowCamera, x as GameFlow, y as screenFlash } from "./gameplay-CRYw_Q-T.js";
1
+ import { A as Chase, C as restartScene, D as DamageOnContact, E as phaseOf, O as Health, S as goToScene, T as DayNight, _ as Cooldown, a as Wander, b as Interactable, c as SavePoint, d as Patrol, f as PathFollow, g as CameraShake, h as Lifetime, i as WaveSpawner, k as Collector, l as Projectile, m as MoveTo, n as registerGameplayBehaviors, o as Spawner, p as Oscillate, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Pickup, v as hitStop, w as FollowCamera, x as GameFlow, y as screenFlash } from "./gameplay-CZ2yq37J.js";
2
2
  export { CameraShake, Chase, Collector, Cooldown, DamageOnContact, DayNight, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, SavePoint, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };