@rpgjs/physic
Version:
A deterministic 2D top-down physics library for RPG, sandbox and MMO games
402 lines (401 loc) • 13.2 kB
JavaScript
import { Entity } from "./index7.js";
import { IntegrationMethod, Integrator } from "./index8.js";
import { CollisionResolver } from "./index17.js";
import { SpatialHash } from "./index14.js";
import { testCollision, createCollider } from "./index18.js";
import { EventSystem } from "./index24.js";
import { Ray } from "./index21.js";
import { sweepEntities } from "./index22.js";
class World {
/**
* Creates a new physics world
*
* @param config - World configuration
*/
constructor(config = {}) {
this.entities = /* @__PURE__ */ new Set();
this.staticEntities = /* @__PURE__ */ new Set();
this.dynamicEntities = /* @__PURE__ */ new Set();
this.previousCollisions = /* @__PURE__ */ new Map();
this.queryResults = /* @__PURE__ */ new Set();
this.timeStep = config.timeStep ?? 1 / 60;
this.enableSleep = config.enableSleep ?? true;
this.tileWidth = config.tileWidth ?? 32;
this.tileHeight = config.tileHeight ?? 32;
this.sleepThreshold = config.sleepThreshold ?? 0.5;
this.sleepVelocityThreshold = config.sleepVelocityThreshold ?? 0.01;
this.positionQuantizationStep = typeof config.positionQuantizationStep === "number" && config.positionQuantizationStep > 0 ? config.positionQuantizationStep : null;
this.velocityQuantizationStep = typeof config.velocityQuantizationStep === "number" && config.velocityQuantizationStep > 0 ? config.velocityQuantizationStep : null;
const integratorConfig = {
deltaTime: this.timeStep,
method: config.integrationMethod ?? IntegrationMethod.Euler
};
if (config.gravity) {
integratorConfig.gravity = config.gravity;
}
this.integrator = new Integrator(integratorConfig);
this.resolverIterations = Math.max(1, Math.floor(config.resolverIterations ?? 3));
const resolverConfig = {};
if (config.positionCorrectionFactor !== void 0) resolverConfig.positionCorrectionFactor = config.positionCorrectionFactor;
if (config.maxPositionCorrection !== void 0) resolverConfig.maxPositionCorrection = config.maxPositionCorrection;
if (config.minPenetrationDepth !== void 0) resolverConfig.minPenetrationDepth = config.minPenetrationDepth;
this.resolver = new CollisionResolver(resolverConfig);
if (config.spatialPartition) {
this.spatialPartition = config.spatialPartition;
} else {
this.spatialPartition = new SpatialHash(
config.spatialCellSize ?? 100,
config.spatialGridWidth ?? 100,
config.spatialGridHeight ?? 100
);
}
this.events = new EventSystem();
}
/**
* Gets the event system
*
* @returns Event system instance
*/
getEvents() {
return this.events;
}
/**
* Returns the fixed simulation time step.
*
* @returns Time step in seconds
*/
getTimeStep() {
return this.timeStep;
}
/**
* Adds an entity to the world
*
* @param entity - Entity to add
* @returns The added entity
*/
addEntity(entity) {
this.entities.add(entity);
if (entity.isStatic()) {
this.staticEntities.add(entity);
} else {
this.dynamicEntities.add(entity);
}
this.spatialPartition.insert(entity);
this.events.emitEntityAdded(entity);
return entity;
}
/**
* Performs a raycast against all entities in the world.
*
* @param origin - Starting point of the ray
* @param direction - Direction of the ray (normalized)
* @param length - Maximum length (default: Infinity)
* @param mask - Optional collision mask (layer)
* @param filter - Optional filter function (return true to include entity)
* @returns Raycast hit info if hit, null otherwise
*/
raycast(origin, direction, length = Infinity, mask, filter) {
const ray = new Ray(origin, direction, length);
return this.spatialPartition.raycast(ray, mask, filter);
}
/**
* Creates and adds a new entity
*
* @param config - Entity configuration
* @returns Created entity
*/
createEntity(config) {
const entity = new Entity(config);
return this.addEntity(entity);
}
/**
* Removes an entity from the world
*
* @param entity - Entity to remove
*/
removeEntity(entity) {
if (this.entities.delete(entity)) {
this.staticEntities.delete(entity);
this.dynamicEntities.delete(entity);
this.spatialPartition.remove(entity);
this.events.emitEntityRemoved(entity);
}
}
/**
* Gets all entities in the world
*
* @returns Array of entities
*/
getEntities() {
return Array.from(this.entities);
}
/**
* Gets an entity by UUID
*
* @param uuid - Entity UUID
* @returns Entity or undefined
*/
getEntityByUUID(uuid) {
for (const entity of this.entities) {
if (entity.uuid === uuid) {
return entity;
}
}
return void 0;
}
/**
* Steps the physics simulation forward
*
* Updates all entities, detects and resolves collisions.
*/
step() {
this.refreshDynamicEntitiesInPartition();
for (const entity of this.dynamicEntities) {
if (!entity.isSleeping()) {
const startPos = entity.position.clone();
this.integrator.integrate(entity);
this.updateEntityTile(entity, startPos);
if (entity.continuous) {
this.performCCD(entity);
}
}
}
let firstPassCollisions = [];
for (let iteration = 0; iteration < this.resolverIterations; iteration++) {
const collisions = this.detectCollisions();
if (iteration === 0) {
firstPassCollisions = collisions;
}
if (collisions.length === 0) {
break;
}
this.sortCollisionsForDeterminism(collisions);
this.resolver.resolveAll(collisions);
if (iteration + 1 < this.resolverIterations) {
this.refreshDynamicEntitiesInPartition();
}
}
if (this.positionQuantizationStep !== null || this.velocityQuantizationStep !== null) {
this.quantizeEntities();
}
this.handleCollisionEvents(firstPassCollisions);
if (this.enableSleep) {
this.updateSleepState();
}
}
/**
* Detects collisions using spatial partition
*
* @returns Array of collision infos
*/
/**
* Detects collisions using spatial partition
*
* @returns Array of collision infos
*/
detectCollisions() {
const collisions = [];
for (const entity of this.dynamicEntities) {
const nearby = this.spatialPartition.query(entity, this.queryResults);
for (const other of nearby) {
if (other.isDynamic() && entity.uuid > other.uuid) {
continue;
}
const collision = testCollision(entity, other);
if (collision) {
collisions.push(collision);
}
}
}
return collisions;
}
sortCollisionsForDeterminism(collisions) {
collisions.sort((a, b) => {
const keyA = this.getCollisionKey(a);
const keyB = this.getCollisionKey(b);
return keyA.localeCompare(keyB);
});
}
getCollisionKey(collision) {
const idA = collision.entityA.uuid;
const idB = collision.entityB.uuid;
return idA < idB ? `${idA}-${idB}` : `${idB}-${idA}`;
}
/**
* Handles collision enter/exit events
*
* @param collisions - Current frame collisions
*/
handleCollisionEvents(collisions) {
const currentCollisions = /* @__PURE__ */ new Map();
for (const collision of collisions) {
const pairKey = collision.entityA.uuid < collision.entityB.uuid ? `${collision.entityA.uuid}-${collision.entityB.uuid}` : `${collision.entityB.uuid}-${collision.entityA.uuid}`;
currentCollisions.set(pairKey, collision);
if (!this.previousCollisions.has(pairKey)) {
this.events.emitCollisionEnter(collision);
collision.entityA.notifyCollisionEnter(collision, collision.entityB);
collision.entityB.notifyCollisionEnter(collision, collision.entityA);
}
}
for (const [pairKey, collision] of this.previousCollisions) {
if (!currentCollisions.has(pairKey)) {
this.events.emitCollisionExit(collision);
collision.entityA.notifyCollisionExit(collision, collision.entityB);
collision.entityB.notifyCollisionExit(collision, collision.entityA);
}
}
this.previousCollisions = currentCollisions;
}
/**
* Updates sleep state for entities
*/
updateSleepState() {
for (const entity of this.entities) {
if (entity.isStatic() || entity.isSleeping()) {
continue;
}
const speed = entity.velocity.length();
const angularSpeed = Math.abs(entity.angularVelocity);
if (speed < this.sleepVelocityThreshold && angularSpeed < this.sleepVelocityThreshold) {
entity.timeSinceMovement += this.timeStep;
if (entity.timeSinceMovement >= this.sleepThreshold) {
entity.sleep();
this.events.emitEntitySleep(entity);
}
} else {
entity.timeSinceMovement = 0;
if (entity.isSleeping()) {
entity.wakeUp();
this.events.emitEntityWake(entity);
}
}
}
}
/**
* Clears all entities from the world
*/
clear() {
for (const entity of this.entities) {
this.events.emitEntityRemoved(entity);
}
this.entities.clear();
this.spatialPartition.clear();
this.previousCollisions.clear();
}
quantizeEntities() {
for (const entity of this.dynamicEntities) {
if (this.positionQuantizationStep !== null) {
entity.position.x = this.quantizeValue(entity.position.x, this.positionQuantizationStep);
entity.position.y = this.quantizeValue(entity.position.y, this.positionQuantizationStep);
}
if (this.velocityQuantizationStep !== null) {
entity.velocity.x = this.quantizeValue(entity.velocity.x, this.velocityQuantizationStep);
entity.velocity.y = this.quantizeValue(entity.velocity.y, this.velocityQuantizationStep);
}
}
}
quantizeValue(value, step) {
return Math.round(value / step) * step;
}
refreshDynamicEntitiesInPartition() {
for (const entity of this.dynamicEntities) {
this.spatialPartition.update(entity);
}
}
/**
* Gets statistics about the world
*
* @returns Statistics object
*/
getStats() {
let dynamic = 0;
let static_ = 0;
let sleeping = 0;
for (const entity of this.entities) {
if (entity.isStatic()) {
static_++;
} else {
dynamic++;
}
if (entity.isSleeping()) {
sleeping++;
}
}
return {
totalEntities: this.entities.size,
dynamicEntities: dynamic,
staticEntities: static_,
sleepingEntities: sleeping
};
}
/**
* Performs Continuous Collision Detection (CCD) for an entity
*
* @param entity - Entity to check
*/
/**
* Updates entity tile position and triggers hooks
*
* @param entity - Entity to update
* @param previousPosition - Position before integration
*/
updateEntityTile(entity, previousPosition) {
const oldTileX = Math.floor(previousPosition.x / this.tileWidth);
const oldTileY = Math.floor(previousPosition.y / this.tileHeight);
const newTileX = Math.floor(entity.position.x / this.tileWidth);
const newTileY = Math.floor(entity.position.y / this.tileHeight);
if (entity.currentTile.x === 0 && entity.currentTile.y === 0 && (oldTileX !== 0 || oldTileY !== 0)) {
entity.currentTile.set(oldTileX, oldTileY);
}
if (newTileX !== oldTileX || newTileY !== oldTileY) {
if (!entity.checkCanEnterTile(newTileX, newTileY)) {
entity.position.copyFrom(previousPosition);
entity.velocity.set(0, 0);
return;
}
entity.notifyLeaveTile(oldTileX, oldTileY);
entity.currentTile.set(newTileX, newTileY);
entity.notifyEnterTile(newTileX, newTileY);
}
}
performCCD(entity) {
const dt = this.timeStep;
const delta = entity.velocity.mul(dt);
const dist = delta.length();
if (dist < entity.radius) {
return;
}
const collider = createCollider(entity);
if (!collider) return;
const currentBounds = collider.getBounds();
const originalBounds = currentBounds.translate(-delta.x, -delta.y);
const sweptBounds = currentBounds.union(originalBounds);
const nearby = this.spatialPartition.queryAABB(sweptBounds);
let minTime = 1;
let collision = null;
for (const other of nearby) {
if (other === entity || !other.isStatic()) continue;
if (!entity.canCollideWith(other)) continue;
const originalPos = entity.position.clone();
entity.position.subInPlace(delta);
const hit = sweepEntities(entity, other, delta);
entity.position.copyFrom(originalPos);
if (hit && hit.time < minTime) {
minTime = hit.time;
collision = hit;
}
}
if (collision && minTime < 1) {
const correction = collision.normal.mul(1e-3);
entity.position.subInPlace(delta.mul(1 - minTime)).addInPlace(correction);
const vn = entity.velocity.dot(collision.normal);
if (vn < 0) {
entity.velocity.subInPlace(collision.normal.mul(vn));
}
}
}
}
export {
World
};
//# sourceMappingURL=index23.js.map