• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 20450486171

23 Dec 2025 03:28AM UTC coverage: 88.744% (+0.1%) from 88.638%
20450486171

push

github

web-flow
fix: Realistic body sleeping (#3589)

* wip sleeping is more stable

* refactor

* maybe help with floaters

* maybe fix all the thigns

* okay

* fix motion system

* dont interpolate sleeping bodies

* better canonicalizeAngle

* working

* implement islands

* fix tests and refactoring

* tweak

* fix lint

* add system durations

* fix tests

* really fix tests

* really fix tests

* delete commented code

* remove unused param

* remove assert

* add changelog

* fix trail off

* really fix tests

5387 of 7332 branches covered (73.47%)

148 of 154 new or added lines in 10 files covered. (96.1%)

3 existing lines in 3 files now uncovered.

14846 of 16729 relevant lines covered (88.74%)

24534.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.74
/src/engine/Collision/CollisionSystem.ts
1
import type { ComponentCtor, Query, World } from '../EntityComponentSystem';
2
import { SystemPriority } from '../EntityComponentSystem';
3
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
4
import { System, SystemType } from '../EntityComponentSystem/System';
5
import { CollisionEndEvent, CollisionStartEvent, ContactEndEvent, ContactStartEvent } from '../Events';
6
import { SolverStrategy } from './SolverStrategy';
7
import { ArcadeSolver } from './Solver/ArcadeSolver';
8
import type { Collider } from './Colliders/Collider';
9
import type { CollisionContact } from './Detection/CollisionContact';
10
import { RealisticSolver } from './Solver/RealisticSolver';
11
import type { CollisionSolver } from './Solver/Solver';
12
import { ColliderComponent } from './ColliderComponent';
13
import { CompositeCollider } from './Colliders/CompositeCollider';
14
import type { Engine } from '../Engine';
15
import type { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext';
16
import type { Scene } from '../Scene';
17
import { Side } from '../Collision/Side';
18
import type { PhysicsWorld } from './PhysicsWorld';
19
import type { CollisionProcessor } from './Detection/CollisionProcessor';
20
import { SeparatingAxis } from './Colliders/SeparatingAxis';
21
import { MotionSystem } from './MotionSystem';
22
import { Pair } from './Detection/Pair';
23
import { BodyComponent } from './Index';
24
import { buildContactIslands } from './Island';
25
export class CollisionSystem extends System {
248✔
26
  static priority = SystemPriority.Higher;
27

28
  public systemType = SystemType.Update;
1,389✔
29
  public query: Query<ComponentCtor<TransformComponent> | ComponentCtor<ColliderComponent>>;
30
  public bodyQuery: Query<ComponentCtor<BodyComponent>>;
31

32
  private _engine: Engine;
33
  private _configDirty = false;
1,389✔
34
  private _realisticSolver: RealisticSolver;
35
  private _arcadeSolver: ArcadeSolver;
36
  private _lastFrameContacts = new Map<string, CollisionContact>();
1,389✔
37
  private _currentFrameContacts = new Map<string, CollisionContact>();
1,389✔
38
  private _motionSystem: MotionSystem;
39
  private _bodies: BodyComponent[] = [];
1,389✔
40
  private get _processor(): CollisionProcessor {
41
    return this._physics.collisionProcessor;
4,090✔
42
  }
43

44
  private _trackCollider: (c: Collider) => void;
45
  private _untrackCollider: (c: Collider) => void;
46

47
  constructor(
48
    world: World,
49
    private _physics: PhysicsWorld
1,389✔
50
  ) {
51
    super();
1,389✔
52
    this._arcadeSolver = new ArcadeSolver(_physics.config.arcade);
1,389✔
53
    this._realisticSolver = new RealisticSolver(_physics.config.realistic);
1,389✔
54
    this._physics.$configUpdate.subscribe(() => (this._configDirty = true));
1,389✔
55
    this._trackCollider = (c: Collider) => this._processor.track(c);
1,389✔
56
    this._untrackCollider = (c: Collider) => this._processor.untrack(c);
1,389✔
57
    this.query = world.query([TransformComponent, ColliderComponent]);
1,389✔
58
    this.query.entityAdded$.subscribe((e) => {
1,389✔
59
      const colliderComponent = e.get(ColliderComponent);
634✔
60
      colliderComponent.$colliderAdded.subscribe(this._trackCollider);
634✔
61
      colliderComponent.$colliderRemoved.subscribe(this._untrackCollider);
634✔
62
      const collider = colliderComponent.get();
634✔
63
      if (collider) {
634✔
64
        this._processor.track(collider);
300✔
65
      }
66
    });
67
    this.query.entityRemoved$.subscribe((e) => {
1,389✔
68
      const colliderComponent = e.get(ColliderComponent);
30✔
69
      const collider = colliderComponent.get();
30✔
70
      if (colliderComponent && collider) {
30✔
71
        this._processor.untrack(collider);
7✔
72
      }
73
    });
74
    this._motionSystem = world.get(MotionSystem) as MotionSystem;
1,389✔
75
    this.bodyQuery = world.query([BodyComponent]);
1,389✔
76

77
    this.bodyQuery.entityAdded$.subscribe((e) => {
1,389✔
78
      this._bodies.push(e.get(BodyComponent));
634✔
79
    });
80

81
    this.bodyQuery.entityRemoved$.subscribe((e) => {
1,389✔
82
      const body = e.get(BodyComponent);
30✔
83

84
      const indexOf = this._bodies.indexOf(body);
30✔
85
      if (indexOf > -1) {
30!
86
        this._bodies.splice(indexOf, 1);
30✔
87
      }
88
    });
89
  }
90

91
  initialize(world: World, scene: Scene) {
92
    this._engine = scene.engine;
758✔
93
  }
94

95
  update(elapsed: number): void {
96
    if (!this._physics.config.enabled) {
1,817!
97
      return;
×
98
    }
99

100
    // TODO do we need to do this every frame?
101
    // Collect up all the colliders and update them
102
    let colliders: Collider[] = [];
1,817✔
103
    for (let entityIndex = 0; entityIndex < this.query.entities.length; entityIndex++) {
1,817✔
104
      const entity = this.query.entities[entityIndex];
2,185✔
105
      const colliderComp = entity.get(ColliderComponent);
2,185✔
106
      const collider = colliderComp?.get();
2,185!
107
      if (colliderComp && colliderComp.owner?.isActive && collider) {
2,185!
108
        colliderComp.update();
1,247✔
109

110
        // Flatten composite colliders
111
        if (collider instanceof CompositeCollider) {
1,247✔
112
          const compositeColliders = collider.getColliders();
12✔
113
          if (!collider.compositeStrategy) {
12✔
114
            collider.compositeStrategy = this._physics.config.colliders.compositeStrategy;
10✔
115
          }
116
          colliders = colliders.concat(compositeColliders);
12✔
117
        } else {
118
          colliders.push(collider);
1,235✔
119
        }
120
      }
121
    }
122

123
    // Update the spatial partitioning data structures
124
    // TODO if collider invalid it will break the processor
125
    // TODO rename "update" to something more specific
126
    this._processor.update(colliders, elapsed);
1,817✔
127

128
    // Run broadphase on all colliders and locates potential collisions
129
    let pairs = this._processor.broadphase(colliders, elapsed);
1,817✔
130

131
    this._currentFrameContacts.clear();
1,817✔
132

133
    // Given possible pairs find actual contacts
134
    let contacts: CollisionContact[] = [];
1,817✔
135

136
    const solver: CollisionSolver = this.getSolver();
1,817✔
137

138
    // Solve, this resolves the position/velocity so entities aren't overlapping
139
    const substep = this._physics.config.substep;
1,817✔
140
    for (let step = 0; step < substep; step++) {
1,817✔
141
      if (step > 0) {
1,817!
142
        // first step is run by the MotionSystem when configured, so skip 0th
143
        // elapsed is used here because step size is calcluated in motion system
UNCOV
144
        this._motionSystem.update(elapsed);
×
145
      }
146
      // Re-use pairs from previous collision
147
      if (contacts.length) {
1,817!
148
        pairs = contacts.map((c) => new Pair(c.colliderA, c.colliderB));
×
149
      }
150

151
      if (pairs.length) {
1,817✔
152
        contacts = this._processor.narrowphase(pairs, this._engine?.debug?.stats?.currFrame);
112!
153

154
        if (this._physics.config.solver === SolverStrategy.Realistic) {
112✔
155
          // TODO we could possbily enable this for Arcade, will require some thinking
156
          const islands = buildContactIslands(this._physics.config.bodies, this._bodies, contacts);
9✔
157

158
          for (const island of islands) {
9✔
159
            island.updateSleepState(elapsed / substep);
14✔
160
          }
161
        }
162

163
        contacts = solver.solve(contacts, elapsed / substep);
112✔
164

165
        // Record contacts for start/end
166
        for (const contact of contacts) {
112✔
167
          if (contact.isCanceled()) {
142✔
168
            continue;
19✔
169
          }
170
          // Process composite ids, things with the same composite id are treated as the same collider for start/end
171
          const index = contact.id.indexOf('|');
123✔
172
          if (index > 0) {
123!
173
            const compositeId = contact.id.substring(index + 1);
×
174
            this._currentFrameContacts.set(compositeId, contact);
×
175
          } else {
176
            this._currentFrameContacts.set(contact.id, contact);
123✔
177
          }
178
        }
179
      }
180
    }
181

182
    // Emit contact start/end events
183
    this.runContactStartEnd();
1,817✔
184

185
    // reset the last frame cache
186
    this._lastFrameContacts.clear();
1,817✔
187

188
    // Keep track of collisions contacts that have started or ended
189
    this._lastFrameContacts = new Map(this._currentFrameContacts);
1,817✔
190

191
    // Process deferred collider removals
192
    for (const entity of this.query.entities) {
1,817✔
193
      const collider = entity.get(ColliderComponent);
2,185✔
194
      if (collider) {
2,185!
195
        collider.processColliderRemoval();
2,185✔
196
      }
197
    }
198
  }
199

200
  postupdate(): void {
201
    SeparatingAxis.SeparationPool.done();
1,815✔
202
  }
203

204
  getSolver(): CollisionSolver {
205
    if (this._configDirty) {
1,817✔
206
      this._configDirty = false;
524✔
207
      this._arcadeSolver = new ArcadeSolver(this._physics.config.arcade);
524✔
208
      this._realisticSolver = new RealisticSolver(this._physics.config.realistic);
524✔
209
    }
210
    return this._physics.config.solver === SolverStrategy.Realistic ? this._realisticSolver : this._arcadeSolver;
1,817✔
211
  }
212

213
  debug(ex: ExcaliburGraphicsContext) {
214
    this._processor.debug(ex, 0);
×
215
  }
216

217
  public runContactStartEnd() {
218
    // If composite colliders are 'together' collisions may have a duplicate id because we want to treat those as a singular start/end
219
    for (const [id, c] of this._currentFrameContacts) {
1,817✔
220
      // find all new contacts
221
      if (!this._lastFrameContacts.has(id)) {
123✔
222
        const colliderA = c.colliderA;
32✔
223
        const colliderB = c.colliderB;
32✔
224
        const side = Side.fromDirection(c.mtv);
32✔
225
        const opposite = Side.getOpposite(side);
32✔
226
        colliderA.events.emit('collisionstart', new CollisionStartEvent(colliderA, colliderB, side, c));
32✔
227
        colliderA.events.emit('contactstart', new ContactStartEvent(colliderA, colliderB, side, c) as any);
32✔
228
        colliderB.events.emit('collisionstart', new CollisionStartEvent(colliderB, colliderA, opposite, c));
32✔
229
        colliderB.events.emit('contactstart', new ContactStartEvent(colliderB, colliderA, opposite, c) as any);
32✔
230
      }
231
    }
232

233
    // find all contacts that have ceased
234
    for (const [id, c] of this._lastFrameContacts) {
1,817✔
235
      if (!this._currentFrameContacts.has(id)) {
101✔
236
        const colliderA = c.colliderA;
10✔
237
        const colliderB = c.colliderB;
10✔
238
        c.bodyA.isSleeping = false;
10✔
239
        c.bodyB.isSleeping = false;
10✔
240
        const side = Side.fromDirection(c.mtv);
10✔
241
        const opposite = Side.getOpposite(side);
10✔
242
        colliderA.events.emit('collisionend', new CollisionEndEvent(colliderA, colliderB, side, c));
10✔
243
        colliderA.events.emit('contactend', new ContactEndEvent(colliderA, colliderB, side, c) as any);
10✔
244
        colliderB.events.emit('collisionend', new CollisionEndEvent(colliderB, colliderA, opposite, c));
10✔
245
        colliderB.events.emit('contactend', new ContactEndEvent(colliderB, colliderA, opposite, c) as any);
10✔
246
      }
247
    }
248
  }
249
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc