From a8d07a790395e14fe7aedd3ba638db466f9c0842 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Fri, 1 Mar 2024 18:56:58 -0700 Subject: [PATCH] boundingbox + draw player --- public/assets/lambda.png | Bin 1557 -> 0 bytes public/assets/lambda/down.png | Bin 0 -> 1458 bytes public/assets/lambda/left.png | Bin 0 -> 1461 bytes public/assets/lambda/neutral.png | Bin 0 -> 1494 bytes public/assets/lambda/right.png | Bin 0 -> 1512 bytes public/assets/lambda/up.png | Bin 0 -> 1545 bytes src/App.tsx | 4 + src/components/GameCanvas.tsx | 22 +++- src/engine/Game.ts | 90 +++++++++++++++++ src/engine/TheAbstractionEngine.ts | 42 ++++++++ src/engine/components/BoundingBox.ts | 122 +++++++++++++++++++++++ src/engine/components/ComponentNames.ts | 3 + src/engine/components/FacingDirection.ts | 12 +++ src/engine/components/GridPosition.ts | 13 +++ src/engine/components/Sprite.ts | 96 ++++++++++++++++++ src/engine/components/index.ts | 4 + src/engine/config/assets.ts | 42 ++++++++ src/engine/config/constants.ts | 7 ++ src/engine/config/index.ts | 3 + src/engine/config/sprites.ts | 39 ++++++++ src/engine/entities/Entity.ts | 6 +- src/engine/entities/EntityNames.ts | 2 - src/engine/entities/Player.ts | 58 +++++++++++ src/engine/entities/index.ts | 1 + src/engine/index.ts | 92 +---------------- src/engine/interfaces/Direction.ts | 7 ++ src/engine/interfaces/Draw.ts | 9 ++ src/engine/interfaces/Vec2.ts | 25 +++++ src/engine/interfaces/index.ts | 3 + src/engine/systems/Render.ts | 50 ++++++++++ src/engine/systems/index.ts | 1 + src/engine/utils/clamp.ts | 2 + src/engine/utils/dotProduct.ts | 4 + src/engine/utils/index.ts | 3 + src/engine/utils/rotateVector.ts | 15 +++ 35 files changed, 681 insertions(+), 96 deletions(-) delete mode 100644 public/assets/lambda.png create mode 100644 public/assets/lambda/down.png create mode 100644 public/assets/lambda/left.png create mode 100644 public/assets/lambda/neutral.png create mode 100644 public/assets/lambda/right.png create mode 100644 public/assets/lambda/up.png create mode 100644 src/engine/Game.ts create mode 100644 src/engine/TheAbstractionEngine.ts create mode 100644 src/engine/components/BoundingBox.ts create mode 100644 src/engine/components/FacingDirection.ts create mode 100644 src/engine/components/GridPosition.ts create mode 100644 src/engine/components/Sprite.ts create mode 100644 src/engine/config/assets.ts create mode 100644 src/engine/config/constants.ts create mode 100644 src/engine/config/index.ts create mode 100644 src/engine/config/sprites.ts create mode 100644 src/engine/entities/Player.ts create mode 100644 src/engine/interfaces/Direction.ts create mode 100644 src/engine/interfaces/Draw.ts create mode 100644 src/engine/interfaces/Vec2.ts create mode 100644 src/engine/interfaces/index.ts create mode 100644 src/engine/systems/Render.ts create mode 100644 src/engine/utils/clamp.ts create mode 100644 src/engine/utils/dotProduct.ts create mode 100644 src/engine/utils/index.ts create mode 100644 src/engine/utils/rotateVector.ts diff --git a/public/assets/lambda.png b/public/assets/lambda.png deleted file mode 100644 index d90ed51334332a59148864791ab6e00ce116e441..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1557 zcmV+w2I~2VP)k+gpg%|KJY81WZWSxwO6AS>RM zN=G`#!ozARBfK1FUuwRQZ%4n-C~Q$kqiY*fSi1w*jab zuf|{5{Lp)Y6^=tH2FOqeBtFtufU`fa#!se}`j7~fv_LWweTM?S_(nR0o2Jb$Kf7sOCzWl07G6>0x+>_jkz)hRP4p<*2f_?17JEq zT>-pE&=NRi9Y_eRgjHM zu>qiSTYY|mOdtg`Yd*xo*|;?a&!0X06ZY}$VC7FnxCNl#U-C`x$nm|_jeQuLC1&cr zqa#YMa}^Fs@-rFWZ1xuZbN*-JSL*g|0Ef`L2+$HWWgX}np|Vz14lww=u)7-o1AZIepPjsfKrygnEH?jM z8VG9uF9IaV_ZBFNkcB-pJq;9>wub*GH=7O!MKYGtR6 zm&E2V7n@h?IIWnc8G!o#&Rd@awFvF4!%1u=6JV-;;f})bo}73`YY<1}NmG=aJStb)}fR6E(N^(^DH;#i9p?f5K@ z!m0+)1`f5;9^71127iSY`=ub}_oN0ismD zC`XYQ-6uj0B<5G|03OvY;(fRz37q3!YtF0~W*HCaz8Ed0FhHvcVZdt%FG3ZZc`X{j z09*}h11Py5iU^e8kd}#popS{%HUNpKJvb!;9N|SF;x|8@LX1GxU6=p>002ovPDHLk HV1fVu0SC7G diff --git a/public/assets/lambda/down.png b/public/assets/lambda/down.png new file mode 100644 index 0000000000000000000000000000000000000000..afd6b3696cce2d8bf8db2f6095cc4db7949d1d45 GIT binary patch literal 1458 zcmV;j1x@;iP)~i#}dQENbv>@fB}rL??|K+JX+X~^+v=m~#I{h`F2|+@n4t~Q5XTIy zkc5ZKj{+eE*j3<8PODUb%&nlg0d^I*6Z`5_AS(x923Rcv?nIC?5x5v&2R~=|{51sHQyuQAcV_rlrRCz^va z2B=%1*$i-nCp!3E27qFT;Q?BMizSr-&L*$|%q2=XuZ1Tv_)<*R`A{q||8Ndk1ACTw zTsi|36IcP}5+$8SsQ_iK(iV=+MeqEO_-ihtaR@WO)xj~@PaAO5dJ+Rfomp#NYV~XF zYw@=d(+cqY{m1{lIqnSZ$D?1DSfBlQ0&@TS%`4Nhg9lI;fXRL(=q)Zs<^hBMOf#el z4i}~(+6L&m1S0^*0A&YG@D)7VTmp*yG4Uz^_P%9f8{kBDwt!zLp(lb?^}AZ<5CtTt zEPO!ctnrfcfN-*T?(2Eg#g`L+-%UAP&50)R3WLwL3^SOLt+LyZe#YAY^M8sHEt zRwUWPjX4hm8)YnJ?+jx&254zQ+7`MPfa6}yoD9K*&OjCPj684+V38Qj4Il|1RV);YwHZl7@+r;&d9zmN*vr|z?~O`v$wB?nKjlZXEdz<8iCVTBa09ufs@S5 zf?unJ(d5F+09wM8R*@{hm+~Q)L0nuZz?zh+WYHGq}VF@Uq0z=9KtHLZ-`aaKt}FgJiC)3t)4QTnU`Uau<& z&d8O}Ge#1VsTn|W2#y#%3Fe&7`r_`*H-&>9aOtbR4pn@ao|Kx^svs0LO7q8RdQ+DFVm< zP!PD7M@Rr3*d_c;V}RDPqelQT7$75l5AG%6Kjuzl08R;%5@`M2PdQ&yMXvW9LHLDN zkpU>d@ftgFFf=$?9Y2-B=?p+6V2@v$fo*`bkz0Nk~beVO2tDF6Tf M07*qoM6N<$g0Xsn)c^nh literal 0 HcmV?d00001 diff --git a/public/assets/lambda/left.png b/public/assets/lambda/left.png new file mode 100644 index 0000000000000000000000000000000000000000..1f80d9da80354785e14ae4f8a72704fdbfeeaed7 GIT binary patch literal 1461 zcmV;m1xosfP)%09YQFS^*INAK$^8 zQ)goZ@2&vJtXGEb?RZoeARR`H+ufZG4@3h1CB3!RQOv-Jf0WoAd>Hl!6abv$A0w)6 zgxTXi<`SbtrF??`U;@VATS=5X9yFpXa(1rQ$dU~$QS_jxF};W z1tZHCOk9=kfh+(ZGmioRCl1Ikkak&&FRlJnj=*F9un8RD6)86cPUp;Kd<+0bFF@dD zJqD5hz?y#xmTR6&MmWbniNB>mRwJ}@r)W?S0y6+cfPj+xD+6(kP?-wARSq#l00IHC zhL;ZDTOi3ss&@e?03&n(t_D>ga0j5;5kOJ^dgtg&RZt^F>=7#4<=7N}5spC`<{059 zB+()BOMwsoRu_1%(=1&e^HES8fL}j8{r3&gr_Wy8s^0}9)em}|xFCov1SkNmOx-dC z7J$(!AXbN&0La)0TmYEyE`SAKwnFgv%hzT@jVK1808~sqwi~v_Rlg8prF+mBX ztXH`UXaSG~0yF?urhfkV<>mYZzyl7D4#y&}1C+fOPyv7nLv%3!0q7-B3xF!^e@`Y> z6Al2?v}hR|ihcLa0^p!v*6If(^K61WxzN0;4lo-4(MKVZ4!~`DS(6VLnG3)MN^}K; za)s%7SMfud9ZwIDUz}qB&^r2H#q3d^5dtWgTmV`fAme?2qQT}5XgqWTI%{fmIsgHn zn3_6v#Ok+cfGrS_m5-V$$k502wBq-GAPj&?$)}nHJ08*?Q#V`yV6%!GS1I|(kjTSk z)&T1M(C1{a8xul004DJn&%s)vLw6vL)~^{0XD!UP0z4pC0C2LWFsEezkEAPXU%Z3_ z0L`PJI{>`|&yxIB2iV?pWd8M(*!6!Gz?gr3mbE8R0D$@bWvpI7@9F-sAkJH9%F!!b-=`b&Y6`SZU9gMpp4CCZp?nL1{hU`UiWM1XQf3- z0D5O+jKQ*x!2&R<4jcfijzTT~aOmaC$!>g(p-0q#3jjwy$P?xOND@e?lu)2qKIg>5 znUf{nt9?uZfb?yK1fGlr;Ksw9S49Kr+Z@e;VA=sR5~s0779&U!Cz+cieyt;nrWR%Z zXc<@9MY1Gcs)uxb1|76w&{}8H!~_7%4fbyT+h^4F8D#{Gs->-~rxx0K*IKUx09LQS zq76WWS1akBaqrL4-n-VxKpg<>K#$3?T-(wV8r9#*NS1M1?^y!^7XYZ0u9uXG5NHCR zb&>Qieh&aGPe}cs3V?Jon51Y>k9m&P7z<4-*3^pkypG3SB?ZPD04>rnN1=?DVo7o; zcS6rXC4o#00Ldme)yGp{&i^{osh z-P1nbDmUDMpaKB*;aE{C9tAu!@puL`N0FVGQ*gupWJm%haB5;H<2^O;d={iZ^j^nP9of(-j`2OygGEcXHE(q|20bRII8zx8hp(49ln0XW1#ZIt_YqzNDZ zpd@f(u229xVwdPQ3;?ayjvfif06@n0J+zmYf15iL09PjPNJ3=yfx19M{2tGg8h@WJ z0zhjMn(Lg+SScAAI?NtFRm0%`pbD_Y&#k}$VD9`+AN>MgdJD`gz#q<7vwa03x1az3 P002ovPDHLkV1fVu#BFlC literal 0 HcmV?d00001 diff --git a/public/assets/lambda/neutral.png b/public/assets/lambda/neutral.png new file mode 100644 index 0000000000000000000000000000000000000000..ade570e2a9acdb88d1daa3e46245e14711f836b1 GIT binary patch literal 1494 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 { 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, + }; +};