From dd5ced505b0439d1215ac8fbf251d8f455d5c234 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 12:47:51 +0200 Subject: [PATCH] fix(ui): Landeplatz nur auf ganzem Kollisionsboden, Trackpad-Zoom, Fokus und Ladefehler der Minimap Co-Authored-By: Claude Sonnet 5 --- public/data/vice-a/map-walkable.png | Bin 27144 -> 24705 bytes src/main.js | 10 +++- src/ui/map.js | 62 +++++++++++++++----- src/ui/mapMath.js | 81 +++++++++++++++++++++++---- tests/fixtures/chunk-names.json | 2 +- tests/mapMath.test.js | 69 +++++++++++++++++++---- tools/extract/leonida/coords.py | 3 +- tools/extract/overview.py | 41 +++++++++++--- tools/extract/tests/test_overview.py | 49 ++++++++++++++-- 9 files changed, 263 insertions(+), 54 deletions(-) diff --git a/public/data/vice-a/map-walkable.png b/public/data/vice-a/map-walkable.png index 47a1243ffb8cf451cc19378ab89ff8fb66a47dc7..e77cdfd5ccca3803eea15db0d6d1bca0edf891af 100644 GIT binary patch literal 24705 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 U@p>lvy8r+H07*qoM6N<$g0Utj^Z)<= literal 27144 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 P00000NkvXXu0mjfOh_qO 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())