Pardus navigation highlight a.k.a. Paze

Shows the route your ship will fly.

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

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址