@homie-rocks/studio 0.44.1 → 0.44.3

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.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,26 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.44.3] - 2026-10-10
21
+
22
+ **Plugin 0.44.3**
23
+
24
+ Solid terrain and indexed map queries keep large arenas within the rules budget.
25
+
26
+ ### Added
27
+
28
+ - Solid triangulated height tiles: explicit bases and source diagonals preserve ramp sides, roofs and underpasses across movement, support, overlap and rays.
29
+ - Build-time spatial indexing for up to 100,000 static shapes, with query work charged by visited geometry on authority and prediction.
30
+
31
+ ### Fixed
32
+
33
+ - Terrain-heavy rules maps spawn and move within the default tick budget, including Stormbreak's losslessly prepared 4,058 shapes.
34
+ - Build and deployment retain the same committed Wrangler config while local rebuilds and deployment select complete immutable Worker generations.
35
+
36
+ ### Upgrade notes
37
+
38
+ - Height tiles remain open surfaces unless `base` is declared. Solid tiles collide against the whole body, so capsule clearance on a slope differs from the old foot-point surface.
39
+
20
40
  ## [0.44.1] - 2026-10-10
21
41
 
22
42
  **Plugin 0.44.1**
@@ -76,3 +76,19 @@ for existing movement code. Keep build/damage/door logic in entity handlers.
76
76
  Never copy collision lists into player motion or implement a second replay loop.
77
77
  Test visible prediction at 300 ms with loss, rejoin and saved-room restore.
78
78
  The installed toolkit's `guides/game/` is the version-matched source for this guide.
79
+
80
+
81
+ ### Solid terrain and query cost
82
+
83
+ Preserve each source height triangle and its diagonal. Declare height tiles with
84
+ `base` for solid ramps, terraces, roofs and underpasses; omitting it deliberately
85
+ means an open top surface. Do not replace slopes with boxes, invisible walls or
86
+ coarser sampling. Test both sides, tall end, ascent/descent, jump, dash, underside,
87
+ step head clearance, overlap and weapon/camera/blast rays. A discrete reference
88
+ mover may stop one input increment before the continuous capsule contact; report
89
+ both numbers instead of changing its body radius to make them match.
90
+
91
+ Map compilation indexes up to 100,000 shapes. Measure the default-budget guarded
92
+ spawn, six initial bodies/eight seats and traversal/combat with live cover. Repeat
93
+ on seeded virtual time with 300 ms/loss, rejoin and saved-room restore, then smoke
94
+ test real browser sockets. Geometry passing alone does not complete a game rewrite.
@@ -71,7 +71,7 @@ With `space: { dims: 3 }`, z is up and pos is the body's feet. Three.js renders
71
71
  Use continuous sweeps, then slide the remainder along the contact tangent.
72
72
  `world.sweep` includes entity bodies. Movement sees the map plus declared live colliders through `ctx.world` (also exposed on `ctx.map`).
73
73
 
74
- | Map list (at most 4,096 solids in all) | JSON shape |
74
+ | Map list (at most 100,000 shapes in all) | JSON shape |
75
75
  | --- | --- |
76
76
  | boxes | `{ "min": [0,0,0], "max": [2,2,1] }` |
77
77
  | circles (vertical columns in 3D) | `{ "at": [4,0,0], "r": 1 }` |
@@ -79,8 +79,25 @@ Use continuous sweeps, then slide the remainder along the contact tangent.
79
79
  | capsules | `{ "at": [0,0,0], "r": 0.5, "height": 2 }` |
80
80
  | heightTiles | `{ "at": [0,0,0], "size": [2,2], "heights": [0,1,0,1] }` |
81
81
 
