@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
package/SDK-GUIDE.md
ADDED
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
# @umicat/phaser-sdk — Agent Guide
|
|
2
|
+
|
|
3
|
+
Reference for AI agents building games on the Umicat platform. Tracks the **installed SDK version** — always matches the code in `node_modules/@umicat/phaser-sdk`. Read this before calling any platform API.
|
|
4
|
+
|
|
5
|
+
## What the SDK provides
|
|
6
|
+
|
|
7
|
+
- `createUmicatGame(options)` — Phaser.Game factory with platform defaults (screenshot + recording wired up)
|
|
8
|
+
- `UmicatScene` — optional base scene with `createButton`, `shakeCamera`, `flashCamera` helpers
|
|
9
|
+
- `Umicat.init()` — platform services entry point
|
|
10
|
+
- `umicat.saves` — **per-user** key-value store (only the owner sees/writes their data)
|
|
11
|
+
- `umicat.gameData` — **per-game** key-value store (read by anyone; write requires auth)
|
|
12
|
+
- `umicat.rooms` — **multiplayer rooms** (server-authoritative state sync, requires sign-in and host support)
|
|
13
|
+
|
|
14
|
+
## `createUmicatGame` options
|
|
15
|
+
|
|
16
|
+
| field | type | note |
|
|
17
|
+
|-------|------|------|
|
|
18
|
+
| `orientation` | `'portrait' \| 'landscape'` | mutually exclusive with `width`/`height`. Resolves to preset dims from `ORIENTATION_DIMENSIONS`. **Since 0.2.16.** |
|
|
19
|
+
| `width` | `number` | required if `orientation` is omitted |
|
|
20
|
+
| `height` | `number` | required if `orientation` is omitted |
|
|
21
|
+
| `scenes` | `Phaser.Scene class[]` | required |
|
|
22
|
+
| `backgroundColor` | `string` | defaults to `'#1a1a2e'` |
|
|
23
|
+
| `pixelArt` | `boolean` | defaults to `false` |
|
|
24
|
+
| `physics` | `Phaser.Types.Core.PhysicsConfig` | defaults to arcade physics, no gravity |
|
|
25
|
+
| `plugins` | `Phaser.Types.Core.PluginObject` | forwarded as-is into the Phaser `GameConfig`. **Since 0.2.9.** Earlier versions silently dropped this field. |
|
|
26
|
+
|
|
27
|
+
`orientation` and explicit `width`/`height` are a TypeScript union — pass one or the other, not both. The compiler rejects mixed usage.
|
|
28
|
+
|
|
29
|
+
Any field not listed is ignored. If you need something else (e.g. `callbacks`, custom `render` options), use plain `new Phaser.Game(config)` instead of `createUmicatGame`.
|
|
30
|
+
|
|
31
|
+
### Orientation presets
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { ORIENTATION_DIMENSIONS } from '@umicat/phaser-sdk';
|
|
35
|
+
// { landscape: { width: 1280, height: 720 }, portrait: { width: 720, height: 1280 } }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The orientation a game uses is decided **once at game creation** and baked into the template's `src/config.ts`. There is no runtime API to flip orientation — switching mid-development would invalidate every screen layout.
|
|
39
|
+
|
|
40
|
+
### Registering third-party Phaser plugins (e.g. rexUI)
|
|
41
|
+
|
|
42
|
+
Pass them via the `plugins` option — do NOT try to register them inside a scene's `preload`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import VirtualJoystickPlugin from 'phaser3-rex-plugins/plugins/virtualjoystick-plugin.js';
|
|
46
|
+
|
|
47
|
+
createUmicatGame({
|
|
48
|
+
// ...
|
|
49
|
+
plugins: {
|
|
50
|
+
global: [
|
|
51
|
+
{ key: 'rexVirtualJoystick', plugin: VirtualJoystickPlugin, start: true },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then in a scene: `this.plugins.get('rexVirtualJoystick').add(this, { ... })`.
|
|
58
|
+
|
|
59
|
+
## Platform services
|
|
60
|
+
|
|
61
|
+
Initialize once at module load — resolves the host (home-ui, standalone, etc.) and returns a bound instance. Scenes read from the resulting promise.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// src/main.ts
|
|
65
|
+
import { Umicat } from '@umicat/phaser-sdk';
|
|
66
|
+
|
|
67
|
+
export const umicatReady = Umicat.init({ standaloneGameId: 'my-game' })
|
|
68
|
+
.catch(() => null);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`umicatReady` resolves to an `Umicat` instance, or `null` if init failed. Scenes should be resilient to `null` — they must still run for anonymous players.
|
|
72
|
+
|
|
73
|
+
### Identity
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const umicat = await umicatReady;
|
|
77
|
+
if (umicat) {
|
|
78
|
+
umicat.user; // { id, name, avatar? } | null — null = anonymous
|
|
79
|
+
umicat.isAuthenticated; // boolean
|
|
80
|
+
umicat.gameId; // stable platform-issued game id
|
|
81
|
+
umicat.host; // 'umicat-home-ui' | 'standalone' | 'discord' (future)
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Save data — `umicat.saves`
|
|
86
|
+
|
|
87
|
+
Per-user key-value store scoped to `(gameId, userId)`. Backed by the Umicat backend when authenticated, by localStorage when standalone/anonymous — **the API is identical either way**. Games should never branch on auth state for save logic.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// Read — returns null if the key doesn't exist
|
|
91
|
+
const highScore = await umicat.saves.get<number>('highScore');
|
|
92
|
+
|
|
93
|
+
// Write — returns the new version number
|
|
94
|
+
await umicat.saves.set('highScore', 12340);
|
|
95
|
+
|
|
96
|
+
// Optimistic concurrency — write only if stored version matches
|
|
97
|
+
try {
|
|
98
|
+
await umicat.saves.set('progress', { level: 3 }, { ifVersion: 7 });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// RpcError with code 'VERSION_MISMATCH' — someone else wrote first
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// List keys (no values — cheap)
|
|
104
|
+
const keys = await umicat.saves.list();
|
|
105
|
+
|
|
106
|
+
// Delete
|
|
107
|
+
await umicat.saves.delete('progress');
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Type parameter on `.get<T>()`** is only for TypeScript convenience — the SDK does not validate. Treat returned data as untrusted and defend against missing/malformed values.
|
|
111
|
+
|
|
112
|
+
### Quotas (enforced at backend)
|
|
113
|
+
|
|
114
|
+
| Limit | Value |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Per value | 100 KB |
|
|
117
|
+
| Per `(game, user)` total | 1 MB |
|
|
118
|
+
| Max keys per `(game, user)` | 64 |
|
|
119
|
+
| Key format | `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` |
|
|
120
|
+
|
|
121
|
+
Exceeding a quota throws `RpcError` with code `QUOTA_EXCEEDED`. Don't catch silently — surface a clear error.
|
|
122
|
+
|
|
123
|
+
### When to use saves
|
|
124
|
+
|
|
125
|
+
**Persist (by convention, no need to ask):**
|
|
126
|
+
- High scores, personal bests
|
|
127
|
+
- Level / stage / chapter progression
|
|
128
|
+
- Unlockables (characters, skins, abilities)
|
|
129
|
+
- In-game currency, inventories
|
|
130
|
+
- Cosmetic selection (chosen character, color)
|
|
131
|
+
- User-configurable settings (volume, difficulty preference, control scheme)
|
|
132
|
+
- Tutorial-seen / onboarding-completed flags
|
|
133
|
+
|
|
134
|
+
**Do not persist (transient state):**
|
|
135
|
+
- Current-run position, velocity, HP
|
|
136
|
+
- Active enemies, projectiles, particles
|
|
137
|
+
- Scene camera position
|
|
138
|
+
- Animation frames, timers, cooldowns
|
|
139
|
+
- UI focus, current menu
|
|
140
|
+
|
|
141
|
+
**Rule of thumb:** if the user would be annoyed to lose it on refresh, persist. If they'd be confused to see it come back (e.g. "why is there an enemy here from my last game?"), don't.
|
|
142
|
+
|
|
143
|
+
### Suggested key names
|
|
144
|
+
|
|
145
|
+
Use stable, descriptive key names so data carries across SDK versions:
|
|
146
|
+
|
|
147
|
+
| Concept | Key |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Highest score ever | `highScore` |
|
|
150
|
+
| Current progression | `progress` |
|
|
151
|
+
| Unlocked items | `unlocks` |
|
|
152
|
+
| Player settings | `settings` |
|
|
153
|
+
| Cosmetic selection | `cosmetics` |
|
|
154
|
+
| Onboarding flag | `onboardedAt` |
|
|
155
|
+
|
|
156
|
+
### Error handling patterns
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Graceful degradation — save failures must never block gameplay
|
|
160
|
+
try {
|
|
161
|
+
await umicat.saves.set('highScore', score);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.warn('[game] failed to save highScore', err);
|
|
164
|
+
// continue — game still works, just without persistence this turn
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// Load at scene start — handle missing, malformed, and failure cases
|
|
170
|
+
const saved = await umicat.saves.get<number>('highScore').catch(() => null);
|
|
171
|
+
this.highScore = typeof saved === 'number' ? saved : 0;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Game data — `umicat.gameData`
|
|
175
|
+
|
|
176
|
+
Same API shape as `saves`, but **game-scope**: one value per key, shared by all players of the game. Use it for things the whole playerbase sees — scoreboards, shared inventories, tournament state, level-of-the-day.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// Read — public. Works for anonymous visitors.
|
|
180
|
+
type Entry = { name: string; score: number; at: number };
|
|
181
|
+
const board = await umicat.gameData.get<Entry[]>('leaderboard') ?? [];
|
|
182
|
+
|
|
183
|
+
// Write — requires an authenticated user. Throws RpcError('UNAUTHENTICATED') otherwise.
|
|
184
|
+
// Safe append: read + modify + write with ifVersion to avoid clobbering
|
|
185
|
+
// concurrent writers (two players finishing at the same time).
|
|
186
|
+
const { value: current, version } = await umicat.gameData
|
|
187
|
+
.get<Entry[]>('leaderboard')
|
|
188
|
+
.then((v) => ({ value: v ?? [], version: null as number | null })) // null means "not yet written"
|
|
189
|
+
|
|
190
|
+
async function submitScore(name: string, score: number) {
|
|
191
|
+
if (!umicat.isAuthenticated) return; // skip for anonymous
|
|
192
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
193
|
+
const raw = await umicat.gameData.get<Entry[]>('leaderboard');
|
|
194
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
195
|
+
const next = [...list, { name, score, at: Date.now() }]
|
|
196
|
+
.sort((a, b) => b.score - a.score)
|
|
197
|
+
.slice(0, 100); // cap to top 100 so we don't blow the 100 KB limit
|
|
198
|
+
try {
|
|
199
|
+
await umicat.gameData.set('leaderboard', next);
|
|
200
|
+
return; // success
|
|
201
|
+
} catch (err: any) {
|
|
202
|
+
if (err?.code !== 'VERSION_MISMATCH') throw err;
|
|
203
|
+
// another writer landed first — re-read and retry
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Saves vs. gameData — which one?
|
|
210
|
+
|
|
211
|
+
| Need | Use | Why |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| My personal high score | `saves` | Only I write it, only I read it. Anonymous-friendly (localStorage fallback). |
|
|
214
|
+
| Progress through levels | `saves` | Per-user private state. |
|
|
215
|
+
| Character / loadout choice | `saves` | Per-user. |
|
|
216
|
+
| Scoreboard visible to everyone | `gameData` | Multiple users write; anyone reads. |
|
|
217
|
+
| Today's puzzle / daily challenge | `gameData` | Single global value read by all players. |
|
|
218
|
+
| Shared tournament bracket | `gameData` | Shared mutable state across players. |
|
|
219
|
+
| Player name in a chat log | `gameData` | But consider size caps — chat is noisy. |
|
|
220
|
+
| Current frame's enemy positions | *neither* — transient, don't persist | Wastes quota and confuses state after refresh. |
|
|
221
|
+
|
|
222
|
+
Rule of thumb: if two different players would write to the same key, it's `gameData`. If a write from player A should never be visible to player B, it's `saves`.
|
|
223
|
+
|
|
224
|
+
### Concurrency + size (gameData specifics)
|
|
225
|
+
|
|
226
|
+
- **List writes race.** If two players append to `leaderboard` simultaneously, one overwrites the other's work. Use `ifVersion` for read-modify-write. On `VERSION_MISMATCH`, re-read and retry.
|
|
227
|
+
- **Truncate.** A single key caps at 100 KB — roughly 500-1000 compact JSON entries. Keep lists bounded (top N, drop older), or shard across keys.
|
|
228
|
+
- **Reads are cheap and public.** No auth needed — even logged-out players see `gameData`. Don't put anything secret here.
|
|
229
|
+
- **Trust is thin.** The backend stores what you write, verbatim. A player with devtools could submit any JSON. For casual games this is fine; for competitive stakes, a future typed leaderboards API with server-side validation is the right fix.
|
|
230
|
+
|
|
231
|
+
### Multiplayer rooms — `umicat.rooms`
|
|
232
|
+
|
|
233
|
+
Realtime rooms backed by umicat-realtime-service. The platform is **game-agnostic** — the server tracks who's in the room and relays data, but does not know what kind of game you're building. Two opaque primitives are your building blocks:
|
|
234
|
+
|
|
235
|
+
1. **Delta-synced KV state** (`room.player.*`, `room.data.*`) — server-maintained maps. Values are JSON-serializable. Every client sees changes via `onStateChange`. State is preserved while the room exists; new joiners see the current snapshot.
|
|
236
|
+
2. **Transient message relay** (`room.send` + `room.on`) — fire-and-forget. Payloads are stamped with `from: senderSessionId` and broadcast to every other client in the room. Not persisted.
|
|
237
|
+
|
|
238
|
+
#### Join a room
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// Join or create a room scoped to this game. The SDK fetches a short-lived
|
|
242
|
+
// token from the host and forwards it; the iframe never handles credentials.
|
|
243
|
+
const room = await umicat.rooms.joinOrCreate('lobby', {
|
|
244
|
+
displayName: umicat.user?.name ?? 'guest',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
room.id; // room identifier
|
|
248
|
+
room.sessionId; // this connection's session id within the room
|
|
249
|
+
room.state; // proxy of the server-authoritative schema
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Room type is always `'lobby'`.** This is the only room type the realtime server registers — there is no `'blokus'` / `'chess'` / `'mygame'` type. Passing any other name results in "no handler" and throws. To distinguish rooms (private matches, per-code lobbies, etc.) use the `roomCode` option, documented below.
|
|
253
|
+
|
|
254
|
+
#### Matchmaking — one game can have many rooms
|
|
255
|
+
|
|
256
|
+
By default (no `roomCode`), every player of the same game lands in **one shared room**. Fine for a single hangout or a "public lobby" — but not for games where users need to open *private* rooms (e.g. "play this match with my friend" / "create a room with code ABC123 and share it").
|
|
257
|
+
|
|
258
|
+
Use the `roomCode` option to bucket players. Two clients with the same `(gameId, roomCode)` join the same room; different codes create independent rooms.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// --- Create a private room with a fresh code ---
|
|
262
|
+
const code = Math.random().toString(36).slice(2, 8).toUpperCase(); // e.g. 'AB3X7Y'
|
|
263
|
+
const hostRoom = await umicat.rooms.joinOrCreate('lobby', {
|
|
264
|
+
roomCode: code,
|
|
265
|
+
maxClients: 4, // cap to 4 players (server clamps to [1, 16])
|
|
266
|
+
displayName: umicat.user?.name ?? 'host',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Show `code` in the UI; the host shares it with friends manually.
|
|
270
|
+
|
|
271
|
+
// --- Friend joins the same room with the shared code ---
|
|
272
|
+
const guestRoom = await umicat.rooms.joinOrCreate('lobby', {
|
|
273
|
+
roomCode: 'AB3X7Y', // whatever the host shared
|
|
274
|
+
displayName: umicat.user?.name ?? 'guest',
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**`roomCode` caveats:**
|
|
279
|
+
- Not a password. Anyone who knows the code can join. Use it for unguessable rather than *secret*.
|
|
280
|
+
- Defaults to empty string when omitted. Two clients that both omit `roomCode` share the same "default" room — useful for a single global lobby per game.
|
|
281
|
+
- Changing `roomCode` between joins does NOT transfer state; it's a different room. Leave the old room (`room.leave()`) before joining a new one.
|
|
282
|
+
|
|
283
|
+
**`maxClients` caveats:**
|
|
284
|
+
- Only honored by the client who CREATES the room (the first one in). Later joiners see the creator's choice.
|
|
285
|
+
- Server clamps to `[1, 16]`. Passing `maxClients: 1000` silently becomes 16.
|
|
286
|
+
- When full, `joinOrCreate` throws. Handle that in your UI (e.g. "Room is full. Ask your friend to create a smaller room or try a different code.").
|
|
287
|
+
|
|
288
|
+
**Quick match / public room pattern.** If you want a "click Play, get matched with someone" flow, omit `roomCode` — everyone lands in the same default room. Players are differentiated by their `sessionId`. Add your own ready / waiting logic in `room.data`.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// Public quick match — no roomCode, everyone shares one room per game
|
|
292
|
+
const room = await umicat.rooms.joinOrCreate('lobby', {
|
|
293
|
+
maxClients: 2,
|
|
294
|
+
displayName: umicat.user?.name ?? 'guest',
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
#### Browsing open rooms — `umicat.rooms.list()`
|
|
299
|
+
|
|
300
|
+
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:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
interface RoomListEntry {
|
|
304
|
+
roomId: string; // opaque — pass to joinById
|
|
305
|
+
roomCode: string; // the code the host passed (empty for default rooms)
|
|
306
|
+
clients: number; // current occupants
|
|
307
|
+
maxClients: number; // cap the host set (server-clamped to [1, 16])
|
|
308
|
+
metadata?: unknown; // whatever the host set via room.setMetadata (optional)
|
|
309
|
+
createdAt?: number; // ms since epoch
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const rooms = await umicat.rooms.list();
|
|
313
|
+
rooms
|
|
314
|
+
.filter(r => r.clients < r.maxClients) // hide full rooms
|
|
315
|
+
.forEach(renderLobbyCard);
|
|
316
|
+
|
|
317
|
+
// On click:
|
|
318
|
+
async function joinRoom(entry: RoomListEntry) {
|
|
319
|
+
const room = await umicat.rooms.joinById(entry.roomId, {
|
|
320
|
+
displayName: umicat.user?.name ?? 'guest',
|
|
321
|
+
});
|
|
322
|
+
// room state is populated after the first onStateChange — see
|
|
323
|
+
// "Connection lifecycle" below for the state-timing rule.
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**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).
|
|
328
|
+
|
|
329
|
+
**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.
|
|
330
|
+
|
|
331
|
+
**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.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// Simple lobby browser refresh loop
|
|
335
|
+
let stop = false;
|
|
336
|
+
async function refreshLoop() {
|
|
337
|
+
while (!stop) {
|
|
338
|
+
try {
|
|
339
|
+
const rooms = await umicat.rooms.list();
|
|
340
|
+
renderLobby(rooms);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// Network hiccup or REALTIME_UNAVAILABLE — surface in UI, keep looping.
|
|
343
|
+
}
|
|
344
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
refreshLoop();
|
|
348
|
+
scene.events.once('shutdown', () => { stop = true; });
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**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.
|
|
352
|
+
|
|
353
|
+
#### Delta-synced state — `room.player` and `room.data`
|
|
354
|
+
|
|
355
|
+
Use this for anything that represents the *current world*: positions, hp, score, turn state, scoreboard, etc. The server delta-syncs diffs to every client.
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
// Per-player (only you can write your own entry)
|
|
359
|
+
room.player.set('pos', { x: 120, y: 340 });
|
|
360
|
+
room.player.set('hp', 80);
|
|
361
|
+
room.player.delete('pos');
|
|
362
|
+
|
|
363
|
+
// Read another player's data at any time
|
|
364
|
+
const otherPos = room.player.get<{x:number; y:number}>(otherSessionId, 'pos');
|
|
365
|
+
|
|
366
|
+
// Per-room (any client can write)
|
|
367
|
+
room.data.set('currentTurn', playerSessionId);
|
|
368
|
+
room.data.set('scoreboard', [{ sid: 's1', score: 12 }, ...]);
|
|
369
|
+
const board = room.data.get<Entry[]>('scoreboard') ?? [];
|
|
370
|
+
|
|
371
|
+
// React to any state change
|
|
372
|
+
const offState = room.onStateChange(() => {
|
|
373
|
+
room.state.players.forEach((p, sid) => {
|
|
374
|
+
const pos = room.player.get<{x:number;y:number}>(sid, 'pos');
|
|
375
|
+
// ... re-render
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Values are JSON-stringified under the hood. Use for anything JSON-serializable.
|
|
381
|
+
|
|
382
|
+
#### Transient events — `room.send` and `room.on`
|
|
383
|
+
|
|
384
|
+
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.)
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
// Sender
|
|
388
|
+
room.send('fire', { x: 100, y: 200, angle: 1.5 });
|
|
389
|
+
|
|
390
|
+
// Every other client (sender does NOT receive their own)
|
|
391
|
+
const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angle: number }) => {
|
|
392
|
+
spawnProjectile(msg.x, msg.y, msg.angle);
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
#### Chat — `room.chat.send` and `room.chat.onMessage`
|
|
397
|
+
|
|
398
|
+
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:
|
|
399
|
+
|
|
400
|
+
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.
|
|
401
|
+
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`.
|
|
402
|
+
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.
|
|
403
|
+
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.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
import { MAX_CHAT_TEXT_LEN } from '@umicat/phaser-sdk';
|
|
407
|
+
import type { ChatMessage } from '@umicat/phaser-sdk';
|
|
408
|
+
|
|
409
|
+
const log: ChatMessage[] = [];
|
|
410
|
+
|
|
411
|
+
const offChat = room.chat.onMessage((msg) => {
|
|
412
|
+
log.push(msg);
|
|
413
|
+
if (msg.kind === 'user') {
|
|
414
|
+
const who = msg.from === room.sessionId ? 'You' : (msg.displayName || 'Player');
|
|
415
|
+
appendChatLine(`${formatTime(msg.ts)} ${who}: ${msg.text}`);
|
|
416
|
+
} else {
|
|
417
|
+
// 'system.joined' or 'system.left' — render however you like.
|
|
418
|
+
// msg.text is already a sensible English fallback ("Alice joined").
|
|
419
|
+
appendSystemLine(`${formatTime(msg.ts)} — ${msg.text}`);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
inputEl.maxLength = MAX_CHAT_TEXT_LEN; // mirror the cap on the input box
|
|
424
|
+
inputEl.addEventListener('keydown', async (e) => {
|
|
425
|
+
if (e.key !== 'Enter') return;
|
|
426
|
+
try {
|
|
427
|
+
await room.chat.send(inputEl.value); // trim + cap + local echo + wire send
|
|
428
|
+
inputEl.value = ''; // safe: echo already dispatched synchronously
|
|
429
|
+
} catch (err) {
|
|
430
|
+
// Local-side dispatch failed (e.g. connection dropped). Show a UI hint.
|
|
431
|
+
showChatError(err);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
scene.events.once('shutdown', offChat);
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`ChatMessage` discriminates by `kind`:
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
|
|
442
|
+
interface ChatMessage {
|
|
443
|
+
kind: ChatMessageKind;
|
|
444
|
+
from: string; // sessionId of the sender (or join/leave subject)
|
|
445
|
+
displayName: string; // their displayName at the time
|
|
446
|
+
text: string; // user-typed text, or English system fallback
|
|
447
|
+
ts: number; // Date.now() when emitted
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Notes:
|
|
452
|
+
- **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.
|
|
453
|
+
- **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.
|
|
454
|
+
- **`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.
|
|
455
|
+
- **Order by arrival.** `ts` is `Date.now()`, advisory only. Skew between clients is small in practice but don't sort by `ts` across senders.
|
|
456
|
+
- **Trust model.** `text` and `displayName` come from the sender — no server-side validation. Same as every other transient relay payload.
|
|
457
|
+
|
|
458
|
+
#### Server-tracked membership
|
|
459
|
+
|
|
460
|
+
`room.state.players` is server-owned. Each entry exposes:
|
|
461
|
+
- `userId` — Umicat identity (from the JWT)
|
|
462
|
+
- `displayName` — set by the joining client
|
|
463
|
+
- `joinedAt` — ms timestamp
|
|
464
|
+
- `data` — the MapSchema<string> the player writes to via `room.player.set`
|
|
465
|
+
|
|
466
|
+
Games never mutate these directly; read them from `room.state.players`.
|
|
467
|
+
|
|
468
|
+
#### Connection lifecycle
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
room.onLeave((code) => console.log('disconnected', code));
|
|
472
|
+
room.onError((code, message) => console.warn('room error', code, message));
|
|
473
|
+
|
|
474
|
+
// Clean up on scene shutdown
|
|
475
|
+
offState();
|
|
476
|
+
offFire();
|
|
477
|
+
await room.leave();
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
#### Picking the right primitive
|
|
481
|
+
|
|
482
|
+
| Need | Use | Why |
|
|
483
|
+
|---|---|---|
|
|
484
|
+
| Smooth position sync of a player | `room.player.set('pos', {x,y})` at ~10-20 Hz + `onStateChange` | Delta-compressed, survives reconnect, server is source of truth. |
|
|
485
|
+
| Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
|
|
486
|
+
| Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
|
|
487
|
+
| Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
|
|
488
|
+
| Chat message | `room.chat.send(text)` + `room.chat.onMessage(...)` | Helper over the relay: local echo, stamped sender, length cap. |
|
|
489
|
+
| Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
|
|
490
|
+
|
|
491
|
+
Rules of thumb:
|
|
492
|
+
- **If someone joining mid-game should see it, it's state** (`room.player.*` or `room.data.*`).
|
|
493
|
+
- **If it's a one-shot action, it's a message** (`room.send` / `room.on`).
|
|
494
|
+
- **Do not implement position sync via messages.** You will reinvent delta compression badly and spend more bandwidth. Use `room.player.set('pos', {x,y})`.
|
|
495
|
+
- **Server is authoritative but permissive** — it doesn't validate what you put in `data`. Treat inbound values from other players as untrusted (don't eval, validate shape before reading).
|
|
496
|
+
|
|
497
|
+
#### Making remote motion feel smooth
|
|
498
|
+
|
|
499
|
+
State updates arrive at roughly 20 Hz (every ~50 ms). If you re-render remote entities by snapping directly to `room.player.get(sid, 'pos')` each frame, their movement will visibly step. Pick a smoothing technique that fits the game:
|
|
500
|
+
|
|
501
|
+
**Interpolation** — good for continuous motion (platformer, shooter, racer). Keep a `target` on each remote sprite, update it from state, lerp toward it each frame.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
// On state change, just update the target
|
|
505
|
+
room.onStateChange(() => {
|
|
506
|
+
room.state.players.forEach((_p, sid) => {
|
|
507
|
+
if (sid === room.sessionId) return;
|
|
508
|
+
const pos = room.player.get<{x:number;y:number}>(sid, 'pos');
|
|
509
|
+
if (pos) remoteSprites.get(sid)!.target = pos;
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// In your scene's update() loop
|
|
514
|
+
remoteSprites.forEach((s) => {
|
|
515
|
+
s.sprite.x = Phaser.Math.Linear(s.sprite.x, s.target.x, 0.2);
|
|
516
|
+
s.sprite.y = Phaser.Math.Linear(s.sprite.y, s.target.y, 0.2);
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Pair this with **client-side prediction for the LOCAL player**: render your own sprite from a locally-predicted position (updated by input every frame) so input feels instant, not gated on the round trip. Publish the predicted position to state at ~10–20 Hz with `room.player.set('pos', predicted)`. You rarely need to reconcile in casual games; trust the local prediction.
|
|
521
|
+
|
|
522
|
+
**Extrapolation** — good for projectiles and other predictable trajectories. Send `{ x, y, vx, vy, t }` once on spawn, let each client simulate locally with `x += vx * dt`. Only re-sync on events that change the trajectory (hit, bounce, destroy). Uses far less bandwidth than per-frame position sync.
|
|
523
|
+
|
|
524
|
+
**Snap** — the right choice for:
|
|
525
|
+
- Infrequent updates: score, hp, current turn, chat messages, lobby ready flags.
|
|
526
|
+
- Turn-based games: pieces jumping to their next square shouldn't glide.
|
|
527
|
+
- Discrete grids: same reason.
|
|
528
|
+
|
|
529
|
+
Don't blindly apply interpolation to everything. It's wrong for a chess piece and wasteful on a hp bar.
|
|
530
|
+
|
|
531
|
+
#### Availability
|
|
532
|
+
|
|
533
|
+
- Requires sign-in. Anonymous players get `RpcError('UNAUTHENTICATED')`.
|
|
534
|
+
- Requires a host with multiplayer enabled. Standalone games (no host) get `RpcError('REALTIME_UNAVAILABLE')` — guard with `if (umicat.host === 'umicat-home-ui')` if your game can run both hosted and standalone.
|
|
535
|
+
|
|
536
|
+
#### Available room types
|
|
537
|
+
|
|
538
|
+
- `lobby` — the generic room described above. Use for any multiplayer gameplay.
|
|
539
|
+
|
|
540
|
+
**Rules of the road**
|
|
541
|
+
- The server is the source of truth. Never trust `send()` payloads coming from other clients — the server validates.
|
|
542
|
+
- Room state is *ephemeral*. When the last player leaves, it disposes. Persist anything you want to keep via `saves` or `gameData`.
|
|
543
|
+
- Keep messages small. State syncs are delta-compressed; raw `send()` payloads are not.
|
|
544
|
+
- Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
|
|
545
|
+
- One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
|
|
546
|
+
|
|
547
|
+
## Scene-as-data (visual editor foundation, since 0.2.17)
|
|
548
|
+
|
|
549
|
+
Games that ship with `public/scenes/manifest.json` load layout from JSON instead of hardcoding `this.add.sprite(x, y, ...)` in scene classes. This split is what lets the visual editor edit a game without touching code.
|
|
550
|
+
|
|
551
|
+
**File layout:**
|
|
552
|
+
```
|
|
553
|
+
src/scenes/
|
|
554
|
+
BootScene.ts — preloads manifest, draws loading bar, starts GameScene
|
|
555
|
+
GameScene.ts — generic loader: await loadWorldScene(this, sceneId)
|
|
556
|
+
public/scenes/
|
|
557
|
+
manifest.json — scenes list + asset table + initial scene id
|
|
558
|
+
world/<scene>.json — one world scene per file (entities, camera, world dims)
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Manifest shape:**
|
|
562
|
+
```json
|
|
563
|
+
{
|
|
564
|
+
"schemaVersion": 1,
|
|
565
|
+
"id": "my-game", "title": "My Game", "version": "1.0.0",
|
|
566
|
+
"initialScene": "main",
|
|
567
|
+
"scenes": [{ "id": "main", "type": "world", "file": "world/main.json", "hud": null }],
|
|
568
|
+
"huds": [],
|
|
569
|
+
"assets": [
|
|
570
|
+
{ "id": "knight", "textureKey": "knight", "path": "uploaded/knight.png", "kind": "image" },
|
|
571
|
+
{ "id": "tiles", "textureKey": "tiles", "path": "uploaded/tiles.png",
|
|
572
|
+
"kind": "spritesheet",
|
|
573
|
+
"spriteSheetConfig": { "frameWidth": 16, "frameHeight": 16 } }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The `assets[]` table is the single source of truth for asset loading in scene-as-data games. Replaces hand-rolled `BootScene.preload()` lines: `loadWorldScene` queues the right `this.load.*` call based on each asset's `kind` and config (lazy — only assets the active scene uses).
|
|
579
|
+
|
|
580
|
+
**Entity shape (world scenes — flat schema, SDK 0.3.0):**
|
|
581
|
+
```json
|
|
582
|
+
{
|
|
583
|
+
"id": "player",
|
|
584
|
+
"kind": "sprite",
|
|
585
|
+
"role": "player",
|
|
586
|
+
"transform": { "x": 200, "y": 400, "rotation": 0, "scaleX": 1, "scaleY": 1 },
|
|
587
|
+
"assetId": "knight",
|
|
588
|
+
"properties": { "maxHp": 3, "speed": 200 }
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`kind` is the entity discriminator with seven variants: `sprite` / `rect` / `circle` / `code-rendered` / `group` / `tilemap` / `trigger`. Per-kind render fields live at the entity root (no nested `visual` sub-object):
|
|
593
|
+
|
|
594
|
+
- `sprite` → `assetId`, optional `frame` / `tint` / `alpha` / `flipX` / `flipY`
|
|
595
|
+
- `rect` → `width`, `height`, optional `fillColor` / `strokeColor` / `strokeWidth` / `alpha`
|
|
596
|
+
- `circle` → `radius`, optional `fillColor` / `strokeColor` / `strokeWidth` / `alpha`
|
|
597
|
+
- `code-rendered` → `script` (path to render script), optional `params` / `width` / `height`
|
|
598
|
+
- `tilemap` → `tileSize`, `size`, `layers[]`
|
|
599
|
+
- `trigger` → `shape`, optional `description` / `targets` / `behaviorScript`
|
|
600
|
+
- `group` → `children[]`
|
|
601
|
+
|
|
602
|
+
**Optional physics body (since 0.2.54).** Any renderable entity (`sprite` / `rect` / `circle` / `code-rendered`) may carry a `physics` block — the same shape prefabs use (see the Prefabs section's `physics` field reference). When present, `loadWorldScene` gives the entity an Arcade body, sized and offset per the block, so behavior code doesn't call `physics.add.existing` / `body.setSize` for it. A code-rendered platform, for example, can declare `"physics": { "bodyW": 200, "bodyH": 32, "immovable": true }` and be collidable the moment the scene loads.
|
|
603
|
+
|
|
604
|
+
**World gravity from scene data (since 0.2.55).** A world scene's `world.physics.gravity` (`{ x?, y? }`) — or, as a manifest-wide default, `manifest.globals.physics.gravity` — is applied to the scene's Arcade physics world by `loadWorldScene`. Scene-level wins over the manifest global. Example: a platformer's `world/main.json` carries `"world": { "width": 5120, "height": 720, "physics": { "gravity": { "x": 0, "y": 700 } } }` and the player just falls — no `this.physics.world.gravity` line in `GameScene.ts`. (Before 0.2.55 these two fields were typed but inert; only `game.json`'s `GameConfig.physics.gravity`, wired through `createUmicatGame` in `main.ts`, took effect.)
|
|
605
|
+
|
|
606
|
+
**API:**
|
|
607
|
+
```ts
|
|
608
|
+
import { preloadManifest, getManifest, loadWorldScene, getEntityRegistry } from '@umicat/phaser-sdk';
|
|
609
|
+
|
|
610
|
+
class BootScene extends Phaser.Scene {
|
|
611
|
+
preload() { preloadManifest(this); /* + draw loading bar */ }
|
|
612
|
+
create() {
|
|
613
|
+
const manifest = getManifest(this);
|
|
614
|
+
this.scene.start('GameScene', { sceneId: manifest.initialScene });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
class GameScene extends Phaser.Scene {
|
|
619
|
+
private sceneId!: string;
|
|
620
|
+
private player!: Phaser.Physics.Arcade.Sprite;
|
|
621
|
+
init(data: { sceneId: string }) { this.sceneId = data.sceneId; }
|
|
622
|
+
async create() {
|
|
623
|
+
await loadWorldScene(this, this.sceneId); // spawns entities, applies camera
|
|
624
|
+
const registry = getEntityRegistry(this)!;
|
|
625
|
+
this.player = registry.byRole('player')[0] as Phaser.Physics.Arcade.Sprite;
|
|
626
|
+
// ...wire physics + input on `this.player`...
|
|
627
|
+
}
|
|
628
|
+
update() {
|
|
629
|
+
// Safe: loadWorldScene suspends update() during its await, so any
|
|
630
|
+
// class field assigned after `await loadWorldScene(...)` is defined
|
|
631
|
+
// by the first time update() ticks. No `if (!this.player) return;`
|
|
632
|
+
// guard needed.
|
|
633
|
+
this.player.setVelocityX(0);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Async create() safety (since 0.2.46).** Phaser does not await `create()`'s returned promise — without help, `update()` starts ticking before `await loadWorldScene(...)` resolves, and any class field assigned after the await is still `undefined` on frame 1. `loadWorldScene` (and `loadHudScene`) suspends the scene's update/physics/tween processing for the duration of its await via the exported `suspendSceneUpdates(scene)` helper, then resumes — so the pattern above is safe by construction. If you have **additional** awaits in `create()` after `loadWorldScene` returns, wrap them yourself:
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
async create() {
|
|
642
|
+
await loadWorldScene(this, this.sceneId);
|
|
643
|
+
const release = suspendSceneUpdates(this);
|
|
644
|
+
try {
|
|
645
|
+
await loadSomethingElse();
|
|
646
|
+
this.player = ...; // assigned here
|
|
647
|
+
} finally {
|
|
648
|
+
release();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
**Behavior conventions:**
|
|
654
|
+
- Layout (positions, asset references, world dims, camera bounds) lives in scene JSON.
|
|
655
|
+
- Behavior (physics setup, input handlers, update loop, tweens) lives in `GameScene.ts`.
|
|
656
|
+
- Look entities up by `role` (`registry.byRole('player')`) — portable across scenes. By id (`registry.byId('boss-1')`) when you need a specific one.
|
|
657
|
+
- Don't hand-write `this.load.image(...)` lines for game assets — declare them in `manifest.assets[]`.
|
|
658
|
+
- Don't call `this.add.sprite(...)` for game entities in code — declare them in scene JSON.
|
|
659
|
+
|
|
660
|
+
The `scene-data-architecture` agent skill has the full guidance. The `scene-data-migration` skill walks an existing scene-as-code game through the conversion.
|
|
661
|
+
|
|
662
|
+
**Edit mode (host-driven):** when the host (home-ui) sends `{ type: 'umicat:setEditMode', enabled: true }` via `postMessage`, the SDK pauses every active non-Boot Phaser scene — entities stay rendered but `update`/physics/tweens stop. Sending `enabled: false` resumes. Used by the visual editor's read-only viewer in slice 1.
|
|
663
|
+
|
|
664
|
+
## Prefabs — entity types for runtime spawning (slice 11 Phase B.1, since 0.2.47)
|
|
665
|
+
|
|
666
|
+
Scene entities (above) cover *authored instances at design-time positions* — the player, a fixed boss, level decorations. They're the right pattern for layout-heavy games (top-down RPG, platformer, puzzle) where the user drags entities in the editor.
|
|
667
|
+
|
|
668
|
+
For **runtime-spawned types** — Galaga enemies in a wave, bullets, drops, particle effects — declare them as **prefabs** in `manifest.prefabs[]` instead. A prefab is a self-contained TYPE record: visual + physics + properties. Code spawns instances via `spawnPrefab(scene, prefabId, x, y)`. The visual editor surfaces prefabs in a separate Hierarchy tab so the user can tweak HP / speed / color / hitbox without touching code — the editor's value extends to genres where layout editing isn't useful.
|
|
669
|
+
|
|
670
|
+
### Manifest reference
|
|
671
|
+
|
|
672
|
+
```json
|
|
673
|
+
{
|
|
674
|
+
"schemaVersion": 1,
|
|
675
|
+
"scenes": [...],
|
|
676
|
+
"huds": [...],
|
|
677
|
+
"assets": [...],
|
|
678
|
+
"prefabs": [
|
|
679
|
+
{
|
|
680
|
+
"id": "enemy_grunt",
|
|
681
|
+
"kind": "code-rendered",
|
|
682
|
+
"role": "enemy",
|
|
683
|
+
"script": "src/visuals/enemy-grunt.ts",
|
|
684
|
+
"width": 40,
|
|
685
|
+
"height": 32,
|
|
686
|
+
"params": { "bodyColor": "#cc3344", "eyeColor": "#ffff66" },
|
|
687
|
+
"physics": {
|
|
688
|
+
"bodyW": 28,
|
|
689
|
+
"bodyH": 22,
|
|
690
|
+
"offsetX": 6,
|
|
691
|
+
"offsetY": 5,
|
|
692
|
+
"collideWorldBounds": false
|
|
693
|
+
},
|
|
694
|
+
"properties": {
|
|
695
|
+
"hp": 3,
|
|
696
|
+
"fireIntervalMs": 1800,
|
|
697
|
+
"scoreValue": 100
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
"id": "player_bullet",
|
|
702
|
+
"kind": "code-rendered",
|
|
703
|
+
"role": "bullet",
|
|
704
|
+
"script": "src/visuals/player-bullet.ts",
|
|
705
|
+
"width": 4,
|
|
706
|
+
"height": 14,
|
|
707
|
+
"physics": { "velocityY": -700 },
|
|
708
|
+
"properties": { "damage": 1 }
|
|
709
|
+
}
|
|
710
|
+
]
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Prefabs use the same flat schema as scene entities (SDK 0.3.0). The four prefab kinds — `sprite` / `rect` / `circle` / `code-rendered` — each carry their render fields at the prefab root (no nested `visual` sub-object). The `physics` block carries the body sizing the SDK applies after `physics.add.existing`, so behavior code doesn't have to call `body.setSize` / `setOffset` manually. Since 0.2.54 the **same `physics` block also works on authored scene entities** (see the scene-as-data section) — prefabs and scene entities share one body-level path.
|
|
715
|
+
|
|
716
|
+
**Field reference for `physics`:**
|
|
717
|
+
- `bodyW` / `bodyH` — body dimensions. Default: the visual's drawn extent (sprite texture size, rect/circle dims, or code-rendered `width` / `height` — 64 if unset).
|
|
718
|
+
- `offsetX` / `offsetY` — body offset, measured from the visual's top-left. Default: centers the body inside the visual. (For `code-rendered` entities the SDK shifts the body frame so the offset is measured from the top-left the same way it is for sprites/rect/circle — a Phaser `Graphics` has no Origin component, so the body math needs the shift. Fixed in 0.2.54; before that, code-rendered bodies landed at the bottom-right of the visual.)
|
|
719
|
+
- `immovable`, `velocityX`, `velocityY`, `collideWorldBounds`, `bounceX`, `bounceY` — passed through to the Arcade body.
|
|
720
|
+
|
|
721
|
+
### Code use
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
import { spawnPrefab } from '@umicat/phaser-sdk';
|
|
725
|
+
|
|
726
|
+
// In GameScene.create() — replaces `this.physics.add.image(x, y, 'tex')`
|
|
727
|
+
class GameScene extends Phaser.Scene {
|
|
728
|
+
async create() {
|
|
729
|
+
await loadWorldScene(this, this.sceneId);
|
|
730
|
+
|
|
731
|
+
// Spawn a 3×6 grid of enemies. Each is a registered EntityRegistry
|
|
732
|
+
// instance; behavior code can find them all via `registry.byRole('enemy')`.
|
|
733
|
+
for (let row = 0; row < 3; row++) {
|
|
734
|
+
for (let col = 0; col < 6; col++) {
|
|
735
|
+
const enemy = spawnPrefab(this, 'enemy_grunt', col * 90 + 90, row * 72 + 100);
|
|
736
|
+
this.enemies.add(enemy);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Per-instance overrides for a unique boss.
|
|
741
|
+
// `fields` carries flat render-field overrides (assetId / params /
|
|
742
|
+
// fillColor / etc.), deep-merged onto the prefab record.
|
|
743
|
+
const boss = spawnPrefab(this, 'enemy_grunt', 360, 200, {
|
|
744
|
+
properties: { hp: 30, scoreValue: 5000 },
|
|
745
|
+
fields: { params: { bodyColor: '#ff0066' } },
|
|
746
|
+
});
|
|
747
|
+
this.enemies.add(boss);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
fireBullet(x: number, y: number) {
|
|
751
|
+
const bullet = spawnPrefab(this, 'player_bullet', x, y);
|
|
752
|
+
this.playerBullets.add(bullet);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### API
|
|
758
|
+
|
|
759
|
+
| Function | Purpose |
|
|
760
|
+
|---|---|
|
|
761
|
+
| `spawnPrefab(scene, prefabId, x, y, overrides?, options?)` | Create + register a runtime instance. Returns the GameObject. |
|
|
762
|
+
| `getPrefab(scene, prefabId)` | Read a prefab record from the manifest. Throws if missing. |
|
|
763
|
+
| `listPrefabs(scene, role?)` | Enumerate prefabs, optionally filtered by role. |
|
|
764
|
+
| `registry.byPrefabId(prefabId)` | List every live instance of a prefab. Used by editor live-edit + game-level operations ("kill all enemies"). |
|
|
765
|
+
|
|
766
|
+
`SpawnPrefabOverrides` is a deep-partial of the prefab record:
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
interface SpawnPrefabOverrides {
|
|
770
|
+
role?: string;
|
|
771
|
+
visual?: DeepPartial<WorldVisual>;
|
|
772
|
+
physics?: DeepPartial<PrefabPhysics>;
|
|
773
|
+
properties?: Record<string, unknown>;
|
|
774
|
+
transform?: Partial<Omit<Transform, 'x' | 'y'>>; // rotation, scale, depth
|
|
775
|
+
}
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Each spawn gets a runtime-generated id of the form `<prefabId>#<n>` starting at 1. Use it (`go.getData('entityId')`) to disambiguate instances of the same prefab. The instance auto-unregisters on `destroy()` so dead enemies don't bloat the registry.
|
|
779
|
+
|
|
780
|
+
### When prefab vs scene entity
|
|
781
|
+
|
|
782
|
+
| Scenario | Use |
|
|
783
|
+
|---|---|
|
|
784
|
+
| The thing is unique and stays at a fixed position the user might drag | scene entity in `world/*.json` |
|
|
785
|
+
| The thing is one of many at runtime-decided positions (waves, drops, projectiles) | prefab + `spawnPrefab` |
|
|
786
|
+
| The thing has a type the user might want to tweak (HP, speed, color, hitbox) without code | prefab |
|
|
787
|
+
| The thing is purely procedural — a transient particle effect, a debug overlay | inline code (`this.add.sprite`), no registry needed |
|
|
788
|
+
|
|
789
|
+
When in doubt: **anything you'd `this.physics.add.sprite` more than twice for the same type is a prefab.**
|
|
790
|
+
|
|
791
|
+
### Anti-patterns
|
|
792
|
+
|
|
793
|
+
- ❌ Putting a prefab's per-spawn position in the prefab record. Position comes from `spawnPrefab(x, y)`; the prefab is *type-level*.
|
|
794
|
+
- ❌ Calling `body.setSize` / `setOffset` after `spawnPrefab`. The SDK applied them from the prefab's `physics` block. Re-applying breaks the editor's live-edit flow.
|
|
795
|
+
- ❌ Hardcoding tunable values (HP, fire rate, score) at the spawn site as overrides. They belong in the prefab record so the editor can edit them; overrides are for per-instance variation (a boss with 10× HP).
|
|
796
|
+
|
|
797
|
+
The `phaser-prefab-spawning` agent skill (Phase B.1) has the full guidance including a Galaga conversion walkthrough.
|
|
798
|
+
|
|
799
|
+
## Game config — top-level settings as data (slice 11 Phase B.3, since 0.2.50)
|
|
800
|
+
|
|
801
|
+
`public/game.json` is a small JSON file carrying the **canvas + physics defaults + controls hints** that today live as TS `const` exports in `src/config.ts`. Once extracted to data the visual editor's eventual "Game settings" panel (Phase B.5) lets the user tweak canvas size / orientation / gravity / controls without code edits.
|
|
802
|
+
|
|
803
|
+
### File reference
|
|
804
|
+
|
|
805
|
+
`public/game.json`:
|
|
806
|
+
|
|
807
|
+
```json
|
|
808
|
+
{
|
|
809
|
+
"orientation": "portrait",
|
|
810
|
+
"pixelArt": true,
|
|
811
|
+
"physics": {
|
|
812
|
+
"gravity": { "x": 0, "y": 980 }
|
|
813
|
+
},
|
|
814
|
+
"controls": {
|
|
815
|
+
"primary": "wasd",
|
|
816
|
+
"fire": "space"
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
All fields are optional. Use `orientation` *or* `width`+`height`, not both. `controls` is a hint table — the SDK doesn't bind keys from it; behavior code reads what it needs.
|
|
822
|
+
|
|
823
|
+
### Code use
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
// main.ts — requires top-level await (Vite default target supports it)
|
|
827
|
+
import { createUmicatGame, loadGameConfig } from '@umicat/phaser-sdk';
|
|
828
|
+
import { BootScene } from './scenes/BootScene';
|
|
829
|
+
import { GameScene } from './scenes/GameScene';
|
|
830
|
+
|
|
831
|
+
const cfg = await loadGameConfig();
|
|
832
|
+
createUmicatGame({
|
|
833
|
+
orientation: cfg?.orientation ?? 'portrait',
|
|
834
|
+
pixelArt: cfg?.pixelArt ?? false,
|
|
835
|
+
physics: cfg?.physics ? { default: 'arcade', arcade: { gravity: cfg.physics.gravity, debug: cfg.physics.debug } } : undefined,
|
|
836
|
+
scenes: [BootScene, GameScene],
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
`loadGameConfig` resolves to `null` on missing file — callers fall back to in-code defaults so games without `public/game.json` keep working.
|
|
841
|
+
|
|
842
|
+
### API
|
|
843
|
+
|
|
844
|
+
| Function | Purpose |
|
|
845
|
+
|---|---|
|
|
846
|
+
| `loadGameConfig(path?)` | Async fetch of `public/game.json` (or custom path). Returns `GameConfig | null`. |
|
|
847
|
+
|
|
848
|
+
### When game.json vs rules.json
|
|
849
|
+
|
|
850
|
+
| Value | Goes in |
|
|
851
|
+
|---|---|
|
|
852
|
+
| Canvas dimensions, orientation, `pixelArt` flag | `game.json` |
|
|
853
|
+
| Default Arcade gravity at game level | `game.json` |
|
|
854
|
+
| Tunable game balance (lives, fire cooldown, win threshold) | `rules.json` |
|
|
855
|
+
| Per-entity-type properties (enemy HP, drop chance) | `prefab.properties` |
|
|
856
|
+
|
|
857
|
+
`game.json` is for **boot-time settings** read once when constructing Phaser. `rules.json` is for **gameplay tunables** read by behavior code with fallbacks and reactive subscriptions.
|
|
858
|
+
|
|
859
|
+
## Rules — tunable game parameters as data (slice 11 Phase B.2, since 0.2.48)
|
|
860
|
+
|
|
861
|
+
`public/rules.json` is a free-form key-value tree where any value the user might want to tune lives — lives count, score thresholds, fire cooldowns, gravity, win conditions, controls. Replaces TS `const X = ...` declarations for these values. Behavior code reads via dot-notation paths; the visual editor's Rules mode (when it lands) lets the user tweak any field in a form.
|
|
862
|
+
|
|
863
|
+
### File reference
|
|
864
|
+
|
|
865
|
+
`public/rules.json`:
|
|
866
|
+
|
|
867
|
+
```json
|
|
868
|
+
{
|
|
869
|
+
"balance": {
|
|
870
|
+
"lives": 3,
|
|
871
|
+
"shootCooldownMs": 210,
|
|
872
|
+
"playerSpeed": 340,
|
|
873
|
+
"scoreThresholds": [1000, 2500, 5000, 10000]
|
|
874
|
+
},
|
|
875
|
+
"physics": {
|
|
876
|
+
"gravity": { "x": 0, "y": 980 }
|
|
877
|
+
},
|
|
878
|
+
"controls": {
|
|
879
|
+
"primary": "wasd",
|
|
880
|
+
"fire": "space"
|
|
881
|
+
},
|
|
882
|
+
"winCondition": {
|
|
883
|
+
"type": "score",
|
|
884
|
+
"target": 50000
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
No required shape — group however makes sense (`balance`, `physics`, `controls`, `difficulty`, …). Optional `_meta_<key>` siblings carry editor-form hints (label, min, max, step, enum options); the editor's form-renderer uses them when present.
|
|
890
|
+
|
|
891
|
+
### Code use
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
import { preloadRules, getRule, onRuleChange } from '@umicat/phaser-sdk';
|
|
895
|
+
|
|
896
|
+
class BootScene extends Phaser.Scene {
|
|
897
|
+
preload() {
|
|
898
|
+
preloadManifest(this);
|
|
899
|
+
preloadRules(this); // tolerant of missing file
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
class GameScene extends Phaser.Scene {
|
|
904
|
+
async create() {
|
|
905
|
+
await loadWorldScene(this, this.sceneId);
|
|
906
|
+
|
|
907
|
+
// One-time reads
|
|
908
|
+
this.lives = getRule(this, 'balance.lives', 3);
|
|
909
|
+
this.shootCooldown = getRule(this, 'balance.shootCooldownMs', 200);
|
|
910
|
+
|
|
911
|
+
// Reactive: react to live editor edits (e.g. user lowers shootCooldownMs
|
|
912
|
+
// while playing → next shot uses the new value)
|
|
913
|
+
const unsub = onRuleChange(this, 'balance.shootCooldownMs', (v) => {
|
|
914
|
+
this.shootCooldown = v as number;
|
|
915
|
+
});
|
|
916
|
+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, unsub);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
### API
|
|
922
|
+
|
|
923
|
+
| Function | Purpose |
|
|
924
|
+
|---|---|
|
|
925
|
+
| `preloadRules(scene)` | Queue load of `public/rules.json` in BootScene preload. Missing file is fine. |
|
|
926
|
+
| `getRule(scene, path, fallback)` | Read a value by dot-path. Returns fallback when unset. |
|
|
927
|
+
| `getRules(scene)` | Read the full tree (rare; usually for debug HUDs). |
|
|
928
|
+
| `onRuleChange(scene, path, handler)` | Subscribe to live edits. Fires on the exact path AND any descendant. Returns an unsubscribe function. |
|
|
929
|
+
| `patchRule(scene, path, value)` | Internal — called by `EditorBridge` on `umicat:editor:patchRule`. Direct use from game code is unusual. |
|
|
930
|
+
|
|
931
|
+
### When rule vs prefab.properties
|
|
932
|
+
|
|
933
|
+
| Scenario | Use |
|
|
934
|
+
|---|---|
|
|
935
|
+
| Value is per-instance of an entity type (this enemy's HP, that drop's chance) | `prefab.properties` |
|
|
936
|
+
| Value is global to the game (starting lives, fire cooldown, gravity, win threshold) | `rules.<group>.<key>` |
|
|
937
|
+
| Value is computed from gameplay state (current score, time elapsed) | code variable, not data |
|
|
938
|
+
|
|
939
|
+
A rule of thumb: if changing the value affects ONE entity type, it's a prefab property. If it affects the whole game or many entity types, it's a rule.
|
|
940
|
+
|
|
941
|
+
### Anti-patterns
|
|
942
|
+
|
|
943
|
+
- ❌ **Putting algorithmic constants in rules** (e.g. `"physics.bulletSpeedFalloff": 0.7321`). Implementation detail. Stay in code.
|
|
944
|
+
- ❌ **Putting per-instance prefab properties in rules** (e.g. `rules.enemy.grunt.hp`). Belongs in the prefab's `properties.hp`.
|
|
945
|
+
- ❌ **Re-reading the same rule in a hot loop without subscribing.** `getRule` is cheap but not free. Cache the value in a class field; subscribe to changes if needed.
|
|
946
|
+
- ❌ **Deep paths used in `getRule` and `onRuleChange` that don't match the data shape.** No schema validation — fallbacks fire silently. Test by reading the rule once at scene start with a sentinel fallback (e.g. `getRule(this, 'balance.lives', -1)`); if you ever read `-1`, the path is wrong.
|
|
947
|
+
|
|
948
|
+
The `game-rules-extraction` agent skill teaches the agent to push tunables into `rules.json` automatically.
|
|
949
|
+
|
|
950
|
+
## Waves — spawn schedules as data (slice 11 Phase B.4, since 0.2.49)
|
|
951
|
+
|
|
952
|
+
A **wave schedule** is a time-ordered sequence of spawn instructions in `public/waves/<id>.json` that references prefab ids. Models Galaga formations, infinite-runner difficulty curves, survivor-game wave timing — anything where the game spawns entities by a script.
|
|
953
|
+
|
|
954
|
+
The SDK provides `runWaveSchedule(scene, id, callbacks)` that drives the script and fires callbacks at each beat. Replaces hand-rolled wave loops (`time + interval` math, manual TimerEvent management, custom end conditions) in `GameScene.ts`.
|
|
955
|
+
|
|
956
|
+
### File reference
|
|
957
|
+
|
|
958
|
+
`public/waves/stage-1.json`:
|
|
959
|
+
|
|
960
|
+
```json
|
|
961
|
+
{
|
|
962
|
+
"schemaVersion": 1,
|
|
963
|
+
"id": "stage-1",
|
|
964
|
+
"name": "Stage 1",
|
|
965
|
+
"waves": [
|
|
966
|
+
{
|
|
967
|
+
"id": "wave-1",
|
|
968
|
+
"spawns": [
|
|
969
|
+
{
|
|
970
|
+
"kind": "formation",
|
|
971
|
+
"prefabId": "enemy_grunt",
|
|
972
|
+
"formation": { "shape": "grid", "rows": 3, "cols": 6, "spacing": { "x": 90, "y": 72 }, "origin": { "x": 90, "y": 120 } },
|
|
973
|
+
"startMs": 0,
|
|
974
|
+
"intervalMs": 100
|
|
975
|
+
}
|
|
976
|
+
],
|
|
977
|
+
"endCondition": "allDead"
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
"id": "wave-2",
|
|
981
|
+
"delayMs": 1500,
|
|
982
|
+
"spawns": [
|
|
983
|
+
{
|
|
984
|
+
"kind": "formation",
|
|
985
|
+
"prefabId": "enemy_bomber",
|
|
986
|
+
"formation": { "shape": "v-shape", "cols": 5, "spacing": { "x": 60, "y": 40 }, "origin": { "x": 360, "y": 100 } },
|
|
987
|
+
"startMs": 0,
|
|
988
|
+
"intervalMs": 180
|
|
989
|
+
}
|
|
990
|
+
],
|
|
991
|
+
"endCondition": "allDead"
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"id": "boss",
|
|
995
|
+
"delayMs": 2500,
|
|
996
|
+
"spawns": [
|
|
997
|
+
{ "kind": "point", "prefabId": "enemy_boss", "x": 360, "y": 200, "atMs": 0 }
|
|
998
|
+
],
|
|
999
|
+
"endCondition": "allDead"
|
|
1000
|
+
}
|
|
1001
|
+
]
|
|
1002
|
+
}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
**Spawn instruction kinds (v1):**
|
|
1006
|
+
- `point` — single spawn at `{ x, y }` at `atMs` (relative to wave start).
|
|
1007
|
+
- `formation` — multiple spawns expanded from a `FormationRecord` (`shape: 'grid' | 'line' | 'v-shape' | 'arc'`), each at `startMs + i * intervalMs`.
|
|
1008
|
+
|
|
1009
|
+
**Wave end conditions:**
|
|
1010
|
+
- `"allDead"` (default) — wait until every spawned instance from this wave has destroyed.
|
|
1011
|
+
- `{ "type": "time", "ms": N }` — fixed duration from wave start.
|
|
1012
|
+
- `{ "type": "count", "killed": N }` — wait until N instances destroyed.
|
|
1013
|
+
|
|
1014
|
+
`loop: true` at the top of the schedule restarts wave 0 after the last one ends — for endless modes.
|
|
1015
|
+
|
|
1016
|
+
### Code use
|
|
1017
|
+
|
|
1018
|
+
```ts
|
|
1019
|
+
import { runWaveSchedule, spawnPrefab } from '@umicat/phaser-sdk';
|
|
1020
|
+
|
|
1021
|
+
class GameScene extends Phaser.Scene {
|
|
1022
|
+
async create() {
|
|
1023
|
+
await loadWorldScene(this, this.sceneId);
|
|
1024
|
+
this.enemies = this.physics.add.group();
|
|
1025
|
+
// ... wire colliders ...
|
|
1026
|
+
|
|
1027
|
+
runWaveSchedule(this, 'stage-1', {
|
|
1028
|
+
onSpawn: ({ prefabId, x, y, overrides }) => {
|
|
1029
|
+
const go = spawnPrefab(this, prefabId, x, y, overrides as Record<string, unknown>);
|
|
1030
|
+
this.enemies.add(go);
|
|
1031
|
+
return go; // returned so the controller can track allDead
|
|
1032
|
+
},
|
|
1033
|
+
onWaveStart: (i, wave) => {
|
|
1034
|
+
this.events.emit('hudWave', i + 1);
|
|
1035
|
+
},
|
|
1036
|
+
onWaveEnd: (i) => {
|
|
1037
|
+
// play a "wave cleared" SFX, brief tween
|
|
1038
|
+
},
|
|
1039
|
+
onScheduleEnd: () => {
|
|
1040
|
+
this.scene.start('VictoryScene');
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
### API
|
|
1048
|
+
|
|
1049
|
+
| Function / type | Purpose |
|
|
1050
|
+
|---|---|
|
|
1051
|
+
| `loadWaveSchedule(scene, id)` | Async fetch via Phaser loader. Cached. Resolves to `WaveScheduleRecord`. |
|
|
1052
|
+
| `runWaveSchedule(scene, id, callbacks)` | Start a schedule. Returns a `WaveController` immediately; loads + starts asynchronously. |
|
|
1053
|
+
| `WaveController.pause()` / `.resume()` | Suspend / resume timers (e.g. pause menu). |
|
|
1054
|
+
| `WaveController.skipTo(index)` | Jump to a wave (debug / cheat). |
|
|
1055
|
+
| `WaveController.stop()` | Halt the schedule + fire `onScheduleEnd`. |
|
|
1056
|
+
| `WaveController.currentWaveIndex()` / `.isComplete()` | Status queries. |
|
|
1057
|
+
|
|
1058
|
+
**Always return the GameObject from `onSpawn`** when using `endCondition: 'allDead'` or `{ type: 'count' }` — the controller tracks `Phaser.GameObjects.Events.DESTROY` events on returned instances. Returning `void` short-circuits lifecycle tracking (acceptable for `{ type: 'time' }` end conditions where the schedule advances by clock, not kills).
|
|
1059
|
+
|
|
1060
|
+
### When wave vs spawn-loop-in-code
|
|
1061
|
+
|
|
1062
|
+
| Scenario | Use |
|
|
1063
|
+
|---|---|
|
|
1064
|
+
| Time-ordered formations, staged waves, boss-on-wave-N | `waves/*.json` + `runWaveSchedule` |
|
|
1065
|
+
| Conditional spawns ("spawn wave 4 only if player has ≥ 5000 score") | Code, with `WaveController.skipTo` for advancing |
|
|
1066
|
+
| Spawn-on-trigger (enemy spawns when player enters a region) | Code calling `spawnPrefab` directly — not a wave |
|
|
1067
|
+
| Single boss with no minions | A single-wave schedule, OR a direct `spawnPrefab` if no formation |
|
|
1068
|
+
| Procedural generation (rogue-like room population) | Code reads `prefabs[]` + an RNG; not a wave schedule |
|
|
1069
|
+
|
|
1070
|
+
### Anti-patterns
|
|
1071
|
+
|
|
1072
|
+
- ❌ **Manual `time + interval` loops with `scene.time.addEvent` for wave timing.** That's exactly what `runWaveSchedule` automates.
|
|
1073
|
+
- ❌ **Per-spawn hardcoded `(x, y)` in code when the agent could use a `formation: { shape: 'grid' }`** — the latter is editable in the waves timeline editor (Phase B.5).
|
|
1074
|
+
- ❌ **Spawning via `this.physics.add.sprite(...)` from inside `onSpawn`.** Use `spawnPrefab` so the entity goes through the prefab pipeline (physics, properties, registry tagging).
|
|
1075
|
+
- ❌ **Not returning the GameObject from `onSpawn`** when `endCondition: 'allDead'`. The wave never advances.
|
|
1076
|
+
|
|
1077
|
+
The `phaser-wave-schedule` agent skill teaches the wave authoring pattern with worked examples (Galaga, runner difficulty curve).
|
|
1078
|
+
|
|
1079
|
+
## HUD scenes (visual editor slice 5+, since 0.2.29)
|
|
1080
|
+
|
|
1081
|
+
A HUD scene is a separate `Phaser.Scene` that runs in parallel with the active world scene and renders anchor-positioned UI on top of it. The visual editor's `HUD` mode edits these. Five widget kinds ship today: **text**, **image**, **icon-button**, **progress-bar**, **panel**.
|
|
1082
|
+
|
|
1083
|
+
### Manifest reference
|
|
1084
|
+
|
|
1085
|
+
```json
|
|
1086
|
+
{
|
|
1087
|
+
"scenes": [
|
|
1088
|
+
{ "id": "main", "type": "world", "file": "world/main.json", "hud": "game-hud" }
|
|
1089
|
+
],
|
|
1090
|
+
"huds": [
|
|
1091
|
+
{ "id": "game-hud", "file": "hud/game-hud.json" }
|
|
1092
|
+
]
|
|
1093
|
+
}
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
When `loadWorldScene(this, sceneId)` runs, it auto-launches the `UmicatHudScene` with the matching `hudId`. You don't call `loadHudScene` yourself — `loadWorldScene` does it for you. Multiple world scenes can reference the same HUD; editing `game-hud` updates every scene that points at it.
|
|
1097
|
+
|
|
1098
|
+
### Scene file shape
|
|
1099
|
+
|
|
1100
|
+
`public/scenes/hud/game-hud.json`:
|
|
1101
|
+
|
|
1102
|
+
```json
|
|
1103
|
+
{
|
|
1104
|
+
"schemaVersion": 1,
|
|
1105
|
+
"id": "game-hud",
|
|
1106
|
+
"name": "Game HUD",
|
|
1107
|
+
"type": "hud",
|
|
1108
|
+
"design": {
|
|
1109
|
+
"designAspectRatio": "16:9",
|
|
1110
|
+
"safeArea": { "top": 16, "right": 16, "bottom": 16, "left": 16 }
|
|
1111
|
+
},
|
|
1112
|
+
"entities": [ /* HudEntity[] */ ]
|
|
1113
|
+
}
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
### Anchor + offset positioning (no transform)
|
|
1117
|
+
|
|
1118
|
+
HUD entities use a **9-grid anchor + numeric offset** instead of world coords:
|
|
1119
|
+
|
|
1120
|
+
```json
|
|
1121
|
+
{
|
|
1122
|
+
"id": "score-text",
|
|
1123
|
+
"kind": "text",
|
|
1124
|
+
"anchor": { "side": "top-left", "offsetX": 16, "offsetY": 16 },
|
|
1125
|
+
"layer": "base",
|
|
1126
|
+
"source": { "mode": "static", "text": "Score: 0" }
|
|
1127
|
+
}
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
HUD entities use the same flat schema as world entities (SDK 0.3.0) — per-kind fields (`source` / `assetId` / `label` / `value` / etc.) live at the entity root, alongside `id` / `kind` / `anchor` / `layer` / `z`.
|
|
1131
|
+
|
|
1132
|
+
`side` is one of `top-left | top | top-right | left | center | right | bottom-left | bottom | bottom-right`. The widget is anchored to that corner/edge of the canvas, then offset inward (or outward) by `offsetX/Y`. Resizing the canvas (orientation change, device rotation) re-resolves anchors automatically.
|
|
1133
|
+
|
|
1134
|
+
`layer` is one of `base | overlay | modal` (default `base`) — controls render z-order; `modal` sits on top of `overlay` sits on top of `base`. `z` adds further ordering within a layer.
|
|
1135
|
+
|
|
1136
|
+
### Widget reference
|
|
1137
|
+
|
|
1138
|
+
#### `text`
|
|
1139
|
+
|
|
1140
|
+
```json
|
|
1141
|
+
{
|
|
1142
|
+
"kind": "text",
|
|
1143
|
+
"anchor": { "side": "top-left", "offsetX": 16, "offsetY": 16 },
|
|
1144
|
+
"source": { "mode": "static", "text": "Hello" },
|
|
1145
|
+
"fontSize": 24, "color": "#ffffff", "align": "left"
|
|
1146
|
+
}
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
`source` is either `{ mode: "static", text }` for a literal string OR `{ mode: "dynamic", binding, prefix?, suffix?, fallback? }` for a value read live from Phaser's game registry (see "Dynamic bindings" below).
|
|
1150
|
+
|
|
1151
|
+
#### `image`
|
|
1152
|
+
|
|
1153
|
+
```json
|
|
1154
|
+
{
|
|
1155
|
+
"kind": "image",
|
|
1156
|
+
"anchor": { "side": "top-right", "offsetX": -16, "offsetY": 16 },
|
|
1157
|
+
"assetId": "coin-icon",
|
|
1158
|
+
"width": 32, "height": 32
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
`assetId` resolves through `manifest.assets` exactly like world sprite entities. Optional `tint`, `alpha`, `frame` (atlas frame name or sprite-sheet index).
|
|
1163
|
+
|
|
1164
|
+
#### `icon-button`
|
|
1165
|
+
|
|
1166
|
+
```json
|
|
1167
|
+
{
|
|
1168
|
+
"kind": "icon-button",
|
|
1169
|
+
"anchor": { "side": "bottom-right", "offsetX": -32, "offsetY": -32 },
|
|
1170
|
+
"layer": "overlay",
|
|
1171
|
+
"label": "Pause",
|
|
1172
|
+
"shape": "rounded-rect",
|
|
1173
|
+
"width": 96, "height": 48,
|
|
1174
|
+
"fillColor": "#3b82f6", "textColor": "#ffffff"
|
|
1175
|
+
}
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
Click handler is decoupled — the SDK emits a Phaser scene event `'hud:press'` with the entity id when the button is pressed. Subscribe in your gameplay code:
|
|
1179
|
+
|
|
1180
|
+
```ts
|
|
1181
|
+
// In GameScene.ts (or any active scene)
|
|
1182
|
+
this.events.on('hud:press', (id: string) => {
|
|
1183
|
+
if (id === 'pause-button') { this.scene.pause(); /* etc. */ }
|
|
1184
|
+
});
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
For now there's no built-in action binding (no `behavior: { type: 'pause-game' }` etc.) — you wire each button by its id explicitly. That's coming in a later slice.
|
|
1188
|
+
|
|
1189
|
+
#### `progress-bar`
|
|
1190
|
+
|
|
1191
|
+
```json
|
|
1192
|
+
{
|
|
1193
|
+
"kind": "progress-bar",
|
|
1194
|
+
"anchor": { "side": "top-left", "offsetX": 16, "offsetY": 50 },
|
|
1195
|
+
"value": { "mode": "dynamic", "binding": "playerHp", "fallback": 0 },
|
|
1196
|
+
"max": { "mode": "dynamic", "binding": "playerMaxHp", "fallback": 100 },
|
|
1197
|
+
"width": 200, "height": 16,
|
|
1198
|
+
"fillColor": "#22c55e", "backgroundColor": "#0f172a",
|
|
1199
|
+
"borderRadius": 4, "borderColor": "#ffffff", "borderWidth": 1
|
|
1200
|
+
}
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
`value` and `max` are independently bindable — either can be static (`{ mode: 'static', value: 100 }`) or dynamic. Bar fills from 0 → 1 as `value / max`. Subscribes to `changedata-<binding>` registry events and redraws on change.
|
|
1204
|
+
|
|
1205
|
+
#### `panel`
|
|
1206
|
+
|
|
1207
|
+
```json
|
|
1208
|
+
{
|
|
1209
|
+
"kind": "panel",
|
|
1210
|
+
"anchor": { "side": "center" },
|
|
1211
|
+
"width": 320, "height": 200,
|
|
1212
|
+
"backgroundColor": "#000000", "backgroundAlpha": 0.5,
|
|
1213
|
+
"borderColor": "#ffffff", "borderWidth": 1, "borderRadius": 8
|
|
1214
|
+
}
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
A colored rectangle with optional border + rounded corners. Use as a visual frame behind groups of HUD widgets (no children layout in v1 — child widgets are sibling entities; you align them with anchor + offset).
|
|
1218
|
+
|
|
1219
|
+
### Textured backgrounds for `icon-button` and `panel` (since 0.2.37)
|
|
1220
|
+
|
|
1221
|
+
Both `icon-button` and `panel` accept an asset image as the background instead of the default colored fill. Three flavors, each stretchable with crisp corners via the SDK's built-in 9-slice renderer:
|
|
1222
|
+
|
|
1223
|
+
**1. Single-image 9-slice** (since 0.2.37) — manifest asset is `kind: 'image'` with `ninePatch: { leftWidth, rightWidth, topHeight, bottomHeight }`. The whole texture slices into 9 regions; corners stay native, edges + middle stretch.
|
|
1224
|
+
|
|
1225
|
+
```json
|
|
1226
|
+
{
|
|
1227
|
+
"kind": "icon-button",
|
|
1228
|
+
"label": "Start",
|
|
1229
|
+
"width": 240, "height": 64,
|
|
1230
|
+
"backgroundAssetId": "button-panel"
|
|
1231
|
+
}
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
**2. Per-frame on a uniform grid** (since 0.2.38) — manifest asset is `kind: 'spritesheet'` with `ninePatch: { perFrame: {...} }`. Used when every cell of a button-pack sheet is the same size and shares the same corner radius (typical itch.io packs). Pair with `backgroundFrame: <cell index>` to pick which cell.
|
|
1235
|
+
|
|
1236
|
+
```json
|
|
1237
|
+
{
|
|
1238
|
+
"kind": "icon-button",
|
|
1239
|
+
"backgroundAssetId": "button-pack",
|
|
1240
|
+
"backgroundFrame": 3
|
|
1241
|
+
}
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
**3. Region atlas** (since 0.2.42) — manifest asset is `kind: 'atlas'` + `atlasFormat: 'json'` + `atlasPath: '...'`. The atlas JSON file carries named frame rects, each optionally with its own inline `ninePatch`. Pair with `backgroundRegion: '<frame name>'` to pick the region. Mutually exclusive with `backgroundFrame` — when both are set, `backgroundRegion` wins.
|
|
1245
|
+
|
|
1246
|
+
```json
|
|
1247
|
+
{
|
|
1248
|
+
"kind": "icon-button",
|
|
1249
|
+
"backgroundAssetId": "ui-buttons",
|
|
1250
|
+
"backgroundRegion": "btn-primary-idle"
|
|
1251
|
+
}
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
Atlas JSON shape — Phaser-native JSON-Hash with an optional per-frame `ninePatch` field that Phaser's parser ignores but the SDK reads via a side-cache:
|
|
1255
|
+
|
|
1256
|
+
```json
|
|
1257
|
+
{
|
|
1258
|
+
"frames": {
|
|
1259
|
+
"btn-primary-idle": {
|
|
1260
|
+
"frame": { "x": 7, "y": 9, "w": 18, "h": 30 },
|
|
1261
|
+
"sourceSize": { "w": 18, "h": 30 },
|
|
1262
|
+
"ninePatch": { "leftWidth": 4, "rightWidth": 4, "topHeight": 4, "bottomHeight": 4 }
|
|
1263
|
+
},
|
|
1264
|
+
"btn-primary-pressed": {
|
|
1265
|
+
"frame": { "x": 39, "y": 9, "w": 18, "h": 30 },
|
|
1266
|
+
"sourceSize": { "w": 18, "h": 30 }
|
|
1267
|
+
}
|
|
1268
|
+
},
|
|
1269
|
+
"meta": { "image": "ui-buttons.png", "size": { "w": 128, "h": 96 }, "scale": "1" }
|
|
1270
|
+
}
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
The Region Editor in the home-ui (visual editor slice 10) writes this shape for you — drag-tag named regions on a single PNG, optionally toggle 9-slice per region, save. Regions without `ninePatch` render as plain stretched images of that region; regions with `ninePatch` render via the 9-slice path.
|
|
1274
|
+
|
|
1275
|
+
### Dynamic bindings — driving the HUD from gameplay
|
|
1276
|
+
|
|
1277
|
+
For values that change during play (score, HP, timer, lives), use **Phaser's game registry** as the bridge. The HUD widget references a `binding` key; gameplay code calls `this.registry.set(key, value)` and the HUD auto-refreshes.
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
// In GameScene.ts create():
|
|
1281
|
+
this.registry.set('score', 0);
|
|
1282
|
+
this.registry.set('playerHp', 100);
|
|
1283
|
+
this.registry.set('playerMaxHp', 100);
|
|
1284
|
+
|
|
1285
|
+
// Wherever score changes:
|
|
1286
|
+
this.registry.set('score', this.score + 10);
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
The HUD widget's text / progress fill updates synchronously on the next frame — no manual `setText` / re-render call needed. Registry is `game.registry` under the hood, shared across all scenes.
|
|
1290
|
+
|
|
1291
|
+
**Pick stable, lowercase keys**: `score`, `coins`, `playerHp`, `lives`, `currentLevel`, `xp`. The binding key goes into a `changedata-<key>` event name; spaces or special characters break it.
|
|
1292
|
+
|
|
1293
|
+
**No declare ceremony**: just `set` the key. The HUD picks it up. The `umicat.gameData` module is for *persistent* shared values (across sessions); for in-game reactive HUD, registry is the right tool.
|
|
1294
|
+
|
|
1295
|
+
### Editor mode (for visual-editor users only)
|
|
1296
|
+
|
|
1297
|
+
The visual editor's `World/HUD` toggle pauses the active world scene and lets the user drag widgets / edit anchor + offset / live-tune visual fields. Edits flow back to `public/scenes/hud/<id>.json` on save (Cmd+S / Edit→Play / Publish). Auto-save during editing only persists JSON to disk — no rebuild — so the iframe doesn't reload mid-edit.
|
|
1298
|
+
|
|
1299
|
+
For game code, the editor is invisible — you write HUD scene JSON the same way regardless of whether the user uses the editor.
|
|
1300
|
+
|
|
1301
|
+
## Collision + Y-sort (visual editor slice 8, since 0.2.44)
|
|
1302
|
+
|
|
1303
|
+
Two new fields on `AssetRecord` give per-asset collision shapes and footprint
|
|
1304
|
+
anchors for top-down draw-order sorting. The SDK consumes them automatically
|
|
1305
|
+
at sprite spawn time. `applyAssetHitbox` is also exported for behavior code
|
|
1306
|
+
that attaches a physics body after spawn.
|
|
1307
|
+
|
|
1308
|
+
### `Asset.hitbox` — collision body shape
|
|
1309
|
+
|
|
1310
|
+
Per-asset rect (in v1) in source-pixel coordinates relative to the
|
|
1311
|
+
asset's frame top-left. Two shapes:
|
|
1312
|
+
|
|
1313
|
+
```ts
|
|
1314
|
+
// Single — one body across all frames (trees, walls, idle characters)
|
|
1315
|
+
hitbox: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 }
|
|
1316
|
+
|
|
1317
|
+
// Per-frame — combat sheets where attack frames need a wider hitbox
|
|
1318
|
+
hitbox: {
|
|
1319
|
+
default: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 },
|
|
1320
|
+
frames: {
|
|
1321
|
+
8: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
|
|
1322
|
+
9: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
|
|
1323
|
+
10: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
|
|
1324
|
+
11: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 }
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
`kind: 'rect'` is the only valid value in v1; `'circle'` is reserved for v2
|
|
1330
|
+
and the union widens as a pure addition. The `isPerFrameHitbox(hitbox)` type
|
|
1331
|
+
guard narrows the union.
|
|
1332
|
+
|
|
1333
|
+
### `Asset.depthAnchor` — Y-sort footprint pixel
|
|
1334
|
+
|
|
1335
|
+
```ts
|
|
1336
|
+
depthAnchor: { x: 16, y: 28 } // pixel within frame 0 the SDK aligns sprite.y with
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
The SDK applies `sprite.setOrigin(x / frameW, y / frameH)` at spawn so the
|
|
1340
|
+
sprite's world `y` equals the world y of this pixel. Typical: feet for
|
|
1341
|
+
characters, trunk base for trees, foundation midpoint for buildings.
|
|
1342
|
+
|
|
1343
|
+
### `applyAssetHitbox(sprite, asset)` — apply at runtime
|
|
1344
|
+
|
|
1345
|
+
Called automatically by `createSprite` at scene boot. **Also callable** by
|
|
1346
|
+
behavior code after attaching a body later:
|
|
1347
|
+
|
|
1348
|
+
```ts
|
|
1349
|
+
import { applyAssetHitbox } from '@umicat/phaser-sdk';
|
|
1350
|
+
|
|
1351
|
+
const player = registry.getById('player');
|
|
1352
|
+
const asset = manifest.assets.find(a => a.id === player.getData('assetId'));
|
|
1353
|
+
|
|
1354
|
+
scene.physics.add.existing(player);
|
|
1355
|
+
applyAssetHitbox(player, asset); // reads asset.hitbox, applies to body
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
Semantics:
|
|
1359
|
+
|
|
1360
|
+
- **No-op (silent)** when `asset.hitbox` is unset. Safe to call defensively.
|
|
1361
|
+
- **Dev-warn (NOT throw)** when called on a sprite without a physics body.
|
|
1362
|
+
Most likely cause: call order — `scene.physics.add.existing(sprite)` must
|
|
1363
|
+
come first.
|
|
1364
|
+
- **For per-frame hitboxes**: installs an `ANIMATION_UPDATE` listener that
|
|
1365
|
+
swaps `body.setSize`/`setOffset` on every frame change during animation
|
|
1366
|
+
playback. Idempotent — re-applying tears down the old listener first.
|
|
1367
|
+
|
|
1368
|
+
**v1 caveat**: manual `sprite.setFrame(idx)` calls outside of animation
|
|
1369
|
+
playback do NOT swap the body. Combat sheets are animation-driven, so this
|
|
1370
|
+
rarely bites in practice.
|
|
1371
|
+
|
|
1372
|
+
### Scene-level `ySort: true` — per-frame depth sort
|
|
1373
|
+
|
|
1374
|
+
```json
|
|
1375
|
+
// public/scenes/world/main.json
|
|
1376
|
+
{
|
|
1377
|
+
"schemaVersion": 1,
|
|
1378
|
+
"id": "main",
|
|
1379
|
+
"type": "world",
|
|
1380
|
+
"ySort": true,
|
|
1381
|
+
"entities": [ ... ]
|
|
1382
|
+
}
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
When set, `loadWorldScene` installs a per-frame update hook that walks the
|
|
1386
|
+
entity registry and assigns `sprite.depth = sprite.y` to every entity.
|
|
1387
|
+
**Per-game cost**: O(n) per frame; 200 entities at 60 FPS = 12,000
|
|
1388
|
+
`setDepth` calls/sec — well within Phaser's render budget.
|
|
1389
|
+
|
|
1390
|
+
Two opt-out mechanisms (orthogonal, cover different intents):
|
|
1391
|
+
|
|
1392
|
+
```ts
|
|
1393
|
+
// explicit constant depth → ySort skips the entity entirely
|
|
1394
|
+
{ "transform": { "x": 100, "y": 100, "depth": 1000 } } // cloud always on top
|
|
1395
|
+
|
|
1396
|
+
// behavior-managed depth → ySort doesn't touch the entity
|
|
1397
|
+
{ "transform": { "x": 100, "y": 100 }, "properties": { "skipYSort": true } }
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
### The conditional rule (when to call `applyAssetHitbox`)
|
|
1401
|
+
|
|
1402
|
+
```ts
|
|
1403
|
+
const asset = manifest.assets.find(a => a.id === sprite.getData('assetId'));
|
|
1404
|
+
scene.physics.add.existing(sprite);
|
|
1405
|
+
|
|
1406
|
+
if (asset?.hitbox) {
|
|
1407
|
+
applyAssetHitbox(sprite, asset); // metadata is source of truth
|
|
1408
|
+
} else {
|
|
1409
|
+
sprite.body.setSize(30, 20); // hardcode from gameplay intent
|
|
1410
|
+
sprite.body.setOffset(5, 5);
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
Both paths are correct depending on context:
|
|
1415
|
+
|
|
1416
|
+
- **`applyAssetHitbox` path** — sprite uses an Asset that has `hitbox`
|
|
1417
|
+
metadata. Pack import sets it via vision; users refine via the Hitbox
|
|
1418
|
+
Editor. Hardcoding `setSize` afterward would override the user's
|
|
1419
|
+
authored values.
|
|
1420
|
+
- **Hardcode path** — primitive sprites (`scene.add.rectangle`), AI-gen
|
|
1421
|
+
images with no vision pass, chat-only games with no scene-as-data
|
|
1422
|
+
manifest. There's nothing to apply; hardcoding is the right answer.
|
|
1423
|
+
|
|
1424
|
+
The `asset-hitbox-physics` agent skill (in
|
|
1425
|
+
`plugins/unboxy-phaser/skills/asset-hitbox-physics/SKILL.md`) walks through
|
|
1426
|
+
this decision tree with worked examples.
|
|
1427
|
+
|
|
1428
|
+
### Authoring metadata (the editor surface)
|
|
1429
|
+
|
|
1430
|
+
Users author `hitbox` + `depthAnchor` in the home-ui's Hitbox Editor modal —
|
|
1431
|
+
launched from the Assets panel's per-card hover button (green hitbox icon).
|
|
1432
|
+
Supports both Same-for-all-frames and Per-frame overrides modes, with a
|
|
1433
|
+
frame strip + override-dot indicators for the per-frame case.
|
|
1434
|
+
|
|
1435
|
+
Vision pre-fills the single-shape form at pack-import time for character /
|
|
1436
|
+
tree / building / rock / decoration subjects (narrow trunk for trees, foot
|
|
1437
|
+
footprint for characters, foundation for buildings).
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
- Do **not** block scene creation on `umicatReady`. Games must cold-start immediately; hydrate when the promise resolves.
|
|
1441
|
+
- Do **not** save on every frame / every score tick. Debounce or only save on meaningful events (game over, level clear).
|
|
1442
|
+
- Do **not** store large binary blobs (images, audio) as save values — use a dedicated blob API when it ships.
|
|
1443
|
+
- Do **not** use `localStorage` directly for persistent data when `umicat.saves` is available — you'll lose cross-device sync when we ship provisional accounts.
|
|
1444
|
+
- Do **not** put secrets, private messages, or per-user data in `gameData` — it's public. That's `saves`.
|
|
1445
|
+
- Do **not** write to `gameData` on every score tick either — submit once at game-over or level-clear.
|
|
1446
|
+
|
|
1447
|
+
## Tilemap per-tile metadata + collision (visual editor slice 6 Phase C, since 0.2.115)
|
|
1448
|
+
|
|
1449
|
+
Each tile in a tileset can carry per-tile metadata (collision flag, damage,
|
|
1450
|
+
terrain tag, movement modifier, etc.). The SDK reads it from
|
|
1451
|
+
`asset.tileset.tiles` and:
|
|
1452
|
+
|
|
1453
|
+
1. **Auto-wires solid-tile collision** at scene load via Phaser's native
|
|
1454
|
+
`layer.setCollisionByProperty({ solid: true })`. Behavior code only
|
|
1455
|
+
needs `scene.physics.add.collider(player, layer)` — every tile flagged
|
|
1456
|
+
`solid: true` blocks the player.
|
|
1457
|
+
2. **Exposes per-tile metadata** via the new `getTilemapAt(scene, entityId, x, y)`
|
|
1458
|
+
helper so game code can react to non-collision fields (damage, slope,
|
|
1459
|
+
movement, ground type).
|
|
1460
|
+
|
|
1461
|
+
### Authoring
|
|
1462
|
+
|
|
1463
|
+
Users author metadata in the home-ui's Tile Metadata Editor — select a
|
|
1464
|
+
tile in the TilemapInspectorPanel's palette, then click the **⚙ Configure
|
|
1465
|
+
tile #N** button that appears below the palette. (Right-click also opens
|
|
1466
|
+
the modal as a power-user shortcut.) Eight fields per tile:
|
|
1467
|
+
|
|
1468
|
+
| Field | Type | Purpose |
|
|
1469
|
+
|-------|------|---------|
|
|
1470
|
+
| `solid` | boolean | Auto-armed via `setCollisionByProperty`. Standard collision. |
|
|
1471
|
+
| `oneWay` | boolean | Jump-through platform flag. v1 stores only; game code wires the check. |
|
|
1472
|
+
| `slope` | `'up-left'` \| `'up-right'` | Slope direction for diagonal walk-up. |
|
|
1473
|
+
| `damage` | number | HP/sec dealt when player stands on tile. Game code applies. |
|
|
1474
|
+
| `terrainTag` | string | Free-form (`grass`, `water`, `sand`) — used by Phase D autotile + behavior. |
|
|
1475
|
+
| `movement` | `'walk'` \| `'swim'` \| `'fly'` \| `'block'` | Character speed/passability modifier. |
|
|
1476
|
+
| `groundType` | string | Footstep-sound selection: `soft`, `stone`, `metal`. |
|
|
1477
|
+
| `customTag` | string | Game-specific (`checkpoint`, `spawn`, `exit`). |
|
|
1478
|
+
|
|
1479
|
+
Only non-default fields are persisted — sparse map keyed by tile index.
|
|
1480
|
+
|
|
1481
|
+
### Collision — zero-config
|
|
1482
|
+
|
|
1483
|
+
If any painted tile has `solid: true`, the SDK auto-arms collision on the
|
|
1484
|
+
layer. Use the `addTilemapCollider` helper to wire it up — handles both
|
|
1485
|
+
the default cell-rect collision AND sub-tile collision rects (see next
|
|
1486
|
+
section) in one call:
|
|
1487
|
+
|
|
1488
|
+
```ts
|
|
1489
|
+
import { addTilemapCollider } from '@umicat/phaser-sdk';
|
|
1490
|
+
|
|
1491
|
+
create() {
|
|
1492
|
+
// ... spawn player ...
|
|
1493
|
+
addTilemapCollider(this, 'world', this.player);
|
|
1494
|
+
}
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
No `setCollisionByIndex` or `setCollision` boilerplate — the SDK calls
|
|
1498
|
+
`setCollisionByProperty({ solid: true })` automatically at scene load.
|
|
1499
|
+
|
|
1500
|
+
### Sub-tile collision shape (Godot/Tiled-style polygon-on-tile)
|
|
1501
|
+
|
|
1502
|
+
For tiles where only PART of the cell should block the player (e.g. a
|
|
1503
|
+
tile that's visually half wall + half floor, an L-shaped wall corner,
|
|
1504
|
+
fence posts, decorative overhangs), the Tile Metadata Editor's
|
|
1505
|
+
**Collision Shape** sub-panel lets the user draw **N axis-aligned
|
|
1506
|
+
rectangles** on the tile preview. The shape is **authored once per tile**
|
|
1507
|
+
— every painted instance of that tile across every level automatically
|
|
1508
|
+
gets the same collision shape, same as Godot's TileSet Physics editor.
|
|
1509
|
+
|
|
1510
|
+
Data shape (`TileMetadata.collisionRects`):
|
|
1511
|
+
```ts
|
|
1512
|
+
Array<{ x: number, y: number, w: number, h: number }> // each rect in source-pixel coords, relative to tile top-left
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
Multi-rect supports L / U / frame-shaped collision (e.g. a wall corner
|
|
1516
|
+
is two rects forming an L; a frame border is four). For non-axis-aligned
|
|
1517
|
+
shapes (slopes), Phaser Arcade has no native support — that would require
|
|
1518
|
+
switching to Matter physics which has different semantics and breaks the
|
|
1519
|
+
project's Arcade-based skill library.
|
|
1520
|
+
|
|
1521
|
+
At runtime the SDK:
|
|
1522
|
+
1. Disables Phaser's native cell-rect collision for tiles with custom
|
|
1523
|
+
`collisionRects` (so it doesn't double-collide).
|
|
1524
|
+
2. Creates one invisible `Phaser.GameObjects.Rectangle` with an Arcade
|
|
1525
|
+
static body per rect at every painted instance of that tile, sized +
|
|
1526
|
+
positioned in world coords.
|
|
1527
|
+
3. Bodies live in a `Phaser.Physics.Arcade.StaticGroup` stashed on the
|
|
1528
|
+
layer's data manager (`unboxySubTileStaticGroup`). Rebuilt on each
|
|
1529
|
+
painter op (paint / erase / fillRect / bucketFill) and on each
|
|
1530
|
+
`assetUpdate` (live save from the Tile Metadata Editor).
|
|
1531
|
+
|
|
1532
|
+
Behavior code doesn't need to know about any of this — `addTilemapCollider`
|
|
1533
|
+
wires up both the layer collider AND the sub-tile group at once. Cells
|
|
1534
|
+
without custom rects keep their default `solid` behavior; cells with a
|
|
1535
|
+
custom shape collide only on the sub-rects.
|
|
1536
|
+
|
|
1537
|
+
**Implementation note**: Phaser's TilemapLayer + Arcade physics has no
|
|
1538
|
+
native sub-tile collision. This is a known limitation — the Phaser
|
|
1539
|
+
community workaround is exactly this static-body pattern. Matter physics
|
|
1540
|
+
has per-tile polygon support but requires switching the whole game to a
|
|
1541
|
+
different physics model, which has different semantics than Arcade and
|
|
1542
|
+
breaks every existing behavior pattern. We deliberately keep Arcade and
|
|
1543
|
+
materialize sub-rects as static bodies.
|
|
1544
|
+
|
|
1545
|
+
### `getTilemapAt(scene, entityId, worldX, worldY, options?)`
|
|
1546
|
+
|
|
1547
|
+
Read the painted tile at a world coord. Returns `null` when no tile is
|
|
1548
|
+
painted there (or coord is outside the layer); returns a `TilemapHit`
|
|
1549
|
+
otherwise:
|
|
1550
|
+
|
|
1551
|
+
```ts
|
|
1552
|
+
interface TilemapHit {
|
|
1553
|
+
layerId: string; // which layer holds the tile
|
|
1554
|
+
tileIndex: number; // 0-based row-major index in the tileset image
|
|
1555
|
+
metadata: TileMetadata; // the eight-field object (empty when no metadata)
|
|
1556
|
+
tileX: number; // tile column within the layer
|
|
1557
|
+
tileY: number; // tile row within the layer
|
|
1558
|
+
}
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
```ts
|
|
1562
|
+
import { getTilemapAt } from '@umicat/phaser-sdk';
|
|
1563
|
+
|
|
1564
|
+
update(_t: number, dt: number) {
|
|
1565
|
+
const hit = getTilemapAt(this, 'world', this.player.x, this.player.y);
|
|
1566
|
+
if (!hit) return;
|
|
1567
|
+
// Damage tile (lava, spikes)
|
|
1568
|
+
if (hit.metadata.damage) this.player.hp -= hit.metadata.damage * (dt / 1000);
|
|
1569
|
+
// Movement modifier (water slows the player to 50%)
|
|
1570
|
+
this.player.speed = hit.metadata.movement === 'swim' ? 0.5 : 1.0;
|
|
1571
|
+
// Footstep sound by ground type
|
|
1572
|
+
if (this.lastTile !== hit.tileIndex) {
|
|
1573
|
+
this.lastTile = hit.tileIndex;
|
|
1574
|
+
this.sound.play(`footstep-${hit.metadata.groundType ?? 'soft'}`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
Layer scan order is top-down by default (highest `z` wins, matching the
|
|
1580
|
+
visual stack). Pass `options.layerId` to scope to one layer:
|
|
1581
|
+
|
|
1582
|
+
```ts
|
|
1583
|
+
// Read the ground layer specifically (ignore decoration overlays)
|
|
1584
|
+
const hit = getTilemapAt(this, 'world', x, y, { layerId: 'floor' });
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
### Anti-patterns
|
|
1588
|
+
|
|
1589
|
+
- Do **not** call `setCollisionByIndex` / `setCollision` yourself — the
|
|
1590
|
+
SDK already arms `setCollisionByProperty({ solid: true })` after scene
|
|
1591
|
+
load. Calling both can produce conflicting state where some painted
|
|
1592
|
+
tiles are flagged but others aren't.
|
|
1593
|
+
- Do **not** read `layer.getTileAtWorldXY()` directly — it works but
|
|
1594
|
+
loses the `metadata` object, the cross-layer top-down walk, and the
|
|
1595
|
+
hidden-layer skip. Use `getTilemapAt`.
|
|
1596
|
+
- Do **not** rely on per-tile metadata for things that should be entity
|
|
1597
|
+
properties — boss patrol points, NPC spawn positions, conversation
|
|
1598
|
+
triggers. Those are entities (or triggers, slice 5), not tile flags.
|
|
1599
|
+
|
|
1600
|
+
## Animated tiles (visual editor slice 6 Phase F, since 0.4.0)
|
|
1601
|
+
|
|
1602
|
+
Painted cells whose source tile index equals an animation's `rootTileIndex`
|
|
1603
|
+
cycle through `frames[*].tileIndex` synchronously at runtime — water flow,
|
|
1604
|
+
lava bubble, torch flicker, glowing crystals. All matching cells animate
|
|
1605
|
+
together (Sprout Lands water lake-cells all flow in lockstep).
|
|
1606
|
+
|
|
1607
|
+
### Schema (tileset metadata extension)
|
|
1608
|
+
|
|
1609
|
+
```json
|
|
1610
|
+
{
|
|
1611
|
+
"cellSize": { "width": 16, "height": 16 },
|
|
1612
|
+
"cols": 4, "rows": 1,
|
|
1613
|
+
"animations": [
|
|
1614
|
+
{
|
|
1615
|
+
"id": "water-flow",
|
|
1616
|
+
"rootTileIndex": 0,
|
|
1617
|
+
"frames": [
|
|
1618
|
+
{ "tileIndex": 0, "duration": 250 },
|
|
1619
|
+
{ "tileIndex": 1, "duration": 250 },
|
|
1620
|
+
{ "tileIndex": 2, "duration": 250 },
|
|
1621
|
+
{ "tileIndex": 3, "duration": 250 }
|
|
1622
|
+
]
|
|
1623
|
+
}
|
|
1624
|
+
]
|
|
1625
|
+
}
|
|
1626
|
+
```
|
|
1627
|
+
|
|
1628
|
+
- **`rootTileIndex`** — the tile users paint with. The painter palette shows
|
|
1629
|
+
this tile with a 🎬 badge so users know which one to paint. Other frames
|
|
1630
|
+
(1, 2, 3) are "internal" — the SDK swaps cells to them per-tick.
|
|
1631
|
+
- **`frames`** — cycle order. Convention: `frames[0].tileIndex === rootTileIndex`
|
|
1632
|
+
so the static editor view (where animations don't run) matches frame 0.
|
|
1633
|
+
- **`duration`** — milliseconds per frame. Floor 16ms. Sprout Lands water
|
|
1634
|
+
uses 250ms (4fps) across all 4 frames.
|
|
1635
|
+
|
|
1636
|
+
### Runtime behavior
|
|
1637
|
+
|
|
1638
|
+
`loadWorldScene` arms animations automatically — no code change needed in
|
|
1639
|
+
game scripts. Painted cells matching any `rootTileIndex` get swapped to the
|
|
1640
|
+
current frame every UPDATE tick. Cells painted with non-root indices stay
|
|
1641
|
+
static.
|
|
1642
|
+
|
|
1643
|
+
### Edit mode vs Play mode
|
|
1644
|
+
|
|
1645
|
+
In Edit mode the scene is paused (`setActive(false)`), so the animation
|
|
1646
|
+
UPDATE handler doesn't fire. `enterEdit` resets every animated cell to its
|
|
1647
|
+
root index so the editor always shows the authored ("data") frame, not
|
|
1648
|
+
whatever was visible at the moment of toggle. `exitEdit` resumes — next
|
|
1649
|
+
UPDATE tick swaps each cell to its current animation frame via elapsed-
|
|
1650
|
+
time math.
|
|
1651
|
+
|
|
1652
|
+
### Save-path safety
|
|
1653
|
+
|
|
1654
|
+
The iframe's per-frame `tile.index` mutation does NOT leak into the host's
|
|
1655
|
+
draft state. Home-ui keeps its own copy in `useEditorDraft`'s baseline +
|
|
1656
|
+
commands. Mid-animation Cmd+S saves root indices (the "data"), not the
|
|
1657
|
+
displayed frame.
|
|
1658
|
+
|
|
1659
|
+
### Authoring UI
|
|
1660
|
+
|
|
1661
|
+
Open the asset → `TilesetInspectorModal` → "Animations" section →
|
|
1662
|
+
"+ Add animation…" or "Edit animations…". The editor lets you pick a root
|
|
1663
|
+
tile + frames + uniform duration. For typical N-cell water/lava/torch
|
|
1664
|
+
spritesheets there's a one-click "Animate all" shortcut (sets root=0,
|
|
1665
|
+
frames=[0..N-1], duration=250ms).
|
|
1666
|
+
|
|
1667
|
+
### When NOT to use
|
|
1668
|
+
|
|
1669
|
+
- **Sprite animations** (player walk cycles, enemy attack frames) — those
|
|
1670
|
+
are Phaser sprite animations, registered via `scene.anims.create()` from
|
|
1671
|
+
`manifest.assets[].animations` (separate path, slice 7).
|
|
1672
|
+
- **One-shot tile state changes** (door opens, gate closes) — those are
|
|
1673
|
+
`layer.putTileAt(x, y, newIndex)` from behavior code, not data-driven
|
|
1674
|
+
cycling.
|
|
1675
|
+
- **Per-cell phase offset** (each water cell offset to look organic) — v2
|
|
1676
|
+
per design 06 §8.9. v1 ships synchronized cycling only.
|
|
1677
|
+
|
|
1678
|
+
## Changelog
|
|
1679
|
+
|
|
1680
|
+
- **0.4.0** — visual editor slice 6 Phase F: **animated tiles** (2026-05-24). Closes the last open Slice 6 phase. New `TilesetAnimation` type + `TilesetMetadata.animations?: TilesetAnimation[]` schema (additive — minor bump). Painted cells with `tile.index === rootTileIndex` cycle through `frames[*].tileIndex` per-frame via a scene UPDATE listener installed by new `applyTilesetAnimations(layer, asset)` in `spawnEntity.ts`. All matching cells animate synchronously. Phaser 3 PARSES Tiled's `tile.animation` field into `tileset.tileData[id].animation` but its TilemapLayer renderer does NOT consume it (community `phaser-animated-tiles` plugin is bit-rotted on 3.80+); ~50-line custom impl avoids the dep. **Edit-mode behavior**: scenes pause via `setActive(false)` → handler dormant → cells reset to root via new `resetTilesetAnimationsToRoot(container)` so editor view always shows the authored frame. `exitEdit` resumes on next UPDATE tick. **Save-path safety**: per-frame `tile.index` mutation doesn't leak into the host's draft state. **Re-arm paths**: editor's `handleAssetUpdate` (Animation Editor save → live re-apply), `applyTilemapStructureOp addLayer` (new layer arm), every paint op's metadata re-application (newly-painted root tile starts animating immediately). Pairs with home-ui `TilesetAnimationsEditorModal` + "Animate all" quick-fill + 🎬 root-tile badge in the tilemap palette. SDK-GUIDE gains an "Animated tiles" chapter above the changelog covering schema, runtime, edit-mode behavior, when-not-to-use. Pack-import auto-detection of `water_0.png` / `water_1.png` filename sequences deferred to Phase F.2.
|
|
1681
|
+
- **0.2.121** — Phase C follow-up: **multi-rect sub-tile collision** (L / U / frame shapes). `TileMetadata.collisionRect` → `TileMetadata.collisionRects[]`. SDK `syncSubTileBodies` now creates N invisible static bodies per painted tile (one per rect). TileMetadataEditorModal's Collision Shape sub-panel rewritten as a multi-rect canvas: drag empty area = new rect (auto-selected), click existing rect = select it, drag selected = move, drag corner = resize, Delete key = remove selected, "Clear all" replaces "Clear". Selected rect = solid red border + corner handles; unselected rects = dashed translucent (clickable). Palette overlay draws N rects per cell. Backend POJO `TileMetadata.collisionRect` field replaced with `collisionRects: List<CollisionRect>`; parser validates each. Breaking change for in-flight test data but acceptable (one tester, no production data). Wall corners + complex collision shapes now expressible without splitting tiles.
|
|
1682
|
+
- **0.2.120** — fix: tilemap grid sketch (editor-only visualization showing tile boundaries on empty / sparsely-painted tilemaps) bled into Play mode. `createTilemap` now defaults the grid Graphics to `setVisible(false)` and tags it with `unboxyTilemapGrid` data flag. New `setTilemapGridsVisible(game, boolean)` helper walks every tilemap container's children, finds the tagged grid, and toggles. Called with `true` on `enterEdit` and `false` on `exitEdit`. Surfaced 2026-05-19 — in Play mode user saw the tilemap's grid lines overlaid on the actual tiles + extending past the world bounds.
|
|
1683
|
+
- **0.2.119** — fix: sub-tile collision bodies (0.2.116) landed at world origin instead of inside the tilemap's bounds — visible as static bodies stacked top-left of the canvas in `physics.world.createDebugGraphic()` while painted walls sat at the entity position. Root cause: `applyTilesetTileMetadata` was called inside `renderLayerInto` BEFORE the caller set `layerObj.x/y`, so `tile.getLeft()` returned layer-local coords (near 0,0) instead of world coords. `createTilemap` mirrors layer position from the container transform AFTER `renderLayerInto` returns, so painted tiles rendered correctly but sub-tile bodies were stranded. Fix: moved `applyTilesetTileMetadata` out of `renderLayerInto` to AFTER positioning in both `createTilemap` (initial spawn) and `applyTilemapStructureOp addLayer` (editor-time). `renderLayerInto` now does NOT call `applyTilesetTileMetadata` — comment notes the caller is responsible.
|
|
1684
|
+
- **0.2.118** — fix: deleting a tilemap entity in the editor left its TilemapLayers orphaned in scene root, still visible in the canvas until next save+reload. Same root cause as the 0.2.59-era "TilemapLayer can't be a Container child" architecture: layers live in scene root with a per-frame sync hook positioning them at `container.x + offset`. `deleteEntity` only destroyed the Container, so the orphaned layers had no parent to follow → frozen at their last sync position, visible until the next scene rebuild stripped them. Fix: `deleteEntity` now walks `container.getData('tilemapLayers')` and `.destroy()`s each layer before destroying the container. Also tears down the sub-tile static body group (`layer.getData('unboxySubTileStaticGroup')`) — those bodies are scene-root members too and would survive the container destroy.
|
|
1685
|
+
- **0.2.117** — fix: `createEntity` for tilemap entities didn't lazy-load tileset textures, leading to `tileset 'X' texture not loaded; skipping layer` warning + the painter falling back to drag-the-entity behavior right after CreateTilemapModal. The lazy-load gate was sprite-only (`entity.kind === 'sprite'`). Pre-existing bug, surfaced when the user deleted a tilemap and re-created one against a tileset that wasn't in the SDK's texture cache. Fix: generalize the lazy-load to walk `tilemap.layers[].tilesetIds[]`, resolve each via `manifestAsset` (preferred) or `getManifest(scene)`, and `loadAssetIntoScene` for any not in `scene.textures`. Also unconditionally `upsertCachedManifestAsset(scene, manifestAsset)` whenever the host passes one, mirroring the addLayer + assetUpdate paths so the manifest cache is consistent regardless of entity kind. Same fix pattern as 0.2.43's region-atlas live-edit gap. No new exports, no API changes.
|
|
1686
|
+
- **0.2.116** — visual editor slice 6 Phase C follow-up: **sub-tile collision rects** (Godot/Tiled-style polygon-on-tile, rect-only in v1). Solves the "tile is visually half wall + half floor — block only the wall half" case without splitting the asset. New `TileMetadata.collisionRect: { x, y, w, h }` (source-pixel coords relative to tile top-left). SDK runtime: `applyTilesetTileMetadata` now also calls new `syncSubTileBodies` — walks painted tiles with a `collisionRect`, disables Phaser's native cell-rect collision for them (`tile.setCollision(false, ...)`), and creates invisible `Phaser.GameObjects.Rectangle`s with Arcade static bodies sized to the sub-rect. Bodies live in a per-layer `StaticGroup` stashed on the layer's data manager (`unboxySubTileStaticGroup`). Rebuilt on every painter op (paint / erase / fillRect / bucketFill) — `handleEditTilemap` looks up the layer's tileset asset post-op and re-runs `applyTilesetTileMetadata`, which is cheap when no tile has a `collisionRect` (single map scan + early return). Also rebuilt on `assetUpdate` (Tile Metadata Editor save). **New SDK export `addTilemapCollider(scene, entityId, target, callback?)`** — single-call helper that wires both `physics.add.collider(target, layer)` AND `physics.add.collider(target, subTileGroup)` for every layer of a tilemap entity, so behavior code never has to know whether a tile has cell-rect or sub-rect collision. Returns the created `Collider[]` so callers can destroy on level transition. Pairs with home-ui's TileMetadataEditorModal which gains a "Collision shape" sub-panel — drag on a zoomed tile preview to define the rect, drag corner handles to resize, drag body to move, click-without-drag to clear; rect persists as `TileMetadata.collisionRect`. TilemapInspectorPanel's tile palette gains a red rect overlay on cells with a custom collision shape (additive to the existing metadata dot). Backend POJO + parser extended with new `CollisionRect` inner class (positive w/h, non-negative x/y validation). **Phaser community context**: Phaser's TilemapLayer + Arcade physics has no native sub-tile collision; the established community pattern is exactly this static-body workaround (Matter physics has per-tile polygon support but switching breaks every Arcade-based behavior pattern). We stay on Arcade and materialize per-painted-tile bodies. Pure forward-compat addition — existing tilesets without `collisionRect` keep their cell-rect-only behavior.
|
|
1687
|
+
- **0.2.115** — visual editor slice 6 Phase C: per-tile metadata + auto-collision + `getTilemapAt`. New `TilesetMetadata.tiles` field (sparse map keyed by tile index, eight-field per-tile metadata — solid / oneWay / slope / damage / terrainTag / movement / groundType / customTag). SDK consumer: new `applyTilesetTileMetadata(tileset, layer, asset)` is called automatically after `map.createLayer` in both initial spawn (`renderLayerInto` in spawnEntity.ts) AND editor-time `addLayer` (EditorBridge.applyTilemapStructureOp); it stamps `tileset.tileProperties` so newly-painted tiles inherit the lookup, walks already-painted tiles via `layer.forEachTile` to refresh `tile.properties`, and calls `layer.setCollisionByProperty({ solid: true })` so behavior code only needs `physics.add.collider(player, layer)` for free collision. Live edit: `handleAssetUpdate` (`umicat:editor:assetUpdate` message) now also walks every tilemap layer using the updated asset and re-applies metadata + collision wiring — Tile Metadata Editor save takes effect in the running iframe without a workspace rebuild / scene reload. New SDK export `getTilemapAt(scene, entityId, worldX, worldY, options?)` returns a `TilemapHit` (`{ layerId, tileIndex, metadata, tileX, tileY }`) or null; default layer scan is top-down (highest z wins, matches visual stack), `options.layerId` scopes to one layer. New exports: `applyTilesetTileMetadata`, `getTilemapAt`, types `TileMetadata`, `TilemapHit`. Asset record now carries `tileset.tiles?: { [tileIndex: number]: TileMetadata }`. Tilemap layer GO is tagged with `tilemapTilesetId` so the assetUpdate handler can find affected layers without a registry walk. Pairs with home-ui's TileMetadataEditorModal (right-click any tile in the TilemapInspectorPanel palette → modal with eight fields → PATCH `/games/{gameId}/assets/{assetId}/tileset` → updateAsset round-trip to iframe). No template PR needed — purely additive editor / runtime work. SDK-GUIDE gains a "Tilemap per-tile metadata + collision" chapter covering authoring, zero-config collision, `getTilemapAt` API + examples, and anti-patterns.
|
|
1688
|
+
- **0.2.55** — fix: gravity declared in scene data is now applied. `world.physics.gravity` (per world scene) and `manifest.globals.physics.gravity` (manifest-wide default) were typed-but-dead fields — `loadWorldScene` never read them, so a game that declared gravity in scene data ran at zero gravity (a platformer that "looks right but nothing falls"). `loadWorldScene` now applies them to `scene.physics.world.gravity`, scene-level winning over the manifest global, components applied individually (a partial `{ y }` leaves `x` intact). No schema change (both fields already existed in the `Manifest` / `WorldSceneConfig` types), no new exports. `game.json`'s `GameConfig.physics.gravity` is unchanged — it's a separate boot-time path wired through `createUmicatGame`. Behavior note: a game that *already* declared scene-data gravity now actually gets it — intended, since the field was inert before.
|
|
1689
|
+
- **0.2.54** — fix: code-rendered scene entities had no usable physics body, and the prefab physics path mis-anchored code-rendered bodies. Two parts: (1) **scene entities can now carry a `physics` block** (`sprite` / `primitive` / `code-rendered`, same `PrefabPhysics` shape prefabs use) — `spawnEntity` applies it via `physics.add.existing` + body size/offset/immovable/velocity/bounce. Before this, scene entities had no physics-application path at all, so a code-rendered platform got a 0×0 body and everything fell through it. (2) **code-rendered bodies are now correctly anchored.** A Phaser `Graphics` hardcodes `displayOrigin = (0,0)` (no Origin component) while render scripts draw centered on local (0,0); the Arcade body positions at `gameObject.x + offset.x`, so the prior default-offset body landed at the bottom-right of the visual ("player floats above the platform"). The fix shifts the *body frame* by `(-visualW/2, -visualH/2)` for code-rendered visuals — body-only, never touches the Graphics, so rendering is unaffected (this is the correct fix that 0.2.52's reverted `g.setOrigin` approach got wrong). Prefab and scene-entity physics now share one body-level path (`applyEntityPhysics` in `spawnEntity.ts`). Sprite / primitive physics behavior is unchanged. No new exports — `WorldEntityBase` gains an optional `physics` field; existing games are unaffected.
|
|
1690
|
+
- **0.2.45** — fix: `isPerFrameHitbox` type guard tightened to check `default != null` rather than `'default' in h`. Closes a wire-shape gap from slice 8 v1.1 — the backend now writes explicit null values on the alternate shape's fields when a Hitbox PATCH switches modes (Single ↔ Per-frame), because OpenSearch's `_update` API deep-merges nested objects and would otherwise leave stale per-frame fields after a Single-mode save. Without this guard fix, the SDK's `applyAssetHitbox` would have treated the explicit-null `default: null` as "per-frame mode" via the `'in'` check and crashed when calling `applyShape(body, null)`. Pure SDK change — no API additions; only the guard's branch condition.
|
|
1691
|
+
- **0.2.44** — visual editor slice 8: depth + asymmetric collision. New fields `AssetRecord.hitbox` (single rect or per-frame overrides) + `AssetRecord.depthAnchor` (footprint pixel for Y-sort). New scene flag `WorldScene.ySort: boolean` (per-frame `setDepth(y)` hook). New SDK export `applyAssetHitbox(sprite, asset)` — silent no-op without metadata, dev-warn on missing body, idempotent `ANIMATION_UPDATE` listener install for per-frame variants. New type guard `isPerFrameHitbox`. Two new editor protocol messages — `umicat:editor:assetUpdate { asset }` (broadcast hitbox/anchor edits to running iframe; re-applies to every spawned instance, no scene reload) + `umicat:editor:setDebugOverlay { showHitboxes }` (toggle the EditorOverlayScene's per-frame hitbox + anchor draw). See "Collision + Y-sort" chapter. Pairs with: backend `PATCH /games/{id}/assets/{aid}/hitbox`, vision detection in pack import (character/tree/building/rock/decoration subjects), Hitbox Editor modal in home-ui, `asset-hitbox-physics` agent skill teaching the conditional rule.
|
|
1692
|
+
- **0.2.33** — docs: HUD scenes chapter added to this guide (covers slice-5 widget kinds, anchor model, dynamic bindings via `scene.registry`, `hud:press` event). Existing workspaces pick this up via `npm update @umicat/phaser-sdk`.
|
|
1693
|
+
- **0.2.32** — fix: 9-grid anchor side change in the visual editor moved container-based widgets (icon-button, progress-bar, panel) far from the new corner. `applyHudPatch` now re-applies the origin shift that compensates for containers having no `setOrigin`.
|
|
1694
|
+
- **0.2.31** — fix: progress-bar and panel widgets drew their Graphics rect from `(0, 0)` instead of centered, so the visual was offset by `(w/2, h/2)` from the editor's selection rect. Both now draw centered.
|
|
1695
|
+
- **0.2.30** — added HUD `progress-bar` and `panel` widgets (visual editor slice 5.5). progress-bar supports independent static/dynamic `value` and `max` bindings; panel is a colored rectangle with optional border + rounded corners. New types: `HudProgressBarVisual`, `HudPanelVisual`, `HudNumberSource`. See "HUD scenes" chapter above.
|
|
1696
|
+
- **0.2.29** — added HUD scene runtime (visual editor slice 5). New module `src/scene/HudRuntime.ts` exporting `UmicatHudScene`, `loadHudScene`, `spawnHudEntity`, `resolveAnchor`. Three v1 widget kinds: `text` (static or dynamic), `image`, `icon-button`. Auto-launched by `loadWorldScene` when `manifest.scenes[].hud` is set. Dynamic bindings hook into Phaser's game registry. Editor protocol gains `umicat:editor:setEditMode { mode: 'world' \| 'hud' }`. See "HUD scenes" chapter above.
|
|
1697
|
+
- **0.2.28** — fix: `EditorBridge.computeScreenRect` was multiplying by `Phaser.Scale.ScaleManager.displayScale.x` which is `gameSize / canvasBoundsSize` (the inverse of what the variable name suggests), so coords ballooned out of the iframe. Now measures CSS-to-logical scale directly via `canvas.getBoundingClientRect()`.
|
|
1698
|
+
- **0.2.27** — added `EditorSelectionRectMessage` (visual editor slice 4 — inline AI popover). SDK posts `umicat:editor:selectionRect { entityId, rect }` on selection change / drag-end / pan-zoom; rect is in iframe-relative pixels. Used by home-ui to anchor a floating ✨ button next to the selected entity for entity-scoped AI prompts.
|
|
1699
|
+
- **0.2.26** — fix: tilemap entities couldn't be selected in the editor. `spawnEntity` for `tilemap` now sets explicit hit-test bounds (Phaser Graphics has no intrinsic size).
|
|
1700
|
+
- **0.2.25** — fix: Backspace / Delete in the editor didn't remove selected entities once focus was inside the iframe. The overlay scene now catches bare Backspace/Delete and posts `action: 'delete'` to the host.
|
|
1701
|
+
- **0.2.24** — fix: re-dragging an already-in-manifest asset spawned an unrendered entity. `createEntity` resolver now falls back to the cached `manifest.json` when the host omits `manifestAsset`.
|
|
1702
|
+
- **0.2.23** — fix: code-rendered entities couldn't be selected/dragged in the editor. `spawnEntity` now sets explicit Graphics size (Phaser Graphics returns 0×0 from `getBounds()` otherwise). Schema gains optional `visual.width` / `visual.height`.
|
|
1703
|
+
- **0.2.22** — render-script registry wired (visual editor slice 3.5). `createUmicatGame` accepts a new `renderScripts: Record<path, RenderScriptModule>` option; templates build it via `import.meta.glob('./visuals/*.ts', { eager: true })`. `loadWorldScene` falls back to that registry when no explicit `resolveRenderScript` is passed, so games don't have to plumb it through every scene call. Missing scripts no longer crash the scene boot — `spawnEntity` renders a clear orange-bordered "?" placeholder instead and tags the GameObject with `renderScriptMissing` for debug. `applyEdit` now handles `visual.params` patches on `code-rendered` entities by re-calling render with merged params (live editor preview as you tune in the Inspector). New exports: `RenderScriptModule`, `setRenderScriptRegistry`, `getRenderScriptRegistry`, `resolveRenderScript`. Pairs with the `phaser-render-script` agent skill which teaches the pure-function contract.
|
|
1704
|
+
- **0.2.21** — visual editor slice 3 (drag-to-place). Formalized trigger + tilemap entity data shapes (TriggerEntity now has `shape: rect|circle`, `description`, `targets[]`; TilemapEntity has `tileSize`, `size`, `layers[]`). spawnEntity renders both as placeholders (trigger = semi-transparent cyan rect/circle, tilemap = translucent grid sketch — full features ship in slices 5+6). New protocol: `umicat:editor:createEntity { entity, manifestAsset? }` (host-side drag-to-place spawns entity at world coord; lazy-loads asset texture if needed) + `umicat:editor:deleteEntity { entityId }` (Backspace + undo of create). `sceneLoaded` now also carries the manifest snapshot so home-ui can mutate the asset table when a new asset gets dropped on the canvas.
|
|
1705
|
+
- **0.2.20** — added editor shortcut forwarding (Cmd+Z/Y/S land in iframe when canvas focused; SDK posts back to host via `umicat:editor:shortcut`). Without this, native shortcuts didn't reach the host's keydown listener after the user clicked the canvas.
|
|
1706
|
+
- **0.2.19** — fix: editor `enter` arriving while BootScene is still preloading (the post-flush iframe-rebuild path) had nothing to pause, so the world scene resumed running freely once it started up. Now the bridge re-attempts the pause + scene snapshot for ~3 seconds after enter, catching the scene as it transitions from BootScene → GameScene. Snapshot is also re-posted once a scene file lands in cache. Surfaced when slice-2's auto-flush rebuilt the iframe and the user found the sprite started moving again post-save (2026-05-07).
|
|
1707
|
+
- **0.2.18** — added editor bridge (visual editor slice 2). Replaces the slice-1 `umicat:setEditMode` message with a full editor protocol: `umicat:editor:enter`/`:exit` (toggles edit mode + launches the overlay scene), `:applyEdit` (mutates a live entity from the host), `:setSelection` (host pushes selection), `:panZoom` (editor camera control), plus SDK→host `:sceneLoaded` (initial snapshot), `:pickEntity` (pointer-down selection), `:dragEnd` (entity dragged to new position). New module `src/editor/` with `EditorOverlayScene` (high-depth Phaser.Scene drawing selection rect + world bounds, capturing pointer events) and `EditorBridge` (postMessage handler + applyEdit logic). New exports: `setupEditorBridge`, `EditorOverlayScene`, `EDITOR_OVERLAY_KEY`, plus all editor protocol types. `setupEditorModeListener` is preserved as a thin wrapper that delegates to the bridge so existing `createUmicatGame` wiring continues to work. Pairs with home-ui's new Hierarchy + Inspector panels and `useEditorDraft` hook.
|
|
1708
|
+
- **0.2.17** — added scene-as-data foundation (visual editor slice 1). New exports: `loadWorldScene`, `preloadManifest`, `getManifest`, `preloadSceneAssets`, `spawnEntity`, `EntityRegistry`, `attachEntityRegistry`, `getEntityRegistry`, `setupEditorModeListener`, `isEditMode`, `parseColor`, `SCHEMA_VERSION`, plus all schema types (`Manifest`, `WorldScene`, `WorldEntity`, `Transform`, `AssetRecord`, etc.). New games that ship with `public/scenes/manifest.json` load entities + camera config from JSON; `GameScene.ts` becomes a generic loader that calls `await loadWorldScene(this, sceneId)`. `createUmicatGame` now also wires `setupEditorModeListener` so the host (home-ui) can pause active scenes via `postMessage({ type: 'umicat:setEditMode', enabled: boolean })`. Purely additive — existing scene-as-code games are unaffected. Migration to scene-as-data is opt-in via the `scene-data-migration` agent skill.
|
|
1709
|
+
- **0.2.16** — added orientation presets. `createUmicatGame` now accepts an `orientation: 'portrait' | 'landscape'` option as an alternative to explicit `width`/`height` (TS union — pass one or the other). New exports: `Orientation` type and `ORIENTATION_DIMENSIONS` map (`landscape: 1280×720`, `portrait: 720×1280`). Lets games declare orientation once and have a single source of truth for canvas dimensions.
|
|
1710
|
+
- **0.2.15** — fix: `ChatFacade.subscribeToPlayers` early-returned when `state.players` was `undefined` at construction time, which it always is on a fresh `joinOrCreate` (Colyseus delivers initial state as a separate message a few ms later). The early return meant `onStateChange` never subscribed and `system.joined` / `system.left` stayed silent in production despite the 0.2.14 callback-API fix. Now: always subscribe; treat the first state change as initial hydration (silent), subsequent changes do real diff. Three regression tests cover the deferred-hydration flow.
|
|
1711
|
+
- **0.2.14** — fix: `ChatFacade` `system.joined` / `system.left` events were silently no-op'ing in production. The lifecycle subscription used `state.players.onAdd` / `.onRemove` instance methods that Colyseus 0.16 removed in favour of `getStateCallbacks(room)`; the runtime check `typeof players.onAdd === 'function'` evaluated false, so neither system message fired. Now uses `room.onStateChange` + a known-names diff — robust to future Colyseus API changes. Tests updated to match. Patch-only; no API change for game code.
|
|
1712
|
+
- **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.
|
|
1713
|
+
- **0.2.12** — added `umicat.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.
|
|
1714
|
+
- **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.
|
|
1715
|
+
- **0.2.10** — docs: added a `createUmicatGame` 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 @umicat/phaser-sdk`.
|
|
1716
|
+
- **0.2.9** — `createUmicatGame` 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.
|
|
1717
|
+
- **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 @umicat/phaser-sdk`.
|
|
1718
|
+
- **0.2.7** — fixed handshake race on slow-mounting hosts. `PostMessageTransport.connect` now retries `umicat:hello` every 200 ms until `umicat:init` arrives (previously one-shot → lost on Android/mobile where React mounted after the iframe first fired hello). Default handshake timeout bumped from 2 s → 5 s.
|
|
1719
|
+
- **0.2.6** — redesigned `umicat.rooms` around two generic primitives: delta-synced KV state (`room.player.set/get/delete`, `room.data.set/get/delete`) + transient relay (`room.send` / `room.on`). Server no longer bakes in game-specific fields like x/y/color/ready or a `move` handler — games define their own shapes via opaque JSON values, same contract as `gameData` / `saves`.
|
|
1720
|
+
- **0.2.5** — added `umicat.rooms` module (server-authoritative multiplayer rooms backed by Colyseus on umicat-realtime-service). Requires sign-in. Host must advertise `realtime` capability. Colyseus client loaded lazily — single-player games do not pay for the dependency.
|
|
1721
|
+
- **0.2.4** — added `umicat.gameData` module (game-scope key-value store for scoreboards, shared state)
|
|
1722
|
+
- **0.2.3** — anonymous users now use localStorage even inside a host (was throwing UNAUTHENTICATED when calling `saves` in home-ui without login)
|
|
1723
|
+
- **0.2.2** — added `SDK-GUIDE.md`
|
|
1724
|
+
- **0.2.1** — added `Umicat.init`, `umicat.user`, `umicat.saves`, Transport abstraction (PostMessage + LocalStorage backends)
|
|
1725
|
+
- **0.2.0** — added `UmicatScene`, recording support. Migrated to public npm.
|
|
1726
|
+
- **0.1.0** — initial release (CodeArtifact).
|