incanto 0.4.2 → 0.5.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 (43) hide show
  1. package/dist/2d.d.ts +74 -6
  2. package/dist/2d.js +4 -4
  3. package/dist/3d.d.ts +174 -16
  4. package/dist/3d.js +4 -4
  5. package/dist/{audio-player-DqUR3XFs.d.ts → audio-player-DkBqRTs4.d.ts} +1 -1
  6. package/dist/{behavior-BAQq7HGM.d.ts → behavior-CWhW3oa6.d.ts} +54 -0
  7. package/dist/{create-game-CZHROKcT.js → create-game-BFESHv1X.js} +37 -25
  8. package/dist/{create-game-CMS7DiPi.js → create-game-D8UvwXme.js} +231 -8
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-DP2WPYom.js → duplicate-BEvGBtb_.js} +1 -1
  11. package/dist/{gameplay-Ccruc3Wd.js → gameplay-CDFgSG6z.js} +293 -24
  12. package/dist/gameplay.d.ts +113 -2
  13. package/dist/gameplay.js +2 -2
  14. package/dist/index.d.ts +139 -5
  15. package/dist/index.js +59 -6
  16. package/dist/{loader-CGs_G-r0.js → loader-B4OEXDZ8.js} +60 -0
  17. package/dist/{loader-Mo0KghCv.d.ts → loader-D2u0fVW2.d.ts} +1 -1
  18. package/dist/net.d.ts +1 -1
  19. package/dist/net.js +1 -1
  20. package/dist/{particle-sim-DYuSUxvK.js → particle-sim-CFkILGwh.js} +84 -3
  21. package/dist/{particle-sim-CbN4YUuH.d.ts → particle-sim-CwJ5rI_P.d.ts} +3 -0
  22. package/dist/{physics-2d-KuMWPTf6.js → physics-2d-KXn1N1jF.js} +95 -10
  23. package/dist/{physics-3d-CXOBOUKG.js → physics-3d-C58oQzTX.js} +121 -43
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-B0gq63VW.js → register-C35HDZpm.js} +2 -2
  27. package/dist/{register-DPEV9_9t.js → register-D71C4rDC.js} +76 -5
  28. package/dist/{register-BuUV1_KB.js → register-Dl_ixIJe.js} +275 -2
  29. package/dist/{register-02aSywx2.js → register-DvPHJdVj.js} +390 -59
  30. package/dist/test.d.ts +2 -2
  31. package/dist/test.js +10 -10
  32. package/editor/assets/{agent8-DUVZGcuO.js → agent8-DruKhR22.js} +1 -1
  33. package/editor/assets/index-D2qufqUo.js +7417 -0
  34. package/editor/index.html +1 -1
  35. package/package.json +1 -1
  36. package/schemas/scene.schema.json +847 -133
  37. package/skills/incanto-3d-models.md +24 -1
  38. package/skills/incanto-building-3d-games.md +35 -3
  39. package/skills/incanto-gameplay-behaviors.md +60 -0
  40. package/skills/incanto-hud.md +58 -0
  41. package/skills/incanto-node-reference.md +113 -0
  42. package/skills/incanto-physics-and-input.md +47 -0
  43. package/editor/assets/index-DsM2Yp9F.js +0 -7365
@@ -1,14 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { _ as registerBehavior, n as loadScene } from "./loader-CGs_G-r0.js";
3
- import { a as Engine } from "./particle-sim-DYuSUxvK.js";
2
+ import { _ as registerBehavior, n as loadScene } from "./loader-B4OEXDZ8.js";
3
+ import { a as Engine } from "./particle-sim-CFkILGwh.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
- import { r as AudioPlayer } from "./register-BuUV1_KB.js";
5
+ import { s as AudioPlayer } from "./register-Dl_ixIJe.js";
6
6
  import { i as resolveRendering, n as attachTouchControls } from "./touch-031PxtCR.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-Ccruc3Wd.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-CDFgSG6z.js";
8
8
  import { t as debugSources } from "./debug-draw-CZmOYjL2.js";
