@dill-pixel/plugin-crunch-physics
Version:
Crunch Physics
728 lines (614 loc) • 20.5 kB
text/typescript
import { Application } from 'dill-pixel';
import { Entity } from './Entity';
import { Solid } from './Solid';
import {
ActorCollisionResult,
CollisionResult,
EntityData,
PhysicsEntityConfig,
PhysicsEntityType,
Vector2,
} from './types';
/**
* Dynamic physics entity that can move and collide with other entities.
* Actors are typically used for players, enemies, projectiles, and other moving game objects.
*
* Features:
* - Velocity-based movement with gravity
* - Collision detection and response
* - Solid surface detection (riding)
* - Automatic culling when out of bounds
* - Actor-to-actor collision detection
*
* @typeParam T - Application type, defaults to base Application
*
* @example
* ```typescript
* // Create a player actor
* class Player extends Actor {
* constructor() {
* super({
* type: 'Player',
* position: [100, 100],
* size: [32, 64],
* view: playerSprite
* });
* }
*
* // Handle collisions
* onCollide(result: CollisionResult) {
* if (result.solid.type === 'Spike') {
* this.die();
* }
* }
*
* // Handle actor-to-actor collisions
* onActorCollide(result: ActorCollisionResult) {
* if (result.actor.type === 'Enemy') {
* this.takeDamage(10);
* }
* }
*
* // Custom movement
* update(dt: number) {
* super.update(dt);
*
* // Move left/right
* if (this.app.input.isKeyDown('ArrowLeft')) {
* this.velocity.x = -200;
* } else if (this.app.input.isKeyDown('ArrowRight')) {
* this.velocity.x = 200;
* }
*
* // Jump when on ground
* if (this.app.input.isKeyPressed('Space') && this.isRidingSolid()) {
* this.velocity.y = -400;
* }
* }
* }
* ```
*/
export class Actor<T extends Application = Application, D extends EntityData = EntityData> extends Entity<T, D> {
public readonly entityType: PhysicsEntityType = 'Actor';
/** Current velocity in pixels per second */
public velocity: Vector2 = { x: 0, y: 0 };
/** Whether actor-to-actor collisions are disabled for this actor */
public disableActorCollisions: boolean = false;
/** Whether the actor should be removed when culled (out of bounds) */
public shouldRemoveOnCull: boolean = true;
/** List of current frame collisions */
public collisions: CollisionResult[] = [];
/** List of current frame actor-to-actor collisions */
public actorCollisions: ActorCollisionResult[] = [];
/** Cache for isRidingSolid check */
private _isRidingSolidCache: boolean | null = null;
/** Tracks which solid is currently carrying this actor in the current frame */
private _carriedBy: Solid | null = null;
private _carriedByOverlap: number = 0;
/** Tracks the grid cells this actor currently occupies */
private _currentGridCells: string[] = [];
/**
* Initialize or reinitialize the actor with new configuration.
*
* @param config - Configuration for the actor
*/
public init(config: PhysicsEntityConfig<D>): void {
super.init(config);
// Reset velocity and carried state
this.velocity = { x: 0, y: 0 };
this._isRidingSolidCache = null;
this._carriedBy = null;
this._carriedByOverlap = 0;
this.actorCollisions = [];
this._currentGridCells = [];
if (config.disableActorCollisions !== undefined) {
this.disableActorCollisions = config.disableActorCollisions;
}
// Add actor to grid initially if actor collisions are enabled
if (this.system.enableActorCollisions && !this.disableActorCollisions) {
this.updateGridCells();
}
}
/**
* Called at the start of each update to prepare for collision checks.
*/
public preUpdate(): void {
if (!this.active) return;
this.collisions = [];
this.actorCollisions = [];
// Reset the cache at the start of each update
this._isRidingSolidCache = null;
this._carriedBy = null;
this._carriedByOverlap = 0;
}
/**
* Updates the actor's position based on velocity and handles collisions.
*
* @param dt - Delta time in seconds
*/
public update(dt: number): void {
if (!this.active) return;
// Ensure velocity is valid
if (!this.isRidingSolid()) {
this.velocity.y += this.system.gravity * dt;
}
// Clamp velocity
this.velocity.x = Math.min(Math.max(this.velocity.x, -this.system.maxVelocity), this.system.maxVelocity);
this.velocity.y = Math.min(Math.max(this.velocity.y, -this.system.maxVelocity), this.system.maxVelocity);
// Move horizontally
if (this.velocity.x !== 0) {
this.moveX(this.velocity.x * dt);
}
// Move vertically
if (this.velocity.y !== 0) {
this.moveY(this.velocity.y * dt);
}
if (this.system.enableActorCollisions) {
this.updateGridCells();
}
// Update view
this.updateView();
}
/**
* Called after update to handle post-movement effects.
*/
public postUpdate(): void {
if (!this.active) return;
if (this.isRidingSolid()) {
this.velocity.y = 0;
}
}
/**
* Resets the actor to its initial state.
*/
public reset(): void {
super.reset();
this._isRidingSolidCache = null;
this._carriedBy = null;
this._carriedByOverlap = 0;
this.velocity = { x: 0, y: 0 };
this.updatePosition();
}
/**
* Called when the actor is culled (goes out of bounds).
* Override this to handle culling differently.
*/
public onCull(): void {
// Default behavior: destroy the view
this.view?.destroy();
}
/**
* Called when this actor collides with a solid.
* Override this method to implement custom collision response.
*
* @param result - Information about the collision
*/
public onCollide(result: CollisionResult): void {
// Default implementation does nothing
// Override this in your actor subclass to handle collisions
void result;
}
/**
* Called when this actor collides with another actor.
* Override this method to implement custom actor-to-actor collision response.
*
* @param result - Information about the actor collision
*/
public onActorCollide(result: ActorCollisionResult): void {
// Default implementation does nothing
// Override this in your actor subclass to handle actor-to-actor collisions
void result;
}
/**
* Checks if this actor is riding the given solid.
* An actor is riding if it's directly above the solid.
*
* @param solid - The solid to check against
* @returns True if riding the solid
*/
public isRiding(solid: Solid): boolean {
// Skip if solid has no collisions
if (!solid.collideable) return false;
// Check collision layers and masks
// An actor can only ride a solid if their collision layers/masks allow interaction
if ((this.collisionLayer & solid.collisionMask) === 0 || (solid.collisionLayer & this.collisionMask) === 0) {
return false;
}
// If we're already being carried by a different solid this frame,
// we can't be riding this one
if (this._carriedBy && this._carriedBy !== solid) {
return false;
}
// Must be directly above the solid (within 1 pixel)
const actorBottom = this.y + this.height;
const onTop = Math.abs(actorBottom - solid.y) <= 1;
// Must be horizontally overlapping
const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width;
const overlapWidth = Math.min(this.x + this.width, solid.x + solid.width) - Math.max(this.x, solid.x);
const isRiding = onTop && overlap;
if (isRiding && overlapWidth > this._carriedByOverlap) {
this._carriedBy = solid;
this._carriedByOverlap = overlapWidth;
}
return isRiding;
}
/**
* Checks if this actor is riding any solid in the physics system.
* 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.y + 1);
this._isRidingSolidCache = solids.some((solid) => this.isRiding(solid));
return this._isRidingSolidCache;
}
/**
* Called when the actor is squeezed between solids.
* Override this to handle squishing differently.
*/
public squish(result: CollisionResult): void {
void result;
// do something
}
/**
* Updates the actor's grid cells in the spatial partitioning system.
* This is called when the actor moves or when its size changes.
*/
public updateGridCells(): void {
// Skip if actor collisions are disabled system-wide or for this actor specifically
if (this.disableActorCollisions) return;
this.system.updateActorInGrid(this);
}
/**
* Gets the current grid cells this actor occupies
*/
public get currentGridCells(): string[] {
return this._currentGridCells;
}
/**
* Sets the current grid cells this actor occupies
*/
public set currentGridCells(cells: string[]) {
this._currentGridCells = cells;
}
/**
* Moves the actor horizontally, checking for collisions with solids.
*
* @param amount - Distance to move in pixels
* @param collisionHandler - Optional callback for handling collisions
* @returns Array of collision results
*/
public moveX(
amount: number,
collisionHandler?: (result: CollisionResult) => void,
pushingSolid?: Solid,
): CollisionResult[] {
// Early return if inactive or zero movement
if (!this.active || amount === 0) return [];
this._xRemainder += amount;
const move = Math.round(this._xRemainder);
// Early return if rounded movement is zero
if (move === 0) return [];
const collisions: CollisionResult[] = [];
// Cache collision layer and mask for faster access
const actorLayer = this.collisionLayer;
const actorMask = this.collisionMask;
// Skip collision checks if no collision mask
if (actorMask === 0) {
// Just move without checking collisions
this._xRemainder -= move;
this._x += move;
this.updateView();
return [];
}
this._xRemainder -= move;
const sign = Math.sign(move);
let remaining = Math.abs(move);
const step = sign;
// If we're being pushed by a solid, temporarily make it non-collidable
if (pushingSolid) {
pushingSolid.collideable = false;
}
// Move one pixel at a time, checking for collisions
while (remaining > 0) {
const nextX = this._x + step;
// Get solids at the next position
const solids = this.getSolidsAt(nextX, this._y);
let collided = false;
// Check for collisions with each solid
for (const solid of solids) {
// Skip if solid can't collide
if (!solid.canCollide) continue;
// Skip if collision layers don't match
if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {
continue;
}
// Calculate collision details
const result: CollisionResult = {
collided: true,
solid,
normal: { x: -sign, y: 0 },
penetration: step > 0 ? this.x + this.width - solid.x : solid.x + solid.width - this.x,
pushingSolid,
};
// Add to collisions array
collisions.push(result);
// Call collision handler if provided
if (collisionHandler) {
collisionHandler(result);
}
// Call actor's collision handler
this.onCollide(result);
collided = true;
}
if (collided) {
// Stop movement on collision
break;
} else {
// Move to next position
this._x = nextX;
remaining--;
// Update view every few pixels for better performance
// This reduces the number of view updates during movement
if (remaining % 4 === 0 || remaining === 0) {
this.updateView();
}
}
}
// Restore solid's collidable state
if (pushingSolid) {
pushingSolid.collideable = true;
}
// Final view update if we moved
if (Math.abs(move) - remaining > 0) {
this.updateView();
}
return collisions;
}
/**
* Moves the actor vertically, checking for collisions with solids.
*
* @param amount - Distance to move in pixels
* @param collisionHandler - Optional callback for handling collisions
* @returns Array of collision results
*/
public moveY(
amount: number,
collisionHandler?: (result: CollisionResult) => void,
pushingSolid?: Solid,
): CollisionResult[] {
// Early return if inactive or zero movement
if (!this.active || amount === 0) return [];
this._yRemainder += amount;
const move = Math.round(this._yRemainder);
// Early return if rounded movement is zero
if (move === 0) return [];
const collisions: CollisionResult[] = [];
// Cache collision layer and mask for faster access
const actorLayer = this.collisionLayer;
const actorMask = this.collisionMask;
// Skip collision checks if no collision mask
if (actorMask === 0) {
// Just move without checking collisions
this._yRemainder -= move;
this._y += move;
this.updateView();
return [];
}
this._yRemainder -= move;
const sign = Math.sign(move);
let remaining = Math.abs(move);
const step = sign;
// If we're being pushed by a solid, temporarily make it non-collidable
if (pushingSolid) {
pushingSolid.collideable = false;
}
// Move one pixel at a time, checking for collisions
while (remaining > 0) {
const nextY = this._y + step;
// Get solids at the next position
const solids = this.getSolidsAt(this._x, nextY);
let collided = false;
// Check for collisions with each solid
for (const solid of solids) {
// Skip if solid can't collide
if (!solid.canCollide) continue;
// Skip if collision layers don't match
if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {
continue;
}
// Calculate collision details
const result: CollisionResult = {
collided: true,
solid,
normal: { x: 0, y: -sign },
penetration: step > 0 ? this.y + this.height - solid.y : solid.y + solid.height - this.y,
pushingSolid,
};
// Add to collisions array
collisions.push(result);
// Call collision handler if provided
if (collisionHandler) {
collisionHandler(result);
}
// Call actor's collision handler
this.onCollide(result);
collided = true;
}
if (collided) {
// Stop movement on collision
break;
} else {
// Move to next position
this._y = nextY;
remaining--;
// Update view every few pixels for better performance
// This reduces the number of view updates during movement
if (remaining % 4 === 0 || remaining === 0) {
this.updateView();
}
}
}
// Restore solid's collidable state
if (pushingSolid) {
pushingSolid.collideable = true;
}
// Final view update if we moved
if (Math.abs(move) - remaining > 0) {
this.updateView();
// Update grid cells if actor moved and actor collisions are enabled
}
return collisions;
}
/**
* Updates the actor's view position.
*/
public updateView(): void {
if (this.view && this.view.visible) {
this.view.x = this._x;
this.view.y = this._y;
}
}
/**
* Gets all solids at the specified position that could collide with this actor.
*
* @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);
}
/**
* Checks if this actor is colliding with another actor.
* The collision will only occur if:
* 1. Both actors are active
* 2. Neither actor has disabled actor collisions
* 3. The collision layers and masks match:
* - (this.collisionLayer & other.collisionMask) !== 0
* - (other.collisionLayer & this.collisionMask) !== 0
*
* @param actor - The actor to check collision with
* @returns Collision result with information about the collision
*/
public checkActorCollision(actor: Actor): ActorCollisionResult {
// Skip if either actor is not active
if (!this.active || !actor.active) {
return { collided: false, actor };
}
// Skip if either actor has disabled actor collisions
if (this.disableActorCollisions || actor.disableActorCollisions) {
return { collided: false, actor };
}
// Skip if the actors can't collide based on collision layers
if ((this.collisionLayer & actor.collisionMask) === 0 || (actor.collisionLayer & this.collisionMask) === 0) {
return { collided: false, actor };
}
// Simple AABB collision check
const thisLeft = this.x;
const thisRight = this.x + this.width;
const thisTop = this.y;
const thisBottom = this.y + this.height;
const otherLeft = actor.x;
const otherRight = actor.x + actor.width;
const otherTop = actor.y;
const otherBottom = actor.y + actor.height;
// Check if the bounding boxes overlap
if (thisRight > otherLeft && thisLeft < otherRight && thisBottom > otherTop && thisTop < otherBottom) {
// Calculate penetration and normal
const overlapX = Math.min(thisRight - otherLeft, otherRight - thisLeft);
const overlapY = Math.min(thisBottom - otherTop, otherBottom - thisTop);
let normal: Vector2;
let penetration: number;
// Determine the collision normal based on the smallest overlap
if (overlapX < overlapY) {
penetration = overlapX;
normal = {
x: thisLeft < otherLeft ? -1 : 1,
y: 0,
};
} else {
penetration = overlapY;
normal = {
x: 0,
y: thisTop < otherTop ? -1 : 1,
};
}
return {
collided: true,
actor,
normal,
penetration,
};
}
return { collided: false, actor };
}
/**
* Resolves a collision with another actor.
*
* @param result - The collision result to resolve
* @param shouldMove - Whether this actor should move to resolve the collision
* @returns The updated collision result
*/
public resolveActorCollision(result: ActorCollisionResult): ActorCollisionResult {
if (!result.collided || !result.normal || !result.penetration) {
return result;
}
// Call the collision handler
this.onActorCollide(result);
// Example:
// Move this actor to resolve the collision
// this.x += result.normal.x * result.penetration * 0.5;
// this.y += result.normal.y * result.penetration * 0.5;
return result;
}
/**
* Sets the actor's size and updates grid cells if needed.
*
* @param width - New width in pixels
* @param height - New height in pixels
*/
public setSize(width: number, height: number): void {
const sizeChanged = this.width !== width || this.height !== height;
this.width = width;
this.height = height;
// Update grid cells if size changed and actor collisions are enabled
if (sizeChanged && this.system.enableActorCollisions) {
this.updateGridCells();
}
}
/**
* Sets the actor's width and updates grid cells if needed.
*
* @param value - New width in pixels
*/
public setWidth(value: number): void {
if (this.width !== value) {
this.width = value;
// Update grid cells if size changed and actor collisions are enabled
if (this.system.enableActorCollisions) {
this.updateGridCells();
}
}
}
/**
* Sets the actor's height and updates grid cells if needed.
*
* @param value - New height in pixels
*/
public setHeight(value: number): void {
if (this.height !== value) {
this.height = value;
// Update grid cells if size changed and actor collisions are enabled
if (this.system.enableActorCollisions) {
this.updateGridCells();
}
}
}
}