@rpgjs/common 5.0.0-alpha.4 → 5.0.0-alpha.41

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.
Files changed (62) hide show
  1. package/dist/PerlinNoise.d.ts +167 -0
  2. package/dist/Player.d.ts +92 -89
  3. package/dist/PrebuiltGui.d.ts +3 -1
  4. package/dist/Presets.d.ts +9 -0
  5. package/dist/Shape.d.ts +100 -0
  6. package/dist/Utils.d.ts +1 -0
  7. package/dist/database/Item.d.ts +17 -1
  8. package/dist/database/Skill.d.ts +21 -0
  9. package/dist/database/index.d.ts +1 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +8994 -13678
  12. package/dist/index.js.map +1 -1
  13. package/dist/modules.d.ts +17 -0
  14. package/dist/movement/MovementManager.d.ts +16 -72
  15. package/dist/movement/MovementStrategy.d.ts +1 -39
  16. package/dist/movement/index.d.ts +3 -12
  17. package/dist/rooms/Map.d.ts +512 -21
  18. package/dist/rooms/WorldMaps.d.ts +163 -0
  19. package/dist/services/save.d.ts +12 -0
  20. package/dist/weather.d.ts +27 -0
  21. package/package.json +8 -9
  22. package/src/PerlinNoise.ts +294 -0
  23. package/src/Player.ts +126 -149
  24. package/src/PrebuiltGui.ts +4 -2
  25. package/src/Presets.ts +9 -0
  26. package/src/Shape.ts +149 -0
  27. package/src/Utils.ts +4 -0
  28. package/src/database/Item.ts +27 -6
  29. package/src/database/Skill.ts +35 -0
  30. package/src/database/index.ts +2 -1
  31. package/src/index.ts +8 -3
  32. package/src/modules.ts +29 -1
  33. package/src/movement/MovementManager.ts +38 -119
  34. package/src/movement/MovementStrategy.ts +4 -42
  35. package/src/movement/index.ts +14 -15
  36. package/src/rooms/Map.ts +1624 -138
  37. package/src/rooms/WorldMaps.ts +269 -0
  38. package/src/services/save.ts +14 -0
  39. package/src/weather.ts +29 -0
  40. package/dist/Physic.d.ts +0 -619
  41. package/dist/movement/strategies/CompositeMovement.d.ts +0 -76
  42. package/dist/movement/strategies/Dash.d.ts +0 -52
  43. package/dist/movement/strategies/IceMovement.d.ts +0 -87
  44. package/dist/movement/strategies/Knockback.d.ts +0 -50
  45. package/dist/movement/strategies/LinearMove.d.ts +0 -43
  46. package/dist/movement/strategies/LinearRepulsion.d.ts +0 -55
  47. package/dist/movement/strategies/Oscillate.d.ts +0 -60
  48. package/dist/movement/strategies/PathFollow.d.ts +0 -78
  49. package/dist/movement/strategies/ProjectileMovement.d.ts +0 -138
  50. package/dist/movement/strategies/SeekAvoid.d.ts +0 -27
  51. package/src/Physic.ts +0 -1644
  52. package/src/movement/strategies/CompositeMovement.ts +0 -173
  53. package/src/movement/strategies/Dash.ts +0 -82
  54. package/src/movement/strategies/IceMovement.ts +0 -158
  55. package/src/movement/strategies/Knockback.ts +0 -81
  56. package/src/movement/strategies/LinearMove.ts +0 -58
  57. package/src/movement/strategies/LinearRepulsion.ts +0 -128
  58. package/src/movement/strategies/Oscillate.ts +0 -144
  59. package/src/movement/strategies/PathFollow.ts +0 -156
  60. package/src/movement/strategies/ProjectileMovement.ts +0 -322
  61. package/src/movement/strategies/SeekAvoid.ts +0 -123
  62. package/tests/physic.spec.ts +0 -454
package/src/rooms/Map.ts CHANGED
@@ -1,18 +1,170 @@
1
1
  import { generateShortUUID, users } from "@signe/sync";
2
2
  import { effect, Signal, signal } from "@signe/reactive";
3
3
  import { Direction, RpgCommonPlayer } from "../Player";
4
- import { RpgCommonPhysic } from "../Physic";
5
- import { Observable, share, Subject } from "rxjs";
6
- import { Knockback, LinearMove, MovementManager } from "../movement";
4
+ import {
5
+ PhysicsEngine,
6
+ Vector2,
7
+ Entity,
8
+ EntityState,
9
+ assignPolygonCollider,
10
+ AABB,
11
+ createCollider,
12
+ } from "@rpgjs/physic";
13
+ import { combineLatest, Observable, share, Subject, Subscription } from "rxjs";
14
+ import { MovementManager } from "../movement";
15
+ import { WorldMapsManager, type RpgWorldMaps } from "./WorldMaps";
16
+
17
+ export type PhysicsEntityKind = "hero" | "npc" | "generic";
18
+
19
+ export interface MapPhysicsInitContext {
20
+ mapData: any;
21
+ }
22
+
23
+ export interface MapPhysicsEntityContext {
24
+ owner: any;
25
+ entity: Entity;
26
+ kind: PhysicsEntityKind;
27
+ }
28
+
29
+ interface ZoneOptions {
30
+ x?: number;
31
+ y?: number;
32
+ radius: number;
33
+ angle?: number;
34
+ direction?: "up" | "down" | "left" | "right";
35
+ linkedTo?: string;
36
+ limitedByWalls?: boolean;
37
+ }
7
38
 
8
39
  export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
9
40
  abstract players: Signal<Record<string, T>>;
10
41
  abstract events: Signal<Record<string, any>>;
11
-
42
+
12
43
  data = signal<any | null>(null);
13
- physic = new RpgCommonPhysic();
14
- moveManager = new MovementManager()
44
+ physic = new PhysicsEngine({
45
+ timeStep: 1 / 60,
46
+ gravity: new Vector2(0, 0),
47
+ enableSleep: false,
48
+ });
49
+ moveManager = new MovementManager(() => this.physic);
50
+
51
+ private speedScalar = 50; // Default speed scalar for movement
52
+
53
+ // World Maps properties
54
+ tileWidth: number = 32;
55
+ tileHeight: number = 32;
56
+ worldMapsManager?: WorldMapsManager;
57
+
58
+ // Synchronization throttling properties
59
+ throttleSync?: number;
60
+ throttleStorage?: number;
61
+ sessionExpiryTime?: number;
62
+
63
+ tickSubscription?: Subscription | null;
64
+ playersSubscription?: Subscription | null;
65
+ eventsSubscription?: Subscription | null;
66
+ private physicsAccumulatorMs = 0;
67
+ private physicsSyncDepth = 0;
68
+
69
+ /**
70
+ * Whether to automatically subscribe to tick$ for physics updates
71
+ * Set to false in test environments for manual control with nextTick()
72
+ */
73
+ protected autoTickEnabled: boolean = true;
74
+
75
+ get isStandalone() {
76
+ return typeof window !== 'undefined'
77
+ }
78
+
79
+
80
+ /**
81
+ * Get the width of the map in pixels
82
+ *
83
+ * @returns The width of the map in pixels, or 0 if not loaded
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const width = map.widthPx;
88
+ * console.log(`Map width: ${width}px`);
89
+ * ```
90
+ */
91
+ get widthPx(): number {
92
+ return this.data()?.width ?? 0
93
+ }
94
+
95
+ /**
96
+ * Get the height of the map in pixels
97
+ *
98
+ * @returns The height of the map in pixels, or 0 if not loaded
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const height = map.heightPx;
103
+ * console.log(`Map height: ${height}px`);
104
+ * ```
105
+ */
106
+ get heightPx(): number {
107
+ return this.data()?.height ?? 0
108
+ }
109
+
110
+ /**
111
+ * Get the unique identifier of the map
112
+ *
113
+ * @returns The map ID, or empty string if not loaded
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const mapId = map.id;
118
+ * console.log(`Current map: ${mapId}`);
119
+ * ```
120
+ */
121
+ get id(): string {
122
+ return this.data()?.id ?? ''
123
+ }
15
124
 
125
+ /**
126
+ * Get the X position of this map in the world coordinate system
127
+ *
128
+ * This is used when maps are part of a larger world map. The world position
129
+ * indicates where this map is located relative to other maps.
130
+ *
131
+ * @returns The X position in world coordinates, or 0 if not in a world
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * const worldX = map.worldX;
136
+ * console.log(`Map is at world position (${worldX}, ${map.worldY})`);
137
+ * ```
138
+ */
139
+ get worldX(): number {
140
+ const worldMaps = this.getWorldMapsManager?.();
141
+ if (!worldMaps) return 0;
142
+ // Extract real map ID (remove "map-" prefix if present)
143
+ const mapId = this.id.startsWith('map-') ? this.id.slice(4) : this.id;
144
+ return worldMaps.getMapInfo(mapId)?.worldX ?? 0
145
+ }
146
+
147
+ /**
148
+ * Get the Y position of this map in the world coordinate system
149
+ *
150
+ * This is used when maps are part of a larger world map. The world position
151
+ * indicates where this map is located relative to other maps.
152
+ *
153
+ * @returns The Y position in world coordinates, or 0 if not in a world
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * const worldY = map.worldY;
158
+ * console.log(`Map is at world position (${map.worldX}, ${worldY})`);
159
+ * ```
160
+ */
161
+ get worldY(): number {
162
+ const worldMaps = this.getWorldMapsManager?.();
163
+ if (!worldMaps) return 0;
164
+ // Extract real map ID (remove "map-" prefix if present)
165
+ const mapId = this.id.startsWith('map-') ? this.id.slice(4) : this.id;
166
+ return worldMaps.getMapInfo(mapId)?.worldY ?? 0
167
+ }
16
168
 
