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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/Gui/DialogGui.d.ts +5 -0
  2. package/dist/Gui/GameoverGui.d.ts +23 -0
  3. package/dist/Gui/Gui.d.ts +6 -0
  4. package/dist/Gui/MenuGui.d.ts +22 -3
  5. package/dist/Gui/NotificationGui.d.ts +1 -2
  6. package/dist/Gui/SaveLoadGui.d.ts +13 -0
  7. package/dist/Gui/ShopGui.d.ts +28 -3
  8. package/dist/Gui/TitleGui.d.ts +23 -0
  9. package/dist/Gui/index.d.ts +10 -1
  10. package/dist/Player/BattleManager.d.ts +34 -12
  11. package/dist/Player/ClassManager.d.ts +46 -13
  12. package/dist/Player/ComponentManager.d.ts +123 -0
  13. package/dist/Player/Components.d.ts +345 -0
  14. package/dist/Player/EffectManager.d.ts +86 -0
  15. package/dist/Player/ElementManager.d.ts +104 -0
  16. package/dist/Player/GoldManager.d.ts +22 -0
  17. package/dist/Player/GuiManager.d.ts +259 -0
  18. package/dist/Player/ItemFixture.d.ts +6 -0
  19. package/dist/Player/ItemManager.d.ts +450 -9
  20. package/dist/Player/MoveManager.d.ts +324 -69
  21. package/dist/Player/ParameterManager.d.ts +344 -14
  22. package/dist/Player/Player.d.ts +460 -8
  23. package/dist/Player/SkillManager.d.ts +197 -15
  24. package/dist/Player/StateManager.d.ts +89 -25
  25. package/dist/Player/VariableManager.d.ts +74 -0
  26. package/dist/RpgServer.d.ts +502 -64
  27. package/dist/RpgServerEngine.d.ts +2 -1
  28. package/dist/decorators/event.d.ts +46 -0
  29. package/dist/decorators/map.d.ts +287 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.js +21653 -20900
  32. package/dist/index.js.map +1 -1
  33. package/dist/logs/log.d.ts +2 -3
  34. package/dist/module.d.ts +43 -1
  35. package/dist/presets/index.d.ts +0 -9
  36. package/dist/rooms/BaseRoom.d.ts +132 -0
  37. package/dist/rooms/lobby.d.ts +10 -2
  38. package/dist/rooms/map.d.ts +1236 -17
  39. package/dist/services/save.d.ts +43 -0
  40. package/dist/storage/index.d.ts +1 -0
  41. package/dist/storage/localStorage.d.ts +23 -0
  42. package/package.json +14 -10
  43. package/src/Gui/DialogGui.ts +19 -4
  44. package/src/Gui/GameoverGui.ts +39 -0
  45. package/src/Gui/Gui.ts +23 -1
  46. package/src/Gui/MenuGui.ts +155 -6
  47. package/src/Gui/NotificationGui.ts +1 -2
  48. package/src/Gui/SaveLoadGui.ts +60 -0
  49. package/src/Gui/ShopGui.ts +146 -16
  50. package/src/Gui/TitleGui.ts +39 -0
  51. package/src/Gui/index.ts +15 -2
  52. package/src/Player/BattleManager.ts +91 -49
  53. package/src/Player/ClassManager.ts +118 -50
  54. package/src/Player/ComponentManager.ts +425 -19
  55. package/src/Player/Components.ts +380 -0
  56. package/src/Player/EffectManager.ts +81 -44
  57. package/src/Player/ElementManager.ts +109 -86
  58. package/src/Player/GoldManager.ts +32 -35
  59. package/src/Player/GuiManager.ts +308 -150
  60. package/src/Player/ItemFixture.ts +4 -5
  61. package/src/Player/ItemManager.ts +774 -355
  62. package/src/Player/MoveManager.ts +1544 -774
  63. package/src/Player/ParameterManager.ts +546 -104
  64. package/src/Player/Player.ts +1163 -88
  65. package/src/Player/SkillManager.ts +520 -195
  66. package/src/Player/StateManager.ts +170 -182
  67. package/src/Player/VariableManager.ts +101 -63
  68. package/src/RpgServer.ts +525 -63
  69. package/src/core/context.ts +1 -0
  70. package/src/decorators/event.ts +61 -0
  71. package/src/decorators/map.ts +327 -0
  72. package/src/index.ts +11 -1
  73. package/src/logs/log.ts +10 -3
  74. package/src/module.ts +126 -3
  75. package/src/presets/index.ts +1 -10
  76. package/src/rooms/BaseRoom.ts +232 -0
  77. package/src/rooms/lobby.ts +25 -7
  78. package/src/rooms/map.ts +2502 -194
  79. package/src/services/save.ts +147 -0
  80. package/src/storage/index.ts +1 -0
  81. package/src/storage/localStorage.ts +76 -0
  82. package/tests/battle.spec.ts +375 -0
  83. package/tests/change-map.spec.ts +72 -0
  84. package/tests/class.spec.ts +274 -0
  85. package/tests/effect.spec.ts +219 -0
  86. package/tests/element.spec.ts +221 -0
  87. package/tests/event.spec.ts +80 -0
  88. package/tests/gold.spec.ts +99 -0
  89. package/tests/item.spec.ts +609 -0
  90. package/tests/module.spec.ts +38 -0
  91. package/tests/move.spec.ts +601 -0
  92. package/tests/player-param.spec.ts +28 -0
  93. package/tests/prediction-reconciliation.spec.ts +182 -0
  94. package/tests/random-move.spec.ts +65 -0
  95. package/tests/skill.spec.ts +658 -0
  96. package/tests/state.spec.ts +467 -0
  97. package/tests/variable.spec.ts +185 -0
  98. package/tests/world-maps.spec.ts +896 -0
  99. package/vite.config.ts +16 -0
  100. package/dist/Player/Event.d.ts +0 -0
  101. package/src/Player/Event.ts +0 -0
@@ -1,8 +1,8 @@
1
- import { type Constructor } from "@rpgjs/common";
2
- import { RpgCommonPlayer, Matter, Direction } from "@rpgjs/common";
3
- import {
4
- MovementManager,
1
+ import { PlayerCtor, ProjectileType } from "@rpgjs/common";
2
+ import { RpgCommonPlayer, Direction, Entity } from "@rpgjs/common";
3
+ import {
5
4
  MovementStrategy,
5
+ MovementOptions,
6
6
  LinearMove,
7
7
  Dash,
8
8
  Knockback,
@@ -13,15 +13,83 @@ import {
13
13
  LinearRepulsion,
14
14
  IceMovement,
15
15
  ProjectileMovement,
16
- ProjectileType,
17
16
  random,
18
17
  isFunction,
19
- capitalize
18
+ capitalize,
19
+ PerlinNoise2D
20
20
  } from "@rpgjs/common";
21
+ import type { MovementBody } from "@rpgjs/physic";
21
22
  import { RpgMap } from "../rooms/map";
22
- import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from } from 'rxjs';
23
+ import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from, take } from 'rxjs';
23
24
  import { RpgPlayer } from "./Player";
24
25
 
26
+ /**
27
+ * Additive knockback strategy that **adds** an impulse-like velocity on top of the
28
+ * current velocity, instead of overwriting it.
29
+ *
30
+ * This is designed for A-RPG gameplay where the player should keep control during
31
+ * knockback. Inputs keep setting the base velocity, and this strategy adds a decaying
32
+ * impulse each physics step for a short duration.
33
+ *
34
+ * ## Design
35
+ *
36
+ * - The built-in physics `Knockback` strategy overwrites velocity every frame.
37
+ * When inputs also change velocity, the two systems compete and can cause visible jitter.
38
+ * - This strategy avoids the "tug of war" by reading the current velocity and adding
39
+ * the knockback impulse on top.
40
+ * - When finished, it does **not** force velocity to zero, so player input remains responsive.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // Add a short impulse to the right, while player can still steer
45
+ * await player.addMovement(new AdditiveKnockback({ x: 1, y: 0 }, 5, 0.3));
46
+ * ```
47
+ */
48
+ class AdditiveKnockback implements MovementStrategy {
49
+ private readonly direction: { x: number; y: number };
50
+ private elapsed = 0;
51
+ private currentSpeed: number;
52
+
53
+ constructor(
54
+ direction: { x: number; y: number },
55
+ initialSpeed: number,
56
+ private readonly duration: number,
57
+ private readonly decayFactor = 0.35
58
+ ) {
59
+ const magnitude = Math.hypot(direction.x, direction.y);
60
+ this.direction = magnitude > 0
61
+ ? { x: direction.x / magnitude, y: direction.y / magnitude }
62
+ : { x: 1, y: 0 };
63
+ this.currentSpeed = initialSpeed;
64
+ }
65
+
66
+ update(body: MovementBody, dt: number): void {
67
+ this.elapsed += dt;
68
+ if (this.elapsed > this.duration) {
69
+ return;
70
+ }
71
+
72
+ const impulseX = this.direction.x * this.currentSpeed;
73
+ const impulseY = this.direction.y * this.currentSpeed;
74
+
75
+ body.setVelocity({
76
+ x: body.velocity.x + impulseX,
77
+ y: body.velocity.y + impulseY,
78
+ });
79
+
80
+ const decay = Math.max(0, Math.min(1, this.decayFactor));
81
+ if (decay === 0) {
82
+ this.currentSpeed = 0;
83
+ } else if (decay !== 1) {
84
+ this.currentSpeed *= Math.pow(decay, dt);
85
+ }
86
+ }
87
+
88
+ isFinished(): boolean {
89
+ return this.elapsed >= this.duration;
90
+ }
91
+ }
92
+
25
93
 
26
94
  interface PlayerWithMixins extends RpgCommonPlayer {
27
95
  getCurrentMap(): RpgMap;
@@ -34,31 +102,10 @@ interface PlayerWithMixins extends RpgCommonPlayer {
34
102
  changeDirection: (direction: Direction) => boolean;
35
103
  }
36
104
 
37
- export interface IMoveManager {
38
- addMovement(strategy: MovementStrategy): void;
39
- removeMovement(strategy: MovementStrategy): boolean;
40
- clearMovements(): void;
41
- hasActiveMovements(): boolean;
42
- getActiveMovements(): MovementStrategy[];
43
-
44
- moveTo(target: RpgCommonPlayer | { x: number, y: number }): void;
45
- stopMoveTo(): void;
46
- dash(direction: { x: number, y: number }, speed?: number, duration?: number): void;
47
- knockback(direction: { x: number, y: number }, force?: number, duration?: number): void;
48
- followPath(waypoints: Array<{ x: number, y: number }>, speed?: number, loop?: boolean): void;
49
- oscillate(direction: { x: number, y: number }, amplitude?: number, period?: number): void;
50
- applyIceMovement(direction: { x: number, y: number }, maxSpeed?: number): void;
51
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed?: number): void;
52
- moveRoutes(routes: Routes): Promise<boolean>;
53
- infiniteMoveRoute(routes: Routes): void;
54
- breakRoutes(force?: boolean): void;
55
- replayRoutes(): void;
56
- }
57
-
58
105
 
59
106
  function wait(sec: number) {
60
107
  return new Promise((resolve) => {
61
- setTimeout(resolve, sec * 1000)
108
+ setTimeout(resolve, sec * 1000)
62
109
  })
63
110
  }
64
111
 
@@ -66,6 +113,51 @@ type CallbackTileMove = (player: RpgPlayer, map) => Direction[]
66
113
  type CallbackTurnMove = (player: RpgPlayer, map) => string
67
114
  type Routes = (string | Promise<any> | Direction | Direction[] | Function)[]
68
115
 
