Watilin / ABDestination

// ==UserScript==
// @name        ABDestination
// @namespace   fr.kergoz-panic.watilin
// @description Choisissez une destination et ce script vous dira quelle direction prendre et quand vous arriverez.
// @version     1.0
// @include     http://www.alphabounce.com/
// @include     http://www.alphabounce.com/user/*
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_getResourceText
// @resource    ui-html             ./ui.html
// @resource    ui-css              ./ui.css
// ==/UserScript==

"use strict";

// Note: this userscript is Firefox only.

// Table of Contents
//  [CON] Script Constants
//  [MAI] Main Script Section
//  [UIM] UI Managment
//  [ANI] Animation
//  [UTI] Utils

// [@CON] Script Constants /////////////////////////////////////////////

const π = Math.PI;
const ANIM_DURATION = 1200; // ms
const WORMHOLE_THRESHOLD = 4000; // ms
const FREE_FUEL = 3; // not intended to remain constant

// [@MAI] Main Script Section //////////////////////////////////////////

if (self === top &&
    "/" === location.pathname) { // top-level window

  let iw = document.getElementById("iframe").contentWindow;

  // hacking haxe.Http to retrieve player's coordinates
  // this avoids making an unnecessary request to the server
  let proto = unsafeWindow.haxe.Http.prototype;
  proxifyFunction(proto, "request", function () {
    if (!this.url.startsWith("/user/data.xml")) return;

    proxifyFunction(this, "onData", function (str) {
      var engineChanged = false;
      var coordsChanged = false;

      var engineMatch = /\bengine="(\d+)"/.exec(str);
      if (engineMatch) {
        let oldEngine = GM_getValue("engine", 1);
        let engine = parseInt(engineMatch[1], 10);
        engineChanged = oldEngine !== engine;
        GM_setValue("engine", engine);
      }

      var xMatch = /\bx="(-?\d+)"/.exec(str);
      var yMatch = /\by="(-?\d+)"/.exec(str);
      if (xMatch && yMatch) {
        let oldX = GM_getValue("x", 0);
        let oldY = GM_getValue("y", 0);
        let x = parseInt(xMatch[1], 10);
        let y = parseInt(yMatch[1], 10);
        coordsChanged = (oldX !== x) || (oldY !== y);
        GM_setValue("x", x);
        GM_setValue("y", y);
      }

      // sending one event for one or both changes
      if (engineChanged || coordsChanged) {
        document.getElementById("iframe").contentWindow
          .dispatchEvent(new CustomEvent("gameDataChanged"));
      }
    });
  });

} else if ("/" === parent.location.pathname &&
           location.pathname.startsWith("/user/")) { // iframe

  let $menuUl = document.querySelector("#menu ul");

  let $newLi = document.createElement("li");
  let $oldLi = $menuUl.querySelector(".active");
  let $ui;
  let $section = document.getElementById("section");

  let $a = document.createElement("a");
  $a.textContent = "Destination";
  $a.href = "/user/destination";

  $a.addEventListener("click", event => {
    event.preventDefault();
    if ($newLi.classList.contains("active")) return;

    if (!$ui) {
      let fragment = injectUI();
      $ui = fragment.querySelector("#section");
      $section.parentNode.insertBefore(fragment, $section.nextSibling);
    }
    $ui.style.display = "";
    $section.style.display = "none";
    $oldLi.classList.remove("active");
    $newLi.classList.add("active");
    requestAnimationFrame(updateUI);
  });

  $oldLi.querySelector("a").addEventListener("click", event => {
    if (!$newLi.classList.contains("active")) return;

    event.preventDefault();
    $ui.style.display = "none";
    $section.style.display = "";
    $oldLi.classList.add("active");
    $newLi.classList.remove("active");
  });

  $newLi.appendChild($a);
  $menuUl.appendChild($newLi);

}

// [@UIM] UI Managment /////////////////////////////////////////////////

