@homie-rocks/studio 0.44.1 → 0.45.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/rules/core.ts CHANGED
@@ -245,7 +245,6 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
245
245
  let stateHeavy = false;
246
246
  const viewCost = new Map<KindTable, number>();
247
247
  for (const k of c.kinds) viewCost.set(k, VIEW + k.fields.length + k.motion.length);
248
- const mapShapes = 4 + c.map.boxes.length + c.map.circles.length;
249
248
 
250
249
  const ticks = (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; };
251
250
  const V = (v: unknown): Vec3 => vec3(v, dims);
@@ -611,7 +610,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
611
610
  },
612
611
  ray: (from: unknown, direction: unknown, max: unknown): unknown => {
613
612
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
614
- charge(20 + 4 * (mapShapes + ents.size));
613
+ charge(20 + 4 * ents.size);
615
614
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
616
615
  if (dims === 3) {
617
616
  const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
@@ -633,7 +632,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
633
632
  },
634
633
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
635
634
  const e = mine(self, 'world.sweep');
636
- charge(20 + 4 * (mapShapes + ents.size));
635
+ charge(20 + 4 * ents.size);
637
636
  const d = V(delta);
638
637
  const radius = e.kind.body?.radius ?? 0;
639
638
  // The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
@@ -1007,7 +1006,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1007
1006
  }
1008
1007
  guides?.();
1009
1008
  // All movers see the same world, independent of entity iteration order.
