// ==UserScript==
// @name Pardus navigation highlight a.k.a. Paze
// @namespace leaumar
// @version 4
// @description Shows the route your ship will fly.
// @author [email protected]
// @match https://*.pardus.at/main.php
// @icon https://icons.duckduckgo.com/ip2/pardus.at.ico
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ramda.min.js
// @grant unsafeWindow
// @grant GM_addStyle
// @license MPL-2.0
// ==/UserScript==
// convention: nav grid is width * height tiles with origin 0,0 top-left, just like pixels in css
// a point is an {x, y} coordinate
// a tile is a <td> and a point
const classes = {
map: {
// set by the game
impassable: "navImpassable", // i.e. solid energy
npc: "navNpc",
nodata: "navNoData",
tile: "paze-tile", // every tile in the grid
passable: "paze-passable", // all clickable tiles
},
route: {
reachable: "paze-reachable", // a route can be flown to this destination
unreachable: "paze-unreachable", // this destination has no route to it
step: "paze-step", // ship will fly through here
deadEnd: "paze-dead-end", // route calculation gets stuck here, or runs into a monster
},
};
const style = (() => {
const css = `
#navareatransition img[class^=nf] {
/*
for some reason these tiles have position:relative but they aren't offset
this obscures the outline on the parent td
unsetting the position does not break the flying animation
*/
position: unset !important;
}
.${classes.map.tile}.${classes.map.impassable} img, .${classes.map.tile}.${classes.map.nodata} img {
cursor: not-allowed !important;
}
.${classes.map.tile}.${classes.map.passable} {
/* outline doesn't affect box sizing nor image scaling, unlike border */
outline: 1px dotted #fff3;
outline-offset: -1px;
}
.${classes.map.tile}.${classes.route.unreachable} img {
cursor: no-drop !important;
}
.${classes.map.tile}.${classes.route.step} {
outline-color: yellow;
}
.${classes.map.tile}.${classes.route.reachable} {
outline: 1px solid green;
}
.${classes.map.tile}.${classes.route.deadEnd} {
outline: 1px solid red;
}
`;
return {
attach: () => {
GM_addStyle(css);
}
};
})();
const makeMap = (() => {
// url(//static.pardus.at/img/stdhq/96/backgrounds/space3.png)
const bgRegex = /\/backgrounds\/([a-z_]+)(\d+)\.png/;
function getTileType(td) {
const bg = td.style.backgroundImage;
const imageUrl =
bg == null || bg === "" ? td.getElementsByTagName("img")[0].src : bg;
const [match, name, number] =
bgRegex.exec(imageUrl) ??
(() => {
throw new Error("unexpected missing background", { cause: td });
})();
// TODO is this correct?
return name === "viral_cloud"
? parseInt(number) < 23
? "space"
: "energy"
: name;
}
// -----
const baseExitCost = {
asteroids: 24,
energy: 19,
exotic_matter: 35,
nebula: 15,
space: 10,
};
let realExitCost = null;
// TODO advanced skills, flux capacitors, stim chips...
function getExitCosts(centerTileType) {
if (realExitCost != null) {
return realExitCost;
}
const currentCost = parseInt(
document.getElementById("tdStatusMove").textContent.trim()
);
const efficiency = baseExitCost[centerTileType] - currentCost;
return (realExitCost = R.map(
(baseCost) => baseCost - efficiency,
baseExitCost
));
}
// -----
return (navArea) => {
const height = navArea.getElementsByTagName("tr").length;
const tds = [...navArea.getElementsByTagName("td")];
const width = tds.length / height;
const size = tds[0].getBoundingClientRect().width;
console.info(
`found a ${width}x${height} ${size}px nav grid with ${tds.length} tiles`
);
const centerTd = tds[Math.floor(tds.length / 2)];
const centerTileType = getTileType(centerTd);
const exitCosts = getExitCosts(centerTileType);
const tiles = tds.map((td, i) => {
const x = i % width;
const y = Math.floor(i / width);
const equalsPoint = (point) => point.x === x && point.y === y;
const acceptsTraffic =
!td.classList.contains(classes.map.impassable) &&
!td.classList.contains(classes.map.nodata);
const trafficProperties = acceptsTraffic
? (() => {
const type = getTileType(td);
return {
isMonster: td.classList.contains(classes.map.npc),
type,
exitCost: exitCosts[type],
};
})()
: null;
return { td, x, y, equalsPoint, acceptsTraffic, ...trafficProperties };
});
function findTileOfTd(td) {
return tiles.find((tile) => tile.td.id === td.id);
}
function isInBounds({ x, y }) {
return x < width && y < height;
}
function findTileAt(point) {
return isInBounds(point) ? tiles[point.y * width + point.x] : null;
}
function getCenterTile() {
return tiles[Math.floor(tiles.length / 2)];
}
return {
height,
tiles,
width,
size,
findTileOfTd,
findTileAt,
getCenterTile,
};
};
})();
const navigation = (() => {
const stuck = "stuck";
// diagonal first, then straight line
// obstacle avoidance: try left+right or up+down neighboring tiles
// vertical movement first if diagonally sidestepping, down/right first if orthogonally
function* pardusWayfind(destinationTile, map) {
let currentTile = map.getCenterTile();
yield currentTile;
while (!destinationTile.equalsPoint(currentTile)) {
if (currentTile.isMonster) {
yield stuck;
return;
}
const delta = {
x: destinationTile.x - currentTile.x,
y: destinationTile.y - currentTile.y,
};
// if delta is 0, sign is 0 so no move
const nextMovePoint = {
x: currentTile.x + 1 * Math.sign(delta.x),
y: currentTile.y + 1 * Math.sign(delta.y),
};
const nextMoveTile = map.findTileAt(nextMovePoint);
if (nextMoveTile != null && nextMoveTile.acceptsTraffic) {
yield (currentTile = nextMoveTile);
continue;
}
const isHorizontal = delta.x !== 0;
const isVertical = delta.y !== 0;
const sidestepPoints = (() => {
if (isHorizontal && isVertical) {
return [
{
x: currentTile.x,
y: nextMovePoint.y,
},
{
x: nextMovePoint.x,
y: currentTile.y,
},
];
}
if (isHorizontal) {
return [
{
x: nextMovePoint.x,
y: currentTile.y + 1,
},
{
x: nextMovePoint.x,
y: currentTile.y - 1,
},
];
}
if (isVertical) {
return [
{
x: currentTile.x + 1,
y: nextMovePoint.y,
},
{
x: currentTile.x - 1,
y: nextMovePoint.y,
},
];
}
})();
const sidestepTile = sidestepPoints
.map((point) => map.findTileAt(point))
.find((tile) => tile != null && tile.acceptsTraffic);
if (sidestepTile == null) {
// autopilot failure
yield stuck;
return;
}
yield (currentTile = sidestepTile);
}
}
function wayfind(destinationTile, map) {
const tiles = [...pardusWayfind(destinationTile, map)];
const isStuck = tiles.at(-1) === stuck;
return {
tiles: isStuck ? tiles.slice(0, -1) : tiles,
stuck: isStuck,
};
}
return {
wayfind,
};
})();
function makeCanvas(map) {
const canvas = document.createElement("canvas");
Object.assign(canvas, {
width: map.size * map.width,
height: map.size * map.height,
});
Object.assign(canvas.style, {
position: "absolute",
top: "0",
left: "0",
pointerEvents: "none",
});
const ctx = canvas.getContext("2d");
function drawDashes(tiles, { gap, width, length, color }) {
ctx.setLineDash([length, gap]);
ctx.lineWidth = width;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo((map.width * map.size) / 2, (map.height * map.size) / 2);
tiles.forEach(({ x, y }) => {
ctx.lineTo(x * map.size + map.size / 2, y * map.size + map.size / 2);
});
ctx.stroke();
}
function drawCosts(tiles) {
ctx.fillStyle = "white";
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.font = "12px sans-serif";
tiles.slice(1).forEach((tile, i) => {
const previousTile = tiles[i];
const exitCost = `${previousTile.exitCost}`;
const x = tile.x * map.size + 10;
const y = tile.y * map.size + 16;
ctx.strokeText(exitCost, x, y);
ctx.fillText(exitCost, x, y);
});
}
function drawRectangle(tile, color) {
ctx.fillStyle = color;
ctx.fillRect(tile.x * map.size, tile.y * map.size, map.size, map.size);
}
return {
appendTo: (parent) => {
const { position } = window.getComputedStyle(parent);
switch (position) {
// partial refresh is off
case "static": {
parent.style.position = "relative";
break;
}
// partial refresh is on
case "absolute":
break;
// mods?
default: {
throw new Error("unexpected parent position", { cause: parent });
}
}
parent.appendChild(canvas);
},
clear: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
},
plot: (way, destinationTile) => {
drawRectangle(destinationTile, way.stuck ? "#f001" : "#fff1");
// they start at the same pixel so the outline is actually misaligned by 1 border width
drawDashes(way.tiles, {
gap: 6,
width: 4,
length: 9,
color: "black",
});
drawDashes(way.tiles, {
gap: 7,
width: 2,
length: 8,
color: way.stuck ? "red" : "green",
});
drawCosts(way.tiles);
},
};
}
const makeGrid = (() => {
const routeClassNames = R.values(classes.route);
return (map) => {
function show() {
map.tiles.forEach((tile) => {
tile.td.classList.add(classes.map.tile);
if (tile.acceptsTraffic) {
tile.td.classList.add(classes.map.passable);
}
});
}
function erase() {
map.tiles.forEach((tile) => {
tile.td.classList.remove(...routeClassNames);
});
}
function plot(way, destinationTile) {
way.tiles.slice(1).forEach((tile) => {
tile.td.classList.add(classes.route.step);
});
destinationTile.td.classList.add(
way.stuck ? classes.route.unreachable : classes.route.reachable
);
if (way.stuck) {
const stuckTile = way.tiles.at(-1);
stuckTile.td.classList.add(classes.route.deadEnd);
}
}
return {
erase,
plot,
show,
};
};
})();
function gps(navArea) {
const map = makeMap(navArea);
const canvas = makeCanvas(map);
canvas.appendTo(navArea.parentNode);
const grid = makeGrid(map);
grid.show();
function clearRoute() {
grid.erase();
canvas.clear();
}
function showRoute(event) {
if (event.target.tagName === "TD") {
const destinationTile = map.findTileOfTd(event.target);
if (destinationTile == null) {
throw new Error("unexpected tile", { cause: event.target });
}
clearRoute();
if (
destinationTile.acceptsTraffic &&
!map.getCenterTile().equalsPoint(destinationTile)
) {
const way = navigation.wayfind(destinationTile, map);
// TODO hint at more efficient route?
canvas.plot(way, destinationTile);
grid.plot(way, destinationTile);
}
}
}
// capture phase is required: https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseenter_event#behavior_of_mouseenter_events
navArea.addEventListener("mouseenter", showRoute, true);
navArea.addEventListener("mouseleave", clearRoute);
return function cleanUp() {
clearRoute();
navArea.removeEventListener("mouseenter", showRoute, true);
navArea.removeEventListener("mouseleave", clearRoute);
};
}
style.attach();
let cleanUp = gps(document.getElementById("navarea"));
unsafeWindow.addUserFunction(() => {
cleanUp();
// TODO if the mouse moves during flight animation, the appropriate route isn't shown until mousing over another tile
cleanUp = gps(document.getElementById("navareatransition"));
});