@rpgjs/server 5.0.0-alpha.26 → 5.0.0-alpha.28

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.
@@ -2,6 +2,7 @@ import { PlayerCtor, ProjectileType } from "@rpgjs/common";
2
2
  import { RpgCommonPlayer, Direction, Entity } from "@rpgjs/common";
3
3
  import {
4
4
  MovementStrategy,
5
+ MovementOptions,
5
6
  LinearMove,
6
7
  Dash,
7
8
  Knockback,
@@ -14,12 +15,81 @@ import {
14
15
  ProjectileMovement,
15
16
  random,
16
17
  isFunction,
17
- capitalize
18
+ capitalize,
19
+ PerlinNoise2D
18
20
  } from "@rpgjs/common";
21
+ import type { MovementBody } from "@rpgjs/physic";
19
22
  import { RpgMap } from "../rooms/map";
20
- import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from } from 'rxjs';
23
+ import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from, take } from 'rxjs';
21
24
  import { RpgPlayer } from "./Player";
22
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
+
23
93
 
24
94
  interface PlayerWithMixins extends RpgCommonPlayer {
25
95
  getCurrentMap(): RpgMap;
@@ -43,6 +113,51 @@ type CallbackTileMove = (player: RpgPlayer, map) => Direction[]
43
113
  type CallbackTurnMove = (player: RpgPlayer, map) => string
44
114
  type Routes = (string | Promise<any> | Direction | Direction[] | Function)[]
45
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
+
46
161
  export enum Frequency {
47
162
  Lowest = 600,
48
163
  Lower = 400,
@@ -90,7 +205,63 @@ export enum Speed {
90
205
  * Move.turnTowardPlayer(player) | Turns in the direction of the designated player
91
206
  * @memberof Move
92
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
+
93
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
+ }
94
265
 
95
266
  repeatMove(direction: Direction, repeat: number): Direction[] {
96
267
  // Safety check for valid repeat value
@@ -170,33 +341,13 @@ class MoveList {
170
341
  }
171
342
 
172
343
  random(repeat: number = 1): Direction[] {
173
- // Safety check for valid repeat value
174
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 10000) {
175
- console.warn('Invalid repeat value in random:', repeat, 'using default value 1');
176
- repeat = 1;
177
- }
178
-
179
- // Ensure repeat is an integer
180
- repeat = Math.floor(repeat);
181
-
182
- // Additional safety check - ensure repeat is a safe integer
183
- if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
184
- console.warn('Unsafe repeat value in random:', repeat, 'using default value 1');
185
- repeat = 1;
186
- }
187
-
188
- try {
189
- return new Array(repeat).fill(null).map(() => [
344
+ return new Array(repeat).fill(null).map(() => [
190
345
  Direction.Right,
191
346
  Direction.Left,
192
347
  Direction.Up,
193
348
  Direction.Down
194
- ][random(0, 3)]);
195
- } catch (error) {
196
- console.error('Error creating random array with repeat:', repeat, error);
197
- return [Direction.Down]; // Return single direction as fallback
198
- }
199
- }
349
+ ][random(0, 3)])
350
+ }
200
351
 
201
352
  tileRight(repeat: number = 1): CallbackTileMove {
202
353
  return this.repeatTileMove('right', repeat, 'tileWidth')
@@ -216,43 +367,22 @@ class MoveList {
216
367
 
217
368
  tileRandom(repeat: number = 1): CallbackTileMove {
218
369
  return (player: RpgPlayer, map): Direction[] => {
219
- // Safety check for valid repeat value
220
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1000) {
221
- console.warn('Invalid repeat value in tileRandom:', repeat, 'using default value 1');
222
- repeat = 1;
223
- }
224
-
225
- // Ensure repeat is an integer
226
- repeat = Math.floor(repeat);
227
-
228
- let directions: Direction[] = []
229
- for (let i = 0; i < repeat; i++) {
230
- const randFn: CallbackTileMove = [
231
- this.tileRight(),
232
- this.tileLeft(),
233
- this.tileUp(),
234
- this.tileDown()
235
- ][random(0, 3)]
236
-
237
- try {
238
- const newDirections = randFn(player, map);
239
- if (Array.isArray(newDirections)) {
240
- directions = [...directions, ...newDirections];
241
- }
242
- } catch (error) {
243
- console.warn('Error in tileRandom iteration:', error);
244
- // Continue with next iteration instead of breaking
245
- }
246
-
247
- // Safety check to prevent excessive array growth
248
- if (directions.length > 10000) {
249
- console.warn('tileRandom generated too many directions, truncating');
250
- break;
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
+ ]
251
382
  }
252
- }
253
- return directions
383
+ return directions
254
384
  }
255
- }
385
+ }
256
386
 
257
387
  private _awayFromPlayerDirection(player: RpgPlayer, otherPlayer: RpgPlayer): Direction {
258
388
  const directionOtherPlayer = otherPlayer.getDirection()
@@ -364,12 +494,14 @@ class MoveList {
364
494
  }
365
495
 
366
496
  turnRandom(): string {
497
+ // Use Perlin noise for smooth random turn direction with guaranteed variation
498
+ const directionIndex = this.getRandomDirectionIndex();
367
499
  return [
368
500
  this.turnRight(),
369
501
  this.turnLeft(),
370
502
  this.turnUp(),
371
503
  this.turnDown()
372
- ][random(0, 3)]
504
+ ][directionIndex]
373
505
  }
374
506
 
375
507
  turnAwayFromPlayer(otherPlayer: RpgPlayer): CallbackTurnMove {
@@ -512,6 +644,14 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
512
644
  return this._through();
513
645
  }
514
646
 
647
+ set throughEvent(value: boolean) {
648
+ this._throughEvent.set(value);
649
+ }
650
+
651
+ get throughEvent(): boolean {
652
+ return this._throughEvent();
653
+ }
654
+
515
655
  set frequency(value: number) {
516
656
  this._frequency.set(value);
517
657
  }
@@ -520,25 +660,70 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
520
660
  return this._frequency();
521
661
  }
522
662
 
