@homie-rocks/studio 0.44.0 → 0.44.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +2 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +94 -0
- package/guides/game/RULES.md +754 -0
- package/guides/game/SKILL.md +394 -0
- package/lib/authoring-guides.mjs +27 -0
- package/lib/cloudflare.mjs +4 -3
- package/lib/dev.mjs +4 -2
- 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 +84 -0
- package/lib/worker-build.mjs +50 -6
- package/lib/worker-config.mjs +2 -2
- package/package.json +5 -3
- package/rules/collision.ts +16 -10
- package/rules/core.ts +35 -14
- package/rules/interest.mjs +5 -3
- package/rules/live.ts +75 -0
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +15 -9
- package/rules/rules.ts +27 -5
- package/rules/terrain.ts +93 -0
- package/rules/types.ts +4 -3
- package/rules/view.ts +22 -4
- package/starters/hero-rush-3d/src/move.ts +6 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/version.mjs +1 -1
package/rules/math.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { nearbyMap } from './map-index.ts';
|
|
2
|
+
import { collisionQueries } from './live.ts';
|
|
1
3
|
/*
|
|
2
4
|
* math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
|
|
3
5
|
* =============================================================================
|
|
@@ -182,7 +184,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
|
|
|
182
184
|
/* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
|
|
183
185
|
|
|
184
186
|
/** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
|
|
185
|
-
export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
|
|
187
|
+
export interface MapShapes { staticMap?: MapShapes; bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
|
|
186
188
|
/** How close a swept body stops to what it hit, in metres. */
|
|
187
189
|
export const SKIN = 0.001;
|
|
188
190
|
|
|
@@ -204,7 +206,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
|
|
|
204
206
|
return { t, nx: hx / l, ny: hy / l };
|
|
205
207
|
}
|
|
206
208
|
/** The same point against a box grown by r with round corners (a circle of radius r against the box). */
|
|
207
|
-
function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
|
|
209
|
+
export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
|
|
208
210
|
let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
|
|
209
211
|
const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
|
|
210
212
|
const p = [px, py]; const d = [dx, dy];
|
|
@@ -236,9 +238,11 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
|
|
|
236
238
|
}
|
|
237
239
|
/** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
|
|
238
240
|
export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
|
|
241
|
+
map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
|
|
242
|
+
charge(16 + 4 * (map.boxes.length + map.circles.length));
|
|
239
243
|
let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
|
|
240
|
-
for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = h; }
|
|
241
|
-
for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = h; }
|
|
244
|
+
for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
|
|
245
|
+
for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
|
|
242
246
|
return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
|
|
243
247
|
}
|
|
244
248
|
|
|
@@ -249,7 +253,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
|
|
|
249
253
|
*/
|
|
250
254
|
export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
|
|
251
255
|
// Charged before the cast, for every shape of the map it may test.
|
|
252
|
-
charge(20
|
|
256
|
+
charge(20);
|
|
253
257
|
const fr = Math.fround;
|
|
254
258
|
// Both are whatever `move` handed over: each is read as a plain number or as nothing, and nothing is called to convert it.
|
|
255
259
|
const num = (a: unknown): number => { const x = typeof a === 'number' ? fr(a) : 0; return Number.isFinite(x) ? x : 0; };
|
|
@@ -264,7 +268,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
264
268
|
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
265
269
|
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
266
270
|
put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
|
|
267
|
-
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
|
|
271
|
+
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
268
272
|
}
|
|
269
273
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
270
274
|
put(body, 'grounded', true);
|
|
@@ -273,7 +277,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
273
277
|
const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
|
|
274
278
|
const at = v(fr(px + dx * t), fr(py + dy * t), 0);
|
|
275
279
|
put(body, 'pos', at);
|
|
276
|
-
return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
|
|
280
|
+
return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
/**
|
|
@@ -281,12 +285,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
281
285
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
282
286
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
283
287
|
*/
|
|
284
|
-
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number }): unknown {
|
|
288
|
+
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes }): unknown {
|
|
285
289
|
const none = Object.freeze([]);
|
|
290
|
+
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
|
|
286
291
|
return brand(Object.freeze({
|
|
287
292
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
288
293
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
289
294
|
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
290
|
-
|
|
295
|
+
world: brand(Object.freeze(queries)),
|
|
296
|
+
map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
|
|
291
297
|
}));
|
|
292
298
|
}
|
package/rules/rules.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prepareTile } from './terrain.ts';
|
|
2
|
+
import { indexMap } from './map-index.ts';
|
|
1
3
|
/*
|
|
2
4
|
* rules.ts — the rules contract, version 2: what a rules module declares, and how the runtime reads it.
|
|
3
5
|
* =============================================================================
|
|
@@ -123,6 +125,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
|
|
|
123
125
|
export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
|
|
124
126
|
export type MoveFn = (body: any, input: any, ctx: any) => void;
|
|
125
127
|
|
|
128
|
+
export type ColliderDef = true | { size?: string; enabled?: string };
|
|
126
129
|
export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
|
|
127
130
|
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
128
131
|
export interface EntityDef {
|
|
@@ -131,6 +134,7 @@ export interface EntityDef {
|
|
|
131
134
|
motion?: Fields;
|
|
132
135
|
input?: Fields;
|
|
133
136
|
body?: BodyDef;
|
|
137
|
+
collider?: ColliderDef;
|
|
134
138
|
guide?: GuideDef;
|
|
135
139
|
tick?: Handler;
|
|
136
140
|
think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
|
|
@@ -244,7 +248,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
244
248
|
|
|
245
249
|
export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
|
|
246
250
|
export interface MapCircle { readonly at: Vec3; readonly r: number }
|
|
247
|
-
export interface MapHeightTile { readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
|
|
251
|
+
export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
|
|
248
252
|
export interface GameMap { readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
|
|
249
253
|
|
|
250
254
|
const vecOf = (a: unknown, what: string): Vec3 => {
|
|
@@ -278,15 +282,19 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
278
282
|
return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
|
|
279
283
|
};
|
|
280
284
|
if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].some(list => list !== undefined && !Array.isArray(list))) throw new Error(`${where}: shapes are lists`);
|
|
281
|
-
if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) >
|
|
282
|
-
|
|
285
|
+
if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 100000) throw new Error(`${where}: at most 100000 static shapes`);
|
|
286
|
+
const result = Object.freeze({
|
|
283
287
|
name,
|
|
284
288
|
heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
|
|
285
289
|
const what = `${where} heightTiles[${i}]`;
|
|
286
290
|
const at = vecOf(t?.at, `${what}.at`);
|
|
287
291
|
if (!Array.isArray(t.size) || t.size.length !== 2 || t.size.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v) || v <= 0)) throw new Error(`${what}: size is two positive finite lengths`);
|
|
288
292
|
if (!Array.isArray(t.heights) || t.heights.length !== 4 || t.heights.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v))) throw new Error(`${what}: heights are four finite offsets, in row order`);
|
|
289
|
-
|
|
293
|
+
if (t.base !== undefined && (typeof t.base !== 'number' || !Number.isFinite(t.base) || t.heights.some((h:number) => h < t.base))) throw new Error(`${what}: base must be finite and at or below every top sample`);
|
|
294
|
+
if (t.diagonal !== undefined && !['00-11','10-01'].includes(t.diagonal)) throw new Error(`${what}: diagonal is 00-11 or 10-01`);
|
|
295
|
+
const tile = Object.freeze({ ...(t.base !== undefined ? {base:t.base} : {}), ...(t.diagonal ? {diagonal:t.diagonal} : {}), at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
|
|
296
|
+
prepareTile(tile);
|
|
297
|
+
return tile;
|
|
290
298
|
})),
|
|
291
299
|
spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
|
|
292
300
|
capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
|
|
@@ -303,6 +311,8 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
303
311
|
})),
|
|
304
312
|
spots: Object.freeze(spots),
|
|
305
313
|
});
|
|
314
|
+
indexMap(result);
|
|
315
|
+
return result;
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
/* ------------------------------------------------------------------ compiled tables */
|
|
@@ -315,6 +325,7 @@ export interface KindTable {
|
|
|
315
325
|
fields: FieldList;
|
|
316
326
|
motion: FieldList;
|
|
317
327
|
input: FieldList;
|
|
328
|
+
collider?: ColliderDef;
|
|
318
329
|
body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
|
|
319
330
|
score: string | null;
|
|
320
331
|
tick: Handler | null;
|
|
@@ -521,11 +532,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
521
532
|
if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
|
|
522
533
|
const cells = cellsOfList(fields) + cellsOfList(motion);
|
|
523
534
|
if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
|
|
524
|
-
|
|
535
|
+
if (e.collider !== undefined) {
|
|
536
|
+
if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
|
|
537
|
+
if (e.collider !== true) {
|
|
538
|
+
if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
|
|
539
|
+
for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
|
|
540
|
+
const name = (e.collider as Record<string, unknown>)[key];
|
|
541
|
+
if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
|
|
525
546
|
kinds.push(k);
|
|
526
547
|
kindOf[name] = k;
|
|
527
548
|
}
|
|
528
549
|
|
|
550
|
+
if (kinds.some(k => k.collider) && kinds.some(k => k.player && k.body?.owner)) throw new Error("live colliders need server movement: omit body.move: 'owner' on players");
|
|
529
551
|
const room = d.room ?? {};
|
|
530
552
|
const roomOn = handlers<RoomHandler>(room.on, 'room.on');
|
|
531
553
|
for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
|
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
|
+
}
|
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;
|
|
@@ -4,16 +4,20 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
|
|
|
4
4
|
|
|
5
5
|
import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
|
|
6
6
|
|
|
7
|
+
// Solid slopes use the same contact tangent as walls; the capsule keeps its full
|
|
8
|
+
// clearance. Never replace this sweep with an unchecked height assignment.
|
|
7
9
|
function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
|
|
8
|
-
const before = body.pos, hit = ctx.
|
|
10
|
+
const before = body.pos, hit = ctx.world.sweep(body, delta);
|
|
9
11
|
if (!hit) return;
|
|
10
12
|
const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
|
|
11
13
|
const into = M.dot(remaining, hit.normal);
|
|
12
|
-
if (into < 0) ctx.
|
|
14
|
+
if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export const move = defineMove({
|
|
16
18
|
runner(body, input, ctx) {
|
|
19
|
+
// Recheck support before jumping: a platform/cover may have moved or broken.
|
|
20
|
+
body.grounded = Boolean(ctx.world.support(body));
|
|
17
21
|
const M = ctx.math, m = body.motion, T = ctx.tune;
|
|
18
22
|
const dt = ctx.dt, knocked = m.knockAt > 0;
|
|
19
23
|
const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
|
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.3';
|