diff --git a/public/assets/lambda.png b/public/assets/lambda.png deleted file mode 100644 index d90ed51..0000000 Binary files a/public/assets/lambda.png and /dev/null differ diff --git a/public/assets/lambda/down.png b/public/assets/lambda/down.png new file mode 100644 index 0000000..afd6b36 Binary files /dev/null and b/public/assets/lambda/down.png differ diff --git a/public/assets/lambda/left.png b/public/assets/lambda/left.png new file mode 100644 index 0000000..1f80d9d Binary files /dev/null and b/public/assets/lambda/left.png differ diff --git a/public/assets/lambda/neutral.png b/public/assets/lambda/neutral.png new file mode 100644 index 0000000..ade570e Binary files /dev/null and b/public/assets/lambda/neutral.png differ diff --git a/public/assets/lambda/right.png b/public/assets/lambda/right.png new file mode 100644 index 0000000..5ea80cb Binary files /dev/null and b/public/assets/lambda/right.png differ diff --git a/public/assets/lambda/up.png b/public/assets/lambda/up.png new file mode 100644 index 0000000..7da6755 Binary files /dev/null and b/public/assets/lambda/up.png differ diff --git a/src/App.tsx b/src/App.tsx index 9c5f790..1ed1ffd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,10 @@ export const App = () => { className="tf" > simponic + {" "} + | inspired by{" "} + + baba is you diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index ea93c64..5cb40a6 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -1,4 +1,5 @@ -import { useRef } from "react"; +import { useState, useEffect, useRef } from "react"; +import { TheAbstractionEngine, Game } from "../engine"; export interface GameCanvasProps { width: number; @@ -7,6 +8,25 @@ export interface GameCanvasProps { export const GameCanvas = ({ width, height }: GameCanvasProps) => { const canvasRef = useRef(null); + const [_game, setGame] = useState(); + + 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, + }; +};