@unboxy/phaser-sdk 0.2.11 → 0.2.13
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/SDK-GUIDE.md +122 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/realtime/RealtimeModule.d.ts +43 -0
- package/dist/realtime/RealtimeModule.js +61 -0
- package/dist/realtime/UnboxyRoom.d.ts +89 -0
- package/dist/realtime/UnboxyRoom.js +171 -0
- package/package.json +2 -1
package/SDK-GUIDE.md
CHANGED
|
@@ -283,6 +283,61 @@ const room = await unboxy.rooms.joinOrCreate('lobby', {
|
|
|
283
283
|
});
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
+
#### Browsing open rooms — `unboxy.rooms.list()`
|
|
287
|
+
|
|
288
|
+
A room code is enough for "my friend and I agreed on code ABC123 out of band", but not for "show me a list of open games and let me click one to join." For that, ask the server what rooms are currently open for your game:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
interface RoomListEntry {
|
|
292
|
+
roomId: string; // opaque — pass to joinById
|
|
293
|
+
roomCode: string; // the code the host passed (empty for default rooms)
|
|
294
|
+
clients: number; // current occupants
|
|
295
|
+
maxClients: number; // cap the host set (server-clamped to [1, 16])
|
|
296
|
+
metadata?: unknown; // whatever the host set via room.setMetadata (optional)
|
|
297
|
+
createdAt?: number; // ms since epoch
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rooms = await unboxy.rooms.list();
|
|
301
|
+
rooms
|
|
302
|
+
.filter(r => r.clients < r.maxClients) // hide full rooms
|
|
303
|
+
.forEach(renderLobbyCard);
|
|
304
|
+
|
|
305
|
+
// On click:
|
|
306
|
+
async function joinRoom(entry: RoomListEntry) {
|
|
307
|
+
const room = await unboxy.rooms.joinById(entry.roomId, {
|
|
308
|
+
displayName: unboxy.user?.name ?? 'guest',
|
|
309
|
+
});
|
|
310
|
+
// room state is populated after the first onStateChange — see
|
|
311
|
+
// "Connection lifecycle" below for the state-timing rule.
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Scope.** `list()` only returns rooms for the caller's game. A player can't see rooms from other games (the server cross-checks the gameId claim on the token).
|
|
316
|
+
|
|
317
|
+
**Filter.** Pass `{ roomCode: 'ABC' }` to narrow to one code without paging through the whole list — useful for "is the room with code ABC up yet?" polling.
|
|
318
|
+
|
|
319
|
+
**Polling, not subscription.** There is no push-on-change API today. Poll on a timer (e.g. every 3-5 s) while the lobby is open, and refresh on the user's explicit click. Cancel the timer when the player leaves the lobby.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// Simple lobby browser refresh loop
|
|
323
|
+
let stop = false;
|
|
324
|
+
async function refreshLoop() {
|
|
325
|
+
while (!stop) {
|
|
326
|
+
try {
|
|
327
|
+
const rooms = await unboxy.rooms.list();
|
|
328
|
+
renderLobby(rooms);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// Network hiccup or REALTIME_UNAVAILABLE — surface in UI, keep looping.
|
|
331
|
+
}
|
|
332
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
refreshLoop();
|
|
336
|
+
scene.events.once('shutdown', () => { stop = true; });
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Unlisted rooms.** Every room created via `joinOrCreate` / `create` is currently listable by players of the same game. There is no "private room" flag yet — if you need that, tell your users to share the code out of band and don't render a lobby browser.
|
|
340
|
+
|
|
286
341
|
#### Delta-synced state — `room.player` and `room.data`
|
|
287
342
|
|
|
288
343
|
Use this for anything that represents the *current world*: positions, hp, score, turn state, scoreboard, etc. The server delta-syncs diffs to every client.
|
|
@@ -314,7 +369,7 @@ Values are JSON-stringified under the hood. Use for anything JSON-serializable.
|
|
|
314
369
|
|
|
315
370
|
#### Transient events — `room.send` and `room.on`
|
|
316
371
|
|
|
317
|
-
Use this for ephemeral actions: fires, hits, emotes, explosions
|
|
372
|
+
Use this for ephemeral actions: fires, hits, emotes, explosions. Not persisted. (For **chat**, prefer the dedicated `room.chat` helper below — it adds local echo, length capping, and a stamped sender name on top of this same primitive.)
|
|
318
373
|
|
|
319
374
|
```ts
|
|
320
375
|
// Sender
|
|
@@ -326,6 +381,68 @@ const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angl
|
|
|
326
381
|
});
|
|
327
382
|
```
|
|
328
383
|
|
|
384
|
+
#### Chat — `room.chat.send` and `room.chat.onMessage`
|
|
385
|
+
|
|
386
|
+
A thin wrapper over the transient relay tuned for in-game chat. Use this instead of raw `room.send('chat', text)` — you get four things you'd otherwise re-implement in every game:
|
|
387
|
+
|
|
388
|
+
1. **Local echo** — the sender's own message hits the same `onMessage` handler stream the moment it's sent, so your UI has one render path for both your messages and remote ones.
|
|
389
|
+
2. **Stamped sender info** — every `ChatMessage` carries `kind`, `from` (sessionId), `displayName` (sender's human-readable name at send time), `text`, and `ts`. No need to look up the sender in `room.state.players`.
|
|
390
|
+
3. **Trim + length cap** — text is `.trim()`ed, dropped if empty, and silently truncated to `MAX_CHAT_TEXT_LEN` (500) so a stray paste can't flood the relay.
|
|
391
|
+
4. **Auto-emitted system messages** — when a remote player joins or leaves, the SDK dispatches a `kind: 'system.joined'` / `'system.left'` message into the same stream. No wire traffic; computed locally on each client from `room.state.players`. Skipped for the local player's own join.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import { MAX_CHAT_TEXT_LEN } from '@unboxy/phaser-sdk';
|
|
395
|
+
import type { ChatMessage } from '@unboxy/phaser-sdk';
|
|
396
|
+
|
|
397
|
+
const log: ChatMessage[] = [];
|
|
398
|
+
|
|
399
|
+
const offChat = room.chat.onMessage((msg) => {
|
|
400
|
+
log.push(msg);
|
|
401
|
+
if (msg.kind === 'user') {
|
|
402
|
+
const who = msg.from === room.sessionId ? 'You' : (msg.displayName || 'Player');
|
|
403
|
+
appendChatLine(`${formatTime(msg.ts)} ${who}: ${msg.text}`);
|
|
404
|
+
} else {
|
|
405
|
+
// 'system.joined' or 'system.left' — render however you like.
|
|
406
|
+
// msg.text is already a sensible English fallback ("Alice joined").
|
|
407
|
+
appendSystemLine(`${formatTime(msg.ts)} — ${msg.text}`);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
inputEl.maxLength = MAX_CHAT_TEXT_LEN; // mirror the cap on the input box
|
|
412
|
+
inputEl.addEventListener('keydown', async (e) => {
|
|
413
|
+
if (e.key !== 'Enter') return;
|
|
414
|
+
try {
|
|
415
|
+
await room.chat.send(inputEl.value); // trim + cap + local echo + wire send
|
|
416
|
+
inputEl.value = ''; // safe: echo already dispatched synchronously
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// Local-side dispatch failed (e.g. connection dropped). Show a UI hint.
|
|
419
|
+
showChatError(err);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
scene.events.once('shutdown', offChat);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
`ChatMessage` discriminates by `kind`:
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
|
|
430
|
+
interface ChatMessage {
|
|
431
|
+
kind: ChatMessageKind;
|
|
432
|
+
from: string; // sessionId of the sender (or join/leave subject)
|
|
433
|
+
displayName: string; // their displayName at the time
|
|
434
|
+
text: string; // user-typed text, or English system fallback
|
|
435
|
+
ts: number; // Date.now() when emitted
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Notes:
|
|
440
|
+
- **No history.** A player who joins mid-room sees no prior `'user'` messages. If you need scrollback, write to `room.data.set('chatLog', [...])` yourself — the relay is fire-and-forget.
|
|
441
|
+
- **System messages are local-only.** Each client computes them from `room.state.players` add/remove events. They are NOT broadcast over the wire, and never echo from the wire even if a different client tries to send one.
|
|
442
|
+
- **`send` returns `Promise<void>`.** Resolves once dispatch is complete (echo + wire queued); rejects if the underlying connection blew up. It is **not** an ack — the relay itself is fire-and-forget; the Promise only catches local-side errors.
|
|
443
|
+
- **Order by arrival.** `ts` is `Date.now()`, advisory only. Skew between clients is small in practice but don't sort by `ts` across senders.
|
|
444
|
+
- **Trust model.** `text` and `displayName` come from the sender — no server-side validation. Same as every other transient relay payload.
|
|
445
|
+
|
|
329
446
|
#### Server-tracked membership
|
|
330
447
|
|
|
331
448
|
`room.state.players` is server-owned. Each entry exposes:
|
|
@@ -356,7 +473,7 @@ await room.leave();
|
|
|
356
473
|
| Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
|
|
357
474
|
| Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
|
|
358
475
|
| Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
|
|
359
|
-
| Chat message | `room.send(
|
|
476
|
+
| Chat message | `room.chat.send(text)` + `room.chat.onMessage(...)` | Helper over the relay: local echo, stamped sender, length cap. |
|
|
360
477
|
| Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
|
|
361
478
|
|
|
362
479
|
Rules of thumb:
|
|
@@ -427,6 +544,9 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
|
|
|
427
544
|
|
|
428
545
|
## Changelog
|
|
429
546
|
|
|
547
|
+
- **0.2.13** — added `room.chat` helper for in-game chat. New API: `room.chat.send(text)` (returns `Promise<void>`) + `room.chat.onMessage(handler)`, plus exports `ChatMessage`, `ChatMessageKind`, and `MAX_CHAT_TEXT_LEN`. Wraps the existing `'chat'` wildcard relay with local echo (sender sees own message synchronously), stamped `displayName` on every payload, trim + 500-char silent truncate, and auto-emitted `kind: 'system.joined'` / `'system.left'` messages computed locally from `room.state.players` add/remove events (no wire traffic). Replaces the raw `room.send('chat', text)` pattern in the Multiplayer chapter — raw still works but loses local echo, length cap, and join/leave events.
|
|
548
|
+
- **0.2.12** — added `unboxy.rooms.list(options?)` — returns the open rooms for the caller's game right now, scoped server-side to the caller's gameId. Use this to build a lobby browser ("show me open rooms, click to join") without needing an out-of-band room code. SDK-GUIDE adds a "Browsing open rooms" section under Multiplayer covering the `RoomListEntry` shape, the poll-on-timer pattern, and the `{ roomCode }` filter for "is room X up yet?" probes.
|
|
549
|
+
- **0.2.11** — added `roomCode` + `maxClients` to `JoinOptions`. SDK-GUIDE's Multiplayer chapter now documents matchmaking: room type is always `'lobby'`; use `roomCode` to bucket independent rooms per gameId (create-with-fresh-code, join-with-shared-code, quick-match with empty code). Fixes a real bug where agents were passing a gameId-derived string as the room type (e.g. `joinOrCreate('blokus-<code>', ...)`) and failing with "no handler" server-side.
|
|
430
550
|
- **0.2.10** — docs: added a `createUnboxyGame` options table (including the new `plugins` field) and a third-party Phaser plugin registration example. No code changes; version bumped so existing workspaces pick up the new guidance via `npm update @unboxy/phaser-sdk`.
|
|
431
551
|
- **0.2.9** — `createUnboxyGame` now forwards the optional `plugins` field to Phaser's `GameConfig`. Previously the option was accepted at the call site but silently dropped — any `phaser3-rex-plugins` (or similar) registration never took effect, and `this.plugins.get(key)` returned undefined at runtime. Purely additive; no effect on games that don't pass `plugins`. Lets the `phaser-rex-touch` agent skill work end-to-end.
|
|
432
552
|
- **0.2.8** — docs: added "Making remote motion feel smooth" section to the multiplayer chapter with concrete examples for interpolation (continuous motion), extrapolation (projectiles), and snap (discrete/turn-based/infrequent). No code changes — version bumped so existing workspaces pick up the new guidance via `npm update @unboxy/phaser-sdk`.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,9 @@ export type { UnboxyInitOptions } from './core/Unboxy.js';
|
|
|
8
8
|
export { SavesModule } from './saves/SavesModule.js';
|
|
9
9
|
export { GameDataModule } from './gamedata/GameDataModule.js';
|
|
10
10
|
export { RealtimeModule } from './realtime/RealtimeModule.js';
|
|
11
|
-
export type { JoinOptions } from './realtime/RealtimeModule.js';
|
|
12
|
-
export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
|
|
11
|
+
export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/RealtimeModule.js';
|
|
12
|
+
export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
|
|
13
|
+
export type { ChatMessage, ChatMessageKind } from './realtime/UnboxyRoom.js';
|
|
13
14
|
export { RpcError } from './core/Transport.js';
|
|
14
15
|
export type { Transport, TransportKind } from './core/Transport.js';
|
|
15
16
|
export type { UnboxyUser } from './protocol.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,6 @@ export { Unboxy } from './core/Unboxy.js';
|
|
|
10
10
|
export { SavesModule } from './saves/SavesModule.js';
|
|
11
11
|
export { GameDataModule } from './gamedata/GameDataModule.js';
|
|
12
12
|
export { RealtimeModule } from './realtime/RealtimeModule.js';
|
|
13
|
-
export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
|
|
13
|
+
export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
|
|
14
14
|
export { RpcError } from './core/Transport.js';
|
|
15
15
|
export { PROTOCOL_VERSION, } from './protocol.js';
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import type { Transport } from '../core/Transport.js';
|
|
2
2
|
import { UnboxyRoom } from './UnboxyRoom.js';
|
|
3
|
+
export interface RoomListEntry {
|
|
4
|
+
/** Opaque Colyseus room id — pass to `joinById` to connect. */
|
|
5
|
+
roomId: string;
|
|
6
|
+
/** Short code the host passed on create (empty string for default rooms). */
|
|
7
|
+
roomCode: string;
|
|
8
|
+
/** Current number of connected clients. */
|
|
9
|
+
clients: number;
|
|
10
|
+
/** Max clients the host set (server-clamped to [1, 16]). */
|
|
11
|
+
maxClients: number;
|
|
12
|
+
/** Whatever the host attached via `room.setMetadata(...)`. Unvalidated. */
|
|
13
|
+
metadata?: unknown;
|
|
14
|
+
/** Server-side creation time (ms since epoch). */
|
|
15
|
+
createdAt?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface RoomListOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Filter to rooms with this exact roomCode. Useful for "is a room with code
|
|
20
|
+
* X up yet?" polling without enumerating the whole list.
|
|
21
|
+
*/
|
|
22
|
+
roomCode?: string;
|
|
23
|
+
/** Max entries to return. Server caps to 100 regardless. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
|
3
26
|
export interface JoinOptions extends Record<string, unknown> {
|
|
4
27
|
/** Optional display name passed to the server-side room on join. */
|
|
5
28
|
displayName?: string;
|
|
@@ -46,5 +69,25 @@ export declare class RealtimeModule {
|
|
|
46
69
|
create<State = unknown>(roomType: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
47
70
|
join<State = unknown>(roomType: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
48
71
|
joinById<State = unknown>(roomId: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
72
|
+
/**
|
|
73
|
+
* List the open rooms for the caller's game, as seen by the realtime
|
|
74
|
+
* service right now. Results are scoped server-side to the caller's
|
|
75
|
+
* gameId — you can't enumerate rooms from a different game.
|
|
76
|
+
*
|
|
77
|
+
* Use this to build a lobby browser ("show me open rooms, click to join").
|
|
78
|
+
* Poll on a timer or on a user's "Refresh" click; the API is request/
|
|
79
|
+
* response, not a live subscription.
|
|
80
|
+
*
|
|
81
|
+
* Example:
|
|
82
|
+
* ```ts
|
|
83
|
+
* const rooms = await unboxy.rooms.list();
|
|
84
|
+
* rooms
|
|
85
|
+
* .filter(r => r.clients < r.maxClients)
|
|
86
|
+
* .forEach(r => {
|
|
87
|
+
* // render a button; on click: unboxy.rooms.joinById(r.roomId)
|
|
88
|
+
* });
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
list(options?: RoomListOptions): Promise<RoomListEntry[]>;
|
|
49
92
|
private connect;
|
|
50
93
|
}
|
|
@@ -33,6 +33,56 @@ export class RealtimeModule {
|
|
|
33
33
|
async joinById(roomId, options = {}) {
|
|
34
34
|
return this.connect(roomId, options, 'joinById');
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* List the open rooms for the caller's game, as seen by the realtime
|
|
38
|
+
* service right now. Results are scoped server-side to the caller's
|
|
39
|
+
* gameId — you can't enumerate rooms from a different game.
|
|
40
|
+
*
|
|
41
|
+
* Use this to build a lobby browser ("show me open rooms, click to join").
|
|
42
|
+
* Poll on a timer or on a user's "Refresh" click; the API is request/
|
|
43
|
+
* response, not a live subscription.
|
|
44
|
+
*
|
|
45
|
+
* Example:
|
|
46
|
+
* ```ts
|
|
47
|
+
* const rooms = await unboxy.rooms.list();
|
|
48
|
+
* rooms
|
|
49
|
+
* .filter(r => r.clients < r.maxClients)
|
|
50
|
+
* .forEach(r => {
|
|
51
|
+
* // render a button; on click: unboxy.rooms.joinById(r.roomId)
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
async list(options = {}) {
|
|
56
|
+
if (!this.available) {
|
|
57
|
+
throw new RpcError('REALTIME_UNAVAILABLE', 'Multiplayer is not available for this host. Unboxy.rooms.list requires running inside unboxy-home-ui with realtime enabled.');
|
|
58
|
+
}
|
|
59
|
+
const { token } = await this.transport.call('realtime.getToken', {});
|
|
60
|
+
const url = new URL(toHttpBase(this.realtimeUrl) + '/rooms');
|
|
61
|
+
url.searchParams.set('gameId', this.transport.gameId);
|
|
62
|
+
if (typeof options.roomCode === 'string')
|
|
63
|
+
url.searchParams.set('roomCode', options.roomCode);
|
|
64
|
+
if (typeof options.limit === 'number' && Number.isFinite(options.limit)) {
|
|
65
|
+
url.searchParams.set('limit', String(Math.floor(options.limit)));
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(url.toString(), {
|
|
68
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
let detail = '';
|
|
72
|
+
try {
|
|
73
|
+
const body = await res.json();
|
|
74
|
+
if (body && typeof body.error === 'string')
|
|
75
|
+
detail = `: ${body.error}`;
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
throw new RpcError('ROOMS_LIST_FAILED', `/rooms returned ${res.status}${detail}`);
|
|
79
|
+
}
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
if (!body || !Array.isArray(body.rooms)) {
|
|
82
|
+
throw new RpcError('ROOMS_LIST_FAILED', '/rooms response missing rooms[]');
|
|
83
|
+
}
|
|
84
|
+
return body.rooms;
|
|
85
|
+
}
|
|
36
86
|
async connect(nameOrId, options, method) {
|
|
37
87
|
if (!this.available) {
|
|
38
88
|
throw new RpcError('REALTIME_UNAVAILABLE', 'Multiplayer is not available for this host. Unboxy.rooms requires running inside unboxy-home-ui with realtime enabled.');
|
|
@@ -52,3 +102,14 @@ export class RealtimeModule {
|
|
|
52
102
|
return new UnboxyRoom(room);
|
|
53
103
|
}
|
|
54
104
|
}
|
|
105
|
+
// Convert the realtime WebSocket base (wss://ws.unboxy.com/rt or ws://…) to
|
|
106
|
+
// the matching HTTP base used for the /rooms REST endpoint. Colyseus's HTTP
|
|
107
|
+
// routes (/matchmake, /rooms, /health) ride the same nginx vhost + path
|
|
108
|
+
// prefix as the WebSocket, so this is just a scheme swap.
|
|
109
|
+
function toHttpBase(wsUrl) {
|
|
110
|
+
if (wsUrl.startsWith('wss://'))
|
|
111
|
+
return 'https://' + wsUrl.slice('wss://'.length);
|
|
112
|
+
if (wsUrl.startsWith('ws://'))
|
|
113
|
+
return 'http://' + wsUrl.slice('ws://'.length);
|
|
114
|
+
return wsUrl;
|
|
115
|
+
}
|
|
@@ -29,6 +29,94 @@ export declare class RoomDataFacade {
|
|
|
29
29
|
/** Read a shared room value. Returns null if the key is absent. */
|
|
30
30
|
get<T = unknown>(key: string): T | null;
|
|
31
31
|
}
|
|
32
|
+
/** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
|
|
33
|
+
* truncated. Games should mirror this on their input box's `maxLength`. */
|
|
34
|
+
export declare const MAX_CHAT_TEXT_LEN = 500;
|
|
35
|
+
/**
|
|
36
|
+
* Discriminator for `ChatMessage`:
|
|
37
|
+
* - `'user'` — a real player message sent via `ChatFacade.send`.
|
|
38
|
+
* - `'system.joined'` — auto-emitted locally when a remote player enters the room.
|
|
39
|
+
* - `'system.left'` — auto-emitted locally when a player leaves the room.
|
|
40
|
+
*
|
|
41
|
+
* System messages are computed locally on each client from `room.state.players`
|
|
42
|
+
* add/remove events; they never go over the wire. The first joiner does not
|
|
43
|
+
* see "X joined" for players already in the room when they themselves arrived
|
|
44
|
+
* (initial hydration is suppressed).
|
|
45
|
+
*/
|
|
46
|
+
export type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
|
|
47
|
+
/** Canonical chat-message shape delivered to `ChatFacade.onMessage` handlers.
|
|
48
|
+
* See unboxy-design/features/multiplayer-chat.md §3 for the rationale on
|
|
49
|
+
* each field. */
|
|
50
|
+
export interface ChatMessage {
|
|
51
|
+
/** What kind of message this is — see `ChatMessageKind`. Default `'user'`. */
|
|
52
|
+
kind: ChatMessageKind;
|
|
53
|
+
/** For user messages: sender's session id (equals `room.sessionId` on the
|
|
54
|
+
* sender's local-echoed copy). For system messages: the joined/left player's
|
|
55
|
+
* session id. */
|
|
56
|
+
from: string;
|
|
57
|
+
/** Sender's (or subject's) display name at the time of the message. Empty if
|
|
58
|
+
* the Player entry carried no displayName. */
|
|
59
|
+
displayName: string;
|
|
60
|
+
/** For user messages: the trimmed, length-capped text. For system messages:
|
|
61
|
+
* a sensible English fallback (e.g. `"Alice joined"`). Games doing their own
|
|
62
|
+
* i18n should switch on `kind` and ignore this. */
|
|
63
|
+
text: string;
|
|
64
|
+
/** Sender-side `Date.now()` for user messages, local `Date.now()` for system
|
|
65
|
+
* messages. Advisory: order chat by arrival, not by ts. */
|
|
66
|
+
ts: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Convenience layer over the transient relay for in-game chat. Wraps
|
|
70
|
+
* `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
|
|
71
|
+
* game would otherwise re-implement inconsistently:
|
|
72
|
+
* - Local echo so the sender's own message hits the same handler stream
|
|
73
|
+
* immediately (synchronous, before the network call).
|
|
74
|
+
* - A canonical `ChatMessage` payload shape every chat in every game shares.
|
|
75
|
+
* - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
|
|
76
|
+
* paste cannot flood the relay.
|
|
77
|
+
*
|
|
78
|
+
* `displayName` is read from the sender's Player entry at send time and
|
|
79
|
+
* stamped onto the payload so receivers don't have to chase room.state to
|
|
80
|
+
* resolve a sessionId. See unboxy-design/features/multiplayer-chat.md §3.4.
|
|
81
|
+
*/
|
|
82
|
+
export declare class ChatFacade {
|
|
83
|
+
private readonly room;
|
|
84
|
+
private readonly handlers;
|
|
85
|
+
private remoteOff;
|
|
86
|
+
/** Sids that were already in `state.players` when this facade was constructed
|
|
87
|
+
* — we suppress "joined" system messages for them so the joiner doesn't see
|
|
88
|
+
* a flood of "X joined" for everyone already in the room. */
|
|
89
|
+
private readonly seen;
|
|
90
|
+
constructor(room: Room<unknown>);
|
|
91
|
+
/**
|
|
92
|
+
* Send a chat line. Empty-after-trim input is dropped silently. Text longer
|
|
93
|
+
* than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
|
|
94
|
+
* synchronously before the wire send, so the input box can be cleared on
|
|
95
|
+
* the same tick.
|
|
96
|
+
*
|
|
97
|
+
* Returns a Promise that resolves once dispatch is complete (local echo +
|
|
98
|
+
* wire send queued). Rejects if the underlying `room.send` throws (e.g.
|
|
99
|
+
* the connection has dropped). Note: resolution does NOT confirm remote
|
|
100
|
+
* delivery — the underlying transient relay is fire-and-forget. Use this
|
|
101
|
+
* for catching local-side errors; do not treat it as an ack.
|
|
102
|
+
*/
|
|
103
|
+
send(text: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Subscribe to chat messages — both remote and local-echoed (your own), plus
|
|
106
|
+
* auto-emitted `'system.joined'` / `'system.left'` events. Returns an
|
|
107
|
+
* unsubscribe function. Call it on scene shutdown.
|
|
108
|
+
*/
|
|
109
|
+
onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
|
|
110
|
+
/**
|
|
111
|
+
* Snapshot the current player list and subscribe to add/remove for system
|
|
112
|
+
* messages. By the time `client.joinOrCreate` resolves (and UnboxyRoom is
|
|
113
|
+
* constructed), Colyseus has delivered the initial state — so the snapshot
|
|
114
|
+
* here covers everyone already in the room, and we skip emission for them.
|
|
115
|
+
* Adds and removes after this point are real events.
|
|
116
|
+
*/
|
|
117
|
+
private subscribeToPlayers;
|
|
118
|
+
private dispatch;
|
|
119
|
+
}
|
|
32
120
|
/**
|
|
33
121
|
* Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
|
|
34
122
|
* documented in SDK-GUIDE.md so games are portable to a different realtime
|
|
@@ -38,6 +126,7 @@ export declare class UnboxyRoom<State = unknown> {
|
|
|
38
126
|
private readonly room;
|
|
39
127
|
readonly player: PlayerDataFacade;
|
|
40
128
|
readonly data: RoomDataFacade;
|
|
129
|
+
readonly chat: ChatFacade;
|
|
41
130
|
constructor(room: Room<State>);
|
|
42
131
|
get id(): string;
|
|
43
132
|
get name(): string;
|
|
@@ -54,6 +54,176 @@ export class RoomDataFacade {
|
|
|
54
54
|
return parseOrNull(state?.data?.get?.(key));
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
/** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
|
|
58
|
+
* truncated. Games should mirror this on their input box's `maxLength`. */
|
|
59
|
+
export const MAX_CHAT_TEXT_LEN = 500;
|
|
60
|
+
/**
|
|
61
|
+
* Convenience layer over the transient relay for in-game chat. Wraps
|
|
62
|
+
* `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
|
|
63
|
+
* game would otherwise re-implement inconsistently:
|
|
64
|
+
* - Local echo so the sender's own message hits the same handler stream
|
|
65
|
+
* immediately (synchronous, before the network call).
|
|
66
|
+
* - A canonical `ChatMessage` payload shape every chat in every game shares.
|
|
67
|
+
* - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
|
|
68
|
+
* paste cannot flood the relay.
|
|
69
|
+
*
|
|
70
|
+
* `displayName` is read from the sender's Player entry at send time and
|
|
71
|
+
* stamped onto the payload so receivers don't have to chase room.state to
|
|
72
|
+
* resolve a sessionId. See unboxy-design/features/multiplayer-chat.md §3.4.
|
|
73
|
+
*/
|
|
74
|
+
export class ChatFacade {
|
|
75
|
+
constructor(room) {
|
|
76
|
+
this.room = room;
|
|
77
|
+
this.handlers = new Set();
|
|
78
|
+
this.remoteOff = null;
|
|
79
|
+
/** Sids that were already in `state.players` when this facade was constructed
|
|
80
|
+
* — we suppress "joined" system messages for them so the joiner doesn't see
|
|
81
|
+
* a flood of "X joined" for everyone already in the room. */
|
|
82
|
+
this.seen = new Set();
|
|
83
|
+
this.subscribeToPlayers();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Send a chat line. Empty-after-trim input is dropped silently. Text longer
|
|
87
|
+
* than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
|
|
88
|
+
* synchronously before the wire send, so the input box can be cleared on
|
|
89
|
+
* the same tick.
|
|
90
|
+
*
|
|
91
|
+
* Returns a Promise that resolves once dispatch is complete (local echo +
|
|
92
|
+
* wire send queued). Rejects if the underlying `room.send` throws (e.g.
|
|
93
|
+
* the connection has dropped). Note: resolution does NOT confirm remote
|
|
94
|
+
* delivery — the underlying transient relay is fire-and-forget. Use this
|
|
95
|
+
* for catching local-side errors; do not treat it as an ack.
|
|
96
|
+
*/
|
|
97
|
+
send(text) {
|
|
98
|
+
if (typeof text !== 'string')
|
|
99
|
+
return Promise.resolve();
|
|
100
|
+
const trimmed = text.trim();
|
|
101
|
+
if (trimmed.length === 0)
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
const capped = trimmed.length > MAX_CHAT_TEXT_LEN ? trimmed.slice(0, MAX_CHAT_TEXT_LEN) : trimmed;
|
|
104
|
+
const state = this.room.state;
|
|
105
|
+
const me = state?.players?.get?.(this.room.sessionId);
|
|
106
|
+
const displayName = typeof me?.displayName === 'string' ? me.displayName : '';
|
|
107
|
+
const msg = {
|
|
108
|
+
kind: 'user',
|
|
109
|
+
from: this.room.sessionId,
|
|
110
|
+
displayName,
|
|
111
|
+
text: capped,
|
|
112
|
+
ts: Date.now(),
|
|
113
|
+
};
|
|
114
|
+
// Local echo first, synchronously, so callers can clear input on the same
|
|
115
|
+
// tick the message lands in their chat log.
|
|
116
|
+
this.dispatch(msg);
|
|
117
|
+
// Wire send. The server's wildcard relay rebroadcasts to every other
|
|
118
|
+
// client (`except: senderClient`) with `from: client.sessionId` stamped.
|
|
119
|
+
try {
|
|
120
|
+
this.room.send('chat', { kind: 'user', text: capped, ts: msg.ts, displayName });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return Promise.reject(err);
|
|
124
|
+
}
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Subscribe to chat messages — both remote and local-echoed (your own), plus
|
|
129
|
+
* auto-emitted `'system.joined'` / `'system.left'` events. Returns an
|
|
130
|
+
* unsubscribe function. Call it on scene shutdown.
|
|
131
|
+
*/
|
|
132
|
+
onMessage(handler) {
|
|
133
|
+
this.handlers.add(handler);
|
|
134
|
+
if (!this.remoteOff) {
|
|
135
|
+
const cb = (raw) => {
|
|
136
|
+
const p = (raw && typeof raw === 'object' ? raw : {});
|
|
137
|
+
if (typeof p.text !== 'string')
|
|
138
|
+
return; // defensive: drop legacy raw-string sends
|
|
139
|
+
// System messages are always computed locally — clamp wire kind to 'user'.
|
|
140
|
+
const msg = {
|
|
141
|
+
kind: 'user',
|
|
142
|
+
from: typeof p.from === 'string' ? p.from : '',
|
|
143
|
+
displayName: typeof p.displayName === 'string' ? p.displayName : '',
|
|
144
|
+
text: p.text,
|
|
145
|
+
ts: typeof p.ts === 'number' ? p.ts : Date.now(),
|
|
146
|
+
};
|
|
147
|
+
// Server uses `except: senderClient`, so a remote 'chat' is never the
|
|
148
|
+
// sender's own — no de-dupe with local echo needed.
|
|
149
|
+
this.dispatch(msg);
|
|
150
|
+
};
|
|
151
|
+
this.remoteOff = this.room.onMessage('chat', cb);
|
|
152
|
+
}
|
|
153
|
+
return () => {
|
|
154
|
+
this.handlers.delete(handler);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Snapshot the current player list and subscribe to add/remove for system
|
|
159
|
+
* messages. By the time `client.joinOrCreate` resolves (and UnboxyRoom is
|
|
160
|
+
* constructed), Colyseus has delivered the initial state — so the snapshot
|
|
161
|
+
* here covers everyone already in the room, and we skip emission for them.
|
|
162
|
+
* Adds and removes after this point are real events.
|
|
163
|
+
*/
|
|
164
|
+
subscribeToPlayers() {
|
|
165
|
+
const players = this.room.state?.players;
|
|
166
|
+
if (!players)
|
|
167
|
+
return;
|
|
168
|
+
if (typeof players.forEach === 'function') {
|
|
169
|
+
try {
|
|
170
|
+
players.forEach((_p, sid) => { this.seen.add(sid); });
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// forEach can throw on an unsynced state; the seen set stays empty,
|
|
174
|
+
// which means we'll emit "joined" for the initial hydration. That's
|
|
175
|
+
// a degraded but non-broken behavior — the chat log gets an extra
|
|
176
|
+
// line per existing player. Log once for visibility.
|
|
177
|
+
console.warn('[unboxy.chat] could not snapshot players on subscribe');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (typeof players.onAdd === 'function') {
|
|
181
|
+
players.onAdd((player, sid) => {
|
|
182
|
+
if (this.seen.has(sid))
|
|
183
|
+
return;
|
|
184
|
+
this.seen.add(sid);
|
|
185
|
+
// Don't announce yourself joining your own room.
|
|
186
|
+
if (sid === this.room.sessionId)
|
|
187
|
+
return;
|
|
188
|
+
const name = typeof player?.displayName === 'string' ? player.displayName : '';
|
|
189
|
+
const who = name || 'A player';
|
|
190
|
+
this.dispatch({
|
|
191
|
+
kind: 'system.joined',
|
|
192
|
+
from: sid,
|
|
193
|
+
displayName: name,
|
|
194
|
+
text: `${who} joined`,
|
|
195
|
+
ts: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (typeof players.onRemove === 'function') {
|
|
200
|
+
players.onRemove((player, sid) => {
|
|
201
|
+
if (!this.seen.has(sid))
|
|
202
|
+
return;
|
|
203
|
+
this.seen.delete(sid);
|
|
204
|
+
const name = typeof player?.displayName === 'string' ? player.displayName : '';
|
|
205
|
+
const who = name || 'A player';
|
|
206
|
+
this.dispatch({
|
|
207
|
+
kind: 'system.left',
|
|
208
|
+
from: sid,
|
|
209
|
+
displayName: name,
|
|
210
|
+
text: `${who} left`,
|
|
211
|
+
ts: Date.now(),
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
dispatch(msg) {
|
|
217
|
+
for (const h of this.handlers) {
|
|
218
|
+
try {
|
|
219
|
+
h(msg);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
console.error('[unboxy.chat] handler threw', err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
57
227
|
/**
|
|
58
228
|
* Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
|
|
59
229
|
* documented in SDK-GUIDE.md so games are portable to a different realtime
|
|
@@ -64,6 +234,7 @@ export class UnboxyRoom {
|
|
|
64
234
|
this.room = room;
|
|
65
235
|
this.player = new PlayerDataFacade(room);
|
|
66
236
|
this.data = new RoomDataFacade(room);
|
|
237
|
+
this.chat = new ChatFacade(room);
|
|
67
238
|
}
|
|
68
239
|
get id() { return this.room.roomId; }
|
|
69
240
|
get name() { return this.room.name; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unboxy/phaser-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Unboxy Phaser 3 SDK — game infrastructure for the Unboxy platform",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
+
"test": "npm run build && node --test scripts/test-chat-facade.mjs",
|
|
13
14
|
"prepublishOnly": "npm run build"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|