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:
1 parent
e702517662
commit
dd5ced505b
9 files changed
+263
-54
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user