@rpgjs/common 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/Player.d.ts CHANGED
@@ -94,6 +94,70 @@ export declare abstract class RpgCommonPlayer {
94
94
  componentsRight: import('@signe/reactive').WritableSignal<string | null>;
95
95
  isConnected: import('@signe/reactive').WritableSignal<boolean>;
96
96
  private _intendedDirection;
97
+ private _directionFixed;
98
+ private _animationFixed;
99
+ /**
100
+ * Get whether direction changes are locked
101
+ *
102
+ * @returns True if direction is locked and cannot be changed automatically
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * if (player.directionFixed) {
107
+ * // Direction is locked, won't change automatically
108
+ * }
109
+ * ```
110
+ */
111
+ get directionFixed(): boolean;
112
+ /**
113
+ * Set whether direction changes are locked
114
+ *
115
+ * When set to true, the player's direction will not change automatically
116
+ * during movement or from physics engine callbacks.
117
+ *
118
+ * @param value - True to lock direction, false to allow automatic changes
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // Lock direction during a special animation
123
+ * player.directionFixed = true;
124
+ * player.setAnimation('attack');
125
+ * // ... later
126
+ * player.directionFixed = false;
127
+ * ```
128
+ */
129
+ set directionFixed(value: boolean);
130
+ /**
131
+ * Get whether animation changes are locked
132
+ *
133
+ * @returns True if animation is locked and cannot be changed automatically
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * if (player.animationFixed) {
138
+ * // Animation is locked, won't change automatically
139
+ * }
140
+ * ```
141
+ */
142
+ get animationFixed(): boolean;
143
+ /**
144
+ * Set whether animation changes are locked
145
+ *
146
+ * When set to true, the player's animation will not change automatically
147
+ * during movement or from physics engine callbacks.
148
+ *
149
+ * @param value - True to lock animation, false to allow automatic changes
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * // Lock animation during a special skill
154
+ * player.animationFixed = true;
155
+ * player.setAnimation('skill');
156
+ * // ... later
157
+ * player.animationFixed = false;
158
+ * ```
159
+ */
160
+ set animationFixed(value: boolean);
97
161
  pendingInputs: any[];
98
162
  /**
99
163
  * Change the player's facing direction
@@ -103,12 +167,18 @@ export declare abstract class RpgCommonPlayer {
103
167
  * intends to move in a specific direction, not when they are pushed
104
168
  * by physics or sliding.
105
169
  *
170
+ * If `directionFixed` is true, this method will not change the direction.
171
+ *
106
172
  * @param direction - The new direction to face
107
173
  *
108
174
  * @example
109
175
  * ```ts
110
176
  * // Player presses right arrow key
111
177
  * player.changeDirection(Direction.Right);
178
+ *
179
+ * // Lock direction to prevent automatic changes
180
+ * player.directionFixed = true;
181
+ * player.changeDirection(Direction.Up); // This will be ignored
112
182
  * ```
113
183
  */
114
184
  changeDirection(direction: Direction): void;
package/dist/index.js CHANGED
@@ -2542,8 +2542,81 @@ class RpgCommonPlayer {
2542
2542
  this.isConnected = signal(false);
2543
2543
  // Store intended movement direction (not synced, only used locally)
2544
2544
  this._intendedDirection = null;
2545
+ // Direction and animation locking (server-side only, not synced)
2546
+ this._directionFixed = signal(false);
2547
+ this._animationFixed = signal(false);
2545
2548
  this.pendingInputs = [];
2546
2549
  }
2550
+ /**
2551
+ * Get whether direction changes are locked
2552
+ *
2553
+ * @returns True if direction is locked and cannot be changed automatically
2554
+ *
2555
+ * @example
2556
+ * ```ts
2557
+ * if (player.directionFixed) {
2558
+ * // Direction is locked, won't change automatically
2559
+ * }
2560
+ * ```
2561
+ */
2562
+ get directionFixed() {
2563
+ return this._directionFixed();
2564
+ }
2565
+ /**
2566
+ * Set whether direction changes are locked
2567
+ *
2568
+ * When set to true, the player's direction will not change automatically
2569
+ * during movement or from physics engine callbacks.
2570
+ *
2571
+ * @param value - True to lock direction, false to allow automatic changes
2572
+ *
2573
+ * @example
2574
+ * ```ts
2575
+ * // Lock direction during a special animation
2576
+ * player.directionFixed = true;
2577
+ * player.setAnimation('attack');
2578
+ * // ... later
2579
+ * player.directionFixed = false;
2580
+ * ```
2581
+ */
2582
+ set directionFixed(value) {
2583
+ this._directionFixed.set(value);
2584
+ }
2585
+ /**
2586
+ * Get whether animation changes are locked
2587
+ *
2588
+ * @returns True if animation is locked and cannot be changed automatically
2589
+ *
2590
+ * @example
2591
+ * ```ts
2592
+ * if (player.animationFixed) {
2593
+ * // Animation is locked, won't change automatically
2594
+ * }
2595
+ * ```
2596
+ */
2597
+ get animationFixed() {
2598
+ return this._animationFixed();
2599
+ }
2600
+ /**
2601
+ * Set whether animation changes are locked
2602
+ *
2603
+ * When set to true, the player's animation will not change automatically
2604
+ * during movement or from physics engine callbacks.
2605
+ *
2606
+ * @param value - True to lock animation, false to allow automatic changes
2607
+ *
2608
+ * @example
2609
+ * ```ts
2610
+ * // Lock animation during a special skill
2611
+ * player.animationFixed = true;
2612
+ * player.setAnimation('skill');
2613
+ * // ... later
2614
+ * player.animationFixed = false;
2615
+ * ```
2616
+ */
2617
+ set animationFixed(value) {
2618
+ this._animationFixed.set(value);
2619
+ }
2547
2620
  /**
2548
2621
  * Change the player's facing direction
2549
2622
  *
@@ -2551,6 +2624,8 @@ class RpgCommonPlayer {
2551
2624
  * and directional abilities. This should be called when the player
2552
2625
  * intends to move in a specific direction, not when they are pushed
2553
2626
  * by physics or sliding.
2627
+ *
2628
+ * If `directionFixed` is true, this method will not change the direction.
2554
2629
  *
2555
2630
  * @param direction - The new direction to face
2556
2631
  *
@@ -2558,9 +2633,16 @@ class RpgCommonPlayer {
2558
2633
  * ```ts
2559
2634
  * // Player presses right arrow key
2560
2635
  * player.changeDirection(Direction.Right);
2636
+ *
2637
+ * // Lock direction to prevent automatic changes
2638
+ * player.directionFixed = true;
2639
+ * player.changeDirection(Direction.Up); // This will be ignored
2561
2640
  * ```
2562
2641
  */
2563
2642
  changeDirection(direction) {
2643
+ if (this._directionFixed()) {
2644
+ return;
2645
+ }
2564
2646
  this.direction.set(direction);
2565
2647
  }
2566
2648
  /**
@@ -3633,6 +3715,8 @@ class Entity {
3633
3715
  this.enterTileHandlers = /* @__PURE__ */ new Set();
3634
3716
  this.leaveTileHandlers = /* @__PURE__ */ new Set();
3635
3717
  this.canEnterTileHandlers = /* @__PURE__ */ new Set();
3718
+ this.collisionFilterHandlers = /* @__PURE__ */ new Set();
3719
+ this.resolutionFilterHandlers = /* @__PURE__ */ new Set();
3636
3720
  this.wasMoving = this.velocity.lengthSquared() > MOVEMENT_EPSILON_SQ;
3637
3721
  }
