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