kommu / Geoguessr Yandex Plugin

// ==UserScript==
// @name          Geoguessr Yandex Plugin
// @namespace     kommu
// @description   Play Geoguessr with Yandex Panoramas. A big shout out to @MrAmericanMike who help me coding this script and to @Alok who had the idea and help us to test the script
// @version       1.0.1
// @include       https://www.geoguessr.com/*
// @run-at        document-start
// @updateURL https://openuserjs.org/meta/kommu/Geoguessr_Yandex_Plugin.meta.js
// @license       MIT
// ==/UserScript==

const YANDEX_API_KEY = "";

function myLog(...args) {
  console.log(...args);
}
function myHighlight(...args) {
  console.log(`%c${[...args]}`, "color: dodgerblue; font-size: 24px;");
}

myLog("Geoguessr Yandex Plugin");

const MAP_NAME_INCLUDES = "Yandex";
const PANORAMA_MAP_NAME_INCLUDES = "Yandex Air Panorama";

const MAPS_API_URL = "https://maps.googleapis.com/maps/api/js?";

let PLAYER = null;
let ROUND = 0;
let YANDEX_INJECTED = false;
let NEW_ROUND_LOADED = false;
let COMPASS = null;
let PANORAMA_MAP = false;
let CURRENT_ROUND_DATA = null;

/**
 * Resolves succesfully after detecting that Google Maps API was loaded in a page
 *
 * @returns Promise
 */
function gmp() {
  return new Promise((resolve, reject) => {
    let scriptObserver = new MutationObserver((mutations, observer) => {
      for (let mutation of mutations) {
        for (let node of mutation.addedNodes) {
          if (node.tagName === "SCRIPT" && node.src.startsWith(MAPS_API_URL)) {
            scriptObserver.disconnect();
            scriptObserver = undefined;
            myLog("Detected Google Maps API");
            node.onload = () => resolve();
          }
        }
      }
    });

    let bodyDone = false;
    let headDone = false;

    let injectorObserver = new MutationObserver((mutations, observer) => {
      if (!bodyDone && document.body) {
        bodyDone = true;
        myLog("Body Observer Injected");
        scriptObserver && scriptObserver.observe(document.body, {
          childList: true
        });
      }
      if (!headDone && document.head) {
        headDone = true;
        myLog("Head Observer Injected");
        scriptObserver && scriptObserver.observe(document.head, {
          childList: true
        });
      }
      if (headDone && bodyDone) {
        myLog("Body and Head Observers Injected");
        observer.disconnect();
      }
    });

    injectorObserver.observe(document.documentElement, {
      childList: true,
      subtree: true
    });
  });
}

/**
 * Once the Google Maps API was loaded we can do more stuff
 */
gmp().then(() => {
  launchObserver();
});

/**
 * This observer stays alive while the script is running
 */
function launchObserver() {
  myHighlight("Main Observer");
  const OBSERVER = new MutationObserver((mutations, observer) => {
    detectGamePage();
  });
  OBSERVER.observe(document.head, { attributes: true, childList: true, subtree: true });
}

/**
 * Detects if the current page contains /game/ or /challenge/ in it
 * If it doesn't it sets:
 * ROUND = 0
 * PLAYER = null
 * COMPASS = null
 */
function detectGamePage() {
  const PATHNAME = window.location.pathname;
  if (PATHNAME.startsWith("/game/") || PATHNAME.startsWith("/challenge/")) {
    myLog("Game page");
    checkRound();
  }
  else {
    myLog("Not a Game page");
    ROUND = 0;
    PLAYER = null;
    COMPASS = null;
  }
}

/**
 * Checks for round changes
 */
function checkRound() {
  let currentRound = getRoundFromPage();
  if (ROUND != currentRound) {
    myHighlight("New round");
    ROUND = currentRound;
    NEW_ROUND_LOADED = true;
    COMPASS = null;
    getMapData();
  }
}

/**
 * Gets data for the current map and checks if name contains `MAP_NAME_INCLUDES`
 * If so it continues calling other functions, otherway it does nothing else
 */
function getMapData() {
  getSeed().then((data) => {
    myHighlight("Seed data");
    myLog(data);
    if (data.mapName.includes(MAP_NAME_INCLUDES)) {
      PANORAMA_MAP = (data.mapName.includes(PANORAMA_MAP_NAME_INCLUDES));
      myHighlight("Yandex Map");
      hideGoogleCanvas();
      hideDefaultZoomControls();
      injectYandexCanvas();
      CURRENT_ROUND_DATA = data;
      if (PLAYER) {
        goToLocation(data);
      }
      else {
        injectYandexScript().then(() => {
          myLog("Ready to inject player");
          injectYandexPlayer(data);
        }).catch((error) => {
          myLog(error);
        });
      }
    }
  }).catch((error) => {
    myLog(error);
  });
}

/**
 * Gets the seed data for the current game
 *
 * @returns Promise with seed data as object
 */
function getSeed() {
  myLog("getSeed called");
  return new Promise((resolve, reject) => {
    let token = getToken();
    let URL;

    const PATHNAME = window.location.pathname;

    if (PATHNAME.startsWith("/game/")) {
      URL = `https://www.geoguessr.com/api/v3/games/${token}`;
    }
    else if (PATHNAME.startsWith("/challenge/")) {
      URL = `https://www.geoguessr.com/api/v3/challenges/${token}/game`;
    }

    fetch(URL)
      .then((response) => response.json())
      .then((data) => {
        resolve(data);
      })
      .catch((error) => {
        reject(error);
      });
  });
}

