incanto 0.28.0 → 0.30.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 (63) hide show
  1. package/README.md +44 -0
  2. package/bin/incanto-editor.mjs +219 -29
  3. package/dist/2d.d.ts +54 -6
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +353 -16
  6. package/dist/3d.js +5 -5
  7. package/dist/{behavior-CPibUfnH.d.ts → behavior-BAc0erXF.d.ts} +21 -0
  8. package/dist/{create-game-71TJjW1T.js → create-game-5z_QVtLx.js} +59 -16
  9. package/dist/{create-game-BRgWpNsa.js → create-game-DuBTv2zI.js} +276 -49
  10. package/dist/debug-draw-BM3DsvtT.js +18 -0
  11. package/dist/debug.d.ts +51 -6
  12. package/dist/debug.js +242 -19
  13. package/dist/editor-switch-B0wB_DSr.d.ts +59 -0
  14. package/dist/editor.d.ts +49 -0
  15. package/dist/editor.js +8554 -0
  16. package/dist/{gameplay-DEG-TP7D.js → gameplay-Cfr6aFZ1.js} +219 -40
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +31 -3
  20. package/dist/index.js +2 -2
  21. package/dist/{loader-DILt9PGC.d.ts → loader-B242FF6N.d.ts} +1 -1
  22. package/dist/net.d.ts +1 -1
  23. package/dist/net.js +2 -2
  24. package/dist/{pathfinding-RWYkNKx9.d.ts → pathfinding-BwD974Ss.d.ts} +1 -1
  25. package/dist/{physics-2d-DiVFFlH3.js → physics-2d-3kOQCtgd.js} +88 -3
  26. package/dist/{physics-3d--y5clE2j.js → physics-3d-CeRH-Ff_.js} +475 -9
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-nObreUQR.js → register-BTg0EM7s.js} +35 -3
  30. package/dist/{register-6R75AC7-.js → register-DJ0SByQg.js} +5550 -5073
  31. package/dist/{register-CvpSUU3O.js → register-DWcWq4QG.js} +22 -2
  32. package/dist/{register-BFFE1Mh1.js → register-MelqEdza.js} +1 -1
  33. package/dist/teardown-ByzfDPyu.js +240 -0
  34. package/dist/test.d.ts +35 -3
  35. package/dist/test.js +104 -10
  36. package/dist/vite.d.ts +139 -1
  37. package/dist/vite.js +355 -4
  38. package/editor/assets/{agent8-CojUfCXN.js → agent8-CAp0i5qn.js} +1 -1
  39. package/editor/assets/debug-BoEYfbqK.js +2 -0
  40. package/editor/assets/index-BO6WU8by.js +10696 -0
  41. package/editor/index.html +3 -157
  42. package/package.json +3 -2
  43. package/schemas/scene.schema.json +102 -4
  44. package/skills/incanto-3d-models.md +38 -0
  45. package/skills/incanto-assets.md +13 -0
  46. package/skills/incanto-building-3d-games.md +83 -6
  47. package/skills/incanto-editor.md +212 -8
  48. package/skills/incanto-environment.md +65 -1
  49. package/skills/incanto-node-reference.md +33 -1
  50. package/skills/incanto-physics-and-input.md +60 -6
  51. package/skills/incanto-verifying-your-game.md +57 -2
  52. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +5 -0
  53. package/templates-app/beacon-isle-3d/package.json +1 -1
  54. package/templates-app/beacon-isle-3d/vite.config.ts +7 -0
  55. package/templates-app/tps-3d/docs/project-3d-rules.md +5 -0
  56. package/templates-app/tps-3d/package.json +1 -1
  57. package/templates-app/tps-3d/vite.config.ts +7 -0
  58. package/templates-app/village-quest-3d/docs/project-3d-rules.md +5 -0
  59. package/templates-app/village-quest-3d/package.json +1 -1
  60. package/templates-app/village-quest-3d/vite.config.ts +7 -0
  61. package/dist/debug-draw-CZmOYjL2.js +0 -13
  62. package/editor/assets/index-D6RQgROR.js +0 -8330
  63. package/editor/assets/index-D8QvwvOm.css +0 -1
