@homie-rocks/studio 0.43.0 → 0.44.1
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 +39 -1
- package/README.md +16 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +78 -0
- package/guides/game/RULES.md +737 -0
- package/guides/game/SKILL.md +392 -0
- package/lib/authoring-guides.mjs +27 -0
- 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 +83 -0
- package/lib/worker-build.mjs +39 -6
- package/lib/worker-config.mjs +2 -2
- package/netplay/netplay.ts +3 -0
- package/package.json +5 -3
- package/rules/collision.ts +6 -6
- package/rules/core.ts +33 -11
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +81 -0
- package/rules/live.ts +73 -0
- package/rules/math.ts +10 -7
- package/rules/rules.ts +28 -6
- package/rules/types.ts +4 -3
- package/rules/view.ts +24 -4
- package/starters/hero-rush-3d/src/move.ts +4 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/room.mjs +15 -5
- package/worker/version.mjs +1 -1
package/lib/worker-build.mjs
CHANGED
|
@@ -1,23 +1,56 @@
|
|
|
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} 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,{main:relative(workerDir(root),join(dir,'worker.js'))});
|
|
42
|
+
// Dev sometimes watches a copy with routes/remote AI removed. Publish its
|
|
43
|
+
// entry too, keeping its local bindings and its module root at one generation.
|
|
44
|
+
const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
|
|
45
|
+
if(existsSync(local)) {
|
|
46
|
+
const config=JSON.parse(readFileSync(local,'utf8'));
|
|
47
|
+
if(typeof config.main==='string' && config.main.startsWith(out+sep)) {
|
|
48
|
+
config.main=join(dir,'worker.js');
|
|
49
|
+
const text=JSON.stringify(config,null,2)+'\n';
|
|
50
|
+
if(readFileSync(local,'utf8')!==text) {const temp=`${local}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,local);}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,join(dir,basename(path))),bytes:value.bytes,imports:value.imports}));
|
|
21
54
|
writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
|
|
22
55
|
return sizes;
|
|
23
56
|
}
|
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/netplay/netplay.ts
CHANGED
|
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2628
2628
|
setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
|
|
2629
2629
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
2630
2630
|
}
|
|
2631
|
+
// A reconnect to the same role emits no role event, but its welcome is a new
|
|
2632
|
+
// connection's keyframe. Rules views must adopt it before the first delta.
|
|
2633
|
+
if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
|
|
2631
2634
|
if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
|
|
2632
2635
|
if (link === 'online') paintLink(); else setLink('online', 'welcome');
|
|
2633
2636
|
// A build older than the live one, let in to its own room (section 23).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.1",
|
|
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
|
@@ -14,7 +14,7 @@ export interface Map3 {
|
|
|
14
14
|
capsules?: readonly (MapCircle & { height: number })[];
|
|
15
15
|
}
|
|
16
16
|
const EPS = 1e-7;
|
|
17
|
-
const point = (x: number, y: number, z: number):
|
|
17
|
+
const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
|
|
18
18
|
export function solidAt(at: Vec3, body: BodyShape): Solid {
|
|
19
19
|
const r = body.radius, height = body.height || 2 * r;
|
|
20
20
|
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 };
|
|
@@ -93,7 +93,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
93
93
|
charge(24);
|
|
94
94
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
95
95
|
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; };
|
|
96
|
+
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
97
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
98
98
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
99
99
|
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
@@ -102,11 +102,11 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
102
102
|
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
103
103
|
}
|
|
104
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 }));
|
|
105
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
|
|
106
106
|
// 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 })));
|
|
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 }), (c as any).id);
|
|
108
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
|
|
109
|
+
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
110
|
return best;
|
|
111
111
|
}
|
|
112
112
|
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;
|
|
@@ -483,7 +484,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
483
484
|
const solid = solidAt(p, shape);
|
|
484
485
|
for (const other of ents.values()) {
|
|
485
486
|
if (!other.kind.body || ignored.has(other.id)) continue;
|
|
486
|
-
|
|
487
|
+
if (other.kind.collider) charge(48);
|
|
488
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
489
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
490
|
+
const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
|
|
487
491
|
if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
|
|
488
492
|
}
|
|
489
493
|
return best;
|
|
@@ -618,7 +622,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
618
622
|
let best: Hit | null = cast.hit;
|
|
619
623
|
for (const e of ents.values()) {
|
|
620
624
|
if (!e.kind.body || e === cx.ent) continue;
|
|
621
|
-
|
|
625
|
+
if (e.kind.collider) charge(48);
|
|
626
|
+
const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
|
|
627
|
+
if (e.dead || e.kind.collider && !row) continue;
|
|
628
|
+
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
629
|
if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
|
|
623
630
|
}
|
|
624
631
|
if (!best) return undefined;
|
|
@@ -648,7 +655,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
648
655
|
let best: Hit | null = cast.hit;
|
|
649
656
|
for (const other of ents.values()) {
|
|
650
657
|
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
651
|
-
|
|
658
|
+
if (other.kind.collider) charge(48);
|
|
659
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
660
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
661
|
+
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
662
|
if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
|
|
653
663
|
}
|
|
654
664
|
const l = Math.sqrt(d.x * d.x + d.y * d.y);
|
|
@@ -678,11 +688,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
678
688
|
});
|
|
679
689
|
Object.freeze(world);
|
|
680
690
|
|
|
681
|
-
const
|
|
682
|
-
|
|
691
|
+
const hasColliders = c.kinds.some(k => k.collider);
|
|
692
|
+
const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
|
|
693
|
+
if (e.dead || !e.kind.collider || !e.kind.body) return [];
|
|
694
|
+
const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
|
|
695
|
+
return row ? [row] : [];
|
|
696
|
+
})];
|
|
697
|
+
let moveGeometry = c.map as import('./math.ts').MapShapes;
|
|
698
|
+
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
|
|
699
|
+
const moveWorld = brand(Object.freeze(queries));
|
|
700
|
+
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
|
|
683
701
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
684
702
|
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 });
|
|
703
|
+
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
704
|
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
687
705
|
Object.freeze(moveCtx);
|
|
688
706
|
const joinCtx = brand({} as Record<string, unknown>);
|
|
@@ -988,6 +1006,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
988
1006
|
}
|
|
989
1007
|
}
|
|
990
1008
|
guides?.();
|
|
1009
|
+
// 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]); }
|
|
991
1011
|
// Phase 1: one input step and `move` for every body.
|
|
992
1012
|
for (const e of [...ents.values()]) {
|
|
993
1013
|
if (e.dead || !e.kind.body) continue;
|
|
@@ -1040,7 +1060,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1040
1060
|
if (!k.move) continue;
|
|
1041
1061
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1042
1062
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1043
|
-
|
|
1063
|
+
moveShape = body;
|
|
1044
1064
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1045
1065
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
1046
1066
|
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
@@ -1049,7 +1069,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1049
1069
|
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
1070
|
G.note = null;
|
|
1051
1071
|
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 } });
|
|
1072
|
+
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
1073
|
}
|
|
1054
1074
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1055
1075
|
const list = [...ents.values()];
|
|
@@ -1369,7 +1389,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1369
1389
|
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1370
1390
|
// Include all three components of position, velocity and heading in a 3D snapshot.
|
|
1371
1391
|
snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
|
|
1372
|
-
|
|
1392
|
+
const collision = hasColliders ? collisionState() : null;
|
|
1393
|
+
if (collision) snapCells += 3 + collision[2].length * 8;
|
|
1394
|
+
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1373
1395
|
},
|
|
1374
1396
|
shared: () => packFields(c.shared, shared, dims),
|
|
1375
1397
|
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/host.ts
CHANGED
|
@@ -99,6 +99,7 @@ export interface Host {
|
|
|
99
99
|
readonly running: boolean;
|
|
100
100
|
readonly paused: boolean;
|
|
101
101
|
readonly people: number;
|
|
102
|
+
readonly viewRadiusM: number | null;
|
|
102
103
|
frame(m: Record<string, unknown>): void;
|
|
103
104
|
/** Start the tick loop (the first person has joined). */
|
|
104
105
|
start(): void;
|
|
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
767
768
|
}
|
|
768
769
|
|
|
769
770
|
return {
|
|
771
|
+
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
770
772
|
get tick() { return core.tick; },
|
|
771
773
|
get epoch() { return epoch; },
|
|
772
774
|
get running() { return running; },
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Per-connection state delivery. Pure data, shared by the relay and the view.
|
|
2
|
+
* Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
|
|
3
|
+
* therefore loses no baseline. A new keyframe bounds recovery to one second.
|
|
4
|
+
* No history grows with time: one keyframe per connection, no per-frame queue.
|
|
5
|
+
*/
|
|
6
|
+
export function interestSnapshot(snap, seat, radiusM) {
|
|
7
|
+
const [round, entities] = snap.d;
|
|
8
|
+
const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
|
|
9
|
+
const visible = own && radiusM !== null ? entities.filter(e => {
|
|
10
|
+
const p = e[3], q = own[3];
|
|
11
|
+
return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
|
|
12
|
+
}) : seat === null ? entities : own ? entities : [];
|
|
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) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function snapshotEncoder(keyframeTicks) {
|
|
19
|
+
let base = null;
|
|
20
|
+
let rows = new Map();
|
|
21
|
+
return {
|
|
22
|
+
reset() { base = null; rows.clear(); },
|
|
23
|
+
encode(snap) {
|
|
24
|
+
if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
|
|
25
|
+
base = snap;
|
|
26
|
+
rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
|
|
27
|
+
return snap;
|
|
28
|
+
}
|
|
29
|
+
const present = new Set(), changed = [];
|
|
30
|
+
for (const entity of snap.d[1]) {
|
|
31
|
+
const id = entity[0]; present.add(id);
|
|
32
|
+
const before = rows.get(id);
|
|
33
|
+
if (!before) { changed.push(entity); continue; }
|
|
34
|
+
let mask = 0; const values = [];
|
|
35
|
+
for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
36
|
+
if (mask) changed.push([id, mask, values]);
|
|
37
|
+
}
|
|
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)) } };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function snapshotDecoder() {
|
|
44
|
+
let base = null;
|
|
45
|
+
let rows = new Map();
|
|
46
|
+
return {
|
|
47
|
+
reset() { base = null; rows.clear(); },
|
|
48
|
+
decode(snap) {
|
|
49
|
+
if (!snap || typeof snap !== 'object') return null;
|
|
50
|
+
if (Array.isArray(snap.d)) {
|
|
51
|
+
if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
|
|
52
|
+
!snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
|
|
53
|
+
// A late duplicate keyframe must not replace the newer baseline.
|
|
54
|
+
if (!base || snap.e !== base.e || snap.k >= base.k) {
|
|
55
|
+
base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
|
|
56
|
+
}
|
|
57
|
+
return snap;
|
|
58
|
+
}
|
|
59
|
+
const d = snap.d;
|
|
60
|
+
if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
|
|
61
|
+
if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
|
|
62
|
+
const next = new Map(rows);
|
|
63
|
+
for (const id of d.removed) next.delete(id);
|
|
64
|
+
for (const change of d.changed) {
|
|
65
|
+
if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
|
|
66
|
+
if (change.length !== 3) {
|
|
67
|
+
if (change.length < 9) return null;
|
|
68
|
+
next.set(change[0], change); continue;
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
|
|
71
|
+
const old = rows.get(change[0]);
|
|
72
|
+
if (!old) return null;
|
|
73
|
+
const entity = old.slice(); let j = 0;
|
|
74
|
+
for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
|
|
75
|
+
if (j !== change[2].length) return null;
|
|
76
|
+
next.set(entity[0], entity);
|
|
77
|
+
}
|
|
78
|
+
return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/rules/live.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Declared collision geometry, shared by authority and prediction. Entity fields
|
|
2
|
+
* own its lifetime and dimensions; this projection contains no gameplay state. */
|
|
3
|
+
import type { Vec3, ColliderDef } from './rules.ts';
|
|
4
|
+
import type { MapShapes } from './math.ts';
|
|
5
|
+
import { charge, own } from './guard.ts';
|
|
6
|
+
import { castMap3, solidAt, type BodyShape } from './collision.ts';
|
|
7
|
+
import { sweepMap, rayBox, rayCircle } from './math.ts';
|
|
8
|
+
|
|
9
|
+
export type ColliderRow = [string, string, number, number, number, number, number, number];
|
|
10
|
+
export type CollisionRevision = [number, number, ColliderRow[]];
|
|
11
|
+
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
|
|
12
|
+
const opts = def === true ? {} : def;
|
|
13
|
+
if (opts.enabled && fields[opts.enabled] !== true) return null;
|
|
14
|
+
const size = opts.size ? fields[opts.size] as Vec3 : null;
|
|
15
|
+
const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
|
|
16
|
+
if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
|
|
17
|
+
return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
|
|
18
|
+
}
|
|
19
|
+
export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
|
|
20
|
+
const [, shape, x, y, z, w, d, h] = row;
|
|
21
|
+
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 }
|
|
22
|
+
: solidAt({x,y,z}, {shape, radius:w/2, height:h});
|
|
23
|
+
}
|
|
24
|
+
export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
|
|
25
|
+
const solid = colliderSolid(row);
|
|
26
|
+
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);
|
|
27
|
+
}
|
|
28
|
+
export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
|
|
29
|
+
if (!rows.length) return map;
|
|
30
|
+
const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
|
|
31
|
+
for (const [id, shape, x, y, z, w, d, h] of rows) {
|
|
32
|
+
const at = { x, y, z };
|
|
33
|
+
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);
|
|
34
|
+
else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
|
|
35
|
+
else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
|
|
36
|
+
else capsules.push({ at, r: w / 2, height: h, id } as any);
|
|
37
|
+
}
|
|
38
|
+
return { ...map, boxes, circles, spheres, capsules };
|
|
39
|
+
}
|
|
40
|
+
/** Latest known revision at the requested tick. Unknown future edits are never
|
|
41
|
+
* guessed: a later snapshot rebases the body and replays its pending inputs. */
|
|
42
|
+
export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
|
|
43
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
|
|
47
|
+
const position = (body: unknown): Vec3 => {
|
|
48
|
+
const p = own(body, 'pos');
|
|
49
|
+
const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
|
|
50
|
+
return { x: n('x'), y: n('y'), z: n('z') };
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
|
|
54
|
+
support: (body: unknown, distance: unknown = 0.002) => {
|
|
55
|
+
charge(20);
|
|
56
|
+
const p = position(body), m = map();
|
|
57
|
+
const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
|
|
58
|
+
if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
|
|
59
|
+
const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
|
|
60
|
+
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
|
+
},
|
|
62
|
+
overlaps: (body: unknown) => {
|
|
63
|
+
const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
|
|
64
|
+
charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
|
|
65
|
+
const overlap = (b: ReturnType<typeof solidAt>): boolean => {
|
|
66
|
+
let sq = 0;
|
|
67
|
+
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
|
+
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
|
+
};
|
|
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 })));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/rules/math.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collisionQueries } from './live.ts';
|
|
1
2
|
/*
|
|
2
3
|
* math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
|
|
3
4
|
* =============================================================================
|
|
@@ -204,7 +205,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
|
|
|
204
205
|
return { t, nx: hx / l, ny: hy / l };
|
|
205
206
|
}
|
|
206
207
|
/** The same point against a box grown by r with round corners (a circle of radius r against the box). */
|
|
207
|
-
function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
|
|
208
|
+
export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
|
|
208
209
|
let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
|
|
209
210
|
const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
|
|
210
211
|
const p = [px, py]; const d = [dx, dy];
|
|
@@ -237,8 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
|
|
|
237
238
|
/** 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. */
|
|
238
239
|
export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
|
|
239
240
|
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);
|
|
240
|
-
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; }
|
|
241
|
-
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; }
|
|
241
|
+
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
|
+
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 }; }
|
|
242
243
|
return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -264,7 +265,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
264
265
|
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
265
266
|
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
266
267
|
put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
|
|
267
|
-
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
|
|
268
|
+
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
268
269
|
}
|
|
269
270
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
270
271
|
put(body, 'grounded', true);
|
|
@@ -273,7 +274,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
273
274
|
const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
|
|
274
275
|
const at = v(fr(px + dx * t), fr(py + dy * t), 0);
|
|
275
276
|
put(body, 'pos', at);
|
|
276
|
-
return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
|
|
277
|
+
return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
|
|
277
278
|
}
|
|
278
279
|
|
|
279
280
|
/**
|
|
@@ -281,12 +282,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
281
282
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
282
283
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
283
284
|
*/
|
|
284
|
-
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number }): unknown {
|
|
285
|
+
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes }): unknown {
|
|
285
286
|
const none = Object.freeze([]);
|
|
287
|
+
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
|
|
286
288
|
return brand(Object.freeze({
|
|
287
289
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
288
290
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
289
291
|
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
290
|
-
|
|
292
|
+
world: brand(Object.freeze(queries)),
|
|
293
|
+
map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
|
|
291
294
|
}));
|
|
292
295
|
}
|
package/rules/rules.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const SAVE_REVISION = 2;
|
|
|
37
37
|
export const REACH_M = 64;
|
|
38
38
|
/** The most entities one room holds. A spawn past it throws in the handler that asked. */
|
|
39
39
|
export const ENTITY_MAX = 2048;
|
|
40
|
-
/** The
|
|
40
|
+
/** The current bot-fill ceiling; public admission lives in worker/seats.mjs. */
|
|
41
41
|
export const SEATS_MAX = 32;
|
|
42
42
|
/** The most values one declared field may hold when it is full (`cellsOf`), and the most all the fields of one kind of entity may. */
|
|
43
43
|
export const FIELD_CELLS_MAX = 16_384;
|
|
@@ -123,6 +123,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
|
|
|
123
123
|
export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
|
|
124
124
|
export type MoveFn = (body: any, input: any, ctx: any) => void;
|
|
125
125
|
|
|
126
|
+
export type ColliderDef = true | { size?: string; enabled?: string };
|
|
126
127
|
export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
|
|
127
128
|
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
128
129
|
export interface EntityDef {
|
|
@@ -131,6 +132,7 @@ export interface EntityDef {
|
|
|
131
132
|
motion?: Fields;
|
|
132
133
|
input?: Fields;
|
|
133
134
|
body?: BodyDef;
|
|
135
|
+
collider?: ColliderDef;
|
|
134
136
|
guide?: GuideDef;
|
|
135
137
|
tick?: Handler;
|
|
136
138
|
think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
|
|
@@ -176,6 +178,8 @@ export interface RoomSettings {
|
|
|
176
178
|
offline: boolean;
|
|
177
179
|
tickHz: number;
|
|
178
180
|
inputHz: number;
|
|
181
|
+
/** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
|
|
182
|
+
view: { radiusM: number | null };
|
|
179
183
|
durability: { movementSeconds: number };
|
|
180
184
|
budget: { tick: number };
|
|
181
185
|
predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
|
|
@@ -191,7 +195,7 @@ export const BUDGET_TICK = 500_000;
|
|
|
191
195
|
export const BUDGET_SECOND = 10_000_000;
|
|
192
196
|
export const budgetFor = (tickHz: number): number => Math.min(BUDGET_TICK, Math.floor(BUDGET_SECOND / tickHz));
|
|
193
197
|
export const ROOM_DEFAULTS: RoomSettings = deepFreeze({
|
|
194
|
-
host: 'server', offline: true, tickHz: 20, inputHz: 20, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
|
|
198
|
+
host: 'server', offline: true, tickHz: 20, inputHz: 20, view: { radiusM: null }, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
|
|
195
199
|
predict: { catchM: null, catchUp: 1.25, snapM: null, blendMs: 100, interpMs: null },
|
|
196
200
|
});
|
|
197
201
|
|
|
@@ -212,18 +216,24 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
212
216
|
if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
|
|
213
217
|
return n;
|
|
214
218
|
};
|
|
215
|
-
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict']);
|
|
216
|
-
for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict)`);
|
|
219
|
+
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
|
|
220
|
+
for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
|
|
217
221
|
let host: 'server' | 'browser' = 'server';
|
|
218
222
|
if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
|
|
219
223
|
let offline = true;
|
|
220
224
|
if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
|
|
221
225
|
const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
|
|
222
226
|
const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
|
|
227
|
+
if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
|
|
228
|
+
if (r.view !== undefined) {
|
|
229
|
+
if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
|
|
230
|
+
else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
|
|
231
|
+
}
|
|
223
232
|
const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
|
|
224
233
|
return {
|
|
225
234
|
settings: {
|
|
226
235
|
host, offline, tickHz, inputHz,
|
|
236
|
+
view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
|
|
227
237
|
durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
|
|
228
238
|
budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
|
|
229
239
|
predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
|
|
@@ -307,6 +317,7 @@ export interface KindTable {
|
|
|
307
317
|
fields: FieldList;
|
|
308
318
|
motion: FieldList;
|
|
309
319
|
input: FieldList;
|
|
320
|
+
collider?: ColliderDef;
|
|
310
321
|
body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
|
|
311
322
|
score: string | null;
|
|
312
323
|
tick: Handler | null;
|
|
@@ -513,11 +524,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
513
524
|
if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
|
|
514
525
|
const cells = cellsOfList(fields) + cellsOfList(motion);
|
|
515
526
|
if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
|
|
516
|
-
|
|
527
|
+
if (e.collider !== undefined) {
|
|
528
|
+
if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
|
|
529
|
+
if (e.collider !== true) {
|
|
530
|
+
if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
|
|
531
|
+
for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
|
|
532
|
+
const name = (e.collider as Record<string, unknown>)[key];
|
|
533
|
+
if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
|
|
517
538
|
kinds.push(k);
|
|
518
539
|
kindOf[name] = k;
|
|
519
540
|
}
|
|
520
541
|
|
|
542
|
+
if (kinds.some(k => k.collider) && kinds.some(k => k.player && k.body?.owner)) throw new Error("live colliders need server movement: omit body.move: 'owner' on players");
|
|
521
543
|
const room = d.room ?? {};
|
|
522
544
|
const roomOn = handlers<RoomHandler>(room.on, 'room.on');
|
|
523
545
|
for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
|
|
@@ -540,7 +562,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
540
562
|
if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
|
|
541
563
|
asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
|
|
542
564
|
}
|
|
543
|
-
const seats = Math.max(1, Math.min(
|
|
565
|
+
const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
|
|
544
566
|
return {
|
|
545
567
|
contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
546
568
|
shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|