@umicat/phaser-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SDK-GUIDE.md +1726 -0
- package/dist/core/Transport.d.ts +28 -0
- package/dist/core/Transport.js +7 -0
- package/dist/core/Umicat.d.ts +45 -0
- package/dist/core/Umicat.js +60 -0
- package/dist/core/UmicatGame.d.ts +43 -0
- package/dist/core/UmicatGame.js +64 -0
- package/dist/core/UmicatScene.d.ts +19 -0
- package/dist/core/UmicatScene.js +38 -0
- package/dist/core/transports/LocalStorageTransport.d.ts +22 -0
- package/dist/core/transports/LocalStorageTransport.js +78 -0
- package/dist/core/transports/PostMessageTransport.d.ts +28 -0
- package/dist/core/transports/PostMessageTransport.js +105 -0
- package/dist/editor/EditorBridge.d.ts +114 -0
- package/dist/editor/EditorBridge.js +2608 -0
- package/dist/editor/EditorOverlayScene.d.ts +333 -0
- package/dist/editor/EditorOverlayScene.js +1896 -0
- package/dist/editor/EditorState.d.ts +251 -0
- package/dist/editor/EditorState.js +197 -0
- package/dist/gamedata/GameDataModule.d.ts +45 -0
- package/dist/gamedata/GameDataModule.js +59 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/orientation.d.ts +5 -0
- package/dist/orientation.js +4 -0
- package/dist/protocol.d.ts +807 -0
- package/dist/protocol.js +3 -0
- package/dist/realtime/RealtimeModule.d.ts +93 -0
- package/dist/realtime/RealtimeModule.js +115 -0
- package/dist/realtime/UmicatRoom.d.ts +197 -0
- package/dist/realtime/UmicatRoom.js +353 -0
- package/dist/recording/RecordingManager.d.ts +11 -0
- package/dist/recording/RecordingManager.js +59 -0
- package/dist/saves/SavesModule.d.ts +23 -0
- package/dist/saves/SavesModule.js +37 -0
- package/dist/scene/EditorMode.d.ts +17 -0
- package/dist/scene/EditorMode.js +22 -0
- package/dist/scene/EntityRegistry.d.ts +39 -0
- package/dist/scene/EntityRegistry.js +103 -0
- package/dist/scene/GameConfig.d.ts +60 -0
- package/dist/scene/GameConfig.js +50 -0
- package/dist/scene/HudRuntime.d.ts +131 -0
- package/dist/scene/HudRuntime.js +1224 -0
- package/dist/scene/Prefabs.d.ts +92 -0
- package/dist/scene/Prefabs.js +175 -0
- package/dist/scene/Rules.d.ts +73 -0
- package/dist/scene/Rules.js +164 -0
- package/dist/scene/SceneLoader.d.ts +118 -0
- package/dist/scene/SceneLoader.js +615 -0
- package/dist/scene/Waves.d.ts +85 -0
- package/dist/scene/Waves.js +365 -0
- package/dist/scene/autotile.d.ts +103 -0
- package/dist/scene/autotile.js +321 -0
- package/dist/scene/renderScripts.d.ts +53 -0
- package/dist/scene/renderScripts.js +67 -0
- package/dist/scene/spawnEntity.d.ts +201 -0
- package/dist/scene/spawnEntity.js +1326 -0
- package/dist/scene/types.d.ts +1166 -0
- package/dist/scene/types.js +34 -0
- package/dist/screenshot/ScreenshotManager.d.ts +14 -0
- package/dist/screenshot/ScreenshotManager.js +33 -0
- package/package.json +35 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave schedules — time-ordered spawn sequences as data.
|
|
3
|
+
*
|
|
4
|
+
* Slice 11 (Phase B.4, 2026-05-12). Each schedule lives in
|
|
5
|
+
* `public/waves/<id>.json` as a `WaveScheduleRecord`. Behavior code calls
|
|
6
|
+
* `runWaveSchedule(scene, id, callbacks)` to drive enemy waves / formations /
|
|
7
|
+
* staged spawns without hand-implementing wave loops.
|
|
8
|
+
*
|
|
9
|
+
* Design: umicat-design/features/visual-editor/11-game-data-foundation.md §7
|
|
10
|
+
*
|
|
11
|
+
* Public API:
|
|
12
|
+
* - `loadWaveSchedule(scene, id)` — async fetch via Phaser's loader. Cached.
|
|
13
|
+
* - `runWaveSchedule(scene, id, callbacks)` — start a schedule; returns a
|
|
14
|
+
* `WaveController` (pause / resume / skipTo / stop).
|
|
15
|
+
*
|
|
16
|
+
* Wave end conditions:
|
|
17
|
+
* - `'allDead'` (default) — waits until every spawned instance from this
|
|
18
|
+
* wave has been destroyed.
|
|
19
|
+
* - `{ type: 'time', ms }` — fixed duration from wave start.
|
|
20
|
+
* - `{ type: 'count', killed }` — wait until N spawned instances destroyed.
|
|
21
|
+
*/
|
|
22
|
+
import Phaser from 'phaser';
|
|
23
|
+
import { WaveRecord, WaveScheduleRecord } from './types.js';
|
|
24
|
+
export interface SpawnInstance {
|
|
25
|
+
/** Prefab id requested by the schedule. */
|
|
26
|
+
prefabId: string;
|
|
27
|
+
/** World coordinate where the spawn should land. */
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
/** Wave id the spawn belongs to (for logging / per-wave logic). */
|
|
31
|
+
waveId: string;
|
|
32
|
+
/** Zero-based index of the wave within the schedule. */
|
|
33
|
+
waveIndex: number;
|
|
34
|
+
/** Per-spawn overrides from the schedule (deep-merged into the prefab). */
|
|
35
|
+
overrides?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface WaveCallbacks {
|
|
38
|
+
/**
|
|
39
|
+
* Called for every spawn. Caller spawns the instance (typically via
|
|
40
|
+
* `spawnPrefab`) and returns the GameObject so the controller can
|
|
41
|
+
* track it for `endCondition: 'allDead'` / `{ type: 'count', killed }`.
|
|
42
|
+
* Return `void` if you don't want lifecycle tracking (rare).
|
|
43
|
+
*/
|
|
44
|
+
onSpawn: (instance: SpawnInstance) => Phaser.GameObjects.GameObject | void;
|
|
45
|
+
onWaveStart?: (waveIndex: number, wave: WaveRecord) => void;
|
|
46
|
+
onWaveEnd?: (waveIndex: number, wave: WaveRecord) => void;
|
|
47
|
+
onScheduleEnd?: () => void;
|
|
48
|
+
}
|
|
49
|
+
export interface WaveController {
|
|
50
|
+
/** Pause all pending timers + advance checks. Idempotent. */
|
|
51
|
+
pause(): void;
|
|
52
|
+
/** Resume after pause. Idempotent if not paused. */
|
|
53
|
+
resume(): void;
|
|
54
|
+
/** Jump to the wave at `index` (0-based). Cancels in-flight spawns of current wave. */
|
|
55
|
+
skipTo(index: number): void;
|
|
56
|
+
/** Stop the schedule. Fires `onScheduleEnd`. Controller is dead after this. */
|
|
57
|
+
stop(): void;
|
|
58
|
+
/** Zero-based index of the wave currently spawning / waiting on. */
|
|
59
|
+
currentWaveIndex(): number;
|
|
60
|
+
/** `true` after the schedule fully completes (no more waves, no loop). */
|
|
61
|
+
isComplete(): boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a wave schedule via Phaser's JSON loader. Cached per scene cache
|
|
65
|
+
* — re-calling with the same id returns the cached record immediately.
|
|
66
|
+
*/
|
|
67
|
+
export declare function loadWaveSchedule(scene: Phaser.Scene, scheduleId: string): Promise<WaveScheduleRecord>;
|
|
68
|
+
/**
|
|
69
|
+
* Start a wave schedule. Returns a `WaveController` immediately; the
|
|
70
|
+
* schedule loads asynchronously and starts as soon as the file is in
|
|
71
|
+
* cache. Use this in `GameScene.create()`:
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* const wc = runWaveSchedule(this, 'stage-1', {
|
|
75
|
+
* onSpawn: ({ prefabId, x, y, overrides }) => {
|
|
76
|
+
* const go = spawnPrefab(this, prefabId, x, y, overrides);
|
|
77
|
+
* this.enemies.add(go);
|
|
78
|
+
* return go;
|
|
79
|
+
* },
|
|
80
|
+
* onWaveStart: (i) => this.events.emit('hudWave', i + 1),
|
|
81
|
+
* onScheduleEnd: () => this.scene.start('VictoryScene'),
|
|
82
|
+
* });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export declare function runWaveSchedule(scene: Phaser.Scene, scheduleId: string, callbacks: WaveCallbacks): WaveController;
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave schedules — time-ordered spawn sequences as data.
|
|
3
|
+
*
|
|
4
|
+
* Slice 11 (Phase B.4, 2026-05-12). Each schedule lives in
|
|
5
|
+
* `public/waves/<id>.json` as a `WaveScheduleRecord`. Behavior code calls
|
|
6
|
+
* `runWaveSchedule(scene, id, callbacks)` to drive enemy waves / formations /
|
|
7
|
+
* staged spawns without hand-implementing wave loops.
|
|
8
|
+
*
|
|
9
|
+
* Design: umicat-design/features/visual-editor/11-game-data-foundation.md §7
|
|
10
|
+
*
|
|
11
|
+
* Public API:
|
|
12
|
+
* - `loadWaveSchedule(scene, id)` — async fetch via Phaser's loader. Cached.
|
|
13
|
+
* - `runWaveSchedule(scene, id, callbacks)` — start a schedule; returns a
|
|
14
|
+
* `WaveController` (pause / resume / skipTo / stop).
|
|
15
|
+
*
|
|
16
|
+
* Wave end conditions:
|
|
17
|
+
* - `'allDead'` (default) — waits until every spawned instance from this
|
|
18
|
+
* wave has been destroyed.
|
|
19
|
+
* - `{ type: 'time', ms }` — fixed duration from wave start.
|
|
20
|
+
* - `{ type: 'count', killed }` — wait until N spawned instances destroyed.
|
|
21
|
+
*/
|
|
22
|
+
import Phaser from 'phaser';
|
|
23
|
+
const WAVE_CACHE_PREFIX = 'umicat:wave:';
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a wave schedule via Phaser's JSON loader. Cached per scene cache
|
|
26
|
+
* — re-calling with the same id returns the cached record immediately.
|
|
27
|
+
*/
|
|
28
|
+
export function loadWaveSchedule(scene, scheduleId) {
|
|
29
|
+
const cacheKey = `${WAVE_CACHE_PREFIX}${scheduleId}`;
|
|
30
|
+
if (scene.cache.json.exists(cacheKey)) {
|
|
31
|
+
return Promise.resolve(scene.cache.json.get(cacheKey));
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
scene.load.once(Phaser.Loader.Events.COMPLETE, () => {
|
|
35
|
+
const record = scene.cache.json.get(cacheKey);
|
|
36
|
+
if (!record) {
|
|
37
|
+
reject(new Error(`[umicat/waves] schedule '${scheduleId}' loaded but cache is empty`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
resolve(record);
|
|
41
|
+
});
|
|
42
|
+
scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
|
|
43
|
+
if (file.key === cacheKey) {
|
|
44
|
+
reject(new Error(`[umicat/waves] failed to load wave schedule '${scheduleId}' — check public/waves/${scheduleId}.json exists`));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
scene.load.json(cacheKey, `waves/${scheduleId}.json`);
|
|
48
|
+
scene.load.start();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Start a wave schedule. Returns a `WaveController` immediately; the
|
|
53
|
+
* schedule loads asynchronously and starts as soon as the file is in
|
|
54
|
+
* cache. Use this in `GameScene.create()`:
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const wc = runWaveSchedule(this, 'stage-1', {
|
|
58
|
+
* onSpawn: ({ prefabId, x, y, overrides }) => {
|
|
59
|
+
* const go = spawnPrefab(this, prefabId, x, y, overrides);
|
|
60
|
+
* this.enemies.add(go);
|
|
61
|
+
* return go;
|
|
62
|
+
* },
|
|
63
|
+
* onWaveStart: (i) => this.events.emit('hudWave', i + 1),
|
|
64
|
+
* onScheduleEnd: () => this.scene.start('VictoryScene'),
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function runWaveSchedule(scene, scheduleId, callbacks) {
|
|
69
|
+
const controller = new WaveControllerImpl(scene, callbacks);
|
|
70
|
+
loadWaveSchedule(scene, scheduleId)
|
|
71
|
+
.then((record) => controller.start(record))
|
|
72
|
+
.catch((err) => {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.warn('[umicat/waves]', err);
|
|
75
|
+
});
|
|
76
|
+
return controller;
|
|
77
|
+
}
|
|
78
|
+
// --- Internals ------------------------------------------------------------
|
|
79
|
+
class WaveControllerImpl {
|
|
80
|
+
constructor(scene, callbacks) {
|
|
81
|
+
this.scene = scene;
|
|
82
|
+
this.callbacks = callbacks;
|
|
83
|
+
this.record = null;
|
|
84
|
+
this.waveIndex = 0;
|
|
85
|
+
this.paused = false;
|
|
86
|
+
this.stopped = false;
|
|
87
|
+
this.complete = false;
|
|
88
|
+
/** Active TimerEvents we need to cancel on pause / skip / stop. */
|
|
89
|
+
this.timers = new Set();
|
|
90
|
+
/** GameObjects spawned in the current wave — tracked for endCondition. */
|
|
91
|
+
this.aliveInWave = new Set();
|
|
92
|
+
/** Count of GameObjects destroyed in the current wave (for `count` end condition). */
|
|
93
|
+
this.killedInWave = 0;
|
|
94
|
+
/** Wave-start timestamp (for `time` end condition). */
|
|
95
|
+
this.waveStartedAtMs = 0;
|
|
96
|
+
/** Polling timer for endCondition checks. */
|
|
97
|
+
this.endCheckTimer = null;
|
|
98
|
+
// Stop the schedule if the scene shuts down — frees all timers.
|
|
99
|
+
scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.stop());
|
|
100
|
+
}
|
|
101
|
+
start(record) {
|
|
102
|
+
if (this.stopped)
|
|
103
|
+
return;
|
|
104
|
+
this.record = record;
|
|
105
|
+
this.runWave(0);
|
|
106
|
+
}
|
|
107
|
+
currentWaveIndex() {
|
|
108
|
+
return this.waveIndex;
|
|
109
|
+
}
|
|
110
|
+
isComplete() {
|
|
111
|
+
return this.complete;
|
|
112
|
+
}
|
|
113
|
+
pause() {
|
|
114
|
+
if (this.paused || this.stopped)
|
|
115
|
+
return;
|
|
116
|
+
this.paused = true;
|
|
117
|
+
this.scene.time.paused = false; // we manage our own timers; don't pause the whole scene clock
|
|
118
|
+
for (const t of this.timers)
|
|
119
|
+
t.paused = true;
|
|
120
|
+
if (this.endCheckTimer)
|
|
121
|
+
this.endCheckTimer.paused = true;
|
|
122
|
+
}
|
|
123
|
+
resume() {
|
|
124
|
+
if (!this.paused || this.stopped)
|
|
125
|
+
return;
|
|
126
|
+
this.paused = false;
|
|
127
|
+
for (const t of this.timers)
|
|
128
|
+
t.paused = false;
|
|
129
|
+
if (this.endCheckTimer)
|
|
130
|
+
this.endCheckTimer.paused = false;
|
|
131
|
+
}
|
|
132
|
+
skipTo(index) {
|
|
133
|
+
if (this.stopped || !this.record)
|
|
134
|
+
return;
|
|
135
|
+
this.cancelTimers();
|
|
136
|
+
this.aliveInWave.clear();
|
|
137
|
+
this.killedInWave = 0;
|
|
138
|
+
this.runWave(Math.max(0, Math.min(index, this.record.waves.length)));
|
|
139
|
+
}
|
|
140
|
+
stop() {
|
|
141
|
+
if (this.stopped)
|
|
142
|
+
return;
|
|
143
|
+
this.stopped = true;
|
|
144
|
+
this.cancelTimers();
|
|
145
|
+
this.aliveInWave.clear();
|
|
146
|
+
this.complete = true;
|
|
147
|
+
this.callbacks.onScheduleEnd?.();
|
|
148
|
+
}
|
|
149
|
+
// ---- Wave lifecycle ------------------------------------------------------
|
|
150
|
+
runWave(index) {
|
|
151
|
+
if (this.stopped || !this.record)
|
|
152
|
+
return;
|
|
153
|
+
// Beyond the last wave — loop or end.
|
|
154
|
+
if (index >= this.record.waves.length) {
|
|
155
|
+
if (this.record.loop) {
|
|
156
|
+
this.runWave(0);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.complete = true;
|
|
160
|
+
this.callbacks.onScheduleEnd?.();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const wave = this.record.waves[index];
|
|
165
|
+
this.waveIndex = index;
|
|
166
|
+
const startWave = () => {
|
|
167
|
+
if (this.stopped)
|
|
168
|
+
return;
|
|
169
|
+
this.waveStartedAtMs = this.scene.time.now;
|
|
170
|
+
this.killedInWave = 0;
|
|
171
|
+
this.aliveInWave.clear();
|
|
172
|
+
this.callbacks.onWaveStart?.(index, wave);
|
|
173
|
+
this.scheduleSpawns(wave, index);
|
|
174
|
+
this.armEndConditionCheck(wave, index);
|
|
175
|
+
};
|
|
176
|
+
if (wave.delayMs && wave.delayMs > 0) {
|
|
177
|
+
this.addTimer(wave.delayMs, startWave);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
startWave();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
scheduleSpawns(wave, waveIndex) {
|
|
184
|
+
for (const ins of wave.spawns) {
|
|
185
|
+
switch (ins.kind) {
|
|
186
|
+
case 'point':
|
|
187
|
+
this.schedulePoint(ins, wave, waveIndex);
|
|
188
|
+
break;
|
|
189
|
+
case 'formation':
|
|
190
|
+
this.scheduleFormation(ins, wave, waveIndex);
|
|
191
|
+
break;
|
|
192
|
+
default: {
|
|
193
|
+
// Unknown kind — log + skip rather than crash. v1 ships point + formation;
|
|
194
|
+
// future kinds (`random`, ...) wire here.
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn('[umicat/waves] unknown spawn kind:', ins.kind);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
schedulePoint(ins, wave, waveIndex) {
|
|
202
|
+
this.addTimer(ins.atMs ?? 0, () => {
|
|
203
|
+
this.doSpawn({
|
|
204
|
+
prefabId: ins.prefabId,
|
|
205
|
+
x: ins.x,
|
|
206
|
+
y: ins.y,
|
|
207
|
+
waveId: wave.id,
|
|
208
|
+
waveIndex,
|
|
209
|
+
overrides: ins.overrides,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
scheduleFormation(ins, wave, waveIndex) {
|
|
214
|
+
const positions = expandFormation(ins);
|
|
215
|
+
for (let i = 0; i < positions.length; i++) {
|
|
216
|
+
const [x, y] = positions[i];
|
|
217
|
+
const at = ins.startMs + i * (ins.intervalMs ?? 0);
|
|
218
|
+
this.addTimer(at, () => {
|
|
219
|
+
this.doSpawn({
|
|
220
|
+
prefabId: ins.prefabId,
|
|
221
|
+
x,
|
|
222
|
+
y,
|
|
223
|
+
waveId: wave.id,
|
|
224
|
+
waveIndex,
|
|
225
|
+
overrides: ins.overrides,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
doSpawn(instance) {
|
|
231
|
+
if (this.stopped)
|
|
232
|
+
return;
|
|
233
|
+
const go = this.callbacks.onSpawn(instance);
|
|
234
|
+
if (!go)
|
|
235
|
+
return;
|
|
236
|
+
this.aliveInWave.add(go);
|
|
237
|
+
go.once(Phaser.GameObjects.Events.DESTROY, () => {
|
|
238
|
+
this.aliveInWave.delete(go);
|
|
239
|
+
this.killedInWave++;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
armEndConditionCheck(wave, waveIndex) {
|
|
243
|
+
const end = wave.endCondition ?? 'allDead';
|
|
244
|
+
const tick = () => {
|
|
245
|
+
if (this.stopped || this.paused)
|
|
246
|
+
return;
|
|
247
|
+
if (this.shouldEndWave(end)) {
|
|
248
|
+
this.endWave(wave, waveIndex);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// Poll once per 100ms. Light enough at any reasonable wave count;
|
|
252
|
+
// simpler than per-destroy event aggregation across all end-conditions.
|
|
253
|
+
this.endCheckTimer = this.scene.time.addEvent({
|
|
254
|
+
delay: 100,
|
|
255
|
+
loop: true,
|
|
256
|
+
callback: tick,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
shouldEndWave(end) {
|
|
260
|
+
if (end === 'allDead') {
|
|
261
|
+
// Wait until every scheduled spawn has fired AND every spawned instance
|
|
262
|
+
// is gone. While the schedule still has pending timer callbacks, alive
|
|
263
|
+
// can be 0 transiently — guard with the timer count.
|
|
264
|
+
const pendingTimers = pendingSpawnTimers(this.timers);
|
|
265
|
+
return pendingTimers === 0 && this.aliveInWave.size === 0;
|
|
266
|
+
}
|
|
267
|
+
if (end.type === 'time') {
|
|
268
|
+
return this.scene.time.now - this.waveStartedAtMs >= end.ms;
|
|
269
|
+
}
|
|
270
|
+
if (end.type === 'count') {
|
|
271
|
+
return this.killedInWave >= end.killed;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
endWave(wave, waveIndex) {
|
|
276
|
+
if (this.endCheckTimer) {
|
|
277
|
+
this.endCheckTimer.remove(false);
|
|
278
|
+
this.endCheckTimer = null;
|
|
279
|
+
}
|
|
280
|
+
this.callbacks.onWaveEnd?.(waveIndex, wave);
|
|
281
|
+
this.cancelTimers();
|
|
282
|
+
this.runWave(waveIndex + 1);
|
|
283
|
+
}
|
|
284
|
+
// ---- Timer plumbing ------------------------------------------------------
|
|
285
|
+
addTimer(delay, callback) {
|
|
286
|
+
const t = this.scene.time.addEvent({
|
|
287
|
+
delay: Math.max(0, delay),
|
|
288
|
+
callback: () => {
|
|
289
|
+
this.timers.delete(t);
|
|
290
|
+
callback();
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
this.timers.add(t);
|
|
294
|
+
}
|
|
295
|
+
cancelTimers() {
|
|
296
|
+
for (const t of this.timers)
|
|
297
|
+
t.remove(false);
|
|
298
|
+
this.timers.clear();
|
|
299
|
+
if (this.endCheckTimer) {
|
|
300
|
+
this.endCheckTimer.remove(false);
|
|
301
|
+
this.endCheckTimer = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Count of `addTimer` events that haven't fired yet. */
|
|
306
|
+
function pendingSpawnTimers(timers) {
|
|
307
|
+
let n = 0;
|
|
308
|
+
for (const t of timers)
|
|
309
|
+
if (!t.hasDispatched)
|
|
310
|
+
n++;
|
|
311
|
+
return n;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Expand a formation record into a list of [x, y] positions. v1 ships
|
|
315
|
+
* `grid` and `line`; `arc` and `v-shape` are forward-compat'd but expand
|
|
316
|
+
* to a single point at the origin with a warn.
|
|
317
|
+
*/
|
|
318
|
+
function expandFormation(ins) {
|
|
319
|
+
const f = ins.formation;
|
|
320
|
+
const rows = Math.max(1, f.rows ?? 1);
|
|
321
|
+
const cols = Math.max(1, f.cols ?? 1);
|
|
322
|
+
const sx = f.spacing?.x ?? 0;
|
|
323
|
+
const sy = f.spacing?.y ?? 0;
|
|
324
|
+
const ox = f.origin?.x ?? 0;
|
|
325
|
+
const oy = f.origin?.y ?? 0;
|
|
326
|
+
const positions = [];
|
|
327
|
+
if (f.shape === 'grid') {
|
|
328
|
+
for (let r = 0; r < rows; r++) {
|
|
329
|
+
for (let c = 0; c < cols; c++) {
|
|
330
|
+
positions.push([ox + c * sx, oy + r * sy]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return positions;
|
|
334
|
+
}
|
|
335
|
+
if (f.shape === 'line') {
|
|
336
|
+
for (let c = 0; c < cols; c++) {
|
|
337
|
+
positions.push([ox + c * sx, oy]);
|
|
338
|
+
}
|
|
339
|
+
return positions;
|
|
340
|
+
}
|
|
341
|
+
if (f.shape === 'v-shape') {
|
|
342
|
+
// simple v: rows = depth, cols = members per side; falls back to grid
|
|
343
|
+
// when ambiguous. v1 minimum: one row, cols members in a V centered on origin.
|
|
344
|
+
const half = Math.floor(cols / 2);
|
|
345
|
+
for (let c = 0; c < cols; c++) {
|
|
346
|
+
const dx = (c - half) * sx;
|
|
347
|
+
const dy = Math.abs(c - half) * sy;
|
|
348
|
+
positions.push([ox + dx, oy + dy]);
|
|
349
|
+
}
|
|
350
|
+
return positions;
|
|
351
|
+
}
|
|
352
|
+
if (f.shape === 'arc') {
|
|
353
|
+
const r = f.radius ?? 0;
|
|
354
|
+
const span = f.arcRadians ?? Math.PI;
|
|
355
|
+
for (let c = 0; c < cols; c++) {
|
|
356
|
+
const t = cols === 1 ? 0.5 : c / (cols - 1);
|
|
357
|
+
const theta = -span / 2 + t * span;
|
|
358
|
+
positions.push([ox + r * Math.sin(theta), oy + r * (1 - Math.cos(theta))]);
|
|
359
|
+
}
|
|
360
|
+
return positions;
|
|
361
|
+
}
|
|
362
|
+
// eslint-disable-next-line no-console
|
|
363
|
+
console.warn(`[umicat/waves] unknown formation shape '${f.shape}'; spawning single point at origin`);
|
|
364
|
+
return [[ox, oy]];
|
|
365
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wang autotile runtime (slice 6 Phase D — design doc 06 §7).
|
|
3
|
+
*
|
|
4
|
+
* The user paints with a TERRAIN (e.g. "grass"); the SDK picks the actual
|
|
5
|
+
* tile index for each affected cell from the tileset's per-terrain ruleMap
|
|
6
|
+
* based on the cell's bitmask. Editing one cell can cascade to up to 8
|
|
7
|
+
* neighbors because corner vertices (and edge neighbors, in 8-bit mode)
|
|
8
|
+
* are shared.
|
|
9
|
+
*
|
|
10
|
+
* Bit convention for 4-bit corner mode (matches design doc 06 §7.3):
|
|
11
|
+
*
|
|
12
|
+
* bit 0 = BR corner (1)
|
|
13
|
+
* bit 1 = BL corner (2)
|
|
14
|
+
* bit 2 = TR corner (4)
|
|
15
|
+
* bit 3 = TL corner (8)
|
|
16
|
+
*
|
|
17
|
+
* `0b1111 = 15` = fully interior. `0b0000 = 0` = cell is empty.
|
|
18
|
+
*
|
|
19
|
+
* D.2.5.1 storage model (replaces the prior vertex grid from 0.2.122–0.2.124):
|
|
20
|
+
* source of truth is a per-CELL boolean grid of size W×H, stashed on the
|
|
21
|
+
* layer's data manager. The corner state at a vertex is derived as "any
|
|
22
|
+
* of the 4 cells touching this vertex is painted with the terrain" — same
|
|
23
|
+
* semantics as the old vertex grid (each click set 4 vertices = the 4
|
|
24
|
+
* vertices of the clicked cell), expressed at cell granularity instead.
|
|
25
|
+
*
|
|
26
|
+
* Why cell-painted instead of vertex-painted:
|
|
27
|
+
* 1. **Truly authoritative**: vertex state was a derivation of cell-paint
|
|
28
|
+
* anyway. Storing the derivation invited drift (subtract paths on
|
|
29
|
+
* shared vertices got tricky).
|
|
30
|
+
* 2. **Sets up 8-bit Wang (D.2.5.2)**: edge bits = "is the neighbor cell
|
|
31
|
+
* on this side painted", which needs cell-level state. Vertex grid
|
|
32
|
+
* couldn't express this; cell grid does it for free.
|
|
33
|
+
*
|
|
34
|
+
* Reverse-derive on first paint of a fresh layer: walk `layer.data`, mark
|
|
35
|
+
* every cell whose tile index appears in the terrain's ruleMap as painted.
|
|
36
|
+
* Tiles outside the ruleMap leave cells unmarked (assumed not part of
|
|
37
|
+
* this terrain — stamped manually, or from a different terrain). One
|
|
38
|
+
* cell-painted grid per (layer, terrain) — v1 limits a layer to one
|
|
39
|
+
* terrain so we cache a single grid keyed by `terrainId`.
|
|
40
|
+
*/
|
|
41
|
+
import type Phaser from 'phaser';
|
|
42
|
+
import type { AssetRecord, WangTerrain } from './types.js';
|
|
43
|
+
/**
|
|
44
|
+
* One cell mutated by an autotile cascade. `prevIndex === -1` means the
|
|
45
|
+
* cell was empty before; `index === -1` means the cell is empty after.
|
|
46
|
+
* Callers emit these as the `previousCells` array on `tilemapEdited` so
|
|
47
|
+
* undo can restore the prior state in one shot.
|
|
48
|
+
*/
|
|
49
|
+
export interface AutotileAffectedCell {
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
index: number;
|
|
53
|
+
prevIndex: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a terrain by id from a tileset asset's autotile config. Returns
|
|
57
|
+
* null when the asset lacks autotile metadata or no terrain matches —
|
|
58
|
+
* callers typically warn + skip in that case.
|
|
59
|
+
*/
|
|
60
|
+
export declare function findTerrain(asset: AssetRecord | null | undefined, terrainId: string): WangTerrain | null;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the autotile kind (`'wang-4bit'` | `'wang-8bit'`) on a tileset
|
|
63
|
+
* asset. Defaults to `'wang-4bit'` for legacy assets where `autotile.kind`
|
|
64
|
+
* is missing (shouldn't happen with the validating backend parser, but
|
|
65
|
+
* defensive for hand-authored manifests).
|
|
66
|
+
*/
|
|
67
|
+
export declare function getAutotileKind(asset: AssetRecord | null | undefined): 'wang-4bit' | 'wang-8bit';
|
|
68
|
+
/**
|
|
69
|
+
* Invalidate the cached autotile state on a layer. Next autotile op
|
|
70
|
+
* rebuilds from the layer's current `data`. Call after structural
|
|
71
|
+
* mutations the grid model can't track (resize, removeLayer, tileset
|
|
72
|
+
* swap, terrain rule edits — `assetUpdate` does this automatically).
|
|
73
|
+
*
|
|
74
|
+
* Kept the old `invalidateAutotileVertices` name as an alias for
|
|
75
|
+
* back-compat with the 0.2.122–0.2.124 export surface — internal SDK
|
|
76
|
+
* code uses the new name, external consumers (none today) keep working.
|
|
77
|
+
*/
|
|
78
|
+
export declare function invalidateAutotileCells(layer: Phaser.Tilemaps.TilemapLayer): void;
|
|
79
|
+
/** @deprecated Renamed to `invalidateAutotileCells` (D.2.5.1). */
|
|
80
|
+
export declare const invalidateAutotileVertices: typeof invalidateAutotileCells;
|
|
81
|
+
/**
|
|
82
|
+
* Apply a Wang autotile paint (or erase) at cell `(cellX, cellY)`.
|
|
83
|
+
*
|
|
84
|
+
* Paint mode (`mode = 'paint'`): the cell's painted-flag is set; the 3×3
|
|
85
|
+
* cell window around the click is recomputed. Each cell's new bitmask
|
|
86
|
+
* drives the tile index from `terrain.ruleMap`. Bitmask 0 (no corners) →
|
|
87
|
+
* cell becomes empty. Missing ruleMap entries are treated the same way
|
|
88
|
+
* (no tile defined → cell empty).
|
|
89
|
+
*
|
|
90
|
+
* Erase mode (`mode = 'erase'`): same flow with the cell's painted-flag
|
|
91
|
+
* cleared. Neighbors whose other corners are still terrain stay painted;
|
|
92
|
+
* cells whose bitmask drops to 0 become empty.
|
|
93
|
+
*
|
|
94
|
+
* Returns the affected cells with previous + new tile indices (one entry
|
|
95
|
+
* per cell in the 3×3 window that lies inside the layer's bounds, even
|
|
96
|
+
* if the index didn't change — the caller may dedup downstream). Cells
|
|
97
|
+
* outside the layer's bounds are silently skipped.
|
|
98
|
+
*
|
|
99
|
+
* Cheap: ~9 cell mutations + a Map lookup per cell. Cell-painted grid is
|
|
100
|
+
* stashed on the layer's data manager so subsequent calls reuse it
|
|
101
|
+
* without rebuilding from `layer.data`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function applyAutotile(layer: Phaser.Tilemaps.TilemapLayer, cellX: number, cellY: number, terrain: WangTerrain, mode: 'paint' | 'erase', kind?: 'wang-4bit' | 'wang-8bit'): AutotileAffectedCell[];
|