Swarm behaviour!
This commit is contained in:
commit
029034215d
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
15
README.md
Normal file
15
README.md
Normal 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
16
index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Luckberry</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
7952
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
124
src/camera.ts
Normal 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
143
src/canvas.ts
Normal 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
11
src/common/array.ts
Normal 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;
|
||||||
|
}
|
||||||
5
src/common/destructable.ts
Normal file
5
src/common/destructable.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Canvas } from "@/canvas";
|
||||||
|
|
||||||
|
export interface Destructable {
|
||||||
|
destruct(canvas: Canvas): void;
|
||||||
|
}
|
||||||
15
src/common/file-utils.ts
Normal file
15
src/common/file-utils.ts
Normal 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
7
src/common/functions.ts
Normal 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
14
src/common/gameobject.ts
Normal 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
14
src/common/grid.ts
Normal 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
38
src/common/noise.ts
Normal 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
36
src/common/rand.ts
Normal 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
78
src/common/rect.ts
Normal 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
5
src/common/renderable.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Canvas } from "@/canvas";
|
||||||
|
|
||||||
|
export interface Renderable {
|
||||||
|
render(canvas: Canvas): void;
|
||||||
|
}
|
||||||
5
src/common/updateable.ts
Normal file
5
src/common/updateable.ts
Normal 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
218
src/common/vec.ts
Normal 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
184
src/game/fluids/fluids.ts
Normal 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 = 200;
|
||||||
|
const pressureForce = 8000;
|
||||||
|
export const particleSize = 10;
|
||||||
|
export const desiredDensity = 6;
|
||||||
|
const densityRadius = 40;
|
||||||
|
const mass = 1;
|
||||||
|
const viscosityStrength = 0.4;
|
||||||
|
const viscosityRadius = 100;
|
||||||
|
const mouseRadius = 200;
|
||||||
|
const mouseForce = 0.2;
|
||||||
|
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, 10);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/game/fluids/particle.ts
Normal file
37
src/game/fluids/particle.ts
Normal 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
168
src/game/gamemap.ts
Normal 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);
|
||||||
|
}
|
||||||
195
src/game/swarm/agent.ts
Normal file
195
src/game/swarm/agent.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
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 = 2 / 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 = [];
|
||||||
|
/**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);
|
||||||
|
}*/
|
||||||
|
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) {
|
||||||
|
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)));
|
||||||
|
}*/
|
||||||
|
|
||||||
|
render({ctx}: Canvas) {
|
||||||
|
const color = navigationColorMap[this.lastNavigation.type];
|
||||||
|
fillCircle(ctx, this.pos, particleSize, color);
|
||||||
|
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/game/swarm/swarm.ts
Normal file
42
src/game/swarm/swarm.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Canvas } from "@/canvas";
|
||||||
|
import { GameObject } from "@/common/gameobject";
|
||||||
|
import { Rect } from "@/common/rect";
|
||||||
|
import { Agent } from "@/game/swarm/agent";
|
||||||
|
|
||||||
|
const numAgents = 100;
|
||||||
|
|
||||||
|
export class Swarm extends GameObject {
|
||||||
|
rect: Rect;
|
||||||
|
agents: Agent[];
|
||||||
|
constructor(canvas: Canvas) {
|
||||||
|
super();
|
||||||
|
this.rect = new Rect(0, 0, canvas.width, canvas.height);
|
||||||
|
this.agents = [];
|
||||||
|
this.init();
|
||||||
|
// @ts-ignore
|
||||||
|
window.swarm = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
98
src/graphics.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/index.ts
Normal file
25
src/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Canvas } from "@/canvas";
|
||||||
|
import { setSeed } from "@/common/rand";
|
||||||
|
import { Fluids } from "@/game/fluids/fluids";
|
||||||
|
import { fillMap, Gamemap } from "@/game/gamemap";
|
||||||
|
import { Swarm } from "@/game/swarm/swarm";
|
||||||
|
|
||||||
|
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 map = new Gamemap(canvas);
|
||||||
|
canvas.add(map);
|
||||||
|
fillMap(map);*/
|
||||||
|
/**const fluids = new Fluids(canvas);
|
||||||
|
canvas.add(fluids);*/
|
||||||
|
const swarm = new Swarm(canvas);
|
||||||
|
canvas.add(swarm);
|
||||||
|
canvas.init();
|
||||||
|
});
|
||||||
123
src/input.ts
Normal file
123
src/input.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', event => {
|
||||||
|
this._mousePos = new Vec(event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', event => {
|
||||||
|
if (event.button === 0) {
|
||||||
|
this._mouseDown = false;
|
||||||
|
} else if (event.button === 1) {
|
||||||
|
this._middleMouseDown = false;
|
||||||
|
this._middleMousePressed = false;
|
||||||
|
} else if (event.button === 2) {
|
||||||
|
this._rightMouseDown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mousedown', event => {
|
||||||
|
if (event.button === 0) {
|
||||||
|
this._mouseDown = true;
|
||||||
|
this._mousePressed = true;
|
||||||
|
} else if (event.button === 1) {
|
||||||
|
this._middleMouseDown = true;
|
||||||
|
this._middleMousePressed = true;
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.button === 2) {
|
||||||
|
this._rightMouseDown = true;
|
||||||
|
this._rightMousePressed = true;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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
69
src/ui/button.ts
Normal 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
34
src/ui/colors.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export class Colors {
|
||||||
|
/**
|
||||||
|
* @link https://coolors.co/cb997e-ddbea9-ffe8d6-b7b7a4-a5a58d-6b705c
|
||||||
|
*/
|
||||||
|
static get BG(): Color {
|
||||||
|
return '#cb997eff';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get BG_ACTIVE(): Color {
|
||||||
|
return '#ddbea9ff';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get BG_HOVER(): Color {
|
||||||
|
return '#ffe8d6ff';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get TEXT(): Color {
|
||||||
|
return '#b7b7a4ff';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
14
tsconfig.json
Normal 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
62
webpack.config.js
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user