Fluidsimulation

This commit is contained in:
Schmop 2024-05-04 15:29:30 +02:00
commit 3eb4855d8a
32 changed files with 15093 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
.idea

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# 🚀 Welcome to your new awesome project!
This project has been created using **webpack-cli**, you can now run
```
npm run build
```
or
```
yarn build
```
to bundle your application

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8" />
<title>Fluid</title>
<style>
body {
padding: 0;
margin: 0;
}
</style>
</head>
<body>
<canvas id="game" width="300px" height="300px"></canvas>
</body>
</html>

7952
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"scripts": {
"build": "webpack --mode=production --define-process-env-node-env=production",
"build:dev": "webpack --mode=development",
"build:prod": "webpack --mode=production --define-process-env-node-env=production",
"watch": "webpack --watch",
"serve": "webpack serve"
},
"devDependencies": {
"@webpack-cli/generators": "^3.0.0",
"css-loader": "^6.5.1",
"html-webpack-plugin": "^5.5.0",
"prettier": "^2.5.1",
"style-loader": "^3.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0",
"webpack-dev-server": "^4.7.3"
},
"version": "1.0.0",
"description": "My webpack project",
"name": "my-webpack-project"
}

124
src/camera.ts Normal file
View File

@ -0,0 +1,124 @@
import { Canvas } from "@/canvas";
import { GameObject } from '@/common/gameobject';
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
export class Camera extends GameObject {
canvas: Canvas;
zoom: number = 1;
rotateAngle: number = 0;
lockedObject: GameObject | null = null;
constructor(canvas: Canvas) {
super();
this.canvas = canvas;
}
get ctx() {
return this.canvas.ctx;
}
get width() {
return this.canvas.width / this.zoom;
}
get height() {
return this.canvas.height / this.zoom;
}
applyTransformation() {
this.resetTransform();
this.ctx.scale(this.zoom, this.zoom);
//this.ctx.rotate(this.rotateAngle);
this.ctx.translate(-this.pos.x, -this.pos.y);
}
resetTransform() {
this.ctx.resetTransform();
}
withoutTransform(callback: () => void) {
this.resetTransform();
callback();
this.applyTransformation();
}
view() {
return new Rect(
this.pos.x,
this.pos.y,
this.width + this.pos.x,
this.height + this.pos.y
);
}
moveTo(pos: Vec) {
this.ctx.translate(this.pos.x - pos.x, this.pos.y - pos.y);
this.pos = pos.clone();
}
centerPos(pos: Vec) {
this.moveTo(pos.sub(this.width / 2, this.height / 2));
}
moveBy(dir: Vec) {
this.moveTo(this.pos.sub(dir));
}
rotateBy(angle: number) {
this.rotateAround(this.canvas.center.x, this.canvas.center.y, angle);
this.rotateAngle += angle;
}
rotateTo(angle: number) {
this.rotateBy(angle - this.rotateAngle);
this.rotateAngle = angle;
}
rotateAround(x: number, y: number, angle: number) {
this.ctx.translate(x, y);
this.ctx.rotate(angle);
this.ctx.translate(-x, -y);
}
zoomTo(zoom: number, zoomPoint: Vec) {
zoom = Math.max(0.1, Math.min(10, zoom));
this.pos = zoomPoint.scale(1 / this.zoom - 1 / zoom).add(this.pos);
this.zoom = zoom;
this.applyTransformation();
}
zoomBy(zoom: number, zoomPoint: Vec) {
this.zoomTo(this.zoom + zoom, zoomPoint);
}
lockToObj(object: GameObject) {
this.lockedObject = object;
console.log("Watching", object);
}
unlock() {
this.lockedObject = null;
}
screenToScene(pos: Vec) {
return pos
.scale(1 / this.zoom)
.add(this.pos);
}
update(canvas: Canvas) {
if (null != this.lockedObject && canvas.objects.includes(this.lockedObject)) {
this.centerPos(this.lockedObject.pos);
}
}
render(canvas: Canvas) {
if (false) {
const { ctx } = canvas;
const view = this.view();
ctx.strokeStyle = "white";
ctx.strokeRect(view.x + 10, view.y + 10, view.width - 10, view.height - 10);
}
}
}

143
src/canvas.ts Normal file
View File

@ -0,0 +1,143 @@
import { Camera } from "@/camera";
import { GameObject } from "@/common/gameobject";
import { Vec } from '@/common/vec';
import { Input } from "@/input";
export class Canvas {
canvas: HTMLCanvasElement;
tickNum: number;
objects: GameObject[];
uiObjects: GameObject[];
skipUpdate: boolean;
input: Input;
camera: Camera;
width: number;
height: number;
ctx: CanvasRenderingContext2D;
lastTime: number;
static get LAYER_BACKGROUND() {
return 0;
}
static get LAYER_SCENE() {
return 1;
}
static get LAYER_OVERLAY() {
return 2;
}
static get LAYER_UI() {
return 3;
}
constructor(element: HTMLCanvasElement) {
this.canvas = element;
this.tickNum = 0;
this.objects = [];
this.uiObjects = [];
this.skipUpdate = false;
this.input = new Input(this);
this.camera = new Camera(this);
this.width = window.innerWidth;
this.height = window.innerHeight;
this.ctx = this.canvas.getContext("2d")!;
this.lastTime = performance.now();
this.resetCanvas();
this.add(this.camera);
}
get biggerDimension() {
return Math.max(this.width, this.height);
}
get center() {
return new Vec(this.width, this.height).half();
}
resetCanvas() {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
this.ctx = this.canvas.getContext('2d')!;
this.ctx.imageSmoothingEnabled = false;
this.camera.applyTransformation();
}
init() {
window.addEventListener('resize', this.resetCanvas.bind(this));
this.lastTime = performance.now();
this.tick();
}
add(object: GameObject, layer = Canvas.LAYER_BACKGROUND) {
object.layer = layer;
if (layer === Canvas.LAYER_UI) {
this.uiObjects.push(object);
return;
}
this.objects.push(object);
this.objects.sort((a, b) => a.layer - b.layer);
}
remove(object: GameObject) {
object.destruct(this);
this.objects = this.objects.filter(obj => obj !== object);
this.uiObjects = this.uiObjects.filter(obj => obj !== object);
}
tick() {
if (this.input.keydown('p')) {
return;
}
const cameraView = this.camera.view();
this.ctx.fillStyle = 'black';
this.ctx.fillRect(cameraView.x, cameraView.y, cameraView.width, cameraView.height);
const delta = performance.now() - this.lastTime;
this.uiObjects.forEach(object => {
object.update(this, delta);
});
if (!this.skipUpdate) {
this.objects.forEach(object => {
object.update(this, delta);
});
}
this.objects.forEach(object => {
object.render(this);
});
this.camera.withoutTransform(() => {
this.uiObjects.forEach(object => {
object.render(this);
});
});
this.input.update(this);
this.tickNum++;
this.lastTime += delta;
requestAnimationFrame(this.tick.bind(this));
}
skipGameUpdate() {
this.skipUpdate = true;
}
closestObject(pos: Vec, constraint?: (object: GameObject) => boolean) {
let selected = null as GameObject | null;
this.objects.forEach(object => {
if (constraint && !constraint(object)) {
return;
}
if (null == selected || object.pos.distance(pos) < selected.pos.distance(pos)) {
selected = object;
}
});
return selected;
}
}

