add particles

This commit is contained in:
Elizabeth Hunt 2024-03-06 14:35:04 -07:00
parent ce06fa7c29
commit 823620b2a6
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
22 changed files with 369 additions and 44 deletions

View File

@ -15,6 +15,7 @@ import {
Render, Render,
Collision, Collision,
GridSpawner, GridSpawner,
Life,
} from "./systems"; } from "./systems";
export class TheAbstractionEngine { export class TheAbstractionEngine {
@ -49,6 +50,7 @@ export class TheAbstractionEngine {
new GridSpawner(), new GridSpawner(),
new Collision(), new Collision(),
new Render(this.ctx), new Render(this.ctx),
new Life(),
].forEach((system) => this.game.addSystem(system)); ].forEach((system) => this.game.addSystem(system));
const player = new Player(); const player = new Player();

View File

@ -1,7 +1,13 @@
import { Component, ComponentNames } from "."; import { Component, ComponentNames } from ".";
import { Game } from "..";
import { Entity } from "../entities";
export class Colliding extends Component { export class Colliding extends Component {
constructor() { public onCollision?: (game: Game, entity: Entity) => void;
constructor(onCollision?: (game: Game, entity: Entity) => void) {
super(ComponentNames.Colliding); super(ComponentNames.Colliding);
this.onCollision = onCollision;
} }
} }

View File

@ -11,4 +11,5 @@ export namespace ComponentNames {
export const GridSpawn = "GridSpawn"; export const GridSpawn = "GridSpawn";
export const Text = "Text"; export const Text = "Text";
export const LambdaTerm = "LambdaTerm"; export const LambdaTerm = "LambdaTerm";
export const Life = "Life";
} }

View File

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

View File

@ -2,7 +2,12 @@ import { Component, ComponentNames } from ".";
import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces"; import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces";
import { clamp } from "../utils"; 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 sheet: HTMLImageElement;
private spriteImgPos: Coord2D; private spriteImgPos: Coord2D;

View File

@ -12,3 +12,4 @@ export * from "./Colliding";
export * from "./GridSpawn"; export * from "./GridSpawn";
export * from "./Text"; export * from "./Text";
export * from "./LambdaTerm"; export * from "./LambdaTerm";
export * from "./Life";

View File

@ -1,4 +1,5 @@
import { Entity, EntityNames } from "."; import { Entity, EntityNames } from ".";
import { Game } from "..";
import { BoundingBox, Colliding, Grid, Sprite } from "../components"; import { BoundingBox, Colliding, Grid, Sprite } from "../components";
import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config";
import { Coord2D } from "../interfaces"; import { Coord2D } from "../interfaces";
@ -13,7 +14,7 @@ export class Curry extends Entity {
this.addComponent(new Grid(gridPosition)); this.addComponent(new Grid(gridPosition));
this.addComponent(new Colliding()); this.addComponent(new Colliding(this.collisionHandler));
this.addComponent( this.addComponent(
new BoundingBox( 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();
}
}
} }

View File

