UNPKG

@rpgjs/physic

Version:

A deterministic 2D top-down physics library for RPG, sandbox and MMO games

402 lines (401 loc) 13.2 kB
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