11
src/common/array.ts Normal file
View File

@ -0,0 +1,11 @@
export function create2DArray<T>(rows: number, cols: number, init: (x: number, y: number) => T): T[][] {
const arr: T[][] = new Array(rows);
for (let i = 0; i < rows; i++) {
arr[i] = new Array(cols);
for (let j = 0; j < cols; j++) {
arr[i][j] = init(i, j);
}
}
return arr;
}

View File

@ -0,0 +1,5 @@
import { Canvas } from "@/canvas";
export interface Destructable {
destruct(canvas: Canvas): void;
}

15
src/common/file-utils.ts Normal file
View File

@ -0,0 +1,15 @@
export default class FileUtils {
static saveFile(data: BlobPart, filename: string, type = "application/json") {
const file = new Blob([data], { type: type });
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}
}

7
src/common/functions.ts Normal file
View File

@ -0,0 +1,7 @@
export function map(x: number, inMin: number, inMax: number, outMin: number, outMax: number) {
return (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
}
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}

14
src/common/gameobject.ts Normal file
View File

@ -0,0 +1,14 @@
import { Canvas } from '@/canvas';
import { Renderable } from '@/common/renderable';
import { Updateable } from '@/common/updateable';
import { Vec } from '@/common/vec';
import { Destructable } from '@/common/destructable';
export abstract class GameObject implements Renderable, Updateable, Destructable {
layer: number = 0;
pos: Vec = new Vec;
abstract render(canvas: Canvas): void;
abstract update(canvas: Canvas, delta: DOMHighResTimeStamp): void;
destruct(canvas: Canvas): void { }
}

14
src/common/grid.ts Normal file
View File

@ -0,0 +1,14 @@
import { Vec } from "@/common/vec";
export const GridSize = 16;
export function snapToGrid(pos: Vec) {
return pos.floor(GridSize);
}
export function worldToGrid(pos: Vec) {
return new Vec(
Math.floor(pos.x / GridSize),
Math.floor(pos.y / GridSize)
);
}
export function gridToWorld(pos: Vec) {
return pos.scale(GridSize);
}

38
src/common/noise.ts Normal file
View File

@ -0,0 +1,38 @@
import { Vec } from "@/common/vec";
function dotProdGrid(pos: Vec, vel: Vec): number {
const d_vect = new Vec(pos.x - vel.x, pos.y - vel.y);
const g_vect = Vec.randomUnit();
return d_vect.x * g_vect.x + d_vect.y * g_vect.y;
}
function smootherstep(x: number): number {
return 6*x**5 - 15*x**4 + 10*x**3;
}
/**
* @param t {Number} from 0 to 1
* @param from
* @param to
*/
function interpolate(t: number, from: number, to: number){
return from + smootherstep(t) * (to-from);
}
export function perlinNoise(x: number, y: number): number;
export function perlinNoise(pos: Vec): number;
export function perlinNoise(x: Vec|number, y?: number): number {
const pos = x instanceof Vec ? x : new Vec(x, y);
const flooredPos = pos.floor();
const fractionalPos = pos.sub(flooredPos);
//interpolate
const tl = dotProdGrid(pos, flooredPos);
const tr = dotProdGrid(pos, flooredPos.add(1,0));
const bl = dotProdGrid(pos, flooredPos.add(0, 1));
const br = dotProdGrid(pos, flooredPos.add(1,1));
const xt = interpolate(fractionalPos.x, tl, tr);
const xb = interpolate(fractionalPos.x, bl, br);
return interpolate(fractionalPos.y, xt, xb);
}

36
src/common/rand.ts Normal file
View File