82
- Height tiles support the foot point, have open edges and no underside; use boxes
83
- for platforms and ceilings. A rendered mesh is not a collider. Draw solid geometry
82
+ Height tiles without `base` retain their open, foot-point top surface. Add `base`
83
+ (a height offset from `at.z`, at or below all four samples) for a closed solid:
84
+ vertical sides, the sampled top and a flat underside. For example the relay ramp
85
+ is `{ "at": [-2.5,5,0], "size": [5,12], "heights": [4,4,0,0], "base": 0 }`.
86
+ Raise `at.z` or `base` for roofs and underpasses. `diagonal` defaults to `"00-11"`;
87
+ `"10-01"` preserves meshes split the other way. Row order is 00,10,01,11.
88
+ Noncoplanar cells are two triangular prisms, never a convex hull or bilinear patch.
89
+ Solid tiles use the whole declared body for sweeps, support and overlap; rays hit
90
+ sides and undersides as well as tops. Capsules sit above slopes by their rounded
91
+ foot's clearance, rather than embedding their lower sphere in a foot-point surface.
92
+
93
+ `compileMap` builds an immutable spatial index, shared in meaning by authority and
94
+ prediction. Queries charge visited bounds and actual geometry tests, not every
95
+ shape in the map; live colliders remain a separate overlay. A 100,000-shape map is
96
+ supported without decimation or a larger tick budget. Dense local geometry still
97
+ costs work. Walk slopes by projecting the remaining sweep onto a walkable contact
98
+ normal. For a step, sweep upward for head clearance, sweep across, then support
99
+ and sweep down; never teleport upward through a ceiling. Keep jump and snap-down
100
+ limits in the shared move function. A rendered mesh is not a collider. Draw solid geometry
84
101
  from room.map, including spheres/capsules/heightTiles. Changing the map changes
85
102
  state compatibility; it is static build data, not a per-room saved copy.
86
103
 
@@ -8,7 +8,9 @@ metadata:
8
8
 
9
9
  Read the installed toolkit's `node_modules/@homie-rocks/studio/guides/game/{RULES,REWRITE}.md`
10
10
  before authoring: these match its runtime even when this plugin is older. Toolkit
11
- check/build report stale installed plugin copies and the update step. For live
11
+ check/build report stale installed plugin copies and the update step. For solid slopes and terrain, use heightTiles with `base` and the source
12
+ `diagonal`; test sides, underside, step clearance and rays. Maps are indexed at
13
+ compile time up to 100,000 shapes; measure default-budget spawn and traversal. For live
12
14
  cover, doors and platforms, use declared entity `collider` state and movement's
13
15
  `ctx.world.sweep/support/overlaps`; test prediction, loss, rejoin and restore.
14
16
 
@@ -1,3 +1,4 @@
1
+ import {workerEntry} from './worker-build.mjs';
1
2
  import { refreshWorkerConfig } from './worker-config.mjs';
2
3
  import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
