UNPKG

@dill-pixel/plugin-crunch-physics

Version:

Crunch Physics

1 lines 205 kB
{"version":3,"file":"dill-pixel-plugin-crunch-physics.mjs","sources":["../src/types.ts","../src/utils.ts","../src/Entity.ts","../src/Actor.ts","../src/Group.ts","../src/Sensor.ts","../src/Solid.ts","../src/System.ts","../src/CrunchPhysicsPlugin.ts"],"sourcesContent":["import { PointLike, SizeLike } from 'dill-pixel';\nimport { Container } from 'pixi.js';\nimport { Actor } from './Actor';\nimport { Entity } from './Entity';\nimport { Group } from './Group';\nimport { Sensor } from './Sensor';\nimport { Solid } from './Solid';\n\nexport interface Vector2 {\n x: number;\n y: number;\n}\n\nexport interface Rectangle {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport type CollisionShape = 'rectangle';\nexport type EntityData = {\n [key: string]: any;\n};\n\nexport type PhysicsEntityClass = new (config?: PhysicsEntityConfig) => Actor | Solid | Sensor;\n\n/**\n * Collision layers using bitwise flags\n * Each entity can belong to multiple layers (using bitwise OR)\n * and can collide with multiple layers (using bitwise AND)\n */\nexport enum CollisionLayer {\n NONE = 0,\n DEFAULT = 1 << 0,\n PLAYER = 1 << 1,\n ENEMY = 1 << 2,\n PROJECTILE = 1 << 3,\n PLATFORM = 1 << 4,\n TRIGGER = 1 << 5,\n ITEM = 1 << 6,\n WALL = 1 << 7,\n FX = 1 << 8,\n // Reserve first 16 bits for built-in layers\n // Bits 16-31 are available for user-defined layers\n ALL = 0xffffffff, // All bits set to 1\n}\n\n/**\n * Interface for a registered collision layer\n */\nexport interface RegisteredCollisionLayer {\n /** Name of the collision layer */\n name: string;\n /** Numeric value of the collision layer (bitwise) */\n value: number;\n /** Description of the collision layer (optional) */\n description?: string;\n}\n\n/**\n * Registry for tracking custom collision layers\n */\nexport class CollisionLayerRegistry {\n private static _instance: CollisionLayerRegistry;\n private _layers: Map<string, RegisteredCollisionLayer> = new Map();\n private _usedIndices: Set<number> = new Set();\n\n /**\n * Get the singleton instance of the registry\n */\n public static get instance(): CollisionLayerRegistry {\n if (!CollisionLayerRegistry._instance) {\n CollisionLayerRegistry._instance = new CollisionLayerRegistry();\n }\n return CollisionLayerRegistry._instance;\n }\n\n /**\n * Register a new collision layer\n *\n * @param name Name of the collision layer\n * @param index Index from 0-15 representing which user bit to use (gets shifted to bits 16-31)\n * @param description Optional description of the layer\n * @returns The registered collision layer\n */\n public register(name: string, index: number, description?: string): RegisteredCollisionLayer {\n if (index < 0 || index > 15) {\n throw new Error('Custom collision layer index must be between 0 and 15');\n }\n\n if (this._usedIndices.has(index)) {\n throw new Error(`Collision layer index ${index} is already in use`);\n }\n\n if (this._layers.has(name)) {\n throw new Error(`Collision layer with name \"${name}\" already exists`);\n }\n\n const value = 1 << (index + 16);\n const layer: RegisteredCollisionLayer = { name, value, description };\n\n this._layers.set(name, layer);\n this._usedIndices.add(index);\n\n return layer;\n }\n\n /**\n * Get a registered collision layer by name\n *\n * @param name Name of the collision layer\n * @returns The registered collision layer or undefined if not found\n */\n public get(name: string): RegisteredCollisionLayer | undefined {\n return this._layers.get(name);\n }\n\n /**\n * Get all registered collision layers\n *\n * @returns Array of all registered collision layers\n */\n public getAll(): RegisteredCollisionLayer[] {\n return Array.from(this._layers.values());\n }\n\n /**\n * Check if a collision layer with the given name exists\n *\n * @param name Name of the collision layer\n * @returns True if the layer exists\n */\n public has(name: string): boolean {\n return this._layers.has(name);\n }\n\n /**\n * Remove a registered collision layer\n *\n * @param name Name of the collision layer to remove\n * @returns True if the layer was removed, false if it didn't exist\n */\n public remove(name: string): boolean {\n const layer = this._layers.get(name);\n if (!layer) return false;\n\n // Calculate the index from the value\n const value = layer.value;\n const index = Math.log2(value) - 16;\n\n this._usedIndices.delete(index);\n return this._layers.delete(name);\n }\n\n /**\n * Clear all registered collision layers\n */\n public clear(): void {\n this._layers.clear();\n this._usedIndices.clear();\n }\n\n /**\n * Get the next available index for a custom collision layer\n *\n * @returns The next available index or -1 if all indices are used\n */\n public getNextAvailableIndex(): number {\n for (let i = 0; i < 16; i++) {\n if (!this._usedIndices.has(i)) {\n return i;\n }\n }\n return -1;\n }\n}\n\n/**\n * Utility functions for working with collision layers\n */\nexport const CollisionLayers = {\n /**\n * Creates a custom collision layer using bits 16-31 (user space)\n *\n * @param index Index from 0-15 representing which user bit to use (gets shifted to bits 16-31)\n * @returns A unique collision layer value\n *\n * @example\n * ```typescript\n * // Create custom collision layers\n * const WATER_LAYER = CollisionLayers.createLayer(0); // 1 << 16\n * const LAVA_LAYER = CollisionLayers.createLayer(1); // 1 << 17\n * const CLOUD_LAYER = CollisionLayers.createLayer(2); // 1 << 18\n *\n * // Use in entity creation\n * const waterEntity = physics.createActor({\n * type: 'Water',\n * position: [100, 400],\n * size: [800, 100],\n * collisionLayer: WATER_LAYER,\n * collisionMask: CollisionLayer.PLAYER | CollisionLayer.ENEMY\n * });\n * ```\n */\n createLayer(index: number): number {\n if (index < 0 || index > 15) {\n throw new Error('Custom collision layer index must be between 0 and 15');\n }\n return 1 << (index + 16);\n },\n\n /**\n * Creates a collision mask from multiple layers\n *\n * @param layers Array of collision layers to combine\n * @returns A combined collision mask\n *\n * @example\n * ```typescript\n * // Create a mask that collides with players, enemies and projectiles\n * const mask = CollisionLayers.createMask([\n * CollisionLayer.PLAYER,\n * CollisionLayer.ENEMY,\n * CollisionLayer.PROJECTILE\n * ]);\n * ```\n */\n createMask(layers: number[]): number {\n return layers.reduce((mask, layer) => mask | layer, 0);\n },\n\n /**\n * Checks if a layer is included in a mask\n *\n * @param layer The layer to check\n * @param mask The mask to check against\n * @returns True if the layer is included in the mask\n *\n * @example\n * ```typescript\n * // Check if player layer is in the mask\n * if (CollisionLayers.isLayerInMask(CollisionLayer.PLAYER, entity.collisionMask)) {\n * console.log('Entity can collide with players');\n * }\n * ```\n */\n isLayerInMask(layer: number, mask: number): boolean {\n return (layer & mask) !== 0;\n },\n\n /**\n * Get the registry for custom collision layers\n *\n * @returns The collision layer registry\n */\n getRegistry(): CollisionLayerRegistry {\n return CollisionLayerRegistry.instance;\n },\n};\n\nexport interface PhysicsEntityConfig<D extends EntityData = EntityData> {\n id?: string;\n class?: PhysicsEntityClass;\n type?: PhysicsEntityType;\n position?: PointLike;\n size?: SizeLike;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n restitution?: number;\n view?: PhysicsEntityView;\n data?: Partial<D>;\n group?: Group;\n groupOffset?: PointLike;\n follows?: Entity;\n followOffset?: PointLike;\n /** Collision layer this entity belongs to (bitwise) */\n collisionLayer?: number;\n /** Collision mask defining which layers this entity collides with (bitwise) */\n collisionMask?: number;\n /** Whether to disable actor-to-actor collisions for this entity */\n disableActorCollisions?: boolean;\n}\n\nexport interface CollisionResult {\n collided: boolean;\n normal?: Vector2;\n penetration?: number;\n solid: Solid;\n}\n\nexport interface SensorOverlap {\n type: `${PhysicsEntityType}|${PhysicsEntityType}`;\n actor: Actor;\n sensor: Sensor;\n}\n\nexport interface CollisionResult {\n collided: boolean;\n normal?: Vector2;\n penetration?: number;\n solid: Solid;\n pushingSolid?: Solid;\n}\n\n/**\n * Result of an actor-to-actor collision\n */\nexport interface ActorCollisionResult {\n collided: boolean;\n normal?: Vector2;\n penetration?: number;\n actor: Actor;\n}\n\nexport interface Collision {\n type: `${PhysicsEntityType}|${PhysicsEntityType}`;\n entity1: Actor | Sensor;\n entity2: Actor | Solid;\n result: CollisionResult;\n}\n\n/**\n * Represents a collision between two actors\n */\nexport interface ActorCollision {\n type: `${PhysicsEntityType}|${PhysicsEntityType}`;\n actor1: Actor;\n actor2: Actor;\n result: ActorCollisionResult;\n}\n\nexport type PhysicsEntityView = Container;\nexport type PhysicsEntityType = 'Actor' | 'Solid' | 'Sensor' | 'Group' | string;\n","import { resolvePointLike, resolveSizeLike } from 'dill-pixel';\nimport { PhysicsEntityConfig } from './types';\n\n/**\n * Utility functions for resolving entity positions and sizes from various input formats.\n * These functions handle the conversion of different coordinate and size specifications\n * into standardized formats used by the physics system.\n */\n\n/**\n * Resolves an entity's position from various input formats.\n * Supports direct x/y values or a position object/array.\n *\n * @param config - Entity configuration containing position information\n * @returns Resolved {x, y} coordinates\n *\n * @example\n * ```typescript\n * // Using direct x/y values\n * resolveEntityPosition({ x: 100, y: 200 })\n * // → { x: 100, y: 200 }\n *\n * // Using position array\n * resolveEntityPosition({ position: [100, 200] })\n * // → { x: 100, y: 200 }\n *\n * // Using position object\n * resolveEntityPosition({ position: { x: 100, y: 200 } })\n * // → { x: 100, y: 200 }\n * ```\n */\nexport function resolveEntityPosition(config: PhysicsEntityConfig): { x: number; y: number } {\n const { x, y } =\n config.position !== undefined ? resolvePointLike(config.position) : { x: config?.x ?? 0, y: config?.y ?? 0 };\n\n return { x, y };\n}\n\n/**\n * Resolves an entity's size from various input formats.\n * Supports direct width/height values or a size object/array.\n *\n * @param config - Entity configuration containing size information\n * @returns Resolved {width, height} dimensions\n *\n * @example\n * ```typescript\n * // Using direct width/height values\n * resolveEntitySize({ width: 100, height: 200 })\n * // → { width: 100, height: 200 }\n *\n * // Using size array\n * resolveEntitySize({ size: [100, 200] })\n * // → { width: 100, height: 200 }\n *\n * // Using size object\n * resolveEntitySize({ size: { width: 100, height: 200 } })\n * // → { width: 100, height: 200 }\n *\n * // Using defaults\n * resolveEntitySize({})\n * // → { width: 32, height: 32 }\n * ```\n */\nexport function resolveEntitySize(config: PhysicsEntityConfig): { width: number; height: number } {\n const { width, height } =\n config.size !== undefined\n ? resolveSizeLike(config.size)\n : { width: config?.width ?? 32, height: config?.height ?? 32 };\n\n return { width, height };\n}\n","import {\n Application,\n type AppTypeOverrides,\n bindAllMethods,\n defaultFactoryMethods,\n PointLike,\n randomUUID,\n resolvePointLike,\n SignalConnection,\n SignalConnections,\n} from 'dill-pixel';\nimport CrunchPhysicsPlugin from './CrunchPhysicsPlugin';\nimport { Group } from './Group';\nimport { System } from './System';\nimport {\n CollisionLayer,\n EntityData,\n PhysicsEntityConfig,\n PhysicsEntityType,\n PhysicsEntityView,\n Rectangle,\n} from './types';\nimport { resolveEntityPosition, resolveEntitySize } from './utils';\n\n/**\n * Base class for all physics entities in the Crunch physics system.\n * Provides common functionality for position, size, view management, and lifecycle.\n *\n * Entity is the foundation for:\n * - Actors (dynamic objects)\n * - Solids (static objects)\n * - Sensors (trigger zones)\n *\n * It handles:\n * - Position and size management\n * - View (sprite) management and updates\n * - Group membership and relative positioning\n * - Culling and lifecycle states\n * - Signal connections for event handling\n *\n * @typeParam A - Application type, defaults to base Application\n * @typeParam D - Entity data type, defaults to base EntityData\n *\n * @example\n * ```typescript\n * // Create a custom entity\n * class CustomEntity extends Entity {\n * constructor() {\n * super({\n * type: 'Custom',\n * position: [100, 100],\n * size: [32, 32],\n * view: sprite\n * });\n * }\n *\n * // Override update for custom behavior\n * update(dt: number) {\n * super.update(dt);\n * // Custom update logic\n * }\n * }\n * ```\n */\nexport class Entity<D extends EntityData = EntityData> {\n public readonly entityType: PhysicsEntityType;\n protected _id: string;\n /** Unique type identifier for this entity */\n public type!: string;\n\n /** Color to use when rendering debug visuals */\n public debugColor: number;\n\n /** Whether the entity should be removed when culled (out of bounds) */\n public shouldRemoveOnCull: boolean;\n\n /** Entity width in pixels */\n public width: number;\n\n /** Entity height in pixels */\n public height: number;\n\n /** Visual representation (sprite/graphics) of this entity */\n public view!: PhysicsEntityView;\n\n /** Whether this entity is active and should be updated */\n\n /** Collision layer this entity belongs to (bitwise) */\n public collisionLayer: number = CollisionLayer.NONE;\n\n /** Collision mask defining which layers this entity collides with (bitwise) */\n public collisionMask: number = CollisionLayer.NONE;\n\n protected _data: Partial<D>;\n protected _group: Group | null;\n protected _groupOffset: { x: number; y: number };\n protected _isCulled: boolean;\n protected _isDestroyed: boolean;\n protected _isInitialized: boolean;\n protected _xRemainder: number;\n protected _yRemainder: number;\n protected _x: number;\n protected _y: number;\n protected signalConnections: SignalConnections;\n protected _following: Entity | null;\n protected _followOffset: { x: number; y: number };\n protected _config: PhysicsEntityConfig<D> | undefined;\n protected _active: boolean = true;\n\n public updatedFollowPosition: boolean = false;\n public updatedGroupPosition: boolean = false;\n\n set active(value: boolean) {\n this._active = value;\n }\n\n get active(): boolean {\n return this._active;\n }\n\n set id(value: string) {\n this._id = value;\n }\n\n get id(): string {\n return this._id || this.type;\n }\n\n get make(): typeof defaultFactoryMethods {\n return this.app.make;\n }\n\n /**\n * Custom data associated with this entity\n */\n set data(value: Partial<D>) {\n this._data = value;\n }\n\n get data(): Partial<D> {\n return this._data;\n }\n\n setFollowing(entityToFollow: Entity | null, offset: PointLike = { x: 0, y: 0 }) {\n this._followOffset = resolvePointLike(offset);\n if (this._following) {\n this.system.removeFollower(this);\n }\n this._following = entityToFollow;\n if (entityToFollow) {\n this.system.addFollower(entityToFollow, this);\n }\n }\n\n get followOffset(): { x: number; y: number } {\n return this._followOffset || { x: 0, y: 0 };\n }\n\n get following(): Entity | null {\n return this._following;\n }\n\n get followers(): Entity[] {\n return this.system.getFollowersOf(this);\n }\n\n /**\n * The group this entity belongs to, if any.\n * Groups allow for collective movement and management of entities.\n */\n get group(): Group | null {\n return this._group;\n }\n\n set groupOffset(value: { x: number; y: number }) {\n this._groupOffset = resolvePointLike(value);\n }\n\n get groupOffset(): { x: number; y: number } {\n return this._groupOffset || { x: 0, y: 0 };\n }\n\n setGroup(group: Group | null, offset: PointLike = { x: 0, y: 0 }) {\n this._groupOffset = resolvePointLike(offset);\n // if we're already in a group, remove ourselves first\n if (this._group) {\n this.system.removeFromGroup(this);\n this.onRemovedFromGroup();\n }\n this._group = group;\n if (group) {\n this.system.addToGroup(group, this);\n this.onAddedToGroup();\n }\n }\n\n set position(value: PointLike) {\n const { x, y } = resolvePointLike(value);\n this.setPosition(x, y);\n }\n\n get position(): PointLike {\n return { x: this.x, y: this.y };\n }\n\n /**\n * Entity's X position in world space.\n * If the entity belongs to a group, returns position relative to group.\n */\n set x(value: number) {\n this._x = value;\n }\n\n get x(): number {\n if (this._group) {\n return Math.round(this._x + this._group.getChildOffset(this).x); // Return world position\n }\n return Math.round(this._x);\n }\n\n /**\n * Entity's Y position in world space.\n * If the entity belongs to a group, returns position relative to group.\n */\n set y(value: number) {\n this._y = value;\n }\n\n get y(): number {\n if (this._group) {\n return Math.round(this._y + this._group.getChildOffset(this).y); // Return world position\n }\n return Math.round(this._y);\n }\n\n /** Whether this entity is currently culled (out of bounds) */\n get isCulled(): boolean {\n return this._isCulled;\n }\n\n /** Whether this entity has been destroyed */\n get isDestroyed(): boolean {\n return this._isDestroyed;\n }\n\n /** Reference to the main application instance */\n get app(): AppTypeOverrides['App'] {\n return Application.getInstance();\n }\n\n /** Reference to the physics plugin */\n get physics(): CrunchPhysicsPlugin {\n return this.app.getPlugin('crunch-physics') as CrunchPhysicsPlugin;\n }\n\n /**\n * Creates a new Entity instance.\n *\n * @param config - Optional configuration for the entity\n */\n constructor(config?: PhysicsEntityConfig<D>) {\n bindAllMethods(this);\n\n this._config = config;\n\n this.signalConnections = new SignalConnections();\n this.shouldRemoveOnCull = false;\n this.width = 0;\n this.height = 0;\n\n this._data = {};\n this._group = null;\n this._isCulled = false;\n this._isDestroyed = false;\n this._isInitialized = false;\n this._xRemainder = 0;\n this._yRemainder = 0;\n\n this._x = 0;\n this._y = 0;\n\n if (config) {\n this.init(config);\n }\n\n this.initialize();\n this.addView();\n }\n\n /**\n * Called after construction to perform additional initialization.\n * Override this in subclasses to add custom initialization logic.\n */\n protected initialize() {\n // Override in subclass\n }\n\n /**\n * Called before update to prepare for the next frame.\n * Override this in subclasses to add pre-update logic.\n */\n public preUpdate(): void {\n // Override in subclass\n }\n\n /**\n * Called every frame to update the entity's state.\n * Override this in subclasses to add update logic.\n *\n * @param dt - Delta time in seconds since last update\n */\n public update(dt: number): void {\n // Override in subclass\n void dt;\n }\n\n /**\n * Called after update to finalize the frame.\n * Override this in subclasses to add post-update logic.\n */\n public postUpdate(): void {\n // Override in subclass\n }\n\n /**\n * Excludes collision types for this entity\n * @deprecated Use setCollisionMask instead\n */\n excludeCollisionType() {\n console.warn('excludeCollisionType is deprecated. Use setCollisionMask instead.');\n // No-op as we're removing this functionality\n }\n\n /**\n * Includes collision types for this entity\n * @deprecated Use setCollisionMask instead\n */\n includeCollisionType() {\n console.warn('includeCollisionType is deprecated. Use setCollisionMask instead.');\n // No-op as we're removing this functionality\n }\n\n /**\n * Removes collision types for this entity\n * @deprecated Use removeCollisionMask instead\n */\n removeCollisionType() {\n console.warn('removeCollisionType is deprecated. Use removeCollisionMask instead.');\n // No-op as we're removing this functionality\n }\n\n /**\n * Adds collision types for this entity\n * @deprecated Use addCollisionMask instead\n */\n addCollisionType() {\n console.warn('addCollisionType is deprecated. Use addCollisionMask instead.');\n // No-op as we're removing this functionality\n }\n\n /**\n * Checks if this entity can collide with a specific type\n */\n canCollideWith(): boolean {\n // Always return true as we're now using only collision layers/masks\n return true;\n }\n\n /**\n * Adds the entity's view to the physics container and updates its position.\n */\n protected addView() {\n if (this.view) {\n this.view.visible = true;\n this.view.label = this.id || this.type;\n if (this.system.container) {\n this.system.container.addChild(this.view);\n this.updateView();\n }\n }\n }\n\n /**\n * Initializes or reinitializes the entity with new configuration.\n * Used by object pools when recycling entities.\n *\n * @param config - New configuration to apply\n */\n public init(config: PhysicsEntityConfig<D>): void {\n if (!config) return;\n this._config = config as PhysicsEntityConfig<D>;\n\n if (config.id) {\n this._id = config.id;\n } else {\n this._id = randomUUID();\n }\n\n if (config.type) {\n this.type = config.type;\n }\n\n if (config.data) {\n this._data = config.data as Partial<D>;\n }\n\n const position = resolveEntityPosition(config);\n this._x = position.x;\n this._y = position.y;\n\n const size = resolveEntitySize(config);\n this.width = size.width;\n this.height = size.height;\n\n // Initialize collision layers\n if (config.collisionLayer !== undefined) {\n this.setCollisionLayer(config.collisionLayer);\n }\n\n if (config.collisionMask !== undefined) {\n this.setCollisionMask(config.collisionMask);\n }\n\n // Reset physics properties\n this._xRemainder = 0;\n this._yRemainder = 0;\n\n if (config.view) {\n this.setView(config.view);\n }\n\n if (config.group) {\n this.setGroup(config.group ?? null, config.groupOffset ? resolvePointLike(config.groupOffset) : { x: 0, y: 0 });\n }\n\n if (config.follows) {\n this.setFollowing(\n config.follows ?? null,\n config.followOffset ? resolvePointLike(config.followOffset) : { x: 0, y: 0 },\n );\n }\n // Show and update view if it exists\n this.addView();\n }\n\n /**\n * Resets the entity to its initial state for reuse in object pools.\n * Override this to handle custom reset logic.\n */\n public reset(): void {\n // Reset culling state\n this._isCulled = false;\n this._isDestroyed = false;\n\n // Reset remainders\n this._xRemainder = 0;\n this._yRemainder = 0;\n\n this._followOffset = { x: 0, y: 0 };\n this._following = null;\n\n this._groupOffset = { x: 0, y: 0 };\n this._group = null;\n\n this._data = {};\n\n this._x = -Number.MAX_SAFE_INTEGER;\n this._y = -Number.MAX_SAFE_INTEGER;\n\n if (this.view) {\n this.view.visible = false;\n }\n\n this.system.removeEntity(this);\n }\n\n /** Reference to the physics system */\n get system(): System {\n return this.physics.system;\n }\n\n /**\n * Called when the entity is added to a group.\n * Override this to handle custom group addition logic.\n */\n public onAddedToGroup(): void {\n // Override in subclass\n }\n\n /**\n * Called when the entity is removed from a group.\n * Override this to handle custom group removal logic.\n */\n public onRemovedFromGroup(): void {\n // Override in subclass\n }\n\n /**\n * Updates the entity's position and view.\n */\n public updatePosition(): void {\n this.x = this._x;\n this.y = this._y;\n this.updateView();\n }\n\n /**\n * Called when the entity is culled (goes out of bounds).\n * Override this to handle culling differently.\n */\n public onCull(): void {\n this._isCulled = true;\n // Default behavior: hide the view\n if (this.view) {\n this.view.visible = false;\n }\n }\n\n /**\n * Called when the entity is brought back after being culled.\n * Override this to handle unculling differently.\n */\n public onUncull(): void {\n this._isCulled = false;\n // Default behavior: show the view\n if (this.view) {\n this.view.visible = true;\n }\n }\n\n /**\n * Prepares the entity for removal/recycling.\n * Override this to handle custom cleanup.\n */\n public destroy(): void {\n if (this._isDestroyed) return;\n\n this._isDestroyed = true;\n this._isCulled = false;\n\n this.signalConnections.disconnectAll();\n\n // Don't destroy the view - it will be reused\n if (this.view) {\n this.view.visible = false;\n this.view.removeFromParent();\n }\n\n this.system.removeFollower(this);\n }\n\n /**\n * Called when the entity is removed from the physics system.\n * Override this to handle custom removal logic.\n */\n public onRemoved(): void {\n if (!this._isDestroyed) {\n this.destroy();\n }\n }\n\n /**\n * Sets a new view for the entity and updates its position.\n *\n * @param view - The new view to use\n */\n public setView(view: PhysicsEntityView): void {\n this.view = view;\n this.updateView();\n }\n\n /**\n * Updates the view's position to match the entity's position.\n */\n public updateView(): void {\n if (this.view && this.view.visible && this.view.position) {\n this.view.position.set(this.x, this.y);\n }\n }\n\n /**\n * Gets the entity's bounding rectangle.\n *\n * @returns Rectangle representing the entity's bounds\n */\n public getBounds(): Rectangle {\n return {\n x: this.x,\n y: this.y,\n width: this.width,\n height: this.height,\n };\n }\n\n /**\n * Sets the entity's position, resetting any movement remainders.\n *\n * @param x - New X position\n * @param y - New Y position\n */\n public setPosition(x: number, y: number): void {\n this._x = x;\n this._y = y;\n this._xRemainder = 0;\n this._yRemainder = 0;\n this.updateView();\n }\n\n /**\n * Alias for setPosition.\n *\n * @param x - New X position\n * @param y - New Y position\n */\n public moveTo(x: number, y: number): void {\n this.setPosition(x, y);\n }\n\n /**\n * Adds signal connections to the entity.\n *\n * @param args - Signal connections to add\n */\n public addSignalConnection(...args: SignalConnection[]) {\n for (const connection of args) {\n this.signalConnections.add(connection);\n }\n }\n\n /**\n * Alias for addSignalConnection.\n *\n * @param args - Signal connections to add\n */\n public connectSignal(...args: SignalConnection[]) {\n for (const connection of args) {\n this.signalConnections.add(connection);\n }\n }\n\n /**\n * Alias for addSignalConnection, specifically for action signals.\n *\n * @param args - Action signal connections to add\n */\n public connectAction(...args: SignalConnection[]) {\n for (const connection of args) {\n this.signalConnections.add(connection);\n }\n }\n\n /**\n * Checks if this entity can collide with another entity\n */\n public canCollideWithEntity(entity: Entity): boolean {\n // Check if the entities can collide based on their collision layers and masks\n // A collision occurs when (A.layer & B.mask) !== 0 && (B.layer & A.mask) !== 0\n return (this.collisionLayer & entity.collisionMask) !== 0 && (entity.collisionLayer & this.collisionMask) !== 0;\n }\n\n /**\n * Sets the collision layer for this entity\n *\n * @param layer The collision layer or layers (can be combined with bitwise OR)\n */\n public setCollisionLayer(layer: number): void {\n this.collisionLayer = layer;\n }\n\n /**\n * Adds the specified layers to this entity's collision layer\n *\n * @param layers The layers to add (can be combined with bitwise OR)\n */\n public addCollisionLayer(layers: number): void {\n this.collisionLayer |= layers;\n }\n\n /**\n * Removes the specified layers from this entity's collision layer\n *\n * @param layers The layers to remove (can be combined with bitwise OR)\n */\n public removeCollisionLayer(layers: number): void {\n this.collisionLayer &= ~layers;\n }\n\n /**\n * Sets the collision mask for this entity\n *\n * @param mask The collision mask (can be combined with bitwise OR)\n */\n public setCollisionMask(...mask: number[]): void {\n this.collisionMask = this.physics.createCollisionMask(...mask);\n }\n\n /**\n * Adds the specified layers to this entity's collision mask\n *\n * @param layers The layers to add to the mask (can be combined with bitwise OR)\n */\n public addCollisionMask(layers: number): void {\n this.collisionMask |= layers;\n }\n\n /**\n * Removes the specified layers from this entity's collision mask\n *\n * @param layers The layers to remove from the mask (can be combined with bitwise OR)\n */\n public removeCollisionMask(layers: number): void {\n this.collisionMask &= ~layers;\n }\n\n /**\n * Checks if this entity belongs to a specific collision layer\n *\n * @param layer The layer to check\n * @returns True if the entity belongs to the specified layer\n *\n * @example\n * ```typescript\n * // Check if entity is on the PLAYER layer\n * if (entity.hasCollisionLayer(CollisionLayer.PLAYER)) {\n * console.log('Entity is a player');\n * }\n *\n * // Check if entity is on a custom layer\n * const WATER_LAYER = CollisionLayers.createLayer(0);\n * if (entity.hasCollisionLayer(WATER_LAYER)) {\n * console.log('Entity is water');\n * }\n * ```\n */\n public hasCollisionLayer(layer: number): boolean {\n return (this.collisionLayer & layer) !== 0;\n }\n\n /**\n * Checks if this entity can collide with a specific collision layer\n *\n * @param layer The layer to check\n * @returns True if the entity can collide with the specified layer\n *\n * @example\n * ```typescript\n * // Check if entity can collide with players\n * if (entity.canCollideWithLayer(CollisionLayer.PLAYER)) {\n * console.log('Entity can collide with players');\n * }\n *\n * // Check if entity can collide with a custom layer\n * const WATER_LAYER = CollisionLayers.createLayer(0);\n * if (entity.canCollideWithLayer(WATER_LAYER)) {\n * console.log('Entity can collide with water');\n * }\n * ```\n */\n public canCollideWithLayer(layer: number): boolean {\n return (this.collisionMask & layer) !== 0;\n }\n}\n","import { Entity } from './Entity';\nimport { Solid } from './Solid';\nimport {\n ActorCollisionResult,\n CollisionResult,\n EntityData,\n PhysicsEntityConfig,\n PhysicsEntityType,\n Vector2,\n} from './types';\n\n/**\n * Dynamic physics entity that can move and collide with other entities.\n * Actors are typically used for players, enemies, projectiles, and other moving game objects.\n *\n * Features:\n * - Velocity-based movement with gravity\n * - Collision detection and response\n * - Solid surface detection (riding)\n * - Automatic culling when out of bounds\n * - Actor-to-actor collision detection\n *\n * @typeParam T - Application type, defaults to base Application\n *\n * @example\n * ```typescript\n * // Create a player actor\n * class Player extends Actor {\n * constructor() {\n * super({\n * type: 'Player',\n * position: [100, 100],\n * size: [32, 64],\n * view: playerSprite\n * });\n * }\n *\n * // Handle collisions\n * onCollide(result: CollisionResult) {\n * if (result.solid.type === 'Spike') {\n * this.die();\n * }\n * }\n *\n * // Handle actor-to-actor collisions\n * onActorCollide(result: ActorCollisionResult) {\n * if (result.actor.type === 'Enemy') {\n * this.takeDamage(10);\n * }\n * }\n *\n * // Custom movement\n * update(dt: number) {\n * super.update(dt);\n *\n * // Move left/right\n * if (this.app.input.isKeyDown('ArrowLeft')) {\n * this.velocity.x = -200;\n * } else if (this.app.input.isKeyDown('ArrowRight')) {\n * this.velocity.x = 200;\n * }\n *\n * // Jump when on ground\n * if (this.app.input.isKeyPressed('Space') && this.isRidingSolid()) {\n * this.velocity.y = -400;\n * }\n * }\n * }\n * ```\n */\nexport class Actor<D extends EntityData = EntityData> extends Entity<D> {\n public readonly entityType: PhysicsEntityType = 'Actor';\n\n /** Current velocity in pixels per second */\n public velocity: Vector2 = { x: 0, y: 0 };\n\n /** Whether actor-to-actor collisions are disabled for this actor */\n public disableActorCollisions: boolean = false;\n\n /** Whether the actor should be removed when culled (out of bounds) */\n public shouldRemoveOnCull: boolean = true;\n\n /** List of current frame collisions */\n public collisions: CollisionResult[] = [];\n\n /** List of current frame actor-to-actor collisions */\n public actorCollisions: ActorCollisionResult[] = [];\n\n /** Cache for isRidingSolid check */\n private _isRidingSolidCache: boolean | null = null;\n\n /** Tracks which solid is currently carrying this actor in the current frame */\n private _carriedBy: Solid | null = null;\n private _carriedByOverlap: number = 0;\n\n /** Tracks the grid cells this actor currently occupies */\n private _currentGridCells: string[] = [];\n\n /**\n * Initialize or reinitialize the actor with new configuration.\n *\n * @param config - Configuration for the actor\n */\n public init(config: PhysicsEntityConfig<D>): void {\n super.init(config);\n // Reset velocity and carried state\n this.velocity = { x: 0, y: 0 };\n this._isRidingSolidCache = null;\n this._carriedBy = null;\n this._carriedByOverlap = 0;\n this.actorCollisions = [];\n this._currentGridCells = [];\n\n if (config.disableActorCollisions !== undefined) {\n this.disableActorCollisions = config.disableActorCollisions;\n }\n\n // Add actor to grid initially if actor collisions are enabled\n if (this.system.enableActorCollisions && !this.disableActorCollisions) {\n this.updateGridCells();\n }\n }\n\n /**\n * Called at the start of each update to prepare for collision checks.\n */\n public preUpdate(): void {\n if (!this.active) return;\n\n this.collisions = [];\n this.actorCollisions = [];\n // Reset the cache at the start of each update\n this._isRidingSolidCache = null;\n this._carriedBy = null;\n this._carriedByOverlap = 0;\n }\n\n /**\n * Updates the actor's position based on velocity and handles collisions.\n *\n * @param dt - Delta time in seconds\n */\n public update(dt: number): void {\n if (!this.active) return;\n\n // Ensure velocity is valid\n if (!this.isRidingSolid()) {\n this.velocity.y += this.system.gravity * dt;\n }\n\n // Clamp velocity\n this.velocity.x = Math.min(Math.max(this.velocity.x, -this.system.maxVelocity), this.system.maxVelocity);\n this.velocity.y = Math.min(Math.max(this.velocity.y, -this.system.maxVelocity), this.system.maxVelocity);\n\n // Move horizontally\n if (this.velocity.x !== 0) {\n this.moveX(this.velocity.x * dt);\n }\n\n // Move vertically\n if (this.velocity.y !== 0) {\n this.moveY(this.velocity.y * dt);\n }\n\n if (this.system.enableActorCollisions) {\n this.updateGridCells();\n }\n\n // Update view\n this.updateView();\n }\n\n /**\n * Called after update to handle post-movement effects.\n */\n public postUpdate(): void {\n if (!this.active) return;\n\n if (this.isRidingSolid()) {\n this.velocity.y = 0;\n }\n }\n\n /**\n * Resets the actor to its initial state.\n */\n public reset(): void {\n super.reset();\n\n this._isRidingSolidCache = null;\n this._carriedBy = null;\n this._carriedByOverlap = 0;\n this.velocity = { x: 0, y: 0 };\n\n this.updatePosition();\n }\n\n /**\n * Called when the actor is culled (goes out of bounds).\n * Override this to handle culling differently.\n */\n public onCull(): void {\n // Default behavior: destroy the view\n this.view?.destroy();\n }\n\n /**\n * Called when this actor collides with a solid.\n * Override this method to implement custom collision response.\n *\n * @param result - Information about the collision\n */\n public onCollide(result: CollisionResult): void {\n // Default implementation does nothing\n // Override this in your actor subclass to handle collisions\n void result;\n }\n\n /**\n * Called when this actor collides with another actor.\n * Override this method to implement custom actor-to-actor collision response.\n *\n * @param result - Information about the actor collision\n */\n public onActorCollide(result: ActorCollisionResult): void {\n // Default implementation does nothing\n // Override this in your actor subclass to handle actor-to-actor collisions\n void result;\n }\n\n /**\n * Checks if this actor is riding the given solid.\n * An actor is riding if it's directly above the solid.\n *\n * @param solid - The solid to check against\n * @returns True if riding the solid\n */\n public isRiding(solid: Solid): boolean {\n // Skip if solid has no collisions\n if (!solid.collideable) return false;\n\n // Check collision layers and masks\n // An actor can only ride a solid if their collision layers/masks allow interaction\n if ((this.collisionLayer & solid.collisionMask) === 0 || (solid.collisionLayer & this.collisionMask) === 0) {\n return false;\n }\n\n // If we're already being carried by a different solid this frame,\n // we can't be riding this one\n if (this._carriedBy && this._carriedBy !== solid) {\n return false;\n }\n\n // Must be directly above the solid (within 1 pixel)\n const actorBottom = this.y + this.height;\n const onTop = Math.abs(actorBottom - solid.y) <= 1;\n\n // Must be horizontally overlapping\n const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width;\n const overlapWidth = Math.min(this.x + this.width, solid.x + solid.width) - Math.max(this.x, solid.x);\n\n const isRiding = onTop && overlap;\n\n if (isRiding && overlapWidth > this._carriedByOverlap) {\n this._carriedBy = solid;\n this._carriedByOverlap = overlapWidth;\n }\n return isRiding;\n }\n\n /**\n * Checks if this actor is riding any solid in the physics system.\n * Uses caching to optimize multiple checks per frame.\n *\n * @returns True if riding any solid\n */\n public isRidingSolid(): boolean {\n // Return cached value if available\n if (this._isRidingSolidCache !== null) {\n return this._isRidingSolidCache;\n }\n\n // Calculate and cache the result\n const solids = this.getSolidsAt(this.x, this.y + 1);\n this._isRidingSolidCache = solids.some((solid) => this.isRiding(solid));\n return this._isRidingSolidCache;\n }\n\n /**\n * Called when the actor is squeezed between solids.\n * Override this to handle squishing differently.\n */\n public squish(result: CollisionResult): void {\n void result;\n // do something\n }\n\n /**\n * Updates the actor's grid cells in the spatial partitioning system.\n * This is called when the actor moves or when its size changes.\n */\n public updateGridCells(): void {\n // Skip if actor collisions are disabled system-wide or for this actor specifically\n if (this.disableActorCollisions) return;\n\n this.system.updateActorInGrid(this);\n }\n\n /**\n * Gets the current grid cells this actor occupies\n */\n public get currentGridCells(): string[] {\n return this._currentGridCells;\n }\n\n /**\n * Sets the current grid cells this actor occupies\n */\n public set currentGridCells(cells: string[]) {\n this._currentGridCells = cells;\n }\n\n /**\n * Moves the actor horizontally, checking for collisions with solids.\n *\n * @param amount - Distance to move in pixels\n * @param collisionHandler - Optional callback for handling collisions\n * @returns Array of collision results\n */\n public moveX(\n amount: number,\n collisionHandler?: (result: CollisionResult) => void,\n pushingSolid?: Solid,\n ): CollisionResult[] {\n // Early return if inactive or zero movement\n if (!this.active || amount === 0) return [];\n\n this._xRemainder += amount;\n const move = Math.round(this._xRemainder);\n\n // Early return if rounded movement is zero\n if (move === 0) return [];\n\n const collisions: CollisionResult[] = [];\n\n // Cache collision layer and mask for faster access\n const actorLayer = this.collisionLayer;\n const actorMask = this.collisionMask;\n\n // Skip collision checks if no collision mask\n if (actorMask === 0) {\n // Just move without checking collisions\n this._xRemainder -= move;\n this._x += move;\n this.updateView();\n\n return [];\n }\n\n this._xRemainder -= move;\n const sign = Math.sign(move);\n let remaining = Math.abs(move);\n const step = sign;\n\n // If we're being pushed by a solid, temporarily make it non-collidable\n if (pushingSolid) {\n pushingSolid.collideable = false;\n }\n\n // Move one pixel at a time, checking for collisions\n while (remaining > 0) {\n const nextX = this._x + step;\n\n // Get solids at the next position\n const solids = this.getSolidsAt(nextX, this._y);\n let collided = false;\n\n // Check for collisions with each solid\n for (const solid of solids) {\n // Skip if solid can't collide\n if (!solid.canCollide) continue;\n\n // Skip if collision layers don't match\n if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {\n continue;\n }\n\n // Calculate collision details\n const result: CollisionResult = {\n collided: true,\n solid,\n normal: { x: -sign, y: 0 },\n penetration: step > 0 ? this.x + this.width - solid.x : solid.x + solid.width - this.x,\n pushingSolid,\n };\n\n // Add to collisions array\n collisions.push(result);\n\n // Call collision handler if provided\n if (collisionHandler) {\n collisionHandler(result);\n }\n\n // Call actor's collision handler\n this.onCollide(result);\n\n collided = true;\n }\n\n if (collided) {\n // Stop movement on collision\n break;\n } else {\n // Move to next position\n this._x = nextX;\n remaining--;\n\n // Update view every few pixels for better performance\n // This reduces the number of view updates during movement\n if (remaining % 4 === 0 || remaining === 0) {\n this.updateView();\n }\n }\n }\n\n // Restore solid's collidable state\n if (pushingSolid) {\n pushingSolid.collideable = true;\n }\n\n // Final view update if we moved\n if (Math.abs(move) - remaining > 0) {\n this.updateView();\n }\n\n return collisions;\n }\n\n /**\n * Moves the actor vertically, checking for collisions with solids.\n *\n * @param amount - Distance to move in pixels\n * @param collisionHandler - Optional callback for handling collisions\n * @returns Array of collision results\n */\n public moveY(\n amount: number,\n collisionHandler?: (result: CollisionResult) => void,\n pushingSolid?: Solid,\n ): CollisionResult[] {\n // Early return if inactive or zero movement\n if (!this.active || amount === 0) return [];\n\n this._yRemainder += amount;\n const move = Math.round(this._yRemainder);\n\n // Early return if rounded movement is zero\n if (move === 0) return [];\n\n const collisions: CollisionResult[] = [];\n\n // Cache collision layer and mask for faster access\n const actorLayer = this.collisionLayer;\n const actorMask = this.collisionMask;\n\n // Skip collision checks if no collision mask\n if (actorMask === 0) {\n // Just move without checking collisions\n this._yRemainder -= move;\n this._y += move;\n this.updateView();\n\n return [];\n }\n\n this._yRemainder -= move;\n const sign = Math.sign(move);\n let remaining = Math.abs(move);\n const step = sign;\n\n // If we're being pushed by a solid, temporarily make it non-collidable\n if (pushingSolid) {\n pushingSolid.collideable = false;\n }\n\n // Move one pixel at a time, checking for collisions\n while (remaining > 0) {\n const nextY = this._y + step;\n\n // Get solids at the next position\n const solids = this.getSolidsAt(this._x, nextY);\n let collided = false;\n\n // Check for collisions with each solid\n for (const solid of solids) {\n // Skip if solid can't collide\n if (!solid.canCollide) continue;\n\n // Skip if collision layers don't match\n if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {\n continue;\n }\n\n // Calculate collision details\n const result: CollisionResult = {\n collided: true,\n solid,\n normal: { x: 0, y: -sign },\n penetration: step > 0 ? this.y + this.height - solid.y : solid.y + solid.height - this.y,\n pushingSolid,\n };\n\n // Add to collisions array\n collisions.push(result);\n\n // Call collision handler if provided\n if (collisionHandler) {\n collisionHandler(result);\n }\n\n // Call actor's collision handler\n this.onCollide(result);\n\n collided = true;\n }\n\n if (collided) {\n // Stop movement on collision\n break;\n } else {\n // Move to next position\n this._y = nextY;\n remaining--;\n\n // Update view every few pixels for better performance\n // This reduces the number of view updates during movement\n if (remaining % 4 === 0 || remaining === 0) {\n this.updateView();\n }\n }\n }\n\n // Restore solid's collidable state\n if (pushingSolid) {\n pushingSolid.collideable = true;\n }\n\n // Final view update if we moved\n if (Math.abs(move) - remaining > 0) {\n this.updateView();\n\n // Update grid cells if actor moved and actor collisions are enabled\n }\n\n return collisions;\n }\n\n /**\n * Updates the actor's view position.\n */\n public updateView(): void {\n if (this.view && this.view.visible) {\n this.view.x = this._x;\n this.view.y = this._y;\n }\n }\n\n /**\n * Gets all solids at the specified position that could collide with this actor.\n *\n * @param _x - X position to check\n * @param _y - Y position to check\n * @returns Array of solids at the position\n */\n protected getSolidsAt(_x: number, _y: number): Solid[] {\n return this.system.getSolidsAt(_x, _y, this);\n }\n\n /**\n * Checks if this actor is colliding with another actor.\n * The collision will only occur if:\n * 1. Both actors are active\n * 2. Neither actor has disabled actor collisions\n * 3. The collision layers and masks match:\n * - (this.collisionLayer & other.collisionMask) !== 0\n * - (other.collisionLayer & this.collisionMask) !== 0\n *\n * @param actor - The actor to check collision with\n * @returns Collision result with information about the collision\n */\n public checkActorCollision(actor: Actor): ActorCollisionResult {\n // Skip if either actor is not active\n if (!this.active || !actor.active) {\n return { collided: false, actor };\n }\n\n // Skip if either actor has disabled actor collisions\n if (this.disableActorCollisions || actor.disableActorCollisions) {\n return { collided: false, actor };\n }\n\n // Skip if the actors can't collide based on collision layers\n if ((this.collisionLayer & actor.collisionMask) === 0 || (actor.collisionLayer & this.collisionMask) === 0) {\n return { collided: false, actor };\n }\n\n // Simple AABB collision check\n const thisLeft = this.x;\n const thisRight = this.x + this.width;\n const thisTop = this.y;\n const thisBottom = this.y + this.height;\n\n const otherLeft = actor.x;\n const otherRight = actor.x + actor.width;\n const otherTop = actor.y;\n const otherBottom = actor.y + actor.height;\n\n // Check if the bounding boxes overlap\n if (thisRight > otherLeft && thisLeft < otherRight && thisBottom > otherTop && thisTop < otherBottom) {\n // Calculate penetration and normal\n const overlapX = Math.min(thisRight - otherLeft, otherRight - thisLeft);\n const overlapY = Math.min(