@ -0,0 +1,36 @@
function mulberry32(a: number) {
return function() {
let t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
let rand = mulberry32(Date.now() * Math.random());
/**
* @return {Number} from 0 to 1
*/
export function random(): number;
/**
* @return {Number} from `from` to `to`
*/
export function random(from: number, to: number): number;
/**
* @return {Number} from 0 to `to`
*/
export function random(to: number): number;
export function random(from?: number, to?: number): number {
if (from === undefined) {
return rand();
}
if (to === undefined) {
to = from;
from = 0;
}
return Math.floor(rand() * (to - from + 1)) + from;
}
export function setSeed(seed: number) {
rand = mulberry32(seed);
}

78
src/common/rect.ts Normal file
View File

@ -0,0 +1,78 @@
import { map } from "@/common/functions";
import { Vec } from "@/common/vec";
export class Rect {
left: number;
top: number;
right: number;
bottom: number;
constructor(left: number, top: number, right: number, bottom: number) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
static bySize(pos: Vec, size: Vec) {
return new Rect(pos.x, pos.y, pos.x + size.x, pos.y + size.y);
}
translate(offset: Vec) {
return new Rect(
this.left + offset.x,
this.top + offset.y,
this.right + offset.x,
this.bottom + offset.y
);
}
scale(factor: number) {
return new Rect(
map(factor, 1, -1, this.left, this.right),
map(factor, 1, -1, this.top, this.bottom),
map(factor, 1, -1, this.right, this.left),
map(factor, 1, -1, this.bottom, this.top),
);
}
get x() {
return this.left;
}
get y() {
return this.top;
}
get width() {
return this.right - this.left;
}
get height() {
return this.bottom - this.top;
}
get center() {
return new Vec((this.left + this.right) / 2, (this.top + this.bottom) / 2);
}
contains(pos: Vec) {
return this.left <= pos.x && this.right > pos.x && this.top <= pos.y && this.bottom > pos.y;
}
get tl() {
return new Vec(this.left, this.top);
}
get tr() {
return new Vec(this.right, this.top);
}
get br() {
return new Vec(this.right, this.bottom);
}
get bl() {
return new Vec(this.left, this.bottom);
}
}

5
src/common/renderable.ts Normal file
View File

@ -0,0 +1,5 @@
import { Canvas } from "@/canvas";
export interface Renderable {
render(canvas: Canvas): void;
}

5
src/common/updateable.ts Normal file
View File

@ -0,0 +1,5 @@
import { Canvas } from "@/canvas";
export interface Updateable {
update(canvas: Canvas, delta: DOMHighResTimeStamp): void;
}

218
src/common/vec.ts Normal file
View File

@ -0,0 +1,218 @@
import { random } from "@/common/rand";
import { Rect } from "@/common/rect";
export class Vec {
x: number;
y: number;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
static randomUnit() {
const theta = random() * 2 * Math.PI;
return new Vec(Math.cos(theta), Math.sin(theta));
}
static random(toX: number, toY: number): Vec;
static random(fromX: number, fromY: number, toX: number, toY: number): Vec;
static random(from: Vec, to: Vec): Vec;
static random(bounds: Rect): Vec;
static random(first: number|Vec|Rect, second?: number|Vec, toX?: number, toY?: number): Vec {
if (first instanceof Rect) {
const {left, right, top, bottom} = first;
return new Vec(random(left, right), random(top, bottom));
}
if (first instanceof Vec && second instanceof Vec) {
const from = first;
const to = second;
return new Vec(random(from.x, to.x), random(from.y, to.y));
}
if (undefined === toX
&& undefined === toY
&& 'number' === typeof first
&& 'number' === typeof second
) {
toX = first;
toY = second;
return new Vec(random(toX), random(toY));
}
if ('number' === typeof first
&& 'number' === typeof second
&& 'number' === typeof toX
&& 'number' === typeof toY) {
return new Vec(random(first, toX), random(second, toY));
}
throw new TypeError('Invalid arguments for Vec.random');
}
clone() {
return new Vec(this.x, this.y);
}
add(vec: Vec): Vec;
add(x: number, y: number): Vec;
add(x: number|Vec, y?: number) {
if (x instanceof Vec) {
return new Vec(this.x + x.x, this.y + x.y);
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return new Vec(this.x + x, this.y + y);
}
sub(vec: Vec): Vec;
sub(x: number, y: number): Vec;
sub(x: number|Vec, y?: number) {
if (x instanceof Vec) {
return new Vec(this.x - x.x, this.y - x.y);
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return new Vec(this.x - x, this.y - y);
}
mult(vec: Vec): Vec;
mult(x: number, y: number): Vec;
mult(x: number|Vec, y?: number) {
if (x instanceof Vec) {
return new Vec(this.x * x.x, this.y * x.y);
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return new Vec(this.x * x, this.y * y);
}
dot(vec: Vec): number;
dot(x: number, y: number): number;
dot(x: number|Vec, y?: number): number {
if (x instanceof Vec) {
return this.x * x.x + this.y * x.y;
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return this.x * x + this.y * y;
}
det(vec: Vec): number;
det(x: number, y: number): number;
det(x: number|Vec, y?: number): number {
if (x instanceof Vec) {
return this.x * x.y - this.y * x.x;
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return this.x * y - this.y * x;
}
clockwiseAngleBetween(vec: Vec): number;
clockwiseAngleBetween(x: number, y: number): number;
clockwiseAngleBetween(x: number|Vec, y?: number): number {
if (x instanceof Vec) {
return Math.atan2(this.det(x), this.dot(x));
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return Math.atan2(this.det(x, y), this.dot(x, y));
}
normalize() {
return this.scale(1 / this.length());
}
ceil(step: number = 1) {
return new Vec(Math.ceil(this.x / step) * step, Math.ceil(this.y / step) * step);
}
floor(step: number = 1) {
return new Vec(Math.floor(this.x / step) * step, Math.floor(this.y / step) * step);
}
round(step: number) {
return new Vec(Math.round(this.x / step) * step, Math.round(this.y / step) * step);
}
scale(factor: number) {
return new Vec(this.x * factor, this.y * factor);
}
half() {
return this.scale(0.5);
}
length() {
return Math.sqrt(this.dot(this));
}
distance(vec: Vec): number;
distance(x: number, y: number): number;
distance(x: number|Vec, y?: number): number {
if (x instanceof Vec) {
return this.sub(x).length();
}
if (undefined === y) {
throw new Error("y-coordinate undefined on vector operation");
}
return this.sub(x, y).length();
}
rotate(angle: number) {
return new Vec(
this.x * Math.cos(angle) - this.y * Math.sin(angle),
this.x * Math.sin(angle) + this.y * Math.cos(angle)
);
}
steer(desired: Vec, factor: number) {
const angle = this.normalize().clockwiseAngleBetween(desired.normalize());
if (isNaN(angle)) {
return this;
}
return this.rotate(angle * factor);
}
closestPointOnLine(a: Vec, b: Vec) {
let ap = this.sub(a);
let ab = b.sub(a);
let magnitudeAB = ab.dot(ab);
let abapProduct = ap.dot(ab);
let dist = abapProduct / magnitudeAB;
if (dist < 0) {
return a.clone();
} else if (dist > 1) {
return b.clone();
}
return a.add(ab.scale(dist));
}
toString() {
return `(${this.x}, ${this.y})`;
}
static minBounds(...vecs: Vec[]) {
return new Vec(Math.min(...vecs.map(vec => vec.x)), Math.min(...vecs.map(vec => vec.y)));
}
static maxBounds(...vecs: Vec[]) {
return new Vec(Math.max(...vecs.map(vec => vec.x)), Math.max(...vecs.map(vec => vec.y)));
}
}

184
src/game/fluids/fluids.ts Normal file
View File

@ -0,0 +1,184 @@
import { Canvas } from "@/canvas";
import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { Particle } from "@/game/fluids/particle";
import { strokeCircle } from "@/graphics";
const gravity = 0.008;
const numParticles = 40;
const pressureForce = 3000;
export const particleSize = 10;
export const desiredDensity = 0;
const densityRadius = 34;
const mass = 1;
const viscosityStrength = 0.2;
const viscosityRadius = 100;
const mouseRadius = 150;
const mouseForce = 0.4;
const densityKernelVolume = Math.PI * Math.pow(densityRadius, 4) / 6;
const derivativeScale = 12 / (Math.PI * Math.pow(densityRadius, 4));
const viscosityKernelVolume = Math.PI * Math.pow(viscosityRadius, 8) / 4;
export class Fluids extends GameObject {
particles: Particle[];
rect: Rect;
constructor(canvas: Canvas) {
super();
this.particles = [];
this.rect = new Rect(0, 0, canvas.width, canvas.height);
this.init();
// @ts-ignore
window.fluids = this;
}
init() {
const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5);
const cols = Math.floor(Math.sqrt(numParticles));
const rows = Math.floor(numParticles / cols);
const spacing = rectInTheMiddle.width / cols;
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing);
this.particles.push(new Particle(pos));
}
}
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
// fluid simulation seems to be breaking when browser goes to sleep
delta = Math.min(delta, 5);
this.predictParticles(delta);
this.applyGravityForce(delta);
this.applyPressureForce(delta);
this.applyViscosityForce(delta);
this.applyMouseForce(canvas);
this.particles.forEach(p => p.update(canvas, delta));
}
applyMouseForce({input}: Canvas) {
if (input.mouseDown) {
for (const p of this.particles) {
const diff = input.mousePos.sub(p.pos);
const dist = diff.length();
if (dist < mouseRadius) {
p.vel = p.vel.add(diff.normalize().scale(mouseForce));
}
}
}
}
predictParticles(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.posPrediction = p.pos.add(p.vel.scale(delta));
}
}
smoothingKernel(dist: number) {
const diff = densityRadius - dist;
return diff * diff / densityKernelVolume;
}
smoothingKernelDerivative(dist: number) {
if (dist >= densityRadius) return 0;
return (dist - densityRadius) * derivativeScale;
}
calculateDensities() {
for (const p of this.particles) {
p.densitySample = 0;
for (const other of this.particles) {
if (p === other) continue;
const dist = p.posPrediction.distance(other.posPrediction);
p.densitySample += mass * this.smoothingKernel(dist);
}
}
}
convertDensityToPressure(density: number) {
const densityError = density - desiredDensity;
return densityError * pressureForce;
}
calculateSharedPressure(a: Particle, b: Particle) {
const pressureA = this.convertDensityToPressure(a.densitySample);
const pressureB = this.convertDensityToPressure(b.densitySample);
return (pressureA + pressureB) / 2;
}
calculatePressureForce(p: Particle) {
let pressureForce = new Vec;
for (const other of this.particles) {
if (p === other) continue;
const diff = p.posPrediction.sub(other.posPrediction);
const dist = diff.length();
const dir = dist <= 0 ? Vec.randomUnit() : diff.normalize();
const slope = this.smoothingKernelDerivative(dist);
const density = other.densitySample;
const sharedPressure = this.calculateSharedPressure(p, other);
pressureForce = pressureForce.sub(
dir.scale(
sharedPressure * slope * mass / density
)
);
}
return pressureForce;
}
viscositySmoothingKernel(dist: number) {
const value = Math.max(0, viscosityRadius * viscosityRadius - dist * dist);
return value * value * value / viscosityKernelVolume;
}
calculateViscosityForce(p: Particle) {
let viscosityForce = new Vec;
for (const other of this.particles) {
if (p === other) continue;
const dist = p.posPrediction.distance(other.posPrediction);
const influence = this.viscositySmoothingKernel(dist);
viscosityForce = viscosityForce.add(
other.vel.sub(p.vel).scale(influence)
);
}
return viscosityForce.scale(viscosityStrength);
}
applyPressureForce(delta: DOMHighResTimeStamp) {
this.calculateDensities();
for (const p of this.particles) {
p.vel = p.vel.add(this.calculatePressureForce(p).scale(delta));
}
}
applyViscosityForce(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.vel = p.vel.add(this.calculateViscosityForce(p).scale(delta));
}
}
applyGravityForce(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.vel = p.vel.add(new Vec(0, gravity * delta));
}
}
render(canvas: Canvas) {
this.particles.forEach(p => p.render(canvas));
if (canvas.input.mouseDown) {
strokeCircle(canvas.ctx, canvas.input.mousePos, mouseRadius, "white");
}
}
getAverageDensity() {
return this.particles.reduce((acc, p) => acc + p.densitySample, 0) / this.particles.length;
}
getLowestDensity() {
return this.particles.reduce((acc, p) => Math.min(acc, p.densitySample), Infinity);
}
}

View File

@ -0,0 +1,37 @@
import { Canvas } from "@/canvas";
import { clamp } from "@/common/functions";
import { Vec } from "@/common/vec";
import { desiredDensity, particleSize } from "@/game/fluids/fluids";
import { fillCircle } from "@/graphics";
const padding = 50;
export class Particle {
vel: Vec;
pos: Vec;
posPrediction: Vec;
densitySample: number;
constructor(pos: Vec) {
this.pos = pos;
this.posPrediction = new Vec;
this.vel = new Vec;
this.densitySample = 0;
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
const nextPos = this.pos.add(this.vel);
if (nextPos.y >= canvas.height - padding || nextPos.y < padding) {
this.vel.y *= -1;
nextPos.y = clamp(nextPos.y, padding, canvas.height - padding);
}
if (nextPos.x >= canvas.width - padding || nextPos.x < padding) {
this.vel.x *= -1;
nextPos.x = clamp(nextPos.x, padding, canvas.width - padding);
}
this.pos = nextPos;
}
render({ctx}: Canvas) {
const densityError = this.densitySample - desiredDensity; // [0, inf)
const densityErrorNormalized = Math.min(densityError * densityError / 4, 255); // [0, 255]
const inverted = 255 - densityErrorNormalized;
fillCircle(ctx, this.pos, particleSize, `rgb(${densityErrorNormalized}, 0, ${inverted})`);
}
}

168
src/game/gamemap.ts Normal file
View File

@ -0,0 +1,168 @@
import { Canvas } from "@/canvas";
import { create2DArray } from "@/common/array";
import { GameObject } from "@/common/gameobject";
import { GridSize } from "@/common/grid";
import { perlinNoise } from "@/common/noise";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { Black, Blue, Color, Green, White } from "@/ui/colors";
export type TileState = 'land'|'air'|'none';
const TileColors: Record<TileState, Color> = {
land: White,
air: Blue,
none: Black,
};
export type Tile = {
state: TileState,
}
export class Gamemap extends GameObject {
public tiles: Tile[][];
public cols: number;
public rows: number;
public rect: Rect;
constructor(canvas: Canvas) {
super();
this.rows = Math.floor(canvas.width / GridSize);
this.cols = Math.floor(canvas.height / GridSize);
this.rect = new Rect(0, 0, this.rows, this.cols);
this.tiles = create2DArray(this.rows, this.cols, (x: number, y: number) => ({state: 'air'}));
}
getState(x: number, y: number): TileState;
getState(pos: Vec): TileState;
getState(x: number|Vec, y?: number): TileState {
const pos = x instanceof Vec ? x : new Vec(x, y);
return this.tiles[pos.x][pos.y].state;
}
setState(x: number, y: number, state: TileState): void;
setState(pos: Vec, state: TileState): void;
setState(x: number|Vec, y: TileState|number, state?: TileState) {
if (x instanceof Vec && "string" === typeof y) {
this.tiles[x.x][x.y].state = y;
} else if ("number" === typeof x && "number" === typeof y && "string" === typeof state) {
this.tiles[x][y].state = state;
} else {
throw new TypeError("Invalid arguments for setState");
}
}
render(canvas: Canvas): void {
const {ctx} = canvas;
for (let x = 0; x < this.rows; x++) {
for (let y = 0; y < this.cols; y++) {
ctx.fillStyle = TileColors[this.getState(x, y)];
ctx.fillRect(x * GridSize, y * GridSize, GridSize, GridSize);
}
}
}
update(canvas: Canvas) {
/** noop */
}
}
function findNearestTile(map: Gamemap, pos: Vec): Vec|null {
let distance = 1;
while (distance < Math.max(map.rows, map.cols)) {
for (let i = -distance; i <= distance; i++) {
const top = pos.add(i, -distance);
if (map.rect.contains(top) && map.getState(top) === 'land') {
return top;
}
const bottom = pos.add(i, distance);
if (map.rect.contains(bottom) && map.getState(bottom) === 'land') {
return bottom;
}
const left = pos.add(-distance, i);
if (map.rect.contains(left) && map.getState(left) === 'land') {
return left;
}
const right = pos.add(distance, i);
if (map.rect.contains(right) && map.getState(right) === 'land') {
return right;
}
}
distance++;
}
return null;
}
function marchToFirstEmptyTile(map: Gamemap, from: Vec, to: Vec): Vec|null {
const direction = to.sub(from).normalize();
if (from.distance(to) <= 1) {
return null;
}
if (Math.abs(direction.x) > Math.abs(direction.y)) {
direction.x = Math.sign(direction.x);
direction.y = 0;
} else {
direction.x = 0;
direction.y = Math.sign(direction.y);
}
const pos = from.add(direction);
if (map.getState(pos) !== 'land') {
return pos;
}
return marchToFirstEmptyTile(map, pos, to);
}
function floodFill(map: Gamemap, start: Vec, state: TileState): void {
const stack: Vec[] = [start];
const removeState = map.getState(start);
while (stack.length) {
const pos = stack.pop();
if (!pos || !map.rect.contains(pos) || map.getState(pos) !== removeState) {
continue;
}
map.setState(pos, state);
stack.push(
pos.add(1, 0),
pos.add(-1, 0),
pos.add(0, 1),
pos.add(0, -1),
);
}
}
function removeAirPockets(map: Gamemap): void {
for (let x = 0; x < map.rows; x++) {
for (let y = 0; y < map.cols; y++) {
if (map.getState(x, y) === 'air') {
map.setState(x, y, 'land');
}
}
}
}
export function fillMap(map: Gamemap): void {
for (let x = 0; x < map.rows; x++) {
for (let y = 0; y < map.cols; y++) {
const noise =
perlinNoise(new Vec(x, y).scale(0.03));
if (noise > 0.5) {
map.setState(x, y, 'land');
}
}
}
for (let i = 0; i < map.rows * map.cols * 0.3; i++) {
const pos = Vec.random(map.rows - 1, map.cols - 1);
const tile = findNearestTile(map, pos);
if (!tile) {
throw new Error("No tile found, wtf?!");
}
const emptyTile = marchToFirstEmptyTile(map, tile, pos) ?? pos;
map.setState(emptyTile, 'land');
}
floodFill(map, new Vec(0, 0), 'none');
removeAirPockets(map);
}

192
src/game/swarm/agent.ts Normal file
View File

@ -0,0 +1,192 @@
import { Canvas } from "@/canvas";
import { clamp } from "@/common/functions";
import { Vec } from "@/common/vec";
import { desiredDensity, particleSize } from "@/game/fluids/fluids";
import { Swarm } from "@/game/swarm/swarm";
import { fillCircle, strokeLine } from "@/graphics";
const padding = 50;
const allowedSteer = 1 / 180;
const idlingUrgency = 0.001;
const idlingDistance = 20;
const desiredDistanceToEveryone = 30;
const flockPerceptionDistance = 150;
const behaveLikeFlockDoesUrgency = 0.8;
const epsilon = 1e-5;
export type NavigationType = 'stayInBounds' | 'doNotBumpIntoFlock' | 'keepFlockClose' | 'behaveLikeFlockDoes' | 'idling';
export type NavigatingDesire = {
pos: Vec;
urgency: number; // [0,1]
type: NavigationType;
}
const navigationColorMap = {
stayInBounds: 'red',
doNotBumpIntoFlock: 'blue',
keepFlockClose: 'green',
behaveLikeFlockDoes: 'yellow',
idling: 'purple'
};
export class Agent {
dir: Vec;
pos: Vec;
swarm: Swarm;
idlingGoal: Vec|null = null;
lastNavigation: NavigatingDesire;
constructor(pos: Vec, swarm: Swarm) {
this.pos = pos;
this.swarm = swarm;
this.lastNavigation = {
pos: pos,
urgency: 0,
type: 'idling'
}
this.dir = Vec.randomUnit();
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
const nextPos = this.pos.add(this.dir);
const navigations = [];
navigations.push(this.stayInBounds(nextPos));
navigations.push(this.doNotBumpIntoFlock(nextPos));
navigations.push(this.keepFlockClose(nextPos));
navigations.push(this.behaveLikeFlockDoes(nextPos));
navigations.push(this.idling(nextPos));
const navigation = this.mostUrgentNavigation(navigations);
this.steerTowards(navigation.pos);
this.lastNavigation = navigation;
this.pos = nextPos;
}
steerTowards(target: Vec) {
const targetDir = target.sub(this.pos);
// no division by zero!
if (targetDir.length() < epsilon) {
return;
}
const alpha = this.dir.clockwiseAngleBetween(targetDir.normalize());
this.dir = this.dir.rotate(Math.min(allowedSteer, Math.abs(alpha)) * Math.sign(alpha)).normalize();
}
mostUrgentNavigation(navigations: NavigatingDesire[]) {
return navigations
.reduce((a, b) => a.urgency > b.urgency ? a : b);
}
stayInBounds(nextPos: Vec): NavigatingDesire {
const {rect} = this.swarm;
if (rect.contains(this.pos.add(this.dir.scale(padding)))) {
return {
pos: nextPos,
urgency: 0,
type: 'stayInBounds'
};
}
return {
pos: rect.center,
urgency: 1,
type: 'stayInBounds'
};
}
doNotBumpIntoFlock(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgentsNotMe = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance && a !== this);
const visibleAgentsInBounds = visibleAgentsNotMe
.filter(a => this.swarm.rect.contains(a.pos));
let closestAgent: Agent|null = null;
for (const a of visibleAgentsInBounds) {
if (!closestAgent || a.pos.distance(this.pos) < closestAgent.pos.distance(this.pos)) {
closestAgent = a;
}
}
if (!closestAgent) {
return {
pos: nextPos,
urgency: 0,
type: 'doNotBumpIntoFlock'
};
}
const awayFromClosestAgentDir = this.pos.sub(closestAgent.pos).normalize();
return {
pos: this.pos.add(awayFromClosestAgentDir),
urgency: (desiredDistanceToEveryone - closestAgent.pos.distance(this.pos)) / desiredDistanceToEveryone,
type: 'doNotBumpIntoFlock'
};
}
keepFlockClose(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos));
const center = visibleAgentsInBounds
.reduce((sum, a) => sum.add(a.pos), new Vec)
.scale(1 / visibleAgentsInBounds.length);
const distanceToCenter = this.pos.distance(center);
return {
pos: center,
urgency: distanceToCenter / flockPerceptionDistance,
type: 'keepFlockClose'
};
}
behaveLikeFlockDoes(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos));
const averageDir = visibleAgentsInBounds
.reduce((sum, a) => sum.add(a.dir), new Vec)
.scale(1 / visibleAgentsInBounds.length);
const angleDiff = this.dir.clockwiseAngleBetween(averageDir);
return {
pos: nextPos.add(averageDir),
urgency: behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI,
type: 'behaveLikeFlockDoes'
};
}
idling(nextPos: Vec): NavigatingDesire {
if (this.idlingGoal && this.idlingGoal.distance(nextPos) > idlingDistance) {
return {
pos: this.idlingGoal,
urgency: idlingUrgency,
type: 'idling'
};
}
this.idlingGoal = Vec.random(this.swarm.rect);
return {
pos: this.idlingGoal,
urgency: idlingUrgency,
type: 'idling'
}
}
/**
* @returns {Number} between 0 and 255
*/
satisfaction(): number {
return 255 - this.lastNavigation.urgency * 255;
}
/**render({ctx}: Canvas) {
}*/
render({ctx}: Canvas) {
if (this.swarm.renderStyle === 'satisfaction') {
const satisfaction = this.satisfaction();
const dissatisfaction = 255 - satisfaction;
fillCircle(ctx, this.pos, particleSize, `rgb(${dissatisfaction}, 0, ${satisfaction})`);
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
} else {
const color = navigationColorMap[this.lastNavigation.type];
fillCircle(ctx, this.pos, particleSize, color);
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
}
}
}

