diff --git a/src/engine/TheAbstractionEngine.ts b/src/engine/TheAbstractionEngine.ts index d7dd28b..20ff6cc 100644 --- a/src/engine/TheAbstractionEngine.ts +++ b/src/engine/TheAbstractionEngine.ts @@ -15,6 +15,7 @@ import { Render, Collision, GridSpawner, + Life, } from "./systems"; export class TheAbstractionEngine { @@ -49,6 +50,7 @@ export class TheAbstractionEngine { new GridSpawner(), new Collision(), new Render(this.ctx), + new Life(), ].forEach((system) => this.game.addSystem(system)); const player = new Player(); diff --git a/src/engine/components/Colliding.ts b/src/engine/components/Colliding.ts index 4027c3d..fe782df 100644 --- a/src/engine/components/Colliding.ts +++ b/src/engine/components/Colliding.ts @@ -1,7 +1,13 @@ import { Component, ComponentNames } from "."; +import { Game } from ".."; +import { Entity } from "../entities"; export class Colliding extends Component { - constructor() { + public onCollision?: (game: Game, entity: Entity) => void; + + constructor(onCollision?: (game: Game, entity: Entity) => void) { super(ComponentNames.Colliding); + + this.onCollision = onCollision; } } diff --git a/src/engine/components/ComponentNames.ts b/src/engine/components/ComponentNames.ts index a9f0c15..dd50fb3 100644 --- a/src/engine/components/ComponentNames.ts +++ b/src/engine/components/ComponentNames.ts @@ -11,4 +11,5 @@ export namespace ComponentNames { export const GridSpawn = "GridSpawn"; export const Text = "Text"; export const LambdaTerm = "LambdaTerm"; + export const Life = "Life"; } diff --git a/src/engine/components/Life.ts b/src/engine/components/Life.ts new file mode 100644 index 0000000..6e6d278 --- /dev/null +++ b/src/engine/components/Life.ts @@ -0,0 +1,11 @@ +import { Component, ComponentNames } from "."; + +export class Life extends Component { + public alive: boolean = true; + + constructor(alive: boolean) { + super(ComponentNames.Life); + + this.alive = alive; + } +} diff --git a/src/engine/components/Sprite.ts b/src/engine/components/Sprite.ts index c623bac..fdf9675 100644 --- a/src/engine/components/Sprite.ts +++ b/src/engine/components/Sprite.ts @@ -2,7 +2,12 @@ import { Component, ComponentNames } from "."; import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces"; import { clamp } from "../utils"; -export class Sprite extends Component { +export interface Renderable { + update(dt: number): void; + draw(ctx: CanvasRenderingContext2D, drawArgs: DrawArgs): void; +} + +export class Sprite extends Component implements Renderable { private sheet: HTMLImageElement; private spriteImgPos: Coord2D; diff --git a/src/engine/components/index.ts b/src/engine/components/index.ts index a7a3cf1..023e73d 100644 --- a/src/engine/components/index.ts +++ b/src/engine/components/index.ts @@ -12,3 +12,4 @@ export * from "./Colliding"; export * from "./GridSpawn"; export * from "./Text"; export * from "./LambdaTerm"; +export * from "./Life"; diff --git a/src/engine/entities/Curry.ts b/src/engine/entities/Curry.ts index 85bc7ef..bd57e19 100644 --- a/src/engine/entities/Curry.ts +++ b/src/engine/entities/Curry.ts @@ -1,4 +1,5 @@ import { Entity, EntityNames } from "."; +import { Game } from ".."; import { BoundingBox, Colliding, Grid, Sprite } from "../components"; import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { Coord2D } from "../interfaces"; @@ -13,7 +14,7 @@ export class Curry extends Entity { this.addComponent(new Grid(gridPosition)); - this.addComponent(new Colliding()); + this.addComponent(new Colliding(this.collisionHandler)); this.addComponent( new BoundingBox( @@ -42,4 +43,11 @@ export class Curry extends Entity { ), ); } + + private collisionHandler(game: Game, entity: Entity) { + if (entity.name === EntityNames.Player) { + game.removeEntity(this.id); + game.stop(); + } + } } diff --git a/src/engine/entities/Entity.ts b/src/engine/entities/Entity.ts index d5a8e6e..a9c0d4b 100644 --- a/src/engine/entities/Entity.ts +++ b/src/engine/entities/Entity.ts @@ -29,7 +29,7 @@ export abstract class Entity { this.hooks.get(name)?.remove(); } - public getComponent(name: string): T { + public getComponent(name: string): T { if (!this.hasComponent(name)) { throw new Error("Entity does not have component " + name); } diff --git a/src/engine/entities/EntityNames.ts b/src/engine/entities/EntityNames.ts index 3f6d26f..056db9a 100644 --- a/src/engine/entities/EntityNames.ts +++ b/src/engine/entities/EntityNames.ts @@ -7,4 +7,5 @@ export namespace EntityNames { export const LockedDoor = "LockedDoor"; export const Curry = "Curry"; export const FunctionApplication = "FunctionApplication"; + export const Particles = "Particles"; } diff --git a/src/engine/entities/LockedDoor.ts b/src/engine/entities/LockedDoor.ts index 5e364b8..b4887d6 100644 --- a/src/engine/entities/LockedDoor.ts +++ b/src/engine/entities/LockedDoor.ts @@ -1,7 +1,16 @@ -import { Entity, EntityNames } from "."; -import { BoundingBox, Colliding, Grid, Sprite } from "../components"; +import { Entity, EntityNames, Particles } from "."; +import { Game } from ".."; +import { + BoundingBox, + Colliding, + Grid, + Sprite, + ComponentNames, +} from "../components"; import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { Coord2D } from "../interfaces"; +import { Grid as GridSystem, SystemNames } from "../systems"; +import { colors } from "../utils"; export class LockedDoor extends Entity { private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( @@ -13,7 +22,7 @@ export class LockedDoor extends Entity { this.addComponent(new Grid(gridPosition)); - this.addComponent(new Colliding()); + this.addComponent(new Colliding(this.handleCollision.bind(this))); this.addComponent( new BoundingBox( @@ -42,4 +51,36 @@ export class LockedDoor extends Entity { ), ); } + + private handleCollision(game: Game, entity: Entity) { + if (entity.name !== EntityNames.Key) { + return; + } + + game.removeEntity(this.id); + game.removeEntity(entity.id); + + const grid = this.getComponent(ComponentNames.Grid); + const gridSystem = game.getSystem(SystemNames.Grid); + const { dimension } = gridSystem; + const particles = new Particles({ + center: gridSystem.gridToScreenPosition(grid.gridPosition), + spawnerDimensions: { + width: dimension.width / 2, + height: dimension.height / 2, + }, + particleCount: 20, + spawnerShape: "rectangle", + particleShape: "rectangle", + particleMeanSpeed: 0.35, + particleSpeedVariance: 0.15, + particleMeanLife: 80, + particleMeanSize: 3, + particleSizeVariance: 1, + particleLifeVariance: 20, + particleColors: [colors.yellow, colors.lightYellow], + }); + + game.addEntity(particles); + } } diff --git a/src/engine/entities/Particles.ts b/src/engine/entities/Particles.ts new file mode 100644 index 0000000..34b475c --- /dev/null +++ b/src/engine/entities/Particles.ts @@ -0,0 +1,194 @@ +import { Entity, EntityNames } from "."; +import { + BoundingBox, + Component, + ComponentNames, + Life, + Renderable, +} from "../components"; +import { Coord2D, Dimension2D, DrawArgs } from "../interfaces"; +import { colors } from "../utils"; +import { normalRandom } from "../utils/random"; + +export interface ParticleSpawnOptions { + spawnerDimensions: Dimension2D; + center: Coord2D; + spawnerShape: "circle" | "rectangle"; + particleShape: "circle" | "rectangle"; + particleCount: number; + particleMeanLife: number; + particleLifeVariance: number; + particleMeanSize: number; + particleSizeVariance: number; + particleMeanSpeed: number; + particleSpeedVariance: number; + particleColors: Array; +} + +const DEFAULT_PARTICLE_SPAWN_OPTIONS: ParticleSpawnOptions = { + spawnerDimensions: { width: 0, height: 0 }, + center: { x: 0, y: 0 }, + spawnerShape: "circle", + particleShape: "circle", + particleCount: 50, + particleMeanLife: 200, + particleLifeVariance: 50, + particleMeanSize: 12, + particleSizeVariance: 1, + particleMeanSpeed: 2, + particleSpeedVariance: 1, + particleColors: [colors.gray, colors.aqua, colors.lightAqua], +}; + +interface Particle { + position: Coord2D; + velocity: Coord2D; + dimension: Dimension2D; + color: string; + life: number; + shape: "circle" | "rectangle"; +} + +class ParticleRenderer extends Component implements Renderable { + private particles: Array; + private onDeath?: () => void; + + constructor(particles: Array = [], onDeath?: () => void) { + super(ComponentNames.Sprite); + + this.particles = particles; + this.onDeath = onDeath; + } + + public update(dt: number) { + this.particles = this.particles.filter((particle) => { + particle.position.x += particle.velocity.x * dt; + particle.position.y += particle.velocity.y * dt; + particle.life -= dt; + return particle.life > 0; + }); + + if (this.particles.length === 0 && this.onDeath) { + this.onDeath(); + } + } + + public draw(ctx: CanvasRenderingContext2D, _drawArgs: DrawArgs) { + for (const particle of this.particles) { + ctx.fillStyle = particle.color; + if (particle.shape === "circle") { + ctx.beginPath(); + + ctx.ellipse( + particle.position.x, + particle.position.y, + particle.dimension.width / 2, + particle.dimension.height / 2, + 0, + 0, + Math.PI * 2, + ); + ctx.fill(); + } else { + ctx.fillRect( + particle.position.x - particle.dimension.width / 2, + particle.position.y - particle.dimension.height / 2, + particle.dimension.width, + particle.dimension.height, + ); + } + } + } +} + +export class Particles extends Entity { + constructor(options: Partial) { + super(EntityNames.Particles); + + const spawnOptions = { + ...DEFAULT_PARTICLE_SPAWN_OPTIONS, + ...options, + }; + const particles = Array(options.particleCount) + .fill(0) + .map(() => Particles.spawnParticle(spawnOptions)); + + this.addComponent(new Life(true)); + this.addComponent( + new ParticleRenderer(particles, () => { + const life = this.getComponent(ComponentNames.Life); + life.alive = false; + this.addComponent(life); + }), + ); + + this.addComponent( + new BoundingBox( + { + x: 0, + y: 0, + }, + { + width: spawnOptions.spawnerDimensions.width, + height: spawnOptions.spawnerDimensions.height, + }, + 0, + ), + ); + } + + static spawnParticle(options: ParticleSpawnOptions) { + const angle = Math.random() * Math.PI * 2; + const speed = normalRandom( + options.particleMeanSpeed, + options.particleSpeedVariance, + ); + const life = normalRandom( + options.particleMeanLife, + options.particleLifeVariance, + ); + const size = normalRandom( + options.particleMeanSize, + options.particleSizeVariance, + ); + const color = + options.particleColors[ + Math.floor(Math.random() * options.particleColors.length) + ]; + const position = { + x: options.center.x + Math.cos(angle) * options.spawnerDimensions.width, + y: options.center.y + Math.sin(angle) * options.spawnerDimensions.height, + }; + if (options.spawnerShape === "rectangle") { + // determine a random position on the edge of the spawner based on the angle + const halfWidth = options.spawnerDimensions.width / 2; + const halfHeight = options.spawnerDimensions.height / 2; + + if (angle < Math.PI / 4 || angle > (Math.PI * 7) / 4) { + position.x += halfWidth; + position.y += Math.tan(angle) * halfWidth; + } else if (angle < (Math.PI * 3) / 4) { + position.y += halfHeight; + position.x += halfHeight / Math.tan(angle); + } else if (angle < (Math.PI * 5) / 4) { + position.x -= halfWidth; + position.y -= Math.tan(angle) * halfWidth; + } else { + position.y -= halfHeight; + position.x -= halfHeight / Math.tan(angle); + } + } + + return { + position, + velocity: { + x: Math.cos(angle) * speed, + y: Math.sin(angle) * speed, + }, + color, + life, + dimension: { width: size, height: size }, + shape: options.particleShape, + }; + } +} diff --git a/src/engine/entities/index.ts b/src/engine/entities/index.ts index 45c95c0..cb256ec 100644 --- a/src/engine/entities/index.ts +++ b/src/engine/entities/index.ts @@ -7,3 +7,5 @@ export * from "./LambdaFactory"; export * from "./Key"; export * from "./LockedDoor"; export * from "./Curry"; +export * from "./FunctionApplication"; +export * from "./Particles"; diff --git a/src/engine/systems/Collision.ts b/src/engine/systems/Collision.ts index 1912fb6..8ef8215 100644 --- a/src/engine/systems/Collision.ts +++ b/src/engine/systems/Collision.ts @@ -1,7 +1,7 @@ import { System, SystemNames } from "."; import { Game } from ".."; import { Entity, EntityNames } from "../entities"; -import { BoundingBox, ComponentNames, Grid } from "../components"; +import { BoundingBox, Colliding, ComponentNames, Grid } from "../components"; const collisionMap: Record> = { [EntityNames.Key]: new Set([EntityNames.LockedDoor]), @@ -70,34 +70,14 @@ export class Collision extends System { return; } - const keyDoorPair = [EntityNames.Key, EntityNames.LockedDoor].map((x) => - [entity, otherEntity].find((y) => y.name === x), - ); - const [key, door] = keyDoorPair; - if (key && door) { - this.handleKeyDoorCollision(key, door, game); - } - - const curryPlayerPair = [EntityNames.Curry, EntityNames.Player].map((x) => - [entity, otherEntity].find((y) => y.name === x), - ); - const [curry, player] = curryPlayerPair; - if (curry && player) { - this.handleCurryPlayerCollision(curry, player, game); - } - } - - private handleKeyDoorCollision(key: Entity, door: Entity, game: Game) { - game.removeEntity(key.id); - game.removeEntity(door.id); - } - - private handleCurryPlayerCollision( - curry: Entity, - _player: Entity, - game: Game, - ) { - game.removeEntity(curry.id); - game.stop(); + [entity, otherEntity].forEach((e) => { + if (!e.hasComponent(ComponentNames.Colliding)) { + return; + } + const colliding = e.getComponent(ComponentNames.Colliding); + if (colliding?.onCollision) { + colliding.onCollision(game, e === entity ? otherEntity : entity); + } + }); } } diff --git a/src/engine/systems/Grid.ts b/src/engine/systems/Grid.ts index 915335b..1d4a623 100644 --- a/src/engine/systems/Grid.ts +++ b/src/engine/systems/Grid.ts @@ -309,7 +309,7 @@ export class Grid extends System { return false; } - private gridToScreenPosition(gridPosition: Coord2D) { + public gridToScreenPosition(gridPosition: Coord2D) { const { width, height } = this.dimension; return { x: gridPosition.x * width + width / 2, diff --git a/src/engine/systems/Input.ts b/src/engine/systems/Input.ts index 3da018d..8900f4e 100644 --- a/src/engine/systems/Input.ts +++ b/src/engine/systems/Input.ts @@ -1,10 +1,11 @@ -import { SystemNames, System } from "."; +import { Grid as GridSystem, SystemNames, System } from "."; import { Game } from ".."; import { ComponentNames, Grid, Interactable } from "../components"; import { Control } from "../components/Control"; import { Action, KeyConstants } from "../config"; -import { Entity } from "../entities"; +import { Entity, Particles } from "../entities"; import { Coord2D, Direction } from "../interfaces"; +import { colors } from "../utils"; export class Input extends System { private keys: Set; @@ -31,7 +32,7 @@ export class Input extends System { public update(_dt: number, game: Game) { game.forEachEntityWithComponent(ComponentNames.Control, (entity) => - this.handleMovement(entity), + this.handleMovement(entity, game), ); game.forEachEntityWithComponent(ComponentNames.Interactable, (entity) => this.handleInteraction(entity), @@ -57,7 +58,7 @@ export class Input extends System { ); } - public handleMovement(entity: Entity) { + public handleMovement(entity: Entity, game: Game) { const controlComponent = entity.getComponent( ComponentNames.Control, ); @@ -103,6 +104,25 @@ export class Input extends System { ); } + if (moveUp || moveLeft || moveRight || moveDown) { + const gridPosition = gridComponent.gridPosition; + const gridSystem = game.getSystem(SystemNames.Grid); + const particles = new Particles({ + center: gridSystem.gridToScreenPosition(gridPosition), + particleCount: 5, + particleShape: "circle", + particleMeanSpeed: 0.05, + particleSpeedVariance: 0.005, + particleMeanLife: 120, + particleMeanSize: 5, + particleSizeVariance: 2, + particleLifeVariance: 30, + particleColors: [colors.gray, colors.darkGray], + }); + + game.addEntity(particles); + } + entity.addComponent(gridComponent); } diff --git a/src/engine/systems/Life.ts b/src/engine/systems/Life.ts new file mode 100644 index 0000000..f454437 --- /dev/null +++ b/src/engine/systems/Life.ts @@ -0,0 +1,18 @@ +import { System, SystemNames } from "."; +import { Game } from ".."; +import { Life as LifeComponent, ComponentNames } from "../components"; + +export class Life extends System { + constructor() { + super(SystemNames.Life); + } + + public update(_dt: number, game: Game) { + game.forEachEntityWithComponent(ComponentNames.Life, (entity) => { + const life = entity.getComponent(ComponentNames.Life); + if (!life.alive) { + game.removeEntity(entity.id); + } + }); + } +} diff --git a/src/engine/systems/Render.ts b/src/engine/systems/Render.ts index f273deb..2dd35e2 100644 --- a/src/engine/systems/Render.ts +++ b/src/engine/systems/Render.ts @@ -4,7 +4,7 @@ import { Text, ComponentNames, Highlight, - Sprite, + Renderable, } from "../components"; import { Game } from ".."; import { clamp } from "../utils"; @@ -22,7 +22,7 @@ export class Render extends System { this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); game.forEachEntityWithComponent(ComponentNames.Sprite, (entity) => { - const sprite = entity.getComponent(ComponentNames.Sprite); + const sprite = entity.getComponent(ComponentNames.Sprite); sprite.update(dt); const boundingBox = entity.getComponent( diff --git a/src/engine/systems/SystemNames.ts b/src/engine/systems/SystemNames.ts index 555746c..738dfba 100644 --- a/src/engine/systems/SystemNames.ts +++ b/src/engine/systems/SystemNames.ts @@ -5,4 +5,5 @@ export namespace SystemNames { export const Grid = "Grid"; export const GridSpawner = "GridSpawner"; export const Collision = "Collision"; + export const Life = "Life"; } diff --git a/src/engine/systems/index.ts b/src/engine/systems/index.ts index 34b369c..a420216 100644 --- a/src/engine/systems/index.ts +++ b/src/engine/systems/index.ts @@ -6,3 +6,4 @@ export * from "./FacingDirection"; export * from "./Grid"; export * from "./GridSpawner"; export * from "./Collision"; +export * from "./Life"; diff --git a/src/engine/utils/colors.ts b/src/engine/utils/colors.ts new file mode 100644 index 0000000..199180c --- /dev/null +++ b/src/engine/utils/colors.ts @@ -0,0 +1,22 @@ +// gruvbox dark +export const colors = { + bg: "#282828", + fg: "#ebdbb2", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + purple: "#b16286", + aqua: "#689d6a", + orange: "#d65d0e", + gray: "#a89984", + lightGray: "#928374", + darkGray: "#3c3836", + lightRed: "#fb4934", + lightGreen: "#b8bb26", + lightYellow: "#fabd2f", + lightBlue: "#83a598", + lightPurple: "#d3869b", + lightAqua: "#8ec07c", + lightOrange: "#fe8019", +}; diff --git a/src/engine/utils/index.ts b/src/engine/utils/index.ts index 78b600e..48b94b8 100644 --- a/src/engine/utils/index.ts +++ b/src/engine/utils/index.ts @@ -2,3 +2,5 @@ export * from "./clamp"; export * from "./dotProduct"; export * from "./rotateVector"; export * from "./modal"; +export * from "./colors"; +export * from "./random"; diff --git a/src/engine/utils/random.ts b/src/engine/utils/random.ts new file mode 100644 index 0000000..3d9d8b8 --- /dev/null +++ b/src/engine/utils/random.ts @@ -0,0 +1,9 @@ +export const normalRandom = (mean: number, stdDev: number, maxStdDevs = 2) => { + const [u, v] = [0, 0].map(() => Math.random() + 1e-12); + const normal = + mean + stdDev * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + return Math.min( + mean + maxStdDevs * stdDev, + Math.max(mean - maxStdDevs * stdDev, normal), + ); +};