@rpgjs/server 5.0.0-alpha.2 → 5.0.0-alpha.20

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 (53) hide show
  1. package/dist/Gui/DialogGui.d.ts +4 -0
  2. package/dist/Gui/index.d.ts +1 -0
  3. package/dist/Player/BattleManager.d.ts +32 -22
  4. package/dist/Player/ClassManager.d.ts +31 -18
  5. package/dist/Player/ComponentManager.d.ts +123 -0
  6. package/dist/Player/Components.d.ts +345 -0
  7. package/dist/Player/EffectManager.d.ts +40 -0
  8. package/dist/Player/ElementManager.d.ts +31 -0
  9. package/dist/Player/GoldManager.d.ts +22 -0
  10. package/dist/Player/GuiManager.d.ts +176 -0
  11. package/dist/Player/ItemFixture.d.ts +6 -0
  12. package/dist/Player/ItemManager.d.ts +164 -10
  13. package/dist/Player/MoveManager.d.ts +32 -44
  14. package/dist/Player/ParameterManager.d.ts +343 -14
  15. package/dist/Player/Player.d.ts +266 -8
  16. package/dist/Player/SkillManager.d.ts +27 -19
  17. package/dist/Player/StateManager.d.ts +28 -35
  18. package/dist/Player/VariableManager.d.ts +30 -0
  19. package/dist/RpgServer.d.ts +227 -1
  20. package/dist/decorators/event.d.ts +46 -0
  21. package/dist/decorators/map.d.ts +177 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.js +17436 -18167
  24. package/dist/index.js.map +1 -1
  25. package/dist/rooms/map.d.ts +486 -8
  26. package/package.json +17 -15
  27. package/src/Gui/DialogGui.ts +7 -2
  28. package/src/Gui/index.ts +3 -1
  29. package/src/Player/BattleManager.ts +97 -38
  30. package/src/Player/ClassManager.ts +95 -35
  31. package/src/Player/ComponentManager.ts +425 -19
  32. package/src/Player/Components.ts +380 -0
  33. package/src/Player/EffectManager.ts +110 -27
  34. package/src/Player/ElementManager.ts +126 -25
  35. package/src/Player/GoldManager.ts +32 -35
  36. package/src/Player/GuiManager.ts +187 -140
  37. package/src/Player/ItemFixture.ts +4 -5
  38. package/src/Player/ItemManager.ts +363 -48
  39. package/src/Player/MoveManager.ts +323 -308
  40. package/src/Player/ParameterManager.ts +499 -99
  41. package/src/Player/Player.ts +719 -80
  42. package/src/Player/SkillManager.ts +44 -23
  43. package/src/Player/StateManager.ts +210 -95
  44. package/src/Player/VariableManager.ts +180 -48
  45. package/src/RpgServer.ts +236 -1
  46. package/src/core/context.ts +1 -0
  47. package/src/decorators/event.ts +61 -0
  48. package/src/decorators/map.ts +198 -0
  49. package/src/index.ts +5 -1
  50. package/src/module.ts +24 -0
  51. package/src/rooms/map.ts +1054 -54
  52. package/dist/Player/Event.d.ts +0 -0
  53. package/src/Player/Event.ts +0 -0
