added a working physics system

This commit is contained in:
Joe Weaver 2020-12-22 13:18:10 -05:00
parent 4b8ebf360d
commit ea33e71619
10 changed files with 352 additions and 58 deletions

View File

@ -55,6 +55,9 @@ export interface Physical {
/** The shape of the collider for this physics object. */
collisionShape: Shape;
/** The offset of the collision shape from the center of the node */
colliderOffset: Vec2;
/** Represents whether this object can move or not. */
isStatic: boolean;
@ -84,6 +87,8 @@ export interface Physical {
isPlayer: boolean;
isColliding: boolean;
/*---------- FUNCTIONS ----------*/
/**
@ -104,7 +109,7 @@ export interface Physical {
* @param isCollidable Whether this object will be able to collide with other objects
* @param isStatic Whether this object will be static or not
*/
addPhysics: (collisionShape?: Shape, isCollidable?: boolean, isStatic?: boolean) => void;
addPhysics: (collisionShape?: Shape, colliderOffset?: Vec2, isCollidable?: boolean, isStatic?: boolean) => void;
/**
* Adds a trigger to this object for a specific group
@ -118,6 +123,11 @@ export interface Physical {
* @param layer The name of the layer
*/
setPhysicsLayer: (layer: String) => void;
/**
* If used before "move()", it will tell you the velocity of the node after its last movement
*/
getLastVelocity(): Vec2;
}
/**

View File

@ -2,6 +2,7 @@ import Shape from "./Shape";
import Vec2 from "../Vec2";
import MathUtils from "../../Utils/MathUtils";
import Circle from "./Circle";
import Debug from "../../Debug/Debug";
export default class AABB extends Shape {
@ -109,25 +110,20 @@ export default class AABB extends Shape {
* @param paddingX Pads the AABB in the x axis
* @param paddingY Pads the AABB in the y axis
*/
intersectSegment(point: Vec2, direction: Vec2, distance?: number, paddingX?: number, paddingY?: number): Hit {
// Scale by the distance if it has been provided
if(distance){
direction = direction.scaled(distance);
}
intersectSegment(point: Vec2, delta: Vec2, padding?: Vec2): Hit {
let paddingX = padding ? padding.x : 0;
let paddingY = padding ? padding.y : 0;
let _paddingX = paddingX ? paddingX : 0;
let _paddingY = paddingY ? paddingY : 0;
let scaleX = 1/direction.x;
let scaleY = 1/direction.y;
let scaleX = 1/delta.x;
let scaleY = 1/delta.y;
let signX = MathUtils.sign(scaleX);
let signY = MathUtils.sign(scaleY);
let tnearx = scaleX*(this.x - signX*(this.hw + _paddingX) - point.x);
let tneary = scaleX*(this.y - signY*(this.hh + _paddingY) - point.y);
let tfarx = scaleY*(this.x + signX*(this.hw + _paddingX) - point.x);
let tfary = scaleY*(this.y + signY*(this.hh + _paddingY) - point.y);
let tnearx = scaleX*(this.x - signX*(this.hw + paddingX) - point.x);
let tneary = scaleY*(this.y - signY*(this.hh + paddingY) - point.y);
let tfarx = scaleX*(this.x + signX*(this.hw + paddingX) - point.x);
let tfary = scaleY*(this.y + signY*(this.hh + paddingY) - point.y);
if(tnearx > tfary || tneary > tfarx){
// We aren't colliding - we clear one axis before intersecting another
@ -135,15 +131,29 @@ export default class AABB extends Shape {
}
let tnear = Math.max(tnearx, tneary);
// Double check for NaNs
if(tnearx !== tnearx){
tnear = tneary;
} else if (tneary !== tneary){
tnear = tnearx;
}
let tfar = Math.min(tfarx, tfary);
if(tnear === -Infinity){
return null;
}
if(tnear >= 1 || tfar <= 0){
return null;
}
// We are colliding
let hit = new Hit();
hit.t = MathUtils.clamp01(tnear);
hit.time = MathUtils.clamp01(tnear);
hit.nearTimes.x = tnearx;
hit.nearTimes.y = tneary;
if(tnearx > tneary){
// We hit on the left or right size
@ -154,10 +164,10 @@ export default class AABB extends Shape {
hit.normal.y = -signY;
}
hit.delta.x = (1.0 - hit.t) * -direction.x;
hit.delta.y = (1.0 - hit.t) * -direction.y;
hit.pos.x = point.x + direction.x * hit.t;
hit.pos.y = point.y + direction.y * hit.t;
hit.delta.x = (1.0 - hit.time) * -delta.x;
hit.delta.y = (1.0 - hit.time) * -delta.y;
hit.pos.x = point.x + delta.x * hit.time;
hit.pos.y = point.y + delta.y * hit.time;
return hit;
}
@ -241,7 +251,8 @@ export default class AABB extends Shape {
}
export class Hit {
t: number;
time: number;
nearTimes: Vec2 = Vec2.ZERO;
pos: Vec2 = Vec2.ZERO;
delta: Vec2 = Vec2.ZERO;
normal: Vec2 = Vec2.ZERO;

View File

@ -94,6 +94,7 @@ export default abstract class CanvasNode extends GameNode implements Region {
debugRender(): void {
super.debugRender();
Debug.drawBox(this.relativePosition, this.sizeWithZoom, false, Color.GREEN);
let color = this.isColliding ? Color.RED : Color.GREEN;
Debug.drawBox(this.relativePosition, this.sizeWithZoom, false, color);
}
}

View File

@ -31,6 +31,7 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
onCeiling: boolean;
active: boolean;
collisionShape: Shape;
colliderOffset: Vec2;
isStatic: boolean;
isCollidable: boolean;
isTrigger: boolean;
@ -41,6 +42,7 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
collidedWithTilemap: boolean;
physicsLayer: number;
isPlayer: boolean;
isColliding: boolean = false;
/*---------- ACTOR ----------*/
_ai: AI;
@ -128,7 +130,7 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
* @param isCollidable Whether this is collidable or not. True by default.
* @param isStatic Whether this is static or not. False by default
*/
addPhysics = (collisionShape?: Shape, isCollidable: boolean = true, isStatic: boolean = false): void => {
addPhysics = (collisionShape?: Shape, colliderOffset?: Vec2, isCollidable: boolean = true, isStatic: boolean = false): void => {
this.hasPhysics = true;
this.moving = false;
this.onGround = false;
@ -154,6 +156,12 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
throw "No collision shape specified for physics object."
}
if(colliderOffset){
this.colliderOffset = colliderOffset;
} else {
this.colliderOffset = Vec2.ZERO;
}
this.sweptRect = this.collisionShape.getBoundingRect();
this.scene.getPhysicsManager().registerObject(this);
}
@ -171,6 +179,10 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
this.scene.getPhysicsManager().setLayer(this, layer);
}
getLastVelocity(): Vec2 {
return this._velocity;
}
/*---------- ACTOR ----------*/
get ai(): AI {
return this._ai;
@ -251,7 +263,7 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
*/
protected positionChanged(): void {
if(this.hasPhysics){
this.collisionShape.center = this.position;
this.collisionShape.center = this.position.clone().add(this.colliderOffset);
}
};
@ -260,11 +272,17 @@ export default abstract class GameNode implements Positioned, Unique, Updateable
}
debugRender(): void {
Debug.drawPoint(this.relativePosition, Color.GREEN);
let color = this.isColliding ? Color.RED : Color.GREEN;
Debug.drawPoint(this.relativePosition, color);
// If velocity is not zero, draw a vector for it
if(this._velocity && !this._velocity.isZero()){
Debug.drawRay(this.relativePosition, this._velocity.clone().scaleTo(20).add(this.relativePosition), Color.GREEN);
Debug.drawRay(this.relativePosition, this._velocity.clone().scaleTo(20).add(this.relativePosition), color);
}
// If this has a collider, draw it
if(this.isCollidable && this.collisionShape){
Debug.drawBox(this.collisionShape.center, this.collisionShape.halfSize, false, Color.RED);
}
}
}

View File

@ -27,7 +27,7 @@ export default class OrthogonalTilemap extends Tilemap {
// The size of the tilemap on the canvas
this.size.set(this.numCols * this.tileSize.x, this.numRows * this.tileSize.y);
this.position.copy(this.size);
this.position.copy(this.size.scaled(0.5));
this.data = layer.data;
this.visible = layer.visible;

View File

@ -91,6 +91,12 @@ export default class BasicPhysicsManager extends PhysicsManager {
resolveCollision(node1: Physical, node2: Physical, firstContact: Vec2, lastContact: Vec2, collidingX: boolean, collidingY: boolean): void {
// Handle collision
if( (firstContact.x < 1 || collidingX) && (firstContact.y < 1 || collidingY)){
if(node1.isPlayer){
node1.isColliding = true;
} else if(node2.isPlayer){
node2.isColliding = true;
}
// We are colliding. Check for any triggers
let group1 = node1.group;
let group2 = node2.group;
@ -109,7 +115,7 @@ export default class BasicPhysicsManager extends PhysicsManager {
}
if(collidingX && collidingY){
// If we're already intersecting, freak out I guess? Probably should handle this in some way for if nodes get spawned inside of tiles
// If we're already intersecting, resolve the current collision
} else if(node1.isCollidable && node2.isCollidable) {
// We aren't already colliding, and both nodes can collide, so this is a new collision.
@ -264,6 +270,11 @@ export default class BasicPhysicsManager extends PhysicsManager {
node.onCeiling = false;
node.onWall = false;
node.collidedWithTilemap = false;
node.isColliding = false;
if(node.isPlayer){
Debug.log("pvel", "Player Velocity:", node._velocity.toString());
}
// Update the swept shapes of each node
if(node.moving){

View File

@ -0,0 +1,229 @@
import GameNode from "../Nodes/GameNode";
import { Physical, Updateable } from "../DataTypes/Interfaces/Descriptors";
import Tilemap from "../Nodes/Tilemap";
import PhysicsManager from "./PhysicsManager";
import Vec2 from "../DataTypes/Vec2";
import Debug from "../Debug/Debug";
import Color from "../Utils/Color";
import AABB from "../DataTypes/Shapes/AABB";
import OrthogonalTilemap from "../Nodes/Tilemaps/OrthogonalTilemap";
export default class TestPhysicsManager extends PhysicsManager {
/** The array of static nodes */
protected staticNodes: Array<Physical>;
/** The array of dynamic nodes */
protected dynamicNodes: Array<Physical>;
/** The array of tilemaps */
protected tilemaps: Array<Tilemap>;
constructor(){
super();
this.staticNodes = new Array();
this.dynamicNodes = new Array();
this.tilemaps = new Array();
}
/**
* Add a new physics object to be updated with the physics system
* @param node The node to be added to the physics system
*/
registerObject(node: GameNode): void {
if(node.isStatic){
// Static and not collidable
this.staticNodes.push(node);
} else {
// Dynamic and not collidable
this.dynamicNodes.push(node);
}
}
/**
* Registers a tilemap with this physics manager
* @param tilemap
*/
registerTilemap(tilemap: Tilemap): void {
this.tilemaps.push(tilemap);
}
setLayer(node: GameNode, layer: string): void {
node.physicsLayer = this.layerMap.get(layer);
}
/**
* Updates the physics
* @param deltaT
*/
update(deltaT: number): void {
/* ALGORITHM:
In an effort to keep things simple and working effectively, each dynamic node will resolve its
collisions considering the rest of the world as static.
Collision detecting will happen first. This can be considered a broad phase, but it is not especially
efficient, as it does not need to be for this game engine. Every dynamic node is checked against every
other node for collision area. If collision area is non-zero (meaning the current node sweeps into another),
it is added to a list of hits.
INITIALIZATION:
- Physics constants are reset
- Swept shapes are recalculated. If a node isn't moving, it is skipped.
COLLISION DETECTION:
- For a node, collision area will be calculated using the swept AABB of the node against every other AABB in a static state
- These collisions will be sorted by area in descending order
COLLISION RESOLUTION:
- For each hit, time of collision is calculated using a swept line through the AABB of the static node expanded
with minkowski sums (discretely, but the concept is there)
- The collision is resolved based on the near time of the collision (from method of separated axes)
- X is resolved by near x, Y by near y.
- There is some fudging to allow for sliding along walls of separate colliders. Sorting by area also helps with this.
- Corner to corner collisions are resolve to favor x-movement. This is in consideration of platformers, to give
the player some help with jumps
Pros:
- Everything happens with a consistent time. There is a distinct before and after for each resolution.
- No back-tracking needs to be done. Once we resolve a node, it is definitively resolved.
Cons:
- Nodes that are processed early have movement priority over other nodes. This can lead to some undesirable interactions.
*/
for(let node of this.dynamicNodes){
/*---------- INITIALIZATION PHASE ----------*/
// Clear frame dependent boolean values for each node
node.onGround = false;
node.onCeiling = false;
node.onWall = false;
node.collidedWithTilemap = false;
// Update the swept shapes of each node
if(node.moving){
// If moving, reflect that in the swept shape
node.sweptRect.sweep(node._velocity, node.collisionShape.center, node.collisionShape.halfSize);
} else {
// If our node isn't moving, don't bother to check it (other nodes will detect if they run into it)
node._velocity.zero();
continue;
}
/*---------- DETECTION PHASE ----------*/
// Gather a set of overlaps
let overlaps = new Array<AreaCollision>();
// First, check this node against every static node (order doesn't actually matter here, since we sort anyways)
for(let other of this.staticNodes){
let collider = other.collisionShape.getBoundingRect();
let area = node.sweptRect.overlapArea(collider);
if(area > 0){
// We had a collision
overlaps.push(new AreaCollision(area, collider));
}
}
// Then, check it against every dynamic node
for(let other of this.dynamicNodes){
let collider = other.collisionShape.getBoundingRect();
let area = node.sweptRect.overlapArea(collider);
if(area > 0){
// We had a collision
overlaps.push(new AreaCollision(area, collider));
}
}
// Lastly, gather a set of AABBs from the tilemap.
// This step involves the most extra work, so it is abstracted into a method
for(let tilemap of this.tilemaps){
if(tilemap instanceof OrthogonalTilemap){
this.collideWithOrthogonalTilemap(node, tilemap, overlaps);
}
}
// Sort the overlaps by area
overlaps = overlaps.sort((a, b) => b.area - a.area);
/*---------- RESOLUTION PHASE ----------*/
// For every overlap, determine if we need to collide with it and when
for(let other of overlaps){
// Do a swept line test on the static AABB with this AABB size as padding (this is basically using a minkowski sum!)
// Start the sweep at the position of this node with a delta of _velocity
const point = node.collisionShape.center;
const delta = node._velocity;
const padding = node.collisionShape.halfSize;
const otherAABB = other.collider;
const hit = otherAABB.intersectSegment(node.collisionShape.center, node._velocity, node.collisionShape.halfSize);
if(hit !== null){
// We got a hit, resolve with the time inside of the hit
let tnearx = hit.nearTimes.x;
let tneary = hit.nearTimes.y;
// Allow edge clipping (edge overlaps don't count, only area overlaps)
// Importantly don't allow both cases to be true. Then we clip through corners. Favor x to help players land jumps
if(tnearx < 1.0 && (point.y === otherAABB.top - padding.y || point.y === otherAABB.bottom + padding.y) && delta.x !== 0) {
tnearx = 1.0;
} else if(tneary < 1.0 && (point.x === otherAABB.left - padding.x || point.x === otherAABB.right + padding.x) && delta.y !== 0) {
tneary = 1.0;
}
if(hit.nearTimes.x >= 0 && hit.nearTimes.x < 1){
node._velocity.x = node._velocity.x * tnearx;
}
if(hit.nearTimes.y >= 0 && hit.nearTimes.y < 1){
node._velocity.y = node._velocity.y * tneary;
}
}
}
// Resolve the collision with the node, and move it
node.finishMove();
}
}
collideWithOrthogonalTilemap(node: Physical, tilemap: OrthogonalTilemap, overlaps: Array<AreaCollision>): void {
// Get the min and max x and y coordinates of the moving node
let min = new Vec2(node.sweptRect.left, node.sweptRect.top);
let max = new Vec2(node.sweptRect.right, node.sweptRect.bottom);
// Convert the min/max x/y to the min and max row/col in the tilemap array
let minIndex = tilemap.getColRowAt(min);
let maxIndex = tilemap.getColRowAt(max);
let tileSize = tilemap.getTileSize();
// Loop over all possible tiles (which isn't many in the scope of the velocity per frame)
for(let col = minIndex.x; col <= maxIndex.x; col++){
for(let row = minIndex.y; row <= maxIndex.y; row++){
if(tilemap.isTileCollidable(col, row)){
// Get the position of this tile
let tilePos = new Vec2(col * tileSize.x + tileSize.x/2, row * tileSize.y + tileSize.y/2);
// Create a new collider for this tile
let collider = new AABB(tilePos, tileSize.scaled(1/2));
// Calculate collision area between the node and the tile
let area = node.sweptRect.overlapArea(collider);
if(area > 0){
// We had a collision
overlaps.push(new AreaCollision(area, collider));
}
}
}
}
}
}
class AreaCollision {
area: number;
collider: AABB;
constructor(area: number, collider: AABB){
this.area = area;
this.collider = collider;
}
}

View File

@ -187,7 +187,7 @@ export default class TilemapFactory {
// Now we have sprite. Associate it with our physics object if there is one
if(hasPhysics){
sprite.addPhysics(sprite.boundary.clone(), isCollidable, isStatic);
sprite.addPhysics(sprite.boundary.clone(), Vec2.ZERO, isCollidable, isStatic);
sprite.group = group;
}
}

View File

@ -1,55 +1,69 @@
import AABB from "../../DataTypes/Shapes/AABB";
import { TiledTilemapData } from "../../DataTypes/Tilesets/TiledData";
import Vec2 from "../../DataTypes/Vec2";
import Debug from "../../Debug/Debug";
import InputHandler from "../../Input/InputHandler";
import InputReceiver from "../../Input/InputReceiver";
import CanvasNode from "../../Nodes/CanvasNode";
import { GraphicType } from "../../Nodes/Graphics/GraphicTypes";
import BasicPhysicsManager from "../../Physics/BasicPhysicsManager";
import TestPhysicsManager from "../../Physics/TestPhysicsManager";
import Scene from "../../Scene/Scene";
import Color from "../../Utils/Color";
export default class TestScene extends Scene {
loadScene(){
this.load.tilemap("test", "assets/tilemaps/PhysicsTest.json");
}
startScene(){
// Opt into a custom physics manager
this.physicsManager = new BasicPhysicsManager(this.sceneOptions.physics);
this.physicsManager = new TestPhysicsManager();
let tilemap = <CanvasNode>this.add.tilemap("test")[0].getItems()[0];
let layer = this.getLayer("MovingObject");
layer.getItems().forEach(item => {
let timer = 0;
let dir = new Vec2(-1, 0);
item.update = (deltaT: number) => {
if(timer > 2){
timer = 0;
dir.scale(-1);
}
item.move(dir.scaled(100*deltaT));
timer += deltaT;
}
})
this.addLayer("main");
let player = this.add.graphic(GraphicType.RECT, "main", {position: new Vec2(100, 100), size: new Vec2(100, 100)});
player.addPhysics();
let player = this.add.graphic(GraphicType.RECT, "main", {position: new Vec2(50, 100), size: new Vec2(45, 45)});
player.color = Color.ORANGE;
player.addPhysics(new AABB(new Vec2(0, 0), new Vec2(15, 15)), new Vec2(0, 7.5));
player.update = (deltaT: number) => {
const input = InputReceiver.getInstance()
let xDir = (input.isPressed("a") ? -1 : 0) + (input.isPressed("d") ? 1 : 0);
let yDir = (input.isPressed("w") ? -1 : 0) + (input.isPressed("s") ? 1 : 0);
let yDir = input.isJustPressed("space") ? -1 : 0;
let dir = new Vec2(xDir, yDir);
dir.normalize();
let dir = new Vec2(xDir * 300 * deltaT, yDir*1000 * deltaT);
// Gravity
if(dir.y === 0){
dir.y = player.getLastVelocity().y + 50 * deltaT;
}
Debug.log("pvel", player.getLastVelocity());
if(!dir.isZero()){
player.move(dir.scale(deltaT * 300));
player.move(dir);
}
}
let block = this.add.graphic(GraphicType.RECT, "main", {position: new Vec2(300, 500), size: new Vec2(100, 100)});
block.color = Color.CYAN;
block.addPhysics(block.boundary, true, true);
let movingBlock = this.add.graphic(GraphicType.RECT, "main", {position: new Vec2(500, 200), size: new Vec2(100, 100)});
movingBlock.color = Color.CYAN;
movingBlock.addPhysics();
let timer = 0;
let dir = new Vec2(1, 0);
movingBlock.update = (deltaT: number) => {
if(timer > 0.5){
timer = 0;
dir.scale(-1);
}
movingBlock.move(dir.scaled(200*deltaT));
timer += deltaT;
}
player.isPlayer = true;
}
}

View File

@ -15,7 +15,7 @@ function main(){
game.start();
let sm = game.getSceneManager();
sm.addScene(MainMenu, {});
sm.addScene(TestScene, {});
}
CanvasRenderingContext2D.prototype.roundedRect = function(x: number, y: number, w: number, h: number, r: number): void {