66
src/game/swarm/swarm.ts Normal file
View File

@ -0,0 +1,66 @@
import { Canvas } from "@/canvas";
import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { Agent } from "@/game/swarm/agent";
import Button from "@/ui/button";
let numAgents = 20;
export class Swarm extends GameObject {
rect: Rect;
agents: Agent[];
renderStyle: 'satisfaction'|'navigationMode' = 'navigationMode';
constructor(canvas: Canvas) {
super();
this.rect = new Rect(0, 0, canvas.width, canvas.height);
this.agents = [];
this.init();
this.initUI(canvas);
// @ts-ignore
window.swarm = this;
}
init() {
this.agents = [];
const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5);
const cols = Math.floor(Math.sqrt(numAgents));
const rows = Math.floor(numAgents / cols);
const spacing = rectInTheMiddle.width / cols;
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing);
this.agents.push(new Agent(pos, this));
}
}
}
initUI(canvas: Canvas) {
const buttonSize = new Vec(100, 40);
const buttons = [
new Button("Reset", Rect.bySize(new Vec(10, 10), buttonSize), () => this.init()),
new Button("Toggle Colors", Rect.bySize(new Vec(110, 10), buttonSize), () => {
this.renderStyle = this.renderStyle === 'satisfaction' ? 'navigationMode' : 'satisfaction';
}),
new Button("Less Agents", Rect.bySize(new Vec(10, 60), buttonSize), () => {
numAgents -= Math.max(1, numAgents / 10);
this.init();
}),
new Button("More Agents", Rect.bySize(new Vec(110, 60), buttonSize), () => {
numAgents += Math.max(1, numAgents / 10);
this.init();
}),
]
buttons.forEach(b => canvas.add(b, Canvas.LAYER_UI));
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
// simulation seems to be breaking when browser goes to sleep
delta = Math.min(delta, 10);
this.agents.forEach(p => p.update(canvas, delta));
}
render(canvas: Canvas) {
this.agents.forEach(p => p.render(canvas));
}
}

