@homie-rocks/studio 0.46.5 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +12 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +67 -0
- package/guides/game/SKILL.md +6 -0
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check-worker.mjs +2 -1
- package/lib/rules-guard.mjs +2 -2
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/rules/collision.ts +1 -0
- package/rules/core.ts +81 -33
- package/rules/host.ts +6 -2
- package/rules/interest.mjs +29 -14
- package/rules/live.ts +4 -3
- package/rules/map-index.ts +24 -0
- package/rules/map-state.ts +54 -0
- package/rules/math.ts +8 -5
- package/rules/nav/Flow.ts +53 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +22 -4
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +2 -2
- package/rules/view.ts +45 -19
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +26 -8
- package/worker/office.mjs +2 -2
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
package/rules/map-index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { imageOffsets } from './topology.mjs';
|
|
1
2
|
/** Immutable build-time BVH. Queries charge nodes visited and narrow-phase work,
|
|
2
3
|
* never the number of shapes in the level. Live geometry is a separate overlay. */
|
|
3
4
|
import { charge } from './guard.ts';
|
|
@@ -31,6 +32,29 @@ export function indexMap(map: MapShapes): void {
|
|
|
31
32
|
}
|
|
32
33
|
/** The segment of a body's reference point against bounds expanded by its extents. */
|
|
33
34
|
export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
|
|
35
|
+
if (!map.wrap) return nearbyFlatMap(map,p,d,radius,height,dims);
|
|
36
|
+
const bounds={min:{...map.bounds.min},max:{...map.bounds.max}};
|
|
37
|
+
for(const k of axes)if(map.wrap[k]){bounds.min[k]=-Infinity;bounds.max[k]=Infinity;}
|
|
38
|
+
const out:any={bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
|
|
39
|
+
const extent={min:{...map.bounds.min},max:{...map.bounds.max}};
|
|
40
|
+
const root=indexes.get(map.staticMap??map);
|
|
41
|
+
if(root) for(const k of axes){if(Number.isFinite(root.min[k]))extent.min[k]=Math.min(extent.min[k],root.min[k]);if(Number.isFinite(root.max[k]))extent.max[k]=Math.max(extent.max[k],root.max[k]);}
|
|
42
|
+
if(map.staticMap)for(const key of ['boxes','circles','spheres','capsules'] as const)for(const raw of map[key]??[]){
|
|
43
|
+
charge(12);const v:any=raw;
|
|
44
|
+
for(const k of axes){const low=key==='boxes'?v.min[k]:v.at[k]-v.r,high=key==='boxes'?v.max[k]:v.at[k]+(k==='z'&&key==='capsules'?v.height:v.r);extent.min[k]=Math.min(extent.min[k],low);extent.max[k]=Math.max(extent.max[k],high);}
|
|
45
|
+
}
|
|
46
|
+
for(const offset of imageOffsets(extent,map.wrap,p,d,radius,height,dims)){
|
|
47
|
+
charge(48);
|
|
48
|
+
const found=nearbyFlatMap(map,{x:p.x-offset.x,y:p.y-offset.y,z:p.z-offset.z},d,radius,height,dims);
|
|
49
|
+
const shift=(v:Vec3)=>({x:v.x+offset.x,y:v.y+offset.y,z:v.z+offset.z});
|
|
50
|
+
for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const value of found[key]??[]){
|
|
51
|
+
charge(24);const v:any=value;
|
|
52
|
+
out[key].push(key==='boxes'?{...v,min:shift(v.min),max:shift(v.max)}:{...v,at:shift(v.at)});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function nearbyFlatMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
|
|
34
58
|
const parent=map.staticMap;
|
|
35
59
|
const root=indexes.get(parent??map);
|
|
36
60
|
if(!root)return map;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Copy and compile a room map before publishing it. No mutation survives a failed bake. */
|
|
2
|
+
import { charge, own } from './guard.ts';
|
|
3
|
+
import { compileMap, type GameMap } from './rules.ts';
|
|
4
|
+
export type MapRevision = readonly [
|
|
5
|
+
number,
|
|
6
|
+
unknown
|
|
7
|
+
];
|
|
8
|
+
function copy(value: unknown, depth = 0): unknown {
|
|
9
|
+
charge(6);
|
|
10
|
+
if (typeof value === 'string') {
|
|
11
|
+
charge(value.length);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (value === null || typeof value === 'boolean')
|
|
15
|
+
return value;
|
|
16
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
17
|
+
return value;
|
|
18
|
+
if (!value || typeof value !== 'object' || depth > 8)
|
|
19
|
+
throw new Error('map.replace needs finite plain map data');
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (let i = 0; i < value.length; i++)
|
|
23
|
+
out.push(copy(own(value, i), depth + 1));
|
|
24
|
+
return Object.freeze(out);
|
|
25
|
+
}
|
|
26
|
+
const out: Record<string, unknown> = Object.create(null);
|
|
27
|
+
for (const key of Object.keys(value)) {
|
|
28
|
+
charge(key.length);
|
|
29
|
+
out[key] = copy(own(value, key), depth + 1);
|
|
30
|
+
}
|
|
31
|
+
return Object.freeze(out);
|
|
32
|
+
}
|
|
33
|
+
export function prepareMapReplacement(value: unknown, base: GameMap): {
|
|
34
|
+
raw: unknown;
|
|
35
|
+
map: GameMap;
|
|
36
|
+
cells: number;
|
|
37
|
+
} {
|
|
38
|
+
const raw = copy(value) as any;
|
|
39
|
+
// Index construction sorts at each depth. Charge its upper bound before compilation.
|
|
40
|
+
let runs = 0;
|
|
41
|
+
const tiles = raw?.tiles;
|
|
42
|
+
if (tiles && Array.isArray(tiles.data)) {
|
|
43
|
+
charge(tiles.data.length * 8);
|
|
44
|
+
for (let i = 0; i < tiles.data.length; i++)
|
|
45
|
+
if (tiles.data[i] && (i % tiles.width === 0 || !tiles.data[i - 1]))
|
|
46
|
+
runs++;
|
|
47
|
+
}
|
|
48
|
+
const count = (raw?.boxes?.length ?? 0) + (raw?.circles?.length ?? 0) + (raw?.spheres?.length ?? 0) + (raw?.capsules?.length ?? 0) + (raw?.heightTiles?.length ?? 0) + runs;
|
|
49
|
+
charge(64 + count * (16 + Math.ceil(Math.log2(count + 1)) ** 2));
|
|
50
|
+
const map = compileMap(raw, base.name);
|
|
51
|
+
if (JSON.stringify(map.bounds) !== JSON.stringify(base.bounds) || (['x', 'y', 'z'] as const).some(k => Boolean(map.wrap?.[k]) !== Boolean(base.wrap?.[k])))
|
|
52
|
+
throw new Error('map.replace preserves bounds and wrap; replace level geometry, tiles and spots');
|
|
53
|
+
return { raw, map, cells: JSON.stringify(raw).length };
|
|
54
|
+
}
|
package/rules/math.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { wrapPoint } from './topology.mjs';
|
|
2
|
+
import { tileApi } from './tiles.ts';
|
|
1
3
|
import { nearbyMap } from './map-index.ts';
|
|
2
4
|
import { collisionQueries } from './live.ts';
|
|
3
5
|
/*
|
|
@@ -184,7 +186,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
|
|
|
184
186
|
/* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
|
|
185
187
|
|
|
186
188
|
/** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
|
|
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[] }
|
|
189
|
+
export interface MapShapes { wrap?: import('./rules.ts').MapWrap; 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[] }
|
|
188
190
|
/** How close a swept body stops to what it hit, in metres. */
|
|
189
191
|
export const SKIN = 0.001;
|
|
190
192
|
|
|
@@ -278,16 +280,16 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
278
280
|
const h = castMap3(map, p, d, shape);
|
|
279
281
|
const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
|
|
280
282
|
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
281
|
-
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
283
|
+
const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t)), map.wrap);
|
|
282
284
|
put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
|
|
283
285
|
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
284
286
|
}
|
|
285
287
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
286
288
|
put(body, 'grounded', true);
|
|
287
|
-
if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
|
|
289
|
+
if (!h) { put(body, 'pos', wrapPoint(v(fr(px + dx), fr(py + dy), 0), map.wrap)); return undefined; }
|
|
288
290
|
const l = Math.sqrt(dx * dx + dy * dy);
|
|
289
291
|
const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
|
|
290
|
-
const at = v(fr(px + dx * t), fr(py + dy * t), 0);
|
|
292
|
+
const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), 0), map.wrap);
|
|
291
293
|
put(body, 'pos', at);
|
|
292
294
|
return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
|
|
293
295
|
}
|
|
@@ -299,12 +301,13 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
299
301
|
*/
|
|
300
302
|
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; self?: () => string | undefined }): unknown {
|
|
301
303
|
const none = Object.freeze([]);
|
|
304
|
+
const currentMap = () => (o.geometry?.()?.staticMap ?? o.geometry?.() ?? o.map) as import('./rules.ts').GameMap;
|
|
302
305
|
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
|
|
303
306
|
return brand(Object.freeze({
|
|
304
307
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
305
308
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
306
309
|
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; },
|
|
307
310
|
world: brand(Object.freeze(queries)),
|
|
308
|
-
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 })),
|
|
311
|
+
map: brand(Object.freeze({ ...tileApi(currentMap), name: o.name, spot: (name: string) => (own(currentMap().spots ?? o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(currentMap().spots ?? o.spots, name) ?? none, ...queries })),
|
|
309
312
|
}));
|
|
310
313
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Generated by scripts/studio-nav.mjs from packages/nav/src/Flow.ts.
|
|
2
|
+
/** Cardinal multi-source flow bake. Stable left/up/right/down ties.
|
|
3
|
+
* Callers can debit deterministic work before allocation and for each visited cell. */
|
|
4
|
+
export interface FlowGrid {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
data: readonly number[];
|
|
8
|
+
wrapX?: boolean;
|
|
9
|
+
wrapY?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function bakeGridFlow(
|
|
13
|
+
grid: FlowGrid,
|
|
14
|
+
goals: readonly number[],
|
|
15
|
+
charge: (units: number) => void = () => {},
|
|
16
|
+
): { distance: Int32Array; next: Int32Array } {
|
|
17
|
+
const { width, height, data } = grid;
|
|
18
|
+
const count = width * height;
|
|
19
|
+
if (!Number.isSafeInteger(count) || width < 1 || height < 1 ||
|
|
20
|
+
!Number.isInteger(width) || !Number.isInteger(height) || data.length !== count) {
|
|
21
|
+
throw new Error('nav flow: invalid grid');
|
|
22
|
+
}
|
|
23
|
+
charge(count * 4);
|
|
24
|
+
const distance = new Int32Array(count).fill(-1);
|
|
25
|
+
const next = new Int32Array(count).fill(-1);
|
|
26
|
+
const queue = new Int32Array(count);
|
|
27
|
+
let tail = 0;
|
|
28
|
+
for (const goal of goals) {
|
|
29
|
+
charge(2);
|
|
30
|
+
if (!Number.isInteger(goal) || goal < 0 || goal >= count) throw new Error('nav flow: goal outside grid');
|
|
31
|
+
if (!data[goal] && distance[goal]! < 0) {
|
|
32
|
+
distance[goal] = 0;
|
|
33
|
+
next[goal] = goal;
|
|
34
|
+
queue[tail++] = goal;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (let head = 0; head < tail; head++) {
|
|
38
|
+
charge(16);
|
|
39
|
+
const cell = queue[head]!, x = cell % width, y = Math.floor(cell / width);
|
|
40
|
+
for (const [dx, dy] of [[-1, 0], [0, -1], [1, 0], [0, 1]]) {
|
|
41
|
+
let nx = x + dx!, ny = y + dy!;
|
|
42
|
+
if (grid.wrapX) nx = (nx + width) % width;
|
|
43
|
+
if (grid.wrapY) ny = (ny + height) % height;
|
|
44
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
|
45
|
+
const id = ny * width + nx;
|
|
46
|
+
if (data[id] || distance[id] !== -1) continue;
|
|
47
|
+
distance[id] = distance[cell]! + 1;
|
|
48
|
+
next[id] = cell;
|
|
49
|
+
queue[tail++] = id;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { distance, next };
|
|
53
|
+
}
|
package/rules/query.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { imageOffsets, wrapPoint } from './topology.mjs';
|
|
1
2
|
/** Bounded, data-only ray policy. Shared by rules and predicted geometry queries. */
|
|
2
3
|
import { charge, brand, plainData, put } from './guard.ts';
|
|
3
4
|
import { own, dir, vec3 } from './pack.ts';
|
|
@@ -37,14 +38,15 @@ export function rayQuery(map: MapShapes, targets: readonly QueryTarget[], dims:
|
|
|
37
38
|
const take=(h: {t:number;nx:number;ny:number;nz?:number}|null,id?:string,part?:string):void=>{
|
|
38
39
|
if(!h)return; charge(32);
|
|
39
40
|
if(h.t===0 && h.nx===0 && h.ny===0 && !h.nz)h={...h,nx:-d.x,ny:-d.y,nz:-d.z};
|
|
40
|
-
hits.push(Object.freeze({at:vec3({x:p.x+delta.x*h.t,y:p.y+delta.y*h.t,z:p.z+delta.z*h.t},dims),normal:vec3({x:h.nx,y:h.ny,z:h.nz??0},dims),dist:max*h.t,...(id?{entity:id}:{}),...(part?{part}:{})}));
|
|
41
|
+
hits.push(Object.freeze({at:vec3(wrapPoint({x:p.x+delta.x*h.t,y:p.y+delta.y*h.t,z:p.z+delta.z*h.t},map.wrap),dims),normal:vec3({x:h.nx,y:h.ny,z:h.nz??0},dims),dist:max*h.t,...(id?{entity:id}:{}),...(part?{part}:{})}));
|
|
41
42
|
};
|
|
42
43
|
if(own(options,'entitiesOnly')!==true && (layer===undefined || layer==='geometry')) {
|
|
43
44
|
if(dims===3) {const found: import('./collision.ts').Hit3[]=[];const h=castMap3(map,start,delta,shape,all?found:undefined,includeInside);if(all)for(const h of found)take(h);else take(h);}
|
|
44
45
|
else {const found: import('./math.ts').Hit[]=[];const h=castMap(map,p.x,p.y,delta.x,delta.y,radius,all?found:undefined,includeInside,shape.shape==='box').hit;if(all)for(const h of found)take(h);else take(h);}
|
|
45
46
|
}
|
|
46
47
|
const point=solidAt(start,shape);
|
|
47
|
-
|
|
48
|
+
const candidates=map.wrap?periodicTargets(targets,map,start,delta,radius,height,dims,profile as string | undefined):targets;
|
|
49
|
+
for(const target of candidates) {
|
|
48
50
|
if(excluded.has(target.id) || own(options,'geometryOnly')===true&&!target.geometry || layer!==undefined && layer!==(target.query?.layer??(target.geometry?'geometry':'body')) || kind!==undefined && kind!==target.kind || tag!==undefined && !target.query?.tags?.includes(tag as string)) continue;
|
|
49
51
|
const declared = target.at ? queryParts(target.at,target.query,profile as string | undefined) : target.parts;
|
|
50
52
|
const parts=(own(options,'geometryOnly')!==true && declared?.length?declared:[{name:'',solid:target.solid}]).filter(part=>{
|
|
@@ -96,8 +98,21 @@ export function raySnapshot(map:MapShapes,targets:readonly QueryTarget[],dims:nu
|
|
|
96
98
|
const ray=(from:unknown,direction:unknown,max:unknown)=>rayQuery(map,scene,dims,from,direction,max,policy,self)[0];
|
|
97
99
|
const radius=typeof own(policy,'radius')==='number'?own(policy,'radius') as number:0,height=own(policy,'shape')==='box'||own(policy,'shape')==='capsule'?Number(own(policy,'height')??radius*2):radius*2;
|
|
98
100
|
const position=(body:unknown)=>{const at=own(body,'pos');if(!at||typeof at!=='object')throw new Error('scene sweep/support takes a scratch body with a plain pos');return vec3(at,dims);};
|
|
99
|
-
const support=(body:unknown,distance:unknown=.002)=>{const p=position(body);if(dims===2)return Object.freeze({at:p,normal:Object.freeze({x:0,y:0,z:1}),dist:0});if(typeof distance!=='number'||!Number.isFinite(distance)||distance<0)throw new Error('support distance is finite and nonnegative');const h=rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},{x:0,y:0,z:-1},distance,policy,self,false,false)[0];return h&&h.normal.z>.5?Object.freeze({...h,at:vec3({x:h.at.x,y:h.at.y,z:(h.at.z??0)-height/2},dims)}):undefined;};
|
|
101
|
+
const support=(body:unknown,distance:unknown=.002)=>{const p=position(body);if(dims===2)return Object.freeze({at:p,normal:Object.freeze({x:0,y:0,z:1}),dist:0});if(typeof distance!=='number'||!Number.isFinite(distance)||distance<0)throw new Error('support distance is finite and nonnegative');const h=rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},{x:0,y:0,z:-1},distance,policy,self,false,false)[0];return h&&h.normal.z>.5?Object.freeze({...h,at:vec3(wrapPoint({x:h.at.x,y:h.at.y,z:(h.at.z??0)-height/2},map.wrap),dims)}):undefined;};
|
|
100
102
|
const sweep=(body:unknown,delta:unknown,options?:unknown)=>{const p=position(body),d=vec3(delta,dims),length=Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z),h=length?rayQuery(map,scene,dims,{x:p.x,y:p.y,z:p.z+height/2},d,length,policy,self,false,false)[0]:undefined;
|
|
101
|
-
const fraction=h?Math.max(0,(h.dist-.001)/length):1;put(body as object,'pos',vec3({x:p.x+d.x*fraction,y:p.y+d.y*fraction,z:p.z+d.z*fraction},dims));if(own(options,'ground')!==false)put(body as object,'grounded',dims===2||Boolean(support(body)));return h?Object.freeze({...h,at:vec3(own(body,'pos'),dims)}):undefined;};
|
|
103
|
+
const fraction=h?Math.max(0,(h.dist-.001)/length):1;put(body as object,'pos',vec3(wrapPoint({x:p.x+d.x*fraction,y:p.y+d.y*fraction,z:p.z+d.z*fraction},map.wrap),dims));if(own(options,'ground')!==false)put(body as object,'grounded',dims===2||Boolean(support(body)));return h?Object.freeze({...h,at:vec3(own(body,'pos'),dims)}):undefined;};
|
|
102
104
|
return brand(Object.freeze({ray,rayAll:(from:unknown,direction:unknown,max:unknown)=>rayQuery(map,scene,dims,from,direction,max,policy,self,true),sweep,support}));
|
|
103
105
|
}
|
|
106
|
+
|
|
107
|
+
/** Default queries retain their original target loop; only wrapped worlds construct images. */
|
|
108
|
+
function* periodicTargets(targets:readonly QueryTarget[],map:MapShapes,start:Vec3,delta:Vec3,radius:number,height:number,dims:number,profile?:string):Generator<QueryTarget> {
|
|
109
|
+
for(const source of targets) {
|
|
110
|
+
const ext={min:{x:source.solid.min.x-source.solid.r,y:source.solid.min.y-source.solid.r,z:source.solid.min.z-source.solid.r},max:{x:source.solid.max.x+source.solid.r,y:source.solid.max.y+source.solid.r,z:source.solid.max.z+source.solid.r}};
|
|
111
|
+
for(const part of (source.at ? queryParts(source.at,source.query,profile) : source.parts) ?? []) for(const k of ['x','y','z'] as const){ext.min[k]=Math.min(ext.min[k],part.solid.min[k]-part.solid.r);ext.max[k]=Math.max(ext.max[k],part.solid.max[k]+part.solid.r);}
|
|
112
|
+
for(const offset of imageOffsets(ext,map.wrap,start,delta,radius,height,dims)) {
|
|
113
|
+
charge(48);
|
|
114
|
+
const shift=(v:Vec3)=>({x:v.x+offset.x,y:v.y+offset.y,z:v.z+offset.z});
|
|
115
|
+
yield {...source,at:source.at?shift(source.at):undefined,solid:{...source.solid,min:shift(source.solid.min),max:shift(source.solid.max)},parts:source.parts?.map(part=>({...part,solid:{...part.solid,min:shift(part.solid.min),max:shift(part.solid.max)}}))};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/rules/rules.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileTiles, type Tiles } from './tiles.ts';
|
|
1
2
|
import {prepareGraph,type RouteNode} from './navigation.ts';
|
|
2
3
|
import { prepareTile as prepareExactTile } from './terrain.ts';
|
|
3
4
|
import { prepareTile as prepareLegacyTile } from './terrain-legacy.ts';
|
|
@@ -289,7 +290,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
289
290
|
export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
|
|
290
291
|
export interface MapCircle { readonly at: Vec3; readonly r: number }
|
|
291
292
|
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] }
|
|
292
|
-
export
|
|
293
|
+
export type MapWrap = Readonly<Partial<Record<'x' | 'y' | 'z', readonly [number, number]>>>;
|
|
294
|
+
export interface GameMap { readonly tiles?: Tiles; readonly wrap?: MapWrap; 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[]>> }
|
|
293
295
|
|
|
294
296
|
const vecOf = (a: unknown, what: string): Vec3 => {
|
|
295
297
|
const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
|
|
@@ -323,8 +325,24 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
323
325
|
};
|
|
324
326
|
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`);
|
|
325
327
|
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`);
|
|
328
|
+
const bounds = box(m.bounds, `${where} bounds`);
|
|
329
|
+
let wrap: MapWrap | undefined;
|
|
330
|
+
if (m.wrap !== undefined) {
|
|
331
|
+
if (!m.wrap || typeof m.wrap !== 'object' || Array.isArray(m.wrap)) throw new Error(`${where}: wrap names axes x, y, z`);
|
|
332
|
+
const axes: Partial<Record<'x'|'y'|'z', readonly [number,number]>> = {};
|
|
333
|
+
for (const key of Object.keys(m.wrap)) {
|
|
334
|
+
if (!['x','y','z'].includes(key) || m.wrap[key] !== true) throw new Error(`${where}: wrap axes are true (period comes from bounds)`);
|
|
335
|
+
const k = key as 'x'|'y'|'z';
|
|
336
|
+
if (!(bounds.max[k] > bounds.min[k]) || !Number.isFinite(bounds.max[k]-bounds.min[k])) throw new Error(`${where}: wrapped axis needs finite positive extent`);
|
|
337
|
+
axes[k] = Object.freeze([bounds.min[k], bounds.max[k]]);
|
|
338
|
+
}
|
|
339
|
+
if(Object.keys(axes).length) wrap = Object.freeze(axes);
|
|
340
|
+
}
|
|
341
|
+
const tiled=compileTiles(m.tiles,wrap);
|
|
342
|
+
if(tiled) for(const k of ['x','y'] as const) if(wrap?.[k] && (tiled.tiles.at[k]!==bounds.min[k] || tiled.tiles.at[k]+tiled.tiles.size*(k==='x'?tiled.tiles.width:tiled.tiles.height)!==bounds.max[k])) throw new Error(`${where}: tiles must span each wrapped axis`);
|
|
326
343
|
const result = Object.freeze({
|
|
327
|
-
|
|
344
|
+
...(tiled?{tiles:tiled.tiles}:{}),
|
|
345
|
+
name, ...(wrap ? {wrap} : {}),
|
|
328
346
|
heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
|
|
329
347
|
const what = `${where} heightTiles[${i}]`;
|
|
330
348
|
const at = vecOf(t?.at, `${what}.at`);
|
|
@@ -342,8 +360,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
342
360
|
if (!Number.isFinite(c.height) || c.height < 2 * shape.r) throw new Error(`${where} capsules[${i}]: height is at least twice the radius`);
|
|
343
361
|
return Object.freeze({ ...shape, height: c.height });
|
|
344
362
|
})),
|
|
345
|
-
bounds
|
|
346
|
-
boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
363
|
+
bounds,
|
|
364
|
+
boxes: Object.freeze([...(tiled?.boxes??[]),...(Array.isArray(m.boxes) ? m.boxes : [])].map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
347
365
|
circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
|
|
348
366
|
const r = Number(c?.r);
|
|
349
367
|
if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
|
package/rules/tiles.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { bakeGridFlow } from './nav/Flow.ts';
|
|
2
|
+
import { charge } from './guard.ts';
|
|
3
|
+
import type { GameMap, MapBox, MapWrap, Vec3 } from './rules.ts';
|
|
4
|
+
export interface Tiles {
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
readonly size: number;
|
|
8
|
+
readonly at: Vec3;
|
|
9
|
+
readonly solidHeight: number;
|
|
10
|
+
readonly data: readonly number[];
|
|
11
|
+
readonly flows: Readonly<Record<string, readonly number[]>>;
|
|
12
|
+
}
|
|
13
|
+
const fields = new WeakMap<object, Map<string, ReturnType<typeof bakeGridFlow>>>();
|
|
14
|
+
export function compileTiles(raw: any, wrap?: MapWrap): {
|
|
15
|
+
tiles: Tiles;
|
|
16
|
+
boxes: MapBox[];
|
|
17
|
+
} | undefined {
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return;
|
|
20
|
+
const { width, height, size = 1, solidHeight = 1 } = raw, n = width * height, at = raw.at ?? [0, 0, 0];
|
|
21
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || !Number.isSafeInteger(n) || !Number.isFinite(size) || size <= 0 || !Number.isFinite(solidHeight) || solidHeight <= 0 || !Array.isArray(at) || at.length < 2 || at.length > 3 || at.some((x: unknown) => typeof x !== 'number' || !Number.isFinite(x)) || !Array.isArray(raw.data) || raw.data.length !== n)
|
|
22
|
+
throw new Error('map tiles: positive width, height, size, solidHeight, at and one data value per cell');
|
|
23
|
+
if (!Number.isFinite(at[0] + width * size) || !Number.isFinite(at[1] + height * size) || !Number.isFinite((at[2] ?? 0) + solidHeight))
|
|
24
|
+
throw new Error('map tiles: finite extent required');
|
|
25
|
+
const data = raw.data.map((v: unknown) => { if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 65535)
|
|
26
|
+
throw new Error('map tile values are integers 0..65535'); return v; });
|
|
27
|
+
const origin = { x: at[0], y: at[1], z: at[2] ?? 0 }, boxes: MapBox[] = [];
|
|
28
|
+
for (let y = 0; y < height; y++)
|
|
29
|
+
for (let x = 0; x < width; x++)
|
|
30
|
+
if (data[y * width + x]) {
|
|
31
|
+
const start = x;
|
|
32
|
+
while (x + 1 < width && data[y * width + x + 1])
|
|
33
|
+
x++;
|
|
34
|
+
boxes.push(Object.freeze({ min: Object.freeze({ x: origin.x + start * size, y: origin.y + y * size, z: origin.z }), max: Object.freeze({ x: origin.x + (x + 1) * size, y: origin.y + (y + 1) * size, z: origin.z + solidHeight }) }));
|
|
35
|
+
}
|
|
36
|
+
const flows: Record<string, readonly number[]> = {}, baked = new Map<string, ReturnType<typeof bakeGridFlow>>();
|
|
37
|
+
for (const [name, goals] of Object.entries(raw.flows ?? {})) {
|
|
38
|
+
if (!/^[a-z][A-Za-z0-9_]{0,31}$/.test(name) || !Array.isArray(goals))
|
|
39
|
+
throw new Error('tile flows name lists of goal cell indices');
|
|
40
|
+
flows[name] = Object.freeze([...goals]);
|
|
41
|
+
baked.set(name, bakeGridFlow({ width, height, data, wrapX: Boolean(wrap?.x), wrapY: Boolean(wrap?.y) }, goals, charge));
|
|
42
|
+
}
|
|
43
|
+
const tiles = Object.freeze({ width, height, size, solidHeight, at: Object.freeze(origin), data: Object.freeze(data), flows: Object.freeze(flows) });
|
|
44
|
+
fields.set(tiles, baked);
|
|
45
|
+
return { tiles, boxes };
|
|
46
|
+
}
|
|
47
|
+
export function tileApi(get: () => GameMap) {
|
|
48
|
+
const cell = (x: unknown, y: unknown) => { const t = get().tiles; if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y))
|
|
49
|
+
return -1; const w = get().wrap; if (w?.x)
|
|
50
|
+
x = ((x % t.width) + t.width) % t.width; if (w?.y)
|
|
51
|
+
y = ((y % t.height) + t.height) % t.height; return x < 0 || y < 0 || x >= t.width || y >= t.height ? -1 : y * t.width + x; };
|
|
52
|
+
return {
|
|
53
|
+
tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
|
|
54
|
+
distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
|
|
55
|
+
flow: (name: unknown, x: unknown, y: unknown) => { charge(8); const id = cell(x, y), t = get().tiles, f = typeof name === 'string' && t ? fields.get(t)?.get(name) : null; if (!t || id < 0 || !f || f.next[id] < 0)
|
|
56
|
+
return undefined; const next = f.next[id], dx = next % t.width - id % t.width, dy = Math.floor(next / t.width) - Math.floor(id / t.width); return Object.freeze({ x: Math.abs(dx) > 1 ? -Math.sign(dx) : dx, y: Math.abs(dy) > 1 ? -Math.sign(dy) : dy, z: 0 }); },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Periodic coordinates. Half-period ties consistently choose the negative way. */
|
|
2
|
+
export const wrapValue = (value, range) => range ? range[0] + ((value - range[0]) % (range[1] - range[0]) + range[1] - range[0]) % (range[1] - range[0]) : value;
|
|
3
|
+
export const periodicDelta = (a, b, range) => range ? wrapValue(b - a, [-(range[1] - range[0]) / 2, (range[1] - range[0]) / 2]) : b - a;
|
|
4
|
+
export const wrapPoint = (p, wrap) => wrap ? {x: wrapValue(p.x, wrap.x), y: wrapValue(p.y, wrap.y), z: wrapValue(p.z ?? 0, wrap.z)} : p;
|
|
5
|
+
export const relativePoint = (p, at, wrap) => wrap ? {x:at.x+periodicDelta(at.x,p.x,wrap.x),y:at.y+periodicDelta(at.y,p.y,wrap.y),z:(at.z??0)+periodicDelta(at.z??0,p.z??0,wrap.z)} : p;
|
|
6
|
+
export const blendPosition = (a,b,t,wrap) => ({x:wrapValue(a.x+periodicDelta(a.x,b.x,wrap?.x)*t,wrap?.x),y:wrapValue(a.y+periodicDelta(a.y,b.y,wrap?.y)*t,wrap?.y),z:wrapValue(a.z+periodicDelta(a.z,b.z,wrap?.z)*t,wrap?.z)});
|
|
7
|
+
export function boundPosition(p, map, radius, height, dims) {
|
|
8
|
+
const b=map.bounds,w=map.wrap;
|
|
9
|
+
return {x:w?.x?wrapValue(p.x,w.x):Math.max(b.min.x+radius,Math.min(b.max.x-radius,p.x)),y:w?.y?wrapValue(p.y,w.y):Math.max(b.min.y+radius,Math.min(b.max.y-radius,p.y)),z:dims===3?(w?.z?wrapValue(p.z,w.z):Math.max(b.min.z,Math.min(b.max.z-height,p.z))):0};
|
|
10
|
+
}
|
|
11
|
+
/** Stream periodic images intersecting a query's swept bounds; callers meter each image. */
|
|
12
|
+
export function* imageOffsets(bounds, wrap, p, d, radius, height, dims=3) {
|
|
13
|
+
const ranges=['x','y','z'].map(k=>{
|
|
14
|
+
if(!wrap?.[k] || k==='z'&&dims===2)return [0,0,0];
|
|
15
|
+
const period=wrap[k][1]-wrap[k][0];
|
|
16
|
+
const lo=Math.min(p[k],p[k]+d[k])-(k==='z'?0:radius),hi=Math.max(p[k],p[k]+d[k])+(k==='z'?height:radius);
|
|
17
|
+
const first=Math.ceil((lo-bounds.max[k])/period),last=Math.floor((hi-bounds.min[k])/period);
|
|
18
|
+
if(!Number.isSafeInteger(first)||!Number.isSafeInteger(last))throw new RangeError('periodic query exceeds exact integer coordinates');
|
|
19
|
+
return [first,last,period];
|
|
20
|
+
});
|
|
21
|
+
for(let x=ranges[0][0];x<=ranges[0][1];x++)for(let y=ranges[1][0];y<=ranges[1][1];y++)for(let z=ranges[2][0];z<=ranges[2][1];z++)yield {x:x*ranges[0][2],y:y*ranges[1][2],z:z*ranges[2][2]};
|
|
22
|
+
}
|
package/rules/types.ts
CHANGED
|
@@ -24,11 +24,11 @@ export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Poi
|
|
|
24
24
|
export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
|
|
25
25
|
export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
|
|
26
26
|
export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
|
|
27
|
-
export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
27
|
+
export interface MapView { tile(x:number,y:number):number; distance(flow:string,x:number,y:number):number; flow(name:string,x:number,y:number):Vec3|undefined; readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
28
28
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
29
29
|
export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
|
|
30
30
|
export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
31
|
-
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
31
|
+
readonly tune: ReadonlyState<T>; readonly map: MapView & (Room extends true ? {replace(data:Record<string,unknown>):void} : {}); readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
32
32
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
33
33
|
readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
|
|
34
34
|
/** Budget units the running handler may still spend (0.46.3). */
|
package/rules/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
|
|
1
2
|
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
2
3
|
import { solidAt } from './collision.ts';
|
|
3
4
|
import type { RayQueries, RayOptions } from './types.ts';
|
|
@@ -72,7 +73,7 @@ export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
|
|
75
|
-
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
76
|
+
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; wrap?: Partial<Record<'x'|'y'|'z',true>>; tiles?: unknown; spots?: Record<string, unknown[]> } }
|
|
76
77
|
let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
|
|
77
78
|
/** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
|
|
78
79
|
export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
|
|
@@ -159,7 +160,7 @@ export interface Room<R = unknown> extends RayQueries {
|
|
|
159
160
|
askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
|
|
160
161
|
readonly tune: Readonly<Record<string, unknown>>;
|
|
161
162
|
/** The game's static map: its edges, its solid shapes and its named spots, in metres. */
|
|
162
|
-
readonly map:
|
|
163
|
+
readonly map: import('./rules.ts').GameMap;
|
|
163
164
|
readonly net: Netplay;
|
|
164
165
|
/** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
|
|
165
166
|
pump(): void;
|
|
@@ -191,6 +192,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
191
192
|
const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
|
|
192
193
|
const map = compileMap(game.map, game.map.name ?? 'main');
|
|
193
194
|
const spots = map.spots;
|
|
195
|
+
const compiledMaps = new Map<string, typeof map>();
|
|
196
|
+
const mapOf = (data?: import('./map-state.ts').MapRevision | null) => {
|
|
197
|
+
if (!data) return map;
|
|
198
|
+
const key = `${epoch}:${data[0]}`;
|
|
199
|
+
let value = compiledMaps.get(key);
|
|
200
|
+
if (!value) {
|
|
201
|
+
value = compileMap(data[1], map.name);
|
|
202
|
+
compiledMaps.set(key, value);
|
|
203
|
+
// Only replay history needs old compiled indexes; saves carry the compact data.
|
|
204
|
+
if (compiledMaps.size > 64) compiledMaps.delete(compiledMaps.keys().next().value!);
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
};
|
|
194
208
|
const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
|
|
195
209
|
|
|
196
210
|
let status: RoomStatus = 'connecting';
|
|
@@ -259,7 +273,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
259
273
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
260
274
|
const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
|
|
261
275
|
const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
|
|
262
|
-
const distance = (a: Vec3, b: Vec3): number => Math.hypot(a.x
|
|
276
|
+
const distance = (a: Vec3, b: Vec3): number => Math.hypot(periodicDelta(b.x,a.x,map.wrap?.x),periodicDelta(b.y,a.y,map.wrap?.y),periodicDelta(b.z,a.z,map.wrap?.z));
|
|
263
277
|
const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
|
|
264
278
|
function visualOffset(now: number, path?: Vec3, settled = false): Vec3 {
|
|
265
279
|
if (predict.blendMs <= 0) { offsets = []; return { x: 0, y: 0, z: 0 }; }
|
|
@@ -300,9 +314,21 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
300
314
|
let collisionHistory: CollisionRevision[] = [];
|
|
301
315
|
let cachedCollision: CollisionRevision | undefined;
|
|
302
316
|
let geometry = map as import('./math.ts').MapShapes;
|
|
317
|
+
const mapAt = (tick:number) => {
|
|
318
|
+
if (!collisionHistory.at(-1)?.[3]?.map) return map;
|
|
319
|
+
// Map activation and live collider projection have independent effective ticks.
|
|
320
|
+
// A lost pending snapshot must not postpone a map already active in its successor.
|
|
321
|
+
for (let i=collisionHistory.length-1;i>=0;i--) {
|
|
322
|
+
const metadata=collisionHistory[i][3];
|
|
323
|
+
if (metadata?.map && metadata.map[0]<=tick) return mapOf(metadata.map);
|
|
324
|
+
if (metadata?.previousMap && metadata.previousMap[0]<=tick) return mapOf(metadata.previousMap);
|
|
325
|
+
}
|
|
326
|
+
return map;
|
|
327
|
+
};
|
|
303
328
|
const geometryAt = () => {
|
|
304
329
|
const revision = revisionAt(collisionHistory, moveTick);
|
|
305
|
-
|
|
330
|
+
const activeMap=mapAt(moveTick);
|
|
331
|
+
if (revision !== cachedCollision || (geometry.staticMap??geometry)!==activeMap) { geometry = collisionMap(activeMap, revision?.[2] ?? []); cachedCollision = revision; }
|
|
306
332
|
return geometry;
|
|
307
333
|
};
|
|
308
334
|
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, self: () => mine?.id, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ terrain: myKind()?.terrain, shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
@@ -342,7 +368,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
342
368
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
343
369
|
if (mine) {
|
|
344
370
|
history.set(stepped, copyMine(mine));
|
|
345
|
-
if (shown) offsets = [{ delta: { x:
|
|
371
|
+
if (shown) offsets = [{ delta: { x: periodicDelta(mine.pos.x,shown.x,map.wrap?.x), y: periodicDelta(mine.pos.y,shown.y,map.wrap?.y), z: periodicDelta(mine.pos.z,shown.z,map.wrap?.z) }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
|
|
346
372
|
}
|
|
347
373
|
}
|
|
348
374
|
/** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
|
|
@@ -361,7 +387,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
361
387
|
const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
|
|
362
388
|
const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
|
|
363
389
|
const height = kind?.height || 2 * r;
|
|
364
|
-
out.pos = vec3(
|
|
390
|
+
out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
|
|
365
391
|
return out;
|
|
366
392
|
}
|
|
367
393
|
/* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
|
|
@@ -400,7 +426,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
400
426
|
judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
|
|
401
427
|
get viewTick() { return viewTickNow(); },
|
|
402
428
|
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
403
|
-
rays: (options) => raySnapshot(
|
|
429
|
+
rays: (options) => raySnapshot(mapAt(t), viewTargets(atTickOf(options)), dims, options, id),
|
|
404
430
|
rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
|
|
405
431
|
ghost(kindOfGhost, at, data = {}, options = {}) {
|
|
406
432
|
const k = kindOf.get(kindOfGhost);
|
|
@@ -665,7 +691,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
665
691
|
// Limit the new reconciliation, not an offset already being blended (which
|
|
666
692
|
// can include a clock recalibration). Clipping that carried offset would
|
|
667
693
|
// turn a small new error into a visible snap.
|
|
668
|
-
const change = { x: shown.x
|
|
694
|
+
const change = { x: periodicDelta(corrected.x,shown.x,map.wrap?.x) - oldOffset.x, y: periodicDelta(corrected.y,shown.y,map.wrap?.y) - oldOffset.y, z: periodicDelta(corrected.z,shown.z,map.wrap?.z) - oldOffset.z };
|
|
669
695
|
const gap = Math.hypot(change.x, change.y, change.z);
|
|
670
696
|
const ahead = Math.max(0, tickAt(clock()) - f.k) / tickHz;
|
|
671
697
|
const cap = predict.snapM ?? myKind()!.maxSpeed * (2 * ahead + 0.1);
|
|
@@ -811,7 +837,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
811
837
|
const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
|
|
812
838
|
av = Math.min(1, a / contact); landed = a >= contact;
|
|
813
839
|
}
|
|
814
|
-
pos = vec3({
|
|
840
|
+
pos = vec3({...blendPosition(mine.pos,next.pos,a,map.wrap), z:wrapPoint({x:0,y:0,z:mine.pos.z+periodicDelta(mine.pos.z,next.pos.z,map.wrap?.z)*av},map.wrap).z}, dims);
|
|
815
841
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
816
842
|
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
817
843
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
@@ -832,14 +858,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
832
858
|
// Catch-up can reach the fractional current tick before its next whole step exists.
|
|
833
859
|
// Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
|
|
834
860
|
const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
|
|
835
|
-
if (a && b) { const f = catchTick - t; pos =
|
|
861
|
+
if (a && b) { const f = catchTick - t; pos = blendPosition(a.pos, b.pos, f, map.wrap); heading = blendHeading(a.heading, b.heading, f, dims);
|
|
836
862
|
velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
|
|
837
863
|
grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
|
|
838
864
|
} else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
|
|
839
865
|
}
|
|
840
866
|
}
|
|
841
867
|
const offset = visualOffset(clock(), pos, settled);
|
|
842
|
-
pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
|
|
868
|
+
pos = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
|
|
843
869
|
if (headingBlend) {
|
|
844
870
|
const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
|
|
845
871
|
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
@@ -859,7 +885,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
859
885
|
if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
|
|
860
886
|
const t = s.alpha;
|
|
861
887
|
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
862
|
-
return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(
|
|
888
|
+
return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
|
|
863
889
|
}
|
|
864
890
|
function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
|
|
865
891
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
@@ -948,8 +974,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
948
974
|
let before: Frame | null = null, after: Frame | null = null;
|
|
949
975
|
for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
|
|
950
976
|
const a = before?.ents.get(id), b = after?.ents.get(id);
|
|
951
|
-
if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3(
|
|
952
|
-
if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
|
|
977
|
+
if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3(blendPosition(a.pos, b.pos, m, map.wrap), dims), vel: b.vel, known: true }; }
|
|
978
|
+
if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3(wrapPoint({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s },map.wrap), dims), vel: a.vel, known: false }; }
|
|
953
979
|
return b ? { pos: b.pos, vel: b.vel, known: false } : null;
|
|
954
980
|
}
|
|
955
981
|
const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
|
|
@@ -963,7 +989,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
963
989
|
}
|
|
964
990
|
const viewTargets = (atTick?: number) => {
|
|
965
991
|
pump();
|
|
966
|
-
const targets: QueryTarget[] = collisionTargets(collisionMap(
|
|
992
|
+
const targets: QueryTarget[] = collisionTargets(collisionMap(mapAt(latest?.k??0), collisionHistory.at(-1)?.[2] ?? []));
|
|
967
993
|
for(const k of schema.kinds) {
|
|
968
994
|
if(k.collider || !k.radius)continue;
|
|
969
995
|
each(k.name,e=>{
|
|
@@ -974,7 +1000,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
974
1000
|
}
|
|
975
1001
|
return targets;
|
|
976
1002
|
};
|
|
977
|
-
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(
|
|
1003
|
+
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
|
|
978
1004
|
return {
|
|
979
1005
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
980
1006
|
get status() { return solo ? 'offline' : status; },
|
|
@@ -982,7 +1008,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
982
1008
|
get me() { pump(); return meNow(); },
|
|
983
1009
|
each, get,
|
|
984
1010
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
985
|
-
rays: options => solo ? solo.rays(options) : raySnapshot(
|
|
1011
|
+
rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
|
|
986
1012
|
poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
|
|
987
1013
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
988
1014
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
@@ -1015,7 +1041,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1015
1041
|
if (nextShown && distance(shown, nextShown) > 0.00001) {
|
|
1016
1042
|
const carried = visualOffset(now);
|
|
1017
1043
|
// Coalesce the carried blend: analogue controls may change every frame.
|
|
1018
|
-
offsets = [{ delta: { x: carried.x + shown.x
|
|
1044
|
+
offsets = [{ delta: { x: carried.x + periodicDelta(nextShown.x,shown.x,map.wrap?.x), y: carried.y + periodicDelta(nextShown.y,shown.y,map.wrap?.y), z: carried.z + periodicDelta(nextShown.z,shown.z,map.wrap?.z) },
|
|
1019
1045
|
at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
|
|
1020
1046
|
}
|
|
1021
1047
|
}
|
|
@@ -1027,7 +1053,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1027
1053
|
probe(x) { extra = { ...extra, ...x }; },
|
|
1028
1054
|
ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
1029
1055
|
askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
1030
|
-
tune, map
|
|
1056
|
+
tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
|
|
1031
1057
|
pump,
|
|
1032
1058
|
close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
1033
1059
|
};
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.47.0","sha256":"4e1a1a503557e68e60b55a05dc289e4e688831c429d18c8f13ad2a24c3e05315"});
|