@homie-rocks/studio 0.46.2 → 0.46.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/guides/game/RULES.md +104 -3
- package/guides/game/SKILL.md +5 -0
- 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 +42 -0
- package/netplay/netplay.ts +1 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +85 -13
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +16 -5
- package/rules/types.ts +5 -0
- package/rules/view.ts +229 -25
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +2 -0
- 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/rules.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const QUEUE_MAX = 16_384;
|
|
|
54
54
|
|
|
55
55
|
/* ------------------------------------------------------------------ field types */
|
|
56
56
|
|
|
57
|
-
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
|
|
57
|
+
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse' | 'held';
|
|
58
58
|
export interface Field {
|
|
59
59
|
readonly t: FieldKind;
|
|
60
60
|
/** The value a new entity starts with. */
|
|
@@ -82,7 +82,8 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
|
82
82
|
* false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
|
|
83
83
|
* 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
|
|
84
84
|
* entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
|
|
85
|
-
* fires once
|
|
85
|
+
* fires once; `held` (in `input` only) is a number the browser averages over each tick, so a tap shorter than a tick
|
|
86
|
+
* moves for the part of the tick it was down.
|
|
86
87
|
*/
|
|
87
88
|
export const f = brand(Object.freeze({
|
|
88
89
|
u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
|
|
@@ -93,6 +94,8 @@ export const f = brand(Object.freeze({
|
|
|
93
94
|
struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
|
|
94
95
|
press: (): Field => made('press', undefined),
|
|
95
96
|
pulse: (): Field => made('pulse', undefined),
|
|
97
|
+
/** Input only (0.46.3): a number averaged over the tick. A key down for a third of a tick reads a third; a stick reads its mean. */
|
|
98
|
+
held: (): Field => made('held', undefined),
|
|
96
99
|
}));
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -167,6 +170,8 @@ export interface RulesDef {
|
|
|
167
170
|
bots?: { keep: number };
|
|
168
171
|
records?: RoundRecords;
|
|
169
172
|
historySeconds?: number;
|
|
173
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
174
|
+
rewindSeconds?: number;
|
|
170
175
|
start?: RoomHandler;
|
|
171
176
|
join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
|
|
172
177
|
on?: Record<string, RoomHandler>;
|
|
@@ -387,6 +392,8 @@ export interface Compiled {
|
|
|
387
392
|
view: FieldList;
|
|
388
393
|
records?: RoundRecords;
|
|
389
394
|
historySeconds?: number;
|
|
395
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
396
|
+
rewindSeconds?: number;
|
|
390
397
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
391
398
|
bots: number;
|
|
392
399
|
start: RoomHandler | null;
|
|
@@ -409,7 +416,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
|
|
|
409
416
|
|
|
410
417
|
const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
|
|
411
418
|
const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
|
|
412
|
-
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
|
|
419
|
+
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse', 'held']);
|
|
413
420
|
|
|
414
421
|
/**
|
|
415
422
|
* One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
|
|
@@ -436,7 +443,8 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
|
|
|
436
443
|
const x = fd as Field;
|
|
437
444
|
if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
|
|
438
445
|
if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
|
|
439
|
-
if (
|
|
446
|
+
if (x.t === 'held' && !inInput) throw new Error(`${where}: f.held() is for input only (a control averaged over the tick it was held in)`);
|
|
447
|
+
if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'held' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.held(), f.bit() or f.press()`);
|
|
440
448
|
if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
|
|
441
449
|
if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
|
|
442
450
|
if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
|
|
@@ -646,13 +654,14 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
646
654
|
}
|
|
647
655
|
}
|
|
648
656
|
if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
|
|
657
|
+
if (room.rewindSeconds !== undefined && (typeof room.rewindSeconds !== 'number' || !Number.isFinite(room.rewindSeconds) || room.rewindSeconds < 0 || room.rewindSeconds > (room.historySeconds ?? 0))) throw new Error('room.rewindSeconds is the most a shot is rewound for a slow connection: a number from 0 to room.historySeconds');
|
|
649
658
|
const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
|
|
650
659
|
const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
|
|
651
660
|
if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
|
|
652
661
|
Object.freeze(navigation);
|
|
653
662
|
return {
|
|
654
663
|
navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
655
|
-
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
664
|
+
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
656
665
|
tune, publicTune, map, settings, seats,
|
|
657
666
|
};
|
|
658
667
|
}
|
|
@@ -674,6 +683,8 @@ export interface Schema {
|
|
|
674
683
|
commands: Record<string, FieldList>;
|
|
675
684
|
shared: FieldList;
|
|
676
685
|
historySeconds?: number;
|
|
686
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
687
|
+
rewindSeconds?: number;
|
|
677
688
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
678
689
|
settings: RoomSettings;
|
|
679
690
|
}
|
package/rules/types.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface RayOptions {
|
|
|
22
22
|
export type RayHit = Hit & { readonly dist: number };
|
|
23
23
|
export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
|
|
24
24
|
export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
|
|
25
|
+
export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
|
|
25
26
|
export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
|
|
26
27
|
export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
27
28
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
@@ -30,6 +31,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
|
30
31
|
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;
|
|
31
32
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
32
33
|
readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
|
|
34
|
+
/** Budget units the running handler may still spend (0.46.3). */
|
|
35
|
+
readonly unitsLeft: number;
|
|
33
36
|
random(): number;
|
|
34
37
|
label(player:string):string;
|
|
35
38
|
send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
|
|
@@ -38,6 +41,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
|
38
41
|
sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
|
|
39
42
|
after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
|
|
40
43
|
emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
|
|
44
|
+
/** With an audience (0.46.3): only the people driving the entities named in `to`, and those whose own body is within `within` metres, receive it. A watcher receives the ones with a range, never one addressed only `to` somebody. */
|
|
45
|
+
emit<K extends keyof F>(effect: K, at: Point | string, data: WriteState<F[K]>, audience: EffectAudience): void;
|
|
41
46
|
spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
|
|
42
47
|
near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
|
|
43
48
|
near(at: Point, radius: number): readonly Query<V[keyof V]>[];
|
package/rules/view.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { lab } from '../lab/lab.ts';
|
|
|
49
49
|
import { exposePort, type PortProbeOptions } from '../port/probe.ts';
|
|
50
50
|
import { BudgetError } from './guard.ts';
|
|
51
51
|
import { moveContext } from './math.ts';
|
|
52
|
-
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.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,12 +95,37 @@ 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 tick the other bodies are drawn at: what the player is looking at. */
|
|
117
|
+
readonly viewTick: number;
|
|
118
|
+
/** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
|
|
119
|
+
readonly preview: boolean;
|
|
120
|
+
/** 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. */
|
|
121
|
+
ghost(kind: string, at: { x: number; y: number; z?: number }, fields?: Record<string, unknown>, options?: { seconds?: number; match?: (e: Entity) => boolean; key?: string }): string;
|
|
122
|
+
/**
|
|
123
|
+
* 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.
|
|
124
|
+
* 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.
|
|
125
|
+
*/
|
|
126
|
+
effect(name: string, at: { x: number; y: number; z?: number } | string, data?: Record<string, unknown>, options?: { match?: (e: any) => boolean; key?: string }): void;
|
|
127
|
+
}
|
|
128
|
+
export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
|
|
104
129
|
export interface Room<R = unknown> extends RayQueries {
|
|
105
130
|
readonly viewTick: number;
|
|
106
131
|
readonly status: RoomStatus;
|
|
@@ -163,7 +188,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
163
188
|
let round: RoomRound | null = null;
|
|
164
189
|
let results: RoundInfo['results'] | undefined;
|
|
165
190
|
let closed = false;
|
|
166
|
-
const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown
|
|
191
|
+
const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown>; confirms?: boolean }[] = [];
|
|
167
192
|
const setStatus = (s: RoomStatus): void => { if (s !== status) { status = s; emit('status', s); } };
|
|
168
193
|
|
|
169
194
|
/* ---------------------------------------------------------------- frames in */
|
|
@@ -186,7 +211,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
186
211
|
|
|
187
212
|
/* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
|
|
188
213
|
|
|
189
|
-
interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
|
|
214
|
+
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
215
|
let mine: Mine | null = null;
|
|
191
216
|
/** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
|
|
192
217
|
let base: { tick: number; at: number } | null = null;
|
|
@@ -203,8 +228,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
203
228
|
let entries: { t: number; row: number[] }[] = [];
|
|
204
229
|
let hiddenSent = false;
|
|
205
230
|
const history = new Map<number, Mine>();
|
|
206
|
-
let pending: { t: number; input: Readonly<Record<string, unknown
|
|
231
|
+
let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
|
|
232
|
+
/** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
|
|
233
|
+
let pulseCount = 0;
|
|
234
|
+
let pulseNumber = new Map<string, number>();
|
|
207
235
|
let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
236
|
+
/** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
|
|
237
|
+
let serverHeld: Record<string, number> = {};
|
|
208
238
|
let speed = 1;
|
|
209
239
|
let leadAfter = 0;
|
|
210
240
|
let observedLeadAt = 0;
|
|
@@ -294,7 +324,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
294
324
|
leadAfter = clock() + 2 * rtt + period;
|
|
295
325
|
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
296
326
|
stepped = Math.floor(base.tick) - 1;
|
|
297
|
-
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
|
|
327
|
+
entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
298
328
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
299
329
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
300
330
|
if (mine) {
|
|
@@ -305,7 +335,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
305
335
|
/** `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
336
|
const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
|
|
307
337
|
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) };
|
|
338
|
+
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
339
|
}
|
|
310
340
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
311
341
|
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 +351,117 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
321
351
|
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
352
|
return out;
|
|
323
353
|
}
|
|
354
|
+
/* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
|
|
355
|
+
|
|
356
|
+
const predictors = opts.predict ?? {};
|
|
357
|
+
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 }
|
|
358
|
+
const ghosts = new Map<string, Ghost>();
|
|
359
|
+
let ghostSeq = 0;
|
|
360
|
+
let provisional: { name: string; tick: number; payload: Record<string, unknown>; match?: (e: any) => boolean }[] = [];
|
|
361
|
+
/** 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. */
|
|
362
|
+
const shown = new Map<number, Set<string>>();
|
|
363
|
+
let previewKey = '', previewFields: Record<string, unknown> | null = null;
|
|
364
|
+
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;
|
|
365
|
+
const ZERO3: Vec3 = Object.freeze({ x: 0, y: 0, z: 0 });
|
|
366
|
+
function dropPredictions(): void {
|
|
367
|
+
for (const g of ghosts.values()) if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick });
|
|
368
|
+
for (const x of provisional) emit('retract', { ...x.payload, effect: x.name, tick: x.tick });
|
|
369
|
+
ghosts.clear(); provisional = []; shown.clear(); previewKey = ''; previewFields = null;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The game's predictor for one tick of the own body. `live` is false while a snapshot's pending inputs are run again:
|
|
373
|
+
* that run only brings the fields up to date, and shows nothing.
|
|
374
|
+
*/
|
|
375
|
+
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 {
|
|
376
|
+
const fn = predictors[kindName], kind = kindOf.get(kindName);
|
|
377
|
+
if (!fn || !kind || !mine) return;
|
|
378
|
+
const id = mine.id;
|
|
379
|
+
// 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
|
|
380
|
+
// run has asked for: never by the order of everything asked, which changes when another action joins the tick.
|
|
381
|
+
const asked = new Map<string, number>();
|
|
382
|
+
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; };
|
|
383
|
+
const ctx: PredictContext = {
|
|
384
|
+
tick: t, dt: 1 / tickHz, preview,
|
|
385
|
+
get viewTick() { return viewTickNow(); },
|
|
386
|
+
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
387
|
+
rays: (options) => raySnapshot(map, viewTargets(), dims, options, id),
|
|
388
|
+
rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
|
|
389
|
+
ghost(kindOfGhost, at, data = {}, options = {}) {
|
|
390
|
+
const k = kindOf.get(kindOfGhost);
|
|
391
|
+
if (!k) { console.warn(`[room] predict: no kind "${kindOfGhost}"`); return ''; }
|
|
392
|
+
if (!once('g:' + kindOfGhost + ':' + (options.key ?? ''))) return '';
|
|
393
|
+
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 };
|
|
394
|
+
ghosts.set(g.id, g);
|
|
395
|
+
emit('enter', ghostEntity(g));
|
|
396
|
+
return g.id;
|
|
397
|
+
},
|
|
398
|
+
effect(name, at, data = {}, options = {}) {
|
|
399
|
+
if (!schema.effects[name]) { console.warn(`[room] predict: no effect "${name}" is declared in shapes.effects`); return; }
|
|
400
|
+
if (!once('e:' + name + ':' + (options.key ?? ''))) return;
|
|
401
|
+
const target = typeof at === 'string' ? at : null;
|
|
402
|
+
const payload = { ...data, at: target ? get(target)?.pos ?? null : vec3(at as Vec3, dims), ...(target ? { id: target } : {}), tick: t };
|
|
403
|
+
// The server's own effect may already be here, waiting for its tick to be drawn (it acted before this browser
|
|
404
|
+
// stepped the tick, and its snapshot is still on the way): that one is this prediction's confirmation.
|
|
405
|
+
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 }))));
|
|
406
|
+
if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
|
|
407
|
+
emit(name, Object.freeze({ ...payload, predicted: true }));
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
|
|
411
|
+
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 });
|
|
412
|
+
try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
|
|
413
|
+
// What it wrote is held to the declared types, as the server holds its own writes.
|
|
414
|
+
for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
|
|
415
|
+
for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
|
|
416
|
+
}
|
|
417
|
+
/** After a snapshot: a ghost or a provisional effect whose tick the server has run without confirming it is taken back. */
|
|
418
|
+
function settlePredictions(f: Frame, before: Frame | null, ack: number): void {
|
|
419
|
+
if (ghosts.size) {
|
|
420
|
+
for (const [id, u] of f.ents) {
|
|
421
|
+
if (before?.ents.has(id)) continue;
|
|
422
|
+
const e = entityOf(u);
|
|
423
|
+
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; }
|
|
424
|
+
}
|
|
425
|
+
const now = clock();
|
|
426
|
+
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)); }
|
|
427
|
+
}
|
|
428
|
+
if (provisional.length) {
|
|
429
|
+
const gone = provisional.filter((x) => ack >= x.tick && f.k > x.tick);
|
|
430
|
+
if (gone.length) { provisional = provisional.filter((x) => !gone.includes(x)); for (const x of gone) emit('retract', Object.freeze({ ...x.payload, effect: x.name })); }
|
|
431
|
+
}
|
|
432
|
+
for (const t of shown.keys()) if (t < f.k - tickHz) shown.delete(t);
|
|
433
|
+
}
|
|
434
|
+
const safe = (fn: () => boolean): boolean => { try { return fn() === true; } catch { return false; } };
|
|
435
|
+
|
|
324
436
|
/** The input values the last step held (a press is never held). */
|
|
325
437
|
let held: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
438
|
+
/*
|
|
439
|
+
* `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
|
|
440
|
+
* 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
|
|
441
|
+
* 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
|
|
442
|
+
* the server as here. Each field keeps its changes since the last edge it was read up to: [when (ms), value from then].
|
|
443
|
+
*/
|
|
444
|
+
const heldTrack = new Map<string, { from: number; changes: [number, number][] }>();
|
|
445
|
+
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; };
|
|
446
|
+
/** When tick `t` begins on this browser's clock, in ms. */
|
|
447
|
+
const edgeOf = (t: number): number => (base ? base.at + (t - base.tick) * period / speed : clock());
|
|
448
|
+
/** 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. */
|
|
449
|
+
function heldMean(name: string, init: unknown, end: number, advance: boolean, fill = end): number {
|
|
450
|
+
const now = heldNumber(sample[name], init);
|
|
451
|
+
let tr = heldTrack.get(name);
|
|
452
|
+
if (!tr) { tr = { from: end, changes: [[end, now]] }; heldTrack.set(name, tr); }
|
|
453
|
+
const span = fill - tr.from;
|
|
454
|
+
let sum = 0, value = tr.changes[0][1];
|
|
455
|
+
if (span > 0.001) {
|
|
456
|
+
let at = tr.from;
|
|
457
|
+
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; }
|
|
458
|
+
if (end > at) sum += value * (end - at);
|
|
459
|
+
if (fill > end) sum += now * (fill - end);
|
|
460
|
+
}
|
|
461
|
+
const mean = span > 0.001 ? sum / span : now;
|
|
462
|
+
if (advance) { tr.changes = [[end, value], ...tr.changes.filter((c) => c[0] > end)]; tr.from = end; }
|
|
463
|
+
return mean;
|
|
464
|
+
}
|
|
326
465
|
/** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
|
|
327
466
|
function step(t: number, fresh = true): void {
|
|
328
467
|
const kind = myKind();
|
|
@@ -331,17 +470,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
331
470
|
const input: Record<string, unknown> = {};
|
|
332
471
|
let press = false;
|
|
333
472
|
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); }
|
|
473
|
+
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
474
|
}
|
|
336
|
-
|
|
475
|
+
const numbered = fresh && pulseNumber.size ? Object.fromEntries(pulseNumber) : undefined;
|
|
476
|
+
if (fresh) { pressed = new Map(); pulseNumber = new Map(); }
|
|
337
477
|
held = Object.freeze({ ...input });
|
|
338
478
|
{
|
|
339
479
|
const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
|
|
340
480
|
mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
|
|
481
|
+
runPredict(kind.name, body, mine.fields, held, t, true, false);
|
|
482
|
+
previewKey = '';
|
|
341
483
|
}
|
|
342
484
|
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
485
|
const sig = JSON.stringify([values, claim]);
|
|
344
|
-
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
|
|
486
|
+
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
345
487
|
history.set(t, copyMine(mine));
|
|
346
488
|
for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
|
|
347
489
|
}
|
|
@@ -363,12 +505,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
363
505
|
function keepalive(t: number): void {
|
|
364
506
|
const kind = myKind();
|
|
365
507
|
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))));
|
|
508
|
+
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
509
|
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
510
|
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
511
|
}
|
|
512
|
+
let pumping = false;
|
|
370
513
|
function pump(): void {
|
|
371
|
-
|
|
514
|
+
// A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
|
|
515
|
+
if (closed || pumping) return;
|
|
516
|
+
pumping = true;
|
|
517
|
+
try { pumpOnce(); } finally { pumping = false; }
|
|
518
|
+
}
|
|
519
|
+
function pumpOnce(): void {
|
|
372
520
|
if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
373
521
|
const now = clock();
|
|
374
522
|
checkSolo(now);
|
|
@@ -379,7 +527,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
379
527
|
if (hidden) {
|
|
380
528
|
// A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
|
|
381
529
|
// 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); }
|
|
530
|
+
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
531
|
base = null;
|
|
384
532
|
return;
|
|
385
533
|
}
|
|
@@ -425,6 +573,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
425
573
|
if (placed) emit('placed', entityOf(me, true));
|
|
426
574
|
} else if (kindOf.get(me.kind)?.owner) {
|
|
427
575
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
576
|
+
if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
|
|
428
577
|
} else {
|
|
429
578
|
const previous = mine!.pos;
|
|
430
579
|
const shownBody = meNow();
|
|
@@ -434,13 +583,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
434
583
|
adopt(me);
|
|
435
584
|
history.set(f.k, copyMine(mine!));
|
|
436
585
|
const ack = row?.[2] ?? 0;
|
|
586
|
+
// A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
|
|
587
|
+
// can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
|
|
588
|
+
const taken = typeof row?.[4] === 'number' ? row[4] : null;
|
|
589
|
+
const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
|
|
590
|
+
const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
|
|
437
591
|
const late = new Map<string, unknown>();
|
|
592
|
+
// Held input the server had not received when it ran its tick (0.46.3): those ticks ran on the values before, and
|
|
593
|
+
// the server settles the difference on its next ticks (host.ts). The replay owes the same, so the body is
|
|
594
|
+
// predicted where the server will bring it and not where a late frame left it for a moment.
|
|
595
|
+
const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
|
|
596
|
+
const debt: Record<string, number> = {};
|
|
597
|
+
if (heldFields.length) {
|
|
598
|
+
for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
|
|
599
|
+
const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
|
|
600
|
+
if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
|
|
601
|
+
let meant: Readonly<Record<string, unknown>> | null = null;
|
|
602
|
+
for (const entry of waiting) if (entry.t <= i) meant = entry.input;
|
|
603
|
+
if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
438
606
|
for (const entry of pending) if (entry.t <= f.k) {
|
|
439
607
|
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]);
|
|
608
|
+
if (fresh(entry))
|
|
609
|
+
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
610
|
}
|
|
611
|
+
// After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
|
|
612
|
+
// (a `move` that reads `input.yaw` would be handed `undefined`).
|
|
443
613
|
let input = replayHeld;
|
|
614
|
+
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
615
|
for (let t = f.k + 1; t <= stepped; t++) {
|
|
445
616
|
const presses = new Map(t === f.k + 1 ? late : []);
|
|
446
617
|
for (const entry of pending) if (entry.t === t) {
|
|
@@ -449,10 +620,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
449
620
|
}
|
|
450
621
|
const stepInput = { ...input };
|
|
451
622
|
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
|
|
623
|
+
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
624
|
Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
|
|
625
|
+
runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
|
|
453
626
|
history.set(t, copyMine(mine!));
|
|
454
627
|
}
|
|
455
|
-
pending = pending.filter(e => e.t > f.k || e.t > ack
|
|
628
|
+
pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
|
|
456
629
|
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
457
630
|
const error = distance(previous, mine!.pos);
|
|
458
631
|
correction.last = error;
|
|
@@ -504,7 +677,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
504
677
|
steer(now);
|
|
505
678
|
}
|
|
506
679
|
}
|
|
507
|
-
|
|
680
|
+
previewKey = '';
|
|
681
|
+
settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
|
|
682
|
+
} else { if (mine) dropPredictions(); mine = null; }
|
|
508
683
|
if (before) {
|
|
509
684
|
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
685
|
for (const [id, u] of before.ents) if (!f.ents.has(id)) emit('leave', entityOf(u));
|
|
@@ -532,11 +707,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
532
707
|
for (let i = 0; i < fxQueue.length;) {
|
|
533
708
|
const x = fxQueue[i];
|
|
534
709
|
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; }
|
|
710
|
+
if (!x.confirms && x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
|
|
536
711
|
fxQueue.splice(i, 1);
|
|
537
712
|
const id = typeof x.at === 'string' ? x.at : null;
|
|
538
713
|
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 }));
|
|
714
|
+
emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick, ...(x.confirms ? { confirms: true } : {}) }));
|
|
540
715
|
}
|
|
541
716
|
}
|
|
542
717
|
|
|
@@ -549,7 +724,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
549
724
|
net.on('event', (e) => {
|
|
550
725
|
if (e.k === 'fx' && Array.isArray(e.d)) {
|
|
551
726
|
const [tick, list] = e.d as [number, [number, unknown, unknown][]];
|
|
552
|
-
for (const [i, at, data] of Array.isArray(list) ? list : []) {
|
|
727
|
+
for (const [i, at, data] of Array.isArray(list) ? list : []) {
|
|
728
|
+
const name = schema.effectNames[i];
|
|
729
|
+
if (!name) continue;
|
|
730
|
+
const item: (typeof fxQueue)[number] = { tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) };
|
|
731
|
+
// The server's word on something this browser already showed (a predictor's `ctx.effect`): it confirms it.
|
|
732
|
+
if (provisional.length) {
|
|
733
|
+
const probe = { ...item.data, ...(typeof at === 'string' ? { id: at } : { at: unpackVec(at, dims) }), tick: item.tick };
|
|
734
|
+
const n = provisional.findIndex((x) => x.name === name && (!x.match || safe(() => x.match!(probe))));
|
|
735
|
+
if (n >= 0) { provisional.splice(n, 1); item.confirms = true; }
|
|
736
|
+
}
|
|
737
|
+
fxQueue.push(item);
|
|
738
|
+
}
|
|
553
739
|
return;
|
|
554
740
|
}
|
|
555
741
|
// A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
|
|
@@ -594,7 +780,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
594
780
|
if (kind && base) {
|
|
595
781
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
596
782
|
const now: Record<string, unknown> = {};
|
|
597
|
-
|
|
783
|
+
// A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
|
|
784
|
+
// the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
|
|
785
|
+
// and stops dead at a release, with nothing to blend away afterwards.
|
|
786
|
+
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
787
|
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
788
|
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);
|
|
600
789
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
@@ -602,6 +791,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
602
791
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
603
792
|
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
604
793
|
preview = next;
|
|
794
|
+
if (predictors[kind.name]) {
|
|
795
|
+
// The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
|
|
796
|
+
const key = stepped + JSON.stringify(now);
|
|
797
|
+
if (key !== previewKey) { previewKey = key; previewFields = thawFields(kind.fields, mine.fields); runPredict(kind.name, next, previewFields, Object.freeze(now), stepped + 1, true, true); }
|
|
798
|
+
}
|
|
605
799
|
settled = distance(next.pos, mine.pos) < 0.00001;
|
|
606
800
|
}
|
|
607
801
|
if (catchTick !== null && base) {
|
|
@@ -625,7 +819,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
625
819
|
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
626
820
|
if (t >= 1) headingBlend = null;
|
|
627
821
|
}
|
|
628
|
-
return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
|
|
822
|
+
return entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
|
|
629
823
|
}
|
|
630
824
|
function drawn(id: string): Entity | null {
|
|
631
825
|
if (solo) return solo.get(id);
|
|
@@ -641,7 +835,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
641
835
|
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
642
836
|
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
837
|
}
|
|
644
|
-
function get(id: string): Entity | null { return drawn(id); }
|
|
838
|
+
function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
|
|
645
839
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
646
840
|
pump();
|
|
647
841
|
if (solo) { solo.each(kind, fn); return; }
|
|
@@ -653,6 +847,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
653
847
|
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
848
|
if (mine && mine.kind === kind) ids.add(mine.id);
|
|
655
849
|
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
850
|
+
// A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
|
|
851
|
+
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
852
|
}
|
|
657
853
|
function roster(): RosterRow[] {
|
|
658
854
|
if (solo) return solo.roster;
|
|
@@ -684,7 +880,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
684
880
|
// traffic, writes no room save to the server, and closes as soon as a body arrives.
|
|
685
881
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
686
882
|
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 } });
|
|
883
|
+
solo = openRoom<R>({ game, move: moves, predict: opts.predict, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
|
|
688
884
|
[g.__homieNet, g.__homiePort] = probes;
|
|
689
885
|
for (const name of listeners.keys()) forwardSolo(name);
|
|
690
886
|
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,6 +911,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
715
911
|
const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
|
|
716
912
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
717
913
|
|
|
914
|
+
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; }
|
|
718
915
|
const viewTargets = () => {
|
|
719
916
|
pump();
|
|
720
917
|
const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
|
|
@@ -726,7 +923,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
726
923
|
};
|
|
727
924
|
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
|
|
728
925
|
return {
|
|
729
|
-
get viewTick() {
|
|
926
|
+
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
730
927
|
get status() { return solo ? 'offline' : status; },
|
|
731
928
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
732
929
|
get me() { pump(); return meNow(); },
|
|
@@ -748,9 +945,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
748
945
|
// A fresh control can replace a preview partway through a tick (especially
|
|
749
946
|
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
750
947
|
// may advance the new path, rather than drawing elapsed time with new input.
|
|
948
|
+
// A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
|
|
949
|
+
const at = clock();
|
|
950
|
+
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
|
|
951
|
+
const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
|
|
952
|
+
if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
|
|
953
|
+
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); }
|
|
954
|
+
}
|
|
751
955
|
if (changed) pump();
|
|
752
956
|
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); }
|
|
957
|
+
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
958
|
sample = next;
|
|
755
959
|
if (shown) {
|
|
756
960
|
const now = clock(), nextShown = meNow()?.pos;
|