116
+ // Re-export MovementOptions from @rpgjs/common for convenience
117
+ export type { MovementOptions };
118
+
119
+ /**
120
+ * Options for moveRoutes method
121
+ */
122
+ export interface MoveRoutesOptions {
123
+ /**
124
+ * Callback function called when the player gets stuck (cannot move towards target)
125
+ *
126
+ * This callback is triggered when the player is trying to move but cannot make progress
127
+ * towards the target position, typically due to obstacles or collisions.
128
+ *
129
+ * @param player - The player instance that is stuck
130
+ * @param target - The target position the player was trying to reach
131
+ * @param currentPosition - The current position of the player
132
+ * @returns If true, the route will continue; if false, the route will be cancelled
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * await player.moveRoutes([Move.right()], {
137
+ * onStuck: (player, target, currentPos) => {
138
+ * console.log('Player is stuck!');
139
+ * return false; // Cancel the route
140
+ * }
141
+ * });
142
+ * ```
143
+ */
144
+ onStuck?: (player: RpgPlayer, target: { x: number; y: number }, currentPosition: { x: number; y: number }) => boolean | void;
145
+
146
+ /**
147
+ * Time in milliseconds to wait before considering the player stuck (default: 500ms)
148
+ *
149
+ * The player must be unable to make progress for this duration before onStuck is called.
150
+ */
151
+ stuckTimeout?: number;
152
+
153
+ /**
154
+ * Minimum distance change in pixels to consider movement progress (default: 1 pixel)
155
+ *
156
+ * If the player moves less than this distance over the stuckTimeout period, they are considered stuck.
157
+ */
158
+ stuckThreshold?: number;
159
+ }
160
+
69
161
  export enum Frequency {
70
162
  Lowest = 600,
71
163
  Lower = 400,
@@ -113,300 +205,317 @@ export enum Speed {
113
205
  * Move.turnTowardPlayer(player) | Turns in the direction of the designated player
114
206
  * @memberof Move
115
207
  * */
208
+ /**
209
+ * Tracks player state for stuck detection in random movement
210
+ *
211
+ * Stores the last known position and direction for each player to detect
212
+ * when they are stuck (position hasn't changed) and need a different direction.
213
+ */
214
+ interface PlayerMoveState {
215
+ lastX: number;
216
+ lastY: number;
217
+ lastDirection: number;
218
+ stuckCount: number;
219
+ }
220
+
116
221
  class MoveList {
222
+ // Shared Perlin noise instance for smooth random movement
223
+ private static perlinNoise: PerlinNoise2D = new PerlinNoise2D();
224
+ private static randomCounter: number = 0;
225
+ // Instance counter for each call to ensure variation
226
+ private static callCounter: number = 0;
227
+ // Track player positions and directions to detect stuck state
228
+ private static playerMoveStates: Map<string, PlayerMoveState> = new Map();
229
+ // Threshold for considering a player as "stuck" (in pixels)
230
+ private static readonly STUCK_THRESHOLD = 2;
231
+
232
+
233
+ /**
234
+ * Clears the movement state for a specific player
235
+ *
236
+ * Should be called when a player changes map or is destroyed to prevent
237
+ * memory leaks and stale stuck detection data.
238
+ *
239
+ * @param playerId - The ID of the player to clear state for
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * // Clear state when player leaves map
244
+ * Move.clearPlayerState(player.id);
245
+ * ```
246
+ */
247
+ static clearPlayerState(playerId: string): void {
248
+ MoveList.playerMoveStates.delete(playerId);
249
+ }
250
+
251
+ /**
252
+ * Clears all player movement states
253
+ *
254
+ * Useful for cleanup during server shutdown or when resetting game state.
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * // Clear all states on server shutdown
259
+ * Move.clearAllPlayerStates();
260
+ * ```
261
+ */
262
+ static clearAllPlayerStates(): void {
263
+ MoveList.playerMoveStates.clear();
264
+ }
117
265
 
118
266
  repeatMove(direction: Direction, repeat: number): Direction[] {
119
- // Safety check for valid repeat value
120
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 10000) {
121
- console.warn('Invalid repeat value:', repeat, 'using default value 1');
122
- repeat = 1;
267
+ // Safety check for valid repeat value
268
+ if (!Number.isFinite(repeat) || repeat < 0 || repeat > 10000) {
269
+ console.warn('Invalid repeat value:', repeat, 'using default value 1');
270
+ repeat = 1;
271
+ }
272
+
273
+ // Ensure repeat is an integer
274
+ repeat = Math.floor(repeat);
275
+
276
+ // Additional safety check - ensure repeat is a safe integer
277
+ if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
278
+ console.warn('Unsafe repeat value:', repeat, 'using default value 1');
279
+ repeat = 1;
280
+ }
281
+
282
+ try {
283
+ return new Array(repeat).fill(direction);
284
+ } catch (error) {
285
+ console.error('Error creating array with repeat:', repeat, error);
286
+ return [direction]; // Return single direction as fallback
287
+ }
288
+ }
289
+
290
+ private repeatTileMove(direction: string, repeat: number, propMap: string): CallbackTileMove {
291
+ return (player: RpgPlayer, map): Direction[] => {
292
+ const playerSpeed = typeof player.speed === 'function' ? player.speed() : player.speed;
293
+
294
+ // Safety checks
295
+ if (!playerSpeed || playerSpeed <= 0) {
296
+ console.warn('Invalid player speed:', playerSpeed, 'using default speed 3');
297
+ return this[direction](repeat);
123
298
  }
124
-
125
- // Ensure repeat is an integer
126
- repeat = Math.floor(repeat);
127
-
128
- // Additional safety check - ensure repeat is a safe integer
129
- if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
130
- console.warn('Unsafe repeat value:', repeat, 'using default value 1');
131
- repeat = 1;
299
+
300
+ const repeatTile = Math.floor((map[propMap] || 32) / playerSpeed) * repeat;
301
+
302
+ // Additional safety check for the calculated repeat value
303
+ if (!Number.isFinite(repeatTile) || repeatTile < 0 || repeatTile > 10000) {
304
+ console.warn('Calculated repeatTile is invalid:', repeatTile, 'using original repeat:', repeat);
305
+ return this[direction](repeat);
132
306
  }
133
-
134
- try {
135
- return new Array(repeat).fill(direction);
136
- } catch (error) {
137
- console.error('Error creating array with repeat:', repeat, error);
138
- return [direction]; // Return single direction as fallback
307
+
308
+ // Final safety check before calling the method
309
+ if (!Number.isSafeInteger(repeatTile)) {
310
+ console.warn('repeatTile is not a safe integer:', repeatTile, 'using original repeat:', repeat);
311
+ return this[direction](repeat);
139
312
  }
140
- }
141
313
 
142
- private repeatTileMove(direction: string, repeat: number, propMap: string): CallbackTileMove {
143
- return (player: RpgPlayer, map): Direction[] => {
144
- const playerSpeed = typeof player.speed === 'function' ? player.speed() : player.speed;
145
-
146
- // Safety checks
147
- if (!playerSpeed || playerSpeed <= 0) {
148
- console.warn('Invalid player speed:', playerSpeed, 'using default speed 3');
149
- return this[direction](repeat);
150
- }
151
-
152
- const repeatTile = Math.floor((map[propMap] || 32) / playerSpeed) * repeat;
153
-
154
- // Additional safety check for the calculated repeat value
155
- if (!Number.isFinite(repeatTile) || repeatTile < 0 || repeatTile > 10000) {
156
- console.warn('Calculated repeatTile is invalid:', repeatTile, 'using original repeat:', repeat);
157
- return this[direction](repeat);
158
- }
159
-
160
- // Final safety check before calling the method
161
- if (!Number.isSafeInteger(repeatTile)) {
162
- console.warn('repeatTile is not a safe integer:', repeatTile, 'using original repeat:', repeat);
163
- return this[direction](repeat);
164
- }
165
-
166
- try {
167
- return this[direction](repeatTile);
168
- } catch (error) {
169
- console.error('Error calling direction method with repeatTile:', repeatTile, error);
170
- return this[direction](repeat); // Fallback to original repeat
171
- }
314
+ try {
315
+ return this[direction](repeatTile);
316
+ } catch (error) {
317
+ console.error('Error calling direction method with repeatTile:', repeatTile, error);
318
+ return this[direction](repeat); // Fallback to original repeat
172
319
  }
320
+ }
173
321
  }
174
322
 
175
323
  right(repeat: number = 1): Direction[] {
176
- return this.repeatMove(Direction.Right, repeat)
324
+ return this.repeatMove(Direction.Right, repeat)
177
325
  }
178
326
 
179
327
  left(repeat: number = 1): Direction[] {
180
- return this.repeatMove(Direction.Left, repeat)
328
+ return this.repeatMove(Direction.Left, repeat)
181
329
  }
182
330
 
183
331
  up(repeat: number = 1): Direction[] {
184
- return this.repeatMove(Direction.Up, repeat)
332
+ return this.repeatMove(Direction.Up, repeat)
185
333
  }
186
334
 
187
335
  down(repeat: number = 1): Direction[] {
188
- return this.repeatMove(Direction.Down, repeat)
336
+ return this.repeatMove(Direction.Down, repeat)
189
337
  }
190
338
 
191
339
  wait(sec: number): Promise<unknown> {
192
- return wait(sec)
340
+ return wait(sec)
193
341
  }
194
342
 
195
343
  random(repeat: number = 1): Direction[] {
196
- // Safety check for valid repeat value
197
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 10000) {
198
- console.warn('Invalid repeat value in random:', repeat, 'using default value 1');
199
- repeat = 1;
200
- }
201
-
202
- // Ensure repeat is an integer
203
- repeat = Math.floor(repeat);
204
-
205
- // Additional safety check - ensure repeat is a safe integer
206
- if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
207
- console.warn('Unsafe repeat value in random:', repeat, 'using default value 1');
208
- repeat = 1;
209
- }
210
-
211
- try {
212
- return new Array(repeat).fill(null).map(() => [
213
- Direction.Right,
214
- Direction.Left,
215
- Direction.Up,
216
- Direction.Down
217
- ][random(0, 3)]);
218
- } catch (error) {
219
- console.error('Error creating random array with repeat:', repeat, error);
220
- return [Direction.Down]; // Return single direction as fallback
221
- }
222
- }
344
+ return new Array(repeat).fill(null).map(() => [
345
+ Direction.Right,
346
+ Direction.Left,
347
+ Direction.Up,
348
+ Direction.Down
349
+ ][random(0, 3)])
350
+ }
223
351
 
224
352
  tileRight(repeat: number = 1): CallbackTileMove {
225
- return this.repeatTileMove('right', repeat, 'tileWidth')
353
+ return this.repeatTileMove('right', repeat, 'tileWidth')
226
354
  }
227
355
 
228
356
  tileLeft(repeat: number = 1): CallbackTileMove {
229
- return this.repeatTileMove('left', repeat, 'tileWidth')
357
+ return this.repeatTileMove('left', repeat, 'tileWidth')
230
358
  }
231
359
 
232
360
  tileUp(repeat: number = 1): CallbackTileMove {
233
- return this.repeatTileMove('up', repeat, 'tileHeight')
361
+ return this.repeatTileMove('up', repeat, 'tileHeight')
234
362
  }
235
363
 
236
364
  tileDown(repeat: number = 1): CallbackTileMove {
237
- return this.repeatTileMove('down', repeat, 'tileHeight')
365
+ return this.repeatTileMove('down', repeat, 'tileHeight')
238
366
  }
239
367
 
240
368
  tileRandom(repeat: number = 1): CallbackTileMove {
241
- return (player: RpgPlayer, map): Direction[] => {
242
- // Safety check for valid repeat value
243
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1000) {
244
- console.warn('Invalid repeat value in tileRandom:', repeat, 'using default value 1');
245
- repeat = 1;
246
- }
247
-
248
- // Ensure repeat is an integer
249
- repeat = Math.floor(repeat);
250
-
251
- let directions: Direction[] = []
252
- for (let i = 0; i < repeat; i++) {
253
- const randFn: CallbackTileMove = [
254
- this.tileRight(),
255
- this.tileLeft(),
256
- this.tileUp(),
257
- this.tileDown()
258
- ][random(0, 3)]
259
-
260
- try {
261
- const newDirections = randFn(player, map);
262
- if (Array.isArray(newDirections)) {
263
- directions = [...directions, ...newDirections];
264
- }
265
- } catch (error) {
266
- console.warn('Error in tileRandom iteration:', error);
267
- // Continue with next iteration instead of breaking
268
- }
269
-
270
- // Safety check to prevent excessive array growth
271
- if (directions.length > 10000) {
272
- console.warn('tileRandom generated too many directions, truncating');
273
- break;
274
- }
275
- }
276
- return directions
277
- }
278
- }
369
+ return (player: RpgPlayer, map): Direction[] => {
370
+ let directions: Direction[] = []
371
+ for (let i = 0; i < repeat; i++) {
372
+ const randFn: CallbackTileMove = [
373
+ this.tileRight(),
374
+ this.tileLeft(),
375
+ this.tileUp(),
376
+ this.tileDown()
377
+ ][random(0, 3)]
378
+ directions = [
379
+ ...directions,
380
+ ...randFn(player, map)
381
+ ]
382
+ }
383
+ return directions
384
+ }
385
+ }
279
386
 
280
387
  private _awayFromPlayerDirection(player: RpgPlayer, otherPlayer: RpgPlayer): Direction {
281
- const directionOtherPlayer = otherPlayer.getDirection()
282
- let newDirection: Direction = Direction.Down
388
+ const directionOtherPlayer = otherPlayer.getDirection()
389
+ let newDirection: Direction = Direction.Down
283
390
 
284
- switch (directionOtherPlayer) {
285
- case Direction.Left:
286
- case Direction.Right:
287
- if (otherPlayer.x() > player.x()) {
288
- newDirection = Direction.Left
289
- }
290
- else {
291
- newDirection = Direction.Right
292
- }
293
- break
294
- case Direction.Up:
295
- case Direction.Down:
296
- if (otherPlayer.y() > player.y()) {
297
- newDirection = Direction.Up
298
- }
299
- else {
300
- newDirection = Direction.Down
301
- }
302
- break
303
- }
304
- return newDirection
391
+ switch (directionOtherPlayer) {
392
+ case Direction.Left:
393
+ case Direction.Right:
394
+ if (otherPlayer.x() > player.x()) {
395
+ newDirection = Direction.Left
396
+ }
397
+ else {
398
+ newDirection = Direction.Right
399
+ }
400
+ break
401
+ case Direction.Up:
402
+ case Direction.Down:
403
+ if (otherPlayer.y() > player.y()) {
404
+ newDirection = Direction.Up
405
+ }
406
+ else {
407
+ newDirection = Direction.Down
408
+ }
409
+ break
410
+ }
411
+ return newDirection
305
412
  }
306
413
 
307
414
  private _towardPlayerDirection(player: RpgPlayer, otherPlayer: RpgPlayer): Direction {
308
- const directionOtherPlayer = otherPlayer.getDirection()
309
- let newDirection: Direction = Direction.Down
310
-
311
- switch (directionOtherPlayer) {
312
- case Direction.Left:
313
- case Direction.Right:
314
- if (otherPlayer.x() > player.x()) {
315
- newDirection = Direction.Right
316
- }
317
- else {
318
- newDirection = Direction.Left
319
- }
320
- break
321
- case Direction.Up:
322
- case Direction.Down:
323
- if (otherPlayer.y() > player.y()) {
324
- newDirection = Direction.Down
325
- }
326
- else {
327
- newDirection = Direction.Up
328
- }
329
- break
330
- }
331
- return newDirection
415
+ const directionOtherPlayer = otherPlayer.getDirection()
416
+ let newDirection: Direction = Direction.Down
417
+
418
+ switch (directionOtherPlayer) {
419
+ case Direction.Left:
420
+ case Direction.Right:
421
+ if (otherPlayer.x() > player.x()) {
422
+ newDirection = Direction.Right
423
+ }
424
+ else {
425
+ newDirection = Direction.Left
426
+ }
427
+ break
428
+ case Direction.Up:
429
+ case Direction.Down:
430
+ if (otherPlayer.y() > player.y()) {
431
+ newDirection = Direction.Down
432
+ }
433
+ else {
434
+ newDirection = Direction.Up
435
+ }
436
+ break
437
+ }
438
+ return newDirection
332
439
  }
