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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9906,8 +9906,81 @@ class RpgCommonPlayer {
9906
9906
  this.isConnected = signal(false);
9907
9907
  // Store intended movement direction (not synced, only used locally)
9908
9908
  this._intendedDirection = null;
9909
+ // Direction and animation locking (server-side only, not synced)
9910
+ this._directionFixed = signal(false);
9911
+ this._animationFixed = signal(false);
9909
9912
  this.pendingInputs = [];
9910
9913
  }
9914
+ /**
9915
+ * Get whether direction changes are locked
9916
+ *
9917
+ * @returns True if direction is locked and cannot be changed automatically
9918
+ *
9919
+ * @example
9920
+ * ```ts
9921
+ * if (player.directionFixed) {
9922
+ * // Direction is locked, won't change automatically
9923
+ * }
9924
+ * ```
9925
+ */
9926
+ get directionFixed() {
9927
+ return this._directionFixed();
9928
+ }
9929
+ /**
9930
+ * Set whether direction changes are locked
9931
+ *
9932
+ * When set to true, the player's direction will not change automatically
9933
+ * during movement or from physics engine callbacks.
9934
+ *
9935
+ * @param value - True to lock direction, false to allow automatic changes
9936
+ *
9937
+ * @example
9938
+ * ```ts
9939
+ * // Lock direction during a special animation
9940
+ * player.directionFixed = true;
9941
+ * player.setAnimation('attack');
9942
+ * // ... later
9943
+ * player.directionFixed = false;
9944
+ * ```
9945
+ */
9946
+ set directionFixed(value) {
9947
+ this._directionFixed.set(value);
9948
+ }
9949
+ /**
9950
+ * Get whether animation changes are locked
9951
+ *
9952
+ * @returns True if animation is locked and cannot be changed automatically
9953
+ *
9954
+ * @example
9955
+ * ```ts
9956
+ * if (player.animationFixed) {
9957
+ * // Animation is locked, won't change automatically
9958
+ * }
9959
+ * ```
9960
+ */
9961
+ get animationFixed() {
9962
+ return this._animationFixed();
9963
+ }
9964
+ /**
9965
+ * Set whether animation changes are locked
9966
+ *
9967
+ * When set to true, the player's animation will not change automatically
9968
+ * during movement or from physics engine callbacks.
9969
+ *
9970
+ * @param value - True to lock animation, false to allow automatic changes
9971
+ *
9972
+ * @example
9973
+ * ```ts
9974
+ * // Lock animation during a special skill
9975
+ * player.animationFixed = true;
9976
+ * player.setAnimation('skill');
9977
+ * // ... later
9978
+ * player.animationFixed = false;
9979
+ * ```
9980
+ */
9981
+ set animationFixed(value) {
9982
+ this._animationFixed.set(value);
9983
+ }
9911
9984
  /**
9912
9985
  * Change the player's facing direction
9913
9986
  *
@@ -9915,6 +9988,8 @@ class RpgCommonPlayer {
9915
9988
  * and directional abilities. This should be called when the player
9916
9989
  * intends to move in a specific direction, not when they are pushed
9917
9990
  * by physics or sliding.
9991
+ *
9992
+ * If `directionFixed` is true, this method will not change the direction.
9918
9993
  *
9919
9994
  * @param direction - The new direction to face
9920
9995
  *
@@ -9922,9 +9997,16 @@ class RpgCommonPlayer {
9922
9997
  * ```ts
9923
9998
  * // Player presses right arrow key
9924
9999
  * player.changeDirection(Direction.Right);
10000
+ *
10001
+ * // Lock direction to prevent automatic changes
10002
+ * player.directionFixed = true;
10003
+ * player.changeDirection(Direction.Up); // This will be ignored
9925
10004
  * ```
9926
10005
  */
9927
10006
  changeDirection(direction) {
10007
+ if (this._directionFixed()) {
10008
+ return;
10009
+ }
9928
10010
  this.direction.set(direction);
9929
10011
  }
9930
10012
  /**
@@ -10759,6 +10841,8 @@ class Entity {
10759
10841
  this.enterTileHandlers = /* @__PURE__ */ new Set();
10760
10842
  this.leaveTileHandlers = /* @__PURE__ */ new Set();
10761
10843
  this.canEnterTileHandlers = /* @__PURE__ */ new Set();
10844
+ this.collisionFilterHandlers = /* @__PURE__ */ new Set();
10845
+ this.resolutionFilterHandlers = /* @__PURE__ */ new Set();
10762
10846
  this.wasMoving = this.velocity.lengthSquared() > MOVEMENT_EPSILON_SQ;
10763
10847
  }
10764
10848
  /**
@@ -11305,9 +11389,46 @@ class Entity {
11305
11389
  this.angularVelocity = Math.sign(this.angularVelocity) * this.maxAngularVelocity;
11306
11390
  }
11307
11391
  }
11392
+ /**
11393
+ * Adds a collision filter to this entity
11394
+ *
11395
+ * Collision filters allow dynamic, conditional collision filtering beyond static bitmasks.
11396
+ * Each filter is called when checking if this entity can collide with another.
11397
+ * If any filter returns `false`, the collision is ignored.
11398
+ *
11399
+ * This enables scenarios like:
11400
+ * - Players passing through other players (`throughOtherPlayer`)
11401
+ * - Entities passing through all characters (`through`)
11402
+ * - Custom game-specific collision rules
11403
+ *
11404
+ * @param filter - Function that returns `true` to allow collision, `false` to ignore
11405
+ * @returns Unsubscribe function to remove the filter
11406
+ *
11407
+ * @example
11408
+ * ```typescript
11409
+ * // Allow entity to pass through other players
11410
+ * const unsubscribe = entity.addCollisionFilter((self, other) => {
11411
+ * const otherOwner = (other as any).owner;
11412
+ * if (otherOwner?.type === 'player') {
11413
+ * return false; // No collision with players
11414
+ * }
11415
+ * return true; // Collide with everything else
11416
+ * });
11417
+ *
11418
+ * // Later, remove the filter
11419
+ * unsubscribe();
11420
+ * ```
11421
+ */
11422
+ addCollisionFilter(filter) {
11423
+ this.collisionFilterHandlers.add(filter);
11424
+ return () => this.collisionFilterHandlers.delete(filter);
11425
+ }
11308
11426
  /**
11309
11427
  * Checks if this entity can collide with another entity
11310
11428
  *
11429
+ * First checks collision masks (bitmask filtering), then executes all registered
11430
+ * collision filters. If any filter returns `false`, the collision is ignored.
11431
+ *
11311
11432
  * @param other - Other entity to check
11312
11433
  * @returns True if collision is possible
11313
11434
  */
@@ -11316,7 +11437,77 @@ class Entity {
11316
11437
  const maskA = this.collisionMask;
11317
11438
  const categoryB = other.collisionCategory;
11318
11439
  const maskB = other.collisionMask;
11319
- return (categoryA & maskB) !== 0 && (categoryB & maskA) !== 0;
11440
+ if ((categoryA & maskB) === 0 || (categoryB & maskA) === 0) {
11441
+ return false;
11442
+ }
11443
+ for (const filter of this.collisionFilterHandlers) {
11444
+ if (!filter(this, other)) {
11445
+ return false;
11446
+ }
11447
+ }
11448
+ for (const filter of other.collisionFilterHandlers) {
11449
+ if (!filter(other, this)) {
11450
+ return false;
11451
+ }
11452
+ }
11453
+ return true;
11454
+ }
11455
+ /**
11456
+ * Adds a resolution filter to this entity
11457
+ *
11458
+ * Resolution filters determine whether a collision should be **resolved** (blocking)
11459
+ * or just **detected** (notification only). Unlike collision filters which prevent
11460
+ * detection entirely, resolution filters allow collision events to fire while
11461
+ * optionally skipping the physical blocking.
11462
+ *
11463
+ * This enables scenarios like:
11464
+ * - Players passing through other players but still triggering touch events
11465
+ * - Entities passing through characters but still calling onPlayerTouch hooks
11466
+ * - Ghost mode where collisions are detected but not resolved
11467
+ *
11468
+ * @param filter - Function that returns `true` to resolve (block), `false` to skip
11469
+ * @returns Unsubscribe function to remove the filter
11470
+ *
11471
+ * @example
11472
+ * ```typescript
11473
+ * // Allow entity to pass through players but still trigger events
11474
+ * const unsubscribe = entity.addResolutionFilter((self, other) => {
11475
+ * const otherOwner = (other as any).owner;
11476
+ * if (otherOwner?.type === 'player') {
11477
+ * return false; // Pass through but events still fire
11478
+ * }
11479
+ * return true; // Block other entities
11480
+ * });
11481
+ *
11482
+ * // Later, remove the filter
11483
+ * unsubscribe();
11484
+ * ```
11485
+ */
11486
+ addResolutionFilter(filter) {
11487
+ this.resolutionFilterHandlers.add(filter);
11488
+ return () => this.resolutionFilterHandlers.delete(filter);
11489
+ }
11490
+ /**
11491
+ * Checks if this entity should resolve (block) a collision with another entity
11492
+ *
11493
+ * This is called by the CollisionResolver to determine if the collision should
11494
+ * result in physical blocking or just notification.
11495
+ *
11496
+ * @param other - Other entity to check
11497
+ * @returns True if collision should be resolved (blocking), false to pass through
11498
+ */
11499
+ shouldResolveCollisionWith(other) {
11500
+ for (const filter of this.resolutionFilterHandlers) {
11501
+ if (!filter(this, other)) {
11502
+ return false;
11503
+ }
11504
+ }
11505
+ for (const filter of other.resolutionFilterHandlers) {
11506
+ if (!filter(other, this)) {
11507
+ return false;
11508
+ }
11509
+ }
11510
+ return true;
11320
11511
  }
