incanto 0.28.0 → 0.29.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 (58) hide show
  1. package/README.md +44 -0
  2. package/bin/incanto-editor.mjs +118 -1
  3. package/dist/2d.d.ts +38 -5
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +319 -15
  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-BRgWpNsa.js → create-game-CNKXGfpr.js} +270 -47
  9. package/dist/{create-game-71TJjW1T.js → create-game-CbuLWorm.js} +53 -14
  10. package/dist/debug.d.ts +51 -6
  11. package/dist/debug.js +242 -19
  12. package/dist/editor-switch-B0wB_DSr.d.ts +59 -0
  13. package/dist/editor-switch-CAKlJMIY.js +161 -0
  14. package/dist/editor.d.ts +49 -0
  15. package/dist/editor.js +8439 -0
  16. package/dist/{gameplay-DEG-TP7D.js → gameplay-L05WgWd1.js} +207 -37
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +22 -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-CCVTrKOd.js} +62 -2
  26. package/dist/{physics-3d--y5clE2j.js → physics-3d-BZZLtwJu.js} +439 -8
  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-C44aSduO.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/test.d.ts +35 -3
  34. package/dist/test.js +104 -10
  35. package/dist/vite.d.ts +53 -1
  36. package/dist/vite.js +94 -2
  37. package/editor/assets/{agent8-CojUfCXN.js → agent8-DEVkEa3d.js} +1 -1
  38. package/editor/assets/debug-zGAtpDF0.js +2 -0
  39. package/editor/assets/index-Bx4UtWYY.js +10586 -0
  40. package/editor/index.html +3 -157
  41. package/package.json +3 -2
  42. package/schemas/scene.schema.json +102 -4
  43. package/skills/incanto-3d-models.md +38 -0
  44. package/skills/incanto-assets.md +13 -0
  45. package/skills/incanto-building-3d-games.md +83 -6
  46. package/skills/incanto-editor.md +151 -0
  47. package/skills/incanto-environment.md +65 -1
  48. package/skills/incanto-node-reference.md +33 -1
  49. package/skills/incanto-physics-and-input.md +45 -6
  50. package/skills/incanto-verifying-your-game.md +51 -2
  51. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +5 -0
  52. package/templates-app/beacon-isle-3d/package.json +1 -1
  53. package/templates-app/tps-3d/docs/project-3d-rules.md +5 -0
  54. package/templates-app/tps-3d/package.json +1 -1
  55. package/templates-app/village-quest-3d/docs/project-3d-rules.md +5 -0
  56. package/templates-app/village-quest-3d/package.json +1 -1
  57. package/editor/assets/index-D6RQgROR.js +0 -8330
  58. 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,47 @@ 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
+ }
2252
2370
  /** @internal The backing three object (lazily created). */
