From c278f62cff5ae221712542c53668d7fa9d9938a9 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 10:47:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Kartenmathematik=20f=C3=BCr=20die?= =?UTF-8?q?=20Minimap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- src/ui/mapMath.js | 127 +++++++++++++++++++++++++++++++++ tests/mapMath.test.js | 158 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/ui/mapMath.js create mode 100644 tests/mapMath.test.js diff --git a/src/ui/mapMath.js b/src/ui/mapMath.js new file mode 100644 index 0000000..2d95c8c --- /dev/null +++ b/src/ui/mapMath.js @@ -0,0 +1,127 @@ +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 "C10 · 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(""); +} diff --git a/tests/mapMath.test.js b/tests/mapMath.test.js new file mode 100644 index 0000000..1850746 --- /dev/null +++ b/tests/mapMath.test.js @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick, + isMapToggleKey, landingFor, mapToWorld, nearestWalkable, screenToMap, sha256Hex, worldToMap, zoomAt, +} from "../src/ui/mapMath.js"; + +const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } }; + +describe("Welt und Karte", () => { + it("setzt den Spawn an die richtige Kartenstelle", () => { + expect(worldToMap(INFO, 1699, -743)).toEqual({ px: 2699, py: 1757 }); + }); + + it("legt die vier Ecken des Ausschnitts auf die vier Bildecken — Norden oben", () => { + expect(worldToMap(INFO, -1000, -2500)).toEqual({ px: 0, py: 0 }); // Nordwest + expect(worldToMap(INFO, 2000, -2500)).toEqual({ px: 3000, py: 0 }); // Nordost + expect(worldToMap(INFO, -1000, 500)).toEqual({ px: 0, py: 3000 }); // Südwest + expect(worldToMap(INFO, 2000, 500)).toEqual({ px: 3000, py: 3000 }); // Südost + }); + + it("kommt hin und zurück", () => { + for (const [x, z] of [[1699, -743], [-511, -254], [0, 0], [123.5, -2001.25]]) { + const { px, py } = worldToMap(INFO, x, z); + const back = mapToWorld(INFO, px, py); + expect(back.x).toBeCloseTo(x, 9); + expect(back.z).toBeCloseTo(z, 9); + } + }); + + it("liegt ein nördlicherer Punkt weiter oben — fängt eine gespiegelte Achse", () => { + const south = worldToMap(INFO, 0, -100); // Welt y = 100 + const north = worldToMap(INFO, 0, -900); // Welt y = 900 + expect(north.py).toBeLessThan(south.py); + }); +}); + +describe("Ansicht", () => { + it("passt die ganze Karte zentriert ins Fenster", () => { + expect(fitView(3000, 1200, 900)).toEqual({ scale: 0.3, offsetX: 150, offsetY: 0, minScale: 0.3 }); + }); + + it("hält beim Zoomen den Punkt unter dem Mauszeiger fest", () => { + const view = fitView(3000, 1200, 900); + const before = screenToMap(view, 700, 400); + const after = screenToMap(zoomAt(view, 2, 700, 400), 700, 400); + expect(after.px).toBeCloseTo(before.px, 9); + expect(after.py).toBeCloseTo(before.py, 9); + }); + + it("zoomt nie weiter heraus als der ganze Ausschnitt und nie weiter hinein als vierfach", () => { + const view = fitView(3000, 1200, 900); + expect(zoomAt(view, 0.01, 600, 450).scale).toBeCloseTo(0.3, 9); + expect(zoomAt(view, 100, 600, 450).scale).toBeCloseTo(1.2, 9); + }); + + it("zentriert eine Karte, die kleiner als das Fenster ist", () => { + const view = { scale: 0.3, offsetX: -500, offsetY: 999, minScale: 0.3 }; + expect(clampView(view, 3000, 1200, 900)).toMatchObject({ offsetX: 150, offsetY: 0 }); + }); + + it("lässt eine vergrößerte Karte nicht über ihren Rand hinaus verschieben", () => { + const view = { scale: 1.2, offsetX: 50, offsetY: -99999, minScale: 0.3 }; // Inhalt 3600 px + const clamped = clampView(view, 3000, 1200, 900); + expect(clamped.offsetX).toBe(0); + expect(clamped.offsetY).toBe(900 - 3600); + }); +}); + +describe("Klick gegen Ziehen", () => { + it("zählt unter 4 px Bewegung als Klick und ab 4 px als Ziehen", () => { + expect(CLICK_TOLERANCE_PX).toBe(4); + expect(isClick({ x: 10, y: 10 }, { x: 13.9, y: 10 })).toBe(true); + expect(isClick({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(false); + }); +}); + +describe("Landeplatz", () => { + const blank = (w, h) => new Uint8Array(w * h); + + it("bleibt bei einem begehbaren Treffer genau am Klickpunkt", () => { + const mask = blank(10, 10); + mask[2 * 10 + 5] = 255; + expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.4, y: 2.2 }); + }); + + it("findet von einem Gebäude aus die Straße daneben", () => { + const mask = blank(10, 10); + for (let y = 0; y < 10; y++) mask[y * 10 + 5] = 255; // Straße in Spalte 5 + expect(nearestWalkable(mask, 10, 10, 3.2, 3.7, 5)).toEqual({ x: 5.5, y: 3.5 }); + }); + + it("findet nichts, wenn im Umkreis nur Wasser ist", () => { + expect(nearestWalkable(blank(10, 10), 10, 10, 4, 4, 5)).toBeNull(); + }); + + it("zählt einen Treffer genau auf dem Radius, aber nicht knapp dahinter", () => { + const onRadius = blank(20, 20); + onRadius[3 * 20 + 8] = 255; // Abstand 5 + expect(nearestWalkable(onRadius, 20, 20, 3, 3, 5)).toEqual({ x: 8.5, y: 3.5 }); + const beyond = blank(20, 20); + beyond[4 * 20 + 8] = 255; // Abstand √26 ≈ 5,1 + expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull(); + }); + + it("rechnet einen Kartenklick über die Maske in eine Weltposition um", () => { + const walk = { mask: blank(750, 750), width: 750, height: 750 }; + walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200) + expect(landingFor(INFO, walk, 400, 800)).toEqual({ x: -600, z: -1700 }); // Treffer: genau dorthin + const nearby = landingFor(INFO, walk, 412, 800); // 3 Maskenpixel daneben + expect(nearby.x).toBeCloseTo(-598, 9); // Mitte von (100, 200) = Karte (402, 802) + expect(nearby.z).toBeCloseTo(-1698, 9); + expect(landingFor(INFO, walk, 2000, 2000)).toBeNull(); + }); +}); + +describe("Chunk-Namen und Anzeige", () => { + it("nennt am Kartenpunkt des Downtown-Kerns B9", () => { + expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9"); + }); + + it("liefert am östlichen Kartenrand keinen Namen mehr", () => { + expect(chunkNameAtMap(INFO, 3000, 10)).toBeNull(); + }); + + it("zeigt Chunk und Weltkoordinate im Format der Spec", () => { + expect(hoverLabel(INFO, 489, 2246)).toBe("B9 · x -511 / y 254"); + }); +}); + +describe("Richtung und Tasten", () => { + it("misst die Richtung ab Norden im Uhrzeigersinn", () => { + expect(headingFromDirection(0, -1)).toBeCloseTo(0, 9); + expect(headingFromDirection(1, 0)).toBeCloseTo(Math.PI / 2, 9); + expect(Math.abs(headingFromDirection(0, 1))).toBeCloseTo(Math.PI, 9); + expect(headingFromDirection(-1, 0)).toBeCloseTo(-Math.PI / 2, 9); + }); + + it("schaltet mit M, aber nicht beim Tippen, nicht bei Wiederholung und nicht mit Strg", () => { + const key = (extra) => ({ code: "KeyM", repeat: false, ctrlKey: false, metaKey: false, altKey: false, ...extra }); + expect(isMapToggleKey(key({}), "BODY")).toBe(true); + expect(isMapToggleKey(key({}), "INPUT")).toBe(false); + expect(isMapToggleKey(key({}), "SELECT")).toBe(false); + expect(isMapToggleKey(key({ repeat: true }), "BODY")).toBe(false); + expect(isMapToggleKey(key({ ctrlKey: true }), "BODY")).toBe(false); + expect(isMapToggleKey(key({ code: "KeyN" }), "BODY")).toBe(false); + }); +}); + +describe("Fingerabdruck", () => { + it("liefert SHA-256 als Hex", async () => { + expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); + + it("liefert null ohne sicheren Kontext, statt zu werfen", async () => { + expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull(); + }); +});