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>
);
}

View file

@ -91,7 +91,18 @@ export async function loadDetailMapList(name: string) {
export async function loadMission(name: string) {
const missionInfo = getMissionInfo(name);
const res = await fetch(getUrlForPath(missionInfo.resourcePath));
const missionScript = await res.text();
const buffer = await res.arrayBuffer();
// Most mission files are Windows-1252 (common circa 2001), but some were
// later re-saved as UTF-8. Try strict UTF-8 first; fall back to Windows-1252.
let missionScript: string;
try {
missionScript = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
missionScript = new TextDecoder("windows-1252").decode(buffer);
}
// Some files were saved as UTF-8 with corrupted Windows-1252 characters
// (e.g. smart quotes became U+FFFD replacement characters). Fix these.
missionScript = missionScript.replaceAll("\uFFFD", "'");
return parseMissionScript(missionScript);
}

305
src/torqueGuiMarkup.tsx Normal file
View file

@ -0,0 +1,305 @@
import { useMemo } from "react";
import { getUrlForPath } from "./loaders";
import { getStandardTextureResourceKey } from "./manifest";
// Types
interface Style {
color?: string;
fontSize?: number;
}
interface Span {
type: "span";
text: string;
style: Style;
}
interface Bitmap {
type: "bitmap";
name: string;
}
type Inline = Span | Bitmap;
interface Line {
align: "left" | "center" | "right";
/** Container padding-left for non-bullet lines. */
lmargin: number;
/** When > 0, a bitmap precedes indented text — render as bullet layout. */
textIndent: number;
items: Inline[];
}
// Tokenizer
type Token =
| { type: "text"; value: string }
| { type: "newline" }
| { type: "tag"; name: string; arg: string };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
const re = /<([^>]*)>/g;
let last = 0;
const pushText = (text: string) => {
const parts = text.split("\n");
parts.forEach((part, i) => {
if (part) tokens.push({ type: "text", value: part });
if (i < parts.length - 1) tokens.push({ type: "newline" });
});
};
let m: RegExpExecArray | null;
while ((m = re.exec(input))) {
if (m.index > last) pushText(input.slice(last, m.index));
last = m.index + m[0].length;
const raw = m[1].trim();
const sep = raw.indexOf(":");
const name = (sep === -1 ? raw : raw.slice(0, sep)).toLowerCase();
const arg = sep === -1 ? "" : raw.slice(sep + 1);
tokens.push({ type: "tag", name, arg });
}
if (last < input.length) pushText(input.slice(last));
return tokens;
}
// Parser
function parseFontSize(arg: string): number {
// arg is "FontName:size" — size is after the last colon
const last = arg.lastIndexOf(":");
const size = parseInt(last === -1 ? arg : arg.slice(last + 1), 10) || 14;
return Math.min(size, 16);
}
function parseMarkup(input: string): Line[] {
const tokens = tokenize(input);
// Style state (affected by spush/spop)
const styleStack: Style[] = [];
let style: Style = {};
// Layout state (persistent, not stack-based)
let align: Line["align"] = "left";
let lmargin = 0;
// Current line being accumulated
let items: Inline[] = [];
let lineAlign: Line["align"] = "left";
let lineLmargin = 0;
let hasBitmap = false;
let textIndent = 0;
const lines: Line[] = [];
const flushLine = () => {
lines.push({ align: lineAlign, lmargin: lineLmargin, textIndent, items });
items = [];
lineAlign = align;
lineLmargin = lmargin;
hasBitmap = false;
textIndent = 0;
};
const addSpan = (text: string) => {
if (!text) return;
// Merge adjacent spans with identical style
const prev = items[items.length - 1];
if (
prev?.type === "span" &&
prev.style.color === style.color &&
prev.style.fontSize === style.fontSize
) {
prev.text += text;
} else {
items.push({ type: "span", text, style: { ...style } });
}
};
for (const tok of tokens) {
if (tok.type === "newline") {
flushLine();
continue;
}
if (tok.type === "text") {
addSpan(tok.value.replace(/\t/g, " "));
continue;
}
const { name, arg } = tok;
switch (name) {
case "spush":
styleStack.push({ ...style });
break;
case "spop":
if (styleStack.length > 0) style = styleStack.pop()!;
break;
case "color":
style = { ...style, color: `#${arg.trim()}` };
break;
case "font":
style = { ...style, fontSize: parseFontSize(arg) };
break;
case "lmargin": {
const px = parseInt(arg, 10) || 0;
lmargin = px;
if (hasBitmap && px > 0) {
// lmargin after a bitmap → bullet indent for this line's text
textIndent = px;
} else if (items.length === 0) {
lineLmargin = px;
}
break;
}
case "just": {
const v = arg.trim().toLowerCase();
if (v === "left" || v === "center" || v === "right") {
align = v;
if (items.length === 0) lineAlign = v;
}
break;
}
case "bitmap":
hasBitmap = true;
items.push({ type: "bitmap", name: arg.trim() });
break;
case "br":
flushLine();
break;
case "sbreak":
if (items.length > 0) flushLine();
flushLine(); // empty spacer line
break;
// Intentionally ignored: tab, rmargin, clip, /clip, a, /a
}
}
if (items.length > 0) flushLine();
return lines;
}
// Bitmap rendering
const bitmapUrlCache = new Map<string, string | null>();
function getBitmapUrl(name: string): string | null {
if (bitmapUrlCache.has(name)) return bitmapUrlCache.get(name)!;
let url: string | null;
try {
url = getUrlForPath(getStandardTextureResourceKey(`textures/gui/${name}`));
} catch {
url = null;
}
bitmapUrlCache.set(name, url);
return url;
}
function GuiBitmapEl({ name }: { name: string }) {
const url = getBitmapUrl(name);
if (url) {
return <img src={url} alt="" className="GuiMarkup-bitmap" />;
}
if (/bullet/i.test(name)) {
return <span className="GuiMarkup-bullet"></span>;
}
return null;
}
function SpanEl({ span }: { span: Span }) {
const { color, fontSize } = span.style;
if (!color && !fontSize) return <>{span.text}</>;
return (
<span
style={{
color,
fontSize: fontSize != null ? `${fontSize}px` : undefined,
}}
>
{span.text}
</span>
);
}
// Public API
/**
* Filter a mission string by game mode prefix, e.g. `[CTF]`, `[DM Bounty]`.
* Lines without a prefix are shown for all modes.
*/
export function filterMissionStringByMode(
str: string,
missionType: string,
): string {
const type = missionType.toUpperCase();
return str
.split("\n")
.flatMap((line) => {
const m = line.match(/^\[([^\]]+)\]/);
if (m && !m[1].toUpperCase().split(/\s+/).includes(type)) return [];
return [line.replace(/^\[[^\]]+\]/, "")];
})
.join("\n");
}
/** Renders Torque `GuiMLTextCtrl` markup as React elements. */
export function GuiMarkup({ markup }: { markup: string }) {
const lines = useMemo(() => parseMarkup(markup), [markup]);
return (
<div className="GuiMarkup">
{lines.map((line, i) => {
const { align, lmargin, textIndent, items } = line;
const bitmaps = items.filter(
(it): it is Bitmap => it.type === "bitmap",
);
const spans = items.filter((it): it is Span => it.type === "span");
const hasText = spans.some((s) => s.text.trim().length > 0);
// Bullet layout: bitmap + lmargin indent + text on the same line
if (bitmaps.length > 0 && textIndent > 0 && hasText) {
return (
<div key={i} className="GuiMarkup-bulletLine">
<div className="GuiMarkup-bulletIcon">
{bitmaps.map((b, j) => (
<GuiBitmapEl key={j} name={b.name} />
))}
</div>
<div className="GuiMarkup-bulletText">
{spans.map((s, j) => (
<SpanEl key={j} span={s} />
))}
</div>
</div>
);
}
// Empty line → vertical spacer
if (!hasText && bitmaps.length === 0) {
return <div key={i} className="GuiMarkup-spacer" />;
}
return (
<div
key={i}
className="GuiMarkup-line"
style={{
textAlign: align !== "left" ? align : undefined,
paddingLeft: lmargin > 0 ? `${lmargin}px` : undefined,
}}
>
{items.map((item, j) =>
item.type === "bitmap" ? (
<GuiBitmapEl key={j} name={item.name} />
) : (
<SpanEl key={j} span={item} />
),
)}
</div>
);
})}
</div>
);
}