@rpgjs/server 5.0.0-alpha.4 → 5.0.0-alpha.41

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.
Files changed (101) hide show
  1. package/dist/Gui/DialogGui.d.ts +5 -0
  2. package/dist/Gui/GameoverGui.d.ts +23 -0
  3. package/dist/Gui/Gui.d.ts +6 -0
  4. package/dist/Gui/MenuGui.d.ts +22 -3
  5. package/dist/Gui/NotificationGui.d.ts +1 -2
  6. package/dist/Gui/SaveLoadGui.d.ts +13 -0
  7. package/dist/Gui/ShopGui.d.ts +28 -3
  8. package/dist/Gui/TitleGui.d.ts +23 -0
  9. package/dist/Gui/index.d.ts +10 -1
  10. package/dist/Player/BattleManager.d.ts +34 -12
  11. package/dist/Player/ClassManager.d.ts +46 -13
  12. package/dist/Player/ComponentManager.d.ts +123 -0
  13. package/dist/Player/Components.d.ts +345 -0
  14. package/dist/Player/EffectManager.d.ts +86 -0
  15. package/dist/Player/ElementManager.d.ts +104 -0
  16. package/dist/Player/GoldManager.d.ts +22 -0
  17. package/dist/Player/GuiManager.d.ts +259 -0
  18. package/dist/Player/ItemFixture.d.ts +6 -0
  19. package/dist/Player/ItemManager.d.ts +450 -9
  20. package/dist/Player/MoveManager.d.ts +324 -69
  21. package/dist/Player/ParameterManager.d.ts +344 -14
  22. package/dist/Player/Player.d.ts +460 -8
  23. package/dist/Player/SkillManager.d.ts +197 -15
  24. package/dist/Player/StateManager.d.ts +89 -25
  25. package/dist/Player/VariableManager.d.ts +74 -0
  26. package/dist/RpgServer.d.ts +502 -64
  27. package/dist/RpgServerEngine.d.ts +2 -1
  28. package/dist/decorators/event.d.ts +46 -0
  29. package/dist/decorators/map.d.ts +287 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.js +21653 -20900
  32. package/dist/index.js.map +1 -1
  33. package/dist/logs/log.d.ts +2 -3
  34. package/dist/module.d.ts +43 -1
  35. package/dist/presets/index.d.ts +0 -9
  36. package/dist/rooms/BaseRoom.d.ts +132 -0
  37. package/dist/rooms/lobby.d.ts +10 -2
  38. package/dist/rooms/map.d.ts +1236 -17
  39. package/dist/services/save.d.ts +43 -0
  40. package/dist/storage/index.d.ts +1 -0
  41. package/dist/storage/localStorage.d.ts +23 -0
  42. package/package.json +14 -10
  43. package/src/Gui/DialogGui.ts +19 -4
  44. package/src/Gui/GameoverGui.ts +39 -0
  45. package/src/Gui/Gui.ts +23 -1
  46. package/src/Gui/MenuGui.ts +155 -6
  47. package/src/Gui/NotificationGui.ts +1 -2
  48. package/src/Gui/SaveLoadGui.ts +60 -0
  49. package/src/Gui/ShopGui.ts +146 -16
  50. package/src/Gui/TitleGui.ts +39 -0
  51. package/src/Gui/index.ts +15 -2
  52. package/src/Player/BattleManager.ts +91 -49
  53. package/src/Player/ClassManager.ts +118 -50
  54. package/src/Player/ComponentManager.ts +425 -19
  55. package/src/Player/Components.ts +380 -0
  56. package/src/Player/EffectManager.ts +81 -44
  57. package/src/Player/ElementManager.ts +109 -86
  58. package/src/Player/GoldManager.ts +32 -35
  59. package/src/Player/GuiManager.ts +308 -150
  60. package/src/Player/ItemFixture.ts +4 -5
  61. package/src/Player/ItemManager.ts +774 -355
  62. package/src/Player/MoveManager.ts +1544 -774
  63. package/src/Player/ParameterManager.ts +546 -104
  64. package/src/Player/Player.ts +1163 -88
  65. package/src/Player/SkillManager.ts +520 -195
  66. package/src/Player/StateManager.ts +170 -182
  67. package/src/Player/VariableManager.ts +101 -63
  68. package/src/RpgServer.ts +525 -63
  69. package/src/core/context.ts +1 -0
  70. package/src/decorators/event.ts +61 -0
  71. package/src/decorators/map.ts +327 -0
  72. package/src/index.ts +11 -1
  73. package/src/logs/log.ts +10 -3
  74. package/src/module.ts +126 -3
  75. package/src/presets/index.ts +1 -10
  76. package/src/rooms/BaseRoom.ts +232 -0
  77. package/src/rooms/lobby.ts +25 -7
  78. package/src/rooms/map.ts +2502 -194
  79. package/src/services/save.ts +147 -0
  80. package/src/storage/index.ts +1 -0
  81. package/src/storage/localStorage.ts +76 -0
  82. package/tests/battle.spec.ts +375 -0
  83. package/tests/change-map.spec.ts +72 -0
  84. package/tests/class.spec.ts +274 -0
  85. package/tests/effect.spec.ts +219 -0
  86. package/tests/element.spec.ts +221 -0
  87. package/tests/event.spec.ts +80 -0
  88. package/tests/gold.spec.ts +99 -0
  89. package/tests/item.spec.ts +609 -0
  90. package/tests/module.spec.ts +38 -0
  91. package/tests/move.spec.ts +601 -0
  92. package/tests/player-param.spec.ts +28 -0
  93. package/tests/prediction-reconciliation.spec.ts +182 -0
  94. package/tests/random-move.spec.ts +65 -0
  95. package/tests/skill.spec.ts +658 -0
  96. package/tests/state.spec.ts +467 -0
  97. package/tests/variable.spec.ts +185 -0
  98. package/tests/world-maps.spec.ts +896 -0
  99. package/vite.config.ts +16 -0
  100. package/dist/Player/Event.d.ts +0 -0
  101. package/src/Player/Event.ts +0 -0
package/src/rooms/map.ts CHANGED
@@ -1,7 +1,21 @@
1
- import { Action, MockConnection, Request, Room, RoomOnJoin } from "@signe/room";
2
- import { Hooks, IceMovement, ModulesToken, ProjectileMovement, ProjectileType, RpgCommonMap, ZoneData } from "@rpgjs/common";
1
+ import { Action, MockConnection, Request, Room, RoomMethods, RoomOnJoin } from "@signe/room";
2
+ import {
3
+ Hooks,
4
+ IceMovement,
5
+ ModulesToken,
6
+ ProjectileMovement,
7
+ ProjectileType,
8
+ RpgCommonMap,
9
+ Direction,
10
+ RpgCommonPlayer,
11
+ RpgShape,
12
+ findModules,
13
+ type MapPhysicsInitContext,
14
+ type MapPhysicsEntityContext,
15
+ } from "@rpgjs/common";
16
+ import { WorldMapsManager, type WeatherState, type WorldMapConfig } from "@rpgjs/common";
3
17
  import { RpgPlayer, RpgEvent } from "../Player/Player";
4
- import { generateShortUUID, sync, users } from "@signe/sync";
18
+ import { generateShortUUID, sync, type, users } from "@signe/sync";
5
19
  import { signal } from "@signe/reactive";
6
20
  import { inject } from "@signe/di";
7
21
  import { context } from "../core/context";;
@@ -9,6 +23,61 @@ import { finalize, lastValueFrom } from "rxjs";
9
23
  import { Subject } from "rxjs";
10
24
  import { BehaviorSubject } from "rxjs";
11
25
  import { COEFFICIENT_ELEMENTS, DAMAGE_CRITICAL, DAMAGE_PHYSIC, DAMAGE_SKILL } from "../presets";
26
+ import { z } from "zod";
27
+ import { EntityState } from "@rpgjs/physic";
28
+ import { MapOptions } from "../decorators/map";
29
+ import { EventMode } from "../decorators/event";
30
+ import { BaseRoom } from "./BaseRoom";
31
+ import { buildSaveSlotMeta, resolveSaveStorageStrategy } from "../services/save";
32
+ import { Log } from "../logs/log";
33
+
34
+ function isRpgLog(error: unknown): error is Log {
35
+ return error instanceof Log
36
+ || (typeof error === "object"
37
+ && error !== null
38
+ && "id" in error
39
+ && (error as any).name === "RpgLog");
40
+ }
41
+
42
+ /**
43
+ * Interface for input controls configuration
44
+ *
45
+ * Defines the structure for input validation and anti-cheat controls
46
+ */
47
+ export interface Controls {
48
+ /** Maximum allowed time delta between inputs in milliseconds */
49
+ maxTimeDelta?: number;
50
+ /** Maximum allowed frame delta between inputs */
51
+ maxFrameDelta?: number;
52
+ /** Minimum time between inputs in milliseconds */
53
+ minTimeBetweenInputs?: number;
54
+ /** Whether to enable anti-cheat validation */
55
+ enableAntiCheat?: boolean;
56
+ /** Maximum number of queued inputs processed per server tick */
57
+ maxInputsPerTick?: number;
58
+ }
59
+
60
+ /**
61
+ * Zod schema for validating map update request body
62
+ *
63
+ * This schema ensures that the required fields are present and properly typed
64
+ * when updating a map configuration.
65
+ */
66
+ const MapUpdateSchema = z.object({
67
+ /** Configuration object for the map (optional) */
68
+ config: z.any().optional(),
69
+ /** Damage formulas configuration (optional) */
70
+ damageFormulas: z.any().optional(),
71
+ /** Unique identifier for the map (required) */
72
+ id: z.string(),
73
+ /** Width of the map in pixels (required) */
74
+ width: z.number(),
75
+ /** Height of the map in pixels (required) */
76
+ height: z.number(),
77
+ });
78
+
79
+ const SAFE_MAP_WIDTH = 1000;
80
+ const SAFE_MAP_HEIGHT = 1000;
12
81
 
13
82
  /**
14
83
  * Interface representing hook methods available for map events
@@ -25,12 +94,13 @@ export interface EventHooks {
25
94
  /** Called when a player touches this event */
26
95
  onPlayerTouch?: (player: RpgPlayer) => void;
27
96
  /** Called when a player enters a shape */
28
- onInShape?: (zone: ZoneData, player: RpgPlayer) => void;
97
+ onInShape?: (zone: RpgShape, player: RpgPlayer) => void;
29
98
  /** Called when a player exits a shape */
30
- onOutShape?: (zone: ZoneData, player: RpgPlayer) => void;
31
-
32
- onDetectInShape?: (player: RpgPlayer, shape: ZoneData) => void;
33
- onDetectOutShape?: (player: RpgPlayer, shape: ZoneData) => void;
99
+ onOutShape?: (zone: RpgShape, player: RpgPlayer) => void;
100
+ /** Called when a player is detected entering a shape */
101
+ onDetectInShape?: (player: RpgPlayer, shape: RpgShape) => void;
102
+ /** Called when a player is detected exiting a shape */
103
+ onDetectOutShape?: (player: RpgPlayer, shape: RpgShape) => void;
34
104
  }
35
105
 
36
106
  /** Type for event class constructor */
