@watchtower-sdk/core 0.2.2 → 0.3.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/README.md +213 -102
- package/dist/index.d.mts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +194 -24
- package/dist/index.mjs +194 -24
- 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
|
@@ -211,6 +211,14 @@ interface SyncOptions {
|
|
|
211
211
|
tickRate?: number;
|
|
212
212
|
/** Enable interpolation for remote entities (default: true) */
|
|
213
213
|
interpolate?: boolean;
|
|
214
|
+
/** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
|
|
215
|
+
interpolationDelay?: number;
|
|
216
|
+
/** Jitter buffer size in ms - smooths network variance (default: 50) */
|
|
217
|
+
jitterBuffer?: number;
|
|
218
|
+
/** Enable auto-reconnection on disconnect (default: true) */
|
|
219
|
+
autoReconnect?: boolean;
|
|
220
|
+
/** Max reconnection attempts (default: 10) */
|
|
221
|
+
maxReconnectAttempts?: number;
|
|
214
222
|
}
|
|
215
223
|
interface JoinOptions {
|
|
216
224
|
/** Create room if it doesn't exist */
|
|
@@ -263,14 +271,29 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
263
271
|
get roomId(): string | null;
|
|
264
272
|
/** Whether currently connected to a room */
|
|
265
273
|
get connected(): boolean;
|
|
274
|
+
/** Number of players in the current room */
|
|
275
|
+
get playerCount(): number;
|
|
276
|
+
/** Current latency to server in milliseconds */
|
|
277
|
+
get latency(): number;
|
|
266
278
|
private config;
|
|
267
279
|
private options;
|
|
268
280
|
private _roomId;
|
|
269
281
|
private ws;
|
|
270
282
|
private syncInterval;
|
|
283
|
+
private interpolationInterval;
|
|
271
284
|
private lastSentState;
|
|
272
|
-
private interpolationTargets;
|
|
273
285
|
private listeners;
|
|
286
|
+
private snapshots;
|
|
287
|
+
private jitterQueue;
|
|
288
|
+
private reconnectAttempts;
|
|
289
|
+
private reconnectTimeout;
|
|
290
|
+
private lastJoinOptions;
|
|
291
|
+
private isReconnecting;
|
|
292
|
+
private serverTimeOffset;
|
|
293
|
+
private _playerCount;
|
|
294
|
+
private _latency;
|
|
295
|
+
private pingStartTime;
|
|
296
|
+
private pingInterval;
|
|
274
297
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
275
298
|
/**
|
|
276
299
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -303,7 +326,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
303
326
|
/**
|
|
304
327
|
* Subscribe to sync events
|
|
305
328
|
*/
|
|
306
|
-
on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'message', callback: Function): void;
|
|
329
|
+
on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
|
|
307
330
|
/**
|
|
308
331
|
* Unsubscribe from sync events
|
|
309
332
|
*/
|
|
@@ -313,13 +336,21 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
313
336
|
private handleMessage;
|
|
314
337
|
private applyFullState;
|
|
315
338
|
private applyPlayerState;
|
|
339
|
+
private addSnapshot;
|
|
340
|
+
private applyStateDirect;
|
|
316
341
|
private removePlayer;
|
|
342
|
+
private attemptReconnect;
|
|
317
343
|
private clearRemotePlayers;
|
|
318
344
|
private findPlayersKey;
|
|
319
345
|
private startSyncLoop;
|
|
346
|
+
private startInterpolationLoop;
|
|
347
|
+
private measureLatency;
|
|
348
|
+
private stopInterpolationLoop;
|
|
349
|
+
private processJitterQueue;
|
|
320
350
|
private stopSyncLoop;
|
|
321
351
|
private syncMyState;
|
|
322
352
|
private updateInterpolation;
|
|
353
|
+
private lerpState;
|
|
323
354
|
private generateRoomCode;
|
|
324
355
|
private getHeaders;
|
|
325
356
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -211,6 +211,14 @@ interface SyncOptions {
|
|
|
211
211
|
tickRate?: number;
|
|
212
212
|
/** Enable interpolation for remote entities (default: true) */
|
|
213
213
|
interpolate?: boolean;
|
|
214
|
+
/** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
|
|
215
|
+
interpolationDelay?: number;
|
|
216
|
+
/** Jitter buffer size in ms - smooths network variance (default: 50) */
|
|
217
|
+
jitterBuffer?: number;
|
|
218
|
+
/** Enable auto-reconnection on disconnect (default: true) */
|
|
219
|
+
autoReconnect?: boolean;
|
|
220
|
+
/** Max reconnection attempts (default: 10) */
|
|
221
|
+
maxReconnectAttempts?: number;
|
|
214
222
|
}
|
|
215
223
|
interface JoinOptions {
|
|
216
224
|
/** Create room if it doesn't exist */
|
|
@@ -263,14 +271,29 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
263
271
|
get roomId(): string | null;
|
|
264
272
|
/** Whether currently connected to a room */
|
|
265
273
|
get connected(): boolean;
|
|
274
|
+
/** Number of players in the current room */
|
|
275
|
+
get playerCount(): number;
|
|
276
|
+
/** Current latency to server in milliseconds */
|
|
277
|
+
get latency(): number;
|
|
266
278
|
private config;
|
|
267
279
|
private options;
|
|
268
280
|
private _roomId;
|
|
269
281
|
private ws;
|
|
270
282
|
private syncInterval;
|
|
283
|
+
private interpolationInterval;
|
|
271
284
|
private lastSentState;
|
|
272
|
-
private interpolationTargets;
|
|
273
285
|
private listeners;
|
|
286
|
+
private snapshots;
|
|
287
|
+
private jitterQueue;
|
|
288
|
+
private reconnectAttempts;
|
|
289
|
+
private reconnectTimeout;
|
|
290
|
+
private lastJoinOptions;
|
|
291
|
+
private isReconnecting;
|
|
292
|
+
private serverTimeOffset;
|
|
293
|
+
private _playerCount;
|
|
294
|
+
private _latency;
|
|
295
|
+
private pingStartTime;
|
|
296
|
+
private pingInterval;
|
|
274
297
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
275
298
|
/**
|
|
276
299
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -303,7 +326,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
303
326
|
/**
|
|
304
327
|
* Subscribe to sync events
|
|
305
328
|
*/
|
|
306
|
-
on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'message', callback: Function): void;
|
|
329
|
+
on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
|
|
307
330
|
/**
|
|
308
331
|
* Unsubscribe from sync events
|
|
309
332
|
*/
|
|
@@ -313,13 +336,21 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
313
336
|
private handleMessage;
|
|
314
337
|
private applyFullState;
|
|
315
338
|
private applyPlayerState;
|
|
339
|
+
private addSnapshot;
|
|
340
|
+
private applyStateDirect;
|
|
316
341
|
private removePlayer;
|
|
342
|
+
private attemptReconnect;
|
|
317
343
|
private clearRemotePlayers;
|
|
318
344
|
private findPlayersKey;
|
|
319
345
|
private startSyncLoop;
|
|
346
|
+
private startInterpolationLoop;
|
|
347
|
+
private measureLatency;
|
|
348
|
+
private stopInterpolationLoop;
|
|
349
|
+
private processJitterQueue;
|
|
320
350
|
private stopSyncLoop;
|
|
321
351
|
private syncMyState;
|
|
322
352
|
private updateInterpolation;
|
|
353
|
+
private lerpState;
|
|
323
354
|
private generateRoomCode;
|
|
324
355
|
private getHeaders;
|
|
325
356
|
}
|
package/dist/index.js
CHANGED
|
@@ -288,15 +288,36 @@ var Sync = class {
|
|
|
288
288
|
this._roomId = null;
|
|
289
289
|
this.ws = null;
|
|
290
290
|
this.syncInterval = null;
|
|
291
|
+
this.interpolationInterval = null;
|
|
291
292
|
this.lastSentState = "";
|
|
292
|
-
this.interpolationTargets = /* @__PURE__ */ new Map();
|
|
293
293
|
this.listeners = /* @__PURE__ */ new Map();
|
|
294
|
+
// Snapshot-based interpolation: store timestamped snapshots per player
|
|
295
|
+
this.snapshots = /* @__PURE__ */ new Map();
|
|
296
|
+
// Jitter buffer: queue incoming updates before applying
|
|
297
|
+
this.jitterQueue = [];
|
|
298
|
+
// Auto-reconnect state
|
|
299
|
+
this.reconnectAttempts = 0;
|
|
300
|
+
this.reconnectTimeout = null;
|
|
301
|
+
this.lastJoinOptions = void 0;
|
|
302
|
+
this.isReconnecting = false;
|
|
303
|
+
// Server time sync and metrics
|
|
304
|
+
this.serverTimeOffset = 0;
|
|
305
|
+
// Local time - server time
|
|
306
|
+
this._playerCount = 1;
|
|
307
|
+
this._latency = 0;
|
|
308
|
+
this.pingStartTime = 0;
|
|
309
|
+
this.pingInterval = null;
|
|
294
310
|
this.state = state;
|
|
295
311
|
this.myId = config.playerId;
|
|
296
312
|
this.config = config;
|
|
297
313
|
this.options = {
|
|
298
314
|
tickRate: options?.tickRate ?? 20,
|
|
299
|
-
interpolate: options?.interpolate ?? true
|
|
315
|
+
interpolate: options?.interpolate ?? true,
|
|
316
|
+
interpolationDelay: options?.interpolationDelay ?? 100,
|
|
317
|
+
jitterBuffer: options?.jitterBuffer ?? 0,
|
|
318
|
+
// 0 = immediate, set to 50+ for smoothing
|
|
319
|
+
autoReconnect: options?.autoReconnect ?? true,
|
|
320
|
+
maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
|
|
300
321
|
};
|
|
301
322
|
}
|
|
302
323
|
/** Current room ID (null if not in a room) */
|
|
@@ -307,6 +328,14 @@ var Sync = class {
|
|
|
307
328
|
get connected() {
|
|
308
329
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
309
330
|
}
|
|
331
|
+
/** Number of players in the current room */
|
|
332
|
+
get playerCount() {
|
|
333
|
+
return this._playerCount;
|
|
334
|
+
}
|
|
335
|
+
/** Current latency to server in milliseconds */
|
|
336
|
+
get latency() {
|
|
337
|
+
return this._latency;
|
|
338
|
+
}
|
|
310
339
|
/**
|
|
311
340
|
* Join a room - your state will sync with everyone in this room
|
|
312
341
|
*
|
|
@@ -314,23 +343,34 @@ var Sync = class {
|
|
|
314
343
|
* @param options - Join options
|
|
315
344
|
*/
|
|
316
345
|
async join(roomId, options) {
|
|
317
|
-
if (this._roomId) {
|
|
346
|
+
if (this._roomId && !this.isReconnecting) {
|
|
318
347
|
await this.leave();
|
|
319
348
|
}
|
|
320
349
|
this._roomId = roomId;
|
|
350
|
+
this.lastJoinOptions = options;
|
|
351
|
+
this.reconnectAttempts = 0;
|
|
321
352
|
await this.connectWebSocket(roomId, options);
|
|
322
353
|
this.startSyncLoop();
|
|
354
|
+
this.startInterpolationLoop();
|
|
323
355
|
}
|
|
324
356
|
/**
|
|
325
357
|
* Leave the current room
|
|
326
358
|
*/
|
|
327
359
|
async leave() {
|
|
360
|
+
if (this.reconnectTimeout) {
|
|
361
|
+
clearTimeout(this.reconnectTimeout);
|
|
362
|
+
this.reconnectTimeout = null;
|
|
363
|
+
}
|
|
364
|
+
this.isReconnecting = false;
|
|
328
365
|
this.stopSyncLoop();
|
|
366
|
+
this.stopInterpolationLoop();
|
|
329
367
|
if (this.ws) {
|
|
330
368
|
this.ws.close();
|
|
331
369
|
this.ws = null;
|
|
332
370
|
}
|
|
333
371
|
this.clearRemotePlayers();
|
|
372
|
+
this.snapshots.clear();
|
|
373
|
+
this.jitterQueue = [];
|
|
334
374
|
this._roomId = null;
|
|
335
375
|
}
|
|
336
376
|
/**
|
|
@@ -424,6 +464,9 @@ var Sync = class {
|
|
|
424
464
|
this.ws.onclose = () => {
|
|
425
465
|
this.stopSyncLoop();
|
|
426
466
|
this.emit("disconnected");
|
|
467
|
+
if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
|
|
468
|
+
this.attemptReconnect();
|
|
469
|
+
}
|
|
427
470
|
};
|
|
428
471
|
this.ws.onmessage = (event) => {
|
|
429
472
|
try {
|
|
@@ -436,23 +479,41 @@ var Sync = class {
|
|
|
436
479
|
});
|
|
437
480
|
}
|
|
438
481
|
handleMessage(data) {
|
|
482
|
+
if (data.serverTime) {
|
|
483
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
484
|
+
}
|
|
439
485
|
switch (data.type) {
|
|
486
|
+
case "welcome":
|
|
487
|
+
if (data.state) {
|
|
488
|
+
this.applyFullState(data.state);
|
|
489
|
+
}
|
|
490
|
+
this._playerCount = data.playerCount || 1;
|
|
491
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
492
|
+
break;
|
|
440
493
|
case "full_state":
|
|
441
494
|
this.applyFullState(data.state);
|
|
442
495
|
break;
|
|
443
496
|
case "state":
|
|
444
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
497
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
445
498
|
break;
|
|
446
499
|
case "join":
|
|
500
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
447
501
|
this.emit("join", data.playerId);
|
|
448
502
|
break;
|
|
449
503
|
case "leave":
|
|
504
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
450
505
|
this.removePlayer(data.playerId);
|
|
451
506
|
this.emit("leave", data.playerId);
|
|
452
507
|
break;
|
|
453
508
|
case "message":
|
|
454
509
|
this.emit("message", data.from, data.data);
|
|
455
510
|
break;
|
|
511
|
+
case "pong":
|
|
512
|
+
if (this.pingStartTime) {
|
|
513
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
514
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
515
|
+
}
|
|
516
|
+
break;
|
|
456
517
|
}
|
|
457
518
|
}
|
|
458
519
|
applyFullState(fullState) {
|
|
@@ -462,16 +523,46 @@ var Sync = class {
|
|
|
462
523
|
}
|
|
463
524
|
}
|
|
464
525
|
}
|
|
465
|
-
applyPlayerState(playerId, playerState) {
|
|
526
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
527
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
528
|
+
if (this.options.interpolate && this.options.jitterBuffer > 0) {
|
|
529
|
+
this.jitterQueue.push({
|
|
530
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
531
|
+
playerId,
|
|
532
|
+
state: { ...playerState },
|
|
533
|
+
timestamp
|
|
534
|
+
});
|
|
535
|
+
} else if (this.options.interpolate) {
|
|
536
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
537
|
+
} else {
|
|
538
|
+
this.applyStateDirect(playerId, playerState);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
542
|
+
const isNewPlayer = !this.snapshots.has(playerId);
|
|
543
|
+
if (isNewPlayer) {
|
|
544
|
+
this.snapshots.set(playerId, []);
|
|
545
|
+
}
|
|
546
|
+
const playerSnapshots = this.snapshots.get(playerId);
|
|
547
|
+
playerSnapshots.push({
|
|
548
|
+
time: timestamp || Date.now(),
|
|
549
|
+
state: { ...playerState }
|
|
550
|
+
});
|
|
551
|
+
while (playerSnapshots.length > 10) {
|
|
552
|
+
playerSnapshots.shift();
|
|
553
|
+
}
|
|
554
|
+
const playersKey = this.findPlayersKey();
|
|
555
|
+
if (playersKey) {
|
|
556
|
+
const players = this.state[playersKey];
|
|
557
|
+
if (isNewPlayer || !players[playerId]) {
|
|
558
|
+
players[playerId] = { ...playerState };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
applyStateDirect(playerId, playerState) {
|
|
466
563
|
const playersKey = this.findPlayersKey();
|
|
467
564
|
if (!playersKey) return;
|
|
468
565
|
const players = this.state[playersKey];
|
|
469
|
-
if (this.options.interpolate && players[playerId]) {
|
|
470
|
-
this.interpolationTargets.set(playerId, {
|
|
471
|
-
target: { ...playerState },
|
|
472
|
-
current: { ...players[playerId] }
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
566
|
players[playerId] = playerState;
|
|
476
567
|
}
|
|
477
568
|
removePlayer(playerId) {
|
|
@@ -479,7 +570,28 @@ var Sync = class {
|
|
|
479
570
|
if (!playersKey) return;
|
|
480
571
|
const players = this.state[playersKey];
|
|
481
572
|
delete players[playerId];
|
|
482
|
-
this.
|
|
573
|
+
this.snapshots.delete(playerId);
|
|
574
|
+
}
|
|
575
|
+
attemptReconnect() {
|
|
576
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
577
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.isReconnecting = true;
|
|
581
|
+
this.reconnectAttempts++;
|
|
582
|
+
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
|
|
583
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
|
|
584
|
+
this.reconnectTimeout = setTimeout(async () => {
|
|
585
|
+
try {
|
|
586
|
+
await this.connectWebSocket(this._roomId, this.lastJoinOptions);
|
|
587
|
+
this.startSyncLoop();
|
|
588
|
+
this.isReconnecting = false;
|
|
589
|
+
this.reconnectAttempts = 0;
|
|
590
|
+
this.emit("reconnected");
|
|
591
|
+
} catch (e) {
|
|
592
|
+
this.isReconnecting = false;
|
|
593
|
+
}
|
|
594
|
+
}, delay);
|
|
483
595
|
}
|
|
484
596
|
clearRemotePlayers() {
|
|
485
597
|
const playersKey = this.findPlayersKey();
|
|
@@ -490,7 +602,8 @@ var Sync = class {
|
|
|
490
602
|
delete players[playerId];
|
|
491
603
|
}
|
|
492
604
|
}
|
|
493
|
-
this.
|
|
605
|
+
this.snapshots.clear();
|
|
606
|
+
this.jitterQueue = [];
|
|
494
607
|
}
|
|
495
608
|
findPlayersKey() {
|
|
496
609
|
const candidates = ["players", "entities", "gnomes", "users", "clients"];
|
|
@@ -511,11 +624,43 @@ var Sync = class {
|
|
|
511
624
|
const intervalMs = 1e3 / this.options.tickRate;
|
|
512
625
|
this.syncInterval = setInterval(() => {
|
|
513
626
|
this.syncMyState();
|
|
514
|
-
if (this.options.interpolate) {
|
|
515
|
-
this.updateInterpolation();
|
|
516
|
-
}
|
|
517
627
|
}, intervalMs);
|
|
518
628
|
}
|
|
629
|
+
startInterpolationLoop() {
|
|
630
|
+
if (this.interpolationInterval) return;
|
|
631
|
+
if (!this.options.interpolate) return;
|
|
632
|
+
this.interpolationInterval = setInterval(() => {
|
|
633
|
+
this.processJitterQueue();
|
|
634
|
+
this.updateInterpolation();
|
|
635
|
+
}, 16);
|
|
636
|
+
this.pingInterval = setInterval(() => {
|
|
637
|
+
this.measureLatency();
|
|
638
|
+
}, 2e3);
|
|
639
|
+
}
|
|
640
|
+
measureLatency() {
|
|
641
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
642
|
+
this.pingStartTime = Date.now();
|
|
643
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
stopInterpolationLoop() {
|
|
647
|
+
if (this.interpolationInterval) {
|
|
648
|
+
clearInterval(this.interpolationInterval);
|
|
649
|
+
this.interpolationInterval = null;
|
|
650
|
+
}
|
|
651
|
+
if (this.pingInterval) {
|
|
652
|
+
clearInterval(this.pingInterval);
|
|
653
|
+
this.pingInterval = null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
processJitterQueue() {
|
|
657
|
+
const now = Date.now();
|
|
658
|
+
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
659
|
+
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
660
|
+
for (const item of ready) {
|
|
661
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
519
664
|
stopSyncLoop() {
|
|
520
665
|
if (this.syncInterval) {
|
|
521
666
|
clearInterval(this.syncInterval);
|
|
@@ -541,16 +686,41 @@ var Sync = class {
|
|
|
541
686
|
const playersKey = this.findPlayersKey();
|
|
542
687
|
if (!playersKey) return;
|
|
543
688
|
const players = this.state[playersKey];
|
|
544
|
-
const
|
|
545
|
-
for (const [playerId,
|
|
689
|
+
const renderTime = Date.now() - this.options.interpolationDelay;
|
|
690
|
+
for (const [playerId, playerSnapshots] of this.snapshots) {
|
|
691
|
+
if (playerId === this.myId) continue;
|
|
546
692
|
const player = players[playerId];
|
|
547
693
|
if (!player) continue;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
694
|
+
let before = null;
|
|
695
|
+
let after = null;
|
|
696
|
+
for (const snapshot of playerSnapshots) {
|
|
697
|
+
if (snapshot.time <= renderTime) {
|
|
698
|
+
before = snapshot;
|
|
699
|
+
} else if (!after) {
|
|
700
|
+
after = snapshot;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (before && after) {
|
|
704
|
+
const total = after.time - before.time;
|
|
705
|
+
const elapsed = renderTime - before.time;
|
|
706
|
+
const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
|
|
707
|
+
this.lerpState(player, before.state, after.state, alpha);
|
|
708
|
+
} else if (before) {
|
|
709
|
+
this.lerpState(player, player, before.state, 0.3);
|
|
710
|
+
} else if (after) {
|
|
711
|
+
this.lerpState(player, player, after.state, 0.3);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
lerpState(target, from, to, alpha) {
|
|
716
|
+
for (const key of Object.keys(to)) {
|
|
717
|
+
const fromVal = from[key];
|
|
718
|
+
const toVal = to[key];
|
|
719
|
+
if (typeof fromVal === "number" && typeof toVal === "number") {
|
|
720
|
+
target[key] = fromVal + (toVal - fromVal) * alpha;
|
|
721
|
+
} else {
|
|
722
|
+
if (alpha > 0.5) {
|
|
723
|
+
target[key] = toVal;
|
|
554
724
|
}
|
|
555
725
|
}
|
|
556
726
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -261,15 +261,36 @@ var Sync = class {
|
|
|
261
261
|
this._roomId = null;
|
|
262
262
|
this.ws = null;
|
|
263
263
|
this.syncInterval = null;
|
|
264
|
+
this.interpolationInterval = null;
|
|
264
265
|
this.lastSentState = "";
|
|
265
|
-
this.interpolationTargets = /* @__PURE__ */ new Map();
|
|
266
266
|
this.listeners = /* @__PURE__ */ new Map();
|
|
267
|
+
// Snapshot-based interpolation: store timestamped snapshots per player
|
|
268
|
+
this.snapshots = /* @__PURE__ */ new Map();
|
|
269
|
+
// Jitter buffer: queue incoming updates before applying
|
|
270
|
+
this.jitterQueue = [];
|
|
271
|
+
// Auto-reconnect state
|
|
272
|
+
this.reconnectAttempts = 0;
|
|
273
|
+
this.reconnectTimeout = null;
|
|
274
|
+
this.lastJoinOptions = void 0;
|
|
275
|
+
this.isReconnecting = false;
|
|
276
|
+
// Server time sync and metrics
|
|
277
|
+
this.serverTimeOffset = 0;
|
|
278
|
+
// Local time - server time
|
|
279
|
+
this._playerCount = 1;
|
|
280
|
+
this._latency = 0;
|
|
281
|
+
this.pingStartTime = 0;
|
|
282
|
+
this.pingInterval = null;
|
|
267
283
|
this.state = state;
|
|
268
284
|
this.myId = config.playerId;
|
|
269
285
|
this.config = config;
|
|
270
286
|
this.options = {
|
|
271
287
|
tickRate: options?.tickRate ?? 20,
|
|
272
|
-
interpolate: options?.interpolate ?? true
|
|
288
|
+
interpolate: options?.interpolate ?? true,
|
|
289
|
+
interpolationDelay: options?.interpolationDelay ?? 100,
|
|
290
|
+
jitterBuffer: options?.jitterBuffer ?? 0,
|
|
291
|
+
// 0 = immediate, set to 50+ for smoothing
|
|
292
|
+
autoReconnect: options?.autoReconnect ?? true,
|
|
293
|
+
maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
|
|
273
294
|
};
|
|
274
295
|
}
|
|
275
296
|
/** Current room ID (null if not in a room) */
|
|
@@ -280,6 +301,14 @@ var Sync = class {
|
|
|
280
301
|
get connected() {
|
|
281
302
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
282
303
|
}
|
|
304
|
+
/** Number of players in the current room */
|
|
305
|
+
get playerCount() {
|
|
306
|
+
return this._playerCount;
|
|
307
|
+
}
|
|
308
|
+
/** Current latency to server in milliseconds */
|
|
309
|
+
get latency() {
|
|
310
|
+
return this._latency;
|
|
311
|
+
}
|
|
283
312
|
/**
|
|
284
313
|
* Join a room - your state will sync with everyone in this room
|
|
285
314
|
*
|
|
@@ -287,23 +316,34 @@ var Sync = class {
|
|
|
287
316
|
* @param options - Join options
|
|
288
317
|
*/
|
|
289
318
|
async join(roomId, options) {
|
|
290
|
-
if (this._roomId) {
|
|
319
|
+
if (this._roomId && !this.isReconnecting) {
|
|
291
320
|
await this.leave();
|
|
292
321
|
}
|
|
293
322
|
this._roomId = roomId;
|
|
323
|
+
this.lastJoinOptions = options;
|
|
324
|
+
this.reconnectAttempts = 0;
|
|
294
325
|
await this.connectWebSocket(roomId, options);
|
|
295
326
|
this.startSyncLoop();
|
|
327
|
+
this.startInterpolationLoop();
|
|
296
328
|
}
|
|
297
329
|
/**
|
|
298
330
|
* Leave the current room
|
|
299
331
|
*/
|
|
300
332
|
async leave() {
|
|
333
|
+
if (this.reconnectTimeout) {
|
|
334
|
+
clearTimeout(this.reconnectTimeout);
|
|
335
|
+
this.reconnectTimeout = null;
|
|
336
|
+
}
|
|
337
|
+
this.isReconnecting = false;
|
|
301
338
|
this.stopSyncLoop();
|
|
339
|
+
this.stopInterpolationLoop();
|
|
302
340
|
if (this.ws) {
|
|
303
341
|
this.ws.close();
|
|
304
342
|
this.ws = null;
|
|
305
343
|
}
|
|
306
344
|
this.clearRemotePlayers();
|
|
345
|
+
this.snapshots.clear();
|
|
346
|
+
this.jitterQueue = [];
|
|
307
347
|
this._roomId = null;
|
|
308
348
|
}
|
|
309
349
|
/**
|
|
@@ -397,6 +437,9 @@ var Sync = class {
|
|
|
397
437
|
this.ws.onclose = () => {
|
|
398
438
|
this.stopSyncLoop();
|
|
399
439
|
this.emit("disconnected");
|
|
440
|
+
if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
|
|
441
|
+
this.attemptReconnect();
|
|
442
|
+
}
|
|
400
443
|
};
|
|
401
444
|
this.ws.onmessage = (event) => {
|
|
402
445
|
try {
|
|
@@ -409,23 +452,41 @@ var Sync = class {
|
|
|
409
452
|
});
|
|
410
453
|
}
|
|
411
454
|
handleMessage(data) {
|
|
455
|
+
if (data.serverTime) {
|
|
456
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
457
|
+
}
|
|
412
458
|
switch (data.type) {
|
|
459
|
+
case "welcome":
|
|
460
|
+
if (data.state) {
|
|
461
|
+
this.applyFullState(data.state);
|
|
462
|
+
}
|
|
463
|
+
this._playerCount = data.playerCount || 1;
|
|
464
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
465
|
+
break;
|
|
413
466
|
case "full_state":
|
|
414
467
|
this.applyFullState(data.state);
|
|
415
468
|
break;
|
|
416
469
|
case "state":
|
|
417
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
470
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
418
471
|
break;
|
|
419
472
|
case "join":
|
|
473
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
420
474
|
this.emit("join", data.playerId);
|
|
421
475
|
break;
|
|
422
476
|
case "leave":
|
|
477
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
423
478
|
this.removePlayer(data.playerId);
|
|
424
479
|
this.emit("leave", data.playerId);
|
|
425
480
|
break;
|
|
426
481
|
case "message":
|
|
427
482
|
this.emit("message", data.from, data.data);
|
|
428
483
|
break;
|
|
484
|
+
case "pong":
|
|
485
|
+
if (this.pingStartTime) {
|
|
486
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
487
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
429
490
|
}
|
|
430
491
|
}
|
|
431
492
|
applyFullState(fullState) {
|
|
@@ -435,16 +496,46 @@ var Sync = class {
|
|
|
435
496
|
}
|
|
436
497
|
}
|
|
437
498
|
}
|
|
438
|
-
applyPlayerState(playerId, playerState) {
|
|
499
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
500
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
501
|
+
if (this.options.interpolate && this.options.jitterBuffer > 0) {
|
|
502
|
+
this.jitterQueue.push({
|
|
503
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
504
|
+
playerId,
|
|
505
|
+
state: { ...playerState },
|
|
506
|
+
timestamp
|
|
507
|
+
});
|
|
508
|
+
} else if (this.options.interpolate) {
|
|
509
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
510
|
+
} else {
|
|
511
|
+
this.applyStateDirect(playerId, playerState);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
515
|
+
const isNewPlayer = !this.snapshots.has(playerId);
|
|
516
|
+
if (isNewPlayer) {
|
|
517
|
+
this.snapshots.set(playerId, []);
|
|
518
|
+
}
|
|
519
|
+
const playerSnapshots = this.snapshots.get(playerId);
|
|
520
|
+
playerSnapshots.push({
|
|
521
|
+
time: timestamp || Date.now(),
|
|
522
|
+
state: { ...playerState }
|
|
523
|
+
});
|
|
524
|
+
while (playerSnapshots.length > 10) {
|
|
525
|
+
playerSnapshots.shift();
|
|
526
|
+
}
|
|
527
|
+
const playersKey = this.findPlayersKey();
|
|
528
|
+
if (playersKey) {
|
|
529
|
+
const players = this.state[playersKey];
|
|
530
|
+
if (isNewPlayer || !players[playerId]) {
|
|
531
|
+
players[playerId] = { ...playerState };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
applyStateDirect(playerId, playerState) {
|
|
439
536
|
const playersKey = this.findPlayersKey();
|
|
440
537
|
if (!playersKey) return;
|
|
441
538
|
const players = this.state[playersKey];
|
|
442
|
-
if (this.options.interpolate && players[playerId]) {
|
|
443
|
-
this.interpolationTargets.set(playerId, {
|
|
444
|
-
target: { ...playerState },
|
|
445
|
-
current: { ...players[playerId] }
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
539
|
players[playerId] = playerState;
|
|
449
540
|
}
|
|
450
541
|
removePlayer(playerId) {
|
|
@@ -452,7 +543,28 @@ var Sync = class {
|
|
|
452
543
|
if (!playersKey) return;
|
|
453
544
|
const players = this.state[playersKey];
|
|
454
545
|
delete players[playerId];
|
|
455
|
-
this.
|
|
546
|
+
this.snapshots.delete(playerId);
|
|
547
|
+
}
|
|
548
|
+
attemptReconnect() {
|
|
549
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
550
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.isReconnecting = true;
|
|
554
|
+
this.reconnectAttempts++;
|
|
555
|
+
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
|
|
556
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
|
|
557
|
+
this.reconnectTimeout = setTimeout(async () => {
|
|
558
|
+
try {
|
|
559
|
+
await this.connectWebSocket(this._roomId, this.lastJoinOptions);
|
|
560
|
+
this.startSyncLoop();
|
|
561
|
+
this.isReconnecting = false;
|
|
562
|
+
this.reconnectAttempts = 0;
|
|
563
|
+
this.emit("reconnected");
|
|
564
|
+
} catch (e) {
|
|
565
|
+
this.isReconnecting = false;
|
|
566
|
+
}
|
|
567
|
+
}, delay);
|
|
456
568
|
}
|
|
457
569
|
clearRemotePlayers() {
|
|
458
570
|
const playersKey = this.findPlayersKey();
|
|
@@ -463,7 +575,8 @@ var Sync = class {
|
|
|
463
575
|
delete players[playerId];
|
|
464
576
|
}
|
|
465
577
|
}
|
|
466
|
-
this.
|
|
578
|
+
this.snapshots.clear();
|
|
579
|
+
this.jitterQueue = [];
|
|
467
580
|
}
|
|
468
581
|
findPlayersKey() {
|
|
469
582
|
const candidates = ["players", "entities", "gnomes", "users", "clients"];
|
|
@@ -484,11 +597,43 @@ var Sync = class {
|
|
|
484
597
|
const intervalMs = 1e3 / this.options.tickRate;
|
|
485
598
|
this.syncInterval = setInterval(() => {
|
|
486
599
|
this.syncMyState();
|
|
487
|
-
if (this.options.interpolate) {
|
|
488
|
-
this.updateInterpolation();
|
|
489
|
-
}
|
|
490
600
|
}, intervalMs);
|
|
491
601
|
}
|
|
602
|
+
startInterpolationLoop() {
|
|
603
|
+
if (this.interpolationInterval) return;
|
|
604
|
+
if (!this.options.interpolate) return;
|
|
605
|
+
this.interpolationInterval = setInterval(() => {
|
|
606
|
+
this.processJitterQueue();
|
|
607
|
+
this.updateInterpolation();
|
|
608
|
+
}, 16);
|
|
609
|
+
this.pingInterval = setInterval(() => {
|
|
610
|
+
this.measureLatency();
|
|
611
|
+
}, 2e3);
|
|
612
|
+
}
|
|
613
|
+
measureLatency() {
|
|
614
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
615
|
+
this.pingStartTime = Date.now();
|
|
616
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
stopInterpolationLoop() {
|
|
620
|
+
if (this.interpolationInterval) {
|
|
621
|
+
clearInterval(this.interpolationInterval);
|
|
622
|
+
this.interpolationInterval = null;
|
|
623
|
+
}
|
|
624
|
+
if (this.pingInterval) {
|
|
625
|
+
clearInterval(this.pingInterval);
|
|
626
|
+
this.pingInterval = null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
processJitterQueue() {
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
632
|
+
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
633
|
+
for (const item of ready) {
|
|
634
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
492
637
|
stopSyncLoop() {
|
|
493
638
|
if (this.syncInterval) {
|
|
494
639
|
clearInterval(this.syncInterval);
|
|
@@ -514,16 +659,41 @@ var Sync = class {
|
|
|
514
659
|
const playersKey = this.findPlayersKey();
|
|
515
660
|
if (!playersKey) return;
|
|
516
661
|
const players = this.state[playersKey];
|
|
517
|
-
const
|
|
518
|
-
for (const [playerId,
|
|
662
|
+
const renderTime = Date.now() - this.options.interpolationDelay;
|
|
663
|
+
for (const [playerId, playerSnapshots] of this.snapshots) {
|
|
664
|
+
if (playerId === this.myId) continue;
|
|
519
665
|
const player = players[playerId];
|
|
520
666
|
if (!player) continue;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
667
|
+
let before = null;
|
|
668
|
+
let after = null;
|
|
669
|
+
for (const snapshot of playerSnapshots) {
|
|
670
|
+
if (snapshot.time <= renderTime) {
|
|
671
|
+
before = snapshot;
|
|
672
|
+
} else if (!after) {
|
|
673
|
+
after = snapshot;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (before && after) {
|
|
677
|
+
const total = after.time - before.time;
|
|
678
|
+
const elapsed = renderTime - before.time;
|
|
679
|
+
const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
|
|
680
|
+
this.lerpState(player, before.state, after.state, alpha);
|
|
681
|
+
} else if (before) {
|
|
682
|
+
this.lerpState(player, player, before.state, 0.3);
|
|
683
|
+
} else if (after) {
|
|
684
|
+
this.lerpState(player, player, after.state, 0.3);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
lerpState(target, from, to, alpha) {
|
|
689
|
+
for (const key of Object.keys(to)) {
|
|
690
|
+
const fromVal = from[key];
|
|
691
|
+
const toVal = to[key];
|
|
692
|
+
if (typeof fromVal === "number" && typeof toVal === "number") {
|
|
693
|
+
target[key] = fromVal + (toVal - fromVal) * alpha;
|
|
694
|
+
} else {
|
|
695
|
+
if (alpha > 0.5) {
|
|
696
|
+
target[key] = toVal;
|
|
527
697
|
}
|
|
528
698
|
}
|
|
529
699
|
}
|