UNPKG

@rpgjs/physic

Version:

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

742 lines (741 loc) 23 kB
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