1010
- if (hasColliders) { left -= 64 * ents.size + 4 * mapShapes; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1009
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1011
1010
  // Phase 1: one input step and `move` for every body.
1012
1011
  for (const e of [...ents.values()]) {
1013
1012
  if (e.dead || !e.kind.body) continue;
package/rules/host.ts CHANGED
@@ -100,6 +100,7 @@ export interface Host {
100
100
  readonly paused: boolean;
101
101
  readonly people: number;
102
102
  readonly viewRadiusM: number | null;
103
+ readonly viewSettings: Compiled['settings']['view'];
103
104
  frame(m: Record<string, unknown>): void;
104
105
  /** Start the tick loop (the first person has joined). */
105
106
  start(): void;
@@ -768,6 +769,7 @@ export function createHost(o: HostOptions): Host {
768
769
  }
769
770
 
770
771
  return {
772
+ get viewSettings() { return c.settings.view; },
771
773
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
772
774
  get tick() { return core.tick; },
773
775
  get epoch() { return epoch; },
@@ -1,41 +1,77 @@
1
1
  /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
- * Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
3
- * therefore loses no baseline. A new keyframe bounds recovery to one second.
2
+ * Deltas may name a periodic keyframe or the previous ordered frame. A missing
3
+ * chained predecessor is ignored until the next keyframe (at most one second).
4
4
  * No history grows with time: one keyframe per connection, no per-frame queue.
5
5
  */
6
+ const spatial = new WeakMap(), controls = new WeakMap();
7
+ function indexFor(snap, radius) {
8
+ let byRadius = spatial.get(snap.d[1]);
9
+ if (!byRadius) { byRadius = new Map(); spatial.set(snap.d[1], byRadius); }
10
+ if (byRadius.has(radius)) return byRadius.get(radius);
11
+ const own = new Map(), cells = new Map(), order = new Map();
12
+ const size = radius > 0 ? radius : 1;
13
+ for (const [i, e] of snap.d[1].entries()) {
14
+ order.set(e, i);
15
+ if (e[9] !== undefined && e[10] !== 1) own.set(e[9], e);
16
+ const p = e[3], key = `${Math.floor(p[0]/size)},${Math.floor(p[1]/size)},${Math.floor((p[2]??0)/size)}`;
17
+ const list = cells.get(key) ?? []; list.push(e); cells.set(key, list);
18
+ }
19
+ const index = { own, cells, size, order }; byRadius.set(radius,index); return index;
20
+ }
6
21
  export function interestSnapshot(snap, seat, radiusM) {
7
22
  const [round, entities] = snap.d;
8
- const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
9
- const visible = own && radiusM !== null ? entities.filter(e => {
10
- const p = e[3], q = own[3];
11
- return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
- }) : seat === null ? entities : own ? entities : [];
13
- // Collision revisions are prediction inputs, independent of visual interest.
14
- // Deliver all geometry: authored sweeps/dashes need not fit inside the view radius.
15
- return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: (snap.c ?? []).filter(row => row[0] === seat) };
23
+ let visible = entities;
24
+ if (seat !== null) {
25
+ const index = indexFor(snap, radiusM), own = index.own.get(seat);
26
+ visible = [];
27
+ if (own && radiusM === null) visible = entities;
28
+ else if (own) {
29
+ 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);
30
+ for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(let dz=-1;dz<=1;dz++)
31
+ for(const e of index.cells.get(`${x+dx},${y+dy},${z+dz}`)??[]) {
32
+ const p=e[3];
33
+ if(e===own||(p[0]-q[0])**2+(p[1]-q[1])**2+((p[2]??0)-(q[2]??0))**2<=radiusM**2)visible.push(e);
34
+ }
35
+ // Snapshot order is part of the view contract, even though cells are not.
36
+ visible.sort((a,b)=>index.order.get(a)-index.order.get(b));
37
+ }
38
+ }
39
+ let control = snap.c && controls.get(snap.c);
40
+ if (!control) { control = new Map((snap.c ?? []).map(row => [row[0], row])); if (snap.c) controls.set(snap.c, control); }
41
+ return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: control.has(seat) ? [control.get(seat)] : [] };
16
42
  }
43
+ const encodedRows = new WeakMap();
44
+ const rowText = row => {
45
+ let text=encodedRows.get(row);
46
+ if(!text){text=row.map(v=>JSON.stringify(v));encodedRows.set(row,text);}
47
+ return text;
48
+ };
17
49
 
18
- export function snapshotEncoder(keyframeTicks) {
50
+ export function snapshotEncoder(keyframeTicks, chained = false) {
19
51
  let base = null;
20
- let rows = new Map();
52
+ let rows = new Map(), keyTick = -Infinity;
21
53
  return {
22
- reset() { base = null; rows.clear(); },
23
- encode(snap) {
24
- if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
25
- base = snap;
26
- rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
54
+ reset() { base = null; rows.clear(); keyTick = -Infinity; },
55
+ encode(snap, seat = null) {
56
+ if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - keyTick >= keyframeTicks) {
57
+ base = snap; keyTick = snap.k;
58
+ rows = new Map(snap.d[1].map(e => [e[0], rowText(e)]));
27
59
  return snap;
28
60
  }
29
61
  const present = new Set(), changed = [];
62
+ const own = chained && seat !== null ? snap.d[1].filter(e => e[9] === seat && e[10] !== 1) : null;
30
63
  for (const entity of snap.d[1]) {
31
64
  const id = entity[0]; present.add(id);
65
+ if (own?.includes(entity)) continue;
32
66
  const before = rows.get(id);
33
67
  if (!before) { changed.push(entity); continue; }
34
68
  let mask = 0; const values = [];
35
- for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
69
+ for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
36
70
  if (mask) changed.push([id, mask, values]);
37
71
  }
38
- return { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
72
+ const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
73
+ if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
74
+ return out;
39
75
  },
40
76
  };
41
77
  }
@@ -57,8 +93,18 @@ export function snapshotDecoder() {
57
93
  return snap;
58
94
  }
59
95
  const d = snap.d;
60
- if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
96
+ if (!base || !d || snap.e !== base.e || snap.k <= base.k) return null;
97
+ if (d.own !== undefined && (!Array.isArray(d.own) || !d.own.every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string'))) return null;
61
98
  if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
99
+ if (d.base !== base.k) {
100
+ // A skipped remote delta must not stall local prediction or collider edits.
101
+ // Keep the remote baseline unchanged until a keyframe repairs the chain.
102
+ if (!d.chain || !d.own?.length || !Array.isArray(d.round)) return null;
103
+ const visible = new Map(rows);
104
+ for (const id of d.removed) visible.delete(id);
105
+ for (const e of d.own) visible.set(e[0], e);
106
+ return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? [] : [d.collision])] };
107
+ }
62
108
  const next = new Map(rows);
63
109
  for (const id of d.removed) next.delete(id);
64
110
  for (const change of d.changed) {
@@ -75,7 +121,46 @@ export function snapshotDecoder() {
75
121
  if (j !== change[2].length) return null;
76
122
  next.set(entity[0], entity);
77
123
  }
78
- return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
124
+ for (const e of d.own ?? []) next.set(e[0], e);
125
+ const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
126
+ if (d.chain) { base = decoded; rows = next; }
127
+ return decoded;
79
128
  },
80
129
  };
81
130
  }
