@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.
- package/dist/Player/MoveManager.d.ts +62 -1
- package/dist/Player/Player.d.ts +33 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1827 -365
- package/dist/index.js.map +1 -1
- package/dist/rooms/BaseRoom.d.ts +95 -0
- package/dist/rooms/lobby.d.ts +4 -1
- package/dist/rooms/map.d.ts +17 -75
- package/package.json +10 -10
- package/src/Player/ItemManager.ts +50 -15
- package/src/Player/MoveManager.ts +654 -112
- package/src/Player/Player.ts +179 -136
- package/src/index.ts +2 -1
- package/src/module.ts +13 -0
- package/src/rooms/BaseRoom.ts +120 -0
- package/src/rooms/lobby.ts +11 -1
- package/src/rooms/map.ts +70 -146
- package/tests/change-map.spec.ts +2 -2
- package/tests/event.spec.ts +80 -0
- package/tests/item.spec.ts +455 -441
- package/tests/move.spec.ts +601 -0
- package/tests/random-move.spec.ts +65 -0
- package/tests/world-maps.spec.ts +43 -81
|
@@ -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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
][
|
|
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 =
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
});
|
|
817
|
+
return route;
|
|
818
|
+
})
|
|
819
|
+
);
|
|
654
820
|
|
|
655
821
|
// Flatten nested arrays
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
677
|
-
|
|
888
|
+
const currentRoute = this.routes[this.routeIndex];
|
|
889
|
+
this.routeIndex++;
|
|
678
890
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
695
|
-
//
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
726
|
-
|
|
1075
|
+
// If no target, try to process next route
|
|
1076
|
+
if (!this.currentTarget) {
|
|
1077
|
+
if (!this.finished) {
|
|
1078
|
+
this.processNextRoute();
|
|
727
1079
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
//
|
|
751
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|