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
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
+7
-3
@@ -260,17 +260,21 @@ function tick(delta) {
|
||||
// Umstellung hört das Streaming beim Fahren auf nachzuladen.
|
||||
streamer.update(playerPosition(player));
|
||||
if (!settled) {
|
||||
// Ein Sprung vor dem ersten Boden — Karte und Panel sind schon vor props.load() bedienbar —
|
||||
// ersetzt den Spawn als Startpunkt, statt beim Einrasten stillschweigend verloren zu gehen.
|
||||
const start = pendingTeleport ?? SPAWN;
|
||||
// Vehicles existieren hier noch nicht (vehiclesSpawned kommt erst nach settled) —
|
||||
// playerPosition() liefert also zwangsläufig die Kapsel, wie zuvor.
|
||||
if (streamer.isReadyAt(playerPosition(player))) {
|
||||
settled = true;
|
||||
pendingTeleport = null;
|
||||
player.setSkipCapsuleCollision(false);
|
||||
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
|
||||
player.reset(new Vector3(start.x, start.y, start.z));
|
||||
} else {
|
||||
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
|
||||
player.reset(new Vector3(start.x, start.y, start.z));
|
||||
if (!warnedSlowSpawn && performance.now() - startedAt > 15000) {
|
||||
warnedSlowSpawn = true;
|
||||
console.warn(`Chunk am Spawn (${SPAWN.x}, ${SPAWN.z}) ist nach 15s immer noch nicht bereit.`);
|
||||
console.warn(`Chunk am Startpunkt (${start.x}, ${start.z}) ist nach 15s immer noch nicht bereit.`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pairs": [["A1", 0, 0], ["L1", 11, 0], ["A12", 0, 11], ["L12", 11, 11], ["C10", 2, 9]],
|
||||
"lowercase": [["c10", 2, 9], ["l12", 11, 11]],
|
||||
"invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " ", "A1\n"],
|
||||
"invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " ", "A1\n", "A1١", "A11"],
|
||||
"invalidIndices": [[1.5, 3], [3, 2.5], [true, 0], [0, false]]
|
||||
}
|
||||
+57
-12
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick,
|
||||
isMapToggleKey, landingFor, mapToWorld, nearestWalkable, screenToMap, sha256Hex, worldToMap, zoomAt,
|
||||
isMapToggleKey, isTextEntry, landingFor, mapFilesProblem, mapToWorld, maskMatches, nearestWalkable,
|
||||
screenToMap, sha256Hex, wheelZoomFactor, worldToMap, zoomAt,
|
||||
} from "../src/ui/mapMath.js";
|
||||
|
||||
const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } };
|
||||
@@ -71,16 +72,17 @@ describe("Klick gegen Ziehen", () => {
|
||||
expect(CLICK_TOLERANCE_PX).toBe(4);
|
||||
expect(isClick({ x: 10, y: 10 }, { x: 13.9, y: 10 })).toBe(true);
|
||||
expect(isClick({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(false);
|
||||
expect(isClick({ x: 10, y: 10 }, { x: 10, y: 10 }, 4, 30)).toBe(false); // weggezogen und zurück
|
||||
});
|
||||
});
|
||||
|
||||
describe("Landeplatz", () => {
|
||||
const blank = (w, h) => new Uint8Array(w * h);
|
||||
|
||||
it("bleibt bei einem begehbaren Treffer genau am Klickpunkt", () => {
|
||||
it("landet bei einem begehbaren Treffer in der Mitte dieses Pixels", () => {
|
||||
const mask = blank(10, 10);
|
||||
mask[2 * 10 + 5] = 255;
|
||||
expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.4, y: 2.2 });
|
||||
expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.5, y: 2.5 });
|
||||
});
|
||||
|
||||
it("findet von einem Gebäude aus die Straße daneben", () => {
|
||||
@@ -102,10 +104,10 @@ describe("Landeplatz", () => {
|
||||
expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it("rechnet einen Kartenklick über die Maske in eine Weltposition um", () => {
|
||||
it("rechnet einen Kartenklick über die Maske in eine Weltposition in der Pixelmitte um", () => {
|
||||
const walk = { mask: blank(750, 750), width: 750, height: 750 };
|
||||
walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200)
|
||||
expect(landingFor(INFO, walk, 400, 800)).toEqual({ x: -600, z: -1700 }); // Treffer: genau dorthin
|
||||
walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200) = Karte 400..404 / 800..804
|
||||
expect(landingFor(INFO, walk, 401, 801)).toEqual({ x: -598, z: -1698 }); // Treffer: Pixelmitte (402, 802)
|
||||
const nearby = landingFor(INFO, walk, 412, 800); // 3 Maskenpixel daneben
|
||||
expect(nearby.x).toBeCloseTo(-598, 9); // Mitte von (100, 200) = Karte (402, 802)
|
||||
expect(nearby.z).toBeCloseTo(-1698, 9);
|
||||
@@ -138,12 +140,24 @@ describe("Richtung und Tasten", () => {
|
||||
|
||||
it("schaltet mit M, aber nicht beim Tippen, nicht bei Wiederholung und nicht mit Strg", () => {
|
||||
const key = (extra) => ({ code: "KeyM", repeat: false, ctrlKey: false, metaKey: false, altKey: false, ...extra });
|
||||
expect(isMapToggleKey(key({}), "BODY")).toBe(true);
|
||||
expect(isMapToggleKey(key({}), "INPUT")).toBe(false);
|
||||
expect(isMapToggleKey(key({}), "SELECT")).toBe(false);
|
||||
expect(isMapToggleKey(key({ repeat: true }), "BODY")).toBe(false);
|
||||
expect(isMapToggleKey(key({ ctrlKey: true }), "BODY")).toBe(false);
|
||||
expect(isMapToggleKey(key({ code: "KeyN" }), "BODY")).toBe(false);
|
||||
expect(isMapToggleKey(key({}), false)).toBe(true);
|
||||
expect(isMapToggleKey(key({}), true)).toBe(false);
|
||||
expect(isMapToggleKey(key({ repeat: true }), false)).toBe(false);
|
||||
expect(isMapToggleKey(key({ ctrlKey: true }), false)).toBe(false);
|
||||
expect(isMapToggleKey(key({ code: "KeyN" }), false)).toBe(false);
|
||||
});
|
||||
|
||||
it("zählt Text- und Zahlenfelder als Tippen, Auswahllisten und Kontrollkästchen nicht", () => {
|
||||
expect(isTextEntry({ tagName: "INPUT", type: "text" })).toBe(true);
|
||||
expect(isTextEntry({ tagName: "INPUT", type: "number" })).toBe(true);
|
||||
expect(isTextEntry({ tagName: "INPUT" })).toBe(true);
|
||||
expect(isTextEntry({ tagName: "TEXTAREA" })).toBe(true);
|
||||
expect(isTextEntry({ tagName: "DIV", isContentEditable: true })).toBe(true);
|
||||
expect(isTextEntry({ tagName: "SELECT" })).toBe(false);
|
||||
expect(isTextEntry({ tagName: "INPUT", type: "checkbox" })).toBe(false);
|
||||
expect(isTextEntry({ tagName: "INPUT", type: "color" })).toBe(false);
|
||||
expect(isTextEntry({ tagName: "BODY" })).toBe(false);
|
||||
expect(isTextEntry(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,3 +171,34 @@ describe("Fingerabdruck", () => {
|
||||
expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mausrad und Trackpad", () => {
|
||||
it("zoomt proportional zur Scrollstrecke statt je Ereignis um einen festen Schritt", () => {
|
||||
expect(wheelZoomFactor(-100)).toBeGreaterThan(1.1);
|
||||
expect(wheelZoomFactor(-100)).toBeLessThan(1.25);
|
||||
expect(wheelZoomFactor(100)).toBeCloseTo(1 / wheelZoomFactor(-100), 9);
|
||||
expect(wheelZoomFactor(-4) ** 25).toBeCloseTo(wheelZoomFactor(-100), 9); // Trackpad: viele kleine Schritte
|
||||
});
|
||||
|
||||
it("rechnet Zeilen, Seiten und die Pinch-Geste in Pixel um und zoomt bei reiner Querbewegung nicht", () => {
|
||||
expect(wheelZoomFactor(-3, 1)).toBeCloseTo(wheelZoomFactor(-48), 9);
|
||||
expect(wheelZoomFactor(-1, 2)).toBeCloseTo(wheelZoomFactor(-100), 9);
|
||||
expect(wheelZoomFactor(-2, 0, true)).toBeCloseTo(wheelZoomFactor(-20), 9);
|
||||
expect(wheelZoomFactor(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kartendateien prüfen", () => {
|
||||
it("nimmt eine passende map.json mit passenden Bildern an", () => {
|
||||
expect(mapFilesProblem(INFO, 3000, 3000)).toBeNull();
|
||||
expect(maskMatches(INFO, 750, 750)).toBe(true);
|
||||
});
|
||||
|
||||
it("benennt eine unvollständige map.json oder ein unpassendes Bild, statt später zu werfen", () => {
|
||||
expect(mapFilesProblem({ ...INFO, scale: undefined }, 3000, 3000)).toMatch(/scale/);
|
||||
expect(mapFilesProblem({ ...INFO, slice: undefined }, 3000, 3000)).toMatch(/slice/);
|
||||
expect(mapFilesProblem(null, 3000, 3000)).toMatch(/scale/);
|
||||
expect(mapFilesProblem(INFO, 1500, 1500)).toMatch(/map\.png/);
|
||||
expect(maskMatches(INFO, 375, 375)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,8 @@ def pixel_to_world(px: int, py: int) -> tuple[float, float]:
|
||||
|
||||
CHUNKS_PER_SIDE = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12
|
||||
CHUNK_COLUMNS = "ABCDEFGHIJKL"
|
||||
_CHUNK_NAME = re.compile(r"([A-La-l])([1-9]\d?)")
|
||||
# [0-9] statt \d: in Python passt \d auch auf andere Ziffernzeichen wie ١ oder 1, in JavaScript nicht.
|
||||
_CHUNK_NAME = re.compile(r"([A-La-l])([1-9][0-9]?)")
|
||||
|
||||
|
||||
def _is_index(k) -> bool:
|
||||
|
||||
@@ -15,6 +15,7 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from leonida.coords import SLICE
|
||||
@@ -78,8 +79,9 @@ def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building,
|
||||
"""Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird.
|
||||
|
||||
surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe.
|
||||
palm=None zeichnet keine Palmen. Reihenfolge und Füllung entsprechen exakt dem
|
||||
bisherigen Prüfbild, damit dessen Ausgabe bytegleich bleibt.
|
||||
palm=None zeichnet keine Palmen; eine Füllung None lässt Beläge oder Straßen aus.
|
||||
Reihenfolge und Füllung entsprechen exakt dem bisherigen Prüfbild, damit dessen
|
||||
Ausgabe bytegleich bleibt.
|
||||
"""
|
||||
stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0}
|
||||
placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks]
|
||||
@@ -89,10 +91,14 @@ def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building,
|
||||
stats["land"] += 1
|
||||
for chunk, ox, oy in placed:
|
||||
for item in chunk["surfaces"]:
|
||||
_draw(img, item["poly"], ox, oy, surface(item["kind"]), scale=scale)
|
||||
fill = surface(item["kind"])
|
||||
if fill is not None:
|
||||
_draw(img, item["poly"], ox, oy, fill, scale=scale)
|
||||
for chunk, ox, oy in placed:
|
||||
for item in chunk["roads"]:
|
||||
_draw(img, item["poly"], ox, oy, bridge if item["bridge"] else road, scale=scale)
|
||||
fill = bridge if item["bridge"] else road
|
||||
if fill is not None:
|
||||
_draw(img, item["poly"], ox, oy, fill, scale=scale)
|
||||
stats["bruecken" if item["bridge"] else "strassen"] += 1
|
||||
for chunk, ox, oy in placed:
|
||||
for item in chunk["buildings"]:
|
||||
@@ -117,6 +123,27 @@ def render(out_dir: Path, dst: Path) -> dict:
|
||||
return stats
|
||||
|
||||
|
||||
def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image:
|
||||
"""Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet.
|
||||
|
||||
Begehbar heißt: die Figur steht dort auf Kollision. Die tragen nur die Landplatte und die
|
||||
Brücken — Straßen und Beläge sind reine Auflagen (DECALS in src/world/streaming.js) —, und
|
||||
Gebäude sind geschlossene Kästen. Ein Maskenpixel gilt nur als begehbar, wenn jeder
|
||||
Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel setzte die Figur sonst in eine
|
||||
Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten traf das 4,3 % der Klicks
|
||||
auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in src/ui/mapMath.js),
|
||||
also mindestens 2 m von jedem gesperrten Quadratmeter entfernt.
|
||||
"""
|
||||
fine = Image.new("L", (width_m, width_m), BLOCKED)
|
||||
_paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, road=None,
|
||||
bridge=WALKABLE, building=lambda _height: BLOCKED)
|
||||
step = round(1 / WALKABLE_SCALE)
|
||||
size = width_m // step
|
||||
cells = np.asarray(fine).reshape(size, step, size, step)
|
||||
free = (cells == WALKABLE).all(axis=(1, 3))
|
||||
return Image.fromarray(np.where(free, WALKABLE, BLOCKED).astype(np.uint8))
|
||||
|
||||
|
||||
def render_map(out_dir: Path, dst_dir: Path) -> dict:
|
||||
"""Kartenbild, Begehbarkeitsmaske und map.json für die Minimap."""
|
||||
chunks = _load_chunks(out_dir)
|
||||
@@ -126,11 +153,7 @@ def render_map(out_dir: Path, dst_dir: Path) -> dict:
|
||||
_paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD,
|
||||
bridge=BRIDGE, building=_building_shade, outline=OUTLINE)
|
||||
|
||||
# Begehbar ist, was Boden hat: Land, Beläge, Straßen und Brücken. Gebäude werden danach wieder
|
||||
# gesperrt; Wasser ist der Hintergrund. Innenhöfe bleiben über die Löcher begehbar.
|
||||
walkable = Image.new("L", (int(width_m * WALKABLE_SCALE),) * 2, BLOCKED)
|
||||
_paint_layers(walkable, chunks, WALKABLE_SCALE, land=WALKABLE, surface=lambda _kind: WALKABLE,
|
||||
road=WALKABLE, bridge=WALKABLE, building=lambda _height: BLOCKED)
|
||||
walkable = _walkable_mask(chunks, width_m)
|
||||
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
picture.save(dst_dir / "map.png")
|
||||
|
||||
@@ -85,8 +85,8 @@ def test_debug_overview_still_draws_palms(tmp_path):
|
||||
assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m
|
||||
|
||||
|
||||
def test_walkable_mask_marks_roads_bridges_and_surfaces_without_land(tmp_path):
|
||||
"""Jede begehbare Ebene für sich, ohne Land darunter: sonst verdeckt das Land einen Fehler."""
|
||||
def test_walkable_mask_counts_only_ground_with_collision(tmp_path):
|
||||
"""Straßen und Beläge sind reine Auflagen: ohne Landplatte darunter trägt nur eine Brücke."""
|
||||
src = tmp_path / "chunks"
|
||||
src.mkdir()
|
||||
chunk = {
|
||||
@@ -105,7 +105,48 @@ def test_walkable_mask_marks_roads_bridges_and_surfaces_without_land(tmp_path):
|
||||
dst = tmp_path / "out"
|
||||
overview.render_map(src, dst)
|
||||
mask = Image.open(dst / "map-walkable.png")
|
||||
assert mask.getpixel((5, 5)) == overview.WALKABLE # Sand bei 20 m / 20 m
|
||||
assert mask.getpixel((27, 5)) == overview.WALKABLE # Straße bei 110 m / 20 m
|
||||
assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: keine Kollision
|
||||
assert mask.getpixel((27, 5)) == overview.BLOCKED # Straße ohne Land bei 110 m / 20 m: keine Kollision
|
||||
assert mask.getpixel((52, 5)) == overview.WALKABLE # Brücke bei 210 m / 20 m
|
||||
assert mask.getpixel((40, 5)) == overview.BLOCKED # Wasser bei 160 m / 20 m
|
||||
|
||||
|
||||
def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path):
|
||||
"""Ein 4-m-Pixel, den Wasser oder ein Gebäude auch nur anschneidet, ist kein Landeplatz."""
|
||||
src = tmp_path / "chunks"
|
||||
src.mkdir()
|
||||
chunk = {
|
||||
"chunk": [0, 0],
|
||||
"origin": [SLICE["x0"], -SLICE["y1"]],
|
||||
"land": [[_ring(0, 0, 102, 250)]], # Küste bei x = 102 m, mitten in Pixel 25
|
||||
"surfaces": [],
|
||||
"roads": [],
|
||||
"centerlines": [],
|
||||
"buildings": [{"poly": [_ring(38, 40, 50, 50)], "height": 8.0, "source": "district", "speculative": False}],
|
||||
"palms": [],
|
||||
}
|
||||
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
|
||||
(src / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
||||
dst = tmp_path / "out"
|
||||
overview.render_map(src, dst)
|
||||
mask = Image.open(dst / "map-walkable.png")
|
||||
assert mask.getpixel((24, 30)) == overview.WALKABLE # 96..100 m: ganz Land
|
||||
assert mask.getpixel((25, 30)) == overview.BLOCKED # 100..104 m: halb Wasser
|
||||
assert mask.getpixel((9, 11)) == overview.BLOCKED # 36..40 m: das Gebäude beginnt bei 38 m
|
||||
assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei
|
||||
|
||||
|
||||
def test_committed_map_files_match_the_committed_chunks(tmp_path):
|
||||
"""Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen.
|
||||
|
||||
Ändert ein Pipeline-Lauf die Chunks, ohne dass overview.py läuft, zeigte die Minimap eine alte
|
||||
Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das
|
||||
erst im Browser, dieser Test schon beim Testlauf.
|
||||
"""
|
||||
shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a"
|
||||
overview.render_map(shipped, tmp_path)
|
||||
for name in ("map.png", "map-walkable.png"):
|
||||
fresh, committed = Image.open(tmp_path / name), Image.open(shipped / name)
|
||||
assert (fresh.size, fresh.mode) == (committed.size, committed.mode), name
|
||||
assert fresh.tobytes() == committed.tobytes(), f"{name} ist veraltet: cd tools/extract && uv run python overview.py"
|
||||
assert json.loads((tmp_path / "map.json").read_text()) == json.loads((shipped / "map.json").read_text())
|
||||
Reference in new issue
Block a user