131
+
132
+ /** Studio-chosen visual precision and rates. The controlled body stays exact.
133
+ * Exits are immediate even between far updates; new arrivals never wait.
134
+ */
135
+ const quantizedRows = new WeakMap();
136
+ export function scheduledView({ radiusM = null, precisionM = 0, nearM = null, farHz = null } = {}, tickHz = 20) {
137
+ let previous = new Map(), epoch = null;
138
+ return (snap, seat) => {
139
+ if (snap.e !== epoch) { previous.clear(); epoch = snap.e; }
140
+ const selected = interestSnapshot(snap, seat, radiusM);
141
+ const own = selected.d[1].find(e => e[9] === seat && e[10] !== 1);
142
+ const next = new Map();
143
+ const entities = selected.d[1].map(entity => {
144
+ let row = entity;
145
+ if (entity !== own) {
146
+ const distance = own ? Math.hypot(...entity[3].map((n, i) => n - (own[3][i] ?? 0))) : 0;
147
+ const old = previous.get(entity[0]);
148
+ const interval = farHz && nearM !== null && distance > nearM ? Math.max(1, Math.round(tickHz / farHz)) : 1;
149
+ if (old && snap.k % interval !== 0) row = old;
150
+ else if (precisionM > 0) {
151
+ let versions = quantizedRows.get(entity);
152
+ if (!versions) { versions = new Map(); quantizedRows.set(entity, versions); }
153
+ row = versions.get(precisionM);
154
+ if (!row) {
155
+ row = entity.slice();
156
+ for (const i of [3, 4]) row[i] = entity[i].map(n => Number((Math.round(n / precisionM) * precisionM).toPrecision(12)));
157
+ versions.set(precisionM, row);
158
+ }
159
+ }
160
+ }
161
+ next.set(entity[0], row); return row;
162
+ });
163
+ previous = next;
164
+ return { ...selected, d: [selected.d[0], entities, ...selected.d.slice(2)] };
165
+ };
166
+ }
package/rules/live.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { overlapsTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
1
3
  /** Declared collision geometry, shared by authority and prediction. Entity fields
2
4
  * own its lifetime and dimensions; this projection contains no gameplay state. */
3
5
  import type { Vec3, ColliderDef } from './rules.ts';
@@ -27,7 +29,7 @@ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number
27
29
  }
28
30
  export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
29
31
  if (!rows.length) return map;
30
- const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
32
+ const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
31
33
  for (const [id, shape, x, y, z, w, d, h] of rows) {
32
34
  const at = { x, y, z };
33
35
  if (shape === 'box') boxes.push({ min: { x: x - w / 2, y: y - d / 2, z }, max: { x: x + w / 2, y: y + d / 2, z: z + h }, id } as any);
@@ -35,7 +37,7 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
35
37
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
36
38
  else capsules.push({ at, r: w / 2, height: h, id } as any);
37
39
  }
38
- return { ...map, boxes, circles, spheres, capsules };
40
+ return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
39
41
  }
40
42
  /** Latest known revision at the requested tick. Unknown future edits are never
41
43
  * guessed: a later snapshot rebases the body and replays its pending inputs. */
@@ -60,14 +62,14 @@ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, d
60
62
  return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze({ ...p, z: p.z - d * h.t }), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
61
63
  },
62
64
  overlaps: (body: unknown) => {
63
- const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
65
+ const p = position(body), s = shape(), a = solidAt(p, s), m = nearbyMap(map(), p, {x:0,y:0,z:0}, s.radius, s.height || 2*s.radius, dims);
64
66
  charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
65
67
  const overlap = (b: ReturnType<typeof solidAt>): boolean => {
66
68
  let sq = 0;
67
69
  for (const k of dims === 2 ? ['x', 'y'] as const : ['x', 'y', 'z'] as const) { const gap = Math.max(0, a.min[k] - b.max[k], b.min[k] - a.max[k]); sq += gap * gap; }
68
70
  return sq < (a.r + b.r) ** 2 || a.r + b.r === 0 && a.min.x < b.max.x && a.max.x > b.min.x && a.min.y < b.max.y && a.max.y > b.min.y && (dims === 2 || a.min.z < b.max.z && a.max.z > b.min.z);
69
71
  };
70
- return m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
72
+ return (m.heightTiles ?? []).some(t => t.base !== undefined && overlapsTerrain(t, a)) || m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
71
73
  },
72
74
  };
73
75
  }
