@dill-pixel/plugin-crunch-physics
Version:
Crunch Physics
428 lines (379 loc) • 11.5 kB
text/typescript
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);
}
}