3
4
  /**
@@ -381,14 +382,14 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
381
382
  if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
382
383
 
383
384
  const started = Date.now();
384
- let dep = w(['deploy', ...repoVar(root)]);
385
+ let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
385
386
  if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
386
387
  const headers = await cloudflareAuth({ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
387
388
  exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
388
389
  const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
389
390
  if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
390
391
  step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
391
- dep = w(['deploy', ...repoVar(root)]);
392
+ dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
392
393
  }
393
394
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
394
395
  if (paidReleases(root).length) {
@@ -607,7 +608,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
607
608
  } catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
608
609
  }
609
610
  const started = Date.now();
610
- const dep = w(['deploy', ...repoVar(root)]);
611
+ const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
611
612
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
612
613
  step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
613
614
  const repo = repoOf(root);
package/lib/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {workerEntry} from './worker-build.mjs';
1
2
  import { networkInterfaces } from 'node:os';
2
3
  /**
3
4
  * `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
@@ -194,10 +195,11 @@ export function devConfig(root, remoteAi) {
194
195
  const stripped = routesOf(json).map((r) => r.pattern);
195
196
  const routed = ROUTE_KEYS.some((k) => k in json);
196
197
  const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
197
- if (Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
198
+ const entry=workerEntry(root);
199
+ if (!entry && Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
198
200
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
199
201
  delete json.$schema;
200
- json.main = abs(json.main);
202
+ json.main = entry ?? abs(json.main);
201
203
  if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
202
204
  json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
203
205
  for (const k of ROUTE_KEYS) delete json[k];
@@ -2468,6 +2468,48 @@
2468
2468
  }
2469
2469
  }
2470
2470
  }
2471
+ },
2472
+ "0.44.3": {
2473
+ "files": {
2474
+ "CLAUDE.md": "ddf2b2a0376202f6",
2475
+ "README.md": "684b3cca8001673c",
2476
+ "HANDOFF.md": "29337e2ace95b2e7",
2477
+ "games/README.md": "5ad32fe80c61f117",
2478
+ "music/README.md": "a3dc042dadd39df8",
2479
+ "videos/README.md": "16babc3437d5545b",
2480
+ "posts/README.md": "37841aae476bd540",
2481
+ "site/README.md": "732894273a32dc3f",
2482
+ "site/src/worker.mjs": "34444a6ca4b14437"
2483
+ },
2484
+ "sections": {
2485
+ "AGENTS.md": {
2486
+ "(intro)": "214aa46eb3a895d5",
2487
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2488
+ "## Layout": "f3a280b8c96cd068",
2489
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2490
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2491
+ "## The site": "0cb8dd9a6f8a0096",
2492
+ "## Making games": "9e44559de535d842",
2493
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2494
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2495
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2496
+ "## Servers and AI seats": "d0028361fe567add",
2497
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2498
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2499
+ "## Rules": "2e6b29afb85dcf7e",
2500
+ "## Telling Homie": "80b46fdd9e578db8",
2501
+ "## Beta": "9d02638207a26e73"
2502
+ }
2503
+ },
2504
+ "named": {
2505
+ "Homie Arcade": {
2506
+ "sections": {
2507
+ "AGENTS.md": {
2508
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2509
+ }
2510
+ }
2511
+ }
2512
+ }
2471
2513
  }
2472
2514
  }
2473
2515
  }
@@ -2,7 +2,7 @@ import {createHash} from 'node:crypto';
2
2
  import {refreshWorkerConfig} from './worker-config.mjs';
3
3
  import {pathToFileURL} from 'node:url';
4
4
  import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
5
- import {join,relative,basename,sep} from 'node:path';
5
+ import {join,relative,basename,sep,resolve} from 'node:path';
6
6
  import {readConfig} from './routes.mjs';
7
7
  import {workerDir} from './studio.mjs';
8
8
  import {paidReleases} from './parts-upload.mjs';
@@ -38,7 +38,9 @@ export async function buildWorker(root,esbuild) {
38
38
  }
39
39
  // Wrangler's module root is the immutable generation, so old generations are
40
40
  // retained locally for in-flight reloads but are never added to a deployment.
41
- refreshWorkerConfig(root,{main:relative(workerDir(root),join(dir,'worker.js'))});
41
+ refreshWorkerConfig(root);
42
+ const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
43
+ writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
42
44
  // Dev sometimes watches a copy with routes/remote AI removed. Publish its
43
45
  // entry too, keeping its local bindings and its module root at one generation.
44
46
  const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
@@ -54,3 +56,12 @@ export async function buildWorker(root,esbuild) {
54
56
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
55
57
  return sizes;
56
58
  }
59
+
60
+ /** Local immutable entry; the committed config always names the stable shim. */
61
+ export function workerEntry(root) {
62
+ const main=readConfig(root)?.main;
63
+ // Explicit custom entry points remain the owner's choice.
64
+ if(typeof main!=='string' || resolve(workerDir(root),main)!==resolve(root,'site/src/runtime/worker.js'))return null;
65
+ const file=join(root,'site/src/runtime/current.json');
66
+ return existsSync(file)?JSON.parse(readFileSync(file,'utf8')).main:null;
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.44.1",
3
+ "version": "0.44.3",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,3 +1,5 @@
1
+ import { castTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
1
3
  /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
2
4
  * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
3
5
  */
@@ -8,6 +10,7 @@ export interface BodyShape { shape: string; radius: number; height: number }
8
10
  export interface Solid { min: Vec3; max: Vec3; r: number }
9
11
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
10
12
  export interface Map3 {
13
+ staticMap?: Map3;
11
14
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
12
15
  heightTiles?: readonly MapHeightTile[];
13
16
  spheres?: readonly MapCircle[];
@@ -74,15 +77,17 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
74
77
  const x = p.x - tile.at.x, y = p.y - tile.at.y;
75
78
  let best: Hit3 | null = null;
76
79
  for (const half of [0, 1]) {
80
+ const alternate = tile.diagonal === '10-01';
77
81
  const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
78
- const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
79
- const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
82
+ const gy = alternate ? (half === 0 ? h01 - h00 : h11 - h10) / sy : (half === 0 ? h11 - h10 : h01 - h00) / sy;
83
+ const base = alternate && half === 1 ? h10 + h01 - h11 : h00;
84
+ const gap = p.z - tile.at.z - base - gx * x - gy * y;
80
85
  const closing = gx * d.x + gy * d.y - d.z;
81
86
  if (gap < -EPS || closing <= EPS) continue;
82
87
  const t = Math.max(0, gap / closing);
83
88
  if (t > 1 || best && t >= best.t) continue;
84
89
  const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
85
- if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
90
+ if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (alternate ? (half === 0 ? u + v > 1 + EPS : u + v < 1 - EPS) : (half === 0 ? v > u + EPS : u > v + EPS))) continue;
86
91
  const length = Math.sqrt(gx * gx + gy * gy + 1);
87
92
  best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
88
93
  }
@@ -90,6 +95,7 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
90
95
  }
91
96
 
92
97
  export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
98
+ map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
93
99
  charge(24);
94
100
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
101
  let best: Hit3 | null = null;
@@ -101,7 +107,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
101
107
  const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
102
108
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
109
  }