@@ -42,9 +112,13 @@ export type EventPosOption = {
42
112
  id?: string,
43
113
 
44
114
  /** X position of the event on the map */
45
- x: number,
115
+ x?: number,
46
116
  /** Y position of the event on the map */
47
- y: number,
117
+ y?: number,
118
+ /** Event mode override */
119
+ mode?: EventMode | "shared" | "scenario",
120
+ /** Owner player id when mode is scenario */
121
+ scenarioOwnerId?: string,
48
122
  /**
49
123
  * Event definition - can be either:
50
124
  * - A class that extends RpgPlayer
@@ -53,259 +127,2493 @@ export type EventPosOption = {
53
127
  event: EventConstructor | (EventHooks & Record<string, any>)
54
128
  }
55
129
 
130
+ type CreateDynamicEventOptions = {
131
+ mode?: EventMode | "shared" | "scenario";
132
+ scenarioOwnerId?: string;
133
+ };
134
+
135
+ interface WeatherSetOptions {
136
+ sync?: boolean;
137
+ }
138
+
56
139
  @Room({
57
- path: "map-{id}",
58
- throttleSync: 0
140
+ path: "map-{id}"
59
141
  })
60
142
  export class RpgMap extends RpgCommonMap<RpgPlayer> implements RoomOnJoin {
143
+ /**
144
+ * Synchronized signal containing all players currently on the map
145
+ *
146
+ * This signal is automatically synchronized with clients using @signe/sync.
147
+ * Players are indexed by their unique ID.
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * // Get all players
152
+ * const allPlayers = map.players();
153
+ *
154
+ * // Get a specific player
155
+ * const player = map.players()['player-id'];
156
+ * ```
157
+ */
61
158
  @users(RpgPlayer) players = signal({});
159
+
160
+ /**
161
+ * Synchronized signal containing all events (NPCs, objects) on the map
162
+ *
163
+ * This signal is automatically synchronized with clients using @signe/sync.
164
+ * Events are indexed by their unique ID.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * // Get all events
169
+ * const allEvents = map.events();
170
+ *
171
+ * // Get a specific event
172
+ * const event = map.events()['event-id'];
173
+ * ```
174
+ */
62
175
  @sync(RpgPlayer) events = signal({});
176
+
177
+ /**
178
+ * Signal containing the map's database of items, classes, and other game data
179
+ *
180
+ * This database can be dynamically populated using `addInDatabase()` and
181
+ * `removeInDatabase()` methods. It's used to store game entities like items,
182
+ * classes, skills, etc. that are specific to this map.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * // Add data to database
187
+ * map.addInDatabase('Potion', PotionClass);
188
+ *
189
+ * // Access database
190
+ * const potion = map.database()['Potion'];
191
+ * ```
192
+ */
63
193
  database = signal({});
64
- maps: any[] = []
194
+
195
+ /**
196
+ * Array of map configurations - can contain MapOptions objects or instances of map classes
197
+ *
198
+ * This array stores the configuration for this map and any related maps.
199
+ * It's populated when the map is loaded via `updateMap()`.
200
+ */
201
+ maps: (MapOptions | any)[] = []
202
+
203
+ /**
204
+ * Array of sound IDs to play when players join the map
205
+ *
206
+ * These sounds are automatically played for each player when they join the map.
207
+ * Sounds must be defined on the client side.
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * // Set sounds for the map
212
+ * map.sounds = ['background-music', 'ambient-forest'];
213
+ * ```
214
+ */
215
+ sounds: string[] = []
216
+
217
+ /**
218
+ * BehaviorSubject that completes when the map data is ready
219
+ *
220
+ * This subject is used to signal when the map has finished loading all its data.
221
+ * Players wait for this to complete before the map is fully initialized.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * // Wait for map data to be ready
226
+ * map.dataIsReady$.subscribe(() => {
227
+ * console.log('Map is ready!');
228
+ * });
229
+ * ```
230
+ */
65
231
  dataIsReady$ = new BehaviorSubject<void>(undefined);
232
+
233
+ /**
234
+ * Global configuration object for the map
235
+ *
236
+ * This object contains configuration settings that apply to the entire map.
237
+ * It's populated from the map data when `updateMap()` is called.
238
+ */
66
239
  globalConfig: any = {}
240
+
241
+ /**
242
+ * Damage formulas configuration for the map
243
+ *
244
+ * Contains formulas for calculating damage from skills, physical attacks,
245
+ * critical hits, and element coefficients. Default formulas are merged
246
+ * with custom formulas when the map is loaded.
247
+ */
67
248
  damageFormulas: any = {}
249
+ private _weatherState: WeatherState | null = null;
250
+ /** Internal: Map of shapes by name */
251
+ private _shapes: Map<string, RpgShape> = new Map();
252
+ /** Internal: Map of shape entity UUIDs to RpgShape instances */
253
+ private _shapeEntities: Map<string, RpgShape> = new Map();
254
+ /** Internal: Subscription for the input processing loop */
255
+ private _inputLoopSubscription?: any;
256
+ /** Enable/disable automatic tick processing (useful for unit tests) */
257
+ private _autoTickEnabled: boolean = true;
258
+ /** Runtime templates for scenario events to instantiate per player */
259
+ private _scenarioEventTemplates: EventPosOption[] = [];
260
+ /** Runtime registry of event mode by id */
261
+ private _eventModeById: Map<string, EventMode> = new Map();
262
+ /** Runtime registry of scenario owner by event id */
263
+ private _eventOwnerById: Map<string, string> = new Map();
264
+ /** Runtime registry of spawned scenario event ids by player id */
265
+ private _scenarioEventIdsByPlayer: Map<string, Set<string>> = new Map();
68
266
 
69
- onJoin(player: RpgPlayer, conn: MockConnection) {
70
- player.map = this;
71
- player.context = context;
72
- player.conn = conn;
73
- this.physic.addMovableHitbox(player, player.x(), player.y(), player.hitbox().w, player.hitbox().h, {}, {
74
- enabled: true,
75
- friction: 0.8,
76
- minVelocity: 0.5
77
- });
78
- this.physic.registerMovementEvents(player.id, () => {
79
- player.animationName.set('walk')
80
- }, () => {
81
- player.animationName.set('stand')
82
- })
83
- this.dataIsReady$.pipe(
84
- finalize(() => {
85
- this.hooks
86
- .callHooks("server-player-onJoinMap", player, this)
87
- .subscribe();
88
- })
89
- ).subscribe();
267
+ autoSync: boolean = true;
268
+
269
+ constructor(room) {
270
+ super();
271
+ this.hooks.callHooks("server-map-onStart", this).subscribe();
272
+ const isTest = room.env.TEST === 'true' ? true : false;
273
+ if (isTest) {
274
+ this.autoSync = false;
275
+ this.setAutoTick(false);
276
+ this.autoTickEnabled = false;
277
+ this.throttleSync = 0;
278
+ this.throttleStorage = 0;
279
+ }
280
+ else {
281
+ this.throttleSync = this.isStandalone ? 1 : 50
282
+ this.throttleStorage = this.isStandalone ? 1 : 50
283
+ };
284
+ this.sessionExpiryTime = 1000 * 60 * 5;
285
+ this.setupCollisionDetection();
286
+ if (this._autoTickEnabled) {
287
+ this.loop();
288
+ }
90
289
  }
91
290
 
92
- get hooks() {
93
- return inject<Hooks>(context, ModulesToken);
291
+ onStart() {
292
+ return BaseRoom.prototype.onStart.call(this)
94
293
  }
95
294
 
96
- @Action('gui.interaction')
97
- guiInteraction(player: RpgPlayer, value) {
98
- //this.hooks.callHooks("server-player-guiInteraction", player, value);
99
- player.syncChanges();
295
+ protected emitPhysicsInit(context: MapPhysicsInitContext): void {
296
+ this.hooks.callHooks("server-map-onPhysicsInit", this, context).subscribe();
100
297
  }
101
298
 
102
- @Action('gui.exit')
103
- guiExit(player: RpgPlayer, { guiId, data }) {
104
- player.removeGui(guiId, data)
299
+ protected emitPhysicsEntityAdd(context: MapPhysicsEntityContext): void {
300
+ this.hooks.callHooks("server-map-onPhysicsEntityAdd", this, context).subscribe();
105
301
  }
106
302
 
107
- @Action('action')
108
- onAction(player: RpgPlayer, action: any) {
109
- const collisions = this.physic.getCollisions(player.id)
110
- const events: (RpgEvent | undefined)[] = collisions.map(id => this.getEvent(id))
111
- if (events.length > 0) {
112
- events.forEach(event => {
113
- event?.execMethod('onAction', [player, action]);
114
- });
303
+ protected emitPhysicsEntityRemove(context: MapPhysicsEntityContext): void {
304
+ this.hooks.callHooks("server-map-onPhysicsEntityRemove", this, context).subscribe();
305
+ }
306
+
307
+ protected emitPhysicsReset(): void {
308
+ this.hooks.callHooks("server-map-onPhysicsReset", this).subscribe();
309
+ }
310
+
311
+ private isPositiveNumber(value: unknown): value is number {
312
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
313
+ }
314
+
315
+ private resolveTrustedMapDimensions(map: any): void {
316
+ const normalizedId = typeof map?.id === "string"
317
+ ? map.id.replace(/^map-/, "")
318
+ : "";
319
+ const worldMapInfo = normalizedId
320
+ ? this.worldMapsManager?.getMapInfo(normalizedId)
321
+ : null;
322
+
323
+ if (!this.isPositiveNumber(map?.width)) {
324
+ map.width = this.isPositiveNumber(worldMapInfo?.width)
325
+ ? worldMapInfo.width
326
+ : SAFE_MAP_WIDTH;
327
+ }
328
+
329
+ if (!this.isPositiveNumber(map?.height)) {
330
+ map.height = this.isPositiveNumber(worldMapInfo?.height)
331
+ ? worldMapInfo.height
332
+ : SAFE_MAP_HEIGHT;
115
333
  }
116
- player.execMethod('onInput', [action]);
117
334
  }
118
335
 
119
- @Action('move')
120
- onInput(player: RpgPlayer, input: any) {
121
- this.movePlayer(player, input.input)
336
+ private normalizeEventMode(mode: unknown): EventMode {
337
+ return mode === EventMode.Scenario || mode === "scenario"
338
+ ? EventMode.Scenario
339
+ : EventMode.Shared;
122
340
  }
123
341
 
124
- @Request({
125
- path: "/map/update",
126
- method: "POST",
127
- })
128
- async updateMap(request: Request) {
129
- const map = await request.json()
130
- this.data.set(map)
131
- this.globalConfig = map.config
132
- this.damageFormulas = map.damageFormulas || {};
133
- this.damageFormulas = {
134
- damageSkill: DAMAGE_SKILL,
135
- damagePhysic: DAMAGE_PHYSIC,
136
- damageCritical: DAMAGE_CRITICAL,
137
- coefficientElements: COEFFICIENT_ELEMENTS,
138
- ...this.damageFormulas
342
+ private resolveEventMode(eventObj: any): EventMode {
343
+ if (!eventObj) return EventMode.Shared;
344
+
345
+ if (eventObj.mode !== undefined) {
346
+ return this.normalizeEventMode(eventObj.mode);
139
347
  }
140
- await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
141
348
 
142
- map.events = map.events ?? []
349
+ const eventDef = eventObj.event ?? eventObj;
350
+ if (eventDef?.mode !== undefined) {
351
+ return this.normalizeEventMode(eventDef.mode);
352
+ }
143
353
 
144
- if (map.id) {
145
- const mapFound = this.maps.find(m => m.id === map.id)
146
- if (mapFound.events) {
147
- map.events = [
148
- ...mapFound.events,
149
- ...map.events
150
- ]
151
- }
354
+ if (typeof eventDef === "function") {
355
+ const staticMode = (eventDef as any).mode;
356
+ const prototypeMode = (eventDef as any).prototype?.mode;
357
+ if (staticMode !== undefined) {
358
+ return this.normalizeEventMode(staticMode);
359
+ }
360
+ if (prototypeMode !== undefined) {
361
+ return this.normalizeEventMode(prototypeMode);
362
+ }
152
363
  }
153
364
 
154
- await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
365
+ return EventMode.Shared;
366
+ }
155
367
 
156
- this.loadPhysic()
368
+ private resolveScenarioOwnerId(eventObj: any): string | undefined {
369
+ if (!eventObj) return undefined;
370
+ const ownerId = eventObj.scenarioOwnerId
371
+ ?? eventObj._scenarioOwnerId
372
+ ?? eventObj.event?.scenarioOwnerId
373
+ ?? eventObj.event?._scenarioOwnerId;
374
+ return typeof ownerId === "string" && ownerId.length > 0 ? ownerId : undefined;
375
+ }
157
376
 
158
- for (let event of map.events ?? []) {
159
- await this.createDynamicEvent(event);
377
+ private normalizeEventObject(eventObj: EventPosOption | any): EventPosOption {
378
+ if (eventObj && typeof eventObj === "object" && "event" in eventObj) {
379
+ return eventObj as EventPosOption;
160
380
  }
381
+ return {
382
+ event: eventObj as any,
383
+ };
384
+ }
161
385
 
162
- this.dataIsReady$.complete()
163
- // TODO: Update map
386
+ private cloneEventTemplate(eventObj: EventPosOption): EventPosOption {
387
+ const clone: EventPosOption = { ...eventObj };
388
+ if (clone.event && typeof clone.event === "object") {
389
+ clone.event = { ...(clone.event as Record<string, any>) } as any;
390
+ }
391
+ return clone;
392
+ }
393
+
394
+ private buildRuntimeEventId(baseId: string | undefined, mode: EventMode, scenarioOwnerId?: string): string {
395
+ const fallbackId = baseId || generateShortUUID();
396
+ if (mode !== EventMode.Scenario || !scenarioOwnerId) {
397
+ return fallbackId;
398
+ }
399
+
400
+ const scopedId = `${fallbackId}::${scenarioOwnerId}`;
401
+ if (!this.events()[scopedId]) {
402
+ return scopedId;
403
+ }
404
+ return `${scopedId}::${generateShortUUID()}`;
405
+ }
406
+
407
+ private setEventRuntimeMetadata(eventId: string, mode: EventMode, scenarioOwnerId?: string): void {
408
+ this._eventModeById.set(eventId, mode);
409
+ if (mode === EventMode.Scenario && scenarioOwnerId) {
410
+ this._eventOwnerById.set(eventId, scenarioOwnerId);
411
+ const ids = this._scenarioEventIdsByPlayer.get(scenarioOwnerId) ?? new Set<string>();
412
+ ids.add(eventId);
413
+ this._scenarioEventIdsByPlayer.set(scenarioOwnerId, ids);
414
+ return;
415
+ }
416
+ this._eventOwnerById.delete(eventId);
417
+ }
418
+
419
+ private clearEventRuntimeMetadata(eventId: string): void {
420
+ this._eventModeById.delete(eventId);
421
+ const ownerId = this._eventOwnerById.get(eventId);
422
+ if (ownerId) {
423
+ const ids = this._scenarioEventIdsByPlayer.get(ownerId);
424
+ if (ids) {
425
+ ids.delete(eventId);
426
+ if (ids.size === 0) {
427
+ this._scenarioEventIdsByPlayer.delete(ownerId);
428
+ }
429
+ }
430
+ }
431
+ this._eventOwnerById.delete(eventId);
432
+ }
433
+
434
+ private getEventModeById(eventId: string): EventMode {
435
+ const runtimeMode = this._eventModeById.get(eventId);
436
+ if (runtimeMode) {
437
+ return runtimeMode;
438
+ }
439
+ const event = this.getEvent(eventId) as any;
440
+ return this.normalizeEventMode(event?.mode);
441
+ }
442
+
443
+ private getScenarioOwnerIdByEventId(eventId: string): string | undefined {
444
+ const runtimeOwnerId = this._eventOwnerById.get(eventId);
445
+ if (runtimeOwnerId) {
446
+ return runtimeOwnerId;
447
+ }
448
+ const event = this.getEvent(eventId) as any;
449
+ const ownerId = event?._scenarioOwnerId ?? event?.scenarioOwnerId;
450
+ return typeof ownerId === "string" && ownerId.length > 0 ? ownerId : undefined;
451
+ }
452
+
453
+ isEventVisibleForPlayer(eventOrId: string | RpgEvent, playerOrId: string | RpgPlayer): boolean {
454
+ const playerId = typeof playerOrId === "string" ? playerOrId : playerOrId?.id;
455
+ if (!playerId) {
456
+ return false;
457
+ }
458
+ const eventId = typeof eventOrId === "string" ? eventOrId : eventOrId?.id;
459
+ if (!eventId) {
460
+ return false;
461
+ }
462
+ const mode = this.getEventModeById(eventId);
463
+ if (mode === EventMode.Shared) {
464
+ return true;
465
+ }
466
+ const ownerId = this.getScenarioOwnerIdByEventId(eventId);
467
+ return ownerId === playerId;
468
+ }
469
+
470
+ private async spawnScenarioEventsForPlayer(player: RpgPlayer): Promise<void> {
471
+ if (!player?.id || this._scenarioEventTemplates.length === 0) {
472
+ return;
473
+ }
474
+ this.removeScenarioEventsForPlayer(player.id);
475
+ for (const template of this._scenarioEventTemplates) {
476
+ const clone = this.cloneEventTemplate(template);
477
+ await this.createDynamicEvent(clone, { mode: EventMode.Scenario, scenarioOwnerId: player.id });
478
+ }
164
479
  }
165
480
 
166
- addInDatabase(id: string, data: any) {
167
- this.database()[id] = data;
481
+ private removeScenarioEventsForPlayer(playerId: string): void {
482
+ const ids = this._scenarioEventIdsByPlayer.get(playerId);
483
+ if (!ids || ids.size === 0) {
484
+ return;
485
+ }
486
+ for (const eventId of [...ids]) {
487
+ const event = this.getEvent(eventId) as any;
488
+ if (event && typeof event.remove === "function") {
489
+ try {
490
+ event.remove();
491
+ continue;
492
+ }
493
+ catch {
494
+ // Fallback to direct map removal when the event lifecycle is already partially torn down.
495
+ }
496
+ }
497
+ this.removeEvent(eventId);
498
+ }
499
+ this._scenarioEventIdsByPlayer.delete(playerId);
168
500
  }
169
501
 
170
502
  /**
171
- * Creates a dynamic event on the map
172
- *
173
- * This method handles both class-based events and object-based events with hooks.
174
- * For class-based events, it creates a new instance of the class.
175
- * For object-based events, it creates a dynamic class that extends RpgPlayer and
176
- * implements the hook methods from the object.
503
+ * Setup collision detection between players, events, and shapes
177
504
  *
178
- * @param eventObj - The event position and definition
505
+ * This method listens to physics collision events and triggers hooks:
506
+ * - `onPlayerTouch` on events when a player collides with them
507
+ * - `onInShape` on players and events when they enter a shape
508
+ * - `onOutShape` on players and events when they exit a shape
179
509
  *
180
- * @example
181
- * // Using a class-based event
182
- * class MyEvent extends RpgPlayer {
183
- * onInit() {
184
- * console.log('Event initialized');
185
- * }
186
- * }
510
+ * ## Architecture
187
511
  *
188
- * map.createDynamicEvent({
189
- * x: 100,
190
- * y: 200,
191
- * event: MyEvent
192
- * });
512
+ * Uses the physics engine's collision event system to detect when entities collide.
513
+ * When a collision is detected:
514
+ * - Between a player and an event: triggers `onPlayerTouch` on the event
515
+ * - Between a player/event and a shape: triggers `onInShape`/`onOutShape` hooks
193
516
  *
194
- * // Using an object-based event
517
+ * @example
518
+ * ```ts
519
+ * // Event with onPlayerTouch hook
195
520
  * map.createDynamicEvent({
196
521
  * x: 100,
197
522
  * y: 200,
198
523
  * event: {
199
- * onInit() {
200
- * console.log('Event initialized');
201
- * },
202
524
  * onPlayerTouch(player) {
203
- * console.log('Player touched event');
525
+ * console.log(`Player ${player.id} touched this event!`);
204
526
  * }
205
527
  * }
206
528
  * });
529
+ *
530
+ * // Player with onInShape hook
531
+ * const player: RpgPlayerHooks = {
532
+ * onInShape(player: RpgPlayer, shape: RpgShape) {
533
+ * console.log('in', player.name, shape.name);
534
+ * },
535
+ * onOutShape(player: RpgPlayer, shape: RpgShape) {
536
+ * console.log('out', player.name, shape.name);
537
+ * }
538
+ * };
539
+ * ```
207
540
  */
208
- async createDynamicEvent(eventObj: EventPosOption) {
541
+ private setupCollisionDetection(): void {
542
+ // Track collisions to avoid calling hooks multiple times for the same collision
543
+ const activeCollisions = new Set<string>();
544
+ const activeShapeCollisions = new Set<string>();
545
+
546
+ // Helper function to check if entities have different z (height)
547
+ const hasDifferentZ = (entityA: any, entityB: any): boolean => {
548
+ const zA = entityA.owner?.z();
549
+ const zB = entityB.owner?.z();
550
+ return zA !== zB;
551
+ };
209
552
 
210
- if (!eventObj.event) {
211
- // @ts-ignore
212
- eventObj = {
213
- event: eventObj
553
+ // Listen to collision enter events
554
+ this.physic.getEvents().onCollisionEnter((collision) => {
555
+ const entityA = collision.entityA;
556
+ const entityB = collision.entityB;
557
+
558
+ // Skip collision callbacks if entities have different z (height)
559
+ // Higher z entities should not trigger collision callbacks with lower z entities
560
+ if (hasDifferentZ(entityA, entityB)) {
561
+ return;
214
562
  }
215
- }
216
563
 
217
- const value = await lastValueFrom(this.hooks.callHooks("server-event-onBeforeCreated", eventObj, this));
218
- value.filter(v => v).forEach(v => {
219
- eventObj = v
220
- })
564
+ // Create a unique key for this collision pair
565
+ const collisionKey = entityA.uuid < entityB.uuid
566
+ ? `${entityA.uuid}-${entityB.uuid}`
567
+ : `${entityB.uuid}-${entityA.uuid}`;
221
568
 
222
- const { x, y, event } = eventObj;
223
- let id = eventObj.id || generateShortUUID()
224
- let eventInstance: RpgPlayer;
569
+ // Skip if we've already processed this collision
570
+ if (activeCollisions.has(collisionKey)) {
571
+ return;
572
+ }
225
573
 
226
- if (this.events()[id]) {
227
- console.warn(`Event ${id} already exists on map`);
228
- return;
229
- }
574
+ // Check for shape collisions first
575
+ const shapeA = this._shapeEntities.get(entityA.uuid);
576
+ const shapeB = this._shapeEntities.get(entityB.uuid);
230
577
 
231
- // Check if event is a constructor function (class)
232
- if (typeof event === 'function') {
233
- eventInstance = new event();
234
- }
235
- // Handle event as an object with hooks
236
- else {
237
- // Create a new instance extending RpgPlayer with the hooks from the event object
238
- class DynamicEvent extends RpgEvent {
239
- onInit?: () => void;
240
- onChanges?: (player: RpgPlayer) => void;
241
- onAction?: (player: RpgPlayer) => void;
242
- onPlayerTouch?: (player: RpgPlayer) => void;
243
- onInShape?: (zone: ZoneData, player: RpgPlayer) => void;
244
- onOutShape?: (zone: ZoneData, player: RpgPlayer) => void;
245
- onDetectInShape?: (player: RpgPlayer, shape: ZoneData) => void;
246
- onDetectOutShape?: (player: RpgPlayer, shape: ZoneData) => void;
578
+ if (shapeA || shapeB) {
579
+ // One of the entities is a shape
580
+ const shape = shapeA || shapeB;
581
+ const otherEntity = shapeA ? entityB : entityA;
247
582
 
248
- constructor() {
249
- super();
250
-
251
- // Copy hooks from the event object
252
- const hookObj = event as EventHooks;
253
- if (hookObj.onInit) this.onInit = hookObj.onInit.bind(this);
254
- if (hookObj.onChanges) this.onChanges = hookObj.onChanges.bind(this);
255
- if (hookObj.onAction) this.onAction = hookObj.onAction.bind(this);
256
- if (hookObj.onPlayerTouch) this.onPlayerTouch = hookObj.onPlayerTouch.bind(this);
257
- if (hookObj.onInShape) this.onInShape = hookObj.onInShape.bind(this);
258
- if (hookObj.onOutShape) this.onOutShape = hookObj.onOutShape.bind(this);
259
- if (hookObj.onDetectInShape) this.onDetectInShape = hookObj.onDetectInShape.bind(this);
260
- if (hookObj.onDetectOutShape) this.onDetectOutShape = hookObj.onDetectOutShape.bind(this);
583
+ if (shape) {
584
+ const shapeKey = `${otherEntity.uuid}-${shape.name}`;
585
+ if (!activeShapeCollisions.has(shapeKey)) {
586
+ activeShapeCollisions.add(shapeKey);
587
+
588
+ // Check if the other entity is a player or event
589
+ const player = this.getPlayer(otherEntity.uuid);
590
+ const event = this.getEvent(otherEntity.uuid);
591
+
592
+ if (player) {
593
+ // Trigger onInShape hook on player
594
+ player.execMethod('onInShape', [player, shape]);
595
+ }
596
+ if (event) {
597
+ // Trigger onInShape hook on event
598
+ event.execMethod('onInShape', [shape, player || event]);
599
+ }
600
+ }
261
601
  }
602
+ return;
262
603
  }
263
604
 
264
- eventInstance = new DynamicEvent();
265
- }
605
+ // Check if one entity is a player and the other is an event
606
+ const player = this.getPlayer(entityA.uuid) || this.getPlayer(entityB.uuid);
607
+ if (!player) {
608
+ return;
609
+ }
266
610
 
267
- eventInstance.map = this;
268
- eventInstance.context = context;
611
+ // Determine which entity is the event
612
+ const eventId = player.id === entityA.uuid ? entityB.uuid : entityA.uuid;
613
+ const event = this.getEvent(eventId);
269
614
 
270
- eventInstance.x.set(x);
271
- eventInstance.y.set(y);
272
-
273
- this.events()[id] = eventInstance;
615
+ if (event && this.isEventVisibleForPlayer(eventId, player)) {
616
+ // Mark this collision as processed
617
+ activeCollisions.add(collisionKey);
274
618
 
275
- await eventInstance.execMethod('onInit')
276
- }
619
+ // Trigger the onPlayerTouch hook on the event
620
+ event.execMethod('onPlayerTouch', [player]);
621
+ }
622
+ });
277
623
 
278
- getEvent<T extends RpgPlayer>(eventId: string): T | undefined {
279
- return this.events()[eventId] as T
280
- }
624
+ // Listen to collision exit events to clean up tracking
625
+ this.physic.getEvents().onCollisionExit((collision) => {
626
+ const entityA = collision.entityA;
627
+ const entityB = collision.entityB;
281
628
 
282
- getPlayer(playerId: string): RpgPlayer | undefined {
283
- return this.players()[playerId]
284
- }
629
+ // Skip collision callbacks if entities have different z (height)
630
+ if (hasDifferentZ(entityA, entityB)) {
631
+ return;
632
+ }
285
633
 
286
- getEvents(): RpgEvent[] {
287
- return Object.values(this.events())
288
- }
634
+ const collisionKey = entityA.uuid < entityB.uuid
635
+ ? `${entityA.uuid}-${entityB.uuid}`
636
+ : `${entityB.uuid}-${entityA.uuid}`;
289
637
 
290
- removeEvent(eventId: string) {
291
- delete this.events()[eventId]
292
- }
638
+ // Check for shape collisions
639
+ const shapeA = this._shapeEntities.get(entityA.uuid);
640
+ const shapeB = this._shapeEntities.get(entityB.uuid);
293
641
 
294
- showAnimation(animationName: string, object: RpgPlayer) {
295
- this.$broadcast({
296
- type: 'showEffect',
297
- value: {
298
- id: 'animation',
299
- params: {
300
- name: animationName
301
- },
302
- object: object.id
642
+ if (shapeA || shapeB) {
643
+ // One of the entities is a shape
644
+ const shape = shapeA || shapeB;
645
+ const otherEntity = shapeA ? entityB : entityA;
646
+
647
+ if (shape) {
648
+ const shapeKey = `${otherEntity.uuid}-${shape.name}`;
649
+ if (activeShapeCollisions.has(shapeKey)) {
650
+ activeShapeCollisions.delete(shapeKey);
651
+
652
+ // Check if the other entity is a player or event
653
+ const player = this.getPlayer(otherEntity.uuid);
654
+ const event = this.getEvent(otherEntity.uuid);
655
+
656
+ if (player) {
657
+ // Trigger onOutShape hook on player
658
+ player.execMethod('onOutShape', [player, shape]);
659
+ }
660
+ if (event) {
661
+ // Trigger onOutShape hook on event
662
+ event.execMethod('onOutShape', [shape, player || event]);
663
+ }
664
+ }
665
+ }
666
+ return;
303
667
  }
304
- })
668
+
669
+ // Remove from active collisions so onPlayerTouch can be called again if they collide again
670
+ activeCollisions.delete(collisionKey);
671
+ });
672
+ }
673
+
674
+ /**
675
+ * Intercepts and modifies packets before they are sent to clients
676
+ *
677
+ * This method is automatically called by @signe/room for each packet sent to clients.
678
+ * It adds timestamp and acknowledgment information to sync packets for client-side
679
+ * prediction reconciliation. This helps with network synchronization and reduces
680
+ * perceived latency.
681
+ *
682
+ * ## Architecture
683
+ *
684
+ * Adds metadata to packets:
685
+ * - `timestamp`: Current server time for client-side prediction
686
+ * - `ack`: Acknowledgment info with last processed frame and authoritative position
687
+ *
688
+ * @param player - The player receiving the packet
689
+ * @param packet - The packet data to intercept
690
+ * @param conn - The connection object
691
+ * @returns Modified packet with timestamp and ack info, or null if player is invalid
692
+ *
693
+ * @example
694
+ * ```ts
695
+ * // This method is called automatically by the framework
696
+ * // You typically don't call it directly
697
+ * ```
698
+ */
699
+ interceptorPacket(player: RpgPlayer, packet: any, conn: MockConnection) {
700
+ let obj: any = {}
701
+ let packetValue = packet?.value;
702
+
703
+ if (!player) {
704
+ return null
705
+ }
706
+
707
+ // Add timestamp to sync packets for client-side prediction reconciliation
708
+ if (packet && typeof packet === 'object') {
709
+ obj.timestamp = Date.now();
710
+
711
+ // Add ack info: last processed frame and authoritative position.
712
+ // When the sync payload already contains this player's coordinates,
713
+ // prefer them to keep ack state aligned with the snapshot sent to the client.
714
+ if (player) {
715
+ const value = packet.value && typeof packet.value === "object" ? packet.value : undefined;
716
+ const packetPlayers = value?.players && typeof value.players === "object" ? value.players : undefined;
717
+ const playerSnapshot = packetPlayers?.[player.id];
718
+ const bodyPos = this.getBodyPosition(player.id, "top-left");
719
+ const ackX =
720
+ typeof playerSnapshot?.x === "number" ? playerSnapshot.x : bodyPos?.x ?? player.x();
721
+ const ackY =
722
+ typeof playerSnapshot?.y === "number" ? playerSnapshot.y : bodyPos?.y ?? player.y();
723
+ const lastFramePositions = player._lastFramePositions;
724
+ obj.ack = {
725
+ frame: lastFramePositions?.frame ?? 0,
726
+ serverTick: this.getTick(),
727
+ x: Math.round(ackX),
728
+ y: Math.round(ackY),
729
+ direction: playerSnapshot?.direction ?? player.direction(),
730
+ };
731
+ }
732
+ }
733
+
734
+ if (packetValue && typeof packetValue === "object" && packetValue.events && typeof packetValue.events === "object") {
735
+ const eventEntries = Object.entries(packetValue.events);
736
+ const filteredEntries = eventEntries.filter(([eventId]) => this.isEventVisibleForPlayer(eventId, player));
737
+ if (filteredEntries.length !== eventEntries.length) {
738
+ packetValue = { ...packetValue };
739
+ if (filteredEntries.length === 0) {
740
+ delete (packetValue as any).events;
741
+ }
742
+ else {
743
+ (packetValue as any).events = Object.fromEntries(filteredEntries);
744
+ }
745
+ }
746
+ }
747
+
748
+ if (typeof packet.value == 'string') {
749
+ return packet
750
+ }
751
+
752
+ return {
753
+ ...packet,
754
+ value: {
755
+ ...packetValue,
756
+ ...obj
757
+ }
758
+ };
759
+ }
760
+
761
+ /**
762
+ * Called when a player joins the map
763
+ *
764
+ * This method is automatically called by @signe/room when a player connects to the map.
765
+ * It initializes the player's connection, sets up the map context, and waits for
766
+ * the map data to be ready before playing sounds and triggering hooks.
767
+ *
768
+ * ## Architecture
769
+ *
770
+ * 1. Sets player's map reference and context
771
+ * 2. Initializes the player
772
+ * 3. Waits for map data to be ready
773
+ * 4. Plays map sounds for the player
774
+ * 5. Triggers `server-player-onJoinMap` hook
775
+ *
776
+ * @param player - The player joining the map
777
+ * @param conn - The connection object for the player
778
+ *
779
+ * @example
780
+ * ```ts
781
+ * // This method is called automatically by the framework
782
+ * // You can listen to the hook to perform custom logic
783
+ * server.addHook('server-player-onJoinMap', (player, map) => {
784
+ * console.log(`Player ${player.id} joined map ${map.id}`);
785
+ * });
786
+ * ```
787
+ */
788
+ onJoin(player: RpgPlayer, conn: MockConnection) {
789
+ if (player.setMap) {
790
+ player.setMap(this);
791
+ } else {
792
+ player.map = this;
793
+ }
794
+ player.context = context;
795
+ player.conn = conn;
796
+ player.pendingInputs = [];
797
+ player.lastProcessedInputTs = 0;
798
+ player._lastFramePositions = null;
799
+ player._onInit()
800
+ this.dataIsReady$.pipe(
801
+ finalize(() => {
802
+ // Avoid unhandled promise rejections from async hook execution.
803
+ void (async () => {
804
+ try {
805
+ const hitbox = typeof player.hitbox === 'function' ? player.hitbox() : player.hitbox;
806
+ const width = hitbox?.w ?? 32;
807
+ const height = hitbox?.h ?? 32;
808
+ const body = this.getBody(player.id) as any;
809
+ if (body) {
810
+ // Ensure physics callbacks target the current player instance
811
+ // after session transfer/map return.
812
+ body.owner = player;
813
+ }
814
+ // Keep physics body aligned with restored snapshot coordinates on map join.
815
+ this.updateHitbox(player.id, player.x(), player.y(), width, height);
816
+ await this.spawnScenarioEventsForPlayer(player);
817
+
818
+ // Check if we should stop all sounds before playing new ones
819
+ if ((this as any).stopAllSoundsBeforeJoin) {
820
+ player.stopAllSounds();
821
+ }
822
+
823
+ this.sounds.forEach(sound => player.playSound(sound, { loop: true }));
824
+ player.emit("weatherState", this.getWeather());
825
+
826
+ // Execute global map hooks (from RpgServer.map)
827
+ await lastValueFrom(this.hooks.callHooks("server-map-onJoin", player, this));
828
+
829
+ // // Execute map-specific hooks (from @MapData or MapOptions)
830
+ if (typeof (this as any)._onJoin === 'function') {
831
+ await (this as any)._onJoin(player);
832
+ }
833
+
834
+ // Execute player hooks
835
+ await lastValueFrom(this.hooks.callHooks("server-player-onJoinMap", player, this));
836
+ }
837
+ catch (error) {
838
+ if (isRpgLog(error)) {
839
+ console.warn(`[RpgLog:${error.id}] ${error.message}`);
840
+ return;
841
+ }
842
+ console.error("[RPGJS] Error during map onJoin hooks:", error);
843
+ }
844
+ })();
845
+ })
846
+ ).subscribe();
847
+ }
848
+
849
+ /**
850
+ * Called when a player leaves the map
851
+ *
852
+ * This method is automatically called by @signe/room when a player disconnects from the map.
853
+ * It cleans up the player's pending inputs and triggers the appropriate hooks.
854
+ *
855
+ * ## Architecture
856
+ *
857
+ * 1. Triggers `server-player-onLeaveMap` hook
858
+ * 2. Clears pending inputs to prevent processing after disconnection
859
+ *
860
+ * @param player - The player leaving the map
861
+ * @param conn - The connection object for the player
862
+ *
863
+ * @example
864
+ * ```ts
865
+ * // This method is called automatically by the framework
866
+ * // You can listen to the hook to perform custom cleanup
867
+ * server.addHook('server-player-onLeaveMap', (player, map) => {
868
+ * console.log(`Player ${player.id} left map ${map.id}`);
869
+ * });
870
+ * ```
871
+ */
872
+ async onLeave(player: RpgPlayer, conn: MockConnection) {
873
+ // Execute global map hooks (from RpgServer.map)
874
+ await lastValueFrom(this.hooks.callHooks("server-map-onLeave", player, this));
875
+
876
+ // Execute map-specific hooks (from @MapData or MapOptions)
877
+ if (typeof (this as any)._onLeave === 'function') {
878
+ await (this as any)._onLeave(player);
879
+ }
880
+
881
+ // Execute player hooks
882
+ await lastValueFrom(this.hooks.callHooks("server-player-onLeaveMap", player, this));
883
+ this.removeScenarioEventsForPlayer(player.id);
884
+ player.pendingInputs = [];
885
+ player.lastProcessedInputTs = 0;
886
+ player._lastFramePositions = null;
887
+ }
888
+
889
+ /**
890
+ * Get the hooks system for this map
891
+ *
892
+ * Returns the dependency-injected Hooks instance that allows you to trigger
893
+ * and listen to various game events.
894
+ *
895
+ * @returns The Hooks instance for this map
896
+ *
897
+ * @example
898
+ * ```ts
899
+ * // Trigger a custom hook
900
+ * map.hooks.callHooks('custom-event', data).subscribe();
901
+ * ```
902
+ */
903
+ get hooks() {
904
+ return BaseRoom.prototype.hooks;
905
+ }
906
+
907
+ async onSessionRestore(payload: { userSnapshot: any; user?: RpgPlayer }) {
908
+ return await BaseRoom.prototype.onSessionRestore.call(this, payload);
909
+ }
910
+
911
+ /**
912
+ * Handle GUI interaction from a player
913
+ *
914
+ * This method is called when a player interacts with a GUI element.
915
+ * It synchronizes the player's changes to ensure the client state is up to date.
916
+ *
917
+ * @param player - The player performing the interaction
918
+ * @param value - The interaction data from the client
919
+ *
920
+ * @example
921
+ * ```ts
922
+ * // This method is called automatically when a player interacts with a GUI
923
+ * // The interaction data is sent from the client
924
+ * ```
925
+ */
926
+ @Action('gui.interaction')
927
+ async guiInteraction(player: RpgPlayer, value: { guiId: string, name: string, data: any }) {
928
+ const gui = player.getGui(value.guiId)
929
+ if (gui) {
930
+ await gui.emit(value.name, value.data)
931
+ }
932
+ player.syncChanges();
933
+ }
934
+
935
+ /**
936
+ * Handle GUI exit from a player
937
+ *
938
+ * This method is called when a player closes or exits a GUI.
939
+ * It removes the GUI from the player's active GUIs.
940
+ *
941
+ * @param player - The player exiting the GUI
942
+ * @param guiId - The ID of the GUI being exited
943
+ * @param data - Optional data associated with the GUI exit
944
+ *
945
+ * @example
946
+ * ```ts
947
+ * // This method is called automatically when a player closes a GUI
948
+ * // The GUI is removed from the player's active GUIs
949
+ * ```
950
+ */
951
+ @Action('gui.exit')
952
+ guiExit(player: RpgPlayer, { guiId, data }) {
953
+ player.removeGui(guiId, data)
954
+ }
955
+
956
+ /**
957
+ * Handle action input from a player
958
+ *
959
+ * This method is called when a player performs an action (like pressing a button).
960
+ * It checks for collisions with events and triggers the appropriate hooks.
961
+ *
962
+ * ## Architecture
963
+ *
964
+ * 1. Gets all entities colliding with the player
965
+ * 2. Triggers `onAction` hook on colliding events
966
+ * 3. Triggers `onInput` hook on the player
967
+ *
968
+ * @param player - The player performing the action
969
+ * @param action - The action data (button pressed, etc.)
970
+ *
971
+ * @example
972
+ * ```ts
973
+ * // This method is called automatically when a player presses an action button
974
+ * // Events near the player will have their onAction hook triggered
975
+ * ```
976
+ */
977
+ @Action('action')
978
+ onAction(player: RpgPlayer, action: any) {
979
+ // Get collisions using the helper method from RpgCommonMap
980
+ const collisions = (this as any).getCollisions(player.id);
981
+ const events = collisions
982
+ .map(id => this.getEvent(id))
983
+ .filter((event): event is RpgEvent => !!event && this.isEventVisibleForPlayer(event, player));
984
+ if (events.length > 0) {
985
+ events.forEach(event => {
986
+ event.execMethod('onAction', [player, action]);
987
+ });
988
+ }
989
+ player.execMethod('onInput', [action]);
990
+ }
991
+
992
+ /**
993
+ * Handle movement input from a player
994
+ *
995
+ * This method is called when a player sends movement input from the client.
996
+ * It queues the input for processing by the game loop. Inputs are processed
997
+ * with frame numbers to ensure proper ordering and client-side prediction.
998
+ *
999
+ * ## Architecture
1000
+ *
1001
+ * - Inputs are queued in `player.pendingInputs`
1002
+ * - Duplicate frames are skipped to prevent processing the same input twice
1003
+ * - Inputs are processed asynchronously by the game loop
1004
+ *
1005
+ * @param player - The player sending the movement input
1006
+ * @param input - The input data containing frame number, input direction, and timestamp
1007
+ *
1008
+ * @example
1009
+ * ```ts
1010
+ * // This method is called automatically when a player moves
1011
+ * // The input is queued and processed by processInput()
1012
+ * ```
1013
+ */
1014
+ @Action('move')
1015
+ async onInput(player: RpgPlayer, input: any) {
1016
+ const lastAckedFrame = player._lastFramePositions?.frame ?? 0;
1017
+ const now = Date.now();
1018
+ const candidates: Array<{
1019
+ input: any;
1020
+ frame: number;
1021
+ tick?: number;
1022
+ timestamp: number;
1023
+ clientState?: { x: number; y: number; direction?: Direction };
1024
+ }> = [];
1025
+
1026
+ const enqueueCandidate = (entry: any) => {
1027
+ if (typeof entry?.frame !== "number") {
1028
+ return;
1029
+ }
1030
+ if (!entry?.input) {
1031
+ return;
1032
+ }
1033
+ const candidate: {
1034
+ input: any;
1035
+ frame: number;
1036
+ tick?: number;
1037
+ timestamp: number;
1038
+ clientState?: { x: number; y: number; direction?: Direction };
1039
+ } = {
1040
+ input: entry.input,
1041
+ frame: entry.frame,
1042
+ tick: typeof entry.tick === "number" ? entry.tick : undefined,
1043
+ timestamp: typeof entry.timestamp === "number" ? entry.timestamp : now,
1044
+ };
1045
+ if (typeof entry.x === "number" && typeof entry.y === "number") {
1046
+ candidate.clientState = {
1047
+ x: entry.x,
1048
+ y: entry.y,
1049
+ direction: entry.direction,
1050
+ };
1051
+ }
1052
+ candidates.push(candidate);
1053
+ };
1054
+
1055
+ for (const trajectoryEntry of Array.isArray(input?.trajectory) ? input.trajectory : []) {
1056
+ enqueueCandidate(trajectoryEntry);
1057
+ }
1058
+
1059
+ enqueueCandidate(input);
1060
+
1061
+ if (candidates.length === 0) {
1062
+ return;
1063
+ }
1064
+
1065
+ candidates.sort((a, b) => a.frame - b.frame);
1066
+ const existingFrames = new Set<number>(
1067
+ player.pendingInputs
1068
+ .map((pending: any) => pending?.frame)
1069
+ .filter((frame: any): frame is number => typeof frame === "number"),
1070
+ );
1071
+
1072
+ for (const candidate of candidates) {
1073
+ if (candidate.frame <= lastAckedFrame) {
1074
+ continue;
1075
+ }
1076
+ if (existingFrames.has(candidate.frame)) {
1077
+ continue;
1078
+ }
1079
+ player.pendingInputs.push(candidate);
1080
+ existingFrames.add(candidate.frame);
1081
+ }
1082
+ }
1083
+
1084
+ @Action("ping")
1085
+ onPing(player: RpgPlayer, payload: { clientTime?: number; clientFrame?: number }) {
1086
+ player.emit("pong", {
1087
+ serverTick: this.getTick(),
1088
+ clientTime: typeof payload?.clientTime === "number" ? payload.clientTime : Date.now(),
1089
+ clientFrame: typeof payload?.clientFrame === "number" ? payload.clientFrame : 0,
1090
+ });
1091
+ }
1092
+
1093
+ @Action('save.save')
1094
+ async saveSlot(player: RpgPlayer, value: { requestId: string; index: number; meta?: any }) {
1095
+ BaseRoom.prototype.saveSlot(player, value);
1096
+ }
1097
+
1098
+ @Action('save.load')
1099
+ async loadSlot(player: RpgPlayer, value: { requestId: string; index: number }) {
1100
+ BaseRoom.prototype.loadSlot(player, value);
1101
+ }
1102
+
1103
+ @Action('save.list')
1104
+ async listSaveSlots(player: RpgPlayer, value: { requestId: string }) {
1105
+ return await BaseRoom.prototype.listSaveSlots(player, value);
1106
+ }
1107
+
1108
+ /**
1109
+ * Update the map configuration and data
1110
+ *
1111
+ * This endpoint receives map data from the client and initializes the map.
1112
+ * It loads the map configuration, damage formulas, events, and physics.
1113
+ *
1114
+ * ## Architecture
1115
+ *
1116
+ * 1. Validates the request body using MapUpdateSchema
1117
+ * 2. Updates map data, global config, and damage formulas
1118
+ * 3. Merges events and sounds from map configuration
1119
+ * 4. Triggers hooks for map loading
1120
+ * 5. Loads physics engine
1121
+ * 6. Creates all events on the map
1122
+ * 7. Completes the dataIsReady$ subject
1123
+ *
1124
+ * @param request - HTTP request containing map data
1125
+ * @returns Promise that resolves when the map is fully loaded
1126
+ *
1127
+ * @example
1128
+ * ```ts
1129
+ * // This endpoint is called automatically when a map is loaded
1130
+ * // POST /map/update
1131
+ * // Body: { id: string, width: number, height: number, config?: any, damageFormulas?: any }
1132
+ * ```
1133
+ */
1134
+ @Request({
1135
+ path: "/map/update",
1136
+ method: "POST"
1137
+ }, MapUpdateSchema as any)
1138
+ async updateMap(request: Request) {
1139
+ const map = await request.json()
1140
+ this.data.set(map)
1141
+ this.globalConfig = map.config
1142
+ this.damageFormulas = map.damageFormulas || {};
1143
+ this.damageFormulas = {
1144
+ damageSkill: DAMAGE_SKILL,
1145
+ damagePhysic: DAMAGE_PHYSIC,
1146
+ damageCritical: DAMAGE_CRITICAL,
1147
+ coefficientElements: COEFFICIENT_ELEMENTS,
1148
+ ...this.damageFormulas
1149
+ }
1150
+ await lastValueFrom(this.hooks.callHooks("server-maps-load", this))
1151
+ await lastValueFrom(this.hooks.callHooks("server-worldMaps-load", this))
1152
+ await lastValueFrom(this.hooks.callHooks("server-databaseHooks-load", this))
1153
+
1154
+ this.resolveTrustedMapDimensions(map)
1155
+ this.data.set(map)
1156
+
1157
+ map.events = map.events ?? []
1158
+ let initialWeather: WeatherState | null | undefined = this.globalConfig?.weather;
1159
+
1160
+ if (map.id) {
1161
+ const mapFound = this.maps.find(m => m.id === map.id)
1162
+ if (typeof mapFound?.weather !== "undefined") {
1163
+ initialWeather = mapFound.weather;
1164
+ }
1165
+ if (mapFound?.events) {
1166
+ map.events = [
1167
+ ...mapFound.events,
1168
+ ...map.events
1169
+ ]
1170
+ }
1171
+ if (mapFound?.sounds) {
1172
+ this.sounds = [
1173
+ ...(map.sounds ?? []),
1174
+ ...mapFound.sounds
1175
+ ]
1176
+ }
1177
+ else {
1178
+ this.sounds = map.sounds ?? []
1179
+ }
1180
+
1181
+ // Attach map-specific hooks from MapOptions or @MapData
1182
+ if (mapFound?.onLoad) {
1183
+ (this as any)._onLoad = mapFound.onLoad;
1184
+ }
1185
+ if (mapFound?.onJoin) {
1186
+ (this as any)._onJoin = mapFound.onJoin;
1187
+ }
1188
+ if (mapFound?.onLeave) {
1189
+ (this as any)._onLeave = mapFound.onLeave;
1190
+ }
1191
+ if (mapFound?.stopAllSoundsBeforeJoin !== undefined) {
1192
+ (this as any).stopAllSoundsBeforeJoin = mapFound.stopAllSoundsBeforeJoin;
1193
+ }
1194
+ }
1195
+
1196
+ if (typeof initialWeather !== "undefined") {
1197
+ this.setWeather(initialWeather);
1198
+ } else {
1199
+ this.clearWeather();
1200
+ }
1201
+
1202
+ await lastValueFrom(this.hooks.callHooks("server-map-onBeforeUpdate", map, this))
1203
+
1204
+ this._scenarioEventTemplates = [];
1205
+ this._eventModeById.clear();
1206
+ this._eventOwnerById.clear();
1207
+ this._scenarioEventIdsByPlayer.clear();
1208
+
1209
+ this.loadPhysic()
1210
+
1211
+ for (let event of map.events ?? []) {
1212
+ const normalizedEvent = this.normalizeEventObject(event);
1213
+ const mode = this.resolveEventMode(normalizedEvent);
1214
+ if (mode === EventMode.Scenario) {
1215
+ this._scenarioEventTemplates.push(this.cloneEventTemplate(normalizedEvent));
1216
+ continue;
1217
+ }
1218
+ await this.createDynamicEvent(normalizedEvent, { mode: EventMode.Shared });
1219
+ }
1220
+
1221
+ for (const player of this.getPlayers()) {
1222
+ await this.spawnScenarioEventsForPlayer(player);
1223
+ }
1224
+
1225
+ this.dataIsReady$.complete()
1226
+
1227
+ // Execute global map hooks (from RpgServer.map)
1228
+ await lastValueFrom(this.hooks.callHooks("server-map-onLoad", this))
1229
+
1230
+ // Execute map-specific hooks (from @MapData or MapOptions)
1231
+ if (typeof (this as any)._onLoad === 'function') {
1232
+ await (this as any)._onLoad();
1233
+ }
1234
+
1235
+ // TODO: Update map
1236
+ }
1237
+
1238
+ /**
1239
+ * Update (or create) a world configuration and propagate to all maps in that world
1240
+ *
1241
+ * This endpoint receives world map configuration data (typically from Tiled world import)
1242
+ * and creates or updates the world manager. The world ID is extracted from the URL path.
1243
+ *
1244
+ * ## Architecture
1245
+ *
1246
+ * 1. Extracts world ID from URL path parameter
1247
+ * 2. Normalizes input to array of WorldMapConfig
1248
+ * 3. Ensures all required map properties are present (width, height, tile sizes)
1249
+ * 4. Creates or updates the world manager
1250
+ *
1251
+ * Expected payload examples:
1252
+ * - `{ id: string, maps: WorldMapConfig[] }`
1253
+ * - `WorldMapConfig[]`
1254
+ *
1255
+ * @param request - HTTP request containing world configuration
1256
+ * @returns Promise resolving to `{ ok: true }` when complete
1257
+ *
1258
+ * @example
1259
+ * ```ts
1260
+ * // POST /world/my-world/update
1261
+ * // Body: [{ id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 }]
1262
+ *
1263
+ * // Or with nested structure
1264
+ * // Body: { id: 'my-world', maps: [{ id: 'map1', ... }] }
1265
+ * ```
1266
+ */
1267
+ @Request({
1268
+ path: "/world/:id/update",
1269
+ method: "POST",
1270
+ })
1271
+ async updateWorld(request: Request) {
1272
+ // Extract world id from URL: /world/:id/update
1273
+ let worldId = '';
1274
+ try {
1275
+ const reqUrl = (request as any).url as string;
1276
+ const urlObj = new URL(reqUrl, 'http://localhost');
1277
+ const parts = urlObj.pathname.split('/');
1278
+ // ['', 'world', ':id', 'update'] → index 2
1279
+ worldId = parts[2] ?? '';
1280
+ } catch { }
1281
+ const payload = await request.json();
1282
+
1283
+ // Normalize input to array of WorldMapConfig
1284
+ const mapsConfig: WorldMapConfig[] = Array.isArray(payload)
1285
+ ? payload
1286
+ : payload?.maps ?? [];
1287
+
1288
+ // Ensure map sizes are present; fallback to current map data when ID matches
1289
+ const normalized: WorldMapConfig[] = mapsConfig.map((m: any) => {
1290
+ return {
1291
+ id: m.id,
1292
+ worldX: m.worldX ?? m.x ?? 0,
1293
+ worldY: m.worldY ?? m.y ?? 0,
1294
+ width: m.width ?? m.widthPx ?? this.data()?.width ?? 0,
1295
+ height: m.height ?? m.heightPx ?? this.data()?.height ?? 0,
1296
+ tileWidth: m.tileWidth ?? this.tileWidth ?? 32,
1297
+ tileHeight: m.tileHeight ?? this.tileHeight ?? 32,
1298
+ } as WorldMapConfig;
1299
+ });
1300
+
1301
+ await this.updateWorldMaps(worldId, normalized);
1302
+ return { ok: true } as any;
1303
+ }
1304
+
1305
+ /**
1306
+ * Process pending inputs for a player with anti-cheat validation
1307
+ *
1308
+ * This method processes pending inputs for a player while performing
1309
+ * anti-cheat validation to prevent time manipulation and frame skipping.
1310
+ * It validates the time deltas between inputs and ensures they are within
1311
+ * acceptable ranges. To preserve movement itinerary under network bursts,
1312
+ * the number of inputs processed per call is capped.
1313
+ *
1314
+ * ## Architecture
1315
+ *
1316
+ * **Important**: This method only updates entity velocities - it does NOT step
1317
+ * the physics engine. Physics simulation is handled centrally by the game loop
1318
+ * (`tick$` -> `runFixedTicks`). This ensures:
1319
+ * - Consistent physics timing (60fps fixed timestep)
1320
+ * - No double-stepping when multiple inputs are processed
1321
+ * - Deterministic physics regardless of input frequency
1322
+ *
1323
+ * @param playerId - The ID of the player to process inputs for
1324
+ * @param controls - Optional anti-cheat configuration
1325
+ * @returns Promise containing the player and processed input strings
1326
+ *
1327
+ * @example
1328
+ * ```ts
1329
+ * // Process inputs with default anti-cheat settings
1330
+ * const result = await map.processInput('player1');
1331
+ * console.log('Processed inputs:', result.inputs);
1332
+ *
1333
+ * // Process inputs with custom anti-cheat configuration
1334
+ * const result = await map.processInput('player1', {
1335
+ * maxTimeDelta: 100,
1336
+ * maxFrameDelta: 5,
1337
+ * minTimeBetweenInputs: 16,
1338
+ * enableAntiCheat: true
1339
+ * });
1340
+ * ```
1341
+ */
1342
+ async processInput(playerId: string, controls?: Controls): Promise<{
1343
+ player: RpgPlayer,
1344
+ inputs: string[]
1345
+ }> {
1346
+ const player = this.getPlayer(playerId);
1347
+ if (!player) {
1348
+ throw new Error(`Player ${playerId} not found`);
1349
+ }
1350
+
1351
+ if (!player.isConnected()) {
1352
+ player.pendingInputs = [];
1353
+ return {
1354
+ player,
1355
+ inputs: []
1356
+ }
1357
+ }
1358
+
1359
+ const processedInputs: string[] = [];
1360
+ const defaultControls: Required<Controls> = {
1361
+ maxTimeDelta: 1000, // 1 second max between inputs
1362
+ maxFrameDelta: 10, // Max 10 frames skipped
1363
+ minTimeBetweenInputs: 16, // ~60fps minimum
1364
+ enableAntiCheat: false,
1365
+ maxInputsPerTick: 1,
1366
+ };
1367
+
1368
+ const config = { ...defaultControls, ...controls };
1369
+ let lastProcessedTime = player.lastProcessedInputTs || 0;
1370
+ let lastProcessedFrame = player._lastFramePositions?.frame ?? 0;
1371
+
1372
+ // Sort inputs by frame number to ensure proper order
1373
+ player.pendingInputs.sort((a, b) => (a.frame || 0) - (b.frame || 0));
1374
+
1375
+ let hasProcessedInputs = false;
1376
+ let processedThisTick = 0;
1377
+
1378
+ // Process pending inputs progressively to preserve itinerary under latency.
1379
+ while (player.pendingInputs.length > 0 && processedThisTick < config.maxInputsPerTick) {
1380
+ const input = player.pendingInputs.shift();
1381
+
1382
+ if (!input || typeof input.frame !== 'number') {
1383
+ continue;
1384
+ }
1385
+
1386
+ // Anti-cheat validation
1387
+ if (config.enableAntiCheat) {
1388
+ // Check frame delta
1389
+ if (input.frame > lastProcessedFrame + config.maxFrameDelta) {
1390
+ // Reset to last valid frame
1391
+ input.frame = lastProcessedFrame + 1;
1392
+ }
1393
+
1394
+ // Check time delta if timestamp is available
1395
+ if (input.timestamp && lastProcessedTime > 0) {
1396
+ const timeDelta = input.timestamp - lastProcessedTime;
1397
+ if (timeDelta > config.maxTimeDelta) {
1398
+ input.timestamp = lastProcessedTime + config.minTimeBetweenInputs;
1399
+ }
1400
+ }
1401
+
1402
+ // Check minimum time between inputs
1403
+ if (input.timestamp && lastProcessedTime > 0) {
1404
+ const timeDelta = input.timestamp - lastProcessedTime;
1405
+ if (timeDelta < config.minTimeBetweenInputs) {
1406
+ continue;
1407
+ }
1408
+ }
1409
+ }
1410
+
1411
+ // Skip if frame is too old (more than 10 frames behind)
1412
+ if (input.frame < lastProcessedFrame - 10) {
1413
+ continue;
1414
+ }
1415
+
1416
+ // Process the input - update velocity based on the latest input
1417
+ if (input.input) {
1418
+ await this.movePlayer(player, input.input);
1419
+ processedInputs.push(input.input);
1420
+ hasProcessedInputs = true;
1421
+ lastProcessedTime = input.timestamp || Date.now();
1422
+ processedThisTick += 1;
1423
+
1424
+ const bodyPos = this.getBodyPosition(player.id, "top-left");
1425
+ const ackX =
1426
+ typeof input.clientState?.x === "number"
1427
+ ? input.clientState.x
1428
+ : bodyPos?.x ?? player.x();
1429
+ const ackY =
1430
+ typeof input.clientState?.y === "number"
1431
+ ? input.clientState.y
1432
+ : bodyPos?.y ?? player.y();
1433
+ player._lastFramePositions = {
1434
+ frame: input.frame,
1435
+ position: {
1436
+ x: Math.round(ackX),
1437
+ y: Math.round(ackY),
1438
+ direction: input.clientState?.direction ?? player.direction(),
1439
+ },
1440
+ serverTick: this.getTick(),
1441
+ };
1442
+ }
1443
+
1444
+ // Update tracking variables
1445
+ lastProcessedFrame = input.frame;
1446
+ }
1447
+
1448
+ // Physics is now handled by the main game loop (tick$ -> runFixedTicks)
1449
+ // We only update timestamps and handle idle timeout here
1450
+ // The physics step will be executed in the next tick cycle
1451
+ if (hasProcessedInputs) {
1452
+ player.lastProcessedInputTs = lastProcessedTime;
1453
+ } else {
1454
+ const idleTimeout = Math.max(config.minTimeBetweenInputs * 4, 50);
1455
+ const lastTs = player.lastProcessedInputTs || 0;
1456
+ if (lastTs > 0 && Date.now() - lastTs > idleTimeout) {
1457
+ (this as any).stopMovement(player);
1458
+ player.lastProcessedInputTs = 0;
1459
+ }
1460
+ }
1461
+
1462
+ return {
1463
+ player,
1464
+ inputs: processedInputs
1465
+ };
1466
+ }
1467
+
1468
+ /**
1469
+ * Main game loop that processes player inputs
1470
+ *
1471
+ * This private method subscribes to tick$ and processes pending inputs
1472
+ * for all players on the map with a throttle of 50ms. It ensures inputs are
1473
+ * processed in order and prevents concurrent processing for the same player.
1474
+ *
1475
+ * ## Architecture
1476
+ *
1477
+ * - Subscribes to tick$ with throttleTime(50ms) for responsive input processing
1478
+ * - Processes inputs for each player with pending inputs
1479
+ * - Uses a flag to prevent concurrent processing for the same player
1480
+ * - Calls `processInput()` to handle anti-cheat validation and movement
1481
+ *
1482
+ * @example
1483
+ * ```ts
1484
+ * // This method is called automatically in the constructor if autoTick is enabled
1485
+ * // You typically don't call it directly
1486
+ * ```
1487
+ */
1488
+ private loop() {
1489
+ if (this._inputLoopSubscription) {
1490
+ this._inputLoopSubscription.unsubscribe();
1491
+ }
1492
+
1493
+ this._inputLoopSubscription = this.tick$.subscribe(() => {
1494
+ for (const player of this.getPlayers()) {
1495
+ const anyPlayer = player as any;
1496
+ const shouldProcess = player.pendingInputs.length > 0 || (player.lastProcessedInputTs || 0) > 0;
1497
+ if (!shouldProcess || anyPlayer._isProcessingInputs) {
1498
+ continue;
1499
+ }
1500
+ anyPlayer._isProcessingInputs = true;
1501
+ void this.processInput(player.id).finally(() => {
1502
+ anyPlayer._isProcessingInputs = false;
1503
+ });
1504
+ }
1505
+ });
1506
+ }
1507
+
1508
+ /**
1509
+ * Enable or disable automatic tick processing
1510
+ *
1511
+ * When disabled, the input processing loop will not run automatically.
1512
+ * This is useful for unit tests where you want manual control over when
1513
+ * inputs are processed.
1514
+ *
1515
+ * @param enabled - Whether to enable automatic tick processing (default: true)
1516
+ *
1517
+ * @example
1518
+ * ```ts
1519
+ * // Disable auto tick for testing
1520
+ * map.setAutoTick(false);
1521
+ *
1522
+ * // Manually trigger tick processing
1523
+ * await map.processInput('player1');
1524
+ * ```
1525
+ */
1526
+ setAutoTick(enabled: boolean): void {
1527
+ this._autoTickEnabled = enabled;
1528
+ if (enabled && !this._inputLoopSubscription) {
1529
+ this.loop();
1530
+ } else if (!enabled && this._inputLoopSubscription) {
1531
+ this._inputLoopSubscription.unsubscribe();
1532
+ this._inputLoopSubscription = undefined;
1533
+ }
1534
+ }
1535
+
1536
+ /**
1537
+ * Get a world manager by id
1538
+ *
1539
+ * Returns the world maps manager for the given world ID. Currently, only
1540
+ * one world manager is supported per map instance.
1541
+ *
1542
+ * @param id - The world ID (currently unused, returns the single manager)
1543
+ * @returns The WorldMapsManager instance, or null if not initialized
1544
+ *
1545
+ * @example
1546
+ * ```ts
1547
+ * const worldManager = map.getWorldMaps('my-world');
1548
+ * if (worldManager) {
1549
+ * const mapInfo = worldManager.getMapInfo('map1');
1550
+ * }
1551
+ * ```
1552
+ */
1553
+ getWorldMaps(id: string): WorldMapsManager | null {
1554
+ if (!this.worldMapsManager) return null;
1555
+ return this.worldMapsManager;
1556
+ }
1557
+
1558
+ /**
1559
+ * Delete a world manager by id
1560
+ *
1561
+ * Removes the world maps manager from this map instance. Currently, only
1562
+ * one world manager is supported, so this clears the single manager.
1563
+ *
1564
+ * @param id - The world ID (currently unused)
1565
+ * @returns true if the manager was deleted, false if it didn't exist
1566
+ *
1567
+ * @example
1568
+ * ```ts
1569
+ * const deleted = map.deleteWorldMaps('my-world');
1570
+ * if (deleted) {
1571
+ * console.log('World manager removed');
1572
+ * }
1573
+ * ```
1574
+ */
1575
+ deleteWorldMaps(id: string): boolean {
1576
+ if (!this.worldMapsManager) return false;
1577
+ // For now, clear the single manager
1578
+ this.worldMapsManager = undefined;
1579
+ return true;
1580
+ }
1581
+
1582
+ /**
1583
+ * Create a world manager dynamically
1584
+ *
1585
+ * Creates a new WorldMapsManager instance and configures it with the provided
1586
+ * map configurations. This is used when loading world data from Tiled or
1587
+ * other map editors.
1588
+ *
1589
+ * @param world - World configuration object
1590
+ * @param world.id - Optional world identifier
1591
+ * @param world.maps - Array of map configurations for the world
1592
+ * @returns The newly created WorldMapsManager instance
1593
+ *
1594
+ * @example
1595
+ * ```ts
1596
+ * const manager = map.createDynamicWorldMaps({
1597
+ * id: 'my-world',
1598
+ * maps: [
1599
+ * { id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 },
1600
+ * { id: 'map2', worldX: 800, worldY: 0, width: 800, height: 600 }
1601
+ * ]
1602
+ * });
1603
+ * ```
1604
+ */
1605
+ createDynamicWorldMaps(world: { id?: string; maps: WorldMapConfig[] }): WorldMapsManager {
1606
+ const manager = new WorldMapsManager();
1607
+ manager.configure(world.maps);
1608
+ this.worldMapsManager = manager;
1609
+ return manager;
1610
+ }
1611
+
1612
+ /**
1613
+ * Update world maps by id. Auto-create when missing.
1614
+ *
1615
+ * Updates the world maps configuration. If the world manager doesn't exist,
1616
+ * it is automatically created. This is useful for dynamically loading world
1617
+ * data or updating map positions.
1618
+ *
1619
+ * @param id - The world ID
1620
+ * @param maps - Array of map configurations to update
1621
+ * @returns Promise that resolves when the update is complete
1622
+ *
1623
+ * @example
1624
+ * ```ts
1625
+ * await map.updateWorldMaps('my-world', [
1626
+ * { id: 'map1', worldX: 0, worldY: 0, width: 800, height: 600 },
1627
+ * { id: 'map2', worldX: 800, worldY: 0, width: 800, height: 600 }
1628
+ * ]);
1629
+ * ```
1630
+ */
1631
+ async updateWorldMaps(id: string, maps: WorldMapConfig[]) {
1632
+ let world = this.getWorldMaps(id);
1633
+ if (!world) {
1634
+ world = this.createDynamicWorldMaps({ id, maps });
1635
+ } else {
1636
+ world.configure(maps);
1637
+ }
1638
+ }
1639
+
1640
+ /**
1641
+ * Add data to the map's database
1642
+ *
1643
+ * This method delegates to BaseRoom's implementation to avoid code duplication.
1644
+ *
1645
+ * @param id - Unique identifier for the data
1646
+ * @param data - The data to store (can be a class, object, or any value)
1647
+ * @param options - Optional configuration
1648
+ * @param options.force - If true, overwrites existing data even if ID already exists (default: false)
1649
+ * @returns true if data was added, false if ignored (ID already exists)
1650
+ *
1651
+ * @example
1652
+ * ```ts
1653
+ * // Add an item class to the database
1654
+ * map.addInDatabase('Potion', PotionClass);
1655
+ *
1656
+ * // Add an item object to the database
1657
+ * map.addInDatabase('custom-item', {
1658
+ * name: 'Custom Item',
1659
+ * price: 100
1660
+ * });
1661
+ *
1662
+ * // Force overwrite existing data
1663
+ * map.addInDatabase('Potion', UpdatedPotionClass, { force: true });
1664
+ * ```
1665
+ */
1666
+ addInDatabase(id: string, data: any, options?: { force?: boolean }): boolean {
1667
+ return BaseRoom.prototype.addInDatabase.call(this, id, data, options);
1668
+ }
1669
+
1670
+ /**
1671
+ * Remove data from the map's database
1672
+ *
1673
+ * This method delegates to BaseRoom's implementation to avoid code duplication.
1674
+ *
1675
+ * @param id - Unique identifier of the data to remove
1676
+ * @returns true if data was removed, false if ID didn't exist
1677
+ *
1678
+ * @example
1679
+ * ```ts
1680
+ * // Remove an item from the database
1681
+ * map.removeInDatabase('Potion');
1682
+ *
1683
+ * // Check if removal was successful
1684
+ * const removed = map.removeInDatabase('custom-item');
1685
+ * if (removed) {
1686
+ * console.log('Item removed successfully');
1687
+ * }
1688
+ * ```
1689
+ */
1690
+ removeInDatabase(id: string): boolean {
1691
+ return BaseRoom.prototype.removeInDatabase.call(this, id);
1692
+ }
1693
+
1694
+ /**
1695
+ * Creates a dynamic event on the map
1696
+ *
1697
+ * This method handles both class-based events and object-based events with hooks.
1698
+ * For class-based events, it creates a new instance of the class.
1699
+ * For object-based events, it creates a dynamic class that extends RpgPlayer and
1700
+ * implements the hook methods from the object.
1701
+ *
1702
+ * @param eventObj - The event position and definition
1703
+ *
1704
+ * @example
1705
+ * // Using a class-based event
1706
+ * class MyEvent extends RpgPlayer {
1707
+ * onInit() {
1708
+ * console.log('Event initialized');
1709
+ * }
1710
+ * }
1711
+ *
1712
+ * map.createDynamicEvent({
1713
+ * x: 100,
1714
+ * y: 200,
1715
+ * event: MyEvent
1716
+ * });
1717
+ *
1718
+ * // Using an object-based event
1719
+ * map.createDynamicEvent({
1720
+ * x: 100,
1721
+ * y: 200,
1722
+ * event: {
1723
+ * onInit() {
1724
+ * console.log('Event initialized');
1725
+ * },
1726
+ * onPlayerTouch(player) {
1727
+ * console.log('Player touched event');
1728
+ * }
1729
+ * }
1730
+ * });
1731
+ */
1732
+ async createDynamicEvent(eventObj: EventPosOption, options: CreateDynamicEventOptions = {}): Promise<string | undefined> {
1733
+ eventObj = this.normalizeEventObject(eventObj);
1734
+
1735
+ const value = await lastValueFrom(this.hooks.callHooks("server-event-onBeforeCreated", eventObj, this));
1736
+ value.filter(v => v).forEach(v => {
1737
+ eventObj = v;
1738
+ });
1739
+
1740
+ const event = eventObj.event;
1741
+ const x = typeof eventObj.x === "number" ? eventObj.x : 0;
1742
+ const y = typeof eventObj.y === "number" ? eventObj.y : 0;
1743
+
1744
+ const requestedMode = options.mode ?? this.resolveEventMode(eventObj);
1745
+ const mode = this.normalizeEventMode(requestedMode);
1746
+ const ownerFromData = options.scenarioOwnerId ?? this.resolveScenarioOwnerId(eventObj);
1747
+ const scenarioOwnerId = mode === EventMode.Scenario ? ownerFromData : undefined;
1748
+ const effectiveMode = mode === EventMode.Scenario && scenarioOwnerId
1749
+ ? EventMode.Scenario
1750
+ : EventMode.Shared;
1751
+
1752
+ if (mode === EventMode.Scenario && !scenarioOwnerId) {
1753
+ console.warn("Scenario event created without owner id. Falling back to shared mode.");
1754
+ }
1755
+
1756
+ const id = this.buildRuntimeEventId(eventObj.id, effectiveMode, scenarioOwnerId);
1757
+ let eventInstance: RpgPlayer;
1758
+
1759
+ if (this.events()[id]) {
1760
+ console.warn(`Event ${id} already exists on map`);
1761
+ return undefined;
1762
+ }
1763
+
1764
+ // Check if event is a constructor function (class)
1765
+ if (typeof event === 'function') {
1766
+ eventInstance = new event();
1767
+ if (event.prototype.name) eventInstance.name.set(event.prototype.name);
1768
+ }
1769
+ // Handle event as an object with hooks
1770
+ else {
1771
+ // Create a new instance extending RpgPlayer with the hooks from the event object
1772
+ class DynamicEvent extends RpgEvent {
1773
+ onInit?: () => void;
1774
+ onChanges?: (player: RpgPlayer) => void;
1775
+ onAction?: (player: RpgPlayer) => void;
1776
+ onPlayerTouch?: (player: RpgPlayer) => void;
1777
+ onInShape?: (zone: RpgShape, player: RpgPlayer) => void;
1778
+ onOutShape?: (zone: RpgShape, player: RpgPlayer) => void;
1779
+ onDetectInShape?: (player: RpgPlayer, shape: RpgShape) => void;
1780
+ onDetectOutShape?: (player: RpgPlayer, shape: RpgShape) => void;
1781
+
1782
+ constructor() {
1783
+ super();
1784
+
1785
+ // Copy hooks from the event object
1786
+ const hookObj = event as EventHooks;
1787
+ if (hookObj.onInit) this.onInit = hookObj.onInit.bind(this);
1788
+ if (hookObj.onChanges) this.onChanges = hookObj.onChanges.bind(this);
1789
+ if (hookObj.onAction) this.onAction = hookObj.onAction.bind(this);
1790
+ if (hookObj.onPlayerTouch) this.onPlayerTouch = hookObj.onPlayerTouch.bind(this);
1791
+ if (hookObj.onInShape) this.onInShape = hookObj.onInShape.bind(this);
1792
+ if (hookObj.onOutShape) this.onOutShape = hookObj.onOutShape.bind(this);
1793
+ if (hookObj.onDetectInShape) this.onDetectInShape = hookObj.onDetectInShape.bind(this);
1794
+ if (hookObj.onDetectOutShape) this.onDetectOutShape = hookObj.onDetectOutShape.bind(this);
1795
+ }
1796
+ }
1797
+
1798
+ eventInstance = new DynamicEvent();
1799
+ if ((event as any).name) eventInstance.name.set((event as any).name);
1800
+ }
1801
+
1802
+ (eventInstance as any).mode = effectiveMode;
1803
+ if (effectiveMode === EventMode.Scenario && scenarioOwnerId) {
1804
+ (eventInstance as any)._scenarioOwnerId = scenarioOwnerId;
1805
+ (eventInstance as any).scenarioOwnerId = scenarioOwnerId;
1806
+ }
1807
+ else {
1808
+ delete (eventInstance as any)._scenarioOwnerId;
1809
+ delete (eventInstance as any).scenarioOwnerId;
1810
+ }
1811
+
1812
+ eventInstance.map = this;
1813
+ eventInstance.context = context;
1814
+
1815
+ await eventInstance.teleport({ x, y });
1816
+
1817
+ this.events()[id] = eventInstance;
1818
+ this.setEventRuntimeMetadata(id, effectiveMode, scenarioOwnerId);
1819
+
1820
+ await eventInstance.execMethod('onInit');
1821
+ return id;
1822
+ }
1823
+
1824
+ /**
1825
+ * Get an event by its ID
1826
+ *
1827
+ * Returns the event with the specified ID, or undefined if not found.
1828
+ * The return type can be narrowed using TypeScript generics.
1829
+ *
1830
+ * @param eventId - The unique identifier of the event
1831
+ * @returns The event instance, or undefined if not found
1832
+ *
1833
+ * @example
1834
+ * ```ts
1835
+ * // Get any event
1836
+ * const event = map.getEvent('npc-1');
1837
+ *
1838
+ * // Get event with type narrowing
1839
+ * const npc = map.getEvent<MyNPC>('npc-1');
1840
+ * if (npc) {
1841
+ * npc.speak('Hello!');
1842
+ * }
1843
+ * ```
1844
+ */
1845
+ getEvent<T extends RpgPlayer>(eventId: string): T | undefined {
1846
+ return this.events()[eventId] as T
1847
+ }
1848
+
1849
+ /**
1850
+ * Get a player by their ID
1851
+ *
1852
+ * Returns the player with the specified ID, or undefined if not found.
1853
+ *
1854
+ * @param playerId - The unique identifier of the player
1855
+ * @returns The player instance, or undefined if not found
1856
+ *
1857
+ * @example
1858
+ * ```ts
1859
+ * const player = map.getPlayer('player-123');
1860
+ * if (player) {
1861
+ * console.log(`Player ${player.name} is on the map`);
1862
+ * }
1863
+ * ```
1864
+ */
1865
+ getPlayer(playerId: string): RpgPlayer | undefined {
1866
+ return this.players()[playerId]
1867
+ }
1868
+
1869
+ /**
1870
+ * Get all players currently on the map
1871
+ *
1872
+ * Returns an array of all players that are currently connected to this map.
1873
+ *
1874
+ * @returns Array of all RpgPlayer instances on the map
1875
+ *
1876
+ * @example
1877
+ * ```ts
1878
+ * const players = map.getPlayers();
1879
+ * console.log(`There are ${players.length} players on the map`);
1880
+ *
1881
+ * players.forEach(player => {
1882
+ * console.log(`- ${player.name}`);
1883
+ * });
1884
+ * ```
1885
+ */
1886
+ getPlayers(): RpgPlayer[] {
1887
+ return Object.values(this.players())
1888
+ }
1889
+
1890
+ /**
1891
+ * Get all events on the map
1892
+ *
1893
+ * Returns an array of all events (NPCs, objects, etc.) that are currently
1894
+ * on this map.
1895
+ *
1896
+ * @returns Array of all RpgEvent instances on the map
1897
+ *
1898
+ * @example
1899
+ * ```ts
1900
+ * const events = map.getEvents();
1901
+ * console.log(`There are ${events.length} events on the map`);
1902
+ *
1903
+ * events.forEach(event => {
1904
+ * console.log(`- ${event.name} at (${event.x}, ${event.y})`);
1905
+ * });
1906
+ * ```
1907
+ */
1908
+ getEvents(): RpgEvent[] {
1909
+ return Object.values(this.events())
1910
+ }
1911
+
1912
+ getEventsForPlayer(playerOrId: string | RpgPlayer): RpgEvent[] {
1913
+ return this.getEvents().filter(event => this.isEventVisibleForPlayer(event, playerOrId));
1914
+ }
1915
+
1916
+ /**
1917
+ * Get the first event that matches a condition
1918
+ *
1919
+ * Searches through all events on the map and returns the first one that
1920
+ * matches the provided callback function.
1921
+ *
1922
+ * @param cb - Callback function that returns true for the desired event
1923
+ * @returns The first matching event, or undefined if none found
1924
+ *
1925
+ * @example
1926
+ * ```ts
1927
+ * // Find an event by name
1928
+ * const npc = map.getEventBy(event => event.name === 'Merchant');
1929
+ *
1930
+ * // Find an event at a specific position
1931
+ * const chest = map.getEventBy(event =>
1932
+ * event.x === 100 && event.y === 200
1933
+ * );
1934
+ * ```
1935
+ */
1936
+ getEventBy(cb: (event: RpgEvent) => boolean): RpgEvent | undefined {
1937
+ return this.getEventsBy(cb)[0]
1938
+ }
1939
+
1940
+ /**
1941
+ * Get all events that match a condition
1942
+ *
1943
+ * Searches through all events on the map and returns all events that
1944
+ * match the provided callback function.
1945
+ *
1946
+ * @param cb - Callback function that returns true for desired events
1947
+ * @returns Array of all matching events
1948
+ *
1949
+ * @example
1950
+ * ```ts
1951
+ * // Find all NPCs
1952
+ * const npcs = map.getEventsBy(event => event.name.startsWith('NPC-'));
1953
+ *
1954
+ * // Find all events in a specific area
1955
+ * const nearbyEvents = map.getEventsBy(event =>
1956
+ * event.x >= 0 && event.x <= 100 &&
1957
+ * event.y >= 0 && event.y <= 100
1958
+ * );
1959
+ * ```
1960
+ */
1961
+ getEventsBy(cb: (event: RpgEvent) => boolean): RpgEvent[] {
1962
+ return this.getEvents().filter(cb)
1963
+ }
1964
+
1965
+ /**
1966
+ * Remove an event from the map
1967
+ *
1968
+ * Removes the event with the specified ID from the map. The event will
1969
+ * be removed from the synchronized events signal, causing it to disappear
1970
+ * on all clients.
1971
+ *
1972
+ * @param eventId - The unique identifier of the event to remove
1973
+ *
1974
+ * @example
1975
+ * ```ts
1976
+ * // Remove an event
1977
+ * map.removeEvent('npc-1');
1978
+ *
1979
+ * // Remove event after interaction
1980
+ * const chest = map.getEvent('chest-1');
1981
+ * if (chest) {
1982
+ * // ... do something with chest ...
1983
+ * map.removeEvent('chest-1');
1984
+ * }
1985
+ * ```
1986
+ */
1987
+ removeEvent(eventId: string) {
1988
+ const event = this.getEvent(eventId) as any;
1989
+ if (event) {
1990
+ try {
1991
+ event.stopMoveTo?.();
1992
+ }
1993
+ catch {
1994
+ // Ignore teardown race: the physics entity may already be gone.
1995
+ }
1996
+ try {
1997
+ event.breakRoutes?.(true);
1998
+ }
1999
+ catch {
2000
+ // Ignore teardown race in route manager.
2001
+ }
2002
+ }
2003
+ this.clearEventRuntimeMetadata(eventId);
2004
+ delete this.events()[eventId]
2005
+ }
2006
+
2007
+ /**
2008
+ * Display a component animation at a specific position on the map
2009
+ *
2010
+ * This method broadcasts a component animation to all clients connected to the map,
2011
+ * allowing temporary visual effects to be displayed at any location on the map.
2012
+ * Component animations are custom Canvas Engine components that can display
2013
+ * complex effects with custom logic and parameters.
2014
+ *
2015
+ * @param id - The ID of the component animation to display
2016
+ * @param position - The x, y coordinates where to display the animation
2017
+ * @param params - Parameters to pass to the component animation
2018
+ *
2019
+ * @example
2020
+ * ```ts
2021
+ * // Show explosion at specific coordinates
2022
+ * map.showComponentAnimation("explosion", { x: 300, y: 400 }, {
2023
+ * intensity: 2.5,
2024
+ * duration: 1500
2025
+ * });
2026
+ *
2027
+ * // Show area damage effect
2028
+ * map.showComponentAnimation("area-damage", { x: player.x, y: player.y }, {
2029
+ * radius: 100,
2030
+ * color: "red",
2031
+ * damage: 50
2032
+ * });
2033
+ *
2034
+ * // Show treasure spawn effect
2035
+ * map.showComponentAnimation("treasure-spawn", { x: 150, y: 200 }, {
2036
+ * sparkle: true,
2037
+ * sound: "treasure-appear"
2038
+ * });
2039
+ * ```
2040
+ */
2041
+ showComponentAnimation(id: string, position: { x: number, y: number }, params: any) {
2042
+ this.$broadcast({
2043
+ type: "showComponentAnimation",
2044
+ value: {
2045
+ id,
2046
+ params,
2047
+ position,
2048
+ },
2049
+ });
2050
+ }
2051
+
2052
+ /**
2053
+ * Display a spritesheet animation at a specific position on the map
2054
+ *
2055
+ * This method displays a temporary visual animation using a spritesheet at any
2056
+ * location on the map. It's a convenience method that internally uses showComponentAnimation
2057
+ * with the built-in 'animation' component. This is useful for spell effects, environmental
2058
+ * animations, or any visual feedback that uses predefined spritesheets.
2059
+ *
2060
+ * @param position - The x, y coordinates where to display the animation
2061
+ * @param graphic - The ID of the spritesheet to use for the animation
2062
+ * @param animationName - The name of the animation within the spritesheet (default: 'default')
2063
+ *
2064
+ * @example
2065
+ * ```ts
2066
+ * // Show explosion at specific coordinates
2067
+ * map.showAnimation({ x: 100, y: 200 }, "explosion");
2068
+ *
2069
+ * // Show spell effect at player position
2070
+ * const playerPos = { x: player.x, y: player.y };
2071
+ * map.showAnimation(playerPos, "spell-effects", "lightning");
2072
+ *
2073
+ * // Show environmental effect
2074
+ * map.showAnimation({ x: 300, y: 150 }, "nature-effects", "wind-gust");
2075
+ *
2076
+ * // Show portal opening animation
2077
+ * map.showAnimation({ x: 500, y: 400 }, "portals", "opening");
2078
+ * ```
2079
+ */
2080
+ showAnimation(position: { x: number, y: number }, graphic: string, animationName: string = 'default') {
2081
+ this.showComponentAnimation('animation', position, {
2082
+ graphic,
2083
+ animationName,
2084
+ })
2085
+ }
2086
+
2087
+ private cloneWeatherState(weather: WeatherState | null): WeatherState | null {
2088
+ if (!weather) {
2089
+ return null;
2090
+ }
2091
+ return {
2092
+ ...weather,
2093
+ params: weather.params ? { ...weather.params } : undefined,
2094
+ };
2095
+ }
2096
+
2097
+ /**
2098
+ * Get the current map weather state.
2099
+ */
2100
+ getWeather(): WeatherState | null {
2101
+ return this.cloneWeatherState(this._weatherState);
2102
+ }
2103
+
2104
+ /**
2105
+ * Set the full weather state for this map.
2106
+ *
2107
+ * When `sync` is true (default), all connected clients receive the new weather.
2108
+ */
2109
+ setWeather(next: WeatherState | null, options: WeatherSetOptions = {}): WeatherState | null {
2110
+ const sync = options.sync !== false;
2111
+ if (next && !next.effect) {
2112
+ throw new Error("setWeather: 'effect' is required when weather is not null.");
2113
+ }
2114
+ this._weatherState = this.cloneWeatherState(next);
2115
+ if (sync) {
2116
+ this.$broadcast({
2117
+ type: "weatherState",
2118
+ value: this._weatherState,
2119
+ });
2120
+ }
2121
+ return this.getWeather();
2122
+ }
2123
+
2124
+ /**
2125
+ * Patch the current weather state.
2126
+ *
2127
+ * Nested `params` values are merged.
2128
+ */
2129
+ patchWeather(patch: Partial<WeatherState>, options: WeatherSetOptions = {}): WeatherState | null {
2130
+ const current = this._weatherState ?? null;
2131
+ if (!current && !patch.effect) {
2132
+ throw new Error("patchWeather: 'effect' is required when no weather is currently set.");
2133
+ }
2134
+ const next: WeatherState = {
2135
+ ...(current ?? {}),
2136
+ ...patch,
2137
+ params: {
2138
+ ...(current?.params ?? {}),
2139
+ ...(patch.params ?? {}),
2140
+ },
2141
+ } as WeatherState;
2142
+ return this.setWeather(next, options);
2143
+ }
2144
+
2145
+ /**
2146
+ * Clear weather for this map.
2147
+ */
2148
+ clearWeather(options: WeatherSetOptions = {}): void {
2149
+ this.setWeather(null, options);
2150
+ }
2151
+
2152
+ /**
2153
+ * Configure runtime synchronized properties on the map
2154
+ *
2155
+ * This method allows you to dynamically add synchronized properties to the map
2156
+ * that will be automatically synced with clients. The schema follows the same
2157
+ * structure as module properties with `$initial`, `$syncWithClient`, and `$permanent` options.
2158
+ *
2159
+ * ## Architecture
2160
+ *
2161
+ * - Reads a schema object shaped like module props
2162
+ * - Creates typed sync signals with @signe/sync
2163
+ * - Properties are accessible as `map.propertyName`
2164
+ *
2165
+ * @param schema - Schema object defining the properties to sync
2166
+ * @param schema[key].$initial - Initial value for the property
2167
+ * @param schema[key].$syncWithClient - Whether to sync this property to clients
2168
+ * @param schema[key].$permanent - Whether to persist this property
2169
+ *
2170
+ * @example
2171
+ * ```ts
2172
+ * // Add synchronized properties to the map
2173
+ * map.setSync({
2174
+ * weather: {
2175
+ * $initial: 'sunny',
2176
+ * $syncWithClient: true,
2177
+ * $permanent: false
2178
+ * },
2179
+ * timeOfDay: {
2180
+ * $initial: 12,
2181
+ * $syncWithClient: true,
2182
+ * $permanent: false
2183
+ * }
2184
+ * });
2185
+ *
2186
+ * // Use the properties
2187
+ * map.weather.set('rainy');
2188
+ * const currentWeather = map.weather();
2189
+ * ```
2190
+ */
2191
+ setSync(schema: Record<string, any>) {
2192
+ for (let key in schema) {
2193
+ const initial = typeof schema[key]?.$initial !== 'undefined' ? schema[key].$initial : null;
2194
+ // Use type() directly with a plain object holder to avoid signal type mismatch
2195
+ const holder: any = {};
2196
+ this[key] = type(signal(initial) as any, key, {
2197
+ syncToClient: schema[key]?.$syncWithClient,
2198
+ persist: schema[key]?.$permanent,
2199
+ }, holder);
2200
+ }
2201
+ }
2202
+
2203
+ /**
2204
+ * Apply sync to the client
2205
+ *
2206
+ * This method applies sync to the client by calling the `$applySync()` method.
2207
+ *
2208
+ * @example
2209
+ * ```ts
2210
+ * map.applySyncToClient();
2211
+ * ```
2212
+ */
2213
+ applySyncToClient() {
2214
+ this.$applySync();
2215
+ }
2216
+
2217
+ /**
2218
+ * Create a shape dynamically on the map
2219
+ *
2220
+ * This method creates a static hitbox on the map that can be used for
2221
+ * collision detection, area triggers, or visual boundaries. The shape is
2222
+ * backed by the physics engine's static entity system for accurate collision detection.
2223
+ *
2224
+ * ## Architecture
2225
+ *
2226
+ * Creates a static entity (hitbox) in the physics engine at the specified position and size.
2227
+ * The shape is stored internally and can be retrieved by name. When players or events
2228
+ * collide with this hitbox, the `onInShape` and `onOutShape` hooks are automatically
2229
+ * triggered on both the player and the event.
2230
+ *
2231
+ * @param obj - Shape configuration object
2232
+ * @param obj.x - X position of the shape (top-left corner) (required)
2233
+ * @param obj.y - Y position of the shape (top-left corner) (required)
2234
+ * @param obj.width - Width of the shape in pixels (required)
2235
+ * @param obj.height - Height of the shape in pixels (required)
2236
+ * @param obj.name - Name of the shape (optional, auto-generated if not provided)
2237
+ * @param obj.z - Z position/depth for rendering (optional)
2238
+ * @param obj.color - Color in hexadecimal format, shared with client (optional)
2239
+ * @param obj.collision - Whether the shape has collision (optional)
2240
+ * @param obj.properties - Additional custom properties (optional)
2241
+ * @returns The created RpgShape instance
2242
+ *
2243
+ * @example
2244
+ * ```ts
2245
+ * // Create a simple rectangular shape
2246
+ * const shape = map.createShape({
2247
+ * x: 100,
2248
+ * y: 200,
2249
+ * width: 50,
2250
+ * height: 50,
2251
+ * name: "spawn-zone"
2252
+ * });
2253
+ *
2254
+ * // Create a shape with visual properties
2255
+ * const triggerZone = map.createShape({
2256
+ * x: 300,
2257
+ * y: 400,
2258
+ * width: 100,
2259
+ * height: 100,
2260
+ * name: "treasure-area",
2261
+ * color: "#FFD700",
2262
+ * z: 1,
2263
+ * collision: false,
2264
+ * properties: {
2265
+ * type: "treasure",
2266
+ * value: 100
2267
+ * }
2268
+ * });
2269
+ *
2270
+ * // Player hooks will be triggered automatically
2271
+ * const player: RpgPlayerHooks = {
2272
+ * onInShape(player: RpgPlayer, shape: RpgShape) {
2273
+ * console.log('in', player.name, shape.name);
2274
+ * },
2275
+ * onOutShape(player: RpgPlayer, shape: RpgShape) {
2276
+ * console.log('out', player.name, shape.name);
2277
+ * }
2278
+ * };
2279
+ * ```
2280
+ */
2281
+ createShape(obj: {
2282
+ x: number;
2283
+ y: number;
2284
+ width: number;
2285
+ height: number;
2286
+ name?: string;
2287
+ z?: number;
2288
+ color?: string;
2289
+ collision?: boolean;
2290
+ properties?: Record<string, any>;
2291
+ }): RpgShape {
2292
+ const { x, y, width, height } = obj;
2293
+
2294
+ // Validate required parameters
2295
+ if (typeof x !== 'number' || typeof y !== 'number') {
2296
+ throw new Error('Shape x and y must be numbers');
2297
+ }
2298
+ if (typeof width !== 'number' || width <= 0) {
2299
+ throw new Error('Shape width must be a positive number');
2300
+ }
2301
+ if (typeof height !== 'number' || height <= 0) {
2302
+ throw new Error('Shape height must be a positive number');
2303
+ }
2304
+
2305
+ // Generate name if not provided
2306
+ const name = obj.name || generateShortUUID();
2307
+
2308
+ // Check if shape with this name already exists
2309
+ if (this._shapes.has(name)) {
2310
+ throw new Error(`Shape with name "${name}" already exists`);
2311
+ }
2312
+
2313
+ // Calculate center position for the static hitbox
2314
+ const centerX = x + width / 2;
2315
+ const centerY = y + height / 2;
2316
+
2317
+ // Create static entity (hitbox) in physics engine
2318
+ const entityId = `shape-${name}`;
2319
+ const entity = this.physic.createEntity({
2320
+ uuid: entityId,
2321
+ position: { x: centerX, y: centerY },
2322
+ width: width,
2323
+ height: height,
2324
+ mass: Infinity, // Static entity
2325
+ state: EntityState.Static,
2326
+ restitution: 0, // No bounce
2327
+ });
2328
+ entity.freeze(); // Ensure it's frozen
2329
+
2330
+ // Build properties object
2331
+ const properties: Record<string, any> = {
2332
+ ...(obj.properties || {}),
2333
+ };
2334
+ if (obj.z !== undefined) properties.z = obj.z;
2335
+ if (obj.color !== undefined) properties.color = obj.color;
2336
+ if (obj.collision !== undefined) properties.collision = obj.collision;
2337
+
2338
+ // Create RpgShape instance
2339
+ // Note: We use entityId as physicZoneId for compatibility, but it's actually an entity UUID
2340
+ const shape = new RpgShape({
2341
+ name: name,
2342
+ positioning: 'default',
2343
+ width: width,
2344
+ height: height,
2345
+ x: centerX,
2346
+ y: centerY,
2347
+ properties: properties,
2348
+ playerOwner: undefined, // Static shapes are not attached to players
2349
+ physicZoneId: entityId, // Store entity UUID for reference
2350
+ map: this,
2351
+ });
2352
+
2353
+ // Store the shape
2354
+ this._shapes.set(name, shape);
2355
+ this._shapeEntities.set(entityId, shape);
2356
+
2357
+ return shape;
2358
+ }
2359
+
2360
+ /**
2361
+ * Delete a shape from the map
2362
+ *
2363
+ * Removes a shape by its name and cleans up the associated static hitbox entity.
2364
+ * If the shape doesn't exist, the method does nothing.
2365
+ *
2366
+ * @param name - Name of the shape to remove
2367
+ * @returns void
2368
+ *
2369
+ * @example
2370
+ * ```ts
2371
+ * // Create and then remove a shape
2372
+ * const shape = map.createShape({
2373
+ * x: 100,
2374
+ * y: 200,
2375
+ * width: 50,
2376
+ * height: 50,
2377
+ * name: "temp-zone"
2378
+ * });
2379
+ *
2380
+ * // Later, remove it
2381
+ * map.removeShape("temp-zone");
2382
+ * ```
2383
+ */
2384
+ removeShape(name: string): void {
2385
+ const shape = this._shapes.get(name);
2386
+ if (!shape) {
2387
+ return;
2388
+ }
2389
+
2390
+ // Remove entity from physics engine
2391
+ const entityId = (shape as any)._physicZoneId;
2392
+ const entity = this.physic.getEntityByUUID(entityId);
2393
+ if (entity) {
2394
+ this.physic.removeEntity(entity);
2395
+ }
2396
+
2397
+ // Remove from internal storage
2398
+ this._shapes.delete(name);
2399
+ this._shapeEntities.delete(entityId);
2400
+ }
2401
+
2402
+ /**
2403
+ * Get all shapes on the map
2404
+ *
2405
+ * Returns an array of all shapes that have been created on this map,
2406
+ * regardless of whether they are static shapes or player-attached shapes.
2407
+ *
2408
+ * @returns Array of RpgShape instances
2409
+ *
2410
+ * @example
2411
+ * ```ts
2412
+ * // Create multiple shapes
2413
+ * map.createShape({ x: 0, y: 0, width: 50, height: 50, name: "zone1" });
2414
+ * map.createShape({ x: 100, y: 100, width: 50, height: 50, name: "zone2" });
2415
+ *
2416
+ * // Get all shapes
2417
+ * const allShapes = map.getShapes();
2418
+ * console.log(allShapes.length); // 2
2419
+ * ```
2420
+ */
2421
+ getShapes(): RpgShape[] {
2422
+ return Array.from(this._shapes.values());
2423
+ }
2424
+
2425
+ /**
2426
+ * Get a shape by its name
2427
+ *
2428
+ * Returns a shape with the specified name, or undefined if no shape
2429
+ * with that name exists on the map.
2430
+ *
2431
+ * @param name - Name of the shape to retrieve
2432
+ * @returns The RpgShape instance, or undefined if not found
2433
+ *
2434
+ * @example
2435
+ * ```ts
2436
+ * // Create a shape with a specific name
2437
+ * map.createShape({
2438
+ * x: 100,
2439
+ * y: 200,
2440
+ * width: 50,
2441
+ * height: 50,
2442
+ * name: "spawn-point"
2443
+ * });
2444
+ *
2445
+ * // Retrieve it later
2446
+ * const spawnZone = map.getShape("spawn-point");
2447
+ * if (spawnZone) {
2448
+ * console.log(`Spawn zone at (${spawnZone.x}, ${spawnZone.y})`);
2449
+ * }
2450
+ * ```
2451
+ */
2452
+ getShape(name: string): RpgShape | undefined {
2453
+ return this._shapes.get(name);
2454
+ }
2455
+
2456
+ /**
2457
+ * Play a sound for all players on the map
2458
+ *
2459
+ * This method plays a sound for all players currently on the map by iterating
2460
+ * over each player and calling `player.playSound()`. The sound must be defined
2461
+ * on the client side (in the client module configuration).
2462
+ * This is ideal for environmental sounds, battle music, or map-wide events that
2463
+ * all players should hear simultaneously.
2464
+ *
2465
+ * ## Design
2466
+ *
2467
+ * Iterates over all players on the map and calls `player.playSound()` for each one.
2468
+ * This avoids code duplication and reuses the existing player sound logic.
2469
+ * For player-specific sounds, use `player.playSound()` directly.
2470
+ *
2471
+ * @param soundId - Sound identifier, defined on the client side
2472
+ * @param options - Optional sound configuration
2473
+ * @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
2474
+ * @param options.loop - Whether the sound should loop (default: false)
2475
+ *
2476
+ * @example
2477
+ * ```ts
2478
+ * // Play a sound for all players on the map
2479
+ * map.playSound("explosion");
2480
+ *
2481
+ * // Play background music for everyone with volume and loop
2482
+ * map.playSound("battle-theme", {
2483
+ * volume: 0.7,
2484
+ * loop: true
2485
+ * });
2486
+ *
2487
+ * // Play a door opening sound at low volume
2488
+ * map.playSound("door-open", { volume: 0.4 });
2489
+ * ```
2490
+ */
2491
+ playSound(soundId: string, options?: { volume?: number; loop?: boolean }): void {
2492
+ const players = this.getPlayers();
2493
+ players.forEach((player) => {
2494
+ player.playSound(soundId, options);
2495
+ });
2496
+ }
2497
+
2498
+ /**
2499
+ * Stop a sound for all players on the map
2500
+ *
2501
+ * This method stops a sound that was previously started with `map.playSound()`
2502
+ * for all players on the map by iterating over each player and calling `player.stopSound()`.
2503
+ *
2504
+ * @param soundId - Sound identifier to stop
2505
+ *
2506
+ * @example
2507
+ * ```ts
2508
+ * // Start background music for everyone
2509
+ * map.playSound("battle-theme", { loop: true });
2510
+ *
2511
+ * // Later, stop it for everyone
2512
+ * map.stopSound("battle-theme");
2513
+ * ```
2514
+ */
2515
+ stopSound(soundId: string): void {
2516
+ const players = this.getPlayers();
2517
+ players.forEach((player) => {
2518
+ player.stopSound(soundId);
2519
+ });
2520
+ }
2521
+
2522
+ /**
2523
+ * Shake the map for all players
2524
+ *
2525
+ * This method triggers a shake animation on the map for all players currently on the map.
2526
+ * The shake effect creates a visual feedback that can be used for earthquakes, explosions,
2527
+ * impacts, or any dramatic event that should affect the entire map visually.
2528
+ *
2529
+ * ## Architecture
2530
+ *
2531
+ * Broadcasts a shake event to all clients connected to the map. Each client receives
2532
+ * the shake configuration and triggers the shake animation on the map container using
2533
+ * Canvas Engine's shake directive.
2534
+ *
2535
+ * @param options - Optional shake configuration
2536
+ * @param options.intensity - Shake intensity in pixels (default: 10)
2537
+ * @param options.duration - Duration of the shake animation in milliseconds (default: 500)
2538
+ * @param options.frequency - Number of shake oscillations during the animation (default: 10)
2539
+ * @param options.direction - Direction of the shake - 'x', 'y', or 'both' (default: 'both')
2540
+ *
2541
+ * @example
2542
+ * ```ts
2543
+ * // Basic shake with default settings
2544
+ * map.shakeMap();
2545
+ *
2546
+ * // Intense earthquake effect
2547
+ * map.shakeMap({
2548
+ * intensity: 25,
2549
+ * duration: 1000,
2550
+ * frequency: 15,
2551
+ * direction: 'both'
2552
+ * });
2553
+ *
2554
+ * // Horizontal shake for side impact
2555
+ * map.shakeMap({
2556
+ * intensity: 15,
2557
+ * duration: 400,
2558
+ * direction: 'x'
2559
+ * });
2560
+ *
2561
+ * // Vertical shake for ground impact
2562
+ * map.shakeMap({
2563
+ * intensity: 20,
2564
+ * duration: 600,
2565
+ * direction: 'y'
2566
+ * });
2567
+ * ```
2568
+ */
2569
+ shakeMap(options?: {
2570
+ intensity?: number;
2571
+ duration?: number;
2572
+ frequency?: number;
2573
+ direction?: 'x' | 'y' | 'both';
2574
+ }): void {
2575
+ this.$broadcast({
2576
+ type: "shakeMap",
2577
+ value: {
2578
+ intensity: options?.intensity ?? 10,
2579
+ duration: options?.duration ?? 500,
2580
+ frequency: options?.frequency ?? 10,
2581
+ direction: options?.direction ?? 'both',
2582
+ },
2583
+ });
2584
+ }
2585
+
2586
+ /**
2587
+ * Clear all server resources and reset state
2588
+ *
2589
+ * This method should be called to clean up all server-side resources when
2590
+ * shutting down or resetting the map. It stops the input processing loop
2591
+ * and ensures that all subscriptions are properly cleaned up.
2592
+ *
2593
+ * ## Design
2594
+ *
2595
+ * This method is used primarily in testing environments to ensure clean
2596
+ * state between tests. It stops the tick subscription to prevent memory leaks.
2597
+ *
2598
+ * @example
2599
+ * ```ts
2600
+ * // In test cleanup
2601
+ * afterEach(() => {
2602
+ * map.clear();
2603
+ * });
2604
+ * ```
2605
+ */
2606
+ clear(): void {
2607
+ try {
2608
+ // Stop input processing loop
2609
+ if (this._inputLoopSubscription) {
2610
+ this._inputLoopSubscription.unsubscribe();
2611
+ this._inputLoopSubscription = undefined;
2612
+ }
2613
+ } catch (error) {
2614
+ console.warn('Error during map cleanup:', error);
2615
+ }
305
2616
  }
306
2617
  }
307
2618
 
308
- export interface RpgMap {
309
- $send: (conn: MockConnection, data: any) => void;
310
- $broadcast: (data: any) => void;
311
- }
2619
+ export interface RpgMap extends RoomMethods { }