@unboxy/phaser-sdk 0.2.12 → 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 +65 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- 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
|
@@ -369,7 +369,7 @@ Values are JSON-stringified under the hood. Use for anything JSON-serializable.
|
|
|
369
369
|
|
|
370
370
|
#### Transient events — `room.send` and `room.on`
|
|
371
371
|
|
|
372
|
-
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.)
|
|
373
373
|
|
|
374
374
|
```ts
|
|
375
375
|
// Sender
|
|
@@ -381,6 +381,68 @@ const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angl
|
|
|
381
381
|
});
|
|
382
382
|
```
|
|
383
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
|
+
|
|
384
446
|
#### Server-tracked membership
|
|
385
447
|
|
|
386
448
|
`room.state.players` is server-owned. Each entry exposes:
|
|
@@ -411,7 +473,7 @@ await room.leave();
|
|
|
411
473
|
| Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
|
|
412
474
|
| Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
|
|
413
475
|
| Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
|
|
414
|
-
| Chat message | `room.send(
|
|
476
|
+
| Chat message | `room.chat.send(text)` + `room.chat.onMessage(...)` | Helper over the relay: local echo, stamped sender, length cap. |
|
|
415
477
|
| Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
|
|
416
478
|
|
|
417
479
|
Rules of thumb:
|
|
@@ -482,6 +544,7 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
|
|
|
482
544
|
|
|
483
545
|
## Changelog
|
|
484
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.
|
|
485
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.
|
|
486
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.
|
|
487
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`.
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export { SavesModule } from './saves/SavesModule.js';
|
|
|
9
9
|
export { GameDataModule } from './gamedata/GameDataModule.js';
|
|
10
10
|
export { RealtimeModule } from './realtime/RealtimeModule.js';
|
|
11
11
|
export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/RealtimeModule.js';
|
|
12
|
-
export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.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';
|
|
@@ -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": {
|