summaryrefslogtreecommitdiff
path: root/client/lib
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2023-07-19 20:38:24 -0700
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2023-07-19 20:38:24 -0700
commit0fd9fb097552686f2257c1aa689d797e80057bd1 (patch)
treeb8d0367bf7b62c049af60ace301ce1cffc08d821 /client/lib
downloadjumpstorm-0fd9fb097552686f2257c1aa689d797e80057bd1.tar.gz
jumpstorm-0fd9fb097552686f2257c1aa689d797e80057bd1.zip
initial commit
Diffstat (limited to 'client/lib')
-rw-r--r--client/lib/Game.ts70
-rw-r--r--client/lib/JumpStorm.ts54
-rw-r--r--client/lib/components/BoundingBox.ts97
-rw-r--r--client/lib/components/Collide.ts7
-rw-r--r--client/lib/components/Component.ts7
-rw-r--r--client/lib/components/Control.ts7
-rw-r--r--client/lib/components/FacingDirection.ts13
-rw-r--r--client/lib/components/Forces.ts17
-rw-r--r--client/lib/components/Gravity.ts13
-rw-r--r--client/lib/components/Jump.ts10
-rw-r--r--client/lib/components/Mass.ts10
-rw-r--r--client/lib/components/Moment.ts10
-rw-r--r--client/lib/components/Sprite.ts92
-rw-r--r--client/lib/components/TopCollidable.ts7
-rw-r--r--client/lib/components/Velocity.ts15
-rw-r--r--client/lib/components/WallBounded.ts7
-rw-r--r--client/lib/components/index.ts15
-rw-r--r--client/lib/components/names.ts15
-rw-r--r--client/lib/config/assets.ts40
-rw-r--r--client/lib/config/constants.ts34
-rw-r--r--client/lib/config/index.ts3
-rw-r--r--client/lib/config/sprites.ts49
-rw-r--r--client/lib/entities/Entity.ts33
-rw-r--r--client/lib/entities/Floor.ts31
-rw-r--r--client/lib/entities/Player.ts68
-rw-r--r--client/lib/entities/index.ts3
-rw-r--r--client/lib/interfaces/Action.ts5
-rw-r--r--client/lib/interfaces/Direction.ts6
-rw-r--r--client/lib/interfaces/Draw.ts9
-rw-r--r--client/lib/interfaces/LeaderBoardEntry.ts5
-rw-r--r--client/lib/interfaces/Vec2.ts22
-rw-r--r--client/lib/interfaces/index.ts5
-rw-r--r--client/lib/structures/QuadTree.ts154
-rw-r--r--client/lib/structures/index.ts1
-rw-r--r--client/lib/systems/Collision.ts214
-rw-r--r--client/lib/systems/FacingDirection.ts39
-rw-r--r--client/lib/systems/Input.ts86
-rw-r--r--client/lib/systems/Physics.ts94
-rw-r--r--client/lib/systems/Render.ts41
-rw-r--r--client/lib/systems/System.ts15
-rw-r--r--client/lib/systems/WallBounds.ts35
-rw-r--r--client/lib/systems/index.ts8
-rw-r--r--client/lib/systems/names.ts8
-rw-r--r--client/lib/utils/dotProduct.ts4
-rw-r--r--client/lib/utils/index.ts3
-rw-r--r--client/lib/utils/normalizeVector.ts8
-rw-r--r--client/lib/utils/rotateVector.ts15
47 files changed, 1504 insertions, 0 deletions
diff --git a/client/lib/Game.ts b/client/lib/Game.ts
new file mode 100644
index 0000000..d6ffb47
--- /dev/null
+++ b/client/lib/Game.ts
@@ -0,0 +1,70 @@
+import { Entity } from "./entities";
+import { System } from "./systems";
+
+export class Game {
+ private entities: Map<number, Entity>;
+ private systems: Map<string, System>;
+ private systemOrder: string[];
+
+ private running: boolean;
+ private lastTimeStamp: number;
+
+ constructor() {
+ this.running = false;
+ this.systemOrder = [];
+ this.systems = new Map();
+ this.entities = new Map();
+ }
+
+ public start() {
+ this.lastTimeStamp = performance.now();
+ this.running = true;
+ }
+
+ public addEntity(entity: Entity) {
+ this.entities.set(entity.id, entity);
+ }
+
+ public getEntity(id: number): Entity {
+ return this.entities.get(id);
+ }
+
+ public removeEntity(id: number) {
+ this.entities.delete(id);
+ }
+
+ public addSystem(system: System) {
+ if (!this.systemOrder.includes(system.name)) {
+ this.systemOrder.push(system.name);
+ }
+ this.systems.set(system.name, system);
+ }
+
+ public getSystem(name: string): System {
+ return this.systems.get(name);
+ }
+
+ public doGameLoop = (timeStamp: number) => {
+ if (!this.running) {
+ return;
+ }
+
+ const dt = timeStamp - this.lastTimeStamp;
+ this.lastTimeStamp = timeStamp;
+
+ const componentEntities = new Map<string, Set<number>>();
+ this.entities.forEach((entity) =>
+ entity.getComponents().forEach((component) => {
+ if (!componentEntities.has(component.name)) {
+ componentEntities.set(component.name, new Set<number>([entity.id]));
+ return;
+ }
+ componentEntities.get(component.name).add(entity.id);
+ })
+ );
+
+ this.systemOrder.forEach((systemName) => {
+ this.systems.get(systemName).update(dt, this.entities, componentEntities);
+ });
+ };
+}
diff --git a/client/lib/JumpStorm.ts b/client/lib/JumpStorm.ts
new file mode 100644
index 0000000..c76d9bc
--- /dev/null
+++ b/client/lib/JumpStorm.ts
@@ -0,0 +1,54 @@
+import { Floor, Player } from "./entities";
+import { Game } from "./Game";
+import {
+ WallBounds,
+ FacingDirection,
+ Render,
+ Physics,
+ Input,
+ Collision,
+} from "./systems";
+
+export class JumpStorm {
+ private game: Game;
+
+ constructor(ctx: CanvasRenderingContext2D) {
+ this.game = new Game();
+
+ [
+ this.createInputSystem(),
+ new FacingDirection(),
+ new Physics(),
+ new Collision(),
+ new WallBounds(ctx.canvas.width),
+ new Render(ctx),
+ ].forEach((system) => this.game.addSystem(system));
+
+ [new Floor(160), new Player()].forEach((entity) =>
+ this.game.addEntity(entity)
+ );
+ }
+
+ public play() {
+ this.game.start();
+
+ const loop = (timestamp: number) => {
+ this.game.doGameLoop(timestamp);
+ requestAnimationFrame(loop); // tail call recursion! /s
+ };
+ requestAnimationFrame(loop);
+ }
+
+ private createInputSystem(): Input {
+ const inputSystem = new Input();
+
+ window.addEventListener("keydown", (e) => {
+ if (!e.repeat) {
+ inputSystem.keyPressed(e.key);
+ }
+ });
+ window.addEventListener("keyup", (e) => inputSystem.keyReleased(e.key));
+
+ return inputSystem;
+ }
+}
diff --git a/client/lib/components/BoundingBox.ts b/client/lib/components/BoundingBox.ts
new file mode 100644
index 0000000..2b1d648
--- /dev/null
+++ b/client/lib/components/BoundingBox.ts
@@ -0,0 +1,97 @@
+import { Component, ComponentNames } from ".";
+import type { Coord2D, Dimension2D } from "../interfaces";
+import { dotProduct, rotateVector, normalizeVector } from "../utils";
+
+export class BoundingBox extends Component {
+ public center: Coord2D;
+ public dimension: Dimension2D;
+ public rotation: number;
+
+ constructor(center: Coord2D, dimension: Dimension2D, rotation?: number) {
+ super(ComponentNames.BoundingBox);
+
+ this.center = center;
+ this.dimension = dimension;
+ this.rotation = rotation ?? 0;
+ }
+
+ public isCollidingWith(box: BoundingBox): boolean {
+ const boxes = [this.getVertices(), box.getVertices()];
+ for (const poly of boxes) {
+ for (let i = 0; i < poly.length; ++i) {
+ const [A, B] = [poly[i], poly[(i + 1) % poly.length]];
+ const normal: Coord2D = { x: B.y - A.y, y: A.x - B.x };
+
+ const [[minThis, maxThis], [minBox, maxBox]] = boxes.map((box) =>
+ box.reduce(
+ ([min, max], vertex) => {
+ const projection = dotProduct(normal, vertex);
+ return [Math.min(min, projection), Math.max(max, projection)];
+ },
+ [Infinity, -Infinity]
+ )
+ );
+
+ if (maxThis < minBox || maxBox < minThis) return false;
+ }
+ }
+
+ return true;
+ }
+
+ public getVertices(): Coord2D[] {
+ return [
+ { x: -this.dimension.width / 2, y: -this.dimension.height / 2 },
+ { x: -this.dimension.width / 2, y: this.dimension.height / 2 },
+ { x: this.dimension.width / 2, y: this.dimension.height / 2 },
+ { x: this.dimension.width / 2, y: -this.dimension.height / 2 },
+ ]
+ .map((vertex) => rotateVector(vertex, this.rotation))
+ .map((vertex) => {
+ return {
+ x: vertex.x + this.center.x,
+ y: vertex.y + this.center.y,
+ };
+ });
+ }
+
+ private getAxes() {
+ const corners: Coord2D[] = this.getVerticesRelativeToCenter();
+ const axes: Coord2D[] = [];
+
+ for (let i = 0; i < corners.length; ++i) {
+ const [cornerA, cornerB] = [
+ corners[i],
+ corners[(i + 1) % corners.length],
+ ].map((corner) => rotateVector(corner, this.rotation));
+
+ axes.push(
+ normalizeVector({
+ x: cornerB.y - cornerA.y,
+ y: -(cornerB.x - cornerA.x),
+ })
+ );
+ }
+
+ return axes;
+ }
+
+ private project(axis: Coord2D): [number, number] {
+ const corners = this.getCornersRelativeToCenter();
+ let [min, max] = [Infinity, -Infinity];
+
+ for (const corner of corners) {
+ const rotated = rotateVector(corner, this.rotation);
+ const translated = {
+ x: rotated.x + this.center.x,
+ y: rotated.y + this.center.y,
+ };
+ const projection = dotProduct(translated, axis);
+
+ min = Math.min(projection, min);
+ max = Math.max(projection, max);
+ }
+
+ return [min, max];
+ }
+}
diff --git a/client/lib/components/Collide.ts b/client/lib/components/Collide.ts
new file mode 100644
index 0000000..889ecf8
--- /dev/null
+++ b/client/lib/components/Collide.ts
@@ -0,0 +1,7 @@
+import { Component, ComponentNames } from ".";
+
+export class Collide extends Component {
+ constructor() {
+ super(ComponentNames.Collide);
+ }
+}
diff --git a/client/lib/components/Component.ts b/client/lib/components/Component.ts
new file mode 100644
index 0000000..7331982
--- /dev/null
+++ b/client/lib/components/Component.ts
@@ -0,0 +1,7 @@
+export abstract class Component {
+ public readonly name: string;
+
+ constructor(name: string) {
+ this.name = name;
+ }
+}
diff --git a/client/lib/components/Control.ts b/client/lib/components/Control.ts
new file mode 100644
index 0000000..094ef1c
--- /dev/null
+++ b/client/lib/components/Control.ts
@@ -0,0 +1,7 @@
+import { Component, ComponentNames } from ".";
+
+export class Control extends Component {
+ constructor() {
+ super(ComponentNames.Control);
+ }
+}
diff --git a/client/lib/components/FacingDirection.ts b/client/lib/components/FacingDirection.ts
new file mode 100644
index 0000000..1c701a3
--- /dev/null
+++ b/client/lib/components/FacingDirection.ts
@@ -0,0 +1,13 @@
+import { Component, ComponentNames, Sprite } from ".";
+
+export class FacingDirection extends Component {
+ public readonly facingLeftSprite: Sprite;
+ public readonly facingRightSprite: Sprite;
+
+ constructor(facingLeftSprite: Sprite, facingRightSprite: Sprite) {
+ super(ComponentNames.FacingDirection);
+
+ this.facingLeftSprite = facingLeftSprite;
+ this.facingRightSprite = facingRightSprite;
+ }
+}
diff --git a/client/lib/components/Forces.ts b/client/lib/components/Forces.ts
new file mode 100644
index 0000000..bf540a1
--- /dev/null
+++ b/client/lib/components/Forces.ts
@@ -0,0 +1,17 @@
+import type { Accel2D, Force2D } from "../interfaces";
+import { Component } from "./Component";
+import { ComponentNames } from ".";
+
+/**
+ * A list of forces and torque, (in newtons, and newton-meters respectively)
+ * to apply on one Physics system update (after which, they are cleared).
+ */
+export class Forces extends Component {
+ public forces: Force2D[];
+
+ constructor(forces?: Force2D[]) {
+ super(ComponentNames.Forces);
+
+ this.forces = forces ?? [];
+ }
+}
diff --git a/client/lib/components/Gravity.ts b/client/lib/components/Gravity.ts
new file mode 100644
index 0000000..89fcb67
--- /dev/null
+++ b/client/lib/components/Gravity.ts
@@ -0,0 +1,13 @@
+import { ComponentNames, Component } from ".";
+
+export class Gravity extends Component {
+ private static DEFAULT_TERMINAL_VELOCITY = 5;
+
+ public terminalVelocity: number;
+
+ constructor(terminalVelocity?: number) {
+ super(ComponentNames.Gravity);
+ this.terminalVelocity =
+ terminalVelocity ?? Gravity.DEFAULT_TERMINAL_VELOCITY;
+ }
+}
diff --git a/client/lib/components/Jump.ts b/client/lib/components/Jump.ts
new file mode 100644
index 0000000..0b40767
--- /dev/null
+++ b/client/lib/components/Jump.ts
@@ -0,0 +1,10 @@
+import { Component, ComponentNames } from ".";
+
+export class Jump extends Component {
+ public canJump: boolean;
+
+ constructor() {
+ super(ComponentNames.Jump);
+ this.canJump = false;
+ }
+}
diff --git a/client/lib/components/Mass.ts b/client/lib/components/Mass.ts
new file mode 100644
index 0000000..daa2d71
--- /dev/null
+++ b/client/lib/components/Mass.ts
@@ -0,0 +1,10 @@
+import { Component, ComponentNames } from ".";
+
+export class Mass extends Component {
+ public mass: number;
+
+ constructor(mass: number) {
+ super(ComponentNames.Mass);
+ this.mass = mass;
+ }
+}
diff --git a/client/lib/components/Moment.ts b/client/lib/components/Moment.ts
new file mode 100644
index 0000000..3d0dd2f
--- /dev/null
+++ b/client/lib/components/Moment.ts
@@ -0,0 +1,10 @@
+import { Component, ComponentNames } from ".";
+
+export class Moment extends Component {
+ public inertia: number;
+
+ constructor(inertia: number) {
+ super(ComponentNames.Moment);
+ this.inertia = inertia;
+ }
+}
diff --git a/client/lib/components/Sprite.ts b/client/lib/components/Sprite.ts
new file mode 100644
index 0000000..90e1389
--- /dev/null
+++ b/client/lib/components/Sprite.ts
@@ -0,0 +1,92 @@
+import { Component, ComponentNames } from ".";
+import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces";
+
+export class Sprite extends Component {
+ private sheet: HTMLImageElement;
+
+ private spriteImgPos: Coord2D;
+ private spriteImgDimensions: Dimension2D;
+
+ private msPerFrame: number;
+ private msSinceLastFrame: number;
+ private currentFrame: number;
+ private numFrames: number;
+
+ constructor(
+ sheet: HTMLImageElement,
+ spriteImgPos: Coord2D,
+ spriteImgDimensions: Dimension2D,
+ msPerFrame: number,
+ numFrames: number
+ ) {
+ super(ComponentNames.Sprite);
+
+ this.sheet = sheet;
+ this.spriteImgPos = spriteImgPos;
+ this.spriteImgDimensions = spriteImgDimensions;
+ this.msPerFrame = msPerFrame;
+ this.numFrames = numFrames;
+
+ this.msSinceLastFrame = 0;
+ this.currentFrame = 0;
+ }
+
+ public update(dt: number) {
+ this.msSinceLastFrame += dt;
+ if (this.msSinceLastFrame >= this.msPerFrame) {
+ this.currentFrame = (this.currentFrame + 1) % this.numFrames;
+ this.msSinceLastFrame = 0;
+ }
+ }
+
+ public draw(ctx: CanvasRenderingContext2D, drawArgs: DrawArgs) {
+ const { center, rotation, tint, opacity } = drawArgs;
+
+ ctx.save();
+ ctx.translate(center.x, center.y);
+ if (rotation != 0) {
+ ctx.rotate(rotation * (Math.PI / 180));
+ }
+ ctx.translate(-center.x, -center.y);
+
+ if (opacity) {
+ ctx.globalAlpha = opacity;
+ }
+
+ ctx.drawImage(
+ this.sheet,
+ ...this.getSpriteArgs(),
+ ...this.getDrawArgs(drawArgs)
+ );
+
+ if (tint) {
+ ctx.globalAlpha = 0.5;
+ ctx.globalCompositeOperation = "source-atop";
+ ctx.fillStyle = tint;
+ ctx.fillRect(...this.getDrawArgs(drawArgs));
+ }
+
+ ctx.restore();
+ }
+
+ private getSpriteArgs(): [sx: number, sy: number, sw: number, sh: number] {
+ return [
+ this.spriteImgPos.x + this.currentFrame * this.spriteImgDimensions.width,
+ this.spriteImgPos.y,
+ this.spriteImgDimensions.width,
+ this.spriteImgDimensions.height,
+ ];
+ }
+
+ private getDrawArgs({
+ center,
+ dimension,
+ }: DrawArgs): [dx: number, dy: number, dw: number, dh: number] {
+ return [
+ center.x - dimension.width / 2,
+ center.y - dimension.height / 2,
+ dimension.width,
+ dimension.height,
+ ];
+ }
+}
diff --git a/client/lib/components/TopCollidable.ts b/client/lib/components/TopCollidable.ts
new file mode 100644
index 0000000..7fb147d
--- /dev/null
+++ b/client/lib/components/TopCollidable.ts
@@ -0,0 +1,7 @@
+import { Component, ComponentNames } from ".";
+
+export class TopCollidable extends Component {
+ constructor() {
+ super(ComponentNames.TopCollidable);
+ }
+}
diff --git a/client/lib/components/Velocity.ts b/client/lib/components/Velocity.ts
new file mode 100644
index 0000000..119427d
--- /dev/null
+++ b/client/lib/components/Velocity.ts
@@ -0,0 +1,15 @@
+import type { Velocity2D } from "../interfaces";
+import { Component } from "./Component";
+import { ComponentNames } from ".";
+
+export class Velocity extends Component {
+ public dCartesian: Velocity2D;
+ public dTheta: number;
+
+ constructor(dCartesian: Velocity2D, dTheta: number) {
+ super(ComponentNames.Velocity);
+
+ this.dCartesian = dCartesian;
+ this.dTheta = dTheta;
+ }
+}
diff --git a/client/lib/components/WallBounded.ts b/client/lib/components/WallBounded.ts
new file mode 100644
index 0000000..5f787e1
--- /dev/null
+++ b/client/lib/components/WallBounded.ts
@@ -0,0 +1,7 @@
+import { Component, ComponentNames } from ".";
+
+export class WallBounded extends Component {
+ constructor() {
+ super(ComponentNames.WallBounded);
+ }
+}
diff --git a/client/lib/components/index.ts b/client/lib/components/index.ts
new file mode 100644
index 0000000..67f1259
--- /dev/null
+++ b/client/lib/components/index.ts
@@ -0,0 +1,15 @@
+export * from "./Component";
+export * from "./BoundingBox";
+export * from "./Velocity";
+export * from "./Forces";
+export * from "./Sprite";
+export * from "./FacingDirection";
+export * from "./Jump";
+export * from "./TopCollidable";
+export * from "./Collide";
+export * from "./Control";
+export * from "./WallBounded";
+export * from "./Gravity";
+export * from "./Mass";
+export * from "./Moment";
+export * from "./names";
diff --git a/client/lib/components/names.ts b/client/lib/components/names.ts
new file mode 100644
index 0000000..e2ee3d3
--- /dev/null
+++ b/client/lib/components/names.ts
@@ -0,0 +1,15 @@
+export namespace ComponentNames {
+ export const Sprite = "Sprite";
+ export const BoundingBox = "BoundingBox";
+ export const Velocity = "Velocity";
+ export const FacingDirection = "FacingDirection";
+ export const Control = "Control";
+ export const Jump = "Jump";
+ export const TopCollidable = "TopCollidable";
+ export const Collide = "Collide";
+ export const WallBounded = "WallBounded";
+ export const Gravity = "Gravity";
+ export const Forces = "Forces";
+ export const Mass = "Mass";
+ export const Moment = "Moment";
+}
diff --git a/client/lib/config/assets.ts b/client/lib/config/assets.ts
new file mode 100644
index 0000000..51a5303
--- /dev/null
+++ b/client/lib/config/assets.ts
@@ -0,0 +1,40 @@
+import type { SpriteSpec } from "./sprites";
+import { SPRITE_SPECS } from "./sprites";
+
+export const IMAGES = new Map<string, HTMLImageElement>();
+
+export const loadSpritesIntoImageElements = (
+ spriteSpecs: Partial<SpriteSpec>[]
+): Promise<void>[] => {
+ const spritePromises: Promise<void>[] = [];
+
+ for (const spriteSpec of spriteSpecs) {
+ if (spriteSpec.sheet) {
+ const img = new Image();
+ img.src = spriteSpec.sheet;
+ IMAGES.set(spriteSpec.sheet, img);
+
+ spritePromises.push(
+ new Promise((resolve) => {
+ img.onload = () => resolve();
+ })
+ );
+ }
+
+ if (spriteSpec.states) {
+ spritePromises.push(
+ ...loadSpritesIntoImageElements(Object.values(spriteSpec.states))
+ );
+ }
+ }
+
+ return spritePromises;
+};
+
+export const loadAssets = () =>
+ Promise.all([
+ ...loadSpritesIntoImageElements(
+ Array.from(SPRITE_SPECS.keys()).map((key) => SPRITE_SPECS.get(key))
+ ),
+ // TODO: Sound
+ ]);
diff --git a/client/lib/config/constants.ts b/client/lib/config/constants.ts
new file mode 100644
index 0000000..27c8160
--- /dev/null
+++ b/client/lib/config/constants.ts
@@ -0,0 +1,34 @@
+import { Action } from "../interfaces";
+
+export namespace KeyConstants {
+ export const KeyActions: Record<string, Action> = {
+ a: Action.MOVE_LEFT,
+ ArrowLeft: Action.MOVE_LEFT,
+ d: Action.MOVE_RIGHT,
+ ArrowRight: Action.MOVE_RIGHT,
+ w: Action.JUMP,
+ ArrowUp: Action.JUMP,
+ };
+
+ export const ActionKeys: Map<Action, string[]> = Object.keys(
+ KeyActions
+ ).reduce((acc: Map<Action, string[]>, key) => {
+ const action = KeyActions[key];
+
+ if (acc.has(action)) {
+ acc.get(action).push(key);
+ return acc;
+ }
+
+ acc.set(action, [key]);
+ return acc;
+ }, new Map<Action, string[]>());
+}
+
+export namespace PhysicsConstants {
+ export const MAX_JUMP_TIME_MS = 150;
+ export const GRAVITY = 0.0075;
+ export const PLAYER_MOVE_VEL = 1;
+ export const PLAYER_JUMP_ACC = -0.01;
+ export const PLAYER_JUMP_INITIAL_VEL = -0.9;
+}
diff --git a/client/lib/config/index.ts b/client/lib/config/index.ts
new file mode 100644
index 0000000..7a1052a
--- /dev/null
+++ b/client/lib/config/index.ts
@@ -0,0 +1,3 @@
+export * from "./constants";
+export * from "./assets.ts";
+export * from "./sprites.ts";
diff --git a/client/lib/config/sprites.ts b/client/lib/config/sprites.ts
new file mode 100644
index 0000000..18bec73
--- /dev/null
+++ b/client/lib/config/sprites.ts
@@ -0,0 +1,49 @@
+export enum Sprites {
+ FLOOR,
+ TRAMPOLINE,
+ COFFEE,
+}
+
+export interface SpriteSpec {
+ sheet: string;
+ width: number;
+ height: number;
+ frames: number;
+ msPerFrame: number;
+ states?: Record<string | number, Partial<SpriteSpec>>;
+}
+
+export const SPRITE_SPECS: Map<Sprites, Partial<SpriteSpec>> = new Map<
+ Sprites,
+ SpriteSpec
+>();
+
+const floorSpriteSpec = {
+ height: 40,
+ frames: 3,
+ msPerFrame: 125,
+ states: {},
+};
+floorSpriteSpec.states = [40, 80, 120, 160].reduce((acc, cur) => {
+ acc[cur] = {
+ width: cur,
+ sheet: `/assets/floor_tile_${cur}.png`,
+ };
+ return acc;
+}, {});
+SPRITE_SPECS.set(Sprites.FLOOR, floorSpriteSpec);
+
+SPRITE_SPECS.set(Sprites.COFFEE, {
+ msPerFrame: 100,
+ width: 60,
+ height: 45,
+ frames: 3,
+ states: {
+ LEFT: {
+ sheet: "/assets/coffee_left.png",
+ },
+ RIGHT: {
+ sheet: "/assets/coffee_right.png",
+ },
+ },
+});
diff --git a/client/lib/entities/Entity.ts b/client/lib/entities/Entity.ts
new file mode 100644
index 0000000..e57ccde
--- /dev/null
+++ b/client/lib/entities/Entity.ts
@@ -0,0 +1,33 @@
+import type { Component } from "../components";
+import { ComponentNotFoundError } from "../exceptions";
+
+export abstract class Entity {
+ private static ID = 0;
+
+ public readonly id: number;
+ public readonly components: Map<string, Component>;
+
+ constructor() {
+ this.id = Entity.ID++;
+ this.components = new Map();
+ }
+
+ public addComponent(component: Component) {
+ this.components.set(component.name, component);
+ }
+
+ public getComponent<T extends Component>(name: string): T {
+ if (!this.hasComponent(name)) {
+ throw new Error("Entity does not have component " + name);
+ }
+ return this.components.get(name) as T;
+ }
+
+ public getComponents(): Component[] {
+ return Array.from(this.components.values());
+ }
+
+ public hasComponent(name: string): boolean {
+ return this.components.has(name);
+ }
+}
diff --git a/client/lib/entities/Floor.ts b/client/lib/entities/Floor.ts
new file mode 100644
index 0000000..d51badc
--- /dev/null
+++ b/client/lib/entities/Floor.ts
@@ -0,0 +1,31 @@
+import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config";
+import { BoundingBox, Sprite } from "../components";
+import { TopCollidable } from "../components/TopCollidable";
+import { Entity } from "../entities";
+
+export class Floor extends Entity {
+ private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(Sprites.FLOOR);
+
+ constructor(width: number) {
+ super();
+
+ this.addComponent(
+ new Sprite(
+ IMAGES.get(Floor.spriteSpec.states[width].sheet),
+ { x: 0, y: 0 },
+ { width, height: Floor.spriteSpec.height },
+ Floor.spriteSpec.msPerFrame,
+ Floor.spriteSpec.frames
+ )
+ );
+
+ this.addComponent(
+ new BoundingBox(
+ { x: 300, y: 300 },
+ { width, height: Floor.spriteSpec.height }
+ )
+ );
+
+ this.addComponent(new TopCollidable());
+ }
+}
diff --git a/client/lib/entities/Player.ts b/client/lib/entities/Player.ts
new file mode 100644
index 0000000..0ba5a41
--- /dev/null
+++ b/client/lib/entities/Player.ts
@@ -0,0 +1,68 @@
+import { Entity } from ".";
+import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config";
+import {
+ Jump,
+ FacingDirection,
+ BoundingBox,
+ Sprite,
+ Velocity,
+ Gravity,
+ WallBounded,
+ Forces,
+ Collide,
+ Control,
+ Mass,
+ Moment,
+} from "../components";
+import { PhysicsConstants } from "../config";
+import { Direction } from "../interfaces";
+
+export class Player extends Entity {
+ private static MASS: number = 10;
+ private static MOI: number = 1000;
+
+ private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(Sprites.COFFEE);
+
+ constructor() {
+ super();
+
+ this.addComponent(
+ new BoundingBox(
+ { x: 300, y: 100 },
+ { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
+ 0
+ )
+ );
+
+ this.addComponent(new Velocity({ dx: 0, dy: 0 }, 0));
+
+ this.addComponent(new Mass(Player.MASS));
+ this.addComponent(new Moment(Player.MOI));
+ this.addComponent(new Forces());
+ this.addComponent(new Gravity());
+
+ this.addComponent(new Jump());
+ this.addComponent(new Control());
+
+ this.addComponent(new Collide());
+ this.addComponent(new WallBounded());
+
+ this.addFacingDirectionComponents();
+ }
+
+ private addFacingDirectionComponents() {
+ const [leftSprite, rightSprite] = [Direction.LEFT, Direction.RIGHT].map(
+ (direction) =>
+ new Sprite(
+ IMAGES.get(Player.spriteSpec.states[direction].sheet),
+ { x: 0, y: 0 },
+ { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
+ Player.spriteSpec.msPerFrame,
+ Player.spriteSpec.frames
+ )
+ );
+
+ this.addComponent(new FacingDirection(leftSprite, rightSprite));
+ this.addComponent(leftSprite); // face Left by default
+ }
+}
diff --git a/client/lib/entities/index.ts b/client/lib/entities/index.ts
new file mode 100644
index 0000000..a921512
--- /dev/null
+++ b/client/lib/entities/index.ts
@@ -0,0 +1,3 @@
+export * from "./Entity";
+export * from "./Floor";
+export * from "./Player";
diff --git a/client/lib/interfaces/Action.ts b/client/lib/interfaces/Action.ts
new file mode 100644
index 0000000..61c89e1
--- /dev/null
+++ b/client/lib/interfaces/Action.ts
@@ -0,0 +1,5 @@
+export enum Action {
+ MOVE_LEFT,
+ MOVE_RIGHT,
+ JUMP,
+}
diff --git a/client/lib/interfaces/Direction.ts b/client/lib/interfaces/Direction.ts
new file mode 100644
index 0000000..0bc6ef3
--- /dev/null
+++ b/client/lib/interfaces/Direction.ts
@@ -0,0 +1,6 @@
+export enum Direction {
+ UP = "UP",
+ DOWN = "DOWN",
+ LEFT = "LEFT",
+ RIGHT = "RIGHT",
+}
diff --git a/client/lib/interfaces/Draw.ts b/client/lib/interfaces/Draw.ts
new file mode 100644
index 0000000..6561a01
--- /dev/null
+++ b/client/lib/interfaces/Draw.ts
@@ -0,0 +1,9 @@
+import type { Coord2D, Dimension2D } from "./";
+
+export interface DrawArgs {
+ center: Coord2D;
+ dimension: Dimension2D;
+ tint?: string;
+ opacity?: number;
+ rotation?: number;
+}
diff --git a/client/lib/interfaces/LeaderBoardEntry.ts b/client/lib/interfaces/LeaderBoardEntry.ts
new file mode 100644
index 0000000..1b1e7b3
--- /dev/null
+++ b/client/lib/interfaces/LeaderBoardEntry.ts
@@ -0,0 +1,5 @@
+export interface LeaderBoardEntry {
+ name: string;
+ score: number;
+ avatar: string;
+}
diff --git a/client/lib/interfaces/Vec2.ts b/client/lib/interfaces/Vec2.ts
new file mode 100644
index 0000000..b2bae37
--- /dev/null
+++ b/client/lib/interfaces/Vec2.ts
@@ -0,0 +1,22 @@
+export interface Coord2D {
+ x: number;
+ y: number;
+}
+
+export interface Dimension2D {
+ width: number;
+ height: number;
+}
+
+export interface Velocity2D {
+ dx: number;
+ dy: number;
+}
+
+export interface Force2D {
+ fCartesian: {
+ fx: number;
+ fy: number;
+ };
+ torque: number;
+}
diff --git a/client/lib/interfaces/index.ts b/client/lib/interfaces/index.ts
new file mode 100644
index 0000000..0398abd
--- /dev/null
+++ b/client/lib/interfaces/index.ts
@@ -0,0 +1,5 @@
+export * from "./LeaderBoardEntry";
+export * from "./Vec2";
+export * from "./Draw";
+export * from "./Direction";
+export * from "./Action";
diff --git a/client/lib/structures/QuadTree.ts b/client/lib/structures/QuadTree.ts
new file mode 100644
index 0000000..7913e59
--- /dev/null
+++ b/client/lib/structures/QuadTree.ts
@@ -0,0 +1,154 @@
+import type { Coord2D, Dimension2D } from "../interfaces";
+import { ComponentNames, BoundingBox } from "../components";
+import { Entity } from "../entities";
+
+interface BoxedEntry {
+ id: number;
+ dimension: Dimension2D;
+ center: Coord2D;
+}
+
+enum Quadrant {
+ I,
+ II,
+ III,
+ IV,
+}
+
+export class QuadTree {
+ private maxLevels: number;
+ private splitThreshold: number;
+ private level: number;
+ private topLeft: Coord2D;
+ private dimension: Dimension2D;
+
+ private children: Map<Quadrant, QuadTree>;
+ private objects: BoxedEntry[];
+
+ constructor(
+ topLeft: Coord2D,
+ dimension: Dimension2D,
+ maxLevels: number,
+ splitThreshold: number,
+ level?: number
+ ) {
+ this.children = [];
+ this.objects = [];
+
+ this.maxLevels = maxLevels;
+ this.splitThreshold = splitThreshold;
+ this.level = level ?? 0;
+ }
+
+ public insert(id: number, dimension: Dimension2D, center: Coord2D): void {
+ if (this.hasChildren()) {
+ this.getIndices(boundingBox).forEach((i) =>
+ this.children[i].insert(id, dimension, center)
+ );
+ return;
+ }
+
+ this.objects.push({ id, dimension, center });
+
+ if (
+ this.objects.length > this.splitThreshold &&
+ this.level < this.maxLevels
+ ) {
+ if (!this.hasChildren()) {
+ this.performSplit();
+ }
+ this.realignObjects();
+ }
+ }
+
+ public clear(): void {
+ this.objects = [];
+ if (this.hasChildren()) {
+ this.children.forEach((child) => child.clear());
+ this.children.clear();
+ }
+ }
+
+ public getNeighborIds(boxedEntry: BoxedEntry): number[] {
+ const neighbors: number[] = this.objects.map(({ id }) => id);
+
+ if (this.hasChildren()) {
+ this.getQuadrants(boxedEntry).forEach((quadrant) => {
+ this.children
+ .get(quadrant)
+ .getNeighborIds(boxedEntry)
+ .forEach((id) => neighbors.push(id));
+ });
+ }
+
+ return neighbors;
+ }
+
+ private performSplit(): void {
+ const halfWidth = this.dimension.width / 2;
+ const halfHeight = this.dimension.height / 2;
+
+ [
+ [Quadrant.I, { x: this.topLeft.x + halfWidth, y: this.topLeft.y }],
+ [Quadrant.II, { ...this.topLeft }],
+ [Quadrant.III, { x: this.topLeft.x, y: this.topLeft.y + halfHeight }],
+ [
+ Quadrant.IV,
+ { x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight },
+ ],
+ ].forEach(([quadrant, pos]) => {
+ this.children.set(
+ quadrant,
+ new QuadTree(
+ pos,
+ { width: halfWidth, height: halfHeight },
+ this.maxLevels,
+ this.splitThreshold,
+ this.level + 1
+ )
+ );
+ });
+ }
+
+ private getQuandrants(boxedEntry: BoxedEntry): Quadrant[] {
+ const treeCenter: Coord2D = {
+ x: this.topLeft.x + this.dimension.width / 2,
+ y: this.topLeft.y + this.dimension.height / 2,
+ };
+
+ return [
+ [Quadrant.I, (x, y) => x >= treeCenter.x && y < treeCenter.y],
+ [Quadrant.II, (x, y) => x < treeCenter.x && y < treeCenter.y],
+ [Quadrant.III, (x, y) => x < treeCenter.x && y >= treeCenter.y],
+ [Quadrant.IV, (x, y) => x >= treeCenter.x && y >= treeCenter.y],
+ ]
+ .filter(
+ ([_quadrant, condition]) =>
+ condition(
+ boxedEntry.center.x + boxedEntry.dimension.width / 2,
+ boxedEntry.center.y + boxedEntry.dimension.height / 2
+ ) ||
+ condition(
+ boxedEntry.center.x - boxedEntry.dimension.width / 2,
+ boxedEntry.center.y - boxedEntry.dimension.height / 2
+ )
+ )
+ .map(([quadrant]) => quadrant);
+ }
+
+ private realignObjects(): void {
+ this.objects.forEach((boxedEntry) => {
+ this.getQuadrants(boxedEntry).forEach((direction) => {
+ this.children
+ .get(direction)
+ .insert(boxedEntry.id, boxedEntry.dimension, boxedEntry.center);
+ });
+ });
+
+ this.objects = [];
+ }
+
+ private hasChildren() {
+ return this.children && this.children.length > 0;
+ }
+}
diff --git a/client/lib/structures/index.ts b/client/lib/structures/index.ts
new file mode 100644
index 0000000..605a82a
--- /dev/null
+++ b/client/lib/structures/index.ts
@@ -0,0 +1 @@
+export * from "./QuadTree";
diff --git a/client/lib/systems/Collision.ts b/client/lib/systems/Collision.ts
new file mode 100644
index 0000000..16ad8c6
--- /dev/null
+++ b/client/lib/systems/Collision.ts
@@ -0,0 +1,214 @@
+import { SystemNames, System } from ".";
+import {
+ Mass,
+ BoundingBox,
+ ComponentNames,
+ Jump,
+ Velocity,
+ Moment,
+} from "../components";
+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_COMPONENTS = [
+ 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,
+ entityMap: Map<number, Entity>,
+ entityComponents: Map<string, Set<number>>
+ ) {
+ this.quadTree.clear();
+
+ const entitiesToAddToQuadtree: Entity[] = [];
+ Collision.COLLIDABLE_COMPONENTS.map((componentName) =>
+ entityComponents.get(componentName)
+ ).forEach((entityIds: Set<number>) =>
+ entityIds.forEach((id) => {
+ const entity = entityMap.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
+ );
+ });
+
+ const collidingEntities = this.getCollidingEntities(
+ entitiesToAddToQuadtree,
+ entityMap
+ );
+ collidingEntities.forEach(([entityAId, entityBId]) => {
+ const [entityA, entityB] = [entityAId, entityBId].map((id) =>
+ entityMap.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;
+ }
+
+ private getDyToPushOutOfFloor(
+ entityBoundingBox: BoundingBox,
+ floorBoundingBox: BoundingBox
+ ): number {
+ // ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ
+ 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/client/lib/systems/FacingDirection.ts b/client/lib/systems/FacingDirection.ts
new file mode 100644
index 0000000..fbb4c7c
--- /dev/null
+++ b/client/lib/systems/FacingDirection.ts
@@ -0,0 +1,39 @@
+import {
+ ComponentNames,
+ Velocity,
+ FacingDirection as FacingDirectionComponent,
+} from "../components";
+import type { Entity } from "../entities";
+import { System, SystemNames } from "./";
+
+export class FacingDirection extends System {
+ constructor() {
+ super(SystemNames.FacingDirection);
+ }
+
+ public update(
+ _dt: number,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ) {
+ componentEntities
+ .get(ComponentNames.FacingDirection)
+ ?.forEach((entityId) => {
+ const entity = entityMap.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/client/lib/systems/Input.ts b/client/lib/systems/Input.ts
new file mode 100644
index 0000000..92932dd
--- /dev/null
+++ b/client/lib/systems/Input.ts
@@ -0,0 +1,86 @@
+import {
+ Jump,
+ Forces,
+ Acceleration,
+ ComponentNames,
+ Velocity,
+ Mass,
+} from "../components";
+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,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ) {
+ componentEntities.get(ComponentNames.Control)?.forEach((entityId) => {
+ const entity = entityMap.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;
+ }
+ });
+
+ componentEntities.get(ComponentNames.Jump)?.forEach((entityId) => {
+ const entity = entityMap.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/client/lib/systems/Physics.ts b/client/lib/systems/Physics.ts
new file mode 100644
index 0000000..319ae29
--- /dev/null
+++ b/client/lib/systems/Physics.ts
@@ -0,0 +1,94 @@
+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";
+
+export class Physics extends System {
+ constructor() {
+ super(SystemNames.Physics);
+ }
+
+ public update(
+ dt: number,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ): void {
+ componentEntities.get(ComponentNames.Forces)?.forEach((entityId) => {
+ const entity = entityMap.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;
+ }
+ });
+
+ componentEntities.get(ComponentNames.Velocity)?.forEach((entityId) => {
+ const entity = entityMap.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/client/lib/systems/Render.ts b/client/lib/systems/Render.ts
new file mode 100644
index 0000000..0c76b00
--- /dev/null
+++ b/client/lib/systems/Render.ts
@@ -0,0 +1,41 @@
+import { System, SystemNames } from ".";
+import { BoundingBox, ComponentNames, Sprite } from "../components";
+import type { Entity } from "../entities";
+import type { DrawArgs } from "../interfaces";
+
+export class Render extends System {
+ private ctx: CanvasRenderingContext2D;
+
+ constructor(ctx: CanvasRenderingContext2D) {
+ super(SystemNames.Render);
+ this.ctx = ctx;
+ }
+
+ public update(
+ dt: number,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ) {
+ this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
+
+ componentEntities.get(ComponentNames.Sprite)?.forEach((entityId) => {
+ const entity = entityMap.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
+ );
+
+ drawArgs = {
+ center: boundingBox.center,
+ dimension: boundingBox.dimension,
+ rotation: boundingBox.rotation,
+ };
+ }
+ sprite.draw(this.ctx, drawArgs);
+ });
+ }
+}
diff --git a/client/lib/systems/System.ts b/client/lib/systems/System.ts
new file mode 100644
index 0000000..2accc97
--- /dev/null
+++ b/client/lib/systems/System.ts
@@ -0,0 +1,15 @@
+import { Entity } from "../entities";
+
+export abstract class System {
+ public readonly name: string;
+
+ constructor(name: string) {
+ this.name = name;
+ }
+
+ abstract update(
+ dt: number,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ): void;
+}
diff --git a/client/lib/systems/WallBounds.ts b/client/lib/systems/WallBounds.ts
new file mode 100644
index 0000000..3fd5dc4
--- /dev/null
+++ b/client/lib/systems/WallBounds.ts
@@ -0,0 +1,35 @@
+import { System, SystemNames } from ".";
+import { BoundingBox, ComponentNames } from "../components";
+import type { Entity } from "../entities";
+
+export class WallBounds extends System {
+ private screenWidth: number;
+
+ constructor(screenWidth: number) {
+ super(SystemNames.WallBounds);
+
+ this.screenWidth = screenWidth;
+ }
+
+ public update(
+ _dt: number,
+ entityMap: Map<number, Entity>,
+ componentEntities: Map<string, Set<number>>
+ ) {
+ componentEntities.get(ComponentNames.WallBounded)?.forEach((entityId) => {
+ const entity = entityMap.get(entityId);
+ if (!entity.hasComponent(ComponentNames.BoundingBox)) {
+ return;
+ }
+
+ const boundingBox = entity.getComponent<BoundingBox>(
+ ComponentNames.BoundingBox
+ );
+
+ boundingBox.center.x = Math.min(
+ this.screenWidth - boundingBox.dimension.width / 2,
+ Math.max(boundingBox.dimension.width / 2, boundingBox.center.x)
+ );
+ });
+ }
+}
diff --git a/client/lib/systems/index.ts b/client/lib/systems/index.ts
new file mode 100644
index 0000000..6cb6f35
--- /dev/null
+++ b/client/lib/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/client/lib/systems/names.ts b/client/lib/systems/names.ts
new file mode 100644
index 0000000..23f31fc
--- /dev/null
+++ b/client/lib/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";
+}
diff --git a/client/lib/utils/dotProduct.ts b/client/lib/utils/dotProduct.ts
new file mode 100644
index 0000000..59f8857
--- /dev/null
+++ b/client/lib/utils/dotProduct.ts
@@ -0,0 +1,4 @@
+import type { Coord2D } from "../interfaces";
+
+export const dotProduct = (vector1: Coord2D, vector2: Coord2D): number =>
+ vector1.x * vector2.x + vector1.y * vector2.y;
diff --git a/client/lib/utils/index.ts b/client/lib/utils/index.ts
new file mode 100644
index 0000000..1f8e2f0
--- /dev/null
+++ b/client/lib/utils/index.ts
@@ -0,0 +1,3 @@
+export * from "./rotateVector";
+export * from "./normalizeVector";
+export * from "./dotProduct";
diff --git a/client/lib/utils/normalizeVector.ts b/client/lib/utils/normalizeVector.ts
new file mode 100644
index 0000000..e6dfd7f
--- /dev/null
+++ b/client/lib/utils/normalizeVector.ts
@@ -0,0 +1,8 @@
+import type { Coord2D } from "../interfaces";
+
+export const normalizeVector = (vector: Coord2D): Coord2D => {
+ const { x, y } = vector;
+ const length = Math.sqrt(x * x + y * y);
+
+ return { x: x / length, y: y / length };
+};
diff --git a/client/lib/utils/rotateVector.ts b/client/lib/utils/rotateVector.ts
new file mode 100644
index 0000000..82bb54d
--- /dev/null
+++ b/client/lib/utils/rotateVector.ts
@@ -0,0 +1,15 @@
+import type { Coord2D } from "../interfaces";
+
+/**
+ * ([[cos(θ), -sin(θ),]) ([x,)
+ * ([sin(θ), cos(θ)] ]) ( y])
+ */
+export const rotateVector = (vector: Coord2D, theta: number): Coord2D => {
+ const rads = (theta * Math.PI) / 180;
+ const [cos, sin] = [Math.cos(rads), Math.sin(rads)];
+
+ return {
+ x: vector.x * cos - vector.y * sin,
+ y: vector.x * sin + vector.y * cos,
+ };
+};