Compare commits

..

No commits in common. "2688e86b9daaa2468b9ba89bcac3ab8912ee5f1a" and "b88129823195027dcd49eb63019c3c31eb315598" have entirely different histories.

14 changed files with 343 additions and 1119 deletions

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 FX Client
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

139
build.js
View File

@ -1,21 +1,17 @@
// @ts-check
import beautifier from 'js-beautify';
const { js: beautify } = beautifier;
import UglifyJS from 'uglify-js';
import fs from 'fs';
import webpack from 'webpack';
import path from 'path';
import applyPatches from './patches/patches.js';
import ModUtils, { minifyCode } from './modUtils.js';
import applyPatches from './patches.js';
if (!fs.existsSync("./build")) fs.mkdirSync("./build");
fs.cpSync("./static/", "./build/", { recursive: true });
fs.cpSync("./assets/", "./build/assets/", { recursive: true });
const buildTimestamp = Date.now().toString();
fs.writeFileSync("./build/index.html", fs.readFileSync("./build/index.html").toString().replace(/buildTimestamp/g, buildTimestamp));
fs.writeFileSync("./build/sw.js", fs.readFileSync("./build/sw.js").toString().replace("buildTimestamp", buildTimestamp));
fs.writeFileSync("./build/index.html", fs.readFileSync("./build/index.html").toString().replace(/buildTimestamp/g, Date.now()));
const buildClientCode = () => /** @type {Promise<void>} */(new Promise((resolve, reject) => {
const buildClientCode = () => new Promise((resolve, reject) => {
webpack({
mode: 'production',
entry: { fxClient: "./src/main.js" },
@ -36,9 +32,9 @@ const buildClientCode = () => /** @type {Promise<void>} */(new Promise((resolve,
}
else resolve();
});
}));
});
let script = fs.readFileSync('./game/latest.js', { encoding: 'utf8' }).trim();
let script = fs.readFileSync('./game/latest.js', { encoding: 'utf8' }).replace("\n", "").trim();
const exposeVarsToGlobalScope = true;
// need to first remove the iife wrapper so the top-level functions aren't inlined
@ -47,35 +43,93 @@ if (exposeVarsToGlobalScope && script.startsWith("\"use strict\"; (function (
if (exposeVarsToGlobalScope && script.startsWith("(function () {") && script.endsWith("})();"))
script = script.slice("(function () {".length, -"})();".length);
// uncompress strings
// this will break if there is a closing square bracket ("]") in one of the strings
const stringArrayRaw = script.match(/var S=(\[[^\]]+\]);/)?.[1];
if (stringArrayRaw === undefined) throw new Error("cannot find the string array");
const stringArray = JSON.parse(stringArrayRaw);
script = script.replace(/\bS\[(\d+)\]/g, (_match, index) => `"${stringArray[index]}"`);
const modUtils = new ModUtils(minifyCode(script));
import customLobbyPatches from './patches/customLobby.js';
customLobbyPatches(modUtils);
// for versions ^1.99.5.2
const minificationResult = UglifyJS.minify(modUtils.script, {
const minificationResult = UglifyJS.minify(script, {
"compress": { "arrows": false },
"mangle": false
});
if (minificationResult.error) {
console.log("error while passing through UglifyJS, replaceCode replacements might have caused errors");
throw minificationResult.error;
}
if (minificationResult.error) console.log(minificationResult.error);
if (minificationResult.warnings) console.log(minificationResult.warnings);
modUtils.script = minificationResult.code;
script = minificationResult.code;
const {
matchDictionaryExpression,
generateRegularExpression
} = modUtils;
const dictionary = modUtils.dictionary;
const replaceOne = (expression, replaceValue) => {
const result = matchOne(expression);
// this (below) works correctly because expression.lastIndex gets reset above in matchOne when there is no match
script = script.replace(expression, replaceValue);
return result;
}
const replace = (...args) => script = script.replace(...args);
const matchOne = (expression) => {
const result = expression.exec(script);
if (result === null) throw new Error("no match for: ") + expression;
if (expression.exec(script) !== null) throw new Error("more than one match for: " + expression);
return result;
}
// https://stackoverflow.com/a/63838890
const escapeRegExp = (string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
//const dictionary = { __dictionaryVersion: '1.90.0 4 Feb 2024', playerId: 'bB', playerNames: 'hA', playerBalances: 'bC', playerTerritories: 'bj', gIsSingleplayer: 'fc', gIsTeamGame: 'cH' };
//if (!script.includes(`"${dictionary.__dictionaryVersion}"`)) throw new Error("Dictionary is outdated.");
const dictionary = {};
const matchDictionaryExpression = expression => {
const result = expression.exec(script);
if (result === null) throw new Error("no match for ") + expression;
if (expression.exec(script) !== null) throw new Error("more than one match for: ") + expression;
for (let [key, value] of Object.entries(result.groups)) dictionary[key] = value;
}
// Return value example:
// When replaceRawCode or matchRawCode are called with "var1=var2+1;" as the code
// and this matches "a=b+1;", the returned value will be the object: { var1: "a", var2: "b" }
const replaceRawCode = (/** @type {string} */ raw, /** @type {string} */ result, nameMappings) => {
const { expression, groups } = generateRegularExpression(raw, false, nameMappings);
let localizerCount = 0;
let replacementString = result.replaceAll("$", "$$").replaceAll("__L()", "__L)").replaceAll("__L(", "__L,")
.replace(/\w+/g, match => {
// these would get stored as "___localizer1", "___localizer2", ...
if (match === "__L") match = "___localizer" + (++localizerCount);
return groups.hasOwnProperty(match) ? "$" + groups[match] : match;
});
//console.log(replacementString);
let expressionMatchResult;
try { expressionMatchResult = replaceOne(expression, replacementString); }
catch (e) {
throw new Error("replaceRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
}
return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
}
const matchRawCode = (/** @type {string} */ raw, nameMappings) => {
const { expression, groups } = generateRegularExpression(raw, false, nameMappings);
const expressionMatchResult = matchOne(expression);
return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
}
const generateRegularExpression = (/** @type {string} */ code, /** @type {boolean} */ isForDictionary, nameMappings) => {
const groups = {};
let groupNumberCounter = 1;
let localizerCount = 0;
let raw = escapeRegExp(code).replaceAll("__L\\(\\)", "___localizer\\)")
// when there is a parameter, add a comma to separate it from the added number
.replaceAll("__L\\(", "___localizer,");
raw = raw.replace(isForDictionary ? /(?:@@)*(@?)(\w+)/g : /()(\w+)/g, (_match, modifier, word) => {
// if a substitution string for the "word" is specified in the nameMappings, use it
if (nameMappings && nameMappings.hasOwnProperty(word)) return nameMappings[word];
// if the "word" is a number or is one of these specific words, ingore it
if (/^\d/.test(word) || ["return", "this", "var", "function", "Math"].includes(word)) return word;
// for easy localizer function matching
else if (word === "___localizer") {
groups[word + (++localizerCount)] = groupNumberCounter++;
return "\\b(L\\(\\d+)"; // would match "L(123", "L(50" and etc. when using "__L("
}
else if (groups.hasOwnProperty(word)) return "\\" + groups[word]; // regex numeric reference to the group
else {
groups[word] = groupNumberCounter++;
return modifier === "@" ? `(?<${word}>\\w+)` : "(\\w+)";
}
});
let expression = new RegExp(isForDictionary ? raw.replaceAll("@@", "@") : raw, "g");
return { expression, groups };
}
[
/,this\.(?<gIsTeamGame>\w+)=this\.\w+<7\|\|9===this\.\w+,/g,
@ -86,12 +140,11 @@ const dictionary = modUtils.dictionary;
].forEach(matchDictionaryExpression);
const rawCodeSegments = [
`aQ.eI(e0)?aQ.eE(e0)?a38=__L([a38]):(player=aQ.eF(e0),oq=__L([b0.uS.zG(@playerData.@rawPlayerNames[player],b0.p9.qQ(0,10),150)])+" ",oq=(oq+=__L([b0.wx.a07(playerData.@playerBalances[player])])+" ")+__L([b0.wx.a07(playerData.@playerTerritories[player])])+" ",`,
"this.@gLobbyMaxJoin=1===dg?this.@gHumans:this.@data.@playerCount,this.tZ=this.gLobbyMaxJoin,this.@gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,",
`aR.f1(fy)?aR.fB(fy)?a0z=__L([a0z]):(player=aR.fA(fy),oM=__L([b1.t9.xw(@playerData.@rawPlayerNames[player],b1.kx.l2(0,10),150)])+" ",a0z=(oM+=__L([b1.l5.l6(playerData.@playerBalances[player])])+" ")+(__L([b1.l5.l6(playerData.@playerTerritories[player])])+" ")+`,
"this.@gIsSingleplayer?this.@gLobbyMaxJoin=@SingleplayerMenu.@getSingleplayerPlayerCount():this.gLobbyMaxJoin=this.@gMaxPlayers,this.@gBots=this.gLobbyMaxJoin-this.@gHumans,this.sg=0,",
"[0]=__L(),@strs[1]=@game.@gIsSingleplayer?__L():__L(),",
"?(this.gB=Math.floor(.066*aK.fw),g5=aK.g5-4*@uiSizes.@gap-this.gB):",
`for(a0L=new Array(@game.@gMaxPlayers),a0A.font=a07,@i=game.gMaxPlayers-1;0<=i;i--)a0L[i]=i+1+".",@playerData.@playerNames[i]=aY.qW.tm(playerData.@rawPlayerNames[i],a07,a0W),a0K[i]=Math.floor(a0A.measureText(playerData.playerNames[i]).width);`,
`var dt=@MenuManager.@getState();if(6===dt){if(4211===d)`
]
rawCodeSegments.forEach(code => {
@ -100,24 +153,18 @@ rawCodeSegments.forEach(code => {
matchDictionaryExpression(expression);
});
modUtils.executePostMinifyHandlers();
applyPatches(modUtils);
script = modUtils.script;
applyPatches({ replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp });
await buildClientCode();
// the dictionary should maybe get embedded into one of the files in the bundle
fs.writeFileSync(
"./build/fx.bundle.js",
`const buildTimestamp = "${buildTimestamp}"; const dictionary = ${JSON.stringify(dictionary)};\n`
+ fs.readFileSync("./build/fx.bundle.js").toString()
);
fs.writeFileSync("./build/fx.bundle.js", `const dictionary = ${JSON.stringify(dictionary)};\n` + fs.readFileSync("./build/fx.bundle.js").toString());
console.log("Formatting code...");
script = beautify(script, {
"indent_size": 1,
"indent_size": "1",
"indent_char": "\t",
"max_preserve_newlines": 5,
"max_preserve_newlines": "5",
"preserve_newlines": true,
"keep_array_indentation": false,
"break_chained_methods": false,
@ -128,7 +175,7 @@ script = beautify(script, {
"unescape_strings": false,
"jslint_happy": false,
"end_with_newline": false,
"wrap_line_length": 250,
"wrap_line_length": "250",
"indent_inner_html": false,
"comma_first": false,
"e4x": false,

View File

@ -1,134 +0,0 @@
// @ts-check
import UglifyJS from 'uglify-js';
// https://stackoverflow.com/a/63838890
const escapeRegExp = (/** @type {string} */ string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
export function minifyCode(/** @type {string} */ script) {
const output = UglifyJS.minify(script, {
compress: false,
mangle: false
});
if (output.error) throw output.error;
if (output.warnings) throw (output.warnings);
if (output.warnings) console.warn(output.warnings);
return output.code;
}
class ModUtils {
script = "";
/** @type {{[key: string]: string}} */
dictionary = {};
/** @type {Function[]} */
postMinifyHandlers = [];
constructor(/** @type {string} */ script) {
this.script = script;
// Bind methods
this.matchDictionaryExpression = this.matchDictionaryExpression.bind(this);
this.generateRegularExpression = this.generateRegularExpression.bind(this);
this.replace = this.replace.bind(this);
this.replaceOne = this.replaceOne.bind(this);
this.replaceRawCode = this.replaceRawCode.bind(this);
this.matchOne = this.matchOne.bind(this);
this.matchRawCode = this.matchRawCode.bind(this);
this.replaceCode = this.replaceCode.bind(this);
this.waitForMinification = this.waitForMinification.bind(this);
}
/** @param {RegExp} expression */
matchDictionaryExpression(expression) {
const result = this.matchOne(expression);
// @ts-ignore
for (let [key, value] of Object.entries(result.groups)) this.addToDictionary(key, value);
}
replace(/** @type {Parameters<typeof String.prototype.replace>} */ ...args) {
return this.script = this.script.replace(...args);
};
/** Expressions passed to this function must have the global flag set. */
matchOne(/** @type {RegExp} */ expression) {
const result = expression.exec(this.script);
if (result === null) throw new Error("no match for: " + expression.toString());
if (expression.exec(this.script) !== null) throw new Error("more than one match for: " + expression.toString());
return result;
};
addToDictionary(/** @type {string} */ key, /** @type {string} */ value) {
if (this.dictionary[key] !== undefined && this.dictionary[key] !== value)
throw new Error("name different from existing one:\n KEY: " + key + "\n VALUE: " + value + "\n Value in dictionary: " + this.dictionary[key]);
this.dictionary[key] = value;
};
/**
* @param {RegExp} expression
* @param {string} replaceValue
*/
replaceOne(expression, replaceValue) {
const result = this.matchOne(expression);
// this (below) works correctly because expression.lastIndex gets reset above in matchOne when there is no match
this.script = this.script.replace(expression, replaceValue);
return result;
};
// Return value example:
// When replaceRawCode or matchRawCode are called with "var1=var2+1;" as the code
// and this matches "a=b+1;", the returned value will be the object: { var1: "a", var2: "b" }
replaceRawCode(/** @type {string} */ raw, /** @type {string} */ result, nameMappings) {
const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
let localizerCount = 0;
let replacementString = result.replaceAll("$", "$$").replaceAll("__L()", "__L)").replaceAll("__L(", "__L,")
.replace(/\w+/g, match => {
// these would get stored as "___localizer1", "___localizer2", ...
if (match === "__L") match = "___localizer" + (++localizerCount);
return groups.hasOwnProperty(match) ? "$" + groups[match] : match;
});
//console.log(replacementString);
let expressionMatchResult;
try { expressionMatchResult = this.replaceOne(expression, replacementString); }
catch (e) {
throw new Error("replaceRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
}
return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
}
matchRawCode(/** @type {string} */ raw, nameMappings) {
const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
const expressionMatchResult = this.matchOne(expression);
return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
}
generateRegularExpression(/** @type {string} */ code, /** @type {boolean} */ isForDictionary, nameMappings) {
const groups = {};
let groupNumberCounter = 1;
let localizerCount = 0;
let raw = escapeRegExp(code).replaceAll("__L\\(\\)", "___localizer\\)")
// when there is a parameter, add a comma to separate it from the added number
.replaceAll("__L\\(", "___localizer,");
raw = raw.replace(isForDictionary ? /(?:@@)*(@?)(\w+)/g : /()(\w+)/g, (_match, modifier, word) => {
// if a substitution string for the "word" is specified in the nameMappings, use it
if (nameMappings && nameMappings.hasOwnProperty(word)) return nameMappings[word];
// if the "word" is a number or is one of these specific words, ingore it
if (/^\d/.test(word) || ["return", "this", "var", "function", "new", "Math", "WebSocket"].includes(word)) return word;
// for easy localizer function matching
else if (word === "___localizer") {
groups[word + (++localizerCount)] = groupNumberCounter++;
return "\\b(L\\(\\d+)"; // would match "L(123", "L(50" and etc. when using "__L("
}
else if (groups.hasOwnProperty(word)) return "\\" + groups[word]; // regex numeric reference to the group
else {
groups[word] = groupNumberCounter++;
return modifier === "@" ? `(?<${word}>\\w+)` : "(\\w+)";
}
});
let expression = new RegExp(isForDictionary ? raw.replaceAll("@@", "@") : raw, "g");
return { expression, groups };
}
replaceCode(code, replacement, options) {
return this.replaceRawCode(minifyCode(code), replacement);
}
waitForMinification(/** @type {Function} */ handler) {
this.postMinifyHandlers.push(handler);
}
executePostMinifyHandlers() {
this.postMinifyHandlers.forEach(handler => handler());
}
escapeRegExp = escapeRegExp
}
export default ModUtils;

View File

@ -1,6 +1,5 @@
import assets from '../assets.js';
import ModUtils from '../modUtils.js';
export default (/** @type {ModUtils} */ { replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp }) => {
import assets from './assets.js';
export default ({ replace, replaceOne, replaceRawCode, dictionary, matchOne, matchRawCode, escapeRegExp }) => {
// Constants for easy usage of otherwise long variable access expressions
const dict = dictionary;
@ -29,10 +28,10 @@ export default (/** @type {ModUtils} */ { replace, replaceOne, replaceRawCode, d
// TODO: test this; it might cause issues with new boat mechanics?
{ // Add Troop Density and Maximum Troops in side panel
const { valuesArray } = replaceRawCode(`,labels[5]=__L(0,"Interest"),labels[6]=__L(),labels[7]=__L(),(truncatedLabels=new Array(labels.length)).fill(""),(valuesArray=new Array(labels.length))[0]=game.io?`,
`,labels[5]=__L(0,"Interest"),labels[6]=__L(),labels[7]=__L(),
const { valuesArray } = replaceRawCode(`,labels[5]=__L(),labels[6]=__L(),labels[7]=__L(),(valuesArray=new Array(labels.length))[0]=game.io?`,
`,labels[5]=__L(),labels[6]=__L(),labels[7]=__L(),
labels.push("Max Troops", "Density"), // add labels
(truncatedLabels=new Array(labels.length)).fill(""),(valuesArray=new Array(labels.length))[0]=game.io?`);
(valuesArray=new Array(labels.length))[0]=game.io?`);
replaceOne(new RegExp(/(:(?<valueIndex>\w+)<7\?\w+\.\w+\.\w+\(valuesArray\[\2\]\)):(\w+\.\w+\(valuesArray\[7\]\))}/
.source.replace(/valuesArray/g, valuesArray), "g"),
'$1 : $<valueIndex> === 7 ? $3 '
@ -43,25 +42,22 @@ export default (/** @type {ModUtils} */ { replace, replaceOne, replaceRawCode, d
}
// Increment win counter on wins
replaceRawCode(`=function(sE){a8.gD[sE]&&(o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0))`,
replaceRawCode(`=function(sE){o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0)`,
`=function(sE){
if (${playerId} === sE && !${gIsSingleplayer})
__fx.wins.count++, window.localStorage.setItem("fx_winCount", __fx.wins.count),
xD(0,"Your Win Count is now " + __fx.wins.count,3,sE,ad.gN,ad.kl,-1,!0);
a8.gD[sE]&&(o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0))`);
o.ha(sE,2),b.h9<100?xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0):xD(0,__L([a8.jx[sE]]),3,sE,ad.gN,ad.kl,-1,!0)`);
{ // Add settings button, custom lobby button and win count
// add buttons
{ // Add settings button and win count
// add settings button
replaceRawCode(`,new nQ("☰<br>"+__L(),function(){aD6(3)},aa.ks),new nQ("",function(){at.d5(12)},aa.kg,!1)]`,
`,new nQ("☰<br>"+__L(),function(){aD6(3)},aa.ks),new nQ("",function(){at.d5(12)},aa.kg,!1),
new nQ("FX Client settings", function() { __fx.WindowManager.openWindow("settings"); }, "rgba(0, 0, 20, 0.5)"),
new nQ("Join/Create custom lobby", function() { __fx.customLobby.showJoinPrompt(); }, "rgba(20, 9, 77, 0.5)")]`)
// set position
new nQ("FX Client settings", function() { __fx.WindowManager.openWindow("settings"); }, "rgba(0, 0, 20, 0.5")]`)
// set settings button position
replaceRawCode(`aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h);`,
`aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h);
aZ.g5.vO(aD3[5].button, x, a3X + h * 2 + gap * 2, a0S * 2 + gap, h / 3);
aZ.g5.vO(aD3[6].button, x, a3X + h * 2.33 + gap * 3, a0S * 2 + gap, h / 3);`);
`aZ.g5.vO(aD3[3].button,x+a0S+gap,a3X+h+gap,a0S,h); aZ.g5.vO(aD3[5].button, x, a3X + h * 2 + gap * 2, a0S * 2 + gap, h / 3);`);
// render win count
replaceRawCode(`if(_y.a4l(),_r.gI(),_m.gI(),aw.gI(),a0.g8()){ctx.imageSmoothingEnabled=!1;var iQ=a0.a4o("territorial.io"),kL=.84*aD4.gA/iQ.width;`,
`if(_y.a4l(),_r.gI(),_m.gI(),aw.gI(),a0.g8()){
@ -98,7 +94,7 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
{ // Keybinds
// match required variables
const { 0: match, groups: { attackBarObject, setRelative } } = matchOne(/:\w+\.\w+\(\w+,8\)\?(?<attackBarObject>\w+)\.(?<setRelative>\w+)\(32\/31\):/g);
const { 0: match, groups: { attackBarObject, setRelative } } = matchOne(/:"."===(\w+\.key)\?(?<attackBarObject>\w+)\.(?<setRelative>\w+)\(31\/32\):"."===\1\?\2\.\3\(32\/31\):/g,);
// create a setAbsolutePercentage function on the attack percentage bar object,
// and also register the keybind handler functions
replaceOne(/}(function \w+\((\w+)\){return!\(1<\2&&1===(?<attackPercentage>\w+)\|\|\(1<\2&&\2\*\3-\3<1\/1024\?\2=\(\3\+1\/1024\)\/\3:\2<1)/g,
@ -141,14 +137,12 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
`, ${dict.game}.${dict.gIsTeamGame} && __fx.donationsTracker.displayHistory($2, ${rawPlayerNames}, ${gIsSingleplayer}), $1 && !isEmptySpace $3`);
// Reset donation history and leaderboard filter when a new game is started
replaceRawCode(",ab.dP(),ad.a10(),b5.nZ.oJ=[],bc.dP(),this.wE=1,",
`,ab.dP(),ad.a10(),b5.nZ.oJ=[],bc.dP(),this.wE=1,
__fx.donationsTracker.reset(), __fx.leaderboardFilter.reset(), __fx.customLobby.isActive() && __fx.customLobby.hideWindow(),`)
replaceOne(new RegExp(`,this\\.${dictionary.playerBalances}.fill\\(0\\),`, "g"), "$& __fx.donationsTracker.reset(), __fx.leaderboardFilter.reset(), ");
{ // Player list and leaderboard filter tabs
// Draw player list button
const uiOffset = dictionary.uiSizes + "." + dictionary.gap;
const { groups: { drawFunction, topBarHeight } } = replaceOne(/(="";function (?<drawFunction>\w+)\(\){[^}]+?(?<canvas>\w+)\.fillRect\(0,(?<topBarHeight>\w+),\w+,1\),(?:\3\.fillRect\([^()]+\),)+\3\.font=\w+,(\w+\.\w+)\.textBaseline\(\3,1\),\5\.textAlign\(\3,1\),\3\.fillText\(\w+,Math\.floor\()(\w+)\/2\),(Math\.floor\(\w+\+\w+\/2\)\));/g,
const { groups: { drawFunction, topBarHeight } } = replaceOne(/(=1;function (?<drawFunction>\w+)\(\){[^}]+?(?<canvas>\w+)\.fillRect\(0,(?<topBarHeight>\w+),\w+,1\),(?:\3\.fillRect\([^()]+\),)+\3\.font=\w+,(\w+\.\w+)\.textBaseline\(\3,1\),\5\.textAlign\(\3,1\),\3\.fillText\(\w+\(\d+\),Math\.floor\()(\w+)\/2\),(Math\.floor\(\w+\+\w+\/2\)\));/g,
"$1($6 + $<topBarHeight> - 22) / 2), $7; __fx.playerList.drawButton($<canvas>, 12, 12, $<topBarHeight> - 22);");
const buttonBoundsCheck = `__fx.utils.isPointInRectangle($<x>, $<y>, ${uiOffset} + 12, ${uiOffset} + 12, ${topBarHeight} - 22, ${topBarHeight} - 22)`
// Handle player list button and leaderboard tabs mouseDown
@ -167,23 +161,19 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
)) return; $4`);
}
{ // Name rendering patches - Display density of other players & Hide bot names features
const { placeBalanceAbove } = matchRawCode(`,aGH+=Math.floor(.78*fontSize),placeBalanceAbove?aGN(a7,aGJ,aGG,aGH,hT):aGM(hT,a7,aGJ,aGG,aGH,aGI)`);
// Balance rendering; Renders density when the "Reverse Name/Balance" setting is off
{ // Display density of other players
const r = matchRawCode(`bD.dO.data[7].value?a9W(i,jm,jk,jl,ctx):a9V(ctx,i,jm,jk,jl,a9S)))`);
const settingsSwitchNameAndBalance = `${r.bD}.${r.dO}.${r.data}[7].${r.value}`;
//console.log(settingsSwitchNameAndBalance);
// Applies when the "Reverse Name/Balance" setting is off
replaceRawCode("function a9V(ctx,i,fontSize,x,y,a9S){i=ac.jv.formatNumber(playerData.playerBalances[i]);a9S>>1&1?(ctx.lineWidth=.05*fontSize,ctx.strokeStyle=a9U(fontSize,a9S%2),ctx.strokeText(i,x,y)):(1<a9S&&(ctx.lineWidth=.12*fontSize,ctx.strokeStyle=a9U(fontSize,a9S),ctx.strokeText(i,x,y)),ctx.fillText(i,x,y))}",
`function a9V(ctx,i,fontSize,x,y,a9S){
var ___id = i;
i=ac.jv.formatNumber(playerData.playerBalances[i]);a9S>>1&1?(ctx.lineWidth=.05*fontSize,ctx.strokeStyle=a9U(fontSize,a9S%2),ctx.strokeText(i,x,y)):(1<a9S&&(ctx.lineWidth=.12*fontSize,ctx.strokeStyle=a9U(fontSize,a9S),ctx.strokeText(i,x,y)),ctx.fillText(i,x,y));
${placeBalanceAbove} || __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && (ctx.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), ctx.fillText(__fx.utils.getDensity(___id), x, y + fontSize))}`)
// Name rendering; Renders density when the "Reverse Name/Balance" setting is on (default)
replaceOne(/(function \w+\((?<i>\w+),(?<fontSize>\w+),(?<x>\w+),(?<y>\w+),(?<canvas>\w+)\){)(\6\.fillText\((?<playerData>\w+)\.(?<playerNames>\w+)\[\2\],\4,\5\)),(\2<(?<game>\w+)\.(?<gHumans>\w+)&&2!==\8\.(?<playerStates>\w+)\[[^}]+)}/g,
`$1 var ___id = $2;
var showName = $<i> < $<game>.$<gHumans> || !__fx.settings.hideBotNames;
if (showName) $7, $10;
${placeBalanceAbove} && __fx.settings.showPlayerDensity && (
__fx.settings.coloredDensity && ($<canvas>.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)),
$<canvas>.fillText(__fx.utils.getDensity(___id), $<x>, showName ? $<y> + $<fontSize> : $<y>)
); }`);
${settingsSwitchNameAndBalance} || __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && (ctx.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), ctx.fillText(__fx.utils.getDensity(___id), x, y + fontSize))}`)
// Applies when the "Reverse Name/Balance" setting is on (default)
replaceOne(/(function \w+\((\w+),(?<fontSize>\w+),(?<x>\w+),(?<y>\w+),(?<canvas>\w+)\){)(\6\.fillText\((?<playerData>\w+)\.(?<playerNames>\w+)\[\2\],\4,\5\)),(\2<(?<game>\w+)\.(?<gHumans>\w+)&&2!==\8\.(?<playerStates>\w+)\[[^}]+)}/g,
`$1 var ___id = $2; $7, $10; ${settingsSwitchNameAndBalance} && __fx.settings.showPlayerDensity && (__fx.settings.coloredDensity && ($<canvas>.fillStyle = __fx.utils.textStyleBasedOnDensity(___id)), $<canvas>.fillText(__fx.utils.getDensity(___id), $<x>, $<y> + $<fontSize>)); }`);
}
{ // Leaderboard filter
@ -303,11 +293,6 @@ canvas.font=aY.g0.g1(1,fontSize),canvas.fillStyle="rgba("+gR+","+tD+","+hj+",0.6
)
}
// Detailed team pie chart percentage
replaceRawCode(`qr=Math.floor(100*f0+.5)+"%"`,
`qr = (__fx.settings.detailedTeamPercentage ? (100*f0).toFixed(2) : Math.floor(100*f0+.5)) + "%"`)
replaceRawCode(",fontSize=+dz*Math.min(f0,.37);", ",fontSize=(__fx.settings.detailedTeamPercentage ? 0.75 : 1)*dz*Math.min(f0,.37);")
// Invalid hostname detection avoidance
replaceRawCode(`,hostnameIsValid=0<=window.location.hostname.toLowerCase().indexOf("territorial.io"),`,
`,hostnameIsValid=true,`)

View File

@ -1,81 +0,0 @@
import ModUtils from '../modUtils.js';
// Custom lobby patches
export default (/** @type {ModUtils} */ { replaceCode, replaceRawCode, dictionary: dict, waitForMinification }) => {
// set player id correctly
replaceCode(`function aBG(aBE) {
if (!Lobby.aAl) { return -1; }
var s = aBE.length;
var qu = Lobby.aAl.qu;
for (var i = 0; i < s; i++) { if (aBE[i].qu === qu) { return i; } }
return -1;
}`, `function aBG(aBE) {
if (!Lobby.aAl) { return -1; }
if (__fx.customLobby.isActive()) return __fx.customLobby.getPlayerId();
var s = aBE.length;
var qu = Lobby.aAl.qu;
for (var i = 0; i < s; i++) { if (aBE[i].qu === qu) { return i; } }
return -1;
}`);
waitForMinification(() => {
replaceRawCode("this.aHm=function(){i___.rX(),aM.a7U(0),aM.init()}",
`this.aHm=function(){i___.rX(),aM.a7U(0),aM.init()},
__fx.customLobby.setJoinFunction(() => { i___.rX(); aM.a7U(0); aM.init(); })`
)
replaceRawCode(`(socketId-aq.kt.a82)+"/",(socket=new WebSocket(url)`,
`(socketId-aq.kt.a82)+"/",(socket=new WebSocket(__fx.customLobby.isActive() && socketId === 1 ? __fx.customLobby.getSocketURL() : url)`)
replaceRawCode("this.send=function(socketId,data){aJE(socketId),aJ4[socketId].send(data)}",
"this.send=function(socketId,data){aJE(socketId),aJ4[socketId].send(data)},__fx.customLobby.setSendFunction(this.send)")
replaceRawCode("b7.dH(a0),0===b7.size?aq.kt.aJJ(wR,3205):",
"b7.dH(a0),0===b7.size?aq.kt.aJJ(wR,3205):__fx.customLobby.isCustomMessage(a0)||")
// set the custom lobby to inactive when clicking the "Back" button on the connection screen or leaving the lobby
replaceRawCode("this.xZ=function(){Sockets.kt.wf(3260),i___.kt.we()}",
"this.xZ=function(){Sockets.kt.wf(3260),__fx.customLobby.setActive(false),i___.kt.we()}")
replaceRawCode("function(){n.r(),bl.zf(),Sockets.s.ze(3240),n.o(5,5)}",
`(__fx.customLobby.setLeaveFunction(() => {n.r(),bl.zf(),Sockets.s.ze(3240),__fx.customLobby.setActive(false),n.o(5,5)}),
function(){n.r(),bl.zf(),Sockets.s.ze(3240),__fx.customLobby.setActive(false),n.o(5,5)})`)
// when a socket error occurs on the custom lobby socket
replaceRawCode("this.wQ=function(wR,d){if(8===i.pz&&0===wR)if(4211===d)wS(d);",
`this.wQ=function(wR,d){
wR===1 && __fx.customLobby.isActive() && ${dict.MenuManager}.${dict.getState}() !== 6 && __fx.customLobby.setActive(false);
if(8===i.pz&&0===wR)if(4211===d)wS(d);`)
// when leaving a game
replaceRawCode("this.wl=function(zs){a1.gZ||az.oO.a11.length||(az.oO.a11=az.a12.vd()),ap.ky.zt(),this.vH=0,bU.zu(),m.n.setState(0),zs||bJ.df.show(),aN.setState(0),2===this.a3D?i.ky.a3U():1===this.a3D?i.j(19):i.j(5,5)}",
`this.wl=function(zs){a1.gZ||az.oO.a11.length||(az.oO.a11=az.a12.vd()),
__fx.customLobby.isActive() === false && ap.ky.zt(),
this.vH=0,bU.zu(),m.n.setState(0),zs||bJ.df.show(),aN.setState(0);
if (__fx.customLobby.isActive()) __fx.customLobby.rejoinLobby(); else 2===this.a3D?i.ky.a3U():1===this.a3D?i.j(19):i.j(5,5)}`)
// do not display lobby UI
replaceRawCode(`(sV.style.backdropFilter="blur(4px)",sV.style.webkitBackdropFilter="blur(4px)"),`,
`(sV.style.backdropFilter="blur(4px)",sV.style.webkitBackdropFilter="blur(4px)"),
__fx.customLobby.isActive() && (sV.style.display = "none"),`);
// allow games with one player
replaceRawCode("if((t3=bk.t1.t3[e0])<2)return!1;", "if((t3=bk.t1.t3[e0])<2 && !__fx.customLobby.isActive())return!1;")
// if the server is unreachable
replaceRawCode("{g.wc(3249)}", "{__fx.customLobby.isActive()?(g.wc(3249),__fx.customLobby.setActive(false)):g.wc(3249)}")
// error descriptions
const errors = { 3249: "No servers found", 4705: "Lobby not found", 4730: "Kicked from lobby" };
replaceRawCode(`m.n___(4,5,new o(__L(),xT(e),!0))`,
`m.n___(4,5,new o(__L(),${JSON.stringify(errors)}[e] ?? xT(e),!0))`)
// map info (for the map selection menu)
replaceRawCode("this.info=new Array(Maps.totalMapCount+1),this.info[0]={name:__L(),",
"this.info=new Array(Maps.totalMapCount+1),__fx.customLobby.setMapInfo(this.info),this.info[0]={name:__L(),")
// to not set custom lobby games as singleplayer
replaceRawCode("this.vK=this.jS=this.data.a0f,this.gameIsSingleplayer=1===this.vK,",
"this.vK=this.jS=this.data.a0f,this.gameIsSingleplayer=1===this.vK&&!__fx.customLobby.isActive(),")
// custom difficulty
replaceRawCode("if(9===a1.jq)this.jr();else if(a1.js)if(3===a1.data.jv)for(z=a1.ju-1;0<=z;z--){var jw=z+jp;this.ie[jw]=",
`if(9===a1.jq)this.jr();
else if (__fx.customLobby.isActive()) for(z=a1.ju-1;0<=z;z--) this.ie[z+jp] = __fx.customLobby.gameInfo.difficulty;
else if(a1.js)if(3===a1.data.jv)for(z=a1.ju-1;0<=z;z--){var jw=z+jp;this.ie[jw]=`)
// spawn selection
replaceRawCode(":50,this.a=this.b=this.data.c,this.d=this.b?new e:null,",
":50,this.a=this.b=__fx.customLobby.isActive() ? __fx.customLobby.gameInfo.spawnSelection : this.data.c,this.d=this.b?new e:null,")
// bot count
replaceRawCode(",this.gLobbyMaxJoin=1===dg?this.gHumans:this.data.playerCount,this.maxPlayers=this.gLobbyMaxJoin,this.gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,",
`,this.gLobbyMaxJoin = __fx.customLobby.isActive() ? Math.max(Math.min(__fx.customLobby.gameInfo.botCount, this.data.playerCount), this.gHumans) : 1===dg?this.gHumans:this.data.playerCount,
this.maxPlayers=this.gLobbyMaxJoin,this.gBots=this.gLobbyMaxJoin-this.gHumans,this.sg=0,`)
});
}

View File

@ -25,18 +25,17 @@ FX Client is the first Territorial.io client, offering a better User Interface a
4. Displays your troop density and maximum troops
5. Displays the density of players and bots
6. Adds a "Clan" tab on the leaderboard, allowing you to easily see your clanmates
7. Adds custom lobbies
8. Hovering tooltip: makes the territory map information (normally visible on right click) be visible constantly (on hover)
9. Adds a player list
10. Adds the ability to view the history of who donated to a player during a team game by clicking on their name in the leaderboard or the player list
11. Adds a win counter
12. Can be installed as a PWA (progressive web app) ensuring maximum enjoyment on consoles, phones and even desktop devices
7. Hovering tooltip: makes the territory map information (normally visible on right click) be visible constantly (on hover)
8. Adds a player list
9. Adds the ability to view the history of who donated to a player during a team game by clicking on their name in the leaderboard or the player list
10. Adds a win counter
11. Can be installed as a PWA (progressive web app) ensuring maximum enjoyment on consoles, phones and even desktop devices
#### The client has a settings menu, from which you can:
13. Make fullscreen mode trigger automatically
14. Set a custom main menu background
15. Create custom attack percentage keybinds
12. Make fullscreen mode trigger automatically
13. Set a custom main menu background
14. Create custom attack percentage keybinds
## Building Locally

View File

@ -1,306 +0,0 @@
import WindowManager from "./windowManager.js";
//const socketURL = "ws://localhost:8080/";
const socketURL = "wss://fx.peshomir.workers.dev/";
const customMessageMarker = 120;
let isActive = false;
let currentCode = "";
let joinLobby = () => { };
let leaveLobby = () => { };
let sendRaw = (socketId, data) => { };
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
WindowManager.add({
name: "lobbyJoinMenu",
element: document.getElementById("customLobbyJoinMenu")
})
const windowElement = WindowManager.create({
name: "customLobby",
classes: "scrollable selectable flex-column text-align-center",
closable: false
});
const header = document.createElement("h2");
header.textContent = "Custom Lobby";
const main = document.createElement("div");
main.className = "customlobby-main";
const playerListContainer = document.createElement("div");
const playerCount = document.createElement("p");
playerCount.textContent = "0 Players";
const playerListDiv = document.createElement("div");
playerListContainer.append(playerCount, playerListDiv);
const optionsContainer = document.createElement("div");
optionsContainer.className = "text-align-left";
const optionsStructure = {
mode: {
label: "Mode:", type: "selectMenu", options: [
{ value: 0, label: "2 Teams" },
{ value: 1, label: "3 Teams" },
{ value: 2, label: "4 Teams" },
{ value: 3, label: "5 Teams" },
{ value: 4, label: "6 Teams" },
{ value: 5, label: "7 Teams" },
{ value: 6, label: "8 Teams" },
{ value: 7, label: "Battle Royale" },
{ value: 10, label: "No Fullsend Battle Royale" },
{ value: 9, label: "Zombie mode" }
]
},
map: { label: "Map:", type: "selectMenu" },
difficulty: { label: "Difficulty:", type: "selectMenu", options: [
{ value: 0, label: "Very Easy (Default)" },
{ value: 1, label: "Easy (1v1)" },
{ value: 2, label: "Normal" },
{ value: 3, label: "Hard" },
{ value: 4, label: "Very Hard" },
{ value: 5, label: "Impossible" }
]},
spawnSelection: { label: "Spawn selection", type: "checkbox" },
botCount: { label: "Bot & player count:", type: "numberInput", attributes: { min: "1", max: "512" } }
}
const optionsElements = {};
const optionsValues = {};
function updateOption(option, value) {
if (optionsStructure[option].type === "checkbox")
optionsElements[option].checked = (value === 0 ? false : true);
else optionsElements[option].value = value.toString();
optionsValues[option] = value;
}
function inputUpdateHandler(key, e) {
sendMessage("options", [key, parseInt(e.target.value)])
}
function checkboxUpdateHandler(key, e) {
sendMessage("options", [key, e.target.checked ? 1 : 0])
}
Object.entries(optionsStructure).forEach(([key, item]) => {
const label = document.createElement("label");
if (item.tooltip) label.title = item.tooltip;
const isValueInput = item.type.endsWith("Input");
const element = document.createElement(
isValueInput || item.type === "checkbox" ? "input"
: item.type === "selectMenu" ? "select"
: "button"
);
optionsElements[key] = element;
if (item.type === "textInput") element.type = "text";
if (item.type === "numberInput") element.type = "number";
if (item.placeholder) element.placeholder = item.placeholder;
if (isValueInput || item.type === "selectMenu")
element.addEventListener("change", inputUpdateHandler.bind(undefined, key))
if (item.text) element.innerText = item.text;
if (item.action) element.addEventListener("click", item.action);
if (item.label) label.append(item.label + " ");
if (item.note) {
const note = document.createElement("small");
note.innerText = item.note;
label.append(document.createElement("br"), note);
}
if (item.options) setSelectMenuOptions(item.options, element);
if (item.attributes) Object.entries(item.attributes).forEach(
([name, value]) => element.setAttribute(name, value)
);
label.append(element);
if (item.type === "checkbox") {
element.type = "checkbox";
const checkmark = document.createElement("span");
checkmark.className = "checkmark";
label.className = "checkbox";
label.append(checkmark);
//checkboxFields[item.for] = element;
element.addEventListener("change", checkboxUpdateHandler.bind(undefined, key))
} else label.append(document.createElement("br"));
optionsContainer.append(label/*, document.createElement("br")*/);
});
function setMapInfo(maps) {
setTimeout(() => setSelectMenuOptions(maps.map((info, index) => ({ value: index.toString(), label: info.name })), optionsElements["map"]), 0);
}
main.append(playerListContainer, optionsContainer);
const footer = document.createElement("footer");
footer.style.marginTop = "10px";
const startButton = document.createElement("button");
const leaveButton = document.createElement("button");
startButton.textContent = "Start game";
leaveButton.textContent = "Leave lobby";
startButton.addEventListener("click", startGame);
leaveButton.addEventListener("click", () => leaveLobby());
footer.append(startButton, leaveButton);
windowElement.append(header, main, footer);
/** @param {HTMLSelectElement} element */
function setSelectMenuOptions(options, element) {
options.forEach(data => {
const option = document.createElement("option");
option.setAttribute("value", data.value);
option.textContent = data.label;
element.append(option);
})
}
function showJoinPrompt() {
WindowManager.openWindow("lobbyJoinMenu");
}
document.getElementById("lobbyCode").addEventListener("input", ({ target: input }) => {
if (input.value.length !== 5) return;
currentCode = input.value.toLowerCase();
input.value = "";
WindowManager.closeWindow("lobbyJoinMenu");
isActive = true;
joinLobby();
});
document.getElementById("createLobbyButton").addEventListener("click", () => {
currentCode = "";
WindowManager.closeWindow("lobbyJoinMenu");
isActive = true;
joinLobby();
});
function sendMessage(type, data) {
const message = data !== undefined ? { t: type, d: data } : { t: type }
const originalArray = textEncoder.encode(JSON.stringify(message));
const buffer = new ArrayBuffer(originalArray.length + 1);
const view = new DataView(buffer);
// Set the first byte to the custom message marker
view.setUint8(0, customMessageMarker);
// Copy the original array starting from the second byte
const uint8ArrayView = new Uint8Array(buffer, 1);
uint8ArrayView.set(originalArray);
sendRaw(1, buffer);
}
let playerIsHost = false;
/** @param {Uint8Array} raw */
function isCustomMessage(raw) {
if (raw[0] !== customMessageMarker) return false;
if (raw.length === 1) return true; // ping
const subArray = new Uint8Array(raw.buffer, 1);
const message = JSON.parse(textDecoder.decode(subArray));
const { t: type, d: data } = message;
if (type === "lobby") {
WindowManager.openWindow("customLobby");
header.textContent = "Custom Lobby " + data.code;
currentCode = data.code;
playerIsHost = data.isHost;
startButton.disabled = !playerIsHost;
if (playerIsHost) optionsContainer.classList.remove("disabled");
else optionsContainer.classList.add("disabled");
Object.entries(data.options).forEach(([option, value]) => updateOption(option, value));
displayPlayers(data.players);
} else if (type === "addPlayer") {
addPlayer({ name: data.name, inGame: false, isHost: false });
updatePlayerCount();
} else if (type === "removePlayer") {
const index = data;
playerList[index].element.remove();
playerList.splice(index, 1);
updatePlayerCount();
} else if (type === "inLobby") {
const index = data;
playerList[index].inGameBadge.className = "d-none";
} else if (type === "options") {
const [option, value] = data;
updateOption(option, value);
} else if (type === "setHost") {
const index = data;
playerList[index].isHost = true;
playerList[index].hostBadge.className = "";
} else if (type === "host") {
playerIsHost = true;
startButton.disabled = false;
optionsContainer.classList.remove("disabled");
playerList.forEach(p => { if (!p.isHost) p.kickButton.className = "" });
} else if (type === "serverMessage") alert(data);
return true;
}
/** @typedef {{ element: HTMLDivElement, hostBadge: HTMLSpanElement, inGameBadge: HTMLSpanElement, kickButton: HTMLButtonElement, isHost: boolean, inGame: boolean }} PlayerListEntry */
/** @type {PlayerListEntry[]} */
let playerList = [];
/** @type {PlayerListEntry} */
let thisPlayer;
function createBadge(text, visible) {
const badge = document.createElement("span");
badge.textContent = text;
badge.className = visible ? "" : "d-none";
return badge;
}
/** @typedef {{ name: string, isHost: boolean, inGame: boolean }} PlayerInfo */
/** @param {PlayerInfo} player */
function addPlayer(player) {
const div = document.createElement("div");
div.className = "lobby-player";
div.textContent = player.name;
const kickButton = document.createElement("button");
kickButton.textContent = "Kick";
kickButton.className = playerIsHost && !player.isHost ? "" : "d-none";
kickButton.addEventListener("click", kickButtonHandler);
const hostBadge = createBadge("Host", player.isHost);
const inGameBadge = createBadge("In Game", player.inGame);
div.append(hostBadge, inGameBadge, kickButton);
playerListDiv.append(div);
playerList.push({ element: div, hostBadge, inGameBadge, kickButton, isHost: player.isHost, inGame: player.inGame });
}
function kickButtonHandler(event) {
const button = event.target;
for (let index = 0; index < playerList.length; index++) {
if (playerList[index].kickButton === button) {
sendMessage("kick", index);
break;
}
}
}
/** @param {PlayerInfo[]} players */
function displayPlayers(players) {
playerList = [];
playerListDiv.innerHTML = "";
players.forEach(addPlayer);
thisPlayer = playerList[playerList.length - 1];
updatePlayerCount();
}
function updatePlayerCount() {
playerCount.textContent = `${playerList.length} Player${playerList.length === 1 ? "" : "s"}`
}
function getSocketURL() {
return socketURL + (currentCode === "" ? "create" : "join?" + currentCode)
}
function getPlayerId() {
let id = 0;
for (let i = 0; i < playerList.length; i++) {
const player = playerList[i];
if (player === thisPlayer) return id;
if (player.inGame === false) id++;
}
}
function startGame() {
WindowManager.closeWindow("customLobby");
sendMessage("startGame");
}
function rejoinLobby() {
joinLobby();
}
function setJoinFunction(f) { joinLobby = f; }
function setLeaveFunction(f) { leaveLobby = f; }
function setSendFunction(f) { sendRaw = f; }
function setActive(active) {
isActive = active;
if (active === false) WindowManager.closeWindow("customLobby");
}
function hideWindow() {
WindowManager.closeWindow("customLobby");
}
const gameInterface = { gameInfo: optionsValues, showJoinPrompt, isCustomMessage, getSocketURL, getPlayerId, setJoinFunction, setLeaveFunction, setSendFunction, setMapInfo, rejoinLobby, hideWindow, isActive: () => isActive, setActive }
const customLobby = gameInterface
export default customLobby

View File

@ -1,7 +1,7 @@
const playerDataProperties = ["playerTerritories", "playerBalances", "rawPlayerNames"];
const gameObjectProperties = ["playerId", "gIsTeamGame", "gHumans", "gLobbyMaxJoin", "gameState", "gIsSingleplayer"];
export const getVar = varName => {
if (playerDataProperties.includes(varName)) return window[dictionary.playerData]?.[dictionary[varName]];
if (gameObjectProperties.includes(varName)) return window[dictionary.game]?.[dictionary[varName]];
if (playerDataProperties.includes(varName)) return window[dictionary.playerData][dictionary[varName]];
if (gameObjectProperties.includes(varName)) return window[dictionary.game][dictionary[varName]];
return window[dictionary[varName]]
};

View File

@ -1,16 +1,5 @@
const fx_version = '0.6.7'; // FX Client Version
const fx_update = 'Feb 8'; // FX Client Last Updated
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", (e) => {
const message = e.data;
if (message.event === "activate" && buildTimestamp !== message.version) {
// worker was updated in the background
document.getElementById("updateNotification").style.display = "block";
}
});
navigator.serviceWorker.register("./sw.js");
}
const fx_version = '0.6.5.6'; // FX Client Version
const fx_update = 'Oct 3'; // FX Client Last Updated
import settingsManager from './settings.js';
import { clanFilter, leaderboardFilter } from "./clanFilters.js";
@ -21,7 +10,6 @@ import playerList from "./playerList.js";
import gameScriptUtils from "./gameScriptUtils.js";
import hoveringTooltip from "./hoveringTooltip.js";
import { keybindFunctions, keybindHandler } from "./keybinds.js";
import customLobby from './customLobby.js';
window.__fx = window.__fx || {};
const __fx = window.__fx;
@ -38,6 +26,5 @@ __fx.playerList = playerList;
__fx.hoveringTooltip = hoveringTooltip;
__fx.clanFilter = clanFilter;
__fx.wins = winCounter;
__fx.customLobby = customLobby;
console.log('Successfully loaded FX Client');
console.log('Successfully loaded FX Client');

View File

@ -6,301 +6,204 @@ window.__fx = window.__fx || {};
const __fx = window.__fx;
var settings = {
//"fontName": "Trebuchet MS",
//"showBotDonations": false,
displayWinCounter: true,
useFullscreenMode: false,
hoveringTooltip: true,
//"hideAllLinks": false,
realisticNames: false,
showPlayerDensity: true,
coloredDensity: true,
densityDisplayStyle: "percentage",
hideBotNames: false,
highlightClanSpawns: false,
detailedTeamPercentage: false,
//"customMapFileBtn": true
customBackgroundUrl: "",
attackPercentageKeybinds: [],
//"fontName": "Trebuchet MS",
//"showBotDonations": false,
"displayWinCounter": true,
"useFullscreenMode": false,
"hoveringTooltip": true,
//"hideAllLinks": false,
"realisticNames": false,
"showPlayerDensity": true,
"coloredDensity": true,
"densityDisplayStyle": "percentage",
"highlightClanSpawns": false,
//"customMapFileBtn": true
"customBackgroundUrl": "",
"attackPercentageKeybinds": [],
};
__fx.settings = settings;
const discontinuedSettings = ["hideAllLinks", "fontName"];
const discontinuedSettings = [ "hideAllLinks", "fontName" ];
__fx.makeMainMenuTransparent = false;
/*var settingsGearIcon = document.createElement('img');
settingsGearIcon.setAttribute('src', 'assets/geari_white.png');*/
const settingsManager = new (function () {
const settingsStructure = [
{
for: "displayWinCounter",
type: "checkbox",
label: "Display win counter",
note: "The win counter tracks multiplayer solo wins (not in team games)",
},
{
type: "button",
text: "Reset win counter",
action: winCounter.removeWins,
},
{
for: "useFullscreenMode",
type: "checkbox",
label: "Use fullscreen mode",
note: "Note: fullscreen mode will trigger after you click anywhere on the page due to browser policy restrictions.",
},
{
for: "hoveringTooltip",
type: "checkbox",
label: "Hovering tooltip",
note: "Display map territory info constantly (on mouse hover) instead of only when right clicking on the map",
},
//{ for: "hideAllLinks", type: "checkbox", label: "Hide Links option also hides app store links" },
{ for: "realisticNames", type: "checkbox", label: "Realistic Bot Names" },
{
for: "showPlayerDensity",
type: "checkbox",
label: "Show player density",
},
{
for: "coloredDensity",
type: "checkbox",
label: "Colored density",
note: "Display the density with a color between red and green depending on the density value",
},
{
for: "densityDisplayStyle",
type: "selectMenu",
label: "Density value display style:",
tooltip: "Controls how the territorial density value should be rendered",
options: [
{ value: "percentage", label: "Percentage" },
{
value: "absoluteQuotient",
label: "Value from 0 to 150 (BetterTT style)",
},
],
},
{ for: "hideBotNames", type: "checkbox", label: "Hide bot names" },
{
for: "highlightClanSpawns",
type: "checkbox",
label: "Highlight clan spawnpoints",
note: "Increases the spawnpoint glow size for members of your clan",
},
{
for: "detailedTeamPercentage", type: "checkbox",
label: "Detailed team pie chart percentage",
note: "For example: this would show 25.82% instead of 26% on the pie chart in team games"
},
{
for: "customBackgroundUrl",
type: "textInput",
label: "Custom main menu background:",
placeholder: "Enter an image URL here",
tooltip:
"A custom image to be shown as the main menu background instead of the currently selected map.",
},
KeybindsInput,
];
const settingsContainer = document.querySelector(".settings .scrollable");
var inputFields = {}; // (includes select menus)
var checkboxFields = {};
var customElements = [];
settingsStructure.forEach((item) => {
if (typeof item === "function") {
const container = document.createElement("div");
customElements.push(new item(container));
return settingsContainer.append(container);
}
const label = document.createElement("label");
if (item.tooltip) label.title = item.tooltip;
const isValueInput = item.type.endsWith("Input");
const element = document.createElement(
isValueInput || item.type === "checkbox"
? "input"
: item.type === "selectMenu"
? "select"
: "button"
);
if (item.type === "textInput") element.type = "text";
if (item.placeholder) element.placeholder = item.placeholder;
if (isValueInput || item.type === "selectMenu")
inputFields[item.for] = element;
if (item.text) element.innerText = item.text;
if (item.action) element.addEventListener("click", item.action);
if (item.label) label.append(item.label + " ");
if (item.note) {
const note = document.createElement("small");
note.innerText = item.note;
label.append(document.createElement("br"), note);
}
if (item.options)
item.options.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.setAttribute("value", option.value);
optionElement.innerText = option.label;
element.append(optionElement);
});
label.append(element);
if (item.type === "checkbox") {
element.type = "checkbox";
const checkmark = document.createElement("span");
checkmark.className = "checkmark";
label.className = "checkbox";
label.append(checkmark);
checkboxFields[item.for] = element;
} else label.append(document.createElement("br"));
settingsContainer.append(label, document.createElement("br"));
});
this.save = function () {
Object.keys(inputFields).forEach(function (key) {
settings[key] = inputFields[key].value.trim();
const settingsManager = new (function() {
const settingsStructure = [
{ for: "displayWinCounter", type: "checkbox", label: "Display win counter",
note: "The win counter tracks multiplayer solo wins (not in team games)" },
{ type: "button", text: "Reset win counter", action: winCounter.removeWins },
{ for: "useFullscreenMode", type: "checkbox", label: "Use fullscreen mode",
note: "Note: fullscreen mode will trigger after you click anywhere on the page due to browser policy restrictions." },
{ for: "hoveringTooltip", type: "checkbox", label: "Hovering tooltip",
note: "Display map territory info constantly (on mouse hover) instead of only when right clicking on the map" },
//{ for: "hideAllLinks", type: "checkbox", label: "Hide Links option also hides app store links" },
{ for: "realisticNames", type: "checkbox", label: "Realistic Bot Names" },
{ for: "showPlayerDensity", type: "checkbox", label: "Show player density" },
{ for: "coloredDensity", type: "checkbox", label: "Colored density", note: "Display the density with a color between red and green depending on the density value" },
{ for: "densityDisplayStyle", type: "selectMenu", label: "Density value display style:", tooltip: "Controls how the territorial density value should be rendered", options: [
{ value: "percentage", label: "Percentage" },
{ value: "absoluteQuotient", label: "Value from 0 to 150 (BetterTT style)" }
]},
{ for: "highlightClanSpawns", type: "checkbox", label: "Highlight clan spawnpoints",
note: "Increases the spawnpoint glow size for members of your clan" },
{ for: "customBackgroundUrl", type: "textInput", label: "Custom main menu background:", placeholder: "Enter an image URL here", tooltip: "A custom image to be shown as the main menu background instead of the currently selected map." },
KeybindsInput
];
const settingsContainer = document.querySelector(".settings .scrollable");
var inputFields = {}; // (includes select menus)
var checkboxFields = {};
var customElements = [];
settingsStructure.forEach(item => {
if (typeof item === "function") {
const container = document.createElement("div");
customElements.push(new item(container));
return settingsContainer.append(container);
}
const label = document.createElement("label");
if (item.tooltip) label.title = item.tooltip;
const isValueInput = item.type.endsWith("Input");
const element = document.createElement(isValueInput || item.type === "checkbox" ? "input" : item.type === "selectMenu" ? "select" : "button");
if (item.type === "textInput") element.type = "text";
if (item.placeholder) element.placeholder = item.placeholder;
if (isValueInput || item.type === "selectMenu") inputFields[item.for] = element;
if (item.text) element.innerText = item.text;
if (item.action) element.addEventListener("click", item.action);
if (item.label) label.append(item.label + " ");
if (item.note) {
const note = document.createElement("small");
note.innerText = item.note;
label.append(document.createElement("br"), note)
}
if (item.options) item.options.forEach(option => {
const optionElement = document.createElement("option");
optionElement.setAttribute("value", option.value);
optionElement.innerText = option.label;
element.append(optionElement);
});
label.append(element);
if (item.type === "checkbox") {
element.type = "checkbox";
const checkmark = document.createElement("span");
checkmark.className = "checkmark";
label.className = "checkbox";
label.append(checkmark);
checkboxFields[item.for] = element;
} else label.append(document.createElement("br"));
settingsContainer.append(label, document.createElement("br"));
});
Object.keys(checkboxFields).forEach(function (key) {
settings[key] = checkboxFields[key].checked;
});
this.applySettings();
WindowManager.closeWindow("settings");
discontinuedSettings.forEach((settingName) => delete settings[settingName]);
localStorage.setItem("fx_settings", JSON.stringify(settings));
// should probably firgure out a way to do this without reloading - // You can't do it, localstorages REQUIRE you to reload
window.location.reload();
};
const fileInput = document.createElement("input");
fileInput.type = "file";
function handleFileSelect(event) {
const input = event.target;
/** @type {File} */
const selectedFile = input.files[0];
if (!selectedFile) return;
input.removeEventListener("change", handleFileSelect);
input.value = "";
if (!selectedFile.name.endsWith(".json"))
return alert("Invalid file format");
const fileReader = new FileReader();
fileReader.onload = function () {
let result;
try {
result = JSON.parse(fileReader.result);
if (
confirm(
'Warning: This will override all current settings, click "OK" to confirm'
)
)
__fx.settings = settings = result;
this.save = function() {
Object.keys(inputFields).forEach(function(key) { settings[key] = inputFields[key].value.trim(); });
Object.keys(checkboxFields).forEach(function(key) { settings[key] = checkboxFields[key].checked; });
this.applySettings();
WindowManager.closeWindow("settings");
discontinuedSettings.forEach(settingName => delete settings[settingName]);
localStorage.setItem("fx_settings", JSON.stringify(settings));
// should probably firgure out a way to do this without reloading - // You can't do it, localstorages REQUIRE you to reload
window.location.reload();
} catch (error) {
alert("Error\n" + error);
}
};
fileReader.readAsText(selectedFile);
}
this.importFromFile = function () {
fileInput.click();
fileInput.addEventListener("change", handleFileSelect);
};
// https://stackoverflow.com/a/34156339
function saveFile(content, fileName, contentType) {
var a = document.createElement("a");
var file = new Blob([content], { type: contentType });
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
this.exportToFile = function () {
saveFile(
JSON.stringify(settings),
"FX_client_settings.json",
"application/json"
);
};
this.syncFields = function () {
Object.keys(inputFields).forEach(function (key) {
inputFields[key].value = settings[key];
});
Object.keys(checkboxFields).forEach(function (key) {
checkboxFields[key].checked = settings[key];
});
customElements.forEach((element) => element.update(settings));
};
this.resetAll = function () {
if (
!confirm(
"Are you Really SURE you want to RESET ALL SETTINGS back to the default?"
)
)
return;
localStorage.removeItem("fx_settings");
window.location.reload();
};
this.applySettings = function () {
//setVarByName("bu", "px " + settings.fontName);
const fileInput = document.createElement("input");
fileInput.type = "file";
function handleFileSelect(event) {
const input = event.target;
/** @type {File} */
const selectedFile = input.files[0];
if (!selectedFile) return;
if (settings.customBackgroundUrl !== "") {
document.body.style.backgroundImage =
"url(" + settings.customBackgroundUrl + ")";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center";
input.removeEventListener("change", handleFileSelect);
input.value = "";
if (!selectedFile.name.endsWith(".json")) return alert("Invalid file format");
const fileReader = new FileReader();
fileReader.onload = function() {
let result;
try {
result = JSON.parse(fileReader.result);
if (confirm("Warning: This will override all current settings, click \"OK\" to confirm")) __fx.settings = settings = result;
localStorage.setItem("fx_settings", JSON.stringify(settings));
window.location.reload();
} catch (error) {
alert("Error\n" + error)
}
}
fileReader.readAsText(selectedFile);
}
__fx.makeMainMenuTransparent = settings.customBackgroundUrl !== "";
};
this.importFromFile = function() {
fileInput.click();
fileInput.addEventListener('change', handleFileSelect);
};
// https://stackoverflow.com/a/34156339
function saveFile(content, fileName, contentType) {
var a = document.createElement("a");
var file = new Blob([content], {type: contentType});
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
this.exportToFile = function() {
saveFile(JSON.stringify(settings), 'FX_client_settings.json', 'application/json');
};
if (settings.useFullscreenMode) tryEnterFullscreen();
})();
export function tryEnterFullscreen() {
if (document.fullscreenElement !== null || !document.fullscreenEnabled) return;
document.documentElement
.requestFullscreen({ navigationUI: "hide" })
.then(() => {
console.log("Fullscreen mode activated");
})
.catch((error) => {
console.warn("Could not enter fullscreen mode:", error);
});
}
this.syncFields = function() {
Object.keys(inputFields).forEach(function(key) { inputFields[key].value = settings[key]; });
Object.keys(checkboxFields).forEach(function(key) { checkboxFields[key].checked = settings[key]; });
customElements.forEach(element => element.update(settings));
};
this.resetAll = function() {
if (!confirm("Are you Really SURE you want to RESET ALL SETTINGS back to the default?")) return;
localStorage.removeItem("fx_settings");
window.location.reload();
};
this.applySettings = function() {
//setVarByName("bu", "px " + settings.fontName);
if (settings.useFullscreenMode && document.fullscreenEnabled) {
function tryEnterFullscreen() {
if (document.fullscreenElement !== null) return;
document.documentElement.requestFullscreen({ navigationUI: "hide" })
.then(() => { console.log('Fullscreen mode activated'); })
.catch((error) => { console.warn('Could not enter fullscreen mode:', error); });
}
document.addEventListener('mousedown', tryEnterFullscreen, { once: true });
document.addEventListener('click', tryEnterFullscreen, { once: true });
}
if (settings.customBackgroundUrl !== "") {
document.body.style.backgroundImage = "url(" + settings.customBackgroundUrl + ")";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center";
}
__fx.makeMainMenuTransparent = settings.customBackgroundUrl !== "";
};
});
const openCustomBackgroundFilePicker = () => {
const fileInput = document.getElementById("customBackgroundFileInput");
fileInput.click();
fileInput.addEventListener("change", handleFileSelect);
};
const fileInput = document.getElementById("customBackgroundFileInput");
fileInput.click();
fileInput.addEventListener('change', handleFileSelect);
}
function handleFileSelect(event) {
const fileInput = event.target;
const selectedFile = fileInput.files[0];
console.log(fileInput.files);
console.log(fileInput.files[0]);
if (selectedFile) {
const fileUrl = URL.createObjectURL(selectedFile);
console.log("File URL:", fileUrl);
fileInput.value = "";
fileInput.removeEventListener("change", handleFileSelect);
}
const fileInput = event.target;
const selectedFile = fileInput.files[0];
console.log(fileInput.files);
console.log(fileInput.files[0]);
if (selectedFile) {
const fileUrl = URL.createObjectURL(selectedFile);
console.log("File URL:", fileUrl);
fileInput.value = "";
fileInput.removeEventListener("change", handleFileSelect);
}
}
WindowManager.add({
name: "settings",
element: document.querySelector(".settings"),
beforeOpen: function () {
settingsManager.syncFields();
},
name: "settings",
element: document.querySelector(".settings"),
beforeOpen: function() { settingsManager.syncFields(); }
});
if (localStorage.getItem("fx_settings") !== null) {
__fx.settings = settings = {
...settings,
...JSON.parse(localStorage.getItem("fx_settings")),
};
__fx.settings = settings = {...settings, ...JSON.parse(localStorage.getItem("fx_settings"))};
}
settingsManager.applySettings();
export default settingsManager;
export function getSettings() {
return settings;
}
export function getSettings() { return settings; };

View File

@ -1,63 +1,27 @@
import { getSettings, tryEnterFullscreen } from "./settings.js";
var windows = {};
const container = document.getElementById("windowContainer");
function create(info) {
const window = document.createElement("div");
info.element = window;
window.className =
"window" +
(info.classes !== undefined
? " " + info.classes
: " scrollable selectable");
window.style.display = "none";
container.appendChild(window);
add(info);
return window;
}
function add(newWindow) {
windows[newWindow.name] = newWindow;
windows[newWindow.name].isOpen = false;
}
windows[newWindow.name] = newWindow;
windows[newWindow.name].isOpen = false;
};
function openWindow(windowName, ...args) {
if (windows[windowName].isOpen === true) return;
if (windows[windowName].beforeOpen !== undefined)
windows[windowName].beforeOpen(...args);
windows[windowName].isOpen = true;
windows[windowName].element.style.display = null;
}
if (windows[windowName].isOpen === true) return;
if (windows[windowName].beforeOpen !== undefined) windows[windowName].beforeOpen(...args);
windows[windowName].isOpen = true;
windows[windowName].element.style.display = null;
};
function closeWindow(windowName) {
if (windows[windowName].isOpen === false) return;
windows[windowName].isOpen = false;
windows[windowName].element.style.display = "none";
if (windows[windowName].onClose !== undefined) windows[windowName].onClose();
}
if (windows[windowName].isOpen === false) return;
windows[windowName].isOpen = false;
windows[windowName].element.style.display = "none";
if (windows[windowName].onClose !== undefined) windows[windowName].onClose();
};
function closeAll() {
Object.values(windows).forEach(function (windowObj) {
if (windowObj.closable !== false) closeWindow(windowObj.name);
});
}
document.addEventListener(
"mousedown",
(e) => {
// when clicking outside a window
if (!container.contains(e.target)) closeAll();
Object.values(windows).forEach(function (windowObj) {
closeWindow(windowObj.name);
});
};
document.getElementById("canvasA").addEventListener("mousedown", closeAll);
document.getElementById("canvasA").addEventListener("touchstart", closeAll, { passive: true });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeAll(); });
const isFullScreenEnabled = getSettings().useFullscreenMode;
if (isFullScreenEnabled) {
tryEnterFullscreen();
}
},
{ passive: true, capture: true }
);
document
.getElementById("canvasA")
.addEventListener("touchstart", closeAll, { passive: true });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeAll();
});
export default { create, add, openWindow, closeWindow, closeAll };
export default { add, openWindow, closeWindow, closeAll }

View File

@ -2,14 +2,14 @@
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<!--<script async src="https://www.googletagmanager.com/gtag/js?id=G-WYYDMY13BG"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-WYYDMY13BG"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-WYYDMY13BG');
</script>-->
</script>
<meta charset="utf-8" />
<title>FX Client</title>
<meta name="description" content="Modified Version of Territorial.io - FX Client">
@ -59,7 +59,7 @@
<body onload="aiCommand746(0);">
<canvas id="canvasA" width="128" height="128"></canvas>
<span id="windowContainer"><div class="window flex-column settings" style="display:none">
<span><div class="window flex settings" style="display:none">
<h1>Settings</h1>
<div class="scrollable"></div>
<hr>
@ -70,11 +70,6 @@
<button onclick="__fx.settingsManager.exportToFile()">Export</button>
</footer>
</div>
<div class="window flex-column" id="customLobbyJoinMenu" style="display: none">
<input type="text" id="lobbyCode" placeholder="Enter lobby code">
or
<button id="createLobbyButton">Create new lobby</button>
</div>
<div class="window scrollable selectable" id="playerlist" style="display: none;">
<h1>Player List</h1>
<table><tbody id="playerlist_content"></tbody></table>
@ -83,11 +78,6 @@
<h1>Donation history for </h1>
<p id="donationhistory_note">Note: donations from bots are not shown here</p>
<table><tbody id="donationhistory_content"></tbody></table>
</div>
<div class="window" style="display: none" id="updateNotification">
<h3>A new version of FX is available! Reload to update</h3>
<button onclick="window.location.reload()">Reload</button>
<button onclick="document.getElementById('updateNotification').style.display = 'none'">Dismiss</button>
</div></span>
<script src="variables.js?buildTimestamp"></script>
<script src="fx.bundle.js?buildTimestamp"></script>

View File

@ -34,6 +34,15 @@
z-index : 10;
}
.window.flex {
display : flex;
flex-direction: column;
}
hr {
width: 100%;
}
.window button,
.window input,
.window select {
@ -45,77 +54,6 @@
transition : 0.2s;
border : 1px solid #fff;
border-radius : 5px;
margin : 5px;
}
.window :disabled, .window .disabled {
pointer-events: none;
opacity: 0.65;
}
.window.settings button,
.window.settings input,
.window.settings select {
margin: 0px;
}
.flex {
display: flex;
}
.flex-column {
display : flex;
flex-direction: column;
}
#customLobbyJoinMenu {
align-items: center;
}
.customlobby-main {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 10px;
}
.lobby-player {
margin: 5px;
width: 15rem;
display: flex;
align-items: center;
justify-content: center;
}
.lobby-player span {
margin: 0px 5px;
font-size: .7em;
border-style: solid;
border-width: 1px;
padding: 3px 5px;
border-color: #ffffff7d;
border-radius: 5px;
}
.lobby-player button {
font-size: 0.7em;
margin: 0px 5px;
padding: 3px 5px;
}
.d-none {
display: none;
}
.text-align-center {
text-align: center;
}
.text-align-left {
text-align: left;
}
hr {
width: 100%;
}
h1 {
@ -158,16 +96,16 @@ td {
#playerlist_content.clickable td:hover { background-color: #00ff0040; }
tr.new {
animation: flashAnimation 0.4s ease-out;
animation: flashAnimation 0.4s ease-out;
}
@keyframes flashAnimation {
0% {
background-color: #ffffffaa;
}
100% {
background-color: transparent;
}
0% {
background-color: #ffffffaa;
}
100% {
background-color: transparent;
}
}
table {

View File

@ -1,47 +0,0 @@
const cacheName = "buildTimestamp"; // this gets replaced by the build script
self.addEventListener("install", (e) => {
console.log("[Service Worker] Install");
self.skipWaiting();
});
self.addEventListener("fetch", (e) => {
const url = e.request.url;
// Cache http and https only, skip unsupported chrome-extension:// and file://...
if (!(url.startsWith('http:') || url.startsWith('https:'))) {
return;
}
e.respondWith(
(async () => {
const r = await caches.match(e.request);
console.log(`[Service Worker] Fetching resource: ${url}`);
if (r) {
return r;
}
const response = await fetch(e.request);
const cache = await caches.open(cacheName);
console.log(`[Service Worker] Caching new resource: ${url}`);
cache.put(e.request, response.clone());
return response;
})(),
);
});
self.addEventListener("activate", (e) => {
console.log("[Service Worker] Activated", cacheName);
self.clients.matchAll().then(clients => {
clients.forEach(client => client.postMessage({ event: "activate", version: cacheName }));
});
e.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key === cacheName) {
return;
}
return caches.delete(key);
}),
);
}),
);
});