@caperjs/plugin-crunch 0.1.0

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/src/System.ts ADDED
@@ -0,0 +1,1546 @@
1
+ import { Container, Graphics } from 'pixi.js';
2
+ import { Actor } from './Actor';
3
+ import CrunchPhysicsPlugin from './CrunchPhysicsPlugin';
4
+ import { Entity } from './Entity';
5
+ import { Group } from './Group';
6
+ import { AABBLike } from './interfaces';
7
+ import { Sensor } from './Sensor';
8
+ import { Solid } from './Solid';
9
+ import {
10
+ ActorCollision,
11
+ ActorCollisionResult,
12
+ Collision,
13
+ PhysicsEntityConfig,
14
+ PhysicsEntityView,
15
+ Rectangle,
16
+ SensorOverlap,
17
+ } from './types';
18
+
19
+ /**
20
+ * Configuration options for the Crunch physics system.
21
+ * These options control the core behavior of the physics simulation.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const options: PhysicsSystemOptions = {
26
+ * gridSize: 32,
27
+ * gravity: 980,
28
+ * maxVelocity: 1000,
29
+ * debug: true,
30
+ * boundary: { x: 0, y: 0, width: 800, height: 600 },
31
+ * culling: true,
32
+ * collisionResolver: (collisions) => {
33
+ * for (const collision of collisions) {
34
+ * handleCollision(collision);
35
+ * }
36
+ * }
37
+ * };
38
+ * ```
39
+ */
40
+ export interface PhysicsSystemOptions {
41
+ /** Reference to the parent Crunch physics plugin */
42
+ plugin: CrunchPhysicsPlugin;
43
+ /** Size of each grid cell for spatial partitioning (in pixels) */
44
+ gridSize: number;
45
+ /** Gravity strength in pixels per second squared */
46
+ gravity: number;
47
+ /** Maximum velocity for any entity in pixels per second */
48
+ maxVelocity: number;
49
+ /** Whether to render debug visualizations */
50
+ debug?: boolean;
51
+ /** World boundary for culling entities */
52
+ boundary?: Rectangle;
53
+ /** Whether to automatically cull out-of-bounds entities */
54
+ culling?: boolean;
55
+ /** Custom handler for resolving collisions */
56
+ collisionResolver?: (collisions: Collision[]) => void;
57
+ /** Custom handler for resolving sensor overlaps */
58
+ overlapResolver?: (overlaps: SensorOverlap[]) => void;
59
+ /** Custom handler for resolving actor-to-actor collisions */
60
+ actorCollisionResolver?: (collisions: ActorCollision[]) => void;
61
+ /** Whether to enable actor-to-actor collisions */
62
+ enableActorCollisions?: boolean;
63
+ /** Default collision layer for new entities */
64
+ defaultCollisionLayer?: number;
65
+ /** Default collision mask for new entities */
66
+ defaultCollisionMask?: number;
67
+ }
68
+
69
+ /**
70
+ * Represents a cell in the spatial partitioning grid that can contain both solids and actors
71
+ */
72
+ interface GridCell {
73
+ solids: Set<Solid>;
74
+ actors: Set<Actor>;
75
+ }
76
+
77
+ /**
78
+ * Core physics system that manages all physics entities and their interactions.
79
+ * Handles spatial partitioning, collision detection, and entity lifecycle.
80
+ *
81
+ * Features:
82
+ * - Grid-based spatial partitioning for efficient collision checks
83
+ * - Entity management (actors, solids, sensors, groups)
84
+ * - Collision and overlap detection
85
+ * - Debug visualization
86
+ * - Culling system for out-of-bounds entities
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * // Create the physics system
91
+ * const system = new System({
92
+ * gridSize: 32,
93
+ * gravity: 980,
94
+ * maxVelocity: 1000
95
+ * });
96
+ *
97
+ * // Add entities
98
+ * const player = system.createActor({
99
+ * type: 'Player',
100
+ * position: [100, 100]
101
+ * });
102
+ *
103
+ * const platform = system.createSolid({
104
+ * type: 'Platform',
105
+ * position: [0, 500],
106
+ * size: [800, 32]
107
+ * });
108
+ *
109
+ * // Update physics (in game loop)
110
+ * system.update(deltaTime);
111
+ * ```
112
+ */
113
+ export class System {
114
+ private readonly options: PhysicsSystemOptions;
115
+ // Public collections
116
+ public entities: Set<Entity> = new Set();
117
+ private _flaggedEntities: Set<Entity> = new Set();
118
+ // Type-based lookup maps
119
+ public actors: Set<Actor> = new Set();
120
+ public solids: Set<Solid> = new Set();
121
+ public sensors: Set<Sensor> = new Set();
122
+ public groups: Set<Group> = new Set();
123
+ // Type-based lookup maps
124
+ private actorsByType: Map<string, Set<Actor>> = new Map();
125
+ private solidsByType: Map<string, Set<Solid>> = new Map();
126
+ private sensorsByType: Map<string, Set<Sensor>> = new Map();
127
+ private groupsByType: Map<string, Set<Group>> = new Map();
128
+
129
+ // Followers tracking
130
+ private followers: Map<Entity, Set<Entity>> = new Map();
131
+
132
+ // groups tracking
133
+ private groupWithEntities: Map<Group, Set<Entity>> = new Map();
134
+
135
+ // Single grid for both solids and actors
136
+ private grid: Map<string, GridCell> = new Map();
137
+
138
+ // Collision tracking
139
+ private collisions: Collision[] = [];
140
+ private sensorOverlaps: SensorOverlap[] = [];
141
+ private actorCollisions: ActorCollision[] = [];
142
+
143
+ // Reusable data structures for actor collision detection
144
+ private _checkedPairs: Set<string> = new Set();
145
+
146
+ // Object pools for collision results to reduce GC pressure
147
+ private _collisionResultPool: ActorCollisionResult[] = [];
148
+ private _collisionResultPoolIndex: number = 0;
149
+ private _actorCollisionPool: ActorCollision[] = [];
150
+ private _actorCollisionPoolIndex: number = 0;
151
+
152
+ // Debugging
153
+ private _debugContainer: Container;
154
+ private _debugGfx: Graphics | null = null;
155
+ private _debug: boolean = false;
156
+
157
+ private _movedActors: Set<Actor> = new Set();
158
+ private _activeGridCells: Set<string> = new Set();
159
+ private _potentialCollisions: Map<string, [Actor, Actor]> = new Map();
160
+
161
+ set debug(value: boolean) {
162
+ this._debug = value;
163
+
164
+ if (this._debug) {
165
+ if (!this._debugContainer) {
166
+ this._debugContainer = this.options.plugin.container.addChild(new Container());
167
+ }
168
+ if (!this._debugGfx) {
169
+ this._debugGfx = new Graphics();
170
+ }
171
+ this._debugContainer.addChild(this._debugGfx);
172
+ } else {
173
+ this._debugGfx?.clear();
174
+ this._debugContainer?.removeChildren();
175
+ }
176
+ }
177
+
178
+ set gridSize(value: number) {
179
+ this.options.gridSize = value;
180
+ this.grid.clear();
181
+
182
+ for (const solid of this.solids) {
183
+ this.addSolidToGrid(solid);
184
+ }
185
+ }
186
+
187
+ set gravity(value: number) {
188
+ this.options.gravity = value;
189
+ }
190
+
191
+ get gravity(): number {
192
+ return this.options.gravity;
193
+ }
194
+
195
+ set maxVelocity(value: number) {
196
+ this.options.maxVelocity = value;
197
+ }
198
+
199
+ get maxVelocity(): number {
200
+ return this.options.maxVelocity;
201
+ }
202
+
203
+ set boundary(value: Rectangle) {
204
+ this.options.boundary = value;
205
+ }
206
+
207
+ get boundary(): Rectangle {
208
+ return this.options.boundary!;
209
+ }
210
+
211
+ get container(): Container {
212
+ return this.options.plugin.container;
213
+ }
214
+
215
+ public addView(view: PhysicsEntityView): void {
216
+ this.container.addChild(view);
217
+ }
218
+
219
+ constructor(options: PhysicsSystemOptions) {
220
+ this.options = {
221
+ ...options,
222
+ culling: options.culling ?? false,
223
+ };
224
+ this.debug = options.debug ?? false;
225
+ this._debugContainer = new Container();
226
+ options.plugin.container.addChild(this._debugContainer);
227
+ }
228
+
229
+ private _resetPositionFlags(entity: Entity): void {
230
+ entity.updatedFollowPosition = false;
231
+ entity.updatedGroupPosition = false;
232
+ }
233
+
234
+ public update(dt: number): void {
235
+ if (!this.options.plugin.enabled) return;
236
+
237
+ // Clear collections at the start with pre-allocated capacity
238
+ this.collisions.length = 0;
239
+ this.sensorOverlaps.length = 0;
240
+ this.actorCollisions.length = 0;
241
+
242
+ // Reset object pools
243
+ this.resetCollisionPools();
244
+
245
+ // Convert delta time to seconds (cache this calculation)
246
+ const deltaTime = dt / 60;
247
+
248
+ // Update containers first (groups)
249
+ for (const group of this.groups) {
250
+ group.update(deltaTime);
251
+ }
252
+
253
+ // Update solids with optimized grid management
254
+ this.updateSolids(deltaTime);
255
+
256
+ // Update sensors (before actors so they can detect entry/exit in the same frame)
257
+ for (const sensor of this.sensors) {
258
+ this.updateSensor(sensor, deltaTime);
259
+ }
260
+
261
+ // Clear actor collision lists before updating actors
262
+ for (const actor of this.actors) {
263
+ if (actor.actorCollisions.length > 0) {
264
+ actor.actorCollisions.length = 0;
265
+ }
266
+ }
267
+
268
+ // Update actors with potential batching
269
+ this.updateActors(deltaTime);
270
+
271
+ // Process position updates for followers and group entities
272
+ this.updateEntityPositions();
273
+
274
+ // Process overlaps, collisions, and culling in batch operations
275
+ this.processCollisionsAndOverlaps();
276
+
277
+ // Cull out-of-bounds entities if enabled and boundary is set
278
+ if (this.options.culling && this.options.boundary) {
279
+ this.cullOutOfBounds();
280
+ }
281
+
282
+ // Update debug rendering if enabled
283
+ if (this._debug) {
284
+ this.debugRender();
285
+ }
286
+ }
287
+
288
+ // New optimized methods to break up the update loop
289
+ private updateSolids(deltaTime: number): void {
290
+ // Fast path if no solids are moving
291
+ let hasMovingSolids = false;
292
+
293
+ for (const solid of this.solids) {
294
+ solid.preUpdate();
295
+ solid.update(deltaTime);
296
+ solid.postUpdate();
297
+
298
+ if (solid.moving) {
299
+ hasMovingSolids = true;
300
+ }
301
+ }
302
+
303
+ // Skip grid updates if nothing is moving
304
+ if (!hasMovingSolids) return;
305
+
306
+ // Batch grid updates for moving solids
307
+ const movedSolids = new Set<Solid>();
308
+
309
+ for (const solid of this.solids) {
310
+ if (solid.moving) {
311
+ // Remove from old grid cells
312
+ this.removeSolidFromGrid(solid);
313
+ // Move the solid (which will handle pushing/carrying actors and sensors)
314
+ solid.move(0, 0, this.actors, this.sensors, true);
315
+ // Track for batch grid update
316
+ movedSolids.add(solid);
317
+ }
318
+ }
319
+
320
+ // Batch add to grid after all movements are complete
321
+ for (const solid of movedSolids) {
322
+ this.addSolidToGrid(solid);
323
+ }
324
+ }
325
+
326
+ private updateActors(deltaTime: number): void {
327
+ // Skip if no active actors
328
+ if (this.actors.size === 0) return;
329
+
330
+ // Use for...of instead of forEach for better performance
331
+ for (const actor of this.actors) {
332
+ if (actor.active) {
333
+ actor.preUpdate();
334
+ actor.update(deltaTime);
335
+ actor.postUpdate();
336
+
337
+ // Collect collisions in batch
338
+ const actorCollisions = actor.collisions;
339
+ if (actorCollisions.length > 0) {
340
+ for (const result of actorCollisions) {
341
+ this.collisions.push({
342
+ type: `${actor.type}|${result.solid!.type}`,
343
+ entity1: actor,
344
+ entity2: result.solid!,
345
+ result: {
346
+ collided: result.collided,
347
+ normal: result.normal,
348
+ penetration: result.penetration,
349
+ solid: result.solid,
350
+ },
351
+ });
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ private updateEntityPositions(): void {
359
+ this._flaggedEntities.clear();
360
+
361
+ // Process followers first (most dependent)
362
+ for (const [entity, followers] of this.followers) {
363
+ const followX = entity.x;
364
+ const followY = entity.y;
365
+
366
+ for (const follower of followers) {
367
+ if (!follower.updatedFollowPosition) {
368
+ // Cache group checks to avoid repeated property access
369
+ const hasGroup = follower.group !== null;
370
+ const sameGroup = hasGroup && follower.group === entity.group;
371
+
372
+ if (sameGroup) {
373
+ // Get the entity's position relative to its group using the offset
374
+ const entityGroupOffset = entity.groupOffset;
375
+ const groupX = follower.group!.x;
376
+ const groupY = follower.group!.y;
377
+
378
+ // Set position using combined offsets relative to group
379
+ follower.setPosition(
380
+ groupX + entityGroupOffset.x + follower.followOffset.x,
381
+ groupY + entityGroupOffset.y + follower.followOffset.y,
382
+ );
383
+ } else if (hasGroup) {
384
+ // Different groups or followed entity not in a group
385
+ follower.setPosition(
386
+ followX + follower.followOffset.x + follower.group!.x + follower.groupOffset.x,
387
+ followY + follower.followOffset.y + follower.group!.y + follower.groupOffset.y,
388
+ );
389
+ } else {
390
+ // No group, just following
391
+ follower.setPosition(followX + follower.followOffset.x, followY + follower.followOffset.y);
392
+ }
393
+
394
+ follower.updatedFollowPosition = true;
395
+ this._flaggedEntities.add(follower);
396
+
397
+ if (hasGroup) {
398
+ follower.updatedGroupPosition = true;
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ // Process remaining group entities
405
+ for (const [group, groupEntities] of this.groupWithEntities) {
406
+ const groupX = group.x;
407
+ const groupY = group.y;
408
+
409
+ for (const entity of groupEntities) {
410
+ if (!entity.updatedGroupPosition && !entity.updatedFollowPosition) {
411
+ entity.setPosition(groupX + entity.groupOffset.x, groupY + entity.groupOffset.y);
412
+ entity.updatedGroupPosition = true;
413
+ this._flaggedEntities.add(entity);
414
+ }
415
+ }
416
+ }
417
+
418
+ // Reset flags in batch
419
+ this._flaggedEntities.forEach(this._resetPositionFlags);
420
+ }
421
+
422
+ private processCollisionsAndOverlaps(): void {
423
+ // Process overlaps if resolver is set and we have overlaps
424
+ if (this.options.overlapResolver && this.sensorOverlaps.length > 0) {
425
+ this.options.overlapResolver(this.sensorOverlaps);
426
+ }
427
+
428
+ // Check actor-to-actor collisions if enabled and we have multiple actors
429
+ if (this.options.enableActorCollisions && this.actors.size > 1) {
430
+ this.checkActorCollisions();
431
+ }
432
+
433
+ // Process collisions if resolver is set and we have collisions
434
+ if (this.collisions.length > 0 && this.options.collisionResolver) {
435
+ this.options.collisionResolver(this.collisions);
436
+ }
437
+
438
+ // Process actor-to-actor collisions if resolver is set and we have collisions
439
+ if (this.actorCollisions.length > 0 && this.options.actorCollisionResolver) {
440
+ this.options.actorCollisionResolver(this.actorCollisions);
441
+ }
442
+ }
443
+
444
+ // Optimize the cullOutOfBounds method
445
+ private cullOutOfBounds(): void {
446
+ // Skip if no boundary
447
+ if (!this.options.boundary) return;
448
+
449
+ const boundary = this.options.boundary;
450
+ // Pre-allocate removal arrays with reasonable capacity
451
+ const toRemoveActors: Actor[] = [];
452
+ const toRemoveSolids: Solid[] = [];
453
+ const toRemoveSensors: Sensor[] = [];
454
+ const toRemoveGroups: Group[] = [];
455
+
456
+ // Cache boundary values to avoid repeated property access
457
+ const boundX = boundary.x;
458
+ const boundY = boundary.y;
459
+ const boundRight = boundX + boundary.width;
460
+ const boundBottom = boundY + boundary.height;
461
+
462
+ // Check actors with optimized bounds check
463
+ for (const actor of this.actors) {
464
+ const actorRight = actor.x + actor.width;
465
+ const actorBottom = actor.y + actor.height;
466
+
467
+ const inBounds = !(
468
+ actor.x >= boundRight || // Completely to the right
469
+ actorRight <= boundX || // Completely to the left
470
+ actor.y >= boundBottom || // Completely below
471
+ actorBottom <= boundY // Completely above
472
+ );
473
+
474
+ if (!inBounds) {
475
+ if (!actor.isCulled) {
476
+ actor.onCull();
477
+ }
478
+ if (actor.shouldRemoveOnCull) {
479
+ toRemoveActors.push(actor);
480
+ }
481
+ } else if (actor.isCulled) {
482
+ // Uncull if back in bounds
483
+ actor.onUncull();
484
+ }
485
+ }
486
+
487
+ // Check solids with the same optimized bounds check
488
+ for (const solid of this.solids) {
489
+ const solidRight = solid.x + solid.width;
490
+ const solidBottom = solid.y + solid.height;
491
+
492
+ const inBounds = !(
493
+ solid.x >= boundRight ||
494
+ solidRight <= boundX ||
495
+ solid.y >= boundBottom ||
496
+ solidBottom <= boundY
497
+ );
498
+
499
+ if (!inBounds) {
500
+ if (!solid.isCulled) {
501
+ solid.onCull();
502
+ }
503
+ if (solid.shouldRemoveOnCull) {
504
+ toRemoveSolids.push(solid);
505
+ }
506
+ } else if (solid.isCulled) {
507
+ solid.onUncull();
508
+ }
509
+ }
510
+
511
+ // Check sensors with the same optimized bounds check
512
+ for (const sensor of this.sensors) {
513
+ const sensorRight = sensor.x + sensor.width;
514
+ const sensorBottom = sensor.y + sensor.height;
515
+
516
+ const inBounds = !(
517
+ sensor.x >= boundRight ||
518
+ sensorRight <= boundX ||
519
+ sensor.y >= boundBottom ||
520
+ sensorBottom <= boundY
521
+ );
522
+
523
+ if (!inBounds) {
524
+ if (!sensor.isCulled) {
525
+ sensor.onCull();
526
+ }
527
+ if (sensor.shouldRemoveOnCull) {
528
+ toRemoveSensors.push(sensor);
529
+ }
530
+ } else if (sensor.isCulled) {
531
+ sensor.onUncull();
532
+ }
533
+ }
534
+
535
+ // Check groups with the same optimized bounds check
536
+ for (const group of this.groups) {
537
+ const groupRight = group.x + group.width;
538
+ const groupBottom = group.y + group.height;
539
+
540
+ const inBounds = !(
541
+ group.x >= boundRight ||
542
+ groupRight <= boundX ||
543
+ group.y >= boundBottom ||
544
+ groupBottom <= boundY
545
+ );
546
+
547
+ if (!inBounds) {
548
+ if (!group.isCulled) {
549
+ group.onCull();
550
+ }
551
+ if (group.shouldRemoveOnCull) {
552
+ toRemoveGroups.push(group);
553
+ }
554
+ } else if (group.isCulled) {
555
+ group.onUncull();
556
+ }
557
+ }
558
+
559
+ // Batch remove culled entities if any exist
560
+ const actorCount = toRemoveActors.length;
561
+ const solidCount = toRemoveSolids.length;
562
+ const sensorCount = toRemoveSensors.length;
563
+ const groupCount = toRemoveGroups.length;
564
+
565
+ if (actorCount + solidCount + sensorCount + groupCount === 0) return;
566
+
567
+ // Use for loops instead of forEach for better performance
568
+ for (let i = 0; i < actorCount; i++) {
569
+ this.removeActor(toRemoveActors[i]);
570
+ }
571
+
572
+ for (let i = 0; i < solidCount; i++) {
573
+ this.removeSolid(toRemoveSolids[i]);
574
+ }
575
+
576
+ for (let i = 0; i < sensorCount; i++) {
577
+ this.removeSensor(toRemoveSensors[i]);
578
+ }
579
+
580
+ for (let i = 0; i < groupCount; i++) {
581
+ this.removeGroup(toRemoveGroups[i]);
582
+ }
583
+ }
584
+
585
+ private updateSensor(sensor: Sensor, dt: number): void {
586
+ sensor.preUpdate();
587
+ sensor.update(dt);
588
+ sensor.postUpdate();
589
+
590
+ const overlaps = sensor.checkActorOverlaps();
591
+ this.sensorOverlaps.push(...overlaps);
592
+ }
593
+
594
+ public createEntity(config: PhysicsEntityConfig): Actor | Solid | Sensor | Group {
595
+ if (config.type === 'actor') {
596
+ return this.createActor(config);
597
+ } else if (config.type === 'solid') {
598
+ return this.createSolid(config);
599
+ } else if (config.type === 'sensor') {
600
+ return this.createSensor(config);
601
+ } else if (config.type === 'group') {
602
+ return this.createGroup(config);
603
+ }
604
+ throw new Error(`Invalid entity type: ${config.type}`);
605
+ }
606
+
607
+ public addEntity(entity: Entity | Actor | Solid | Sensor | Group): Actor | Solid | Sensor | Group {
608
+ if (!entity || !entity.entityType) {
609
+ throw new Error('Entity is required');
610
+ }
611
+ if (entity.entityType === 'Actor') {
612
+ return this.addActor(entity as Actor);
613
+ } else if (entity.entityType === 'Solid') {
614
+ return this.addSolid(entity as Solid);
615
+ } else if (entity.entityType === 'Sensor') {
616
+ return this.addSensor(entity as Sensor);
617
+ } else if (entity.entityType === 'Group') {
618
+ return this.addGroup(entity as Group);
619
+ }
620
+ throw new Error(`Invalid entity type: ${entity!.entityType}`);
621
+ }
622
+
623
+ public removeEntity(entity: Entity | Actor | Solid | Sensor | Group): void {
624
+ if (entity.entityType === 'Actor') {
625
+ this.removeActor(entity as Actor);
626
+ } else if (entity.entityType === 'Solid') {
627
+ this.removeSolid(entity as Solid);
628
+ } else if (entity.entityType === 'Sensor') {
629
+ this.removeSensor(entity as Sensor);
630
+ } else if (entity.entityType === 'Group') {
631
+ this.removeGroup(entity as Group);
632
+ }
633
+ }
634
+
635
+ public createActor(config: PhysicsEntityConfig): Actor {
636
+ // Apply default collision settings if not specified in config
637
+ if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) {
638
+ config.collisionLayer = this.options.defaultCollisionLayer;
639
+ }
640
+ if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) {
641
+ config.collisionMask = this.options.defaultCollisionMask;
642
+ }
643
+
644
+ const actor = config.class ? (new config.class(config) as Actor) : new Actor(config);
645
+ return this.addActor(actor);
646
+ }
647
+
648
+ public addActor(actor: Actor): Actor {
649
+ this.entities.add(actor);
650
+ this.actors.add(actor);
651
+
652
+ // Add to type index
653
+ if (!this.actorsByType.has(actor.type)) {
654
+ this.actorsByType.set(actor.type, new Set());
655
+ }
656
+ this.actorsByType.get(actor.type)!.add(actor);
657
+
658
+ // Add to actor grid if actor collisions are enabled
659
+ if (this.options.enableActorCollisions) {
660
+ actor.updateGridCells();
661
+ }
662
+
663
+ return actor;
664
+ }
665
+
666
+ public createSensor(config: PhysicsEntityConfig): Sensor {
667
+ // Apply default collision settings if not specified in config
668
+ if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) {
669
+ config.collisionLayer = this.options.defaultCollisionLayer;
670
+ }
671
+ if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) {
672
+ config.collisionMask = this.options.defaultCollisionMask;
673
+ }
674
+
675
+ const sensor = config.class ? (new config.class(config) as Sensor) : new Sensor(config);
676
+ return this.addSensor(sensor);
677
+ }
678
+
679
+ public addSensor(sensor: Sensor): Sensor {
680
+ this.entities.add(sensor);
681
+ this.sensors.add(sensor);
682
+ // Add to type index
683
+ if (!this.sensorsByType.has(sensor.type)) {
684
+ this.sensorsByType.set(sensor.type, new Set());
685
+ }
686
+ this.sensorsByType.get(sensor.type)!.add(sensor);
687
+ return sensor;
688
+ }
689
+
690
+ public createSolid(config: PhysicsEntityConfig): Solid {
691
+ // Apply default collision settings if not specified in config
692
+ if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) {
693
+ config.collisionLayer = this.options.defaultCollisionLayer;
694
+ }
695
+ if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) {
696
+ config.collisionMask = this.options.defaultCollisionMask;
697
+ }
698
+
699
+ const solid = config.class ? (new config.class(config) as Solid) : new Solid(config);
700
+ return this.addSolid(solid);
701
+ }
702
+
703
+ public addSolid(solid: Solid): Solid {
704
+ this.entities.add(solid);
705
+ this.solids.add(solid);
706
+ // Add to type index
707
+ if (!this.solidsByType.has(solid.type)) {
708
+ this.solidsByType.set(solid.type, new Set());
709
+ }
710
+ this.solidsByType.get(solid.type)!.add(solid);
711
+ // Add to spatial grid
712
+ this.addSolidToGrid(solid);
713
+
714
+ return solid;
715
+ }
716
+
717
+ public removeActor(actor: Actor, destroyView: boolean = true): void {
718
+ this.entities.delete(actor);
719
+ this.actors.delete(actor);
720
+ // Remove from type index
721
+ const typeSet = this.actorsByType.get(actor.type);
722
+ if (typeSet) {
723
+ typeSet.delete(actor);
724
+ if (typeSet.size === 0) {
725
+ this.actorsByType.delete(actor.type);
726
+ }
727
+ }
728
+ actor.onRemoved();
729
+
730
+ if (destroyView) {
731
+ actor.view?.removeFromParent();
732
+ }
733
+
734
+ // Remove from actor grid
735
+ if (this.options.enableActorCollisions) {
736
+ this.removeActorFromGrid(actor);
737
+ }
738
+ }
739
+
740
+ public removeSolid(solid: Solid, destroyView: boolean = true): void {
741
+ this.entities.delete(solid);
742
+ this.solids.delete(solid);
743
+ // Remove from type index
744
+ const typeSet = this.solidsByType.get(solid.type);
745
+ if (typeSet) {
746
+ typeSet.delete(solid);
747
+ if (typeSet.size === 0) {
748
+ this.solidsByType.delete(solid.type);
749
+ }
750
+ }
751
+ this.removeSolidFromGrid(solid);
752
+
753
+ if (destroyView) {
754
+ solid.view?.removeFromParent();
755
+ }
756
+
757
+ solid.onRemoved();
758
+ }
759
+
760
+ public removeSensor(sensor: Sensor, destroyView: boolean = true): void {
761
+ this.entities.delete(sensor);
762
+ this.sensors.delete(sensor);
763
+ // Remove from type index
764
+ const typeSet = this.sensorsByType.get(sensor.type);
765
+ if (typeSet) {
766
+ typeSet.delete(sensor);
767
+ if (typeSet.size === 0) {
768
+ this.sensorsByType.delete(sensor.type);
769
+ }
770
+ }
771
+
772
+ sensor.onRemoved();
773
+
774
+ if (destroyView) {
775
+ sensor.view?.removeFromParent();
776
+ }
777
+ }
778
+
779
+ public moveSolid(solid: Solid, x: number, y: number): void {
780
+ // Remove from old grid cells
781
+ this.removeSolidFromGrid(solid);
782
+
783
+ // Move the solid (which will handle pushing/carrying actors)
784
+ solid.move(x, y, this.actors, this.sensors);
785
+
786
+ // Add to new grid cells
787
+ this.addSolidToGrid(solid);
788
+
789
+ solid.updateView();
790
+ }
791
+
792
+ public getSolidsAt(x: number, y: number, entity: Actor | Sensor): Solid[] {
793
+ // Early return if entity has no collision mask
794
+ if (entity.collisionMask === 0) return [];
795
+
796
+ const bounds = {
797
+ x,
798
+ y,
799
+ width: entity.width,
800
+ height: entity.height,
801
+ };
802
+
803
+ // Calculate movement direction from current position to check position
804
+ const dx = x - entity.x;
805
+ const dy = y - entity.y;
806
+
807
+ // Get the base cells that the bounds intersect
808
+ const cells = this.getCells(bounds);
809
+
810
+ // Add one extra cell in the direction of movement
811
+ if (dx !== 0) {
812
+ const extraX =
813
+ dx > 0 ? Math.ceil((x + bounds.width) / this.options.gridSize) : Math.floor(x / this.options.gridSize) - 1;
814
+
815
+ for (
816
+ let y = Math.floor(bounds.y / this.options.gridSize);
817
+ y < Math.ceil((bounds.y + bounds.height) / this.options.gridSize);
818
+ y++
819
+ ) {
820
+ cells.push(`${extraX},${y}`);
821
+ }
822
+ }
823
+
824
+ if (dy !== 0) {
825
+ const extraY =
826
+ dy > 0 ? Math.ceil((y + bounds.height) / this.options.gridSize) : Math.floor(y / this.options.gridSize) - 1;
827
+
828
+ for (
829
+ let x = Math.floor(bounds.x / this.options.gridSize);
830
+ x < Math.ceil((bounds.x + bounds.width) / this.options.gridSize);
831
+ x++
832
+ ) {
833
+ cells.push(`${x},${extraY}`);
834
+ }
835
+ }
836
+
837
+ // Create a Set of excluded types for O(1) lookups
838
+ // Pre-allocate result array with a reasonable size to avoid resizing
839
+ const result: Solid[] = [];
840
+ const seen = new Set<Solid>();
841
+
842
+ // Cache entity collision layer and mask for faster access
843
+ const entityLayer = entity.collisionLayer;
844
+ const entityMask = entity.collisionMask;
845
+
846
+ for (const cell of cells) {
847
+ const gridCell = this.grid.get(cell);
848
+ if (gridCell) {
849
+ for (const solid of gridCell.solids) {
850
+ // Skip already seen solids
851
+ if (seen.has(solid)) continue;
852
+
853
+ // Fast collision layer check
854
+ if ((entityLayer & solid.collisionMask) === 0 || (solid.collisionLayer & entityMask) === 0) continue;
855
+
856
+ seen.add(solid);
857
+
858
+ // Only add solids that actually overlap with the entity's bounds
859
+ if (
860
+ this.overlaps(bounds, {
861
+ x: solid.x,
862
+ y: solid.y,
863
+ width: solid.width,
864
+ height: solid.height,
865
+ })
866
+ ) {
867
+ result.push(solid);
868
+ }
869
+ }
870
+ }
871
+ }
872
+
873
+ return result;
874
+ }
875
+
876
+ public addFollower(entity: Entity, follower: Entity): void {
877
+ // Logger.log('adding follower', entity.type, follower.type);
878
+ if (!this.followers.has(entity)) {
879
+ this.followers.set(entity, new Set());
880
+ }
881
+ this.followers.get(entity)!.add(follower);
882
+ }
883
+
884
+ public removeFollower(entity: Entity): void {
885
+ // remove the entity from any set in the followers map
886
+ for (const followers of this.followers.values()) {
887
+ followers.delete(entity);
888
+ }
889
+ }
890
+
891
+ public getFollowersOf(entity: Entity): Entity[] {
892
+ return Array.from(this.followers.get(entity) || []);
893
+ }
894
+
895
+ public removeFollowersOf(entity: Entity): void {
896
+ const set = this.followers.get(entity);
897
+ if (set) {
898
+ set.forEach((follower) => {
899
+ follower.destroy();
900
+ });
901
+ set.clear();
902
+ this.followers.delete(entity);
903
+ }
904
+ }
905
+
906
+ public addToGroup(group: Group, entity: Entity): Entity {
907
+ if (!this.groupWithEntities.has(group)) {
908
+ this.groupWithEntities.set(group, new Set());
909
+ }
910
+ this.groupWithEntities.get(group)!.add(entity);
911
+ return entity;
912
+ }
913
+
914
+ public removeFromGroup(entity: Entity): Entity {
915
+ for (const group of this.groupWithEntities.values()) {
916
+ group.delete(entity);
917
+ }
918
+ return entity;
919
+ }
920
+
921
+ public getEntitiesInGroup(group: Group): Entity[] {
922
+ return Array.from(this.groupWithEntities.get(group) || []);
923
+ }
924
+
925
+ public removeEntitiesOfGroup(group: Group): Entity[] {
926
+ const entities = Array.from(this.groupWithEntities.get(group) || []) || [];
927
+ entities.forEach((entity) => {
928
+ entity.setGroup(null);
929
+ });
930
+ this.groupWithEntities.delete(group);
931
+ return entities;
932
+ }
933
+
934
+ private overlaps(a: Rectangle, b: Rectangle): boolean {
935
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
936
+ }
937
+
938
+ private getCells(bounds: Rectangle): string[] {
939
+ const cells: string[] = [];
940
+ const { gridSize } = this.options;
941
+
942
+ // Calculate the exact grid cells that the bounds intersect with
943
+ const startX = Math.floor(bounds.x / gridSize);
944
+ const startY = Math.floor(bounds.y / gridSize);
945
+ const endX = Math.ceil((bounds.x + bounds.width) / gridSize);
946
+ const endY = Math.ceil((bounds.y + bounds.height) / gridSize);
947
+
948
+ // Pre-allocate array with exact size to avoid resizing
949
+ cells.length = (endX - startX) * (endY - startY);
950
+
951
+ // Use a single index counter to avoid array.push() operations
952
+ let index = 0;
953
+
954
+ // Only check one additional cell in the direction of movement for actors
955
+ // For solids or static bounds, just use the exact cells
956
+ for (let x = startX; x < endX; x++) {
957
+ for (let y = startY; y < endY; y++) {
958
+ // Use template string only once per iteration
959
+ cells[index++] = `${x},${y}`;
960
+ }
961
+ }
962
+
963
+ return cells;
964
+ }
965
+
966
+ private addSolidToGrid(solid: Solid): void {
967
+ const bounds = {
968
+ x: solid.x,
969
+ y: solid.y,
970
+ width: solid.width,
971
+ height: solid.height,
972
+ };
973
+ const cells = this.getCells(bounds);
974
+
975
+ for (const cell of cells) {
976
+ if (!this.grid.has(cell)) {
977
+ this.grid.set(cell, { solids: new Set(), actors: new Set() });
978
+ }
979
+ this.grid.get(cell)!.solids.add(solid);
980
+ }
981
+ }
982
+
983
+ private removeSolidFromGrid(solid: Solid): void {
984
+ const bounds = {
985
+ x: solid.x,
986
+ y: solid.y,
987
+ width: solid.width,
988
+ height: solid.height,
989
+ };
990
+ const cells = this.getCells(bounds);
991
+
992
+ for (const cell of cells) {
993
+ const gridCell = this.grid.get(cell);
994
+ if (gridCell) {
995
+ gridCell.solids.delete(solid);
996
+ if (gridCell.solids.size === 0 && gridCell.actors.size === 0) {
997
+ this.grid.delete(cell);
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ public debugRender(): void {
1004
+ if (!this._debugGfx) {
1005
+ return;
1006
+ }
1007
+ const gfx = this._debugGfx!;
1008
+ gfx.clear();
1009
+
1010
+ // Draw boundary if set
1011
+ if (this.options.boundary) {
1012
+ const b = this.options.boundary;
1013
+ gfx.rect(b.x, b.y, b.width, b.height);
1014
+ gfx.stroke({ color: 0xff0000, width: 2, alignment: 0.5 });
1015
+ }
1016
+
1017
+ // Draw grid
1018
+ for (const cell of this.grid.keys()) {
1019
+ const [x, y] = cell.split(',').map(Number);
1020
+ gfx.rect(x * this.options.gridSize, y * this.options.gridSize, this.options.gridSize, this.options.gridSize);
1021
+ }
1022
+ gfx.stroke({ color: 0x00ff00, width: 1, join: 'miter', cap: 'butt' });
1023
+
1024
+ // Draw solids
1025
+ for (const solid of this.solids) {
1026
+ gfx.rect(solid.x, solid.y, solid.width, solid.height);
1027
+ gfx.stroke({ color: solid.debugColor ?? 0x00ff00, alpha: 1 });
1028
+ }
1029
+
1030
+ // Draw actors
1031
+ for (const actor of this.actors) {
1032
+ gfx.rect(actor.x, actor.y, actor.width, actor.height);
1033
+ gfx.stroke({ color: actor.debugColor ?? 0xff0000, alpha: 1 });
1034
+ }
1035
+
1036
+ // Draw sensors
1037
+ for (const sensor of this.sensors) {
1038
+ gfx.rect(sensor.x, sensor.y, sensor.width, sensor.height);
1039
+ gfx.stroke({ color: sensor.debugColor ?? 0xffff00, alpha: 1 });
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * Get all entities of a specific type
1045
+ * @param type The type to look for
1046
+ * @returns Array of entities matching the type
1047
+ */
1048
+ public getByType(type: string): (Actor | Solid)[] {
1049
+ const actors = this.actorsByType.get(type) || new Set<Actor>();
1050
+ const solids = this.solidsByType.get(type) || new Set<Solid>();
1051
+ return [...actors, ...solids];
1052
+ }
1053
+
1054
+ /**
1055
+ * Get all actors of a specific type
1056
+ * @param type The type to look for
1057
+ * @returns Array of actors matching the type
1058
+ */
1059
+ public getActorsByType(type: string | string[]): Actor[] {
1060
+ if (Array.isArray(type)) {
1061
+ return type.flatMap((t) => Array.from(this.actorsByType.get(t) || new Set()));
1062
+ }
1063
+ return Array.from(this.actorsByType.get(type) || new Set());
1064
+ }
1065
+ /**
1066
+ * Get all solids of a specific type
1067
+ * @param type The type to look for
1068
+ * @returns Array of solids matching the type
1069
+ */
1070
+ public getSolidsByType(type: string | string[]): Solid[] {
1071
+ if (Array.isArray(type)) {
1072
+ return type.flatMap((t) => Array.from(this.solidsByType.get(t) || new Set()));
1073
+ }
1074
+ return Array.from(this.solidsByType.get(type) || new Set());
1075
+ }
1076
+ /**
1077
+ * Get all sensors of a specific type
1078
+ * @param type The type to look for
1079
+ * @returns Array of sensors matching the type
1080
+ */
1081
+ public getSensorsByType(type: string | string[]): Sensor[] {
1082
+ if (Array.isArray(type)) {
1083
+ return type.flatMap((t) => Array.from(this.sensorsByType.get(t) || new Set()));
1084
+ }
1085
+ return Array.from(this.sensorsByType.get(type) || new Set());
1086
+ }
1087
+
1088
+ public clearGrid(): void {
1089
+ this.grid.clear();
1090
+ }
1091
+
1092
+ public clearAll(destroy: boolean = true) {
1093
+ this.grid.clear();
1094
+ this.entities.clear();
1095
+
1096
+ if (destroy) {
1097
+ this.solids.forEach((solid) => {
1098
+ solid.destroy();
1099
+ });
1100
+ this.actors.forEach((actor) => {
1101
+ actor.destroy();
1102
+ });
1103
+ this.sensors.forEach((sensor) => sensor.destroy());
1104
+ }
1105
+
1106
+ this.solids.clear();
1107
+ this.actors.clear();
1108
+ this.sensors.clear();
1109
+
1110
+ this.solidsByType.clear();
1111
+ this.actorsByType.clear();
1112
+ this.sensorsByType.clear();
1113
+ }
1114
+
1115
+ public destroy(): void {
1116
+ this.debug = false;
1117
+ this.gravity = 0;
1118
+ this.maxVelocity = 0;
1119
+
1120
+ this.clearAll();
1121
+ }
1122
+
1123
+ /**
1124
+ * Gets a collision result object from the pool or creates a new one if needed
1125
+ */
1126
+ private getCollisionResult(): ActorCollisionResult {
1127
+ if (this._collisionResultPoolIndex < this._collisionResultPool.length) {
1128
+ const result = this._collisionResultPool[this._collisionResultPoolIndex++];
1129
+ result.collided = false;
1130
+ result.actor = null as any;
1131
+ result.normal = undefined;
1132
+ result.penetration = 0;
1133
+ return result;
1134
+ }
1135
+
1136
+ // Create a new object and add it to the pool
1137
+ const newResult: ActorCollisionResult = {
1138
+ collided: false,
1139
+ actor: null as any,
1140
+ normal: undefined,
1141
+ penetration: 0,
1142
+ };
1143
+
1144
+ this._collisionResultPool.push(newResult);
1145
+ this._collisionResultPoolIndex++;
1146
+ return newResult;
1147
+ }
1148
+
1149
+ /**
1150
+ * Gets an actor collision object from the pool or creates a new one if needed
1151
+ */
1152
+ private getActorCollision(): ActorCollision {
1153
+ if (this._actorCollisionPoolIndex < this._actorCollisionPool.length) {
1154
+ return this._actorCollisionPool[this._actorCollisionPoolIndex++];
1155
+ }
1156
+
1157
+ // Create a new object and add it to the pool
1158
+ const newCollision: ActorCollision = {
1159
+ type: 'unknown|unknown' as `${string}|${string}`,
1160
+ actor1: null as any,
1161
+ actor2: null as any,
1162
+ result: null as any,
1163
+ };
1164
+
1165
+ this._actorCollisionPool.push(newCollision);
1166
+ this._actorCollisionPoolIndex++;
1167
+ return newCollision;
1168
+ }
1169
+
1170
+ /**
1171
+ * Resets the collision result pools for the next frame
1172
+ */
1173
+ private resetCollisionPools(): void {
1174
+ this._collisionResultPoolIndex = 0;
1175
+ this._actorCollisionPoolIndex = 0;
1176
+ }
1177
+
1178
+ /**
1179
+ * Updates an actor's position in the grid.
1180
+ * This is called when an actor moves or when its size changes.
1181
+ *
1182
+ * @param actor - The actor to update in the grid
1183
+ */
1184
+ public updateActorInGrid(actor: Actor): void {
1185
+ // Skip if actor collisions are disabled
1186
+ if (!this.options.enableActorCollisions) return;
1187
+
1188
+ // Skip if actor can't collide
1189
+ if (!actor.active || actor.collisionLayer === 0 || actor.collisionMask === 0) {
1190
+ // If actor has cells, remove it from them
1191
+ if (actor.currentGridCells.length > 0) {
1192
+ this.removeActorFromGrid(actor);
1193
+ }
1194
+ return;
1195
+ }
1196
+
1197
+ // First remove from current cells
1198
+ this.removeActorFromGrid(actor);
1199
+
1200
+ // Calculate new cells
1201
+ const bounds = {
1202
+ x: actor.x,
1203
+ y: actor.y,
1204
+ width: actor.width,
1205
+ height: actor.height,
1206
+ };
1207
+
1208
+ const cells = this.getCells(bounds);
1209
+
1210
+ // Store new cells in actor
1211
+ actor.currentGridCells = cells;
1212
+
1213
+ // Add to new cells
1214
+ for (const cell of cells) {
1215
+ if (!this.grid.has(cell)) {
1216
+ this.grid.set(cell, { solids: new Set(), actors: new Set() });
1217
+ }
1218
+
1219
+ const gridCell = this.grid.get(cell)!;
1220
+ gridCell.actors.add(actor);
1221
+ }
1222
+ }
1223
+
1224
+ /**
1225
+ * Removes an actor from its current grid cells.
1226
+ *
1227
+ * @param actor - The actor to remove from the grid
1228
+ */
1229
+ public removeActorFromGrid(actor: Actor): void {
1230
+ const currentCells = actor.currentGridCells;
1231
+
1232
+ for (const cell of currentCells) {
1233
+ const gridCell = this.grid.get(cell);
1234
+
1235
+ if (gridCell) {
1236
+ gridCell.actors.delete(actor);
1237
+
1238
+ // Remove the cell if it's empty
1239
+ if (gridCell.actors.size === 0 && gridCell.solids.size === 0) {
1240
+ this.grid.delete(cell);
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ // Clear the actor's cells
1246
+ actor.currentGridCells = [];
1247
+ }
1248
+
1249
+ /**
1250
+ * Checks for collisions between all active actors.
1251
+ * This is an O(n²) operation, so it can be expensive with many actors.
1252
+ */
1253
+ private checkActorCollisions(): void {
1254
+ // Skip if there are no actors or only one actor
1255
+ if (this.actors.size <= 1 || !this.options.enableActorCollisions) return;
1256
+
1257
+ // Clear the checked pairs set and potential collisions
1258
+ this._checkedPairs.clear();
1259
+ this._potentialCollisions.clear();
1260
+ this._activeGridCells.clear();
1261
+
1262
+ // First pass: Mark cells with moving actors as active
1263
+ for (const [cellKey, gridCell] of this.grid.entries()) {
1264
+ for (const actor of gridCell.actors) {
1265
+ if (!actor.active) continue;
1266
+
1267
+ // If actor is moving or recently moved
1268
+ if (actor.velocity.x !== 0 || actor.velocity.y !== 0) {
1269
+ this._activeGridCells.add(cellKey);
1270
+ break;
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ // Second pass: Generate potential collision pairs from active cells
1276
+ for (const cellKey of this._activeGridCells) {
1277
+ const gridCell = this.grid.get(cellKey);
1278
+ if (!gridCell || gridCell.actors.size <= 1) continue;
1279
+
1280
+ const actorsInCell = Array.from(gridCell.actors);
1281
+ const cellActorCount = actorsInCell.length;
1282
+
1283
+ // Check each actor against others in the same cell
1284
+ for (let i = 0; i < cellActorCount; i++) {
1285
+ const actor1 = actorsInCell[i];
1286
+ if (!actor1.active) continue;
1287
+
1288
+ for (let j = i + 1; j < cellActorCount; j++) {
1289
+ const actor2 = actorsInCell[j];
1290
+ if (!actor2.active) continue;
1291
+
1292
+ // Create a unique key for this actor pair
1293
+ const pairKey = actor1.id < actor2.id ? `${actor1.id}|${actor2.id}` : `${actor2.id}|${actor1.id}`;
1294
+
1295
+ // Skip if we've already checked this pair
1296
+ if (this._checkedPairs.has(pairKey)) continue;
1297
+ this._checkedPairs.add(pairKey);
1298
+
1299
+ // Fast collision layer check
1300
+ if (
1301
+ (actor1.collisionLayer & actor2.collisionMask) === 0 &&
1302
+ (actor2.collisionLayer & actor1.collisionMask) === 0
1303
+ )
1304
+ continue;
1305
+
1306
+ // Fast AABB check
1307
+ if (
1308
+ actor1.x < actor2.x + actor2.width &&
1309
+ actor1.x + actor1.width > actor2.x &&
1310
+ actor1.y < actor2.y + actor2.height &&
1311
+ actor1.y + actor1.height > actor2.y
1312
+ ) {
1313
+ // Store potential collision pair
1314
+ this._potentialCollisions.set(pairKey, [actor1, actor2]);
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ // Final pass: Detailed collision checks for potential pairs
1321
+ for (const [actor1, actor2] of this._potentialCollisions.values()) {
1322
+ // Check for collision
1323
+ const collisionResult = actor1.checkActorCollision(actor2);
1324
+
1325
+ if (collisionResult.collided) {
1326
+ // Add to actor1's collision list
1327
+ actor1.actorCollisions.push(collisionResult);
1328
+
1329
+ // Get a mirrored result from the pool
1330
+ const mirroredResult = this.getCollisionResult();
1331
+ mirroredResult.collided = true;
1332
+ mirroredResult.actor = actor1;
1333
+
1334
+ if (collisionResult.normal) {
1335
+ if (!mirroredResult.normal) {
1336
+ mirroredResult.normal = { x: 0, y: 0 };
1337
+ }
1338
+ mirroredResult.normal.x = -collisionResult.normal.x;
1339
+ mirroredResult.normal.y = -collisionResult.normal.y;
1340
+ } else {
1341
+ mirroredResult.normal = undefined;
1342
+ }
1343
+
1344
+ mirroredResult.penetration = collisionResult.penetration;
1345
+
1346
+ // Add to actor2's collision list
1347
+ actor2.actorCollisions.push(mirroredResult);
1348
+
1349
+ // Get an actor collision from the pool and add to system's collision list
1350
+ const actorCollision = this.getActorCollision();
1351
+ actorCollision.type = `${actor1.type}|${actor2.type}`;
1352
+ actorCollision.actor1 = actor1;
1353
+ actorCollision.actor2 = actor2;
1354
+ actorCollision.result = collisionResult;
1355
+ this.actorCollisions[this._actorCollisionPoolIndex++] = actorCollision;
1356
+
1357
+ // Call collision handlers
1358
+ actor1.onActorCollide(collisionResult);
1359
+ actor2.onActorCollide(mirroredResult);
1360
+
1361
+ // Resolve the collision
1362
+ actor1.resolveActorCollision(collisionResult);
1363
+ actor2.resolveActorCollision(mirroredResult);
1364
+ }
1365
+ }
1366
+
1367
+ // Process collisions with resolver if provided
1368
+ if (this._actorCollisionPoolIndex > 0 && this.options.actorCollisionResolver) {
1369
+ this.options.actorCollisionResolver(this.actorCollisions.slice(0, this._actorCollisionPoolIndex));
1370
+ }
1371
+ }
1372
+
1373
+ setCollisionResolver(resolver: (collisions: Collision[]) => void): void {
1374
+ this.options.collisionResolver = resolver;
1375
+ }
1376
+
1377
+ /**
1378
+ * Sets a custom resolver for actor-to-actor collisions.
1379
+ *
1380
+ * @param resolver - Function to handle actor-to-actor collisions
1381
+ */
1382
+ public setActorCollisionResolver(resolver: (collisions: ActorCollision[]) => void): void {
1383
+ this.options.actorCollisionResolver = resolver;
1384
+ }
1385
+
1386
+ /**
1387
+ * Returns whether actor-to-actor collision detection is enabled.
1388
+ *
1389
+ * @returns True if actor collisions are enabled
1390
+ */
1391
+ public get enableActorCollisions(): boolean {
1392
+ return !!this.options.enableActorCollisions;
1393
+ }
1394
+
1395
+ /**
1396
+ * Enables or disables actor-to-actor collision detection.
1397
+ * When enabled, it initializes the grid for all existing actors.
1398
+ *
1399
+ * @param enabled - Whether to enable actor-to-actor collisions
1400
+ */
1401
+ public setActorCollisionsEnabled(enabled: boolean): void {
1402
+ const wasEnabled = this.options.enableActorCollisions;
1403
+ this.options.enableActorCollisions = enabled;
1404
+
1405
+ // If newly enabled, add all actors to the grid
1406
+ if (enabled && !wasEnabled) {
1407
+ for (const actor of this.actors) {
1408
+ actor.updateGridCells();
1409
+ }
1410
+ } else if (!enabled && wasEnabled) {
1411
+ // If newly disabled, clear the actor grid
1412
+ this.grid.clear();
1413
+
1414
+ // Clear all actors' grid cells
1415
+ for (const actor of this.actors) {
1416
+ actor.currentGridCells = [];
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ public createGroup(config: PhysicsEntityConfig): Group {
1422
+ // Apply default collision settings if not specified in config
1423
+ if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) {
1424
+ config.collisionLayer = this.options.defaultCollisionLayer;
1425
+ }
1426
+ if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) {
1427
+ config.collisionMask = this.options.defaultCollisionMask;
1428
+ }
1429
+
1430
+ const group = config.class ? (new config.class(config) as unknown as Group) : new Group(config);
1431
+ return this.addGroup(group);
1432
+ }
1433
+
1434
+ public addGroup(group: Group): Group {
1435
+ this.entities.add(group);
1436
+ this.groups.add(group);
1437
+
1438
+ // Add to type index
1439
+ if (!this.groupsByType.has(group.type)) {
1440
+ this.groupsByType.set(group.type, new Set());
1441
+ }
1442
+ this.groupsByType.get(group.type)!.add(group);
1443
+
1444
+ return group;
1445
+ }
1446
+
1447
+ public removeGroup(group: Group, destroyView: boolean = true): void {
1448
+ // Early return if group doesn't exist
1449
+ if (!this.groups.has(group)) return;
1450
+
1451
+ this.entities.delete(group);
1452
+ this.groups.delete(group);
1453
+
1454
+ // Remove from type index
1455
+ const typeSet = this.groupsByType.get(group.type);
1456
+ if (typeSet) {
1457
+ typeSet.delete(group);
1458
+ if (typeSet.size === 0) {
1459
+ this.groupsByType.delete(group.type);
1460
+ }
1461
+ }
1462
+
1463
+ // Get all entities in the group before removing them
1464
+ const groupEntities = this.groupWithEntities.get(group);
1465
+ if (groupEntities && groupEntities.size > 0) {
1466
+ // Create arrays for each entity type to batch process
1467
+ const actors: Actor[] = [];
1468
+ const solids: Solid[] = [];
1469
+ const sensors: Sensor[] = [];
1470
+
1471
+ // Sort entities by type for batch processing
1472
+ for (const entity of groupEntities) {
1473
+ if (entity.entityType === 'Actor') {
1474
+ actors.push(entity as Actor);
1475
+ } else if (entity.entityType === 'Solid') {
1476
+ solids.push(entity as Solid);
1477
+ } else if (entity.entityType === 'Sensor') {
1478
+ sensors.push(entity as Sensor);
1479
+ }
1480
+ }
1481
+
1482
+ // Batch remove entities by type
1483
+ for (const actor of actors) {
1484
+ this.removeActor(actor, destroyView);
1485
+ }
1486
+
1487
+ for (const solid of solids) {
1488
+ this.removeSolid(solid, destroyView);
1489
+ }
1490
+
1491
+ for (const sensor of sensors) {
1492
+ this.removeSensor(sensor, destroyView);
1493
+ }
1494
+ }
1495
+
1496
+ // Clean up the group's entry in the tracking map
1497
+ this.groupWithEntities.delete(group);
1498
+
1499
+ group.onRemoved();
1500
+ }
1501
+
1502
+ /**
1503
+ * Get all groups of a specific type
1504
+ * @param type The type to look for
1505
+ * @returns Array of groups matching the type
1506
+ */
1507
+ public getGroupsByType(type: string | string[]): Group[] {
1508
+ if (Array.isArray(type)) {
1509
+ // Pre-calculate total size to avoid array resizing
1510
+ let totalSize = 0;
1511
+ for (const t of type) {
1512
+ const set = this.groupsByType.get(t);
1513
+ if (set) totalSize += set.size;
1514
+ }
1515
+
1516
+ // Pre-allocate result array
1517
+ const result = new Array<Group>(totalSize);
1518
+ let index = 0;
1519
+
1520
+ // Fill array without using flatMap (more efficient)
1521
+ for (const t of type) {
1522
+ const set = this.groupsByType.get(t);
1523
+ if (set) {
1524
+ for (const group of set) {
1525
+ result[index++] = group;
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ return result;
1531
+ }
1532
+
1533
+ return Array.from(this.groupsByType.get(type) || new Set());
1534
+ }
1535
+
1536
+ /**
1537
+ * Checks if two AABB rectangles overlap.
1538
+ *
1539
+ * @param a - The first AABB rectangle
1540
+ * @param b - The second AABB rectangle
1541
+ * @returns True if the rectangles overlap, false otherwise
1542
+ */
1543
+ public aabbOverlap(a: AABBLike, b: AABBLike): boolean {
1544
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1545
+ }
1546
+ }