@homie-rocks/studio 0.44.0 → 0.44.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rules/math.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { nearbyMap } from './map-index.ts';
2
+ import { collisionQueries } from './live.ts';
1
3
  /*
2
4
  * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
3
5
  * =============================================================================
@@ -182,7 +184,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
182
184
  /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
183
185
 
184
186
  /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
185
- export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
187
+ export interface MapShapes { staticMap?: MapShapes; bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
186
188
  /** How close a swept body stops to what it hit, in metres. */
187
189
  export const SKIN = 0.001;
188
190
 
@@ -204,7 +206,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
204
206
  return { t, nx: hx / l, ny: hy / l };
205
207
  }
206
208
  /** The same point against a box grown by r with round corners (a circle of radius r against the box). */
207
- function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
209
+ export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
208
210
  let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
209
211
  const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
210
212
  const p = [px, py]; const d = [dx, dy];
@@ -236,9 +238,11 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
236
238
  }
237
239
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
238
240
  export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
241
+ map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
242
+ charge(16 + 4 * (map.boxes.length + map.circles.length));
239
243
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
240
- for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = h; }
241
- for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = h; }
244
+ for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
245
+ for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
242
246
  return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
243
247
  }
244
248
 
@@ -249,7 +253,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
249
253
  */
250
254
  export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
251
255
  // Charged before the cast, for every shape of the map it may test.
252
- charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
256
+ charge(20);
253
257
  const fr = Math.fround;
254
258
  // Both are whatever `move` handed over: each is read as a plain number or as nothing, and nothing is called to convert it.
255
259
  const num = (a: unknown): number => { const x = typeof a === 'number' ? fr(a) : 0; return Number.isFinite(x) ? x : 0; };
@@ -264,7 +268,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
264
268
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
265
269
  const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
266
270
  put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
267
- return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
271
+ return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
268
272
  }
269
273
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
270
274
  put(body, 'grounded', true);
@@ -273,7 +277,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
273
277
  const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
274
278
  const at = v(fr(px + dx * t), fr(py + dy * t), 0);
275
279
  put(body, 'pos', at);
276
- return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
280
+ return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
277
281
  }
278
282
 
279
283
  /**
@@ -281,12 +285,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
281
285
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
282
286
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
283
287
  */
284
- export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number }): unknown {
288
+ export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes }): unknown {
285
289
  const none = Object.freeze([]);
290
+ const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
286
291
  return brand(Object.freeze({
287
292
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
288
293
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
289
294
  ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
290
- 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, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims, o.shape?.()) })),
295
+ world: brand(Object.freeze(queries)),
296
+ map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
291
297
  }));
292
298
  }
package/rules/rules.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { prepareTile } from './terrain.ts';
2
+ import { indexMap } from './map-index.ts';
1
3
  /*
2
4
  * rules.ts — the rules contract, version 2: what a rules module declares, and how the runtime reads it.
3
5
  * =============================================================================
@@ -123,6 +125,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
123
125
  export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
124
126
  export type MoveFn = (body: any, input: any, ctx: any) => void;
125
127
 
128
+ export type ColliderDef = true | { size?: string; enabled?: string };
126
129
  export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
127
130
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
128
131
  export interface EntityDef {
@@ -131,6 +134,7 @@ export interface EntityDef {
131
134
  motion?: Fields;
132
135
  input?: Fields;
133
136
  body?: BodyDef;
137
+ collider?: ColliderDef;
134
138
  guide?: GuideDef;
135
139
  tick?: Handler;
136
140
  think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
@@ -244,7 +248,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
244
248
 
245
249
  export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
246
250
  export interface MapCircle { readonly at: Vec3; readonly r: number }
247
- export interface MapHeightTile { readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
251
+ export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
248
252
  export interface GameMap { readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
249
253
 
250
254
  const vecOf = (a: unknown, what: string): Vec3 => {
@@ -278,15 +282,19 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
278
282
  return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
279
283
  };
280
284
  if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].some(list => list !== undefined && !Array.isArray(list))) throw new Error(`${where}: shapes are lists`);
281
- if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 4096) throw new Error(`${where}: at most 4096 static shapes`);
282
- return Object.freeze({
285
+ if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 100000) throw new Error(`${where}: at most 100000 static shapes`);
286
+ const result = Object.freeze({
283
287
  name,
284
288
  heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
285
289
  const what = `${where} heightTiles[${i}]`;
286
290
  const at = vecOf(t?.at, `${what}.at`);
287
291
  if (!Array.isArray(t.size) || t.size.length !== 2 || t.size.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v) || v <= 0)) throw new Error(`${what}: size is two positive finite lengths`);
288
292
  if (!Array.isArray(t.heights) || t.heights.length !== 4 || t.heights.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v))) throw new Error(`${what}: heights are four finite offsets, in row order`);
289
- return Object.freeze({ at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
293
+ if (t.base !== undefined && (typeof t.base !== 'number' || !Number.isFinite(t.base) || t.heights.some((h:number) => h < t.base))) throw new Error(`${what}: base must be finite and at or below every top sample`);
294
+ if (t.diagonal !== undefined && !['00-11','10-01'].includes(t.diagonal)) throw new Error(`${what}: diagonal is 00-11 or 10-01`);
295
+ const tile = Object.freeze({ ...(t.base !== undefined ? {base:t.base} : {}), ...(t.diagonal ? {diagonal:t.diagonal} : {}), at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
296
+ prepareTile(tile);
297
+ return tile;
290
298
  })),
291
299
  spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
292
300
  capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
@@ -303,6 +311,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
303
311
  })),
304
312
  spots: Object.freeze(spots),
305
313
  });
314
+ indexMap(result);
315
+ return result;
306
316
  }
307
317
 
308
318
  /* ------------------------------------------------------------------ compiled tables */
