2022-02-08 15:59:11 +01:00

1753 lines
47 KiB
Plaintext

let deps = {
"axios": "^0.22.0",
"codegrid-js": "git+https://github.com/Tzhf/codegrid-js.git",
"dotenv": "^8.2.0",
"electron-store": "^8.0.1",
"electron-updater": "^4.3.9",
"jquery": "^3.5.1",
"tmi.js": "^1.8.5"
}
const Game = require("./Classes/Game");
const GameHelper = require("./utils/GameHelper");
const tmi = require("./Classes/tmi");
const game = new Game();
class GameHandler {
constructor(win, settingsWindow) {
this.win = win;
this.settingsWindow = settingsWindow;
this.initTmi();
this.init();
}
init = () => {
game.init(this.win, settings);
// Browser Listening
this.win.webContents.on("did-navigate-in-page", (e, url) => {
if (isGameURL(url)) {
game.start(url, settings.isMultiGuess).then(() => {
this.win.webContents.send("game-started", game.isMultiGuess);
TMI.action(`${game.round == 1 ? "🌎 A new seed of " + game.mapName : "🌎 Round " + game.round} has started`);
openGuesses();
});
} else {
game.outGame();
this.win.webContents.send("game-quitted");
}
});
this.win.webContents.on("did-frame-finish-load", () => {
if (!game.isInGame) return;
this.win.webContents.send("refreshed-in-game", settings.noCompass);
// Checks and update seed when the game has refreshed
// update the current location if it was skipped
// if the streamer has guessed returns scores
game.refreshSeed().then((scores) => {
if (scores) showResults(scores.location, scores.scores);
});
this.win.webContents.executeJavaScript(`
window.nextRoundBtn = document.querySelector('[data-qa="close-round-result"]');
if(window.nextRoundBtn) {
nextRoundBtn.addEventListener("click", () => {
nextRoundBtn.setAttribute('disabled', 'disabled');
ipcRenderer.send('next-round-click');
});
}
`);
});
const showResults = (location, scores) => {
const round = game.seed.state === "finished" ? game.round : game.round - 1;
this.win.webContents.send("show-round-results", round, location, scores);
TMI.action(`🌎 Round ${round} has finished. Congrats ${GameHelper.toEmojiFlag(scores[0].flag)} ${scores[0].username} !`);
};
ipcMain.on("next-round-click", () => nextRound());
const nextRound = () => {
game.nextRound();
if (game.seed.state === "finished") {
processTotalScores();
} else {
this.win.webContents.send("next-round", game.isMultiGuess);
TMI.action(`🌎 Round ${game.round} has started`);
openGuesses();
}
};
TMI.client.on("guess", async (from, userstate, message, self) => {
const msg = message.split("!g")[1].trim();
if (!GameHelper.isCoordinates(msg)) return;
const location = { lat: parseFloat(msg.split(",")[0]), lng: parseFloat(msg.split(",")[1]) };
game.handleUserGuess(userstate, location).then((res) => {
if (res === "alreadyGuessed") return TMI.say(`${userstate["display-name"]} you already guessed`);
const { user, guess } = res;
this.win.webContents.send("render-guess", guess, game.nbGuesses);
if (settings.showHasGuessed) return TMI.say(`${GameHelper.toEmojiFlag(user.flag)} ${userstate["display-name"]} guessed`);
});
});
}
module.exports = GameHandler;
const GameHandler = require("./GameHandler");
const Scoreboard = require("./Classes/Scoreboard");
const Store = require("./utils/Store");
window.addEventListener("DOMContentLoaded", () => {
window.ipcRenderer = require("electron").ipcRenderer;
window.$ = window.jQuery = require("jquery");
window.MAP = null;
hijackMap();
const head = document.getElementsByTagName("head")[0];
const styles = document.createElement("link");
styles.rel = "stylesheet";
styles.type = "text/css";
styles.href = `${path.join(__dirname, "./public/styles.css")}`;
head.appendChild(styles);
const scoreboardContainer = document.createElement("div");
scoreboardContainer.setAttribute("id", "scoreboardContainer");
scoreboardContainer.innerHTML = `
<div id='scoreboard'>
<div id='scoreboardHeader'>
<span></span>
<span id='scoreboardTitle'>GUESSES (0)</span>
<label id='switchContainer'>
<input id='switchBtn' type='checkbox' />
<div class='slider'></div>
</label>
</div>
<table id='datatable' width='100%'>
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Streak</th>
<th>Distance</th>
<th>Score</th>
</tr>
</thead>
<tbody id='guessList'></tbody>
</table>
</div>`;
document.body.appendChild(scoreboardContainer);
const flagIcon = document.createElement("link");
flagIcon.rel = "stylesheet";
flagIcon.type = "text/css";
flagIcon.href = `${path.join(__dirname, "./public/flag-icon.min.css")}`;
head.appendChild(flagIcon);
const jqueryUI = document.createElement("script");
jqueryUI.type = "text/javascript";
jqueryUI.src = `${path.join(__dirname, "./public/jquery-ui.min.js")}`;
jqueryUI.addEventListener("load", () => loadDatatables());
document.body.appendChild(jqueryUI);
const loadDatatables = () => {
const datatables = document.createElement("script");
datatables.type = "text/javascript";
datatables.src = `${path.join(__dirname, "./public/datatables.bundle.min.js")}`;
datatables.addEventListener("load", () => init());
document.body.appendChild(datatables);
};
const init = () => {
const markerRemover = document.createElement("style");
markerRemover.innerHTML = ".map-pin{display:none}";
const settingsIcon = document.createElement("div");
settingsIcon.setAttribute("title", "Settings (ctrl+p)");
settingsIcon.id = "settingsIcon";
settingsIcon.innerHTML = "<span>⚙️</span>";
settingsIcon.addEventListener("click", () => {
ipcRenderer.send("openSettings");
});
document.body.appendChild(settingsIcon);
const scoreboard = new Scoreboard();
const showScoreboard = document.createElement("div");
showScoreboard.setAttribute("title", "Show scoreboard");
showScoreboard.id = "showScoreboard";
showScoreboard.innerHTML = "<span>👁️‍🗨️</span>";
showScoreboard.addEventListener("click", () => {
scoreboard.setVisibility();
});
ipcRenderer.on("game-started", (e, isMultiGuess) => {
document.body.appendChild(showScoreboard);
scoreboard.checkVisibility();
scoreboard.reset(isMultiGuess);
});
ipcRenderer.on("refreshed-in-game", (e, noCompass) => {
document.body.appendChild(showScoreboard);
scoreboard.checkVisibility();
drParseNoCompass(noCompass);
});
ipcRenderer.on("game-quitted", () => {
scoreboard.hide();
if ($("#showScoreboard")) $("#showScoreboard").remove();
markerRemover.remove();
clearMarkers();
});
ipcRenderer.on("render-guess", (e, guess, nbGuesses) => {
scoreboard.setTitle(`GUESSES (${nbGuesses})`);
scoreboard.renderGuess(guess);
});
ipcRenderer.on("render-multiguess", (e, guesses, nbGuesses) => {
scoreboard.setTitle(`GUESSES (${nbGuesses})`);
scoreboard.renderMultiGuess(guesses);
});
ipcRenderer.on("pre-round-results", () => document.body.appendChild(markerRemover));
ipcRenderer.on("show-round-results", (e, round, location, scores) => {
scoreboard.show();
scoreboard.setTitle(`ROUND ${round} RESULTS`);
scoreboard.displayScores(scores);
scoreboard.showSwitch(false);
populateMap(location, scores);
});
ipcRenderer.on("show-final-results", (e, totalScores) => {
document.body.appendChild(markerRemover);
scoreboard.show();
scoreboard.setTitle("HIGHSCORES");
scoreboard.showSwitch(false);
scoreboard.displayScores(totalScores, true);
clearMarkers();
});
ipcRenderer.on("next-round", (e, isMultiGuess) => {
scoreboard.checkVisibility();
scoreboard.reset(isMultiGuess);
scoreboard.showSwitch(true);
setTimeout(() => {
markerRemover.remove();
clearMarkers();
}, 1000);
});
ipcRenderer.on("switch-on", () => scoreboard.switchOn(true));
ipcRenderer.on("switch-off", () => scoreboard.switchOn(false));
ipcRenderer.on("game-settings-change", (e, noCompass) => drParseNoCompass(noCompass));
};
});
let markers = [];
let polylines = [];
function populateMap(location, scores) {
const infowindow = new google.maps.InfoWindow();
const icon = {
path: `M13.04,41.77c-0.11-1.29-0.35-3.2-0.99-5.42c-0.91-3.17-4.74-9.54-5.49-10.79c-3.64-6.1-5.46-9.21-5.45-12.07
c0.03-4.57,2.77-7.72,3.21-8.22c0.52-0.58,4.12-4.47,9.8-4.17c4.73,0.24,7.67,3.23,8.45,4.07c0.47,0.51,3.22,3.61,3.31,8.11
c0.06,3.01-1.89,6.26-5.78,12.77c-0.18,0.3-4.15,6.95-5.1,10.26c-0.64,2.24-0.89,4.17-1,5.48C13.68,41.78,13.36,41.78,13.04,41.77z
`,
fillColor: "#de3e3e",
fillOpacity: 0.7,
scale: 1.2,
strokeColor: "#000000",
strokeWeight: 1,
anchor: new google.maps.Point(14, 43),
labelOrigin: new google.maps.Point(13.5, 15),
};
const locationMarker = new google.maps.Marker({
position: location,
url: `http://maps.google.com/maps?q=&layer=c&cbll=${location.lat},${location.lng}`,
icon: icon,
map: MAP,
});
google.maps.event.addListener(locationMarker, "click", () => {
window.open(locationMarker.url, "_blank");
});
markers.push(locationMarker);
icon.scale = 1;
scores.forEach((score, index) => {
const color = index == 0 ? "#E3BB39" : index == 1 ? "#C9C9C9" : index == 2 ? "#A3682E" : score.color;
icon.fillColor = color;
const guessMarker = new google.maps.Marker({
position: score.position,
icon: icon,
map: MAP,
label: { color: "#000", fontWeight: "bold", fontSize: "16px", text: `${index + 1}` },
});
google.maps.event.addListener(guessMarker, "mouseover", () => {
infowindow.setContent(`
<p class="gm-iw__content">
<span style="font-size:14px;">${score.flag ? `<span class="flag-icon flag-icon-${score.flag}"></span>` : ""}${score.username}</span><br>
${score.distance >= 1 ? parseFloat(score.distance.toFixed(1)) + "km" : parseInt(score.distance * 1000) + "m"}<br>
${score.score}
</p>
`);
infowindow.open(MAP, guessMarker);
});
google.maps.event.addListener(guessMarker, "mouseout", () => {
infowindow.close();
});
markers.push(guessMarker);
polylines.push(
new google.maps.Polyline({
strokeColor: color,
strokeWeight: 4,
strokeOpacity: 0.6,
geodesic: true,
map: MAP,
path: [score.position, location],
})
);
});
}
function clearMarkers() {
while (markers[0]) {
markers.pop().setMap(null);
}
while (polylines[0]) {
polylines.pop().setMap(null);
}
}
function hijackMap() {
const MAPS_API_URL = "https://maps.googleapis.com/maps/api/js?";
const GOOGLE_MAPS_PROMISE = new Promise((resolve, reject) => {
let scriptObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.tagName === "SCRIPT" && node.src.startsWith(MAPS_API_URL)) {
node.onload = () => {
scriptObserver.disconnect();
scriptObserver = undefined;
resolve();
};
}
}
}
});
let bodyDone = false;
let headDone = false;
new MutationObserver((_, observer) => {
if (!bodyDone && document.body) {
if (scriptObserver) {
scriptObserver.observe(document.body, {
childList: true,
});
bodyDone = true;
}
}
if (!headDone && document.head) {
if (scriptObserver) {
scriptObserver.observe(document.head, {
childList: true,
});
headDone = true;
}
}
if (headDone && bodyDone) {
observer.disconnect();
}
}).observe(document.documentElement, {
childList: true,
subtree: true,
});
});
function runAsClient(f) {
const script = document.createElement("script");
script.type = "text/javascript";
script.text = `(${f.toString()})()`;
document.body.appendChild(script);
}
GOOGLE_MAPS_PROMISE.then(() => {
runAsClient(() => {
const google = window.google;
const isGamePage = () => location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/");
const onMapUpdate = (map) => {
try {
if (!isGamePage()) return;
MAP = map;
} catch (error) {
console.error("GeoguessrHijackMap Error:", error);
}
};
const oldMap = google.maps.Map;
google.maps.Map = Object.assign(
function (...args) {
const res = oldMap.apply(this, args);
this.addListener("idle", () => {
if (MAP != null) return;
onMapUpdate(this);
});
return res;
},
{
prototype: Object.create(oldMap.prototype),
}
);
});
});
}
function drParseNoCompass(noCompass) {
const style = document.getElementById("noCompass");
if (noCompass) {
if (!style) {
const style = document.createElement("style");
style.id = "noCompass";
style.innerHTML = ".compass { display: none }.game-layout__compass{display: none}";
document.head.appendChild(style);
}
} else {
if (style) style.remove();
}
}
function drParseNoCar() {
if (!noCar) return;
const OPTIONS = { colorR: 0.5, colorG: 0.5, colorB: 0.5 };
const vertexOld =
"const float f=3.1415926;varying vec3 a;uniform vec4 b;attribute vec3 c;attribute vec2 d;uniform mat4 e;void main(){vec4 g=vec4(c,1);gl_Position=e*g;a=vec3(d.xy*b.xy+b.zw,1);a*=length(c);}";
const fragOld =
"precision highp float;const float h=3.1415926;varying vec3 a;uniform vec4 b;uniform float f;uniform sampler2D g;void main(){vec4 i=vec4(texture2DProj(g,a).rgb,f);gl_FragColor=i;}";
const vertexNew = `
const float f=3.1415926;
varying vec3 a;
varying vec3 potato;
uniform vec4 b;
attribute vec3 c;
attribute vec2 d;
uniform mat4 e;
void main(){
vec4 g=vec4(c,1);
gl_Position=e*g;
a = vec3(d.xy * b.xy + b.zw,1);
a *= length(c);
potato = vec3(d.xy, 1.0) * length(c);
}
`;
const fragNew = `
precision highp float;
const float h=3.1415926;
varying vec3 a;
varying vec3 potato;
uniform vec4 b;
uniform float f;
uniform sampler2D g;
void main(){
vec2 aD = potato.xy / a.z;
float thetaD = aD.y;
float thresholdD1 = 0.6;
float thresholdD2 = 0.7;
float x = aD.x;
float y = abs(4.0*x - 2.0);
float phiD = smoothstep(0.0, 1.0, y > 1.0 ? 2.0 - y : y);
vec4 i = vec4(thetaD > mix(thresholdD1, thresholdD2, phiD)
? vec3(float(${OPTIONS.colorR}), float(${OPTIONS.colorG}), float(${OPTIONS.colorB})) // texture2DProj(g,a).rgb * 0.25
: texture2DProj(g,a).rgb,f);
gl_FragColor=i;
}
`;
function installShaderSource(ctx) {
const g = ctx.shaderSource;
function shaderSource() {
if (typeof arguments[1] === "string") {
let glsl = arguments[1];
if (glsl === vertexOld) glsl = vertexNew;
else if (glsl === fragOld) glsl = fragNew;
return g.call(this, arguments[0], glsl);
}
return g.apply(this, arguments);
}
shaderSource.bestcity = "bintulu";
ctx.shaderSource = shaderSource;
}
function installGetContext(el) {
const g = el.getContext;
el.getContext = function () {
if (arguments[0] === "webgl" || arguments[0] === "webgl2") {
const ctx = g.apply(this, arguments);
if (ctx && ctx.shaderSource && ctx.shaderSource.bestcity !== "bintulu") {
installShaderSource(ctx);
}
return ctx;
}
return g.apply(this, arguments);
};
}
const f = document.createElement;
document.createElement = function () {
if (arguments[0] === "canvas" || arguments[0] === "CANVAS") {
const el = f.apply(this, arguments);
installGetContext(el);
return el;
}
return f.apply(this, arguments);
};
}
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "../../.env") });
const axios = require("axios");
const CG = require("codegrid-js").CodeGrid();
const countryCodes = require("./countryCodes");
const countryCodesNames = require("./countryCodesNames");
class GameHelper {
/**
* Checks if '/game/' is in the URL
* @param {string} url Game URL
* @return {boolean}
*/
static isGameURL = (url) => url.includes("/game/");
/**
* Gets the Game ID from a game URL
* Checks if ID is 16 characters in length
* @param {string} url Game URL
* @return {string|boolean} id or false
*/
static getGameId = (url) => {
const id = url.substring(url.lastIndexOf("/") + 1);
if (id.length == 16) {
return id;
} else {
return false;
}
};
/**
* Fetch a game seed
* @param {string} url
* @return {Promise} Seed Promise
*/
static fetchSeed = async (url) => {
return axios
.get(`https://www.geoguessr.com/api/v3/games/${url.substring(url.lastIndexOf("/") + 1)}`)
.then((res) => res.data)
.catch((error) => console.log(error));
};
/**
* Returns a country code
* @param {Object} location {lat, lng}
* @return {Promise} Country code Promise
*/
static getCountryCode = async (location) => {
return axios
.get(`https://api.bigdatacloud.net/data/reverse-geocode?latitude=${location.lat}&longitude=${location.lng}&key=${process.env.BDC_KEY}`)
.then((res) => countryCodes[res.data.countryCode])
.catch((error) => {
// if BDC returns an error use CodeGrid
return new Promise((resolve, reject) => {
CG.getCode(location.lat, location.lng, (error, code) => {
resolve(code);
reject(new Error(error));
});
}).then((code) => countryCodes[code.toUpperCase()]);
});
};
/**
* Returns a country code
* It uses CodeGrid first and then BDC if needed
* @param {Object} location {lat, lng}
* @return {Promise} Country code Promise
*/
static getCountryCodeLocally = async (location) => {
return new Promise((resolve, reject) => {
let coordinates = this.getSurroundings(location);
let promises = [];
coordinates.forEach((coord) => {
promises.push(this.getCountryCG(coord));
});
Promise.all(promises).then((values) => {
let unique = new Set(values);
if (unique.size === 1) {
console.log(unique.values().next().value);
} else {
this.getCountryBDC(location).then((data) => resolve(data));
}
});
});
};
/**
* Returns a country code (Only using BDC)
* Do not use externally - Used by getCountryCodeLocally
* Ultimately we will call our own API here and remove/
* replace getCountryCode
* @param {Object} location {lat, lng}
* @return {Promise} Country code Promise
*/
static getCountryBDC = async (location) => {
return axios
.get(`https://api.bigdatacloud.net/data/reverse-geocode?latitude=${location.lat}&longitude=${location.lng}&key=${process.env.BDC_KEY}`)
.then((res) => countryCodes[res.data.countryCode])
.catch((error) => error);
};
/**
* Returns a country code (Only using CodeGrid)
* Do not use externally - Used by getCountryCodeLocally
* @param {Object} location {lat, lng}
* @return {Promise} Country code Promise
*/
static getCountryCG = (location) => {
return new Promise((resolve, reject) => {
CG.getCode(location.lat, location.lng, (error, code) => {
if (error) {
reject(new Error(error));
} else {
resolve(countryCodes[code.toUpperCase()]);
}
});
});
};
/**
* Returns an array of 9 coodinates as objects.
* Each coordinate is 100 meters aways from the given
* coordinate y angles from 0 to 315
* The first coordinate is the original passed
* @param {Object} location {lat, lng}
* @return {Array} Coordinates [{lat, lng}, {lat, lng}] x 8
*/
static getSurroundings = (location) => {
const meters = 100;
const R_EARTH = 6378.137;
const M = 1 / (((2 * Math.PI) / 360) * R_EARTH) / 1000;
function moveFrom(coords, angle, distance) {
let radianAngle = (angle * Math.PI) / 180;
let x = 0 + distance * Math.cos(radianAngle);
let y = 0 + distance * Math.sin(radianAngle);
let newLat = coords.lat + y * M;
let newLng = coords.lng + (x * M) / Math.cos(coords.lat * (Math.PI / 180));
return { lat: newLat, lng: newLng };
}
let coordinates = [location];
for (let angle = 0; angle < 360; angle += 45) {
coordinates.push(moveFrom({ lat: location.lat, lng: location.lng }, angle, meters));
}
return coordinates;
};
/**
* Check if the param is coordinates
* @param {string} coordinates
* @return {boolean}
*/
static isCoordinates = (coordinates) => {
const regex = /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/g;
return regex.test(coordinates);
};
/**
* Returns map scale
* @param {Object} bounds map bounds
* @return {number} map scale
*/
static calculateScale = (bounds) =>
GameHelper.haversineDistance({ lat: bounds.min.lat, lng: bounds.min.lng }, { lat: bounds.max.lat, lng: bounds.max.lng }) / 7.458421;
/**
* Returns distance in km between two coordinates
* @param {Object} mk1 {lat, lng}
* @param {Object} mk2 {lat, lng}
* @return {number} km
*/
static haversineDistance = (mk1, mk2) => {
const R = 6371.071;
const rlat1 = mk1.lat * (Math.PI / 180);
const rlat2 = mk2.lat * (Math.PI / 180);
const difflat = rlat2 - rlat1;
const difflon = (mk2.lng - mk1.lng) * (Math.PI / 180);
const km =
2 *
R *
Math.asin(Math.sqrt(Math.sin(difflat / 2) * Math.sin(difflat / 2) + Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(difflon / 2) * Math.sin(difflon / 2)));
return km;
};
/**
* Returns score based on distance and scale
* @param {number} distance
* @param {number} scale
* @return {number} score
*/
static calculateScore = (distance, scale) => Math.round(5000 * Math.pow(0.99866017, (distance * 1000) / scale));
/**
* Returns guesses sorted by distance ASC
* @param {array} guesses
* @return {array} guesses
*/
static sortByDistance = (guesses) => guesses.sort((a, b) => a.distance - b.distance);
/**
* Returns guesses sorted by score DESC
* @param {array} guesses
* @return {array} guesses
*/
static sortByScore = (guesses) => guesses.sort((a, b) => b.score - a.score);
/** Converts a country code into an emoji flag
* @param {String} value
*/
static toEmojiFlag = (value) => {
if (value.length == 2) {
return value.toUpperCase().replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
} else {
const flag = value
.toUpperCase()
.substring(0, 2)
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
const region = value
.toUpperCase()
.substring(2)
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397) + " ");
return `${flag} ${region}`.trim();
}
};
/** Replace special chars
* @param {String} val
*/
static normalize = (val) => val.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
/** Matches words above 3 letters
* @param {String} input
* @param {String} key
*/
static isMatch = (input, key) => input.length >= 3 && key.includes(input) && input.length <= key.length;
/** Find country by code or name
* @param {String} input
* @return {Object} countryCodesNames
*/
static findCountry = (input) => {
const normalized = GameHelper.normalize(input);
return countryCodesNames.find((country) => country.code === normalized || GameHelper.isMatch(normalized, country.names.toLowerCase()));
};
/** Return a random country code
* @return {String}
*/
static getRandomFlag = () => countryCodesNames[Math.floor(Math.random() * countryCodesNames.length)].code;
/** Make game summary link
* @param {string} streamer
* @param {string} mapName
* @param {Object} mode
* @param {Object[]} locations
* @param {Object[]} scores
* @return {Promise} link
*/
static makeLink = (streamer, mapName, mode, locations, totalScores) => {
const players = totalScores.map((guess) => {
return { username: guess.username, flag: guess.flag, score: guess.score, rounds: guess.rounds };
});
return axios
.post(`${process.env.API_URL}/game`, {
streamer: streamer,
map: mapName,
mode: mode,
locations: locations,
players: players,
})
.then((res) => {
return `${process.env.BASE_URL}/game/${res.data.code}`;
})
.catch((err) => {
console.log(err);
});
};
}
module.exports = GameHelper;
const GameHelper = require("./GameHelper");
function mainWindow() {
let win = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(__dirname, "../preload.js"),
enableRemoteModule: true,
contextIsolation: false,
webSecurity: false,
devTools: false,
},
});
win.setMenuBarVisibility(false);
win.loadURL("https://www.geoguessr.com/classic");
win.webContents.on("new-window", (e, link) => {
e.preventDefault();
shell.openExternal(link);
});
win.on("closed", () => {
win = null;
});
return win;
}
module.exports = mainWindow();
const path = require("path");
const { BrowserWindow } = require("electron");
function updateWindow() {
const win = new BrowserWindow({
width: 400,
minWidth: 400,
height: 240,
minHeight: 240,
frame: false,
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
devTools: false,
},
});
win.setMenuBarVisibility(false);
win.loadURL(path.join(__dirname, "./update.html"));
return win;
}
module.exports = updateWindow();
const ipcRenderer = require("electron").ipcRenderer;
const message = document.getElementById("message");
const restartButton = document.getElementById("restart-button");
ipcRenderer.on("download_progress", () => {
ipcRenderer.removeAllListeners("download_progress");
message.innerHTML = `Download in progress<span class="one">.</span><span class="two">.</span><span class="three">.</span>`;
});
ipcRenderer.on("update_downloaded", () => {
ipcRenderer.removeAllListeners("update_downloaded");
message.innerHTML = `
Update downloaded successfully. It will be installed on restart.<br>
Restart now ?
`;
restartButton.classList.remove("hidden");
});
ipcRenderer.on("update_error", (err) => {
ipcRenderer.removeAllListeners("update_error");
message.innerHTML = "An error occured.";
});
function closeWindow() {
ipcRenderer.send("close_update_window");
}
function restartApp() {
ipcRenderer.send("restart_app");
}
const path = require("path");
const { BrowserWindow, shell } = require("electron");
function settingsWindow() {
const win = new BrowserWindow({
width: 600,
minWidth: 600,
height: 500,
minHeight: 500,
show: false,
frame: false,
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
devTools: false,
},
});
win.setMenuBarVisibility(false);
win.loadURL(path.join(__dirname, "./settings.html"));
win.webContents.on("new-window", (e, link) => {
e.preventDefault();
shell.openExternal(link);
});
return win;
}
const openTab = (e, tab) => {
const tabcontent = document.getElementsByClassName("tabcontent");
for (let i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
const tablinks = document.getElementsByClassName("tablinks");
for (let i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tab).style.display = "block";
e.currentTarget.className += " active";
};
document.getElementById("defaultOpen").click();
const GameHelper = require("../utils/GameHelper");
const Store = require("../utils/Store");
const Guess = require("./Guess");
class Game {
constructor() {
this.win;
this.settings;
this.url;
this.seed;
this.mapScale;
this.location;
this.country;
this.isInGame = false;
this.guessesOpen = false;
this.isMultiGuess = false;
this.guesses = [];
this.total = [];
this.lastLocation = {};
}
init = (win, settings) => {
this.win = win;
this.settings = settings;
this.lastLocation = Store.get("lastLocation", {});
};
start = async (url, isMultiGuess) => {
this.isInGame = true;
this.isMultiGuess = isMultiGuess;
if (this.url === url) {
this.refreshSeed();
} else {
this.url = url;
this.seed = await this.getSeed();
this.mapScale = GameHelper.calculateScale(this.seed.bounds);
this.getCountry();
this.clearGuesses();
}
};
outGame = () => {
this.isInGame = false;
this.closeGuesses();
};
streamerHasguessed = (newSeed) => newSeed.player.guesses.length != this.seed.player.guesses.length;
locHasChanged = (newSeed) => JSON.stringify(newSeed.rounds[newSeed.rounds.length - 1]) != JSON.stringify(this.getLocation());
refreshSeed = async () => {
const newSeed = await this.getSeed();
// If a guess has been comitted, process streamer guess then return scores
if (this.streamerHasguessed(newSeed)) {
this.win.webContents.send("pre-round-results");
this.closeGuesses();
this.seed = newSeed;
const location = this.location;
const scores = await this.makeGuess().then(() => this.getRoundScores());
return { location, scores };
// Else, if only the loc has changed, the location was skipped, replace current loc
} else if (this.locHasChanged(newSeed)) {
this.seed = newSeed;
this.getCountry();
return false;
}
};
getSeed = async () => await GameHelper.fetchSeed(this.url);
getCountry = async () => {
this.location = this.getLocation();
this.country = await GameHelper.getCountryCode(this.location);
};
makeGuess = async () => {
this.seed = await this.getSeed();
if (this.isMultiGuess) await this.processMultiGuesses();
const streamerGuess = await this.processStreamerGuess();
this.guesses.push(streamerGuess);
this.guesses.forEach((guess) => this.pushToTotal(guess));
this.lastLocation = { lat: this.location.lat, lng: this.location.lng };
Store.set("lastLocation", this.lastLocation);
if (this.seed.state != "finished") {
this.getCountry();
}
};
processMultiGuesses = () => {
let promises = [];
this.guesses.forEach(async (guess, index) => {
promises.push(
new Promise(async (resolve, reject) => {
const guessedCountry = await GameHelper.getCountryCode(guess.position);
guessedCountry === this.country ? guess.streak++ : (guess.streak = 0);
this.guesses[index].streak = guess.streak;
Store.setUserStreak(guess.user, guess.streak);
resolve();
})
);
});
return Promise.all(promises);
};
processStreamerGuess = async () => {
const index = this.seed.state === "finished" ? 1 : 2;
const streamerGuess = this.seed.player.guesses[this.seed.round - index];
const location = { lat: streamerGuess.lat, lng: streamerGuess.lng };
const streamer = Store.getOrCreateUser(this.settings.channelName, this.settings.channelName);
const guessedCountry = await GameHelper.getCountryCode(location);
guessedCountry === this.country ? streamer.addStreak() : streamer.setStreak(0);
const distance = GameHelper.haversineDistance(location, this.location);
const score = streamerGuess.timedOut ? 0 : GameHelper.calculateScore(distance, this.mapScale);
if (score == 5000) streamer.perfects++;
streamer.calcMeanScore(score);
streamer.nbGuesses++;
Store.saveUser(this.settings.channelName, streamer);
return new Guess(streamer.username, streamer.username, "#FFF", streamer.flag, location, streamer.streak, distance, score);
};
handleUserGuess = async (userstate, location) => {
const index = this.hasGuessedThisRound(userstate.username);
if (!this.isMultiGuess && index != -1) return "alreadyGuessed";
const user = Store.getOrCreateUser(userstate.username, userstate["display-name"]);
if (this.hasPastedPreviousGuess(user.previousGuess, location)) return "pastedPreviousGuess";
if (JSON.stringify(user.lastLocation) != JSON.stringify(this.lastLocation)) user.setStreak(0);
if (!this.isMultiGuess) {
const guessedCountry = await GameHelper.getCountryCode(location);
guessedCountry === this.country ? user.addStreak() : user.setStreak(0);
}
const distance = GameHelper.haversineDistance(location, this.location);
const score = GameHelper.calculateScore(distance, this.mapScale);
if (score == 5000) user.perfects++;
user.calcMeanScore(score);
const guess = new Guess(userstate.username, userstate["display-name"], userstate.color, user.flag, location, user.streak, distance, score);
// Modify guess or push it
if (this.isMultiGuess && index != -1) {
this.guesses[index] = guess;
this.guesses[index].modified = true;
} else {
user.nbGuesses++;
this.guesses.push(guess);
}
user.setLastLocation({ lat: this.location.lat, lng: this.location.lng });
user.setPreviousGuess(location);
Store.saveUser(userstate.username, user);
return { user: user, guess: guess };
};
nextRound = () => {
this.guesses = [];
if (this.seed.state != "finished") {
this.win.webContents.send("next-round", this.isMultiGuess);
} else {
this.win.webContents.send("final-results");
}
};
getLocation = () => this.seed.rounds[this.seed.round - 1];
getLocations = () => {
return this.seed.rounds.map((round) => {
return {
lat: round.lat,
lng: round.lng,
heading: Math.round(round.heading),
pitch: Math.round(round.pitch),
};
});
};
openGuesses = () => {
this.guessesOpen = true;
};
closeGuesses = () => {
this.guessesOpen = false;
};
clearGuesses = () => {
this.guesses = [];
this.total = [];
};
/**
* @param {string} user
* @return {Number} index
*/
hasGuessedThisRound = (user) => this.guesses.findIndex((e) => e.user === user);
/**
* @param {Object} previousGuess
* @param {Object} location {lat, lng}
* @return {boolean}
*/
hasPastedPreviousGuess = (previousGuess, location) => {
if (previousGuess === null) return false;
return previousGuess.lat === location.lat && previousGuess.lng === location.lng;
};
/**
* @param {Object} guess
*/
pushToTotal = (guess) => {
const index = this.total.findIndex((e) => e.user === guess.user);
if (index != -1) {
this.total[index].scores.push({ round: this.seed.round - 1, score: guess.score });
this.total[index].score += guess.score;
this.total[index].distance += guess.distance;
this.total[index].streak = guess.streak;
this.total[index].color = guess.color;
this.total[index].flag = guess.flag;
this.total[index].rounds++;
} else {
this.total.push({ scores: [{ round: this.seed.round, score: guess.score }], ...guess, rounds: 1 });
}
};
/**
* @return {Guess[]} sorted guesses by Distance
*/
getRoundScores = () => GameHelper.sortByDistance(this.guesses);
/**
* @return {Guess[]} sorted guesses by Score
*/
getTotalScores() {
const scores = GameHelper.sortByScore(this.total);
// TODO: Remember to check equality
Store.userAddVictory(scores[0].user);
return scores;
}
get mapName() {
return this.seed.mapName;
}
get mode() {
return { noMove: this.seed.forbidMoving, noPan: this.seed.forbidRotating, noZoom: this.seed.forbidZooming };
}
get round() {
return this.seed.round;
}
get nbGuesses() {
return this.guesses.length;
}
}
module.exports = Game;
class Guess {
/**
* @param {String} user
* @param {String} username
* @param {String} color
* @param {String} flag
* @param {Object} position {lat, lng}
* @param {Number} streak
* @param {Number} distance
* @param {Number} score
* @param {Boolean} modified
*/
constructor(user, username, color, flag, position, streak, distance, score, modified = false) {
this.user = user;
this.username = username;
this.color = color === null ? "#FFF" : color;
this.flag = flag;
this.position = position;
this.streak = streak;
this.distance = distance;
this.score = score;
this.modified = modified;
}
}
module.exports = Guess;
const Store = require("../../utils/Store");
class Scoreboard {
constructor() {
this.visibility;
this.position;
this.container;
this.scoreboard;
this.title;
this.switchContainer;
this.switchBtn;
this.table;
this.columnState;
this.isMultiGuess = false;
this.isResults = false;
this.isScrolling = false;
this.speed = 50;
this.init();
}
init() {
this.visibility = this.getCookie("visibility", true);
this.position = this.getCookie("scoreboard_position", { top: 20, left: 5, width: 380, height: 180 });
this.container = $("#scoreboardContainer");
this.title = $("#scoreboardTitle");
this.switchContainer = $("#switchContainer");
this.switchBtn = $("#switchBtn");
this.scoreboard = $("#scoreboard");
this.scoreboard.css("top", this.position.top);
this.scoreboard.css("left", this.position.left);
this.scoreboard.css("width", this.position.width);
this.scoreboard.css("height", this.position.height);
this.scoreboard
.resizable({
handles: "n, e, s, w, ne, se, sw, nw",
containment: "#scoreboardContainer",
})
.draggable({
containment: "#scoreboardContainer",
})
.mouseup(() => {
const currentPosition = this.getPosition();
if (JSON.stringify(this.position) !== JSON.stringify(currentPosition)) {
this.setPosition(currentPosition);
this.setCookie("scoreboard_position", JSON.stringify(currentPosition));
}
});
this.switchBtn.on("change", () => {
if (this.switchBtn.is(":checked")) {
ipcRenderer.send("open-guesses");
} else {
ipcRenderer.send("close-guesses");
}
});
this.table = $("#datatable").DataTable({
info: false,
searching: false,
paging: false,
scrollY: 100,
scrollResize: true,
scrollCollapse: true,
language: { zeroRecords: " " },
dom: "Bfrtip",
buttons: [
{
extend: "colvis",
text: "⚙️",
className: "colvis-btn",
columns: ":not(.noVis)",
},
],
columns: [
{ data: "Position" },
{ data: "Player" },
{ data: "Streak" },
{
data: "Distance",
render: (data, type) => {
if (type === "display" || type === "filter") {
return this.toMeter(data);
}
return data;
},
},
{ data: "Score" },
],
columnDefs: [
{ targets: 0, width: "35px", className: "noVis" },
{ targets: 1, width: "auto", className: "noVis" },
{ targets: 2, width: "55px" },
{ targets: 3, width: "100px" },
{ targets: 4, width: "75px", type: "natural" },
],
});
// Column Visisbility
this.columnState = this.getCookie("CG_ColVis", [
{ column: 0, state: true },
{ column: 2, state: true },
{ column: 3, state: true },
{ column: 4, state: true },
]);
// Handle ColVis change
this.table.on("column-visibility.dt", (e, settings, column, state) => {
if (this.isResults || this.isMultiGuess) return;
const i = this.columnState.findIndex((o) => o.column === column);
if (this.columnState[i]) {
this.columnState[i] = { column, state };
} else {
this.columnState.push({ column, state });
}
this.setCookie("CG_ColVis", JSON.stringify(this.columnState));
});
// SCROLLER
const sliderElem = `<input type="range" min="5" max="50" value="20" id="scrollSpeedSlider">`;
$(".dt-buttons").append(sliderElem);
const slider = document.getElementById("scrollSpeedSlider");
slider.oninput = (e) => {
this.speed = e.currentTarget.value;
this.scroller(".dataTables_scrollBody");
};
const scrollBtn = `
<div class="dt-button scrollBtn">
<label>
<input type="checkbox" id="scrollBtn"><span>⮃</span>
</label>
</div>
`;
$(".dt-buttons").prepend(scrollBtn);
$("#scrollBtn").on("change", (e) => {
if (e.currentTarget.checked != true) {
this.isScrolling = $(e.currentTarget).is(":checked");
this.stop(".dataTables_scrollBody");
slider.style.display = "none";
} else {
this.isScrolling = $(e.currentTarget).is(":checked");
this.scroller(".dataTables_scrollBody");
slider.style.display = "inline";
}
});
}
/**
* @param {boolean} isMultiGuess
*/
reset = (isMultiGuess) => {
this.isMultiGuess = isMultiGuess;
this.setColVis();
this.isResults = false;
this.setTitle("GUESSES (0)");
this.showSwitch(true);
this.table.clear().draw();
};
setVisibility = () => {
this.visibility = !this.visibility;
this.setCookie("visibility", this.visibility);
this.checkVisibility();
};
checkVisibility = () => {
if (this.visibility) {
this.show();
} else {
this.hide();
}
};
show = () => {
this.container.show();
};
hide = () => {
this.container.hide();
};
renderGuess = (guess) => {
const row = {
Position: "",
Player: `${guess.flag ? `<span class="flag-icon flag-icon-${guess.flag}"></span>` : ""}<span class='username' style='color:${guess.color}'>${
guess.username
}</span>`,
Streak: guess.streak,
Distance: guess.distance,
Score: guess.score,
};
const rowNode = this.table.row.add(row).node();
rowNode.classList.add("expand");
setTimeout(() => {
rowNode.classList.remove("expand");
}, 200);
this.table.order([3, "asc"]).draw(false);
this.table
.column(0)
.nodes()
.each((cell, i) => {
cell.innerHTML = i + 1;
});
};
renderMultiGuess = (guesses) => {
const rows = guesses.map((guess) => {
return {
Position: "",
Player: `${guess.flag ? `<span class="flag-icon flag-icon-${guess.flag}"></span>` : ""}<span class='username' style='color:${guess.color}'>${
guess.username
}</span>`,
Streak: "",
Distance: "",
Score: "",
};
});
this.table.clear().draw();
this.table.rows.add(rows).draw();
};
displayScores = (scores, isTotal = false) => {
this.isResults = true;
if (scores[0]) scores[0].color = "#E3BB39";
if (scores[1]) scores[1].color = "#C9C9C9";
if (scores[2]) scores[2].color = "#A3682E";
const rows = scores.map((score) => {
return {
Position: "",
Player: `${score.flag ? `<span class="flag-icon flag-icon-${score.flag}"></span>` : ""}<span class='username' style='color:${score.color}'>${
score.username
}</span>`,
Streak: score.streak,
Distance: score.distance,
Score: `${score.score}${isTotal ? " [" + score.rounds + "]" : ""}`,
};
});
this.table.clear().draw();
this.table.rows.add(rows);
this.table.order([4, "desc"]).draw(false);
let content;
this.table
.column(0)
.nodes()
.each((cell, i) => {
content = i + 1;
if (isTotal) {
if (i == 0) content = "<span class='icon'>🏆</span>";
else if (i == 1) content = "<span class='icon'>🥈</span>";
else if (i == 2) content = "<span class='icon'>🥉</span>";
}
cell.innerHTML = content;
});
// Restore columns visibility
this.table.columns().visible(true);
this.toTop(".dataTables_scrollBody");
};
scroller = (elem) => {
const div = $(elem);
const loop = () => {
if (!this.isScrolling) return;
div.stop().animate({ scrollTop: div[0].scrollHeight }, (div[0].scrollHeight - div.scrollTop() - 84) * this.speed, "linear", () => {
setTimeout(() => {
div.stop().animate({ scrollTop: 0 }, 1000, "swing", () => {
setTimeout(() => {
loop();
}, 3000);
});
}, 1000);
});
};
loop();
};
toTop = (elem) => {
this.stop(elem);
setTimeout(() => {
this.scroller(elem);
}, 3000);
};
stop(elem) {
$(elem).stop();
}
setColVis = () => {
if (this.isMultiGuess) {
this.table.columns([0, 2, 3, 4]).visible(false);
} else {
this.columnState.forEach((column) => {
this.table.column(column.column).visible(column.state);
});
}
};
getPosition = () => ({
top: this.scoreboard.position().top,
left: this.scoreboard.position().left,
width: this.scoreboard.width(),
height: this.scoreboard.height(),
});
setPosition = (position) => (this.position = position);
setTitle = (title) => this.title.text(title);
showSwitch = (state) => this.switchContainer.css("display", state ? "block" : "none");
switchOn = (state) => this.switchBtn.prop("checked", state);
toMeter = (distance) => (distance >= 1 ? parseFloat(distance.toFixed(1)) + "km" : parseInt(distance * 1000) + "m");
// ColVis Cookies
setCookie = (name, value, exdays = 60) => {
const d = new Date();
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
const expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/";
};
getCookie = (name, defaultValue = {}) => {
const cname = name + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(";");
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == " ") {
c = c.substring(1);
}
if (c.indexOf(cname) == 0) {
return JSON.parse(c.substring(cname.length, c.length));
}
}
return defaultValue;
};
}
module.exports = Scoreboard;
class Settings {
/**
* @param {String} channelName=""
* @param {String} botUsername=""
* @param {String} token=""
* @param {String} cgCmd="!cg"
* @param {String} cgMsg="To play along, go to this link, pick a location, and paste the whole command into chat: <your cg link>"
* @param {String} userGetStatsCmd="!me"
* @param {String} userClearStatsCmd="!clear"
* @param {String} setStreakCmd="!setstreak"
* @param {Boolean} showHasGuessed=true
* @param {Boolean} isMultiGuess=false
* @param {Boolean} noCar=false
* @param {Boolean} noCompass=false
*/
constructor(
channelName = "",
botUsername = "",
token = "",
cgCmd = "!cg",
cgMsg = "To play along, go to this link, pick a location, and paste the whole command into chat: <your cg link>",
userGetStatsCmd = "!me",
userClearStatsCmd = "!clear",
setStreakCmd = "!setstreak",
showHasGuessed = true,
isMultiGuess = false,
noCar = false,
noCompass = false
) {
this.channelName = channelName;
this.botUsername = botUsername;
this.token = token;
this.cgCmd = cgCmd;
this.cgMsg = cgMsg;
this.userGetStatsCmd = userGetStatsCmd;
this.userClearStatsCmd = userClearStatsCmd;
this.setStreakCmd = setStreakCmd;
this.showHasGuessed = showHasGuessed;
this.isMultiGuess = isMultiGuess;
this.noCar = noCar;
this.noCompass = noCompass;
}
/**
* @param {boolean} noCar
* @param {boolean} noCompass
*/
setGameSettings(isMultiGuess, noCar, noCompass) {
this.isMultiGuess = isMultiGuess;
this.noCar = noCar;
this.noCompass = noCompass;
}
/**
* @param {Object} commands
*/
setTwitchCommands(commands) {
this.cgCmd = commands.cgCmdd;
this.cgMsg = commands.cgMsgg;
this.userGetStatsCmd = commands.userGetStats;
this.userClearStatsCmd = commands.userClearStats;
this.setStreakCmd = commands.setStreak;
this.showHasGuessed = commands.showHasGuessed;
}
/**
* @param {string} channelName
* @param {string} botUsername
* @param {string} token
*/
setTwitchSettings(channelName, botUsername, token) {
this.channelName = channelName;
this.botUsername = botUsername;
this.token = token;
}
}
module.exports = Settings;
class User {
/**
* @param {String} user
* @param {String} username
* @param {String} flag=""
* @param {Number} streak=0
* @param {Number} bestStreak=0
* @param {Number} correctGuesses=0
* @param {Number} nbGuesses=0
* @param {Number} perfects=0
* @param {Number} victories=0
* @param {Number} meanScore=null
* @param {Object} previousGuess={}
* @param {Object} lastLocation=null
*/
constructor(
user,
username,
flag = "",
streak = 0,
bestStreak = 0,
correctGuesses = 0,
nbGuesses = 0,
perfects = 0,
victories = 0,
meanScore = null,
previousGuess = {},
lastLocation = null
) {
this.user = user;
this.username = username;
this.flag = flag;
this.streak = streak;
this.bestStreak = bestStreak;
this.correctGuesses = correctGuesses;
this.nbGuesses = nbGuesses;
this.perfects = perfects;
this.victories = victories;
this.meanScore = meanScore;
this.previousGuess = previousGuess;
this.lastLocation = lastLocation;
}
/* Add 1 to streak and correctGuesses. */
addStreak() {
this.streak++;
this.correctGuesses++;
if (this.streak > this.bestStreak) this.bestStreak = this.streak;
}
/** Set user streak
* @param {Number} number
*/
setStreak(number) {
this.streak = number;
if (this.streak > this.bestStreak) this.bestStreak = this.streak;
}
/** Set last location
* @param {Object} location
*/
setLastLocation(location) {
this.lastLocation = location;
}
/** Set previous guess
* @param {Object} location
*/
setPreviousGuess(location) {
this.previousGuess = location;
}
/** Set a country flag
* @param {String} flag country code
*/
setFlag(flag) {
this.flag = flag;
}
/** Calculate mean score
* @param {Number} score
*/
calcMeanScore(score) {
if (this.meanScore === null) {
this.meanScore = score;
} else {
this.meanScore = (this.meanScore * this.nbGuesses + score) / (this.nbGuesses + 1);
}
}
}