@@ -5,33 +5,36 @@ 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 { createStatesSnapshot, 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";
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";
31
32
  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
-
33
+ import { IElementManager, WithElementManager } from "./ElementManager";
34
+ import { ISkillManager, WithSkillManager } from "./SkillManager";
35
+ import { IBattleManager, WithBattleManager } from "./BattleManager";
36
+ import { IClassManager, WithClassManager } from "./ClassManager";
37
+ import { IStateManager, WithStateManager } from "./StateManager";
35
38
 
36
39
  /**
37
40
  * Combines multiple RpgCommonPlayer mixins into one
@@ -46,49 +49,132 @@ function combinePlayerMixins<T extends Constructor<RpgCommonPlayer>>(
46
49
  mixins.reduce((ExtendedClass, mixin) => mixin(ExtendedClass), Base);
47
50
  }
48
51
 
49
- const PlayerMixins = combinePlayerMixins([
52
+ // Start with basic mixins that work
53
+ const BasicPlayerMixins = combinePlayerMixins([
50
54
  WithComponentManager,
51
55
  WithEffectManager,
52
56
  WithGuiManager,
53
57
  WithMoveManager,
54
58
  WithGoldManager,
55
- WithVariableManager,
56
59
  WithParameterManager,
57
60
  WithItemFixture,
58
- WithStateManager,
59
61
  WithItemManager,
60
- WithSkillManager,
62
+ WithElementManager,
63
+ WithVariableManager,
64
+ WithStateManager,
61
65
  WithClassManager,
66
+ WithSkillManager,
62
67
  WithBattleManager,
63
- WithElementManager,
64
68
  ]);
65
69
 
66
70
  /**
67
71
  * RPG Player class with component management capabilities
72
+ *
73
+ * Combines all player mixins to provide a complete player implementation
74
+ * with graphics, movement, inventory, skills, and battle capabilities.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * // Create a new player
79
+ * const player = new RpgPlayer();
80
+ *
81
+ * // Set player graphics
82
+ * player.setGraphic("hero");
83
+ *
84
+ * // Add parameters and items
85
+ * player.addParameter("strength", { start: 10, end: 100 });
86
+ * player.addItem(sword);
87
+ * ```
68
88
  */
69
- export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
89
+ export class RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
70
90
  map: RpgMap | null = null;
71
91
  context?: Context;
72
92
  conn: MockConnection | null = null;
93
+ touchSide: boolean = false; // Protection against map change loops
94
+
95
+ /** Internal: Shapes attached to this player */
96
+ private _attachedShapes: Map<string, RpgShape> = new Map();
97
+
98
+ /** Internal: Shapes where this player is currently located */
99
+ private _inShapes: Set<RpgShape> = new Set();
100
+ /** Last processed client input timestamp for reconciliation */
101
+ lastProcessedInputTs: number = 0;
102
+ /** Last processed client input frame for reconciliation with server tick */
103
+ _lastFramePositions: {
104
+ frame: number;
105
+ position: {
106
+ x: number;
107
+ y: number;
108
+ direction: Direction;
109
+ };
110
+ serverTick?: number; // Server tick at which this position was computed
111
+ } | null = null;
112
+
113
+ frames: { x: number; y: number; ts: number }[] = [];
73
114
 
74
115
  @sync(RpgPlayer) events = signal<RpgEvent[]>([]);
75
116
 
76
117
  constructor() {
77
118
  super();
78
- this.expCurve = {
119
+ // Use type assertion to access mixin properties
120
+ (this as any).expCurve = {
79
121
  basis: 30,
80
122
  extra: 20,
81
123
  accelerationA: 30,
82
124
  accelerationB: 30
83
- }
125
+ };
84
126
 
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()
127
+ (this as any).addParameter(MAXHP, MAXHP_CURVE);
128
+ (this as any).addParameter(MAXSP, MAXSP_CURVE);
129
+ (this as any).addParameter(STR, STR_CURVE);
130
+ (this as any).addParameter(INT, INT_CURVE);
131
+ (this as any).addParameter(DEX, DEX_CURVE);
132
+ (this as any).addParameter(AGI, AGI_CURVE);
133
+ (this as any).allRecovery();
134
+
135
+ let lastEmitted: { x: number; y: number } | null = null;
136
+ let pendingUpdate: { x: number; y: number } | null = null;
137
+ let updateScheduled = false;
138
+
139
+ combineLatest([this.x.observable, this.y.observable])
140
+ .subscribe(([x, y]) => {
141
+ pendingUpdate = { x, y };
142
+
143
+ // Schedule a synchronous update using queueMicrotask
144
+ // This groups multiple rapid changes (x and y in the same tick) into a single frame
145
+ if (!updateScheduled) {
146
+ updateScheduled = true;
147
+ queueMicrotask(() => {
148
+ if (pendingUpdate) {
149
+ const { x, y } = pendingUpdate;
150
+ // Only emit if the values are different from the last emitted frame
151
+ if (!lastEmitted || lastEmitted.x !== x || lastEmitted.y !== y) {
152
+ this.frames = [...this.frames, {
153
+ x: x,
154
+ y: y,
155
+ ts: Date.now(),
156
+ }];
157
+ lastEmitted = { x, y };
158
+ }
159
+ pendingUpdate = null;
160
+ }
161
+ updateScheduled = false;
162
+ });
163
+ }
164
+ })
165
+ }
166
+
167
+ _onInit() {
168
+ this.hooks.callHooks("server-playerProps-load", this).subscribe();
169
+ }
170
+
171
+ get hooks() {
172
+ return inject<Hooks>(this.context as any, ModulesToken);
173
+ }
174
+
175
+ applyFrames() {
176
+ this._frames.set(this.frames)
177
+ this.frames = []
92
178
  }
93
179
 
94
180
  async execMethod(method: string, methodData: any[] = [], target?: any) {
@@ -97,8 +183,7 @@ export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
97
183
  ret = await target[method](...methodData);
98
184
  }
99
185
  else {
100
- const hooks = inject<Hooks>(this.context as any, ModulesToken);
101
- ret = await lastValueFrom(hooks
186
+ ret = await lastValueFrom(this.hooks
102
187
  .callHooks(`server-player-${method}`, target ?? this, ...methodData));
103
188
  }
104
189
  this.syncChanges()
@@ -125,17 +210,154 @@ export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
125
210
  mapId: string,
126
211
  positions?: { x: number; y: number; z?: number } | string
127
212
  ): Promise<any | null | boolean> {
213
+ const realMapId = 'map-' + mapId;
214
+ const room = this.getCurrentMap();
215
+
216
+ const canChange: boolean[] = await lastValueFrom(this.hooks.callHooks("server-player-canChangeMap", this, {
217
+ id: mapId,
218
+ }));
219
+ if (canChange.some(v => v === false)) return false;
220
+
221
+ if (positions && typeof positions === 'object') {
222
+ this.teleport(positions)
223
+ }
224
+ await room?.$sessionTransfer(this.conn, realMapId);
128
225
  this.emit("changeMap", {
129
- mapId: 'map-' + mapId,
226
+ mapId: realMapId,
130
227
  positions,
131
228
  });
132
229
  return true;
133
230
  }
134
231
 
232
+ /**
233
+ * Auto change map when player touches map borders
234
+ *
235
+ * This method checks if the player touches the current map borders
236
+ * and automatically performs a change to the adjacent map if it exists.
237
+ *
238
+ * @param nextPosition - The next position of the player
239
+ * @returns Promise<boolean> - true if a map change occurred
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * // Called automatically by the movement system
244
+ * const changed = await player.autoChangeMap({ x: newX, y: newY });
245
+ * if (changed) {
246
+ * console.log('Player changed map automatically');
247
+ * }
248
+ * ```
249
+ */
250
+ async autoChangeMap(nextPosition: { x: number; y: number }, forcedDirection?: any): Promise<boolean> {
251
+ const map = this.getCurrentMap() as RpgMap; // Cast to access extended properties
252
+ if (!map) return false;
253
+
254
+ const worldMaps = map.getWorldMapsManager?.();
255
+ let ret: boolean = false;
256
+
257
+ if (worldMaps && map) {
258
+ const direction = forcedDirection ?? this.getDirection();
259
+ const marginLeftRight = (map.tileWidth ?? 32) / 2;
260
+ const marginTopDown = (map.tileHeight ?? 32) / 2;
261
+
262
+ // Current world position of the player
263
+ const worldPositionX = (map.worldX ?? 0) + this.x();
264
+ const worldPositionY = (map.worldY ?? 0) + this.y();
265
+
266
+ const changeMap = async (adjacentCoords: {x: number, y: number}, positionCalculator: (nextMapInfo: any) => {x: number, y: number}) => {
267
+ if (this.touchSide) {
268
+ return false;
269
+ }
270
+ this.touchSide = true;
271
+
272
+ const [nextMap] = worldMaps.getAdjacentMaps(map, adjacentCoords);
273
+ if (!nextMap) {
274
+ this.touchSide = false;
275
+ return false;
276
+ }
277
+
278
+ const id = nextMap.id as string;
279
+ const nextMapInfo = worldMaps.getMapInfo(id);
280
+ if (!nextMapInfo) {
281
+ this.touchSide = false;
282
+ return false;
283
+ }
284
+
285
+ const newPosition = positionCalculator(nextMapInfo);
286
+ const success = await this.changeMap(id, newPosition);
287
+
288
+ // Reset touchSide after a delay to allow the change
289
+ setTimeout(() => {
290
+ this.touchSide = false;
291
+ }, 100);
292
+
293
+ return !!success;
294
+ };
295
+ // Check left border
296
+ if (nextPosition.x < marginLeftRight && direction === Direction.Left) {
297
+ ret = await changeMap({
298
+ x: (map.worldX ?? 0) - 1,
299
+ y: worldPositionY
300
+ }, nextMapInfo => ({
301
+ x: nextMapInfo.width - (this.hitbox().w) - marginLeftRight,
302
+ y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
303
+ }));
304
+ }
305
+ // Check right border
306
+ else if (nextPosition.x > map.widthPx - this.hitbox().w - marginLeftRight && direction === Direction.Right) {
307
+ ret = await changeMap({
308
+ x: (map.worldX ?? 0) + map.widthPx + 1,
309
+ y: worldPositionY
310
+ }, nextMapInfo => ({
311
+ x: marginLeftRight,
312
+ y: (map.worldY ?? 0) - (nextMapInfo.y ?? 0) + nextPosition.y
313
+ }));
314
+ }
315
+ // Check top border
316
+ else if (nextPosition.y < marginTopDown && direction === Direction.Up) {
317
+ ret = await changeMap({
318
+ x: worldPositionX,
319
+ y: (map.worldY ?? 0) - 1
320
+ }, nextMapInfo => ({
321
+ x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
322
+ y: nextMapInfo.height - this.hitbox().h - marginTopDown
323
+ }));
324
+ }
325
+ // Check bottom border
326
+ else if (nextPosition.y > map.heightPx - this.hitbox().h - marginTopDown && direction === Direction.Down) {
327
+ ret = await changeMap({
328
+ x: worldPositionX,
329
+ y: (map.worldY ?? 0) + map.heightPx + 1
330
+ }, nextMapInfo => ({
331
+ x: (map.worldX ?? 0) - (nextMapInfo.x ?? 0) + nextPosition.x,
332
+ y: marginTopDown
333
+ }));
334
+ }
335
+ else {
336
+ this.touchSide = false;
337
+ }
338
+ }
339
+
340
+ return ret;
341
+ }
342
+
135
343
  async teleport(positions: { x: number; y: number }) {
136
344
  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);
345
+ if (this.map.physic) {
346
+ // Skip collision check for teleportation (allow teleporting through walls)
347
+ const entity = this.map.physic.getEntityByUUID(this.id);
348
+ if (entity) {
349
+ this.map.physic.teleport(entity, { x: positions.x, y: positions.y });
350
+ }
351
+ }
352
+ else {
353
+ this.x.set(positions.x)
354
+ this.y.set(positions.y)
355
+ }
356
+ // Wait for the frame to be added before applying frames
357
+ // This ensures the frame is added before applyFrames() is called
358
+ queueMicrotask(() => {
359
+ this.applyFrames()
360
+ })
139
361
  }
