diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e8313b0..4c64aa5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- Fixed issue with `ex.ArcadeSolver` based collisions where colliders were catching on seams when sliding along a floor of multiple colliders. This was by sorting contacts by distance between bodies. + ![sorted-collisions](https://user-images.githubusercontent.com/612071/172401390-9e9c3490-3566-47bf-b258-6a7da86a3464.gif) + +- Fixed issue with `ex.ArcadeSolver` where corner contacts would zero out velocity even if the bodies were already moving away from the contact "divergent contacts". + ![cancel-velocity-fix](https://user-images.githubusercontent.com/612071/172500318-539f3a36-31ae-4efc-b6ab-c4524b297adb.gif) + - Fixed issue where `ex.Sound` wasn't being paused when the browser window lost focus ### Updates diff --git a/sandbox/tests/side-collision/index.html b/sandbox/tests/side-collision/index.html new file mode 100644 index 00000000..be7b8895 --- /dev/null +++ b/sandbox/tests/side-collision/index.html @@ -0,0 +1,13 @@ + + + + + + + Side Collision + + + + + + \ No newline at end of file diff --git a/sandbox/tests/side-collision/index.ts b/sandbox/tests/side-collision/index.ts new file mode 100644 index 00000000..d54847e1 --- /dev/null +++ b/sandbox/tests/side-collision/index.ts @@ -0,0 +1,89 @@ + +var game = new ex.Engine({ + width: 400, + height: 400, + displayMode: ex.DisplayMode.FitScreenAndFill +}); + +ex.Physics.acc = new ex.Vector(0, 800); + +class Player2 extends ex.Actor { + onGround = false; + constructor() { + super({ + x: 100, + y: 100, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + + + } + + onInitialize() { + // onPostCollision is an event, not a lifecycle meaning it can be subscribed to by other things + this.on("postcollision", (evt) => this.onPostCollision(evt)); + } + + onPostCollision(evt) { + if (evt.side === "Left" || evt.side === "Right") { + console.log(evt.side); + } + + if (evt.side === ex.Side.Bottom) { + this.onGround = true; + } + } + + // After main update, once per frame execute this code + onPreUpdate(engine) { + // Reset x velocity + // this.vel.x = 0; + + // Player input + if (engine.input.keyboard.isHeld(ex.Input.Keys.Left)) { + this.vel.x = -150; + } + + if (engine.input.keyboard.isHeld(ex.Input.Keys.Right)) { + this.vel.x = 150; + } + + if (engine.input.keyboard.isHeld(ex.Input.Keys.Up) && this.onGround) { + this.vel.y = -400; + this.onGround = false; + } + } +} + +// many small tiles +for (let x = 0; x < 500; x += 16) { + game.add( + new ex.Actor({ + x, + y: 300, + width: 16, + height: 16, + color: ex.Color.Green, + collisionType: ex.CollisionType.Fixed + }) + ); +} + +// single large floor +game.add( + new ex.Actor({ + x: 300, + y: 220, + width: 200, + height: 16, + color: ex.Color.Green, + collisionType: ex.CollisionType.Fixed + }) +); + +game.add(new Player2()); + +game.start(); diff --git a/sandbox/tests/side-collision2/index.html b/sandbox/tests/side-collision2/index.html new file mode 100644 index 00000000..be7b8895 --- /dev/null +++ b/sandbox/tests/side-collision2/index.html @@ -0,0 +1,13 @@ + + + + + + + Side Collision + + + + + + \ No newline at end of file diff --git a/sandbox/tests/side-collision2/index.ts b/sandbox/tests/side-collision2/index.ts new file mode 100644 index 00000000..76619740 --- /dev/null +++ b/sandbox/tests/side-collision2/index.ts @@ -0,0 +1,77 @@ + +var game = new ex.Engine({ + width: 400, + height: 400, + displayMode: ex.DisplayMode.FitScreenAndFill +}); + +ex.Physics.acc = new ex.Vector(0, 800); + +class Player3 extends ex.Actor { + onGround = false; + constructor() { + super({ + x: 100, + y: 100, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + } + + onInitialize() { + // onPostCollision is an event, not a lifecycle meaning it can be subscribed to by other things + this.on("postcollision", (evt) => this.onPostCollision(evt)); + } + + onPostCollision(evt) { + if (evt.side === ex.Side.Bottom) { + this.onGround = true; + } + } + + // After main update, once per frame execute this code + onPreUpdate(engine) { + // Reset x velocity + this.vel.x = 0; + + // Player input + if (engine.input.keyboard.isHeld(ex.Input.Keys.Left)) { + this.vel.x = -150; + } + + if (engine.input.keyboard.isHeld(ex.Input.Keys.Right)) { + this.vel.x = 150; + } + + if (engine.input.keyboard.isHeld(ex.Input.Keys.Up) && this.onGround) { + this.vel.y = -400; + this.onGround = false; + } + } +} + +const objects = [ + new ex.Actor({ + x: 200, + y: 300, + width: 400, + height: 20, + color: ex.Color.Green, + collisionType: ex.CollisionType.Fixed + }), + new ex.Actor({ + x: 300, + y: 260, + width: 200, + height: 60, + color: ex.Color.Green, + collisionType: ex.CollisionType.Fixed + }), + new Player3() +]; + +objects.forEach((i) => game.add(i)); + +game.start(); diff --git a/sandbox/tests/sound/sound.ts b/sandbox/tests/sound/sound.ts index b35bf46d..171cbd78 100644 --- a/sandbox/tests/sound/sound.ts +++ b/sandbox/tests/sound/sound.ts @@ -1,9 +1,11 @@ - +ex.Physics.enabled = false; var game = new ex.Engine({ width: 600, height: 400 }); + + var sound = new ex.Sound('./preview.ogg'); sound.playbackRate = 2.0; var loader = new ex.Loader([ @@ -37,7 +39,8 @@ var play = new ex.Actor({ y: 200, width: 40, height: 40, - color: ex.Color.Green + color: ex.Color.Green, + collisionType: ex.CollisionType.PreventCollision }); play.on('pointerdown', () => { sound.play(); diff --git a/src/engine/Collision/Solver/ArcadeSolver.ts b/src/engine/Collision/Solver/ArcadeSolver.ts index e6743286..d0fc9a6e 100644 --- a/src/engine/Collision/Solver/ArcadeSolver.ts +++ b/src/engine/Collision/Solver/ArcadeSolver.ts @@ -12,11 +12,48 @@ import { BodyComponent } from '../BodyComponent'; * This is usually the type of collisions used for 2D games that don't need a more realistic collision simulation. * */ -export class ArcadeSolver extends CollisionSolver { +export class ArcadeSolver implements CollisionSolver { + directionMap = new Map(); + distanceMap = new Map(); + + public solve(contacts: CollisionContact[]): CollisionContact[] { + // Events and init + this.preSolve(contacts); + + // Remove any canceled contacts + contacts = contacts.filter(c => !c.isCanceled()); + + // Sort contacts by distance to avoid artifacts with seams + // It's important to solve in a specific order + contacts.sort((a, b) => { + const aDist = this.distanceMap.get(a.id); + const bDist = this.distanceMap.get(b.id); + return aDist - bDist; + }); + + for (const contact of contacts) { + // Solve position first in arcade + this.solvePosition(contact); + + // Solve velocity first + this.solveVelocity(contact); + } + + // Events and any contact house-keeping the solver needs + this.postSolve(contacts); + + return contacts; + } + public preSolve(contacts: CollisionContact[]) { + for (const contact of contacts) { const side = Side.fromDirection(contact.mtv); const mtv = contact.mtv.negate(); + + const distance = contact.colliderA.worldPos.squareDistance(contact.colliderB.worldPos); + this.distanceMap.set(contact.id, distance); + // Publish collision events on both participants contact.colliderA.events.emit('precollision', new PreCollisionEvent(contact.colliderA, contact.colliderB, side, mtv)); contact.colliderB.events.emit( @@ -28,6 +65,9 @@ export class ArcadeSolver extends CollisionSolver { public postSolve(contacts: CollisionContact[]) { for (const contact of contacts) { + if (contact.isCanceled()) { + continue; + } const colliderA = contact.colliderA; const colliderB = contact.colliderB; const bodyA = colliderA.owner?.get(BodyComponent); @@ -37,6 +77,7 @@ export class ArcadeSolver extends CollisionSolver { continue; } } + const side = Side.fromDirection(contact.mtv); const mtv = contact.mtv.negate(); // Publish collision events on both participants @@ -48,69 +89,83 @@ export class ArcadeSolver extends CollisionSolver { } } - public solvePosition(contacts: CollisionContact[]) { - for (const contact of contacts) { - // if bounds no longer interesect skip to the next - // this removes jitter from overlapping/stacked solid tiles or a wall of solid tiles - if (!contact.colliderA.bounds.overlaps(contact.colliderB.bounds)) { - continue; + public solvePosition(contact: CollisionContact) { + // if bounds no longer intersect skip to the next + // this removes jitter from overlapping/stacked solid tiles or a wall of solid tiles + if (!contact.colliderA.bounds.overlaps(contact.colliderB.bounds)) { + // Cancel the contact to prevent and solving + contact.cancel(); + return; + } + let mtv = contact.mtv; + const colliderA = contact.colliderA; + const colliderB = contact.colliderB; + const bodyA = colliderA.owner?.get(BodyComponent); + const bodyB = colliderB.owner?.get(BodyComponent); + if (bodyA && bodyB) { + if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) { + return; } - let mtv = contact.mtv; - const colliderA = contact.colliderA; - const colliderB = contact.colliderB; - const bodyA = colliderA.owner?.get(BodyComponent); - const bodyB = colliderB.owner?.get(BodyComponent); - if (bodyA && bodyB) { - if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) { - continue; - } - if (bodyA.collisionType === CollisionType.Active && bodyB.collisionType === CollisionType.Active) { - // split overlaps if both are Active - mtv = mtv.scale(0.5); - } + if (bodyA.collisionType === CollisionType.Active && bodyB.collisionType === CollisionType.Active) { + // split overlaps if both are Active + mtv = mtv.scale(0.5); + } - // Resolve overlaps - if (bodyA.collisionType === CollisionType.Active) { - bodyA.pos.x -= mtv.x; - bodyA.pos.y -= mtv.y; - colliderA.update(bodyA.transform); - } + // Resolve overlaps + if (bodyA.collisionType === CollisionType.Active) { + bodyA.pos.x -= mtv.x; + bodyA.pos.y -= mtv.y; + colliderA.update(bodyA.transform); + } - if (bodyB.collisionType === CollisionType.Active) { - bodyB.pos.x += mtv.x; - bodyB.pos.y += mtv.y; - colliderB.update(bodyB.transform); - } + if (bodyB.collisionType === CollisionType.Active) { + bodyB.pos.x += mtv.x; + bodyB.pos.y += mtv.y; + colliderB.update(bodyB.transform); } } } - public solveVelocity(contacts: CollisionContact[]) { - for (const contact of contacts) { - const colliderA = contact.colliderA; - const colliderB = contact.colliderB; - const bodyA = colliderA.owner?.get(BodyComponent); - const bodyB = colliderB.owner?.get(BodyComponent); + public solveVelocity(contact: CollisionContact) { + if (contact.isCanceled()) { + return; + } - if (bodyA && bodyB) { + const colliderA = contact.colliderA; + const colliderB = contact.colliderB; + const bodyA = colliderA.owner?.get(BodyComponent); + const bodyB = colliderB.owner?.get(BodyComponent); - if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) { - continue; - } + if (bodyA && bodyB) { - const normal = contact.normal; - const opposite = normal.negate(); + if (bodyA.collisionType === CollisionType.Passive || bodyB.collisionType === CollisionType.Passive) { + return; + } - // Cancel out velocity opposite direction of collision normal - if (bodyA.collisionType === CollisionType.Active) { + const normal = contact.normal; + const opposite = normal.negate(); + + if (bodyA.collisionType === CollisionType.Active) { + // only adjust velocity if the contact normal is opposite to the current velocity + // this avoids catching edges on a platform when sliding off + if (bodyA.vel.normalize().dot(opposite) < 0) { + // Cancel out velocity opposite direction of collision normal const velAdj = normal.scale(normal.dot(bodyA.vel.negate())); bodyA.vel = bodyA.vel.add(velAdj); + } else { + contact.cancel(); } + } - if (bodyB.collisionType === CollisionType.Active) { + if (bodyB.collisionType === CollisionType.Active) { + // only adjust velocity if the contact normal is opposite to the current velocity + // this avoids catching edges on a platform + if (bodyB.vel.normalize().dot(normal) < 0) { const velAdj = opposite.scale(opposite.dot(bodyB.vel.negate())); bodyB.vel = bodyB.vel.add(velAdj); + } else { + contact.cancel(); } } } diff --git a/src/engine/Collision/Solver/RealisticSolver.ts b/src/engine/Collision/Solver/RealisticSolver.ts index 5ca2101a..37405a7a 100644 --- a/src/engine/Collision/Solver/RealisticSolver.ts +++ b/src/engine/Collision/Solver/RealisticSolver.ts @@ -9,7 +9,7 @@ import { CollisionSolver } from './Solver'; import { BodyComponent } from '../BodyComponent'; import { CollisionJumpTable } from '../Colliders/CollisionJumpTable'; -export class RealisticSolver extends CollisionSolver { +export class RealisticSolver implements CollisionSolver { lastFrameContacts: Map = new Map(); // map contact id to contact points @@ -19,6 +19,25 @@ export class RealisticSolver extends CollisionSolver { return this.idToContactConstraint.get(id) ?? []; } + public solve(contacts: CollisionContact[]): CollisionContact[] { + // Events and init + this.preSolve(contacts); + + // Remove any canceled contacts + contacts = contacts.filter(c => !c.isCanceled()); + + // Solve velocity first + this.solveVelocity(contacts); + + // Solve position last because non-overlap is the most important + this.solvePosition(contacts); + + // Events and any contact house-keeping the solver needs + this.postSolve(contacts); + + return contacts; + } + preSolve(contacts: CollisionContact[]) { for (const contact of contacts) { // Publish collision events on both participants diff --git a/src/engine/Collision/Solver/Solver.ts b/src/engine/Collision/Solver/Solver.ts index 94c72921..817dd9ce 100644 --- a/src/engine/Collision/Solver/Solver.ts +++ b/src/engine/Collision/Solver/Solver.ts @@ -11,35 +11,35 @@ import { CollisionContact } from '../Detection/CollisionContact'; * 4. postSolve * @inheritdoc */ -export abstract class CollisionSolver { - /** - * Pre-solve is fired on contacts before any resolution is started. It is used for any setup work before collision resolution - * can proceed. Optionally contacts can be "opted out" of any collision processing by calling `contact.cancel()` - * @param contacts - * @inheritdoc - */ - abstract preSolve(contacts: CollisionContact[]): void; - - /** - * Post-solve is fired after all resolution is complete - * @param contacts - * @inheritdoc - */ - abstract postSolve(contacts: CollisionContact[]): void; - - /** - * Solve velocity adjusts the velocity of colliders so that they are not overlapping or will not be overlapping from velocity - * @param contacts - * @inheritdoc - */ - abstract solveVelocity(contacts: CollisionContact[]): void; - - /** - * Solve position adjust the position of colliders so that they are not overlapping - * @param contacts - * @inheritdoc - */ - abstract solvePosition(contacts: CollisionContact[]): void; +export interface CollisionSolver { + // /** + // * Pre-solve is fired on contacts before any resolution is started. It is used for any setup work before collision resolution + // * can proceed. Optionally contacts can be "opted out" of any collision processing by calling `contact.cancel()` + // * @param contacts + // * @inheritdoc + // */ + // abstract preSolve(contacts: CollisionContact[]): void; + + // /** + // * Post-solve is fired after all resolution is complete + // * @param contacts + // * @inheritdoc + // */ + // abstract postSolve(contacts: CollisionContact[]): void; + + // /** + // * Solve velocity adjusts the velocity of colliders so that they are not overlapping or will not be overlapping from velocity + // * @param contacts + // * @inheritdoc + // */ + // abstract solveVelocity(contacts: CollisionContact[]): void; + + // /** + // * Solve position adjust the position of colliders so that they are not overlapping + // * @param contacts + // * @inheritdoc + // */ + // abstract solvePosition(contacts: CollisionContact[]): void; /** * Solves overlapping contact in @@ -51,22 +51,5 @@ export abstract class CollisionSolver { * 4. postSolve * @param contacts */ - public solve(contacts: CollisionContact[]): CollisionContact[] { - // Events and init - this.preSolve(contacts); - - // Remove any canceled contacts - contacts = contacts.filter(c => !c.isCanceled()); - - // Solve velocity first - this.solveVelocity(contacts); - - // Solve position last because non-overlap is the most important - this.solvePosition(contacts); - - // Events and any contact house-keeping the solver needs - this.postSolve(contacts); - - return contacts; - } + solve(contacts: CollisionContact[]): CollisionContact[] } \ No newline at end of file diff --git a/src/spec/ArcadeSolverSpec.ts b/src/spec/ArcadeSolverSpec.ts index 6112eeaf..887aeb1a 100644 --- a/src/spec/ArcadeSolverSpec.ts +++ b/src/spec/ArcadeSolverSpec.ts @@ -1,5 +1,6 @@ import * as ex from '@excalibur'; import { ExcaliburMatchers } from 'excalibur-jasmine'; +import { TestUtils } from './util/TestUtils'; describe('An ArcadeSolver', () => { beforeAll(() => { @@ -25,7 +26,9 @@ describe('An ArcadeSolver', () => { const sut = new ex.ArcadeSolver(); - sut.solvePosition(contacts); + for (const contact of contacts) { + sut.solvePosition(contact); + } // Each contact has the same mtv expect(contacts[0].mtv).toBeVector(ex.vec(0, -1)); @@ -35,4 +38,130 @@ describe('An ArcadeSolver', () => { expect(player.pos.y).toBe(100); expect(player.pos.x).toBe(50); }); + + it('should not catch on a seam (left/right)', async () => { + ex.Physics.acc = new ex.Vector(0, 800); + const game = TestUtils.engine({ + width: 1000, + height: 1000 + }); + const clock = game.clock as ex.TestClock; + await TestUtils.runToReady(game); + // many small tiles + for (let x = 0; x < 500; x += 16) { + game.add( + new ex.Actor({ + name: 'floor-tile', + x, + y: 300, + width: 16, + height: 16, + color: ex.Color.Green, + collisionType: ex.CollisionType.Fixed + }) + ); + } + + const player = new ex.Actor({ + x: 100, + y: 100, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + + // place player on tiles + player.pos.y = 270; + game.add(player); + + // run simulation and ensure now left/right contacts are generated + player.on('postcollision', evt => { + expect(evt.side).not.toBe(ex.Side.Left); + expect(evt.side).not.toBe(ex.Side.Right); + expect(evt.side).toBe(ex.Side.Bottom); + }); + + for (let i = 0; i < 200; i++) { + clock.step(16); + } + + // give player right velocity + ex.Physics.acc = ex.Vector.Zero; + }); + + it('should cancel collision contacts where there is no more overlap', () => { + const arcadeSolver = new ex.ArcadeSolver(); + + const player = new ex.Actor({ + x: 0, + y: 0, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + player.vel = ex.vec(0, 10); // moving away from the contact + + const block = new ex.Actor({ + x: 0, + y: 39, + width: 40, + height: 40, + collisionType: ex.CollisionType.Fixed, + color: ex.Color.Green + }); + + // 1 pixel overlap + const contact = new ex.CollisionContact( + player.collider.get(), block.collider.get(), ex.Vector.Down, ex.Vector.Down, ex.Vector.Down.perpendicular(), [], [], null); + arcadeSolver.solvePosition(contact); + expect(player.pos).toBeVector(ex.vec(0, -1)); + + // No more overlap + const contact2 = new ex.CollisionContact( + player.collider.get(), block.collider.get(), ex.Vector.Down, ex.Vector.Down, ex.Vector.Down.perpendicular(), [], [], null); + arcadeSolver.solvePosition(contact2); + expect(contact2.isCanceled()).toBeTrue(); + }); + + it('should cancel collisions where the bodies are moving away from the contact', () => { + + const arcadeSolver = new ex.ArcadeSolver(); + + const player = new ex.Actor({ + x: 0, + y: 0, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + player.vel = ex.vec(0, -10); // moving away from the contact + + const block = new ex.Actor({ + x: 39, + y: 39, + width: 40, + height: 40, + collisionType: ex.CollisionType.Fixed, + color: ex.Color.Green + }); + + // Player moving away from contact + const contact = new ex.CollisionContact( + player.collider.get(), block.collider.get(), ex.Vector.Down, ex.Vector.Down, ex.Vector.Up.perpendicular(), [], [], null); + arcadeSolver.solveVelocity(contact); + expect(contact.isCanceled()).toBeTrue(); + expect(player.vel).toBeVector(ex.vec(0, -10)); + + // Player moving towards contact + player.vel = player.vel.negate(); + const contact2 = new ex.CollisionContact( + player.collider.get(), block.collider.get(), ex.Vector.Down, ex.Vector.Down, ex.Vector.Down.perpendicular(), [], [], null); + + arcadeSolver.solveVelocity(contact2); + expect(contact2.isCanceled()).toBeFalse(); + expect(player.vel).toBeVector(ex.Vector.Zero); + }); }); \ No newline at end of file diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts index 707e1d9b..9b31c16f 100644 --- a/src/spec/CollisionSpec.ts +++ b/src/spec/CollisionSpec.ts @@ -270,17 +270,17 @@ describe('A Collision', () => { it('should cancel out velocity when objects collide', () => { ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade; - - const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()}); + engine.currentScene.clear(); + const activeBlock = new ex.Actor({name: 'active-block', x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()}); activeBlock.body.collisionType = ex.CollisionType.Active; activeBlock.vel.x = 100; engine.add(activeBlock); - const fixedBlock = new ex.Actor({x: 400, y: 200, width: 50, height: 50, color: ex.Color.DarkGray.clone()}); + const fixedBlock = new ex.Actor({name: 'fixed-block', x: 400, y: 200, width: 50, height: 50, color: ex.Color.DarkGray.clone()}); fixedBlock.body.collisionType = ex.CollisionType.Fixed; engine.add(fixedBlock); - clock.run(15, 100); + clock.run(25, 100); expect(activeBlock.vel.x).toBe(0); });