xsanda / GeoGuessr keyboard controls

// ==UserScript==
// @name     GeoGuessr keyboard controls
// @namespace   xsanda
// @description Offer keyboard controls for using GeoGuessr: Enter where applicable, and typing country codes/backspace/escape to clear when playing on country mode.
// @version  0.1.3
// @include  https://www.geoguessr.com/*
// @require https://openuserjs.org/src/libs/xsanda/Run_code_as_client.js
// @require https://openuserjs.org/src/libs/xsanda/Google_Maps_Promise.js
// @run-at document-start
// @grant    none
// @license MIT
// ==/UserScript==

/* jshint esversion: 8 */
/* globals runAsClient, googleMapsPromise, exportFunction, google */

const isGamePage = () => location.pathname.startsWith("/challenge/") || location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/") || location.pathname.startsWith("/battle-royale/");

// Used for remembering where each country is, by saving past clicks.
const locationPrefix = (code) => `country-location/${code}`;

// Wrap a function in an error logger. This is good for making sure that errors in callbacks aren’t lost
function logErrors(f) {
  return async (...args) => {
    try {
      await f(...args);
    }
    catch (e) {
      console.error(e);
    }
  };
}

// A promise that resolves as soon as the document is loaded.
async function onLoad() {
  if (!document.body) await new Promise(resolve => document.addEventListener('load', resolve));
  return document.body;
}

// Returns true if the game is country-oriented (as opposed to pinpointing)
function isCountryGame() {
  return !!document.querySelector('.country-map');
}

// <kbd> styling from StackOverflow
const CSS_RULES = `
  .guess-keys {
    position: absolute;
    top: 0;
    right: 0;
    align: right;
    pointer-events: none;
  }

  .guess-keys kbd {
    display: inline-block;
    margin: 0 .1em;
    padding: .1em;
    width: 1.2em;
    text-align: center;
    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
    font-size: 11px;
    color: #242729;
    text-shadow: 0 1px 0 white;
    background-color: #e4e6e8;
    border: 1px solid #9fa6ad;
    border-radius: 3px;
    box-shadow: 0 1px 1px rgba(12,13,14,0.15),inset 0 1px 0 0 #fff;
  }
`;

// Add the above CSS to the document, so that the characters are styled correctly
function addCSS() {
  if (document.querySelector('#kbd-css')) return;
  const style = document.createElement('style');
  style.id = 'kbd-css';
  style.innerText = CSS_RULES;
  document.body.appendChild(style);
}

// Check if the key pressed is a letter
function isLetter(key) {
  return /^[a-z]$/i.test(key);
}

// Generate an element representing a letter drawn in a box.
function kbd(key) {
  const elem = document.createElement('kbd');
  elem.innerText = key.toUpperCase();
  return elem;
}

// Get the element containing the pressed keys, creating it if necessary.
function guessKeys() {
  addCSS();
  let elem = document.querySelector('.country-map > .guess-keys');
  if (!elem) {
    elem = document.createElement('div');
    elem.classList.add('guess-keys');
    const parent = document.querySelector('.country-map');
    if (parent) parent.insertBefore(elem, parent.children[1]);
  }
  return elem;
}

// Remove any guesses
function clearGuess(elem) {
  elem.innerText = '';
}

// Receive a new keypress from the user. This adds it to the existing input, clearing it if it is too long, and then selects the relevant country if two letters have been typed.
function typeGuess(key) {
  if (!isCountryGame()) return;
  const parent = guessKeys();
  if (parent.innerText.length > 1) clearGuess(parent);
  parent.appendChild(kbd(key));
  const guess = parent.innerText;
  if (guess.length === 2) {
    const country = JSON.parse(localStorage[locationPrefix(guess)] || 'null');
    if (country) {
      makeGuess(country.lat, country.long);
    }
    clearGuess(parent);
  }
}

// Show the country code to the user.
function teachAcronym(code) {
  const parent = guessKeys();
  clearGuess(parent);
  for (const key of code) parent.appendChild(kbd(key));
}

// Delete the last letter typed.
function backspace() {
  if (!isCountryGame()) return;
  const parent = guessKeys();
  if (!parent) return;
  const lastKey = parent.children[parent.children.length - 1];
  if (lastKey) {
    lastKey.remove();
  }
}

// Clear any typed input when escape has been pressed
function escape() {
  if (!isCountryGame()) return;
  const parent = guessKeys();
  if (!parent.innerText) return false;
  clearGuess(parent);
  return true;
}

// Click the main button when enter is pressed
const ACTION_BUTTONS = ['perform-guess', 'close-round-result'];

function pressButton() {
  for (const button of ACTION_BUTTONS) {
    const el = document.querySelector(`[data-qa=${button}]`);
    if (!el) continue;
    el.click();
    return;
  }
}

// When a country is clicked, remember its location, so its country code will be recognised in future
async function rememberLocation(lat, long) {
  if (!isCountryGame()) return;
  await new Promise(resolve => setTimeout(resolve));
  const countryImg = document.querySelector('.country-map-selected-country__circle img');
  if (!countryImg) {
    teachAcronym('');
    return; // Nowhere selected
  }
  const countryCode = countryImg.src.split('/').pop().split('.')[0];
  localStorage[locationPrefix(countryCode)] = JSON.stringify({
    lat,
    long
  });
  teachAcronym(countryCode);
}

let makeGuess = () => console.error('makeGuess: no map found');



// When the page is ready, listen for presses (enter/letters) and keydown (backspace/escape).
onLoad().then(() => {
  document.body.addEventListener('keypress', logErrors(event => {
    const key = event.key;
    if (!isGamePage()) return;
    if (key === 'Enter') {
      pressButton();
    }
    else if (isLetter(key)) {
      typeGuess(key);
    }
    else return;
    event.preventDefault();
    event.stopPropagation();
  }));

  document.body.addEventListener('keydown', logErrors(event => {
    const key = event.key;
    if (!isGamePage()) return;
    if (key === 'Backspace') {
      backspace();
    }
    else if (key === 'Escape') {
      if (escape() === false) return;
    }
    else return;
    event.preventDefault();
    event.stopPropagation();
  }));

  // Allow rememberLocation to be called from user functions
  unsafeWindow.rememberLocation = exportFunction(logErrors(rememberLocation), unsafeWindow);
  unsafeWindow.provideGuesser = exportFunction(guesser => { makeGuess = guesser; }, unsafeWindow);
});

// When Google Maps is loaded, add a click handler to every map, and a provide a handler for guessing
googleMapsPromise.then(() => runAsClient(() => {
  const oldMap = google.maps.Map;
  google.maps.Map = Object.assign(function (...args) {
    const res = oldMap.apply(this, args);
    provideGuesser((lat, long) => {
      google.maps.event.trigger(this, 'click', {
        latLng: new google.maps.LatLng(lat, long),
      });
    });
    this.addListener('click', (e) => window.rememberLocation(e.latLng.lat(), e.latLng.lng()));
    return res;
  }, {
    prototype: oldMap.prototype
  });
}));