523
- addMovement(strategy: MovementStrategy): void {
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
+
679
+ /**
680
+ * Add a movement strategy to this entity
681
+ *
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.
684
+ *
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
688
+ *
689
+ * @example
690
+ * ```ts
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
+ * });
703
+ * ```
704
+ */
705
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void> {
524
706
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
525
- if (!map) return;
707
+ if (!map) return Promise.resolve();
526
708
 
527
- map.moveManager.add((this as unknown as PlayerWithMixins).id, strategy);
709
+ const playerId = (this as unknown as PlayerWithMixins).id;
710
+ return map.moveManager.add(playerId, strategy, options);
528
711
  }
529
712
 
530
713
  removeMovement(strategy: MovementStrategy): boolean {
531
714
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
532
715
  if (!map) return false;
533
716
 
534
- return map.moveManager.remove((this as unknown as PlayerWithMixins).id, strategy);
717
+ const playerId = (this as unknown as PlayerWithMixins).id;
718
+ return map.moveManager.remove(playerId, strategy);
535
719
  }
536
720
 
537
721
  clearMovements(): void {
538
722
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
539
723
  if (!map) return;
540
724
 
541
- map.moveManager.clear((this as unknown as PlayerWithMixins).id);
725
+ const playerId = (this as unknown as PlayerWithMixins).id;
726
+ map.moveManager.clear(playerId);
542
727
  }
543
728
 
544
729
  hasActiveMovements(): boolean {
@@ -555,17 +740,59 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
555
740
  return map.moveManager.getStrategies((this as unknown as PlayerWithMixins).id);
556
741
  }
557
742
 
743
+ /**
744
+ * Move toward a target player or position using AI pathfinding
745
+ *
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` property, scaled appropriately.
748
+ *
749
+ * @param target - Target player or position `{ x, y }` to move toward
750
+ *
751
+ * @example
752
+ * ```ts
753
+ * // Move toward another player
754
+ * player.moveTo(otherPlayer);
755
+ *
756
+ * // Move toward a specific position
757
+ * player.moveTo({ x: 200, y: 150 });
758
+ * ```
759
+ */
558
760
  moveTo(target: RpgCommonPlayer | { x: number, y: number }): void {
559
761
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
560
762
  if (!map) return;
561
763
 
764
+ const playerId = (this as unknown as PlayerWithMixins).id;
562
765
  const engine = map.physic;
563
766
 
767
+ // Calculate maxSpeed based on player's speed
768
+ // Original values: 180 for player target, 80 for position target (with default speed=4)
769
+ // Factor: 45 for player (180/4), 20 for position (80/4)
770
+ const playerSpeed = (this as any).speed();
771
+
772
+ // Remove ALL movement strategies that could interfere with SeekAvoid
773
+ // This includes SeekAvoid, Dash, Knockback, and LinearRepulsion
774
+ const existingStrategies = this.getActiveMovements();
775
+ const conflictingStrategies = existingStrategies.filter(s =>
776
+ s instanceof SeekAvoid ||
777
+ s instanceof Dash ||
778
+ s instanceof Knockback ||
779
+ s instanceof LinearRepulsion
780
+ );
781
+
782
+ if (conflictingStrategies.length > 0) {
783
+ conflictingStrategies.forEach(s => this.removeMovement(s));
784
+ }
785
+
564
786
  if ('id' in target) {
565
- const targetProvider = () => (map as any).getBody(target.id) ?? null;
787
+ const targetProvider = () => {
788
+ const body = (map as any).getBody(target.id) ?? null;
789
+ return body;
790
+ };
791
+ // Factor 45: with speed=4 gives 180 (original value)
792
+ const maxSpeed = playerSpeed * 45;
566
793
  map.moveManager.add(
567
- (this as unknown as PlayerWithMixins).id,
568
- new SeekAvoid(engine, targetProvider, 180, 140, 80, 48)
794
+ playerId,
795
+ new SeekAvoid(engine, targetProvider, maxSpeed, 140, 80, 48)
569
796
  );
570
797
  return;
571
798
  }
@@ -576,9 +803,11 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
576
803
  });
577
804
  staticTarget.freeze();
578
805
 
806
+ // Factor 20: with speed=4 gives 80 (original value)
807
+ const maxSpeed = playerSpeed * 20;
579
808
  map.moveManager.add(
580
- (this as unknown as PlayerWithMixins).id,
581
- new SeekAvoid(engine, () => staticTarget, 80, 140, 80, 48)
809
+ playerId,
810
+ new SeekAvoid(engine, () => staticTarget, maxSpeed, 140, 80, 48)
582
811
  );
583
812
  }
584
813
 
@@ -586,35 +815,310 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
586
815
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
587
816
  if (!map) return;
588
817
 
818
+ const playerId = (this as unknown as PlayerWithMixins).id;
589
819
  const strategies = this.getActiveMovements();
590
- strategies.forEach(strategy => {
591
- if (strategy instanceof SeekAvoid || strategy instanceof LinearRepulsion) {
820
+ const toRemove = strategies.filter(s => s instanceof SeekAvoid || s instanceof LinearRepulsion);
821
+
822
+ if (toRemove.length > 0) {
823
+ toRemove.forEach(strategy => {
592
824
  this.removeMovement(strategy);
593
- }
594
- });
825
+ });
826
+ }
595
827
  }
596
828
 
