diff --git a/index.html b/index.html
index 05d6c32..d175adf 100644
--- a/index.html
+++ b/index.html
@@ -20,7 +20,9 @@
font: 11px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
border-radius: 4px;
pointer-events: none;
- z-index: 10;
+ /* Ü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;
}
diff --git a/src/main.js b/src/main.js
index 33e6aca..b79933d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -9,7 +9,9 @@ import { createMaterials } from "./world/materials.js";
import { PropLayer } from "./world/props.js";
import { chunkForWorld, threeToWorld } from "./world/coords.js";
import { createCharacterModule } from "./characters/index.js";
-import { playerIsGrounded, playerPosition } from "./player.js";
+import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
+import { createMap } from "./ui/map.js";
+import { headingFromDirection } from "./ui/mapMath.js";
import { createPanel } from "./ui/panel.js";
import { buildAttribution, mountHud } from "./ui/hud.js";
@@ -183,6 +185,34 @@ function requestTeleport(position) {
createPanel({ player, characterModule, scene, manifest, requestTeleport });
+// 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 }),
+});
+
// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
seafloor.position.set(500, -3.0, -1000); // Oberkante bei y = -2,5
diff --git a/src/player.js b/src/player.js
index 12f4e5f..8fe179f 100644
--- a/src/player.js
+++ b/src/player.js
@@ -19,7 +19,7 @@
* Fahrzeug lieferte getActiveVehicle() weiterhin das verlassene Fahrzeug, obwohl die Kapsel
* schon zurück auf dem Spawnpunkt stand und controllerMode schon wieder 0 war.
*/
-function activeVehicleOrNull(player) {
+export function activeVehicleOrNull(player) {
return player.getControllerMode() === 1 ? player.getActiveVehicle() : null;
}
diff --git a/src/ui/map.js b/src/ui/map.js
new file mode 100644
index 0000000..a70a51b
--- /dev/null
+++ b/src/ui/map.js
@@ -0,0 +1,336 @@
+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 };
+}