@rpgjs/server 5.0.0-alpha.9 → 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 (114) 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 +95 -32
  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 +87 -4
  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 -29711
  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 +394 -32
  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 +125 -14
  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
@@ -5,7 +5,12 @@ import {
5
5
  RpgCommonPlayer,
6
6
  ShowAnimationParams,
7
7
  Constructor,
8
+ Direction,
9
+ AttachShapeOptions,
10
+ RpgShape,
11
+ ShapePositioning,
8
12
  } from "@rpgjs/common";
13
+ import { Entity, Vector2 } from "@rpgjs/physic";
9
14
  import { IComponentManager, WithComponentManager } from "./ComponentManager";
10
15
  import { RpgMap } from "../rooms/map";
11
16
  import { Context, inject } from "@signe/di";
@@ -14,33 +19,33 @@ import { MockConnection } from "@signe/room";
14
19
  import { IMoveManager, WithMoveManager } from "./MoveManager";
15
20
  import { IGoldManager, WithGoldManager } from "./GoldManager";
16
21
  import { WithVariableManager, type IVariableManager } from "./VariableManager";
17
- import { sync } from "@signe/sync";
18
- import { signal } from "@signe/reactive";
22
+ import { createStatesSnapshotDeep, load, sync, type } from "@signe/sync";
23
+ import { computed, signal } from "@signe/reactive";
19
24
  import {
20
25
  IParameterManager,
21
26
  WithParameterManager,
22
27
  } from "./ParameterManager";
23
28
  import { WithItemFixture } from "./ItemFixture";
24
29
  import { IItemManager, WithItemManager } from "./ItemManager";
25
- import { lastValueFrom } from "rxjs";
30
+ import { bufferTime, combineLatest, debounceTime, distinctUntilChanged, filter, lastValueFrom, map, Observable, pairwise, sample, throttleTime } from "rxjs";
26
31
  import { IEffectManager, WithEffectManager } from "./EffectManager";
27
- import { AGI, AGI_CURVE, DEX, DEX_CURVE, INT, INT_CURVE, MAXHP, MAXHP_CURVE, MAXSP, MAXSP_CURVE, STR, STR_CURVE } from "../presets";
32
+ import { AGI, DEX, INT, MAXHP, MAXSP, STR } from "@rpgjs/common";
33
+ import { AGI_CURVE, DEX_CURVE, INT_CURVE, MAXHP_CURVE, MAXSP_CURVE, STR_CURVE } from "../presets";
28
34
  import { IElementManager, WithElementManager } from "./ElementManager";
29
35
  import { ISkillManager, WithSkillManager } from "./SkillManager";
30
36
  import { IBattleManager, WithBattleManager } from "./BattleManager";
31
37
  import { IClassManager, WithClassManager } from "./ClassManager";
32
38
  import { IStateManager, WithStateManager } from "./StateManager";
33
-
34
- // Local interface for ZoneOptions to avoid import issues
35
- interface ZoneOptions {
36
- x?: number;
37
- y?: number;
38
- radius: number;
39
- angle?: number;
40
- direction?: any;
41
- linkedTo?: string;
42
- limitedByWalls?: boolean;
43
- }
39
+ import {
40
+ buildSaveSlotMeta,
41
+ resolveAutoSaveStrategy,
42
+ resolveSaveSlot,
43
+ resolveSaveStorageStrategy,
44
+ shouldAutoSave,
45
+ type SaveRequestContext,
46
+ type SaveSlotIndex,
47
+ type SaveSlotMeta,
48
+ } from "../services/save";
44
49
 
45
50
  /**
46
51
  * Combines multiple RpgCommonPlayer mixins into one
@@ -96,36 +101,224 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
96
101
  map: RpgMap | null = null;
97
102
  context?: Context;
98
103
  conn: MockConnection | null = null;
104
+ touchSide: boolean = false; // Protection against map change loops
105
+ private _clientListeners = new Map<string, Set<(data: any) => void | Promise<void>>>();
106
+
107
+ /**
108
+ * Computed signal for world X position
109
+ *
110
+ * Calculates the absolute world X position from the map's world position
111
+ * plus the player's local X position. Returns 0 if no map is assigned.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const worldX = player.worldX();
116
+ * console.log(`Player is at world X: ${worldX}`);
117
+ * ```
118
+ */
119
+ get worldPositionX() {
120
+ return this._getComputedWorldPosition('x');
121
+ }
122
+
123
+ /**
124
+ * Computed signal for world Y position
125
+ *
126
+ * Calculates the absolute world Y position from the map's world position
127
+ * plus the player's local Y position. Returns 0 if no map is assigned.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * const worldY = player.worldY();
132
+ * console.log(`Player is at world Y: ${worldY}`);
133
+ * ```
134
+ */
135
+ get worldPositionY() {
136
+ return this._getComputedWorldPosition('y');
137
+ }
138
+
139
+ private _worldPositionSignals = new WeakMap<any, any>();
140
+
141
+ private _getComputedWorldPosition(axis: 'x' | 'y') {
142
+ // We use a WeakMap to cache the computed signal per instance
143
+ // This ensures that if the player object is copied (e.g. in tests),
144
+ // the new instance gets its own signal bound to itself.
145
+ if (!this._worldPositionSignals) {
146
+ this._worldPositionSignals = new WeakMap();
147
+ }
148
+
149
+ const key = axis;
150
+ let signals = this._worldPositionSignals.get(this);
151
+ if (!signals) {
152
+ signals = {};
153
+ this._worldPositionSignals.set(this, signals);
154
+ }
155
+
156
+ if (!signals[key]) {
157
+ signals[key] = computed(() => {
158
+ const map = this.map as RpgMap | null;
159
+ const mapWorldPos = map ? (map[axis === 'x' ? 'worldX' : 'worldY'] ?? 0) : 0;
160
+ return mapWorldPos + (this[axis] as any)();
161
+ });
162
+ }
163
+ return signals[key];
164
+ }
165
+
166
+ /** Internal: Shapes attached to this player */
167
+ private _attachedShapes: Map<string, RpgShape> = new Map();
168
+
169
+ /** Internal: Shapes where this player is currently located */
170
+ private _inShapes: Set<RpgShape> = new Set();
171
+ /** Last processed client input timestamp for reconciliation */
172
+ lastProcessedInputTs: number = 0;
173
+ /** Last processed client input frame for reconciliation with server tick */
174
+ _lastFramePositions: {
175
+ frame: number;
176
+ position: {
177
+ x: number;
178
+ y: number;
179
+ direction: Direction;
180
+ };
181
+ serverTick?: number; // Server tick at which this position was computed
182
+ } | null = null;
183
+
184
+ frames: { x: number; y: number; ts: number }[] = [];
99
185
 
100
186
  @sync(RpgPlayer) events = signal<RpgEvent[]>([]);
101
187
 
