Sigmally Fixes V2

Easily 10X your FPS on Sigmally.com + many bug fixes + great for multiboxing + supports SigMod

  1. // ==UserScript==
  2. // @name Sigmally Fixes V2
  3. // @version 2.6.8
  4. // @description Easily 10X your FPS on Sigmally.com + many bug fixes + great for multiboxing + supports SigMod
  5. // @author 8y8x
  6. // @match https://*.sigmally.com/*
  7. // @license MIT
  8. // @grant none
  9. // @namespace https://8y8x.dev/sigmally-fixes
  10. // @icon https://raw.githubusercontent.com/8y8x/sigmally-fixes/refs/heads/main/icon.png
  11. // @compatible chrome
  12. // @compatible opera
  13. // @compatible edge
  14. // ==/UserScript==
  15.  
  16. // @ts-check
  17. /* eslint
  18. camelcase: 'error',
  19. comma-dangle: ['error', 'always-multiline'],
  20. indent: ['error', 'tab', { SwitchCase: 1 }],
  21. max-len: ['error', { code: 120 }],
  22. no-console: ['error', { allow: ['warn', 'error'] }],
  23. no-trailing-spaces: 'error',
  24. quotes: ['error', 'single'],
  25. semi: 'error',
  26. */ // a light eslint configuration that doesn't compromise code quality
  27. 'use strict';
  28.  
  29. (async () => {
  30. const sfVersion = '2.6.8';
  31. const undefined = void 0; // yes, this actually makes a significant difference
  32.  
  33. ////////////////////////////////
  34. // Define Auxiliary Functions //
  35. ////////////////////////////////
  36. const aux = (() => {
  37. const aux = {};
  38.  
  39. /** @type {Map<string, string>} */
  40. aux.clans = new Map();
  41. function fetchClans() {
  42. fetch('https://sigmally.com/api/clans').then(r => r.json()).then(r => {
  43. if (r.status !== 'success') {
  44. setTimeout(() => fetchClans(), 10_000);
  45. return;
  46. }
  47.  
  48. aux.clans.clear();
  49. r.data.forEach(clan => {
  50. if (typeof clan._id !== 'string' || typeof clan.name !== 'string') return;
  51. aux.clans.set(clan._id, clan.name);
  52. });
  53.  
  54. // does not need to be updated often, but just enough so people who leave their tab open don't miss out
  55. setTimeout(() => fetchClans(), 600_000);
  56. }).catch(err => {
  57. console.warn('Error while fetching clans:', err);
  58. setTimeout(() => fetchClans(), 10_000);
  59. });
  60. }
  61. fetchClans();
  62.  
  63. /**
  64. * @template T
  65. * @param {T} x
  66. * @param {string} err should be readable and easily translatable
  67. * @returns {T extends (null | undefined | false | 0) ? never : T}
  68. */
  69. aux.require = (x, err) => {
  70. if (!x) {
  71. err = '[Sigmally Fixes]: ' + err;
  72. prompt(err, err); // use prompt, so people can paste the error message into google translate
  73. throw err;
  74. }
  75.  
  76. return /** @type {any} */ (x);
  77. };
  78.  
  79. const dominantColorCtx = aux.require(
  80. document.createElement('canvas').getContext('2d', { willReadFrequently: true }),
  81. 'Unable to get 2D context for aux utilities. This is probably your browser being dumb, maybe reload ' +
  82. 'the page?',
  83. );
  84. /**
  85. * @param {HTMLImageElement} img
  86. * @returns {[number, number, number, number]}
  87. */
  88. aux.dominantColor = img => {
  89. dominantColorCtx.canvas.width = dominantColorCtx.canvas.height = 7;
  90. dominantColorCtx.drawImage(img, 0, 0, 7, 7);
  91. const data = dominantColorCtx.getImageData(0, 0, 7, 7);
  92.  
  93. const r = [], g = [], b = [];
  94. let sumA = 0, numA = 0;
  95. for (let x = 0; x < 7; ++x) {
  96. for (let y = 0; y < 7; ++y) {
  97. const d = Math.hypot((3 - x) / 6, (3 - y) / 6);
  98. if (d > 1) continue; // do not consider pixels outside a circle, as they may be blank
  99. const pixel = y * 7 + x;
  100. r.push(data.data[pixel * 4]);
  101. g.push(data.data[pixel * 4 + 1]);
  102. b.push(data.data[pixel * 4 + 2]);
  103. sumA += data.data[pixel * 4 + 3];
  104. ++numA;
  105. }
  106. }
  107.  
  108. r.sort();
  109. g.sort();
  110. b.sort();
  111. /** @type {[number, number, number, number]} */
  112. const color = [
  113. r[Math.ceil(r.length / 2)] / 255, g[Math.ceil(g.length / 2)] / 255,
  114. b[Math.ceil(b.length / 2)] / 255, sumA / numA / 255];
  115.  
  116. const max = Math.max(Math.max(color[0], color[1]), color[2]);
  117. if (max === 0) {
  118. color[0] = color[1] = color[2] = 1;
  119. } else {
  120. color[0] *= 1 / max;
  121. color[1] *= 1 / max;
  122. color[2] *= 1 / max;
  123. }
  124.  
  125. color[3] **= 4; // transparent skins should use the player color
  126.  
  127. return color;
  128. };
  129.  
  130. /**
  131. * consistent exponential easing relative to 60fps. this models "x += (targetX - x) * dt" scenarios.
  132. * for example, with a factor of 2, o=0, n=1:
  133. * - at 60fps, 0.5 is returned.
  134. * - at 30fps (after 2 frames), 0.75 is returned.
  135. * - at 15fps (after 4 frames), 0.875 is returned.
  136. * - at 120fps, 0.292893 is returned. if you called this again with o=0.292893, n=1, you would get 0.5.
  137. *
  138. * @param {number} o
  139. * @param {number} n
  140. * @param {number} factor
  141. * @param {number} dt in seconds
  142. */
  143. aux.exponentialEase = (o, n, factor, dt) => {
  144. return o + (n - o) * (1 - (1 - 1 / factor) ** (60 * dt));
  145. };
  146.  
  147. /**
  148. * @param {string} hex
  149. * @returns {[number, number, number, number]}
  150. */
  151. aux.hex2rgba = hex => {
  152. switch (hex.length) {
  153. case 4: // #rgb
  154. case 5: // #rgba
  155. return [
  156. (parseInt(hex[1], 16) || 0) / 15,
  157. (parseInt(hex[2], 16) || 0) / 15,
  158. (parseInt(hex[3], 16) || 0) / 15,
  159. hex.length === 5 ? (parseInt(hex[4], 16) || 0) / 15 : 1,
  160. ];
  161. case 7: // #rrggbb
  162. case 9: // #rrggbbaa
  163. return [
  164. (parseInt(hex.slice(1, 3), 16) || 0) / 255,
  165. (parseInt(hex.slice(3, 5), 16) || 0) / 255,
  166. (parseInt(hex.slice(5, 7), 16) || 0) / 255,
  167. hex.length === 9 ? (parseInt(hex.slice(7, 9), 16) || 0) / 255 : 1,
  168. ];
  169. default:
  170. return [1, 1, 1, 1];
  171. }
  172. };
  173.  
  174. /**
  175. * @param {number} r
  176. * @param {number} g
  177. * @param {number} b
  178. * @param {number} a
  179. */
  180. aux.rgba2hex = (r, g, b, a) => {
  181. return [
  182. '#',
  183. Math.floor(r * 255).toString(16).padStart(2, '0'),
  184. Math.floor(g * 255).toString(16).padStart(2, '0'),
  185. Math.floor(b * 255).toString(16).padStart(2, '0'),
  186. Math.floor(a * 255).toString(16).padStart(2, '0'),
  187. ].join('');
  188. };
  189.  
  190. // i don't feel like making an awkward adjustment to aux.rgba2hex
  191. /**
  192. * @param {number} r
  193. * @param {number} g
  194. * @param {number} b
  195. * @param {any} _a
  196. */
  197. aux.rgba2hex6 = (r, g, b, _a) => {
  198. return [
  199. '#',
  200. Math.floor(r * 255).toString(16).padStart(2, '0'),
  201. Math.floor(g * 255).toString(16).padStart(2, '0'),
  202. Math.floor(b * 255).toString(16).padStart(2, '0'),
  203. ].join('');
  204. };
  205.  
  206. /** @param {string} name */
  207. aux.parseName = name => name.match(/^\{.*?\}(.*)$/)?.[1] ?? name;
  208.  
  209. /** @param {string} skin */
  210. aux.parseSkin = skin => {
  211. if (!skin) return skin;
  212. skin = skin.replace('1%', '').replace('2%', '').replace('3%', '');
  213. return '/static/skins/' + skin + '.png';
  214. };
  215.  
  216. /**
  217. * @param {DataView} dat
  218. * @param {number} o
  219. * @returns {[string, number]}
  220. */
  221. aux.readZTString = (dat, o) => {
  222. if (dat.getUint8(o) === 0) return ['', o + 1]; // quick return for empty strings (there are a lot)
  223. const startOff = o;
  224. for (; o < dat.byteLength; ++o) {
  225. if (dat.getUint8(o) === 0) break;
  226. }
  227.  
  228. return [aux.textDecoder.decode(new DataView(dat.buffer, startOff, o - startOff)), o + 1];
  229. };
  230.  
  231. /**
  232. * @param {string} selector
  233. * @param {boolean} value
  234. */
  235. aux.setting = (selector, value) => {
  236. /** @type {HTMLInputElement | null} */
  237. const el = document.querySelector(selector);
  238. return el ? el.checked : value;
  239. };
  240.  
  241. /** @param {boolean} accessSigmod */
  242. const settings = accessSigmod => {
  243. try {
  244. // current skin is saved in localStorage
  245. aux.settings = JSON.parse(localStorage.getItem('settings') ?? '');
  246. } catch (_) {
  247. aux.settings = /** @type {any} */ ({});
  248. }
  249.  
  250. // sigmod doesn't have a checkbox for dark theme, so we infer it from the custom map color
  251. const { mapColor } = accessSigmod ? sigmod.settings : {};
  252. if (mapColor) {
  253. aux.settings.darkTheme
  254. = mapColor ? (mapColor[0] < 0.6 && mapColor[1] < 0.6 && mapColor[2] < 0.6) : true;
  255. } else {
  256. aux.settings.darkTheme = aux.setting('input#darkTheme', true);
  257. }
  258. aux.settings.jellyPhysics = aux.setting('input#jellyPhysics', false);
  259. aux.settings.showBorder = aux.setting('input#showBorder', true);
  260. aux.settings.showClanmates = aux.setting('input#showClanmates', true);
  261. aux.settings.showGrid = aux.setting('input#showGrid', true);
  262. aux.settings.showMass = aux.setting('input#showMass', false);
  263. aux.settings.showMinimap = aux.setting('input#showMinimap', true);
  264. aux.settings.showSkins = aux.setting('input#showSkins', true);
  265. aux.settings.zoomout = aux.setting('input#moreZoom', true);
  266. return aux.settings;
  267. };
  268.  
  269. /** @type {{ darkTheme: boolean, jellyPhysics: boolean, showBorder: boolean, showClanmates: boolean,
  270. showGrid: boolean, showMass: boolean, showMinimap: boolean, showSkins: boolean, zoomout: boolean,
  271. gamemode: any, skin: any }} */
  272. aux.settings = settings(false);
  273. setInterval(() => settings(true), 250);
  274. // apply saved gamemode because sigmally fixes connects before the main game even loads
  275. if (aux.settings?.gamemode) {
  276. /** @type {HTMLSelectElement | null} */
  277. const gamemode = document.querySelector('select#gamemode');
  278. if (gamemode)
  279. gamemode.value = aux.settings.gamemode;
  280. }
  281.  
  282. let caught = false;
  283. const tabScan = new BroadcastChannel('sigfix-tabscan');
  284. tabScan.addEventListener('message', () => {
  285. if (caught || world.score(world.selected) <= 50) return;
  286. caught = true;
  287. const str = 'hi! sigmally fixes v2.5.0 is now truly one-tab, so you don\'t need multiple tabs anymore. ' +
  288. 'set a keybind under the "One-tab multibox key" setting and enjoy!';
  289. prompt(str, str);
  290. });
  291. setInterval(() => {
  292. if (world.score(world.selected) > 50 && !caught) tabScan.postMessage(undefined);
  293. }, 5000);
  294.  
  295. aux.textEncoder = new TextEncoder();
  296. aux.textDecoder = new TextDecoder();
  297.  
  298. const trimCtx = aux.require(
  299. document.createElement('canvas').getContext('2d'),
  300. 'Unable to get 2D context for text utilities. This is probably your browser being dumb, maybe reload ' +
  301. 'the page?',
  302. );
  303. trimCtx.font = '20px Ubuntu';
  304. /**
  305. * trims text to a max of 250px at 20px font, same as vanilla sigmally
  306. * @param {string} text
  307. */
  308. aux.trim = text => {
  309. while (trimCtx.measureText(text).width > 250)
  310. text = text.slice(0, -1);
  311.  
  312. return text;
  313. };
  314.  
  315. /** @type {{ token: string, updated: number } | undefined} */
  316. aux.token = undefined;
  317.  
  318. // @ts-expect-error
  319. let handler = window.signOut;
  320. Object.defineProperty(window, 'signOut', {
  321. get: () => () => {
  322. aux.token = undefined;
  323. return handler?.();
  324. },
  325. set: x => handler = x,
  326. });
  327.  
  328. /** @type {object | undefined} */
  329. aux.userData = undefined;
  330. aux.oldFetch = fetch.bind(window);
  331. let lastUserData = -Infinity;
  332. // this is the best method i've found to get the userData object, since game.js uses strict mode
  333. Object.defineProperty(window, 'fetch', {
  334. value: new Proxy(fetch, {
  335. apply: (target, thisArg, args) => {
  336. let url = args[0];
  337. const data = args[1];
  338. if (typeof url === 'string') {
  339. if (url.includes('/server/recaptcha/v3'))
  340. return new Promise(() => {}); // block game.js from attempting to go through captcha flow
  341.  
  342. // game.js doesn't think we're connected to a server, we default to eu0 because that's the
  343. // default everywhere else
  344. if (url.includes('/userdata/')) {
  345. // when holding down the respawn key, you can easily make 30+ requests a second,
  346. // bombing you into ratelimit hell
  347. const now = performance.now();
  348. if (now - lastUserData < 500) return new Promise(() => {});
  349. url = url.replace('///', '//eu0.sigmally.com/server/');
  350. lastUserData = now;
  351. }
  352.  
  353. if (url.includes('/server/auth')) {
  354. // sigmod must be properly initialized (client can't be null), otherwise it will error
  355. // and game.js will never get the account data
  356. sigmod.patch();
  357. }
  358.  
  359. // patch the current token in the url and body of the request
  360. if (aux.token) {
  361. // 128 hex characters surrounded by non-hex characters (lookahead and lookbehind)
  362. const tokenTest = /(?<![0-9a-fA-F])[0-9a-fA-F]{128}(?![0-9a-fA-F])/g;
  363. url = url.replaceAll(tokenTest, aux.token.token);
  364. if (typeof data?.body === 'string')
  365. data.body = data.body.replaceAll(tokenTest, aux.token.token);
  366. }
  367.  
  368. args[0] = url;
  369. args[1] = data;
  370. }
  371.  
  372. return target.apply(thisArg, args).then(res => new Proxy(res, {
  373. get: (target, prop, _receiver) => {
  374. if (prop !== 'json') {
  375. const val = target[prop];
  376. if (typeof val === 'function')
  377. return val.bind(target);
  378. else
  379. return val;
  380. }
  381.  
  382. return () => target.json().then(obj => {
  383. if (obj?.body?.user) {
  384. aux.userData = obj.body.user;
  385. // NaN if invalid / undefined
  386. let updated = Number(new Date(aux.userData.updateTime));
  387. if (Number.isNaN(updated))
  388. updated = Date.now();
  389.  
  390. if (!aux.token || updated >= aux.token.updated) {
  391. aux.token = { token: aux.userData.token, updated };
  392. }
  393. }
  394.  
  395. return obj;
  396. });
  397. },
  398. }));
  399. },
  400. }),
  401. });
  402.  
  403. // get the latest game.js version whenever possible
  404. // some players are stuck on an older game.js version which does not allow signing in
  405. fetch('https://one.sigmally.com/assets/js/game.js', { cache: 'reload' });
  406.  
  407. return aux;
  408. })();
  409.  
  410.  
  411.  
  412. ////////////////////////
  413. // Destroy Old Client //
  414. ////////////////////////
  415. const destructor = (() => {
  416. const destructor = {};
  417.  
  418. const vanillaStack = () => {
  419. try {
  420. throw new Error();
  421. } catch (err) {
  422. // prevent drawing the game, but do NOT prevent saving settings (which is called on RQA)
  423. return err.stack.includes('/game.js') && !err.stack.includes('HTML');
  424. }
  425. };
  426.  
  427. // #1 : kill the rendering process
  428. const oldRQA = requestAnimationFrame;
  429. window.requestAnimationFrame = function(fn) {
  430. return vanillaStack() ? -1 : oldRQA(fn);
  431. };
  432.  
  433. // #2 : kill access to using a WebSocket
  434. destructor.realWebSocket = WebSocket;
  435. Object.defineProperty(window, 'WebSocket', {
  436. value: new Proxy(WebSocket, {
  437. construct(_target, argArray, _newTarget) {
  438. if (argArray[0].includes('sigmally.com') && vanillaStack()) {
  439. throw new Error('sigfix: Preventing new WebSocket() for unknown Sigmally connection');
  440. }
  441.  
  442. // @ts-expect-error
  443. return new destructor.realWebSocket(...argArray);
  444. },
  445. }),
  446. });
  447.  
  448. const cmdRepresentation = new TextEncoder().encode('/leaveworld').toString();
  449. destructor.realWsSend = WebSocket.prototype.send;
  450. WebSocket.prototype.send = function (x) {
  451. if (vanillaStack() && this.url.includes('sigmally.com')) {
  452. this.onclose = null;
  453. this.close();
  454. throw new Error('sigfix: Preventing .send on unknown Sigmally connection');
  455. }
  456.  
  457. if (settings.blockNearbyRespawns) {
  458. let buf;
  459. if (x instanceof ArrayBuffer) buf = x;
  460. else if (x instanceof DataView) buf = x.buffer;
  461. else if (x instanceof Uint8Array) buf = x.buffer;
  462.  
  463. if (buf && buf.byteLength === '/leaveworld'.length + 3
  464. && new Uint8Array(buf).toString().includes(cmdRepresentation)) {
  465. const now = performance.now();
  466. let con, view, vision; // cba to put explicit types here
  467. for (const [otherView, otherCon] of net.connections) {
  468. world.camera(otherView, now); // ensure all tabs update their cameras
  469. if (otherCon.ws !== this) continue;
  470.  
  471. con = otherCon;
  472. view = otherView;
  473. vision = world.views.get(otherView);
  474. }
  475. if (con && view && vision) {
  476. // block respawns if we haven't actually respawned yet
  477. // (with a 500ms max in case something fails)
  478. if (performance.now() - (con.respawnBlock?.started ?? -Infinity) < 500) return;
  479. con.respawnBlock = undefined;
  480. // trying to respawn; see if we are nearby an alive multi-tab
  481. if (world.score(view) > 0 && vision.border) {
  482. const { border } = vision;
  483. // use a smaller respawn radius on EU servers
  484. const radius = Math.min(border.r - border.l, border.b - border.t) / 4;
  485. for (const [otherView, otherVision] of world.views) {
  486. if (vision === otherVision) continue;
  487. if (world.score(otherView) <= 0) continue;
  488. const dx = vision.camera.tx - otherVision.camera.tx;
  489. const dy = vision.camera.ty - otherVision.camera.ty;
  490. if (Math.hypot(dx, dy) <= radius)
  491. return;
  492. }
  493. }
  494.  
  495. // we are allowing a respawn, take note
  496. con.respawnBlock = { status: 'pending', started: performance.now() };
  497. }
  498. }
  499. }
  500.  
  501. return destructor.realWsSend.apply(this, arguments);
  502. };
  503.  
  504. // #3 : prevent keys from being registered by the game
  505. setInterval(() => onkeydown = onkeyup = null, 200);
  506.  
  507. return destructor;
  508. })();
  509.  
  510.  
  511.  
  512. //////////////////////////////////
  513. // Apply Complex SigMod Patches //
  514. //////////////////////////////////
  515. const sigmod = (() => {
  516. const sigmod = {};
  517.  
  518. /** @type {{
  519. * cellColor?: [number, number, number, number],
  520. * foodColor?: [number, number, number, number],
  521. * font?: string,
  522. * mapColor?: [number, number, number, number],
  523. * outlineColor?: [number, number, number, number],
  524. * nameColor1?: [number, number, number, number],
  525. * nameColor2?: [number, number, number, number],
  526. * rapidFeedKey?: string,
  527. * removeOutlines?: boolean,
  528. * showNames?: boolean,
  529. * tripleKey?: string,
  530. * virusImage?: string,
  531. * }} */
  532. sigmod.settings = {};
  533. /** @type {Set<string>} */
  534. const loadedFonts = new Set();
  535. setInterval(() => {
  536. // @ts-expect-error
  537. const real = window.sigmod?.settings;
  538. if (!real) return;
  539. /**
  540. * @param {'cellColor' | 'foodColor' | 'mapColor' | 'outlineColor' | 'nameColor1' | 'nameColor2'} prop
  541. * @param {any} initial
  542. * @param {any[]} lookups
  543. */
  544. const applyColor = (prop, initial, lookups) => {
  545. for (const lookup of lookups) {
  546. if (lookup && lookup !== initial) {
  547. sigmod.settings[prop] = aux.hex2rgba(lookup);
  548. return;
  549. }
  550. }
  551. sigmod.settings[prop] = undefined;
  552. };
  553. applyColor('cellColor', null, [real.game?.cellColor]);
  554. applyColor('foodColor', null, [real.game?.foodColor]);
  555. applyColor('mapColor', null, [real.game?.map?.color, real.mapColor]);
  556. // sigmod treats the map border as cell borders for some reason
  557. applyColor('outlineColor', '#0000ff', [real.game?.borderColor]);
  558. // note: singular nameColor takes priority
  559. applyColor('nameColor1', '#ffffff', [
  560. real.game?.name?.color,
  561. real.game?.name?.gradient?.enabled && real.game.name.gradient.left,
  562. ]);
  563. applyColor('nameColor2', '#ffffff', [
  564. real.game?.name?.color,
  565. real.game?.name?.gradient?.enabled && real.game.name.gradient.right,
  566. ]);
  567. sigmod.settings.removeOutlines = real.game?.removeOutlines;
  568. sigmod.settings.virusImage = real.game?.virusImage;
  569. sigmod.settings.rapidFeedKey = real.macros?.keys?.rapidFeed;
  570. // sigmod's showNames setting is always "true" interally (i think??)
  571. sigmod.settings.showNames = aux.setting('input#showNames', true);
  572.  
  573. sigmod.settings.tripleKey = real.macros?.keys?.splits?.triple || undefined; // blank keys are ''
  574. sigmod.settings.font = real.game?.font;
  575.  
  576. // sigmod does not download the bold variants of fonts, so we have to do that ourselves
  577. if (sigmod.settings.font && !loadedFonts.has(sigmod.settings.font)) {
  578. loadedFonts.add(sigmod.settings.font);
  579. const link = document.createElement('link');
  580. link.href = `https://fonts.googleapis.com/css2?family=${sigmod.settings.font}:wght@700&display=swap`;
  581. link.rel = 'stylesheet';
  582. document.head.appendChild(link);
  583. }
  584. }, 200);
  585.  
  586. // patch sigmod when it's ready; typically sigmod loads first, but i can't guarantee that
  587. sigmod.proxy = {};
  588. let patchInterval;
  589. sigmod.patch = () => {
  590. const real = /** @type {any} */ (window).sigmod;
  591. if (!real || patchInterval === undefined) return;
  592.  
  593. clearInterval(patchInterval);
  594. patchInterval = undefined;
  595.  
  596. // anchor chat and minimap to the screen, so scrolling to zoom doesn't move them
  597. // it's possible that cursed will change something at any time so i'm being safe here
  598. const minimapContainer = /** @type {HTMLElement | null} */ (document.querySelector('.minimapContainer'));
  599. if (minimapContainer) minimapContainer.style.position = 'fixed';
  600.  
  601. const modChat = /** @type {HTMLElement | null} */ (document.querySelector('.modChat'));
  602. if (modChat) modChat.style.position = 'fixed';
  603.  
  604. // sigmod keeps track of the # of displayed messages with a counter, but it doesn't reset on clear
  605. // therefore, if the chat gets cleared with 200 (the maximum) messages in it, it will stay permanently*
  606. // blank
  607. const modMessages = /** @type {HTMLElement | null} */ (document.querySelector('#mod-messages'));
  608. if (modMessages) {
  609. const old = modMessages.removeChild;
  610. modMessages.removeChild = node => {
  611. if (modMessages.children.length > 200) return old.call(modMessages, node);
  612. else return node;
  613. };
  614. }
  615.  
  616. // create a fake sigmally proxy for sigmod, which properly relays some packets (because SigMod does not
  617. // support one-tab technology). it should also fix chat bugs due to disconnects and stuff
  618. // we do this by hooking into the SigWsHandler object
  619. {
  620. /** @type {object | undefined} */
  621. let handler;
  622. const old = Function.prototype.bind;
  623. Function.prototype.bind = function(obj) {
  624. if (obj.constructor?.name === 'SigWsHandler') handler = obj;
  625. return old.call(this, obj);
  626. };
  627. new WebSocket('wss://255.255.255.255/sigmally.com?sigfix');
  628. Function.prototype.bind = old;
  629. // handler is expected to be a "SigWsHandler", but it might be something totally different
  630. if (handler && 'sendPacket' in handler && 'handleMessage' in handler) {
  631. // first, set up the handshake (opcode not-really-a-shuffle)
  632. // handshake is reset to false on close (which may or may not happen immediately)
  633. Object.defineProperty(handler, 'handshake', { get: () => true, set: () => {} });
  634. handler.R = handler.C = new Uint8Array(256); // R and C are linked
  635. for (let i = 0; i < 256; ++i) handler.C[i] = i;
  636.  
  637. // expose some functions here, don't directly access the handler anywhere else
  638. sigmod.proxy = {
  639. /** @param {DataView} dat */
  640. handleMessage: dat => handler.handleMessage({ data: dat.buffer }),
  641. isPlaying: () => /** @type {any} */ (window).gameSettings.isPlaying = true,
  642. };
  643.  
  644. // override sendPacket to properly handle what sigmod expects
  645. /** @param {object} buf */
  646. handler.sendPacket = buf => {
  647. if ('build' in buf) buf = buf.build(); // convert sigmod/sigmally Writer class to a buffer
  648. if ('buffer' in buf) buf = buf.buffer;
  649. const dat = new DataView(/** @type {ArrayBuffer} */ (buf));
  650. switch (dat.getUint8(0)) {
  651. // case 0x00, sendPlay, isn't really used outside of secret tournaments
  652. case 0x10: { // used for linesplits and such
  653. net.move(world.selected, dat.getInt32(1, true), dat.getInt32(5, true));
  654. break;
  655. }
  656. // case 0x63, sendChat, already goes directly to sigfix.net.chat
  657. // case 0xdc, sendFakeCaptcha, is not used outside of secret tournaments
  658. }
  659. };
  660. }
  661. }
  662. };
  663. patchInterval = setInterval(sigmod.patch, 500);
  664. sigmod.patch();
  665.  
  666. return sigmod;
  667. })();
  668.  
  669.  
  670. /////////////////////
  671. // Prepare Game UI //
  672. /////////////////////
  673. const ui = (() => {
  674. const ui = {};
  675.  
  676. (() => {
  677. const title = document.querySelector('#title');
  678. if (!title) return;
  679.  
  680. const watermark = document.createElement('span');
  681. watermark.innerHTML = `<a href="https://greasyfork.org/scripts/483587/versions" \
  682. target="_blank">Sigmally Fixes ${sfVersion}</a> by yx`;
  683. if (sfVersion.includes('BETA')) {
  684. watermark.innerHTML += ' <br><a \
  685. href="https://raw.githubusercontent.com/8y8x/sigmally-fixes/refs/heads/main/sigmally-fixes.user.js"\
  686. target="_blank">[Update beta here]</a>';
  687. }
  688. title.insertAdjacentElement('afterend', watermark);
  689.  
  690. // check if this version is problematic, don't do anything if this version is too new to be in versions.json
  691. // take care to ensure users can't be logged
  692. fetch('https://raw.githubusercontent.com/8y8x/sigmally-fixes/main/versions.json')
  693. .then(res => res.json())
  694. .then(res => {
  695. if (sfVersion in res && !res[sfVersion].ok && res[sfVersion].alert) {
  696. const color = res[sfVersion].color || '#f00';
  697. const box = document.createElement('div');
  698. box.style.cssText = `background: ${color}3; border: 1px solid ${color}; width: 100%; \
  699. height: fit-content; font-size: 1em; padding: 5px; margin: 5px 0; border-radius: 3px; \
  700. color: ${color}`;
  701. box.innerHTML = String(res[sfVersion].alert)
  702. .replace(/\<|\>/g, '') // never allow html tag injection
  703. .replace(/\{link\}/g, '<a href="https://greasyfork.org/scripts/483587">[click here]</a>')
  704. .replace(/\{autolink\}/g, '<a href="\
  705. https://update.greasyfork.org/scripts/483587/Sigmally%20Fixes%20V2.user.js">\
  706. [click here]</a>');
  707.  
  708. watermark.insertAdjacentElement('afterend', box);
  709. }
  710. })
  711. .catch(err => console.warn('Failed to check Sigmally Fixes version:', err));
  712. })();
  713.  
  714. ui.game = (() => {
  715. const game = {};
  716.  
  717. /** @type {HTMLCanvasElement | null} */
  718. const oldCanvas = document.querySelector('canvas#canvas');
  719. if (!oldCanvas) {
  720. throw 'exiting script - no canvas found';
  721. }
  722.  
  723. const newCanvas = document.createElement('canvas');
  724. newCanvas.id = 'sf-canvas';
  725. newCanvas.style.cssText = `background: #003; width: 100vw; height: 100vh; position: fixed; top: 0; left: 0;
  726. z-index: 1;`;
  727. game.canvas = newCanvas;
  728. (document.querySelector('body div') ?? document.body).appendChild(newCanvas);
  729.  
  730. // leave the old canvas so the old client can actually run
  731. oldCanvas.style.display = 'none';
  732.  
  733. // forward macro inputs from the canvas to the old one - this is for sigmod mouse button controls
  734. newCanvas.addEventListener('mousedown', e => oldCanvas.dispatchEvent(new MouseEvent('mousedown', e)));
  735. newCanvas.addEventListener('mouseup', e => oldCanvas.dispatchEvent(new MouseEvent('mouseup', e)));
  736. // forward mouse movements from the old canvas to the window - this is for sigmod keybinds that move
  737. // the mouse
  738. oldCanvas.addEventListener('mousemove', e => dispatchEvent(new MouseEvent('mousemove', e)));
  739.  
  740. const gl = aux.require(
  741. newCanvas.getContext('webgl2', { alpha: false, antialias: false, depth: false }),
  742. 'Couldn\'t get WebGL2 context. Possible causes:\r\n' +
  743. '- Maybe GPU/Hardware acceleration needs to be enabled in your browser settings; \r\n' +
  744. '- Maybe your browser is just acting weird and it might fix itself after a restart; \r\n' +
  745. '- Maybe your GPU drivers are exceptionally old.',
  746. );
  747.  
  748. game.gl = gl;
  749.  
  750. // indicate that we will restore the context
  751. newCanvas.addEventListener('webglcontextlost', e => {
  752. e.preventDefault(); // signal that we want to restore the context
  753. });
  754. newCanvas.addEventListener('webglcontextrestored', () => {
  755. glconf.init();
  756. // cleanup old caches (after render), as we can't do this within glconf.init()
  757. render.resetDatabaseCache();
  758. render.resetTextCache();
  759. render.resetTextureCache();
  760. });
  761.  
  762. function resize() {
  763. // devicePixelRatio does not have very high precision; it could be 0.800000011920929 for example
  764. newCanvas.width = Math.ceil(innerWidth * (devicePixelRatio - 0.0001));
  765. newCanvas.height = Math.ceil(innerHeight * (devicePixelRatio - 0.0001));
  766. game.gl.viewport(0, 0, newCanvas.width, newCanvas.height);
  767. }
  768.  
  769. addEventListener('resize', resize);
  770. resize();
  771.  
  772. return game;
  773. })();
  774.  
  775. ui.stats = (() => {
  776. const container = document.createElement('div');
  777. container.style.cssText = 'position: fixed; top: 10px; left: 10px; width: 400px; height: fit-content; \
  778. user-select: none; z-index: 2; transform-origin: top left; font-family: Ubuntu;';
  779. document.body.appendChild(container);
  780.  
  781. const score = document.createElement('div');
  782. score.style.cssText = 'font-size: 30px; color: #fff; line-height: 1.0;';
  783. container.appendChild(score);
  784.  
  785. const measures = document.createElement('div');
  786. measures.style.cssText = 'font-size: 20px; color: #fff; line-height: 1.1;';
  787. container.appendChild(measures);
  788.  
  789. const misc = document.createElement('div');
  790. // white-space: pre; allows using \r\n to insert line breaks
  791. misc.style.cssText = 'font-size: 14px; color: #fff; white-space: pre; line-height: 1.1; opacity: 0.5;';
  792. container.appendChild(misc);
  793.  
  794. /** @param {symbol} view */
  795. const update = view => {
  796. const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
  797. if (container.style.fontFamily !== fontFamily) container.style.fontFamily = fontFamily;
  798.  
  799. const color = aux.settings.darkTheme ? '#fff' : '#000';
  800. score.style.color = color;
  801. measures.style.color = color;
  802. misc.style.color = color;
  803.  
  804. score.style.fontWeight = measures.style.fontWeight = settings.boldUi ? 'bold' : 'normal';
  805. measures.style.opacity = settings.showStats ? '1' : '0.5';
  806. misc.style.opacity = settings.showStats ? '0.5' : '0';
  807.  
  808. const scoreVal = world.score(world.selected);
  809. const multiplier = (typeof aux.userData?.boost === 'number' && aux.userData.boost > Date.now()) ? 2 : 1;
  810. if (scoreVal * multiplier > world.stats.highestScore) world.stats.highestScore = scoreVal * multiplier;
  811. let scoreHtml;
  812. if (scoreVal <= 0) scoreHtml = '';
  813. else if (settings.separateBoost) {
  814. scoreHtml = `Score: ${Math.floor(scoreVal)}`;
  815. if (multiplier > 1) scoreHtml += ` <span style="color: #fc6;">(X${multiplier})</span>`;
  816. } else {
  817. scoreHtml = 'Score: ' + Math.floor(scoreVal * multiplier);
  818. }
  819. score.innerHTML = scoreHtml;
  820.  
  821. const con = net.connections.get(view);
  822. let measuresText = `${Math.floor(render.fps)} FPS`;
  823. if (con?.latency !== undefined) {
  824. const spectateCon = net.connections.get(world.viewId.spectate);
  825. if (settings.spectatorLatency && spectateCon?.latency !== undefined) {
  826. measuresText += ` ${Math.floor(con.latency)}ms (${Math.floor(spectateCon.latency)}ms) ping`;
  827. } else {
  828. measuresText += ` ${Math.floor(con.latency)}ms ping`;
  829. }
  830. }
  831. measures.textContent = measuresText;
  832. };
  833.  
  834. /** @param {object | undefined} stats */
  835. const updateStats = (stats) => {
  836. if (!stats) {
  837. misc.textContent = '';
  838. return;
  839. }
  840.  
  841. let uptime;
  842. if (stats.uptime < 60) {
  843. uptime = Math.floor(stats.uptime) + 's';
  844. } else {
  845. uptime = Math.floor(stats.uptime / 60 % 60) + 'min';
  846. if (stats.uptime >= 60 * 60)
  847. uptime = Math.floor(stats.uptime / 60 / 60 % 24) + 'hr ' + uptime;
  848. if (stats.uptime >= 24 * 60 * 60)
  849. uptime = Math.floor(stats.uptime / 24 / 60 / 60 % 60) + 'd ' + uptime;
  850. }
  851.  
  852. misc.textContent = [
  853. `${stats.name} (${stats.gamemode})`,
  854. `${stats.external} / ${stats.limit} players`,
  855. // bots do not count towards .playing **except in my private server**
  856. `${stats.playing} playing` + (stats.internal > 0 ? ` + ${stats.internal} bots` : ''),
  857. `${stats.spectating} spectating`,
  858. `${(stats.loadTime / 40 * 100).toFixed(1)}% load @ ${uptime}`,
  859. ].join('\r\n');
  860. };
  861.  
  862. /** @type {object | undefined} */
  863. let lastStats;
  864. setInterval(() => { // update as frequently as possible
  865. const currentStats = world.views.get(world.selected)?.stats;
  866. if (currentStats !== lastStats) updateStats(lastStats = currentStats);
  867. });
  868.  
  869. return { update };
  870. })();
  871.  
  872. ui.leaderboard = (() => {
  873. const container = document.createElement('div');
  874. container.style.cssText = 'position: fixed; top: 10px; right: 10px; width: 200px; height: fit-content; \
  875. user-select: none; z-index: 2; background: #0006; padding: 15px 5px; transform-origin: top right; \
  876. display: none;';
  877. document.body.appendChild(container);
  878.  
  879. const title = document.createElement('div');
  880. title.style.cssText = 'font-family: Ubuntu; font-size: 30px; color: #fff; text-align: center; width: 100%;';
  881. title.textContent = 'Leaderboard';
  882. container.appendChild(title);
  883.  
  884. const linesContainer = document.createElement('div');
  885. linesContainer.style.cssText = 'font-family: Ubuntu; font-size: 20px; line-height: 1.2; width: 100%; \
  886. height: fit-content; text-align: center; white-space: pre; overflow: hidden;';
  887. container.appendChild(linesContainer);
  888.  
  889. /** @type {HTMLDivElement[]} */
  890. const lines = [];
  891. /** @param {{ me: boolean, name: string, sub: boolean, place: number | undefined }[]} lb */
  892. function update(lb) {
  893. const fontFamily = `"${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
  894. if (linesContainer.style.fontFamily !== fontFamily)
  895. linesContainer.style.fontFamily = title.style.fontFamily = fontFamily;
  896.  
  897. const friends = /** @type {any} */ (window).sigmod?.friend_names;
  898. const friendSettings = /** @type {any} */ (window).sigmod?.friends_settings;
  899. lb.forEach((entry, i) => {
  900. let line = lines[i];
  901. if (!line) {
  902. line = document.createElement('div');
  903. line.style.display = 'none';
  904. linesContainer.appendChild(line);
  905. lines.push(line);
  906. }
  907.  
  908. line.style.display = 'block';
  909. line.textContent = `${entry.place ?? i + 1}. ${entry.name || 'An unnamed cell'}`;
  910. if (entry.me) line.style.color = '#faa';
  911. else if (friends instanceof Set && friends.has(entry.name) && friendSettings?.highlight_friends)
  912. line.style.color = friendSettings.highlight_color;
  913. else if (entry.sub) line.style.color = '#ffc826';
  914. else line.style.color = '#fff';
  915. });
  916.  
  917. for (let i = lb.length; i < lines.length; ++i)
  918. lines[i].style.display = 'none';
  919.  
  920. container.style.display = lb.length > 0 ? '' : 'none';
  921. container.style.fontWeight = settings.boldUi ? 'bold' : 'normal';
  922. }
  923.  
  924. /** @type {object | undefined} */
  925. let lastLb;
  926. setInterval(() => { // update leaderboard frequently
  927. const currentLb = world.views.get(world.selected)?.leaderboard;
  928. if (currentLb !== lastLb) update((lastLb = currentLb) ?? []);
  929. });
  930. })();
  931.  
  932. /** @type {HTMLElement} */
  933. const mainMenu = aux.require(
  934. document.querySelector('#__line1')?.parentElement,
  935. 'Can\'t find the main menu UI. Try reloading the page?',
  936. );
  937.  
  938. /** @type {HTMLElement} */
  939. const statsContainer = aux.require(
  940. document.querySelector('#__line2'),
  941. 'Can\'t find the death screen UI. Try reloading the page?',
  942. );
  943.  
  944. /** @type {HTMLElement} */
  945. const continueButton = aux.require(
  946. document.querySelector('#continue_button'),
  947. 'Can\'t find the continue button (on death). Try reloading the page?',
  948. );
  949.  
  950. /** @type {HTMLElement | null} */
  951. const menuLinks = document.querySelector('#menu-links');
  952. /** @type {HTMLElement | null} */
  953. const overlay = document.querySelector('#overlays');
  954.  
  955. // sigmod uses this to detect if the menu is closed or not, otherwise this is unnecessary
  956. /** @type {HTMLElement | null} */
  957. const menuWrapper = document.querySelector('#menu-wrapper');
  958.  
  959. let escOverlayVisible = true;
  960. /**
  961. * @param {boolean} [show]
  962. */
  963. ui.toggleEscOverlay = show => {
  964. escOverlayVisible = show ?? !escOverlayVisible;
  965. if (escOverlayVisible) {
  966. mainMenu.style.display = '';
  967. if (overlay) overlay.style.display = '';
  968. if (menuLinks) menuLinks.style.display = '';
  969. if (menuWrapper) menuWrapper.style.display = '';
  970.  
  971. ui.deathScreen.hide();
  972. } else {
  973. mainMenu.style.display = 'none';
  974. if (overlay) overlay.style.display = 'none';
  975. if (menuLinks) menuLinks.style.display = 'none';
  976. if (menuWrapper) menuWrapper.style.display = 'none';
  977. }
  978. };
  979.  
  980. ui.escOverlayVisible = () => escOverlayVisible;
  981.  
  982. ui.deathScreen = (() => {
  983. const deathScreen = {};
  984.  
  985. continueButton.addEventListener('click', () => {
  986. ui.toggleEscOverlay(true);
  987. });
  988.  
  989. // TODO: figure out how this thing works
  990. /** @type {HTMLElement | null} */
  991. const bonus = document.querySelector('#menu__bonus');
  992. if (bonus) bonus.style.display = 'none';
  993.  
  994. deathScreen.check = () => {
  995. if (world.stats.spawnedAt === undefined) return;
  996. if (world.alive()) return;
  997. deathScreen.show();
  998. };
  999.  
  1000. deathScreen.show = () => {
  1001. const foodEatenElement = document.querySelector('#food_eaten');
  1002. if (foodEatenElement)
  1003. foodEatenElement.textContent = world.stats.foodEaten.toString();
  1004.  
  1005. const highestMassElement = document.querySelector('#highest_mass');
  1006. if (highestMassElement)
  1007. highestMassElement.textContent = Math.round(world.stats.highestScore).toString();
  1008.  
  1009. const highestPositionElement = document.querySelector('#top_leaderboard_position');
  1010. if (highestPositionElement)
  1011. highestPositionElement.textContent = world.stats.highestPosition.toString();
  1012.  
  1013. const timeAliveElement = document.querySelector('#time_alive');
  1014. if (timeAliveElement) {
  1015. const time = (performance.now() - (world.stats.spawnedAt ?? 0)) / 1000;
  1016. const hours = Math.floor(time / 60 / 60);
  1017. const mins = Math.floor(time / 60 % 60);
  1018. const seconds = Math.floor(time % 60);
  1019.  
  1020. timeAliveElement.textContent = `${hours ? hours + ' h' : ''} ${mins ? mins + ' m' : ''} `
  1021. + `${seconds ? seconds + ' s' : ''}`;
  1022. }
  1023.  
  1024. statsContainer.classList.remove('line--hidden');
  1025. ui.toggleEscOverlay(false);
  1026. if (overlay) overlay.style.display = '';
  1027. world.stats = { foodEaten: 0, highestPosition: 200, highestScore: 0, spawnedAt: undefined };
  1028. };
  1029.  
  1030. deathScreen.hide = () => {
  1031. statsContainer?.classList.add('line--hidden');
  1032. // ads are managed by the game client
  1033. };
  1034.  
  1035. return deathScreen;
  1036. })();
  1037.  
  1038. ui.minimap = (() => {
  1039. const canvas = document.createElement('canvas');
  1040. canvas.style.cssText = 'position: fixed; bottom: 0; right: 0; background: #0006; width: 200px; \
  1041. height: 200px; z-index: 2; user-select: none;';
  1042. canvas.width = canvas.height = 200;
  1043. document.body.appendChild(canvas);
  1044.  
  1045. const ctx = aux.require(
  1046. canvas.getContext('2d', { willReadFrequently: false }),
  1047. 'Unable to get 2D context for the minimap. This is probably your browser being dumb, maybe reload ' +
  1048. 'the page?',
  1049. );
  1050.  
  1051. return { canvas, ctx };
  1052. })();
  1053.  
  1054. ui.chat = (() => {
  1055. const chat = {};
  1056.  
  1057. const block = aux.require(
  1058. document.querySelector('#chat_block'),
  1059. 'Can\'t find the chat UI. Try reloading the page?',
  1060. );
  1061.  
  1062. /**
  1063. * @param {ParentNode} root
  1064. * @param {string} selector
  1065. */
  1066. function clone(root, selector) {
  1067. /** @type {HTMLElement} */
  1068. const old = aux.require(
  1069. root.querySelector(selector),
  1070. `Can't find this chat element: ${selector}. Try reloading the page?`,
  1071. );
  1072.  
  1073. const el = /** @type {HTMLElement} */ (old.cloneNode(true));
  1074. el.id = '';
  1075. old.style.display = 'none';
  1076. old.insertAdjacentElement('afterend', el);
  1077.  
  1078. return el;
  1079. }
  1080.  
  1081. // can't just replace the chat box - otherwise sigmod can't hide it - so we make its children invisible
  1082. // elements grabbed with clone() are only styled by their class, not id
  1083. const toggle = clone(document, '#chat_vsbltyBtn');
  1084. const scrollbar = clone(document, '#chat_scrollbar');
  1085. const thumb = clone(scrollbar, '#chat_thumb');
  1086.  
  1087. const input = chat.input = /** @type {HTMLInputElement} */ (aux.require(
  1088. document.querySelector('#chat_textbox'),
  1089. 'Can\'t find the chat textbox. Try reloading the page?',
  1090. ));
  1091.  
  1092. // allow zooming in/out on trackpad without moving the UI
  1093. input.style.position = 'fixed';
  1094. toggle.style.position = 'fixed';
  1095. scrollbar.style.position = 'fixed';
  1096.  
  1097. const list = document.createElement('div');
  1098. list.style.cssText = 'width: 400px; height: 182px; position: fixed; bottom: 54px; left: 46px; \
  1099. overflow: hidden; user-select: none; z-index: 301;';
  1100. block.appendChild(list);
  1101.  
  1102. let toggled = true;
  1103. toggle.style.borderBottomLeftRadius = '10px'; // a bug fix :p
  1104. toggle.addEventListener('click', () => {
  1105. toggled = !toggled;
  1106. input.style.display = toggled ? '' : 'none';
  1107. scrollbar.style.display = toggled ? 'block' : 'none';
  1108. list.style.display = toggled ? '' : 'none';
  1109.  
  1110. if (toggled) {
  1111. toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '';
  1112. toggle.style.opacity = '';
  1113. } else {
  1114. toggle.style.borderTopRightRadius = toggle.style.borderBottomRightRadius = '10px';
  1115. toggle.style.opacity = '0.25';
  1116. }
  1117. });
  1118.  
  1119. scrollbar.style.display = 'block';
  1120. let scrollTop = 0; // keep a float here, because list.scrollTop is always casted to an int
  1121. let thumbHeight = 1;
  1122. let lastY;
  1123. thumb.style.height = '182px';
  1124.  
  1125. function updateThumb() {
  1126. thumb.style.bottom = (1 - list.scrollTop / (list.scrollHeight - 182)) * (182 - thumbHeight) + 'px';
  1127. }
  1128.  
  1129. function scroll() {
  1130. if (scrollTop >= list.scrollHeight - 182 - 40) {
  1131. // close to bottom, snap downwards
  1132. list.scrollTop = scrollTop = list.scrollHeight - 182;
  1133. }
  1134.  
  1135. thumbHeight = Math.min(Math.max(182 / list.scrollHeight, 0.1), 1) * 182;
  1136. thumb.style.height = thumbHeight + 'px';
  1137. updateThumb();
  1138. }
  1139.  
  1140. let scrolling = false;
  1141. thumb.addEventListener('mousedown', () => void (scrolling = true));
  1142. addEventListener('mouseup', () => void (scrolling = false));
  1143. addEventListener('mousemove', e => {
  1144. const deltaY = e.clientY - lastY;
  1145. lastY = e.clientY;
  1146.  
  1147. if (!scrolling) return;
  1148. e.preventDefault();
  1149.  
  1150. if (lastY === undefined) {
  1151. lastY = e.clientY;
  1152. return;
  1153. }
  1154.  
  1155. list.scrollTop = scrollTop = Math.min(Math.max(
  1156. scrollTop + deltaY * list.scrollHeight / 182, 0), list.scrollHeight - 182);
  1157. updateThumb();
  1158. });
  1159.  
  1160. let lastWasBarrier = true; // init to true, so we don't print a barrier as the first ever message (ugly)
  1161. /**
  1162. * @param {string} authorName
  1163. * @param {[number, number, number, number]} rgb
  1164. * @param {string} text
  1165. * @param {boolean} server
  1166. */
  1167. chat.add = (authorName, rgb, text, server) => {
  1168. lastWasBarrier = false;
  1169.  
  1170. const container = document.createElement('div');
  1171. const author = document.createElement('span');
  1172. author.style.cssText = `color: ${aux.rgba2hex(...rgb)}; padding-right: 0.75em;`;
  1173. author.textContent = aux.trim(authorName);
  1174. container.appendChild(author);
  1175.  
  1176. const msg = document.createElement('span');
  1177. if (server) msg.style.cssText = `color: ${aux.rgba2hex(...rgb)}`;
  1178. msg.textContent = server ? text : aux.trim(text); // /help text can get cut off
  1179. container.appendChild(msg);
  1180.  
  1181. while (list.children.length > 100)
  1182. list.firstChild?.remove();
  1183.  
  1184. list.appendChild(container);
  1185.  
  1186. scroll();
  1187. };
  1188.  
  1189. chat.barrier = () => {
  1190. if (lastWasBarrier) return;
  1191. lastWasBarrier = true;
  1192.  
  1193. const barrier = document.createElement('div');
  1194. barrier.style.cssText = 'width: calc(100% - 20px); height: 1px; background: #8888; margin: 10px;';
  1195. list.appendChild(barrier);
  1196.  
  1197. scroll();
  1198. };
  1199.  
  1200. chat.matchTheme = () => {
  1201. list.style.color = aux.settings.darkTheme ? '#fffc' : '#000c';
  1202. // make author names darker in light theme
  1203. list.style.filter = aux.settings.darkTheme ? '' : 'brightness(75%)';
  1204. };
  1205.  
  1206. return chat;
  1207. })();
  1208.  
  1209. /** @param {string} msg */
  1210. ui.error = msg => {
  1211. const modal = /** @type {HTMLElement | null} */ (document.querySelector('#errormodal'));
  1212. const desc = document.querySelector('#errormodal p');
  1213. if (desc)
  1214. desc.innerHTML = msg;
  1215.  
  1216. if (modal)
  1217. modal.style.display = 'block';
  1218. };
  1219.  
  1220. // make sure nothing gets cut off on the center menu panel
  1221. const style = document.createElement('style');
  1222. style.innerHTML = '#menu-wrapper > .menu-center { height: fit-content !important; }';
  1223. document.head.appendChild(style);
  1224.  
  1225. // sigmod quick fix
  1226. // TODO does this work?
  1227. (() => {
  1228. // the play timer is inserted below the top-left stats, but because we offset them, we need to offset this
  1229. // too
  1230. const style = document.createElement('style');
  1231. style.textContent = '.playTimer { transform: translate(5px, 10px); }';
  1232. document.head.appendChild(style);
  1233. })();
  1234.  
  1235. return ui;
  1236. })();
  1237.  
  1238.  
  1239.  
  1240. /////////////////////////
  1241. // Create Options Menu //
  1242. /////////////////////////
  1243. const settings = (() => {
  1244. const settings = {
  1245. autoZoom: true,
  1246. background: '',
  1247. blockBrowserKeybinds: false,
  1248. blockNearbyRespawns: false,
  1249. boldUi: false,
  1250. /** @type {'natural' | 'default'} */
  1251. camera: 'default',
  1252. /** @type {'default' | 'instant'} */
  1253. cameraMovement: 'default',
  1254. cameraSmoothness: 2,
  1255. cameraSpawnAnimation: true,
  1256. cellGlow: false,
  1257. cellOpacity: 1,
  1258. cellOutlines: true,
  1259. clans: false,
  1260. clanScaleFactor: 1,
  1261. colorUnderSkin: true,
  1262. drawDelay: 120,
  1263. jellySkinLag: true,
  1264. massBold: false,
  1265. massOpacity: 1,
  1266. massScaleFactor: 1,
  1267. mergeCamera: true,
  1268. multibox: '',
  1269. nameBold: false,
  1270. nameScaleFactor: 1,
  1271. outlineMulti: 0.2,
  1272. // delta's default colors, #ff00aa and #ffffff
  1273. outlineMultiColor: /** @type {[number, number, number, number]} */ ([1, 0, 2/3, 1]),
  1274. outlineMultiInactiveColor: /** @type {[number, number, number, number]} */ ([1, 1, 1, 1]),
  1275. pelletGlow: false,
  1276. rainbowBorder: false,
  1277. scrollFactor: 1,
  1278. selfSkin: '',
  1279. selfSkinMulti: '',
  1280. slowerJellyPhysics: false,
  1281. separateBoost: false,
  1282. showStats: true,
  1283. spectator: false,
  1284. spectatorLatency: false,
  1285. textOutlinesFactor: 1,
  1286. tracer: false,
  1287. unsplittableColor: /** @type {[number, number, number, number]} */ ([1, 1, 1, 1]),
  1288. };
  1289.  
  1290. const settingsExt = {};
  1291. Object.setPrototypeOf(settings, settingsExt);
  1292.  
  1293. try {
  1294. Object.assign(settings, JSON.parse(localStorage.getItem('sigfix') ?? ''));
  1295. } catch (_) { }
  1296.  
  1297. // convert old settings
  1298. {
  1299. if (/** @type {any} */ (settings.multibox) === true) settings.multibox = 'Tab';
  1300. else if (/** @type {any} */ (settings.multibox) === false) settings.multibox = '';
  1301.  
  1302. if (/** @type {any} */ (settings).unsplittableOpacity !== undefined) {
  1303. settings.unsplittableColor = [1, 1, 1, /** @type {any} */ (settings).unsplittableOpacity];
  1304. delete settings.unsplittableOpacity;
  1305. }
  1306.  
  1307. const { autoZoom, multiCamera } = /** @type {any} */ (settings);
  1308. if (multiCamera !== undefined) {
  1309. if (multiCamera === 'natural' || multiCamera === 'delta' || multiCamera === 'weighted') {
  1310. settings.camera = 'natural';
  1311. } else if (multiCamera === 'none') settings.camera = 'default';
  1312.  
  1313. settings.mergeCamera = multiCamera !== 'weighted' && multiCamera !== 'none'; // the two-tab settings
  1314. delete settings.multiCamera;
  1315. }
  1316.  
  1317. if (autoZoom === 'auto') settings.autoZoom = true;
  1318. else if (autoZoom === 'never') settings.autoZoom = false;
  1319. }
  1320.  
  1321. /** @type {(() => void)[]} */
  1322. const onSyncs = [];
  1323. /** @type {(() => void)[]} */
  1324. const onUpdates = [];
  1325.  
  1326. settingsExt.refresh = () => {
  1327. onSyncs.forEach(fn => fn());
  1328. onUpdates.forEach(fn => fn());
  1329. };
  1330.  
  1331. // allow syncing sigfixes settings in case you leave an extra sig tab open for a long time and would lose your
  1332. // changed settings
  1333. const channel = new BroadcastChannel('sigfix-settings');
  1334. channel.addEventListener('message', msg => {
  1335. Object.assign(settings, msg.data);
  1336. settingsExt.refresh();
  1337. });
  1338.  
  1339. /** @type {IDBDatabase | undefined} */
  1340. settingsExt.database = undefined;
  1341. const dbReq = indexedDB.open('sigfix', 1);
  1342. dbReq.addEventListener('success', () => void (settingsExt.database = dbReq.result));
  1343. dbReq.addEventListener('upgradeneeded', () => {
  1344. settingsExt.database = dbReq.result;
  1345. settingsExt.database.createObjectStore('images');
  1346. });
  1347.  
  1348. // #1 : define helper functions
  1349. /**
  1350. * @param {string} html
  1351. * @returns {HTMLElement}
  1352. */
  1353. function fromHTML(html) {
  1354. const div = document.createElement('div');
  1355. div.innerHTML = html;
  1356. return /** @type {HTMLElement} */ (div.firstElementChild);
  1357. }
  1358.  
  1359. function save() {
  1360. localStorage.setItem('sigfix', JSON.stringify(settings));
  1361. channel.postMessage(settings);
  1362. onUpdates.forEach(fn => fn());
  1363. }
  1364.  
  1365. /**
  1366. * @template O, T
  1367. * @typedef {{ [K in keyof O]: O[K] extends T ? K : never }[keyof O]} PropertyOfType
  1368. */
  1369.  
  1370. /** @type {HTMLElement | null} */
  1371. const vanillaModal = document.querySelector('#cm_modal__settings .ctrl-modal__modal');
  1372. if (vanillaModal) vanillaModal.style.width = '440px'; // make modal wider to fit everything properly
  1373.  
  1374. const vanillaMenu = document.querySelector('#cm_modal__settings .ctrl-modal__content');
  1375. vanillaMenu?.appendChild(fromHTML(`
  1376. <div class="menu__item">
  1377. <div style="width: 100%; height: 1px; background: #bfbfbf;"></div>
  1378. </div>
  1379. `));
  1380.  
  1381. /**
  1382. * @template T
  1383. * @param {T | null} el
  1384. * @returns {T}
  1385. */
  1386. const require = el => /** @type {T} */ (el); // aux.require is unnecessary for requiring our own elements
  1387.  
  1388. const vanillaContainer = document.createElement('div');
  1389. vanillaContainer.className = 'menu__item';
  1390. vanillaMenu?.appendChild(vanillaContainer);
  1391.  
  1392. const sigmodContainer = document.createElement('div');
  1393. sigmodContainer.className = 'mod_tab scroll';
  1394. sigmodContainer.style.display = 'none';
  1395.  
  1396. /** @type {{ container: HTMLElement, help: HTMLElement, helpbox: HTMLElement }[]} */
  1397. const containers = [];
  1398.  
  1399. /**
  1400. * @param {string} title
  1401. * @param {{ sigmod: HTMLElement, vanilla: HTMLElement }[]} components
  1402. * @param {() => boolean} show
  1403. * @param {string} help
  1404. */
  1405. const setting = (title, components, show, help) => {
  1406. const vanilla = fromHTML(`
  1407. <div style="height: 25px; position: relative;">
  1408. <div style="height: 25px; line-height: 25px; position: absolute; top: 0; left: 0;">
  1409. <a id="sf-help" style="color: #0009; cursor: help; user-select: none;">(?)</a> ${title}
  1410. </div>
  1411. <div id="sf-components" style="height: 25px; margin-left: 5px; position: absolute; right: 0;
  1412. bottom: 0;"></div>
  1413. <div id="sf-helpbox" style="display: none; position: absolute; top: calc(100% + 5px); left: 20px;
  1414. width: calc(100% - 30px); height: fit-content; padding: 10px; color: #000; background: #fff;
  1415. border: 1px solid #999; border-radius: 4px; z-index: 2;">
  1416. ${help}
  1417. </div>
  1418. </div>
  1419. `);
  1420. const sigmod = fromHTML(`
  1421. <div class="modRowItems justify-sb" style="padding: 5px 10px; position: relative;">
  1422. <span><a id="sfsm-help" style="color: #fff9; cursor: help; user-select: none;">(?)</a> ${title}\
  1423. </span>
  1424. <span class="justify-sb" id="sfsm-components"></span>
  1425. <div id="sfsm-helpbox" style="display: none; position: absolute; top: calc(100% + 5px); left: 30px;
  1426. width: calc(100% - 40px); height: fit-content; padding: 10px; color: #fffe; background: #000;
  1427. border: 1px solid #6871f1; border-radius: 4px; z-index: 2;">${help}</div>
  1428. </div>
  1429. `);
  1430.  
  1431. const vanillaComponents = require(vanilla.querySelector('#sf-components'));
  1432. const sigmodComponents = require(sigmod.querySelector('#sfsm-components'));
  1433. for (const pair of components) {
  1434. vanillaComponents.appendChild(pair.vanilla);
  1435. sigmodComponents.appendChild(pair.sigmod);
  1436. }
  1437.  
  1438. const reshow = () => void (vanilla.style.display = sigmod.style.display = show() ? '' : 'none');
  1439. reshow();
  1440. onUpdates.push(reshow);
  1441.  
  1442. vanillaContainer.appendChild(vanilla);
  1443. sigmodContainer.appendChild(sigmod);
  1444. containers.push({
  1445. container: vanilla,
  1446. help: require(vanilla.querySelector('#sf-help')),
  1447. helpbox: require(vanilla.querySelector('#sf-helpbox')),
  1448. }, {
  1449. container: sigmod,
  1450. help: require(sigmod.querySelector('#sfsm-help')),
  1451. helpbox: require(sigmod.querySelector('#sfsm-helpbox')),
  1452. });
  1453. };
  1454.  
  1455. /**
  1456. * @param {PropertyOfType<typeof settings, number>} property
  1457. * @param {number} initial
  1458. * @param {number} min
  1459. * @param {number} max
  1460. * @param {number} step
  1461. * @param {number} decimals
  1462. */
  1463. const slider = (property, initial, min, max, step, decimals) => {
  1464. /**
  1465. * @param {HTMLInputElement} slider
  1466. * @param {HTMLInputElement} display
  1467. */
  1468. const listen = (slider, display) => {
  1469. const change = () => slider.value = display.value = settings[property].toFixed(decimals);
  1470. onSyncs.push(change);
  1471. change();
  1472.  
  1473. /** @param {HTMLInputElement} input */
  1474. const onInput = input => {
  1475. const value = Number(input.value);
  1476. if (Number.isNaN(value)) return;
  1477.  
  1478. settings[property] = value;
  1479. change();
  1480. save();
  1481. };
  1482. slider.addEventListener('input', () => onInput(slider));
  1483. display.addEventListener('change', () => onInput(display));
  1484. };
  1485.  
  1486. const datalist = `<datalist id="sf-${property}-markers"> <option value="${initial}"></option> </datalist>`;
  1487. const vanilla = fromHTML(`
  1488. <div>
  1489. <input id="sf-${property}" style="display: block; float: left; height: 25px; line-height: 25px;
  1490. margin-left: 5px;" min="${min}" max="${max}" step="${step}" value="${initial}"
  1491. list="sf-${property}-markers" type="range" />
  1492. ${initial !== undefined ? datalist : ''}
  1493. <input id="sf-${property}-display" style="display: block; float: left; height: 25px;
  1494. line-height: 25px; width: 50px; text-align: center; margin-left: 5px;" />
  1495. </div>
  1496. `);
  1497. const sigmod = fromHTML(`
  1498. <span class="justify-sb">
  1499. <input id="sfsm-${property}" style="width: 200px;" type="range" min="${min}" max="${max}"
  1500. step="${step}" value="${initial}" list="sf-${property}-markers" />
  1501. ${initial !== undefined ? datalist : ''}
  1502. <input id="sfsm-${property}-display" class="text-center form-control" style="border: none;
  1503. width: 50px; margin: 0 15px;" />
  1504. </span>
  1505. `);
  1506.  
  1507. listen(require(vanilla.querySelector(`#sf-${property}`)),
  1508. require(vanilla.querySelector(`#sf-${property}-display`)));
  1509. listen(aux.require(sigmod.querySelector(`#sfsm-${property}`), 'no selector match'),
  1510. aux.require(sigmod.querySelector(`#sfsm-${property}-display`), 'no selector match'));
  1511. return { sigmod, vanilla };
  1512. };
  1513.  
  1514. /** @param {PropertyOfType<typeof settings, boolean>} property */
  1515. const checkbox = property => {
  1516. /** @param {HTMLInputElement} input */
  1517. const listen = input => {
  1518. onSyncs.push(() => input.checked = settings[property]);
  1519. input.checked = settings[property];
  1520.  
  1521. input.addEventListener('input', () => {
  1522. settings[property] = input.checked;
  1523. save();
  1524. });
  1525. };
  1526.  
  1527. const vanilla = fromHTML(`<input id="sf-${property}" type="checkbox" />`);
  1528. const sigmod = fromHTML(`
  1529. <div style="margin-right: 25px;">
  1530. <div class="modCheckbox" style="display: inline-block;">
  1531. <input id="sfsm-${property}" type="checkbox" />
  1532. <label class="cbx" for="sfsm-${property}"></label>
  1533. </div>
  1534. </div>
  1535. `);
  1536. listen(/** @type {HTMLInputElement} */ (vanilla));
  1537. listen(require(sigmod.querySelector(`#sfsm-${property}`)));
  1538. return { sigmod, vanilla };
  1539. };
  1540.  
  1541. /**
  1542. * @param {PropertyOfType<typeof settings, string>} property
  1543. */
  1544. const image = property => {
  1545. /**
  1546. * @param {HTMLInputElement} input
  1547. * @param {boolean} isSigmod
  1548. */
  1549. const listen = (input, isSigmod) => {
  1550. onSyncs.push(() => input.value = settings[property]);
  1551. input.value = settings[property];
  1552.  
  1553. input.addEventListener('input', e => {
  1554. if (input.value.startsWith('🖼️')) {
  1555. input.value = settings[property];
  1556. e.preventDefault(); // TODO idk if needed
  1557. return;
  1558. }
  1559.  
  1560. /** @type {string} */ (settings[property]) = input.value;
  1561. save();
  1562. });
  1563. input.addEventListener('dragenter', () => void (input.style.borderColor = '#00ccff'));
  1564. input.addEventListener('dragleave',
  1565. () => void (input.style.borderColor = isSigmod ? 'transparent' : ''));
  1566. input.addEventListener('drop', e => {
  1567. input.style.borderColor = isSigmod ? 'transparent' : '';
  1568. e.preventDefault();
  1569.  
  1570. const file = e.dataTransfer?.files[0];
  1571. if (!file) return;
  1572.  
  1573. const { database } = settingsExt;
  1574. if (!database) return;
  1575.  
  1576. input.value = '(importing)';
  1577.  
  1578. const transaction = database.transaction('images', 'readwrite');
  1579. transaction.objectStore('images').put(file, property);
  1580.  
  1581. transaction.addEventListener('complete', () => {
  1582. /** @type {string} */ (settings[property]) = input.value = `🖼️ ${file.name}`;
  1583. save();
  1584. render.resetDatabaseCache();
  1585. });
  1586. transaction.addEventListener('error', err => {
  1587. input.value = '(failed to load image)';
  1588. console.warn('sigfix database error:', err);
  1589. });
  1590. });
  1591. };
  1592.  
  1593. const placeholder = 'https://i.imgur.com/... or drag here';
  1594. const vanilla = fromHTML(`<input id="sf-${property}" placeholder="${placeholder}" type="text" />`);
  1595. const sigmod = fromHTML(`<input class="modInput" id="sfsm-${property}" placeholder="${placeholder}"
  1596. style="border: 1px solid transparent; width: 250px;" type="text" />`);
  1597. listen(/** @type {HTMLInputElement} */ (vanilla), false);
  1598. listen(/** @type {HTMLInputElement} */ (sigmod), true);
  1599. return { sigmod, vanilla };
  1600. };
  1601.  
  1602. /** @param {PropertyOfType<typeof settings, [number, number, number, number]>} property */
  1603. const color = property => {
  1604. /**
  1605. * @param {HTMLInputElement} input
  1606. * @param {HTMLInputElement} alpha
  1607. */
  1608. const listen = (input, alpha) => {
  1609. const update = () => {
  1610. input.value = aux.rgba2hex6(...settings[property]);
  1611. alpha.value = String(settings[property][3]);
  1612. };
  1613. onSyncs.push(update);
  1614. update();
  1615.  
  1616. const changed = () => {
  1617. settings[property] = aux.hex2rgba(input.value);
  1618. settings[property][3] = Number(alpha.value);
  1619. save();
  1620. };
  1621. input.addEventListener('input', changed);
  1622. alpha.addEventListener('input', changed);
  1623. };
  1624.  
  1625. const vanilla = fromHTML(`
  1626. <div>
  1627. <input id="sf-${property}-alpha" type="range" min="0" max="1" step="0.01" style="width: 100px" />
  1628. <input id="sf-${property}" type="color" />
  1629. </div>
  1630. `);
  1631. const sigmod = fromHTML(`
  1632. <div>
  1633. <input id="sfsm-${property}-alpha" type="range" min="0" max="1" step="0.01" \
  1634. style="width: 100px" />
  1635. <input id="sfsm-${property}" type="color" />
  1636. </div>
  1637. `);
  1638. listen(require(vanilla.querySelector(`#sf-${property}`)),
  1639. require(vanilla.querySelector(`#sf-${property}-alpha`)));
  1640. listen(require(sigmod.querySelector(`#sfsm-${property}`)),
  1641. require(sigmod.querySelector(`#sfsm-${property}-alpha`)));
  1642. return { sigmod, vanilla };
  1643. };
  1644.  
  1645. /**
  1646. * @param {PropertyOfType<typeof settings, string>} property
  1647. * @param {[string, string][]} options
  1648. */
  1649. const dropdown = (property, options) => {
  1650. /** @param {HTMLSelectElement} input */
  1651. const listen = input => {
  1652. onSyncs.push(() => input.value = settings[property]);
  1653. input.value = settings[property];
  1654.  
  1655. input.addEventListener('input', () => {
  1656. /** @type {string} */ (settings[property]) = input.value;
  1657. save();
  1658. });
  1659. };
  1660.  
  1661. const vanilla = fromHTML(`
  1662. <select id="sf-${property}">
  1663. ${options.map(([value, name]) => `<option value="${value}">${name}</option>`).join('\n')}
  1664. </select>
  1665. `);
  1666. const sigmod = fromHTML(`
  1667. <select class="form-control" id="sfsm-${property}" style="width: 250px;">
  1668. ${options.map(([value, name]) => `<option value="${value}">${name}</option>`).join('\n')}
  1669. </select>
  1670. `);
  1671. listen(/** @type {HTMLSelectElement} */ (vanilla));
  1672. listen(/** @type {HTMLSelectElement} */ (sigmod));
  1673. return { sigmod, vanilla };
  1674. };
  1675.  
  1676. /** @param {PropertyOfType<typeof settings, string>} property */
  1677. const keybind = property => {
  1678. /** @param {HTMLInputElement} input */
  1679. const listen = input => {
  1680. onSyncs.push(() => input.value = settings[property]);
  1681. input.value = settings[property];
  1682.  
  1683. input.addEventListener('keydown', e => {
  1684. if (e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') return;
  1685. if (e.code === 'Escape' || e.code === 'Backspace') {
  1686. /** @type {string} */ (settings[property]) = input.value = '';
  1687. } else {
  1688. let key = e.key;
  1689. if (e.ctrlKey && e.key !== 'Control') key = 'Ctrl+' + key;
  1690. if (e.altKey) key = 'Alt+' + key;
  1691. if (e.metaKey) key = 'Cmd+' + key;
  1692. /** @type {string} */ (settings[property]) = input.value = key;
  1693. }
  1694. save();
  1695. e.preventDefault(); // prevent the key being typed in
  1696. });
  1697. };
  1698.  
  1699. const vanilla = fromHTML(`<input id="sf-${property}" placeholder="..." type="text" style="
  1700. text-align: center; width: 80px;" />`);
  1701. const sigmod = fromHTML(`<input class="keybinding" id="sfsm-${property}" placeholder="..."
  1702. style="max-width: 100px; width: 100px;" type="text" />`);
  1703. listen(/** @type {HTMLInputElement} */ (vanilla));
  1704. listen(/** @type {HTMLInputElement} */ (sigmod));
  1705. return { sigmod, vanilla };
  1706. };
  1707.  
  1708. addEventListener('mousedown', ev => {
  1709. for (const { container, help, helpbox } of containers) {
  1710. if (container.contains(/** @type {Node | null} */ (ev.target))) {
  1711. if (ev.target === help) helpbox.style.display = '';
  1712. } else {
  1713. if (helpbox.style.display === '') helpbox.style.display = 'none';
  1714. }
  1715. }
  1716. });
  1717.  
  1718. const separator = (text = '•') => {
  1719. vanillaContainer.appendChild(fromHTML(`<div style="text-align: center; width: 100%;">${text}</div>`));
  1720. sigmodContainer.appendChild(fromHTML(`<span class="text-center">${text}</span>`));
  1721. };
  1722.  
  1723. const newTag = `<span style="padding: 2px 5px; border-radius: 10px; background: #76f; color: #fff;
  1724. font-weight: bold; font-size: 0.95rem; user-select: none;">NEW</span>`;
  1725.  
  1726. // #2 : generate ui for settings
  1727. setting('Draw delay', [slider('drawDelay', 120, 40, 300, 1, 0)], () => true,
  1728. 'How long (in milliseconds) cells will lag behind for. Lower values mean cells will very quickly catch ' +
  1729. 'up to where they actually are.');
  1730. setting('Cell outlines', [checkbox('cellOutlines')], () => true,
  1731. 'Whether the subtle dark outlines around cells (including skins) should draw.');
  1732. setting('Cell opacity', [slider('cellOpacity', 1, 0, 1, 0.005, 3)], () => true,
  1733. 'How opaque cells should be. 1 = fully visible, 0 = invisible. It can be helpful to see the size of a ' +
  1734. 'smaller cell under a big cell.');
  1735. setting('Self skin URL', [image('selfSkin')], () => true,
  1736. 'Direct URL to a custom skin for yourself. Not visible to others.');
  1737. setting('Secondary skin URL', [image('selfSkinMulti')], () => !!settings.multibox,
  1738. 'Direct URL to a custom skin for your secondary multibox tab. Not visible to others.');
  1739. setting('Map background', [image('background')], () => true,
  1740. 'A square background image to use within the entire map border. Images 512x512 and under will be treated ' +
  1741. 'as a repeating pattern, where 50 pixels = 1 grid square.');
  1742. setting('Lines between cell and mouse', [checkbox('tracer')], () => true,
  1743. 'If enabled, draws tracers between all of the cells you ' +
  1744. 'control and your mouse. Useful as a hint to your subconscious about which tab you\'re currently on.');
  1745.  
  1746. separator('• camera •');
  1747. setting('Camera style', [dropdown('camera', [['natural', 'Natural (weighted)'], ['default', 'Default']])],
  1748. () => true,
  1749. 'How the camera focuses on your cells. <br>' +
  1750. '- A "natural" camera follows your center of mass. If you have a lot of small back pieces, they would ' +
  1751. 'barely affect your camera position. <br>' +
  1752. '- The "default" camera focuses on every cell equally. If you have a lot of small back pieces, your ' +
  1753. 'camera would focus on those instead. <br>' +
  1754. 'When one-tab multiboxing, you <b>must</b> use the Natural (weighted) camera style.');
  1755. setting(`Camera movement ${newTag}`,
  1756. [dropdown('cameraMovement', [['default', 'Default'], ['instant', 'Instant']])], () => true,
  1757. 'How the camera moves. <br>' +
  1758. '- "Default" camera movement follows your cell positions, but when a cell dies or splits, it immediately ' +
  1759. 'stops or starts focusing on it. Artificial smoothness is added - you can control that with the ' +
  1760. '"Camera smoothness" setting. <br>' +
  1761. '- "Instant" camera movement exactly follows your cells without lagging behind, gradually focusing more ' +
  1762. 'or less on cells while they split or die. There is no artificial smoothness, but you should use a ' +
  1763. 'higher draw delay (at least 100). You might find this significantly smoother than the default camera.');
  1764. setting('Camera smoothness', [slider('cameraSmoothness', 2, 1, 10, 0.1, 1)],
  1765. () => settings.cameraMovement === 'default',
  1766. 'How slowly the camera lags behind. The default is 2; using 4 moves the camera about twice as slowly, ' +
  1767. 'for example. Setting to 1 removes all camera smoothness.');
  1768. setting('Zoom speed', [slider('scrollFactor', 1, 0.05, 1, 0.05, 2)], () => true,
  1769. 'A smaller zoom speed lets you fine-tune your zoom.');
  1770. setting('Auto-zoom', [checkbox('autoZoom')], () => true,
  1771. 'When enabled, automatically zooms in/out for you based on how big you are.');
  1772. setting('Move camera while spawning', [checkbox('cameraSpawnAnimation')], () => true,
  1773. 'When spawning, normally the camera will take a bit of time to move to where your cell spawned. This ' +
  1774. 'can be disabled.');
  1775.  
  1776. separator('• multibox •');
  1777. setting('Multibox keybind', [keybind('multibox')], () => true,
  1778. 'The key to press for switching multibox tabs. "Tab" is recommended, but you can also use "Ctrl+Tab" and ' +
  1779. 'most other keybinds.');
  1780. setting('One-tab mode', [checkbox('mergeCamera')], () => !!settings.multibox,
  1781. 'When enabled, your camera will focus on both multibox tabs at once. Disable this if you prefer two-tab-' +
  1782. 'style multiboxing. <br>' +
  1783. 'When one-tab multiboxing, you <b>must</b> use the Natural (weighted) camera style.');
  1784. setting('Multibox outline thickness', [slider('outlineMulti', 0.2, 0, 1, 0.01, 2)],
  1785. () => !!settings.multibox,
  1786. 'When multiboxing, rings appear on your cells, the thickness being a % of your cell radius. This only ' +
  1787. 'shows when you\'re near one of your tabs.');
  1788. setting('Current tab outline color', [color('outlineMultiColor')], () => !!settings.multibox,
  1789. 'The color of the rings around your current multibox tab. Only shown when near another tab. The slider ' +
  1790. 'is the outline opacity.');
  1791. setting('Other tab outline color', [color('outlineMultiInactiveColor')], () => !!settings.multibox,
  1792. 'The color of the rings around your other inactive multibox tabs. Only shown when near another tab. The ' +
  1793. 'slider is the outline opacity.');
  1794. setting('Block respawns near other tabs', [checkbox('blockNearbyRespawns')], () => !!settings.multibox,
  1795. 'When enabled, the respawn key (using SigMod) will be disabled if your multibox tabs are close. ' +
  1796. 'This means you can spam the respawn key until your multibox tab spawns nearby.');
  1797.  
  1798. separator('• text •');
  1799. setting('Name scale factor', [slider('nameScaleFactor', 1, 0.5, 2, 0.01, 2)], () => true,
  1800. 'The size multiplier of names.');
  1801. setting('Mass scale factor', [slider('massScaleFactor', 1, 0.5, 4, 0.01, 2)], () => true,
  1802. 'The size multiplier of mass (which is half the size of names).');
  1803. setting('Mass opacity', [slider('massOpacity', 1, 0, 1, 0.01, 2)], () => true,
  1804. 'The opacity of the mass text. You might find it visually appealing to have mass be a little dimmer than ' +
  1805. 'names.');
  1806. setting('Bold name / mass text', [checkbox('nameBold'), checkbox('massBold')], () => true,
  1807. 'Uses the bold Ubuntu font (like Agar.io) for names (left checkbox) or mass (right checkbox).');
  1808. setting('Show clans', [checkbox('clans')], () => true,
  1809. 'When enabled, shows the name of the clan a player is in above their name. ' +
  1810. 'If you turn off names (using SigMod), then player names will be replaced with their clan\'s.');
  1811. setting('Clan scale factor', [slider('clanScaleFactor', 1, 0.5, 4, 0.01, 2)], () => settings.clans,
  1812. 'The size multiplier of a player\'s clan displayed above their name. When names are off, names will be ' +
  1813. 'replaced with clans and use the name scale factor instead.');
  1814. setting('Text outline thickness', [slider('textOutlinesFactor', 1, 0, 2, 0.01, 2)], () => true,
  1815. 'The multiplier of the thickness of the black stroke around names, mass, and clans on cells. You can set ' +
  1816. 'this to 0 to disable outlines AND text shadows.');
  1817.  
  1818. separator('• other •');
  1819. setting('Block all browser keybinds', [checkbox('blockBrowserKeybinds')], () => true,
  1820. 'When enabled, only F11 is allowed to be pressed when in fullscreen. Most other browser and system ' +
  1821. 'keybinds will be disabled.');
  1822. setting('Unsplittable cell outline', [color('unsplittableColor')], () => true,
  1823. 'The color of the ring around cells that cannot split. The slider ');
  1824. setting('Jelly physics skin size lag', [checkbox('jellySkinLag')], () => true,
  1825. 'Jelly physics causes cells to grow and shrink slower than text and skins, making the game more ' +
  1826. 'satisfying. If you have a skin that looks weird only with jelly physics, try turning this off.');
  1827. setting(`Slower jelly physics ${newTag}`, [checkbox('slowerJellyPhysics')], () => true,
  1828. 'Sigmally Fixes normally speeds up the jelly physics animation for it to be tolerable when splitrunning. ' +
  1829. 'If you prefer how it was in the vanilla client (really slow but satisfying), enable this setting.');
  1830. setting('Cell / pellet glow', [checkbox('cellGlow'), checkbox('pelletGlow')], () => true,
  1831. 'When enabled, gives cells or pellets a slight glow. Basically, shaders for Sigmally. This is very ' +
  1832. 'optimized and should not impact performance.');
  1833. setting('Rainbow border', [checkbox('rainbowBorder')], () => true,
  1834. 'Gives the map a rainbow border. So shiny!!!');
  1835. setting('Top UI uses bold text', [checkbox('boldUi')], () => true,
  1836. 'When enabled, the top-left score and stats UI and the leaderboard will use the bold Ubuntu font.');
  1837. setting('Show server stats', [checkbox('showStats')], () => true,
  1838. 'When disabled, hides the top-left server stats including the player count and server uptime.');
  1839. setting('Connect spectating tab', [checkbox('spectator')], () => true,
  1840. 'Automatically connects an extra tab and sets it to spectate #1.');
  1841. setting('Show spectator tab ping', [checkbox('spectatorLatency')], () => settings.spectator,
  1842. 'When enabled, shows another ping measurement for your spectator tab.');
  1843. setting('Separate XP boost from score', [checkbox('separateBoost')], () => true,
  1844. 'If you have an XP boost, your score will be doubled. If you don\'t want that, you can separate the XP ' +
  1845. 'boost from your score.');
  1846. setting('Color under skin', [checkbox('colorUnderSkin')], () => true,
  1847. 'When disabled, transparent skins will be see-through and not show your cell color. Turn this off ' +
  1848. 'if using a bubble skin, for example.');
  1849.  
  1850. // #3 : create options for sigmod
  1851. let sigmodInjection;
  1852. sigmodInjection = setInterval(() => {
  1853. const nav = document.querySelector('.mod_menu_navbar');
  1854. const content = document.querySelector('.mod_menu_content');
  1855. if (!nav || !content) return;
  1856.  
  1857. clearInterval(sigmodInjection);
  1858.  
  1859. content.appendChild(sigmodContainer);
  1860.  
  1861. const navButton = fromHTML('<button class="mod_nav_btn">🔥 Sig Fixes</button>');
  1862. nav.appendChild(navButton);
  1863. navButton.addEventListener('click', () => {
  1864. // basically openModTab() from sigmod
  1865. (/** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.mod_tab'))).forEach(tab => {
  1866. tab.style.opacity = '0';
  1867. setTimeout(() => tab.style.display = 'none', 200);
  1868. });
  1869.  
  1870. (/** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.mod_nav_btn'))).forEach(tab => {
  1871. tab.classList.remove('mod_selected');
  1872. });
  1873.  
  1874. navButton.classList.add('mod_selected');
  1875. setTimeout(() => {
  1876. sigmodContainer.style.display = 'flex';
  1877. setTimeout(() => sigmodContainer.style.opacity = '1', 10);
  1878. }, 200);
  1879. });
  1880. }, 100);
  1881.  
  1882. return settings;
  1883. })();
  1884.  
  1885.  
  1886.  
  1887. ///////////////////////////
  1888. // Setup World Variables //
  1889. ///////////////////////////
  1890. /** @typedef {{
  1891. * id: number,
  1892. * ox: number, nx: number,
  1893. * oy: number, ny: number,
  1894. * or: number, nr: number,
  1895. * jr: number, a: number,
  1896. * rgb: [number, number, number],
  1897. * updated: number, born: number, deadTo: number, deadAt: number | undefined,
  1898. * jagged: boolean, pellet: boolean,
  1899. * name: string, skin: string, sub: boolean, clan: string,
  1900. * }} Cell */
  1901. const world = (() => {
  1902. const world = {};
  1903.  
  1904. // #1 : define cell variables and functions
  1905. /** @type {Map<number, { merged: Cell | undefined, model: Cell | undefined, views: Map<symbol, Cell> }>} */
  1906. world.cells = new Map();
  1907. /** @type {Map<number, { merged: Cell | undefined, model: Cell | undefined, views: Map<symbol, Cell> }>} */
  1908. world.pellets = new Map();
  1909. world.viewId = { // decoupling views like this should make it easier to do n-boxing in the future
  1910. // these could be swapped around at any time (for example, if the spectate tab is promoted)
  1911. primary: Symbol(),
  1912. secondary: Symbol(),
  1913. spectate: Symbol(),
  1914. };
  1915. world.selected = world.viewId.primary;
  1916. /** @type {Map<symbol, {
  1917. * border: { l: number, r: number, t: number, b: number } | undefined,
  1918. * camera: {
  1919. * x: number, tx: number,
  1920. * y: number, ty: number,
  1921. * scale: number, tscale: number,
  1922. * merging: symbol[],
  1923. * updated: number,
  1924. * },
  1925. * leaderboard: { name: string, me: boolean, sub: boolean, place: number | undefined }[],
  1926. * owned: number[],
  1927. * spawned: number,
  1928. * stats: object | undefined,
  1929. * used: number,
  1930. * }>} */
  1931. world.views = new Map();
  1932.  
  1933. world.alive = () => {
  1934. for (const vision of world.views.values()) {
  1935. for (const id of vision.owned) {
  1936. const cell = world.cells.get(id)?.merged;
  1937. // if a cell does not exist yet, we treat it as alive
  1938. if (!cell || cell.deadAt === undefined) return true;
  1939. }
  1940. }
  1941. return false;
  1942. };
  1943.  
  1944. /**
  1945. * @param {symbol} view
  1946. * @param {number} weightExponent
  1947. * @param {number} now
  1948. * @returns {{ mass: number, scale: number, sumX: number, sumY: number, weight: number }}
  1949. */
  1950. world.singleCamera = (view, weightExponent, now) => {
  1951. let mass = 0;
  1952. let r = 0;
  1953. let sumX = 0;
  1954. let sumY = 0;
  1955. let weight = 0;
  1956. for (const id of (world.views.get(view)?.owned ?? [])) {
  1957. const resolution = world.cells.get(id);
  1958. const cell = world.dirtyMerged ? resolution?.views.get(view) : resolution?.merged;
  1959. if (!cell) continue;
  1960.  
  1961. if (settings.cameraMovement === 'instant') {
  1962. const xyr = world.xyr(cell, undefined, now);
  1963. r += xyr.r * xyr.a;
  1964. mass += (xyr.r * xyr.r / 100) * xyr.a;
  1965. const cellWeight = xyr.a * (xyr.r ** weightExponent);
  1966. sumX += xyr.x * cellWeight;
  1967. sumY += xyr.y * cellWeight;
  1968. weight += cellWeight;
  1969. } else { // settings.cameraMovement === 'default'
  1970. if (cell.deadAt !== undefined) continue;
  1971. const xyr = world.xyr(cell, undefined, now);
  1972. r += cell.nr;
  1973. mass += cell.nr * cell.nr / 100;
  1974. sumX += xyr.x * (cell.nr ** weightExponent);
  1975. sumY += xyr.y * (cell.nr ** weightExponent);
  1976. weight += (cell.nr ** weightExponent);
  1977. }
  1978. }
  1979.  
  1980. const scale = Math.min(64 / r, 1) ** 0.4;
  1981. return { mass, scale, sumX, sumY, weight };
  1982. };
  1983.  
  1984. /**
  1985. * @param {symbol} view
  1986. * @param {number} now
  1987. */
  1988. world.camera = (view, now) => {
  1989. // temporary default camera for now
  1990. const vision = world.views.get(view);
  1991. if (!vision) return;
  1992.  
  1993. const dt = (now - vision.camera.updated) / 1000;
  1994. vision.camera.updated = now;
  1995.  
  1996. const weighted = settings.camera !== 'default';
  1997. /** @type {symbol[]} */
  1998. const merging = [];
  1999. /** @type {{ mass: number, sumX: number, sumY: number, weight: number }[]} */
  2000. const mergingCameras = [];
  2001. const desc = world.singleCamera(view, weighted ? 2 : 0, now);
  2002. let xyFactor;
  2003. if (desc.weight > 0) {
  2004. const mainX = desc.sumX / desc.weight;
  2005. const mainY = desc.sumY / desc.weight;
  2006. if (settings.multibox) {
  2007. const mainWeight = desc.weight;
  2008. const mainWidth = 1920 / 2 / desc.scale;
  2009. const mainHeight = 1080 / 2 / desc.scale;
  2010. for (const [otherView, otherVision] of world.views) {
  2011. if (otherView === view) continue;
  2012. if (now - otherVision.used > 20_000) continue; // don't merge with inactive tabs
  2013. const otherDesc = world.singleCamera(otherView, 2, now);
  2014. if (otherDesc.weight <= 0) continue;
  2015.  
  2016. const otherX = otherDesc.sumX / otherDesc.weight;
  2017. const otherY = otherDesc.sumY / otherDesc.weight;
  2018. const otherWidth = 1920 / 2 / otherDesc.scale;
  2019. const otherHeight = 1080 / 2 / otherDesc.scale;
  2020.  
  2021. // only merge with tabs if their vision regions are close. expand threshold depending on
  2022. // how much mass each tab has (if both tabs are large, allow them to go pretty far)
  2023. const threshold = 1000 + Math.min(mainWeight / 100 / 25, otherDesc.weight / 100 / 25);
  2024. if (Math.abs(otherX - mainX) < mainWidth + otherWidth + threshold
  2025. && Math.abs(otherY - mainY) < mainHeight + otherHeight + threshold) {
  2026. merging.push(otherView);
  2027. mergingCameras.push(otherDesc);
  2028. }
  2029. }
  2030. }
  2031.  
  2032. if (settings.multibox && settings.mergeCamera && settings.camera === 'default') {
  2033. // merging the default camera would be absolutely disastrous. no one would ever use it.
  2034. settings.camera = 'natural';
  2035. settings.refresh();
  2036. }
  2037.  
  2038. let mass = desc.mass;
  2039. let targetX, targetY, zoom;
  2040. if (settings.camera === 'default') {
  2041. // default, unweighted, **unmerged** camera
  2042. targetX = desc.sumX / desc.weight;
  2043. targetY = desc.sumY / desc.weight;
  2044. zoom = settings.autoZoom ? desc.scale : 0.25;
  2045. } else { // settings.camera === 'natural'
  2046. targetX = desc.sumX;
  2047. targetY = desc.sumY;
  2048. let totalWeight = desc.weight;
  2049. if (settings.multibox && settings.mergeCamera) {
  2050. for (const camera of mergingCameras) {
  2051. mass += camera.mass;
  2052. targetX += camera.sumX;
  2053. targetY += camera.sumY;
  2054. totalWeight += camera.weight;
  2055. }
  2056. }
  2057. targetX /= totalWeight;
  2058. targetY /= totalWeight;
  2059. const scale = Math.min(64 / Math.sqrt(100 * mass), 1) ** 0.4;
  2060. zoom = settings.autoZoom ? scale : 0.25;
  2061. }
  2062.  
  2063. vision.camera.tx = targetX;
  2064. vision.camera.ty = targetY;
  2065. vision.camera.tscale = zoom;
  2066.  
  2067. if (settings.cameraMovement === 'instant') {
  2068. xyFactor = 1;
  2069. } else {
  2070. // when spawning, move camera quickly (like vanilla), then make it smoother after a bit
  2071. const aliveFor = (performance.now() - vision.spawned) / 1000;
  2072. const a = Math.min(Math.max((aliveFor - 0.3) / 0.3, 0), 1);
  2073. const base = settings.cameraSpawnAnimation ? 2 : 1;
  2074. xyFactor = Math.min(settings.cameraSmoothness, base * (1-a) + settings.cameraSmoothness * a);
  2075. }
  2076. } else {
  2077. xyFactor = 20;
  2078. }
  2079.  
  2080. vision.camera.x = aux.exponentialEase(vision.camera.x, vision.camera.tx, xyFactor, dt);
  2081. vision.camera.y = aux.exponentialEase(vision.camera.y, vision.camera.ty, xyFactor, dt);
  2082. vision.camera.scale = aux.exponentialEase(vision.camera.scale, input.zoom * vision.camera.tscale, 9, dt);
  2083. vision.camera.merging = merging;
  2084. };
  2085.  
  2086. /** @param {symbol} view */
  2087. world.create = view => {
  2088. const old = world.views.get(view);
  2089. if (old) return old;
  2090.  
  2091. const vision = {
  2092. border: undefined,
  2093. camera: { x: 0, tx: 0, y: 0, ty: 0, scale: 0, tscale: 0, merging: [], updated: performance.now() - 1 },
  2094. leaderboard: [],
  2095. owned: [],
  2096. spawned: -Infinity,
  2097. stats: undefined,
  2098. used: -Infinity,
  2099. };
  2100. world.views.set(view, vision);
  2101. return vision;
  2102. };
  2103.  
  2104. /** @type {number | undefined} */
  2105. let disagreementStart = undefined;
  2106. world.dirtyMerged = false;
  2107. world.merge = (stable = false) => {
  2108. const now = performance.now();
  2109. if (world.views.size <= 1 || stable) {
  2110. // no-merge strategy (stable)
  2111. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2112. for (const resolution of world[key].values()) {
  2113. resolution.merged = resolution.views.get(world.selected);
  2114. }
  2115. }
  2116. world.dirtyMerged = true;
  2117. } else { // "flawless" merging
  2118. // for camera merging to look extremely smooth, we need to merge packets and apply them *ONLY* when all
  2119. // tabs are synchronized.
  2120. // if you simply fall back to what the other tabs see, you will get lots of flickering and warping (what
  2121. // delta suffers from).
  2122. // threfore, we make sure that all tabs that share visible cells see them in the same spots, to make
  2123. // sure they are all on the same tick.
  2124. // it's also not sufficient to simply count how many update (0x10) packets we get, as /leaveworld (part
  2125. // of respawn functionality) stops those packets from coming in.
  2126. // if the view areas are disjoint, then there's nothing we can do but this should never happen when
  2127. // splitrunning
  2128. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2129. for (const resolution of world[key].values()) {
  2130. /** @type {Cell | undefined} */
  2131. let model;
  2132. for (const cell of resolution.views.values()) {
  2133. if (!model) {
  2134. model = cell;
  2135. continue;
  2136. }
  2137.  
  2138. const modelDisappeared = model.deadAt !== undefined && model.deadTo === -1;
  2139. const cellDisappeared = cell.deadAt !== undefined && cell.deadTo === -1;
  2140. if (!modelDisappeared && !cellDisappeared) {
  2141. // both cells are visible; are they at the same place?
  2142. if (model.nx !== cell.nx || model.ny !== cell.ny || model.nr !== cell.nr) {
  2143. // disagreement! if we haven't agreed for more than 200ms, skip flawless merging
  2144. // for now, until that pesky tab comes back
  2145. disagreementStart ??= now;
  2146. if (now - disagreementStart > 200) world.merge(true);
  2147. return;
  2148. }
  2149. } else if (modelDisappeared && !cellDisappeared) {
  2150. // model went out of view; prefer the visible cell
  2151. model = cell;
  2152. } else if (!modelDisappeared && cellDisappeared) {
  2153. // cell went out of view; prefer the model
  2154. } else { // modelDisappeared && cellDisappeared
  2155. // both cells disappeared; prefer the one that disappeared last
  2156. if (/** @type {number} */ (cell.deadAt) > /** @type {number} */ (model.deadAt)) {
  2157. model = cell;
  2158. }
  2159. }
  2160. }
  2161. // we don't want to maintain a separate map for models because indexes are very expensive
  2162. resolution.model = model;
  2163. }
  2164. }
  2165.  
  2166. // all views are synced; merge according to the models
  2167. disagreementStart = undefined;
  2168. if (world.dirtyMerged) {
  2169. // if `merged` uses references from other tabs, then that can cause very bad bugginess when those
  2170. // cells die!
  2171. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2172. for (const resolution of world[key].values()) {
  2173. resolution.merged = undefined;
  2174. }
  2175. }
  2176. world.dirtyMerged = false;
  2177. }
  2178.  
  2179. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2180. for (const resolution of world[key].values()) {
  2181. const { merged, model } = resolution;
  2182. if (!model) {
  2183. resolution.merged = undefined;
  2184. continue;
  2185. }
  2186.  
  2187. if (!merged) {
  2188. // merged cell doesn't exist; only make it if the cell didn't immediately die
  2189. // otherwise, it would just stay transparent
  2190. if (model.deadAt === undefined) {
  2191. resolution.merged = {
  2192. id: model.id,
  2193. ox: model.nx, nx: model.nx,
  2194. oy: model.ny, ny: model.ny,
  2195. or: model.nr, nr: model.nr,
  2196. jr: model.nr, a: model.a,
  2197. rgb: model.rgb,
  2198. jagged: model.jagged, pellet: model.pellet,
  2199. name: model.name, skin: model.skin, sub: model.sub, clan: model.clan,
  2200. born: model.born, updated: now,
  2201. deadAt: undefined, deadTo: -1,
  2202. };
  2203. }
  2204. } else {
  2205. // merged cell *does* exist, move it if the cell is not currently dead
  2206. if (model.deadAt === undefined) {
  2207. if (merged.deadAt === undefined) {
  2208. const { x, y, r, jr, a } = world.xyr(merged, undefined, now);
  2209. merged.ox = x;
  2210. merged.oy = y;
  2211. merged.or = r;
  2212. merged.jr = jr;
  2213. merged.a = a;
  2214. } else {
  2215. // came back to life (probably back into view)
  2216. merged.ox = model.nx;
  2217. merged.oy = model.ny;
  2218. merged.or = model.nr;
  2219. merged.jr = model.jr;
  2220. merged.deadAt = undefined;
  2221. merged.deadTo = -1;
  2222. merged.born = now;
  2223. }
  2224. merged.nx = model.nx;
  2225. merged.ny = model.ny;
  2226. merged.nr = model.nr;
  2227. merged.updated = now;
  2228. } else {
  2229. // model died; only kill/update the merged cell once
  2230. if (merged.deadAt === undefined) {
  2231. merged.deadAt = now;
  2232. merged.deadTo = model.deadTo;
  2233. merged.updated = now;
  2234. }
  2235. }
  2236. }
  2237. }
  2238. }
  2239. }
  2240. };
  2241.  
  2242. /** @param {symbol} view */
  2243. world.score = view => {
  2244. let score = 0;
  2245. for (const id of (world.views.get(view)?.owned ?? [])) {
  2246. const cell = world.cells.get(id)?.merged;
  2247. if (!cell || cell.deadAt !== undefined) continue;
  2248. score += cell.nr * cell.nr / 100; // use exact score as given by the server, no interpolation
  2249. }
  2250.  
  2251. return score;
  2252. };
  2253.  
  2254. /**
  2255. * @param {Cell} cell
  2256. * @param {Cell | undefined} killer
  2257. * @param {number} now
  2258. * @returns {{ x: number, y: number, r: number, jr: number, a: number }}
  2259. */
  2260. world.xyr = (cell, killer, now) => {
  2261. let nx = cell.nx;
  2262. let ny = cell.ny;
  2263. if (killer && cell.deadAt !== undefined && (killer.deadAt === undefined || cell.deadAt <= killer.deadAt)) {
  2264. // do not animate death towards a cell that died already (went offscreen)
  2265. nx = killer.nx;
  2266. ny = killer.ny;
  2267. }
  2268.  
  2269. let x, y, r, a;
  2270. if (cell.pellet && cell.deadAt === undefined) {
  2271. x = nx;
  2272. y = ny;
  2273. r = cell.nr;
  2274. a = 1;
  2275. } else {
  2276. let alpha = (now - cell.updated) / settings.drawDelay;
  2277. alpha = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha;
  2278.  
  2279. x = cell.ox + (nx - cell.ox) * alpha;
  2280. y = cell.oy + (ny - cell.oy) * alpha;
  2281. r = cell.or + (cell.nr - cell.or) * alpha;
  2282.  
  2283. const targetA = cell.deadAt !== undefined ? 0 : 1;
  2284. a = cell.a + (targetA - cell.a) * alpha;
  2285. }
  2286.  
  2287. const dt = (now - cell.updated) / 1000;
  2288.  
  2289. return {
  2290. x, y, r,
  2291. jr: aux.exponentialEase(cell.jr, r, settings.slowerJellyPhysics ? 10 : 5, dt),
  2292. a,
  2293. };
  2294. };
  2295.  
  2296. // clean up dead, invisible cells ONLY before uploading pellets
  2297. let lastClean = performance.now();
  2298. world.clean = () => {
  2299. const now = performance.now();
  2300. if (now - lastClean < 200) return;
  2301. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2302. for (const [id, resolution] of world[key]) {
  2303. for (const [view, cell] of resolution.views) {
  2304. if (cell.deadAt !== undefined && now - cell.deadAt >= settings.drawDelay + 200)
  2305. resolution.views.delete(view);
  2306. }
  2307.  
  2308. if (resolution.views.size === 0) world[key].delete(id);
  2309. }
  2310. }
  2311. };
  2312.  
  2313.  
  2314.  
  2315. // #2 : define stats
  2316. world.stats = {
  2317. foodEaten: 0,
  2318. highestPosition: 200,
  2319. highestScore: 0,
  2320. /** @type {number | undefined} */
  2321. spawnedAt: undefined,
  2322. };
  2323.  
  2324.  
  2325.  
  2326. return world;
  2327. })();
  2328.  
  2329.  
  2330.  
  2331. //////////////////////////
  2332. // Setup All Networking //
  2333. //////////////////////////
  2334. const net = (() => {
  2335. const net = {};
  2336.  
  2337. // #1 : define state
  2338. /** @type {Map<symbol, {
  2339. * handshake: { shuffle: Uint8Array, unshuffle: Uint8Array } | undefined,
  2340. * latency: number | undefined,
  2341. * opened: boolean,
  2342. * pinged: number | undefined,
  2343. * rejected: boolean,
  2344. * respawnBlock: { status: 'left' | 'pending', started: number } | undefined,
  2345. * ws: WebSocket | undefined,
  2346. * }>} */
  2347. net.connections = new Map();
  2348.  
  2349. /** @param {symbol} view */
  2350. net.create = view => {
  2351. if (net.connections.has(view)) return;
  2352.  
  2353. net.connections.set(view, {
  2354. handshake: undefined,
  2355. latency: undefined,
  2356. opened: false,
  2357. pinged: undefined,
  2358. rejected: false,
  2359. respawnBlock: undefined,
  2360. ws: connect(view),
  2361. });
  2362. };
  2363.  
  2364. /**
  2365. * @param {symbol} view
  2366. * @returns {WebSocket | undefined}
  2367. */
  2368. const connect = view => {
  2369. if (net.connections.get(view)?.ws) return; // already being handled by another process
  2370.  
  2371. // do not allow sigmod's args[0].includes('sigmally.com') check to pass
  2372. const realUrl = net.url();
  2373. const fakeUrl = /** @type {any} */ ({ includes: () => false, toString: () => realUrl });
  2374. let ws;
  2375. try {
  2376. ws = new WebSocket(fakeUrl);
  2377. } catch (err) {
  2378. console.error('can\'t make WebSocket:', err);
  2379. aux.require(null, `The server address "${realUrl}" is invalid. Try changing the server, reloading ` +
  2380. 'the page, and clearing your browser cache.');
  2381. return; // ts-check is dumb
  2382. }
  2383.  
  2384. {
  2385. const con = net.connections.get(view);
  2386. if (con) con.ws = ws;
  2387. }
  2388.  
  2389. ws.binaryType = 'arraybuffer';
  2390. ws.addEventListener('close', () => {
  2391. const connection = net.connections.get(view);
  2392. const vision = world.views.get(view);
  2393. if (!connection || !vision) return; // if the entry no longer exists, don't reconnect
  2394.  
  2395. connection.handshake = undefined;
  2396. connection.latency = undefined;
  2397. connection.pinged = undefined;
  2398. connection.respawnBlock = undefined;
  2399. if (!connection.opened) connection.rejected = true;
  2400. connection.opened = false;
  2401.  
  2402. vision.border = undefined;
  2403. // don't reset vision.camera
  2404. vision.owned = [];
  2405. vision.leaderboard = [];
  2406. vision.spawned = -Infinity;
  2407. vision.stats = undefined;
  2408.  
  2409. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2410. for (const [id, resolution] of world[key]) {
  2411. resolution.views.delete(view);
  2412. if (resolution.views.size === 0) world[key].delete(id);
  2413. }
  2414. }
  2415.  
  2416. connection.ws = undefined;
  2417.  
  2418. if (connection.rejected && net.connections.get(world.viewId.spectate)?.handshake
  2419. && view !== world.viewId.spectate) {
  2420. // "promote" spectator tab, swap with disconnected multi
  2421. const key = view === world.viewId.primary ? 'primary' : 'secondary';
  2422. [world.viewId[key], world.viewId.spectate] = [world.viewId.spectate, world.viewId[key]];
  2423.  
  2424. connect(world.viewId.spectate);
  2425. } else {
  2426. setTimeout(() => connect(view), connection.rejected ? 3000 : 0);
  2427. }
  2428.  
  2429. world.merge();
  2430. render.upload('pellets');
  2431. });
  2432. ws.addEventListener('error', () => {});
  2433. ws.addEventListener('message', e => {
  2434. const connection = net.connections.get(view);
  2435. const vision = world.views.get(view);
  2436. if (!connection || !vision) return ws.close();
  2437. const dat = new DataView(e.data);
  2438.  
  2439. if (!connection.handshake) {
  2440. // skip version "SIG 0.0.1\0"
  2441. let o = 10;
  2442.  
  2443. const shuffle = new Uint8Array(256);
  2444. const unshuffle = new Uint8Array(256);
  2445. for (let i = 0; i < 256; ++i) {
  2446. const shuffled = dat.getUint8(o + i);
  2447. shuffle[i] = shuffled;
  2448. unshuffle[shuffled] = i;
  2449. }
  2450.  
  2451. connection.handshake = { shuffle, unshuffle };
  2452. return;
  2453. }
  2454.  
  2455. // do this so the packet can easily be sent to sigmod afterwards
  2456. dat.setUint8(0, connection.handshake.unshuffle[dat.getUint8(0)]);
  2457.  
  2458. const now = performance.now();
  2459. let o = 1;
  2460. switch (dat.getUint8(0)) {
  2461. case 0x10: { // world update
  2462. // (a) : kills / consumes
  2463. const killCount = dat.getUint16(o, true);
  2464. o += 2;
  2465. for (let i = 0; i < killCount; ++i) {
  2466. const killerId = dat.getUint32(o, true);
  2467. const killedId = dat.getUint32(o + 4, true);
  2468. o += 8;
  2469.  
  2470. const killed = (world.pellets.get(killedId) ?? world.cells.get(killedId))?.views.get(view);
  2471. if (killed) {
  2472. killed.deadAt = killed.updated = now;
  2473. killed.deadTo = killerId;
  2474. if (killed.pellet && vision.owned.includes(killerId)) {
  2475. ++world.stats.foodEaten;
  2476. net.food(view); // dumbass quest code go brrr
  2477. }
  2478. }
  2479. }
  2480.  
  2481. // (b) : updates
  2482. do {
  2483. const id = dat.getUint32(o, true);
  2484. o += 4;
  2485. if (id === 0) break;
  2486.  
  2487. const x = dat.getInt16(o, true);
  2488. const y = dat.getInt16(o + 2, true);
  2489. const r = dat.getUint16(o + 4, true);
  2490. const flags = dat.getUint8(o + 6);
  2491. // (void 1 byte, "isUpdate")
  2492. // (void 1 byte, "isPlayer")
  2493. const sub = !!dat.getUint8(o + 9);
  2494. o += 10;
  2495.  
  2496. let clan; [clan, o] = aux.readZTString(dat, o);
  2497.  
  2498. /** @type {[number, number, number] | undefined} */
  2499. let rgb;
  2500. if (flags & 0x02) { // update color
  2501. rgb = [dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, dat.getUint8(o++) / 255];
  2502. }
  2503.  
  2504. let skin = '';
  2505. if (flags & 0x04) { // update skin
  2506. [skin, o] = aux.readZTString(dat, o);
  2507. skin = aux.parseSkin(skin);
  2508. }
  2509.  
  2510. let name = '';
  2511. if (flags & 0x08) { // update name
  2512. [name, o] = aux.readZTString(dat, o);
  2513. name = aux.parseName(name);
  2514. if (name) render.textFromCache(name, sub); // make sure the texture is ready on render
  2515. }
  2516.  
  2517. const jagged = !!(flags & 0x11); // spiked or agitated
  2518. const eject = !!(flags & 0x20);
  2519. const pellet = r <= 40 && !eject; // tourney servers have bigger pellets (r=40)
  2520. const cell = (pellet ? world.pellets : world.cells).get(id)?.views.get(view);
  2521. if (cell && cell.deadAt === undefined) {
  2522. const { x: ix, y: iy, r: ir, jr, a } = world.xyr(cell, undefined, now);
  2523. cell.ox = ix;
  2524. cell.oy = iy;
  2525. cell.or = ir;
  2526. cell.jr = jr;
  2527. cell.a = a;
  2528. cell.nx = x; cell.ny = y; cell.nr = r;
  2529. cell.jagged = jagged;
  2530. cell.updated = now;
  2531.  
  2532. cell.clan = clan;
  2533. cell.rgb = rgb ?? cell.rgb;
  2534. if (skin) cell.skin = skin;
  2535. if (name) cell.name = name;
  2536. cell.sub = sub;
  2537. } else {
  2538. if (cell?.deadAt !== undefined) {
  2539. // when respawning, OgarII does not send the description of cells if you spawn in
  2540. // the same area, despite those cells being deleted from your view area
  2541. if (rgb === undefined) ({ rgb } = cell);
  2542. name ||= cell.name; // note the || and not ??
  2543. skin ||= cell.skin;
  2544. }
  2545.  
  2546. /** @type {Cell} */
  2547. const ncell = {
  2548. id,
  2549. ox: x, nx: x,
  2550. oy: y, ny: y,
  2551. or: r, nr: r,
  2552. jr: r, a: 0,
  2553. rgb: rgb ?? [0.5, 0.5, 0.5],
  2554. jagged, pellet,
  2555. updated: now, born: now,
  2556. deadAt: undefined, deadTo: -1,
  2557. name, skin, sub, clan,
  2558. };
  2559. let resolution = world[pellet ? 'pellets' : 'cells'].get(id);
  2560. if (!resolution) {
  2561. resolution = { merged: undefined, model: undefined, views: new Map() };
  2562. world[pellet ? 'pellets' : 'cells'].set(id, resolution);
  2563. }
  2564. resolution.views.set(view, ncell);
  2565. }
  2566. } while (true);
  2567.  
  2568. // (c) : deletes
  2569. const deleteCount = dat.getUint16(o, true);
  2570. o += 2;
  2571. for (let i = 0; i < deleteCount; ++i) {
  2572. const deletedId = dat.getUint32(o, true);
  2573. o += 4;
  2574.  
  2575. const deleted
  2576. = (world.pellets.get(deletedId) ?? world.cells.get(deletedId))?.views.get(view);
  2577. if (deleted && deleted.deadAt === undefined) {
  2578. deleted.deadAt = now;
  2579. deleted.deadTo = -1;
  2580. }
  2581. }
  2582.  
  2583. // (d) : finalize, upload data
  2584. world.merge();
  2585. world.clean();
  2586. render.upload('pellets');
  2587.  
  2588. // (e) : clear own cells that don't exist anymore (NOT on world.clean!)
  2589. for (let i = 0; i < vision.owned.length; ++i) {
  2590. if (world.cells.has(vision.owned[i])) {
  2591. // only disable respawnBlock once we're definitely alive
  2592. if (connection.respawnBlock?.status === 'left') connection.respawnBlock = undefined;
  2593. continue;
  2594. }
  2595.  
  2596. vision.owned.splice(i--, 1);
  2597. }
  2598. ui.deathScreen.check();
  2599. break;
  2600. }
  2601.  
  2602. case 0x11: { // update camera pos
  2603. vision.camera.tx = dat.getFloat32(o, true);
  2604. vision.camera.ty = dat.getFloat32(o + 4, true);
  2605. vision.camera.tscale = dat.getFloat32(o + 8, true);
  2606. break;
  2607. }
  2608.  
  2609. case 0x12: { // delete all cells
  2610. // happens every time you respawn
  2611. if (connection.respawnBlock?.status === 'pending') connection.respawnBlock.status = 'left';
  2612.  
  2613. // DO NOT just clear the maps! when respawning, OgarII will not resend cell data if we spawn
  2614. // nearby.
  2615. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2616. for (const resolution of world[key].values()) {
  2617. const cell = resolution.views.get(view);
  2618. if (cell && cell.deadAt === undefined) cell.deadAt = now;
  2619. }
  2620. }
  2621. world.merge();
  2622. render.upload('pellets');
  2623. // passthrough
  2624. }
  2625. case 0x14: { // delete my cells
  2626. vision.owned = [];
  2627. // only reset spawn time if no other tab is alive.
  2628. // this could be cheated (if you alternate respawning your tabs, for example) but i don't think
  2629. // multiboxers ever see the stats menu anyway
  2630. if (!world.alive()) world.stats.spawnedAt = undefined;
  2631. ui.deathScreen.check(); // don't trigger death screen on respawn
  2632. break;
  2633. }
  2634.  
  2635. case 0x20: { // new owned cell
  2636. // check if this is the first owned cell
  2637. let first = true;
  2638. let firstThis = true;
  2639. for (const [otherView, otherVision] of world.views) {
  2640. for (const id of otherVision.owned) {
  2641. const cell = world.cells.get(id)?.views.get(otherView);
  2642. if (!cell || cell.deadAt !== undefined) continue;
  2643. first = false;
  2644. if (otherVision === vision) firstThis = false;
  2645. break;
  2646. }
  2647. }
  2648. if (first) world.stats.spawnedAt = now;
  2649. if (firstThis) vision.spawned = now;
  2650.  
  2651. vision.owned.push(dat.getUint32(o, true));
  2652. break;
  2653. }
  2654.  
  2655. // case 0x30 is a text list (not a numbered list), leave unsupported
  2656. case 0x31: { // ffa leaderboard list
  2657. const lb = [];
  2658. const count = dat.getUint32(o, true);
  2659. o += 4;
  2660.  
  2661. /** @type {number | undefined} */
  2662. let myPosition;
  2663. for (let i = 0; i < count; ++i) {
  2664. const me = !!dat.getUint32(o, true);
  2665. o += 4;
  2666.  
  2667. let name; [name, o] = aux.readZTString(dat, o);
  2668. name = aux.parseName(name);
  2669.  
  2670. // why this is copied into every leaderboard entry is beyond my understanding
  2671. myPosition = dat.getUint32(o, true);
  2672. const sub = !!dat.getUint32(o + 4, true);
  2673. o += 8;
  2674.  
  2675. lb.push({ name, sub, me, place: undefined });
  2676. }
  2677.  
  2678. if (myPosition) { // myPosition could be zero
  2679. if (myPosition - 1 >= lb.length) {
  2680. const nick = world.selected === world.viewId.primary
  2681. ? input.nick1.value : input.nick2.value;
  2682. lb.push({
  2683. me: true,
  2684. name: aux.parseName(nick),
  2685. place: myPosition,
  2686. sub: false, // doesn't matter
  2687. });
  2688. }
  2689.  
  2690. world.stats.highestPosition = Math.min(world.stats.highestPosition, myPosition);
  2691. }
  2692.  
  2693. vision.leaderboard = lb;
  2694. break;
  2695. }
  2696.  
  2697. case 0x40: { // border update
  2698. vision.border = {
  2699. l: dat.getFloat64(o, true),
  2700. t: dat.getFloat64(o + 8, true),
  2701. r: dat.getFloat64(o + 16, true),
  2702. b: dat.getFloat64(o + 24, true),
  2703. };
  2704. break;
  2705. }
  2706.  
  2707. case 0x63: { // chat message
  2708. // only handle chat messages on the primary tab, to prevent duplicate messages
  2709. // this means that command responses won't be shown on the secondary tab but who actually cares
  2710. if (view !== world.viewId.primary) return;
  2711. const flags = dat.getUint8(o++);
  2712. const rgb = /** @type {[number, number, number, number]} */
  2713. ([dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, dat.getUint8(o++) / 255, 1]);
  2714.  
  2715. let name; [name, o] = aux.readZTString(dat, o);
  2716. let msg; [msg, o] = aux.readZTString(dat, o);
  2717. ui.chat.add(name, rgb, msg, !!(flags & 0x80));
  2718. break;
  2719. }
  2720.  
  2721. case 0xb4: { // incorrect password alert
  2722. ui.error('Password is incorrect');
  2723. break;
  2724. }
  2725.  
  2726. case 0xdd: {
  2727. // request for analytics (idk if used anymore) (previously "howarewelosingmoney")
  2728. sendJson(view, 0xd0, { session: null });
  2729. break;
  2730. }
  2731.  
  2732. case 0xfe: { // server stats (in response to a ping)
  2733. let statString; [statString, o] = aux.readZTString(dat, o);
  2734. vision.stats = JSON.parse(statString);
  2735. if (connection.pinged !== undefined) connection.latency = now - connection.pinged;
  2736. connection.pinged = undefined;
  2737. break;
  2738. }
  2739. }
  2740.  
  2741. sigmod.proxy.handleMessage?.(dat);
  2742. });
  2743. ws.addEventListener('open', () => {
  2744. const connection = net.connections.get(view);
  2745. const vision = world.views.get(view);
  2746. if (!connection || !vision) return ws.close();
  2747.  
  2748. connection.rejected = false;
  2749. connection.opened = true;
  2750.  
  2751. vision.camera.x = vision.camera.tx = 0;
  2752. vision.camera.y = vision.camera.ty = 0;
  2753. vision.camera.scale = input.zoom;
  2754. vision.camera.tscale = 1;
  2755. ws.send(aux.textEncoder.encode('SIG 0.0.1\x00'));
  2756. });
  2757.  
  2758. return ws;
  2759. };
  2760.  
  2761. // ping loop
  2762. setInterval(() => {
  2763. for (const connection of net.connections.values()) {
  2764. if (!connection.handshake || connection.ws?.readyState !== WebSocket.OPEN) continue;
  2765. connection.pinged = performance.now();
  2766. connection.ws.send(connection.handshake.shuffle.slice(0xfe, 0xfe + 1));
  2767. }
  2768. }, 2000);
  2769.  
  2770. // #2 : define helper functions
  2771. /** @type {HTMLSelectElement | null} */
  2772. const gamemode = document.querySelector('#gamemode');
  2773. /** @type {HTMLOptionElement | null} */
  2774. const firstGamemode = document.querySelector('#gamemode option');
  2775. net.url = () => {
  2776. if (location.search.startsWith('?ip=')) return location.search.slice('?ip='.length);
  2777. else return 'wss://' + (gamemode?.value || firstGamemode?.value || 'ca0.sigmally.com/ws/');
  2778. };
  2779.  
  2780. // disconnect if a different gamemode is selected
  2781. // an interval is preferred because the game can apply its gamemode setting *after* connecting without
  2782. // triggering any events
  2783. setInterval(() => {
  2784. for (const connection of net.connections.values()) {
  2785. if (!connection.ws) continue;
  2786. if (connection.ws.readyState !== WebSocket.CONNECTING && connection.ws.readyState !== WebSocket.OPEN)
  2787. continue;
  2788. if (connection.ws.url === net.url()) continue;
  2789. connection.ws.close();
  2790. }
  2791. }, 200);
  2792.  
  2793. /**
  2794. * @param {symbol} view
  2795. * @param {number} opcode
  2796. * @param {object} data
  2797. */
  2798. const sendJson = (view, opcode, data) => {
  2799. // must check readyState as a weboscket might be in the 'CLOSING' state (so annoying!)
  2800. const connection = net.connections.get(view);
  2801. if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
  2802. const dataBuf = aux.textEncoder.encode(JSON.stringify(data));
  2803. const dat = new DataView(new ArrayBuffer(dataBuf.byteLength + 2));
  2804.  
  2805. dat.setUint8(0, connection.handshake.shuffle[opcode]);
  2806. for (let i = 0; i < dataBuf.byteLength; ++i) {
  2807. dat.setUint8(1 + i, dataBuf[i]);
  2808. }
  2809. connection.ws.send(dat);
  2810. };
  2811.  
  2812. // #5 : export input functions
  2813. /**
  2814. * @param {symbol} view
  2815. * @param {number} x
  2816. * @param {number} y
  2817. */
  2818. net.move = (view, x, y) => {
  2819. const connection = net.connections.get(view);
  2820. if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
  2821. const dat = new DataView(new ArrayBuffer(13));
  2822.  
  2823. dat.setUint8(0, connection.handshake.shuffle[0x10]);
  2824. dat.setInt32(1, x, true);
  2825. dat.setInt32(5, y, true);
  2826. connection.ws.send(dat);
  2827.  
  2828. sigmod.proxy.isPlaying?.(); // without this, the respawn key will build up timeouts and make the game laggy
  2829. };
  2830.  
  2831. /** @param {number} opcode */
  2832. const bindOpcode = opcode => /** @param {symbol} view */ view => {
  2833. const connection = net.connections.get(view);
  2834. if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
  2835. connection.ws.send(connection.handshake.shuffle.slice(opcode, opcode + 1));
  2836. };
  2837. net.w = bindOpcode(21);
  2838. net.qdown = bindOpcode(18);
  2839. net.qup = bindOpcode(19);
  2840. net.split = bindOpcode(17);
  2841. // quests
  2842. net.food = bindOpcode(0xc0);
  2843. net.time = bindOpcode(0xbf);
  2844.  
  2845. // reversed argument order for sigmod compatibility
  2846. /**
  2847. * @param {string} msg
  2848. * @param {symbol=} view
  2849. */
  2850. net.chat = (msg, view = world.selected) => {
  2851. const connection = net.connections.get(view);
  2852. if (!connection?.handshake || connection.ws?.readyState !== WebSocket.OPEN) return;
  2853. const msgBuf = aux.textEncoder.encode(msg);
  2854. const dat = new DataView(new ArrayBuffer(msgBuf.byteLength + 3));
  2855.  
  2856. dat.setUint8(0, connection.handshake.shuffle[0x63]);
  2857. // skip flags
  2858. for (let i = 0; i < msgBuf.byteLength; ++i) {
  2859. dat.setUint8(2 + i, msgBuf[i]);
  2860. }
  2861. connection.ws.send(dat);
  2862. };
  2863.  
  2864. /**
  2865. * @param {symbol} view
  2866. * @param {{ name: string, skin: string, [x: string]: any }} data
  2867. */
  2868. net.play = (view, data) => {
  2869. sendJson(view, 0x00, data);
  2870. };
  2871.  
  2872. // create initial connection
  2873. world.create(world.viewId.primary);
  2874. net.create(world.viewId.primary);
  2875. let lastChangedSpectate = -Infinity;
  2876. setInterval(() => {
  2877. if (!settings.multibox) world.selected = world.viewId.primary;
  2878. if (settings.spectator) {
  2879. const vision = world.create(world.viewId.spectate);
  2880. net.create(world.viewId.spectate);
  2881. net.play(world.viewId.spectate, { name: '', skin: '', clan: aux.userData?.clan, state: 2 });
  2882.  
  2883. // only press Q to toggle once in a while, in case ping is above 200
  2884. const now = performance.now();
  2885. if (now - lastChangedSpectate > 1000) {
  2886. if (vision.camera.tscale > 0.39) { // when roaming, the spectate scale is set to ~0.4
  2887. net.qdown(world.viewId.spectate);
  2888. lastChangedSpectate = now;
  2889. }
  2890. } else {
  2891. net.qup(world.viewId.spectate); // doubly serves as anti-afk
  2892. }
  2893. } else {
  2894. const con = net.connections.get(world.viewId.spectate);
  2895. if (con?.ws && con?.ws.readyState !== WebSocket.CLOSED && con?.ws.readyState !== WebSocket.CLOSING) {
  2896. con?.ws.close();
  2897. }
  2898. net.connections.delete(world.viewId.spectate);
  2899. world.views.delete(world.viewId.spectate);
  2900. input.views.delete(world.viewId.spectate);
  2901.  
  2902. for (const key of /** @type {const} */ (['cells', 'pellets'])) {
  2903. for (const [id, resolution] of world[key]) {
  2904. resolution.views.delete(world.viewId.spectate);
  2905. if (resolution.views.size === 0) world[key].delete(id);
  2906. }
  2907. }
  2908. }
  2909. }, 200);
  2910.  
  2911. // dumbass quest code go brrr
  2912. setInterval(() => {
  2913. for (const view of net.connections.keys()) net.time(view);
  2914. }, 1000);
  2915.  
  2916. return net;
  2917. })();
  2918.  
  2919.  
  2920.  
  2921. //////////////////////////
  2922. // Setup Input Handlers //
  2923. //////////////////////////
  2924. const input = (() => {
  2925. const input = {};
  2926.  
  2927. // #1 : general inputs
  2928. // between -1 and 1
  2929. /** @type {[number, number]} */
  2930. input.current = [0, 0];
  2931. /** @type {Map<symbol, {
  2932. * forceW: boolean,
  2933. * lock: { mouse: [number, number], world: [number, number], until: number } | undefined,
  2934. * mouse: [number, number], // between -1 and 1
  2935. * w: boolean,
  2936. * world: [number, number], // world position; only updates when tab is selected
  2937. * }>} */
  2938. input.views = new Map();
  2939. input.zoom = 1;
  2940.  
  2941. /** @param {symbol} view */
  2942. const create = view => {
  2943. const old = input.views.get(view);
  2944. if (old) return old;
  2945.  
  2946. /** @type {typeof input.views extends Map<symbol, infer T> ? T : never} */
  2947. const inputs = { forceW: false, lock: undefined, mouse: [0, 0], w: false, world: [0, 0] };
  2948. input.views.set(view, inputs);
  2949. return inputs;
  2950. };
  2951.  
  2952. /**
  2953. * @param {symbol} view
  2954. * @param {[number, number]} x, y
  2955. * @returns {[number, number]}
  2956. */
  2957. input.toWorld = (view, [x, y]) => {
  2958. const camera = world.views.get(view)?.camera;
  2959. if (!camera) return [0, 0];
  2960. return [
  2961. camera.x + x * (innerWidth / innerHeight) * 540 / camera.scale,
  2962. camera.y + y * 540 / camera.scale,
  2963. ];
  2964. };
  2965.  
  2966. // sigmod freezes the player by overlaying an invisible div, so we just listen for canvas movements instead
  2967. addEventListener('mousemove', e => {
  2968. if (ui.escOverlayVisible()) return;
  2969. // sigmod freezes the player by overlaying an invisible div, so we respect it
  2970. if (e.target instanceof HTMLDivElement
  2971. && /** @type {CSSUnitValue | undefined} */ (e.target.attributeStyleMap.get('z-index'))?.value === 99)
  2972. return;
  2973. input.current = [(e.clientX / innerWidth * 2) - 1, (e.clientY / innerHeight * 2) - 1];
  2974. });
  2975.  
  2976. const unfocused = () => ui.escOverlayVisible() || document.activeElement?.tagName === 'INPUT';
  2977.  
  2978. /**
  2979. * @param {symbol} view
  2980. * @param {boolean} forceUpdate
  2981. */
  2982. input.move = (view, forceUpdate) => {
  2983. const now = performance.now();
  2984. const inputs = input.views.get(view) ?? create(view);
  2985. if (inputs.lock && now <= inputs.lock.until) {
  2986. const d = Math.hypot(input.current[0] - inputs.lock.mouse[0], input.current[1] - inputs.lock.mouse[1]);
  2987. // only lock the mouse as long as the mouse has not moved further than 25% (of 2) of the screen away
  2988. if (d < 0.5) {
  2989. net.move(view, ...inputs.lock.world);
  2990. return;
  2991. }
  2992. }
  2993.  
  2994. inputs.lock = undefined;
  2995. if (world.selected === view || forceUpdate) {
  2996. inputs.world = input.toWorld(view, inputs.mouse = input.current);
  2997. }
  2998. net.move(view, ...inputs.world);
  2999. };
  3000.  
  3001. setInterval(() => {
  3002. create(world.selected);
  3003. for (const [view, inputs] of input.views) {
  3004. input.move(view, false);
  3005. // if tapping W very fast, make sure at least one W is ejected
  3006. if (inputs.forceW || inputs.w) net.w(view);
  3007. inputs.forceW = false;
  3008. }
  3009. }, 40);
  3010.  
  3011. /** @type {Node | null} */
  3012. let sigmodChat;
  3013. setInterval(() => sigmodChat ||= document.querySelector('.modChat'), 500);
  3014. addEventListener('wheel', e => {
  3015. if (unfocused()) return;
  3016. // when scrolling through sigmod chat, don't allow zooming.
  3017. // for consistency, use the container .modChat and not #mod-messages as #mod-messages can have zero height
  3018. if (sigmodChat && sigmodChat.contains(/** @type {Node} */ (e.target))) return;
  3019. // support for the very obscure "scroll by page" setting in windows
  3020. // i don't think browsers support DOM_DELTA_LINE, so assume DOM_DELTA_PIXEL otherwise
  3021. const deltaY = e.deltaMode === e.DOM_DELTA_PAGE ? e.deltaY : e.deltaY / 100;
  3022. input.zoom *= 0.8 ** (deltaY * settings.scrollFactor);
  3023. const minZoom = (!settings.multibox && !aux.settings.zoomout) ? 1 : 0.8 ** 15;
  3024. input.zoom = Math.min(Math.max(input.zoom, minZoom), 0.8 ** -21);
  3025. });
  3026.  
  3027. addEventListener('keydown', e => {
  3028. const view = world.selected;
  3029. const inputs = input.views.get(view) ?? create(view);
  3030.  
  3031. let keybind = e.key;
  3032. if (e.ctrlKey) keybind = 'Ctrl+' + keybind;
  3033. if (e.altKey) keybind = 'Alt+' + keybind;
  3034. if (e.metaKey) keybind = 'Cmd+' + keybind;
  3035.  
  3036. // never allow pressing Tab by itself
  3037. if (e.code === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) e.preventDefault();
  3038.  
  3039. if (settings.multibox && keybind.toLowerCase() === settings.multibox.toLowerCase()) {
  3040. e.preventDefault(); // prevent selecting anything on the page
  3041.  
  3042. inputs.w = false; // stop current tab from feeding; don't change forceW
  3043. // update mouse immediately (after setTimeout, when mouse events happen)
  3044. setTimeout(() => inputs.world = input.toWorld(view, inputs.mouse = input.current));
  3045.  
  3046. // swap tabs
  3047. if (world.selected === world.viewId.primary) world.selected = world.viewId.secondary;
  3048. else world.selected = world.viewId.primary;
  3049. world.create(world.selected);
  3050. net.create(world.selected);
  3051.  
  3052. // also, press play on the current tab ONLY if any tab is alive
  3053. if (world.alive()) {
  3054. const name = world.selected === world.viewId.primary ? input.nick1.value : input.nick2.value;
  3055. net.play(world.selected, playData(name, false));
  3056. }
  3057. return;
  3058. }
  3059.  
  3060. if (e.code === 'Escape') {
  3061. if (document.activeElement === ui.chat.input) ui.chat.input.blur();
  3062. else ui.toggleEscOverlay();
  3063. return;
  3064. }
  3065.  
  3066. if (unfocused()) {
  3067. if (e.code === 'Enter' && document.activeElement === ui.chat.input && ui.chat.input.value.length > 0) {
  3068. net.chat(ui.chat.input.value.slice(0, 15), world.selected);
  3069. ui.chat.input.value = '';
  3070. ui.chat.input.blur();
  3071. }
  3072.  
  3073. return;
  3074. }
  3075.  
  3076. if (settings.blockBrowserKeybinds) {
  3077. if (e.code === 'F11') {
  3078. // force true fullscreen to make sure Ctrl+W and other binds are caught.
  3079. // not well supported on safari
  3080. if (!document.fullscreenElement) {
  3081. document.body.requestFullscreen?.()?.catch(() => {});
  3082. /** @type {any} */ (navigator).keyboard?.lock()?.catch(() => {});
  3083. } else {
  3084. document.exitFullscreen?.()?.catch(() => {});
  3085. /** @type {any} */ (navigator).keyboard?.unlock()?.catch(() => {});
  3086. }
  3087. }
  3088.  
  3089. if (e.code !== 'Tab') e.preventDefault(); // allow ctrl+tab and alt+tab
  3090. } else if (e.ctrlKey && e.code === 'KeyW') {
  3091. e.preventDefault(); // doesn't seem to work for me, but works for others
  3092. }
  3093.  
  3094. // if fast feed is rebound, only allow the spoofed W's from sigmod
  3095. let fastFeeding = e.code === 'KeyW';
  3096. if (sigmod.settings.rapidFeedKey && sigmod.settings.rapidFeedKey !== 'w') {
  3097. fastFeeding &&= !e.isTrusted;
  3098. }
  3099. if (fastFeeding) inputs.forceW = inputs.w = true;
  3100.  
  3101. switch (e.code) {
  3102. case 'KeyQ':
  3103. if (!e.repeat) net.qdown(world.selected);
  3104. break;
  3105. case 'Space': {
  3106. if (!e.repeat) {
  3107. // send mouse position immediately, so the split will go in the correct direction.
  3108. // setTimeout is used to ensure that our mouse position is actually updated (it comes after
  3109. // keydown events)
  3110. setTimeout(() => {
  3111. input.move(view, true);
  3112. net.split(view);
  3113. });
  3114. }
  3115. break;
  3116. }
  3117. case 'Enter': {
  3118. ui.chat.input.focus();
  3119. break;
  3120. }
  3121. }
  3122.  
  3123. if (e.isTrusted && e.key.toLowerCase() === sigmod.settings.tripleKey?.toLowerCase()) {
  3124. inputs.lock ||= {
  3125. mouse: inputs.mouse,
  3126. world: input.toWorld(world.selected, inputs.mouse),
  3127. until: performance.now() + 650,
  3128. };
  3129. }
  3130. });
  3131.  
  3132. addEventListener('keyup', e => {
  3133. // allow inputs if unfocused
  3134. if (e.code === 'KeyQ') net.qup(world.selected);
  3135. else if (e.code === 'KeyW') {
  3136. const inputs = input.views.get(world.selected) ?? create(world.selected);
  3137. inputs.w = false; // don't change forceW
  3138. }
  3139. });
  3140.  
  3141. // prompt before closing window
  3142. addEventListener('beforeunload', e => e.preventDefault());
  3143.  
  3144. // prevent right clicking on the game
  3145. ui.game.canvas.addEventListener('contextmenu', e => e.preventDefault());
  3146.  
  3147. // prevent dragging when some things are selected - i have a habit of unconsciously clicking all the time,
  3148. // making me regularly drag text, disabling my mouse inputs for a bit
  3149. addEventListener('dragstart', e => e.preventDefault());
  3150.  
  3151.  
  3152.  
  3153. // #2 : play and spectate buttons, and captcha
  3154. /**
  3155. * @param {string} name
  3156. * @param {boolean} spectating
  3157. */
  3158. const playData = (name, spectating) => {
  3159. /** @type {HTMLInputElement | null} */
  3160. const password = document.querySelector('input#password');
  3161.  
  3162. return {
  3163. state: spectating ? 2 : undefined,
  3164. name,
  3165. skin: aux.userData ? aux.settings.skin : '',
  3166. token: aux.token?.token,
  3167. sub: (aux.userData?.subscription ?? 0) > Date.now(),
  3168. clan: aux.userData?.clan,
  3169. showClanmates: aux.settings.showClanmates,
  3170. password: password?.value,
  3171. };
  3172. };
  3173.  
  3174. /** @type {HTMLInputElement} */
  3175. input.nick1 = aux.require(document.querySelector('input#nick'),
  3176. 'Can\'t find the nickname element. Try reloading the page?');
  3177.  
  3178. input.nick2 = /** @type {HTMLInputElement} */ (input.nick1?.cloneNode(true));
  3179. input.nick2.style.display = settings.multibox ? '' : 'none';
  3180. setInterval(() => input.nick2.style.display = settings.multibox ? '' : 'none', 200);
  3181. input.nick2.placeholder = 'Nickname #2';
  3182. // sigmod probably won't apply this to the second nick element, so we do it ourselves too
  3183. input.nick1.maxLength = input.nick2.maxLength = 50;
  3184.  
  3185. // place nick2 on a separate row
  3186. const row = /** @type {Element | null} */ (input.nick1.parentElement?.cloneNode());
  3187. if (row) {
  3188. row.appendChild(input.nick2);
  3189. input.nick1.parentElement?.insertAdjacentElement('afterend', row);
  3190. }
  3191.  
  3192. /** @type {HTMLButtonElement} */
  3193. const play = aux.require(document.querySelector('button#play-btn'),
  3194. 'Can\'t find the play button. Try reloading the page?');
  3195. /** @type {HTMLButtonElement} */
  3196. const spectate = aux.require(document.querySelector('button#spectate-btn'),
  3197. 'Can\'t find the spectate button. Try reloading the page?');
  3198.  
  3199. play.disabled = spectate.disabled = true;
  3200.  
  3201. (async () => {
  3202. const mount = document.createElement('div');
  3203. mount.id = 'sf-captcha-mount';
  3204. mount.style.display = 'none';
  3205. play.parentNode?.insertBefore(mount, play);
  3206.  
  3207. /** @type {Set<() => void> | undefined} */
  3208. let onGrecaptchaReady = new Set();
  3209. /** @type {Set<() => void> | undefined} */
  3210. let onTurnstileReady = new Set();
  3211. let grecaptcha, turnstile, CAPTCHA2, CAPTCHA3, TURNSTILE;
  3212.  
  3213. let readyCheck;
  3214. readyCheck = setInterval(() => {
  3215. // it's possible that recaptcha or turnstile may be removed in the future, so we be redundant to stay
  3216. // safe
  3217. if (onGrecaptchaReady) {
  3218. ({ grecaptcha, CAPTCHA2, CAPTCHA3 } = /** @type {any} */ (window));
  3219. if (grecaptcha?.ready && CAPTCHA2 && CAPTCHA3) {
  3220. const handlers = onGrecaptchaReady;
  3221. onGrecaptchaReady = undefined;
  3222.  
  3223. grecaptcha.ready(() => {
  3224. handlers.forEach(cb => cb());
  3225. // prevent game.js from using grecaptcha and messing things up
  3226. ({ grecaptcha } = /** @type {any} */ (window));
  3227. /** @type {any} */ (window).grecaptcha = {
  3228. execute: () => { },
  3229. ready: () => { },
  3230. render: () => { },
  3231. reset: () => { },
  3232. };
  3233. });
  3234. }
  3235. }
  3236.  
  3237. if (onTurnstileReady) {
  3238. ({ turnstile, TURNSTILE } = /** @type {any} */ (window));
  3239. if (turnstile?.ready && TURNSTILE) {
  3240. const handlers = onTurnstileReady;
  3241. onTurnstileReady = undefined;
  3242. handlers.forEach(cb => cb());
  3243.  
  3244. // prevent game.js from using turnstile and messing things up
  3245. /** @type {any} */ (window).turnstile = {
  3246. execute: () => { },
  3247. ready: () => { },
  3248. render: () => { },
  3249. reset: () => { },
  3250. };
  3251. }
  3252. }
  3253.  
  3254. if (!onGrecaptchaReady && !onTurnstileReady)
  3255. clearInterval(readyCheck);
  3256. }, 50);
  3257.  
  3258. /**
  3259. * @param {string} url
  3260. * @returns {Promise<string>}
  3261. */
  3262. const tokenVariant = async url => {
  3263. const host = new URL(url).host;
  3264. if (host.includes('sigmally.com'))
  3265. return aux.oldFetch(`https://${host}/server/recaptcha/v3`)
  3266. .then(res => res.json())
  3267. .then(res => res.version ?? 'none');
  3268. else
  3269. return Promise.resolve('none');
  3270. };
  3271.  
  3272. /** @type {unique symbol} */
  3273. const waiting = Symbol();
  3274. let nextTryAt = 0;
  3275. /** @type {undefined | typeof waiting | { variant: string, token: string | undefined }} */
  3276. let token = undefined;
  3277. /** @type {string | undefined} */
  3278. let turnstileHandle;
  3279. /** @type {number | undefined} */
  3280. let v2Handle;
  3281.  
  3282. /**
  3283. * @param {string} url
  3284. * @param {string} variant
  3285. * @param {string | undefined} captchaToken
  3286. */
  3287. const publishToken = (url, variant, captchaToken) => {
  3288. const url2 = net.url();
  3289. if (url !== url2) {
  3290. token = { variant, token: captchaToken };
  3291. return;
  3292. }
  3293.  
  3294. const complete = () => {
  3295. token = undefined;
  3296. play.disabled = spectate.disabled = false;
  3297. for (const con of net.connections.values()) {
  3298. con.rejected = false; // wait until we try connecting again
  3299. }
  3300. };
  3301.  
  3302. if (variant === 'none') {
  3303. complete();
  3304. return;
  3305. }
  3306.  
  3307. const host = new URL(url).host;
  3308. aux.oldFetch(`https://${host}/server/recaptcha/v3`, {
  3309. method: 'POST',
  3310. headers: { 'content-type': 'application/json' },
  3311. body: JSON.stringify({ token: captchaToken }),
  3312. })
  3313. .then(res => res.json())
  3314. .then(res => {
  3315. if (res.status === 'complete') {
  3316. complete();
  3317. } else if (res.status === 'wait') {
  3318. setTimeout(() => publishToken(url, variant, captchaToken), 1000);
  3319. } else {
  3320. token = undefined;
  3321. }
  3322. })
  3323. .catch(err => {
  3324. token = undefined;
  3325. nextTryAt = performance.now() + 3000;
  3326. throw err;
  3327. });
  3328. };
  3329.  
  3330. setInterval(() => {
  3331. const con = net.connections.get(world.selected);
  3332. if (!con) return;
  3333.  
  3334. const canPlay = !net.rejected && con?.ws?.readyState === WebSocket.OPEN;
  3335. if (play.disabled !== !canPlay) {
  3336. play.disabled = spectate.disabled = !canPlay;
  3337. }
  3338.  
  3339. if (token === waiting) return;
  3340. if (!con.rejected) return;
  3341.  
  3342. const url = net.url();
  3343.  
  3344. if (typeof token !== 'object') {
  3345. // get a new token if first time, or if we're on a new connection now
  3346. if (performance.now() < nextTryAt) return;
  3347.  
  3348. token = waiting;
  3349. play.disabled = spectate.disabled = true;
  3350. tokenVariant(url)
  3351. .then(async variant => {
  3352. const url2 = net.url();
  3353. if (url !== url2) {
  3354. // server changed and may want a different variant; restart
  3355. token = undefined;
  3356. return;
  3357. }
  3358.  
  3359. if (variant === 'v2') {
  3360. mount.style.display = 'block';
  3361. play.style.display = spectate.style.display = 'none';
  3362. if (v2Handle !== undefined) {
  3363. grecaptcha.reset(v2Handle);
  3364. } else {
  3365. const cb = () => void (v2Handle = grecaptcha.render('sf-captcha-mount', {
  3366. sitekey: CAPTCHA2,
  3367. callback: v2 => {
  3368. mount.style.display = 'none';
  3369. play.style.display = spectate.style.display = '';
  3370. publishToken(url, variant, v2);
  3371. },
  3372. }));
  3373. if (onGrecaptchaReady)
  3374. onGrecaptchaReady.add(cb);
  3375. else
  3376. grecaptcha.ready(cb);
  3377. }
  3378. } else if (variant === 'v3') {
  3379. const cb = () => grecaptcha.execute(CAPTCHA3)
  3380. .then(v3 => publishToken(url, variant, v3));
  3381. if (onGrecaptchaReady)
  3382. onGrecaptchaReady.add(cb);
  3383. else
  3384. grecaptcha.ready(cb);
  3385. } else if (variant === 'turnstile') {
  3386. mount.style.display = 'block';
  3387. play.style.display = spectate.style.display = 'none';
  3388. if (turnstileHandle !== undefined) {
  3389. turnstile.reset(turnstileHandle);
  3390. } else {
  3391. const cb = () => void (turnstileHandle = turnstile.render('#sf-captcha-mount', {
  3392. sitekey: TURNSTILE,
  3393. callback: turnstileToken => {
  3394. mount.style.display = 'none';
  3395. play.style.display = spectate.style.display = '';
  3396. publishToken(url, variant, turnstileToken);
  3397. },
  3398. }));
  3399. if (onTurnstileReady)
  3400. onTurnstileReady.add(cb);
  3401. else
  3402. cb();
  3403. }
  3404. } else {
  3405. // server wants "none" or unknown token variant; don't show a captcha
  3406. publishToken(url, variant, undefined);
  3407. }
  3408. }).catch(err => {
  3409. token = undefined;
  3410. nextTryAt = performance.now() + 3000;
  3411. console.warn('Error while getting token variant:', err);
  3412. });
  3413. } else {
  3414. // token is ready to be used, check variant
  3415. const got = token;
  3416. token = waiting;
  3417. play.disabled = spectate.disabled = true;
  3418. tokenVariant(url)
  3419. .then(variant2 => {
  3420. if (got.variant !== variant2) {
  3421. // server wants a different token variant
  3422. token = undefined;
  3423. } else
  3424. publishToken(url, got.variant, got.token);
  3425. }).catch(err => {
  3426. token = got;
  3427. nextTryAt = performance.now() + 3000;
  3428. console.warn('Error while getting token variant:', err);
  3429. });
  3430. }
  3431. }, 100);
  3432.  
  3433. /** @param {MouseEvent} e */
  3434. async function clickHandler(e) {
  3435. const name = world.selected === world.viewId.primary ? input.nick1.value : input.nick2.value;
  3436.  
  3437. const con = net.connections.get(world.selected);
  3438. if (!con || con.rejected) return;
  3439. ui.toggleEscOverlay(false);
  3440. net.play(world.selected, playData(name, e.currentTarget === spectate));
  3441. }
  3442.  
  3443. play.addEventListener('click', clickHandler);
  3444. spectate.addEventListener('click', clickHandler);
  3445. })();
  3446.  
  3447. return input;
  3448. })();
  3449.  
  3450.  
  3451.  
  3452. //////////////////////////
  3453. // Configure WebGL Data //
  3454. //////////////////////////
  3455. const glconf = (() => {
  3456. // note: WebGL functions only really return null if the context is lost - in which case, data will be replaced
  3457. // anyway after it's restored. so, we cast everything to a non-null type.
  3458. const glconf = {};
  3459. const programs = glconf.programs = {};
  3460. const uniforms = glconf.uniforms = {};
  3461. /** @type {WebGLBuffer} */
  3462. glconf.pelletAlphaBuffer = /** @type {never} */ (undefined);
  3463. /** @type {WebGLBuffer} */
  3464. glconf.pelletBuffer = /** @type {never} */ (undefined);
  3465. /** @type {{
  3466. * vao: WebGLVertexArrayObject,
  3467. * circleBuffer: WebGLBuffer,
  3468. * alphaBuffer: WebGLBuffer,
  3469. * alphaBufferSize: number }[]} */
  3470. glconf.vao = [];
  3471.  
  3472. const gl = ui.game.gl;
  3473. /** @type {Map<string, number>} */
  3474. const uboBindings = new Map();
  3475.  
  3476. /**
  3477. * @param {string} name
  3478. * @param {number} type
  3479. * @param {string} source
  3480. */
  3481. function shader(name, type, source) {
  3482. const s = /** @type {WebGLShader} */ (gl.createShader(type));
  3483. gl.shaderSource(s, source);
  3484. gl.compileShader(s);
  3485.  
  3486. // note: compilation errors should not happen in production
  3487. aux.require(
  3488. gl.getShaderParameter(s, gl.COMPILE_STATUS) || gl.isContextLost(),
  3489. `Can\'t compile WebGL2 shader "${name}". You might be on a weird browser.\n\nFull error log:\n` +
  3490. gl.getShaderInfoLog(s),
  3491. );
  3492.  
  3493. return s;
  3494. }
  3495.  
  3496. /**
  3497. * @param {string} name
  3498. * @param {string} vSource
  3499. * @param {string} fSource
  3500. * @param {string[]} ubos
  3501. * @param {string[]} textures
  3502. */
  3503. function program(name, vSource, fSource, ubos, textures) {
  3504. const vShader = shader(`${name}.vShader`, gl.VERTEX_SHADER, vSource.trim());
  3505. const fShader = shader(`${name}.fShader`, gl.FRAGMENT_SHADER, fSource.trim());
  3506. const p = /** @type {WebGLProgram} */ (gl.createProgram());
  3507.  
  3508. gl.attachShader(p, vShader);
  3509. gl.attachShader(p, fShader);
  3510. gl.linkProgram(p);
  3511.  
  3512. // note: linking errors should not happen in production
  3513. aux.require(
  3514. gl.getProgramParameter(p, gl.LINK_STATUS) || gl.isContextLost(),
  3515. `Can\'t link WebGL2 program "${name}". You might be on a weird browser.\n\nFull error log:\n` +
  3516. gl.getProgramInfoLog(p),
  3517. );
  3518.  
  3519. for (const tag of ubos) {
  3520. const index = gl.getUniformBlockIndex(p, tag); // returns 4294967295 if invalid... just don't make typos
  3521. let binding = uboBindings.get(tag);
  3522. if (binding === undefined)
  3523. uboBindings.set(tag, binding = uboBindings.size);
  3524. gl.uniformBlockBinding(p, index, binding);
  3525.  
  3526. const size = gl.getActiveUniformBlockParameter(p, index, gl.UNIFORM_BLOCK_DATA_SIZE);
  3527. const ubo = uniforms[tag] = gl.createBuffer();
  3528. gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
  3529. gl.bufferData(gl.UNIFORM_BUFFER, size, gl.DYNAMIC_DRAW);
  3530. gl.bindBufferBase(gl.UNIFORM_BUFFER, binding, ubo);
  3531. }
  3532.  
  3533. // bind texture uniforms to TEXTURE0, TEXTURE1, etc.
  3534. gl.useProgram(p);
  3535. for (let i = 0; i < textures.length; ++i) {
  3536. const loc = gl.getUniformLocation(p, textures[i]);
  3537. gl.uniform1i(loc, i);
  3538. }
  3539. gl.useProgram(null);
  3540.  
  3541. return p;
  3542. }
  3543.  
  3544. const parts = {
  3545. boilerplate: '#version 300 es\nprecision highp float; precision highp int;',
  3546. borderUbo: `layout(std140) uniform Border { // size = 0x28
  3547. vec4 u_border_color; // @ 0x00, i = 0
  3548. vec4 u_border_xyzw_lrtb; // @ 0x10, i = 4
  3549. int u_border_flags; // @ 0x20, i = 8
  3550. float u_background_width; // @ 0x24, i = 9
  3551. float u_background_height; // @ 0x28, i = 10
  3552. float u_border_time; // @ 0x2c, i = 11
  3553. };`,
  3554. cameraUbo: `layout(std140) uniform Camera { // size = 0x10
  3555. float u_camera_ratio; // @ 0x00
  3556. float u_camera_scale; // @ 0x04
  3557. vec2 u_camera_pos; // @ 0x08
  3558. };`,
  3559. cellUbo: `layout(std140) uniform Cell { // size = 0x28
  3560. float u_cell_radius; // @ 0x00, i = 0
  3561. float u_cell_radius_skin; // @ 0x04, i = 1
  3562. vec2 u_cell_pos; // @ 0x08, i = 2
  3563. vec4 u_cell_color; // @ 0x10, i = 4
  3564. float u_cell_alpha; // @ 0x20, i = 8
  3565. int u_cell_flags; // @ 0x24, i = 9
  3566. };`,
  3567. cellSettingsUbo: `layout(std140) uniform CellSettings { // size = 0x40
  3568. vec4 u_cell_active_outline; // @ 0x00
  3569. vec4 u_cell_inactive_outline; // @ 0x10
  3570. vec4 u_cell_unsplittable_outline; // @ 0x20
  3571. vec4 u_cell_subtle_outline_override; // @ 0x30
  3572. float u_cell_active_outline_thickness; // @ 0x40
  3573. };`,
  3574. circleUbo: `layout(std140) uniform Circle { // size = 0x08
  3575. float u_circle_alpha; // @ 0x00
  3576. float u_circle_scale; // @ 0x04
  3577. };`,
  3578. textUbo: `layout(std140) uniform Text { // size = 0x38
  3579. vec4 u_text_color1; // @ 0x00, i = 0
  3580. vec4 u_text_color2; // @ 0x10, i = 4
  3581. float u_text_alpha; // @ 0x20, i = 8
  3582. float u_text_aspect_ratio; // @ 0x24, i = 9
  3583. float u_text_scale; // @ 0x28, i = 10
  3584. int u_text_silhouette_enabled; // @ 0x2c, i = 11
  3585. vec2 u_text_offset; // @ 0x30, i = 12
  3586. };`,
  3587. tracerUbo: `layout(std140) uniform Tracer { // size = 0x10
  3588. vec2 u_tracer_pos1; // @ 0x00, i = 0
  3589. vec2 u_tracer_pos2; // @ 0x08, i = 2
  3590. };`,
  3591. };
  3592.  
  3593.  
  3594.  
  3595. glconf.init = () => {
  3596. gl.enable(gl.BLEND);
  3597. gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  3598.  
  3599. // create programs and uniforms
  3600. programs.bg = program('bg', `
  3601. ${parts.boilerplate}
  3602. layout(location = 0) in vec2 a_vertex;
  3603. ${parts.borderUbo}
  3604. ${parts.cameraUbo}
  3605. flat out float f_blur;
  3606. flat out float f_thickness;
  3607. out vec2 v_uv;
  3608. out vec2 v_world_pos;
  3609.  
  3610. void main() {
  3611. f_blur = 1.0 * (540.0 * u_camera_scale);
  3612. f_thickness = max(3.0 / f_blur, 25.0); // force border to always be visible, otherwise it flickers
  3613.  
  3614. v_world_pos = a_vertex * vec2(u_camera_ratio, 1.0) / u_camera_scale;
  3615. v_world_pos += u_camera_pos * vec2(1.0, -1.0);
  3616.  
  3617. if ((u_border_flags & 0x04) != 0) { // background repeating
  3618. v_uv = v_world_pos * 0.02 * (50.0 / u_background_width);
  3619. v_uv /= vec2(1.0, u_background_height / u_background_width);
  3620. } else {
  3621. v_uv = (v_world_pos - vec2(u_border_xyzw_lrtb.x, u_border_xyzw_lrtb.z))
  3622. / vec2(u_border_xyzw_lrtb.y - u_border_xyzw_lrtb.x,
  3623. u_border_xyzw_lrtb.w - u_border_xyzw_lrtb.z);
  3624. v_uv = vec2(v_uv.x, 1.0 - v_uv.y); // flip vertically
  3625. }
  3626.  
  3627. gl_Position = vec4(a_vertex, 0, 1); // span the whole screen
  3628. }
  3629. `, `
  3630. ${parts.boilerplate}
  3631. flat in float f_blur;
  3632. flat in float f_thickness;
  3633. in vec2 v_uv;
  3634. in vec2 v_world_pos;
  3635. ${parts.borderUbo}
  3636. ${parts.cameraUbo}
  3637. uniform sampler2D u_texture;
  3638. out vec4 out_color;
  3639.  
  3640. void main() {
  3641. if ((u_border_flags & 0x01) != 0) { // background enabled
  3642. if ((u_border_flags & 0x04) != 0 // repeating
  3643. || (0.0 <= min(v_uv.x, v_uv.y) && max(v_uv.x, v_uv.y) <= 1.0)) { // within border
  3644. out_color = texture(u_texture, v_uv);
  3645. }
  3646. }
  3647.  
  3648. // make a larger inner rectangle and a normal inverted outer rectangle
  3649. float inner_alpha = min(
  3650. min((v_world_pos.x + f_thickness) - u_border_xyzw_lrtb.x,
  3651. u_border_xyzw_lrtb.y - (v_world_pos.x - f_thickness)),
  3652. min((v_world_pos.y + f_thickness) - u_border_xyzw_lrtb.z,
  3653. u_border_xyzw_lrtb.w - (v_world_pos.y - f_thickness))
  3654. );
  3655. float outer_alpha = max(
  3656. max(u_border_xyzw_lrtb.x - v_world_pos.x, v_world_pos.x - u_border_xyzw_lrtb.y),
  3657. max(u_border_xyzw_lrtb.z - v_world_pos.y, v_world_pos.y - u_border_xyzw_lrtb.w)
  3658. );
  3659. float alpha = clamp(f_blur * min(inner_alpha, outer_alpha), 0.0, 1.0);
  3660. if (u_border_color.a == 0.0) alpha = 0.0;
  3661.  
  3662. vec4 border_color;
  3663. if ((u_border_flags & 0x08) != 0) { // rainbow border
  3664. float angle = atan(v_world_pos.y, v_world_pos.x) + u_border_time;
  3665. float red = (2.0/3.0) * cos(6.0 * angle) + 1.0/3.0;
  3666. float green = (2.0/3.0) * cos(6.0 * angle - 2.0 * 3.1415926535 / 3.0) + 1.0/3.0;
  3667. float blue = (2.0/3.0) * cos(6.0 * angle - 4.0 * 3.1415926535 / 3.0) + 1.0/3.0;
  3668. border_color = vec4(red, green, blue, 1.0);
  3669. } else {
  3670. border_color = u_border_color;
  3671. }
  3672.  
  3673. out_color = out_color * (1.0 - alpha) + border_color * alpha;
  3674. }
  3675. `, ['Border', 'Camera'], ['u_texture']);
  3676.  
  3677.  
  3678.  
  3679. programs.cell = program('cell', `
  3680. ${parts.boilerplate}
  3681. layout(location = 0) in vec2 a_vertex;
  3682. ${parts.cameraUbo}
  3683. ${parts.cellUbo}
  3684. ${parts.cellSettingsUbo}
  3685. flat out vec4 f_active_outline;
  3686. flat out float f_active_radius;
  3687. flat out float f_blur;
  3688. flat out int f_color_under_skin;
  3689. flat out int f_show_skin;
  3690. flat out vec4 f_subtle_outline;
  3691. flat out float f_subtle_radius;
  3692. flat out vec4 f_unsplittable_outline;
  3693. flat out float f_unsplittable_radius;
  3694. out vec2 v_vertex;
  3695. out vec2 v_uv;
  3696.  
  3697. void main() {
  3698. f_blur = 0.5 * u_cell_radius * (540.0 * u_camera_scale);
  3699. f_color_under_skin = u_cell_flags & 0x20;
  3700. f_show_skin = u_cell_flags & 0x01;
  3701.  
  3702. // subtle outlines (at least 1px wide)
  3703. float subtle_thickness = max(max(u_cell_radius * 0.02, 2.0 / (540.0 * u_camera_scale)), 10.0);
  3704. f_subtle_radius = 1.0 - (subtle_thickness / u_cell_radius);
  3705. if ((u_cell_flags & 0x02) != 0) {
  3706. f_subtle_outline = u_cell_color * 0.9; // darker outline by default
  3707. f_subtle_outline.rgb += (u_cell_subtle_outline_override.rgb - f_subtle_outline.rgb)
  3708. * u_cell_subtle_outline_override.a;
  3709. } else {
  3710. f_subtle_outline = vec4(0, 0, 0, 0);
  3711. }
  3712.  
  3713. // unsplittable cell outline, 2x the subtle thickness
  3714. // (except at small sizes, it shouldn't look overly thick)
  3715. float unsplittable_thickness = max(max(u_cell_radius * 0.04, 4.0 / (540.0 * u_camera_scale)), 10.0);
  3716. f_unsplittable_radius = 1.0 - (unsplittable_thickness / u_cell_radius);
  3717. if ((u_cell_flags & 0x10) != 0) {
  3718. f_unsplittable_outline = u_cell_unsplittable_outline;
  3719. } else {
  3720. f_unsplittable_outline = vec4(0, 0, 0, 0);
  3721. }
  3722.  
  3723. // active multibox outlines (thick, a % of the visible cell radius)
  3724. // or at minimum, 3x the subtle thickness
  3725. float active_thickness = max(max(u_cell_radius * 0.06, 6.0 / (540.0 * u_camera_scale)), 10.0);
  3726. f_active_radius = 1.0 - max(active_thickness / u_cell_radius, u_cell_active_outline_thickness);
  3727. if ((u_cell_flags & 0x0c) != 0) {
  3728. f_active_outline = (u_cell_flags & 0x04) != 0 ? u_cell_active_outline : u_cell_inactive_outline;
  3729. } else {
  3730. f_active_outline = vec4(0, 0, 0, 0);
  3731. }
  3732.  
  3733. v_vertex = a_vertex;
  3734. v_uv = a_vertex * min(u_cell_radius / u_cell_radius_skin, 1.0) * 0.5 + 0.5;
  3735.  
  3736. vec2 clip_pos = -u_camera_pos + u_cell_pos + v_vertex * u_cell_radius;
  3737. clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
  3738. gl_Position = vec4(clip_pos, 0, 1);
  3739. }
  3740. `, `
  3741. ${parts.boilerplate}
  3742. flat in vec4 f_active_outline;
  3743. flat in float f_active_radius;
  3744. flat in float f_blur;
  3745. flat in int f_color_under_skin;
  3746. flat in int f_show_skin;
  3747. flat in vec4 f_subtle_outline;
  3748. flat in float f_subtle_radius;
  3749. flat in vec4 f_unsplittable_outline;
  3750. flat in float f_unsplittable_radius;
  3751. in vec2 v_vertex;
  3752. in vec2 v_uv;
  3753. ${parts.cameraUbo}
  3754. ${parts.cellUbo}
  3755. ${parts.cellSettingsUbo}
  3756. uniform sampler2D u_skin;
  3757. out vec4 out_color;
  3758.  
  3759. void main() {
  3760. float d = length(v_vertex.xy);
  3761. if (f_show_skin == 0 || f_color_under_skin != 0) {
  3762. out_color = u_cell_color;
  3763. }
  3764.  
  3765. // skin
  3766. if (f_show_skin != 0) {
  3767. vec4 tex = texture(u_skin, v_uv);
  3768. out_color = out_color * (1.0 - tex.a) + tex;
  3769. }
  3770.  
  3771. // subtle outline
  3772. float a = clamp(f_blur * (d - f_subtle_radius), 0.0, 1.0) * f_subtle_outline.a;
  3773. out_color.rgb += (f_subtle_outline.rgb - out_color.rgb) * a;
  3774.  
  3775. // active multibox outline
  3776. a = clamp(f_blur * (d - f_active_radius), 0.0, 1.0) * f_active_outline.a;
  3777. out_color.rgb += (f_active_outline.rgb - out_color.rgb) * a;
  3778.  
  3779. // unsplittable cell outline
  3780. a = clamp(f_blur * (d - f_unsplittable_radius), 0.0, 1.0) * f_unsplittable_outline.a;
  3781. out_color.rgb += (f_unsplittable_outline.rgb - out_color.rgb) * a;
  3782.  
  3783. // final circle mask
  3784. a = clamp(-f_blur * (d - 1.0), 0.0, 1.0);
  3785. out_color.a *= a * u_cell_alpha;
  3786. }
  3787. `, ['Camera', 'Cell', 'CellSettings'], ['u_skin']);
  3788.  
  3789.  
  3790.  
  3791. // also used to draw glow
  3792. programs.circle = program('circle', `
  3793. ${parts.boilerplate}
  3794. layout(location = 0) in vec2 a_vertex;
  3795. layout(location = 1) in vec2 a_cell_pos;
  3796. layout(location = 2) in float a_cell_radius;
  3797. layout(location = 3) in vec4 a_cell_color;
  3798. layout(location = 4) in float a_cell_alpha;
  3799. ${parts.cameraUbo}
  3800. ${parts.circleUbo}
  3801. out vec2 v_vertex;
  3802. flat out float f_blur;
  3803. flat out vec4 f_cell_color;
  3804.  
  3805. void main() {
  3806. float radius = a_cell_radius;
  3807. f_cell_color = a_cell_color * vec4(1, 1, 1, a_cell_alpha * u_circle_alpha);
  3808. if (u_circle_scale > 0.0) {
  3809. f_blur = 1.0;
  3810. radius *= u_circle_scale;
  3811. } else {
  3812. f_blur = 0.5 * a_cell_radius * (540.0 * u_camera_scale);
  3813. }
  3814. v_vertex = a_vertex;
  3815.  
  3816. vec2 clip_pos = -u_camera_pos + a_cell_pos + v_vertex * radius;
  3817. clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
  3818. gl_Position = vec4(clip_pos, 0, 1);
  3819. }
  3820. `, `
  3821. ${parts.boilerplate}
  3822. in vec2 v_vertex;
  3823. flat in float f_blur;
  3824. flat in vec4 f_cell_color;
  3825. out vec4 out_color;
  3826.  
  3827. void main() {
  3828. // use squared distance for more natural glow; shouldn't matter for pellets
  3829. float d = length(v_vertex.xy);
  3830. out_color = f_cell_color;
  3831. out_color.a *= clamp(f_blur * (1.0 - d), 0.0, 1.0);
  3832. }
  3833. `, ['Camera', 'Circle'], []);
  3834.  
  3835.  
  3836.  
  3837. programs.text = program('text', `
  3838. ${parts.boilerplate}
  3839. layout(location = 0) in vec2 a_vertex;
  3840. ${parts.cameraUbo}
  3841. ${parts.cellUbo}
  3842. ${parts.textUbo}
  3843. out vec4 v_color;
  3844. out vec2 v_uv;
  3845. out vec2 v_vertex;
  3846.  
  3847. void main() {
  3848. v_uv = a_vertex * 0.5 + 0.5;
  3849. float c2_alpha = (v_uv.x + v_uv.y) / 2.0;
  3850. v_color = u_text_color1 * (1.0 - c2_alpha) + u_text_color2 * c2_alpha;
  3851. v_vertex = a_vertex;
  3852.  
  3853. vec2 clip_space = v_vertex * u_text_scale + u_text_offset;
  3854. clip_space *= u_cell_radius_skin * 0.45 * vec2(u_text_aspect_ratio, 1.0);
  3855. clip_space += -u_camera_pos + u_cell_pos;
  3856. clip_space *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
  3857. gl_Position = vec4(clip_space, 0, 1);
  3858. }
  3859. `, `
  3860. ${parts.boilerplate}
  3861. in vec4 v_color;
  3862. in vec2 v_uv;
  3863. in vec2 v_vertex;
  3864. ${parts.cameraUbo}
  3865. ${parts.cellUbo}
  3866. ${parts.textUbo}
  3867. uniform sampler2D u_texture;
  3868. uniform sampler2D u_silhouette;
  3869. out vec4 out_color;
  3870.  
  3871. float f(float x) {
  3872. // a cubic function with turning points at (0,0) and (1,0)
  3873. // meant to sharpen out blurry linear interpolation
  3874. return x * x * (3.0 - 2.0*x);
  3875. }
  3876.  
  3877. vec4 fv(vec4 v) {
  3878. return vec4(f(v.x), f(v.y), f(v.z), f(v.w));
  3879. }
  3880.  
  3881. void main() {
  3882. vec4 normal = texture(u_texture, v_uv);
  3883.  
  3884. if (u_text_silhouette_enabled != 0) {
  3885. vec4 silhouette = texture(u_silhouette, v_uv);
  3886.  
  3887. // #fff - #000 => color (text)
  3888. // #fff - #fff => #fff (respect emoji)
  3889. // #888 - #888 => #888 (respect emoji)
  3890. // #fff - #888 => #888 + color/2 (blur/antialias)
  3891. out_color = silhouette + fv(normal - silhouette) * v_color;
  3892. } else {
  3893. out_color = fv(normal) * v_color;
  3894. }
  3895.  
  3896. out_color.a *= u_text_alpha;
  3897. }
  3898. `, ['Camera', 'Cell', 'Text'], ['u_texture', 'u_silhouette']);
  3899.  
  3900. programs.tracer = program('tracer', `
  3901. ${parts.boilerplate}
  3902. layout(location = 0) in vec2 a_vertex;
  3903. ${parts.cameraUbo}
  3904. ${parts.tracerUbo}
  3905. out vec2 v_vertex;
  3906.  
  3907. void main() {
  3908. v_vertex = a_vertex;
  3909. float alpha = (a_vertex.x + 1.0) / 2.0;
  3910. float d = length(u_tracer_pos2 - u_tracer_pos1);
  3911. float thickness = 0.002 / u_camera_scale;
  3912. // black magic
  3913. vec2 world_pos = u_tracer_pos1 + (u_tracer_pos2 - u_tracer_pos1)
  3914. * mat2(alpha, a_vertex.y / d * thickness, a_vertex.y / d * -thickness, alpha);
  3915.  
  3916. vec2 clip_pos = -u_camera_pos + world_pos;
  3917. clip_pos *= u_camera_scale * vec2(1.0 / u_camera_ratio, -1.0);
  3918. gl_Position = vec4(clip_pos, 0, 1);
  3919. }
  3920. `, `
  3921. ${parts.boilerplate}
  3922. in vec2 v_pos;
  3923. out vec4 out_color;
  3924.  
  3925. void main() {
  3926. out_color = vec4(0.5, 0.5, 0.5, 0.25);
  3927. }
  3928. `, ['Camera', 'Tracer'], []);
  3929.  
  3930. // initialize two VAOs; one for pellets, one for cell glow only
  3931. glconf.vao = [];
  3932. for (let i = 0; i < 2; ++i) {
  3933. const vao = /** @type {WebGLVertexArrayObject} */ (gl.createVertexArray());
  3934. gl.bindVertexArray(vao);
  3935.  
  3936. // square (location = 0), used for all instances
  3937. gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  3938. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 1, -1, -1, 1, 1, 1 ]), gl.STATIC_DRAW);
  3939. gl.enableVertexAttribArray(0);
  3940. gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  3941.  
  3942. // pellet/circle buffer (each instance is 6 floats or 24 bytes)
  3943. const circleBuffer = /** @type {WebGLBuffer} */ (gl.createBuffer());
  3944. gl.bindBuffer(gl.ARRAY_BUFFER, circleBuffer);
  3945. // a_cell_pos, vec2 (location = 1)
  3946. gl.enableVertexAttribArray(1);
  3947. gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 4 * 7, 0);
  3948. gl.vertexAttribDivisor(1, 1);
  3949. // a_cell_radius, float (location = 2)
  3950. gl.enableVertexAttribArray(2);
  3951. gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 4 * 7, 4 * 2);
  3952. gl.vertexAttribDivisor(2, 1);
  3953. // a_cell_color, vec3 (location = 3)
  3954. gl.enableVertexAttribArray(3);
  3955. gl.vertexAttribPointer(3, 4, gl.FLOAT, false, 4 * 7, 4 * 3);
  3956. gl.vertexAttribDivisor(3, 1);
  3957.  
  3958. // pellet/circle alpha buffer, updated every frame
  3959. const alphaBuffer = /** @type {WebGLBuffer} */ (gl.createBuffer());
  3960. gl.bindBuffer(gl.ARRAY_BUFFER, alphaBuffer);
  3961. // a_cell_alpha, float (location = 4)
  3962. gl.enableVertexAttribArray(4);
  3963. gl.vertexAttribPointer(4, 1, gl.FLOAT, false, 0, 0);
  3964. gl.vertexAttribDivisor(4, 1);
  3965.  
  3966. glconf.vao.push({ vao, alphaBuffer, circleBuffer, alphaBufferSize: 0 });
  3967. }
  3968.  
  3969. gl.bindVertexArray(glconf.vao[0].vao);
  3970. };
  3971.  
  3972. glconf.init();
  3973. return glconf;
  3974. })();
  3975.  
  3976.  
  3977.  
  3978. ///////////////////////////////
  3979. // Define Rendering Routines //
  3980. ///////////////////////////////
  3981. const render = (() => {
  3982. const render = {};
  3983. const { gl } = ui.game;
  3984.  
  3985. // #1 : define small misc objects
  3986. // no point in breaking this across multiple lines
  3987. // eslint-disable-next-line max-len
  3988. const darkGridSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAGBJREFUaIHtz4EJwCAAwDA39oT/H+qeEAzSXNA+a61xgfmeLtilEU0jmkY0jWga0TSiaUTTiKYRTSOaRjSNaBrRNKJpRNOIphFNI5pGNI1oGtE0omlEc83IN8aYpyN2+AH6nwOVa0odrQAAAABJRU5ErkJggg==';
  3989. // eslint-disable-next-line max-len
  3990. const lightGridSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAGFJREFUaIHtzwENgDAQwMA9LvAvdJgg2UF6CtrZe6+vm5n7Oh3xlkY0jWga0TSiaUTTiKYRTSOaRjSNaBrRNKJpRNOIphFNI5pGNI1oGtE0omlE04imEc1vRmatdZ+OeMMDa8cDlf3ZAHkAAAAASUVORK5CYII=';
  3991.  
  3992. let lastMinimapDraw = performance.now();
  3993. /** @type {{ bg: ImageData, darkTheme: boolean } | undefined} */
  3994. let minimapCache;
  3995. document.fonts.addEventListener('loadingdone', () => void (minimapCache = undefined));
  3996.  
  3997.  
  3998. // #2 : define helper functions
  3999. const { resetDatabaseCache, resetTextureCache, textureFromCache, textureFromDatabase } = (() => {
  4000. /** @type {Map<string, {
  4001. * color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
  4002. * } | null>} */
  4003. const cache = new Map();
  4004. render.textureCache = cache;
  4005.  
  4006. /** @type {Map<string, {
  4007. * color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
  4008. * } | null>} */
  4009. const dbCache = new Map();
  4010. render.dbCache = dbCache;
  4011.  
  4012. return {
  4013. resetTextureCache: () => cache.clear(),
  4014. /**
  4015. * @param {string} src
  4016. * @returns {{
  4017. * color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
  4018. * } | undefined}
  4019. */
  4020. textureFromCache: src => {
  4021. const cached = cache.get(src);
  4022. if (cached !== undefined)
  4023. return cached ?? undefined;
  4024.  
  4025. cache.set(src, null);
  4026.  
  4027. const image = new Image();
  4028. image.crossOrigin = '';
  4029. image.addEventListener('load', () => {
  4030. const texture = /** @type {WebGLTexture} */ (gl.createTexture());
  4031. if (!texture) return;
  4032.  
  4033. gl.bindTexture(gl.TEXTURE_2D, texture);
  4034. gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  4035. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
  4036. gl.generateMipmap(gl.TEXTURE_2D);
  4037.  
  4038. const color = aux.dominantColor(image);
  4039. cache.set(src, { color, texture, width: image.width, height: image.height });
  4040. });
  4041. image.src = src;
  4042.  
  4043. return undefined;
  4044. },
  4045. resetDatabaseCache: () => dbCache.clear(),
  4046. /**
  4047. * @param {string} property
  4048. * @returns {{
  4049. * color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
  4050. * } | undefined}
  4051. */
  4052. textureFromDatabase: property => {
  4053. const cached = dbCache.get(property);
  4054. if (cached !== undefined)
  4055. return cached ?? undefined;
  4056.  
  4057. /** @type {IDBDatabase | undefined} */
  4058. const database = settings.database;
  4059. if (!database) return undefined;
  4060.  
  4061. dbCache.set(property, null);
  4062. const req = database.transaction('images').objectStore('images').get(property);
  4063. req.addEventListener('success', () => {
  4064. if (!req.result) return;
  4065.  
  4066. const reader = new FileReader();
  4067. reader.addEventListener('load', () => {
  4068. const image = new Image();
  4069. // this can cause a lot of lag (~500ms) when loading a large image for the first time
  4070. image.addEventListener('load', () => {
  4071. const texture = gl.createTexture();
  4072. if (!texture) return;
  4073.  
  4074. gl.bindTexture(gl.TEXTURE_2D, texture);
  4075. gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  4076. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
  4077. gl.generateMipmap(gl.TEXTURE_2D);
  4078.  
  4079. const color = aux.dominantColor(image);
  4080. dbCache.set(property, { color, texture, width: image.width, height: image.height });
  4081. });
  4082. image.src = /** @type {string} */ (reader.result);
  4083. });
  4084. reader.readAsDataURL(req.result);
  4085. });
  4086. req.addEventListener('error', err => {
  4087. console.warn(`sigfix database failed to get ${property}:`, err);
  4088. });
  4089. },
  4090. };
  4091. })();
  4092. render.resetDatabaseCache = resetDatabaseCache;
  4093. render.resetTextureCache = resetTextureCache;
  4094.  
  4095. const { maxMassWidth, refreshTextCache, massTextFromCache, resetTextCache, textFromCache } = (() => {
  4096. /**
  4097. * @template {boolean} T
  4098. * @typedef {{
  4099. * aspectRatio: number,
  4100. * text: WebGLTexture | null,
  4101. * silhouette: WebGLTexture | null | undefined,
  4102. * accessed: number
  4103. * }} CacheEntry
  4104. */
  4105. /** @type {Map<string, CacheEntry<boolean>>} */
  4106. const cache = new Map();
  4107. render.textCache = cache;
  4108.  
  4109. setInterval(() => {
  4110. // remove text after not being used for 1 minute
  4111. const now = performance.now();
  4112. cache.forEach((entry, text) => {
  4113. if (now - entry.accessed > 60_000) {
  4114. // immediately delete text instead of waiting for GC
  4115. if (entry.text !== undefined)
  4116. gl.deleteTexture(entry.text);
  4117. if (entry.silhouette !== undefined)
  4118. gl.deleteTexture(entry.silhouette);
  4119. cache.delete(text);
  4120. }
  4121. });
  4122. }, 60_000);
  4123.  
  4124. const canvas = document.createElement('canvas');
  4125. const ctx = aux.require(
  4126. canvas.getContext('2d', { willReadFrequently: true }),
  4127. 'Unable to get 2D context for text drawing. This is probably your browser being weird, maybe reload ' +
  4128. 'the page?',
  4129. );
  4130.  
  4131. // sigmod forces a *really* ugly shadow on ctx.fillText so we have to lock the property beforehand
  4132. const realProps = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(ctx));
  4133. const realShadowBlurSet
  4134. = aux.require(realProps.shadowBlur.set, 'did CanvasRenderingContext2D spec change?').bind(ctx);
  4135. const realShadowColorSet
  4136. = aux.require(realProps.shadowColor.set, 'did CanvasRenderingContext2D spec change?').bind(ctx);
  4137. Object.defineProperties(ctx, {
  4138. shadowBlur: {
  4139. get: () => 0,
  4140. set: x => {
  4141. if (x === 0) realShadowBlurSet(0);
  4142. else realShadowBlurSet(8);
  4143. },
  4144. },
  4145. shadowColor: {
  4146. get: () => 'transparent',
  4147. set: x => {
  4148. if (x === 'transparent') realShadowColorSet('transparent');
  4149. else realShadowColorSet('#0003');
  4150. },
  4151. },
  4152. });
  4153.  
  4154. /**
  4155. * @param {string} text
  4156. * @param {boolean} silhouette
  4157. * @param {boolean} mass
  4158. * @returns {WebGLTexture | null}
  4159. */
  4160. const texture = (text, silhouette, mass) => {
  4161. const texture = gl.createTexture();
  4162. if (!texture) return texture;
  4163.  
  4164. const baseTextSize = 96;
  4165. const textSize = baseTextSize * (mass ? 0.5 * settings.massScaleFactor : settings.nameScaleFactor);
  4166. const lineWidth = Math.ceil(textSize / 10) * settings.textOutlinesFactor;
  4167.  
  4168. let font = '';
  4169. if (mass ? settings.massBold : settings.nameBold)
  4170. font = 'bold';
  4171. font += ` ${textSize}px "${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
  4172.  
  4173. ctx.font = font;
  4174. // if rendering an empty string (somehow) then width can be 0 with no outlines
  4175. canvas.width = (ctx.measureText(text).width + lineWidth * 4) || 1;
  4176. canvas.height = textSize * 3;
  4177. ctx.clearRect(0, 0, canvas.width, canvas.height);
  4178.  
  4179. // setting canvas.width resets the canvas state
  4180. ctx.font = font;
  4181. ctx.lineJoin = 'round';
  4182. ctx.lineWidth = lineWidth;
  4183. ctx.fillStyle = silhouette ? '#000' : '#fff';
  4184. ctx.strokeStyle = '#000';
  4185. ctx.textBaseline = 'middle';
  4186.  
  4187. ctx.shadowBlur = lineWidth;
  4188. ctx.shadowColor = lineWidth > 0 ? '#0002' : 'transparent';
  4189.  
  4190. // add a space, which is to prevent sigmod from detecting the name
  4191. if (lineWidth > 0) ctx.strokeText(text + ' ', lineWidth * 2, textSize * 1.5);
  4192. ctx.shadowColor = 'transparent';
  4193. ctx.fillText(text + ' ', lineWidth * 2, textSize * 1.5);
  4194.  
  4195. const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
  4196.  
  4197. gl.bindTexture(gl.TEXTURE_2D, texture);
  4198. gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
  4199. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
  4200. gl.generateMipmap(gl.TEXTURE_2D);
  4201. return texture;
  4202. };
  4203.  
  4204. let maxMassWidth = 0;
  4205. /** @type {({ height: number, width: number, texture: WebGLTexture | null } | undefined)[]} */
  4206. const massTextCache = [];
  4207.  
  4208. /**
  4209. * @param {string} digit
  4210. * @returns {{ height: number, width: number, texture: WebGLTexture | null }}
  4211. */
  4212. const massTextFromCache = digit => {
  4213. let cached = massTextCache[/** @type {any} */ (digit)];
  4214. if (!cached) {
  4215. cached = massTextCache[digit] = {
  4216. texture: texture(digit, false, true),
  4217. height: canvas.height, // mind the execution order
  4218. width: canvas.width,
  4219. };
  4220. if (cached.width > maxMassWidth) maxMassWidth = cached.width;
  4221. }
  4222.  
  4223. return cached;
  4224. };
  4225.  
  4226. const resetTextCache = () => {
  4227. cache.clear();
  4228. maxMassWidth = 0;
  4229. while (massTextCache.length > 0) massTextCache.pop();
  4230. };
  4231.  
  4232. /** @type {{
  4233. * massBold: boolean, massScaleFactor: number, nameBold: boolean, nameScaleFactor: number,
  4234. * outlinesFactor: number, font: string | undefined,
  4235. * } | undefined} */
  4236. let drawn;
  4237.  
  4238. const refreshTextCache = () => {
  4239. if (!drawn ||
  4240. (drawn.massBold !== settings.massBold || drawn.massScaleFactor !== settings.massScaleFactor
  4241. || drawn.nameBold !== settings.nameBold || drawn.nameScaleFactor !== settings.nameScaleFactor
  4242. || drawn.outlinesFactor !== settings.textOutlinesFactor || drawn.font !== sigmod.settings.font)
  4243. ) {
  4244. resetTextCache();
  4245. drawn = {
  4246. massBold: settings.massBold, massScaleFactor: settings.massScaleFactor,
  4247. nameBold: settings.nameBold, nameScaleFactor: settings.nameScaleFactor,
  4248. outlinesFactor: settings.textOutlinesFactor, font: sigmod.settings.font,
  4249. };
  4250. }
  4251. };
  4252.  
  4253. /**
  4254. * @template {boolean} T
  4255. * @param {string} text
  4256. * @param {T} silhouette
  4257. * @returns {CacheEntry<T>}
  4258. */
  4259. const textFromCache = (text, silhouette) => {
  4260. let entry = cache.get(text);
  4261. if (!entry) {
  4262. const shortened = aux.trim(text);
  4263. /** @type {CacheEntry<T>} */
  4264. entry = {
  4265. text: texture(shortened, false, false),
  4266. aspectRatio: canvas.width / canvas.height, // mind the execution order
  4267. silhouette: silhouette ? texture(shortened, true, false) : undefined,
  4268. accessed: performance.now(),
  4269. };
  4270. cache.set(text, entry);
  4271. } else {
  4272. entry.accessed = performance.now();
  4273. }
  4274.  
  4275. if (silhouette && entry.silhouette === undefined) {
  4276. setTimeout(() => {
  4277. entry.silhouette = texture(aux.trim(text), true, false);
  4278. });
  4279. }
  4280.  
  4281. return entry;
  4282. };
  4283.  
  4284. // reload text once Ubuntu has loaded, prevents some serif fonts from being locked in
  4285. // also support loading in new fonts at any time via sigmod
  4286. document.fonts.addEventListener('loadingdone', () => resetTextCache());
  4287.  
  4288. return {
  4289. maxMassWidth: () => maxMassWidth,
  4290. massTextFromCache,
  4291. refreshTextCache,
  4292. resetTextCache,
  4293. textFromCache,
  4294. };
  4295. })();
  4296. render.resetTextCache = resetTextCache;
  4297. render.textFromCache = textFromCache;
  4298.  
  4299. /**
  4300. * @param {Cell} cell
  4301. * @param {number} now
  4302. * @returns {number}
  4303. */
  4304. const calcAlpha = (cell, now) => {
  4305. let alpha = (now - cell.born) / 100;
  4306. if (cell.deadAt !== undefined) {
  4307. const alpha2 = 1 - (now - cell.deadAt) / 100;
  4308. if (alpha2 < alpha) alpha = alpha2;
  4309. }
  4310. return alpha > 1 ? 1 : alpha < 0 ? 0 : alpha;
  4311. };
  4312.  
  4313. /**
  4314. * @param {Cell} cell
  4315. * @returns {{
  4316. * color: [number, number, number, number], texture: WebGLTexture, width: number, height: number
  4317. * } | undefined}
  4318. */
  4319. const calcSkin = cell => {
  4320. /** @type {symbol | undefined} */
  4321. let ownerView;
  4322. for (const [otherView, otherVision] of world.views) {
  4323. if (!otherVision.owned.includes(cell.id)) continue;
  4324. ownerView = otherView;
  4325. break;
  4326. }
  4327.  
  4328. // 🖼️
  4329. let texture;
  4330. if (ownerView) {
  4331. // owned by primary === selected primary => use primary skin
  4332. // else use multi skin
  4333. const prop = ownerView === world.viewId.primary ? 'selfSkin' : 'selfSkinMulti';
  4334. if (settings[prop]) {
  4335. if (settings[prop].startsWith('🖼️')) texture = textureFromDatabase(prop);
  4336. else texture = textureFromCache(settings[prop]);
  4337. }
  4338. }
  4339.  
  4340. // allow turning off sigmally skins while still using custom skins
  4341. if (!texture && aux.settings.showSkins && cell.skin) {
  4342. texture = textureFromCache(cell.skin);
  4343. }
  4344.  
  4345. return texture;
  4346. };
  4347.  
  4348. let cellAlpha = new Float32Array(0);
  4349. let cellBuffer = new Float32Array(0);
  4350. let pelletAlpha = new Float32Array(0);
  4351. let pelletBuffer = new Float32Array(0);
  4352. let uploadedPellets = 0;
  4353. /**
  4354. * @param {'cells' | 'pellets'} key
  4355. * @param {number=} now
  4356. */
  4357. render.upload = (key, now) => {
  4358. if (performance.now() - render.lastFrame > 1_000) {
  4359. // do not render pellets on inactive windows (very laggy!)
  4360. uploadedPellets = 0;
  4361. return;
  4362. }
  4363.  
  4364. now ??= performance.now(); // the result will never actually be used, just for type checking
  4365. const vao = glconf.vao[key === 'pellets' ? 0 : 1];
  4366.  
  4367. // find expected # of pellets (exclude any that are being *animated*)
  4368. let expected = 0;
  4369. if (key === 'pellets') {
  4370. for (const resolution of world.pellets.values()) {
  4371. if (resolution.merged?.deadTo === -1) ++expected;
  4372. }
  4373. } else {
  4374. for (const resolution of world.cells.values()) {
  4375. if (resolution.merged) ++expected;
  4376. }
  4377. }
  4378.  
  4379. // grow the pellet buffer by 2x multiples if necessary
  4380. let alphaBuffer = key === 'cells' ? cellAlpha : pelletAlpha;
  4381. let objBuffer = key === 'cells' ? cellBuffer : pelletBuffer;
  4382. let instances = alphaBuffer.length || 1;
  4383. while (instances < expected) {
  4384. instances *= 2;
  4385. }
  4386. // when the webgl context is lost, the buffer sizes get reset to zero
  4387. const resizing = instances * 4 !== vao.alphaBufferSize;
  4388. if (resizing) {
  4389. if (key === 'pellets') {
  4390. alphaBuffer = pelletAlpha = new Float32Array(instances);
  4391. objBuffer = pelletBuffer = new Float32Array(instances * 7);
  4392. } else {
  4393. alphaBuffer = cellAlpha = new Float32Array(instances);
  4394. objBuffer = cellBuffer = new Float32Array(instances * 7);
  4395. }
  4396. }
  4397.  
  4398. const override = key === 'pellets' ? sigmod.settings.foodColor : sigmod.settings.cellColor;
  4399. const pelletOverrideBlack
  4400. = key === 'pellets' && override?.[0] === 0 && override?.[1] === 0 && override?.[2] === 0;
  4401.  
  4402. let i = 0;
  4403. /** @param {Cell} cell */
  4404. const iterate = cell => {
  4405. /** @type {number} */
  4406. let nx, ny, nr;
  4407. if (key !== 'cells') {
  4408. if (cell.deadTo !== -1) return;
  4409. nx = cell.nx; ny = cell.ny; nr = cell.nr;
  4410. } else {
  4411. let jr;
  4412. ({ x: nx, y: ny, r: nr, jr } = world.xyr(cell, undefined, now));
  4413. if (aux.settings.jellyPhysics) nr = jr;
  4414. }
  4415.  
  4416. objBuffer[i * 7] = nx;
  4417. objBuffer[i * 7 + 1] = ny;
  4418. objBuffer[i * 7 + 2] = nr;
  4419.  
  4420. const baseColor = override ?? cell.rgb;
  4421.  
  4422. let localOverride;
  4423. if (key === 'cells') {
  4424. const skinColor = calcSkin(cell)?.color;
  4425. if (skinColor) {
  4426. // blend with player color
  4427. localOverride = [
  4428. skinColor[0] + (baseColor[0] - skinColor[0]) * (1 - skinColor[3]),
  4429. skinColor[1] + (baseColor[1] - skinColor[1]) * (1 - skinColor[3]),
  4430. skinColor[2] + (baseColor[2] - skinColor[2]) * (1 - skinColor[3]),
  4431. 1,
  4432. ];
  4433. }
  4434. }
  4435. localOverride ??= override;
  4436.  
  4437. if (localOverride && !pelletOverrideBlack) {
  4438. objBuffer[i * 7 + 3] = localOverride[0];
  4439. objBuffer[i * 7 + 4] = localOverride[1];
  4440. objBuffer[i * 7 + 5] = localOverride[2];
  4441. objBuffer[i * 7 + 6] = localOverride[3];
  4442. } else {
  4443. objBuffer[i * 7 + 3] = cell.rgb[0];
  4444. objBuffer[i * 7 + 4] = cell.rgb[1];
  4445. objBuffer[i * 7 + 5] = cell.rgb[2];
  4446. objBuffer[i * 7 + 6] = pelletOverrideBlack ? override[3] : 1;
  4447. }
  4448. ++i;
  4449. };
  4450. for (const cell of world[key].values()) {
  4451. if (cell.merged) iterate(cell.merged);
  4452. }
  4453.  
  4454. // now, upload data
  4455. if (resizing) {
  4456. gl.bindBuffer(gl.ARRAY_BUFFER, vao.alphaBuffer);
  4457. gl.bufferData(gl.ARRAY_BUFFER, alphaBuffer.byteLength, gl.STATIC_DRAW);
  4458. gl.bindBuffer(gl.ARRAY_BUFFER, vao.circleBuffer);
  4459. gl.bufferData(gl.ARRAY_BUFFER, objBuffer, gl.STATIC_DRAW);
  4460. vao.alphaBufferSize = alphaBuffer.byteLength;
  4461. } else {
  4462. gl.bindBuffer(gl.ARRAY_BUFFER, vao.circleBuffer);
  4463. gl.bufferSubData(gl.ARRAY_BUFFER, 0, objBuffer);
  4464. }
  4465. gl.bindBuffer(gl.ARRAY_BUFFER, null);
  4466.  
  4467. if (key === 'pellets') uploadedPellets = expected;
  4468. };
  4469.  
  4470.  
  4471. // #3 : define ubo views
  4472. // firefox adds some padding to uniform buffer sizes, so best to check its size
  4473. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Border);
  4474. const borderUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
  4475. // must reference an arraybuffer for the memory to be shared between these views
  4476. const borderUboFloats = new Float32Array(borderUboBuffer);
  4477. const borderUboInts = new Int32Array(borderUboBuffer);
  4478.  
  4479. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Cell);
  4480. const cellUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
  4481. const cellUboFloats = new Float32Array(cellUboBuffer);
  4482. const cellUboInts = new Int32Array(cellUboBuffer);
  4483.  
  4484. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
  4485. const circleUboFloats = new Float32Array(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
  4486.  
  4487. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Text);
  4488. const textUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
  4489. const textUboFloats = new Float32Array(textUboBuffer);
  4490. const textUboInts = new Int32Array(textUboBuffer);
  4491.  
  4492. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Tracer);
  4493. const tracerUboBuffer = new ArrayBuffer(gl.getBufferParameter(gl.UNIFORM_BUFFER, gl.BUFFER_SIZE));
  4494. const tracerUboFloats = new Float32Array(tracerUboBuffer);
  4495.  
  4496. gl.bindBuffer(gl.UNIFORM_BUFFER, null); // leaving uniform buffer bound = scary!
  4497.  
  4498.  
  4499. // #4 : define the render function
  4500. const start = performance.now();
  4501. render.fps = 0;
  4502. render.lastFrame = performance.now();
  4503. function renderGame() {
  4504. const now = performance.now();
  4505. const dt = Math.max(now - render.lastFrame, 0.1) / 1000; // there's a chance (now - lastFrame) can be 0
  4506. render.fps += (1 / dt - render.fps) / 10;
  4507. render.lastFrame = now;
  4508.  
  4509. if (gl.isContextLost()) {
  4510. requestAnimationFrame(renderGame);
  4511. return;
  4512. }
  4513.  
  4514. // get settings
  4515. const defaultVirusSrc = '/assets/images/viruses/2.png';
  4516. const virusSrc = sigmod.settings.virusImage || defaultVirusSrc;
  4517.  
  4518. const showNames = sigmod.settings.showNames ?? true;
  4519.  
  4520. const { cellColor, foodColor, outlineColor } = sigmod.settings;
  4521.  
  4522. refreshTextCache();
  4523.  
  4524. const vision = aux.require(world.views.get(world.selected), 'no selected vision (BAD BUG)');
  4525. vision.used = performance.now();
  4526. for (const view of world.views.keys()) {
  4527. world.camera(view, now);
  4528. }
  4529.  
  4530. // note: most routines are named, for benchmarking purposes
  4531. (function setGlobalUniforms() {
  4532. // note that binding the same buffer to gl.UNIFORM_BUFFER twice in a row causes it to not update.
  4533. // why that happens is completely beyond me but oh well.
  4534. // for consistency, we always bind gl.UNIFORM_BUFFER to null directly after updating it.
  4535. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Camera);
  4536. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, new Float32Array([
  4537. ui.game.canvas.width / ui.game.canvas.height, vision.camera.scale / 540,
  4538. vision.camera.x, vision.camera.y,
  4539. ]));
  4540. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4541.  
  4542. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.CellSettings);
  4543. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, new Float32Array([
  4544. ...settings.outlineMultiColor, // cell_active_outline
  4545. ...settings.outlineMultiInactiveColor, // cell_inactive_outline
  4546. ...settings.unsplittableColor, // cell_unsplittable_outline
  4547. ...(outlineColor ?? [0, 0, 0, 0]), // cell_subtle_outline_override
  4548. settings.outlineMulti,
  4549. ]));
  4550. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4551. })();
  4552.  
  4553. (function background() {
  4554. if (sigmod.settings.mapColor) {
  4555. gl.clearColor(...sigmod.settings.mapColor);
  4556. } else if (aux.settings.darkTheme) {
  4557. gl.clearColor(0x11 / 255, 0x11 / 255, 0x11 / 255, 1); // #111
  4558. } else {
  4559. gl.clearColor(0xf2 / 255, 0xfb / 255, 0xff / 255, 1); // #f2fbff
  4560. }
  4561. gl.clear(gl.COLOR_BUFFER_BIT);
  4562.  
  4563. gl.useProgram(glconf.programs.bg);
  4564.  
  4565. let texture;
  4566. if (settings.background) {
  4567. if (settings.background.startsWith('🖼️'))
  4568. texture = textureFromDatabase('background');
  4569. else
  4570. texture = textureFromCache(settings.background);
  4571. } else if (aux.settings.showGrid) {
  4572. texture = textureFromCache(aux.settings.darkTheme ? darkGridSrc : lightGridSrc);
  4573. }
  4574. gl.bindTexture(gl.TEXTURE_2D, texture?.texture ?? null);
  4575. const repeating = texture && texture.width <= 512 && texture.height <= 512;
  4576.  
  4577. let borderColor;
  4578. let borderLrtb;
  4579. borderColor = (aux.settings.showBorder && vision.border) ? [0, 0, 1, 1] /* #00ff */
  4580. : [0, 0, 0, 0] /* transparent */;
  4581. borderLrtb = vision.border || { l: 0, r: 0, t: 0, b: 0 };
  4582.  
  4583. // u_border_color
  4584. borderUboFloats[0] = borderColor[0]; borderUboFloats[1] = borderColor[1];
  4585. borderUboFloats[2] = borderColor[2]; borderUboFloats[3] = borderColor[3];
  4586. // u_border_xyzw_lrtb
  4587. borderUboFloats[4] = borderLrtb.l;
  4588. borderUboFloats[5] = borderLrtb.r;
  4589. borderUboFloats[6] = borderLrtb.t;
  4590. borderUboFloats[7] = borderLrtb.b;
  4591.  
  4592. // flags
  4593. borderUboInts[8] = (texture ? 0x01 : 0) | (aux.settings.darkTheme ? 0x02 : 0) | (repeating ? 0x04 : 0)
  4594. | (settings.rainbowBorder ? 0x08 : 0);
  4595.  
  4596. // u_background_width and u_background_height
  4597. borderUboFloats[9] = texture?.width ?? 1;
  4598. borderUboFloats[10] = texture?.height ?? 1;
  4599. borderUboFloats[11] = (now - start) / 1000 * 0.2 % (Math.PI * 2);
  4600.  
  4601. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Border);
  4602. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, borderUboFloats);
  4603. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4604. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4605. })();
  4606.  
  4607. (function cells() {
  4608. // for white cell outlines
  4609. let nextCellIdx = 0;
  4610. const ownedToMerged = vision.owned.map(id => world.cells.get(id)?.merged);
  4611. for (const cell of ownedToMerged) {
  4612. if (cell && cell.deadAt === undefined) ++nextCellIdx;
  4613. }
  4614. const canSplit = ownedToMerged.map(cell => {
  4615. if (!cell || cell.nr < 128) return false;
  4616. return nextCellIdx++ < 16;
  4617. });
  4618.  
  4619. /**
  4620. * @param {Cell} cell
  4621. */
  4622. function draw(cell) {
  4623. // #1 : draw cell
  4624. gl.useProgram(glconf.programs.cell);
  4625.  
  4626. const alpha = calcAlpha(cell, now);
  4627. cellUboFloats[8] = alpha * settings.cellOpacity;
  4628.  
  4629. /** @type {Cell | undefined} */
  4630. let killer;
  4631. if (cell.deadTo !== -1) {
  4632. killer = world.cells.get(cell.deadTo)?.merged;
  4633. }
  4634. const { x, y, r, jr } = world.xyr(cell, killer, now);
  4635. // without jelly physics, the radius of cells is adjusted such that its subtle outline doesn't go
  4636. // past its original radius.
  4637. // jelly physics does not do this, so colliding cells need to look kinda 'joined' together,
  4638. // so we multiply the radius by 1.02 (approximately the size increase from the stroke thickness)
  4639. cellUboFloats[2] = x;
  4640. cellUboFloats[3] = y;
  4641. if (aux.settings.jellyPhysics && !cell.jagged && !cell.pellet) {
  4642. const strokeThickness = Math.max(jr * 0.01, 10);
  4643. cellUboFloats[0] = jr + strokeThickness;
  4644. cellUboFloats[1] = (settings.jellySkinLag ? r : jr) + strokeThickness;
  4645. } else {
  4646. cellUboFloats[0] = cellUboFloats[1] = r + 2;
  4647. }
  4648.  
  4649. if (cell.jagged) {
  4650. const virusTexture = textureFromCache(virusSrc);
  4651. if (virusTexture) {
  4652. gl.bindTexture(gl.TEXTURE_2D, virusTexture.texture);
  4653. cellUboInts[9] = 0x01; // skin and nothing else
  4654. // draw a fully transparent cell
  4655. cellUboFloats[4] = cellUboFloats[5] = cellUboFloats[6] = cellUboFloats[7] = 0;
  4656. } else {
  4657. cellUboInts[9] = 0;
  4658. cellUboFloats[4] = 1;
  4659. cellUboFloats[5] = 0;
  4660. cellUboFloats[6] = 0;
  4661. cellUboFloats[7] = 0.5;
  4662. }
  4663.  
  4664. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Cell);
  4665. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, cellUboBuffer);
  4666. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4667. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4668. // draw default viruses twice for better contrast against light theme
  4669. if (!aux.settings.darkTheme && virusSrc === defaultVirusSrc)
  4670. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4671. return;
  4672. }
  4673.  
  4674. cellUboInts[9] = 0;
  4675.  
  4676. /** @type {[number, number, number, number] | [number, number, number] | undefined} */
  4677. let color = cell.pellet ? foodColor : cellColor;
  4678. if (cell.pellet && foodColor && foodColor[0] === 0 && foodColor[1] === 0 && foodColor[2] === 0) {
  4679. color = [cell.rgb[0], cell.rgb[1], cell.rgb[2], foodColor[3]];
  4680. } else {
  4681. color ??= cell.rgb;
  4682. }
  4683.  
  4684. cellUboFloats[4] = color[0]; cellUboFloats[5] = color[1];
  4685. cellUboFloats[6] = color[2]; cellUboFloats[7] = color[3] ?? 1;
  4686.  
  4687. cellUboInts[9] |= settings.cellOutlines ? 0x02 : 0;
  4688. cellUboInts[9] |= settings.colorUnderSkin ? 0x20 : 0;
  4689.  
  4690. if (!cell.pellet) {
  4691. /** @type {symbol | undefined} */
  4692. let ownerView;
  4693. let ownerVision;
  4694. for (const [otherView, otherVision] of world.views) {
  4695. if (!otherVision.owned.includes(cell.id)) continue;
  4696. ownerView = otherView;
  4697. ownerVision = otherVision;
  4698. break;
  4699. }
  4700.  
  4701. if (ownerView === world.selected) {
  4702. const myIndex = vision.owned.indexOf(cell.id);
  4703. if (!canSplit[myIndex]) cellUboInts[9] |= 0x10;
  4704.  
  4705. if (vision.camera.merging.length > 0) cellUboInts[9] |= 0x04;
  4706. } else if (ownerVision) {
  4707. if (ownerVision.camera.merging.length > 0) cellUboInts[9] |= 0x08;
  4708. }
  4709.  
  4710. const texture = calcSkin(cell);
  4711. if (texture) {
  4712. cellUboInts[9] |= 0x01; // skin
  4713. gl.bindTexture(gl.TEXTURE_2D, texture.texture);
  4714. }
  4715. }
  4716.  
  4717. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Cell);
  4718. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, cellUboBuffer);
  4719. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4720. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4721.  
  4722. // #2 : draw text
  4723. if (cell.pellet) return;
  4724. const name = cell.name || 'An unnamed cell';
  4725. const showThisName = showNames && cell.nr > 75;
  4726. const showThisMass = aux.settings.showMass && cell.nr > 75;
  4727. const clan = (settings.clans && aux.clans.get(cell.clan)) || '';
  4728. if (!showThisName && !showThisMass && !clan) return;
  4729.  
  4730. gl.useProgram(glconf.programs.text);
  4731. textUboFloats[8] = alpha; // text_alpha
  4732.  
  4733. let useSilhouette = false;
  4734. if (cell.sub) {
  4735. // text_color1 = #eb9500 * 1.2
  4736. textUboFloats[0] = 0xeb / 255 * 1.2; textUboFloats[1] = 0x95 / 255 * 1.2;
  4737. textUboFloats[2] = 0x00 / 255 * 1.2; textUboFloats[3] = 1;
  4738. // text_color2 = #f9bf0d * 1.2
  4739. textUboFloats[4] = 0xf9 / 255 * 1.2; textUboFloats[5] = 0xbf / 255 * 1.2;
  4740. textUboFloats[6] = 0x0d / 255 * 1.2; textUboFloats[7] = 1;
  4741. useSilhouette = true;
  4742. } else {
  4743. // text_color1 = text_color2 = #fff
  4744. textUboFloats[0] = textUboFloats[1] = textUboFloats[2] = textUboFloats[3] = 1;
  4745. textUboFloats[4] = textUboFloats[5] = textUboFloats[6] = textUboFloats[7] = 1;
  4746. }
  4747.  
  4748. if (name === input.nick1.value || (settings.multibox && name === input.nick2.value)) {
  4749. const { nameColor1, nameColor2 } = sigmod.settings;
  4750. if (nameColor1) {
  4751. textUboFloats[0] = nameColor1[0]; textUboFloats[1] = nameColor1[1];
  4752. textUboFloats[2] = nameColor1[2]; textUboFloats[3] = nameColor1[3];
  4753. useSilhouette = true;
  4754. }
  4755.  
  4756. if (nameColor2) {
  4757. textUboFloats[4] = nameColor2[0]; textUboFloats[5] = nameColor2[1];
  4758. textUboFloats[6] = nameColor2[2]; textUboFloats[7] = nameColor2[3];
  4759. useSilhouette = true;
  4760. }
  4761. }
  4762.  
  4763. if (clan) {
  4764. const { aspectRatio, text, silhouette } = textFromCache(clan, useSilhouette);
  4765. if (text) {
  4766. textUboFloats[9] = aspectRatio; // text_aspect_ratio
  4767. textUboFloats[10]
  4768. = showThisName ? settings.clanScaleFactor * 0.5 : settings.nameScaleFactor;
  4769. textUboInts[11] = Number(useSilhouette); // text_silhouette_enabled
  4770. textUboFloats[12] = 0; // text_offset.x
  4771. textUboFloats[13] = showThisName
  4772. ? -settings.nameScaleFactor/3 - settings.clanScaleFactor/6 : 0; // text_offset.y
  4773.  
  4774. gl.bindTexture(gl.TEXTURE_2D, text);
  4775. if (silhouette) {
  4776. gl.activeTexture(gl.TEXTURE1);
  4777. gl.bindTexture(gl.TEXTURE_2D, silhouette);
  4778. gl.activeTexture(gl.TEXTURE0);
  4779. }
  4780.  
  4781. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Text);
  4782. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, textUboBuffer);
  4783. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4784. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4785. }
  4786. }
  4787.  
  4788. if (showThisName) {
  4789. const { aspectRatio, text, silhouette } = textFromCache(name, useSilhouette);
  4790. if (text) {
  4791. textUboFloats[9] = aspectRatio; // text_aspect_ratio
  4792. textUboFloats[10] = settings.nameScaleFactor; // text_scale
  4793. textUboInts[11] = Number(useSilhouette); // text_silhouette_enabled
  4794. textUboFloats[12] = textUboFloats[13] = 0; // text_offset = (0, 0)
  4795.  
  4796. gl.bindTexture(gl.TEXTURE_2D, text);
  4797. if (silhouette) {
  4798. gl.activeTexture(gl.TEXTURE1);
  4799. gl.bindTexture(gl.TEXTURE_2D, silhouette);
  4800. gl.activeTexture(gl.TEXTURE0);
  4801. }
  4802.  
  4803. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Text);
  4804. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, textUboBuffer);
  4805. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4806. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4807. }
  4808. }
  4809.  
  4810. if (showThisMass) {
  4811. textUboFloats[8] = alpha * settings.massOpacity; // text_alpha
  4812. textUboFloats[10] = 0.5 * settings.massScaleFactor; // text_scale
  4813. textUboInts[11] = 0; // text_silhouette_enabled
  4814.  
  4815. let yOffset;
  4816. if (showThisName)
  4817. yOffset = (settings.nameScaleFactor + 0.5 * settings.massScaleFactor) / 3;
  4818. else if (clan)
  4819. yOffset = (1 + 0.5 * settings.massScaleFactor) / 3;
  4820. else
  4821. yOffset = 0;
  4822. // draw each digit separately, as Ubuntu makes them all the same width.
  4823. // significantly reduces the size of the text cache
  4824. const mass = Math.floor(cell.nr * cell.nr / 100).toString();
  4825. const maxWidth = maxMassWidth();
  4826. for (let i = 0; i < mass.length; ++i) {
  4827. const { height, width, texture } = massTextFromCache(mass[i]);
  4828. textUboFloats[9] = width / height; // text_aspect_ratio
  4829. // text_offset.x; kerning is fixed by subtracting most of the padding from lineWidth
  4830. textUboFloats[12] = (i - (mass.length - 1) / 2) * settings.massScaleFactor
  4831. * (maxWidth / width)
  4832. * (maxWidth - 20 * settings.textOutlinesFactor * settings.massScaleFactor) / maxWidth;
  4833. textUboFloats[13] = yOffset;
  4834. gl.bindTexture(gl.TEXTURE_2D, texture);
  4835.  
  4836. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Text);
  4837. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, textUboBuffer);
  4838. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4839. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4840. }
  4841. }
  4842. }
  4843.  
  4844. // draw static pellets first
  4845. let i = 0;
  4846. for (const resolution of world.pellets.values()) {
  4847. // deadTo property should never change in between upload('pellets') calls
  4848. if (resolution.merged?.deadTo !== -1) continue;
  4849. pelletAlpha[i++] = calcAlpha(resolution.merged, now);
  4850. }
  4851. gl.bindBuffer(gl.ARRAY_BUFFER, glconf.vao[0].alphaBuffer);
  4852. gl.bufferSubData(gl.ARRAY_BUFFER, 0, pelletAlpha);
  4853. gl.bindBuffer(gl.ARRAY_BUFFER, null);
  4854.  
  4855. // setup no-glow for static pellets
  4856. if (settings.pelletGlow && aux.settings.darkTheme) {
  4857. gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // make sure pellets (and glow) are visible in light theme
  4858. }
  4859. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
  4860. circleUboFloats[0] = 1;
  4861. circleUboFloats[1] = 0;
  4862. gl.bufferData(gl.UNIFORM_BUFFER, circleUboFloats, gl.STATIC_DRAW);
  4863. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4864.  
  4865. // draw static pellets
  4866. gl.useProgram(glconf.programs.circle);
  4867. gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, uploadedPellets);
  4868. if (settings.pelletGlow) {
  4869. // setup glow for static pellets
  4870. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
  4871. circleUboFloats[0] = 0.25;
  4872. circleUboFloats[1] = 2;
  4873. gl.bufferData(gl.UNIFORM_BUFFER, circleUboFloats, gl.STATIC_DRAW);
  4874. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4875.  
  4876. // draw glow for static pellets
  4877. gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, uploadedPellets);
  4878. gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  4879. }
  4880.  
  4881. // then draw *animated* pellets
  4882. for (const resolution of world.pellets.values()) {
  4883. // animated pellets shouldn't glow
  4884. if (resolution.merged && resolution.merged.deadTo !== -1) draw(resolution.merged);
  4885. }
  4886.  
  4887. /** @type {[Cell, number][]} */
  4888. const sorted = [];
  4889. for (const resolution of world.cells.values()) {
  4890. const cell = resolution.merged;
  4891. if (!cell) continue;
  4892. const rAlpha = Math.min(Math.max((now - cell.updated) / settings.drawDelay, 0), 1);
  4893. const computedR = cell.or + (cell.nr - cell.or) * rAlpha;
  4894. sorted.push([cell, computedR]);
  4895. }
  4896.  
  4897. // sort by smallest to biggest
  4898. sorted.sort(([_a, ar], [_b, br]) => ar - br);
  4899. for (const [cell] of sorted)
  4900. draw(cell);
  4901.  
  4902. if (settings.cellGlow) {
  4903. render.upload('cells', now);
  4904. let i = 0;
  4905. for (const resolution of world.cells.values()) {
  4906. const cell = resolution.merged;
  4907. if (!cell) continue;
  4908. if (cell.jagged) cellAlpha[i++] = 0;
  4909. else {
  4910. let alpha = calcAlpha(cell, now);
  4911. // it looks kinda weird when cells get sucked in when being eaten
  4912. if (cell.deadTo !== -1) alpha *= 0.25;
  4913. cellAlpha[i++] = alpha;
  4914. }
  4915. }
  4916.  
  4917. gl.bindBuffer(gl.ARRAY_BUFFER, glconf.vao[1].alphaBuffer);
  4918. gl.bufferSubData(gl.ARRAY_BUFFER, 0, cellAlpha);
  4919. gl.bindBuffer(gl.ARRAY_BUFFER, null);
  4920.  
  4921. // use a separate vao for cells, so pellet data doesn't have to be copied as often
  4922. gl.useProgram(glconf.programs.circle);
  4923. gl.bindVertexArray(glconf.vao[1].vao);
  4924. gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
  4925.  
  4926. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Circle);
  4927. circleUboFloats[0] = 0.25;
  4928. circleUboFloats[1] = 1.5;
  4929. gl.bufferData(gl.UNIFORM_BUFFER, circleUboFloats, gl.STATIC_DRAW);
  4930. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4931.  
  4932. gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, i);
  4933. gl.bindVertexArray(glconf.vao[0].vao);
  4934. gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  4935. }
  4936.  
  4937. // draw tracers
  4938. if (settings.tracer) {
  4939. gl.useProgram(glconf.programs.tracer);
  4940.  
  4941. const inputs = input.views.get(world.selected);
  4942. if (inputs) {
  4943. let mouse;
  4944. if (inputs.lock && now <= inputs.lock.until) mouse = inputs.lock.world;
  4945. else mouse = input.toWorld(world.selected, input.current);
  4946. [tracerUboFloats[2], tracerUboFloats[3]] = mouse; // tracer_pos2.xy
  4947. }
  4948.  
  4949. ownedToMerged.forEach(cell => {
  4950. if (!cell || cell.deadAt !== undefined) return;
  4951.  
  4952. const { x, y } = world.xyr(cell, undefined, now);
  4953. tracerUboFloats[0] = x; // tracer_pos1.x
  4954. tracerUboFloats[1] = y; // tracer_pos1.y
  4955. gl.bindBuffer(gl.UNIFORM_BUFFER, glconf.uniforms.Tracer);
  4956. gl.bufferSubData(gl.UNIFORM_BUFFER, 0, tracerUboBuffer);
  4957. gl.bindBuffer(gl.UNIFORM_BUFFER, null);
  4958. gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  4959. });
  4960. }
  4961. })();
  4962.  
  4963. ui.stats.update(world.selected);
  4964.  
  4965. (function minimap() {
  4966. if (now - lastMinimapDraw < 40) return; // should be good enough when multiboxing, may change later
  4967. lastMinimapDraw = now;
  4968.  
  4969. if (!aux.settings.showMinimap) {
  4970. ui.minimap.canvas.style.display = 'none';
  4971. return;
  4972. } else {
  4973. ui.minimap.canvas.style.display = '';
  4974. }
  4975.  
  4976. const { canvas, ctx } = ui.minimap;
  4977. // clears the canvas
  4978. const canvasLength = canvas.width = canvas.height = Math.ceil(200 * (devicePixelRatio - 0.0001));
  4979. const sectorSize = canvas.width / 5;
  4980.  
  4981. // cache the background if necessary (25 texts = bad)
  4982. if (minimapCache && minimapCache.bg.width === canvasLength
  4983. && minimapCache.darkTheme === aux.settings.darkTheme) {
  4984. ctx.putImageData(minimapCache.bg, 0, 0);
  4985. } else {
  4986. // draw section names
  4987. ctx.font = `${Math.floor(sectorSize / 3)}px "${sigmod.settings.font || 'Ubuntu'}", Ubuntu`;
  4988. ctx.fillStyle = '#fff';
  4989. ctx.globalAlpha = aux.settings.darkTheme ? 0.3 : 0.7;
  4990. ctx.textAlign = 'center';
  4991. ctx.textBaseline = 'middle';
  4992.  
  4993. const cols = ['1', '2', '3', '4', '5'];
  4994. const rows = ['A', 'B', 'C', 'D', 'E'];
  4995. cols.forEach((col, y) => {
  4996. rows.forEach((row, x) => {
  4997. ctx.fillText(row + col, (x + 0.5) * sectorSize, (y + 0.5) * sectorSize);
  4998. });
  4999. });
  5000.  
  5001. minimapCache = {
  5002. bg: ctx.getImageData(0, 0, canvas.width, canvas.height),
  5003. darkTheme: aux.settings.darkTheme,
  5004. };
  5005. }
  5006.  
  5007. const { border } = vision;
  5008. if (!border) return;
  5009.  
  5010. // sigmod overlay resizes itself differently, so we correct it whenever we need to
  5011. /** @type {HTMLCanvasElement | null} */
  5012. const sigmodMinimap = document.querySelector('canvas.minimap');
  5013. if (sigmodMinimap) {
  5014. // we need to check before updating the canvas, otherwise we will clear it
  5015. if (sigmodMinimap.style.width !== '200px' || sigmodMinimap.style.height !== '200px')
  5016. sigmodMinimap.style.width = sigmodMinimap.style.height = '200px';
  5017.  
  5018. if (sigmodMinimap.width !== canvas.width || sigmodMinimap.height !== canvas.height)
  5019. sigmodMinimap.width = sigmodMinimap.height = canvas.width;
  5020. }
  5021.  
  5022. const gameWidth = (border.r - border.l);
  5023. const gameHeight = (border.b - border.t);
  5024.  
  5025. // highlight current section
  5026. ctx.fillStyle = '#ff0';
  5027. ctx.globalAlpha = 0.3;
  5028.  
  5029. const sectionX = Math.floor((vision.camera.x - border.l) / gameWidth * 5);
  5030. const sectionY = Math.floor((vision.camera.y - border.t) / gameHeight * 5);
  5031. ctx.fillRect(sectionX * sectorSize, sectionY * sectorSize, sectorSize, sectorSize);
  5032.  
  5033. // draw section names
  5034. ctx.font = `${Math.floor(sectorSize / 3)}px Ubuntu`;
  5035. ctx.fillStyle = aux.settings.darkTheme ? '#fff' : '#000';
  5036. ctx.textAlign = 'center';
  5037. ctx.textBaseline = 'middle';
  5038.  
  5039. ctx.globalAlpha = 1;
  5040.  
  5041. // draw cells
  5042. /** @param {{ nx: number, ny: number, nr: number, rgb: [number, number, number] }} cell */
  5043. const drawCell = function drawCell(cell) {
  5044. const x = (cell.nx - border.l) / gameWidth * canvas.width;
  5045. const y = (cell.ny - border.t) / gameHeight * canvas.height;
  5046. const r = Math.max(cell.nr / gameWidth * canvas.width, 2);
  5047.  
  5048. ctx.scale(0.01, 0.01); // prevent sigmod from treating minimap cells as pellets
  5049. ctx.fillStyle = aux.rgba2hex6(cell.rgb[0], cell.rgb[1], cell.rgb[2], 1);
  5050. ctx.beginPath();
  5051. ctx.moveTo((x + r) * 100, y * 100);
  5052. ctx.arc(x * 100, y * 100, r * 100, 0, 2 * Math.PI);
  5053. ctx.fill();
  5054. ctx.resetTransform();
  5055. };
  5056.  
  5057. /**
  5058. * @param {number} x
  5059. * @param {number} y
  5060. * @param {string} name
  5061. */
  5062. const drawName = function drawName(x, y, name) {
  5063. x = (x - border.l) / gameWidth * canvas.width;
  5064. y = (y - border.t) / gameHeight * canvas.height;
  5065.  
  5066. ctx.fillStyle = '#fff';
  5067. // add a space to prevent sigmod from detecting names
  5068. ctx.fillText(name + ' ', x, y - 7 * devicePixelRatio - sectorSize / 6);
  5069. };
  5070.  
  5071. // draw clanmates first, below yourself
  5072. // we sort clanmates by color AND name, to ensure clanmates stay separate
  5073. /** @type {Map<string, { name: string, n: number, x: number, y: number }>} */
  5074. const avgPos = new Map();
  5075. for (const resolution of world.cells.values()) {
  5076. const cell = resolution.merged;
  5077. if (!cell) continue;
  5078. let owned = false;
  5079. for (const vision of world.views.values()) owned ||= vision.owned.includes(cell.id);
  5080. if (!owned && (!cell.clan || cell.clan !== aux.userData?.clan)) continue;
  5081. drawCell(cell);
  5082.  
  5083. const name = cell.name || 'An unnamed cell';
  5084. const id = ((name + cell.rgb[0]) + cell.rgb[1]) + cell.rgb[2];
  5085. const entry = avgPos.get(id);
  5086. if (entry) {
  5087. ++entry.n;
  5088. entry.x += cell.nx;
  5089. entry.y += cell.ny;
  5090. } else {
  5091. avgPos.set(id, { name, n: 1, x: cell.nx, y: cell.ny });
  5092. }
  5093. }
  5094.  
  5095. avgPos.forEach(entry => {
  5096. drawName(entry.x / entry.n, entry.y / entry.n, entry.name);
  5097. });
  5098.  
  5099. // draw my cells above everyone else
  5100. let myName = '';
  5101. let ownN = 0;
  5102. let ownX = 0;
  5103. let ownY = 0;
  5104. vision.owned.forEach(id => {
  5105. const cell = world.cells.get(id)?.merged;
  5106. if (!cell) return;
  5107.  
  5108. drawCell(cell);
  5109. myName = cell.name || 'An unnamed cell';
  5110. ++ownN;
  5111. ownX += cell.nx;
  5112. ownY += cell.ny;
  5113. });
  5114.  
  5115. if (ownN <= 0) {
  5116. // if no cells were drawn, draw our spectate pos instead
  5117. drawCell({
  5118. nx: vision.camera.x, ny: vision.camera.y, nr: gameWidth / canvas.width * 5,
  5119. rgb: [1, 0.6, 0.6],
  5120. });
  5121. } else {
  5122. ownX /= ownN;
  5123. ownY /= ownN;
  5124. // draw name above player's cells
  5125. drawName(ownX, ownY, myName);
  5126.  
  5127. // send a hint to sigmod
  5128. ctx.globalAlpha = 0;
  5129. ctx.fillText(`X: ${ownX}, Y: ${ownY}`, 0, -1000);
  5130. }
  5131. })();
  5132.  
  5133. ui.chat.matchTheme();
  5134.  
  5135. requestAnimationFrame(renderGame);
  5136. }
  5137.  
  5138. requestAnimationFrame(renderGame);
  5139. return render;
  5140. })();
  5141.  
  5142.  
  5143.  
  5144. // @ts-expect-error for debugging purposes and other scripts. dm me on discord @ 8y8x to guarantee stability
  5145. window.sigfix = {
  5146. destructor, aux, sigmod, ui, settings, world, net, input, glconf, render,
  5147. };
  5148. })();