104
- for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
110
+ for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
105
111
  for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
106
112
  // Legacy map circles are vertical columns in a 3D map.
107
113
  for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
package/rules/core.ts CHANGED
@@ -245,7 +245,6 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
245
245
  let stateHeavy = false;
246
246
  const viewCost = new Map<KindTable, number>();
247
247
  for (const k of c.kinds) viewCost.set(k, VIEW + k.fields.length + k.motion.length);
248
- const mapShapes = 4 + c.map.boxes.length + c.map.circles.length;
249
248
 
250
249
  const ticks = (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; };
251
250
  const V = (v: unknown): Vec3 => vec3(v, dims);
@@ -611,7 +610,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
611
610
  },
612
611
  ray: (from: unknown, direction: unknown, max: unknown): unknown => {
613
612
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
614
- charge(20 + 4 * (mapShapes + ents.size));
613
+ charge(20 + 4 * ents.size);
615
614
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
616
615
  if (dims === 3) {
617
616
  const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
@@ -633,7 +632,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
633
632
  },
634
633
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
635
634
  const e = mine(self, 'world.sweep');
636
- charge(20 + 4 * (mapShapes + ents.size));
635
+ charge(20 + 4 * ents.size);
637
636
  const d = V(delta);
638
637
  const radius = e.kind.body?.radius ?? 0;
639
638
  // The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
@@ -1007,7 +1006,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1007
1006
  }
1008
1007
  guides?.();
1009
1008
  // All movers see the same world, independent of entity iteration order.
