(−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>
1323 lines
57 KiB
Markdown
1323 lines
57 KiB
Markdown
# Minimap Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Auf Taste M öffnet sich über dem laufenden Spiel eine große Karte des Vice-City-Ausschnitts mit benanntem Chunk-Raster (A1–L12), Koordinatenanzeige, Spielerpfeil, Fahrzeugen, Zoomen, Verschieben und Klick zum Hinspringen.
|
||
|
||
**Architecture:** `tools/extract/overview.py` erzeugt aus den ausgelieferten Chunk-Dateien ein Kartenbild (1 px/m), eine Begehbarkeitsmaske (4 m/px) und `map.json`. Im Browser rechnet `src/ui/mapMath.js` rein funktional zwischen Welt, Karte, Maske und Bildschirm um; `src/ui/map.js` ist eine dünne Canvas-Überlagerung darauf, eingehängt in `src/main.js` über die vorhandenen Funktionen `playerPosition`, `getAllVehicles` und `requestTeleport`.
|
||
|
||
**Tech Stack:** three 0.184.0, Vite, vitest (Node-Umgebung, **kein** jsdom) · Python 3.12 über uv mit Pillow und pytest
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-13-minimap-design.md`
|
||
|
||
> **Korrektur nach Task 4 (Ruling M7):** `(−511, 254)` ist der Bezugspunkt „Downtown“ aus `tools/extract/data/districts.json` — der Mittelpunkt der westlichen Hochhäuser —, nicht der Hochhauskern selbst. Der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, steht in C12. Wortlaut, zwei Testtitel und das Anzeigebeispiel `F3 · x 412 / y 1830` (vorher fälschlich mit C10) sind unten angepasst.
|
||
|
||
## Global Constraints
|
||
|
||
- three 0.184.0; **keine neue Laufzeit- oder Testabhängigkeit.** vitest läuft ohne DOM — alles, was ohne Browser getestet werden soll, gehört in reine Funktionen in `mapMath.js`; `map.js` wird im Browser geprüft.
|
||
- Python-Bibliotheken: numpy, scipy, scikit-image, shapely, networkx, pillow, pytest.
|
||
- **Niemals** `git add three-player-controller`, **niemals** `git add -A`. Das Submodul bleibt auf `ac39682`; `git status` zeigt es als geändert, das ist ein gewollter Zustand. Commits nur mit ausdrücklichen Pfaden: `git commit -m "…" -- <pfade>`.
|
||
- **`tools/extract/run.py` wird nicht angefasst.** Eine parallele Sitzung arbeitet an der Pipeline. Die Kartendateien entstehen über `overview.py`.
|
||
- Chunk-Namen: Spalte `A`–`L` aus `i` (West → Ost), Zeile `1`–`12` aus `j` (Nord → Süd). **A1 = `chunk_0_0` = Nordwest-Ecke.** C10 = `chunk_2_9`. Downtown-Bezugspunkt aus `districts.json` Welt `(−511, 254)` = B9; höchster Turm, Southeast Financial Center `(−461, −460)` = C12.
|
||
- Koordinaten: three `(x, z)` → Welt `(X = x, Y = −z)` → Kartenpixel `px = (X − x0) · scale`, `py = (y1 − Y) · scale`, mit `x0 = −1000`, `y1 = 2500`.
|
||
- Ausschnitt: `SLICE = { x0: -1000, x1: 2000, y0: -500, y1: 2500 }`, Chunkgröße 250 m, 12 × 12 Chunks.
|
||
- Kartendateien in `public/data/vice-a/`: `map.png` 3.000 × 3.000, `map-walkable.png` 750 × 750 (255 begehbar, 0 nicht), `map.json`. Vite liefert sie unter `./data/vice-a/` aus.
|
||
- Landeradius 20 m = 5 Maskenpixel; Klick gegen Ziehen bei 4 CSS-Pixeln; Zoom vom ganzen Ausschnitt bis vierfach; Chunk-Namen erst ab 40 CSS-Pixeln Chunkbreite.
|
||
- Stapelordnung: `.attribution` 2000 > Karte 1500 > lil-gui 1001. Die Namensnennung bleibt über der Karte sichtbar (Lizenzpflicht).
|
||
- Testausgabe muss sauber sein. JavaScript: `npm test`. Python: `cd tools/extract && uv run pytest -q`.
|
||
|
||
---
|
||
|
||
### Task 1: Chunk-Namen in JavaScript und Python
|
||
|
||
**Files:**
|
||
- Modify: `src/world/coords.js`
|
||
- Modify: `tools/extract/leonida/coords.py`
|
||
- Create: `tests/fixtures/chunk-names.json`
|
||
- Test: `tests/coords.test.js`, `tools/extract/tests/test_coords.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CHUNKS_PER_SIDE`, `chunkForWorld` aus `src/world/coords.js`
|
||
- Produces:
|
||
- `chunkName(i: number, j: number) -> string | null` (JavaScript) — `null` außerhalb von 0–11
|
||
- `parseChunkName(name: string) -> { i: number, j: number } | null` (JavaScript)
|
||
- `chunk_name(i: int, j: int) -> str | None` und `parse_chunk_name(name: str) -> tuple[int, int] | None` (Python)
|
||
- `tests/fixtures/chunk-names.json` als gemeinsamer Beispielsatz beider Sprachen
|
||
|
||
- [ ] **Step 1: Den gemeinsamen Beispielsatz anlegen**
|
||
|
||
`tests/fixtures/chunk-names.json`:
|
||
|
||
```json
|
||
{
|
||
"pairs": [["A1", 0, 0], ["L1", 11, 0], ["A12", 0, 11], ["L12", 11, 11], ["C10", 2, 9]],
|
||
"lowercase": [["c10", 2, 9], ["l12", 11, 11]],
|
||
"invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " "]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Die fehlschlagenden JavaScript-Tests schreiben**
|
||
|
||
In `tests/coords.test.js` die **bestehende** Import-Zeile aus `../src/world/coords.js` durch die beiden ersten Zeilen des Blocks ersetzen (die Zeile `import { describe, expect, it } from "vitest";` bleibt unverändert davor stehen) und den Rest des Blocks ans Dateiende anfügen. Nicht den ganzen Block anhängen — sonst steht der Import aus `coords.js` doppelt da:
|
||
|
||
```js
|
||
import fs from "node:fs";
|
||
import { CHUNK_SIZE, chunkForWorld, chunkName, parseChunkName, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js";
|
||
|
||
const NAMES = JSON.parse(fs.readFileSync(new URL("./fixtures/chunk-names.json", import.meta.url), "utf8"));
|
||
|
||
describe("Chunk-Namen", () => {
|
||
it("benennt Ecken und Beispiel wie in der Spec, in beide Richtungen", () => {
|
||
for (const [name, i, j] of NAMES.pairs) {
|
||
expect(chunkName(i, j)).toBe(name);
|
||
expect(parseChunkName(name)).toEqual({ i, j });
|
||
}
|
||
});
|
||
|
||
it("akzeptiert Kleinschreibung", () => {
|
||
for (const [name, i, j] of NAMES.lowercase) expect(parseChunkName(name)).toEqual({ i, j });
|
||
});
|
||
|
||
it("weist alles außerhalb von A1 bis L12 ab", () => {
|
||
for (const name of NAMES.invalid) expect(parseChunkName(name)).toBeNull();
|
||
});
|
||
|
||
it("liefert null für Indizes außerhalb des Ausschnitts", () => {
|
||
expect(chunkName(12, 0)).toBeNull();
|
||
expect(chunkName(0, 12)).toBeNull();
|
||
expect(chunkName(-1, 3)).toBeNull();
|
||
expect(chunkName(1.5, 3)).toBeNull();
|
||
});
|
||
|
||
it("kommt für alle 144 Chunks hin und zurück", () => {
|
||
for (let i = 0; i < 12; i++) {
|
||
for (let j = 0; j < 12; j++) expect(parseChunkName(chunkName(i, j))).toEqual({ i, j });
|
||
}
|
||
});
|
||
|
||
it("verbindet den Namen mit der Lage: Downtown-Bezugspunkt in B9, höchster Turm in C12", () => {
|
||
// (−511, 254) ist der Bezugspunkt "Downtown" aus tools/extract/data/districts.json, der Mittelpunkt
|
||
// der westlichen Hochhäuser. Die Türme selbst stehen nördlich und südlich davon.
|
||
const { i, j } = chunkForWorld(-511, 254);
|
||
expect({ i, j }).toEqual({ i: 1, j: 8 });
|
||
expect(chunkName(i, j)).toBe("B9");
|
||
const tower = chunkForWorld(-461.31, -460.1); // Southeast Financial Center, 248 m
|
||
expect(chunkName(tower.i, tower.j)).toBe("C12");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: JavaScript-Tests laufen lassen, sie müssen scheitern**
|
||
|
||
Run: `npx vitest run tests/coords.test.js`
|
||
Expected: FAIL — `chunkName is not a function` (bzw. Import schlägt fehl).
|
||
|
||
- [ ] **Step 4: `chunkName` und `parseChunkName` in `src/world/coords.js` anfügen**
|
||
|
||
```js
|
||
const CHUNK_COLUMNS = "ABCDEFGHIJKL";
|
||
const CHUNK_NAME_PATTERN = /^([A-La-l])([1-9]\d?)$/;
|
||
|
||
/**
|
||
* Planquadrat-Name eines Chunks: Spalte A–L aus i (West → Ost), Zeile 1–12 aus j (Nord → Süd).
|
||
* A1 ist die Nordwest-Ecke, weil chunkOrigin i nach Osten und j von der Nordkante nach Süden zählt.
|
||
* Die Namen sind die Schnittstelle zur Feinkalibrierung: was im Chat "C10" heißt, ist chunk_2_9.
|
||
*/
|
||
export function chunkName(i, j) {
|
||
const inside = (k) => Number.isInteger(k) && k >= 0 && k < CHUNKS_PER_SIDE;
|
||
return inside(i) && inside(j) ? CHUNK_COLUMNS[i] + (j + 1) : null;
|
||
}
|
||
|
||
/** Umkehrung von chunkName. Akzeptiert Kleinschreibung, liefert null für alles außerhalb A1–L12. */
|
||
export function parseChunkName(name) {
|
||
const match = CHUNK_NAME_PATTERN.exec(String(name));
|
||
if (!match) return null;
|
||
const row = Number(match[2]);
|
||
if (row > CHUNKS_PER_SIDE) return null;
|
||
return { i: CHUNK_COLUMNS.indexOf(match[1].toUpperCase()), j: row - 1 };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: JavaScript-Tests laufen lassen, sie müssen bestehen**
|
||
|
||
Run: `npx vitest run tests/coords.test.js`
|
||
Expected: PASS, alle Tests der Datei.
|
||
|
||
- [ ] **Step 6: Die fehlschlagenden Python-Tests schreiben**
|
||
|
||
In `tools/extract/tests/test_coords.py` die **bestehende** Zeile `from leonida.coords import …` durch die Importe am Anfang des Blocks ersetzen und die Tests ans Dateiende anfügen. Nicht den ganzen Block anhängen — sonst stehen die Importe doppelt da:
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from leonida.coords import SLICE, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world
|
||
|
||
# Derselbe Beispielsatz wie tests/coords.test.js — JavaScript und Python dürfen nicht auseinanderlaufen.
|
||
NAMES = json.loads((Path(__file__).resolve().parents[3] / "tests/fixtures/chunk-names.json").read_text())
|
||
|
||
|
||
def test_chunk_names_match_the_shared_examples():
|
||
for name, i, j in NAMES["pairs"]:
|
||
assert chunk_name(i, j) == name
|
||
assert parse_chunk_name(name) == (i, j)
|
||
|
||
|
||
def test_chunk_names_accept_lowercase():
|
||
for name, i, j in NAMES["lowercase"]:
|
||
assert parse_chunk_name(name) == (i, j)
|
||
|
||
|
||
def test_chunk_names_reject_everything_outside_a1_to_l12():
|
||
for name in NAMES["invalid"]:
|
||
assert parse_chunk_name(name) is None
|
||
assert chunk_name(12, 0) is None
|
||
assert chunk_name(0, -1) is None
|
||
|
||
|
||
def test_chunk_names_round_trip_for_all_144_chunks():
|
||
for i in range(12):
|
||
for j in range(12):
|
||
assert parse_chunk_name(chunk_name(i, j)) == (i, j)
|
||
```
|
||
|
||
- [ ] **Step 7: Python-Tests laufen lassen, sie müssen scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_coords.py -q`
|
||
Expected: FAIL — `ImportError: cannot import name 'chunk_name'`.
|
||
|
||
- [ ] **Step 8: `chunk_name` und `parse_chunk_name` in `tools/extract/leonida/coords.py` anfügen**
|
||
|
||
Oben `import re` ergänzen, am Ende anfügen:
|
||
|
||
```python
|
||
CHUNKS_PER_SIDE = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12
|
||
CHUNK_COLUMNS = "ABCDEFGHIJKL"
|
||
_CHUNK_NAME = re.compile(r"^([A-La-l])([1-9]\d?)$")
|
||
|
||
|
||
def chunk_name(i: int, j: int) -> str | None:
|
||
"""Planquadrat-Name wie src/world/coords.js: Spalte A–L aus i, Zeile 1–12 aus j. A1 = Nordwest."""
|
||
if not (0 <= i < CHUNKS_PER_SIDE and 0 <= j < CHUNKS_PER_SIDE):
|
||
return None
|
||
return f"{CHUNK_COLUMNS[i]}{j + 1}"
|
||
|
||
|
||
def parse_chunk_name(name: str) -> tuple[int, int] | None:
|
||
"""Umkehrung von chunk_name. Akzeptiert Kleinschreibung, None außerhalb A1–L12."""
|
||
match = _CHUNK_NAME.match(str(name))
|
||
if not match or int(match.group(2)) > CHUNKS_PER_SIDE:
|
||
return None
|
||
return CHUNK_COLUMNS.index(match.group(1).upper()), int(match.group(2)) - 1
|
||
```
|
||
|
||
- [ ] **Step 9: Beide Suiten laufen lassen**
|
||
|
||
Run: `npm test` und `cd tools/extract && uv run pytest -q`
|
||
Expected: beide PASS, Ausgabe sauber.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/world/coords.js tools/extract/leonida/coords.py tests/fixtures/chunk-names.json tests/coords.test.js tools/extract/tests/test_coords.py
|
||
git commit -m "feat(coords): Chunk-Namen als Planquadrat A1 bis L12
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- src/world/coords.js tools/extract/leonida/coords.py tests/fixtures/chunk-names.json tests/coords.test.js tools/extract/tests/test_coords.py
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Kartenbild, Begehbarkeitsmaske und map.json
|
||
|
||
**Files:**
|
||
- Modify: `tools/extract/overview.py`
|
||
- Create: `tools/extract/tests/test_overview.py`
|
||
- Erzeugt: `public/data/vice-a/map.png`, `public/data/vice-a/map-walkable.png`, `public/data/vice-a/map.json`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SLICE` aus `leonida.coords`; Chunk-Dateien und `manifest.json` in `public/data/vice-a/`
|
||
- Produces:
|
||
- `render_map(out_dir: Path, dst_dir: Path) -> dict` — schreibt die drei Dateien
|
||
- `render(out_dir: Path, dst: Path) -> dict` — Debug-Gesamtansicht, **Ausgabe bytegleich zu vorher**
|
||
- `map.json`: `{ "scale": 1.0, "walkable_scale": 0.25, "slice": {x0, x1, y0, y1}, "manifest_sha256": "<hex>" }`
|
||
|
||
- [ ] **Step 1: Prüfsumme der heutigen Debug-Gesamtansicht festhalten**
|
||
|
||
Run: `cd tools/extract && uv run python overview.py && shasum -a 256 debug/overview.png | tee /tmp/overview-vorher.sha`
|
||
Expected: eine Zeile mit Prüfsumme. Sie belegt in Step 6, dass der Umbau die Debug-Ansicht nicht verändert.
|
||
|
||
- [ ] **Step 2: Die fehlschlagenden Tests schreiben**
|
||
|
||
`tools/extract/tests/test_overview.py`:
|
||
|
||
```python
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from PIL import Image
|
||
|
||
import overview
|
||
from leonida.coords import SLICE
|
||
|
||
|
||
def _ring(x0, z0, x1, z1):
|
||
return [[x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0]]
|
||
|
||
|
||
def _fixture(tmp_path: Path) -> Path:
|
||
"""Ein einziger Chunk A1 (Nordwest): Land, eine Straße, ein Gebäude, eine Palme.
|
||
|
||
Chunk-lokale Koordinaten von chunk_0_0 fallen bei scale 1 genau auf die Kartenpixel,
|
||
weil der Chunk-Ursprung die Nordwest-Ecke des Ausschnitts ist.
|
||
"""
|
||
chunk = {
|
||
"chunk": [0, 0],
|
||
"origin": [SLICE["x0"], -SLICE["y1"]],
|
||
"land": [[_ring(0, 0, 250, 250)]],
|
||
"surfaces": [],
|
||
"roads": [{"poly": [_ring(100, 0, 120, 250)], "bridge": False}],
|
||
"centerlines": [],
|
||
"buildings": [{"poly": [_ring(20, 20, 60, 60)], "height": 12.0, "source": "district", "speculative": False}],
|
||
"palms": [[200.0, 200.0]],
|
||
}
|
||
(tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk))
|
||
(tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
||
return tmp_path
|
||
|
||
|
||
def test_map_picture_has_one_pixel_per_metre_and_no_palms(tmp_path):
|
||
src = tmp_path / "chunks"
|
||
src.mkdir()
|
||
_fixture(src)
|
||
dst = tmp_path / "out"
|
||
overview.render_map(src, dst)
|
||
picture = Image.open(dst / "map.png")
|
||
assert picture.size == (3000, 3000)
|
||
assert picture.getpixel((110, 125)) == overview.ROAD
|
||
assert picture.getpixel((40, 40)) == overview._building_shade(12.0)
|
||
assert picture.getpixel((200, 200)) == overview.LAND # Palme wird nicht gezeichnet
|
||
assert picture.getpixel((1500, 1500)) == overview.WATER
|
||
|
||
|
||
def test_walkable_mask_blocks_buildings_and_water(tmp_path):
|
||
src = tmp_path / "chunks"
|
||
src.mkdir()
|
||
_fixture(src)
|
||
dst = tmp_path / "out"
|
||
overview.render_map(src, dst)
|
||
mask = Image.open(dst / "map-walkable.png")
|
||
assert mask.mode == "L"
|
||
assert mask.size == (750, 750)
|
||
assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m
|
||
assert mask.getpixel((27, 31)) == overview.WALKABLE # Straße bei 110 m / 125 m
|
||
assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m
|
||
assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser
|
||
|
||
|
||
def test_map_json_carries_scale_slice_and_manifest_fingerprint(tmp_path):
|
||
src = tmp_path / "chunks"
|
||
src.mkdir()
|
||
_fixture(src)
|
||
dst = tmp_path / "out"
|
||
overview.render_map(src, dst)
|
||
info = json.loads((dst / "map.json").read_text())
|
||
assert info["scale"] == 1.0
|
||
assert info["walkable_scale"] == 0.25
|
||
assert info["slice"] == SLICE
|
||
assert info["manifest_sha256"] == hashlib.sha256((src / "manifest.json").read_bytes()).hexdigest()
|
||
|
||
|
||
def test_debug_overview_still_draws_palms(tmp_path):
|
||
src = tmp_path / "chunks"
|
||
src.mkdir()
|
||
_fixture(src)
|
||
dst = tmp_path / "debug.png"
|
||
stats = overview.render(src, dst)
|
||
assert stats["palmen"] == 1
|
||
assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen lassen, sie müssen scheitern**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_overview.py -q`
|
||
Expected: FAIL — `AttributeError: module 'overview' has no attribute 'render_map'`.
|
||
|
||
- [ ] **Step 4: `tools/extract/overview.py` vollständig ersetzen**
|
||
|
||
```python
|
||
"""Gesamtansicht und Spielkarte des Ausschnitts aus den fertigen Chunk-Dateien.
|
||
|
||
Zeichnet ausschließlich das, was wirklich ausgeliefert wird — kein Kartenbild darunter.
|
||
Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks,
|
||
Beschriftungen der Originalkarte, die als Geometrie durchgerutscht sind.
|
||
|
||
Zwei Ausgaben:
|
||
- debug/overview.png: Prüfbild mit Palmen, 0,5 px/m.
|
||
- map.png, map-walkable.png und map.json in public/data/vice-a/ für die Minimap
|
||
(docs/superpowers/specs/2026-09-13-minimap-design.md).
|
||
|
||
cd tools/extract && uv run python overview.py
|
||
"""
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw
|
||
|
||
from leonida.coords import SLICE
|
||
|
||
OUT = Path("../../public/data/vice-a")
|
||
DST = Path("debug/overview.png")
|
||
SCALE = 0.5 # Pixel je Meter, Prüfbild
|
||
MAP_SCALE = 1.0 # Pixel je Meter, map.png
|
||
WALKABLE_SCALE = 0.25 # Pixel je Meter, map-walkable.png (4 m je Pixel)
|
||
WATER = (36, 72, 104)
|
||
LAND = (150, 162, 120)
|
||
SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)}
|
||
ROAD = (74, 74, 78)
|
||
BRIDGE = (196, 92, 92)
|
||
PALM = (40, 190, 70)
|
||
OUTLINE = (30, 30, 30)
|
||
TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt
|
||
WALKABLE = 255
|
||
BLOCKED = 0
|
||
|
||
|
||
def _to_px(ring, ox, oy, scale=SCALE):
|
||
"""Chunk-lokale three.js-Koordinaten (x, z) -> Bildpixel."""
|
||
return [((x + ox - SLICE["x0"]) * scale, (SLICE["y1"] - (oy - z)) * scale) for x, z in ring]
|
||
|
||
|
||
def _draw(img, rings, ox, oy, fill, outline=None, scale=SCALE):
|
||
"""Fläche über eine Maske einsetzen, damit durch die Löcher die Ebene darunter bleibt.
|
||
|
||
Ein Loch mit Wasserfarbe zu übermalen wäre falsch: der Innenhof eines Blocks liegt auf
|
||
Stadtfläche, nicht im Wasser. Funktioniert für RGB-Bilder (fill als Tupel) und
|
||
Graustufenbilder (fill als Zahl) gleichermaßen.
|
||
"""
|
||
outer = _to_px(rings[0], ox, oy, scale)
|
||
xs = [p[0] for p in outer]
|
||
ys = [p[1] for p in outer]
|
||
x0, y0 = int(min(xs)) - 1, int(min(ys)) - 1
|
||
x1, y1 = int(max(xs)) + 2, int(max(ys)) + 2
|
||
if x1 <= x0 or y1 <= y0:
|
||
return
|
||
mask = Image.new("L", (x1 - x0, y1 - y0), 0)
|
||
md = ImageDraw.Draw(mask)
|
||
md.polygon([(x - x0, y - y0) for x, y in outer], fill=255, outline=255)
|
||
for hole in rings[1:]:
|
||
md.polygon([(x - x0, y - y0) for x, y in _to_px(hole, ox, oy, scale)], fill=0)
|
||
img.paste(fill, (x0, y0), mask)
|
||
if outline:
|
||
ImageDraw.Draw(img).polygon(outer, fill=None, outline=outline)
|
||
|
||
|
||
def _building_shade(height: float) -> tuple[int, int, int]:
|
||
t = min(1.0, height / TALL)
|
||
return (int(70 + 170 * t), int(70 + 120 * t), int(80 + 60 * t))
|
||
|
||
|
||
def _load_chunks(out_dir: Path) -> list[dict]:
|
||
return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))]
|
||
|
||
|
||
def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, outline=None, palm=None) -> dict:
|
||
"""Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird.
|
||
|
||
surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe.
|
||
palm=None zeichnet keine Palmen. Reihenfolge und Füllung entsprechen exakt dem
|
||
bisherigen Prüfbild, damit dessen Ausgabe bytegleich bleibt.
|
||
"""
|
||
stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0}
|
||
placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks]
|
||
for chunk, ox, oy in placed:
|
||
for rings in chunk["land"]:
|
||
_draw(img, rings, ox, oy, land, scale=scale)
|
||
stats["land"] += 1
|
||
for chunk, ox, oy in placed:
|
||
for item in chunk["surfaces"]:
|
||
_draw(img, item["poly"], ox, oy, surface(item["kind"]), scale=scale)
|
||
for chunk, ox, oy in placed:
|
||
for item in chunk["roads"]:
|
||
_draw(img, item["poly"], ox, oy, bridge if item["bridge"] else road, scale=scale)
|
||
stats["bruecken" if item["bridge"] else "strassen"] += 1
|
||
for chunk, ox, oy in placed:
|
||
for item in chunk["buildings"]:
|
||
_draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale)
|
||
stats["gebaeude"] += 1
|
||
if palm is not None:
|
||
for x, z in chunk["palms"]:
|
||
px, py = _to_px([(x, z)], ox, oy, scale)[0]
|
||
ImageDraw.Draw(img).ellipse([px - 1.6, py - 1.6, px + 1.6, py + 1.6], fill=palm)
|
||
stats["palmen"] += 1
|
||
return stats
|
||
|
||
|
||
def render(out_dir: Path, dst: Path) -> dict:
|
||
"""Debug-Gesamtansicht mit Palmen."""
|
||
size = int((SLICE["x1"] - SLICE["x0"]) * SCALE)
|
||
img = Image.new("RGB", (size, size), WATER)
|
||
stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__,
|
||
road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(dst)
|
||
return stats
|
||
|
||
|
||
def render_map(out_dir: Path, dst_dir: Path) -> dict:
|
||
"""Kartenbild, Begehbarkeitsmaske und map.json für die Minimap."""
|
||
chunks = _load_chunks(out_dir)
|
||
width_m = SLICE["x1"] - SLICE["x0"]
|
||
|
||
picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER)
|
||
_paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD,
|
||
bridge=BRIDGE, building=_building_shade, outline=OUTLINE)
|
||
|
||
# Begehbar ist, was Boden hat: Land, Beläge, Straßen und Brücken. Gebäude werden danach wieder
|
||
# gesperrt; Wasser ist der Hintergrund. Innenhöfe bleiben über die Löcher begehbar.
|
||
walkable = Image.new("L", (int(width_m * WALKABLE_SCALE),) * 2, BLOCKED)
|
||
_paint_layers(walkable, chunks, WALKABLE_SCALE, land=WALKABLE, surface=lambda _kind: WALKABLE,
|
||
road=WALKABLE, bridge=WALKABLE, building=lambda _height: BLOCKED)
|
||
|
||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||
picture.save(dst_dir / "map.png")
|
||
walkable.save(dst_dir / "map-walkable.png")
|
||
info = {
|
||
"scale": MAP_SCALE,
|
||
"walkable_scale": WALKABLE_SCALE,
|
||
"slice": dict(SLICE),
|
||
"manifest_sha256": hashlib.sha256((out_dir / "manifest.json").read_bytes()).hexdigest(),
|
||
}
|
||
(dst_dir / "map.json").write_text(json.dumps(info, indent=2) + "\n")
|
||
return {"map": picture.size, "walkable": walkable.size, "manifest_sha256": info["manifest_sha256"]}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print(render(OUT, DST), "->", DST)
|
||
print(render_map(OUT, OUT), "->", OUT)
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen**
|
||
|
||
Run: `cd tools/extract && uv run pytest tests/test_overview.py -q`
|
||
Expected: PASS, 4 Tests.
|
||
|
||
- [ ] **Step 6: Belegen, dass die Debug-Ansicht bytegleich geblieben ist, und die echten Kartendateien erzeugen**
|
||
|
||
Run: `cd tools/extract && uv run python overview.py && shasum -a 256 debug/overview.png && cat /tmp/overview-vorher.sha`
|
||
Expected: beide Prüfsummen identisch. Unterscheiden sie sich, ist der Umbau nicht verhaltensgleich — nicht committen, sondern melden.
|
||
|
||
Run: `ls -la ../../public/data/vice-a/map*.png ../../public/data/vice-a/map.json && cat ../../public/data/vice-a/map.json`
|
||
Expected: `map.png` und `map-walkable.png` vorhanden, `map.json` mit den Werten aus den Interfaces. Größe von `map.png` im Bericht nennen.
|
||
|
||
- [ ] **Step 7: Beide Suiten laufen lassen**
|
||
|
||
Run: `cd tools/extract && uv run pytest -q` und `npm test`
|
||
Expected: beide PASS, Ausgabe sauber.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add tools/extract/overview.py tools/extract/tests/test_overview.py public/data/vice-a/map.png public/data/vice-a/map-walkable.png public/data/vice-a/map.json
|
||
git commit -m "feat(extract): Kartenbild, Begehbarkeitsmaske und map.json für die Minimap
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/overview.py tools/extract/tests/test_overview.py public/data/vice-a/map.png public/data/vice-a/map-walkable.png public/data/vice-a/map.json
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Kartenmathematik
|
||
|
||
**Files:**
|
||
- Create: `src/ui/mapMath.js`
|
||
- Test: `tests/mapMath.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `chunkForWorld`, `chunkName`, `threeToWorld` aus `src/world/coords.js` (Task 1)
|
||
- Produces (alle rein, ohne DOM):
|
||
- Konstanten `CLICK_TOLERANCE_PX = 4`, `MAX_ZOOM = 4`, `LABEL_MIN_CHUNK_PX = 40`, `LANDING_RADIUS_M = 20`
|
||
- `worldToMap(info, x, z) -> { px, py }` · `mapToWorld(info, px, py) -> { x, z }`
|
||
- `fitView(mapSize, viewW, viewH) -> { scale, offsetX, offsetY, minScale }`
|
||
- `zoomAt(view, factor, sx, sy) -> view` · `clampView(view, mapSize, viewW, viewH) -> view`
|
||
- `screenToMap(view, sx, sy) -> { px, py }` · `mapToScreen(view, px, py) -> { sx, sy }`
|
||
- `isClick(down, up, tolerance) -> boolean` mit `down`/`up` als `{ x, y }`
|
||
- `nearestWalkable(mask, width, height, x, y, radiusPx) -> { x, y } | null` in Maskenkoordinaten
|
||
- `landingFor(info, walk, px, py) -> { x, z } | null` mit `walk = { mask, width, height }`
|
||
- `chunkNameAtMap(info, px, py) -> string | null` · `hoverLabel(info, px, py) -> string | null`
|
||
- `headingFromDirection(dx, dz) -> number` (0 = Norden, im Uhrzeigersinn)
|
||
- `isMapToggleKey(event, activeTag) -> boolean`
|
||
- `sha256Hex(buffer, subtle = globalThis.crypto?.subtle) -> Promise<string | null>`
|
||
- `info` hat die Form von `map.json`
|
||
|
||
- [ ] **Step 1: Die fehlschlagenden Tests schreiben**
|
||
|
||
`tests/mapMath.test.js`:
|
||
|
||
```js
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick,
|
||
isMapToggleKey, landingFor, mapToWorld, nearestWalkable, screenToMap, sha256Hex, worldToMap, zoomAt,
|
||
} from "../src/ui/mapMath.js";
|
||
|
||
const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } };
|
||
|
||
describe("Welt und Karte", () => {
|
||
it("setzt den Spawn an die richtige Kartenstelle", () => {
|
||
expect(worldToMap(INFO, 1699, -743)).toEqual({ px: 2699, py: 1757 });
|
||
});
|
||
|
||
it("legt die vier Ecken des Ausschnitts auf die vier Bildecken — Norden oben", () => {
|
||
expect(worldToMap(INFO, -1000, -2500)).toEqual({ px: 0, py: 0 }); // Nordwest
|
||
expect(worldToMap(INFO, 2000, -2500)).toEqual({ px: 3000, py: 0 }); // Nordost
|
||
expect(worldToMap(INFO, -1000, 500)).toEqual({ px: 0, py: 3000 }); // Südwest
|
||
expect(worldToMap(INFO, 2000, 500)).toEqual({ px: 3000, py: 3000 }); // Südost
|
||
});
|
||
|
||
it("kommt hin und zurück", () => {
|
||
for (const [x, z] of [[1699, -743], [-511, -254], [0, 0], [123.5, -2001.25]]) {
|
||
const { px, py } = worldToMap(INFO, x, z);
|
||
const back = mapToWorld(INFO, px, py);
|
||
expect(back.x).toBeCloseTo(x, 9);
|
||
expect(back.z).toBeCloseTo(z, 9);
|
||
}
|
||
});
|
||
|
||
it("liegt ein nördlicherer Punkt weiter oben — fängt eine gespiegelte Achse", () => {
|
||
const south = worldToMap(INFO, 0, -100); // Welt y = 100
|
||
const north = worldToMap(INFO, 0, -900); // Welt y = 900
|
||
expect(north.py).toBeLessThan(south.py);
|
||
});
|
||
});
|
||
|
||
describe("Ansicht", () => {
|
||
it("passt die ganze Karte zentriert ins Fenster", () => {
|
||
expect(fitView(3000, 1200, 900)).toEqual({ scale: 0.3, offsetX: 150, offsetY: 0, minScale: 0.3 });
|
||
});
|
||
|
||
it("hält beim Zoomen den Punkt unter dem Mauszeiger fest", () => {
|
||
const view = fitView(3000, 1200, 900);
|
||
const before = screenToMap(view, 700, 400);
|
||
const after = screenToMap(zoomAt(view, 2, 700, 400), 700, 400);
|
||
expect(after.px).toBeCloseTo(before.px, 9);
|
||
expect(after.py).toBeCloseTo(before.py, 9);
|
||
});
|
||
|
||
it("zoomt nie weiter heraus als der ganze Ausschnitt und nie weiter hinein als vierfach", () => {
|
||
const view = fitView(3000, 1200, 900);
|
||
expect(zoomAt(view, 0.01, 600, 450).scale).toBeCloseTo(0.3, 9);
|
||
expect(zoomAt(view, 100, 600, 450).scale).toBeCloseTo(1.2, 9);
|
||
});
|
||
|
||
it("zentriert eine Karte, die kleiner als das Fenster ist", () => {
|
||
const view = { scale: 0.3, offsetX: -500, offsetY: 999, minScale: 0.3 };
|
||
expect(clampView(view, 3000, 1200, 900)).toMatchObject({ offsetX: 150, offsetY: 0 });
|
||
});
|
||
|
||
it("lässt eine vergrößerte Karte nicht über ihren Rand hinaus verschieben", () => {
|
||
const view = { scale: 1.2, offsetX: 50, offsetY: -99999, minScale: 0.3 }; // Inhalt 3600 px
|
||
const clamped = clampView(view, 3000, 1200, 900);
|
||
expect(clamped.offsetX).toBe(0);
|
||
expect(clamped.offsetY).toBe(900 - 3600);
|
||
});
|
||
});
|
||
|
||
describe("Klick gegen Ziehen", () => {
|
||
it("zählt unter 4 px Bewegung als Klick und ab 4 px als Ziehen", () => {
|
||
expect(CLICK_TOLERANCE_PX).toBe(4);
|
||
expect(isClick({ x: 10, y: 10 }, { x: 13.9, y: 10 })).toBe(true);
|
||
expect(isClick({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Landeplatz", () => {
|
||
const blank = (w, h) => new Uint8Array(w * h);
|
||
|
||
it("bleibt bei einem begehbaren Treffer genau am Klickpunkt", () => {
|
||
const mask = blank(10, 10);
|
||
mask[2 * 10 + 5] = 255;
|
||
expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.4, y: 2.2 });
|
||
});
|
||
|
||
it("findet von einem Gebäude aus die Straße daneben", () => {
|
||
const mask = blank(10, 10);
|
||
for (let y = 0; y < 10; y++) mask[y * 10 + 5] = 255; // Straße in Spalte 5
|
||
expect(nearestWalkable(mask, 10, 10, 3.2, 3.7, 5)).toEqual({ x: 5.5, y: 3.5 });
|
||
});
|
||
|
||
it("findet nichts, wenn im Umkreis nur Wasser ist", () => {
|
||
expect(nearestWalkable(blank(10, 10), 10, 10, 4, 4, 5)).toBeNull();
|
||
});
|
||
|
||
it("zählt einen Treffer genau auf dem Radius, aber nicht knapp dahinter", () => {
|
||
const onRadius = blank(20, 20);
|
||
onRadius[3 * 20 + 8] = 255; // Abstand 5
|
||
expect(nearestWalkable(onRadius, 20, 20, 3, 3, 5)).toEqual({ x: 8.5, y: 3.5 });
|
||
const beyond = blank(20, 20);
|
||
beyond[4 * 20 + 8] = 255; // Abstand √26 ≈ 5,1
|
||
expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull();
|
||
});
|
||
|
||
it("rechnet einen Kartenklick über die Maske in eine Weltposition um", () => {
|
||
const walk = { mask: blank(750, 750), width: 750, height: 750 };
|
||
walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200)
|
||
expect(landingFor(INFO, walk, 400, 800)).toEqual({ x: -600, z: -1700 }); // Treffer: genau dorthin
|
||
const nearby = landingFor(INFO, walk, 412, 800); // 3 Maskenpixel daneben
|
||
expect(nearby.x).toBeCloseTo(-598, 9); // Mitte von (100, 200) = Karte (402, 802)
|
||
expect(nearby.z).toBeCloseTo(-1698, 9);
|
||
expect(landingFor(INFO, walk, 2000, 2000)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Chunk-Namen und Anzeige", () => {
|
||
it("nennt am Downtown-Bezugspunkt B9 und am Southeast Financial Center C12", () => {
|
||
expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9");
|
||
expect(chunkNameAtMap(INFO, 538.69, 2960.1)).toBe("C12");
|
||
});
|
||
|
||
it("liefert am östlichen Kartenrand keinen Namen mehr", () => {
|
||
expect(chunkNameAtMap(INFO, 3000, 10)).toBeNull();
|
||
});
|
||
|
||
it("zeigt Chunk und Weltkoordinate im Format der Spec", () => {
|
||
expect(hoverLabel(INFO, 489, 2246)).toBe("B9 · x -511 / y 254");
|
||
});
|
||
});
|
||
|
||
describe("Richtung und Tasten", () => {
|
||
it("misst die Richtung ab Norden im Uhrzeigersinn", () => {
|
||
expect(headingFromDirection(0, -1)).toBeCloseTo(0, 9);
|
||
expect(headingFromDirection(1, 0)).toBeCloseTo(Math.PI / 2, 9);
|
||
expect(Math.abs(headingFromDirection(0, 1))).toBeCloseTo(Math.PI, 9);
|
||
expect(headingFromDirection(-1, 0)).toBeCloseTo(-Math.PI / 2, 9);
|
||
});
|
||
|
||
it("schaltet mit M, aber nicht beim Tippen, nicht bei Wiederholung und nicht mit Strg", () => {
|
||
const key = (extra) => ({ code: "KeyM", repeat: false, ctrlKey: false, metaKey: false, altKey: false, ...extra });
|
||
expect(isMapToggleKey(key({}), "BODY")).toBe(true);
|
||
expect(isMapToggleKey(key({}), "INPUT")).toBe(false);
|
||
expect(isMapToggleKey(key({}), "SELECT")).toBe(false);
|
||
expect(isMapToggleKey(key({ repeat: true }), "BODY")).toBe(false);
|
||
expect(isMapToggleKey(key({ ctrlKey: true }), "BODY")).toBe(false);
|
||
expect(isMapToggleKey(key({ code: "KeyN" }), "BODY")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Fingerabdruck", () => {
|
||
it("liefert SHA-256 als Hex", async () => {
|
||
expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe(
|
||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||
});
|
||
|
||
it("liefert null ohne sicheren Kontext, statt zu werfen", async () => {
|
||
expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
|
||
|
||
Run: `npx vitest run tests/mapMath.test.js`
|
||
Expected: FAIL — `Failed to load url ../src/ui/mapMath.js`.
|
||
|
||
- [ ] **Step 3: `src/ui/mapMath.js` schreiben**
|
||
|
||
```js
|
||
import { chunkForWorld, chunkName, threeToWorld } from "../world/coords.js";
|
||
|
||
/**
|
||
* Reine Kartenmathematik für die Minimap (docs/superpowers/specs/2026-09-13-minimap-design.md).
|
||
* Ohne DOM, damit vitest sie ohne Browser prüfen kann. `info` hat die Form von map.json.
|
||
*
|
||
* Koordinatensysteme:
|
||
* - three (x, z): Szene; Norden ist −z.
|
||
* - Welt (X, Y): X = x, Y = −z.
|
||
* - Karte (px, py): Pixel in map.png, Ursprung Nordwest, py wächst nach Süden.
|
||
* - Maske: Pixel in map-walkable.png, gleiche Ausrichtung, anderer Maßstab.
|
||
* - Bildschirm (sx, sy): Canvas-Pixel; Ansicht = { scale, offsetX, offsetY, minScale }.
|
||
*/
|
||
|
||
export const CLICK_TOLERANCE_PX = 4;
|
||
export const MAX_ZOOM = 4;
|
||
export const LABEL_MIN_CHUNK_PX = 40;
|
||
export const LANDING_RADIUS_M = 20;
|
||
|
||
/** three (x, z) -> Kartenpixel. Die einzige Stelle der Achsenumkehr zwischen three-z und Karten-y. */
|
||
export function worldToMap(info, x, z) {
|
||
const { X, Y } = threeToWorld(x, z);
|
||
return { px: (X - info.slice.x0) * info.scale, py: (info.slice.y1 - Y) * info.scale };
|
||
}
|
||
|
||
/** Kartenpixel -> three (x, z). */
|
||
export function mapToWorld(info, px, py) {
|
||
const X = info.slice.x0 + px / info.scale;
|
||
const Y = info.slice.y1 - py / info.scale;
|
||
return { x: X, z: -Y };
|
||
}
|
||
|
||
/** Ansicht, in der die ganze quadratische Karte zentriert ins Fenster passt. */
|
||
export function fitView(mapSize, viewW, viewH) {
|
||
const scale = Math.min(viewW / mapSize, viewH / mapSize);
|
||
return { scale, offsetX: (viewW - mapSize * scale) / 2, offsetY: (viewH - mapSize * scale) / 2, minScale: scale };
|
||
}
|
||
|
||
/** Zoomt um den Bildschirmpunkt (sx, sy); begrenzt auf den ganzen Ausschnitt bis MAX_ZOOM. */
|
||
export function zoomAt(view, factor, sx, sy) {
|
||
const scale = Math.min(view.minScale * MAX_ZOOM, Math.max(view.minScale, view.scale * factor));
|
||
const k = scale / view.scale;
|
||
return { ...view, scale, offsetX: sx - (sx - view.offsetX) * k, offsetY: sy - (sy - view.offsetY) * k };
|
||
}
|
||
|
||
/** Kleinere Karte zentrieren, größere nie über ihren Rand hinaus verschieben. */
|
||
export function clampView(view, mapSize, viewW, viewH) {
|
||
const content = mapSize * view.scale;
|
||
const axis = (offset, viewport) =>
|
||
content <= viewport ? (viewport - content) / 2 : Math.min(0, Math.max(viewport - content, offset));
|
||
return { ...view, offsetX: axis(view.offsetX, viewW), offsetY: axis(view.offsetY, viewH) };
|
||
}
|
||
|
||
export const screenToMap = (view, sx, sy) => ({ px: (sx - view.offsetX) / view.scale, py: (sy - view.offsetY) / view.scale });
|
||
export const mapToScreen = (view, px, py) => ({ sx: px * view.scale + view.offsetX, sy: py * view.scale + view.offsetY });
|
||
|
||
/** Kurzer Klick statt Ziehen: Bewegung zwischen Drücken und Loslassen unter der Toleranz. */
|
||
export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX) {
|
||
return Math.hypot(up.x - down.x, up.y - down.y) < tolerance;
|
||
}
|
||
|
||
/**
|
||
* Nächster begehbarer Maskenpixel (Wert 255) im Umkreis radiusPx, euklidisch gemessen.
|
||
* Ist der Pixel unter (x, y) selbst begehbar, bleibt der Punkt genau, wo er ist; sonst die Mitte
|
||
* des nächsten begehbaren Pixels. Gleich weite Kandidaten: der erste in Zeilenreihenfolge.
|
||
*/
|
||
export function nearestWalkable(mask, width, height, x, y, radiusPx) {
|
||
const cx = Math.floor(x), cy = Math.floor(y);
|
||
const walkable = (ix, iy) => ix >= 0 && iy >= 0 && ix < width && iy < height && mask[iy * width + ix] === 255;
|
||
if (walkable(cx, cy)) return { x, y };
|
||
const reach = Math.ceil(radiusPx);
|
||
let best = null;
|
||
for (let iy = cy - reach; iy <= cy + reach; iy++) {
|
||
for (let ix = cx - reach; ix <= cx + reach; ix++) {
|
||
if (!walkable(ix, iy)) continue;
|
||
const d2 = (ix - cx) ** 2 + (iy - cy) ** 2;
|
||
if (d2 <= radiusPx * radiusPx && (!best || d2 < best.d2)) best = { ix, iy, d2 };
|
||
}
|
||
}
|
||
return best ? { x: best.ix + 0.5, y: best.iy + 0.5 } : null;
|
||
}
|
||
|
||
/** Zielpunkt eines Kartenklicks in three-Koordinaten, oder null ohne Landeplatz im Umkreis. */
|
||
export function landingFor(info, walk, px, py) {
|
||
const toMask = info.walkable_scale / info.scale;
|
||
const hit = nearestWalkable(walk.mask, walk.width, walk.height, px * toMask, py * toMask,
|
||
LANDING_RADIUS_M * info.walkable_scale);
|
||
return hit ? mapToWorld(info, hit.x / toMask, hit.y / toMask) : null;
|
||
}
|
||
|
||
/** Planquadrat-Name an einem Kartenpunkt, oder null außerhalb des Ausschnitts. */
|
||
export function chunkNameAtMap(info, px, py) {
|
||
const { x, z } = mapToWorld(info, px, py);
|
||
const { X, Y } = threeToWorld(x, z);
|
||
const { i, j } = chunkForWorld(X, Y);
|
||
return chunkName(i, j);
|
||
}
|
||
|
||
/** Anzeige für die Stelle unter dem Mauszeiger, etwa "F3 · x 412 / y 1830". */
|
||
export function hoverLabel(info, px, py) {
|
||
const name = chunkNameAtMap(info, px, py);
|
||
if (!name) return null;
|
||
const { x, z } = mapToWorld(info, px, py);
|
||
const { X, Y } = threeToWorld(x, z);
|
||
return `${name} · x ${Math.round(X)} / y ${Math.round(Y)}`;
|
||
}
|
||
|
||
/** Kartenwinkel aus einer three-Richtung (dx, dz): 0 = Norden, positiv im Uhrzeigersinn. */
|
||
export function headingFromDirection(dx, dz) {
|
||
return Math.atan2(dx, -dz);
|
||
}
|
||
|
||
/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Eingabefeld. */
|
||
export function isMapToggleKey(event, activeTag) {
|
||
if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||
return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag);
|
||
}
|
||
|
||
/**
|
||
* SHA-256 als Hex, oder null ohne crypto.subtle. Das fehlt außerhalb eines sicheren Kontexts,
|
||
* etwa beim Aufruf über eine LAN-Adresse statt localhost — dann entfällt die Veraltungsprüfung still.
|
||
*/
|
||
export async function sha256Hex(buffer, subtle = globalThis.crypto?.subtle) {
|
||
if (!subtle) return null;
|
||
const digest = await subtle.digest("SHA-256", buffer);
|
||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||
|
||
Run: `npx vitest run tests/mapMath.test.js`
|
||
Expected: PASS, alle Tests der Datei.
|
||
|
||
- [ ] **Step 5: Ganze Suite laufen lassen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, Ausgabe sauber.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mapMath.js tests/mapMath.test.js
|
||
git commit -m "feat(ui): Kartenmathematik für die Minimap
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- src/ui/mapMath.js tests/mapMath.test.js
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Kartenkomponente und Einhängen
|
||
|
||
**Files:**
|
||
- Create: `src/ui/map.js`
|
||
- Modify: `src/main.js` (Importe; nach `createPanel(…)` die Karte anlegen)
|
||
- Modify: `src/player.js` (`activeVehicleOrNull` exportieren)
|
||
- Modify: `index.html` (`z-index` der Namensnennung)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus `src/ui/mapMath.js` (Task 3); `chunkName`, `parseChunkName`, `CHUNK_SIZE`, `CHUNKS_PER_SIDE` aus `src/world/coords.js` (Task 1); die Kartendateien aus Task 2; in `main.js` vorhanden: `player`, `camera`, `requestTeleport(position)`, `playerPosition(player)`
|
||
- Produces: `createMap({ getPosition, getHeading, getVehicles, onTeleport, baseUrl }) -> { open(), close(), toggle(), isOpen() }`
|
||
|
||
Diese Aufgabe hat keine neuen Unit-Tests: vitest läuft ohne DOM, und alles Rechnerische liegt bereits getestet in `mapMath.js`. Die Abnahme ist die Browser-Prüfung in Step 6.
|
||
|
||
- [ ] **Step 1: `activeVehicleOrNull` in `src/player.js` exportieren**
|
||
|
||
In `src/player.js` die Zeile `function activeVehicleOrNull(player) {` ersetzen durch:
|
||
|
||
```js
|
||
export function activeVehicleOrNull(player) {
|
||
```
|
||
|
||
Die Funktion und ihr Kommentar bleiben sonst unverändert.
|
||
|
||
- [ ] **Step 2: Namensnennung in `index.html` über die Karte heben**
|
||
|
||
In der Regel `.attribution` die Zeile `z-index: 10;` ersetzen durch:
|
||
|
||
```css
|
||
/* Über allem, auch über der Minimap (1500) und lil-gui (1001): Lizenzpflicht, und die
|
||
Karte ist selbst aus den CC-BY-4.0-Kacheln abgeleitet. */
|
||
z-index: 2000;
|
||
```
|
||
|
||
- [ ] **Step 3: `src/ui/map.js` schreiben**
|
||
|
||
```js
|
||
import { CHUNK_SIZE, CHUNKS_PER_SIDE, chunkName, parseChunkName } from "../world/coords.js";
|
||
import {
|
||
CLICK_TOLERANCE_PX, LABEL_MIN_CHUNK_PX, chunkNameAtMap, clampView, fitView, hoverLabel, isClick,
|
||
isMapToggleKey, landingFor, mapToScreen, screenToMap, sha256Hex, worldToMap, zoomAt,
|
||
} from "./mapMath.js";
|
||
|
||
/**
|
||
* Große Kartenansicht auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md).
|
||
* Dünne Canvas-Schicht; alles Rechnerische steht getestet in mapMath.js.
|
||
*
|
||
* Eingaben: InputSystem des Controllers hört auf window in der Bubble-Phase. Die Karte hört auf
|
||
* window in der Capture-Phase und wirkt nur bei offener Karte: Tasten fängt sie dort vollständig
|
||
* ab, Mausereignisse außerhalb der Karte ebenfalls. Mausereignisse auf der Karte erreichen den
|
||
* Canvas und werden an der Kartenwurzel gestoppt, bevor sie window erreichen. unbindEvents() wäre
|
||
* falsch: es setzt isupdate = false und hielte die ganze Figur an, nicht nur die Eingabe.
|
||
*/
|
||
|
||
const STYLE = `
|
||
.leonida-map { position: fixed; inset: 0; z-index: 1500; background: rgba(10, 18, 28, 0.94); }
|
||
.leonida-map[hidden] { display: none; }
|
||
.leonida-map canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
|
||
.leonida-map-note, .leonida-map-readout, .leonida-map-here {
|
||
position: absolute; padding: 6px 10px; border-radius: 6px; pointer-events: none;
|
||
font: 14px/1.3 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||
color: #fff; background: rgba(0, 0, 0, 0.6);
|
||
}
|
||
.leonida-map-note { top: 16px; left: 50%; transform: translateX(-50%); }
|
||
.leonida-map-here { top: 16px; left: 16px; }
|
||
.leonida-map-readout { bottom: 16px; right: 16px; font-variant-numeric: tabular-nums; }
|
||
.leonida-map-note:empty, .leonida-map-readout:empty { display: none; }
|
||
`;
|
||
|
||
const NOTE_MISSING = "Kartenbild fehlt — cd tools/extract && uv run python overview.py ausführen";
|
||
const NOTE_NO_MASK = "Begehbarkeitsmaske fehlt — Klick zum Hinspringen ist abgeschaltet";
|
||
const NOTE_STALE = "Karte veraltet — overview.py erneut ausführen";
|
||
const NOTE_NO_LANDING = "Kein Landeplatz";
|
||
|
||
function loadImage(url) {
|
||
const image = new Image();
|
||
image.src = url;
|
||
return image.decode().then(() => image);
|
||
}
|
||
|
||
/** Graustufenmaske als Uint8Array; Schwelle statt Gleichheit, falls der Browser Farben leicht verschiebt. */
|
||
function readMask(image) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = image.naturalWidth;
|
||
canvas.height = image.naturalHeight;
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
ctx.drawImage(image, 0, 0);
|
||
const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||
const mask = new Uint8Array(canvas.width * canvas.height);
|
||
for (let k = 0; k < mask.length; k++) mask[k] = rgba[k * 4] >= 128 ? 255 : 0;
|
||
return { mask, width: canvas.width, height: canvas.height };
|
||
}
|
||
|
||
function drawArrow(ctx, x, y, heading, dpr) {
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate(heading);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -12 * dpr);
|
||
ctx.lineTo(8 * dpr, 9 * dpr);
|
||
ctx.lineTo(0, 4 * dpr);
|
||
ctx.lineTo(-8 * dpr, 9 * dpr);
|
||
ctx.closePath();
|
||
ctx.fillStyle = "#ffd84a";
|
||
ctx.strokeStyle = "#141414";
|
||
ctx.lineWidth = 2 * dpr;
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawVehicle(ctx, x, y, heading, dpr) {
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate(heading);
|
||
ctx.fillStyle = "#4ad0ff";
|
||
ctx.strokeStyle = "#0b2a3a";
|
||
ctx.lineWidth = 1.5 * dpr;
|
||
ctx.fillRect(-4 * dpr, -7 * dpr, 8 * dpr, 14 * dpr);
|
||
ctx.strokeRect(-4 * dpr, -7 * dpr, 8 * dpr, 14 * dpr);
|
||
ctx.restore();
|
||
}
|
||
|
||
export function createMap({ getPosition, getHeading, getVehicles, onTeleport, baseUrl = "./data/vice-a" }) {
|
||
const style = document.createElement("style");
|
||
style.textContent = STYLE;
|
||
document.head.appendChild(style);
|
||
|
||
const root = document.createElement("div");
|
||
root.className = "leonida-map";
|
||
root.hidden = true;
|
||
const canvas = document.createElement("canvas");
|
||
const note = document.createElement("div");
|
||
note.className = "leonida-map-note";
|
||
const here = document.createElement("div");
|
||
here.className = "leonida-map-here";
|
||
const readout = document.createElement("div");
|
||
readout.className = "leonida-map-readout";
|
||
root.append(canvas, note, here, readout);
|
||
document.body.appendChild(root);
|
||
|
||
const state = {
|
||
open: false, info: null, picture: null, walk: null, failed: false, noMask: false, stale: false,
|
||
view: null, dpr: 1, drag: null, hover: null, flashText: "", flashUntil: 0, frame: 0,
|
||
};
|
||
let loading = null;
|
||
|
||
function load() {
|
||
loading ??= (async () => {
|
||
try {
|
||
const response = await fetch(`${baseUrl}/map.json`);
|
||
if (!response.ok) throw new Error(`map.json: ${response.status}`);
|
||
state.info = await response.json();
|
||
state.picture = await loadImage(`${baseUrl}/map.png`);
|
||
} catch {
|
||
state.failed = true;
|
||
return;
|
||
}
|
||
try {
|
||
state.walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`));
|
||
} catch {
|
||
state.noMask = true;
|
||
}
|
||
try {
|
||
const bytes = await fetch(`${baseUrl}/manifest.json`).then((r) => r.arrayBuffer());
|
||
const hash = await sha256Hex(bytes);
|
||
state.stale = hash !== null && hash !== state.info.manifest_sha256;
|
||
} catch {
|
||
// Manifest nicht lesbar: ohne Vergleichswert keine Aussage über Veraltung.
|
||
}
|
||
})();
|
||
return loading;
|
||
}
|
||
|
||
const mapSize = () => state.picture.naturalWidth;
|
||
|
||
function resize() {
|
||
state.dpr = Math.min(devicePixelRatio, 2);
|
||
canvas.width = Math.round(innerWidth * state.dpr);
|
||
canvas.height = Math.round(innerHeight * state.dpr);
|
||
if (state.picture) state.view = fitView(mapSize(), canvas.width, canvas.height);
|
||
}
|
||
|
||
const toCanvas = (event) => ({ x: event.offsetX * state.dpr, y: event.offsetY * state.dpr });
|
||
|
||
function flash(text) {
|
||
state.flashText = text;
|
||
state.flashUntil = performance.now() + 2000;
|
||
}
|
||
|
||
function draw() {
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const { info, picture, view, dpr } = state;
|
||
if (state.failed) {
|
||
note.textContent = NOTE_MISSING;
|
||
here.textContent = "";
|
||
readout.textContent = "";
|
||
return;
|
||
}
|
||
if (!picture || !view) {
|
||
note.textContent = "Karte lädt …";
|
||
return;
|
||
}
|
||
|
||
ctx.imageSmoothingEnabled = view.scale < 1;
|
||
ctx.drawImage(picture, view.offsetX, view.offsetY, mapSize() * view.scale, mapSize() * view.scale);
|
||
|
||
const chunkPx = CHUNK_SIZE * info.scale * view.scale;
|
||
const gridSize = CHUNKS_PER_SIDE * chunkPx;
|
||
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let k = 0; k <= CHUNKS_PER_SIDE; k++) {
|
||
ctx.moveTo(view.offsetX + k * chunkPx, view.offsetY);
|
||
ctx.lineTo(view.offsetX + k * chunkPx, view.offsetY + gridSize);
|
||
ctx.moveTo(view.offsetX, view.offsetY + k * chunkPx);
|
||
ctx.lineTo(view.offsetX + gridSize, view.offsetY + k * chunkPx);
|
||
}
|
||
ctx.stroke();
|
||
|
||
const position = getPosition();
|
||
const me = worldToMap(info, position.x, position.z);
|
||
const hereName = chunkNameAtMap(info, me.px, me.py);
|
||
|
||
if (chunkPx / dpr >= LABEL_MIN_CHUNK_PX) {
|
||
ctx.font = `600 ${Math.round(Math.min(chunkPx * 0.2, 26 * dpr))}px system-ui, sans-serif`;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
for (let j = 0; j < CHUNKS_PER_SIDE; j++) {
|
||
for (let i = 0; i < CHUNKS_PER_SIDE; i++) {
|
||
const name = chunkName(i, j);
|
||
ctx.fillStyle = name === hereName ? "rgba(255, 216, 74, 0.95)" : "rgba(255, 255, 255, 0.6)";
|
||
ctx.fillText(name, view.offsetX + (i + 0.5) * chunkPx, view.offsetY + (j + 0.5) * chunkPx);
|
||
}
|
||
}
|
||
}
|
||
if (hereName) {
|
||
const { i, j } = parseChunkName(hereName);
|
||
ctx.strokeStyle = "rgba(255, 216, 74, 0.9)";
|
||
ctx.lineWidth = 2 * dpr;
|
||
ctx.strokeRect(view.offsetX + i * chunkPx, view.offsetY + j * chunkPx, chunkPx, chunkPx);
|
||
}
|
||
|
||
for (const vehicle of getVehicles()) {
|
||
if (vehicle.occupied) continue;
|
||
const m = worldToMap(info, vehicle.x, vehicle.z);
|
||
const s = mapToScreen(view, m.px, m.py);
|
||
drawVehicle(ctx, s.sx, s.sy, vehicle.heading, dpr);
|
||
}
|
||
const s = mapToScreen(view, me.px, me.py);
|
||
drawArrow(ctx, s.sx, s.sy, getHeading(), dpr);
|
||
|
||
here.textContent = hereName ? `Du bist in ${hereName}` : "Außerhalb des Ausschnitts";
|
||
readout.textContent = state.hover ?? "";
|
||
note.textContent = performance.now() < state.flashUntil ? state.flashText
|
||
: state.stale ? NOTE_STALE
|
||
: state.noMask ? NOTE_NO_MASK
|
||
: "";
|
||
}
|
||
|
||
function loop() {
|
||
if (!state.open) return;
|
||
draw();
|
||
state.frame = requestAnimationFrame(loop);
|
||
}
|
||
|
||
async function open() {
|
||
if (state.open) return;
|
||
state.open = true;
|
||
root.hidden = false;
|
||
document.exitPointerLock?.();
|
||
// InputSystem hört auf blur und ruft dort resetKeys(): eine beim Drücken von M gehaltene
|
||
// Bewegungstaste bleibt sonst gesetzt, und die Figur liefe hinter der Karte weiter.
|
||
window.dispatchEvent(new Event("blur"));
|
||
resize();
|
||
loop();
|
||
await load();
|
||
if (state.open) resize(); // beim Öffnen immer der ganze Ausschnitt, auch nach dem ersten Laden
|
||
}
|
||
|
||
function close() {
|
||
state.open = false;
|
||
root.hidden = true;
|
||
state.drag = null;
|
||
state.hover = null;
|
||
cancelAnimationFrame(state.frame);
|
||
}
|
||
|
||
function jumpTo(point) {
|
||
if (!state.walk) {
|
||
flash(NOTE_NO_MASK);
|
||
return;
|
||
}
|
||
const m = screenToMap(state.view, point.x, point.y);
|
||
const target = landingFor(state.info, state.walk, m.px, m.py);
|
||
if (!target) {
|
||
flash(NOTE_NO_LANDING);
|
||
return;
|
||
}
|
||
close();
|
||
onTeleport(target);
|
||
}
|
||
|
||
// —— Eingaben ——
|
||
function onKeyCapture(event) {
|
||
const typing = document.activeElement?.tagName ?? "";
|
||
if (state.open) {
|
||
if (event.type === "keydown" && (event.code === "Escape" || isMapToggleKey(event, typing))) close();
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
if (event.type === "keydown" && isMapToggleKey(event, typing)) {
|
||
event.stopPropagation();
|
||
void open();
|
||
}
|
||
}
|
||
window.addEventListener("keydown", onKeyCapture, true);
|
||
window.addEventListener("keyup", onKeyCapture, true);
|
||
|
||
for (const type of ["mousedown", "mouseup", "mousemove", "click", "wheel"]) {
|
||
window.addEventListener(type, (event) => {
|
||
if (state.open && !root.contains(event.target)) event.stopPropagation();
|
||
}, true);
|
||
root.addEventListener(type, (event) => event.stopPropagation());
|
||
}
|
||
|
||
canvas.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
if (!state.view) return;
|
||
const p = toCanvas(event);
|
||
const zoomed = zoomAt(state.view, event.deltaY < 0 ? 1.2 : 1 / 1.2, p.x, p.y);
|
||
state.view = clampView(zoomed, mapSize(), canvas.width, canvas.height);
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener("mousedown", (event) => {
|
||
if (!state.view || event.button !== 0) return;
|
||
const p = toCanvas(event);
|
||
state.drag = { start: p, last: p, view: state.view };
|
||
});
|
||
|
||
canvas.addEventListener("mousemove", (event) => {
|
||
if (!state.view) return;
|
||
const p = toCanvas(event);
|
||
const m = screenToMap(state.view, p.x, p.y);
|
||
state.hover = hoverLabel(state.info, m.px, m.py);
|
||
if (!state.drag) return;
|
||
const moved = { ...state.view, offsetX: state.view.offsetX + p.x - state.drag.last.x,
|
||
offsetY: state.view.offsetY + p.y - state.drag.last.y };
|
||
state.view = clampView(moved, mapSize(), canvas.width, canvas.height);
|
||
state.drag.last = p;
|
||
});
|
||
|
||
canvas.addEventListener("mouseup", (event) => {
|
||
if (!state.drag) return;
|
||
const p = toCanvas(event);
|
||
const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr);
|
||
if (click) state.view = state.drag.view; // ein Klick soll die Karte nicht verschieben
|
||
state.drag = null;
|
||
if (click) jumpTo(p);
|
||
});
|
||
|
||
canvas.addEventListener("mouseleave", () => {
|
||
state.drag = null;
|
||
state.hover = null;
|
||
});
|
||
|
||
addEventListener("resize", () => {
|
||
if (state.open) resize();
|
||
});
|
||
|
||
return { open, close, toggle: () => (state.open ? close() : open()), isOpen: () => state.open };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: In `src/main.js` einhängen**
|
||
|
||
Importe ergänzen — die bestehende Zeile `import { playerIsGrounded, playerPosition } from "./player.js";` ersetzen und zwei Zeilen anfügen:
|
||
|
||
```js
|
||
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
|
||
import { createMap } from "./ui/map.js";
|
||
import { headingFromDirection } from "./ui/mapMath.js";
|
||
```
|
||
|
||
Direkt nach der Zeile `createPanel({ player, characterModule, scene, manifest, requestTeleport });` einfügen:
|
||
|
||
```js
|
||
// Minimap auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md). Position über
|
||
// playerPosition — im Fahrzeug liefert die Kapsel nur den lokalen Sitzplatz-Offset. Die Richtung
|
||
// kommt aus der Kamera, die zu Fuß der Blickrichtung und im Fahrzeug dessen Richtung folgt.
|
||
// Sprünge laufen über requestTeleport und damit über dessen Wartewache bis zum fertigen Chunk.
|
||
const MAP_TELEPORT_HEIGHT = 3;
|
||
const mapCameraDirection = new Vector3();
|
||
const mapVehicleForward = new Vector3();
|
||
createMap({
|
||
getPosition: () => playerPosition(player),
|
||
getHeading: () => {
|
||
camera.getWorldDirection(mapCameraDirection);
|
||
return headingFromDirection(mapCameraDirection.x, mapCameraDirection.z);
|
||
},
|
||
getVehicles: () => {
|
||
const riding = activeVehicleOrNull(player);
|
||
return player.getAllVehicles().map((vehicle) => {
|
||
mapVehicleForward.copy(vehicle.forwardLocal).transformDirection(vehicle.vehicleGroup.matrixWorld);
|
||
return {
|
||
x: vehicle.vehicleGroup.position.x,
|
||
z: vehicle.vehicleGroup.position.z,
|
||
heading: headingFromDirection(mapVehicleForward.x, mapVehicleForward.z),
|
||
occupied: vehicle === riding,
|
||
};
|
||
});
|
||
},
|
||
onTeleport: ({ x, z }) => requestTeleport({ x, y: MAP_TELEPORT_HEIGHT, z }),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Suite laufen lassen**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, Ausgabe sauber. (Keine neuen Tests; die Suite belegt, dass nichts Bestehendes gebrochen ist.)
|
||
|
||
- [ ] **Step 6: Im Browser prüfen — das ist die Abnahme dieser Aufgabe**
|
||
|
||
Dev-Server starten, Seite öffnen, jeweils mit Screenshot im Bericht:
|
||
|
||
1. **M** öffnet die Karte: ganzer Ausschnitt, Raster, Namen A1–L12, A1 oben links, gelber Pfeil, „Du bist in …" oben links. Die Namensnennung ist unten links weiterhin sichtbar.
|
||
2. Zeiger über der Karte bewegen: unten rechts erscheint `Name · x … / y …`. Den Zeiger über den Downtown-Bezugspunkt Welt `(−511, 254)` führen — dort muss `B9` stehen; über dem Southeast Financial Center `C12`.
|
||
3. Mausrad zoomt um den Zeiger bis vierfach; Ziehen verschiebt und stoppt am Kartenrand.
|
||
4. Ein Eingabefeld im Panel anklicken, dann M drücken: die Karte darf **nicht** aufgehen.
|
||
5. W gedrückt halten, M drücken, W loslassen, Karte schließen: die Figur darf **nicht** weiterlaufen.
|
||
6. Klick auf eine Straße: Karte schließt, Sprung genau dorthin. Klick mitten auf ein großes Gebäude in B9: Landung auf der Straße daneben. Klick mitten in die Bucht: „Kein Landeplatz", die Karte bleibt offen.
|
||
7. Mit **E** in ein Auto steigen, fahren, Karte öffnen: Pfeil an der Fahrzeugposition, das eigene Auto nicht zusätzlich als Symbol, die übrigen Autos als Symbole.
|
||
8. `public/data/vice-a/map.json` vorübergehend umbenennen, Seite neu laden, M: Hinweis „Kartenbild fehlt". Datei zurückbenennen.
|
||
9. In `public/data/vice-a/map.json` vorübergehend eine Ziffer von `manifest_sha256` ändern, neu laden, M: „Karte veraltet". Datei zurücksetzen.
|
||
|
||
Bekannte Eigenheiten der Vorschau, die keine Fehler dieses Codes sind: Pointer-Lock schlägt im iframe fehl (`WrongDocumentError`), und die Animationsschleife der Seite kann ruhen, solange das Vorschaufenster verborgen ist. Wer für Screenshots einen temporären `window`-Haken setzt, entfernt ihn vor dem Commit und liest **den vollständigen Diff** vor dem Commit.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/ui/map.js src/main.js src/player.js index.html
|
||
git commit -m "feat(ui): Minimap auf Taste M mit Chunk-Raster, Koordinaten und Klick zum Hinspringen
|
||
|
||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- src/ui/map.js src/main.js src/player.js index.html
|
||
```
|