Korrektur eines Fehlers im Plantext, nicht in der vorigen Umsetzung: "begehbar" ist in src/ui/mapMath.js (nearestWalkable()/landingFor()) als "Kollisionsboden ohne Gebäude" definiert, nicht als Fußgängerzone. Fahrbahn ist die kollidierende Mulde, Restflächen liegen auf der Bodenplatte, Brücken haben ihr eigenes Deck - alle drei tragen wie die Landplatte und der Gehweg. Nur Gebäude sperren; Wasser bleibt gesperrt, weil nichts darüber gemalt wird. Ein Klick auf eine Straße oder einen Damm über Wasser landet damit wieder auf ihr, statt ins Nichts zu greifen. chunk["bridges"] wird dafür wieder gelesen und zusätzlich im Kartenbild in BRIDGE gezeichnet, nach der Fahrbahn, damit eine Brücke als Brücke zu sehen ist und nicht als Straße im Wasser. test_walkable_mask_counts_only_ground_with_collision und test_walkable_mask_blocks_buildings_and_water zurückgestellt; ein neuer Test sichert die Brückenfarbe im Kartenbild. test_gehweg_ist_begehbar_die_fahrbahn_nicht sagte nach der Korrektur nicht mehr die Wahrheit über sich selbst - Fahrbahn ist jetzt auch begehbar - und ist umbenannt in test_gehweg_und_fahrbahn_sind_beide_begehbar. Der Skip-Grund von test_committed_map_files_match_the_committed_chunks nennt jetzt die Bedingung fürs Entfernen statt einer Aufgabennummer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
206 lines
9.6 KiB
Python
206 lines
9.6 KiB
Python
"""Gesamtansicht und Spielkarte des Ausschnitts aus den fertigen Chunk-Dateien.
|
|
|
|
Zeichnet ausschließlich das, was wirklich ausgeliefert wird — kein Kartenbild darunter.
|
|
Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks,
|
|
Beschriftungen der Originalkarte, die als Geometrie durchgerutscht sind.
|
|
|
|
Zwei Ausgaben:
|
|
- debug/overview.png: Prüfbild mit Palmen, 0,5 px/m.
|
|
- map.png, map-walkable.png und map.json in public/data/vice-a/ für die Minimap
|
|
(docs/superpowers/specs/2026-09-13-minimap-design.md).
|
|
|
|
cd tools/extract && uv run python overview.py
|
|
"""
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw
|
|
|
|
from leonida.coords import SLICE
|
|
|
|
OUT = Path("../../public/data/vice-a")
|
|
DST = Path("debug/overview.png")
|
|
SCALE = 0.5 # Pixel je Meter, Prüfbild
|
|
MAP_SCALE = 1.0 # Pixel je Meter, map.png
|
|
WALKABLE_SCALE = 0.25 # Pixel je Meter, map-walkable.png (4 m je Pixel)
|
|
WATER = (36, 72, 104)
|
|
LAND = (150, 162, 120)
|
|
ROAD = (74, 74, 78)
|
|
SIDEWALK = (206, 202, 194)
|
|
# field, court, pool und parking: Flamingo Park nach OpenStreetMap (docs/superpowers/specs/2026-09-15-flamingo-park-design.md)
|
|
SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172),
|
|
"field": (96, 176, 78), "court": (92, 138, 118), "pool": (70, 150, 220), "parking": ROAD}
|
|
PATH = (226, 216, 190)
|
|
BRIDGE = (196, 92, 92)
|
|
PALM = (40, 190, 70)
|
|
OUTLINE = (30, 30, 30)
|
|
TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt
|
|
WALKABLE = 255
|
|
BLOCKED = 0
|
|
|
|
|
|
def _to_px(ring, ox, oy, scale=SCALE):
|
|
"""Chunk-lokale three.js-Koordinaten (x, z) -> Bildpixel."""
|
|
return [((x + ox - SLICE["x0"]) * scale, (SLICE["y1"] - (oy - z)) * scale) for x, z in ring]
|
|
|
|
|
|
def _draw(img, rings, ox, oy, fill, outline=None, scale=SCALE):
|
|
"""Fläche über eine Maske einsetzen, damit durch die Löcher die Ebene darunter bleibt.
|
|
|
|
Ein Loch mit Wasserfarbe zu übermalen wäre falsch: der Innenhof eines Blocks liegt auf
|
|
Stadtfläche, nicht im Wasser. Funktioniert für RGB-Bilder (fill als Tupel) und
|
|
Graustufenbilder (fill als Zahl) gleichermaßen.
|
|
"""
|
|
outer = _to_px(rings[0], ox, oy, scale)
|
|
xs = [p[0] for p in outer]
|
|
ys = [p[1] for p in outer]
|
|
x0, y0 = int(min(xs)) - 1, int(min(ys)) - 1
|
|
x1, y1 = int(max(xs)) + 2, int(max(ys)) + 2
|
|
if x1 <= x0 or y1 <= y0:
|
|
return
|
|
mask = Image.new("L", (x1 - x0, y1 - y0), 0)
|
|
md = ImageDraw.Draw(mask)
|
|
md.polygon([(x - x0, y - y0) for x, y in outer], fill=255, outline=255)
|
|
for hole in rings[1:]:
|
|
md.polygon([(x - x0, y - y0) for x, y in _to_px(hole, ox, oy, scale)], fill=0)
|
|
img.paste(fill, (x0, y0), mask)
|
|
if outline:
|
|
ImageDraw.Draw(img).polygon(outer, fill=None, outline=outline)
|
|
|
|
|
|
def _building_shade(height: float) -> tuple[int, int, int]:
|
|
t = min(1.0, height / TALL)
|
|
return (int(70 + 170 * t), int(70 + 120 * t), int(80 + 60 * t))
|
|
|
|
|
|
def _load_chunks(out_dir: Path) -> list[dict]:
|
|
return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))]
|
|
|
|
|
|
def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, bridge, building,
|
|
outline=None, palm=None, path=None) -> dict:
|
|
"""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; beide können
|
|
None liefern und die Fläche so aussparen (path=None lässt ebenso die ganze Ebene aus). land,
|
|
sidewalk, lots, road und bridge sind dagegen an jedem Aufrufort eine feste Füllung: in der
|
|
Begehbarkeitsmaske für alle fünf WALKABLE, denn nach der Bedeutung in nearestWalkable()
|
|
(src/ui/mapMath.js) ist ein Pixel begehbar, wenn seine Fläche Kollisionsboden ohne Gebäude ist —
|
|
und Fahrbahn (die kollidierende Mulde), Restflächen (auf der Bodenplatte) und Brücken (eigenes
|
|
Deck) haben Kollision, Beläge und Wege dagegen nicht.
|
|
Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Brücken,
|
|
Gebäude — jede Ebene deckt die vorige ab. Brücken zuletzt, damit eine Brücke als Brücke zu sehen
|
|
ist und nicht als Straße im Wasser: ihre Fahrbahn steckt schon in carriageway an derselben Stelle.
|
|
|
|
chunk["markings"] wird hier nie gelesen: bei einem Pixel je Meter wäre eine 10 cm breite Linie
|
|
ein Drittel Pixel und damit nur Rauschen.
|
|
"""
|
|
stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0}
|
|
placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks]
|
|
for chunk, ox, oy in placed:
|
|
for rings in chunk["land"]:
|
|
_draw(img, rings, ox, oy, land, scale=scale)
|
|
stats["land"] += 1
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["surfaces"]:
|
|
fill = surface(item["kind"])
|
|
if fill is not None:
|
|
_draw(img, item["poly"], ox, oy, fill, scale=scale)
|
|
if path is not None:
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk.get("paths", []):
|
|
_draw(img, item["poly"], ox, oy, path, scale=scale)
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["sidewalk"]:
|
|
_draw(img, item["poly"], ox, oy, sidewalk, scale=scale)
|
|
stats["gehweg"] += 1
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["lots"]:
|
|
_draw(img, item["poly"], ox, oy, lots, scale=scale)
|
|
stats["restflaechen"] += 1
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["carriageway"]:
|
|
_draw(img, item["poly"], ox, oy, road, scale=scale)
|
|
stats["strassen"] += 1
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["bridges"]:
|
|
_draw(img, item["poly"], ox, oy, bridge, scale=scale)
|
|
stats["bruecken"] += 1
|
|
for chunk, ox, oy in placed:
|
|
for item in chunk["buildings"]:
|
|
_draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale)
|
|
stats["gebaeude"] += 1
|
|
if palm is not None:
|
|
for x, z in chunk["palms"]:
|
|
px, py = _to_px([(x, z)], ox, oy, scale)[0]
|
|
ImageDraw.Draw(img).ellipse([px - 1.6, py - 1.6, px + 1.6, py + 1.6], fill=palm)
|
|
stats["palmen"] += 1
|
|
return stats
|
|
|
|
|
|
def render(out_dir: Path, dst: Path) -> dict:
|
|
"""Debug-Gesamtansicht mit Palmen."""
|
|
size = int((SLICE["x1"] - SLICE["x0"]) * SCALE)
|
|
img = Image.new("RGB", (size, size), WATER)
|
|
stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__,
|
|
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, bridge=BRIDGE,
|
|
building=_building_shade, outline=OUTLINE, palm=PALM, path=PATH)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(dst)
|
|
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 hier wörtlich, was nearestWalkable() in src/ui/mapMath.js daraus liest: die ganze
|
|
Fläche eines Pixels ist Kollisionsboden ohne Gebäude. Das sind Landplatte, Gehweg, Fahrbahn,
|
|
Restflächen und Brückendecks — alle fünf tragen für sich, auch eine Brücke über Wasser ganz ohne
|
|
Landplatte darunter. Beläge und Wege bleiben reine Auflagen ohne eigene Kollision, Gebäude sind
|
|
geschlossene Kästen, und Wasser bleibt gesperrt, weil dort nichts gemalt wird. 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, sidewalk=WALKABLE,
|
|
lots=WALKABLE, road=WALKABLE, 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)
|
|
width_m = SLICE["x1"] - SLICE["x0"]
|
|
|
|
picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER)
|
|
_paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__,
|
|
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, bridge=BRIDGE, building=_building_shade,
|
|
outline=OUTLINE, path=PATH)
|
|
|
|
walkable = _walkable_mask(chunks, width_m)
|
|
|
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
picture.save(dst_dir / "map.png")
|
|
walkable.save(dst_dir / "map-walkable.png")
|
|
info = {
|
|
"scale": MAP_SCALE,
|
|
"walkable_scale": WALKABLE_SCALE,
|
|
"slice": dict(SLICE),
|
|
"manifest_sha256": hashlib.sha256((out_dir / "manifest.json").read_bytes()).hexdigest(),
|
|
}
|
|
(dst_dir / "map.json").write_text(json.dumps(info, indent=2) + "\n")
|
|
return {"map": picture.size, "walkable": walkable.size, "manifest_sha256": info["manifest_sha256"]}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(render(OUT, DST), "->", DST)
|
|
print(render_map(OUT, OUT), "->", OUT)
|