17
169
  /**
18
170
  * Observable representing the game loop tick
@@ -21,12 +173,33 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
21
173
  * It's shared using the share() operator, meaning that all subscribers will receive
22
174
  * events from a single interval rather than creating multiple intervals.
23
175
  *
176
+ * ## Physics Loop Architecture
177
+ *
178
+ * The physics simulation is centralized in this game loop:
179
+ *
180
+ * 1. **Input Processing** (`processInput`): Only updates entity velocities, does NOT step physics
181
+ * 2. **Game Loop** (`tick$` -> `runFixedTicks`): Executes physics simulation with fixed timestep
182
+ * 3. **Fixed Timestep Pattern**: Accumulator-based approach ensures deterministic physics
183
+ *
184
+ * ```
185
+ * Input Events ─────────────────────────────────────────────────────────────►
186
+ * │
187
+ * ▼ (update velocity only)
188
+ * ┌─────────────────────────────────────────────────────────────────────────┐
189
+ * │ Game Loop (tick$) │
190
+ * │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
191
+ * │ │ updateMovements │ → │ stepOneTick │ → │ postTickUpdates │ │
192
+ * │ │ (apply velocity)│ │ (physics step) │ │ (zones, sync) │ │
193
+ * │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
194
+ * └─────────────────────────────────────────────────────────────────────────┘
195
+ * ```
196
+ *
24
197
  * @example
25
198
  * ```ts
26
- * // Subscribe to the game tick to update entity positions
27
- * map.tick$.subscribe(timestamp => {
28
- * // Update game entities based on elapsed time
29
- * this.updateEntities(timestamp);
199
+ * // Subscribe to the game tick for custom updates
200
+ * map.tick$.subscribe(({ delta, timestamp }) => {
201
+ * // Custom game logic runs alongside physics
202
+ * this.updateCustomEntities(delta);
30
203
  * });
31
204
  * ```
32
205
  */
@@ -42,76 +215,243 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
42
215
  share()
43
216
  );
44
217
 
