diff --git a/packages/exojs-physics/README.md b/packages/exojs-physics/README.md index 37688f7c9..f6396bdd1 100644 --- a/packages/exojs-physics/README.md +++ b/packages/exojs-physics/README.md @@ -20,7 +20,7 @@ import { BoxShape, Collider, PhysicsBody, PhysicsWorld } from '@codexo/exojs-phy export class GameScene extends Scene { override init(): void { - const world = new PhysicsWorld({ gravity: { x: 0, y: 980 } }); + const world = new PhysicsWorld({ fixedDelta: this.app.fixedTimeStep, gravity: { x: 0, y: 980 } }); this.systems.add(world, { order: SystemOrder.Physics }); world.add( @@ -41,11 +41,11 @@ export class GameScene extends Scene { } ``` -The scene registry drives and destroys this world. The snippet creates simulation only; bodies do not draw themselves. Use `world.attach(node, definition)` or `world.bind(body, node)` to connect visible nodes, and render them from the scene's `draw` hook. The [Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) supplies a complete, asset-free falling-box application. +The scene registry drives and destroys this world: it steps once per application fixed step, after the scene's own `fixedUpdate` and its behaviours, so a command they issue is applied by that same step. A hosted world simulates its own `fixedDelta` per step, so it is built with `app.fixedTimeStep`; a world whose `fixedDelta` differs from the host step throws on its first step instead of running too fast or too slow. The snippet creates simulation only; bodies do not draw themselves. Use `world.attach(node, definition)` or `world.bind(body, node)` to connect visible nodes and add those nodes to the scene, which renders them with the rest of its root. The [Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) supplies a complete, asset-free falling-box application. ## Bodies owned by nodes -`PhysicsBodyComponent` gives a node a body that follows the node's lifecycle: it joins the world while the node is active in a scene, leaves it with its state kept when the component is disabled, the node is removed or reparented, or its scene is retained, and is destroyed with the node. +`PhysicsBodyComponent` gives a node a body that follows the node's lifecycle: it joins the world while the node is active in a scene, leaves it with its state kept when the component is disabled, the node is removed from its scene, or its scene is retained, and is destroyed with the node. ```ts import { Graphics } from '@codexo/exojs'; @@ -60,9 +60,97 @@ physics.body.applyImpulse(0, -300); Component-managed bodies do not support joints yet. `world.remove(body)` takes any body out of the simulation without destroying it; `world.add(body)` puts it back. +## Collision-aware movement + +`KinematicMovementComponent` moves a kinematic body in a requested direction without passing through colliders: the motion stops at the first obstacle and slides along it. Issue a command every fixed step, from gameplay input or a navigation agent; a step without one leaves the body at rest. + +```ts +import { BehaviorComponent, Container, Scene, SystemOrder } from '@codexo/exojs'; +import { CircleShape, KinematicMovementComponent, PhysicsBodyComponent, PhysicsWorld } from '@codexo/exojs-physics'; + +class Drift extends BehaviorComponent { + public constructor(private readonly movement: KinematicMovementComponent) { + super(); + } + + public override fixedUpdate(): void { + this.movement.move({ x: 1, y: 0.25 }); + } +} + +export class RoomScene extends Scene { + override init(): void { + const world = new PhysicsWorld({ fixedDelta: this.app.fixedTimeStep }); + const hero = new Container(); + const physics = hero.addComponent(new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CircleShape(16) }] })); + const movement = hero.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 240 })); + + this.systems.add(world, { order: SystemOrder.Physics }); + hero.addComponent(new Drift(movement)); + this.addChild(hero); + } +} +``` + +The movement resolves each command with `world.shapeCast`, an exact translation-only sweep that is also public: it returns the first collider a shape meets along a displacement, its distance and surface normal, and reports a starting overlap explicitly instead of as a hit. + +## Character movement + +`CharacterMovementComponent` composes over a `KinematicMovementComponent` for platformer-style movement: gravity, walking along the ground, walkable slopes, small steps, staying on the ground over dips, one-way platforms and moving platforms. The movement stays the only thing that moves the body; while the character is enabled it issues the movement's commands. When a jump is allowed is the game's decision, made from `grounded` and the other reported contact state. + +```ts +import { ActionMap, AxisAction, BehaviorComponent, ButtonAction, Container, Keyboard, Scene, SystemOrder } from '@codexo/exojs'; +import { + CapsuleShape, + CharacterMovementComponent, + KinematicMovementComponent, + PhysicsBodyComponent, + PhysicsWorld, +} from '@codexo/exojs-physics'; + +const controls = new ActionMap({ + walk: new AxisAction({ negative: Keyboard.A, positive: Keyboard.D }), + jump: new ButtonAction(Keyboard.Space), +}); + +class Controls extends BehaviorComponent { + public constructor(private readonly character: CharacterMovementComponent) { + super(); + } + + public override fixedUpdate(): void { + this.character.move(controls.walk.value); + + // `tick` hands each press to exactly one fixed step, however many steps a frame runs. + if (controls.jump.tick.pressed && this.character.grounded) { + this.character.launch({ x: 0, y: -520 }); + } + } +} + +export class LevelScene extends Scene { + override init(): void { + const world = new PhysicsWorld({ fixedDelta: this.app.fixedTimeStep }); + const hero = new Container(); + const physics = hero.addComponent( + new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }] }), + ); + const movement = hero.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 240 })); + const character = hero.addComponent(new CharacterMovementComponent(movement, { gravity: 1600, maxStepHeight: 8 })); + + this.systems.add(world, { order: SystemOrder.Physics }); + this.inputs.attach(controls); + hero.addComponent(new Controls(character)); + this.addChild(hero); + } +} +``` + ## Important boundaries -Choose one stepping clock. A world registered in `scene.systems` is stepped by the host; do not also call `step()`. An independently hosted world can use `step(frameDeltaSeconds)` and its own accumulator. +Choose one stepping clock. A world registered in `scene.systems` is stepped by the host, once per fixed step, and needs `fixedDelta: app.fixedTimeStep`; do not also call `step()`, which development builds report. A world that is not registered can use `step(frameDeltaSeconds)` and its own accumulator, at any rate. A world registered in `app.systems` instead steps before every scene's fixed-step code and keeps stepping while the scene is paused: an advanced setup, not a substitute for the scene-owned world. + +An interpolating hosted world reads its blend factor from the host: `new PhysicsWorld({ fixedDelta: app.fixedTimeStep, interpolation: true, frameAlphaSource: () => app.frameAlpha })`. A world stepped by hand defaults to its own accumulator. Physics angles use radians and clockwise-positive screen rotation. Scene-node rotation uses degrees with the opposite screen sign; the built-in binding performs the conversion. Interpolation affects bound-node presentation, not the number of collision steps. @@ -72,7 +160,7 @@ For tilemap collision, use [`@codexo/exojs-tilemap-physics`](https://github.com/ ## Documentation -[Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) · [Joints and contact behavior](https://exoridus.github.io/ExoJS/en/guide/physics/joints-and-dynamics/) · [PhysicsWorld API](https://exoridus.github.io/ExoJS/en/api/physics-world/) · [Drag and Throw playground](https://exoridus.github.io/ExoJS/en/playground/?example=physics/sprite-follows-body) +[Physics guide](https://exoridus.github.io/ExoJS/en/guide/physics/physics-basics/) · [Joints and contact behavior](https://exoridus.github.io/ExoJS/en/guide/physics/joints-and-dynamics/) · [PhysicsWorld API](https://exoridus.github.io/ExoJS/en/api/physics-world/) · [Drag and Throw playground](https://exoridus.github.io/ExoJS/en/playground/?example=physics/sprite-follows-body) · [Kinematic Movement playground](https://exoridus.github.io/ExoJS/en/playground/?example=physics/kinematic-movement) · [Character movement guide](https://exoridus.github.io/ExoJS/en/guide/physics/character-movement/) · [Character Movement playground](https://exoridus.github.io/ExoJS/en/playground/?example=physics/character-movement) ## License diff --git a/packages/exojs-physics/src/Aabb.ts b/packages/exojs-physics/src/Aabb.ts index 4f14d4ad0..c72ac5cfc 100644 --- a/packages/exojs-physics/src/Aabb.ts +++ b/packages/exojs-physics/src/Aabb.ts @@ -10,10 +10,12 @@ import type { AabbLike } from '@codexo/exojs'; export const createAabb = (): AabbLike => ({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); /** `true` when two AABBs overlap (touching edges count as overlapping). */ -export const aabbOverlap = (a: AabbLike, b: AabbLike): boolean => a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; +export const aabbOverlap = (a: AabbLike, b: AabbLike): boolean => + a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; /** `true` when `(x, y)` lies inside (or on the edge of) `aabb`. */ -export const aabbContainsPoint = (aabb: AabbLike, x: number, y: number): boolean => x >= aabb.minX && x <= aabb.maxX && y >= aabb.minY && y <= aabb.maxY; +export const aabbContainsPoint = (aabb: AabbLike, x: number, y: number): boolean => + x >= aabb.minX && x <= aabb.maxX && y >= aabb.minY && y <= aabb.maxY; /** Grow `aabb` outward by `margin` on every side, in place. */ export const expandAabb = (aabb: AabbLike, margin: number): AabbLike => { diff --git a/packages/exojs-physics/src/CharacterMovementComponent.ts b/packages/exojs-physics/src/CharacterMovementComponent.ts new file mode 100644 index 000000000..187c64ae9 --- /dev/null +++ b/packages/exojs-physics/src/CharacterMovementComponent.ts @@ -0,0 +1,724 @@ +import { Component, type PointLike, Vector } from '@codexo/exojs'; + +import type { Collider } from './Collider'; +import { + contactOffset, + createSlideResult, + isCastable, + type KinematicMovementComponent, + type SlideResult, +} from './KinematicMovementComponent'; +import type { PhysicsBody } from './PhysicsBody'; + +/** Construction options for {@link CharacterMovementComponent}. */ +export interface CharacterMovementOptions { + /** Direction the character stands up along, in simulation space; normalized. Default `(0, -1)`. */ + readonly up?: Readonly; + /** + * Downward acceleration along `-up` in world units per second squared. + * Defaults to the world's gravity projected onto `-up` when the component is + * constructed, or `0` when that points up. + */ + readonly gravity?: number; + /** Steepest surface, in radians from `up`, the character stands on. In `[0, π/2)`. Default `π/4`. */ + readonly maxSlopeAngle?: number; + /** Tallest obstacle the character steps onto while walking, in world units. Default `0`. */ + readonly maxStepHeight?: number; + /** Fastest the character falls along `-up`, in world units per second. Default `Infinity`. */ + readonly maxFallSpeed?: number; +} + +type SlideMode = 'walk' | 'snap' | 'air'; + +/** + * How far below the body the support probe reaches. A slide ends at most + * `contactOffset / minApproach` (`0.1`) short of the surface that stopped it, + * so twice that keeps a body that came to rest on its support counted as + * standing without reaching surfaces it is visibly apart from. + */ +const supportProbe = 0.2; + +/** Shortest motion worth casting; anything below is rounding. */ +const negligible = 1e-6; + +// The component also acts as the movement driver, slide constraint and contact +// visitor. Those roles are internal interfaces, so the class does not name them: +// an `implements` clause would leak them into the published declarations. They +// are still checked structurally where the component is handed to the movement +// and the query engine. +/** + * Moves a character over surfaces: walking along the ground, falling under + * gravity, launching into the air, climbing walkable slopes and small steps, + * staying on the ground over small dips, and riding moving platforms. It + * composes over a {@link KinematicMovementComponent}, which stays the only + * thing that moves the body; while the character is enabled it issues the + * movement's commands and `KinematicMovementComponent.move` throws. + * + * Call {@link move} with the walking input for the coming step and + * {@link launch} to throw the character into the air. Whether a jump is + * allowed - only from the ground, a second time in the air, shortly after + * leaving a ledge - is the game's decision: {@link grounded} and the other + * contact state are there to make it, and `launch` works anywhere. + * + * Every fixed step the world runs, the character integrates gravity, walks + * along the surface it stands on at the movement's `maxSpeed`, and casts the + * result through the movement's collide-and-slide. A `PhysicsWorld.step` call + * that runs several fixed steps integrates each of them and applies the same + * walking input to all of them, like a movement command. Contact state - which + * surface supports the character, which walls and ceilings the last step ran + * into - describes the end of the latest completed step, so a `fixedUpdate` + * reading it before the world steps sees the pose the character is in. + * + * A surface supports the character when it lies within the walkable angle of + * {@link up} and touches the body's underside. Sensors, and colliders the + * body's filters exclude, never support it. One-way platforms are open + * `ChainShape`s, which block only from the side their normal points to: the + * character jumps through one from below and lands on it from above. Dropping + * through one is a game rule; clear the platform's category from the + * character collider's filter mask until the character is below it. + * + * Standing on a moving body carries the character with the body's velocity at + * the character's position, including its rotation, but the character itself + * never rotates. Leaving the support keeps that velocity, and launching from + * it adds it to the launch. A support moved by teleporting carries nothing. + * + * @example + * ```ts + * const physics = hero.addComponent( + * new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }] }), + * ); + * const movement = hero.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 240 })); + * const character = hero.addComponent(new CharacterMovementComponent(movement, { gravity: 1600, maxStepHeight: 8 })); + * + * class PlayerControls extends BehaviorComponent { + * public override fixedUpdate(): void { + * character.move(controls.walk.value); + * + * if (controls.jump.tick.pressed && character.grounded) { + * character.launch({ x: 0, y: -520 }); + * } + * } + * } + * ``` + */ +export class CharacterMovementComponent extends Component { + /** The movement this component drives. */ + public readonly movement: KinematicMovementComponent; + + private readonly _up = new Vector(0, -1); + private _gravity = 0; + private _maxSlopeAngle = Math.PI / 4; + private _minWalkableCos = Math.cos(Math.PI / 4); + private _maxStepHeight = 0; + private _maxFallSpeed = Infinity; + + private _input = 0; + private _inputEpoch = -1; + private _launchPending = false; + private _launchX = 0; + private _launchY = 0; + private _freeX = 0; + private _freeY = 0; + + private _probed = false; + private _grounded = false; + private _groundCollider: Collider | null = null; + private readonly _groundNormal = new Vector(); + private _groundOverlap = 0; + private _onWall = false; + private _onCeiling = false; + private readonly _wallNormal = new Vector(); + private readonly _velocity = new Vector(); + + /** What the slide in progress is for; decides how {@link _constrainNormal} treats its contacts. */ + private _slideMode: SlideMode = 'air'; + private _airborne = false; + private _wallBlocked = false; + private _probeCandidate: Collider | null = null; + private _probeDepth = 0; + private _probeAlong = 0; + private _probeNormalX = 0; + private _probeNormalY = 0; + private _carryX = 0; + private _carryY = 0; + private readonly _exclude: PhysicsBody[] = []; + private readonly _walk: SlideResult = createSlideResult(); + private readonly _scratch: SlideResult = createSlideResult(); + private readonly _step: SlideResult = createSlideResult(); + + /** + * @throws RangeError for an invalid option; see the matching property. + */ + public constructor(movement: KinematicMovementComponent, options: CharacterMovementOptions = {}) { + super(); + + this.movement = movement; + + if (options.up !== undefined) { + this.up = options.up; + } + + const gravity = movement.physics.world.gravity; + + this.gravity = options.gravity ?? Math.max(0, -(gravity.x * this._up.x + gravity.y * this._up.y)); + this.maxSlopeAngle = options.maxSlopeAngle ?? Math.PI / 4; + this.maxStepHeight = options.maxStepHeight ?? 0; + this.maxFallSpeed = options.maxFallSpeed ?? Infinity; + } + + /** + * Unit direction the character stands up along. Assigning normalizes the + * value and re-evaluates the support at the next step. + * + * @throws RangeError for a zero or non-finite direction. + */ + public get up(): Readonly { + return this._up; + } + + public set up(value: Readonly) { + const length = Math.hypot(value.x, value.y); + + if (!Number.isFinite(length) || length === 0) { + throw new RangeError(`CharacterMovementComponent: up must be a finite non-zero direction, received (${value.x}, ${value.y}).`); + } + + this._up.set(value.x / length, value.y / length); + this._probed = false; + } + + /** @throws RangeError when negative or not finite. */ + public get gravity(): number { + return this._gravity; + } + + public set gravity(value: number) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`CharacterMovementComponent: gravity must be a finite non-negative number, received ${value}.`); + } + + this._gravity = value; + } + + /** @throws RangeError outside `[0, π/2)`. */ + public get maxSlopeAngle(): number { + return this._maxSlopeAngle; + } + + public set maxSlopeAngle(value: number) { + if (!(value >= 0 && value < Math.PI / 2)) { + throw new RangeError(`CharacterMovementComponent: maxSlopeAngle must be in [0, π/2) radians, received ${value}.`); + } + + this._maxSlopeAngle = value; + this._minWalkableCos = Math.cos(value); + } + + /** @throws RangeError when negative or not finite. */ + public get maxStepHeight(): number { + return this._maxStepHeight; + } + + public set maxStepHeight(value: number) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`CharacterMovementComponent: maxStepHeight must be a finite non-negative number, received ${value}.`); + } + + this._maxStepHeight = value; + } + + /** @throws RangeError when not positive. */ + public get maxFallSpeed(): number { + return this._maxFallSpeed; + } + + public set maxFallSpeed(value: number) { + if (!(value > 0)) { + throw new RangeError(`CharacterMovementComponent: maxFallSpeed must be positive, received ${value}.`); + } + + this._maxFallSpeed = value; + } + + /** + * `true` when a walkable surface supported the character at the end of the + * latest step and the character was not moving away from it. + */ + public get grounded(): boolean { + return this._grounded && this._supportAlive(); + } + + /** Unit normal of the supporting surface, pointing away from it; zero when not {@link grounded}. */ + public get groundNormal(): Readonly { + if (!this.grounded) { + this._groundNormal.set(0, 0); + } + + return this._groundNormal; + } + + /** The collider the character stands on; a chain is reported as the chain. `null` when not {@link grounded}. */ + public get groundCollider(): Collider | null { + return this.grounded ? this._groundCollider : null; + } + + /** The body the character stands on, or `null` when not {@link grounded}. */ + public get groundBody(): PhysicsBody | null { + return this.grounded ? this._groundCollider!.body : null; + } + + /** `true` when the latest step ran into a surface too steep to stand on that is not a ceiling. */ + public get onWall(): boolean { + return this._onWall; + } + + /** Normal of the wall the latest step ran into, pointing away from it; zero when not {@link onWall}. */ + public get wallNormal(): Readonly { + return this._wallNormal; + } + + /** `true` when the latest step ran into a surface facing down within the walkable angle. */ + public get onCeiling(): boolean { + return this._onCeiling; + } + + /** + * Velocity the latest step actually moved the character at, in world units + * per second: walking, falling and being carried, after collisions. Zero + * before the first step. + */ + public get velocity(): Readonly { + return this._velocity; + } + + /** + * Walks along the ground during the world's next advance, at the movement's + * `maxSpeed` times `input`: positive towards the right of {@link up} (`+x` + * for the default up), negative towards the left. `input` is clamped to + * `[-1, 1]`. In the air the same input steers sideways. Like a movement + * command it lasts until the world has advanced once, so issue it every + * fixed step; the last call before the world advances wins. + * + * @throws RangeError for a non-finite input. + */ + public move(input: number): void { + if (!Number.isFinite(input)) { + throw new RangeError(`CharacterMovementComponent.move: input must be finite, received ${input}.`); + } + + this._input = Math.min(1, Math.max(-1, input)); + this._inputEpoch = this.movement.physics.world._stepEpoch; + } + + /** + * Replaces the character's airborne velocity with `velocity`, in world units + * per second, at the start of the next fixed step, and lets it leave the + * ground. Launching from a moving support adds the support's velocity. + * Gravity then acts on it until the character lands, hits a ceiling or wall + * that absorbs it, or is launched again. Walking input adds on top and is + * not part of it. + * + * Applies once, however many fixed steps the next advance runs, and works + * whether or not the character is {@link grounded}. Launching with zero + * velocity cancels the airborne velocity. + * + * @throws RangeError for a non-finite velocity. + */ + public launch(velocity: Readonly): void { + if (!Number.isFinite(velocity.x) || !Number.isFinite(velocity.y)) { + throw new RangeError(`CharacterMovementComponent.launch: velocity must be finite, received (${velocity.x}, ${velocity.y}).`); + } + + this._launchPending = true; + this._launchX = velocity.x; + this._launchY = velocity.y; + } + + protected override onEnable(): void { + const movement = this.movement; + + if (movement._driver !== null && movement._driver !== this) { + throw new Error( + 'CharacterMovementComponent: the movement is already driven by another CharacterMovementComponent. Disable that one first.', + ); + } + + // A command issued to the movement before the takeover is not the + // character's and must not run once the movement is handed back. + movement.stop(); + movement._driver = this; + this._reset(); + } + + protected override onDisable(): void { + if (this.movement._driver === this) { + this.movement._driver = null; + } + + this._reset(); + this.movement.stop(); + } + + /** @internal */ + public _driveStep(fixedDelta: number): void { + const upX = this._up.x; + const upY = this._up.y; + const world = this.movement.physics.world; + + if (!this._probed) { + this._probe(); + } + + if (!this._supportAlive()) { + this._clearSupport(); + } + + const support = this._groundCollider; + const input = this._inputEpoch === world._stepEpoch ? this._input : 0; + const speed = input * this.movement.maxSpeed; + const launching = this._launchPending; + + let carryX = 0; + let carryY = 0; + + if (this._grounded && support !== null) { + this._pointVelocity(support.body); + carryX = this._carryX; + carryY = this._carryY; + } + + this._launchPending = false; + this._onWall = false; + this._onCeiling = false; + this._wallNormal.set(0, 0); + + const result = this._walk; + + this._airborne = !this._grounded || launching; + + if (!this._airborne) { + this._freeX = 0; + this._freeY = 0; + this._walkStep(speed * fixedDelta, carryX, carryY, fixedDelta); + } else { + if (launching) { + this._freeX = this._launchX + carryX; + this._freeY = this._launchY + carryY; + } + + this._freeX -= upX * this._gravity * fixedDelta; + this._freeY -= upY * this._gravity * fixedDelta; + + const fall = -(this._freeX * upX + this._freeY * upY); + + if (fall > this._maxFallSpeed) { + this._freeX += upX * (fall - this._maxFallSpeed); + this._freeY += upY * (fall - this._maxFallSpeed); + } + + const sideways = speed * fixedDelta; + + this._slideMode = 'air'; + this.movement._slide(0, 0, this._freeX * fixedDelta - upY * sideways, this._freeY * fixedDelta + upX * sideways, this, null, result); + } + + this._velocity.set(result.x / fixedDelta, result.y / fixedDelta); + this.movement._commit(result.x, result.y, fixedDelta, result.blocked, result.normalX, result.normalY); + } + + /** @internal */ + public _afterStep(): void { + this._probe(); + } + + /** @internal - classifies each contact a slide runs into and keeps a walk on the ground. */ + public _constrainNormal(_target: Collider, normal: Vector): void { + const upX = this._up.x; + const upY = this._up.y; + const along = normal.x * upX + normal.y * upY; + + if (this._slideMode === 'snap') { + // Ground ends the snap where it is met; anything else is slid off. + if (along >= this._minWalkableCos) { + normal.set(upX, upY); + } + + return; + } + + if (along <= -this._minWalkableCos) { + this._onCeiling = true; + } else if (along < this._minWalkableCos) { + this._onWall = true; + this._wallNormal.set(normal.x, normal.y); + + // On the ground, a surface too steep to stand on stops the walk like a + // wall. Sliding along its true normal would carry the character up it. + if (this._slideMode === 'walk' && along > 0) { + normal.set(normal.x - along * upX, normal.y - along * upY); + normal.normalize(); + } + + if (this._slideMode === 'walk') { + this._wallBlocked = true; + } + } + + const into = this._freeX * normal.x + this._freeY * normal.y; + + if (into < 0) { + this._freeX -= into * normal.x; + this._freeY -= into * normal.y; + } + } + + /** @internal - keeps the closest walkable surface the support probe touches. */ + public _visitContact(target: Collider, normalX: number, normalY: number, penetration: number): void { + const along = normalX * this._up.x + normalY * this._up.y; + + if (along < this._minWalkableCos) { + return; + } + + if (this._probeCandidate === null || penetration > this._probeDepth || (penetration === this._probeDepth && along > this._probeAlong)) { + this._probeCandidate = target; + this._probeDepth = penetration; + this._probeAlong = along; + this._probeNormalX = normalX; + this._probeNormalY = normalY; + } + } + + /** + * Walks one step along the supporting surface: recovers from overlapping + * the support, walks or steps up, follows the ground down, then rides the + * support's own motion. Every part but the ride is resolved against the + * support where it stands before the step, which is where the walk is + * relative to. Leaves the outcome in {@link _walk}. + */ + private _walkStep(distance: number, carryVelocityX: number, carryVelocityY: number, fixedDelta: number): void { + const movement = this.movement; + const upX = this._up.x; + const upY = this._up.y; + const groundX = this._groundNormal.x; + const groundY = this._groundNormal.y; + const support = this._groundCollider!; + const exclude = this._exclude; + const walk = this._walk; + const scratch = this._scratch; + + exclude.length = 0; + exclude.push(support.body); + + let startX = 0; + let startY = 0; + + if (this._groundOverlap > 0) { + const depth = this._groundOverlap + contactOffset; + + movement._sweep(0, 0, groundX * depth, groundY * depth, exclude, scratch); + startX = scratch.x; + startY = scratch.y; + } + + // The ground's tangent, oriented like the right of `up`. + const tangentX = -groundY; + const tangentY = groundX; + + this._slideMode = 'walk'; + movement._slide(startX, startY, tangentX * distance, tangentY * distance, this, null, walk); + + let stepped = false; + + if (this._wallBlocked && this._maxStepHeight > 0 && Math.abs(distance) > negligible) { + stepped = this._stepUp(startX, startY, distance, walk); + } + + // Set by the slide's constraint only; cleared here so the next walk starts clean. + this._wallBlocked = false; + + if (!stepped) { + const reach = this._maxStepHeight + Math.abs(distance) * Math.tan(this._maxSlopeAngle) + supportProbe; + + // Sliding rather than stopping at the first contact lets a rounded body + // come off the edge of a step down onto the lower ground; a contact + // that is not walkable at the end means there is no ground to snap to. + this._slideMode = 'snap'; + movement._slide(walk.x, walk.y, -upX * reach, -upY * reach, this, null, scratch); + + if (scratch.blocked && scratch.normalX * upX + scratch.normalY * upY >= this._minWalkableCos) { + walk.x = scratch.x; + walk.y = scratch.y; + } else { + // Walking off the support keeps its motion, like stepping off a + // moving walkway would. + this._freeX = carryVelocityX; + this._freeY = carryVelocityY; + } + } + + if (carryVelocityX !== 0 || carryVelocityY !== 0) { + this._slideMode = 'air'; + movement._slide(walk.x, walk.y, carryVelocityX * fixedDelta, carryVelocityY * fixedDelta, this, exclude, scratch); + walk.x = scratch.x; + walk.y = scratch.y; + + if (scratch.blocked) { + walk.blocked = true; + walk.normalX = scratch.normalX; + walk.normalY = scratch.normalY; + } + } + + exclude.length = 0; + } + + /** + * Tries to step onto an obstacle that stopped the walk: up by at most + * {@link maxStepHeight}, forward, and down onto a walkable top. Rewrites + * `walk` and returns `true` when that gets further than the blocked walk. + */ + private _stepUp(startX: number, startY: number, distance: number, walk: SlideResult): boolean { + const movement = this.movement; + const upX = this._up.x; + const upY = this._up.y; + const rightX = -upY; + const rightY = upX; + const step = this._step; + const height = this._maxStepHeight; + + movement._sweep(startX, startY, upX * height, upY * height, null, step); + + const riseX = step.x; + const riseY = step.y; + const rise = (riseX - startX) * upX + (riseY - startY) * upY; + + if (rise <= contactOffset) { + return false; + } + + movement._sweep(riseX, riseY, rightX * distance, rightY * distance, null, step); + + // Running into the obstacle's top while still raised means the top is + // higher than the step allows: a rounded body would otherwise ride up its + // corner. + if (step.blocked && step.normalX * upX + step.normalY * upY > negligible) { + return false; + } + + const forwardX = step.x; + const forwardY = step.y; + const progress = ((forwardX - startX) * rightX + (forwardY - startY) * rightY) * Math.sign(distance); + const walked = ((walk.x - startX) * rightX + (walk.y - startY) * rightY) * Math.sign(distance); + + if (progress <= walked + negligible) { + return false; + } + + const reach = rise + Math.abs(distance) * Math.tan(this._maxSlopeAngle) + supportProbe; + + movement._sweep(forwardX, forwardY, -upX * reach, -upY * reach, null, step); + + if (!step.blocked || step.normalX * upX + step.normalY * upY < this._minWalkableCos) { + return false; + } + + walk.x = step.x; + walk.y = step.y; + walk.blocked = false; + walk.target = null; + walk.normalX = 0; + walk.normalY = 0; + this._onWall = false; + this._wallNormal.set(0, 0); + + return true; + } + + /** Finds the surface supporting the body where it stands now. */ + private _probe(): void { + const body = this.movement.body; + const queries = this.movement.physics.world._queries; + const upX = this._up.x; + const upY = this._up.y; + + this._probed = true; + + if (!body.attached || body.destroyed) { + this._clearSupport(); + + return; + } + + for (const collider of body.colliders) { + if (isCastable(collider)) { + queries._visitContacts(collider, -upX * supportProbe, -upY * supportProbe, body, this); + } + } + + const candidate = this._probeCandidate; + + // Left empty between probes, so the visitor starts from scratch. + this._probeCandidate = null; + + if (candidate === null) { + this._clearSupport(); + + return; + } + + // Moving away from the surface is leaving it, however close it still is: + // a launch must not be cancelled by the ground it starts from. Only an + // airborne step has a velocity of its own; a walk moves with the support. + this._pointVelocity(candidate.body); + + if (this._airborne && (this._freeX - this._carryX) * upX + (this._freeY - this._carryY) * upY > negligible) { + this._clearSupport(); + + return; + } + + this._grounded = true; + this._groundCollider = candidate; + this._groundNormal.set(this._probeNormalX, this._probeNormalY); + this._groundOverlap = Math.max(0, this._probeDepth - supportProbe * this._probeAlong); + } + + /** Velocity of `body`'s material point under the character, into {@link _carryX}/{@link _carryY}. */ + private _pointVelocity(body: PhysicsBody): void { + const self = this.movement.body; + const rx = self.x - body.worldCenterOfMassX; + const ry = self.y - body.worldCenterOfMassY; + + this._carryX = body.linearVelocityX - body.angularVelocity * ry; + this._carryY = body.linearVelocityY + body.angularVelocity * rx; + } + + private _supportAlive(): boolean { + const collider = this._groundCollider; + + return collider !== null && !collider.destroyed && collider.body.attached && !collider.body.destroyed; + } + + private _clearSupport(): void { + this._grounded = false; + this._groundCollider = null; + this._groundNormal.set(0, 0); + this._groundOverlap = 0; + } + + private _reset(): void { + this._clearSupport(); + this._probed = false; + this._input = 0; + this._inputEpoch = -1; + this._launchPending = false; + this._airborne = false; + this._freeX = 0; + this._freeY = 0; + this._onWall = false; + this._onCeiling = false; + this._wallNormal.set(0, 0); + this._velocity.set(0, 0); + } +} diff --git a/packages/exojs-physics/src/ContactGraph.ts b/packages/exojs-physics/src/ContactGraph.ts index b43dbd9a0..0ed0c8273 100644 --- a/packages/exojs-physics/src/ContactGraph.ts +++ b/packages/exojs-physics/src/ContactGraph.ts @@ -151,7 +151,11 @@ export class ContactGraph { // Checked against the size first so a world without such a joint pays one // property read per pair rather than a map lookup. - if (this._uncollidablePairs !== null && this._uncollidablePairs.size > 0 && this._uncollidablePairs.has(bodyPairKey(ownerA.body.id, ownerB.body.id))) { + if ( + this._uncollidablePairs !== null && + this._uncollidablePairs.size > 0 && + this._uncollidablePairs.has(bodyPairKey(ownerA.body.id, ownerB.body.id)) + ) { continue; } diff --git a/packages/exojs-physics/src/KinematicMovementComponent.ts b/packages/exojs-physics/src/KinematicMovementComponent.ts new file mode 100644 index 000000000..f181a8300 --- /dev/null +++ b/packages/exojs-physics/src/KinematicMovementComponent.ts @@ -0,0 +1,527 @@ +import { Component, type PointLike, Vector } from '@codexo/exojs'; + +import type { Collider } from './Collider'; +import type { PhysicsBody } from './PhysicsBody'; +import type { PhysicsBodyComponent } from './PhysicsBodyComponent'; +import { type CastState, createCastState } from './query/QueryEngine'; + +/** + * Takes over the per-step motion of a {@link KinematicMovementComponent}: while + * one is installed, the movement accepts no direct commands and asks the + * driver for each step's motion instead. + * @internal + */ +export interface MovementDriver { + /** Resolves the step about to run, ending in exactly one {@link KinematicMovementComponent._commit}. */ + _driveStep(fixedDelta: number): void; + /** Runs once the step has moved every body. */ + _afterStep(): void; +} + +/** + * Rewrites the normal a slide pass deflects along, after the pass has + * travelled up to the contact the normal belongs to. + * @internal + */ +export interface SlideConstraint { + _constrainNormal(target: Collider, normal: Vector): void; +} + +/** + * Where a slide or sweep ended, as an offset from the body's current + * position, and the last contact that shortened it. + * @internal + */ +export interface SlideResult { + x: number; + y: number; + blocked: boolean; + target: Collider | null; + normalX: number; + normalY: number; +} + +/** @internal */ +export const createSlideResult = (): SlideResult => ({ x: 0, y: 0, blocked: false, target: null, normalX: 0, normalY: 0 }); + +/** Construction options for {@link KinematicMovementComponent}. */ +export interface KinematicMovementOptions { + /** Speed in world units per second that a direction of length `1` moves at. */ + readonly maxSpeed: number; +} + +/** + * Gap the movement keeps between its body and whatever stopped it. Ending a + * step exactly in contact would make the next step's cast start out touching, + * where floating-point noise decides whether the wall counts as overlapping. + * @internal + */ +export const contactOffset = 0.01; + +/** + * Slide passes per step: the first contact, the wall slid along, and a corner + * where a second wall ends the slide. A third redirect only arises in geometry + * that converges on the body, where stopping is the right answer. + */ +const maxSlidePasses = 4; + +/** + * Lowest approach cosine the contact offset is divided by. Backing off along a + * grazing motion to open the gap along the normal would otherwise give up most + * of the step; this caps the backoff at ten offsets. + * @internal + */ +export const minApproach = 0.1; + +/** + * Moves a kinematic body in requested directions without passing through + * colliders: blocked motion stops at the obstacle and slides along it, and a + * corner between two walls stops it. + * + * Call {@link move} with the direction for the coming step. A command lasts + * until the world has advanced once - one `PhysicsWorld.fixedUpdate`, or + * one `PhysicsWorld.step` call that ran at least one fixed step - and the + * body stands still in any step no command was given for. Issue it every fixed + * step while the body should move, typically from a + * `BehaviorComponent.fixedUpdate`, which runs before a world registered + * as a scene system steps. Nothing has to be cleared when the controlling code + * stops: no command means no motion. + * + * The world resolves the command at the start of its step against the + * geometry as it then stands, sets the body's velocity to the motion it found + * and integrates it like any kinematic motion, so contacts, interpolation and + * the bound node see continuous movement rather than a teleport. The body + * stays the single transform authority. Motion is translation only; the + * body's angular velocity is left to the caller. + * + * Every non-sensor circle, capsule and polygon collider of the body is cast, + * under its own collision filter. Static, kinematic and dynamic colliders + * block alike - a dynamic body in the way stops the movement rather than being + * pushed - and sensors never block. Two movements moving towards each other in + * one step each cast against the other's position before the step. + * + * The component holds the body's movement while it is active: enabling a + * second movement for the same body throws. Disabling it zeroes the body's + * velocity. Positions and directions are in the world's simulation space. + * + * A `CharacterMovementComponent` composed over the movement takes over its + * commands while that character is enabled: {@link move} then throws, and the + * character is the body's only source of motion. + * + * @example + * ```ts + * const physics = hero.addComponent(new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CircleShape(12) }] })); + * const movement = hero.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 180 })); + * + * class Steering extends BehaviorComponent { + * public override fixedUpdate(): void { + * movement.move(input.direction); + * } + * } + * ``` + */ +export class KinematicMovementComponent extends Component { + /** The physics component whose body this component moves. */ + public readonly physics: PhysicsBodyComponent; + + /** @internal - the component that resolves this movement's steps instead of {@link move}, if any. */ + public _driver: MovementDriver | null = null; + + private _maxSpeed = 0; + private _directionX = 0; + private _directionY = 0; + private _commandEpoch = -1; + private _budget = 0; + private _blocked = false; + private readonly _contactNormal = new Vector(); + private readonly _cast: CastState = createCastState(); + private readonly _best: CastState = createCastState(); + private readonly _slideNormal = new Vector(); + private readonly _ownResult: SlideResult = createSlideResult(); + private _registered = false; + + /** + * @throws TypeError when the physics component's body is not kinematic. + * @throws RangeError when `maxSpeed` is negative or not finite. + */ + public constructor(physics: PhysicsBodyComponent, options: KinematicMovementOptions) { + super(); + + if (physics.body.type !== 'kinematic') { + throw new TypeError( + `KinematicMovementComponent: the body is '${physics.body.type}', but only a kinematic body takes its motion from the movement. Create the PhysicsBodyComponent with type 'kinematic'.`, + ); + } + + this.physics = physics; + this.maxSpeed = options.maxSpeed; + } + + /** The moved body. */ + public get body(): PhysicsBody { + return this.physics.body; + } + + /** Speed in world units per second for a direction of length `1`. */ + public get maxSpeed(): number { + return this._maxSpeed; + } + + /** @throws RangeError when negative or not finite. */ + public set maxSpeed(value: number) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`KinematicMovementComponent: maxSpeed must be a finite non-negative number, received ${value}.`); + } + + this._maxSpeed = value; + } + + /** + * `true` when a collider shortened the motion of the last resolved step. + * Reset by the next step that resolves a command. + */ + public get blocked(): boolean { + return this._blocked; + } + + /** + * Surface normal of the last collider that shortened the motion, pointing + * away from it; zero when the last step was not {@link blocked}. + */ + public get contactNormal(): Readonly { + return this._contactNormal; + } + + /** @internal */ + public get _followsOthers(): boolean { + return this._driver !== null; + } + + /** + * Moves the body along `direction` during the world's next advance, at + * {@link maxSpeed} times the direction's length. A direction longer than `1` + * is shortened to `1`, so analog input below full deflection moves slower + * and a raw difference vector moves at full speed. The last call before the + * world advances wins. + * + * `distance` caps how far the command travels in total. A + * `PhysicsWorld.step` call that runs several fixed steps applies the same + * command to each of them, so a command meant to end on a point - the last + * stretch to a destination - passes the distance to it and stops there + * instead of overshooting by the extra steps. Defaults to no cap. + * + * @throws RangeError for a non-finite direction or a negative or NaN distance. + * @throws Error while a `CharacterMovementComponent` drives this movement. + */ + public move(direction: Readonly, distance = Infinity): void { + const x = direction.x; + const y = direction.y; + + if (this._driver !== null) { + throw new Error( + 'KinematicMovementComponent.move: a CharacterMovementComponent drives this movement. Command the character instead, or disable it first.', + ); + } + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + throw new RangeError(`KinematicMovementComponent.move: direction must be finite, received (${x}, ${y}).`); + } + + if (!(distance >= 0)) { + throw new RangeError(`KinematicMovementComponent.move: distance must be non-negative, received ${distance}.`); + } + + const length = Math.hypot(x, y); + const scale = length > 1 ? 1 / length : 1; + + this._directionX = x * scale; + this._directionY = y * scale; + this._budget = distance; + this._commandEpoch = this.physics.world._stepEpoch; + } + + /** Cancels a pending command and halts the body at once. */ + public stop(): void { + this._commandEpoch = -1; + this._directionX = 0; + this._directionY = 0; + this._halt(); + } + + protected override onEnable(): void { + const body = this.physics.body; + + if (body._movement !== null && body._movement !== this) { + throw new Error( + 'KinematicMovementComponent: the body is already moved by another KinematicMovementComponent. Disable that one first.', + ); + } + + body._movement = this; + this.physics.world._addStepController(this); + this._registered = true; + } + + protected override onDisable(): void { + const body = this.physics.body; + + if (body._movement === this) { + body._movement = null; + } + + if (this._registered) { + this._registered = false; + this.physics.world._removeStepController(this); + } + + this._commandEpoch = -1; + this._halt(); + } + + /** @internal - resolves the pending command into the body's velocity for the step about to run. */ + public _beforeStep(fixedDelta: number): void { + const body = this.physics.body; + + if (!body.attached || body.destroyed) { + return; + } + + if (this._driver !== null) { + this._driver._driveStep(fixedDelta); + + return; + } + + if ( + this._commandEpoch !== this.physics.world._stepEpoch || + (this._directionX === 0 && this._directionY === 0) || + this._maxSpeed === 0 || + this._budget <= 0 + ) { + body.linearVelocityX = 0; + body.linearVelocityY = 0; + this._blocked = false; + this._contactNormal.set(0, 0); + + return; + } + + const length = Math.hypot(this._directionX, this._directionY); + const distance = Math.min(this._maxSpeed * fixedDelta * length, this._budget); + const scale = distance / length; + const result = this._ownResult; + + this._budget -= distance; + this._slide(0, 0, this._directionX * scale, this._directionY * scale, null, null, result); + this._commit(result.x, result.y, fixedDelta, result.blocked, result.normalX, result.normalY); + } + + /** @internal - forwards the end of a step to the driver. */ + public _afterStep(): void { + const body = this.physics.body; + + if (this._driver !== null && body.attached && !body.destroyed) { + this._driver._afterStep(); + } + } + + /** + * @internal - slides the body from `(offsetX, offsetY)` along `(dx, dy)` + * against the geometry as it stands and writes where it ends into `out`. + * `constraint` may rewrite each blocking normal before the slide deflects + * along it; nothing in `exclude` blocks. Moves nothing. + */ + public _slide( + offsetX: number, + offsetY: number, + dx: number, + dy: number, + constraint: SlideConstraint | null, + exclude: ReadonlyArray | null, + out: SlideResult, + ): void { + const body = this.physics.body; + const best = this._best; + const normal = this._slideNormal; + + let remainingX = dx; + let remainingY = dy; + let previousNormalX = 0; + let previousNormalY = 0; + + out.blocked = false; + out.target = null; + out.normalX = 0; + out.normalY = 0; + + for (let pass = 0; pass < maxSlidePasses; pass++) { + const length = Math.hypot(remainingX, remainingY); + + if (length < 1e-9 || !this._castBody(body, offsetX, offsetY, remainingX, remainingY, exclude)) { + offsetX += remainingX; + offsetY += remainingY; + break; + } + + const travelled = travelFraction(best, remainingX, remainingY, length); + + out.blocked = true; + out.target = best.target; + out.normalX = best.normalX; + out.normalY = best.normalY; + offsetX += remainingX * travelled; + offsetY += remainingY * travelled; + remainingX -= remainingX * travelled; + remainingY -= remainingY * travelled; + + normal.set(best.normalX, best.normalY); + + if (constraint !== null && best.target !== null) { + constraint._constrainNormal(best.target, normal); + } + + const normalX = normal.x; + const normalY = normal.y; + const into = remainingX * normalX + remainingY * normalY; + + if (into < 0) { + remainingX -= into * normalX; + remainingY -= into * normalY; + } + + // Two walls meeting at less than a straight angle leave no direction in + // 2D that slides along both, so the slide off the second one would only + // walk back into the first. + if (pass > 0 && remainingX * previousNormalX + remainingY * previousNormalY < 0) { + break; + } + + previousNormalX = normalX; + previousNormalY = normalY; + } + + out.x = offsetX; + out.y = offsetY; + } + + /** + * @internal - moves the body straight from `(offsetX, offsetY)` along + * `(dx, dy)` up to the first contact, without sliding, and writes where it + * ends into `out`. Moves nothing. + */ + public _sweep( + offsetX: number, + offsetY: number, + dx: number, + dy: number, + exclude: ReadonlyArray | null, + out: SlideResult, + ): void { + const best = this._best; + const length = Math.hypot(dx, dy); + + out.blocked = false; + out.target = null; + out.normalX = 0; + out.normalY = 0; + + if (length < 1e-9 || !this._castBody(this.physics.body, offsetX, offsetY, dx, dy, exclude)) { + out.x = offsetX + dx; + out.y = offsetY + dy; + + return; + } + + const travelled = travelFraction(best, dx, dy, length); + + out.blocked = true; + out.target = best.target; + out.normalX = best.normalX; + out.normalY = best.normalY; + out.x = offsetX + dx * travelled; + out.y = offsetY + dy * travelled; + } + + /** + * @internal - sets the body's velocity for the step about to run to the + * motion `(offsetX, offsetY)` and records what blocked it. + */ + public _commit(offsetX: number, offsetY: number, fixedDelta: number, blocked: boolean, normalX: number, normalY: number): void { + const body = this.physics.body; + + this._blocked = blocked; + this._contactNormal.set(blocked ? normalX : 0, blocked ? normalY : 0); + + if (body.isSleeping && (offsetX !== 0 || offsetY !== 0)) { + body.wake(); + } + + body.linearVelocityX = offsetX / fixedDelta; + body.linearVelocityY = offsetY / fixedDelta; + } + + private _halt(): void { + const body = this.physics.body; + + this._blocked = false; + this._contactNormal.set(0, 0); + + if (!body.destroyed) { + body.linearVelocityX = 0; + body.linearVelocityY = 0; + } + } + + /** Casts every castable collider of the body and keeps the earliest contact in {@link _best}. */ + private _castBody( + body: PhysicsBody, + offsetX: number, + offsetY: number, + dx: number, + dy: number, + exclude: ReadonlyArray | null, + ): boolean { + const queries = this.physics.world._queries; + const cast = this._cast; + const best = this._best; + + best.target = null; + best.fraction = Infinity; + best.initialOverlap = false; + + for (const collider of body.colliders) { + if (!isCastable(collider) || !queries._castCollider(collider, offsetX, offsetY, dx, dy, body, cast, exclude)) { + continue; + } + + if (cast.fraction < best.fraction || (cast.initialOverlap && !best.initialOverlap)) { + best.target = cast.target; + best.fraction = cast.fraction; + best.normalX = cast.normalX; + best.normalY = cast.normalY; + best.initialOverlap = cast.initialOverlap; + best.penetration = cast.penetration; + } + } + + return best.target !== null; + } +} + +/** + * Fraction of `(dx, dy)` travelled before `hit`, backed off to keep the + * contact offset along the hit normal. + */ +const travelFraction = (hit: CastState, dx: number, dy: number, length: number): number => { + if (hit.initialOverlap) { + return 0; + } + + const approach = -(dx * hit.normalX + dy * hit.normalY) / length; + + return Math.max(0, hit.fraction * length - contactOffset / Math.max(approach, minApproach)) / length; +}; + +/** @internal - `true` for a collider the movement casts: a non-sensor with an area. */ +export const isCastable = (collider: Collider): boolean => + !collider.isSensor && collider.shape.type !== 'segment' && collider.shape.type !== 'chain'; diff --git a/packages/exojs-physics/src/PhysicsBody.ts b/packages/exojs-physics/src/PhysicsBody.ts index e0f9887e6..a9d048841 100644 --- a/packages/exojs-physics/src/PhysicsBody.ts +++ b/packages/exojs-physics/src/PhysicsBody.ts @@ -48,6 +48,9 @@ export interface BodyOwner { * body with {@link applyForce}, {@link applyTorque} and {@link applyImpulse}, or * set {@link linearVelocityX}/{@link linearVelocityY}/{@link angularVelocity} * directly; reposition it (teleport, no velocity) with {@link setTransform}. + * The force, torque, impulse and transform methods wake a sleeping body; the + * velocity fields are plain fields and do not, so call {@link wake} before + * steering a body that may have fallen asleep by writing its velocity. */ export class PhysicsBody { public readonly type: BodyType; @@ -72,11 +75,11 @@ export class PhysicsBody { /** Inverse rotational inertia (`0` = no angular response). */ public invInertia = 0; - /** Linear velocity X in px/s. */ + /** Linear velocity X in px/s. Writing it does not wake a sleeping body. */ public linearVelocityX = 0; - /** Linear velocity Y in px/s. */ + /** Linear velocity Y in px/s. Writing it does not wake a sleeping body. */ public linearVelocityY = 0; - /** Angular velocity in rad/s. */ + /** Angular velocity in rad/s. Writing it does not wake a sleeping body. */ public angularVelocity = 0; /** When `false`, this body is never put to sleep. Default `true`. */ @@ -84,6 +87,8 @@ export class PhysicsBody { /** @internal - set by `PhysicsBodyComponent`; such a body may not be constrained by a joint. */ public _componentManaged = false; + /** @internal - the movement component currently driving this body, so a second one is refused. */ + public _movement: object | null = null; /** * @internal - whether the body left a world through `remove` and has not @@ -534,7 +539,12 @@ export class PhysicsBody { const sin = Math.sin(newAngle); // origin = newCoM − R(newAngle)·comLocal (so rotation pivots about the CoM). - setTransform(this._transform, newComX - (cos * this._comX - sin * this._comY), newComY - (sin * this._comX + cos * this._comY), newAngle); + setTransform( + this._transform, + newComX - (cos * this._comX - sin * this._comY), + newComY - (sin * this._comX + cos * this._comY), + newAngle, + ); this._resetDelta(); diff --git a/packages/exojs-physics/src/PhysicsBodyComponent.ts b/packages/exojs-physics/src/PhysicsBodyComponent.ts index 5e32aa4c6..341f9d9f8 100644 --- a/packages/exojs-physics/src/PhysicsBodyComponent.ts +++ b/packages/exojs-physics/src/PhysicsBodyComponent.ts @@ -8,9 +8,11 @@ import type { PhysicsWorld } from './PhysicsWorld'; * Gives a scene node a {@link PhysicsBody} whose world membership follows the * component's lifecycle. While the component is active the body is in * {@link world} and drives the node. Whenever the component is disabled - - * switched off, its node removed or reparented, its scene retained or ended - + * switched off, its node removed from its scene, its scene retained or ended - * the body leaves the world with its state kept, and the next activation puts - * the same body back. Destroying the component, or its node, destroys the + * the same body back. Moving the node to another parent of the same scene is + * not a disable: the body stays in the world, keeps its contacts and state, + * and keeps driving the node. Destroying the component, or its node, destroys the * body. Pausing the scene changes nothing here: a world registered as that * scene's system stops stepping with it, a world owned elsewhere keeps going. * @@ -18,7 +20,8 @@ import type { PhysicsWorld } from './PhysicsWorld'; * the node enters a scene. Unless `options.position` or `options.angle` is * given, the first activation places the body at the node's current world * position or rotation. The node must be world-space-rooted, as for - * {@link PhysicsWorld.bind}. + * {@link PhysicsWorld.bind}; the body keeps writing world coordinates to the + * node, so moving it under a transformed parent is not corrected. * * The world is never inferred: a node moved into a scene that steps another * world keeps this component's body in the world it was given. A @@ -59,7 +62,10 @@ export class PhysicsBodyComponent extends Component { // body is the transform authority, and the node merely shows where it is. // A failed attempt leaves the flags set, so the retry reads the node anew. if (this._adoptNodePosition || this._adoptNodeAngle) { - body.setTransform(this._adoptNodePosition ? nodeWorldPosition(node) : body.position, this._adoptNodeAngle ? nodeWorldAngle(node) : body.angle); + body.setTransform( + this._adoptNodePosition ? nodeWorldPosition(node) : body.position, + this._adoptNodeAngle ? nodeWorldAngle(node) : body.angle, + ); } this.world.add(body); @@ -70,6 +76,7 @@ export class PhysicsBodyComponent extends Component { // A failed enable leaves the component inactive; the body must not keep // simulating behind it. this.world.remove(body); + throw error; } diff --git a/packages/exojs-physics/src/PhysicsWorld.ts b/packages/exojs-physics/src/PhysicsWorld.ts index 8f7738b35..a9cd5a02b 100644 --- a/packages/exojs-physics/src/PhysicsWorld.ts +++ b/packages/exojs-physics/src/PhysicsWorld.ts @@ -16,7 +16,7 @@ import type { CollisionEvent, SensorEvent } from './events'; import { type Joint, softConstraint } from './joints/Joint'; import type { BodyOwner } from './PhysicsBody'; import { PhysicsBody } from './PhysicsBody'; -import type { QueryFilter, RayHit } from './query/QueryEngine'; +import type { QueryFilter, RayHit, ShapeCastHit, ShapeCastOptions } from './query/QueryEngine'; import { QueryEngine } from './query/QueryEngine'; import type { AnyShape } from './shapes/AnyShape'; import { sleepPenetrationTolerance, sleepPushOutProgressSpeed } from './solver/tolerances'; @@ -166,11 +166,35 @@ const isUnresolved = (record: ContactRecord, dt: number): boolean => { return false; }; +/** + * Something that resolves its body's motion at the start of every fixed step, + * against the geometry as the step finds it. + * @internal + */ +export interface StepController { + /** + * `true` while the controller reads other bodies' velocities for the step + * about to run (a character carried by a moving platform). Such controllers + * resolve after every other controller, so a platform moved by a controller + * of its own has its velocity for the step set before it is read. + */ + readonly _followsOthers: boolean; + _beforeStep(fixedDelta: number): void; + /** Runs once the step has moved every body and re-synced collider geometry. */ + _afterStep(): void; +} + /** Construction options for a {@link PhysicsWorld}. */ export interface PhysicsWorldOptions { /** Gravity in px/s² (+Y down). Integrated each sub-step. Default `(0, 0)`. */ gravity?: Readonly; - /** Fixed timestep in seconds. Default `1 / 60`. */ + /** + * Fixed timestep in seconds. Default `1 / 60`. + * + * A world registered as a system steps once per host fixed step and must + * use the host's duration - pass `app.fixedTimeStep`. A mismatch throws on + * the first step; see {@link PhysicsWorld.fixedUpdate}. + */ fixedDelta?: number; /** Maximum fixed steps per `step` call (spiral-of-death guard). Default `8`. */ maxSubSteps?: number; @@ -198,18 +222,18 @@ export interface PhysicsWorldOptions { * to the latest one, which smooths motion when the fixed rate is below the * frame rate. Default `false`. * - * The blend factor comes from {@link frameAlphaSource}; a world driven as a - * `System` has to supply one. + * The blend factor comes from {@link frameAlphaSource}; a world registered + * as a system has to supply one. */ interpolation?: boolean; /** * Where interpolated bindings read their blend factor, in `[0, 1)`. * * Defaults to this world's own accumulator - correct when the world is driven - * with {@link PhysicsWorld.step}, which is what advances it. A world driven as - * a `System` goes through {@link PhysicsWorld.fixedUpdate} and bypasses that - * accumulator entirely, so the host owns the fraction: pass - * `() => app.frameAlpha`. + * with {@link PhysicsWorld.step}, which is what advances it. A world + * registered as a system is stepped by its host and has no fraction of its + * own: pass the host's, `() => app.frameAlpha`. Without one, a hosted world + * presents the latest step unsmoothed and development builds warn. */ frameAlphaSource?: () => number; /** Linear speed at or below which a body is a sleep candidate, px/s. Default `5`. */ @@ -371,8 +395,9 @@ export class PhysicsWorld implements BodyOwner { /** * Supplies the `[0, 1)` blend factor for interpolated bindings. Defaults to * this world's own accumulator; point it at the host's own fraction - * (`() => app.frameAlpha`) when the world runs as a `System`, because - * {@link fixedUpdate} never advances the local accumulator. + * (`() => app.frameAlpha`) when the world runs as a system, because + * {@link fixedUpdate} never advances the local accumulator. The default + * presents a hosted world's latest step as it stands. */ public frameAlphaSource: () => number; @@ -415,6 +440,7 @@ export class PhysicsWorld implements BodyOwner { private readonly _bindings = new BindingRegistry(); private readonly _query: QueryEngine; private readonly _commands: Array<() => void> = []; + private readonly _stepControllers: StepController[] = []; /** Colliders with a deferred removal queued but not yet applied; see {@link assertBodyKeepsItsMass}. */ private readonly _pendingColliderRemovals = new Set(); /** @@ -460,6 +486,12 @@ export class PhysicsWorld implements BodyOwner { */ public _ccdBroadPhaseCandidates = 0; + /** + * @internal - counts the {@link step} and {@link fixedUpdate} calls that + * advanced the simulation. A movement command records it, which is how a + * command lasts exactly until the call that consumed it. + */ + public _stepEpoch = 0; private _nextBodyId = 1; private _nextColliderId = 1; private _dispatching = false; @@ -487,7 +519,7 @@ export class PhysicsWorld implements BodyOwner { this.timeToSleep = options.timeToSleep ?? 0.5; this.contactModifier = options.contactModifier ?? null; this.interpolation = options.interpolation ?? false; - this.frameAlphaSource = options.frameAlphaSource ?? (() => this.timeStepper.alpha); + this.frameAlphaSource = options.frameAlphaSource ?? this.timeStepper._ownAlpha; this._query = new QueryEngine(this._detectionColliders, this._backend.spatialIndex); this._backend.contactGraph._useUncollidablePairs(this._uncollidableJointPairs); } @@ -775,29 +807,25 @@ export class PhysicsWorld implements BodyOwner { // ── stepping ─────────────────────────────────────────────────────────── /** - * Advance the world by `frameDeltaSeconds`. Accumulates into fixed steps; each - * fixed step runs detection once, then a TGS-Soft sub-step loop (integrate - * gravity, solve contacts with a soft bias, integrate positions, relax) and a - * restitution pass, then writes the accumulated motion into each body. Finally - * dispatches events and writes bound node transforms. + * Advance the world by `frameDeltaSeconds` on its own clock. Accumulates into + * fixed steps of {@link PhysicsWorldOptions.fixedDelta}; each fixed step runs + * detection once, then a TGS-Soft sub-step loop (integrate gravity, solve + * contacts with a soft bias, integrate positions, relax) and a restitution + * pass, then writes the accumulated motion into each body. Finally dispatches + * events and presents bound nodes. * - * **Prefer registering the world as a system instead** - * (`app.systems.add(world, { order: SystemOrder.Physics })` or the scene - * equivalent) so {@link fixedUpdate} drives stepping directly from the - * engine's own fixed-timestep scheduler - one call per fixed step, no - * accumulator duplication. `step` remains available here for manual/advanced - * driving (e.g. a fixed-but-non-standard rate, or stepping outside the - * normal frame loop entirely): pass any delta and this accumulator converts - * it into the right number of fixed sub-steps (0, 1, or several, clamped by - * {@link PhysicsWorldOptions.maxSubSteps}). + * **In a scene, prefer registering the world as the scene's system instead** + * (`this.systems.add(world, { order: SystemOrder.Physics })`), so + * {@link fixedUpdate} steps it on the application's fixed-step clock. `step` + * is the manual path for a world with no host: pass any delta and the + * accumulator converts it into 0, 1 or several fixed steps, at most + * {@link PhysicsWorldOptions.maxSubSteps}, dropping the backlog beyond that. + * A world is driven one way or the other; a registered world that is also + * stepped by hand advances twice, which development builds report. * - * Either way the simulation is frame-rate independent and deterministic - - * the same sequence of deltas replays identically. `timeStepper.alpha` - * (`[0, 1)`) is the leftover sub-step fraction after this call, for callers - * that want to interpolate a bound node's rendered position between the last - * two fixed states instead of snapping to the latest one (bindings do not do - * this automatically - {@link bind} always writes the latest fixed-step - * transform verbatim). + * The same sequence of deltas replays identically. `timeStepper.alpha` + * (`[0, 1)`) is the leftover fraction of a step after this call, which an + * interpolating world uses by default to place bound nodes. */ public step(frameDeltaSeconds: number): void { this._assertAlive(); @@ -818,6 +846,8 @@ export class PhysicsWorld implements BodyOwner { this._stepOnce(h, subStepCount, gravityX, gravityY, contactHertz, dampingRatio, hasJoints, hasBullets); this._dispatchEvents(); } + + this._stepEpoch++; } // `step` is called once per rendered frame, so presenting here is already @@ -827,28 +857,36 @@ export class PhysicsWorld implements BodyOwner { } /** - * Advance by exactly one fixed step, driven directly by the engine's own - * fixed-timestep scheduler when this world is registered as a `System` - * (`app.systems.add(world, { order: SystemOrder.Physics })` or the scene - * equivalent) - bypasses {@link timeStepper}'s variable-delta accumulator - * entirely, since the caller has already decided exactly when a fixed step - * occurs. Prefer this over manual {@link step} once the world is - * system-registered; `step` remains available for advanced manual driving. - * Always advances by exactly {@link timeStepper}'s configured `fixedDelta`, - * regardless of the caller's actual fixed-step interval - if the engine's - * `Application.fixedTimeStep` doesn't match this world's `fixedDelta`, the - * simulation stays deterministic but runs at the wrong wall-clock speed - * relative to real time. + * Advance by exactly one fixed step on the host's clock. Called by the system + * registry the world is registered with (`scene.systems` or `app.systems`), + * once per host fixed step; the world's own accumulator is not involved. + * + * `step` must equal this world's {@link PhysicsWorldOptions.fixedDelta}: + * construct a hosted world with `fixedDelta: app.fixedTimeStep`. A step that + * differs by more than 1e-9 relative to fixedDelta, or is not a positive finite + * number, throws before anything in the world changes - no body, controller, + * contact or queued command is touched - because simulating `fixedDelta` per + * host step would run the world faster or slower than its host. */ - public fixedUpdate(_step: Seconds): void { + public fixedUpdate(step: Seconds): void { this._assertAlive(); const subStepCount = this.subStepCount; - const h = this.timeStepper.fixedDelta / subStepCount; - - this._stepOnce(h, subStepCount, this.gravity.x, this.gravity.y, this.contactHertz, this.dampingRatio, this._joints.length > 0, this._hasBullets()); + const h = this.timeStepper._hostStep(step) / subStepCount; + + this._stepOnce( + h, + subStepCount, + this.gravity.x, + this.gravity.y, + this.contactHertz, + this.dampingRatio, + this._joints.length > 0, + this._hasBullets(), + ); this._dispatchEvents(); + this._stepEpoch++; // Interpolated presentation is a per-FRAME job and moves to `update`; this // phase can run several times per frame, and the blend factor is only final @@ -869,7 +907,7 @@ export class PhysicsWorld implements BodyOwner { this._assertAlive(); if (this.interpolation) { - this._bindings.syncInterpolated(clampAlpha(this.frameAlphaSource())); + this._bindings.syncInterpolated(clampAlpha(this.timeStepper._readAlpha(this.frameAlphaSource))); } } @@ -892,6 +930,11 @@ export class PhysicsWorld implements BodyOwner { hasJoints: boolean, hasBullets: boolean, ): void { + // Controllers cast against the geometry the previous step left behind and + // only set velocities, so one index sync serves all of them and the step + // below integrates exactly the motion they resolved. + const followers = this._stepControllers.length > 0 && this._runControllersBeforeStep(); + // Detection runs once per fixed step (collider geometry is already current // from the previous frame's finalize / attach / setTransform). TGS-Soft // reuses the manifolds across the sub-steps below. @@ -972,6 +1015,49 @@ export class PhysicsWorld implements BodyOwner { if (hasBullets) { this._advanceBullets(); } + + if (followers) { + this._runControllersAfterStep(); + } + } + + /** + * Resolves every controller's motion for the step about to run, the ones + * that follow other bodies last. Returns whether any of them follows. + */ + private _runControllersBeforeStep(): boolean { + const fixedDelta = this.timeStepper.fixedDelta; + let followers = false; + + this._query._syncIndex(); + + for (const controller of this._stepControllers) { + if (controller._followsOthers) { + followers = true; + } else { + controller._beforeStep(fixedDelta); + } + } + + if (followers) { + for (const controller of this._stepControllers) { + if (controller._followsOthers) { + controller._beforeStep(fixedDelta); + } + } + } + + return followers; + } + + private _runControllersAfterStep(): void { + this._query._syncIndex(); + + for (const controller of this._stepControllers) { + if (controller._followsOthers) { + controller._afterStep(); + } + } } /** @@ -1048,7 +1134,13 @@ export class PhysicsWorld implements BodyOwner { } /** All collider hits along the ray, sorted by distance. Writes into `out` (cleared) if given. */ - public rayCastAll(origin: Readonly, direction: Readonly, filter?: QueryFilter, out?: RayHit[], maxDistance?: number): RayHit[] { + public rayCastAll( + origin: Readonly, + direction: Readonly, + filter?: QueryFilter, + out?: RayHit[], + maxDistance?: number, + ): RayHit[] { return this._query.rayCastAll(origin, direction, filter, out, maxDistance); } @@ -1057,6 +1149,71 @@ export class PhysicsWorld implements BodyOwner { return this._query.overlapShape(shape, position, filter, angle); } + /** + * Translates `shape` from `position` by `displacement` and returns the first + * collider in its way, or `null` when the whole motion is free. The cast is + * exact and translation-only, the same sweep {@link PhysicsBody.isBullet} + * relies on, so thin geometry cannot be skipped however long the motion is. + * + * Static, kinematic and dynamic colliders block alike, at their current + * placement. Sensors block only with {@link ShapeCastOptions.includeSensors}. + * Chains stay one-sided: a shape crossing a chain from its back face passes. + * + * A collider the shape already overlaps or touches at `position` is reported with + * {@link ShapeCastHit.initialOverlap} set and fraction `0` - but only when the + * displacement does not lead out of it, so a shape resting against a wall + * can still cast along it or away from it. With a zero displacement the cast + * reports the deepest such overlap, or `null`. + * + * Pass `out` to reuse a hit object; the cast then allocates nothing. + * + * @throws TypeError for a segment or chain, which enclose no area to cast. + * @throws RangeError for a non-finite position or displacement. + * + * @example + * ```ts + * const hit = world.shapeCast(new CircleShape(8), body.position, { x: 0, y: 120 }, { exclude: [body] }); + * + * if (hit !== null && !hit.initialOverlap) { + * console.log(`ground ${hit.distance}px below, normal`, hit.normal); + * } + * ``` + */ + public shapeCast( + shape: AnyShape, + position: Readonly, + displacement: Readonly, + options?: ShapeCastOptions, + out?: ShapeCastHit, + ): ShapeCastHit | null { + this._assertAlive(); + + return this._query.shapeCast(shape, position, displacement, options, out); + } + + /** @internal - the query engine, for controllers that cast bodies through it. */ + public get _queries(): QueryEngine { + return this._query; + } + + /** @internal - run `controller` at the start of every fixed step until it is removed. */ + public _addStepController(controller: StepController): void { + this._assertAlive(); + + if (!this._stepControllers.includes(controller)) { + this._stepControllers.push(controller); + } + } + + /** @internal */ + public _removeStepController(controller: StepController): void { + const index = this._stepControllers.indexOf(controller); + + if (index !== -1) { + this._stepControllers.splice(index, 1); + } + } + /** * Release every body, collider, binding and backend resource. Changes queued * from an event callback are applied first; if one of them throws, the world @@ -1105,6 +1262,7 @@ export class PhysicsWorld implements BodyOwner { this._joints.length = 0; this._uncollidableJointPairs.clear(); this._commands.length = 0; + this._stepControllers.length = 0; this._pendingBodyRemovals.clear(); this._pendingJoints.clear(); this._bindings.clear(); diff --git a/packages/exojs-physics/src/TimeStepper.ts b/packages/exojs-physics/src/TimeStepper.ts index d401d7f9d..eeb47f2b4 100644 --- a/packages/exojs-physics/src/TimeStepper.ts +++ b/packages/exojs-physics/src/TimeStepper.ts @@ -1,3 +1,14 @@ +import { logger } from '@codexo/exojs'; + +/** + * Relative tolerance between a host's fixed step and `fixedDelta`. A host that + * stores its step in milliseconds hands back `1 / 60` as `(1000 / 60) / 1000`, + * which can differ in the last bits; one part in a billion absorbs that + * rounding at any rate while every real rate mismatch (120 Hz against 121 Hz is + * already 0.8 %) is still refused. + */ +const hostStepTolerance = 1e-9; + /** Construction options for {@link TimeStepper}. */ export interface TimeStepperOptions { /** Fixed simulation timestep in seconds. Default `1 / 60`. Must be > 0. */ @@ -24,7 +35,21 @@ export class TimeStepper { public readonly fixedDelta: number; public readonly maxSubSteps: number; + /** + * @internal - the owning world's default blend factor: {@link alpha} while + * the accumulator drives the world. A hosted world never moves the + * accumulator, so reading it would pin bound nodes one step behind; the + * latest step is presented instead. + */ + public readonly _ownAlpha = (): number => (this._driver === 'host' ? 1 : this.alpha); + private _accumulator = 0; + /** What advanced the owning world last: this accumulator or a host's fixed step. */ + private _driver: 'accumulator' | 'host' | null = null; + /** Development builds only: how often {@link _driver} changed; a second change means two drivers. */ + private _driverChanges = 0; + /** Development builds only: whether the missing hosted alpha source was reported. */ + private _reportedHostedAlpha = false; public constructor(options: TimeStepperOptions = {}) { const fixedDelta = options.fixedDelta ?? 1 / 60; @@ -62,6 +87,7 @@ export class TimeStepper { return 0; } + this._track('accumulator'); this._accumulator += frameDeltaSeconds; let steps = Math.floor(this._accumulator / this.fixedDelta); @@ -88,4 +114,70 @@ export class TimeStepper { public reset(): void { this._accumulator = 0; } + + /** + * @internal - accept one host fixed step for the owning world and return the + * duration to simulate, before anything is mutated. Every consumer of a step + * - controllers, the solver's sub-steps, sleep timing, contact events, + * interpolation - is sized from {@link fixedDelta}, so a host running at + * another rate would advance the simulation by the wrong amount of time per + * host step rather than by the step it was handed. + */ + public _hostStep(step: number): number { + const fixedDelta = this.fixedDelta; + + if (!(step > 0) || step === Number.POSITIVE_INFINITY) { + throw new RangeError(`PhysicsWorld.fixedUpdate: the host step must be a positive finite number of seconds, received ${step}.`); + } + + if (Math.abs(step - fixedDelta) > fixedDelta * hostStepTolerance) { + throw new Error( + `PhysicsWorld.fixedUpdate: the host steps ${step} s at a time, but this world's fixedDelta is ${fixedDelta} s. ` + + 'Construct a hosted world with the host step, e.g. `new PhysicsWorld({ fixedDelta: app.fixedTimeStep })`, or step it by hand with `world.step(delta)` instead of registering it.', + ); + } + + this._track('host'); + + return fixedDelta; + } + + /** + * @internal - read an interpolating world's blend factor from `source`. + * Development builds report, once, a hosted world still on the default + * source, which has no fraction to offer. + */ + public _readAlpha(source: () => number): number { + if (__DEV__ && source === this._ownAlpha && this._driver === 'host' && !this._reportedHostedAlpha) { + this._reportedHostedAlpha = true; + logger.warn( + 'PhysicsWorld: an interpolating world stepped by its host has no frameAlphaSource, so bound nodes show the latest step without smoothing. ' + + 'Pass the host fraction: `new PhysicsWorld({ interpolation: true, frameAlphaSource: () => app.frameAlpha })`.', + { source: 'physics' }, + ); + } + + return source(); + } + + /** + * Record which driver advances the owning world. Development builds report + * a driver that changes a second time: a single change is a deliberate + * hand-over, alternating means two drivers each advancing the world. + */ + private _track(driver: 'accumulator' | 'host'): void { + if (this._driver === driver) { + return; + } + + if (__DEV__ && this._driver !== null && ++this._driverChanges === 2) { + logger.warn( + 'PhysicsWorld: this world is advanced both by step() and by the fixedUpdate() of a system registry it is registered with. ' + + 'Each advances the simulation, so the world runs ahead of its clock. Register the world as a system or step it by hand, not both.', + { source: 'physics' }, + ); + } + + this._driver = driver; + } } diff --git a/packages/exojs-physics/src/broadphase/AabbTreeBroadPhase.ts b/packages/exojs-physics/src/broadphase/AabbTreeBroadPhase.ts index 36c2e3a78..bcd418f4d 100644 --- a/packages/exojs-physics/src/broadphase/AabbTreeBroadPhase.ts +++ b/packages/exojs-physics/src/broadphase/AabbTreeBroadPhase.ts @@ -157,7 +157,14 @@ export class AabbTreeBroadPhase implements BroadPhase, SpatialIndex { } /** Colliders whose AABB the ray could cross within `maxDistance`, via the tree's own ray-cast prune. */ - public rayCast(originX: number, originY: number, dirX: number, dirY: number, maxDistance: number, callback: (collider: Collider) => void): void { + public rayCast( + originX: number, + originY: number, + dirX: number, + dirY: number, + maxDistance: number, + callback: (collider: Collider) => void, + ): void { this._tree.rayCast(originX, originY, dirX, dirY, maxDistance, callback); } @@ -185,7 +192,13 @@ export class AabbTreeBroadPhase implements BroadPhase, SpatialIndex { // reuse, not concurrent dual-ownership) must not carry a stale non-`-1` // proxy from this instance forward. `query` with infinite bounds visits // every leaf currently in the tree, allocation-free. - this._tree.query(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, this._resetTreeProxy); + this._tree.query( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + this._resetTreeProxy, + ); this._tree.destroy(); this._pairs.clear(); this._emitStale = true; diff --git a/packages/exojs-physics/src/collision/narrowphase.ts b/packages/exojs-physics/src/collision/narrowphase.ts index 7221a4d61..254fdb775 100644 --- a/packages/exojs-physics/src/collision/narrowphase.ts +++ b/packages/exojs-physics/src/collision/narrowphase.ts @@ -1,11 +1,12 @@ import type { PointLike } from '@codexo/exojs'; +import { ChainEdgeShape } from '../shapes/ChainEdgeShape'; import { chainEdgeAdmits } from './chainAdjacency'; import type { CollisionProxy } from './CollisionProxy'; import type { PairTable } from './dispatch'; import { needsFlip, symmetricEntry, symmetricTable } from './dispatch'; import type { Manifold } from './Manifold'; -import { closestPointOnSegment, pointSegmentDistanceSquared } from './segments'; +import { closestPointOnSegment, pointSegmentDistanceSquared, segmentSegmentDistanceSquared } from './segments'; const eps = 1e-9; @@ -63,7 +64,9 @@ export const collide = (a: CollisionProxy, b: CollisionProxy, manifold: Manifold // ring with a radius, which is exactly why it shares the polygon routines: the // ring supplies `count` and `worldVertices`/`worldNormals`, the radius rounds // the result off. -const radiusOf = (collider: CollisionProxy): number => (collider.shape.type === 'circle' || collider.shape.type === 'capsule' ? collider.shape.radius : 0); +const radiusOf = (collider: CollisionProxy): number => + collider.shape.type === 'circle' || collider.shape.type === 'capsule' ? collider.shape.radius : 0; + const countOf = (collider: CollisionProxy): number => { switch (collider.shape.type) { case 'polygon': @@ -400,7 +403,8 @@ const copyClip = (from: ClipVertex, to: ClipVertex): void => { to.id = from.id; }; -const encodeId = (flip: boolean, refEdge: number, incidentId: number): number => (flip ? 1 << 20 : 0) | ((refEdge & 0xff) << 12) | (incidentId & 0xfff); +const encodeId = (flip: boolean, refEdge: number, incidentId: number): number => + (flip ? 1 << 20 : 0) | ((refEdge & 0xff) << 12) | (incidentId & 0xfff); /** * Convex ring vs convex ring: SAT reference face + Sutherland-Hodgman clip, with @@ -477,19 +481,18 @@ const collideRoundedPolygons: CollidePair = (a, b, manifold, callerFlip) => { const posSide = tx * v2x + ty * v2y; const clipped1 = _clipped1Scratch; + const flipNormal = refSwapped !== callerFlip; if (clipSegment(-tx, -ty, negSide, incident, clipped1, encodeId(refSwapped, refEdge, 0xffe)) < 2) { - return false; + return collideCoreEnds(v1x, v1y, v2x, v2y, ref, incident, inc, totalRadius, flipNormal, encodeId(refSwapped, refEdge, 0xffd), manifold); } const clipped2 = _clipped2Scratch; if (clipSegment(tx, ty, posSide, clipped1, clipped2, encodeId(refSwapped, refEdge, 0xfff)) < 2) { - return false; + return collideCoreEnds(v1x, v1y, v2x, v2y, ref, incident, inc, totalRadius, flipNormal, encodeId(refSwapped, refEdge, 0xffd), manifold); } - const flipNormal = refSwapped !== callerFlip; - manifold.normalX = flipNormal ? -refNx : refNx; manifold.normalY = flipNormal ? -refNy : refNy; @@ -518,14 +521,117 @@ const collideRoundedPolygons: CollidePair = (a, b, manifold, callerFlip) => { } } + if (cp === 0) { + // Both clipped points lie beyond the rounded reference face, yet the + // incident face's core end can still be within reach of a reference corner. + return collideCoreEnds(v1x, v1y, v2x, v2y, ref, incident, inc, totalRadius, flipNormal, encodeId(refSwapped, refEdge, 0xffd), manifold); + } + manifold.pointCount = cp; - return cp > 0; + return true; +}; + +const _coreRef: PointLike = { x: 0, y: 0 }; +const _coreInc: PointLike = { x: 0, y: 0 }; + +/** + * Contact between the rounded outlines of a reference edge and an incident + * face whose cores lie beyond each other's ends, where clipping leaves no + * point: a capsule's cap resting on a polygon's corner or a chain vertex. The + * face-normal SAT still counts such a pair as touching, but the contact is + * between the two closest core points, along the line joining them. Without + * it a rounded body passing over a corner loses its contact there. + * + * A chain vertex only has such a corner where the chain turns away from the + * body. At a straight or inward join the neighbouring edge's face already + * holds the contact, and a second one from the vertex would tilt it. + */ +const collideCoreEnds = ( + v1x: number, + v1y: number, + v2x: number, + v2y: number, + ref: CollisionProxy, + incident: readonly [ClipVertex, ClipVertex], + inc: CollisionProxy, + totalRadius: number, + flipNormal: boolean, + id: number, + manifold: Manifold, +): boolean => { + const i0 = incident[0]; + const i1 = incident[1]; + const distanceSquared = segmentSegmentDistanceSquared(v1x, v1y, v2x, v2y, i0.x, i0.y, i1.x, i1.y, _coreRef, _coreInc); + + // Cores that touch or cross have no direction between them; clipping owns those. + if (distanceSquared >= totalRadius * totalRadius || distanceSquared < 1e-12) { + return false; + } + + if (!isCornerOf(ref, _coreRef) || !isCornerOf(inc, _coreInc)) { + return false; + } + + const distance = Math.sqrt(distanceSquared); + const nx = (_coreInc.x - _coreRef.x) / distance; + const ny = (_coreInc.y - _coreRef.y) / distance; + const incidentRadius = radiusOf(inc); + const point = manifold.points[0]; + + manifold.normalX = flipNormal ? -nx : nx; + manifold.normalY = flipNormal ? -ny : ny; + point.x = _coreInc.x - nx * incidentRadius; + point.y = _coreInc.y - ny * incidentRadius; + point.penetration = totalRadius - distance; + point.id = id; + manifold.pointCount = 1; + + return true; +}; + +/** Squared distance below which a closest point counts as a segment's endpoint. */ +const endpointEpsilonSquared = 1e-12; + +/** + * `false` when `point` is the end of a chain edge whose neighbour continues the + * surface straight or turns towards the body there; `true` for any other point + * and any other shape. + */ +const isCornerOf = (proxy: CollisionProxy, point: Readonly): boolean => { + const shape = proxy.shape; + + if (!(shape instanceof ChainEdgeShape)) { + return true; + } + + const vertices = proxy.worldVertices; + const startX = point.x - vertices[0]!; + const startY = point.y - vertices[1]!; + + if (startX * startX + startY * startY <= endpointEpsilonSquared) { + return !shape.hasPrevious || shape.previousSin < -convexTurn; + } + + const endX = point.x - vertices[2]!; + const endY = point.y - vertices[3]!; + + if (endX * endX + endY * endY <= endpointEpsilonSquared) { + return !shape.hasNext || shape.nextSin > convexTurn; + } + + return true; }; +/** Smallest turn, as the sine between neighbouring edge normals, that makes a chain vertex a corner. */ +const convexTurn = 1e-6; + /** * Boolean overlap test (no manifold) used by sensors and shape-overlap queries. - * Exact for the circle/circle, circle/polygon and polygon/polygon pairs. + * Exact for every supported pair and in agreement with {@link collide}, except + * that it ignores chain one-sidedness: a shape behind a chain edge overlaps it. + * As in {@link collide}, exact tangency counts as overlap for pairs involving a + * circle and not for the others. */ export const testOverlap = (a: CollisionProxy, b: CollisionProxy): boolean => { const ta = a.shape.type; @@ -549,10 +655,67 @@ const circlesOverlap: OverlapPair = (a, b) => { return dx * dx + dy * dy <= rsum * rsum; }; +/** + * The face axes alone are exact only for radius-free operands. Once a radius + * rounds a core, the separating direction in a vertex region is the line + * between the two closest core points, which no face axis covers, so cores + * that are apart are decided by their true distance instead. + */ const roundedPolygonsOverlap: OverlapPair = (a, b) => { const totalRadius = radiusOf(a) + radiusOf(b); + const separation = Math.max(findMaxSeparation(a, b, _sepA).separation, findMaxSeparation(b, a, _sepB).separation); + + if (separation >= totalRadius) { + return false; + } + + // Overlapping cores, or cores whose gap a face axis already measures exactly. + if (separation <= 0 || totalRadius === 0) { + return true; + } + + return coreDistanceSquared(a, b) < totalRadius * totalRadius; +}; + +/** + * Squared distance between two convex cores that do not overlap: the closest + * pair always lies on some pair of edges. A two-vertex ring is one segment + * traversed both ways, so it contributes a single edge. + */ +const coreDistanceSquared = (a: CollisionProxy, b: CollisionProxy): number => { + const av = a.worldVertices; + const bv = b.worldVertices; + const ac = countOf(a); + const bc = countOf(b); + const aEdges = ac === 2 ? 1 : ac; + const bEdges = bc === 2 ? 1 : bc; + let best = Infinity; + + for (let i = 0; i < aEdges; i++) { + const i2 = (i + 1) % ac; + + for (let j = 0; j < bEdges; j++) { + const j2 = (j + 1) % bc; + const distanceSquared = segmentSegmentDistanceSquared( + av[i * 2]!, + av[i * 2 + 1]!, + av[i2 * 2]!, + av[i2 * 2 + 1]!, + bv[j * 2]!, + bv[j * 2 + 1]!, + bv[j2 * 2]!, + bv[j2 * 2 + 1]!, + _coreRef, + _coreInc, + ); + + if (distanceSquared < best) { + best = distanceSquared; + } + } + } - return findMaxSeparation(a, b, _sepA).separation < totalRadius && findMaxSeparation(b, a, _sepB).separation < totalRadius; + return best; }; /** @@ -611,7 +774,9 @@ const circleSpineOverlap: OverlapPair = (circle, spineShape) => { const centre = circle.worldCenter; const radiusSum = radiusOf(circle) + radiusOf(spineShape); - return pointSegmentDistanceSquared(centre.x, centre.y, spine[0]!, spine[1]!, spine[2]!, spine[3]!, _segmentScratch) <= radiusSum * radiusSum; + return ( + pointSegmentDistanceSquared(centre.x, centre.y, spine[0]!, spine[1]!, spine[2]!, spine[3]!, _segmentScratch) <= radiusSum * radiusSum + ); }; const circlePolygonOverlap: OverlapPair = (circle, polygon) => { diff --git a/packages/exojs-physics/src/collision/segments.ts b/packages/exojs-physics/src/collision/segments.ts index aa33eb301..92afec099 100644 --- a/packages/exojs-physics/src/collision/segments.ts +++ b/packages/exojs-physics/src/collision/segments.ts @@ -34,7 +34,15 @@ export const closestPointOnSegment = (px: number, py: number, ax: number, ay: nu }; /** Squared distance from `(px, py)` to the segment `a → b`. */ -export const pointSegmentDistanceSquared = (px: number, py: number, ax: number, ay: number, bx: number, by: number, scratch: PointLike): number => { +export const pointSegmentDistanceSquared = ( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, + scratch: PointLike, +): number => { closestPointOnSegment(px, py, ax, ay, bx, by, scratch); const dx = px - scratch.x; diff --git a/packages/exojs-physics/src/collision/sweep.ts b/packages/exojs-physics/src/collision/sweep.ts index 010f87900..aa1b7fed1 100644 --- a/packages/exojs-physics/src/collision/sweep.ts +++ b/packages/exojs-physics/src/collision/sweep.ts @@ -85,7 +85,8 @@ const coreCount = (proxy: CollisionProxy): number => { } }; -const radiusOf = (proxy: CollisionProxy): number => (proxy.shape.type === 'circle' || proxy.shape.type === 'capsule' ? proxy.shape.radius : 0); +const radiusOf = (proxy: CollisionProxy): number => + proxy.shape.type === 'circle' || proxy.shape.type === 'capsule' ? proxy.shape.radius : 0; /** Moving circle vs static circle: a ray against the Minkowski-summed circle. */ const sweepCircleCircle = (moving: CollisionProxy, dx: number, dy: number, target: CollisionProxy, out: SweepHit): boolean => { @@ -282,7 +283,16 @@ const castAtOffsetFaces = ( }; /** Vertex-arc pass of {@link castPointAtRing}: a ray against the circle of radius `r` around each vertex. */ -const castAtVertexArcs = (ox: number, oy: number, dx: number, dy: number, r: number, verts: readonly number[], count: number, out: SweepHit): void => { +const castAtVertexArcs = ( + ox: number, + oy: number, + dx: number, + dy: number, + r: number, + verts: readonly number[], + count: number, + out: SweepHit, +): void => { if (r <= 0) { return; // A radius-free ring has no arcs; its faces meet at the vertices. } @@ -571,7 +581,14 @@ const sweepRings = (moving: CollisionProxy, dx: number, dy: number, target: Coll * end pose) against the static `target`. Returns `false` as soon as an axis * proves the shapes never meet within the motion. */ -const sweptSatAxes = (axisOwner: CollisionProxy, moving: CollisionProxy, dx: number, dy: number, target: CollisionProxy, state: SweptSatState): boolean => { +const sweptSatAxes = ( + axisOwner: CollisionProxy, + moving: CollisionProxy, + dx: number, + dy: number, + target: CollisionProxy, + state: SweptSatState, +): boolean => { const axes = axisOwner.worldNormals; const axisCount = coreCount(axisOwner); const mv = moving.worldVertices; diff --git a/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts b/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts index 5a3c565f8..51b97c7cd 100644 --- a/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts +++ b/packages/exojs-physics/src/debug/PhysicsDebugDraw.ts @@ -36,7 +36,8 @@ export interface PhysicsDebugDrawOptions { const segments = 24; /** `Color` channels are bytes, so a colour written as unit-range fractions has to be scaled to them. */ -const unitColor = (r: number, g: number, b: number, a: number): Color => new Color(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); +const unitColor = (r: number, g: number, b: number, a: number): Color => + new Color(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); const colorStatic = unitColor(0.3, 0.9, 0.4, 0.9); const colorKinematic = unitColor(0.4, 0.7, 1, 0.9); diff --git a/packages/exojs-physics/src/joints/WeldJoint.ts b/packages/exojs-physics/src/joints/WeldJoint.ts index a43a61ba0..614ef54c9 100644 --- a/packages/exojs-physics/src/joints/WeldJoint.ts +++ b/packages/exojs-physics/src/joints/WeldJoint.ts @@ -181,7 +181,8 @@ export class WeldJoint extends Joint { // Angular lock first. const cdotAngle = bodyB.angularVelocity - bodyA.angularVelocity; const biasAngle = useBias ? this._angular.biasRate * this._angleError : 0; - const impulseAngle = -this._effMassAngle * this._angular.massScale * (cdotAngle + biasAngle) - this._angular.impulseScale * this._impulseAngle; + const impulseAngle = + -this._effMassAngle * this._angular.massScale * (cdotAngle + biasAngle) - this._angular.impulseScale * this._impulseAngle; this._impulseAngle += impulseAngle; bodyA.angularVelocity -= bodyA.invInertia * impulseAngle; diff --git a/packages/exojs-physics/src/joints/WheelJoint.ts b/packages/exojs-physics/src/joints/WheelJoint.ts index 66770deff..4bdc7dad0 100644 --- a/packages/exojs-physics/src/joints/WheelJoint.ts +++ b/packages/exojs-physics/src/joints/WheelJoint.ts @@ -249,7 +249,8 @@ export class WheelJoint extends Joint { // Suspension spring along the axis (soft). const cdotAxis = this._axisVelocity(); const springImpulse = - -this._axialMass * this._springMassScale * (cdotAxis + this._springBiasRate * this._translation) - this._springImpulseScale * this._springImpulse; + -this._axialMass * this._springMassScale * (cdotAxis + this._springBiasRate * this._translation) - + this._springImpulseScale * this._springImpulse; this._springImpulse += springImpulse; this._applyAxial(springImpulse); diff --git a/packages/exojs-physics/src/public.ts b/packages/exojs-physics/src/public.ts index 38340d0c6..2c56ec69f 100644 --- a/packages/exojs-physics/src/public.ts +++ b/packages/exojs-physics/src/public.ts @@ -4,6 +4,7 @@ // extension. The tree-shakeable debug overlay lives at `@codexo/exojs-physics/debug`. export { PhysicsBinding } from './binding/PhysicsBinding'; +export { CharacterMovementComponent, type CharacterMovementOptions } from './CharacterMovementComponent'; export type { ColliderOptions } from './Collider'; export { Collider } from './Collider'; export type { ContactModifier, ContactModifierContext } from './ContactModifier'; @@ -15,13 +16,14 @@ export { PrismaticJoint, type PrismaticJointOptions } from './joints/PrismaticJo export { RevoluteJoint, type RevoluteJointOptions } from './joints/RevoluteJoint'; export { WeldJoint, type WeldJointOptions } from './joints/WeldJoint'; export { WheelJoint, type WheelJointOptions } from './joints/WheelJoint'; +export { KinematicMovementComponent, type KinematicMovementOptions } from './KinematicMovementComponent'; export type { BodyOptions } from './PhysicsBody'; export { PhysicsBody } from './PhysicsBody'; export { PhysicsBodyComponent } from './PhysicsBodyComponent'; export { type PhysicsBuildInfo, physicsBuildInfo } from './physicsBuildInfo'; export type { AttachOptions, PhysicsWorldOptions } from './PhysicsWorld'; export { PhysicsWorld } from './PhysicsWorld'; -export type { QueryFilter, RayHit } from './query/QueryEngine'; +export type { QueryFilter, RayHit, ShapeCastHit, ShapeCastOptions } from './query/QueryEngine'; export type { AnyShape } from './shapes/AnyShape'; export { BoxShape } from './shapes/BoxShape'; export { CapsuleShape } from './shapes/CapsuleShape'; diff --git a/packages/exojs-physics/src/query/QueryEngine.ts b/packages/exojs-physics/src/query/QueryEngine.ts index 54e3faedf..c847af7a0 100644 --- a/packages/exojs-physics/src/query/QueryEngine.ts +++ b/packages/exojs-physics/src/query/QueryEngine.ts @@ -4,13 +4,15 @@ import { aabbContainsPoint, aabbOverlap, createAabb } from '../Aabb'; import type { Collider } from '../Collider'; import { authoredCollider } from '../Collider'; import type { CollisionProxy } from '../collision/CollisionProxy'; -import { testOverlap } from '../collision/narrowphase'; +import { Manifold } from '../collision/Manifold'; +import { collide, testOverlap } from '../collision/narrowphase'; import { closestPointOnSegment, pointSegmentDistanceSquared } from '../collision/segments'; +import { type SweepHit, sweepProxies } from '../collision/sweep'; import { applyRotation, applyTransform, createTransform } from '../math'; import type { PhysicsBody } from '../PhysicsBody'; import type { AnyShape } from '../shapes/AnyShape'; import type { ChainShape } from '../shapes/ChainShape'; -import { resolveFilter, shouldCollide } from '../types'; +import { type CollisionFilter, defaultFilter, resolveFilter, shouldCollide } from '../types'; import type { SpatialIndex } from './SpatialIndex'; /** Reused sink for the closest-point primitives; queries run sequentially. */ @@ -34,6 +36,101 @@ export interface RayHit { distance: number; } +/** Options for {@link PhysicsWorld.shapeCast}. */ +export interface ShapeCastOptions { + /** Rotation of the cast shape in radians. It stays fixed for the whole motion. Default `0`. */ + readonly angle?: number | undefined; + /** Which colliders the cast can hit, matched like a collider's own filter. Omitting it hits everything. */ + readonly filter?: QueryFilter | undefined; + /** Let sensor colliders stop the cast. Default `false`: sensors never block motion. */ + readonly includeSensors?: boolean | undefined; + /** + * Bodies and colliders the cast passes through - typically the body whose + * shape is being cast. A body excludes every collider it holds. + */ + readonly exclude?: ReadonlyArray | undefined; +} + +/** + * The first obstacle a {@link PhysicsWorld.shapeCast} meets. Pass one as `out` + * to reuse it across casts. + */ +export interface ShapeCastHit { + /** The blocking collider; a chain is reported as the chain, never as one of its edges. */ + collider: Collider; + body: PhysicsBody; + /** Fraction of the displacement travelled before contact, in `[0, 1]`. */ + fraction: number; + /** Distance travelled before contact: `fraction` times the displacement's length. */ + distance: number; + /** Unit surface normal at the contact, pointing from the obstacle back toward the cast shape. */ + normal: { x: number; y: number }; + /** + * `true` when the shape already overlapped or touched the collider at its start + * position. `fraction` and `distance` are then `0`, `normal` is the direction + * that separates the shape from the collider, and {@link penetration} says + * how deep the overlap is (`0` for a touch). + */ + initialOverlap: boolean; + /** Overlap depth at the start position; `0` unless {@link initialOverlap}. */ + penetration: number; +} + +/** A proxy whose geometry a cast rewrites in place, so casting allocates nothing. */ +interface CastProxy { + shape: SolverShape; + worldCenter: PointLike; + worldVertices: number[]; + worldNormals: number[]; +} + +/** + * Earliest blocking contact of a cast, written by {@link QueryEngine._castProxy}. + * @internal + */ +export interface CastState { + target: Collider | null; + fraction: number; + normalX: number; + normalY: number; + initialOverlap: boolean; + penetration: number; +} + +/** + * Receives the contacts {@link QueryEngine._visitContacts} finds. + * @internal + */ +export interface ContactVisitor { + _visitContact(target: Collider, normalX: number, normalY: number, penetration: number): void; +} + +/** @internal */ +export const createCastState = (): CastState => ({ + target: null, + fraction: 0, + normalX: 0, + normalY: 0, + initialOverlap: false, + penetration: 0, +}); + +const noCastOptions: ShapeCastOptions = Object.freeze({}); + +/** + * How far along the motion a cast looks for a contact its start pose only + * touches. Far below any tolerance the solver uses, far above the rounding of + * world coordinates. + */ +const touchProbe = 1e-6; + +const createCastProxy = (): CastProxy => ({ + shape: null as unknown as SolverShape, + worldCenter: { x: 0, y: 0 }, + worldVertices: [], + worldNormals: [], +}); + /** * Spatial queries over the world's live collider set. The engine holds a * reference to the world's collider array (kept world-synchronised on every @@ -55,6 +152,17 @@ export class QueryEngine { /** Chains already reported by the running `forEachAabbHit`, so one chain is one callback. */ private readonly _scratchSeenForEach: Collider[] = []; private readonly _scratchAabb: AabbLike = createAabb(); + private readonly _castStart: CastProxy = createCastProxy(); + private readonly _castEnd: CastProxy = createCastProxy(); + private readonly _castBounds: AabbLike = createAabb(); + private readonly _castShifted: CastProxy = createCastProxy(); + private readonly _castNudged: CastProxy = createCastProxy(); + private readonly _castShiftedBounds: AabbLike = createAabb(); + private readonly _castCandidates: Collider[] = []; + private readonly _castSweep: SweepHit = { t: 0, normalX: 0, normalY: 0 }; + private readonly _castManifold = new Manifold(); + private readonly _castFilter: CollisionFilter = { ...defaultFilter }; + private readonly _castState: CastState = createCastState(); public constructor(colliders: readonly Collider[], spatialIndex?: SpatialIndex) { this._colliders = colliders; @@ -180,6 +288,7 @@ export class QueryEngine { const resolved = filter ? resolveFilter(filter) : null; let best: RayHit | null = null; + const consider = (collider: Collider): void => { const authored = authoredCollider(collider); @@ -208,7 +317,13 @@ export class QueryEngine { } /** All collider hits along the ray, sorted by distance. Writes into `out` (cleared first) when given. */ - public rayCastAll(origin: Readonly, direction: Readonly, filter?: QueryFilter, out?: RayHit[], maxDistance = Infinity): RayHit[] { + public rayCastAll( + origin: Readonly, + direction: Readonly, + filter?: QueryFilter, + out?: RayHit[], + maxDistance = Infinity, + ): RayHit[] { const length = Math.hypot(direction.x, direction.y); if (length < 1e-9) { @@ -220,6 +335,7 @@ export class QueryEngine { const resolved = filter ? resolveFilter(filter) : null; const result = out ?? []; result.length = 0; + const consider = (collider: Collider): void => { const authored = authoredCollider(collider); @@ -284,6 +400,269 @@ export class QueryEngine { return out; } + + /** + * First collider `shape` meets when translated by `displacement` from + * `position`, or `null` when the motion is free. See {@link PhysicsWorld.shapeCast}. + */ + public shapeCast( + shape: AnyShape, + position: Readonly, + displacement: Readonly, + options: ShapeCastOptions = noCastOptions, + out?: ShapeCastHit, + ): ShapeCastHit | null { + if (shape.type === 'segment' || shape.type === 'chain') { + throw new TypeError( + `PhysicsWorld.shapeCast: a ${shape.type} encloses no area and cannot be cast; cast a circle, capsule or polygon.`, + ); + } + + const dx = displacement.x; + const dy = displacement.y; + + if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || !Number.isFinite(dx) || !Number.isFinite(dy)) { + throw new RangeError('PhysicsWorld.shapeCast: position and displacement must be finite.'); + } + + const start = this._castStart; + + fillProxy(shape, position.x, position.y, options.angle ?? 0, start); + + let filter: CollisionFilter | null = null; + + if (options.filter !== undefined) { + filter = this._castFilter; + filter.category = options.filter.category ?? defaultFilter.category; + filter.mask = options.filter.mask ?? defaultFilter.mask; + filter.group = options.filter.group ?? defaultFilter.group; + } + + this._syncIndex(); + + const state = this._castState; + const bounds = proxyAabb(shape, start, this._scratchAabb); + + if (!this._castProxy(start, bounds, dx, dy, filter, options.includeSensors === true, null, options.exclude ?? null, state)) { + return null; + } + + const target = state.target!; + const hit = out ?? { + collider: target, + body: target.body, + fraction: 0, + distance: 0, + normal: { x: 0, y: 0 }, + initialOverlap: false, + penetration: 0, + }; + + hit.collider = target; + hit.body = target.body; + hit.fraction = state.fraction; + hit.distance = state.fraction * Math.hypot(dx, dy); + hit.normal.x = state.normalX; + hit.normal.y = state.normalY; + hit.initialOverlap = state.initialOverlap; + hit.penetration = state.penetration; + + return hit; + } + + /** @internal - bring the spatial index up to date with moved colliders before a run of {@link _castProxy} calls. */ + public _syncIndex(): void { + this._spatialIndex?.sync(this._colliders); + } + + /** + * @internal - {@link _castProxy} for one of a body's own colliders, displaced + * by `(offsetX, offsetY)` from where it currently sits and filtered by its own + * collision filter. Sensors never block it, and neither does anything in + * `exclude`. + */ + public _castCollider( + collider: Collider, + offsetX: number, + offsetY: number, + dx: number, + dy: number, + excludeBody: PhysicsBody, + state: CastState, + exclude: ReadonlyArray | null = null, + ): boolean { + const aabb = collider.aabb; + const bounds = this._castShiftedBounds; + + bounds.minX = aabb.minX + offsetX; + bounds.minY = aabb.minY + offsetY; + bounds.maxX = aabb.maxX + offsetX; + bounds.maxY = aabb.maxY + offsetY; + + let start: CollisionProxy = collider; + + if (offsetX !== 0 || offsetY !== 0) { + translateProxy(collider, offsetX, offsetY, this._castShifted); + start = this._castShifted; + } + + return this._castProxy(start, bounds, dx, dy, collider.filter, false, excludeBody, exclude, state); + } + + /** + * @internal - reports every collider that one of a body's own colliders, + * displaced by `(offsetX, offsetY)`, overlaps or touches, under the same + * rules as {@link _castCollider}. The normal passed to the visitor points + * from the touched collider back towards the displaced one. Does not sync + * the spatial index. + */ + public _visitContacts(collider: Collider, offsetX: number, offsetY: number, excludeBody: PhysicsBody, visitor: ContactVisitor): void { + const aabb = collider.aabb; + const bounds = this._castShiftedBounds; + + bounds.minX = aabb.minX + offsetX; + bounds.minY = aabb.minY + offsetY; + bounds.maxX = aabb.maxX + offsetX; + bounds.maxY = aabb.maxY + offsetY; + + let start: CollisionProxy = collider; + + if (offsetX !== 0 || offsetY !== 0) { + translateProxy(collider, offsetX, offsetY, this._castShifted); + start = this._castShifted; + } + + const manifold = this._castManifold; + const candidates = this._spatialIndex === undefined ? this._colliders : this._spatialIndex.queryAabb(bounds, this._castCandidates); + + for (const candidate of candidates) { + const target = authoredCollider(candidate); + + if ( + !aabbOverlap(bounds, candidate.aabb) || + castIgnores(target, collider.filter, false, excludeBody, null) || + !collide(start, candidate, manifold) + ) { + continue; + } + + visitor._visitContact(target, -manifold.normalX, -manifold.normalY, deepestPenetration(manifold)); + } + } + + /** + * @internal - casts one proxy, given at its start pose with `bounds` as its + * start AABB, along `(dx, dy)` and writes the earliest blocking contact into + * `state`. Returns whether anything blocks. Does not sync the spatial index: + * a caller casting several proxies against unchanged geometry calls + * {@link _syncIndex} once first. + * + * `filter` is matched against each target's filter with the collision rule; + * `null` matches everything. A target the proxy starts out overlapping + * blocks only a motion that does not lead out of it, and blocks it at once, + * so a shape touching a wall can still slide along it or move away. + */ + public _castProxy( + start: CollisionProxy, + bounds: Readonly, + dx: number, + dy: number, + filter: CollisionFilter | null, + includeSensors: boolean, + excludeBody: PhysicsBody | null, + exclude: ReadonlyArray | null, + state: CastState, + ): boolean { + const swept = this._castBounds; + + swept.minX = dx < 0 ? bounds.minX + dx : bounds.minX; + swept.maxX = dx > 0 ? bounds.maxX + dx : bounds.maxX; + swept.minY = dy < 0 ? bounds.minY + dy : bounds.minY; + swept.maxY = dy > 0 ? bounds.maxY + dy : bounds.maxY; + + const moving = dx !== 0 || dy !== 0; + const end = this._castEnd; + const sweep = this._castSweep; + + const length = Math.hypot(dx, dy); + const nudged = this._castNudged; + + if (moving) { + translateProxy(start, dx, dy, end); + translateProxy(start, (dx / length) * touchProbe, (dy / length) * touchProbe, nudged); + } + + state.target = null; + state.fraction = Infinity; + state.normalX = 0; + state.normalY = 0; + state.initialOverlap = false; + state.penetration = 0; + + const candidates = this._spatialIndex === undefined ? this._colliders : this._spatialIndex.queryAabb(swept, this._castCandidates); + + for (const candidate of candidates) { + const target = authoredCollider(candidate); + + if (!aabbOverlap(swept, candidate.aabb) || castIgnores(target, filter, includeSensors, excludeBody, exclude)) { + continue; + } + + if (aabbOverlap(bounds, candidate.aabb) && this._startContact(start, nudged, candidate, target, moving, dx, dy, state)) { + continue; + } + + if (moving && state.fraction > 0 && sweepProxies(end, dx, dy, candidate, sweep) && sweep.t < state.fraction) { + state.target = target; + state.fraction = sweep.t; + state.normalX = sweep.normalX; + state.normalY = sweep.normalY; + } + } + + return state.target !== null; + } + + /** + * Decides a candidate the cast starts out overlapping or touching: records + * it when the motion does not lead out of it and returns `true`, so the + * sweep skips it either way. Returns `false` for a candidate the start pose + * is clear of. + */ + private _startContact( + start: CollisionProxy, + nudged: CollisionProxy, + candidate: Collider, + target: Collider, + moving: boolean, + dx: number, + dy: number, + state: CastState, + ): boolean { + const manifold = this._castManifold; + let penetration = 0; + + if (collide(start, candidate, manifold)) { + penetration = deepestPenetration(manifold); + } else if (!moving || !collide(nudged, candidate, manifold)) { + // Exactly touching is neither an overlap to the narrow phase nor an + // entry to the sweep, so a shape resting flush against a wall would pass + // through it. A contact that appears a hair further along the motion is + // that touch. + return false; + } + + // The manifold normal points from the cast shape into the target, so its + // negation is the direction that leads out of the contact. + const normalX = -manifold.normalX; + const normalY = -manifold.normalY; + + if (!moving || dx * normalX + dy * normalY < 0) { + recordOverlap(state, target, penetration, normalX, normalY); + } + + return true; + } } /** Read a flat vertex/normal buffer in-bounds: callers index within 0..count-1. */ @@ -319,7 +698,10 @@ const pointInCollider = (collider: Collider, px: number, py: number): boolean => if (collider.shape.type === 'capsule') { const spine = collider.worldVertices; - return pointSegmentDistanceSquared(px, py, spine[0]!, spine[1]!, spine[2]!, spine[3]!, _pointScratch) <= collider.shape.radius * collider.shape.radius; + return ( + pointSegmentDistanceSquared(px, py, spine[0]!, spine[1]!, spine[2]!, spine[3]!, _pointScratch) <= + collider.shape.radius * collider.shape.radius + ); } const verts = collider.worldVertices; @@ -409,7 +791,15 @@ const rayCastSegment = (collider: Collider, ox: number, oy: number, dx: number, * a fixed 40 bisections put the result far below the engine's contact slop. The * cost is a query-path concern only - the solver never runs this. */ -const rayCastCapsule = (collider: Collider, radius: number, ox: number, oy: number, dx: number, dy: number, maxDistance: number): RayHit | null => { +const rayCastCapsule = ( + collider: Collider, + radius: number, + ox: number, + oy: number, + dx: number, + dy: number, + maxDistance: number, +): RayHit | null => { const spine = collider.worldVertices; const ax = spine[0]!; const ay = spine[1]!; @@ -714,3 +1104,93 @@ const proxyAabb = (shape: SolverShape, proxy: CollisionProxy, out: AabbLike): Aa return out; }; + +/** Writes a solver shape placed at `(x, y)` with `angle` into a reusable cast proxy. */ +const fillProxy = (shape: SolverShape, x: number, y: number, angle: number, out: CastProxy): void => { + const vertices = out.worldVertices; + const normals = out.worldNormals; + + out.shape = shape; + out.worldCenter.x = x; + out.worldCenter.y = y; + + if (shape.type === 'circle') { + vertices.length = 0; + normals.length = 0; + + return; + } + + const count = shape.type === 'polygon' ? shape.count : 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + vertices.length = count * 2; + normals.length = count * 2; + + for (let i = 0; i < count * 2; i += 2) { + const vx = shape.vertices[i]!; + const vy = shape.vertices[i + 1]!; + const nx = shape.normals[i]!; + const ny = shape.normals[i + 1]!; + + vertices[i] = x + cos * vx - sin * vy; + vertices[i + 1] = y + sin * vx + cos * vy; + normals[i] = cos * nx - sin * ny; + normals[i + 1] = sin * nx + cos * ny; + } +}; + +/** Writes `source` translated by `(dx, dy)` into a reusable cast proxy; normals are translation-invariant. */ +const translateProxy = (source: CollisionProxy, dx: number, dy: number, out: CastProxy): void => { + const from = source.worldVertices; + const fromNormals = source.worldNormals; + const vertices = out.worldVertices; + const normals = out.worldNormals; + + out.shape = source.shape as SolverShape; + out.worldCenter.x = source.worldCenter.x + dx; + out.worldCenter.y = source.worldCenter.y + dy; + vertices.length = from.length; + normals.length = from.length; + + for (let i = 0; i < from.length; i += 2) { + vertices[i] = from[i]! + dx; + vertices[i + 1] = from[i + 1]! + dy; + normals[i] = fromNormals[i]!; + normals[i + 1] = fromNormals[i + 1]!; + } +}; + +/** `true` when a shape cast passes through `target` under the cast's own rules. */ +const castIgnores = ( + target: Collider, + filter: CollisionFilter | null, + includeSensors: boolean, + excludeBody: PhysicsBody | null, + exclude: ReadonlyArray | null, +): boolean => + (!includeSensors && target.isSensor) || + target.body === excludeBody || + (filter !== null && !shouldCollide(filter, target.filter)) || + (exclude !== null && (exclude.includes(target) || exclude.includes(target.body))); + +const deepestPenetration = (manifold: Manifold): number => { + const first = manifold.points[0].penetration; + + return manifold.pointCount > 1 ? Math.max(first, manifold.points[1].penetration) : first; +}; + +/** Keeps the deepest starting overlap a cast has met so far. */ +const recordOverlap = (state: CastState, target: Collider, penetration: number, normalX: number, normalY: number): void => { + if (state.initialOverlap && penetration <= state.penetration) { + return; + } + + state.target = target; + state.fraction = 0; + state.normalX = normalX; + state.normalY = normalY; + state.initialOverlap = true; + state.penetration = penetration; +}; diff --git a/packages/exojs-physics/src/shapes/ChainShape.ts b/packages/exojs-physics/src/shapes/ChainShape.ts index e4bb20d72..c14c54556 100644 --- a/packages/exojs-physics/src/shapes/ChainShape.ts +++ b/packages/exojs-physics/src/shapes/ChainShape.ts @@ -98,7 +98,9 @@ export class ChainShape extends Shape { const minimum = closed ? 3 : 2; if (count < minimum) { - throw new RangeError(`ChainShape: ${closed ? 'a closed chain' : 'a chain'} needs at least ${minimum} distinct vertices, received ${count}.`); + throw new RangeError( + `ChainShape: ${closed ? 'a closed chain' : 'a chain'} needs at least ${minimum} distinct vertices, received ${count}.`, + ); } let boundingRadius = 0; diff --git a/packages/exojs-physics/src/shapes/decompose.ts b/packages/exojs-physics/src/shapes/decompose.ts index 8d01586dd..f0320bff5 100644 --- a/packages/exojs-physics/src/shapes/decompose.ts +++ b/packages/exojs-physics/src/shapes/decompose.ts @@ -113,7 +113,9 @@ const normalize = (vertices: ReadonlyArray>): number[] => { dropCollinear(points); if (points.length < 6) { - throw new RangeError(`decomposeToConvexParts: needs at least 3 distinct, non-collinear vertices, received ${points.length / 2} after cleanup.`); + throw new RangeError( + `decomposeToConvexParts: needs at least 3 distinct, non-collinear vertices, received ${points.length / 2} after cleanup.`, + ); } if (Math.abs(signedArea(points)) <= weldEpsilon) { @@ -391,7 +393,8 @@ const sequence = (count: number): number[] => { return out; }; -const toPoints = (points: number[], cycle: number[]): PointLike[] => cycle.map(index => ({ x: points[index * 2]!, y: points[index * 2 + 1]! })); +const toPoints = (points: number[], cycle: number[]): PointLike[] => + cycle.map(index => ({ x: points[index * 2]!, y: points[index * 2 + 1]! })); /** Shoelace signed area of an index cycle over `points`. */ const cycleArea = (points: number[], cycle: number[]): number => { diff --git a/packages/exojs-physics/src/solver/ContactSolver.ts b/packages/exojs-physics/src/solver/ContactSolver.ts index 3d399d8f3..1c6a12203 100644 --- a/packages/exojs-physics/src/solver/ContactSolver.ts +++ b/packages/exojs-physics/src/solver/ContactSolver.ts @@ -466,8 +466,26 @@ export class ContactSolver { const ny = constraint.ny; const a1 = n(constraint.record.normalImpulse, 0); const a2 = n(constraint.record.normalImpulse, 1); - const vn1 = normalVelocity(bodyA, bodyB, n(constraint.rotAx, 0), n(constraint.rotAy, 0), n(constraint.rotBx, 0), n(constraint.rotBy, 0), nx, ny); - const vn2 = normalVelocity(bodyA, bodyB, n(constraint.rotAx, 1), n(constraint.rotAy, 1), n(constraint.rotBx, 1), n(constraint.rotBy, 1), nx, ny); + const vn1 = normalVelocity( + bodyA, + bodyB, + n(constraint.rotAx, 0), + n(constraint.rotAy, 0), + n(constraint.rotBx, 0), + n(constraint.rotBy, 0), + nx, + ny, + ); + const vn2 = normalVelocity( + bodyA, + bodyB, + n(constraint.rotAx, 1), + n(constraint.rotAy, 1), + n(constraint.rotBx, 1), + n(constraint.rotBy, 1), + nx, + ny, + ); // Residual at the current impulse: b = (vn − bias) − K·a. const bx = vn1 - n(constraint.velocityBias, 0) - (constraint.k11 * a1 + constraint.k12 * a2); @@ -510,8 +528,10 @@ export class ContactSolver { const d2 = x2 - a2; const jx = (d1 + d2) * nx; const jy = (d1 + d2) * ny; - const torqueA = (n(constraint.rotAx, 0) * ny - n(constraint.rotAy, 0) * nx) * d1 + (n(constraint.rotAx, 1) * ny - n(constraint.rotAy, 1) * nx) * d2; - const torqueB = (n(constraint.rotBx, 0) * ny - n(constraint.rotBy, 0) * nx) * d1 + (n(constraint.rotBx, 1) * ny - n(constraint.rotBy, 1) * nx) * d2; + const torqueA = + (n(constraint.rotAx, 0) * ny - n(constraint.rotAy, 0) * nx) * d1 + (n(constraint.rotAx, 1) * ny - n(constraint.rotAy, 1) * nx) * d2; + const torqueB = + (n(constraint.rotBx, 0) * ny - n(constraint.rotBy, 0) * nx) * d1 + (n(constraint.rotBx, 1) * ny - n(constraint.rotBy, 1) * nx) * d2; bodyA.linearVelocityX -= jx * bodyA.invMass; bodyA.linearVelocityY -= jy * bodyA.invMass; @@ -569,7 +589,16 @@ const currentSeparation = (constraint: ContactConstraint, i: number): number => }; /** Relative normal velocity at a contact point: `dot(vB + ωB×rB − vA − ωA×rA, n)`. */ -const normalVelocity = (bodyA: PhysicsBody, bodyB: PhysicsBody, rAx: number, rAy: number, rBx: number, rBy: number, nx: number, ny: number): number => { +const normalVelocity = ( + bodyA: PhysicsBody, + bodyB: PhysicsBody, + rAx: number, + rAy: number, + rBx: number, + rBy: number, + nx: number, + ny: number, +): number => { const dvx = bodyB.linearVelocityX - bodyB.angularVelocity * rBy - (bodyA.linearVelocityX - bodyA.angularVelocity * rAy); const dvy = bodyB.linearVelocityY + bodyB.angularVelocity * rBx - (bodyA.linearVelocityY + bodyA.angularVelocity * rAx); @@ -577,7 +606,16 @@ const normalVelocity = (bodyA: PhysicsBody, bodyB: PhysicsBody, rAx: number, rAy }; /** Apply impulse `(jx, jy)` to B and its negation to A about their contact arms. */ -const applyImpulse = (bodyA: PhysicsBody, bodyB: PhysicsBody, rAx: number, rAy: number, rBx: number, rBy: number, jx: number, jy: number): void => { +const applyImpulse = ( + bodyA: PhysicsBody, + bodyB: PhysicsBody, + rAx: number, + rAy: number, + rBx: number, + rBy: number, + jx: number, + jy: number, +): void => { bodyA.linearVelocityX -= jx * bodyA.invMass; bodyA.linearVelocityY -= jy * bodyA.invMass; bodyA.angularVelocity -= (rAx * jy - rAy * jx) * bodyA.invInertia; diff --git a/packages/exojs-physics/test/allocationSampler.ts b/packages/exojs-physics/test/allocationSampler.ts index ed4cf3a7c..18198713a 100644 --- a/packages/exojs-physics/test/allocationSampler.ts +++ b/packages/exojs-physics/test/allocationSampler.ts @@ -64,6 +64,7 @@ export const measureAllocationRate = async (run: () => void, options: Allocation session.post(method, params, (error: Error | null, result?: unknown) => { if (error) { reject(error); + return; } diff --git a/packages/exojs-physics/test/binding.test.ts b/packages/exojs-physics/test/binding.test.ts index 6556a3088..e7d0b3809 100644 --- a/packages/exojs-physics/test/binding.test.ts +++ b/packages/exojs-physics/test/binding.test.ts @@ -38,7 +38,9 @@ const fakeNode = (skewX = 0, skewY = 0): FakeNode => ({ describe('SceneNode binding', () => { it('writes the body position onto the node on bind and after each step', () => { const world = new PhysicsWorld(); - const body = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 10, y: 20 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const body = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 10, y: 20 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const node = fakeNode(); world.bind(body, node as unknown as SceneNode); @@ -53,7 +55,9 @@ describe('SceneNode binding', () => { it('writes the body rotation as the counter-rotating degrees a SceneNode expects', () => { const world = new PhysicsWorld(); - const body = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, angle: Math.PI / 2, colliders: [{ shape: new BoxShape(10, 10) }] })); + const body = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, angle: Math.PI / 2, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const node = fakeNode(); world.bind(body, node as unknown as SceneNode); @@ -69,7 +73,9 @@ describe('SceneNode binding', () => { it("renders the bound node where the body's colliders are", () => { const world = new PhysicsWorld(); const angle = Math.PI / 6; - const body = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 50, y: 20 }, angle, colliders: [{ shape: new BoxShape(10, 10) }] })); + const body = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 50, y: 20 }, angle, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const node = new Drawable(); world.bind(body, node); @@ -105,7 +111,8 @@ describe('SceneNode binding', () => { }); describe('SceneNode binding: world-space-rooted guard (F16)', () => { - const makeBody = (): PhysicsBody => new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }); + const makeBody = (): PhysicsBody => + new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }); it('warns in dev when an ancestor carries a non-identity transform', () => { const registry = new BindingRegistry(); diff --git a/packages/exojs-physics/test/capsule.test.ts b/packages/exojs-physics/test/capsule.test.ts index 3351c5973..c4423f757 100644 --- a/packages/exojs-physics/test/capsule.test.ts +++ b/packages/exojs-physics/test/capsule.test.ts @@ -206,6 +206,33 @@ describe('capsule narrow phase', () => { expect(testOverlap(a, b)).toBe(false); }); + it('touches a box corner its cap overlaps beyond the face', () => { + const world = new PhysicsWorld(); + // Top face at y = 90, right edge at x = 100: the corner is (100, 90). + const box = colliderAt(world, new BoxShape(200, 20), { x: 0, y: 100 }); + // Cap centre 4 right of the corner and 6.8 above it: 7.89 from it, inside the radius. + const capsule = colliderAt(world, new CapsuleShape(0, -12, 0, 12, 8), { x: 104, y: 90 - 6.8 - 12 }); + + expect(collide(box, capsule, manifold)).toBe(true); + expect(manifold.pointCount).toBe(1); + + const distance = Math.hypot(4, 6.8); + + expect(manifold.normalX).toBeCloseTo(4 / distance, 9); + expect(manifold.normalY).toBeCloseTo(-6.8 / distance, 9); + expect(manifold.points[0].penetration).toBeCloseTo(8 - distance, 9); + // On the capsule's surface, the penetration depth away from the corner. + expect(Math.hypot(manifold.points[0].x - 100, manifold.points[0].y - 90)).toBeCloseTo(8 - distance, 9); + + expect(collide(capsule, box, manifold)).toBe(true); + expect(manifold.normalX).toBeCloseTo(-4 / distance, 9); + + // Just out of reach of the corner, though inside both faces' reach. + capsule.body.setTransform({ x: 104, y: 90 - 6.95 - 12 }, 0); + world.step(0); + expect(collide(box, capsule, manifold)).toBe(false); + }); + it('leaves polygon against polygon exactly as it was', () => { const world = new PhysicsWorld(); const floor = colliderAt(world, new BoxShape(200, 20), { x: 0, y: 100 }); @@ -240,7 +267,9 @@ describe('capsule in a world', () => { // Released just above its resting height, the way the sleeping suite drops // its boxes. A long fall is a different question - see the note below. - const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 288 }, colliders: [{ shape: new CapsuleShape(-15, 0, 15, 0, 8) }] })); + const body = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 288 }, colliders: [{ shape: new CapsuleShape(-15, 0, 15, 0, 8) }] }), + ); for (let frame = 0; frame < 180; frame++) { world.step(DT); diff --git a/packages/exojs-physics/test/ccd-shapes.test.ts b/packages/exojs-physics/test/ccd-shapes.test.ts index 8bfa510ce..a2f0b707c 100644 --- a/packages/exojs-physics/test/ccd-shapes.test.ts +++ b/packages/exojs-physics/test/ccd-shapes.test.ts @@ -39,7 +39,8 @@ const movingRightFrom = (world: PhysicsWorld, shape: ConstructorParameters[0]['shape'], y = 0): Collider => colliderAt(world, shape, { x: 50, y }); +const obstacleAt = (world: PhysicsWorld, shape: ConstructorParameters[0]['shape'], y = 0): Collider => + colliderAt(world, shape, { x: 50, y }); describe('the sweep matrix', () => { it('casts every mass-bearing shape against every target shape', () => { @@ -173,7 +174,13 @@ describe('sweep invariants across the matrix', () => { expect(sweepProxies(moving, 40, 0, obstacleAt(world, new SegmentShape(0, -20, 0, 20)), hit)).toBe(false); expect( - sweepProxies(colliderAt(world, new CapsuleShape(0, -10, 0, 10, 4), { x: 46, y: 0 }), 46, 0, obstacleAt(world, new SegmentShape(0, -20, 0, 20)), hit), + sweepProxies( + colliderAt(world, new CapsuleShape(0, -10, 0, 10, 4), { x: 46, y: 0 }), + 46, + 0, + obstacleAt(world, new SegmentShape(0, -20, 0, 20)), + hit, + ), ).toBe(true); expect(hit.t).toBeGreaterThan(0); expect(hit.t).toBeLessThanOrEqual(1); @@ -269,7 +276,11 @@ describe('sweeping a chain', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [{ shape: flatFloor() }] })); - const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: -100 }, colliders: [{ shape: new CircleShape(5), density: 1 }] }); + const ball = new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: -100 }, + colliders: [{ shape: new CircleShape(5), density: 1 }], + }); ball.isBullet = bullet; world.add(ball); ball.linearVelocityY = 9000; // 150px per fixed step: no step lands near the floor, and one step crosses it @@ -348,7 +359,11 @@ describe('sweeping the new shapes costs no per-step allocation', () => { world.add(new PhysicsBody({ type: 'static', position: { x, y: 0 }, colliders: [{ shape: wall(), restitution: 1 }] })); } - const body = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: bullet(), density: 1, restitution: 1 }] }); + const body = new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: 0 }, + colliders: [{ shape: bullet(), density: 1, restitution: 1 }], + }); body.isBullet = true; world.add(body); body.linearVelocityX = 9000; diff --git a/packages/exojs-physics/test/ccd.test.ts b/packages/exojs-physics/test/ccd.test.ts index ab9756dd5..4d84bd623 100644 --- a/packages/exojs-physics/test/ccd.test.ts +++ b/packages/exojs-physics/test/ccd.test.ts @@ -30,16 +30,20 @@ describe('continuous collision (bullet mode)', () => { it('a fast bullet does not tunnel through a thin static wall', () => { // Without CCD the body tunnels straight through (the documented limit). const plain = fireAtWall(false); + for (let frame = 0; frame < 30; frame++) { plain.world.step(FRAME); } + expect(plain.ball.x).toBeGreaterThan(220); // passed clean through the wall // With isBullet the swept test stops it at the wall. const ccd = fireAtWall(true); + for (let frame = 0; frame < 30; frame++) { ccd.world.step(FRAME); } + expect(ccd.ball.x).toBeLessThan(200); // never crossed the wall plane at x = 200 expect(Number.isFinite(ccd.ball.x)).toBe(true); }); @@ -63,15 +67,21 @@ describe('continuous collision (bullet mode)', () => { it('a fast bullet stays finite and contained in a closed static box', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const wall = (x: number, y: number, w: number, h: number): void => { world.add(new PhysicsBody({ type: 'static', position: { x, y }, colliders: [{ shape: new BoxShape(w, h) }] })); }; + wall(0, -110, 240, 20); wall(0, 110, 240, 20); wall(-110, 0, 20, 240); wall(110, 0, 20, 240); - const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(6), restitution: 0.5 }] }); + const ball = new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: 0 }, + colliders: [{ shape: new CircleShape(6), restitution: 0.5 }], + }); ball.isBullet = true; world.add(ball); ball.linearVelocityX = 5000; @@ -94,7 +104,9 @@ describe('continuous collision (bullet mode)', () => { const make = (bullet: boolean): { world: PhysicsWorld; ball: PhysicsBody } => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); // Heavy thin dynamic target at x = 200 (barely shoved within the short run). - world.add(new PhysicsBody({ type: 'dynamic', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(8, 160), density: 50 }] })); + world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(8, 160), density: 50 }] }), + ); const ball = new PhysicsBody({ type: 'dynamic', position: { x: 55, y: 0 }, colliders: [{ shape: new CircleShape(6) }] }); ball.isBullet = bullet; @@ -106,16 +118,20 @@ describe('continuous collision (bullet mode)', () => { // A non-bullet skips clean through the thin target (discrete detection misses it). const plain = make(false); + for (let frame = 0; frame < 6; frame++) { plain.world.step(FRAME); } + expect(plain.ball.x).toBeGreaterThan(210); // tunnelled straight past the dynamic target // The bullet is swept against the dynamic target too and stops just short of it. const ccd = make(true); + for (let frame = 0; frame < 6; frame++) { ccd.world.step(FRAME); } + expect(ccd.ball.x).toBeLessThan(196); // clamped on the near side of the target's left face expect(Number.isFinite(ccd.ball.x)).toBe(true); }); @@ -207,15 +223,19 @@ describe('continuous collision (swept shape, not just the centre)', () => { // Discrete detection misses it entirely - and with the old centre-point // sweep a bullet tunnelled too (the regression this suite pins). const plain = make(false); + for (let frame = 0; frame < 3; frame++) { plain.world.step(FRAME); } + expect(plain.box.x).toBeGreaterThan(210); // tunnelled clean through const ccd = make(true); + for (let frame = 0; frame < 10; frame++) { ccd.world.step(FRAME); } + // Leading face stops at the wall face x = 198 → centre ≈ 188. expect(ccd.box.x).toBeLessThan(190); expect(ccd.box.x).toBeGreaterThan(170); @@ -238,9 +258,11 @@ describe('continuous collision (swept shape, not just the centre)', () => { }; const plain = make(false); + for (let frame = 0; frame < 3; frame++) { plain.world.step(FRAME); } + expect(plain.ball.x).toBeGreaterThan(210); // tunnelled clean through // The impact frame clamps the ball at the corner: |centre − (198, 50)| = 30 @@ -270,9 +292,11 @@ describe('continuous collision (swept shape, not just the centre)', () => { }; const plain = make(false); + for (let frame = 0; frame < 3; frame++) { plain.world.step(FRAME); } + expect(plain.box.x).toBeGreaterThan(210); // tunnelled clean through const ccd = make(true); @@ -299,9 +323,11 @@ describe('continuous collision (swept shape, not just the centre)', () => { }; const plain = make(false); + for (let frame = 0; frame < 3; frame++) { plain.world.step(FRAME); } + expect(plain.ball.x).toBeGreaterThan(210); // tunnelled clean through const ccd = make(true); @@ -398,9 +424,11 @@ describe('continuous collision (swept shape, not just the centre)', () => { // Sanity: the same world does run narrow-phase sweeps once the bullet is fast. ball.linearVelocityX = 6000; + for (let frame = 0; frame < 5; frame++) { world.step(FRAME); } + expect(world._ccdSweepTests).toBeGreaterThan(0); }); @@ -417,7 +445,9 @@ describe('continuous collision (swept shape, not just the centre)', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] })); for (let i = 0; i < distantColliderCount; i++) { - world.add(new PhysicsBody({ type: 'static', position: { x: 10_000 + i * 100, y: 10_000 }, colliders: [{ shape: new BoxShape(40, 40) }] })); + world.add( + new PhysicsBody({ type: 'static', position: { x: 10_000 + i * 100, y: 10_000 }, colliders: [{ shape: new BoxShape(40, 40) }] }), + ); } const ball = new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(6) }] }); diff --git a/packages/exojs-physics/test/chain-seam.test.ts b/packages/exojs-physics/test/chain-seam.test.ts index c7ff40051..c8b2fa252 100644 --- a/packages/exojs-physics/test/chain-seam.test.ts +++ b/packages/exojs-physics/test/chain-seam.test.ts @@ -54,7 +54,9 @@ const slideAcross = (shape: AnyShape, chain: ChainShape, startX = -120, steps = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [{ shape: chain, friction: 0 }] })); - const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: startX, y: 60 }, colliders: [{ shape, density: 1, friction: 0 }] })); + const body = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: startX, y: 60 }, colliders: [{ shape, density: 1, friction: 0 }] }), + ); for (let i = 0; i < 90; i++) { world.step(DT); @@ -93,9 +95,10 @@ const shapes = [ describe('a collinear chain seam', () => { it.each(shapes)('carries a $kind across at constant velocity', ({ shape }) => { - const trace = slideAcross(shape(), flatFloor()); + // 25 steps cross both interior vertices and stop before the floor's free + // end at x = 150, where a rounded body rightly tips over the corner. + const trace = slideAcross(shape(), flatFloor(), -120, 25); - // Both interior vertices are crossed within these 40 steps. expect(trace.x).toBeGreaterThan(50); expect(trace.maxUpward).toBeLessThan(5); expect(trace.maxSpin).toBeLessThan(0.5); diff --git a/packages/exojs-physics/test/chain.test.ts b/packages/exojs-physics/test/chain.test.ts index 0f5281fa2..6a846d7bb 100644 --- a/packages/exojs-physics/test/chain.test.ts +++ b/packages/exojs-physics/test/chain.test.ts @@ -126,7 +126,9 @@ describe('chain collision', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [{ shape: flatFloor() }] })); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] }), + ); settle(world, 120); @@ -147,7 +149,9 @@ describe('chain collision', () => { }), ); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(20, 20), density: 1 }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(20, 20), density: 1 }] }), + ); settle(world, 120); @@ -160,7 +164,11 @@ describe('chain collision', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [{ shape: flatFloor() }] })); const box = world.add( - new PhysicsBody({ type: 'dynamic', position: { x: -120, y: 88 }, colliders: [{ shape: new BoxShape(20, 20), density: 1, friction: 0 }] }), + new PhysicsBody({ + type: 'dynamic', + position: { x: -120, y: 88 }, + colliders: [{ shape: new BoxShape(20, 20), density: 1, friction: 0 }], + }), ); settle(world, 30); @@ -193,7 +201,9 @@ describe('chain collision', () => { }), ); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: -60, y: -40 }, colliders: [{ shape: new BoxShape(20, 20), density: 1 }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: -60, y: -40 }, colliders: [{ shape: new BoxShape(20, 20), density: 1 }] }), + ); settle(world, 180); @@ -209,7 +219,9 @@ describe('chain collision', () => { started++; }); world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 }, colliders: [{ shape: flatFloor() }] })); - world.add(new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new ChainShape(points(0, -50, 0, 50)) }] })); + world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new ChainShape(points(0, -50, 0, 50)) }] }), + ); settle(world, 5); @@ -224,7 +236,9 @@ describe('chain events use the authored collider', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [chain] })); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] }), + ); const started: CollisionEvent[] = []; const ended: CollisionEvent[] = []; @@ -366,7 +380,9 @@ describe('chain lifecycle', () => { world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [chain] })); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 40 }, colliders: [{ shape: new BoxShape(120, 20), density: 1 }] }), + ); settle(world, 120); expect(box.y).toBeCloseTo(90, 0); @@ -387,7 +403,9 @@ describe('chain lifecycle', () => { // A bullet body elsewhere, just so the world runs the CCD pass at all - // `_advanceBullets` (and its broad-phase resync) is skipped entirely // without one. - const bullet = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 5000, y: 5000 }, isBullet: true, colliders: [{ shape: new CircleShape(4) }] })); + const bullet = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 5000, y: 5000 }, isBullet: true, colliders: [{ shape: new CircleShape(4) }] }), + ); bullet.linearVelocityX = 600; expect(chain._treeProxy).toBe(-1); @@ -443,7 +461,9 @@ describe('chain performance', () => { const chainRate = await measureAllocationRate(() => chainWorld.step(DT), { iterations: 200 }); const boxRate = await measureAllocationRate(() => boxWorld.step(DT), { iterations: 200 }); - console.log(`${(chainRate.bytesPerIteration / 1024).toFixed(2)} KB/step chain floor vs ${(boxRate.bytesPerIteration / 1024).toFixed(2)} KB/step box floor`); + console.log( + `${(chainRate.bytesPerIteration / 1024).toFixed(2)} KB/step chain floor vs ${(boxRate.bytesPerIteration / 1024).toFixed(2)} KB/step box floor`, + ); // A chain that rebuilt its edge geometry per step would allocate a multiple // of the solid floor's rate; sharing the proxies keeps the two comparable. diff --git a/packages/exojs-physics/test/character-movement.test.ts b/packages/exojs-physics/test/character-movement.test.ts new file mode 100644 index 000000000..e3337a3c5 --- /dev/null +++ b/packages/exojs-physics/test/character-movement.test.ts @@ -0,0 +1,1097 @@ +import { BehaviorComponent, Container, type PointLike, Scene, SceneDirector, type Seconds, SystemOrder, Time } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { + BoxShape, + CapsuleShape, + ChainShape, + CharacterMovementComponent, + type CharacterMovementOptions, + CircleShape, + KinematicMovementComponent, + type PhysicsBody, + PhysicsBodyComponent, + PhysicsWorld, + PolygonShape, +} from '../src/index'; +import { measureAllocationRate } from './allocationSampler'; +import { createAppStub } from './sceneHarness'; +import { colliderAt } from './support'; + +const STEP = Time.seconds(1 / 60); +const DT = 1 / 60; + +/** Capsule half-height: spine half-length 12 plus radius 8. */ +const HALF = 20; +/** Floor top used by most scenes. */ +const FLOOR = 100; +const REST_Y = FLOOR - HALF; + +class WorldScene extends Scene { + public readonly world = new PhysicsWorld(); + + public override init(): void { + this.systems.add(this.world, { order: SystemOrder.Physics }); + } +} + +/** Feeds walking input to a character every fixed step, like gameplay code. */ +class Controls extends BehaviorComponent { + public input = 0; + + public constructor(private readonly character: CharacterMovementComponent) { + super(); + } + + public override fixedUpdate(_step: Seconds): void { + this.character.move(this.input); + } +} + +interface SpawnOptions extends CharacterMovementOptions { + readonly position?: PointLike; + readonly maxSpeed?: number; +} + +const setup = async () => { + const { app, errors } = createAppStub(); + const director = new SceneDirector(app, { world: WorldScene }); + + await director.change(WorldScene); + + const scene = director.currentScene as WorldScene; + const world = scene.world; + + const frame = (fixedSteps = 1): void => { + director._beginFrame(); + + try { + for (let i = 0; i < fixedSteps; i++) { + director.fixedUpdate(STEP); + } + + director.update(STEP); + } finally { + director._endFrame(); + } + }; + + const spawn = (options: SpawnOptions = {}) => { + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(world, { + type: 'kinematic', + position: options.position ?? { x: 0, y: REST_Y - 0.05 }, + colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }], + }), + ); + const movement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed: options.maxSpeed ?? 240 })); + const character = node.addComponent(new CharacterMovementComponent(movement, { gravity: 1600, ...options })); + const controls = node.addComponent(new Controls(character)); + + scene.addChild(node); + + return { node, physics, movement, character, controls, body: physics.body }; + }; + + const floor = (top = FLOOR, from = -2000, to = 2000) => + colliderAt(world, new BoxShape(to - from, 20), { x: (from + to) / 2, y: top + 10 }); + + return { director, errors, scene, world, frame, spawn, floor }; +}; + +const run = (frame: (steps?: number) => void, steps: number, each?: () => void): void => { + for (let i = 0; i < steps; i++) { + frame(); + each?.(); + } +}; + +describe('CharacterMovementComponent', () => { + describe('composition', () => { + it('takes over the movement while enabled and hands it back when disabled', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { movement, character } = spawn(); + + expect(() => movement.move({ x: 1, y: 0 })).toThrow(/CharacterMovementComponent drives this movement/); + + character.enabled = false; + movement.move({ x: 1, y: 0 }); + frame(); + expect(movement.body.x).toBeCloseTo(4, 9); + }); + + it('drops a pending movement command when it takes over the movement and when it hands it back', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { movement, character, body } = spawn(); + + frame(); + + const x = body.x; + + // A direct command issued while the movement is free, then a takeover and + // a release within the same world advance: the command predates the + // character and must not run once the movement is free again. + character.enabled = false; + movement.move({ x: 1, y: 0 }); + character.enabled = true; + character.enabled = false; + frame(); + + expect(body.x).toBe(x); + expect(body.linearVelocityX).toBe(0); + + // A command issued after the release still runs. + movement.move({ x: 1, y: 0 }); + frame(); + expect(body.x).toBeCloseTo(x + 4, 9); + }); + + it('refuses a second character for one movement', async () => { + const { errors, scene, spawn, floor } = await setup(); + + floor(); + + const { movement } = spawn(); + const other = new Container(); + + other.addComponent(new CharacterMovementComponent(movement)); + scene.addChild(other); + + expect(errors.map(error => error.message)).toEqual([expect.stringMatching(/already driven/)]); + }); + + it('defaults gravity to the world gravity along -up', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 980 } }); + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CircleShape(4) }] }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 10 }); + + expect(new CharacterMovementComponent(movement).gravity).toBeCloseTo(980, 9); + expect(new CharacterMovementComponent(movement, { up: { x: 0, y: 1 } }).gravity).toBe(0); + }); + + it('validates its options', () => { + const world = new PhysicsWorld(); + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CircleShape(4) }] }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 10 }); + + expect(() => new CharacterMovementComponent(movement, { up: { x: 0, y: 0 } })).toThrow(RangeError); + expect(() => new CharacterMovementComponent(movement, { maxSlopeAngle: Math.PI / 2 })).toThrow(RangeError); + expect(() => new CharacterMovementComponent(movement, { gravity: -1 })).toThrow(RangeError); + expect(() => new CharacterMovementComponent(movement, { maxStepHeight: Number.NaN })).toThrow(RangeError); + expect(() => new CharacterMovementComponent(movement, { maxFallSpeed: 0 })).toThrow(RangeError); + + const character = new CharacterMovementComponent(movement); + + expect(() => character.move(Number.NaN)).toThrow(RangeError); + expect(() => character.launch({ x: Infinity, y: 0 })).toThrow(RangeError); + }); + }); + + describe('ground and contacts', () => { + it('stands still on flat ground without drifting', async () => { + const { spawn, floor, frame } = await setup(); + const ground = floor(); + const { character, body } = spawn(); + + frame(); + expect(character.grounded).toBe(true); + + const restY = body.y; + + run(frame, 240, () => { + expect(character.grounded).toBe(true); + expect(body.y).toBeCloseTo(restY, 9); + expect(character.velocity.y).toBeCloseTo(0, 9); + }); + + expect(character.groundNormal.x).toBeCloseTo(0, 9); + expect(character.groundNormal.y).toBeCloseTo(-1, 9); + expect(character.groundBody).toBe(ground.body); + expect(character.groundCollider).toBe(ground); + expect(restY).toBeGreaterThan(REST_Y - 0.2); + expect(restY).toBeLessThan(REST_Y); + }); + + it('falls under gravity and lands without bouncing', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { character, body } = spawn({ position: { x: 0, y: 0 } }); + + frame(); + expect(character.grounded).toBe(false); + expect(character.velocity.y).toBeCloseTo(1600 * DT, 6); + + let landedAt = -1; + + for (let i = 0; i < 120 && landedAt < 0; i++) { + frame(); + + if (character.grounded) { + landedAt = i; + } + } + + expect(landedAt).toBeGreaterThan(0); + expect(body.y).toBeLessThan(REST_Y); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + + const landedY = body.y; + + run(frame, 30, () => { + expect(character.grounded).toBe(true); + expect(body.y).toBeCloseTo(landedY, 9); + }); + }); + + it('reports grounded for the step that just completed, not one step late', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { character, body } = spawn({ position: { x: 0, y: REST_Y - 3 } }); + const seen: boolean[] = []; + + run(frame, 6, () => { + // A touching body is grounded in the same frame its position reaches the floor. + seen.push(character.grounded); + expect(character.grounded).toBe(body.y > REST_Y - 0.2); + }); + + expect(seen).toContain(false); + expect(seen.at(-1)).toBe(true); + }); + + it('walks at maxSpeed along flat ground', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { character, controls, body } = spawn(); + + frame(); + controls.input = 1; + + const y = body.y; + + run(frame, 60, () => { + expect(character.velocity.x).toBeCloseTo(240, 6); + expect(body.y).toBeCloseTo(y, 9); + expect(character.grounded).toBe(true); + }); + + expect(body.x).toBeCloseTo(240, 4); + + controls.input = -0.5; + frame(); + expect(character.velocity.x).toBeCloseTo(-120, 6); + + controls.input = 0; + frame(); + expect(character.velocity.x).toBe(0); + }); + + it('walks off an edge and falls', async () => { + const { spawn, floor, frame } = await setup(); + + floor(FLOOR, -500, 20); + + const { character, controls, body } = spawn(); + + frame(); + controls.input = 1; + run(frame, 30); + + expect(character.grounded).toBe(false); + expect(body.y).toBeGreaterThan(REST_Y + 1); + expect(character.velocity.x).toBeCloseTo(240, 6); + }); + + it('keeps standing against a wall and reports it', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + + const wall = colliderAt(world, new BoxShape(20, 200), { x: 60, y: 0 }); + const { character, controls, body } = spawn(); + + frame(); + controls.input = 1; + run(frame, 30); + + expect(body.x).toBeLessThan(42); + expect(body.x).toBeGreaterThan(41.8); + expect(character.grounded).toBe(true); + expect(character.onWall).toBe(true); + expect(character.wallNormal.x).toBeCloseTo(-1, 9); + expect(character.groundBody).not.toBe(wall.body); + }); + + it('stops a launch at a ceiling and falls back', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(400, 20), { x: 0, y: FLOOR - 2 * HALF - 30 - 10 }); + + const { character, body } = spawn(); + + frame(); + character.launch({ x: 0, y: -600 }); + + let hit = false; + + run(frame, 10, () => { + hit ||= character.onCeiling; + }); + + expect(hit).toBe(true); + expect(body.y).toBeGreaterThan(REST_Y - 30); + run(frame, 60); + expect(character.grounded).toBe(true); + }); + + it('stands on whatever side up points away from', async () => { + const { world, spawn, frame } = await setup(); + const wall = colliderAt(world, new BoxShape(20, 400), { x: -10, y: 0 }); + const { character, body } = spawn({ position: { x: 40, y: 0 }, up: { x: 1, y: 0 } }); + + body.setTransform({ x: 8.05, y: 0 }, 0); + run(frame, 60); + + expect(character.grounded).toBe(true); + expect(character.groundBody).toBe(wall.body); + expect(character.groundNormal.x).toBeCloseTo(1, 9); + expect(body.x).toBeLessThan(8.2); + expect(body.x).toBeGreaterThan(8); + }); + + it('does not stand on sensors', async () => { + const { world, spawn, frame } = await setup(); + + colliderAt(world, new BoxShape(400, 20), { x: 0, y: FLOOR + 10 }, 0, 'static', { isSensor: true }); + + const { character, body } = spawn(); + + run(frame, 30); + + expect(character.grounded).toBe(false); + expect(body.y).toBeGreaterThan(REST_Y + 10); + }); + + it('falls once its support is removed', async () => { + const { world, spawn, frame } = await setup(); + const platform = colliderAt(world, new BoxShape(200, 20), { x: 0, y: FLOOR + 10 }, 0, 'kinematic'); + const { character, body } = spawn(); + + run(frame, 5); + expect(character.groundBody).toBe(platform.body); + + world.remove(platform.body); + expect(character.grounded).toBe(false); + expect(character.groundBody).toBeNull(); + + run(frame, 20); + expect(body.y).toBeGreaterThan(REST_Y + 10); + }); + }); + + describe('launching', () => { + it('launches above maxSpeed and keeps walking speed sideways', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { character, controls, body } = spawn({ maxSpeed: 100 }); + + frame(); + controls.input = 1; + character.launch({ x: 0, y: -520 }); + frame(); + + expect(character.grounded).toBe(false); + expect(character.velocity.y).toBeCloseTo(-520 + 1600 * DT, 6); + expect(character.velocity.x).toBeCloseTo(100, 6); + + let apex = body.y; + + run(frame, 120, () => { + apex = Math.min(apex, body.y); + }); + + // Semi-implicit Euler peaks at v^2 / 2g minus one step's gravity share. + expect(REST_Y - apex).toBeGreaterThan(80); + expect(REST_Y - apex).toBeLessThan((520 * 520) / (2 * 1600) + 0.5); + expect(character.grounded).toBe(true); + }); + + it('launches once per advance, even across several fixed steps', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { character } = spawn(); + + frame(); + character.launch({ x: 0, y: -300 }); + frame(3); + + expect(character.velocity.y).toBeCloseTo(-300 + 3 * 1600 * DT, 6); + }); + + it('launches in mid-air as well', async () => { + const { spawn, frame } = await setup(); + const { character } = spawn({ position: { x: 0, y: 0 } }); + + run(frame, 10); + expect(character.velocity.y).toBeGreaterThan(0); + + character.launch({ x: 0, y: -400 }); + frame(); + expect(character.velocity.y).toBeCloseTo(-400 + 1600 * DT, 6); + }); + + it('caps the fall speed', async () => { + const { spawn, frame } = await setup(); + const { character } = spawn({ position: { x: 0, y: 0 }, maxFallSpeed: 300 }); + + run(frame, 60); + expect(character.velocity.y).toBeCloseTo(300, 6); + }); + }); + + describe('surfaces', () => { + /** Flat ground to x = 0, then a ramp rising at `angle` for 400 units, then flat again. */ + const ramp = (world: PhysicsWorld, angle: number) => { + const rise = Math.tan(angle) * 400; + + return colliderAt( + world, + new ChainShape([ + { x: -1000, y: FLOOR }, + { x: 0, y: FLOOR }, + { x: 400, y: FLOOR - rise }, + { x: 1400, y: FLOOR - rise }, + ]), + { x: 0, y: 0 }, + ); + }; + + it('walks up and down a walkable slope without leaving it', async () => { + const { world, spawn, frame } = await setup(); + const angle = (30 * Math.PI) / 180; + + ramp(world, angle); + + const { character, controls, body } = spawn({ position: { x: -100, y: REST_Y - 0.05 } }); + + frame(); + controls.input = 1; + run(frame, 150, () => expect(character.grounded).toBe(true)); + + // At the top of the ramp, 400 * tan(30°) ≈ 230.9 above the floor. + expect(body.x).toBeGreaterThan(400); + expect(body.y).toBeLessThan(REST_Y - 230); + + controls.input = -1; + run(frame, 150, () => expect(character.grounded).toBe(true)); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('does not walk up or stand on a slope steeper than maxSlopeAngle', async () => { + const { world, spawn, frame } = await setup(); + + ramp(world, (60 * Math.PI) / 180); + + const { character, controls, body } = spawn({ position: { x: -100, y: REST_Y - 0.05 } }); + + frame(); + controls.input = 1; + run(frame, 90); + + expect(body.y).toBeGreaterThan(REST_Y - 6); + expect(body.x).toBeLessThan(5); + expect(character.grounded).toBe(true); + expect(character.onWall).toBe(true); + + // Placed on the steep face, it slides down instead of standing. + controls.input = 0; + body.setTransform({ x: 100, y: FLOOR - Math.tan((60 * Math.PI) / 180) * 100 - 30 }, 0); + frame(); + expect(character.grounded).toBe(false); + run(frame, 120); + expect(body.x).toBeLessThan(10); + }); + + it('steps onto obstacles up to maxStepHeight and not higher', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(200, 6), { x: 150, y: FLOOR - 3 }); + colliderAt(world, new BoxShape(200, 12), { x: -150, y: FLOOR - 6 }); + + const { character, controls, body } = spawn({ maxStepHeight: 8 }); + + frame(); + controls.input = 1; + run(frame, 30); + + expect(body.x).toBeGreaterThan(80); + expect(body.y).toBeCloseTo(REST_Y - 6, 0); + expect(character.grounded).toBe(true); + + controls.input = -1; + run(frame, 60); + + // Stopped by the 12-unit box's right face at x = -50, one radius away. + expect(body.x).toBeGreaterThan(-42.2); + expect(body.x).toBeLessThan(-41.8); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + expect(character.onWall).toBe(true); + }); + + it('does not ride its rounded underside over a step just above maxStepHeight', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(200, 10), { x: 150, y: FLOOR - 5 }); + + const { controls, body } = spawn({ maxStepHeight: 8 }); + + frame(); + controls.input = 1; + run(frame, 60); + + expect(body.x).toBeLessThan(42.01); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('does not step without maxStepHeight', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(200, 6), { x: 150, y: FLOOR - 3 }); + + const { controls, body } = spawn(); + + frame(); + controls.input = 1; + run(frame, 30); + + expect(body.x).toBeLessThan(50); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('does not step up into a ceiling', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(200, 6), { x: 150, y: FLOOR - 3 }); + // Leaves 2 units of headroom above a standing character: less than the step. + colliderAt(world, new BoxShape(600, 20), { x: 150, y: FLOOR - 2 * HALF - 2 - 10 }); + + const { controls, body } = spawn({ maxStepHeight: 8 }); + + frame(); + controls.input = 1; + run(frame, 30); + + expect(body.x).toBeLessThan(50); + expect(body.y).toBeGreaterThan(REST_Y - 2.2); + }); + + it('follows the ground down stairs and ramps instead of flying off', async () => { + const { world, spawn, frame } = await setup(); + const angle = (40 * Math.PI) / 180; + + ramp(world, angle); + + const { character, controls, body } = spawn({ position: { x: 600, y: REST_Y - Math.tan(angle) * 400 - 0.05 } }); + + frame(); + controls.input = -1; + run(frame, 240, () => expect(character.grounded).toBe(true)); + expect(body.x).toBeLessThan(0); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('stays on the ground walking down a step no taller than maxStepHeight', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(200, 10), { x: 0, y: FLOOR - 5 }); + + const { character, controls, body } = spawn({ position: { x: 0, y: REST_Y - 10 - 0.05 }, maxStepHeight: 12 }); + + frame(); + controls.input = 1; + run(frame, 45, () => expect(character.grounded).toBe(true)); + + expect(body.x).toBeGreaterThan(150); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('does not snap back to the ground while launching', async () => { + const { world, spawn, frame } = await setup(); + + ramp(world, (30 * Math.PI) / 180); + + const { character, controls } = spawn({ position: { x: 600, y: REST_Y - Math.tan((30 * Math.PI) / 180) * 400 - 0.05 } }); + + frame(); + controls.input = -1; + run(frame, 5); + character.launch({ x: 0, y: -60 }); + frame(); + expect(character.grounded).toBe(false); + expect(character.velocity.y).toBeLessThan(0); + }); + + it('crosses adjacent tiles and chain seams without snagging', async () => { + const { world, spawn, frame } = await setup(); + + for (let i = -10; i < 60; i++) { + colliderAt(world, new BoxShape(16, 16), { x: i * 16 + 8, y: FLOOR + 8 }); + } + + const seam: PointLike[] = []; + + for (let x = 960; x <= 2000; x += 16) { + seam.push({ x, y: FLOOR }); + } + + colliderAt(world, new ChainShape(seam), { x: 0, y: 0 }); + + const { character, controls, body } = spawn(); + + frame(); + controls.input = 1; + + const y = body.y; + + run(frame, 300, () => { + expect(character.grounded).toBe(true); + expect(character.velocity.x).toBeCloseTo(240, 6); + expect(body.y).toBeCloseTo(y, 6); + }); + + expect(body.x).toBeGreaterThan(1150); + }); + + it('lands in a corner between floor and wall', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + colliderAt(world, new BoxShape(20, 400), { x: 30, y: 0 }); + + const { character, controls, body } = spawn({ position: { x: 0, y: 0 } }); + + controls.input = 1; + run(frame, 60); + + expect(character.grounded).toBe(true); + expect(body.x).toBeLessThan(12); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + + it('lands on a one-way platform from above and jumps through it from below', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + + const top = FLOOR - 60; + const platform = colliderAt( + world, + new ChainShape([ + { x: -100, y: top }, + { x: 100, y: top }, + ]), + { x: 0, y: 0 }, + 0, + 'static', + { filter: { category: 0x0002 } }, + ); + const { character, body } = spawn(); + + frame(); + character.launch({ x: 0, y: -600 }); + run(frame, 120); + + expect(character.groundCollider).toBe(platform); + expect(body.y).toBeCloseTo(top - HALF, 0); + + // Dropping through is a game rule: leave the platform's category out of the mask. + const collider = body.colliders[0]!; + + collider.filter.mask &= ~0x0002; + run(frame, 20); + expect(body.y).toBeGreaterThan(top); + collider.filter.mask |= 0x0002; + run(frame, 60); + expect(character.groundCollider).not.toBe(platform); + expect(body.y).toBeGreaterThan(REST_Y - 0.2); + }); + }); + + describe('moving platforms', () => { + /** A kinematic platform whose velocity a behaviour sets every fixed step, before the world steps. */ + class Drive extends BehaviorComponent { + public vx = 0; + public vy = 0; + + public constructor(private readonly body: PhysicsBody) { + super(); + } + + public override fixedUpdate(): void { + this.body.linearVelocityX = this.vx; + this.body.linearVelocityY = this.vy; + } + } + + const platformSetup = async () => { + const harness = await setup(); + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(harness.world, { + type: 'kinematic', + position: { x: 0, y: FLOOR + 10 }, + colliders: [{ shape: new BoxShape(200, 20) }], + }), + ); + const drive = node.addComponent(new Drive(physics.body)); + + harness.scene.addChild(node); + + return { ...harness, platform: physics.body, platformNode: node, drive }; + }; + + it('carries a character horizontally, through reversals', async () => { + const { spawn, frame, platform, drive } = await platformSetup(); + const { character, body } = spawn(); + + frame(); + drive.vx = 90; + run(frame, 60, () => { + expect(character.grounded).toBe(true); + expect(body.x - platform.x).toBeCloseTo(0, 6); + }); + + drive.vx = -90; + run(frame, 30, () => expect(body.x - platform.x).toBeCloseTo(0, 6)); + expect(platform.x).toBeCloseTo(45, 4); + expect(character.velocity.x).toBeCloseTo(-90, 6); + }); + + it('carries a character up and down without sinking in or floating off', async () => { + const { spawn, frame, platform, drive } = await platformSetup(); + const { character, body } = spawn(); + + frame(); + + const gap = platform.y - body.y; + + for (const vy of [-120, 120, -60, 200]) { + drive.vy = vy; + run(frame, 30, () => { + expect(character.grounded).toBe(true); + expect(platform.y - body.y).toBeCloseTo(gap, 4); + }); + } + }); + + it('keeps the platform velocity when jumping off it', async () => { + const { spawn, frame, drive } = await platformSetup(); + const { character } = spawn(); + + frame(); + drive.vx = 120; + run(frame, 5); + character.launch({ x: 0, y: -400 }); + frame(); + + expect(character.grounded).toBe(false); + expect(character.velocity.x).toBeCloseTo(120, 6); + }); + + it('stops at a wall while the platform carries on', async () => { + const { world, spawn, frame, platform, drive } = await platformSetup(); + + colliderAt(world, new BoxShape(20, 60), { x: 40, y: FLOOR - 40 }); + + const { character, body } = spawn(); + + frame(); + drive.vx = 120; + run(frame, 20); + + expect(body.x).toBeLessThan(22); + expect(platform.x).toBeGreaterThan(35); + expect(character.grounded).toBe(true); + }); + + it('picks up the velocity of a platform moved by its own movement, whatever the order', async () => { + const harness = await setup(); + const { spawn, frame, world, scene } = harness; + const { character, body } = spawn(); + // Attached after the character, so its controller is registered second. + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(world, { + type: 'kinematic', + position: { x: 0, y: FLOOR + 10 }, + colliders: [{ shape: new BoxShape(200, 20) }], + }), + ); + const platformMovement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 60 })); + + class Push extends BehaviorComponent { + public override fixedUpdate(): void { + platformMovement.move({ x: 1, y: 0 }); + } + } + + node.addComponent(new Push()); + scene.addChild(node); + + frame(); + run(frame, 60, () => expect(body.x - physics.body.x).toBeCloseTo(0, 6)); + expect(character.grounded).toBe(true); + }); + + it('falls once a carrying platform is destroyed', async () => { + const { spawn, frame, platformNode, drive } = await platformSetup(); + const { character, body } = spawn(); + + frame(); + drive.vx = 60; + run(frame, 5); + platformNode.destroy(); + run(frame, 20); + + expect(character.grounded).toBe(false); + expect(body.y).toBeGreaterThan(REST_Y + 5); + }); + }); + + describe('runtime', () => { + it('integrates every fixed step of a multi-step advance', async () => { + const a = await setup(); + const b = await setup(); + const fallA = a.spawn({ position: { x: 0, y: 0 } }); + const fallB = b.spawn({ position: { x: 0, y: 0 } }); + + a.floor(); + b.floor(); + fallA.controls.input = 1; + fallB.controls.input = 1; + + for (let i = 0; i < 20; i++) { + a.frame(3); + b.frame(); + b.frame(); + b.frame(); + } + + expect(fallA.body.x).toBeCloseTo(fallB.body.x, 9); + expect(fallA.body.y).toBeCloseTo(fallB.body.y, 9); + }); + + it('steps with a manually driven world', () => { + const world = new PhysicsWorld(); + + colliderAt(world, new BoxShape(400, 20), { x: 0, y: FLOOR + 10 }); + + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { + type: 'kinematic', + position: { x: 0, y: 0 }, + colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }], + }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 60 }); + const character = new CharacterMovementComponent(movement, { gravity: 1600 }); + + world.add(physics.body); + movement._driver = character; + world._addStepController(movement); + + character.move(1); + world.step(3 / 60 + 1e-9); + + // Gravity integrates three times: v = 3g·dt, y = g·dt²·(1 + 2 + 3). + expect(physics.body.y).toBeCloseTo(1600 * DT * DT * 6, 6); + expect(physics.body.x).toBeCloseTo(3, 6); + expect(character.velocity.y).toBeCloseTo(1600 * DT * 3, 6); + + world.step(1 / 60); + expect(physics.body.x).toBeCloseTo(3, 6); + }); + + it('moves through the simulation so interpolation sees continuous motion', async () => { + const { spawn, floor, frame } = await setup(); + + floor(); + + const { controls, body, node } = spawn(); + + frame(); + controls.input = 1; + run(frame, 10, () => { + expect(body.x - body.previousX).toBeCloseTo(4, 9); + expect(node.x).toBeCloseTo(body.x, 9); + }); + }); + + it('leaves no velocity behind when disabled and starts at rest when enabled again', async () => { + const { spawn, frame } = await setup(); + const { character, body } = spawn({ position: { x: 0, y: 0 } }); + + run(frame, 20); + expect(character.velocity.y).toBeGreaterThan(300); + + character.enabled = false; + expect(body.linearVelocityY).toBe(0); + expect(character.velocity.y).toBe(0); + + const y = body.y; + + run(frame, 5); + expect(body.y).toBe(y); + + // A launch issued while disabled is not kept for later. + character.launch({ x: 0, y: -500 }); + character.enabled = true; + frame(); + expect(character.velocity.y).toBeCloseTo(1600 * DT, 6); + }); + + it('stops with the scene when its node leaves it and resumes from rest', async () => { + const { scene, spawn, frame } = await setup(); + const { character, node, body } = spawn({ position: { x: 0, y: 0 } }); + + run(frame, 20); + scene.removeChild(node); + expect(body.linearVelocityY).toBe(0); + + run(frame, 5); + scene.addChild(node); + frame(); + expect(character.velocity.y).toBeCloseTo(1600 * DT, 6); + }); + + it('keeps its state across a reparent within the scene', async () => { + const { scene, spawn, frame } = await setup(); + const { character, node } = spawn({ position: { x: 0, y: 0 } }); + const holder = new Container(); + + scene.addChild(holder); + run(frame, 10); + + const before = character.velocity.y; + + holder.addChild(node); + frame(); + expect(character.velocity.y).toBeCloseTo(before + 1600 * DT, 6); + }); + + it('steps on after its node is destroyed and releases cleanly when its world is destroyed', async () => { + const { world, spawn, floor, frame } = await setup(); + + floor(); + + const { node } = spawn(); + + frame(); + node.destroy(); + frame(); + + const other = spawn(); + + frame(); + world.destroy(); + expect(other.character.grounded).toBe(false); + }); + + it('stays within the allocation rate of the movement it drives', async () => { + const course = (driven: boolean) => { + const world = new PhysicsWorld(); + + colliderAt(world, new BoxShape(4000, 20), { x: 0, y: FLOOR + 10 }); + colliderAt( + world, + new PolygonShape([ + { x: 0, y: 0 }, + { x: 60, y: -30 }, + { x: 60, y: 0 }, + ]), + { x: 100, y: FLOOR }, + ); + colliderAt(world, new BoxShape(40, 6), { x: 220, y: FLOOR - 3 }); + + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { + type: 'kinematic', + position: { x: 0, y: REST_Y - 0.05 }, + colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }], + }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 240 }); + const character = new CharacterMovementComponent(movement, { gravity: 1600, maxStepHeight: 8 }); + + world.add(physics.body); + world._addStepController(movement); + + if (driven) { + movement._driver = character; + } + + let tick = 0; + + // Back and forth over the slope and the step, launching now and then. + return (): void => { + const direction = Math.floor(tick / 90) % 2 === 0 ? 1 : -1; + + if (driven) { + character.move(direction); + + if (tick % 45 === 0) { + character.launch({ x: 0, y: -300 }); + } + } else { + movement.move({ x: direction, y: 0 }); + } + + tick++; + world.step(DT); + }; + }; + + if (CharacterMovementComponent.prototype._driveStep.toString().includes('cov_')) { + console.log('allocation comparison skipped under coverage (instrumentation inflates the measurement)'); + + return; + } + + const characterRate = await measureAllocationRate(course(true), { warmup: 60, iterations: 2000 }); + const movementRate = await measureAllocationRate(course(false), { warmup: 60, iterations: 2000 }); + + // Neither builds objects per step; what the sampler sees is the world's own + // step plus V8 boxing doubles passed to casts it declines to inline, and a + // character casts a few times more per step than a bare movement (measured + // about 1.7x). An object per cast or per contact would add far more. + expect(characterRate.bytesPerIteration).toBeLessThan(movementRate.bytesPerIteration * 2.5); + }); + }); +}); diff --git a/packages/exojs-physics/test/character-tick-input.test.ts b/packages/exojs-physics/test/character-tick-input.test.ts new file mode 100644 index 000000000..eeeb56340 --- /dev/null +++ b/packages/exojs-physics/test/character-tick-input.test.ts @@ -0,0 +1,308 @@ +import { ActionMap, Application, BehaviorComponent, ButtonAction, Container, Keyboard, Scene, SystemOrder } from '@codexo/exojs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + BoxShape, + CapsuleShape, + CharacterMovementComponent, + KinematicMovementComponent, + PhysicsBodyComponent, + PhysicsWorld, +} from '../src/index'; +import { colliderAt } from './support'; + +/** + * A character driven from fixed steps through an action's `tick` view, in a + * scene-owned world, under a real Application frame loop: one press launches + * the character once, whatever the frame rate, and a press a pause interrupts + * never launches it after resume. + */ + +vi.mock('../../../src/rendering/OutputTransform', async importOriginal => { + const actual = await importOriginal(); + + class StubOutputTransform extends actual.OutputTransform { + public override present(): void {} + } + + return { ...actual, OutputTransform: StubOutputTransform }; +}); + +vi.mock('../../../src/rendering/webgl2/WebGl2Backend', async () => { + const { View } = await import('../../../src/rendering/View'); + + return { + WebGl2Backend: vi.fn().mockImplementation(function () { + return { + onContextLost: { add: vi.fn(), destroy: vi.fn() }, + onContextRestored: { add: vi.fn(), destroy: vi.fn() }, + onRenderError: { add: vi.fn(), destroy: vi.fn() }, + stats: { + frameTimeMs: 0, + drawCalls: 0, + culledNodes: 0, + submittedNodes: 0, + batches: 0, + renderPasses: 0, + renderTargetChanges: 0, + frame: 0, + rawFrameDeltaMs: 0, + }, + resetStats: vi.fn().mockReturnThis(), + flush: vi.fn().mockReturnThis(), + initialize: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn(), + resize: vi.fn().mockReturnThis(), + view: new View(400, 300, 800, 600), + renderTarget: {}, + rendererRegistry: { bindRenderer: vi.fn() }, + backendType: 'webgl2', + setView: vi.fn().mockReturnThis(), + draw: vi.fn().mockReturnThis(), + execute: vi.fn().mockReturnThis(), + setRenderTarget: vi.fn().mockReturnThis(), + resolveRenderTarget: vi.fn(), + clearColor: { red: 0, green: 0, blue: 0, alpha: 1 }, + clear: vi.fn().mockReturnThis(), + pushScissorRect: vi.fn().mockReturnThis(), + popScissorRect: vi.fn().mockReturnThis(), + acquireRenderTexture: vi.fn(), + releaseRenderTexture: vi.fn().mockReturnThis(), + composeWithAlphaMask: vi.fn().mockReturnThis(), + }; + }), + }; +}); + +const FLOOR = 100; +const REST_Y = FLOOR - 20; +const STEP_MS = 1000 / 60; + +type Controls = ActionMap<{ jump: ButtonAction }>; + +/** Jumps from the ground, and once more in the air: the game's rule. */ +class Jumper extends BehaviorComponent { + public launches = 0; + public airJumps = 1; + /** Whether the jump was pressed, as this jumper reads it. */ + public read: (controls: Controls) => boolean = controls => controls.jump.tick.pressed; + private readonly _controls: Controls; + private readonly _character: CharacterMovementComponent; + + public constructor(controls: Controls, character: CharacterMovementComponent) { + super(); + this._controls = controls; + this._character = character; + } + + public override fixedUpdate(): void { + const character = this._character; + + character.move(0); + + if (character.grounded) { + this.airJumps = 1; + } + + if (!this.read(this._controls)) { + return; + } + + if (character.grounded) { + character.launch({ x: 0, y: -520 }); + this.launches++; + } else if (this.airJumps > 0) { + this.airJumps--; + character.launch({ x: 0, y: -440 }); + this.launches++; + } + } +} + +/** A second, independent reader of the same action. */ +class PressCounter extends BehaviorComponent { + public presses = 0; + private readonly _controls: Controls; + + public constructor(controls: Controls) { + super(); + this._controls = controls; + } + + public override fixedUpdate(): void { + this.presses += this._controls.jump.tick.pressCount; + } +} + +class PlatformScene extends Scene { + public readonly world = new PhysicsWorld(); + public controls!: Controls; + public character!: CharacterMovementComponent; + public jumper!: Jumper; + public counter!: PressCounter; + + public override init(): void { + this.systems.add(this.world, { order: SystemOrder.Physics }); + this.controls = this.inputs.attach(new ActionMap({ jump: new ButtonAction(Keyboard.Space) })); + + colliderAt(this.world, new BoxShape(4000, 20), { x: 0, y: FLOOR + 10 }); + + const hero = new Container(); + const physics = hero.addComponent( + new PhysicsBodyComponent(this.world, { + type: 'kinematic', + position: { x: 0, y: REST_Y - 0.05 }, + colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }], + }), + ); + const movement = hero.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 240 })); + + this.character = hero.addComponent(new CharacterMovementComponent(movement, { gravity: 1600 })); + this.jumper = hero.addComponent(new Jumper(this.controls, this.character)); + this.addChild(hero); + + const reader = new Container(); + + this.counter = reader.addComponent(new PressCounter(this.controls)); + this.addChild(reader); + } +} + +const apps: Application[] = []; + +const setup = async (): Promise<{ app: Application; scene: PlatformScene; frame: (ms: number) => void }> => { + const app = new Application({ backend: { type: 'webgl2' }, fixedTimeStep: 1 / 60, scenes: { platform: PlatformScene } }); + + vi.spyOn(app.platform, 'requestFrame').mockReturnValue(1); + vi.spyOn(app.platform, 'cancelFrame').mockImplementation(() => {}); + apps.push(app); + + await app.start(PlatformScene); + + let now = 0; + + (app as unknown as { _scheduler: { _lastFrameTimestamp: number } })._scheduler._lastFrameTimestamp = now; + app.canvas.dispatchEvent(new FocusEvent('focus')); + + const frame = (ms: number): void => { + now += ms; + app.update(now); + }; + + // Settle onto the floor. + for (let i = 0; i < 10; i++) { + frame(STEP_MS); + } + + return { app, scene: app.scenes.currentScene as PlatformScene, frame }; +}; + +afterEach(async () => { + for (const app of apps.splice(0)) { + await app.destroy(); + } +}); + +const tap = (): void => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })); +}; + +describe('character driven by tick input', () => { + it('a tap in a 144 Hz frame without a fixed step launches the character once', async () => { + const { scene, frame } = await setup(); + + expect(scene.character.grounded).toBe(true); + + tap(); + frame(5); + + expect(scene.jumper.launches).toBe(0); + + frame(STEP_MS); + + expect(scene.jumper.launches).toBe(1); + expect(scene.character.velocity.y).toBeLessThan(0); + expect(scene.counter.presses).toBe(1); + + for (let i = 0; i < 20; i++) { + frame(1000 / 144); + } + + expect(scene.jumper.launches).toBe(1); + expect(scene.jumper.airJumps).toBe(1); + }); + + it('a press before three catch-up steps launches once and leaves the air jump unused', async () => { + const { scene, frame } = await setup(); + + tap(); + frame(STEP_MS * 3); + + expect(scene.jumper.launches).toBe(1); + expect(scene.jumper.airJumps).toBe(1); + expect(scene.counter.presses).toBe(1); + }); + + it('two taps before the next step are one launch for the jumper and two presses for a counter', async () => { + const { scene, frame } = await setup(); + + tap(); + frame(5); + tap(); + frame(STEP_MS); + + expect(scene.jumper.launches).toBe(1); + expect(scene.counter.presses).toBe(2); + }); + + it('a tap a pause interrupts never launches the character after resume', async () => { + const { app, scene, frame } = await setup(); + const epoch = scene.world._stepEpoch; + + tap(); + frame(5); + app.scenes.pause(); + frame(STEP_MS); + frame(STEP_MS); + + expect(scene.world._stepEpoch).toBe(epoch); + + app.scenes.resume(); + frame(STEP_MS); + frame(STEP_MS); + + expect(scene.jumper.launches).toBe(0); + expect(scene.character.grounded).toBe(true); + }); +}); + +describe('the same character driven by the frame API, for comparison', () => { + const frameJumper = async () => { + const harness = await setup(); + + harness.scene.jumper.read = controls => controls.jump.pressed; + + return harness; + }; + + it('a press before three catch-up steps reads as pressed in each of them and spends the air jump', async () => { + const { scene, frame } = await frameJumper(); + + tap(); + frame(STEP_MS * 3); + + expect(scene.jumper.launches).toBe(2); + expect(scene.jumper.airJumps).toBe(0); + }); + + it('a tap in a frame without a fixed step is gone by the next step', async () => { + const { scene, frame } = await frameJumper(); + + tap(); + frame(5); + frame(STEP_MS); + + expect(scene.jumper.launches).toBe(0); + }); +}); diff --git a/packages/exojs-physics/test/contact-modifier.test.ts b/packages/exojs-physics/test/contact-modifier.test.ts index 42979a8af..511b61854 100644 --- a/packages/exojs-physics/test/contact-modifier.test.ts +++ b/packages/exojs-physics/test/contact-modifier.test.ts @@ -34,7 +34,8 @@ describe('contact modifier', () => { const floor = addFloor(world, 100); const box = addBox(world, 0, 60); - const seen: { a: unknown; b: unknown; normalY: number; pointCount: number }[] = []; + const seen: Array<{ a: unknown; b: unknown; normalY: number; pointCount: number }> = []; + world.contactModifier = contact => { seen.push({ a: contact.bodyA, b: contact.bodyB, normalY: contact.normalY, pointCount: contact.pointCount }); }; @@ -74,6 +75,7 @@ describe('contact modifier', () => { const starts: CollisionEvent[] = []; world.onCollisionStart.add(event => starts.push(event)); + world.contactModifier = contact => { contact.enabled = false; }; @@ -91,8 +93,9 @@ describe('contact modifier', () => { addFloor(world, 100); addBox(world, 0, 60, 0.36, 0.5); - const observed: { friction: number; restitution: number; enabled: boolean }[] = []; + const observed: Array<{ friction: number; restitution: number; enabled: boolean }> = []; let step = 0; + world.contactModifier = contact => { observed.push({ friction: contact.friction, restitution: contact.restitution, enabled: contact.enabled }); @@ -209,6 +212,7 @@ describe('contact modifier', () => { world.contactModifier = contact => { contact.enabled = false; }; + world.step(FRAME); expect(record.normalImpulse).toEqual([0, 0]); @@ -242,6 +246,7 @@ describe('contact modifier', () => { box.linearVelocityY = velocityY; let movingUp = false; + world.contactModifier = contact => { if (movingUp) { contact.enabled = false; diff --git a/packages/exojs-physics/test/convex-decomposition.test.ts b/packages/exojs-physics/test/convex-decomposition.test.ts index 01fec9157..4f14bea12 100644 --- a/packages/exojs-physics/test/convex-decomposition.test.ts +++ b/packages/exojs-physics/test/convex-decomposition.test.ts @@ -101,7 +101,9 @@ const aggregate = (parts: readonly PointLike[][]): MassProperties => { cx += properties.area * properties.centroidX; cy += properties.area * properties.centroidY; // Parallel axis back to the origin, so parts with different centroids add up. - inertiaOrigin += properties.unitInertia + properties.area * (properties.centroidX * properties.centroidX + properties.centroidY * properties.centroidY); + inertiaOrigin += + properties.unitInertia + + properties.area * (properties.centroidX * properties.centroidX + properties.centroidY * properties.centroidY); } cx /= area; @@ -186,7 +188,7 @@ const boundsOf = (polygon: readonly PointLike[]): { minX: number; minY: number; return { minX, minY, maxX, maxY }; }; -const outlines: [string, PointLike[]][] = [ +const outlines: Array<[string, PointLike[]]> = [ ['L', lShape], ['star', star], ['comb', comb], @@ -357,7 +359,9 @@ describe('convex parts as physics shapes', () => { area += properties.area; cx += properties.area * properties.centroidX; cy += properties.area * properties.centroidY; - inertiaOrigin += properties.unitInertia + properties.area * (properties.centroidX * properties.centroidX + properties.centroidY * properties.centroidY); + inertiaOrigin += + properties.unitInertia + + properties.area * (properties.centroidX * properties.centroidX + properties.centroidY * properties.centroidY); } cx /= area; @@ -396,7 +400,9 @@ describe('convex parts as physics shapes', () => { colliders: toConvexPolygonShapes(comb).map(shape => ({ shape })), }), ); - const probe = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 45, y: 5 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const probe = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 45, y: 5 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); expect(compound.colliders.length).toBeGreaterThan(1); diff --git a/packages/exojs-physics/test/dynamics.test.ts b/packages/exojs-physics/test/dynamics.test.ts index 69d46f3ab..68dccb954 100644 --- a/packages/exojs-physics/test/dynamics.test.ts +++ b/packages/exojs-physics/test/dynamics.test.ts @@ -35,7 +35,13 @@ const advance = (world: PhysicsWorld, seconds: number): void => { /** A wide static floor whose top surface sits at `topY`. */ const addFloor = (world: PhysicsWorld, topY: number, friction = 0.5, halfWidth = 600): void => { - world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: topY + 20 }, colliders: [{ shape: new BoxShape(halfWidth * 2, 40), friction }] })); + world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: topY + 20 }, + colliders: [{ shape: new BoxShape(halfWidth * 2, 40), friction }], + }), + ); }; /** A dynamic box of `width`×`height` centred at `(x, y)`. */ @@ -46,18 +52,22 @@ const addBox = ( width: number, height = width, options: { friction?: number; restitution?: number; density?: number; fixedRotation?: boolean } = {}, -): PhysicsBody => { - return world.add( +): PhysicsBody => + world.add( new PhysicsBody({ type: 'dynamic', position: { x, y }, ...(options.fixedRotation === undefined ? {} : { fixedRotation: options.fixedRotation }), colliders: [ - { shape: new BoxShape(width, height), density: options.density ?? 1, friction: options.friction ?? 0.5, restitution: options.restitution ?? 0 }, + { + shape: new BoxShape(width, height), + density: options.density ?? 1, + friction: options.friction ?? 0.5, + restitution: options.restitution ?? 0, + }, ], }), ); -}; const speed = (body: PhysicsBody): number => Math.hypot(body.linearVelocityX, body.linearVelocityY); @@ -495,7 +505,11 @@ describe('kinematic interaction', () => { const platformTop = 300; const platform = world.add( - new PhysicsBody({ type: 'kinematic', position: { x: 0, y: platformTop + 20 }, colliders: [{ shape: new BoxShape(200, 40), friction: 0.9 }] }), + new PhysicsBody({ + type: 'kinematic', + position: { x: 0, y: platformTop + 20 }, + colliders: [{ shape: new BoxShape(200, 40), friction: 0.9 }], + }), ); platform.linearVelocityX = 100; @@ -517,7 +531,11 @@ describe('kinematic interaction', () => { const platformTop = 300; const platform = world.add( - new PhysicsBody({ type: 'kinematic', position: { x: 0, y: platformTop + 20 }, colliders: [{ shape: new BoxShape(200, 40), friction: 0.5 }] }), + new PhysicsBody({ + type: 'kinematic', + position: { x: 0, y: platformTop + 20 }, + colliders: [{ shape: new BoxShape(200, 40), friction: 0.5 }], + }), ); for (let i = 0; i < 4; i++) { diff --git a/packages/exojs-physics/test/events.test.ts b/packages/exojs-physics/test/events.test.ts index 10f51d6e9..ca656f2ea 100644 --- a/packages/exojs-physics/test/events.test.ts +++ b/packages/exojs-physics/test/events.test.ts @@ -10,7 +10,9 @@ describe('contact events', () => { it('fires collisionStart once on overlap and collisionEnd on separation', () => { const world = new PhysicsWorld(); colliderAt(world, new BoxShape(10, 10), { x: 0, y: 0 }); - const movingBody = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 8, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const movingBody = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 8, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const starts: CollisionEvent[] = []; const ends: CollisionEvent[] = []; @@ -87,7 +89,9 @@ describe('contact events', () => { world.onCollisionEnd.add(() => log.push('end')); colliderAt(world, new BoxShape(20, 20), { x: 0, y: 0 }); - const mover = world.add(new PhysicsBody({ type: 'dynamic', position: { x: -60, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const mover = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: -60, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); mover.linearVelocityX = 600; for (let i = 0; i < count; i++) { @@ -111,7 +115,9 @@ describe('contact events', () => { world.onSensorExit.add(() => log.push('exit')); colliderAt(world, new BoxShape(40, 40), { x: 0, y: 0 }, 0, 'static', { isSensor: true }); - const mover = world.add(new PhysicsBody({ type: 'kinematic', position: { x: -120, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const mover = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: -120, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); mover.linearVelocityX = 240; for (let i = 0; i < count; i++) { @@ -130,7 +136,9 @@ describe('contact events', () => { it('defers body destruction requested inside a callback', () => { const world = new PhysicsWorld(); colliderAt(world, new BoxShape(10, 10), { x: 0, y: 0 }); - const projectile = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 8, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const projectile = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 8, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); world.onCollisionStart.add(() => { // Deferred: must not throw or corrupt the live arrays mid-dispatch. @@ -206,7 +214,9 @@ describe('contact events', () => { const world = new PhysicsWorld(); // Solid collider added first → lower id; sensor added second → higher id. const solid = colliderAt(world, new BoxShape(20, 20), { x: 0, y: 0 }); - const sensorBody = world.add(new PhysicsBody({ type: 'static', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), isSensor: true }] })); + const sensorBody = world.add( + new PhysicsBody({ type: 'static', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), isSensor: true }] }), + ); const sensor = sensorBody.colliders[0]!; const enters: SensorEvent[] = []; @@ -221,7 +231,9 @@ describe('contact events', () => { it('fires one sensor event (with the lower-id collider as sensor) when both overlapping colliders are sensors', () => { const world = new PhysicsWorld(); const first = colliderAt(world, new BoxShape(20, 20), { x: 0, y: 0 }, 0, 'static', { isSensor: true }); - const secondBody = world.add(new PhysicsBody({ type: 'static', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), isSensor: true }] })); + const secondBody = world.add( + new PhysicsBody({ type: 'static', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), isSensor: true }] }), + ); const second = secondBody.colliders[0]!; const enters: SensorEvent[] = []; @@ -268,7 +280,11 @@ describe('collision filter group override', () => { const world = new PhysicsWorld(); colliderAt(world, new BoxShape(10, 10), { x: 0, y: 0 }, 0, 'static', { filter: { group: 5, category: 0x0001, mask: 0x0000 } }); world.add( - new PhysicsBody({ type: 'kinematic', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), filter: { group: 5, mask: 0x0000 } }] }), + new PhysicsBody({ + type: 'kinematic', + position: { x: 5, y: 0 }, + colliders: [{ shape: new BoxShape(10, 10), filter: { group: 5, mask: 0x0000 } }], + }), ); const start = vi.fn(); @@ -281,7 +297,9 @@ describe('collision filter group override', () => { it('a shared negative group never collides, overriding an otherwise-matching mask', () => { const world = new PhysicsWorld(); colliderAt(world, new BoxShape(10, 10), { x: 0, y: 0 }, 0, 'static', { filter: { group: -3 } }); - world.add(new PhysicsBody({ type: 'kinematic', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), filter: { group: -3 } }] })); + world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), filter: { group: -3 } }] }), + ); const start = vi.fn(); world.onCollisionStart.add(start); @@ -293,7 +311,9 @@ describe('collision filter group override', () => { it('different non-zero groups fall through to the normal category/mask check', () => { const world = new PhysicsWorld(); colliderAt(world, new BoxShape(10, 10), { x: 0, y: 0 }, 0, 'static', { filter: { group: 5 } }); - world.add(new PhysicsBody({ type: 'kinematic', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), filter: { group: 7 } }] })); + world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 5, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), filter: { group: 7 } }] }), + ); const start = vi.fn(); world.onCollisionStart.add(start); diff --git a/packages/exojs-physics/test/fields.ts b/packages/exojs-physics/test/fields.ts index bbb261b52..dc73b5e91 100644 --- a/packages/exojs-physics/test/fields.ts +++ b/packages/exojs-physics/test/fields.ts @@ -11,14 +11,24 @@ import { PhysicsBody } from '../src/PhysicsBody'; export const FRAME = 1 / 60; /** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ -export const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { +export const buildField = ( + columns: number, + rows: number, + worldOptions: { enableSleeping?: boolean } = {}, +): { world: PhysicsWorld; bodies: PhysicsBody[] } => { const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); const size = 16; const spacing = 20; const floorTop = 1000; const width = columns * spacing + 200; - world.add(new PhysicsBody({ type: 'static', position: { x: width / 2, y: floorTop + 20 }, colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }] })); + world.add( + new PhysicsBody({ + type: 'static', + position: { x: width / 2, y: floorTop + 20 }, + colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }], + }), + ); const bodies: PhysicsBody[] = []; @@ -48,7 +58,11 @@ export const buildField = (columns: number, rows: number, worldOptions: { enable * steady state is constraint bookkeeping with no contacts at all - the scene in * which per-joint and per-body per-step work is the whole cost. */ -export const buildChains = (chains: number, links: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; links: PhysicsBody[] } => { +export const buildChains = ( + chains: number, + links: number, + worldOptions: { enableSleeping?: boolean } = {}, +): { world: PhysicsWorld; links: PhysicsBody[] } => { const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); const size = 16; const spacing = size * 4; @@ -57,12 +71,18 @@ export const buildChains = (chains: number, links: number, worldOptions: { enabl for (let chain = 0; chain < chains; chain++) { const x = 120 + chain * spacing; - let previous = world.add(new PhysicsBody({ type: 'static', position: { x, y: anchorY }, colliders: [{ shape: new BoxShape(size, size), friction: 0.5 }] })); + let previous = world.add( + new PhysicsBody({ type: 'static', position: { x, y: anchorY }, colliders: [{ shape: new BoxShape(size, size), friction: 0.5 }] }), + ); for (let link = 0; link < links; link++) { const y = anchorY + (link + 1) * size; const body = world.add( - new PhysicsBody({ type: 'dynamic', position: { x, y }, colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }] }), + new PhysicsBody({ + type: 'dynamic', + position: { x, y }, + colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }], + }), ); world.addJoint(new RevoluteJoint({ bodyA: previous, bodyB: body, anchor: { x, y: y - size / 2 }, collideConnected: false })); diff --git a/packages/exojs-physics/test/hosted-clock.test.ts b/packages/exojs-physics/test/hosted-clock.test.ts new file mode 100644 index 000000000..1887b6f4a --- /dev/null +++ b/packages/exojs-physics/test/hosted-clock.test.ts @@ -0,0 +1,800 @@ +import { + ActionMap, + Application, + BehaviorComponent, + ButtonAction, + Container, + Keyboard, + logger, + Scene, + type Seconds, + SystemOrder, + Time, +} from '@codexo/exojs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + BoxShape, + CircleShape, + KinematicMovementComponent, + PhysicsBody, + PhysicsBodyComponent, + PhysicsWorld, + type PhysicsWorldOptions, +} from '../src/index'; +import { colliderAt } from './support'; + +/** + * A world hosted by a real Application frame loop: one world step per host + * fixed step, at the host's duration, with presentation that never feeds back + * into the simulation. A world stepped by hand keeps its own accumulator. + */ + +vi.mock('../../../src/rendering/OutputTransform', async importOriginal => { + const actual = await importOriginal(); + + class StubOutputTransform extends actual.OutputTransform { + public override present(): void {} + } + + return { ...actual, OutputTransform: StubOutputTransform }; +}); + +vi.mock('../../../src/rendering/webgl2/WebGl2Backend', async () => { + const { View } = await import('../../../src/rendering/View'); + + return { + WebGl2Backend: vi.fn().mockImplementation(function () { + return { + onContextLost: { add: vi.fn(), destroy: vi.fn() }, + onContextRestored: { add: vi.fn(), destroy: vi.fn() }, + onRenderError: { add: vi.fn(), destroy: vi.fn() }, + stats: { + frameTimeMs: 0, + drawCalls: 0, + culledNodes: 0, + submittedNodes: 0, + batches: 0, + renderPasses: 0, + renderTargetChanges: 0, + frame: 0, + rawFrameDeltaMs: 0, + }, + resetStats: vi.fn().mockReturnThis(), + flush: vi.fn().mockReturnThis(), + initialize: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn(), + resize: vi.fn().mockReturnThis(), + view: new View(400, 300, 800, 600), + renderTarget: {}, + rendererRegistry: { bindRenderer: vi.fn() }, + backendType: 'webgl2', + setView: vi.fn().mockReturnThis(), + draw: vi.fn().mockReturnThis(), + execute: vi.fn().mockReturnThis(), + setRenderTarget: vi.fn().mockReturnThis(), + resolveRenderTarget: vi.fn(), + clearColor: { red: 0, green: 0, blue: 0, alpha: 1 }, + clear: vi.fn().mockReturnThis(), + pushScissorRect: vi.fn().mockReturnThis(), + popScissorRect: vi.fn().mockReturnThis(), + acquireRenderTexture: vi.fn(), + releaseRenderTexture: vi.fn().mockReturnThis(), + composeWithAlphaMask: vi.fn().mockReturnThis(), + }; + }), + }; +}); + +type Configure = (scene: HostScene, app: Application) => void; + +class HostScene extends Scene { + public static configure: Configure = () => {}; + public world!: PhysicsWorld; + public onUpdate: (delta: Seconds) => void = () => {}; + + public override init(): void { + HostScene.configure(this, this.app); + } + + public override update(delta: Seconds): void { + this.onUpdate(delta); + } +} + +class OtherScene extends Scene {} + +/** Runs `run` from the fixed step of a behaviour on a node of `scene`, ahead of the scene's systems. */ +class Hook extends BehaviorComponent { + public constructor(private readonly _run: () => void) { + super(); + } + + public override fixedUpdate(): void { + this._run(); + } +} + +interface Rig { + readonly app: Application; + readonly scene: HostScene; + readonly errors: Error[]; + /** Host fixed steps run since the scene started. */ + hostSteps(): number; + frame(ms: number): void; +} + +const apps: Application[] = []; + +const host = async (hz: number, configure: Configure): Promise => { + HostScene.configure = configure; + + const app = new Application({ backend: { type: 'webgl2' }, fixedTimeStep: 1 / hz, scenes: { host: HostScene, other: OtherScene } }); + const errors: Error[] = []; + let steps = 0; + + vi.spyOn(app.platform, 'requestFrame').mockReturnValue(1); + vi.spyOn(app.platform, 'cancelFrame').mockImplementation(() => {}); + apps.push(app); + app.onError.add(error => errors.push(error)); + app.onFixedFrame.add(() => steps++); + + await app.start(HostScene); + + let now = 0; + + (app as unknown as { _scheduler: { _lastFrameTimestamp: number } })._scheduler._lastFrameTimestamp = now; + + return { + app, + scene: app.scenes.currentScene as HostScene, + errors, + hostSteps: () => steps, + frame: ms => { + now += ms; + app.update(now); + }, + }; +}; + +afterEach(async () => { + vi.restoreAllMocks(); + + for (const app of apps.splice(0)) { + await app.destroy(); + } +}); + +/** A scene-owned world, registered once as the scene's physics system. */ +const sceneWorld = + (options: (app: Application) => PhysicsWorldOptions = app => ({ fixedDelta: app.fixedTimeStep })): Configure => + (scene, app) => { + scene.world = new PhysicsWorld(options(app)); + scene.systems.add(scene.world, { order: SystemOrder.Physics }); + }; + +/** A dynamic body coasting along +x at `speed`, so its position is speed × simulated time. */ +const coaster = (world: PhysicsWorld, speed: number): PhysicsBody => { + const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + + body.linearVelocityX = speed; + + return body; +}; + +/** A kinematic body a behaviour moves along +x at full speed on every fixed step. */ +const walker = (scene: HostScene, maxSpeed: number): KinematicMovementComponent => { + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(scene.world, { type: 'kinematic', position: { x: 0, y: 500 }, colliders: [{ shape: new CircleShape(8) }] }), + ); + const movement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed })); + + node.addComponent(new Hook(() => movement.move({ x: 1, y: 0 }))); + scene.addChild(node); + + return movement; +}; + +const STEP_60 = 1000 / 60; + +describe('a world hosted on a matching clock', () => { + it.each([30, 60, 120])('advances one world step per %i Hz host step and integrates exactly the host time', async hz => { + let body!: PhysicsBody; + let movement!: KinematicMovementComponent; + const rig = await host(hz, (scene, app) => { + sceneWorld()(scene, app); + body = coaster(scene.world, 60); + movement = walker(scene, 90); + }); + + for (let i = 0; i < 144; i++) { + rig.frame(1000 / 144); + } + + const steps = rig.hostSteps(); + const simulated = steps / hz; + + expect(rig.errors).toEqual([]); + expect(steps).toBeGreaterThanOrEqual(hz - 1); + expect(rig.scene.world._stepEpoch).toBe(steps); + expect(body.x).toBeCloseTo(60 * simulated, 6); + expect(movement.body.x).toBeCloseTo(90 * simulated, 6); + }); + + it.each([30, 50, 60, 120, 144])('accepts a world built with the literal 1 / %i on a host configured with the same rate', async hz => { + const rig = await host( + hz, + sceneWorld(() => ({ fixedDelta: 1 / hz })), + ); + + for (let i = 0; i < 10; i++) { + rig.frame(1000 / hz); + } + + expect(rig.errors).toEqual([]); + expect(rig.hostSteps()).toBeGreaterThan(0); + expect(rig.scene.world._stepEpoch).toBe(rig.hostSteps()); + }); + + it('runs exactly the host-authorized steps for 0, 1, N and clamped frames', async () => { + const rig = await host(60, sceneWorld()); + const world = rig.scene.world; + const observed: Array<[host: number, world: number]> = []; + + for (const ms of [5, STEP_60 - 5, STEP_60, STEP_60 * 3, 1000, 0, 0]) { + const hostBefore = rig.hostSteps(); + const worldBefore = world._stepEpoch; + + rig.frame(ms); + observed.push([rig.hostSteps() - hostBefore, world._stepEpoch - worldBefore]); + } + + // 1000 ms is clamped to the host's 100 ms frame budget: six 60 Hz steps. + expect(observed).toEqual([ + [0, 0], + [1, 1], + [1, 1], + [3, 3], + [6, 6], + [0, 0], + [0, 0], + ]); + }); +}); + +describe('a world on a mismatched host clock', () => { + it.each([ + [120, 1 / 60], + [30, 1 / 60], + [60, 1 / 50], + ])('is refused on a %i Hz host with fixedDelta %d before anything moves', async (hz, fixedDelta) => { + let body!: PhysicsBody; + let movement!: KinematicMovementComponent; + const rig = await host(hz, (scene, app) => { + sceneWorld(() => ({ fixedDelta }))(scene, app); + body = coaster(scene.world, 60); + movement = walker(scene, 90); + }); + const contacts: unknown[] = []; + + rig.scene.world.onCollisionStart.add(event => contacts.push(event)); + colliderAt(rig.scene.world, new BoxShape(20, 20), { x: 0, y: 0 }); + + rig.frame(1000 / hz); + + expect(rig.errors).toHaveLength(1); + expect(rig.errors[0]!.message).toMatch(/fixedDelta/); + expect(rig.scene.world._stepEpoch).toBe(0); + expect(body.x).toBe(0); + expect(body.linearVelocityX).toBe(60); + expect(movement.body.x).toBe(0); + expect(movement.body.linearVelocityX).toBe(0); + expect(contacts).toEqual([]); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 0, -1 / 60, (1 / 60) * (1 + 2e-9)])( + 'refuses a host step of %d without changing existing simulation or pending commands', + step => { + const world = new PhysicsWorld(); + const body = coaster(world, 1); + const node = new Container(); + + world.bind(body, node); + colliderAt(world, new BoxShape(20, 20), { x: 0, y: 0 }, 0, 'static', { isSensor: true }); + world.fixedUpdate(Time.seconds(1 / 60)); + + const beforeStep = vi.fn(); + const afterStep = vi.fn(); + const contact = vi.fn(); + const command = vi.fn(); + const commands = (world as unknown as { _commands: Array<() => void> })._commands; + + world._addStepController({ _followsOthers: true, _beforeStep: beforeStep, _afterStep: afterStep }); + world.onSensorEnter.add(contact); + world.onSensorExit.add(contact); + commands.push(command); + + const snapshot = () => ({ + x: body.x, + y: body.y, + previousX: body.previousX, + previousY: body.previousY, + velocity: body.linearVelocityX, + sleepTime: body._sleepTime, + sleeping: body.isSleeping, + epoch: world._stepEpoch, + accumulator: world.timeStepper.accumulator, + nodeX: node.x, + nodeY: node.y, + }); + const before = snapshot(); + + expect(() => world.fixedUpdate(Time.seconds(step))).toThrow(Number.isFinite(step) && step > 0 ? Error : RangeError); + expect(snapshot()).toEqual(before); + expect(beforeStep).not.toHaveBeenCalled(); + expect(afterStep).not.toHaveBeenCalled(); + expect(contact).not.toHaveBeenCalled(); + expect(command).not.toHaveBeenCalled(); + expect(commands).toEqual([command]); + + world.fixedUpdate(Time.seconds(1 / 60)); + + expect(beforeStep).toHaveBeenCalledExactlyOnceWith(1 / 60); + expect(afterStep).toHaveBeenCalledOnce(); + expect(contact).not.toHaveBeenCalled(); + expect(command).toHaveBeenCalledOnce(); + expect(commands).toEqual([]); + world.destroy(); + }, + ); + + it.each([ + [1 / 60, 1 / 60], + [1 / 60, (1 / 60) * (1 + 0.5e-9)], + [1 / 60, (1 / 60) * (1 - 0.5e-9)], + [1e9, 1e9 + 1], + [1e9, 1e9 - 1], + ])('accepts fixedDelta %d and host step %d, preserving the configured duration', (fixedDelta, step) => { + // At 1e9 seconds the 1e-9 relative boundary is exactly one second: + // both the threshold and the subtraction are representable without rounding. + const world = new PhysicsWorld({ fixedDelta, enableSleeping: false }); + const body = coaster(world, 1 / fixedDelta); + const beforeStep = vi.fn(); + + world._addStepController({ _followsOthers: false, _beforeStep: beforeStep, _afterStep: vi.fn() }); + world.fixedUpdate(Time.seconds(step)); + + expect(world._stepEpoch).toBe(1); + expect(world.timeStepper.fixedDelta).toBe(fixedDelta); + expect(beforeStep).toHaveBeenCalledExactlyOnceWith(fixedDelta); + expect(body.x).toBeCloseTo(1, 10); + world.destroy(); + }); + + it.each([1e9 + 1.25, 1e9 - 1.25])('rejects host step %d clearly outside the exact relative boundary', step => { + const world = new PhysicsWorld({ fixedDelta: 1e9 }); + const body = coaster(world, 1e-9); + + expect(() => world.fixedUpdate(Time.seconds(step))).toThrow(/fixedDelta/); + expect(world._stepEpoch).toBe(0); + expect(body.x).toBe(0); + world.destroy(); + }); +}); + +describe('a world stepped by hand', () => { + it('keeps its own accumulator and rate inside a scene on a different host clock', async () => { + let body!: PhysicsBody; + const world = new PhysicsWorld({ fixedDelta: 1 / 50, maxSubSteps: 3 }); + const rig = await host(60, scene => { + body = coaster(world, 50); + scene.onUpdate = delta => world.step(delta); + }); + const warn = vi.spyOn(logger, 'warn'); + + // 20 ms frames: one 50 Hz step each, whatever the host's 60 Hz steps do. + for (let i = 0; i < 10; i++) { + rig.frame(20); + } + + expect(rig.errors).toEqual([]); + expect(body.x).toBeCloseTo(10, 6); + + // A 100 ms frame owes five 50 Hz steps; maxSubSteps runs three and drops the rest. + rig.frame(100); + + expect(body.x).toBeCloseTo(13, 6); + expect(world.timeStepper.accumulator).toBeLessThan(world.timeStepper.fixedDelta); + expect(warn).not.toHaveBeenCalled(); + + world.destroy(); + }); +}); + +describe('a world with two drivers', () => { + it('warns once when a scene-hosted world is also stepped by hand', async () => { + const warn = vi.spyOn(logger, 'warn'); + const rig = await host(60, (scene, app) => { + sceneWorld()(scene, app); + scene.onUpdate = delta => scene.world.step(delta); + }); + + for (let i = 0; i < 10; i++) { + rig.frame(STEP_60); + } + + const calls = warn.mock.calls.filter(([message]) => /step\(\)/.test(String(message))); + + expect(calls).toHaveLength(1); + expect(String(calls[0]![0])).toMatch(/PhysicsWorld/); + }); + + it.each([0, -1 / 60, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'keeps hosted presentation and diagnostics unchanged after a no-op manual step of %d', + delta => { + const warn = vi.spyOn(logger, 'warn'); + const world = new PhysicsWorld({ interpolation: true }); + const body = coaster(world, 600); + const node = new Container(); + + world.bind(body, node); + world.fixedUpdate(Time.seconds(1 / 60)); + world.update(Time.seconds(0)); + warn.mockClear(); + + world.step(delta); + + expect(world._stepEpoch).toBe(1); + expect(body.x).toBeCloseTo(10, 6); + expect(node.x).toBeCloseTo(body.x, 6); + expect(world.timeStepper.accumulator).toBe(0); + + world.fixedUpdate(Time.seconds(1 / 60)); + world.update(Time.seconds(0)); + + expect(world._stepEpoch).toBe(2); + expect(node.x).toBeCloseTo(body.x, 6); + expect(warn).not.toHaveBeenCalled(); + world.destroy(); + }, + ); + + it('does not warn when a world changes driver once', () => { + const warn = vi.spyOn(logger, 'warn'); + const world = new PhysicsWorld(); + + world.step(1 / 60); + world.step(1 / 60); + world.fixedUpdate(Time.seconds(1 / 60)); + world.fixedUpdate(Time.seconds(1 / 60)); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns once when one world is registered with both the application and its scene', async () => { + const warn = vi.spyOn(logger, 'warn'); + const rig = await host(60, (scene, app) => { + sceneWorld()(scene, app); + app.systems.add(scene.world, { order: SystemOrder.Physics }); + }); + + for (let i = 0; i < 5; i++) { + rig.frame(STEP_60); + } + + const calls = warn.mock.calls.filter(([message]) => /more than one|two registries|registered with/i.test(String(message))); + + expect(calls).toHaveLength(1); + + rig.app.systems.remove(rig.scene.world); + }); +}); + +describe('presentation alpha', () => { + /** A node bound to a body moving 10 px per 60 Hz step. */ + const presented = async (options: (app: Application) => PhysicsWorldOptions) => { + let body!: PhysicsBody; + const node = new Container(); + const rig = await host(60, (scene, app) => { + sceneWorld(options)(scene, app); + body = coaster(scene.world, 600); + scene.world.bind(body, node); + scene.addChild(node); + }); + + return { rig, body, node }; + }; + + it('places bound nodes by the host alpha without moving bodies, contacts or the step count', async () => { + const { rig, body, node } = await presented(app => ({ + fixedDelta: app.fixedTimeStep, + interpolation: true, + frameAlphaSource: () => app.frameAlpha, + })); + const world = rig.scene.world; + const sample = () => ({ x: body.x, previous: body.previousX, epoch: world._stepEpoch, node: node.x, alpha: rig.app.frameAlpha }); + + rig.frame(STEP_60); + + const stepped = sample(); + + expect(stepped.epoch).toBe(1); + expect(stepped.alpha).toBeCloseTo(0, 9); + expect(stepped.node).toBeCloseTo(stepped.previous, 6); + + rig.frame(STEP_60 / 2); + + const half = sample(); + + expect(half.epoch).toBe(1); + expect(half.x).toBe(stepped.x); + expect(half.alpha).toBeCloseTo(0.5, 9); + expect(half.node).toBeCloseTo(stepped.previous + 5, 6); + + rig.frame(STEP_60 * 0.49); + + const nearOne = sample(); + + expect(nearOne.epoch).toBe(1); + expect(nearOne.x).toBe(stepped.x); + expect(nearOne.alpha).toBeCloseTo(0.99, 9); + expect(nearOne.node).toBeCloseTo(stepped.previous + 9.9, 6); + + // Extra render submissions without elapsed time change nothing. + rig.frame(0); + rig.frame(0); + + expect(sample()).toEqual(nearOne); + + rig.frame(STEP_60 * 0.51); + + const next = sample(); + + expect(next.epoch).toBe(2); + expect(next.x).toBeCloseTo(stepped.x + 10, 6); + expect(next.alpha).toBeCloseTo(0.5, 6); + expect(next.node).toBeCloseTo(next.previous + 5, 6); + expect(rig.errors).toEqual([]); + }); + + it('presents the latest step, and warns once, when a hosted world interpolates without an alpha source', async () => { + const warn = vi.spyOn(logger, 'warn'); + const { rig, body, node } = await presented(app => ({ fixedDelta: app.fixedTimeStep, interpolation: true })); + + rig.frame(STEP_60 * 1.5); + rig.frame(STEP_60); + + expect(rig.scene.world._stepEpoch).toBe(2); + expect(node.x).toBeCloseTo(body.x, 6); + expect(warn.mock.calls.filter(([message]) => /frameAlphaSource/.test(String(message)))).toHaveLength(1); + }); + + it('interpolates a world stepped by hand from its own accumulator', async () => { + const world = new PhysicsWorld({ interpolation: true }); + const body = coaster(world, 600); + const node = new Container(); + const rig = await host(60, scene => { + world.bind(body, node); + scene.addChild(node); + scene.onUpdate = delta => world.step(delta); + }); + + rig.frame(STEP_60 * 1.5); + + expect(world.timeStepper.alpha).toBeCloseTo(0.5, 6); + expect(node.x).toBeCloseTo(body.previousX + 5, 6); + + world.destroy(); + }); +}); + +describe('scene world and application world', () => { + /** A behaviour gives the body its velocity on the first fixed step; where does that step's world advance stand? */ + const commandLatency = async (register: (world: PhysicsWorld, scene: HostScene, app: Application) => void): Promise => { + let body!: PhysicsBody; + let issued = false; + const rig = await host(60, (scene, app) => { + scene.world = new PhysicsWorld({ fixedDelta: app.fixedTimeStep }); + register(scene.world, scene, app); + body = scene.world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + + const node = new Container(); + + node.addComponent( + new Hook(() => { + if (!issued) { + issued = true; + body.linearVelocityX = 600; + } + }), + ); + scene.addChild(node); + }); + const positions: number[] = []; + + for (let i = 0; i < 3; i++) { + rig.frame(STEP_60); + positions.push(body.x); + } + + if (rig.app.systems.has(rig.scene.world)) { + rig.app.systems.remove(rig.scene.world); + rig.scene.world.destroy(); + } + + return positions; + }; + + it('a scene-registered world applies a command from a scene behaviour in the same step', async () => { + const positions = await commandLatency((world, scene) => scene.systems.add(world, { order: SystemOrder.Physics })); + + expect(positions[0]).toBeCloseTo(10, 6); + expect(positions[1]).toBeCloseTo(20, 6); + }); + + it('an application-registered world has already stepped when the scene issues the command', async () => { + const positions = await commandLatency((world, _scene, app) => app.systems.add(world, { order: SystemOrder.Physics })); + + expect(positions[0]).toBe(0); + expect(positions[1]).toBeCloseTo(10, 6); + }); +}); + +describe('tick input into a scene world', () => { + it.each([30, 60, 120])('a tap reaches the %i Hz step whose world advance applies it, exactly once', async hz => { + let body!: PhysicsBody; + let controls!: ActionMap<{ jump: ButtonAction }>; + const node = new Container(); + const applied: number[] = []; + const rig = await host(hz, (scene, app) => { + sceneWorld(() => ({ fixedDelta: app.fixedTimeStep, interpolation: true, frameAlphaSource: () => app.frameAlpha }))(scene, app); + controls = scene.inputs.attach(new ActionMap({ jump: new ButtonAction(Keyboard.Space) })); + body = scene.world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + scene.world.bind(body, node); + node.addComponent( + new Hook(() => { + if (controls.jump.tick.pressed) { + body.linearVelocityY = -600; + applied.push(scene.world._stepEpoch); + } + }), + ); + scene.addChild(node); + }); + + rig.app.canvas.dispatchEvent(new FocusEvent('focus')); + rig.frame(1000 / hz); + + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })); + + // Render frames without a fixed step hold the press for the next step. + rig.frame(2); + rig.frame(0); + + expect(applied).toEqual([]); + + const before = body.y; + const epoch = rig.scene.world._stepEpoch; + + rig.frame(1000 / hz); + + // Applied in this step, ahead of the world, which then advanced it by one host step. + expect(applied).toEqual([epoch]); + expect(rig.scene.world._stepEpoch).toBe(epoch + 1); + expect(body.y).toBeCloseTo(before - 600 / hz, 6); + expect(body.previousY).toBeCloseTo(before, 6); + expect(rig.app.frameAlpha).toBeCloseTo(0.002 * hz, 9); + expect(node.y).toBeCloseTo(before - (600 / hz) * rig.app.frameAlpha, 6); + expect(node.y).toBeGreaterThan(body.y); + expect(node.y).toBeLessThan(before); + + const stepped = body.y; + const presentedY = node.y; + + rig.frame(0); + rig.frame(0); + + expect(body.y).toBe(stepped); + expect(body.previousY).toBeCloseTo(before, 6); + expect(rig.scene.world._stepEpoch).toBe(epoch + 1); + expect(node.y).toBe(presentedY); + + for (let i = 0; i < 12; i++) { + rig.frame(1000 / 144); + } + + expect(applied).toHaveLength(1); + expect(rig.errors).toEqual([]); + }); +}); + +describe('pause and retention', () => { + it('a paused scene holds its world, presentation included, and resumes without a catch-up burst', async () => { + let body!: PhysicsBody; + const node = new Container(); + const rig = await host(60, (scene, app) => { + sceneWorld(() => ({ fixedDelta: app.fixedTimeStep, interpolation: true, frameAlphaSource: () => app.frameAlpha }))(scene, app); + body = coaster(scene.world, 600); + scene.world.bind(body, node); + scene.addChild(node); + }); + const world = rig.scene.world; + + rig.frame(STEP_60 * 1.5); + rig.app.scenes.pause(); + + const held = { x: body.x, epoch: world._stepEpoch, node: node.x }; + + for (let i = 0; i < 10; i++) { + rig.frame(STEP_60 * 0.7); + } + + expect({ x: body.x, epoch: world._stepEpoch, node: node.x }).toEqual(held); + + rig.app.scenes.resume(); + + const hostBefore = rig.hostSteps(); + + rig.frame(STEP_60); + + expect(world._stepEpoch - held.epoch).toBe(rig.hostSteps() - hostBefore); + expect(world._stepEpoch - held.epoch).toBeLessThanOrEqual(2); + expect(rig.errors).toEqual([]); + }); + + it('a suspended scene holds its world and a restored one steps again on the host clock', async () => { + let body!: PhysicsBody; + const rig = await host(60, (scene, app) => { + sceneWorld()(scene, app); + body = coaster(scene.world, 60); + }); + const world = rig.scene.world; + + rig.frame(STEP_60); + + await rig.app.scenes.change(OtherScene, { suspendCurrent: true }); + + const held = { x: body.x, epoch: world._stepEpoch }; + + for (let i = 0; i < 10; i++) { + rig.frame(STEP_60); + } + + expect({ x: body.x, epoch: world._stepEpoch }).toEqual(held); + + await rig.app.scenes.restore(HostScene); + rig.frame(STEP_60); + + expect(world._stepEpoch).toBe(held.epoch + 1); + expect(body.x).toBeCloseTo(held.x + 1, 6); + expect(rig.errors).toEqual([]); + }); +}); + +describe('ownership', () => { + it('the scene registry destroys a scene-owned world exactly once when the scene ends', async () => { + let destroy!: ReturnType; + let component!: PhysicsBodyComponent; + const rig = await host(60, (scene, app) => { + sceneWorld()(scene, app); + destroy = vi.spyOn(scene.world, 'destroy'); + + const node = new Container(); + + component = node.addComponent( + new PhysicsBodyComponent(scene.world, { type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(4) }] }), + ); + scene.addChild(node); + }); + + rig.frame(STEP_60); + await rig.app.scenes.change(OtherScene); + rig.frame(STEP_60); + + expect(destroy).toHaveBeenCalledTimes(1); + expect(component.body.destroyed).toBe(true); + expect(rig.errors).toEqual([]); + }); +}); diff --git a/packages/exojs-physics/test/interpolation.test.ts b/packages/exojs-physics/test/interpolation.test.ts index 86df21613..5d8754e55 100644 --- a/packages/exojs-physics/test/interpolation.test.ts +++ b/packages/exojs-physics/test/interpolation.test.ts @@ -83,7 +83,9 @@ describe('previous fixed state', () => { it('reports previous === current for a body that did not move', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); // Far from the mover's path: nothing touches it, so it never moves. - const still = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 400, y: 400 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const still = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 400, y: 400 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const mover = movingBody(world, 600); world.step(FIXED * 2); @@ -227,8 +229,8 @@ describe('system-driven interpolation', () => { node.setPosition(-1, -1); // Two fixed steps for this frame: neither may present. - world.fixedUpdate(Time.seconds(0)); - world.fixedUpdate(Time.seconds(0)); + world.fixedUpdate(Time.seconds(FIXED)); + world.fixedUpdate(Time.seconds(FIXED)); expect(node.x).toBe(-1); @@ -247,7 +249,7 @@ describe('system-driven interpolation', () => { const node = fakeNode(); world.bind(body, node as unknown as SceneNode); - world.fixedUpdate(Time.seconds(0)); + world.fixedUpdate(Time.seconds(FIXED)); expect(node.x).toBeCloseTo(body.x, 6); diff --git a/packages/exojs-physics/test/joints.test.ts b/packages/exojs-physics/test/joints.test.ts index e22cdf9c2..b6441e1f5 100644 --- a/packages/exojs-physics/test/joints.test.ts +++ b/packages/exojs-physics/test/joints.test.ts @@ -86,7 +86,9 @@ describe('joints', () => { const run = (): string => { const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); - const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 60, y: 120 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + const bob = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 60, y: 120 }, colliders: [{ shape: new BoxShape(16, 16) }] }), + ); world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); @@ -217,7 +219,9 @@ describe('joints', () => { const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(40, 40) }] })); - world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: wheel, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 })); + world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: wheel, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 }), + ); advance(world, 1); @@ -231,7 +235,9 @@ describe('joints', () => { const bar = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] })); const limit = Math.PI / 4; - world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: bar, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -limit, upperAngle: limit })); + world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: bar, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -limit, upperAngle: limit }), + ); advance(world, 3); @@ -307,7 +313,9 @@ describe('joints', () => { const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); // Suspension axis vertical (0,1): the wheel may travel along it (sprung) + spin, but not slide sideways. - world.addJoint(new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, hertz: 5, dampingRatio: 1 })); + world.addJoint( + new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, hertz: 5, dampingRatio: 1 }), + ); wheel.angularVelocity = 10; // give it spin wheel.applyImpulse(5000, 0); // shove it sideways (perpendicular to the axis) @@ -337,7 +345,9 @@ describe('joints', () => { it('maxForce caps how hard a mouse joint can pull', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); - const heavy = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), density: 100 }] })); + const heavy = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20), density: 100 }] }), + ); // A tiny force against a far target: it creeps but cannot snap across. const joint = world.addJoint(new MouseJoint({ body: heavy, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 1, maxForce: 50 })); @@ -355,7 +365,15 @@ describe('joints', () => { const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); world.addJoint( - new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 }), + new WheelJoint({ + bodyA: chassis, + bodyB: wheel, + anchor: { x: 0, y: 30 }, + axis: { x: 0, y: 1 }, + enableMotor: true, + motorSpeed: 5, + maxMotorTorque: 1e8, + }), ); advance(world, 1); @@ -369,7 +387,9 @@ describe('joints', () => { // Suspension axis vertical (0,1) = gravity: an unloaded soft spring sags then settles. const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); - world.addJoint(new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, hertz: 1, dampingRatio: 1 })); + world.addJoint( + new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, hertz: 1, dampingRatio: 1 }), + ); advance(world, 3); @@ -474,7 +494,9 @@ describe('joints', () => { const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); expect(() => new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: 0, y: 0 } })).toThrow(RangeError); - expect(() => new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: Number.NaN, y: 0 } })).toThrow(RangeError); + expect(() => new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: Number.NaN, y: 0 } })).toThrow( + RangeError, + ); }); it('a prismatic joint with a fixed-rotation slider keeps the perpendicular lock solvable (k22 fallback)', () => { @@ -483,7 +505,9 @@ describe('joints', () => { // Both bodies are rotation-locked (static anchor + fixedRotation slider): iA+iB=0, // which would make the perpendicular+angular block matrix singular without the // `_k22 = 1` fallback. - const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(20, 20) }] })); + const slider = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(20, 20) }] }), + ); world.addJoint(new PrismaticJoint({ bodyA: anchor, bodyB: slider, anchor: { x: 0, y: 0 }, axis: { x: 1, y: 0 } })); @@ -512,9 +536,13 @@ describe('joints', () => { it('an angular motor on a fixed-rotation body does nothing (zero angular effective mass)', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); - const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(16, 16) }] })); + const bob = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(16, 16) }] }), + ); - world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: bob, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 10, maxMotorTorque: 1e8 })); + world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: bob, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 10, maxMotorTorque: 1e8 }), + ); advance(world, 1); @@ -526,7 +554,9 @@ describe('joints', () => { const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); const bar = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] })); - world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: bar, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -0.2, upperAngle: 0.2 })); + world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: bar, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -0.2, upperAngle: 0.2 }), + ); bar.angularVelocity = -50; // slam it hard into the lower limit, forcing a real overshoot @@ -556,7 +586,9 @@ describe('joints', () => { it('a weld joint between fixed-rotation bodies still locks position (zero angular effective mass)', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(20, 20) }] })); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(20, 20) }] }), + ); world.addJoint(new WeldJoint({ bodyA: anchor, bodyB: box })); @@ -573,18 +605,28 @@ describe('joints', () => { const chassis = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); - expect(() => new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 0 }, hertz: 5, dampingRatio: 1 })).toThrow( - RangeError, - ); + expect( + () => new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 0 }, hertz: 5, dampingRatio: 1 }), + ).toThrow(RangeError); }); it('a wheel motor on a fixed-rotation wheel does nothing (zero angular effective mass)', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); const chassis = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); - const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, fixedRotation: true, colliders: [{ shape: new CircleShape(10) }] })); + const wheel = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, fixedRotation: true, colliders: [{ shape: new CircleShape(10) }] }), + ); world.addJoint( - new WheelJoint({ bodyA: chassis, bodyB: wheel, anchor: { x: 0, y: 30 }, axis: { x: 0, y: 1 }, enableMotor: true, motorSpeed: 20, maxMotorTorque: 1e8 }), + new WheelJoint({ + bodyA: chassis, + bodyB: wheel, + anchor: { x: 0, y: 30 }, + axis: { x: 0, y: 1 }, + enableMotor: true, + motorSpeed: 20, + maxMotorTorque: 1e8, + }), ); advance(world, 1); @@ -725,7 +767,9 @@ describe('connected-body collision', () => { let previous = anchor; for (let index = 1; index <= 8; index++) { - const link = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: index * 16 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + const link = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: index * 16 }, colliders: [{ shape: new BoxShape(16, 16) }] }), + ); world.addJoint(new RevoluteJoint({ bodyA: previous, bodyB: link, anchor: { x: 0, y: index * 16 - 8 }, collideConnected: false })); previous = link; @@ -869,9 +913,13 @@ describe('joint lifecycle', () => { // still, which is what a revolute anchor to a static wall would do. const world = new PhysicsWorld({ gravity: { x: 0, y: 0 }, enableSleeping: false }); const wall = boxBody(world, 400, 0, 'static'); - const bullet = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new BoxShape(8, 8) }] })); + const bullet = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new BoxShape(8, 8) }] }), + ); - world.addJoint(new PrismaticJoint({ bodyA: wall, bodyB: bullet, anchor: { x: 0, y: 0 }, axis: { x: 1, y: 0 }, collideConnected: false })); + world.addJoint( + new PrismaticJoint({ bodyA: wall, bodyB: bullet, anchor: { x: 0, y: 0 }, axis: { x: 1, y: 0 }, collideConnected: false }), + ); // 1000 px in one step at 60 Hz, so a clamp at the wall is unmistakable. bullet.linearVelocityX = 60_000; @@ -884,7 +932,9 @@ describe('joint lifecycle', () => { // The counterpart, so the test above cannot pass by CCD being off entirely. const world = new PhysicsWorld({ gravity: { x: 0, y: 0 }, enableSleeping: false }); const wall = boxBody(world, 400, 0, 'static'); - const bullet = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new BoxShape(8, 8) }] })); + const bullet = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new BoxShape(8, 8) }] }), + ); bullet.linearVelocityX = 60_000; diff --git a/packages/exojs-physics/test/kinematic-movement.test.ts b/packages/exojs-physics/test/kinematic-movement.test.ts new file mode 100644 index 000000000..b9ad911e2 --- /dev/null +++ b/packages/exojs-physics/test/kinematic-movement.test.ts @@ -0,0 +1,363 @@ +import { BehaviorComponent, Container, type PointLike, Scene, SceneDirector, type Seconds, SystemOrder, Time } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { BoxShape, CircleShape, KinematicMovementComponent, PhysicsBodyComponent, PhysicsWorld } from '../src/index'; +import { createAppStub } from './sceneHarness'; +import { colliderAt } from './support'; + +const STEP = Time.seconds(1 / 60); + +class WorldScene extends Scene { + public readonly world = new PhysicsWorld(); + + public override init(): void { + this.systems.add(this.world, { order: SystemOrder.Physics }); + } +} + +class OtherScene extends Scene {} + +/** Issues one direction to a movement on every fixed step, like gameplay input would. */ +class Steer extends BehaviorComponent { + public direction: PointLike | null = { x: 1, y: 0 }; + + public constructor(private readonly movement: KinematicMovementComponent) { + super(); + } + + public override fixedUpdate(_step: Seconds): void { + if (this.direction !== null) { + this.movement.move(this.direction); + } + } +} + +const setup = async (position: PointLike = { x: 0, y: 0 }, maxSpeed = 120) => { + const { app, errors } = createAppStub(); + const director = new SceneDirector(app, { world: WorldScene, other: OtherScene }); + + await director.change(WorldScene); + + const scene = director.currentScene as WorldScene; + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(scene.world, { type: 'kinematic', position, colliders: [{ shape: new CircleShape(10) }] }), + ); + const movement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed })); + + scene.addChild(node); + + const frame = (fixedSteps = 1): void => { + director._beginFrame(); + + try { + for (let i = 0; i < fixedSteps; i++) { + director.fixedUpdate(STEP); + } + + director.update(STEP); + } finally { + director._endFrame(); + } + }; + + return { director, errors, scene, world: scene.world, node, physics, movement, body: physics.body, frame }; +}; + +describe('KinematicMovementComponent', () => { + it('moves at maxSpeed through the simulation, not by teleporting', async () => { + const { node, movement, body, frame } = await setup(); + const steer = node.addComponent(new Steer(movement)); + + frame(); + + for (let i = 0; i < 60; i++) { + const before = body.x; + + frame(); + expect(body.previousX).toBeCloseTo(before, 9); + expect(body.x - before).toBeCloseTo(2, 9); + expect(body.linearVelocityX).toBeCloseTo(120, 9); + } + + expect(body.x).toBeCloseTo(122, 6); + expect(node.x).toBeCloseTo(body.x, 9); + expect(movement.blocked).toBe(false); + + steer.direction = { x: 0.5, y: 0 }; + frame(); + expect(body.linearVelocityX).toBeCloseTo(60, 9); + + steer.direction = { x: 30, y: 40 }; + frame(); + expect(body.linearVelocityX).toBeCloseTo(72, 9); + expect(body.linearVelocityY).toBeCloseTo(96, 9); + }); + + it('applies a command issued in the same fixed step, whatever the attachment order', async () => { + const { node, movement, body, frame } = await setup(); + + // The steering behaviour is attached after the movement, and the movement + // runs no hook of its own: the world resolves the command when it steps. + node.addComponent(new Steer(movement)); + frame(); + + expect(body.x).toBeCloseTo(2, 9); + }); + + it('lasts one world step and leaves no velocity behind', async () => { + const { movement, body, frame } = await setup(); + + movement.move({ x: 1, y: 0 }); + frame(3); + + expect(body.x).toBeCloseTo(2, 9); + expect(body.linearVelocityX).toBe(0); + + frame(); + expect(body.x).toBeCloseTo(2, 9); + }); + + it('carries one command through every fixed step of a manual world.step call', async () => { + const world = new PhysicsWorld(); + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(10) }] }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 60 }); + + world.add(physics.body); + world._addStepController(movement); + movement.move({ x: 1, y: 0 }); + world.step(3 / 60 + 1e-9); + + expect(physics.body.x).toBeCloseTo(3, 9); + + world.step(1 / 60); + expect(physics.body.x).toBeCloseTo(3, 9); + }); + + it('caps a command at its distance across a multi-step world.step call', () => { + const world = new PhysicsWorld(); + const physics = new Container().addComponent( + new PhysicsBodyComponent(world, { type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(10) }] }), + ); + const movement = new KinematicMovementComponent(physics, { maxSpeed: 60 }); + + world.add(physics.body); + world._addStepController(movement); + movement.move({ x: 1, y: 0 }, 2.5); + world.step(5 / 60 + 1e-9); + + expect(physics.body.x).toBeCloseTo(2.5, 9); + expect(physics.body.linearVelocityX).toBe(0); + expect(() => movement.move({ x: 1, y: 0 }, -1)).toThrow(RangeError); + expect(() => movement.move({ x: 1, y: 0 }, Number.NaN)).toThrow(RangeError); + }); + + it('stops at a wall without entering it and reports the contact', async () => { + const { world, node, movement, body, frame } = await setup(); + + colliderAt(world, new BoxShape(20, 400), { x: 110, y: 0 }); + node.addComponent(new Steer(movement)); + + for (let i = 0; i < 120; i++) { + frame(); + expect(body.x).toBeLessThanOrEqual(90); + } + + expect(body.x).toBeGreaterThan(89.9); + expect(movement.blocked).toBe(true); + expect(movement.contactNormal.x).toBeCloseTo(-1, 9); + expect(movement.contactNormal.y).toBeCloseTo(0, 9); + expect(body.linearVelocityX).toBeCloseTo(0, 6); + }); + + it('does not pass through a wall it starts flush against', async () => { + const { app, errors } = createAppStub(); + const director = new SceneDirector(app, { world: WorldScene, other: OtherScene }); + + await director.change(WorldScene); + + const scene = director.currentScene as WorldScene; + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(scene.world, { type: 'kinematic', position: { x: 90, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }), + ); + const movement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 2400 })); + + colliderAt(scene.world, new BoxShape(4, 400), { x: 102, y: 0 }); + scene.addChild(node); + node.addComponent(new Steer(movement)); + + for (let i = 0; i < 10; i++) { + director._beginFrame(); + director.fixedUpdate(STEP); + director._endFrame(); + expect(physics.body.x).toBeLessThanOrEqual(90); + } + + expect(movement.blocked).toBe(true); + expect(errors).toEqual([]); + }); + + it('slides along a wall it is pushed into at an angle', async () => { + const { world, node, movement, body, frame } = await setup({ x: 89, y: 0 }); + + colliderAt(world, new BoxShape(20, 4000), { x: 110, y: 0 }); + node.addComponent(new Steer(movement)).direction = { x: Math.SQRT1_2, y: Math.SQRT1_2 }; + + for (let i = 0; i < 60; i++) { + frame(); + expect(body.x).toBeLessThanOrEqual(90); + } + + // The tangential share of the speed survives the contact. + expect(body.y).toBeGreaterThan(120 * Math.SQRT1_2 * 0.95); + expect(movement.blocked).toBe(true); + }); + + it('stops in a corner instead of creeping into either wall', async () => { + const { world, node, movement, body, frame } = await setup({ x: 50, y: 50 }); + + colliderAt(world, new BoxShape(20, 400), { x: 110, y: 0 }); + colliderAt(world, new BoxShape(400, 20), { x: 0, y: 110 }); + node.addComponent(new Steer(movement)).direction = { x: 1, y: 1 }; + + for (let i = 0; i < 120; i++) { + frame(); + expect(body.x).toBeLessThanOrEqual(90); + expect(body.y).toBeLessThanOrEqual(90); + } + + expect(body.x).toBeGreaterThan(89.5); + expect(body.y).toBeGreaterThan(89.5); + }); + + it('cannot tunnel through thin geometry at any speed', async () => { + const { world, node, movement, body, frame } = await setup({ x: 0, y: 0 }, 100_000); + + colliderAt(world, new BoxShape(0.5, 400), { x: 500, y: 0 }); + node.addComponent(new Steer(movement)); + + for (let i = 0; i < 10; i++) { + frame(); + } + + expect(body.x).toBeLessThanOrEqual(489.75); + expect(body.x).toBeGreaterThan(489); + }); + + it('halts at once on stop()', async () => { + const { node, movement, body, frame } = await setup(); + const steer = node.addComponent(new Steer(movement)); + + frame(); + frame(); + steer.direction = null; + movement.stop(); + + expect(body.linearVelocityX).toBe(0); + + const x = body.x; + + frame(); + expect(body.x).toBe(x); + }); + + it('zeroes the velocity when disabled and resumes once enabled again', async () => { + const { node, movement, body, frame } = await setup(); + + node.addComponent(new Steer(movement)); + frame(); + frame(); + + const x = body.x; + + movement.enabled = false; + expect(body.linearVelocityX).toBe(0); + frame(); + expect(body.x).toBe(x); + + movement.enabled = true; + frame(); + expect(body.x).toBeCloseTo(x + 2, 9); + }); + + it('moves nothing while its physics component is disabled', async () => { + const { node, physics, movement, body, frame } = await setup(); + + node.addComponent(new Steer(movement)); + frame(); + frame(); + physics.enabled = false; + frame(); + + const x = body.x; + + physics.enabled = true; + frame(); + expect(body.x).toBeCloseTo(x + 2, 9); + }); + + it('survives detach, reattach and destruction', async () => { + const { scene, node, movement, body, frame } = await setup(); + + node.addComponent(new Steer(movement)); + frame(); + frame(); + scene.removeChild(node); + frame(); + scene.addChild(node); + frame(); + frame(); + expect(body.x).toBeCloseTo(8, 9); + + node.destroy(); + expect(movement.destroyed).toBe(true); + expect(body.destroyed).toBe(true); + expect(() => frame()).not.toThrow(); + }); + + it('refuses a second movement for the same body', async () => { + const { errors, scene, physics, movement } = await setup(); + const other = new Container(); + + const second = other.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 1 })); + + scene.addChild(other); + + expect(errors.map(error => error.message)).toEqual([expect.stringMatching(/already moved/)]); + expect(second.active).toBe(false); + expect(movement.active).toBe(true); + expect(physics.body._movement).toBe(movement); + }); + + it('requires a kinematic body and a valid speed', () => { + const world = new PhysicsWorld(); + const dynamic = new PhysicsBodyComponent(world, { colliders: [{ shape: new CircleShape(1) }] }); + const kinematic = new PhysicsBodyComponent(world, { type: 'kinematic', colliders: [{ shape: new CircleShape(1) }] }); + + expect(() => new KinematicMovementComponent(dynamic, { maxSpeed: 1 })).toThrow(TypeError); + expect(() => new KinematicMovementComponent(kinematic, { maxSpeed: -1 })).toThrow(RangeError); + expect(() => new KinematicMovementComponent(kinematic, { maxSpeed: Number.NaN })).toThrow(RangeError); + expect(() => new KinematicMovementComponent(kinematic, { maxSpeed: 1 }).move({ x: Number.NaN, y: 0 })).toThrow(RangeError); + }); + + it('leaves the motion of other bodies untouched', async () => { + const { world, node, movement, frame } = await setup(); + const falling = colliderAt(world, new CircleShape(5), { x: -100, y: 0 }, 0, 'dynamic'); + const reference = new PhysicsWorld(); + const control = colliderAt(reference, new CircleShape(5), { x: -100, y: 0 }, 0, 'dynamic'); + + falling.body.linearVelocityY = 50; + control.body.linearVelocityY = 50; + node.addComponent(new Steer(movement)); + + for (let i = 0; i < 30; i++) { + frame(); + reference.fixedUpdate(STEP); + } + + expect(falling.body.y).toBe(control.body.y); + }); +}); diff --git a/packages/exojs-physics/test/manifold.test.ts b/packages/exojs-physics/test/manifold.test.ts index 3e28f7256..5c12e3e55 100644 --- a/packages/exojs-physics/test/manifold.test.ts +++ b/packages/exojs-physics/test/manifold.test.ts @@ -92,9 +92,11 @@ describe('narrow phase — manifold generation', () => { } const ids = []; + for (let i = 0; i < manifold.pointCount; i++) { ids.push(manifold.points[i].id); } + ids.sort((m, n) => m - n); if (previousIds !== null) { diff --git a/packages/exojs-physics/test/physics-body-component.test.ts b/packages/exojs-physics/test/physics-body-component.test.ts index f835e7465..0cb09cf48 100644 --- a/packages/exojs-physics/test/physics-body-component.test.ts +++ b/packages/exojs-physics/test/physics-body-component.test.ts @@ -138,15 +138,26 @@ describe('PhysicsBodyComponent', () => { from.addChild(node); physics.body.linearVelocityY = -75; + const body = physics.body; + const colliders = [...body.colliders]; + const add = vi.spyOn(scene.world, 'add'); + const remove = vi.spyOn(scene.world, 'remove'); + const bind = vi.spyOn(scene.world, 'bind'); + to.addChild(node); expect(node.parent).toBe(to); expect(physics.active).toBe(true); - expect(physics.body.attached).toBe(true); - expect(physics.body.linearVelocityY).toBe(-75); + expect(physics.body).toBe(body); + expect([...body.colliders]).toEqual(colliders); + expect(body.attached).toBe(true); + expect(body.linearVelocityY).toBe(-75); + expect(add).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); scene.world.step(DT); - expect(node.y).toBe(physics.body.y); + expect(node.y).toBe(body.y); }); it('leaves the world while its scene is retained and returns on restore', async () => { diff --git a/packages/exojs-physics/test/removal-events.test.ts b/packages/exojs-physics/test/removal-events.test.ts index b9214e1ef..793161f57 100644 --- a/packages/exojs-physics/test/removal-events.test.ts +++ b/packages/exojs-physics/test/removal-events.test.ts @@ -84,7 +84,9 @@ describe('end events when a collider leaves the world', () => { world.add(new PhysicsBody({ type: 'static', colliders: [{ shape: chain }] })); // Straddles the vertex at x = -50, so it touches two edge proxies. - const box = world.add(new PhysicsBody({ type: 'kinematic', position: { x: -50, y: -4 }, colliders: [{ shape: new BoxShape(20, 10) }] })); + const box = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: -50, y: -4 }, colliders: [{ shape: new BoxShape(20, 10) }] }), + ); const starts: CollisionEvent[] = []; const ends: CollisionEvent[] = []; @@ -152,8 +154,12 @@ describe('end events when a collider leaves the world', () => { colliderAt(world, new BoxShape(100, 10), { x: 0, y: 0 }); - const earlier = world.add(new PhysicsBody({ type: 'kinematic', position: { x: -20, y: -8 }, colliders: [{ shape: new BoxShape(10, 10) }] })); - const later = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 20, y: -8 }, colliders: [{ shape: new BoxShape(10, 10) }] })); + const earlier = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: -20, y: -8 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); + const later = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 20, y: -8 }, colliders: [{ shape: new BoxShape(10, 10) }] }), + ); const ends: CollisionEvent[] = []; world.step(DT); @@ -199,7 +205,9 @@ describe('end events when a collider leaves the world', () => { colliderAt(world, new BoxShape(100, 10), { x: 0, y: 0 }, 0, 'static', { isSensor: true }); - const box = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 0, y: -8 }, colliders: [{ shape: new BoxShape(10, 10), isSensor: true }] })); + const box = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 0, y: -8 }, colliders: [{ shape: new BoxShape(10, 10), isSensor: true }] }), + ); const enters: SensorEvent[] = []; const exits: SensorEvent[] = []; diff --git a/packages/exojs-physics/test/rounded-overlap.test.ts b/packages/exojs-physics/test/rounded-overlap.test.ts new file mode 100644 index 000000000..a6c9ebba1 --- /dev/null +++ b/packages/exojs-physics/test/rounded-overlap.test.ts @@ -0,0 +1,202 @@ +import type { PointLike } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import type { Collider } from '../src/Collider'; +import { Manifold } from '../src/collision/Manifold'; +import { collide, testOverlap } from '../src/collision/narrowphase'; +import { BoxShape, CapsuleShape, ChainShape, CircleShape, PhysicsWorld, PolygonShape, SegmentShape } from '../src/index'; +import { colliderAt } from './support'; + +const points = (...values: number[]): PointLike[] => { + const out: PointLike[] = []; + + for (let i = 0; i < values.length; i += 2) { + out.push({ x: values[i]!, y: values[i + 1]! }); + } + + return out; +}; + +const move = (world: PhysicsWorld, collider: Collider, position: Readonly, angle = 0): void => { + collider.body.setTransform(position, angle); + world.step(0); +}; + +// A floor box whose top-right corner is (100, 90), and a vertical capsule whose +// lower cap centre sits 12 below its position. +const floor = (): BoxShape => new BoxShape(200, 20); +const upright = (): CapsuleShape => new CapsuleShape(0, -12, 0, 12, 8); + +describe('rounded overlap near corners', () => { + const manifold = new Manifold(); + + it('does not report a capsule beside a box corner it does not reach', () => { + const world = new PhysicsWorld(); + + colliderAt(world, floor(), { x: 0, y: 100 }); + + // Lower cap centre (104, 83): about 8.06 from the corner, radius 8. + expect(world.overlapShape(upright(), { x: 104, y: 71 })).toHaveLength(0); + expect(world.overlapShape(new CircleShape(8), { x: 104, y: 83 })).toHaveLength(0); + expect(world.shapeCast(upright(), { x: 104, y: 71 }, { x: 0, y: 0 })).toBeNull(); + }); + + it('decides corner overlap by the distance to the corner, around the whole vertex region', () => { + const world = new PhysicsWorld(); + const box = colliderAt(world, floor(), { x: 0, y: 100 }); + const capsule = colliderAt(world, upright(), { x: 0, y: 0 }); + + // Every direction strictly between the top face's and the right face's normals. + for (let step = 1; step < 12; step++) { + const angle = (step / 12) * (Math.PI / 2); + const dx = Math.cos(angle); + const dy = -Math.sin(angle); + + for (const distance of [7.99, 8.01]) { + move(world, capsule, { x: 100 + dx * distance, y: 90 + dy * distance - 12 }); + + const expected = distance < 8; + + expect(testOverlap(box, capsule), `angle ${angle}, distance ${distance}`).toBe(expected); + expect(testOverlap(capsule, box), `reversed, angle ${angle}, distance ${distance}`).toBe(expected); + expect(collide(box, capsule, manifold), `collide, angle ${angle}, distance ${distance}`).toBe(expected); + } + } + }); + + it('decides corner overlap for a rotated capsule by the distance from its spine', () => { + const world = new PhysicsWorld(); + const box = colliderAt(world, floor(), { x: 0, y: 100 }); + const capsule = colliderAt(world, upright(), { x: 0, y: 0 }); + const tilt = 0.6; + + move(world, capsule, { x: 0, y: 0 }, tilt); + + // The lower spine end, relative to the capsule's position, once turned by `tilt`. + const spine = capsule.worldVertices; + const lower = spine[1]! > spine[3]! ? { x: spine[0]!, y: spine[1]! } : { x: spine[2]!, y: spine[3]! }; + + for (const distance of [7.99, 8.01]) { + // Lower end straight up and to the right of the corner, in its vertex region. + const ex = 100 + Math.SQRT1_2 * distance; + const ey = 90 - Math.SQRT1_2 * distance; + + move(world, capsule, { x: ex - lower.x, y: ey - lower.y }, tilt); + + expect(testOverlap(box, capsule), `distance ${distance}`).toBe(distance < 8); + expect(testOverlap(capsule, box), `reversed, distance ${distance}`).toBe(distance < 8); + } + }); + + it('agrees with the contact manifold across a grid around a box corner', () => { + const world = new PhysicsWorld(); + const box = colliderAt(world, new PolygonShape(points(-20, -20, 20, -20, 20, 20, -20, 20)), { x: 0, y: 0 }, 0.3); + const capsule = colliderAt(world, new CapsuleShape(-10, 0, 10, 0, 5), { x: 0, y: 0 }); + + for (let x = -50; x <= 50; x += 2.5) { + for (let y = -50; y <= 50; y += 2.5) { + for (const angle of [0, 0.7, 1.9]) { + move(world, capsule, { x: x + 0.123, y: y + 0.0789 }, angle); + + const touching = collide(box, capsule, manifold); + + expect(testOverlap(box, capsule), `(${x}, ${y}) angle ${angle}`).toBe(touching); + expect(testOverlap(capsule, box), `reversed (${x}, ${y}) angle ${angle}`).toBe(touching); + } + } + } + }); + + it('still reports a capsule core inside a polygon', () => { + const world = new PhysicsWorld(); + const box = colliderAt(world, new BoxShape(100, 100), { x: 0, y: 0 }); + const capsule = colliderAt(world, new CapsuleShape(-5, 0, 5, 0, 2), { x: 3, y: 4 }); + + expect(testOverlap(box, capsule)).toBe(true); + expect(testOverlap(capsule, box)).toBe(true); + }); + + it('decides capsule against segment end by the distance to the end', () => { + const world = new PhysicsWorld(); + const segment = colliderAt(world, new SegmentShape(-50, 0, 50, 0), { x: 0, y: 0 }); + const capsule = colliderAt(world, upright(), { x: 0, y: 0 }); + + for (const distance of [7.99, 8.01]) { + // Lower cap centre up and to the right of the segment's end at (50, 0). + move(world, capsule, { x: 50 + Math.SQRT1_2 * distance, y: -Math.SQRT1_2 * distance - 12 }); + + expect(testOverlap(segment, capsule), `distance ${distance}`).toBe(distance < 8); + expect(testOverlap(capsule, segment), `reversed, distance ${distance}`).toBe(distance < 8); + } + }); + + it('decides capsule against capsule by the distance between their spines', () => { + const world = new PhysicsWorld(); + const lying = colliderAt(world, new CapsuleShape(-20, 0, 20, 0, 5), { x: 0, y: 0 }); + const standing = colliderAt(world, new CapsuleShape(0, -20, 0, 20, 3), { x: 0, y: 0 }); + + for (const distance of [7.99, 8.01]) { + // The standing capsule's lower spine end diagonally beyond the lying one's right end. + move(world, standing, { x: 20 + Math.SQRT1_2 * distance, y: -Math.SQRT1_2 * distance - 20 }); + + expect(testOverlap(lying, standing), `distance ${distance}`).toBe(distance < 8); + expect(testOverlap(standing, lying), `reversed, distance ${distance}`).toBe(distance < 8); + } + }); + + it('leaves polygon against polygon to the face axes', () => { + const world = new PhysicsWorld(); + const a = colliderAt(world, new BoxShape(20, 20), { x: 0, y: 0 }); + const b = colliderAt(world, new BoxShape(20, 20), { x: 19.5, y: 19.5 }, 0.2); + const c = colliderAt(world, new BoxShape(20, 20), { x: 21, y: 0 }); + + expect(testOverlap(a, b)).toBe(collide(a, b, manifold)); + expect(testOverlap(a, c)).toBe(false); + }); +}); + +describe('rounded overlap against chains', () => { + it('does not report a capsule beside a convex chain corner it does not reach', () => { + const world = new PhysicsWorld(); + + // A ledge: floor along y = 90 up to x = 100, then straight down. + colliderAt(world, new ChainShape(points(-100, 90, 100, 90, 100, 200)), { x: 0, y: 0 }); + + expect(world.overlapShape(upright(), { x: 104, y: 71 })).toHaveLength(0); + expect(world.overlapShape(upright(), { x: 104, y: 72 })).toHaveLength(1); + }); + + it('reports a capsule resting in a concave joint, and over a straight seam, once', () => { + const world = new PhysicsWorld(); + const valley = colliderAt(world, new ChainShape(points(-100, 0, 0, 50, 100, 0)), { x: 0, y: 0 }); + const flat = colliderAt(world, new ChainShape(points(-100, 200, 0, 200, 100, 200)), { x: 0, y: 0 }); + + expect(world.overlapShape(upright(), { x: 0, y: 30 })).toEqual([valley]); + expect(world.overlapShape(upright(), { x: 0, y: 200 - 7.99 - 12 })).toEqual([flat]); + expect(world.overlapShape(upright(), { x: 0, y: 200 - 8.01 - 12 })).toHaveLength(0); + }); +}); + +describe('capsule sensors near corners', () => { + it('enters and exits by the true distance to a box corner', () => { + const world = new PhysicsWorld(); + + colliderAt(world, floor(), { x: 0, y: 100 }); + + const sensor = colliderAt(world, upright(), { x: 104, y: 71 }, 0, 'kinematic', { isSensor: true }); + const log: string[] = []; + + world.onSensorEnter.add(() => log.push('enter')); + world.onSensorExit.add(() => log.push('exit')); + world.step(1 / 60); + expect(log).toEqual([]); + + move(world, sensor, { x: 104, y: 72 }); + world.step(1 / 60); + expect(log).toEqual(['enter']); + + move(world, sensor, { x: 104, y: 71 }); + world.step(1 / 60); + expect(log).toEqual(['enter', 'exit']); + }); +}); diff --git a/packages/exojs-physics/test/scene-pause.test.ts b/packages/exojs-physics/test/scene-pause.test.ts new file mode 100644 index 000000000..4efa0a907 --- /dev/null +++ b/packages/exojs-physics/test/scene-pause.test.ts @@ -0,0 +1,215 @@ +import { BehaviorComponent, Container, Scene, SceneDirector, type Seconds, SystemOrder, Time } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { + BoxShape, + CapsuleShape, + CharacterMovementComponent, + CircleShape, + KinematicMovementComponent, + PhysicsBodyComponent, + PhysicsWorld, +} from '../src/index'; +import { createAppStub } from './sceneHarness'; +import { colliderAt } from './support'; + +/** + * A scene pause requested while a fixed step runs - from a behaviour, a + * scene system or a world callback - takes effect at the next step: the step + * it was requested in finishes, world advance included, and no further step + * runs until resume. + */ + +const STEP = Time.seconds(1 / 60); +const FLOOR = 100; +const REST_Y = FLOOR - 20; + +class WorldScene extends Scene { + public readonly world = new PhysicsWorld({ gravity: { x: 0, y: 980 } }); + public readonly afterWorld: number[] = []; + + public override init(): void { + this.systems.add(this.world, { order: SystemOrder.Physics }); + this.systems.add( + { + fixedUpdate: () => { + this.afterWorld.push(this.world._stepEpoch); + }, + }, + { order: SystemOrder.Physics + 1 }, + ); + } +} + +class Hook extends BehaviorComponent { + public onFixedUpdate: () => void = () => {}; + + public override fixedUpdate(_step: Seconds): void { + this.onFixedUpdate(); + } +} + +const setup = async () => { + const { app } = createAppStub(); + const director = new SceneDirector(app, { world: WorldScene }); + + await director.change(WorldScene); + + const scene = director.currentScene as WorldScene; + const world = scene.world; + + colliderAt(world, new BoxShape(4000, 20), { x: 0, y: FLOOR + 10 }); + + const frame = (fixedSteps = 1): void => { + director._beginFrame(); + + try { + for (let i = 0; i < fixedSteps; i++) { + director.fixedUpdate(STEP); + } + + director.update(STEP); + } finally { + director._endFrame(); + } + }; + + const hook = (): Hook => { + const node = new Container(); + const component = node.addComponent(new Hook()); + + scene.addChild(node); + + return component; + }; + + const character = (): CharacterMovementComponent => { + const node = new Container(); + const physics = node.addComponent( + new PhysicsBodyComponent(world, { + type: 'kinematic', + position: { x: 0, y: REST_Y - 0.05 }, + colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8) }], + }), + ); + const movement = node.addComponent(new KinematicMovementComponent(physics, { maxSpeed: 240 })); + const result = node.addComponent(new CharacterMovementComponent(movement, { gravity: 1600 })); + + scene.addChild(node); + + return result; + }; + + return { director, scene, world, frame, hook, character }; +}; + +describe('scene pause inside a fixed step', () => { + it('a pause from a collision callback finishes the step and holds the world until resume', async () => { + const { director, scene, world, frame } = await setup(); + const ball = new Container(); + + ball.addComponent( + new PhysicsBodyComponent(world, { + type: 'dynamic', + position: { x: 0, y: FLOOR - 60 }, + colliders: [{ shape: new CircleShape(10) }], + }), + ); + scene.addChild(ball); + + let pausedAt = -1; + + world.onCollisionStart.add(() => { + if (pausedAt === -1) { + pausedAt = world._stepEpoch; + director.pause(); + } + }); + + for (let i = 0; i < 60 && pausedAt === -1; i++) { + frame(3); + } + + expect(pausedAt).not.toBe(-1); + + // The system after the world still ran in the step the callback paused, + // and nothing stepped after it. + const epochAtPause = world._stepEpoch; + + expect(scene.afterWorld.at(-1)).toBe(epochAtPause); + + frame(3); + frame(3); + + expect(world._stepEpoch).toBe(epochAtPause); + + director.resume(); + frame(2); + + expect(world._stepEpoch).toBe(epochAtPause + 2); + }); + + it('a command issued after a behaviour paused the scene in the same step is applied by that step', async () => { + const { director, world, frame, hook, character } = await setup(); + const pauser = hook(); + const jumper = hook(); + const player = character(); + + frame(); + expect(player.grounded).toBe(true); + + pauser.onFixedUpdate = () => { + pauser.onFixedUpdate = () => {}; + director.pause(); + }; + + jumper.onFixedUpdate = () => { + jumper.onFixedUpdate = () => {}; + player.launch({ x: 0, y: -400 }); + }; + + const epoch = world._stepEpoch; + + frame(3); + + expect(world._stepEpoch).toBe(epoch + 1); + expect(player.grounded).toBe(false); + expect(player.velocity.y).toBeLessThan(0); + + const vy = player.velocity.y; + + director.resume(); + frame(); + + // One launch, not a second one replayed on resume: gravity only slows it. + expect(player.velocity.y).toBeGreaterThan(vy); + }); + + it('a command issued outside the fixed phase is still pending after a pause and runs on the first step after resume', async () => { + // Characterization of the window a step-atomic pause does not close: a + // command issued in the variable update belongs to no step, so the pause + // that follows leaves it pending until resume. + const { director, world, frame, character } = await setup(); + const player = character(); + + frame(); + expect(player.grounded).toBe(true); + + player.launch({ x: 0, y: -400 }); + director.pause(); + + frame(3); + frame(3); + + expect(player.grounded).toBe(true); + + const epoch = world._stepEpoch; + + director.resume(); + frame(); + + expect(world._stepEpoch).toBe(epoch + 1); + expect(player.grounded).toBe(false); + expect(player.velocity.y).toBeLessThan(0); + }); +}); diff --git a/packages/exojs-physics/test/segment.test.ts b/packages/exojs-physics/test/segment.test.ts index 0cbd972ec..9b1f3d0f3 100644 --- a/packages/exojs-physics/test/segment.test.ts +++ b/packages/exojs-physics/test/segment.test.ts @@ -59,7 +59,9 @@ describe('a dynamic body must carry mass', () => { it('accepts a boundary collider alongside a solid one', () => { const world = new PhysicsWorld(); - const body = world.add(new PhysicsBody({ type: 'dynamic', colliders: [{ shape: new BoxShape(10, 10) }, { shape: new SegmentShape(-10, 0, 10, 0) }] })); + const body = world.add( + new PhysicsBody({ type: 'dynamic', colliders: [{ shape: new BoxShape(10, 10) }, { shape: new SegmentShape(-10, 0, 10, 0) }] }), + ); // The segment contributes collision only; the mass is the box's alone. expect(body.mass).toBeCloseTo(100); diff --git a/packages/exojs-physics/test/shape-cast.test.ts b/packages/exojs-physics/test/shape-cast.test.ts new file mode 100644 index 000000000..c0c0d13c3 --- /dev/null +++ b/packages/exojs-physics/test/shape-cast.test.ts @@ -0,0 +1,234 @@ +import type { PointLike } from '@codexo/exojs'; +import { describe, expect, it } from 'vitest'; + +import { BoxShape, CapsuleShape, ChainShape, CircleShape, PhysicsBody, PhysicsWorld, SegmentShape, type ShapeCastHit } from '../src/index'; +import type { AnyShape } from '../src/shapes/AnyShape'; +import { measureAllocationRate } from './allocationSampler'; +import { colliderAt } from './support'; + +const points = (...coordinates: number[]): PointLike[] => { + const out: PointLike[] = []; + + for (let i = 0; i < coordinates.length; i += 2) { + out.push({ x: coordinates[i]!, y: coordinates[i + 1]! }); + } + + return out; +}; + +const movers: ReadonlyArray AnyShape, number]> = [ + ['circle', () => new CircleShape(10), 10], + ['box', () => new BoxShape(20, 20), 10], + ['capsule', () => new CapsuleShape(0, -5, 0, 5, 5), 5], +]; + +/** A wall whose left face sits at x = 100. */ +const wallAt100 = (world: PhysicsWorld) => colliderAt(world, new BoxShape(20, 400), { x: 110, y: 0 }); + +describe('PhysicsWorld.shapeCast', () => { + it.each(movers)('reports the earliest hit and its normal for a %s', (_name, shape, halfWidth) => { + const world = new PhysicsWorld(); + const near = wallAt100(world); + + colliderAt(world, new BoxShape(20, 400), { x: 160, y: 0 }); + + const hit = world.shapeCast(shape(), { x: 0, y: 0 }, { x: 200, y: 0 }); + + expect(hit).not.toBeNull(); + expect(hit!.collider).toBe(near); + expect(hit!.body).toBe(near.body); + expect(hit!.initialOverlap).toBe(false); + expect(hit!.distance).toBeCloseTo(100 - halfWidth, 6); + expect(hit!.fraction).toBeCloseTo((100 - halfWidth) / 200, 6); + expect(hit!.normal.x).toBeCloseTo(-1, 6); + expect(hit!.normal.y).toBeCloseTo(0, 6); + }); + + it('returns null when the motion is free and when it stops short of the obstacle', () => { + const world = new PhysicsWorld(); + + wallAt100(world); + + expect(world.shapeCast(new CircleShape(10), { x: 0, y: 0 }, { x: 0, y: 300 })).toBeNull(); + expect(world.shapeCast(new CircleShape(10), { x: 0, y: 0 }, { x: 80, y: 0 })).toBeNull(); + }); + + it('rotates the cast shape by the angle option', () => { + const world = new PhysicsWorld(); + + wallAt100(world); + + const upright = world.shapeCast(new BoxShape(40, 10), { x: 0, y: 0 }, { x: 200, y: 0 }); + const turned = world.shapeCast(new BoxShape(40, 10), { x: 0, y: 0 }, { x: 200, y: 0 }, { angle: Math.PI / 2 }); + + expect(upright!.distance).toBeCloseTo(80, 6); + expect(turned!.distance).toBeCloseTo(95, 6); + }); + + it('respects the filter, exclusions and sensors', () => { + const world = new PhysicsWorld(); + const filtered = colliderAt(world, new BoxShape(20, 400), { x: 50, y: 0 }, 0, 'static', { filter: { category: 0x0004 } }); + const sensor = colliderAt(world, new BoxShape(20, 400), { x: 80, y: 0 }, 0, 'static', { isSensor: true }); + const excluded = colliderAt(world, new BoxShape(20, 400), { x: 110, y: 0 }); + const last = colliderAt(world, new BoxShape(20, 400), { x: 140, y: 0 }); + const shape = new CircleShape(5); + const from = { x: 0, y: 0 }; + const motion = { x: 300, y: 0 }; + + expect(world.shapeCast(shape, from, motion)!.collider).toBe(filtered); + expect(world.shapeCast(shape, from, motion, { filter: { mask: 0x0001 } })!.collider).toBe(excluded); + expect(world.shapeCast(shape, from, motion, { filter: { mask: 0x0001 }, includeSensors: true })!.collider).toBe(sensor); + expect(world.shapeCast(shape, from, motion, { filter: { mask: 0x0001 }, exclude: [excluded] })!.collider).toBe(last); + expect(world.shapeCast(shape, from, motion, { exclude: [filtered.body, excluded, last.body] })).toBeNull(); + }); + + it('hits static, kinematic and dynamic colliders alike', () => { + for (const type of ['static', 'kinematic', 'dynamic'] as const) { + const world = new PhysicsWorld(); + const target = colliderAt(world, new BoxShape(20, 20), { x: 110, y: 0 }, 0, type); + + expect(world.shapeCast(new CircleShape(10), { x: 0, y: 0 }, { x: 200, y: 0 })!.collider).toBe(target); + } + }); + + it('does not skip thin geometry however long the motion', () => { + const world = new PhysicsWorld(); + const sliver = colliderAt(world, new BoxShape(0.5, 400), { x: 5000, y: 0 }); + const segment = colliderAt(world, new SegmentShape(0, -200, 0, 200), { x: 9000, y: 0 }); + + expect(world.shapeCast(new CircleShape(2), { x: 0, y: 0 }, { x: 100_000, y: 0 })!.collider).toBe(sliver); + expect(world.shapeCast(new BoxShape(4, 4), { x: 6000, y: 0 }, { x: 100_000, y: 0 })!.collider).toBe(segment); + }); + + it('keeps chains one-sided and reports the chain, not an edge', () => { + const world = new PhysicsWorld(); + const floor = colliderAt(world, new ChainShape(points(-200, 0, 0, 0, 200, 0)), { x: 0, y: 0 }); + const down = world.shapeCast(new CircleShape(5), { x: 10, y: -50 }, { x: 0, y: 100 }); + const up = world.shapeCast(new CircleShape(5), { x: 10, y: 50 }, { x: 0, y: -100 }); + + // Exactly one side of the chain is solid; casting from the other side passes. + expect([down, up].filter(hit => hit !== null)).toHaveLength(1); + + const hit = (down ?? up)!; + + expect(hit.collider).toBe(floor); + expect(hit.distance).toBeCloseTo(45, 6); + }); + + it('reports an initial overlap only for motion that does not leave it', () => { + const world = new PhysicsWorld(); + const wall = wallAt100(world); + const shape = new CircleShape(10); + // Overlapping the wall's left face by 2. + const from = { x: 92, y: 0 }; + + const deeper = world.shapeCast(shape, from, { x: 50, y: 0 }); + + expect(deeper!.collider).toBe(wall); + expect(deeper!.initialOverlap).toBe(true); + expect(deeper!.fraction).toBe(0); + expect(deeper!.distance).toBe(0); + expect(deeper!.penetration).toBeCloseTo(2, 6); + expect(deeper!.normal.x).toBeCloseTo(-1, 6); + + expect(world.shapeCast(shape, from, { x: -50, y: 0 })).toBeNull(); + expect(world.shapeCast(shape, from, { x: 0, y: 50 })).toBeNull(); + }); + + it.each(movers)('blocks a %s that starts exactly touching the obstacle', (_name, shape, halfWidth) => { + const world = new PhysicsWorld(); + const wall = wallAt100(world); + const from = { x: 100 - halfWidth, y: 0 }; + const into = world.shapeCast(shape(), from, { x: 50, y: 0 }); + + expect(into!.collider).toBe(wall); + expect(into!.initialOverlap).toBe(true); + expect(into!.fraction).toBe(0); + expect(into!.penetration).toBeCloseTo(0, 6); + expect(into!.normal.x).toBeCloseTo(-1, 6); + expect(world.shapeCast(shape(), from, { x: 0, y: 50 })).toBeNull(); + expect(world.shapeCast(shape(), from, { x: -50, y: 0 })).toBeNull(); + }); + + it('treats a zero displacement as an overlap test', () => { + const world = new PhysicsWorld(); + const wall = wallAt100(world); + const shape = new CircleShape(10); + + expect(world.shapeCast(shape, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeNull(); + + const hit = world.shapeCast(shape, { x: 95, y: 0 }, { x: 0, y: 0 }); + + expect(hit!.collider).toBe(wall); + expect(hit!.initialOverlap).toBe(true); + expect(hit!.penetration).toBeCloseTo(5, 6); + }); + + it('rejects boundary shapes and non-finite input', () => { + const world = new PhysicsWorld(); + + expect(() => world.shapeCast(new SegmentShape(0, 0, 1, 0), { x: 0, y: 0 }, { x: 1, y: 0 })).toThrow(TypeError); + expect(() => world.shapeCast(new ChainShape(points(0, 0, 1, 0)), { x: 0, y: 0 }, { x: 1, y: 0 })).toThrow(TypeError); + expect(() => world.shapeCast(new CircleShape(1), { x: 0, y: 0 }, { x: Number.NaN, y: 0 })).toThrow(RangeError); + expect(() => world.shapeCast(new CircleShape(1), { x: Infinity, y: 0 }, { x: 1, y: 0 })).toThrow(RangeError); + }); + + it('writes into a caller-owned hit and sees bodies moved since the last step', () => { + const world = new PhysicsWorld(); + const target = new PhysicsBody({ type: 'kinematic', position: { x: 110, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }); + const out: ShapeCastHit = { + collider: null!, + body: null!, + fraction: 0, + distance: 0, + normal: { x: 0, y: 0 }, + initialOverlap: false, + penetration: 0, + }; + + world.add(target); + world.step(1 / 60); + + expect(world.shapeCast(new CircleShape(10), { x: 0, y: 0 }, { x: 300, y: 0 }, {}, out)).toBe(out); + expect(out.distance).toBeCloseTo(90, 6); + + target.setTransform({ x: 210, y: 0 }); + world.shapeCast(new CircleShape(10), { x: 0, y: 0 }, { x: 300, y: 0 }, {}, out); + expect(out.distance).toBeCloseTo(190, 6); + }); + + it('allocates no objects in steady state with a caller-owned hit', async () => { + const world = new PhysicsWorld(); + + if (world.shapeCast.toString().includes('cov_')) { + return; + } + + for (let i = 0; i < 20; i++) { + colliderAt(world, new BoxShape(20, 20), { x: 100 + i * 40, y: (i % 3) * 10 }); + } + + const shape = new BoxShape(10, 10); + const from = { x: 0, y: 0 }; + const motion = { x: 1000, y: 0 }; + const exclude = [world.bodies[0]!]; + const options = { exclude, filter: { mask: 0xffff } }; + const out: ShapeCastHit = { + collider: null!, + body: null!, + fraction: 0, + distance: 0, + normal: { x: 0, y: 0 }, + initialOverlap: false, + penetration: 0, + }; + const rate = await measureAllocationRate(() => world.shapeCast(shape, from, motion, options, out), { warmup: 50, iterations: 2000 }); + + // The cast builds no objects. What the sampler still sees is V8 boxing the + // displacement doubles passed into the per-candidate sweep whenever it + // declines to inline that call - measured ~0.7 KB per cast over these 20 + // candidates. Building a proxy or a hit per candidate would add several + // kilobytes on top. + expect(rate.bytesPerIteration).toBeLessThan(4 * 1024); + }); +}); diff --git a/packages/exojs-physics/test/sleep-penetration.test.ts b/packages/exojs-physics/test/sleep-penetration.test.ts index debb47193..1b898a20f 100644 --- a/packages/exojs-physics/test/sleep-penetration.test.ts +++ b/packages/exojs-physics/test/sleep-penetration.test.ts @@ -67,8 +67,8 @@ const framesUntilAsleep = (world: PhysicsWorld, body: PhysicsBody, seconds: numb return -1; }; -const points = (...coords: number[]): Readonly[] => { - const result: Readonly[] = []; +const points = (...coords: number[]): Array> => { + const result: Array> = []; for (let i = 0; i < coords.length; i += 2) { result.push({ x: coords[i]!, y: coords[i + 1]! }); @@ -242,7 +242,13 @@ describe('sleeping with unresolved contact penetration', () => { addBoxFloor(world); // A trigger volume the resting body sits well inside of. - world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: FLOOR_TOP - 16 }, colliders: [{ shape: new BoxShape(200, 200), isSensor: true }] })); + world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: FLOOR_TOP - 16 }, + colliders: [{ shape: new BoxShape(200, 200), isSensor: true }], + }), + ); const body = addDynamic(world, new BoxShape(32, 32), FLOOR_TOP - 16 - 2); const sleptAt = framesUntilAsleep(world, body, 2); @@ -259,7 +265,9 @@ describe('sleeping with unresolved contact penetration', () => { // A pass-through platform the resting body overlaps by 10px. Disabled, it // applies no impulse, so its penetration is never going to be resolved - // and must not keep the body awake forever. - const platform = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: FLOOR_TOP - 26 }, colliders: [{ shape: new BoxShape(200, 20) }] })); + const platform = world.add( + new PhysicsBody({ type: 'static', position: { x: 0, y: FLOOR_TOP - 26 }, colliders: [{ shape: new BoxShape(200, 20) }] }), + ); world.contactModifier = (contact: ContactModifierContext): void => { if (contact.bodyA === platform || contact.bodyB === platform) { diff --git a/packages/exojs-physics/test/sleeping.test.ts b/packages/exojs-physics/test/sleeping.test.ts index 6735d1248..2dbd49780 100644 --- a/packages/exojs-physics/test/sleeping.test.ts +++ b/packages/exojs-physics/test/sleeping.test.ts @@ -93,6 +93,25 @@ describe('sleeping', () => { expect(box.linearVelocityX).toBeGreaterThan(0); }); + it('a velocity written to a sleeping body takes effect only after wake()', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + const body = world.add( + new PhysicsBody({ position: { x: 0, y: 0 }, fixedRotation: true, colliders: [{ shape: new BoxShape(24, 24) }] }), + ); + + advance(world, 1); + expect(body.isSleeping).toBe(true); + + body.linearVelocityX = 145; + advance(world, 0.25); + expect(body.isSleeping).toBe(true); + expect(body.x).toBe(0); + + body.wake(); + advance(world, 0.25); + expect(body.x).toBeGreaterThan(30); + }); + it('a settling stack falls asleep', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); addFloor(world, 300); diff --git a/packages/exojs-physics/test/world-remove.test.ts b/packages/exojs-physics/test/world-remove.test.ts index ec8b6b491..2f210d909 100644 --- a/packages/exojs-physics/test/world-remove.test.ts +++ b/packages/exojs-physics/test/world-remove.test.ts @@ -106,7 +106,9 @@ describe('PhysicsWorld.remove', () => { it('wakes bodies resting on it, and a re-added sleeper wakes and falls', () => { const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); - const platform = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 320 }, colliders: [{ shape: new BoxShape(1200, 40) }] })); + const platform = world.add( + new PhysicsBody({ type: 'static', position: { x: 0, y: 320 }, colliders: [{ shape: new BoxShape(1200, 40) }] }), + ); const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 282 }, colliders: [{ shape: new BoxShape(32, 32) }] })); advance(world, 2); diff --git a/packages/exojs-physics/test/world.test.ts b/packages/exojs-physics/test/world.test.ts index 8d01d85f0..01a103de8 100644 --- a/packages/exojs-physics/test/world.test.ts +++ b/packages/exojs-physics/test/world.test.ts @@ -1,4 +1,4 @@ -import { Container, Drawable, type SceneNode } from '@codexo/exojs'; +import { Container, Drawable, type SceneNode, Time } from '@codexo/exojs'; import { describe, expect, it } from 'vitest'; import { createAabb, expandAabb } from '../src/Aabb'; @@ -112,8 +112,12 @@ describe('PhysicsWorld lifecycle and mass model', () => { it('treats static and kinematic bodies as infinite mass', () => { const world = new PhysicsWorld(); - const staticBody = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), density: 5 }] })); - const kinematicBody = world.add(new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), density: 5 }] })); + const staticBody = world.add( + new PhysicsBody({ type: 'static', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), density: 5 }] }), + ); + const kinematicBody = world.add( + new PhysicsBody({ type: 'kinematic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(10, 10), density: 5 }] }), + ); expect(staticBody.invMass).toBe(0); expect(staticBody.invInertia).toBe(0); @@ -123,7 +127,9 @@ describe('PhysicsWorld lifecycle and mass model', () => { it('fixedRotation removes angular response', () => { const world = new PhysicsWorld(); - const body = world.add(new PhysicsBody({ type: 'dynamic', fixedRotation: true, colliders: [{ shape: new BoxShape(10, 10), density: 1 }] })); + const body = world.add( + new PhysicsBody({ type: 'dynamic', fixedRotation: true, colliders: [{ shape: new BoxShape(10, 10), density: 1 }] }), + ); expect(body.invMass).toBeGreaterThan(0); expect(body.invInertia).toBe(0); @@ -160,7 +166,11 @@ describe('PhysicsWorld lifecycle and mass model', () => { new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, - colliders: [{ shape: new BoxShape(10, 10) }, { shape: new BoxShape(10, 10), offset: { x: 12, y: 0 } }, { shape: new SegmentShape(0, 20, 20, 20) }], + colliders: [ + { shape: new BoxShape(10, 10) }, + { shape: new BoxShape(10, 10), offset: { x: 12, y: 0 } }, + { shape: new SegmentShape(0, 20, 20, 20) }, + ], }), ); const box0 = body.colliders[0]!; @@ -325,8 +335,12 @@ describe('PhysicsWorld joints and backend accessors', () => { // `bodyB` is added first (the lower island index) but passed as the joint's // *second* body - deliberately reversed from index order, so the island // union-find takes its `rootB < rootA` branch, not just `rootA < rootB`. - const dynB = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 20, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] })); - const dynA = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] })); + const dynB = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 20, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] }), + ); + const dynA = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] }), + ); const joint = world.addJoint(new DistanceJoint({ bodyA: dynA, bodyB: dynB, length: 20 })); expect(() => world.step(1 / 60)).not.toThrow(); @@ -343,8 +357,12 @@ describe('PhysicsWorld joints and backend accessors', () => { // same pair is unioned twice in one pass: once via the solid contact (which // merges them first), once via the joint. The second call finds both // already at the same union-find root - neither `_union` branch fires. - const dynB = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 8, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] })); - const dynA = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] })); + const dynB = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 8, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] }), + ); + const dynA = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(5), density: 1 }] }), + ); const joint = world.addJoint(new DistanceJoint({ bodyA: dynA, bodyB: dynB, length: 8 })); expect(() => world.step(1 / 60)).not.toThrow(); @@ -364,7 +382,9 @@ describe('PhysicsWorld joints and backend accessors', () => { // touching each other - two solid contacts share the same `a` (the floor), // so the sort comparator must fall through to comparing `b.id`. colliderAt(world, new BoxShape(400, 20), { x: 0, y: 100 }); - const boxLeft = world.add(new PhysicsBody({ type: 'dynamic', position: { x: -50, y: 100 - 10 - 5 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + const boxLeft = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: -50, y: 100 - 10 - 5 }, colliders: [{ shape: new BoxShape(16, 16) }] }), + ); world.add(new PhysicsBody({ type: 'dynamic', position: { x: 50, y: 100 - 10 - 5 }, colliders: [{ shape: new BoxShape(16, 16) }] })); for (let i = 0; i < 30; i++) { @@ -456,7 +476,9 @@ describe('Collider validation and accessors', () => { describe('PhysicsBody accessors, force/torque/impulse and mass edge cases', () => { it('exposes position as a fresh Vector and body-local centre-of-mass accessors', () => { const world = new PhysicsWorld(); - const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 3, y: 4 }, colliders: [{ shape: new BoxShape(10, 10), density: 1 }] })); + const body = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 3, y: 4 }, colliders: [{ shape: new BoxShape(10, 10), density: 1 }] }), + ); expect(body.position.x).toBe(3); expect(body.position.y).toBe(4); @@ -720,13 +742,17 @@ describe("PhysicsWorld.attach: defaults to the node's world position (P2f)", () describe('PhysicsWorld.fixedUpdate()', () => { it('advances exactly one fixed step per call, matching step() with a delta equal to fixedDelta', () => { const worldA = new PhysicsWorld({ gravity: { x: 0, y: 900 } }); - const bodyA = worldA.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] })); + const bodyA = worldA.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] }), + ); const worldB = new PhysicsWorld({ gravity: { x: 0, y: 900 } }); - const bodyB = worldB.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] })); + const bodyB = worldB.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] }), + ); worldA.step(1 / 60); - worldB.fixedUpdate({ seconds: 1 / 60 } as never); + worldB.fixedUpdate(Time.seconds(1 / 60)); expect(bodyB.y).toBeCloseTo(bodyA.y, 10); expect(bodyB.linearVelocityY).toBeCloseTo(bodyA.linearVelocityY, 10); @@ -734,14 +760,18 @@ describe('PhysicsWorld.fixedUpdate()', () => { it('is a no-op-safe repeatable call: N fixedUpdate() calls match N step() calls of the same size', () => { const worldA = new PhysicsWorld({ gravity: { x: 0, y: 900 } }); - const bodyA = worldA.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] })); + const bodyA = worldA.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] }), + ); const worldB = new PhysicsWorld({ gravity: { x: 0, y: 900 } }); - const bodyB = worldB.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] })); + const bodyB = worldB.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] }), + ); for (let i = 0; i < 5; i++) { worldA.step(1 / 60); - worldB.fixedUpdate({ seconds: 1 / 60 } as never); + worldB.fixedUpdate(Time.seconds(1 / 60)); } expect(bodyB.y).toBeCloseTo(bodyA.y, 8); @@ -753,14 +783,18 @@ describe('PhysicsWorld.fixedUpdate()', () => { world.onCollisionStart.add(() => events.push('start')); - const ground = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [new Collider({ shape: new BoxShape(200, 20) })] })); - const box = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] })); + const ground = world.add( + new PhysicsBody({ type: 'static', position: { x: 0, y: 100 }, colliders: [new Collider({ shape: new BoxShape(200, 20) })] }), + ); + const box = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [new Collider({ shape: new BoxShape(10, 10) })] }), + ); void ground; void box; for (let i = 0; i < 60; i++) { - world.fixedUpdate({ seconds: 1 / 60 } as never); + world.fixedUpdate(Time.seconds(1 / 60)); } expect(events).toContain('start');