333
440
 
334
441
  private _awayFromPlayer({ isTile, typeMov }: { isTile: boolean, typeMov: string }, otherPlayer: RpgPlayer, repeat: number = 1) {
335
- const method = (dir: Direction) => {
336
- const direction: string = DirectionNames[dir as any] || 'down'
337
- return this[isTile ? 'tile' + capitalize(direction) : direction](repeat)
442
+ const method = (dir: Direction) => {
443
+ const direction: string = DirectionNames[dir as any] || 'down'
444
+ return this[isTile ? 'tile' + capitalize(direction) : direction](repeat)
445
+ }
446
+ return (player: RpgPlayer, map) => {
447
+ let newDirection: Direction = Direction.Down
448
+ switch (typeMov) {
449
+ case 'away':
450
+ newDirection = this._awayFromPlayerDirection(player, otherPlayer)
451
+ break;
452
+ case 'toward':
453
+ newDirection = this._towardPlayerDirection(player, otherPlayer)
454
+ break
338
455
  }
339
- return (player: RpgPlayer, map) => {
340
- let newDirection: Direction = Direction.Down
341
- switch (typeMov) {
342
- case 'away':
343
- newDirection = this._awayFromPlayerDirection(player, otherPlayer)
344
- break;
345
- case 'toward':
346
- newDirection = this._towardPlayerDirection(player, otherPlayer)
347
- break
348
- }
349
- let direction: any = method(newDirection)
350
- if (isFunction(direction)) {
351
- direction = direction(player, map)
352
- }
353
- return direction
456
+ let direction: any = method(newDirection)
457
+ if (isFunction(direction)) {
458
+ direction = direction(player, map)
354
459
  }
460
+ return direction
461
+ }
355
462
  }
356
463
 
357
464
  towardPlayer(player: RpgPlayer, repeat: number = 1) {
358
- return this._awayFromPlayer({ isTile: false, typeMov: 'toward' }, player, repeat)
465
+ return this._awayFromPlayer({ isTile: false, typeMov: 'toward' }, player, repeat)
359
466
  }
360
467
 
361
468
  tileTowardPlayer(player: RpgPlayer, repeat: number = 1) {
362
- return this._awayFromPlayer({ isTile: true, typeMov: 'toward' }, player, repeat)
469
+ return this._awayFromPlayer({ isTile: true, typeMov: 'toward' }, player, repeat)
363
470
  }
364
471
 
365
472
  awayFromPlayer(player: RpgPlayer, repeat: number = 1): CallbackTileMove {
366
- return this._awayFromPlayer({ isTile: false, typeMov: 'away' }, player, repeat)
473
+ return this._awayFromPlayer({ isTile: false, typeMov: 'away' }, player, repeat)
367
474
  }
368
475
 
369
476
  tileAwayFromPlayer(player: RpgPlayer, repeat: number = 1): CallbackTileMove {
370
- return this._awayFromPlayer({ isTile: true, typeMov: 'away' }, player, repeat)
477
+ return this._awayFromPlayer({ isTile: true, typeMov: 'away' }, player, repeat)
371
478
  }
372
479
 
373
480
  turnLeft(): string {
374
- return 'turn-' + Direction.Left
481
+ return 'turn-' + Direction.Left
375
482
  }
376
483
 
377
484
  turnRight(): string {
378
- return 'turn-' + Direction.Right
485
+ return 'turn-' + Direction.Right
379
486
  }
380
487
 
381
488
  turnUp(): string {
382
- return 'turn-' + Direction.Up
489
+ return 'turn-' + Direction.Up
383
490
  }
384
491
 
385
492
  turnDown(): string {
386
- return 'turn-' + Direction.Down
493
+ return 'turn-' + Direction.Down
387
494
  }
388
495
 
389
496
  turnRandom(): string {
390
- return [
391
- this.turnRight(),
392
- this.turnLeft(),
393
- this.turnUp(),
394
- this.turnDown()
395
- ][random(0, 3)]
497
+ // Use Perlin noise for smooth random turn direction with guaranteed variation
498
+ const directionIndex = this.getRandomDirectionIndex();
499
+ return [
500
+ this.turnRight(),
501
+ this.turnLeft(),
502
+ this.turnUp(),
503
+ this.turnDown()
504
+ ][directionIndex]
396
505
  }
397
506
 
398
507
  turnAwayFromPlayer(otherPlayer: RpgPlayer): CallbackTurnMove {
399
- return (player: RpgPlayer) => {
400
- const direction = this._awayFromPlayerDirection(player, otherPlayer)
401
- return 'turn-' + direction
402
- }
508
+ return (player: RpgPlayer) => {
509
+ const direction = this._awayFromPlayerDirection(player, otherPlayer)
510
+ return 'turn-' + direction
511
+ }
403
512
  }
404
513
 
405
514
  turnTowardPlayer(otherPlayer: RpgPlayer): CallbackTurnMove {
406
- return (player: RpgPlayer) => {
407
- const direction = this._towardPlayerDirection(player, otherPlayer)
408
- return 'turn-' + direction
409
- }
515
+ return (player: RpgPlayer) => {
516
+ const direction = this._towardPlayerDirection(player, otherPlayer)
517
+ return 'turn-' + direction
518
+ }
410
519
  }
411
520
  }
412
521
 
@@ -431,7 +540,7 @@ export const Move = new MoveList();
431
540
  * - **Strategy Management**: Add, remove, and query movement strategies
432
541
  * - **Predefined Movements**: Quick access to common movement patterns
433
542
  * - **Composite Movements**: Combine multiple strategies
434
- * - **Physics Integration**: Seamless integration with Matter.js physics
543
+ * - **Physics Integration**: Seamless integration with the deterministic @rpgjs/physic engine
435
544
  *
436
545
  * ## Available Movement Strategies
437
546
  * - `LinearMove`: Constant velocity movement
@@ -475,28 +584,50 @@ export const Move = new MoveList();
475
584
  * }
476
585
  * ```
477
586
  */
478
- export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
479
- Base: TBase
480
- ): Constructor<IMoveManager> & TBase {
481
- return class extends Base implements IMoveManager {
482
-
483
- // Private properties for infinite route management
484
- private _infiniteRoutes: Routes | null = null;
485
- private _finishRoute: ((value: boolean) => void) | null = null;
486
- private _isInfiniteRouteActive: boolean = false;
487
-
488
- /**
489
- * The player passes through the other players (or vice versa). But the player does not go through the events.
490
- *
491
- * ```ts
492
- * player.throughOtherPlayer = true
493
- * ```
494
- *
495
- * @title Go through to other player
496
- * @prop {boolean} player.throughOtherPlayer
497
- * @default true
498
- * @memberof MoveManager
499
- * */
587
+ /**
588
+ * Move Manager Mixin
589
+ *
590
+ * Provides comprehensive movement management capabilities to any class. This mixin handles
591
+ * various types of movement including pathfinding, physics-based movement, route following,
592
+ * and advanced movement strategies like dashing, knockback, and projectile movement.
593
+ *
594
+ * @param Base - The base class to extend with movement management
595
+ * @returns Extended class with movement management methods
596
+ *
597
+ * @example
598
+ * ```ts
599
+ * class MyPlayer extends WithMoveManager(BasePlayer) {
600
+ * constructor() {
601
+ * super();
602
+ * this.frequency = Frequency.High;
603
+ * }
604
+ * }
605
+ *
606
+ * const player = new MyPlayer();
607
+ * player.moveTo({ x: 100, y: 100 });
608
+ * player.dash({ x: 1, y: 0 }, 8, 200);
609
+ * ```
610
+ */
611
+ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
612
+ const baseProto = Base.prototype as any;
613
+ class WithMoveManagerClass extends Base {
614
+ setAnimation(animationName: string, nbTimes: number): void {
615
+ if (typeof baseProto.setAnimation === 'function') {
616
+ baseProto.setAnimation.call(this, animationName, nbTimes);
617
+ }
618
+ }
619
+
620
+ showComponentAnimation(id: string, params: any): void {
621
+ if (typeof baseProto.showComponentAnimation === 'function') {
622
+ baseProto.showComponentAnimation.call(this, id, params);
623
+ }
624
+ }
625
+
626
+ // Properties for infinite route management
627
+ _infiniteRoutes: Routes | null = null;
628
+ _finishRoute: ((value: boolean) => void) | null = null;
629
+ _isInfiniteRouteActive: boolean = false;
630
+
500
631
  set throughOtherPlayer(value: boolean) {
501
632
  this._throughOtherPlayer.set(value);
502
633
  }
@@ -505,55 +636,22 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
505
636
  return this._throughOtherPlayer();
506
637
  }
507
638
 
508
- /**
509
- * The player goes through the event or the other players (or vice versa)
510
- *
511
- * ```ts
512
- * player.through = true
513
- * ```
514
- *
515
- * @title Go through the player
516
- * @prop {boolean} player.through
517
- * @default false
518
- * @memberof MoveManager
519
- * */
520
639
  set through(value: boolean) {
521
640
  this._through.set(value);
522
641
  }
523
-
642
+
524
643
  get through(): boolean {
525
644
  return this._through();
526
645
  }
527
646
 
528
- /**
529
- * The frequency allows to put a stop time between each movement in the array of the moveRoutes() method.
530
- * The value represents a dwell time in milliseconds. The higher the value, the slower the frequency.
531
- *
532
- * ```ts
533
- * player.frequency = 400
534
- * ```
535
- *
536
- * You can use Frequency enum
537
- *
538
- * ```ts
539
- * import { Frequency } from '@rpgjs/server'
540
- * player.frequency = Frequency.Low
541
- * ```
542
- *
543
- * @title Change Frequency
544
- * @prop {number} player.speed
545
- * @enum {number}
546
- *
547
- * Frequency.Lowest | 600
548
- * Frequency.Lower | 400
549
- * Frequency.Low | 200
550
- * Frequency.High | 100
551
- * Frequency.Higher | 50
552
- * Frequency.Highest | 25
553
- * Frequency.None | 0
554
- * @default 0
555
- * @memberof MoveManager
556
- * */
647
+ set throughEvent(value: boolean) {
648
+ this._throughEvent.set(value);
649
+ }
650
+
651
+ get throughEvent(): boolean {
652
+ return this._throughEvent();
653
+ }
654
+
557
655
  set frequency(value: number) {
558
656
  this._frequency.set(value);
559
657
  }
@@ -561,296 +659,419 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
561
659
  get frequency(): number {
562
660
  return this._frequency();
563
661
  }
564
-
662
+
663
+ set directionFixed(value: boolean) {
664
+ (this as any)._directionFixed.set(value);
665
+ }
666
+
667
+ get directionFixed(): boolean {
668
+ return (this as any)._directionFixed();
669
+ }
670
+
671
+ set animationFixed(value: boolean) {
672
+ (this as any)._animationFixed.set(value);
673
+ }
674
+
675
+ get animationFixed(): boolean {
676
+ return (this as any)._animationFixed();
677
+ }
678
+
565
679
  /**
566
- * Add a custom movement strategy to this entity
680
+ * Add a movement strategy to this entity
567
681
  *
568
- * Allows adding any custom MovementStrategy implementation.
569
- * Multiple strategies can be active simultaneously.
682
+ * Returns a Promise that resolves when the movement completes (when `isFinished()` returns true).
683
+ * If the strategy doesn't implement `isFinished()`, the Promise resolves immediately.
570
684
  *
571
685
  * @param strategy - The movement strategy to add
686
+ * @param options - Optional callbacks for start and completion events
687
+ * @returns Promise that resolves when the movement completes
572
688
  *
573
689
  * @example
574
690
  * ```ts
