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