102
188
  constructor() {
103
189
  super();
190
+
191
+ const initialX = typeof this.x === "function" ? Number(this.x()) || 0 : 0;
192
+ const initialY = typeof this.y === "function" ? Number(this.y()) || 0 : 0;
193
+ let lastEmitted: { x: number; y: number } | null = { x: initialX, y: initialY };
194
+ let pendingUpdate: { x: number; y: number } | null = null;
195
+ let updateScheduled = false;
196
+
197
+ combineLatest([this.x.observable, this.y.observable])
198
+ .subscribe(([x, y]) => {
199
+ pendingUpdate = { x, y };
200
+
201
+ // Schedule a synchronous update using queueMicrotask
202
+ // This groups multiple rapid changes (x and y in the same tick) into a single frame
203
+ if (!updateScheduled) {
204
+ updateScheduled = true;
205
+ queueMicrotask(() => {
206
+ if (pendingUpdate) {
207
+ const { x, y } = pendingUpdate;
208
+ // Only emit if the values are different from the last emitted frame
209
+ if (!lastEmitted || lastEmitted.x !== x || lastEmitted.y !== y) {
210
+ this.frames = [...this.frames, {
211
+ x: x,
212
+ y: y,
213
+ ts: Date.now(),
214
+ }];
215
+ lastEmitted = { x, y };
216
+ }
217
+ pendingUpdate = null;
218
+ }
219
+ updateScheduled = false;
220
+ });
221
+ }
222
+ })
223
+ }
224
+
225
+ private _getClientListenerBucket(key: string) {
226
+ let listeners = this._clientListeners.get(key);
227
+ if (!listeners) {
228
+ listeners = new Set();
229
+ this._clientListeners.set(key, listeners);
230
+ }
231
+ return listeners;
232
+ }
233
+
234
+ async _dispatchClientEvent(key: string, data: any) {
235
+ const listeners = [...(this._clientListeners.get(key) ?? [])];
236
+ for (const callback of listeners) {
237
+ await callback(data);
238
+ }
239
+ }
240
+
241
+ _onInit() {
242
+ this.hooks.callHooks("server-playerProps-load", this).subscribe();
243
+ }
244
+
245
+ /**
246
+ * Apply the built-in default parameter curves to this player.
247
+ *
248
+ * Use this when you want RPGJS to provide the initial parameter setup
249
+ * instead of restoring values from your own database or a saved snapshot.
250
+ *
251
+ * This method only defines the parameter curves and related defaults.
252
+ * It does not restore custom persisted data for you.
253
+ *
254
+ * @method player.applyDefaultParameters()
255
+ * @returns {void}
256
+ */
257
+ applyDefaultParameters() {
104
258
  // Use type assertion to access mixin properties
105
259
  (this as any).expCurve = {
106
- basis: 30,
107
- extra: 20,
108
- accelerationA: 30,
109
- accelerationB: 30
260
+ basis: 30,
261
+ extra: 20,
262
+ accelerationA: 30,
263
+ accelerationB: 30
110
264
  };
111
265
 
112
- (this as any).addParameter(MAXHP, MAXHP_CURVE);
266
+ ;(this as any).addParameter(MAXHP, MAXHP_CURVE);
113
267
  (this as any).addParameter(MAXSP, MAXSP_CURVE);
114
268
  (this as any).addParameter(STR, STR_CURVE);
115
269
  (this as any).addParameter(INT, INT_CURVE);
116
270
  (this as any).addParameter(DEX, DEX_CURVE);
117
271
  (this as any).addParameter(AGI, AGI_CURVE);
272
+ }
273
+
274
+ /**
275
+ * Initialize the built-in default player stats.
276
+ *
277
+ * This applies the default parameter curves and then restores HP/SP to their
278
+ * current maximum values so the client receives coherent bars on first load.
279
+ *
280
+ * Call this manually in `onConnected()` or `onStart()` when your game relies
281
+ * on the built-in defaults. Do not call it after loading a snapshot or
282
+ * hydrating player data from your own database unless you explicitly want to
283
+ * overwrite those values.
284
+ *
285
+ * @method player.initializeDefaultStats()
286
+ * @returns {void}
287
+ */
288
+ initializeDefaultStats() {
289
+ this.applyDefaultParameters();
118
290
  (this as any).allRecovery();
119
291
  }
120
292
 
