@homie-rocks/studio 0.46.2 → 0.46.5
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 +106 -0
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +127 -3
- package/guides/game/SKILL.md +7 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +3 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +94 -14
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +24 -7
- package/rules/types.ts +5 -0
- package/rules/view.ts +291 -33
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +20 -2
- package/worker/pages.mjs +46 -1
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +28 -3
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
package/rules/view.ts
CHANGED
|
@@ -47,9 +47,9 @@ import { createNetplay } from '../netplay/netplay.ts';
|
|
|
47
47
|
import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
48
48
|
import { lab } from '../lab/lab.ts';
|
|
49
49
|
import { exposePort, type PortProbeOptions } from '../port/probe.ts';
|
|
50
|
-
import { BudgetError } from './guard.ts';
|
|
51
|
-
import { moveContext } from './math.ts';
|
|
52
|
-
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
50
|
+
import { BudgetError, brand } from './guard.ts';
|
|
51
|
+
import { math, moveContext } from './math.ts';
|
|
52
|
+
import { coerce, coerceFields, dir, initFields, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
53
53
|
import type { Unpacked } from './pack.ts';
|
|
54
54
|
import { compileMap } from './rules.ts';
|
|
55
55
|
import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
|
|
@@ -95,14 +95,50 @@ export interface OpenRoomOptions {
|
|
|
95
95
|
game?: GameData;
|
|
96
96
|
move?: Record<string, MoveFn>;
|
|
97
97
|
net?: NetplayOptions;
|
|
98
|
+
/**
|
|
99
|
+
* Predict the player's OWN actions (0.46.3), by kind: `predict: { runner(me, input, ctx) { ... } }`. The room runs it
|
|
100
|
+
* for the own body once a predicted tick, after `move`, with that tick's input, and again on a copy between ticks so
|
|
101
|
+
* a press answers on the next drawn frame. `me` holds the body's declared fields and may be written: `room.me` then
|
|
102
|
+
* reads the predicted values. Every snapshot puts the server's values back and the pending inputs are run again,
|
|
103
|
+
* exactly as the own position is. `ctx.ghost` shows a provisional entity and `ctx.effect` a provisional effect until
|
|
104
|
+
* the server confirms or the room retracts them (`room.on('retract', ...)`). The server stays the authority.
|
|
105
|
+
*/
|
|
106
|
+
predict?: Record<string, Predictor>;
|
|
98
107
|
/** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
|
|
99
108
|
screenBasis?: PortProbeOptions['basis'];
|
|
100
109
|
/** Tests: a clock in ms and a way to run without timers. */
|
|
101
110
|
now?: () => number;
|
|
102
111
|
timers?: boolean;
|
|
103
112
|
}
|
|
113
|
+
/** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
|
|
114
|
+
export interface PredictContext extends RayQueries {
|
|
115
|
+
readonly tick: number; readonly dt: number;
|
|
116
|
+
/** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
|
|
117
|
+
readonly math: typeof math;
|
|
118
|
+
/**
|
|
119
|
+
* The tick the server will judge this player's ray against when the browser says it aimed at `aimTick` (0.46.5):
|
|
120
|
+
* `aimTick`, held to how far back this seat may look right now (the server says so in every snapshot) and to this
|
|
121
|
+
* tick. Pass it to `ctx.ray(..., { atTick })`: the other bodies are then posed as the server will pose them, from
|
|
122
|
+
* the snapshots this browser holds (between two of them, or carried on from the newest along its velocity).
|
|
123
|
+
*/
|
|
124
|
+
judgedTick(aimTick: number): number;
|
|
125
|
+
/** The tick the other bodies are drawn at: what the player is looking at. */
|
|
126
|
+
readonly viewTick: number;
|
|
127
|
+
/** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
|
|
128
|
+
readonly preview: boolean;
|
|
129
|
+
/** A provisional entity, drawn by `room.each` with `provisional: true` until an entity of that kind which `match` accepts (default: within a metre of it) arrives, or the server has run the tick without one. Returns its id. */
|
|
130
|
+
ghost(kind: string, at: { x: number; y: number; z?: number }, fields?: Record<string, unknown>, options?: { seconds?: number; match?: (e: Entity) => boolean; key?: string }): string;
|
|
131
|
+
/**
|
|
132
|
+
* A provisional effect: listeners hear it now with `predicted: true`. The server's own effect of that name which `match` accepts (default: the next one) then arrives with `confirms: true`; if the server ran the tick without one, listeners of `retract` hear it.
|
|
133
|
+
* A tick's prediction runs more than once (a preview between ticks, again whenever the input changes, then the tick itself) and shows each thing once: things are told apart by their name and `key`, so give two effects of one name that mean different things (two sounds) a `key` each.
|
|
134
|
+
*/
|
|
135
|
+
effect(name: string, at: { x: number; y: number; z?: number } | string, data?: Record<string, unknown>, options?: { match?: (e: any) => boolean; key?: string }): void;
|
|
136
|
+
}
|
|
137
|
+
export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
|
|
104
138
|
export interface Room<R = unknown> extends RayQueries {
|
|
105
139
|
readonly viewTick: number;
|
|
140
|
+
/** Where a body was at a tick, from the snapshots this browser holds (0.46.5); `known: false` past the newest one. */
|
|
141
|
+
poseAt(id: string, tick: number): { pos: Vec3; vel: Vec3; known: boolean } | null;
|
|
106
142
|
readonly status: RoomStatus;
|
|
107
143
|
readonly seat: number | null;
|
|
108
144
|
readonly me: Entity | null;
|
|
@@ -159,11 +195,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
159
195
|
|
|
160
196
|
let status: RoomStatus = 'connecting';
|
|
161
197
|
let latest: Frame | null = null;
|
|
198
|
+
/** The snapshots this browser holds, oldest first: two seconds of them (see poseAt). */
|
|
199
|
+
const held2: Frame[] = [];
|
|
162
200
|
let epoch = 0;
|
|
163
201
|
let round: RoomRound | null = null;
|
|
164
202
|
let results: RoundInfo['results'] | undefined;
|
|
165
203
|
let closed = false;
|
|
166
|
-
const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown
|
|
204
|
+
const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown>; confirms?: boolean }[] = [];
|
|
167
205
|
const setStatus = (s: RoomStatus): void => { if (s !== status) { status = s; emit('status', s); } };
|
|
168
206
|
|
|
169
207
|
/* ---------------------------------------------------------------- frames in */
|
|
@@ -186,7 +224,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
186
224
|
|
|
187
225
|
/* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
|
|
188
226
|
|
|
189
|
-
interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
|
|
227
|
+
interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>; fields: Record<string, unknown> }
|
|
190
228
|
let mine: Mine | null = null;
|
|
191
229
|
/** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
|
|
192
230
|
let base: { tick: number; at: number } | null = null;
|
|
@@ -203,8 +241,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
203
241
|
let entries: { t: number; row: number[] }[] = [];
|
|
204
242
|
let hiddenSent = false;
|
|
205
243
|
const history = new Map<number, Mine>();
|
|
206
|
-
let pending: { t: number; input: Readonly<Record<string, unknown
|
|
244
|
+
let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
|
|
245
|
+
/** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
|
|
246
|
+
let pulseCount = 0;
|
|
247
|
+
let pulseNumber = new Map<string, number>();
|
|
207
248
|
let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
249
|
+
/** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
|
|
250
|
+
let serverHeld: Record<string, number> = {};
|
|
208
251
|
let speed = 1;
|
|
209
252
|
let leadAfter = 0;
|
|
210
253
|
let observedLeadAt = 0;
|
|
@@ -294,7 +337,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
294
337
|
leadAfter = clock() + 2 * rtt + period;
|
|
295
338
|
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
296
339
|
stepped = Math.floor(base.tick) - 1;
|
|
297
|
-
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
|
|
340
|
+
entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
298
341
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
299
342
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
300
343
|
if (mine) {
|
|
@@ -305,7 +348,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
305
348
|
/** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
|
|
306
349
|
const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
|
|
307
350
|
function adopt(u: Unpacked): void {
|
|
308
|
-
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion) };
|
|
351
|
+
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
|
|
309
352
|
}
|
|
310
353
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
311
354
|
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
|
|
@@ -321,8 +364,120 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
321
364
|
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);
|
|
322
365
|
return out;
|
|
323
366
|
}
|
|
367
|
+
/* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
|
|
368
|
+
|
|
369
|
+
const predictors = opts.predict ?? {};
|
|
370
|
+
interface Ghost { id: string; kind: string; tick: number; pos: Vec3; fields: Record<string, unknown>; until: number; match?: (e: Entity) => boolean; /** The server's entity that confirmed it: the ghost is drawn until that one is. */ real?: string }
|
|
371
|
+
const ghosts = new Map<string, Ghost>();
|
|
372
|
+
let ghostSeq = 0;
|
|
373
|
+
let provisional: { name: string; tick: number; payload: Record<string, unknown>; match?: (e: any) => boolean }[] = [];
|
|
374
|
+
/** What a tick's prediction has already shown, by tick and by the order it asked: a preview and the tick's own run show a thing once. */
|
|
375
|
+
const shown = new Map<number, Set<string>>();
|
|
376
|
+
let previewKey = '', previewFields: Record<string, unknown> | null = null;
|
|
377
|
+
const ghostEntity = (g: Ghost): Entity => Object.freeze({ id: g.id, kind: g.kind, pos: g.pos, vel: ZERO3, heading: ZERO3, grounded: false, provisional: true, ...g.fields, motion: Object.freeze({}) }) as Entity;
|
|
378
|
+
const ZERO3: Vec3 = Object.freeze({ x: 0, y: 0, z: 0 });
|
|
379
|
+
function dropPredictions(): void {
|
|
380
|
+
for (const g of ghosts.values()) if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick });
|
|
381
|
+
for (const x of provisional) emit('retract', { ...x.payload, effect: x.name, tick: x.tick });
|
|
382
|
+
ghosts.clear(); provisional = []; shown.clear(); previewKey = ''; previewFields = null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The game's predictor for one tick of the own body. `live` is false while a snapshot's pending inputs are run again:
|
|
386
|
+
* that run only brings the fields up to date, and shows nothing.
|
|
387
|
+
*/
|
|
388
|
+
function runPredict(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, fields: Record<string, unknown>, input: Readonly<Record<string, unknown>>, t: number, live: boolean, preview: boolean): void {
|
|
389
|
+
const fn = predictors[kindName], kind = kindOf.get(kindName);
|
|
390
|
+
if (!fn || !kind || !mine) return;
|
|
391
|
+
const id = mine.id;
|
|
392
|
+
// What a run asks to show is told apart by what it is (the name and the game's `key`), then by how many of that this
|
|
393
|
+
// run has asked for: never by the order of everything asked, which changes when another action joins the tick.
|
|
394
|
+
const asked = new Map<string, number>();
|
|
395
|
+
const once = (what: string): boolean => { if (!live) return false; let set = shown.get(t); if (!set) { set = new Set(); shown.set(t, set); } const n = asked.get(what) ?? 0; asked.set(what, n + 1); const key = what + '#' + n; if (set.has(key)) return false; set.add(key); return true; };
|
|
396
|
+
// Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
|
|
397
|
+
// code can be run on it instead of being restated in the view.
|
|
398
|
+
const ctx: PredictContext = brand({
|
|
399
|
+
tick: t, dt: 1 / tickHz, preview, math,
|
|
400
|
+
judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
|
|
401
|
+
get viewTick() { return viewTickNow(); },
|
|
402
|
+
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
403
|
+
rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
|
|
404
|
+
rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
|
|
405
|
+
ghost(kindOfGhost, at, data = {}, options = {}) {
|
|
406
|
+
const k = kindOf.get(kindOfGhost);
|
|
407
|
+
if (!k) { console.warn(`[room] predict: no kind "${kindOfGhost}"`); return ''; }
|
|
408
|
+
if (!once('g:' + kindOfGhost + ':' + (options.key ?? ''))) return '';
|
|
409
|
+
const g: Ghost = { id: `~${++ghostSeq}`, kind: kindOfGhost, tick: t, pos: vec3(at, dims), fields: coerceFields(k.fields, { ...initFields(k.fields, dims), ...data }, dims), until: clock() + (options.seconds ?? 2) * 1000, match: options.match };
|
|
410
|
+
ghosts.set(g.id, g);
|
|
411
|
+
emit('enter', ghostEntity(g));
|
|
412
|
+
return g.id;
|
|
413
|
+
},
|
|
414
|
+
effect(name, at, data = {}, options = {}) {
|
|
415
|
+
if (!schema.effects[name]) { console.warn(`[room] predict: no effect "${name}" is declared in shapes.effects`); return; }
|
|
416
|
+
if (!once('e:' + name + ':' + (options.key ?? ''))) return;
|
|
417
|
+
const target = typeof at === 'string' ? at : null;
|
|
418
|
+
const payload = { ...data, at: target ? get(target)?.pos ?? null : vec3(at as Vec3, dims), ...(target ? { id: target } : {}), tick: t };
|
|
419
|
+
// The server's own effect may already be here, waiting for its tick to be drawn (it acted before this browser
|
|
420
|
+
// stepped the tick, and its snapshot is still on the way): that one is this prediction's confirmation.
|
|
421
|
+
const waiting = fxQueue.find((x) => x.name === name && !x.confirms && (!options.match || safe(() => options.match!({ ...x.data, ...(typeof x.at === 'string' ? { id: x.at } : { at: unpackVec(x.at, dims) }), tick: x.tick }))));
|
|
422
|
+
if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
|
|
423
|
+
emit(name, Object.freeze({ ...payload, predicted: true }));
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
|
|
427
|
+
for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
|
|
428
|
+
try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
|
|
429
|
+
// What it wrote is held to the declared types, as the server holds its own writes.
|
|
430
|
+
for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
|
|
431
|
+
for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
|
|
432
|
+
}
|
|
433
|
+
/** After a snapshot: a ghost or a provisional effect whose tick the server has run without confirming it is taken back. */
|
|
434
|
+
function settlePredictions(f: Frame, before: Frame | null, ack: number): void {
|
|
435
|
+
if (ghosts.size) {
|
|
436
|
+
for (const [id, u] of f.ents) {
|
|
437
|
+
if (before?.ents.has(id)) continue;
|
|
438
|
+
const e = entityOf(u);
|
|
439
|
+
for (const g of ghosts.values()) if (!g.real && g.kind === u.kind && (g.match ? safe(() => g.match!(e)) : distance(u.pos, g.pos) <= 1)) { g.real = u.id; g.until = Infinity; emit('confirm', { ghost: g.id, id: u.id, kind: g.kind, tick: g.tick }); break; }
|
|
440
|
+
}
|
|
441
|
+
const now = clock();
|
|
442
|
+
for (const g of [...ghosts.values()]) if (g.real ? !f.ents.has(g.real) : (ack >= g.tick && f.k >= g.tick) || now >= g.until) { ghosts.delete(g.id); if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick }); emit('leave', ghostEntity(g)); }
|
|
443
|
+
}
|
|
444
|
+
if (provisional.length) {
|
|
445
|
+
const gone = provisional.filter((x) => ack >= x.tick && f.k > x.tick);
|
|
446
|
+
if (gone.length) { provisional = provisional.filter((x) => !gone.includes(x)); for (const x of gone) emit('retract', Object.freeze({ ...x.payload, effect: x.name })); }
|
|
447
|
+
}
|
|
448
|
+
for (const t of shown.keys()) if (t < f.k - tickHz) shown.delete(t);
|
|
449
|
+
}
|
|
450
|
+
const safe = (fn: () => boolean): boolean => { try { return fn() === true; } catch { return false; } };
|
|
451
|
+
|
|
324
452
|
/** The input values the last step held (a press is never held). */
|
|
325
453
|
let held: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
454
|
+
/*
|
|
455
|
+
* `f.held()` inputs (0.46.3). A step takes one value a tick, read at the tick's edge: a key down for 60 ms of a 50 ms
|
|
456
|
+
* tick then moves the body for one tick or for two, by where the edges fell. A held field is instead the MEAN of what
|
|
457
|
+
* the control read over the tick's own span, so the body moves for the part of the tick the key was down, the same on
|
|
458
|
+
* the server as here. Each field keeps its changes since the last edge it was read up to: [when (ms), value from then].
|
|
459
|
+
*/
|
|
460
|
+
const heldTrack = new Map<string, { from: number; changes: [number, number][] }>();
|
|
461
|
+
const heldNumber = (v: unknown, init: unknown): number => { const x = v === undefined ? init : v; return x === true ? 1 : typeof x === 'number' && Number.isFinite(x) ? x : 0; };
|
|
462
|
+
/** When tick `t` begins on this browser's clock, in ms. */
|
|
463
|
+
const edgeOf = (t: number): number => (base ? base.at + (t - base.tick) * period / speed : clock());
|
|
464
|
+
/** The mean of a held field from its last edge to `end`. `advance` moves the edge there; without it the tick is not over, and what is left of it (to `fill`) counts at the value the control reads now. */
|
|
465
|
+
function heldMean(name: string, init: unknown, end: number, advance: boolean, fill = end): number {
|
|
466
|
+
const now = heldNumber(sample[name], init);
|
|
467
|
+
let tr = heldTrack.get(name);
|
|
468
|
+
if (!tr) { tr = { from: end, changes: [[end, now]] }; heldTrack.set(name, tr); }
|
|
469
|
+
const span = fill - tr.from;
|
|
470
|
+
let sum = 0, value = tr.changes[0][1];
|
|
471
|
+
if (span > 0.001) {
|
|
472
|
+
let at = tr.from;
|
|
473
|
+
for (const [when, next] of tr.changes) { const w = Math.min(Math.max(when, tr.from), end); if (w > at) { sum += value * (w - at); at = w; } if (when <= end) value = next; }
|
|
474
|
+
if (end > at) sum += value * (end - at);
|
|
475
|
+
if (fill > end) sum += now * (fill - end);
|
|
476
|
+
}
|
|
477
|
+
const mean = span > 0.001 ? sum / span : now;
|
|
478
|
+
if (advance) { tr.changes = [[end, value], ...tr.changes.filter((c) => c[0] > end)]; tr.from = end; }
|
|
479
|
+
return mean;
|
|
480
|
+
}
|
|
326
481
|
/** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
|
|
327
482
|
function step(t: number, fresh = true): void {
|
|
328
483
|
const kind = myKind();
|
|
@@ -331,17 +486,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
331
486
|
const input: Record<string, unknown> = {};
|
|
332
487
|
let press = false;
|
|
333
488
|
for (const [name, fd] of kind.input) {
|
|
334
|
-
if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
489
|
+
if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else if (fd.t === 'held') { const v = coerce(fd, heldMean(name, fd.init, edgeOf(t), true), dims) as number; input[name] = v; values.push(v); } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
335
490
|
}
|
|
336
|
-
|
|
491
|
+
const numbered = fresh && pulseNumber.size ? Object.fromEntries(pulseNumber) : undefined;
|
|
492
|
+
if (fresh) { pressed = new Map(); pulseNumber = new Map(); }
|
|
337
493
|
held = Object.freeze({ ...input });
|
|
338
494
|
{
|
|
339
495
|
const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
|
|
340
496
|
mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
|
|
497
|
+
runPredict(kind.name, body, mine.fields, held, t, true, false);
|
|
498
|
+
previewKey = '';
|
|
341
499
|
}
|
|
342
500
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
343
501
|
const sig = JSON.stringify([values, claim]);
|
|
344
|
-
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
|
|
502
|
+
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
345
503
|
history.set(t, copyMine(mine));
|
|
346
504
|
for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
|
|
347
505
|
}
|
|
@@ -363,12 +521,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
363
521
|
function keepalive(t: number): void {
|
|
364
522
|
const kind = myKind();
|
|
365
523
|
if (!mine || !kind) return;
|
|
366
|
-
const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
524
|
+
const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
367
525
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
368
526
|
entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
369
527
|
}
|
|
528
|
+
let pumping = false;
|
|
370
529
|
function pump(): void {
|
|
371
|
-
|
|
530
|
+
// A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
|
|
531
|
+
if (closed || pumping) return;
|
|
532
|
+
pumping = true;
|
|
533
|
+
try { pumpOnce(); } finally { pumping = false; }
|
|
534
|
+
}
|
|
535
|
+
function pumpOnce(): void {
|
|
372
536
|
if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
373
537
|
const now = clock();
|
|
374
538
|
checkSolo(now);
|
|
@@ -379,7 +543,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
379
543
|
if (hidden) {
|
|
380
544
|
// A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
|
|
381
545
|
// one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
|
|
382
|
-
if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
|
|
546
|
+
if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); heldTrack.clear(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
|
|
383
547
|
base = null;
|
|
384
548
|
return;
|
|
385
549
|
}
|
|
@@ -403,6 +567,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
403
567
|
const before = latest;
|
|
404
568
|
if (before && before.e === f.e && f.k <= before.k) return;
|
|
405
569
|
latest = f;
|
|
570
|
+
if (before && before.e !== f.e) held2.length = 0;
|
|
571
|
+
held2.push(f); if (held2.length > 2 * tickHz) held2.shift();
|
|
406
572
|
if (!before || before.e !== f.e) collisionHistory = [];
|
|
407
573
|
const collision = f.collision;
|
|
408
574
|
if (collision) {
|
|
@@ -425,6 +591,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
425
591
|
if (placed) emit('placed', entityOf(me, true));
|
|
426
592
|
} else if (kindOf.get(me.kind)?.owner) {
|
|
427
593
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
594
|
+
if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
|
|
428
595
|
} else {
|
|
429
596
|
const previous = mine!.pos;
|
|
430
597
|
const shownBody = meNow();
|
|
@@ -434,13 +601,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
434
601
|
adopt(me);
|
|
435
602
|
history.set(f.k, copyMine(mine!));
|
|
436
603
|
const ack = row?.[2] ?? 0;
|
|
604
|
+
// A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
|
|
605
|
+
// can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
|
|
606
|
+
const taken = typeof row?.[4] === 'number' ? row[4] : null;
|
|
607
|
+
const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
|
|
608
|
+
const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
|
|
437
609
|
const late = new Map<string, unknown>();
|
|
610
|
+
// Held input the server had not received when it ran its tick (0.46.3): those ticks ran on the values before, and
|
|
611
|
+
// the server settles the difference on its next ticks (host.ts). The replay owes the same, so the body is
|
|
612
|
+
// predicted where the server will bring it and not where a late frame left it for a moment.
|
|
613
|
+
const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
|
|
614
|
+
const debt: Record<string, number> = {};
|
|
615
|
+
if (heldFields.length) {
|
|
616
|
+
for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
|
|
617
|
+
const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
|
|
618
|
+
if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
|
|
619
|
+
let meant: Readonly<Record<string, unknown>> | null = null;
|
|
620
|
+
for (const entry of waiting) if (entry.t <= i) meant = entry.input;
|
|
621
|
+
if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
438
624
|
for (const entry of pending) if (entry.t <= f.k) {
|
|
439
625
|
replayHeld = entry.input;
|
|
440
|
-
if (entry
|
|
441
|
-
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) late.set(name, entry.input[name]);
|
|
626
|
+
if (fresh(entry))
|
|
627
|
+
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t === 'pulse' ? owed(entry, name) : entry.t > ack)) late.set(name, entry.input[name]);
|
|
442
628
|
}
|
|
629
|
+
// After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
|
|
630
|
+
// (a `move` that reads `input.yaw` would be handed `undefined`).
|
|
443
631
|
let input = replayHeld;
|
|
632
|
+
if (!Object.keys(input).length) { const zero: Record<string, unknown> = {}; for (const [name, fd] of myKind()!.input) zero[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, fd.init, dims); input = Object.freeze(zero); }
|
|
444
633
|
for (let t = f.k + 1; t <= stepped; t++) {
|
|
445
634
|
const presses = new Map(t === f.k + 1 ? late : []);
|
|
446
635
|
for (const entry of pending) if (entry.t === t) {
|
|
@@ -449,10 +638,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
449
638
|
}
|
|
450
639
|
const stepInput = { ...input };
|
|
451
640
|
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
|
|
641
|
+
for (const name of heldFields) if (debt[name]) { const level = Number(stepInput[name]) || 0, range = Math.max(1, Math.abs(level)), want = level + debt[name], paid = Math.max(-range, Math.min(range, want)); stepInput[name] = paid; debt[name] = want - paid; }
|
|
452
642
|
Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
|
|
643
|
+
runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
|
|
453
644
|
history.set(t, copyMine(mine!));
|
|
454
645
|
}
|
|
455
|
-
pending = pending.filter(e => e.t > f.k || e.t > ack
|
|
646
|
+
pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
|
|
456
647
|
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
457
648
|
const error = distance(previous, mine!.pos);
|
|
458
649
|
correction.last = error;
|
|
@@ -504,7 +695,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
504
695
|
steer(now);
|
|
505
696
|
}
|
|
506
697
|
}
|
|
507
|
-
|
|
698
|
+
previewKey = '';
|
|
699
|
+
settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
|
|
700
|
+
} else { if (mine) dropPredictions(); mine = null; }
|
|
508
701
|
if (before) {
|
|
509
702
|
for (const [id, u] of f.ents) { const old = before.ents.get(id); if (!old) emit('enter', entityOf(u)); else if (old.r !== u.r && id !== mine?.id) emit('placed', entityOf(u)); }
|
|
510
703
|
for (const [id, u] of before.ents) if (!f.ents.has(id)) emit('leave', entityOf(u));
|
|
@@ -532,11 +725,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
532
725
|
for (let i = 0; i < fxQueue.length;) {
|
|
533
726
|
const x = fxQueue[i];
|
|
534
727
|
const ownTick = catchTick === null ? tickAt(clock()) : catchTick + Math.max(0, clock() - catchAt) / period * predict.catchUp;
|
|
535
|
-
if (x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
|
|
728
|
+
if (!x.confirms && x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
|
|
536
729
|
fxQueue.splice(i, 1);
|
|
537
730
|
const id = typeof x.at === 'string' ? x.at : null;
|
|
538
731
|
const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
|
|
539
|
-
emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
|
|
732
|
+
emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick, ...(x.confirms ? { confirms: true } : {}) }));
|
|
540
733
|
}
|
|
541
734
|
}
|
|
542
735
|
|
|
@@ -549,7 +742,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
549
742
|
net.on('event', (e) => {
|
|
550
743
|
if (e.k === 'fx' && Array.isArray(e.d)) {
|
|
551
744
|
const [tick, list] = e.d as [number, [number, unknown, unknown][]];
|
|
552
|
-
for (const [i, at, data] of Array.isArray(list) ? list : []) {
|
|
745
|
+
for (const [i, at, data] of Array.isArray(list) ? list : []) {
|
|
746
|
+
const name = schema.effectNames[i];
|
|
747
|
+
if (!name) continue;
|
|
748
|
+
const item: (typeof fxQueue)[number] = { tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) };
|
|
749
|
+
// The server's word on something this browser already showed (a predictor's `ctx.effect`): it confirms it.
|
|
750
|
+
if (provisional.length) {
|
|
751
|
+
const probe = { ...item.data, ...(typeof at === 'string' ? { id: at } : { at: unpackVec(at, dims) }), tick: item.tick };
|
|
752
|
+
const n = provisional.findIndex((x) => x.name === name && (!x.match || safe(() => x.match!(probe))));
|
|
753
|
+
if (n >= 0) { provisional.splice(n, 1); item.confirms = true; }
|
|
754
|
+
}
|
|
755
|
+
fxQueue.push(item);
|
|
756
|
+
}
|
|
553
757
|
return;
|
|
554
758
|
}
|
|
555
759
|
// A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
|
|
@@ -594,14 +798,30 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
594
798
|
if (kind && base) {
|
|
595
799
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
596
800
|
const now: Record<string, unknown> = {};
|
|
597
|
-
|
|
801
|
+
// A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
|
|
802
|
+
// the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
|
|
803
|
+
// and stops dead at a release, with nothing to blend away afterwards.
|
|
804
|
+
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
598
805
|
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);
|
|
599
|
-
|
|
806
|
+
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
807
|
+
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
808
|
+
// then and standing from then, not sinking until the tick's end.
|
|
809
|
+
let av = a, landed = false;
|
|
810
|
+
if (dims === 3 && !mine.grounded && next.grounded && mine.vel.z < 0) {
|
|
811
|
+
const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
|
|
812
|
+
av = Math.min(1, a / contact); landed = a >= contact;
|
|
813
|
+
}
|
|
814
|
+
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, av) }, dims);
|
|
600
815
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
601
816
|
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);
|
|
602
817
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
603
|
-
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
818
|
+
grounded = landed || mine.grounded && (a === 0 || next.grounded);
|
|
604
819
|
preview = next;
|
|
820
|
+
if (predictors[kind.name]) {
|
|
821
|
+
// The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
|
|
822
|
+
const key = stepped + JSON.stringify(now);
|
|
823
|
+
if (key !== previewKey) { previewKey = key; previewFields = thawFields(kind.fields, mine.fields); runPredict(kind.name, next, previewFields, Object.freeze(now), stepped + 1, true, true); }
|
|
824
|
+
}
|
|
605
825
|
settled = distance(next.pos, mine.pos) < 0.00001;
|
|
606
826
|
}
|
|
607
827
|
if (catchTick !== null && base) {
|
|
@@ -625,7 +845,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
625
845
|
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
626
846
|
if (t >= 1) headingBlend = null;
|
|
627
847
|
}
|
|
628
|
-
return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
|
|
848
|
+
return entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
|
|
629
849
|
}
|
|
630
850
|
function drawn(id: string): Entity | null {
|
|
631
851
|
if (solo) return solo.get(id);
|
|
@@ -641,7 +861,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
641
861
|
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
642
862
|
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 });
|
|
643
863
|
}
|
|
644
|
-
function get(id: string): Entity | null { return drawn(id); }
|
|
864
|
+
function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
|
|
645
865
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
646
866
|
pump();
|
|
647
867
|
if (solo) { solo.each(kind, fn); return; }
|
|
@@ -653,6 +873,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
653
873
|
for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
|
|
654
874
|
if (mine && mine.kind === kind) ids.add(mine.id);
|
|
655
875
|
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
876
|
+
// A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
|
|
877
|
+
for (const g of [...ghosts.values()]) if (g.kind === kind) { if (g.real && ids.has(g.real)) { ghosts.delete(g.id); emit('leave', ghostEntity(g)); } else fn(ghostEntity(g)); }
|
|
656
878
|
}
|
|
657
879
|
function roster(): RosterRow[] {
|
|
658
880
|
if (solo) return solo.roster;
|
|
@@ -684,7 +906,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
684
906
|
// traffic, writes no room save to the server, and closes as soon as a body arrives.
|
|
685
907
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
686
908
|
const probes = [g.__homieNet, g.__homiePort];
|
|
687
|
-
solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
|
|
909
|
+
solo = openRoom<R>({ game, move: moves, predict: opts.predict, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
|
|
688
910
|
[g.__homieNet, g.__homiePort] = probes;
|
|
689
911
|
for (const name of listeners.keys()) forwardSolo(name);
|
|
690
912
|
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');
|
|
@@ -715,24 +937,53 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
715
937
|
const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
|
|
716
938
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
717
939
|
|
|
718
|
-
const
|
|
940
|
+
function viewTickNow(): number { const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; }
|
|
941
|
+
/*
|
|
942
|
+
* WHERE A BODY WAS, OR WILL BE JUDGED TO BE (0.46.5). The server judges a rewound ray against poses it kept by
|
|
943
|
+
* tick. A browser holds the snapshots it has received: a pose at a tick between two of them is the point between;
|
|
944
|
+
* past the newest it is that snapshot's pose carried on along its velocity (`known: false`: a guess); before the
|
|
945
|
+
* oldest it is the oldest. A placement between the two is not glided across.
|
|
946
|
+
*/
|
|
947
|
+
function poseAt(id: string, at: number): { pos: Vec3; vel: Vec3; known: boolean } | null {
|
|
948
|
+
let before: Frame | null = null, after: Frame | null = null;
|
|
949
|
+
for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
|
|
950
|
+
const a = before?.ents.get(id), b = after?.ents.get(id);
|
|
951
|
+
if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
|
|
952
|
+
if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
|
|
953
|
+
return b ? { pos: b.pos, vel: b.vel, known: false } : null;
|
|
954
|
+
}
|
|
955
|
+
const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
|
|
956
|
+
/** How many ticks back this seat's ray may look: the server's own figure from the newest snapshot, else the rule it follows with this browser's round trip. */
|
|
957
|
+
function rewindAllowance(): number {
|
|
958
|
+
const seat = net.seat, row = seat === null ? undefined : latest?.rows.find((x) => x[0] === seat);
|
|
959
|
+
const history = Math.ceil((schema.rewind?.historySeconds ?? 0) * tickHz);
|
|
960
|
+
if (typeof row?.[5] === 'number') return row[5];
|
|
961
|
+
const cap = schema.rewind?.capSeconds !== undefined ? Math.ceil(schema.rewind.capSeconds * tickHz) : history;
|
|
962
|
+
return Math.min(history, cap, Math.ceil((authorityRtt() + 150) / period) + 1);
|
|
963
|
+
}
|
|
964
|
+
const viewTargets = (atTick?: number) => {
|
|
719
965
|
pump();
|
|
720
966
|
const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
|
|
721
967
|
for(const k of schema.kinds) {
|
|
722
968
|
if(k.collider || !k.radius)continue;
|
|
723
|
-
each(k.name,e=>
|
|
969
|
+
each(k.name,e=>{
|
|
970
|
+
// At a tick: where the server will have the body, with the fields as they are now (the server filters on current fields too).
|
|
971
|
+
const at = atTick !== undefined && e.id !== mine?.id && !e.provisional ? poseAt(e.id, atTick)?.pos ?? e.pos : e.pos;
|
|
972
|
+
targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(at,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at});
|
|
973
|
+
});
|
|
724
974
|
}
|
|
725
975
|
return targets;
|
|
726
976
|
};
|
|
727
|
-
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
|
|
977
|
+
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
|
|
728
978
|
return {
|
|
729
|
-
get viewTick() {
|
|
979
|
+
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
730
980
|
get status() { return solo ? 'offline' : status; },
|
|
731
981
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
732
982
|
get me() { pump(); return meNow(); },
|
|
733
983
|
each, get,
|
|
734
984
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
735
|
-
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
|
|
985
|
+
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
|
|
986
|
+
poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
|
|
736
987
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
737
988
|
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); }; },
|
|
738
989
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
@@ -748,9 +999,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
748
999
|
// A fresh control can replace a preview partway through a tick (especially
|
|
749
1000
|
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
750
1001
|
// may advance the new path, rather than drawing elapsed time with new input.
|
|
1002
|
+
// A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
|
|
1003
|
+
const at = clock();
|
|
1004
|
+
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
|
|
1005
|
+
const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
|
|
1006
|
+
if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
|
|
1007
|
+
else if (tr.changes[tr.changes.length - 1][1] !== v) { tr.changes.push([at, v]); if (tr.changes.length > 256) tr.changes.splice(1, tr.changes.length - 256); }
|
|
1008
|
+
}
|
|
751
1009
|
if (changed) pump();
|
|
752
1010
|
const shown = changed ? meNow()?.pos : null;
|
|
753
|
-
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name))net.send('pulse',[epoch,name,value]); pressed.set(name,value); }
|
|
1011
|
+
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name)){pulseCount+=1;pulseNumber.set(name,pulseCount);net.send('pulse',[epoch,name,value,stepped+1,pulseCount]);} pressed.set(name,value); }
|
|
754
1012
|
sample = next;
|
|
755
1013
|
if (shown) {
|
|
756
1014
|
const now = clock(), nextShown = meNow()?.pos;
|