@@ -0,0 +1,51 @@
1
+ /** Immutable build-time BVH. Queries charge nodes visited and narrow-phase work,
2
+ * never the number of shapes in the level. Live geometry is a separate overlay. */
3
+ import { charge } from './guard.ts';
4
+ import type { MapShapes } from './math.ts';
5
+ import type { Vec3 } from './rules.ts';
6
+ type Key = 'boxes' | 'circles' | 'spheres' | 'capsules' | 'heightTiles';
7
+ type Entry = { key: Key; value: any; min: Vec3; max: Vec3; order: number };
8
+ type Node = { min: Vec3; max: Vec3; left?: Node; right?: Node; entries?: Entry[] };
9
+ const indexes = new WeakMap<object, Node>();
10
+ const axes = ['x','y','z'] as const;
11
+ export function indexMap(map: MapShapes): void {
12
+ const entries: Entry[] = [];
13
+ for (const key of ['boxes','circles','spheres','capsules','heightTiles'] as const) for (const value of map[key] ?? []) {
14
+ const v:any=value; let min:Vec3,max:Vec3;
15
+ if(key==='boxes') { min=v.min;max=v.max; }
16
+ else if(key==='heightTiles') { min={...v.at,z:v.at.z+Math.min(v.base??Infinity,...v.heights)};max={x:v.at.x+v.size.x,y:v.at.y+v.size.y,z:v.at.z+Math.max(...v.heights)}; }
17
+ else { min={x:v.at.x-v.r,y:v.at.y-v.r,z:key==='circles'?-Infinity:key==='capsules'?v.at.z:v.at.z-v.r};max={x:v.at.x+v.r,y:v.at.y+v.r,z:key==='circles'?Infinity:key==='capsules'?v.at.z+v.height:v.at.z+v.r}; }
18
+ entries.push({key,value,min,max,order:entries.length});
19
+ }
20
+ function build(es:Entry[]):Node {
21
+ const min={x:Infinity,y:Infinity,z:Infinity},max={x:-Infinity,y:-Infinity,z:-Infinity};
22
+ for(const e of es)for(const k of axes){min[k]=Math.min(min[k],e.min[k]);max[k]=Math.max(max[k],e.max[k]);}
23
+ if(es.length<=8)return {min,max,entries:es};
24
+ // Choose the widest finite axis; unbounded columns still split horizontally.
25
+ let k: 'x' | 'y' | 'z' = max.x-min.x>=max.y-min.y?'x':'y';
26
+ if(Number.isFinite(max.z-min.z) && max.z-min.z>max[k]-min[k])k='z';
27
+ es.sort((a,b)=>(a.min[k]+a.max[k])-(b.min[k]+b.max[k])||a.order-b.order);
28
+ const mid=es.length>>1;return {min,max,left:build(es.slice(0,mid)),right:build(es.slice(mid))};
29
+ }
30
+ indexes.set(map,build(entries));
31
+ }
32
+ /** The segment of a body's reference point against bounds expanded by its extents. */
33
+ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:number, dims=3):MapShapes {
34
+ const parent=map.staticMap;
35
+ const root=indexes.get(parent??map);
36
+ if(!root)return map;
37
+ const out:any={bounds:map.bounds,boxes:[],circles:[],spheres:[],capsules:[],heightTiles:[]};
38
+ function touches(n:{min:Vec3;max:Vec3}):boolean {
39
+ charge(12);let near=0,far=1;
40
+ for(const k of dims===2?['x','y'] as const:axes){const lo=n.min[k]-(k==='z'?height:radius),hi=n.max[k]+(k==='z'?0:radius);
41
+ if(d[k]===0){if(p[k]<lo-1e-7||p[k]>hi+1e-7)return false;}
42
+ else {let a=(lo-p[k])/d[k],b=(hi-p[k])/d[k];if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far+1e-7)return false;}
43
+ }return true;
44
+ }
45
+ const found:Entry[]=[];
46
+ function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
47
+ visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
48
+ found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
49
+ if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const v of map[key]??[]){charge(12);out[key].push(v);}
50
+ return out;
51
+ }
package/rules/math.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { nearbyMap } from './map-index.ts';
1
2
  import { collisionQueries } from './live.ts';
2
3
  /*
3
4
  * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
@@ -183,7 +184,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
183
184
  /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
184
185
 
185
186
  /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
186
- 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[] }
187
188
  /** How close a swept body stops to what it hit, in metres. */
188
189
  export const SKIN = 0.001;
189
190
 
@@ -237,6 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
238
  }
238
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. */
239
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));
240
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);
241
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 }; }
242
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 }; }
@@ -250,7 +253,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
250
253
  */
251
254
  export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
252
255
  // Charged before the cast, for every shape of the map it may test.
253
- charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
256
+ charge(20);
254
257
  const fr = Math.fround;
255
258
  // Both are whatever `move` handed over: each is read as a plain number or as nothing, and nothing is called to convert it.
