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:
1 parent
e702517662
commit
dd5ced505b
9 files changed
+263
-54
No files matched your search
+48
-14
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user