9
- import { E as PhysicsBody3D, c as ModelInstance3D, k as Node3D, t as registerNodes3D, u as DirectionalLight3D, v as Camera3D } from "./register-02aSywx2.js";
10
- import { n as enablePhysics3D } from "./physics-3d-CXOBOUKG.js";
11
- import { ACESFilmicToneMapping, AmbientLight, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
9
+ import { O as PhysicsBody3D, c as ModelInstance3D, d as DirectionalLight3D, j as Node3D, t as registerNodes3D, y as Camera3D } from "./register-DvPHJdVj.js";
10
+ import { n as enablePhysics3D } from "./physics-3d-C58oQzTX.js";
11
+ import { ACESFilmicToneMapping, AmbientLight, 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";
12
12
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
13
13
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
14
14
  import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
@@ -127,6 +127,122 @@ var AssetStore3D = class {
127
127
  }
128
128
  };
129
129
  //#endregion
130
+ //#region src/3d/bloom/bloom-composite.ts
131
+ /**
132
+ * HDR bloom post pass:
133
+ *
134
+ * scene → offscreen HalfFloat target (raw linear HDR — three applies neither
135
+ * tone mapping nor the sRGB transform when rendering into a target)
136
+ * bright-pass at HALF resolution (keep only luminance above `threshold`;
137
+ * with HDR buffers, emissives and sun glints sit above 1.0)
138
+ * separable 9-tap Gaussian blur (H then V) at half res
139
+ * composite: scene + glow × strength, THEN the standard postprocessing tail
140
+ * (ACES tone mapping + linear→sRGB) — skipping that tail is exactly the
141
+ * dark/oversaturated bug the volumetric-clouds pipeline shipped with.
142
+ */
143
+ const VERT = `
144
+ varying vec2 vUv;
145
+ void main() {
146
+ vUv = uv;
147
+ gl_Position = vec4(position.xy, 0.0, 1.0);
148
+ }
149
+ `;
150
+ const BRIGHT_FRAG = `
151
+ precision highp float;
152
+ varying vec2 vUv;
153
+ uniform sampler2D tScene;
154
+ uniform float uThreshold;
155
+ void main() {
156
+ vec3 c = texture2D(tScene, vUv).rgb;
157
+ // HDR luminance knee: the scene buffer is raw linear HDR (tone mapping is
158
+ // applied later, in the composite), so emissives/sun glints sit ABOVE 1.0 —
159
+ // keep only the energy above the threshold, in proportion
160
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
161
+ float k = max(l - uThreshold, 0.0);
162
+ gl_FragColor = vec4(c * (k / max(l, 1e-4)), 1.0);
163
+ }
164
+ `;
165
+ const BLUR_FRAG = `
166
+ precision highp float;
167
+ varying vec2 vUv;
168
+ uniform sampler2D tTex;
169
+ uniform vec2 uDir;
170
+ void main() {
171
+ // 9-tap Gaussian, sigma ~2 texels (same recipe as the cloud blur)
172
+ vec4 sum = texture2D(tTex, vUv) * 0.227027;
173
+ sum += texture2D(tTex, vUv + uDir * 1.3846) * 0.3162162;
174
+ sum += texture2D(tTex, vUv - uDir * 1.3846) * 0.3162162;
175
+ sum += texture2D(tTex, vUv + uDir * 3.2308) * 0.0702703;
176
+ sum += texture2D(tTex, vUv - uDir * 3.2308) * 0.0702703;
177
+ gl_FragColor = sum;
178
+ }
179
+ `;
180
+ const COMPOSITE_FRAG = `
181
+ precision highp float;
182
+ varying vec2 vUv;
183
+ uniform sampler2D tScene;
184
+ uniform sampler2D tBloom;
185
+ uniform float uStrength;
186
+ void main() {
187
+ vec3 base = texture2D(tScene, vUv).rgb;
188
+ vec3 glow = texture2D(tBloom, vUv).rgb;
189
+ gl_FragColor = vec4(base + glow * uStrength, 1.0);
190
+ // three skips BOTH tone mapping and the output colorspace transform when the
191
+ // scene renders into an offscreen target — the buffer holds raw linear HDR.
192
+ // This is the final to-screen pass, so apply the standard postprocessing
193
+ // tail here (ACES then linear→sRGB); without it the frame comes out dark
194
+ // and oversaturated (the volumetric-clouds pipeline bug, root-caused).
195
+ #include <tonemapping_fragment>
196
+ #include <colorspace_fragment>
197
+ }
198
+ `;
199
+ function quad(fragment, uniforms) {
200
+ const material = new ShaderMaterial({
201
+ vertexShader: VERT,
202
+ fragmentShader: fragment,
203
+ depthTest: false,
204
+ depthWrite: false,
205
+ uniforms
206
+ });
207
+ const mesh = new Mesh(new PlaneGeometry(2, 2), material);
208
+ mesh.frustumCulled = false;
209
+ return mesh;
210
+ }
211
+ /** Half-res bright-pass quad — keeps only pixels brighter than the threshold. */
212
+ function createBloomBrightQuad() {
213
+ const mesh = quad(BRIGHT_FRAG, {
214
+ tScene: { value: null },
215
+ uThreshold: { value: 1 }
216
+ });
217
+ return {
218
+ mesh,
219
+ uniforms: mesh.material.uniforms
220
+ };
221
+ }
222
+ /** Separable-blur quad (run twice: uDir = (1/w, 0) then (0, 1/h)). */
223
+ function createBloomBlurQuad() {
224
+ const mesh = quad(BLUR_FRAG, {
225
+ tTex: { value: null },
226
+ uDir: { value: new Vector2() }
227
+ });
228
+ return {
229
+ mesh,
230
+ uniforms: mesh.material.uniforms
231
+ };
232
+ }
233
+ /** Full-res composite quad — adds the blurred glow over the scene. */
234
+ function createBloomCompositeQuad() {
235
+ const mesh = quad(COMPOSITE_FRAG, {
236
+ tScene: { value: null },
237
+ tBloom: { value: null },
238
+ uStrength: { value: .85 }
239
+ });
240
+ return {
241
+ mesh,
242
+ uniforms: mesh.material.uniforms
243
+ };
244
+ }
245
+ //#endregion
130
246
  //#region src/3d/clouds/clouds-composite.ts
131
247
  /**
132
248
  * Volumetric clouds — a raymarched cloud DECK the camera flies through, drawn as
@@ -446,6 +562,7 @@ const CLOUD_KEYS = [
446
562
  "speed",
447
563
  "scale"
448
564
  ];
565
+ const BLOOM_KEYS = ["threshold", "strength"];
449
566
  const SHADOW_KEYS = ["mapSize", "radius"];
450
567
  const SHADOW_MAP_SIZES = [1024, 2048];
451
568
  const DEFAULT_TURBIDITY = 2;
@@ -462,6 +579,7 @@ function parseEnvironment3D(env) {
462
579
  sky: parseSky(env?.sky),
463
580
  fog: parseFog(env?.fog, env?.sky !== void 0),
464
581
  clouds: parseClouds(env?.clouds),
582
+ bloom: parseBloom(env?.bloom),
465
583
  shadows: parseShadows(env?.shadows)
466
584
  };
467
585
  }
@@ -616,6 +734,23 @@ function parseClouds(value) {
616
734
  scale
617
735
  };
618
736
  }
737
+ function parseBloom(value) {
738
+ if (value === void 0) return null;
739
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.bloom must be an object ({ threshold?, strength? }), got ${JSON.stringify(value)}.`, { prop: "bloom" });
740
+ const b = value;
741
+ for (const key of Object.keys(b)) if (!BLOOM_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.bloom has unknown key '${key}'. Valid keys: [${BLOOM_KEYS.join(", ")}].`, {
742
+ prop: "bloom",
743
+ validOptions: BLOOM_KEYS
744
+ });
745
+ const threshold = numberOr(b.threshold, 1, "bloom.threshold");
746
+ if (threshold < 0 || threshold > 8) throw new IncantoError("BAD_FORMAT", `environment.bloom.threshold must be in [0, 8] (linear-HDR luminance; 1 = white), got ${threshold}.`, { prop: "bloom" });
747
+ const strength = numberOr(b.strength, .8, "bloom.strength");
748
+ if (strength < 0) throw new IncantoError("BAD_FORMAT", `environment.bloom.strength must be >= 0, got ${strength}.`, { prop: "bloom" });
749
+ return {
750
+ threshold,
751
+ strength
752
+ };
753
+ }
619
754
  function parseShadows(value) {
620
755
  if (value === void 0) return null;
621
756
  if (value === false) return false;
@@ -730,6 +865,10 @@ var Environment3D = class {
730
865
  get clouds() {
731
866
  return this.config.clouds;
732
867
  }
868
+ /** Parsed bloom config, or null when no bloom pass is declared. */
869
+ get bloom() {
870
+ return this.config.bloom;
871
+ }
733
872
  /** The live scene fog (color/near/far) the cloud composite fades into, or null. */
734
873
  get sceneFog() {
735
874
  return this.scene.fog instanceof Fog ? this.scene.fog : null;
@@ -1228,6 +1367,15 @@ var Renderer3D = class {
1228
1367
  cloudsBlurUniforms = null;
1229
1368
  cloudsCompositeScene = null;
1230
1369
  cloudsCompositeUniforms = null;
1370
+ bloomTarget = null;
1371
+ bloomBrightTarget = null;
1372
+ bloomBlurTarget = null;
1373
+ bloomBrightScene = null;
1374
+ bloomBrightUniforms = null;
1375
+ bloomBlurScene = null;
1376
+ bloomBlurUniforms = null;
1377
+ bloomCompositeScene = null;
1378
+ bloomCompositeUniforms = null;
1231
1379
  constructor(opts) {
1232
1380
  this.canvas = opts.canvas;
1233
1381
  this.engine = opts.engine;
@@ -1334,11 +1482,80 @@ var Renderer3D = class {
1334
1482
  }
1335
1483
  this.environment.applyUnderwater(underwater);
1336
1484
  const clouds = this.environment.clouds;
1485
+ const bloom = this.environment.bloom;
1337
1486
  if (underwater?.caustics.enabled) this.renderWithCaustics(activeCamera, underwater);
1338
1487
  else if (clouds && !underwater) this.renderWithClouds(activeCamera, clouds, threeLight);
1488
+ else if (bloom && !underwater) this.renderWithBloom(activeCamera, bloom);
1339
1489
  else this.webgl.render(this.threeScene, activeCamera);
1340
1490
  }
1341
1491
  /**
1492
+ * Bloom post pass (only when `environment.bloom` is declared). LDR and safe:
1493
+ * 1. scene → full-res offscreen (tone-mapped exactly like direct output)
1494
+ * 2. bright-pass at HALF res (keep pixels above threshold)
1495
+ * 3. separable Gaussian blur (H, V) at half res
1496
+ * 4. composite scene + glow×strength → screen (pure ADD — never re-touches
1497
+ * the scene's tone; the clouds pipeline taught us that lesson)
1498
+ * Caustics/clouds paths take precedence (composite pipelines don't stack yet).
1499
+ */
1500
+ renderWithBloom(camera, bloom) {
1501
+ const buf = this.webgl.getDrawingBufferSize(sizeScratch);
1502
+ const w = Math.max(1, buf.x);
1503
+ const h = Math.max(1, buf.y);
1504
+ const hw = Math.max(1, Math.ceil(w / 2));
1505
+ const hh = Math.max(1, Math.ceil(h / 2));
1506
+ if (this.bloomTarget && (this.bloomTarget.width !== w || this.bloomTarget.height !== h)) {
1507
+ this.bloomTarget.dispose();
1508
+ this.bloomTarget = null;
1509
+ this.bloomBrightTarget?.dispose();
1510
+ this.bloomBrightTarget = null;
1511
+ this.bloomBlurTarget?.dispose();
1512
+ this.bloomBlurTarget = null;
1513
+ }
1514
+ if (!this.bloomTarget) {
1515
+ this.bloomTarget = new WebGLRenderTarget(w, h, { type: HalfFloatType });
1516
+ this.bloomBrightTarget = new WebGLRenderTarget(hw, hh, { type: HalfFloatType });
1517
+ this.bloomBlurTarget = new WebGLRenderTarget(hw, hh, { type: HalfFloatType });
1518
+ }
1519
+ if (!this.bloomBrightScene) {
1520
+ const bright = createBloomBrightQuad();
1521
+ this.bloomBrightScene = new Scene();
1522
+ this.bloomBrightScene.add(bright.mesh);
1523
+ this.bloomBrightUniforms = bright.uniforms;
1524
+ const blur = createBloomBlurQuad();
1525
+ this.bloomBlurScene = new Scene();
1526
+ this.bloomBlurScene.add(blur.mesh);
1527
+ this.bloomBlurUniforms = blur.uniforms;
1528
+ const composite = createBloomCompositeQuad();
1529
+ this.bloomCompositeScene = new Scene();
1530
+ this.bloomCompositeScene.add(composite.mesh);
1531
+ this.bloomCompositeUniforms = composite.uniforms;
1532
+ }
1533
+ const brightTarget = this.bloomBrightTarget;
1534
+ const blurTarget = this.bloomBlurTarget;
1535
+ this.webgl.setRenderTarget(this.bloomTarget);
1536
+ this.webgl.render(this.threeScene, camera);
1537
+ const bu = this.bloomBrightUniforms;
1538
+ bu.tScene.value = this.bloomTarget.texture;
1539
+ bu.uThreshold.value = bloom.threshold;
1540
+ this.webgl.setRenderTarget(brightTarget);
1541
+ this.webgl.render(this.bloomBrightScene, camera);
1542
+ const blu = this.bloomBlurUniforms;
1543
+ blu.tTex.value = brightTarget.texture;
1544
+ blu.uDir.value.set(1 / hw, 0);
1545
+ this.webgl.setRenderTarget(blurTarget);
1546
+ this.webgl.render(this.bloomBlurScene, camera);
1547
+ blu.tTex.value = blurTarget.texture;
1548
+ blu.uDir.value.set(0, 1 / hh);
1549
+ this.webgl.setRenderTarget(brightTarget);
1550
+ this.webgl.render(this.bloomBlurScene, camera);
1551
+ const cu = this.bloomCompositeUniforms;
1552
+ cu.tScene.value = this.bloomTarget.texture;
1553
+ cu.tBloom.value = brightTarget.texture;
1554
+ cu.uStrength.value = bloom.strength;
1555
+ this.webgl.setRenderTarget(null);
1556
+ this.webgl.render(this.bloomCompositeScene, camera);
1557
+ }
1558
+ /**
1342
1559
  * Volumetric cloud pass (only when `environment.clouds` is declared — scenes
1343
1560
  * without it render exactly as before, zero cost). Three steps:
1344
1561
  * 1. scene → full-res offscreen target (color + depth)
@@ -1564,10 +1781,16 @@ var Renderer3D = class {
1564
1781
  this.cloudsTarget?.dispose();
1565
1782
  this.cloudsHalfTarget?.dispose();
1566
1783
  this.cloudsBlurTarget?.dispose();
1784
+ this.bloomTarget?.dispose();
1785
+ this.bloomBrightTarget?.dispose();
1786
+ this.bloomBlurTarget?.dispose();
1567
1787
  for (const s of [
1568
1788
  this.cloudsScene,
1569
1789
  this.cloudsBlurScene,
1570
- this.cloudsCompositeScene
1790
+ this.cloudsCompositeScene,
1791
+ this.bloomBrightScene,
1792
+ this.bloomBlurScene,
1793
+ this.bloomCompositeScene
1571
1794
  ]) {
1572
1795
  const m = s?.children[0];
1573
1796
  m?.geometry?.dispose();
package/dist/debug.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as RendererStats, k as LogLevel, v as Engine } from "./behavior-BAQq7HGM.js";
1
+ import { C as RendererStats, k as LogLevel, v as Engine } from "./behavior-CWhW3oa6.js";
2
2
 
3
3
  //#region src/debug/panel.d.ts
4
4
  /** Minimal document surface the overlay needs (injectable for tests). */
@@ -1,4 +1,4 @@
1
- import { i as serializeNode, t as buildNodeJson } from "./loader-CGs_G-r0.js";
1
+ import { i as serializeNode, t as buildNodeJson } from "./loader-B4OEXDZ8.js";
2
2
  //#region src/core/scene/duplicate.ts
3
3
  function stripUids(json) {
4
4
  const { uid: _uid, ...rest } = json;
@@ -1,6 +1,7 @@
1
- import { _ as registerBehavior, m as Behavior } from "./loader-CGs_G-r0.js";
1
+ import { _ as registerBehavior, m as Behavior, n as loadScene } from "./loader-B4OEXDZ8.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
- import { t as duplicateNode } from "./duplicate-DP2WPYom.js";
3
+ import { t as jsonClone } from "./json-BLk7H2Qa.js";
4
+ import { t as duplicateNode } from "./duplicate-BEvGBtb_.js";
4
5
  //#region src/gameplay/spatial.ts
5
6
  /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
6
7
  function hasPosition$1(node) {
@@ -36,13 +37,6 @@ function add(a, b) {
36
37
  for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
37
38
  return out;
38
39
  }
39
- /** Component-wise `a - b`. */
40
- function sub(a, b) {
41
- const n = Math.max(a.length, b.length);
42
- const out = new Array(n);
43
- for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) - (b[i] ?? 0);
44
- return out;
45
- }
46
40
  /** Vector length. */
47
41
  function length(v) {
48
42
  let sum = 0;
@@ -60,15 +54,28 @@ function normalize(v) {
60
54
  * whether the destination was reached this step (clamped — no overshoot).
61
55
  */
62
56
  function moveToward(from, to, maxStep) {
63
- const delta = sub(to, from);
64
- const dist = length(delta);
65
- if (dist <= maxStep || dist === 0) return {
66
- position: [...to],
67
- reached: true
68
- };
57
+ const n = Math.max(from.length, to.length);
58
+ let sum = 0;
59
+ for (let i = 0; i < n; i++) {
60
+ const d = (to[i] ?? 0) - (from[i] ?? 0);
61
+ sum += d * d;
62
+ }
63
+ const dist = Math.sqrt(sum);
64
+ const position = new Array(n);
65
+ if (dist <= maxStep || dist === 0) {
66
+ for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
67
+ return {
68
+ position,
69
+ reached: true
70
+ };
71
+ }
69
72
  const f = maxStep / dist;
73
+ for (let i = 0; i < n; i++) {
74
+ const c = from[i] ?? 0;
75
+ position[i] = c + ((to[i] ?? 0) - c) * f;
76
+ }
70
77
  return {
71
- position: from.map((c, i) => c + (delta[i] ?? 0) * f),
78
+ position,
72
79
  reached: false
73
80
  };
74
81
  }
@@ -427,19 +434,128 @@ var FollowCamera = class extends Behavior {
427
434
  if (this.deadzone < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "deadzone" must be >= 0, got ${this.deadzone}.`, { prop: "deadzone" });
428
435
  requirePosition(this);
429
436
  }
437
+ shakeMag = 0;
438
+ shakeT = 0;
439
+ shakeFalloff = .28;
440
+ /** Kick the camera (impacts, explosions). Composes with the follow. */
441
+ shake(magnitude, seconds = .28) {
442
+ this.shakeMag = Math.max(this.shakeMag, magnitude);
443
+ this.shakeT = seconds;
444
+ this.shakeFalloff = seconds;
445
+ }
430
446
  update(dt) {
431
447
  const cam = this.node;
432
448
  if (!hasPosition$1(cam)) return;
433
449
  const target = cam.getNodeOrNull(this.target);
434
450
  if (!target || !hasPosition$1(target)) return;
435
451
  const desired = add(target.position, this.offset);
436
- if (this.deadzone > 0 && distance$1(cam.position, desired) <= this.deadzone) return;
437
- const f = smoothingFactor(this.smoothing, dt);
438
- if (f >= 1) {
439
- cam.position = [...desired];
440
- return;
452
+ if (!(this.deadzone > 0 && distance$1(cam.position, desired) <= this.deadzone)) {
453
+ const f = smoothingFactor(this.smoothing, dt);
454
+ if (f >= 1) cam.position = [...desired];
455
+ else cam.position = cam.position.map((c, i) => c + ((desired[i] ?? c) - c) * f);
456
+ }
457
+ if (this.shakeT > 0) {
458
+ this.shakeT -= dt;
459
+ const k = this.shakeMag * Math.max(0, this.shakeT) / this.shakeFalloff;
460
+ if (this.shakeT <= 0) this.shakeMag = 0;
461
+ const jolted = cam.position.slice();
462
+ for (let i = 0; i < jolted.length; i++) {
463
+ const scale = jolted.length >= 3 && i === 1 ? .5 : 1;
464
+ jolted[i] = (jolted[i] ?? 0) + (this.engine.rng.next() * 2 - 1) * k * scale;
465
+ }
466
+ cam.position = jolted;
441
467
  }
442
- cam.position = cam.position.map((c, i) => c + ((desired[i] ?? c) - c) * f);
468
+ }
469
+ };
470
+ //#endregion
471
+ //#region src/gameplay/game-flow.ts
472
+ /**
473
+ * Reload the CURRENT scene from its source JSON — fresh nodes, reset physics,
474
+ * rewired input. The restart primitive every game-over screen wants.
475
+ */
476
+ function restartScene(engine) {
477
+ const source = engine.scene?.source;
478
+ if (!source) return;
479
+ engine.timeScale = 1;
480
+ engine.setScene(loadScene(jsonClone(source)));
481
+ }
482
+ /**
483
+ * The win/lose/restart state machine 6+ examples hand-rolled as `over`/`win`
484
+ * booleans. Attach to any node (the scene root is natural):
485
+ *
486
+ * const flow = root.behavior as GameFlow; // script: { "name": "GameFlow" }
487
+ * flow.gameOver('YOU DIED'); // freezes time, sticky banner
488
+ * flow.win('AREA CLEAR');
489
+ * flow.pause(); flow.resume();
490
+ *
491
+ * While in `gameover`/`won`, pressing `restartAction` (default action name
492
+ * 'restart' — declare it in the scene input map, or leave undeclared and call
493
+ * `flow.restart()` yourself) reloads the scene from source. Banners render
494
+ * through a `%Banner` UiBanner when one exists; otherwise states are silent
495
+ * (drive your own UI off the `flowChanged` signal).
496
+ */
497
+ var GameFlow = class extends Behavior {
498
+ static signals = ["flowChanged"];
499
+ static props = {
500
+ restartAction: { default: "restart" },
501
+ freezeOnEnd: { default: true },
502
+ bannerPath: { default: "%Banner" }
503
+ };
504
+ /** Input action that restarts from gameover/won (declare it in `input{}`). */
505
+ restartAction = "restart";
506
+ /** Freeze `engine.timeScale` on gameover/won (banner UI keeps rendering). */
507
+ freezeOnEnd = true;
508
+ /** Where the flow looks for a UiBanner ('' = never). */
509
+ bannerPath = "%Banner";
510
+ state = "playing";
511
+ frozeScale = false;
512
+ gameOver(text = "GAME OVER", color = "#ef4444") {
513
+ this.transition("gameover", text, color);
514
+ }
515
+ win(text = "YOU WIN", color = "#4ade80") {
516
+ this.transition("won", text, color);
517
+ }
518
+ pause() {
519
+ if (this.state !== "playing") return;
520
+ this.state = "paused";
521
+ this.freeze();
522
+ this.node.emit("flowChanged", this.state);
523
+ }
524
+ resume() {
525
+ if (this.state !== "paused") return;
526
+ this.state = "playing";
527
+ this.thaw();
528
+ this.node.emit("flowChanged", this.state);
529
+ }
530
+ restart() {
531
+ this.thaw();
532
+ restartScene(this.engine);
533
+ }
534
+ transition(state, text, color) {
535
+ if (this.state === "gameover" || this.state === "won") return;
536
+ this.state = state;
537
+ if (this.freezeOnEnd) this.freeze();
538
+ if (this.bannerPath !== "") this.node.getNodeOrNull(this.bannerPath)?.show(text, {
539
+ color,
540
+ seconds: 0
541
+ });
542
+ this.node.emit("flowChanged", state);
543
+ }
544
+ freeze() {
545
+ if (this.frozeScale) return;
546
+ this.frozeScale = true;
547
+ this.engine.timeScale = 0;
548
+ }
549
+ thaw() {
550
+ if (!this.frozeScale) return;
551
+ this.frozeScale = false;
552
+ this.engine.timeScale = 1;
553
+ }
554
+ update() {
555
+ if (this.state !== "gameover" && this.state !== "won") return;
556
+ try {
557
+ if (this.engine.input.justPressed(this.restartAction)) this.restart();
558
+ } catch {}
443
559
  }
444
560
  };
445
561
  //#endregion
@@ -508,6 +624,153 @@ function collectGroup(node, group) {
508
624
  return out;
509
625
  }
510
626
  //#endregion
627
+ //#region src/gameplay/juice.ts
628
+ /**
629
+ * Game-feel primitives ("juice"): the weapon cooldown, camera shake, screen
630
+ * flash and hit-stop that action examples kept hand-rolling.
631
+ */
632
+ /**
633
+ * A fire-rate / ability cooldown — replaces the copy-pasted
634
+ * `this.clock += dt * 1000; if (now > nextFire)` pattern:
635
+ *
636
+ * private gun = new Cooldown(0.2);
637
+ * override update(dt: number): void {
638
+ * this.gun.tick(dt);
639
+ * if (this.engine.input.isPressed('fire') && this.gun.tryUse()) this.shoot();
640
+ * }
641
+ */
642
+ var Cooldown = class {
643
+ seconds;
644
+ remaining = 0;
645
+ constructor(seconds) {
646
+ this.seconds = seconds;
647
+ }
648
+ /** Advance time. Call once per update with the frame dt. */
649
+ tick(dt) {
650
+ if (this.remaining > 0) this.remaining -= dt;
651
+ }
652
+ get ready() {
653
+ return this.remaining <= 0;
654
+ }
655
+ /** 0..1 — how far through the cooldown we are (1 = ready). UiBar-friendly. */
656
+ get progress() {
657
+ return this.seconds <= 0 ? 1 : Math.min(1, Math.max(0, 1 - this.remaining / this.seconds));
658
+ }
659
+ /** Consume if ready. Returns whether the action should fire. */
660
+ tryUse() {
661
+ if (this.remaining > 0) return false;
662
+ this.remaining = this.seconds;
663
+ return true;
664
+ }
665
+ /** Force-ready (pickups that reset your reload). */
666
+ reset() {
667
+ this.remaining = 0;
668
+ }
669
+ };
670
+ const SHAKE_FALLOFF_SECONDS = .28;
671
+ /**
672
+ * Camera shake as a standalone behavior for cameras WITHOUT another script.
673
+ * (FollowCamera has this built in — one script per node.) Composes with any
674
+ * other position writer by applying only the DELTA of its own offset, so the
675
+ * camera returns exactly to where the other writer left it.
676
+ *
677
+ * (camera.behavior as CameraShake).shake(8); // pixels (2D) / meters·100 feel (3D: use ~0.2)
678
+ */
679
+ var CameraShake = class extends Behavior {
680
+ static props = { falloff: { default: SHAKE_FALLOFF_SECONDS } };
681
+ /** Seconds a shake takes to decay to zero. */
682
+ falloff = SHAKE_FALLOFF_SECONDS;
683
+ magnitude = 0;
684
+ t = 0;
685
+ prev = [];
686
+ shake(magnitude, seconds) {
687
+ this.magnitude = Math.max(this.magnitude, magnitude);
688
+ this.t = seconds ?? this.falloff;
689
+ }
690
+ update(dt) {
691
+ const node = requirePosition(this);
692
+ const pos = node.position;
693
+ for (let i = 0; i < this.prev.length; i++) pos[i] = (pos[i] ?? 0) - (this.prev[i] ?? 0);
694
+ if (this.t <= 0) {
695
+ if (this.prev.length > 0) {
696
+ node.position = pos.slice();
697
+ this.prev.length = 0;
698
+ }
699
+ return;
700
+ }
701
+ this.t -= dt;
702
+ const k = this.magnitude * Math.max(0, this.t) / this.falloff;
703
+ if (this.t <= 0) this.magnitude = 0;
704
+ const next = pos.slice();
705
+ if (this.prev.length !== next.length) this.prev = new Array(next.length).fill(0);
706
+ for (let i = 0; i < next.length; i++) {
707
+ const scale = next.length >= 3 && i === 1 ? .5 : 1;
708
+ this.prev[i] = (this.engine.rng.next() * 2 - 1) * k * scale;
709
+ next[i] = (next[i] ?? 0) + this.prev[i];
710
+ }
711
+ node.position = next;
712
+ }
713
+ };
714
+ /**
715
+ * Full-screen color flash (damage red, pickup white). DOM overlay above
716
+ * everything; headless no-op. Repeated calls restart the fade.
717
+ */
718
+ function screenFlash(color = "#ffffff", opacity = .35, seconds = .25) {
719
+ if (typeof document === "undefined") return;
720
+ const id = "incanto-screen-flash";
721
+ let el = document.getElementById(id);
722
+ if (!el) {
723
+ el = document.createElement("div");
724
+ el.id = id;
725
+ el.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:0;transition:opacity .05s;";
726
+ document.body.appendChild(el);
727
+ }
728
+ el.style.background = color;
729
+ el.style.transition = "opacity .05s";
730
+ el.style.opacity = String(opacity);
731
+ const fade = () => {
732
+ el.style.transition = `opacity ${seconds}s`;
733
+ el.style.opacity = "0";
734
+ };
735
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(fade);
736
+ else fade();
737
+ }
738
+ /**
739
+ * Hit-stop: freeze game time for `seconds` of REAL time, then restore the
740
+ * previous timeScale. Stacking calls extend the freeze instead of fighting.
741
+ */
742
+ function hitStop(engine, seconds = .08) {
743
+ const state = hitStops.get(engine);
744
+ if (state) {
745
+ state.until = Math.max(state.until, realNow() + seconds * 1e3);
746
+ return;
747
+ }
748
+ const restore = engine.timeScale;
749
+ const entry = {
750
+ until: realNow() + seconds * 1e3,
751
+ restore
752
+ };
753
+ hitStops.set(engine, entry);
754
+ engine.timeScale = 0;
755
+ const pump = () => {
756
+ if (realNow() >= entry.until) {
757
+ engine.timeScale = entry.restore;
758
+ hitStops.delete(engine);
759
+ return;
760
+ }
761
+ schedule(pump);
762
+ };
763
+ schedule(pump);
764
+ }
765
+ const hitStops = /* @__PURE__ */ new Map();
766
+ function realNow() {
767
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
768
+ }
769
+ function schedule(fn) {
770
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn);
771
+ else setTimeout(fn, 16);
772
+ }
773
+ //#endregion
511
774
  //#region src/gameplay/lifetime.ts
512
775
  /**
513
776
  * Self-destruct after a fixed time — bullets, particles, temporary spawns,
@@ -876,7 +1139,11 @@ var Projectile = class extends Behavior {
876
1139
  update(dt) {
877
1140
  const node = this.node;
878
1141
  if (this.gravity !== 0) this.velocity[1] = (this.velocity[1] ?? 0) + this.gravity * dt;
879
- node.position = add(node.position, this.velocity.map((v) => v * dt));
1142
+ const pos = node.position;
1143
+ const n = Math.max(pos.length, this.velocity.length);
1144
+ const next = new Array(n);
1145
+ for (let i = 0; i < n; i++) next[i] = (pos[i] ?? 0) + (this.velocity[i] ?? 0) * dt;
1146
+ node.position = next;
880
1147
  }
881
1148
  /** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
882
1149
  resolveDirection() {
@@ -1470,6 +1737,8 @@ var ZombieAI = class extends Behavior {
1470
1737
  */
1471
1738
  /** Every built-in gameplay behavior, keyed by its registration name. */
1472
1739
  const GAMEPLAY_BEHAVIORS = {
1740
+ CameraShake,
1741
+ GameFlow,
1473
1742
  Health,
1474
1743
  Lifetime,
1475
1744
  ScoreKeeper,
@@ -1498,4 +1767,4 @@ function registerGameplayBehaviors(opts) {
1498
1767
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
1499
1768
  }
1500
1769
  //#endregion
1501
- export { Health as _, Wander as a, Projectile as c, Oscillate as d, MoveTo as f, DamageOnContact as g, FollowCamera as h, WaveSpawner as i, Pickup as l, Interactable as m, registerGameplayBehaviors as n, Spawner as o, Lifetime as p, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Patrol as u, Collector as v, Chase as y };
1770
+ export { Health as C, DamageOnContact as S, Chase as T, screenFlash as _, Wander as a, restartScene as b, Projectile as c, Oscillate as d, MoveTo as f, hitStop as g, Cooldown as h, WaveSpawner as i, Pickup as l, CameraShake as m, registerGameplayBehaviors as n, Spawner as o, Lifetime as p, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Patrol as u, Interactable as v, Collector as w, FollowCamera as x, GameFlow as y };