feat(ui): Minimap auf Taste M mit Chunk-Raster, Koordinaten und Klick zum Hinspringen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 11:30:21 +02:00
1 parent c278f62cff
commit 2ece0decb6
4 files changed
+371 -3

No files matched your search

+3 -1
View File
@@ -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;
}
</style>
</head>
+31 -1
View File
@@ -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
+1 -1
View File
@@ -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;
}
+336
View File
@@ -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 };
}