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

excaliburjs / Excalibur / 13075183322

31 Jan 2025 02:55PM UTC coverage: 90.024% (+0.01%) from 90.01%
13075183322

Pull #3351

github

web-flow
Merge 0cbbc39e6 into aaafeacbf
Pull Request #3351: fix: composite multi contact

6295 of 8096 branches covered (77.75%)

12 of 12 new or added lines in 3 files covered. (100.0%)

1 existing line in 1 file now uncovered.

13798 of 15327 relevant lines covered (90.02%)

25572.92 hits per line

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

96.12
/src/engine/Collision/Solver/ArcadeSolver.ts
1
import { PostCollisionEvent, PreCollisionEvent } from '../../Events';
2
import { CollisionContact } from '../Detection/CollisionContact';
3
import { CollisionType } from '../CollisionType';
4
import { Side } from '../Side';
5
import { CollisionSolver } from './Solver';
6
import { BodyComponent } from '../BodyComponent';
7
import { ContactBias, ContactSolveBias, HorizontalFirst, None, VerticalFirst } from './ContactBias';
8
import { PhysicsConfig } from '../PhysicsConfig';
9
import { DeepRequired } from '../../Util/Required';
10

11
/**
12
 * ArcadeSolver is the default in Excalibur. It solves collisions so that there is no overlap between contacts,
13
 * and negates velocity along the collision normal.
14
 *
15
 * This is usually the type of collisions used for 2D games that don't need a more realistic collision simulation.
16
 *
17
 */
18
export class ArcadeSolver implements CollisionSolver {
19
  directionMap = new Map<string, 'horizontal' | 'vertical'>();
1,867✔
20
  distanceMap = new Map<string, number>();
1,867✔
21

22
  constructor(public config: DeepRequired<Pick<PhysicsConfig, 'arcade'>['arcade']>) {}
1,867✔
23

24
  public solve(contacts: CollisionContact[]): CollisionContact[] {
25
    // Events and init
26
    this.preSolve(contacts);
105✔
27

28
    // Remove any canceled contacts
29
    contacts = contacts.filter((c) => !c.isCanceled());
136✔
30

31
    // Locate collision bias order
32
    let bias: ContactBias;
33
    switch (this.config.contactSolveBias) {
105✔
34
      case ContactSolveBias.HorizontalFirst: {
105!
35
        bias = HorizontalFirst;
×
36
        break;
×
37
      }
38
      case ContactSolveBias.VerticalFirst: {
39
        bias = VerticalFirst;
34✔
40
        break;
34✔
41
      }
42
      default: {
43
        bias = None;
71✔
44
      }
45
    }
46

47
    // Sort by bias (None, VerticalFirst, HorizontalFirst) to avoid artifacts with seams
48
    // Sort contacts by distance to avoid artifacts with seams
49
    // It's important to solve in a specific order
50
    contacts.sort((a, b) => {
105✔
51
      const aDir = this.directionMap.get(a.id);
29✔
52
      const bDir = this.directionMap.get(b.id);
29✔
53
      const aDist = this.distanceMap.get(a.id);
29✔
54
      const bDist = this.distanceMap.get(b.id);
29✔
55
      return bias[aDir] - bias[bDir] || aDist - bDist;
29✔
56
    });
57

58
    for (const contact of contacts) {
105✔
59
      // Solve position first in arcade
60
      this.solvePosition(contact);
134✔
61

62
      // Solve velocity second in arcade
63
      this.solveVelocity(contact);
134✔
64
    }
65

66
    // Events and any contact house-keeping the solver needs
67
    this.postSolve(contacts);
105✔
68

69
    return contacts;
105✔
70
  }
71

72
  private _compositeContactsIds = new Set<string>();
1,867✔
73
  public preSolve(contacts: CollisionContact[]) {
74
    // TODO keep track of composite contacts and remove dupes in the same direction
75
    const epsilon = 0.0001;
106✔
76
    for (let i = 0; i < contacts.length; i++) {
106✔
77
      const contact = contacts[i];
137✔
78

79
      // Cancel dup composite together stratey contacts
80
      const index = contact.id.indexOf('|');
137✔
81
      if (index > 0) {
137✔
82
        const compositeId = contact.id.substring(index + 1);
2✔
83
        if (this._compositeContactsIds.has(compositeId)) {
2✔
84
          contact.cancel();
1✔
85
          continue;
1✔
86
        }
87
        this._compositeContactsIds.add(compositeId);
1✔
88
      }
89

90
      // Cancel near 0 mtv collisions
91
      if (Math.abs(contact.mtv.x) < epsilon && Math.abs(contact.mtv.y) < epsilon) {
136✔
92
        contact.cancel();
1✔
93
        continue;
1✔
94
      }
95

96
      // Record distance/direction for sorting
97
      const side = Side.fromDirection(contact.mtv);
135✔
98
      const mtv = contact.mtv.negate();
135✔
99

100
      const distance = Math.abs(contact.info.separation);
135✔
101
      this.distanceMap.set(contact.id, distance);
135✔
102

103
      this.directionMap.set(contact.id, side === Side.Left || side === Side.Right ? 'horizontal' : 'vertical');
135✔
104

105
      // Publish collision events on both participants
106
      contact.colliderA.events.emit('precollision', new PreCollisionEvent(contact.colliderA, contact.colliderB, side, mtv, contact));
135✔
107
      contact.colliderB.events.emit(
135✔
108
        'precollision',
109
        new PreCollisionEvent(contact.colliderB, contact.colliderA, Side.getOpposite(side), mtv.negate(), contact)
110
      );
111
    }
112
    this._compositeContactsIds.clear();
106✔
113
  }
114

115
  public postSolve(contacts: CollisionContact[]) {
116
    for (let i = 0; i < contacts.length; i++) {
105✔
117
      const contact = contacts[i];
134✔
118
      if (contact.isCanceled()) {
134✔
119
        continue;
20✔
120
      }
121
      const colliderA = contact.colliderA;
114✔
122
      const colliderB = contact.colliderB;
114✔
123
      const bodyA = colliderA.owner?.get(BodyComponent);
114!
124
      const bodyB = colliderB.owner?.get(BodyComponent);
114!
125
      if (bodyA && bodyB) {
114!
126
        if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) {
114✔
127
          continue;
17✔
128
        }
129
      }
130

131
      const side = Side.fromDirection(contact.mtv);
97✔
132
      const mtv = contact.mtv.negate();
97✔
133
      // Publish collision events on both participants
134
      contact.colliderA.events.emit('postcollision', new PostCollisionEvent(contact.colliderA, contact.colliderB, side, mtv, contact));
97✔
135
      contact.colliderB.events.emit(
97✔
136
        'postcollision',
137
        new PostCollisionEvent(contact.colliderB, contact.colliderA, Side.getOpposite(side), mtv.negate(), contact)
138
      );
139
    }
140
  }
