@rpgjs/server 5.0.0-alpha.8 → 5.0.0-beta.1

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