Pardus navigation highlight a.k.a. Paze

Shows the route your ship will fly.

目前為 2024-12-08 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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