1010
- if (hasColliders) { left -= 64 * ents.size + 4 * mapShapes; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1009
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1011
1010
  // Phase 1: one input step and `move` for every body.
1012
1011
  for (const e of [...ents.values()]) {
1013
1012
  if (e.dead || !e.kind.body) continue;
package/rules/live.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { overlapsTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
1
3
  /** Declared collision geometry, shared by authority and prediction. Entity fields
2
4
  * own its lifetime and dimensions; this projection contains no gameplay state. */
3
5
  import type { Vec3, ColliderDef } from './rules.ts';
@@ -27,7 +29,7 @@ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number
27
29
  }
28
30
  export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
29
31
  if (!rows.length) return map;
30
- const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
32
+ const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
31
33
  for (const [id, shape, x, y, z, w, d, h] of rows) {
32
34
  const at = { x, y, z };
33
35
  if (shape === 'box') boxes.push({ min: { x: x - w / 2, y: y - d / 2, z }, max: { x: x + w / 2, y: y + d / 2, z: z + h }, id } as any);
@@ -35,7 +37,7 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
35
37
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
36
38
  else capsules.push({ at, r: w / 2, height: h, id } as any);
37
39
  }
38
- return { ...map, boxes, circles, spheres, capsules };
40
+ return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
39
41
  }
40
42
  /** Latest known revision at the requested tick. Unknown future edits are never
41
43
  * guessed: a later snapshot rebases the body and replays its pending inputs. */
@@ -60,14 +62,14 @@ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, d
60
62
  return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze({ ...p, z: p.z - d * h.t }), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
61
63
  },
62
64
  overlaps: (body: unknown) => {
63
- const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
65
+ const p = position(body), s = shape(), a = solidAt(p, s), m = nearbyMap(map(), p, {x:0,y:0,z:0}, s.radius, s.height || 2*s.radius, dims);
64
66
  charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
65
67
  const overlap = (b: ReturnType<typeof solidAt>): boolean => {
66
68
  let sq = 0;
67
69
  for (const k of dims === 2 ? ['x', 'y'] as const : ['x', 'y', 'z'] as const) { const gap = Math.max(0, a.min[k] - b.max[k], b.min[k] - a.max[k]); sq += gap * gap; }
68
70
  return sq < (a.r + b.r) ** 2 || a.r + b.r === 0 && a.min.x < b.max.x && a.max.x > b.min.x && a.min.y < b.max.y && a.max.y > b.min.y && (dims === 2 || a.min.z < b.max.z && a.max.z > b.min.z);
69
71
  };
70
- return m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
72
+ return (m.heightTiles ?? []).some(t => t.base !== undefined && overlapsTerrain(t, a)) || m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
71
73
  },
72
74
  };
73
75
  }