140
362
 
141
363
  getCurrentMap<T extends RpgMap = RpgMap>(): T | null {
@@ -151,7 +373,63 @@ export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
151
373
  });
152
374
  }
153
375
 
154
- showAnimation(params: ShowAnimationParams) {}
376
+ async save() {
377
+ const snapshot = createStatesSnapshot(this)
378
+ await lastValueFrom(this.hooks.callHooks("server-player-onSave", this, snapshot))
379
+ return JSON.stringify(snapshot)
380
+ }
381
+
382
+ async load(snapshot: string) {
383
+ const data = JSON.parse(snapshot)
384
+ const dataLoaded = load(this, data)
385
+ await lastValueFrom(this.hooks.callHooks("server-player-onLoad", this, dataLoaded))
386
+ return dataLoaded
387
+ }
388
+
389
+ /**
390
+ * Set the current animation of the player's sprite
391
+ *
392
+ * This method changes the animation state of the player's current sprite.
393
+ * It's used to trigger character animations like attack, skill, or custom movements.
394
+ * When `nbTimes` is set to a finite number, the animation will play that many times
395
+ * before returning to the previous animation state.
396
+ *
397
+ * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
398
+ * @param nbTimes - Number of times to repeat the animation (default: Infinity for continuous)
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * // Set continuous walk animation
403
+ * player.setAnimation('walk');
404
+ *
405
+ * // Play attack animation 3 times then return to previous state
406
+ * player.setAnimation('attack', 3);
407
+ *
408
+ * // Play skill animation once
409
+ * player.setAnimation('skill', 1);
410
+ *
411
+ * // Set idle/stand animation
412
+ * player.setAnimation('stand');
413
+ * ```
414
+ */
415
+ setAnimation(animationName: string, nbTimes: number = Infinity) {
416
+ const map = this.getCurrentMap();
417
+ if (!map) return;
418
+ if (nbTimes === Infinity) {
419
+ this.animationName.set(animationName);
420
+ }
421
+ else {
422
+ map.$broadcast({
423
+ type: "setAnimation",
424
+ value: {
425
+ animationName,
426
+ nbTimes,
427
+ object: this.id,
428
+ },
429
+ });
430
+ }
431
+ }
432
+
155
433
 
