@rpgjs/physic
Version:
A deterministic 2D top-down physics library for RPG, sandbox and MMO games
742 lines (741 loc) • 23 kB
JavaScript
import { Vector2 } from "./index2.js";
import { EntityState } from "./index6.js";
import { generateUUID } from "./index43.js";
const MOVEMENT_EPSILON = 1e-3;
const MOVEMENT_EPSILON_SQ = MOVEMENT_EPSILON * MOVEMENT_EPSILON;
const DIRECTION_CHANGE_THRESHOLD = 1;
const DIRECTION_CHANGE_THRESHOLD_SQ = DIRECTION_CHANGE_THRESHOLD * DIRECTION_CHANGE_THRESHOLD;
class Entity {
/**
* Creates a new entity
*
* @param config - Entity configuration
*/
constructor(config = {}) {
this.lastCardinalDirection = "idle";
this.uuid = config.uuid ?? generateUUID();
if (config.position instanceof Vector2) {
this.position = config.position.clone();
} else if (config.position) {
this.position = new Vector2(config.position.x, config.position.y);
} else {
this.position = new Vector2(0, 0);
}
this.currentTile = new Vector2(0, 0);
if (config.velocity instanceof Vector2) {
this.velocity = config.velocity.clone();
} else if (config.velocity) {
this.velocity = new Vector2(config.velocity.x, config.velocity.y);
} else {
this.velocity = new Vector2(0, 0);
}
this.rotation = config.rotation ?? 0;
this.angularVelocity = config.angularVelocity ?? 0;
this.mass = config.mass ?? 1;
this.invMass = this.mass > 0 ? 1 / this.mass : 0;
this.radius = config.radius ?? 0;
this.width = config.width ?? 0;
this.height = config.height ?? 0;
if (config.capsule !== void 0) {
this.capsule = config.capsule;
}
this.continuous = config.continuous ?? false;
this.state = config.state ?? EntityState.Dynamic;
this.restitution = config.restitution ?? 0.2;
this.friction = config.friction ?? 0.3;
this.linearDamping = config.linearDamping ?? 0.01;
this.angularDamping = config.angularDamping ?? 0.01;
this.maxLinearVelocity = config.maxLinearVelocity ?? Infinity;
this.maxAngularVelocity = config.maxAngularVelocity ?? Infinity;
this.force = new Vector2(0, 0);
this.torque = 0;
this.collisionMask = config.collisionMask ?? 4294967295;
this.collisionCategory = config.collisionCategory ?? 1;
this.timeSinceMovement = 0;
this.sleepThreshold = 0.5;
this.collisionEnterHandlers = /* @__PURE__ */ new Set();
this.collisionExitHandlers = /* @__PURE__ */ new Set();
this.positionSyncHandlers = /* @__PURE__ */ new Set();
this.directionSyncHandlers = /* @__PURE__ */ new Set();
this.positionSyncHandlers = /* @__PURE__ */ new Set();
this.directionSyncHandlers = /* @__PURE__ */ new Set();
this.movementChangeHandlers = /* @__PURE__ */ new Set();
this.enterTileHandlers = /* @__PURE__ */ new Set();
this.leaveTileHandlers = /* @__PURE__ */ new Set();
this.canEnterTileHandlers = /* @__PURE__ */ new Set();
this.wasMoving = this.velocity.lengthSquared() > MOVEMENT_EPSILON_SQ;
}
/**
* Registers a handler fired when this entity starts colliding with another one.
*
* - **Purpose:** offer per-entity collision hooks without subscribing to the global event system.
* - **Design:** lightweight Set-based listeners returning an unsubscribe closure to keep GC pressure low.
*
* @param handler - Collision enter listener
* @returns Unsubscribe closure
* @example
* ```typescript
* const unsubscribe = entity.onCollisionEnter(({ other }) => {
* console.log('Started colliding with', other.uuid);
* });
* ```
*/
onCollisionEnter(handler) {
this.collisionEnterHandlers.add(handler);
return () => this.collisionEnterHandlers.delete(handler);
}
/**
* Registers a handler fired when this entity stops colliding with another one.
*
* - **Purpose:** detect collision separation at the entity level for local gameplay reactions.
* - **Design:** mirrors `onCollisionEnter` with identical lifecycle management semantics.
*
* @param handler - Collision exit listener
* @returns Unsubscribe closure
* @example
* ```typescript
* const unsubscribe = entity.onCollisionExit(({ other }) => {
* console.log('Stopped colliding with', other.uuid);
* });
* ```
*/
onCollisionExit(handler) {
this.collisionExitHandlers.add(handler);
return () => this.collisionExitHandlers.delete(handler);
}
/**
* Registers a handler fired when the entity position changes (x, y).
*
* - **Purpose:** synchronize position changes for logging, rendering, network sync, etc.
* - **Design:** lightweight Set-based listeners returning an unsubscribe closure to keep GC pressure low.
*
* @param handler - Position change listener
* @returns Unsubscribe closure
* @example
* ```typescript
* const unsubscribe = entity.onPositionChange(({ x, y }) => {
* console.log('Position changed to', x, y);
* // Update rendering, sync network, etc.
* });
* ```
*/
onPositionChange(handler) {
this.positionSyncHandlers.add(handler);
return () => this.positionSyncHandlers.delete(handler);
}
/**
* Registers a handler fired when the entity direction changes.
*
* - **Purpose:** synchronize direction changes for logging, rendering, network sync, etc.
* - **Design:** lightweight Set-based listeners returning an unsubscribe closure to keep GC pressure low.
*
* @param handler - Direction change listener
* @returns Unsubscribe closure
* @example
* ```typescript
* const unsubscribe = entity.onDirectionChange(({ direction, cardinalDirection }) => {
* console.log('Direction changed to', cardinalDirection);
* // Update rendering, sync network, etc.
* });
* ```
*/
onDirectionChange(handler) {
this.directionSyncHandlers.add(handler);
return () => this.directionSyncHandlers.delete(handler);
}
/**
* Manually notifies that the position has changed.
*
* - **Purpose:** allow external code to trigger position sync hooks when position is modified directly.
* - **Design:** can be called after direct position modifications (e.g., `entity.position.set()`).
*
* @example
* ```typescript
* entity.position.set(100, 200);
* entity.notifyPositionChange(); // Trigger sync hooks
* ```
*/
notifyPositionChange() {
if (this.positionSyncHandlers.size === 0) {
return;
}
const payload = {
entity: this,
x: this.position.x,
y: this.position.y
};
for (const handler of this.positionSyncHandlers) {
handler(payload);
}
}
/**
* Manually notifies that the direction has changed.
*
* - **Purpose:** allow external code to trigger direction sync hooks when direction is modified directly.
* - **Design:** computes direction from velocity and cardinal direction.
*
* @example
* ```typescript
* entity.velocity.set(5, 0);
* entity.notifyDirectionChange(); // Trigger sync hooks
* ```
*/
notifyDirectionChange() {
const isMoving = this.velocity.lengthSquared() > DIRECTION_CHANGE_THRESHOLD_SQ;
const direction = isMoving ? this.velocity.clone().normalize() : new Vector2(0, 0);
const cardinalDirection = this.computeCardinalDirection(direction);
if (cardinalDirection !== "idle") {
this.lastCardinalDirection = cardinalDirection;
}
if (this.directionSyncHandlers.size === 0) {
return;
}
const payload = {
entity: this,
direction,
cardinalDirection
};
for (const handler of this.directionSyncHandlers) {
handler(payload);
}
}
/**
* Gets the current cardinal direction.
*
* This value is updated whenever `notifyDirectionChange()` is called (e.g. by `setVelocity`).
* It includes hysteresis logic to prevent rapid direction flipping during collisions.
*
* @returns The current cardinal direction ('up', 'down', 'left', 'right', 'idle')
*
* @example
* ```typescript
* const dir = entity.cardinalDirection;
* if (dir === 'left') {
* // Render left-facing sprite
* }
* ```
*/
get cardinalDirection() {
return this.lastCardinalDirection;
}
/**
* Registers a handler fired when the entity movement state changes (moving/stopped).
*
* - **Purpose:** detect when an entity starts or stops moving for gameplay reactions, animations, or network sync.
* - **Design:** lightweight Set-based listeners returning an unsubscribe closure to keep GC pressure low.
* - **Movement detection:** uses `MOVEMENT_EPSILON` threshold to determine if entity is moving.
* - **Intensity:** provides the movement speed magnitude to allow fine-grained animation control (e.g., walk vs run).
*
* @param handler - Movement state change listener
* @returns Unsubscribe closure
* @example
* ```typescript
* const unsubscribe = entity.onMovementChange(({ isMoving, intensity }) => {
* console.log('Entity is', isMoving ? 'moving' : 'stopped', 'at speed', intensity);
* // Update animations based on intensity
* if (isMoving && intensity > 100) {
* // Fast movement - use run animation
* } else if (isMoving) {
* // Slow movement - use walk animation
* }
* });
* ```
*/
onMovementChange(handler) {
this.movementChangeHandlers.add(handler);
return () => this.movementChangeHandlers.delete(handler);
}
/**
* Manually notifies that the movement state has changed.
*
* - **Purpose:** allow external code to trigger movement state sync hooks when velocity is modified directly.
* - **Design:** checks if movement state (moving/stopped) has changed and notifies handlers with movement intensity.
* - **Intensity:** calculated as the magnitude of the velocity vector (speed in pixels per second).
*
* @example
* ```typescript
* entity.velocity.set(5, 0);
* entity.notifyMovementChange(); // Trigger sync hooks if state changed
* ```
*/
notifyMovementChange() {
const isMoving = this.velocity.lengthSquared() > MOVEMENT_EPSILON_SQ;
const intensity = this.velocity.length();
if (this.movementChangeHandlers.size === 0) {
this.wasMoving = isMoving;
return;
}
if (isMoving !== this.wasMoving) {
this.wasMoving = isMoving;
const payload = {
entity: this,
isMoving,
intensity
};
for (const handler of this.movementChangeHandlers) {
handler(payload);
}
} else {
this.wasMoving = isMoving;
}
}
/**
* Registers a handler fired when the entity enters a new tile.
*
* @param handler - Tile enter listener
* @returns Unsubscribe closure
*/
onEnterTile(handler) {
this.enterTileHandlers.add(handler);
return () => this.enterTileHandlers.delete(handler);
}
/**
* Registers a handler fired when the entity leaves a tile.
*
* @param handler - Tile leave listener
* @returns Unsubscribe closure
*/
onLeaveTile(handler) {
this.leaveTileHandlers.add(handler);
return () => this.leaveTileHandlers.delete(handler);
}
/**
* Registers a handler to check if the entity can enter a tile.
* If any handler returns false, the entity cannot enter.
*
* @param handler - Can enter tile listener
* @returns Unsubscribe closure
*/
canEnterTile(handler) {
this.canEnterTileHandlers.add(handler);
return () => this.canEnterTileHandlers.delete(handler);
}
/**
* @internal
* Notifies that the entity has entered a tile.
*/
notifyEnterTile(x, y) {
if (this.enterTileHandlers.size === 0) return;
const event = { entity: this, x, y };
for (const handler of this.enterTileHandlers) {
handler(event);
}
}
/**
* @internal
* Notifies that the entity has left a tile.
*/
notifyLeaveTile(x, y) {
if (this.leaveTileHandlers.size === 0) return;
const event = { entity: this, x, y };
for (const handler of this.leaveTileHandlers) {
handler(event);
}
}
/**
* @internal
* Checks if the entity can enter a tile.
*/
checkCanEnterTile(x, y) {
if (this.canEnterTileHandlers.size === 0) return true;
const event = { entity: this, x, y };
for (const handler of this.canEnterTileHandlers) {
if (handler(event) === false) {
return false;
}
}
return true;
}
/**
* Applies a force to the entity
*
* Force is accumulated and applied during integration.
*
* @param force - Force vector to apply
* @returns This entity for chaining
*
* @example
* ```typescript
* entity.applyForce(new Vector2(10, 0)); // Push right
* ```
*/
applyForce(force) {
if (this.isStatic() || this.isSleeping()) {
return this;
}
this.force.addInPlace(force);
return this;
}
/**
* Applies a force at a specific point (creates torque)
*
* @param force - Force vector to apply
* @param point - Point of application in world space
* @returns This entity for chaining
*/
applyForceAtPoint(force, point) {
if (this.isStatic() || this.isSleeping()) {
return this;
}
this.force.addInPlace(force);
const r = point.sub(this.position);
this.torque += r.cross(force);
return this;
}
/**
* Applies an impulse (instantaneous change in velocity)
*
* @param impulse - Impulse vector
* @returns This entity for chaining
*
* @example
* ```typescript
* entity.applyImpulse(new Vector2(5, 0)); // Instant push
* ```
*/
applyImpulse(impulse) {
if (this.isStatic() || this.isSleeping()) {
return this;
}
this.velocity.addInPlace(impulse.mul(this.invMass));
this.notifyMovementChange();
this.notifyDirectionChange();
return this;
}
/**
* Applies an angular impulse (instantaneous change in angular velocity)
*
* @param impulse - Angular impulse value
* @returns This entity for chaining
*/
applyAngularImpulse(impulse) {
if (this.isStatic() || this.isSleeping()) {
return this;
}
const momentOfInertia = this.mass * this.radius * this.radius;
if (momentOfInertia > 0) {
this.angularVelocity += impulse / momentOfInertia;
}
return this;
}
/**
* Teleports the entity to a new position
*
* @param position - New position
* @returns This entity for chaining
*/
teleport(position) {
if (position instanceof Vector2) {
this.position.copyFrom(position);
} else {
this.position.set(position.x, position.y);
}
this.wakeUp();
this.notifyPositionChange();
return this;
}
/**
* Sets the velocity directly
*
* @param velocity - New velocity
* @returns This entity for chaining
*/
setVelocity(velocity) {
const oldVelocity = this.velocity.clone();
if (velocity instanceof Vector2) {
this.velocity.copyFrom(velocity);
} else {
this.velocity.set(velocity.x, velocity.y);
}
this.wakeUp();
const oldDirection = oldVelocity.lengthSquared() > MOVEMENT_EPSILON_SQ ? oldVelocity.clone().normalize() : new Vector2(0, 0);
const newDirection = this.velocity.lengthSquared() > MOVEMENT_EPSILON_SQ ? this.velocity.clone().normalize() : new Vector2(0, 0);
const oldCardinal = this.computeCardinalDirection(oldDirection);
const newCardinal = this.computeCardinalDirection(newDirection);
if (oldCardinal !== newCardinal || Math.abs(oldDirection.dot(newDirection) - 1) > 0.01) {
this.notifyDirectionChange();
}
this.notifyMovementChange();
return this;
}
/**
* Freezes the entity (makes it static)
*
* @returns This entity for chaining
*/
freeze() {
this.state = EntityState.Static;
this.velocity.set(0, 0);
this.angularVelocity = 0;
this.force.set(0, 0);
this.torque = 0;
this.notifyMovementChange();
return this;
}
/**
* Unfreezes the entity (makes it dynamic)
*
* @returns This entity for chaining
*/
unfreeze() {
if (this.mass > 0) {
this.state = EntityState.Dynamic;
}
return this;
}
/**
* Puts the entity to sleep (stops updating)
*
* @returns This entity for chaining
*/
sleep() {
if (!this.isStatic()) {
this.state |= EntityState.Sleeping;
this.velocity.set(0, 0);
this.angularVelocity = 0;
this.force.set(0, 0);
this.torque = 0;
this.notifyMovementChange();
}
return this;
}
/**
* Wakes up the entity (resumes updating)
*
* @returns This entity for chaining
*/
wakeUp() {
this.state &= ~EntityState.Sleeping;
this.timeSinceMovement = 0;
return this;
}
/**
* Checks if the entity is static
*
* An entity is considered static if:
* - It has the Static state flag, OR
* - It has infinite mass (mass = Infinity), OR
* - It has zero inverse mass (invMass = 0)
*
* @returns True if static
*/
isStatic() {
return (this.state & EntityState.Static) !== 0 || this.invMass === 0;
}
/**
* Checks if the entity is dynamic
*
* @returns True if dynamic
*/
isDynamic() {
return (this.state & EntityState.Dynamic) !== 0 && this.mass > 0;
}
/**
* Checks if the entity is sleeping
*
* @returns True if sleeping
*/
isSleeping() {
return (this.state & EntityState.Sleeping) !== 0;
}
/**
* Checks if the entity is kinematic
*
* @returns True if kinematic
*/
isKinematic() {
return (this.state & EntityState.Kinematic) !== 0;
}
/**
* Resets accumulated forces and torques
*
* Called at the start of each physics step.
*/
clearForces() {
this.force.set(0, 0);
this.torque = 0;
}
/**
* Stops all movement immediately
*
* Completely stops the entity's movement by:
* - Setting velocity to zero
* - Setting angular velocity to zero
* - Clearing accumulated forces and torques
* - Waking up the entity if it was sleeping
* - Notifying movement state change
*
* Unlike `freeze()`, this method keeps the entity dynamic and does not
* change its state. It's useful for stopping movement when changing maps,
* teleporting, or when you need to halt an entity without making it static.
*
* @returns This entity for chaining
*
* @example
* ```ts
* // Stop movement when changing maps
* if (mapChanged) {
* entity.stopMovement();
* }
*
* // Stop movement after teleporting
* entity.position.set(100, 200);
* entity.stopMovement();
*
* // Stop movement when player dies
* if (player.isDead()) {
* playerEntity.stopMovement();
* }
* ```
*/
stopMovement() {
this.velocity.set(0, 0);
this.angularVelocity = 0;
this.clearForces();
this.wakeUp();
this.notifyMovementChange();
this.notifyDirectionChange();
return this;
}
/**
* Clamps velocities to maximum values
*/
clampVelocities() {
const speed = this.velocity.length();
if (speed > this.maxLinearVelocity) {
this.velocity.normalizeInPlace().mulInPlace(this.maxLinearVelocity);
}
if (Math.abs(this.angularVelocity) > this.maxAngularVelocity) {
this.angularVelocity = Math.sign(this.angularVelocity) * this.maxAngularVelocity;
}
}
/**
* Checks if this entity can collide with another entity
*
* @param other - Other entity to check
* @returns True if collision is possible
*/
canCollideWith(other) {
const categoryA = this.collisionCategory;
const maskA = this.collisionMask;
const categoryB = other.collisionCategory;
const maskB = other.collisionMask;
return (categoryA & maskB) !== 0 && (categoryB & maskA) !== 0;
}
/**
* @internal
*
* Notifies the entity that a collision has started.
*
* @param collision - Collision information shared by the world
* @param other - The counterpart entity
*/
notifyCollisionEnter(collision, other) {
if (this.collisionEnterHandlers.size === 0) {
return;
}
const payload = {
entity: this,
other,
collision
};
for (const handler of this.collisionEnterHandlers) {
handler(payload);
}
}
/**
* @internal
*
* Notifies the entity that a collision has ended.
*
* @param collision - Collision information stored before separation
* @param other - The counterpart entity
*/
notifyCollisionExit(collision, other) {
if (this.collisionExitHandlers.size === 0) {
return;
}
const payload = {
entity: this,
other,
collision
};
for (const handler of this.collisionExitHandlers) {
handler(payload);
}
}
computeCardinalDirection(direction) {
if (direction.lengthSquared() <= MOVEMENT_EPSILON_SQ) {
return "idle";
}
if (this.lastCardinalDirection === "idle") {
const absX2 = Math.abs(direction.x);
const absY2 = Math.abs(direction.y);
if (absX2 >= absY2) {
return direction.x >= 0 ? "right" : "left";
}
return direction.y >= 0 ? "down" : "up";
}
const isOpposite = this.lastCardinalDirection === "left" && direction.x > 0.5 || this.lastCardinalDirection === "right" && direction.x < -0.5 || this.lastCardinalDirection === "up" && direction.y > 0.5 || this.lastCardinalDirection === "down" && direction.y < -0.5;
const speedSq = this.velocity.lengthSquared();
if (isOpposite && speedSq < 100) {
return this.lastCardinalDirection;
}
const absX = Math.abs(direction.x);
const absY = Math.abs(direction.y);
const bias = 2;
if (["left", "right"].includes(this.lastCardinalDirection)) {
if (absY > absX * bias) {
if (Math.abs(this.velocity.y) > 5) {
return direction.y >= 0 ? "down" : "up";
}
}
if (speedSq > 1) {
return direction.x >= 0 ? "right" : "left";
}
return this.lastCardinalDirection;
} else {
if (absX > absY * bias) {
if (Math.abs(this.velocity.x) > 5) {
return direction.x >= 0 ? "right" : "left";
}
}
if (speedSq > 1) {
return direction.y >= 0 ? "down" : "up";
}
return this.lastCardinalDirection;
}
}
/**
* Creates a copy of this entity
*
* @returns New entity with copied properties
*/
clone() {
const entity = new Entity({
position: this.position.clone(),
velocity: this.velocity.clone(),
rotation: this.rotation,
angularVelocity: this.angularVelocity,
mass: this.mass,
radius: this.radius,
width: this.width,
height: this.height,
state: this.state,
restitution: this.restitution,
friction: this.friction,
linearDamping: this.linearDamping,
angularDamping: this.angularDamping,
maxLinearVelocity: this.maxLinearVelocity,
maxAngularVelocity: this.maxAngularVelocity,
collisionMask: this.collisionMask,
collisionCategory: this.collisionCategory,
uuid: this.uuid
});
return entity;
}
}
export {
Entity
};
//# sourceMappingURL=index7.js.map