/**
 * Gets the token from the current URL
 *
 * @returns token
 */
function getToken() {
  const PATHNAME = window.location.pathname;
  if (PATHNAME.startsWith("/game/")) {
    return PATHNAME.replace("/game/", "");
  }
  else if (PATHNAME.startsWith("/challenge/")) {
    return PATHNAME.replace("/challenge/", "");
  }
}

/**
 * Gets the round number from the ongoing game from the page itself
 *
 * @returns Round number
 */
function getRoundFromPage() {
  const roundData = document.querySelector("div[data-qa='round-number']");
  if (roundData) {
    let roundElement = roundData.querySelector("div:last-child");
    if (roundElement) {
      let round = parseInt(roundElement.innerText.charAt(0));
      if (!isNaN(round) && round >= 1 && round <= 5) {
        return round;
      }
    }
  }
  else {
    return ROUND;
  }
}

/**
 * Hides Google Canvas
 */
function hideGoogleCanvas() {
  const GOOGLE_MAPS_CANVAS = document.querySelector(".game-layout__panorama-canvas");
  GOOGLE_MAPS_CANVAS.style.display = "none";
  myLog("Google Canvas hidden");
}

/**
 * Hides default zoom controls and Yandex GOTO link
 */
function hideDefaultZoomControls() {
  let style = `
	.ymaps-2-1-79-panorama-gotoymaps {display: none !important;}
	.game-layout__controls {bottom: 8rem !important; left: 1rem !important;}
	.game-layout__controls .styles_controlGroup__2pd1f, .styles_horizontalControls__ewOLi {display: none !important;}
	`;

  let style_element = document.createElement("style");
  style_element.innerHTML = style;
  document.body.appendChild(style_element);
}

/**
 * Injects Yandex Canvas
 */
function injectYandexCanvas() {
  const GAME_CANVAS = document.querySelector(".game-layout__panorama");
  GAME_CANVAS.id = "yandex_player";
  myLog("Yandex Canvas injected");
}

/**
 * Injects Yandex Script
 */
function injectYandexScript() {
  return new Promise((resolve, reject) => {
    if (!YANDEX_INJECTED) {
      if (YANDEX_API_KEY === "") {
        let canvas = document.getElementById("yandex_player");
        canvas.innerHTML = `
			<div style="text-align: center;">
			<h1 style="margin-top: 80px; font-size: 48px;">YOU NEED YANDEX API KEY<h1>
			<p><a target="_blank" href="https://yandex.com/dev/maps/jsapi/doc/2.1/quick-start/index.html?from=techmapsmain">Get it here</a></p>
			<br/>
			<p>After that you need to add that key into this script in</p>
			<code>const YANDEX_API_KEY = "";</code>
			</div>
			`;
        reject();
      }
      else {
        const SCRIPT = document.createElement("script");
        SCRIPT.type = "text/javascript";
        SCRIPT.async = true;
        SCRIPT.onload = () => {
          ymaps.ready(() => {
            YANDEX_INJECTED = true;
            myHighlight("Yandex API Loaded");
            resolve();
          });
        }
        SCRIPT.src = `https://api-maps.yandex.ru/2.1/?lang=en_US&apikey=${YANDEX_API_KEY}`;
        document.body.appendChild(SCRIPT);
      }
    }
    else {
      resolve();
    }
  });
}

/**
 * Injects Yandex Player and calls handleReturnToStart
 */
function injectYandexPlayer(data) {
  const lat = data.rounds[data.round - 1].lat;
  const lng = data.rounds[data.round - 1].lng;

  if (PLAYER === null) {
    let options = {
      "direction": [0, 16],
      "span": [10, 67],
      "controls": ["zoomControl"]
    };
    if (PANORAMA_MAP) {
      options.layer = 'yandex#airPanorama';
    }
    ymaps.panorama.createPlayer("yandex_player", [lat, lng], options)
      .done((player) => {
        PLAYER = player;
        PLAYER.events.add("directionchange", (e) => {
          updateCompass(data);
        });
        myHighlight("Player injected");
      });
  }
  else {
    goToLocation(data);
  }
}

/**
 * Goes to location when PLAYER already exists
 *
 * @param {*} data
 */
function goToLocation(data) {
  myLog("Going to location");
  const lat = data.rounds[data.round - 1].lat;
  const lng = data.rounds[data.round - 1].lng;
  let options = {};
  if (PANORAMA_MAP) {
    options.layer = 'yandex#airPanorama';
  }
  PLAYER.moveTo([lat, lng], options);
  PLAYER.setDirection([0, 16]);
  PLAYER.setSpan([10, 67]);
}

/**
 * Updates the compass to match Yandex Panorama facing
 */
function updateCompass() {
  if (!COMPASS) {
    let compass = document.querySelector("img.compass__indicator");
    if (compass != null) {
      COMPASS = compass;
      let direction = PLAYER.getDirection()[0] * -1;
      COMPASS.setAttribute("style", `transform: rotate(${direction}deg);`);
    }
    handleReturnToStart();
  }
  else {
    let direction = PLAYER.getDirection()[0] * -1;
    COMPASS.setAttribute("style", `transform: rotate(${direction}deg);`);
  }
}

/**
 * Injects behavior to go back to start when the return to start flag is clicked
 *
 * @param {*} data
 */
function handleReturnToStart() {
  let flag = document.querySelector("button[data-qa='return-to-start']");
  if (flag != null) {
    myLog("Return to start attached");
    let FLAG = flag;
    FLAG.addEventListener("click", () => { goToLocation(CURRENT_ROUND_DATA); });
  }
}