fix(ui): Landeplatz nur auf ganzem Kollisionsboden, Trackpad-Zoom, Fokus und Ladefehler der Minimap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 12:47:51 +02:00
1 parent e702517662
commit dd5ced505b
9 files changed
+263 -54

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+7 -3
View File
@@ -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;
+48 -14
View File
@@ -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);
+71 -10
View File
@@ -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);
}
/**
+1 -1
View File
@@ -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]]
}
+57 -12
View File
@@ -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);
});
});
+2 -1
View File
@@ -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:
+32 -9
View File
@@ -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")
+45 -4
View File
@@ -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())