@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.
- package/dist/Player/MoveManager.d.ts +169 -43
- package/dist/Player/Player.d.ts +13 -2
- package/dist/Player/SkillManager.d.ts +157 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2368 -320
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/Player/MoveManager.ts +1182 -194
- package/src/Player/Player.ts +37 -3
- package/src/Player/SkillManager.ts +401 -73
- package/src/index.ts +2 -1
- package/src/rooms/map.ts +20 -2
- package/tests/battle.spec.ts +375 -0
- package/tests/change-map.spec.ts +2 -2
- package/tests/class.spec.ts +274 -0
- package/tests/effect.spec.ts +219 -0
- package/tests/element.spec.ts +221 -0
- package/tests/event.spec.ts +80 -0
- package/tests/gold.spec.ts +99 -0
- package/tests/item.spec.ts +2 -2
- package/tests/move.spec.ts +601 -0
- package/tests/random-move.spec.ts +65 -0
- package/tests/skill.spec.ts +658 -0
- package/tests/state.spec.ts +467 -0
- package/tests/variable.spec.ts +185 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
][
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
568
|
-
new SeekAvoid(engine, targetProvider,
|
|
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
|
-
|
|
581
|
-
new SeekAvoid(engine, () => staticTarget,
|
|
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.
|
|
591
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
});
|
|
1155
|
+
return route;
|
|
1156
|
+
})
|
|
1157
|
+
);
|
|
654
1158
|
|
|
655
1159
|
// Flatten nested arrays
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
677
|
-
|
|
1231
|
+
const currentRoute = this.routes[this.routeIndex];
|
|
1232
|
+
this.routeIndex++;
|
|
678
1233
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
695
|
-
//
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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 (
|
|
726
|
-
|
|
1416
|
+
if (frequencyMs > 0 && Date.now() - this.frequencyWaitStartTime >= frequencyMs * this.ratioFrequency) {
|
|
1417
|
+
this.waitingForFrequency = false;
|
|
1418
|
+
this.processNextRoute();
|
|
727
1419
|
}
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
executeNextRoute();
|
|
1642
|
+
cardinalDirection = this.currentDirection.y >= 0 ? Direction.Down : Direction.Up;
|
|
752
1643
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
executeNextRoute();
|
|
1644
|
+
|
|
1645
|
+
map.movePlayer(this.player as any, cardinalDirection)
|
|
756
1646
|
}
|
|
757
|
-
};
|
|
758
1647
|
|
|
759
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 },
|
|
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 -
|
|
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
|
|
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 }>,
|
|
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
|
|
1908
|
+
* @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
|
|
925
1909
|
*/
|
|
926
|
-
applyIceMovement(direction: { x: number, y: number },
|
|
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
|
|
1919
|
+
* @param speedFactor - Factor multiplied with base speed (default: 50)
|
|
934
1920
|
*/
|
|
935
|
-
shootProjectile(type: ProjectileType, direction: { x: number, y: number },
|
|
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
|
+
|