2253
2371
  _ensureObject3D() {
2254
2372
  if (!this._object3D) {
@@ -2291,7 +2409,38 @@ var Node3D = class extends Node {
2291
2409
  * `{shape:'capsule', radius, height}` (meters, y-up).
2292
2410
  */
2293
2411
  var PhysicsBody3D = class extends Node3D {
2294
- static props = { collider: { default: {} } };
2412
+ static props = { collider: {
2413
+ default: {},
2414
+ variants: {
2415
+ tag: "shape",
2416
+ byTag: {
2417
+ auto: { shape: "auto" },
2418
+ box: {
2419
+ shape: "box",
2420
+ size: [
2421
+ 1,
2422
+ 1,
2423
+ 1
2424
+ ]
2425
+ },
2426
+ sphere: {
2427
+ shape: "sphere",
2428
+ radius: .5
2429
+ },
2430
+ capsule: {
2431
+ shape: "capsule",
2432
+ radius: .35,
2433
+ height: 1
2434
+ },
2435
+ trimesh: {
2436
+ shape: "trimesh",
2437
+ vertices: [],
2438
+ indices: []
2439
+ },
2440
+ heightfield: { shape: "heightfield" }
2441
+ }
2442
+ }
2443
+ } };
2295
2444
  /** The unified collision model: every collider participant can emit these. */
2296
2445
  static signals = ["triggerEnter", "triggerExit"];
2297
2446
  _collider = {};
@@ -2561,24 +2710,24 @@ const WATER_PRESETS = {
2561
2710
  }
2562
2711
  },
2563
2712
  pond: {
2564
- waveHeight: .012,
2713
+ waveHeight: .006,
2565
2714
  swell: 0,
2566
2715
  whitecaps: 0,
2567
- absorption: .55,
2568
- opacity: .88,
2569
- detailStrength: .2,
2570
- sunIntensity: .9,
2716
+ absorption: .85,
2717
+ opacity: .93,
2718
+ detailStrength: .14,
2719
+ sunIntensity: .7,
2571
2720
  colors: {
2572
- trough: "#24402a",
2573
- surface: "#436b4d",
2574
- peak: "#8fae8e"
2721
+ trough: "#1c2e1e",
2722
+ surface: "#39543c",
2723
+ peak: "#6f8a68"
2575
2724
  },
2576
- color: "#436b4d",
2725
+ color: "#39543c",
2577
2726
  causticsAbove: 0,
2578
2727
  reflectionInterval: 800,
2579
2728
  _look: {
2580
- fresnelScale: 1.1,
2581
- reflectivityMax: .68,
2729
+ fresnelScale: 1.05,
2730
+ reflectivityMax: .78,
2582
2731
  edgeFade: false
2583
2732
  }
2584
2733
  }
@@ -2731,6 +2880,7 @@ var Water3D = class Water3D extends Node3D {
2731
2880
  mirrorInterval: { default: 33 },
2732
2881
  swellSteepness: { default: .35 },
2733
2882
  foam: { default: true },
2883
+ reflectivity: { default: null },
2734
2884
  interaction: { default: true },
2735
2885
  splash: { default: true },
2736
2886
  sunDirection: { default: [
@@ -2790,6 +2940,16 @@ var Water3D = class Water3D extends Node3D {
2790
2940
  reflectionInterval = 1e3;
2791
2941
  /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
2792
2942
  foam = true;
2943
+ /**
2944
+ * How much sky the surface throws back at grazing angles, 0..1 — `null`
2945
+ * keeps the preset's own ceiling.
2946
+ *
2947
+ * Turn it DOWN when the sky is blown out: a bright overcast reflected at 0.78
2948
+ * is a sheet of white, and a pool doing that next to a River3D (which paints
2949
+ * an AUTHORED sky palette and so never blows out) reads as two unrelated
2950
+ * materials meeting at a line. Turn it up for a mirror-calm lake.
2951
+ */
2952
+ reflectivity = null;
2793
2953
  /** Bodies crossing the surface emit signals and raise ripples. */
2794
2954
  interaction = true;
2795
2955
  /** Splash WHITEWATER: the shaders paint expanding surface foam where bodies
@@ -2880,9 +3040,10 @@ var Water3D = class Water3D extends Node3D {
2880
3040
  /** Per-preset internal look constants (fresnel curve, reflection ceiling). */
2881
3041
  presetLook() {
2882
3042
  const look = WATER_PRESETS[this.preset]?._look;
3043
+ const asked = this.rp("reflectivity");
2883
3044
  return {
2884
3045
  fresnelScale: look?.fresnelScale ?? FANCY_LOOK.fresnelScale,
2885
- reflectivityMax: look?.reflectivityMax ?? .6,
3046
+ reflectivityMax: typeof asked === "number" ? Math.min(Math.max(asked, 0), 1) : look?.reflectivityMax ?? .6,
2886
3047
  edgeFade: look?.edgeFade ?? true,
2887
3048
  foamRangeScale: look?.foamRangeScale ?? 1,
2888
3049
  foamIntensity: look?.foamIntensity ?? FOAM_LOOK.intensity
@@ -2962,6 +3123,9 @@ var Water3D = class Water3D extends Node3D {
2962
3123
  mirrorCamera = new PerspectiveCamera();
2963
3124
  mirrorMatrix = new Matrix4();
2964
3125
  lastMirrorAt = 0;
3126
+ /** Camera pose the current mirror image was rendered from (see redraw). */
3127
+ lastMirrorEye = new Vector3(Infinity, Infinity, Infinity);
3128
+ lastMirrorQuat = new Quaternion();
2965
3129
  _createObject3D() {
2966
3130
  return new Mesh();
2967
3131
  }
@@ -3044,7 +3208,8 @@ var Water3D = class Water3D extends Node3D {
3044
3208
  if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
3045
3209
  depthBuffer: true,
3046
3210
  stencilBuffer: false,
3047
- type: HalfFloatType
3211
+ type: HalfFloatType,
3212
+ samples: 4
3048
3213
  });
3049
3214
  const surfaceY = worldTranslationOf(this).y;
3050
3215
  const cam = ctx.camera;
@@ -3065,11 +3230,15 @@ var Water3D = class Water3D extends Node3D {
3065
3230
  mirrorCam.lookAt(mirrorTargetScratch);
3066
3231
  mirrorCam.updateMatrixWorld(true);
3067
3232
  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")) {
3233
+ cam.getWorldPosition(mirrorPrevEyeCheck);
3234
+ const moved = mirrorPrevEyeCheck.distanceToSquared(this.lastMirrorEye) > 1e-6 || Math.abs(mirrorQuatScratch.dot(this.lastMirrorQuat)) < .9999995;
3235
+ if (this.lastMirrorAt === 0 || moved || now - this.lastMirrorAt >= this.rp("mirrorInterval")) {
3072
3236
  this.lastMirrorAt = now;
3237
+ this.lastMirrorEye.copy(mirrorPrevEyeCheck);
3238
+ this.lastMirrorQuat.copy(mirrorQuatScratch);
3239
+ this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
3240
+ this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
3241
+ this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
3073
3242
  const prevMirrorTarget = ctx.gl.getRenderTarget();
3074
3243
  const prevMirrorClip = ctx.gl.clippingPlanes;
3075
3244
  const wasMirrorVisible = mesh.visible;
@@ -3697,6 +3866,7 @@ const UNDERWATER_MARGIN = 3;
3697
3866
  /** Keep-below clip plane for the scene pre-pass (constant set per render):
3698
3867
  * normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
3699
3868
  const mirrorEyeScratch = new Vector3();
3869
+ const mirrorPrevEyeCheck = new Vector3();
3700
3870
  const mirrorTargetScratch = new Vector3();
3701
3871
  const mirrorUpScratch = new Vector3();
3702
3872
  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-L05WgWd1.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
@@ -49,6 +49,13 @@ interface DebugLineSource {
49
49
  readonly dimension: "2d" | "3d";
50
50
  /** Toggle at runtime: `physics.debugDraw = true`. */
51
51
  debugDraw: boolean;
52
+ /**
53
+ * Draw only the colliders under this node (its subtree, or the nearest body
54
+ * above it when the node itself carries none). Null draws every collider in
55
+ * the world — which on a real map is a terrain grid burying the one shape you
56
+ * meant to look at.
57
+ */
58
+ debugScope: object | null;
52
59
  /** Flat segment vertices (xy pairs in 2D px, xyz triples in 3D meters), or null while off. */
53
60
  debugLines(): Float32Array | null;
54
61
  }
@@ -108,6 +115,18 @@ declare class HudLayer extends Node {
108
115
  _element: HTMLElement | null;
109
116
  private readonly slots;
110
117
  override onEnterTree(): void;
118
+ /**
119
+ * Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
120
+ * the window when nobody claims it (a game — a HUD covers the screen).
121
+ *
122
+ * Re-checked every frame because a scene's tree is BUILT before it is handed
123
+ * to an engine: at onEnterTree there is no engine to ask yet, so the first
124
+ * update is where the answer actually arrives. appendChild moves the element
125
+ * if it already landed somewhere else, so the HUD follows the host across a
126
+ * scene swap into a differently-hosted engine (game → editor) instead of
127
+ * being stranded on document.body over the inspector.
128
+ */
129
+ private _mount;
111
130
  override onExitTree(): void;
112
131
  override update(): void;
113
132
  /** @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.29.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
  /**
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
- import { _ as RigidBody2D, b as validateCollider2D, g as PhysicsBody2D, h as CharacterBody2D, m as Area2D, p as Joint2D, y as Node2D } from "./register-CvpSUU3O.js";
3
+ import { _ as RigidBody2D, b as validateCollider2D, g as PhysicsBody2D, h as CharacterBody2D, m as Area2D, p as Joint2D, y as Node2D } from "./register-DWcWq4QG.js";
4
4
  import { n as registerDebugSource } from "./debug-draw-CZmOYjL2.js";
5
5
  //#region src/2d/physics/physics-2d.ts
6
6
  var physics_2d_exports = /* @__PURE__ */ __exportAll({
@@ -28,6 +28,8 @@ var Physics2D = class {
28
28
  engine;
29
29
  /** Render collider outlines in the GAME view (renderers pick this up). */
30
30
  debugDraw = false;
31
+ /** Narrow those outlines to one node's subtree (see DebugLineSource). */
32
+ debugScope = null;
31
33
  dimension = "2d";
32
34
  unregisterDebug;
33
35
  warnedNoCollider = /* @__PURE__ */ new WeakSet();
@@ -157,11 +159,69 @@ var Physics2D = class {
157
159
  /** Rapier debug segments, scaled back to pixels. Null while debugDraw is off. */
158
160
  debugLines() {
159
161
  if (!this.debugDraw) return null;
160
- const raw = this.world.debugRender().vertices;
162
+ const raw = this.debugScope ? this.scopedLines(this.debugScope) : this.world.debugRender().vertices;
161
163
  const px = new Float32Array(raw.length);
162
164
  for (let i = 0; i < raw.length; i++) px[i] = raw[i] * SCALE;
163
165
  return px;
164
166
  }
167
+ /**
168
+ * Only the selected node's colliders (meters — the caller scales to pixels).
169
+ * Rapier draws the whole world or nothing, so a chosen few are outlined from
170
+ * the same shape data the solver holds. 2D colliders are rect/circle/capsule,
171
+ * which is the whole switch.
172
+ */
173
+ scopedLines(node) {
174
+ const out = [];
175
+ const bodies = [];
176
+ const collect = (n) => {
177
+ const entry = this.entries.get(n);
178
+ if (entry) bodies.push(entry.body);
179
+ for (const child of n.children) collect(child);
180
+ };
181
+ collect(node);
182
+ for (let p = node.parent; p && bodies.length === 0; p = p.parent) {
183
+ const entry = this.entries.get(p);
184
+ if (entry) bodies.push(entry.body);
185
+ }
186
+ const RING = 24;
187
+ for (const body of bodies) for (let i = 0; i < body.numColliders(); i++) {
188
+ const collider = body.collider(i);
189
+ if (!collider) continue;
190
+ const t = collider.translation();
191
+ const angle = collider.rotation();
192
+ const cos = Math.cos(angle);
193
+ const sin = Math.sin(angle);
194
+ const put = (x, y) => {
195
+ out.push(t.x + x * cos - y * sin, t.y + x * sin + y * cos);
196
+ };
197
+ const seg = (ax, ay, bx, by) => {
198
+ put(ax, ay);
199
+ put(bx, by);
200
+ };
201
+ const shape = collider.shape;
202
+ if (shape.halfExtents) {
203
+ const { x, y } = shape.halfExtents;
204
+ seg(-x, -y, x, -y);
205
+ seg(x, -y, x, y);
206
+ seg(x, y, -x, y);
207
+ seg(-x, y, -x, -y);
208
+ } else if (shape.radius !== void 0) {
209
+ const r = shape.radius;
210
+ const half = shape.halfHeight ?? 0;
211
+ for (let k = 0; k < RING; k++) {
212
+ const a = k / RING * Math.PI * 2;
213
+ const b = (k + 1) / RING * Math.PI * 2;
214
+ const lift = (v) => v >= 0 ? half : -half;
215
+ seg(Math.cos(a) * r, Math.sin(a) * r + lift(Math.sin(a)), Math.cos(b) * r, Math.sin(b) * r + lift(Math.sin(b)));
216
+ }
217
+ if (half > 0) {
218
+ seg(-r, -half, -r, half);
219
+ seg(r, -half, r, half);
220
+ }
221
+ }
222
+ }
223
+ return new Float32Array(out);
224
+ }
165
225
  /** Create newly-arrived joints; tear down departed ones. */
166
226
  syncJoints(structureChanged) {
167
227
  const R = this.R;