Clean up code and enable strict TS

This commit is contained in:
Andras Schmelczer 2026-05-04 10:16:28 +01:00
parent f350b1ff37
commit 0735dd764f
21 changed files with 124 additions and 119 deletions

2
definitions.d.ts vendored
View file

@ -2,5 +2,3 @@ declare module '*.wgsl?raw' {
const content: string; const content: string;
export default content; export default content;
} }
declare const __BUILD_DATE__: number;

View file

@ -22,10 +22,9 @@
<meta property="og:image:width" content="1920" /> <meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" /> <meta property="og:image:height" content="1920" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>Just a bunch of blobs</title> <title>Just a bunch of blobs</title>
</head> </head>
@ -51,9 +50,21 @@
<section> <section>
<h1>Just a bunch of blobs</h1> <h1>Just a bunch of blobs</h1>
<p> <p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Eaque itaque A million autonomous agents wander a 2D field. Each one lays down a faint
perspiciatis nesciunt, molestiae officiis dignissimos porro! Provident totam trail and follows trails it senses ahead. Two generations are competing for
sit enim, dolores dicta possimus ex assumenda earum, ea tempore, aut quidem? territory: the older one fades, the newer one spreads.
</p>
<p>
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
<em>Settings</em> to retune sensors, decay rates and aggression.
</p>
<p>
Runs entirely on your GPU via WebGPU compute shaders &mdash; no servers, no
tracking, no analytics. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
>.
</p> </p>
</section> </section>
</main> </main>

6
public/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#b7455e" />
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
</svg>

After

Width:  |  Height:  |  Size: 312 B

View file

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

View file

@ -1,2 +1 @@
export const isProduction: boolean = import.meta.env.PROD; export const isProduction: boolean = import.meta.env.PROD;
export const lastEdit = new Date(__BUILD_DATE__);

View file

