UNPKG

@dill-pixel/plugin-crunch-physics

Version:

Crunch Physics

428 lines (379 loc) 11.5 kB
import { Actor } from './Actor'; import { Entity } from './Entity'; import { Solid } from './Solid'; import { EntityData, PhysicsEntityConfig, PhysicsEntityType, SensorOverlap, Vector2 } from './types'; /** * A trigger zone that can detect overlaps with actors. * Sensors are typically used for collectibles, triggers, and detection zones. * * Features: * - Overlap detection with specific actor types * - Optional gravity and movement * - Can be static or dynamic * - Callbacks for enter/exit events * * @typeParam T - Application type, defaults to base Application * * @example * ```typescript * // Create a coin pickup sensor * class Coin extends Sensor { * constructor() { * super({ * type: 'Coin', * position: [100, 100], * size: [32, 32], * view: coinSprite * }); * * // Only detect overlaps with player * this.collidableTypes = ['Player']; * } * * // Called when a player enters the coin * onActorEnter(actor: Actor) { * if (actor.type === 'Player') { * increaseScore(10); * this.physics.removeSensor(this); * } * } * } * * // Create a damage zone * class Spikes extends Sensor { * constructor() { * super({ * type: 'Spikes', * position: [300, 500], * size: [100, 32], * view: spikesSprite * }); * * this.collidableTypes = ['Player', 'Enemy']; * this.isStatic = true; // Don't move or fall * } * * onActorEnter(actor: Actor) { * if (actor.type === 'Player') { * actor.damage(10); * } * } * } * ``` */ export class Sensor<D extends EntityData = EntityData> extends Entity<D> { public readonly entityType: PhysicsEntityType = 'Sensor'; /** Whether this sensor should be removed when culled */ public shouldRemoveOnCull = false; /** List of actor types this sensor can detect */ public collidableTypes: string[] = []; /** Current velocity in pixels per second */ public velocity: Vector2 = { x: 0, y: 0 }; /** Whether this sensor should stay in place */ public isStatic: boolean = false; /** Set of actors currently overlapping this sensor */ private overlappingActors: Set<Actor> = new Set(); /** Cache for isRidingSolid check */ private _isRidingSolidCache: boolean | null = null; private _currentSensorOverlaps = new Set<SensorOverlap>(); private _currentOverlaps = new Set<Actor>(); public setPosition(x: number, y: number): void { if (this.isStatic) { this._x = x; this._y = y; this._xRemainder = 0; this._yRemainder = 0; this.updateView(); this.checkActorOverlaps(); } else { super.setPosition(x, y); } } set x(value: number) { super.x = value; if (this.isStatic) { this._xRemainder = 0; this.updateView(); this.checkActorOverlaps(); } } get x(): number { return super.x; } set y(value: number) { super.y = value; if (this.isStatic) { this._yRemainder = 0; this.updateView(); this.checkActorOverlaps(); } } get y(): number { return super.y; } /** * Initializes or reinitializes the sensor with new configuration. * * @param config - Configuration for the sensor */ public init(config: PhysicsEntityConfig<D>): void { super.init(config); if (!this.velocity) { this.velocity = { x: 0, y: 0 }; } this.velocity.x = 0; this.velocity.y = 0; if (!this.overlappingActors) { this.overlappingActors = new Set(); } this._isRidingSolidCache = null; } /** * Checks if this sensor is riding the given solid. * Takes into account gravity direction for proper riding detection. * * @param solid - The solid to check against * @returns True if riding the solid */ public isRiding(solid: Solid): boolean { const gravityDirection = Math.sign(this.system.gravity); if (gravityDirection > 0) { // Normal gravity - check if we're on top of the solid const sensorBottom = this.y + this.height; const onTop = Math.abs(sensorBottom - solid.y) <= 1; const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width; return onTop && overlap; } else { // Reversed gravity - check if we're on bottom of the solid const sensorTop = this.y; const onBottom = Math.abs(sensorTop - (solid.y + solid.height)) <= 1; const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width; return onBottom && overlap; } } /** * Checks if this sensor is riding any solid. * Uses caching to optimize multiple checks per frame. * * @returns True if riding any solid */ public isRidingSolid(): boolean { // Return cached value if available if (this._isRidingSolidCache !== null) { return this._isRidingSolidCache; } // Calculate and cache the result const solids = this.getSolidsAt(this.x, this.system.gravity > 0 ? this.y + 1 : this.y - 1); this._isRidingSolidCache = solids.some((solid) => this.isRiding(solid)); return this._isRidingSolidCache; } /** * Force moves the sensor to a new position, ignoring static state and collisions. * * @param x - New X position * @param y - New Y position */ public moveStatic(x: number, y: number): void { this._x = x; this._y = y; this._xRemainder = 0; this._yRemainder = 0; this.updateView(); this.checkActorOverlaps(); } /** * Moves the sensor horizontally, passing through solids. * * @param amount - Distance to move in pixels */ public moveX(amount: number): void { if (this.isStatic) { return; } this._xRemainder += amount; const move = Math.round(this._xRemainder); if (move !== 0) { this._xRemainder -= move; const sign = Math.sign(move); let remaining = Math.abs(move); while (remaining > 0) { const step = sign; const nextX = this.x + step; // Check for collision with any solid let collided = false; for (const solid of this.getSolidsAt(nextX, this.y)) { if (solid.canCollide) { collided = true; break; } } if (!collided) { this._x = nextX; remaining--; this.updateView(); this.checkActorOverlaps(); } else { // Stop horizontal movement when hitting a solid this.velocity.x = 0; break; } } } } /** * Moves the sensor vertically, colliding with solids for riding. * * @param amount - Distance to move in pixels */ public moveY(amount: number): void { if (this.isStatic) { return; } this._yRemainder += amount; const move = Math.round(this._yRemainder); if (move !== 0) { this._yRemainder -= move; const sign = Math.sign(move); let remaining = Math.abs(move); while (remaining > 0) { const step = sign; const nextY = this.y + step; // Check for collision with any solid let collided = false; // Only check collisions when moving in the direction of gravity if (Math.sign(this.system.gravity) === sign) { for (const solid of this.getSolidsAt(this.x, nextY)) { if (solid.canCollide) { collided = true; break; } } } if (!collided) { this._y = nextY; remaining--; this.updateView(); this.checkActorOverlaps(); } else { // Stop vertical movement when landing on a solid this.velocity.y = 0; break; } } } } /** * Updates the sensor's position and checks for overlapping actors. * * @param deltaTime - Delta time in seconds */ public update(deltaTime: number): void { // Reset the cache at the start of each update this._isRidingSolidCache = null; if (!this.active) { return; } // Only apply gravity if not static and not riding a solid if (!this.isStatic && !this.isRidingSolid()) { this.velocity.y += this.system.gravity * deltaTime; } // Move if (this.velocity.x !== 0) { this.moveX(this.velocity.x * deltaTime); } if (this.velocity.y !== 0) { this.moveY(this.velocity.y * deltaTime); } } /** * Checks for overlapping actors and triggers callbacks. * * @returns Set of current overlaps */ public checkActorOverlaps(): Set<SensorOverlap> { // Skip if sensor has no collision mask or is inactive if (this.collisionMask === 0 || !this.active) { return new Set(); } this._currentSensorOverlaps.clear(); this._currentOverlaps.clear(); // Get all actors at current position - use spatial filtering first // Only get actors that are potentially in the same grid cells // Cache collision layer and mask for faster access const sensorLayer = this.collisionLayer; const sensorMask = this.collisionMask; // Get actors by type, but only process those that could be nearby const nearbyActors = this.system.getActorsByType(this.collidableTypes); for (const actor of nearbyActors) { // Skip inactive actors if (!actor.active) continue; // Fast collision layer check if ((sensorLayer & actor.collisionMask) === 0 || (actor.collisionLayer & sensorMask) === 0) { continue; } // Fast AABB check before detailed overlap if (this.system.aabbOverlap(this, actor)) { this._currentOverlaps.add(actor); if (!this.overlappingActors.has(actor)) { // New overlap this._currentSensorOverlaps.add({ actor, sensor: this, type: `${actor.type}|${this.type}`, }); this.onActorEnter(actor); } } } // Check for actors that are no longer overlapping for (const actor of this.overlappingActors) { if (!this._currentOverlaps.has(actor)) { this.onActorExit(actor); } } // Swap the sets to avoid unnecessary clear and forEach operations const temp = this.overlappingActors; this.overlappingActors = this._currentOverlaps; this._currentOverlaps = temp; this._currentOverlaps.clear(); return this._currentSensorOverlaps; } public reset(): void { super.reset(); this._currentSensorOverlaps.clear(); this._currentOverlaps.clear(); this._isRidingSolidCache = null; this.velocity = { x: 0, y: 0 }; this.overlappingActors = new Set(); } /** * Called when an actor starts overlapping with this sensor. * Override this to handle overlap start events. * * @param actor - The actor that entered */ public onActorEnter<A extends Actor = Actor>(actor: A): void { // Override in subclass void actor; } /** * Called when an actor stops overlapping with this sensor. * Override this to handle overlap end events. * * @param actor - The actor that exited */ public onActorExit<A extends Actor = Actor>(actor: A): void { // Override in subclass void actor; } /** * Gets all solids at the specified position. * * @param x - X position to check * @param y - Y position to check * @returns Array of solids at the position */ protected getSolidsAt(x: number, y: number): Solid[] { return this.system.getSolidsAt(x, y, this); } }