@umicat/phaser-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SDK-GUIDE.md +1726 -0
- package/dist/core/Transport.d.ts +28 -0
- package/dist/core/Transport.js +7 -0
- package/dist/core/Umicat.d.ts +45 -0
- package/dist/core/Umicat.js +60 -0
- package/dist/core/UmicatGame.d.ts +43 -0
- package/dist/core/UmicatGame.js +64 -0
- package/dist/core/UmicatScene.d.ts +19 -0
- package/dist/core/UmicatScene.js +38 -0
- package/dist/core/transports/LocalStorageTransport.d.ts +22 -0
- package/dist/core/transports/LocalStorageTransport.js +78 -0
- package/dist/core/transports/PostMessageTransport.d.ts +28 -0
- package/dist/core/transports/PostMessageTransport.js +105 -0
- package/dist/editor/EditorBridge.d.ts +114 -0
- package/dist/editor/EditorBridge.js +2608 -0
- package/dist/editor/EditorOverlayScene.d.ts +333 -0
- package/dist/editor/EditorOverlayScene.js +1896 -0
- package/dist/editor/EditorState.d.ts +251 -0
- package/dist/editor/EditorState.js +197 -0
- package/dist/gamedata/GameDataModule.d.ts +45 -0
- package/dist/gamedata/GameDataModule.js +59 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/orientation.d.ts +5 -0
- package/dist/orientation.js +4 -0
- package/dist/protocol.d.ts +807 -0
- package/dist/protocol.js +3 -0
- package/dist/realtime/RealtimeModule.d.ts +93 -0
- package/dist/realtime/RealtimeModule.js +115 -0
- package/dist/realtime/UmicatRoom.d.ts +197 -0
- package/dist/realtime/UmicatRoom.js +353 -0
- package/dist/recording/RecordingManager.d.ts +11 -0
- package/dist/recording/RecordingManager.js +59 -0
- package/dist/saves/SavesModule.d.ts +23 -0
- package/dist/saves/SavesModule.js +37 -0
- package/dist/scene/EditorMode.d.ts +17 -0
- package/dist/scene/EditorMode.js +22 -0
- package/dist/scene/EntityRegistry.d.ts +39 -0
- package/dist/scene/EntityRegistry.js +103 -0
- package/dist/scene/GameConfig.d.ts +60 -0
- package/dist/scene/GameConfig.js +50 -0
- package/dist/scene/HudRuntime.d.ts +131 -0
- package/dist/scene/HudRuntime.js +1224 -0
- package/dist/scene/Prefabs.d.ts +92 -0
- package/dist/scene/Prefabs.js +175 -0
- package/dist/scene/Rules.d.ts +73 -0
- package/dist/scene/Rules.js +164 -0
- package/dist/scene/SceneLoader.d.ts +118 -0
- package/dist/scene/SceneLoader.js +615 -0
- package/dist/scene/Waves.d.ts +85 -0
- package/dist/scene/Waves.js +365 -0
- package/dist/scene/autotile.d.ts +103 -0
- package/dist/scene/autotile.js +321 -0
- package/dist/scene/renderScripts.d.ts +53 -0
- package/dist/scene/renderScripts.js +67 -0
- package/dist/scene/spawnEntity.d.ts +201 -0
- package/dist/scene/spawnEntity.js +1326 -0
- package/dist/scene/types.d.ts +1166 -0
- package/dist/scene/types.js +34 -0
- package/dist/screenshot/ScreenshotManager.d.ts +14 -0
- package/dist/screenshot/ScreenshotManager.js +33 -0
- package/package.json +35 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
function parseOrNull(raw) {
|
|
2
|
+
if (raw == null)
|
|
3
|
+
return null;
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(raw);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Facade for per-player state — one entry per player, only the owner can write
|
|
13
|
+
* to their own entry. Delta-synced via Colyseus schema.
|
|
14
|
+
*/
|
|
15
|
+
export class PlayerDataFacade {
|
|
16
|
+
constructor(room) {
|
|
17
|
+
this.room = room;
|
|
18
|
+
}
|
|
19
|
+
/** Write a JSON-serializable value to the caller's own player data. */
|
|
20
|
+
set(key, value) {
|
|
21
|
+
this.room.send('player.set', { key, value });
|
|
22
|
+
}
|
|
23
|
+
/** Remove a key from the caller's own player data. */
|
|
24
|
+
delete(key) {
|
|
25
|
+
this.room.send('player.delete', { key });
|
|
26
|
+
}
|
|
27
|
+
/** Read a player's data value. Returns null if the player or key is absent. */
|
|
28
|
+
get(sessionId, key) {
|
|
29
|
+
const state = this.room.state;
|
|
30
|
+
const player = state?.players?.get?.(sessionId);
|
|
31
|
+
return parseOrNull(player?.data?.get?.(key));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Facade for room-scope shared state — one map per room, any client may write.
|
|
36
|
+
* Delta-synced via Colyseus schema. Use for shared game state like current
|
|
37
|
+
* turn, scoreboard, shared level-of-the-day, etc.
|
|
38
|
+
*/
|
|
39
|
+
export class RoomDataFacade {
|
|
40
|
+
constructor(room) {
|
|
41
|
+
this.room = room;
|
|
42
|
+
}
|
|
43
|
+
/** Write a JSON-serializable value into the room's shared data map. */
|
|
44
|
+
set(key, value) {
|
|
45
|
+
this.room.send('room.set', { key, value });
|
|
46
|
+
}
|
|
47
|
+
/** Remove a key from the room's shared data map. */
|
|
48
|
+
delete(key) {
|
|
49
|
+
this.room.send('room.delete', { key });
|
|
50
|
+
}
|
|
51
|
+
/** Read a shared room value. Returns null if the key is absent. */
|
|
52
|
+
get(key) {
|
|
53
|
+
const state = this.room.state;
|
|
54
|
+
return parseOrNull(state?.data?.get?.(key));
|
|
55
|
+
}
|
|
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 umicat-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 known to this facade, mapped to the displayName they had at the
|
|
80
|
+
* time we last observed them. Populated either at construction (if state
|
|
81
|
+
* was already synced) or on the first `onStateChange` (initial hydration
|
|
82
|
+
* — silent, no system messages). Subsequent state changes diff against
|
|
83
|
+
* this map to emit `system.joined` / `system.left`. We track displayName
|
|
84
|
+
* here because by the time a player is detected as "left" they're already
|
|
85
|
+
* gone from `state.players` and we can't read it from there. */
|
|
86
|
+
this.knownNames = new Map();
|
|
87
|
+
/** Becomes true the first time we observe a usable `state.players` map.
|
|
88
|
+
* The very first observation is treated as "initial state arrived" and
|
|
89
|
+
* must NOT fire `system.joined` for the players already in the room when
|
|
90
|
+
* the local user joined — only subsequent diffs are real lifecycle events. */
|
|
91
|
+
this.hydrated = false;
|
|
92
|
+
this.subscribeToPlayers();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Send a chat line. Empty-after-trim input is dropped silently. Text longer
|
|
96
|
+
* than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
|
|
97
|
+
* synchronously before the wire send, so the input box can be cleared on
|
|
98
|
+
* the same tick.
|
|
99
|
+
*
|
|
100
|
+
* Returns a Promise that resolves once dispatch is complete (local echo +
|
|
101
|
+
* wire send queued). Rejects if the underlying `room.send` throws (e.g.
|
|
102
|
+
* the connection has dropped). Note: resolution does NOT confirm remote
|
|
103
|
+
* delivery — the underlying transient relay is fire-and-forget. Use this
|
|
104
|
+
* for catching local-side errors; do not treat it as an ack.
|
|
105
|
+
*/
|
|
106
|
+
send(text) {
|
|
107
|
+
if (typeof text !== 'string')
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
const trimmed = text.trim();
|
|
110
|
+
if (trimmed.length === 0)
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
const capped = trimmed.length > MAX_CHAT_TEXT_LEN ? trimmed.slice(0, MAX_CHAT_TEXT_LEN) : trimmed;
|
|
113
|
+
const state = this.room.state;
|
|
114
|
+
const me = state?.players?.get?.(this.room.sessionId);
|
|
115
|
+
const displayName = typeof me?.displayName === 'string' ? me.displayName : '';
|
|
116
|
+
const msg = {
|
|
117
|
+
kind: 'user',
|
|
118
|
+
from: this.room.sessionId,
|
|
119
|
+
displayName,
|
|
120
|
+
text: capped,
|
|
121
|
+
ts: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
// Local echo first, synchronously, so callers can clear input on the same
|
|
124
|
+
// tick the message lands in their chat log.
|
|
125
|
+
this.dispatch(msg);
|
|
126
|
+
// Wire send. The server's wildcard relay rebroadcasts to every other
|
|
127
|
+
// client (`except: senderClient`) with `from: client.sessionId` stamped.
|
|
128
|
+
try {
|
|
129
|
+
this.room.send('chat', { kind: 'user', text: capped, ts: msg.ts, displayName });
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
return Promise.reject(err);
|
|
133
|
+
}
|
|
134
|
+
return Promise.resolve();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Subscribe to chat messages — both remote and local-echoed (your own), plus
|
|
138
|
+
* auto-emitted `'system.joined'` / `'system.left'` events. Returns an
|
|
139
|
+
* unsubscribe function. Call it on scene shutdown.
|
|
140
|
+
*/
|
|
141
|
+
onMessage(handler) {
|
|
142
|
+
this.handlers.add(handler);
|
|
143
|
+
if (!this.remoteOff) {
|
|
144
|
+
const cb = (raw) => {
|
|
145
|
+
const p = (raw && typeof raw === 'object' ? raw : {});
|
|
146
|
+
if (typeof p.text !== 'string')
|
|
147
|
+
return; // defensive: drop legacy raw-string sends
|
|
148
|
+
// System messages are always computed locally — clamp wire kind to 'user'.
|
|
149
|
+
const msg = {
|
|
150
|
+
kind: 'user',
|
|
151
|
+
from: typeof p.from === 'string' ? p.from : '',
|
|
152
|
+
displayName: typeof p.displayName === 'string' ? p.displayName : '',
|
|
153
|
+
text: p.text,
|
|
154
|
+
ts: typeof p.ts === 'number' ? p.ts : Date.now(),
|
|
155
|
+
};
|
|
156
|
+
// Server uses `except: senderClient`, so a remote 'chat' is never the
|
|
157
|
+
// sender's own — no de-dupe with local echo needed.
|
|
158
|
+
this.dispatch(msg);
|
|
159
|
+
};
|
|
160
|
+
this.remoteOff = this.room.onMessage('chat', cb);
|
|
161
|
+
}
|
|
162
|
+
return () => {
|
|
163
|
+
this.handlers.delete(handler);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Hook `onStateChange` to detect joins / leaves and try an eager snapshot
|
|
168
|
+
* if `state.players` is already populated.
|
|
169
|
+
*
|
|
170
|
+
* **The onStateChange subscription must always be set up, even when
|
|
171
|
+
* `state.players` is `undefined` at construction.** Colyseus 0.16 (and
|
|
172
|
+
* earlier) deliver the initial state as a *separate* message that arrives
|
|
173
|
+
* a few ms after `client.joinOrCreate` resolves — so right when UmicatRoom
|
|
174
|
+
* is constructed, `room.state.players` is typically `undefined`. An earlier
|
|
175
|
+
* 0.2.14 implementation early-returned in that case and never subscribed,
|
|
176
|
+
* which meant the diff machinery never armed and BOTH `system.joined` /
|
|
177
|
+
* `system.left` silently dead-stopped in production (Blokus chat game,
|
|
178
|
+
* 2026-04-27).
|
|
179
|
+
*
|
|
180
|
+
* Eager snapshot if `state.players` is already there sets `hydrated=true`
|
|
181
|
+
* so the first state change does a real diff. Otherwise the first state
|
|
182
|
+
* change is treated as initial hydration: populate `knownNames` silently,
|
|
183
|
+
* skip system messages, set `hydrated=true`. Subsequent state changes do
|
|
184
|
+
* the real lifecycle diff.
|
|
185
|
+
*
|
|
186
|
+
* Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
|
|
187
|
+
* 0.16 dropped those instance methods in favour of a separate
|
|
188
|
+
* `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
|
|
189
|
+
* `players` keys ourselves works the same in any Colyseus version, keeps
|
|
190
|
+
* us decoupled from the realtime backend's callback shape, and adds < 1ms
|
|
191
|
+
* of work per state change for typical room sizes (max 16 players).
|
|
192
|
+
*/
|
|
193
|
+
subscribeToPlayers() {
|
|
194
|
+
// Always subscribe — regardless of whether `state.players` is populated yet.
|
|
195
|
+
this.room.onStateChange(() => this.diffPlayers());
|
|
196
|
+
// Best-effort eager snapshot. If state is already synced (rare — usually
|
|
197
|
+
// only in tests or on a reconnect to a hot room), this lets us mark
|
|
198
|
+
// hydrated immediately and treat the first state change as a real diff.
|
|
199
|
+
this.tryEagerSnapshot();
|
|
200
|
+
}
|
|
201
|
+
/** Populate `knownNames` from `state.players` if it's already available
|
|
202
|
+
* and mark hydrated. Silent — never emits system messages. A successful
|
|
203
|
+
* `forEach` (even iterating zero items) means the schema is synced and
|
|
204
|
+
* this is "what the room looked like when we joined" — safe to hydrate. */
|
|
205
|
+
tryEagerSnapshot() {
|
|
206
|
+
const players = this.room.state?.players;
|
|
207
|
+
if (!players || typeof players.forEach !== 'function')
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
players.forEach((p, sid) => {
|
|
211
|
+
const name = typeof p?.displayName === 'string' ? p.displayName : '';
|
|
212
|
+
this.knownNames.set(sid, name);
|
|
213
|
+
});
|
|
214
|
+
this.hydrated = true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// State not iterable yet — leave it for the first onStateChange.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Compare current `players` membership against `knownNames`. On the very
|
|
221
|
+
* first invocation (post-construction) when `hydrated=false`, this is
|
|
222
|
+
* the initial-hydration call: populate `knownNames` silently and bail.
|
|
223
|
+
* Subsequent invocations diff against `knownNames` and emit
|
|
224
|
+
* `system.joined` for new sids, `system.left` for vanished sids. */
|
|
225
|
+
diffPlayers() {
|
|
226
|
+
const state = this.room.state;
|
|
227
|
+
const players = state?.players;
|
|
228
|
+
if (!players || typeof players.forEach !== 'function')
|
|
229
|
+
return;
|
|
230
|
+
if (!this.hydrated) {
|
|
231
|
+
// Initial hydration — silently absorb whoever's in the room when we
|
|
232
|
+
// joined. Don't say "X joined" for everyone already there.
|
|
233
|
+
try {
|
|
234
|
+
players.forEach((p, sid) => {
|
|
235
|
+
const name = typeof p?.displayName === 'string' ? p.displayName : '';
|
|
236
|
+
this.knownNames.set(sid, name);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.hydrated = true;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const nowSids = new Set();
|
|
246
|
+
try {
|
|
247
|
+
players.forEach((p, sid) => {
|
|
248
|
+
nowSids.add(sid);
|
|
249
|
+
const name = typeof p?.displayName === 'string' ? p.displayName : '';
|
|
250
|
+
if (this.knownNames.has(sid)) {
|
|
251
|
+
// Refresh cached name in case Colyseus updated it post-join.
|
|
252
|
+
this.knownNames.set(sid, name);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.knownNames.set(sid, name);
|
|
256
|
+
if (sid === this.room.sessionId)
|
|
257
|
+
return; // don't announce self-join
|
|
258
|
+
const who = name || 'A player';
|
|
259
|
+
this.dispatch({
|
|
260
|
+
kind: 'system.joined',
|
|
261
|
+
from: sid,
|
|
262
|
+
displayName: name,
|
|
263
|
+
text: `${who} joined`,
|
|
264
|
+
ts: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Anyone in knownNames but not in nowSids has left.
|
|
272
|
+
for (const sid of [...this.knownNames.keys()]) {
|
|
273
|
+
if (nowSids.has(sid))
|
|
274
|
+
continue;
|
|
275
|
+
const name = this.knownNames.get(sid) ?? '';
|
|
276
|
+
this.knownNames.delete(sid);
|
|
277
|
+
const who = name || 'A player';
|
|
278
|
+
this.dispatch({
|
|
279
|
+
kind: 'system.left',
|
|
280
|
+
from: sid,
|
|
281
|
+
displayName: name,
|
|
282
|
+
text: `${who} left`,
|
|
283
|
+
ts: Date.now(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
dispatch(msg) {
|
|
288
|
+
for (const h of this.handlers) {
|
|
289
|
+
try {
|
|
290
|
+
h(msg);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
console.error('[umicat.chat] handler threw', err);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Umicat-flavored wrapper around a Colyseus Room. Exposes only the surface
|
|
300
|
+
* documented in SDK-GUIDE.md so games are portable to a different realtime
|
|
301
|
+
* backend should we migrate away from Colyseus.
|
|
302
|
+
*/
|
|
303
|
+
export class UmicatRoom {
|
|
304
|
+
constructor(room) {
|
|
305
|
+
this.room = room;
|
|
306
|
+
this.player = new PlayerDataFacade(room);
|
|
307
|
+
this.data = new RoomDataFacade(room);
|
|
308
|
+
this.chat = new ChatFacade(room);
|
|
309
|
+
}
|
|
310
|
+
get id() { return this.room.roomId; }
|
|
311
|
+
get name() { return this.room.name; }
|
|
312
|
+
get sessionId() { return this.room.sessionId; }
|
|
313
|
+
/**
|
|
314
|
+
* Current server-authoritative state. Proxied from Colyseus Schema — read
|
|
315
|
+
* values directly. Mutations do not propagate; only server-side handlers
|
|
316
|
+
* may change state.
|
|
317
|
+
*/
|
|
318
|
+
get state() { return this.room.state; }
|
|
319
|
+
/** Send a transient message. Relayed to every other client in the room
|
|
320
|
+
* with `from: sessionId` stamped onto the payload. Not persisted in state. */
|
|
321
|
+
send(type, payload) {
|
|
322
|
+
this.room.send(type, payload);
|
|
323
|
+
}
|
|
324
|
+
/** Register a handler for a server-sent message type. Returns unsubscribe. */
|
|
325
|
+
on(type, handler) {
|
|
326
|
+
return this.room.onMessage(type, handler);
|
|
327
|
+
}
|
|
328
|
+
/** Fires whenever the server-authoritative state changes. */
|
|
329
|
+
onStateChange(handler) {
|
|
330
|
+
const cb = () => handler(this.room.state);
|
|
331
|
+
this.room.onStateChange(cb);
|
|
332
|
+
return () => this.room.onStateChange.remove(cb);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Fires when the connection closes (kick, server shutdown, network drop).
|
|
336
|
+
* `code` follows WebSocket close codes plus Colyseus-specific ones.
|
|
337
|
+
*/
|
|
338
|
+
onLeave(handler) {
|
|
339
|
+
const cb = (code) => handler(code);
|
|
340
|
+
this.room.onLeave(cb);
|
|
341
|
+
return () => this.room.onLeave.remove(cb);
|
|
342
|
+
}
|
|
343
|
+
/** Fires when the server reports an error for this room. */
|
|
344
|
+
onError(handler) {
|
|
345
|
+
const cb = (code, message) => handler(code, message);
|
|
346
|
+
this.room.onError(cb);
|
|
347
|
+
return () => this.room.onError.remove(cb);
|
|
348
|
+
}
|
|
349
|
+
/** Disconnect from the room. Resolves with the close code. */
|
|
350
|
+
async leave(consented = true) {
|
|
351
|
+
return this.room.leave(consented);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
/**
|
|
3
|
+
* Sets up postMessage listeners for video recording of the game canvas.
|
|
4
|
+
*
|
|
5
|
+
* Parent sends: 'startRecording'
|
|
6
|
+
* Game responds: { type: 'recordingStarted' }
|
|
7
|
+
*
|
|
8
|
+
* Parent sends: 'stopRecording'
|
|
9
|
+
* Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
|
|
10
|
+
*/
|
|
11
|
+
export declare function setupRecordingListener(game: Phaser.Game): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
let mediaRecorder = null;
|
|
2
|
+
let recordedChunks = [];
|
|
3
|
+
/**
|
|
4
|
+
* Sets up postMessage listeners for video recording of the game canvas.
|
|
5
|
+
*
|
|
6
|
+
* Parent sends: 'startRecording'
|
|
7
|
+
* Game responds: { type: 'recordingStarted' }
|
|
8
|
+
*
|
|
9
|
+
* Parent sends: 'stopRecording'
|
|
10
|
+
* Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
|
|
11
|
+
*/
|
|
12
|
+
export function setupRecordingListener(game) {
|
|
13
|
+
window.addEventListener('message', (event) => {
|
|
14
|
+
if (event.data === 'startRecording') {
|
|
15
|
+
try {
|
|
16
|
+
const canvas = game.canvas;
|
|
17
|
+
if (!canvas)
|
|
18
|
+
return;
|
|
19
|
+
recordedChunks = [];
|
|
20
|
+
const stream = canvas.captureStream(30);
|
|
21
|
+
// Pick best available codec
|
|
22
|
+
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
|
23
|
+
? 'video/webm;codecs=vp9'
|
|
24
|
+
: 'video/webm';
|
|
25
|
+
mediaRecorder = new MediaRecorder(stream, {
|
|
26
|
+
mimeType,
|
|
27
|
+
videoBitsPerSecond: 1500000, // 1.5 Mbps
|
|
28
|
+
});
|
|
29
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
30
|
+
if (e.data.size > 0)
|
|
31
|
+
recordedChunks.push(e.data);
|
|
32
|
+
};
|
|
33
|
+
mediaRecorder.onstop = () => {
|
|
34
|
+
const blob = new Blob(recordedChunks, { type: 'video/webm' });
|
|
35
|
+
const reader = new FileReader();
|
|
36
|
+
reader.onloadend = () => {
|
|
37
|
+
window.parent.postMessage({
|
|
38
|
+
type: 'recordingComplete',
|
|
39
|
+
data: reader.result,
|
|
40
|
+
mimeType: 'video/webm',
|
|
41
|
+
}, '*');
|
|
42
|
+
};
|
|
43
|
+
reader.readAsDataURL(blob);
|
|
44
|
+
};
|
|
45
|
+
mediaRecorder.start(1000); // collect data every 1 second
|
|
46
|
+
window.parent.postMessage({ type: 'recordingStarted' }, '*');
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
console.error('[UmicatSDK] Recording failed to start:', e);
|
|
50
|
+
window.parent.postMessage({ type: 'recordingError', error: String(e) }, '*');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (event.data === 'stopRecording') {
|
|
54
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
55
|
+
mediaRecorder.stop();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Transport } from '../core/Transport.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-user key-value save data scoped to the current (game, user).
|
|
4
|
+
*
|
|
5
|
+
* When the viewer is authenticated, reads/writes go through the host to the
|
|
6
|
+
* Umicat backend. When anonymous or standalone, the same API is backed by
|
|
7
|
+
* localStorage — games do not branch on auth state.
|
|
8
|
+
*
|
|
9
|
+
* Size quotas (enforced at the backend):
|
|
10
|
+
* - 100 KB per value
|
|
11
|
+
* - 1 MB total per (game, user)
|
|
12
|
+
* - 64 keys per (game, user)
|
|
13
|
+
*/
|
|
14
|
+
export declare class SavesModule {
|
|
15
|
+
private transport;
|
|
16
|
+
constructor(transport: Transport);
|
|
17
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
18
|
+
set(key: string, value: unknown, options?: {
|
|
19
|
+
ifVersion?: number;
|
|
20
|
+
}): Promise<number>;
|
|
21
|
+
delete(key: string): Promise<boolean>;
|
|
22
|
+
list(): Promise<string[]>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user key-value save data scoped to the current (game, user).
|
|
3
|
+
*
|
|
4
|
+
* When the viewer is authenticated, reads/writes go through the host to the
|
|
5
|
+
* Umicat backend. When anonymous or standalone, the same API is backed by
|
|
6
|
+
* localStorage — games do not branch on auth state.
|
|
7
|
+
*
|
|
8
|
+
* Size quotas (enforced at the backend):
|
|
9
|
+
* - 100 KB per value
|
|
10
|
+
* - 1 MB total per (game, user)
|
|
11
|
+
* - 64 keys per (game, user)
|
|
12
|
+
*/
|
|
13
|
+
export class SavesModule {
|
|
14
|
+
constructor(transport) {
|
|
15
|
+
this.transport = transport;
|
|
16
|
+
}
|
|
17
|
+
async get(key) {
|
|
18
|
+
const res = await this.transport.call('saves.get', { key });
|
|
19
|
+
return (res?.value ?? null);
|
|
20
|
+
}
|
|
21
|
+
async set(key, value, options) {
|
|
22
|
+
const res = await this.transport.call('saves.set', {
|
|
23
|
+
key,
|
|
24
|
+
value,
|
|
25
|
+
ifVersion: options?.ifVersion,
|
|
26
|
+
});
|
|
27
|
+
return res.version;
|
|
28
|
+
}
|
|
29
|
+
async delete(key) {
|
|
30
|
+
const res = await this.transport.call('saves.delete', { key });
|
|
31
|
+
return res.deleted;
|
|
32
|
+
}
|
|
33
|
+
async list() {
|
|
34
|
+
const res = await this.transport.call('saves.list');
|
|
35
|
+
return res.keys;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
/**
|
|
3
|
+
* Slice-1 entry point that became a thin wrapper over the slice-2 editor
|
|
4
|
+
* bridge. The bridge owns the full edit-mode lifecycle (pause world scenes,
|
|
5
|
+
* launch overlay, handle pointer events, route postMessage commands).
|
|
6
|
+
*
|
|
7
|
+
* Wire shape (host → SDK):
|
|
8
|
+
* { type: 'umicat:editor:enter' } - enter edit mode
|
|
9
|
+
* { type: 'umicat:editor:exit' } - exit edit mode
|
|
10
|
+
* ...plus the rest of the editor protocol — see protocol.ts
|
|
11
|
+
*
|
|
12
|
+
* The slice-1 `umicat:setEditMode` message is no longer accepted; home-ui
|
|
13
|
+
* has been updated to send the editor:enter / editor:exit pair as part of
|
|
14
|
+
* the slice-2 work.
|
|
15
|
+
*/
|
|
16
|
+
export declare function setupEditorModeListener(game: Phaser.Game): void;
|
|
17
|
+
export declare function isEditMode(game: Phaser.Game): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { setupEditorBridge } from '../editor/EditorBridge.js';
|
|
2
|
+
import { getEditorState } from '../editor/EditorState.js';
|
|
3
|
+
/**
|
|
4
|
+
* Slice-1 entry point that became a thin wrapper over the slice-2 editor
|
|
5
|
+
* bridge. The bridge owns the full edit-mode lifecycle (pause world scenes,
|
|
6
|
+
* launch overlay, handle pointer events, route postMessage commands).
|
|
7
|
+
*
|
|
8
|
+
* Wire shape (host → SDK):
|
|
9
|
+
* { type: 'umicat:editor:enter' } - enter edit mode
|
|
10
|
+
* { type: 'umicat:editor:exit' } - exit edit mode
|
|
11
|
+
* ...plus the rest of the editor protocol — see protocol.ts
|
|
12
|
+
*
|
|
13
|
+
* The slice-1 `umicat:setEditMode` message is no longer accepted; home-ui
|
|
14
|
+
* has been updated to send the editor:enter / editor:exit pair as part of
|
|
15
|
+
* the slice-2 work.
|
|
16
|
+
*/
|
|
17
|
+
export function setupEditorModeListener(game) {
|
|
18
|
+
setupEditorBridge(game);
|
|
19
|
+
}
|
|
20
|
+
export function isEditMode(game) {
|
|
21
|
+
return getEditorState(game).active;
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
/**
|
|
3
|
+
* Per-scene lookup of spawned entities. Behavior code uses this to find
|
|
4
|
+
* the player, enemies, doors, etc. by id or role without coupling to
|
|
5
|
+
* scene-file structure.
|
|
6
|
+
*
|
|
7
|
+
* Created by `loadWorldScene` and stashed on `scene.data` under the key
|
|
8
|
+
* `'unboxyEntityRegistry'`. Access via `getEntityRegistry(scene)`.
|
|
9
|
+
*/
|
|
10
|
+
export declare class EntityRegistry {
|
|
11
|
+
private byIdMap;
|
|
12
|
+
private byRoleMap;
|
|
13
|
+
/**
|
|
14
|
+
* Slice 11 (Phase B.1): track every spawned instance of each prefab so
|
|
15
|
+
* the editor's `umicat:editor:editPrefab` flow can re-apply visual /
|
|
16
|
+
* physics edits to live instances. Populated by `spawnPrefab`; ignored
|
|
17
|
+
* for non-prefab entities authored in scene JSON.
|
|
18
|
+
*/
|
|
19
|
+
private byPrefabIdMap;
|
|
20
|
+
/** Per-prefab monotonic counter for runtime-generated entity ids. */
|
|
21
|
+
private prefabCounters;
|
|
22
|
+
register(id: string, role: string | undefined, go: Phaser.GameObjects.GameObject, prefabId?: string): void;
|
|
23
|
+
byId(id: string): Phaser.GameObjects.GameObject | undefined;
|
|
24
|
+
byRole(role: string): Phaser.GameObjects.GameObject[];
|
|
25
|
+
/** All live instances of a given prefab. Slice 11. */
|
|
26
|
+
byPrefabId(prefabId: string): Phaser.GameObjects.GameObject[];
|
|
27
|
+
all(): Phaser.GameObjects.GameObject[];
|
|
28
|
+
/**
|
|
29
|
+
* Reserve the next runtime id for instances of `prefabId`. Format
|
|
30
|
+
* is `<prefabId>#<counter>` starting at 1. Used by `spawnPrefab`.
|
|
31
|
+
*/
|
|
32
|
+
nextInstanceId(prefabId: string): string;
|
|
33
|
+
/** Remove an entity from the registry. Does NOT destroy the GameObject —
|
|
34
|
+
* callers (editor delete path) destroy first, then unregister. */
|
|
35
|
+
unregister(id: string): void;
|
|
36
|
+
clear(): void;
|
|
37
|
+
}
|
|
38
|
+
export declare function attachEntityRegistry(scene: Phaser.Scene): EntityRegistry;
|
|
39
|
+
export declare function getEntityRegistry(scene: Phaser.Scene): EntityRegistry | undefined;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-scene lookup of spawned entities. Behavior code uses this to find
|
|
3
|
+
* the player, enemies, doors, etc. by id or role without coupling to
|
|
4
|
+
* scene-file structure.
|
|
5
|
+
*
|
|
6
|
+
* Created by `loadWorldScene` and stashed on `scene.data` under the key
|
|
7
|
+
* `'unboxyEntityRegistry'`. Access via `getEntityRegistry(scene)`.
|
|
8
|
+
*/
|
|
9
|
+
export class EntityRegistry {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.byIdMap = new Map();
|
|
12
|
+
this.byRoleMap = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Slice 11 (Phase B.1): track every spawned instance of each prefab so
|
|
15
|
+
* the editor's `umicat:editor:editPrefab` flow can re-apply visual /
|
|
16
|
+
* physics edits to live instances. Populated by `spawnPrefab`; ignored
|
|
17
|
+
* for non-prefab entities authored in scene JSON.
|
|
18
|
+
*/
|
|
19
|
+
this.byPrefabIdMap = new Map();
|
|
20
|
+
/** Per-prefab monotonic counter for runtime-generated entity ids. */
|
|
21
|
+
this.prefabCounters = new Map();
|
|
22
|
+
}
|
|
23
|
+
register(id, role, go, prefabId) {
|
|
24
|
+
this.byIdMap.set(id, go);
|
|
25
|
+
if (role) {
|
|
26
|
+
const list = this.byRoleMap.get(role) ?? [];
|
|
27
|
+
list.push(go);
|
|
28
|
+
this.byRoleMap.set(role, list);
|
|
29
|
+
}
|
|
30
|
+
if (prefabId) {
|
|
31
|
+
const list = this.byPrefabIdMap.get(prefabId) ?? [];
|
|
32
|
+
list.push(go);
|
|
33
|
+
this.byPrefabIdMap.set(prefabId, list);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
byId(id) {
|
|
37
|
+
return this.byIdMap.get(id);
|
|
38
|
+
}
|
|
39
|
+
byRole(role) {
|
|
40
|
+
return this.byRoleMap.get(role) ?? [];
|
|
41
|
+
}
|
|
42
|
+
/** All live instances of a given prefab. Slice 11. */
|
|
43
|
+
byPrefabId(prefabId) {
|
|
44
|
+
return this.byPrefabIdMap.get(prefabId) ?? [];
|
|
45
|
+
}
|
|
46
|
+
all() {
|
|
47
|
+
return Array.from(this.byIdMap.values());
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reserve the next runtime id for instances of `prefabId`. Format
|
|
51
|
+
* is `<prefabId>#<counter>` starting at 1. Used by `spawnPrefab`.
|
|
52
|
+
*/
|
|
53
|
+
nextInstanceId(prefabId) {
|
|
54
|
+
const next = (this.prefabCounters.get(prefabId) ?? 0) + 1;
|
|
55
|
+
this.prefabCounters.set(prefabId, next);
|
|
56
|
+
return `${prefabId}#${next}`;
|
|
57
|
+
}
|
|
58
|
+
/** Remove an entity from the registry. Does NOT destroy the GameObject —
|
|
59
|
+
* callers (editor delete path) destroy first, then unregister. */
|
|
60
|
+
unregister(id) {
|
|
61
|
+
const go = this.byIdMap.get(id);
|
|
62
|
+
if (!go)
|
|
63
|
+
return;
|
|
64
|
+
this.byIdMap.delete(id);
|
|
65
|
+
for (const [role, list] of this.byRoleMap) {
|
|
66
|
+
const idx = list.indexOf(go);
|
|
67
|
+
if (idx >= 0) {
|
|
68
|
+
list.splice(idx, 1);
|
|
69
|
+
if (list.length === 0)
|
|
70
|
+
this.byRoleMap.delete(role);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [prefabId, list] of this.byPrefabIdMap) {
|
|
74
|
+
const idx = list.indexOf(go);
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
list.splice(idx, 1);
|
|
77
|
+
if (list.length === 0)
|
|
78
|
+
this.byPrefabIdMap.delete(prefabId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
clear() {
|
|
83
|
+
this.byIdMap.clear();
|
|
84
|
+
this.byRoleMap.clear();
|
|
85
|
+
this.byPrefabIdMap.clear();
|
|
86
|
+
this.prefabCounters.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const REGISTRY_KEY = 'unboxyEntityRegistry';
|
|
90
|
+
export function attachEntityRegistry(scene) {
|
|
91
|
+
const existing = scene.data?.get(REGISTRY_KEY);
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.clear();
|
|
94
|
+
return existing;
|
|
95
|
+
}
|
|
96
|
+
const registry = new EntityRegistry();
|
|
97
|
+
// Phaser auto-creates DataManager on first .data access if absent.
|
|
98
|
+
scene.data.set(REGISTRY_KEY, registry);
|
|
99
|
+
return registry;
|
|
100
|
+
}
|
|
101
|
+
export function getEntityRegistry(scene) {
|
|
102
|
+
return scene.data?.get(REGISTRY_KEY);
|
|
103
|
+
}
|