@ -31,9 +31,9 @@ export default class GameLoop {
private readonly hasFinishedPromise: Promise<void> = new Promise( private readonly hasFinishedPromise: Promise<void> = new Promise(
(resolve) => (this.resolveHasFinished = resolve) (resolve) => (this.resolveHasFinished = resolve)
); );
private resolveHasFinished: () => void; private resolveHasFinished!: () => void;
private isSwipeActive = false; private activePointerId: number | null = null;
public constructor( public constructor(
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
@ -73,38 +73,48 @@ export default class GameLoop {
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
window.addEventListener('resize', this.resize.bind(this)); window.addEventListener('resize', this.resize.bind(this));
canvas.addEventListener('mousemove', this.onSwipe.bind(this)); canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
canvas.addEventListener('touchmove', this.onSwipe.bind(this)); canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
canvas.addEventListener('mousedown', (e) => { canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
if (!this.isSwipeActive) { canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
this.brushPipeline.clearSwipes(); }
this.isSwipeActive = true;
}
this.onSwipe(e);
});
canvas.addEventListener('touchstart', (e) => { private onPointerDown(event: PointerEvent) {
if (!this.isSwipeActive) { if (this.activePointerId !== null) {
this.brushPipeline.clearSwipes(); return;
this.isSwipeActive = true; }
} this.activePointerId = event.pointerId;
this.onSwipe(e); this.canvas.setPointerCapture(event.pointerId);
}); this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
}
window.addEventListener('mouseup', (e) => { private onPointerMove(event: PointerEvent) {
this.onSwipe(e); if (event.pointerId !== this.activePointerId) {
this.isSwipeActive = false; return;
}); }
this.addSwipeAt(event);
}
window.addEventListener('touchend', (e) => { private onPointerUp(event: PointerEvent) {
this.onSwipe(e); if (event.pointerId !== this.activePointerId) {
this.isSwipeActive = false; return;
}); }
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
}
window.addEventListener('touchcancel', (e) => { private addSwipeAt(event: PointerEvent) {
this.onSwipe(e); const position = vec2.fromValues(
this.isSwipeActive = false; 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<void> { public async start(): Promise<void> {
@ -135,25 +145,6 @@ export default class GameLoop {
return this.agentGenerationPipeline.maxAgentCount; 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() { private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio; this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio; this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;

View file

@ -2,13 +2,12 @@ import { vec3 } from 'gl-matrix';
import { settings } from '../settings'; import { settings } from '../settings';
import { hsl } from '../utils/hsl'; import { hsl } from '../utils/hsl';
import { last } from '../utils/last';
import { Random } from '../utils/random'; import { Random } from '../utils/random';
const hues = [settings.startColorHue]; const hues = [settings.startColorHue];
for (let i = 0; i < 100; i++) { 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) => const colors = hues.map((hue) =>

View file

@ -13,8 +13,8 @@ export interface SpawnAction {
} }
export class GameRules { export class GameRules {
private static readonly DEAFULT_SPAWN_INTERVAL = 8; private static readonly DEFAULT_SPAWN_INTERVAL = 8;
private static readonly DEAFULT_SPAWN_TIME_LENGTH = 2; private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_RADIUS = 20; private static readonly DEFAULT_SPAWN_RADIUS = 20;
private lastSpawnTimeInSeconds = 0; private lastSpawnTimeInSeconds = 0;
@ -41,14 +41,14 @@ export class GameRules {
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction { public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
if ( if (
this.lastSpawnAction && this.lastSpawnAction &&
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEAFULT_SPAWN_TIME_LENGTH timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
) { ) {
return this.lastSpawnAction; return this.lastSpawnAction;
} }
this.currentSpawnInterval = mix( this.currentSpawnInterval = mix(
GameRules.DEAFULT_SPAWN_INTERVAL, GameRules.DEFAULT_SPAWN_INTERVAL,
GameRules.DEAFULT_SPAWN_INTERVAL / 5, GameRules.DEFAULT_SPAWN_INTERVAL / 5,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120) clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
); );

View file

@ -15,7 +15,7 @@ html > body {
> canvas { > canvas {
height: 100%; height: 100%;
width: 100%; width: 100%;
touch-action: none;
cursor: cursor:
url('../assets/icons/brush.svg') 0 24, url('../assets/icons/brush.svg') 0 24,
auto; auto;
@ -165,27 +165,18 @@ html > body {
} }
&.info::after { &.info::after {
-webkit-mask-image: url('../assets/icons/info.svg');
mask-image: url('../assets/icons/info.svg'); mask-image: url('../assets/icons/info.svg');
} }
&.maximize-full-screen::after { &.maximize-full-screen::after {
-webkit-mask-image: url('../assets/icons/maximize.svg');
mask-image: url('../assets/icons/maximize.svg'); mask-image: url('../assets/icons/maximize.svg');
} }
&.minimize-full-screen::after { &.minimize-full-screen::after {
-webkit-mask-image: url('../assets/icons/minimize.svg');
mask-image: url('../assets/icons/minimize.svg'); mask-image: url('../assets/icons/minimize.svg');
} }
&.settings::after { &.settings::after {
-webkit-mask-image: url('../assets/icons/settings.svg');
mask-image: url('../assets/icons/settings.svg'); mask-image: url('../assets/icons/settings.svg');
} }
&.restart::after { &.restart::after {
-webkit-mask-image: url('../assets/icons/restart.svg');
mask-image: url('../assets/icons/restart.svg'); mask-image: url('../assets/icons/restart.svg');
} }
} }
@ -194,6 +185,8 @@ html > body {
> main.pages { > main.pages {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent;
&::-webkit-scrollbar-track, &::-webkit-scrollbar-track,
&::-webkit-scrollbar { &::-webkit-scrollbar {
background-color: transparent; background-color: transparent;

View file

@ -1,4 +1,4 @@
import { isProduction, lastEdit } from './constants'; import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop'; import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules'; import { GameRules } from './game-loop/game-rules';
@ -30,9 +30,7 @@ const elements = {
settingsButton: document.querySelector('button.settings') as HTMLButtonElement, settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement, restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement, canvas: document.querySelector('canvas') as HTMLCanvasElement,
canvasContainer: document.querySelector('main.canvas-container') as HTMLCanvasElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement, errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
// counters: document.querySelector('.counters > pre') as HTMLPreElement,
}; };
const main = async () => { const main = async () => {
@ -90,16 +88,6 @@ const main = async () => {
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource()); 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) { while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000); const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules); game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
@ -111,7 +99,8 @@ const main = async () => {
await game.start(); await game.start();
} }
} catch (e) { } 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); console.error(e);
} }
}; };

View file

@ -6,13 +6,16 @@ export const setUpSettingsPage = (
settingsPage: HTMLDivElement, settingsPage: HTMLDivElement,
maxAgentCount: number maxAgentCount: number
): Array<SettingsSlider<any>> => { ): Array<SettingsSlider<any>> => {
const sliders = [ const sliders: Array<SettingsSlider<any>> = [
!isProduction && ...(isProduction
new SettingsSlider(settings, 'renderSpeed', { ? []
min: 1, : [
max: 10, new SettingsSlider(settings, 'renderSpeed', {
rounding: Math.round, min: 1,
}), max: 10,
rounding: Math.round,
}),
]),
new SettingsSlider(settings, 'agentCount', { new SettingsSlider(settings, 'agentCount', {
min: 1, min: 1,
@ -102,11 +105,9 @@ export const setUpSettingsPage = (
const sliderContainerElement = document.createElement('div'); const sliderContainerElement = document.createElement('div');
sliders sliders.forEach((slider) => {
.filter((v) => v) sliderContainerElement.appendChild(slider.element);
.forEach((slider) => { });
sliderContainerElement.appendChild(slider.element);
});
settingsPage.appendChild(sliderContainerElement); settingsPage.appendChild(sliderContainerElement);

View file

@ -3,7 +3,7 @@ import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts'; import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; 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 { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw'; import shader from './agent.wgsl?raw';
@ -32,7 +32,7 @@ export class AgentPipeline {
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchme, shader), module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });

View file

@ -188,7 +188,7 @@ export class BrushPipeline {
result.push(position); result.push(position);
} }
result.push(last(points)); result.push(last(points)!);
return result; return result;
} }

View file

@ -11,7 +11,6 @@ export class DiffusionPipeline {
private readonly pipeline: GPURenderPipeline; private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer; private readonly vertexBuffer: GPUBuffer;
private readonly noise: GPUTextureView;
private bindGroup?: GPUBindGroup; private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView; private previousTrailMapIn?: GPUTextureView;

View file

@ -31,7 +31,9 @@ p {
html { html {
height: 100%; height: 100%;
-webkit-font-smooth: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
} }
.large-button { .large-button {

View file

@ -41,6 +41,6 @@ export class DeltaTimeCalculator {
} }
public get fps() { public get fps() {
return 1 / this.deltaTimeAccumulator; return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
} }
} }

View file

@ -30,7 +30,7 @@ export class ErrorHandler {
} }
public static addMetadata(key: string, value: any) { public static addMetadata(key: string, value: any) {
const serialized = {}; const serialized: Record<string, any> = {};
for (const k in value) { for (const k in value) {
serialized[k] = value[k]; serialized[k] = value[k];
} }

View file

@ -83,5 +83,5 @@ export const generateNoise = ({
textureCache.set(cacheKey, colorTexture); textureCache.set(cacheKey, colorTexture);
} }
return textureCache.get(cacheKey).createView(); return textureCache.get(cacheKey)!.createView();
}; };

View file

@ -3,8 +3,8 @@ import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline'; import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
export class ResizableTexture { export class ResizableTexture {
private texture: GPUTexture; private texture!: GPUTexture;
private textureView: GPUTextureView; private textureView!: GPUTextureView;
private readonly copyPipeline: CopyPipeline; private readonly copyPipeline: CopyPipeline;
private size: vec2 | null = null; private size: vec2 | null = null;
@ -35,7 +35,7 @@ export class ResizableTexture {
const newTextureView = newTexture.createView(); const newTextureView = newTexture.createView();
if (this.textureView) { if (this.size) {
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
this.copyPipeline.execute( this.copyPipeline.execute(
commandEncoder, commandEncoder,

View file

@ -14,9 +14,7 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "strict": true,
"strictNullChecks": false,
"useUnknownInCatchVariables": false,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false "noUnusedParameters": false
}, },

View file

@ -3,9 +3,6 @@ import { viteSingleFile } from 'vite-plugin-singlefile';
export default defineConfig({ export default defineConfig({
plugins: [viteSingleFile()], plugins: [viteSingleFile()],
define: {
__BUILD_DATE__: Date.now(),
},
build: { build: {
target: 'es2022', target: 'es2022',
cssCodeSplit: false, cssCodeSplit: false,