@rpgjs/server 5.0.0-alpha.9 → 5.0.0-beta.1

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