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

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