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

57 KiB
Raw Blame History

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:

{
  "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:

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
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:

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:

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
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:

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
"""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
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:

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
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
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:

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:

            /* Ü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
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:

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:

// 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
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