您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Easily 2X or 3X your FPS + many bug fixes + supports SigMod
当前为
// ==UserScript== // @name Sigmally Fixes V2 // @version 2024-03-11.1 // @description Easily 2X or 3X your FPS + many bug fixes + 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 () => { //////////////////////////////// // 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 {string} hex * @returns {[number, number, number]} */ aux.hex2rgb = hex => { if (hex.length === 4) { return [ (parseInt(hex[1], 16) || 0) / 15, (parseInt(hex[2], 16) || 0) / 15, (parseInt(hex[3], 16) || 0) / 15 ]; } else if (hex.length === 7) { return [ (parseInt(hex.slice(1, 3), 16) || 0) / 255, (parseInt(hex.slice(3, 5), 16) || 0) / 255, (parseInt(hex.slice(5, 7), 16) || 0) / 255 ]; } else { return [0, 0, 0]; } } /** @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 }; }; /** * Only changes sometimes, like when your skin is updated * @type {object | undefined} */ aux.settings = undefined; setInterval(() => { try { aux.settings = JSON.parse(localStorage.getItem('settings') ?? ''); } catch (_) {} }, 50); /** @type {object | undefined} */ aux.userData = undefined; // this is the best method i've found to get the userData object, since game.js uses strict mode window.fetch = new Proxy(fetch, { apply: (target, thisArg, args) => { let url = args[0]; if (typeof url !== 'string') return target.apply(thisArg, args); // fix: game.js doesn't think we're connected to a server, we default to eu0 because that's the default // everywhere else if (url.includes('/userdata/')) args[0] = url.replace('///', '//eu0.sigmally.com/server/'); return target.apply(thisArg, args).then(res => new Proxy(res, { get: (target, prop, _receiver) => { if (prop !== 'json') { const val = target[prop]; if (typeof val === 'function') return val.bind(target); else return val; } return () => target.json().then(obj => { aux.userData = obj?.body?.user ?? aux.userData; return obj; }); }, })); } }); /** @param {number} ms */ aux.wait = ms => new Promise(resolve => setTimeout(resolve, ms)); return aux; })(); //////////////////////// // 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); } }); /** @type {WeakSet<WebSocket>} */ const safeWebSockets = new WeakSet(); let realWsSend = WebSocket.prototype.send; WebSocket.prototype.send = function() { if (!safeWebSockets.has(this) && 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, safeWebSockets }; })(); /////////////////////////////////////// // Configuration by External Scripts // /////////////////////////////////////// const config = (() => { const config = {}; // intended to be modified by external scripts (like SigMod) // there is no validation against garbage data - please be careful /** * this is the color of the map border in RGBA format (values are between 0-1, use aux.hex2rgb for conversion) * @type {[number, number, number, number]} */ config.borderColor = [0, 0, 1, 1]; /** * when not undefined, this is the outline color (in RGBA format) applied to all cells except pellets * @type {[number, number, number, number] | undefined} */ config.cellOutline = undefined; /** * when not undefined, this is the color (in RGBA format) applied to all pellets * @type {[number, number, number, number] | undefined} */ config.pelletColor = undefined; /** * when enabled, a white/black outline is placed around your cells that can't split * @type {boolean} */ config.outlineUnsplittable = true; /** * every key is a case-sensitive substring (like "Chet") to match in a skin's URL * and the value is the exact URL to use instead * @type {Map<string, string>} */ config.skinOverrides = new Map(); return config; })(); ///////////////////// // Prepare Game UI // ///////////////////// const ui = (() => { const ui = {}; (() => { const title = document.querySelector('#title'); if (!title) return; const watermark = document.createElement('span'); watermark.innerHTML = '<a href="https://gf.qytechs.cn/en/scripts/483587">Sigmally Fixes</a> by yx'; title.insertAdjacentElement('afterend', watermark); })(); 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; // forward macro inputs from the canvas to the old one - this is for sigmod mouse controls newCanvas.addEventListener('mousedown', e => oldCanvas.dispatchEvent(new MouseEvent('mousedown', e))); newCanvas.addEventListener('mouseup', e => oldCanvas.dispatchEvent(new MouseEvent('mouseup', e))); 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; })(); 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'); } function matchTheme() { let color = '#fff'; /** @type {HTMLInputElement | null} */ const darkTheme = document.querySelector('input#darkTheme'); 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 continueButton = /** @type {HTMLElement | null} */ (document.querySelector('#continue_button')); if (continueButton) { 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); 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;`; author.textContent = authorName ? aux.parseNameSkin(authorName, '').name : 'Unnamed'; 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(); }; chat.matchTheme = () => { /** @type {HTMLInputElement | null} */ const darkTheme = document.querySelector('input#darkTheme'); if (!darkTheme || darkTheme.checked) { list.style.color = '#fffc'; } else { list.style.color = '#000c'; } }; return chat; })(); return ui; })(); /////////////////////////// // 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 {Set<Cell>} */ world.clanmates = new Set(); /** @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); destructor.safeWebSockets.add(ws); 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.border = undefined; world.cells.clear(); // make sure we won't see overlapping IDs from new cells from the new connection world.clanmates.clear(); while (world.mine.length) world.mine.pop(); 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; ws.send(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]); } ws.send(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; } ws.send(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); world.clanmates.delete(killed); } } // #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; if (clan && clan === aux.userData?.clan) world.clanmates.add(cell); } else { if (r > 20 && !(flags & 0x20)) { // not pellet, not ejected ({ name, skin } = aux.parseNameSkin(name, skin)); } /** @type {Cell} */ const cell = { 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 }, }; world.cells.set(id, cell); if (clan && clan === aux.userData?.clan) world.clanmates.add(cell); } } // #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 }; }); world.clanmates.clear(); // 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); ws.send(buf); } net.w = function() { if (!handshake) return; ws.send(new Uint8Array([ Number(handshake.shuffle.get(21)) ])); } net.qdown = function() { if (!handshake) return; ws.send(new Uint8Array([ Number(handshake.shuffle.get(18)) ])); } net.qup = function() { if (!handshake) return; ws.send(new Uint8Array([ Number(handshake.shuffle.get(19)) ])); } net.split = function() { if (!handshake) return; ws.send(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]); ws.send(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; // fix for sigmod (<=8.5.5.4)'s broken randomPos() function, which spazzes your mouse // all other mouse movements orchestrated by sigmod are targetted at the canvas if (!e.isTrusted && e.target === document) 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'); /** @type {HTMLInputElement | null} */ const showClanmatesElement = document.querySelector('input#showClanmates'); let clan; let sub = false; let token; if (aux.userData) { clan = aux.userData.clan; sub = (aux.userData.subscription ?? 0) > Date.now(); token = aux.userData.token; } return { state: spectating ? 2 : undefined, name: nickElement?.value ?? '', skin: aux.settings?.skin, token, sub, clan, showClanmates: !showClanmatesElement || showClanmatesElement.checked, } } /** @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(50); } while (!(grecaptcha && CAPTCHA2 && grecaptcha.render && grecaptcha.reset)); const container = document.createElement('div'); container.id = 'g-recaptcha2'; 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-recaptcha2', { 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'; // add a space, which is to prevent sigmod from detecting the name 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, ...config.borderColor); 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; /** @type {HTMLInputElement | null} */ const showSkinsElement = document.querySelector('input#showSkins'); const showSkins = !showSkinsElement || showSkinsElement.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; } if (cell.r <= 20) { gl.uniform1i(uniforms.cell.u_outline_thick, 0); if (config.pelletColor) { gl.uniform4f(uniforms.cell.u_color, ...config.pelletColor); gl.uniform4f(uniforms.cell.u_outline_color, ...config.pelletColor); } else { gl.uniform4f(uniforms.cell.u_color, ...cell.rgb, 1); gl.uniform4f(uniforms.cell.u_outline_color, ...cell.rgb, 1); } } else { gl.uniform4f(uniforms.cell.u_color, ...cell.rgb, 1); const myIndex = world.mine.indexOf(cell.id); if (!config.outlineUnsplittable || myIndex === -1 || canSplit[myIndex]) { gl.uniform1i(uniforms.cell.u_outline_thick, 0); if (config.cellOutline) { gl.uniform4f(uniforms.cell.u_outline_color, ...config.cellOutline); } else { gl.uniform4f(uniforms.cell.u_outline_color, cell.rgb[0] * 0.9, cell.rgb[1] * 0.9, cell.rgb[2] * 0.9, 1); } } 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); if (showThisName) gl.uniform1i(uniforms.text.u_subtext_enabled, 1); else { // if a player has no name, keep the mass in the middle gl.uniform1f(uniforms.text.u_radius, cell.r / 2); gl.uniform1i(uniforms.text.u_subtext_enabled, 0); } 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 (typeof aux.userData?.boost === 'number' && aux.userData.boost > Date.now()) score *= 2; if (score > 0) ui.stats.score.textContent = 'Score: ' + Math.floor(score); else ui.stats.score.textContent = ''; let measures = `${Math.floor(fps)} FPS`; if (net.latency !== undefined) { if (net.latency === -1) measures += ' ????ms ping'; else measures += ` ${Math.floor(net.latency)}ms ping`; } ui.stats.measures.textContent = measures; 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() { // text needs to be small and sharp, i don't trust webgl with that, so we use a 2d context const { border } = world; if (!border) return; /** @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); }); }); ctx.globalAlpha = 1; // draw cells /** @param {Cell} cell */ const drawCell = function drawCell(cell) { 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); ctx.fillStyle = aux.rgb2hex(cell.rgb); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill(); }; /** * @param {number} x * @param {number} y * @param {string} name */ const drawName = function drawName(x, y, name) { x = (x - border.l) / gameWidth * canvas.width; y = (y - border.t) / gameHeight * canvas.height; ctx.fillStyle = '#fff'; // add a space to prevent sigmod from detecting names ctx.fillText(name + ' ', x, y - 7 * devicePixelRatio - sectorSize / 6); } // draw clanmates first, below yourself // we sort clanmates by color AND name, to ensure clanmates stay separate /** @type {Map<string, { name: string, n: number, x: number, y: number }>} */ const avgPos = new Map(); world.clanmates.forEach(cell => { if (world.mine.includes(cell.id)) return; drawCell(cell); const id = cell.name + (cell.rgb[0] + 255*cell.rgb[1] + 255*255*cell.rgb[2]); const entry = avgPos.get(id); if (entry) { ++entry.n; entry.x += cell.x; entry.y += cell.y; } else { avgPos.set(id, { name: cell.name, n: 1, x: cell.x, y: cell.y }); } }); avgPos.forEach(entry => { drawName(entry.x / entry.n, entry.y / entry.n, entry.name); }); // draw my cells above everyone else let myName = ''; let ownN = 0; let ownX = 0; let ownY = 0; world.mine.forEach(id => { const cell = world.cells.get(id); if (!cell) return; drawCell(cell); myName = cell.name; ++ownN; ownX += cell.x; ownY += cell.y; }); if (ownN <= 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 * devicePixelRatio, 0, 2 * Math.PI); ctx.fill(); } else { // draw name above player's cells drawName(ownX / ownN, ownY / ownN, myName); } })(); 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, config, aux, ui, world, net, version: 2 }; })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址