import { Util } from '../..'; import { Pair } from '../detection/pair'; import type { Color } from '../../color'; import type { ExcaliburGraphicsContext } from '../../graphics/context/excalibur-graphics-context'; import type { LineSegment } from '../../math/line-segment'; import { Projection } from '../../math/projection'; import type { Ray } from '../../math/ray'; import { Vector } from '../../math/vector'; import { BoundingBox } from '../bounding-box'; import type { CollisionContact } from '../detection/collision-contact'; import { DynamicTree } from '../detection/dynamic-tree'; import { DynamicTreeCollisionProcessor } from '../detection/dynamic-tree-collision-processor'; import type { RayCastHit } from '../detection/ray-cast-hit'; import { Collider } from './collider'; import type { Transform } from '../../math/transform'; import { getDefaultPhysicsConfig } from '../physics-config'; export type CompositeStrategy = 'separate' | 'together'; export class CompositeCollider extends Collider { private _transform!: Transform; private _collisionProcessor = new DynamicTreeCollisionProcessor({ ...getDefaultPhysicsConfig() }); private _dynamicAABBTree = new DynamicTree({ boundsPadding: 5, velocityMultiplier: 2 }); private _colliders: Collider[] = []; private _compositeStrategy?: CompositeStrategy; /** * Treat composite collider's member colliders as either separate colliders for the purposes of onCollisionStart/onCollision * or as a single collider together. * * This property can be overridden on individual {@apilink CompositeColliders}. * * For composites without gaps or small groups of colliders, you probably want 'together' * * For composites with deliberate gaps, like a platforming level layout, you probably want 'separate' * * Default is 'together' if unset */ public set compositeStrategy(value: CompositeStrategy) { this._compositeStrategy = value; } public get compositeStrategy() { return this._compositeStrategy!; } constructor(colliders: Collider[]) { super(); for (const c of colliders) { this.addCollider(c); } } clearColliders() { this._colliders = []; } addCollider(collider: Collider) { let colliders: Collider[]; if (collider instanceof CompositeCollider) { colliders = collider.getColliders(); colliders.forEach((c) => c.offset.addEqual(collider.offset)); } else { colliders = [collider]; } // Flatten composites for (const c of colliders) { c.events.pipe(this.events); c.composite = this; this._colliders.push(c); this._collisionProcessor.track(c); this._dynamicAABBTree.trackCollider(c as any); } } removeCollider(collider: Collider) { collider.events.unpipe(this.events); collider.composite = null; Util.removeItemFromArray(collider, this._colliders); this._collisionProcessor.untrack(collider); this._dynamicAABBTree.untrackCollider(collider as any); } getColliders(): Collider[] { return this._colliders; } get worldPos(): Vector { return (this._transform?.pos ?? Vector.Zero).add(this.offset); } get center(): Vector { return (this._transform?.pos ?? Vector.Zero).add(this.offset); } get bounds(): BoundingBox { // TODO cache this const colliders = this.getColliders(); const results = colliders.reduce( (acc, collider) => acc.combine(collider.bounds), colliders[0]?.bounds ?? new BoundingBox().translate(this.worldPos) ); return results.translate(this.offset); } get localBounds(): BoundingBox { // TODO cache this const colliders = this.getColliders(); const results = colliders.reduce((acc, collider) => acc.combine(collider.localBounds), colliders[0]?.localBounds ?? new BoundingBox()); return results; } get axes(): Vector[] { // TODO cache this const colliders = this.getColliders(); let axes: Vector[] = []; for (const collider of colliders) { axes = axes.concat(collider.axes); } return axes; } getFurthestPoint(direction: Vector): Vector { const colliders = this.getColliders(); const furthestPoints: Vector[] = []; for (const collider of colliders) { furthestPoints.push(collider.getFurthestPoint(direction)); } // Pick best point from all colliders let bestPoint = furthestPoints[0]; let maxDistance = -Number.MAX_VALUE; for (const point of furthestPoints) { const distance = point.dot(direction); if (distance > maxDistance) { bestPoint = point; maxDistance = distance; } } return bestPoint; } getInertia(mass: number): number { const colliders = this.getColliders(); let totalInertia = 0; for (const collider of colliders) { totalInertia += collider.getInertia(mass); } return totalInertia; } collide(other: Collider): CollisionContact[] { let otherColliders = [other]; if (other instanceof CompositeCollider) { otherColliders = other.getColliders(); } const pairs: Pair[] = []; for (const c of otherColliders) { // FIXME this type seems wrong this._dynamicAABBTree.query(c as any, (potentialCollider) => { pairs.push(new Pair(c, potentialCollider as any)); return false; }); } let contacts: CollisionContact[] = []; for (const p of pairs) { contacts = contacts.concat(p.collide()); } return contacts; } getClosestLineBetween(other: Collider): LineSegment { const colliders = this.getColliders(); const lines: LineSegment[] = []; if (other instanceof CompositeCollider) { const otherColliders = other.getColliders(); for (const colliderA of colliders) { for (const colliderB of otherColliders) { const maybeLine = colliderA.getClosestLineBetween(colliderB); if (maybeLine) { lines.push(maybeLine); } } } } else { for (const collider of colliders) { const maybeLine = other.getClosestLineBetween(collider); if (maybeLine) { lines.push(maybeLine); } } } if (lines.length) { let minLength = lines[0].getLength(); let minLine = lines[0]; for (const line of lines) { const length = line.getLength(); if (length < minLength) { minLength = length; minLine = line; } } return minLine; } return null as any; } contains(point: Vector): boolean { const colliders = this.getColliders(); for (const collider of colliders) { if (collider.contains(point)) { return true; } } return false; } rayCast(ray: Ray, max?: number): RayCastHit | null { const colliders = this.getColliders(); const hits: RayCastHit[] = []; for (const collider of colliders) { const hit = collider.rayCast(ray, max); if (hit) { hits.push(hit); } } if (hits.length) { let minHit = hits[0]; let minDistance = minHit.point.dot(ray.dir); for (const hit of hits) { const distance = ray.dir.dot(hit.point); if (distance < minDistance) { minHit = hit; minDistance = distance; } } return minHit; } return null; } project(axis: Vector): Projection { const colliders = this.getColliders(); const projections: Projection[] = []; for (const collider of colliders) { const proj = collider.project(axis); if (proj) { projections.push(proj); } } // Merge all proj's on the same axis if (projections.length) { const newProjection = new Projection(projections[0].min, projections[0].max); for (const proj of projections) { newProjection.min = Math.min(proj.min, newProjection.min); newProjection.max = Math.max(proj.max, newProjection.max); } return newProjection; } return null as any; } update(transform: Transform): void { if (transform) { const colliders = this.getColliders(); for (const collider of colliders) { collider.owner = this.owner; collider.update(transform); } } } public debug(ex: ExcaliburGraphicsContext, color: Color, options?: { lineWidth: number; pointSize: number }) { const colliders = this.getColliders(); ex.save(); ex.translate(this.offset.x, this.offset.y); for (const collider of colliders) { collider.debug(ex, color, options); } ex.restore(); } clone(): Collider { const result = new CompositeCollider(this._colliders.map((c) => c.clone())); result.offset = this.offset.clone(); return result; } }