@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 +20 -0
- package/guides/game/REWRITE.md +16 -0
- package/guides/game/RULES.md +20 -3
- package/guides/game/SKILL.md +3 -1
- package/lib/cloudflare.mjs +4 -3
- package/lib/dev.mjs +4 -2
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -2
- package/package.json +1 -1
- package/rules/collision.ts +10 -4
- package/rules/core.ts +3 -4
- package/rules/live.ts +6 -4
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +5 -2
- package/rules/rules.ts +12 -4
- package/rules/terrain.ts +93 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/worker/version.mjs +1 -1
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**
|
package/guides/game/REWRITE.md
CHANGED
|
@@ -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.
|
package/guides/game/RULES.md
CHANGED
|
@@ -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
|
|
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
|
|
83
|
-
|
|
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
|
|
package/guides/game/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/worker-build.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|
package/rules/collision.ts
CHANGED
|
@@ -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 ?
|
|
79
|
-
const
|
|
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 *
|
|
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 *
|
|
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
|
|
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 = [
|
|
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 {
|
|
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
|
|
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
|
|
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) >
|
|
284
|
-
|
|
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
|
-
|
|
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 */
|
package/rules/terrain.ts
ADDED
|
@@ -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;
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.44.3';
|