();
+
+ useEffect(() => {
+ if (canvasRef.current) {
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ const game = new Game();
+ const theAbstractionEngine = new TheAbstractionEngine(game, ctx);
+
+ theAbstractionEngine.init().then(() => {
+ theAbstractionEngine.play();
+ setGame(theAbstractionEngine);
+ });
+
+ return () => theAbstractionEngine.stop();
+ }
+ }
+ }, [canvasRef]);
return (
diff --git a/src/engine/Game.ts b/src/engine/Game.ts
new file mode 100644
index 0000000..2df9f17
--- /dev/null
+++ b/src/engine/Game.ts
@@ -0,0 +1,90 @@
+import { Entity } from "./entities";
+import { System } from "./systems";
+
+export class Game {
+ private systemOrder: string[];
+
+ private running: boolean;
+ private lastTimeStamp: number;
+
+ public entities: Map;
+ public systems: Map;
+ public componentEntities: Map>;
+
+ constructor() {
+ this.lastTimeStamp = performance.now();
+ this.running = false;
+ this.systemOrder = [];
+ this.systems = new Map();
+ this.entities = new Map();
+ this.componentEntities = new Map();
+ }
+
+ public start() {
+ this.lastTimeStamp = performance.now();
+ this.running = true;
+ }
+
+ public addEntity(entity: Entity) {
+ this.entities.set(entity.id, entity);
+ }
+
+ public getEntity(id: string): Entity | undefined {
+ return this.entities.get(id);
+ }
+
+ public removeEntity(id: string) {
+ this.entities.delete(id);
+ }
+
+ public forEachEntityWithComponent(
+ componentName: string,
+ callback: (entity: Entity) => void,
+ ) {
+ this.componentEntities.get(componentName)?.forEach((entityId) => {
+ const entity = this.getEntity(entityId);
+ if (!entity) return;
+
+ callback(entity);
+ });
+ }
+
+ 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): T {
+ return this.systems.get(name) as unknown as T;
+ }
+
+ public doGameLoop(timeStamp: number) {
+ if (!this.running) {
+ return;
+ }
+
+ const dt = timeStamp - this.lastTimeStamp;
+ this.lastTimeStamp = timeStamp;
+
+ // rebuild the Component -> { Entity } map
+ this.componentEntities.clear();
+ this.entities.forEach((entity) =>
+ entity.getComponents().forEach((component) => {
+ if (!this.componentEntities.has(component.name)) {
+ this.componentEntities.set(
+ component.name,
+ new Set([entity.id]),
+ );
+ return;
+ }
+ this.componentEntities.get(component.name)?.add(entity.id);
+ }),
+ );
+
+ this.systemOrder.forEach((systemName) => {
+ this.systems.get(systemName)?.update(dt, this);
+ });
+ }
+}
diff --git a/src/engine/TheAbstractionEngine.ts b/src/engine/TheAbstractionEngine.ts
new file mode 100644
index 0000000..e720293
--- /dev/null
+++ b/src/engine/TheAbstractionEngine.ts
@@ -0,0 +1,42 @@
+import { Game } from ".";
+import { loadAssets } from "./config";
+import { Player } from "./entities";
+import { Render } from "./systems";
+
+export class TheAbstractionEngine {
+ private game: Game;
+ private ctx: CanvasRenderingContext2D;
+ private animationFrameId: number | null;
+
+ constructor(game: Game, ctx: CanvasRenderingContext2D) {
+ this.game = game;
+ this.ctx = ctx;
+ this.animationFrameId = null;
+ }
+
+ public async init() {
+ await loadAssets();
+
+ [new Render(this.ctx)].forEach((system) => this.game.addSystem(system));
+
+ const player = new Player();
+ this.game.addEntity(player);
+ }
+
+ public play() {
+ this.game.start();
+
+ const loop = (timestamp: number) => {
+ this.game.doGameLoop(timestamp);
+ this.animationFrameId = requestAnimationFrame(loop); // tail call recursion! /s
+ };
+ this.animationFrameId = requestAnimationFrame(loop);
+ }
+
+ public stop() {
+ if (this.animationFrameId) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
+ }
+}
diff --git a/src/engine/components/BoundingBox.ts b/src/engine/components/BoundingBox.ts
new file mode 100644
index 0000000..d64041f
--- /dev/null
+++ b/src/engine/components/BoundingBox.ts
@@ -0,0 +1,122 @@
+import { Component, ComponentNames } from ".";
+import type { Coord2D, Dimension2D } from "../interfaces";
+import { dotProduct, rotateVector } 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 {
+ // optimization; when neither rotates just check if they overlap
+ if (this.rotation == 0 && box.rotation == 0) {
+ const thisTopLeft = this.getTopLeft();
+ const thisBottomRight = this.getBottomRight();
+
+ const thatTopLeft = box.getTopLeft();
+ const thatBottomRight = box.getBottomRight();
+
+ if (
+ thisBottomRight.x <= thatTopLeft.x ||
+ thisTopLeft.x >= thatBottomRight.x ||
+ thisBottomRight.y <= thatTopLeft.y ||
+ thisTopLeft.y >= thatBottomRight.y
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // https://en.wikipedia.org/wiki/Hyperplane_separation_theorem
+ 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)) // rotate
+ .map((vertex) => {
+ // translate
+ return {
+ x: vertex.x + this.center.x,
+ y: vertex.y + this.center.y,
+ };
+ });
+ }
+
+ public getRotationInPiOfUnitCircle(): number {
+ let rads = this.rotation * (Math.PI / 180);
+ if (rads >= Math.PI) {
+ // Physics system guarantees rotation \in [0, 360)
+ rads -= Math.PI;
+ }
+ return rads;
+ }
+
+ public getOutscribedBoxDims(): Dimension2D {
+ let rads = this.getRotationInPiOfUnitCircle();
+ const { width, height } = this.dimension;
+
+ if (rads == 0) return this.dimension;
+
+ if (rads <= Math.PI / 2) {
+ return {
+ width: Math.abs(height * Math.sin(rads) + width * Math.cos(rads)),
+ height: Math.abs(width * Math.sin(rads) + height * Math.cos(rads)),
+ };
+ }
+
+ rads -= Math.PI / 2;
+ return {
+ width: Math.abs(height * Math.cos(rads) + width * Math.sin(rads)),
+ height: Math.abs(width * Math.cos(rads) + height * Math.sin(rads)),
+ };
+ }
+
+ public getTopLeft(): Coord2D {
+ return {
+ x: this.center.x - this.dimension.width / 2,
+ y: this.center.y - this.dimension.height / 2,
+ };
+ }
+
+ public getBottomRight(): Coord2D {
+ return {
+ x: this.center.x + this.dimension.width / 2,
+ y: this.center.y + this.dimension.height / 2,
+ };
+ }
+}
diff --git a/src/engine/components/ComponentNames.ts b/src/engine/components/ComponentNames.ts
index 90dfb90..0f1200a 100644
--- a/src/engine/components/ComponentNames.ts
+++ b/src/engine/components/ComponentNames.ts
@@ -1,3 +1,6 @@
export namespace ComponentNames {
export const Sprite = "Sprite";
+ export const FacingDirection = "FacingDirection";
+ export const GridPosition = "GridPosition";
+ export const BoundingBox = "BoundingBox";
}
diff --git a/src/engine/components/FacingDirection.ts b/src/engine/components/FacingDirection.ts
new file mode 100644
index 0000000..a449d21
--- /dev/null
+++ b/src/engine/components/FacingDirection.ts
@@ -0,0 +1,12 @@
+import { Component, ComponentNames, Sprite } from ".";
+import { type Direction } from "../interfaces";
+
+export class FacingDirection extends Component {
+ public readonly directionSprites: Map;
+
+ constructor() {
+ super(ComponentNames.FacingDirection);
+
+ this.directionSprites = new Map();
+ }
+}
diff --git a/src/engine/components/GridPosition.ts b/src/engine/components/GridPosition.ts
new file mode 100644
index 0000000..b5acf3b
--- /dev/null
+++ b/src/engine/components/GridPosition.ts
@@ -0,0 +1,13 @@
+import { Component, ComponentNames } from ".";
+
+export class GridPosition extends Component {
+ public x: number;
+ public y: number;
+
+ constructor(x: number, y: number) {
+ super(ComponentNames.GridPosition);
+
+ this.x = x;
+ this.y = y;
+ }
+}
diff --git a/src/engine/components/Sprite.ts b/src/engine/components/Sprite.ts
new file mode 100644
index 0000000..6a66a5c
--- /dev/null
+++ b/src/engine/components/Sprite.ts
@@ -0,0 +1,96 @@
+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 != undefined && 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,
+ ];
+ }
+
+ public getSpriteDimensions() {
+ return this.spriteImgDimensions;
+ }
+}
diff --git a/src/engine/components/index.ts b/src/engine/components/index.ts
index a2fd5d1..30fe50a 100644
--- a/src/engine/components/index.ts
+++ b/src/engine/components/index.ts
@@ -1,2 +1,6 @@
export * from "./Component";
export * from "./ComponentNames";
+export * from "./Sprite";
+export * from "./FacingDirection";
+export * from "./GridPosition";
+export * from "./BoundingBox";
diff --git a/src/engine/config/assets.ts b/src/engine/config/assets.ts
new file mode 100644
index 0000000..173bab3
--- /dev/null
+++ b/src/engine/config/assets.ts
@@ -0,0 +1,42 @@
+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(Array.from(spriteSpec.states.values())),
+ );
+ }
+ }
+
+ return spritePromises;
+};
+
+export const loadAssets = () =>
+ Promise.all([
+ ...loadSpritesIntoImageElements(
+ Array.from(SPRITE_SPECS.keys()).map(
+ (key) => SPRITE_SPECS.get(key) as SpriteSpec,
+ ),
+ ),
+ // TODO: Sound
+ ]);
diff --git a/src/engine/config/constants.ts b/src/engine/config/constants.ts
new file mode 100644
index 0000000..a00a141
--- /dev/null
+++ b/src/engine/config/constants.ts
@@ -0,0 +1,7 @@
+export namespace Miscellaneous {
+ export const WIDTH = 800;
+ export const HEIGHT = 800;
+
+ export const DEFAULT_GRID_WIDTH = 30;
+ export const DEFAULT_GRID_HEIGHT = 30;
+}
diff --git a/src/engine/config/index.ts b/src/engine/config/index.ts
new file mode 100644
index 0000000..a574965
--- /dev/null
+++ b/src/engine/config/index.ts
@@ -0,0 +1,3 @@
+export * from "./constants";
+export * from "./assets";
+export * from "./sprites";
diff --git a/src/engine/config/sprites.ts b/src/engine/config/sprites.ts
new file mode 100644
index 0000000..37185fd
--- /dev/null
+++ b/src/engine/config/sprites.ts
@@ -0,0 +1,39 @@
+import { Direction } from "../interfaces/Direction";
+
+export enum Sprites {
+ PLAYER,
+}
+
+export interface SpriteSpec {
+ sheet: string;
+ width: number;
+ height: number;
+ frames: number;
+ msPerFrame: number;
+ states?: Map>;
+}
+
+export const SPRITE_SPECS: Map> = new Map<
+ Sprites,
+ SpriteSpec
+>();
+
+const playerSpriteSpec = {
+ msPerFrame: 200,
+ width: 64,
+ height: 64,
+ frames: 3,
+ states: new Map>(),
+};
+playerSpriteSpec.states.set(Direction.NONE, {
+ sheet: "/assets/lambda/neutral.png",
+});
+[Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN].forEach(
+ (direction) => {
+ playerSpriteSpec.states.set(direction, {
+ sheet: `/assets/lambda/${direction.toLowerCase()}.png`,
+ });
+ },
+);
+
+SPRITE_SPECS.set(Sprites.PLAYER, playerSpriteSpec);
diff --git a/src/engine/entities/Entity.ts b/src/engine/entities/Entity.ts
index 18ee5d0..2cc2ac3 100644
--- a/src/engine/entities/Entity.ts
+++ b/src/engine/entities/Entity.ts
@@ -1,13 +1,13 @@
import { type Component } from "../components";
-const randomId = () => (Math.random() * 1_000_000_000).toString();
-
export abstract class Entity {
+ static Id = 0;
+
public id: string;
public components: Map;
public name: string;
- constructor(name: string, id: string = randomId()) {
+ constructor(name: string, id: string = (Entity.Id++).toString()) {
this.name = name;
this.id = id;
this.components = new Map();
diff --git a/src/engine/entities/EntityNames.ts b/src/engine/entities/EntityNames.ts
index 59010fc..e2f642a 100644
--- a/src/engine/entities/EntityNames.ts
+++ b/src/engine/entities/EntityNames.ts
@@ -1,5 +1,3 @@
export namespace EntityNames {
export const Player = "Player";
- export const Wall = "Wall";
- export const Ball = "Ball";
}
diff --git a/src/engine/entities/Player.ts b/src/engine/entities/Player.ts
new file mode 100644
index 0000000..f25730c
--- /dev/null
+++ b/src/engine/entities/Player.ts
@@ -0,0 +1,58 @@
+import { Entity, EntityNames } from ".";
+import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config";
+import {
+ FacingDirection,
+ Sprite,
+ GridPosition,
+ BoundingBox,
+} from "../components";
+import { Direction } from "../interfaces/";
+
+export class Player extends Entity {
+ private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(
+ Sprites.PLAYER,
+ ) as SpriteSpec;
+
+ constructor() {
+ super(EntityNames.Player);
+
+ this.addComponent(
+ new BoundingBox(
+ {
+ x: 0,
+ y: 0,
+ },
+ { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
+ 0,
+ ),
+ );
+
+ this.addComponent(new GridPosition(0, 0));
+ this.addFacingDirectionComponents();
+ }
+
+ private addFacingDirectionComponents() {
+ const facingDirectionComponent = new FacingDirection();
+ [
+ Direction.NONE,
+ Direction.LEFT,
+ Direction.RIGHT,
+ Direction.UP,
+ Direction.DOWN,
+ ].forEach((direction) => {
+ const sprite = new Sprite(
+ IMAGES.get(Player.spriteSpec.states!.get(direction)!.sheet!)!,
+ { x: 0, y: 0 },
+ { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
+ Player.spriteSpec.msPerFrame,
+ Player.spriteSpec.frames,
+ );
+ facingDirectionComponent.directionSprites.set(direction, sprite);
+ });
+
+ this.addComponent(facingDirectionComponent);
+ this.addComponent(
+ facingDirectionComponent.directionSprites.get(Direction.NONE)!,
+ ); // face no direction by default
+ }
+}
diff --git a/src/engine/entities/index.ts b/src/engine/entities/index.ts
index ee26a63..13dd57a 100644
--- a/src/engine/entities/index.ts
+++ b/src/engine/entities/index.ts
@@ -1,2 +1,3 @@
export * from "./Entity";
export * from "./EntityNames";
+export * from "./Player";
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 2df9f17..42c6287 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -1,90 +1,2 @@
-import { Entity } from "./entities";
-import { System } from "./systems";
-
-export class Game {
- private systemOrder: string[];
-
- private running: boolean;
- private lastTimeStamp: number;
-
- public entities: Map;
- public systems: Map;
- public componentEntities: Map>;
-
- constructor() {
- this.lastTimeStamp = performance.now();
- this.running = false;
- this.systemOrder = [];
- this.systems = new Map();
- this.entities = new Map();
- this.componentEntities = new Map();
- }
-
- public start() {
- this.lastTimeStamp = performance.now();
- this.running = true;
- }
-
- public addEntity(entity: Entity) {
- this.entities.set(entity.id, entity);
- }
-
- public getEntity(id: string): Entity | undefined {
- return this.entities.get(id);
- }
-
- public removeEntity(id: string) {
- this.entities.delete(id);
- }
-
- public forEachEntityWithComponent(
- componentName: string,
- callback: (entity: Entity) => void,
- ) {
- this.componentEntities.get(componentName)?.forEach((entityId) => {
- const entity = this.getEntity(entityId);
- if (!entity) return;
-
- callback(entity);
- });
- }
-
- 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): T {
- return this.systems.get(name) as unknown as T;
- }
-
- public doGameLoop(timeStamp: number) {
- if (!this.running) {
- return;
- }
-
- const dt = timeStamp - this.lastTimeStamp;
- this.lastTimeStamp = timeStamp;
-
- // rebuild the Component -> { Entity } map
- this.componentEntities.clear();
- this.entities.forEach((entity) =>
- entity.getComponents().forEach((component) => {
- if (!this.componentEntities.has(component.name)) {
- this.componentEntities.set(
- component.name,
- new Set([entity.id]),
- );
- return;
- }
- this.componentEntities.get(component.name)?.add(entity.id);
- }),
- );
-
- this.systemOrder.forEach((systemName) => {
- this.systems.get(systemName)?.update(dt, this);
- });
- }
-}
+export * from "./Game";
+export * from "./TheAbstractionEngine";
diff --git a/src/engine/interfaces/Direction.ts b/src/engine/interfaces/Direction.ts
new file mode 100644
index 0000000..c2e2c1e
--- /dev/null
+++ b/src/engine/interfaces/Direction.ts
@@ -0,0 +1,7 @@
+export enum Direction {
+ UP = "UP",
+ DOWN = "DOWN",
+ LEFT = "LEFT",
+ RIGHT = "RIGHT",
+ NONE = "NONE",
+}
diff --git a/src/engine/interfaces/Draw.ts b/src/engine/interfaces/Draw.ts
new file mode 100644
index 0000000..6561a01
--- /dev/null
+++ b/src/engine/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/src/engine/interfaces/Vec2.ts b/src/engine/interfaces/Vec2.ts
new file mode 100644
index 0000000..04be4be
--- /dev/null
+++ b/src/engine/interfaces/Vec2.ts
@@ -0,0 +1,25 @@
+export interface Coord2D {
+ x: number;
+ y: number;
+}
+
+export interface Dimension2D {
+ width: number;
+ height: number;
+}
+
+export interface Velocity2D {
+ dCartesian: {
+ dx: number;
+ dy: number;
+ };
+ dTheta: number;
+}
+
+export interface Force2D {
+ fCartesian: {
+ fx: number;
+ fy: number;
+ };
+ torque: number;
+}
diff --git a/src/engine/interfaces/index.ts b/src/engine/interfaces/index.ts
new file mode 100644
index 0000000..efcc83b
--- /dev/null
+++ b/src/engine/interfaces/index.ts
@@ -0,0 +1,3 @@
+export * from "./Vec2";
+export * from "./Draw";
+export * from "./Direction";
diff --git a/src/engine/systems/Render.ts b/src/engine/systems/Render.ts
new file mode 100644
index 0000000..6f539c0
--- /dev/null
+++ b/src/engine/systems/Render.ts
@@ -0,0 +1,50 @@
+import { System, SystemNames } from ".";
+import { BoundingBox, ComponentNames, Sprite } from "../components";
+import { Game } from "..";
+import { clamp } from "../utils";
+
+export class Render extends System {
+ private ctx: CanvasRenderingContext2D;
+
+ constructor(ctx: CanvasRenderingContext2D) {
+ super(SystemNames.Render);
+ this.ctx = ctx;
+ }
+
+ public update(dt: number, game: Game) {
+ this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
+
+ game.forEachEntityWithComponent(ComponentNames.Sprite, (entity) => {
+ const sprite = entity.getComponent(ComponentNames.Sprite);
+ sprite.update(dt);
+
+ const boundingBox = entity.getComponent(
+ ComponentNames.BoundingBox,
+ );
+
+ // don't render if we're outside the screen
+ if (
+ clamp(
+ boundingBox.center.y,
+ -boundingBox.dimension.height / 2,
+ this.ctx.canvas.height + boundingBox.dimension.height / 2,
+ ) != boundingBox.center.y ||
+ clamp(
+ boundingBox.center.x,
+ -boundingBox.dimension.width / 2,
+ this.ctx.canvas.width + boundingBox.dimension.width / 2,
+ ) != boundingBox.center.x
+ ) {
+ return;
+ }
+
+ const drawArgs = {
+ center: boundingBox.center,
+ dimension: boundingBox.dimension,
+ rotation: boundingBox.rotation,
+ };
+
+ sprite.draw(this.ctx, drawArgs);
+ });
+ }
+}
diff --git a/src/engine/systems/index.ts b/src/engine/systems/index.ts
index 989dc7f..bb87060 100644
--- a/src/engine/systems/index.ts
+++ b/src/engine/systems/index.ts
@@ -1,2 +1,3 @@
export * from "./SystemNames";
export * from "./System";
+export * from "./Render";
diff --git a/src/engine/utils/clamp.ts b/src/engine/utils/clamp.ts
new file mode 100644
index 0000000..42e1764
--- /dev/null
+++ b/src/engine/utils/clamp.ts
@@ -0,0 +1,2 @@
+export const clamp = (num: number, min: number, max: number) =>
+ Math.min(Math.max(num, min), max);
diff --git a/src/engine/utils/dotProduct.ts b/src/engine/utils/dotProduct.ts
new file mode 100644
index 0000000..59f8857
--- /dev/null
+++ b/src/engine/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/src/engine/utils/index.ts b/src/engine/utils/index.ts
new file mode 100644
index 0000000..439e664
--- /dev/null
+++ b/src/engine/utils/index.ts
@@ -0,0 +1,3 @@
+export * from "./clamp";
+export * from "./dotProduct";
+export * from "./rotateVector";
diff --git a/src/engine/utils/rotateVector.ts b/src/engine/utils/rotateVector.ts
new file mode 100644
index 0000000..82bb54d
--- /dev/null
+++ b/src/engine/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,
+ };
+};