@lagless/misc 0.0.33
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/README.md +596 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/lib/logger.d.ts +24 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +45 -0
- package/dist/lib/now.d.ts +2 -0
- package/dist/lib/now.d.ts.map +1 -0
- package/dist/lib/now.js +6 -0
- package/dist/lib/phase-nudger.d.ts +27 -0
- package/dist/lib/phase-nudger.d.ts.map +1 -0
- package/dist/lib/phase-nudger.js +84 -0
- package/dist/lib/ring-buffer.d.ts +11 -0
- package/dist/lib/ring-buffer.d.ts.map +1 -0
- package/dist/lib/ring-buffer.js +27 -0
- package/dist/lib/simulation-clock.d.ts +18 -0
- package/dist/lib/simulation-clock.d.ts.map +1 -0
- package/dist/lib/simulation-clock.js +40 -0
- package/dist/lib/snapshot-history.d.ts +18 -0
- package/dist/lib/snapshot-history.d.ts.map +1 -0
- package/dist/lib/snapshot-history.js +121 -0
- package/dist/lib/transform2d-utils.d.ts +30 -0
- package/dist/lib/transform2d-utils.d.ts.map +1 -0
- package/dist/lib/transform2d-utils.js +28 -0
- package/dist/lib/uuid.d.ts +33 -0
- package/dist/lib/uuid.d.ts.map +1 -0
- package/dist/lib/uuid.js +195 -0
- package/dist/lib/visual-smoother-2d.d.ts +82 -0
- package/dist/lib/visual-smoother-2d.d.ts.map +1 -0
- package/dist/lib/visual-smoother-2d.js +141 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
# @lagless/misc
|
|
2
|
+
|
|
3
|
+
## 1. Responsibility & Context
|
|
4
|
+
|
|
5
|
+
Provides utility classes and functions used across the Lagless ECS framework: time management (`SimulationClock`, `PhaseNudger`), snapshot storage for rollback (`SnapshotHistory`), circular buffers (`RingBuffer`), UUID generation with bot detection (`UUID`), Transform2d interpolation helpers, and visual rollback smoothing (`VisualSmoother2d`). This library sits between low-level primitives and the core ECS engine, providing common abstractions needed by networking, rendering, and simulation layers.
|
|
6
|
+
|
|
7
|
+
## 2. Architecture Role
|
|
8
|
+
|
|
9
|
+
**Utility layer** — sits above `@lagless/math` (peer dependency) and provides utilities used by `@lagless/core` and `@lagless/net-wire`.
|
|
10
|
+
|
|
11
|
+
**Downstream consumers:**
|
|
12
|
+
- `@lagless/core` — Uses `SimulationClock` for tick loop timing, `SnapshotHistory` for rollback storage, `RingBuffer` for input buffering
|
|
13
|
+
- `@lagless/net-wire` — Uses `RingBuffer` for network packet buffering
|
|
14
|
+
- `circle-sumo-simulation` — Uses `UUID` for player identification, `interpolateTransform2d` for smooth rendering between ticks
|
|
15
|
+
|
|
16
|
+
**Upstream dependencies:**
|
|
17
|
+
- `@lagless/math` (peer dependency) — Used by `transform2d-utils` for angle interpolation
|
|
18
|
+
|
|
19
|
+
## 3. Public API
|
|
20
|
+
|
|
21
|
+
### now()
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
export const now: () => number
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Cross-platform `performance.now()` wrapper. Returns high-resolution timestamp in milliseconds. Works in browsers (via `globalThis.performance.now`) and Node.js (via `node:perf_hooks`).
|
|
28
|
+
|
|
29
|
+
### UUID
|
|
30
|
+
|
|
31
|
+
UUID v4 generation and validation with "masked UUID" support for bot detection.
|
|
32
|
+
|
|
33
|
+
**Standard UUID generation:**
|
|
34
|
+
- `UUID.generate(): UUID` — Generate standard RFC 4122 v4 UUID (122 bits entropy)
|
|
35
|
+
|
|
36
|
+
**Masked UUID (bot detection):**
|
|
37
|
+
- `UUID.generateMasked(): UUID` — Generate UUID where last 4 bytes are FNV-1a hash of first 12 bytes (90 bits entropy). Used to mark bot players.
|
|
38
|
+
- `UUID.isMaskedUint8(bytes: Uint8Array): boolean` — Check if 16-byte array is a masked UUID (validates hash)
|
|
39
|
+
- `UUID.isMaskedString(uuidStr: string): boolean` — Check if UUID string is masked. Returns false for invalid strings.
|
|
40
|
+
|
|
41
|
+
**Conversion:**
|
|
42
|
+
- `UUID.fromString(uuidStr: string): UUID` — Parse canonical UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
|
43
|
+
- `UUID.fromUint8(uuidUint8: Uint8Array): UUID` — Create UUID from 16-byte array
|
|
44
|
+
- `uuid.asString(): string` — Convert to canonical string format (cached after first call)
|
|
45
|
+
- `uuid.asUint8(): Uint8Array` — Convert to 16-byte array (creates new copy)
|
|
46
|
+
|
|
47
|
+
### RingBuffer<T>
|
|
48
|
+
|
|
49
|
+
Fixed-size circular buffer with FIFO semantics. Overwrites oldest elements when full.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
class RingBuffer<T> {
|
|
53
|
+
constructor(size: number);
|
|
54
|
+
add(item: T): number; // Add item, returns index, wraps around when full
|
|
55
|
+
get(atIdx: number): T | undefined; // Get item at index
|
|
56
|
+
clear(): void; // Reset buffer
|
|
57
|
+
[Symbol.iterator](): Iterator<T>; // Iterate over all added items
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### SnapshotHistory<T>
|
|
62
|
+
|
|
63
|
+
Stores snapshots indexed by tick for rollback netcode. Maintains snapshots in tick order with efficient binary search for nearest-past-snapshot retrieval.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
class SnapshotHistory<T> {
|
|
67
|
+
constructor(maxSize: number);
|
|
68
|
+
set(tick: number, snapshot: T): void; // Store snapshot at tick (must be non-decreasing)
|
|
69
|
+
getNearest(tick: number): T; // Get snapshot with greatest tick < requested tick (binary search)
|
|
70
|
+
rollback(tick: number): void; // Remove all snapshots with tick >= given tick
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Key behavior:**
|
|
75
|
+
- Ticks must be non-decreasing (monotonic). Call `rollback()` before writing older ticks.
|
|
76
|
+
- Overwrites snapshot if same tick is set twice (useful for repeated rollback-replay)
|
|
77
|
+
- `getNearest()` throws if no snapshot exists with tick < requested tick
|
|
78
|
+
- Uses ring buffer internally for O(1) insertion when full
|
|
79
|
+
|
|
80
|
+
### SimulationClock
|
|
81
|
+
|
|
82
|
+
Manages game time accumulation with `PhaseNudger` integration for server-side clock sync.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
class SimulationClock {
|
|
86
|
+
constructor(frameLength: number, maxNudgePerFrame: number);
|
|
87
|
+
|
|
88
|
+
readonly phaseNudger: PhaseNudger; // Time debt manager for server sync
|
|
89
|
+
|
|
90
|
+
get startedTime(): number; // Timestamp when start() was called (from now())
|
|
91
|
+
get accumulatedTime(): number; // Total accumulated time in milliseconds
|
|
92
|
+
|
|
93
|
+
start(): void; // Start the clock (must be called before getElapsedTime())
|
|
94
|
+
getElapsedTime(): number; // Real time since start() in milliseconds
|
|
95
|
+
update(dt: number): void; // Accumulate time delta + phase nudge adjustments
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Typical usage:**
|
|
100
|
+
```typescript
|
|
101
|
+
const clock = new SimulationClock(16.666, 2); // 60 FPS, max 2ms nudge per frame
|
|
102
|
+
clock.start();
|
|
103
|
+
|
|
104
|
+
// In game loop:
|
|
105
|
+
const dt = getDeltaTime();
|
|
106
|
+
clock.update(dt); // Adds dt + phaseNudger.drain()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### PhaseNudger
|
|
110
|
+
|
|
111
|
+
Gradually adjusts simulation time to synchronize with server tick hints. Prevents abrupt jumps by draining "time debt" incrementally.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
class PhaseNudger {
|
|
115
|
+
constructor(frameLength: number, maxNudgePerFrame: number);
|
|
116
|
+
|
|
117
|
+
get isActive(): boolean; // True after activate() is called
|
|
118
|
+
get currentDebtMs(): number; // Current phase debt in milliseconds
|
|
119
|
+
|
|
120
|
+
activate(): void; // Enable phase nudging (call when ClockSync is ready)
|
|
121
|
+
onServerTickHint(serverTick: number, localTick: number): void; // Accumulate time debt based on tick difference
|
|
122
|
+
drain(): number; // Drain small portion of debt per frame, returns ms to add to time
|
|
123
|
+
reset(): void; // Reset debt to zero (use for hard sync)
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**How it works:**
|
|
128
|
+
- Server sends tick hints → `onServerTickHint()` calculates tick difference → accumulates debt with 0.3 weight
|
|
129
|
+
- Every frame, `drain()` returns small correction (limited by `maxNudgePerFrame`)
|
|
130
|
+
- Large debt (≥50ms) drains faster (50% per frame, capped at `frameLength`)
|
|
131
|
+
- Small debt drains gradually for smoothness
|
|
132
|
+
|
|
133
|
+
### Transform2d Interpolation Helpers
|
|
134
|
+
|
|
135
|
+
Functions for interpolating Transform2d component values between ticks for smooth rendering.
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
interface Transform2dCursorLike {
|
|
139
|
+
positionX: number; positionY: number; rotation: number;
|
|
140
|
+
prevPositionX: number; prevPositionY: number; prevRotation: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Interpolate transform between prev and current state
|
|
144
|
+
export function interpolateTransform2d(
|
|
145
|
+
prevPositionX: number, prevPositionY: number,
|
|
146
|
+
positionX: number, positionY: number,
|
|
147
|
+
prevRotation: number, rotation: number,
|
|
148
|
+
interpolationFactor: number,
|
|
149
|
+
): { readonly x: number; readonly y: number; readonly rotation: number };
|
|
150
|
+
|
|
151
|
+
// Zero-allocation variant (writes to ref)
|
|
152
|
+
export function interpolateTransform2dToRef(
|
|
153
|
+
prevPositionX: number, prevPositionY: number,
|
|
154
|
+
positionX: number, positionY: number,
|
|
155
|
+
prevRotation: number, rotation: number,
|
|
156
|
+
interpolationFactor: number,
|
|
157
|
+
ref: { x: number; y: number; rotation: number },
|
|
158
|
+
teleportThresholdSquared?: number, // Default: 300
|
|
159
|
+
): void;
|
|
160
|
+
|
|
161
|
+
// Cursor-based convenience wrappers
|
|
162
|
+
export function interpolateTransform2dCursor(
|
|
163
|
+
cursor: Transform2dCursorLike,
|
|
164
|
+
interpolationFactor: number,
|
|
165
|
+
): { readonly x: number; readonly y: number; readonly rotation: number };
|
|
166
|
+
|
|
167
|
+
export function interpolateTransform2dCursorToRef(
|
|
168
|
+
cursor: Transform2dCursorLike,
|
|
169
|
+
interpolationFactor: number,
|
|
170
|
+
ref: { x: number; y: number; rotation: number },
|
|
171
|
+
): void;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Key behavior:**
|
|
175
|
+
- Uses `MathOps.lerpAngle()` for rotation (shortest path)
|
|
176
|
+
- Detects teleportation: if distance² ≥ threshold², skip interpolation (snap to target)
|
|
177
|
+
- Y coordinate is negated (game coordinate system convention)
|
|
178
|
+
- Non-`ToRef` variants return a shared buffer (not thread-safe, reused on next call)
|
|
179
|
+
|
|
180
|
+
### VisualSmoother2d
|
|
181
|
+
|
|
182
|
+
Handles both sim↔render interpolation and rollback lag smoothing. Takes raw ECS prev/current values + interpolationFactor, outputs smoothed render position. One instance per rendered entity.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
class VisualSmoother2d {
|
|
186
|
+
x: number; // Smoothed X (read after update)
|
|
187
|
+
y: number; // Smoothed Y (read after update)
|
|
188
|
+
rotation: number; // Smoothed rotation (read after update)
|
|
189
|
+
|
|
190
|
+
readonly isSmoothing: boolean; // True while offset is non-zero
|
|
191
|
+
|
|
192
|
+
constructor(options?: VisualSmoother2dOptions);
|
|
193
|
+
|
|
194
|
+
update(
|
|
195
|
+
prevPositionX: number, prevPositionY: number,
|
|
196
|
+
positionX: number, positionY: number,
|
|
197
|
+
prevRotation: number, rotation: number,
|
|
198
|
+
interpolationFactor: number,
|
|
199
|
+
): void;
|
|
200
|
+
|
|
201
|
+
reset(): void;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface VisualSmoother2dOptions {
|
|
205
|
+
positionJumpThreshold?: number; // px, detects rollback jumps (default: 10)
|
|
206
|
+
rotationJumpThreshold?: number; // radians (default: PI/4)
|
|
207
|
+
smoothingHalfLifeMs?: number; // offset decay half-life (default: 200)
|
|
208
|
+
teleportThreshold?: number; // px, jumps above this snap instantly (default: Infinity)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**How it works:**
|
|
213
|
+
1. Computes sim-interpolated position from prev/current + factor
|
|
214
|
+
2. Compares with previous frame's sim position — if jump > threshold, it's a rollback
|
|
215
|
+
3. Absorbs the jump into an offset (entity stays visually in place)
|
|
216
|
+
4. Decays offset exponentially each frame (`pow(0.5, dt / halfLifeMs)`)
|
|
217
|
+
5. Output = sim position + decaying offset
|
|
218
|
+
|
|
219
|
+
**Normal frames:** offset = 0, output = pure interpolation, zero added latency.
|
|
220
|
+
**After rollback:** entity smoothly slides to correct position over ~200-400ms instead of teleporting.
|
|
221
|
+
|
|
222
|
+
**Key detail:** `_lastSimX/Y` stores the raw sim position (not smoothed) to avoid a feedback loop where the offset causes repeated false jump detections.
|
|
223
|
+
|
|
224
|
+
## 4. Preconditions
|
|
225
|
+
|
|
226
|
+
- **`SimulationClock.start()` must be called before `getElapsedTime()`** — Throws error if called before start
|
|
227
|
+
- **`SnapshotHistory.set()` requires non-decreasing ticks** — Call `rollback()` before writing older ticks
|
|
228
|
+
- **`PhaseNudger.activate()` should be called when ClockSync is ready** — Nudging is disabled until activated
|
|
229
|
+
- **`UUID.fromString()` requires valid canonical UUID format** — Throws `TypeError` for invalid strings
|
|
230
|
+
|
|
231
|
+
## 5. Postconditions
|
|
232
|
+
|
|
233
|
+
- `SimulationClock.update(dt)` advances `accumulatedTime` by `dt + phaseNudger.drain()`
|
|
234
|
+
- `SnapshotHistory.getNearest(tick)` returns the snapshot with the greatest tick < requested tick
|
|
235
|
+
- `UUID.generateMasked()` produces UUIDs that pass `isMaskedUint8()` validation
|
|
236
|
+
- `interpolateTransform2d*()` functions produce smooth interpolation for rendering between ticks
|
|
237
|
+
|
|
238
|
+
## 6. Invariants & Constraints
|
|
239
|
+
|
|
240
|
+
- **SnapshotHistory monotonicity:** Ticks must be non-decreasing. Violating this throws an error.
|
|
241
|
+
- **RingBuffer wraps around:** When full, oldest elements are overwritten (FIFO)
|
|
242
|
+
- **PhaseNudger debt accumulation:** Uses weighted average (0.3) to prevent oscillation from noisy server hints
|
|
243
|
+
- **UUID masked format:** Last 4 bytes = FNV-1a hash of first 12 bytes (after RFC 4122 version/variant bits set)
|
|
244
|
+
- **Transform2d interpolation coordinate system:** Y is negated, rotation is negated (matches Pixi.js convention)
|
|
245
|
+
|
|
246
|
+
## 7. Safety Notes (AI Agent)
|
|
247
|
+
|
|
248
|
+
### DO NOT
|
|
249
|
+
|
|
250
|
+
- **DO NOT call `SimulationClock.getElapsedTime()` before `start()`** — This throws an error
|
|
251
|
+
- **DO NOT write older ticks to `SnapshotHistory` without calling `rollback()` first** — This throws an error
|
|
252
|
+
- **DO NOT rely on `interpolateTransform2d()` return value persistence** — It returns a shared buffer that is reused on the next call. Use `...ToRef()` variant for persistent results.
|
|
253
|
+
- **DO NOT use `PhaseNudger` directly inside ECS systems** — It's managed by `SimulationClock`, which calls `drain()` automatically
|
|
254
|
+
- **DO NOT assume masked UUIDs are cryptographically secure** — They have 90 bits entropy (vs 122 in standard v4) and are detectable by hash validation
|
|
255
|
+
- **DO NOT mutate `RingBuffer` during iteration** — Iterator behavior is undefined if buffer is modified during iteration
|
|
256
|
+
|
|
257
|
+
### Common Mistakes
|
|
258
|
+
|
|
259
|
+
- Forgetting to call `clock.start()` before using `getElapsedTime()` → throws error
|
|
260
|
+
- Writing ticks out of order to `SnapshotHistory` without rollback → throws error
|
|
261
|
+
- Storing `interpolateTransform2d()` result → next call overwrites it (use `...ToRef()` instead)
|
|
262
|
+
- Using `UUID.isMaskedString()` to validate UUID format → it returns false for invalid UUIDs (not an error)
|
|
263
|
+
|
|
264
|
+
## 8. Usage Examples
|
|
265
|
+
|
|
266
|
+
### SimulationClock with PhaseNudger
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { SimulationClock } from '@lagless/misc';
|
|
270
|
+
|
|
271
|
+
const FRAME_LENGTH = 16.666; // 60 FPS
|
|
272
|
+
const MAX_NUDGE_PER_FRAME = 2; // Max 2ms correction per frame
|
|
273
|
+
|
|
274
|
+
const clock = new SimulationClock(FRAME_LENGTH, MAX_NUDGE_PER_FRAME);
|
|
275
|
+
clock.start();
|
|
276
|
+
|
|
277
|
+
// When ClockSync is ready
|
|
278
|
+
clock.phaseNudger.activate();
|
|
279
|
+
|
|
280
|
+
// On server tick hint (from network)
|
|
281
|
+
clock.phaseNudger.onServerTickHint(serverTick, localTick);
|
|
282
|
+
|
|
283
|
+
// In game loop
|
|
284
|
+
function gameLoop(dt: number) {
|
|
285
|
+
clock.update(dt); // Advances accumulatedTime with phase correction
|
|
286
|
+
|
|
287
|
+
const ticks = Math.floor(clock.accumulatedTime / FRAME_LENGTH);
|
|
288
|
+
for (let i = 0; i < ticks; i++) {
|
|
289
|
+
runSimulationTick();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### SnapshotHistory for Rollback
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
import { SnapshotHistory } from '@lagless/misc';
|
|
298
|
+
|
|
299
|
+
const history = new SnapshotHistory<ArrayBuffer>(100); // Store up to 100 snapshots
|
|
300
|
+
|
|
301
|
+
// Store snapshots after each tick
|
|
302
|
+
function saveTick(tick: number, worldState: ArrayBuffer) {
|
|
303
|
+
history.set(tick, worldState.slice(0)); // Clone ArrayBuffer
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Rollback to tick
|
|
307
|
+
function rollbackTo(tick: number) {
|
|
308
|
+
const snapshot = history.getNearest(tick); // Get snapshot with tick < target
|
|
309
|
+
restoreWorldState(snapshot);
|
|
310
|
+
history.rollback(tick); // Remove snapshots >= tick
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Now you can write new snapshots starting from tick
|
|
314
|
+
saveTick(tick, newWorldState);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### UUID with Masked Bot Detection
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { UUID } from '@lagless/misc';
|
|
321
|
+
|
|
322
|
+
// Human player
|
|
323
|
+
const playerUuid = UUID.generate();
|
|
324
|
+
console.log(playerUuid.asString()); // "550e8400-e29b-41d4-a716-446655440000"
|
|
325
|
+
console.log(UUID.isMaskedString(playerUuid.asString())); // false
|
|
326
|
+
|
|
327
|
+
// Bot player
|
|
328
|
+
const botUuid = UUID.generateMasked();
|
|
329
|
+
console.log(botUuid.asString()); // "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
|
330
|
+
console.log(UUID.isMaskedString(botUuid.asString())); // true
|
|
331
|
+
|
|
332
|
+
// Check at runtime
|
|
333
|
+
function handlePlayer(uuid: UUID) {
|
|
334
|
+
if (UUID.isMaskedUint8(uuid.asUint8())) {
|
|
335
|
+
console.log('Bot detected');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### RingBuffer
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { RingBuffer } from '@lagless/misc';
|
|
344
|
+
|
|
345
|
+
const buffer = new RingBuffer<number>(3);
|
|
346
|
+
|
|
347
|
+
buffer.add(1); // idx 0
|
|
348
|
+
buffer.add(2); // idx 1
|
|
349
|
+
buffer.add(3); // idx 2
|
|
350
|
+
buffer.add(4); // idx 0 (overwrites 1)
|
|
351
|
+
|
|
352
|
+
console.log(buffer.get(0)); // 4
|
|
353
|
+
console.log(buffer.get(1)); // 2
|
|
354
|
+
console.log(buffer.get(2)); // 3
|
|
355
|
+
|
|
356
|
+
// Iterate (visits all added items, including overwritten slots)
|
|
357
|
+
for (const item of buffer) {
|
|
358
|
+
console.log(item); // 4, 2, 3
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Transform2d Interpolation (Rendering)
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { interpolateTransform2dCursorToRef } from '@lagless/misc';
|
|
366
|
+
|
|
367
|
+
// In rendering loop (between simulation ticks)
|
|
368
|
+
function render(interpolationFactor: number) {
|
|
369
|
+
const result = { x: 0, y: 0, rotation: 0 };
|
|
370
|
+
|
|
371
|
+
for (const entityId of entities) {
|
|
372
|
+
const transform = getTransform2dComponent(entityId);
|
|
373
|
+
|
|
374
|
+
// Interpolate between prev and current transform
|
|
375
|
+
interpolateTransform2dCursorToRef(transform, interpolationFactor, result);
|
|
376
|
+
|
|
377
|
+
// Use result for rendering
|
|
378
|
+
sprite.x = result.x;
|
|
379
|
+
sprite.y = result.y;
|
|
380
|
+
sprite.rotation = result.rotation;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### VisualSmoother2d (Multiplayer Rendering)
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { VisualSmoother2d } from '@lagless/misc';
|
|
389
|
+
|
|
390
|
+
// One smoother per rendered entity (e.g. in React ref)
|
|
391
|
+
const smoother = new VisualSmoother2d();
|
|
392
|
+
|
|
393
|
+
// Each render frame — feed raw ECS data, read smoothed output
|
|
394
|
+
function onUpdate(entity: number) {
|
|
395
|
+
smoother.update(
|
|
396
|
+
transform2d.unsafe.prevPositionX[entity],
|
|
397
|
+
transform2d.unsafe.prevPositionY[entity],
|
|
398
|
+
transform2d.unsafe.positionX[entity],
|
|
399
|
+
transform2d.unsafe.positionY[entity],
|
|
400
|
+
transform2d.unsafe.prevRotation[entity],
|
|
401
|
+
transform2d.unsafe.rotation[entity],
|
|
402
|
+
simulation.interpolationFactor,
|
|
403
|
+
);
|
|
404
|
+
container.x = smoother.x;
|
|
405
|
+
container.y = smoother.y;
|
|
406
|
+
container.rotation = smoother.rotation;
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## 9. Testing Guidance
|
|
411
|
+
|
|
412
|
+
No tests currently exist for this library. When adding tests, consider:
|
|
413
|
+
|
|
414
|
+
**Framework suggestion:** Vitest (used by other Lagless libraries)
|
|
415
|
+
|
|
416
|
+
**Test coverage priorities:**
|
|
417
|
+
1. `SnapshotHistory` — Binary search correctness, rollback behavior, edge cases (empty, single element)
|
|
418
|
+
2. `PhaseNudger` — Debt accumulation, drain rate, large correction rejection
|
|
419
|
+
3. `UUID` — Masked UUID validation (hash correctness), string parsing edge cases
|
|
420
|
+
4. `RingBuffer` — Wrap-around behavior, iteration during overwrites
|
|
421
|
+
5. `SimulationClock` — Time accumulation with phase nudge integration
|
|
422
|
+
|
|
423
|
+
**Example test pattern:**
|
|
424
|
+
```typescript
|
|
425
|
+
import { describe, it, expect } from 'vitest';
|
|
426
|
+
import { SnapshotHistory } from '@lagless/misc';
|
|
427
|
+
|
|
428
|
+
describe('SnapshotHistory', () => {
|
|
429
|
+
it('should retrieve nearest snapshot before target tick', () => {
|
|
430
|
+
const history = new SnapshotHistory<string>(10);
|
|
431
|
+
history.set(10, 'tick10');
|
|
432
|
+
history.set(20, 'tick20');
|
|
433
|
+
history.set(30, 'tick30');
|
|
434
|
+
|
|
435
|
+
expect(history.getNearest(25)).toBe('tick20');
|
|
436
|
+
expect(history.getNearest(31)).toBe('tick30');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should throw if no snapshot exists before target tick', () => {
|
|
440
|
+
const history = new SnapshotHistory<string>(10);
|
|
441
|
+
history.set(10, 'tick10');
|
|
442
|
+
|
|
443
|
+
expect(() => history.getNearest(5)).toThrow();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## 10. Change Checklist
|
|
449
|
+
|
|
450
|
+
When modifying this module:
|
|
451
|
+
|
|
452
|
+
1. **Verify rollback correctness:** Changes to `SnapshotHistory` must preserve binary search invariants
|
|
453
|
+
2. **Profile allocation:** `interpolateTransform2dToRef()` must remain zero-allocation
|
|
454
|
+
3. **Test PhaseNudger stability:** Ensure debt accumulation doesn't oscillate with noisy server hints
|
|
455
|
+
4. **Maintain UUID masked format:** FNV-1a hash of first 12 bytes must be embedded in last 4 bytes
|
|
456
|
+
5. **Update this README:** Document new APIs in Public API section
|
|
457
|
+
6. **Add tests:** Cover new functionality with unit tests
|
|
458
|
+
7. **Check cross-platform behavior:** `now()` must work in Node.js and all browsers
|
|
459
|
+
|
|
460
|
+
## 11. Integration Notes
|
|
461
|
+
|
|
462
|
+
### Used By
|
|
463
|
+
|
|
464
|
+
- **`@lagless/core`:**
|
|
465
|
+
- `SimulationClock` — Drives tick loop timing in `ECSSimulation`
|
|
466
|
+
- `SnapshotHistory` — Stores world state snapshots for rollback
|
|
467
|
+
- `RingBuffer` — Buffers incoming RPC inputs
|
|
468
|
+
|
|
469
|
+
- **`@lagless/net-wire`:**
|
|
470
|
+
- `RingBuffer` — Buffers network packets
|
|
471
|
+
|
|
472
|
+
- **`circle-sumo-simulation`:**
|
|
473
|
+
- `UUID` — Identifies players, detects bots with `isMaskedUint8()`
|
|
474
|
+
- `interpolateTransform2d*` — Smooths rendering between ticks in game view
|
|
475
|
+
|
|
476
|
+
### Common Integration Patterns
|
|
477
|
+
|
|
478
|
+
**Rollback Netcode Pattern:**
|
|
479
|
+
```typescript
|
|
480
|
+
import { SnapshotHistory, SimulationClock } from '@lagless/misc';
|
|
481
|
+
|
|
482
|
+
class ECSSimulation {
|
|
483
|
+
private clock = new SimulationClock(16.666, 2);
|
|
484
|
+
private snapshots = new SnapshotHistory<ArrayBuffer>(100);
|
|
485
|
+
|
|
486
|
+
start() {
|
|
487
|
+
this.clock.start();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
update(dt: number) {
|
|
491
|
+
this.clock.update(dt);
|
|
492
|
+
|
|
493
|
+
const targetTick = Math.floor(this.clock.accumulatedTime / 16.666);
|
|
494
|
+
while (this.currentTick < targetTick) {
|
|
495
|
+
this.runTick();
|
|
496
|
+
this.snapshots.set(this.currentTick, this.saveSnapshot());
|
|
497
|
+
this.currentTick++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
rollbackTo(tick: number) {
|
|
502
|
+
const snapshot = this.snapshots.getNearest(tick);
|
|
503
|
+
this.restoreSnapshot(snapshot);
|
|
504
|
+
this.snapshots.rollback(tick);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
**Server Clock Sync:**
|
|
510
|
+
```typescript
|
|
511
|
+
import { SimulationClock } from '@lagless/misc';
|
|
512
|
+
import { ClockSync } from '@lagless/net-wire';
|
|
513
|
+
|
|
514
|
+
const clock = new SimulationClock(16.666, 2);
|
|
515
|
+
const clockSync = new ClockSync(...);
|
|
516
|
+
|
|
517
|
+
// When clock sync is ready
|
|
518
|
+
clockSync.on('ready', () => {
|
|
519
|
+
clock.phaseNudger.activate();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// On every tick input from server
|
|
523
|
+
connection.on('tickInput', (msg) => {
|
|
524
|
+
const serverTick = msg.tick;
|
|
525
|
+
const localTick = Math.floor(clock.accumulatedTime / 16.666);
|
|
526
|
+
clock.phaseNudger.onServerTickHint(serverTick, localTick);
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## 12. Appendix
|
|
531
|
+
|
|
532
|
+
### UUID Masked Format Details
|
|
533
|
+
|
|
534
|
+
A **masked UUID** embeds a checksum in the last 4 bytes, allowing bot detection without a database lookup.
|
|
535
|
+
|
|
536
|
+
**Structure:**
|
|
537
|
+
```
|
|
538
|
+
Byte 0-5: Random data
|
|
539
|
+
Byte 6-7: Version/variant bits (RFC 4122 v4)
|
|
540
|
+
Byte 8-11: Random data
|
|
541
|
+
Byte 12-15: FNV-1a hash of bytes 0-11
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**FNV-1a Hash (32-bit):**
|
|
545
|
+
```
|
|
546
|
+
hash = 0x811c9dc5 (offset basis)
|
|
547
|
+
for each byte:
|
|
548
|
+
hash ^= byte
|
|
549
|
+
hash *= 0x01000193 (FNV prime)
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
**Entropy:** 90 bits (vs 122 in standard v4 UUID)
|
|
553
|
+
|
|
554
|
+
**False positive rate:** 1 in ~4.3 billion (2^32)
|
|
555
|
+
|
|
556
|
+
**Why this works:**
|
|
557
|
+
- Standard UUIDs are random → hash of first 12 bytes doesn't match last 4 bytes
|
|
558
|
+
- Masked UUIDs are generated with embedded hash → validation passes
|
|
559
|
+
- No server roundtrip needed to identify bots
|
|
560
|
+
|
|
561
|
+
### SnapshotHistory Ring Buffer Implementation
|
|
562
|
+
|
|
563
|
+
Internally uses two arrays (`_ticks` and `_snapshots`) as a ring buffer:
|
|
564
|
+
- `_head` — Physical index of oldest element
|
|
565
|
+
- `_count` — Number of stored elements
|
|
566
|
+
- `indexAt(logicalIndex)` — Maps logical index [0, count) to physical index [0, maxSize)
|
|
567
|
+
|
|
568
|
+
**Binary search** for `getNearest()`:
|
|
569
|
+
- Searches logical indices [0, count)
|
|
570
|
+
- Finds greatest tick < target using standard binary search
|
|
571
|
+
- O(log n) complexity
|
|
572
|
+
|
|
573
|
+
**Rollback** uses binary search to find first tick ≥ target, then truncates count.
|
|
574
|
+
|
|
575
|
+
### PhaseNudger Tuning Parameters
|
|
576
|
+
|
|
577
|
+
**`LARGE_DEBT_THRESHOLD_MS = 50`** — Debt above this drains faster (50% per frame vs gradual)
|
|
578
|
+
|
|
579
|
+
**`MAX_SINGLE_CORRECTION_MS = 5000`** — Reject server hints with corrections > 5s (likely bad data)
|
|
580
|
+
|
|
581
|
+
**`weight = 0.3`** — Weighted accumulation: `debt = debt * 0.7 + correction * 0.3`. Prevents oscillation from noisy hints.
|
|
582
|
+
|
|
583
|
+
**Recommended `maxNudgePerFrame`:**
|
|
584
|
+
- 1-2ms for 60 FPS (imperceptible to players)
|
|
585
|
+
- Higher values = faster convergence but more visible speed changes
|
|
586
|
+
|
|
587
|
+
### Transform2d Interpolation Coordinate System
|
|
588
|
+
|
|
589
|
+
**Pixi.js convention (Circle Sumo uses this):**
|
|
590
|
+
- Y-axis points down → Y is negated during interpolation
|
|
591
|
+
- Rotation is counter-clockwise → Rotation is negated
|
|
592
|
+
|
|
593
|
+
**Teleport detection:**
|
|
594
|
+
- Calculates distance² between prev and current position
|
|
595
|
+
- If distance² ≥ threshold² (default 300² = 90,000), skip interpolation (snap to target)
|
|
596
|
+
- Prevents interpolation artifacts when player teleports or respawns
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './lib/now.js';
|
|
2
|
+
export * from './lib/uuid.js';
|
|
3
|
+
export * from './lib/ring-buffer.js';
|
|
4
|
+
export * from './lib/snapshot-history.js';
|
|
5
|
+
export * from './lib/simulation-clock.js';
|
|
6
|
+
export * from './lib/transform2d-utils.js';
|
|
7
|
+
export * from './lib/visual-smoother-2d.js';
|
|
8
|
+
export * from './lib/logger.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iBAAiB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './lib/now.js';
|
|
2
|
+
export * from './lib/uuid.js';
|
|
3
|
+
export * from './lib/ring-buffer.js';
|
|
4
|
+
export * from './lib/snapshot-history.js';
|
|
5
|
+
export * from './lib/simulation-clock.js';
|
|
6
|
+
export * from './lib/transform2d-utils.js';
|
|
7
|
+
export * from './lib/visual-smoother-2d.js';
|
|
8
|
+
export * from './lib/logger.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare enum LogLevel {
|
|
2
|
+
Debug = 0,
|
|
3
|
+
Info = 1,
|
|
4
|
+
Warn = 2,
|
|
5
|
+
Error = 3,
|
|
6
|
+
Silent = 4
|
|
7
|
+
}
|
|
8
|
+
export interface LogSink {
|
|
9
|
+
debug(tag: string, message: string, ...args: unknown[]): void;
|
|
10
|
+
info(tag: string, message: string, ...args: unknown[]): void;
|
|
11
|
+
warn(tag: string, message: string, ...args: unknown[]): void;
|
|
12
|
+
error(tag: string, message: string, ...args: unknown[]): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function setLogLevel(level: LogLevel): void;
|
|
15
|
+
export declare function getLogLevel(): LogLevel;
|
|
16
|
+
export declare function setLogSink(sink: LogSink): void;
|
|
17
|
+
export type Logger = ReturnType<typeof createLogger>;
|
|
18
|
+
export declare function createLogger(tag: string): {
|
|
19
|
+
debug(message: string, ...args: unknown[]): void;
|
|
20
|
+
info(message: string, ...args: unknown[]): void;
|
|
21
|
+
warn(message: string, ...args: unknown[]): void;
|
|
22
|
+
error(message: string, ...args: unknown[]): void;
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/lib/logger.ts"],"names":[],"mappings":"AAAA,oBAAY,QAAQ;IAClB,KAAK,IAAI;IACT,IAAI,IAAI;IACR,IAAI,IAAI;IACR,KAAK,IAAI;IACT,MAAM,IAAI;CACX;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC9D,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC7D,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC7D,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAC/D;AAYD,wBAAgB,WAAW,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI,CAEjD;AAED,wBAAgB,WAAW,IAAI,QAAQ,CAEtC;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAE9C;AAED,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AAErD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM;mBAErB,MAAM,WAAW,OAAO,EAAE,GAAG,IAAI;kBAGlC,MAAM,WAAW,OAAO,EAAE,GAAG,IAAI;kBAGjC,MAAM,WAAW,OAAO,EAAE,GAAG,IAAI;mBAGhC,MAAM,WAAW,OAAO,EAAE,GAAG,IAAI;EAInD"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export var LogLevel;
|
|
2
|
+
(function (LogLevel) {
|
|
3
|
+
LogLevel[LogLevel["Debug"] = 0] = "Debug";
|
|
4
|
+
LogLevel[LogLevel["Info"] = 1] = "Info";
|
|
5
|
+
LogLevel[LogLevel["Warn"] = 2] = "Warn";
|
|
6
|
+
LogLevel[LogLevel["Error"] = 3] = "Error";
|
|
7
|
+
LogLevel[LogLevel["Silent"] = 4] = "Silent";
|
|
8
|
+
})(LogLevel || (LogLevel = {}));
|
|
9
|
+
const consoleSink = {
|
|
10
|
+
debug: (tag, msg, ...args) => console.debug(`[${tag}]`, msg, ...args),
|
|
11
|
+
info: (tag, msg, ...args) => console.log(`[${tag}]`, msg, ...args),
|
|
12
|
+
warn: (tag, msg, ...args) => console.warn(`[${tag}]`, msg, ...args),
|
|
13
|
+
error: (tag, msg, ...args) => console.error(`[${tag}]`, msg, ...args),
|
|
14
|
+
};
|
|
15
|
+
let globalLevel = LogLevel.Debug;
|
|
16
|
+
let globalSink = consoleSink;
|
|
17
|
+
export function setLogLevel(level) {
|
|
18
|
+
globalLevel = level;
|
|
19
|
+
}
|
|
20
|
+
export function getLogLevel() {
|
|
21
|
+
return globalLevel;
|
|
22
|
+
}
|
|
23
|
+
export function setLogSink(sink) {
|
|
24
|
+
globalSink = sink;
|
|
25
|
+
}
|
|
26
|
+
export function createLogger(tag) {
|
|
27
|
+
return {
|
|
28
|
+
debug(message, ...args) {
|
|
29
|
+
if (globalLevel <= LogLevel.Debug)
|
|
30
|
+
globalSink.debug(tag, message, ...args);
|
|
31
|
+
},
|
|
32
|
+
info(message, ...args) {
|
|
33
|
+
if (globalLevel <= LogLevel.Info)
|
|
34
|
+
globalSink.info(tag, message, ...args);
|
|
35
|
+
},
|
|
36
|
+
warn(message, ...args) {
|
|
37
|
+
if (globalLevel <= LogLevel.Warn)
|
|
38
|
+
globalSink.warn(tag, message, ...args);
|
|
39
|
+
},
|
|
40
|
+
error(message, ...args) {
|
|
41
|
+
if (globalLevel <= LogLevel.Error)
|
|
42
|
+
globalSink.error(tag, message, ...args);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|