@rpgjs/server 5.0.0-alpha.16 → 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()
@@ -14274,6 +14274,11 @@ class CollisionResolver {
14274
14274
  /**
14275
14275
  * Separates two entities by moving them apart
14276
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
+ *
14277
14282
  * @param entityA - First entity
14278
14283
  * @param entityB - Second entity
14279
14284
  * @param normal - Separation normal (from A to B)
@@ -14292,9 +14297,11 @@ class CollisionResolver {
14292
14297
  const correctionB = normal.mul(correction * (entityB.invMass / totalInvMass));
14293
14298
  if (!entityA.isStatic()) {
14294
14299
  entityA.position.addInPlace(correctionA);
14300
+ entityA.notifyPositionChange();
14295
14301
  }
14296
14302
  if (!entityB.isStatic()) {
14297
14303
  entityB.position.addInPlace(correctionB);
14304
+ entityB.notifyPositionChange();
14298
14305
  }
14299
14306
  }
14300
14307
  /**
@@ -17123,12 +17130,33 @@ class RpgCommonMap {
17123
17130
  * It's shared using the share() operator, meaning that all subscribers will receive
17124
17131
  * events from a single interval rather than creating multiple intervals.
17125
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
+ *
17126
17154
  * @example
17127
17155
  * ```ts
17128
- * // Subscribe to the game tick to update entity positions
17129
- * map.tick$.subscribe(timestamp => {
17130
- * // Update game entities based on elapsed time
17131
- * 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);
17132
17160
  * });
17133
17161
  * ```
17134
17162
  */
@@ -17304,6 +17332,43 @@ class RpgCommonMap {
17304
17332
  getObjectById(id) {
17305
17333
  return this.players()[id] ?? this.events()[id];
17306
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
+ */
17307
17372
  runFixedTicks(deltaMs, hooks) {
17308
17373
  if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
17309
17374
  return 0;
@@ -17314,12 +17379,46 @@ class RpgCommonMap {
17314
17379
  while (this.physicsAccumulatorMs >= fixedStepMs) {
17315
17380
  this.physicsAccumulatorMs -= fixedStepMs;
17316
17381
  hooks?.beforeStep?.();
17382
+ this.physic.updateMovements();
17317
17383
  const tick = this.physic.stepOneTick();
17318
17384
  executed += 1;
17385
+ this.runPostTickUpdates();
17319
17386
  hooks?.afterStep?.(tick);
17320
17387
  }
17321
17388
  return executed;
17322
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
+ */
17323
17422
  forceSingleTick(hooks) {
17324
17423
  hooks?.beforeStep?.();
17325
17424
  this.physic.updateMovements();
@@ -17647,23 +17746,60 @@ class RpgCommonMap {
17647
17746
  * Add a character to the physics world
17648
17747
  * @private
17649
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
+ */
17650
17779
  addCharacter(options) {
17651
17780
  if (!options || typeof options.owner?.id !== "string") {
17652
17781
  throw new Error("Character requires an owner object with a string id");
17653
17782
  }
17654
17783
  const owner = options.owner;
17655
17784
  const id = owner.id;
17656
- const radius = owner.hitbox?.w ?? options.radius ?? 25;
17657
- const diameter = radius * 2;
17658
- const topLeftX = owner.x() - radius;
17659
- const topLeftY = owner.y() - radius;
17660
- const centerX = topLeftX + diameter / 2;
17661
- const centerY = topLeftY + diameter / 2;
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;
17662
17793
  const isStatic = !!options.isStatic;
17663
17794
  const entity = this.physic.createEntity({
17664
17795
  uuid: id,
17665
17796
  position: { x: centerX, y: centerY },
17797
+ // Use radius for circular collision detection
17666
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,
17667
17803
  mass: options.mass ?? (isStatic ? Infinity : 1),
17668
17804
  friction: options.friction ?? 0.4,
17669
17805
  linearDamping: isStatic ? 1 : 0.2,
@@ -17691,17 +17827,21 @@ class RpgCommonMap {
17691
17827
  owner.animationName.set("stand");
17692
17828
  }
17693
17829
  });
17830
+ const entityWidth = width;
17831
+ const entityHeight = height;
17694
17832
  entity.onPositionChange(({ x, y }) => {
17695
- const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
17696
- const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
17697
- const topLeftX2 = x - width / 2;
17698
- const topLeftY2 = y - height / 2;
17833
+ const topLeftX2 = x - entityWidth / 2;
17834
+ const topLeftY2 = y - entityHeight / 2;
17835
+ let changed = false;
17699
17836
  if (typeof owner.x === "function" && typeof owner.x.set === "function") {
17700
17837
  owner.x.set(Math.round(topLeftX2));
17701
- owner.applyFrames?.();
17838
+ changed = true;
17702
17839
  }
17703
17840
  if (typeof owner.y === "function" && typeof owner.y.set === "function") {
17704
17841
  owner.y.set(Math.round(topLeftY2));
17842
+ changed = true;
17843
+ }
17844
+ if (changed) {
17705
17845
  owner.applyFrames?.();
17706
17846
  }
17707
17847
  });
@@ -17917,11 +18057,11 @@ class RpgCommonMap {
17917
18057
  setBodyPosition(id, x, y, mode = "center") {
17918
18058
  const entity = this.physic.getEntityByUUID(id);
17919
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);
17920
18062
  let centerX = x;
17921
18063
  let centerY = y;
17922
18064
  if (mode === "top-left") {
17923
- const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
17924
- const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
17925
18065
  centerX = x + width / 2;
17926
18066
  centerY = y + height / 2;
17927
18067
  }
@@ -19173,7 +19313,7 @@ function WithMoveManager(Base) {
19173
19313
  staticTarget.freeze();
19174
19314
  map.moveManager.add(
19175
19315
  this.id,
19176
- new SeekAvoid(engine, () => staticTarget, 3, 50, 5)
19316
+ new SeekAvoid(engine, () => staticTarget, 80, 140, 80, 48)
19177
19317
  );
19178
19318
  }
19179
19319
  /**
@@ -26069,7 +26209,6 @@ let RpgMap = class extends RpgCommonMap {
26069
26209
  player._onInit();
26070
26210
  this.dataIsReady$.pipe(
26071
26211
  finalize$1(() => {
26072
- player.applyFrames();
26073
26212
  this.hooks.callHooks("server-player-onJoinMap", player, this).subscribe();
26074
26213
  })
26075
26214
  ).subscribe();
@@ -26189,8 +26328,16 @@ let RpgMap = class extends RpgCommonMap {
26189
26328
  * This method processes all pending inputs for a player while performing
26190
26329
  * anti-cheat validation to prevent time manipulation and frame skipping.
26191
26330
  * It validates the time deltas between inputs and ensures they are within
26192
- * acceptable ranges. After processing, it saves the last frame position
26193
- * 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
26194
26341
  *
26195
26342
  * @param playerId - The ID of the player to process inputs for
26196
26343
  * @param controls - Optional anti-cheat configuration
@@ -26272,7 +26419,6 @@ let RpgMap = class extends RpgCommonMap {
26272
26419
  lastProcessedFrame = input.frame;
26273
26420
  }
26274
26421
  if (hasProcessedInputs) {
26275
- this.forceSingleTick();
26276
26422
  player.lastProcessedInputTs = lastProcessedTime;
26277
26423
  } else {
26278
26424
  const idleTimeout = Math.max(config.minTimeBetweenInputs * 4, 50);
@@ -26282,7 +26428,6 @@ let RpgMap = class extends RpgCommonMap {
26282
26428
  player.lastProcessedInputTs = 0;
26283
26429
  }
26284
26430
  }
26285
- player.applyFrames();
26286
26431
  return {
26287
26432
  player,
26288
26433
  inputs: processedInputs
@@ -26419,7 +26564,6 @@ let RpgMap = class extends RpgCommonMap {
26419
26564
  eventInstance.context = context$1;
26420
26565
  eventInstance.x.set(x);
26421
26566
  eventInstance.y.set(y);
26422
- eventInstance.applyFrames();
26423
26567
  if (event.name) eventInstance.name.set(event.name);
26424
26568
  this.events()[id] = eventInstance;
26425
26569
  await eventInstance.execMethod("onInit");