UNPKG

@dill-pixel/plugin-crunch-physics

Version:

Crunch Physics

762 lines (659 loc) 19.1 kB
import { Application, bindAllMethods, defaultFactoryMethods, PointLike, randomUUID, resolvePointLike, SignalConnection, SignalConnections, } from 'dill-pixel'; import CrunchPhysicsPlugin from './CrunchPhysicsPlugin'; import { Group } from './Group'; import { System } from './System'; import { CollisionLayer, EntityData, PhysicsEntityConfig, PhysicsEntityType, PhysicsEntityView, Rectangle, } from './types'; import { resolveEntityPosition, resolveEntitySize } from './utils'; /** * Base class for all physics entities in the Crunch physics system. * Provides common functionality for position, size, view management, and lifecycle. * * Entity is the foundation for: * - Actors (dynamic objects) * - Solids (static objects) * - Sensors (trigger zones) * * It handles: * - Position and size management * - View (sprite) management and updates * - Group membership and relative positioning * - Culling and lifecycle states * - Signal connections for event handling * * @typeParam A - Application type, defaults to base Application * @typeParam D - Entity data type, defaults to base EntityData * * @example * ```typescript * // Create a custom entity * class CustomEntity extends Entity { * constructor() { * super({ * type: 'Custom', * position: [100, 100], * size: [32, 32], * view: sprite * }); * } * * // Override update for custom behavior * update(dt: number) { * super.update(dt); * // Custom update logic * } * } * ``` */ export class Entity<A extends Application = Application, D extends EntityData = EntityData> { public readonly entityType: PhysicsEntityType; protected _id: string; /** Unique type identifier for this entity */ public type!: string; /** Color to use when rendering debug visuals */ public debugColor: number; /** Whether the entity should be removed when culled (out of bounds) */ public shouldRemoveOnCull: boolean; /** Entity width in pixels */ public width: number; /** Entity height in pixels */ public height: number; /** Visual representation (sprite/graphics) of this entity */ public view!: PhysicsEntityView; /** Whether this entity is active and should be updated */ /** Collision layer this entity belongs to (bitwise) */ public collisionLayer: number = CollisionLayer.NONE; /** Collision mask defining which layers this entity collides with (bitwise) */ public collisionMask: number = CollisionLayer.NONE; protected _data: Partial<D>; protected _group: Group | null; protected _groupOffset: { x: number; y: number }; protected _isCulled: boolean; protected _isDestroyed: boolean; protected _isInitialized: boolean; protected _xRemainder: number; protected _yRemainder: number; protected _x: number; protected _y: number; protected signalConnections: SignalConnections; protected _following: Entity | null; protected _followOffset: { x: number; y: number }; protected _config: PhysicsEntityConfig<D> | undefined; protected _active: boolean = true; public updatedFollowPosition: boolean = false; public updatedGroupPosition: boolean = false; set active(value: boolean) { this._active = value; } get active(): boolean { return this._active; } set id(value: string) { this._id = value; } get id(): string { return this._id || this.type; } get make(): typeof defaultFactoryMethods { return this.app.make; } /** * Custom data associated with this entity */ set data(value: Partial<D>) { this._data = value; } get data(): Partial<D> { return this._data; } setFollowing(entityToFollow: Entity | null, offset: PointLike = { x: 0, y: 0 }) { this._followOffset = resolvePointLike(offset); if (this._following) { this.system.removeFollower(this); } this._following = entityToFollow; if (entityToFollow) { this.system.addFollower(entityToFollow, this); } } get followOffset(): { x: number; y: number } { return this._followOffset || { x: 0, y: 0 }; } get following(): Entity | null { return this._following; } get followers(): Entity[] { return this.system.getFollowersOf(this); } /** * The group this entity belongs to, if any. * Groups allow for collective movement and management of entities. */ get group(): Group | null { return this._group; } set groupOffset(value: { x: number; y: number }) { this._groupOffset = resolvePointLike(value); } get groupOffset(): { x: number; y: number } { return this._groupOffset || { x: 0, y: 0 }; } setGroup(group: Group | null, offset: PointLike = { x: 0, y: 0 }) { this._groupOffset = resolvePointLike(offset); // if we're already in a group, remove ourselves first if (this._group) { this.system.removeFromGroup(this); this.onRemovedFromGroup(); } this._group = group; if (group) { this.system.addToGroup(group, this); this.onAddedToGroup(); } } set position(value: PointLike) { const { x, y } = resolvePointLike(value); this.setPosition(x, y); } get position(): PointLike { return { x: this.x, y: this.y }; } /** * Entity's X position in world space. * If the entity belongs to a group, returns position relative to group. */ set x(value: number) { this._x = value; } get x(): number { if (this._group) { return Math.round(this._x + this._group.getChildOffset(this).x); // Return world position } return Math.round(this._x); } /** * Entity's Y position in world space. * If the entity belongs to a group, returns position relative to group. */ set y(value: number) { this._y = value; } get y(): number { if (this._group) { return Math.round(this._y + this._group.getChildOffset(this).y); // Return world position } return Math.round(this._y); } /** Whether this entity is currently culled (out of bounds) */ get isCulled(): boolean { return this._isCulled; } /** Whether this entity has been destroyed */ get isDestroyed(): boolean { return this._isDestroyed; } /** Reference to the main application instance */ get app(): A { return Application.getInstance() as A; } /** Reference to the physics plugin */ get physics(): CrunchPhysicsPlugin { return this.app.getPlugin('crunch-physics') as CrunchPhysicsPlugin; } /** * Creates a new Entity instance. * * @param config - Optional configuration for the entity */ constructor(config?: PhysicsEntityConfig<D>) { bindAllMethods(this); this._config = config; this.signalConnections = new SignalConnections(); this.shouldRemoveOnCull = false; this.width = 0; this.height = 0; this._data = {}; this._group = null; this._isCulled = false; this._isDestroyed = false; this._isInitialized = false; this._xRemainder = 0; this._yRemainder = 0; this._x = 0; this._y = 0; if (config) { this.init(config); } this.initialize(); this.addView(); } /** * Called after construction to perform additional initialization. * Override this in subclasses to add custom initialization logic. */ protected initialize() { // Override in subclass } /** * Called before update to prepare for the next frame. * Override this in subclasses to add pre-update logic. */ public preUpdate(): void { // Override in subclass } /** * Called every frame to update the entity's state. * Override this in subclasses to add update logic. * * @param dt - Delta time in seconds since last update */ public update(dt: number): void { // Override in subclass void dt; } /** * Called after update to finalize the frame. * Override this in subclasses to add post-update logic. */ public postUpdate(): void { // Override in subclass } /** * Excludes collision types for this entity * @deprecated Use setCollisionMask instead */ excludeCollisionType() { console.warn('excludeCollisionType is deprecated. Use setCollisionMask instead.'); // No-op as we're removing this functionality } /** * Includes collision types for this entity * @deprecated Use setCollisionMask instead */ includeCollisionType() { console.warn('includeCollisionType is deprecated. Use setCollisionMask instead.'); // No-op as we're removing this functionality } /** * Removes collision types for this entity * @deprecated Use removeCollisionMask instead */ removeCollisionType() { console.warn('removeCollisionType is deprecated. Use removeCollisionMask instead.'); // No-op as we're removing this functionality } /** * Adds collision types for this entity * @deprecated Use addCollisionMask instead */ addCollisionType() { console.warn('addCollisionType is deprecated. Use addCollisionMask instead.'); // No-op as we're removing this functionality } /** * Checks if this entity can collide with a specific type */ canCollideWith(): boolean { // Always return true as we're now using only collision layers/masks return true; } /** * Adds the entity's view to the physics container and updates its position. */ protected addView() { if (this.view) { this.view.visible = true; this.view.label = this.id || this.type; if (this.system.container) { this.system.container.addChild(this.view); this.updateView(); } } } /** * Initializes or reinitializes the entity with new configuration. * Used by object pools when recycling entities. * * @param config - New configuration to apply */ public init(config: PhysicsEntityConfig<D>): void { if (!config) return; this._config = config as PhysicsEntityConfig<D>; if (config.id) { this._id = config.id; } else { this._id = randomUUID(); } if (config.type) { this.type = config.type; } if (config.data) { this._data = config.data as Partial<D>; } const position = resolveEntityPosition(config); this._x = position.x; this._y = position.y; const size = resolveEntitySize(config); this.width = size.width; this.height = size.height; // Initialize collision layers if (config.collisionLayer !== undefined) { this.setCollisionLayer(config.collisionLayer); } if (config.collisionMask !== undefined) { this.setCollisionMask(config.collisionMask); } // Reset physics properties this._xRemainder = 0; this._yRemainder = 0; if (config.view) { this.setView(config.view); } if (config.group) { this.setGroup(config.group ?? null, config.groupOffset ? resolvePointLike(config.groupOffset) : { x: 0, y: 0 }); } if (config.follows) { this.setFollowing( config.follows ?? null, config.followOffset ? resolvePointLike(config.followOffset) : { x: 0, y: 0 }, ); } // Show and update view if it exists this.addView(); } /** * Resets the entity to its initial state for reuse in object pools. * Override this to handle custom reset logic. */ public reset(): void { // Reset culling state this._isCulled = false; this._isDestroyed = false; // Reset remainders this._xRemainder = 0; this._yRemainder = 0; this._followOffset = { x: 0, y: 0 }; this._following = null; this._groupOffset = { x: 0, y: 0 }; this._group = null; this._data = {}; this._x = -Number.MAX_SAFE_INTEGER; this._y = -Number.MAX_SAFE_INTEGER; if (this.view) { this.view.visible = false; } this.system.removeEntity(this); } /** Reference to the physics system */ get system(): System { return this.physics.system; } /** * Called when the entity is added to a group. * Override this to handle custom group addition logic. */ public onAddedToGroup(): void { // Override in subclass } /** * Called when the entity is removed from a group. * Override this to handle custom group removal logic. */ public onRemovedFromGroup(): void { // Override in subclass } /** * Updates the entity's position and view. */ public updatePosition(): void { this.x = this._x; this.y = this._y; this.updateView(); } /** * Called when the entity is culled (goes out of bounds). * Override this to handle culling differently. */ public onCull(): void { this._isCulled = true; // Default behavior: hide the view if (this.view) { this.view.visible = false; } } /** * Called when the entity is brought back after being culled. * Override this to handle unculling differently. */ public onUncull(): void { this._isCulled = false; // Default behavior: show the view if (this.view) { this.view.visible = true; } } /** * Prepares the entity for removal/recycling. * Override this to handle custom cleanup. */ public destroy(): void { if (this._isDestroyed) return; this._isDestroyed = true; this._isCulled = false; this.signalConnections.disconnectAll(); // Don't destroy the view - it will be reused if (this.view) { this.view.visible = false; this.view.removeFromParent(); } this.system.removeFollower(this); } /** * Called when the entity is removed from the physics system. * Override this to handle custom removal logic. */ public onRemoved(): void { if (!this._isDestroyed) { this.destroy(); } } /** * Sets a new view for the entity and updates its position. * * @param view - The new view to use */ public setView(view: PhysicsEntityView): void { this.view = view; this.updateView(); } /** * Updates the view's position to match the entity's position. */ public updateView(): void { if (this.view && this.view.visible && this.view.position) { this.view.position.set(this.x, this.y); } } /** * Gets the entity's bounding rectangle. * * @returns Rectangle representing the entity's bounds */ public getBounds(): Rectangle { return { x: this.x, y: this.y, width: this.width, height: this.height, }; } /** * Sets the entity's position, resetting any movement remainders. * * @param x - New X position * @param y - New Y position */ public setPosition(x: number, y: number): void { this._x = x; this._y = y; this._xRemainder = 0; this._yRemainder = 0; this.updateView(); } /** * Alias for setPosition. * * @param x - New X position * @param y - New Y position */ public moveTo(x: number, y: number): void { this.setPosition(x, y); } /** * Adds signal connections to the entity. * * @param args - Signal connections to add */ public addSignalConnection(...args: SignalConnection[]) { for (const connection of args) { this.signalConnections.add(connection); } } /** * Alias for addSignalConnection. * * @param args - Signal connections to add */ public connectSignal(...args: SignalConnection[]) { for (const connection of args) { this.signalConnections.add(connection); } } /** * Alias for addSignalConnection, specifically for action signals. * * @param args - Action signal connections to add */ public connectAction(...args: SignalConnection[]) { for (const connection of args) { this.signalConnections.add(connection); } } /** * Checks if this entity can collide with another entity */ public canCollideWithEntity(entity: Entity): boolean { // Check if the entities can collide based on their collision layers and masks // A collision occurs when (A.layer & B.mask) !== 0 && (B.layer & A.mask) !== 0 return (this.collisionLayer & entity.collisionMask) !== 0 && (entity.collisionLayer & this.collisionMask) !== 0; } /** * Sets the collision layer for this entity * * @param layer The collision layer or layers (can be combined with bitwise OR) */ public setCollisionLayer(layer: number): void { this.collisionLayer = layer; } /** * Adds the specified layers to this entity's collision layer * * @param layers The layers to add (can be combined with bitwise OR) */ public addCollisionLayer(layers: number): void { this.collisionLayer |= layers; } /** * Removes the specified layers from this entity's collision layer * * @param layers The layers to remove (can be combined with bitwise OR) */ public removeCollisionLayer(layers: number): void { this.collisionLayer &= ~layers; } /** * Sets the collision mask for this entity * * @param mask The collision mask (can be combined with bitwise OR) */ public setCollisionMask(...mask: number[]): void { this.collisionMask = this.physics.createCollisionMask(...mask); } /** * Adds the specified layers to this entity's collision mask * * @param layers The layers to add to the mask (can be combined with bitwise OR) */ public addCollisionMask(layers: number): void { this.collisionMask |= layers; } /** * Removes the specified layers from this entity's collision mask * * @param layers The layers to remove from the mask (can be combined with bitwise OR) */ public removeCollisionMask(layers: number): void { this.collisionMask &= ~layers; } /** * Checks if this entity belongs to a specific collision layer * * @param layer The layer to check * @returns True if the entity belongs to the specified layer * * @example * ```typescript * // Check if entity is on the PLAYER layer * if (entity.hasCollisionLayer(CollisionLayer.PLAYER)) { * console.log('Entity is a player'); * } * * // Check if entity is on a custom layer * const WATER_LAYER = CollisionLayers.createLayer(0); * if (entity.hasCollisionLayer(WATER_LAYER)) { * console.log('Entity is water'); * } * ``` */ public hasCollisionLayer(layer: number): boolean { return (this.collisionLayer & layer) !== 0; } /** * Checks if this entity can collide with a specific collision layer * * @param layer The layer to check * @returns True if the entity can collide with the specified layer * * @example * ```typescript * // Check if entity can collide with players * if (entity.canCollideWithLayer(CollisionLayer.PLAYER)) { * console.log('Entity can collide with players'); * } * * // Check if entity can collide with a custom layer * const WATER_LAYER = CollisionLayers.createLayer(0); * if (entity.canCollideWithLayer(WATER_LAYER)) { * console.log('Entity can collide with water'); * } * ``` */ public canCollideWithLayer(layer: number): boolean { return (this.collisionMask & layer) !== 0; } }