11321
11512
  /**
11322
11513
  * @internal
@@ -12967,6 +13158,9 @@ class CollisionResolver {
12967
13158
  * Resolves a collision
12968
13159
  *
12969
13160
  * Separates entities and applies collision response.
13161
+ * First checks if the collision should be resolved using resolution filters.
13162
+ * If any entity has a resolution filter that returns false, the collision
13163
+ * is skipped (entities pass through) but events are still fired.
12970
13164
  *
12971
13165
  * @param collision - Collision information to resolve
12972
13166
  */
@@ -12975,6 +13169,9 @@ class CollisionResolver {
12975
13169
  if (depth < this.config.minPenetrationDepth) {
12976
13170
  return;
12977
13171
  }
13172
+ if (!entityA.shouldResolveCollisionWith(entityB)) {
13173
+ return;
13174
+ }
12978
13175
  this.separateEntities(entityA, entityB, normal, depth);
12979
13176
  this.resolveVelocities(entityA, entityB, normal);
12980
13177
  }
@@ -14118,20 +14315,64 @@ let MovementManager$1 = class MovementManager {
14118
14315
  }
14119
14316
  /**
14120
14317
  * Adds a movement strategy to an entity.
14318
+ *
14319
+ * Returns a Promise that resolves when the movement completes (when `isFinished()` returns true).
14320
+ * If the strategy doesn't implement `isFinished()`, the Promise resolves immediately after adding.
14121
14321
  *
14122
14322
  * @param target - Entity instance or entity UUID when a resolver is configured
14123
14323
  * @param strategy - Strategy to execute
14324
+ * @param options - Optional callbacks for movement lifecycle events
14325
+ * @returns Promise that resolves when the movement completes
14326
+ *
14327
+ * @example
14328
+ * ```typescript
14329
+ * // Simple usage - fire and forget
14330
+ * manager.add(player, new Dash(8, { x: 1, y: 0 }, 200));
14331
+ *
14332
+ * // Wait for completion
14333
+ * await manager.add(player, new Dash(8, { x: 1, y: 0 }, 200));
14334
+ * console.log('Dash finished!');
14335
+ *
14336
+ * // With callbacks
14337
+ * await manager.add(player, new Knockback({ x: -1, y: 0 }, 5, 300), {
14338
+ * onStart: () => {
14339
+ * player.directionFixed = true;
14340
+ * player.animationFixed = true;
14341
+ * },
14342
+ * onComplete: () => {
14343
+ * player.directionFixed = false;
14344
+ * player.animationFixed = false;
14345
+ * }
14346
+ * });
14347
+ * ```
14124
14348
  */