575
- * // Add custom movement
576
- * const customMove = new LinearMove(5, 0, 1000);
577
- * player.addMovement(customMove);
578
- *
579
- * // Add multiple movements
580
- * player.addMovement(new Dash(8, { x: 1, y: 0 }, 200));
581
- * player.addMovement(new Oscillate({ x: 0, y: 1 }, 10, 1000));
691
+ * // Fire and forget
692
+ * player.addMovement(new LinearMove({ x: 1, y: 0 }, 200));
693
+ *
694
+ * // Wait for completion
695
+ * await player.addMovement(new Dash(10, { x: 1, y: 0 }, 200));
696
+ * console.log('Dash completed!');
697
+ *
698
+ * // With callbacks
699
+ * await player.addMovement(new Knockback({ x: -1, y: 0 }, 5, 300), {
700
+ * onStart: () => console.log('Knockback started'),
701
+ * onComplete: () => console.log('Knockback completed')
702
+ * });
582
703
  * ```
583
704
  */
584
- addMovement(strategy: MovementStrategy): void {
585
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
586
- if (!map) return;
587
-
588
- map.moveManager.add((this as unknown as PlayerWithMixins).id, strategy);
705
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void> {
706
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
707
+ if (!map) return Promise.resolve();
708
+
709
+ const playerId = (this as unknown as PlayerWithMixins).id;
710
+ return map.moveManager.add(playerId, strategy, options);
589
711
  }
590
712
 
591
- /**
592
- * Remove a specific movement strategy from this entity
593
- *
594
- * @param strategy - The strategy instance to remove
595
- * @returns True if the strategy was found and removed
596
- *
597
- * @example
598
- * ```ts
599
- * const dashMove = new Dash(8, { x: 1, y: 0 }, 200);
600
- * player.addMovement(dashMove);
601
- *
602
- * // Later, remove the specific movement
603
- * const removed = player.removeMovement(dashMove);
604
- * console.log('Movement removed:', removed);
605
- * ```
606
- */
607
713
  removeMovement(strategy: MovementStrategy): boolean {
608
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
714
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
609
715
  if (!map) return false;
610
-
611
- return map.moveManager.remove((this as unknown as PlayerWithMixins).id, strategy);
716
+
717
+ const playerId = (this as unknown as PlayerWithMixins).id;
718
+ return map.moveManager.remove(playerId, strategy);
612
719
  }
613
720
 
614
- /**
615
- * Remove all active movement strategies from this entity
616
- *
617
- * Stops all current movements immediately.
618
- *
619
- * @example
620
- * ```ts
621
- * // Stop all movements when player dies
622
- * player.clearMovements();
623
- *
624
- * // Clear movements before applying new ones
625
- * player.clearMovements();
626
- * player.dash({ x: 1, y: 0 });
627
- * ```
628
- */
629
721
  clearMovements(): void {
630
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
722
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
631
723
  if (!map) return;
632
-
633
- map.moveManager.clear((this as unknown as PlayerWithMixins).id);
724
+
725
+ const playerId = (this as unknown as PlayerWithMixins).id;
726
+ map.moveManager.clear(playerId);
634
727
  }
635
728
 
636
- /**
637
- * Check if this entity has any active movement strategies
638
- *
639
- * @returns True if entity has active movements
640
- *
641
- * @example
642
- * ```ts
643
- * // Don't accept input while movements are active
644
- * if (!player.hasActiveMovements()) {
645
- * player.dash(inputDirection);
646
- * }
647
- *
648
- * // Check before adding new movement
649
- * if (player.hasActiveMovements()) {
650
- * player.clearMovements();
651
- * }
652
- * ```
653
- */
654
729
  hasActiveMovements(): boolean {
655
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
730
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
656
731
  if (!map) return false;
657
-
732
+
658
733
  return map.moveManager.hasActiveStrategies((this as unknown as PlayerWithMixins).id);
659
734
  }
660
735
 
661
- /**
662
- * Get all active movement strategies for this entity
663
- *
664
- * @returns Array of active movement strategies
665
- *
666
- * @example
667
- * ```ts
668
- * // Check what movements are currently active
669
- * const movements = player.getActiveMovements();
670
- * console.log(`Player has ${movements.length} active movements`);
671
- *
672
- * // Find specific movement type
673
- * const hasDash = movements.some(m => m instanceof Dash);
674
- * ```
675
- */
676
736
  getActiveMovements(): MovementStrategy[] {
677
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
737
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
678
738
  if (!map) return [];
679
-
739
+
680
740
  return map.moveManager.getStrategies((this as unknown as PlayerWithMixins).id);
681
741
  }
682
742
 
683
743
  /**
684
744
  * Move toward a target player or position using AI pathfinding
685
745
  *
686
- * Uses SeekAvoid strategy for intelligent pathfinding with obstacle avoidance.
687
- * The entity will seek toward the target while avoiding obstacles.
746
+ * Uses the `SeekAvoid` strategy to navigate toward the target while avoiding obstacles.
747
+ * The movement speed is based on the player's current `speed` and `frequency` settings,
748
+ * scaled appropriately.
688
749
  *
689
- * @param target - Target player or position to move toward
750
+ * @param target - Target player or position `{ x, y }` to move toward
690
751
  *
691
752
  * @example
692
753
  * ```ts
693
754
  * // Move toward another player
694
- * const targetPlayer = game.getPlayer('player2');
695
- * player.moveTo(targetPlayer);
755
+ * player.moveTo(otherPlayer);
696
756
  *
697
757
  * // Move toward a specific position
698
- * player.moveTo({ x: 300, y: 200 });
699
- *
700
- * // Stop the movement later
701
- * player.stopMoveTo();
758
+ * player.moveTo({ x: 200, y: 150 });
702
759
  * ```
703
760
  */
704
761
  moveTo(target: RpgCommonPlayer | { x: number, y: number }): void {
705
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
762
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
706
763
  if (!map) return;
764
+
765
+ const playerId = (this as unknown as PlayerWithMixins).id;
766
+ const engine = map.physic;
767
+
768
+ // Calculate maxSpeed based on player's speed and frequency
769
+ // Original values: 180 for player target, 80 for position target (with default speed=4)
770
+ // Factor: 45 for player (180/4), 20 for position (80/4)
771
+ const playerSpeed = (this as any).speed();
772
+ const rawFrequency = (this as any).frequency;
773
+ const playerFrequency = typeof rawFrequency === 'function' ? rawFrequency() : rawFrequency;
774
+ const frequencyScale = playerFrequency > 0 ? Frequency.High / playerFrequency : 1;
775
+ const normalizedFrequencyScale = Number.isFinite(frequencyScale) && frequencyScale > 0 ? frequencyScale : 1;
776
+
777
+ // Remove ALL movement strategies that could interfere with SeekAvoid
778
+ // This includes SeekAvoid, Dash, Knockback, and LinearRepulsion
779
+ const existingStrategies = this.getActiveMovements();
780
+ const conflictingStrategies = existingStrategies.filter(s =>
781
+ s instanceof SeekAvoid ||
782
+ s instanceof Dash ||
783
+ s instanceof Knockback ||
784
+ s instanceof LinearRepulsion
785
+ );
707
786
 
708
- let targetBody: Matter.Body | null = null;
709
-
787
+ if (conflictingStrategies.length > 0) {
788
+ conflictingStrategies.forEach(s => this.removeMovement(s));
789
+ }
790
+
710
791
  if ('id' in target) {
711
- // Target is a player
712
- targetBody = map.physic.getBody(target.id);
713
- } else {
714
- // Target is a position - create a temporary target function
715
- const getTargetPos = () => Matter.Vector.create(target.x, target.y);
792
+ const targetProvider = () => {
793
+ const body = (map as any).getBody(target.id) ?? null;
794
+ return body;
795
+ };
796
+ // Factor 45: with speed=4 gives 180 (original value)
797
+ const maxSpeed = playerSpeed * 45 * normalizedFrequencyScale;
716
798
  map.moveManager.add(
717
- (this as unknown as PlayerWithMixins).id,
718
- new SeekAvoid(map.physic, getTargetPos, 3, 50, 5)
799
+ playerId,
800
+ new SeekAvoid(engine, targetProvider, maxSpeed, 140, 80, 48)
719
801
  );
720
802
  return;
721
803
  }
722
-
723
- if (targetBody) {
724
- map.moveManager.add(
725
- (this as unknown as PlayerWithMixins).id,
726
- new SeekAvoid(map.physic, targetBody, 3, 50, 5)
727
- );
728
- }
804
+
805
+ const staticTarget = new Entity({
806
+ position: { x: target.x, y: target.y },
807
+ mass: Infinity,
808
+ });
809
+ staticTarget.freeze();
810
+
811
+ // Factor 20: with speed=4 gives 80 (original value)
812
+ const maxSpeed = playerSpeed * 20 * normalizedFrequencyScale;
813
+ map.moveManager.add(
814
+ playerId,
815
+ new SeekAvoid(engine, () => staticTarget, maxSpeed, 140, 80, 48)
816
+ );
729
817
  }
730
818
 
731
- /**
732
- * Stop the current moveTo behavior
733
- *
734
- * Removes any active SeekAvoid strategies.
735
- *
736
- * @example
737
- * ```ts
738
- * // Start following a target
739
- * player.moveTo(targetPlayer);
740
- *
741
- * // Stop following when target is reached
742
- * if (distanceToTarget < 10) {
743
- * player.stopMoveTo();
744
- * }
745
- * ```
746
- */
747
819
  stopMoveTo(): void {
748
- const map = (this as unknown as PlayerWithMixins).getCurrentMap();
820
+ const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
749
821
  if (!map) return;
822
+
823
+ let strategies: MovementStrategy[] = [];
824
+ try {
825
+ strategies = this.getActiveMovements();
826
+ }
827
+ catch (error) {
828
+ // Teardown race: entity can be removed while AI still clears movements.
829
+ const message = (error as Error | undefined)?.message ?? "";
830
+ if (message.includes("unable to resolve entity")) {
831
+ return;
832
+ }
833
+ throw error;
834
+ }
835
+ const toRemove = strategies.filter(s => s instanceof SeekAvoid || s instanceof LinearRepulsion);
750
836
 
751
- const strategies = this.getActiveMovements();
752
- strategies.forEach(strategy => {
753
- if (strategy instanceof SeekAvoid || strategy instanceof LinearRepulsion) {
837
+ if (toRemove.length > 0) {
838
+ toRemove.forEach(strategy => {
754
839
  this.removeMovement(strategy);
755
- }
756
- });
840
+ });
841
+ }
757
842
  }
758
843
 
759
844
  /**
760
845
  * Perform a dash movement in the specified direction
761
846
  *
762
- * Applies high-speed movement for a short duration.
847
+ * Creates a burst of velocity for a fixed duration. The total speed is calculated
848
+ * by adding the player's base speed (`this.speed()`) to the additional dash speed.
849
+ * This ensures faster players also dash faster proportionally.
850
+ *
851
+ * With default speed=4 and additionalSpeed=4: total = 8 (same as original default)
763
852
  *
764
- * @param direction - Normalized direction vector
765
- * @param speed - Movement speed (default: 8)
853
+ * @param direction - Normalized direction vector `{ x, y }` for the dash
854
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
766
855
  * @param duration - Duration in milliseconds (default: 200)
856
+ * @param options - Optional callbacks for movement events
857
+ * @returns Promise that resolves when the dash completes
767
858
  *
768
859
  * @example
769
860
  * ```ts
770
- * // Dash right
771
- * player.dash({ x: 1, y: 0 });
772
- *
773
- * // Dash diagonally with custom speed and duration
774
- * player.dash({ x: 0.7, y: 0.7 }, 12, 300);
775
- *
776
- * // Dash in input direction
777
- * player.dash(inputDirection, 10, 150);
861
+ * // Dash to the right and wait for completion
862
+ * await player.dash({ x: 1, y: 0 });
863
+ *
864
+ * // Powerful dash with callbacks
865
+ * await player.dash({ x: 0, y: -1 }, 12, 300, {
866
+ * onStart: () => console.log('Dash started!'),
867
+ * onComplete: () => console.log('Dash finished!')
868
+ * });
778
869
  * ```
779
870
  */
780
- dash(direction: { x: number, y: number }, speed: number = 8, duration: number = 200): void {
781
- this.addMovement(new Dash(speed, direction, duration));
871
+ dash(direction: { x: number, y: number }, additionalSpeed: number = 4, duration: number = 200, options?: MovementOptions): Promise<void> {
872
+ const playerSpeed = (this as any).speed();
873
+ // Total dash speed = base speed + additional speed
874
+ // With speed=4, additionalSpeed=4: gives 8 (original default value)
875
+ const totalSpeed = playerSpeed + additionalSpeed;
876
+ // Physic strategies expect seconds (dt is in seconds), while the server API exposes milliseconds
877
+ const durationSeconds = duration / 1000;
878
+ return this.addMovement(new Dash(totalSpeed, direction, durationSeconds), options);
782
879
  }
783
880
 
784
881
  /**
785
882
  * Apply knockback effect in the specified direction
786
883
  *
787
- * Creates a push effect that gradually decreases over time.
884
+ * Pushes the entity with an initial force that decays over time.
885
+ * Returns a Promise that resolves when the knockback completes **or is cancelled**.
788
886
  *
789
- * @param direction - Normalized direction vector
887
+ * ## Design notes
888
+ * - The underlying physics `MovementManager` can cancel strategies via `remove()`, `clear()`,
889
+ * or `stopMovement()` **without resolving the Promise** returned by `add()`.
890
+ * - For this reason, this method considers the knockback finished when either:
891
+ * - the `add()` promise resolves (normal completion), or
892
+ * - the strategy is no longer present in the active movements list (cancellation).
893
+ * - When multiple knockbacks overlap, `directionFixed` and `animationFixed` are restored
894
+ * only after **all** knockbacks have finished (including cancellations).
895
+ *
896
+ * @param direction - Normalized direction vector `{ x, y }` for the knockback
790
897
  * @param force - Initial knockback force (default: 5)
791
898
  * @param duration - Duration in milliseconds (default: 300)
899
+ * @param options - Optional callbacks for movement events
900
+ * @returns Promise that resolves when the knockback completes or is cancelled
792
901
  *
793
902
  * @example
794
903
  * ```ts
795
- * // Knockback from explosion
796
- * const explosionDir = { x: -1, y: 0 };
797
- * player.knockback(explosionDir, 8, 400);
798
- *
799
- * // Light knockback from attack
800
- * player.knockback(attackDirection, 3, 200);
904
+ * // Simple knockback (await is optional)
905
+ * await player.knockback({ x: 1, y: 0 }, 5, 300);
906
+ *
907
+ * // Overlapping knockbacks: flags are restored only after the last one ends
908
+ * player.knockback({ x: -1, y: 0 }, 5, 300);
909
+ * player.knockback({ x: 0, y: 1 }, 3, 200);
910
+ *
911
+ * // Cancellation (e.g. map change) will still restore fixed flags
912
+ * // even if the underlying movement strategy promise is never resolved.
801
913
  * ```
802
914
  */
803
- knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300): void {
804
- this.addMovement(new Knockback(direction, force, duration));
805
- }
915
+ async knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300, options?: MovementOptions): Promise<void> {
916
+ const durationSeconds = duration / 1000;
917
+ const selfAny = this as any;
918
+ const lockKey = '__rpg_knockback_lock__';
806
919
 
807
- /**
920
+ type KnockbackLockState = {
921
+ prevDirectionFixed: boolean;
922
+ prevAnimationFixed: boolean;
923
+ prevAnimationName?: string;
924
+ };
925
+
926
+ const getLock = (): KnockbackLockState | undefined => selfAny[lockKey];
927
+ const setLock = (lock: KnockbackLockState): void => {
928
+ selfAny[lockKey] = lock;
929
+ };
930
+ const clearLock = (): void => {
931
+ delete selfAny[lockKey];
932
+ };
933
+
934
+ const hasActiveKnockback = (): boolean =>
935
+ this.getActiveMovements().some(s => s instanceof Knockback || s instanceof AdditiveKnockback);
936
+
937
+ const setAnimationName = (name: string): void => {
938
+ if (typeof selfAny.setGraphicAnimation === 'function') {
939
+ selfAny.setGraphicAnimation(name);
940
+ return;
941
+ }
942
+ const animSignal = selfAny.animationName;
943
+ if (animSignal && typeof animSignal === 'object' && typeof animSignal.set === 'function') {
944
+ animSignal.set(name);
945
+ }
946
+ };
947
+
948
+ const getAnimationName = (): string | undefined => {
949
+ const animSignal = selfAny.animationName;
950
+ if (typeof animSignal === 'function') {
951
+ try {
952
+ return animSignal();
953
+ } catch {
954
+ return undefined;
955
+ }
956
+ }
957
+ return undefined;
958
+ };
959
+
960
+ const restore = (): void => {
961
+ const lock = getLock();
962
+ if (!lock) return;
963
+
964
+ this.directionFixed = lock.prevDirectionFixed;
965
+
966
+ const prevAnimFixed = lock.prevAnimationFixed;
967
+ this.animationFixed = false; // temporarily unlock so we can restore animation
968
+ if (!prevAnimFixed && lock.prevAnimationName) {
969
+ setAnimationName(lock.prevAnimationName);
970
+ }
971
+ this.animationFixed = prevAnimFixed;
972
+
973
+ clearLock();
974
+ };
975
+
976
+ const ensureLockInitialized = (): void => {
977
+ if (getLock()) return;
978
+
979
+ setLock({
980
+ prevDirectionFixed: this.directionFixed,
981
+ prevAnimationFixed: this.animationFixed,
982
+ prevAnimationName: getAnimationName(),
983
+ });
984
+
985
+ this.directionFixed = true;
986
+ setAnimationName('stand');
987
+ this.animationFixed = true;
988
+ };
989
+
990
+ const waitUntilRemovedOrTimeout = (strategy: MovementStrategy): Promise<void> => {
991
+ return new Promise<void>((resolve) => {
992
+ const start = Date.now();
993
+ const maxMs = Math.max(0, duration + 1000);
994
+
995
+ const intervalId = setInterval(() => {
996
+ const active = this.getActiveMovements();
997
+ if (!active.includes(strategy) || (Date.now() - start > maxMs)) {
998
+ clearInterval(intervalId);
999
+ resolve();
1000
+ }
1001
+ }, 16);
1002
+ });
1003
+ };
1004
+
1005
+ // First knockback creates the lock and freezes direction/animation.
1006
+ // Next knockbacks reuse the lock and keep the fixed flags enabled.
1007
+ ensureLockInitialized();
1008
+
1009
+ // Use additive knockback to avoid jitter with player inputs
1010
+ const strategy = new AdditiveKnockback(direction, force, durationSeconds);
1011
+ const addPromise = this.addMovement(strategy, options);
1012
+
1013
+ try {
1014
+ await Promise.race([addPromise, waitUntilRemovedOrTimeout(strategy)]);
1015
+ } finally {
1016
+ // Restore only when ALL knockbacks are done (including cancellations).
1017
+ if (!hasActiveKnockback()) {
1018
+ restore();
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ /**
808
1024
  * Follow a sequence of waypoints
809
1025
  *
810
- * Entity will move through each waypoint in order.
1026
+ * Makes the entity move through a list of positions at a speed calculated
1027
+ * from the player's base speed. The `speedMultiplier` allows adjusting
1028
+ * the travel speed relative to the player's normal movement speed.
1029
+ *
1030
+ * With default speed=4 and multiplier=0.5: speed = 2 (same as original default)
811
1031
  *
812
- * @param waypoints - Array of x,y positions to follow
813
- * @param speed - Movement speed (default: 2)
814
- * @param loop - Whether to loop back to start (default: false)
1032
+ * @param waypoints - Array of `{ x, y }` positions to follow in order
1033
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
1034
+ * @param loop - Whether to loop back to start after reaching the end (default: false)
815
1035
  *
816
1036
  * @example
817
1037
  * ```ts
