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