@rpgjs/server 5.0.0-alpha.25 → 5.0.0-alpha.27

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.
@@ -14,10 +14,12 @@ import {
14
14
  ProjectileMovement,
15
15
  random,
16
16
  isFunction,
17
- capitalize
17
+ capitalize,
18
+ PerlinNoise2D
18
19
  } from "@rpgjs/common";
20
+ import type { MovementBody } from "@rpgjs/physic";
19
21
  import { RpgMap } from "../rooms/map";
20
- import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from } from 'rxjs';
22
+ import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from, take } from 'rxjs';
21
23
  import { RpgPlayer } from "./Player";
22
24
 
23
25
 
@@ -43,6 +45,48 @@ type CallbackTileMove = (player: RpgPlayer, map) => Direction[]
43
45
  type CallbackTurnMove = (player: RpgPlayer, map) => string
44
46
  type Routes = (string | Promise<any> | Direction | Direction[] | Function)[]
45
47
 
48
+ /**
49
+ * Options for moveRoutes method
50
+ */
51
+ export interface MoveRoutesOptions {
52
+ /**
53
+ * Callback function called when the player gets stuck (cannot move towards target)
54
+ *
55
+ * This callback is triggered when the player is trying to move but cannot make progress
56
+ * towards the target position, typically due to obstacles or collisions.
57
+ *
58
+ * @param player - The player instance that is stuck
59
+ * @param target - The target position the player was trying to reach
60
+ * @param currentPosition - The current position of the player
61
+ * @returns If true, the route will continue; if false, the route will be cancelled
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * await player.moveRoutes([Move.right()], {
66
+ * onStuck: (player, target, currentPos) => {
67
+ * console.log('Player is stuck!');
68
+ * return false; // Cancel the route
69
+ * }
70
+ * });
71
+ * ```
72
+ */
73
+ onStuck?: (player: RpgPlayer, target: { x: number; y: number }, currentPosition: { x: number; y: number }) => boolean | void;
74
+
75
+ /**
76
+ * Time in milliseconds to wait before considering the player stuck (default: 500ms)
77
+ *
78
+ * The player must be unable to make progress for this duration before onStuck is called.
79
+ */
80
+ stuckTimeout?: number;
81
+
82
+ /**
83
+ * Minimum distance change in pixels to consider movement progress (default: 1 pixel)
84
+ *
85
+ * If the player moves less than this distance over the stuckTimeout period, they are considered stuck.
86
+ */
87
+ stuckThreshold?: number;
88
+ }
89
+
46
90
  export enum Frequency {
47
91
  Lowest = 600,
48
92
  Lower = 400,
@@ -91,6 +135,100 @@ export enum Speed {
91
135
  * @memberof Move
92
136
  * */
93
137
  class MoveList {
138
+ // Shared Perlin noise instance for smooth random movement
139
+ private static perlinNoise: PerlinNoise2D = new PerlinNoise2D();
140
+ private static randomCounter: number = 0;
141
+ // Instance counter for each call to ensure variation
142
+ private static callCounter: number = 0;
143
+
144
+ /**
145
+ * Gets a random direction index (0-3) using a hybrid approach for balanced randomness
146
+ *
147
+ * Uses a combination of hash-based pseudo-randomness and Perlin noise to ensure
148
+ * fair distribution of directions while maintaining smooth, natural-looking movement patterns.
149
+ * The hash function guarantees uniform distribution, while Perlin noise adds spatial/temporal coherence.
150
+ *
151
+ * @param player - Optional player instance for coordinate-based noise
152
+ * @param index - Optional index for array-based calls to ensure variation
153
+ * @returns Direction index (0-3) corresponding to Right, Left, Up, Down
154
+ */
155
+ private getRandomDirectionIndex(player?: RpgPlayer, index?: number): number {
156
+ // Increment call counter for each invocation to ensure variation
157
+ MoveList.callCounter++;
158
+
159
+ // Generate a unique seed from multiple sources
160
+ let seed: number;
161
+ const time = Date.now() * 0.001; // Convert to seconds
162
+
163
+ if (player) {
164
+ // Use player coordinates combined with time and call counter
165
+ const playerX = typeof player.x === 'function' ? player.x() : player.x;
166
+ const playerY = typeof player.y === 'function' ? player.y() : player.y;
167
+
168
+ // Combine with prime multipliers for better distribution
169
+ seed = Math.floor(
170
+ (playerX * 0.1) +
171
+ (playerY * 0.1) +
172
+ (time * 1000) +
173
+ (MoveList.callCounter * 17) +
174
+ ((index ?? 0) * 31)
175
+ );
176
+ } else {
177
+ // Fallback for non-player contexts
178
+ MoveList.randomCounter++;
179
+ seed = Math.floor(
180
+ (MoveList.randomCounter * 17) +
181
+ (time * 1000) +
182
+ (MoveList.callCounter * 31) +
183
+ ((index ?? 0) * 47)
184
+ );
185
+ }
186
+
187
+ // Use multiple hash functions combined to ensure uniform distribution
188
+ // This approach guarantees fair probability across all directions
189
+
190
+ // Hash 1: Linear congruential generator
191
+ let hash1 = ((seed * 1103515245 + 12345) & 0x7fffffff) >>> 0;
192
+
193
+ // Hash 2: Multiply-shift hash
194
+ let hash2 = ((seed * 2654435761) >>> 0);
195
+
196
+ // Hash 3: XOR with rotation
197
+ let hash3 = seed ^ (seed >>> 16);
198
+ hash3 = ((hash3 * 2246822507) >>> 0);
199
+
200
+ // Combine hashes using XOR for better distribution
201
+ let combinedHash = (hash1 ^ hash2 ^ hash3) >>> 0;
202
+
203
+ // Convert to 0-1 range
204
+ const hashValue = (combinedHash % 1000000) / 1000000;
205
+
206
+ // Use Perlin noise for smooth spatial/temporal variation (10% influence only)
207
+ // Very low influence to avoid bias while maintaining some smoothness
208
+ const perlinX = seed * 0.001;
209
+ const perlinY = (seed * 1.618) * 0.001; // Golden ratio
210
+ const perlinValue = MoveList.perlinNoise.getNormalized(perlinX, perlinY, 0.3);
211
+
212
+ // Combine hash (90%) with Perlin noise (10%)
213
+ // Very high weight on hash ensures fair distribution, minimal Perlin for subtle smoothness
214
+ const finalValue = (hashValue * 0.9) + (perlinValue * 0.1);
215
+
216
+ // Map to direction index (0-3) ensuring uniform distribution
217
+ // Clamp finalValue to [0, 1) range to ensure valid index
218
+ const clampedValue = Math.max(0, Math.min(0.999999, finalValue));
219
+ let directionIndex = Math.floor(clampedValue * 4);
220
+
221
+ // Ensure directionIndex is always in valid range [0, 3]
222
+ directionIndex = Math.max(0, Math.min(3, directionIndex));
223
+
224
+ // Additional safety check: if somehow we get an invalid value (NaN, Infinity), use hash directly
225
+ if (!Number.isFinite(directionIndex) || directionIndex < 0 || directionIndex > 3) {
226
+ const fallbackIndex = Math.floor(hashValue * 4) % 4;
227
+ return Math.max(0, Math.min(3, fallbackIndex));
228
+ }
229
+
230
+ return directionIndex;
231
+ }
94
232
 
95
233
  repeatMove(direction: Direction, repeat: number): Direction[] {
96
234
  // Safety check for valid repeat value
@@ -186,12 +324,20 @@ class MoveList {
186
324
  }
187
325
 
188
326
  try {
189
- return new Array(repeat).fill(null).map(() => [
190
- Direction.Right,
191
- Direction.Left,
192
- Direction.Up,
193
- Direction.Down
194
- ][random(0, 3)]);
327
+ // Use Perlin noise for smooth random directions
328
+ // Increment counter before generating directions
329
+ MoveList.randomCounter += repeat;
330
+
331
+ return new Array(repeat).fill(null).map((_, index) => {
332
+ // Use getRandomDirectionIndex with index to ensure variation for each element
333
+ const directionIndex = this.getRandomDirectionIndex(undefined, index);
334
+ return [
335
+ Direction.Right,
336
+ Direction.Left,
337
+ Direction.Up,
338
+ Direction.Down
339
+ ][directionIndex];
340
+ });
195
341
  } catch (error) {
196
342
  console.error('Error creating random array with repeat:', repeat, error);
197
343
  return [Direction.Down]; // Return single direction as fallback
@@ -226,13 +372,31 @@ class MoveList {
226
372
  repeat = Math.floor(repeat);
227
373
 
228
374
  let directions: Direction[] = []
375
+ const directionFunctions: CallbackTileMove[] = [
376
+ this.tileRight(),
377
+ this.tileLeft(),
378
+ this.tileUp(),
379
+ this.tileDown()
380
+ ];
381
+
229
382
  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)]
383
+ // Use Perlin noise with player coordinates and index for smooth random movement
384
+ // Passing index ensures each iteration gets a different direction
385
+ let directionIndex = this.getRandomDirectionIndex(player, i);
386
+
387
+ // Ensure directionIndex is valid (0-3)
388
+ if (!Number.isInteger(directionIndex) || directionIndex < 0 || directionIndex > 3) {
389
+ console.warn('Invalid directionIndex in tileRandom:', directionIndex, 'using fallback');
390
+ directionIndex = Math.floor(Math.random() * 4) % 4;
391
+ }
392
+
393
+ const randFn = directionFunctions[directionIndex];
394
+
395
+ // Verify that randFn is a function before calling it
396
+ if (typeof randFn !== 'function') {
397
+ console.warn('randFn is not a function in tileRandom, skipping iteration');
398
+ continue;
399
+ }
236
400
 
237
401
  try {
238
402
  const newDirections = randFn(player, map);
@@ -364,12 +528,14 @@ class MoveList {
364
528
  }
365
529
 
366
530
  turnRandom(): string {
531
+ // Use Perlin noise for smooth random turn direction with guaranteed variation
532
+ const directionIndex = this.getRandomDirectionIndex();
367
533
  return [
368
534
  this.turnRight(),
369
535
  this.turnLeft(),
370
536
  this.turnUp(),
371
537
  this.turnDown()
372
- ][random(0, 3)]
538
+ ][directionIndex]
373
539
  }
374
540
 
375
541
  turnAwayFromPlayer(otherPlayer: RpgPlayer): CallbackTurnMove {
@@ -628,9 +794,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
628
794
  this.addMovement(new ProjectileMovement(type, config));
629
795
  }
630
796
 
631
- moveRoutes(routes: Routes): Promise<boolean> {
632
- let count = 0;
633
- let frequence = 0;
797
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean> {
634
798
  const player = this as unknown as PlayerWithMixins;
635
799
 
636
800
  // Break any existing route movement
@@ -641,122 +805,499 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
641
805
  this._finishRoute = resolve;
642
806
 
643
807
  // 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;
808
+ const processedRoutes = await Promise.all(
809
+ routes.map(async (route: any) => {
810
+ if (typeof route === 'function') {
811
+ const map = player.getCurrentMap() as any;
812
+ if (!map) {
813
+ return undefined;
814
+ }
815
+ return route.apply(route, [player, map]);
649
816
  }
650
- return route.apply(route, [player, map]);
651
- }
652
- return route;
653
- });
817
+ return route;
818
+ })
819
+ );
654
820
 
655
821
  // Flatten nested arrays
656
- const flatRoutes = this.flattenRoutes(processedRoutes);
657
- let routeIndex = 0;
658
-
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;
822
+ // Note: We keep promises in the routes array and handle them in the strategy
823
+ const finalRoutes = this.flattenRoutes(processedRoutes);
824
+
825
+ // Create a movement strategy that handles all routes
826
+ class RouteMovementStrategy implements MovementStrategy {
827
+ private routeIndex = 0;
828
+ private currentTarget: { x: number; y: number } | null = null; // Center position for physics
829
+ private currentTargetTopLeft: { x: number; y: number } | null = null; // Top-left position for player.x() comparison
830
+ private currentDirection: { x: number; y: number } = { x: 0, y: 0 };
831
+ private finished = false;
832
+ private waitingForPromise = false;
833
+ private promiseStartTime = 0;
834
+ private promiseDuration = 0;
835
+ private readonly routes: Routes;
836
+ private readonly player: PlayerWithMixins;
837
+ private readonly onComplete: (success: boolean) => void;
838
+ private readonly tileSize: number;
839
+ private readonly tolerance: number;
840
+ private readonly onStuck?: MoveRoutesOptions['onStuck'];
841
+ private readonly stuckTimeout: number;
842
+ private readonly stuckThreshold: number;
843
+
844
+ // Frequency wait state
845
+ private waitingForFrequency = false;
846
+ private frequencyWaitStartTime = 0;
847
+ private ratioFrequency = 15;
848
+
849
+ // Stuck detection state
850
+ private lastPosition: { x: number; y: number } | null = null;
851
+ private lastPositionTime: number = 0;
852
+ private stuckCheckStartTime: number = 0;
853
+ private lastDistanceToTarget: number | null = null;
854
+ private isCurrentlyStuck: boolean = false;
855
+ private stuckCheckInitialized: boolean = false;
856
+
857
+ constructor(
858
+ routes: Routes,
859
+ player: PlayerWithMixins,
860
+ onComplete: (success: boolean) => void,
861
+ options?: MoveRoutesOptions
862
+ ) {
863
+ this.routes = routes;
864
+ this.player = player;
865
+ this.onComplete = onComplete;
866
+ this.tileSize = player.nbPixelInTile || 32;
867
+ this.tolerance = 0.5; // Tolerance in pixels for reaching target (reduced for precision)
868
+ this.onStuck = options?.onStuck;
869
+ this.stuckTimeout = options?.stuckTimeout ?? 500; // Default 500ms
870
+ this.stuckThreshold = options?.stuckThreshold ?? 1; // Default 1 pixel
871
+
872
+ // Process initial route
873
+ this.processNextRoute();
665
874
  }
666
875
 
667
- // Handle frequency timing
668
- if (count >= (player.nbPixelInTile || 32)) {
669
- if (frequence < (player.frequency || 0)) {
670
- frequence++;
671
- setTimeout(executeNextRoute, 16); // ~60fps timing
876
+ private processNextRoute(): void {
877
+ // Reset frequency wait state when processing a new route
878
+ this.waitingForFrequency = false;
879
+ this.frequencyWaitStartTime = 0;
880
+
881
+ // Check if we've completed all routes
882
+ if (this.routeIndex >= this.routes.length) {
883
+ this.finished = true;
884
+ this.onComplete(true);
672
885
  return;
673
886
  }
674
- }
675
887
 
676
- frequence = 0;
677
- count++;
888
+ const currentRoute = this.routes[this.routeIndex];
889
+ this.routeIndex++;
678
890
 
679
- // Check if we've completed all routes
680
- if (routeIndex >= flatRoutes.length) {
681
- this._finishRoute = null;
682
- resolve(true);
683
- return;
684
- }
891
+ if (currentRoute === undefined) {
892
+ this.processNextRoute();
893
+ return;
894
+ }
895
+
896
+ try {
897
+ // Handle different route types
898
+ if (typeof currentRoute === 'object' && 'then' in currentRoute) {
899
+ // Handle Promise (like Move.wait())
900
+ // For Move.wait(), we need to track the wait time
901
+ // Check if it's a wait promise by checking if it resolves after a delay
902
+ this.waitingForPromise = true;
903
+ this.promiseStartTime = Date.now();
904
+
905
+ // Try to get duration from promise if possible (for Move.wait())
906
+ // Move.wait() creates a promise that resolves after a delay
907
+ // We'll use a default duration and let the promise resolve naturally
908
+ this.promiseDuration = 1000; // Default 1 second, will be updated when promise resolves
909
+
910
+ // Set up promise resolution handler
911
+ (currentRoute as Promise<any>).then(() => {
912
+ this.waitingForPromise = false;
913
+ this.processNextRoute();
914
+ }).catch(() => {
915
+ this.waitingForPromise = false;
916
+ this.processNextRoute();
917
+ });
918
+ } else if (typeof currentRoute === 'string' && currentRoute.startsWith('turn-')) {
919
+ // Handle turn commands - just change direction, no movement
920
+ const directionStr = currentRoute.replace('turn-', '');
921
+ let direction: Direction = Direction.Down;
922
+
923
+ switch (directionStr) {
924
+ case 'up':
925
+ case Direction.Up:
926
+ direction = Direction.Up;
927
+ break;
928
+ case 'down':
929
+ case Direction.Down:
930
+ direction = Direction.Down;
931
+ break;
932
+ case 'left':
933
+ case Direction.Left:
934
+ direction = Direction.Left;
935
+ break;
936
+ case 'right':
937
+ case Direction.Right:
938
+ direction = Direction.Right;
939
+ break;
940
+ }
941
+
942
+ if (this.player.changeDirection) {
943
+ this.player.changeDirection(direction);
944
+ }
945
+ // Turn is instant, continue immediately
946
+ this.processNextRoute();
947
+ } else if (typeof currentRoute === 'number' || typeof currentRoute === 'string') {
948
+ // Handle Direction enum values (number or string) - calculate target position
949
+ const moveDirection = currentRoute as unknown as Direction;
950
+ const map = this.player.getCurrentMap() as any;
951
+ if (!map) {
952
+ this.finished = true;
953
+ this.onComplete(false);
954
+ return;
955
+ }
956
+
957
+ // Get current position (top-left from player, which is what player.x() returns)
958
+ // We calculate target based on top-left position to match player.x() expectations
959
+ const currentTopLeftX = typeof this.player.x === 'function' ? this.player.x() : this.player.x;
960
+ const currentTopLeftY = typeof this.player.y === 'function' ? this.player.y() : this.player.y;
961
+
962
+ // Get player speed
963
+ let playerSpeed = this.player.speed()
964
+
965
+ // Use player speed as distance, not tile size
966
+ let distance = playerSpeed;
967
+
968
+ // Merge consecutive routes of same direction
969
+ const initialDistance = distance;
970
+ const initialRouteIndex = this.routeIndex;
971
+ while (this.routeIndex < this.routes.length) {
972
+ const nextRoute = this.routes[this.routeIndex];
973
+ if (nextRoute === currentRoute) {
974
+ distance += playerSpeed;
975
+ this.routeIndex++;
976
+ } else {
977
+ break;
978
+ }
979
+ }
980
+
981
+ // Calculate target top-left position
982
+ let targetTopLeftX = currentTopLeftX;
983
+ let targetTopLeftY = currentTopLeftY;
984
+
985
+ switch (moveDirection) {
986
+ case Direction.Right:
987
+ case 'right' as any:
988
+ targetTopLeftX = currentTopLeftX + distance;
989
+ break;
990
+ case Direction.Left:
991
+ case 'left' as any:
992
+ targetTopLeftX = currentTopLeftX - distance;
993
+ break;
994
+ case Direction.Down:
995
+ case 'down' as any:
996
+ targetTopLeftY = currentTopLeftY + distance;
997
+ break;
998
+ case Direction.Up:
999
+ case 'up' as any:
1000
+ targetTopLeftY = currentTopLeftY - distance;
1001
+ break;
1002
+ }
685
1003
 
686
- const currentRoute = flatRoutes[routeIndex];
687
- routeIndex++;
1004
+ // Convert target top-left to center position for physics engine
1005
+ // Get entity to access hitbox dimensions
1006
+ const entity = map.physic.getEntityByUUID(this.player.id);
1007
+ if (!entity) {
1008
+ this.finished = true;
1009
+ this.onComplete(false);
1010
+ return;
1011
+ }
688
1012
 
689
- if (currentRoute === undefined) {
690
- executeNextRoute();
691
- return;
1013
+ // Get hitbox dimensions for conversion
1014
+ const hitbox = this.player.hitbox();
1015
+ const hitboxWidth = hitbox?.w ?? 32;
1016
+ const hitboxHeight = hitbox?.h ?? 32;
1017
+
1018
+ // Convert top-left to center: center = topLeft + (size / 2)
1019
+ const targetX = targetTopLeftX + hitboxWidth / 2;
1020
+ const targetY = targetTopLeftY + hitboxHeight / 2;
1021
+
1022
+ this.currentTarget = { x: targetX, y: targetY }; // Center position for physics engine
1023
+ this.currentTargetTopLeft = { x: targetTopLeftX, y: targetTopLeftY }; // Top-left position for player.x() comparison
1024
+ this.currentDirection = { x: 0, y: 0 };
1025
+ // Reset stuck detection when starting a new movement
1026
+ this.lastPosition = null;
1027
+ this.isCurrentlyStuck = false;
1028
+ this.stuckCheckStartTime = 0;
1029
+ this.lastDistanceToTarget = null;
1030
+ this.stuckCheckInitialized = false;
1031
+ // Reset frequency wait state when starting a new movement
1032
+ this.waitingForFrequency = false;
1033
+ this.frequencyWaitStartTime = 0;
1034
+ } else if (Array.isArray(currentRoute)) {
1035
+ // Handle array of directions - insert them into routes
1036
+ for (let i = currentRoute.length - 1; i >= 0; i--) {
1037
+ this.routes.splice(this.routeIndex, 0, currentRoute[i]);
1038
+ }
1039
+ this.processNextRoute();
1040
+ } else {
1041
+ // Unknown route type, skip
1042
+ this.processNextRoute();
1043
+ }
1044
+ } catch (error) {
1045
+ console.warn('Error processing route:', error);
1046
+ this.processNextRoute();
1047
+ }
692
1048
  }
693
1049
 
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;
1050
+ update(body: MovementBody, dt: number): void {
1051
+ // Don't process if waiting for promise
1052
+ if (this.waitingForPromise) {
1053
+ body.setVelocity({ x: 0, y: 0 });
1054
+ // Check if promise wait time has elapsed (fallback)
1055
+ if (Date.now() - this.promiseStartTime > this.promiseDuration) {
1056
+ this.waitingForPromise = false;
1057
+ this.processNextRoute();
1058
+ }
1059
+ return;
1060
+ }
1061
+
1062
+ // Don't process if waiting for frequency delay
1063
+ if (this.waitingForFrequency) {
1064
+ body.setVelocity({ x: 0, y: 0 });
1065
+ const playerFrequency = this.player.frequency;
1066
+ const frequencyMs = playerFrequency || 0;
1067
+
1068
+ if (frequencyMs > 0 && Date.now() - this.frequencyWaitStartTime >= frequencyMs * this.ratioFrequency) {
1069
+ this.waitingForFrequency = false;
1070
+ this.processNextRoute();
723
1071
  }
1072
+ return;
1073
+ }
724
1074
 
725
- if (player.changeDirection) {
726
- player.changeDirection(direction);
1075
+ // If no target, try to process next route
1076
+ if (!this.currentTarget) {
1077
+ if (!this.finished) {
1078
+ this.processNextRoute();
727
1079
  }
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;
1080
+ if (!this.currentTarget) {
1081
+ body.setVelocity({ x: 0, y: 0 });
1082
+ // Reset stuck detection when no target
1083
+ this.lastPosition = null;
1084
+ this.isCurrentlyStuck = false;
1085
+ this.lastDistanceToTarget = null;
1086
+ this.stuckCheckInitialized = false;
1087
+ this.currentTargetTopLeft = null;
1088
+ return;
1089
+ }
1090
+ }
1091
+
1092
+ const entity = body.getEntity?.();
1093
+ if (!entity) {
1094
+ this.finished = true;
1095
+ this.onComplete(false);
1096
+ return;
1097
+ }
1098
+
1099
+ const currentPosition = { x: entity.position.x, y: entity.position.y };
1100
+ const currentTime = Date.now();
1101
+
1102
+ // Check distance using player's top-left position (what player.x() returns)
1103
+ // This ensures we match the test expectations which compare player.x()
1104
+ const currentTopLeftX = this.player.x();
1105
+ const currentTopLeftY = this.player.y()
1106
+
1107
+ // Calculate direction and distance using top-left position if available
1108
+ let dx: number, dy: number, distance: number;
1109
+ if (this.currentTargetTopLeft) {
1110
+ dx = this.currentTargetTopLeft.x - currentTopLeftX;
1111
+ dy = this.currentTargetTopLeft.y - currentTopLeftY;
1112
+ distance = Math.hypot(dx, dy);
1113
+
1114
+ // Check if we've reached the target (using top-left position)
1115
+ if (distance <= this.tolerance) {
1116
+ // Target reached, wait for frequency before processing next route
1117
+ this.currentTarget = null;
1118
+ this.currentTargetTopLeft = null;
1119
+ this.currentDirection = { x: 0, y: 0 };
1120
+ body.setVelocity({ x: 0, y: 0 });
1121
+ // Reset stuck detection
1122
+ this.lastPosition = null;
1123
+ this.isCurrentlyStuck = false;
1124
+ this.lastDistanceToTarget = null;
1125
+ this.stuckCheckInitialized = false;
1126
+
1127
+ // Wait for frequency before processing next route
1128
+ if (!this.finished) {
1129
+ const playerFrequency = this.player.frequency;
1130
+ if (playerFrequency && playerFrequency > 0) {
1131
+ this.waitingForFrequency = true;
1132
+ this.frequencyWaitStartTime = Date.now();
1133
+ } else {
1134
+ // No frequency delay, process immediately
1135
+ this.processNextRoute();
1136
+ }
742
1137
  }
743
- const speed = player.speed?.() ?? 3;
744
- this.addMovement(new LinearMove({ x: vx * speed, y: vy * speed }, 0.1));
745
- setTimeout(executeNextRoute, 100);
746
1138
  return;
747
1139
  }
748
- executeNextRoute();
749
1140
  } else {
750
- // Unknown route type, skip
751
- executeNextRoute();
1141
+ // Fallback: use center position distance if top-left target not available
1142
+ dx = this.currentTarget.x - currentPosition.x;
1143
+ dy = this.currentTarget.y - currentPosition.y;
1144
+ distance = Math.hypot(dx, dy);
1145
+
1146
+ // Check if we've reached the target (using center position as fallback)
1147
+ if (distance <= this.tolerance) {
1148
+ // Target reached, wait for frequency before processing next route
1149
+ this.currentTarget = null;
1150
+ this.currentTargetTopLeft = null;
1151
+ this.currentDirection = { x: 0, y: 0 };
1152
+ body.setVelocity({ x: 0, y: 0 });
1153
+ // Reset stuck detection
1154
+ this.lastPosition = null;
1155
+ this.isCurrentlyStuck = false;
1156
+ this.lastDistanceToTarget = null;
1157
+ this.stuckCheckInitialized = false;
1158
+
1159
+ // Wait for frequency before processing next route
1160
+ if (!this.finished) {
1161
+ const playerFrequency = player.frequency;
1162
+ if (playerFrequency && playerFrequency > 0) {
1163
+ this.waitingForFrequency = true;
1164
+ this.frequencyWaitStartTime = Date.now();
1165
+ } else {
1166
+ // No frequency delay, process immediately
1167
+ this.processNextRoute();
1168
+ }
1169
+ }
1170
+ return;
1171
+ }
1172
+ }
1173
+
1174
+ // Stuck detection: check if player is making progress
1175
+ if (this.onStuck && this.currentTarget) {
1176
+ // Initialize tracking on first update
1177
+ if (!this.stuckCheckInitialized) {
1178
+ this.lastPosition = { ...currentPosition };
1179
+ this.lastDistanceToTarget = distance;
1180
+ this.stuckCheckInitialized = true;
1181
+ // Update tracking and continue (don't return early)
1182
+ this.lastPositionTime = currentTime;
1183
+ } else if (this.lastPosition && this.lastDistanceToTarget !== null) {
1184
+ // We have a target, so we're trying to move (regardless of current velocity,
1185
+ // which may be zero due to physics engine collision handling)
1186
+ const positionChanged = Math.hypot(
1187
+ currentPosition.x - this.lastPosition.x,
1188
+ currentPosition.y - this.lastPosition.y
1189
+ ) > this.stuckThreshold;
1190
+
1191
+ const distanceImproved = distance < (this.lastDistanceToTarget - this.stuckThreshold);
1192
+
1193
+ // Player is stuck if: not moving AND not getting closer to target
1194
+ if (!positionChanged && !distanceImproved) {
1195
+ // Player is not making progress
1196
+ if (!this.isCurrentlyStuck) {
1197
+ // Start stuck timer
1198
+ this.stuckCheckStartTime = currentTime;
1199
+ this.isCurrentlyStuck = true;
1200
+ } else {
1201
+ // Check if stuck timeout has elapsed
1202
+ if (currentTime - this.stuckCheckStartTime >= this.stuckTimeout) {
1203
+ // Player is stuck, call onStuck callback
1204
+ const shouldContinue = this.onStuck(
1205
+ this.player as any,
1206
+ this.currentTarget,
1207
+ currentPosition
1208
+ );
1209
+
1210
+ if (shouldContinue === false) {
1211
+ // Cancel the route
1212
+ this.finished = true;
1213
+ this.onComplete(false);
1214
+ body.setVelocity({ x: 0, y: 0 });
1215
+ return;
1216
+ }
1217
+
1218
+ // Reset stuck detection to allow another check
1219
+ this.isCurrentlyStuck = false;
1220
+ this.stuckCheckStartTime = 0;
1221
+ // Reset position tracking to start fresh check
1222
+ this.lastPosition = { ...currentPosition };
1223
+ this.lastDistanceToTarget = distance;
1224
+ }
1225
+ }
1226
+ } else {
1227
+ // Player is making progress, reset stuck detection
1228
+ this.isCurrentlyStuck = false;
1229
+ this.stuckCheckStartTime = 0;
1230
+ }
1231
+
1232
+ // Update tracking variables
1233
+ this.lastPosition = { ...currentPosition };
1234
+ this.lastPositionTime = currentTime;
1235
+ this.lastDistanceToTarget = distance;
1236
+ }
1237
+ }
1238
+
1239
+ // Get speed scalar from map (default 50 if not found)
1240
+ const map = this.player.getCurrentMap() as any;
1241
+ const speedScalar = map?.speedScalar ?? 50;
1242
+
1243
+ // Calculate direction and speed
1244
+ // Use the distance calculated above (from top-left if available, center otherwise)
1245
+ if (distance > 0) {
1246
+ this.currentDirection = { x: dx / distance, y: dy / distance };
1247
+ } else {
1248
+ // If distance is 0 or negative, we've reached or passed the target
1249
+ this.currentTarget = null;
1250
+ this.currentTargetTopLeft = null;
1251
+ this.currentDirection = { x: 0, y: 0 };
1252
+ body.setVelocity({ x: 0, y: 0 });
1253
+ if (!this.finished) {
1254
+ const playerFrequency = typeof this.player.frequency === 'function' ? this.player.frequency() : this.player.frequency;
1255
+ if (playerFrequency && playerFrequency > 0) {
1256
+ this.waitingForFrequency = true;
1257
+ this.frequencyWaitStartTime = Date.now();
1258
+ } else {
1259
+ // No frequency delay, process immediately
1260
+ this.processNextRoute();
1261
+ }
1262
+ }
1263
+ return;
752
1264
  }
753
- } catch (error) {
754
- console.warn('Error executing route:', error);
755
- executeNextRoute();
1265
+
1266
+ // Convert vector direction to cardinal direction (like moveBody does)
1267
+ const absX = Math.abs(this.currentDirection.x);
1268
+ const absY = Math.abs(this.currentDirection.y);
1269
+ let cardinalDirection: Direction;
1270
+
1271
+ if (absX >= absY) {
1272
+ cardinalDirection = this.currentDirection.x >= 0 ? Direction.Right : Direction.Left;
1273
+ } else {
1274
+ cardinalDirection = this.currentDirection.y >= 0 ? Direction.Down : Direction.Up;
1275
+ }
1276
+
1277
+ map.movePlayer(this.player as any, cardinalDirection)
1278
+ }
1279
+
1280
+ isFinished(): boolean {
1281
+ return this.finished;
1282
+ }
1283
+
1284
+ onFinished(): void {
1285
+ this.onComplete(true);
756
1286
  }
757
- };
1287
+ }
1288
+
1289
+ // Create and add the route movement strategy
1290
+ const routeStrategy = new RouteMovementStrategy(
1291
+ finalRoutes,
1292
+ player,
1293
+ (success: boolean) => {
1294
+ this._finishRoute = null;
1295
+ resolve(success);
1296
+ },
1297
+ options
1298
+ );
758
1299
 
759
- executeNextRoute();
1300
+ this.addMovement(routeStrategy);
760
1301
  });
761
1302
  }
762
1303
 
@@ -938,9 +1479,10 @@ export interface IMoveManager {
938
1479
  * Give an itinerary to follow using movement strategies
939
1480
  *
940
1481
  * @param routes - Array of movement instructions to execute
1482
+ * @param options - Optional configuration including onStuck callback
941
1483
  * @returns Promise that resolves when all routes are completed
942
1484
  */
943
- moveRoutes(routes: Routes): Promise<boolean>;
1485
+ moveRoutes(routes: Routes, options?: MoveRoutesOptions): Promise<boolean>;
944
1486
 
945
1487
  /**
946
1488
  * Give a path that repeats itself in a loop to a character