597
- dash(direction: { x: number, y: number }, speed: number = 8, duration: number = 200): void {
598
- this.addMovement(new Dash(speed, direction, duration));
829
+ /**
830
+ * Perform a dash movement in the specified direction
831
+ *
832
+ * Creates a burst of velocity for a fixed duration. The total speed is calculated
833
+ * by adding the player's base speed (`this.speed()`) to the additional dash speed.
834
+ * This ensures faster players also dash faster proportionally.
835
+ *
836
+ * With default speed=4 and additionalSpeed=4: total = 8 (same as original default)
837
+ *
838
+ * @param direction - Normalized direction vector `{ x, y }` for the dash
839
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
840
+ * @param duration - Duration in milliseconds (default: 200)
841
+ * @param options - Optional callbacks for movement events
842
+ * @returns Promise that resolves when the dash completes
843
+ *
844
+ * @example
845
+ * ```ts
846
+ * // Dash to the right and wait for completion
847
+ * await player.dash({ x: 1, y: 0 });
848
+ *
849
+ * // Powerful dash with callbacks
850
+ * await player.dash({ x: 0, y: -1 }, 12, 300, {
851
+ * onStart: () => console.log('Dash started!'),
852
+ * onComplete: () => console.log('Dash finished!')
853
+ * });
854
+ * ```
855
+ */
856
+ dash(direction: { x: number, y: number }, additionalSpeed: number = 4, duration: number = 200, options?: MovementOptions): Promise<void> {
857
+ const playerSpeed = (this as any).speed();
858
+ // Total dash speed = base speed + additional speed
859
+ // With speed=4, additionalSpeed=4: gives 8 (original default value)
860
+ const totalSpeed = playerSpeed + additionalSpeed;
861
+ // Physic strategies expect seconds (dt is in seconds), while the server API exposes milliseconds
862
+ const durationSeconds = duration / 1000;
863
+ return this.addMovement(new Dash(totalSpeed, direction, durationSeconds), options);
599
864
  }
600
865
 
601
- knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300): void {
602
- this.addMovement(new Knockback(direction, force, duration));
866
+ /**
867
+ * Apply knockback effect in the specified direction
868
+ *
869
+ * Pushes the entity with an initial force that decays over time.
870
+ * Returns a Promise that resolves when the knockback completes **or is cancelled**.
871
+ *
872
+ * ## Design notes
873
+ * - The underlying physics `MovementManager` can cancel strategies via `remove()`, `clear()`,
874
+ * or `stopMovement()` **without resolving the Promise** returned by `add()`.
875
+ * - For this reason, this method considers the knockback finished when either:
876
+ * - the `add()` promise resolves (normal completion), or
877
+ * - the strategy is no longer present in the active movements list (cancellation).
878
+ * - When multiple knockbacks overlap, `directionFixed` and `animationFixed` are restored
879
+ * only after **all** knockbacks have finished (including cancellations).
880
+ *
881
+ * @param direction - Normalized direction vector `{ x, y }` for the knockback
882
+ * @param force - Initial knockback force (default: 5)
883
+ * @param duration - Duration in milliseconds (default: 300)
884
+ * @param options - Optional callbacks for movement events
885
+ * @returns Promise that resolves when the knockback completes or is cancelled
886
+ *
887
+ * @example
888
+ * ```ts
889
+ * // Simple knockback (await is optional)
890
+ * await player.knockback({ x: 1, y: 0 }, 5, 300);
891
+ *
892
+ * // Overlapping knockbacks: flags are restored only after the last one ends
893
+ * player.knockback({ x: -1, y: 0 }, 5, 300);
894
+ * player.knockback({ x: 0, y: 1 }, 3, 200);
895
+ *
896
+ * // Cancellation (e.g. map change) will still restore fixed flags
897
+ * // even if the underlying movement strategy promise is never resolved.
898
+ * ```
899
+ */
900
+ async knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300, options?: MovementOptions): Promise<void> {
901
+ const durationSeconds = duration / 1000;
902
+ const selfAny = this as any;
903
+ const lockKey = '__rpg_knockback_lock__';
904
+
905
+ type KnockbackLockState = {
906
+ prevDirectionFixed: boolean;
907
+ prevAnimationFixed: boolean;
908
+ prevAnimationName?: string;
909
+ };
910
+
911
+ const getLock = (): KnockbackLockState | undefined => selfAny[lockKey];
912
+ const setLock = (lock: KnockbackLockState): void => {
913
+ selfAny[lockKey] = lock;
914
+ };
915
+ const clearLock = (): void => {
916
+ delete selfAny[lockKey];
917
+ };
918
+
919
+ const hasActiveKnockback = (): boolean =>
920
+ this.getActiveMovements().some(s => s instanceof Knockback || s instanceof AdditiveKnockback);
921
+
922
+ const setAnimationName = (name: string): void => {
923
+ if (typeof selfAny.setAnimation === 'function') {
924
+ selfAny.setAnimation(name);
925
+ return;
926
+ }
927
+ const animSignal = selfAny.animationName;
928
+ if (animSignal && typeof animSignal === 'object' && typeof animSignal.set === 'function') {
929
+ animSignal.set(name);
930
+ }
931
+ };
932
+
933
+ const getAnimationName = (): string | undefined => {
934
+ const animSignal = selfAny.animationName;
935
+ if (typeof animSignal === 'function') {
936
+ try {
937
+ return animSignal();
938
+ } catch {
939
+ return undefined;
940
+ }
941
+ }
942
+ return undefined;
943
+ };
944
+
945
+ const restore = (): void => {
946
+ const lock = getLock();
947
+ if (!lock) return;
948
+
949
+ this.directionFixed = lock.prevDirectionFixed;
950
+
951
+ const prevAnimFixed = lock.prevAnimationFixed;
952
+ this.animationFixed = false; // temporarily unlock so we can restore animation
953
+ if (!prevAnimFixed && lock.prevAnimationName) {
954
+ setAnimationName(lock.prevAnimationName);
955
+ }
956
+ this.animationFixed = prevAnimFixed;
957
+
958
+ clearLock();
959
+ };
960
+
961
+ const ensureLockInitialized = (): void => {
962
+ if (getLock()) return;
963
+
964
+ setLock({
965
+ prevDirectionFixed: this.directionFixed,
966
+ prevAnimationFixed: this.animationFixed,
967
+ prevAnimationName: getAnimationName(),
968
+ });
969
+
970
+ this.directionFixed = true;
971
+ setAnimationName('stand');
972
+ this.animationFixed = true;
973
+ };
974
+
975
+ const waitUntilRemovedOrTimeout = (strategy: MovementStrategy): Promise<void> => {
976
+ return new Promise<void>((resolve) => {
977
+ const start = Date.now();
978
+ const maxMs = Math.max(0, duration + 1000);
979
+
980
+ const intervalId = setInterval(() => {
981
+ const active = this.getActiveMovements();
982
+ if (!active.includes(strategy) || (Date.now() - start > maxMs)) {
983
+ clearInterval(intervalId);
984
+ resolve();
985
+ }
986
+ }, 16);
987
+ });
988
+ };
989
+
990
+ // First knockback creates the lock and freezes direction/animation.
991
+ // Next knockbacks reuse the lock and keep the fixed flags enabled.
992
+ ensureLockInitialized();
993
+
994
+ // Use additive knockback to avoid jitter with player inputs
995
+ const strategy = new AdditiveKnockback(direction, force, durationSeconds);
996
+ const addPromise = this.addMovement(strategy, options);
997
+
998
+ try {
999
+ await Promise.race([addPromise, waitUntilRemovedOrTimeout(strategy)]);
1000
+ } finally {
1001
+ // Restore only when ALL knockbacks are done (including cancellations).
1002
+ if (!hasActiveKnockback()) {
1003
+ restore();
1004
+ }
1005
+ }
603
1006
  }
604
1007
 
605
- followPath(waypoints: Array<{ x: number, y: number }>, speed: number = 2, loop: boolean = false): void {
1008
+ /**
1009
+ * Follow a sequence of waypoints
1010
+ *
1011
+ * Makes the entity move through a list of positions at a speed calculated
1012
+ * from the player's base speed. The `speedMultiplier` allows adjusting
1013
+ * the travel speed relative to the player's normal movement speed.
1014
+ *
1015
+ * With default speed=4 and multiplier=0.5: speed = 2 (same as original default)
1016
+ *
1017
+ * @param waypoints - Array of `{ x, y }` positions to follow in order
1018
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
1019
+ * @param loop - Whether to loop back to start after reaching the end (default: false)
1020
+ *
1021
+ * @example
1022
+ * ```ts
1023
+ * // Follow a patrol path at normal speed
1024
+ * const patrol = [
1025
+ * { x: 100, y: 100 },
1026
+ * { x: 200, y: 100 },
1027
+ * { x: 200, y: 200 }
1028
+ * ];
1029
+ * player.followPath(patrol, 1, true); // Loop at full speed
1030
+ *
1031
+ * // Slow walk through waypoints
1032
+ * player.followPath(waypoints, 0.25, false);
1033
+ * ```
1034
+ */
1035
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier: number = 0.5, loop: boolean = false): void {
1036
+ const playerSpeed = (this as any).speed();
1037
+ // Path follow speed = player base speed * multiplier
1038
+ // With speed=4, multiplier=0.5: gives 2 (original default value)
1039
+ const speed = playerSpeed * speedMultiplier;
606
1040
  this.addMovement(new PathFollow(waypoints, speed, loop));
607
1041
  }
608
1042
 
1043
+ /**
1044
+ * Apply oscillating movement pattern
1045
+ *
1046
+ * Creates a back-and-forth movement along the specified axis. The movement
1047
+ * oscillates sinusoidally between -amplitude and +amplitude from the starting position.
1048
+ *
1049
+ * @param direction - Primary oscillation axis (normalized direction vector)
1050
+ * @param amplitude - Maximum distance from center in pixels (default: 50)
1051
+ * @param period - Time for a complete cycle in milliseconds (default: 2000)
1052
+ *
1053
+ * @example
1054
+ * ```ts
1055
+ * // Horizontal oscillation
1056
+ * player.oscillate({ x: 1, y: 0 }, 100, 3000);
1057
+ *
1058
+ * // Diagonal bobbing motion
1059
+ * player.oscillate({ x: 1, y: 1 }, 30, 1000);
1060
+ * ```
1061
+ */
609
1062
  oscillate(direction: { x: number, y: number }, amplitude: number = 50, period: number = 2000): void {
610
1063
  this.addMovement(new Oscillate(direction, amplitude, period));
611
1064
  }
612
1065
 
