import { vec, Vector } from '../math/vector'; import { CollisionType } from './collision-type'; import type { Clonable } from '../interfaces/clonable'; import { TransformComponent } from '../entity-component-system/components/transform-component'; import { MotionComponent } from '../entity-component-system/components/motion-component'; import { Component } from '../entity-component-system/component'; import { CollisionGroup } from './group/collision-group'; import type { Id } from '../id'; import { createId } from '../id'; import { angleDifference, canonicalizeAngle, clamp } from '../math/util'; import { ColliderComponent } from './collider-component'; import { Transform } from '../math/transform'; import { EventEmitter } from '../event-emitter'; import type { BodyConfig, PhysicsConfig } from './physics-config'; import { getDefaultPhysicsConfig } from './physics-config'; import type { Entity } from '../entity-component-system'; import type { Island } from './island'; export interface BodyComponentOptions { type?: CollisionType; group?: CollisionGroup; useGravity?: boolean; config?: Pick['bodies']; } // ============================================================================ // BodyComponent Serialization Data // ============================================================================ export interface BodyComponentData { type: string; // Physics configuration collisionType: string; // 'PreventCollision' | 'Passive' | 'Active' | 'Fixed' mass: number; bounciness: number; friction: number; useGravity: boolean; // Collision group (simplified - may need custom handling) collisionGroup?: string; // 'All' | custom group name // Sleep settings canSleep: boolean; isSleeping: boolean; // Degrees of freedom limitations limitDegreeOfFreedom: string[]; // ['x', 'y', 'rotation'] // Fixed update interpolation enableFixedUpdateInterpolate: boolean; } export enum DegreeOfFreedom { Rotation = 'rotation', X = 'x', Y = 'y' } /** * Body describes all the physical properties pos, vel, acc, rotation, angular velocity for the purpose of * of physics simulation. */ export class BodyComponent extends Component implements Clonable { // @ts-ignore private static _NAME = 'BodyComponent'; public dependencies = [TransformComponent, MotionComponent]; public static _ID = 0; public readonly id: Id<'body'> = createId('body', BodyComponent._ID++); public events = new EventEmitter(); public island: Island | null = null; public oldTransform = new Transform(); /** * Indicates whether the old transform has been captured at least once for interpolation * @internal */ public __oldTransformCaptured: boolean = false; /** * Enable or disabled the fixed update interpolation, by default interpolation is on. */ public enableFixedUpdateInterpolate = true; private _bodyConfig: BodyConfig; private static _DEFAULT_CONFIG: BodyConfig = { ...getDefaultPhysicsConfig().bodies }; public wakeThreshold: number; public sleepTime: number = 0; constructor(options?: BodyComponentOptions) { super(); if (options) { this.collisionType = options.type ?? this.collisionType; this.group = options.group ?? this.group; this.useGravity = options.useGravity ?? this.useGravity; this._bodyConfig = { ...getDefaultPhysicsConfig().bodies, ...options.config }; } else { this._bodyConfig = { ...getDefaultPhysicsConfig().bodies }; } this.updatePhysicsConfig(this._bodyConfig); this._mass = BodyComponent._DEFAULT_CONFIG.defaultMass; } public get matrix() { return this.transform.get().matrix; } /** * Called by excalibur to update physics config defaults if they change * @param config */ public updatePhysicsConfig(config: BodyConfig) { this._bodyConfig = { ...getDefaultPhysicsConfig().bodies, ...config }; // FIXME this doesnt' seem to work when config updated this.canSleep = this._bodyConfig.canSleepByDefault; this.sleepMotion = this._bodyConfig.sleepEpsilon * 5; this.wakeThreshold = this._bodyConfig.wakeThreshold; } public get canFallAsleep() { return this.canSleep && this.collisionType === CollisionType.Active && this.sleepMotion < this._bodyConfig.sleepEpsilon; } public get canWakeUp() { return this.collisionType === CollisionType.Active && this.sleepMotion > this.wakeThreshold; } /** * Called by excalibur to update defaults * @param config */ public static updateDefaultPhysicsConfig(config: BodyConfig) { BodyComponent._DEFAULT_CONFIG = config; } /** * Collision type for the rigidbody physics simulation, by default {@apilink CollisionType.PreventCollision} */ public collisionType: CollisionType = CollisionType.PreventCollision; /** * The collision group for the body's colliders, by default body colliders collide with everything */ public group: CollisionGroup = CollisionGroup.All; /** * The amount of mass the body has */ private _mass: number; public get mass(): number { return this._mass; } public set mass(newMass: number) { this._mass = newMass; this._cachedInertia = undefined; this._cachedInverseInertia = undefined; } /** * The inverse mass (1/mass) of the body. If {@apilink CollisionType.Fixed} this is 0, meaning "infinite" mass */ public get inverseMass(): number { return this.collisionType === CollisionType.Fixed ? 0 : 1 / this.mass; } /** * Amount of "motion" the body has before sleeping. If below {@apilink Physics.sleepEpsilon} it goes to "sleep" */ public sleepMotion: number; /** * Can this body sleep, by default bodies do not sleep */ public canSleep: boolean = this.collisionType === CollisionType.Active; private _sleeping = false; /** * Whether this body is sleeping or not * @deprecated use isSleeping */ public get sleeping(): boolean { return this.isSleeping; } /** * Whether this body is sleeping or not */ public get isSleeping(): boolean { return this.canSleep && this._sleeping; } /** * Set the sleep state of the body * @param sleeping * @deprecated use isSleeping */ public setSleeping(sleeping: boolean) { this.isSleeping = sleeping; } public wake() { if (this._sleeping && this.collisionType === CollisionType.Active) { this._sleeping = false; this.owner?.removeTag('ex.is_sleeping'); // Give it a kick to keep it from falling asleep immediately this.sleepMotion = this._bodyConfig.sleepEpsilon * 2; this.sleepTime = 0; } } public sleep() { if (!this._sleeping && this.canSleep) { this._sleeping = true; this.owner?.addTag('ex.is_sleeping'); this.vel = Vector.Zero; this.acc = Vector.Zero; this.angularVelocity = 0; this.sleepMotion = 0; } } public set isSleeping(sleeping: boolean) { if (sleeping) { this.sleep(); } else { this.wake(); } } /** * Update body's {@apilink BodyComponent.sleepMotion} for the purpose of sleeping */ public updateMotion(duration: number) { if (this.collisionType !== CollisionType.Active) { return; } // Implementation inspired from Game Physics Engine Development by Ian Millington // Tweaked slightly for excalibur // What is their effective perceptive velocity, instead of instantaneous .vel/.angularVelocity const effectiveVel = this.pos.sub(this.oldPos); const effectiveAngularVel = angleDifference(canonicalizeAngle(this.rotation), canonicalizeAngle(this.oldRotation)); // This is effectively a massless kinetic energy term // We remove the mass terms from 1/2 mv^2 to keep objects of different masses from sleeping at different times const vel = effectiveVel.magnitude; const omega = effectiveAngularVel; const currentMotion = vel * vel + omega * omega; // Bias is frame duration dependent (sleepBias is smeared over 1-s frames const bias = Math.pow(this._bodyConfig.sleepBias, duration / 1000); // Rolling average of previous motion to keep things from sleeping if they stop abruptly but were just moving fast const previousMotion = this.sleepMotion; this.sleepMotion = bias * previousMotion + (1.0 - bias) * currentMotion; // Clamp motion to a maximum this.sleepMotion = clamp(this.sleepMotion, 0, 10 * this._bodyConfig.sleepEpsilon); // Low energy bodies go to sleep, just like real life ;) if (this.canFallAsleep) { this.sleepTime += duration; } } private _cachedInertia: number; /** * Get the moment of inertia from the {@apilink ColliderComponent} */ public get inertia() { if (this._cachedInertia) { return this._cachedInertia; } // Inertia is a property of the geometry, so this is a little goofy but seems to be okay? const collider = this.owner.get(ColliderComponent); if (collider) { collider.$colliderAdded.subscribe(() => { this._cachedInertia = null; }); collider.$colliderRemoved.subscribe(() => { this._cachedInertia = null; }); const maybeCollider = collider.get(); if (maybeCollider) { return (this._cachedInertia = maybeCollider.getInertia(this.mass)); } } return 0; } private _cachedInverseInertia: number; /** * Get the inverse moment of inertial from the {@apilink ColliderComponent}. If {@apilink CollisionType.Fixed} this is 0, meaning "infinite" mass */ public get inverseInertia() { if (this._cachedInverseInertia) { return this._cachedInverseInertia; } return (this._cachedInverseInertia = this.collisionType === CollisionType.Fixed ? 0 : 1 / this.inertia); } /** * The also known as coefficient of restitution of this actor, represents the amount of energy preserved after collision or the * bounciness. If 1, it is 100% bouncy, 0 it completely absorbs. */ public bounciness: number = 0.2; /** * The coefficient of friction on this actor. * * The {@apilink SolverStrategy.Arcade} does not support this property. * */ public friction: number = 0.99; /** * Should use global gravity {@apilink Physics.gravity} in it's physics simulation, default is true */ public useGravity: boolean = true; /** * Degrees of freedom to limit * * Note: this only limits responses in the realistic solver, if velocity/angularVelocity is set the actor will still respond */ public limitDegreeOfFreedom: DegreeOfFreedom[] = []; /** * Returns if the owner is active * @deprecated use isActive */ public get active() { return !!this.owner?.isActive; } /** * Returns if the owner is active */ public get isActive() { return !!this.owner?.isActive; } /** * @deprecated Use globalPos */ public get center() { return this.globalPos; } public transform: TransformComponent; public motion: MotionComponent; override onAdd(owner: Entity): void { this.transform = this.owner?.get(TransformComponent); this.motion = this.owner?.get(MotionComponent); } public get pos(): Vector { return this.transform.pos; } public set pos(val: Vector) { this.transform.pos = val; } /** * The (x, y) position of the actor this will be in the middle of the actor if the * {@apilink Actor.anchor} is set to (0.5, 0.5) which is default. * If you want the (x, y) position to be the top left of the actor specify an anchor of (0, 0). */ public get globalPos(): Vector { return this.transform.globalPos; } public set globalPos(val: Vector) { this.transform.globalPos = val; } private _oldGlobalPos: Vector = Vector.Zero; /** * The position of the actor last frame (x, y) in pixels */ public get oldPos(): Vector { return this.oldTransform.pos; } /** * The global position of the actor last frame (x, y) in pixels */ public get oldGlobalPos(): Vector { return this._oldGlobalPos; } /** * The current velocity vector (vx, vy) of the actor in pixels/second */ public get vel(): Vector { return this.motion.vel; } public set vel(val: Vector) { this.motion.vel = val; } /** * The velocity of the actor last frame (vx, vy) in pixels/second */ public oldVel: Vector = new Vector(0, 0); /** * The current acceleration vector (ax, ay) of the actor in pixels/second/second. An acceleration pointing down such as (0, 100) may * be useful to simulate a gravitational effect. */ public get acc(): Vector { return this.motion.acc; } public set acc(val: Vector) { this.motion.acc = val; } /** * Gets/sets the acceleration of the actor from the last frame. This does not include the global acc {@apilink Physics.acc}. */ public oldAcc: Vector = Vector.Zero; /** * The current torque applied to the actor */ public get torque(): number { return this.motion.torque; } public set torque(val: number) { this.motion.torque = val; } /** * Gets/sets the rotation of the body from the last frame. */ public get oldRotation(): number { return this.oldTransform.rotation; } /** * The rotation of the body in radians */ public get rotation() { return this.transform.globalRotation; } public set rotation(val: number) { this.transform.globalRotation = val; } /** * The scale vector of the actor */ public get scale(): Vector { return this.transform.globalScale; } public set scale(val: Vector) { this.transform.globalScale = val; } /** * The scale of the actor last frame */ public get oldScale(): Vector { return this.oldTransform.scale; } /** * The scale rate of change of the actor in scale/second */ public get scaleFactor(): Vector { return this.motion.scaleFactor; } public set scaleFactor(scaleFactor: Vector) { this.motion.scaleFactor = scaleFactor; } /** * Get the angular velocity in radians/second */ public get angularVelocity(): number { return this.motion.angularVelocity; } /** * Set the angular velocity in radians/second */ public set angularVelocity(value: number) { this.motion.angularVelocity = value; } private _impulseScratch = vec(0, 0); private _distanceFromCenterScratch = vec(0, 0); /** * Apply a specific impulse to the body * @param point * @param impulse */ public applyImpulse(point: Vector, impulse: Vector) { if (this.collisionType !== CollisionType.Active) { return; // only active objects participate in the simulation } const finalImpulse = impulse.scale(this.inverseMass, this._impulseScratch); if (this.limitDegreeOfFreedom.indexOf(DegreeOfFreedom.X) > -1) { finalImpulse.x = 0; } if (this.limitDegreeOfFreedom.indexOf(DegreeOfFreedom.Y) > -1) { finalImpulse.y = 0; } this.vel.addEqual(finalImpulse); if (!this.limitDegreeOfFreedom.includes(DegreeOfFreedom.Rotation)) { const distanceFromCenter = point.sub(this.globalPos, this._distanceFromCenterScratch); this.angularVelocity += this.inverseInertia * distanceFromCenter.cross(impulse); } } /** * Apply only linear impulse to the body * @param impulse */ public applyLinearImpulse(impulse: Vector) { if (this.collisionType !== CollisionType.Active) { return; // only active objects participate in the simulation } const finalImpulse = impulse.scale(this.inverseMass); if (this.limitDegreeOfFreedom.includes(DegreeOfFreedom.X)) { finalImpulse.x = 0; } if (this.limitDegreeOfFreedom.includes(DegreeOfFreedom.Y)) { finalImpulse.y = 0; } this.vel = this.vel.add(finalImpulse); } /** * Apply only angular impulse to the body * @param point * @param impulse */ public applyAngularImpulse(point: Vector, impulse: Vector) { if (this.collisionType !== CollisionType.Active) { return; // only active objects participate in the simulation } if (!this.limitDegreeOfFreedom.includes(DegreeOfFreedom.Rotation)) { const distanceFromCenter = point.sub(this.globalPos); this.angularVelocity += this.inverseInertia * distanceFromCenter.cross(impulse); } } /** * Sets the old versions of pos, vel, acc, and scale. */ public captureOldTransform() { // Capture old values before integration step updates them this.__oldTransformCaptured = true; const tx = this.transform.get(); tx.clone(this.oldTransform); this.oldTransform.parent = tx.parent; // also grab parent this.oldVel.setTo(this.vel.x, this.vel.y); this.oldAcc.setTo(this.acc.x, this.acc.y); this.oldGlobalPos.setTo(this.globalPos.x, this.globalPos.y); } public clone(): BodyComponent { const component = super.clone() as BodyComponent; return component; } public serialize(): BodyComponentData { const type = this.constructor.name; return { type, // Core physics settings collisionType: CollisionType[this.collisionType], // Convert enum to string mass: this._mass, bounciness: this.bounciness, friction: this.friction, useGravity: this.useGravity, // Collision group (simplified) collisionGroup: this._serializeCollisionGroup(this.group), // Sleep settings canSleep: this.canSleep, isSleeping: this._sleeping, // Limitations limitDegreeOfFreedom: this.limitDegreeOfFreedom.map((dof) => dof.toString()), // Interpolation enableFixedUpdateInterpolate: this.enableFixedUpdateInterpolate }; } /** * Custom deserialization */ public deserialize(data: BodyComponentData): void { // Restore physics settings this.collisionType = CollisionType[data.collisionType as keyof typeof CollisionType]; this._mass = data.mass; this.bounciness = data.bounciness ?? 0.2; this.friction = data.friction ?? 0.99; this.useGravity = data.useGravity ?? true; // Restore collision group if (data.collisionGroup) { this.group = this._deserializeCollisionGroup(data.collisionGroup); } // Restore sleep settings this.canSleep = data.canSleep ?? false; this._sleeping = data.isSleeping ?? false; // Restore limitations this.limitDegreeOfFreedom = (data.limitDegreeOfFreedom ?? []).map((str) => DegreeOfFreedom[str as keyof typeof DegreeOfFreedom]); // Restore interpolation this.enableFixedUpdateInterpolate = data.enableFixedUpdateInterpolate ?? true; // Runtime state is NOT restored - will be initialized fresh // transform and motion will be set in onAdd() } /** * Helper to serialize CollisionGroup * This is simplified - you may need more complex handling */ private _serializeCollisionGroup(group: CollisionGroup): string { if (group === CollisionGroup.All) { return 'All'; } // For custom groups, you'd need to serialize their name/mask return group.name ?? 'Custom'; } /** * Helper to deserialize CollisionGroup */ private _deserializeCollisionGroup(groupName: string): CollisionGroup { if (groupName === 'All') { return CollisionGroup.All; } // For custom groups, you'd need to look them up by name // This is a limitation - custom collision groups need a registry return CollisionGroup.All; } }