3638
3722
  /**
@@ -4179,9 +4263,46 @@ class Entity {
4179
4263
  this.angularVelocity = Math.sign(this.angularVelocity) * this.maxAngularVelocity;
4180
4264
  }
4181
4265
  }
4266
+ /**
4267
+ * Adds a collision filter to this entity
4268
+ *
4269
+ * Collision filters allow dynamic, conditional collision filtering beyond static bitmasks.
4270
+ * Each filter is called when checking if this entity can collide with another.
4271
+ * If any filter returns `false`, the collision is ignored.
4272
+ *
4273
+ * This enables scenarios like:
4274
+ * - Players passing through other players (`throughOtherPlayer`)
4275
+ * - Entities passing through all characters (`through`)
4276
+ * - Custom game-specific collision rules
4277
+ *
4278
+ * @param filter - Function that returns `true` to allow collision, `false` to ignore
4279
+ * @returns Unsubscribe function to remove the filter
4280
+ *
4281
+ * @example
4282
+ * ```typescript
4283
+ * // Allow entity to pass through other players
4284
+ * const unsubscribe = entity.addCollisionFilter((self, other) => {
4285
+ * const otherOwner = (other as any).owner;
4286
+ * if (otherOwner?.type === 'player') {
4287
+ * return false; // No collision with players
4288
+ * }
4289
+ * return true; // Collide with everything else
4290
+ * });
4291
+ *
4292
+ * // Later, remove the filter
4293
+ * unsubscribe();
4294
+ * ```
4295
+ */
4296
+ addCollisionFilter(filter) {
4297
+ this.collisionFilterHandlers.add(filter);
4298
+ return () => this.collisionFilterHandlers.delete(filter);
4299
+ }
4182
4300
  /**
4183
4301
  * Checks if this entity can collide with another entity
4184
4302
  *
4303
+ * First checks collision masks (bitmask filtering), then executes all registered
4304
+ * collision filters. If any filter returns `false`, the collision is ignored.
4305
+ *
4185
4306
  * @param other - Other entity to check
4186
4307
  * @returns True if collision is possible
4187
4308
  */
