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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { PlayerCtor, ProjectileType } from "@rpgjs/common";
2
2
  import { RpgCommonPlayer, Direction, Entity } from "@rpgjs/common";
3
3
  import {
4
4
  MovementStrategy,
5
+ MovementOptions,
5
6
  LinearMove,
6
7
  Dash,
7
8
  Knockback,
@@ -22,6 +23,73 @@ import { RpgMap } from "../rooms/map";
22
23
  import { Observable, Subscription, takeUntil, Subject, tap, switchMap, of, from, take } from 'rxjs';
23
24
  import { RpgPlayer } from "./Player";
24
25
 
26
+ /**
27
+ * Additive knockback strategy that **adds** an impulse-like velocity on top of the
28
+ * current velocity, instead of overwriting it.
29
+ *
30
+ * This is designed for A-RPG gameplay where the player should keep control during
31
+ * knockback. Inputs keep setting the base velocity, and this strategy adds a decaying
32
+ * impulse each physics step for a short duration.
33
+ *
34
+ * ## Design
35
+ *
36
+ * - The built-in physics `Knockback` strategy overwrites velocity every frame.
37
+ * When inputs also change velocity, the two systems compete and can cause visible jitter.
38
+ * - This strategy avoids the "tug of war" by reading the current velocity and adding
39
+ * the knockback impulse on top.
40
+ * - When finished, it does **not** force velocity to zero, so player input remains responsive.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // Add a short impulse to the right, while player can still steer
45
+ * await player.addMovement(new AdditiveKnockback({ x: 1, y: 0 }, 5, 0.3));
46
+ * ```
47
+ */
48
+ class AdditiveKnockback implements MovementStrategy {
49
+ private readonly direction: { x: number; y: number };
50
+ private elapsed = 0;
51
+ private currentSpeed: number;
52
+
53
+ constructor(
54
+ direction: { x: number; y: number },
55
+ initialSpeed: number,
56
+ private readonly duration: number,
57
+ private readonly decayFactor = 0.35
58
+ ) {
59
+ const magnitude = Math.hypot(direction.x, direction.y);
60
+ this.direction = magnitude > 0
61
+ ? { x: direction.x / magnitude, y: direction.y / magnitude }
62
+ : { x: 1, y: 0 };
63
+ this.currentSpeed = initialSpeed;
64
+ }
65
+
66
+ update(body: MovementBody, dt: number): void {
67
+ this.elapsed += dt;
68
+ if (this.elapsed > this.duration) {
69
+ return;
70
+ }
71
+
72
+ const impulseX = this.direction.x * this.currentSpeed;
73
+ const impulseY = this.direction.y * this.currentSpeed;
74
+
75
+ body.setVelocity({
76
+ x: body.velocity.x + impulseX,
77
+ y: body.velocity.y + impulseY,
78
+ });
79
+
80
+ const decay = Math.max(0, Math.min(1, this.decayFactor));
81
+ if (decay === 0) {
82
+ this.currentSpeed = 0;
83
+ } else if (decay !== 1) {
84
+ this.currentSpeed *= Math.pow(decay, dt);
85
+ }
86
+ }
87
+
88
+ isFinished(): boolean {
89
+ return this.elapsed >= this.duration;
90
+ }
91
+ }
92
+
25
93
 
26
94
  interface PlayerWithMixins extends RpgCommonPlayer {
27
95
  getCurrentMap(): RpgMap;
@@ -45,6 +113,9 @@ type CallbackTileMove = (player: RpgPlayer, map) => Direction[]
45
113
  type CallbackTurnMove = (player: RpgPlayer, map) => string
46
114
  type Routes = (string | Promise<any> | Direction | Direction[] | Function)[]
47
115
 
116
+ // Re-export MovementOptions from @rpgjs/common for convenience
117
+ export type { MovementOptions };
118
+
48
119
  /**
49
120
  * Options for moveRoutes method
50
121
  */
@@ -134,100 +205,62 @@ export enum Speed {
134
205
  * Move.turnTowardPlayer(player) | Turns in the direction of the designated player
135
206
  * @memberof Move
136
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
+
137
221
  class MoveList {
138
222
  // Shared Perlin noise instance for smooth random movement
139
223
  private static perlinNoise: PerlinNoise2D = new PerlinNoise2D();
140
224
  private static randomCounter: number = 0;
141
225
  // Instance counter for each call to ensure variation
142
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
+
143
232
 
144
233
  /**
145
- * Gets a random direction index (0-3) using a hybrid approach for balanced randomness
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.
146
238
  *
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.
239
+ * @param playerId - The ID of the player to clear state for
150
240
  *
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
241
+ * @example
242
+ * ```ts
243
+ * // Clear state when player leaves map
244
+ * Move.clearPlayerState(player.id);
245
+ * ```
154
246
  */
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
- }
247
+ static clearPlayerState(playerId: string): void {
248
+ MoveList.playerMoveStates.delete(playerId);
249
+ }
229
250
 
230
- return directionIndex;
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();
231
264
  }
232
265
 
233
266
  repeatMove(direction: Direction, repeat: number): Direction[] {
@@ -308,41 +341,13 @@ class MoveList {
308
341
  }
309
342
 
310
343
  random(repeat: number = 1): Direction[] {
311
- // Safety check for valid repeat value
312
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 10000) {
313
- console.warn('Invalid repeat value in random:', repeat, 'using default value 1');
314
- repeat = 1;
315
- }
316
-
317
- // Ensure repeat is an integer
318
- repeat = Math.floor(repeat);
319
-
320
- // Additional safety check - ensure repeat is a safe integer
321
- if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
322
- console.warn('Unsafe repeat value in random:', repeat, 'using default value 1');
323
- repeat = 1;
324
- }
325
-
326
- try {
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
- });
341
- } catch (error) {
342
- console.error('Error creating random array with repeat:', repeat, error);
343
- return [Direction.Down]; // Return single direction as fallback
344
- }
345
- }
344
+ return new Array(repeat).fill(null).map(() => [
345
+ Direction.Right,
346
+ Direction.Left,
347
+ Direction.Up,
348
+ Direction.Down
349
+ ][random(0, 3)])
350
+ }
346
351
 
347
352
  tileRight(repeat: number = 1): CallbackTileMove {
348
353
  return this.repeatTileMove('right', repeat, 'tileWidth')
@@ -362,61 +367,22 @@ class MoveList {
362
367
 
363
368
  tileRandom(repeat: number = 1): CallbackTileMove {
364
369
  return (player: RpgPlayer, map): Direction[] => {
365
- // Safety check for valid repeat value
366
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1000) {
367
- console.warn('Invalid repeat value in tileRandom:', repeat, 'using default value 1');
368
- repeat = 1;
369
- }
370
-
371
- // Ensure repeat is an integer
372
- repeat = Math.floor(repeat);
373
-
374
- let directions: Direction[] = []
375
- const directionFunctions: CallbackTileMove[] = [
376
- this.tileRight(),
377
- this.tileLeft(),
378
- this.tileUp(),
379
- this.tileDown()
380
- ];
381
-
382
- for (let i = 0; i < repeat; i++) {
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
- }
400
-
401
- try {
402
- const newDirections = randFn(player, map);
403
- if (Array.isArray(newDirections)) {
404
- directions = [...directions, ...newDirections];
405
- }
406
- } catch (error) {
407
- console.warn('Error in tileRandom iteration:', error);
408
- // Continue with next iteration instead of breaking
409
- }
410
-
411
- // Safety check to prevent excessive array growth
412
- if (directions.length > 10000) {
413
- console.warn('tileRandom generated too many directions, truncating');
414
- 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
+ ]
415
382
  }
416
- }
417
- return directions
383
+ return directions
418
384
  }
419
- }
385
+ }
420
386
 
421
387
  private _awayFromPlayerDirection(player: RpgPlayer, otherPlayer: RpgPlayer): Direction {
422
388
  const directionOtherPlayer = otherPlayer.getDirection()
@@ -678,6 +644,14 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
678
644
  return this._through();
679
645
  }
680
646
 
647
+ set throughEvent(value: boolean) {
648
+ this._throughEvent.set(value);
649
+ }
650
+
651
+ get throughEvent(): boolean {
652
+ return this._throughEvent();
653
+ }
654
+
681
655
  set frequency(value: number) {
682
656
  this._frequency.set(value);
683
657
  }
@@ -686,25 +660,70 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
686
660
  return this._frequency();
687
661
  }
688
662
 
689
- addMovement(strategy: MovementStrategy): void {
663
+ set directionFixed(value: boolean) {
664
+ (this as any)._directionFixed.set(value);
665
+ }
666
+
667
+ get directionFixed(): boolean {
668
+ return (this as any)._directionFixed();
669
+ }
670
+
671
+ set animationFixed(value: boolean) {
672
+ (this as any)._animationFixed.set(value);
673
+ }
674
+
675
+ get animationFixed(): boolean {
676
+ return (this as any)._animationFixed();
677
+ }
678
+
679
+ /**
680
+ * Add a movement strategy to this entity
681
+ *
682
+ * Returns a Promise that resolves when the movement completes (when `isFinished()` returns true).
683
+ * If the strategy doesn't implement `isFinished()`, the Promise resolves immediately.
684
+ *
685
+ * @param strategy - The movement strategy to add
686
+ * @param options - Optional callbacks for start and completion events
687
+ * @returns Promise that resolves when the movement completes
688
+ *
689
+ * @example
690
+ * ```ts
691
+ * // Fire and forget
692
+ * player.addMovement(new LinearMove({ x: 1, y: 0 }, 200));
693
+ *
694
+ * // Wait for completion
695
+ * await player.addMovement(new Dash(10, { x: 1, y: 0 }, 200));
696
+ * console.log('Dash completed!');
697
+ *
698
+ * // With callbacks
699
+ * await player.addMovement(new Knockback({ x: -1, y: 0 }, 5, 300), {
700
+ * onStart: () => console.log('Knockback started'),
701
+ * onComplete: () => console.log('Knockback completed')
702
+ * });
703
+ * ```
704
+ */
705
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void> {
690
706
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
691
- if (!map) return;
707
+ if (!map) return Promise.resolve();
692
708
 
693
- map.moveManager.add((this as unknown as PlayerWithMixins).id, strategy);
709
+ const playerId = (this as unknown as PlayerWithMixins).id;
710
+ return map.moveManager.add(playerId, strategy, options);
694
711
  }
695
712
 
696
713
  removeMovement(strategy: MovementStrategy): boolean {
697
714
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
698
715
  if (!map) return false;
699
716
 
700
- return map.moveManager.remove((this as unknown as PlayerWithMixins).id, strategy);
717
+ const playerId = (this as unknown as PlayerWithMixins).id;
718
+ return map.moveManager.remove(playerId, strategy);
701
719
  }
702
720
 
703
721
  clearMovements(): void {
704
722
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
705
723
  if (!map) return;
706
724
 
707
- map.moveManager.clear((this as unknown as PlayerWithMixins).id);
725
+ const playerId = (this as unknown as PlayerWithMixins).id;
726
+ map.moveManager.clear(playerId);
708
727
  }
709
728
 
710
729
  hasActiveMovements(): boolean {
@@ -721,17 +740,59 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
721
740
  return map.moveManager.getStrategies((this as unknown as PlayerWithMixins).id);
722
741
  }
723
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
+ */
724
760
  moveTo(target: RpgCommonPlayer | { x: number, y: number }): void {
725
761
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
726
762
  if (!map) return;
727
763
 
764
+ const playerId = (this as unknown as PlayerWithMixins).id;
728
765
  const engine = map.physic;
729
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
+
730
786
  if ('id' in target) {
731
- const targetProvider = () => (map as any).getBody(target.id) ?? null;
787
+ const targetProvider = () => {
788
+ const body = (map as any).getBody(target.id) ?? null;
789
+ return body;
790
+ };
791
+ // Factor 45: with speed=4 gives 180 (original value)
792
+ const maxSpeed = playerSpeed * 45;
732
793
  map.moveManager.add(
733
- (this as unknown as PlayerWithMixins).id,
734
- new SeekAvoid(engine, targetProvider, 180, 140, 80, 48)
794
+ playerId,
795
+ new SeekAvoid(engine, targetProvider, maxSpeed, 140, 80, 48)
735
796
  );
736
797
  return;
737
798
  }
@@ -742,9 +803,11 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
742
803
  });
743
804
  staticTarget.freeze();
744
805
 
806
+ // Factor 20: with speed=4 gives 80 (original value)
807
+ const maxSpeed = playerSpeed * 20;
745
808
  map.moveManager.add(
746
- (this as unknown as PlayerWithMixins).id,
747
- new SeekAvoid(engine, () => staticTarget, 80, 140, 80, 48)
809
+ playerId,
810
+ new SeekAvoid(engine, () => staticTarget, maxSpeed, 140, 80, 48)
748
811
  );
749
812
  }
750
813
 
@@ -752,35 +815,310 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
752
815
  const map = (this as unknown as PlayerWithMixins).getCurrentMap() as any;
753
816
  if (!map) return;
754
817
 
818
+ const playerId = (this as unknown as PlayerWithMixins).id;
755
819
  const strategies = this.getActiveMovements();
756
- strategies.forEach(strategy => {
757
- if (strategy instanceof SeekAvoid || strategy instanceof LinearRepulsion) {
820
+ const toRemove = strategies.filter(s => s instanceof SeekAvoid || s instanceof LinearRepulsion);
821
+
822
+ if (toRemove.length > 0) {
823
+ toRemove.forEach(strategy => {
758
824
  this.removeMovement(strategy);
759
- }
760
- });
825
+ });
826
+ }
761
827
  }
762
828
 
