add map info dialog

This commit is contained in:
Brian Beck 2026-02-19 05:51:55 -08:00
parent 0b345facea
commit c44df43a91
36 changed files with 2051 additions and 1068 deletions

View file

@ -8,12 +8,13 @@ import { MissionSelect } from "./MissionSelect";
import { RefObject, useEffect, useState, useRef } from "react";
import { Camera } from "three";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { FiSettings } from "react-icons/fi";
import { FiInfo, FiSettings } from "react-icons/fi";
export function InspectorControls({
missionName,
missionType,
onChangeMission,
onOpenMapInfo,
cameraRef,
isTouch,
}: {
@ -26,6 +27,7 @@ export function InspectorControls({
missionName: string;
missionType: string;
}) => void;
onOpenMapInfo: () => void;
cameraRef: RefObject<Camera | null>;
isTouch: boolean | null;
}) {
@ -70,110 +72,6 @@ export function InspectorControls({
}
};
const settingsFields = (
<>
<div className="Controls-group">
<CopyCoordinatesButton
cameraRef={cameraRef}
missionName={missionName}
missionType={missionType}
/>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="CheckboxField">
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label htmlFor="audioInput">Audio?</label>
</div>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label htmlFor="debugInput">Debug?</label>
</div>
</div>
<div className="Controls-group">
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="fovInput">{fov}</output>
</div>
<div className="Field">
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}
/>
</div>
</div>
{isTouch && (
<div className="Controls-group">
<div className="Field">
<label htmlFor="touchModeInput">Joystick:</label>{" "}
<select
id="touchModeInput"
value={touchMode}
onChange={(e) => setTouchMode(e.target.value as TouchMode)}
>
<option value="dualStick">Dual Stick</option>
<option value="moveLookStick">Single Stick</option>
</select>
</div>
</div>
)}
</>
);
return (
<div
id="controls"
@ -208,7 +106,114 @@ export function InspectorControls({
onBlur={handleDropdownBlur}
data-open={settingsOpen}
>
{settingsFields}
<div className="Controls-group">
<CopyCoordinatesButton
cameraRef={cameraRef}
missionName={missionName}
missionType={missionType}
/>
<button
type="button"
className="IconButton LabelledButton MapInfoButton"
aria-label="Show map info"
onClick={onOpenMapInfo}
>
<FiInfo />
<span className="ButtonLabel">Show map info</span>
</button>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="CheckboxField">
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label htmlFor="audioInput">Audio?</label>
</div>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label htmlFor="debugInput">Debug?</label>
</div>
</div>
<div className="Controls-group">
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="fovInput">{fov}</output>
</div>
<div className="Field">
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}
/>
</div>
</div>
{isTouch && (
<div className="Controls-group">
<div className="Field">
<label htmlFor="touchModeInput">Joystick:</label>{" "}
<select
id="touchModeInput"
value={touchMode}
onChange={(e) => setTouchMode(e.target.value as TouchMode)}
>
<option value="dualStick">Dual Stick</option>
<option value="moveLookStick">Single Stick</option>
</select>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,317 @@
import { useEffect, useRef, useState } from "react";
import { FaVolumeUp, FaVolumeMute } from "react-icons/fa";
import { useQuery } from "@tanstack/react-query";
import { loadMission, getUrlForPath, RESOURCE_ROOT_URL } from "../loaders";
import { getStandardTextureResourceKey } from "../manifest";
import { GuiMarkup, filterMissionStringByMode } from "../torqueGuiMarkup";
import type * as AST from "../torqueScript/ast";
function useParsedMission(name: string) {
return useQuery({
queryKey: ["parsedMission", name],
queryFn: () => loadMission(name),
});
}
function getMissionGroupProps(ast: AST.Program): Record<string, string> {
for (const node of ast.body) {
if (node.type !== "ObjectDeclaration") continue;
const { instanceName, body } = node;
if (
instanceName &&
instanceName.type === "Identifier" &&
instanceName.name.toLowerCase() === "missiongroup"
) {
const props: Record<string, string> = {};
for (const item of body) {
if (item.type !== "Assignment") continue;
const { target, value } = item;
if (target.type === "Identifier" && value.type === "StringLiteral") {
props[target.name.toLowerCase()] = value.value;
}
}
return props;
}
}
return {};
}
function getBitmapUrl(
bitmap: string | null,
missionName: string,
): string | null {
// Try bitmap from pragma comment first (single-player missions use this)
if (bitmap) {
try {
const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`);
return getUrlForPath(key);
} catch {}
}
// Fall back to Load_<MissionName>.png convention (multiplayer missions)
try {
const key = getStandardTextureResourceKey(
`textures/gui/Load_${missionName}`,
);
return getUrlForPath(key);
} catch {}
return null;
}
/**
* Renders a preview image bypassing browser color management, matching how
* Tribes 2 displayed these textures (raw pixel values, no gamma conversion).
* Many T2 preview PNGs embed an incorrect gAMA chunk (22727 = gamma 4.4
* instead of the correct 45455 = gamma 2.2), which causes browsers to
* over-darken them. `colorSpaceConversion: "none"` ignores gAMA/ICC data.
*/
function RawPreviewImage({
src,
alt,
className = "MapInfoDialog-preview",
}: {
src: string;
alt: string;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isLoaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
fetch(src)
.then((r) => r.blob())
.then((blob) => createImageBitmap(blob, { colorSpaceConversion: "none" }))
.then((bitmap) => {
if (cancelled) {
bitmap.close();
return;
}
const canvas = canvasRef.current;
if (!canvas) {
bitmap.close();
return;
}
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
bitmap.close();
setLoaded(true);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [src]);
return (
<canvas
ref={canvasRef}
className={className}
aria-label={alt}
style={{ display: isLoaded ? "block" : "none" }}
/>
);
}
function MusicPlayer({ track }: { track: string }) {
const [playing, setPlaying] = useState(false);
const [available, setAvailable] = useState(true);
const audioRef = useRef<HTMLAudioElement>(null);
const url = `${RESOURCE_ROOT_URL}music/${track.toLowerCase()}.mp3`;
useEffect(() => {
return () => {
audioRef.current?.pause();
};
}, []);
const toggle = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
audio.play().catch(() => setAvailable(false));
}
};
return (
<div className="MapInfoDialog-musicTrack" data-playing={playing}>
<audio
ref={audioRef}
src={url}
loop
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={() => setAvailable(false)}
/>
<span className="MusicTrackName">{track}</span>
{available && (
<button
className="MapInfoDialog-musicBtn"
onClick={toggle}
aria-label={playing ? "Pause music" : "Play music"}
>
{playing ? <FaVolumeUp /> : <FaVolumeMute />}
</button>
)}
</div>
);
}
export function MapInfoDialog({
open,
onClose,
missionName,
missionType,
}: {
open: boolean;
onClose: () => void;
missionName: string;
missionType: string;
}) {
const { data: parsedMission } = useParsedMission(missionName);
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
dialogRef.current?.focus();
document.exitPointerLock();
}
}, [open]);
// While open: block keyboard events from reaching drei, and handle close keys.
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "KeyI" || e.key === "Escape") {
onClose();
} else if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
onClose();
return; // let Cmd-K propagate to open the mission search
}
e.stopImmediatePropagation();
};
const handleKeyUp = (e: KeyboardEvent) => {
e.stopImmediatePropagation();
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", handleKeyUp, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp, { capture: true });
};
}, [open, onClose]);
if (!open) return null;
const missionGroupProps = parsedMission
? getMissionGroupProps(parsedMission.ast)
: {};
const bitmapUrl = parsedMission
? getBitmapUrl(parsedMission.bitmap, missionName)
: null;
const displayName = parsedMission?.displayName ?? missionName;
const typeKey = missionType.toLowerCase();
const isSinglePlayer = typeKey === "singleplayer";
const musicTrack = missionGroupProps["musictrack"];
const missionString = parsedMission?.missionString
? filterMissionStringByMode(parsedMission.missionString, missionType)
: null;
// Split quote into body text and attribution line
const quoteLines = parsedMission?.missionQuote?.trim().split("\n") ?? [];
let quoteText = "";
let quoteAttrib = "";
for (const line of quoteLines) {
const trimmed = line.trim();
if (trimmed.match(/^-+\s/)) {
quoteAttrib = trimmed.replace(/^-+\s*/, "").trim();
} else if (trimmed) {
quoteText += (quoteText ? " " : "") + trimmed;
}
}
return (
<div className="MapInfoDialog-overlay" onClick={onClose}>
<div
ref={dialogRef}
className="MapInfoDialog"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Map Information"
tabIndex={-1}
>
<div className="MapInfoDialog-inner">
<div className="MapInfoDialog-left">
{bitmapUrl && isSinglePlayer && (
<RawPreviewImage
key={bitmapUrl}
className="MapInfoDialog-preview--floated"
src={bitmapUrl}
alt={`${displayName} preview`}
/>
)}
<h1 className="MapInfoDialog-title">{displayName}</h1>
<div className="MapInfoDialog-meta">
{parsedMission?.planetName && (
<span className="MapInfoDialog-planet">
{parsedMission.planetName}
</span>
)}
</div>
{quoteText && (
<blockquote className="MapInfoDialog-quote">
<p>{quoteText}</p>
{quoteAttrib && <cite> {quoteAttrib}</cite>}
</blockquote>
)}
{parsedMission?.missionBlurb && (
<p className="MapInfoDialog-blurb">
{parsedMission.missionBlurb.trim()}
</p>
)}
{missionString && missionString.trim() && (
<div className="MapInfoDialog-section">
<GuiMarkup markup={missionString} />
</div>
)}
{parsedMission?.missionBriefing && (
<div className="MapInfoDialog-section">
<h2 className="MapInfoDialog-sectionTitle">Mission Briefing</h2>
<GuiMarkup markup={parsedMission.missionBriefing} />
</div>
)}
{musicTrack && <MusicPlayer track={musicTrack} />}
</div>
{bitmapUrl && !isSinglePlayer && (
<div className="MapInfoDialog-right">
<RawPreviewImage
key={bitmapUrl}
src={bitmapUrl}
alt={`${displayName} preview`}
/>
</div>
)}
</div>
<div className="MapInfoDialog-footer">
<button className="MapInfoDialog-closeBtn" onClick={onClose}>
Close
</button>
<span className="MapInfoDialog-hint">I or Esc to close</span>
</div>
</div>
</div>
);
}