156
434
  /**
157
435
  * 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
@@ -183,56 +461,310 @@ export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
183
461
  const { events } = map;
184
462
  const arrayEvents: any[] = [
185
463
  ...Object.values(this.events()),
186
- ...Object.values(events()),
464
+ ...Object.values(events?.() ?? {}),
187
465
  ];
188
466
  for (let event of arrayEvents) {
189
467
  if (event.onChanges) event.onChanges(this);
190
468
  }
191
469
  }
192
470
 
193
- attachShape(id: string, options: ZoneOptions) {
471
+ /**
472
+ * Attach a zone shape to this player using the physic zone system
473
+ *
474
+ * This method creates a zone attached to the player's entity in the physics engine.
475
+ * The zone can be circular or cone-shaped and will detect other entities (players/events)
476
+ * entering or exiting the zone.
477
+ *
478
+ * @param id - Optional zone identifier. If not provided, a unique ID will be generated
479
+ * @param options - Zone configuration options
480
+ *
481
+ * @example
482
+ * ```ts
483
+ * // Create a circular detection zone
484
+ * player.attachShape("vision", {
485
+ * radius: 150,
486
+ * angle: 360,
487
+ * });
488
+ *
489
+ * // Create a cone-shaped vision zone
490
+ * player.attachShape("vision", {
491
+ * radius: 200,
492
+ * angle: 120,
493
+ * direction: Direction.Right,
494
+ * limitedByWalls: true,
495
+ * });
496
+ *
497
+ * // Create a zone with width/height (radius calculated automatically)
498
+ * player.attachShape({
499
+ * width: 100,
500
+ * height: 100,
501
+ * positioning: "center",
502
+ * });
503
+ * ```
504
+ */
505
+ attachShape(idOrOptions: string | AttachShapeOptions, options?: AttachShapeOptions): RpgShape | undefined {
194
506
  const map = this.getCurrentMap();
195
- if (!map) return;
507
+ if (!map) return undefined;
508
+
509
+ // Handle overloaded signature: attachShape(options) or attachShape(id, options)
510
+ let zoneId: string;
511
+ let shapeOptions: AttachShapeOptions;
512
+
513
+ if (typeof idOrOptions === 'string') {
514
+ zoneId = idOrOptions;
515
+ if (!options) {
516
+ console.warn('attachShape: options must be provided when id is specified');
517
+ return undefined;
518
+ }
519
+ shapeOptions = options;
520
+ } else {
521
+ zoneId = `zone-${this.id}-${Date.now()}`;
522
+ shapeOptions = idOrOptions;
523
+ }
196
524
 
197
- const physic = map.physic;
525
+ // Get player entity from physic engine
526
+ const playerEntity = map.physic.getEntityByUUID(this.id);
527
+ if (!playerEntity) {
528
+ console.warn(`Player entity not found in physic engine for player ${this.id}`);
529
+ return undefined;
530
+ }
198
531
 
199
- const zoneId = physic.addZone(id, {
200
- linkedTo: this.id,
201
- ...options,
202
- });
532
+ // Calculate radius from width/height if not provided
533
+ let radius: number;
534
+ if (shapeOptions.radius !== undefined) {
535
+ radius = shapeOptions.radius;
536
+ } else if (shapeOptions.width && shapeOptions.height) {
537
+ // Use the larger dimension as radius, or calculate from area
538
+ radius = Math.max(shapeOptions.width, shapeOptions.height) / 2;
539
+ } else {
540
+ console.warn('attachShape: radius or width/height must be provided');
541
+ return undefined;
542
+ }
203
543
 
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
- });
544
+ // Calculate offset based on positioning
545
+ let offset: Vector2 = new Vector2(0, 0);
546
+ const positioning: ShapePositioning = shapeOptions.positioning || "default";
547
+ if (shapeOptions.positioning) {
548
+ const playerWidth = playerEntity.width || playerEntity.radius * 2 || 32;
549
+ const playerHeight = playerEntity.height || playerEntity.radius * 2 || 32;
550
+
551
+ switch (shapeOptions.positioning) {
552
+ case 'top':
553
+ offset = new Vector2(0, -playerHeight / 2);
554
+ break;
555
+ case 'bottom':
556
+ offset = new Vector2(0, playerHeight / 2);
557
+ break;
558
+ case 'left':
559
+ offset = new Vector2(-playerWidth / 2, 0);
560
+ break;
561
+ case 'right':
562
+ offset = new Vector2(playerWidth / 2, 0);
563
+ break;
564
+ case 'center':
565
+ default:
566
+ offset = new Vector2(0, 0);
567
+ break;
568
+ }
569
+ }
570
+
571
+ // Get zone manager and create attached zone
572
+ const zoneManager = map.physic.getZoneManager();
573
+
574
+ // Convert direction from Direction enum to string if needed
575
+ // Direction enum values are already strings ("up", "down", "left", "right")
576
+ let direction: 'up' | 'down' | 'left' | 'right' = 'down';
577
+ if (shapeOptions.direction !== undefined) {
578
+ if (typeof shapeOptions.direction === 'string') {
579
+ direction = shapeOptions.direction as 'up' | 'down' | 'left' | 'right';
580
+ } else {
581
+ // Direction enum value is already a string, just cast it
582
+ direction = String(shapeOptions.direction) as 'up' | 'down' | 'left' | 'right';
583
+ }
584
+ }
585
+
586
+ // Create zone with metadata for name and properties
587
+ const metadata: Record<string, any> = {};
588
+ if (shapeOptions.name) {
589
+ metadata.name = shapeOptions.name;
590
+ }
591
+ if (shapeOptions.properties) {
592
+ metadata.properties = shapeOptions.properties;
593
+ }
594
+
595
+ // Get initial position
596
+ const initialX = playerEntity.position.x + offset.x;
597
+ const initialY = playerEntity.position.y + offset.y;
598
+
599
+ const physicZoneId = zoneManager.createAttachedZone(
600
+ playerEntity,
601
+ {
602
+ radius,
603
+ angle: shapeOptions.angle ?? 360,
604
+ direction,
605
+ limitedByWalls: shapeOptions.limitedByWalls ?? false,
606
+ offset,
607
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
216
608
  },
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
- });
609
+ {
610
+ onEnter: (entities: Entity[]) => {
611
+ entities.forEach((entity) => {
612
+ const event = map.getEvent<RpgEvent>(entity.uuid);
613
+ const player = map.getPlayer(entity.uuid);
614
+
615
+ if (event) {
616
+ event.execMethod("onInShape", [shape, this]);
617
+ // Track that this event is in the shape
618
+ if ((event as any)._inShapes) {
619
+ (event as any)._inShapes.add(shape);
620
+ }
621
+ }
622
+ if (player) {
623
+ this.execMethod("onDetectInShape", [player, shape]);
624
+ // Track that this player is in the shape
625
+ if (player._inShapes) {
626
+ player._inShapes.add(shape);
627
+ }
628
+ }
629
+ });
630
+ },
631
+ onExit: (entities: Entity[]) => {
632
+ entities.forEach((entity) => {
633
+ const event = map.getEvent<RpgEvent>(entity.uuid);
634
+ const player = map.getPlayer(entity.uuid);
635
+
636
+ if (event) {
637
+ event.execMethod("onOutShape", [shape, this]);
638
+ // Remove from tracking
639
+ if ((event as any)._inShapes) {
640
+ (event as any)._inShapes.delete(shape);
641
+ }
642
+ }
643
+ if (player) {
644
+ this.execMethod("onDetectOutShape", [player, shape]);
645
+ // Remove from tracking
646
+ if (player._inShapes) {
647
+ player._inShapes.delete(shape);
648
+ }
649
+ }
650
+ });
651
+ },
227
652
  }