256
259
  const num = (a: unknown): number => { const x = typeof a === 'number' ? fr(a) : 0; return Number.isFinite(x) ? x : 0; };
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
  * =============================================================================
@@ -179,7 +181,7 @@ export interface RoomSettings {
179
181
  tickHz: number;
180
182
  inputHz: number;
181
183
  /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
182
- view: { radiusM: number | null };
184
+ view: { radiusM: number | null; precisionM?: number; nearM?: number | null; farHz?: number | null };
183
185
  durability: { movementSeconds: number };
184
186
  budget: { tick: number };
185
187
  predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
@@ -227,13 +229,13 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
227
229
  if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
228
230
  if (r.view !== undefined) {
229
231
  if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
230
- else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
232
+ else for (const key of Object.keys(r.view)) if (!['radiusM', 'precisionM', 'nearM', 'farHz'].includes(key)) problems.push(`"room.view.${key}" is not a setting; use radiusM, precisionM, nearM or farHz`);
231
233
  }
232
234
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
233
235
  return {
234
236
  settings: {
235
237
  host, offline, tickHz, inputHz,
236
- view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
238
+ view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
237
239
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
238
240
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
239
241
  predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
@@ -246,7 +248,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
246
248
 
247
249
  export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
248
250
  export interface MapCircle { readonly at: Vec3; readonly r: number }
249
- 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] }
250
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[]>> }
251
253
 
252
254
  const vecOf = (a: unknown, what: string): Vec3 => {
@@ -280,15 +282,19 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
280
282
  return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
281
283
  };
282
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`);
283
- 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`);
284
- 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({
285
287
  name,
286
288
  heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
287
289
  const what = `${where} heightTiles[${i}]`;
288
290
  const at = vecOf(t?.at, `${what}.at`);
289
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`);
290
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`);
291
- 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;
292
298
  })),
293
299
  spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
294
300
  capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
@@ -305,6 +311,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
305
311
  })),
306
312
  spots: Object.freeze(spots),
307
313
  });
314
+ indexMap(result);
315
+ return result;
308
316
  }
309
317
 
310
318
  /* ------------------------------------------------------------------ compiled tables */
@@ -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
+ }
@@ -2,10 +2,20 @@
2
2
  "id": "coin-dash",
3
3
  "name": "Coin Dash",
4
4
  "blurb": "Run for the coins before the others do. Sixty-second rounds, and the score is kept on the server, where nobody can change it.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "entry": "src/view.ts",
7
10
  "example": true,
8
- "netplay": { "v": 1, "public": true },
9
- "room": { "host": "server" },
10
- "landing": { "pitch": "Run for the coins. First one there takes it.", "controls": { "phone": "Drag to run.", "computer": "WASD or arrows to run." } }
11
+ "room": {
12
+ "host": "server"
13
+ },
14
+ "landing": {
15
+ "pitch": "Run for the coins. First one there takes it.",
16
+ "controls": {
17
+ "phone": "Drag to run.",
18
+ "computer": "WASD or arrows to run."
19
+ }
20
+ }
11
21
  }
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 90,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "saves": true,
16
12
  "decide": false,
17
13
  "landing": {
@@ -1,7 +1,7 @@
1
1
  // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
2
  // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
3
3
  /** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
4
- import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
4
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type Slot, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
5
5
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
6
6
  import { lab } from '@homie-rocks/studio/lab';
7
7
  import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
@@ -28,7 +28,7 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
28
28
 
29
29
  /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
30
30
  interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
31
- interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
31
+ interface Body { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent']; x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
32
32
  /** A slime; while it is struck: where its push starts (kx, ky), along (kdx, kdy), from when (kat, after the hit-stop). */
33
33
  interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number; kx?: number; ky?: number; kdx?: number; kdy?: number; kat?: number }
34
34
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 60,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "landing": {
16
12
  "pitch": "Grab the gems. Bump your rivals away.",
17
13
  "controls": {
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 60,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "needs": {
16
12
  "three": "0.185.1",
17
13
  "@types/three": "0.185.1"
@@ -8,10 +8,6 @@
8
8
  },
9
9
  "roundSeconds": 60,
10
10
  "entry": "src/view.ts",
11
- "netplay": {
12
- "v": 1,
13
- "public": true
14
- },
15
11
  "needs": {
16
12
  "three": "0.185.1",
17
13
  "@types/three": "0.185.1"
@@ -4,6 +4,8 @@ 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
10
  const before = body.pos, hit = ctx.world.sweep(body, delta);
9
11
  if (!hit) return;