98
src/graphics.ts Normal file
View File

@ -0,0 +1,98 @@
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
export function drawImageRotated(
ctx: CanvasRenderingContext2D,
img: CanvasImageSource,
angle: number,
x: number,
y: number,
w: number,
h: number,
x2: number,
y2: number,
w2: number,
h2: number,
) {
if (typeof h2 !== "undefined") {
ctx.translate(x2, y2);
ctx.rotate(angle);
ctx.drawImage(img, x, y, w, h, -w2 / 2, -h2 / 2, w2, h2);
ctx.rotate(-angle);
ctx.translate(-x2, -y2);
} else {
ctx.translate(x, y);
ctx.rotate(angle);
ctx.drawImage(img, -w / 2, -h / 2, w, h);
ctx.rotate(-angle);
ctx.translate(-x, -y);
}
}
export function strokeLine(ctx: CanvasRenderingContext2D, start: Vec, end: Vec, style: string = "white", width: number = 2) {
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.lineWidth = width;
ctx.strokeStyle = style;
ctx.stroke();
}
export function fillCircle(ctx: CanvasRenderingContext2D, pos: Vec, radius: number, style: string = "white") {
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = style;
ctx.fill();
}
export function strokeCircle(ctx: CanvasRenderingContext2D, pos: Vec, radius: number, style: string = "white") {
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI);
ctx.strokeStyle = style;
ctx.lineWidth = 2;
ctx.stroke();
}
export function fillTextCentered(ctx: CanvasRenderingContext2D, text: string, pos: Vec, containerWidth: number) {
let { width } = ctx.measureText(text);
width = Math.min(width, containerWidth);
ctx.fillText(text, pos.x + (containerWidth - width) / 2, pos.y, containerWidth);
}
export function fillRoundRect(ctx: CanvasRenderingContext2D, rect: Rect, radius: number) {
roundRect(ctx, rect, radius, true, false);
}
export function strokeRoundRect(ctx: CanvasRenderingContext2D, rect: Rect, radius: number) {
roundRect(ctx, rect, radius, false, true);
}
export function roundRect(ctx: CanvasRenderingContext2D, rect: Rect, radius?: number|Rect, fill?: boolean, stroke?: boolean) {
if (typeof stroke === 'undefined') {
stroke = true;
}
if (typeof radius === 'undefined') {
radius = 5;
}
if (typeof radius === 'number') {
radius = Rect.bySize(new Vec(radius, radius), new Vec(radius, radius));
}
ctx.beginPath();
ctx.moveTo(rect.x + radius.left, rect.y);
ctx.lineTo(rect.right - radius.top, rect.y);
ctx.quadraticCurveTo(rect.right, rect.y, rect.right, rect.y + radius.top);
ctx.lineTo(rect.right, rect.bottom - radius.right);
ctx.quadraticCurveTo(rect.right, rect.bottom, rect.right - radius.right, rect.bottom);
ctx.lineTo(rect.x + radius.bottom, rect.bottom);
ctx.quadraticCurveTo(rect.x, rect.bottom, rect.x, rect.bottom - radius.bottom);
ctx.lineTo(rect.x, rect.y + radius.left);
ctx.quadraticCurveTo(rect.x, rect.y, rect.x + radius.left, rect.y);
ctx.closePath();
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}

