128 lines
5.8 KiB
JavaScript
128 lines
5.8 KiB
JavaScript
import { chunkForWorld, chunkName, threeToWorld } from "../world/coords.js";
|
||
|
||
/**
|
||
* Reine Kartenmathematik für die Minimap (docs/superpowers/specs/2026-09-13-minimap-design.md).
|
||
* Ohne DOM, damit vitest sie ohne Browser prüfen kann. `info` hat die Form von map.json.
|
||
*
|
||
* Koordinatensysteme:
|
||
* - three (x, z): Szene; Norden ist −z.
|
||
* - Welt (X, Y): X = x, Y = −z.
|
||
* - Karte (px, py): Pixel in map.png, Ursprung Nordwest, py wächst nach Süden.
|
||
* - Maske: Pixel in map-walkable.png, gleiche Ausrichtung, anderer Maßstab.
|
||
* - Bildschirm (sx, sy): Canvas-Pixel; Ansicht = { scale, offsetX, offsetY, minScale }.
|
||
*/
|
||
|
||
export const CLICK_TOLERANCE_PX = 4;
|
||
export const MAX_ZOOM = 4;
|
||
export const LABEL_MIN_CHUNK_PX = 40;
|
||
export const LANDING_RADIUS_M = 20;
|
||
|
||
/** three (x, z) -> Kartenpixel. Die einzige Stelle der Achsenumkehr zwischen three-z und Karten-y. */
|
||
export function worldToMap(info, x, z) {
|
||
const { X, Y } = threeToWorld(x, z);
|
||
return { px: (X - info.slice.x0) * info.scale, py: (info.slice.y1 - Y) * info.scale };
|
||
}
|
||
|
||
/** Kartenpixel -> three (x, z). */
|
||
export function mapToWorld(info, px, py) {
|
||
const X = info.slice.x0 + px / info.scale;
|
||
const Y = info.slice.y1 - py / info.scale;
|
||
return { x: X, z: -Y };
|
||
}
|
||
|
||
/** Ansicht, in der die ganze quadratische Karte zentriert ins Fenster passt. */
|
||
export function fitView(mapSize, viewW, viewH) {
|
||
const scale = Math.min(viewW / mapSize, viewH / mapSize);
|
||
return { scale, offsetX: (viewW - mapSize * scale) / 2, offsetY: (viewH - mapSize * scale) / 2, minScale: scale };
|
||
}
|
||
|
||
/** Zoomt um den Bildschirmpunkt (sx, sy); begrenzt auf den ganzen Ausschnitt bis MAX_ZOOM. */
|
||
export function zoomAt(view, factor, sx, sy) {
|
||
const scale = Math.min(view.minScale * MAX_ZOOM, Math.max(view.minScale, view.scale * factor));
|
||
const k = scale / view.scale;
|
||
return { ...view, scale, offsetX: sx - (sx - view.offsetX) * k, offsetY: sy - (sy - view.offsetY) * k };
|
||
}
|
||
|
||
/** Kleinere Karte zentrieren, größere nie über ihren Rand hinaus verschieben. */
|
||
export function clampView(view, mapSize, viewW, viewH) {
|
||
const content = mapSize * view.scale;
|
||
const axis = (offset, viewport) =>
|
||
content <= viewport ? (viewport - content) / 2 : Math.min(0, Math.max(viewport - content, offset));
|
||
return { ...view, offsetX: axis(view.offsetX, viewW), offsetY: axis(view.offsetY, viewH) };
|
||
}
|
||
|
||
export const screenToMap = (view, sx, sy) => ({ px: (sx - view.offsetX) / view.scale, py: (sy - view.offsetY) / view.scale });
|
||
export const mapToScreen = (view, px, py) => ({ sx: px * view.scale + view.offsetX, sy: py * view.scale + view.offsetY });
|
||
|
||
/** Kurzer Klick statt Ziehen: Bewegung zwischen Drücken und Loslassen unter der Toleranz. */
|
||
export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX) {
|
||
return Math.hypot(up.x - down.x, up.y - down.y) < tolerance;
|
||
}
|
||
|
||
/**
|
||
* Nächster begehbarer Maskenpixel (Wert 255) im Umkreis radiusPx, euklidisch gemessen.
|
||
* Ist der Pixel unter (x, y) selbst begehbar, bleibt der Punkt genau, wo er ist; sonst die Mitte
|
||
* des nächsten begehbaren Pixels. Gleich weite Kandidaten: der erste in Zeilenreihenfolge.
|
||
*/
|
||
export function nearestWalkable(mask, width, height, x, y, radiusPx) {
|
||
const cx = Math.floor(x), cy = Math.floor(y);
|
||
const walkable = (ix, iy) => ix >= 0 && iy >= 0 && ix < width && iy < height && mask[iy * width + ix] === 255;
|
||
if (walkable(cx, cy)) return { x, y };
|
||
const reach = Math.ceil(radiusPx);
|
||
let best = null;
|
||
for (let iy = cy - reach; iy <= cy + reach; iy++) {
|
||
for (let ix = cx - reach; ix <= cx + reach; ix++) {
|
||
if (!walkable(ix, iy)) continue;
|
||
const d2 = (ix - cx) ** 2 + (iy - cy) ** 2;
|
||
if (d2 <= radiusPx * radiusPx && (!best || d2 < best.d2)) best = { ix, iy, d2 };
|
||
}
|
||
}
|
||
return best ? { x: best.ix + 0.5, y: best.iy + 0.5 } : null;
|
||
}
|
||
|
||
/** Zielpunkt eines Kartenklicks in three-Koordinaten, oder null ohne Landeplatz im Umkreis. */
|
||
export function landingFor(info, walk, px, py) {
|
||
const toMask = info.walkable_scale / info.scale;
|
||
const hit = nearestWalkable(walk.mask, walk.width, walk.height, px * toMask, py * toMask,
|
||
LANDING_RADIUS_M * info.walkable_scale);
|
||
return hit ? mapToWorld(info, hit.x / toMask, hit.y / toMask) : null;
|
||
}
|
||
|
||
/** Planquadrat-Name an einem Kartenpunkt, oder null außerhalb des Ausschnitts. */
|
||
export function chunkNameAtMap(info, px, py) {
|
||
const { x, z } = mapToWorld(info, px, py);
|
||
const { X, Y } = threeToWorld(x, z);
|
||
const { i, j } = chunkForWorld(X, Y);
|
||
return chunkName(i, j);
|
||
}
|
||
|
||
/** Anzeige für die Stelle unter dem Mauszeiger, etwa "F3 · x 412 / y 1830". */
|
||
export function hoverLabel(info, px, py) {
|
||
const name = chunkNameAtMap(info, px, py);
|
||
if (!name) return null;
|
||
const { x, z } = mapToWorld(info, px, py);
|
||
const { X, Y } = threeToWorld(x, z);
|
||
return `${name} · x ${Math.round(X)} / y ${Math.round(Y)}`;
|
||
}
|
||
|
||
/** Kartenwinkel aus einer three-Richtung (dx, dz): 0 = Norden, positiv im Uhrzeigersinn. */
|
||
export function headingFromDirection(dx, dz) {
|
||
return Math.atan2(dx, -dz);
|
||
}
|
||
|
||
/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Eingabefeld. */
|
||
export function isMapToggleKey(event, activeTag) {
|
||
if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||
return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag);
|
||
}
|
||
|
||
/**
|
||
* SHA-256 als Hex, oder null ohne crypto.subtle. Das fehlt außerhalb eines sicheren Kontexts,
|
||
* etwa beim Aufruf über eine LAN-Adresse statt localhost — dann entfällt die Veraltungsprüfung still.
|
||
*/
|
||
export async function sha256Hex(buffer, subtle = globalThis.crypto?.subtle) {
|
||
if (!subtle) return null;
|
||
const digest = await subtle.digest("SHA-256", buffer);
|
||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|