@colyseus/core 0.17.44 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/MatchMaker.cjs +22 -13
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.d.ts +10 -0
- package/build/MatchMaker.mjs +22 -14
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +102 -37
- package/build/Protocol.cjs.map +2 -2
- package/build/Protocol.d.ts +33 -2
- package/build/Protocol.mjs +102 -37
- package/build/Protocol.mjs.map +2 -2
- package/build/Rewind.cjs +385 -0
- package/build/Rewind.cjs.map +7 -0
- package/build/Rewind.d.ts +291 -0
- package/build/Rewind.mjs +359 -0
- package/build/Rewind.mjs.map +7 -0
- package/build/Room.cjs +425 -149
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +314 -27
- package/build/Room.mjs +434 -152
- package/build/Room.mjs.map +3 -3
- package/build/RoomMessages.cjs +265 -0
- package/build/RoomMessages.cjs.map +7 -0
- package/build/RoomMessages.d.ts +49 -0
- package/build/RoomMessages.mjs +240 -0
- package/build/RoomMessages.mjs.map +7 -0
- package/build/RoomPlugin.cjs +252 -0
- package/build/RoomPlugin.cjs.map +7 -0
- package/build/RoomPlugin.d.ts +271 -0
- package/build/RoomPlugin.mjs +220 -0
- package/build/RoomPlugin.mjs.map +7 -0
- package/build/Server.cjs +49 -11
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +26 -0
- package/build/Server.mjs +50 -12
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +64 -2
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +108 -5
- package/build/Transport.mjs +63 -2
- package/build/Transport.mjs.map +2 -2
- package/build/errors/RoomExceptions.cjs +7 -4
- package/build/errors/RoomExceptions.cjs.map +2 -2
- package/build/errors/RoomExceptions.d.ts +15 -3
- package/build/errors/RoomExceptions.mjs +5 -3
- package/build/errors/RoomExceptions.mjs.map +2 -2
- package/build/index.cjs +30 -4
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +9 -5
- package/build/index.mjs +29 -6
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +536 -0
- package/build/input/InputBuffer.cjs.map +7 -0
- package/build/input/InputBuffer.d.ts +226 -0
- package/build/input/InputBuffer.mjs +507 -0
- package/build/input/InputBuffer.mjs.map +7 -0
- package/build/input/RoomInput.cjs +306 -0
- package/build/input/RoomInput.cjs.map +7 -0
- package/build/input/RoomInput.d.ts +91 -0
- package/build/input/RoomInput.mjs +287 -0
- package/build/input/RoomInput.mjs.map +7 -0
- package/build/input/types.cjs +18 -0
- package/build/input/types.cjs.map +7 -0
- package/build/input/types.d.ts +476 -0
- package/build/input/types.mjs +0 -0
- package/build/input/types.mjs.map +7 -0
- package/build/internal.cjs +61 -0
- package/build/internal.cjs.map +7 -0
- package/build/internal.d.ts +9 -0
- package/build/internal.mjs +29 -0
- package/build/internal.mjs.map +7 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
- package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
- package/build/matchmaker/driver.cjs.map +1 -1
- package/build/matchmaker/driver.d.ts +12 -0
- package/build/matchmaker/driver.mjs.map +1 -1
- package/build/presence/LocalPresence.d.ts +1 -1
- package/build/rooms/LobbyRoom.cjs +8 -10
- package/build/rooms/LobbyRoom.cjs.map +2 -2
- package/build/rooms/LobbyRoom.d.ts +4 -3
- package/build/rooms/LobbyRoom.mjs +8 -10
- package/build/rooms/LobbyRoom.mjs.map +2 -2
- package/build/rooms/QueueRoom.cjs +1 -1
- package/build/rooms/QueueRoom.cjs.map +2 -2
- package/build/rooms/QueueRoom.mjs +1 -1
- package/build/rooms/QueueRoom.mjs.map +2 -2
- package/build/rooms/RelayRoom.cjs +12 -16
- package/build/rooms/RelayRoom.cjs.map +2 -2
- package/build/rooms/RelayRoom.d.ts +32 -11
- package/build/rooms/RelayRoom.mjs +10 -16
- package/build/rooms/RelayRoom.mjs.map +2 -2
- package/build/router/default_routes.cjs +4 -1
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +4 -1
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +71 -5
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.d.ts +30 -6
- package/build/router/index.mjs +72 -7
- package/build/router/index.mjs.map +3 -3
- package/build/serializer/NoneSerializer.cjs +2 -2
- package/build/serializer/NoneSerializer.cjs.map +2 -2
- package/build/serializer/NoneSerializer.d.ts +3 -3
- package/build/serializer/NoneSerializer.mjs +2 -2
- package/build/serializer/NoneSerializer.mjs.map +2 -2
- package/build/serializer/SchemaSerializer.cjs +44 -14
- package/build/serializer/SchemaSerializer.cjs.map +2 -2
- package/build/serializer/SchemaSerializer.d.ts +3 -3
- package/build/serializer/SchemaSerializer.mjs +46 -16
- package/build/serializer/SchemaSerializer.mjs.map +2 -2
- package/build/serializer/Serializer.cjs.map +1 -1
- package/build/serializer/Serializer.d.ts +12 -2
- package/build/utils/DevMode.cjs +14 -14
- package/build/utils/DevMode.cjs.map +2 -2
- package/build/utils/DevMode.d.ts +0 -2
- package/build/utils/DevMode.mjs +14 -12
- package/build/utils/DevMode.mjs.map +2 -2
- package/build/utils/UserSessionIndex.cjs +162 -0
- package/build/utils/UserSessionIndex.cjs.map +7 -0
- package/build/utils/UserSessionIndex.d.ts +166 -0
- package/build/utils/UserSessionIndex.mjs +130 -0
- package/build/utils/UserSessionIndex.mjs.map +7 -0
- package/build/utils/Utils.cjs +3 -0
- package/build/utils/Utils.cjs.map +2 -2
- package/build/utils/Utils.d.ts +8 -0
- package/build/utils/Utils.mjs +2 -0
- package/build/utils/Utils.mjs.map +2 -2
- package/package.json +19 -14
- package/src/MatchMaker.ts +47 -15
- package/src/Protocol.ts +130 -59
- package/src/Rewind.ts +572 -0
- package/src/Room.ts +649 -235
- package/src/RoomMessages.ts +342 -0
- package/src/RoomPlugin.ts +563 -0
- package/src/Server.ts +82 -15
- package/src/Transport.ts +172 -9
- package/src/errors/RoomExceptions.ts +18 -4
- package/src/index.ts +23 -3
- package/src/input/InputBuffer.ts +549 -0
- package/src/input/RoomInput.ts +337 -0
- package/src/input/types.ts +503 -0
- package/src/internal.ts +46 -0
- package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
- package/src/matchmaker/driver.ts +13 -0
- package/src/rooms/LobbyRoom.ts +12 -8
- package/src/rooms/QueueRoom.ts +1 -1
- package/src/rooms/RelayRoom.ts +9 -15
- package/src/router/default_routes.ts +6 -1
- package/src/router/index.ts +117 -11
- package/src/serializer/NoneSerializer.ts +3 -3
- package/src/serializer/SchemaSerializer.ts +110 -19
- package/src/serializer/Serializer.ts +13 -2
- package/src/utils/DevMode.ts +18 -13
- package/src/utils/UserSessionIndex.ts +311 -0
- package/src/utils/Utils.ts +9 -0
package/src/Rewind.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { $values, type MapSchema, type ArraySchema, type SetSchema } from "@colyseus/schema";
|
|
2
|
+
import { $METADATA } from "./utils/Utils.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_REWIND_MS = 500;
|
|
5
|
+
/** Ring-sizing fallback when the record cadence isn't supplied (manual `record(now)`
|
|
6
|
+
* without a hint). `allowRewindState` feeds the real sim interval, so this only
|
|
7
|
+
* matters for ad-hoc manual use. */
|
|
8
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 1000 / 60;
|
|
9
|
+
|
|
10
|
+
// Hot record() path: dense `$values[index]` is ~15× faster than `inst[fieldName]`; entity symbol prop ~8× faster than a WeakMap.
|
|
11
|
+
/** Per-entity history, under this private symbol ON the entity. Symbol-keyed so it's
|
|
12
|
+
* invisible to Object.keys / `assign` / `clone`, and GC'd with the entity. */
|
|
13
|
+
const $HISTORY = Symbol("rewind.history");
|
|
14
|
+
|
|
15
|
+
/** Keys of T whose value type is `number` — the only rewindable fields. */
|
|
16
|
+
type NumericKeys<T> = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T] & string;
|
|
17
|
+
/** A Colyseus collection whose element type is `E`. Inferring `E` from the live
|
|
18
|
+
* collection the caller passes is what lets `fields` narrow with zero state-type
|
|
19
|
+
* plumbing. */
|
|
20
|
+
type Collection<E> = MapSchema<E> | ArraySchema<E> | SetSchema<E>;
|
|
21
|
+
|
|
22
|
+
/** Options for {@link Rewind.get} / {@link Room.allowRewindState}. */
|
|
23
|
+
export interface RewindOptions {
|
|
24
|
+
/** Default rewind window (ms) for attaches that don't pass their own — sizes the
|
|
25
|
+
* per-entity history ring. Per-attach `maxRewindMs` overrides it. Default 500. */
|
|
26
|
+
maxRewindMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Field's `$values` index from the schema metadata (`metadata[name]` = index), or
|
|
30
|
+
* -1 if unknown. Resolved ONCE per constructor (cold path), never per tick. */
|
|
31
|
+
function fieldIndexOf(instance: object, field: string): number {
|
|
32
|
+
const md = (instance.constructor as any)[$METADATA] as Record<string, unknown> | undefined;
|
|
33
|
+
const idx = md?.[field];
|
|
34
|
+
return typeof idx === "number" ? idx : -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The lag-comp technique an attach-group rewinds to — the TIMELINE the client
|
|
38
|
+
* displays those targets on. `"snapshot"` (default): the renderTime stamp (what
|
|
39
|
+
* a lerping/damped client shows, behind real time). `"reckon"`: the reckonTime
|
|
40
|
+
* stamp (what a forward-extrapolated client shows, ≈ serverNow). Declared at
|
|
41
|
+
* {@link Rewind.attachAll}, not on the schema. */
|
|
42
|
+
export type RewindMode = "snapshot" | "reckon";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-entity ring of recent field snapshots. `valueAt(time, col)` reconstructs
|
|
46
|
+
* one field's recorded PATH at an arbitrary past time — `linear` interp for
|
|
47
|
+
* continuous motion (patrol, a sine bob), or `step` (hold the last value) for
|
|
48
|
+
* discrete motion (teleport snaps), where a lerp would smear across the jump.
|
|
49
|
+
*/
|
|
50
|
+
class EntityHistory {
|
|
51
|
+
readonly fields: readonly string[];
|
|
52
|
+
/** The attach-group's `mode: "reckon"` technique, mirrored here so a view can
|
|
53
|
+
* pick its aim time PER FIELD (each field's covering history carries it). */
|
|
54
|
+
readonly reckoned: boolean;
|
|
55
|
+
/** The attach-group this history belongs to. One entity can have several
|
|
56
|
+
* histories (attached to multiple groups over disjoint fields); `record()`
|
|
57
|
+
* finds the right one by this id. */
|
|
58
|
+
readonly groupId: number;
|
|
59
|
+
/** Each tracked field's index into the entity's dense `$values` array —
|
|
60
|
+
* resolved per CONSTRUCTOR (mixed-type collections differ per type). */
|
|
61
|
+
private readonly fieldIdx: readonly number[];
|
|
62
|
+
private readonly cap: number;
|
|
63
|
+
private readonly t: Float64Array;
|
|
64
|
+
private readonly cols: Float64Array[]; // one column per field
|
|
65
|
+
private readonly step: boolean; // hold (vs lerp) between samples
|
|
66
|
+
private head = 0; // next write slot
|
|
67
|
+
private count = 0;
|
|
68
|
+
|
|
69
|
+
constructor(fields: readonly string[], fieldIdx: readonly number[], maxRewindMs: number, sampleIntervalMs: number, step: boolean, reckoned: boolean, groupId: number) {
|
|
70
|
+
this.fields = fields;
|
|
71
|
+
this.fieldIdx = fieldIdx;
|
|
72
|
+
this.step = step;
|
|
73
|
+
this.reckoned = reckoned;
|
|
74
|
+
this.groupId = groupId;
|
|
75
|
+
this.cap = Math.max(2, Math.ceil(maxRewindMs / sampleIntervalMs) + 4);
|
|
76
|
+
this.t = new Float64Array(this.cap);
|
|
77
|
+
this.cols = fields.map(() => new Float64Array(this.cap));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `values` = the entity's dense `$values` array. Direct array reads — no
|
|
81
|
+
* per-field megamorphic accessor. */
|
|
82
|
+
record(time: number, values: ArrayLike<number>): void {
|
|
83
|
+
const fieldIdx = this.fieldIdx;
|
|
84
|
+
this.t[this.head] = time;
|
|
85
|
+
for (let f = 0; f < fieldIdx.length; f++) this.cols[f][this.head] = values[fieldIdx[f]];
|
|
86
|
+
this.head = (this.head + 1) % this.cap;
|
|
87
|
+
if (this.count < this.cap) this.count++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Interpolated value of column `col` at `time`, clamped to the retained range. */
|
|
91
|
+
valueAt(time: number, col: number): number {
|
|
92
|
+
const c = this.cols[col];
|
|
93
|
+
const oldest = (this.head - this.count + this.cap) % this.cap;
|
|
94
|
+
if (time <= this.t[oldest]) return c[oldest];
|
|
95
|
+
const newest = (this.head - 1 + this.cap) % this.cap;
|
|
96
|
+
if (time >= this.t[newest]) return c[newest];
|
|
97
|
+
let prev = oldest;
|
|
98
|
+
for (let i = 1; i < this.count; i++) {
|
|
99
|
+
const idx = (oldest + i) % this.cap;
|
|
100
|
+
if (this.t[idx] >= time) {
|
|
101
|
+
if (this.step) return c[prev]; // discrete: hold the value before `time`
|
|
102
|
+
const t0 = this.t[prev], t1 = this.t[idx];
|
|
103
|
+
const a = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
|
|
104
|
+
return c[prev] + (c[idx] - c[prev]) * a;
|
|
105
|
+
}
|
|
106
|
+
prev = idx;
|
|
107
|
+
}
|
|
108
|
+
return c[newest]; // unreachable: `newest` guard above covers it
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The histories an entity accumulates — one per attach-group it belongs to
|
|
113
|
+
* (length 1 in the common single-group case). Stored under {@link $HISTORY} on
|
|
114
|
+
* the entity; GC'd with it. */
|
|
115
|
+
type EntityHistories = EntityHistory[];
|
|
116
|
+
|
|
117
|
+
/** Find the history covering `field` (the group that recorded it), or undefined
|
|
118
|
+
* → read live. Disjoint field sets across groups make the first match the only
|
|
119
|
+
* match; the scan is length-1 in every common case. */
|
|
120
|
+
function historyForField(instance: object, field: string): EntityHistory | undefined {
|
|
121
|
+
const arr = (instance as any)[$HISTORY] as EntityHistories | undefined;
|
|
122
|
+
if (arr === undefined) return undefined;
|
|
123
|
+
for (let i = 0; i < arr.length; i++) {
|
|
124
|
+
if (arr[i].fields.indexOf(field) >= 0) return arr[i];
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** How `valueAt` reconstructs a value between recorded samples: `linear` interp
|
|
130
|
+
* (continuous motion) or `step` = hold the last sample (discrete motion). */
|
|
131
|
+
type Interp = "linear" | "step";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A read-only view of the tracked world at ONE past time — the rewound state a
|
|
135
|
+
* hit test reads instead of live positions. Created by {@link Rewind.at} /
|
|
136
|
+
* {@link Rewind.lastSeenBy}, which bake in the `maxRewindMs` clamp and the
|
|
137
|
+
* live-fallback so callers never re-implement either.
|
|
138
|
+
*
|
|
139
|
+
* The view assumes NOTHING about your schema's field names — you name the
|
|
140
|
+
* fields (and therefore the shape) at the call site:
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* const seen = rewind.lastSeenBy(shooterSessionId);
|
|
144
|
+
* // read fields directly…
|
|
145
|
+
* overlaps(bullet, seen.value(target, "x"), seen.value(target, "y"));
|
|
146
|
+
* // …or batch them into an object shaped by YOUR field list:
|
|
147
|
+
* const pos = seen.read(target, ["x", "y"]); // { x, y }
|
|
148
|
+
* seen.read(enemy, ["x", "y"], this.seenScratch); // fill + return a reused scratch
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* By default `at`/`lastSeenBy` re-aim and return the Rewind's own internal
|
|
152
|
+
* view — the usual "one view at a time, read it right away" flow is zero-alloc
|
|
153
|
+
* AND zero-setup (the server is single-threaded; nothing interleaves a
|
|
154
|
+
* synchronous read). Need more than one view alive at once (compare two
|
|
155
|
+
* shooters' perspectives, A/B a rewind window)? Pass your own instance as
|
|
156
|
+
* their `out` — it is re-aimed and returned instead of the shared one:
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const a = rewind.lastSeenBy(shooterA); // shared default view
|
|
160
|
+
* const b = rewind.lastSeenBy(shooterB, new RewindView()); // independent second view
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* Don't store a view across calls or ticks: the default is re-aimed by the
|
|
164
|
+
* next `at`/`lastSeenBy`, and ANY view's clamp was computed against
|
|
165
|
+
* `lastRecordedAt`, so it goes stale at the next `record()` regardless.
|
|
166
|
+
*/
|
|
167
|
+
export class RewindView {
|
|
168
|
+
/** Bound (and re-bound) by {@link _retarget} — a bare `new RewindView()` is
|
|
169
|
+
* un-aimed scratch until it first passes through at()/lastSeenBy(). */
|
|
170
|
+
private rewind?: Rewind;
|
|
171
|
+
private _time = 0;
|
|
172
|
+
private _reckonTime = 0;
|
|
173
|
+
|
|
174
|
+
/** The clamped server-time (ms) this view reads at — after the maxRewindMs
|
|
175
|
+
* clamp and the live (newest-sample) fallback. Mostly for logging/debug. */
|
|
176
|
+
get time(): number { return this._time; }
|
|
177
|
+
|
|
178
|
+
/** The clamped server-time (ms) `mode: "reckon"` groups read at — the
|
|
179
|
+
* client's reconstructed simulation instant, not the raw stamp (see
|
|
180
|
+
* {@link Rewind.at}). Always a resolved instant: a clamped direct stamp,
|
|
181
|
+
* or the midpoint/live reconstruction when unstamped. `0` only before the
|
|
182
|
+
* first `record()`. */
|
|
183
|
+
get reckonTime(): number { return this._reckonTime; }
|
|
184
|
+
|
|
185
|
+
/** @internal Aim at a rewind + clamped times ({@link Rewind.at} owns the clamps). */
|
|
186
|
+
_retarget(rewind: Rewind, at: number, reckonAt: number): this {
|
|
187
|
+
this.rewind = rewind;
|
|
188
|
+
this._time = at;
|
|
189
|
+
this._reckonTime = reckonAt;
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Rewound value of a numeric `field` on `entity` (live if it isn't tracked). */
|
|
194
|
+
value<T extends object>(entity: T, field: NumericKeys<T>): number {
|
|
195
|
+
if (this.rewind === undefined) {
|
|
196
|
+
throw new Error("RewindView is not aimed — obtain it from rewind.at()/lastSeenBy(), or pass it to them as `out`.");
|
|
197
|
+
}
|
|
198
|
+
// The field's covering attach-group picks the timeline: mode:"reckon" reads
|
|
199
|
+
// at the reckon-display instant, mode:"snapshot" at the renderTime stamp.
|
|
200
|
+
const h = historyForField(entity, field);
|
|
201
|
+
if (h === undefined) return (entity as Record<string, number>)[field]; // untracked → live
|
|
202
|
+
return h.valueAt(h.reckoned ? this._reckonTime : this._time, h.fields.indexOf(field));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Batch {@link value} reads: the `fields` YOU list define the result's shape
|
|
207
|
+
* (`Record<field, number>`). Pass `out` to fill (and return) a reused scratch
|
|
208
|
+
* instead of allocating — its properties beyond `fields` are left untouched,
|
|
209
|
+
* so a scratch can carry extra context (an `alive` flag, say).
|
|
210
|
+
*/
|
|
211
|
+
read<T extends object, F extends NumericKeys<T>, O extends Record<F, number> = Record<F, number>>(
|
|
212
|
+
entity: T,
|
|
213
|
+
fields: readonly F[],
|
|
214
|
+
out?: O,
|
|
215
|
+
): O {
|
|
216
|
+
const o = (out ?? {}) as Record<F, number>;
|
|
217
|
+
for (let i = 0; i < fields.length; i++) o[fields[i]] = this.value(entity, fields[i]);
|
|
218
|
+
return o as O;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Array-form `fields` resolved against one constructor: names with a known
|
|
223
|
+
* `$values` index, in lock-step. Fields a type doesn't declare are dropped
|
|
224
|
+
* (they read live) instead of recording `values[-1]` garbage. */
|
|
225
|
+
interface ResolvedFields { fields: readonly string[]; idx: readonly number[]; }
|
|
226
|
+
|
|
227
|
+
interface TrackedGroup {
|
|
228
|
+
entities: () => Iterable<object>;
|
|
229
|
+
fields: readonly string[] | ((entity: any) => readonly string[]);
|
|
230
|
+
/** Per-constructor resolution of array-form `fields` — one entry per entity
|
|
231
|
+
* type seen in the group (mixed-type collections differ per type). */
|
|
232
|
+
resolvedByCtor: Map<Function, ResolvedFields>;
|
|
233
|
+
maxRewindMs: number;
|
|
234
|
+
interpolate: Interp | ((entity: any) => Interp);
|
|
235
|
+
/** `true` when this group's `mode` is `"reckon"` (vs the `"snapshot"` default). */
|
|
236
|
+
reckoned: boolean;
|
|
237
|
+
/** Stable id (push index) — links an entity's per-group {@link EntityHistory}
|
|
238
|
+
* back to its group so the same collection can be attached more than once. */
|
|
239
|
+
groupId: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Per-attach options for {@link Rewind.attachAll} / {@link Rewind.attach}.
|
|
244
|
+
*/
|
|
245
|
+
interface AttachOptions<E> {
|
|
246
|
+
/** Numeric fields to record per tick. An array applies to every type in the
|
|
247
|
+
* collection (fields a type lacks are skipped — they read live); a per-entity
|
|
248
|
+
* fn picks the list per entity, mirroring `interpolate`'s fn form. */
|
|
249
|
+
fields: readonly NumericKeys<E>[] | ((entity: E) => readonly NumericKeys<E>[]);
|
|
250
|
+
maxRewindMs?: number;
|
|
251
|
+
/** How `valueAt` reconstructs between samples (a mode or a per-entity fn,
|
|
252
|
+
* default `linear`) — use `step` for discrete motion (e.g. teleporters). */
|
|
253
|
+
interpolate?: Interp | ((entity: E) => Interp);
|
|
254
|
+
/**
|
|
255
|
+
* The lag-comp TIMELINE these fields rewind to — must match how the CLIENT
|
|
256
|
+
* displays this target ("what you see is what you hit"):
|
|
257
|
+
* - `"snapshot"` (default): rewind to the client's `renderTime` — for
|
|
258
|
+
* targets it shows INTERPOLATED (Predict `lerp`/`damped`), behind real
|
|
259
|
+
* time by its interp buffer + rtt/2.
|
|
260
|
+
* - `"reckon"`: rewind to the client's `reckonTime` (≈ its serverNow) — for
|
|
261
|
+
* targets it forward-extrapolates (Predict `reckon`).
|
|
262
|
+
* Attach the same collection twice with disjoint `fields` to mix techniques.
|
|
263
|
+
* The room ships exactly the stamp(s) its attached groups need (see the
|
|
264
|
+
* handshake derivation in `Room`).
|
|
265
|
+
*/
|
|
266
|
+
mode?: RewindMode;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Server-side lag compensation — the dual of the client's `Predict`. Where
|
|
271
|
+
* `Predict` forward-reckons entities it RECEIVES, `Rewind` records the recent
|
|
272
|
+
* positions of entities it OWNS and rewinds field reads to a past (client
|
|
273
|
+
* render) time, so a hit test judges against where the client actually SAW an
|
|
274
|
+
* entity — not where it has slid to ~RTT later.
|
|
275
|
+
*
|
|
276
|
+
* Prefer {@link Room.allowRewindState}, which creates a `Rewind` and records it
|
|
277
|
+
* automatically each simulation tick:
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* onCreate() {
|
|
282
|
+
* const rewind = this.allowRewindState({ maxRewindMs: 500 });
|
|
283
|
+
* rewind.attachAll(this.state.enemies, { fields: ["x", "y"] }); // fields ← Enemy's numeric keys
|
|
284
|
+
* this.setTimestep((dt) => { ...move enemies... }, 1000 / 30);
|
|
285
|
+
* // framework calls rewind.record() after each tick.
|
|
286
|
+
* }
|
|
287
|
+
* // in your hit test, with the client's renderTime:
|
|
288
|
+
* const seenX = rewind.valueAt(enemy, renderTime, "x");
|
|
289
|
+
* ```
|
|
290
|
+
*
|
|
291
|
+
* `attachAll` takes the live collection (not a string key), so the element type
|
|
292
|
+
* — and the legal `fields` / `valueAt` field names — are inferred with no
|
|
293
|
+
* state-type generic. Entities are keyed by object identity — removed entities
|
|
294
|
+
* and their history are reclaimed automatically.
|
|
295
|
+
*
|
|
296
|
+
* The rewind TIMELINE is declared per attach-group via `attachAll(coll, { mode })`:
|
|
297
|
+
* `"snapshot"` (default) reads at the client's `renderTime` stamp, `"reckon"` at
|
|
298
|
+
* its `reckonTime` (see {@link at}). Not-attached = not rewound (reads are live)
|
|
299
|
+
* — there is no "none". The same collection may be attached more than once with
|
|
300
|
+
* disjoint `fields` to put different fields on different timelines. The room
|
|
301
|
+
* ships exactly the stamp(s) these groups need (handshake derivation reads
|
|
302
|
+
* {@link timelineMode}); the client declares its display mode independently.
|
|
303
|
+
*/
|
|
304
|
+
export class Rewind {
|
|
305
|
+
private static readonly byRoom = new WeakMap<object, Rewind>();
|
|
306
|
+
|
|
307
|
+
/** One `Rewind` per room (idempotent). `room` is only the cache key. */
|
|
308
|
+
static get(room: object, opts?: RewindOptions): Rewind {
|
|
309
|
+
let r = Rewind.byRoom.get(room);
|
|
310
|
+
if (r === undefined) { r = new Rewind(opts?.maxRewindMs ?? DEFAULT_MAX_REWIND_MS); Rewind.byRoom.set(room, r); }
|
|
311
|
+
return r;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private readonly groups: TrackedGroup[] = [];
|
|
315
|
+
private readonly defaultMaxRewindMs: number;
|
|
316
|
+
private _sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS;
|
|
317
|
+
private _lastRecordedAt = -1;
|
|
318
|
+
/** Resolves a client's auto-stamped render time (set by `Room.allowRewindState`
|
|
319
|
+
* when a `mode:"snapshot"` group is attached); powers {@link lastSeenBy}. */
|
|
320
|
+
private _renderTimeOf?: (sessionId: string) => number;
|
|
321
|
+
/** Resolves a client's auto-stamped reckon-display instant (its serverNow
|
|
322
|
+
* estimate at input-sample time). When present (> 0), {@link lastSeenBy}
|
|
323
|
+
* reads `mode:"reckon"` groups AT this stamp directly — exact regardless
|
|
324
|
+
* of the client's clock/RTT-estimation error (it displayed f(est), we read
|
|
325
|
+
* f(est)) — instead of reconstructing it via the midpoint. */
|
|
326
|
+
private _reckonTimeOf?: (sessionId: string) => number;
|
|
327
|
+
/** Resolves the CURRENT server time (set by `Room.allowRewindState`) — the
|
|
328
|
+
* reckon midpoint's "now" anchor in {@link at}. Without it, the anchor falls
|
|
329
|
+
* back to `lastRecordedAt`, which is one tick stale at hit-test time. */
|
|
330
|
+
private _nowOf?: () => number;
|
|
331
|
+
/** Default view returned by at/lastSeenBy when no `out` is given — one per
|
|
332
|
+
* Rewind, re-aimed per call (zero alloc, zero userland setup). */
|
|
333
|
+
private readonly _view = new RewindView();
|
|
334
|
+
|
|
335
|
+
private constructor(defaultMaxRewindMs: number) { this.defaultMaxRewindMs = defaultMaxRewindMs; }
|
|
336
|
+
|
|
337
|
+
/** The default rewind window (ms). Use it to bound a hit test:
|
|
338
|
+
* `Math.max(renderTime, now - rewind.maxRewindMs)`. */
|
|
339
|
+
get maxRewindMs(): number { return this.defaultMaxRewindMs; }
|
|
340
|
+
/** Server time of the last {@link record} (or -1). The auto-record skips a tick
|
|
341
|
+
* whose time was already recorded manually — your `record()` wins. */
|
|
342
|
+
get lastRecordedAt(): number { return this._lastRecordedAt; }
|
|
343
|
+
|
|
344
|
+
/** Track every entity in a collection (Map/Array/Set schema). `fields` narrows
|
|
345
|
+
* to its element's numeric fields. `interpolate` (a mode or a per-entity fn,
|
|
346
|
+
* default `linear`) picks how `valueAt` reconstructs between samples — use
|
|
347
|
+
* `step` for discrete-motion entities (e.g. teleporters). `mode` picks the
|
|
348
|
+
* lag-comp timeline (default `"snapshot"`; see {@link AttachOptions}). */
|
|
349
|
+
attachAll<E extends object>(
|
|
350
|
+
collection: Collection<E>,
|
|
351
|
+
opts: AttachOptions<E>,
|
|
352
|
+
): this {
|
|
353
|
+
const c = collection as unknown as { values(): Iterable<object> };
|
|
354
|
+
this.groups.push({ entities: () => c.values(), fields: opts.fields, resolvedByCtor: new Map(), maxRewindMs: opts.maxRewindMs ?? this.defaultMaxRewindMs, interpolate: opts.interpolate ?? "linear", reckoned: opts.mode === "reckon", groupId: this.groups.length });
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Track a single entity (e.g. a boss). `fields` narrows to its numeric fields. */
|
|
359
|
+
attach<E extends object>(instance: E, opts: AttachOptions<E>): this {
|
|
360
|
+
const one: object[] = [instance]; // reused; no per-tick alloc
|
|
361
|
+
this.groups.push({ entities: () => one, fields: opts.fields, resolvedByCtor: new Map(), maxRewindMs: opts.maxRewindMs ?? this.defaultMaxRewindMs, interpolate: opts.interpolate ?? "linear", reckoned: opts.mode === "reckon", groupId: this.groups.length });
|
|
362
|
+
return this;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Snapshot every tracked entity at `now`. Call once per tick, AFTER they move —
|
|
367
|
+
* {@link Room.allowRewindState} does this for you. `sampleIntervalMs` (the gap
|
|
368
|
+
* between records) sizes the history rings; the framework passes the sim
|
|
369
|
+
* interval. Calling this yourself during a tick suppresses that tick's
|
|
370
|
+
* auto-record (see {@link lastRecordedAt}).
|
|
371
|
+
*/
|
|
372
|
+
record(now: number, sampleIntervalMs?: number): void {
|
|
373
|
+
if (sampleIntervalMs !== undefined && sampleIntervalMs > 0) this._sampleIntervalMs = sampleIntervalMs;
|
|
374
|
+
this._lastRecordedAt = now;
|
|
375
|
+
for (const g of this.groups) {
|
|
376
|
+
for (const e of g.entities()) {
|
|
377
|
+
const t = e as any; // private-symbol access on a foreign schema instance
|
|
378
|
+
let arr = t[$HISTORY] as EntityHistories | undefined;
|
|
379
|
+
if (arr === undefined) { arr = []; t[$HISTORY] = arr; }
|
|
380
|
+
let h: EntityHistory | undefined;
|
|
381
|
+
for (let i = 0; i < arr.length; i++) { if (arr[i].groupId === g.groupId) { h = arr[i]; break; } }
|
|
382
|
+
if (h === undefined) { h = this.createHistory(g, e); arr.push(h); } // cold: once per (entity, group)
|
|
383
|
+
h.record(now, t[$values] as ArrayLike<number>);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Cold path — first time an entity is seen by `record()` for a given group.
|
|
389
|
+
* Resolves the field→`$values` indices (cached per constructor for array-form
|
|
390
|
+
* `fields`; fn-form resolves per entity) and bakes in the group's `mode`. */
|
|
391
|
+
private createHistory(g: TrackedGroup, e: object): EntityHistory {
|
|
392
|
+
let rf: ResolvedFields;
|
|
393
|
+
if (typeof g.fields === "function") {
|
|
394
|
+
rf = this.resolveFields(e, g.fields(e));
|
|
395
|
+
} else {
|
|
396
|
+
let cached = g.resolvedByCtor.get(e.constructor);
|
|
397
|
+
if (cached === undefined) {
|
|
398
|
+
cached = this.resolveFields(e, g.fields);
|
|
399
|
+
g.resolvedByCtor.set(e.constructor, cached);
|
|
400
|
+
}
|
|
401
|
+
rf = cached;
|
|
402
|
+
}
|
|
403
|
+
const interp = typeof g.interpolate === "function" ? g.interpolate(e) : g.interpolate;
|
|
404
|
+
return new EntityHistory(rf.fields, rf.idx, g.maxRewindMs, this._sampleIntervalMs, interp === "step", g.reckoned, g.groupId);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Keep only the fields this entity's type declares (unknown → read live). */
|
|
408
|
+
private resolveFields(e: object, names: readonly string[]): ResolvedFields {
|
|
409
|
+
const fields: string[] = [];
|
|
410
|
+
const idx: number[] = [];
|
|
411
|
+
for (const name of names) {
|
|
412
|
+
const i = fieldIndexOf(e, name);
|
|
413
|
+
if (i >= 0) { fields.push(name); idx.push(i); }
|
|
414
|
+
}
|
|
415
|
+
return { fields, idx };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* `instance`'s `field` value at past `time` (interpolated from history). Falls
|
|
420
|
+
* back to the live value when the entity has no history yet, or the field
|
|
421
|
+
* isn't tracked.
|
|
422
|
+
*/
|
|
423
|
+
valueAt<T extends object>(instance: T, time: number, field: NumericKeys<T>): number {
|
|
424
|
+
const h = historyForField(instance, field);
|
|
425
|
+
if (h === undefined) return (instance as Record<string, number>)[field];
|
|
426
|
+
return h.valueAt(time, h.fields.indexOf(field));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Which timeline(s) the attached groups rewind to — `reckon` true iff any
|
|
431
|
+
* group is `mode:"reckon"`, `snapshot` true iff any is `mode:"snapshot"`. The
|
|
432
|
+
* room reads this once (groups are declared in `onCreate`, before any join) to
|
|
433
|
+
* pick which stamp(s) the client must ship: reckon-only / render-only / both /
|
|
434
|
+
* none. @internal
|
|
435
|
+
*/
|
|
436
|
+
timelineMode(): { reckon: boolean; snapshot: boolean } {
|
|
437
|
+
let reckon = false, snapshot = false;
|
|
438
|
+
for (const g of this.groups) {
|
|
439
|
+
if (g.reckoned) reckon = true; else snapshot = true;
|
|
440
|
+
}
|
|
441
|
+
return { reckon, snapshot };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* @internal Wire a resolver from sessionId → that client's auto-stamped
|
|
446
|
+
* `renderTime`. Called by {@link Room.allowRewindState} so {@link lastSeenBy}
|
|
447
|
+
* works for `mode:"snapshot"` groups; auto-enabled by attaching one.
|
|
448
|
+
*/
|
|
449
|
+
bindRenderTime(resolver: (sessionId: string) => number): void {
|
|
450
|
+
this._renderTimeOf = resolver;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @internal Wire a resolver for the CURRENT server time (ms, same epoch as
|
|
455
|
+
* `record` times). Called by {@link Room.allowRewindState}; standalone users
|
|
456
|
+
* (tests/harnesses) should bind their own. Anchors the reckon midpoint in
|
|
457
|
+
* {@link at} at the true processing instant — the `lastRecordedAt` fallback
|
|
458
|
+
* is one tick stale by hit-test time, biasing the aim ~half a tick early:
|
|
459
|
+
* imperceptible on slow horizontal motion, but enough to flip knife-edge
|
|
460
|
+
* stomp/hit verdicts on fast vertical movers (a bobbing jumper).
|
|
461
|
+
*/
|
|
462
|
+
bindNow(resolver: () => number): void {
|
|
463
|
+
this._nowOf = resolver;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @internal Wire a resolver from sessionId → that client's auto-stamped
|
|
468
|
+
* reckon-display instant (`input(sid).reckonTime`). Called by
|
|
469
|
+
* {@link Room.allowRewindState}. With it, {@link lastSeenBy} aims
|
|
470
|
+
* `mode:"reckon"` groups at the EXACT instant the client displayed them
|
|
471
|
+
* (immune to its RTT-estimation error); without it (or while the stamp is
|
|
472
|
+
* still 0), the midpoint reconstruction in {@link at} is the fallback.
|
|
473
|
+
*/
|
|
474
|
+
bindReckonTime(resolver: (sessionId: string) => number): void {
|
|
475
|
+
this._reckonTimeOf = resolver;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A {@link RewindView} of the tracked world at `time` (an acting client's render
|
|
480
|
+
* time), with the two things every hit test re-implements baked in:
|
|
481
|
+
* - **clamp** to `[lastRecordedAt − maxRewindMs, lastRecordedAt]` — an
|
|
482
|
+
* anti-spoof / clock-skew bound (a client can't rewind arbitrarily far), and
|
|
483
|
+
* - **live fallback**: `time <= 0` (the client's clock hasn't synced) → the
|
|
484
|
+
* newest recorded sample (≈ current position).
|
|
485
|
+
*
|
|
486
|
+
* Sugar over {@link valueAt}; pass the render time yourself (e.g. from
|
|
487
|
+
* `input(sid).renderTime`, or a value stored on the entity). For the common
|
|
488
|
+
* "rewind to a specific client's view" case use {@link lastSeenBy}.
|
|
489
|
+
*
|
|
490
|
+
* Re-aims and returns this Rewind's internal view by default — zero alloc,
|
|
491
|
+
* nothing to declare. Pass `out` to aim a view of your own instead; needed
|
|
492
|
+
* only to hold several views at once — see the {@link RewindView} doc.
|
|
493
|
+
*/
|
|
494
|
+
at(time: number, out?: RewindView): RewindView {
|
|
495
|
+
return this._aim(time, 0, out);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Shared aiming: clamp the snapshot-timeline `time`, and resolve the reckon
|
|
500
|
+
* timeline either from the DIRECT `reckonStamp` (the instant the client's
|
|
501
|
+
* forward-reckoned entities were displayed at — exact, immune to the
|
|
502
|
+
* client's RTT-estimation error) or, when absent (0), by reconstruction.
|
|
503
|
+
*/
|
|
504
|
+
private _aim(time: number, reckonStamp: number, out?: RewindView): RewindView {
|
|
505
|
+
const newest = this._lastRecordedAt;
|
|
506
|
+
const oldest = newest - this.defaultMaxRewindMs;
|
|
507
|
+
// time<=0 → not synced yet → read live (newest). Else clamp into the window.
|
|
508
|
+
const at = time <= 0 ? newest : (time < oldest ? oldest : time > newest ? newest : time);
|
|
509
|
+
// mode:"reckon" groups display forward-extrapolated to ≈ the client's
|
|
510
|
+
// serverNow — rewinding them to the raw snapshot stamp would double-
|
|
511
|
+
// compensate (client extrapolates forward + server rewinds back = a
|
|
512
|
+
// full-RTT ghost in the entity's past).
|
|
513
|
+
let reckonAt: number;
|
|
514
|
+
if (reckonStamp > 0) {
|
|
515
|
+
// Direct stamp: read at exactly what the client displayed. The clamp is
|
|
516
|
+
// the anti-spoof bound, same as the snapshot timeline's.
|
|
517
|
+
reckonAt = reckonStamp < oldest ? oldest : reckonStamp > newest ? newest : reckonStamp;
|
|
518
|
+
} else if (time <= 0) {
|
|
519
|
+
reckonAt = newest;
|
|
520
|
+
} else {
|
|
521
|
+
// Reconstruction fallback (custom `at(time)` users / old clients): with
|
|
522
|
+
// symmetric latency the client's instant is the midpoint of the stamp
|
|
523
|
+
// and ARRIVAL — anchored at the bound "now" (the true processing
|
|
524
|
+
// instant; the lastRecordedAt fallback is one tick stale by hit-test
|
|
525
|
+
// time and biases the aim ~half a tick early — see bindNow). NOTE: the
|
|
526
|
+
// stamp itself embeds the client's rtt/2 estimate, so this path inherits
|
|
527
|
+
// its error — the direct stamp above is the accurate one.
|
|
528
|
+
const anchor = this._nowOf !== undefined ? this._nowOf() : newest;
|
|
529
|
+
const mid = (time + anchor) / 2;
|
|
530
|
+
reckonAt = mid < oldest ? oldest : mid > newest ? newest : mid;
|
|
531
|
+
}
|
|
532
|
+
return (out ?? this._view)._retarget(this, at, reckonAt);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A {@link RewindView} of the world as session `sessionId` LAST saw it —
|
|
537
|
+
* resolves the render time stamped on that client's most recently CONSUMED
|
|
538
|
+
* input (hence "last": under a multi-frame `drain()` it is the newest frame's
|
|
539
|
+
* stamp) and hands off to {@link at}. This is the "what you see is what you
|
|
540
|
+
* hit" path:
|
|
541
|
+
*
|
|
542
|
+
* ```ts
|
|
543
|
+
* const seen = this.rewind.lastSeenBy(shooterSessionId);
|
|
544
|
+
* const hit = overlaps(bullet, seen.value(target, "x"), seen.value(target, "y"));
|
|
545
|
+
* ```
|
|
546
|
+
*
|
|
547
|
+
* Requires the room's framework input (`this.defineInput(Input)`) — the
|
|
548
|
+
* per-client stamp auto-enables from the `attachAll` `mode` of the groups you
|
|
549
|
+
* rewind (declaration order vs `allowRewindState` doesn't matter). A client
|
|
550
|
+
* that merely hasn't stamped yet (clock still syncing, unknown sessionId) is
|
|
551
|
+
* NOT an error — it reads 0 and the view falls back to live. Use
|
|
552
|
+
* {@link at} with an explicit time if you stamp render times yourself.
|
|
553
|
+
*
|
|
554
|
+
* Re-aims and returns this Rewind's internal view by default — zero alloc,
|
|
555
|
+
* nothing to declare. Pass `out` to aim a view of your own instead; needed
|
|
556
|
+
* only to hold several views at once — see the {@link RewindView} doc.
|
|
557
|
+
*/
|
|
558
|
+
lastSeenBy(sessionId: string, out?: RewindView): RewindView {
|
|
559
|
+
if (this._renderTimeOf === undefined) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
"Rewind.lastSeenBy(sessionId) needs the framework input API. Declare " +
|
|
562
|
+
"`this.defineInput(Input)` (stamps auto-enable from your attachAll `mode`), " +
|
|
563
|
+
"or call `at(time)` and pass the render time yourself.",
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return this._aim(
|
|
567
|
+
this._renderTimeOf(sessionId),
|
|
568
|
+
this._reckonTimeOf !== undefined ? this._reckonTimeOf(sessionId) : 0,
|
|
569
|
+
out,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|