@homie-rocks/studio 0.38.0 → 0.40.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 +57 -1
- package/bin/homie-studio.mjs +13 -1
- package/lib/build.mjs +4 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/port-check.mjs +33 -7
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +19 -6
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +82 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +17 -6
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
package/rules/host.ts
CHANGED
|
@@ -290,6 +290,10 @@ export function createHost(o: HostOptions): Host {
|
|
|
290
290
|
agents.on('ask', (d) => o.send({ t: 'ev', k: 'agent:ask', d }));
|
|
291
291
|
}
|
|
292
292
|
function guideBeat(): void {
|
|
293
|
+
// Roles come from admitted peers and reserved server seats, never player input.
|
|
294
|
+
const guideSeats = agentSlots().filter(slot => slot.agent?.role === 'guide').map(slot => slot.slot).sort((a, b) => a - b);
|
|
295
|
+
const previous = (core.world as { guideSeats: readonly number[] }).guideSeats;
|
|
296
|
+
if (guideSeats.length !== previous.length || guideSeats.some((seat, i) => seat !== previous[i])) core.setPolicy({ guideSeats });
|
|
293
297
|
if (!agents) return;
|
|
294
298
|
const bodies = core.bodies();
|
|
295
299
|
for (const b of bodies) {
|
|
@@ -498,7 +502,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
498
502
|
speech: raw.speech === 'off' || raw.speech === 'lines' ? raw.speech : 'game' };
|
|
499
503
|
if (previous.kind !== policy.kind || previous.aiSeats !== policy.aiSeats || previous.guides !== policy.guides) rosterText = '';
|
|
500
504
|
}
|
|
501
|
-
if (p) core.setPolicy({ bots: p.bots === 'off' ? 'off' : 'fill', level: num(own(own(p, 'skill'), 'level') ?? own(p, 'level')), levelMax: num(own(p, 'levelMax')), reserved: policy.kind === 'hybrid' || policy.kind === 'beginner' ? policy.aiSeats + policy.guides : 0 });
|
|
505
|
+
if (p) core.setPolicy({ kids: own(p, 'kids') === true, ...(own(p, 'level') !== undefined ? { guideLevel: num(own(p, 'level')) } : {}), levelSet: Boolean(own(p, 'by')), bots: p.bots === 'off' ? 'off' : 'fill', level: num(own(own(p, 'skill'), 'level') ?? own(p, 'level')), levelMax: num(own(p, 'levelMax')), reserved: policy.kind === 'hybrid' || policy.kind === 'beginner' ? policy.aiSeats + policy.guides : 0 });
|
|
502
506
|
return;
|
|
503
507
|
}
|
|
504
508
|
default: return;
|
package/rules/math.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
|
|
13
13
|
* =============================================================================
|
|
14
14
|
*/
|
|
15
|
+
import { castMap3, restsOnMap, type BodyShape } from './collision.ts';
|
|
15
16
|
import { brand, charge, own, put } from './guard.ts';
|
|
16
17
|
import { num } from './pack.ts';
|
|
17
18
|
|
|
@@ -181,7 +182,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
|
|
|
181
182
|
/* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
|
|
182
183
|
|
|
183
184
|
/** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
|
|
184
|
-
export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[] }
|
|
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[] }
|
|
185
186
|
/** How close a swept body stops to what it hit, in metres. */
|
|
186
187
|
export const SKIN = 0.001;
|
|
187
188
|
|
|
@@ -244,9 +245,9 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
|
|
|
244
245
|
/**
|
|
245
246
|
* `ctx.map.sweep(body, delta)`: move `body.pos` along `delta` and stop at the first static shape in the way. The server
|
|
246
247
|
* and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
|
|
247
|
-
*
|
|
248
|
+
* The 2D path charges 20 units and 4 per shape; 3D also charges each convex cast and terrain tile. With `dims` 2 a body always rests on the ground.
|
|
248
249
|
*/
|
|
249
|
-
export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number,
|
|
250
|
+
export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
|
|
250
251
|
// Charged before the cast, for every shape of the map it may test.
|
|
251
252
|
charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
|
|
252
253
|
const fr = Math.fround;
|
|
@@ -256,6 +257,15 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
256
257
|
const pos = own(body, 'pos');
|
|
257
258
|
const dx = num(own(delta, 'x')); const dy = num(own(delta, 'y'));
|
|
258
259
|
const px = num(own(pos, 'x')); const py = num(own(pos, 'y'));
|
|
260
|
+
if (dims === 3) {
|
|
261
|
+
const p = v(px, py, num(own(pos, 'z'))), d = v(dx, dy, num(own(delta, 'z')));
|
|
262
|
+
const h = castMap3(map, p, d, shape);
|
|
263
|
+
const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
|
|
264
|
+
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
265
|
+
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
266
|
+
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
|
+
}
|
|
259
269
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
260
270
|
put(body, 'grounded', true);
|
|
261
271
|
if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
|
|
@@ -271,12 +281,12 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
271
281
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
272
282
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
273
283
|
*/
|
|
274
|
-
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; dims: number }): unknown {
|
|
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 {
|
|
275
285
|
const none = Object.freeze([]);
|
|
276
286
|
return brand(Object.freeze({
|
|
277
287
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
278
288
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
279
289
|
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; },
|
|
280
|
-
map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims) })),
|
|
290
|
+
map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims, o.shape?.()) })),
|
|
281
291
|
}));
|
|
282
292
|
}
|
package/rules/rules.ts
CHANGED
|
@@ -71,7 +71,7 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
|
71
71
|
/**
|
|
72
72
|
* The types of declared state. Whole numbers (`u8` to `u32`, `i8` to `i32`) are held to their range; `bit` is true or
|
|
73
73
|
* false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
|
|
74
|
-
* unit vector
|
|
74
|
+
* unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
|
|
75
75
|
* entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
|
|
76
76
|
* fires once.
|
|
77
77
|
*/
|
|
@@ -236,7 +236,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
236
236
|
|
|
237
237
|
export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
|
|
238
238
|
export interface MapCircle { readonly at: Vec3; readonly r: number }
|
|
239
|
-
export interface
|
|
239
|
+
export interface MapHeightTile { readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
|
|
240
|
+
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[]>> }
|
|
240
241
|
|
|
241
242
|
const vecOf = (a: unknown, what: string): Vec3 => {
|
|
242
243
|
const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
|
|
@@ -247,8 +248,7 @@ const vecOf = (a: unknown, what: string): Vec3 => {
|
|
|
247
248
|
|
|
248
249
|
/**
|
|
249
250
|
* One map file (`games/<id>/map/<name>.json`), checked: `bounds` (the world's edges), `boxes` and `circles` (solid
|
|
250
|
-
* shapes on the ground plane) and named `spots`. Positions are metres, `z` up.
|
|
251
|
-
* arrive with 3D bodies.
|
|
251
|
+
* shapes on the ground plane), spheres, upright capsules, height tiles and named `spots`. Positions are metres, `z` up.
|
|
252
252
|
*/
|
|
253
253
|
export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
254
254
|
const m = raw && typeof raw === 'object' ? raw as Record<string, any> : {};
|
|
@@ -256,7 +256,7 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
256
256
|
if (!m.bounds) throw new Error(`${where} needs "bounds": { "min": [x, y], "max": [x, y] }, the edges of the world in metres`);
|
|
257
257
|
const box = (b: any, what: string): MapBox => {
|
|
258
258
|
const min = vecOf(b?.min, `${what}.min`); const max = vecOf(b?.max, `${what}.max`);
|
|
259
|
-
if (!(max.x > min.x) || !(max.y > min.y)) throw new Error(`${what}: "max" is above and to the right of "min"`);
|
|
259
|
+
if (!(max.x > min.x) || !(max.y > min.y) || max.z < min.z) throw new Error(`${what}: "max" is above and to the right of "min"`);
|
|
260
260
|
return Object.freeze({ min, max });
|
|
261
261
|
};
|
|
262
262
|
const spots: Record<string, readonly Vec3[]> = {};
|
|
@@ -264,13 +264,33 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
264
264
|
if (!/^[a-z][A-Za-z0-9_-]{0,31}$/.test(key) || !Array.isArray(list)) throw new Error(`${where} spots.${key}: a spot name is a short word and holds a list of points`);
|
|
265
265
|
spots[key] = Object.freeze(list.map((p, i) => vecOf(p, `${where} spots.${key}[${i}]`)));
|
|
266
266
|
}
|
|
267
|
+
const roundShape = (c: any, what: string): MapCircle => {
|
|
268
|
+
const r = Number(c?.r);
|
|
269
|
+
if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${what} needs a finite positive radius`);
|
|
270
|
+
return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
|
|
271
|
+
};
|
|
272
|
+
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`);
|
|
273
|
+
if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 4096) throw new Error(`${where}: at most 4096 static shapes`);
|
|
267
274
|
return Object.freeze({
|
|
268
275
|
name,
|
|
276
|
+
heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
|
|
277
|
+
const what = `${where} heightTiles[${i}]`;
|
|
278
|
+
const at = vecOf(t?.at, `${what}.at`);
|
|
279
|
+
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`);
|
|
280
|
+
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`);
|
|
281
|
+
return Object.freeze({ at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
|
|
282
|
+
})),
|
|
283
|
+
spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
|
|
284
|
+
capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
|
|
285
|
+
const shape = roundShape(c, `${where} capsules[${i}]`);
|
|
286
|
+
if (!Number.isFinite(c.height) || c.height < 2 * shape.r) throw new Error(`${where} capsules[${i}]: height is at least twice the radius`);
|
|
287
|
+
return Object.freeze({ ...shape, height: c.height });
|
|
288
|
+
})),
|
|
269
289
|
bounds: box(m.bounds, `${where} bounds`),
|
|
270
290
|
boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
|
|
271
291
|
circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
|
|
272
292
|
const r = Number(c?.r);
|
|
273
|
-
if (!(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
|
|
293
|
+
if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
|
|
274
294
|
return Object.freeze({ at: vecOf(c?.at, `${where} circles[${i}].at`), r });
|
|
275
295
|
})),
|
|
276
296
|
spots: Object.freeze(spots),
|
|
@@ -430,6 +450,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
430
450
|
if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
|
|
431
451
|
const settings = env.settings ?? ROOM_DEFAULTS;
|
|
432
452
|
const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
|
|
453
|
+
if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
|
|
454
|
+
if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
|
|
433
455
|
const { tune, publicTune } = tunablesOf(env.tune);
|
|
434
456
|
|
|
435
457
|
const events: Record<string, FieldList> = {};
|
|
@@ -475,9 +497,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
475
497
|
const b = e.body;
|
|
476
498
|
const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
|
|
477
499
|
if (!b || !shapes.includes(b.shape)) throw new Error(`${at}.body.shape is ${shapes.map((s) => `'${s}'`).join(' or ')} when space.dims is ${dims}`);
|
|
478
|
-
if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
|
|
500
|
+
if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !Number.isFinite(b.radius) || !Number.isFinite(b.maxSpeed) || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
|
|
479
501
|
if (b.move !== undefined && b.move !== 'owner') throw new Error(`${at}.body.move is 'owner', or left out`);
|
|
480
|
-
|
|
502
|
+
if (dims === 3 && b.height !== undefined && (!Number.isFinite(b.height) || b.height <= 0 || b.shape === 'capsule' && b.height < b.radius * 2)) throw new Error(`${at}.body.height must be finite, positive and at least twice the radius for a capsule`);
|
|
503
|
+
body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner' };
|
|
481
504
|
}
|
|
482
505
|
if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
|
|
483
506
|
const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
|
|
@@ -535,7 +558,7 @@ export interface Schema {
|
|
|
535
558
|
contract: 2;
|
|
536
559
|
dims: 2 | 3;
|
|
537
560
|
seats: number;
|
|
538
|
-
kinds: { name: string; player: boolean; owner: boolean; radius: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
|
|
561
|
+
kinds: { name: string; player: boolean; owner: boolean; radius: number; shape?: string; height?: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
|
|
539
562
|
effects: Record<string, FieldList>;
|
|
540
563
|
effectNames: string[];
|
|
541
564
|
commands: Record<string, FieldList>;
|
|
@@ -546,7 +569,7 @@ export interface Schema {
|
|
|
546
569
|
export function schemaOf(c: Compiled): Schema {
|
|
547
570
|
return {
|
|
548
571
|
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats,
|
|
549
|
-
kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
|
|
572
|
+
kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
|
|
550
573
|
effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
|
|
551
574
|
};
|
|
552
575
|
}
|
package/rules/types.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface MapView { readonly name: string; spot(name: string): Vec3 | und
|
|
|
22
22
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
23
23
|
export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
|
|
24
24
|
export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
25
|
-
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number;
|
|
25
|
+
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
26
26
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
27
27
|
readonly round: Round & (Room extends true ? { end(): void } : {});
|
|
28
28
|
random(): number;
|
|
@@ -49,7 +49,7 @@ export type Undeliverable<E> = { [K in keyof E]: { to: string; event: K; data: E
|
|
|
49
49
|
export type BuiltIns<E = Record<string, never>> = { arrive: { why: 'join' | 'spawn' }; leave: {}; takeover: {}; undeliverable: Undeliverable<E>; answer: { ask: string; by: string; picks: Readonly<Record<string, unknown>>; why?: string } };
|
|
50
50
|
export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined: { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; owner: string; took: boolean }; seatAway: { seat: number; id: string; away: boolean }; seatLeft: { seat: number; id: string }; answer: BuiltIns['answer']; undeliverable: BuiltIns<E>['undeliverable'] };
|
|
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
|
-
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
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
54
|
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
|
|
55
55
|
tick?: (world: W, self: S) => void;
|
package/rules/view.ts
CHANGED
|
@@ -46,10 +46,28 @@ import { BudgetError } from './guard.ts';
|
|
|
46
46
|
import { moveContext } from './math.ts';
|
|
47
47
|
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
48
48
|
import type { Unpacked } from './pack.ts';
|
|
49
|
+
import { compileMap } from './rules.ts';
|
|
49
50
|
import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
|
|
50
51
|
|
|
52
|
+
/** Unit headings take the shortest arc, including an exact half turn. Linear
|
|
53
|
+
* interpolation passes through zero at a half turn and visibly flips a character. */
|
|
54
|
+
export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
|
|
55
|
+
if (t <= 0) return a;
|
|
56
|
+
if (t >= 1) return b;
|
|
57
|
+
const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
|
|
58
|
+
if (dot > 0.9995) return dir({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t) }, dims);
|
|
59
|
+
if (dot < -0.9995) {
|
|
60
|
+
const side = Math.abs(a.z) < 0.9 ? dir({ x: -a.y, y: a.x, z: 0 }, 3) : dir({ x: a.z, y: 0, z: -a.x }, 3);
|
|
61
|
+
const c = Math.cos(Math.PI * t), s = Math.sin(Math.PI * t);
|
|
62
|
+
return dir({ x: a.x * c + side.x * s, y: a.y * c + side.y * s, z: a.z * c + side.z * s }, dims);
|
|
63
|
+
}
|
|
64
|
+
const angle = Math.acos(dot), scale = Math.sin(angle);
|
|
65
|
+
const from = Math.sin((1 - t) * angle) / scale, to = Math.sin(t * angle) / scale;
|
|
66
|
+
return dir({ x: a.x * from + b.x * to, y: a.y * from + b.y * to, z: a.z * from + b.z * to }, dims);
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
|
|
52
|
-
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
70
|
+
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
53
71
|
let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
|
|
54
72
|
/** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
|
|
55
73
|
export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
|
|
@@ -67,6 +85,8 @@ export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
|
|
|
67
85
|
export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
|
|
68
86
|
export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
|
|
69
87
|
export interface OpenRoomOptions {
|
|
88
|
+
/** Play a private rules round while an online room has no body for this player. */
|
|
89
|
+
fallback?: 'solo';
|
|
70
90
|
game?: GameData;
|
|
71
91
|
move?: Record<string, MoveFn>;
|
|
72
92
|
net?: NetplayOptions;
|
|
@@ -97,7 +117,7 @@ export interface Room<R = unknown> {
|
|
|
97
117
|
askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
|
|
98
118
|
readonly tune: Readonly<Record<string, unknown>>;
|
|
99
119
|
/** The game's static map: its edges, its solid shapes and its named spots, in metres. */
|
|
100
|
-
readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
|
|
120
|
+
readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
|
|
101
121
|
readonly net: Netplay;
|
|
102
122
|
/** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
|
|
103
123
|
pump(): void;
|
|
@@ -124,11 +144,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
124
144
|
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
|
|
125
145
|
const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
|
|
126
146
|
const listeners = new Map<string, Set<(e: any) => void>>();
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
147
|
+
let solo: Room<R> | null = null, noBodyMs = 0, checkedAt = clock();
|
|
148
|
+
const soloListeners = new Map<string, () => void>();
|
|
149
|
+
const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
|
|
150
|
+
const map = compileMap(game.map, game.map.name ?? 'main');
|
|
151
|
+
const spots = map.spots;
|
|
132
152
|
const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
|
|
133
153
|
|
|
134
154
|
let status: RoomStatus = 'connecting';
|
|
@@ -183,6 +203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
183
203
|
let observedLeadAt = 0;
|
|
184
204
|
let catchTick: number | null = null;
|
|
185
205
|
let catchAt = 0;
|
|
206
|
+
let headingBlend: { from: Vec3; at: number } | null = null;
|
|
186
207
|
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
|
|
187
208
|
const predict = schema.settings.predict;
|
|
188
209
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
@@ -199,6 +220,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
199
220
|
if (path) {
|
|
200
221
|
// Loss can make even a small correction longer than the distance travelled in
|
|
201
222
|
// 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
|
|
223
|
+
// A forced knock can travel much faster than running. It must not also
|
|
224
|
+
// erase a large offset at that speed and almost double the drawn impulse.
|
|
202
225
|
// Only one queued fade advances at a time. A stopped body finishes its correction.
|
|
203
226
|
if (waiting) o.at = now;
|
|
204
227
|
else {
|
|
@@ -224,7 +247,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
224
247
|
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
|
|
225
248
|
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
226
249
|
let moveTick = 0;
|
|
227
|
-
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
|
|
250
|
+
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
228
251
|
function authorityRtt(): number {
|
|
229
252
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
230
253
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
@@ -257,7 +280,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
257
280
|
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
258
281
|
stepped = Math.floor(base.tick) - 1;
|
|
259
282
|
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
|
|
260
|
-
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
|
|
283
|
+
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
261
284
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
262
285
|
if (mine) {
|
|
263
286
|
history.set(stepped, copyMine(mine));
|
|
@@ -275,8 +298,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
275
298
|
if (!fn) return body;
|
|
276
299
|
moveTick = t;
|
|
277
300
|
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); });
|
|
278
|
-
const
|
|
279
|
-
|
|
301
|
+
const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
|
|
302
|
+
const height = kind?.height || 2 * r;
|
|
303
|
+
out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
|
|
280
304
|
return out;
|
|
281
305
|
}
|
|
282
306
|
/** The input values the last step held (a press is never held). */
|
|
@@ -329,6 +353,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
329
353
|
if (closed) return;
|
|
330
354
|
if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
331
355
|
const now = clock();
|
|
356
|
+
checkSolo(now);
|
|
332
357
|
const hidden = typeof document !== 'undefined' && document.hidden === true;
|
|
333
358
|
// Effects play for everyone who draws the room: a watcher has no body and still sees them.
|
|
334
359
|
fire();
|
|
@@ -378,7 +403,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
378
403
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
379
404
|
} else {
|
|
380
405
|
const previous = mine!.pos;
|
|
381
|
-
const
|
|
406
|
+
const shownBody = meNow();
|
|
407
|
+
const shown = shownBody?.pos ?? previous;
|
|
408
|
+
const oldHeading = mine!.heading;
|
|
382
409
|
const oldOffset = visualOffset(clock()), oldOffsets = offsets;
|
|
383
410
|
adopt(me);
|
|
384
411
|
history.set(f.k, copyMine(mine!));
|
|
@@ -402,6 +429,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
402
429
|
history.set(t, copyMine(mine!));
|
|
403
430
|
}
|
|
404
431
|
pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
|
|
432
|
+
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
405
433
|
const error = distance(previous, mine!.pos);
|
|
406
434
|
correction.last = error;
|
|
407
435
|
if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
|
|
@@ -466,6 +494,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
466
494
|
setStatus(seat === null || me ? 'playing' : status);
|
|
467
495
|
}
|
|
468
496
|
function roundNow(): RoomRound | null {
|
|
497
|
+
if (solo) return solo.round;
|
|
469
498
|
if (!round || !latest) return null;
|
|
470
499
|
// The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
|
|
471
500
|
const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
|
|
@@ -527,21 +556,26 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
527
556
|
* drawn frame, not on the next tick.
|
|
528
557
|
*/
|
|
529
558
|
function meNow(): Entity | null {
|
|
559
|
+
if (solo) return solo.me;
|
|
530
560
|
if (!mine || !latest) return null;
|
|
531
561
|
const server = latest.ents.get(mine.id);
|
|
532
562
|
if (!server) return null;
|
|
533
563
|
const kind = kindOf.get(mine.kind);
|
|
534
564
|
let pos = mine.pos;
|
|
535
565
|
let heading = mine.heading;
|
|
566
|
+
let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
|
|
536
567
|
let settled = true;
|
|
537
|
-
let preview:
|
|
568
|
+
let preview: ReturnType<typeof runMove> | null = null;
|
|
538
569
|
if (kind && base) {
|
|
539
570
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
540
571
|
const now: Record<string, unknown> = {};
|
|
541
572
|
for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
542
573
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
543
574
|
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
|
|
544
|
-
heading = next.heading;
|
|
575
|
+
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
576
|
+
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
577
|
+
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
578
|
+
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
545
579
|
preview = next;
|
|
546
580
|
settled = distance(next.pos, mine.pos) < 0.00001;
|
|
547
581
|
}
|
|
@@ -551,17 +585,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
551
585
|
if (catchTick >= tickAt(now)) catchTick = null;
|
|
552
586
|
else {
|
|
553
587
|
// Catch-up can reach the fractional current tick before its next whole step exists.
|
|
554
|
-
// Use the
|
|
588
|
+
// Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
|
|
555
589
|
const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
|
|
556
|
-
if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading;
|
|
557
|
-
|
|
590
|
+
if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
|
|
591
|
+
velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
|
|
592
|
+
grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
|
|
593
|
+
} else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
|
|
558
594
|
}
|
|
559
595
|
}
|
|
560
596
|
const offset = visualOffset(clock(), pos, settled);
|
|
561
597
|
pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
|
|
562
|
-
|
|
598
|
+
if (headingBlend) {
|
|
599
|
+
const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
|
|
600
|
+
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
601
|
+
if (t >= 1) headingBlend = null;
|
|
602
|
+
}
|
|
603
|
+
return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
|
|
563
604
|
}
|
|
564
605
|
function drawn(id: string): Entity | null {
|
|
606
|
+
if (solo) return solo.get(id);
|
|
565
607
|
if (mine && id === mine.id) return meNow();
|
|
566
608
|
const s = net.sample();
|
|
567
609
|
const a = s ? frameOf(s.a) : latest;
|
|
@@ -571,12 +613,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
571
613
|
// A placed body jumps: nobody's view glides across a placement.
|
|
572
614
|
if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
|
|
573
615
|
const t = s.alpha;
|
|
574
|
-
const h =
|
|
575
|
-
return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
|
|
616
|
+
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
617
|
+
return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
|
|
576
618
|
}
|
|
577
619
|
function get(id: string): Entity | null { return drawn(id); }
|
|
578
620
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
579
621
|
pump();
|
|
622
|
+
if (solo) { solo.each(kind, fn); return; }
|
|
580
623
|
const s = net.sample();
|
|
581
624
|
const a = s ? frameOf(s.a) : latest;
|
|
582
625
|
const b = s ? frameOf(s.b) : latest;
|
|
@@ -587,20 +630,51 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
587
630
|
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
588
631
|
}
|
|
589
632
|
function roster(): RosterRow[] {
|
|
633
|
+
if (solo) return solo.roster;
|
|
590
634
|
const scores = new Map<number, number>();
|
|
591
635
|
if (latest) for (const u of latest.ents.values()) { const k = kindOf.get(u.kind); if (u.seat !== undefined && k?.score) scores.set(u.seat, Number(u.fields[k.score]) || 0); }
|
|
592
636
|
return (net.slots ?? []).map((sl) => ({ seat: sl.slot, name: sl.name, driver: sl.agent ? 'ai' as const : sl.bot ? 'bot' as const : 'person' as const, score: scores.get(sl.slot) ?? 0, me: sl.seat !== null && sl.seat === net.seat }));
|
|
593
637
|
}
|
|
638
|
+
function forwardSolo(name: string): void {
|
|
639
|
+
if (solo && !soloListeners.has(name)) soloListeners.set(name, solo.on(name, e => emit(name, e, true)));
|
|
640
|
+
}
|
|
641
|
+
function stopSolo(): void {
|
|
642
|
+
for (const off of soloListeners.values()) off(); soloListeners.clear();
|
|
643
|
+
const old = solo; solo = null; old?.close();
|
|
644
|
+
}
|
|
645
|
+
function checkSolo(now: number): void {
|
|
646
|
+
if (opts.fallback !== 'solo') return;
|
|
647
|
+
const elapsed = Math.min(300, Math.max(0, now - checkedAt)); checkedAt = now;
|
|
648
|
+
if (net.offline || net.watching) { noBodyMs = 0; stopSolo(); return; }
|
|
649
|
+
if (net.link !== 'online') { noBodyMs = 0; return; }
|
|
650
|
+
if (mine && net.seat !== null && latest?.ents.has(mine.id)) {
|
|
651
|
+
noBodyMs = 0;
|
|
652
|
+
if (solo) { stopSolo(); net.line(null); emit('status', status); emit('placed', { id: mine.id }); }
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (solo || net.seat === null && !net.full) return;
|
|
656
|
+
noBodyMs += elapsed;
|
|
657
|
+
if (noBodyMs < 4000 || !(schema.settings.offline || schema.settings.host === 'browser')) return;
|
|
658
|
+
// Keep the online socket and its seat queue. The separate offline room emits no wire
|
|
659
|
+
// traffic, writes no room save to the server, and closes as soon as a body arrives.
|
|
660
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
661
|
+
const probes = [g.__homieNet, g.__homiePort];
|
|
662
|
+
solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
|
|
663
|
+
[g.__homieNet, g.__homiePort] = probes;
|
|
664
|
+
for (const name of listeners.keys()) forwardSolo(name);
|
|
665
|
+
net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
|
|
666
|
+
emit('status', 'offline', true);
|
|
667
|
+
}
|
|
594
668
|
let extra: Record<string, () => unknown> = {};
|
|
595
|
-
const myScore = (): number | null => { const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
|
|
596
|
-
const busy = (): boolean => status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
669
|
+
const myScore = (): number | null => { if (solo) { const k = schema.kinds.find(k => k.player); return k?.score && solo.me ? Number(solo.me[k.score]) || 0 : null; } const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
|
|
670
|
+
const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
597
671
|
// The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
|
|
598
672
|
net.expose({
|
|
599
673
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
600
674
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
601
675
|
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
602
676
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
603
|
-
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
|
|
677
|
+
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
604
678
|
});
|
|
605
679
|
if (typeof window !== 'undefined') {
|
|
606
680
|
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
@@ -616,27 +690,47 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
616
690
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
617
691
|
|
|
618
692
|
return {
|
|
619
|
-
get status() { return status; },
|
|
620
|
-
get seat() { return net.seat; },
|
|
693
|
+
get status() { return solo ? 'offline' : status; },
|
|
694
|
+
get seat() { return solo ? solo.seat : net.seat; },
|
|
621
695
|
get me() { pump(); return meNow(); },
|
|
622
696
|
each, get,
|
|
623
|
-
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); return () => { set?.delete(fn); }; },
|
|
624
|
-
get shared() { return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
697
|
+
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
698
|
+
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
625
699
|
input(s) {
|
|
700
|
+
if (solo) { solo.input(s); return; }
|
|
626
701
|
const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
|
|
627
702
|
const next: Record<string, unknown> = {};
|
|
628
|
-
|
|
703
|
+
let changed = false;
|
|
704
|
+
for (const [name, fd] of kind?.input ?? []) {
|
|
705
|
+
if (fd.t === 'press') { if ((s[name] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
|
|
706
|
+
else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
|
|
707
|
+
}
|
|
708
|
+
// A fresh control can replace a preview partway through a tick (especially
|
|
709
|
+
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
710
|
+
// may advance the new path, rather than drawing elapsed time with new input.
|
|
711
|
+
if (changed) pump();
|
|
712
|
+
const shown = changed ? meNow()?.pos : null;
|
|
713
|
+
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name] === true || s[name] === 1)) pressed.add(name);
|
|
629
714
|
sample = next;
|
|
715
|
+
if (shown) {
|
|
716
|
+
const now = clock(), nextShown = meNow()?.pos;
|
|
717
|
+
if (nextShown && distance(shown, nextShown) > 0.00001) {
|
|
718
|
+
const carried = visualOffset(now);
|
|
719
|
+
// Coalesce the carried blend: analogue controls may change every frame.
|
|
720
|
+
offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
|
|
721
|
+
at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
|
|
722
|
+
}
|
|
723
|
+
}
|
|
630
724
|
},
|
|
631
|
-
command(name, data = {}) { if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
|
|
725
|
+
command(name, data = {}) { if (solo) { solo.command(name, data); return; } if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
|
|
632
726
|
get round() { return roundNow(); },
|
|
633
727
|
get roster() { return roster(); },
|
|
634
728
|
follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
|
|
635
729
|
probe(x) { extra = { ...extra, ...x }; },
|
|
636
|
-
ask(id, askId, args = {}) { const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
637
|
-
askButtons(id, offer = {}) { const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
730
|
+
ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
731
|
+
askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
638
732
|
tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
|
|
639
733
|
pump,
|
|
640
|
-
close() { closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
734
|
+
close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
641
735
|
};
|
|
642
736
|
}
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
"id": "ember-vale",
|
|
3
3
|
"name": "Ember Vale",
|
|
4
4
|
"blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
|
|
5
|
-
"players": {
|
|
5
|
+
"players": {
|
|
6
|
+
"min": 1,
|
|
7
|
+
"max": 8
|
|
8
|
+
},
|
|
6
9
|
"roundSeconds": 90,
|
|
7
|
-
"entry": "src/
|
|
8
|
-
"netplay": {
|
|
10
|
+
"entry": "src/view.ts",
|
|
11
|
+
"netplay": {
|
|
12
|
+
"v": 1,
|
|
13
|
+
"public": true
|
|
14
|
+
},
|
|
9
15
|
"saves": true,
|
|
10
16
|
"decide": false,
|
|
11
17
|
"landing": {
|
|
@@ -16,6 +22,12 @@
|
|
|
16
22
|
"Every night ends with a ranking; your hero keeps what it earned.",
|
|
17
23
|
"Hardcore: one life. Fall and you join the Hall of the Fallen."
|
|
18
24
|
],
|
|
19
|
-
"controls": {
|
|
25
|
+
"controls": {
|
|
26
|
+
"phone": "Drag on the left to move, STRIKE on the right.",
|
|
27
|
+
"computer": "WASD or arrows to move, Space to strike."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"room": {
|
|
31
|
+
"host": "server"
|
|
20
32
|
}
|
|
21
33
|
}
|