From 0fd9fb097552686f2257c1aa689d797e80057bd1 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Wed, 19 Jul 2023 20:38:24 -0700 Subject: initial commit --- client/lib/Game.ts | 70 ++++++++++ client/lib/JumpStorm.ts | 54 ++++++++ client/lib/components/BoundingBox.ts | 97 ++++++++++++++ client/lib/components/Collide.ts | 7 + client/lib/components/Component.ts | 7 + client/lib/components/Control.ts | 7 + client/lib/components/FacingDirection.ts | 13 ++ client/lib/components/Forces.ts | 17 +++ client/lib/components/Gravity.ts | 13 ++ client/lib/components/Jump.ts | 10 ++ client/lib/components/Mass.ts | 10 ++ client/lib/components/Moment.ts | 10 ++ client/lib/components/Sprite.ts | 92 +++++++++++++ client/lib/components/TopCollidable.ts | 7 + client/lib/components/Velocity.ts | 15 +++ client/lib/components/WallBounded.ts | 7 + client/lib/components/index.ts | 15 +++ client/lib/components/names.ts | 15 +++ client/lib/config/assets.ts | 40 ++++++ client/lib/config/constants.ts | 34 +++++ client/lib/config/index.ts | 3 + client/lib/config/sprites.ts | 49 +++++++ client/lib/entities/Entity.ts | 33 +++++ client/lib/entities/Floor.ts | 31 +++++ client/lib/entities/Player.ts | 68 ++++++++++ client/lib/entities/index.ts | 3 + client/lib/interfaces/Action.ts | 5 + client/lib/interfaces/Direction.ts | 6 + client/lib/interfaces/Draw.ts | 9 ++ client/lib/interfaces/LeaderBoardEntry.ts | 5 + client/lib/interfaces/Vec2.ts | 22 +++ client/lib/interfaces/index.ts | 5 + client/lib/structures/QuadTree.ts | 154 +++++++++++++++++++++ client/lib/structures/index.ts | 1 + client/lib/systems/Collision.ts | 214 ++++++++++++++++++++++++++++++ client/lib/systems/FacingDirection.ts | 39 ++++++ client/lib/systems/Input.ts | 86 ++++++++++++ client/lib/systems/Physics.ts | 94 +++++++++++++ client/lib/systems/Render.ts | 41 ++++++ client/lib/systems/System.ts | 15 +++ client/lib/systems/WallBounds.ts | 35 +++++ client/lib/systems/index.ts | 8 ++ client/lib/systems/names.ts | 8 ++ client/lib/utils/dotProduct.ts | 4 + client/lib/utils/index.ts | 3 + client/lib/utils/normalizeVector.ts | 8 ++ client/lib/utils/rotateVector.ts | 15 +++ 47 files changed, 1504 insertions(+) create mode 100644 client/lib/Game.ts create mode 100644 client/lib/JumpStorm.ts create mode 100644 client/lib/components/BoundingBox.ts create mode 100644 client/lib/components/Collide.ts create mode 100644 client/lib/components/Component.ts create mode 100644 client/lib/components/Control.ts create mode 100644 client/lib/components/FacingDirection.ts create mode 100644 client/lib/components/Forces.ts create mode 100644 client/lib/components/Gravity.ts create mode 100644 client/lib/components/Jump.ts create mode 100644 client/lib/components/Mass.ts create mode 100644 client/lib/components/Moment.ts create mode 100644 client/lib/components/Sprite.ts create mode 100644 client/lib/components/TopCollidable.ts create mode 100644 client/lib/components/Velocity.ts create mode 100644 client/lib/components/WallBounded.ts create mode 100644 client/lib/components/index.ts create mode 100644 client/lib/components/names.ts create mode 100644 client/lib/config/assets.ts create mode 100644 client/lib/config/constants.ts create mode 100644 client/lib/config/index.ts create mode 100644 client/lib/config/sprites.ts create mode 100644 client/lib/entities/Entity.ts create mode 100644 client/lib/entities/Floor.ts create mode 100644 client/lib/entities/Player.ts create mode 100644 client/lib/entities/index.ts create mode 100644 client/lib/interfaces/Action.ts create mode 100644 client/lib/interfaces/Direction.ts create mode 100644 client/lib/interfaces/Draw.ts create mode 100644 client/lib/interfaces/LeaderBoardEntry.ts create mode 100644 client/lib/interfaces/Vec2.ts create mode 100644 client/lib/interfaces/index.ts create mode 100644 client/lib/structures/QuadTree.ts create mode 100644 client/lib/structures/index.ts create mode 100644 client/lib/systems/Collision.ts create mode 100644 client/lib/systems/FacingDirection.ts create mode 100644 client/lib/systems/Input.ts create mode 100644 client/lib/systems/Physics.ts create mode 100644 client/lib/systems/Render.ts create mode 100644 client/lib/systems/System.ts create mode 100644 client/lib/systems/WallBounds.ts create mode 100644 client/lib/systems/index.ts create mode 100644 client/lib/systems/names.ts create mode 100644 client/lib/utils/dotProduct.ts create mode 100644 client/lib/utils/index.ts create mode 100644 client/lib/utils/normalizeVector.ts create mode 100644 client/lib/utils/rotateVector.ts (limited to 'client/lib') 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; + private systems: Map; + 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>(); + this.entities.forEach((entity) => + entity.getComponents().forEach((component) => { + if (!componentEntities.has(component.name)) { + componentEntities.set(component.name, new Set([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(); + +export const loadSpritesIntoImageElements = ( + spriteSpecs: Partial[] +): Promise[] => { + const spritePromises: Promise[] = []; + + 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 = { + 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 = Object.keys( + KeyActions + ).reduce((acc: Map, key) => { + const action = KeyActions[key]; + + if (acc.has(action)) { + acc.get(action).push(key); + return acc; + } + + acc.set(action, [key]); + return acc; + }, new Map()); +} + +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>; +} + +export const SPRITE_SPECS: Map> = 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; + + constructor() { + this.id = Entity.ID++; + this.components = new Map(); + } + + public addComponent(component: Component) { + this.components.set(component.name, component); + } + + public getComponent(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; + 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, + entityComponents: Map> + ) { + this.quadTree.clear(); + + const entitiesToAddToQuadtree: Entity[] = []; + Collision.COLLIDABLE_COMPONENTS.map((componentName) => + entityComponents.get(componentName) + ).forEach((entityIds: Set) => + entityIds.forEach((id) => { + const entity = entityMap.get(id); + if (!entity.hasComponent(ComponentNames.BoundingBox)) { + return; + } + entitiesToAddToQuadtree.push(entity); + }) + ); + + entitiesToAddToQuadtree.forEach((entity) => { + const boundingBox = entity.getComponent( + 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(ComponentNames.BoundingBox) + ); + + let velocity: Velocity; + if (entityA.hasComponent(ComponentNames.Velocity)) { + velocity = entityA.getComponent(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(ComponentNames.Mass).mass; + const F_n = -mass * PhysicsConstants.GRAVITY; + + entityA.getComponent(ComponentNames.Forces).forces.push({ + fCartesian: { fy: F_n }, + }); + } + + // reset the entities' jump + if (entityA.hasComponent(ComponentNames.Jump)) { + entityA.getComponent(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, number][] { + const collidingEntityIds: [number, number] = []; + + for (const entity of collidableEntities) { + const boundingBox = entity.getComponent( + 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(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, + componentEntities: Map> + ) { + componentEntities + .get(ComponentNames.FacingDirection) + ?.forEach((entityId) => { + const entity = entityMap.get(entityId); + if (!entity.hasComponent(ComponentNames.Velocity)) { + return; + } + + const velocity = entity.getComponent(ComponentNames.Velocity); + const facingDirection = entity.getComponent( + 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; + private actionTimeStamps: Map; + + constructor() { + super(SystemNames.Input); + + this.keys = new Set(); + this.actionTimeStamps = new Map(); + } + + 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, + componentEntities: Map> + ) { + componentEntities.get(ComponentNames.Control)?.forEach((entityId) => { + const entity = entityMap.get(entityId); + if (!entity.hasComponent(ComponentNames.Velocity)) { + return; + } + + const velocity = entity.getComponent(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(ComponentNames.Jump); + const velocity = entity.getComponent(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(ComponentNames.Mass).mass; + entity.getComponent(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, + componentEntities: Map> + ): void { + componentEntities.get(ComponentNames.Forces)?.forEach((entityId) => { + const entity = entityMap.get(entityId); + + const mass = entity.getComponent(ComponentNames.Mass).mass; + const forces = entity.getComponent(ComponentNames.Forces).forces; + const velocity = entity.getComponent(ComponentNames.Velocity); + const inertia = entity.getComponent( + ComponentNames.Moment + ).inertia; + + // F_g = mg, applied only until terminal velocity is reached + if (entity.hasComponent(ComponentNames.Gravity)) { + const gravity = entity.getComponent(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(ComponentNames.Forces).forces = []; + + // maybe we fell off the floor + if (ddy > 0 && entity.hasComponent(ComponentNames.Jump)) { + entity.getComponent(ComponentNames.Jump).canJump = false; + } + }); + + componentEntities.get(ComponentNames.Velocity)?.forEach((entityId) => { + const entity = entityMap.get(entityId); + const velocity = entity.getComponent(ComponentNames.Velocity); + const boundingBox = entity.getComponent( + 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, + componentEntities: Map> + ) { + 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(ComponentNames.Sprite); + sprite.update(dt); + + let drawArgs: DrawArgs; + if (entity.hasComponent(ComponentNames.BoundingBox)) { + const boundingBox = entity.getComponent( + 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, + componentEntities: Map> + ): 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, + componentEntities: Map> + ) { + componentEntities.get(ComponentNames.WallBounded)?.forEach((entityId) => { + const entity = entityMap.get(entityId); + if (!entity.hasComponent(ComponentNames.BoundingBox)) { + return; + } + + const boundingBox = entity.getComponent( + 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, + }; +}; -- cgit v1.2.3-70-g09d2