@homie-rocks/studio 0.44.0 → 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 +42 -0
- package/README.md +2 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +94 -0
- package/guides/game/RULES.md +754 -0
- package/guides/game/SKILL.md +394 -0
- package/lib/authoring-guides.mjs +27 -0
- package/lib/cloudflare.mjs +4 -3
- package/lib/dev.mjs +4 -2
- package/lib/rules-check-worker.mjs +4 -3
- package/lib/rules-guard.mjs +2 -1
- package/lib/scaffold.mjs +9 -2
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +50 -6
- package/lib/worker-config.mjs +2 -2
- package/package.json +5 -3
- package/rules/collision.ts +16 -10
- package/rules/core.ts +35 -14
- package/rules/interest.mjs +5 -3
- package/rules/live.ts +75 -0
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +15 -9
- package/rules/rules.ts +27 -5
- package/rules/terrain.ts +93 -0
- package/rules/types.ts +4 -3
- package/rules/view.ts +22 -4
- package/starters/hero-rush-3d/src/move.ts +6 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/version.mjs +1 -1
|
@@ -2426,6 +2426,90 @@
|
|
|
2426
2426
|
}
|
|
2427
2427
|
}
|
|
2428
2428
|
}
|
|
2429
|
+
},
|
|
2430
|
+
"0.44.1": {
|
|
2431
|
+
"files": {
|
|
2432
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2433
|
+
"README.md": "684b3cca8001673c",
|
|
2434
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2435
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2436
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2437
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2438
|
+
"posts/README.md": "37841aae476bd540",
|
|
2439
|
+
"site/README.md": "732894273a32dc3f",
|
|
2440
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2441
|
+
},
|
|
2442
|
+
"sections": {
|
|
2443
|
+
"AGENTS.md": {
|
|
2444
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2445
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2446
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2447
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2448
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2449
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2450
|
+
"## Making games": "9e44559de535d842",
|
|
2451
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2452
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2453
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2454
|
+
"## Servers and AI seats": "d0028361fe567add",
|
|
2455
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2456
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2457
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2458
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2459
|
+
"## Beta": "9d02638207a26e73"
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
"named": {
|
|
2463
|
+
"Homie Arcade": {
|
|
2464
|
+
"sections": {
|
|
2465
|
+
"AGENTS.md": {
|
|
2466
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
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
|
+
}
|
|
2429
2513
|
}
|
|
2430
2514
|
}
|
|
2431
2515
|
}
|
package/lib/worker-build.mjs
CHANGED
|
@@ -1,23 +1,67 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
1
2
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
2
3
|
import {pathToFileURL} from 'node:url';
|
|
3
|
-
import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
|
|
4
|
-
import {join,relative} from 'node:path';
|
|
4
|
+
import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
|
|
5
|
+
import {join,relative,basename,sep,resolve} from 'node:path';
|
|
5
6
|
import {readConfig} from './routes.mjs';
|
|
7
|
+
import {workerDir} from './studio.mjs';
|
|
6
8
|
import {paidReleases} from './parts-upload.mjs';
|
|
7
9
|
/** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
|
|
8
10
|
export async function buildWorker(root,esbuild) {
|
|
9
11
|
const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
|
|
10
12
|
const out=join(root,'site/src/runtime');
|
|
11
|
-
|
|
13
|
+
|
|
12
14
|
const alias={...(readConfig(root)?.alias??{})};
|
|
13
15
|
const toolModule=join(root,'site/src/tools/index.mjs');
|
|
14
16
|
const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
|
|
15
17
|
if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
|
|
16
18
|
const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
|
|
17
|
-
|
|
19
|
+
// A watcher may already have read the previous entry while this build is
|
|
20
|
+
// publishing. Keep its entire graph available. Publish immutable modules first,
|
|
21
|
+
// then switch the single entry atomically; never remove a running graph.
|
|
22
|
+
const generation=createHash('sha256');
|
|
23
|
+
for(const file of result.outputFiles)generation.update(basename(file.path)).update(file.contents);
|
|
24
|
+
const id=generation.digest('hex').slice(0,24),dir=join(out,id);
|
|
18
25
|
mkdirSync(out,{recursive:true});
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
if(!existsSync(dir)) {
|
|
27
|
+
const stage=mkdtempSync(join(out,'.generation-'));
|
|
28
|
+
try {
|
|
29
|
+
for(const file of result.outputFiles)writeFileSync(join(stage,basename(file.path)),file.contents);
|
|
30
|
+
try {renameSync(stage,dir);} catch(error) {if(!existsSync(dir))throw error;}
|
|
31
|
+
} finally {rmSync(stage,{recursive:true,force:true});}
|
|
32
|
+
}
|
|
33
|
+
const entry=`export * from './${id}/worker.js';\nexport {default} from './${id}/worker.js';\n`;
|
|
34
|
+
const target=join(out,'worker.js');
|
|
35
|
+
if(!existsSync(target)||readFileSync(target,'utf8')!==entry) {
|
|
36
|
+
const temp=join(out,`.worker-${process.pid}.tmp`);
|
|
37
|
+
writeFileSync(temp,entry);renameSync(temp,target);
|
|
38
|
+
}
|
|
39
|
+
// Wrangler's module root is the immutable generation, so old generations are
|
|
40
|
+
// retained locally for in-flight reloads but are never added to a deployment.
|
|
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);
|
|
44
|
+
// Dev sometimes watches a copy with routes/remote AI removed. Publish its
|
|
45
|
+
// entry too, keeping its local bindings and its module root at one generation.
|
|
46
|
+
const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
|
|
47
|
+
if(existsSync(local)) {
|
|
48
|
+
const config=JSON.parse(readFileSync(local,'utf8'));
|
|
49
|
+
if(typeof config.main==='string' && config.main.startsWith(out+sep)) {
|
|
50
|
+
config.main=join(dir,'worker.js');
|
|
51
|
+
const text=JSON.stringify(config,null,2)+'\n';
|
|
52
|
+
if(readFileSync(local,'utf8')!==text) {const temp=`${local}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,local);}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,join(dir,basename(path))),bytes:value.bytes,imports:value.imports}));
|
|
21
56
|
writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
|
|
22
57
|
return sizes;
|
|
23
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/lib/worker-config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {readFileSync,writeFileSync,existsSync} from 'node:fs';
|
|
1
|
+
import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
2
2
|
import {join} from 'node:path';
|
|
3
3
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
4
4
|
import {wranglerConfig} from './scaffold.mjs';
|
|
@@ -14,7 +14,7 @@ function merge(base,custom) {
|
|
|
14
14
|
export function updateWorkerConfig(root,generated) {
|
|
15
15
|
const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
|
|
16
16
|
const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
|
|
17
|
-
if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(
|
|
17
|
+
if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
|
|
18
18
|
}
|
|
19
19
|
export function refreshWorkerConfig(root,overrides={}) {
|
|
20
20
|
const studio=readStudio(root),cf=studio.cloudflare;
|
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",
|
|
@@ -121,7 +121,8 @@
|
|
|
121
121
|
"apps/",
|
|
122
122
|
"app-starters/",
|
|
123
123
|
"tools/",
|
|
124
|
-
"functions/"
|
|
124
|
+
"functions/",
|
|
125
|
+
"guides/"
|
|
125
126
|
],
|
|
126
127
|
"engines": {
|
|
127
128
|
"node": ">=22"
|
|
@@ -160,6 +161,7 @@
|
|
|
160
161
|
"zod": "4.3.6"
|
|
161
162
|
},
|
|
162
163
|
"scripts": {
|
|
163
|
-
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
|
|
164
|
+
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
|
|
165
|
+
"prepack": "node ../../scripts/authoring-guides.mjs --check"
|
|
164
166
|
}
|
|
165
167
|
}
|
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,13 +10,14 @@ 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[];
|
|
14
17
|
capsules?: readonly (MapCircle & { height: number })[];
|
|
15
18
|
}
|
|
16
19
|
const EPS = 1e-7;
|
|
17
|
-
const point = (x: number, y: number, z: number):
|
|
20
|
+
const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
|
|
18
21
|
export function solidAt(at: Vec3, body: BodyShape): Solid {
|
|
19
22
|
const r = body.radius, height = body.height || 2 * r;
|
|
20
23
|
if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
|
|
@@ -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,10 +95,11 @@ 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;
|
|
96
|
-
const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
|
|
102
|
+
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = id ? {...h, id} : h; };
|
|
97
103
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
98
104
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
99
105
|
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
@@ -101,12 +107,12 @@ 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));
|
|
105
|
-
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
|
|
110
|
+
for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
|
|
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
|
-
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 }));
|
|
108
|
-
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
|
|
109
|
-
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
|
|
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);
|
|
114
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
|
|
115
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
|
|
110
116
|
return best;
|
|
111
117
|
}
|
|
112
118
|
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
|
|
1
2
|
/*
|
|
2
3
|
* core.ts — one room's world, stepped a tick at a time from a compiled rules module.
|
|
3
4
|
* =============================================================================
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
|
|
42
43
|
import { argsWhy, type Vocabulary } from '../agents/agents.ts';
|
|
43
44
|
import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
|
|
44
|
-
import { SKIN, castMap, exact, math, rayCircle
|
|
45
|
+
import { SKIN, castMap, exact, math, rayCircle } from './math.ts';
|
|
45
46
|
import type { Hit } from './math.ts';
|
|
46
47
|
import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
47
48
|
import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
|
|
@@ -178,7 +179,7 @@ export interface Core {
|
|
|
178
179
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
179
180
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
180
181
|
*/
|
|
181
|
-
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
182
|
+
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
182
183
|
const dims = c.dims;
|
|
183
184
|
const tickHz = c.settings.tickHz;
|
|
184
185
|
const dt = 1 / tickHz;
|
|
@@ -244,7 +245,6 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
244
245
|
let stateHeavy = false;
|
|
245
246
|
const viewCost = new Map<KindTable, number>();
|
|
246
247
|
for (const k of c.kinds) viewCost.set(k, VIEW + k.fields.length + k.motion.length);
|
|
247
|
-
const mapShapes = 4 + c.map.boxes.length + c.map.circles.length;
|
|
248
248
|
|
|
249
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; };
|
|
250
250
|
const V = (v: unknown): Vec3 => vec3(v, dims);
|
|
@@ -483,7 +483,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
483
483
|
const solid = solidAt(p, shape);
|
|
484
484
|
for (const other of ents.values()) {
|
|
485
485
|
if (!other.kind.body || ignored.has(other.id)) continue;
|
|
486
|
-
|
|
486
|
+
if (other.kind.collider) charge(48);
|
|
487
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
488
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
489
|
+
const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
|
|
487
490
|
if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
|
|
488
491
|
}
|
|
489
492
|
return best;
|
|
@@ -607,7 +610,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
607
610
|
},
|
|
608
611
|
ray: (from: unknown, direction: unknown, max: unknown): unknown => {
|
|
609
612
|
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
610
|
-
charge(20 + 4 *
|
|
613
|
+
charge(20 + 4 * ents.size);
|
|
611
614
|
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
612
615
|
if (dims === 3) {
|
|
613
616
|
const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
|
|
@@ -618,7 +621,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
618
621
|
let best: Hit | null = cast.hit;
|
|
619
622
|
for (const e of ents.values()) {
|
|
620
623
|
if (!e.kind.body || e === cx.ent) continue;
|
|
621
|
-
|
|
624
|
+
if (e.kind.collider) charge(48);
|
|
625
|
+
const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
|
|
626
|
+
if (e.dead || e.kind.collider && !row) continue;
|
|
627
|
+
const h = row ? castCollider2(row, p, {x:d.x*far,y:d.y*far,z:0}, 0) : rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
|
|
622
628
|
if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
|
|
623
629
|
}
|
|
624
630
|
if (!best) return undefined;
|
|
@@ -626,7 +632,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
626
632
|
},
|
|
627
633
|
sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
|
|
628
634
|
const e = mine(self, 'world.sweep');
|
|
629
|
-
charge(20 + 4 *
|
|
635
|
+
charge(20 + 4 * ents.size);
|
|
630
636
|
const d = V(delta);
|
|
631
637
|
const radius = e.kind.body?.radius ?? 0;
|
|
632
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.
|
|
@@ -648,7 +654,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
648
654
|
let best: Hit | null = cast.hit;
|
|
649
655
|
for (const other of ents.values()) {
|
|
650
656
|
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
651
|
-
|
|
657
|
+
if (other.kind.collider) charge(48);
|
|
658
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
659
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
660
|
+
const h = row ? castCollider2(row, e.pos, d, radius) : rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
|
|
652
661
|
if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
|
|
653
662
|
}
|
|
654
663
|
const l = Math.sqrt(d.x * d.x + d.y * d.y);
|
|
@@ -678,11 +687,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
678
687
|
});
|
|
679
688
|
Object.freeze(world);
|
|
680
689
|
|
|
681
|
-
const
|
|
682
|
-
|
|
690
|
+
const hasColliders = c.kinds.some(k => k.collider);
|
|
691
|
+
const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
|
|
692
|
+
if (e.dead || !e.kind.collider || !e.kind.body) return [];
|
|
693
|
+
const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
|
|
694
|
+
return row ? [row] : [];
|
|
695
|
+
})];
|
|
696
|
+
let moveGeometry = c.map as import('./math.ts').MapShapes;
|
|
697
|
+
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
|
|
698
|
+
const moveWorld = brand(Object.freeze(queries));
|
|
699
|
+
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
|
|
683
700
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
684
701
|
const moveCtx = brand({} as Record<string, unknown>);
|
|
685
|
-
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
702
|
+
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
686
703
|
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
687
704
|
Object.freeze(moveCtx);
|
|
688
705
|
const joinCtx = brand({} as Record<string, unknown>);
|
|
@@ -988,6 +1005,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
988
1005
|
}
|
|
989
1006
|
}
|
|
990
1007
|
guides?.();
|
|
1008
|
+
// All movers see the same world, independent of entity iteration order.
|
|
1009
|
+
if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
|
|
991
1010
|
// Phase 1: one input step and `move` for every body.
|
|
992
1011
|
for (const e of [...ents.values()]) {
|
|
993
1012
|
if (e.dead || !e.kind.body) continue;
|
|
@@ -1040,7 +1059,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1040
1059
|
if (!k.move) continue;
|
|
1041
1060
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1042
1061
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1043
|
-
|
|
1062
|
+
moveShape = body;
|
|
1044
1063
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1045
1064
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
1046
1065
|
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
@@ -1049,7 +1068,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1049
1068
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1050
1069
|
G.note = null;
|
|
1051
1070
|
e.grounded = own(b, 'grounded') === true;
|
|
1052
|
-
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
|
|
1071
|
+
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry);
|
|
1053
1072
|
}
|
|
1054
1073
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1055
1074
|
const list = [...ents.values()];
|
|
@@ -1369,7 +1388,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1369
1388
|
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1370
1389
|
// Include all three components of position, velocity and heading in a 3D snapshot.
|
|
1371
1390
|
snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
|
|
1372
|
-
|
|
1391
|
+
const collision = hasColliders ? collisionState() : null;
|
|
1392
|
+
if (collision) snapCells += 3 + collision[2].length * 8;
|
|
1393
|
+
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1373
1394
|
},
|
|
1374
1395
|
shared: () => packFields(c.shared, shared, dims),
|
|
1375
1396
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
package/rules/interest.mjs
CHANGED
|
@@ -10,7 +10,9 @@ export function interestSnapshot(snap, seat, radiusM) {
|
|
|
10
10
|
const p = e[3], q = own[3];
|
|
11
11
|
return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
|
|
12
12
|
}) : seat === null ? entities : own ? entities : [];
|
|
13
|
-
|
|
13
|
+
// Collision revisions are prediction inputs, independent of visual interest.
|
|
14
|
+
// Deliver all geometry: authored sweeps/dashes need not fit inside the view radius.
|
|
15
|
+
return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: (snap.c ?? []).filter(row => row[0] === seat) };
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function snapshotEncoder(keyframeTicks) {
|
|
@@ -33,7 +35,7 @@ export function snapshotEncoder(keyframeTicks) {
|
|
|
33
35
|
for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
34
36
|
if (mask) changed.push([id, mask, values]);
|
|
35
37
|
}
|
|
36
|
-
return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
|
|
38
|
+
return { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
41
|
}
|
|
@@ -73,7 +75,7 @@ export function snapshotDecoder() {
|
|
|
73
75
|
if (j !== change[2].length) return null;
|
|
74
76
|
next.set(entity[0], entity);
|
|
75
77
|
}
|
|
76
|
-
return { ...snap, d: [d.round, [...next.values()]] };
|
|
78
|
+
return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
|
|
77
79
|
},
|
|
78
80
|
};
|
|
79
81
|
}
|
package/rules/live.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { overlapsTerrain } from './terrain.ts';
|
|
2
|
+
import { nearbyMap } from './map-index.ts';
|
|
3
|
+
/** Declared collision geometry, shared by authority and prediction. Entity fields
|
|
4
|
+
* own its lifetime and dimensions; this projection contains no gameplay state. */
|
|
5
|
+
import type { Vec3, ColliderDef } from './rules.ts';
|
|
6
|
+
import type { MapShapes } from './math.ts';
|
|
7
|
+
import { charge, own } from './guard.ts';
|
|
8
|
+
import { castMap3, solidAt, type BodyShape } from './collision.ts';
|
|
9
|
+
import { sweepMap, rayBox, rayCircle } from './math.ts';
|
|
10
|
+
|
|
11
|
+
export type ColliderRow = [string, string, number, number, number, number, number, number];
|
|
12
|
+
export type CollisionRevision = [number, number, ColliderRow[]];
|
|
13
|
+
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
|
|
14
|
+
const opts = def === true ? {} : def;
|
|
15
|
+
if (opts.enabled && fields[opts.enabled] !== true) return null;
|
|
16
|
+
const size = opts.size ? fields[opts.size] as Vec3 : null;
|
|
17
|
+
const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
|
|
18
|
+
if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
|
|
19
|
+
return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
|
|
20
|
+
}
|
|
21
|
+
export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
|
|
22
|
+
const [, shape, x, y, z, w, d, h] = row;
|
|
23
|
+
return shape === 'box' ? { min: { x: x-w/2, y: y-d/2, z }, max: { x:x+w/2, y:y+d/2, z:z+h }, r:0 }
|
|
24
|
+
: solidAt({x,y,z}, {shape, radius:w/2, height:h});
|
|
25
|
+
}
|
|
26
|
+
export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
|
|
27
|
+
const solid = colliderSolid(row);
|
|
28
|
+
return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
|
|
29
|
+
}
|
|
30
|
+
export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
|
|
31
|
+
if (!rows.length) return map;
|
|
32
|
+
const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
|
|
33
|
+
for (const [id, shape, x, y, z, w, d, h] of rows) {
|
|
34
|
+
const at = { x, y, z };
|
|
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);
|
|
36
|
+
else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
|
|
37
|
+
else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
|
|
38
|
+
else capsules.push({ at, r: w / 2, height: h, id } as any);
|
|
39
|
+
}
|
|
40
|
+
return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
|
|
41
|
+
}
|
|
42
|
+
/** Latest known revision at the requested tick. Unknown future edits are never
|
|
43
|
+
* guessed: a later snapshot rebases the body and replays its pending inputs. */
|
|
44
|
+
export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
|
|
45
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
|
|
49
|
+
const position = (body: unknown): Vec3 => {
|
|
50
|
+
const p = own(body, 'pos');
|
|
51
|
+
const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
|
|
52
|
+
return { x: n('x'), y: n('y'), z: n('z') };
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
|
|
56
|
+
support: (body: unknown, distance: unknown = 0.002) => {
|
|
57
|
+
charge(20);
|
|
58
|
+
const p = position(body), m = map();
|
|
59
|
+
const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
|
|
60
|
+
if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
|
|
61
|
+
const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
|
|
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;
|
|
63
|
+
},
|
|
64
|
+
overlaps: (body: unknown) => {
|
|
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);
|
|
66
|
+
charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
|
|
67
|
+
const overlap = (b: ReturnType<typeof solidAt>): boolean => {
|
|
68
|
+
let sq = 0;
|
|
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; }
|
|
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);
|
|
71
|
+
};
|
|
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 })));
|
|
73
|
+
},
|
|
74
|
+
};
|
|
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
|
+
}
|