@@ -315,6 +325,7 @@ export interface KindTable {
315
325
  fields: FieldList;
316
326
  motion: FieldList;
317
327
  input: FieldList;
328
+ collider?: ColliderDef;
318
329
  body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
319
330
  score: string | null;
320
331
  tick: Handler | null;
@@ -521,11 +532,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
521
532
  if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
522
533
  const cells = cellsOfList(fields) + cellsOfList(motion);
523
534
  if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
524
- const k: KindTable = { name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
535
+ if (e.collider !== undefined) {
536
+ if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
537
+ if (e.collider !== true) {
538
+ if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
539
+ for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
540
+ const name = (e.collider as Record<string, unknown>)[key];
541
+ if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
542
+ }
543
+ }
544
+ }
545
+ const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
525
546
  kinds.push(k);
526
547
  kindOf[name] = k;
527
548
  }
528
549
 
550
+ if (kinds.some(k => k.collider) && kinds.some(k => k.player && k.body?.owner)) throw new Error("live colliders need server movement: omit body.move: 'owner' on players");
529
551
  const room = d.room ?? {};
530
552
  const roomOn = handlers<RoomHandler>(room.on, 'room.on');
531
553
  for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
@@ -0,0 +1,93 @@
1
+ /** Solid triangulated height tiles. Each source triangle is a closed vertical
2
+ * prism; their union preserves noncoplanar samples without a convex-hull fill. */
3
+ import { charge } from './guard.ts';
4
+ import type { Vec3, MapHeightTile } from './rules.ts';
5
+ import type { Solid, Hit3 } from './collision.ts';
6
+ type Plane = { n: Vec3; c: number };
7
+ type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
8
+ const cache = new WeakMap<MapHeightTile, Prism[]>();
9
+ const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
10
+ const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
11
+ const dot=(a:Vec3,b:Vec3)=>a.x*b.x+a.y*b.y+a.z*b.z;
12
+ const cross=(a:Vec3,b:Vec3):Vec3=>({x:a.y*b.z-a.z*b.y,y:a.z*b.x-a.x*b.z,z:a.x*b.y-a.y*b.x});
13
+ const clamp=(x:number)=>Math.max(0,Math.min(1,x));
14
+ export function prepareTile(t:MapHeightTile):void {
15
+ if(t.base===undefined)return;
16
+ const top=t.heights.map((h,i)=>({x:t.at.x+(i%2)*t.size.x,y:t.at.y+(i>>1)*t.size.y,z:t.at.z+h}));
17
+ const tris=t.diagonal==='10-01'?[[0,1,2],[1,3,2]]:[[0,1,3],[0,3,2]];
18
+ const prisms:Prism[]=[];
19
+ for(const tri of tris){
20
+ const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
21
+ if(upper.every(v=>v.z===lower[0].z))continue;
22
+ const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
23
+ for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
24
+ const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
25
+ for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
26
+ prisms.push({vertices,faces:valid,planes,edges});
27
+ }
28
+ cache.set(t,prisms);
29
+ }
30
+ // Closest point on a triangle, including vertex and edge Voronoi regions.
31
+ function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
32
+ const ab=sub(b,a),ac=sub(c,a),ap=sub(p,a),d1=dot(ab,ap),d2=dot(ac,ap);
33
+ if(d1<=0&&d2<=0)return a;
34
+ const bp=sub(p,b),d3=dot(ab,bp),d4=dot(ac,bp);if(d3>=0&&d4<=d3)return b;
35
+ const vc=d1*d4-d3*d2;if(vc<=0&&d1>=0&&d3<=0)return add(a,ab,d1/(d1-d3));
36
+ const cp=sub(p,c),d5=dot(ab,cp),d6=dot(ac,cp);if(d6>=0&&d5<=d6)return c;
37
+ const vb=d5*d2-d1*d6;if(vb<=0&&d2>=0&&d6<=0)return add(a,ac,d2/(d2-d6));
38
+ const va=d3*d6-d5*d4;if(va<=0&&d4-d3>=0&&d5-d6>=0)return add(b,sub(c,b),(d4-d3)/(d4-d3+d5-d6));
39
+ const den=1/(va+vb+vc);return add(add(a,ab,vb*den),ac,vc*den);
40
+ }
41
+ function segmentPair(a:Vec3,b:Vec3,c:Vec3,d:Vec3):Vec3 {
42
+ const u=sub(b,a),v=sub(d,c),w=sub(a,c),aa=dot(u,u),bb=dot(u,v),cc=dot(v,v),dd=dot(u,w),ee=dot(v,w);
43
+ let s=aa?clamp((bb*ee-cc*dd)/(aa*cc-bb*bb)||0):0;
44
+ let t=cc?clamp((bb*s+ee)/cc):0;
45
+ s=aa?clamp((bb*t-dd)/aa):0;t=cc?clamp((bb*s+ee)/cc):0;
46
+ return sub(add(a,u,s),add(c,v,t));
47
+ }
48
+ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
49
+ let near=0,far=1,n={x:0,y:0,z:0};
50
+ for(const plane of prism.planes){charge(8);const gap=plane.c-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-9)return null;continue;}const t=gap/speed;if(speed<0){if(t>=near){near=t;n=plane.n;}}else far=Math.min(far,t);if(near>far+1e-9)return null;}
51
+ return near>=0&&near<=1&&far>=0?{t:near,nx:n.x,ny:n.y,nz:n.z}:null;
52
+ }
53
+ function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
54
+ if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
55
+ let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
56
+ const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
57
+ for(const [x,y,z] of prism.faces){charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
58
+ return best;
59
+ }
60
+ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
61
+ const axes:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
62
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
63
+ let near=0,far=1,normal={x:0,y:0,z:0};
64
+ for(const axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
65
+ for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
66
+ let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
67
+ const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
68
+ let enter=(lo-amax)/speed,exit=(hi-amin)/speed;if(enter>exit)[enter,exit]=[exit,enter];
69
+ if(enter>=near){near=enter;normal={x:n.x*(speed>0?-1:1),y:n.y*(speed>0?-1:1),z:n.z*(speed>0?-1:1)};}far=Math.min(far,exit);if(near>far)return null;
70
+ }
71
+ return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
72
+ }
73
+ export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
74
+ charge(12);
75
+ let best:Hit3|null=null;
76
+ for(const prism of cache.get(tile)??[]){
77
+ let hit:Hit3|null=null;
78
+ if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
79
+ else {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
80
+ if(hit&&(!best||hit.t<best.t))best=hit;
81
+ }return best;
82
+ }
83
+ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
84
+ for(const prism of cache.get(tile)??[]){
85
+ if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
86
+ else {
87
+ // SAT overlap uses the same face, edge and box axes as continuous casts.
88
+ const axes=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
89
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
90
+ let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
91
+ }
92
+ }return false;
93
+ }
package/rules/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The build specialises these faces from a game's declarations; the runtime still checks every write. */
2
- import type { Vec3, BodyDef, Fields } from './rules.ts';
2
+ import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
3
3
  import type { math } from './math.ts';
