boundingbox + draw player
This commit is contained in:
parent
aa08a8943a
commit
a8d07a7903
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/lambda/down.png
Normal file
BIN
public/assets/lambda/down.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/lambda/left.png
Normal file
BIN
public/assets/lambda/left.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/lambda/neutral.png
Normal file
BIN
public/assets/lambda/neutral.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/lambda/right.png
Normal file
BIN
public/assets/lambda/right.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/lambda/up.png
Normal file
BIN
public/assets/lambda/up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
@ -23,6 +23,10 @@ export const App = () => {
|
|||||||
className="tf"
|
className="tf"
|
||||||
>
|
>
|
||||||
simponic
|
simponic
|
||||||
|
</a>{" "}
|
||||||
|
| inspired by{" "}
|
||||||
|
<a href="https://hempuli.com/baba/" target="_blank" className="tf">
|
||||||
|
baba is you
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { TheAbstractionEngine, Game } from "../engine";
|
||||||
|
|
||||||
export interface GameCanvasProps {
|
export interface GameCanvasProps {
|
||||||
width: number;
|
width: number;
|
||||||
@ -7,6 +8,25 @@ export interface GameCanvasProps {
|
|||||||
|
|
||||||
export const GameCanvas = ({ width, height }: GameCanvasProps) => {
|
export const GameCanvas = ({ width, height }: GameCanvasProps) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [_game, setGame] = useState<TheAbstractionEngine>();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="centered-game">
|
<div className="centered-game">
|
||||||
|
90
src/engine/Game.ts
Normal file
90
src/engine/Game.ts
Normal file
@ -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<string, Entity>;
|
||||||
|
public systems: Map<string, System>;
|
||||||
|
public componentEntities: Map<string, Set<string>>;
|
||||||
|
|
||||||
|
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<T>(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<string>([entity.id]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.componentEntities.get(component.name)?.add(entity.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.systemOrder.forEach((systemName) => {
|
||||||
|
this.systems.get(systemName)?.update(dt, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
42
src/engine/TheAbstractionEngine.ts
Normal file
42
src/engine/TheAbstractionEngine.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
122
src/engine/components/BoundingBox.ts
Normal file
122
src/engine/components/BoundingBox.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,6 @@
|
|||||||
export namespace ComponentNames {
|
export namespace ComponentNames {
|
||||||
export const Sprite = "Sprite";
|
export const Sprite = "Sprite";
|
||||||
|
export const FacingDirection = "FacingDirection";
|
||||||
|
export const GridPosition = "GridPosition";
|
||||||
|
export const BoundingBox = "BoundingBox";
|
||||||
}
|
}
|
||||||
|
12
src/engine/components/FacingDirection.ts
Normal file
12
src/engine/components/FacingDirection.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Component, ComponentNames, Sprite } from ".";
|
||||||
|
import { type Direction } from "../interfaces";
|
||||||
|
|
||||||
|
export class FacingDirection extends Component {
|
||||||
|
public readonly directionSprites: Map<Direction, Sprite>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(ComponentNames.FacingDirection);
|
||||||
|
|
||||||
|
this.directionSprites = new Map<Direction, Sprite>();
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/components/GridPosition.ts
Normal file
13
src/engine/components/GridPosition.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
96
src/engine/components/Sprite.ts
Normal file
96
src/engine/components/Sprite.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,2 +1,6 @@
|
|||||||
export * from "./Component";
|
export * from "./Component";
|
||||||
export * from "./ComponentNames";
|
export * from "./ComponentNames";
|
||||||
|
export * from "./Sprite";
|
||||||
|
export * from "./FacingDirection";
|
||||||
|
export * from "./GridPosition";
|
||||||
|
export * from "./BoundingBox";
|
||||||
|
42
src/engine/config/assets.ts
Normal file
42
src/engine/config/assets.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { SpriteSpec } from "./sprites";
|
||||||
|
import { SPRITE_SPECS } from "./sprites";
|
||||||
|
|
||||||
|
export const IMAGES = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
export const loadSpritesIntoImageElements = (
|
||||||
|
spriteSpecs: Partial<SpriteSpec>[],
|
||||||
|
): Promise<void>[] => {
|
||||||
|
const spritePromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const spriteSpec of spriteSpecs) {
|
||||||
|
if (spriteSpec.sheet) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = spriteSpec.sheet;
|
||||||
|
IMAGES.set(spriteSpec.sheet, img);
|
||||||
|
|
||||||
|
spritePromises.push(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spriteSpec.states) {
|
||||||
|
spritePromises.push(
|
||||||
|
...loadSpritesIntoImageElements(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
|
||||||
|
]);
|
7
src/engine/config/constants.ts
Normal file
7
src/engine/config/constants.ts
Normal file
@ -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;
|
||||||
|
}
|
3
src/engine/config/index.ts
Normal file
3
src/engine/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./constants";
|
||||||
|
export * from "./assets";
|
||||||
|
export * from "./sprites";
|
39
src/engine/config/sprites.ts
Normal file
39
src/engine/config/sprites.ts
Normal file
@ -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<string | number, Partial<SpriteSpec>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPRITE_SPECS: Map<Sprites, Partial<SpriteSpec>> = new Map<
|
||||||
|
Sprites,
|
||||||
|
SpriteSpec
|
||||||
|
>();
|
||||||
|
|
||||||
|
const playerSpriteSpec = {
|
||||||
|
msPerFrame: 200,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
frames: 3,
|
||||||
|
states: new Map<string, Partial<SpriteSpec>>(),
|
||||||
|
};
|
||||||
|
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);
|
@ -1,13 +1,13 @@
|
|||||||
import { type Component } from "../components";
|
import { type Component } from "../components";
|
||||||
|
|
||||||
const randomId = () => (Math.random() * 1_000_000_000).toString();
|
|
||||||
|
|
||||||
export abstract class Entity {
|
export abstract class Entity {
|
||||||
|
static Id = 0;
|
||||||
|
|
||||||
public id: string;
|
public id: string;
|
||||||
public components: Map<string, Component>;
|
public components: Map<string, Component>;
|
||||||
public name: string;
|
public name: string;
|
||||||
|
|
||||||
constructor(name: string, id: string = randomId()) {
|
constructor(name: string, id: string = (Entity.Id++).toString()) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.components = new Map();
|
this.components = new Map();
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
export namespace EntityNames {
|
export namespace EntityNames {
|
||||||
export const Player = "Player";
|
export const Player = "Player";
|
||||||
export const Wall = "Wall";
|
|
||||||
export const Ball = "Ball";
|
|
||||||
}
|
}
|
||||||
|
58
src/engine/entities/Player.ts
Normal file
58
src/engine/entities/Player.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./Entity";
|
export * from "./Entity";
|
||||||
export * from "./EntityNames";
|
export * from "./EntityNames";
|
||||||
|
export * from "./Player";
|
||||||
|
@ -1,90 +1,2 @@
|
|||||||
import { Entity } from "./entities";
|
export * from "./Game";
|
||||||
import { System } from "./systems";
|
export * from "./TheAbstractionEngine";
|
||||||
|
|
||||||
export class Game {
|
|
||||||
private systemOrder: string[];
|
|
||||||
|
|
||||||
private running: boolean;
|
|
||||||
private lastTimeStamp: number;
|
|
||||||
|
|
||||||
public entities: Map<string, Entity>;
|
|
||||||
public systems: Map<string, System>;
|
|
||||||
public componentEntities: Map<string, Set<string>>;
|
|
||||||
|
|
||||||
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<T>(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<string>([entity.id]),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.componentEntities.get(component.name)?.add(entity.id);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.systemOrder.forEach((systemName) => {
|
|
||||||
this.systems.get(systemName)?.update(dt, this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
7
src/engine/interfaces/Direction.ts
Normal file
7
src/engine/interfaces/Direction.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export enum Direction {
|
||||||
|
UP = "UP",
|
||||||
|
DOWN = "DOWN",
|
||||||
|
LEFT = "LEFT",
|
||||||
|
RIGHT = "RIGHT",
|
||||||
|
NONE = "NONE",
|
||||||
|
}
|
9
src/engine/interfaces/Draw.ts
Normal file
9
src/engine/interfaces/Draw.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { Coord2D, Dimension2D } from "./";
|
||||||
|
|
||||||
|
export interface DrawArgs {
|
||||||
|
center: Coord2D;
|
||||||
|
dimension: Dimension2D;
|
||||||
|
tint?: string;
|
||||||
|
opacity?: number;
|
||||||
|
rotation?: number;
|
||||||
|
}
|
25
src/engine/interfaces/Vec2.ts
Normal file
25
src/engine/interfaces/Vec2.ts
Normal file
@ -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;
|
||||||
|
}
|
3
src/engine/interfaces/index.ts
Normal file
3
src/engine/interfaces/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./Vec2";
|
||||||
|
export * from "./Draw";
|
||||||
|
export * from "./Direction";
|
50
src/engine/systems/Render.ts
Normal file
50
src/engine/systems/Render.ts
Normal file
@ -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<Sprite>(ComponentNames.Sprite);
|
||||||
|
sprite.update(dt);
|
||||||
|
|
||||||
|
const boundingBox = entity.getComponent<BoundingBox>(
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./SystemNames";
|
export * from "./SystemNames";
|
||||||
export * from "./System";
|
export * from "./System";
|
||||||
|
export * from "./Render";
|
||||||
|
2
src/engine/utils/clamp.ts
Normal file
2
src/engine/utils/clamp.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const clamp = (num: number, min: number, max: number) =>
|
||||||
|
Math.min(Math.max(num, min), max);
|
4
src/engine/utils/dotProduct.ts
Normal file
4
src/engine/utils/dotProduct.ts
Normal file
@ -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;
|
3
src/engine/utils/index.ts
Normal file
3
src/engine/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./clamp";
|
||||||
|
export * from "./dotProduct";
|
||||||
|
export * from "./rotateVector";
|
15
src/engine/utils/rotateVector.ts
Normal file
15
src/engine/utils/rotateVector.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user