@@ -4190,7 +4311,77 @@ class Entity {
4190
4311
  const maskA = this.collisionMask;
4191
4312
  const categoryB = other.collisionCategory;
4192
4313
  const maskB = other.collisionMask;
4193
- return (categoryA & maskB) !== 0 && (categoryB & maskA) !== 0;
4314
+ if ((categoryA & maskB) === 0 || (categoryB & maskA) === 0) {
4315
+ return false;
4316
+ }
4317
+ for (const filter of this.collisionFilterHandlers) {
4318
+ if (!filter(this, other)) {
4319
+ return false;
4320
+ }
4321
+ }
4322
+ for (const filter of other.collisionFilterHandlers) {
4323
+ if (!filter(other, this)) {
4324
+ return false;
4325
+ }
4326
+ }
4327
+ return true;
4328
+ }
4329
+ /**
4330
+ * Adds a resolution filter to this entity
4331
+ *
4332
+ * Resolution filters determine whether a collision should be **resolved** (blocking)
4333
+ * or just **detected** (notification only). Unlike collision filters which prevent
4334
+ * detection entirely, resolution filters allow collision events to fire while
4335
+ * optionally skipping the physical blocking.
4336
+ *
4337
+ * This enables scenarios like:
4338
+ * - Players passing through other players but still triggering touch events
4339
+ * - Entities passing through characters but still calling onPlayerTouch hooks
4340
+ * - Ghost mode where collisions are detected but not resolved
4341
+ *
4342
+ * @param filter - Function that returns `true` to resolve (block), `false` to skip
4343
+ * @returns Unsubscribe function to remove the filter
4344
+ *
4345
+ * @example
4346
+ * ```typescript
4347
+ * // Allow entity to pass through players but still trigger events
4348
+ * const unsubscribe = entity.addResolutionFilter((self, other) => {
4349
+ * const otherOwner = (other as any).owner;
4350
+ * if (otherOwner?.type === 'player') {
4351
+ * return false; // Pass through but events still fire
4352
+ * }
4353
+ * return true; // Block other entities
4354
+ * });
4355
+ *
4356
+ * // Later, remove the filter
4357
+ * unsubscribe();
4358
+ * ```
4359
+ */
4360
+ addResolutionFilter(filter) {
4361
+ this.resolutionFilterHandlers.add(filter);
4362
+ return () => this.resolutionFilterHandlers.delete(filter);
4363
+ }
4364
+ /**
4365
+ * Checks if this entity should resolve (block) a collision with another entity
4366
+ *
4367
+ * This is called by the CollisionResolver to determine if the collision should
4368
+ * result in physical blocking or just notification.
4369
+ *
4370
+ * @param other - Other entity to check
4371
+ * @returns True if collision should be resolved (blocking), false to pass through
4372
+ */
4373
+ shouldResolveCollisionWith(other) {
4374
+ for (const filter of this.resolutionFilterHandlers) {
4375
+ if (!filter(this, other)) {
4376
+ return false;
4377
+ }
4378
+ }
4379
+ for (const filter of other.resolutionFilterHandlers) {
4380
+ if (!filter(other, this)) {
4381
+ return false;
4382
+ }
4383
+ }
4384
+ return true;
4194
4385
  }