var [ injectUI, updateUI ] = (function () {

  var $coordX, $coordY,
      $engine,
      $destX, $destY,
      $distH, $distV, $distTot,
      $trip,
      $cape;

  var paintStyles = {};

  return [
    function injectUI() {
      var fragment = document.createDocumentFragment();
      var $container = document.createElement("div");

      var uiHtml = GM_getResourceText("ui-html");
      var uiCss  = GM_getResourceText("ui-css");

      var $style = document.createElement("style");
      $style.type = "text/css";
      $style.media = "screen";
      $style.textContent = uiCss;
      document.head.appendChild($style);

      var sheet = $style.sheet;
      for (let rule of sheet.cssRules) {
        if (rule.selectorText.startsWith("#cape .")) {
          paintStyles[rule.selectorText] = rule.style;
        }
      }

      window.addEventListener("gameDataChanged", function () {
        requestAnimationFrame(updateUI);
      });

      $container.innerHTML = uiHtml;
      $coordX  = $container.querySelector("#coord-x");
      $coordY  = $container.querySelector("#coord-y");
      $engine  = $container.querySelector("#engine");
      $destX   = $container.querySelector("#dest-x");
      $destY   = $container.querySelector("#dest-y");
      $distH   = $container.querySelector("#dist-h");
      $distV   = $container.querySelector("#dist-v");
      $distTot = $container.querySelector("#dist-tot");
      $trip    = $container.querySelector("#trip");
      $cape    = $container.querySelector("#cape");

      var timerId;
      var destinationChange = function (event) {
        clearTimeout(timerId);
        timerId = setTimeout(function () {
          GM_setValue("destinationX", $destX.value);
          GM_setValue("destinationY", $destY.value);
          updateUI();
        }, 200);
      };

      $destX.addEventListener("change", destinationChange);
      $destX.addEventListener("keyup", destinationChange);
      $destY.addEventListener("change", destinationChange);
      $destY.addEventListener("keyup", destinationChange);

      var cx = $cape.getContext("2d");
      cx.translate($cape.width / 2, $cape.height / 2);

      while ($container.firstChild) {
        fragment.appendChild($container.firstChild);
      }
      return fragment;
    },

    function updateUI() {
      var x      = parseInt(GM_getValue("x",            0), 10);
      var y      = parseInt(GM_getValue("y",            0), 10);
      var engine = parseInt(GM_getValue("engine",       1), 10);
      var destX  = parseInt(GM_getValue("destinationX", 0), 10);
      var destY  = parseInt(GM_getValue("destinationY", 0), 10);

      $coordX.textContent = x;
      $coordY.textContent = y;
      $engine.textContent = engine;

      $destX.value = destX;
      $destY.value = destY;

      var distH = destX - x;
      var distV = destY - y;
      var absDistH = Math.abs(distH);
      var absDistV = Math.abs(distV);
      var distTot = absDistH + absDistV;

      $distH.textContent = absDistH;
      $distV.textContent = absDistV;
      $distTot.textContent = distTot;

      var days = Math.ceil(distTot / (engine * FREE_FUEL));
      $trip.textContent = days + (days >= 2 ? "\xA0jours" : "\xA0jour");

      var angle = Math.atan(distV / distH);
      if (distH < 0) angle += π;

      animateAngleChange(angle, $cape, paintStyles);
    }
  ];
}());

// [@ANI] Animation ////////////////////////////////////////////////////

var animateAngleChange = (function () {
  var previousAngle = 0;
  var currentAngle = 0;
  var reqId;

  return function animateAngleChange(newAngle, $cvs, paintStyles) {
    cancelAnimationFrame(reqId);
    previousAngle = currentAngle || 0;

    var angleDiff = newAngle - previousAngle;
    if (Math.abs(angleDiff) > π) {
      angleDiff -= Math.sign(angleDiff) * 2*π;
    }

    var firstFrameTime = Date.now();
    var lastFrameTime = Date.now();

    var ease = easeOutCubic;

    (function drawNextFrame() {
      var now = Date.now();

      var t = now - firstFrameTime;
      if (t <= ANIM_DURATION) {
        reqId = requestAnimationFrame(drawNextFrame);
      }

      // prevents “wormhole” effect
      if (now - lastFrameTime > WORMHOLE_THRESHOLD) return;
      lastFrameTime = now;

      var angle = previousAngle + ease(t/ANIM_DURATION) * angleDiff;
      currentAngle = angle;
      draw($cvs, angle, paintStyles);
    }());
  }
}());

function easeOutCubic(x) {
  if (x < 0) return 0;
  if (x > 1) return 1;
  var p = x - 1;
  return p*p*p + 1;
}

