diff --git a/definitions.d.ts b/definitions.d.ts
index 64b10e4..934370e 100644
--- a/definitions.d.ts
+++ b/definitions.d.ts
@@ -2,5 +2,3 @@ declare module '*.wgsl?raw' {
const content: string;
export default content;
}
-
-declare const __BUILD_DATE__: number;
diff --git a/index.html b/index.html
index d1da4f6..e690a6b 100644
--- a/index.html
+++ b/index.html
@@ -22,10 +22,9 @@
-
-
-
-
+
+
+
Just a bunch of blobs
@@ -51,9 +50,21 @@
Just a bunch of blobs
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Eaque itaque
- perspiciatis nesciunt, molestiae officiis dignissimos porro! Provident totam
- sit enim, dolores dicta possimus ex assumenda earum, ea tempore, aut quidem?
+ A million autonomous agents wander a 2D field. Each one lays down a faint
+ trail and follows trails it senses ahead. Two generations are competing for
+ territory: the older one fades, the newer one spreads.
+
+
+ Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
+ the new generation down and let the old one breathe a little longer. Open
+ Settings to retune sensors, decay rates and aggression.
+
+
+ Runs entirely on your GPU via WebGPU compute shaders — no servers, no
+ tracking, no analytics. Source on
+ GitHub .
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..50bb5e6
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..faebc05
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,22 @@
+{
+ "name": "Just a bunch of blobs",
+ "short_name": "Blobs",
+ "description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "fullscreen",
+ "display_override": ["fullscreen", "standalone", "minimal-ui"],
+ "orientation": "any",
+ "background_color": "#b7455e",
+ "theme_color": "#b7455e",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["entertainment", "graphics"],
+ "lang": "en"
+}
diff --git a/src/constants.ts b/src/constants.ts
index 43a6b71..24bf445 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,2 +1 @@
export const isProduction: boolean = import.meta.env.PROD;
-export const lastEdit = new Date(__BUILD_DATE__);
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index ce2ab29..9586897 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -31,9 +31,9 @@ export default class GameLoop {
private readonly hasFinishedPromise: Promise = new Promise(
(resolve) => (this.resolveHasFinished = resolve)
);
- private resolveHasFinished: () => void;
+ private resolveHasFinished!: () => void;
- private isSwipeActive = false;
+ private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
@@ -73,38 +73,48 @@ export default class GameLoop {
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
window.addEventListener('resize', this.resize.bind(this));
- canvas.addEventListener('mousemove', this.onSwipe.bind(this));
- canvas.addEventListener('touchmove', this.onSwipe.bind(this));
- canvas.addEventListener('mousedown', (e) => {
- if (!this.isSwipeActive) {
- this.brushPipeline.clearSwipes();
- this.isSwipeActive = true;
- }
- this.onSwipe(e);
- });
+ canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
+ canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
+ canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
+ canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
+ }
- canvas.addEventListener('touchstart', (e) => {
- if (!this.isSwipeActive) {
- this.brushPipeline.clearSwipes();
- this.isSwipeActive = true;
- }
- this.onSwipe(e);
- });
+ private onPointerDown(event: PointerEvent) {
+ if (this.activePointerId !== null) {
+ return;
+ }
+ this.activePointerId = event.pointerId;
+ this.canvas.setPointerCapture(event.pointerId);
+ this.brushPipeline.clearSwipes();
+ this.addSwipeAt(event);
+ }
- window.addEventListener('mouseup', (e) => {
- this.onSwipe(e);
- this.isSwipeActive = false;
- });
+ private onPointerMove(event: PointerEvent) {
+ if (event.pointerId !== this.activePointerId) {
+ return;
+ }
+ this.addSwipeAt(event);
+ }
- window.addEventListener('touchend', (e) => {
- this.onSwipe(e);
- this.isSwipeActive = false;
- });
+ private onPointerUp(event: PointerEvent) {
+ if (event.pointerId !== this.activePointerId) {
+ return;
+ }
+ this.addSwipeAt(event);
+ this.canvas.releasePointerCapture(event.pointerId);
+ this.activePointerId = null;
+ }
- window.addEventListener('touchcancel', (e) => {
- this.onSwipe(e);
- this.isSwipeActive = false;
- });
+ private addSwipeAt(event: PointerEvent) {
+ const position = vec2.fromValues(
+ event.clientX * this.devicePixelRatio,
+ this.canvas.height - event.clientY * this.devicePixelRatio
+ );
+ this.brushPipeline.addSwipe(position);
+ }
+
+ private get isSwipeActive(): boolean {
+ return this.activePointerId !== null;
}
public async start(): Promise {
@@ -135,25 +145,6 @@ export default class GameLoop {
return this.agentGenerationPipeline.maxAgentCount;
}
- private onSwipe(event: MouseEvent | TouchEvent) {
- if (!this.isSwipeActive || !event) {
- return;
- }
-
- if (event instanceof TouchEvent && event.touches.length === 0) {
- return;
- }
-
- const x = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX;
- const y = event instanceof MouseEvent ? event.clientY : event.touches[0].clientY;
-
- const position = vec2.fromValues(
- x * this.devicePixelRatio,
- this.canvas.height - y * this.devicePixelRatio
- );
- this.brushPipeline.addSwipe(position);
- }
-
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
diff --git a/src/game-loop/game-presentation.ts b/src/game-loop/game-presentation.ts
index 3fb03cf..7332da3 100644
--- a/src/game-loop/game-presentation.ts
+++ b/src/game-loop/game-presentation.ts
@@ -2,13 +2,12 @@ import { vec3 } from 'gl-matrix';
import { settings } from '../settings';
import { hsl } from '../utils/hsl';
-import { last } from '../utils/last';
import { Random } from '../utils/random';
const hues = [settings.startColorHue];
for (let i = 0; i < 100; i++) {
- hues.push((last(hues) + Random.randomBetween(90, 240)) % 360);
+ hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
}
const colors = hues.map((hue) =>
diff --git a/src/game-loop/game-rules.ts b/src/game-loop/game-rules.ts
index 3c2f8ed..87bf9e4 100644
--- a/src/game-loop/game-rules.ts
+++ b/src/game-loop/game-rules.ts
@@ -13,8 +13,8 @@ export interface SpawnAction {
}
export class GameRules {
- private static readonly DEAFULT_SPAWN_INTERVAL = 8;
- private static readonly DEAFULT_SPAWN_TIME_LENGTH = 2;
+ private static readonly DEFAULT_SPAWN_INTERVAL = 8;
+ private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_RADIUS = 20;
private lastSpawnTimeInSeconds = 0;
@@ -41,14 +41,14 @@ export class GameRules {
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
if (
this.lastSpawnAction &&
- timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEAFULT_SPAWN_TIME_LENGTH
+ timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
) {
return this.lastSpawnAction;
}
this.currentSpawnInterval = mix(
- GameRules.DEAFULT_SPAWN_INTERVAL,
- GameRules.DEAFULT_SPAWN_INTERVAL / 5,
+ GameRules.DEFAULT_SPAWN_INTERVAL,
+ GameRules.DEFAULT_SPAWN_INTERVAL / 5,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
diff --git a/src/index.scss b/src/index.scss
index 982f236..8ba94e8 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -15,7 +15,7 @@ html > body {
> canvas {
height: 100%;
width: 100%;
-
+ touch-action: none;
cursor:
url('../assets/icons/brush.svg') 0 24,
auto;
@@ -165,27 +165,18 @@ html > body {
}
&.info::after {
- -webkit-mask-image: url('../assets/icons/info.svg');
mask-image: url('../assets/icons/info.svg');
}
-
&.maximize-full-screen::after {
- -webkit-mask-image: url('../assets/icons/maximize.svg');
mask-image: url('../assets/icons/maximize.svg');
}
-
&.minimize-full-screen::after {
- -webkit-mask-image: url('../assets/icons/minimize.svg');
mask-image: url('../assets/icons/minimize.svg');
}
-
&.settings::after {
- -webkit-mask-image: url('../assets/icons/settings.svg');
mask-image: url('../assets/icons/settings.svg');
}
-
&.restart::after {
- -webkit-mask-image: url('../assets/icons/restart.svg');
mask-image: url('../assets/icons/restart.svg');
}
}
@@ -194,6 +185,8 @@ html > body {
> main.pages {
overflow-x: hidden;
overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--main-color) transparent;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar {
background-color: transparent;
diff --git a/src/index.ts b/src/index.ts
index a3857a2..1e2c2d9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,4 @@
-import { isProduction, lastEdit } from './constants';
+import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules';
@@ -30,9 +30,7 @@ const elements = {
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
- canvasContainer: document.querySelector('main.canvas-container') as HTMLCanvasElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
- // counters: document.querySelector('.counters > pre') as HTMLPreElement,
};
const main = async () => {
@@ -90,16 +88,6 @@ const main = async () => {
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
});
- console.log({ lastEdit });
-
- // const updateCounters = () => {
- // elements.counters.innerHTML = `FPS: ${deltaTimeCalculator.fps.toFixed(2)}
- // current gen: ${formatNumber(game?.aliveAgentCounts.currentGenerationCount ?? 0)}
- // next gen: ${formatNumber(game?.aliveAgentCounts.nextGenerationCount ?? 0)}`;
- // window.requestAnimationFrame(updateCounters);
- // };
- // updateCounters();
-
while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
@@ -111,7 +99,8 @@ const main = async () => {
await game.start();
}
} catch (e) {
- ErrorHandler.addError(Severity.ERROR, e.stack);
+ const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
+ ErrorHandler.addError(Severity.ERROR, message);
console.error(e);
}
};
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
index 71680f2..238d704 100644
--- a/src/page/set-up-settings-page.ts
+++ b/src/page/set-up-settings-page.ts
@@ -6,13 +6,16 @@ export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array> => {
- const sliders = [
- !isProduction &&
- new SettingsSlider(settings, 'renderSpeed', {
- min: 1,
- max: 10,
- rounding: Math.round,
- }),
+ const sliders: Array> = [
+ ...(isProduction
+ ? []
+ : [
+ new SettingsSlider(settings, 'renderSpeed', {
+ min: 1,
+ max: 10,
+ rounding: Math.round,
+ }),
+ ]),
new SettingsSlider(settings, 'agentCount', {
min: 1,
@@ -102,11 +105,9 @@ export const setUpSettingsPage = (
const sliderContainerElement = document.createElement('div');
- sliders
- .filter((v) => v)
- .forEach((slider) => {
- sliderContainerElement.appendChild(slider.element);
- });
+ sliders.forEach((slider) => {
+ sliderContainerElement.appendChild(slider.element);
+ });
settingsPage.appendChild(sliderContainerElement);
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index 6698ab5..efcf9ed 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -3,7 +3,7 @@ import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
-import agentSchme from './agent-generation/agent-schema.wgsl?raw';
+import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
@@ -32,7 +32,7 @@ export class AgentPipeline {
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
- module: smartCompile(device, CommonState.shaderCode, agentSchme, shader),
+ module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
entryPoint: 'main',
},
});
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index 795db30..ad41a1f 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -188,7 +188,7 @@ export class BrushPipeline {
result.push(position);
}
- result.push(last(points));
+ result.push(last(points)!);
return result;
}
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index 7820b68..3bb3422 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -11,7 +11,6 @@ export class DiffusionPipeline {
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
- private readonly noise: GPUTextureView;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
diff --git a/src/style/common.scss b/src/style/common.scss
index 2b1603e..8954439 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -31,7 +31,9 @@ p {
html {
height: 100%;
- -webkit-font-smooth: antialiased;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
}
.large-button {
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index dd430c3..fb1ef5a 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -41,6 +41,6 @@ export class DeltaTimeCalculator {
}
public get fps() {
- return 1 / this.deltaTimeAccumulator;
+ return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
}
}
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
index e13a006..ca91a8a 100644
--- a/src/utils/error-handler.ts
+++ b/src/utils/error-handler.ts
@@ -30,7 +30,7 @@ export class ErrorHandler {
}
public static addMetadata(key: string, value: any) {
- const serialized = {};
+ const serialized: Record = {};
for (const k in value) {
serialized[k] = value[k];
}
diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts
index 920ddeb..f07a539 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -83,5 +83,5 @@ export const generateNoise = ({
textureCache.set(cacheKey, colorTexture);
}
- return textureCache.get(cacheKey).createView();
+ return textureCache.get(cacheKey)!.createView();
};
diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts
index 77b65e9..bb340c7 100644
--- a/src/utils/graphics/resizable-texture.ts
+++ b/src/utils/graphics/resizable-texture.ts
@@ -3,8 +3,8 @@ import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
export class ResizableTexture {
- private texture: GPUTexture;
- private textureView: GPUTextureView;
+ private texture!: GPUTexture;
+ private textureView!: GPUTextureView;
private readonly copyPipeline: CopyPipeline;
private size: vec2 | null = null;
@@ -35,7 +35,7 @@ export class ResizableTexture {
const newTextureView = newTexture.createView();
- if (this.textureView) {
+ if (this.size) {
const commandEncoder = this.device.createCommandEncoder();
this.copyPipeline.execute(
commandEncoder,
diff --git a/tsconfig.json b/tsconfig.json
index 172a67b..ee86886 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,9 +14,7 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
- "noImplicitAny": false,
- "strictNullChecks": false,
- "useUnknownInCatchVariables": false,
+ "strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
diff --git a/vite.config.ts b/vite.config.ts
index ab1b439..87c15ab 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,9 +3,6 @@ import { viteSingleFile } from 'vite-plugin-singlefile';
export default defineConfig({
plugins: [viteSingleFile()],
- define: {
- __BUILD_DATE__: Date.now(),
- },
build: {
target: 'es2022',
cssCodeSplit: false,