boundingbox + draw player

This commit is contained in:
Elizabeth Hunt 2024-03-01 18:56:58 -07:00
parent aa08a8943a
commit a8d07a7903
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
35 changed files with 681 additions and 96 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/assets/lambda/up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -23,6 +23,10 @@ export const App = () => {
className="tf"
>
simponic
</a>{" "}
| inspired by{" "}
<a href="https://hempuli.com/baba/" target="_blank" className="tf">
baba is you
</a>
</span>
</div>

View File

@ -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<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 (
<div className="centered-game">

90
src/engine/Game.ts Normal file
View 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);
});
}
}

View 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;
}
}
}

View 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,
};
}
}

View File

@ -1,3 +1,6 @@
export namespace ComponentNames {
export const Sprite = "Sprite";
export const FacingDirection = "FacingDirection";
export const GridPosition = "GridPosition";
export const BoundingBox = "BoundingBox";
}

View 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>();
}
}

View 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;
}
}

View 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;
}
}

View File

@ -1,2 +1,6 @@
export * from "./Component";
export * from "./ComponentNames";
export * from "./Sprite";
export * from "./FacingDirection";
export * from "./GridPosition";
export * from "./BoundingBox";

View 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
]);

View 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;
}

View File

@ -0,0 +1,3 @@
export * from "./constants";
export * from "./assets";
export * from "./sprites";

View 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);

View File

@ -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<string, Component>;
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();

View File

@ -1,5 +1,3 @@
export namespace EntityNames {
export const Player = "Player";
export const Wall = "Wall";
export const Ball = "Ball";
}

View 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
}
}

View File

@ -1,2 +1,3 @@
export * from "./Entity";
export * from "./EntityNames";
export * from "./Player";

View File

@ -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<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);
});
}
}
export * from "./Game";
export * from "./TheAbstractionEngine";

View File

@ -0,0 +1,7 @@
export enum Direction {
UP = "UP",
DOWN = "DOWN",
LEFT = "LEFT",
RIGHT = "RIGHT",
NONE = "NONE",
}

View File

@ -0,0 +1,9 @@
import type { Coord2D, Dimension2D } from "./";
export interface DrawArgs {
center: Coord2D;
dimension: Dimension2D;
tint?: string;
opacity?: number;
rotation?: number;
}

View 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;
}

View File

@ -0,0 +1,3 @@
export * from "./Vec2";
export * from "./Draw";
export * from "./Direction";

View 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);
});
}
}

View File

@ -1,2 +1,3 @@
export * from "./SystemNames";
export * from "./System";
export * from "./Render";

View File

@ -0,0 +1,2 @@
export const clamp = (num: number, min: number, max: number) =>
Math.min(Math.max(num, min), max);

View 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;

View File

@ -0,0 +1,3 @@
export * from "./clamp";
export * from "./dotProduct";
export * from "./rotateVector";

View 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,
};
};