@homie-rocks/studio 0.44.0 → 0.44.1
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 +22 -0
- package/README.md +2 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +78 -0
- package/guides/game/RULES.md +737 -0
- package/guides/game/SKILL.md +392 -0
- package/lib/authoring-guides.mjs +27 -0
- package/lib/rules-check-worker.mjs +4 -3
- package/lib/rules-guard.mjs +2 -1
- package/lib/scaffold.mjs +9 -2
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +39 -6
- package/lib/worker-config.mjs +2 -2
- package/package.json +5 -3
- package/rules/collision.ts +6 -6
- package/rules/core.ts +33 -11
- package/rules/interest.mjs +5 -3
- package/rules/live.ts +73 -0
- package/rules/math.ts +10 -7
- package/rules/rules.ts +15 -1
- package/rules/types.ts +4 -3
- package/rules/view.ts +22 -4
- package/starters/hero-rush-3d/src/move.ts +4 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -121,7 +121,8 @@
|
|
|
121
121
|
"apps/",
|
|
122
122
|
"app-starters/",
|
|
123
123
|
"tools/",
|
|
124
|
-
"functions/"
|
|
124
|
+
"functions/",
|
|
125
|
+
"guides/"
|
|
125
126
|
],
|
|
126
127
|
"engines": {
|
|
127
128
|
"node": ">=22"
|
|
@@ -160,6 +161,7 @@
|
|
|
160
161
|
"zod": "4.3.6"
|
|
161
162
|
},
|
|
162
163
|
"scripts": {
|
|
163
|
-
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
|
|
164
|
+
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
|
|
165
|
+
"prepack": "node ../../scripts/authoring-guides.mjs --check"
|
|
164
166
|
}
|
|
165
167
|
}
|
package/rules/collision.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Map3 {
|
|
|
14
14
|
capsules?: readonly (MapCircle & { height: number })[];
|
|
15
15
|
}
|
|
16
16
|
const EPS = 1e-7;
|
|
17
|
-
const point = (x: number, y: number, z: number):
|
|
17
|
+
const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
|
|
18
18
|
export function solidAt(at: Vec3, body: BodyShape): Solid {
|
|
19
19
|
const r = body.radius, height = body.height || 2 * r;
|
|
20
20
|
if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
|
|
@@ -93,7 +93,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
93
93
|
charge(24);
|
|
94
94
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
95
95
|
let best: Hit3 | null = null;
|
|
96
|
-
const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
|
|
96
|
+
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = id ? {...h, id} : h; };
|
|
97
97
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
98
98
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
99
99
|
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
@@ -102,11 +102,11 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
102
102
|
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
103
103
|
}
|
|
104
104
|
for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
|
|
105
|
-
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
|
|
105
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
|
|
106
106
|
// Legacy map circles are vertical columns in a 3D map.
|
|
107
|
-
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }));
|
|
108
|
-
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
|
|
109
|
-
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
|
|
107
|
+
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
|
|
108
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
|
|
109
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
|
|
110
110
|
return best;
|
|
111
111
|
}
|
|
112
112
|
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
|
|
1
2
|
/*
|
|
2
3
|
* core.ts — one room's world, stepped a tick at a time from a compiled rules module.
|
|
3
4
|
* =============================================================================
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
|
|
42
43
|
import { argsWhy, type Vocabulary } from '../agents/agents.ts';
|
|
43
44
|
import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
|
|
44
|
-
import { SKIN, castMap, exact, math, rayCircle
|
|
45
|
+
import { SKIN, castMap, exact, math, rayCircle } from './math.ts';
|
|
45
46
|
import type { Hit } from './math.ts';
|
|
46
47
|
import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
47
48
|
import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
|
|
@@ -178,7 +179,7 @@ export interface Core {
|
|
|
178
179
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
179
180
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
180
181
|
*/
|
|
181
|
-
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
182
|
+
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
182
183
|
const dims = c.dims;
|
|
183
184
|
const tickHz = c.settings.tickHz;
|
|
184
185
|
const dt = 1 / tickHz;
|
|
@@ -483,7 +484,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
483
484
|
const solid = solidAt(p, shape);
|
|
484
485
|
for (const other of ents.values()) {
|
|
485
486
|
if (!other.kind.body || ignored.has(other.id)) continue;
|
|
486
|
-
|
|
487
|
+
if (other.kind.collider) charge(48);
|
|
488
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
489
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
490
|
+
const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
|
|
487
491
|
if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
|
|
488
492
|
}
|
|
489
493
|
return best;
|
|
@@ -618,7 +622,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
618
622
|
let best: Hit | null = cast.hit;
|
|
619
623
|
for (const e of ents.values()) {
|
|
620
624
|
if (!e.kind.body || e === cx.ent) continue;
|
|
621
|
-
|
|
625
|
+
if (e.kind.collider) charge(48);
|
|
626
|
+
const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
|
|
627
|
+
if (e.dead || e.kind.collider && !row) continue;
|
|
628
|
+
const h = row ? castCollider2(row, p, {x:d.x*far,y:d.y*far,z:0}, 0) : rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
|
|
622
629
|
if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
|
|
623
630
|
}
|
|
624
631
|
if (!best) return undefined;
|
|
@@ -648,7 +655,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
648
655
|
let best: Hit | null = cast.hit;
|
|
649
656
|
for (const other of ents.values()) {
|
|
650
657
|
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
651
|
-
|
|
658
|
+
if (other.kind.collider) charge(48);
|
|
659
|
+
const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
|
|
660
|
+
if (other.dead || other.kind.collider && !row) continue;
|
|
661
|
+
const h = row ? castCollider2(row, e.pos, d, radius) : rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
|
|
652
662
|
if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
|
|
653
663
|
}
|
|
654
664
|
const l = Math.sqrt(d.x * d.x + d.y * d.y);
|
|
@@ -678,11 +688,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
678
688
|
});
|
|
679
689
|
Object.freeze(world);
|
|
680
690
|
|
|
681
|
-
const
|
|
682
|
-
|
|
691
|
+
const hasColliders = c.kinds.some(k => k.collider);
|
|
692
|
+
const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
|
|
693
|
+
if (e.dead || !e.kind.collider || !e.kind.body) return [];
|
|
694
|
+
const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
|
|
695
|
+
return row ? [row] : [];
|
|
696
|
+
})];
|
|
697
|
+
let moveGeometry = c.map as import('./math.ts').MapShapes;
|
|
698
|
+
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
|
|
699
|
+
const moveWorld = brand(Object.freeze(queries));
|
|
700
|
+
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
|
|
683
701
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
684
702
|
const moveCtx = brand({} as Record<string, unknown>);
|
|
685
|
-
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
703
|
+
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
686
704
|
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
687
705
|
Object.freeze(moveCtx);
|
|
688
706
|
const joinCtx = brand({} as Record<string, unknown>);
|
|
@@ -988,6 +1006,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
988
1006
|
}
|
|
989
1007
|
}
|
|
990
1008
|
guides?.();
|
|
1009
|
+
// 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]); }
|
|
991
1011
|
// Phase 1: one input step and `move` for every body.
|
|
992
1012
|
for (const e of [...ents.values()]) {
|
|
993
1013
|
if (e.dead || !e.kind.body) continue;
|
|
@@ -1040,7 +1060,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1040
1060
|
if (!k.move) continue;
|
|
1041
1061
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1042
1062
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1043
|
-
|
|
1063
|
+
moveShape = body;
|
|
1044
1064
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1045
1065
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
1046
1066
|
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
@@ -1049,7 +1069,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1049
1069
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1050
1070
|
G.note = null;
|
|
1051
1071
|
e.grounded = own(b, 'grounded') === true;
|
|
1052
|
-
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
|
|
1072
|
+
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry);
|
|
1053
1073
|
}
|
|
1054
1074
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1055
1075
|
const list = [...ents.values()];
|
|
@@ -1369,7 +1389,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1369
1389
|
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1370
1390
|
// Include all three components of position, velocity and heading in a 3D snapshot.
|
|
1371
1391
|
snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
|
|
1372
|
-
|
|
1392
|
+
const collision = hasColliders ? collisionState() : null;
|
|
1393
|
+
if (collision) snapCells += 3 + collision[2].length * 8;
|
|
1394
|
+
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1373
1395
|
},
|
|
1374
1396
|
shared: () => packFields(c.shared, shared, dims),
|
|
1375
1397
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
package/rules/interest.mjs
CHANGED
|
@@ -10,7 +10,9 @@ export function interestSnapshot(snap, seat, radiusM) {
|
|
|
10
10
|
const p = e[3], q = own[3];
|
|
11
11
|
return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
|
|
12
12
|
}) : seat === null ? entities : own ? entities : [];
|
|
13
|
-
|
|
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) };
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function snapshotEncoder(keyframeTicks) {
|
|
@@ -33,7 +35,7 @@ export function snapshotEncoder(keyframeTicks) {
|
|
|
33
35
|
for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
34
36
|
if (mask) changed.push([id, mask, values]);
|
|
35
37
|
}
|
|
36
|
-
return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
|
|
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)) } };
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
41
|
}
|
|
@@ -73,7 +75,7 @@ export function snapshotDecoder() {
|
|
|
73
75
|
if (j !== change[2].length) return null;
|
|
74
76
|
next.set(entity[0], entity);
|
|
75
77
|
}
|
|
76
|
-
return { ...snap, d: [d.round, [...next.values()]] };
|
|
78
|
+
return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
|
|
77
79
|
},
|
|
78
80
|
};
|
|
79
81
|
}
|
package/rules/live.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Declared collision geometry, shared by authority and prediction. Entity fields
|
|
2
|
+
* own its lifetime and dimensions; this projection contains no gameplay state. */
|
|
3
|
+
import type { Vec3, ColliderDef } from './rules.ts';
|
|
4
|
+
import type { MapShapes } from './math.ts';
|
|
5
|
+
import { charge, own } from './guard.ts';
|
|
6
|
+
import { castMap3, solidAt, type BodyShape } from './collision.ts';
|
|
7
|
+
import { sweepMap, rayBox, rayCircle } from './math.ts';
|
|
8
|
+
|
|
9
|
+
export type ColliderRow = [string, string, number, number, number, number, number, number];
|
|
10
|
+
export type CollisionRevision = [number, number, ColliderRow[]];
|
|
11
|
+
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
|
|
12
|
+
const opts = def === true ? {} : def;
|
|
13
|
+
if (opts.enabled && fields[opts.enabled] !== true) return null;
|
|
14
|
+
const size = opts.size ? fields[opts.size] as Vec3 : null;
|
|
15
|
+
const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
|
|
16
|
+
if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
|
|
17
|
+
return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
|
|
18
|
+
}
|
|
19
|
+
export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
|
|
20
|
+
const [, shape, x, y, z, w, d, h] = row;
|
|
21
|
+
return shape === 'box' ? { min: { x: x-w/2, y: y-d/2, z }, max: { x:x+w/2, y:y+d/2, z:z+h }, r:0 }
|
|
22
|
+
: solidAt({x,y,z}, {shape, radius:w/2, height:h});
|
|
23
|
+
}
|
|
24
|
+
export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
|
|
25
|
+
const solid = colliderSolid(row);
|
|
26
|
+
return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
|
|
27
|
+
}
|
|
28
|
+
export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
|
|
29
|
+
if (!rows.length) return map;
|
|
30
|
+
const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
|
|
31
|
+
for (const [id, shape, x, y, z, w, d, h] of rows) {
|
|
32
|
+
const at = { x, y, z };
|
|
33
|
+
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);
|
|
34
|
+
else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
|
|
35
|
+
else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
|
|
36
|
+
else capsules.push({ at, r: w / 2, height: h, id } as any);
|
|
37
|
+
}
|
|
38
|
+
return { ...map, boxes, circles, spheres, capsules };
|
|
39
|
+
}
|
|
40
|
+
/** Latest known revision at the requested tick. Unknown future edits are never
|
|
41
|
+
* guessed: a later snapshot rebases the body and replays its pending inputs. */
|
|
42
|
+
export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
|
|
43
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
|
|
47
|
+
const position = (body: unknown): Vec3 => {
|
|
48
|
+
const p = own(body, 'pos');
|
|
49
|
+
const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
|
|
50
|
+
return { x: n('x'), y: n('y'), z: n('z') };
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
|
|
54
|
+
support: (body: unknown, distance: unknown = 0.002) => {
|
|
55
|
+
charge(20);
|
|
56
|
+
const p = position(body), m = map();
|
|
57
|
+
const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
|
|
58
|
+
if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
|
|
59
|
+
const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
|
|
60
|
+
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
|
+
},
|
|
62
|
+
overlaps: (body: unknown) => {
|
|
63
|
+
const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
|
|
64
|
+
charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
|
|
65
|
+
const overlap = (b: ReturnType<typeof solidAt>): boolean => {
|
|
66
|
+
let sq = 0;
|
|
67
|
+
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
|
+
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
|
+
};
|
|
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 })));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/rules/math.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collisionQueries } from './live.ts';
|
|
1
2
|
/*
|
|
2
3
|
* math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
|
|
3
4
|
* =============================================================================
|
|
@@ -204,7 +205,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
|
|
|
204
205
|
return { t, nx: hx / l, ny: hy / l };
|
|
205
206
|
}
|
|
206
207
|
/** 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 {
|
|
208
|
+
export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
|
|
208
209
|
let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
|
|
209
210
|
const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
|
|
210
211
|
const p = [px, py]; const d = [dx, dy];
|
|
@@ -237,8 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
|
|
|
237
238
|
/** 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
239
|
export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
|
|
239
240
|
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; }
|
|
241
|
+
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
|
+
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
243
|
return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -264,7 +265,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
264
265
|
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
265
266
|
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
266
267
|
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;
|
|
268
|
+
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
268
269
|
}
|
|
269
270
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
270
271
|
put(body, 'grounded', true);
|
|
@@ -273,7 +274,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
273
274
|
const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
|
|
274
275
|
const at = v(fr(px + dx * t), fr(py + dy * t), 0);
|
|
275
276
|
put(body, 'pos', at);
|
|
276
|
-
return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
|
|
277
|
+
return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
|
|
277
278
|
}
|
|
278
279
|
|
|
279
280
|
/**
|
|
@@ -281,12 +282,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
281
282
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
282
283
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
283
284
|
*/
|
|
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 {
|
|
285
|
+
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
286
|
const none = Object.freeze([]);
|
|
287
|
+
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
|
|
286
288
|
return brand(Object.freeze({
|
|
287
289
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
288
290
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
289
291
|
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
|
-
|
|
292
|
+
world: brand(Object.freeze(queries)),
|
|
293
|
+
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
294
|
}));
|
|
292
295
|
}
|
package/rules/rules.ts
CHANGED
|
@@ -123,6 +123,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
|
|
|
123
123
|
export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
|
|
124
124
|
export type MoveFn = (body: any, input: any, ctx: any) => void;
|
|
125
125
|
|
|
126
|
+
export type ColliderDef = true | { size?: string; enabled?: string };
|
|
126
127
|
export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
|
|
127
128
|
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
128
129
|
export interface EntityDef {
|
|
@@ -131,6 +132,7 @@ export interface EntityDef {
|
|
|
131
132
|
motion?: Fields;
|
|
132
133
|
input?: Fields;
|
|
133
134
|
body?: BodyDef;
|
|
135
|
+
collider?: ColliderDef;
|
|
134
136
|
guide?: GuideDef;
|
|
135
137
|
tick?: Handler;
|
|
136
138
|
think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
|
|
@@ -315,6 +317,7 @@ export interface KindTable {
|
|
|
315
317
|
fields: FieldList;
|
|
316
318
|
motion: FieldList;
|
|
317
319
|
input: FieldList;
|
|
320
|
+
collider?: ColliderDef;
|
|
318
321
|
body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
|
|
319
322
|
score: string | null;
|
|
320
323
|
tick: Handler | null;
|
|
@@ -521,11 +524,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
521
524
|
if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
|
|
522
525
|
const cells = cellsOfList(fields) + cellsOfList(motion);
|
|
523
526
|
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
|
-
|
|
527
|
+
if (e.collider !== undefined) {
|
|
528
|
+
if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
|
|
529
|
+
if (e.collider !== true) {
|
|
530
|
+
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}`);
|
|
531
|
+
for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
|
|
532
|
+
const name = (e.collider as Record<string, unknown>)[key];
|
|
533
|
+
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`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
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
538
|
kinds.push(k);
|
|
526
539
|
kindOf[name] = k;
|
|
527
540
|
}
|
|
528
541
|
|
|
542
|
+
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
543
|
const room = d.room ?? {};
|
|
530
544
|
const roomOn = handlers<RoomHandler>(room.on, 'room.on');
|
|
531
545
|
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`);
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -5,15 +5,17 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
|
|
|
5
5
|
import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
|
|
6
6
|
|
|
7
7
|
function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
|
|
8
|
-
const before = body.pos, hit = ctx.
|
|
8
|
+
const before = body.pos, hit = ctx.world.sweep(body, delta);
|
|
9
9
|
if (!hit) return;
|
|
10
10
|
const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
|
|
11
11
|
const into = M.dot(remaining, hit.normal);
|
|
12
|
-
if (into < 0) ctx.
|
|
12
|
+
if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const move = defineMove({
|
|
16
16
|
runner(body, input, ctx) {
|
|
17
|
+
// Recheck support before jumping: a platform/cover may have moved or broken.
|
|
18
|
+
body.grounded = Boolean(ctx.world.support(body));
|
|
17
19
|
const M = ctx.math, m = body.motion, T = ctx.tune;
|
|
18
20
|
const dt = ctx.dt, knocked = m.knockAt > 0;
|
|
19
21
|
const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
|
package/worker/customer-shop.mjs
CHANGED
|
@@ -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
|
|
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) {
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.44.1';
|