Files
gta6/docs/superpowers/plans/2026-09-13-minimap.md
T
knsoandClaude Opus 5 61ef30f081 docs: Downtown-Bezugspunkt statt Downtown-Kern, stimmige Koordinatenbeispiele
(−511, 254) ist der Bezugspunkt aus tools/extract/data/districts.json, der
Mittelpunkt der westlichen Hochhäuser, und liegt in B9. Der höchste Turm,
das Southeast Financial Center, steht in C12. Das Anzeigebeispiel
x 412 / y 1830 liegt in F3, nicht in C10. Im Browser bei Task 4 der
Minimap aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:36:59 +02:00

1323 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 "…" -- <pfade>`.
- **`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 <noreply@anthropic.com>" -- 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": "<hex>" }`
- [ ] **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 <noreply@anthropic.com>" -- 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<string | null>`
- `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 <noreply@anthropic.com>" -- 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 <noreply@anthropic.com>" -- src/ui/map.js src/main.js src/player.js index.html
```