20
src/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { Canvas } from "@/canvas";
import { setSeed } from "@/common/rand";
import { Fluids } from "@/game/fluids/fluids";
setSeed(1234);
document.addEventListener("DOMContentLoaded", async () => {
const canvasElement = document.getElementById("game");
if (!(canvasElement instanceof HTMLCanvasElement)) {
console.error('Could not find canvas element "game"');
return;
}
const canvas = new Canvas(canvasElement);
const fluids = new Fluids(canvas);
canvas.add(fluids);
canvas.init();
});

137
src/input.ts Normal file
View File

@ -0,0 +1,137 @@
import { Canvas } from "@/canvas";
import { Vec } from "@/common/vec";
export class Input {
keydowns: Set<unknown>;
keypresses: Set<unknown>;
private _mousePos: any;
private _mouseDown: boolean;
private _mousePressed: boolean;
private _rightMouseDown: boolean;
private _rightMousePressed: boolean;
private _middleMouseDown: boolean;
private _middleMousePressed: boolean;
canvas: Canvas;
constructor(canvas: Canvas) {
this.keydowns = new Set();
this.keypresses = new Set();
this._mousePos = new Vec();
this._mouseDown = false;
this._mousePressed = false;
this._rightMouseDown = false;
this._rightMousePressed = false;
this._middleMouseDown = false;
this._middleMousePressed = false;
this.canvas = canvas;
document.addEventListener('keydown', event => {
this.keydowns.add(event.key);
this.keypresses.add(event.key);
if (event.key === 'Tab') {
event.preventDefault();
}
});
document.addEventListener('keyup', event => {
this.keydowns.delete(event.key);
});
const mouseUp = (event: MouseEvent|TouchEvent) => {
const button = event instanceof MouseEvent ? event.button : 0;
if (button === 0) {
this._mouseDown = false;
} else if (button === 1) {
this._middleMouseDown = false;
this._middleMousePressed = false;
} else if (button === 2) {
this._rightMouseDown = false;
}
}
const mouseMove = (event: MouseEvent|TouchEvent) => {
if (event instanceof MouseEvent) {
this._mousePos = new Vec(event.clientX, event.clientY);
} else {
this._mousePos = new Vec(event.touches[0].clientX, event.touches[0].clientY);
}
}
const mouseDown = (event: MouseEvent|TouchEvent) => {
mouseMove(event);
const button = event instanceof MouseEvent ? event.button : 0;
if (button === 0) {
this._mouseDown = true;
this._mousePressed = true;
} else if (button === 1) {
this._middleMouseDown = true;
this._middleMousePressed = true;
event.preventDefault();
} else if (button === 2) {
this._rightMouseDown = true;
this._rightMousePressed = true;
event.preventDefault();
}
}
document.addEventListener('mousedown', mouseDown);
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseUp);
document.addEventListener('touchstart', mouseDown);
document.addEventListener('touchmove', mouseMove);
document.addEventListener('touchend', mouseUp);
document.addEventListener('touchcancel', mouseUp);
document.addEventListener('contextmenu', event => {
event.preventDefault();
});
}
update(canvas: Canvas) {
this.keypresses.clear();
this._mousePressed = false;
this._rightMousePressed = false;
this._middleMousePressed = false;
}
keydown(key: string) {
return this.keydowns.has(key);
}
keypressed(key: string) {
return this.keypresses.has(key);
}
get mousePos() {
return this._mousePos;
}
get mouseWorldPos() {
return this.canvas.camera.screenToScene(this._mousePos);
}
get mouseDown() {
return this._mouseDown;
}
get mousePressed() {
return this._mousePressed;
}
get rightMouseDown() {
return this._rightMouseDown;
}
get rightMousePressed() {
return this._rightMousePressed;
}
get middleMouseDown() {
return this._middleMouseDown;
}
get middleMousePressed() {
return this._middleMousePressed;
}
onClick(callback: (event: MouseEvent) => void) {
document.addEventListener('click', callback);
}
onWheel(callback: (event: WheelEvent) => void) {
document.addEventListener('wheel', callback);
}
}