@@ -0,0 +1,51 @@
1
+ /** Immutable build-time BVH. Queries charge nodes visited and narrow-phase work,
2
+ * never the number of shapes in the level. Live geometry is a separate overlay. */
3
+ import { charge } from './guard.ts';
4
+ import type { MapShapes } from './math.ts';
5
+ import type { Vec3 } from './rules.ts';
6
+ type Key = 'boxes' | 'circles' | 'spheres' | 'capsules' | 'heightTiles';
7
+ type Entry = { key: Key; value: any; min: Vec3; max: Vec3; order: number };
8
+ type Node = { min: Vec3; max: Vec3; left?: Node; right?: Node; entries?: Entry[] };
9
+ const indexes = new WeakMap<object, Node>();
10
+ const axes = ['x','y','z'] as const;
11
+ export function indexMap(map: MapShapes): void {
12
+ const entries: Entry[] = [];
13
+ for (const key of ['boxes','circles','spheres','capsules','heightTiles'] as const) for (const value of map[key] ?? []) {
14
+ const v:any=value; let min:Vec3,max:Vec3;
15
+ if(key==='boxes') { min=v.min;max=v.max; }
16
+ else if(key==='heightTiles') { min={...v.at,z:v.at.z+Math.min(v.base??Infinity,...v.heights)};max={x:v.at.x+v.size.x,y:v.at.y+v.size.y,z:v.at.z+Math.max(...v.heights)}; }
17
+ else { min={x:v.at.x-v.r,y:v.at.y-v.r,z:key==='circles'?-Infinity:key==='capsules'?v.at.z:v.at.z-v.r};max={x:v.at.x+v.r,y:v.at.y+v.r,z:key==='circles'?Infinity:key==='capsules'?v.at.z+v.height:v.at.z+v.r}; }
18
+ entries.push({key,value,min,max,order:entries.length});
19
+ }
20
+ function build(es:Entry[]):Node {
21
+ const min={x:Infinity,y:Infinity,z:Infinity},max={x:-Infinity,y:-Infinity,z:-Infinity};
22
+ for(const e of es)for(const k of axes){min[k]=Math.min(min[k],e.min[k]);max[k]=Math.max(max[k],e.max[k]);}
23
+ if(es.length<=8)return {min,max,entries:es};
24
+ // Choose the widest finite axis; unbounded columns still split horizontally.
25
+ let k: 'x' | 'y' | 'z' = max.x-min.x>=max.y-min.y?'x':'y';
26
+ if(Number.isFinite(max.z-min.z) && max.z-min.z>max[k]-min[k])k='z';
27
+ es.sort((a,b)=>(a.min[k]+a.max[k])-(b.min[k]+b.max[k])||a.order-b.order);
28
+ const mid=es.length>>1;return {min,max,left:build(es.slice(0,mid)),right:build(es.slice(mid))};
29
+ }
30
+ indexes.set(map,build(entries));
31
+ }
32
+ /** The segment of a body's reference point against bounds expanded by its extents. */
33
+ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
34
+ const parent=map.staticMap;
35
+ const root=indexes.get(parent??map);
36
+ if(!root)return map;
37
+ const out:any={bounds:map.bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
38
+ function touches(n:{min:Vec3;max:Vec3}):boolean {
39
+ charge(12);let near=0,far=1;
40
+ for(const k of dims===2?['x','y'] as const:axes){const lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
41
+ if(d[k]===0){if(p[k]<lo-1e-7||p[k]>hi+1e-7)return false;}
42
+ else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
43
+ }return true;
44
+ }
45
+ const found:Entry[]=[];
46
+ function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
47
+ visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
48
+ found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
49
+ if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const v of map[key]??[]){charge(12);out[key].push(v);}
50
+ return out;
51
+ }
package/rules/math.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { nearbyMap } from './map-index.ts';
1
2
  import { collisionQueries } from './live.ts';
2
3
  /*
3
4
  * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
@@ -183,7 +184,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
183
184
  /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
184
185
 
185
186
  /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
186
- export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
187
+ export interface MapShapes { staticMap?: MapShapes; bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
187
188
  /** How close a swept body stops to what it hit, in metres. */
188
189
  export const SKIN = 0.001;
189
190
 
@@ -237,6 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
238
  }
238
239
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
239
240
  export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
241
+ map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
242
+ charge(16 + 4 * (map.boxes.length + map.circles.length));
240
243
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
241
244
  for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
242
245
  for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
@@ -250,7 +253,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
250
253
  */
251
254
  export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
252
255
  // Charged before the cast, for every shape of the map it may test.
253
- charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
256
+ charge(20);
254
257
  const fr = Math.fround;
255
258
  // Both are whatever `move` handed over: each is read as a plain number or as nothing, and nothing is called to convert it.
256
259
  const num = (a: unknown): number => { const x = typeof a === 'number' ? fr(a) : 0; return Number.isFinite(x) ? x : 0; };
