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;
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:height" content="1920" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Just a bunch of blobs</title>
</head>
@ -51,9 +50,21 @@
<section>
<h1>Just a bunch of blobs</h1>
<p>
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.
</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>
</section>
</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 lastEdit = new Date(__BUILD_DATE__);

View file

@ -31,9 +31,9 @@ export default class GameLoop {
private readonly hasFinishedPromise: Promise<void> = 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<void> {
@ -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;

View file

@ -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) =>

View file

@ -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)
);

View file

@ -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;

View file

@ -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);
}
};

View file

@ -6,13 +6,16 @@ export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array<SettingsSlider<any>> => {
const sliders = [
!isProduction &&
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
const sliders: Array<SettingsSlider<any>> = [
...(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);

View file

@ -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',
},
});

View file

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

View file

@ -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;

View file

@ -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 {

View file

@ -41,6 +41,6 @@ export class DeltaTimeCalculator {
}
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) {
const serialized = {};
const serialized: Record<string, any> = {};
for (const k in value) {
serialized[k] = value[k];
}

View file

@ -83,5 +83,5 @@ export const generateNoise = ({
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';
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,

View file

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

View file

@ -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,