@@ -413,6 +413,13 @@ void main() {
413
413
  * Foliage/flowers additionally BELONG out of the depth pass: alpha-cutout
414
414
  * quads depth-write their full rectangle and wind-displaced blades render
415
415
  * unposed, so their depth was always a lie the foam mask had to survive.
416
+ *
417
+ * Individual OBJECTS opt out the same way by setting
418
+ * `userData.incantoWaterPassSkip = true` — for a node that is only PARTLY
419
+ * unfit, like a tree whose trunk is honest geometry but whose leaves are the
420
+ * same alpha-cutout lie (a depth-only override drops both their alphaTest and
421
+ * their wind displacement, so every leaf card stamps a solid unposed
422
+ * rectangle; a canopy became a wall of near-depth over the water behind it).
416
423
  */
417
424
  const SKIPPED_IN_WATER_PASSES = new Set([
418
425
  "Foliage3D",
@@ -430,6 +437,11 @@ function hideForWaterPass(root, out) {
430
437
  if (typeof root?.traverse !== "function") return out;
431
438
  root.traverse((obj) => {
432
439
  if (!obj.visible) return;
440
+ if (obj.userData?.incantoWaterPassSkip === true) {
441
+ obj.visible = false;
442
+ out.push(obj);
443
+ return;
444
+ }
433
445
  const type = (obj.userData?.incantoNode)?.constructor?.typeName;
434
446
  if (type && SKIPPED_IN_WATER_PASSES.has(type)) {
435
447
  obj.visible = false;
@@ -1280,6 +1292,24 @@ float rippleFoam(vec2 p) {
1280
1292
  }
1281
1293
 
1282
1294
  // Enhanced depth-based foam effect calculation
1295
+ /**
1296
+ * Linear scene depth at uv, cross-filtered over the pre-pass' own texels.
1297
+ * The pre-pass runs at HALF resolution: a single tap quantises the shoreline
1298
+ * to half-res steps, and any camera motion — even sub-pixel — reshuffles which
1299
+ * texel wins, so every depth-driven band (foam, contact line) BOILS while you
1300
+ * move. Averaging the 4-neighbour cross moves smoothly instead. The shoreline
1301
+ * fade has filtered this way all along (waterColumnAt callers); the foam and
1302
+ * edge bands were still on one tap each.
1303
+ */
1304
+ float filteredSceneDepth(vec2 uv) {
1305
+ float c = linearizeDepth(texture2D(uDepthTexture, uv).r, uCameraNear, uCameraFar);
1306
+ float l = linearizeDepth(texture2D(uDepthTexture, uv - vec2(uScenePassTexel.x, 0.0)).r, uCameraNear, uCameraFar);
1307
+ float r = linearizeDepth(texture2D(uDepthTexture, uv + vec2(uScenePassTexel.x, 0.0)).r, uCameraNear, uCameraFar);
1308
+ float d = linearizeDepth(texture2D(uDepthTexture, uv - vec2(0.0, uScenePassTexel.y)).r, uCameraNear, uCameraFar);
1309
+ float u = linearizeDepth(texture2D(uDepthTexture, uv + vec2(0.0, uScenePassTexel.y)).r, uCameraNear, uCameraFar);
1310
+ return c * 0.4 + (l + r + d + u) * 0.15;
1311
+ }
1312
+
1283
1313
  float calculateFoam() {
1284
1314
  // incanto v2: foam rides the always-on scene depth pass — both gates
1285
1315
  if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5) return 0.0;
@@ -1287,12 +1317,10 @@ float calculateFoam() {
1287
1317
  // Convert screen coordinates to texture coordinates
1288
1318
  vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
1289
1319
 
1290
- // Sample scene depth value from depth texture
1291
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
1292
1320
  float waterDepth = gl_FragCoord.z;
1293
1321
 
1294
- // Convert to linear depth
1295
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
1322
+ // Convert to linear depth (scene side cross-filtered, see above)
1323
+ float linearSceneDepth = filteredSceneDepth(screenUV);
1296
1324
  float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
1297
1325
 
1298
1326
  // Calculate depth difference (distance between water and other objects)
@@ -1344,10 +1372,9 @@ float calculateEdgeBlending() {
1344
1372
  if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5 || uEdgeIntensity <= 0.0) return 0.0;
1345
1373
 
1346
1374
  vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
1347
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
1348
1375
  float waterDepth = gl_FragCoord.z;
1349
1376
 
1350
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
1377
+ float linearSceneDepth = filteredSceneDepth(screenUV); // cross-filtered — one tap boils
1351
1378
  float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
1352
1379
 
1353
1380
  float depthDiff = abs(linearSceneDepth - linearWaterDepth);
@@ -1379,7 +1406,24 @@ void main() {
1379
1406
  linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
1380
1407
 
1381
1408
  float depthDiff = linearWaterDepth - linearSceneDepth;
1382
- waterViewDepth = max(-depthDiff, 0.0);
1409
+
1410
+ // The depth texture DELIBERATELY holds above-water geometry too (the
1411
+ // depth-completion pass, for cutouts) — so a sample NEARER than this
1412
+ // fragment cannot be a bed, and treating it as "column 0" rendered the
1413
+ // water as clear glass over the bare bed. Behind a tree the half-res
1414
+ // pre-pass smears the canopy across the gaps between its own leaves, and
1415
+ // whole stretches of pond turned to glass — "the water isn't drawn". The
1416
+ // column reads the DEEPEST of the 5-tap neighborhood instead; when even
1417
+ // that is contaminated, depth is UNKNOWN (-1) and the flat-ramp fallback
1418
+ // paints honest murk.
1419
+ float seamL = linearizeDepth(texture2D(uDepthTexture, screenUV - vec2(uScenePassTexel.x, 0.0)).r, uCameraNear, uCameraFar);
1420
+ float seamR = linearizeDepth(texture2D(uDepthTexture, screenUV + vec2(uScenePassTexel.x, 0.0)).r, uCameraNear, uCameraFar);
1421
+ float seamD = linearizeDepth(texture2D(uDepthTexture, screenUV - vec2(0.0, uScenePassTexel.y)).r, uCameraNear, uCameraFar);
1422
+ float seamU = linearizeDepth(texture2D(uDepthTexture, screenUV + vec2(0.0, uScenePassTexel.y)).r, uCameraNear, uCameraFar);
1423
+ float seamFarthest = max(max(seamL, seamR), max(seamD, max(seamU, linearSceneDepth)));
1424
+ if (seamFarthest > linearWaterDepth + 0.02) {
1425
+ waterViewDepth = seamFarthest - linearWaterDepth;
1426
+ }
1383
1427
 
1384
1428
  // waterline dither: on a near-flat beach one half-res depth texel spans a
1385
1429
  // wide screen block at grazing view, so these thresholds cut staircase
@@ -1390,10 +1434,21 @@ void main() {
1390
1434
  float seamJitter =
1391
1435
  (noise(vWorldPosition.xz * 3.1) * 0.65 + noise(vWorldPosition.xz * 8.7) * 0.35 - 0.5) * 0.035;
1392
1436
 
1437
+ // This discard exists for ONE thing: dithering the waterline seam — an
1438
+ // organic jittered edge in the few centimetres where half-res depth
1439
+ // quantisation would carve staircase notches. TRUE occlusion is the
1440
+ // hardware depth test's job (depthTest is on, and anything opaque wrote
1441
+ // full-res depth): a water fragment that reached this shader is, by the
1442
+ // full-res buffer's word, VISIBLE. So a pre-pass sample far in front of it
1443
+ // can only be the half-res lie — a tree canopy smearing near-depth across
1444
+ // the gaps between its own leaves — and acting on it erased the whole pond
1445
+ // when viewed through or past a tree. Only the seam band may discard.
1446
+ bool seamEligible = depthDiff < 0.45;
1447
+
1393
1448
  // 🎯 Two-step approach
1394
1449
  // Step 1: Discard completely overlapping parts
1395
1450
  float hardDiscardThreshold = 0.1 + seamJitter;
1396
- if (depthDiff > hardDiscardThreshold) {
1451
+ if (seamEligible && depthDiff > hardDiscardThreshold) {
1397
1452
  discard; // Completely remove definitively overlapping pixels
1398
1453
  }
1399
1454
 
@@ -1401,7 +1456,7 @@ void main() {
1401
1456
  float softDiscardStart = -0.05 + seamJitter; // Start fade slightly ahead
1402
1457
  float softDiscardEnd = hardDiscardThreshold;
1403
1458
 
1404
- if (depthDiff > softDiscardStart) {
1459
+ if (seamEligible && depthDiff > softDiscardStart) {
1405
1460
  depthMask = 1.0 - smoothstep(softDiscardStart, softDiscardEnd, depthDiff);
1406
1461
  // At border: 0 (fully transparent) ~ 1 (fully opaque)
1407
1462
  }
@@ -2160,18 +2215,22 @@ function validateCollider3D(collider, nodeName) {
2160
2215
  return;
2161
2216
  }
2162
2217
  if (shape === "heightfield") return;
2163
- throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': "shape" must be 'box', 'sphere', 'capsule', 'trimesh', or 'heightfield', got ${JSON.stringify(shape)}.`);
2218
+ if (shape === "auto") return;
2219
+ throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': "shape" must be 'auto', 'box', 'sphere', 'capsule', 'trimesh', or 'heightfield', got ${JSON.stringify(shape)}.`);
2164
2220
  }
2165
2221
  //#endregion
2166
2222
  //#region src/3d/nodes/node-3d.ts
2167
- /**
2168
- * Base of every 3D node: a JSON-prop transform backed by a `THREE.Object3D`.
2169
- *
2170
- * Props stay plain JSON (registry/serializer compatible); the renderer layer
2171
- * pushes them onto the backing object once per frame (`_syncObject3D`).
2172
- * Rotation is in DEGREES in JSON — friendlier for agents and humans.
2173
- */
2174
- var Node3D = class extends Node {
2223
+ /** The first ground-answering node in the tree, searched depth-first. */
2224
+ function firstGroundIn(root) {
2225
+ const probe = root;
2226
+ if (probe.constructor?.typeName === "Terrain3D" && typeof probe.heightAt === "function") return root;
2227
+ for (const child of root.children) {
2228
+ const found = firstGroundIn(child);
2229
+ if (found) return found;
2230
+ }
2231
+ return null;
2232
+ }
2233
+ var Node3D = class Node3D extends Node {
2175
2234
  static typeName = "Node3D";
2176
2235
  static props = {
2177
2236
  position: { default: [
@@ -2202,7 +2261,8 @@ var Node3D = class extends Node {
2202
2261
  "effects",
2203
2262
  "overlay"
2204
2263
  ]
2205
- }
2264
+ },
2265
+ snapToGround: { default: null }
2206
2266
  };
2207
2267
  position = [
2208
2268
  0,
@@ -2236,6 +2296,23 @@ var Node3D = class extends Node {
2236
2296
  renderOrder = 0;
2237
2297
  /** Named draw-order band; `renderOrder` is the fine offset inside it. */
2238
2298
  orderGroup = "default";
2299
+ /**
2300
+ * PUT THIS ON THE GROUND. `true` sets the node's Y from the terrain under it
2301
+ * at load; a number does the same and lifts it by that many metres (a post
2302
+ * whose origin sits at its middle wants half its height).
2303
+ *
2304
+ * Computing a Y by hand is the single most repeated mistake in scene
2305
+ * authoring, and it is not a careless one: the number is right when it is
2306
+ * written and wrong the moment anything reshapes the ground — a river carving
2307
+ * its bed, a basin, a different seed, a generator that mirrors the carve
2308
+ * slightly differently. Every "floating boulder", "buried player" and
2309
+ * "bridge posts in mid-air" in this engine's history is that drift.
2310
+ *
2311
+ * With this the ground is asked at load, in the same frame the terrain is
2312
+ * built, and there is nothing to drift from. `null` (default) leaves `y`
2313
+ * exactly as authored — for anything that genuinely hangs in the air.
2314
+ */
2315
+ snapToGround = null;
2239
2316
  /** What three actually sorts by: band base + renderOrder. */
2240
2317
  get effectiveRenderOrder() {
2241
2318
  return effectiveOrder(this.orderGroup, this.renderOrder);
@@ -2249,6 +2326,56 @@ var Node3D = class extends Node {
2249
2326
  */
2250
2327
  _interpPrev = null;
2251
2328
  _interpCurr = null;
2329
+ onReady() {
2330
+ this.applyGroundSnap();
2331
+ }
2332
+ /**
2333
+ * Resolve {@link snapToGround} against the terrain under this node.
2334
+ *
2335
+ * Ancestor transforms are translation-only here, the same contract heightAt
2336
+ * and the physics heightfield already follow, so the world height converts
2337
+ * back to a local Y by subtracting the parents' offset.
2338
+ */
2339
+ applyGroundSnap() {
2340
+ if (this.snapToGround === null || this.snapToGround === false) return;
2341
+ const lift = this.snapToGround === true ? 0 : this.snapToGround;
2342
+ const terrain = firstGroundIn(this.getRoot());
2343
+ if (!terrain) return;
2344
+ let ox = this.position[0] ?? 0;
2345
+ let oy = 0;
2346
+ let oz = this.position[2] ?? 0;
2347
+ for (let n = this.parent; n; n = n.parent) {
2348
+ if (!(n instanceof Node3D)) continue;
2349
+ const yaw = (n.rotation[1] ?? 0) * Math.PI / 180;
2350
+ if (yaw !== 0) {
2351
+ const c = Math.cos(yaw);
2352
+ const sn = Math.sin(yaw);
2353
+ const rx = ox * c + oz * sn;
2354
+ const rz = -ox * sn + oz * c;
2355
+ ox = rx;
2356
+ oz = rz;
2357
+ }
2358
+ ox += n.position[0] ?? 0;
2359
+ oy += n.position[1] ?? 0;
2360
+ oz += n.position[2] ?? 0;
2361
+ }
2362
+ const worldX = ox;
2363
+ const worldZ = oz;
2364
+ this.position = [
2365
+ this.position[0] ?? 0,
2366
+ terrain.heightAt(worldX, worldZ) + lift - oy,
2367
+ this.position[2] ?? 0
2368
+ ];
2369
+ }
2370
+ /**
2371
+ * @internal The backing object IF it has been built — never builds one.
2372
+ *
2373
+ * `_ensureObject3D()` is the wrong call during teardown: it allocates a mesh
2374
+ * for a node that never rendered, purely so the next line can dispose it.
2375
+ */
2376
+ get _builtObject3D() {
2377
+ return this._object3D;
2378
+ }
2252
2379
  /** @internal The backing three object (lazily created). */
2253
2380
  _ensureObject3D() {
2254
2381
  if (!this._object3D) {
@@ -2291,7 +2418,38 @@ var Node3D = class extends Node {
2291
2418
  * `{shape:'capsule', radius, height}` (meters, y-up).
2292
2419
  */
2293
2420
  var PhysicsBody3D = class extends Node3D {
2294
- static props = { collider: { default: {} } };
2421
+ static props = { collider: {
2422
+ default: {},
2423
+ variants: {
2424
+ tag: "shape",
2425
+ byTag: {
2426
+ auto: { shape: "auto" },
2427
+ box: {
2428
+ shape: "box",
2429
+ size: [
2430
+ 1,
2431
+ 1,
2432
+ 1
2433
+ ]
2434
+ },
2435
+ sphere: {
2436
+ shape: "sphere",
2437
+ radius: .5
2438
+ },
2439
+ capsule: {
2440
+ shape: "capsule",
2441
+ radius: .35,
2442
+ height: 1
2443
+ },
2444
+ trimesh: {
2445
+ shape: "trimesh",
2446
+ vertices: [],
2447
+ indices: []
2448
+ },
2449
+ heightfield: { shape: "heightfield" }
2450
+ }
2451
+ }
2452
+ } };
2295
2453
  /** The unified collision model: every collider participant can emit these. */
2296
2454
  static signals = ["triggerEnter", "triggerExit"];
2297
2455
  _collider = {};
@@ -2561,24 +2719,24 @@ const WATER_PRESETS = {
2561
2719
  }
2562
2720
  },
2563
2721
  pond: {
2564
- waveHeight: .012,
2722
+ waveHeight: .006,
2565
2723
  swell: 0,
2566
2724
  whitecaps: 0,
2567
- absorption: .55,
2568
- opacity: .88,
2569
- detailStrength: .2,
2570
- sunIntensity: .9,
2725
+ absorption: .85,
2726
+ opacity: .93,
2727
+ detailStrength: .14,
2728
+ sunIntensity: .7,
2571
2729
  colors: {
2572
- trough: "#24402a",
2573
- surface: "#436b4d",
2574
- peak: "#8fae8e"
2730
+ trough: "#1c2e1e",
2731
+ surface: "#39543c",
2732
+ peak: "#6f8a68"
2575
2733
  },
2576
- color: "#436b4d",
2734
+ color: "#39543c",
2577
2735
  causticsAbove: 0,
2578
2736
  reflectionInterval: 800,
2579
2737
  _look: {
2580
- fresnelScale: 1.1,
2581
- reflectivityMax: .68,
2738
+ fresnelScale: 1.05,
2739
+ reflectivityMax: .78,
2582
2740
  edgeFade: false
2583
2741
  }
2584
2742
  }
@@ -2731,6 +2889,7 @@ var Water3D = class Water3D extends Node3D {
2731
2889
  mirrorInterval: { default: 33 },
2732
2890
  swellSteepness: { default: .35 },
2733
2891
  foam: { default: true },
2892
+ reflectivity: { default: null },
2734
2893
  interaction: { default: true },
2735
2894
  splash: { default: true },
2736
2895
  sunDirection: { default: [
@@ -2790,6 +2949,16 @@ var Water3D = class Water3D extends Node3D {
2790
2949
  reflectionInterval = 1e3;
2791
2950
  /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
2792
2951
  foam = true;
2952
+ /**
2953
+ * How much sky the surface throws back at grazing angles, 0..1 — `null`
2954
+ * keeps the preset's own ceiling.
2955
+ *
2956
+ * Turn it DOWN when the sky is blown out: a bright overcast reflected at 0.78
2957
+ * is a sheet of white, and a pool doing that next to a River3D (which paints
2958
+ * an AUTHORED sky palette and so never blows out) reads as two unrelated
2959
+ * materials meeting at a line. Turn it up for a mirror-calm lake.
2960
+ */
2961
+ reflectivity = null;
2793
2962
  /** Bodies crossing the surface emit signals and raise ripples. */
2794
2963
  interaction = true;
2795
2964
  /** Splash WHITEWATER: the shaders paint expanding surface foam where bodies
@@ -2880,9 +3049,10 @@ var Water3D = class Water3D extends Node3D {
2880
3049
  /** Per-preset internal look constants (fresnel curve, reflection ceiling). */
2881
3050
  presetLook() {
2882
3051
  const look = WATER_PRESETS[this.preset]?._look;
3052
+ const asked = this.rp("reflectivity");
2883
3053
  return {
2884
3054
  fresnelScale: look?.fresnelScale ?? FANCY_LOOK.fresnelScale,
2885
- reflectivityMax: look?.reflectivityMax ?? .6,
3055
+ reflectivityMax: typeof asked === "number" ? Math.min(Math.max(asked, 0), 1) : look?.reflectivityMax ?? .6,
2886
3056
  edgeFade: look?.edgeFade ?? true,
2887
3057
  foamRangeScale: look?.foamRangeScale ?? 1,
2888
3058
  foamIntensity: look?.foamIntensity ?? FOAM_LOOK.intensity
@@ -2962,6 +3132,9 @@ var Water3D = class Water3D extends Node3D {
2962
3132
  mirrorCamera = new PerspectiveCamera();
2963
3133
  mirrorMatrix = new Matrix4();
2964
3134
  lastMirrorAt = 0;
3135
+ /** Camera pose the current mirror image was rendered from (see redraw). */
3136
+ lastMirrorEye = new Vector3(Infinity, Infinity, Infinity);
3137
+ lastMirrorQuat = new Quaternion();
2965
3138
  _createObject3D() {
2966
3139
  return new Mesh();
2967
3140
  }
@@ -3044,7 +3217,8 @@ var Water3D = class Water3D extends Node3D {
3044
3217
  if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
3045
3218
  depthBuffer: true,
3046
3219
  stencilBuffer: false,
3047
- type: HalfFloatType
3220
+ type: HalfFloatType,
3221
+ samples: 4
3048
3222
  });
3049
3223
  const surfaceY = worldTranslationOf(this).y;
3050
3224
  const cam = ctx.camera;
@@ -3065,11 +3239,15 @@ var Water3D = class Water3D extends Node3D {
3065
3239
  mirrorCam.lookAt(mirrorTargetScratch);
3066
3240
  mirrorCam.updateMatrixWorld(true);
3067
3241
  mirrorCam.updateProjectionMatrix();
3068
- this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
3069
- this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
3070
- this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
3071
- if (this.lastMirrorAt === 0 || now - this.lastMirrorAt >= this.rp("mirrorInterval")) {
3242
+ cam.getWorldPosition(mirrorPrevEyeCheck);
3243
+ const moved = mirrorPrevEyeCheck.distanceToSquared(this.lastMirrorEye) > 1e-6 || Math.abs(mirrorQuatScratch.dot(this.lastMirrorQuat)) < .9999995;
3244
+ if (this.lastMirrorAt === 0 || moved || now - this.lastMirrorAt >= this.rp("mirrorInterval")) {
3072
3245
  this.lastMirrorAt = now;
3246
+ this.lastMirrorEye.copy(mirrorPrevEyeCheck);
3247
+ this.lastMirrorQuat.copy(mirrorQuatScratch);
3248
+ this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
3249
+ this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
3250
+ this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
3073
3251
  const prevMirrorTarget = ctx.gl.getRenderTarget();
3074
3252
  const prevMirrorClip = ctx.gl.clippingPlanes;
3075
3253
  const wasMirrorVisible = mesh.visible;
@@ -3172,9 +3350,9 @@ var Water3D = class Water3D extends Node3D {
3172
3350
  } : null;
3173
3351
  }
3174
3352
  free() {
3175
- const mesh = this._ensureObject3D();
3176
- mesh.geometry?.dispose();
3177
- mesh.material?.dispose?.();
3353
+ const mesh = this._builtObject3D;
3354
+ mesh?.geometry?.dispose();
3355
+ (mesh?.material)?.dispose?.();
3178
3356
  this.cubeTarget?.dispose();
3179
3357
  this.cubeTarget = null;
3180
3358
  this.cubeCamera = null;
@@ -3697,6 +3875,7 @@ const UNDERWATER_MARGIN = 3;
3697
3875
  /** Keep-below clip plane for the scene pre-pass (constant set per render):
3698
3876
  * normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
3699
3877
  const mirrorEyeScratch = new Vector3();
3878
+ const mirrorPrevEyeCheck = new Vector3();
3700
3879
  const mirrorTargetScratch = new Vector3();
3701
3880
  const mirrorUpScratch = new Vector3();
3702
3881
  const mirrorQuatScratch = new Quaternion();
@@ -1,5 +1,5 @@
1
1
  import { c as JsonValue, s as JsonObject } from "./schema-CcoWb32N.js";
2
- import { $ as Node, l as PropSchema, n as BehaviorCtor, t as Behavior, v as Engine } from "./behavior-CPibUfnH.js";
2
+ import { $ as Node, l as PropSchema, n as BehaviorCtor, t as Behavior, v as Engine } from "./behavior-BAc0erXF.js";
3
3
 
4
4
  //#region src/gameplay/chase.d.ts
5
5
  /**
package/dist/gameplay.js CHANGED
@@ -1,2 +1,2 @@
1
- import { C as FollowCamera, D as Health, E as DamageOnContact, O as Collector, S as restartScene, T as phaseOf, _ as hitStop, a as Wander, b as GameFlow, c as Projectile, d as PathFollow, f as Oscillate, g as Cooldown, h as CameraShake, i as WaveSpawner, k as Chase, l as Pickup, m as Lifetime, n as registerGameplayBehaviors, o as Spawner, p as MoveTo, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Patrol, v as screenFlash, w as DayNight, x as goToScene, y as Interactable } from "./gameplay-DEG-TP7D.js";
1
+ import { C as FollowCamera, D as Health, E as DamageOnContact, O as Collector, S as restartScene, T as phaseOf, _ as hitStop, a as Wander, b as GameFlow, c as Projectile, d as PathFollow, f as Oscillate, g as Cooldown, h as CameraShake, i as WaveSpawner, k as Chase, l as Pickup, m as Lifetime, n as registerGameplayBehaviors, o as Spawner, p as MoveTo, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Patrol, v as screenFlash, w as DayNight, x as goToScene, y as Interactable } from "./gameplay-Cfr6aFZ1.js";
2
2
  export { CameraShake, Chase, Collector, Cooldown, DamageOnContact, DayNight, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { a as Rng, c as JsonValue, d as jsonKind, i as SceneJson, l as jsonClone, n as NodeJson, o as JsonKind, r as SCENE_FORMAT, s as JsonObject, t as ConnectionJson, u as jsonEquals } from "./schema-CcoWb32N.js";
2
- import { $ as Node, A as SfxPlayOptions, B as spatialPan, C as RendererStats, D as LogManager, E as LogLevel, F as ROLLOFF_MODELS, G as SynthOptions, H as SFX_PRESET_NAMES, I as RolloffModel, J as MusicManager, K as synthSfx, L as SpatialParams, M as Voice, N as VoicePreset, O as InputMap, P as Listener, Q as BusName, R as Vec3, S as GameStats, T as LogEntry, U as SfxParams, V as SFX_PRESETS, W as SfxWave, X as PlayMusicOptions, Y as MusicTrack, Z as AudioBuses, _ as registeredTypes, a as registerBehavior, b as Scheduler, c as PropDef, d as createNode, et as NodeLifecycle, f as getNodeSchema, g as registerNode, h as mergeStaticSignals, i as getBehavior, j as isAudioContextAvailable, k as SfxEngine, l as PropSchema, m as getNodeType, n as BehaviorCtor, nt as SignalListener, o as registeredBehaviors, p as getNodeSignals, q as MusicBackend, r as clearBehaviors, rt as SceneTree, s as NodeCtor, t as Behavior, tt as Signal, u as clearRegistry, v as Engine, w as Scene, x as EngineStats, y as EngineOptions, z as spatialGain } from "./behavior-CPibUfnH.js";
3
- import { n as loadScene, t as LoadSceneOptions } from "./loader-DILt9PGC.js";
2
+ import { $ as Node, A as SfxPlayOptions, B as spatialPan, C as RendererStats, D as LogManager, E as LogLevel, F as ROLLOFF_MODELS, G as SynthOptions, H as SFX_PRESET_NAMES, I as RolloffModel, J as MusicManager, K as synthSfx, L as SpatialParams, M as Voice, N as VoicePreset, O as InputMap, P as Listener, Q as BusName, R as Vec3, S as GameStats, T as LogEntry, U as SfxParams, V as SFX_PRESETS, W as SfxWave, X as PlayMusicOptions, Y as MusicTrack, Z as AudioBuses, _ as registeredTypes, a as registerBehavior, b as Scheduler, c as PropDef, d as createNode, et as NodeLifecycle, f as getNodeSchema, g as registerNode, h as mergeStaticSignals, i as getBehavior, j as isAudioContextAvailable, k as SfxEngine, l as PropSchema, m as getNodeType, n as BehaviorCtor, nt as SignalListener, o as registeredBehaviors, p as getNodeSignals, q as MusicBackend, r as clearBehaviors, rt as SceneTree, s as NodeCtor, t as Behavior, tt as Signal, u as clearRegistry, v as Engine, w as Scene, x as EngineStats, y as EngineOptions, z as spatialGain } from "./behavior-BAc0erXF.js";
3
+ import { n as loadScene, t as LoadSceneOptions } from "./loader-B242FF6N.js";
4
4
  import { n as ParticleSimConfig, r as ParticleView, t as ParticleSim } from "./particle-sim-CwJ5rI_P.js";
5
- import { a as AudioElementLike, i as gridFromRows, n as PathGrid, o as AudioPlayer, r as findPath, t as FindPathOptions } from "./pathfinding-RWYkNKx9.js";
5
+ import { a as AudioElementLike, i as gridFromRows, n as PathGrid, o as AudioPlayer, r as findPath, t as FindPathOptions } from "./pathfinding-BwD974Ss.js";
6
6
  import { i as auditScene, n as IncantoErrorCode, r as IncantoErrorDetails, t as IncantoError } from "./errors-1dXlIwoR.js";
7
7
 
8
8
  //#region src/core/audio/crossfade.d.ts
@@ -47,8 +47,24 @@ declare class WebAudioMusicBackend implements MusicBackend {
47
47
  */
48
48
  interface DebugLineSource {
49
49
  readonly dimension: "2d" | "3d";
50
+ /**
51
+ * The engine whose world these lines describe.
52
+ *
53
+ * A page can hold more than one at a time — the editor runs its edit engine
54
+ * and a preview engine side by side — and a renderer must draw ITS world, not
55
+ * whichever source happens to be first in a module-level Set. Without this,
56
+ * one leaked physics instance paints its frozen colliders over everybody.
57
+ */
58
+ readonly engine: object;
50
59
  /** Toggle at runtime: `physics.debugDraw = true`. */
51
60
  debugDraw: boolean;
61
+ /**
62
+ * Draw only the colliders under this node (its subtree, or the nearest body
63
+ * above it when the node itself carries none). Null draws every collider in
64
+ * the world — which on a real map is a terrain grid burying the one shape you
65
+ * meant to look at.
66
+ */
67
+ debugScope: object | null;
52
68
  /** Flat segment vertices (xy pairs in 2D px, xyz triples in 3D meters), or null while off. */
53
69
  debugLines(): Float32Array | null;
54
70
  }
@@ -108,6 +124,18 @@ declare class HudLayer extends Node {
108
124
  _element: HTMLElement | null;
109
125
  private readonly slots;
110
126
  override onEnterTree(): void;
127
+ /**
128
+ * Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
129
+ * the window when nobody claims it (a game — a HUD covers the screen).
130
+ *
131
+ * Re-checked every frame because a scene's tree is BUILT before it is handed
132
+ * to an engine: at onEnterTree there is no engine to ask yet, so the first
133
+ * update is where the answer actually arrives. appendChild moves the element
134
+ * if it already landed somewhere else, so the HUD follows the host across a
135
+ * scene swap into a differently-hosted engine (game → editor) instead of
136
+ * being stranded on document.body over the inspector.
137
+ */
138
+ private _mount;
111
139
  override onExitTree(): void;
112
140
  override update(): void;
113
141
  /** @internal Widgets mount into per-anchor flex columns. */
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as registerBehavior, a as SCENE_FORMAT, c as SceneTree, d as resolveConstants, f as Node, g as getBehavior, h as clearBehaviors, i as serializeNode, l as CONST_REF_KEY, m as Behavior, n as loadScene, o as computeViewport, p as parseNodePath, r as Scene, s as resolveViewport, u as isConstRef, v as registeredBehaviors, y as Signal } from "./loader-BZqOKfI2.js";
2
- import { C as crossfadeGains, S as WebAudioMusicBackend, T as AudioBuses, _ as spatialPan, a as HudLayer, b as synthSfx, c as UiText, d as LogManager, f as InputMap, g as spatialGain, h as ROLLOFF_MODELS, i as UiDialogue, l as AudioPlayer, m as isAudioContextAvailable, n as Timer, o as UiBanner, p as SfxEngine, r as UiButton, s as UiBar, t as registerCoreNodes, u as Engine, v as SFX_PRESETS, w as fadeGain, x as MusicManager, y as SFX_PRESET_NAMES } from "./register-nObreUQR.js";
2
+ import { C as crossfadeGains, S as WebAudioMusicBackend, T as AudioBuses, _ as spatialPan, a as HudLayer, b as synthSfx, c as UiText, d as LogManager, f as InputMap, g as spatialGain, h as ROLLOFF_MODELS, i as UiDialogue, l as AudioPlayer, m as isAudioContextAvailable, n as Timer, o as UiBanner, p as SfxEngine, r as UiButton, s as UiBar, t as registerCoreNodes, u as Engine, v as SFX_PRESETS, w as fadeGain, x as MusicManager, y as SFX_PRESET_NAMES } from "./register-BTg0EM7s.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { t as auditScene } from "./audit-C6rMyict.js";
@@ -295,6 +295,6 @@ function newUid() {
295
295
  //#endregion
296
296
  //#region src/index.ts
297
297
  /** Engine version. Kept in sync with package.json by the release pipeline. */
298
- const VERSION = "0.28.0";
298
+ const VERSION = "0.30.0";
299
299
  //#endregion
300
300
  export { AudioBuses, AudioPlayer, Behavior, CONST_REF_KEY, Engine, HudLayer, IncantoError, InputMap, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, Scene, SceneTree, SfxEngine, Signal, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiText, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, mergeStaticSignals, newUid, parseNodePath, preloadUrls, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveRendering, resolveViewport, serializeNode, spatialGain, spatialPan, startRecording, synthSfx };
@@ -1,5 +1,5 @@
1
1
  import { i as SceneJson } from "./schema-CcoWb32N.js";
2
- import { v as Engine, w as Scene } from "./behavior-CPibUfnH.js";
2
+ import { v as Engine, w as Scene } from "./behavior-BAc0erXF.js";
3
3
 
4
4
  //#region src/core/scene/loader.d.ts
5
5
  interface LoadSceneOptions {
package/dist/net.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CcoWb32N.js";
2
- import { $ as Node, l as PropSchema, tt as Signal, v as Engine } from "./behavior-CPibUfnH.js";
2
+ import { $ as Node, l as PropSchema, tt as Signal, v as Engine } from "./behavior-BAc0erXF.js";
3
3
 
4
4
  //#region src/net/types.d.ts
5
5
  type Unsubscribe = () => void;
package/dist/net.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { n as loadScene } from "./loader-BZqOKfI2.js";
2
- import { u as Engine } from "./register-nObreUQR.js";
2
+ import { u as Engine } from "./register-BTg0EM7s.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { n as createAgent8Server } from "./agent8-CvsfVskX.js";
6
- import { i as applySyncPatch, n as NetworkSpawner, r as NetworkManager, t as registerNodesNet } from "./register-BFFE1Mh1.js";
6
+ import { i as applySyncPatch, n as NetworkSpawner, r as NetworkManager, t as registerNodesNet } from "./register-MelqEdza.js";
7
7
  //#region src/net/loopback.ts
8
8
  /**
9
9
  * In-memory multiplayer hub implementing the SAME kernel contract as
@@ -1,4 +1,4 @@
1
- import { $ as Node, P as Listener, l as PropSchema } from "./behavior-CPibUfnH.js";
1
+ import { $ as Node, P as Listener, l as PropSchema } from "./behavior-BAc0erXF.js";
2
2
 
3
3
  //#region src/core/nodes/audio-player.d.ts
4
4
  /**