4
4
 
5
5
  export type Point = { readonly x: number; readonly y: number; readonly z?: number };
@@ -51,7 +51,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
51
51
  export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
52
52
  type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
53
  export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
54
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
54
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
55
55
  tick?: (world: W, self: S) => void;
56
56
  think?: (world: W, self: S) => Partial<I>;
57
57
  on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
@@ -60,4 +60,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
60
60
  guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
61
61
  };
62
62
  export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
63
- export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & { sweep(body: MoveBody<M>, delta: Point): Hit | undefined } };
63
+ export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
64
+ export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
package/rules/view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { snapshotDecoder } from './interest.mjs';
2
+ import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
2
3
  import { rulesOutput } from '../netplay/rules-output.mjs';
3
4
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
4
5
  /*
@@ -127,7 +128,7 @@ export interface Room<R = unknown> {
127
128
  readonly __rules?: R;
128
129
  }
129
130
 
130
- interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][] }
131
+ interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][]; collision?: CollisionRevision }
131
132
  const frames = new WeakMap<object, Frame | null>();
132
133
  const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
133
134
 
@@ -168,11 +169,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
168
169
  if (!s || typeof s !== 'object') return null;
169
170
  if (frames.has(s)) return frames.get(s) ?? null;
170
171
  let f: Frame | null = null;
171
- const d = delivery.decode(s)?.d as [unknown, unknown] | null;
172
+ const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
172
173
  if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
173
174
  const ents = new Map<string, Unpacked>();
174
175
  for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
175
- f = { k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
176
+ f = { collision: d[2], k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
176
177
  }
177
178
  frames.set(s, f);
178
179
  return f;
@@ -249,7 +250,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
249
250
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
250
251
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
251
252
  let moveTick = 0;
252
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
253
+ let collisionHistory: CollisionRevision[] = [];
254
+ let cachedCollision: CollisionRevision | undefined;
255
+ let geometry = map as import('./math.ts').MapShapes;
256
+ const geometryAt = () => {
257
+ const revision = revisionAt(collisionHistory, moveTick);
258
+ if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
259
+ return geometry;
260
+ };
261
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
253
262
  function authorityRtt(): number {
254
263
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
255
264
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -299,6 +308,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
299
308
  const fn = moves[kindName];
300
309
  if (!fn) return body;
301
310
  moveTick = t;
311
+ // Like snapshot unpacking, prepare the bounded geometry outside the handler
312
+ // quota. Authority debits projection once per tick, not once per mover.
313
+ geometryAt();
302
314
  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); });
303
315
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
304
316
  const height = kind?.height || 2 * r;
@@ -387,6 +399,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
387
399
  const before = latest;
388
400
  if (before && before.e === f.e && f.k <= before.k) return;
389
401
  latest = f;
402
+ if (!before || before.e !== f.e) collisionHistory = [];
403
+ const collision = f.collision;
404
+ if (collision) {
405
+ collisionHistory.push(collision);
406
+ if (collisionHistory.length > 128) collisionHistory.shift();
407
+ }
390
408
  if (before && before.e !== f.e) hostAges.length = 0;
391
409
  hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
392
410
  if (before && before.e !== f.e) fxQueue.length = 0;
@@ -4,16 +4,20 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
4
4
 
5
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
6
6
 
7
+ // Solid slopes use the same contact tangent as walls; the capsule keeps its full
8
+ // clearance. Never replace this sweep with an unchecked height assignment.
7
9
  function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
8
- const before = body.pos, hit = ctx.map.sweep(body, delta);
10
+ const before = body.pos, hit = ctx.world.sweep(body, delta);
9
11
  if (!hit) return;
10
12
  const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
11
13
  const into = M.dot(remaining, hit.normal);
12
- if (into < 0) ctx.map.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
14
+ if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
13
15
  }
14
16
 
15
17
  export const move = defineMove({
16
18
  runner(body, input, ctx) {
19
+ // Recheck support before jumping: a platform/cover may have moved or broken.
20
+ body.grounded = Boolean(ctx.world.support(body));
17
21
  const M = ctx.math, m = body.motion, T = ctx.tune;
18
22
  const dt = ctx.dt, knocked = m.knockAt > 0;
19
23
  const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
@@ -32,7 +32,7 @@ export async function quoteCart(env,cat,origin,caller,input,request) {
32
32
  session = guest.session;
33
33
  caller = {...caller,id:session.player.id};
34
34
  }
35
- const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:`Studio refund window: ${shop.refundDays} days.`})});
35
+ const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:Number.isFinite(shop.refundDays)?`Studio refund window: ${shop.refundDays} days.`:undefined})});
36
36
  return {...quote,...(session?{account:{player:session.player,cookies:session.cookies}}:{})};
37
37
  }
38
38
  export async function fulfillCart(env,origin,order) {
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.44.0';
2
+ export const STUDIO_VERSION_TAG = '0.44.3';