Pardus navigation highlight a.k.a. Paze

Shows the route your ship will fly.

  1. // ==UserScript==
  2. // @name Pardus navigation highlight a.k.a. Paze
  3. // @namespace leaumar
  4. // @version 4
  5. // @description Shows the route your ship will fly.
  6. // @author leaumar@sent.com
  7. // @match https://*.pardus.at/main.php
  8. // @icon https://icons.duckduckgo.com/ip2/pardus.at.ico
  9. // @require https://cdn.jsdelivr.net/npm/ramda@0.30.1/dist/ramda.min.js
  10. // @grant unsafeWindow
  11. // @grant GM_addStyle
  12. // @license MPL-2.0
  13. // ==/UserScript==
  14.  
  15. // convention: nav grid is width * height tiles with origin 0,0 top-left, just like pixels in css
  16. // a point is an {x, y} coordinate
  17. // a tile is a <td> and a point
  18.  
  19. const classes = {
  20. map: {
  21. // set by the game
  22. impassable: "navImpassable", // i.e. solid energy
  23. npc: "navNpc",
  24. nodata: "navNoData",
  25.  
  26. tile: "paze-tile", // every tile in the grid
  27. passable: "paze-passable", // all clickable tiles
  28. },
  29. route: {
  30. reachable: "paze-reachable", // a route can be flown to this destination
  31. unreachable: "paze-unreachable", // this destination has no route to it
  32. step: "paze-step", // ship will fly through here
  33. deadEnd: "paze-dead-end", // route calculation gets stuck here, or runs into a monster
  34. },
  35. };
  36.  
  37. const style = (() => {
  38. const css = `
  39. #navareatransition img[class^=nf] {
  40. /*
  41. for some reason these tiles have position:relative but they aren't offset
  42. this obscures the outline on the parent td
  43. unsetting the position does not break the flying animation
  44. */
  45. position: unset !important;
  46. }
  47. .${classes.map.tile}.${classes.map.impassable} img, .${classes.map.tile}.${classes.map.nodata} img {
  48. cursor: not-allowed !important;
  49. }
  50. .${classes.map.tile}.${classes.map.passable} {
  51. /* outline doesn't affect box sizing nor image scaling, unlike border */
  52. outline: 1px dotted #fff3;
  53. outline-offset: -1px;
  54. }
  55. .${classes.map.tile}.${classes.route.unreachable} img {
  56. cursor: no-drop !important;
  57. }
  58. .${classes.map.tile}.${classes.route.step} {
  59. outline-color: yellow;
  60. }
  61. .${classes.map.tile}.${classes.route.reachable} {
  62. outline: 1px solid green;
  63. }
  64. .${classes.map.tile}.${classes.route.deadEnd} {
  65. outline: 1px solid red;
  66. }
  67. `;
  68.  
  69. return {
  70. attach: () => {
  71. GM_addStyle(css);
  72. }
  73. };
  74. })();
  75.  
  76. const makeMap = (() => {
  77. // url(//static.pardus.at/img/stdhq/96/backgrounds/space3.png)
  78. const bgRegex = /\/backgrounds\/([a-z_]+)(\d+)\.png/;
  79.  
  80. function getTileType(td) {
  81. const bg = td.style.backgroundImage;
  82. const imageUrl =
  83. bg == null || bg === "" ? td.getElementsByTagName("img")[0].src : bg;
  84. const [match, name, number] =
  85. bgRegex.exec(imageUrl) ??
  86. (() => {
  87. throw new Error("unexpected missing background", { cause: td });
  88. })();
  89. // TODO is this correct?
  90. return name === "viral_cloud"
  91. ? parseInt(number) < 23
  92. ? "space"
  93. : "energy"
  94. : name;
  95. }
  96.  
  97. // -----
  98.  
  99. const baseExitCost = {
  100. asteroids: 24,
  101. energy: 19,
  102. exotic_matter: 35,
  103. nebula: 15,
  104. space: 10,
  105. };
  106. let realExitCost = null;
  107.  
  108. // TODO advanced skills, flux capacitors, stim chips...
  109. function getExitCosts(centerTileType) {
  110. if (realExitCost != null) {
  111. return realExitCost;
  112. }
  113.  
  114. const currentCost = parseInt(
  115. document.getElementById("tdStatusMove").textContent.trim()
  116. );
  117. const efficiency = baseExitCost[centerTileType] - currentCost;
  118. return (realExitCost = R.map(
  119. (baseCost) => baseCost - efficiency,
  120. baseExitCost
  121. ));
  122. }
  123.  
  124. // -----
  125.  
  126. return (navArea) => {
  127. const height = navArea.getElementsByTagName("tr").length;
  128. const tds = [...navArea.getElementsByTagName("td")];
  129. const width = tds.length / height;
  130. const size = tds[0].getBoundingClientRect().width;
  131.  
  132. console.info(
  133. `found a ${width}x${height} ${size}px nav grid with ${tds.length} tiles`
  134. );
  135.  
  136. const centerTd = tds[Math.floor(tds.length / 2)];
  137. const centerTileType = getTileType(centerTd);
  138. const exitCosts = getExitCosts(centerTileType);
  139.  
  140. const tiles = tds.map((td, i) => {
  141. const x = i % width;
  142. const y = Math.floor(i / width);
  143. const equalsPoint = (point) => point.x === x && point.y === y;
  144.  
  145. const acceptsTraffic =
  146. !td.classList.contains(classes.map.impassable) &&
  147. !td.classList.contains(classes.map.nodata);
  148.  
  149. const trafficProperties = acceptsTraffic
  150. ? (() => {
  151. const type = getTileType(td);
  152. return {
  153. isMonster: td.classList.contains(classes.map.npc),
  154. type,
  155. exitCost: exitCosts[type],
  156. };
  157. })()
  158. : null;
  159.  
  160. return { td, x, y, equalsPoint, acceptsTraffic, ...trafficProperties };
  161. });
  162.  
  163. function findTileOfTd(td) {
  164. return tiles.find((tile) => tile.td.id === td.id);
  165. }
  166.  
  167. function isInBounds({ x, y }) {
  168. return x < width && y < height;
  169. }
  170.  
  171. function findTileAt(point) {
  172. return isInBounds(point) ? tiles[point.y * width + point.x] : null;
  173. }
  174.  
  175. function getCenterTile() {
  176. return tiles[Math.floor(tiles.length / 2)];
  177. }
  178.  
  179. return {
  180. height,
  181. tiles,
  182. width,
  183. size,
  184. findTileOfTd,
  185. findTileAt,
  186. getCenterTile,
  187. };
  188. };
  189. })();
  190.  
  191. const navigation = (() => {
  192. const stuck = "stuck";
  193.  
  194. // diagonal first, then straight line
  195. // obstacle avoidance: try left+right or up+down neighboring tiles
  196. // vertical movement first if diagonally sidestepping, down/right first if orthogonally
  197. function* pardusWayfind(destinationTile, map) {
  198. let currentTile = map.getCenterTile();
  199.  
  200. yield currentTile;
  201.  
  202. while (!destinationTile.equalsPoint(currentTile)) {
  203. if (currentTile.isMonster) {
  204. yield stuck;
  205. return;
  206. }
  207.  
  208. const delta = {
  209. x: destinationTile.x - currentTile.x,
  210. y: destinationTile.y - currentTile.y,
  211. };
  212. // if delta is 0, sign is 0 so no move
  213. const nextMovePoint = {
  214. x: currentTile.x + 1 * Math.sign(delta.x),
  215. y: currentTile.y + 1 * Math.sign(delta.y),
  216. };
  217.  
  218. const nextMoveTile = map.findTileAt(nextMovePoint);
  219. if (nextMoveTile != null && nextMoveTile.acceptsTraffic) {
  220. yield (currentTile = nextMoveTile);
  221. continue;
  222. }
  223.  
  224. const isHorizontal = delta.x !== 0;
  225. const isVertical = delta.y !== 0;
  226.  
  227. const sidestepPoints = (() => {
  228. if (isHorizontal && isVertical) {
  229. return [
  230. {
  231. x: currentTile.x,
  232. y: nextMovePoint.y,
  233. },
  234. {
  235. x: nextMovePoint.x,
  236. y: currentTile.y,
  237. },
  238. ];
  239. }
  240.  
  241. if (isHorizontal) {
  242. return [
  243. {
  244. x: nextMovePoint.x,
  245. y: currentTile.y + 1,
  246. },
  247. {
  248. x: nextMovePoint.x,
  249. y: currentTile.y - 1,
  250. },
  251. ];
  252. }
  253.  
  254. if (isVertical) {
  255. return [
  256. {
  257. x: currentTile.x + 1,
  258. y: nextMovePoint.y,
  259. },
  260. {
  261. x: currentTile.x - 1,
  262. y: nextMovePoint.y,
  263. },
  264. ];
  265. }
  266. })();
  267.  
  268. const sidestepTile = sidestepPoints
  269. .map((point) => map.findTileAt(point))
  270. .find((tile) => tile != null && tile.acceptsTraffic);
  271.  
  272. if (sidestepTile == null) {
  273. // autopilot failure
  274. yield stuck;
  275. return;
  276. }
  277.  
  278. yield (currentTile = sidestepTile);
  279. }
  280. }
  281.  
  282. function wayfind(destinationTile, map) {
  283. const tiles = [...pardusWayfind(destinationTile, map)];
  284. const isStuck = tiles.at(-1) === stuck;
  285.  
  286. return {
  287. tiles: isStuck ? tiles.slice(0, -1) : tiles,
  288. stuck: isStuck,
  289. };
  290. }
  291.  
  292. return {
  293. wayfind,
  294. };
  295. })();
  296.  
  297. function makeCanvas(map) {
  298. const canvas = document.createElement("canvas");
  299. Object.assign(canvas, {
  300. width: map.size * map.width,
  301. height: map.size * map.height,
  302. });
  303. Object.assign(canvas.style, {
  304. position: "absolute",
  305. top: "0",
  306. left: "0",
  307. pointerEvents: "none",
  308. });
  309.  
  310. const ctx = canvas.getContext("2d");
  311.  
  312. function drawDashes(tiles, { gap, width, length, color }) {
  313. ctx.setLineDash([length, gap]);
  314. ctx.lineWidth = width;
  315. ctx.strokeStyle = color;
  316. ctx.beginPath();
  317. ctx.moveTo((map.width * map.size) / 2, (map.height * map.size) / 2);
  318. tiles.forEach(({ x, y }) => {
  319. ctx.lineTo(x * map.size + map.size / 2, y * map.size + map.size / 2);
  320. });
  321. ctx.stroke();
  322. }
  323.  
  324. function drawCosts(tiles) {
  325. ctx.fillStyle = "white";
  326. ctx.strokeStyle = "black";
  327. ctx.lineWidth = 2;
  328. ctx.font = "12px sans-serif";
  329.  
  330. tiles.slice(1).forEach((tile, i) => {
  331. const previousTile = tiles[i];
  332. const exitCost = `${previousTile.exitCost}`;
  333. const x = tile.x * map.size + 10;
  334. const y = tile.y * map.size + 16;
  335.  
  336. ctx.strokeText(exitCost, x, y);
  337. ctx.fillText(exitCost, x, y);
  338. });
  339. }
  340.  
  341. function drawRectangle(tile, color) {
  342. ctx.fillStyle = color;
  343. ctx.fillRect(tile.x * map.size, tile.y * map.size, map.size, map.size);
  344. }
  345.  
  346. return {
  347. appendTo: (parent) => {
  348. const { position } = window.getComputedStyle(parent);
  349.  
  350. switch (position) {
  351. // partial refresh is off
  352. case "static": {
  353. parent.style.position = "relative";
  354. break;
  355. }
  356.  
  357. // partial refresh is on
  358. case "absolute":
  359. break;
  360.  
  361. // mods?
  362. default: {
  363. throw new Error("unexpected parent position", { cause: parent });
  364. }
  365. }
  366.  
  367. parent.appendChild(canvas);
  368. },
  369. clear: () => {
  370. ctx.clearRect(0, 0, canvas.width, canvas.height);
  371. },
  372. plot: (way, destinationTile) => {
  373. drawRectangle(destinationTile, way.stuck ? "#f001" : "#fff1");
  374.  
  375. // they start at the same pixel so the outline is actually misaligned by 1 border width
  376. drawDashes(way.tiles, {
  377. gap: 6,
  378. width: 4,
  379. length: 9,
  380. color: "black",
  381. });
  382. drawDashes(way.tiles, {
  383. gap: 7,
  384. width: 2,
  385. length: 8,
  386. color: way.stuck ? "red" : "green",
  387. });
  388.  
  389. drawCosts(way.tiles);
  390. },
  391. };
  392. }
  393.  
  394. const makeGrid = (() => {
  395. const routeClassNames = R.values(classes.route);
  396.  
  397. return (map) => {
  398. function show() {
  399. map.tiles.forEach((tile) => {
  400. tile.td.classList.add(classes.map.tile);
  401. if (tile.acceptsTraffic) {
  402. tile.td.classList.add(classes.map.passable);
  403. }
  404. });
  405. }
  406.  
  407. function erase() {
  408. map.tiles.forEach((tile) => {
  409. tile.td.classList.remove(...routeClassNames);
  410. });
  411. }
  412.  
  413. function plot(way, destinationTile) {
  414. way.tiles.slice(1).forEach((tile) => {
  415. tile.td.classList.add(classes.route.step);
  416. });
  417.  
  418. destinationTile.td.classList.add(
  419. way.stuck ? classes.route.unreachable : classes.route.reachable
  420. );
  421.  
  422. if (way.stuck) {
  423. const stuckTile = way.tiles.at(-1);
  424. stuckTile.td.classList.add(classes.route.deadEnd);
  425. }
  426. }
  427.  
  428. return {
  429. erase,
  430. plot,
  431. show,
  432. };
  433. };
  434. })();
  435.  
  436. function gps(navArea) {
  437. const map = makeMap(navArea);
  438. const canvas = makeCanvas(map);
  439. canvas.appendTo(navArea.parentNode);
  440. const grid = makeGrid(map);
  441. grid.show();
  442.  
  443. function clearRoute() {
  444. grid.erase();
  445. canvas.clear();
  446. }
  447.  
  448. function showRoute(event) {
  449. if (event.target.tagName === "TD") {
  450. const destinationTile = map.findTileOfTd(event.target);
  451.  
  452. if (destinationTile == null) {
  453. throw new Error("unexpected tile", { cause: event.target });
  454. }
  455.  
  456. clearRoute();
  457.  
  458. if (
  459. destinationTile.acceptsTraffic &&
  460. !map.getCenterTile().equalsPoint(destinationTile)
  461. ) {
  462. const way = navigation.wayfind(destinationTile, map);
  463.  
  464. // TODO hint at more efficient route?
  465.  
  466. canvas.plot(way, destinationTile);
  467. grid.plot(way, destinationTile);
  468. }
  469. }
  470. }
  471.  
  472. // capture phase is required: https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseenter_event#behavior_of_mouseenter_events
  473. navArea.addEventListener("mouseenter", showRoute, true);
  474. navArea.addEventListener("mouseleave", clearRoute);
  475.  
  476. return function cleanUp() {
  477. clearRoute();
  478. navArea.removeEventListener("mouseenter", showRoute, true);
  479. navArea.removeEventListener("mouseleave", clearRoute);
  480. };
  481. }
  482.  
  483. style.attach();
  484.  
  485. let cleanUp = gps(document.getElementById("navarea"));
  486.  
  487. unsafeWindow.addUserFunction(() => {
  488. cleanUp();
  489. // TODO if the mouse moves during flight animation, the appropriate route isn't shown until mousing over another tile
  490. cleanUp = gps(document.getElementById("navareatransition"));
  491. });