Vier Aufgaben: Chunk-Namen in JavaScript und Python mit gemeinsamem Beispielsatz; Kartenbild, Begehbarkeitsmaske und map.json aus overview.py; reine Kartenmathematik mit Tests; Kartenkomponente und Einhängen mit Browser-Abnahme. Die Spec bekommt dabei drei Präzisierungen, die sich erst beim Lesen des Codes ergeben haben: die Namensnennung muss über der Karte liegen (Lizenzpflicht), die Veraltungsprüfung erkennt nicht jede Änderung, und die Eingabesperre stoppt Mausereignisse auf der Karte erst an deren Wurzel, weil ein Stopp in der Capture-Phase auch den eigenen Canvas taub machen würde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
56 KiB
Minimap Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Auf Taste M öffnet sich über dem laufenden Spiel eine große Karte des Vice-City-Ausschnitts mit benanntem Chunk-Raster (A1–L12), Koordinatenanzeige, Spielerpfeil, Fahrzeugen, Zoomen, Verschieben und Klick zum Hinspringen.
Architecture: tools/extract/overview.py erzeugt aus den ausgelieferten Chunk-Dateien ein Kartenbild (1 px/m), eine Begehbarkeitsmaske (4 m/px) und map.json. Im Browser rechnet src/ui/mapMath.js rein funktional zwischen Welt, Karte, Maske und Bildschirm um; src/ui/map.js ist eine dünne Canvas-Überlagerung darauf, eingehängt in src/main.js über die vorhandenen Funktionen playerPosition, getAllVehicles und requestTeleport.
Tech Stack: three 0.184.0, Vite, vitest (Node-Umgebung, kein jsdom) · Python 3.12 über uv mit Pillow und pytest
Spec: docs/superpowers/specs/2026-09-13-minimap-design.md
Global Constraints
- three 0.184.0; keine neue Laufzeit- oder Testabhängigkeit. vitest läuft ohne DOM — alles, was ohne Browser getestet werden soll, gehört in reine Funktionen in
mapMath.js;map.jswird im Browser geprüft. - Python-Bibliotheken: numpy, scipy, scikit-image, shapely, networkx, pillow, pytest.
- Niemals
git add three-player-controller, niemalsgit add -A. Das Submodul bleibt aufac39682;git statuszeigt es als geändert, das ist ein gewollter Zustand. Commits nur mit ausdrücklichen Pfaden:git commit -m "…" -- <pfade>. tools/extract/run.pywird nicht angefasst. Eine parallele Sitzung arbeitet an der Pipeline. Die Kartendateien entstehen überoverview.py.- Chunk-Namen: Spalte
A–Lausi(West → Ost), Zeile1–12ausj(Nord → Süd). A1 =chunk_0_0= Nordwest-Ecke. C10 =chunk_2_9. Downtown-Kern Welt(−511, 254)= B9. - Koordinaten: three
(x, z)→ Welt(X = x, Y = −z)→ Kartenpixelpx = (X − x0) · scale,py = (y1 − Y) · scale, mitx0 = −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.png3.000 × 3.000,map-walkable.png750 × 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:
.attribution2000 > 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,chunkForWorldaussrc/world/coords.js -
Produces:
chunkName(i: number, j: number) -> string | null(JavaScript) —nullaußerhalb von 0–11parseChunkName(name: string) -> { i: number, j: number } | null(JavaScript)chunk_name(i: int, j: int) -> str | Noneundparse_chunk_name(name: str) -> tuple[int, int] | None(Python)tests/fixtures/chunk-names.jsonals 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: der Downtown-Kern liegt in B9", () => {
const { i, j } = chunkForWorld(-511, 254);
expect({ i, j }).toEqual({ i: 1, j: 8 });
expect(chunkName(i, j)).toBe("B9");
});
});
- Step 3: JavaScript-Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/coords.test.js
Expected: FAIL — chunkName is not a function (bzw. Import schlägt fehl).
- Step 4:
chunkNameundparseChunkNameinsrc/world/coords.jsanfü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_nameundparse_chunk_nameintools/extract/leonida/coords.pyanfü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:
SLICEausleonida.coords; Chunk-Dateien undmanifest.jsoninpublic/data/vice-a/ -
Produces:
render_map(out_dir: Path, dst_dir: Path) -> dict— schreibt die drei Dateienrender(out_dir: Path, dst: Path) -> dict— Debug-Gesamtansicht, Ausgabe bytegleich zu vorhermap.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.pyvollstä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,threeToWorldaussrc/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) -> viewscreenToMap(view, sx, sy) -> { px, py }·mapToScreen(view, px, py) -> { sx, sy }isClick(down, up, tolerance) -> booleanmitdown/upals{ x, y }nearestWalkable(mask, width, height, x, y, radiusPx) -> { x, y } | nullin MaskenkoordinatenlandingFor(info, walk, px, py) -> { x, z } | nullmitwalk = { mask, width, height }chunkNameAtMap(info, px, py) -> string | null·hoverLabel(info, px, py) -> string | nullheadingFromDirection(dx, dz) -> number(0 = Norden, im Uhrzeigersinn)isMapToggleKey(event, activeTag) -> booleansha256Hex(buffer, subtle = globalThis.crypto?.subtle) -> Promise<string | null>infohat die Form vonmap.json
- Konstanten
-
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 Kartenpunkt des Downtown-Kerns B9", () => {
expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9");
});
it("liefert am östlichen Kartenrand keinen Namen mehr", () => {
expect(chunkNameAtMap(INFO, 3000, 10)).toBeNull();
});
it("zeigt Chunk und Weltkoordinate im Format der Spec", () => {
expect(hoverLabel(INFO, 489, 2246)).toBe("B9 · x -511 / y 254");
});
});
describe("Richtung und Tasten", () => {
it("misst die Richtung ab Norden im Uhrzeigersinn", () => {
expect(headingFromDirection(0, -1)).toBeCloseTo(0, 9);
expect(headingFromDirection(1, 0)).toBeCloseTo(Math.PI / 2, 9);
expect(Math.abs(headingFromDirection(0, 1))).toBeCloseTo(Math.PI, 9);
expect(headingFromDirection(-1, 0)).toBeCloseTo(-Math.PI / 2, 9);
});
it("schaltet mit M, aber nicht beim Tippen, nicht bei Wiederholung und nicht mit Strg", () => {
const key = (extra) => ({ code: "KeyM", repeat: false, ctrlKey: false, metaKey: false, altKey: false, ...extra });
expect(isMapToggleKey(key({}), "BODY")).toBe(true);
expect(isMapToggleKey(key({}), "INPUT")).toBe(false);
expect(isMapToggleKey(key({}), "SELECT")).toBe(false);
expect(isMapToggleKey(key({ repeat: true }), "BODY")).toBe(false);
expect(isMapToggleKey(key({ ctrlKey: true }), "BODY")).toBe(false);
expect(isMapToggleKey(key({ code: "KeyN" }), "BODY")).toBe(false);
});
});
describe("Fingerabdruck", () => {
it("liefert SHA-256 als Hex", async () => {
expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
});
it("liefert null ohne sicheren Kontext, statt zu werfen", async () => {
expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull();
});
});
- Step 2: Tests laufen lassen, sie müssen scheitern
Run: npx vitest run tests/mapMath.test.js
Expected: FAIL — Failed to load url ../src/ui/mapMath.js.
- Step 3:
src/ui/mapMath.jsschreiben
import { chunkForWorld, chunkName, threeToWorld } from "../world/coords.js";
/**
* Reine Kartenmathematik für die Minimap (docs/superpowers/specs/2026-09-13-minimap-design.md).
* Ohne DOM, damit vitest sie ohne Browser prüfen kann. `info` hat die Form von map.json.
*
* Koordinatensysteme:
* - three (x, z): Szene; Norden ist −z.
* - Welt (X, Y): X = x, Y = −z.
* - Karte (px, py): Pixel in map.png, Ursprung Nordwest, py wächst nach Süden.
* - Maske: Pixel in map-walkable.png, gleiche Ausrichtung, anderer Maßstab.
* - Bildschirm (sx, sy): Canvas-Pixel; Ansicht = { scale, offsetX, offsetY, minScale }.
*/
export const CLICK_TOLERANCE_PX = 4;
export const MAX_ZOOM = 4;
export const LABEL_MIN_CHUNK_PX = 40;
export const LANDING_RADIUS_M = 20;
/** three (x, z) -> Kartenpixel. Die einzige Stelle der Achsenumkehr zwischen three-z und Karten-y. */
export function worldToMap(info, x, z) {
const { X, Y } = threeToWorld(x, z);
return { px: (X - info.slice.x0) * info.scale, py: (info.slice.y1 - Y) * info.scale };
}
/** Kartenpixel -> three (x, z). */
export function mapToWorld(info, px, py) {
const X = info.slice.x0 + px / info.scale;
const Y = info.slice.y1 - py / info.scale;
return { x: X, z: -Y };
}
/** Ansicht, in der die ganze quadratische Karte zentriert ins Fenster passt. */
export function fitView(mapSize, viewW, viewH) {
const scale = Math.min(viewW / mapSize, viewH / mapSize);
return { scale, offsetX: (viewW - mapSize * scale) / 2, offsetY: (viewH - mapSize * scale) / 2, minScale: scale };
}
/** Zoomt um den Bildschirmpunkt (sx, sy); begrenzt auf den ganzen Ausschnitt bis MAX_ZOOM. */
export function zoomAt(view, factor, sx, sy) {
const scale = Math.min(view.minScale * MAX_ZOOM, Math.max(view.minScale, view.scale * factor));
const k = scale / view.scale;
return { ...view, scale, offsetX: sx - (sx - view.offsetX) * k, offsetY: sy - (sy - view.offsetY) * k };
}
/** Kleinere Karte zentrieren, größere nie über ihren Rand hinaus verschieben. */
export function clampView(view, mapSize, viewW, viewH) {
const content = mapSize * view.scale;
const axis = (offset, viewport) =>
content <= viewport ? (viewport - content) / 2 : Math.min(0, Math.max(viewport - content, offset));
return { ...view, offsetX: axis(view.offsetX, viewW), offsetY: axis(view.offsetY, viewH) };
}
export const screenToMap = (view, sx, sy) => ({ px: (sx - view.offsetX) / view.scale, py: (sy - view.offsetY) / view.scale });
export const mapToScreen = (view, px, py) => ({ sx: px * view.scale + view.offsetX, sy: py * view.scale + view.offsetY });
/** Kurzer Klick statt Ziehen: Bewegung zwischen Drücken und Loslassen unter der Toleranz. */
export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX) {
return Math.hypot(up.x - down.x, up.y - down.y) < tolerance;
}
/**
* Nächster begehbarer Maskenpixel (Wert 255) im Umkreis radiusPx, euklidisch gemessen.
* Ist der Pixel unter (x, y) selbst begehbar, bleibt der Punkt genau, wo er ist; sonst die Mitte
* des nächsten begehbaren Pixels. Gleich weite Kandidaten: der erste in Zeilenreihenfolge.
*/
export function nearestWalkable(mask, width, height, x, y, radiusPx) {
const cx = Math.floor(x), cy = Math.floor(y);
const walkable = (ix, iy) => ix >= 0 && iy >= 0 && ix < width && iy < height && mask[iy * width + ix] === 255;
if (walkable(cx, cy)) return { x, y };
const reach = Math.ceil(radiusPx);
let best = null;
for (let iy = cy - reach; iy <= cy + reach; iy++) {
for (let ix = cx - reach; ix <= cx + reach; ix++) {
if (!walkable(ix, iy)) continue;
const d2 = (ix - cx) ** 2 + (iy - cy) ** 2;
if (d2 <= radiusPx * radiusPx && (!best || d2 < best.d2)) best = { ix, iy, d2 };
}
}
return best ? { x: best.ix + 0.5, y: best.iy + 0.5 } : null;
}
/** Zielpunkt eines Kartenklicks in three-Koordinaten, oder null ohne Landeplatz im Umkreis. */
export function landingFor(info, walk, px, py) {
const toMask = info.walkable_scale / info.scale;
const hit = nearestWalkable(walk.mask, walk.width, walk.height, px * toMask, py * toMask,
LANDING_RADIUS_M * info.walkable_scale);
return hit ? mapToWorld(info, hit.x / toMask, hit.y / toMask) : null;
}
/** Planquadrat-Name an einem Kartenpunkt, oder null außerhalb des Ausschnitts. */
export function chunkNameAtMap(info, px, py) {
const { x, z } = mapToWorld(info, px, py);
const { X, Y } = threeToWorld(x, z);
const { i, j } = chunkForWorld(X, Y);
return chunkName(i, j);
}
/** Anzeige für die Stelle unter dem Mauszeiger, etwa "C10 · x 412 / y 1830". */
export function hoverLabel(info, px, py) {
const name = chunkNameAtMap(info, px, py);
if (!name) return null;
const { x, z } = mapToWorld(info, px, py);
const { X, Y } = threeToWorld(x, z);
return `${name} · x ${Math.round(X)} / y ${Math.round(Y)}`;
}
/** Kartenwinkel aus einer three-Richtung (dx, dz): 0 = Norden, positiv im Uhrzeigersinn. */
export function headingFromDirection(dx, dz) {
return Math.atan2(dx, -dz);
}
/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Eingabefeld. */
export function isMapToggleKey(event, activeTag) {
if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false;
return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag);
}
/**
* SHA-256 als Hex, oder null ohne crypto.subtle. Das fehlt außerhalb eines sicheren Kontexts,
* etwa beim Aufruf über eine LAN-Adresse statt localhost — dann entfällt die Veraltungsprüfung still.
*/
export async function sha256Hex(buffer, subtle = globalThis.crypto?.subtle) {
if (!subtle) return null;
const digest = await subtle.digest("SHA-256", buffer);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
- Step 4: Tests laufen lassen, sie müssen bestehen
Run: npx vitest run tests/mapMath.test.js
Expected: PASS, alle Tests der Datei.
- Step 5: Ganze Suite laufen lassen
Run: npm test
Expected: PASS, Ausgabe sauber.
- Step 6: Commit
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; nachcreatePanel(…)die Karte anlegen) - Modify:
src/player.js(activeVehicleOrNullexportieren) - Modify:
index.html(z-indexder Namensnennung)
Interfaces:
- Consumes: alles aus
src/ui/mapMath.js(Task 3);chunkName,parseChunkName,CHUNK_SIZE,CHUNKS_PER_SIDEaussrc/world/coords.js(Task 1); die Kartendateien aus Task 2; inmain.jsvorhanden: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:
activeVehicleOrNullinsrc/player.jsexportieren
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.jsschreiben
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.jseinhä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:
- 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.
- Zeiger über der Karte bewegen: unten rechts erscheint
Name · x … / y …. Den Zeiger über den Downtown-Kern führen — dort mussB9stehen. - Mausrad zoomt um den Zeiger bis vierfach; Ziehen verschiebt und stoppt am Kartenrand.
- Ein Eingabefeld im Panel anklicken, dann M drücken: die Karte darf nicht aufgehen.
- W gedrückt halten, M drücken, W loslassen, Karte schließen: die Figur darf nicht weiterlaufen.
- 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.
- 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.
public/data/vice-a/map.jsonvorübergehend umbenennen, Seite neu laden, M: Hinweis „Kartenbild fehlt". Datei zurückbenennen.- In
public/data/vice-a/map.jsonvorübergehend eine Ziffer vonmanifest_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