@watchtower-sdk/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -102
- package/dist/index.d.mts +30 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.js +128 -20
- package/dist/index.mjs +128 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @watchtower-sdk/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The simplest way to add multiplayer to your game. Point at your state, join a room, done.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -15,74 +15,169 @@ import { Watchtower } from '@watchtower-sdk/core'
|
|
|
15
15
|
|
|
16
16
|
const wt = new Watchtower({
|
|
17
17
|
gameId: 'my-game',
|
|
18
|
-
apiKey: 'wt_live_...'
|
|
18
|
+
apiKey: 'wt_live_...' // Get from watchtower.host
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
await wt.save('progress', { level: 5 })
|
|
23
|
-
const data = await wt.load('progress')
|
|
24
|
-
|
|
25
|
-
// Multiplayer
|
|
21
|
+
// Your game state
|
|
26
22
|
const state = { players: {} }
|
|
23
|
+
|
|
24
|
+
// Make it multiplayer
|
|
27
25
|
const sync = wt.sync(state)
|
|
28
|
-
await sync.join('room
|
|
29
|
-
|
|
30
|
-
//
|
|
26
|
+
await sync.join('my-room')
|
|
27
|
+
|
|
28
|
+
// Add yourself
|
|
29
|
+
state.players[sync.myId] = { x: 0, y: 0, name: 'Player1' }
|
|
30
|
+
|
|
31
|
+
// Move (automatically syncs!)
|
|
32
|
+
state.players[sync.myId].x += 10
|
|
33
|
+
|
|
34
|
+
// Others appear automatically in state.players
|
|
35
|
+
for (const [id, player] of Object.entries(state.players)) {
|
|
36
|
+
drawPlayer(player.x, player.y)
|
|
37
|
+
}
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
No events. No message handlers. Just read and write your state.
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
---
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
// Save
|
|
39
|
-
await wt.save('progress', { level: 5, coins: 100 })
|
|
40
|
-
await wt.save('settings', { music: true, sfx: true })
|
|
44
|
+
## State Templates
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
const progress = await wt.load('progress')
|
|
44
|
-
const settings = await wt.load('settings')
|
|
46
|
+
Pick the pattern that matches your game:
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
const keys = await wt.listSaves() // ['progress', 'settings']
|
|
48
|
+
### Movement Game (Cursor Party, Agar.io)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
```typescript
|
|
51
|
+
interface GameState {
|
|
52
|
+
players: Record<string, {
|
|
53
|
+
x: number
|
|
54
|
+
y: number
|
|
55
|
+
name: string
|
|
56
|
+
color: string
|
|
57
|
+
}>
|
|
58
|
+
}
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
const state: GameState = { players: {} }
|
|
61
|
+
const sync = wt.sync(state, {
|
|
62
|
+
interpolate: true,
|
|
63
|
+
interpolationDelay: 100,
|
|
64
|
+
jitterBuffer: 50
|
|
65
|
+
})
|
|
66
|
+
```
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
### Chat / Lobby
|
|
56
69
|
|
|
57
70
|
```typescript
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
interface GameState {
|
|
72
|
+
players: Record<string, {
|
|
73
|
+
name: string
|
|
74
|
+
avatar: string
|
|
75
|
+
ready: boolean
|
|
76
|
+
}>
|
|
77
|
+
messages: Array<{
|
|
78
|
+
from: string
|
|
79
|
+
text: string
|
|
80
|
+
ts: number
|
|
81
|
+
}>
|
|
82
|
+
}
|
|
60
83
|
|
|
61
|
-
|
|
62
|
-
const sync = wt.sync(state)
|
|
84
|
+
const state: GameState = { players: {}, messages: [] }
|
|
85
|
+
const sync = wt.sync(state, { interpolate: false }) // No movement = no interpolation needed
|
|
86
|
+
```
|
|
63
87
|
|
|
64
|
-
|
|
65
|
-
await sync.join('my-room')
|
|
88
|
+
### Turn-Based (Chess, Cards)
|
|
66
89
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
```typescript
|
|
91
|
+
interface GameState {
|
|
92
|
+
players: Record<string, {
|
|
93
|
+
name: string
|
|
94
|
+
hand?: Card[] // Hidden from others in real implementation
|
|
95
|
+
}>
|
|
96
|
+
currentTurn: string
|
|
97
|
+
board: BoardState
|
|
98
|
+
phase: 'waiting' | 'playing' | 'finished'
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
101
|
+
const state: GameState = {
|
|
102
|
+
players: {},
|
|
103
|
+
currentTurn: '',
|
|
104
|
+
board: initialBoard,
|
|
105
|
+
phase: 'waiting'
|
|
106
|
+
}
|
|
107
|
+
const sync = wt.sync(state, { interpolate: false })
|
|
108
|
+
```
|
|
76
109
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
110
|
+
### Action Game (Shooter, Brawler)
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
interface GameState {
|
|
114
|
+
players: Record<string, {
|
|
115
|
+
x: number
|
|
116
|
+
y: number
|
|
117
|
+
vx: number // Velocity helps with prediction
|
|
118
|
+
vy: number
|
|
119
|
+
health: number
|
|
120
|
+
facing: 'left' | 'right'
|
|
121
|
+
animation: string
|
|
122
|
+
}>
|
|
80
123
|
}
|
|
124
|
+
|
|
125
|
+
const state: GameState = { players: {} }
|
|
126
|
+
const sync = wt.sync(state, {
|
|
127
|
+
interpolate: true,
|
|
128
|
+
interpolationDelay: 50, // Lower for faster games
|
|
129
|
+
jitterBuffer: 25
|
|
130
|
+
})
|
|
81
131
|
```
|
|
82
132
|
|
|
83
|
-
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Smoothing Options
|
|
136
|
+
|
|
137
|
+
The SDK automatically smooths remote player movement. Configure based on your game type:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
const sync = wt.sync(state, {
|
|
141
|
+
// Core settings
|
|
142
|
+
tickRate: 20, // Updates per second (default: 20)
|
|
143
|
+
interpolate: true, // Smooth remote movement (default: true)
|
|
144
|
+
|
|
145
|
+
// Smoothing tuning
|
|
146
|
+
interpolationDelay: 100, // Render others Xms in the past (default: 100)
|
|
147
|
+
jitterBuffer: 50, // Buffer packets Xms to smooth delivery (default: 0)
|
|
148
|
+
|
|
149
|
+
// Connection handling
|
|
150
|
+
autoReconnect: true, // Auto-reconnect on disconnect (default: true)
|
|
151
|
+
maxReconnectAttempts: 10 // Give up after X attempts (default: 10)
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Recommended Presets
|
|
156
|
+
|
|
157
|
+
| Game Type | interpolationDelay | jitterBuffer | Notes |
|
|
158
|
+
|-----------|-------------------|--------------|-------|
|
|
159
|
+
| Casual (cursor party) | 100ms | 50ms | Buttery smooth, slight delay |
|
|
160
|
+
| Action (platformer) | 50ms | 25ms | Responsive, still smooth |
|
|
161
|
+
| Fast (shooter) | 40ms | 20ms | Very responsive |
|
|
162
|
+
| Turn-based | 0ms | 0ms | Instant, no movement to smooth |
|
|
84
163
|
|
|
85
|
-
|
|
164
|
+
**Why the delay?** The SDK renders other players slightly "in the past" using server timestamps. This allows it to interpolate between known positions instead of guessing. 100ms is imperceptible for casual games but makes movement silky smooth.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Properties
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
sync.myId // Your player ID
|
|
172
|
+
sync.roomId // Current room ID, or null
|
|
173
|
+
sync.connected // WebSocket connected?
|
|
174
|
+
sync.playerCount // Players in room
|
|
175
|
+
sync.latency // RTT to server in ms
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Rooms
|
|
86
181
|
|
|
87
182
|
```typescript
|
|
88
183
|
// Create a new room
|
|
@@ -99,53 +194,84 @@ await sync.leave()
|
|
|
99
194
|
const rooms = await sync.listRooms()
|
|
100
195
|
```
|
|
101
196
|
|
|
102
|
-
|
|
197
|
+
---
|
|
103
198
|
|
|
104
|
-
|
|
105
|
-
const sync = wt.sync(state, {
|
|
106
|
-
tickRate: 20, // Updates per second (default: 20)
|
|
107
|
-
interpolate: true // Smooth remote movement (default: true)
|
|
108
|
-
})
|
|
109
|
-
```
|
|
199
|
+
## Events
|
|
110
200
|
|
|
111
|
-
|
|
201
|
+
You don't *need* events — just read your state. But if you want notifications:
|
|
112
202
|
|
|
113
203
|
```typescript
|
|
114
|
-
|
|
115
|
-
sync.
|
|
116
|
-
sync.
|
|
204
|
+
// Player events
|
|
205
|
+
sync.on('join', (playerId) => console.log(`${playerId} joined`))
|
|
206
|
+
sync.on('leave', (playerId) => console.log(`${playerId} left`))
|
|
207
|
+
|
|
208
|
+
// Connection events
|
|
209
|
+
sync.on('connected', () => console.log('Connected!'))
|
|
210
|
+
sync.on('disconnected', () => console.log('Disconnected'))
|
|
211
|
+
sync.on('reconnecting', ({ attempt, delay }) => console.log(`Reconnecting in ${delay}ms...`))
|
|
212
|
+
sync.on('reconnected', () => console.log('Reconnected!'))
|
|
213
|
+
|
|
214
|
+
// Error handling
|
|
215
|
+
sync.on('error', (err) => console.error(err))
|
|
117
216
|
```
|
|
118
217
|
|
|
119
|
-
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Broadcast Messages
|
|
120
221
|
|
|
121
|
-
|
|
222
|
+
For one-off events that don't belong in state (explosions, sound effects):
|
|
122
223
|
|
|
123
224
|
```typescript
|
|
124
|
-
|
|
125
|
-
sync.
|
|
126
|
-
|
|
127
|
-
|
|
225
|
+
// Send
|
|
226
|
+
sync.broadcast({ type: 'explosion', x: 100, y: 200 })
|
|
227
|
+
|
|
228
|
+
// Receive
|
|
229
|
+
sync.on('message', (from, data) => {
|
|
230
|
+
if (data.type === 'explosion') {
|
|
231
|
+
playExplosion(data.x, data.y)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
128
234
|
```
|
|
129
235
|
|
|
130
|
-
|
|
236
|
+
---
|
|
131
237
|
|
|
132
|
-
|
|
238
|
+
## Cloud Saves
|
|
239
|
+
|
|
240
|
+
Simple key-value storage per player:
|
|
133
241
|
|
|
134
242
|
```typescript
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
243
|
+
// Save
|
|
244
|
+
await wt.save('progress', { level: 5, coins: 100 })
|
|
245
|
+
|
|
246
|
+
// Load
|
|
247
|
+
const progress = await wt.load('progress')
|
|
248
|
+
|
|
249
|
+
// List all saves
|
|
250
|
+
const keys = await wt.listSaves() // ['progress']
|
|
251
|
+
|
|
252
|
+
// Delete
|
|
253
|
+
await wt.deleteSave('progress')
|
|
139
254
|
```
|
|
140
255
|
|
|
256
|
+
---
|
|
257
|
+
|
|
141
258
|
## Full Example
|
|
142
259
|
|
|
143
260
|
```typescript
|
|
144
261
|
import { Watchtower } from '@watchtower-sdk/core'
|
|
145
262
|
|
|
146
263
|
const wt = new Watchtower({ gameId: 'my-game', apiKey: 'wt_...' })
|
|
147
|
-
|
|
148
|
-
|
|
264
|
+
|
|
265
|
+
// State template: movement game
|
|
266
|
+
const state = {
|
|
267
|
+
players: {} as Record<string, { x: number; y: number; color: string }>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sync = wt.sync(state, {
|
|
271
|
+
interpolate: true,
|
|
272
|
+
interpolationDelay: 100,
|
|
273
|
+
jitterBuffer: 50
|
|
274
|
+
})
|
|
149
275
|
|
|
150
276
|
// Join or create room
|
|
151
277
|
const code = prompt('Room code? (blank to create)')
|
|
@@ -171,57 +297,42 @@ function loop() {
|
|
|
171
297
|
if (keys.up) state.players[sync.myId].y -= 5
|
|
172
298
|
if (keys.down) state.players[sync.myId].y += 5
|
|
173
299
|
|
|
174
|
-
// Draw everyone
|
|
300
|
+
// Draw everyone (others are auto-interpolated!)
|
|
175
301
|
ctx.clearRect(0, 0, 800, 600)
|
|
176
302
|
for (const [id, p] of Object.entries(state.players)) {
|
|
177
303
|
ctx.fillStyle = p.color
|
|
178
304
|
ctx.fillRect(p.x - 10, p.y - 10, 20, 20)
|
|
305
|
+
|
|
306
|
+
// Show latency for your player
|
|
307
|
+
if (id === sync.myId) {
|
|
308
|
+
ctx.fillText(`${sync.latency}ms`, p.x, p.y - 15)
|
|
309
|
+
}
|
|
179
310
|
}
|
|
180
311
|
|
|
312
|
+
// Debug info
|
|
313
|
+
ctx.fillStyle = '#fff'
|
|
314
|
+
ctx.fillText(`Players: ${sync.playerCount}`, 10, 20)
|
|
315
|
+
|
|
181
316
|
requestAnimationFrame(loop)
|
|
182
317
|
}
|
|
183
318
|
loop()
|
|
184
319
|
```
|
|
185
320
|
|
|
186
|
-
|
|
321
|
+
---
|
|
187
322
|
|
|
188
|
-
|
|
323
|
+
## Best Practices
|
|
189
324
|
|
|
190
|
-
|
|
191
|
-
const wt = new Watchtower({
|
|
192
|
-
gameId: string, // From dashboard
|
|
193
|
-
apiKey?: string, // From dashboard
|
|
194
|
-
playerId?: string // Auto-generated if not provided
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
wt.playerId // Current player ID
|
|
198
|
-
wt.gameId // Game ID
|
|
199
|
-
|
|
200
|
-
// Saves
|
|
201
|
-
await wt.save(key: string, data: any): Promise<void>
|
|
202
|
-
await wt.load<T>(key: string): Promise<T | null>
|
|
203
|
-
await wt.listSaves(): Promise<string[]>
|
|
204
|
-
await wt.deleteSave(key: string): Promise<void>
|
|
325
|
+
1. **Keep state flat.** Nested objects sync fine, but flat is faster to diff.
|
|
205
326
|
|
|
206
|
-
|
|
207
|
-
wt.sync(state: object, options?: SyncOptions): Sync
|
|
208
|
-
```
|
|
327
|
+
2. **Use the `players` key.** The SDK auto-detects `players`, `entities`, `users`, or `clients`.
|
|
209
328
|
|
|
210
|
-
|
|
329
|
+
3. **Don't store secrets in state.** Everyone sees everything. Use server-side validation for game logic.
|
|
211
330
|
|
|
212
|
-
|
|
213
|
-
sync.myId: string
|
|
214
|
-
sync.roomId: string | null
|
|
215
|
-
sync.connected: boolean
|
|
331
|
+
4. **Let the SDK interpolate.** Don't add your own smoothing on top — it'll fight the SDK.
|
|
216
332
|
|
|
217
|
-
|
|
218
|
-
await sync.leave(): Promise<void>
|
|
219
|
-
await sync.create(options?: CreateOptions): Promise<string>
|
|
220
|
-
await sync.listRooms(): Promise<RoomListing[]>
|
|
333
|
+
5. **Test with "Open another tab".** Easiest way to see multiplayer working.
|
|
221
334
|
|
|
222
|
-
|
|
223
|
-
sync.off(event: string, callback: Function): void
|
|
224
|
-
```
|
|
335
|
+
---
|
|
225
336
|
|
|
226
337
|
## License
|
|
227
338
|
|
package/dist/index.d.mts
CHANGED
|
@@ -209,11 +209,20 @@ declare class Room {
|
|
|
209
209
|
interface SyncOptions {
|
|
210
210
|
/** Updates per second (default: 20) */
|
|
211
211
|
tickRate?: number;
|
|
212
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Smoothing mode for remote players (default: 'lerp')
|
|
214
|
+
* - 'lerp': Frame-based lerping toward latest position. Zero latency, simple, great for casual games.
|
|
215
|
+
* - 'interpolate': Time-based snapshot interpolation. Adds latency but more accurate for competitive games.
|
|
216
|
+
* - 'none': No smoothing, positions snap immediately.
|
|
217
|
+
*/
|
|
218
|
+
smoothing?: 'lerp' | 'interpolate' | 'none';
|
|
219
|
+
/** Lerp factor - how fast to catch up to target (default: 0.15). Only used in 'lerp' mode. */
|
|
220
|
+
lerpFactor?: number;
|
|
221
|
+
/** @deprecated Use smoothing: 'interpolate' instead */
|
|
213
222
|
interpolate?: boolean;
|
|
214
|
-
/** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
|
|
223
|
+
/** Interpolation delay in ms - how far "in the past" to render others (default: 100). Only used in 'interpolate' mode. */
|
|
215
224
|
interpolationDelay?: number;
|
|
216
|
-
/** Jitter buffer size in ms - smooths network variance (default:
|
|
225
|
+
/** Jitter buffer size in ms - smooths network variance (default: 0). Only used in 'interpolate' mode. */
|
|
217
226
|
jitterBuffer?: number;
|
|
218
227
|
/** Enable auto-reconnection on disconnect (default: true) */
|
|
219
228
|
autoReconnect?: boolean;
|
|
@@ -271,6 +280,10 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
271
280
|
get roomId(): string | null;
|
|
272
281
|
/** Whether currently connected to a room */
|
|
273
282
|
get connected(): boolean;
|
|
283
|
+
/** Number of players in the current room */
|
|
284
|
+
get playerCount(): number;
|
|
285
|
+
/** Current latency to server in milliseconds */
|
|
286
|
+
get latency(): number;
|
|
274
287
|
private config;
|
|
275
288
|
private options;
|
|
276
289
|
private _roomId;
|
|
@@ -280,11 +293,17 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
280
293
|
private lastSentState;
|
|
281
294
|
private listeners;
|
|
282
295
|
private snapshots;
|
|
296
|
+
private lerpTargets;
|
|
283
297
|
private jitterQueue;
|
|
284
298
|
private reconnectAttempts;
|
|
285
299
|
private reconnectTimeout;
|
|
286
300
|
private lastJoinOptions;
|
|
287
301
|
private isReconnecting;
|
|
302
|
+
private serverTimeOffset;
|
|
303
|
+
private _playerCount;
|
|
304
|
+
private _latency;
|
|
305
|
+
private pingStartTime;
|
|
306
|
+
private pingInterval;
|
|
288
307
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
289
308
|
/**
|
|
290
309
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -327,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
327
346
|
private handleMessage;
|
|
328
347
|
private applyFullState;
|
|
329
348
|
private applyPlayerState;
|
|
349
|
+
private setLerpTarget;
|
|
330
350
|
private addSnapshot;
|
|
331
351
|
private applyStateDirect;
|
|
332
352
|
private removePlayer;
|
|
@@ -335,6 +355,13 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
335
355
|
private findPlayersKey;
|
|
336
356
|
private startSyncLoop;
|
|
337
357
|
private startInterpolationLoop;
|
|
358
|
+
/**
|
|
359
|
+
* Frame-based lerping (gnome-chat style)
|
|
360
|
+
* Lerps each remote player's position toward their target by lerpFactor each frame.
|
|
361
|
+
* Simple, zero latency, great for casual games.
|
|
362
|
+
*/
|
|
363
|
+
private updateLerp;
|
|
364
|
+
private measureLatency;
|
|
338
365
|
private stopInterpolationLoop;
|
|
339
366
|
private processJitterQueue;
|
|
340
367
|
private stopSyncLoop;
|
package/dist/index.d.ts
CHANGED
|
@@ -209,11 +209,20 @@ declare class Room {
|
|
|
209
209
|
interface SyncOptions {
|
|
210
210
|
/** Updates per second (default: 20) */
|
|
211
211
|
tickRate?: number;
|
|
212
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Smoothing mode for remote players (default: 'lerp')
|
|
214
|
+
* - 'lerp': Frame-based lerping toward latest position. Zero latency, simple, great for casual games.
|
|
215
|
+
* - 'interpolate': Time-based snapshot interpolation. Adds latency but more accurate for competitive games.
|
|
216
|
+
* - 'none': No smoothing, positions snap immediately.
|
|
217
|
+
*/
|
|
218
|
+
smoothing?: 'lerp' | 'interpolate' | 'none';
|
|
219
|
+
/** Lerp factor - how fast to catch up to target (default: 0.15). Only used in 'lerp' mode. */
|
|
220
|
+
lerpFactor?: number;
|
|
221
|
+
/** @deprecated Use smoothing: 'interpolate' instead */
|
|
213
222
|
interpolate?: boolean;
|
|
214
|
-
/** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
|
|
223
|
+
/** Interpolation delay in ms - how far "in the past" to render others (default: 100). Only used in 'interpolate' mode. */
|
|
215
224
|
interpolationDelay?: number;
|
|
216
|
-
/** Jitter buffer size in ms - smooths network variance (default:
|
|
225
|
+
/** Jitter buffer size in ms - smooths network variance (default: 0). Only used in 'interpolate' mode. */
|
|
217
226
|
jitterBuffer?: number;
|
|
218
227
|
/** Enable auto-reconnection on disconnect (default: true) */
|
|
219
228
|
autoReconnect?: boolean;
|
|
@@ -271,6 +280,10 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
271
280
|
get roomId(): string | null;
|
|
272
281
|
/** Whether currently connected to a room */
|
|
273
282
|
get connected(): boolean;
|
|
283
|
+
/** Number of players in the current room */
|
|
284
|
+
get playerCount(): number;
|
|
285
|
+
/** Current latency to server in milliseconds */
|
|
286
|
+
get latency(): number;
|
|
274
287
|
private config;
|
|
275
288
|
private options;
|
|
276
289
|
private _roomId;
|
|
@@ -280,11 +293,17 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
280
293
|
private lastSentState;
|
|
281
294
|
private listeners;
|
|
282
295
|
private snapshots;
|
|
296
|
+
private lerpTargets;
|
|
283
297
|
private jitterQueue;
|
|
284
298
|
private reconnectAttempts;
|
|
285
299
|
private reconnectTimeout;
|
|
286
300
|
private lastJoinOptions;
|
|
287
301
|
private isReconnecting;
|
|
302
|
+
private serverTimeOffset;
|
|
303
|
+
private _playerCount;
|
|
304
|
+
private _latency;
|
|
305
|
+
private pingStartTime;
|
|
306
|
+
private pingInterval;
|
|
288
307
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
289
308
|
/**
|
|
290
309
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -327,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
327
346
|
private handleMessage;
|
|
328
347
|
private applyFullState;
|
|
329
348
|
private applyPlayerState;
|
|
349
|
+
private setLerpTarget;
|
|
330
350
|
private addSnapshot;
|
|
331
351
|
private applyStateDirect;
|
|
332
352
|
private removePlayer;
|
|
@@ -335,6 +355,13 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
335
355
|
private findPlayersKey;
|
|
336
356
|
private startSyncLoop;
|
|
337
357
|
private startInterpolationLoop;
|
|
358
|
+
/**
|
|
359
|
+
* Frame-based lerping (gnome-chat style)
|
|
360
|
+
* Lerps each remote player's position toward their target by lerpFactor each frame.
|
|
361
|
+
* Simple, zero latency, great for casual games.
|
|
362
|
+
*/
|
|
363
|
+
private updateLerp;
|
|
364
|
+
private measureLatency;
|
|
338
365
|
private stopInterpolationLoop;
|
|
339
366
|
private processJitterQueue;
|
|
340
367
|
private stopSyncLoop;
|
package/dist/index.js
CHANGED
|
@@ -293,6 +293,8 @@ var Sync = class {
|
|
|
293
293
|
this.listeners = /* @__PURE__ */ new Map();
|
|
294
294
|
// Snapshot-based interpolation: store timestamped snapshots per player
|
|
295
295
|
this.snapshots = /* @__PURE__ */ new Map();
|
|
296
|
+
// Lerp-based smoothing: store target positions per player (for 'lerp' mode)
|
|
297
|
+
this.lerpTargets = /* @__PURE__ */ new Map();
|
|
296
298
|
// Jitter buffer: queue incoming updates before applying
|
|
297
299
|
this.jitterQueue = [];
|
|
298
300
|
// Auto-reconnect state
|
|
@@ -300,15 +302,30 @@ var Sync = class {
|
|
|
300
302
|
this.reconnectTimeout = null;
|
|
301
303
|
this.lastJoinOptions = void 0;
|
|
302
304
|
this.isReconnecting = false;
|
|
305
|
+
// Server time sync and metrics
|
|
306
|
+
this.serverTimeOffset = 0;
|
|
307
|
+
// Local time - server time
|
|
308
|
+
this._playerCount = 1;
|
|
309
|
+
this._latency = 0;
|
|
310
|
+
this.pingStartTime = 0;
|
|
311
|
+
this.pingInterval = null;
|
|
303
312
|
this.state = state;
|
|
304
313
|
this.myId = config.playerId;
|
|
305
314
|
this.config = config;
|
|
315
|
+
let smoothing = options?.smoothing ?? "lerp";
|
|
316
|
+
if (options?.interpolate === false) {
|
|
317
|
+
smoothing = "none";
|
|
318
|
+
} else if (options?.interpolate === true && !options?.smoothing) {
|
|
319
|
+
smoothing = "lerp";
|
|
320
|
+
}
|
|
306
321
|
this.options = {
|
|
307
322
|
tickRate: options?.tickRate ?? 20,
|
|
308
|
-
|
|
323
|
+
smoothing,
|
|
324
|
+
lerpFactor: options?.lerpFactor ?? 0.15,
|
|
325
|
+
interpolate: smoothing !== "none",
|
|
326
|
+
// Legacy compat
|
|
309
327
|
interpolationDelay: options?.interpolationDelay ?? 100,
|
|
310
328
|
jitterBuffer: options?.jitterBuffer ?? 0,
|
|
311
|
-
// 0 = immediate, set to 50+ for smoothing
|
|
312
329
|
autoReconnect: options?.autoReconnect ?? true,
|
|
313
330
|
maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
|
|
314
331
|
};
|
|
@@ -321,6 +338,14 @@ var Sync = class {
|
|
|
321
338
|
get connected() {
|
|
322
339
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
323
340
|
}
|
|
341
|
+
/** Number of players in the current room */
|
|
342
|
+
get playerCount() {
|
|
343
|
+
return this._playerCount;
|
|
344
|
+
}
|
|
345
|
+
/** Current latency to server in milliseconds */
|
|
346
|
+
get latency() {
|
|
347
|
+
return this._latency;
|
|
348
|
+
}
|
|
324
349
|
/**
|
|
325
350
|
* Join a room - your state will sync with everyone in this room
|
|
326
351
|
*
|
|
@@ -464,23 +489,41 @@ var Sync = class {
|
|
|
464
489
|
});
|
|
465
490
|
}
|
|
466
491
|
handleMessage(data) {
|
|
492
|
+
if (data.serverTime) {
|
|
493
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
494
|
+
}
|
|
467
495
|
switch (data.type) {
|
|
496
|
+
case "welcome":
|
|
497
|
+
if (data.state) {
|
|
498
|
+
this.applyFullState(data.state);
|
|
499
|
+
}
|
|
500
|
+
this._playerCount = data.playerCount || 1;
|
|
501
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
502
|
+
break;
|
|
468
503
|
case "full_state":
|
|
469
504
|
this.applyFullState(data.state);
|
|
470
505
|
break;
|
|
471
506
|
case "state":
|
|
472
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
507
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
473
508
|
break;
|
|
474
509
|
case "join":
|
|
510
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
475
511
|
this.emit("join", data.playerId);
|
|
476
512
|
break;
|
|
477
513
|
case "leave":
|
|
514
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
478
515
|
this.removePlayer(data.playerId);
|
|
479
516
|
this.emit("leave", data.playerId);
|
|
480
517
|
break;
|
|
481
518
|
case "message":
|
|
482
519
|
this.emit("message", data.from, data.data);
|
|
483
520
|
break;
|
|
521
|
+
case "pong":
|
|
522
|
+
if (this.pingStartTime) {
|
|
523
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
524
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
484
527
|
}
|
|
485
528
|
}
|
|
486
529
|
applyFullState(fullState) {
|
|
@@ -490,27 +533,49 @@ var Sync = class {
|
|
|
490
533
|
}
|
|
491
534
|
}
|
|
492
535
|
}
|
|
493
|
-
applyPlayerState(playerId, playerState) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
playerId,
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
536
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
537
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
538
|
+
switch (this.options.smoothing) {
|
|
539
|
+
case "lerp":
|
|
540
|
+
this.setLerpTarget(playerId, playerState);
|
|
541
|
+
break;
|
|
542
|
+
case "interpolate":
|
|
543
|
+
if (this.options.jitterBuffer > 0) {
|
|
544
|
+
this.jitterQueue.push({
|
|
545
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
546
|
+
playerId,
|
|
547
|
+
state: { ...playerState },
|
|
548
|
+
timestamp
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
552
|
+
}
|
|
553
|
+
break;
|
|
554
|
+
case "none":
|
|
555
|
+
default:
|
|
556
|
+
this.applyStateDirect(playerId, playerState);
|
|
557
|
+
break;
|
|
504
558
|
}
|
|
505
559
|
}
|
|
506
|
-
|
|
560
|
+
setLerpTarget(playerId, playerState) {
|
|
561
|
+
const isNewPlayer = !this.lerpTargets.has(playerId);
|
|
562
|
+
this.lerpTargets.set(playerId, { ...playerState });
|
|
563
|
+
const playersKey = this.findPlayersKey();
|
|
564
|
+
if (playersKey) {
|
|
565
|
+
const players = this.state[playersKey];
|
|
566
|
+
if (isNewPlayer || !players[playerId]) {
|
|
567
|
+
players[playerId] = { ...playerState };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
507
572
|
const isNewPlayer = !this.snapshots.has(playerId);
|
|
508
573
|
if (isNewPlayer) {
|
|
509
574
|
this.snapshots.set(playerId, []);
|
|
510
575
|
}
|
|
511
576
|
const playerSnapshots = this.snapshots.get(playerId);
|
|
512
577
|
playerSnapshots.push({
|
|
513
|
-
time: Date.now(),
|
|
578
|
+
time: timestamp || Date.now(),
|
|
514
579
|
state: { ...playerState }
|
|
515
580
|
});
|
|
516
581
|
while (playerSnapshots.length > 10) {
|
|
@@ -536,6 +601,7 @@ var Sync = class {
|
|
|
536
601
|
const players = this.state[playersKey];
|
|
537
602
|
delete players[playerId];
|
|
538
603
|
this.snapshots.delete(playerId);
|
|
604
|
+
this.lerpTargets.delete(playerId);
|
|
539
605
|
}
|
|
540
606
|
attemptReconnect() {
|
|
541
607
|
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
@@ -568,6 +634,7 @@ var Sync = class {
|
|
|
568
634
|
}
|
|
569
635
|
}
|
|
570
636
|
this.snapshots.clear();
|
|
637
|
+
this.lerpTargets.clear();
|
|
571
638
|
this.jitterQueue = [];
|
|
572
639
|
}
|
|
573
640
|
findPlayersKey() {
|
|
@@ -593,24 +660,65 @@ var Sync = class {
|
|
|
593
660
|
}
|
|
594
661
|
startInterpolationLoop() {
|
|
595
662
|
if (this.interpolationInterval) return;
|
|
596
|
-
if (
|
|
663
|
+
if (this.options.smoothing === "none") return;
|
|
597
664
|
this.interpolationInterval = setInterval(() => {
|
|
598
|
-
this.
|
|
599
|
-
|
|
665
|
+
if (this.options.smoothing === "lerp") {
|
|
666
|
+
this.updateLerp();
|
|
667
|
+
} else if (this.options.smoothing === "interpolate") {
|
|
668
|
+
this.processJitterQueue();
|
|
669
|
+
this.updateInterpolation();
|
|
670
|
+
}
|
|
600
671
|
}, 16);
|
|
672
|
+
this.pingInterval = setInterval(() => {
|
|
673
|
+
this.measureLatency();
|
|
674
|
+
}, 2e3);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Frame-based lerping (gnome-chat style)
|
|
678
|
+
* Lerps each remote player's position toward their target by lerpFactor each frame.
|
|
679
|
+
* Simple, zero latency, great for casual games.
|
|
680
|
+
*/
|
|
681
|
+
updateLerp() {
|
|
682
|
+
const playersKey = this.findPlayersKey();
|
|
683
|
+
if (!playersKey) return;
|
|
684
|
+
const players = this.state[playersKey];
|
|
685
|
+
const lerpFactor = this.options.lerpFactor;
|
|
686
|
+
for (const [playerId, target] of this.lerpTargets) {
|
|
687
|
+
if (playerId === this.myId) continue;
|
|
688
|
+
const player = players[playerId];
|
|
689
|
+
if (!player) continue;
|
|
690
|
+
for (const [key, targetValue] of Object.entries(target)) {
|
|
691
|
+
if (typeof targetValue === "number" && typeof player[key] === "number") {
|
|
692
|
+
const current = player[key];
|
|
693
|
+
player[key] = current + (targetValue - current) * lerpFactor;
|
|
694
|
+
} else if (typeof targetValue !== "number") {
|
|
695
|
+
player[key] = targetValue;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
measureLatency() {
|
|
701
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
702
|
+
this.pingStartTime = Date.now();
|
|
703
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
704
|
+
}
|
|
601
705
|
}
|
|
602
706
|
stopInterpolationLoop() {
|
|
603
707
|
if (this.interpolationInterval) {
|
|
604
708
|
clearInterval(this.interpolationInterval);
|
|
605
709
|
this.interpolationInterval = null;
|
|
606
710
|
}
|
|
711
|
+
if (this.pingInterval) {
|
|
712
|
+
clearInterval(this.pingInterval);
|
|
713
|
+
this.pingInterval = null;
|
|
714
|
+
}
|
|
607
715
|
}
|
|
608
716
|
processJitterQueue() {
|
|
609
717
|
const now = Date.now();
|
|
610
718
|
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
611
719
|
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
612
720
|
for (const item of ready) {
|
|
613
|
-
this.addSnapshot(item.playerId, item.state);
|
|
721
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
614
722
|
}
|
|
615
723
|
}
|
|
616
724
|
stopSyncLoop() {
|
package/dist/index.mjs
CHANGED
|
@@ -266,6 +266,8 @@ var Sync = class {
|
|
|
266
266
|
this.listeners = /* @__PURE__ */ new Map();
|
|
267
267
|
// Snapshot-based interpolation: store timestamped snapshots per player
|
|
268
268
|
this.snapshots = /* @__PURE__ */ new Map();
|
|
269
|
+
// Lerp-based smoothing: store target positions per player (for 'lerp' mode)
|
|
270
|
+
this.lerpTargets = /* @__PURE__ */ new Map();
|
|
269
271
|
// Jitter buffer: queue incoming updates before applying
|
|
270
272
|
this.jitterQueue = [];
|
|
271
273
|
// Auto-reconnect state
|
|
@@ -273,15 +275,30 @@ var Sync = class {
|
|
|
273
275
|
this.reconnectTimeout = null;
|
|
274
276
|
this.lastJoinOptions = void 0;
|
|
275
277
|
this.isReconnecting = false;
|
|
278
|
+
// Server time sync and metrics
|
|
279
|
+
this.serverTimeOffset = 0;
|
|
280
|
+
// Local time - server time
|
|
281
|
+
this._playerCount = 1;
|
|
282
|
+
this._latency = 0;
|
|
283
|
+
this.pingStartTime = 0;
|
|
284
|
+
this.pingInterval = null;
|
|
276
285
|
this.state = state;
|
|
277
286
|
this.myId = config.playerId;
|
|
278
287
|
this.config = config;
|
|
288
|
+
let smoothing = options?.smoothing ?? "lerp";
|
|
289
|
+
if (options?.interpolate === false) {
|
|
290
|
+
smoothing = "none";
|
|
291
|
+
} else if (options?.interpolate === true && !options?.smoothing) {
|
|
292
|
+
smoothing = "lerp";
|
|
293
|
+
}
|
|
279
294
|
this.options = {
|
|
280
295
|
tickRate: options?.tickRate ?? 20,
|
|
281
|
-
|
|
296
|
+
smoothing,
|
|
297
|
+
lerpFactor: options?.lerpFactor ?? 0.15,
|
|
298
|
+
interpolate: smoothing !== "none",
|
|
299
|
+
// Legacy compat
|
|
282
300
|
interpolationDelay: options?.interpolationDelay ?? 100,
|
|
283
301
|
jitterBuffer: options?.jitterBuffer ?? 0,
|
|
284
|
-
// 0 = immediate, set to 50+ for smoothing
|
|
285
302
|
autoReconnect: options?.autoReconnect ?? true,
|
|
286
303
|
maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
|
|
287
304
|
};
|
|
@@ -294,6 +311,14 @@ var Sync = class {
|
|
|
294
311
|
get connected() {
|
|
295
312
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
296
313
|
}
|
|
314
|
+
/** Number of players in the current room */
|
|
315
|
+
get playerCount() {
|
|
316
|
+
return this._playerCount;
|
|
317
|
+
}
|
|
318
|
+
/** Current latency to server in milliseconds */
|
|
319
|
+
get latency() {
|
|
320
|
+
return this._latency;
|
|
321
|
+
}
|
|
297
322
|
/**
|
|
298
323
|
* Join a room - your state will sync with everyone in this room
|
|
299
324
|
*
|
|
@@ -437,23 +462,41 @@ var Sync = class {
|
|
|
437
462
|
});
|
|
438
463
|
}
|
|
439
464
|
handleMessage(data) {
|
|
465
|
+
if (data.serverTime) {
|
|
466
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
467
|
+
}
|
|
440
468
|
switch (data.type) {
|
|
469
|
+
case "welcome":
|
|
470
|
+
if (data.state) {
|
|
471
|
+
this.applyFullState(data.state);
|
|
472
|
+
}
|
|
473
|
+
this._playerCount = data.playerCount || 1;
|
|
474
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
475
|
+
break;
|
|
441
476
|
case "full_state":
|
|
442
477
|
this.applyFullState(data.state);
|
|
443
478
|
break;
|
|
444
479
|
case "state":
|
|
445
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
480
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
446
481
|
break;
|
|
447
482
|
case "join":
|
|
483
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
448
484
|
this.emit("join", data.playerId);
|
|
449
485
|
break;
|
|
450
486
|
case "leave":
|
|
487
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
451
488
|
this.removePlayer(data.playerId);
|
|
452
489
|
this.emit("leave", data.playerId);
|
|
453
490
|
break;
|
|
454
491
|
case "message":
|
|
455
492
|
this.emit("message", data.from, data.data);
|
|
456
493
|
break;
|
|
494
|
+
case "pong":
|
|
495
|
+
if (this.pingStartTime) {
|
|
496
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
497
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
457
500
|
}
|
|
458
501
|
}
|
|
459
502
|
applyFullState(fullState) {
|
|
@@ -463,27 +506,49 @@ var Sync = class {
|
|
|
463
506
|
}
|
|
464
507
|
}
|
|
465
508
|
}
|
|
466
|
-
applyPlayerState(playerId, playerState) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
playerId,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
509
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
510
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
511
|
+
switch (this.options.smoothing) {
|
|
512
|
+
case "lerp":
|
|
513
|
+
this.setLerpTarget(playerId, playerState);
|
|
514
|
+
break;
|
|
515
|
+
case "interpolate":
|
|
516
|
+
if (this.options.jitterBuffer > 0) {
|
|
517
|
+
this.jitterQueue.push({
|
|
518
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
519
|
+
playerId,
|
|
520
|
+
state: { ...playerState },
|
|
521
|
+
timestamp
|
|
522
|
+
});
|
|
523
|
+
} else {
|
|
524
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
case "none":
|
|
528
|
+
default:
|
|
529
|
+
this.applyStateDirect(playerId, playerState);
|
|
530
|
+
break;
|
|
477
531
|
}
|
|
478
532
|
}
|
|
479
|
-
|
|
533
|
+
setLerpTarget(playerId, playerState) {
|
|
534
|
+
const isNewPlayer = !this.lerpTargets.has(playerId);
|
|
535
|
+
this.lerpTargets.set(playerId, { ...playerState });
|
|
536
|
+
const playersKey = this.findPlayersKey();
|
|
537
|
+
if (playersKey) {
|
|
538
|
+
const players = this.state[playersKey];
|
|
539
|
+
if (isNewPlayer || !players[playerId]) {
|
|
540
|
+
players[playerId] = { ...playerState };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
480
545
|
const isNewPlayer = !this.snapshots.has(playerId);
|
|
481
546
|
if (isNewPlayer) {
|
|
482
547
|
this.snapshots.set(playerId, []);
|
|
483
548
|
}
|
|
484
549
|
const playerSnapshots = this.snapshots.get(playerId);
|
|
485
550
|
playerSnapshots.push({
|
|
486
|
-
time: Date.now(),
|
|
551
|
+
time: timestamp || Date.now(),
|
|
487
552
|
state: { ...playerState }
|
|
488
553
|
});
|
|
489
554
|
while (playerSnapshots.length > 10) {
|
|
@@ -509,6 +574,7 @@ var Sync = class {
|
|
|
509
574
|
const players = this.state[playersKey];
|
|
510
575
|
delete players[playerId];
|
|
511
576
|
this.snapshots.delete(playerId);
|
|
577
|
+
this.lerpTargets.delete(playerId);
|
|
512
578
|
}
|
|
513
579
|
attemptReconnect() {
|
|
514
580
|
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
@@ -541,6 +607,7 @@ var Sync = class {
|
|
|
541
607
|
}
|
|
542
608
|
}
|
|
543
609
|
this.snapshots.clear();
|
|
610
|
+
this.lerpTargets.clear();
|
|
544
611
|
this.jitterQueue = [];
|
|
545
612
|
}
|
|
546
613
|
findPlayersKey() {
|
|
@@ -566,24 +633,65 @@ var Sync = class {
|
|
|
566
633
|
}
|
|
567
634
|
startInterpolationLoop() {
|
|
568
635
|
if (this.interpolationInterval) return;
|
|
569
|
-
if (
|
|
636
|
+
if (this.options.smoothing === "none") return;
|
|
570
637
|
this.interpolationInterval = setInterval(() => {
|
|
571
|
-
this.
|
|
572
|
-
|
|
638
|
+
if (this.options.smoothing === "lerp") {
|
|
639
|
+
this.updateLerp();
|
|
640
|
+
} else if (this.options.smoothing === "interpolate") {
|
|
641
|
+
this.processJitterQueue();
|
|
642
|
+
this.updateInterpolation();
|
|
643
|
+
}
|
|
573
644
|
}, 16);
|
|
645
|
+
this.pingInterval = setInterval(() => {
|
|
646
|
+
this.measureLatency();
|
|
647
|
+
}, 2e3);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Frame-based lerping (gnome-chat style)
|
|
651
|
+
* Lerps each remote player's position toward their target by lerpFactor each frame.
|
|
652
|
+
* Simple, zero latency, great for casual games.
|
|
653
|
+
*/
|
|
654
|
+
updateLerp() {
|
|
655
|
+
const playersKey = this.findPlayersKey();
|
|
656
|
+
if (!playersKey) return;
|
|
657
|
+
const players = this.state[playersKey];
|
|
658
|
+
const lerpFactor = this.options.lerpFactor;
|
|
659
|
+
for (const [playerId, target] of this.lerpTargets) {
|
|
660
|
+
if (playerId === this.myId) continue;
|
|
661
|
+
const player = players[playerId];
|
|
662
|
+
if (!player) continue;
|
|
663
|
+
for (const [key, targetValue] of Object.entries(target)) {
|
|
664
|
+
if (typeof targetValue === "number" && typeof player[key] === "number") {
|
|
665
|
+
const current = player[key];
|
|
666
|
+
player[key] = current + (targetValue - current) * lerpFactor;
|
|
667
|
+
} else if (typeof targetValue !== "number") {
|
|
668
|
+
player[key] = targetValue;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
measureLatency() {
|
|
674
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
675
|
+
this.pingStartTime = Date.now();
|
|
676
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
677
|
+
}
|
|
574
678
|
}
|
|
575
679
|
stopInterpolationLoop() {
|
|
576
680
|
if (this.interpolationInterval) {
|
|
577
681
|
clearInterval(this.interpolationInterval);
|
|
578
682
|
this.interpolationInterval = null;
|
|
579
683
|
}
|
|
684
|
+
if (this.pingInterval) {
|
|
685
|
+
clearInterval(this.pingInterval);
|
|
686
|
+
this.pingInterval = null;
|
|
687
|
+
}
|
|
580
688
|
}
|
|
581
689
|
processJitterQueue() {
|
|
582
690
|
const now = Date.now();
|
|
583
691
|
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
584
692
|
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
585
693
|
for (const item of ready) {
|
|
586
|
-
this.addSnapshot(item.playerId, item.state);
|
|
694
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
587
695
|
}
|
|
588
696
|
}
|
|
589
697
|
stopSyncLoop() {
|