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

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