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

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