diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f2c15c31..b5fd6ccb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Breaking Changes
+- `ex.Physics` static is marked as deprecated, configuring these setting will move to the `ex.Engine({...})` constructor
+ ```typescript
+ const engine = new ex.Engine({
+ ...
+ physics: {
+ solver: ex.SolverStrategy.Realistic,
+ gravity: ex.vec(0, 20),
+ arcade: {
+ contactSolveBias: ex.ContactSolveBias.VerticalFirst
+ },
+ }
+ })
+ ```
- Changed the `Font` default base align to `Top` this is more in line with user expectations. This does change the default rendering to the top left corner of the font instead of the bottom left.
- Remove confusing Graphics Layering from `ex.GraphicsComponent`, recommend we use the `ex.GraphicsGroup` to manage this behavior
* Update `ex.GraphicsGroup` to be consistent and use `offset` instead of `pos` for graphics relative positioning
@@ -27,6 +40,21 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Added Arcade Collision Solver bias to help mitigate seams in geometry that can cause problems for certain games.
+ - `ex.ContactSolveBias.None` No bias, current default behavior collisions are solved in the default distance order
+ - `ex.ContactSolveBias.VerticalFirst` Vertical collisions are solved first (useful for platformers with up/down gravity)
+ - `ex.ContactSolveBias.HorizontalFirst` Horizontal collisions are solved first (useful for games with left/right predominant forces)
+ ```typescript
+ const engine = new ex.Engine({
+ ...
+ physics: {
+ solver: ex.SolverStrategy.Realistic,
+ arcade: {
+ contactSolveBias: ex.ContactSolveBias.VerticalFirst
+ },
+ }
+ })
+ ```
- Added Graphics `opacity` on the Actor constructor `new ex.Actor({opacity: .5})`
- Added Graphics pixel `offset` on the Actor constructor `new ex.Actor({offset: ex.vec(-15, -15)})`
- Added new `new ex.Engine({uvPadding: .25})` option to allow users using texture atlases in their sprite sheets to configure this to avoid texture bleed. This can happen if you're sampling from images meant for pixel art
diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts
index b3085aee4..e2ee4a5a0 100644
--- a/sandbox/src/game.ts
+++ b/sandbox/src/game.ts
@@ -38,7 +38,10 @@ var logger = ex.Logger.getInstance();
logger.defaultLevel = ex.LogLevel.Debug;
var fullscreenButton = document.getElementById('fullscreen') as HTMLButtonElement;
-
+// setup physics defaults
+// ex.Physics.useArcadePhysics();
+// ex.Physics.checkForFastBodies = true;
+// ex.Physics.acc = new ex.Vector(0, 10); // global accel
// Create an the game container
var game = new ex.Engine({
width: 800 / 2,
@@ -53,6 +56,17 @@ var game = new ex.Engine({
fixedUpdateFps: 30,
maxFps: 60,
antialiasing: false,
+ uvPadding: 0,
+ physics: {
+ solver: ex.SolverStrategy.Realistic,
+ gravity: ex.vec(0, 20),
+ arcade: {
+ contactSolveBias: ex.ContactSolveBias.VerticalFirst
+ },
+ continuous: {
+ checkForFastBodies: true
+ }
+ },
configurePerformanceCanvas2DFallback: {
allow: true,
showPlayerMessage: true,
@@ -142,10 +156,7 @@ boot.addResource(jump);
// Set background color
game.backgroundColor = new ex.Color(114, 213, 224);
-// setup physics defaults
-ex.Physics.useArcadePhysics();
-ex.Physics.checkForFastBodies = true;
-ex.Physics.acc = new ex.Vector(0, 10); // global accel
+
// Add some UI
//var heart = new ex.ScreenElement(0, 0, 20, 20);
diff --git a/sandbox/tests/arcadeseam/index.html b/sandbox/tests/arcadeseam/index.html
new file mode 100644
index 000000000..64f645f4e
--- /dev/null
+++ b/sandbox/tests/arcadeseam/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Arcade Collision Solver - Seam
+
+
+ Compare with physics.arcade.contactSolveBias: None & physics.arcade.contactSolveBias: VerticalFirst
+
+
+
+
\ No newline at end of file
diff --git a/sandbox/tests/arcadeseam/index.ts b/sandbox/tests/arcadeseam/index.ts
new file mode 100644
index 000000000..11ae19bd0
--- /dev/null
+++ b/sandbox/tests/arcadeseam/index.ts
@@ -0,0 +1,67 @@
+///
+
+var game = new ex.Engine({
+ width: 1000,
+ height: 1000,
+ fixedUpdateFps: 60,
+ physics: {
+ gravity: ex.vec(0, 5000),
+ solver: ex.SolverStrategy.Arcade,
+ arcade: {
+ contactSolveBias: ex.ContactSolveBias.VerticalFirst
+ }
+ }
+});
+
+game.toggleDebug();
+
+// big tiles so distance heuristic doesn't work
+var lastWidth = 200;
+var lastPos = ex.vec(0, 0);
+for (let x = 0; x < 10; x++) {
+ const width = (x % 2 === 1 ? 16 : 200);
+ game.add(
+ new ex.Actor({
+ name: 'floor-tile',
+ x: lastPos.x,
+ y: 300,
+ width: width,
+ height: x % 2 ? 16 : 900,
+ anchor: ex.Vector.Zero,
+ color: ex.Color.Red,
+ collisionType: ex.CollisionType.Fixed
+ })
+ );
+ lastPos.x += width;
+}
+
+var player = new ex.Actor({
+ pos: ex.vec(100, 270),
+ width: 16,
+ height: 16,
+ color: ex.Color.Blue,
+ collisionType: ex.CollisionType.Active
+});
+
+player.onPostUpdate = () => {
+ const speed = 164;
+ if (game.input.keyboard.isHeld(ex.Keys.Right)) {
+ player.vel.x = speed;
+ }
+ if (game.input.keyboard.isHeld(ex.Keys.Left)) {
+ player.vel.x = -speed;
+ }
+ if (game.input.keyboard.isHeld(ex.Keys.Up)) {
+ player.vel.y = -speed;
+ }
+ if (game.input.keyboard.isHeld(ex.Keys.Down)) {
+ player.vel.y = speed;
+ }
+}
+game.add(player);
+
+
+game.currentScene.camera.strategy.elasticToActor(player, .8, .9);
+game.currentScene.camera.zoom = 2;
+
+game.start();
\ No newline at end of file
diff --git a/sandbox/tests/collision/passive.ts b/sandbox/tests/collision/passive.ts
index ab8b10fd2..bf6932e9d 100644
--- a/sandbox/tests/collision/passive.ts
+++ b/sandbox/tests/collision/passive.ts
@@ -4,7 +4,7 @@ var game = new ex.Engine({
height: 400
});
-ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade;
+ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Arcade;
var activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
diff --git a/sandbox/tests/physics/physics.ts b/sandbox/tests/physics/physics.ts
index fd5ad44da..930740513 100644
--- a/sandbox/tests/physics/physics.ts
+++ b/sandbox/tests/physics/physics.ts
@@ -15,7 +15,7 @@ game.debug.collider.showBounds = true;
game.debug.motion.showAll = true;
game.debug.body.showMotion = true;
-ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
ex.Physics.bodiesCanSleepByDefault = true;
ex.Physics.gravity = ex.vec(0, 100);
diff --git a/sandbox/tests/within/within.ts b/sandbox/tests/within/within.ts
index b650e1fd8..eef128d69 100644
--- a/sandbox/tests/within/within.ts
+++ b/sandbox/tests/within/within.ts
@@ -4,7 +4,7 @@
ex.Physics.acc = new ex.Vector(0, 200);
-ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
var game = new ex.Engine({
width: 600,
height: 400
diff --git a/src/engine/Collision/BodyComponent.ts b/src/engine/Collision/BodyComponent.ts
index e437ea3fc..1dd5c884d 100644
--- a/src/engine/Collision/BodyComponent.ts
+++ b/src/engine/Collision/BodyComponent.ts
@@ -1,6 +1,5 @@
import { Vector } from '../Math/vector';
import { CollisionType } from './CollisionType';
-import { Physics } from './Physics';
import { Clonable } from '../Interfaces/Clonable';
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent';
@@ -11,11 +10,14 @@ import { clamp } from '../Math/util';
import { ColliderComponent } from './ColliderComponent';
import { Transform } from '../Math/transform';
import { EventEmitter } from '../EventEmitter';
+import { DefaultPhysicsConfig, PhysicsConfig } from './PhysicsConfig';
+import { DeepRequired } from '../Util/Required';
export interface BodyComponentOptions {
type?: CollisionType;
group?: CollisionGroup;
useGravity?: boolean;
+ config?: Pick['bodies']
}
export enum DegreeOfFreedom {
@@ -47,19 +49,56 @@ export class BodyComponent extends Component implements Clonable
*/
public enableFixedUpdateInterpolate = true;
+ private _bodyConfig: DeepRequired['bodies']>;
+ private static _DEFAULT_CONFIG: DeepRequired['bodies']> = {
+ ...DefaultPhysicsConfig.bodies
+ };
+ public wakeThreshold: number;
+
constructor(options?: BodyComponentOptions) {
super();
if (options) {
this.collisionType = options.type ?? this.collisionType;
this.group = options.group ?? this.group;
this.useGravity = options.useGravity ?? this.useGravity;
+ this._bodyConfig = {
+ ...DefaultPhysicsConfig.bodies,
+ ...options.config
+ };
+ } else {
+ this._bodyConfig = {
+ ...DefaultPhysicsConfig.bodies
+ };
}
+ this.updatePhysicsConfig(this._bodyConfig);
+ this._mass = BodyComponent._DEFAULT_CONFIG.defaultMass;
}
public get matrix() {
return this.transform.get().matrix;
}
+ /**
+ * Called by excalibur to update physics config defaults if they change
+ * @param config
+ */
+ public updatePhysicsConfig(config: DeepRequired['bodies']>) {
+ this._bodyConfig = {
+ ...DefaultPhysicsConfig.bodies,
+ ...config
+ };
+ this.canSleep = this._bodyConfig.canSleepByDefault;
+ this.sleepMotion = this._bodyConfig.sleepEpsilon * 5;
+ this.wakeThreshold = this._bodyConfig.wakeThreshold;
+ }
+ /**
+ * Called by excalibur to update defaults
+ * @param config
+ */
+ public static updateDefaultPhysicsConfig(config: DeepRequired['bodies']>) {
+ BodyComponent._DEFAULT_CONFIG = config;
+ }
+
/**
* Collision type for the rigidbody physics simulation, by default [[CollisionType.PreventCollision]]
*/
@@ -73,7 +112,7 @@ export class BodyComponent extends Component implements Clonable
/**
* The amount of mass the body has
*/
- private _mass: number = Physics.defaultMass;
+ private _mass: number;
public get mass(): number {
return this._mass;
}
@@ -94,12 +133,12 @@ export class BodyComponent extends Component implements Clonable
/**
* Amount of "motion" the body has before sleeping. If below [[Physics.sleepEpsilon]] it goes to "sleep"
*/
- public sleepMotion: number = Physics.sleepEpsilon * 5;
+ public sleepMotion: number;
/**
* Can this body sleep, by default bodies do not sleep
*/
- public canSleep: boolean = Physics.bodiesCanSleepByDefault;
+ public canSleep: boolean;;
private _sleeping = false;
/**
@@ -117,7 +156,7 @@ export class BodyComponent extends Component implements Clonable
this._sleeping = sleeping;
if (!sleeping) {
// Give it a kick to keep it from falling asleep immediately
- this.sleepMotion = Physics.sleepEpsilon * 5;
+ this.sleepMotion = this._bodyConfig.sleepEpsilon * 5;
} else {
this.vel = Vector.Zero;
this.acc = Vector.Zero;
@@ -134,10 +173,10 @@ export class BodyComponent extends Component implements Clonable
this.setSleeping(true);
}
const currentMotion = this.vel.size * this.vel.size + Math.abs(this.angularVelocity * this.angularVelocity);
- const bias = Physics.sleepBias;
+ const bias = this._bodyConfig.sleepBias;
this.sleepMotion = bias * this.sleepMotion + (1 - bias) * currentMotion;
- this.sleepMotion = clamp(this.sleepMotion, 0, 10 * Physics.sleepEpsilon);
- if (this.canSleep && this.sleepMotion < Physics.sleepEpsilon) {
+ this.sleepMotion = clamp(this.sleepMotion, 0, 10 * this._bodyConfig.sleepEpsilon);
+ if (this.canSleep && this.sleepMotion < this._bodyConfig.sleepEpsilon) {
this.setSleeping(true);
}
}
diff --git a/src/engine/Collision/Colliders/CompositeCollider.ts b/src/engine/Collision/Colliders/CompositeCollider.ts
index 1f3d97ec7..75360b026 100644
--- a/src/engine/Collision/Colliders/CompositeCollider.ts
+++ b/src/engine/Collision/Colliders/CompositeCollider.ts
@@ -13,11 +13,12 @@ import { DynamicTreeCollisionProcessor } from '../Detection/DynamicTreeCollision
import { RayCastHit } from '../Detection/RayCastHit';
import { Collider } from './Collider';
import { Transform } from '../../Math/transform';
+import { DefaultPhysicsConfig } from '../PhysicsConfig';
export class CompositeCollider extends Collider {
private _transform: Transform;
- private _collisionProcessor = new DynamicTreeCollisionProcessor();
- private _dynamicAABBTree = new DynamicTree();
+ private _collisionProcessor = new DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
+ private _dynamicAABBTree = new DynamicTree(DefaultPhysicsConfig.dynamicTree);
private _colliders: Collider[] = [];
constructor(colliders: Collider[]) {
diff --git a/src/engine/Collision/CollisionSystem.ts b/src/engine/Collision/CollisionSystem.ts
index 7f6433d55..d50c343db 100644
--- a/src/engine/Collision/CollisionSystem.ts
+++ b/src/engine/Collision/CollisionSystem.ts
@@ -3,7 +3,7 @@ import { MotionComponent } from '../EntityComponentSystem/Components/MotionCompo
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { System, SystemType } from '../EntityComponentSystem/System';
import { CollisionEndEvent, CollisionStartEvent, ContactEndEvent, ContactStartEvent } from '../Events';
-import { CollisionResolutionStrategy, Physics } from './Physics';
+import { SolverStrategy } from './SolverStrategy';
import { ArcadeSolver } from './Solver/ArcadeSolver';
import { Collider } from './Colliders/Collider';
import { CollisionContact } from './Detection/CollisionContact';
@@ -23,18 +23,23 @@ export class CollisionSystem extends System {
public query: Query | ComponentCtor | ComponentCtor>;
private _engine: Engine;
- private _realisticSolver = new RealisticSolver();
- private _arcadeSolver = new ArcadeSolver();
+ private _configDirty = false;
+ private _realisticSolver: RealisticSolver;
+ private _arcadeSolver: ArcadeSolver;
private _lastFrameContacts = new Map();
private _currentFrameContacts = new Map();
- private _processor: DynamicTreeCollisionProcessor;
+ private get _processor(): DynamicTreeCollisionProcessor {
+ return this._physics.collisionProcessor;
+ };
private _trackCollider: (c: Collider) => void;
private _untrackCollider: (c: Collider) => void;
- constructor(world: World, physics: PhysicsWorld) {
+ constructor(world: World, private _physics: PhysicsWorld) {
super();
- this._processor = physics.collisionProcessor;
+ this._arcadeSolver = new ArcadeSolver(_physics.config.arcade);
+ this._realisticSolver = new RealisticSolver(_physics.config.realistic);
+ this._physics.$configUpdate.subscribe(() => this._configDirty = true);
this._trackCollider = (c: Collider) => this._processor.track(c);
this._untrackCollider = (c: Collider) => this._processor.untrack(c);
this.query = world.query([TransformComponent, MotionComponent, ColliderComponent]);
@@ -61,7 +66,7 @@ export class CollisionSystem extends System {
}
update(elapsedMs: number): void {
- if (!Physics.enabled) {
+ if (!this._physics.config.enabled) {
return;
}
@@ -122,7 +127,12 @@ export class CollisionSystem extends System {
}
getSolver(): CollisionSolver {
- return Physics.collisionResolutionStrategy === CollisionResolutionStrategy.Realistic ? this._realisticSolver : this._arcadeSolver;
+ if (this._configDirty) {
+ this._configDirty = false;
+ this._arcadeSolver = new ArcadeSolver(this._physics.config.arcade);
+ this._realisticSolver = new RealisticSolver(this._physics.config.realistic);
+ }
+ return this._physics.config.solver === SolverStrategy.Realistic ? this._realisticSolver : this._arcadeSolver;
}
debug(ex: ExcaliburGraphicsContext) {
diff --git a/src/engine/Collision/Detection/CollisionContact.ts b/src/engine/Collision/Detection/CollisionContact.ts
index 0dcf83105..c53f34d04 100644
--- a/src/engine/Collision/Detection/CollisionContact.ts
+++ b/src/engine/Collision/Detection/CollisionContact.ts
@@ -1,5 +1,4 @@
import { Vector } from '../../Math/vector';
-import { Physics } from '../Physics';
import { Collider } from '../Colliders/Collider';
import { CollisionType } from '../CollisionType';
import { Pair } from './Pair';
@@ -93,10 +92,10 @@ export class CollisionContact {
const bodyB = this.colliderB.owner.get(BodyComponent);
if (bodyA && bodyB) {
if (bodyA.sleeping !== bodyB.sleeping) {
- if (bodyA.sleeping && bodyA.collisionType !== CollisionType.Fixed && bodyB.sleepMotion >= Physics.wakeThreshold) {
+ if (bodyA.sleeping && bodyA.collisionType !== CollisionType.Fixed && bodyB.sleepMotion >= bodyA.wakeThreshold) {
bodyA.setSleeping(false);
}
- if (bodyB.sleeping && bodyB.collisionType !== CollisionType.Fixed && bodyA.sleepMotion >= Physics.wakeThreshold) {
+ if (bodyB.sleeping && bodyB.collisionType !== CollisionType.Fixed && bodyA.sleepMotion >= bodyB.wakeThreshold) {
bodyB.setSleeping(false);
}
}
diff --git a/src/engine/Collision/Detection/DynamicTree.ts b/src/engine/Collision/Detection/DynamicTree.ts
index 6ca640311..9aad9908a 100644
--- a/src/engine/Collision/Detection/DynamicTree.ts
+++ b/src/engine/Collision/Detection/DynamicTree.ts
@@ -1,12 +1,11 @@
-import { Physics } from '../Physics';
import { BoundingBox } from '../BoundingBox';
-
import { Ray } from '../../Math/ray';
import { Logger } from '../../Util/Log';
import { Id } from '../../Id';
import { Entity } from '../../EntityComponentSystem/Entity';
import { BodyComponent } from '../BodyComponent';
import { Color, ExcaliburGraphicsContext } from '../..';
+import { PhysicsConfig } from '../PhysicsConfig';
/**
* Dynamic Tree Node used for tracking bounds within the tree
@@ -47,7 +46,9 @@ export interface ColliderProxy {
export class DynamicTree> {
public root: TreeNode;
public nodes: { [key: number]: TreeNode };
- constructor(public worldBounds: BoundingBox = new BoundingBox(-Number.MAX_VALUE, -Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE)) {
+ constructor(
+ private _config: Required['dynamicTree']>,
+ public worldBounds: BoundingBox = new BoundingBox(-Number.MAX_VALUE, -Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE)) {
this.root = null;
this.nodes = {};
}
@@ -243,17 +244,17 @@ export class DynamicTree> {
}
this._remove(node);
- b.left -= Physics.boundsPadding;
- b.top -= Physics.boundsPadding;
- b.right += Physics.boundsPadding;
- b.bottom += Physics.boundsPadding;
+ b.left -= this._config.boundsPadding;
+ b.top -= this._config.boundsPadding;
+ b.right += this._config.boundsPadding;
+ b.bottom += this._config.boundsPadding;
- // THIS IS CAUSING UNECESSARY CHECKS
+ // THIS IS CAUSING UNNECESSARY CHECKS
if (collider.owner) {
const body = collider.owner?.get(BodyComponent);
if (body) {
- const multdx = ((body.vel.x * 32) / 1000) * Physics.dynamicTreeVelocityMultiplier;
- const multdy = ((body.vel.y * 32) / 1000) * Physics.dynamicTreeVelocityMultiplier;
+ const multdx = ((body.vel.x * 32) / 1000) * this._config.velocityMultiplier;
+ const multdy = ((body.vel.y * 32) / 1000) * this._config.velocityMultiplier;
if (multdx < 0) {
b.left += multdx;
diff --git a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts
index 0eb498e09..a912bdb12 100644
--- a/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts
+++ b/src/engine/Collision/Detection/DynamicTreeCollisionProcessor.ts
@@ -1,4 +1,3 @@
-import { Physics } from '../Physics';
import { CollisionProcessor } from './CollisionProcessor';
import { DynamicTree } from './DynamicTree';
import { Pair } from './Pair';
@@ -15,6 +14,8 @@ import { CompositeCollider } from '../Colliders/CompositeCollider';
import { CollisionGroup } from '../Group/CollisionGroup';
import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext';
import { RayCastHit } from './RayCastHit';
+import { DeepRequired } from '../../Util/Required';
+import { PhysicsConfig } from '../PhysicsConfig';
export interface RayCastOptions {
/**
@@ -40,12 +41,16 @@ export interface RayCastOptions {
* the narrowphase (actual collision contacts)
*/
export class DynamicTreeCollisionProcessor implements CollisionProcessor {
- private _dynamicCollisionTree = new DynamicTree();
+ private _dynamicCollisionTree: DynamicTree;
private _pairs = new Set();
private _collisionPairCache: Pair[] = [];
private _colliders: Collider[] = [];
+ constructor(private _config: DeepRequired) {
+ this._dynamicCollisionTree = new DynamicTree(_config.dynamicTree);
+ }
+
public getColliders(): readonly Collider[] {
return this._colliders;
}
@@ -171,7 +176,7 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {
// Check dynamic tree for fast moving objects
// Fast moving objects are those moving at least there smallest bound per frame
- if (Physics.checkForFastBodies) {
+ if (this._config.continuous.checkForFastBodies) {
for (const collider of potentialColliders) {
const body = collider.owner.get(BodyComponent);
// Skip non-active objects. Does not make sense on other collision types
@@ -186,7 +191,7 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {
// Find the minimum dimension
const minDimension = Math.min(collider.bounds.height, collider.bounds.width);
- if (Physics.disableMinimumSpeedForFastBody || updateDistance > minDimension / 2) {
+ if (this._config.continuous.disableMinimumSpeedForFastBody || updateDistance > minDimension / 2) {
if (stats) {
stats.physics.fastBodies++;
}
@@ -201,12 +206,12 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {
const ray: Ray = new Ray(origin, body.vel);
// back the ray up by -2x surfaceEpsilon to account for fast moving objects starting on the surface
- ray.pos = ray.pos.add(ray.dir.scale(-2 * Physics.surfaceEpsilon));
+ ray.pos = ray.pos.add(ray.dir.scale(-2 * this._config.continuous.surfaceEpsilon));
let minCollider: Collider;
let minTranslate: Vector = new Vector(Infinity, Infinity);
- this._dynamicCollisionTree.rayCastQuery(ray, updateDistance + Physics.surfaceEpsilon * 2, (other: Collider) => {
+ this._dynamicCollisionTree.rayCastQuery(ray, updateDistance + this._config.continuous.surfaceEpsilon * 2, (other: Collider) => {
if (!this._pairExists(collider, other) && Pair.canCollide(collider, other)) {
- const hit = other.rayCast(ray, updateDistance + Physics.surfaceEpsilon * 10);
+ const hit = other.rayCast(ray, updateDistance + this._config.continuous.surfaceEpsilon * 10);
if (hit) {
const translate = hit.point.sub(origin);
if (translate.size < minTranslate.size) {
@@ -230,7 +235,7 @@ export class DynamicTreeCollisionProcessor implements CollisionProcessor {
body.globalPos = origin
.add(shift)
.add(minTranslate)
- .add(ray.dir.scale(10 * Physics.surfaceEpsilon)); // needed to push the shape slightly into contact
+ .add(ray.dir.scale(10 * this._config.continuous.surfaceEpsilon)); // needed to push the shape slightly into contact
collider.update(body.transform.get());
if (stats) {
diff --git a/src/engine/Collision/Index.ts b/src/engine/Collision/Index.ts
index da7a53d61..b2a5685b9 100644
--- a/src/engine/Collision/Index.ts
+++ b/src/engine/Collision/Index.ts
@@ -1,6 +1,7 @@
export * from './BodyComponent';
export * from './ColliderComponent';
export * from './CollisionType';
+export * from './SolverStrategy';
export * from './Colliders/Collider';
export * from './BoundingBox';
@@ -26,6 +27,7 @@ export * from './Detection/DynamicTreeCollisionProcessor';
export * from './Detection/QuadTree';
export * from './Solver/ArcadeSolver';
+export * from './Solver/ContactBias';
export * from './Solver/ContactConstraintPoint';
export * from './Solver/RealisticSolver';
export * from './Solver/Solver';
diff --git a/src/engine/Collision/MotionSystem.ts b/src/engine/Collision/MotionSystem.ts
index a3ed8d8d8..4cb2a1c73 100644
--- a/src/engine/Collision/MotionSystem.ts
+++ b/src/engine/Collision/MotionSystem.ts
@@ -2,17 +2,19 @@ import { Query, SystemPriority, World } from '../EntityComponentSystem';
import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent';
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { System, SystemType } from '../EntityComponentSystem/System';
-import { Physics } from './Physics';
import { BodyComponent } from './BodyComponent';
import { CollisionType } from './CollisionType';
import { EulerIntegrator } from './Integrator';
+import { PhysicsWorld } from './PhysicsWorld';
export class MotionSystem extends System {
public systemType = SystemType.Update;
public priority = SystemPriority.Higher;
+ private _physicsConfigDirty = false;
query: Query;
- constructor(public world: World) {
+ constructor(public world: World, public physics: PhysicsWorld) {
super();
+ physics.$configUpdate.subscribe(() => this._physicsConfigDirty = true);
this.query = this.world.query([TransformComponent, MotionComponent]);
}
@@ -25,13 +27,17 @@ export class MotionSystem extends System {
motion = entities[i].get(MotionComponent);
const optionalBody = entities[i].get(BodyComponent);
+ if (this._physicsConfigDirty && optionalBody) {
+ optionalBody.updatePhysicsConfig(this.physics.config.bodies);
+ }
+
if (optionalBody?.sleeping) {
continue;
}
const totalAcc = motion.acc.clone();
if (optionalBody?.collisionType === CollisionType.Active && optionalBody?.useGravity) {
- totalAcc.addEqual(Physics.gravity);
+ totalAcc.addEqual(this.physics.config.gravity);
}
optionalBody?.captureOldTransform();
diff --git a/src/engine/Collision/Physics.ts b/src/engine/Collision/Physics.ts
index bd639a927..6bd2bf9f0 100644
--- a/src/engine/Collision/Physics.ts
+++ b/src/engine/Collision/Physics.ts
@@ -1,25 +1,12 @@
import { Vector } from '../Math/vector';
-
-
-/**
- * Possible collision resolution strategies
- *
- * The default is [[CollisionResolutionStrategy.Arcade]] which performs simple axis aligned arcade style physics. This is useful for things
- * like platformers or top down games.
- *
- * More advanced rigid body physics are enabled by setting [[CollisionResolutionStrategy.Realistic]] which allows for complicated
- * simulated physical interactions.
- */
-export enum CollisionResolutionStrategy {
- Arcade = 'arcade',
- Realistic = 'realistic'
-}
+import { SolverStrategy } from './SolverStrategy';
/**
* Possible broadphase collision pair identification strategies
*
* The default strategy is [[BroadphaseStrategy.DynamicAABBTree]] which uses a binary tree of axis-aligned bounding boxes to identify
* potential collision pairs which is O(nlog(n)) faster.
+ * @deprecated Unused in Excalibur, will be removed in v0.30
*/
export enum BroadphaseStrategy {
DynamicAABBTree
@@ -27,6 +14,7 @@ export enum BroadphaseStrategy {
/**
* Possible numerical integrators for position and velocity
+ * @deprecated Unused in Excalibur, will be removed in v0.30
*/
export enum Integrator {
Euler
@@ -34,6 +22,7 @@ export enum Integrator {
/**
* The [[Physics]] object is the global configuration object for all Excalibur physics.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
/* istanbul ignore next */
export class Physics {
@@ -42,8 +31,12 @@ export class Physics {
* Global acceleration won't effect [[Label|labels]], [[ScreenElement|ui actors]], or [[Trigger|triggers]] in Excalibur.
*
* This is a great way to globally simulate effects like gravity.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static acc = new Vector(0, 0);
+ /**
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
+ */
public static get gravity() {
return Physics.acc;
}
@@ -53,6 +46,7 @@ export class Physics {
/**
* Globally switches all Excalibur physics behavior on or off.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static enabled = true;
@@ -61,97 +55,122 @@ export class Physics {
*
* The default strategy is [[BroadphaseStrategy.DynamicAABBTree]] which uses a binary tree of axis-aligned bounding boxes to identify
* potential collision pairs which is O(nlog(n)) faster.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static broadphaseStrategy: BroadphaseStrategy = BroadphaseStrategy.DynamicAABBTree;
/**
* Gets or sets the global collision resolution strategy (narrowphase).
*
- * The default is [[CollisionResolutionStrategy.Arcade]] which performs simple axis aligned arcade style physics.
+ * The default is [[SolverStrategy.Arcade]] which performs simple axis aligned arcade style physics.
*
- * More advanced rigid body physics are enabled by setting [[CollisionResolutionStrategy.Realistic]] which allows for complicated
+ * More advanced rigid body physics are enabled by setting [[SolverStrategy.Realistic]] which allows for complicated
* simulated physical interactions.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
- public static collisionResolutionStrategy: CollisionResolutionStrategy = CollisionResolutionStrategy.Arcade;
+ public static collisionResolutionStrategy: SolverStrategy = SolverStrategy.Arcade;
/**
* The default mass to use if none is specified
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static defaultMass: number = 10;
/**
* Gets or sets the position and velocity positional integrator, currently only Euler is supported.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static integrator: Integrator = Integrator.Euler;
/**
* Configures Excalibur to use "arcade" physics. Arcade physics which performs simple axis aligned arcade style physics.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static useArcadePhysics(): void {
- Physics.collisionResolutionStrategy = CollisionResolutionStrategy.Arcade;
+ Physics.collisionResolutionStrategy = SolverStrategy.Arcade;
}
/**
* Configures Excalibur to use rigid body physics. Rigid body physics allows for complicated
* simulated physical interactions.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static useRealisticPhysics(): void {
- Physics.collisionResolutionStrategy = CollisionResolutionStrategy.Realistic;
+ Physics.collisionResolutionStrategy = SolverStrategy.Realistic;
}
/**
* Factor to add to the RigidBody BoundingBox, bounding box (dimensions += vel * dynamicTreeVelocityMultiplier);
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static dynamicTreeVelocityMultiplier = 2;
/**
* Pad RigidBody BoundingBox by a constant amount
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static boundsPadding = 5;
/**
* Number of position iterations (overlap) to run in the solver
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static positionIterations = 3;
/**
* Number of velocity iteration (response) to run in the solver
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static velocityIterations = 8;
/**
* Amount of overlap to tolerate in pixels
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static slop = 1;
/**
* Amount of positional overlap correction to apply each position iteration of the solver
* O - meaning no correction, 1 - meaning correct all overlap
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static steeringFactor = 0.2;
/**
* Warm start set to true re-uses impulses from previous frames back in the solver
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static warmStart = true;
/**
* By default bodies do not sleep
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static bodiesCanSleepByDefault = false;
/**
* Surface epsilon is used to help deal with surface penetration
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static surfaceEpsilon = 0.1;
+ /**
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
+ */
public static sleepEpsilon = 0.07;
+ /**
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
+ */
public static wakeThreshold = Physics.sleepEpsilon * 3;
+ /**
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
+ */
public static sleepBias = 0.9;
/**
* Enable fast moving body checking, this enables checking for collision pairs via raycast for fast moving objects to prevent
* bodies from tunneling through one another.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static checkForFastBodies = true;
@@ -159,6 +178,7 @@ export class Physics {
* Disable minimum fast moving body raycast, by default if ex.Physics.checkForFastBodies = true Excalibur will only check if the
* body is moving at least half of its minimum dimension in an update. If ex.Physics.disableMinimumSpeedForFastBody is set to true,
* Excalibur will always perform the fast body raycast regardless of speed.
+ * @deprecated Use engine args to configure Physics `new ex.Engine({physics: {...}})`, will be removed in v0.30
*/
public static disableMinimumSpeedForFastBody = false;
}
diff --git a/src/engine/Collision/PhysicsConfig.ts b/src/engine/Collision/PhysicsConfig.ts
new file mode 100644
index 000000000..eaffdc478
--- /dev/null
+++ b/src/engine/Collision/PhysicsConfig.ts
@@ -0,0 +1,249 @@
+import { Vector, vec } from '../Math/vector';
+import { DeepRequired } from '../Util/Required';
+import { SolverStrategy } from './SolverStrategy';
+import { Physics } from './Physics';
+import { ContactSolveBias } from './Solver/ContactBias';
+
+
+export interface PhysicsConfig {
+ /**
+ * Excalibur physics simulation is enabled
+ */
+ enabled?: boolean;
+ /**
+ * Configure gravity that applies to all [[CollisionType.Active]] bodies.
+ *
+ * This is acceleration in pixels/sec^2
+ *
+ * Default vec(0, 0)
+ *
+ * [[BodyComponent.useGravity]] to opt out
+ */
+ gravity?: Vector;
+ /**
+ * Configure the type of physics simulation you would like
+ *
+ * * [[SolverStrategy.Arcade]] is suitable for games where you might be doing platforming or top down movement.
+ * * [[SolverStrategy.Realistic]] is where you need objects to bounce off each other and respond like real world objects.
+ *
+ * Default is Arcade
+ */
+ solver?: SolverStrategy;
+
+ /**
+ * Configure excalibur continuous collision (WIP)
+ */
+ continuous?: {
+
+ /**
+ * Enable fast moving body checking, this enables checking for collision pairs via raycast for fast moving objects to prevent
+ * bodies from tunneling through one another.
+ *
+ * Default true
+ */
+ checkForFastBodies?: boolean;
+
+ /**
+ * Disable minimum fast moving body raycast, by default if checkForFastBodies = true Excalibur will only check if the
+ * body is moving at least half of its minimum dimension in an update. If disableMinimumSpeedForFastBody is set to true,
+ * Excalibur will always perform the fast body raycast regardless of speed.
+ *
+ * Default false
+ */
+ disableMinimumSpeedForFastBody?: boolean;
+
+ /**
+ * Surface epsilon is used to help deal with predicting collisions by applying a slop
+ *
+ * Default 0.1
+ */
+ surfaceEpsilon?: number;
+ }
+
+ /**
+ * Configure body defaults
+ */
+ bodies?: {
+ /**
+ * Configure default mass that bodies have
+ *
+ * Default 10 mass units
+ */
+ defaultMass?: number;
+
+ /**
+ * Sleep epsilon
+ *
+ * Default 0.07
+ */
+ sleepEpsilon?: number;
+
+ /**
+ * Wake Threshold, the amount of "motion" need to wake a body from sleep
+ *
+ * Default 0.07 * 3;
+ */
+ wakeThreshold?: number;
+
+ /**
+ * Sleep bias
+ *
+ * Default 0.9
+ */
+ sleepBias?: number;
+
+ /**
+ * By default bodies do not sleep, this can be turned on to improve perf if you have a lot of bodies.
+ *
+ * Default false
+ */
+ canSleepByDefault?: boolean;
+ }
+
+ /**
+ * Configure the dynamic tree spatial data structure for locating pairs and raycasts
+ */
+ dynamicTree?: {
+ /**
+ * Pad collider BoundingBox by a constant amount for purposes of potential pairs
+ *
+ * Default 5 pixels
+ */
+ boundsPadding?: number;
+
+ /**
+ * Factor to add to the collider BoundingBox, bounding box (dimensions += vel * dynamicTreeVelocityMultiplier);
+ *
+ * Default 2
+ */
+ velocityMultiplier?: number;
+ }
+
+ /**
+ * Configure the [[ArcadeSolver]]
+ */
+ arcade?: {
+ /**
+ * Hints the [[ArcadeSolver]] to preferentially solve certain contact directions first.
+ *
+ * Options:
+ * * Solve [[ContactSolveBias.VerticalFirst]] which will do vertical contact resolution first (useful for platformers
+ * with up/down gravity)
+ * * Solve [[ContactSolveBias.HorizontalFirst]] which will do horizontal contact resolution first (useful for games with
+ * left/right forces)
+ * * By default [[ContactSolveBias.None]] which sorts by distance
+ */
+ contactSolveBias?: ContactSolveBias;
+ }
+
+ /**
+ * Configure the [[RealisticSolver]]
+ */
+ realistic?: {
+ /**
+ * Number of position iterations (overlap) to run in the solver
+ *
+ * Default 3 iterations
+ */
+ positionIterations?: number;
+
+ /**
+ * Number of velocity iteration (response) to run in the solver
+ *
+ * Default 8 iterations
+ */
+ velocityIterations?: number;
+
+ /**
+ * Amount of overlap to tolerate in pixels
+ *
+ * Default 1 pixel
+ */
+ slop?: number;
+
+ /**
+ * Amount of positional overlap correction to apply each position iteration of the solver
+ * 0 - meaning no correction, 1 - meaning correct all overlap. Generally values 0 < .5 look nice.
+ *
+ * Default 0.2
+ */
+ steeringFactor?: number;
+
+ /**
+ * Warm start set to true re-uses impulses from previous frames back in the solver. Re-using impulses helps
+ * the solver converge quicker
+ *
+ * Default true
+ */
+ warmStart?: boolean;
+ }
+}
+
+export const DefaultPhysicsConfig: DeepRequired = {
+ enabled: true,
+ gravity: vec(0, 0),
+ solver: SolverStrategy.Arcade,
+ continuous: {
+ checkForFastBodies: true,
+ disableMinimumSpeedForFastBody: false,
+ surfaceEpsilon: 0.1
+ },
+ bodies: {
+ canSleepByDefault: false,
+ sleepEpsilon: 0.07,
+ wakeThreshold: 0.07 * 3,
+ sleepBias: 0.9,
+ defaultMass: 10
+ },
+ dynamicTree: {
+ boundsPadding: 5,
+ velocityMultiplier: 2
+ },
+ arcade: {
+ contactSolveBias: ContactSolveBias.None
+ },
+ realistic: {
+ positionIterations: 3,
+ velocityIterations: 8,
+ slop: 1,
+ steeringFactor: 0.2,
+ warmStart: true
+ }
+};
+
+/**
+ * @deprecated will be removed in v0.30
+ */
+export function DeprecatedStaticToConfig(): DeepRequired {
+ return {
+ enabled: Physics.enabled,
+ gravity: Physics.gravity,
+ solver: Physics.collisionResolutionStrategy,
+ continuous: {
+ checkForFastBodies: Physics.checkForFastBodies,
+ disableMinimumSpeedForFastBody: Physics.disableMinimumSpeedForFastBody,
+ surfaceEpsilon: Physics.surfaceEpsilon
+ },
+ bodies: {
+ canSleepByDefault: Physics.bodiesCanSleepByDefault,
+ sleepEpsilon: Physics.sleepEpsilon,
+ wakeThreshold: Physics.wakeThreshold,
+ sleepBias: Physics.sleepBias,
+ defaultMass: Physics.defaultMass
+ },
+ dynamicTree: {
+ boundsPadding: Physics.boundsPadding,
+ velocityMultiplier: Physics.dynamicTreeVelocityMultiplier
+ },
+ arcade: {
+ contactSolveBias: ContactSolveBias.None
+ },
+ realistic: {
+ positionIterations: Physics.positionIterations,
+ velocityIterations: Physics.velocityIterations,
+ slop: Physics.slop,
+ steeringFactor: Physics.steeringFactor,
+ warmStart: Physics.warmStart
+ }
+ };
+}
\ No newline at end of file
diff --git a/src/engine/Collision/PhysicsWorld.ts b/src/engine/Collision/PhysicsWorld.ts
index 076b98982..127c036b2 100644
--- a/src/engine/Collision/PhysicsWorld.ts
+++ b/src/engine/Collision/PhysicsWorld.ts
@@ -1,13 +1,57 @@
import { Ray } from '../Math/ray';
+import { DeepRequired } from '../Util/Required';
+import { Observable } from '../Util/Observable';
import { DynamicTreeCollisionProcessor, RayCastHit, RayCastOptions } from './Index';
-
+import { BodyComponent } from './BodyComponent';
+import { PhysicsConfig } from './PhysicsConfig';
+import { watchDeep } from '../Util/Watch';
export class PhysicsWorld {
- public collisionProcessor: DynamicTreeCollisionProcessor;
- constructor() {
- this.collisionProcessor = new DynamicTreeCollisionProcessor();
+
+ $configUpdate = new Observable>;
+
+ private _configDirty = false;
+ private _config: DeepRequired;
+ get config(): DeepRequired {
+ return watchDeep(this._config, change => {
+ this.$configUpdate.notifyAll(change);
+ });
+ }
+ set config(newConfig: DeepRequired) {
+ this._config = newConfig;
+ this.$configUpdate.notifyAll(newConfig);
+ }
+
+ private _collisionProcessor: DynamicTreeCollisionProcessor;
+ /**
+ * Spatial data structure for locating potential collision pairs and ray casts
+ */
+ public get collisionProcessor(): DynamicTreeCollisionProcessor {
+ if (this._configDirty) {
+ this._configDirty = false;
+ // preserve tracked colliders if config updates
+ const colliders = this._collisionProcessor.getColliders();
+ this._collisionProcessor = new DynamicTreeCollisionProcessor(this._config);
+ for (const collider of colliders) {
+ this._collisionProcessor.track(collider);
+ }
+ }
+ return this._collisionProcessor;
+ }
+ constructor(config: DeepRequired) {
+ this.config = config;
+ this.$configUpdate.subscribe((config) => {
+ this._configDirty = true;
+ BodyComponent.updateDefaultPhysicsConfig(config.bodies);
+ });
+ this._collisionProcessor = new DynamicTreeCollisionProcessor(this.config);
}
+ /**
+ * Raycast into the scene's physics world
+ * @param ray
+ * @param options
+ */
public rayCast(ray: Ray, options?: RayCastOptions): RayCastHit[] {
return this.collisionProcessor.rayCast(ray, options);
}
diff --git a/src/engine/Collision/Solver/ArcadeSolver.ts b/src/engine/Collision/Solver/ArcadeSolver.ts
index adaeee0a8..a115f6611 100644
--- a/src/engine/Collision/Solver/ArcadeSolver.ts
+++ b/src/engine/Collision/Solver/ArcadeSolver.ts
@@ -4,6 +4,9 @@ import { CollisionType } from '../CollisionType';
import { Side } from '../Side';
import { CollisionSolver } from './Solver';
import { BodyComponent } from '../BodyComponent';
+import { ContactBias, ContactSolveBias, HorizontalFirst, None, VerticalFirst } from './ContactBias';
+import { PhysicsConfig } from '../PhysicsConfig';
+import { DeepRequired } from '../../Util/Required';
/**
* ArcadeSolver is the default in Excalibur. It solves collisions so that there is no overlap between contacts,
@@ -13,9 +16,11 @@ import { BodyComponent } from '../BodyComponent';
*
*/
export class ArcadeSolver implements CollisionSolver {
- directionMap = new Map();
+ directionMap = new Map();
distanceMap = new Map();
+ constructor(public config: DeepRequired['arcade']>) {}
+
public solve(contacts: CollisionContact[]): CollisionContact[] {
// Events and init
this.preSolve(contacts);
@@ -23,12 +28,31 @@ export class ArcadeSolver implements CollisionSolver {
// Remove any canceled contacts
contacts = contacts.filter(c => !c.isCanceled());
+ // Locate collision bias order
+ let bias: ContactBias;
+ switch (this.config.contactSolveBias) {
+ case ContactSolveBias.HorizontalFirst: {
+ bias = HorizontalFirst;
+ break;
+ }
+ case ContactSolveBias.VerticalFirst: {
+ bias = VerticalFirst;
+ break;
+ }
+ default: {
+ bias = None;
+ }
+ }
+
+ // Sort by bias (None, VerticalFirst, HorizontalFirst) to avoid artifacts with seams
// Sort contacts by distance to avoid artifacts with seams
// It's important to solve in a specific order
contacts.sort((a, b) => {
+ const aDir = this.directionMap.get(a.id);
+ const bDir = this.directionMap.get(b.id);
const aDist = this.distanceMap.get(a.id);
const bDist = this.distanceMap.get(b.id);
- return aDist - bDist;
+ return (bias[aDir] - bias[bDir]) || (aDist - bDist);
});
for (const contact of contacts) {
@@ -59,6 +83,8 @@ export class ArcadeSolver implements CollisionSolver {
const distance = contact.colliderA.worldPos.squareDistance(contact.colliderB.worldPos);
this.distanceMap.set(contact.id, distance);
+ this.directionMap.set(contact.id, side === Side.Left || side === Side.Right ? 'horizontal' : 'vertical');
+
// Publish collision events on both participants
contact.colliderA.events.emit(
'precollision',
diff --git a/src/engine/Collision/Solver/ContactBias.ts b/src/engine/Collision/Solver/ContactBias.ts
new file mode 100644
index 000000000..d8049d3b5
--- /dev/null
+++ b/src/engine/Collision/Solver/ContactBias.ts
@@ -0,0 +1,41 @@
+
+/**
+ * Tells the Arcade collision solver to prefer certain contacts over others
+ */
+export enum ContactSolveBias {
+ None = 'none',
+ VerticalFirst = 'vertical-first',
+ HorizontalFirst = 'horizontal-first'
+}
+
+/**
+ * Contact bias values
+ */
+export interface ContactBias {
+ vertical: number;
+ horizontal: number;
+}
+
+/**
+ * Vertical First contact solve bias Used by the [[ArcadeSolver]] to sort contacts
+ */
+export const VerticalFirst: ContactBias = {
+ 'vertical': 1,
+ 'horizontal': 2
+} as const;
+
+/**
+ * Horizontal First contact solve bias Used by the [[ArcadeSolver]] to sort contacts
+ */
+export const HorizontalFirst: ContactBias = {
+ 'horizontal': 1,
+ 'vertical': 2
+} as const;
+
+/**
+ * None value, [[ArcadeSolver]] sorts contacts using distance by default
+ */
+export const None: ContactBias = {
+ 'horizontal': 0,
+ 'vertical': 0
+} as const;
\ No newline at end of file
diff --git a/src/engine/Collision/Solver/RealisticSolver.ts b/src/engine/Collision/Solver/RealisticSolver.ts
index 370f3ee6e..60b8401b1 100644
--- a/src/engine/Collision/Solver/RealisticSolver.ts
+++ b/src/engine/Collision/Solver/RealisticSolver.ts
@@ -4,12 +4,14 @@ import { CollisionContact } from '../Detection/CollisionContact';
import { CollisionType } from '../CollisionType';
import { ContactConstraintPoint } from './ContactConstraintPoint';
import { Side } from '../Side';
-import { Physics } from '../Physics';
import { CollisionSolver } from './Solver';
import { BodyComponent, DegreeOfFreedom } from '../BodyComponent';
import { CollisionJumpTable } from '../Colliders/CollisionJumpTable';
+import { DeepRequired } from '../../Util/Required';
+import { PhysicsConfig } from '../PhysicsConfig';
export class RealisticSolver implements CollisionSolver {
+ constructor(public config: DeepRequired['realistic']>) {}
lastFrameContacts: Map = new Map();
// map contact id to contact points
@@ -142,7 +144,7 @@ export class RealisticSolver implements CollisionSolver {
// Warm contacts with accumulated impulse
// Useful for tall stacks
- if (Physics.warmStart) {
+ if (this.config.warmStart) {
this.warmStart(contacts);
} else {
for (const contact of contacts) {
@@ -209,7 +211,7 @@ export class RealisticSolver implements CollisionSolver {
if (bodyA && bodyB) {
const contactPoints = this.idToContactConstraint.get(contact.id) ?? [];
for (const point of contactPoints) {
- if (Physics.warmStart) {
+ if (this.config.warmStart) {
const normalImpulse = contact.normal.scale(point.normalImpulse);
const tangentImpulse = contact.tangent.scale(point.tangentImpulse);
const impulse = normalImpulse.add(tangentImpulse);
@@ -230,7 +232,7 @@ export class RealisticSolver implements CollisionSolver {
* @param contacts
*/
solvePosition(contacts: CollisionContact[]) {
- for (let i = 0; i < Physics.positionIterations; i++) {
+ for (let i = 0; i < this.config.positionIterations; i++) {
for (const contact of contacts) {
const bodyA = contact.colliderA.owner?.get(BodyComponent);
const bodyB = contact.colliderB.owner?.get(BodyComponent);
@@ -246,9 +248,9 @@ export class RealisticSolver implements CollisionSolver {
const normal = contact.normal;
const separation = CollisionJumpTable.FindContactSeparation(contact, point.local);
- const steeringConstant = Physics.steeringFactor; //0.2;
+ const steeringConstant = this.config.steeringFactor; //0.2;
const maxCorrection = -5;
- const slop = Physics.slop; //1;
+ const slop = this.config.slop; //1;
// Clamp to avoid over-correction
// Remember that we are shooting for 0 overlap in the end
@@ -294,7 +296,7 @@ export class RealisticSolver implements CollisionSolver {
}
solveVelocity(contacts: CollisionContact[]) {
- for (let i = 0; i < Physics.velocityIterations; i++) {
+ for (let i = 0; i < this.config.velocityIterations; i++) {
for (const contact of contacts) {
const bodyA = contact.colliderA.owner?.get(BodyComponent);
const bodyB = contact.colliderB.owner?.get(BodyComponent);
diff --git a/src/engine/Collision/SolverStrategy.ts b/src/engine/Collision/SolverStrategy.ts
new file mode 100644
index 000000000..09a22532e
--- /dev/null
+++ b/src/engine/Collision/SolverStrategy.ts
@@ -0,0 +1,13 @@
+/**
+ * Possible collision resolution strategies
+ *
+ * The default is [[SolverStrategy.Arcade]] which performs simple axis aligned arcade style physics. This is useful for things
+ * like platformers or top down games.
+ *
+ * More advanced rigid body physics are enabled by setting [[SolverStrategy.Realistic]] which allows for complicated
+ * simulated physical interactions.
+ */
+export enum SolverStrategy {
+ Arcade = 'arcade',
+ Realistic = 'realistic'
+}
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts
index 7f47cc100..e6d7345f7 100644
--- a/src/engine/Engine.ts
+++ b/src/engine/Engine.ts
@@ -51,6 +51,8 @@ import { Toaster } from './Util/Toaster';
import { InputMapper } from './Input/InputMapper';
import { GoToOptions, SceneMap, Director, StartOptions, SceneWithOptions, WithRoot } from './Director/Director';
import { InputHost } from './Input/InputHost';
+import { DefaultPhysicsConfig, DeprecatedStaticToConfig, PhysicsConfig } from './Collision/PhysicsConfig';
+import { DeepRequired } from './Util/Required';
export type EngineEvents = {
fallbackgraphicscontext: ExcaliburGraphicsContext2DCanvas,
@@ -181,7 +183,7 @@ export interface EngineOptions {
/**
* Optionally upscale the number of pixels in the canvas. Normally only useful if you need a smoother look to your assets, especially
- * [[Text]].
+ * [[Text]] or Pixel Art assets.
*
* **WARNING** It is recommended you try using `antialiasing: true` before adjusting pixel ratio. Pixel ratio will consume more memory
* and on mobile may break if the internal size of the canvas exceeds 4k pixels in width or height.
@@ -318,6 +320,15 @@ export interface EngineOptions {
threshold?: { numberOfFrames: number, fps: number };
},
+ /**
+ * Optionally configure the physics simulation in excalibur
+ *
+ * If false, Excalibur will not produce a physics simulation.
+ *
+ * Default is configured to use [[SolverStrategy.Arcade]] physics simulation
+ */
+ physics?: boolean | PhysicsConfig
+
/**
* Optionally specify scenes with their transitions and loaders to excalibur's scene [[Director]]
*
@@ -376,6 +387,11 @@ export class Engine implements CanInitialize,
*/
public canvasElementId: string;
+ /**
+ * Direct access to the physics configuration for excalibur
+ */
+ public physics: DeepRequired;
+
/**
* Optionally set the maximum fps if not set Excalibur will go as fast as the device allows.
*
@@ -903,6 +919,20 @@ O|===|* >________________>\n\
this.enableCanvasTransparency = options.enableCanvasTransparency;
+ if (typeof options.physics === 'boolean') {
+ this.physics = {
+ ...DefaultPhysicsConfig,
+ ...DeprecatedStaticToConfig(),
+ enabled: options.physics
+ };
+ } else {
+ this.physics = {
+ ...DefaultPhysicsConfig,
+ ...DeprecatedStaticToConfig(),
+ ...options.physics as DeepRequired
+ };
+ }
+
this.debug = new Debug(this);
this.director = new Director(this, options.scenes);
diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts
index 06cbd968c..5845a2dc1 100644
--- a/src/engine/Scene.ts
+++ b/src/engine/Scene.ts
@@ -38,6 +38,7 @@ import { DefaultLoader } from './Director/DefaultLoader';
import { Transition } from './Director';
import { InputHost } from './Input/InputHost';
import { PointerScope } from './Input/PointerScope';
+import { DefaultPhysicsConfig } from './Collision/PhysicsConfig';
export class PreLoadEvent {
loader: DefaultLoader;
@@ -110,7 +111,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate
*
* Can be used to perform scene ray casts, track colliders, broadphase, and narrowphase.
*/
- public physics = new PhysicsWorld();
+ public physics = new PhysicsWorld(DefaultPhysicsConfig);
/**
* The actors in the current scene
@@ -168,7 +169,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate
// Update
this.world.add(ActionsSystem);
- this.world.add(MotionSystem);
+ this.world.add(new MotionSystem(this.world, this.physics));
this.world.add(new CollisionSystem(this.world, this.physics));
this.world.add(PointerSystem);
this.world.add(IsometricEntitySystem);
@@ -319,6 +320,8 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate
public async _initialize(engine: Engine) {
if (!this.isInitialized) {
this.engine = engine;
+ // PhysicsWorld config is watched so things will automagically update
+ this.physics.config = this.engine.physics;
this.input = new InputHost({
pointerTarget: engine.pointerScope === PointerScope.Canvas ? engine.canvas : document,
grabWindowFocus: engine.grabWindowFocus,
diff --git a/src/engine/Util/Required.ts b/src/engine/Util/Required.ts
new file mode 100644
index 000000000..d3707cae4
--- /dev/null
+++ b/src/engine/Util/Required.ts
@@ -0,0 +1,3 @@
+export type DeepRequired = Required<{
+ [K in keyof T]: T[K] extends Required ? T[K] : DeepRequired
+}>
\ No newline at end of file
diff --git a/src/engine/Util/Watch.ts b/src/engine/Util/Watch.ts
index 52da42fb8..c6725f23d 100644
--- a/src/engine/Util/Watch.ts
+++ b/src/engine/Util/Watch.ts
@@ -33,6 +33,44 @@ export function watch(type: T, change: (type: T) => any): T {
return type;
}
+const createHandler = (path: string[] = [], change: (type: T) => any, typeType: T) => ({
+ get: (target: T, key: string): any => {
+ if (key === '__isProxy') {
+ return true;
+ }
+ if (typeof (target as any)[key] === 'object' && (target as any)[key] != null) {
+ return new Proxy(
+ (target as any)[key],
+ createHandler([...path, key as string], change, typeType)
+ );
+ }
+ return (target as any)[key];
+ },
+ set: (target: T, key: string, value: any) => {
+ if (typeof key === 'string') {
+ if (key[0] !== '_') {
+ change(typeType);
+ }
+ }
+ (target as any)[key] = value;
+ return true;
+ }
+});
+
+/**
+ *
+ */
+export function watchDeep(type: T, change: (type: T) => any): T {
+ if (!type) {
+ return type;
+ }
+ if ((type as any).__isProxy === undefined) {
+ // expando hack to mark a proxy
+ return new Proxy(type, createHandler([], change, type));
+ }
+ return type;
+}
+
/**
* Watch an object with a proxy, fires change on any property value change
*/
diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts
index c2f83b1df..73fedaec1 100644
--- a/src/spec/ActorSpec.ts
+++ b/src/spec/ActorSpec.ts
@@ -22,8 +22,9 @@ describe('A game actor', () => {
actor = new ex.Actor({name: 'Default'});
actor.body.collisionType = ex.CollisionType.Active;
scene = new ex.Scene();
- motionSystem = new ex.MotionSystem(scene.world);
- collisionSystem = new ex.CollisionSystem(scene.world, new PhysicsWorld());
+ const physicsWorld = new PhysicsWorld(engine.physics);
+ motionSystem = new ex.MotionSystem(scene.world, physicsWorld);
+ collisionSystem = new ex.CollisionSystem(scene.world, physicsWorld);
actionSystem = new ex.ActionsSystem(scene.world);
scene.add(actor);
engine.addScene('test', scene);
@@ -636,8 +637,9 @@ describe('A game actor', () => {
await TestUtils.runToReady(engine);
actor = new ex.Actor();
actor.body.collisionType = ex.CollisionType.Active;
- motionSystem = new ex.MotionSystem(engine.currentScene.world);
- collisionSystem = new ex.CollisionSystem(engine.currentScene.world, new PhysicsWorld());
+ const physicsWorld = new PhysicsWorld(engine.physics);
+ motionSystem = new ex.MotionSystem(engine.currentScene.world, physicsWorld);
+ collisionSystem = new ex.CollisionSystem(engine.currentScene.world, physicsWorld);
scene = new ex.Scene();
scene.add(actor);
engine.addScene('test', scene);
diff --git a/src/spec/ArcadeSolverSpec.ts b/src/spec/ArcadeSolverSpec.ts
index 2737fb99e..73357e8fc 100644
--- a/src/spec/ArcadeSolverSpec.ts
+++ b/src/spec/ArcadeSolverSpec.ts
@@ -1,6 +1,7 @@
import * as ex from '@excalibur';
import { ExcaliburMatchers } from 'excalibur-jasmine';
import { TestUtils } from './util/TestUtils';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
describe('An ArcadeSolver', () => {
beforeAll(() => {
@@ -24,7 +25,7 @@ describe('An ArcadeSolver', () => {
const contacts = [...pair1.collide(), ...pair2.collide()];
expect(contacts.length).toBe(2);
- const sut = new ex.ArcadeSolver();
+ const sut = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
for (const contact of contacts) {
sut.solvePosition(contact);
@@ -91,7 +92,7 @@ describe('An ArcadeSolver', () => {
});
it('should cancel collision contacts where there is no more overlap', () => {
- const arcadeSolver = new ex.ArcadeSolver();
+ const arcadeSolver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
const player = new ex.Actor({
x: 0,
@@ -127,7 +128,7 @@ describe('An ArcadeSolver', () => {
it('should NOT cancel collisions where the bodies are moving away from the contact', () => {
- const arcadeSolver = new ex.ArcadeSolver();
+ const arcadeSolver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
const player = new ex.Actor({
x: 0,
@@ -166,7 +167,7 @@ describe('An ArcadeSolver', () => {
});
it('should cancel near zero mtv collisions', () => {
- const arcadeSolver = new ex.ArcadeSolver();
+ const arcadeSolver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
const player = new ex.Actor({
x: 0,
@@ -203,7 +204,7 @@ describe('An ArcadeSolver', () => {
});
it('should cancel near zero overlap collisions', () => {
- const arcadeSolver = new ex.ArcadeSolver();
+ const arcadeSolver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
const player = new ex.Actor({
x: 0,
@@ -232,7 +233,7 @@ describe('An ArcadeSolver', () => {
});
it('should cancel zero overlap collisions during presolve', () => {
- const arcadeSolver = new ex.ArcadeSolver();
+ const arcadeSolver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
const player = new ex.Actor({
x: 0,
@@ -261,4 +262,62 @@ describe('An ArcadeSolver', () => {
// Considers infinitesimally overlapping to no longer be overlapping and thus cancels the contact
expect(contact.isCanceled()).toBe(true);
});
+
+ it('should allow solver bias and solve certain contacts first', async () => {
+ const game = TestUtils.engine({
+ width: 1000,
+ height: 1000,
+ fixedUpdateFps: 60,
+ physics: {
+ gravity: ex.vec(0, 5000),
+ solver: ex.SolverStrategy.Arcade,
+ arcade: {
+ contactSolveBias: ex.ContactSolveBias.VerticalFirst
+ }
+ }
+ });
+ const clock = game.clock as ex.TestClock;
+ await TestUtils.runToReady(game);
+ // big tiles so distance heuristic doesn't work
+ const lastPos = ex.vec(0, 0);
+ for (let x = 0; x < 10; x++) {
+ const width = (x % 2 === 1 ? 16 : 200);
+ game.add(
+ new ex.Actor({
+ name: 'floor-tile',
+ x: lastPos.x,
+ y: 300,
+ width: width,
+ height: x % 2 ? 16 : 900,
+ anchor: ex.Vector.Zero,
+ color: ex.Color.Red,
+ collisionType: ex.CollisionType.Fixed
+ })
+ );
+ lastPos.x += width;
+ }
+
+ const player = new ex.Actor({
+ pos: ex.vec(100, 270),
+ width: 16,
+ height: 16,
+ collisionType: ex.CollisionType.Active,
+ color: ex.Color.Red
+ });
+
+ // place player on tiles
+ player.vel.x = 164;
+ 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 < 40; i++) {
+ clock.step(16);
+ }
+ });
});
\ No newline at end of file
diff --git a/src/spec/CollisionContactSpec.ts b/src/spec/CollisionContactSpec.ts
index 9afb78587..060fa75de 100644
--- a/src/spec/CollisionContactSpec.ts
+++ b/src/spec/CollisionContactSpec.ts
@@ -2,6 +2,7 @@ import * as ex from '@excalibur';
import { TransformComponent } from '@excalibur';
import { EulerIntegrator } from '../engine/Collision/Integrator';
import { MotionComponent } from '../engine/EntityComponentSystem/Components/MotionComponent';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
describe('A CollisionContact', () => {
let actorA: ex.Actor;
@@ -58,7 +59,7 @@ describe('A CollisionContact', () => {
null
);
- const solver = new ex.ArcadeSolver();
+ const solver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
solver.solve([cc]);
expect(actorA.pos.x).toBe(-0.5);
@@ -88,7 +89,7 @@ describe('A CollisionContact', () => {
null
);
- const solver = new ex.ArcadeSolver();
+ const solver = new ex.ArcadeSolver(DefaultPhysicsConfig.arcade);
solver.solve([cc]);
expect(actorAPreCollide).toHaveBeenCalledTimes(1);
@@ -119,9 +120,8 @@ describe('A CollisionContact', () => {
[new ex.Vector(10, 0)],
null
);
- ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here
- const solver = new ex.RealisticSolver();
-
+ // slop is normally 1 pixel, we are testing at a pixel scale here
+ const solver = new ex.RealisticSolver({...DefaultPhysicsConfig.realistic, slop: 0});
// Realistic solver converges over time
for (let i = 0; i < 4; i++) {
solver.solve([cc]);
@@ -166,8 +166,8 @@ describe('A CollisionContact', () => {
[new ex.Vector(10, 0)],
null
);
- ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here
- const solver = new ex.RealisticSolver();
+ // slop is normally 1 pixel, we are testing at a pixel scale here
+ const solver = new ex.RealisticSolver({ ...DefaultPhysicsConfig.realistic, slop: 0});
solver.solve([cc]);
// Realistic solver uses velocity impulses to correct overlap
@@ -211,8 +211,7 @@ describe('A CollisionContact', () => {
[new ex.Vector(10, 0), new ex.Vector(10, 10)],
null
);
- ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here
- const solver = new ex.RealisticSolver();
+ const solver = new ex.RealisticSolver(DefaultPhysicsConfig.realistic);
solver.solve([cc]);
// Realistic solver uses velocity impulses to correct overlap
EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30);
@@ -250,7 +249,10 @@ describe('A CollisionContact', () => {
null
);
ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here
- const solver = new ex.RealisticSolver();
+ const solver = new ex.RealisticSolver({
+ ...DefaultPhysicsConfig.realistic,
+ slop: 0
+ });
solver.solve([cc]);
// Realistic solver uses velocity impulses to correct overlap
EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30);
@@ -287,7 +289,7 @@ describe('A CollisionContact', () => {
null
);
ex.Physics.slop = 0; // slop is normally 1 pixel, we are testing at a pixel scale here
- const solver = new ex.RealisticSolver();
+ const solver = new ex.RealisticSolver(DefaultPhysicsConfig.realistic);
solver.solve([cc]);
// Realistic solver uses velocity impulses to correct overlap
EulerIntegrator.integrate(actorA.get(TransformComponent), actorA.get(MotionComponent), ex.Vector.Zero, 30);
@@ -323,7 +325,7 @@ describe('A CollisionContact', () => {
);
- const solver = new ex.RealisticSolver();
+ const solver = new ex.RealisticSolver(DefaultPhysicsConfig.realistic);
solver.solve([cc]);
expect(emittedA).toBe(true);
diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts
index e275fb8c9..4fa64c63a 100644
--- a/src/spec/CollisionSpec.ts
+++ b/src/spec/CollisionSpec.ts
@@ -1,5 +1,6 @@
import * as ex from '@excalibur';
import { TestUtils } from './util/TestUtils';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
describe('A Collision', () => {
let actor1: ex.Actor = null;
@@ -22,7 +23,7 @@ describe('A Collision', () => {
});
afterEach(() => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Arcade;
engine.stop();
engine = null;
actor1 = null;
@@ -48,7 +49,7 @@ describe('A Collision', () => {
});
it('order of actors collision should not matter when an Active and Active Collision', () => {
- const collisionTree = new ex.DynamicTreeCollisionProcessor();
+ const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
actor1.body.collisionType = ex.CollisionType.Active;
actor2.body.collisionType = ex.CollisionType.Active;
@@ -65,7 +66,7 @@ describe('A Collision', () => {
});
it('order of actors collision should not matter when an Active and Passive Collision', () => {
- const collisionTree = new ex.DynamicTreeCollisionProcessor();
+ const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
actor1.body.collisionType = ex.CollisionType.Active;
actor2.body.collisionType = ex.CollisionType.Passive;
@@ -82,7 +83,7 @@ describe('A Collision', () => {
});
it('order of actors collision should not matter when an Active and PreventCollision', () => {
- const collisionTree = new ex.DynamicTreeCollisionProcessor();
+ const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
actor1.body.collisionType = ex.CollisionType.Active;
actor2.body.collisionType = ex.CollisionType.PreventCollision;
@@ -99,7 +100,7 @@ describe('A Collision', () => {
});
it('order of actors collision should not matter when an Active and Fixed', () => {
- const collisionTree = new ex.DynamicTreeCollisionProcessor();
+ const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
actor1.body.collisionType = ex.CollisionType.Active;
actor2.body.collisionType = ex.CollisionType.Fixed;
@@ -116,7 +117,7 @@ describe('A Collision', () => {
});
it('order of actors collision should not matter when an Fixed and Fixed', () => {
- const collisionTree = new ex.DynamicTreeCollisionProcessor();
+ const collisionTree = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
actor1.body.collisionType = ex.CollisionType.Fixed;
actor2.body.collisionType = ex.CollisionType.Fixed;
@@ -185,7 +186,7 @@ describe('A Collision', () => {
});
it('should not collide when active and passive', (done) => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
@@ -217,7 +218,7 @@ describe('A Collision', () => {
});
it('should emit a start collision once when objects start colliding', () => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
@@ -243,7 +244,7 @@ describe('A Collision', () => {
});
it('should emit a end collision once when objects stop colliding', () => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
@@ -269,7 +270,7 @@ describe('A Collision', () => {
});
it('should cancel out velocity when objects collide', () => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Arcade;
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;
@@ -286,7 +287,7 @@ describe('A Collision', () => {
});
it('should not cancel out velocity when objects move away', () => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Arcade;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Arcade;
const activeBlock = new ex.Actor({x: 350, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
@@ -304,7 +305,7 @@ describe('A Collision', () => {
});
it('should have the actor as the handler context for collisionstart', (done) => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
@@ -327,7 +328,7 @@ describe('A Collision', () => {
});
it('should have the actor as the handler context for collisionend', (done) => {
- ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.Realistic;
+ ex.Physics.collisionResolutionStrategy = ex.SolverStrategy.Realistic;
const activeBlock = new ex.Actor({x: 200, y: 200, width: 50, height: 50, color: ex.Color.Red.clone()});
activeBlock.body.collisionType = ex.CollisionType.Active;
diff --git a/src/spec/CompositeColliderSpec.ts b/src/spec/CompositeColliderSpec.ts
index bcdaabe3f..5ca5dad8c 100644
--- a/src/spec/CompositeColliderSpec.ts
+++ b/src/spec/CompositeColliderSpec.ts
@@ -1,6 +1,7 @@
import * as ex from '@excalibur';
import { BoundingBox, GameEvent, LineSegment, Projection, Ray, vec, Vector } from '@excalibur';
import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
describe('A CompositeCollider', () => {
beforeAll(() => {
jasmine.addAsyncMatchers(ExcaliburAsyncMatchers);
@@ -265,7 +266,7 @@ describe('A CompositeCollider', () => {
it('is separated into a series of colliders in the dynamic tree', () => {
const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]);
- const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor();
+ const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
dynamicTreeProcessor.track(compCollider);
expect(dynamicTreeProcessor.getColliders().length).toBe(2);
@@ -276,7 +277,7 @@ describe('A CompositeCollider', () => {
it('removes all colliders in the dynamic tree', () => {
const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]);
- const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor();
+ const dynamicTreeProcessor = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
dynamicTreeProcessor.track(compCollider);
expect(dynamicTreeProcessor.getColliders().length).toBe(2);
diff --git a/src/spec/DynamicTreeBroadphaseSpec.ts b/src/spec/DynamicTreeBroadphaseSpec.ts
index 8caecfe7e..badc9449a 100644
--- a/src/spec/DynamicTreeBroadphaseSpec.ts
+++ b/src/spec/DynamicTreeBroadphaseSpec.ts
@@ -1,4 +1,5 @@
import * as ex from '@excalibur';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
describe('A DynamicTree Broadphase', () => {
let actorA: ex.Actor;
@@ -27,13 +28,13 @@ describe('A DynamicTree Broadphase', () => {
});
it('can be constructed', () => {
- const dt = new ex.DynamicTreeCollisionProcessor();
+ const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
expect(dt).not.toBe(null);
});
it('can find collision pairs for actors that are potentially colliding', () => {
- const dt = new ex.DynamicTreeCollisionProcessor();
+ const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
dt.track(actorA.collider.get());
dt.track(actorB.collider.get());
dt.track(actorC.collider.get());
@@ -52,7 +53,7 @@ describe('A DynamicTree Broadphase', () => {
box
]);
const actor = new ex.Actor({collider: compCollider});
- const dt = new ex.DynamicTreeCollisionProcessor();
+ const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
dt.track(compCollider);
const pairs = dt.broadphase([circle, box], 100);
@@ -68,7 +69,7 @@ describe('A DynamicTree Broadphase', () => {
]);
const actor = new ex.Actor({collider: compCollider, collisionType: ex.CollisionType.Active});
actor.body.vel = ex.vec(2000, 0); // extra fast to trigger the fast object detection
- const dt = new ex.DynamicTreeCollisionProcessor();
+ const dt = new ex.DynamicTreeCollisionProcessor(DefaultPhysicsConfig);
dt.track(compCollider);
const pairs = dt.broadphase([circle, box], 100);
diff --git a/src/spec/RealisticSolverSpec.ts b/src/spec/RealisticSolverSpec.ts
index f490b0414..64cb6e80b 100644
--- a/src/spec/RealisticSolverSpec.ts
+++ b/src/spec/RealisticSolverSpec.ts
@@ -1,7 +1,8 @@
import { ExcaliburMatchers } from 'excalibur-jasmine';
import * as ex from '@excalibur';
+import { DefaultPhysicsConfig } from '../engine/Collision/PhysicsConfig';
-describe('An ArcadeSolver', () => {
+describe('A RealisticSolver', () => {
beforeAll(() => {
jasmine.addMatchers(ExcaliburMatchers);
});
@@ -11,7 +12,7 @@ describe('An ArcadeSolver', () => {
});
it('should cancel zero overlap collisions during presolve', () => {
- const realisticSolver = new ex.RealisticSolver();
+ const realisticSolver = new ex.RealisticSolver(DefaultPhysicsConfig.realistic);
const player = new ex.Actor({
x: 0,
diff --git a/src/spec/UtilSpec.ts b/src/spec/UtilSpec.ts
index 59f59d495..087f980e3 100644
--- a/src/spec/UtilSpec.ts
+++ b/src/spec/UtilSpec.ts
@@ -1,7 +1,28 @@
import * as ex from '@excalibur';
+import { watchDeep } from '../engine/Util/Watch';
describe('Utility functions', () => {
+ it('can watch deep', () => {
+ const deepObject = {
+ top: {
+ oneLevel: {
+ twoLevel: {
+ value: true
+ }
+ }
+ }
+ };
+
+ const changed = jasmine.createSpy('changed');
+ const watched = watchDeep(deepObject, changed);
+ watched.top.oneLevel.twoLevel.value = false;
+
+ expect(changed).toHaveBeenCalledWith(deepObject);
+ expect(watched.top.oneLevel.twoLevel.value).toEqual(false);
+ expect(deepObject.top.oneLevel.twoLevel.value).toEqual(false);
+ });
+
it('can clamp a number to a maximum and minimum', () => {
expect(ex.clamp(0, 10, 20)).toBe(10);
expect(ex.clamp(15, 10, 20)).toBe(15);