@ -29,7 +29,7 @@ export abstract class Entity {
this.hooks.get(name)?.remove(); this.hooks.get(name)?.remove();
} }
public getComponent<T extends Component>(name: string): T { public getComponent<T>(name: string): T {
if (!this.hasComponent(name)) { if (!this.hasComponent(name)) {
throw new Error("Entity does not have component " + name); throw new Error("Entity does not have component " + name);
} }

View File

@ -7,4 +7,5 @@ export namespace EntityNames {
export const LockedDoor = "LockedDoor"; export const LockedDoor = "LockedDoor";
export const Curry = "Curry"; export const Curry = "Curry";
export const FunctionApplication = "FunctionApplication"; export const FunctionApplication = "FunctionApplication";
export const Particles = "Particles";
} }

View File

@ -1,7 +1,16 @@
import { Entity, EntityNames } from "."; import { Entity, EntityNames, Particles } from ".";
import { BoundingBox, Colliding, Grid, Sprite } from "../components"; import { Game } from "..";
import {
BoundingBox,
Colliding,
Grid,
Sprite,
ComponentNames,
} from "../components";
import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config";
import { Coord2D } from "../interfaces"; import { Coord2D } from "../interfaces";
import { Grid as GridSystem, SystemNames } from "../systems";
import { colors } from "../utils";
export class LockedDoor extends Entity { export class LockedDoor extends Entity {
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(
@ -13,7 +22,7 @@ export class LockedDoor extends Entity {
this.addComponent(new Grid(gridPosition)); this.addComponent(new Grid(gridPosition));
this.addComponent(new Colliding()); this.addComponent(new Colliding(this.handleCollision.bind(this)));
this.addComponent( this.addComponent(
new BoundingBox( 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<Grid>(ComponentNames.Grid);
const gridSystem = game.getSystem<GridSystem>(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);
}
} }

View File

@ -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<string>;
}
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<Particle>;
private onDeath?: () => void;
constructor(particles: Array<Particle> = [], 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<ParticleSpawnOptions>) {
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<Life>(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,
};
}
}

View File

@ -7,3 +7,5 @@ export * from "./LambdaFactory";
export * from "./Key"; export * from "./Key";
export * from "./LockedDoor"; export * from "./LockedDoor";
export * from "./Curry"; export * from "./Curry";
export * from "./FunctionApplication";
export * from "./Particles";

View File

@ -1,7 +1,7 @@
import { System, SystemNames } from "."; import { System, SystemNames } from ".";
import { Game } from ".."; import { Game } from "..";
import { Entity, EntityNames } from "../entities"; import { Entity, EntityNames } from "../entities";
import { BoundingBox, ComponentNames, Grid } from "../components"; import { BoundingBox, Colliding, ComponentNames, Grid } from "../components";
const collisionMap: Record<string, Set<string>> = { const collisionMap: Record<string, Set<string>> = {
[EntityNames.Key]: new Set([EntityNames.LockedDoor]), [EntityNames.Key]: new Set([EntityNames.LockedDoor]),
@ -70,34 +70,14 @@ export class Collision extends System {
return; return;
} }
const keyDoorPair = [EntityNames.Key, EntityNames.LockedDoor].map((x) => [entity, otherEntity].forEach((e) => {
[entity, otherEntity].find((y) => y.name === x), if (!e.hasComponent(ComponentNames.Colliding)) {
); return;
const [key, door] = keyDoorPair;
if (key && door) {
this.handleKeyDoorCollision(key, door, game);
} }
const colliding = e.getComponent<Colliding>(ComponentNames.Colliding);
const curryPlayerPair = [EntityNames.Curry, EntityNames.Player].map((x) => if (colliding?.onCollision) {
[entity, otherEntity].find((y) => y.name === x), colliding.onCollision(game, e === entity ? otherEntity : entity);
);
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();
} }
} }

View File

@ -309,7 +309,7 @@ export class Grid extends System {
return false; return false;
} }
private gridToScreenPosition(gridPosition: Coord2D) { public gridToScreenPosition(gridPosition: Coord2D) {
const { width, height } = this.dimension; const { width, height } = this.dimension;
return { return {
x: gridPosition.x * width + width / 2, x: gridPosition.x * width + width / 2,

View File

@ -1,10 +1,11 @@
import { SystemNames, System } from "."; import { Grid as GridSystem, SystemNames, System } from ".";
import { Game } from ".."; import { Game } from "..";
import { ComponentNames, Grid, Interactable } from "../components"; import { ComponentNames, Grid, Interactable } from "../components";
import { Control } from "../components/Control"; import { Control } from "../components/Control";
import { Action, KeyConstants } from "../config"; import { Action, KeyConstants } from "../config";
import { Entity } from "../entities"; import { Entity, Particles } from "../entities";
import { Coord2D, Direction } from "../interfaces"; import { Coord2D, Direction } from "../interfaces";
import { colors } from "../utils";
export class Input extends System { export class Input extends System {
private keys: Set<string>; private keys: Set<string>;
@ -31,7 +32,7 @@ export class Input extends System {
public update(_dt: number, game: Game) { public update(_dt: number, game: Game) {
game.forEachEntityWithComponent(ComponentNames.Control, (entity) => game.forEachEntityWithComponent(ComponentNames.Control, (entity) =>
this.handleMovement(entity), this.handleMovement(entity, game),
); );
game.forEachEntityWithComponent(ComponentNames.Interactable, (entity) => game.forEachEntityWithComponent(ComponentNames.Interactable, (entity) =>
this.handleInteraction(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<Control>( const controlComponent = entity.getComponent<Control>(
ComponentNames.Control, ComponentNames.Control,
); );
@ -103,6 +104,25 @@ export class Input extends System {
); );
} }
if (moveUp || moveLeft || moveRight || moveDown) {
const gridPosition = gridComponent.gridPosition;
const gridSystem = game.getSystem<GridSystem>(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); entity.addComponent(gridComponent);
} }

View File

@ -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<LifeComponent>(ComponentNames.Life);
if (!life.alive) {
game.removeEntity(entity.id);
}
});
}
}

View File

@ -4,7 +4,7 @@ import {
Text, Text,
ComponentNames, ComponentNames,
Highlight, Highlight,
Sprite, Renderable,
} from "../components"; } from "../components";
import { Game } from ".."; import { Game } from "..";
import { clamp } from "../utils"; 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); this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
game.forEachEntityWithComponent(ComponentNames.Sprite, (entity) => { game.forEachEntityWithComponent(ComponentNames.Sprite, (entity) => {
const sprite = entity.getComponent<Sprite>(ComponentNames.Sprite); const sprite = entity.getComponent<Renderable>(ComponentNames.Sprite);
sprite.update(dt); sprite.update(dt);
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(

View File

@ -5,4 +5,5 @@ export namespace SystemNames {
export const Grid = "Grid"; export const Grid = "Grid";
export const GridSpawner = "GridSpawner"; export const GridSpawner = "GridSpawner";
export const Collision = "Collision"; export const Collision = "Collision";
export const Life = "Life";
} }

View File

@ -6,3 +6,4 @@ export * from "./FacingDirection";
export * from "./Grid"; export * from "./Grid";
export * from "./GridSpawner"; export * from "./GridSpawner";
export * from "./Collision"; export * from "./Collision";
export * from "./Life";

View File

@ -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",
};

View File

@ -2,3 +2,5 @@ export * from "./clamp";
export * from "./dotProduct"; export * from "./dotProduct";
export * from "./rotateVector"; export * from "./rotateVector";
export * from "./modal"; export * from "./modal";
export * from "./colors";
export * from "./random";

View File

@ -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),
);
};