From 44108f94f4dfa6c3e930a176fd2822a6a2a89918 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 09:24:00 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Umsetzungsplan=20f=C3=BCr=20die=20Minim?= =?UTF-8?q?ap,=20Spec=20nachgesch=C3=A4rft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Aufgaben: Chunk-Namen in JavaScript und Python mit gemeinsamem Beispielsatz; Kartenbild, Begehbarkeitsmaske und map.json aus overview.py; reine Kartenmathematik mit Tests; Kartenkomponente und Einhängen mit Browser-Abnahme. Die Spec bekommt dabei drei Präzisierungen, die sich erst beim Lesen des Codes ergeben haben: die Namensnennung muss über der Karte liegen (Lizenzpflicht), die Veraltungsprüfung erkennt nicht jede Änderung, und die Eingabesperre stoppt Mausereignisse auf der Karte erst an deren Wurzel, weil ein Stopp in der Capture-Phase auch den eigenen Canvas taub machen würde. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-13-minimap.md | 1315 +++++++++++++++++ .../specs/2026-09-13-minimap-design.md | 28 +- 2 files changed, 1338 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-13-minimap.md diff --git a/docs/superpowers/plans/2026-09-13-minimap.md b/docs/superpowers/plans/2026-09-13-minimap.md new file mode 100644 index 0000000..5b85137 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-minimap.md @@ -0,0 +1,1315 @@ +# Minimap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auf Taste M öffnet sich über dem laufenden Spiel eine große Karte des Vice-City-Ausschnitts mit benanntem Chunk-Raster (A1–L12), Koordinatenanzeige, Spielerpfeil, Fahrzeugen, Zoomen, Verschieben und Klick zum Hinspringen. + +**Architecture:** `tools/extract/overview.py` erzeugt aus den ausgelieferten Chunk-Dateien ein Kartenbild (1 px/m), eine Begehbarkeitsmaske (4 m/px) und `map.json`. Im Browser rechnet `src/ui/mapMath.js` rein funktional zwischen Welt, Karte, Maske und Bildschirm um; `src/ui/map.js` ist eine dünne Canvas-Überlagerung darauf, eingehängt in `src/main.js` über die vorhandenen Funktionen `playerPosition`, `getAllVehicles` und `requestTeleport`. + +**Tech Stack:** three 0.184.0, Vite, vitest (Node-Umgebung, **kein** jsdom) · Python 3.12 über uv mit Pillow und pytest + +**Spec:** `docs/superpowers/specs/2026-09-13-minimap-design.md` + +## Global Constraints + +- three 0.184.0; **keine neue Laufzeit- oder Testabhängigkeit.** vitest läuft ohne DOM — alles, was ohne Browser getestet werden soll, gehört in reine Funktionen in `mapMath.js`; `map.js` wird im Browser geprüft. +- Python-Bibliotheken: numpy, scipy, scikit-image, shapely, networkx, pillow, pytest. +- **Niemals** `git add three-player-controller`, **niemals** `git add -A`. Das Submodul bleibt auf `ac39682`; `git status` zeigt es als geändert, das ist ein gewollter Zustand. Commits nur mit ausdrücklichen Pfaden: `git commit -m "…" -- `. +- **`tools/extract/run.py` wird nicht angefasst.** Eine parallele Sitzung arbeitet an der Pipeline. Die Kartendateien entstehen über `overview.py`. +- Chunk-Namen: Spalte `A`–`L` aus `i` (West → Ost), Zeile `1`–`12` aus `j` (Nord → Süd). **A1 = `chunk_0_0` = Nordwest-Ecke.** C10 = `chunk_2_9`. Downtown-Kern Welt `(−511, 254)` = B9. +- Koordinaten: three `(x, z)` → Welt `(X = x, Y = −z)` → Kartenpixel `px = (X − x0) · scale`, `py = (y1 − Y) · scale`, mit `x0 = −1000`, `y1 = 2500`. +- Ausschnitt: `SLICE = { x0: -1000, x1: 2000, y0: -500, y1: 2500 }`, Chunkgröße 250 m, 12 × 12 Chunks. +- Kartendateien in `public/data/vice-a/`: `map.png` 3.000 × 3.000, `map-walkable.png` 750 × 750 (255 begehbar, 0 nicht), `map.json`. Vite liefert sie unter `./data/vice-a/` aus. +- Landeradius 20 m = 5 Maskenpixel; Klick gegen Ziehen bei 4 CSS-Pixeln; Zoom vom ganzen Ausschnitt bis vierfach; Chunk-Namen erst ab 40 CSS-Pixeln Chunkbreite. +- Stapelordnung: `.attribution` 2000 > Karte 1500 > lil-gui 1001. Die Namensnennung bleibt über der Karte sichtbar (Lizenzpflicht). +- Testausgabe muss sauber sein. JavaScript: `npm test`. Python: `cd tools/extract && uv run pytest -q`. + +--- + +### Task 1: Chunk-Namen in JavaScript und Python + +**Files:** +- Modify: `src/world/coords.js` +- Modify: `tools/extract/leonida/coords.py` +- Create: `tests/fixtures/chunk-names.json` +- Test: `tests/coords.test.js`, `tools/extract/tests/test_coords.py` + +**Interfaces:** +- Consumes: `CHUNKS_PER_SIDE`, `chunkForWorld` aus `src/world/coords.js` +- Produces: + - `chunkName(i: number, j: number) -> string | null` (JavaScript) — `null` außerhalb von 0–11 + - `parseChunkName(name: string) -> { i: number, j: number } | null` (JavaScript) + - `chunk_name(i: int, j: int) -> str | None` und `parse_chunk_name(name: str) -> tuple[int, int] | None` (Python) + - `tests/fixtures/chunk-names.json` als gemeinsamer Beispielsatz beider Sprachen + +- [ ] **Step 1: Den gemeinsamen Beispielsatz anlegen** + +`tests/fixtures/chunk-names.json`: + +```json +{ + "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", " "] +} +``` + +- [ ] **Step 2: Die fehlschlagenden JavaScript-Tests schreiben** + +In `tests/coords.test.js` die **bestehende** Import-Zeile aus `../src/world/coords.js` durch die beiden ersten Zeilen des Blocks ersetzen (die Zeile `import { describe, expect, it } from "vitest";` bleibt unverändert davor stehen) und den Rest des Blocks ans Dateiende anfügen. Nicht den ganzen Block anhängen — sonst steht der Import aus `coords.js` doppelt da: + +```js +import fs from "node:fs"; +import { CHUNK_SIZE, chunkForWorld, chunkName, parseChunkName, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js"; + +const NAMES = JSON.parse(fs.readFileSync(new URL("./fixtures/chunk-names.json", import.meta.url), "utf8")); + +describe("Chunk-Namen", () => { + it("benennt Ecken und Beispiel wie in der Spec, in beide Richtungen", () => { + for (const [name, i, j] of NAMES.pairs) { + expect(chunkName(i, j)).toBe(name); + expect(parseChunkName(name)).toEqual({ i, j }); + } + }); + + it("akzeptiert Kleinschreibung", () => { + for (const [name, i, j] of NAMES.lowercase) expect(parseChunkName(name)).toEqual({ i, j }); + }); + + it("weist alles außerhalb von A1 bis L12 ab", () => { + for (const name of NAMES.invalid) expect(parseChunkName(name)).toBeNull(); + }); + + it("liefert null für Indizes außerhalb des Ausschnitts", () => { + expect(chunkName(12, 0)).toBeNull(); + expect(chunkName(0, 12)).toBeNull(); + expect(chunkName(-1, 3)).toBeNull(); + expect(chunkName(1.5, 3)).toBeNull(); + }); + + it("kommt für alle 144 Chunks hin und zurück", () => { + for (let i = 0; i < 12; i++) { + for (let j = 0; j < 12; j++) expect(parseChunkName(chunkName(i, j))).toEqual({ i, j }); + } + }); + + it("verbindet den Namen mit der Lage: der Downtown-Kern liegt in B9", () => { + const { i, j } = chunkForWorld(-511, 254); + expect({ i, j }).toEqual({ i: 1, j: 8 }); + expect(chunkName(i, j)).toBe("B9"); + }); +}); +``` + +- [ ] **Step 3: JavaScript-Tests laufen lassen, sie müssen scheitern** + +Run: `npx vitest run tests/coords.test.js` +Expected: FAIL — `chunkName is not a function` (bzw. Import schlägt fehl). + +- [ ] **Step 4: `chunkName` und `parseChunkName` in `src/world/coords.js` anfügen** + +```js +const CHUNK_COLUMNS = "ABCDEFGHIJKL"; +const CHUNK_NAME_PATTERN = /^([A-La-l])([1-9]\d?)$/; + +/** + * Planquadrat-Name eines Chunks: Spalte A–L aus i (West → Ost), Zeile 1–12 aus j (Nord → Süd). + * A1 ist die Nordwest-Ecke, weil chunkOrigin i nach Osten und j von der Nordkante nach Süden zählt. + * Die Namen sind die Schnittstelle zur Feinkalibrierung: was im Chat "C10" heißt, ist chunk_2_9. + */ +export function chunkName(i, j) { + const inside = (k) => Number.isInteger(k) && k >= 0 && k < CHUNKS_PER_SIDE; + return inside(i) && inside(j) ? CHUNK_COLUMNS[i] + (j + 1) : null; +} + +/** Umkehrung von chunkName. Akzeptiert Kleinschreibung, liefert null für alles außerhalb A1–L12. */ +export function parseChunkName(name) { + const match = CHUNK_NAME_PATTERN.exec(String(name)); + if (!match) return null; + const row = Number(match[2]); + if (row > CHUNKS_PER_SIDE) return null; + return { i: CHUNK_COLUMNS.indexOf(match[1].toUpperCase()), j: row - 1 }; +} +``` + +- [ ] **Step 5: JavaScript-Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/coords.test.js` +Expected: PASS, alle Tests der Datei. + +- [ ] **Step 6: Die fehlschlagenden Python-Tests schreiben** + +In `tools/extract/tests/test_coords.py` die **bestehende** Zeile `from leonida.coords import …` durch die Importe am Anfang des Blocks ersetzen und die Tests ans Dateiende anfügen. Nicht den ganzen Block anhängen — sonst stehen die Importe doppelt da: + +```python +import json +from pathlib import Path + +from leonida.coords import SLICE, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world + +# Derselbe Beispielsatz wie tests/coords.test.js — JavaScript und Python dürfen nicht auseinanderlaufen. +NAMES = json.loads((Path(__file__).resolve().parents[3] / "tests/fixtures/chunk-names.json").read_text()) + + +def test_chunk_names_match_the_shared_examples(): + for name, i, j in NAMES["pairs"]: + assert chunk_name(i, j) == name + assert parse_chunk_name(name) == (i, j) + + +def test_chunk_names_accept_lowercase(): + for name, i, j in NAMES["lowercase"]: + assert parse_chunk_name(name) == (i, j) + + +def test_chunk_names_reject_everything_outside_a1_to_l12(): + for name in NAMES["invalid"]: + assert parse_chunk_name(name) is None + assert chunk_name(12, 0) is None + assert chunk_name(0, -1) is None + + +def test_chunk_names_round_trip_for_all_144_chunks(): + for i in range(12): + for j in range(12): + assert parse_chunk_name(chunk_name(i, j)) == (i, j) +``` + +- [ ] **Step 7: Python-Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_coords.py -q` +Expected: FAIL — `ImportError: cannot import name 'chunk_name'`. + +- [ ] **Step 8: `chunk_name` und `parse_chunk_name` in `tools/extract/leonida/coords.py` anfügen** + +Oben `import re` ergänzen, am Ende anfügen: + +```python +CHUNKS_PER_SIDE = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12 +CHUNK_COLUMNS = "ABCDEFGHIJKL" +_CHUNK_NAME = re.compile(r"^([A-La-l])([1-9]\d?)$") + + +def chunk_name(i: int, j: int) -> str | None: + """Planquadrat-Name wie src/world/coords.js: Spalte A–L aus i, Zeile 1–12 aus j. A1 = Nordwest.""" + if not (0 <= i < CHUNKS_PER_SIDE and 0 <= j < CHUNKS_PER_SIDE): + return None + return f"{CHUNK_COLUMNS[i]}{j + 1}" + + +def parse_chunk_name(name: str) -> tuple[int, int] | None: + """Umkehrung von chunk_name. Akzeptiert Kleinschreibung, None außerhalb A1–L12.""" + match = _CHUNK_NAME.match(str(name)) + if not match or int(match.group(2)) > CHUNKS_PER_SIDE: + return None + return CHUNK_COLUMNS.index(match.group(1).upper()), int(match.group(2)) - 1 +``` + +- [ ] **Step 9: Beide Suiten laufen lassen** + +Run: `npm test` und `cd tools/extract && uv run pytest -q` +Expected: beide PASS, Ausgabe sauber. + +- [ ] **Step 10: Commit** + +```bash +git add src/world/coords.js tools/extract/leonida/coords.py tests/fixtures/chunk-names.json tests/coords.test.js tools/extract/tests/test_coords.py +git commit -m "feat(coords): Chunk-Namen als Planquadrat A1 bis L12 + +Co-Authored-By: Claude Opus 5 " -- src/world/coords.js tools/extract/leonida/coords.py tests/fixtures/chunk-names.json tests/coords.test.js tools/extract/tests/test_coords.py +``` + +--- + +### Task 2: Kartenbild, Begehbarkeitsmaske und map.json + +**Files:** +- Modify: `tools/extract/overview.py` +- Create: `tools/extract/tests/test_overview.py` +- Erzeugt: `public/data/vice-a/map.png`, `public/data/vice-a/map-walkable.png`, `public/data/vice-a/map.json` + +**Interfaces:** +- Consumes: `SLICE` aus `leonida.coords`; Chunk-Dateien und `manifest.json` in `public/data/vice-a/` +- Produces: + - `render_map(out_dir: Path, dst_dir: Path) -> dict` — schreibt die drei Dateien + - `render(out_dir: Path, dst: Path) -> dict` — Debug-Gesamtansicht, **Ausgabe bytegleich zu vorher** + - `map.json`: `{ "scale": 1.0, "walkable_scale": 0.25, "slice": {x0, x1, y0, y1}, "manifest_sha256": "" }` + +- [ ] **Step 1: Prüfsumme der heutigen Debug-Gesamtansicht festhalten** + +Run: `cd tools/extract && uv run python overview.py && shasum -a 256 debug/overview.png | tee /tmp/overview-vorher.sha` +Expected: eine Zeile mit Prüfsumme. Sie belegt in Step 6, dass der Umbau die Debug-Ansicht nicht verändert. + +- [ ] **Step 2: Die fehlschlagenden Tests schreiben** + +`tools/extract/tests/test_overview.py`: + +```python +import hashlib +import json +from pathlib import Path + +from PIL import Image + +import overview +from leonida.coords import SLICE + + +def _ring(x0, z0, x1, z1): + return [[x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0]] + + +def _fixture(tmp_path: Path) -> Path: + """Ein einziger Chunk A1 (Nordwest): Land, eine Straße, ein Gebäude, eine Palme. + + Chunk-lokale Koordinaten von chunk_0_0 fallen bei scale 1 genau auf die Kartenpixel, + weil der Chunk-Ursprung die Nordwest-Ecke des Ausschnitts ist. + """ + chunk = { + "chunk": [0, 0], + "origin": [SLICE["x0"], -SLICE["y1"]], + "land": [[_ring(0, 0, 250, 250)]], + "surfaces": [], + "roads": [{"poly": [_ring(100, 0, 120, 250)], "bridge": False}], + "centerlines": [], + "buildings": [{"poly": [_ring(20, 20, 60, 60)], "height": 12.0, "source": "district", "speculative": False}], + "palms": [[200.0, 200.0]], + } + (tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk)) + (tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n') + return tmp_path + + +def test_map_picture_has_one_pixel_per_metre_and_no_palms(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + picture = Image.open(dst / "map.png") + assert picture.size == (3000, 3000) + assert picture.getpixel((110, 125)) == overview.ROAD + assert picture.getpixel((40, 40)) == overview._building_shade(12.0) + assert picture.getpixel((200, 200)) == overview.LAND # Palme wird nicht gezeichnet + assert picture.getpixel((1500, 1500)) == overview.WATER + + +def test_walkable_mask_blocks_buildings_and_water(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + mask = Image.open(dst / "map-walkable.png") + assert mask.mode == "L" + assert mask.size == (750, 750) + assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m + assert mask.getpixel((27, 31)) == overview.WALKABLE # Straße bei 110 m / 125 m + assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m + assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser + + +def test_map_json_carries_scale_slice_and_manifest_fingerprint(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + info = json.loads((dst / "map.json").read_text()) + assert info["scale"] == 1.0 + assert info["walkable_scale"] == 0.25 + assert info["slice"] == SLICE + assert info["manifest_sha256"] == hashlib.sha256((src / "manifest.json").read_bytes()).hexdigest() + + +def test_debug_overview_still_draws_palms(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "debug.png" + stats = overview.render(src, dst) + assert stats["palmen"] == 1 + assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m +``` + +- [ ] **Step 3: Tests laufen lassen, sie müssen scheitern** + +Run: `cd tools/extract && uv run pytest tests/test_overview.py -q` +Expected: FAIL — `AttributeError: module 'overview' has no attribute 'render_map'`. + +- [ ] **Step 4: `tools/extract/overview.py` vollständig ersetzen** + +```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 + +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) +SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)} +ROAD = (74, 74, 78) +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, road, bridge, building, outline=None, palm=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. + palm=None zeichnet keine Palmen. 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] + 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"]: + _draw(img, item["poly"], ox, oy, surface(item["kind"]), 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) + stats["bruecken" if item["bridge"] else "strassen"] += 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__, + road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM) + dst.parent.mkdir(parents=True, exist_ok=True) + img.save(dst) + return stats + + +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__, 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) + + 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) +``` + +- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen** + +Run: `cd tools/extract && uv run pytest tests/test_overview.py -q` +Expected: PASS, 4 Tests. + +- [ ] **Step 6: Belegen, dass die Debug-Ansicht bytegleich geblieben ist, und die echten Kartendateien erzeugen** + +Run: `cd tools/extract && uv run python overview.py && shasum -a 256 debug/overview.png && cat /tmp/overview-vorher.sha` +Expected: beide Prüfsummen identisch. Unterscheiden sie sich, ist der Umbau nicht verhaltensgleich — nicht committen, sondern melden. + +Run: `ls -la ../../public/data/vice-a/map*.png ../../public/data/vice-a/map.json && cat ../../public/data/vice-a/map.json` +Expected: `map.png` und `map-walkable.png` vorhanden, `map.json` mit den Werten aus den Interfaces. Größe von `map.png` im Bericht nennen. + +- [ ] **Step 7: Beide Suiten laufen lassen** + +Run: `cd tools/extract && uv run pytest -q` und `npm test` +Expected: beide PASS, Ausgabe sauber. + +- [ ] **Step 8: Commit** + +```bash +git add tools/extract/overview.py tools/extract/tests/test_overview.py public/data/vice-a/map.png public/data/vice-a/map-walkable.png public/data/vice-a/map.json +git commit -m "feat(extract): Kartenbild, Begehbarkeitsmaske und map.json für die Minimap + +Co-Authored-By: Claude Opus 5 " -- tools/extract/overview.py tools/extract/tests/test_overview.py public/data/vice-a/map.png public/data/vice-a/map-walkable.png public/data/vice-a/map.json +``` + +--- + +### Task 3: Kartenmathematik + +**Files:** +- Create: `src/ui/mapMath.js` +- Test: `tests/mapMath.test.js` + +**Interfaces:** +- Consumes: `chunkForWorld`, `chunkName`, `threeToWorld` aus `src/world/coords.js` (Task 1) +- Produces (alle rein, ohne DOM): + - Konstanten `CLICK_TOLERANCE_PX = 4`, `MAX_ZOOM = 4`, `LABEL_MIN_CHUNK_PX = 40`, `LANDING_RADIUS_M = 20` + - `worldToMap(info, x, z) -> { px, py }` · `mapToWorld(info, px, py) -> { x, z }` + - `fitView(mapSize, viewW, viewH) -> { scale, offsetX, offsetY, minScale }` + - `zoomAt(view, factor, sx, sy) -> view` · `clampView(view, mapSize, viewW, viewH) -> view` + - `screenToMap(view, sx, sy) -> { px, py }` · `mapToScreen(view, px, py) -> { sx, sy }` + - `isClick(down, up, tolerance) -> boolean` mit `down`/`up` als `{ x, y }` + - `nearestWalkable(mask, width, height, x, y, radiusPx) -> { x, y } | null` in Maskenkoordinaten + - `landingFor(info, walk, px, py) -> { x, z } | null` mit `walk = { mask, width, height }` + - `chunkNameAtMap(info, px, py) -> string | null` · `hoverLabel(info, px, py) -> string | null` + - `headingFromDirection(dx, dz) -> number` (0 = Norden, im Uhrzeigersinn) + - `isMapToggleKey(event, activeTag) -> boolean` + - `sha256Hex(buffer, subtle = globalThis.crypto?.subtle) -> Promise` + - `info` hat die Form von `map.json` + +- [ ] **Step 1: Die fehlschlagenden Tests schreiben** + +`tests/mapMath.test.js`: + +```js +import { describe, expect, it } from "vitest"; +import { + CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick, + isMapToggleKey, landingFor, mapToWorld, nearestWalkable, screenToMap, sha256Hex, worldToMap, zoomAt, +} from "../src/ui/mapMath.js"; + +const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } }; + +describe("Welt und Karte", () => { + it("setzt den Spawn an die richtige Kartenstelle", () => { + expect(worldToMap(INFO, 1699, -743)).toEqual({ px: 2699, py: 1757 }); + }); + + it("legt die vier Ecken des Ausschnitts auf die vier Bildecken — Norden oben", () => { + expect(worldToMap(INFO, -1000, -2500)).toEqual({ px: 0, py: 0 }); // Nordwest + expect(worldToMap(INFO, 2000, -2500)).toEqual({ px: 3000, py: 0 }); // Nordost + expect(worldToMap(INFO, -1000, 500)).toEqual({ px: 0, py: 3000 }); // Südwest + expect(worldToMap(INFO, 2000, 500)).toEqual({ px: 3000, py: 3000 }); // Südost + }); + + it("kommt hin und zurück", () => { + for (const [x, z] of [[1699, -743], [-511, -254], [0, 0], [123.5, -2001.25]]) { + const { px, py } = worldToMap(INFO, x, z); + const back = mapToWorld(INFO, px, py); + expect(back.x).toBeCloseTo(x, 9); + expect(back.z).toBeCloseTo(z, 9); + } + }); + + it("liegt ein nördlicherer Punkt weiter oben — fängt eine gespiegelte Achse", () => { + const south = worldToMap(INFO, 0, -100); // Welt y = 100 + const north = worldToMap(INFO, 0, -900); // Welt y = 900 + expect(north.py).toBeLessThan(south.py); + }); +}); + +describe("Ansicht", () => { + it("passt die ganze Karte zentriert ins Fenster", () => { + expect(fitView(3000, 1200, 900)).toEqual({ scale: 0.3, offsetX: 150, offsetY: 0, minScale: 0.3 }); + }); + + it("hält beim Zoomen den Punkt unter dem Mauszeiger fest", () => { + const view = fitView(3000, 1200, 900); + const before = screenToMap(view, 700, 400); + const after = screenToMap(zoomAt(view, 2, 700, 400), 700, 400); + expect(after.px).toBeCloseTo(before.px, 9); + expect(after.py).toBeCloseTo(before.py, 9); + }); + + it("zoomt nie weiter heraus als der ganze Ausschnitt und nie weiter hinein als vierfach", () => { + const view = fitView(3000, 1200, 900); + expect(zoomAt(view, 0.01, 600, 450).scale).toBeCloseTo(0.3, 9); + expect(zoomAt(view, 100, 600, 450).scale).toBeCloseTo(1.2, 9); + }); + + it("zentriert eine Karte, die kleiner als das Fenster ist", () => { + const view = { scale: 0.3, offsetX: -500, offsetY: 999, minScale: 0.3 }; + expect(clampView(view, 3000, 1200, 900)).toMatchObject({ offsetX: 150, offsetY: 0 }); + }); + + it("lässt eine vergrößerte Karte nicht über ihren Rand hinaus verschieben", () => { + const view = { scale: 1.2, offsetX: 50, offsetY: -99999, minScale: 0.3 }; // Inhalt 3600 px + const clamped = clampView(view, 3000, 1200, 900); + expect(clamped.offsetX).toBe(0); + expect(clamped.offsetY).toBe(900 - 3600); + }); +}); + +describe("Klick gegen Ziehen", () => { + it("zählt unter 4 px Bewegung als Klick und ab 4 px als 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); + }); +}); + +describe("Landeplatz", () => { + const blank = (w, h) => new Uint8Array(w * h); + + it("bleibt bei einem begehbaren Treffer genau am Klickpunkt", () => { + 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 }); + }); + + it("findet von einem Gebäude aus die Straße daneben", () => { + const mask = blank(10, 10); + for (let y = 0; y < 10; y++) mask[y * 10 + 5] = 255; // Straße in Spalte 5 + expect(nearestWalkable(mask, 10, 10, 3.2, 3.7, 5)).toEqual({ x: 5.5, y: 3.5 }); + }); + + it("findet nichts, wenn im Umkreis nur Wasser ist", () => { + expect(nearestWalkable(blank(10, 10), 10, 10, 4, 4, 5)).toBeNull(); + }); + + it("zählt einen Treffer genau auf dem Radius, aber nicht knapp dahinter", () => { + const onRadius = blank(20, 20); + onRadius[3 * 20 + 8] = 255; // Abstand 5 + expect(nearestWalkable(onRadius, 20, 20, 3, 3, 5)).toEqual({ x: 8.5, y: 3.5 }); + const beyond = blank(20, 20); + beyond[4 * 20 + 8] = 255; // Abstand √26 ≈ 5,1 + expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull(); + }); + + it("rechnet einen Kartenklick über die Maske in eine Weltposition 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 + 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); + expect(landingFor(INFO, walk, 2000, 2000)).toBeNull(); + }); +}); + +describe("Chunk-Namen und Anzeige", () => { + it("nennt am Kartenpunkt des Downtown-Kerns B9", () => { + expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9"); + }); + + it("liefert am östlichen Kartenrand keinen Namen mehr", () => { + expect(chunkNameAtMap(INFO, 3000, 10)).toBeNull(); + }); + + it("zeigt Chunk und Weltkoordinate im Format der Spec", () => { + expect(hoverLabel(INFO, 489, 2246)).toBe("B9 · x -511 / y 254"); + }); +}); + +describe("Richtung und Tasten", () => { + it("misst die Richtung ab Norden im Uhrzeigersinn", () => { + expect(headingFromDirection(0, -1)).toBeCloseTo(0, 9); + expect(headingFromDirection(1, 0)).toBeCloseTo(Math.PI / 2, 9); + expect(Math.abs(headingFromDirection(0, 1))).toBeCloseTo(Math.PI, 9); + expect(headingFromDirection(-1, 0)).toBeCloseTo(-Math.PI / 2, 9); + }); + + 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); + }); +}); + +describe("Fingerabdruck", () => { + it("liefert SHA-256 als Hex", async () => { + expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); + + it("liefert null ohne sicheren Kontext, statt zu werfen", async () => { + expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern** + +Run: `npx vitest run tests/mapMath.test.js` +Expected: FAIL — `Failed to load url ../src/ui/mapMath.js`. + +- [ ] **Step 3: `src/ui/mapMath.js` schreiben** + +```js +import { chunkForWorld, chunkName, threeToWorld } from "../world/coords.js"; + +/** + * Reine Kartenmathematik für die Minimap (docs/superpowers/specs/2026-09-13-minimap-design.md). + * Ohne DOM, damit vitest sie ohne Browser prüfen kann. `info` hat die Form von map.json. + * + * Koordinatensysteme: + * - three (x, z): Szene; Norden ist −z. + * - Welt (X, Y): X = x, Y = −z. + * - Karte (px, py): Pixel in map.png, Ursprung Nordwest, py wächst nach Süden. + * - Maske: Pixel in map-walkable.png, gleiche Ausrichtung, anderer Maßstab. + * - Bildschirm (sx, sy): Canvas-Pixel; Ansicht = { scale, offsetX, offsetY, minScale }. + */ + +export const CLICK_TOLERANCE_PX = 4; +export const MAX_ZOOM = 4; +export const LABEL_MIN_CHUNK_PX = 40; +export const LANDING_RADIUS_M = 20; + +/** three (x, z) -> Kartenpixel. Die einzige Stelle der Achsenumkehr zwischen three-z und Karten-y. */ +export function worldToMap(info, x, z) { + const { X, Y } = threeToWorld(x, z); + return { px: (X - info.slice.x0) * info.scale, py: (info.slice.y1 - Y) * info.scale }; +} + +/** Kartenpixel -> three (x, z). */ +export function mapToWorld(info, px, py) { + const X = info.slice.x0 + px / info.scale; + const Y = info.slice.y1 - py / info.scale; + return { x: X, z: -Y }; +} + +/** Ansicht, in der die ganze quadratische Karte zentriert ins Fenster passt. */ +export function fitView(mapSize, viewW, viewH) { + const scale = Math.min(viewW / mapSize, viewH / mapSize); + return { scale, offsetX: (viewW - mapSize * scale) / 2, offsetY: (viewH - mapSize * scale) / 2, minScale: scale }; +} + +/** Zoomt um den Bildschirmpunkt (sx, sy); begrenzt auf den ganzen Ausschnitt bis MAX_ZOOM. */ +export function zoomAt(view, factor, sx, sy) { + const scale = Math.min(view.minScale * MAX_ZOOM, Math.max(view.minScale, view.scale * factor)); + const k = scale / view.scale; + return { ...view, scale, offsetX: sx - (sx - view.offsetX) * k, offsetY: sy - (sy - view.offsetY) * k }; +} + +/** Kleinere Karte zentrieren, größere nie über ihren Rand hinaus verschieben. */ +export function clampView(view, mapSize, viewW, viewH) { + const content = mapSize * view.scale; + const axis = (offset, viewport) => + content <= viewport ? (viewport - content) / 2 : Math.min(0, Math.max(viewport - content, offset)); + return { ...view, offsetX: axis(view.offsetX, viewW), offsetY: axis(view.offsetY, 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; +} + +/** + * 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. + */ +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++) { + for (let ix = cx - reach; ix <= cx + reach; ix++) { + if (!walkable(ix, iy)) continue; + const d2 = (ix - cx) ** 2 + (iy - cy) ** 2; + if (d2 <= radiusPx * radiusPx && (!best || d2 < best.d2)) best = { ix, iy, d2 }; + } + } + return best ? { x: best.ix + 0.5, y: best.iy + 0.5 } : null; +} + +/** Zielpunkt eines Kartenklicks in three-Koordinaten, oder null ohne Landeplatz im Umkreis. */ +export function landingFor(info, walk, px, py) { + const toMask = info.walkable_scale / info.scale; + const hit = nearestWalkable(walk.mask, walk.width, walk.height, px * toMask, py * toMask, + LANDING_RADIUS_M * info.walkable_scale); + return hit ? mapToWorld(info, hit.x / toMask, hit.y / toMask) : null; +} + +/** Planquadrat-Name an einem Kartenpunkt, oder null außerhalb des Ausschnitts. */ +export function chunkNameAtMap(info, px, py) { + const { x, z } = mapToWorld(info, px, py); + const { X, Y } = threeToWorld(x, z); + const { i, j } = chunkForWorld(X, Y); + return chunkName(i, j); +} + +/** Anzeige für die Stelle unter dem Mauszeiger, etwa "C10 · x 412 / y 1830". */ +export function hoverLabel(info, px, py) { + const name = chunkNameAtMap(info, px, py); + if (!name) return null; + const { x, z } = mapToWorld(info, px, py); + const { X, Y } = threeToWorld(x, z); + return `${name} · x ${Math.round(X)} / y ${Math.round(Y)}`; +} + +/** Kartenwinkel aus einer three-Richtung (dx, dz): 0 = Norden, positiv im Uhrzeigersinn. */ +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) { + if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false; + return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag); +} + +/** + * SHA-256 als Hex, oder null ohne crypto.subtle. Das fehlt außerhalb eines sicheren Kontexts, + * etwa beim Aufruf über eine LAN-Adresse statt localhost — dann entfällt die Veraltungsprüfung still. + */ +export async function sha256Hex(buffer, subtle = globalThis.crypto?.subtle) { + if (!subtle) return null; + const digest = await subtle.digest("SHA-256", buffer); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} +``` + +- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/mapMath.test.js` +Expected: PASS, alle Tests der Datei. + +- [ ] **Step 5: Ganze Suite laufen lassen** + +Run: `npm test` +Expected: PASS, Ausgabe sauber. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/mapMath.js tests/mapMath.test.js +git commit -m "feat(ui): Kartenmathematik für die Minimap + +Co-Authored-By: Claude Opus 5 " -- src/ui/mapMath.js tests/mapMath.test.js +``` + +--- + +### Task 4: Kartenkomponente und Einhängen + +**Files:** +- Create: `src/ui/map.js` +- Modify: `src/main.js` (Importe; nach `createPanel(…)` die Karte anlegen) +- Modify: `src/player.js` (`activeVehicleOrNull` exportieren) +- Modify: `index.html` (`z-index` der Namensnennung) + +**Interfaces:** +- Consumes: alles aus `src/ui/mapMath.js` (Task 3); `chunkName`, `parseChunkName`, `CHUNK_SIZE`, `CHUNKS_PER_SIDE` aus `src/world/coords.js` (Task 1); die Kartendateien aus Task 2; in `main.js` vorhanden: `player`, `camera`, `requestTeleport(position)`, `playerPosition(player)` +- Produces: `createMap({ getPosition, getHeading, getVehicles, onTeleport, baseUrl }) -> { open(), close(), toggle(), isOpen() }` + +Diese Aufgabe hat keine neuen Unit-Tests: vitest läuft ohne DOM, und alles Rechnerische liegt bereits getestet in `mapMath.js`. Die Abnahme ist die Browser-Prüfung in Step 6. + +- [ ] **Step 1: `activeVehicleOrNull` in `src/player.js` exportieren** + +In `src/player.js` die Zeile `function activeVehicleOrNull(player) {` ersetzen durch: + +```js +export function activeVehicleOrNull(player) { +``` + +Die Funktion und ihr Kommentar bleiben sonst unverändert. + +- [ ] **Step 2: Namensnennung in `index.html` über die Karte heben** + +In der Regel `.attribution` die Zeile `z-index: 10;` ersetzen durch: + +```css + /* Über allem, auch über der Minimap (1500) und lil-gui (1001): Lizenzpflicht, und die + Karte ist selbst aus den CC-BY-4.0-Kacheln abgeleitet. */ + z-index: 2000; +``` + +- [ ] **Step 3: `src/ui/map.js` schreiben** + +```js +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, +} from "./mapMath.js"; + +/** + * Große Kartenansicht auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md). + * Dünne Canvas-Schicht; alles Rechnerische steht getestet in mapMath.js. + * + * Eingaben: InputSystem des Controllers hört auf window in der Bubble-Phase. Die Karte hört auf + * window in der Capture-Phase und wirkt nur bei offener Karte: Tasten fängt sie dort vollständig + * ab, Mausereignisse außerhalb der Karte ebenfalls. Mausereignisse auf der Karte erreichen den + * Canvas und werden an der Kartenwurzel gestoppt, bevor sie window erreichen. unbindEvents() wäre + * falsch: es setzt isupdate = false und hielte die ganze Figur an, nicht nur die Eingabe. + */ + +const STYLE = ` +.leonida-map { position: fixed; inset: 0; z-index: 1500; background: rgba(10, 18, 28, 0.94); } +.leonida-map[hidden] { display: none; } +.leonida-map canvas { display: block; width: 100%; height: 100%; cursor: crosshair; } +.leonida-map-note, .leonida-map-readout, .leonida-map-here { + position: absolute; padding: 6px 10px; border-radius: 6px; pointer-events: none; + font: 14px/1.3 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + color: #fff; background: rgba(0, 0, 0, 0.6); +} +.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-note:empty, .leonida-map-readout: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_NO_LANDING = "Kein Landeplatz"; + +function loadImage(url) { + const image = new Image(); + image.src = url; + return image.decode().then(() => image); +} + +/** Graustufenmaske als Uint8Array; Schwelle statt Gleichheit, falls der Browser Farben leicht verschiebt. */ +function readMask(image) { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const mask = new Uint8Array(canvas.width * canvas.height); + for (let k = 0; k < mask.length; k++) mask[k] = rgba[k * 4] >= 128 ? 255 : 0; + return { mask, width: canvas.width, height: canvas.height }; +} + +function drawArrow(ctx, x, y, heading, dpr) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(heading); + ctx.beginPath(); + ctx.moveTo(0, -12 * dpr); + ctx.lineTo(8 * dpr, 9 * dpr); + ctx.lineTo(0, 4 * dpr); + ctx.lineTo(-8 * dpr, 9 * dpr); + ctx.closePath(); + ctx.fillStyle = "#ffd84a"; + ctx.strokeStyle = "#141414"; + ctx.lineWidth = 2 * dpr; + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +function drawVehicle(ctx, x, y, heading, dpr) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(heading); + ctx.fillStyle = "#4ad0ff"; + ctx.strokeStyle = "#0b2a3a"; + ctx.lineWidth = 1.5 * dpr; + ctx.fillRect(-4 * dpr, -7 * dpr, 8 * dpr, 14 * dpr); + ctx.strokeRect(-4 * dpr, -7 * dpr, 8 * dpr, 14 * dpr); + ctx.restore(); +} + +export function createMap({ getPosition, getHeading, getVehicles, onTeleport, baseUrl = "./data/vice-a" }) { + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + + const root = document.createElement("div"); + root.className = "leonida-map"; + root.hidden = true; + const canvas = document.createElement("canvas"); + const note = document.createElement("div"); + note.className = "leonida-map-note"; + const here = document.createElement("div"); + here.className = "leonida-map-here"; + const readout = document.createElement("div"); + readout.className = "leonida-map-readout"; + root.append(canvas, note, here, readout); + document.body.appendChild(root); + + const state = { + open: false, info: null, picture: null, walk: null, failed: false, noMask: false, stale: false, + view: null, dpr: 1, drag: null, hover: null, flashText: "", flashUntil: 0, frame: 0, + }; + let loading = null; + + function load() { + loading ??= (async () => { + 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 { + state.failed = true; + return; + } + try { + state.walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`)); + } catch { + state.noMask = true; + } + try { + const bytes = await fetch(`${baseUrl}/manifest.json`).then((r) => r.arrayBuffer()); + const hash = await sha256Hex(bytes); + state.stale = hash !== null && hash !== state.info.manifest_sha256; + } catch { + // Manifest nicht lesbar: ohne Vergleichswert keine Aussage über Veraltung. + } + })(); + return loading; + } + + const mapSize = () => state.picture.naturalWidth; + + function resize() { + state.dpr = Math.min(devicePixelRatio, 2); + canvas.width = Math.round(innerWidth * state.dpr); + canvas.height = Math.round(innerHeight * state.dpr); + if (state.picture) state.view = fitView(mapSize(), canvas.width, canvas.height); + } + + const toCanvas = (event) => ({ x: event.offsetX * state.dpr, y: event.offsetY * state.dpr }); + + function flash(text) { + state.flashText = text; + state.flashUntil = performance.now() + 2000; + } + + function draw() { + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const { info, picture, view, dpr } = state; + if (state.failed) { + note.textContent = NOTE_MISSING; + here.textContent = ""; + readout.textContent = ""; + return; + } + if (!picture || !view) { + note.textContent = "Karte lädt …"; + return; + } + + ctx.imageSmoothingEnabled = view.scale < 1; + ctx.drawImage(picture, view.offsetX, view.offsetY, mapSize() * view.scale, mapSize() * view.scale); + + const chunkPx = CHUNK_SIZE * info.scale * view.scale; + const gridSize = CHUNKS_PER_SIDE * chunkPx; + ctx.strokeStyle = "rgba(255, 255, 255, 0.35)"; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let k = 0; k <= CHUNKS_PER_SIDE; k++) { + ctx.moveTo(view.offsetX + k * chunkPx, view.offsetY); + ctx.lineTo(view.offsetX + k * chunkPx, view.offsetY + gridSize); + ctx.moveTo(view.offsetX, view.offsetY + k * chunkPx); + ctx.lineTo(view.offsetX + gridSize, view.offsetY + k * chunkPx); + } + ctx.stroke(); + + const position = getPosition(); + const me = worldToMap(info, position.x, position.z); + const hereName = chunkNameAtMap(info, me.px, me.py); + + if (chunkPx / dpr >= LABEL_MIN_CHUNK_PX) { + ctx.font = `600 ${Math.round(Math.min(chunkPx * 0.2, 26 * dpr))}px system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + for (let j = 0; j < CHUNKS_PER_SIDE; j++) { + for (let i = 0; i < CHUNKS_PER_SIDE; i++) { + const name = chunkName(i, j); + ctx.fillStyle = name === hereName ? "rgba(255, 216, 74, 0.95)" : "rgba(255, 255, 255, 0.6)"; + ctx.fillText(name, view.offsetX + (i + 0.5) * chunkPx, view.offsetY + (j + 0.5) * chunkPx); + } + } + } + if (hereName) { + const { i, j } = parseChunkName(hereName); + ctx.strokeStyle = "rgba(255, 216, 74, 0.9)"; + ctx.lineWidth = 2 * dpr; + ctx.strokeRect(view.offsetX + i * chunkPx, view.offsetY + j * chunkPx, chunkPx, chunkPx); + } + + for (const vehicle of getVehicles()) { + if (vehicle.occupied) continue; + const m = worldToMap(info, vehicle.x, vehicle.z); + const s = mapToScreen(view, m.px, m.py); + drawVehicle(ctx, s.sx, s.sy, vehicle.heading, dpr); + } + const s = mapToScreen(view, me.px, me.py); + drawArrow(ctx, s.sx, s.sy, getHeading(), dpr); + + here.textContent = hereName ? `Du bist in ${hereName}` : "Außerhalb des Ausschnitts"; + readout.textContent = state.hover ?? ""; + note.textContent = performance.now() < state.flashUntil ? state.flashText + : state.stale ? NOTE_STALE + : state.noMask ? NOTE_NO_MASK + : ""; + } + + function loop() { + if (!state.open) return; + draw(); + state.frame = requestAnimationFrame(loop); + } + + async function open() { + if (state.open) return; + state.open = true; + root.hidden = false; + document.exitPointerLock?.(); + // 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")); + resize(); + loop(); + await load(); + if (state.open) resize(); // beim Öffnen immer der ganze Ausschnitt, auch nach dem ersten Laden + } + + function close() { + state.open = false; + root.hidden = true; + state.drag = null; + state.hover = null; + cancelAnimationFrame(state.frame); + } + + function jumpTo(point) { + if (!state.walk) { + flash(NOTE_NO_MASK); + return; + } + const m = screenToMap(state.view, point.x, point.y); + const target = landingFor(state.info, state.walk, m.px, m.py); + if (!target) { + flash(NOTE_NO_LANDING); + return; + } + close(); + onTeleport(target); + } + + // —— Eingaben —— + function onKeyCapture(event) { + const typing = document.activeElement?.tagName ?? ""; + if (state.open) { + if (event.type === "keydown" && (event.code === "Escape" || isMapToggleKey(event, typing))) close(); + event.stopPropagation(); + return; + } + if (event.type === "keydown" && isMapToggleKey(event, typing)) { + event.stopPropagation(); + void open(); + } + } + window.addEventListener("keydown", onKeyCapture, true); + window.addEventListener("keyup", onKeyCapture, true); + + for (const type of ["mousedown", "mouseup", "mousemove", "click", "wheel"]) { + window.addEventListener(type, (event) => { + if (state.open && !root.contains(event.target)) event.stopPropagation(); + }, true); + root.addEventListener(type, (event) => event.stopPropagation()); + } + + canvas.addEventListener("wheel", (event) => { + 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); + 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 }; + }); + + canvas.addEventListener("mousemove", (event) => { + if (!state.view) return; + const p = toCanvas(event); + const m = screenToMap(state.view, p.x, p.y); + state.hover = hoverLabel(state.info, m.px, m.py); + if (!state.drag) return; + 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); + state.drag.last = p; + }); + + canvas.addEventListener("mouseup", (event) => { + if (!state.drag) return; + const p = toCanvas(event); + const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr); + if (click) state.view = state.drag.view; // ein Klick soll die Karte nicht verschieben + state.drag = null; + if (click) jumpTo(p); + }); + + canvas.addEventListener("mouseleave", () => { + state.drag = null; + state.hover = null; + }); + + addEventListener("resize", () => { + if (state.open) resize(); + }); + + return { open, close, toggle: () => (state.open ? close() : open()), isOpen: () => state.open }; +} +``` + +- [ ] **Step 4: In `src/main.js` einhängen** + +Importe ergänzen — die bestehende Zeile `import { playerIsGrounded, playerPosition } from "./player.js";` ersetzen und zwei Zeilen anfügen: + +```js +import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js"; +import { createMap } from "./ui/map.js"; +import { headingFromDirection } from "./ui/mapMath.js"; +``` + +Direkt nach der Zeile `createPanel({ player, characterModule, scene, manifest, requestTeleport });` einfügen: + +```js +// Minimap auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md). Position über +// playerPosition — im Fahrzeug liefert die Kapsel nur den lokalen Sitzplatz-Offset. Die Richtung +// kommt aus der Kamera, die zu Fuß der Blickrichtung und im Fahrzeug dessen Richtung folgt. +// Sprünge laufen über requestTeleport und damit über dessen Wartewache bis zum fertigen Chunk. +const MAP_TELEPORT_HEIGHT = 3; +const mapCameraDirection = new Vector3(); +const mapVehicleForward = new Vector3(); +createMap({ + getPosition: () => playerPosition(player), + getHeading: () => { + camera.getWorldDirection(mapCameraDirection); + return headingFromDirection(mapCameraDirection.x, mapCameraDirection.z); + }, + getVehicles: () => { + const riding = activeVehicleOrNull(player); + return player.getAllVehicles().map((vehicle) => { + mapVehicleForward.copy(vehicle.forwardLocal).transformDirection(vehicle.vehicleGroup.matrixWorld); + return { + x: vehicle.vehicleGroup.position.x, + z: vehicle.vehicleGroup.position.z, + heading: headingFromDirection(mapVehicleForward.x, mapVehicleForward.z), + occupied: vehicle === riding, + }; + }); + }, + onTeleport: ({ x, z }) => requestTeleport({ x, y: MAP_TELEPORT_HEIGHT, z }), +}); +``` + +- [ ] **Step 5: Suite laufen lassen** + +Run: `npm test` +Expected: PASS, Ausgabe sauber. (Keine neuen Tests; die Suite belegt, dass nichts Bestehendes gebrochen ist.) + +- [ ] **Step 6: Im Browser prüfen — das ist die Abnahme dieser Aufgabe** + +Dev-Server starten, Seite öffnen, jeweils mit Screenshot im Bericht: + +1. **M** öffnet die Karte: ganzer Ausschnitt, Raster, Namen A1–L12, A1 oben links, gelber Pfeil, „Du bist in …" oben links. Die Namensnennung ist unten links weiterhin sichtbar. +2. Zeiger über der Karte bewegen: unten rechts erscheint `Name · x … / y …`. Den Zeiger über den Downtown-Kern führen — dort muss `B9` stehen. +3. Mausrad zoomt um den Zeiger bis vierfach; Ziehen verschiebt und stoppt am Kartenrand. +4. Ein Eingabefeld im Panel anklicken, dann M drücken: die Karte darf **nicht** aufgehen. +5. W gedrückt halten, M drücken, W loslassen, Karte schließen: die Figur darf **nicht** weiterlaufen. +6. Klick auf eine Straße: Karte schließt, Sprung genau dorthin. Klick mitten auf ein großes Gebäude in B9: Landung auf der Straße daneben. Klick mitten in die Bucht: „Kein Landeplatz", die Karte bleibt offen. +7. Mit **E** in ein Auto steigen, fahren, Karte öffnen: Pfeil an der Fahrzeugposition, das eigene Auto nicht zusätzlich als Symbol, die übrigen Autos als Symbole. +8. `public/data/vice-a/map.json` vorübergehend umbenennen, Seite neu laden, M: Hinweis „Kartenbild fehlt". Datei zurückbenennen. +9. In `public/data/vice-a/map.json` vorübergehend eine Ziffer von `manifest_sha256` ändern, neu laden, M: „Karte veraltet". Datei zurücksetzen. + +Bekannte Eigenheiten der Vorschau, die keine Fehler dieses Codes sind: Pointer-Lock schlägt im iframe fehl (`WrongDocumentError`), und die Animationsschleife der Seite kann ruhen, solange das Vorschaufenster verborgen ist. Wer für Screenshots einen temporären `window`-Haken setzt, entfernt ihn vor dem Commit und liest **den vollständigen Diff** vor dem Commit. + +- [ ] **Step 7: Commit** + +```bash +git add src/ui/map.js src/main.js src/player.js index.html +git commit -m "feat(ui): Minimap auf Taste M mit Chunk-Raster, Koordinaten und Klick zum Hinspringen + +Co-Authored-By: Claude Opus 5 " -- src/ui/map.js src/main.js src/player.js index.html +``` diff --git a/docs/superpowers/specs/2026-09-13-minimap-design.md b/docs/superpowers/specs/2026-09-13-minimap-design.md index 13cd1c8..7ee20c0 100644 --- a/docs/superpowers/specs/2026-09-13-minimap-design.md +++ b/docs/superpowers/specs/2026-09-13-minimap-design.md @@ -46,15 +46,16 @@ Die Kartenkomponente bekommt ihre Abhängigkeiten übergeben, statt selbst in Co ```js createMap({ - mapInfo, // Inhalt von map.json - manifest, // für den Fingerabdruck-Vergleich getPosition, // () => { x, z } in three-Koordinaten - getHeading, // () => Winkel in Radiant + getHeading, // () => Winkel in Radiant, 0 = Norden, im Uhrzeigersinn getVehicles, // () => [{ x, z, heading, occupied }] onTeleport, // ({ x, z }) => void + baseUrl, // Verzeichnis mit map.json, map.png, map-walkable.png und manifest.json }) // -> { open(), close(), toggle(), isOpen() } ``` +Die Karte lädt `map.json`, beide Bilder und das Manifest selbst, und zwar erst beim ersten Öffnen. So bricht eine fehlende Kartendatei nicht den Spielstart, sondern zeigt ihren Hinweis erst, wenn jemand M drückt. Das Manifest liest sie als **Rohbytes**: der Fingerabdruck in `map.json` ist über die Datei gebildet, ein bereits geparstes und neu serialisiertes Objekt ergäbe einen anderen Wert. + ## Koordinaten three.js (x, z) → Welt (x, y = −z) → Kartenpixel: @@ -91,7 +92,7 @@ name(i, j) = "ABCDEFGHIJKL"[i] + (j + 1) **Auf der Karte:** - Rasterlinien an allen Chunk-Grenzen. - Der Name steht in der Mitte jedes Chunks, sobald der Chunk auf dem Bildschirm mindestens 40 px breit ist; darunter nur die Linien. Beim Öffnen passt der ganze Ausschnitt ins Fenster, dann ist jeder Chunk auf gängigen Bildschirmen breit genug. -- Unter dem Mauszeiger eine Anzeige mit Chunk-Name und Weltkoordinate, etwa `C10 · x 412 / y 1830`. Die Koordinate erlaubt, eine Stelle innerhalb eines Chunks genau zu benennen. +- Eine feste Anzeige unten rechts nennt Chunk-Name und Weltkoordinate der Stelle unter dem Mauszeiger, etwa `C10 · x 412 / y 1830`. Die Koordinate erlaubt, eine Stelle innerhalb eines Chunks genau zu benennen. - Der Chunk, in dem der Spieler gerade steht, ist hervorgehoben und sein Name steht fest in einer Ecke der Karte. ## Anbindung an vorhandene Funktionen @@ -109,7 +110,8 @@ name(i, j) = "ABCDEFGHIJKL"[i] + (j + 1) - **Steuerung ruht, Welt läuft.** Solange die Karte offen ist, erreichen Tastatur- und Mausereignisse die Figur nicht; Streaming, Fahrzeugphysik und Animation laufen weiter. Umsetzung, am Quelltext des Controllers belegt: `InputSystem` registriert `keydown`, `keyup`, `mousemove` und `click` auf `window` in der Bubble-Phase. - - Die Karte registriert beim Öffnen Listener auf `window` **in der Capture-Phase**, die diese Ereignisse mit `stopPropagation()` abfangen, und entfernt sie beim Schließen wieder. Die eigenen Kartenereignisse auf dem Canvas bleiben davon unberührt. + - Die Karte registriert Listener auf `window` **in der Capture-Phase**, die nur bei offener Karte wirken. Tastaturereignisse fängt sie dort vollständig ab (und wertet M und Esc selbst aus). Mausereignisse, deren Ziel **außerhalb** der Karte liegt, fängt sie dort ab; Mausereignisse **auf** der Karte lässt sie bis zum Canvas durch und stoppt sie an der Kartenwurzel in der Bubble-Phase, bevor sie `window` erreichen. Ein Stopp schon in der Capture-Phase auf `window` würde sonst auch den eigenen Canvas-Listenern die Ereignisse nehmen. + - Die Listener bleiben dauerhaft registriert statt beim Öffnen hinzugefügt und beim Schließen entfernt zu werden: M muss bei geschlossener Karte ohnehin gehört werden, und ein vergessenes Entfernen kann so nicht passieren. - Beim Öffnen wird einmal `window.dispatchEvent(new Event("blur"))` ausgelöst. Das ruft im Controller `resetKeys()` auf, damit eine beim Drücken von M gehaltene Bewegungstaste nicht hängen bleibt und die Figur weiterlaufen lässt. - **Nicht** `unbindEvents()` verwenden: es setzt `isupdate = false` und hält damit die gesamte Figurenaktualisierung an, nicht nur die Eingabe. Das widerspräche der Entscheidung, dass die Welt weiterläuft. - **Zoomen:** Mausrad, um den Mauszeiger herum, von „ganzer Ausschnitt passt ins Fenster" bis vierfach. @@ -128,6 +130,22 @@ name(i, j) = "ABCDEFGHIJKL"[i] + (j + 1) | `map-walkable.png` lässt sich nicht laden | Anschauen, Zoomen und Verschieben funktionieren. Klick zum Hinspringen ist abgeschaltet, ein Hinweis nennt den Grund. | | `map.json` fehlt oder ist ungültig | Wie fehlendes Kartenbild: ohne Maßstab und Grenzen lässt sich nichts korrekt platzieren. | | `manifest_sha256` passt nicht zum geladenen Manifest | Die Karte funktioniert vollständig, zeigt aber sichtbar „Karte veraltet — `overview.py` erneut ausführen". | +| Kein sicherer Kontext (Aufruf über eine LAN-Adresse statt `localhost`) | `crypto.subtle` fehlt dort. Die Veraltungsprüfung entfällt still, alles andere funktioniert. | + +**Grenze der Veraltungsprüfung:** Der Fingerabdruck erkennt jede Änderung am Manifest — Klassenanteile, Chunk-Liste, Namensnennung. Eine Änderung, die nur Chunk-Inhalte betrifft, ohne das Manifest zu berühren, bleibt unerkannt; genau so eine war die Mindestbreite für Gebäude. Diese Lücke schließt sich erst, wenn die Kartenerzeugung in `run.py` hängt und damit gar nicht mehr veralten kann. + +## Namensnennung + +Die Namensnennung bleibt auch bei offener Karte sichtbar. Das ist Lizenzpflicht, und die Karte ist selbst aus den CC-BY-4.0-Kacheln abgeleitet. Stapelordnung von oben nach unten: + +| Ebene | `z-index` | +|---|---| +| Namensnennung `.attribution` | 2000 (heute 10, wird angehoben) | +| Kartenüberlagerung | 1500 | +| lil-gui-Panel | 1001 (Vorgabe von lil-gui) | +| Spiel-Canvas | Grundebene | + +Die Namensnennung steht unten links; die Koordinatenanzeige der Karte steht deshalb unten **rechts**, damit sich beide auf schmalen Fenstern nicht überdecken. ## Prüfung