mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 14:34:51 +00:00
add map info dialog
This commit is contained in:
parent
0b345facea
commit
c44df43a91
36 changed files with 2051 additions and 1068 deletions
|
|
@ -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>
|
||||
|
|
|
|||
317
src/components/MapInfoDialog.tsx
Normal file
317
src/components/MapInfoDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
305
src/torqueGuiMarkup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue