@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.
- package/dist/Player/MoveManager.d.ts +115 -50
- package/dist/Player/Player.d.ts +43 -19
- package/dist/Player/SkillManager.d.ts +157 -22
- package/dist/index.js +1007 -197
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/Player/MoveManager.ts +659 -213
- package/src/Player/Player.ts +89 -20
- package/src/Player/SkillManager.ts +401 -73
- package/src/rooms/map.ts +18 -0
- package/tests/battle.spec.ts +375 -0
- package/tests/class.spec.ts +274 -0
- package/tests/effect.spec.ts +219 -0
- package/tests/element.spec.ts +221 -0
- package/tests/gold.spec.ts +99 -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,
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
152
|
-
*
|
|
153
|
-
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* // Clear state when player leaves map
|
|
244
|
+
* Move.clearPlayerState(player.id);
|
|
245
|
+
* ```
|
|
154
246
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
734
|
-
new SeekAvoid(engine, targetProvider,
|
|
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
|
-
|
|
747
|
-
new SeekAvoid(engine, () => staticTarget,
|
|
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.
|
|
757
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1222
|
-
this.
|
|
1223
|
-
this.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 },
|
|
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 -
|
|
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
|
|
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 }>,
|
|
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
|
|
1908
|
+
* @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
|
|
1466
1909
|
*/
|
|
1467
|
-
applyIceMovement(direction: { x: number, y: number },
|
|
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
|
|
1919
|
+
* @param speedFactor - Factor multiplied with base speed (default: 50)
|
|
1475
1920
|
*/
|
|
1476
|
-
shootProjectile(type: ProjectileType, direction: { x: number, y: number },
|
|
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
|
+
|