@dill-pixel/plugin-snap-physics
Version:
Snap Physics
567 lines (501 loc) • 16.5 kB
text/typescript
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;
}
}