228
653
  );
654
+
655
+ // Create RpgShape instance
656
+ const shape = new RpgShape({
657
+ name: shapeOptions.name || zoneId,
658
+ positioning,
659
+ width: shapeOptions.width || radius * 2,
660
+ height: shapeOptions.height || radius * 2,
661
+ x: initialX,
662
+ y: initialY,
663
+ properties: shapeOptions.properties || {},
664
+ playerOwner: this,
665
+ physicZoneId: physicZoneId,
666
+ map: map,
667
+ });
668
+
669
+ // Store mapping from zoneId to physicZoneId for future reference
670
+ (this as any)._zoneIdMap = (this as any)._zoneIdMap || new Map();
671
+ (this as any)._zoneIdMap.set(zoneId, physicZoneId);
672
+
673
+ // Store the shape
674
+ this._attachedShapes.set(zoneId, shape);
675
+
676
+ // Update shape position when player moves
677
+ const updateShapePosition = () => {
678
+ const currentEntity = map.physic.getEntityByUUID(this.id);
679
+ if (currentEntity) {
680
+ const zoneInfo = zoneManager.getZone(physicZoneId);
681
+ if (zoneInfo) {
682
+ shape._updatePosition(zoneInfo.position.x, zoneInfo.position.y);
683
+ }
684
+ }
685
+ };
686
+
687
+ // Listen to position changes to update shape position
688
+ playerEntity.onPositionChange(() => {
689
+ updateShapePosition();
690
+ });
691
+
692
+ return shape;
693
+ }
694
+
695
+ /**
696
+ * Get all shapes attached to this player
697
+ *
698
+ * Returns all shapes that were created using `attachShape()` on this player.
699
+ *
700
+ * @returns Array of RpgShape instances attached to this player
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * player.attachShape("vision", { radius: 150 });
705
+ * player.attachShape("detection", { radius: 100 });
706
+ *
707
+ * const shapes = player.getShapes();
708
+ * console.log(shapes.length); // 2
709
+ * ```
710
+ */
711
+ getShapes(): RpgShape[] {
712
+ return Array.from(this._attachedShapes.values());
713
+ }
714
+
715
+ /**
716
+ * Get all shapes where this player is currently located
717
+ *
718
+ * Returns all shapes (from any player/event) where this player is currently inside.
719
+ * This is updated automatically when the player enters or exits shapes.
720
+ *
721
+ * @returns Array of RpgShape instances where this player is located
722
+ *
723
+ * @example
724
+ * ```ts
725
+ * // Another player has a detection zone
726
+ * otherPlayer.attachShape("detection", { radius: 200 });
727
+ *
728
+ * // Check if this player is in any shape
729
+ * const inShapes = player.getInShapes();
730
+ * if (inShapes.length > 0) {
731
+ * console.log("Player is being detected!");
732
+ * }
733
+ * ```
734
+ */
735
+ getInShapes(): RpgShape[] {
736
+ return Array.from(this._inShapes);
229
737
  }
