From 0735dd764f434d6cb51b19064ac7ee2d190be9b9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 4 May 2026 10:16:28 +0100 Subject: [PATCH] Clean up code and enable strict TS --- definitions.d.ts | 2 - index.html | 25 ++++-- public/favicon.svg | 6 ++ public/manifest.webmanifest | 22 +++++ src/constants.ts | 1 - src/game-loop/game-loop.ts | 89 +++++++++---------- src/game-loop/game-presentation.ts | 3 +- src/game-loop/game-rules.ts | 10 +-- src/index.scss | 13 +-- src/index.ts | 17 +--- src/page/set-up-settings-page.ts | 25 +++--- src/pipelines/agents/agent-pipeline.ts | 4 +- src/pipelines/brush/brush-pipeline.ts | 2 +- src/pipelines/diffusion/diffusion-pipeline.ts | 1 - src/style/common.scss | 4 +- src/utils/delta-time-calculator.ts | 2 +- src/utils/error-handler.ts | 2 +- src/utils/graphics/noise.ts | 2 +- src/utils/graphics/resizable-texture.ts | 6 +- tsconfig.json | 4 +- vite.config.ts | 3 - 21 files changed, 124 insertions(+), 119 deletions(-) create mode 100644 public/favicon.svg create mode 100644 public/manifest.webmanifest 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,