(−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>
57 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
Korrektur nach Task 4 (Ruling M7):
(−511, 254)ist der Bezugspunkt „Downtown“ austools/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 AnzeigebeispielF3 · 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.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-Bezugspunkt ausdistricts.jsonWelt(−511, 254)= B9; höchster Turm, Southeast Financial Center(−461, −460)= C12. - 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: 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:
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 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.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 "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; 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-Bezugspunkt Welt(−511, 254)führen — dort mussB9stehen; über dem Southeast Financial CenterC12. - 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