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 = `
GUESSES (0)
# Player Streak Distance Score
`; 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 = "⚙️"; 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 = "👁️‍🗨️"; 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(`

${score.flag ? `` : ""}${score.username}
${score.distance >= 1 ? parseFloat(score.distance.toFixed(1)) + "km" : parseInt(score.distance * 1000) + "m"}
${score.score}

`); 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...`; }); ipcRenderer.on("update_downloaded", () => { ipcRenderer.removeAllListeners("update_downloaded"); message.innerHTML = ` Update downloaded successfully. It will be installed on restart.
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 = ``; $(".dt-buttons").append(sliderElem); const slider = document.getElementById("scrollSpeedSlider"); slider.oninput = (e) => { this.speed = e.currentTarget.value; this.scroller(".dataTables_scrollBody"); }; const scrollBtn = `
`; $(".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 ? `` : ""}${ guess.username }`, 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 ? `` : ""}${ guess.username }`, 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 ? `` : ""}${ score.username }`, 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 = "🏆"; else if (i == 1) content = "🥈"; else if (i == 2) content = "🥉"; } 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: " * @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: ", 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); } } }