69
src/ui/button.ts Normal file
View File

@ -0,0 +1,69 @@
import { Canvas } from "@/canvas";
import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { fillRoundRect, fillTextCentered } from "@/graphics";
import { Colors } from "@/ui/colors";
export default class Button extends GameObject {
_rect: Rect;
label: string;
onclick: (btn: Button) => void;
parent: GameObject | undefined;
clicked: boolean;
hovered: boolean;
constructor(label: string, rect: Rect, onclick: (btn: Button) => void, parent?: GameObject) {
super();
this._rect = rect;
this.label = label;
this.onclick = onclick;
this.parent = parent;
this.clicked = false;
this.hovered = false;
}
get rect() {
if (!this.parent) {
return this._rect;
}
return this._rect.translate(this.parent.pos);
}
update({input}: Canvas) {
this.hovered = this.rect.contains(input.mousePos);
if (this.hovered && input.mousePressed) {
this.clicked = true;
}
if (!input.mouseDown) {
if (this.clicked && this.hovered) {
this.onclick(this);
}
this.clicked = false;
}
}
private get backgroundStyle() {
if (this.clicked) {
return Colors.BG_ACTIVE;
}
if (this.hovered) {
return Colors.BG_HOVER;
}
return Colors.BG;
}
render({ctx}: Canvas) {
ctx.fillStyle = this.backgroundStyle;
fillRoundRect(ctx, this.rect, 10);
ctx.fillStyle = Colors.TEXT;
fillTextCentered(
ctx,
this.label,
new Vec(this.rect.left, this.rect.top + this.rect.height / 2),
this.rect.width
);
}
}