763
- dash(direction: { x: number, y: number }, speed: number = 8, duration: number = 200): void {
764
- this.addMovement(new Dash(speed, direction, duration));
829
+ /**
830
+ * Perform a dash movement in the specified direction
831
+ *
832
+ * Creates a burst of velocity for a fixed duration. The total speed is calculated
833
+ * by adding the player's base speed (`this.speed()`) to the additional dash speed.
834
+ * This ensures faster players also dash faster proportionally.
835
+ *
836
+ * With default speed=4 and additionalSpeed=4: total = 8 (same as original default)
837
+ *
838
+ * @param direction - Normalized direction vector `{ x, y }` for the dash
839
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
840
+ * @param duration - Duration in milliseconds (default: 200)
841
+ * @param options - Optional callbacks for movement events
842
+ * @returns Promise that resolves when the dash completes
843
+ *
844
+ * @example
845
+ * ```ts
846
+ * // Dash to the right and wait for completion
847
+ * await player.dash({ x: 1, y: 0 });
848
+ *
849
+ * // Powerful dash with callbacks
850
+ * await player.dash({ x: 0, y: -1 }, 12, 300, {
851
+ * onStart: () => console.log('Dash started!'),
852
+ * onComplete: () => console.log('Dash finished!')
853
+ * });
854
+ * ```
855
+ */
856
+ dash(direction: { x: number, y: number }, additionalSpeed: number = 4, duration: number = 200, options?: MovementOptions): Promise<void> {
857
+ const playerSpeed = (this as any).speed();
858
+ // Total dash speed = base speed + additional speed
859
+ // With speed=4, additionalSpeed=4: gives 8 (original default value)
860
+ const totalSpeed = playerSpeed + additionalSpeed;
861
+ // Physic strategies expect seconds (dt is in seconds), while the server API exposes milliseconds
862
+ const durationSeconds = duration / 1000;
863
+ return this.addMovement(new Dash(totalSpeed, direction, durationSeconds), options);
765
864
  }
766
865
 
767
- knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300): void {
768
- this.addMovement(new Knockback(direction, force, duration));
866
+ /**
867
+ * Apply knockback effect in the specified direction
868
+ *
869
+ * Pushes the entity with an initial force that decays over time.
870
+ * Returns a Promise that resolves when the knockback completes **or is cancelled**.
871
+ *
872
+ * ## Design notes
873
+ * - The underlying physics `MovementManager` can cancel strategies via `remove()`, `clear()`,
874
+ * or `stopMovement()` **without resolving the Promise** returned by `add()`.
875
+ * - For this reason, this method considers the knockback finished when either:
876
+ * - the `add()` promise resolves (normal completion), or
877
+ * - the strategy is no longer present in the active movements list (cancellation).
878
+ * - When multiple knockbacks overlap, `directionFixed` and `animationFixed` are restored
879
+ * only after **all** knockbacks have finished (including cancellations).
880
+ *
881
+ * @param direction - Normalized direction vector `{ x, y }` for the knockback
882
+ * @param force - Initial knockback force (default: 5)
883
+ * @param duration - Duration in milliseconds (default: 300)
884
+ * @param options - Optional callbacks for movement events
885
+ * @returns Promise that resolves when the knockback completes or is cancelled
886
+ *
887
+ * @example
888
+ * ```ts
889
+ * // Simple knockback (await is optional)
890
+ * await player.knockback({ x: 1, y: 0 }, 5, 300);
891
+ *
892
+ * // Overlapping knockbacks: flags are restored only after the last one ends
893
+ * player.knockback({ x: -1, y: 0 }, 5, 300);
894
+ * player.knockback({ x: 0, y: 1 }, 3, 200);
895
+ *
896
+ * // Cancellation (e.g. map change) will still restore fixed flags
897
+ * // even if the underlying movement strategy promise is never resolved.
898
+ * ```
899
+ */
900
+ async knockback(direction: { x: number, y: number }, force: number = 5, duration: number = 300, options?: MovementOptions): Promise<void> {
901
+ const durationSeconds = duration / 1000;
902
+ const selfAny = this as any;
903
+ const lockKey = '__rpg_knockback_lock__';
904
+
905
+ type KnockbackLockState = {
906
+ prevDirectionFixed: boolean;
907
+ prevAnimationFixed: boolean;
908
+ prevAnimationName?: string;
909
+ };
910
+
911
+ const getLock = (): KnockbackLockState | undefined => selfAny[lockKey];
912
+ const setLock = (lock: KnockbackLockState): void => {
913
+ selfAny[lockKey] = lock;
914
+ };
915
+ const clearLock = (): void => {
916
+ delete selfAny[lockKey];
917
+ };
918
+
919
+ const hasActiveKnockback = (): boolean =>
920
+ this.getActiveMovements().some(s => s instanceof Knockback || s instanceof AdditiveKnockback);
921
+
922
+ const setAnimationName = (name: string): void => {
923
+ if (typeof selfAny.setAnimation === 'function') {
924
+ selfAny.setAnimation(name);
925
+ return;
926
+ }
927
+ const animSignal = selfAny.animationName;
928
+ if (animSignal && typeof animSignal === 'object' && typeof animSignal.set === 'function') {
929
+ animSignal.set(name);
930
+ }
931
+ };
932
+
933
+ const getAnimationName = (): string | undefined => {
934
+ const animSignal = selfAny.animationName;
935
+ if (typeof animSignal === 'function') {
936
+ try {
937
+ return animSignal();
938
+ } catch {
939
+ return undefined;
940
+ }
941
+ }
942
+ return undefined;
943
+ };
944
+
945
+ const restore = (): void => {
946
+ const lock = getLock();
947
+ if (!lock) return;
948
+
949
+ this.directionFixed = lock.prevDirectionFixed;
950
+
951
+ const prevAnimFixed = lock.prevAnimationFixed;
952
+ this.animationFixed = false; // temporarily unlock so we can restore animation
953
+ if (!prevAnimFixed && lock.prevAnimationName) {
954
+ setAnimationName(lock.prevAnimationName);
955
+ }
956
+ this.animationFixed = prevAnimFixed;
957
+
958
+ clearLock();
959
+ };
960
+
961
+ const ensureLockInitialized = (): void => {
962
+ if (getLock()) return;
963
+
964
+ setLock({
965
+ prevDirectionFixed: this.directionFixed,
966
+ prevAnimationFixed: this.animationFixed,
967
+ prevAnimationName: getAnimationName(),
968
+ });
969
+
970
+ this.directionFixed = true;
971
+ setAnimationName('stand');
972
+ this.animationFixed = true;
973
+ };
974
+
975
+ const waitUntilRemovedOrTimeout = (strategy: MovementStrategy): Promise<void> => {
976
+ return new Promise<void>((resolve) => {
977
+ const start = Date.now();
978
+ const maxMs = Math.max(0, duration + 1000);
979
+
980
+ const intervalId = setInterval(() => {
981
+ const active = this.getActiveMovements();
982
+ if (!active.includes(strategy) || (Date.now() - start > maxMs)) {
983
+ clearInterval(intervalId);
984
+ resolve();
985
+ }
986
+ }, 16);
987
+ });
988
+ };
989
+
990
+ // First knockback creates the lock and freezes direction/animation.
991
+ // Next knockbacks reuse the lock and keep the fixed flags enabled.
992
+ ensureLockInitialized();
993
+
994
+ // Use additive knockback to avoid jitter with player inputs
995
+ const strategy = new AdditiveKnockback(direction, force, durationSeconds);
996
+ const addPromise = this.addMovement(strategy, options);
997
+
998
+ try {
999
+ await Promise.race([addPromise, waitUntilRemovedOrTimeout(strategy)]);
1000
+ } finally {
1001
+ // Restore only when ALL knockbacks are done (including cancellations).
1002
+ if (!hasActiveKnockback()) {
1003
+ restore();
1004
+ }
1005
+ }
769
1006
  }
770
1007
 
771
- followPath(waypoints: Array<{ x: number, y: number }>, speed: number = 2, loop: boolean = false): void {
1008
+ /**
1009
+ * Follow a sequence of waypoints
1010
+ *
1011
+ * Makes the entity move through a list of positions at a speed calculated
1012
+ * from the player's base speed. The `speedMultiplier` allows adjusting
1013
+ * the travel speed relative to the player's normal movement speed.
1014
+ *
1015
+ * With default speed=4 and multiplier=0.5: speed = 2 (same as original default)
1016
+ *
1017
+ * @param waypoints - Array of `{ x, y }` positions to follow in order
1018
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
1019
+ * @param loop - Whether to loop back to start after reaching the end (default: false)
1020
+ *
1021
+ * @example
1022
+ * ```ts
1023
+ * // Follow a patrol path at normal speed
1024
+ * const patrol = [
1025
+ * { x: 100, y: 100 },
1026
+ * { x: 200, y: 100 },
1027
+ * { x: 200, y: 200 }
1028
+ * ];
1029
+ * player.followPath(patrol, 1, true); // Loop at full speed
1030
+ *
1031
+ * // Slow walk through waypoints
1032
+ * player.followPath(waypoints, 0.25, false);
1033
+ * ```
1034
+ */
1035
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier: number = 0.5, loop: boolean = false): void {
1036
+ const playerSpeed = (this as any).speed();
1037
+ // Path follow speed = player base speed * multiplier
1038
+ // With speed=4, multiplier=0.5: gives 2 (original default value)
1039
+ const speed = playerSpeed * speedMultiplier;
772
1040
  this.addMovement(new PathFollow(waypoints, speed, loop));
773
1041
  }
774
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
+ */
775
1062
  oscillate(direction: { x: number, y: number }, amplitude: number = 50, period: number = 2000): void {
776
1063
  this.addMovement(new Oscillate(direction, amplitude, period));
777
1064
  }
778
1065
 
779
- applyIceMovement(direction: { x: number, y: number }, maxSpeed: number = 4): void {
1066
+ /**
1067
+ * Apply ice movement physics
1068
+ *
1069
+ * Simulates slippery surface physics where the entity accelerates gradually
1070
+ * and has difficulty stopping. The maximum speed is based on the player's
1071
+ * base speed multiplied by a speed factor.
1072
+ *
1073
+ * With default speed=4 and factor=1: maxSpeed = 4 (same as original default)
1074
+ *
1075
+ * @param direction - Target movement direction `{ x, y }`
1076
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
1077
+ *
1078
+ * @example
1079
+ * ```ts
1080
+ * // Normal ice physics
1081
+ * player.applyIceMovement({ x: 1, y: 0 });
1082
+ *
1083
+ * // Fast ice sliding
1084
+ * player.applyIceMovement({ x: 0, y: 1 }, 1.5);
1085
+ * ```
1086
+ */
1087
+ applyIceMovement(direction: { x: number, y: number }, speedFactor: number = 1): void {
1088
+ const playerSpeed = (this as any).speed();
1089
+ // Max ice speed = player base speed * factor
1090
+ // With speed=4, factor=1: gives 4 (original default value)
1091
+ const maxSpeed = playerSpeed * speedFactor;
780
1092
  this.addMovement(new IceMovement(direction, maxSpeed));
781
1093
  }
782
1094
 
783
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed: number = 200): void {
1095
+ /**
1096
+ * Shoot a projectile in the specified direction
1097
+ *
1098
+ * Creates a projectile with ballistic trajectory. The speed is calculated
1099
+ * from the player's base speed multiplied by a speed factor.
1100
+ *
1101
+ * With default speed=4 and factor=50: speed = 200 (same as original default)
1102
+ *
1103
+ * @param type - Type of projectile trajectory (`Straight`, `Arc`, or `Bounce`)
1104
+ * @param direction - Normalized direction vector `{ x, y }`
1105
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
1106
+ *
1107
+ * @example
1108
+ * ```ts
1109
+ * // Straight projectile
1110
+ * player.shootProjectile(ProjectileType.Straight, { x: 1, y: 0 });
1111
+ *
1112
+ * // Fast arc projectile
1113
+ * player.shootProjectile(ProjectileType.Arc, { x: 1, y: -0.5 }, 75);
1114
+ * ```
1115
+ */
1116
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor: number = 50): void {
1117
+ const playerSpeed = (this as any).speed();
1118
+ // Projectile speed = player base speed * factor
1119
+ // With speed=4, factor=50: gives 200 (original default value)
1120
+ const speed = playerSpeed * speedFactor;
1121
+
784
1122
  const config = {
785
1123
  speed,
786
1124
  direction,
@@ -873,6 +1211,10 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
873
1211
  this.processNextRoute();
874
1212
  }
875
1213
 
1214
+ private debugLog(message: string, data?: any): void {
1215
+ // Debug logging disabled - enable if needed for troubleshooting
1216
+ }
1217
+
876
1218
  private processNextRoute(): void {
877
1219
  // Reset frequency wait state when processing a new route
878
1220
  this.waitingForFrequency = false;
@@ -880,6 +1222,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
880
1222
 
881
1223
  // Check if we've completed all routes
882
1224
  if (this.routeIndex >= this.routes.length) {
1225
+ this.debugLog('COMPLETE all routes finished');
883
1226
  this.finished = true;
884
1227
  this.onComplete(true);
885
1228
  return;
@@ -897,8 +1240,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
897
1240
  // Handle different route types
898
1241
  if (typeof currentRoute === 'object' && 'then' in currentRoute) {
899
1242
  // 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
1243
+ this.debugLog(`WAIT for promise (route ${this.routeIndex}/${this.routes.length})`);
902
1244
  this.waitingForPromise = true;
903
1245
  this.promiseStartTime = Date.now();
904
1246
 
@@ -909,9 +1251,11 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
909
1251
 
910
1252
  // Set up promise resolution handler
911
1253
  (currentRoute as Promise<any>).then(() => {
1254
+ this.debugLog('WAIT promise resolved');
912
1255
  this.waitingForPromise = false;
913
1256
  this.processNextRoute();
914
1257
  }).catch(() => {
1258
+ this.debugLog('WAIT promise rejected');
915
1259
  this.waitingForPromise = false;
916
1260
  this.processNextRoute();
917
1261
  });
@@ -939,6 +1283,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
939
1283
  break;
940
1284
  }
941
1285
 
1286
+ this.debugLog(`TURN to ${directionStr}`);
942
1287
  if (this.player.changeDirection) {
943
1288
  this.player.changeDirection(direction);
944
1289
  }
@@ -1022,6 +1367,9 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1022
1367
  this.currentTarget = { x: targetX, y: targetY }; // Center position for physics engine
1023
1368
  this.currentTargetTopLeft = { x: targetTopLeftX, y: targetTopLeftY }; // Top-left position for player.x() comparison
1024
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
+
1025
1373
  // Reset stuck detection when starting a new movement
1026
1374
  this.lastPosition = null;
1027
1375
  this.isCurrentlyStuck = false;
@@ -1114,6 +1462,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1114
1462
  // Check if we've reached the target (using top-left position)
1115
1463
  if (distance <= this.tolerance) {
1116
1464
  // Target reached, wait for frequency before processing next route
1465
+ this.debugLog(`TARGET reached at (${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)})`);
1117
1466
  this.currentTarget = null;
1118
1467
  this.currentTargetTopLeft = null;
1119
1468
  this.currentDirection = { x: 0, y: 0 };
@@ -1201,6 +1550,7 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1201
1550
  // Check if stuck timeout has elapsed
1202
1551
  if (currentTime - this.stuckCheckStartTime >= this.stuckTimeout) {
1203
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)})`);
1204
1554
  const shouldContinue = this.onStuck(
1205
1555
  this.player as any,
1206
1556
  this.currentTarget,
@@ -1209,18 +1559,36 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1209
1559
 
1210
1560
  if (shouldContinue === false) {
1211
1561
  // Cancel the route
1562
+ this.debugLog('STUCK cancelled route');
1212
1563
  this.finished = true;
1213
1564
  this.onComplete(false);
1214
1565
  body.setVelocity({ x: 0, y: 0 });
1215
1566
  return;
1216
1567
  }
1217
1568
 
1218
- // Reset stuck detection to allow another check
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
1219
1583
  this.isCurrentlyStuck = false;
1220
1584
  this.stuckCheckStartTime = 0;
1221
- // Reset position tracking to start fresh check
1222
- this.lastPosition = { ...currentPosition };
1223
- this.lastDistanceToTarget = distance;
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;
1224
1592
  }
1225
1593
  }
1226
1594
  } else {
@@ -1310,14 +1678,14 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1310
1678
  }, []);
1311
1679
  }
1312
1680
 
1313
- infiniteMoveRoute(routes: Routes): void {
1681
+ infiniteMoveRoute(routes: Routes, options?: MoveRoutesOptions): void {
1314
1682
  this._infiniteRoutes = routes;
1315
1683
  this._isInfiniteRouteActive = true;
1316
1684
 
1317
1685
  const executeInfiniteRoute = (isBreaking: boolean = false) => {
1318
1686
  if (isBreaking || !this._isInfiniteRouteActive) return;
1319
1687
 
1320
- this.moveRoutes(routes).then((completed) => {
1688
+ this.moveRoutes(routes, options).then((completed) => {
1321
1689
  // Only continue if the route completed successfully and we're still active
1322
1690
  if (completed && this._isInfiniteRouteActive) {
1323
1691
  executeInfiniteRoute();
@@ -1358,7 +1726,6 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1358
1726
 
1359
1727
  return WithMoveManagerClass as unknown as PlayerCtor;
1360
1728
  }
1361
-
1362
1729
  /**
1363
1730
  * Interface for Move Manager functionality
1364
1731
  *
@@ -1367,21 +1734,83 @@ export function WithMoveManager<TBase extends PlayerCtor>(Base: TBase) {
1367
1734
  * This interface defines the public API of the MoveManager mixin.
1368
1735
  */
1369
1736
  export interface IMoveManager {
1370
- /** Whether the player passes through other players */
1737
+ /**
1738
+ * Whether the player passes through other players
1739
+ *
1740
+ * When `true`, the player can walk through other player entities without collision.
1741
+ * This is useful for busy areas where players shouldn't block each other.
1742
+ *
1743
+ * @default true
1744
+ *
1745
+ * @example
1746
+ * ```ts
1747
+ * // Disable player-to-player collision
1748
+ * player.throughOtherPlayer = true;
1749
+ *
1750
+ * // Enable player-to-player collision
1751
+ * player.throughOtherPlayer = false;
1752
+ * ```
1753
+ */
1371
1754
  throughOtherPlayer: boolean;
1372
1755
 
1373
- /** Whether the player goes through events or other players */
1756
+ /**
1757
+ * Whether the player goes through all characters (players and events)
1758
+ *
1759
+ * When `true`, the player can walk through all character entities (both players and events)
1760
+ * without collision. Walls and obstacles still block movement.
1761
+ * This takes precedence over `throughOtherPlayer` and `throughEvent`.
1762
+ *
1763
+ * @default false
1764
+ *
1765
+ * @example
1766
+ * ```ts
1767
+ * // Enable ghost mode - pass through all characters
1768
+ * player.through = true;
1769
+ *
1770
+ * // Disable ghost mode
1771
+ * player.through = false;
1772
+ * ```
1773
+ */
1374
1774
  through: boolean;
1375
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
+
1376
1795
  /** Frequency for movement timing (milliseconds between movements) */
1377
1796
  frequency: number;
1378
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
+
1379
1804
  /**
1380
1805
  * Add a custom movement strategy to this entity
1381
1806
  *
1807
+ * Returns a Promise that resolves when the movement completes.
1808
+ *
1382
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
1383
1812
  */
1384
- addMovement(strategy: MovementStrategy): void;
1813
+ addMovement(strategy: MovementStrategy, options?: MovementOptions): Promise<void>;
1385
1814
 
1386
1815
  /**
1387
1816
  * Remove a specific movement strategy from this entity
@@ -1425,29 +1854,41 @@ export interface IMoveManager {
1425
1854
  /**
1426
1855
  * Perform a dash movement in the specified direction
1427
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
+ *
1428
1860
  * @param direction - Normalized direction vector
1429
- * @param speed - Movement speed (default: 8)
1861
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
1430
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
1431
1865
  */
1432
- dash(direction: { x: number, y: number }, speed?: number, duration?: number): void;
1866
+ dash(direction: { x: number, y: number }, additionalSpeed?: number, duration?: number, options?: MovementOptions): Promise<void>;
1433
1867
 
1434
1868
  /**
1435
1869
  * Apply knockback effect in the specified direction
1436
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
+ *
1437
1874
  * @param direction - Normalized direction vector
1438
- * @param force - Initial knockback force (default: 5)
1875
+ * @param force - Force multiplier applied to base speed (default: 5)
1439
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
1440
1879
  */
1441
- 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>;
1442
1881
 
1443
1882
  /**
1444
1883
  * Follow a sequence of waypoints
1445
1884
  *
1885
+ * Speed is calculated from the player's base speed multiplied by the speedMultiplier.
1886
+ *
1446
1887
  * @param waypoints - Array of x,y positions to follow
1447
- * @param speed - Movement speed (default: 2)
1888
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
1448
1889
  * @param loop - Whether to loop back to start (default: false)
1449
1890
  */
1450
- followPath(waypoints: Array<{ x: number, y: number }>, speed?: number, loop?: boolean): void;
1891
+ followPath(waypoints: Array<{ x: number, y: number }>, speedMultiplier?: number, loop?: boolean): void;
1451
1892
 
1452
1893
  /**
1453
1894
  * Apply oscillating movement pattern
@@ -1461,19 +1902,23 @@ export interface IMoveManager {
1461
1902
  /**
1462
1903
  * Apply ice movement physics
1463
1904
  *
1905
+ * Max speed is calculated from the player's base speed multiplied by the speedFactor.
1906
+ *
1464
1907
  * @param direction - Target movement direction
1465
- * @param maxSpeed - Maximum speed when fully accelerated (default: 4)
1908
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
1466
1909
  */
1467
- applyIceMovement(direction: { x: number, y: number }, maxSpeed?: number): void;
1910
+ applyIceMovement(direction: { x: number, y: number }, speedFactor?: number): void;
1468
1911
 
1469
1912
  /**
1470
1913
  * Shoot a projectile in the specified direction
1471
1914
  *
1915
+ * Speed is calculated from the player's base speed multiplied by the speedFactor.
1916
+ *
1472
1917
  * @param type - Type of projectile trajectory
1473
1918
  * @param direction - Normalized direction vector
1474
- * @param speed - Projectile speed (default: 200)
1919
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
1475
1920
  */
1476
- shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speed?: number): void;
1921
+ shootProjectile(type: ProjectileType, direction: { x: number, y: number }, speedFactor?: number): void;
1477
1922
 
1478
1923
  /**
1479
1924
  * Give an itinerary to follow using movement strategies
@@ -1503,3 +1948,4 @@ export interface IMoveManager {
1503
1948
  */
1504
1949
  replayRoutes(): void;
1505
1950
  }
1951
+