forked from phenom/healer-man
96 lines
5.6 KiB
TypeScript
96 lines
5.6 KiB
TypeScript
import { useState, type KeyboardEvent } from "react";
|
|
import { inputKeyForEvent } from "../input/keybinds";
|
|
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
|
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
|
import { useGameStore } from "../game/store";
|
|
import { useMenuController } from "../input/useMenuController";
|
|
import { DungeonMapGraphic, type MapArtworkStatus } from "./DungeonMapGraphic";
|
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
|
import { useManastormStore } from "../game/manastormStore";
|
|
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkMapFor, dungeonArtworkPoint } from "../game/dungeonArtworkMap";
|
|
|
|
export function closeMapForKey(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" | "repeat" | "defaultPrevented" | "preventDefault" | "stopPropagation">, close: () => void) {
|
|
if (event.repeat || event.defaultPrevented || isCapturingInput()) return;
|
|
const key = inputKeyForEvent(event);
|
|
if (!key || key !== getInputPreferences().pc.map) return;
|
|
// The gameplay listener intentionally ignores focused form controls.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
close();
|
|
}
|
|
|
|
function MapDialog() {
|
|
const bindingLabel = useInputBindingLabels();
|
|
const close = useGameStore((state) => state.closeMap);
|
|
const openOverlay = useGameStore((state) => state.openOverlay);
|
|
const position = useGameStore((state) => state.playerPosition);
|
|
const yaw = useGameStore((state) => state.cameraYaw);
|
|
const area = useGameStore((state) => state.areaName);
|
|
const gameMode = useGameStore((state) => state.gameMode);
|
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
|
const map = dungeonArtworkMapFor(dungeon);
|
|
const navigationPlan = map?.kind === "runewaker" && map.source.kind === "navigation";
|
|
const [floorLevel, setFloorLevel] = useState<number | null>(null);
|
|
const [showPatrols, setShowPatrols] = useState(false);
|
|
const [artworkStatus, setArtworkStatus] = useState<MapArtworkStatus>(map ? "loading" : "unavailable");
|
|
const floor = map ? dungeonArtworkFloorFor(map, position, floorLevel) : null;
|
|
const mapPosition = map && floor ? dungeonArtworkPoint(map, floor, position) : null;
|
|
const positionOnMap = Boolean(map && floor && dungeonArtworkContains(map, floor, position));
|
|
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
|
|
title: dungeon.title,
|
|
mapId: dungeon.mapId,
|
|
areaName: area,
|
|
});
|
|
useMenuController([], { onBack: () => openOverlay("pause") });
|
|
|
|
return (
|
|
<aside className="map-panel" role="dialog" aria-modal="true" aria-labelledby="map-title" onKeyDown={(event) => closeMapForKey(event, close)}>
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">{artworkStatus === "ready" ? navigationPlan ? "Dungeon floorplan" : "Dungeon atlas" : "Expedition survey"}</p>
|
|
<h2 id="map-title">{stagePresentation.title}</h2>
|
|
</div>
|
|
<button type="button" className="icon-button" onClick={close} aria-label="Close map">X</button>
|
|
</header>
|
|
|
|
<div className="map-panel__tools">
|
|
{map && map.floors.length > 1 && <label>Map floor
|
|
<select value={floorLevel ?? "auto"} onChange={(event) => setFloorLevel(event.target.value === "auto" ? null : Number(event.target.value))}>
|
|
<option value="auto">Auto — best match</option>
|
|
{map.floors.map((entry) => <option key={entry.level} value={entry.level}>{entry.name}</option>)}
|
|
</select>
|
|
</label>}
|
|
<label className="map-panel__patrol-toggle"><input type="checkbox" checked={showPatrols} onChange={(event) => setShowPatrols(event.target.checked)} />Patrol routes</label>
|
|
</div>
|
|
<div className="map-panel__canvas">
|
|
<DungeonMapGraphic position={position} yaw={yaw} floorLevel={floorLevel} showPatrols={showPatrols} onArtworkStatus={setArtworkStatus} />
|
|
</div>
|
|
|
|
<div className="map-panel__readout">
|
|
<div><span>Current area</span><strong>{stagePresentation.areaName}</strong></div>
|
|
<div><span>{artworkStatus === "ready" ? floor?.name : "Position"}</span><strong>{artworkStatus === "ready" && mapPosition
|
|
? positionOnMap ? `${(mapPosition[0] / map!.width * 100).toFixed(1)}%, ${(mapPosition[1] / map!.height * 100).toFixed(1)}%` : "Outside selected floor"
|
|
: position.map((value) => Math.round(value)).join(" / ")}</strong></div>
|
|
</div>
|
|
<p className="map-panel__note">
|
|
{stagePresentation.usingFallbackEnvironment
|
|
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
|
|
: artworkStatus === "ready"
|
|
? `${navigationPlan ? "Navigation floorplan" : "Original game artwork"}. White: you and camera direction. Green: entrance. Gold: numbered encounters. `
|
|
: artworkStatus === "loading" ? navigationPlan ? "Loading dungeon floorplan. " : "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
|
|
{map && map.floors.length > 1 && "Choose a floor when rooms overlap. "}
|
|
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
|
|
</p>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export function MapPanel() {
|
|
const open = useGameStore((state) => state.overlay === "map");
|
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
|
return open ? <MapDialog key={activeDungeonId} /> : null;
|
|
}
|