function draw($cvs, θ, paintStyles) {
  var cx = $cvs.getContext("2d");
  var w = $cvs.width;
  var h = $cvs.height;
  var r = Math.min(w, h) / 2;

  var cos = Math.cos.bind(Math);
  var sin = Math.sin.bind(Math);

  cx.clearRect(-w/2, -h/2, w, h);

  // draws the background grid
  cx.fillStyle   = paintStyles["#cape .grid"].backgroundColor;
  cx.fillRect(-w/2, -h/2, w, h);
  cx.strokeStyle = paintStyles["#cape .grid"].color;
  cx.lineWidth = 1;
  var gridOffsetX = Math.round(20 * Math.SQRT1_2 * cos(θ)) - 0.5;
  var gridOffsetY = Math.round(20 * Math.SQRT1_2 * sin(θ)) - 0.5;
  cx.beginPath();
  for (var gridX = -w/2 - gridOffsetX; gridX < w/2; gridX += 20) {
    cx.moveTo(gridX, -h/2);
    cx.lineTo(gridX, +h/2);
  }
  for (var gridY = -h/2 - gridOffsetY; gridY < h/2; gridY += 20) {
    cx.moveTo(-w/2, gridY);
    cx.lineTo(+w/2, gridY);
  }
  cx.stroke();

  var ρ = w*2;
  var a = Math.round(ρ*0.9 * cos(θ+π)) - 0.5;
  var b = Math.round(ρ*0.9 * sin(θ+π)) - 0.5;

  // draws the vernier-like scale
  var scale = 1/60;
  var dash = 2*π * ρ * scale;
  cx.setLineDash([1, dash-1]);
  cx.lineWidth = 20;
  cx.strokeStyle = paintStyles["#cape .vernier"].color;
  cx.beginPath();
  cx.arc(a, b, ρ, θ*3, θ*3 + 2*π);
  cx.stroke();

  // draws the compass scale
  cx.lineCap = "butt";
  cx.strokeStyle = paintStyles["#cape .compass"].color;

  var compassRadius = r-5;
  dash = 2*π * compassRadius / 16;
  cx.lineWidth = 5;
  cx.setLineDash([1, dash-1]);
  cx.lineDashOffset = 0.5;
  cx.beginPath();
  cx.arc(-0.5, -0.5, compassRadius, 0, 2*π);
  cx.stroke();

  compassRadius = r-8;
  dash = 2*π * compassRadius / 4;
  cx.lineWidth = 8;
  cx.setLineDash([1, dash-1]);
  cx.beginPath();
  cx.arc(-0.5, -0.5, compassRadius, 0, 2*π);
  cx.stroke();

  cx.lineDashOffset = 0;
  cx.setLineDash([]);

  // traçage de la flèche
  /*                      H
     F____________________|\
      \                   G \
       \E                    \A
       /                     /
      /___________________C /
     D                    |/
                          B
  */
  var arrowRadius = r-4;
  cx.lineWidth = 1.5;
  cx.beginPath();
  cx.moveTo(-0.5 + Math.round(arrowRadius * cos(θ)),
            -0.5 + Math.round(arrowRadius * sin(θ))); // A
  cx.lineTo(-0.5 + Math.round(0.5*arrowRadius * cos(0.6 + θ)),
            -0.5 + Math.round(0.5*arrowRadius * sin(0.6 + θ))); // B
  cx.lineTo(-0.5 + Math.round(0.5*arrowRadius * cos(0.09 + θ)),
            -0.5 + Math.round(0.5*arrowRadius * sin(0.09 + θ))); // C
  cx.lineTo(-0.5 + Math.round(0.96*arrowRadius * cos(π - 0.15 + θ)),
            -0.5 + Math.round(0.96*arrowRadius * sin(π - 0.15 + θ))); // D
  cx.lineTo(-0.5 + Math.round(0.9*arrowRadius * cos(π + θ)),
            -0.5 + Math.round(0.9*arrowRadius * sin(π + θ))); // E
  cx.lineTo(-0.5 + Math.round(0.96*arrowRadius * cos(π + 0.15 + θ)),
            -0.5 + Math.round(0.96*arrowRadius * sin(π + 0.15 + θ))); // F
  cx.lineTo(-0.5 + Math.round(0.5*arrowRadius * cos(-0.09 + θ)),
            -0.5 + Math.round(0.5*arrowRadius * sin(-0.09 + θ))); // G
  cx.lineTo(-0.5 + Math.round(0.5*arrowRadius * cos(-0.6 + θ)),
            -0.5 + Math.round(0.5*arrowRadius * sin(-0.6 + θ))); // H
  cx.closePath();
  cx.strokeStyle = paintStyles["#cape .arrow"].color;
  cx.fillStyle   = paintStyles["#cape .arrow"].backgroundColor;
  cx.stroke();
  cx.fill();
}

// [@UTI] Utils ////////////////////////////////////////////////////////

function proxifyFunction(targetScope, funcName, action) {
  targetScope = targetScope.wrappedJSObject || targetScope;

  var desc = Object.getOwnPropertyDescriptor(targetScope, funcName);
  if (!desc.configurable) {
    throw new Error("Cannot proxify non configurable function");
  }

  var backup = exportFunction(targetScope[funcName],
                              targetScope,
                              { allowCrossOriginArguments: true });
  exportFunction(function () {
    if (action) action.apply(this, arguments);
    return backup.apply(this, arguments);
  }, targetScope, { defineAs: funcName });
}