293
+ get hooks() {
294
+ return inject<Hooks>(this.context as any, ModulesToken);
295
+ }
296
+
297
+ // compatibility with v4
298
+ get server() {
299
+ return this.map
300
+ }
301
+
302
+ setMap(map: RpgMap) {
303
+ this.map = map;
304
+ // Prevent immediate ping-pong map transfers when spawning near a border.
305
+ this.touchSide = true;
306
+ }
307
+
308
+ applyFrames() {
309
+ this._frames.set(this.frames)
310
+ this.frames = []
311
+ }
312
+
121
313
  async execMethod(method: string, methodData: any[] = [], target?: any) {
122
314
  let ret: any;
123
315
  if (target) {
124
- ret = await target[method](...methodData);
316
+ if (typeof target[method] === 'function') {
317
+ ret = await target[method](...methodData);
318
+ }
125
319
  }
126
320
  else {
127
- const hooks = inject<Hooks>(this.context as any, ModulesToken);
128
- ret = await lastValueFrom(hooks
321
+ ret = await lastValueFrom(this.hooks
129
322
  .callHooks(`server-player-${method}`, target ?? this, ...methodData));
130
323
  }
131
324
  this.syncChanges()
@@ -152,23 +345,169 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
152
345
  mapId: string,
153
346
  positions?: { x: number; y: number; z?: number } | string
154
347
  ): Promise<any | null | boolean> {
348
+ const realMapId = 'map-' + mapId;
349
+ const room = this.getCurrentMap();
350
+
351
+ const canChange: boolean[] = await lastValueFrom(this.hooks.callHooks("server-player-canChangeMap", this, {
352
+ id: mapId,
353
+ }));
354
+ if (canChange.some(v => v === false)) return false;
355
+
356
+ if (positions && typeof positions === 'object') {
357
+ await this.teleport(positions)
358
+ }
359
+ const transferToken = await room?.$sessionTransfer(this.conn, realMapId);
155
360
  this.emit("changeMap", {
156
- mapId: 'map-' + mapId,
361
+ mapId: realMapId,
157
362
  positions,
363
+ transferToken: typeof transferToken === 'string' ? transferToken : undefined,
158
364
  });
159
365
  return true;
160
366
  }
161
367
 
368
+ async autoChangeMap(nextPosition: Vector2): Promise<boolean> {
369
+ const map = this.getCurrentMap()
370
+ const worldMaps = map?.getInWorldMaps()
371
+ let ret: boolean = false
372
+ if (worldMaps && map) {
373
+ const direction = this.getDirection()
374
+ const marginLeftRight = map.tileWidth / 2
375
+ const marginTopDown = map.tileHeight / 2
376
+ const hitbox = this.hitbox()
377
+ const currentX = this.x()
378
+ const currentY = this.y()
379
+ const nearBorder =
380
+ currentX < marginLeftRight ||
381
+ currentX > map.widthPx - hitbox.w - marginLeftRight ||
382
+ currentY < marginTopDown ||
383
+ currentY > map.heightPx - hitbox.h - marginTopDown
384
+
385
+ if (this.touchSide) {
386
+ if (nearBorder) {
387
+ return false
388
+ }
389
+ this.touchSide = false
390
+ }
391
+
392
+ const changeMap = async (adjacent, to) => {
393
+ const [nextMap] = worldMaps.getAdjacentMaps(map, adjacent)
394
+ if (!nextMap) {
395
+ return false
396
+ }
397
+ const id = nextMap.id as string
398
+ const nextMapInfo = worldMaps.getMapInfo(id)
399
+ const changed = !!(await this.changeMap(id, to(nextMapInfo)))
400
+ if (changed) {
401
+ this.touchSide = true
402
+ }
403
+ return changed
404
+ }
405
+
406
+ if (nextPosition.x < marginLeftRight && direction == Direction.Left) {
407
+ ret = await changeMap({
408
+ x: map.worldX - 1,
409
+ y: this.worldPositionY() + 1
410
+ }, nextMapInfo => ({
411
+ x: (nextMapInfo.width) - this.hitbox().w - marginLeftRight,
412
+ y: map.worldY - nextMapInfo.y + nextPosition.y
413
+ }))
414
+ }
415
+ else if (nextPosition.x > map.widthPx - this.hitbox().w - marginLeftRight && direction == Direction.Right) {
416
+ ret = await changeMap({
417
+ x: map.worldX + map.widthPx + 1,
418
+ y: this.worldPositionY() + 1
419
+ }, nextMapInfo => ({
420
+ x: marginLeftRight,
421
+ y: map.worldY - nextMapInfo.y + nextPosition.y
422
+ }))
423
+ }
424
+ else if (nextPosition.y < marginTopDown && direction == Direction.Up) {
425
+ ret = await changeMap({
426
+ x: this.worldPositionX() + 1,
427
+ y: map.worldY - 1
428
+ }, nextMapInfo => ({
429
+ x: map.worldX - nextMapInfo.x + nextPosition.x,
430
+ y: (nextMapInfo.height) - this.hitbox().h - marginTopDown,
431
+ }))
432
+ }
433
+ else if (nextPosition.y > map.heightPx - this.hitbox().h - marginTopDown && direction == Direction.Down) {
434
+ ret = await changeMap({
435
+ x: this.worldPositionX() + 1,
436
+ y: map.worldY + map.heightPx + 1
437
+ }, nextMapInfo => ({
438
+ x: map.worldX - nextMapInfo.x + nextPosition.x,
439
+ y: marginTopDown,
440
+ }))
441
+ }
442
+ else {
443
+ this.touchSide = false
444
+ }
445
+ }
446
+ return ret
447
+ }
448
+
162
449
  async teleport(positions: { x: number; y: number }) {
163
450
  if (!this.map) return false;
164
- // For movable objects like players, the position represents the center
165
- this.map.physic.updateHitbox(this.id, positions.x, positions.y);
451
+ if (this.map && this.map.physic) {
452
+ // Skip collision check for teleportation (allow teleporting through walls)
453
+ const entity = this.map.physic.getEntityByUUID(this.id);
454
+ if (entity) {
455
+ const hitbox = typeof this.hitbox === "function" ? this.hitbox() : this.hitbox;
456
+ const width = hitbox?.w ?? 32;
457
+ const height = hitbox?.h ?? 32;
458
+
459
+ // Convert top-left position to center position for physics engine
460
+ // positions.x/y are TOP-LEFT coordinates, but physic.teleport expects CENTER coordinates
461
+ const centerX = positions.x + width / 2;
462
+ const centerY = positions.y + height / 2;
463
+
464
+ this.map.physic.teleport(entity, { x: centerX, y: centerY });
465
+ }
466
+ }
467
+ this.x.set(positions.x)
468
+ this.y.set(positions.y)
469
+ // Wait for the frame to be added before applying frames
470
+ // This ensures the frame is added before applyFrames() is called
471
+ queueMicrotask(() => {
472
+ this.applyFrames()
473
+ })
166
474
  }
167
475
 
168
476
  getCurrentMap<T extends RpgMap = RpgMap>(): T | null {
169
477
  return this.map as T | null;
170
478
  }
171
479
 
480
+ /**
481
+ * Send a custom event to the current player's client.
482
+ *
483
+ * Use this to push arbitrary websocket payloads to one client only.
484
+ * On the client side, receive the event by injecting `WebSocketToken`
485
+ * and subscribing with `socket.on(...)`.
486
+ *
487
+ * @method player.emit(type, value)
488
+ * @param type - Custom event name sent to the client
489
+ * @param value - Payload sent with the event
490
+ * @returns {void}
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * player.emit("inventory:updated", {
495
+ * slots: player.items().length,
496
+ * });
497
+ * ```
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * import { inject } from "@rpgjs/client";
502
+ * import { WebSocketToken, type AbstractWebsocket } from "@rpgjs/client";
503
+ *
504
+ * const socket = inject<AbstractWebsocket>(WebSocketToken);
505
+ *
506
+ * socket.on("inventory:updated", (payload) => {
507
+ * console.log(payload.slots);
508
+ * });
509
+ * ```
510
+ */
172
511
  emit(type: string, value?: any) {
173
512
  const map = this.getCurrentMap();
174
513
  if (!map || !this.conn) return;
@@ -178,7 +517,272 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
178
517
  });
179
518
  }
180
519
 
