fix(ui): Landeplatz nur auf ganzem Kollisionsboden, Trackpad-Zoom, Fokus und Ladefehler der Minimap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 12:47:51 +02:00
1 parent e702517662
commit dd5ced505b
9 files changed
+263 -54

No files matched your search

+48 -14
View File
@@ -1,7 +1,8 @@
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,
isMapToggleKey, isTextEntry, landingFor, mapFilesProblem, mapToScreen, maskMatches, screenToMap, sha256Hex,
wheelZoomFactor, worldToMap, zoomAt,
} from "./mapMath.js";
/**
@@ -26,13 +27,13 @@ const STYLE = `
}
.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-readout { top: 54px; left: 16px; font-variant-numeric: tabular-nums; }
.leonida-map-note:empty, .leonida-map-readout:empty, .leonida-map-here: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_STALE = "Karte veraltet — overview.py erneut ausführen und Seite neu laden";
const NOTE_NO_LANDING = "Kein Landeplatz";
function loadImage(url) {
@@ -113,15 +114,23 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
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 {
const info = await response.json();
const picture = await loadImage(`${baseUrl}/map.png`);
const problem = mapFilesProblem(info, picture.naturalWidth, picture.naturalHeight);
if (problem) throw new Error(problem);
state.info = info;
state.picture = picture;
} catch (error) {
console.warn(`Minimap: ${error?.message ?? error}`);
state.failed = true;
return;
}
try {
state.walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`));
} catch {
const walk = readMask(await loadImage(`${baseUrl}/map-walkable.png`));
if (!maskMatches(state.info, walk.width, walk.height)) throw new Error("map-walkable.png passt nicht zu map.json");
state.walk = walk;
} catch (error) {
console.warn(`Minimap: ${error?.message ?? error}`);
state.noMask = true;
}
try {
@@ -224,7 +233,13 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
function loop() {
if (!state.open) return;
draw();
try {
draw();
} catch (error) {
// Ein unerwarteter Fehler ließe die Karte sonst ohne Hinweis stehen; so erscheint der Fehlerhinweis.
console.error(error);
state.failed = true;
}
state.frame = requestAnimationFrame(loop);
}
@@ -236,6 +251,12 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
// 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"));
if (state.failed || state.noMask) {
// Nach "overview.py ausführen" soll erneutes Öffnen genügen, ohne die Seite neu zu laden.
state.failed = false;
state.noMask = false;
loading = null;
}
resize();
loop();
await load();
@@ -248,6 +269,9 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
state.drag = null;
state.hover = null;
cancelAnimationFrame(state.frame);
// Zeichenpuffer freigeben (bei DPR 2 rund 24 MB); resize() legt ihn beim nächsten Öffnen neu an.
canvas.width = 0;
canvas.height = 0;
}
function jumpTo(point) {
@@ -267,14 +291,23 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
// —— Eingaben ——
function onKeyCapture(event) {
const typing = document.activeElement?.tagName ?? "";
const typing = isTextEntry(document.activeElement);
if (state.open) {
if (event.type === "keydown" && (event.code === "Escape" || isMapToggleKey(event, typing))) close();
if (event.type === "keydown") {
if (event.code === "Escape" || isMapToggleKey(event, typing)) close();
// Tab wanderte sonst ins verdeckte Panel und gäbe dort einem Bedienelement den Fokus.
if (event.code === "Tab") event.preventDefault();
}
event.stopPropagation();
return;
}
if (event.type === "keydown" && isMapToggleKey(event, typing)) {
// Behielte eine Auswahlliste im Panel den Fokus, wählte sie auf M hin per Tipp-Suche einen
// Eintrag, etwa ein Wahrzeichen, und spränge dorthin. Deshalb die Standardaktion abbrechen
// und den Fokus lösen, bevor die Karte aufgeht.
event.preventDefault();
event.stopPropagation();
document.activeElement?.blur?.();
void open();
}
}
@@ -292,14 +325,14 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
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);
const zoomed = zoomAt(state.view, wheelZoomFactor(event.deltaY, event.deltaMode, event.ctrlKey), 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 };
state.drag = { start: p, last: p, view: state.view, farthest: 0 };
});
canvas.addEventListener("mousemove", (event) => {
@@ -308,6 +341,7 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
const m = screenToMap(state.view, p.x, p.y);
state.hover = hoverLabel(state.info, m.px, m.py);
if (!state.drag) return;
state.drag.farthest = Math.max(state.drag.farthest, Math.hypot(p.x - state.drag.start.x, p.y - state.drag.start.y));
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);
@@ -317,7 +351,7 @@ export function createMap({ getPosition, getHeading, getVehicles, onTeleport, ba
canvas.addEventListener("mouseup", (event) => {
if (!state.drag) return;
const p = toCanvas(event);
const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr);
const click = isClick(state.drag.start, p, CLICK_TOLERANCE_PX * state.dpr, state.drag.farthest);
if (click) state.view = state.drag.view; // ein Klick soll die Karte nicht verschieben
state.drag = null;
if (click) jumpTo(p);
+71 -10
View File
@@ -54,20 +54,25 @@ export function clampView(view, mapSize, viewW, viewH) {
export const screenToMap = (view, sx, sy) => ({ px: (sx - view.offsetX) / view.scale, py: (sy - view.offsetY) / view.scale });
export const mapToScreen = (view, px, py) => ({ sx: px * view.scale + view.offsetX, sy: py * view.scale + view.offsetY });
/** Kurzer Klick statt Ziehen: Bewegung zwischen Drücken und Loslassen unter der Toleranz. */
export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX) {
return Math.hypot(up.x - down.x, up.y - down.y) < tolerance;
/**
* Kurzer Klick statt Ziehen: der Zeiger hat sich zwischen Drücken und Loslassen nie so weit wie die
* Toleranz vom Startpunkt entfernt. farthest ist die größte Entfernung unterwegs; ohne sie zählte ein
* Ziehen, das zum Startpunkt zurückkehrt, als Klick.
*/
export function isClick(down, up, tolerance = CLICK_TOLERANCE_PX, farthest = 0) {
return Math.max(farthest, Math.hypot(up.x - down.x, up.y - down.y)) < tolerance;
}
/**
* Nächster begehbarer Maskenpixel (Wert 255) im Umkreis radiusPx, euklidisch gemessen.
* Ist der Pixel unter (x, y) selbst begehbar, bleibt der Punkt genau, wo er ist; sonst die Mitte
* des nächsten begehbaren Pixels. Gleich weite Kandidaten: der erste in Zeilenreihenfolge.
* Mitte des nächsten begehbaren Maskenpixels (Wert 255) im Umkreis radiusPx, euklidisch gemessen,
* oder null. Der Pixel unter (x, y) hat Abstand 0 und gewinnt, wenn er begehbar ist. Immer die
* Pixelmitte, nie der Klickpunkt: overview.py gibt einen Pixel nur frei, wenn seine ganze Fläche
* Kollisionsboden ohne Gebäude ist, und von der Mitte aus sind es dann mindestens 2 m bis zu Wand
* oder Wasser. Gleich weite Kandidaten: der erste in Zeilenreihenfolge.
*/
export function nearestWalkable(mask, width, height, x, y, radiusPx) {
const cx = Math.floor(x), cy = Math.floor(y);
const walkable = (ix, iy) => ix >= 0 && iy >= 0 && ix < width && iy < height && mask[iy * width + ix] === 255;
if (walkable(cx, cy)) return { x, y };
const reach = Math.ceil(radiusPx);
let best = null;
for (let iy = cy - reach; iy <= cy + reach; iy++) {
@@ -110,10 +115,66 @@ export function headingFromDirection(dx, dz) {
return Math.atan2(dx, -dz);
}
/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Eingabefeld. */
export function isMapToggleKey(event, activeTag) {
/** Soll dieser Tastendruck die Karte umschalten? Nicht beim Tippen in einem Textfeld (s. isTextEntry). */
export function isMapToggleKey(event, typing) {
if (event.code !== "KeyM" || event.repeat || event.ctrlKey || event.metaKey || event.altKey) return false;
return !["INPUT", "TEXTAREA", "SELECT"].includes(activeTag);
return !typing;
}
const TEXT_INPUT_TYPES = new Set(["text", "search", "email", "url", "tel", "password", "number"]);
/**
* Tippt der Nutzer gerade in ein Textfeld? Dann gehört M dem Feld. Auswahllisten, Kontrollkästchen und
* Farbwähler zählen nicht: sie behalten nach einer Änderung im Panel den Fokus, und M soll trotzdem
* die Karte öffnen.
*/
export function isTextEntry(element) {
if (!element) return false;
if (element.isContentEditable || element.tagName === "TEXTAREA") return true;
return element.tagName === "INPUT" && TEXT_INPUT_TYPES.has(String(element.type ?? "text").toLowerCase());
}
export const WHEEL_ZOOM_PER_PX = 0.0015;
/**
* Zoomfaktor aus einem Mausrad- oder Trackpad-Ereignis, proportional zur Scrollstrecke. Ein
* Mausrad-Rastschritt (etwa 100 px) zoomt um rund 1,16×; die vielen kleinen Schritte einer
* Trackpad-Geste zoomen fließend, statt je Ereignis einen festen Sprung zu machen. Zeilen und
* Seiten werden wie in CameraSystem.onZoomWheel des Controllers in Pixel umgerechnet, ebenso die
* Pinch-Geste des Trackpads, die als Mausrad mit ctrlKey ankommt. Reine Querbewegung zoomt nicht.
*/
export function wheelZoomFactor(deltaY, deltaMode = 0, ctrlKey = false) {
let pixels = deltaY;
if (deltaMode === 1) pixels *= 16;
else if (deltaMode === 2) pixels *= 100;
if (ctrlKey) pixels *= 10;
return Math.exp(-pixels * WHEEL_ZOOM_PER_PX);
}
const finite = (value) => typeof value === "number" && Number.isFinite(value);
/**
* Passen map.json und map.png zusammen? null, sonst eine kurze Beschreibung. Ohne diese Prüfung würfe
* draw() bei einer unvollständigen map.json in jedem Bild, und die Karte bliebe ohne Hinweis stehen.
*/
export function mapFilesProblem(info, pictureWidth, pictureHeight) {
if (!info || !finite(info.scale) || info.scale <= 0 || !finite(info.walkable_scale) || info.walkable_scale <= 0) {
return "map.json: scale oder walkable_scale fehlt";
}
const s = info.slice;
if (!s || !["x0", "x1", "y0", "y1"].every((k) => finite(s[k])) || s.x1 <= s.x0 || s.y1 <= s.y0) {
return "map.json: slice fehlt oder ist leer";
}
if (pictureWidth !== Math.round((s.x1 - s.x0) * info.scale) || pictureHeight !== Math.round((s.y1 - s.y0) * info.scale)) {
return "map.png passt nicht zu map.json";
}
return null;
}
/** Hat map-walkable.png die Größe, die map.json mit walkable_scale verspricht? */
export function maskMatches(info, width, height) {
const s = info.slice;
return width === Math.round((s.x1 - s.x0) * info.walkable_scale) && height === Math.round((s.y1 - s.y0) * info.walkable_scale);
}
/**