@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/CHANGELOG.md +41 -0
- package/README.md +20 -22
- package/guides/game/REWRITE.md +20 -2
- package/guides/game/RULES.md +51 -14
- package/guides/game/SKILL.md +12 -8
- package/lib/build.mjs +11 -8
- package/lib/cloudflare.mjs +6 -4
- package/lib/dev.mjs +4 -2
- package/lib/mcp.mjs +1 -1
- package/lib/rules-build.mjs +2 -1
- package/lib/scaffold.mjs +9 -9
- package/lib/template-history.json +97 -0
- package/lib/typecheck.mjs +16 -2
- package/lib/worker-build.mjs +18 -4
- package/netplay/NETPLAY.md +22 -177
- package/netplay/netplay.ts +14 -9
- package/package.json +1 -1
- package/rules/collision.ts +10 -4
- package/rules/core.ts +3 -4
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +106 -21
- package/rules/live.ts +6 -4
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +5 -2
- package/rules/rules.ts +15 -7
- package/rules/terrain.ts +93 -0
- package/starters/coin-dash/game.json +14 -4
- package/starters/ember-vale/game.json +0 -4
- package/starters/ember-vale/src/view.ts +2 -2
- package/starters/gem-rush/game.json +0 -4
- package/starters/gem-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/worker/gate.mjs +151 -0
- package/worker/index.mjs +21 -11
- package/worker/mcp-room.mjs +0 -1
- package/worker/office.mjs +3 -3
- package/worker/room.mjs +35 -19
- package/worker/seats.mjs +9 -13
- package/worker/servers.mjs +6 -6
- package/worker/version.mjs +1 -1
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 *
|
|
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 *
|
|
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
|
|
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; },
|
package/rules/interest.mjs
CHANGED
|
@@ -1,41 +1,77 @@
|
|
|
1
1
|
/** Per-connection state delivery. Pure data, shared by the relay and the view.
|
|
2
|
-
* Deltas name a keyframe
|
|
3
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 -
|
|
25
|
-
base = snap;
|
|
26
|
-
rows = new Map(snap.d[1].map(e => [e[0], e
|
|
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 (
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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 = [
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
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) >
|
|
284
|
-
|
|
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
|
-
|
|
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 */
|
package/rules/terrain.ts
ADDED
|
@@ -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": {
|
|
5
|
+
"players": {
|
|
6
|
+
"min": 1,
|
|
7
|
+
"max": 8
|
|
8
|
+
},
|
|
6
9
|
"entry": "src/view.ts",
|
|
7
10
|
"example": true,
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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];
|
|
@@ -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;
|