UNPKG

@dill-pixel/plugin-snap-physics

Version:

Snap Physics

567 lines (501 loc) 16.5 kB
import { IApplication, ICamera, Logger, Signal } from 'dill-pixel'; import { Circle, Container, Graphics, Point, Rectangle } from 'pixi.js'; import { Collision, EntityType, Side, SpatialHashGridFilter } from './types'; import { Actor } from './Actor'; import { Entity } from './Entity'; import { Sensor } from './Sensor'; import { SnapPhysicsPlugin } from './SnapPhysicsPlugin'; import { Solid } from './Solid'; import { SpatialHashGrid } from './SpatialHashGrid'; import { Wall } from './Wall'; import { getCircleToCircleIntersectionArea, getRectToCircleIntersectionArea, getRectToRectIntersectionArea, } from './utils'; type SystemBoundary = { width: number; height: number; padding: number; }; type SnapPhysicsBoundaryOptions = { width: number; height: number; thickness: number; padding: number; sides: Side[]; }; type OptionalSnapPhysicsBoundaryOptions = Partial<SnapPhysicsBoundaryOptions>; type RequiredWidthAndHeight = Required<Pick<SnapPhysicsBoundaryOptions, 'width' | 'height'>>; type CustomSnapPhysicsBoundaryOptions = OptionalSnapPhysicsBoundaryOptions & RequiredWidthAndHeight; type SnapPhysicsSystemOptions = { gravity: number; fps: number; container: Container; debug: boolean; boundary: CustomSnapPhysicsBoundaryOptions; collisionResolver: (collision: Collision) => boolean; useSpatialHashGrid: boolean; cellSize: number; }; export class System { public static DEFAULT_COLLISION_THRESHOLD: number = 0; public static plugin: SnapPhysicsPlugin; public static app: IApplication; public static container: Container<any>; public static grid: SpatialHashGrid | null; public static fps: number = 60; // static debug: boolean = true; static typeMap: Map<EntityType, Entity[]> = new Map(); static actors: Actor[] = []; static solids: Solid[] = []; static sensors: Sensor[] = []; static gravity: number = 10; static onCollision: Signal<(collision: Collision) => void> = new Signal<(collision: Collision) => void>(); static worldBounds: Wall[] = []; static boundary: SystemBoundary; static camera?: ICamera; static collisionThreshold = 8; static updateHooks: Set<(deltaTime: number) => void> = new Set(); static postUpdateHooks: Set<(deltaTime: number) => void> = new Set(); private static _cleaningUp: boolean = false; private static gfx: Graphics; private static _enabled: boolean = false; private static _fixedTimeStep: number = 1000 / System.fps; // Default 60 FPS private static _fixedUpdateInterval: any = null; public static onSystemEnabledChanged: Signal<(enabled: boolean) => void> = new Signal<(enabled: boolean) => void>(); static get enabled() { return System._enabled; } static set enabled(value: boolean) { if (!System._cleaningUp && value === System._enabled) return; System._enabled = value; // clear the interval if we are disabling if (System._enabled) { // Start fixed update loop System._fixedUpdateInterval = setInterval(() => { System.fixedUpdate(System._fixedTimeStep / 1000); }, System._fixedTimeStep); } else { // Stop fixed update loop if (System._fixedUpdateInterval) { clearInterval(System._fixedUpdateInterval); System._fixedUpdateInterval = null; } } if (!System._cleaningUp) { // don't send the signal if we are cleaning up return; } System.onSystemEnabledChanged.emit(value); } private static _collisionResolver: ((collision: Collision) => boolean) | null = null; static set collisionResolver(collisionResolverMethod: (collision: Collision) => boolean) { System._collisionResolver = collisionResolverMethod; } static get worldWidth() { return System.boundary?.width ? System.boundary.width + (System.boundary.padding ?? 0) : System.container.width; } static get worldHeight() { return System.boundary?.height ? System.boundary.height + (System.boundary.padding ?? 0) : System.container.height; } static get all(): Entity[] { return [...System.actors, ...System.solids, ...System.sensors]; } static get totalEntities(): number { return System.actors.length + System.solids.length + System.sensors.length; } static useSpatialHashGrid(cellSize: number) { if (System.grid) { System.grid.cellSize = cellSize; } else { System.grid = new SpatialHashGrid(cellSize, true); } System.plugin.options.useSpatialHashGrid = true; } static removeSpatialHashGrid() { if (System.grid) { System.grid.destroy(); System.grid = null; } } static resolveCollision(collision: Collision) { // Implement collision resolution logic return System._collisionResolver ? System._collisionResolver(collision) : true; } static addEntity(entity: Entity) { if (!System.typeMap.has(entity.type)) { System.typeMap.set(entity.type, []); } System.typeMap.get(entity.type)!.push(entity); if (System.grid) { System.grid.insert(entity); } } static removeEntity(entity: Entity) { if (System.grid) { System.grid.remove(entity); } if (System.typeMap.has(entity.type)) { const entities = System.typeMap.get(entity.type)!; const index = entities.indexOf(entity); if (index !== -1) { entities.splice(index, 1); } } } static getEntitiesByType<T extends Entity = Entity>(...type: EntityType[]): T[] { if (type.length === 0) { return (System.typeMap.get(type[0]) as T[]) || []; } return type.reduce((acc: T[], t: EntityType) => { const entities = System.typeMap.get(t) as T[]; if (entities?.length) { return [...acc, ...entities]; } return acc; }, []); } static addActor(actor: Actor) { System.actors.push(actor); System.addEntity(actor); } static addSolid(solid: Solid) { System.solids.push(solid); System.addEntity(solid); } static addSensor(sensor: Sensor) { System.sensors.push(sensor); System.addEntity(sensor); } static removeActor(actor: Actor) { System.removeEntity(actor); const index = System.actors.indexOf(actor); if (index !== -1) { System.actors.splice(index, 1); } } static removeSolid(solid: Solid) { System.removeEntity(solid); const index = System.solids.indexOf(solid); if (index !== -1) { System.solids.splice(index, 1); } } static removeSensor(sensor: Sensor) { System.removeEntity(sensor); const index = System.sensors.indexOf(sensor); if (index !== -1) { System.sensors.splice(index, 1); } } static getNearbyEntities<T extends Entity = Entity>( entity: Entity, filter?: SpatialHashGridFilter, dx: number = 0, dy: number = 0, debug?: boolean, ): Set<T> { if (System.grid) { const bounds = entity.boundingRect; return System.grid.query<T>(bounds, filter, dx, dy, entity, debug); } const filtered = System.all.filter((e: Entity) => { if (e.uid === entity.uid) { return false; } if (filter) { switch (typeof filter) { case 'string': return ( filter === e.type || (filter === 'solid' && e.isSolid) || (filter === 'actor' && e.isActor) || (filter === 'sensor' && e.isSensor) ); case 'object': return Array.isArray(filter) && filter.includes(e.type); case 'function': return filter(e); default: return false; } } return true; }); return new Set<T>(filtered as T[]); } static roundBoundingBox(bb: Rectangle) { // round everything bb.x = Math.round(bb.x); bb.y = Math.round(bb.y); bb.width = Math.round(bb.width); bb.height = Math.round(bb.height); return bb; } /** * @param entity1 * @param entity2 * @param dx * @param dy */ static getRectangleIntersection(entity1: Entity, entity2: Entity, dx: number, dy: number): boolean { const bounds1 = entity1.getBoundingBox() as Rectangle; const bounds2 = entity2.getBoundingBox().clone() as Rectangle; bounds2.x += dx; bounds2.y += dy; const intersection = getRectToRectIntersectionArea(bounds1, bounds2); return intersection.area > 0 && intersection.area > System.collisionThreshold; } /** * @param entity1 * @param entity2 * @param dx * @param dy */ static getCircleToCircleIntersection(entity1: Entity, entity2: Entity, dx: number, dy: number): boolean { const bounds1 = entity1.getBoundingBox() as Circle; const bounds2 = entity2.getBoundingBox().clone() as Circle; bounds2.x += dx; bounds2.y += dy; const intersection = getCircleToCircleIntersectionArea(bounds1, bounds2); return intersection.area > 0 && intersection.area > System.collisionThreshold; } /** * @param entity1 * @param entity2 * @param dx * @param dy */ static getRectToCircletIntersection(entity1: Entity, entity2: Entity, dx: number, dy: number): boolean { const bounds1 = entity1.getBoundingBox() as Rectangle; const bounds2 = entity2.getBoundingBox().clone() as Circle; bounds2.x += dx; bounds2.y += dy; const intersection = getRectToCircleIntersectionArea(bounds1, bounds2); return intersection.area > 0 && intersection.area > System.collisionThreshold; } static fixedUpdate(deltaTime: number) { if (!System.enabled) { return; } if (!System.container) { Logger.error('SnapPhysicsPlugin: World container not set!'); } // pre-update phase System.all.forEach((entity: Entity) => { entity.preFixedUpdate(); }); // update hooks if (System.updateHooks) { System.updateHooks.forEach((hook) => hook(deltaTime)); } // Fixed update phases System.solids.forEach((solid: Solid) => { solid.fixedUpdate(deltaTime); }); System.sensors.forEach((sensor: Sensor) => { sensor.fixedUpdate(deltaTime); }); System.actors.forEach((actor: Actor) => { actor.fixedUpdate(deltaTime); }); System.all.forEach((entity: Entity) => { entity.postFixedUpdate(); }); if (System.camera) { System.camera.update(); } if (System.debug) { System.drawDebug(); } else { if (System.gfx) { System.gfx.clear(); } } } static addBoundary( width: number, height: number, size: number = 10, padding: number = 5, sides: Side[] = ['top', 'bottom', 'left', 'right'], ) { if (!System.container) { throw new Error('System container not set. Set World.container before calling System.addBoundary().'); } if (System.worldBounds.length > 0) { // World bounds already added // remove existing bounds System.worldBounds.forEach((wall: Wall) => { wall.parent.removeChild(wall); wall.destroy(); }); System.worldBounds = []; } const pos = new Point(0, 0); const container = System.container; let wall: Wall; if (sides.includes('bottom')) { wall = container.addChild(new Wall({ width, height: size })); wall.position.set(pos.x + width * 0.5, pos.y + height + size * 0.5 - padding); System.worldBounds.push(wall); } if (sides.includes('top')) { wall = container.addChild(new Wall({ width, height: size })); wall.position.set(pos.x + width * 0.5, pos.y - size * 0.5 + padding); System.worldBounds.push(wall); } if (sides.includes('left')) { wall = container.addChild(new Wall({ width: size, height })); wall.position.set(pos.x - size * 0.5 + padding, pos.y + height * 0.5); System.worldBounds.push(wall); } if (sides.includes('right')) { wall = container.addChild(new Wall({ width: size, height })); wall.position.set(pos.x + width - padding + size * 0.5, pos.y + height * 0.5); System.worldBounds.push(wall); } if (System.grid) { System.worldBounds.forEach((wall: Wall) => { System.grid?.remove(wall); System.grid?.insert(wall); }); } } static collide(collision: Collision) { if (!collision.type && collision.entity1 && collision.entity2) { collision.type = `${collision.entity1.type}|${collision.entity2.type}`; } this.onCollision.emit(collision); } static drawDebug() { if (!System.container) { return; } if (!System.gfx) { System.gfx = new Graphics(); System.container.addChild(System.gfx); } // move to top System.container.setChildIndex(System.gfx, System.container.children.length - 1); System.gfx.clear(); [...System.actors, ...System.solids, ...System.sensors].forEach((entity: Entity) => { const bounds = entity.getBoundingBox(); const outerBounds = entity.getOuterBoundingBox(); if (entity.isCircle) { const circBounds = bounds as Circle; System.gfx .circle(circBounds.x, circBounds.y, circBounds.radius) .stroke({ width: 1, color: entity.debugColors.bounds, alignment: 0.5, pixelLine: true }); if (outerBounds) { const outerCircBounds = outerBounds as Circle; System.gfx .circle( outerCircBounds.x + outerCircBounds.radius, outerCircBounds.y + outerCircBounds.radius, outerCircBounds.radius, ) .stroke({ width: 1, color: entity.debugColors.outerBounds, alignment: 0.5, pixelLine: true }); } } else { const rectBounds = bounds as Rectangle; System.gfx .rect(rectBounds.x, rectBounds.y, rectBounds.width, rectBounds.height) .stroke({ width: 1, color: entity.debugColors.bounds, alignment: 0.5 }); if (outerBounds) { const outerRectBounds = outerBounds as Rectangle; System.gfx .rect(outerRectBounds.x, outerRectBounds.y, outerRectBounds.width, outerRectBounds.height) .stroke({ width: 1, color: entity.debugColors.outerBounds, alignment: 0.5, pixelLine: true }); } } }); if (System.grid) { System.grid.draw(System.gfx); } } static setContainer(container: Container) { System.container = container; } static initialize(opts: Partial<SnapPhysicsSystemOptions>) { if (opts.gravity) { System.gravity = opts.gravity; } if (opts.fps) { System.fps = opts.fps; System._fixedTimeStep = 1000 / opts.fps; } if (opts.container) { System.setContainer(opts.container); } if (opts.debug !== undefined) { System.debug = opts.debug; } if (opts.collisionResolver) { System.collisionResolver = opts.collisionResolver; } if (opts.boundary) { System.boundary = { width: opts.boundary.width, height: opts.boundary.height, padding: opts.boundary.padding ?? 0, }; if (opts.boundary.width && opts.boundary.height) { System.addBoundary( opts.boundary.width, opts.boundary.height, opts.boundary.thickness, opts.boundary.padding, opts.boundary.sides, ); } else { Logger.error('SnapPhysicsPlugin System.initialize: Boundary width and height required.'); } } if (opts.useSpatialHashGrid) { System.useSpatialHashGrid(opts.cellSize ?? 100); } } static updateEntity(entity: Entity) { if (System.grid) { System.grid.updateEntity(entity); } } static cleanup() { System._cleaningUp = true; if (System.worldBounds) { System.worldBounds.forEach((wall: Wall) => { wall.parent.removeChild(wall); wall.destroy(); }); System.worldBounds = []; } if (System.container) { System.container.removeChildren(); // @ts-expect-error container can't be null System.container = null; } if (System.gfx) { System.gfx.clear(); // @ts-expect-error GFX can't be null System.gfx = null; } if (System.grid) { System.grid.destroy(); System.grid = null; } if (System.camera) { // @ts-expect-error camera can't be null System.camera = null; } System.enabled = false; // This will clear the interval System.solids = []; System.actors = []; System.sensors = []; System.typeMap.clear(); System.worldBounds = []; System._cleaningUp = false; } }