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:
1 parent
c278f62cff
commit
2ece0decb6
4 files changed
+371
-3
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user