UNPKG

@dill-pixel/plugin-crunch-physics

Version:

Crunch Physics

1,547 lines (1,314 loc) 46.9 kB
import { Container, Graphics } from 'pixi.js'; import { Actor } from './Actor'; import CrunchPhysicsPlugin from './CrunchPhysicsPlugin'; import { Entity } from './Entity'; import { Group } from './Group'; import { AABBLike } from './interfaces'; import { Sensor } from './Sensor'; import { Solid } from './Solid'; import { ActorCollision, ActorCollisionResult, Collision, PhysicsEntityConfig, PhysicsEntityView, Rectangle, SensorOverlap, } from './types'; /** * Configuration options for the Crunch physics system. * These options control the core behavior of the physics simulation. * * @example * ```typescript * const options: PhysicsSystemOptions = { * gridSize: 32, * gravity: 980, * maxVelocity: 1000, * debug: true, * boundary: { x: 0, y: 0, width: 800, height: 600 }, * culling: true, * collisionResolver: (collisions) => { * for (const collision of collisions) { * handleCollision(collision); * } * } * }; * ``` */ export interface PhysicsSystemOptions { /** Reference to the parent Crunch physics plugin */ plugin: CrunchPhysicsPlugin; /** Size of each grid cell for spatial partitioning (in pixels) */ gridSize: number; /** Gravity strength in pixels per second squared */ gravity: number; /** Maximum velocity for any entity in pixels per second */ maxVelocity: number; /** Whether to render debug visualizations */ debug?: boolean; /** World boundary for culling entities */ boundary?: Rectangle; /** Whether to automatically cull out-of-bounds entities */ culling?: boolean; /** Custom handler for resolving collisions */ collisionResolver?: (collisions: Collision[]) => void; /** Custom handler for resolving sensor overlaps */ overlapResolver?: (overlaps: SensorOverlap[]) => void; /** Custom handler for resolving actor-to-actor collisions */ actorCollisionResolver?: (collisions: ActorCollision[]) => void; /** Whether to enable actor-to-actor collisions */ enableActorCollisions?: boolean; /** Default collision layer for new entities */ defaultCollisionLayer?: number; /** Default collision mask for new entities */ defaultCollisionMask?: number; } /** * Represents a cell in the spatial partitioning grid that can contain both solids and actors */ interface GridCell { solids: Set<Solid>; actors: Set<Actor>; } /** * Core physics system that manages all physics entities and their interactions. * Handles spatial partitioning, collision detection, and entity lifecycle. * * Features: * - Grid-based spatial partitioning for efficient collision checks * - Entity management (actors, solids, sensors, groups) * - Collision and overlap detection * - Debug visualization * - Culling system for out-of-bounds entities * * @example * ```typescript * // Create the physics system * const system = new System({ * gridSize: 32, * gravity: 980, * maxVelocity: 1000 * }); * * // Add entities * const player = system.createActor({ * type: 'Player', * position: [100, 100] * }); * * const platform = system.createSolid({ * type: 'Platform', * position: [0, 500], * size: [800, 32] * }); * * // Update physics (in game loop) * system.update(deltaTime); * ``` */ export class System { private readonly options: PhysicsSystemOptions; // Public collections public entities: Set<Entity> = new Set(); private _flaggedEntities: Set<Entity> = new Set(); // Type-based lookup maps public actors: Set<Actor> = new Set(); public solids: Set<Solid> = new Set(); public sensors: Set<Sensor> = new Set(); public groups: Set<Group> = new Set(); // Type-based lookup maps private actorsByType: Map<string, Set<Actor>> = new Map(); private solidsByType: Map<string, Set<Solid>> = new Map(); private sensorsByType: Map<string, Set<Sensor>> = new Map(); private groupsByType: Map<string, Set<Group>> = new Map(); // Followers tracking private followers: Map<Entity, Set<Entity>> = new Map(); // groups tracking private groupWithEntities: Map<Group, Set<Entity>> = new Map(); // Single grid for both solids and actors private grid: Map<string, GridCell> = new Map(); // Collision tracking private collisions: Collision[] = []; private sensorOverlaps: SensorOverlap[] = []; private actorCollisions: ActorCollision[] = []; // Reusable data structures for actor collision detection private _checkedPairs: Set<string> = new Set(); // Object pools for collision results to reduce GC pressure private _collisionResultPool: ActorCollisionResult[] = []; private _collisionResultPoolIndex: number = 0; private _actorCollisionPool: ActorCollision[] = []; private _actorCollisionPoolIndex: number = 0; // Debugging private _debugContainer: Container; private _debugGfx: Graphics | null = null; private _debug: boolean = false; private _movedActors: Set<Actor> = new Set(); private _activeGridCells: Set<string> = new Set(); private _potentialCollisions: Map<string, [Actor, Actor]> = new Map(); set debug(value: boolean) { this._debug = value; if (this._debug) { if (!this._debugContainer) { this._debugContainer = this.options.plugin.container.addChild(new Container()); } if (!this._debugGfx) { this._debugGfx = new Graphics(); } this._debugContainer.addChild(this._debugGfx); } else { this._debugGfx?.clear(); this._debugContainer?.removeChildren(); } } set gridSize(value: number) { this.options.gridSize = value; this.grid.clear(); for (const solid of this.solids) { this.addSolidToGrid(solid); } } set gravity(value: number) { this.options.gravity = value; } get gravity(): number { return this.options.gravity; } set maxVelocity(value: number) { this.options.maxVelocity = value; } get maxVelocity(): number { return this.options.maxVelocity; } set boundary(value: Rectangle) { this.options.boundary = value; } get boundary(): Rectangle { return this.options.boundary!; } get container(): Container { return this.options.plugin.container; } public addView(view: PhysicsEntityView): void { this.container.addChild(view); } constructor(options: PhysicsSystemOptions) { this.options = { ...options, culling: options.culling ?? false, }; this.debug = options.debug ?? false; this._debugContainer = new Container(); options.plugin.container.addChild(this._debugContainer); } private _resetPositionFlags(entity: Entity): void { entity.updatedFollowPosition = false; entity.updatedGroupPosition = false; } public update(dt: number): void { if (!this.options.plugin.enabled) return; // Clear collections at the start with pre-allocated capacity this.collisions.length = 0; this.sensorOverlaps.length = 0; this.actorCollisions.length = 0; // Reset object pools this.resetCollisionPools(); // Convert delta time to seconds (cache this calculation) const deltaTime = dt / 60; // Update containers first (groups) for (const group of this.groups) { group.update(deltaTime); } // Update solids with optimized grid management this.updateSolids(deltaTime); // Update sensors (before actors so they can detect entry/exit in the same frame) for (const sensor of this.sensors) { this.updateSensor(sensor, deltaTime); } // Clear actor collision lists before updating actors for (const actor of this.actors) { if (actor.actorCollisions.length > 0) { actor.actorCollisions.length = 0; } } // Update actors with potential batching this.updateActors(deltaTime); // Process position updates for followers and group entities this.updateEntityPositions(); // Process overlaps, collisions, and culling in batch operations this.processCollisionsAndOverlaps(); // Cull out-of-bounds entities if enabled and boundary is set if (this.options.culling && this.options.boundary) { this.cullOutOfBounds(); } // Update debug rendering if enabled if (this._debug) { this.debugRender(); } } // New optimized methods to break up the update loop private updateSolids(deltaTime: number): void { // Fast path if no solids are moving let hasMovingSolids = false; for (const solid of this.solids) { solid.preUpdate(); solid.update(deltaTime); solid.postUpdate(); if (solid.moving) { hasMovingSolids = true; } } // Skip grid updates if nothing is moving if (!hasMovingSolids) return; // Batch grid updates for moving solids const movedSolids = new Set<Solid>(); for (const solid of this.solids) { if (solid.moving) { // Remove from old grid cells this.removeSolidFromGrid(solid); // Move the solid (which will handle pushing/carrying actors and sensors) solid.move(0, 0, this.actors, this.sensors, true); // Track for batch grid update movedSolids.add(solid); } } // Batch add to grid after all movements are complete for (const solid of movedSolids) { this.addSolidToGrid(solid); } } private updateActors(deltaTime: number): void { // Skip if no active actors if (this.actors.size === 0) return; // Use for...of instead of forEach for better performance for (const actor of this.actors) { if (actor.active) { actor.preUpdate(); actor.update(deltaTime); actor.postUpdate(); // Collect collisions in batch const actorCollisions = actor.collisions; if (actorCollisions.length > 0) { for (const result of actorCollisions) { this.collisions.push({ type: `${actor.type}|${result.solid!.type}`, entity1: actor, entity2: result.solid!, result: { collided: result.collided, normal: result.normal, penetration: result.penetration, solid: result.solid, }, }); } } } } } private updateEntityPositions(): void { this._flaggedEntities.clear(); // Process followers first (most dependent) for (const [entity, followers] of this.followers) { const followX = entity.x; const followY = entity.y; for (const follower of followers) { if (!follower.updatedFollowPosition) { // Cache group checks to avoid repeated property access const hasGroup = follower.group !== null; const sameGroup = hasGroup && follower.group === entity.group; if (sameGroup) { // Get the entity's position relative to its group using the offset const entityGroupOffset = entity.groupOffset; const groupX = follower.group!.x; const groupY = follower.group!.y; // Set position using combined offsets relative to group follower.setPosition( groupX + entityGroupOffset.x + follower.followOffset.x, groupY + entityGroupOffset.y + follower.followOffset.y, ); } else if (hasGroup) { // Different groups or followed entity not in a group follower.setPosition( followX + follower.followOffset.x + follower.group!.x + follower.groupOffset.x, followY + follower.followOffset.y + follower.group!.y + follower.groupOffset.y, ); } else { // No group, just following follower.setPosition(followX + follower.followOffset.x, followY + follower.followOffset.y); } follower.updatedFollowPosition = true; this._flaggedEntities.add(follower); if (hasGroup) { follower.updatedGroupPosition = true; } } } } // Process remaining group entities for (const [group, groupEntities] of this.groupWithEntities) { const groupX = group.x; const groupY = group.y; for (const entity of groupEntities) { if (!entity.updatedGroupPosition && !entity.updatedFollowPosition) { entity.setPosition(groupX + entity.groupOffset.x, groupY + entity.groupOffset.y); entity.updatedGroupPosition = true; this._flaggedEntities.add(entity); } } } // Reset flags in batch this._flaggedEntities.forEach(this._resetPositionFlags); } private processCollisionsAndOverlaps(): void { // Process overlaps if resolver is set and we have overlaps if (this.options.overlapResolver && this.sensorOverlaps.length > 0) { this.options.overlapResolver(this.sensorOverlaps); } // Check actor-to-actor collisions if enabled and we have multiple actors if (this.options.enableActorCollisions && this.actors.size > 1) { this.checkActorCollisions(); } // Process collisions if resolver is set and we have collisions if (this.collisions.length > 0 && this.options.collisionResolver) { this.options.collisionResolver(this.collisions); } // Process actor-to-actor collisions if resolver is set and we have collisions if (this.actorCollisions.length > 0 && this.options.actorCollisionResolver) { this.options.actorCollisionResolver(this.actorCollisions); } } // Optimize the cullOutOfBounds method private cullOutOfBounds(): void { // Skip if no boundary if (!this.options.boundary) return; const boundary = this.options.boundary; // Pre-allocate removal arrays with reasonable capacity const toRemoveActors: Actor[] = []; const toRemoveSolids: Solid[] = []; const toRemoveSensors: Sensor[] = []; const toRemoveGroups: Group[] = []; // Cache boundary values to avoid repeated property access const boundX = boundary.x; const boundY = boundary.y; const boundRight = boundX + boundary.width; const boundBottom = boundY + boundary.height; // Check actors with optimized bounds check for (const actor of this.actors) { const actorRight = actor.x + actor.width; const actorBottom = actor.y + actor.height; const inBounds = !( actor.x >= boundRight || // Completely to the right actorRight <= boundX || // Completely to the left actor.y >= boundBottom || // Completely below actorBottom <= boundY // Completely above ); if (!inBounds) { if (!actor.isCulled) { actor.onCull(); } if (actor.shouldRemoveOnCull) { toRemoveActors.push(actor); } } else if (actor.isCulled) { // Uncull if back in bounds actor.onUncull(); } } // Check solids with the same optimized bounds check for (const solid of this.solids) { const solidRight = solid.x + solid.width; const solidBottom = solid.y + solid.height; const inBounds = !( solid.x >= boundRight || solidRight <= boundX || solid.y >= boundBottom || solidBottom <= boundY ); if (!inBounds) { if (!solid.isCulled) { solid.onCull(); } if (solid.shouldRemoveOnCull) { toRemoveSolids.push(solid); } } else if (solid.isCulled) { solid.onUncull(); } } // Check sensors with the same optimized bounds check for (const sensor of this.sensors) { const sensorRight = sensor.x + sensor.width; const sensorBottom = sensor.y + sensor.height; const inBounds = !( sensor.x >= boundRight || sensorRight <= boundX || sensor.y >= boundBottom || sensorBottom <= boundY ); if (!inBounds) { if (!sensor.isCulled) { sensor.onCull(); } if (sensor.shouldRemoveOnCull) { toRemoveSensors.push(sensor); } } else if (sensor.isCulled) { sensor.onUncull(); } } // Check groups with the same optimized bounds check for (const group of this.groups) { const groupRight = group.x + group.width; const groupBottom = group.y + group.height; const inBounds = !( group.x >= boundRight || groupRight <= boundX || group.y >= boundBottom || groupBottom <= boundY ); if (!inBounds) { if (!group.isCulled) { group.onCull(); } if (group.shouldRemoveOnCull) { toRemoveGroups.push(group); } } else if (group.isCulled) { group.onUncull(); } } // Batch remove culled entities if any exist const actorCount = toRemoveActors.length; const solidCount = toRemoveSolids.length; const sensorCount = toRemoveSensors.length; const groupCount = toRemoveGroups.length; if (actorCount + solidCount + sensorCount + groupCount === 0) return; // Use for loops instead of forEach for better performance for (let i = 0; i < actorCount; i++) { this.removeActor(toRemoveActors[i]); } for (let i = 0; i < solidCount; i++) { this.removeSolid(toRemoveSolids[i]); } for (let i = 0; i < sensorCount; i++) { this.removeSensor(toRemoveSensors[i]); } for (let i = 0; i < groupCount; i++) { this.removeGroup(toRemoveGroups[i]); } } private updateSensor(sensor: Sensor, dt: number): void { sensor.preUpdate(); sensor.update(dt); sensor.postUpdate(); const overlaps = sensor.checkActorOverlaps(); this.sensorOverlaps.push(...overlaps); } public createEntity(config: PhysicsEntityConfig): Actor | Solid | Sensor | Group { if (config.type === 'actor') { return this.createActor(config); } else if (config.type === 'solid') { return this.createSolid(config); } else if (config.type === 'sensor') { return this.createSensor(config); } else if (config.type === 'group') { return this.createGroup(config); } throw new Error(`Invalid entity type: ${config.type}`); } public addEntity(entity: Entity | Actor | Solid | Sensor | Group): Actor | Solid | Sensor | Group { if (!entity || !entity.entityType) { throw new Error('Entity is required'); } if (entity.entityType === 'Actor') { return this.addActor(entity as Actor); } else if (entity.entityType === 'Solid') { return this.addSolid(entity as Solid); } else if (entity.entityType === 'Sensor') { return this.addSensor(entity as Sensor); } else if (entity.entityType === 'Group') { return this.addGroup(entity as Group); } throw new Error(`Invalid entity type: ${entity!.entityType}`); } public removeEntity(entity: Entity | Actor | Solid | Sensor | Group): void { if (entity.entityType === 'Actor') { this.removeActor(entity as Actor); } else if (entity.entityType === 'Solid') { this.removeSolid(entity as Solid); } else if (entity.entityType === 'Sensor') { this.removeSensor(entity as Sensor); } else if (entity.entityType === 'Group') { this.removeGroup(entity as Group); } } public createActor(config: PhysicsEntityConfig): Actor { // Apply default collision settings if not specified in config if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) { config.collisionLayer = this.options.defaultCollisionLayer; } if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) { config.collisionMask = this.options.defaultCollisionMask; } const actor = config.class ? (new config.class(config) as Actor) : new Actor(config); return this.addActor(actor); } public addActor(actor: Actor): Actor { this.entities.add(actor); this.actors.add(actor); // Add to type index if (!this.actorsByType.has(actor.type)) { this.actorsByType.set(actor.type, new Set()); } this.actorsByType.get(actor.type)!.add(actor); // Add to actor grid if actor collisions are enabled if (this.options.enableActorCollisions) { actor.updateGridCells(); } return actor; } public createSensor(config: PhysicsEntityConfig): Sensor { // Apply default collision settings if not specified in config if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) { config.collisionLayer = this.options.defaultCollisionLayer; } if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) { config.collisionMask = this.options.defaultCollisionMask; } const sensor = config.class ? (new config.class(config) as Sensor) : new Sensor(config); return this.addSensor(sensor); } public addSensor(sensor: Sensor): Sensor { this.entities.add(sensor); this.sensors.add(sensor); // Add to type index if (!this.sensorsByType.has(sensor.type)) { this.sensorsByType.set(sensor.type, new Set()); } this.sensorsByType.get(sensor.type)!.add(sensor); return sensor; } public createSolid(config: PhysicsEntityConfig): Solid { // Apply default collision settings if not specified in config if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) { config.collisionLayer = this.options.defaultCollisionLayer; } if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) { config.collisionMask = this.options.defaultCollisionMask; } const solid = config.class ? (new config.class(config) as Solid) : new Solid(config); return this.addSolid(solid); } public addSolid(solid: Solid): Solid { this.entities.add(solid); this.solids.add(solid); // Add to type index if (!this.solidsByType.has(solid.type)) { this.solidsByType.set(solid.type, new Set()); } this.solidsByType.get(solid.type)!.add(solid); // Add to spatial grid this.addSolidToGrid(solid); return solid; } public removeActor(actor: Actor, destroyView: boolean = true): void { this.entities.delete(actor); this.actors.delete(actor); // Remove from type index const typeSet = this.actorsByType.get(actor.type); if (typeSet) { typeSet.delete(actor); if (typeSet.size === 0) { this.actorsByType.delete(actor.type); } } actor.onRemoved(); if (destroyView) { actor.view?.removeFromParent(); } // Remove from actor grid if (this.options.enableActorCollisions) { this.removeActorFromGrid(actor); } } public removeSolid(solid: Solid, destroyView: boolean = true): void { this.entities.delete(solid); this.solids.delete(solid); // Remove from type index const typeSet = this.solidsByType.get(solid.type); if (typeSet) { typeSet.delete(solid); if (typeSet.size === 0) { this.solidsByType.delete(solid.type); } } this.removeSolidFromGrid(solid); if (destroyView) { solid.view?.removeFromParent(); } solid.onRemoved(); } public removeSensor(sensor: Sensor, destroyView: boolean = true): void { this.entities.delete(sensor); this.sensors.delete(sensor); // Remove from type index const typeSet = this.sensorsByType.get(sensor.type); if (typeSet) { typeSet.delete(sensor); if (typeSet.size === 0) { this.sensorsByType.delete(sensor.type); } } sensor.onRemoved(); if (destroyView) { sensor.view?.removeFromParent(); } } public moveSolid(solid: Solid, x: number, y: number): void { // Remove from old grid cells this.removeSolidFromGrid(solid); // Move the solid (which will handle pushing/carrying actors) solid.move(x, y, this.actors, this.sensors); // Add to new grid cells this.addSolidToGrid(solid); solid.updateView(); } public getSolidsAt(x: number, y: number, entity: Actor | Sensor): Solid[] { // Early return if entity has no collision mask if (entity.collisionMask === 0) return []; const bounds = { x, y, width: entity.width, height: entity.height, }; // Calculate movement direction from current position to check position const dx = x - entity.x; const dy = y - entity.y; // Get the base cells that the bounds intersect const cells = this.getCells(bounds); // Add one extra cell in the direction of movement if (dx !== 0) { const extraX = dx > 0 ? Math.ceil((x + bounds.width) / this.options.gridSize) : Math.floor(x / this.options.gridSize) - 1; for ( let y = Math.floor(bounds.y / this.options.gridSize); y < Math.ceil((bounds.y + bounds.height) / this.options.gridSize); y++ ) { cells.push(`${extraX},${y}`); } } if (dy !== 0) { const extraY = dy > 0 ? Math.ceil((y + bounds.height) / this.options.gridSize) : Math.floor(y / this.options.gridSize) - 1; for ( let x = Math.floor(bounds.x / this.options.gridSize); x < Math.ceil((bounds.x + bounds.width) / this.options.gridSize); x++ ) { cells.push(`${x},${extraY}`); } } // Create a Set of excluded types for O(1) lookups // Pre-allocate result array with a reasonable size to avoid resizing const result: Solid[] = []; const seen = new Set<Solid>(); // Cache entity collision layer and mask for faster access const entityLayer = entity.collisionLayer; const entityMask = entity.collisionMask; for (const cell of cells) { const gridCell = this.grid.get(cell); if (gridCell) { for (const solid of gridCell.solids) { // Skip already seen solids if (seen.has(solid)) continue; // Fast collision layer check if ((entityLayer & solid.collisionMask) === 0 || (solid.collisionLayer & entityMask) === 0) continue; seen.add(solid); // Only add solids that actually overlap with the entity's bounds if ( this.overlaps(bounds, { x: solid.x, y: solid.y, width: solid.width, height: solid.height, }) ) { result.push(solid); } } } } return result; } public addFollower(entity: Entity, follower: Entity): void { // Logger.log('adding follower', entity.type, follower.type); if (!this.followers.has(entity)) { this.followers.set(entity, new Set()); } this.followers.get(entity)!.add(follower); } public removeFollower(entity: Entity): void { // remove the entity from any set in the followers map for (const followers of this.followers.values()) { followers.delete(entity); } } public getFollowersOf(entity: Entity): Entity[] { return Array.from(this.followers.get(entity) || []); } public removeFollowersOf(entity: Entity): void { const set = this.followers.get(entity); if (set) { set.forEach((follower) => { follower.destroy(); }); set.clear(); this.followers.delete(entity); } } public addToGroup(group: Group, entity: Entity): Entity { if (!this.groupWithEntities.has(group)) { this.groupWithEntities.set(group, new Set()); } this.groupWithEntities.get(group)!.add(entity); return entity; } public removeFromGroup(entity: Entity): Entity { for (const group of this.groupWithEntities.values()) { group.delete(entity); } return entity; } public getEntitiesInGroup(group: Group): Entity[] { return Array.from(this.groupWithEntities.get(group) || []); } public removeEntitiesOfGroup(group: Group): Entity[] { const entities = Array.from(this.groupWithEntities.get(group) || []) || []; entities.forEach((entity) => { entity.setGroup(null); }); this.groupWithEntities.delete(group); return entities; } private overlaps(a: Rectangle, b: Rectangle): boolean { 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; } private getCells(bounds: Rectangle): string[] { const cells: string[] = []; const { gridSize } = this.options; // Calculate the exact grid cells that the bounds intersect with const startX = Math.floor(bounds.x / gridSize); const startY = Math.floor(bounds.y / gridSize); const endX = Math.ceil((bounds.x + bounds.width) / gridSize); const endY = Math.ceil((bounds.y + bounds.height) / gridSize); // Pre-allocate array with exact size to avoid resizing cells.length = (endX - startX) * (endY - startY); // Use a single index counter to avoid array.push() operations let index = 0; // Only check one additional cell in the direction of movement for actors // For solids or static bounds, just use the exact cells for (let x = startX; x < endX; x++) { for (let y = startY; y < endY; y++) { // Use template string only once per iteration cells[index++] = `${x},${y}`; } } return cells; } private addSolidToGrid(solid: Solid): void { const bounds = { x: solid.x, y: solid.y, width: solid.width, height: solid.height, }; const cells = this.getCells(bounds); for (const cell of cells) { if (!this.grid.has(cell)) { this.grid.set(cell, { solids: new Set(), actors: new Set() }); } this.grid.get(cell)!.solids.add(solid); } } private removeSolidFromGrid(solid: Solid): void { const bounds = { x: solid.x, y: solid.y, width: solid.width, height: solid.height, }; const cells = this.getCells(bounds); for (const cell of cells) { const gridCell = this.grid.get(cell); if (gridCell) { gridCell.solids.delete(solid); if (gridCell.solids.size === 0 && gridCell.actors.size === 0) { this.grid.delete(cell); } } } } public debugRender(): void { if (!this._debugGfx) { return; } const gfx = this._debugGfx!; gfx.clear(); // Draw boundary if set if (this.options.boundary) { const b = this.options.boundary; gfx.rect(b.x, b.y, b.width, b.height); gfx.stroke({ color: 0xff0000, width: 2, alignment: 0.5 }); } // Draw grid for (const cell of this.grid.keys()) { const [x, y] = cell.split(',').map(Number); gfx.rect(x * this.options.gridSize, y * this.options.gridSize, this.options.gridSize, this.options.gridSize); } gfx.stroke({ color: 0x00ff00, width: 1, join: 'miter', cap: 'butt' }); // Draw solids for (const solid of this.solids) { gfx.rect(solid.x, solid.y, solid.width, solid.height); gfx.stroke({ color: solid.debugColor ?? 0x00ff00, alpha: 1 }); } // Draw actors for (const actor of this.actors) { gfx.rect(actor.x, actor.y, actor.width, actor.height); gfx.stroke({ color: actor.debugColor ?? 0xff0000, alpha: 1 }); } // Draw sensors for (const sensor of this.sensors) { gfx.rect(sensor.x, sensor.y, sensor.width, sensor.height); gfx.stroke({ color: sensor.debugColor ?? 0xffff00, alpha: 1 }); } } /** * Get all entities of a specific type * @param type The type to look for * @returns Array of entities matching the type */ public getByType(type: string): (Actor | Solid)[] { const actors = this.actorsByType.get(type) || new Set<Actor>(); const solids = this.solidsByType.get(type) || new Set<Solid>(); return [...actors, ...solids]; } /** * Get all actors of a specific type * @param type The type to look for * @returns Array of actors matching the type */ public getActorsByType(type: string | string[]): Actor[] { if (Array.isArray(type)) { return type.flatMap((t) => Array.from(this.actorsByType.get(t) || new Set())); } return Array.from(this.actorsByType.get(type) || new Set()); } /** * Get all solids of a specific type * @param type The type to look for * @returns Array of solids matching the type */ public getSolidsByType(type: string | string[]): Solid[] { if (Array.isArray(type)) { return type.flatMap((t) => Array.from(this.solidsByType.get(t) || new Set())); } return Array.from(this.solidsByType.get(type) || new Set()); } /** * Get all sensors of a specific type * @param type The type to look for * @returns Array of sensors matching the type */ public getSensorsByType(type: string | string[]): Sensor[] { if (Array.isArray(type)) { return type.flatMap((t) => Array.from(this.sensorsByType.get(t) || new Set())); } return Array.from(this.sensorsByType.get(type) || new Set()); } public clearGrid(): void { this.grid.clear(); } public clearAll(destroy: boolean = true) { this.grid.clear(); this.entities.clear(); if (destroy) { this.solids.forEach((solid) => { solid.destroy(); }); this.actors.forEach((actor) => { actor.destroy(); }); this.sensors.forEach((sensor) => sensor.destroy()); } this.solids.clear(); this.actors.clear(); this.sensors.clear(); this.solidsByType.clear(); this.actorsByType.clear(); this.sensorsByType.clear(); } public destroy(): void { this.debug = false; this.gravity = 0; this.maxVelocity = 0; this.clearAll(); } /** * Gets a collision result object from the pool or creates a new one if needed */ private getCollisionResult(): ActorCollisionResult { if (this._collisionResultPoolIndex < this._collisionResultPool.length) { const result = this._collisionResultPool[this._collisionResultPoolIndex++]; result.collided = false; result.actor = null as any; result.normal = undefined; result.penetration = 0; return result; } // Create a new object and add it to the pool const newResult: ActorCollisionResult = { collided: false, actor: null as any, normal: undefined, penetration: 0, }; this._collisionResultPool.push(newResult); this._collisionResultPoolIndex++; return newResult; } /** * Gets an actor collision object from the pool or creates a new one if needed */ private getActorCollision(): ActorCollision { if (this._actorCollisionPoolIndex < this._actorCollisionPool.length) { return this._actorCollisionPool[this._actorCollisionPoolIndex++]; } // Create a new object and add it to the pool const newCollision: ActorCollision = { type: 'unknown|unknown' as `${string}|${string}`, actor1: null as any, actor2: null as any, result: null as any, }; this._actorCollisionPool.push(newCollision); this._actorCollisionPoolIndex++; return newCollision; } /** * Resets the collision result pools for the next frame */ private resetCollisionPools(): void { this._collisionResultPoolIndex = 0; this._actorCollisionPoolIndex = 0; } /** * Updates an actor's position in the grid. * This is called when an actor moves or when its size changes. * * @param actor - The actor to update in the grid */ public updateActorInGrid(actor: Actor): void { // Skip if actor collisions are disabled if (!this.options.enableActorCollisions) return; // Skip if actor can't collide if (!actor.active || actor.collisionLayer === 0 || actor.collisionMask === 0) { // If actor has cells, remove it from them if (actor.currentGridCells.length > 0) { this.removeActorFromGrid(actor); } return; } // First remove from current cells this.removeActorFromGrid(actor); // Calculate new cells const bounds = { x: actor.x, y: actor.y, width: actor.width, height: actor.height, }; const cells = this.getCells(bounds); // Store new cells in actor actor.currentGridCells = cells; // Add to new cells for (const cell of cells) { if (!this.grid.has(cell)) { this.grid.set(cell, { solids: new Set(), actors: new Set() }); } const gridCell = this.grid.get(cell)!; gridCell.actors.add(actor); } } /** * Removes an actor from its current grid cells. * * @param actor - The actor to remove from the grid */ public removeActorFromGrid(actor: Actor): void { const currentCells = actor.currentGridCells; for (const cell of currentCells) { const gridCell = this.grid.get(cell); if (gridCell) { gridCell.actors.delete(actor); // Remove the cell if it's empty if (gridCell.actors.size === 0 && gridCell.solids.size === 0) { this.grid.delete(cell); } } } // Clear the actor's cells actor.currentGridCells = []; } /** * Checks for collisions between all active actors. * This is an O(n²) operation, so it can be expensive with many actors. */ private checkActorCollisions(): void { // Skip if there are no actors or only one actor if (this.actors.size <= 1 || !this.options.enableActorCollisions) return; // Clear the checked pairs set and potential collisions this._checkedPairs.clear(); this._potentialCollisions.clear(); this._activeGridCells.clear(); // First pass: Mark cells with moving actors as active for (const [cellKey, gridCell] of this.grid.entries()) { for (const actor of gridCell.actors) { if (!actor.active) continue; // If actor is moving or recently moved if (actor.velocity.x !== 0 || actor.velocity.y !== 0) { this._activeGridCells.add(cellKey); break; } } } // Second pass: Generate potential collision pairs from active cells for (const cellKey of this._activeGridCells) { const gridCell = this.grid.get(cellKey); if (!gridCell || gridCell.actors.size <= 1) continue; const actorsInCell = Array.from(gridCell.actors); const cellActorCount = actorsInCell.length; // Check each actor against others in the same cell for (let i = 0; i < cellActorCount; i++) { const actor1 = actorsInCell[i]; if (!actor1.active) continue; for (let j = i + 1; j < cellActorCount; j++) { const actor2 = actorsInCell[j]; if (!actor2.active) continue; // Create a unique key for this actor pair const pairKey = actor1.id < actor2.id ? `${actor1.id}|${actor2.id}` : `${actor2.id}|${actor1.id}`; // Skip if we've already checked this pair if (this._checkedPairs.has(pairKey)) continue; this._checkedPairs.add(pairKey); // Fast collision layer check if ( (actor1.collisionLayer & actor2.collisionMask) === 0 && (actor2.collisionLayer & actor1.collisionMask) === 0 ) continue; // Fast AABB check if ( actor1.x < actor2.x + actor2.width && actor1.x + actor1.width > actor2.x && actor1.y < actor2.y + actor2.height && actor1.y + actor1.height > actor2.y ) { // Store potential collision pair this._potentialCollisions.set(pairKey, [actor1, actor2]); } } } } // Final pass: Detailed collision checks for potential pairs for (const [actor1, actor2] of this._potentialCollisions.values()) { // Check for collision const collisionResult = actor1.checkActorCollision(actor2); if (collisionResult.collided) { // Add to actor1's collision list actor1.actorCollisions.push(collisionResult); // Get a mirrored result from the pool const mirroredResult = this.getCollisionResult(); mirroredResult.collided = true; mirroredResult.actor = actor1; if (collisionResult.normal) { if (!mirroredResult.normal) { mirroredResult.normal = { x: 0, y: 0 }; } mirroredResult.normal.x = -collisionResult.normal.x; mirroredResult.normal.y = -collisionResult.normal.y; } else { mirroredResult.normal = undefined; } mirroredResult.penetration = collisionResult.penetration; // Add to actor2's collision list actor2.actorCollisions.push(mirroredResult); // Get an actor collision from the pool and add to system's collision list const actorCollision = this.getActorCollision(); actorCollision.type = `${actor1.type}|${actor2.type}`; actorCollision.actor1 = actor1; actorCollision.actor2 = actor2; actorCollision.result = collisionResult; this.actorCollisions[this._actorCollisionPoolIndex++] = actorCollision; // Call collision handlers actor1.onActorCollide(collisionResult); actor2.onActorCollide(mirroredResult); // Resolve the collision actor1.resolveActorCollision(collisionResult); actor2.resolveActorCollision(mirroredResult); } } // Process collisions with resolver if provided if (this._actorCollisionPoolIndex > 0 && this.options.actorCollisionResolver) { this.options.actorCollisionResolver(this.actorCollisions.slice(0, this._actorCollisionPoolIndex)); } } setCollisionResolver(resolver: (collisions: Collision[]) => void): void { this.options.collisionResolver = resolver; } /** * Sets a custom resolver for actor-to-actor collisions. * * @param resolver - Function to handle actor-to-actor collisions */ public setActorCollisionResolver(resolver: (collisions: ActorCollision[]) => void): void { this.options.actorCollisionResolver = resolver; } /** * Returns whether actor-to-actor collision detection is enabled. * * @returns True if actor collisions are enabled */ public get enableActorCollisions(): boolean { return !!this.options.enableActorCollisions; } /** * Enables or disables actor-to-actor collision detection. * When enabled, it initializes the grid for all existing actors. * * @param enabled - Whether to enable actor-to-actor collisions */ public setActorCollisionsEnabled(enabled: boolean): void { const wasEnabled = this.options.enableActorCollisions; this.options.enableActorCollisions = enabled; // If newly enabled, add all actors to the grid if (enabled && !wasEnabled) { for (const actor of this.actors) { actor.updateGridCells(); } } else if (!enabled && wasEnabled) { // If newly disabled, clear the actor grid this.grid.clear(); // Clear all actors' grid cells for (const actor of this.actors) { actor.currentGridCells = []; } } } public createGroup(config: PhysicsEntityConfig): Group { // Apply default collision settings if not specified in config if (this.options.defaultCollisionLayer !== undefined && config.collisionLayer === undefined) { config.collisionLayer = this.options.defaultCollisionLayer; } if (this.options.defaultCollisionMask !== undefined && config.collisionMask === undefined) { config.collisionMask = this.options.defaultCollisionMask; } const group = config.class ? (new config.class(config) as unknown as Group) : new Group(config); return this.addGroup(group); } public addGroup(group: Group): Group { this.entities.add(group); this.groups.add(group); // Add to type index if (!this.groupsByType.has(group.type)) { this.groupsByType.set(group.type, new Set()); } this.groupsByType.get(group.type)!.add(group); return group; } public removeGroup(group: Group, destroyView: boolean = true): void { // Early return if group doesn't exist if (!this.groups.has(group)) return; this.entities.delete(group); this.groups.delete(group); // Remove from type index const typeSet = this.groupsByType.get(group.type); if (typeSet) { typeSet.delete(group); if (typeSet.size === 0) { this.groupsByType.delete(group.type); } } // Get all entities in the group before removing them const groupEntities = this.groupWithEntities.get(group); if (groupEntities && groupEntities.size > 0) { // Create arrays for each entity type to batch process const actors: Actor[] = []; const solids: Solid[] = []; const sensors: Sensor[] = []; // Sort entities by type for batch processing for (const entity of groupEntities) { if (entity.entityType === 'Actor') { actors.push(entity as Actor); } else if (entity.entityType === 'Solid') { solids.push(entity as Solid); } else if (entity.entityType === 'Sensor') { sensors.push(entity as Sensor); } } // Batch remove entities by type for (const actor of actors) { this.removeActor(actor, destroyView); } for (const solid of solids) { this.removeSolid(solid, destroyView); } for (const sensor of sensors) { this.removeSensor(sensor, destroyView); } } // Clean up the group's entry in the tracking map this.groupWithEntities.delete(group); group.onRemoved(); } /** * Get all groups of a specific type * @param type The type to look for * @returns Array of groups matching the type */ public getGroupsByType(type: string | string[]): Group[] { if (Array.isArray(type)) { // Pre-calculate total size to avoid array resizing let totalSize = 0; for (const t of type) { const set = this.groupsByType.get(t); if (set) totalSize += set.size; } // Pre-allocate result array const result = new Array<Group>(totalSize); let index = 0; // Fill array without using flatMap (more efficient) for (const t of type) { const set = this.groupsByType.get(t); if (set) { for (const group of set) { result[index++] = group; } } } return result; } return Array.from(this.groupsByType.get(type) || new Set()); } /** * Checks if two AABB rectangles overlap. * * @param a - The first AABB rectangle * @param b - The second AABB rectangle * @returns True if the rectangles overlap, false otherwise */ public aabbOverlap(a: AABBLike, b: AABBLike): boolean { 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; } }