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 }; +}