4195
4386
  /**
4196
4387
  * @internal
@@ -6344,6 +6535,9 @@ class CollisionResolver {
6344
6535
  * Resolves a collision
6345
6536
  *
6346
6537
  * Separates entities and applies collision response.
6538
+ * First checks if the collision should be resolved using resolution filters.
6539
+ * If any entity has a resolution filter that returns false, the collision
6540
+ * is skipped (entities pass through) but events are still fired.
6347
6541
  *
6348
6542
  * @param collision - Collision information to resolve
6349
6543
  */
@@ -6352,6 +6546,9 @@ class CollisionResolver {
6352
6546
  if (depth < this.config.minPenetrationDepth) {
6353
6547
  return;
6354
6548
  }
6549
+ if (!entityA.shouldResolveCollisionWith(entityB)) {
6550
+ return;
6551
+ }
6355
6552
  this.separateEntities(entityA, entityB, normal, depth);
6356
6553
  this.resolveVelocities(entityA, entityB, normal);
6357
6554
  }
@@ -7515,20 +7712,64 @@ let MovementManager$1 = class MovementManager {
7515
7712
  }
7516
7713
  /**
7517
7714
  * Adds a movement strategy to an entity.
7715
+ *
7716
+ * Returns a Promise that resolves when the movement completes (when `isFinished()` returns true).
7717
+ * If the strategy doesn't implement `isFinished()`, the Promise resolves immediately after adding.
7518
7718
  *
7519
7719
  * @param target - Entity instance or entity UUID when a resolver is configured
7520
7720
  * @param strategy - Strategy to execute
7721
+ * @param options - Optional callbacks for movement lifecycle events
7722
+ * @returns Promise that resolves when the movement completes
7723
+ *
7724
+ * @example
7725
+ * ```typescript
7726
+ * // Simple usage - fire and forget
7727
+ * manager.add(player, new Dash(8, { x: 1, y: 0 }, 200));
7728
+ *
7729
+ * // Wait for completion
7730
+ * await manager.add(player, new Dash(8, { x: 1, y: 0 }, 200));
7731
+ * console.log('Dash finished!');
7732
+ *
7733
+ * // With callbacks
7734
+ * await manager.add(player, new Knockback({ x: -1, y: 0 }, 5, 300), {
7735
+ * onStart: () => {
7736
+ * player.directionFixed = true;
7737
+ * player.animationFixed = true;
7738
+ * },
7739
+ * onComplete: () => {
7740
+ * player.directionFixed = false;
7741
+ * player.animationFixed = false;
7742
+ * }
7743
+ * });
7744
+ * ```
7521
7745
  */
7522
- add(target, strategy) {
7746
+ add(target, strategy, options) {
7523
7747
  const body = this.resolveTarget(target);
7524
7748
  const key = body.id;
7525
7749
  if (!this.entries.has(key)) {
7526
7750
  this.entries.set(key, { body, strategies: [] });
7527
7751
  }
7528
- this.entries.get(key).strategies.push(strategy);
7752
+ if (!strategy.isFinished) {
7753
+ const entry = { strategy, started: false };
7754
+ if (options) {
7755
+ entry.options = options;
7756
+ }
7757
+ this.entries.get(key).strategies.push(entry);
7758
+ return Promise.resolve();
7759
+ }
7760
+ return new Promise((resolve) => {
7761
+ const entry = { strategy, resolve, started: false };
7762
+ if (options) {
7763
+ entry.options = options;
7764
+ }
7765
+ this.entries.get(key).strategies.push(entry);
7766
+ });
7529
7767
  }
7530
7768
  /**
7531
7769
  * Removes a specific strategy from an entity.
7770
+ *
7771
+ * Note: This will NOT trigger the onComplete callback or resolve the Promise.
7772
+ * Use this when you want to cancel a movement without completion.
7532
7773
  *
7533
7774
  * @param target - Entity instance or identifier
7534
7775
  * @param strategy - Strategy instance to remove
@@ -7540,7 +7781,7 @@ let MovementManager$1 = class MovementManager {
7540
7781
  if (!entry) {
7541
7782
  return false;
7542
7783
  }
7543
- const index = entry.strategies.indexOf(strategy);
7784
+ const index = entry.strategies.findIndex((e) => e.strategy === strategy);
7544
7785
  if (index === -1) {
7545
7786
  return false;
7546
7787
  }
@@ -7619,13 +7860,21 @@ let MovementManager$1 = class MovementManager {
7619
7860
  getStrategies(target) {
7620
7861
  const body = this.resolveTarget(target);
7621
7862
  const entry = this.entries.get(body.id);
7622
- return entry ? [...entry.strategies] : [];
7863
+ return entry ? entry.strategies.map((e) => e.strategy) : [];
7623
7864
  }
7624
7865
  /**
7625
7866
  * Updates all registered strategies.
7626
7867
  *
7627
7868
  * Call this method once per frame before `PhysicsEngine.step()` so that the
7628
7869
  * physics simulation integrates the velocities that strategies configure.
7870
+ *
7871
+ * This method handles the movement lifecycle:
7872
+ * - Triggers `onStart` callback on first update
7873
+ * - Calls `strategy.update()` each frame
7874
+ * - When `isFinished()` returns true:
7875
+ * - Calls `strategy.onFinished()` if defined
7876
+ * - Triggers `onComplete` callback
7877
+ * - Resolves the Promise returned by `add()`
7629
7878
  *
7630
7879
  * @param dt - Time delta in seconds
7631
7880
  */
@@ -7637,14 +7886,22 @@ let MovementManager$1 = class MovementManager {
7637
7886
  continue;
7638
7887
  }
7639
7888
  for (let i = strategies.length - 1; i >= 0; i -= 1) {
7640
- const current = strategies[i];
7641
- if (!current) {
7889
+ const strategyEntry = strategies[i];
7890
+ if (!strategyEntry) {
7642
7891
  continue;
7643
7892
  }
7644
- current.update(body, dt);
7645
- if (current.isFinished?.()) {
7893
+ const { strategy, options, resolve } = strategyEntry;
7894
+ if (!strategyEntry.started) {
7895
+ strategyEntry.started = true;
7896
+ options?.onStart?.();
7897
+ }
7898
+ strategy.update(body, dt);
7899
+ const isFinished = strategy.isFinished?.();
7900
+ if (isFinished) {
7646
7901
  strategies.splice(i, 1);
7647
- current.onFinished?.();
7902
+ strategy.onFinished?.();
7903
+ options?.onComplete?.();
7904
+ resolve?.();
7648
7905
  }
7649
7906
  }
7650
7907
  if (strategies.length === 0) {
@@ -9449,8 +9706,16 @@ class MovementManager {
9449
9706
  get core() {
9450
9707
  return this.physicProvider().getMovementManager();
9451
9708
  }
9452
- add(id, strategy) {
9453
- this.core.add(id, strategy);
9709
+ /**
9710
+ * Adds a movement strategy and returns a Promise that resolves when it completes.
9711
+ *
9712
+ * @param id - Entity identifier
9713
+ * @param strategy - Movement strategy to add
9714
+ * @param options - Optional callbacks for movement lifecycle events
9715
+ * @returns Promise that resolves when the movement completes
9716
+ */
9717
+ add(id, strategy, options) {
9718
+ return this.core.add(id, strategy, options);
9454
9719
  }
9455
9720
  remove(id, strategy) {
9456
9721
  return this.core.remove(id, strategy);
@@ -10303,11 +10568,14 @@ class RpgCommonMap {
10303
10568
  const owner2 = entity.owner;
10304
10569
  if (!owner2) return;
10305
10570
  if (cardinalDirection === "idle") return;
10571
+ if (owner2.directionFixed) return;
10306
10572
  owner2.changeDirection(cardinalDirection);
10307
10573
  });
10308
10574
  entity.onMovementChange(({ isMoving, intensity }) => {
10575
+ if (!("$send" in this)) return;
10309
10576
  const owner2 = entity.owner;
10310
10577
  if (!owner2) return;
10578
+ if (owner2.animationFixed) return;
10311
10579
  const LOW_INTENSITY_THRESHOLD = 10;
10312
10580
  const hasSetAnimation = typeof owner2.setAnimation === "function";
10313
10581
  const animationNameSignal = owner2.animationName;
@@ -10344,6 +10612,56 @@ class RpgCommonMap {
10344
10612
  owner.applyFrames?.();
10345
10613
  }
10346
10614
  });
10615
+ entity.addResolutionFilter((self, other) => {
10616
+ const selfOwner = self.owner;
10617
+ const otherOwner = other.owner;
10618
+ if (!selfOwner || !otherOwner) {
10619
+ return true;
10620
+ }
10621
+ if (selfOwner.z() !== otherOwner.z()) {
10622
+ return false;
10623
+ }
10624
+ if (typeof selfOwner._through === "function") {
10625
+ try {
10626
+ if (selfOwner._through() === true) {
10627
+ return false;
10628
+ }
10629
+ } catch {
10630
+ }
10631
+ } else if (selfOwner.through === true) {
10632
+ return false;
10633
+ }
10634
+ const playersMap = this.players();
10635
+ const eventsMap = this.events();
10636
+ const isSelfPlayer = !!playersMap[self.uuid];
10637
+ const isOtherPlayer = !!playersMap[other.uuid];
10638
+ const isOtherEvent = !!eventsMap[other.uuid];
10639
+ if (isSelfPlayer && isOtherPlayer) {
10640
+ if (typeof selfOwner._throughOtherPlayer === "function") {
10641
+ try {
10642
+ if (selfOwner._throughOtherPlayer() === true) {
10643
+ return false;
10644
+ }
10645
+ } catch {
10646
+ }
10647
+ } else if (selfOwner.throughOtherPlayer === true) {
10648
+ return false;
10649
+ }
10650
+ }
10651
+ if (isSelfPlayer && isOtherEvent) {
10652
+ if (typeof selfOwner._throughEvent === "function") {
10653
+ try {
10654
+ if (selfOwner._throughEvent() === true) {
10655
+ return false;
10656
+ }
10657
+ } catch {
10658
+ }
10659
+ } else if (selfOwner.throughEvent === true) {
10660
+ return false;
10661
+ }
10662
+ }
10663
+ return true;
10664
+ });
10347
10665
  return id;
10348
10666
  }
10349
10667
  /**