Pardus navigation highlight a.k.a. Paze

Shows the route your ship will fly.

当前为 2024-12-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Pardus navigation highlight a.k.a. Paze
// @namespace    leaumar
// @version      3
// @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 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 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, .${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: () => document.head.appendChild(style),
	};
})();

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;

	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"));
});