34
src/ui/colors.ts Normal file
View File

@ -0,0 +1,34 @@
export class Colors {
/**
* @link https://coolors.co/cb997e-ddbea9-ffe8d6-b7b7a4-a5a58d-6b705c
*/
static get BG(): Color {
return '#252521';
}
static get BG_ACTIVE(): Color {
return '#26453b';
}
static get BG_HOVER(): Color {
return '#284347';
}
static get TEXT(): Color {
return '#d3d3d3';
}
static get TEXT_MUTED(): Color {
return '#a5a58dff';
}
static get TEXT_PRIMARY(): Color {
return '#6b705cff';
}
}
export type Color = string;
export const Green = '#00ff00';
export const Blue = '#0000ff';
export const Black = '#000000';
export const White = '#ffffff';

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "es6",
"target": "ESNext",
"allowJs": false,
"moduleResolution": "node",
"baseUrl": "",
"paths": {
"@/*": ["src/*"],
}
},
}

62
webpack.config.js Normal file
View File

@ -0,0 +1,62 @@
// Generated using webpack-cli https://github.com/webpack/webpack-cli
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const isProduction = process.env.NODE_ENV == "production";
const stylesHandler = "style-loader";
const config = {
entry: "./src/index.ts",
output: {
path: path.resolve(__dirname, "dist"),
},
devServer: {
open: true,
host: "localhost",
},
plugins: [
new HtmlWebpackPlugin({
template: "index.html",
}),
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
module: {
rules: [
{
test: /\.(ts|tsx)$/i,
loader: "ts-loader",
exclude: ["/node_modules/"],
},
{
test: /\.css$/i,
use: [stylesHandler, "css-loader"],
},
{
test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
type: "asset",
},
// Add your rules for custom modules here
// Learn more about loaders from https://webpack.js.org/loaders/
],
},
resolve: {
extensions: [".tsx", ".ts", ".jsx", ".js", "..."],
alias: {
'@': path.resolve(__dirname, 'src'),
}
},
};
module.exports = () => {
if (isProduction) {
config.mode = "production";
} else {
config.mode = "development";
}
return config;
};

5290
yarn.lock Normal file

File diff suppressed because it is too large Load Diff