(−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>
186 lines
15 KiB
Markdown
186 lines
15 KiB
Markdown
# Minimap — Entwurf
|
||
|
||
**Datum:** 2026-09-13
|
||
**Zusammenhang:** Teil des Boden-Updates, das der Nutzer nach dem Spielen angefordert hat. Reihenfolge vom Nutzer entschieden: Schichten und Minimap parallel, danach Straßenbau (Rissrichtung, Bordstein, Gehweg).
|
||
**Baut auf:** `docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md` und dem umgesetzten Plan dazu.
|
||
|
||
## Ziel
|
||
|
||
Eine große Kartenansicht des gesamten Vice-City-Ausschnitts, die sich über dem laufenden Spiel öffnet, wenn man **M** drückt.
|
||
|
||
## Entscheidungen des Nutzers
|
||
|
||
| Frage | Entscheidung |
|
||
|---|---|
|
||
| Darstellung | Große Karte als Überlagerung, bildschirmfüllend. M oder Esc schließt. Keine Eckkarte. |
|
||
| Inhalt | Eigene Position mit Blickrichtung, Fahrzeuge, Zoomen und Verschieben, Klick zum Hinspringen. **Keine** Wahrzeichen. |
|
||
| Kartenbild | Vorab erzeugt, 1 Pixel je Meter. |
|
||
| Welt während offener Karte | Läuft weiter, keine Pause. |
|
||
| Klick auf Gebäude oder Wasser | Landung auf dem nächsten begehbaren Punkt im Umkreis von 20 m, sonst Hinweis und kein Sprung. |
|
||
| Chunks | Raster auf der Karte sichtbar, jeder Chunk benannt als **Planquadrat A1 bis L12**. Zweck: der Nutzer spricht Chunks im Chat für die Feinkalibrierung an. |
|
||
|
||
## Daten
|
||
|
||
Drei neue Dateien neben `manifest.json` in `public/data/vice-a/`:
|
||
|
||
| Datei | Inhalt |
|
||
|---|---|
|
||
| `map.png` | 3.000 × 3.000 px, 1 px/m. Wasser, Land, Sand, Park, Stadtfläche, Straßen und Brücken, Gebäude nach Höhe getönt — dieselben Farben wie die Gesamtansicht. **Ohne Palmen**: bei 1 px/m wären sie nur Rauschen. Löcher werden wie in der Gesamtansicht über eine Maske korrekt ausgespart. |
|
||
| `map-walkable.png` | 750 × 750 px, 4 m/px, Graustufen. 255 = begehbar (Land, Straße, Brücke, Sand, Park, Stadtfläche außerhalb von Gebäuden), 0 = nicht begehbar (Wasser oder Gebäude). |
|
||
| `map.json` | `{ "scale": 1.0, "walkable_scale": 0.25, "slice": { "x0": -1000, "x1": 2000, "y0": -500, "y1": 2500 }, "manifest_sha256": "<SHA-256 von manifest.json>" }` |
|
||
|
||
`map.json` ist die **einzige** Quelle für Maßstab und Ausschnittsgrenzen der Karte. Der Browser liest sie, statt die Werte ein zweites Mal im JavaScript festzuschreiben.
|
||
|
||
**Erzeugung:** vorerst als eigener Schritt `cd tools/extract && uv run python overview.py`, **nicht** in `run.py`. Grund: die Beschriftungs-Aufgabe task_999fed4a arbeitet gleichzeitig in einer anderen Sitzung an der Pipeline, und zwei Sitzungen sollen nicht in dieselbe Datei schreiben. Der Preis dieser Entkopplung ist, dass die Karte nach einem Pipeline-Lauf veralten kann — abgesichert über `manifest_sha256` (siehe Fehlerfälle). Das Einhängen in `run.py` folgt, sobald die Pipeline frei ist.
|
||
|
||
## Aufbau
|
||
|
||
| Einheit | Aufgabe |
|
||
|---|---|
|
||
| `tools/extract/overview.py` | Zusätzlicher Ausgang für `map.png`, `map-walkable.png` und `map.json`. Die bestehende Debug-Gesamtansicht bleibt unverändert erhalten. |
|
||
| `src/ui/mapMath.js` | Reine Funktionen ohne DOM, vollständig ohne Browser testbar: `worldToMap`, `mapToWorld`, Zoom- und Verschiebungsgrenzen, Unterscheidung Klick gegen Ziehen, `nearestWalkable(mask, px, py, radiusPx)`. |
|
||
| `src/ui/map.js` | Die Kartenkomponente: ein Canvas über dem Spiel, zeichnet jedes Bild Kartenbild, Spielerpfeil und Fahrzeugsymbole. Öffnen, Schließen, Offen-Zustand. |
|
||
| `src/main.js` | Nur Einhängen: Taste M und Esc, Position, Blickrichtung, Fahrzeuge, Sprung. |
|
||
|
||
Die Kartenkomponente bekommt ihre Abhängigkeiten übergeben, statt selbst in Controller oder Streamer zu greifen:
|
||
|
||
```js
|
||
createMap({
|
||
getPosition, // () => { x, z } in three-Koordinaten
|
||
getHeading, // () => Winkel in Radiant, 0 = Norden, im Uhrzeigersinn
|
||
getVehicles, // () => [{ x, z, heading, occupied }]
|
||
onTeleport, // ({ x, z }) => void
|
||
baseUrl, // Verzeichnis mit map.json, map.png, map-walkable.png und manifest.json
|
||
}) // -> { open(), close(), toggle(), isOpen() }
|
||
```
|
||
|
||
Die Karte lädt `map.json`, beide Bilder und das Manifest selbst, und zwar erst beim ersten Öffnen. So bricht eine fehlende Kartendatei nicht den Spielstart, sondern zeigt ihren Hinweis erst, wenn jemand M drückt. Das Manifest liest sie als **Rohbytes**: der Fingerabdruck in `map.json` ist über die Datei gebildet, ein bereits geparstes und neu serialisiertes Objekt ergäbe einen anderen Wert.
|
||
|
||
## Koordinaten
|
||
|
||
three.js (x, z) → Welt (x, y = −z) → Kartenpixel:
|
||
|
||
```
|
||
px = (x − slice.x0) · scale
|
||
py = (slice.y1 − y) · scale = (slice.y1 + z) · scale
|
||
```
|
||
|
||
Die Umkehrung dient den Klicks. `mapMath.js` ist die **einzige** Stelle, an der die Achsenumkehr zwischen three.js-z und Karten-y steht — genau dort würde sonst eine gespiegelte Karte entstehen.
|
||
|
||
## Chunk-Raster und Namen
|
||
|
||
Die Namen sind die Schnittstelle zwischen Nutzer und Feinkalibrierung: was im Chat „C10" heißt, muss auf der Karte, in den Dateinamen und in der Pipeline eindeutig dasselbe sein.
|
||
|
||
**Schema:** Spalte als Buchstabe `A` bis `L` aus `i`, Zeile als Zahl `1` bis `12` aus `j`.
|
||
|
||
```
|
||
name(i, j) = "ABCDEFGHIJKL"[i] + (j + 1)
|
||
```
|
||
|
||
**Richtung, gegen die Daten geprüft:** `chunkOrigin(i, j)` liefert `ox = x0 + i · 250` und `oy = y1 − j · 250`. `i` wächst also nach Osten, `j` von der Nordkante nach Süden. Damit gilt:
|
||
|
||
| Name | Datei | Lage |
|
||
|---|---|---|
|
||
| A1 | `chunk_0_0.json` | Nordwest-Ecke, oben links auf der Karte |
|
||
| L1 | `chunk_11_0.json` | Nordost-Ecke |
|
||
| A12 | `chunk_0_11.json` | Südwest-Ecke |
|
||
| L12 | `chunk_11_11.json` | Südost-Ecke |
|
||
| C10 | `chunk_2_9.json` | Beispiel |
|
||
|
||
**Eine Umrechnung, zweimal geschrieben, einmal geprüft:** `chunkName(i, j)` und `parseChunkName(name) -> { i, j }` stehen in `src/world/coords.js` neben `chunkForWorld`, und dieselben zwei Funktionen als `chunk_name` und `parse_chunk_name` in `tools/extract/leonida/coords.py` — die Feinkalibrierung wird sie in der Pipeline brauchen. Beide Seiten prüfen denselben festen Satz an Beispielen aus der Tabelle oben, damit JavaScript und Python nicht auseinanderlaufen können. `parseChunkName` akzeptiert Groß- und Kleinschreibung (`c10` = `C10`) und weist alles außerhalb von A1–L12 ab.
|
||
|
||
**Auf der Karte:**
|
||
- Rasterlinien an allen Chunk-Grenzen.
|
||
- Der Name steht in der Mitte jedes Chunks, sobald der Chunk auf dem Bildschirm mindestens 40 px breit ist; darunter nur die Linien. Beim Öffnen passt der ganze Ausschnitt ins Fenster, dann ist jeder Chunk auf gängigen Bildschirmen breit genug.
|
||
- Eine feste Anzeige unten rechts nennt Chunk-Name und Weltkoordinate der Stelle unter dem Mauszeiger, etwa `F3 · x 412 / y 1830`. Die Koordinate erlaubt, eine Stelle innerhalb eines Chunks genau zu benennen.
|
||
- Der Chunk, in dem der Spieler gerade steht, ist hervorgehoben und sein Name steht fest in einer Ecke der Karte.
|
||
|
||
## Anbindung an vorhandene Funktionen
|
||
|
||
- **Position:** über `playerPosition(player)` aus `src/player.js`. Damit fährt der Pfeil auch im Fahrzeug korrekt mit; dort liefert die Kapselposition nur einen lokalen Sitzplatz-Offset.
|
||
- **Blickrichtung:** aus der Weltrichtung der Kamera, auf die Bodenebene projiziert. Das gilt zu Fuß wie im Fahrzeug gleichermaßen, weil die Kamera der Fahrzeugrichtung folgt (`followVehicleDirection: true`).
|
||
- **Fahrzeuge:** über `player.getAllVehicles()`. Das Fahrzeug, in dem der Spieler sitzt, wird nicht zusätzlich als eigenes Symbol gezeichnet. Ob der Spieler gerade fährt, kommt aus `player.getControllerMode() === 1`, **nicht** aus `getActiveVehicle()` — letzteres wird beim Aussteigen nie zurückgesetzt.
|
||
- **Sprung:** über das vorhandene `requestTeleport`, mit seiner Wartewache, bis der Ziel-Chunk bereit ist.
|
||
|
||
## Bedienung
|
||
|
||
- **Öffnen und Schließen:** M öffnet und schließt, Esc schließt. M wird ignoriert, solange ein Eingabefeld (`input`, `textarea`, `select`) den Fokus hat — sonst öffnete sich die Karte beim Eintippen eines Werts im Panel.
|
||
- **Beim Öffnen** immer der ganze Ausschnitt, eingepasst ins Fenster.
|
||
- **Mausfang:** beim Öffnen wird der Pointer-Lock gelöst. Beim Schließen wird er nicht automatisch neu angefordert; Browser erlauben das nur nach einer Nutzergeste, ein Klick ins Spiel fängt ihn wie gewohnt.
|
||
- **Steuerung ruht, Welt läuft.** Solange die Karte offen ist, erreichen Tastatur- und Mausereignisse die Figur nicht; Streaming, Fahrzeugphysik und Animation laufen weiter.
|
||
|
||
Umsetzung, am Quelltext des Controllers belegt: `InputSystem` registriert `keydown`, `keyup`, `mousemove` und `click` auf `window` in der Bubble-Phase.
|
||
- Die Karte registriert Listener auf `window` **in der Capture-Phase**, die nur bei offener Karte wirken. Tastaturereignisse fängt sie dort vollständig ab (und wertet M und Esc selbst aus). Mausereignisse, deren Ziel **außerhalb** der Karte liegt, fängt sie dort ab; Mausereignisse **auf** der Karte lässt sie bis zum Canvas durch und stoppt sie an der Kartenwurzel in der Bubble-Phase, bevor sie `window` erreichen. Ein Stopp schon in der Capture-Phase auf `window` würde sonst auch den eigenen Canvas-Listenern die Ereignisse nehmen.
|
||
- Die Listener bleiben dauerhaft registriert statt beim Öffnen hinzugefügt und beim Schließen entfernt zu werden: M muss bei geschlossener Karte ohnehin gehört werden, und ein vergessenes Entfernen kann so nicht passieren.
|
||
- Beim Öffnen wird einmal `window.dispatchEvent(new Event("blur"))` ausgelöst. Das ruft im Controller `resetKeys()` auf, damit eine beim Drücken von M gehaltene Bewegungstaste nicht hängen bleibt und die Figur weiterlaufen lässt.
|
||
- **Nicht** `unbindEvents()` verwenden: es setzt `isupdate = false` und hält damit die gesamte Figurenaktualisierung an, nicht nur die Eingabe. Das widerspräche der Entscheidung, dass die Welt weiterläuft.
|
||
- **Zoomen:** Mausrad, um den Mauszeiger herum, von „ganzer Ausschnitt passt ins Fenster" bis vierfach.
|
||
- **Verschieben:** Ziehen mit gedrückter Maustaste, begrenzt auf den Kartenrand.
|
||
- **Hinspringen:** Ein Klick — Maustaste gedrückt und losgelassen mit weniger als 4 px Bewegung dazwischen — springt an die Stelle und schließt die Karte. Mit 4 px oder mehr Bewegung ist es ein Ziehen, und es wird nicht gesprungen.
|
||
- **Landeplatz:** Der Klickpunkt wird in `map-walkable.png` nachgeschlagen.
|
||
- Begehbar: Sprung genau dorthin.
|
||
- Nicht begehbar (Gebäude oder Wasser): Sprung auf den nächsten begehbaren Pixel im Umkreis von 20 m, gemessen als euklidischer Abstand. In Maskenpixeln sind das `20 · walkable_scale = 5` px — `nearestWalkable` erhält den Radius in Pixeln, die Umrechnung aus Metern geschieht an genau einer Stelle über `map.json`.
|
||
- Kein begehbarer Pixel im Umkreis: kein Sprung, die Karte bleibt offen, und für etwa zwei Sekunden erscheint „Kein Landeplatz".
|
||
|
||
## Fehlerfälle
|
||
|
||
| Fall | Verhalten |
|
||
|---|---|
|
||
| `map.png` lässt sich nicht laden | M öffnet die Überlagerung mit dem Hinweis „Kartenbild fehlt — `uv run python overview.py` ausführen". Kein Absturz, das Spiel läuft weiter. |
|
||
| `map-walkable.png` lässt sich nicht laden | Anschauen, Zoomen und Verschieben funktionieren. Klick zum Hinspringen ist abgeschaltet, ein Hinweis nennt den Grund. |
|
||
| `map.json` fehlt oder ist ungültig | Wie fehlendes Kartenbild: ohne Maßstab und Grenzen lässt sich nichts korrekt platzieren. |
|
||
| `manifest_sha256` passt nicht zum geladenen Manifest | Die Karte funktioniert vollständig, zeigt aber sichtbar „Karte veraltet — `overview.py` erneut ausführen". |
|
||
| Kein sicherer Kontext (Aufruf über eine LAN-Adresse statt `localhost`) | `crypto.subtle` fehlt dort. Die Veraltungsprüfung entfällt still, alles andere funktioniert. |
|
||
|
||
**Grenze der Veraltungsprüfung:** Der Fingerabdruck erkennt jede Änderung am Manifest — Klassenanteile, Chunk-Liste, Namensnennung. Eine Änderung, die nur Chunk-Inhalte betrifft, ohne das Manifest zu berühren, bleibt unerkannt; genau so eine war die Mindestbreite für Gebäude. Diese Lücke schließt sich erst, wenn die Kartenerzeugung in `run.py` hängt und damit gar nicht mehr veralten kann.
|
||
|
||
## Namensnennung
|
||
|
||
Die Namensnennung bleibt auch bei offener Karte sichtbar. Das ist Lizenzpflicht, und die Karte ist selbst aus den CC-BY-4.0-Kacheln abgeleitet. Stapelordnung von oben nach unten:
|
||
|
||
| Ebene | `z-index` |
|
||
|---|---|
|
||
| Namensnennung `.attribution` | 2000 (heute 10, wird angehoben) |
|
||
| Kartenüberlagerung | 1500 |
|
||
| lil-gui-Panel | 1001 (Vorgabe von lil-gui) |
|
||
| Spiel-Canvas | Grundebene |
|
||
|
||
Die Namensnennung steht unten links; die Koordinatenanzeige der Karte steht deshalb unten **rechts**, damit sich beide auf schmalen Fenstern nicht überdecken.
|
||
|
||
## Prüfung
|
||
|
||
**Chunk-Namen, in beiden Sprachen mit demselben Beispielsatz (vitest und pytest):**
|
||
- `A1 ↔ (0, 0)`, `L1 ↔ (11, 0)`, `A12 ↔ (0, 11)`, `L12 ↔ (11, 11)`, `C10 ↔ (2, 9)` in beide Richtungen.
|
||
- Kleinschreibung wird akzeptiert; `M1`, `A0`, `A13`, `A`, `10` und die leere Eingabe werden abgewiesen.
|
||
- Für jeden der 144 Chunks ergibt `parseChunkName(chunkName(i, j))` wieder `(i, j)`.
|
||
- Ein Test, der die Namen mit der tatsächlichen Lage verbindet statt nur mit den Indizes: der Downtown-Bezugspunkt aus `tools/extract/data/districts.json` bei Welt `(−511, 254)` ergibt über `chunkForWorld` `(i = 1, j = 8)` und damit `B9`; der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, ergibt `C12`. An der bestehenden Funktion nachgerechnet. (Korrigiert nach Task 4: `(−511, 254)` ist der Mittelpunkt der westlichen Hochhäuser, nicht der Hochhauskern selbst.)
|
||
|
||
**Ohne Browser (vitest), für `mapMath.js`:**
|
||
- `worldToMap` und `mapToWorld` an bekannten Punkten: Spawn `(1699, 3, −743)` und alle vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
|
||
- Zoomgrenzen: nie kleiner als „ganzer Ausschnitt", nie größer als vierfach; Verschiebung bleibt innerhalb des Kartenrands.
|
||
- Klick gegen Ziehen an der 4-px-Schwelle, knapp darunter und knapp darüber.
|
||
- `nearestWalkable` an einer künstlichen Maske: begehbarer Treffer bleibt, wo er ist; ein Gebäudepixel neben einer Straße findet die Straße; nur Wasser im Umkreis ergibt kein Ergebnis; ein Treffer knapp außerhalb von 20 m zählt nicht.
|
||
|
||
**Pipeline (pytest), für `overview.py`:**
|
||
- `map.png` hat 3.000 × 3.000 px; ein bekannter Wasserpunkt in der Bucht ist wasserfarben, ein bekannter Landpunkt nicht.
|
||
- `map-walkable.png` hat 750 × 750 px; die Fläche eines Gebäudegrundrisses ist 0, eine Straße daneben 255.
|
||
- `map.json` enthält den korrekten SHA-256 des aktuellen Manifests.
|
||
|
||
**Im Browser, mit Bildern im Bericht:**
|
||
- M öffnet, M und Esc schließen; bei fokussiertem Eingabefeld öffnet M nicht.
|
||
- Beim Öffnen mit gehaltener W-Taste bleibt die Figur stehen, statt weiterzulaufen.
|
||
- Zoomen, Verschieben, Kartenrand.
|
||
- Klick auf eine Straße, auf ein Gebäude (Landung daneben) und mitten in die Bucht („Kein Landeplatz").
|
||
- Im Auto fahren und prüfen, dass Pfeil und Richtung mitfahren und das eigene Fahrzeug nicht doppelt erscheint.
|
||
- Mit absichtlich verändertem Manifest erscheint „Karte veraltet".
|
||
|
||
## Nicht Teil dieses Entwurfs
|
||
|
||
- **Die Feinkalibrierung selbst** — wie Korrekturen je Chunk gespeichert und angewendet werden. Eigenes Teilsystem mit eigenem Entwurf; diese Spec liefert dafür nur die Namen, das Raster und die Koordinatenanzeige.
|
||
- Wahrzeichen auf der Karte (vom Nutzer abgewählt).
|
||
- Eine kleine Eckkarte.
|
||
- Pausieren des Spiels.
|
||
- Wegmarken und Routen.
|
||
- Einhängen der Kartenerzeugung in `run.py` — folgt, sobald die Beschriftungs-Aufgabe gelandet ist.
|
||
- Bordstein, Gehweg und Rissrichtung — der anschließende Straßenbau. Kommen dort neue Flächen hinzu, zeichnet `overview.py` sie in einem eigenen Schritt nach.
|