add particles
This commit is contained in:
parent
ce06fa7c29
commit
823620b2a6
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -11,4 +11,5 @@ export namespace ComponentNames {
|
||||
export const GridSpawn = "GridSpawn";
|
||||
export const Text = "Text";
|
||||
export const LambdaTerm = "LambdaTerm";
|
||||
export const Life = "Life";
|
||||
}
|
||||
|
11
src/engine/components/Life.ts
Normal file
11
src/engine/components/Life.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -12,3 +12,4 @@ export * from "./Colliding";
|
||||
export * from "./GridSpawn";
|
||||
export * from "./Text";
|
||||
export * from "./LambdaTerm";
|
||||
export * from "./Life";
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export abstract class Entity {
|
||||
this.hooks.get(name)?.remove();
|
||||
}
|
||||
|
||||
public getComponent<T extends Component>(name: string): T {
|
||||
public getComponent<T>(name: string): T {
|
||||
if (!this.hasComponent(name)) {
|
||||
throw new Error("Entity does not have component " + name);
|
||||
}
|
||||
|
@ -7,4 +7,5 @@ export namespace EntityNames {
|
||||
export const LockedDoor = "LockedDoor";
|
||||
export const Curry = "Curry";
|
||||
export const FunctionApplication = "FunctionApplication";
|
||||
export const Particles = "Particles";
|
||||
}
|
||||
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
194
src/engine/entities/Particles.ts
Normal file
194
src/engine/entities/Particles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -7,3 +7,5 @@ export * from "./LambdaFactory";
|
||||
export * from "./Key";
|
||||
export * from "./LockedDoor";
|
||||
export * from "./Curry";
|
||||
export * from "./FunctionApplication";
|
||||
export * from "./Particles";
|
||||
|
@ -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<string, Set<string>> = {
|
||||
[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<Colliding>(ComponentNames.Colliding);
|
||||
if (colliding?.onCollision) {
|
||||
colliding.onCollision(game, e === entity ? otherEntity : entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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<string>;
|
||||
@ -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<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);
|
||||
}
|
||||
|
||||
|
18
src/engine/systems/Life.ts
Normal file
18
src/engine/systems/Life.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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<Sprite>(ComponentNames.Sprite);
|
||||
const sprite = entity.getComponent<Renderable>(ComponentNames.Sprite);
|
||||
sprite.update(dt);
|
||||
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
|
@ -5,4 +5,5 @@ export namespace SystemNames {
|
||||
export const Grid = "Grid";
|
||||
export const GridSpawner = "GridSpawner";
|
||||
export const Collision = "Collision";
|
||||
export const Life = "Life";
|
||||
}
|
||||
|
@ -6,3 +6,4 @@ export * from "./FacingDirection";
|
||||
export * from "./Grid";
|
||||
export * from "./GridSpawner";
|
||||
export * from "./Collision";
|
||||
export * from "./Life";
|
||||
|
22
src/engine/utils/colors.ts
Normal file
22
src/engine/utils/colors.ts
Normal 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",
|
||||
};
|
@ -2,3 +2,5 @@ export * from "./clamp";
|
||||
export * from "./dotProduct";
|
||||
export * from "./rotateVector";
|
||||
export * from "./modal";
|
||||
export * from "./colors";
|
||||
export * from "./random";
|
||||
|
9
src/engine/utils/random.ts
Normal file
9
src/engine/utils/random.ts
Normal 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),
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user