package/rules/rules.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { prepareTile } from './terrain.ts';
2
+ import { indexMap } from './map-index.ts';
1
3
  /*
2
4
  * rules.ts — the rules contract, version 2: what a rules module declares, and how the runtime reads it.
3
5
  * =============================================================================
@@ -246,7 +248,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
246
248
 
247
249
  export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
248
250
  export interface MapCircle { readonly at: Vec3; readonly r: number }
249
- export interface MapHeightTile { readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
251
+ export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
250
252
  export interface GameMap { readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
251
253
 
252
254
  const vecOf = (a: unknown, what: string): Vec3 => {
@@ -280,15 +282,19 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
280
282
  return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
281
283
  };
282
284
  if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].some(list => list !== undefined && !Array.isArray(list))) throw new Error(`${where}: shapes are lists`);
283
- if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 4096) throw new Error(`${where}: at most 4096 static shapes`);
284
- return Object.freeze({
285
+ if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 100000) throw new Error(`${where}: at most 100000 static shapes`);
286
+ const result = Object.freeze({
285
287
  name,
286
288
  heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
287
289
  const what = `${where} heightTiles[${i}]`;
288
290
  const at = vecOf(t?.at, `${what}.at`);
289
291
  if (!Array.isArray(t.size) || t.size.length !== 2 || t.size.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v) || v <= 0)) throw new Error(`${what}: size is two positive finite lengths`);
290
292
  if (!Array.isArray(t.heights) || t.heights.length !== 4 || t.heights.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v))) throw new Error(`${what}: heights are four finite offsets, in row order`);
291
- return Object.freeze({ at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
293
+ if (t.base !== undefined && (typeof t.base !== 'number' || !Number.isFinite(t.base) || t.heights.some((h:number) => h < t.base))) throw new Error(`${what}: base must be finite and at or below every top sample`);
294
+ if (t.diagonal !== undefined && !['00-11','10-01'].includes(t.diagonal)) throw new Error(`${what}: diagonal is 00-11 or 10-01`);
295
+ const tile = Object.freeze({ ...(t.base !== undefined ? {base:t.base} : {}), ...(t.diagonal ? {diagonal:t.diagonal} : {}), at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
296
+ prepareTile(tile);
297
+ return tile;
292
298
  })),
293
299
  spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
294
300
  capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
@@ -305,6 +311,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
305
311
  })),
306
312
  spots: Object.freeze(spots),
307
313
  });
314
+ indexMap(result);
315
+ return result;
308
316
  }
309
317
 
310
318
  /* ------------------------------------------------------------------ compiled tables */
