@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/LICENSE +22 -0
- package/README.md +205 -0
- package/lib/Actor.d.ts +227 -0
- package/lib/Actor.d.ts.map +1 -0
- package/lib/CrunchPhysicsPlugin.d.ts +508 -0
- package/lib/CrunchPhysicsPlugin.d.ts.map +1 -0
- package/lib/Entity.d.ts +377 -0
- package/lib/Entity.d.ts.map +1 -0
- package/lib/Group.d.ts +122 -0
- package/lib/Group.d.ts.map +1 -0
- package/lib/Sensor.d.ts +161 -0
- package/lib/Sensor.d.ts.map +1 -0
- package/lib/Solid.d.ts +109 -0
- package/lib/Solid.d.ts.map +1 -0
- package/lib/System.d.ts +265 -0
- package/lib/System.d.ts.map +1 -0
- package/lib/caper-plugin-crunch.mjs +1331 -0
- package/lib/caper-plugin-crunch.mjs.map +1 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/interfaces.d.ts +32 -0
- package/lib/interfaces.d.ts.map +1 -0
- package/lib/types.d.ts +246 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/utils.d.ts +63 -0
- package/lib/utils.d.ts.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/package.json +42 -0
- package/src/Actor.ts +726 -0
- package/src/CrunchPhysicsPlugin.ts +718 -0
- package/src/Entity.ts +762 -0
- package/src/Group.ts +181 -0
- package/src/Sensor.ts +427 -0
- package/src/Solid.ts +380 -0
- package/src/System.ts +1546 -0
- package/src/collision-layers.d.ts +87 -0
- package/src/index.ts +18 -0
- package/src/interfaces.ts +33 -0
- package/src/types.ts +336 -0
- package/src/utils.ts +72 -0
- package/src/version.ts +1 -0
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
|
+
}
|