218
+ /**
219
+ * Clear all physics content and reset to initial state
220
+ *
221
+ * This method completely clears the physics system by:
222
+ * - Removing all hitboxes (static and movable)
223
+ * - Removing all zones
224
+ * - Clearing all collision data and events
225
+ * - Clearing all movement events and sliding data
226
+ * - Unsubscribing from the tick subscription
227
+ * - Resetting the physics engine to a clean state
228
+ *
229
+ * Use this method when you need to completely reset the map's physics
230
+ * system, such as when changing maps or restarting a level.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * // Clear all physics when changing maps
235
+ * map.clearPhysic();
236
+ *
237
+ * // Then reload physics for the new map
238
+ * map.loadPhysic();
239
+ * ```
240
+ */
241
+ clearPhysic() {
242
+ // Unsubscribe from tick to stop physics updates
243
+ if (this.tickSubscription) {
244
+ this.tickSubscription.unsubscribe();
245
+ this.tickSubscription = null;
246
+ }
247
+
248
+ if (this.playersSubscription) {
249
+ this.playersSubscription.unsubscribe();
250
+ this.playersSubscription = null;
251
+ }
252
+
253
+ if (this.eventsSubscription) {
254
+ this.eventsSubscription.unsubscribe();
255
+ this.eventsSubscription = null;
256
+ }
257
+
258
+ // Clear all hitboxes and zones from physics system
259
+ this.clearAll();
260
+
261
+ // Reset movement manager
262
+ this.moveManager.clearAll();
263
+ this.emitPhysicsReset();
264
+
265
+ this.physicsAccumulatorMs = 0;
266
+ }
267
+
268
+ /**
269
+ * Clear all physics entities and internal state
270
+ * @private
271
+ */
272
+ private clearAll(): void {
273
+ // Remove all entities from physics engine
274
+ const entities = this.physic.getEntities();
275
+ for (const entity of entities) {
276
+ const owner = (entity as any).owner;
277
+ if (owner) {
278
+ this.emitPhysicsEntityRemove({
279
+ owner,
280
+ entity,
281
+ kind: this.resolvePhysicsEntityKind(owner, entity.uuid),
282
+ });
283
+ }
284
+ this.physic.removeEntity(entity);
285
+ }
286
+
287
+ // Clear movement manager and zone manager
288
+ this.physic.getMovementManager().clearAll();
289
+ this.physic.getZoneManager().clear();
290
+ }
291
+
45
292
  loadPhysic() {
46
- const hitboxes = this.data().hitboxes ?? [];
293
+ this.clearPhysic();
294
+
295
+ const mapData = this.data?.();
296
+ const mapWidth = typeof mapData?.width === "number" ? mapData.width : 0;
297
+ const mapHeight = typeof mapData?.height === "number" ? mapData.height : 0;
298
+ const hitboxes: Array<
299
+ | { id?: string; x: number; y: number; width: number; height: number }
300
+ | { id?: string; points: number[][] }
301
+ > = Array.isArray(mapData?.hitboxes) ? mapData.hitboxes : [];
47
302
 
48
- const gap = 100;
49
- this.physic.addStaticHitbox('map-width-left', -gap, 0, gap, this.data().height);
50
- this.physic.addStaticHitbox('map-width-right', this.data().width, 0, gap, this.data().height);
51
- this.physic.addStaticHitbox('map-height-top', 0, -gap, this.data().width, gap);
52
- this.physic.addStaticHitbox('map-height-bottom', 0, this.data().height, this.data().width, gap);
303
+ if (mapWidth > 0 && mapHeight > 0) {
304
+ const gap = 100;
305
+ this.addStaticHitbox('map-width-left', -gap, 0, gap, mapHeight);
306
+ this.addStaticHitbox('map-width-right', mapWidth, 0, gap, mapHeight);
307
+ this.addStaticHitbox('map-height-top', 0, -gap, mapWidth, gap);
308
+ this.addStaticHitbox('map-height-bottom', 0, mapHeight, mapWidth, gap);
309
+ }
53
310
 
54
311
  for (let staticHitbox of hitboxes) {
55
- this.physic.addStaticHitbox(staticHitbox.id ?? generateShortUUID(), staticHitbox.x, staticHitbox.y, staticHitbox.width, staticHitbox.height);
312
+ if ('x' in staticHitbox) {
313
+ this.addStaticHitbox(staticHitbox.id ?? generateShortUUID(), staticHitbox.x, staticHitbox.y, staticHitbox.width, staticHitbox.height);
314
+ }
315
+ else if ('points' in staticHitbox) {
316
+ this.addStaticHitbox(staticHitbox.id ?? generateShortUUID(), staticHitbox.points);
317
+ }
56
318
  }
57
319
 
58
- this.events.observable.subscribe(({ value: event, type }) => {
59
- if (type == 'add') {
60
- this.physic.addMovableHitbox(event, event.x(), event.y(), event.hitbox().w, event.hitbox().h, {
61
- isSensor: true
320
+ this.emitPhysicsInit({ mapData });
321
+
322
+ this.playersSubscription = (this.players as any).observable.subscribe(
323
+ ({ value: player, type, key }: any) => {
324
+ if (type === "remove") {
325
+ this.removeHitbox(key, player, "hero");
326
+ return;
327
+ }
328
+
329
+ if (type == 'reset') {
330
+ if (!player) return;
331
+ for (let id in player) {
332
+ const _player = player[id]
333
+ _player.id = _player.id ?? id;
334
+ this.createCharacterHitbox(_player, "hero");
335
+ }
336
+ return;
337
+ }
338
+
339
+ if (!player) return;
340
+ if (type === "add") {
341
+ player.id = key;
342
+ this.createCharacterHitbox(player, "hero");
343
+ } else if (type === "update") {
344
+ player.id = player.id ?? key;
345
+ if (!this.getBody(key)) {
346
+ this.createCharacterHitbox(player, "hero");
347
+ return;
348
+ }
349
+ if (this.isPhysicsSyncingSignals) {
350
+ return;
351
+ }
352
+ this.updateCharacterHitbox(player);
353
+ }
354
+ },
355
+ );
356
+
357
+ this.eventsSubscription = this.events.observable.subscribe(({ value: event, type, key }) => {
358
+ if (type === "add") {
359
+ event.id = key;
360
+ this.createCharacterHitbox(event, "npc", {
361
+ mass: 100,
62
362
  });
63
- this.physic.registerMovementEvents(event.id, () => {
64
- event.animationName.set('walk')
65
- }, () => {
66
- event.animationName.set('stand')
67
- })
68
- }
69
- else if (type == 'remove') {
70
- this.physic.removeHitbox(event.id);
363
+ } else if (type === "remove") {
364
+ // Clean up movement event subscriptions
365
+ const eventObj = this.getObjectById(key);
366
+ if (eventObj && typeof (eventObj as any)._movementUnsubscribe === 'function') {
367
+ (eventObj as any)._movementUnsubscribe();
368
+ }
369
+ this.removeHitbox(key, event, "npc");
370
+ } else if (type === "update") {
371
+ event.id = event.id ?? key;
372
+ if (!this.getBody(key)) {
373
+ this.createCharacterHitbox(event, "npc", {
374
+ mass: 100,
375
+ });
376
+ return;
377
+ }
378
+ this.updateCharacterHitbox(event);
379
+ } else if (type === "reset") {
380
+ for (const id in event) {
381
+ const _event = event[id];
382
+ if (!_event) continue;
383
+ _event.id = _event.id ?? id;
384
+ this.createCharacterHitbox(_event, "npc", {
385
+ mass: 100,
386
+ });
387
+ }
71
388
  }
72
389
  });
73
390
 
74
- this.tick$.subscribe(({ delta }) => {
75
- this.physic.update(delta);
76
- this.moveManager.update(delta, this.physic);
77
- });
78
- }
391
+ // Hydrate physics world with already-loaded scene objects.
392
+ // This covers cases where sync state is present before subscriptions are attached.
393
+ const players = this.players();
394
+ for (const id in players) {
395
+ const player = players[id];
396
+ if (!player) continue;
397
+ player.id = player.id ?? id;
398
+ this.createCharacterHitbox(player, "hero");
399
+ }
79
400
 
80
- movePlayer(player: T, direction: Direction) {
81
- this.physic.moveBody(player, direction);
401
+ const events = this.events();
402
+ for (const id in events) {
403
+ const event = events[id];
404
+ if (!event) continue;
405
+ event.id = event.id ?? id;
406
+ this.createCharacterHitbox(event, "npc", {
407
+ mass: 100,
408
+ });
409
+ }
410
+
411
+ // S'abonner au ticker automatique seulement si autoTickEnabled est true
412
+ if (this.autoTickEnabled) {
413
+ this.tickSubscription = this.tick$.subscribe(({ delta }) => {
414
+ this.runFixedTicks(delta);
415
+ });
416
+ }
82
417
  }
83
418
 
84
- /**
85
- * Register movement events for a player or event
86
- *
87
- * Attaches event listeners to detect when an entity starts or stops moving
88
- *
89
- * @param id - ID of the entity (player or event)
90
- * @param onStartMoving - Callback when entity starts moving
91
- * @param onStopMoving - Callback when entity stops moving
92
- * @returns Boolean indicating success
93
- *
94
- * @example
95
- * ```ts
96
- * // Register movement events for the player
97
- * map.registerMovementEvents('player1',
98
- * () => console.log('Player started moving'),
99
- * () => console.log('Player stopped moving')
100
- * );
101
- * ```
102
- */
103
- registerMovementEvents(
104
- id: string,
105
- onStartMoving?: () => void,
106
- onStopMoving?: () => void
107
- ): boolean {
108
- // Check if the object with this ID exists
109
- const object = this.getObjectById(id);
110
- if (!object) return false;
419
+ async movePlayer(player: T, direction: Direction) {
420
+ // Calculate next position before movement
421
+ const currentX = player.x();
422
+ const currentY = player.y();
423
+ const speed = player.speed();
424
+
425
+ let nextX = currentX;
426
+ let nextY = currentY;
111
427
 
112
- // Register with physics system
113
- this.physic.registerMovementEvents(id, onStartMoving, onStopMoving);
114
- return true;
428
+ switch (direction) {
429
+ case Direction.Left:
430
+ nextX = currentX - speed;
431
+ break;
432
+ case Direction.Right:
433
+ nextX = currentX + speed;
434
+ break;
435
+ case Direction.Up:
436
+ nextY = currentY - speed;
437
+ break;
438
+ case Direction.Down:
439
+ nextY = currentY + speed;
440
+ break;
441
+ }
442
+
443
+ player.changeDirection(direction);
444
+
445
+ // Check for automatic map change if the method exists
446
+ if (typeof (player as any).autoChangeMap === 'function' && !player.isEvent()) {
447
+ const mapChanged = await (player as any).autoChangeMap({ x: nextX, y: nextY }, direction);
448
+ if (mapChanged) {
449
+ this.stopMovement(player);
450
+ return
451
+ }
452
+ }
453
+
454
+ this.moveBody(player, direction);
115
455
  }
116
456
 
117
457
  /**
@@ -129,7 +469,7 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
129
469
  * ```
130
470
  */
131
471
  isMoving(id: string): boolean {
132
- return this.physic.isMoving(id);
472
+ return this.isEntityMoving(id);
133
473
  }
134
474
 
135
475
  getObjectById(id: string) {
@@ -137,121 +477,483 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
137
477
  }
138
478
 
139
479
  /**
140
- * Create a temporary and moving hitbox on the map
480
+ * Execute physics simulation with fixed timestep
141
481
  *
142
- * Allows to create a temporary hitbox that moves through multiple positions sequentially.
143
- * For example, you can use it to explode a bomb and find all the affected players,
144
- * or during a sword strike, you can create a moving hitbox and find the affected players.
482
+ * This method runs the physics engine using a fixed timestep accumulator pattern.
483
+ * It ensures deterministic physics regardless of frame rate by:
484
+ * 1. Accumulating delta time
485
+ * 2. Running fixed-size physics steps until the accumulator is depleted
486
+ * 3. Calling `updateMovements()` before each step to apply velocity changes
487
+ * 4. Running post-tick updates (zones, callbacks) after each step
145
488
  *
146
- * The method creates a zone sensor that moves through the specified hitbox positions
147
- * at the given speed, detecting collisions with players and events at each step.
489
+ * ## Architecture
148
490
  *
149
- * @param hitboxes - Array of hitbox positions to move through sequentially
150
- * @param options - Configuration options for the movement
151
- * @returns Observable that emits arrays of hit entities and completes when movement is finished
491
+ * The physics loop is centralized here and called from `tick$` subscription.
492
+ * Input processing (`processInput`) only updates entity velocities - it does NOT
493
+ * step the physics. This ensures:
494
+ * - Consistent physics timing (60fps fixed timestep)
495
+ * - No double-stepping when inputs are processed
496
+ * - Proper accumulator-based interpolation support
497
+ *
498
+ * @param deltaMs - Time elapsed since last call in milliseconds
499
+ * @param hooks - Optional callbacks for before/after each physics step
500
+ * @returns Number of physics ticks executed
152
501
  *
153
502
  * @example
154
503
  * ```ts
155
- * // Create a sword slash effect that moves through two positions
156
- * map.createMovingHitbox([
157
- * { x: 100, y: 100, width: 50, height: 50 },
158
- * { x: 120, y: 100, width: 50, height: 50 }
159
- * ], { speed: 2 }).subscribe({
160
- * next(hits) {
161
- * // hits contains RpgPlayer or RpgEvent objects that were hit
162
- * console.log('Hit entities:', hits);
163
- * },
164
- * complete() {
165
- * console.log('Movement finished');
166
- * }
504
+ * // Called automatically by tick$ subscription
505
+ * this.tickSubscription = this.tick$.subscribe(({ delta }) => {
506
+ * this.runFixedTicks(delta);
507
+ * });
508
+ *
509
+ * // Or manually with hooks for debugging
510
+ * this.runFixedTicks(16, {
511
+ * beforeStep: () => console.log('Before physics step'),
512
+ * afterStep: (tick) => console.log(`Physics tick ${tick} completed`)
167
513
  * });
168
514
  * ```
169
515
  */
170
- createMovingHitbox(
171
- hitboxes: Array<{ x: number; y: number; width: number; height: number }>,
172
- options: { speed?: number } = {}
173
- ): Observable<(T | any)[]> {
174
- const { speed = 1 } = options;
175
- const zoneId = `moving_hitbox_${generateShortUUID()}`;
176
-
177
- return new Observable(observer => {
178
- if (hitboxes.length === 0) {
179
- observer.complete();
180
- return;
181
- }
516
+ protected runFixedTicks(
517
+ deltaMs: number,
518
+ hooks?: {
519
+ beforeStep?: () => void;
520
+ afterStep?: (tick: number) => void;
521
+ },
522
+ ): number {
523
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
524
+ return 0;
525
+ }
182
526
 
183
- let currentIndex = 0;
184
- let frameCounter = 0;
185
- const hitEntities = new Set<string>();
527
+ const fixedStepMs = this.physic.getWorld().getTimeStep() * 1000;
528
+ this.physicsAccumulatorMs += deltaMs;
529
+ let executed = 0;
186
530
 
187
- // Create initial zone at first hitbox position
188
- const firstHitbox = hitboxes[0];
189
- const radius = Math.max(firstHitbox.width, firstHitbox.height) / 2;
531
+ while (this.physicsAccumulatorMs >= fixedStepMs) {
532
+ this.physicsAccumulatorMs -= fixedStepMs;
533
+ hooks?.beforeStep?.();
190
534
 
191
- this.physic.addZone(zoneId, {
192
- x: firstHitbox.x + firstHitbox.width / 2,
193
- y: firstHitbox.y + firstHitbox.height / 2,
194
- radius: radius
535
+ // Update movements before physics step (applies velocity changes from inputs)
536
+ this.physic.updateMovements();
537
+
538
+ const tick = this.physic.stepOneTick();
539
+ executed += 1;
540
+
541
+ // Run post-tick updates (zones, position sync callbacks)
542
+ this.runPostTickUpdates();
543
+
544
+ hooks?.afterStep?.(tick);
545
+ }
546
+
547
+ return executed;
548
+ }
549
+
550
+ /**
551
+ * Manually trigger a single game tick
552
+ *
553
+ * This method allows you to manually advance the game by one tick (16ms at 60fps).
554
+ * It's primarily useful for testing where you need precise control over when
555
+ * physics updates occur, rather than relying on the automatic tick$ subscription.
556
+ *
557
+ * ## Use Cases
558
+ *
559
+ * - **Testing**: Control exactly when physics steps occur in unit tests
560
+ * - **Manual control**: Step through game state manually for debugging
561
+ * - **Deterministic testing**: Ensure consistent timing in test scenarios
562
+ *
563
+ * ## Important
564
+ *
565
+ * This method should NOT be used in production code alongside the automatic `tick$`
566
+ * subscription, as it will cause double-stepping. Use either:
567
+ * - Automatic ticks (via `loadPhysic()` which subscribes to `tick$`)
568
+ * - Manual ticks (via `nextTick()` without `loadPhysic()` subscription)
569
+ *
570
+ * @param deltaMs - Optional delta time in milliseconds (default: 16ms for 60fps)
571
+ * @returns Number of physics ticks executed
572
+ *
573
+ * @example
574
+ * ```ts
575
+ * // In tests: manually advance game by one tick
576
+ * map.nextTick(); // Advances by 16ms (one frame at 60fps)
577
+ *
578
+ * // With custom delta
579
+ * map.nextTick(32); // Advances by 32ms (two frames at 60fps)
580
+ *
581
+ * // In a test loop
582
+ * for (let i = 0; i < 60; i++) {
583
+ * map.nextTick(); // Simulate 1 second of game time
584
+ * }
585
+ * ```
586
+ */
587
+ nextTick(deltaMs: number = 16): number {
588
+ return this.runFixedTicks(deltaMs);
589
+ }
590
+
591
+ /**
592
+ * Force a single physics tick outside of the normal game loop
593
+ *
594
+ * This method is primarily used for **client-side prediction** where the client
595
+ * needs to immediately simulate physics in response to local input, rather than
596
+ * waiting for the next game loop tick.
597
+ *
598
+ * ## Use Cases
599
+ *
600
+ * - **Client-side prediction**: Immediately simulate player movement for responsive feel
601
+ * - **Testing**: Force a physics step in unit tests
602
+ * - **Special effects**: Immediate physics response for specific game events
603
+ *
604
+ * ## Important
605
+ *
606
+ * This method should NOT be used on the server for normal input processing.
607
+ * Server-side physics is handled by `runFixedTicks` in the main game loop to ensure
608
+ * deterministic simulation.
609
+ *
610
+ * @param hooks - Optional callbacks for before/after the physics step
611
+ * @returns The physics tick number
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * // Client-side: immediately simulate predicted movement
616
+ * class RpgClientMap extends RpgCommonMap {
617
+ * stepPredictionTick(): void {
618
+ * this.forceSingleTick();
619
+ * }
620
+ * }
621
+ * ```
622
+ */
623
+ protected forceSingleTick(hooks?: { beforeStep?: () => void; afterStep?: (tick: number) => void }): number {
624
+ hooks?.beforeStep?.();
625
+ this.physic.updateMovements();
626
+ const tick = this.physic.stepOneTick();
627
+ this.runPostTickUpdates();
628
+ hooks?.afterStep?.(tick);
629
+ const fixedMs = this.physic.getWorld().getTimeStep() * 1000;
630
+ this.physicsAccumulatorMs = Math.max(0, this.physicsAccumulatorMs - fixedMs);
631
+ return tick;
632
+ }
633
+
634
+ private createCharacterHitbox(
635
+ owner: any,
636
+ kind: PhysicsEntityKind,
637
+ options?: { isStatic?: boolean; mass?: number },
638
+ ): void {
639
+ if (!owner?.id) {
640
+ return;
641
+ }
642
+ const existingEntity = this.physic.getEntityByUUID(owner.id);
643
+ if (existingEntity) {
644
+ // Rebind owner when the player instance is restored/replaced (e.g. map transfer).
645
+ // Position sync callbacks read entity.owner at runtime.
646
+ (existingEntity as any).owner = owner;
647
+ this.updateCharacterHitbox(owner);
648
+ return;
649
+ }
650
+
651
+ const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
652
+ const width = hitbox?.w ?? 32;
653
+ const height = hitbox?.h ?? 32;
654
+ const radius = Math.max(width, height) / 2;
655
+ this.addCharacter({
656
+ owner,
657
+ radius,
658
+ kind,
659
+ maxSpeed: owner.speed(),
660
+ collidesWithCharacters: !this.shouldDisableCharacterCollisions(owner),
661
+ isStatic: options?.isStatic,
662
+ mass: options?.mass,
663
+ });
664
+ const entity = this.getBody(owner.id);
665
+ if (entity) {
666
+ this.emitPhysicsEntityAdd({
667
+ owner,
668
+ entity,
669
+ kind,
195
670
  });
671
+ }
672
+ }
196
673
 
197
- // Register zone events to detect hits
198
- this.physic.registerZoneEvents(
199
- zoneId,
200
- (hitIds: string[]) => {
201
- // Convert hit IDs to actual objects and emit
202
- const hitObjects = hitIds
203
- .map(id => this.getObjectById(id))
204
- .filter(obj => obj !== undefined);
205
-
206
- if (hitObjects.length > 0) {
207
- // Track hit entities to avoid duplicates
208
- hitIds.forEach(id => hitEntities.add(id));
209
- observer.next(hitObjects);
210
- }
674
+ private updateCharacterHitbox(owner: any): void {
675
+ if (!owner?.id) return;
676
+ const entity = this.physic.getEntityByUUID(owner.id);
677
+ if (!entity) return;
678
+
679
+ // Rebind owner on every update to keep physics callbacks attached to the
680
+ // current player instance after room/session transfers.
681
+ (entity as any).owner = owner;
682
+
683
+ const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
684
+ const width = hitbox?.w ?? 32;
685
+ const height = hitbox?.h ?? 32;
686
+ const topLeftX = this.resolveNumeric(owner.x);
687
+ const topLeftY = this.resolveNumeric(owner.y);
688
+ this.updateHitbox(owner.id, topLeftX, topLeftY, width, height);
689
+ this.setCharacterCollisionEnabled(owner.id, !this.shouldDisableCharacterCollisions(owner));
690
+ }
691
+
692
+ private resolveNumeric(source: any, fallback = 0): number {
693
+ if (typeof source === "function") {
694
+ try {
695
+ return Number(source()) ?? fallback;
696
+ } catch {
697
+ return fallback;
698
+ }
699
+ }
700
+ if (typeof source === "number") {
701
+ return source;
702
+ }
703
+ return fallback;
704
+ }
705
+
706
+ private shouldDisableCharacterCollisions(owner: any): boolean {
707
+ if (typeof owner._through === "function") {
708
+ try {
709
+ return !!owner._through();
710
+ } catch {
711
+ return false;
712
+ }
713
+ }
714
+ if (typeof owner.through === "boolean") {
715
+ return owner.through;
716
+ }
717
+ return false;
718
+ }
719
+
720
+ private resolvePhysicsEntityKind(owner: any, id?: string): PhysicsEntityKind {
721
+ if (typeof owner?.isEvent === "function") {
722
+ try {
723
+ if (owner.isEvent()) {
724
+ return "npc";
211
725
  }
212
- );
726
+ } catch {
727
+ // Ignore owner inspection errors and fallback below
728
+ }
729
+ }
730
+
731
+ const ownerId = typeof owner?.id === "string" ? owner.id : id;
732
+ if (ownerId && this.players()?.[ownerId]) {
733
+ return "hero";
734
+ }
735
+ if (ownerId && this.events()?.[ownerId]) {
736
+ return "npc";
737
+ }
738
+ return "generic";
739
+ }
740
+
741
+ protected emitPhysicsInit(_context: MapPhysicsInitContext): void {}
742
+
743
+ protected emitPhysicsEntityAdd(_context: MapPhysicsEntityContext): void {}
744
+
745
+ protected emitPhysicsEntityRemove(_context: MapPhysicsEntityContext): void {}
746
+
747
+ protected emitPhysicsReset(): void {}
748
+
749
+ protected withPhysicsSync<T>(run: () => T): T {
750
+ this.physicsSyncDepth += 1;
751
+ try {
752
+ return run();
753
+ } finally {
754
+ this.physicsSyncDepth -= 1;
755
+ }
756
+ }
757
+
758
+ protected get isPhysicsSyncingSignals(): boolean {
759
+ return this.physicsSyncDepth > 0;
760
+ }
761
+
762
+ /**
763
+ * Get the world maps manager
764
+ *
765
+ * @returns WorldMapsManager instance or null if not configured
766
+ *
767
+ * @example
768
+ * ```ts
769
+ * const worldMaps = map.getWorldMapsManager();
770
+ * if (worldMaps) {
771
+ * const adjacentMaps = worldMaps.getAdjacentMaps(currentMap, coordinates);
772
+ * }
773
+ * ```
774
+ */
775
+ getWorldMapsManager(): WorldMapsManager | null {
776
+ return this.worldMapsManager ?? null;
777
+ }
778
+
779
+ /**
780
+ * Get attached World
781
+ *
782
+ * Recover the world attached to this map (undefined if no world attached)
783
+ *
784
+ * @since 3.0.0-beta.8
785
+ * @returns {RpgWorldMaps | undefined} The world maps manager instance if attached, otherwise undefined
786
+ *
787
+ * @example
788
+ * ```ts
789
+ * const world = map.getInWorldMaps();
790
+ * if (world) {
791
+ * console.log(world.getAllMaps());
792
+ * }
793
+ * ```
794
+ */
795
+ getInWorldMaps(): RpgWorldMaps | undefined {
796
+ return this.worldMapsManager ?? undefined;
797
+ }
798
+
799
+ /**
800
+ * Remove this map from the world
801
+ *
802
+ * Remove this map from the world
803
+ *
804
+ * @since 3.0.0-beta.8
805
+ * @returns {boolean | undefined} True if removed, false if not found, undefined if no world attached
806
+ *
807
+ * @example
808
+ * ```ts
809
+ * const removed = map.removeFromWorldMaps();
810
+ * ```
811
+ */
812
+ removeFromWorldMaps(): boolean | undefined {
813
+ if (!this.worldMapsManager) return undefined;
814
+ const id = (this as any).id as string | undefined;
815
+ if (!id) return false;
816
+ return this.worldMapsManager.removeMap(id);
817
+ }
818
+
819
+ /**
820
+ * Assign the map to a world
821
+ *
822
+ * Assign the map to a world
823
+ *
824
+ * @since 3.0.0-beta.8
825
+ * @param {RpgWorldMaps} worldMap world maps
826
+ * @returns {void}
827
+ *
828
+ * @example
829
+ * ```ts
830
+ * const world = new WorldMapsManager();
831
+ * world.configure([{ id: 'm1', worldX: 0, worldY: 0, width: 1024, height: 1024 }]);
832
+ * map.setInWorldMaps(world);
833
+ * ```
834
+ */
835
+ setInWorldMaps(worldMap: RpgWorldMaps): void {
836
+ this.worldMapsManager = worldMap;
837
+ }
838
+
839
+
840
+
841
+ /**
842
+ * Create a temporary and moving hitbox on the map
843
+ *
844
+ * Allows to create a temporary hitbox that moves through multiple positions sequentially.
845
+ * For example, you can use it to explode a bomb and find all the affected players,
846
+ * or during a sword strike, you can create a moving hitbox and find the affected players.
847
+ *
848
+ * The method creates a zone sensor that moves through the specified hitbox positions
849
+ * at the given speed, detecting collisions with players and events at each step.
850
+ *
851
+ * @param hitboxes - Array of hitbox positions to move through sequentially
852
+ * @param options - Configuration options for the movement
853
+ * @returns Observable that emits arrays of hit entities and completes when movement is finished
854
+ *
855
+ * @example
856
+ * ```ts
857
+ * // Create a sword slash effect that moves through two positions
858
+ * map.createMovingHitbox([
859
+ * { x: 100, y: 100, width: 50, height: 50 },
860
+ * { x: 120, y: 100, width: 50, height: 50 }
861
+ * ], { speed: 2 }).subscribe({
862
+ * next(hits) {
863
+ * // hits contains RpgPlayer or RpgEvent objects that were hit
864
+ * console.log('Hit entities:', hits);
865
+ * },
866
+ * complete() {
867
+ * console.log('Movement finished');
868
+ * }
869
+ * });
870
+ * ```
871
+ */
872
+ createMovingHitbox(
873
+ hitboxes: Array<{ x: number; y: number; width: number; height: number }>,
874
+ options: { speed?: number } = {}
875
+ ): Observable<(T | any)[]> {
876
+ const { speed = 1 } = options;
877
+ const zoneId = `moving_hitbox_${generateShortUUID()}`;
878
+
879
+ return new Observable(observer => {
880
+ if (hitboxes.length === 0) {
881
+ observer.complete();
882
+ return;
883
+ }
884
+
885
+ let currentIndex = 0;
886
+ let frameCounter = 0;
887
+ const hitEntities = new Set<string>();
888
+
889
+ // Create initial zone at first hitbox position
890
+ const firstHitbox = hitboxes[0];
891
+ const radius = Math.max(firstHitbox.width, firstHitbox.height) / 2;
892
+
893
+ this.addZone(zoneId, {
894
+ x: firstHitbox.x + firstHitbox.width / 2,
895
+ y: firstHitbox.y + firstHitbox.height / 2,
896
+ radius: radius
897
+ });
898
+
899
+ // Register zone events to detect hits
900
+ this.registerZoneEvents(
901
+ zoneId,
902
+ (hitIds: string[]) => {
903
+ // Convert hit IDs to actual objects and emit
904
+ const hitObjects = hitIds
905
+ .map(id => this.getObjectById(id))
906
+ .filter(obj => obj !== undefined);
907
+
908
+ if (hitObjects.length > 0) {
909
+ // Track hit entities to avoid duplicates
910
+ hitIds.forEach(id => hitEntities.add(id));
911
+ observer.next(hitObjects);
912
+ }
913
+ }
914
+ );
213
915
 
214
916
  // Subscribe to tick to handle movement
215
917
  const tickSubscription = this.tick$.subscribe(() => {
216
918
  frameCounter++;
217
-
919
+
218
920
  // Move to next position based on speed
219
921
  if (frameCounter >= speed) {
220
922
  frameCounter = 0;
221
923
  currentIndex++;
222
-
924
+
223
925
  // Check if we've reached the end
224
926
  if (currentIndex >= hitboxes.length) {
225
927
  // Clean up and complete
226
- this.physic.removeZone(zoneId);
928
+ this.removeZone(zoneId);
227
929
  tickSubscription.unsubscribe();
228
930
  observer.complete();
229
931
  return;
230
932
  }
231
-
933
+
232
934
  // Move zone to next position
233
935
  const nextHitbox = hitboxes[currentIndex];
234
- const zone = this.physic.getZone(zoneId);
235
-
936
+ const zone = this.getZone(zoneId);
937
+
236
938
  if (zone) {
237
939
  // Remove current zone and create new one at next position
238
- this.physic.removeZone(zoneId);
239
-
940
+ this.removeZone(zoneId);
941
+
240
942
  const newRadius = Math.max(nextHitbox.width, nextHitbox.height) / 2;
241
- this.physic.addZone(zoneId, {
943
+ this.addZone(zoneId, {
242
944
  x: nextHitbox.x + nextHitbox.width / 2,
243
945
  y: nextHitbox.y + nextHitbox.height / 2,
244
946
  radius: newRadius
245
947
  });
246
-
948
+
247
949
  // Re-register zone events for the new zone
248
- this.physic.registerZoneEvents(
950
+ this.registerZoneEvents(
249
951
  zoneId,
250
952
  (hitIds: string[]) => {
251
953
  const hitObjects = hitIds
252
954
  .map(id => this.getObjectById(id))
253
955
  .filter(obj => obj !== undefined);
254
-
956
+
255
957
  if (hitObjects.length > 0) {
256
958
  hitIds.forEach(id => hitEntities.add(id));
257
959
  observer.next(hitObjects);
@@ -265,8 +967,792 @@ export abstract class RpgCommonMap<T extends RpgCommonPlayer> {
265
967
  // Cleanup function
266
968
  return () => {
267
969
  tickSubscription.unsubscribe();
268
- this.physic.removeZone(zoneId);
970
+ this.removeZone(zoneId);
269
971
  };
270
972
  });
271
973
  }
974
+
975
+ /**
976
+ * Add a static hitbox to the physics world
977
+ * @private
978
+ */
979
+ private addStaticHitbox(
980
+ id: string,
981
+ xOrPoints: number | number[][],
982
+ y?: number,
983
+ width?: number,
984
+ height?: number,
985
+ ): string {
986
+ // Check if entity already exists
987
+ if (this.physic.getEntityByUUID(id)) {
988
+ throw new Error(`Hitbox with id ${id} already exists`);
989
+ }
990
+
991
+ let entity: Entity;
992
+ let boxWidth: number;
993
+ let boxHeight: number;
994
+
995
+ if (Array.isArray(xOrPoints)) {
996
+ const points = xOrPoints;
997
+ if (points.length < 3) {
998
+ throw new Error(`Polygon must have at least 3 points, got ${points.length}`);
999
+ }
1000
+
1001
+ let minX = Number.POSITIVE_INFINITY;
1002
+ let minY = Number.POSITIVE_INFINITY;
1003
+ let maxX = Number.NEGATIVE_INFINITY;
1004
+ let maxY = Number.NEGATIVE_INFINITY;
1005
+
1006
+ for (const point of points) {
1007
+ if (!Array.isArray(point) || point.length !== 2 || typeof point[0] !== "number" || typeof point[1] !== "number") {
1008
+ throw new Error(`Invalid point ${JSON.stringify(point)}. Expected [x, y].`);
1009
+ }
1010
+ minX = Math.min(minX, point[0]);
1011
+ maxX = Math.max(maxX, point[0]);
1012
+ minY = Math.min(minY, point[1]);
1013
+ maxY = Math.max(maxY, point[1]);
1014
+ }
1015
+
1016
+ const centerX = (minX + maxX) / 2;
1017
+ const centerY = (minY + maxY) / 2;
1018
+ boxWidth = Math.max(maxX - minX, 1);
1019
+ boxHeight = Math.max(maxY - minY, 1);
1020
+
1021
+ entity = this.physic.createEntity({
1022
+ uuid: id,
1023
+ position: { x: centerX, y: centerY },
1024
+ width: boxWidth,
1025
+ height: boxHeight,
1026
+ mass: Infinity,
1027
+ state: EntityState.Static,
1028
+ restitution: 0
1029
+ });
1030
+ entity.freeze();
1031
+
1032
+ const localVertices = points.map((point) => {
1033
+ const [px, py] = point as [number, number];
1034
+ return new Vector2(px - centerX, py - centerY);
1035
+ });
1036
+ assignPolygonCollider(entity, { vertices: localVertices });
1037
+ } else {
1038
+ if (typeof y !== "number" || typeof width !== "number" || typeof height !== "number") {
1039
+ throw new Error("Rectangle hitbox requires x, y, width and height parameters");
1040
+ }
1041
+
1042
+ const centerX = xOrPoints + width / 2;
1043
+ const centerY = y + height / 2;
1044
+ boxWidth = Math.max(width, 1);
1045
+ boxHeight = Math.max(height, 1);
1046
+
1047
+ entity = this.physic.createEntity({
1048
+ uuid: id,
1049
+ position: { x: centerX, y: centerY },
1050
+ width: boxWidth,
1051
+ height: boxHeight,
1052
+ mass: Infinity,
1053
+ state: EntityState.Static,
1054
+ restitution: 0
1055
+ });
1056
+ entity.freeze();
1057
+ }
1058
+
1059
+ return id;
1060
+ }
1061
+
1062
+ /**
1063
+ * Add a character to the physics world
1064
+ * @private
1065
+ */
1066
+ /**
1067
+ * Add a character entity to the physics world
1068
+ *
1069
+ * Creates a physics entity for a character (player or NPC) with proper position handling.
1070
+ * The owner's x/y signals represent **top-left** coordinates, while the physics entity
1071
+ * uses **center** coordinates internally.
1072
+ *
1073
+ * ## Position System
1074
+ *
1075
+ * - `owner.x()` / `owner.y()` → **top-left** corner of the character's hitbox
1076
+ * - `entity.position` → **center** of the physics collider
1077
+ * - Conversion: `center = topLeft + (size / 2)`
1078
+ *
1079
+ * @param options - Character configuration
1080
+ * @returns The character's unique ID
1081
+ *
1082
+ * @example
1083
+ * ```ts
1084
+ * // Player at top-left position (100, 100) with 32x32 hitbox
1085
+ * // Physics entity will be at center (116, 116)
1086
+ * this.addCharacter({
1087
+ * owner: player,
1088
+ * x: 116, // center X (ignored, uses owner.x())
1089
+ * y: 116, // center Y (ignored, uses owner.y())
1090
+ * kind: "hero"
1091
+ * });
1092
+ * ```
1093
+ *
1094
+ * @private
1095
+ */
1096
+ private addCharacter(options: {
1097
+ owner: any;
1098
+ radius?: number;
1099
+ kind?: PhysicsEntityKind;
1100
+ collidesWithCharacters?: boolean;
1101
+ maxSpeed?: number;
1102
+ isStatic?: boolean;
1103
+ friction?: number;
1104
+ mass?: number;
1105
+ }): string {
1106
+ if (!options || typeof options.owner?.id !== "string") {
1107
+ throw new Error("Character requires an owner object with a string id");
1108
+ }
1109
+
1110
+ const owner = options.owner;
1111
+ const id = owner.id;
1112
+
1113
+ // Get hitbox dimensions - hitbox.w/h are the FULL dimensions, not radius
1114
+ const hitbox = typeof owner.hitbox === "function" ? owner.hitbox() : owner.hitbox;
1115
+ const width = hitbox?.w ?? 32;
1116
+ const height = hitbox?.h ?? 32;
1117
+
1118
+ // Calculate radius from dimensions (use the larger dimension for circular collider)
1119
+ const radius = Math.max(width, height) / 2;
1120
+
1121
+ // owner.x() and owner.y() are TOP-LEFT positions
1122
+ const topLeftX = owner.x();
1123
+ const topLeftY = owner.y();
1124
+
1125
+ // Convert to CENTER for physics engine
1126
+ const centerX = topLeftX + width / 2;
1127
+ const centerY = topLeftY + height / 2;
1128
+
1129
+ const isStatic = !!options.isStatic;
1130
+
1131
+ const entity = this.physic.createEntity({
1132
+ uuid: id,
1133
+ position: { x: centerX, y: centerY },
1134
+ // Use radius for circular collision detection
1135
+ radius: Math.max(radius, 1),
1136
+ // Also store explicit width/height for consistent position conversions
1137
+ // This ensures getBodyPosition/setBodyPosition use the same dimensions
1138
+ width: width,
1139
+ height: height,
1140
+ mass: options.mass ?? (isStatic ? Infinity : 1),
1141
+ friction: options.friction ?? 0.4,
1142
+ linearDamping: isStatic ? 1 : 0.2,
1143
+ maxLinearVelocity: options.maxSpeed ? options.maxSpeed * this.speedScalar : 200,
1144
+ restitution: 0
1145
+ });
1146
+
1147
+ if (isStatic) {
1148
+ entity.freeze();
1149
+ } else {
1150
+ entity.unfreeze();
1151
+ }
1152
+
1153
+ // Store owner reference directly on entity for syncing positions
1154
+ (entity as any).owner = owner;
1155
+
1156
+ entity.onDirectionChange(({ cardinalDirection }) => {
1157
+ // hack to prevent direction in client side
1158
+ if (!('$send' in this)) return;
1159
+ const owner = (entity as any).owner;
1160
+ if (!owner) return;
1161
+ if (cardinalDirection === 'idle') return;
1162
+ // Don't change direction if it's locked
1163
+ if (owner.directionFixed) return;
1164
+ owner.changeDirection(cardinalDirection as Direction);
1165
+ });
1166
+
1167
+ entity.onMovementChange(({ isMoving, intensity }) => {
1168
+ // Prevent animation changes on client side (same as onDirectionChange)
1169
+ if (!('$send' in this)) return;
1170
+
1171
+ // Get owner from entity (same pattern as onDirectionChange)
1172
+ const owner = (entity as any).owner;
1173
+ if (!owner) return;
1174
+
1175
+ // Don't change animation if it's locked
1176
+ if (owner.animationFixed) return;
1177
+
1178
+ // Only change animation if intensity is low (avoid animation flicker on micro-movements)
1179
+ // Intensity threshold: 10 pixels/second (adjust based on your game's needs)
1180
+ const LOW_INTENSITY_THRESHOLD = 10;
1181
+
1182
+ // Try to use setAnimation method if available (preferred method)
1183
+ // Otherwise, try to access animationName signal directly
1184
+ const hasSetAnimation = typeof owner.setAnimation === 'function';
1185
+ const animationNameSignal = owner.animationName;
1186
+ const ownerHasAnimationName = animationNameSignal && typeof animationNameSignal === 'object' && typeof animationNameSignal.set === 'function';
1187
+
1188
+ if (isMoving && intensity > LOW_INTENSITY_THRESHOLD) {
1189
+ if (hasSetAnimation) {
1190
+ owner.setGraphicAnimation("walk");
1191
+ } else if (ownerHasAnimationName) {
1192
+ animationNameSignal.set("walk");
1193
+ }
1194
+ } else if (!isMoving) {
1195
+ if (hasSetAnimation) {
1196
+ owner.setGraphicAnimation("stand");
1197
+ } else if (ownerHasAnimationName) {
1198
+ animationNameSignal.set("stand");
1199
+ }
1200
+ }
1201
+ // If moving with high intensity, keep current animation (e.g., already running)
1202
+ });
1203
+
1204
+ // Register position sync handler to update owner.x and owner.y.
1205
+ // Read owner dynamically from entity to avoid stale references after map transfer.
1206
+
1207
+ entity.onPositionChange(({ x, y }) => {
1208
+ const currentOwner = (entity as any).owner;
1209
+ if (!currentOwner) {
1210
+ return;
1211
+ }
1212
+
1213
+ const entityWidth = entity.width || width;
1214
+ const entityHeight = entity.height || height;
1215
+ // Calculate top-left from center using the original hitbox dimensions
1216
+ // This ensures consistency: center = topLeft + (size / 2)
1217
+ // Therefore: topLeft = center - (size / 2)
1218
+ const topLeftX = x - entityWidth / 2;
1219
+ const topLeftY = y - entityHeight / 2;
1220
+ let changed = false;
1221
+
1222
+ if (typeof currentOwner.x === "function" && typeof currentOwner.x.set === "function") {
1223
+ currentOwner.x.set(Math.round(topLeftX));
1224
+ changed = true;
1225
+ }
1226
+ if (typeof currentOwner.y === "function" && typeof currentOwner.y.set === "function") {
1227
+ currentOwner.y.set(Math.round(topLeftY));
1228
+ changed = true;
1229
+ }
1230
+ if (changed) {
1231
+ currentOwner.applyFrames?.();
1232
+ }
1233
+ });
1234
+
1235
+ // Add resolution filter for through/throughOtherPlayer/throughEvent
1236
+ // This filter determines if collisions should be RESOLVED (blocking) or just DETECTED.
1237
+ // When returning false, entities pass through each other but collision events still fire.
1238
+ entity.addResolutionFilter((self, other) => {
1239
+ const selfOwner = (self as any).owner;
1240
+ const otherOwner = (other as any).owner;
1241
+
1242
+ // If either entity has no owner, resolve collision (e.g., walls, obstacles must block)
1243
+ if (!selfOwner || !otherOwner) {
1244
+ return true;
1245
+ }
1246
+
1247
+
1248
+ if (selfOwner.z() !== otherOwner.z()) {
1249
+ return false; // Don't resolve collision
1250
+ }
1251
+
1252
+ // Check if selfOwner has _through property (passes through everything)
1253
+ // This applies to both players and events
1254
+ if (typeof selfOwner._through === "function") {
1255
+ try {
1256
+ if (selfOwner._through() === true) {
1257
+ return false; // Pass through but events still fire
1258
+ }
1259
+ } catch {
1260
+ // Ignore errors
1261
+ }
1262
+ } else if (selfOwner.through === true) {
1263
+ return false;
1264
+ }
1265
+
1266
+ // Determine the type of both entities via lookup in players/events
1267
+ const playersMap = this.players();
1268
+ const eventsMap = this.events();
1269
+ const isSelfPlayer = !!playersMap[self.uuid];
1270
+ const isOtherPlayer = !!playersMap[other.uuid];
1271
+ const isSelfEvent = !!eventsMap[self.uuid];
1272
+ const isOtherEvent = !!eventsMap[other.uuid];
1273
+ const readScenarioOwnerId = (owner: any): string | undefined => {
1274
+ const scenarioOwnerId = owner?._scenarioOwnerId ?? owner?.scenarioOwnerId;
1275
+ return typeof scenarioOwnerId === "string" && scenarioOwnerId.length > 0
1276
+ ? scenarioOwnerId
1277
+ : undefined;
1278
+ };
1279
+ const selfScenarioOwnerId = readScenarioOwnerId(selfOwner);
1280
+ const otherScenarioOwnerId = readScenarioOwnerId(otherOwner);
1281
+
1282
+ // Scenario events are isolated per player:
1283
+ // they only collide with their owner and scenario events of the same owner.
1284
+ if (selfScenarioOwnerId) {
1285
+ if (isOtherPlayer && other.uuid !== selfScenarioOwnerId) {
1286
+ return false;
1287
+ }
1288
+ if (isOtherEvent && otherScenarioOwnerId !== selfScenarioOwnerId) {
1289
+ return false;
1290
+ }
1291
+ }
1292
+ if (otherScenarioOwnerId) {
1293
+ if (isSelfPlayer && self.uuid !== otherScenarioOwnerId) {
1294
+ return false;
1295
+ }
1296
+ if (isSelfEvent && selfScenarioOwnerId !== otherScenarioOwnerId) {
1297
+ return false;
1298
+ }
1299
+ }
1300
+
1301
+ // throughOtherPlayer only applies when SELF is a player and OTHER is also a player
1302
+ // (players passing through other players)
1303
+ if (isSelfPlayer && isOtherPlayer) {
1304
+ if (typeof selfOwner._throughOtherPlayer === "function") {
1305
+ try {
1306
+ if (selfOwner._throughOtherPlayer() === true) {
1307
+ return false; // Pass through players but events still fire
1308
+ }
1309
+ } catch {
1310
+ // Ignore errors
1311
+ }
1312
+ } else if (selfOwner.throughOtherPlayer === true) {
1313
+ return false;
1314
+ }
1315
+ }
1316
+
1317
+ // throughEvent only applies when SELF is a player and OTHER is an event
1318
+ // (players passing through events)
1319
+ if (isSelfPlayer && isOtherEvent) {
1320
+ if (typeof selfOwner._throughEvent === "function") {
1321
+ try {
1322
+ if (selfOwner._throughEvent() === true) {
1323
+ return false; // Pass through events but events still fire
1324
+ }
1325
+ } catch {
1326
+ // Ignore errors
1327
+ }
1328
+ } else if (selfOwner.throughEvent === true) {
1329
+ return false;
1330
+ }
1331
+ }
1332
+
1333
+ return true; // Resolve collision (block movement)
1334
+ });
1335
+
1336
+ return id;
1337
+ }
1338
+
1339
+ /**
1340
+ * Update hitbox position and size
1341
+ *
1342
+ * @param id - Entity ID
1343
+ * @param x - Top-left X coordinate
1344
+ * @param y - Top-left Y coordinate
1345
+ * @param width - Optional width
1346
+ * @param height - Optional height
1347
+ * @returns True if hitbox was updated successfully
1348
+ */
1349
+ updateHitbox(id: string, x: number, y: number, width?: number, height?: number): boolean {
1350
+ const entity = this.physic.getEntityByUUID(id);
1351
+ if (!entity) return false;
1352
+
1353
+ if (typeof width === "number" && typeof height === "number") {
1354
+ entity.width = Math.max(width, 1);
1355
+ entity.height = Math.max(height, 1);
1356
+ }
1357
+
1358
+ // Calculate center from top-left
1359
+ const entityWidth = entity.width || entity.radius * 2 || 32;
1360
+ const entityHeight = entity.height || entity.radius * 2 || 32;
1361
+ const centerX = x + entityWidth / 2;
1362
+ const centerY = y + entityHeight / 2;
1363
+ entity.position.set(centerX, centerY);
1364
+
1365
+ return true;
1366
+ }
1367
+
1368
+ /**
1369
+ * Remove a hitbox from the physics world
1370
+ * @private
1371
+ */
1372
+ private removeHitbox(id: string, owner?: any, kind?: PhysicsEntityKind): boolean {
1373
+ const entity = this.physic.getEntityByUUID(id);
1374
+ if (!entity) {
1375
+ return false;
1376
+ }
1377
+ const resolvedOwner = owner ?? (entity as any).owner;
1378
+ if (resolvedOwner) {
1379
+ this.emitPhysicsEntityRemove({
1380
+ owner: resolvedOwner,
1381
+ entity,
1382
+ kind: kind ?? this.resolvePhysicsEntityKind(resolvedOwner, id),
1383
+ });
1384
+ }
1385
+ this.physic.removeEntity(entity);
1386
+ return true;
1387
+ }
1388
+
1389
+ /**
1390
+ * Check if an entity is moving
1391
+ * @private
1392
+ */
1393
+ private isEntityMoving(id: string): boolean {
1394
+ const entity = this.physic.getEntityByUUID(id);
1395
+ if (!entity) return false;
1396
+ // Check if entity has velocity
1397
+ return entity.velocity.length() > 0.1;
1398
+ }
1399
+
1400
+ /**
1401
+ * Move a body in a direction
1402
+ * @private
1403
+ */
1404
+ private moveBody(player: any, direction: Direction): boolean {
1405
+ const entity = this.physic.getEntityByUUID(player.id);
1406
+ if (!entity) return false;
1407
+
1408
+ const speedValue = player.speed()
1409
+
1410
+ let vx = 0, vy = 0;
1411
+ switch (direction) {
1412
+ case Direction.Left:
1413
+ vx = -speedValue * this.speedScalar;
1414
+ break;
1415
+ case Direction.Right:
1416
+ vx = speedValue * this.speedScalar;
1417
+ break;
1418
+ case Direction.Up:
1419
+ vy = -speedValue * this.speedScalar;
1420
+ break;
1421
+ case Direction.Down:
1422
+ vy = speedValue * this.speedScalar;
1423
+ break;
1424
+ }
1425
+
1426
+ // Input sets the base velocity. Movement strategies (dash/knockback/AI, etc.)
1427
+ // are responsible for adding or overriding velocity when needed.
1428
+ entity.setVelocity({ x: vx, y: vy });
1429
+ entity.wakeUp();
1430
+ return true;
1431
+ }
1432
+
1433
+ /**
1434
+ * Stop movement for a player
1435
+ *
1436
+ * Completely stops all movement for a player, including:
1437
+ * - Clearing all active movement strategies (dash, linear moves, etc.)
1438
+ * - Setting velocity to zero
1439
+ * - Resetting intended direction
1440
+ *
1441
+ * This method is particularly useful when changing maps to ensure
1442
+ * the player doesn't carry over movement from the previous map.
1443
+ *
1444
+ * @param player - The player to stop
1445
+ * @returns True if the player was found and movement was stopped
1446
+ *
1447
+ * @example
1448
+ * ```ts
1449
+ * // Stop player movement when changing maps
1450
+ * if (mapChanged) {
1451
+ * map.stopMovement(player);
1452
+ * }
1453
+ *
1454
+ * // Stop movement when player dies
1455
+ * if (player.isDead()) {
1456
+ * map.stopMovement(player);
1457
+ * }
1458
+ * ```
1459
+ * @protected
1460
+ */
1461
+ protected stopMovement(player: any): boolean {
1462
+ const entity = this.physic.getEntityByUUID(player.id);
1463
+ if (!entity) return false;
1464
+
1465
+ // Stop all movement using the MovementManager (clears strategies and stops entity movement)
1466
+ this.moveManager.stopMovement(player.id);
1467
+
1468
+ player.pendingInputs = [];
1469
+
1470
+ return true;
1471
+ }
1472
+
1473
+ /**
1474
+ * Set character collision enabled
1475
+ * @private
1476
+ */
1477
+ private setCharacterCollisionEnabled(id: string, collides: boolean): boolean {
1478
+ const entity = this.physic.getEntityByUUID(id);
1479
+ if (!entity) {
1480
+ return false;
1481
+ }
1482
+ // Collision filtering is handled by PhysicsEngine's collision system
1483
+ // This method is kept for API compatibility but doesn't need to do anything
1484
+ // as PhysicsEngine handles collisions automatically
1485
+ return true;
1486
+ }
1487
+
1488
+ /**
1489
+ * Get collisions for an entity
1490
+ *
1491
+ * Returns all entities that are colliding with the specified entity.
1492
+ * Uses the entity's actual AABB bounds to detect collisions in all directions.
1493
+ *
1494
+ * @param id - Entity UUID to check collisions for
1495
+ * @returns Array of entity UUIDs that are colliding with the specified entity
1496
+ *
1497
+ * @example
1498
+ * ```ts
1499
+ * // Get all entities colliding with player
1500
+ * const collisions = map.getCollisions('player1');
1501
+ * collisions.forEach(id => {
1502
+ * console.log(`Entity ${id} is colliding with player`);
1503
+ * });
1504
+ * ```
1505
+ */
1506
+ protected getCollisions(id: string): string[] {
1507
+ const entity = this.physic.getEntityByUUID(id);
1508
+ if (!entity) return [];
1509
+
1510
+ // Get the entity's actual collider and AABB bounds
1511
+ const collider = createCollider(entity);
1512
+ if (!collider) return [];
1513
+
1514
+ const entityAABB = collider.getBounds();
1515
+
1516
+ // Expand AABB slightly to ensure we catch nearby entities
1517
+ // This helps with edge cases where entities are just touching
1518
+ const expandedAABB = entityAABB.expand(1);
1519
+
1520
+ // Query nearby entities using the expanded AABB
1521
+ const nearby = this.physic.queryAABB(expandedAABB);
1522
+ const collisions: string[] = [];
1523
+
1524
+ // Check actual AABB intersections for each nearby entity
1525
+ for (const other of nearby) {
1526
+ if (other.uuid === id) continue;
1527
+
1528
+ const otherCollider = createCollider(other);
1529
+ if (!otherCollider) continue;
1530
+
1531
+ const otherAABB = otherCollider.getBounds();
1532
+
1533
+ // Check if AABBs actually intersect
1534
+ if (entityAABB.intersects(otherAABB)) {
1535
+ collisions.push(other.uuid);
1536
+ }
1537
+ }
1538
+
1539
+ return collisions;
1540
+ }
1541
+
1542
+ /**
1543
+ * Get physics body (entity) for an id
1544
+ * @protected
1545
+ */
1546
+ public getBody(id: string): Entity | undefined {
1547
+ return this.physic.getEntityByUUID(id);
1548
+ }
1549
+
1550
+ /**
1551
+ * Get the current physics tick
1552
+ * @returns Current tick number
1553
+ */
1554
+ getTick(): number {
1555
+ return this.physic.getTick();
1556
+ }
1557
+
1558
+ /**
1559
+ * Get body position in different modes
1560
+ *
1561
+ * @param id - Entity ID
1562
+ * @param mode - Position mode: "center" or "top-left"
1563
+ * @returns Position coordinates or undefined if entity not found
1564
+ */
1565
+ getBodyPosition(
1566
+ id: string,
1567
+ mode: "center" | "top-left" = "center",
1568
+ ): { x: number; y: number } | undefined {
1569
+ const entity = this.physic.getEntityByUUID(id);
1570
+ if (!entity) return undefined;
1571
+
1572
+ const centerX = entity.position.x;
1573
+ const centerY = entity.position.y;
1574
+ if (mode === "center") {
1575
+ return { x: centerX, y: centerY };
1576
+ }
1577
+
1578
+ // Calculate top-left from center
1579
+ const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
1580
+ const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
1581
+ return {
1582
+ x: centerX - width / 2,
1583
+ y: centerY - height / 2,
1584
+ };
1585
+ }
1586
+
1587
+ /**
1588
+ * Set body position
1589
+ *
1590
+ * @param id - Entity ID
1591
+ * @param x - X coordinate
1592
+ * @param y - Y coordinate
1593
+ * @param mode - Position mode: "center" or "top-left"
1594
+ * @returns True if position was set successfully
1595
+ */
1596
+ setBodyPosition(
1597
+ id: string,
1598
+ x: number,
1599
+ y: number,
1600
+ mode: "center" | "top-left" = "center",
1601
+ ): Entity | undefined {
1602
+ const entity = this.physic.getEntityByUUID(id);
1603
+ if (!entity) return;
1604
+
1605
+ const width = entity.width || (entity.radius ? entity.radius * 2 : 32);
1606
+ const height = entity.height || (entity.radius ? entity.radius * 2 : 32);
1607
+
1608
+ let centerX = x;
1609
+ let centerY = y;
1610
+ if (mode === "top-left") {
1611
+ centerX = x + width / 2;
1612
+ centerY = y + height / 2;
1613
+ }
1614
+
1615
+ entity.position.set(centerX, centerY);
1616
+ entity.notifyPositionChange();
1617
+
1618
+ return entity;
1619
+ }
1620
+
1621
+ /**
1622
+ * Handle collision enter
1623
+ * @private
1624
+ */
1625
+ // Collision handling is now done directly via entity hooks in addCharacter
1626
+ // These methods are no longer needed as PhysicsEngine handles collisions internally
1627
+
1628
+ /**
1629
+ * Add a zone
1630
+ * @private
1631
+ */
1632
+ private addZone(id: string, options: ZoneOptions): string {
1633
+ // Check if zone or entity already exists
1634
+ const zoneManager = this.physic.getZoneManager();
1635
+ if (this.physic.getEntityByUUID(id)) {
1636
+ throw new Error(`Zone with id ${id} already exists as entity`);
1637
+ }
1638
+
1639
+ const radius = options.radius;
1640
+ if (typeof radius !== "number" || radius <= 0) {
1641
+ throw new Error("Zone radius must be a positive number");
1642
+ }
1643
+
1644
+ // If linkedTo is specified, get the entity
1645
+ let attachedEntity: Entity | undefined;
1646
+ if (options.linkedTo) {
1647
+ attachedEntity = this.physic.getEntityByUUID(options.linkedTo);
1648
+ if (!attachedEntity) {
1649
+ throw new Error(`Cannot link zone to unknown entity ${options.linkedTo}`);
1650
+ }
1651
+ }
1652
+
1653
+ const callbacks: { onEnter?: (entities: Entity[]) => void; onExit?: (entities: Entity[]) => void } = {};
1654
+
1655
+ // Store callbacks for later updates
1656
+ (callbacks as any)._onEnterString = undefined;
1657
+ (callbacks as any)._onExitString = undefined;
1658
+
1659
+ const zoneId = attachedEntity
1660
+ ? zoneManager.createAttachedZone(attachedEntity, {
1661
+ radius,
1662
+ angle: options.angle ?? 360,
1663
+ direction: options.direction ?? 'down',
1664
+ limitedByWalls: options.limitedByWalls ?? false,
1665
+ }, callbacks)
1666
+ : zoneManager.createZone({
1667
+ position: { x: options.x ?? 0, y: options.y ?? 0 },
1668
+ radius,
1669
+ angle: options.angle ?? 360,
1670
+ direction: options.direction ?? 'down',
1671
+ limitedByWalls: options.limitedByWalls ?? false,
1672
+ }, callbacks);
1673
+
1674
+ // Store zone ID mapping
1675
+ (this as any)._zoneIdMap = (this as any)._zoneIdMap || new Map();
1676
+ (this as any)._zoneIdMap.set(id, zoneId);
1677
+
1678
+ return id;
1679
+ }
1680
+
1681
+ /**
1682
+ * Remove a zone
1683
+ * @private
1684
+ */
1685
+ private removeZone(id: string): boolean {
1686
+ const zoneIdMap = (this as any)._zoneIdMap;
1687
+ if (!zoneIdMap) return false;
1688
+
1689
+ const zoneId = zoneIdMap.get(id);
1690
+ if (!zoneId) return false;
1691
+
1692
+ const zoneManager = this.physic.getZoneManager();
1693
+ zoneManager.removeZone(zoneId);
1694
+ zoneIdMap.delete(id);
1695
+ return true;
1696
+ }
1697
+
1698
+ /**
1699
+ * Get a zone
1700
+ * @private
1701
+ */
1702
+ private getZone(id: string): any {
1703
+ const zoneIdMap = (this as any)._zoneIdMap;
1704
+ if (!zoneIdMap) return undefined;
1705
+
1706
+ const zoneId = zoneIdMap.get(id);
1707
+ if (!zoneId) return undefined;
1708
+
1709
+ const zoneManager = this.physic.getZoneManager();
1710
+ return zoneManager.getZone(zoneId);
1711
+ }
1712
+
1713
+ /**
1714
+ * Register zone events
1715
+ * @private
1716
+ */
1717
+ private registerZoneEvents(
1718
+ id: string,
1719
+ onEnter?: (hitIds: string[]) => void,
1720
+ onExit?: (hitIds: string[]) => void,
1721
+ ): boolean {
1722
+ const zoneIdMap = (this as any)._zoneIdMap;
1723
+ if (!zoneIdMap) return false;
1724
+
1725
+ const zoneId = zoneIdMap.get(id);
1726
+ if (!zoneId) return false;
1727
+
1728
+ const zoneManager = this.physic.getZoneManager();
1729
+
1730
+ // Use registerCallbacks to update callbacks
1731
+ const callbacks: { onEnter?: (entities: Entity[]) => void; onExit?: (entities: Entity[]) => void } = {};
1732
+ if (onEnter) {
1733
+ callbacks.onEnter = (entities: Entity[]) => {
1734
+ onEnter(entities.map(e => e.uuid));
1735
+ };
1736
+ }
1737
+ if (onExit) {
1738
+ callbacks.onExit = (entities: Entity[]) => {
1739
+ onExit(entities.map(e => e.uuid));
1740
+ };
1741
+ }
1742
+
1743
+ return zoneManager.registerCallbacks(zoneId, callbacks);
1744
+ }
1745
+
1746
+ /**
1747
+ * Run post-tick updates (update zones)
1748
+ * @private
1749
+ */
1750
+ private runPostTickUpdates(): void {
1751
+ // Position sync is now handled automatically by entity.onPositionChange hooks
1752
+ // Movement callbacks are also handled in the onPositionChange handler
1753
+
1754
+ // Update zones
1755
+ const zoneManager = this.physic.getZoneManager();
1756
+ zoneManager.update();
1757
+ }
272
1758
  }