818
- * // Create a patrol route
819
- * const patrolPoints = [
1038
+ * // Follow a patrol path at normal speed
1039
+ * const patrol = [
820
1040
  * { x: 100, y: 100 },
821
- * { x: 300, y: 100 },
822
- * { x: 300, y: 300 },
823
- * { x: 100, y: 300 }
1041
+ * { x: 200, y: 100 },
1042
+ * { x: 200, y: 200 }
824
1043
  * ];
825
- * player.followPath(patrolPoints, 3, true);
1044
+ * player.followPath(patrol, 1, true); // Loop at full speed
826
1045
  *
827
- * // One-time path to destination
828
- * player.followPath([{ x: 500, y: 200 }], 4);
1046
+ * // Slow walk through waypoints
1047
+ * player.followPath(waypoints, 0.25, false);
829
1048
  * ```
830
1049
  */
831
- followPath(waypoints: Array<{ x: number, y: number }>, speed: number = 2, loop: boolean = false): void {
1050
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier: number = 0.5, loop: boolean = false): void {
1051
+ const playerSpeed = (this as any).speed();
1052
+ // Path follow speed = player base speed * multiplier
1053
+ // With speed=4, multiplier=0.5: gives 2 (original default value)
1054
+ const speed = playerSpeed * speedMultiplier;
832
1055
  this.addMovement(new PathFollow(waypoints, speed, loop));
833
1056
  }
834
1057
 
835
1058
  /**
836
1059
  * Apply oscillating movement pattern
837
1060
  *
838
- * Entity moves back and forth along the specified axis.
1061
+ * Creates a back-and-forth movement along the specified axis. The movement
1062
+ * oscillates sinusoidally between -amplitude and +amplitude from the starting position.
839
1063
  *
840
- * @param direction - Primary oscillation axis (normalized)
841
- * @param amplitude - Maximum distance from center (default: 50)
842
- * @param period - Time for complete cycle in ms (default: 2000)
1064
+ * @param direction - Primary oscillation axis (normalized direction vector)
1065
+ * @param amplitude - Maximum distance from center in pixels (default: 50)
1066
+ * @param period - Time for a complete cycle in milliseconds (default: 2000)
843
1067
  *
844
1068
  * @example
845
1069
  * ```ts
846
1070
  * // Horizontal oscillation
847
1071
  * player.oscillate({ x: 1, y: 0 }, 100, 3000);
848
1072
  *
849
- * // Vertical oscillation
850
- * player.oscillate({ x: 0, y: 1 }, 30, 1500);
851
- *
852
- * // Diagonal oscillation
853
- * player.oscillate({ x: 0.7, y: 0.7 }, 75, 2500);
1073
+ * // Diagonal bobbing motion
1074
+ * player.oscillate({ x: 1, y: 1 }, 30, 1000);
854
1075
  * ```
855
1076
  */
856
1077
  oscillate(direction: { x: number, y: number }, amplitude: number = 50, period: number = 2000): void {
@@ -860,49 +1081,59 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
860
1081
  /**
861
1082
  * Apply ice movement physics
862
1083
  *
863
- * Creates slippery movement with gradual acceleration and inertia.
864
- * Perfect for ice terrains or slippery surfaces.
1084
+ * Simulates slippery surface physics where the entity accelerates gradually
1085
+ * and has difficulty stopping. The maximum speed is based on the player's
1086
+ * base speed multiplied by a speed factor.
1087
+ *
1088
+ * With default speed=4 and factor=1: maxSpeed = 4 (same as original default)
865
1089
  *
866
- * @param direction - Target movement direction
867
- * @param maxSpeed - Maximum speed when fully accelerated (default: 4)
1090
+ * @param direction - Target movement direction `{ x, y }`
1091
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
868
1092
  *
869
1093
  * @example
870
1094
  * ```ts
871
- * // Apply ice physics when on ice terrain
872
- * if (onIceTerrain) {
873
- * player.applyIceMovement(inputDirection, 5);
874
- * }
1095
+ * // Normal ice physics
1096
+ * player.applyIceMovement({ x: 1, y: 0 });
875
1097
  *
876
- * // Update direction when input changes
877
- * iceMovement.setTargetDirection(newDirection);
1098
+ * // Fast ice sliding
1099
+ * player.applyIceMovement({ x: 0, y: 1 }, 1.5);
878
1100
  * ```
879
1101
  */
880
- applyIceMovement(direction: { x: number, y: number }, maxSpeed: number = 4): void {
1102
+ applyIceMovement(direction: { x: number, y: number }, speedFactor: number = 1): void {
1103
+ const playerSpeed = (this as any).speed();
1104
+ // Max ice speed = player base speed * factor
1105
+ // With speed=4, factor=1: gives 4 (original default value)
1106
+ const maxSpeed = playerSpeed * speedFactor;
881
1107
  this.addMovement(new IceMovement(direction, maxSpeed));
882
1108
  }
883
1109
 
884
1110
  /**
885
1111
  * Shoot a projectile in the specified direction
886
1112
  *
887
- * Creates projectile movement with various trajectory types.
1113
+ * Creates a projectile with ballistic trajectory. The speed is calculated
1114
+ * from the player's base speed multiplied by a speed factor.
888
1115
  *
889
- * @param type - Type of projectile trajectory
890
- * @param direction - Normalized direction vector
891
- * @param speed - Projectile speed (default: 200)
1116
+ * With default speed=4 and factor=50: speed = 200 (same as original default)
1117
+ *
1118
+ * @param type - Type of projectile trajectory (`Straight`, `Arc`, or `Bounce`)
1119
+ * @param direction - Normalized direction vector `{ x, y }`
1120
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
892
1121
  *
893
1122
  * @example
894
1123
  * ```ts
895
- * // Shoot arrow
896
- * player.shootProjectile(ProjectileType.Straight, { x: 1, y: 0 }, 300);
897
- *
898
- * // Throw grenade with arc
899
- * player.shootProjectile(ProjectileType.Arc, { x: 0.7, y: 0.7 }, 150);
1124
+ * // Straight projectile
1125
+ * player.shootProjectile(ProjectileType.Straight, { x: 1, y: 0 });
900
1126
  *
901
- * // Bouncing projectile
902
- * player.shootProjectile(ProjectileType.Bounce, { x: 1, y: 0 }, 100);
1127
+ * // Fast arc projectile
1128
+ * player.shootProjectile(ProjectileType.Arc, { x: 1, y: -0.5 }, 75);
903
1129
  * ```
904
1130
  */
905
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed: number = 200): void {
1131
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor: number = 50): void {
1132
+ const playerSpeed = (this as any).speed();
1133
+ // Projectile speed = player base speed * factor
1134
+ // With speed=4, factor=50: gives 200 (original default value)
1135
+ const speed = playerSpeed * speedFactor;
1136
+
906
1137
  const config = {
907
1138
  speed,
908
1139
  direction,
@@ -912,250 +1143,614 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
912
1143
  maxBounces: type === ProjectileType.Bounce ? 3 : undefined,
913
1144
  bounciness: type === ProjectileType.Bounce ? 0.6 : undefined
914
1145
  };
915
-
1146
+
916
1147
  this.addMovement(new ProjectileMovement(type, config));
917
1148
  }
918
1149
 
919
- /**
920
- * Give an itinerary to follow using movement strategies
921
- *
922
- * Executes a sequence of movements and actions in order. Each route can be:
923
- * - A Direction enum value for basic movement
924
- * - A string starting with "turn-" for direction changes
925
- * - A function that returns directions or actions
926
- * - A Promise for async operations
927
- *
928
- * The method processes routes sequentially, respecting the entity's frequency
929
- * setting for timing between movements.
930
- *
931
- * @param routes - Array of movement instructions to execute
932
- * @returns Promise that resolves when all routes are completed
933
- *
934
- * @example
935
- * ```ts
936
- * // Basic directional movements
937
- * await player.moveRoutes([
938
- * Direction.Right,
939
- * Direction.Up,
940
- * Direction.Left
941
- * ]);
942
- *
943
- * // Mix of movements and turns
944
- * await player.moveRoutes([
945
- * Direction.Right,
946
- * 'turn-' + Direction.Up,
947
- * Direction.Up
948
- * ]);
949
- *
950
- * // Using functions for dynamic behavior
951
- * const customMove = (player, map) => [Direction.Right, Direction.Down];
952
- * await player.moveRoutes([customMove]);
953
- *
954
- * // With async operations
955
- * await player.moveRoutes([
956
- * Direction.Right,
957
- * new Promise(resolve => setTimeout(resolve, 1000)), // Wait 1 second
958
- * Direction.Left
959
- * ]);
960
- * ```
961
- */
962
- moveRoutes(routes: Routes): Promise<boolean> {
963
- let count = 0;
964
- let frequence = 0;
1150
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean> {
965
1151
  const player = this as unknown as PlayerWithMixins;
966
-
1152
+
967
1153
  // Break any existing route movement
968
1154
  this.clearMovements();
969
-
1155
+
970
1156
  return new Promise(async (resolve) => {
971
1157
  // Store the resolve function for potential breaking
972
1158
  this._finishRoute = resolve;
973
-
1159
+
974
1160
  // Process function routes first
975
- const processedRoutes = routes.map((route: any) => {
976
- if (typeof route === 'function') {
977
- const map = player.getCurrentMap();
978
- if (!map) {
979
- return undefined;
1161
+ const processedRoutes = await Promise.all(
1162
+ routes.map(async (route: any) => {
1163
+ if (typeof route === 'function') {
1164
+ const map = player.getCurrentMap() as any;
1165
+ if (!map) {
1166
+ return undefined;
1167
+ }
1168
+ return route.apply(route, [player, map]);
980
1169
  }
981
- return route.apply(route, [player, map]);
982
- }
983
- return route;
984
- });
985
-
1170
+ return route;
1171
+ })
1172
+ );
1173
+
986
1174
  // Flatten nested arrays
987
- const flatRoutes = this.flattenRoutes(processedRoutes);
988
- let routeIndex = 0;
989
-
990
- const executeNextRoute = async (): Promise<void> => {
991
- // Check if player still exists and is on a map
992
- if (!player || !player.getCurrentMap()) {
993
- this._finishRoute = null;
994
- resolve(false);
995
- return;
1175
+ // Note: We keep promises in the routes array and handle them in the strategy
1176
+ const finalRoutes = this.flattenRoutes(processedRoutes);
1177
+
1178
+ // Create a movement strategy that handles all routes
1179
+ class RouteMovementStrategy implements MovementStrategy {
1180
+ private routeIndex = 0;
1181
+ private currentTarget: { x: number; y: number } | null = null; // Center position for physics
1182
+ private currentTargetTopLeft: { x: number; y: number } | null = null; // Top-left position for player.x() comparison
1183
+ private currentDirection: { x: number; y: number } = { x: 0, y: 0 };
1184
+ private finished = false;
1185
+ private waitingForPromise = false;
1186
+ private promiseStartTime = 0;
1187
+ private promiseDuration = 0;
1188
+ private readonly routes: Routes;
1189
+ private readonly player: PlayerWithMixins;
1190
+ private readonly onComplete: (success: boolean) => void;
1191
+ private readonly tileSize: number;
1192
+ private readonly tolerance: number;
1193
+ private readonly onStuck?: MoveRoutesOptions['onStuck'];
1194
+ private readonly stuckTimeout: number;
1195
+ private readonly stuckThreshold: number;
1196
+ private remainingDistance = 0;
1197
+ private segmentDirection: Direction | null = null;
1198
+ private segmentStep = 0;
1199
+
1200
+ // Frequency wait state
1201
+ private waitingForFrequency = false;
1202
+ private frequencyWaitStartTime = 0;
1203
+ private ratioFrequency = 15;
1204
+
1205
+ // Stuck detection state
1206
+ private lastPosition: { x: number; y: number } | null = null;
1207
+ private lastPositionTime: number = 0;
1208
+ private stuckCheckStartTime: number = 0;
1209
+ private lastDistanceToTarget: number | null = null;
1210
+ private isCurrentlyStuck: boolean = false;
1211
+ private stuckCheckInitialized: boolean = false;
1212
+
1213
+ constructor(
1214
+ routes: Routes,
1215
+ player: PlayerWithMixins,
1216
+ onComplete: (success: boolean) => void,
1217
+ options?: MoveRoutesOptions
1218
+ ) {
1219
+ this.routes = routes;
1220
+ this.player = player;
1221
+ this.onComplete = onComplete;
1222
+ this.tileSize = player.nbPixelInTile || 32;
1223
+ this.tolerance = 0.5; // Tolerance in pixels for reaching target (reduced for precision)
1224
+ this.onStuck = options?.onStuck;
1225
+ this.stuckTimeout = options?.stuckTimeout ?? 500; // Default 500ms
1226
+ this.stuckThreshold = options?.stuckThreshold ?? 1; // Default 1 pixel
1227
+
1228
+ // Process initial route
1229
+ this.processNextRoute();
1230
+ }
1231
+
1232
+ private debugLog(message: string, data?: any): void {
1233
+ // Debug logging disabled - enable if needed for troubleshooting
996
1234
  }
997
-
998
- // Handle frequency timing
999
- if (count >= (player.nbPixelInTile || 32)) {
1000
- if (frequence < (player.frequency || 0)) {
1001
- frequence++;
1002
- setTimeout(executeNextRoute, 16); // ~60fps timing
1235
+
1236
+ private processNextRoute(): void {
1237
+ // Reset frequency wait state when processing a new route
1238
+ this.waitingForFrequency = false;
1239
+ this.frequencyWaitStartTime = 0;
1240
+ this.remainingDistance = 0;
1241
+ this.segmentDirection = null;
1242
+ this.segmentStep = 0;
1243
+
1244
+ // Check if we've completed all routes
1245
+ if (this.routeIndex >= this.routes.length) {
1246
+ this.debugLog('COMPLETE all routes finished');
1247
+ this.finished = true;
1248
+ this.onComplete(true);
1003
1249
  return;
1004
1250
  }
1251
+
1252
+ const currentRoute = this.routes[this.routeIndex];
1253
+ this.routeIndex++;
1254
+
1255
+ if (currentRoute === undefined) {
1256
+ this.processNextRoute();
1257
+ return;
1258
+ }
1259
+
1260
+ try {
1261
+ // Handle different route types
1262
+ if (typeof currentRoute === 'object' && 'then' in currentRoute) {
1263
+ // Handle Promise (like Move.wait())
1264
+ this.debugLog(`WAIT for promise (route ${this.routeIndex}/${this.routes.length})`);
1265
+ this.waitingForPromise = true;
1266
+ this.promiseStartTime = Date.now();
1267
+
1268
+ // Try to get duration from promise if possible (for Move.wait())
1269
+ // Move.wait() creates a promise that resolves after a delay
1270
+ // We'll use a default duration and let the promise resolve naturally
1271
+ this.promiseDuration = 1000; // Default 1 second, will be updated when promise resolves
1272
+
1273
+ // Set up promise resolution handler
1274
+ (currentRoute as Promise<any>).then(() => {
1275
+ this.debugLog('WAIT promise resolved');
1276
+ this.waitingForPromise = false;
1277
+ this.processNextRoute();
1278
+ }).catch(() => {
1279
+ this.debugLog('WAIT promise rejected');
1280
+ this.waitingForPromise = false;
1281
+ this.processNextRoute();
1282
+ });
1283
+ } else if (typeof currentRoute === 'string' && currentRoute.startsWith('turn-')) {
1284
+ // Handle turn commands - just change direction, no movement
1285
+ const directionStr = currentRoute.replace('turn-', '');
1286
+ let direction: Direction = Direction.Down;
1287
+
1288
+ switch (directionStr) {
1289
+ case 'up':
1290
+ case Direction.Up:
1291
+ direction = Direction.Up;
1292
+ break;
1293
+ case 'down':
1294
+ case Direction.Down:
1295
+ direction = Direction.Down;
1296
+ break;
1297
+ case 'left':
1298
+ case Direction.Left:
1299
+ direction = Direction.Left;
1300
+ break;
1301
+ case 'right':
1302
+ case Direction.Right:
1303
+ direction = Direction.Right;
1304
+ break;
1305
+ }
1306
+
1307
+ this.debugLog(`TURN to ${directionStr}`);
1308
+ if (this.player.changeDirection) {
1309
+ this.player.changeDirection(direction);
1310
+ }
1311
+ // Turn is instant, continue immediately
1312
+ this.processNextRoute();
1313
+ } else if (typeof currentRoute === 'number' || typeof currentRoute === 'string') {
1314
+ // Handle Direction enum values (number or string) - calculate target position
1315
+ const moveDirection = currentRoute as unknown as Direction;
1316
+ const map = this.player.getCurrentMap() as any;
1317
+ if (!map) {
1318
+ this.finished = true;
1319
+ this.onComplete(false);
1320
+ return;
1321
+ }
1322
+
1323
+ // Get current position (top-left from player, which is what player.x() returns)
1324
+ // We calculate target based on top-left position to match player.x() expectations
1325
+ const currentTopLeftX = typeof this.player.x === 'function' ? this.player.x() : this.player.x;
1326
+ const currentTopLeftY = typeof this.player.y === 'function' ? this.player.y() : this.player.y;
1327
+
1328
+ // Get player speed
1329
+ let playerSpeed = this.player.speed()
1330
+
1331
+ // Use player speed as distance, not tile size
1332
+ let distance = playerSpeed;
1333
+
1334
+ // Merge consecutive routes of same direction
1335
+ const initialDistance = distance;
1336
+ const initialRouteIndex = this.routeIndex;
1337
+ while (this.routeIndex < this.routes.length) {
1338
+ const nextRoute = this.routes[this.routeIndex];
1339
+ if (nextRoute === currentRoute) {
1340
+ distance += playerSpeed;
1341
+ this.routeIndex++;
1342
+ } else {
1343
+ break;
1344
+ }
1345
+ }
1346
+
1347
+ // Prepare segmented movement (per tile)
1348
+ this.remainingDistance = distance;
1349
+ this.segmentDirection = moveDirection;
1350
+ this.segmentStep = this.getTileStepDistance(playerSpeed);
1351
+ this.setNextSegmentTarget(currentTopLeftX, currentTopLeftY);
1352
+
1353
+ if (this.currentTargetTopLeft) {
1354
+ this.debugLog(`MOVE direction=${moveDirection} from=(${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)}) to=(${this.currentTargetTopLeft.x.toFixed(1)}, ${this.currentTargetTopLeft.y.toFixed(1)}) dist=${distance.toFixed(1)}`);
1355
+ }
1356
+
1357
+ // Reset stuck detection when starting a new movement
1358
+ this.lastPosition = null;
1359
+ this.isCurrentlyStuck = false;
1360
+ this.stuckCheckStartTime = 0;
1361
+ this.lastDistanceToTarget = null;
1362
+ this.stuckCheckInitialized = false;
1363
+ // Reset frequency wait state when starting a new movement
1364
+ this.waitingForFrequency = false;
1365
+ this.frequencyWaitStartTime = 0;
1366
+ } else if (Array.isArray(currentRoute)) {
1367
+ // Handle array of directions - insert them into routes
1368
+ for (let i = currentRoute.length - 1; i >= 0; i--) {
1369
+ this.routes.splice(this.routeIndex, 0, currentRoute[i]);
1370
+ }
1371
+ this.processNextRoute();
1372
+ } else {
1373
+ // Unknown route type, skip
1374
+ this.processNextRoute();
1375
+ }
1376
+ } catch (error) {
1377
+ console.warn('Error processing route:', error);
1378
+ this.processNextRoute();
1379
+ }
1005
1380
  }
1006
-
1007
- frequence = 0;
1008
- count++;
1009
-
1010
- // Check if we've completed all routes
1011
- if (routeIndex >= flatRoutes.length) {
1012
- this._finishRoute = null;
1013
- resolve(true);
1014
- return;
1015
- }
1016
-
1017
- const currentRoute = flatRoutes[routeIndex];
1018
- routeIndex++;
1019
-
1020
- if (currentRoute === undefined) {
1021
- executeNextRoute();
1022
- return;
1023
- }
1024
-
1025
- try {
1026
- // Handle different route types
1027
- if (typeof currentRoute === 'object' && 'then' in currentRoute) {
1028
- // Handle Promise
1029
- await currentRoute;
1030
- executeNextRoute();
1031
- } else if (typeof currentRoute === 'string' && currentRoute.startsWith('turn-')) {
1032
- // Handle turn commands
1033
- const directionStr = currentRoute.replace('turn-', '');
1034
- let direction: Direction = Direction.Down;
1035
-
1036
- // Convert string direction to Direction enum
1037
- switch (directionStr) {
1038
- case 'up':
1039
- case Direction.Up:
1040
- direction = Direction.Up;
1041
- break;
1042
- case 'down':
1043
- case Direction.Down:
1044
- direction = Direction.Down;
1045
- break;
1046
- case 'left':
1047
- case Direction.Left:
1048
- direction = Direction.Left;
1049
- break;
1050
- case 'right':
1051
- case Direction.Right:
1052
- direction = Direction.Right;
1053
- break;
1381
+
1382
+ update(body: MovementBody, dt: number): void {
1383
+ // Don't process if waiting for promise
1384
+ if (this.waitingForPromise) {
1385
+ body.setVelocity({ x: 0, y: 0 });
1386
+ // Check if promise wait time has elapsed (fallback)
1387
+ if (Date.now() - this.promiseStartTime > this.promiseDuration) {
1388
+ this.waitingForPromise = false;
1389
+ this.processNextRoute();
1054
1390
  }
1055
-
1056
- if (player.changeDirection) {
1057
- player.changeDirection(direction);
1391
+ return;
1392
+ }
1393
+
1394
+ // Don't process if waiting for frequency delay
1395
+ if (this.waitingForFrequency) {
1396
+ body.setVelocity({ x: 0, y: 0 });
1397
+ const playerFrequency = this.player.frequency;
1398
+ const frequencyMs = playerFrequency || 0;
1399
+
1400
+ if (frequencyMs > 0 && Date.now() - this.frequencyWaitStartTime >= frequencyMs * this.ratioFrequency) {
1401
+ this.waitingForFrequency = false;
1402
+ if (this.remainingDistance > 0) {
1403
+ const currentTopLeftX = this.player.x();
1404
+ const currentTopLeftY = this.player.y();
1405
+ this.setNextSegmentTarget(currentTopLeftX, currentTopLeftY);
1406
+ } else {
1407
+ this.processNextRoute();
1408
+ }
1058
1409
  }
1059
- executeNextRoute();
1060
- } else if (typeof currentRoute === 'number') {
1061
- // Handle Direction enum values
1062
- if (player.moveByDirection) {
1063
- await player.moveByDirection(currentRoute as unknown as Direction, 1);
1064
- } else {
1065
- // Fallback to movement strategy - use direction as velocity components
1066
- let vx = 0, vy = 0;
1067
- const direction = currentRoute as unknown as Direction;
1068
- switch (direction) {
1069
- case Direction.Right: vx = 1; break;
1070
- case Direction.Left: vx = -1; break;
1071
- case Direction.Down: vy = 1; break;
1072
- case Direction.Up: vy = -1; break;
1410
+ return;
1411
+ }
1412
+
1413
+ // If no target, try to process next route
1414
+ if (!this.currentTarget) {
1415
+ if (!this.finished) {
1416
+ this.processNextRoute();
1417
+ }
1418
+ if (!this.currentTarget) {
1419
+ body.setVelocity({ x: 0, y: 0 });
1420
+ // Reset stuck detection when no target
1421
+ this.lastPosition = null;
1422
+ this.isCurrentlyStuck = false;
1423
+ this.lastDistanceToTarget = null;
1424
+ this.stuckCheckInitialized = false;
1425
+ this.currentTargetTopLeft = null;
1426
+ return;
1427
+ }
1428
+ }
1429
+
1430
+ const entity = body.getEntity?.();
1431
+ if (!entity) {
1432
+ this.finished = true;
1433
+ this.onComplete(false);
1434
+ return;
1435
+ }
1436
+
1437
+ const currentPosition = { x: entity.position.x, y: entity.position.y };
1438
+ const currentTime = Date.now();
1439
+
1440
+ // Check distance using player's top-left position (what player.x() returns)
1441
+ // This ensures we match the test expectations which compare player.x()
1442
+ const currentTopLeftX = this.player.x();
1443
+ const currentTopLeftY = this.player.y()
1444
+
1445
+ // Calculate direction and distance using top-left position if available
1446
+ let dx: number, dy: number, distance: number;
1447
+ if (this.currentTargetTopLeft) {
1448
+ dx = this.currentTargetTopLeft.x - currentTopLeftX;
1449
+ dy = this.currentTargetTopLeft.y - currentTopLeftY;
1450
+ distance = Math.hypot(dx, dy);
1451
+
1452
+ // Check if we've reached the target (using top-left position)
1453
+ if (distance <= this.tolerance) {
1454
+ // Target reached, wait for frequency before processing next route
1455
+ this.debugLog(`TARGET reached at (${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)})`);
1456
+ this.currentTarget = null;
1457
+ this.currentTargetTopLeft = null;
1458
+ this.currentDirection = { x: 0, y: 0 };
1459
+ body.setVelocity({ x: 0, y: 0 });
1460
+ // Reset stuck detection
1461
+ this.lastPosition = null;
1462
+ this.isCurrentlyStuck = false;
1463
+ this.lastDistanceToTarget = null;
1464
+ this.stuckCheckInitialized = false;
1465
+
1466
+ // Wait for frequency before processing next route or segment
1467
+ if (!this.finished) {
1468
+ const playerFrequency = this.player.frequency;
1469
+ if (playerFrequency && playerFrequency > 0) {
1470
+ this.waitingForFrequency = true;
1471
+ this.frequencyWaitStartTime = Date.now();
1472
+ } else if (this.remainingDistance > 0) {
1473
+ const nextTopLeftX = this.player.x();
1474
+ const nextTopLeftY = this.player.y();
1475
+ this.setNextSegmentTarget(nextTopLeftX, nextTopLeftY);
1476
+ } else {
1477
+ // No frequency delay, process immediately
1478
+ this.processNextRoute();
1479
+ }
1480
+ }
1481
+ return;
1482
+ }
1483
+ } else {
1484
+ // Fallback: use center position distance if top-left target not available
1485
+ dx = this.currentTarget.x - currentPosition.x;
1486
+ dy = this.currentTarget.y - currentPosition.y;
1487
+ distance = Math.hypot(dx, dy);
1488
+
1489
+ // Check if we've reached the target (using center position as fallback)
1490
+ if (distance <= this.tolerance) {
1491
+ // Target reached, wait for frequency before processing next route
1492
+ this.currentTarget = null;
1493
+ this.currentTargetTopLeft = null;
1494
+ this.currentDirection = { x: 0, y: 0 };
1495
+ body.setVelocity({ x: 0, y: 0 });
1496
+ // Reset stuck detection
1497
+ this.lastPosition = null;
1498
+ this.isCurrentlyStuck = false;
1499
+ this.lastDistanceToTarget = null;
1500
+ this.stuckCheckInitialized = false;
1501
+
1502
+ // Wait for frequency before processing next route or segment
1503
+ if (!this.finished) {
1504
+ const playerFrequency = player.frequency;
1505
+ if (playerFrequency && playerFrequency > 0) {
1506
+ this.waitingForFrequency = true;
1507
+ this.frequencyWaitStartTime = Date.now();
1508
+ } else if (this.remainingDistance > 0) {
1509
+ const nextTopLeftX = this.player.x();
1510
+ const nextTopLeftY = this.player.y();
1511
+ this.setNextSegmentTarget(nextTopLeftX, nextTopLeftY);
1512
+ } else {
1513
+ // No frequency delay, process immediately
1514
+ this.processNextRoute();
1515
+ }
1073
1516
  }
1074
- this.addMovement(new LinearMove(vx * (player.speed?.() || 3), vy * (player.speed?.() || 3), 100));
1075
- setTimeout(executeNextRoute, 100);
1076
1517
  return;
1077
1518
  }
1078
- executeNextRoute();
1519
+ }
1520
+
1521
+ // Stuck detection: check if player is making progress
1522
+ if (this.onStuck && this.currentTarget) {
1523
+ // Initialize tracking on first update
1524
+ if (!this.stuckCheckInitialized) {
1525
+ this.lastPosition = { ...currentPosition };
1526
+ this.lastDistanceToTarget = distance;
1527
+ this.stuckCheckInitialized = true;
1528
+ // Update tracking and continue (don't return early)
1529
+ this.lastPositionTime = currentTime;
1530
+ } else if (this.lastPosition && this.lastDistanceToTarget !== null) {
1531
+ // We have a target, so we're trying to move (regardless of current velocity,
1532
+ // which may be zero due to physics engine collision handling)
1533
+ const positionChanged = Math.hypot(
1534
+ currentPosition.x - this.lastPosition.x,
1535
+ currentPosition.y - this.lastPosition.y
1536
+ ) > this.stuckThreshold;
1537
+
1538
+ const distanceImproved = distance < (this.lastDistanceToTarget - this.stuckThreshold);
1539
+
1540
+ // Player is stuck if: not moving AND not getting closer to target
1541
+ if (!positionChanged && !distanceImproved) {
1542
+ // Player is not making progress
1543
+ if (!this.isCurrentlyStuck) {
1544
+ // Start stuck timer
1545
+ this.stuckCheckStartTime = currentTime;
1546
+ this.isCurrentlyStuck = true;
1547
+ } else {
1548
+ // Check if stuck timeout has elapsed
1549
+ if (currentTime - this.stuckCheckStartTime >= this.stuckTimeout) {
1550
+ // Player is stuck, call onStuck callback
1551
+ this.debugLog(`STUCK detected at (${currentPosition.x.toFixed(1)}, ${currentPosition.y.toFixed(1)}) target=(${this.currentTarget.x.toFixed(1)}, ${this.currentTarget.y.toFixed(1)})`);
1552
+ const shouldContinue = this.onStuck(
1553
+ this.player as any,
1554
+ this.currentTarget,
1555
+ currentPosition
1556
+ );
1557
+
1558
+ if (shouldContinue === false) {
1559
+ // Cancel the route
1560
+ this.debugLog('STUCK cancelled route');
1561
+ this.finished = true;
1562
+ this.onComplete(false);
1563
+ body.setVelocity({ x: 0, y: 0 });
1564
+ return;
1565
+ }
1566
+
1567
+ // Continue route: abandon the current target and move on.
1568
+ //
1569
+ // ## Why?
1570
+ // When random movement hits an obstacle, the physics engine can keep the entity
1571
+ // at the same position while this strategy keeps trying to reach the same target,
1572
+ // resulting in an infinite "stuck loop". By dropping the current target here,
1573
+ // we allow the route to advance and (in the common case of `infiniteMoveRoute`)
1574
+ // re-evaluate `Move.tileRandom()` on the next cycle, producing a new direction.
1575
+ this.currentTarget = null;
1576
+ this.currentTargetTopLeft = null;
1577
+ this.currentDirection = { x: 0, y: 0 };
1578
+ body.setVelocity({ x: 0, y: 0 });
1579
+
1580
+ // Reset stuck detection to start fresh on the next target
1581
+ this.isCurrentlyStuck = false;
1582
+ this.stuckCheckStartTime = 0;
1583
+ this.lastPosition = null;
1584
+ this.lastDistanceToTarget = null;
1585
+ this.stuckCheckInitialized = false;
1586
+
1587
+ // Advance to next route instruction immediately
1588
+ this.processNextRoute();
1589
+ return;
1590
+ }
1591
+ }
1592
+ } else {
1593
+ // Player is making progress, reset stuck detection
1594
+ this.isCurrentlyStuck = false;
1595
+ this.stuckCheckStartTime = 0;
1596
+ }
1597
+
1598
+ // Update tracking variables
1599
+ this.lastPosition = { ...currentPosition };
1600
+ this.lastPositionTime = currentTime;
1601
+ this.lastDistanceToTarget = distance;
1602
+ }
1603
+ }
1604
+
1605
+ // Get speed scalar from map (default 50 if not found)
1606
+ const map = this.player.getCurrentMap() as any;
1607
+ const speedScalar = map?.speedScalar ?? 50;
1608
+
1609
+ // Calculate direction and speed
1610
+ // Use the distance calculated above (from top-left if available, center otherwise)
1611
+ if (distance > 0) {
1612
+ this.currentDirection = { x: dx / distance, y: dy / distance };
1079
1613
  } else {
1080
- // Unknown route type, skip
1081
- executeNextRoute();
1614
+ // If distance is 0 or negative, we've reached or passed the target
1615
+ this.currentTarget = null;
1616
+ this.currentTargetTopLeft = null;
1617
+ this.currentDirection = { x: 0, y: 0 };
1618
+ body.setVelocity({ x: 0, y: 0 });
1619
+ if (!this.finished) {
1620
+ const playerFrequency = typeof this.player.frequency === 'function' ? this.player.frequency() : this.player.frequency;
1621
+ if (playerFrequency && playerFrequency > 0) {
1622
+ this.waitingForFrequency = true;
1623
+ this.frequencyWaitStartTime = Date.now();
1624
+ } else if (this.remainingDistance > 0) {
1625
+ const nextTopLeftX = this.player.x();
1626
+ const nextTopLeftY = this.player.y();
1627
+ this.setNextSegmentTarget(nextTopLeftX, nextTopLeftY);
1628
+ } else {
1629
+ // No frequency delay, process immediately
1630
+ this.processNextRoute();
1631
+ }
1632
+ }
1633
+ return;
1634
+ }
1635
+
1636
+ // Convert vector direction to cardinal direction (like moveBody does)
1637
+ const absX = Math.abs(this.currentDirection.x);
1638
+ const absY = Math.abs(this.currentDirection.y);
1639
+ let cardinalDirection: Direction;
1640
+
1641
+ if (absX >= absY) {
1642
+ cardinalDirection = this.currentDirection.x >= 0 ? Direction.Right : Direction.Left;
1643
+ } else {
1644
+ cardinalDirection = this.currentDirection.y >= 0 ? Direction.Down : Direction.Up;
1082
1645
  }
1083
- } catch (error) {
1084
- console.warn('Error executing route:', error);
1085
- executeNextRoute();
1646
+
1647
+ map.movePlayer(this.player as any, cardinalDirection)
1086
1648
  }
1087
- };
1088
-
1089
- executeNextRoute();
1649
+
1650
+ isFinished(): boolean {
1651
+ return this.finished;
1652
+ }
1653
+
1654
+ onFinished(): void {
1655
+ this.onComplete(true);
1656
+ }
1657
+
1658
+ private getTileStepDistance(playerSpeed: number): number {
1659
+ if (!Number.isFinite(playerSpeed) || playerSpeed <= 0) {
1660
+ return this.tileSize;
1661
+ }
1662
+ const stepsPerTile = Math.max(1, Math.floor(this.tileSize / playerSpeed));
1663
+ return stepsPerTile * playerSpeed;
1664
+ }
1665
+
1666
+ private setNextSegmentTarget(currentTopLeftX: number, currentTopLeftY: number): void {
1667
+ if (!this.segmentDirection || this.remainingDistance <= 0) {
1668
+ return;
1669
+ }
1670
+
1671
+ const map = this.player.getCurrentMap() as any;
1672
+ if (!map) {
1673
+ this.finished = true;
1674
+ this.onComplete(false);
1675
+ return;
1676
+ }
1677
+
1678
+ const entity = map.physic.getEntityByUUID(this.player.id);
1679
+ if (!entity) {
1680
+ this.finished = true;
1681
+ this.onComplete(false);
1682
+ return;
1683
+ }
1684
+
1685
+ const segmentDistance = Math.min(this.segmentStep || this.remainingDistance, this.remainingDistance);
1686
+ let targetTopLeftX = currentTopLeftX;
1687
+ let targetTopLeftY = currentTopLeftY;
1688
+
1689
+ switch (this.segmentDirection) {
1690
+ case Direction.Right:
1691
+ case 'right' as any:
1692
+ targetTopLeftX = currentTopLeftX + segmentDistance;
1693
+ break;
1694
+ case Direction.Left:
1695
+ case 'left' as any:
1696
+ targetTopLeftX = currentTopLeftX - segmentDistance;
1697
+ break;
1698
+ case Direction.Down:
1699
+ case 'down' as any:
1700
+ targetTopLeftY = currentTopLeftY + segmentDistance;
1701
+ break;
1702
+ case Direction.Up:
1703
+ case 'up' as any:
1704
+ targetTopLeftY = currentTopLeftY - segmentDistance;
1705
+ break;
1706
+ }
1707
+
1708
+ const hitbox = this.player.hitbox();
1709
+ const hitboxWidth = hitbox?.w ?? 32;
1710
+ const hitboxHeight = hitbox?.h ?? 32;
1711
+
1712
+ const targetX = targetTopLeftX + hitboxWidth / 2;
1713
+ const targetY = targetTopLeftY + hitboxHeight / 2;
1714
+
1715
+ this.currentTarget = { x: targetX, y: targetY };
1716
+ this.currentTargetTopLeft = { x: targetTopLeftX, y: targetTopLeftY };
1717
+ this.currentDirection = { x: 0, y: 0 };
1718
+ this.remainingDistance -= segmentDistance;
1719
+ }
1720
+ }
1721
+
1722
+ // Create and add the route movement strategy
1723
+ const routeStrategy = new RouteMovementStrategy(
1724
+ finalRoutes,
1725
+ player,
1726
+ (success: boolean) => {
1727
+ this._finishRoute = null;
1728
+ resolve(success);
1729
+ },
1730
+ options
1731
+ );
1732
+
1733
+ this.addMovement(routeStrategy);
1090
1734
  });
1091
1735
  }
1092
-
1093
- /**
1094
- * Utility method to flatten nested route arrays
1095
- *
1096
- * @private
1097
- * @param routes - Routes array that may contain nested arrays
1098
- * @returns Flattened array of routes
1099
- */
1100
- private flattenRoutes(routes: any[]): any[] {
1101
- const result: any[] = [];
1102
-
1103
- for (const route of routes) {
1104
- if (Array.isArray(route)) {
1105
- result.push(...this.flattenRoutes(route));
1106
- } else {
1107
- result.push(route);
1736
+
1737
+ private flattenRoutes(routes: Routes): Routes {
1738
+ return routes.reduce((acc: Routes, item) => {
1739
+ if (Array.isArray(item)) {
1740
+ return acc.concat(this.flattenRoutes(item));
1108
1741
  }
1109
- }
1110
-
1111
- return result;
1742
+ return acc.concat(item);
1743
+ }, []);
1112
1744
  }
1113
1745
 
1114
- /**
1115
- * Give a path that repeats itself in a loop to a character
1116
- *
1117
- * Creates an infinite movement pattern that continues until manually stopped.
1118
- * The routes will repeat in a continuous loop, making it perfect for patrol
1119
- * patterns, ambient movements, or any repetitive behavior.
1120
- *
1121
- * You can stop the movement at any time with `breakRoutes()` and replay it
1122
- * with `replayRoutes()`.
1123
- *
1124
- * @param routes - Array of movement instructions to repeat infinitely
1125
- *
1126
- * @example
1127
- * ```ts
1128
- * // Create an infinite random movement pattern
1129
- * player.infiniteMoveRoute([Move.random()]);
1130
- *
1131
- * // Create a patrol route
1132
- * player.infiniteMoveRoute([
1133
- * Direction.Right,
1134
- * Direction.Right,
1135
- * Direction.Down,
1136
- * Direction.Left,
1137
- * Direction.Left,
1138
- * Direction.Up
1139
- * ]);
1140
- *
1141
- * // Mix movements and rotations
1142
- * player.infiniteMoveRoute([
1143
- * Move.turnRight(),
1144
- * Direction.Right,
1145
- * Move.wait(1),
1146
- * Move.turnLeft(),
1147
- * Direction.Left
1148
- * ]);
1149
- * ```
1150
- */
1151
- infiniteMoveRoute(routes: Routes): void {
1746
+ infiniteMoveRoute(routes: Routes, options?: MoveRoutesOptions): void {
1152
1747
  this._infiniteRoutes = routes;
1153
1748
  this._isInfiniteRouteActive = true;
1154
1749
 
1155
1750
  const executeInfiniteRoute = (isBreaking: boolean = false) => {
1156
1751
  if (isBreaking || !this._isInfiniteRouteActive) return;
1157
-
1158
- this.moveRoutes(routes).then((completed) => {
1752
+
1753
+ this.moveRoutes(routes, options).then((completed) => {
1159
1754
  // Only continue if the route completed successfully and we're still active
1160
1755
  if (completed && this._isInfiniteRouteActive) {
1161
1756
  executeInfiniteRoute();
@@ -1172,37 +1767,14 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
1172
1767
  executeInfiniteRoute();
1173
1768
  }
1174
1769
 
1175
- /**
1176
- * Stop an infinite movement
1177
- *
1178
- * Works only for infinite movements created with `infiniteMoveRoute()`.
1179
- * This method stops the current route execution and prevents the next
1180
- * iteration from starting.
1181
- *
1182
- * @param force - Forces the stop of the infinite movement immediately
1183
- *
1184
- * @example
1185
- * ```ts
1186
- * // Start infinite movement
1187
- * player.infiniteMoveRoute([Move.random()]);
1188
- *
1189
- * // Stop it when player enters combat
1190
- * if (inCombat) {
1191
- * player.breakRoutes(true);
1192
- * }
1193
- *
1194
- * // Gentle stop (completes current route first)
1195
- * player.breakRoutes();
1196
- * ```
1197
- */
1198
1770
  breakRoutes(force: boolean = false): void {
1199
1771
  this._isInfiniteRouteActive = false;
1200
-
1772
+
1201
1773
  if (force) {
1202
1774
  // Force stop by clearing all movements immediately
1203
1775
  this.clearMovements();
1204
1776
  }
1205
-
1777
+
1206
1778
  // If there's an active route promise, resolve it
1207
1779
  if (this._finishRoute) {
1208
1780
  this._finishRoute(force);
@@ -1210,36 +1782,234 @@ export function WithMoveManager<TBase extends Constructor<RpgCommonPlayer>>(
1210
1782
  }
1211
1783
  }
1212
1784
 
1213
- /**
1214
- * Replay an infinite movement
1215
- *
1216
- * Works only for infinite movements that were previously created with
1217
- * `infiniteMoveRoute()`. If the route was stopped with `breakRoutes()`,
1218
- * you can restart it with this method using the same route configuration.
1219
- *
1220
- * @example
1221
- * ```ts
1222
- * // Create infinite movement
1223
- * player.infiniteMoveRoute([Move.random()]);
1224
- *
1225
- * // Stop it temporarily
1226
- * player.breakRoutes(true);
1227
- *
1228
- * // Resume the same movement pattern
1229
- * player.replayRoutes();
1230
- *
1231
- * // Stop and start with different conditions
1232
- * if (playerNearby) {
1233
- * player.breakRoutes();
1234
- * } else {
1235
- * player.replayRoutes();
1236
- * }
1237
- * ```
1238
- */
1239
1785
  replayRoutes(): void {
1240
1786
  if (this._infiniteRoutes && !this._isInfiniteRouteActive) {
1241
1787
  this.infiniteMoveRoute(this._infiniteRoutes);
1242
1788
  }
1243
1789
  }
1244
- };
1790
+ }
1791
+
1792
+ return WithMoveManagerClass as unknown as PlayerCtor;
1793
+ }
1794
+ /**
1795
+ * Interface for Move Manager functionality
1796
+ *
1797
+ * Provides comprehensive movement management capabilities including pathfinding,
1798
+ * physics-based movement, route following, and advanced movement strategies.
1799
+ * This interface defines the public API of the MoveManager mixin.
1800
+ */
1801
+ export interface IMoveManager {
1802
+ /**
1803
+ * Whether the player passes through other players
1804
+ *
1805
+ * When `true`, the player can walk through other player entities without collision.
1806
+ * This is useful for busy areas where players shouldn't block each other.
1807
+ *
1808
+ * @default true
1809
+ *
1810
+ * @example
1811
+ * ```ts
1812
+ * // Disable player-to-player collision
1813
+ * player.throughOtherPlayer = true;
1814
+ *
1815
+ * // Enable player-to-player collision
1816
+ * player.throughOtherPlayer = false;
1817
+ * ```
1818
+ */
1819
+ throughOtherPlayer: boolean;
1820
+
1821
+ /**
1822
+ * Whether the player goes through all characters (players and events)
1823
+ *
1824
+ * When `true`, the player can walk through all character entities (both players and events)
1825
+ * without collision. Walls and obstacles still block movement.
1826
+ * This takes precedence over `throughOtherPlayer` and `throughEvent`.
1827
+ *
1828
+ * @default false
1829
+ *
1830
+ * @example
1831
+ * ```ts
1832
+ * // Enable ghost mode - pass through all characters
1833
+ * player.through = true;
1834
+ *
1835
+ * // Disable ghost mode
1836
+ * player.through = false;
1837
+ * ```
1838
+ */
1839
+ through: boolean;
1840
+
1841
+ /**
1842
+ * Whether the player passes through events (NPCs, objects)
1843
+ *
1844
+ * When `true`, the player can walk through event entities without collision.
1845
+ * This is useful for NPCs that shouldn't block player movement.
1846
+ *
1847
+ * @default false
1848
+ *
1849
+ * @example
1850
+ * ```ts
1851
+ * // Allow passing through events
1852
+ * player.throughEvent = true;
1853
+ *
1854
+ * // Block passage through events
1855
+ * player.throughEvent = false;
1856
+ * ```
1857
+ */
1858
+ throughEvent: boolean;
1859
+
1860
+ /** Frequency for movement timing (milliseconds between movements) */
1861
+ frequency: number;
1862
+
1863
+ /** Whether direction changes are locked (prevents automatic direction changes) */
1864
+ directionFixed: boolean;
1865
+
1866
+ /** Whether animation changes are locked (prevents automatic animation changes) */
1867
+ animationFixed: boolean;
1868
+
1869
+ /**
1870
+ * Add a custom movement strategy to this entity
1871
+ *
1872
+ * Returns a Promise that resolves when the movement completes.
1873
+ *
1874
+ * @param strategy - The movement strategy to add
1875
+ * @param options - Optional callbacks for movement lifecycle events
1876
+ * @returns Promise that resolves when the movement completes
1877
+ */
1878
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void>;
1879
+
1880
+ /**
1881
+ * Remove a specific movement strategy from this entity
1882
+ *
1883
+ * @param strategy - The strategy instance to remove
1884
+ * @returns True if the strategy was found and removed
1885
+ */
1886
+ removeMovement(strategy: MovementStrategy): boolean;
1887
+
1888
+ /**
1889
+ * Remove all active movement strategies from this entity
1890
+ */
1891
+ clearMovements(): void;
1892
+
1893
+ /**
1894
+ * Check if this entity has any active movement strategies
1895
+ *
1896
+ * @returns True if entity has active movements
1897
+ */
1898
+ hasActiveMovements(): boolean;
1899
+
1900
+ /**
1901
+ * Get all active movement strategies for this entity
1902
+ *
1903
+ * @returns Array of active movement strategies
1904
+ */
1905
+ getActiveMovements(): MovementStrategy[];
1906
+
1907
+ /**
1908
+ * Move toward a target player or position using AI pathfinding
1909
+ *
1910
+ * @param target - Target player or position to move toward
1911
+ */
1912
+ moveTo(target: RpgCommonPlayer | { x: number, y: number }): void;
1913
+
1914
+ /**
1915
+ * Stop the current moveTo behavior
1916
+ */
1917
+ stopMoveTo(): void;
1918
+
1919
+ /**
1920
+ * Perform a dash movement in the specified direction
1921
+ *
1922
+ * The total speed is calculated by adding the player's base speed to the additional speed.
1923
+ * Returns a Promise that resolves when the dash completes.
1924
+ *
1925
+ * @param direction - Normalized direction vector
1926
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
1927
+ * @param duration - Duration in milliseconds (default: 200)
1928
+ * @param options - Optional callbacks for movement lifecycle events
1929
+ * @returns Promise that resolves when the dash completes
1930
+ */
1931
+ dash(direction: { x: number, y: number }, additionalSpeed?: number, duration?: number, options?: MovementOptions): Promise<void>;
1932
+
1933
+ /**
1934
+ * Apply knockback effect in the specified direction
1935
+ *
1936
+ * The force is scaled by the player's base speed for consistent behavior.
1937
+ * Returns a Promise that resolves when the knockback completes.
1938
+ *
1939
+ * @param direction - Normalized direction vector
1940
+ * @param force - Force multiplier applied to base speed (default: 5)
1941
+ * @param duration - Duration in milliseconds (default: 300)
1942
+ * @param options - Optional callbacks for movement lifecycle events
1943
+ * @returns Promise that resolves when the knockback completes
1944
+ */
1945
+ knockback(direction: { x: number, y: number }, force?: number, duration?: number, options?: MovementOptions): Promise<void>;
1946
+
1947
+ /**
1948
+ * Follow a sequence of waypoints
1949
+ *
1950
+ * Speed is calculated from the player's base speed multiplied by the speedMultiplier.
1951
+ *
1952
+ * @param waypoints - Array of x,y positions to follow
1953
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
1954
+ * @param loop - Whether to loop back to start (default: false)
1955
+ */
1956
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier?: number, loop?: boolean): void;
1957
+
1958
+ /**
1959
+ * Apply oscillating movement pattern
1960
+ *
1961
+ * @param direction - Primary oscillation axis (normalized)
1962
+ * @param amplitude - Maximum distance from center (default: 50)
1963
+ * @param period - Time for complete cycle in ms (default: 2000)
1964
+ */
1965
+ oscillate(direction: { x: number, y: number }, amplitude?: number, period?: number): void;
1966
+
1967
+ /**
1968
+ * Apply ice movement physics
1969
+ *
1970
+ * Max speed is calculated from the player's base speed multiplied by the speedFactor.
1971
+ *
1972
+ * @param direction - Target movement direction
1973
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
1974
+ */
1975
+ applyIceMovement(direction: { x: number, y: number }, speedFactor?: number): void;
1976
+
1977
+ /**
1978
+ * Shoot a projectile in the specified direction
1979
+ *
1980
+ * Speed is calculated from the player's base speed multiplied by the speedFactor.
1981
+ *
1982
+ * @param type - Type of projectile trajectory
1983
+ * @param direction - Normalized direction vector
1984
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
1985
+ */
1986
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor?: number): void;
1987
+
1988
+ /**
1989
+ * Give an itinerary to follow using movement strategies
1990
+ *
1991
+ * @param routes - Array of movement instructions to execute
1992
+ * @param options - Optional configuration including onStuck callback
1993
+ * @returns Promise that resolves when all routes are completed
1994
+ */
1995
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean>;
1996
+
1997
+ /**
1998
+ * Give a path that repeats itself in a loop to a character
1999
+ *
2000
+ * @param routes - Array of movement instructions to repeat infinitely
2001
+ */
2002
+ infiniteMoveRoute(routes: Routes): void;
2003
+
2004
+ /**
2005
+ * Stop an infinite movement
2006
+ *
2007
+ * @param force - Forces the stop of the infinite movement immediately
2008
+ */
2009
+ breakRoutes(force?: boolean): void;
2010
+
2011
+ /**
2012
+ * Replay an infinite movement
2013
+ */
2014
+ replayRoutes(): void;
1245
2015
  }