141

142
  public solvePosition(contact: CollisionContact) {
143
    const epsilon = 0.0001;
140✔
144
    // if bounds no longer intersect skip to the next
145
    // this removes jitter from overlapping/stacked solid tiles or a wall of solid tiles
146
    if (!contact.colliderA.bounds.overlaps(contact.colliderB.bounds, epsilon)) {
140✔
147
      // Cancel the contact to prevent and solving
148
      contact.cancel();
24✔
149
      return;
24✔
150
    }
151

152
    if (Math.abs(contact.mtv.x) < epsilon && Math.abs(contact.mtv.y) < epsilon) {
116!
153
      // Cancel near 0 mtv collisions
154
      contact.cancel();
×
UNCOV
155
      return;
×
156
    }
157

158
    let mtv = contact.mtv;
116✔
159
    const colliderA = contact.colliderA;
116✔
160
    const colliderB = contact.colliderB;
116✔
161
    const bodyA = colliderA.owner?.get(BodyComponent);
116!
162
    const bodyB = colliderB.owner?.get(BodyComponent);
116!
163
    if (bodyA && bodyB) {
116!
164
      if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) {
116✔
165
        return;
17✔
166
      }
167

168
      if (bodyA.collisionType === CollisionType.Active && bodyB.collisionType === CollisionType.Active) {
99✔
169
        // split overlaps if both are Active
170
        mtv = mtv.scale(0.5);
6✔
171
      }
172

173
      // Resolve overlaps
174
      if (bodyA.collisionType === CollisionType.Active) {
99✔
175
        bodyA.globalPos.x -= mtv.x;
58✔
176
        bodyA.globalPos.y -= mtv.y;
58✔
177
        colliderA.update(bodyA.transform.get());
58✔
178
      }
179

180
      if (bodyB.collisionType === CollisionType.Active) {
99✔
181
        bodyB.globalPos.x += mtv.x;
47✔
182
        bodyB.globalPos.y += mtv.y;
47✔
183
        colliderB.update(bodyB.transform.get());
47✔
184
      }
185
    }
186
  }
187

188
  public solveVelocity(contact: CollisionContact) {
189
    if (contact.isCanceled()) {
136✔
190
      return;
20✔
191
    }
192

193
    const colliderA = contact.colliderA;
116✔
194
    const colliderB = contact.colliderB;
116✔
195
    const bodyA = colliderA.owner?.get(BodyComponent);
116!
196
    const bodyB = colliderB.owner?.get(BodyComponent);
116!
197

198
    if (bodyA && bodyB) {
116!
199
      if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) {
116✔
200
        return;
17✔
201
      }
202

203
      const normal = contact.normal;
99✔
204
      const opposite = normal.negate();
99✔
205

206
      if (bodyA.collisionType === CollisionType.Active) {
99✔
207
        // only adjust velocity if the contact normal is opposite to the current velocity
208
        // this avoids catching edges on a platform when sliding off
209
        if (bodyA.vel.normalize().dot(opposite) < 0) {
58✔
210
          // Cancel out velocity opposite direction of collision normal
211
          const velAdj = normal.scale(normal.dot(bodyA.vel.negate()));
51✔
212
          bodyA.vel = bodyA.vel.add(velAdj);
51✔
213
        }
214
      }
215

216
      if (bodyB.collisionType === CollisionType.Active) {
99✔
217
        // only adjust velocity if the contact normal is opposite to the current velocity
218
        // this avoids catching edges on a platform
219
        if (bodyB.vel.normalize().dot(normal) < 0) {
47✔
220
          const velAdj = opposite.scale(opposite.dot(bodyB.vel.negate()));
41✔
221
          bodyB.vel = bodyB.vel.add(velAdj);
41✔
222
        }
223
      }
224
    }
225
  }
226
}
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