@rpgjs/server 5.0.0-alpha.15 → 5.0.0-alpha.17

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
@@ -11349,10 +11349,10 @@ __decorateClass$3([
11349
11349
  sync()
11350
11350
  ], RpgCommonPlayer.prototype, "type");
11351
11351
  __decorateClass$3([
11352
- persist()
11352
+ sync()
11353
11353
  ], RpgCommonPlayer.prototype, "x");
11354
11354
  __decorateClass$3([
11355
- persist()
11355
+ sync()
11356
11356
  ], RpgCommonPlayer.prototype, "y");
11357
11357
  __decorateClass$3([
11358
11358
  sync()
@@ -12545,6 +12545,48 @@ class Entity {
12545
12545
  this.force.set(0, 0);
12546
12546
  this.torque = 0;
12547
12547
  }
12548
+ /**
12549
+ * Stops all movement immediately
12550
+ *
12551
+ * Completely stops the entity's movement by:
12552
+ * - Setting velocity to zero
12553
+ * - Setting angular velocity to zero
12554
+ * - Clearing accumulated forces and torques
12555
+ * - Waking up the entity if it was sleeping
12556
+ * - Notifying movement state change
12557
+ *
12558
+ * Unlike `freeze()`, this method keeps the entity dynamic and does not
12559
+ * change its state. It's useful for stopping movement when changing maps,
12560
+ * teleporting, or when you need to halt an entity without making it static.
12561
+ *
12562
+ * @returns This entity for chaining
12563
+ *
12564
+ * @example
12565
+ * ```ts
12566
+ * // Stop movement when changing maps
12567
+ * if (mapChanged) {
12568
+ * entity.stopMovement();
12569
+ * }
12570
+ *
12571
+ * // Stop movement after teleporting
12572
+ * entity.position.set(100, 200);
12573
+ * entity.stopMovement();
12574
+ *
12575
+ * // Stop movement when player dies
12576
+ * if (player.isDead()) {
12577
+ * playerEntity.stopMovement();
12578
+ * }
12579
+ * ```
12580
+ */
12581
+ stopMovement() {
12582
+ this.velocity.set(0, 0);
12583
+ this.angularVelocity = 0;
12584
+ this.clearForces();
12585
+ this.wakeUp();
12586
+ this.notifyMovementChange();
12587
+ this.notifyDirectionChange();
12588
+ return this;
12589
+ }
12548
12590
  /**
12549
12591
  * Clamps velocities to maximum values
12550
12592
  */
@@ -14232,6 +14274,11 @@ class CollisionResolver {
14232
14274
  /**
14233
14275
  * Separates two entities by moving them apart
14234
14276
  *
14277
+ * This method applies position corrections to resolve penetration between
14278
+ * colliding entities. After applying corrections, it notifies position change
14279
+ * handlers to ensure proper synchronization with game logic (e.g., updating
14280
+ * owner.x/y signals for network sync).
14281
+ *
14235
14282
  * @param entityA - First entity
14236
14283
  * @param entityB - Second entity
14237
14284
  * @param normal - Separation normal (from A to B)
@@ -14250,9 +14297,11 @@ class CollisionResolver {
14250
14297
  const correctionB = normal.mul(correction * (entityB.invMass / totalInvMass));
14251
14298
  if (!entityA.isStatic()) {
14252
14299
  entityA.position.addInPlace(correctionA);
14300
+ entityA.notifyPositionChange();
14253
14301
  }
14254
14302
  if (!entityB.isStatic()) {
14255
14303
  entityB.position.addInPlace(correctionB);
14304
+ entityB.notifyPositionChange();
14256
14305
  }
14257
14306
  }
14258
14307
  /**
@@ -15404,6 +15453,47 @@ let MovementManager$1 = class MovementManager {
15404
15453
  const body = this.resolveTarget(target);
15405
15454
  this.entries.delete(body.id);
15406
15455
  }
15456
+ /**
15457
+ * Stops all movement for an entity immediately
15458
+ *
15459
+ * This method completely stops an entity's movement by:
15460
+ * - Removing all active movement strategies (dash, linear moves, etc.)
15461
+ * - Stopping the entity's velocity and angular velocity
15462
+ * - Clearing accumulated forces
15463
+ * - Waking up the entity if it was sleeping
15464
+ *
15465
+ * This is useful when changing maps, teleporting, or when you need
15466
+ * to halt an entity's movement completely without making it static.
15467
+ *
15468
+ * @param target - Entity, MovementBody, or identifier
15469
+ *
15470
+ * @example
15471
+ * ```ts
15472
+ * // Stop movement when changing maps
15473
+ * if (mapChanged) {
15474
+ * movement.stopMovement(playerEntity);
15475
+ * }
15476
+ *
15477
+ * // Stop movement after teleporting
15478
+ * entity.position.set(100, 200);
15479
+ * movement.stopMovement(entity);
15480
+ *
15481
+ * // Stop movement when player dies
15482
+ * if (player.isDead()) {
15483
+ * movement.stopMovement(playerEntity);
15484
+ * }
15485
+ * ```
15486
+ */
15487
+ stopMovement(target) {
15488
+ const body = this.resolveTarget(target);
15489
+ this.clear(target);
15490
+ if ("getEntity" in body && typeof body.getEntity === "function") {
15491
+ const entity = body.getEntity();
15492
+ if (entity && typeof entity.stopMovement === "function") {
15493
+ entity.stopMovement();
15494
+ }
15495
+ }
15496
+ }
15407
15497
  /**
15408
15498
  * Checks if an entity has active strategies.
15409
15499
  *
@@ -17004,6 +17094,9 @@ class MovementManager {
17004
17094
  clear(id) {
17005
17095
  this.core.clear(id);
17006
17096
  }
17097
+ stopMovement(id) {
17098
+ this.core.stopMovement(id);
17099
+ }
17007
17100
  hasActiveStrategies(id) {
17008
17101
  return this.core.hasActiveStrategies(id);
17009
17102
  }
@@ -17037,12 +17130,33 @@ class RpgCommonMap {
17037
17130
  * It's shared using the share() operator, meaning that all subscribers will receive
17038
17131
  * events from a single interval rather than creating multiple intervals.
17039
17132
  *
17133
+ * ## Physics Loop Architecture
17134
+ *
17135
+ * The physics simulation is centralized in this game loop:
17136
+ *
17137
+ * 1. **Input Processing** (`processInput`): Only updates entity velocities, does NOT step physics
17138
+ * 2. **Game Loop** (`tick$` -> `runFixedTicks`): Executes physics simulation with fixed timestep
17139
+ * 3. **Fixed Timestep Pattern**: Accumulator-based approach ensures deterministic physics
17140
+ *
17141
+ * ```
17142
+ * Input Events ─────────────────────────────────────────────────────────────►
17143
+ * │
17144
+ * ▼ (update velocity only)
17145
+ * ┌─────────────────────────────────────────────────────────────────────────┐
17146
+ * │ Game Loop (tick$) │
17147
+ * │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
17148
+ * │ │ updateMovements │ → │ stepOneTick │ → │ postTickUpdates │ │
17149
+ * │ │ (apply velocity)│ │ (physics step) │ │ (zones, sync) │ │
17150
+ * │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
17151
+ * └─────────────────────────────────────────────────────────────────────────┘
17152
+ * ```
17153
+ *
17040
17154
  * @example
17041
17155
  * ```ts
17042
- * // Subscribe to the game tick to update entity positions
17043
- * map.tick$.subscribe(timestamp => {
17044
- * // Update game entities based on elapsed time
17045
- * this.updateEntities(timestamp);
17156
+ * // Subscribe to the game tick for custom updates
17157
+ * map.tick$.subscribe(({ delta, timestamp }) => {
17158
+ * // Custom game logic runs alongside physics
17159
+ * this.updateCustomEntities(delta);
17046
17160
  * });
17047
17161
  * ```
17048
17162
  */
@@ -17192,6 +17306,7 @@ class RpgCommonMap {
17192
17306
  if (typeof player.autoChangeMap === "function") {
17193
17307
  const mapChanged = await player.autoChangeMap({ x: nextX, y: nextY }, direction);
17194
17308
  if (mapChanged) {
17309
+ this.stopMovement(player);
17195
17310
  return;
17196
17311
  }
17197
17312
  }
@@ -17217,6 +17332,43 @@ class RpgCommonMap {
17217
17332
  getObjectById(id) {
17218
17333
  return this.players()[id] ?? this.events()[id];
17219
17334
  }
17335
+ /**
17336
+ * Execute physics simulation with fixed timestep
17337
+ *
17338
+ * This method runs the physics engine using a fixed timestep accumulator pattern.
17339
+ * It ensures deterministic physics regardless of frame rate by:
17340
+ * 1. Accumulating delta time
17341
+ * 2. Running fixed-size physics steps until the accumulator is depleted
17342
+ * 3. Calling `updateMovements()` before each step to apply velocity changes
17343
+ * 4. Running post-tick updates (zones, callbacks) after each step
17344
+ *
17345
+ * ## Architecture
17346
+ *
17347
+ * The physics loop is centralized here and called from `tick$` subscription.
17348
+ * Input processing (`processInput`) only updates entity velocities - it does NOT
17349
+ * step the physics. This ensures:
17350
+ * - Consistent physics timing (60fps fixed timestep)
17351
+ * - No double-stepping when inputs are processed
17352
+ * - Proper accumulator-based interpolation support
17353
+ *
17354
+ * @param deltaMs - Time elapsed since last call in milliseconds
17355
+ * @param hooks - Optional callbacks for before/after each physics step
17356
+ * @returns Number of physics ticks executed
17357
+ *
17358
+ * @example
17359
+ * ```ts
17360
+ * // Called automatically by tick$ subscription
17361
+ * this.tickSubscription = this.tick$.subscribe(({ delta }) => {
17362
+ * this.runFixedTicks(delta);
17363
+ * });
17364
+ *
17365
+ * // Or manually with hooks for debugging
17366
+ * this.runFixedTicks(16, {
17367
+ * beforeStep: () => console.log('Before physics step'),
17368
+ * afterStep: (tick) => console.log(`Physics tick ${tick} completed`)
17369
+ * });
17370
+ * ```
17371
+ */
17220
17372
  runFixedTicks(deltaMs, hooks) {
17221
17373
  if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
17222
17374
  return 0;
@@ -17227,12 +17379,46 @@ class RpgCommonMap {
17227
17379
  while (this.physicsAccumulatorMs >= fixedStepMs) {
17228
17380
  this.physicsAccumulatorMs -= fixedStepMs;
17229
17381
  hooks?.beforeStep?.();
17382
+ this.physic.updateMovements();
17230
17383
  const tick = this.physic.stepOneTick();
17231
17384
  executed += 1;
17385
+ this.runPostTickUpdates();
17232
17386
  hooks?.afterStep?.(tick);
17233
17387
  }
17234
17388
  return executed;
17235
17389
  }
17390
+ /**
17391
+ * Force a single physics tick outside of the normal game loop
17392
+ *
17393
+ * This method is primarily used for **client-side prediction** where the client
17394
+ * needs to immediately simulate physics in response to local input, rather than
17395
+ * waiting for the next game loop tick.
17396
+ *
17397
+ * ## Use Cases
17398
+ *
17399
+ * - **Client-side prediction**: Immediately simulate player movement for responsive feel
17400
+ * - **Testing**: Force a physics step in unit tests
17401
+ * - **Special effects**: Immediate physics response for specific game events
17402
+ *
17403
+ * ## Important
17404
+ *
17405
+ * This method should NOT be used on the server for normal input processing.
17406
+ * Server-side physics is handled by `runFixedTicks` in the main game loop to ensure
17407
+ * deterministic simulation.
17408
+ *
17409
+ * @param hooks - Optional callbacks for before/after the physics step
17410
+ * @returns The physics tick number
17411
+ *
17412
+ * @example
17413
+ * ```ts
17414
+ * // Client-side: immediately simulate predicted movement
17415
+ * class RpgClientMap extends RpgCommonMap {
17416
+ * stepPredictionTick(): void {
17417
+ * this.forceSingleTick();
17418
+ * }
17419
+ * }
17420
+ * ```
17421
+ */
17236
17422
  forceSingleTick(hooks) {
17237
17423
  hooks?.beforeStep?.();
17238
17424
  this.physic.updateMovements();
@@ -17560,22 +17746,60 @@ class RpgCommonMap {
17560
17746
  * Add a character to the physics world
17561
17747
  * @private
17562
17748
  */
17749
+ /**
17750
+ * Add a character entity to the physics world
17751
+ *
17752
+ * Creates a physics entity for a character (player or NPC) with proper position handling.
17753
+ * The owner's x/y signals represent **top-left** coordinates, while the physics entity
17754
+ * uses **center** coordinates internally.
17755
+ *
17756
+ * ## Position System
17757
+ *
17758
+ * - `owner.x()` / `owner.y()` → **top-left** corner of the character's hitbox
17759
+ * - `entity.position` → **center** of the physics collider
17760
+ * - Conversion: `center = topLeft + (size / 2)`
17761
+ *
17762
+ * @param options - Character configuration
17763
+ * @returns The character's unique ID
17764
+ *
17765
+ * @example
17766
+ * ```ts
17767
+ * // Player at top-left position (100, 100) with 32x32 hitbox
17768
+ * // Physics entity will be at center (116, 116)
17769
+ * this.addCharacter({
17770
+ * owner: player,
17771
+ * x: 116, // center X (ignored, uses owner.x())
17772
+ * y: 116, // center Y (ignored, uses owner.y())
17773
+ * kind: "hero"
17774
+ * });
17775
+ * ```
17776
+ *
17777
+ * @private
17778
+ */
17563
17779
  addCharacter(options) {
17564
17780
  if (!options || typeof options.owner?.id !== "string") {
17565
17781
  throw new Error("Character requires an owner object with a string id");
17566
17782
  }
17567
- const id = options.owner.id;
17568
- const radius = options.radius ?? 25;
17569
- const diameter = radius * 2;
17570
- const topLeftX = options.x - radius;
17571
- const topLeftY = options.y - radius;
17572
- const centerX = topLeftX + diameter / 2;
17573
- const centerY = topLeftY + diameter / 2;
17783
+ const owner = options.owner;
17784
+ const id = owner.id;
17785
+ const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
17786
+ const width = hitbox?.w ?? 32;
17787
+ const height = hitbox?.h ?? 32;
17788
+ const radius = Math.max(width, height) / 2;
17789
+ const topLeftX = owner.x();
17790
+ const topLeftY = owner.y();
17791
+ const centerX = topLeftX + width / 2;
17792
+ const centerY = topLeftY + height / 2;
17574
17793
  const isStatic = !!options.isStatic;
17575
17794
  const entity = this.physic.createEntity({
17576
17795
  uuid: id,
17577
17796
  position: { x: centerX, y: centerY },
17797
+ // Use radius for circular collision detection
17578
17798
  radius: Math.max(radius, 1),
17799
+ // Also store explicit width/height for consistent position conversions
17800
+ // This ensures getBodyPosition/setBodyPosition use the same dimensions
17801
+ width,
17802
+ height,
17579
17803
  mass: options.mass ?? (isStatic ? Infinity : 1),
17580
17804
  friction: options.friction ?? 0.4,
17581
17805
  linearDamping: isStatic ? 1 : 0.2,
@@ -17587,17 +17811,15 @@ class RpgCommonMap {
17587
17811
  } else {
17588
17812
  entity.unfreeze();
17589
17813
  }
17590
- entity.owner = options.owner;
17814
+ entity.owner = owner;
17591
17815
  entity.onDirectionChange(({ cardinalDirection }) => {
17592
17816
  if (!("$send" in this)) return;
17593
- const owner = entity.owner;
17594
- if (!owner) return;
17817
+ const owner2 = entity.owner;
17818
+ if (!owner2) return;
17595
17819
  if (cardinalDirection === "idle") return;
17596
- owner.changeDirection(cardinalDirection);
17820
+ owner2.changeDirection(cardinalDirection);
17597
17821
  });
17598
17822
  entity.onMovementChange(({ isMoving, intensity }) => {
17599
- const owner = entity.owner;
17600
- if (!owner) return;
17601
17823
  const LOW_INTENSITY_THRESHOLD = 10;
17602
17824
  if (isMoving && intensity > LOW_INTENSITY_THRESHOLD) {
17603
17825
  owner.animationName.set("walk");
@@ -17605,19 +17827,21 @@ class RpgCommonMap {
17605
17827
  owner.animationName.set("stand");
17606
17828
  }
17607
17829
  });
17830
+ const entityWidth = width;
17831
+ const entityHeight = height;
17608
17832
  entity.onPositionChange(({ x, y }) => {
17609
- const owner = entity.owner;
17610
- if (!owner) return;
17611
- const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
17612
- const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
17613
- const topLeftX2 = x - width / 2;
17614
- const topLeftY2 = y - height / 2;
17833
+ const topLeftX2 = x - entityWidth / 2;
17834
+ const topLeftY2 = y - entityHeight / 2;
17835
+ let changed = false;
17615
17836
  if (typeof owner.x === "function" && typeof owner.x.set === "function") {
17616
17837
  owner.x.set(Math.round(topLeftX2));
17617
- owner.applyFrames?.();
17838
+ changed = true;
17618
17839
  }
17619
17840
  if (typeof owner.y === "function" && typeof owner.y.set === "function") {
17620
17841
  owner.y.set(Math.round(topLeftY2));
17842
+ changed = true;
17843
+ }
17844
+ if (changed) {
17621
17845
  owner.applyFrames?.();
17622
17846
  }
17623
17847
  });
@@ -17700,15 +17924,40 @@ class RpgCommonMap {
17700
17924
  }
17701
17925
  /**
17702
17926
  * Stop movement for a player
17927
+ *
17928
+ * Completely stops all movement for a player, including:
17929
+ * - Clearing all active movement strategies (dash, linear moves, etc.)
17930
+ * - Setting velocity to zero
17931
+ * - Resetting intended direction
17932
+ *
17933
+ * This method is particularly useful when changing maps to ensure
17934
+ * the player doesn't carry over movement from the previous map.
17935
+ *
17936
+ * @param player - The player to stop
17937
+ * @returns True if the player was found and movement was stopped
17938
+ *
17939
+ * @example
17940
+ * ```ts
17941
+ * // Stop player movement when changing maps
17942
+ * if (mapChanged) {
17943
+ * map.stopMovement(player);
17944
+ * }
17945
+ *
17946
+ * // Stop movement when player dies
17947
+ * if (player.isDead()) {
17948
+ * map.stopMovement(player);
17949
+ * }
17950
+ * ```
17703
17951
  * @protected
17704
17952
  */
17705
17953
  stopMovement(player) {
17706
17954
  const entity = this.physic.getEntityByUUID(player.id);
17707
17955
  if (!entity) return false;
17956
+ this.moveManager.stopMovement(player.id);
17708
17957
  if (typeof player.setIntendedDirection === "function") {
17709
17958
  player.setIntendedDirection(null);
17710
17959
  }
17711
- entity.setVelocity({ x: 0, y: 0 });
17960
+ player.pendingInputs = [];
17712
17961
  return true;
17713
17962
  }
17714
17963
  /**
@@ -17808,11 +18057,11 @@ class RpgCommonMap {
17808
18057
  setBodyPosition(id, x, y, mode = "center") {
17809
18058
  const entity = this.physic.getEntityByUUID(id);
17810
18059
  if (!entity) return;
18060
+ const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
18061
+ const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
17811
18062
  let centerX = x;
17812
18063
  let centerY = y;
17813
18064
  if (mode === "top-left") {
17814
- const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
17815
- const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
17816
18065
  centerX = x + width / 2;
17817
18066
  centerY = y + height / 2;
17818
18067
  }
@@ -19064,7 +19313,7 @@ function WithMoveManager(Base) {
19064
19313
  staticTarget.freeze();
19065
19314
  map.moveManager.add(
19066
19315
  this.id,
19067
- new SeekAvoid(engine, () => staticTarget, 3, 50, 5)
19316
+ new SeekAvoid(engine, () => staticTarget, 80, 140, 80, 48)
19068
19317
  );
19069
19318
  }
19070
19319
  /**
@@ -22376,7 +22625,10 @@ const _RpgPlayer = class _RpgPlayer extends BasicPlayerMixins(RpgCommonPlayer) {
22376
22625
  async teleport(positions) {
22377
22626
  if (!this.map) return false;
22378
22627
  if (this.map.physic) {
22379
- this.map.physic.updateHitbox(this.id, positions.x, positions.y, void 0, void 0, true);
22628
+ const entity = this.map.physic.getEntityByUUID(this.id);
22629
+ if (entity) {
22630
+ this.map.physic.teleport(entity, { x: positions.x, y: positions.y });
22631
+ }
22380
22632
  } else {
22381
22633
  this.x.set(positions.x);
22382
22634
  this.y.set(positions.y);
@@ -25957,7 +26209,6 @@ let RpgMap = class extends RpgCommonMap {
25957
26209
  player._onInit();
25958
26210
  this.dataIsReady$.pipe(
25959
26211
  finalize$1(() => {
25960
- player.applyFrames();
25961
26212
  this.hooks.callHooks("server-player-onJoinMap", player, this).subscribe();
25962
26213
  })
25963
26214
  ).subscribe();
@@ -26077,8 +26328,16 @@ let RpgMap = class extends RpgCommonMap {
26077
26328
  * This method processes all pending inputs for a player while performing
26078
26329
  * anti-cheat validation to prevent time manipulation and frame skipping.
26079
26330
  * It validates the time deltas between inputs and ensures they are within
26080
- * acceptable ranges. After processing, it saves the last frame position
26081
- * for use in packet interception.
26331
+ * acceptable ranges.
26332
+ *
26333
+ * ## Architecture
26334
+ *
26335
+ * **Important**: This method only updates entity velocities - it does NOT step
26336
+ * the physics engine. Physics simulation is handled centrally by the game loop
26337
+ * (`tick$` -> `runFixedTicks`). This ensures:
26338
+ * - Consistent physics timing (60fps fixed timestep)
26339
+ * - No double-stepping when multiple inputs are processed
26340
+ * - Deterministic physics regardless of input frequency
26082
26341
  *
26083
26342
  * @param playerId - The ID of the player to process inputs for
26084
26343
  * @param controls - Optional anti-cheat configuration
@@ -26160,7 +26419,6 @@ let RpgMap = class extends RpgCommonMap {
26160
26419
  lastProcessedFrame = input.frame;
26161
26420
  }
26162
26421
  if (hasProcessedInputs) {
26163
- this.forceSingleTick();
26164
26422
  player.lastProcessedInputTs = lastProcessedTime;
26165
26423
  } else {
26166
26424
  const idleTimeout = Math.max(config.minTimeBetweenInputs * 4, 50);
@@ -26170,7 +26428,6 @@ let RpgMap = class extends RpgCommonMap {
26170
26428
  player.lastProcessedInputTs = 0;
26171
26429
  }
26172
26430
  }
26173
- player.applyFrames();
26174
26431
  return {
26175
26432
  player,
26176
26433
  inputs: processedInputs
@@ -26307,7 +26564,6 @@ let RpgMap = class extends RpgCommonMap {
26307
26564
  eventInstance.context = context$1;
26308
26565
  eventInstance.x.set(x);
26309
26566
  eventInstance.y.set(y);
26310
- eventInstance.applyFrames();
26311
26567
  if (event.name) eventInstance.name.set(event.name);
26312
26568
  this.events()[id] = eventInstance;
26313
26569
  await eventInstance.execMethod("onInit");