613
- applyIceMovement(direction: { x: number, y: number }, maxSpeed: number = 4): void {
1066
+ /**
1067
+ * Apply ice movement physics
1068
+ *
1069
+ * Simulates slippery surface physics where the entity accelerates gradually
1070
+ * and has difficulty stopping. The maximum speed is based on the player's
1071
+ * base speed multiplied by a speed factor.
1072
+ *
1073
+ * With default speed=4 and factor=1: maxSpeed = 4 (same as original default)
1074
+ *
1075
+ * @param direction - Target movement direction `{ x, y }`
1076
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
1077
+ *
1078
+ * @example
1079
+ * ```ts
1080
+ * // Normal ice physics
1081
+ * player.applyIceMovement({ x: 1, y: 0 });
1082
+ *
1083
+ * // Fast ice sliding
1084
+ * player.applyIceMovement({ x: 0, y: 1 }, 1.5);
1085
+ * ```
1086
+ */
1087
+ applyIceMovement(direction: { x: number, y: number }, speedFactor: number = 1): void {
1088
+ const playerSpeed = (this as any).speed();
1089
+ // Max ice speed = player base speed * factor
1090
+ // With speed=4, factor=1: gives 4 (original default value)
1091
+ const maxSpeed = playerSpeed * speedFactor;
614
1092
  this.addMovement(new IceMovement(direction, maxSpeed));
615
1093
  }
616
1094
 
617
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed: number = 200): void {
1095
+ /**
1096
+ * Shoot a projectile in the specified direction
1097
+ *
1098
+ * Creates a projectile with ballistic trajectory. The speed is calculated
1099
+ * from the player's base speed multiplied by a speed factor.
1100
+ *
1101
+ * With default speed=4 and factor=50: speed = 200 (same as original default)
1102
+ *
1103
+ * @param type - Type of projectile trajectory (`Straight`, `Arc`, or `Bounce`)
1104
+ * @param direction - Normalized direction vector `{ x, y }`
1105
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
1106
+ *
1107
+ * @example
1108
+ * ```ts
1109
+ * // Straight projectile
1110
+ * player.shootProjectile(ProjectileType.Straight, { x: 1, y: 0 });
1111
+ *
1112
+ * // Fast arc projectile
1113
+ * player.shootProjectile(ProjectileType.Arc, { x: 1, y: -0.5 }, 75);
1114
+ * ```
1115
+ */
1116
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor: number = 50): void {
1117
+ const playerSpeed = (this as any).speed();
1118
+ // Projectile speed = player base speed * factor
1119
+ // With speed=4, factor=50: gives 200 (original default value)
1120
+ const speed = playerSpeed * speedFactor;
1121
+
618
1122
  const config = {
619
1123
  speed,
620
1124
  direction,
@@ -628,9 +1132,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
628
1132
  this.addMovement(new ProjectileMovement(type, config));
629
1133
  }
630
1134
 
631
- moveRoutes(routes: Routes): Promise<boolean> {
632
- let count = 0;
633
- let frequence = 0;
1135
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean> {
634
1136
  const player = this as unknown as PlayerWithMixins;
635
1137
 
636
1138
  // Break any existing route movement
@@ -641,122 +1143,529 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
641
1143
  this._finishRoute = resolve;
642
1144
 
643
1145
  // Process function routes first
644
- const processedRoutes = routes.map((route: any) => {
645
- if (typeof route === 'function') {
646
- const map = player.getCurrentMap() as any;
647
- if (!map) {
648
- return undefined;
1146
+ const processedRoutes = await Promise.all(
1147
+ routes.map(async (route: any) => {
1148
+ if (typeof route === 'function') {
1149
+ const map = player.getCurrentMap() as any;
1150
+ if (!map) {
1151
+ return undefined;
1152
+ }
1153
+ return route.apply(route, [player, map]);
649
1154
  }
650
- return route.apply(route, [player, map]);
651
- }
652
- return route;
653
- });
1155
+ return route;
1156
+ })
1157
+ );
654
1158
 
655
1159
  // Flatten nested arrays
656
- const flatRoutes = this.flattenRoutes(processedRoutes);
657
- let routeIndex = 0;
1160
+ // Note: We keep promises in the routes array and handle them in the strategy
1161
+ const finalRoutes = this.flattenRoutes(processedRoutes);
1162
+
1163
+ // Create a movement strategy that handles all routes
1164
+ class RouteMovementStrategy implements MovementStrategy {
1165
+ private routeIndex = 0;
1166
+ private currentTarget: { x: number; y: number } | null = null; // Center position for physics
1167
+ private currentTargetTopLeft: { x: number; y: number } | null = null; // Top-left position for player.x() comparison
1168
+ private currentDirection: { x: number; y: number } = { x: 0, y: 0 };
1169
+ private finished = false;
1170
+ private waitingForPromise = false;
1171
+ private promiseStartTime = 0;
1172
+ private promiseDuration = 0;
1173
+ private readonly routes: Routes;
1174
+ private readonly player: PlayerWithMixins;
1175
+ private readonly onComplete: (success: boolean) => void;
1176
+ private readonly tileSize: number;
1177
+ private readonly tolerance: number;
1178
+ private readonly onStuck?: MoveRoutesOptions['onStuck'];
1179
+ private readonly stuckTimeout: number;
1180
+ private readonly stuckThreshold: number;
1181
+
1182
+ // Frequency wait state
1183
+ private waitingForFrequency = false;
1184
+ private frequencyWaitStartTime = 0;
1185
+ private ratioFrequency = 15;
1186
+
1187
+ // Stuck detection state
1188
+ private lastPosition: { x: number; y: number } | null = null;
1189
+ private lastPositionTime: number = 0;
1190
+ private stuckCheckStartTime: number = 0;
1191
+ private lastDistanceToTarget: number | null = null;
1192
+ private isCurrentlyStuck: boolean = false;
1193
+ private stuckCheckInitialized: boolean = false;
1194
+
1195
+ constructor(
1196
+ routes: Routes,
1197
+ player: PlayerWithMixins,
1198
+ onComplete: (success: boolean) => void,
1199
+ options?: MoveRoutesOptions
1200
+ ) {
1201
+ this.routes = routes;
1202
+ this.player = player;
1203
+ this.onComplete = onComplete;
1204
+ this.tileSize = player.nbPixelInTile || 32;
1205
+ this.tolerance = 0.5; // Tolerance in pixels for reaching target (reduced for precision)
1206
+ this.onStuck = options?.onStuck;
1207
+ this.stuckTimeout = options?.stuckTimeout ?? 500; // Default 500ms
1208
+ this.stuckThreshold = options?.stuckThreshold ?? 1; // Default 1 pixel
1209
+
1210
+ // Process initial route
1211
+ this.processNextRoute();
1212
+ }
658
1213
 
659
- const executeNextRoute = async (): Promise<void> => {
660
- // Check if player still exists and is on a map
661
- if (!player || !player.getCurrentMap()) {
662
- this._finishRoute = null;
663
- resolve(false);
664
- return;
1214
+ private debugLog(message: string, data?: any): void {
1215
+ // Debug logging disabled - enable if needed for troubleshooting
665
1216
  }
666
1217
 
667
- // Handle frequency timing
668
- if (count >= (player.nbPixelInTile || 32)) {
669
- if (frequence < (player.frequency || 0)) {
670
- frequence++;
671
- setTimeout(executeNextRoute, 16); // ~60fps timing
1218
+ private processNextRoute(): void {
1219
+ // Reset frequency wait state when processing a new route
1220
+ this.waitingForFrequency = false;
1221
+ this.frequencyWaitStartTime = 0;
1222
+
1223
+ // Check if we've completed all routes
1224
+ if (this.routeIndex >= this.routes.length) {
1225
+ this.debugLog('COMPLETE all routes finished');
1226
+ this.finished = true;
1227
+ this.onComplete(true);
672
1228
  return;
673
1229
  }
674
- }
675
1230
 
676
- frequence = 0;
677
- count++;
1231
+ const currentRoute = this.routes[this.routeIndex];
1232
+ this.routeIndex++;
678
1233
 
679
- // Check if we've completed all routes
680
- if (routeIndex >= flatRoutes.length) {
681
- this._finishRoute = null;
682
- resolve(true);
683
- return;
684
- }
1234
+ if (currentRoute === undefined) {
1235
+ this.processNextRoute();
1236
+ return;
1237
+ }
1238
+
1239
+ try {
1240
+ // Handle different route types
1241
+ if (typeof currentRoute === 'object' && 'then' in currentRoute) {
1242
+ // Handle Promise (like Move.wait())
1243
+ this.debugLog(`WAIT for promise (route ${this.routeIndex}/${this.routes.length})`);
1244
+ this.waitingForPromise = true;
1245
+ this.promiseStartTime = Date.now();
1246
+
1247
+ // Try to get duration from promise if possible (for Move.wait())
1248
+ // Move.wait() creates a promise that resolves after a delay
1249
+ // We'll use a default duration and let the promise resolve naturally
1250
+ this.promiseDuration = 1000; // Default 1 second, will be updated when promise resolves
1251
+
1252
+ // Set up promise resolution handler
1253
+ (currentRoute as Promise<any>).then(() => {
1254
+ this.debugLog('WAIT promise resolved');
1255
+ this.waitingForPromise = false;
1256
+ this.processNextRoute();
1257
+ }).catch(() => {
1258
+ this.debugLog('WAIT promise rejected');
1259
+ this.waitingForPromise = false;
1260
+ this.processNextRoute();
1261
+ });
1262
+ } else if (typeof currentRoute === 'string' && currentRoute.startsWith('turn-')) {
1263
+ // Handle turn commands - just change direction, no movement
1264
+ const directionStr = currentRoute.replace('turn-', '');
1265
+ let direction: Direction = Direction.Down;
1266
+
1267
+ switch (directionStr) {
1268
+ case 'up':
1269
+ case Direction.Up:
1270
+ direction = Direction.Up;
1271
+ break;
1272
+ case 'down':
1273
+ case Direction.Down:
1274
+ direction = Direction.Down;
1275
+ break;
1276
+ case 'left':
1277
+ case Direction.Left:
1278
+ direction = Direction.Left;
1279
+ break;
1280
+ case 'right':
1281
+ case Direction.Right:
1282
+ direction = Direction.Right;
1283
+ break;
1284
+ }
685
1285
 
686
- const currentRoute = flatRoutes[routeIndex];
687
- routeIndex++;
1286
+ this.debugLog(`TURN to ${directionStr}`);
1287
+ if (this.player.changeDirection) {
1288
+ this.player.changeDirection(direction);
1289
+ }
1290
+ // Turn is instant, continue immediately
1291
+ this.processNextRoute();
1292
+ } else if (typeof currentRoute === 'number' || typeof currentRoute === 'string') {
1293
+ // Handle Direction enum values (number or string) - calculate target position
1294
+ const moveDirection = currentRoute as unknown as Direction;
1295
+ const map = this.player.getCurrentMap() as any;
1296
+ if (!map) {
1297
+ this.finished = true;
1298
+ this.onComplete(false);
1299
+ return;
1300
+ }
688
1301
 
689
- if (currentRoute === undefined) {
690
- executeNextRoute();
691
- return;
1302
+ // Get current position (top-left from player, which is what player.x() returns)
1303
+ // We calculate target based on top-left position to match player.x() expectations
1304
+ const currentTopLeftX = typeof this.player.x === 'function' ? this.player.x() : this.player.x;
1305
+ const currentTopLeftY = typeof this.player.y === 'function' ? this.player.y() : this.player.y;
1306
+
1307
+ // Get player speed
1308
+ let playerSpeed = this.player.speed()
1309
+
1310
+ // Use player speed as distance, not tile size
1311
+ let distance = playerSpeed;
1312
+
1313
+ // Merge consecutive routes of same direction
1314
+ const initialDistance = distance;
1315
+ const initialRouteIndex = this.routeIndex;
1316
+ while (this.routeIndex < this.routes.length) {
1317
+ const nextRoute = this.routes[this.routeIndex];
1318
+ if (nextRoute === currentRoute) {
1319
+ distance += playerSpeed;
1320
+ this.routeIndex++;
1321
+ } else {
1322
+ break;
1323
+ }
1324
+ }
1325
+
1326
+ // Calculate target top-left position
1327
+ let targetTopLeftX = currentTopLeftX;
1328
+ let targetTopLeftY = currentTopLeftY;
1329
+
1330
+ switch (moveDirection) {
1331
+ case Direction.Right:
1332
+ case 'right' as any:
1333
+ targetTopLeftX = currentTopLeftX + distance;
1334
+ break;
1335
+ case Direction.Left:
1336
+ case 'left' as any:
1337
+ targetTopLeftX = currentTopLeftX - distance;
1338
+ break;
1339
+ case Direction.Down:
1340
+ case 'down' as any:
1341
+ targetTopLeftY = currentTopLeftY + distance;
1342
+ break;
1343
+ case Direction.Up:
1344
+ case 'up' as any:
1345
+ targetTopLeftY = currentTopLeftY - distance;
1346
+ break;
1347
+ }
1348
+
1349
+ // Convert target top-left to center position for physics engine
1350
+ // Get entity to access hitbox dimensions
1351
+ const entity = map.physic.getEntityByUUID(this.player.id);
1352
+ if (!entity) {
1353
+ this.finished = true;
1354
+ this.onComplete(false);
1355
+ return;
1356
+ }
1357
+
1358
+ // Get hitbox dimensions for conversion
1359
+ const hitbox = this.player.hitbox();
1360
+ const hitboxWidth = hitbox?.w ?? 32;
1361
+ const hitboxHeight = hitbox?.h ?? 32;
1362
+
1363
+ // Convert top-left to center: center = topLeft + (size / 2)
1364
+ const targetX = targetTopLeftX + hitboxWidth / 2;
1365
+ const targetY = targetTopLeftY + hitboxHeight / 2;
1366
+
1367
+ this.currentTarget = { x: targetX, y: targetY }; // Center position for physics engine
1368
+ this.currentTargetTopLeft = { x: targetTopLeftX, y: targetTopLeftY }; // Top-left position for player.x() comparison
1369
+ this.currentDirection = { x: 0, y: 0 };
1370
+
1371
+ this.debugLog(`MOVE direction=${moveDirection} from=(${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)}) to=(${targetTopLeftX.toFixed(1)}, ${targetTopLeftY.toFixed(1)}) dist=${distance.toFixed(1)}`);
1372
+
1373
+ // Reset stuck detection when starting a new movement
1374
+ this.lastPosition = null;
1375
+ this.isCurrentlyStuck = false;
1376
+ this.stuckCheckStartTime = 0;
1377
+ this.lastDistanceToTarget = null;
1378
+ this.stuckCheckInitialized = false;
1379
+ // Reset frequency wait state when starting a new movement
1380
+ this.waitingForFrequency = false;
1381
+ this.frequencyWaitStartTime = 0;
1382
+ } else if (Array.isArray(currentRoute)) {
1383
+ // Handle array of directions - insert them into routes
1384
+ for (let i = currentRoute.length - 1; i >= 0; i--) {
1385
+ this.routes.splice(this.routeIndex, 0, currentRoute[i]);
1386
+ }
1387
+ this.processNextRoute();
1388
+ } else {
1389
+ // Unknown route type, skip
1390
+ this.processNextRoute();
1391
+ }
1392
+ } catch (error) {
1393
+ console.warn('Error processing route:', error);
1394
+ this.processNextRoute();
1395
+ }
692
1396
  }
693
1397
 
694
- try {
695
- // Handle different route types
696
- if (typeof currentRoute === 'object' && 'then' in currentRoute) {
697
- // Handle Promise
698
- await currentRoute;
699
- executeNextRoute();
700
- } else if (typeof currentRoute === 'string' && currentRoute.startsWith('turn-')) {
701
- // Handle turn commands
702
- const directionStr = currentRoute.replace('turn-', '');
703
- let direction: Direction = Direction.Down;
704
-
705
- // Convert string direction to Direction enum
706
- switch (directionStr) {
707
- case 'up':
708
- case Direction.Up:
709
- direction = Direction.Up;
710
- break;
711
- case 'down':
712
- case Direction.Down:
713
- direction = Direction.Down;
714
- break;
715
- case 'left':
716
- case Direction.Left:
717
- direction = Direction.Left;
718
- break;
719
- case 'right':
720
- case Direction.Right:
721
- direction = Direction.Right;
722
- break;
1398
+ update(body: MovementBody, dt: number): void {
1399
+ // Don't process if waiting for promise
1400
+ if (this.waitingForPromise) {
1401
+ body.setVelocity({ x: 0, y: 0 });
1402
+ // Check if promise wait time has elapsed (fallback)
1403
+ if (Date.now() - this.promiseStartTime > this.promiseDuration) {
1404
+ this.waitingForPromise = false;
1405
+ this.processNextRoute();
723
1406
  }
1407
+ return;
1408
+ }
1409
+
1410
+ // Don't process if waiting for frequency delay
1411
+ if (this.waitingForFrequency) {
1412
+ body.setVelocity({ x: 0, y: 0 });
1413
+ const playerFrequency = this.player.frequency;
1414
+ const frequencyMs = playerFrequency || 0;
724
1415
 
725
- if (player.changeDirection) {
726
- player.changeDirection(direction);
1416
+ if (frequencyMs > 0 && Date.now() - this.frequencyWaitStartTime >= frequencyMs * this.ratioFrequency) {
1417
+ this.waitingForFrequency = false;
1418
+ this.processNextRoute();
727
1419
  }
728
- executeNextRoute();
729
- } else if (typeof currentRoute === 'number') {
730
- // Handle Direction enum values
731
- if (player.moveByDirection) {
732
- await player.moveByDirection(currentRoute as unknown as Direction, 1);
733
- } else {
734
- // Fallback to movement strategy - use direction as velocity components
735
- let vx = 0, vy = 0;
736
- const direction = currentRoute as unknown as Direction;
737
- switch (direction) {
738
- case Direction.Right: vx = 1; break;
739
- case Direction.Left: vx = -1; break;
740
- case Direction.Down: vy = 1; break;
741
- case Direction.Up: vy = -1; break;
1420
+ return;
1421
+ }
1422
+
1423
+ // If no target, try to process next route
1424
+ if (!this.currentTarget) {
1425
+ if (!this.finished) {
1426
+ this.processNextRoute();
1427
+ }
1428
+ if (!this.currentTarget) {
1429
+ body.setVelocity({ x: 0, y: 0 });
1430
+ // Reset stuck detection when no target
1431
+ this.lastPosition = null;
1432
+ this.isCurrentlyStuck = false;
1433
+ this.lastDistanceToTarget = null;
1434
+ this.stuckCheckInitialized = false;
1435
+ this.currentTargetTopLeft = null;
1436
+ return;
1437
+ }
1438
+ }
1439
+
1440
+ const entity = body.getEntity?.();
1441
+ if (!entity) {
1442
+ this.finished = true;
1443
+ this.onComplete(false);
1444
+ return;
1445
+ }
1446
+
1447
+ const currentPosition = { x: entity.position.x, y: entity.position.y };
1448
+ const currentTime = Date.now();
1449
+
1450
+ // Check distance using player's top-left position (what player.x() returns)
1451
+ // This ensures we match the test expectations which compare player.x()
1452
+ const currentTopLeftX = this.player.x();
1453
+ const currentTopLeftY = this.player.y()
1454
+
1455
+ // Calculate direction and distance using top-left position if available
1456
+ let dx: number, dy: number, distance: number;
1457
+ if (this.currentTargetTopLeft) {
1458
+ dx = this.currentTargetTopLeft.x - currentTopLeftX;
1459
+ dy = this.currentTargetTopLeft.y - currentTopLeftY;
1460
+ distance = Math.hypot(dx, dy);
1461
+
1462
+ // Check if we've reached the target (using top-left position)
1463
+ if (distance <= this.tolerance) {
1464
+ // Target reached, wait for frequency before processing next route
1465
+ this.debugLog(`TARGET reached at (${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)})`);
1466
+ this.currentTarget = null;
1467
+ this.currentTargetTopLeft = null;
1468
+ this.currentDirection = { x: 0, y: 0 };
1469
+ body.setVelocity({ x: 0, y: 0 });
1470
+ // Reset stuck detection
1471
+ this.lastPosition = null;
1472
+ this.isCurrentlyStuck = false;
1473
+ this.lastDistanceToTarget = null;
1474
+ this.stuckCheckInitialized = false;
1475
+
1476
+ // Wait for frequency before processing next route
1477
+ if (!this.finished) {
1478
+ const playerFrequency = this.player.frequency;
1479
+ if (playerFrequency && playerFrequency > 0) {
1480
+ this.waitingForFrequency = true;
1481
+ this.frequencyWaitStartTime = Date.now();
1482
+ } else {
1483
+ // No frequency delay, process immediately
1484
+ this.processNextRoute();
1485
+ }
1486
+ }
1487
+ return;
1488
+ }
1489
+ } else {
1490
+ // Fallback: use center position distance if top-left target not available
1491
+ dx = this.currentTarget.x - currentPosition.x;
1492
+ dy = this.currentTarget.y - currentPosition.y;
1493
+ distance = Math.hypot(dx, dy);
1494
+
1495
+ // Check if we've reached the target (using center position as fallback)
1496
+ if (distance <= this.tolerance) {
1497
+ // Target reached, wait for frequency before processing next route
1498
+ this.currentTarget = null;
1499
+ this.currentTargetTopLeft = null;
1500
+ this.currentDirection = { x: 0, y: 0 };
1501
+ body.setVelocity({ x: 0, y: 0 });
1502
+ // Reset stuck detection
1503
+ this.lastPosition = null;
1504
+ this.isCurrentlyStuck = false;
1505
+ this.lastDistanceToTarget = null;
1506
+ this.stuckCheckInitialized = false;
1507
+
1508
+ // Wait for frequency before processing next route
1509
+ if (!this.finished) {
1510
+ const playerFrequency = player.frequency;
1511
+ if (playerFrequency && playerFrequency > 0) {
1512
+ this.waitingForFrequency = true;
1513
+ this.frequencyWaitStartTime = Date.now();
1514
+ } else {
1515
+ // No frequency delay, process immediately
1516
+ this.processNextRoute();
1517
+ }
742
1518
  }
743
- const speed = player.speed?.() ?? 3;
744
- this.addMovement(new LinearMove({ x: vx * speed, y: vy * speed }, 0.1));
745
- setTimeout(executeNextRoute, 100);
746
1519
  return;
747
1520
  }
748
- executeNextRoute();
1521
+ }
1522
+
1523
+ // Stuck detection: check if player is making progress
1524
+ if (this.onStuck && this.currentTarget) {
1525
+ // Initialize tracking on first update
1526
+ if (!this.stuckCheckInitialized) {
1527
+ this.lastPosition = { ...currentPosition };
1528
+ this.lastDistanceToTarget = distance;
1529
+ this.stuckCheckInitialized = true;
1530
+ // Update tracking and continue (don't return early)
1531
+ this.lastPositionTime = currentTime;
1532
+ } else if (this.lastPosition && this.lastDistanceToTarget !== null) {
1533
+ // We have a target, so we're trying to move (regardless of current velocity,
1534
+ // which may be zero due to physics engine collision handling)
1535
+ const positionChanged = Math.hypot(
1536
+ currentPosition.x - this.lastPosition.x,
1537
+ currentPosition.y - this.lastPosition.y
1538
+ ) > this.stuckThreshold;
1539
+
1540
+ const distanceImproved = distance < (this.lastDistanceToTarget - this.stuckThreshold);
1541
+
1542
+ // Player is stuck if: not moving AND not getting closer to target
1543
+ if (!positionChanged && !distanceImproved) {
1544
+ // Player is not making progress
1545
+ if (!this.isCurrentlyStuck) {
1546
+ // Start stuck timer
1547
+ this.stuckCheckStartTime = currentTime;
1548
+ this.isCurrentlyStuck = true;
1549
+ } else {
1550
+ // Check if stuck timeout has elapsed
1551
+ if (currentTime - this.stuckCheckStartTime >= this.stuckTimeout) {
1552
+ // Player is stuck, call onStuck callback
1553
+ this.debugLog(`STUCK detected at (${currentPosition.x.toFixed(1)}, ${currentPosition.y.toFixed(1)}) target=(${this.currentTarget.x.toFixed(1)}, ${this.currentTarget.y.toFixed(1)})`);
1554
+ const shouldContinue = this.onStuck(
1555
+ this.player as any,
1556
+ this.currentTarget,
1557
+ currentPosition
1558
+ );
1559
+
1560
+ if (shouldContinue === false) {
1561
+ // Cancel the route
1562
+ this.debugLog('STUCK cancelled route');
1563
+ this.finished = true;
1564
+ this.onComplete(false);
1565
+ body.setVelocity({ x: 0, y: 0 });
1566
+ return;
1567
+ }
1568
+
1569
+ // Continue route: abandon the current target and move on.
1570
+ //
1571
+ // ## Why?
1572
+ // When random movement hits an obstacle, the physics engine can keep the entity
1573
+ // at the same position while this strategy keeps trying to reach the same target,
1574
+ // resulting in an infinite "stuck loop". By dropping the current target here,
1575
+ // we allow the route to advance and (in the common case of `infiniteMoveRoute`)
1576
+ // re-evaluate `Move.tileRandom()` on the next cycle, producing a new direction.
1577
+ this.currentTarget = null;
1578
+ this.currentTargetTopLeft = null;
1579
+ this.currentDirection = { x: 0, y: 0 };
1580
+ body.setVelocity({ x: 0, y: 0 });
1581
+
1582
+ // Reset stuck detection to start fresh on the next target
1583
+ this.isCurrentlyStuck = false;
1584
+ this.stuckCheckStartTime = 0;
1585
+ this.lastPosition = null;
1586
+ this.lastDistanceToTarget = null;
1587
+ this.stuckCheckInitialized = false;
1588
+
1589
+ // Advance to next route instruction immediately
1590
+ this.processNextRoute();
1591
+ return;
1592
+ }
1593
+ }
1594
+ } else {
1595
+ // Player is making progress, reset stuck detection
1596
+ this.isCurrentlyStuck = false;
1597
+ this.stuckCheckStartTime = 0;
1598
+ }
1599
+
1600
+ // Update tracking variables
1601
+ this.lastPosition = { ...currentPosition };
1602
+ this.lastPositionTime = currentTime;
1603
+ this.lastDistanceToTarget = distance;
1604
+ }
1605
+ }
1606
+
1607
+ // Get speed scalar from map (default 50 if not found)
1608
+ const map = this.player.getCurrentMap() as any;
1609
+ const speedScalar = map?.speedScalar ?? 50;
1610
+
1611
+ // Calculate direction and speed
1612
+ // Use the distance calculated above (from top-left if available, center otherwise)
1613
+ if (distance > 0) {
1614
+ this.currentDirection = { x: dx / distance, y: dy / distance };
1615
+ } else {
1616
+ // If distance is 0 or negative, we've reached or passed the target
1617
+ this.currentTarget = null;
1618
+ this.currentTargetTopLeft = null;
1619
+ this.currentDirection = { x: 0, y: 0 };
1620
+ body.setVelocity({ x: 0, y: 0 });
1621
+ if (!this.finished) {
1622
+ const playerFrequency = typeof this.player.frequency === 'function' ? this.player.frequency() : this.player.frequency;
1623
+ if (playerFrequency && playerFrequency > 0) {
1624
+ this.waitingForFrequency = true;
1625
+ this.frequencyWaitStartTime = Date.now();
1626
+ } else {
1627
+ // No frequency delay, process immediately
1628
+ this.processNextRoute();
1629
+ }
1630
+ }
1631
+ return;
1632
+ }
1633
+
1634
+ // Convert vector direction to cardinal direction (like moveBody does)
1635
+ const absX = Math.abs(this.currentDirection.x);
1636
+ const absY = Math.abs(this.currentDirection.y);
1637
+ let cardinalDirection: Direction;
1638
+
1639
+ if (absX >= absY) {
1640
+ cardinalDirection = this.currentDirection.x >= 0 ? Direction.Right : Direction.Left;
749
1641
  } else {
750
- // Unknown route type, skip
751
- executeNextRoute();
1642
+ cardinalDirection = this.currentDirection.y >= 0 ? Direction.Down : Direction.Up;
752
1643
  }
753
- } catch (error) {
754
- console.warn('Error executing route:', error);
755
- executeNextRoute();
1644
+
1645
+ map.movePlayer(this.player as any, cardinalDirection)
756
1646
  }
757
- };
758
1647
 
759
- executeNextRoute();
1648
+ isFinished(): boolean {
1649
+ return this.finished;
1650
+ }
1651
+
1652
+ onFinished(): void {
1653
+ this.onComplete(true);
1654
+ }
1655
+ }
1656
+
1657
+ // Create and add the route movement strategy
1658
+ const routeStrategy = new RouteMovementStrategy(
1659
+ finalRoutes,
1660
+ player,
1661
+ (success: boolean) => {
1662
+ this._finishRoute = null;
1663
+ resolve(success);
1664
+ },
1665
+ options
1666
+ );
1667
+
1668
+ this.addMovement(routeStrategy);
760
1669
  });
761
1670
  }
762
1671
 
@@ -769,14 +1678,14 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
769
1678
  }, []);
770
1679
  }
771
1680
 
772
- infiniteMoveRoute(routes: Routes): void {
1681
+ infiniteMoveRoute(routes: Routes, options?: MoveRoutesOptions): void {
773
1682
  this._infiniteRoutes = routes;
774
1683
  this._isInfiniteRouteActive = true;
775
1684
 
776
1685
  const executeInfiniteRoute = (isBreaking: boolean = false) => {
777
1686
  if (isBreaking || !this._isInfiniteRouteActive) return;
778
1687
 
779
- this.moveRoutes(routes).then((completed) => {
1688
+ this.moveRoutes(routes, options).then((completed) => {
780
1689
  // Only continue if the route completed successfully and we're still active
781
1690
  if (completed && this._isInfiniteRouteActive) {
782
1691
  executeInfiniteRoute();
@@ -817,7 +1726,6 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
817
1726
 
818
1727
  return WithMoveManagerClass as unknown as PlayerCtor;
819
1728
  }
820
-
821
1729
  /**
822
1730
  * Interface for Move Manager functionality
823
1731
  *
@@ -826,21 +1734,83 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
826
1734
  * This interface defines the public API of the MoveManager mixin.
827
1735
  */
828
1736
  export interface IMoveManager {
829
- /** Whether the player passes through other players */
1737
+ /**
1738
+ * Whether the player passes through other players
1739
+ *
1740
+ * When `true`, the player can walk through other player entities without collision.
1741
+ * This is useful for busy areas where players shouldn't block each other.
1742
+ *
1743
+ * @default true
1744
+ *
1745
+ * @example
1746
+ * ```ts
1747
+ * // Disable player-to-player collision
1748
+ * player.throughOtherPlayer = true;
1749
+ *
1750
+ * // Enable player-to-player collision
1751
+ * player.throughOtherPlayer = false;
1752
+ * ```
1753
+ */
830
1754
  throughOtherPlayer: boolean;
831
1755
 
832
- /** Whether the player goes through events or other players */
1756
+ /**
1757
+ * Whether the player goes through all characters (players and events)
1758
+ *
1759
+ * When `true`, the player can walk through all character entities (both players and events)
1760
+ * without collision. Walls and obstacles still block movement.
1761
+ * This takes precedence over `throughOtherPlayer` and `throughEvent`.
1762
+ *
1763
+ * @default false
1764
+ *
1765
+ * @example
1766
+ * ```ts
1767
+ * // Enable ghost mode - pass through all characters
1768
+ * player.through = true;
1769
+ *
1770
+ * // Disable ghost mode
1771
+ * player.through = false;
1772
+ * ```
1773
+ */
833
1774
  through: boolean;
834
1775
 
1776
+ /**
1777
+ * Whether the player passes through events (NPCs, objects)
1778
+ *
1779
+ * When `true`, the player can walk through event entities without collision.
1780
+ * This is useful for NPCs that shouldn't block player movement.
1781
+ *
1782
+ * @default false
1783
+ *
1784
+ * @example
1785
+ * ```ts
1786
+ * // Allow passing through events
1787
+ * player.throughEvent = true;
1788
+ *
1789
+ * // Block passage through events
1790
+ * player.throughEvent = false;
1791
+ * ```
1792
+ */
1793
+ throughEvent: boolean;
1794
+
835
1795
  /** Frequency for movement timing (milliseconds between movements) */
836
1796
  frequency: number;
837
1797
 
1798
+ /** Whether direction changes are locked (prevents automatic direction changes) */
1799
+ directionFixed: boolean;
1800
+
1801
+ /** Whether animation changes are locked (prevents automatic animation changes) */
1802
+ animationFixed: boolean;
1803
+
838
1804
  /**
839
1805
  * Add a custom movement strategy to this entity
840
1806
  *
1807
+ * Returns a Promise that resolves when the movement completes.
1808
+ *
841
1809
  * @param strategy - The movement strategy to add
1810
+ * @param options - Optional callbacks for movement lifecycle events
1811
+ * @returns Promise that resolves when the movement completes
842
1812
  */
843
- addMovement(strategy: MovementStrategy): void;
1813
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void>;
844
1814
 
845
1815
  /**
846
1816
  * Remove a specific movement strategy from this entity
@@ -884,29 +1854,41 @@ export interface IMoveManager {
884
1854
  /**
885
1855
  * Perform a dash movement in the specified direction
886
1856
  *
1857
+ * The total speed is calculated by adding the player's base speed to the additional speed.
1858
+ * Returns a Promise that resolves when the dash completes.
1859
+ *
887
1860
  * @param direction - Normalized direction vector
888
- * @param speed - Movement speed (default: 8)
1861
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
889
1862
  * @param duration - Duration in milliseconds (default: 200)
1863
+ * @param options - Optional callbacks for movement lifecycle events
1864
+ * @returns Promise that resolves when the dash completes
890
1865
  */
891
- dash(direction: { x: number, y: number }, speed?: number, duration?: number): void;
1866
+ dash(direction: { x: number, y: number }, additionalSpeed?: number, duration?: number, options?: MovementOptions): Promise<void>;
892
1867
 
893
1868
  /**
894
1869
  * Apply knockback effect in the specified direction
895
1870
  *
1871
+ * The force is scaled by the player's base speed for consistent behavior.
1872
+ * Returns a Promise that resolves when the knockback completes.
1873
+ *
896
1874
  * @param direction - Normalized direction vector
897
- * @param force - Initial knockback force (default: 5)
1875
+ * @param force - Force multiplier applied to base speed (default: 5)
898
1876
  * @param duration - Duration in milliseconds (default: 300)
1877
+ * @param options - Optional callbacks for movement lifecycle events
1878
+ * @returns Promise that resolves when the knockback completes
899
1879
  */
900
- knockback(direction: { x: number, y: number }, force?: number, duration?: number): void;
1880
+ knockback(direction: { x: number, y: number }, force?: number, duration?: number, options?: MovementOptions): Promise<void>;
901
1881
 
902
1882
  /**
903
1883
  * Follow a sequence of waypoints
904
1884
  *
1885
+ * Speed is calculated from the player's base speed multiplied by the speedMultiplier.
1886
+ *
905
1887
  * @param waypoints - Array of x,y positions to follow
906
- * @param speed - Movement speed (default: 2)
1888
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
907
1889
  * @param loop - Whether to loop back to start (default: false)
908
1890
  */
909
- followPath(waypoints: Array<{ x: number, y: number }>, speed?: number, loop?: boolean): void;
1891
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier?: number, loop?: boolean): void;
910
1892
 
911
1893
  /**
912
1894
  * Apply oscillating movement pattern
@@ -920,27 +1902,32 @@ export interface IMoveManager {
920
1902
  /**
921
1903
  * Apply ice movement physics
922
1904
  *
1905
+ * Max speed is calculated from the player's base speed multiplied by the speedFactor.
1906
+ *
923
1907
  * @param direction - Target movement direction
924
- * @param maxSpeed - Maximum speed when fully accelerated (default: 4)
1908
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
925
1909
  */
926
- applyIceMovement(direction: { x: number, y: number }, maxSpeed?: number): void;
1910
+ applyIceMovement(direction: { x: number, y: number }, speedFactor?: number): void;
927
1911
 
928
1912
  /**
929
1913
  * Shoot a projectile in the specified direction
930
1914
  *
1915
+ * Speed is calculated from the player's base speed multiplied by the speedFactor.
1916
+ *
931
1917
  * @param type - Type of projectile trajectory
932
1918
  * @param direction - Normalized direction vector
933
- * @param speed - Projectile speed (default: 200)
1919
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
934
1920
  */
935
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed?: number): void;
1921
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor?: number): void;
936
1922
 
937
1923
  /**
938
1924
  * Give an itinerary to follow using movement strategies
939
1925
  *
940
1926
  * @param routes - Array of movement instructions to execute
1927
+ * @param options - Optional configuration including onStuck callback
941
1928
  * @returns Promise that resolves when all routes are completed
942
1929
  */
943
- moveRoutes(routes: Routes): Promise<boolean>;
1930
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean>;
944
1931
 
945
1932
  /**
946
1933
  * Give a path that repeats itself in a loop to a character
@@ -961,3 +1948,4 @@ export interface IMoveManager {
961
1948
  */
962
1949
  replayRoutes(): void;
963
1950
  }
1951
+