14125
- add(target, strategy) {
14349
+ add(target, strategy, options) {
14126
14350
  const body = this.resolveTarget(target);
14127
14351
  const key = body.id;
14128
14352
  if (!this.entries.has(key)) {
14129
14353
  this.entries.set(key, { body, strategies: [] });
14130
14354
  }
14131
- this.entries.get(key).strategies.push(strategy);
14355
+ if (!strategy.isFinished) {
14356
+ const entry = { strategy, started: false };
14357
+ if (options) {
14358
+ entry.options = options;
14359
+ }
14360
+ this.entries.get(key).strategies.push(entry);
14361
+ return Promise.resolve();
14362
+ }
14363
+ return new Promise((resolve) => {
14364
+ const entry = { strategy, resolve, started: false };
14365
+ if (options) {
14366
+ entry.options = options;
14367
+ }
14368
+ this.entries.get(key).strategies.push(entry);
14369
+ });
14132
14370
  }
14133
14371
  /**
14134
14372
  * Removes a specific strategy from an entity.
14373
+ *
14374
+ * Note: This will NOT trigger the onComplete callback or resolve the Promise.
14375
+ * Use this when you want to cancel a movement without completion.
14135
14376
  *
14136
14377
  * @param target - Entity instance or identifier
14137
14378
  * @param strategy - Strategy instance to remove
@@ -14143,7 +14384,7 @@ let MovementManager$1 = class MovementManager {
14143
14384
  if (!entry) {
14144
14385
  return false;
14145
14386
  }
14146
- const index = entry.strategies.indexOf(strategy);
14387
+ const index = entry.strategies.findIndex((e) => e.strategy === strategy);
14147
14388
  if (index === -1) {
14148
14389
  return false;
14149
14390
  }
@@ -14222,13 +14463,21 @@ let MovementManager$1 = class MovementManager {
14222
14463
  getStrategies(target) {
14223
14464
  const body = this.resolveTarget(target);
14224
14465
  const entry = this.entries.get(body.id);
14225
- return entry ? [...entry.strategies] : [];
14466
+ return entry ? entry.strategies.map((e) => e.strategy) : [];
14226
14467
  }
14227
14468
  /**
14228
14469
  * Updates all registered strategies.
14229
14470
  *
14230
14471
  * Call this method once per frame before `PhysicsEngine.step()` so that the
14231
14472
  * physics simulation integrates the velocities that strategies configure.
14473
+ *
14474
+ * This method handles the movement lifecycle:
14475
+ * - Triggers `onStart` callback on first update
14476
+ * - Calls `strategy.update()` each frame
14477
+ * - When `isFinished()` returns true:
14478
+ * - Calls `strategy.onFinished()` if defined
14479
+ * - Triggers `onComplete` callback
14480
+ * - Resolves the Promise returned by `add()`
14232
14481
  *
14233
14482
  * @param dt - Time delta in seconds
14234
14483
  */
@@ -14240,14 +14489,22 @@ let MovementManager$1 = class MovementManager {
14240
14489
  continue;
14241
14490
  }
14242
14491
  for (let i = strategies.length - 1; i >= 0; i -= 1) {
14243
- const current = strategies[i];
14244
- if (!current) {
14492
+ const strategyEntry = strategies[i];
14493
+ if (!strategyEntry) {
14245
14494
  continue;
14246
14495
  }
14247
- current.update(body, dt);
14248
- if (current.isFinished?.()) {
14496
+ const { strategy, options, resolve } = strategyEntry;
14497
+ if (!strategyEntry.started) {
14498
+ strategyEntry.started = true;
14499
+ options?.onStart?.();
14500
+ }
14501
+ strategy.update(body, dt);
14502
+ const isFinished = strategy.isFinished?.();
14503
+ if (isFinished) {
14249
14504
  strategies.splice(i, 1);
14250
- current.onFinished?.();
14505
+ strategy.onFinished?.();
14506
+ options?.onComplete?.();
14507
+ resolve?.();
14251
14508
  }
14252
14509
  }
14253
14510
  if (strategies.length === 0) {
@@ -15770,8 +16027,16 @@ class MovementManager {
15770
16027
  get core() {
15771
16028
  return this.physicProvider().getMovementManager();
15772
16029
  }
15773
- add(id, strategy) {
15774
- this.core.add(id, strategy);
16030
+ /**
16031
+ * Adds a movement strategy and returns a Promise that resolves when it completes.
16032
+ *
16033
+ * @param id - Entity identifier
16034
+ * @param strategy - Movement strategy to add
16035
+ * @param options - Optional callbacks for movement lifecycle events
16036
+ * @returns Promise that resolves when the movement completes
16037
+ */
16038
+ add(id, strategy, options) {
16039
+ return this.core.add(id, strategy, options);
15775
16040
  }
15776
16041
  remove(id, strategy) {
15777
16042
  return this.core.remove(id, strategy);
@@ -16624,11 +16889,14 @@ class RpgCommonMap {
16624
16889
  const owner2 = entity.owner;
16625
16890
  if (!owner2) return;
16626
16891
  if (cardinalDirection === "idle") return;
16892
+ if (owner2.directionFixed) return;
16627
16893
  owner2.changeDirection(cardinalDirection);
16628
16894
  });
16629
16895
  entity.onMovementChange(({ isMoving, intensity }) => {
16896
+ if (!("$send" in this)) return;
16630
16897
  const owner2 = entity.owner;
16631
16898
  if (!owner2) return;
16899
+ if (owner2.animationFixed) return;
16632
16900
  const LOW_INTENSITY_THRESHOLD = 10;
16633
16901
  const hasSetAnimation = typeof owner2.setAnimation === "function";
16634
16902
  const animationNameSignal = owner2.animationName;
@@ -16665,6 +16933,56 @@ class RpgCommonMap {
16665
16933
  owner.applyFrames?.();
16666
16934
  }
16667
16935
  });
16936
+ entity.addResolutionFilter((self, other) => {
16937
+ const selfOwner = self.owner;
16938
+ const otherOwner = other.owner;
16939
+ if (!selfOwner || !otherOwner) {
16940
+ return true;
16941
+ }
16942
+ if (selfOwner.z() !== otherOwner.z()) {
16943
+ return false;
16944
+ }
16945
+ if (typeof selfOwner._through === "function") {
16946
+ try {
16947
+ if (selfOwner._through() === true) {
16948
+ return false;
16949
+ }
16950
+ } catch {
16951
+ }
16952
+ } else if (selfOwner.through === true) {
16953
+ return false;
16954
+ }
16955
+ const playersMap = this.players();
16956
+ const eventsMap = this.events();
16957
+ const isSelfPlayer = !!playersMap[self.uuid];
16958
+ const isOtherPlayer = !!playersMap[other.uuid];
16959
+ const isOtherEvent = !!eventsMap[other.uuid];
16960
+ if (isSelfPlayer && isOtherPlayer) {
16961
+ if (typeof selfOwner._throughOtherPlayer === "function") {
16962
+ try {
16963
+ if (selfOwner._throughOtherPlayer() === true) {
16964
+ return false;
16965
+ }
16966
+ } catch {
16967
+ }
16968
+ } else if (selfOwner.throughOtherPlayer === true) {
16969
+ return false;
16970
+ }
16971
+ }
16972
+ if (isSelfPlayer && isOtherEvent) {
16973
+ if (typeof selfOwner._throughEvent === "function") {
16974
+ try {
16975
+ if (selfOwner._throughEvent() === true) {
16976
+ return false;
16977
+ }
16978
+ } catch {
16979
+ }
16980
+ } else if (selfOwner.throughEvent === true) {
16981
+ return false;
16982
+ }
16983
+ }
16984
+ return true;
16985
+ });
16668
16986
  return id;
16669
16987
  }
16670
16988
  /**
@@ -18232,6 +18550,37 @@ class Gui {
18232
18550
  }
18233
18551
  }
18234
18552
 
18553
+ class AdditiveKnockback {
18554
+ constructor(direction, initialSpeed, duration, decayFactor = 0.35) {
18555
+ this.duration = duration;
18556
+ this.decayFactor = decayFactor;
18557
+ this.elapsed = 0;
18558
+ const magnitude = Math.hypot(direction.x, direction.y);
18559
+ this.direction = magnitude > 0 ? { x: direction.x / magnitude, y: direction.y / magnitude } : { x: 1, y: 0 };
18560
+ this.currentSpeed = initialSpeed;
18561
+ }
18562
+ update(body, dt) {
18563
+ this.elapsed += dt;
18564
+ if (this.elapsed > this.duration) {
18565
+ return;
18566
+ }
18567
+ const impulseX = this.direction.x * this.currentSpeed;
18568
+ const impulseY = this.direction.y * this.currentSpeed;
18569
+ body.setVelocity({
18570
+ x: body.velocity.x + impulseX,
18571
+ y: body.velocity.y + impulseY
18572
+ });
18573
+ const decay = Math.max(0, Math.min(1, this.decayFactor));
18574
+ if (decay === 0) {
18575
+ this.currentSpeed = 0;
18576
+ } else if (decay !== 1) {
18577
+ this.currentSpeed *= Math.pow(decay, dt);
18578
+ }
18579
+ }
18580
+ isFinished() {
18581
+ return this.elapsed >= this.duration;
18582
+ }
18583
+ }
18235
18584
  function wait(sec) {
18236
18585
  return new Promise((resolve) => {
18237
18586
  setTimeout(resolve, sec * 1e3);
@@ -18269,51 +18618,44 @@ class MoveList {
18269
18618
  // Instance counter for each call to ensure variation
18270
18619
  this.callCounter = 0;
18271
18620
  }
18621
+ static {
18622
+ // Track player positions and directions to detect stuck state
18623
+ this.playerMoveStates = /* @__PURE__ */ new Map();
18624
+ }
18625
+ static {
18626
+ // Threshold for considering a player as "stuck" (in pixels)
18627
+ this.STUCK_THRESHOLD = 2;
18628
+ }
18272
18629
  /**
18273
- * Gets a random direction index (0-3) using a hybrid approach for balanced randomness
18630
+ * Clears the movement state for a specific player
18631
+ *
18632
+ * Should be called when a player changes map or is destroyed to prevent
18633
+ * memory leaks and stale stuck detection data.
18274
18634
  *
18275
- * Uses a combination of hash-based pseudo-randomness and Perlin noise to ensure
18276
- * fair distribution of directions while maintaining smooth, natural-looking movement patterns.
18277
- * The hash function guarantees uniform distribution, while Perlin noise adds spatial/temporal coherence.
18635
+ * @param playerId - The ID of the player to clear state for
18278
18636
  *
18279
- * @param player - Optional player instance for coordinate-based noise
18280
- * @param index - Optional index for array-based calls to ensure variation
18281
- * @returns Direction index (0-3) corresponding to Right, Left, Up, Down
18637
+ * @example
18638
+ * ```ts
18639
+ * // Clear state when player leaves map
18640
+ * Move.clearPlayerState(player.id);
18641
+ * ```
18282
18642
  */
18283
- getRandomDirectionIndex(player, index) {
18284
- MoveList.callCounter++;
18285
- let seed;
18286
- const time = Date.now() * 1e-3;
18287
- if (player) {
18288
- const playerX = typeof player.x === "function" ? player.x() : player.x;
18289
- const playerY = typeof player.y === "function" ? player.y() : player.y;
18290
- seed = Math.floor(
18291
- playerX * 0.1 + playerY * 0.1 + time * 1e3 + MoveList.callCounter * 17 + (index ?? 0) * 31
18292
- );
18293
- } else {
18294
- MoveList.randomCounter++;
18295
- seed = Math.floor(
18296
- MoveList.randomCounter * 17 + time * 1e3 + MoveList.callCounter * 31 + (index ?? 0) * 47
18297
- );
18298
- }
18299
- let hash1 = (seed * 1103515245 + 12345 & 2147483647) >>> 0;
18300
- let hash2 = seed * 2654435761 >>> 0;
18301
- let hash3 = seed ^ seed >>> 16;
18302
- hash3 = hash3 * 2246822507 >>> 0;
18303
- let combinedHash = (hash1 ^ hash2 ^ hash3) >>> 0;
18304
- const hashValue = combinedHash % 1e6 / 1e6;
18305
- const perlinX = seed * 1e-3;
18306
- const perlinY = seed * 1.618 * 1e-3;
18307
- const perlinValue = MoveList.perlinNoise.getNormalized(perlinX, perlinY, 0.3);
18308
- const finalValue = hashValue * 0.9 + perlinValue * 0.1;
18309
- const clampedValue = Math.max(0, Math.min(0.999999, finalValue));
18310
- let directionIndex = Math.floor(clampedValue * 4);
18311
- directionIndex = Math.max(0, Math.min(3, directionIndex));
18312
- if (!Number.isFinite(directionIndex) || directionIndex < 0 || directionIndex > 3) {
18313
- const fallbackIndex = Math.floor(hashValue * 4) % 4;
18314
- return Math.max(0, Math.min(3, fallbackIndex));
18315
- }
18316
- return directionIndex;
18643
+ static clearPlayerState(playerId) {
18644
+ MoveList.playerMoveStates.delete(playerId);
18645
+ }
18646
+ /**
18647
+ * Clears all player movement states
18648
+ *
18649
+ * Useful for cleanup during server shutdown or when resetting game state.
18650
+ *
18651
+ * @example
18652
+ * ```ts
18653
+ * // Clear all states on server shutdown
18654
+ * Move.clearAllPlayerStates();
18655
+ * ```
18656
+ */
18657
+ static clearAllPlayerStates() {
18658
+ MoveList.playerMoveStates.clear();
18317
18659
  }
18318
18660
  repeatMove(direction, repeat) {
18319
18661
  if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1e4) {
@@ -18372,30 +18714,12 @@ class MoveList {
18372
18714
  return wait(sec);
18373
18715
  }
18374
18716
  random(repeat = 1) {
18375
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1e4) {
18376
- console.warn("Invalid repeat value in random:", repeat, "using default value 1");
18377
- repeat = 1;
18378
- }
18379
- repeat = Math.floor(repeat);
18380
- if (repeat < 0 || repeat > Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(repeat)) {
18381
- console.warn("Unsafe repeat value in random:", repeat, "using default value 1");
18382
- repeat = 1;
18383
- }
18384
- try {
18385
- MoveList.randomCounter += repeat;
18386
- return new Array(repeat).fill(null).map((_, index) => {
18387
- const directionIndex = this.getRandomDirectionIndex(void 0, index);
18388
- return [
18389
- Direction.Right,
18390
- Direction.Left,
18391
- Direction.Up,
18392
- Direction.Down
18393
- ][directionIndex];
18394
- });
18395
- } catch (error) {
18396
- console.error("Error creating random array with repeat:", repeat, error);
18397
- return [Direction.Down];
18398
- }
18717
+ return new Array(repeat).fill(null).map(() => [
18718
+ Direction.Right,
18719
+ Direction.Left,
18720
+ Direction.Up,
18721
+ Direction.Down
18722
+ ][random(0, 3)]);
18399
18723
  }
18400
18724
  tileRight(repeat = 1) {
18401
18725
  return this.repeatTileMove("right", repeat, "tileWidth");
@@ -18411,41 +18735,18 @@ class MoveList {
18411
18735
  }
18412
18736
  tileRandom(repeat = 1) {
18413
18737
  return (player, map) => {
18414
- if (!Number.isFinite(repeat) || repeat < 0 || repeat > 1e3) {
18415
- console.warn("Invalid repeat value in tileRandom:", repeat, "using default value 1");
18416
- repeat = 1;
18417
- }
18418
- repeat = Math.floor(repeat);
18419
18738
  let directions = [];
18420
- const directionFunctions = [
18421
- this.tileRight(),
18422
- this.tileLeft(),
18423
- this.tileUp(),
18424
- this.tileDown()
18425
- ];
18426
18739
  for (let i = 0; i < repeat; i++) {
18427
- let directionIndex = this.getRandomDirectionIndex(player, i);
18428
- if (!Number.isInteger(directionIndex) || directionIndex < 0 || directionIndex > 3) {
18429
- console.warn("Invalid directionIndex in tileRandom:", directionIndex, "using fallback");
18430
- directionIndex = Math.floor(Math.random() * 4) % 4;
18431
- }
18432
- const randFn = directionFunctions[directionIndex];
18433
- if (typeof randFn !== "function") {
18434
- console.warn("randFn is not a function in tileRandom, skipping iteration");
18435
- continue;
18436
- }
18437
- try {
18438
- const newDirections = randFn(player, map);
18439
- if (Array.isArray(newDirections)) {
18440
- directions = [...directions, ...newDirections];
18441
- }
18442
- } catch (error) {
18443
- console.warn("Error in tileRandom iteration:", error);
18444
- }
18445
- if (directions.length > 1e4) {
18446
- console.warn("tileRandom generated too many directions, truncating");
18447
- break;
18448
- }
18740
+ const randFn = [
18741
+ this.tileRight(),
18742
+ this.tileLeft(),
18743
+ this.tileUp(),
18744
+ this.tileDown()
18745
+ ][random(0, 3)];
18746
+ directions = [
18747
+ ...directions,
18748
+ ...randFn(player, map)
18749
+ ];
18449
18750
  }
18450
18751
  return directions;
18451
18752
  };
@@ -18603,26 +18904,73 @@ function WithMoveManager(Base) {
18603
18904
  get through() {
18604
18905
  return this._through();
18605
18906
  }
18907
+ set throughEvent(value) {
18908
+ this._throughEvent.set(value);
18909
+ }
18910
+ get throughEvent() {
18911
+ return this._throughEvent();
18912
+ }
18606
18913
  set frequency(value) {
18607
18914
  this._frequency.set(value);
18608
18915
  }
18609
18916
  get frequency() {
18610
18917
  return this._frequency();
18611
18918
  }
18612
- addMovement(strategy) {
18919
+ set directionFixed(value) {
18920
+ this._directionFixed.set(value);
18921
+ }
18922
+ get directionFixed() {
18923
+ return this._directionFixed();
18924
+ }
18925
+ set animationFixed(value) {
18926
+ this._animationFixed.set(value);
18927
+ }
18928
+ get animationFixed() {
18929
+ return this._animationFixed();
18930
+ }
18931
+ /**
18932
+ * Add a movement strategy to this entity
18933
+ *
18934
+ * Returns a Promise that resolves when the movement completes (when `isFinished()` returns true).
18935
+ * If the strategy doesn't implement `isFinished()`, the Promise resolves immediately.
18936
+ *
18937
+ * @param strategy - The movement strategy to add
18938
+ * @param options - Optional callbacks for start and completion events
18939
+ * @returns Promise that resolves when the movement completes
18940
+ *
18941
+ * @example
18942
+ * ```ts
18943
+ * // Fire and forget
18944
+ * player.addMovement(new LinearMove({ x: 1, y: 0 }, 200));
18945
+ *
18946
+ * // Wait for completion
18947
+ * await player.addMovement(new Dash(10, { x: 1, y: 0 }, 200));
18948
+ * console.log('Dash completed!');
18949
+ *
18950
+ * // With callbacks
18951
+ * await player.addMovement(new Knockback({ x: -1, y: 0 }, 5, 300), {
18952
+ * onStart: () => console.log('Knockback started'),
18953
+ * onComplete: () => console.log('Knockback completed')
18954
+ * });
18955
+ * ```
18956
+ */
18957
+ addMovement(strategy, options) {
18613
18958
  const map = this.getCurrentMap();
18614
- if (!map) return;
18615
- map.moveManager.add(this.id, strategy);
18959
+ if (!map) return Promise.resolve();
18960
+ const playerId = this.id;
18961
+ return map.moveManager.add(playerId, strategy, options);
18616
18962
  }
18617
18963
  removeMovement(strategy) {
18618
18964
  const map = this.getCurrentMap();
18619
18965
  if (!map) return false;
18620
- return map.moveManager.remove(this.id, strategy);
18966
+ const playerId = this.id;
18967
+ return map.moveManager.remove(playerId, strategy);
18621
18968
  }
18622
18969
  clearMovements() {
18623
18970
  const map = this.getCurrentMap();
18624
18971
  if (!map) return;
18625
- map.moveManager.clear(this.id);
18972
+ const playerId = this.id;
18973
+ map.moveManager.clear(playerId);
18626
18974
  }
18627
18975
  hasActiveMovements() {
18628
18976
  const map = this.getCurrentMap();
@@ -18634,15 +18982,45 @@ function WithMoveManager(Base) {
18634
18982
  if (!map) return [];
18635
18983
  return map.moveManager.getStrategies(this.id);
18636
18984
  }
18985
+ /**
18986
+ * Move toward a target player or position using AI pathfinding
18987
+ *
18988
+ * Uses the `SeekAvoid` strategy to navigate toward the target while avoiding obstacles.
18989
+ * The movement speed is based on the player's current `speed` property, scaled appropriately.
18990
+ *
18991
+ * @param target - Target player or position `{ x, y }` to move toward
18992
+ *
18993
+ * @example
18994
+ * ```ts
18995
+ * // Move toward another player
18996
+ * player.moveTo(otherPlayer);
18997
+ *
18998
+ * // Move toward a specific position
18999
+ * player.moveTo({ x: 200, y: 150 });
19000
+ * ```
19001
+ */
18637
19002
  moveTo(target) {
18638
19003
  const map = this.getCurrentMap();
18639
19004
  if (!map) return;
19005
+ const playerId = this.id;
18640
19006
  const engine = map.physic;
19007
+ const playerSpeed = this.speed();
19008
+ const existingStrategies = this.getActiveMovements();
19009
+ const conflictingStrategies = existingStrategies.filter(
19010
+ (s) => s instanceof SeekAvoid || s instanceof Dash || s instanceof Knockback || s instanceof LinearRepulsion
19011
+ );
19012
+ if (conflictingStrategies.length > 0) {
19013
+ conflictingStrategies.forEach((s) => this.removeMovement(s));
19014
+ }
18641
19015
  if ("id" in target) {
18642
- const targetProvider = () => map.getBody(target.id) ?? null;
19016
+ const targetProvider = () => {
19017
+ const body = map.getBody(target.id) ?? null;
19018
+ return body;
19019
+ };
19020
+ const maxSpeed2 = playerSpeed * 45;
18643
19021
  map.moveManager.add(
18644
- this.id,
18645
- new SeekAvoid(engine, targetProvider, 180, 140, 80, 48)
19022
+ playerId,
19023
+ new SeekAvoid(engine, targetProvider, maxSpeed2, 140, 80, 48)
18646
19024
  );
18647
19025
  return;
18648
19026
  }
@@ -18651,37 +19029,275 @@ function WithMoveManager(Base) {
18651
19029
  mass: Infinity
18652
19030
  });
18653
19031
  staticTarget.freeze();
19032
+ const maxSpeed = playerSpeed * 20;
18654
19033
  map.moveManager.add(
18655
- this.id,
18656
- new SeekAvoid(engine, () => staticTarget, 80, 140, 80, 48)
19034
+ playerId,
19035
+ new SeekAvoid(engine, () => staticTarget, maxSpeed, 140, 80, 48)
18657
19036
  );
18658
19037
  }
18659
19038
  stopMoveTo() {
18660
19039
  const map = this.getCurrentMap();
18661
19040
  if (!map) return;
19041
+ this.id;
18662
19042
  const strategies = this.getActiveMovements();
18663
- strategies.forEach((strategy) => {
18664
- if (strategy instanceof SeekAvoid || strategy instanceof LinearRepulsion) {
19043
+ const toRemove = strategies.filter((s) => s instanceof SeekAvoid || s instanceof LinearRepulsion);
19044
+ if (toRemove.length > 0) {
19045
+ toRemove.forEach((strategy) => {
18665
19046
  this.removeMovement(strategy);
18666
- }
18667
- });
19047
+ });
19048
+ }
18668
19049
  }
18669
- dash(direction, speed = 8, duration = 200) {
18670
- this.addMovement(new Dash(speed, direction, duration));
19050
+ /**
19051
+ * Perform a dash movement in the specified direction
19052
+ *
19053
+ * Creates a burst of velocity for a fixed duration. The total speed is calculated
19054
+ * by adding the player's base speed (`this.speed()`) to the additional dash speed.
19055
+ * This ensures faster players also dash faster proportionally.
19056
+ *
19057
+ * With default speed=4 and additionalSpeed=4: total = 8 (same as original default)
19058
+ *
19059
+ * @param direction - Normalized direction vector `{ x, y }` for the dash
19060
+ * @param additionalSpeed - Extra speed added on top of base speed (default: 4)
19061
+ * @param duration - Duration in milliseconds (default: 200)
19062
+ * @param options - Optional callbacks for movement events
19063
+ * @returns Promise that resolves when the dash completes
19064
+ *
19065
+ * @example
19066
+ * ```ts
19067
+ * // Dash to the right and wait for completion
19068
+ * await player.dash({ x: 1, y: 0 });
19069
+ *
19070
+ * // Powerful dash with callbacks
19071
+ * await player.dash({ x: 0, y: -1 }, 12, 300, {
19072
+ * onStart: () => console.log('Dash started!'),
19073
+ * onComplete: () => console.log('Dash finished!')
19074
+ * });
19075
+ * ```
19076
+ */
19077
+ dash(direction, additionalSpeed = 4, duration = 200, options) {
19078
+ const playerSpeed = this.speed();
19079
+ const totalSpeed = playerSpeed + additionalSpeed;
19080
+ const durationSeconds = duration / 1e3;
19081
+ return this.addMovement(new Dash(totalSpeed, direction, durationSeconds), options);
18671
19082
  }
18672
- knockback(direction, force = 5, duration = 300) {
18673
- this.addMovement(new Knockback(direction, force, duration));
19083
+ /**
19084
+ * Apply knockback effect in the specified direction
19085
+ *
19086
+ * Pushes the entity with an initial force that decays over time.
19087
+ * Returns a Promise that resolves when the knockback completes **or is cancelled**.
19088
+ *
19089
+ * ## Design notes
19090
+ * - The underlying physics `MovementManager` can cancel strategies via `remove()`, `clear()`,
19091
+ * or `stopMovement()` **without resolving the Promise** returned by `add()`.
19092
+ * - For this reason, this method considers the knockback finished when either:
19093
+ * - the `add()` promise resolves (normal completion), or
19094
+ * - the strategy is no longer present in the active movements list (cancellation).
19095
+ * - When multiple knockbacks overlap, `directionFixed` and `animationFixed` are restored
19096
+ * only after **all** knockbacks have finished (including cancellations).
19097
+ *
19098
+ * @param direction - Normalized direction vector `{ x, y }` for the knockback
19099
+ * @param force - Initial knockback force (default: 5)
19100
+ * @param duration - Duration in milliseconds (default: 300)
19101
+ * @param options - Optional callbacks for movement events
19102
+ * @returns Promise that resolves when the knockback completes or is cancelled
19103
+ *
19104
+ * @example
19105
+ * ```ts
19106
+ * // Simple knockback (await is optional)
19107
+ * await player.knockback({ x: 1, y: 0 }, 5, 300);
19108
+ *
19109
+ * // Overlapping knockbacks: flags are restored only after the last one ends
19110
+ * player.knockback({ x: -1, y: 0 }, 5, 300);
19111
+ * player.knockback({ x: 0, y: 1 }, 3, 200);
19112
+ *
19113
+ * // Cancellation (e.g. map change) will still restore fixed flags
19114
+ * // even if the underlying movement strategy promise is never resolved.
19115
+ * ```
19116
+ */
19117
+ async knockback(direction, force = 5, duration = 300, options) {
19118
+ const durationSeconds = duration / 1e3;
19119
+ const selfAny = this;
19120
+ const lockKey = "__rpg_knockback_lock__";
19121
+ const getLock = () => selfAny[lockKey];
19122
+ const setLock = (lock) => {
19123
+ selfAny[lockKey] = lock;
19124
+ };
19125
+ const clearLock = () => {
19126
+ delete selfAny[lockKey];
19127
+ };
19128
+ const hasActiveKnockback = () => this.getActiveMovements().some((s) => s instanceof Knockback || s instanceof AdditiveKnockback);
19129
+ const setAnimationName = (name) => {
19130
+ if (typeof selfAny.setAnimation === "function") {
19131
+ selfAny.setAnimation(name);
19132
+ return;
19133
+ }
19134
+ const animSignal = selfAny.animationName;
19135
+ if (animSignal && typeof animSignal === "object" && typeof animSignal.set === "function") {
19136
+ animSignal.set(name);
19137
+ }
19138
+ };
19139
+ const getAnimationName = () => {
19140
+ const animSignal = selfAny.animationName;
19141
+ if (typeof animSignal === "function") {
19142
+ try {
19143
+ return animSignal();
19144
+ } catch {
19145
+ return void 0;
19146
+ }
19147
+ }
19148
+ return void 0;
19149
+ };
19150
+ const restore = () => {
19151
+ const lock = getLock();
19152
+ if (!lock) return;
19153
+ this.directionFixed = lock.prevDirectionFixed;
19154
+ const prevAnimFixed = lock.prevAnimationFixed;
19155
+ this.animationFixed = false;
19156
+ if (!prevAnimFixed && lock.prevAnimationName) {
19157
+ setAnimationName(lock.prevAnimationName);
19158
+ }
19159
+ this.animationFixed = prevAnimFixed;
19160
+ clearLock();
19161
+ };
19162
+ const ensureLockInitialized = () => {
19163
+ if (getLock()) return;
19164
+ setLock({
19165
+ prevDirectionFixed: this.directionFixed,
19166
+ prevAnimationFixed: this.animationFixed,
19167
+ prevAnimationName: getAnimationName()
19168
+ });
19169
+ this.directionFixed = true;
19170
+ setAnimationName("stand");
19171
+ this.animationFixed = true;
19172
+ };
19173
+ const waitUntilRemovedOrTimeout = (strategy2) => {
19174
+ return new Promise((resolve) => {
19175
+ const start = Date.now();
19176
+ const maxMs = Math.max(0, duration + 1e3);
19177
+ const intervalId = setInterval(() => {
19178
+ const active = this.getActiveMovements();
19179
+ if (!active.includes(strategy2) || Date.now() - start > maxMs) {
19180
+ clearInterval(intervalId);
19181
+ resolve();
19182
+ }
19183
+ }, 16);
19184
+ });
19185
+ };
19186
+ ensureLockInitialized();
19187
+ const strategy = new AdditiveKnockback(direction, force, durationSeconds);
19188
+ const addPromise = this.addMovement(strategy, options);
19189
+ try {
19190
+ await Promise.race([addPromise, waitUntilRemovedOrTimeout(strategy)]);
19191
+ } finally {
19192
+ if (!hasActiveKnockback()) {
19193
+ restore();
19194
+ }
19195
+ }
18674
19196
  }
18675
- followPath(waypoints, speed = 2, loop = false) {
19197
+ /**
19198
+ * Follow a sequence of waypoints
19199
+ *
19200
+ * Makes the entity move through a list of positions at a speed calculated
19201
+ * from the player's base speed. The `speedMultiplier` allows adjusting
19202
+ * the travel speed relative to the player's normal movement speed.
19203
+ *
19204
+ * With default speed=4 and multiplier=0.5: speed = 2 (same as original default)
19205
+ *
19206
+ * @param waypoints - Array of `{ x, y }` positions to follow in order
19207
+ * @param speedMultiplier - Multiplier applied to base speed (default: 0.5)
19208
+ * @param loop - Whether to loop back to start after reaching the end (default: false)
19209
+ *
19210
+ * @example
19211
+ * ```ts
19212
+ * // Follow a patrol path at normal speed
19213
+ * const patrol = [
19214
+ * { x: 100, y: 100 },
19215
+ * { x: 200, y: 100 },
19216
+ * { x: 200, y: 200 }
19217
+ * ];
19218
+ * player.followPath(patrol, 1, true); // Loop at full speed
19219
+ *
19220
+ * // Slow walk through waypoints
19221
+ * player.followPath(waypoints, 0.25, false);
19222
+ * ```
19223
+ */
19224
+ followPath(waypoints, speedMultiplier = 0.5, loop = false) {
19225
+ const playerSpeed = this.speed();
19226
+ const speed = playerSpeed * speedMultiplier;
18676
19227
  this.addMovement(new PathFollow(waypoints, speed, loop));
18677
19228
  }
19229
+ /**
19230
+ * Apply oscillating movement pattern
19231
+ *
19232
+ * Creates a back-and-forth movement along the specified axis. The movement
19233
+ * oscillates sinusoidally between -amplitude and +amplitude from the starting position.
19234
+ *
19235
+ * @param direction - Primary oscillation axis (normalized direction vector)
19236
+ * @param amplitude - Maximum distance from center in pixels (default: 50)
19237
+ * @param period - Time for a complete cycle in milliseconds (default: 2000)
19238
+ *
19239
+ * @example
19240
+ * ```ts
19241
+ * // Horizontal oscillation
19242
+ * player.oscillate({ x: 1, y: 0 }, 100, 3000);
19243
+ *
19244
+ * // Diagonal bobbing motion
19245
+ * player.oscillate({ x: 1, y: 1 }, 30, 1000);
19246
+ * ```
19247
+ */
18678
19248
  oscillate(direction, amplitude = 50, period = 2e3) {
18679
19249
  this.addMovement(new Oscillate(direction, amplitude, period));
18680
19250
  }
18681
- applyIceMovement(direction, maxSpeed = 4) {
19251
+ /**
19252
+ * Apply ice movement physics
19253
+ *
19254
+ * Simulates slippery surface physics where the entity accelerates gradually
19255
+ * and has difficulty stopping. The maximum speed is based on the player's
19256
+ * base speed multiplied by a speed factor.
19257
+ *
19258
+ * With default speed=4 and factor=1: maxSpeed = 4 (same as original default)
19259
+ *
19260
+ * @param direction - Target movement direction `{ x, y }`
19261
+ * @param speedFactor - Factor multiplied with base speed for max speed (default: 1.0)
19262
+ *
19263
+ * @example
19264
+ * ```ts
19265
+ * // Normal ice physics
19266
+ * player.applyIceMovement({ x: 1, y: 0 });
19267
+ *
19268
+ * // Fast ice sliding
19269
+ * player.applyIceMovement({ x: 0, y: 1 }, 1.5);
19270
+ * ```
19271
+ */
19272
+ applyIceMovement(direction, speedFactor = 1) {
19273
+ const playerSpeed = this.speed();
19274
+ const maxSpeed = playerSpeed * speedFactor;
18682
19275
  this.addMovement(new IceMovement(direction, maxSpeed));
18683
19276
  }
18684
- shootProjectile(type, direction, speed = 200) {
19277
+ /**
19278
+ * Shoot a projectile in the specified direction
19279
+ *
19280
+ * Creates a projectile with ballistic trajectory. The speed is calculated
19281
+ * from the player's base speed multiplied by a speed factor.
19282
+ *
19283
+ * With default speed=4 and factor=50: speed = 200 (same as original default)
19284
+ *
19285
+ * @param type - Type of projectile trajectory (`Straight`, `Arc`, or `Bounce`)
19286
+ * @param direction - Normalized direction vector `{ x, y }`
19287
+ * @param speedFactor - Factor multiplied with base speed (default: 50)
19288
+ *
19289
+ * @example
19290
+ * ```ts
19291
+ * // Straight projectile
19292
+ * player.shootProjectile(ProjectileType.Straight, { x: 1, y: 0 });
19293
+ *
19294
+ * // Fast arc projectile
19295
+ * player.shootProjectile(ProjectileType.Arc, { x: 1, y: -0.5 }, 75);
19296
+ * ```
19297
+ */
19298
+ shootProjectile(type, direction, speedFactor = 50) {
19299
+ const playerSpeed = this.speed();
19300
+ const speed = playerSpeed * speedFactor;
18685
19301
  const config = {
18686
19302
  speed,
18687
19303
  direction,
@@ -18744,10 +19360,13 @@ function WithMoveManager(Base) {
18744
19360
  this.stuckThreshold = options2?.stuckThreshold ?? 1;
18745
19361
  this.processNextRoute();
18746
19362
  }
19363
+ debugLog(message, data) {
19364
+ }
18747
19365
  processNextRoute() {
18748
19366
  this.waitingForFrequency = false;
18749
19367
  this.frequencyWaitStartTime = 0;
18750
19368
  if (this.routeIndex >= this.routes.length) {
19369
+ this.debugLog("COMPLETE all routes finished");
18751
19370
  this.finished = true;
18752
19371
  this.onComplete(true);
18753
19372
  return;
@@ -18760,13 +19379,16 @@ function WithMoveManager(Base) {
18760
19379
  }
18761
19380
  try {
18762
19381
  if (typeof currentRoute === "object" && "then" in currentRoute) {
19382
+ this.debugLog(`WAIT for promise (route ${this.routeIndex}/${this.routes.length})`);
18763
19383
  this.waitingForPromise = true;
18764
19384
  this.promiseStartTime = Date.now();
18765
19385
  this.promiseDuration = 1e3;
18766
19386
  currentRoute.then(() => {
19387
+ this.debugLog("WAIT promise resolved");
18767
19388
  this.waitingForPromise = false;
18768
19389
  this.processNextRoute();
18769
19390
  }).catch(() => {
19391
+ this.debugLog("WAIT promise rejected");
18770
19392
  this.waitingForPromise = false;
18771
19393
  this.processNextRoute();
18772
19394
  });
@@ -18791,6 +19413,7 @@ function WithMoveManager(Base) {
18791
19413
  direction = Direction.Right;
18792
19414
  break;
18793
19415
  }
19416
+ this.debugLog(`TURN to ${directionStr}`);
18794
19417
  if (this.player.changeDirection) {
18795
19418
  this.player.changeDirection(direction);
18796
19419
  }
@@ -18852,6 +19475,7 @@ function WithMoveManager(Base) {
18852
19475
  this.currentTarget = { x: targetX, y: targetY };
18853
19476
  this.currentTargetTopLeft = { x: targetTopLeftX, y: targetTopLeftY };
18854
19477
  this.currentDirection = { x: 0, y: 0 };
19478
+ this.debugLog(`MOVE direction=${moveDirection} from=(${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)}) to=(${targetTopLeftX.toFixed(1)}, ${targetTopLeftY.toFixed(1)}) dist=${distance.toFixed(1)}`);
18855
19479
  this.lastPosition = null;
18856
19480
  this.isCurrentlyStuck = false;
18857
19481
  this.stuckCheckStartTime = 0;
@@ -18921,6 +19545,7 @@ function WithMoveManager(Base) {
18921
19545
  dy = this.currentTargetTopLeft.y - currentTopLeftY;
18922
19546
  distance = Math.hypot(dx, dy);
18923
19547
  if (distance <= this.tolerance) {
19548
+ this.debugLog(`TARGET reached at (${currentTopLeftX.toFixed(1)}, ${currentTopLeftY.toFixed(1)})`);
18924
19549
  this.currentTarget = null;
18925
19550
  this.currentTargetTopLeft = null;
18926
19551
  this.currentDirection = { x: 0, y: 0 };
@@ -18983,21 +19608,30 @@ function WithMoveManager(Base) {
18983
19608
  this.isCurrentlyStuck = true;
18984
19609
  } else {
18985
19610
  if (currentTime - this.stuckCheckStartTime >= this.stuckTimeout) {
19611
+ this.debugLog(`STUCK detected at (${currentPosition.x.toFixed(1)}, ${currentPosition.y.toFixed(1)}) target=(${this.currentTarget.x.toFixed(1)}, ${this.currentTarget.y.toFixed(1)})`);
18986
19612
  const shouldContinue = this.onStuck(
18987
19613
  this.player,
18988
19614
  this.currentTarget,
18989
19615
  currentPosition
18990
19616
  );
18991
19617
  if (shouldContinue === false) {
19618
+ this.debugLog("STUCK cancelled route");
18992
19619
  this.finished = true;
18993
19620
  this.onComplete(false);
18994
19621
  body.setVelocity({ x: 0, y: 0 });
18995
19622
  return;
18996
19623
  }
19624
+ this.currentTarget = null;
19625
+ this.currentTargetTopLeft = null;
19626
+ this.currentDirection = { x: 0, y: 0 };
19627
+ body.setVelocity({ x: 0, y: 0 });
18997
19628
  this.isCurrentlyStuck = false;
18998
19629
  this.stuckCheckStartTime = 0;
18999
- this.lastPosition = { ...currentPosition };
19000
- this.lastDistanceToTarget = distance;
19630
+ this.lastPosition = null;
19631
+ this.lastDistanceToTarget = null;
19632
+ this.stuckCheckInitialized = false;
19633
+ this.processNextRoute();
19634
+ return;
19001
19635
  }
19002
19636
  }
19003
19637
  } else {
@@ -19066,12 +19700,12 @@ function WithMoveManager(Base) {
19066
19700
  return acc.concat(item);
19067
19701
  }, []);
19068
19702
  }
19069
- infiniteMoveRoute(routes) {
19703
+ infiniteMoveRoute(routes, options) {
19070
19704
  this._infiniteRoutes = routes;
19071
19705
  this._isInfiniteRouteActive = true;
19072
19706
  const executeInfiniteRoute = (isBreaking = false) => {
19073
19707
  if (isBreaking || !this._isInfiniteRouteActive) return;
19074
- this.moveRoutes(routes).then((completed) => {
19708
+ this.moveRoutes(routes, options).then((completed) => {
19075
19709
  if (completed && this._isInfiniteRouteActive) {
19076
19710
  executeInfiniteRoute();
19077
19711
  }
@@ -20362,64 +20996,208 @@ function WithElementManager(Base) {
20362
20996
 
20363
20997
  function WithSkillManager(Base) {
20364
20998
  return class extends Base {
20365
- _getSkillIndex(skillClass) {
20999
+ /**
21000
+ * Find the index of a skill in the skills array
21001
+ *
21002
+ * Searches by ID for both string inputs and object/class inputs.
21003
+ *
21004
+ * @param skillInput - Skill ID, class, or object to find
21005
+ * @returns Index of the skill or -1 if not found
21006
+ */
21007
+ _getSkillIndex(skillInput) {
21008
+ let searchId = "";
21009
+ if (isString(skillInput)) {
21010
+ searchId = skillInput;
21011
+ } else if (typeof skillInput === "function") {
21012
+ searchId = skillInput.id || skillInput.name;
21013
+ } else {
21014
+ searchId = skillInput.id || "";
21015
+ }
20366
21016
  return this.skills().findIndex((skill) => {
20367
- if (isString(skill)) {
20368
- return skill.id == skillClass;
20369
- }
20370
- if (isString(skillClass)) {
20371
- return skillClass == (skill.id || skill);
20372
- }
20373
- return isInstanceOf(skill, skillClass);
21017
+ const skillId = skill.id || skill.name || "";
21018
+ return skillId === searchId;
20374
21019
  });
20375
21020
  }
20376
- getSkill(skillClass) {
20377
- const index = this._getSkillIndex(skillClass);
21021
+ /**
21022
+ * Retrieves a learned skill
21023
+ *
21024
+ * Searches the player's learned skills by ID, class, or object.
21025
+ *
21026
+ * @param skillInput - Skill ID, class, or object to find
21027
+ * @returns The skill data if found, null otherwise
21028
+ *
21029
+ * @example
21030
+ * ```ts
21031
+ * const skill = player.getSkill('fire');
21032
+ * if (skill) {
21033
+ * console.log(`Fire skill costs ${skill.spCost} SP`);
21034
+ * }
21035
+ * ```
21036
+ */
21037
+ getSkill(skillInput) {
21038
+ const index = this._getSkillIndex(skillInput);
20378
21039
  return this.skills()[index] ?? null;
20379
21040
  }
20380
- learnSkill(skillId) {
21041
+ /**
21042
+ * Learn a new skill
21043
+ *
21044
+ * Adds a skill to the player's skill list. Supports three input formats:
21045
+ * - **String ID**: Retrieves the skill from the database
21046
+ * - **Class**: Creates an instance and adds to database if needed
21047
+ * - **Object**: Uses directly and adds to database if needed
21048
+ *
21049
+ * @param skillInput - Skill ID, class, or object to learn
21050
+ * @returns The learned skill data
21051
+ * @throws SkillLog.alreadyLearned if the skill is already known
21052
+ *
21053
+ * @example
21054
+ * ```ts
21055
+ * // From database
21056
+ * player.learnSkill('fire');
21057
+ *
21058
+ * // From class
21059
+ * player.learnSkill(FireSkill);
21060
+ *
21061
+ * // From object
21062
+ * player.learnSkill({
21063
+ * id: 'custom-skill',
21064
+ * name: 'Custom Skill',
21065
+ * spCost: 20,
21066
+ * onLearn(player) {
21067
+ * console.log('Learned custom skill!');
21068
+ * }
21069
+ * });
21070
+ * ```
21071
+ */
21072
+ learnSkill(skillInput) {
21073
+ const map = this.getCurrentMap() || this.map;
21074
+ let skillId = "";
21075
+ let skillData;
21076
+ if (isString(skillInput)) {
21077
+ skillId = skillInput;
21078
+ skillData = this.databaseById(skillId);
21079
+ } else if (typeof skillInput === "function") {
21080
+ const SkillClassCtor = skillInput;
21081
+ skillId = SkillClassCtor.id || SkillClassCtor.name;
21082
+ const existingData = map?.database()?.[skillId];
21083
+ if (existingData) {
21084
+ skillData = existingData;
21085
+ } else if (map) {
21086
+ map.addInDatabase(skillId, SkillClassCtor);
21087
+ skillData = SkillClassCtor;
21088
+ } else {
21089
+ skillData = SkillClassCtor;
21090
+ }
21091
+ const skillInstance = new SkillClassCtor();
21092
+ skillData = { ...skillData, ...skillInstance, id: skillId };
21093
+ } else {
21094
+ const skillObj = skillInput;
21095
+ skillId = skillObj.id || `skill-${Date.now()}`;
21096
+ skillObj.id = skillId;
21097
+ const existingData = map?.database()?.[skillId];
21098
+ if (existingData) {
21099
+ skillData = { ...existingData, ...skillObj };
21100
+ if (map) {
21101
+ map.addInDatabase(skillId, skillData, { force: true });
21102
+ }
21103
+ } else if (map) {
21104
+ map.addInDatabase(skillId, skillObj);
21105
+ skillData = skillObj;
21106
+ } else {
21107
+ skillData = skillObj;
21108
+ }
21109
+ }
20381
21110
  if (this.getSkill(skillId)) {
20382
- throw SkillLog.alreadyLearned(skillId);
21111
+ throw SkillLog.alreadyLearned(skillData);
20383
21112
  }
20384
- const instance = this.databaseById(skillId);
20385
- this.skills().push(instance);
20386
- this["execMethod"]("onLearn", [this], instance);
20387
- return instance;
21113
+ this.skills().push(skillData);
21114
+ this["execMethod"]("onLearn", [this], skillData);
21115
+ return skillData;
20388
21116
  }
20389
- forgetSkill(skillId) {
20390
- if (isString(skillId)) skillId = this.databaseById(skillId);
20391
- const index = this._getSkillIndex(skillId);
20392
- if (index == -1) {
20393
- throw SkillLog.notLearned(skillId);
21117
+ /**
21118
+ * Forget a learned skill
21119
+ *
21120
+ * Removes a skill from the player's skill list.
21121
+ *
21122
+ * @param skillInput - Skill ID, class, or object to forget
21123
+ * @returns The forgotten skill data
21124
+ * @throws SkillLog.notLearned if the skill is not known
21125
+ *
21126
+ * @example
21127
+ * ```ts
21128
+ * player.forgetSkill('fire');
21129
+ * // or
21130
+ * player.forgetSkill(FireSkill);
21131
+ * ```
21132
+ */
21133
+ forgetSkill(skillInput) {
21134
+ const index = this._getSkillIndex(skillInput);
21135
+ if (index === -1) {
21136
+ let skillData2 = skillInput;
21137
+ if (isString(skillInput)) {
21138
+ try {
21139
+ skillData2 = this.databaseById(skillInput);
21140
+ } catch {
21141
+ skillData2 = { name: skillInput, id: skillInput };
21142
+ }
21143
+ } else if (typeof skillInput === "function") {
21144
+ skillData2 = { name: skillInput.name, id: skillInput.id || skillInput.name };
21145
+ }
21146
+ throw SkillLog.notLearned(skillData2);
20394
21147
  }
20395
- const instance = this.skills()[index];
21148
+ const skillData = this.skills()[index];
20396
21149
  this.skills().splice(index, 1);
20397
- this["execMethod"]("onForget", [this], instance);
20398
- return instance;
21150
+ this["execMethod"]("onForget", [this], skillData);
21151
+ return skillData;
20399
21152
  }
20400
- useSkill(skillId, otherPlayer) {
20401
- const skill = this.getSkill(skillId);
21153
+ /**
21154
+ * Use a learned skill
21155
+ *
21156
+ * Executes a skill, consuming SP and applying effects to targets.
21157
+ * The skill must be learned and the player must have enough SP.
21158
+ *
21159
+ * @param skillInput - Skill ID, class, or object to use
21160
+ * @param otherPlayer - Optional target player(s) to apply skill effects to
21161
+ * @returns The used skill data
21162
+ * @throws SkillLog.restriction if player has CAN_NOT_SKILL effect
21163
+ * @throws SkillLog.notLearned if skill is not known
21164
+ * @throws SkillLog.notEnoughSp if not enough SP
21165
+ * @throws SkillLog.chanceToUseFailed if hit rate check fails
21166
+ *
21167
+ * @example
21168
+ * ```ts
21169
+ * // Use skill without target
21170
+ * player.useSkill('fire');
21171
+ *
21172
+ * // Use skill on a target
21173
+ * player.useSkill('fire', enemy);
21174
+ *
21175
+ * // Use skill on multiple targets
21176
+ * player.useSkill('fire', [enemy1, enemy2]);
21177
+ * ```
21178
+ */
21179
+ useSkill(skillInput, otherPlayer) {
21180
+ const skill = this.getSkill(skillInput);
20402
21181
  if (this.hasEffect(Effect.CAN_NOT_SKILL)) {
20403
- throw SkillLog.restriction(skillId);
21182
+ throw SkillLog.restriction(skill || skillInput);
20404
21183
  }
20405
21184
  if (!skill) {
20406
- throw SkillLog.notLearned(skillId);
21185
+ throw SkillLog.notLearned(skillInput);
20407
21186
  }
20408
- if (skill.spCost > this.sp) {
20409
- throw SkillLog.notEnoughSp(skillId, skill.spCost, this.sp);
21187
+ const spCost = skill.spCost || 0;
21188
+ if (spCost > this.sp) {
21189
+ throw SkillLog.notEnoughSp(skill, spCost, this.sp);
20410
21190
  }
20411
- this.sp -= skill.spCost / (this.hasEffect(Effect.HALF_SP_COST) ? 2 : 1);
21191
+ const costMultiplier = this.hasEffect(Effect.HALF_SP_COST) ? 2 : 1;
21192
+ this.sp -= spCost / costMultiplier;
20412
21193
  const hitRate = skill.hitRate ?? 1;
20413
21194
  if (Math.random() > hitRate) {
20414
21195
  this["execMethod"]("onUseFailed", [this, otherPlayer], skill);
20415
- throw SkillLog.chanceToUseFailed(skillId);
21196
+ throw SkillLog.chanceToUseFailed(skill);
20416
21197
  }
20417
21198
  if (otherPlayer) {
20418
- let players = otherPlayer;
20419
- if (!isArray(players)) {
20420
- players = [otherPlayer];
20421
- }
20422
- for (let player of players) {
21199
+ const players = isArray(otherPlayer) ? otherPlayer : [otherPlayer];
21200
+ for (const player of players) {
20423
21201
  this.applyStates(player, skill);
20424
21202
  player.applyDamage(this, skill);
20425
21203
  }
@@ -20941,6 +21719,8 @@ const _RpgPlayer = class _RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
20941
21719
  * When `nbTimes` is set to a finite number, the animation will play that many times
20942
21720
  * before returning to the previous animation state.
20943
21721
  *
21722
+ * If `animationFixed` is true, this method will not change the animation.
21723
+ *
20944
21724
  * @param animationName - The name of the animation to play (e.g., 'attack', 'skill', 'walk')
20945
21725
  * @param nbTimes - Number of times to repeat the animation (default: Infinity for continuous)
20946
21726
  *
@@ -20952,14 +21732,18 @@ const _RpgPlayer = class _RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
20952
21732
  * // Play attack animation 3 times then return to previous state
20953
21733
  * player.setAnimation('attack', 3);
20954
21734
  *
20955
- * // Play skill animation once
20956
- * player.setAnimation('skill', 1);
21735
+ * // Lock animation to prevent automatic changes
21736
+ * player.animationFixed = true;
21737
+ * player.setAnimation('skill'); // This will be ignored
20957
21738
  *
20958
21739
  * // Set idle/stand animation
20959
21740
  * player.setAnimation('stand');
20960
21741
  * ```
20961
21742
  */
20962
21743
  setAnimation(animationName, nbTimes = Infinity) {
21744
+ if (this.animationFixed) {
21745
+ return;
21746
+ }
20963
21747
  const map2 = this.getCurrentMap();
20964
21748
  if (!map2) return;
20965
21749
  if (nbTimes === Infinity) {
@@ -21559,9 +22343,16 @@ class RpgEvent extends RpgPlayer {
21559
22343
  const ret = instance[methodName](...methodData);
21560
22344
  return ret;
21561
22345
  }
22346
+ /**
22347
+ * Remove this event from the map
22348
+ *
22349
+ * Stops all movements before removing to prevent "unable to resolve entity" errors
22350
+ * from the MovementManager when the entity is destroyed while moving.
22351
+ */
21562
22352
  remove() {
21563
22353
  const map2 = this.getCurrentMap();
21564
22354
  if (!map2) return;
22355
+ this.stopMoveTo();
21565
22356
  map2.removeEvent(this.id);
21566
22357
  }
21567
22358
  isEvent() {
@@ -25983,9 +26774,17 @@ let RpgMap = class extends RpgCommonMap {
25983
26774
  setupCollisionDetection() {
25984
26775
  const activeCollisions = /* @__PURE__ */ new Set();
25985
26776
  const activeShapeCollisions = /* @__PURE__ */ new Set();
26777
+ const hasDifferentZ = (entityA, entityB) => {
26778
+ const zA = entityA.owner.z();
26779
+ const zB = entityB.owner.z();
26780
+ return zA !== zB;
26781
+ };
25986
26782
  this.physic.getEvents().onCollisionEnter((collision) => {
25987
26783
  const entityA = collision.entityA;
25988
26784
  const entityB = collision.entityB;
26785
+ if (hasDifferentZ(entityA, entityB)) {
26786
+ return;
26787
+ }
25989
26788
  const collisionKey = entityA.uuid < entityB.uuid ? `${entityA.uuid}-${entityB.uuid}` : `${entityB.uuid}-${entityA.uuid}`;
25990
26789
  if (activeCollisions.has(collisionKey)) {
25991
26790
  return;
@@ -26025,6 +26824,9 @@ let RpgMap = class extends RpgCommonMap {
26025
26824
  this.physic.getEvents().onCollisionExit((collision) => {
26026
26825
  const entityA = collision.entityA;
26027
26826
  const entityB = collision.entityB;
26827
+ if (hasDifferentZ(entityA, entityB)) {
26828
+ return;
26829
+ }
26028
26830
  const collisionKey = entityA.uuid < entityB.uuid ? `${entityA.uuid}-${entityB.uuid}` : `${entityB.uuid}-${entityA.uuid}`;
26029
26831
  const shapeA = this._shapeEntities.get(entityA.uuid);
26030
26832
  const shapeB = this._shapeEntities.get(entityB.uuid);