# 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` > **Korrektur nach Task 4 (Ruling M7):** `(−511, 254)` ist der Bezugspunkt „Downtown“ aus `tools/extract/data/districts.json` — der Mittelpunkt der westlichen Hochhäuser —, nicht der Hochhauskern selbst. Der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, steht in C12. Wortlaut, zwei Testtitel und das Anzeigebeispiel `F3 · x 412 / y 1830` (vorher fälschlich mit C10) sind unten angepasst. ## 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-Bezugspunkt aus `districts.json` Welt `(−511, 254)` = B9; höchster Turm, Southeast Financial Center `(−461, −460)` = C12. - 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: Downtown-Bezugspunkt in B9, höchster Turm in C12", () => { // (−511, 254) ist der Bezugspunkt "Downtown" aus tools/extract/data/districts.json, der Mittelpunkt // der westlichen Hochhäuser. Die Türme selbst stehen nördlich und südlich davon. const { i, j } = chunkForWorld(-511, 254); expect({ i, j }).toEqual({ i: 1, j: 8 }); expect(chunkName(i, j)).toBe("B9"); const tower = chunkForWorld(-461.31, -460.1); // Southeast Financial Center, 248 m expect(chunkName(tower.i, tower.j)).toBe("C12"); }); }); ``` - [ ] **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 Downtown-Bezugspunkt B9 und am Southeast Financial Center C12", () => { expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9"); expect(chunkNameAtMap(INFO, 538.69, 2960.1)).toBe("C12"); }); 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 "F3 · 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-Bezugspunkt Welt `(−511, 254)` führen — dort muss `B9` stehen; über dem Southeast Financial Center `C12`. 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 ```