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

+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())