diff options
Diffstat (limited to 'engine/systems')
-rw-r--r-- | engine/systems/Collision.ts | 215 | ||||
-rw-r--r-- | engine/systems/FacingDirection.ts | 36 | ||||
-rw-r--r-- | engine/systems/Input.ts | 83 | ||||
-rw-r--r-- | engine/systems/Physics.ts | 91 | ||||
-rw-r--r-- | engine/systems/Render.ts | 55 | ||||
-rw-r--r-- | engine/systems/System.ts | 12 | ||||
-rw-r--r-- | engine/systems/WallBounds.ts | 36 | ||||
-rw-r--r-- | engine/systems/index.ts | 8 | ||||
-rw-r--r-- | engine/systems/names.ts | 8 |
9 files changed, 544 insertions, 0 deletions
diff --git a/engine/systems/Collision.ts b/engine/systems/Collision.ts new file mode 100644 index 0000000..4fcb906 --- /dev/null +++ b/engine/systems/Collision.ts @@ -0,0 +1,215 @@ +import { SystemNames, System } from "."; +import { + Mass, + BoundingBox, + ComponentNames, + Jump, + Velocity, + Moment, +} from "../components"; +import { Game } from "../Game"; +import { PhysicsConstants } from "../config"; +import { Entity } from "../entities"; +import type { Dimension2D } from "../interfaces"; +import { QuadTree } from "../structures"; + +export class Collision extends System { + private static readonly COLLIDABLE_COMPONENT_NAMES = [ + ComponentNames.Collide, + ComponentNames.TopCollidable, + ]; + private static readonly QUADTREE_MAX_LEVELS = 10; + private static readonly QUADTREE_SPLIT_THRESHOLD = 10; + + private quadTree: QuadTree; + + constructor(screenDimensions: Dimension2D) { + super(SystemNames.Collision); + + this.quadTree = new QuadTree( + { x: 0, y: 0 }, + screenDimensions, + Collision.QUADTREE_MAX_LEVELS, + Collision.QUADTREE_SPLIT_THRESHOLD + ); + } + + public update(dt: number, game: Game) { + // rebuild the quadtree + this.quadTree.clear(); + + const entitiesToAddToQuadtree: Entity[] = []; + + Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) => + game.componentEntities.get(componentName) + ).forEach((entityIds: Set<number>) => + entityIds.forEach((id) => { + const entity = game.entities.get(id); + if (!entity.hasComponent(ComponentNames.BoundingBox)) { + return; + } + entitiesToAddToQuadtree.push(entity); + }) + ); + + entitiesToAddToQuadtree.forEach((entity) => { + const boundingBox = entity.getComponent<BoundingBox>( + ComponentNames.BoundingBox + ); + + this.quadTree.insert( + entity.id, + boundingBox.dimension, + boundingBox.center + ); + }); + + // find colliding entities and perform collisions + const collidingEntities = this.getCollidingEntities( + entitiesToAddToQuadtree, + game.entities + ); + + collidingEntities.forEach(([entityAId, entityBId]) => { + const [entityA, entityB] = [entityAId, entityBId].map((id) => + game.entities.get(id) + ); + this.performCollision(entityA, entityB); + }); + } + + private performCollision(entityA: Entity, entityB: Entity) { + const [entityABoundingBox, entityBBoundingBox] = [entityA, entityB].map( + (entity) => entity.getComponent<BoundingBox>(ComponentNames.BoundingBox) + ); + + let velocity: Velocity; + if (entityA.hasComponent(ComponentNames.Velocity)) { + velocity = entityA.getComponent<Velocity>(ComponentNames.Velocity); + } + + if ( + entityA.hasComponent(ComponentNames.Collide) && + entityB.hasComponent(ComponentNames.TopCollidable) && + entityABoundingBox.center.y <= entityBBoundingBox.center.y && + velocity && + velocity.dCartesian.dy >= 0 // don't apply "floor" logic when coming through the bottom + ) { + if (entityBBoundingBox.rotation != 0) { + throw new Error( + `entity with id ${entityB.id} has TopCollidable component and a non-zero rotation. that is not (yet) supported.` + ); + } + + // remove previous velocity in the y axis + velocity.dCartesian.dy = 0; + + // apply normal force + if (entityA.hasComponent(ComponentNames.Gravity)) { + const mass = entityA.getComponent<Mass>(ComponentNames.Mass).mass; + const F_n = -mass * PhysicsConstants.GRAVITY; + + entityA.getComponent<Forces>(ComponentNames.Forces).forces.push({ + fCartesian: { fy: F_n }, + }); + } + + // reset the entities' jump + if (entityA.hasComponent(ComponentNames.Jump)) { + entityA.getComponent<Jump>(ComponentNames.Jump).canJump = true; + } + + entityABoundingBox.center.y = + entityBBoundingBox.center.y - + entityBBoundingBox.dimension.height / 2 - + this.getDyToPushOutOfFloor(entityABoundingBox, entityBBoundingBox); + } + } + + private getCollidingEntities( + collidableEntities: Entity[], + entityMap: Map<number, Entity> + ): [number, number][] { + const collidingEntityIds: [number, number] = []; + + for (const entity of collidableEntities) { + const boundingBox = entity.getComponent<BoundingBox>( + ComponentNames.BoundingBox + ); + + this.quadTree + .getNeighborIds({ + id: entity.id, + dimension: boundingBox.dimension, + center: boundingBox.center, + }) + .filter((neighborId) => neighborId != entity.id) + .forEach((neighborId) => { + const neighborBoundingBox = entityMap + .get(neighborId) + .getComponent<BoundingBox>(ComponentNames.BoundingBox); + + if (boundingBox.isCollidingWith(neighborBoundingBox)) { + collidingEntityIds.push([entity.id, neighborId]); + } + }); + } + + return collidingEntityIds; + } + + // ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ + private getDyToPushOutOfFloor( + entityBoundingBox: BoundingBox, + floorBoundingBox: BoundingBox + ): number { + const { + rotation, + center: { x, y }, + dimension: { width, height }, + } = entityBoundingBox; + + let rads = rotation * (Math.PI / 180); + if (rads >= Math.PI) { + rads -= Math.PI; // we have symmetry so we can skip two cases + } + + let boundedCollisionX = 0; // bounded x on the surface from width + let clippedX = 0; // x coordinate of the vertex below the surface + let outScribedRectangleHeight, dy, dx; + + if (rads <= Math.PI / 2) { + dx = (width * Math.cos(rads) - height * Math.sin(rads)) / 2; + outScribedRectangleHeight = + width * Math.sin(rads) + height * Math.cos(rads); + } else if (rads <= Math.PI) { + rads -= Math.PI / 2; + dx = (height * Math.cos(rads) - width * Math.sin(rads)) / 2; + outScribedRectangleHeight = + width * Math.cos(rads) + height * Math.sin(rads); + } + + if (x >= floorBoundingBox.center.x) { + clippedX = x + dx; + boundedCollisionX = Math.min( + floorBoundingBox.center.x + floorBoundingBox.dimension.width / 2, + clippedX + ); + return ( + outScribedRectangleHeight / 2 - + Math.max((clippedX - boundedCollisionX) * Math.tan(rads), 0) + ); + } + + clippedX = x - dx; + boundedCollisionX = Math.max( + floorBoundingBox.center.x - floorBoundingBox.dimension.width / 2, + clippedX + ); + + return ( + outScribedRectangleHeight / 2 - + Math.max((boundedCollisionX - clippedX) * Math.tan(rads), 0) + ); + } +} diff --git a/engine/systems/FacingDirection.ts b/engine/systems/FacingDirection.ts new file mode 100644 index 0000000..c6513ac --- /dev/null +++ b/engine/systems/FacingDirection.ts @@ -0,0 +1,36 @@ +import { + ComponentNames, + Velocity, + FacingDirection as FacingDirectionComponent, +} from "../components"; +import { Game } from "../Game"; +import type { Entity } from "../entities"; +import { System, SystemNames } from "./"; + +export class FacingDirection extends System { + constructor() { + super(SystemNames.FacingDirection); + } + + public update(_dt: number, game: Game) { + game.componentEntities + .get(ComponentNames.FacingDirection) + ?.forEach((entityId) => { + const entity = game.entities.get(entityId); + if (!entity.hasComponent(ComponentNames.Velocity)) { + return; + } + + const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); + const facingDirection = entity.getComponent<FacingDirectionComponent>( + ComponentNames.FacingDirection + ); + + if (velocity.dCartesian.dx > 0) { + entity.addComponent(facingDirection.facingRightSprite); + } else if (velocity.dCartesian.dx < 0) { + entity.addComponent(facingDirection.facingLeftSprite); + } + }); + } +} diff --git a/engine/systems/Input.ts b/engine/systems/Input.ts new file mode 100644 index 0000000..9a92163 --- /dev/null +++ b/engine/systems/Input.ts @@ -0,0 +1,83 @@ +import { + Jump, + Forces, + Acceleration, + ComponentNames, + Velocity, + Mass, +} from "../components"; +import { Game } from "../Game"; +import { KeyConstants, PhysicsConstants } from "../config"; +import type { Entity } from "../entities"; +import { Action } from "../interfaces"; +import { System, SystemNames } from "./"; + +export class Input extends System { + private keys: Set<string>; + private actionTimeStamps: Map<Action, number>; + + constructor() { + super(SystemNames.Input); + + this.keys = new Set<number>(); + this.actionTimeStamps = new Map<Action, number>(); + } + + public keyPressed(key: string) { + this.keys.add(key); + } + + public keyReleased(key: string) { + this.keys.delete(key); + } + + private hasSomeKey(keys: string[]): boolean { + return keys.some((key) => this.keys.has(key)); + } + + public update(dt: number, game: Game) { + game.componentEntities.get(ComponentNames.Control)?.forEach((entityId) => { + const entity = game.entities.get(entityId); + if (!entity.hasComponent(ComponentNames.Velocity)) { + return; + } + + const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); + + if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) { + velocity.dCartesian.dx = PhysicsConstants.PLAYER_MOVE_VEL; + } else if ( + this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT)) + ) { + velocity.dCartesian.dx = -PhysicsConstants.PLAYER_MOVE_VEL; + } else { + velocity.dCartesian.dx = 0; + } + }); + + game.componentEntities.get(ComponentNames.Jump)?.forEach((entityId) => { + const entity = game.entities.get(entityId); + const jump = entity.getComponent<Jump>(ComponentNames.Jump); + const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); + + if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP))) { + if (jump.canJump) { + this.actionTimeStamps.set(Action.JUMP, performance.now()); + + velocity.dCartesian.dy = PhysicsConstants.PLAYER_JUMP_INITIAL_VEL; + jump.canJump = false; + } + + if ( + performance.now() - this.actionTimeStamps.get(Action.JUMP) < + PhysicsConstants.MAX_JUMP_TIME_MS + ) { + const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass; + entity.getComponent<Forces>(ComponentNames.Forces)?.forces.push({ + fCartesian: { fy: mass * PhysicsConstants.PLAYER_JUMP_ACC }, + }); + } + } + }); + } +} diff --git a/engine/systems/Physics.ts b/engine/systems/Physics.ts new file mode 100644 index 0000000..7edeb41 --- /dev/null +++ b/engine/systems/Physics.ts @@ -0,0 +1,91 @@ +import { System, SystemNames } from "."; +import { + Acceleration, + BoundingBox, + ComponentNames, + Forces, + Gravity, + Velocity, + Mass, + Jump, +} from "../components"; +import { PhysicsConstants } from "../config"; +import type { Entity } from "../entities"; +import type { Force2D } from "../interfaces"; +import { Game } from "../Game"; + +export class Physics extends System { + constructor() { + super(SystemNames.Physics); + } + + public update(dt: number, game: Game): void { + game.componentEntities.get(ComponentNames.Forces)?.forEach((entityId) => { + const entity = game.entities.get(entityId); + + const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass; + const forces = entity.getComponent<Forces>(ComponentNames.Forces).forces; + const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); + const inertia = entity.getComponent<Moment>( + ComponentNames.Moment + ).inertia; + + // F_g = mg, applied only until terminal velocity is reached + if (entity.hasComponent(ComponentNames.Gravity)) { + const gravity = entity.getComponent<Gravity>(ComponentNames.Gravity); + if (velocity.dCartesian.dy <= gravity.terminalVelocity) { + forces.push({ + fCartesian: { + fy: mass * PhysicsConstants.GRAVITY, + }, + }); + } + } + + // ma = Σ(F), Iα = Σ(T) + const sumOfForces = forces.reduce( + (accum: Force2D, { fCartesian, torque }: Force2D) => ({ + fCartesian: { + fx: accum.fCartesian.fx + (fCartesian?.fx ?? 0), + fy: accum.fCartesian.fy + (fCartesian?.fy ?? 0), + }, + torque: accum.torque + (torque ?? 0), + }), + { fCartesian: { fx: 0, fy: 0 }, torque: 0 } + ); + + // integrate accelerations + const [ddy, ddx] = [ + sumOfForces.fCartesian.fy, + sumOfForces.fCartesian.fx, + ].map((x) => x / mass); + velocity.dCartesian.dx += ddx * dt; + velocity.dCartesian.dy += ddy * dt; + velocity.dTheta += (sumOfForces.torque * dt) / inertia; + // clear the forces + entity.getComponent<Forces>(ComponentNames.Forces).forces = []; + + // maybe we fell off the floor + if (ddy > 0 && entity.hasComponent(ComponentNames.Jump)) { + entity.getComponent<Jump>(ComponentNames.Jump).canJump = false; + } + }); + + game.componentEntities.get(ComponentNames.Velocity)?.forEach((entityId) => { + const entity = game.entities.get(entityId); + const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); + const boundingBox = entity.getComponent<BoundingBox>( + ComponentNames.BoundingBox + ); + + // integrate velocity + boundingBox.center.x += velocity.dCartesian.dx * dt; + boundingBox.center.y += velocity.dCartesian.dy * dt; + boundingBox.rotation += velocity.dTheta * dt; + boundingBox.rotation = + (boundingBox.rotation < 0 + ? 360 + boundingBox.rotation + : boundingBox.rotation) % 360; + }); + } +} diff --git a/engine/systems/Render.ts b/engine/systems/Render.ts new file mode 100644 index 0000000..b5479e1 --- /dev/null +++ b/engine/systems/Render.ts @@ -0,0 +1,55 @@ +import { System, SystemNames } from "."; +import { BoundingBox, ComponentNames, Sprite } from "../components"; +import type { Entity } from "../entities"; +import { Game } from "../Game"; +import type { DrawArgs } from "../interfaces"; +import { clamp } from "../utils"; + +export class Render extends System { + private ctx: CanvasRenderingContext2D; + + constructor(ctx: CanvasRenderingContext2D) { + super(SystemNames.Render); + this.ctx = ctx; + } + + public update(dt: number, game: Game) { + this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + + game.componentEntities.get(ComponentNames.Sprite)?.forEach((entityId) => { + const entity = game.entities.get(entityId); + const sprite = entity.getComponent<Sprite>(ComponentNames.Sprite); + sprite.update(dt); + + let drawArgs: DrawArgs; + if (entity.hasComponent(ComponentNames.BoundingBox)) { + const boundingBox = entity.getComponent<BoundingBox>( + ComponentNames.BoundingBox + ); + + // don't render if we're outside the screen + if ( + clamp( + boundingBox.center.y, + -boundingBox.dimension.height / 2, + this.ctx.canvas.height + boundingBox.dimension.height / 2 + ) != boundingBox.center.y || + clamp( + boundingBox.center.x, + -boundingBox.dimension.width / 2, + this.ctx.canvas.width + boundingBox.dimension.width / 2 + ) != boundingBox.center.x + ) { + return; + } + + drawArgs = { + center: boundingBox.center, + dimension: boundingBox.dimension, + rotation: boundingBox.rotation, + }; + } + sprite.draw(this.ctx, drawArgs); + }); + } +} diff --git a/engine/systems/System.ts b/engine/systems/System.ts new file mode 100644 index 0000000..1aad285 --- /dev/null +++ b/engine/systems/System.ts @@ -0,0 +1,12 @@ +import { Entity } from "../entities"; +import { Game } from "../Game"; + +export abstract class System { + public readonly name: string; + + constructor(name: string) { + this.name = name; + } + + abstract update(dt: number, game: Game): void; +} diff --git a/engine/systems/WallBounds.ts b/engine/systems/WallBounds.ts new file mode 100644 index 0000000..6ea2267 --- /dev/null +++ b/engine/systems/WallBounds.ts @@ -0,0 +1,36 @@ +import { System, SystemNames } from "."; +import { BoundingBox, ComponentNames } from "../components"; +import { Game } from "../Game"; +import type { Entity } from "../entities"; +import { clamp } from "../utils"; + +export class WallBounds extends System { + private screenWidth: number; + + constructor(screenWidth: number) { + super(SystemNames.WallBounds); + + this.screenWidth = screenWidth; + } + + public update(_dt: number, game: Game) { + game.componentEntities + .get(ComponentNames.WallBounded) + ?.forEach((entityId) => { + const entity = game.entities.get(entityId); + if (!entity.hasComponent(ComponentNames.BoundingBox)) { + return; + } + + const boundingBox = entity.getComponent<BoundingBox>( + ComponentNames.BoundingBox + ); + + boundingBox.center.x = clamp( + boundingBox.center.x, + boundingBox.dimension.width / 2, + this.screenWidth - boundingBox.dimension.width / 2 + ); + }); + } +} diff --git a/engine/systems/index.ts b/engine/systems/index.ts new file mode 100644 index 0000000..6cb6f35 --- /dev/null +++ b/engine/systems/index.ts @@ -0,0 +1,8 @@ +export * from "./names"; +export * from "./System"; +export * from "./Render"; +export * from "./Physics"; +export * from "./Input"; +export * from "./FacingDirection"; +export * from "./Collision"; +export * from "./WallBounds"; diff --git a/engine/systems/names.ts b/engine/systems/names.ts new file mode 100644 index 0000000..23f31fc --- /dev/null +++ b/engine/systems/names.ts @@ -0,0 +1,8 @@ +export namespace SystemNames { + export const Render = "Render"; + export const Physics = "Physics"; + export const FacingDirection = "FacingDirection"; + export const Input = "Input"; + export const Collision = "Collision"; + export const WallBounds = "WallBounds"; +} |