@watchtower-sdk/core 0.2.0 → 0.2.2
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 +137 -245
- package/dist/index.d.mts +148 -1
- package/dist/index.d.ts +148 -1
- package/dist/index.js +327 -0
- package/dist/index.mjs +326 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -256,6 +256,298 @@ var Room = class {
|
|
|
256
256
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
257
257
|
}
|
|
258
258
|
};
|
|
259
|
+
var Sync = class {
|
|
260
|
+
constructor(state, config, options) {
|
|
261
|
+
this._roomId = null;
|
|
262
|
+
this.ws = null;
|
|
263
|
+
this.syncInterval = null;
|
|
264
|
+
this.lastSentState = "";
|
|
265
|
+
this.interpolationTargets = /* @__PURE__ */ new Map();
|
|
266
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
267
|
+
this.state = state;
|
|
268
|
+
this.myId = config.playerId;
|
|
269
|
+
this.config = config;
|
|
270
|
+
this.options = {
|
|
271
|
+
tickRate: options?.tickRate ?? 20,
|
|
272
|
+
interpolate: options?.interpolate ?? true
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Current room ID (null if not in a room) */
|
|
276
|
+
get roomId() {
|
|
277
|
+
return this._roomId;
|
|
278
|
+
}
|
|
279
|
+
/** Whether currently connected to a room */
|
|
280
|
+
get connected() {
|
|
281
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Join a room - your state will sync with everyone in this room
|
|
285
|
+
*
|
|
286
|
+
* @param roomId - Room identifier (any string)
|
|
287
|
+
* @param options - Join options
|
|
288
|
+
*/
|
|
289
|
+
async join(roomId, options) {
|
|
290
|
+
if (this._roomId) {
|
|
291
|
+
await this.leave();
|
|
292
|
+
}
|
|
293
|
+
this._roomId = roomId;
|
|
294
|
+
await this.connectWebSocket(roomId, options);
|
|
295
|
+
this.startSyncLoop();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Leave the current room
|
|
299
|
+
*/
|
|
300
|
+
async leave() {
|
|
301
|
+
this.stopSyncLoop();
|
|
302
|
+
if (this.ws) {
|
|
303
|
+
this.ws.close();
|
|
304
|
+
this.ws = null;
|
|
305
|
+
}
|
|
306
|
+
this.clearRemotePlayers();
|
|
307
|
+
this._roomId = null;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Send a one-off message to all players in the room
|
|
311
|
+
*
|
|
312
|
+
* @param data - Any JSON-serializable data
|
|
313
|
+
*/
|
|
314
|
+
broadcast(data) {
|
|
315
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
316
|
+
this.ws.send(JSON.stringify({ type: "broadcast", data }));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Create a new room and join it
|
|
321
|
+
*
|
|
322
|
+
* @param options - Room creation options
|
|
323
|
+
* @returns The room code/ID
|
|
324
|
+
*/
|
|
325
|
+
async create(options) {
|
|
326
|
+
const code = this.generateRoomCode();
|
|
327
|
+
await this.join(code, { ...options, create: true });
|
|
328
|
+
return code;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* List public rooms
|
|
332
|
+
*/
|
|
333
|
+
async listRooms() {
|
|
334
|
+
const response = await fetch(`${this.config.apiUrl}/v1/sync/rooms?gameId=${this.config.gameId}`, {
|
|
335
|
+
headers: this.getHeaders()
|
|
336
|
+
});
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
const data2 = await response.json();
|
|
339
|
+
throw new Error(data2.error || "Failed to list rooms");
|
|
340
|
+
}
|
|
341
|
+
const data = await response.json();
|
|
342
|
+
return data.rooms || [];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Subscribe to sync events
|
|
346
|
+
*/
|
|
347
|
+
on(event, callback) {
|
|
348
|
+
if (!this.listeners.has(event)) {
|
|
349
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
350
|
+
}
|
|
351
|
+
this.listeners.get(event).add(callback);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Unsubscribe from sync events
|
|
355
|
+
*/
|
|
356
|
+
off(event, callback) {
|
|
357
|
+
this.listeners.get(event)?.delete(callback);
|
|
358
|
+
}
|
|
359
|
+
emit(event, ...args) {
|
|
360
|
+
this.listeners.get(event)?.forEach((cb) => {
|
|
361
|
+
try {
|
|
362
|
+
cb(...args);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
console.error(`Error in sync ${event} handler:`, e);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async connectWebSocket(roomId, options) {
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
const wsUrl = this.config.apiUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
371
|
+
const params = new URLSearchParams({
|
|
372
|
+
playerId: this.config.playerId,
|
|
373
|
+
gameId: this.config.gameId,
|
|
374
|
+
...this.config.apiKey ? { apiKey: this.config.apiKey } : {},
|
|
375
|
+
...options?.create ? { create: "true" } : {},
|
|
376
|
+
...options?.maxPlayers ? { maxPlayers: String(options.maxPlayers) } : {},
|
|
377
|
+
...options?.public ? { public: "true" } : {},
|
|
378
|
+
...options?.metadata ? { metadata: JSON.stringify(options.metadata) } : {}
|
|
379
|
+
});
|
|
380
|
+
const url = `${wsUrl}/v1/sync/${roomId}/ws?${params}`;
|
|
381
|
+
this.ws = new WebSocket(url);
|
|
382
|
+
const timeout = setTimeout(() => {
|
|
383
|
+
reject(new Error("Connection timeout"));
|
|
384
|
+
this.ws?.close();
|
|
385
|
+
}, 1e4);
|
|
386
|
+
this.ws.onopen = () => {
|
|
387
|
+
clearTimeout(timeout);
|
|
388
|
+
this.emit("connected");
|
|
389
|
+
resolve();
|
|
390
|
+
};
|
|
391
|
+
this.ws.onerror = () => {
|
|
392
|
+
clearTimeout(timeout);
|
|
393
|
+
const error = new Error("WebSocket connection failed");
|
|
394
|
+
this.emit("error", error);
|
|
395
|
+
reject(error);
|
|
396
|
+
};
|
|
397
|
+
this.ws.onclose = () => {
|
|
398
|
+
this.stopSyncLoop();
|
|
399
|
+
this.emit("disconnected");
|
|
400
|
+
};
|
|
401
|
+
this.ws.onmessage = (event) => {
|
|
402
|
+
try {
|
|
403
|
+
const data = JSON.parse(event.data);
|
|
404
|
+
this.handleMessage(data);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
console.error("Failed to parse sync message:", e);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
handleMessage(data) {
|
|
412
|
+
switch (data.type) {
|
|
413
|
+
case "full_state":
|
|
414
|
+
this.applyFullState(data.state);
|
|
415
|
+
break;
|
|
416
|
+
case "state":
|
|
417
|
+
this.applyPlayerState(data.playerId, data.data);
|
|
418
|
+
break;
|
|
419
|
+
case "join":
|
|
420
|
+
this.emit("join", data.playerId);
|
|
421
|
+
break;
|
|
422
|
+
case "leave":
|
|
423
|
+
this.removePlayer(data.playerId);
|
|
424
|
+
this.emit("leave", data.playerId);
|
|
425
|
+
break;
|
|
426
|
+
case "message":
|
|
427
|
+
this.emit("message", data.from, data.data);
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
applyFullState(fullState) {
|
|
432
|
+
for (const [playerId, playerState] of Object.entries(fullState)) {
|
|
433
|
+
if (playerId !== this.myId) {
|
|
434
|
+
this.applyPlayerState(playerId, playerState);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
applyPlayerState(playerId, playerState) {
|
|
439
|
+
const playersKey = this.findPlayersKey();
|
|
440
|
+
if (!playersKey) return;
|
|
441
|
+
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
|
+
players[playerId] = playerState;
|
|
449
|
+
}
|
|
450
|
+
removePlayer(playerId) {
|
|
451
|
+
const playersKey = this.findPlayersKey();
|
|
452
|
+
if (!playersKey) return;
|
|
453
|
+
const players = this.state[playersKey];
|
|
454
|
+
delete players[playerId];
|
|
455
|
+
this.interpolationTargets.delete(playerId);
|
|
456
|
+
}
|
|
457
|
+
clearRemotePlayers() {
|
|
458
|
+
const playersKey = this.findPlayersKey();
|
|
459
|
+
if (!playersKey) return;
|
|
460
|
+
const players = this.state[playersKey];
|
|
461
|
+
for (const playerId of Object.keys(players)) {
|
|
462
|
+
if (playerId !== this.myId) {
|
|
463
|
+
delete players[playerId];
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
this.interpolationTargets.clear();
|
|
467
|
+
}
|
|
468
|
+
findPlayersKey() {
|
|
469
|
+
const candidates = ["players", "entities", "gnomes", "users", "clients"];
|
|
470
|
+
for (const key of candidates) {
|
|
471
|
+
if (key in this.state && typeof this.state[key] === "object") {
|
|
472
|
+
return key;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const key of Object.keys(this.state)) {
|
|
476
|
+
if (typeof this.state[key] === "object" && this.state[key] !== null) {
|
|
477
|
+
return key;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
startSyncLoop() {
|
|
483
|
+
if (this.syncInterval) return;
|
|
484
|
+
const intervalMs = 1e3 / this.options.tickRate;
|
|
485
|
+
this.syncInterval = setInterval(() => {
|
|
486
|
+
this.syncMyState();
|
|
487
|
+
if (this.options.interpolate) {
|
|
488
|
+
this.updateInterpolation();
|
|
489
|
+
}
|
|
490
|
+
}, intervalMs);
|
|
491
|
+
}
|
|
492
|
+
stopSyncLoop() {
|
|
493
|
+
if (this.syncInterval) {
|
|
494
|
+
clearInterval(this.syncInterval);
|
|
495
|
+
this.syncInterval = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
syncMyState() {
|
|
499
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
500
|
+
const playersKey = this.findPlayersKey();
|
|
501
|
+
if (!playersKey) return;
|
|
502
|
+
const players = this.state[playersKey];
|
|
503
|
+
const myState = players[this.myId];
|
|
504
|
+
if (!myState) return;
|
|
505
|
+
const stateJson = JSON.stringify(myState);
|
|
506
|
+
if (stateJson === this.lastSentState) return;
|
|
507
|
+
this.lastSentState = stateJson;
|
|
508
|
+
this.ws.send(JSON.stringify({
|
|
509
|
+
type: "state",
|
|
510
|
+
data: myState
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
updateInterpolation() {
|
|
514
|
+
const playersKey = this.findPlayersKey();
|
|
515
|
+
if (!playersKey) return;
|
|
516
|
+
const players = this.state[playersKey];
|
|
517
|
+
const lerpFactor = 0.2;
|
|
518
|
+
for (const [playerId, interp] of this.interpolationTargets) {
|
|
519
|
+
const player = players[playerId];
|
|
520
|
+
if (!player) continue;
|
|
521
|
+
for (const [key, targetValue] of Object.entries(interp.target)) {
|
|
522
|
+
if (typeof targetValue === "number" && typeof interp.current[key] === "number") {
|
|
523
|
+
const current = interp.current[key];
|
|
524
|
+
const newValue = current + (targetValue - current) * lerpFactor;
|
|
525
|
+
interp.current[key] = newValue;
|
|
526
|
+
player[key] = newValue;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
generateRoomCode() {
|
|
532
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
533
|
+
let code = "";
|
|
534
|
+
for (let i = 0; i < 6; i++) {
|
|
535
|
+
code += chars[Math.floor(Math.random() * chars.length)];
|
|
536
|
+
}
|
|
537
|
+
return code;
|
|
538
|
+
}
|
|
539
|
+
getHeaders() {
|
|
540
|
+
const headers = {
|
|
541
|
+
"Content-Type": "application/json",
|
|
542
|
+
"X-Player-ID": this.config.playerId,
|
|
543
|
+
"X-Game-ID": this.config.gameId
|
|
544
|
+
};
|
|
545
|
+
if (this.config.apiKey) {
|
|
546
|
+
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
547
|
+
}
|
|
548
|
+
return headers;
|
|
549
|
+
}
|
|
550
|
+
};
|
|
259
551
|
var Watchtower = class {
|
|
260
552
|
constructor(config) {
|
|
261
553
|
Object.defineProperty(this, "config", {
|
|
@@ -432,10 +724,44 @@ var Watchtower = class {
|
|
|
432
724
|
get stats() {
|
|
433
725
|
return this.getStats();
|
|
434
726
|
}
|
|
727
|
+
// ============ SYNC API ============
|
|
728
|
+
/**
|
|
729
|
+
* Create a synchronized state object
|
|
730
|
+
*
|
|
731
|
+
* Point this at your game state and it becomes multiplayer.
|
|
732
|
+
* No events, no callbacks - just read and write your state.
|
|
733
|
+
*
|
|
734
|
+
* @param state - Your game state object (e.g., { players: {} })
|
|
735
|
+
* @param options - Sync options (tickRate, interpolation)
|
|
736
|
+
* @returns A Sync instance
|
|
737
|
+
*
|
|
738
|
+
* @example
|
|
739
|
+
* ```ts
|
|
740
|
+
* const state = { players: {} }
|
|
741
|
+
* const sync = wt.sync(state)
|
|
742
|
+
*
|
|
743
|
+
* await sync.join('my-room')
|
|
744
|
+
*
|
|
745
|
+
* // Add yourself
|
|
746
|
+
* state.players[sync.myId] = { x: 0, y: 0 }
|
|
747
|
+
*
|
|
748
|
+
* // Move (automatically syncs to others)
|
|
749
|
+
* state.players[sync.myId].x = 100
|
|
750
|
+
*
|
|
751
|
+
* // Others appear automatically in state.players!
|
|
752
|
+
* for (const [id, player] of Object.entries(state.players)) {
|
|
753
|
+
* draw(player.x, player.y)
|
|
754
|
+
* }
|
|
755
|
+
* ```
|
|
756
|
+
*/
|
|
757
|
+
sync(state, options) {
|
|
758
|
+
return new Sync(state, this.config, options);
|
|
759
|
+
}
|
|
435
760
|
};
|
|
436
761
|
var index_default = Watchtower;
|
|
437
762
|
export {
|
|
438
763
|
Room,
|
|
764
|
+
Sync,
|
|
439
765
|
Watchtower,
|
|
440
766
|
index_default as default
|
|
441
767
|
};
|