@homie-rocks/studio 0.46.6 → 0.48.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 +83 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +207 -0
- package/guides/game/SKILL.md +24 -0
- package/lib/doctor.mjs +8 -6
- package/lib/mcp-tools.mjs +2 -1
- package/lib/physics-mesh.mjs +40 -0
- package/lib/provider-key.mjs +21 -0
- package/lib/rules-build.mjs +18 -10
- package/lib/rules-check-worker.mjs +5 -1
- package/lib/rules-check.mjs +1 -1
- package/lib/rules-guard.mjs +5 -4
- package/lib/template-history.json +84 -0
- package/package.json +11 -2
- package/rules/collision.ts +1 -0
- package/rules/core.ts +94 -34
- package/rules/host.ts +3 -1
- 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 +18 -6
- package/rules/nav/Flow.ts +53 -0
- package/rules/pack.ts +4 -2
- package/rules/physics-engine.ts +73 -0
- package/rules/physics.ts +26 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +31 -4
- package/rules/substeps.ts +39 -0
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +4 -3
- package/rules/view.ts +74 -23
- package/worker/build-id.mjs +1 -1
- package/worker/hosted.mjs +16 -0
- package/worker/index.mjs +19 -4
- package/worker/version.mjs +1 -1
package/rules/interest.mjs
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
import { periodicDelta, wrapValue } from './topology.mjs';
|
|
1
2
|
/** Per-connection state delivery. Pure data, shared by the relay and the view.
|
|
2
3
|
* Deltas may name a periodic keyframe or the previous ordered frame. A missing
|
|
3
4
|
* chained predecessor is ignored until the next keyframe. Periodic encoders
|
|
4
5
|
* repair the chain; ordered transports reset it on connection or epoch change.
|
|
5
6
|
* No history grows with time: one keyframe per connection, no per-frame queue.
|
|
6
7
|
*/
|
|
8
|
+
const wrapOf = snap => snap.d?.[2]?.[3]?.wrap;
|
|
9
|
+
const axisNames=['x','y','z'];
|
|
10
|
+
const distanceSq=(p,q,wrap)=>{if(!wrap){const dx=p[0]-q[0],dy=p[1]-q[1],dz=(p[2]??0)-(q[2]??0);return dx*dx+dy*dy+dz*dz;}let d=0;for(let i=0;i<3;i++)d+=periodicDelta(q[i]??0,p[i]??0,wrap?.[axisNames[i]])**2;return d;};
|
|
11
|
+
function periodicCells(cells,p,size,wrap) {
|
|
12
|
+
const shifts=axisNames.map(k=>wrap?.[k]?[0,wrap[k][1]-wrap[k][0],wrap[k][0]-wrap[k][1]]:[0]);
|
|
13
|
+
const found=new Set();
|
|
14
|
+
for(const sx of shifts[0])for(const sy of shifts[1])for(const sz of shifts[2]){
|
|
15
|
+
const x=Math.floor((p[0]+sx)/size),y=Math.floor((p[1]+sy)/size),z=Math.floor(((p[2]??0)+sz)/size);
|
|
16
|
+
for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)for(const e of cells.get(`${x+dx},${y+dy},${z+dz}`)??[])found.add(e);
|
|
17
|
+
}
|
|
18
|
+
return [...found];
|
|
19
|
+
}
|
|
7
20
|
const spatial = new WeakMap(), controls = new WeakMap();
|
|
8
21
|
function indexFor(snap, radius) {
|
|
9
22
|
let byRadius = spatial.get(snap.d[1]);
|
|
@@ -29,10 +42,11 @@ export function interestSnapshot(snap, seat, radiusM) {
|
|
|
29
42
|
else if (own) {
|
|
30
43
|
const q=own[3], x=Math.floor(q[0]/index.size), y=Math.floor(q[1]/index.size), z=Math.floor((q[2]??0)/index.size);
|
|
31
44
|
const cell = `${x},${y},${z}`;
|
|
32
|
-
let candidates = index.candidates.get(cell);
|
|
45
|
+
let candidates = wrapOf(snap) ? null : index.candidates.get(cell);
|
|
33
46
|
if (!candidates) {
|
|
34
47
|
candidates = [];
|
|
35
|
-
|
|
48
|
+
if(wrapOf(snap)) candidates=periodicCells(index.cells,q,index.size,wrapOf(snap));
|
|
49
|
+
else for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
|
|
36
50
|
candidates.push(...(index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]));
|
|
37
51
|
// Every player in this cell shares the ordered candidates. The final
|
|
38
52
|
// radius check remains exact for that player's position.
|
|
@@ -41,7 +55,7 @@ export function interestSnapshot(snap, seat, radiusM) {
|
|
|
41
55
|
}
|
|
42
56
|
for (const e of candidates) {
|
|
43
57
|
const p=e[3];
|
|
44
|
-
if(e===own||(p
|
|
58
|
+
if(e===own||distanceSq(p,q,wrapOf(snap))<=radiusM**2)visible.push(e);
|
|
45
59
|
}
|
|
46
60
|
}
|
|
47
61
|
}
|
|
@@ -97,9 +111,9 @@ export function snapshotText(snap) {
|
|
|
97
111
|
|
|
98
112
|
/** Whether a body that left `sent` at tick `sentTick` and kept its velocity is within `tolerance` of `entity` now. */
|
|
99
113
|
const POSITION = 1 << 3, VELOCITY = 1 << 4, HEADING = 1 << 5;
|
|
100
|
-
function coasts(entity, sent, ticks, hz, tolerance) {
|
|
114
|
+
function coasts(entity, sent, ticks, hz, tolerance, wrap) {
|
|
101
115
|
const p = entity[3], q = sent[3], v = sent[4], t = ticks / hz;
|
|
102
|
-
for (let i = 0; i < p.length; i++) if (Math.abs(
|
|
116
|
+
for (let i = 0; i < p.length; i++) if (Math.abs(periodicDelta(q[i] + v[i] * t,p[i],wrap?.[axisNames[i]])) > tolerance) return false;
|
|
103
117
|
// A heading is a unit vector: the same tolerance, as a turn of that many metres at one metre's reach.
|
|
104
118
|
const h = entity[5], g = sent[5];
|
|
105
119
|
if (Array.isArray(h) && Array.isArray(g)) for (let i = 0; i < h.length; i++) if (Math.abs(h[i] - g[i]) > COAST_HEADING) return false;
|
|
@@ -136,7 +150,7 @@ export function snapshotEncoder(keyframeTicks, chained = false, coast = null) {
|
|
|
136
150
|
if (!before) { changed.push(entity); continue; }
|
|
137
151
|
const change = changedRow(entity, before);
|
|
138
152
|
// Only where it is, how fast and which way it faces: anything else a body changed is always sent.
|
|
139
|
-
if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM)) continue;
|
|
153
|
+
if (change && coasting && !(change[1] & ~(POSITION | VELOCITY | HEADING)) && coasts(entity, before, snap.k - held.k, coasting.hz, coasting.toleranceM, wrapOf(snap))) continue;
|
|
140
154
|
if (held && chained) { held.entity = entity; held.k = snap.k; }
|
|
141
155
|
if (change) changed.push(change);
|
|
142
156
|
}
|
|
@@ -154,13 +168,13 @@ export function snapshotDecoder() {
|
|
|
154
168
|
// The tick each row was last SENT at: a frame that says `coast` leaves a body out while it keeps its course,
|
|
155
169
|
// and this end moves it along its last velocity. `rows` keeps what was sent; what is returned is where it is now.
|
|
156
170
|
let sentAt = new Map();
|
|
157
|
-
const coasted = (list, snap) => {
|
|
171
|
+
const coasted = (list, snap, collision) => {
|
|
158
172
|
const hz = snap.coast;
|
|
159
173
|
if (!(hz > 0)) return list;
|
|
160
174
|
return list.map(row => {
|
|
161
175
|
const ticks = snap.k - (sentAt.get(row[0]) ?? snap.k), v = row[4];
|
|
162
176
|
if (ticks <= 0 || !Array.isArray(v) || !v.some(n => n !== 0)) return row;
|
|
163
|
-
const moved = row.slice(); moved[3] = row[3].map((n, i) => n + v[i] * ticks / hz);
|
|
177
|
+
const moved = row.slice(); moved[3] = row[3].map((n, i) => wrapValue(n + v[i] * ticks / hz, collision?.[3]?.wrap?.[axisNames[i]]));
|
|
164
178
|
return moved;
|
|
165
179
|
});
|
|
166
180
|
};
|
|
@@ -212,7 +226,7 @@ export function snapshotDecoder() {
|
|
|
212
226
|
for (const id of d.removed) { next.delete(id); if (d.chain) sentAt.delete(id); }
|
|
213
227
|
for (const entity of updates) { next.set(entity[0], entity); if (d.chain) sentAt.set(entity[0], snap.k); }
|
|
214
228
|
for (const e of d.own ?? []) { next.set(e[0], e); if (d.chain) sentAt.set(e[0], snap.k); }
|
|
215
|
-
const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
|
|
229
|
+
const decoded = { ...snap, d: [d.round, coasted([...next.values()], snap, d.collision ?? base.d[2]), ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
|
|
216
230
|
if (d.chain) { base = decoded; rows = next; }
|
|
217
231
|
return decoded;
|
|
218
232
|
},
|
|
@@ -238,7 +252,7 @@ export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, fa
|
|
|
238
252
|
let row = entity, sampledTick = snap.k;
|
|
239
253
|
if (entity !== own) {
|
|
240
254
|
const p = entity[3], q = own?.[3];
|
|
241
|
-
const distanceSquared = q ? (p
|
|
255
|
+
const distanceSquared = q ? distanceSq(p,q,wrapOf(snap)) : 0;
|
|
242
256
|
const old = held?.row;
|
|
243
257
|
const interval = farHz && nearM !== null && distanceSquared > nearM**2 ? Math.max(1, Math.round(tickHz / farHz)) : 1;
|
|
244
258
|
// Spread far-view refreshes across players instead of sending the
|
|
@@ -355,10 +369,11 @@ export function crowdWorlds({ radiusM = null, precisionM = 0 } = {}) {
|
|
|
355
369
|
near(p) {
|
|
356
370
|
if (radiusM === null) return this.everything();
|
|
357
371
|
const x = Math.floor(p[0] / size), y = Math.floor(p[1] / size), z = Math.floor((p[2] ?? 0) / size), key = `${x},${y},${z}`;
|
|
358
|
-
let list = around.get(key);
|
|
372
|
+
let list = wrapOf(snap) ? null : around.get(key);
|
|
359
373
|
if (!list) {
|
|
360
374
|
list = [];
|
|
361
|
-
|
|
375
|
+
if(wrapOf(snap)) list=periodicCells(cells,p,size,wrapOf(snap));
|
|
376
|
+
else for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) { const cell = cells.get(`${x + dx},${y + dy},${z + dz}`); if (cell) for (const i of cell) list.push(i); }
|
|
362
377
|
list.sort((a, b) => a - b); around.set(key, list);
|
|
363
378
|
}
|
|
364
379
|
return list;
|
|
@@ -390,7 +405,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
|
|
|
390
405
|
let far = false;
|
|
391
406
|
if (e !== mine && q) {
|
|
392
407
|
const p = e[3]; let d = 0;
|
|
393
|
-
for (let a = 0; a < p.length; a++) d += (p[a]
|
|
408
|
+
for (let a = 0; a < p.length; a++) d += periodicDelta(q[a],p[a],wrapOf(snap)?.[axisNames[a]]) ** 2;
|
|
394
409
|
if (d > reach) continue;
|
|
395
410
|
far = d > nearSq;
|
|
396
411
|
}
|
|
@@ -407,7 +422,7 @@ export function crowdView(worlds, { nearM = null, farHz = null } = {}, tickHz =
|
|
|
407
422
|
let tolerance = precisionM;
|
|
408
423
|
if (far) { let speed = 0; for (let a = 0; a < v.length; a++) speed += v[a] * v[a]; tolerance = Math.max(precisionM, Math.sqrt(speed) * farShare); }
|
|
409
424
|
let there = true;
|
|
410
|
-
for (let a = 0; a < p.length; a++) if (Math.abs(
|
|
425
|
+
for (let a = 0; a < p.length; a++) if (Math.abs(periodicDelta(b[a]+v[a]*t,p[a],wrapOf(snap)?.[axisNames[a]])) > tolerance) { there = false; break; }
|
|
411
426
|
if (there) {
|
|
412
427
|
const h = row[5], g = before[5];
|
|
413
428
|
if (Array.isArray(h) && Array.isArray(g)) for (let a = 0; a < h.length; a++) if (Math.abs(h[a] - g[a]) > COAST_HEADING) { there = false; break; }
|
package/rules/live.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {wrapPoint} from './topology.mjs';
|
|
1
2
|
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
2
3
|
import { overlapsTerrain } from './terrain.ts';
|
|
3
4
|
import { nearbyMap } from './map-index.ts';
|
|
@@ -10,7 +11,7 @@ import { castMap3, solidAt, type BodyShape } from './collision.ts';
|
|
|
10
11
|
import { sweepMap, rayBox, rayCircle } from './math.ts';
|
|
11
12
|
|
|
12
13
|
export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
|
|
13
|
-
export type CollisionRevision = [number, number, ColliderRow[]];
|
|
14
|
+
export type CollisionRevision = [number, number, ColliderRow[], {wrap?: import('./rules.ts').MapWrap; map?: import('./map-state.ts').MapRevision | null; previousMap?: import('./map-state.ts').MapRevision | null}?];
|
|
14
15
|
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
|
|
15
16
|
const opts = def === true ? {} : def;
|
|
16
17
|
if (opts.enabled && fields[opts.enabled] !== true) return null;
|
|
@@ -44,7 +45,7 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
|
|
|
44
45
|
else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
|
|
45
46
|
else capsules.push({ at, r: w / 2, height: h, id } as any);
|
|
46
47
|
}
|
|
47
|
-
const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
|
|
48
|
+
const combined = { ...(map.wrap?{wrap:map.wrap}:{}), bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
|
|
48
49
|
rowsOf.set(combined, rows);
|
|
49
50
|
return combined;
|
|
50
51
|
}
|
|
@@ -71,7 +72,7 @@ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, d
|
|
|
71
72
|
const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
|
|
72
73
|
if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
|
|
73
74
|
const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
|
|
74
|
-
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;
|
|
75
|
+
return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze(wrapPoint({ ...p, z: p.z - d * h.t },m.wrap)), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
75
76
|
},
|
|
76
77
|
overlaps: (body: unknown) => {
|
|
77
78
|
const p = position(body), s = shape(), a = solidAt(p, s), m = nearbyMap(map(), p, {x:0,y:0,z:0}, s.radius, s.height || 2*s.radius, dims);
|
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,7 @@
|
|
|
1
|
+
import { physicsWorld } from './physics.ts';
|
|
2
|
+
import { substeps } from './substeps.ts';
|
|
3
|
+
import { wrapPoint } from './topology.mjs';
|
|
4
|
+
import { tileApi } from './tiles.ts';
|
|
1
5
|
import { nearbyMap } from './map-index.ts';
|
|
2
6
|
import { collisionQueries } from './live.ts';
|
|
3
7
|
/*
|
|
@@ -184,7 +188,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
|
|
|
184
188
|
/* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
|
|
185
189
|
|
|
186
190
|
/** 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[] }
|
|
191
|
+
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
192
|
/** How close a swept body stops to what it hit, in metres. */
|
|
189
193
|
export const SKIN = 0.001;
|
|
190
194
|
|
|
@@ -278,16 +282,16 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
278
282
|
const h = castMap3(map, p, d, shape);
|
|
279
283
|
const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
|
|
280
284
|
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));
|
|
285
|
+
const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t)), map.wrap);
|
|
282
286
|
put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
|
|
283
287
|
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
284
288
|
}
|
|
285
289
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
286
290
|
put(body, 'grounded', true);
|
|
287
|
-
if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
|
|
291
|
+
if (!h) { put(body, 'pos', wrapPoint(v(fr(px + dx), fr(py + dy), 0), map.wrap)); return undefined; }
|
|
288
292
|
const l = Math.sqrt(dx * dx + dy * dy);
|
|
289
293
|
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);
|
|
294
|
+
const at = wrapPoint(v(fr(px + dx * t), fr(py + dy * t), 0), map.wrap);
|
|
291
295
|
put(body, 'pos', at);
|
|
292
296
|
return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
|
|
293
297
|
}
|
|
@@ -297,14 +301,22 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
297
301
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
298
302
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
299
303
|
*/
|
|
304
|
+
const physicsContexts = new WeakMap<object, () => void>();
|
|
305
|
+
export function disposeMoveContext(context: object): void { physicsContexts.get(context)?.(); physicsContexts.delete(context); }
|
|
300
306
|
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 {
|
|
307
|
+
const physics = physicsWorld((o.map as import('./rules.ts').GameMap).physics);
|
|
301
308
|
const none = Object.freeze([]);
|
|
309
|
+
const currentMap = () => (o.geometry?.()?.staticMap ?? o.geometry?.() ?? o.map) as import('./rules.ts').GameMap;
|
|
302
310
|
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
|
|
303
|
-
|
|
311
|
+
const context = brand(Object.freeze({
|
|
312
|
+
...(physics ? {physics: physics.movement} : {}),
|
|
313
|
+
substeps: substeps(o.tickHz),
|
|
304
314
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
305
315
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
306
316
|
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
317
|
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 })),
|
|
318
|
+
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
319
|
}));
|
|
320
|
+
if (physics) physicsContexts.set(context, physics.dispose);
|
|
321
|
+
return context;
|
|
310
322
|
}
|
|
@@ -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/pack.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { takeSubsteps, type StepPose } from './substeps.ts';
|
|
1
2
|
/*
|
|
2
3
|
* pack.ts — declared state in and out: holding a value to its declared type, and packing it for the wire and the save.
|
|
3
4
|
* =============================================================================
|
|
@@ -262,7 +263,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
|
|
|
262
263
|
* left rounded to 32-bit floats and held to the declared shapes as the server does it, so both hold the same numbers.
|
|
263
264
|
* `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
|
|
264
265
|
*/
|
|
265
|
-
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
266
|
+
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number; steps?: readonly StepPose[] } {
|
|
266
267
|
G.queryEpoch++; G.left = quota;
|
|
267
268
|
const bag = coerceFields(motion, body.motion, dims);
|
|
268
269
|
const target: Record<string, unknown> = {};
|
|
@@ -296,7 +297,8 @@ export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => v
|
|
|
296
297
|
const used = quota - (G.left > 0 ? G.left : 0);
|
|
297
298
|
G.left = Infinity;
|
|
298
299
|
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
|
|
299
|
-
|
|
300
|
+
const steps = takeSubsteps(body);
|
|
301
|
+
return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used, ...(steps ? {steps} : {}) };
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
/** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Loaded only by games declaring a baked physics level. */
|
|
2
|
+
import { createWorld } from '@homie-rocks/physics/World.js';
|
|
3
|
+
import { initPhysics } from '@homie-rocks/physics/Worker.js';
|
|
4
|
+
import { brand, charge } from './guard.ts';
|
|
5
|
+
import { registerPhysics, type PhysicsMovement, type RulesPhysics } from './physics.ts';
|
|
6
|
+
|
|
7
|
+
// CPU calibration is recorded in docs/physics-048-calibration.md. Charges precede work.
|
|
8
|
+
export const PHYSICS_UNITS = Object.freeze({ cast: 768, ray: 640, overlap: 640, step: 2400 });
|
|
9
|
+
export async function initializePhysics(source: WebAssembly.Module | BufferSource): Promise<void> {
|
|
10
|
+
await initPhysics(source);
|
|
11
|
+
registerPhysics((mesh, saved) => {
|
|
12
|
+
const world = createWorld({ up: 'z', fixedDt: 1 / 300, maxSubsteps: 128, bounds: null });
|
|
13
|
+
try {
|
|
14
|
+
if (saved) world.restore(Uint8Array.from(saved));
|
|
15
|
+
else world.createBody({ type: 'static', colliders: [{ shape: { kind: 'mesh', vertices: Float32Array.from(mesh.vertices), indices: Uint32Array.from(mesh.indices) } }] });
|
|
16
|
+
const count = Math.max(1, Math.ceil(mesh.indices.length / (1754 * 3)));
|
|
17
|
+
const convex = (shape: {kind:string}) => { if (!['box','sphere','capsule'].includes(shape.kind)) throw new Error('rules physics queries use a box, sphere or capsule'); };
|
|
18
|
+
const movement: PhysicsMovement = {
|
|
19
|
+
cast(...args) { charge(PHYSICS_UNITS.cast * count); convex(args[0]); return world.castShape(...args); },
|
|
20
|
+
ray(...args) { charge(PHYSICS_UNITS.ray * count); return world.raycast(...args); },
|
|
21
|
+
overlap(...args) { charge(PHYSICS_UNITS.overlap * count); convex(args[0]); return world.overlaps(...args); },
|
|
22
|
+
sweep(body, delta, radius, height) {
|
|
23
|
+
charge(64);
|
|
24
|
+
let left = { ...delta }, pos = { ...body.pos }, vel = { ...body.vel };
|
|
25
|
+
let grounded = false;
|
|
26
|
+
for (let n = 0; n < 4; n++) {
|
|
27
|
+
const length = Math.hypot(left.x, left.y, left.z);
|
|
28
|
+
if (length < 1e-9) break;
|
|
29
|
+
charge(PHYSICS_UNITS.cast * count);
|
|
30
|
+
const hit = world.castCapsule(pos, left, radius, height);
|
|
31
|
+
const fraction = hit ? Math.max(0, hit.fraction) : 1;
|
|
32
|
+
pos = { x: pos.x + left.x * fraction, y: pos.y + left.y * fraction, z: pos.z + left.z * fraction };
|
|
33
|
+
if (!hit) break;
|
|
34
|
+
if (hit.normal.z > .65 && delta.z <= 0) grounded = true;
|
|
35
|
+
const normal = hit.normal;
|
|
36
|
+
const dot = left.x * normal.x + left.y * normal.y + left.z * normal.z;
|
|
37
|
+
const speed = vel.x * normal.x + vel.y * normal.y + vel.z * normal.z;
|
|
38
|
+
if (normal.z > .65 && delta.z <= 0) {
|
|
39
|
+
// Lift the requested horizontal travel along a walkable slope.
|
|
40
|
+
// Projecting gravity onto its normal would make an idle capsule drift.
|
|
41
|
+
left = { x: left.x * (1-fraction), y: left.y * (1-fraction), z: 0 };
|
|
42
|
+
left.z = -(left.x * normal.x + left.y * normal.y) / normal.z;
|
|
43
|
+
vel = { x: vel.x, y: vel.y, z: Math.max(0, vel.z) };
|
|
44
|
+
} else {
|
|
45
|
+
left = { x: (left.x-normal.x*Math.min(0,dot))*(1-fraction), y: (left.y-normal.y*Math.min(0,dot))*(1-fraction), z: (left.z-normal.z*Math.min(0,dot))*(1-fraction) };
|
|
46
|
+
vel = { x:vel.x-normal.x*Math.min(0,speed), y:vel.y-normal.y*Math.min(0,speed), z:vel.z-normal.z*Math.min(0,speed) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
body.pos = pos; body.vel = vel; body.grounded = grounded;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const api: RulesPhysics = {
|
|
53
|
+
...movement,
|
|
54
|
+
step(dt) {
|
|
55
|
+
charge(8);
|
|
56
|
+
if (!Number.isFinite(dt) || dt < 0) throw new Error('physics: dt must be finite and nonnegative');
|
|
57
|
+
charge(PHYSICS_UNITS.step * count * Math.max(1, Math.ceil(dt * 300)));
|
|
58
|
+
while (dt > .1) { world.step(.1); dt -= .1; }
|
|
59
|
+
world.step(dt);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
let disposed = false, finalSave: number[] | undefined;
|
|
63
|
+
return { api: brand(Object.freeze(api)), movement: brand(Object.freeze(movement)),
|
|
64
|
+
save: () => finalSave ? [...finalSave] : Array.from(world.snapshot()),
|
|
65
|
+
dispose: () => {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
try { finalSave = Array.from(world.snapshot()); } catch { /* A failed engine has no final checkpoint. */ }
|
|
68
|
+
world.dispose(); disposed = true;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} catch (error) { world.dispose(); throw error; }
|
|
72
|
+
});
|
|
73
|
+
}
|
package/rules/physics.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Opt-in boundary. Importing the rules kernel never imports or initializes WASM. */
|
|
2
|
+
import type { Vec3 } from './rules.ts';
|
|
3
|
+
export interface PhysicsMesh { vertices: readonly number[]; indices: readonly number[] }
|
|
4
|
+
// Structural query types keep the ordinary rules types independent of the optional package.
|
|
5
|
+
export type PhysicsShape = {kind: 'box'; halfExtents: Vec3} | {kind: 'sphere'; radius: number} | {kind: 'capsule'; radius: number; halfHeight: number};
|
|
6
|
+
export interface PhysicsRotation {x: number; y: number; z: number; w: number}
|
|
7
|
+
export interface PhysicsFilter {layers?: {membership: number; filter: number}; excludeBody?: number; excludeCollider?: number; sensors?: boolean}
|
|
8
|
+
export interface PhysicsHit {collider: number; body: number; point: Vec3; normal: Vec3}
|
|
9
|
+
export interface PhysicsMovement {
|
|
10
|
+
sweep(body: {pos: Vec3; vel: Vec3; grounded: boolean}, delta: Vec3, radius: number, height: number): void;
|
|
11
|
+
ray(origin: Vec3, direction: Vec3, distance: number, filter?: PhysicsFilter): (PhysicsHit & {distance: number}) | null;
|
|
12
|
+
overlap(shape: PhysicsShape, position: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): number[];
|
|
13
|
+
cast(shape: PhysicsShape, position: Vec3, delta: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): (PhysicsHit & {fraction: number}) | null;
|
|
14
|
+
}
|
|
15
|
+
export interface RulesPhysics extends PhysicsMovement {
|
|
16
|
+
step(seconds: number): void;
|
|
17
|
+
}
|
|
18
|
+
export interface PhysicsBridge { api: RulesPhysics; movement: PhysicsMovement; save(): number[]; dispose(): void }
|
|
19
|
+
let factory: ((mesh: PhysicsMesh, saved?: number[]) => PhysicsBridge) | undefined;
|
|
20
|
+
export function registerPhysics(value: NonNullable<typeof factory>): void { factory = value; }
|
|
21
|
+
export function physicsWorld(mesh?: PhysicsMesh, saved?: number[]): PhysicsBridge | undefined {
|
|
22
|
+
if (!mesh) { if (saved !== undefined) throw new Error('physics save requires a physics level'); return; }
|
|
23
|
+
if (saved !== undefined && (!Array.isArray(saved) || saved.some(n => !Number.isInteger(n) || n < 0 || n > 255))) throw new Error('physics: invalid saved bytes');
|
|
24
|
+
if (!factory) throw new Error('physics: initialize the game before starting its room');
|
|
25
|
+
return factory(mesh, saved);
|
|
26
|
+
}
|
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 physics?: import('./physics.ts').PhysicsMesh; 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,33 @@ 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`);
|
|
343
|
+
let physics: import('./physics.ts').PhysicsMesh | undefined;
|
|
344
|
+
if (m.physics !== undefined) {
|
|
345
|
+
const {vertices, indices} = m.physics ?? {};
|
|
346
|
+
if (!Array.isArray(vertices) || !Array.isArray(indices) || !vertices.length || vertices.length % 3 || !indices.length || indices.length % 3 ||
|
|
347
|
+
vertices.some((n: unknown) => typeof n !== 'number' || !Number.isFinite(n) || !Number.isFinite(Math.fround(n))) ||
|
|
348
|
+
indices.some((n: unknown) => !Number.isSafeInteger(n) || (n as number) < 0 || (n as number) >= vertices.length / 3)) throw new Error(`${where}: physics needs a build-time baked triangle mesh`);
|
|
349
|
+
physics = Object.freeze({vertices:Object.freeze([...vertices]),indices:Object.freeze([...indices])});
|
|
350
|
+
}
|
|
326
351
|
const result = Object.freeze({
|
|
327
|
-
|
|
352
|
+
...(m.physics ? {physics: physics!} : {}),
|
|
353
|
+
...(tiled?{tiles:tiled.tiles}:{}),
|
|
354
|
+
name, ...(wrap ? {wrap} : {}),
|
|
328
355
|
heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
|
|
329
356
|
const what = `${where} heightTiles[${i}]`;
|
|
330
357
|
const at = vecOf(t?.at, `${what}.at`);
|
|
@@ -342,8 +369,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
342
369
|
if (!Number.isFinite(c.height) || c.height < 2 * shape.r) throw new Error(`${where} capsules[${i}]: height is at least twice the radius`);
|
|
343
370
|
return Object.freeze({ ...shape, height: c.height });
|
|
344
371
|
})),
|
|
345
|
-
bounds
|
|
346
|
-
boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
372
|
+
bounds,
|
|
373
|
+
boxes: Object.freeze([...(tiled?.boxes??[]),...(Array.isArray(m.boxes) ? m.boxes : [])].map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
347
374
|
circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
|
|
348
375
|
const r = Number(c?.r);
|
|
349
376
|
if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
|