@rpgjs/server 5.0.0-alpha.3 → 5.0.0-alpha.30

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