diff --git a/public/data/vice-a/map-walkable.png b/public/data/vice-a/map-walkable.png index 47a1243..e77cdfd 100644 Binary files a/public/data/vice-a/map-walkable.png and b/public/data/vice-a/map-walkable.png differ diff --git a/src/main.js b/src/main.js index b79933d..4d3f327 100644 --- a/src/main.js +++ b/src/main.js @@ -260,17 +260,21 @@ function tick(delta) { // Umstellung hört das Streaming beim Fahren auf nachzuladen. streamer.update(playerPosition(player)); if (!settled) { + // Ein Sprung vor dem ersten Boden — Karte und Panel sind schon vor props.load() bedienbar — + // ersetzt den Spawn als Startpunkt, statt beim Einrasten stillschweigend verloren zu gehen. + const start = pendingTeleport ?? SPAWN; // Vehicles existieren hier noch nicht (vehiclesSpawned kommt erst nach settled) — // playerPosition() liefert also zwangsläufig die Kapsel, wie zuvor. if (streamer.isReadyAt(playerPosition(player))) { settled = true; + pendingTeleport = null; player.setSkipCapsuleCollision(false); - player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); + player.reset(new Vector3(start.x, start.y, start.z)); } else { - player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); + player.reset(new Vector3(start.x, start.y, start.z)); if (!warnedSlowSpawn && performance.now() - startedAt > 15000) { warnedSlowSpawn = true; - console.warn(`Chunk am Spawn (${SPAWN.x}, ${SPAWN.z}) ist nach 15s immer noch nicht bereit.`); + console.warn(`Chunk am Startpunkt (${start.x}, ${start.z}) ist nach 15s immer noch nicht bereit.`); } } return; diff --git a/src/ui/map.js b/src/ui/map.js index cfaed93..e404306 100644 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -1,7 +1,8 @@ import { CHUNK_SIZE, CHUNKS_PER_SIDE, chunkName, parseChunkName } from "../world/coords.js"; import { CLICK_TOLERANCE_PX, LABEL_MIN_CHUNK_PX, chunkNameAtMap, clampView, fitView, hoverLabel, isClick, - isMapToggleKey, landingFor, mapToScreen, screenToMap, sha256Hex, worldToMap, zoomAt, + isMapToggleKey, isTextEntry, landingFor, mapFilesProblem, mapToScreen, maskMatches, screenToMap, sha256Hex, + wheelZoomFactor, worldToMap, zoomAt, } from "./mapMath.js"; /** @@ -26,13 +27,13 @@ const STYLE = ` } .leonida-map-note { top: 16px; left: 50%; transform: translateX(-50%); } .leonida-map-here { top: 16px; left: 16px; } -.leonida-map-readout { bottom: 16px; right: 16px; font-variant-numeric: tabular-nums; } +.leonida-map-readout { top: 54px; left: 16px; font-variant-numeric: tabular-nums; } .leonida-map-note:empty, .leonida-map-readout:empty, .leonida-map-here:empty { display: none; } `; const NOTE_MISSING = "Kartenbild fehlt — cd tools/extract && uv run python overview.py ausführen"; const NOTE_NO_MASK = "Begehbarkeitsmaske fehlt — Klick zum Hinspringen ist abgeschaltet"; -const NOTE_STALE = "Karte veraltet — overview.py erneut ausführen"; +const NOTE_STALE = "Karte veraltet — overview.py erneut ausführen und Seite neu laden"; const NOTE_NO_LANDING = "Kein Landeplatz"; function loadImage(url) { @@ -113,15 +114,23 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba try { const response = await fetch(`${baseUrl}/map.json`); if (!response.ok) throw new Error(`map.json: ${response.status}`); - state.info = await response.json(); - state.picture = await loadImage(`${baseUrl}/map.png`); - } catch { + const info = await response.json(); + const picture = await loadImage(`${baseUrl}/map.png`); + const problem = mapFilesProblem(info, picture.naturalWidth, picture.naturalHeight); + if (problem) throw new Error(problem); + state.info = info; + state.picture = picture; + } catch (error) { + console.warn(`Minimap: ${error?.message ?? error}`); state.failed = true; return; } try { - state.walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`)); - } catch { + const walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`)); + if (!maskMatches(state.info, walk.width, walk.height)) throw new Error("map-walkable.png passt nicht zu map.json"); + state.walk = walk; + } catch (error) { + console.warn(`Minimap: ${error?.message ?? error}`); state.noMask = true; } try { @@ -224,7 +233,13 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba function loop() { if (!state.open) return; - draw(); + try { + draw(); + } catch (error) { + // Ein unerwarteter Fehler ließe die Karte sonst ohne Hinweis stehen; so erscheint der Fehlerhinweis. + console.error(error); + state.failed = true; + } state.frame = requestAnimationFrame(loop); } @@ -236,6 +251,12 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba // InputSystem hört auf blur und ruft dort resetKeys(): eine beim Drücken von M gehaltene // Bewegungstaste bleibt sonst gesetzt, und die Figur liefe hinter der Karte weiter. window.dispatchEvent(new Event("blur")); + if (state.failed || state.noMask) { + // Nach "overview.py ausführen" soll erneutes Öffnen genügen, ohne die Seite neu zu laden. + state.failed = false; + state.noMask = false; + loading = null; + } resize(); loop(); await load(); @@ -248,6 +269,9 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba state.drag = null; state.hover = null; cancelAnimationFrame(state.frame); + // Zeichenpuffer freigeben (bei DPR 2 rund 24 MB); resize() legt ihn beim nächsten Öffnen neu an. + canvas.width = 0; + canvas.height = 0; } function jumpTo(point) { @@ -267,14 +291,23 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba // —— Eingaben —— function onKeyCapture(event) { - const typing = document.activeElement?.tagName ?? ""; + const typing = isTextEntry(document.activeElement); if (state.open) { - if (event.type === "keydown" && (event.code === "Escape" || isMapToggleKey(event, typing))) close(); + if (event.type === "keydown") { + if (event.code === "Escape" || isMapToggleKey(event, typing)) close(); + // Tab wanderte sonst ins verdeckte Panel und gäbe dort einem Bedienelement den Fokus. + if (event.code === "Tab") event.preventDefault(); + } event.stopPropagation(); return; } if (event.type === "keydown" && isMapToggleKey(event, typing)) { + // Behielte eine Auswahlliste im Panel den Fokus, wählte sie auf M hin per Tipp-Suche einen + // Eintrag, etwa ein Wahrzeichen, und spränge dorthin. Deshalb die Standardaktion abbrechen + // und den Fokus lösen, bevor die Karte aufgeht. + event.preventDefault(); event.stopPropagation(); + document.activeElement?.blur?.(); void open(); } } @@ -292,14 +325,14 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba event.preventDefault(); if (!state.view) return; const p = toCanvas(event); - const zoomed = zoomAt(state.view, event.deltaY < 0 ? 1.2 : 1 / 1.2, p.x, p.y); + const zoomed = zoomAt(state.view, wheelZoomFactor(event.deltaY, event.deltaMode, event.ctrlKey), p.x, p.y); state.view = clampView(zoomed, mapSize(), canvas.width, canvas.height); }, { passive: false }); canvas.addEventListener("mousedown", (event) => { if (!state.view || event.button !== 0) return; const p = toCanvas(event); - state.drag = { start: p, last: p, view: state.view }; + state.drag = { start: p, last: p, view: state.view, farthest: 0 }; }); canvas.addEventListener("mousemove", (event) => { @@ -308,6 +341,7 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba const m = screenToMap(state.view, p.x, p.y); state.hover = hoverLabel(state.info, m.px, m.py); if (!state.drag) return; + state.drag.farthest = Math.max(state.drag.farthest, Math.hypot(p.x - state.drag.start.x, p.y - state.drag.start.y)); const moved = { ...state.view, offsetX: state.view.offsetX + p.x - state.drag.last.x, offsetY: state.view.offsetY + p.y - state.drag.last.y }; state.view = clampView(moved, mapSize(), canvas.width, canvas.height); @@ -317,7 +351,7 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba canvas.addEventListener("mouseup", (event) => { if (!state.drag) return; const p = toCanvas(event); - const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr); + const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr, state.drag.farthest); if (click) state.view = state.drag.view; // ein Klick soll die Karte nicht verschieben state.drag = null; if (click) jumpTo(p); diff --git a/src/ui/mapMath.js b/src/ui/mapMath.js index dda61b9..ed24086 100644 --- a/src/ui/mapMath.js +++ b/src/ui/mapMath.js @@ -54,20 +54,25 @@ export function clampView(view, mapSize, viewW, 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; +/** + * Kurzer Klick statt Ziehen: der Zeiger hat sich zwischen Drücken und Loslassen nie so weit wie die + * Toleranz vom Startpunkt entfernt. farthest ist die größte Entfernung unterwegs; ohne sie zählte ein + * Ziehen, das zum Startpunkt zurückkehrt, als Klick. + */ +export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX, farthest = 0) { + return Math.max(farthest, 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. + * Mitte des nächsten begehbaren Maskenpixels (Wert 255) im Umkreis radiusPx, euklidisch gemessen, + * oder null. Der Pixel unter (x, y) hat Abstand 0 und gewinnt, wenn er begehbar ist. Immer die + * Pixelmitte, nie der Klickpunkt: overview.py gibt einen Pixel nur frei, wenn seine ganze Fläche + * Kollisionsboden ohne Gebäude ist, und von der Mitte aus sind es dann mindestens 2 m bis zu Wand + * oder Wasser. 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++) { @@ -110,10 +115,66 @@ 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) { +/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Textfeld (s. isTextEntry). */ +export function isMapToggleKey(event, typing) { if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false; - return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag); + return !typing; +} + +const TEXT_INPUT_TYPES = new Set(["text", "search", "email", "url", "tel", "password", "number"]); + +/** + * Tippt der Nutzer gerade in ein Textfeld? Dann gehört M dem Feld. Auswahllisten, Kontrollkästchen und + * Farbwähler zählen nicht: sie behalten nach einer Änderung im Panel den Fokus, und M soll trotzdem + * die Karte öffnen. + */ +export function isTextEntry(element) { + if (!element) return false; + if (element.isContentEditable || element.tagName === "TEXTAREA") return true; + return element.tagName === "INPUT" && TEXT_INPUT_TYPES.has(String(element.type ?? "text").toLowerCase()); +} + +export const WHEEL_ZOOM_PER_PX = 0.0015; + +/** + * Zoomfaktor aus einem Mausrad- oder Trackpad-Ereignis, proportional zur Scrollstrecke. Ein + * Mausrad-Rastschritt (etwa 100 px) zoomt um rund 1,16×; die vielen kleinen Schritte einer + * Trackpad-Geste zoomen fließend, statt je Ereignis einen festen Sprung zu machen. Zeilen und + * Seiten werden wie in CameraSystem.onZoomWheel des Controllers in Pixel umgerechnet, ebenso die + * Pinch-Geste des Trackpads, die als Mausrad mit ctrlKey ankommt. Reine Querbewegung zoomt nicht. + */ +export function wheelZoomFactor(deltaY, deltaMode = 0, ctrlKey = false) { + let pixels = deltaY; + if (deltaMode === 1) pixels *= 16; + else if (deltaMode === 2) pixels *= 100; + if (ctrlKey) pixels *= 10; + return Math.exp(-pixels * WHEEL_ZOOM_PER_PX); +} + +const finite = (value) => typeof value === "number" && Number.isFinite(value); + +/** + * Passen map.json und map.png zusammen? null, sonst eine kurze Beschreibung. Ohne diese Prüfung würfe + * draw() bei einer unvollständigen map.json in jedem Bild, und die Karte bliebe ohne Hinweis stehen. + */ +export function mapFilesProblem(info, pictureWidth, pictureHeight) { + if (!info || !finite(info.scale) || info.scale <= 0 || !finite(info.walkable_scale) || info.walkable_scale <= 0) { + return "map.json: scale oder walkable_scale fehlt"; + } + const s = info.slice; + if (!s || !["x0", "x1", "y0", "y1"].every((k) => finite(s[k])) || s.x1 <= s.x0 || s.y1 <= s.y0) { + return "map.json: slice fehlt oder ist leer"; + } + if (pictureWidth !== Math.round((s.x1 - s.x0) * info.scale) || pictureHeight !== Math.round((s.y1 - s.y0) * info.scale)) { + return "map.png passt nicht zu map.json"; + } + return null; +} + +/** Hat map-walkable.png die Größe, die map.json mit walkable_scale verspricht? */ +export function maskMatches(info, width, height) { + const s = info.slice; + return width === Math.round((s.x1 - s.x0) * info.walkable_scale) && height === Math.round((s.y1 - s.y0) * info.walkable_scale); } /** diff --git a/tests/fixtures/chunk-names.json b/tests/fixtures/chunk-names.json index 4fe6554..5487379 100644 --- a/tests/fixtures/chunk-names.json +++ b/tests/fixtures/chunk-names.json @@ -1,6 +1,6 @@ { "pairs": [["A1", 0, 0], ["L1", 11, 0], ["A12", 0, 11], ["L12", 11, 11], ["C10", 2, 9]], "lowercase": [["c10", 2, 9], ["l12", 11, 11]], - "invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " ", "A1\n"], + "invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " ", "A1\n", "A1١", "A11"], "invalidIndices": [[1.5, 3], [3, 2.5], [true, 0], [0, false]] } diff --git a/tests/mapMath.test.js b/tests/mapMath.test.js index 4c5fbf6..c156a83 100644 --- a/tests/mapMath.test.js +++ b/tests/mapMath.test.js @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick, - isMapToggleKey, landingFor, mapToWorld, nearestWalkable, screenToMap, sha256Hex, worldToMap, zoomAt, + isMapToggleKey, isTextEntry, landingFor, mapFilesProblem, mapToWorld, maskMatches, nearestWalkable, + screenToMap, sha256Hex, wheelZoomFactor, worldToMap, zoomAt, } from "../src/ui/mapMath.js"; const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } }; @@ -71,16 +72,17 @@ describe("Klick gegen 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); + expect(isClick({ x: 10, y: 10 }, { x: 10, y: 10 }, 4, 30)).toBe(false); // weggezogen und zurück }); }); describe("Landeplatz", () => { const blank = (w, h) => new Uint8Array(w * h); - it("bleibt bei einem begehbaren Treffer genau am Klickpunkt", () => { + it("landet bei einem begehbaren Treffer in der Mitte dieses Pixels", () => { 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 }); + expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.5, y: 2.5 }); }); it("findet von einem Gebäude aus die Straße daneben", () => { @@ -102,10 +104,10 @@ describe("Landeplatz", () => { expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull(); }); - it("rechnet einen Kartenklick über die Maske in eine Weltposition um", () => { + it("rechnet einen Kartenklick über die Maske in eine Weltposition in der Pixelmitte 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 + walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200) = Karte 400..404 / 800..804 + expect(landingFor(INFO, walk, 401, 801)).toEqual({ x: -598, z: -1698 }); // Treffer: Pixelmitte (402, 802) 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); @@ -138,12 +140,24 @@ describe("Richtung und Tasten", () => { 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); + expect(isMapToggleKey(key({}), false)).toBe(true); + expect(isMapToggleKey(key({}), true)).toBe(false); + expect(isMapToggleKey(key({ repeat: true }), false)).toBe(false); + expect(isMapToggleKey(key({ ctrlKey: true }), false)).toBe(false); + expect(isMapToggleKey(key({ code: "KeyN" }), false)).toBe(false); + }); + + it("zählt Text- und Zahlenfelder als Tippen, Auswahllisten und Kontrollkästchen nicht", () => { + expect(isTextEntry({ tagName: "INPUT", type: "text" })).toBe(true); + expect(isTextEntry({ tagName: "INPUT", type: "number" })).toBe(true); + expect(isTextEntry({ tagName: "INPUT" })).toBe(true); + expect(isTextEntry({ tagName: "TEXTAREA" })).toBe(true); + expect(isTextEntry({ tagName: "DIV", isContentEditable: true })).toBe(true); + expect(isTextEntry({ tagName: "SELECT" })).toBe(false); + expect(isTextEntry({ tagName: "INPUT", type: "checkbox" })).toBe(false); + expect(isTextEntry({ tagName: "INPUT", type: "color" })).toBe(false); + expect(isTextEntry({ tagName: "BODY" })).toBe(false); + expect(isTextEntry(null)).toBe(false); }); }); @@ -157,3 +171,34 @@ describe("Fingerabdruck", () => { expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull(); }); }); + +describe("Mausrad und Trackpad", () => { + it("zoomt proportional zur Scrollstrecke statt je Ereignis um einen festen Schritt", () => { + expect(wheelZoomFactor(-100)).toBeGreaterThan(1.1); + expect(wheelZoomFactor(-100)).toBeLessThan(1.25); + expect(wheelZoomFactor(100)).toBeCloseTo(1 / wheelZoomFactor(-100), 9); + expect(wheelZoomFactor(-4) ** 25).toBeCloseTo(wheelZoomFactor(-100), 9); // Trackpad: viele kleine Schritte + }); + + it("rechnet Zeilen, Seiten und die Pinch-Geste in Pixel um und zoomt bei reiner Querbewegung nicht", () => { + expect(wheelZoomFactor(-3, 1)).toBeCloseTo(wheelZoomFactor(-48), 9); + expect(wheelZoomFactor(-1, 2)).toBeCloseTo(wheelZoomFactor(-100), 9); + expect(wheelZoomFactor(-2, 0, true)).toBeCloseTo(wheelZoomFactor(-20), 9); + expect(wheelZoomFactor(0)).toBe(1); + }); +}); + +describe("Kartendateien prüfen", () => { + it("nimmt eine passende map.json mit passenden Bildern an", () => { + expect(mapFilesProblem(INFO, 3000, 3000)).toBeNull(); + expect(maskMatches(INFO, 750, 750)).toBe(true); + }); + + it("benennt eine unvollständige map.json oder ein unpassendes Bild, statt später zu werfen", () => { + expect(mapFilesProblem({ ...INFO, scale: undefined }, 3000, 3000)).toMatch(/scale/); + expect(mapFilesProblem({ ...INFO, slice: undefined }, 3000, 3000)).toMatch(/slice/); + expect(mapFilesProblem(null, 3000, 3000)).toMatch(/scale/); + expect(mapFilesProblem(INFO, 1500, 1500)).toMatch(/map\.png/); + expect(maskMatches(INFO, 375, 375)).toBe(false); + }); +}); diff --git a/tools/extract/leonida/coords.py b/tools/extract/leonida/coords.py index 51ab974..4b94b26 100644 --- a/tools/extract/leonida/coords.py +++ b/tools/extract/leonida/coords.py @@ -32,7 +32,8 @@ def pixel_to_world(px: int, py: int) -> tuple[float, float]: CHUNKS_PER_SIDE = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12 CHUNK_COLUMNS = "ABCDEFGHIJKL" -_CHUNK_NAME = re.compile(r"([A-La-l])([1-9]\d?)") +# [0-9] statt \d: in Python passt \d auch auf andere Ziffernzeichen wie ١ oder 1, in JavaScript nicht. +_CHUNK_NAME = re.compile(r"([A-La-l])([1-9][0-9]?)") def _is_index(k) -> bool: diff --git a/tools/extract/overview.py b/tools/extract/overview.py index 2610b7f..3eb2304 100644 --- a/tools/extract/overview.py +++ b/tools/extract/overview.py @@ -15,6 +15,7 @@ import hashlib import json from pathlib import Path +import numpy as np from PIL import Image, ImageDraw from leonida.coords import SLICE @@ -78,8 +79,9 @@ def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, """Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird. surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe. - palm=None zeichnet keine Palmen. Reihenfolge und Füllung entsprechen exakt dem - bisherigen Prüfbild, damit dessen Ausgabe bytegleich bleibt. + palm=None zeichnet keine Palmen; eine Füllung None lässt Beläge oder Straßen aus. + Reihenfolge und Füllung entsprechen exakt dem bisherigen Prüfbild, damit dessen + Ausgabe bytegleich bleibt. """ stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0} placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks] @@ -89,10 +91,14 @@ def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, stats["land"] += 1 for chunk, ox, oy in placed: for item in chunk["surfaces"]: - _draw(img, item["poly"], ox, oy, surface(item["kind"]), scale=scale) + fill = surface(item["kind"]) + if fill is not None: + _draw(img, item["poly"], ox, oy, fill, scale=scale) for chunk, ox, oy in placed: for item in chunk["roads"]: - _draw(img, item["poly"], ox, oy, bridge if item["bridge"] else road, scale=scale) + fill = bridge if item["bridge"] else road + if fill is not None: + _draw(img, item["poly"], ox, oy, fill, scale=scale) stats["bruecken" if item["bridge"] else "strassen"] += 1 for chunk, ox, oy in placed: for item in chunk["buildings"]: @@ -117,6 +123,27 @@ def render(out_dir: Path, dst: Path) -> dict: return stats +def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image: + """Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet. + + Begehbar heißt: die Figur steht dort auf Kollision. Die tragen nur die Landplatte und die + Brücken — Straßen und Beläge sind reine Auflagen (DECALS in src/world/streaming.js) —, und + Gebäude sind geschlossene Kästen. Ein Maskenpixel gilt nur als begehbar, wenn jeder + Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel setzte die Figur sonst in eine + Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten traf das 4,3 % der Klicks + auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in src/ui/mapMath.js), + also mindestens 2 m von jedem gesperrten Quadratmeter entfernt. + """ + fine = Image.new("L", (width_m, width_m), BLOCKED) + _paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, road=None, + bridge=WALKABLE, building=lambda _height: BLOCKED) + step = round(1 / WALKABLE_SCALE) + size = width_m // step + cells = np.asarray(fine).reshape(size, step, size, step) + free = (cells == WALKABLE).all(axis=(1, 3)) + return Image.fromarray(np.where(free, WALKABLE, BLOCKED).astype(np.uint8)) + + def render_map(out_dir: Path, dst_dir: Path) -> dict: """Kartenbild, Begehbarkeitsmaske und map.json für die Minimap.""" chunks = _load_chunks(out_dir) @@ -126,11 +153,7 @@ def render_map(out_dir: Path, dst_dir: Path) -> dict: _paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE) - # Begehbar ist, was Boden hat: Land, Beläge, Straßen und Brücken. Gebäude werden danach wieder - # gesperrt; Wasser ist der Hintergrund. Innenhöfe bleiben über die Löcher begehbar. - walkable = Image.new("L", (int(width_m * WALKABLE_SCALE),) * 2, BLOCKED) - _paint_layers(walkable, chunks, WALKABLE_SCALE, land=WALKABLE, surface=lambda _kind: WALKABLE, - road=WALKABLE, bridge=WALKABLE, building=lambda _height: BLOCKED) + walkable = _walkable_mask(chunks, width_m) dst_dir.mkdir(parents=True, exist_ok=True) picture.save(dst_dir / "map.png") diff --git a/tools/extract/tests/test_overview.py b/tools/extract/tests/test_overview.py index 8ce882b..44844a7 100644 --- a/tools/extract/tests/test_overview.py +++ b/tools/extract/tests/test_overview.py @@ -85,8 +85,8 @@ def test_debug_overview_still_draws_palms(tmp_path): assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m -def test_walkable_mask_marks_roads_bridges_and_surfaces_without_land(tmp_path): - """Jede begehbare Ebene für sich, ohne Land darunter: sonst verdeckt das Land einen Fehler.""" +def test_walkable_mask_counts_only_ground_with_collision(tmp_path): + """Straßen und Beläge sind reine Auflagen: ohne Landplatte darunter trägt nur eine Brücke.""" src = tmp_path / "chunks" src.mkdir() chunk = { @@ -105,7 +105,48 @@ def test_walkable_mask_marks_roads_bridges_and_surfaces_without_land(tmp_path): dst = tmp_path / "out" overview.render_map(src, dst) mask = Image.open(dst / "map-walkable.png") - assert mask.getpixel((5, 5)) == overview.WALKABLE # Sand bei 20 m / 20 m - assert mask.getpixel((27, 5)) == overview.WALKABLE # Straße bei 110 m / 20 m + assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: keine Kollision + assert mask.getpixel((27, 5)) == overview.BLOCKED # Straße ohne Land bei 110 m / 20 m: keine Kollision assert mask.getpixel((52, 5)) == overview.WALKABLE # Brücke bei 210 m / 20 m assert mask.getpixel((40, 5)) == overview.BLOCKED # Wasser bei 160 m / 20 m + + +def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path): + """Ein 4-m-Pixel, den Wasser oder ein Gebäude auch nur anschneidet, ist kein Landeplatz.""" + src = tmp_path / "chunks" + src.mkdir() + chunk = { + "chunk": [0, 0], + "origin": [SLICE["x0"], -SLICE["y1"]], + "land": [[_ring(0, 0, 102, 250)]], # Küste bei x = 102 m, mitten in Pixel 25 + "surfaces": [], + "roads": [], + "centerlines": [], + "buildings": [{"poly": [_ring(38, 40, 50, 50)], "height": 8.0, "source": "district", "speculative": False}], + "palms": [], + } + (src / "chunk_0_0.json").write_text(json.dumps(chunk)) + (src / "manifest.json").write_text('{"slice": "vice-a"}\n') + dst = tmp_path / "out" + overview.render_map(src, dst) + mask = Image.open(dst / "map-walkable.png") + assert mask.getpixel((24, 30)) == overview.WALKABLE # 96..100 m: ganz Land + assert mask.getpixel((25, 30)) == overview.BLOCKED # 100..104 m: halb Wasser + assert mask.getpixel((9, 11)) == overview.BLOCKED # 36..40 m: das Gebäude beginnt bei 38 m + assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei + + +def test_committed_map_files_match_the_committed_chunks(tmp_path): + """Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen. + + Ändert ein Pipeline-Lauf die Chunks, ohne dass overview.py läuft, zeigte die Minimap eine alte + Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das + erst im Browser, dieser Test schon beim Testlauf. + """ + shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a" + overview.render_map(shipped, tmp_path) + for name in ("map.png", "map-walkable.png"): + fresh, committed = Image.open(tmp_path / name), Image.open(shipped / name) + assert (fresh.size, fresh.mode) == (committed.size, committed.mode), name + assert fresh.tobytes() == committed.tobytes(), f"{name} ist veraltet: cd tools/extract && uv run python overview.py" + assert json.loads((tmp_path / "map.json").read_text()) == json.loads((shipped / "map.json").read_text())