181
- showAnimation(params: ShowAnimationParams) {}
520
+ snapshot() {
521
+ const snapshot = createStatesSnapshotDeep(this);
522
+ const expCurve = (this as any).expCurve;
523
+ if (expCurve) {
524
+ snapshot.expCurve = { ...expCurve };
525
+ }
526
+ return snapshot;
527
+ }
528
+
529
+ async applySnapshot(snapshot: string | object) {
530
+ const data = typeof snapshot === "string" ? JSON.parse(snapshot) : snapshot;
531
+ const withItems = (this as any).resolveItemsSnapshot?.(data) ?? data;
532
+ const withSkills = (this as any).resolveSkillsSnapshot?.(withItems) ?? withItems;
533
+ const withStates = (this as any).resolveStatesSnapshot?.(withSkills) ?? withSkills;
534
+ const withClass = (this as any).resolveClassSnapshot?.(withStates) ?? withStates;
535
+ const resolvedSnapshot = (this as any).resolveEquipmentsSnapshot?.(withClass) ?? withClass;
536
+ load(this, resolvedSnapshot);
537
+ if (resolvedSnapshot.expCurve) {
538
+ (this as any).expCurve = resolvedSnapshot.expCurve;
539
+ }
540
+ if (Array.isArray(resolvedSnapshot.items)) {
541
+ this.items.set(resolvedSnapshot.items);
542
+ }
543
+ if (Array.isArray(resolvedSnapshot.skills)) {
544
+ this.skills.set(resolvedSnapshot.skills);
545
+ }
546
+ if (Array.isArray(resolvedSnapshot.states)) {
547
+ this.states.set(resolvedSnapshot.states);
548
+ }
549
+ if (resolvedSnapshot._class != null && this._class?.set) {
550
+ this._class.set(resolvedSnapshot._class);
551
+ }
552
+ if (Array.isArray(resolvedSnapshot.equipments)) {
553
+ this.equipments.set(resolvedSnapshot.equipments);
554
+ }
555
+ await lastValueFrom(this.hooks.callHooks("server-player-onLoad", this, resolvedSnapshot));
556
+ return resolvedSnapshot;
557
+ }
558
+
559
+ async save(slot: SaveSlotIndex = "auto", meta: SaveSlotMeta = {}, context: SaveRequestContext = {}) {
560
+ const policy = resolveAutoSaveStrategy();
561
+ if (policy.canSave && !policy.canSave(this, context)) {
562
+ return null;
563
+ }
564
+ const resolvedSlot = resolveSaveSlot(slot, policy, this, context);
565
+ if (resolvedSlot === null) {
566
+ return null;
567
+ }
568
+ const snapshot = this.snapshot();
569
+ await lastValueFrom(this.hooks.callHooks("server-player-onSave", this, snapshot));
570
+ const storage = resolveSaveStorageStrategy();
571
+ const finalMeta = buildSaveSlotMeta(this, meta);
572
+ await storage.save(this, resolvedSlot, JSON.stringify(snapshot), finalMeta);
573
+ return { index: resolvedSlot, meta: finalMeta };
574
+ }
575
+
576
+ async load(
577
+ slot: SaveSlotIndex = "auto",
578
+ context: SaveRequestContext = {},
579
+ options: { changeMap?: boolean } = {}
580
+ ) {
581
+ const policy = resolveAutoSaveStrategy();
582
+ if (policy.canLoad && !policy.canLoad(this, context)) {
583
+ return { ok: false };
584
+ }
585
+ const resolvedSlot = resolveSaveSlot(slot, policy, this, context);
586
+ if (resolvedSlot === null) {
587
+ return { ok: false };
588
+ }
589
+ const storage = resolveSaveStorageStrategy();
590
+ const slotData = await storage.get(this, resolvedSlot);
591
+ if (!slotData?.snapshot) {
592
+ return { ok: false };
593
+ }
594
+ await this.applySnapshot(slotData.snapshot);
595
+ const { snapshot, ...meta } = slotData;
596
+ if (options.changeMap !== false && meta.map) {
597
+ await this.changeMap(meta.map);
598
+ }
599
+ return { ok: true, slot: meta, index: resolvedSlot };
600
+ }
601
+
602
+
603
+ /**
604
+ * @deprecated Use setGraphicAnimation instead.
605
+ * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
606
+ * @param nbTimes - Number of times to repeat the animation (default: Infinity for continuous)
607
+ */
608
+ setAnimation(animationName: string, nbTimes: number = Infinity) {
609
+ console.warn('setAnimation is deprecated. Use setGraphicAnimation instead.');
610
+ this.setGraphicAnimation(animationName, nbTimes);
611
+ }
612
+
613
+ /**
614
+ * @deprecated Use setGraphicAnimation instead.
615
+ * @param graphic - The graphic to use for the animation (e.g., 'attack', 'skill', 'walk')
616
+ * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
617
+ * @param replaceGraphic - Whether to replace the player's graphic (default: false)
618
+ */
619
+ showAnimation(graphic: string, animationName: string, replaceGraphic: boolean = false) {
620
+ if (replaceGraphic) {
621
+ console.warn('showAnimation is deprecated. Use player.setGraphicAnimation instead.');
622
+ this.setGraphicAnimation(animationName, graphic);
623
+ }
624
+ else {
625
+ console.warn('showAnimation is deprecated. Use map.showAnimation instead.');
626
+ const map = this.getCurrentMap();
627
+ map?.showAnimation({ x: this.x(), y: this.y() }, graphic, animationName);
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Listen to custom data sent by the current player's client.
633
+ *
634
+ * This listens to websocket actions emitted from the client with
635
+ * `socket.emit(key, data)`. It is intended for custom client events
636
+ * that are not already handled by built-in server actions such as
637
+ * `move`, `action`, or GUI interactions.
638
+ *
639
+ * @title Listen to data from the client
640
+ * @method player.on(key, cb)
641
+ * @param key - Event name emitted by the client
642
+ * @param cb - Callback invoked with the payload sent by the client
643
+ * @returns {void}
644
+ * @since 3.0.0-beta.5
645
+ *
646
+ * @example
647
+ * ```ts
648
+ * player.on("chat:message", ({ text }) => {
649
+ * console.log("Client says:", text);
650
+ * });
651
+ * ```
652
+ *
653
+ * @example
654
+ * ```ts
655
+ * import { inject } from "@rpgjs/client";
656
+ * import { WebSocketToken, type AbstractWebsocket } from "@rpgjs/client";
657
+ *
658
+ * const socket = inject<AbstractWebsocket>(WebSocketToken);
659
+ * socket.emit("chat:message", { text: "Hello server" });
660
+ * ```
661
+ */
662
+ on(key: string, cb: (data: any) => void | Promise<void>) {
663
+ this._getClientListenerBucket(key).add(cb);
664
+ }
665
+
666
+ /**
667
+ * Listen one time to custom data sent by the current player's client.
668
+ *
669
+ * After the first matching event is received, the listener is removed
670
+ * automatically.
671
+ *
672
+ * @title Listen one-time to data from the client
673
+ * @method player.once(key, cb)
674
+ * @param key - Event name emitted by the client
675
+ * @param cb - Callback invoked only once with the payload sent by the client
676
+ * @returns {void}
677
+ * @since 3.0.0-beta.5
678
+ *
679
+ * @example
680
+ * ```ts
681
+ * player.once("tutorial:ready", (payload) => {
682
+ * console.log("Ready once:", payload.step);
683
+ * });
684
+ * ```
685
+ */
686
+ once(key: string, cb: (data: any) => void | Promise<void>) {
687
+ const onceCallback = async (data: any) => {
688
+ this._clientListeners.get(key)?.delete(onceCallback);
689
+ await cb(data);
690
+ };
691
+ this.on(key, onceCallback);
692
+ }
693
+
694
+ /**
695
+ * Remove all listeners for a custom client event on this player.
696
+ *
697
+ * @title Remove listeners of the client event
698
+ * @method player.off(key)
699
+ * @param key - Event name to clear
700
+ * @returns {void}
701
+ * @since 3.0.0-beta.5
702
+ *
703
+ * @example
704
+ * ```ts
705
+ * player.off("chat:message");
706
+ * ```
707
+ */
708
+ off(key: string) {
709
+ this._clientListeners.delete(key);
710
+ }
711
+
712
+ /**
713
+ * Set the current animation of the player's sprite
714
+ *
715
+ * This method changes the animation state of the player's current sprite.
716
+ * It's used to trigger character animations like attack, skill, or custom movements.
717
+ * When `nbTimes` is set to a finite number, the animation will play that many times
718
+ * before returning to the previous animation state.
719
+ *
720
+ * If `animationFixed` is true, this method will not change the animation.
721
+ *
722
+ * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
723
+ * @param nbTimes - Number of times to repeat the animation (default: Infinity for continuous)
724
+ */
725
+ setGraphicAnimation(animationName: string, nbTimes: number): void;
726
+ /**
727
+ * Set the current animation of the player's sprite with a temporary graphic change
728
+ *
729
+ * This method changes the animation state of the player's current sprite and temporarily
730
+ * changes the player's graphic (sprite sheet) during the animation. The graphic is
731
+ * automatically reset when the animation finishes.
732
+ *
733
+ * When `nbTimes` is set to a finite number, the animation will play that many times
734
+ * before returning to the previous animation state and graphic.
735
+ *
736
+ * If `animationFixed` is true, this method will not change the animation.
737
+ *
738
+ * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
739
+ * @param graphic - The graphic(s) to temporarily use during the animation
740
+ * @param nbTimes - Number of times to repeat the animation (default: Infinity for continuous)
741
+ */
742
+ setGraphicAnimation(animationName: string, graphic: string | string[], nbTimes: number): void;
743
+ setGraphicAnimation(animationName: string, graphic: string | string[]): void;
744
+ setGraphicAnimation(animationName: string, graphicOrNbTimes?: string | string[] | number, nbTimes: number = 1): void {
745
+ // Don't change animation if it's locked
746
+ if (this.animationFixed) {
747
+ return;
748
+ }
749
+
750
+ let graphic: string | string[] | undefined;
751
+ let finalNbTimes: number = Infinity;
752
+
753
+ // Handle overloads
754
+ if (typeof graphicOrNbTimes === 'number') {
755
+ // setGraphicAnimation(animationName, nbTimes)
756
+ finalNbTimes = graphicOrNbTimes;
757
+ } else if (graphicOrNbTimes !== undefined) {
758
+ // setGraphicAnimation(animationName, graphic, nbTimes)
759
+ graphic = graphicOrNbTimes;
760
+ finalNbTimes = nbTimes ?? Infinity;
761
+ } else {
762
+ // setGraphicAnimation(animationName) - nbTimes remains Infinity
763
+ finalNbTimes = Infinity;
764
+ }
765
+
766
+ const map = this.getCurrentMap();
767
+ if (!map) return;
768
+
769
+ if (finalNbTimes === Infinity) {
770
+ if (graphic) this.setGraphic(graphic);
771
+ this.animationName.set(animationName);
772
+ }
773
+ else {
774
+ map.$broadcast({
775
+ type: "setAnimation",
776
+ value: {
777
+ animationName,
778
+ graphic,
779
+ nbTimes: finalNbTimes,
780
+ object: this.id,
781
+ },
782
+ });
783
+ }
784
+ }
785
+
182
786
 
183
787
  /**
184
788
  * Run the change detection cycle. Normally, as soon as a hook is called in a class, the cycle is started. But you can start it manually
@@ -191,11 +795,15 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
191
795
  */
192
796
  syncChanges() {
193
797
  this._eventChanges();
798
+ if (shouldAutoSave(this, { reason: "auto", source: "syncChanges" })) {
799
+ void this.save("auto", {}, { reason: "auto", source: "syncChanges" });
800
+ }
194
801
  }
195
802
 
196
803
  databaseById(id: string) {
197
- const map = this.getCurrentMap();
198
- if (!map) return;
804
+ // Use this.map directly to support both RpgMap and LobbyRoom
805
+ const map = this.map as any;
806
+ if (!map || !map.database) return;
199
807
  const data = map.database()[id];
200
808
  if (!data)
201
809
  throw new Error(
@@ -208,58 +816,315 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
208
816
  const map = this.getCurrentMap();
209
817
  if (!map) return;
210
818
  const { events } = map;
819
+ const visibleMapEvents = Object.values(events?.() ?? {}).filter((event: any) =>
820
+ map.isEventVisibleForPlayer?.(event, this) ?? true
821
+ );
211
822
  const arrayEvents: any[] = [
212
823
  ...Object.values(this.events()),
213
- ...Object.values(events()),
824
+ ...visibleMapEvents,
214
825
  ];
215
826
  for (let event of arrayEvents) {
216
827
  if (event.onChanges) event.onChanges(this);
217
828
  }
218
829
  }
219
830
 
220
- attachShape(id: string, options: ZoneOptions) {
831
+ /**
832
+ * Attach a zone shape to this player using the physic zone system
833
+ *
834
+ * This method creates a zone attached to the player's entity in the physics engine.
835
+ * The zone can be circular or cone-shaped and will detect other entities (players/events)
836
+ * entering or exiting the zone.
837
+ *
838
+ * @param id - Optional zone identifier. If not provided, a unique ID will be generated
839
+ * @param options - Zone configuration options
840
+ *
841
+ * @example
842
+ * ```ts
843
+ * // Create a circular detection zone
844
+ * player.attachShape("vision", {
845
+ * radius: 150,
846
+ * angle: 360,
847
+ * });
848
+ *
849
+ * // Create a cone-shaped vision zone
850
+ * player.attachShape("vision", {
851
+ * radius: 200,
852
+ * angle: 120,
853
+ * direction: Direction.Right,
854
+ * limitedByWalls: true,
855
+ * });
856
+ *
857
+ * // Create a zone with width/height (radius calculated automatically)
858
+ * player.attachShape({
859
+ * width: 100,
860
+ * height: 100,
861
+ * positioning: "center",
862
+ * });
863
+ * ```
864
+ */
865
+ attachShape(idOrOptions: string | AttachShapeOptions, options?: AttachShapeOptions): RpgShape | undefined {
221
866
  const map = this.getCurrentMap();
222
- if (!map) return;
867
+ if (!map) return undefined;
223
868
 
224
- const physic = map.physic;
869
+ // Handle overloaded signature: attachShape(options) or attachShape(id, options)
870
+ let zoneId: string;
871
+ let shapeOptions: AttachShapeOptions;
225
872
 
226
- const zoneId = physic.addZone(id, {
227
- linkedTo: this.id,
228
- ...options,
229
- });
873
+ if (typeof idOrOptions === 'string') {
874
+ zoneId = idOrOptions;
875
+ if (!options) {
876
+ console.warn('attachShape: options must be provided when id is specified');
877
+ return undefined;
878
+ }
879
+ shapeOptions = options;
880
+ } else {
881
+ zoneId = `zone-${this.id}-${Date.now()}`;
882
+ shapeOptions = idOrOptions;
883
+ }
884
+
885
+ // Get player entity from physic engine
886
+ const playerEntity = map.physic.getEntityByUUID(this.id);
887
+ if (!playerEntity) {
888
+ console.warn(`Player entity not found in physic engine for player ${this.id}`);
889
+ return undefined;
890
+ }
230
891
 
231
- physic.registerZoneEvents(
232
- id,
233
- (hitIds) => {
234
- hitIds.forEach((id) => {
235
- const event = map.getEvent<RpgEvent>(id);
236
- const player = map.getPlayer(id);
237
- const zone = physic.getZone(zoneId);
238
- if (event) {
239
- event.execMethod("onInShape", [zone, this]);
240
- }
241
- if (player) this.execMethod("onDetectInShape", [player, zone]);
242
- });
892
+ // Calculate radius from width/height if not provided
893
+ let radius: number;
894
+ if (shapeOptions.radius !== undefined) {
895
+ radius = shapeOptions.radius;
896
+ } else if (shapeOptions.width && shapeOptions.height) {
897
+ // Use the larger dimension as radius, or calculate from area
898
+ radius = Math.max(shapeOptions.width, shapeOptions.height) / 2;
899
+ } else {
900
+ console.warn('attachShape: radius or width/height must be provided');
901
+ return undefined;
902
+ }
903
+
904
+ // Calculate offset based on positioning
905
+ let offset: Vector2 = new Vector2(0, 0);
906
+ const positioning: ShapePositioning = shapeOptions.positioning || "default";
907
+ if (shapeOptions.positioning) {
908
+ const playerWidth = playerEntity.width || playerEntity.radius * 2 || 32;
909
+ const playerHeight = playerEntity.height || playerEntity.radius * 2 || 32;
910
+
911
+ switch (shapeOptions.positioning) {
912
+ case 'top':
913
+ offset = new Vector2(0, -playerHeight / 2);
914
+ break;
915
+ case 'bottom':
916
+ offset = new Vector2(0, playerHeight / 2);
917
+ break;
918
+ case 'left':
919
+ offset = new Vector2(-playerWidth / 2, 0);
920
+ break;
921
+ case 'right':
922
+ offset = new Vector2(playerWidth / 2, 0);
923
+ break;
924
+ case 'center':
925
+ default:
926
+ offset = new Vector2(0, 0);
927
+ break;
928
+ }
929
+ }
930
+
931
+ // Get zone manager and create attached zone
932
+ const zoneManager = map.physic.getZoneManager();
933
+
934
+ // Convert direction from Direction enum to string if needed
935
+ // Direction enum values are already strings ("up", "down", "left", "right")
936
+ let direction: 'up' | 'down' | 'left' | 'right' = 'down';
937
+ if (shapeOptions.direction !== undefined) {
938
+ if (typeof shapeOptions.direction === 'string') {
939
+ direction = shapeOptions.direction as 'up' | 'down' | 'left' | 'right';
940
+ } else {
941
+ // Direction enum value is already a string, just cast it
942
+ direction = String(shapeOptions.direction) as 'up' | 'down' | 'left' | 'right';
943
+ }
944
+ }
945
+
946
+ // Create zone with metadata for name and properties
947
+ const metadata: Record<string, any> = {};
948
+ if (shapeOptions.name) {
949
+ metadata.name = shapeOptions.name;
950
+ }
951
+ if (shapeOptions.properties) {
952
+ metadata.properties = shapeOptions.properties;
953
+ }
954
+
955
+ // Get initial position
956
+ const initialX = playerEntity.position.x + offset.x;
957
+ const initialY = playerEntity.position.y + offset.y;
958
+
959
+ const physicZoneId = zoneManager.createAttachedZone(
960
+ playerEntity,
961
+ {
962
+ radius,
963
+ angle: shapeOptions.angle ?? 360,
964
+ direction,
965
+ limitedByWalls: shapeOptions.limitedByWalls ?? false,
966
+ offset,
967
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
243
968
  },
244
- (hitIds) => {
245
- hitIds.forEach((id) => {
246
- const event = map.getEvent<RpgEvent>(id);
247
- const zone = physic.getZone(zoneId);
248
- const player = map.getPlayer(id);
249
- if (event) {
250
- event.execMethod("onOutShape", [zone, this]);
251
- }
252
- if (player) this.execMethod("onDetectOutShape", [player, zone]);
253
- });
969
+ {
970
+ onEnter: (entities: Entity[]) => {
971
+ entities.forEach((entity) => {
972
+ const event = map.getEvent<RpgEvent>(entity.uuid);
973
+ const player = map.getPlayer(entity.uuid);
974
+
975
+ if (event && (!map.isEventVisibleForPlayer || map.isEventVisibleForPlayer(event, this))) {
976
+ event.execMethod("onInShape", [shape, this]);
977
+ // Track that this event is in the shape
978
+ if ((event as any)._inShapes) {
979
+ (event as any)._inShapes.add(shape);
980
+ }
981
+ }
982
+ if (player) {
983
+ this.execMethod("onDetectInShape", [player, shape]);
984
+ // Track that this player is in the shape
985
+ if (player._inShapes) {
986
+ player._inShapes.add(shape);
987
+ }
988
+ }
989
+ });
990
+ },
991
+ onExit: (entities: Entity[]) => {
992
+ entities.forEach((entity) => {
993
+ const event = map.getEvent<RpgEvent>(entity.uuid);
994
+ const player = map.getPlayer(entity.uuid);
995
+
996
+ if (event && (!map.isEventVisibleForPlayer || map.isEventVisibleForPlayer(event, this))) {
997
+ event.execMethod("onOutShape", [shape, this]);
998
+ // Remove from tracking
999
+ if ((event as any)._inShapes) {
1000
+ (event as any)._inShapes.delete(shape);
1001
+ }
1002
+ }
1003
+ if (player) {
1004
+ this.execMethod("onDetectOutShape", [player, shape]);
1005
+ // Remove from tracking
1006
+ if (player._inShapes) {
1007
+ player._inShapes.delete(shape);
1008
+ }
1009
+ }
1010
+ });
1011
+ },
254
1012
  }
255
1013
  );
1014
+
1015
+ // Create RpgShape instance
1016
+ const shape = new RpgShape({
1017
+ name: shapeOptions.name || zoneId,
1018
+ positioning,
1019
+ width: shapeOptions.width || radius * 2,
1020
+ height: shapeOptions.height || radius * 2,
1021
+ x: initialX,
1022
+ y: initialY,
1023
+ properties: shapeOptions.properties || {},
1024
+ playerOwner: this,
1025
+ physicZoneId: physicZoneId,
1026
+ map: map,
1027
+ });
1028
+
1029
+ // Store mapping from zoneId to physicZoneId for future reference
1030
+ (this as any)._zoneIdMap = (this as any)._zoneIdMap || new Map();
1031
+ (this as any)._zoneIdMap.set(zoneId, physicZoneId);
1032
+
1033
+ // Store the shape
1034
+ this._attachedShapes.set(zoneId, shape);
1035
+
1036
+ // Update shape position when player moves
1037
+ const updateShapePosition = () => {
1038
+ const currentEntity = map.physic.getEntityByUUID(this.id);
1039
+ if (currentEntity) {
1040
+ const zoneInfo = zoneManager.getZone(physicZoneId);
1041
+ if (zoneInfo) {
1042
+ shape._updatePosition(zoneInfo.position.x, zoneInfo.position.y);
1043
+ }
1044
+ }
1045
+ };
1046
+
1047
+ // Listen to position changes to update shape position
1048
+ playerEntity.onPositionChange(() => {
1049
+ updateShapePosition();
1050
+ });
1051
+
1052
+ return shape;
1053
+ }
1054
+
1055
+ /**
1056
+ * Get all shapes attached to this player
1057
+ *
1058
+ * Returns all shapes that were created using `attachShape()` on this player.
1059
+ *
1060
+ * @returns Array of RpgShape instances attached to this player
1061
+ *
1062
+ * @example
1063
+ * ```ts
1064
+ * player.attachShape("vision", { radius: 150 });
1065
+ * player.attachShape("detection", { radius: 100 });
1066
+ *
1067
+ * const shapes = player.getShapes();
1068
+ * console.log(shapes.length); // 2
1069
+ * ```
1070
+ */
1071
+ getShapes(): RpgShape[] {
1072
+ return Array.from(this._attachedShapes.values());
256
1073
  }
257
1074
 
258
- broadcastEffect(id: string, params: any) {
1075
+ /**
1076
+ * Get all shapes where this player is currently located
1077
+ *
1078
+ * Returns all shapes (from any player/event) where this player is currently inside.
1079
+ * This is updated automatically when the player enters or exits shapes.
1080
+ *
1081
+ * @returns Array of RpgShape instances where this player is located
1082
+ *
1083
+ * @example
1084
+ * ```ts
1085
+ * // Another player has a detection zone
1086
+ * otherPlayer.attachShape("detection", { radius: 200 });
1087
+ *
1088
+ * // Check if this player is in any shape
1089
+ * const inShapes = player.getInShapes();
1090
+ * if (inShapes.length > 0) {
1091
+ * console.log("Player is being detected!");
1092
+ * }
1093
+ * ```
1094
+ */
1095
+ getInShapes(): RpgShape[] {
1096
+ return Array.from(this._inShapes);
1097
+ }
1098
+
1099
+ /**
1100
+ * Show a temporary component animation on this player
1101
+ *
1102
+ * This method broadcasts a component animation to all clients, allowing
1103
+ * temporary visual effects like hit indicators, spell effects, or status animations
1104
+ * to be displayed on the player.
1105
+ *
1106
+ * @param id - The ID of the component animation to display
1107
+ * @param params - Parameters to pass to the component animation
1108
+ *
1109
+ * @example
1110
+ * ```ts
1111
+ * // Show a hit animation with damage text
1112
+ * player.showComponentAnimation("hit", {
1113
+ * text: "150",
1114
+ * color: "red"
1115
+ * });
1116
+ *
1117
+ * // Show a heal animation
1118
+ * player.showComponentAnimation("heal", {
1119
+ * amount: 50
1120
+ * });
1121
+ * ```
1122
+ */
1123
+ showComponentAnimation(id: string, params: any = {}) {
259
1124
  const map = this.getCurrentMap();
260
1125
  if (!map) return;
261
1126
  map.$broadcast({
262
- type: "showEffect",
1127
+ type: "showComponentAnimation",
263
1128
  value: {
264
1129
  id,
265
1130
  params,
@@ -269,17 +1134,333 @@ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
269
1134
  }
270
1135
 
271
1136
  showHit(text: string) {
272
- this.broadcastEffect("hit", {
1137
+ this.showComponentAnimation("hit", {
273
1138
  text,
274
1139
  direction: this.direction(),
275
1140
  });
276
1141
  }
1142
+
1143
+ /**
1144
+ * Play a sound on the client side for this player only
1145
+ *
1146
+ * This method emits an event to play a sound only for this specific player.
1147
+ * The sound must be defined on the client side (in the client module configuration).
1148
+ *
1149
+ * ## Design
1150
+ *
1151
+ * The sound is sent only to this player's client connection, making it ideal
1152
+ * for personal feedback sounds like UI interactions, notifications, or personal
1153
+ * achievements. For map-wide sounds that all players should hear, use `map.playSound()` instead.
1154
+ *
1155
+ * @param soundId - Sound identifier, defined on the client side
1156
+ * @param options - Optional sound configuration
1157
+ * @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
1158
+ * @param options.loop - Whether the sound should loop (default: false)
1159
+ *
1160
+ * @example
1161
+ * ```ts
1162
+ * // Play a sound for this player only (default behavior)
1163
+ * player.playSound("item-pickup");
1164
+ *
1165
+ * // Play a sound with volume and loop
1166
+ * player.playSound("background-music", {
1167
+ * volume: 0.5,
1168
+ * loop: true
1169
+ * });
1170
+ *
1171
+ * // Play a notification sound at low volume
1172
+ * player.playSound("notification", { volume: 0.3 });
1173
+ * ```
1174
+ */
1175
+ playSound(soundId: string, options?: { volume?: number; loop?: boolean }): void {
1176
+ const map = this.getCurrentMap();
1177
+ if (!map) return;
1178
+
1179
+ const data: any = {
1180
+ soundId,
1181
+ };
1182
+
1183
+ if (options) {
1184
+ if (options.volume !== undefined) {
1185
+ data.volume = Math.max(0, Math.min(1, options.volume));
1186
+ }
1187
+ if (options.loop !== undefined) {
1188
+ data.loop = options.loop;
1189
+ }
1190
+ }
1191
+
1192
+ // Send only to this player
1193
+ this.emit("playSound", data);
1194
+ }
1195
+
1196
+ /**
1197
+ * Stop a sound that is currently playing for this player
1198
+ *
1199
+ * This method stops a sound that was previously started with `playSound()`.
1200
+ * The sound must be defined on the client side.
1201
+ *
1202
+ * @param soundId - Sound identifier to stop
1203
+ *
1204
+ * @example
1205
+ * ```ts
1206
+ * // Start a looping background music
1207
+ * player.playSound("background-music", { loop: true });
1208
+ *
1209
+ * // Later, stop it
1210
+ * player.stopSound("background-music");
1211
+ * ```
1212
+ */
1213
+ stopSound(soundId: string): void {
1214
+ const map = this.getCurrentMap();
1215
+ if (!map) return;
1216
+
1217
+ const data = {
1218
+ soundId,
1219
+ };
1220
+
1221
+ // Send stop command only to this player
1222
+ this.emit("stopSound", data);
1223
+ }
1224
+
1225
+ /**
1226
+ * Stop all currently playing sounds for this player
1227
+ *
1228
+ * This method stops all sounds that are currently playing for the player.
1229
+ * Useful when changing maps to prevent sound overlap.
1230
+ *
1231
+ * @example
1232
+ * ```ts
1233
+ * // Stop all sounds before changing map
1234
+ * player.stopAllSounds();
1235
+ * await player.changeMap("new-map");
1236
+ * ```
1237
+ */
1238
+ stopAllSounds(): void {
1239
+ const map = this.getCurrentMap();
1240
+ if (!map) return;
1241
+
1242
+ // Send stop all command only to this player
1243
+ this.emit("stopAllSounds", {});
1244
+ }
1245
+
1246
+ /**
1247
+ * Make the camera follow another player or event
1248
+ *
1249
+ * This method sends an instruction to the client to fix the viewport on another sprite.
1250
+ * The camera will follow the specified player or event, with optional smooth animation.
1251
+ *
1252
+ * ## Design
1253
+ *
1254
+ * The camera follow instruction is sent only to this player's client connection.
1255
+ * This allows each player to have their own camera target, useful for cutscenes,
1256
+ * following NPCs, or focusing on specific events.
1257
+ *
1258
+ * @param otherPlayer - The player or event that the camera should follow
1259
+ * @param options - Camera follow options
1260
+ * @param options.smoothMove - Enable smooth animation. Can be a boolean (default: true) or an object with animation parameters
1261
+ * @param options.smoothMove.time - Time duration for the animation in milliseconds (optional)
1262
+ * @param options.smoothMove.ease - Easing function name. Visit https://easings.net for available functions (optional)
1263
+ *
1264
+ * @example
1265
+ * ```ts
1266
+ * // Follow another player with default smooth animation
1267
+ * player.cameraFollow(otherPlayer, { smoothMove: true });
1268
+ *
1269
+ * // Follow an event with custom smooth animation
1270
+ * player.cameraFollow(npcEvent, {
1271
+ * smoothMove: {
1272
+ * time: 1000,
1273
+ * ease: "easeInOutQuad"
1274
+ * }
1275
+ * });
1276
+ *
1277
+ * // Follow without animation (instant)
1278
+ * player.cameraFollow(targetPlayer, { smoothMove: false });
1279
+ * ```
1280
+ */
1281
+ cameraFollow(
1282
+ otherPlayer: RpgPlayer | RpgEvent,
1283
+ options?: {
1284
+ smoothMove?: boolean | { time?: number; ease?: string };
1285
+ }
1286
+ ): void {
1287
+ const map = this.getCurrentMap();
1288
+ if (!map) return;
1289
+
1290
+ const data: any = {
1291
+ targetId: otherPlayer.id,
1292
+ };
1293
+
1294
+ // Handle smoothMove option
1295
+ if (options?.smoothMove !== undefined) {
1296
+ if (typeof options.smoothMove === "boolean") {
1297
+ data.smoothMove = options.smoothMove;
1298
+ } else {
1299
+ // smoothMove is an object
1300
+ data.smoothMove = {
1301
+ enabled: true,
1302
+ ...options.smoothMove,
1303
+ };
1304
+ }
1305
+ } else {
1306
+ // Default to true if not specified
1307
+ data.smoothMove = true;
1308
+ }
1309
+
1310
+ // Send camera follow instruction only to this player
1311
+ this.emit("cameraFollow", data);
1312
+ }
1313
+
1314
+
1315
+ /**
1316
+ * Trigger a flash animation on this player
1317
+ *
1318
+ * This method sends a flash animation event to the client, creating a visual
1319
+ * feedback effect on the player's sprite. The flash can be configured with
1320
+ * various options including type (alpha, tint, or both), duration, cycles, and color.
1321
+ *
1322
+ * ## Design
1323
+ *
1324
+ * The flash is sent as a broadcast event to all clients viewing this player.
1325
+ * This is useful for visual feedback when the player takes damage, receives
1326
+ * a buff, or when an important event occurs.
1327
+ *
1328
+ * @param options - Flash configuration options
1329
+ * @param options.type - Type of flash effect: 'alpha' (opacity), 'tint' (color), or 'both' (default: 'alpha')
1330
+ * @param options.duration - Duration of the flash animation in milliseconds (default: 300)
1331
+ * @param options.cycles - Number of flash cycles (flash on/off) (default: 1)
1332
+ * @param options.alpha - Alpha value when flashing, from 0 to 1 (default: 0.3)
1333
+ * @param options.tint - Tint color when flashing as hex value or color name (default: 0xffffff - white)
1334
+ *
1335
+ * @example
1336
+ * ```ts
1337
+ * // Simple flash with default settings (alpha flash)
1338
+ * player.flash();
1339
+ *
1340
+ * // Flash with red tint when taking damage
1341
+ * player.flash({ type: 'tint', tint: 0xff0000 });
1342
+ *
1343
+ * // Flash with both alpha and tint for dramatic effect
1344
+ * player.flash({
1345
+ * type: 'both',
1346
+ * alpha: 0.5,
1347
+ * tint: 0xff0000,
1348
+ * duration: 200,
1349
+ * cycles: 2
1350
+ * });
1351
+ *
1352
+ * // Quick damage flash
1353
+ * player.flash({
1354
+ * type: 'tint',
1355
+ * tint: 'red',
1356
+ * duration: 150,
1357
+ * cycles: 1
1358
+ * });
1359
+ * ```
1360
+ */
1361
+ flash(options?: {
1362
+ type?: 'alpha' | 'tint' | 'both';
1363
+ duration?: number;
1364
+ cycles?: number;
1365
+ alpha?: number;
1366
+ tint?: number | string;
1367
+ }): void {
1368
+ const map = this.getCurrentMap();
1369
+ if (!map) return;
1370
+
1371
+ const flashOptions = {
1372
+ type: options?.type || 'alpha',
1373
+ duration: options?.duration ?? 300,
1374
+ cycles: options?.cycles ?? 1,
1375
+ alpha: options?.alpha ?? 0.3,
1376
+ tint: options?.tint ?? 0xffffff,
1377
+ };
1378
+
1379
+ map.$broadcast({
1380
+ type: "flash",
1381
+ value: {
1382
+ object: this.id,
1383
+ ...flashOptions,
1384
+ },
1385
+ });
1386
+ }
1387
+
1388
+ /**
1389
+ * Set the hitbox of the player for collision detection
1390
+ *
1391
+ * This method defines the hitbox used for collision detection in the physics engine.
1392
+ * The hitbox can be smaller or larger than the visual representation of the player,
1393
+ * allowing for precise collision detection.
1394
+ *
1395
+ * ## Design
1396
+ *
1397
+ * The hitbox is used by the physics engine to detect collisions with other entities,
1398
+ * static obstacles, and shapes. Changing the hitbox will immediately update the
1399
+ * collision detection without affecting the visual appearance of the player.
1400
+ *
1401
+ * @param width - Width of the hitbox in pixels
1402
+ * @param height - Height of the hitbox in pixels
1403
+ *
1404
+ * @example
1405
+ * ```ts
1406
+ * // Set a 20x20 hitbox for precise collision detection
1407
+ * player.setHitbox(20, 20);
1408
+ *
1409
+ * // Set a larger hitbox for easier collision detection
1410
+ * player.setHitbox(40, 40);
1411
+ * ```
1412
+ */
1413
+ setHitbox(width: number, height: number): void {
1414
+ // Validate inputs
1415
+ if (typeof width !== 'number' || width <= 0) {
1416
+ throw new Error('setHitbox: width must be a positive number');
1417
+ }
1418
+ if (typeof height !== 'number' || height <= 0) {
1419
+ throw new Error('setHitbox: height must be a positive number');
1420
+ }
1421
+
1422
+ // Update hitbox signal
1423
+ this.hitbox.set({
1424
+ w: width,
1425
+ h: height,
1426
+ });
1427
+
1428
+ // Update physics entity if map exists
1429
+ const map = this.getCurrentMap();
1430
+ if (map && map.physic) {
1431
+ const topLeftX = this.x();
1432
+ const topLeftY = this.y();
1433
+ map.updateHitbox(this.id, topLeftX, topLeftY, width, height);
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * Set the sync schema for the map
1439
+ * @param schema - The schema to set
1440
+ */
1441
+ setSync(schema: any) {
1442
+ for (let key in schema) {
1443
+ this[key] = type(signal(null), key, {
1444
+ syncWithClient: schema[key]?.$syncWithClient,
1445
+ persist: schema[key]?.$permanent,
1446
+ }, this)
1447
+ }
1448
+ }
1449
+
1450
+ isEvent(): boolean {
1451
+ return false;
1452
+ }
277
1453
  }
278
1454
 
279
1455
  export class RpgEvent extends RpgPlayer {
1456
+
1457
+ constructor() {
1458
+ super();
1459
+ this.initializeDefaultStats()
1460
+ }
1461
+
280
1462
  override async execMethod(methodName: string, methodData: any[] = [], instance = this) {
281
- const hooks = inject<Hooks>(this.context as any, ModulesToken);
282
- await lastValueFrom(hooks
1463
+ await lastValueFrom(this.hooks
283
1464
  .callHooks(`server-event-${methodName}`, instance, ...methodData));
284
1465
  if (!instance[methodName]) {
285
1466
  return;
@@ -288,11 +1469,25 @@ export class RpgEvent extends RpgPlayer {
288
1469
  return ret;
289
1470
  }
290
1471
 
1472
+ /**
1473
+ * Remove this event from the map
1474
+ *
1475
+ * Stops all movements before removing to prevent "unable to resolve entity" errors
1476
+ * from the MovementManager when the entity is destroyed while moving.
1477
+ */
291
1478
  remove() {
292
1479
  const map = this.getCurrentMap();
293
1480
  if (!map) return;
1481
+
1482
+ // Stop all movements before removing to prevent MovementManager errors
1483
+ this.stopMoveTo();
1484
+
294
1485
  map.removeEvent(this.id);
295
1486
  }
1487
+
1488
+ override isEvent(): boolean {
1489
+ return true;
1490
+ }
296
1491
  }
297
1492
 
298
1493
 
@@ -302,18 +1497,17 @@ export class RpgEvent extends RpgPlayer {
302
1497
  * Extends the RpgPlayer class with additional interfaces from mixins.
303
1498
  * This provides proper TypeScript support for all mixin methods and properties.
304
1499
  */
305
- export interface RpgPlayer extends
306
- IVariableManager,
307
- IMoveManager,
308
- IGoldManager,
309
- IComponentManager,
310
- IGuiManager,
311
- IItemManager,
312
- IEffectManager,
313
- IParameterManager,
314
- IElementManager,
315
- ISkillManager,
316
- IBattleManager,
317
- IClassManager,
318
- IStateManager
319
- {}
1500
+ export interface RpgPlayer extends
1501
+ IVariableManager,
1502
+ IMoveManager,
1503
+ IGoldManager,
1504
+ IComponentManager,
1505
+ IGuiManager,
1506
+ IItemManager,
1507
+ IEffectManager,
1508
+ IParameterManager,
1509
+ IElementManager,
1510
+ ISkillManager,
1511
+ IBattleManager,
1512
+ IClassManager,
1513
+ IStateManager { }