@rpgjs/physic
Version:
A deterministic 2D top-down physics library for RPG, sandbox and MMO games
472 lines (471 loc) • 12.7 kB
JavaScript
import { World } from "./index23.js";
import { Entity } from "./index7.js";
import { RegionManager } from "./index26.js";
import { assignPolygonCollider } from "./index19.js";
import { raycast } from "./index20.js";
import { sweepEntities } from "./index22.js";
import { MovementManager } from "./index30.js";
import { ZoneManager } from "./index29.js";
class PhysicsEngine {
/**
* Creates a new physics engine
*
* @param config - Engine configuration
*/
constructor(config = {}) {
this.regionManager = null;
this.movementManager = null;
this.zoneManager = null;
this.tick = 0;
this.useRegions = config.enableRegions ?? false;
if (this.useRegions) {
if (!config.regionConfig) {
throw new Error("Region configuration is required when enableRegions is true");
}
this.regionManager = new RegionManager(config.regionConfig);
this.world = new World({
...config,
enableSleep: false
// Regions handle sleep
});
} else {
this.world = new World(config);
}
}
/**
* Gets the movement manager bound to this engine.
*
* The manager is lazily created and reused.
*
* @returns Movement manager instance
*/
getMovementManager() {
if (!this.movementManager) {
this.movementManager = MovementManager.forEngine(this);
}
return this.movementManager;
}
/**
* Gets the zone manager bound to this engine.
*
* The manager is lazily created and reused. Zones allow detecting entities
* within circular or cone-shaped areas without physical collisions (useful
* for vision, skill ranges, explosions, etc.).
*
* **Important:** Call `zoneManager.update()` after each physics step to
* keep zones synchronized:
*
* ```typescript
* engine.step();
* engine.getZoneManager().update();
* ```
*
* @returns Zone manager instance
*
* @example
* ```typescript
* const zones = engine.getZoneManager();
* const visionZone = zones.createAttachedZone(player, {
* radius: 100,
* angle: 90,
* direction: 'right',
* }, {
* onEnter: (entities) => console.log('Player sees:', entities),
* });
*
* engine.step();
* zones.update();
* ```
*/
getZoneManager() {
if (!this.zoneManager) {
this.zoneManager = new ZoneManager(this);
}
return this.zoneManager;
}
/**
* Updates all registered movement strategies.
*
* @param dt - Time delta in seconds (defaults to the world's time step)
*/
updateMovements(dt) {
const manager = this.getMovementManager();
const delta = dt ?? this.world.getTimeStep();
manager.update(delta);
}
/**
* Updates movements and then steps the simulation.
*
* @param dt - Time delta in seconds (defaults to the world's time step)
*/
stepWithMovements(dt) {
this.updateMovements(dt);
this.step();
}
/**
* Advances the simulation by exactly one fixed tick.
*
* This helper is equivalent to {@link step} but returns the tick index after the step,
* making it convenient for client-side prediction loops.
*
* @returns Current tick index after stepping
*/
stepOneTick() {
this.step();
return this.tick;
}
/**
* Advances the simulation by a fixed number of ticks.
*
* @param ticks - Number of ticks to simulate (>= 1)
* @returns Current tick index after stepping
*/
stepTicks(ticks) {
if (!Number.isFinite(ticks) || ticks <= 0) {
return this.tick;
}
const total = Math.floor(ticks);
for (let i = 0; i < total; i += 1) {
this.step();
}
return this.tick;
}
/**
* Creates a new entity
*
* @param config - Entity configuration
* @returns Created entity
*
* @example
* ```typescript
* const entity = engine.createEntity({
* position: { x: 100, y: 100 },
* radius: 15,
* mass: 1,
* velocity: { x: 5, y: 0 }
* });
* ```
*/
createEntity(config) {
const entity = new Entity(config);
if (this.useRegions && this.regionManager) {
this.regionManager.addEntity(entity);
} else {
this.world.addEntity(entity);
}
return entity;
}
/**
* Adds an existing entity to the engine
*
* @param entity - Entity to add
* @returns The added entity
*/
addEntity(entity) {
if (this.useRegions && this.regionManager) {
this.regionManager.addEntity(entity);
} else {
this.world.addEntity(entity);
}
return entity;
}
/**
* Removes an entity from the engine
*
* @param entity - Entity to remove
*/
removeEntity(entity) {
if (this.useRegions && this.regionManager) {
this.regionManager.removeEntity(entity);
} else {
this.world.removeEntity(entity);
}
}
/**
* Gets all entities
*
* @returns Array of all entities
*/
getEntities() {
if (this.useRegions && this.regionManager) {
const entities = [];
for (const region of this.regionManager.getRegions()) {
entities.push(...region.getEntities());
}
return entities;
}
return this.world.getEntities();
}
/**
* Gets an entity by UUID
*
* @param uuid - Entity UUID
* @returns Entity or undefined
*/
getEntityByUUID(uuid) {
if (this.useRegions && this.regionManager) {
for (const region of this.regionManager.getRegions()) {
const entity = region.getWorld().getEntityByUUID(uuid);
if (entity) {
return entity;
}
}
return void 0;
}
return this.world.getEntityByUUID(uuid);
}
/**
* Steps the physics simulation forward
*
* Updates all entities, detects and resolves collisions.
*/
step() {
if (this.useRegions && this.regionManager) {
this.regionManager.step();
} else {
this.world.step();
}
this.tick += 1;
}
/**
* Gets the event system
*
* @returns Event system instance
*/
getEvents() {
return this.world.getEvents();
}
/**
* Applies a force to an entity
*
* @param entity - Entity to apply force to
* @param force - Force vector
*/
applyForce(entity, force) {
entity.applyForce(force);
}
/**
* Applies an impulse to an entity
*
* @param entity - Entity to apply impulse to
* @param impulse - Impulse vector
*/
applyImpulse(entity, impulse) {
entity.applyImpulse(impulse);
}
/**
* Teleports an entity to a new position
*
* @param entity - Entity to teleport
* @param position - New position
*/
teleport(entity, position) {
entity.teleport(position);
if (this.useRegions && this.regionManager) {
this.regionManager.updateEntities();
}
}
/**
* Freezes an entity (makes it static)
*
* @param entity - Entity to freeze
*/
freeze(entity) {
entity.freeze();
}
/**
* Unfreezes an entity (makes it dynamic)
*
* @param entity - Entity to unfreeze
*/
unfreeze(entity) {
entity.unfreeze();
}
/**
* Queries entities in an AABB region
*
* @param bounds - AABB to query
* @returns Array of entities in the region
*/
queryAABB(bounds) {
if (this.useRegions && this.regionManager) {
const entities = [];
const regions = this.regionManager.getRegionsInBounds(bounds);
for (const region of regions) {
const world2 = region.getWorld();
const worldEntities = world2.getEntities();
for (const entity of worldEntities) {
if (bounds.contains(entity.position)) {
entities.push(entity);
}
}
}
return entities;
}
const world = this.world;
if (world.spatialPartition) {
return Array.from(world.spatialPartition.queryAABB(bounds));
}
return this.world.getEntities().filter((e) => bounds.contains(e.position));
}
/**
* Clears all entities from the engine
*/
clear() {
if (this.useRegions && this.regionManager) {
this.regionManager.clear();
} else {
this.world.clear();
}
this.tick = 0;
}
/**
* Assigns a polygon collider to an entity (supports convex or concave via convex parts).
*
* Design: the collider is attached via a registry and used by the detector on demand.
* This keeps entities lightweight and preserves the separation of detection/resolution.
*
* @param entity - Target entity
* @param config - Polygon configuration
* @example
* ```typescript
* engine.assignPolygonCollider(entity, { vertices: [new Vector2(-1,-1), new Vector2(1,-1), new Vector2(1,1), new Vector2(-1,1)], isConvex: true });
* ```
*/
assignPolygonCollider(entity, config) {
assignPolygonCollider(entity, config);
}
/**
* Casts a ray in the physics world and returns the nearest hit, if any.
* Uses the world's spatial partition for broad-phase and shape-specific narrow-phase tests.
*
* @param origin - Ray origin
* @param direction - Ray direction (any length)
* @param maxDistance - Maximum cast length
* @param mask - Optional collision mask (layer)
* @param filter - Optional filter function (return true to include entity)
* @returns Raycast hit or null
* @example
* ```typescript
* const hit = engine.raycast(new Vector2(0,0), new Vector2(1,0), 1000);
* ```
*/
raycast(origin, direction, maxDistance, mask, filter) {
const world = this.world;
const partition = world.spatialPartition;
if (!partition) return null;
return raycast(partition, origin, direction, maxDistance, mask, filter);
}
/**
* Computes continuous collision detection (sweep test) time-of-impact between two entities
* over the next step of duration `dt`, using relative motion.
*
* @param a - First entity
* @param b - Second entity
* @param dt - Time step duration
* @returns Sweep result or null if no hit in [0,1]
* @example
* ```typescript
* const toi = engine.sweep(entityA, entityB, 1/60);
* if (toi) {
* // pre-resolve or clamp motion
* }
* ```
*/
sweep(a, b, dt) {
const rel = a.velocity.sub(b.velocity).mul(dt);
return sweepEntities(a, b, rel);
}
/**
* Gets statistics about the engine
*
* @returns Statistics object
*/
getStats() {
if (this.useRegions && this.regionManager) {
const regionStats = this.regionManager.getStats();
const worldStats = this.world.getStats();
return {
...worldStats,
regions: {
total: regionStats.totalRegions,
active: regionStats.activeRegions
}
};
}
return this.world.getStats();
}
/**
* Gets the underlying world instance
*
* @returns World instance
*/
getWorld() {
return this.world;
}
/**
* Gets the current simulation tick.
*
* @returns Tick counter (starts at 0 and increments after each {@link step})
*/
getTick() {
return this.tick;
}
/**
* Captures a lightweight snapshot of the current world state.
*
* The snapshot only stores the minimum data required for client-side prediction:
* position, velocity, rotation, angular velocity and sleeping flag per entity.
*
* @returns Snapshot object
*/
takeSnapshot() {
return {
tick: this.tick,
entities: this.getEntities().map((entity) => ({
uuid: entity.uuid,
position: { x: entity.position.x, y: entity.position.y },
velocity: { x: entity.velocity.x, y: entity.velocity.y },
rotation: entity.rotation,
angularVelocity: entity.angularVelocity,
sleeping: entity.isSleeping()
}))
};
}
/**
* Restores a snapshot previously produced by {@link takeSnapshot}.
*
* Entities that cannot be found in the current engine are skipped silently.
*
* @param snapshot - Snapshot to restore
*/
restoreSnapshot(snapshot) {
const entities = new Map(this.getEntities().map((entity) => [entity.uuid, entity]));
for (const state of snapshot.entities) {
const entity = entities.get(state.uuid);
if (!entity) continue;
entity.position.set(state.position.x, state.position.y);
entity.velocity.set(state.velocity.x, state.velocity.y);
entity.rotation = state.rotation;
entity.angularVelocity = state.angularVelocity;
if (state.sleeping) {
entity.sleep();
} else {
entity.wakeUp();
}
}
this.tick = snapshot.tick;
}
/**
* Gets the region manager (if regions are enabled)
*
* @returns Region manager or null
*/
getRegionManager() {
return this.regionManager;
}
}
export {
PhysicsEngine
};
//# sourceMappingURL=index28.js.map