Sigmally Fixes V2

Massive performance improvements, bug fixes for Sigmally.com - supports SigMod

目前为 2024-02-27 提交的版本。查看 最新版本

// ==UserScript==
// @name         Sigmally Fixes V2
// @version      2024-02-27
// @description  Massive performance improvements, bug fixes for Sigmally.com - supports SigMod
// @author       8y8x
// @match        https://sigmally.com/
// @icon         https://8y8x.dev/favicon.ico
// @license      MIT
// @grant        none
// @namespace https://8y8x.dev/sigmally-fixes
// ==/UserScript==

// @ts-check
'use strict';

(async () => {
	////////////////////////
	// Destroy Old Client //
	////////////////////////
	const destructor = await (async () => {
		// #1 : kill the rendering process
		const oldRQA = requestAnimationFrame;
		window.requestAnimationFrame = function(fn) {
			try {
				throw new Error();
			} catch (err) {
				// prevent drawing the game, but do NOT prevent saving settings (which is called on RQA)
				if (!err.stack.includes('/game.js') || err.stack.includes('HTMLInputElement'))
					return oldRQA(fn);
			}

			return -1;
		}

		// #2 : kill access to using a WebSocket
		const realWebSocket = WebSocket;
		window.WebSocket = new Proxy(WebSocket, {
			construct(_target, argArray, _newTarget) {
				if (argArray[0]?.includes('sigmally.com')) {
					throw new Error('Nope :) - hooked by Sigmally Fixes');
				}

				// @ts-expect-error
				return new oldWS(...argArray);
			}
		});

		let realWsSend = WebSocket.prototype.send;
		WebSocket.prototype.send = function() {
			if (this.url.includes('sigmally.com')) {
				this.onclose = null;
				this.close();
				throw new Error('Nope :) - hooked by Sigmally Fixes');
			}

			return realWsSend.apply(this, arguments);
		};

		// #3 : block play and spectate buttons from working (they show extra captchas)
		let captchaInterval;
		captchaInterval = setInterval(() => {
			const grecaptcha = /** @type {any} */ (window).grecaptcha;
			if (!grecaptcha) return;

			clearInterval(captchaInterval);
			/** @type {any} */ (window).grecaptcha = new Proxy(grecaptcha, {
				get: (_target, prop, _receiver) => {
					if (prop === 'execute') return () => new Promise(_ => {});

					return grecaptcha[prop];
				},
			});
		}, 50);

		// #4 : prevent keys from being registered by the game
		setInterval(() => {
			onkeydown = null;
			onkeyup = null;
		}, 50);

		return { realWebSocket, realWsSend };
	})();



	/////////////////////
	// Prepare Game UI //
	/////////////////////
	const ui = (() => {
		const ui = {};

		ui.game = (() => {
			const game = {};
			/** @type {HTMLCanvasElement | null} */
			const oldCanvas = document.querySelector('canvas#canvas');
			if (!oldCanvas)
				throw new Error('Couldn\'t find canvas');

			// leave the old canvas so the old client can actually run
			oldCanvas.style.display = 'none';
			/** @type {HTMLCanvasElement} */
			const newCanvas = /** @type {any} */ (oldCanvas.cloneNode());
			newCanvas.id = '';
			newCanvas.style.cssText = `background: #003; width: 100vw; height: 100vh; position: fixed; top: 0; left: 0;
				z-index: 1;`;
			(document.querySelector('.body__inner') ?? document.body).appendChild(newCanvas);
			game.canvas = newCanvas;

			const gl = newCanvas.getContext('webgl2');
			if (!gl) {
				alert('Your browser does not support WebGL2. Please use a different one, or disable Sigmally Fixes.');
				throw new Error('Couldn\'t get WebGL2 context');
			}

			game.gl = gl;

			game.viewportScale = 1;
			function resize() {
				newCanvas.width = Math.floor(innerWidth * devicePixelRatio);
				newCanvas.height = Math.floor(innerHeight * devicePixelRatio);
				game.viewportScale = Math.max(innerWidth / 1920, innerHeight / 1080);
				game.gl.viewport(0, 0, newCanvas.width, newCanvas.height);
			}

			addEventListener('resize', resize);
			resize();

			return game;
		})();

		// TODO: text needs to turn black when using light theme
		ui.stats = (() => {
			const container = document.createElement('div');
			container.style.cssText = `position: fixed; top: 10px; left: 10px; width: 400px; height: fit-content;
				user-select: none; z-index: 2; transform-origin: top left;`;
			document.body.appendChild(container);

			const score = document.createElement('div');
			score.style.cssText = `font-family: Ubuntu; font-size: 30px; color: #fff; line-height: 1.0;`;
			container.appendChild(score);

			const measures = document.createElement('div');
			measures.style.cssText = `font-family: Ubuntu; font-size: 20px; color: #fff; line-height: 1.1;`;
			container.appendChild(measures);

			const misc = document.createElement('div');
			// white-space: pre; allows using \r\n to insert line breaks
			misc.style.cssText = `font-family: Ubuntu; font-size: 14px; color: #fff; white-space: pre;
				line-height: 1.1; opacity: 0.5;`;
			container.appendChild(misc);

			/** @param {object} statData */
			function update(statData) {
				let uptime;
				if (statData.uptime < 60) {
					uptime = '<1min';
				} else {
					uptime = Math.floor(statData.uptime / 60 % 60) + 'min';
					if (statData.uptime >= 60 * 60)
						uptime = Math.floor(statData.uptime / 60 / 60 % 24) + 'hr ' + uptime;
					if (statData.uptime >= 24 * 60 * 60)
						uptime = Math.floor(statData.uptime / 24 / 60 / 60 % 60) + 'd ' + uptime;
				}

				misc.textContent = [
					`${statData.name} (${statData.mode})`,
					`${statData.playersTotal} / ${statData.playersLimit} players`,
					`${statData.playersAlive} playing`,
					`${statData.playersSpect} spectating`,
					`${(statData.update * 2.5).toFixed(1)}% load @ ${uptime}`,
				].join('\r\n');
			}

			/** @type {HTMLInputElement | null} */
			const darkTheme = document.querySelector('input#darkTheme');

			function matchTheme() {
				let color = '#fff';

				if (darkTheme && !darkTheme.checked)
					color = '#000';

				score.style.color = color;
				measures.style.color = color;
				misc.style.color = color;
			}

			matchTheme();

			return { container, score, measures, misc, update, matchTheme };
		})();

		ui.leaderboard = (() => {
			const container = document.createElement('div');
			container.style.cssText = `position: fixed; top: 10px; right: 10px; width: 200px; height: fit-content;
				user-select: none; z-index: 2; background: #0006; padding: 15px 5px; transform-origin: top right;
				display: none;`;
			document.body.appendChild(container);

			const title = document.createElement('div');
			title.style.cssText = `font-family: Ubuntu; font-size: 30px; color: #fff; text-align: center; width: 100%;`;
			title.textContent = 'Leaderboard';
			container.appendChild(title);

			const linesContainer = document.createElement('div');
			linesContainer.style.cssText = `font-family: Ubuntu; font-size: 20px; line-height: 1.2; width: 100%;
				height: fit-content; text-align: center; white-space: pre; overflow-x: hidden;`;
			container.appendChild(linesContainer);

			const lines = [];
			for (let i = 0; i < 11; ++i) {
				const line = document.createElement('div');
				line.style.display = 'none';
				linesContainer.appendChild(line);
				lines.push(line);
			}

			function update() {
				world.leaderboard.forEach((entry, i) => {
					const line = lines[i];
					if (!line) return;

					line.style.display = 'block';
					line.textContent = `${entry.place ?? i + 1}. ${entry.name}`;
					if (entry.me)
						line.style.color = '#faa';
					else if (entry.sub)
						line.style.color = '#ffc826';
					else
						line.style.color = '#fff';
				});

				for (let i = world.leaderboard.length; i < lines.length; ++i)
					lines[i].style.display = 'none';
			}

			return { container, title, linesContainer, lines, update };
		})();

		/** @type {HTMLElement | null} */
		const mainMenu = document.querySelector('#__line1')?.parentElement ?? null;
		if (!mainMenu) throw new Error('Can\'t find main menu');
		/** @type {HTMLElement | null} */
		const menuLinks = document.querySelector('#menu-links');
		/** @type {HTMLElement | null} */
		const overlay = document.querySelector('#overlays');

		let escOverlayVisible = true;
		/**
		 * @param {boolean} [show]
		 */
		ui.toggleEscOverlay = show => {
			escOverlayVisible = show ?? !escOverlayVisible;
			if (escOverlayVisible) {
				mainMenu.style.display = '';
				if (overlay) overlay.style.display = '';
				if (menuLinks) menuLinks.style.display = '';

				ui.deathScreen.hide();
			} else {
				mainMenu.style.display = 'none';
				if (overlay) overlay.style.display = 'none';
				if (menuLinks) menuLinks.style.display = 'none';
			}
		};

		ui.escOverlayVisible = () => escOverlayVisible;

		ui.deathScreen = (() => {
			const deathScreen = {};

			const statsContainer = document.querySelector('#__line2');
			const oldContinueButton = /** @type {HTMLElement | null} */ (document.querySelector('#continue_button'));
			if (oldContinueButton) {
				const continueButton = /** @type {HTMLButtonElement} */ (oldContinueButton.cloneNode(true));
				continueButton.id = ''; // prevent game script from seeing
				oldContinueButton.insertAdjacentElement('afterend', continueButton);
				oldContinueButton.style.display = 'none';
				continueButton.addEventListener('click', () => {
					ui.toggleEscOverlay(true);
				});
			}

			// i'm not gonna buy a boost to try and figure out how this thing works
			/** @type {HTMLElement | null} */
			const bonus = document.querySelector('#menu__bonus');
			if (bonus) bonus.style.display = 'none';

			/**
			 * @param {{ foodEaten: number, highestScore: number, highestPosition: number,
			 * spawnedAt: number | undefined }} stats
			 */
			deathScreen.show = stats => {
				const foodEatenElement = document.querySelector('#food_eaten');
				if (foodEatenElement)
					foodEatenElement.textContent = stats.foodEaten.toString();

				const highestMassElement = document.querySelector('#highest_mass');
				if (highestMassElement)
					highestMassElement.textContent = Math.round(stats.highestScore).toString();

				const highestPositionElement = document.querySelector('#top_leaderboard_position');
				if (highestPositionElement)
					highestPositionElement.textContent = stats.highestPosition.toString();

				const timeAliveElement = document.querySelector('#time_alive');
				if (timeAliveElement) {
					let time;
					if (stats.spawnedAt === undefined)
						time = 0;
					else
						time = (performance.now() - stats.spawnedAt) / 1000;
					const hours = Math.floor(time / 60 / 60);
					const mins = Math.floor(time / 60 % 60);
					const seconds = Math.floor(time % 60);

					timeAliveElement.textContent =  `${hours ? hours + ' h' : ''} ${mins ? mins + ' m' : ''} `
						+ `${seconds ? seconds + ' s' : ''}`;
				}

				statsContainer?.classList.remove('line--hidden');
				ui.toggleEscOverlay(false);
				if (overlay) overlay.style.display = '';

				stats.foodEaten = 0;
				stats.highestScore = 0;
				stats.highestPosition = 0;
				stats.spawnedAt = undefined;

				// refresh ads... ...yep
				const { adSlot4, adSlot5, adSlot6, googletag } = /** @type {any} */ (window);
				if (googletag) {
					googletag.cmd.push(() => googletag.display(adSlot4));
					googletag.cmd.push(() => googletag.display(adSlot5));
					googletag.cmd.push(() => googletag.display(adSlot6));
				}
			};

			deathScreen.hide = () => {
				const shown = !statsContainer?.classList.contains('line--hidden');
				statsContainer?.classList.add('line--hidden');
				const { googletag } = /** @type {any} */ (window);
				if (shown && googletag) {
					googletag.cmd.push(() => googletag.pubads().refresh());
				}
			};

			return deathScreen;
		})();

		ui.minimap = (() => {
			const canvas = document.createElement('canvas');
			canvas.style.cssText = `position: absolute; bottom: 0; right: 0; background: #0006; width: 200px;
				height: 200px; z-index: 2; user-select: none;`;
			canvas.width = canvas.height = 200;
			document.body.appendChild(canvas);

			const ctx = canvas.getContext('2d');
			if (!ctx) throw new Error('Can\'t get 2d context for minimap');

			return { canvas, ctx };
		})();

		ui.chat = (() => {
			const chat = {};

			const style = document.createElement('style');
			style.id = 'sigmally-fixes-style';
			style.innerHTML = `
			`;
			document.head.appendChild(style);

			const block = document.querySelector('#chat_block');
			if (!block) throw new Error('Can\'t find #chat_block');

			/**
			 * @param {ParentNode} root
			 * @param {string} selector
			 */
			function clone(root, selector) {
				const old = root.querySelector(selector);
				if (!old) throw new Error(`Can't find element ${selector}`);

				const el = /** @type {HTMLElement} */ (old.cloneNode(true));
				el.id = '';
				old.replaceWith(el);

				return el;
			}

			// elements grabbed with clone() are only styled by their class, not id
			const toggle = chat.toggle = clone(document, '#chat_vsbltyBtn');
			const input = chat.input = /** @type {HTMLInputElement} */ (document.querySelector('#chat_textbox'));
			const scrollbar = chat.scrollbar = clone(document, '#chat_scrollbar');
			const thumb = chat.thumb = clone(scrollbar, '#chat_thumb');

			if (!input) throw new Error('Can\'t find element #chat_textbox');

			const list = chat.list = document.createElement('div');
			list.style.cssText = `width: 400px; height: 182px; position: absolute; bottom: 54px; left: 46px;
				overflow: hidden; user-select: none; z-index: 301;`;
			block.appendChild(list);

			let toggled = true;
			toggle.style.borderBottomLeftRadius = '10px'; // a bug fix :p
			toggle.addEventListener('click', () => {
				toggled = !toggled;
				input.style.display = toggled ? '' : 'none';
				scrollbar.style.display = toggled ? 'block' : 'none';
				list.style.display = toggled ? '' : 'none';

				if (toggled) {
					toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '';
					toggle.style.opacity = '';
				} else {
					toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '10px';
					toggle.style.opacity = '0.25';
				}
			});

			scrollbar.style.display = 'block';
			let scrollTop = 0; // keep a float here, because list.scrollTop is always casted to an int
			let thumbHeight = 1;
			let lastY;
			thumb.style.height = '182px';

			function updateThumb() {
				thumb.style.bottom = (1 - list.scrollTop / (list.scrollHeight - 182)) * (182 - thumbHeight) + 'px';
			}

			function scroll() {
				if (scrollTop >= list.scrollHeight - 182 - 40) {
					// close to bottom, snap downwards
					list.scrollTop = scrollTop = list.scrollHeight - 182;
				}

				thumbHeight = Math.min(Math.max(182 / list.scrollHeight, 0.1), 1) * 182;
				thumb.style.height = thumbHeight + 'px';
				updateThumb();
			}

			let scrolling = false;
			thumb.addEventListener('mousedown', () => void (scrolling = true));
			addEventListener('mouseup', () => void (scrolling = false));
			addEventListener('mousemove', e => {
				const deltaY = e.clientY - lastY;
				lastY = e.clientY;

				if (!scrolling) return;
				e.preventDefault();

				if (lastY === undefined) {
					lastY = e.clientY;
					return;
				}

				list.scrollTop = scrollTop = Math.min(Math.max(
					scrollTop + deltaY * list.scrollHeight / 182, 0), list.scrollHeight - 182);
				console.log(scrollTop, deltaY, list.scrollHeight);
				updateThumb();
			});

			let lastWasBarrier = true; // init to true, so we don't print a barrier as the first ever message (ugly)
			/**
			 * @param {string} authorName
			 * @param {[number, number, number]} rgb
			 * @param {string} text
			 */
			chat.add = (authorName, rgb, text) => {
				lastWasBarrier = false;

				const container = document.createElement('div');
				const author = document.createElement('span');
				author.style.cssText = `color: ${aux.rgb2hex(rgb)}; padding-right: 0.75em;`;
				console.log(aux.rgb2hex(rgb));
				author.textContent = authorName;
				container.appendChild(author);

				const msg = document.createElement('span');
				msg.textContent = text;
				container.appendChild(msg);

				while (list.children.length > 100)
					list.firstChild?.remove();

				list.appendChild(container);

				scroll();
			};

			chat.barrier = () => {
				if (lastWasBarrier) return;
				lastWasBarrier = true;

				const barrier = document.createElement('div');
				barrier.style.cssText = 'width: calc(100% - 20px); height: 1px; background: #8888; margin: 10px;';
				list.appendChild(barrier);

				scroll();
			};

			/** @type {HTMLInputElement | null} */
			const darkTheme = document.querySelector('input#darkTheme');
			chat.matchTheme = () => {
				if (!darkTheme || darkTheme.checked) {
					list.style.color = '#fffc';
				} else {
					list.style.color = '#000c';
				}
			};

			return chat;
		})();

		return ui;
	})();



	////////////////////////////////
	// Define Auxiliary Functions //
	////////////////////////////////
	const aux = (() => {
		const aux = {};

		/**
		 * consistent exponential easing relative to 60fps
		 * @param {number} o
		 * @param {number} n
		 * @param {number} factor
		 * @param {number} dt in seconds
		 */
		aux.exponentialEase = (o, n, factor, dt) => {
			return o + (n - o) * (1 - (1 - 1 / factor)**(60 * dt));
		};

		/** @param {[number, number, number]} rgb */
		aux.rgb2hex = rgb => {
			return [
				'#',
				Math.floor(rgb[0] * 255).toString(16).padStart(2, '0'),
				Math.floor(rgb[1] * 255).toString(16).padStart(2, '0'),
				Math.floor(rgb[2] * 255).toString(16).padStart(2, '0'),
			].join('');
		};

		/**
		 * @param {string} name
		 * @param {string} skin
		 */
		aux.parseNameSkin = (name, skin) => {
			const match = /^\{(.*?)\}.*$/.exec(name);
			if (match) {
				skin ||= match[1];
				name = match[2] || 'An unnamed cell';
			} else {
				name ||= 'An unnamed cell';
			}

			if (skin) {
				skin = skin.replace('1%', '').replace('2%', '').replace('3%', '');
				skin = '/static/skins/' + skin + '.png';
			}

			return { name, skin };
		};

		/** @param {number} ms */
		aux.wait = ms => new Promise(resolve => setTimeout(resolve, ms));

		return aux;
	})();



	///////////////////////////
	// Setup World Variables //
	///////////////////////////
	/** @typedef {{
	 * id: number,
	 * x: number, ox: number, nx: number,
	 * y: number, oy: number, ny: number,
	 * r: number, or: number, nr: number,
	 * rgb: [number, number, number],
	 * updated: number, born: number, dead: { to: Cell | undefined, at: number } | undefined,
	 * jagged: boolean,
	 * name: string, skin: string, sub: boolean,
	 * jelly: { x: number, y: number, r: number },
	 * }} Cell */
	const world = (() => {
		const world = {};

		// #1 : define cell variables and functions
		/** @type {Map<number, Cell>} */
		world.cells = new Map();
		/** @type {number[]} */
		world.mine = []; // order matters, as the oldest cells split first

		/**
		 * @param {Cell} cell
		 * @param {number} now
		 * @param {number | undefined} dt
		 */
		world.move = function(cell, now, dt) {
			const a = Math.min(Math.max((now - cell.updated) / 120, 0), 1);
			let nx = cell.nx;
			let ny = cell.ny;
			if (cell.dead?.to) {
				nx = cell.dead.to.x;
				ny = cell.dead.to.y;
			}

			cell.x = cell.ox + (nx - cell.ox) * a;
			cell.y = cell.oy + (ny - cell.oy) * a;
			cell.r = cell.or + (cell.nr - cell.or) * a;

			if (dt !== undefined) {
				cell.jelly.x = aux.exponentialEase(cell.jelly.x, cell.x, 2, dt);
				cell.jelly.y = aux.exponentialEase(cell.jelly.y, cell.y, 2, dt);
				cell.jelly.r = aux.exponentialEase(cell.jelly.r, cell.r, 5, dt);
			}
		}

		// clean up dead cells
		setInterval(() => {
			const now = performance.now();
			world.cells.forEach((cell, id) => {
				if (!cell.dead) return;
				if (now - cell.dead.at >= 120) {
					world.cells.delete(id);
				}
			});
		}, 100);



		// #2 : define others, like camera and borders
		world.camera = {
			x: 0, tx: 0,
			y: 0, ty: 0,
			scale: 1, tscale: 1,
		};

		/** @type {{ l: number, r: number, t: number, b: number } | undefined} */
		world.border = undefined;

		/** @type {{ name: string, me: boolean, sub: boolean, place: number | undefined }[]} */
		world.leaderboard = [];



		// #3 : define stats
		world.stats = {
			foodEaten: 0,
			highestPosition: 200,
			highestScore: 0,
			/** @type {number | undefined} */
			spawnedAt: undefined,
		};



		return world;
	})();



	//////////////////////////
	// Setup All Networking //
	//////////////////////////
	const net = (() => {
		const net = {};

		// #1 : define state
		/** @type {Symbol | undefined} */
		let connection = undefined;
		/** @type {{ shuffle: Map<number, number>, unshuffle: Map<number, number> } | undefined} */
		let handshake;
		/** @type {number | undefined} */
		let pendingPingFrom;
		let pingInterval;
		let reconnectAttempts = 0;
		/** @type {WebSocket} */
		let ws;

		/** -1 if ping reply took too long @type {number | undefined} */
		net.latency = undefined;
		net.ready = false;

		// #2 : connecting/reconnecting the websocket
		/** @type {HTMLSelectElement | null} */
		const gamemode = document.querySelector('#gamemode');
		if (!gamemode)
			console.warn('#gamemode element no longer exists, falling back to us-1');

		function connect() {
			let server = gamemode?.value ?? 'us0.sigmally.com/ws/';
			if (location.search.startsWith('?ip='))
				server = location.search.slice('?ip='.length); // in csrf we trust

			ws = new destructor.realWebSocket('wss://' + server);
			ws.binaryType = 'arraybuffer';
			ws.addEventListener('close', wsClose);
			ws.addEventListener('error', wsError);
			ws.addEventListener('message', wsMessage);
			ws.addEventListener('open', wsOpen);
		}

		function wsClose() {
			handshake = undefined;
			pendingPingFrom = undefined;
			if (pingInterval)
				clearInterval(pingInterval);

			connection = undefined;
			net.latency = undefined;
			net.ready = false;

			// hide/clear UI
			ui.stats.misc.textContent = '';
			world.leaderboard = [];
			ui.leaderboard.update();

			// clear world
			world.cells.clear(); // make sure we won't see overlapping IDs from new cells from the new connection
			while (world.mine.length) world.mine.pop();
			world.border = undefined;

			setTimeout(connect, 500 * Math.min(reconnectAttempts++ + 1, 10));
		}

		/** @param {Event} err */
		function wsError(err) {
			console.warn('WebSocket error:', err);
		}

		function wsOpen() {
			reconnectAttempts = 0;
			connection = Symbol();

			ui.chat.barrier();

			// reset camera location to the middle; this is implied but never sent by the server
			world.camera.x = world.camera.tx = 0;
			world.camera.y = world.camera.ty = 0;
			world.camera.scale = world.camera.tscale = 1;

			destructor.realWsSend.call(ws, new TextEncoder().encode('SIG 0.0.1\x00'));
		}

		// listen for when the gamemode changes
		gamemode?.addEventListener('change', () => {
			ws.close();
		});



		// #3 : set up auxiliary functions
		/**
		 * @param {DataView} dat
		 * @param {number} off
		 * @returns {[string, number]}
		 */
		function readZTString(dat, off) {
			const startOff = off;
			for (; off < dat.byteLength; ++off) {
				if (dat.getUint8(off) === 0) break;
			}

			return [new TextDecoder('utf-8').decode(dat.buffer.slice(startOff, off)), off + 1];
		}

		/**
		 * @param {number} opcode
		 * @param {object} data
		 */
		function sendJson(opcode, data) {
			if (!handshake) return;
			const dataBuf = new TextEncoder().encode(JSON.stringify(data));
			const buf = new ArrayBuffer(dataBuf.byteLength + 2);
			const dat = new DataView(buf);

			dat.setUint8(0, Number(handshake.shuffle.get(opcode)));
			for (let i = 0; i < dataBuf.byteLength; ++i) {
				dat.setUint8(1 + i, dataBuf[i]);
			}

			destructor.realWsSend.call(ws, buf);
		}

		function createPingLoop() {
			function ping() {
				if (!handshake) return; // shouldn't ever happen

				if (pendingPingFrom !== undefined) {
					// ping was not replied to, tell the player the ping text might be wonky for a bit
					net.latency = -1;
				}

				destructor.realWsSend.call(ws, new Uint8Array([ Number(handshake.shuffle.get(0xfe)) ]));
				pendingPingFrom = performance.now();
			}

			pingInterval = setInterval(ping, 2_000);
		}



		// #4 : set up message handler
		/** @param {MessageEvent} msg */
		function wsMessage(msg) {
			const dat = new DataView(msg.data);
			if (!handshake) {
				// unlikely to change as we're still on v0.0.1 but i'll check it anyway
				let [version, off] = readZTString(dat, 0);
				if (version !== 'SIG 0.0.1') {
					alert(`got unsupported version "${version}", expected "SIG 0.0.1"`);
					return ws.close();
				}

				handshake = { shuffle: new Map(), unshuffle: new Map() };
				for (let i = 0; i < 256; ++i) {
					const shuffled = dat.getUint8(off + i);
					handshake.shuffle.set(i, shuffled);
					handshake.unshuffle.set(shuffled, i);
				}

				createPingLoop();

				return;
			}

			const now = performance.now();
			let off = 1;
			switch (handshake.unshuffle.get(dat.getUint8(0))) {
				case 0x10: { // world update
					// #a : kills / consumes
					const killCount = dat.getUint16(off, true);
					off += 2;

					for (let i = 0; i < killCount; ++i) {
						const killerId = dat.getUint32(off, true);
						const killedId = dat.getUint32(off + 4, true);
						off += 8;

						const killer = world.cells.get(killerId);
						const killed = world.cells.get(killedId);
						if (killed) {
							killed.dead = { to: killer, at: now };
							killed.updated = now;

							if (killed.r <= 20 && world.mine.includes(killerId))
								++world.stats.foodEaten;

							const myIdx = world.mine.indexOf(killedId);
							if (myIdx !== -1)
								world.mine.splice(myIdx, 1);
						}
					}

					// #b : updates
					while (true) {
						const id = dat.getUint32(off, true);
						off += 4;
						if (id === 0) break;

						const x = dat.getInt16(off, true);
						const y = dat.getInt16(off + 2, true);
						const r = dat.getInt16(off + 4, true);
						const flags = dat.getUint8(off + 6);
						// (void 1 byte, "isUpdate")
						// (void 1 byte, "isPlayer")
						const sub = !!dat.getUint8(off + 9);
						off += 10;

						let clan; [clan, off] = readZTString(dat, off);

						/** @type {[number, number, number] | undefined} */
						let rgb;
						if (flags & 0x02) {
							// update color
							rgb = [dat.getUint8(off) / 255, dat.getUint8(off + 1) / 255, dat.getUint8(off + 2) / 255];
							off += 3;
						}

						let skin = '';
						if (flags & 0x04) {
							// update skin
							[skin, off] = readZTString(dat, off);
						}

						let name = '';
						if (flags & 0x08) {
							// update name
							[name, off] = readZTString(dat, off);
						}

						const jagged = !!(flags & 0x11);

						const cell = world.cells.get(id);
						if (cell && !cell.dead) {
							// update cell.x and cell.y, to prevent rubber banding effect when tabbing out for a bit
							world.move(cell, now, undefined);

							cell.ox = cell.x; cell.oy = cell.y; cell.or = cell.r;
							cell.nx = x; cell.ny = y; cell.nr = r; cell.sub = sub;
							cell.jagged = jagged;
							cell.updated = now;

							if (rgb) cell.rgb = rgb;
							if (skin) cell.skin = skin;
							if (name) cell.name = name;
						} else {
							if (r > 20 && !(flags & 0x20)) { // not pellet, not ejected
								({ name, skin } = aux.parseNameSkin(name, skin));
							}

							world.cells.set(id, {
								id,
								x, ox: x, nx: x,
								y, oy: y, ny: y,
								r, or: r, nr: r,
								rgb: rgb ?? [1, 1, 1],
								updated: now, born: now, dead: undefined,
								jagged,
								name, skin, sub,
								jelly: { x, y, r },
							});
						}
					}

					// #c : deletes
					const deleteCount = dat.getUint16(off, true);
					off += 2;

					for (let i = 0; i < deleteCount; ++i) {
						const deletedId = dat.getUint32(off, true);
						off += 4;

						const deleted = world.cells.get(deletedId);
						if (deleted)
							deleted.dead = { to: undefined, at: now };
					}

					break;
				}

				case 0x11: { // update camera pos
					world.camera.tx = dat.getFloat32(off, true);
					world.camera.ty = dat.getFloat32(off + 4, true);
					world.camera.tscale = dat.getFloat32(off + 8, true) * ui.game.viewportScale * input.zoom;
					break;
				}

				case 0x12: // delete all cells
					world.cells.forEach(cell => {
						cell.dead ??= { to: undefined, at: now };
					});
					// passthrough
				case 0x14: // delete my cells
					while (world.mine.length) world.mine.pop();
					break;

				case 0x20: { // new owned cell
					world.mine.push(dat.getUint32(off, true));
					if (world.mine.length === 1)
						world.stats.spawnedAt = now;
					break;
				}

				// case 0x30 is a text list (not a numbered list), leave unsupported
				case 0x31: { // ffa leaderboard list
					const lb = [];
					const count = dat.getUint32(off, true);
					off += 4;

					let myPosition;
					for (let i = 0; i < count; ++i) {
						const me = !!dat.getUint32(off, true);
						off += 4;

						let name;
						[name, off] = readZTString(dat, off);
						name = aux.parseNameSkin(name, '').name;

						// why this is copied into every leaderboard entry is beyond my understanding
						myPosition = dat.getUint32(off, true);
						const sub = !!dat.getUint32(off + 4, true);
						off += 8;

						lb.push({ name, sub, me, place: undefined });
					}

					if (myPosition) {
						if (myPosition - 1 >= lb.length) {
							/** @type {HTMLInputElement | null} */
							const inputName = document.querySelector('input#nick');
							lb.push({ name: aux.parseNameSkin(inputName?.value ?? '', '').name, sub: false, me: true, place: myPosition });
						}

						if (myPosition < world.stats.highestPosition)
							world.stats.highestPosition = myPosition;
					}

					world.leaderboard = lb;
					ui.leaderboard.update();
					break;
				}

				case 0x40: { // border update
					world.border = {
						l: dat.getFloat64(off, true),
						t: dat.getFloat64(off + 8, true),
						r: dat.getFloat64(off + 16, true),
						b: dat.getFloat64(off + 24, true),
					};
					break;
				}

				case 0x63: { // chat message
					const flags = dat.getUint8(off);
					const rgb = /** @type {[number, number, number]} */
						([dat.getUint8(off + 1) / 255, dat.getUint8(off + 2) / 255, dat.getUint8(off + 3) / 255]);
					off += 4;

					let name;
					[name, off] = readZTString(dat, off);
					let msg;
					[msg, off] = readZTString(dat, off);

					ui.chat.add(name, rgb, msg);
					break;
				}

				case 0xdd: {
					net.howarewelosingmoney();
					net.ready = true;
					break;
				}

				case 0xfe: { // server stats, response to a ping
					let statString;
					[statString, off] = readZTString(dat, off);

					const statData = JSON.parse(statString);
					ui.stats.update(statData);

					if (pendingPingFrom) {
						net.latency = now - pendingPingFrom;
						pendingPingFrom = undefined;
					}
					break;
				}
			}
		}



		// #5 : export input functions
		/**
		 * @param {number} x
		 * @param {number} y
		 */
		net.move = function(x, y) {
			if (!handshake) return;
			const buf = new ArrayBuffer(13);
			const dat = new DataView(buf);

			dat.setUint8(0, Number(handshake.shuffle.get(0x10)));
			dat.setInt32(1, x, true);
			dat.setInt32(5, y, true);

			destructor.realWsSend.call(ws, buf);
		}

		net.w = function() {
			if (!handshake) return;
			destructor.realWsSend.call(ws, new Uint8Array([ Number(handshake.shuffle.get(21)) ]));
		}

		net.qdown = function() {
			if (!handshake) return;
			destructor.realWsSend.call(ws, new Uint8Array([ Number(handshake.shuffle.get(18)) ]));
		}

		net.qup = function() {
			if (!handshake) return;
			destructor.realWsSend.call(ws, new Uint8Array([ Number(handshake.shuffle.get(19)) ]));
		}

		net.split = function() {
			if (!handshake) return;
			destructor.realWsSend.call(ws, new Uint8Array([ Number(handshake.shuffle.get(17)) ]));
		}

		/**
		 * @param {string} msg
		 */
		net.chat = function(msg) {
			if (!handshake) return;
			const msgBuf = new TextEncoder().encode(msg);

			const buf = new ArrayBuffer(msgBuf.byteLength + 3);
			const dat = new DataView(buf);

			dat.setUint8(0, Number(handshake.shuffle.get(0x63)));
			// skip byte #1, seems to require authentication + not implemented anyway
			for (let i = 0; i < msgBuf.byteLength; ++i)
				dat.setUint8(2 + i, msgBuf[i]);

			destructor.realWsSend.call(ws, buf);
		}

		/**
		 * @param {string | undefined} v2
		 * @param {string | undefined} v3
		 */
		net.captcha = function(v2, v3) {
			sendJson(0xdc, { recaptchaV2Token: v2, recaptchaV3Token: v3 });
		}

		/**
		 * @param {{ name: string, skin: string, [x: string]: any }} data
		 */
		net.play = function(data) {
			sendJson(0x00, data);
		}

		net.howarewelosingmoney = function() {
			if (!handshake) return;
			// this is a new thing added with the rest of the recent source code obfuscation (2024/02/18)
			// which collects and links to your sigmally account, seemingly just for light data analysis but probably
			// just for the fun of it:
			// - your IP and country
			// - whether you are under a proxy
			// - whether you are using sigmod (because it also blocks ads)
			// - whether you are using a traditional adblocker
			//
			// so, no thank you
			sendJson(0xd0, { ip: '', country: '', proxy: false, user: null, blocker: 'sigmally fixes @8y8x' });
		}

		net.connection = function() {
			if (!ws) return undefined;
			if (ws.readyState !== WebSocket.OPEN) return undefined;
			if (!handshake) return undefined;
			return connection;
		}



		connect();
		return net;
	})();



	//////////////////////////
	// Setup Input Handlers //
	//////////////////////////
	const input = (() => {
		const input = {};

		// #1 : general inputs
		// sigmod's macro feed runs at a slower interval but presses many times in that interval. allowing queueing 2 w
		// presses makes it faster (it would be better if people disabled that macro, but no one would do that)
		let forceW = 0;
		let mouseX = 960;
		let mouseY = 540;
		let w = false;

		input.zoom = 1;

		function mouse() {
			net.move(
				world.camera.x + (mouseX - innerWidth / 2) / ui.game.viewportScale / world.camera.scale,
				world.camera.y + (mouseY - innerHeight / 2) / ui.game.viewportScale / world.camera.scale,
			);
		}

		function unfocused() {
			return ui.escOverlayVisible() || document.activeElement?.tagName === 'INPUT';
		}

		setInterval(() => {
			mouse();
			if (forceW) {
				--forceW;
				net.w();
			} else if (w) net.w();
		}, 40);

		addEventListener('mousemove', e => {
			if (ui.escOverlayVisible()) return;
			mouseX = e.clientX;
			mouseY = e.clientY;
		});

		addEventListener('wheel', e => {
			if (unfocused()) return;
			input.zoom *= 0.8 ** (e.deltaY / 100);
			input.zoom = Math.min(Math.max(input.zoom, 0.1), 10);
		});

		addEventListener('keydown', e => {
			if (unfocused()) {
				if (document.activeElement === ui.chat.input) {
					if (e.code === 'Enter' && ui.chat.input.value.length > 0) {
						net.chat(ui.chat.input.value.slice(0, 15));
						ui.chat.input.value = '';
						ui.chat.input.blur();
					} else if (e.code === 'Escape') {
						ui.chat.input.blur();
					}
				}

				return;
			}

			switch (e.code) {
				case 'KeyQ':
					if (!e.repeat)
						net.qdown();
					break;
				case 'KeyW':
					w = true;
					forceW = Math.min(forceW + 1, 2);
					break;
				case 'Space': {
					if (!e.repeat) {
						// send immediately, otherwise tabbing out would slow down setInterval and cause late splits
						mouse();
						net.split();
					}
					break;
				}
				case 'Enter': {
					ui.chat.input.focus();
					break;
				}
				case 'Escape': {
					ui.toggleEscOverlay();
					break;
				}
			}

			if (e.ctrlKey && e.code === 'KeyW') {
				// prevent ctrl+w (only when in fullscreen!) - helps when multiboxing
				e.preventDefault();
			} else if (e.ctrlKey && e.code === 'Tab') {
				e.returnValue = true; // undo e.preventDefault() by SigMod
				e.stopImmediatePropagation(); // prevent SigMod from calling e.preventDefault() afterwards
			} else if (e.code === 'Tab') {
				// prevent tabbing to a UI element, which then lets you press ctrl+w
				e.preventDefault();
			}
		});

		addEventListener('keyup', e => {
			// do not check if unfocused

			if (e.code === 'KeyQ')
				net.qup();
			else if (e.code === 'KeyW')
				w = false;
		});

		// when switching tabs, make sure W is not being held
		addEventListener('blur', () => {
			w = false;
		});

		addEventListener('beforeunload', e => {
			e.preventDefault();
		});

		// prevent right clicking on the game
		ui.game.canvas.addEventListener('contextmenu', e => e.preventDefault());

		// prevent dragging when some things are selected - i have a habit of unconsciously clicking all the time,
		// making me regularly drag text, disabling my mouse inputs for a bit
		addEventListener('dragstart', e => e.preventDefault());



		// #2 : play and spectate buttons, and captcha
		/** @param {boolean} spectating */
		function playData(spectating) {
			/** @type {HTMLInputElement | null} */
			const nickElement = document.querySelector('input#nick');

			// settings are immediately updated when you change your skin, so we can just pull from that
			/** @type {object | undefined} */
			let settings;
			try {
				settings = JSON.parse(localStorage.getItem('settings') ?? '');
			} catch (_) {}

			let clan;
			let sub = false;
			let token;
			if (settings?.userData) {
				clan = settings.userData.clan;
				sub = (settings.userData.subscription ?? 0) > Date.now();
				token = settings.userData.token;
			}

			return {
				state: spectating ? 2 : undefined,
				name: nickElement?.value ?? '',
				skin: settings?.skin,
				token,
				sub,
				clan,
				showClanmates: true, // dev comment suggests this will be a checkbox in the future
			}
		}

		/** @type {HTMLButtonElement | null} */
		const play = document.querySelector('button#play-btn');
		/** @type {HTMLButtonElement | null} */
		const spectate = document.querySelector('button#spectate-btn');
		if (!play || !spectate) throw new Error('Can\'t find play or spectate button');
		play.disabled = spectate.disabled = true;

		(async () => {
			let grecaptcha, CAPTCHA2;
			do {
				grecaptcha = /** @type {any} */ (window).grecaptcha;
				CAPTCHA2 = /** @type {any} */ (window).CAPTCHA2;

				await aux.wait(100);
			} while (!grecaptcha || !CAPTCHA2);

			// prevent old play and spectate handlers from working
			/** @type {any} */ (window).grecaptcha = new Proxy(grecaptcha, {
				get: (_target, prop, _receiver) => {
					if (prop === 'execute') return () => new Promise(_ => {});
				},
			});

			const container = document.createElement('div');
			container.id = 'g-recaptcha';
			play.parentNode?.insertBefore(container, play);

			let handle;
			/** @type {string | undefined} */
			let v2;
			let v2Pending = false;
			/** @type {Symbol | undefined} */
			let v2LastConnection = undefined;
			const getV2 = function getV2() {
				if (v2Pending) return;
				v2Pending = true;

				if (handle !== undefined) {
					container.style.display = 'block';
					grecaptcha.reset(handle);
				} else {
					handle = grecaptcha.render('g-recaptcha', {
						sitekey: CAPTCHA2,
						/** @param {string} token */
						callback: token => {
							v2 = token;
							v2Pending = false;
							container.style.display = 'none';
						}
					});
				}
			}
			getV2();

			setInterval(() => {
				const con = net.connection();
				if (v2 && con && v2LastConnection !== con) {
					net.captcha(v2, undefined);
					v2 = undefined;
					v2LastConnection = con;
				}

				if (!v2 && !v2Pending && v2LastConnection !== net.connection())
					getV2();

				play.disabled = spectate.disabled = v2LastConnection !== net.connection() || v2Pending;
			}, 100);

			/** @param {MouseEvent} e */
			async function clickHandler(e) {
				if (v2LastConnection !== net.connection() || v2Pending) return;
				ui.toggleEscOverlay(false);
				net.play(playData(e.currentTarget === spectate));
			}

			play.addEventListener('click', clickHandler);
			spectate.addEventListener('click', clickHandler);
		})();

		return input;
	})();



	//////////////////////////////
	// Configure WebGL Programs //
	//////////////////////////////
	const { programs, uniforms } = (() => {
		// #1 : init webgl context, define helper functions
		const gl = ui.game.gl;
		gl.enable(gl.BLEND);
		gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

		/**
		 * @param {string} name
		 * @param {WebGLShader} vShader
		 * @param {WebGLShader} fShader
		 */
		function program(name, vShader, fShader) {
			const p = gl.createProgram();
			if (!p)
				throw new Error('GL program is null'); // highly doubt will ever happen in practice

			gl.attachShader(p, vShader);
			gl.attachShader(p, fShader);
			gl.linkProgram(p);

			// note: linking errors should not happen in production
			if (!gl.getProgramParameter(p, gl.LINK_STATUS))
				throw new Error(`failed to link program ${name}:\n${gl.getProgramInfoLog(p)}`);

			return p;
		}

		/**
		 * @param {string} name
		 * @param {number} type
		 * @param {string} source
		 */
		function shader(name, type, source) {
			const s = gl.createShader(type);
			if (!s)
				throw new Error('GL shader is null'); // highly doubt will ever happen in practice

			gl.shaderSource(s, source);
			gl.compileShader(s);

			// note: compilation errors should not happen in production
			if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
				throw new Error(`failed to compile shader ${name}:\n${gl.getShaderInfoLog(s)}`);

			return s;
		}

		/**
		 * @template {string} T
		 * @param {string} programName
		 * @param {WebGLProgram} program
		 * @param {T[]} names
		 * @returns {{ [x in T]: WebGLUniformLocation }}
		 */
		function getUniforms(programName, program, names) {
			/** @type {{ [x in T]?: WebGLUniformLocation }} */
			const uniforms = {};
			names.forEach(name => {
				const loc = gl.getUniformLocation(program, name);
				if (!loc)
					throw new Error(`uniform ${name} in ${programName} not found`);

				uniforms[name] = loc;
			});

			return /** @type {any} */ (uniforms);
		}



		// #2 : create programs
		const programs = {};
		const uniforms = {};

		programs.bg = program(
			'bg',
			shader('bg.vShader', gl.VERTEX_SHADER, `#version 300 es
			layout(location = 0) in vec2 a_pos;

			uniform float u_aspect_ratio;
			uniform vec2 u_camera_pos;
			uniform float u_camera_scale;

			out vec2 v_world_pos;

			void main() {
				gl_Position = vec4(a_pos, 0, 1);

				v_world_pos = a_pos * vec2(u_aspect_ratio, 1.0) / u_camera_scale;
				v_world_pos += u_camera_pos * vec2(1.0, -1.0);
			}
			`),
			shader('bg.fShader', gl.FRAGMENT_SHADER, `#version 300 es
			precision highp float;
			in vec2 v_world_pos;

			uniform float u_camera_scale;

			uniform vec4 u_border_color;
			uniform float[4] u_border_lrtb;
			uniform bool u_dark_theme_enabled;
			uniform bool u_grid_enabled;
			uniform sampler2D u_texture;

			out vec4 out_color;

			void main() {
				if (u_grid_enabled) {
					vec2 t_coord = v_world_pos / 50.0;
					out_color = texture(u_texture, t_coord);
					// fade grid pixels so when they become <1px wide, they're invisible
					float alpha = clamp(u_camera_scale * 540.0 * 50.0, 0.0, 1.0) * 0.1;
					if (u_dark_theme_enabled) {
						out_color *= vec4(1, 1, 1, alpha);
					} else {
						out_color *= vec4(0, 0, 0, alpha);
					}
				}

				// force border to always be visible, otherwise it flickers
				float thickness = max(3.0 / (u_camera_scale * 540.0), 25.0);

				// make a larger inner rectangle and a normal inverted outer rectangle
				float inner_alpha = min(
					min((v_world_pos.x + thickness) - u_border_lrtb[0], u_border_lrtb[1] - (v_world_pos.x - thickness)),
					min((v_world_pos.y + thickness) - u_border_lrtb[2], u_border_lrtb[3] - (v_world_pos.y - thickness))
				);
				float outer_alpha = max(
					max(u_border_lrtb[0] - v_world_pos.x, v_world_pos.x - u_border_lrtb[1]),
					max(u_border_lrtb[2] - v_world_pos.y, v_world_pos.y - u_border_lrtb[3])
				);
				float alpha = clamp(min(inner_alpha, outer_alpha), 0.0, 1.0);

				out_color = out_color * (1.0 - alpha) + u_border_color * alpha;
			}
			`),
		);
		uniforms.bg = getUniforms('bg', programs.bg, [
			'u_aspect_ratio', 'u_camera_pos', 'u_camera_scale',
			'u_border_color', 'u_border_lrtb', 'u_dark_theme_enabled', 'u_grid_enabled',
		]);



		programs.cell = program(
			'cell',
			shader('cell.vShader', gl.VERTEX_SHADER, `#version 300 es
			layout(location = 0) in vec2 a_pos;

			uniform float u_aspect_ratio;
			uniform vec2 u_camera_pos;
			uniform float u_camera_scale;

			uniform float u_inner_radius;
			uniform float u_outer_radius;
			uniform vec2 u_pos;

			out vec2 v_pos;
			out vec2 v_t_coord;

			void main() {
				v_pos = a_pos;
				v_t_coord = a_pos / (u_inner_radius / u_outer_radius) * 0.5 + 0.5;

				vec2 clip_pos = -u_camera_pos + u_pos + a_pos * u_outer_radius;
				clip_pos *= u_camera_scale * vec2(1.0 / u_aspect_ratio, -1.0);
				gl_Position = vec4(clip_pos, 0, 1);
			}
			`),
			shader('cell.fShader', gl.FRAGMENT_SHADER, `#version 300 es
			precision highp float;
			in vec2 v_pos;
			in vec2 v_t_coord;

			uniform float u_camera_scale;

			uniform float u_alpha;
			uniform vec4 u_color;
			uniform float u_outer_radius;
			uniform vec4 u_outline_color;
			uniform bool u_outline_thick;
			uniform sampler2D u_texture;
			uniform bool u_texture_enabled;

			out vec4 out_color;

			void main() {
				float blur = 0.5 * u_outer_radius * (540.0 * u_camera_scale);
				float d2 = v_pos.x * v_pos.x + v_pos.y * v_pos.y;
				float a = clamp(-blur * (d2 - 1.0), 0.0, 1.0);

				if (u_texture_enabled) {
					out_color = texture(u_texture, v_t_coord);
				}
				out_color = vec4(out_color.rgb, 1) * out_color.a + u_color * (1.0 - out_color.a);

				// outline
				// d > 0.98   => d2 > 0.9604 (default)
				// d > 0.96   => d2 > 0.9216 (thick)
				float outline_d = u_outline_thick ? 0.9216 : 0.9604;
				float oa = clamp(blur * (d2 - outline_d), 0.0, 1.0);
				out_color = out_color * (1.0 - oa) + u_outline_color * oa;

				out_color.a *= a * u_alpha;
			}
			`),
		);
		uniforms.cell = getUniforms('cell', programs.cell, [
			'u_aspect_ratio', 'u_camera_pos', 'u_camera_scale',
			'u_alpha', 'u_color', 'u_outline_color', 'u_outline_thick', 'u_inner_radius', 'u_outer_radius', 'u_pos',
			'u_texture_enabled',
		]);



		programs.text = program(
			'text',
			shader('text.vShader', gl.VERTEX_SHADER, `#version 300 es
			layout(location = 0) in vec2 a_pos;

			uniform float u_aspect_ratio;
			uniform vec2 u_camera_pos;
			uniform float u_camera_scale;

			uniform vec2 u_pos;
			uniform float u_radius;
			uniform bool u_subtext_enabled;
			uniform float u_text_aspect_ratio;

			out vec2 v_pos;

			void main() {
				v_pos = a_pos;

				vec2 clip_space;
				if (u_subtext_enabled) {
					clip_space = a_pos * 0.5 + vec2(0, 0.5);
				} else {
					clip_space = a_pos + vec2(0, -0.25);
				}

				clip_space *= u_radius * 0.3 * vec2(u_text_aspect_ratio, 1.0);
				clip_space += -u_camera_pos + u_pos;
				clip_space *= u_camera_scale * vec2(1.0 / u_aspect_ratio, -1.0);
				gl_Position = vec4(clip_space, 0, 1);
			}
			`),
			shader('text.fShader', gl.FRAGMENT_SHADER, `#version 300 es
			precision highp float;
			in vec2 v_pos;

			uniform float u_alpha;
			uniform vec3 u_color1;
			uniform vec3 u_color2;
			uniform bool u_silhouette_enabled;

			uniform sampler2D u_texture;
			uniform sampler2D u_silhouette;

			out vec4 out_color;

			void main() {
				vec2 t_coord = v_pos * 0.5 + 0.5;

				float c2_alpha = (t_coord.x + t_coord.y) / 2.0;
				vec4 color = vec4(u_color1 * (1.0 - c2_alpha) + u_color2 * c2_alpha, 1);
				vec4 normal = texture(u_texture, t_coord);

				if (u_silhouette_enabled) {
					vec4 silhouette = texture(u_silhouette, t_coord);

					// #fff - #000 => color (text)
					// #fff - #fff => #fff (respect emoji)
					// #888 - #888 => #888 (respect emoji)
					// #fff - #888 => #888 + color/2 (blur/antialias)
					out_color = silhouette + (normal - silhouette) * color;
				} else {
					out_color = normal * color;
				}

				out_color.a *= u_alpha;
			}
			`),
		);
		uniforms.text = getUniforms('text', programs.text, [
			'u_aspect_ratio', 'u_camera_pos', 'u_camera_scale',
			'u_alpha', 'u_color1', 'u_color2', 'u_pos', 'u_radius', 'u_silhouette', 'u_silhouette_enabled',
			'u_subtext_enabled', 'u_text_aspect_ratio', 'u_texture',
		]);



		return { programs, uniforms };
	})();



	///////////////////////////////
	// Define Rendering Routines //
	///////////////////////////////
	(() => {
		const gl = ui.game.gl;

		// #1 : define small misc objects
		const square = gl.createBuffer();
		gl.bindBuffer(gl.ARRAY_BUFFER, square);
		gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);

		const vao = gl.createVertexArray();
		gl.bindVertexArray(vao);
		gl.enableVertexAttribArray(0);
		gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

		const gridSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAAXNSR0IArs4c6QAAAKVJREFUaEPtkkEKwlAUxBzw/jeWL4J4gECkSrqftC/pzjnn9gfP3ofcf/mWbY8OuVLBilypxutbKlIRyUC/liQWYyuC1UnDikhiMbYiWJ00rIgkFmMrgtVJw4pIYjG2IlidNKyIJBZjK4LVScOKSGIxtiJYnTSsiCQWYyuC1UnDikhiMbYiWJ00rIgkFmMrgtVJw4pIYjG2IlidNPwU2TbpHV/DPgFxJfgvliP9RQAAAABJRU5ErkJggg==';
		const virusSrc = '/assets/images/viruses/2.png';



		// #2 : define helper functions
		const textureFromCache = (() => {
			/** @type {Map<string, WebGLTexture | null>} */
			const cache = new Map();

			/**
			 * @param {string} src
			 */
			return src => {
				const cached = cache.get(src);
				if (cached !== undefined)
					return cached ?? undefined;

				cache.set(src, null);

				const image = new Image();
				image.addEventListener('load', () => {
					const texture = gl.createTexture();
					gl.bindTexture(gl.TEXTURE_2D, texture);
					gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
					gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
					gl.generateMipmap(gl.TEXTURE_2D);
					cache.set(src, texture);
				});
				image.src = src;

				return undefined;
			};
		})();

		const textFromCache = (() => {
			/**
			 * @template {boolean} T
			 * @typedef {{ aspectRatio: number, text: WebGLTexture,
				silhouette: T extends true ? WebGLTexture : WebGLTexture | undefined,
			  accessed: number }} CacheEntry
			 */
			/** @type {Map<string, CacheEntry<boolean>>} */
			const cache = new Map();

			setInterval(() => {
				// remove text after not being used for 10 minutes
				const now = performance.now();
				cache.forEach((entry, text) => {
					if (entry.accessed - now > 600_000) {
						// immediately delete text instead of waiting for GC
						gl.deleteTexture(entry.text);
						if (entry.silhouette) gl.deleteTexture(entry.silhouette);
						cache.delete(text);
					}
				});
			}, 60_000);

			const canvas = document.createElement('canvas');
			const ctx = canvas.getContext('2d', { willReadFrequently: true });
			if (!ctx) throw new Error('canvas.getContext(\'2d\') yields null, for whatever reason');
			const textSize = 72;

			// declare a little awkwardly, after ctx is definitely not null
			/**
			 * @param {string} text
			 * @param {boolean} silhouette
			 */
			const texture = function texture(text, silhouette) {
				const lineWidth = Math.ceil(textSize / 10);

				ctx.font = textSize + 'px Ubuntu';
				canvas.width = ctx.measureText(text).width + lineWidth * 2;
				ctx.clearRect(0, 0, canvas.width, canvas.height);

				// setting canvas.width resets the canvas state
				ctx.font = textSize + 'px Ubuntu';
				ctx.lineWidth = lineWidth;
				ctx.fillStyle = silhouette ? '#000' : '#fff';
				ctx.strokeStyle = '#000';

				ctx.strokeText(text, lineWidth, textSize * 1.5);
				ctx.fillText(text, lineWidth, textSize * 1.5);

				const data = ctx.getImageData(0, 0, canvas.width, canvas.height);

				const texture = gl.createTexture();
				if (!texture) throw new Error('gl.createTexture() yields null');
				gl.bindTexture(gl.TEXTURE_2D, texture);
				gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
				gl.generateMipmap(gl.TEXTURE_2D);
				return texture;
			};

			/**
			 * @template {boolean} T
			 * @param {string} text
			 * @param {T} silhouette
			 * @returns {CacheEntry<T>}
			 */
			return (text, silhouette) => {
				let entry = cache.get(text);
				if (!entry) {
					entry = {
						text: texture(text, false),
						aspectRatio: canvas.width / canvas.height, // mind the execution order
						silhouette: silhouette ? texture(text, true) : undefined,
						accessed: performance.now(),
					};
					cache.set(text, entry);
				} else {
					entry.accessed = performance.now();
				}

				if (silhouette && !entry.silhouette) {
					entry.silhouette = texture(text, true);
				}

				return entry;
			};
		})();



		// #3 : define the render function
		let fps = 0;
		let lastFrame = performance.now();
		function render() {
			const now = performance.now();
			const dt = (now - lastFrame) / 1000;
			fps += (1 / dt - fps) / 10;
			lastFrame = now;

			// note: most routines are named, for benchmarking purposes
			(function setGlobalUniforms() {
				const aspectRatio = ui.game.canvas.width / ui.game.canvas.height;
				const cameraPosX = world.camera.x;
				const cameraPosY = world.camera.y;
				const cameraScale = world.camera.scale / 540; // (height of 1920x1080 / 2 = 540)

				gl.useProgram(programs.bg);
				gl.uniform1f(uniforms.bg.u_aspect_ratio, aspectRatio);
				gl.uniform2f(uniforms.bg.u_camera_pos, cameraPosX, cameraPosY);
				gl.uniform1f(uniforms.bg.u_camera_scale, cameraScale);

				gl.useProgram(programs.cell);
				gl.uniform1f(uniforms.cell.u_aspect_ratio, aspectRatio);
				gl.uniform2f(uniforms.cell.u_camera_pos, cameraPosX, cameraPosY);
				gl.uniform1f(uniforms.cell.u_camera_scale, cameraScale);

				gl.useProgram(programs.text);
				gl.uniform1f(uniforms.text.u_aspect_ratio, aspectRatio);
				gl.uniform2f(uniforms.text.u_camera_pos, cameraPosX, cameraPosY);
				gl.uniform1f(uniforms.text.u_camera_scale, cameraScale);
				gl.uniform1i(uniforms.text.u_texture, 0);
				gl.uniform1i(uniforms.text.u_silhouette, 1);
			})();

			/** @type {HTMLInputElement | null} */
			const darkTheme = document.querySelector('input#darkTheme');

			(function background() {
				/** @type {HTMLInputElement | null} */
				const showBorder = document.querySelector('input#showBorder');
				/** @type {HTMLInputElement | null} */
				const showGrid = document.querySelector('input#showGrid');

				if (!darkTheme || darkTheme.checked) {
					gl.clearColor(0x11 / 255, 0x11 / 255, 0x11 / 255, 1); // #111
				} else {
					gl.clearColor(0xf2 / 255, 0xfb / 255, 0xff / 255, 1); // #f2fbff
				}
				gl.clear(gl.COLOR_BUFFER_BIT);

				const gridTexture = textureFromCache(gridSrc);
				if (!gridTexture) return;
				gl.bindTexture(gl.TEXTURE_2D, gridTexture);

				gl.useProgram(programs.bg);

				if (showBorder?.checked && world.border) {
					gl.uniform4f(uniforms.bg.u_border_color, 0, 0, 1, 1); // #00f
					gl.uniform1fv(uniforms.bg.u_border_lrtb,
						new Float32Array([ world.border.l, world.border.r, world.border.t, world.border.b ]));
				} else {
					gl.uniform4f(uniforms.bg.u_border_color, 0, 0, 0, 0); // transparent
					gl.uniform1fv(uniforms.bg.u_border_lrtb, new Float32Array([ 0, 0, 0, 0 ]));
				}

				gl.uniform1i(uniforms.bg.u_dark_theme_enabled, Number(!darkTheme || darkTheme.checked));
				gl.uniform1i(uniforms.bg.u_grid_enabled, Number(!showGrid || showGrid.checked));

				gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
			})();

			(function updateCells() {
				world.cells.forEach(cell => world.move(cell, now, dt));
			})();

			(function moveCamera() {
				let avgX = 0;
				let avgY = 0;
				let totalR = 0;
				let totalCells = 0;

				world.mine.forEach(id => {
					const cell = world.cells.get(id);
					if (!cell || cell.dead) return;

					avgX += cell.x;
					avgY += cell.y;
					totalR += cell.r;
					++totalCells;
				});

				avgX /= totalCells;
				avgY /= totalCells;

				let xyEaseFactor;
				if (totalCells > 0) {
					world.camera.tx = avgX;
					world.camera.ty = avgY;
					world.camera.tscale = Math.min(64 / totalR) ** 0.4 * input.zoom;

					xyEaseFactor = 2;
				} else {
					xyEaseFactor = 20;
				}

				world.camera.x = aux.exponentialEase(world.camera.x, world.camera.tx, xyEaseFactor, dt);
				world.camera.y = aux.exponentialEase(world.camera.y, world.camera.ty, xyEaseFactor, dt);
				world.camera.scale = aux.exponentialEase(world.camera.scale, world.camera.tscale, 9, dt);
			})();

			/** @type {HTMLInputElement | null} */
			const jellyPhysicsElement = document.querySelector('input#jellyPhysics');
			const jellyPhysics = jellyPhysicsElement?.checked;

			/** @type {HTMLInputElement | null} */
			const showNamesElement = document.querySelector('input#showNames');
			const showNames = !showNamesElement || showNamesElement.checked;

			(function cells() {
				const showMass = (/** @type {HTMLInputElement | null} */ (document.querySelector('input#showMass')))?.checked;

				/** @param {Cell} cell */
				function calcAlpha(cell) {
					let alpha = Math.min((now - cell.born) / 120, 1);
					if (cell.dead)
						alpha = Math.min(alpha, Math.max(1 - (now - cell.dead.at) / 120, 0));

					return alpha;
				}

				// for white cell outlines
				let nextCellIdx = world.mine.length;
				const canSplit = world.mine.map(id => {
					const cell = world.cells.get(id);
					if (!cell) {
						--nextCellIdx;
						return false;
					}

					if (cell.nr < 128)
						return false;

					return nextCellIdx++ < 16;
				});

				/**
				 * @param {Cell} cell
				 * @param {number} alpha
				 */
				function drawCell(cell, alpha) {
					gl.useProgram(programs.cell);

					gl.uniform1f(uniforms.cell.u_alpha, alpha);

					if (jellyPhysics) {
						gl.uniform2f(uniforms.cell.u_pos, cell.jelly.x, cell.jelly.y);
						gl.uniform1f(uniforms.cell.u_inner_radius, cell.r);
						gl.uniform1f(uniforms.cell.u_outer_radius, cell.jelly.r);
					} else {
						gl.uniform2f(uniforms.cell.u_pos, cell.x, cell.y);
						gl.uniform1f(uniforms.cell.u_inner_radius, cell.r);
						gl.uniform1f(uniforms.cell.u_outer_radius, cell.r);
					}

					if (cell.jagged) {
						const virusTexture = textureFromCache(virusSrc);
						if (!virusTexture)
							return;

						gl.uniform4f(uniforms.cell.u_color, 0, 0, 0, 0);
						gl.uniform4f(uniforms.cell.u_outline_color, 0, 0, 0, 0);
						gl.uniform1i(uniforms.cell.u_outline_thick, 0);
						gl.uniform1i(uniforms.cell.u_texture_enabled, 1);
						gl.bindTexture(gl.TEXTURE_2D, virusTexture);

						gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
						return;
					}

					const myIndex = world.mine.indexOf(cell.id);
					gl.uniform4f(uniforms.cell.u_color, ...cell.rgb, 1);
					if (myIndex === -1 || canSplit[myIndex]) {
						gl.uniform4f(uniforms.cell.u_outline_color,
							cell.rgb[0] * 0.9, cell.rgb[1] * 0.9, cell.rgb[2] * 0.9, 1);
						gl.uniform1i(uniforms.cell.u_outline_thick, 0);
					} else {
						gl.uniform1i(uniforms.cell.u_outline_thick, 1);
						if (!darkTheme || darkTheme.checked)
							gl.uniform4f(uniforms.cell.u_outline_color, 1, 1, 1, 1);
						else
							gl.uniform4f(uniforms.cell.u_outline_color, 0, 0, 0, 1);
					}

					gl.uniform1i(uniforms.cell.u_texture_enabled, 0);
					if (cell.skin) {
						const texture = textureFromCache(cell.skin);
						if (texture) {
							gl.uniform1i(uniforms.cell.u_texture_enabled, 1);
							gl.bindTexture(gl.TEXTURE_2D, texture);
						}
					}

					gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
				}

				/**
				 * @param {Cell} cell
				 * @param {number} alpha
				 */
				function drawText(cell, alpha) {
					const showThisName = showNames && cell.r > 20 && cell.name;
					const showThisMass = showMass && cell.r > 75;
					if (!showThisName && !showThisMass) return;

					gl.useProgram(programs.text);

					gl.uniform1f(uniforms.text.u_alpha, alpha);
					if (jellyPhysics)
						gl.uniform2f(uniforms.text.u_pos, cell.jelly.x, cell.jelly.y);
					else
						gl.uniform2f(uniforms.text.u_pos, cell.x, cell.y);
					gl.uniform1f(uniforms.text.u_radius, cell.r);

					if (cell.sub) {
						gl.uniform3f(uniforms.text.u_color1, 0xeb / 255, 0x95 / 255, 0x00 / 255); // #eb9500
						gl.uniform3f(uniforms.text.u_color2, 0xe4 / 255, 0xb1 / 255, 0x10 / 255); // #e4b110
					} else {
						gl.uniform3f(uniforms.text.u_color1, 1, 1, 1);
						gl.uniform3f(uniforms.text.u_color2, 1, 1, 1);
					}

					if (showThisName) {
						const { aspectRatio, text, silhouette } = textFromCache(cell.name, cell.sub);
						gl.uniform1f(uniforms.text.u_text_aspect_ratio, aspectRatio);
						gl.uniform1i(uniforms.text.u_silhouette_enabled, silhouette ? 1 : 0);
						gl.uniform1i(uniforms.text.u_subtext_enabled, 0);

						gl.bindTexture(gl.TEXTURE_2D, text);
						if (silhouette) {
							gl.activeTexture(gl.TEXTURE1);
							gl.bindTexture(gl.TEXTURE_2D, silhouette);
							gl.activeTexture(gl.TEXTURE0);
						}

						gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
					}

					if (showThisMass) {
						// use nr (not interpolated), so we only get ~2500 unique mass texts (up to 62500 mass)
						// keep in mind cells go past 62500 for one frame before autosplitting, so not totally foolproof
						const mass = Math.floor(cell.nr * cell.nr / 100).toString();
						const { aspectRatio, text } = textFromCache(mass, false);
						gl.uniform1f(uniforms.text.u_text_aspect_ratio, aspectRatio);
						gl.uniform1i(uniforms.text.u_silhouette_enabled, 0);
						gl.uniform1i(uniforms.text.u_subtext_enabled, 1);

						gl.bindTexture(gl.TEXTURE_2D, text);
						gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
					}
				}

				/** @type {Cell[]} */
				const sorted = [];
				world.cells.forEach(cell => {
					if (cell.r > 20) {
						// not a pellet, will draw sorted later
						sorted.push(cell);
					}

					const alpha = calcAlpha(cell);
					drawCell(cell, alpha);
				});

				sorted.sort((a, b) => a.r - b.r);

				sorted.forEach(cell => {
					const alpha = calcAlpha(cell);
					drawCell(cell, alpha);
					if (!cell.jagged)
						drawText(cell, alpha);
				});
			})();

			(function updateStats() {
				ui.stats.matchTheme(); // not sure how to listen to when the checkbox changes when the game loads
				if (showNames && world.leaderboard.length > 0)
					ui.leaderboard.container.style.display = '';
				else
					ui.leaderboard.container.style.display = 'none';

				let score = 0;
				world.mine.forEach(id => {
					const cell = world.cells.get(id);
					if (!cell || cell.dead) return;

					score += cell.nr * cell.nr / 100;
				});

				if (score > 0)
					ui.stats.score.textContent = 'Score: ' + Math.floor(score);
				else
					ui.stats.score.textContent = '';
				ui.stats.measures.textContent = `${Math.floor(fps)} FPS `
					+ (net.latency ? Math.floor(net.latency) + 'ms ping' : '');

				if (score > world.stats.highestScore) {
					world.stats.highestScore = score;
				}

				if (world.mine.length === 0 && world.stats.spawnedAt !== undefined) {
					ui.deathScreen.show(world.stats);
				}
			})();

			(function minimap() {
				const { border } = world;
				if (!border) return;

				// text needs to be small and sharp, i don't trust webgl with that
				/** @type {HTMLInputElement | null} */
				const showMinimap = document.querySelector('input#showMinimap');
				if (showMinimap && !showMinimap.checked) {
					ui.minimap.canvas.style.display = 'none';
					return;
				} else {
					ui.minimap.canvas.style.display = '';
				}

				const { canvas, ctx } = ui.minimap;
				canvas.width = canvas.height = 200 * devicePixelRatio;
				ctx.clearRect(0, 0, canvas.width, canvas.height);

				const gameWidth = (border.r - border.l);
				const gameHeight = (border.b - border.t);

				// highlight current section
				ctx.fillStyle = '#ff0';
				ctx.globalAlpha = 0.3;

				const sectionX = Math.floor((world.camera.x - border.l) / gameWidth * 5);
				const sectionY = Math.floor((world.camera.y - border.t) / gameHeight * 5);
				const sectorSize = canvas.width / 5;
				ctx.fillRect(sectionX * sectorSize, sectionY * sectorSize, sectorSize, sectorSize);

				// draw section names
				ctx.font = `${Math.floor(sectorSize / 3)}px Ubuntu`;
				ctx.fillStyle = (!darkTheme || darkTheme.checked) ? '#fff' : '#000';
				ctx.globalAlpha = 0.3;
				ctx.textAlign = 'center';
				ctx.textBaseline = 'middle';

				const cols = ['1', '2', '3', '4', '5'];
				const rows = ['A', 'B', 'C', 'D', 'E'];
				cols.forEach((col, y) => {
					rows.forEach((row, x) => {
						ctx.fillText(row + col, (x + 0.5) * sectorSize, (y + 0.5) * sectorSize);
					});
				});

				// draw cells
				ctx.globalAlpha = 1;
				let avgX = 0;
				let avgY = 0;
				let myCells = 0;
				let myName = '';
				world.mine.forEach(id => {
					const cell = world.cells.get(id);
					if (!cell || cell.dead) return;

					++myCells;
					myName = cell.name;

					const x = (cell.x - border.l) / gameWidth * canvas.width;
					const y = (cell.y - border.t) / gameHeight * canvas.height;
					const r = Math.max(cell.r / gameWidth * canvas.width, 2);

					avgX += x;
					avgY += y;

					ctx.fillStyle = aux.rgb2hex(cell.rgb);

					ctx.beginPath();
					ctx.moveTo(x + r, y);
					ctx.arc(x, y, r, 0, 2 * Math.PI);
					ctx.fill();
				});

				if (myCells <= 0) {
					// if no cells were drawn, draw our spectate pos instead
					const x = (world.camera.x - border.l) / gameWidth * canvas.width;
					const y = (world.camera.y - border.t) / gameHeight * canvas.height;

					ctx.fillStyle = '#faa';
					ctx.beginPath();
					ctx.moveTo(x + 5, y);
					ctx.arc(x, y, 5, 0, 2 * Math.PI);
					ctx.fill();
				} else {
					// draw name above player's cells
					avgX /= myCells;
					avgY /= myCells;

					ctx.fillStyle = '#fff';
					ctx.fillText(myName, avgX, avgY - 7 - sectorSize / 6);
				}
			})();

			ui.chat.matchTheme();

			requestAnimationFrame(render);
		}

		requestAnimationFrame(render);
	})();



	// for me and other script developers! i'll try not to change things around too much,
	// but do some null?.coalescing?.just?.in?.case
	// @ts-expect-error
	window.sigfix = { destructor, aux, ui, world, net, version: 1 };
})();