230
738
 
231
- broadcastEffect(id: string, params: any) {
739
+ /**
740
+ * Show a temporary component animation on this player
741
+ *
742
+ * This method broadcasts a component animation to all clients, allowing
743
+ * temporary visual effects like hit indicators, spell effects, or status animations
744
+ * to be displayed on the player.
745
+ *
746
+ * @param id - The ID of the component animation to display
747
+ * @param params - Parameters to pass to the component animation
748
+ *
749
+ * @example
750
+ * ```ts
751
+ * // Show a hit animation with damage text
752
+ * player.showComponentAnimation("hit", {
753
+ * text: "150",
754
+ * color: "red"
755
+ * });
756
+ *
757
+ * // Show a heal animation
758
+ * player.showComponentAnimation("heal", {
759
+ * amount: 50
760
+ * });
761
+ * ```
762
+ */
763
+ showComponentAnimation(id: string, params: any = {}) {
232
764
  const map = this.getCurrentMap();
233
765
  if (!map) return;
234
766
  map.$broadcast({
235
- type: "showEffect",
767
+ type: "showComponentAnimation",
236
768
  value: {
237
769
  id,
238
770
  params,
@@ -242,17 +774,111 @@ export class RpgPlayer extends PlayerMixins(RpgCommonPlayer) {
242
774
  }
243
775
 
244
776
  showHit(text: string) {
245
- this.broadcastEffect("hit", {
777
+ this.showComponentAnimation("hit", {
246
778
  text,
247
779
  direction: this.direction(),
248
780
  });
249
781
  }
782
+
783
+ /**
784
+ * Play a sound on the client side for this player only
785
+ *
786
+ * This method emits an event to play a sound only for this specific player.
787
+ * The sound must be defined on the client side (in the client module configuration).
788
+ *
789
+ * ## Design
790
+ *
791
+ * The sound is sent only to this player's client connection, making it ideal
792
+ * for personal feedback sounds like UI interactions, notifications, or personal
793
+ * achievements. For map-wide sounds that all players should hear, use `map.playSound()` instead.
794
+ *
795
+ * @param soundId - Sound identifier, defined on the client side
796
+ * @param options - Optional sound configuration
797
+ * @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
798
+ * @param options.loop - Whether the sound should loop (default: false)
799
+ *
800
+ * @example
801
+ * ```ts
802
+ * // Play a sound for this player only (default behavior)
803
+ * player.playSound("item-pickup");
804
+ *
805
+ * // Play a sound with volume and loop
806
+ * player.playSound("background-music", {
807
+ * volume: 0.5,
808
+ * loop: true
809
+ * });
810
+ *
811
+ * // Play a notification sound at low volume
812
+ * player.playSound("notification", { volume: 0.3 });
813
+ * ```
814
+ */
815
+ playSound(soundId: string, options?: { volume?: number; loop?: boolean }): void {
816
+ const map = this.getCurrentMap();
817
+ if (!map) return;
818
+
819
+ const data: any = {
820
+ soundId,
821
+ };
822
+
823
+ if (options) {
824
+ if (options.volume !== undefined) {
825
+ data.volume = Math.max(0, Math.min(1, options.volume));
826
+ }
827
+ if (options.loop !== undefined) {
828
+ data.loop = options.loop;
829
+ }
830
+ }
831
+
832
+ // Send only to this player
833
+ this.emit("playSound", data);
834
+ }
835
+
836
+ /**
837
+ * Stop a sound that is currently playing for this player
838
+ *
839
+ * This method stops a sound that was previously started with `playSound()`.
840
+ * The sound must be defined on the client side.
841
+ *
842
+ * @param soundId - Sound identifier to stop
843
+ *
844
+ * @example
845
+ * ```ts
846
+ * // Start a looping background music
847
+ * player.playSound("background-music", { loop: true });
848
+ *
849
+ * // Later, stop it
850
+ * player.stopSound("background-music");
851
+ * ```
852
+ */
853
+ stopSound(soundId: string): void {
854
+ const map = this.getCurrentMap();
855
+ if (!map) return;
856
+
857
+ const data = {
858
+ soundId,
859
+ };
860
+
861
+ // Send stop command only to this player
862
+ this.emit("stopSound", data);
863
+ }
864
+
865
+ /**
866
+ * Set the sync schema for the map
867
+ * @param schema - The schema to set
868
+ */
869
+ setSync(schema: any) {
870
+ for (let key in schema) {
871
+ this[key] = type(signal(null), key, {
872
+ syncWithClient: schema[key]?.$syncWithClient,
873
+ persist: schema[key]?.$permanent,
874
+ }, this)
875
+ }
876
+ }
250
877
  }
251
878
 
252
879
  export class RpgEvent extends RpgPlayer {
253
880
  override async execMethod(methodName: string, methodData: any[] = [], instance = this) {
254
- const hooks = inject<Hooks>(this.context as any, ModulesToken);
255
- await lastValueFrom(hooks
881
+ await lastValueFrom(this.hooks
256
882
  .callHooks(`server-event-${methodName}`, instance, ...methodData));
257
883
  if (!instance[methodName]) {
258
884
  return;
@@ -268,12 +894,25 @@ export class RpgEvent extends RpgPlayer {
268
894
  }
269
895
  }
270
896
 
271
- export interface RpgPlayer
272
- extends RpgCommonPlayer,
273
- IComponentManager,
274
- IGuiManager,
275
- IMoveManager,
276
- IGoldManager,
277
- IWithVariableManager,
278
- IWithParameterManager,
279
- IWithSkillManager {}
897
+
898
+ /**
899
+ * Interface extension for RpgPlayer
900
+ *
901
+ * Extends the RpgPlayer class with additional interfaces from mixins.
902
+ * This provides proper TypeScript support for all mixin methods and properties.
903
+ */
904
+ export interface RpgPlayer extends
905
+ IVariableManager,
906
+ IMoveManager,
907
+ IGoldManager,
908
+ IComponentManager,
909
+ IGuiManager,
910
+ IItemManager,
911
+ IEffectManager,
912
+ IParameterManager,
913
+ IElementManager,
914
+ ISkillManager,
915
+ IBattleManager,
916
+ IClassManager,
917
+ IStateManager
918
+ {}