// ==UserScript==
// @name Pardus navigation highlight a.k.a. Paze
// @namespace leaumar
// @version 1
// @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
// @license MPL-2.0
// ==/UserScript==
// convention: nav grid is width * height tiles with origin 0,0 top-left, just like pixels in css
// a tile is a <td>
// a point is an {x, y} coordinate
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 style = document.createElement("style");
style.textContent = `
#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 {
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: () => document.head.appendChild(style),
};
})();
function makeMap(navArea) {
const height = navArea.getElementsByTagName("tr").length;
const tiles = [...navArea.getElementsByTagName("td")];
const width = tiles.length / height;
const size = tiles[0].getBoundingClientRect().width;
console.info(
`found a ${width}x${height} ${size}px nav grid with ${tiles.length} tiles`
);
function findCoordinateOfTile(tile) {
const index = R.findIndex((it) => it.id === tile.id, tiles);
return index === -1
? null
: {
x: index % width,
y: Math.floor(index / width),
};
}
function isInBounds({ x, y }) {
return x < width && y < height;
}
function findTileByCoordinate(point) {
return isInBounds(point) ? tiles[point.y * width + point.x] : null;
}
function acceptsTraffic(tile) {
return (
!tile.classList.contains(classes.map.impassable) &&
!tile.classList.contains(classes.map.nodata)
);
}
function isMonster(tile) {
return tile.classList.contains(classes.map.npc);
}
function showGrid() {
tiles.forEach((tile) => {
tile.classList.add(classes.map.tile);
if (acceptsTraffic(tile)) {
tile.classList.add(classes.map.passable);
}
});
}
function getCenterPoint() {
return {
x: Math.floor(width / 2),
y: Math.floor(height / 2),
};
}
const getTileType = (() => {
// url(//static.pardus.at/img/stdhq/96/backgrounds/space3.png)
const bgRegex = /\/backgrounds\/([a-z_]+)(\d+)\.png/;
return (tile) => {
const tileBg = tile.style.backgroundImage;
const imageUrl =
tileBg == null || tileBg === ""
? tile.getElementsByTagName("img")[0].src
: tileBg;
const [match, name, number] = bgRegex.exec(imageUrl);
// TODO is this correct?
return name === "viral_cloud"
? parseInt(number) < 23
? "space"
: "energy"
: name;
};
})();
return {
height,
tiles,
width,
size,
findCoordinateOfTile,
findTileByCoordinate,
showGrid,
acceptsTraffic,
getCenterPoint,
getTileType,
isMonster,
};
}
const navigation = (() => {
// 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(destinationPoint, map) {
let position = map.getCenterPoint();
yield position;
while (
position.x !== destinationPoint.x ||
position.y !== destinationPoint.y
) {
if (map.isMonster(map.findTileByCoordinate(position))) {
yield "stuck";
return;
}
const delta = {
x: destinationPoint.x - position.x,
y: destinationPoint.y - position.y,
};
// if delta is 0, sign is 0 so no move
const nextMovePoint = {
x: position.x + 1 * Math.sign(delta.x),
y: position.y + 1 * Math.sign(delta.y),
};
const nextMoveTile = map.findTileByCoordinate(nextMovePoint);
if (nextMoveTile != null && map.acceptsTraffic(nextMoveTile)) {
yield (position = nextMovePoint);
continue;
}
const isHorizontal = delta.x !== 0;
const isVertical = delta.y !== 0;
const sidestepPoints = (() => {
if (isHorizontal && isVertical) {
return [
{
x: position.x,
y: nextMovePoint.y,
},
{
x: nextMovePoint.x,
y: position.y,
},
];
}
if (isHorizontal) {
return [
{
x: nextMovePoint.x,
y: position.y + 1,
},
{
x: nextMovePoint.x,
y: position.y - 1,
},
];
}
if (isVertical) {
return [
{
x: position.x + 1,
y: nextMovePoint.y,
},
{
x: position.x - 1,
y: nextMovePoint.y,
},
];
}
})();
const sidestepPoint = sidestepPoints.find((point) => {
const sidestepTile = map.findTileByCoordinate(point);
return sidestepTile != null && map.acceptsTraffic(sidestepTile);
});
if (sidestepPoint == null) {
// autopilot failure
yield "stuck";
return;
}
yield (position = sidestepPoint);
}
}
function wayfind(destinationPoint, map) {
const points = [...pardusWayfind(destinationPoint, map)];
const stuck = points.at(-1) === "stuck";
return {
points: stuck ? points.slice(0, -1) : points,
stuck,
};
}
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(points, { 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);
points.forEach(({ x, y }) => {
ctx.lineTo(x * map.size + map.size / 2, y * map.size + map.size / 2);
});
ctx.stroke();
}
function drawCosts(points, movementCost) {
ctx.fillStyle = "white";
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.font = "12px sans-serif";
points.slice(1).forEach((point, i) => {
const previousTile = map.findTileByCoordinate(points[i]);
const apCost = `${movementCost.getCostOfMovingFrom(previousTile)}`;
const x = point.x * map.size + 10;
const y = point.y * map.size + 16;
ctx.strokeText(apCost, x, y);
ctx.fillText(apCost, x, y);
});
}
function drawRectangle(point, color) {
ctx.fillStyle = color;
ctx.fillRect(point.x * map.size, point.y * map.size, map.size, map.size);
}
return {
appendTo: (positionedParent) => {
positionedParent.appendChild(canvas);
},
clear: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
},
plot: (way, destinationPoint, movementCost) => {
drawRectangle(destinationPoint, way.stuck ? "#f001" : "#fff1");
// they start at the same pixel so the outline is actually misaligned by 1 border width
drawDashes(way.points, { gap: 6, width: 4, length: 9, color: "black" });
drawDashes(way.points, {
gap: 7,
width: 2,
length: 8,
color: way.stuck ? "red" : "green",
});
drawCosts(way.points, movementCost);
},
};
}
function makeRoute(map) {
const routeClassNames = R.values(classes.route);
function erase() {
map.tiles.forEach((tile) => {
tile.classList.remove(...routeClassNames);
});
}
function plot(way, destinationTile) {
way.points.slice(1).forEach((stepPoint) => {
const stepTile = map.findTileByCoordinate(stepPoint);
stepTile.classList.add(classes.route.step);
});
destinationTile.classList.add(
way.stuck ? classes.route.unreachable : classes.route.reachable
);
if (way.stuck) {
const stuckTile = map.findTileByCoordinate(way.points.at(-1));
stuckTile.classList.add(classes.route.deadEnd);
}
}
return {
erase,
plot,
};
}
const makeMovementCost = (() => {
const baseCost = {
asteroids: 24,
energy: 19,
exotic_matter: 35,
nebula: 15,
space: 10,
};
let calculatedMovementCost = null;
return (map) => {
const effectiveCost =
calculatedMovementCost ??
(calculatedMovementCost = (() => {
const currentCost = parseInt(
document.getElementById("tdStatusMove").textContent.trim()
);
const currentType = map.getTileType(
map.findTileByCoordinate(map.getCenterPoint())
);
const efficiency = baseCost[currentType] - currentCost;
return R.map((cost) => cost - efficiency, baseCost);
})());
function getCostOfMovingFrom(tile) {
return effectiveCost[map.getTileType(tile)];
}
return {
getCostOfMovingFrom,
};
};
})();
function gps(navArea) {
const map = makeMap(navArea);
map.showGrid();
const canvas = makeCanvas(map);
canvas.appendTo(navArea.parentNode);
const route = makeRoute(map);
const movementCost = makeMovementCost(map);
function clearRoute() {
route.erase();
canvas.clear();
}
function showRoute(event) {
if (event.target.tagName === "TD") {
const destinationTile = event.target;
clearRoute();
if (map.acceptsTraffic(destinationTile)) {
const destinationPoint = map.findCoordinateOfTile(destinationTile);
if (destinationPoint == null) {
throw new Error("unexpected tile", destinationTile, destinationPoint);
}
if (R.equals(destinationPoint, map.getCenterPoint())) {
return;
}
const way = navigation.wayfind(destinationPoint, map);
// TODO hint at more efficient route
canvas.plot(way, destinationPoint, movementCost);
route.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 route to tile under mouse after flight should be shown without needing mouseenter event
cleanUp = gps(document.getElementById("navareatransition"));
});