@@ -0,0 +1,93 @@
1
+ /** Solid triangulated height tiles. Each source triangle is a closed vertical
2
+ * prism; their union preserves noncoplanar samples without a convex-hull fill. */
3
+ import { charge } from './guard.ts';
4
+ import type { Vec3, MapHeightTile } from './rules.ts';
5
+ import type { Solid, Hit3 } from './collision.ts';
6
+ type Plane = { n: Vec3; c: number };
7
+ type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
8
+ const cache = new WeakMap<MapHeightTile, Prism[]>();
9
+ const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
10
+ const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
11
+ const dot=(a:Vec3,b:Vec3)=>a.x*b.x+a.y*b.y+a.z*b.z;
12
+ const cross=(a:Vec3,b:Vec3):Vec3=>({x:a.y*b.z-a.z*b.y,y:a.z*b.x-a.x*b.z,z:a.x*b.y-a.y*b.x});
13
+ const clamp=(x:number)=>Math.max(0,Math.min(1,x));
14
+ export function prepareTile(t:MapHeightTile):void {
15
+ if(t.base===undefined)return;
16
+ const top=t.heights.map((h,i)=>({x:t.at.x+(i%2)*t.size.x,y:t.at.y+(i>>1)*t.size.y,z:t.at.z+h}));
17
+ const tris=t.diagonal==='10-01'?[[0,1,2],[1,3,2]]:[[0,1,3],[0,3,2]];
18
+ const prisms:Prism[]=[];
19
+ for(const tri of tris){
20
+ const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
21
+ if(upper.every(v=>v.z===lower[0].z))continue;
22
+ const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
23
+ for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
24
+ const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
25
+ for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
26
+ prisms.push({vertices,faces:valid,planes,edges});
27
+ }
28
+ cache.set(t,prisms);
29
+ }
30
+ // Closest point on a triangle, including vertex and edge Voronoi regions.
31
+ function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
32
+ const ab=sub(b,a),ac=sub(c,a),ap=sub(p,a),d1=dot(ab,ap),d2=dot(ac,ap);
33
+ if(d1<=0&&d2<=0)return a;
34
+ const bp=sub(p,b),d3=dot(ab,bp),d4=dot(ac,bp);if(d3>=0&&d4<=d3)return b;
35
+ const vc=d1*d4-d3*d2;if(vc<=0&&d1>=0&&d3<=0)return add(a,ab,d1/(d1-d3));
36
+ const cp=sub(p,c),d5=dot(ab,cp),d6=dot(ac,cp);if(d6>=0&&d5<=d6)return c;
37
+ const vb=d5*d2-d1*d6;if(vb<=0&&d2>=0&&d6<=0)return add(a,ac,d2/(d2-d6));
38
+ const va=d3*d6-d5*d4;if(va<=0&&d4-d3>=0&&d5-d6>=0)return add(b,sub(c,b),(d4-d3)/(d4-d3+d5-d6));
39
+ const den=1/(va+vb+vc);return add(add(a,ab,vb*den),ac,vc*den);
40
+ }
41
+ function segmentPair(a:Vec3,b:Vec3,c:Vec3,d:Vec3):Vec3 {
42
+ const u=sub(b,a),v=sub(d,c),w=sub(a,c),aa=dot(u,u),bb=dot(u,v),cc=dot(v,v),dd=dot(u,w),ee=dot(v,w);
43
+ let s=aa?clamp((bb*ee-cc*dd)/(aa*cc-bb*bb)||0):0;
44
+ let t=cc?clamp((bb*s+ee)/cc):0;
45
+ s=aa?clamp((bb*t-dd)/aa):0;t=cc?clamp((bb*s+ee)/cc):0;
46
+ return sub(add(a,u,s),add(c,v,t));
47
+ }
48
+ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
49
+ let near=0,far=1,n={x:0,y:0,z:0};
50
+ for(const plane of prism.planes){charge(8);const gap=plane.c-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-9)return null;continue;}const t=gap/speed;if(speed<0){if(t>=near){near=t;n=plane.n;}}else far=Math.min(far,t);if(near>far+1e-9)return null;}
51
+ return near>=0&&near<=1&&far>=0?{t:near,nx:n.x,ny:n.y,nz:n.z}:null;
52
+ }
53
+ function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
54
+ if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
55
+ let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
56
+ const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
57
+ for(const [x,y,z] of prism.faces){charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
58
+ return best;
59
+ }
60
+ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
61
+ const axes:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
62
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
63
+ let near=0,far=1,normal={x:0,y:0,z:0};
64
+ for(const axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
65
+ for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
66
+ let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
67
+ const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
68
+ let enter=(lo-amax)/speed,exit=(hi-amin)/speed;if(enter>exit)[enter,exit]=[exit,enter];
69
+ if(enter>=near){near=enter;normal={x:n.x*(speed>0?-1:1),y:n.y*(speed>0?-1:1),z:n.z*(speed>0?-1:1)};}far=Math.min(far,exit);if(near>far)return null;
70
+ }
71
+ return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
72
+ }
73
+ export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
74
+ charge(12);
75
+ let best:Hit3|null=null;
76
+ for(const prism of cache.get(tile)??[]){
77
+ let hit:Hit3|null=null;
78
+ if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
79
+ else {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
80
+ if(hit&&(!best||hit.t<best.t))best=hit;
81
+ }return best;
82
+ }
83
+ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
84
+ for(const prism of cache.get(tile)??[]){
85
+ if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
86
+ else {
87
+ // SAT overlap uses the same face, edge and box axes as continuous casts.
88
+ const axes=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
89
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
90
+ let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
91
+ }
92
+ }return false;
93
+ }
@@ -4,6 +4,8 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
4
4
 
5
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
6
6
 
7
+ // Solid slopes use the same contact tangent as walls; the capsule keeps its full
8
+ // clearance. Never replace this sweep with an unchecked height assignment.
7
9
  function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
8
10
  const before = body.pos, hit = ctx.world.sweep(body, delta);
9
11
  if (!hit) return;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.44.1';
2
+ export const STUDIO_VERSION_TAG = '0.44.3';