Harden gameplay timing and map loading

This commit is contained in:
2026-10-05 15:15:35 +05:00
parent 8b31891776
commit dcc3779262
36 changed files with 1050 additions and 205 deletions

View File

@@ -26,12 +26,21 @@ export function GuessMap({
const root = useRef<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(null),
[generation, setGeneration] = useState(0);
[generation, setGeneration] = useState(0),
[error, setError] = useState(""),
[attempt, setAttempt] = useState(0);
const callbacks = useRef({ onPin, onReady, onError, disabled });
callbacks.current = { onPin, onReady, onError, disabled };
useEffect(() => {
let disposed = false;
let cleanup = () => {};
setError("");
const fail = (message: string) => {
if (disposed) return;
setError(message);
callbacks.current.onError?.(message);
};
let loadTimer: ReturnType<typeof setTimeout> | undefined;
import("leaflet")
.then((L) => {
if (disposed || !root.current) return;
@@ -48,13 +57,31 @@ export function GuessMap({
maxBounds: b.pad(0.15),
attributionControl: false,
});
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
observer?.disconnect();
markers.current = null;
lib.current = null;
m.remove();
};
const overlay = L.imageOverlay(image, b);
overlay.on("load", () => callbacks.current.onReady?.());
overlay.on("error", () =>
callbacks.current.onError?.(
"The surface map could not load. Reconnect to retry.",
),
loadTimer = setTimeout(
() =>
fail("The surface map took too long to load. Reconnect to retry."),
30000,
);
overlay.on("load", () => {
clearTimeout(loadTimer);
if (!disposed) {
setError("");
callbacks.current.onReady?.();
}
});
overlay.on("error", () => {
clearTimeout(loadTimer);
fail("The surface map could not load. Reconnect to retry.");
});
overlay.addTo(m);
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
m.fitBounds(b, { padding: [0, 0], animate: false });
@@ -74,12 +101,33 @@ export function GuessMap({
)
callbacks.current.onPin?.({ x, z });
});
const keydown = (event: KeyboardEvent) => {
if (
event.key !== "Enter" ||
event.target !== m.getContainer() ||
callbacks.current.disabled
)
return;
event.preventDefault();
const center = m.getCenter();
callbacks.current.onPin?.({
x: Math.max(bounds.minX, Math.min(bounds.maxX, center.lng)),
z: Math.max(bounds.minZ, Math.min(bounds.maxZ, -center.lat)),
});
};
m.getContainer().addEventListener("keydown", keydown);
const removeMap = cleanup;
cleanup = () => {
m.getContainer().removeEventListener("keydown", keydown);
removeMap();
};
let fittedZoom = fitZoom;
const observer = new ResizeObserver(() => {
observer = new ResizeObserver(() => {
if (!root.current?.clientWidth || !root.current.clientHeight) return;
// Expanding the map should reveal the whole survey at its new size.
// Preserve a player's chosen zoom and center while inspecting terrain.
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
m.invalidateSize();
m.invalidateSize({ pan: false });
m.setMinZoom(-5);
fittedZoom = m.getBoundsZoom(b);
m.setMaxZoom(Math.max(3, fittedZoom + 3));
@@ -88,20 +136,14 @@ export function GuessMap({
m.setMinZoom(fittedZoom);
});
observer.observe(root.current);
cleanup = () => {
observer.disconnect();
markers.current = null;
m.remove();
};
})
.catch(() =>
callbacks.current.onError?.("The map viewer could not initialize."),
);
.catch(() => fail("The map viewer could not initialize."));
return () => {
disposed = true;
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
useEffect(() => {
const L = lib.current,
group = markers.current;
@@ -133,8 +175,23 @@ export function GuessMap({
ref={root}
style={{ height: "100%", width: "100%" }}
role="application"
aria-label="Minecraft map. North is up. Click to place a guess."
aria-label={
disabled
? "Minecraft map. North is up."
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
}
/>
{error && (
<div className="map-load-error" role="alert">
<p>{error}</p>
<button
className="button"
onClick={() => setAttempt((value) => value + 1)}
>
Retry map
</button>
</div>
)}
</div>
);
}