UNPKG

@dill-pixel/plugin-crunch-physics

Version:

Crunch Physics

728 lines (614 loc) 20.5 kB
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(); } } } }