summaryrefslogtreecommitdiff
path: root/engine/systems
diff options
context:
space:
mode:
Diffstat (limited to 'engine/systems')
-rw-r--r--engine/systems/Collision.ts215
-rw-r--r--engine/systems/FacingDirection.ts36
-rw-r--r--engine/systems/Input.ts83
-rw-r--r--engine/systems/Physics.ts91
-rw-r--r--engine/systems/Render.ts55
-rw-r--r--engine/systems/System.ts12
-rw-r--r--engine/systems/WallBounds.ts36
-rw-r--r--engine/systems/index.ts8
-rw-r--r--engine/systems/names.ts8
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";
+}