mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 22:44:47 +00:00
improve Torque GUI markup parsing
This commit is contained in:
parent
b833c34110
commit
3c8cce685d
33 changed files with 659 additions and 694 deletions
15
src/components/GuiMarkup.module.css
Normal file
15
src/components/GuiMarkup.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.GuiMarkup {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.GuiMarkup a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.Bullet {
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
30
src/components/GuiMarkup.spec.tsx
Normal file
30
src/components/GuiMarkup.spec.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseMarkup } from "./GuiMarkup";
|
||||
|
||||
const s1 = `<spush><color:00CCFF>Map by: FlyingElmo
|
||||
Flyersfan leave me alone now pls
|
||||
Website:<spop> <color:99CCFF><a:www.planettribes.com/elmo>Part of the <spush><color:ffffff>Double Threat<spop> Pack</a><spop>`;
|
||||
|
||||
describe("GuiMarkup", () => {
|
||||
describe("parseMarkup", () => {
|
||||
it("converts markup to JSX", () => {
|
||||
expect(parseMarkup(s1)).toEqual(
|
||||
<span>
|
||||
<span style={{ color: "#00CCFF" }}>
|
||||
{`Map by: FlyingElmo\nFlyersfan leave me alone now pls\nWebsite:`}
|
||||
</span>{" "}
|
||||
<span style={{ color: "#99CCFF" }}>
|
||||
<a
|
||||
href="http://www.planettribes.com/elmo"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{`Part of the `}
|
||||
<span style={{ color: "#ffffff" }}>Double Threat</span> Pack
|
||||
</a>
|
||||
</span>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
289
src/components/GuiMarkup.tsx
Normal file
289
src/components/GuiMarkup.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { getUrlForPath } from "../loaders";
|
||||
import { getStandardTextureResourceKey } from "../manifest";
|
||||
import styles from "./GuiMarkup.module.css";
|
||||
|
||||
// Tokenizer
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "tag"; name: string; args: string[] };
|
||||
|
||||
// spop, spush, lmargin, font, color, bitmap, a, a:wwwlink\t
|
||||
const validTagNames = new Set([
|
||||
"spop",
|
||||
"spush",
|
||||
"lmargin",
|
||||
"font",
|
||||
"color",
|
||||
"bitmap",
|
||||
"a",
|
||||
"/a",
|
||||
]);
|
||||
|
||||
export function tokenize(input: string): Token[] {
|
||||
const pattern = /<([^><]+)>/g;
|
||||
const tokens = input
|
||||
.split(pattern)
|
||||
.map((text, i) => {
|
||||
if (i % 2 === 0) {
|
||||
return text ? ({ type: "text", value: text } as Token) : null;
|
||||
} else {
|
||||
const [name, ...args] = text.split(":");
|
||||
if (validTagNames.has(name.toLowerCase())) {
|
||||
return { type: "tag", name, args } as Token;
|
||||
} else {
|
||||
return { type: "text", value: `<${text}>` } as Token;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((token) => token != null);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Parser
|
||||
|
||||
function parseFontArgs(args: string[]): {
|
||||
fontDescription: string;
|
||||
fontSize?: number;
|
||||
} {
|
||||
// arg is "FontName:size" — size is after the last colon
|
||||
const [fontDescription, fontSizeString] = args;
|
||||
const fontSize = fontSizeString
|
||||
? Math.max(11, Math.min(parseInt(fontSizeString.trim(), 10), 16))
|
||||
: undefined;
|
||||
return {
|
||||
fontDescription,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
type Node = {
|
||||
type: string;
|
||||
source?: string;
|
||||
style?: Record<string, string | number | undefined>;
|
||||
children?: Array<string | Node>;
|
||||
value?: any;
|
||||
};
|
||||
|
||||
export function parseMarkup(input: string) {
|
||||
const tokens = tokenize(input);
|
||||
const rootEl: Node = {
|
||||
type: "span",
|
||||
source: "root",
|
||||
style: {},
|
||||
children: [],
|
||||
};
|
||||
let topEl = rootEl;
|
||||
const stack = [topEl];
|
||||
const isUsed = (node: Node): boolean => {
|
||||
return (
|
||||
node.children != null &&
|
||||
node.children.some((child) => typeof child === "string" || isUsed(child))
|
||||
);
|
||||
};
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
topEl.children.push(token.value);
|
||||
break;
|
||||
case "tag":
|
||||
switch (token.name) {
|
||||
case "spush": {
|
||||
const span = {
|
||||
type: "span",
|
||||
source: "spush",
|
||||
style: {},
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(span);
|
||||
topEl = span;
|
||||
stack.push(topEl);
|
||||
break;
|
||||
}
|
||||
case "spop": {
|
||||
if (topEl.source !== "root") {
|
||||
let lastPop = stack.pop();
|
||||
while (lastPop.source !== "spush") {
|
||||
lastPop = stack.pop();
|
||||
}
|
||||
topEl = stack[stack.length - 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lmargin": {
|
||||
// const marginLeft = parseInt(token.args[0].trim(), 10) || 0;
|
||||
// if (topEl.children.length) {
|
||||
// topEl.style.marginLeft = marginLeft;
|
||||
// } else {
|
||||
// const marginNode: Node = {
|
||||
// type: "span",
|
||||
// source: "spush",
|
||||
// style: { marginLeft },
|
||||
// children: [],
|
||||
// };
|
||||
// topEl.children.push(marginNode);
|
||||
// topEl = marginNode;
|
||||
// stack.push(topEl);
|
||||
// }
|
||||
break;
|
||||
}
|
||||
case "font": {
|
||||
const fontSize = parseFontArgs(token.args).fontSize;
|
||||
if (!isUsed(topEl)) {
|
||||
topEl.style.fontSize = fontSize;
|
||||
} else {
|
||||
const fontNode: Node = {
|
||||
type: "span",
|
||||
source: "spush",
|
||||
style: { fontSize },
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(fontNode);
|
||||
topEl = fontNode;
|
||||
stack.push(topEl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "color":
|
||||
if (!isUsed(topEl)) {
|
||||
topEl.style.color = `#${token.args[0].trim()}`;
|
||||
} else {
|
||||
const colorNode: Node = {
|
||||
type: "span",
|
||||
source: "spush",
|
||||
style: { color: `#${token.args[0].trim()}` },
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(colorNode);
|
||||
topEl = colorNode;
|
||||
stack.push(topEl);
|
||||
}
|
||||
break;
|
||||
case "bitmap": {
|
||||
const bitmap: Node = {
|
||||
type: "bitmap",
|
||||
value: token.args[0],
|
||||
};
|
||||
topEl.children.push(bitmap);
|
||||
break;
|
||||
}
|
||||
case "a": {
|
||||
const arg = token.args[0].trim().split("\t");
|
||||
const href =
|
||||
arg.length === 2 && arg[0] === "wwwlink" ? arg[1] : arg[0];
|
||||
const link: Node = {
|
||||
type: "a",
|
||||
source: "a",
|
||||
value: `http://${href}`,
|
||||
style: {},
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(link);
|
||||
topEl = link;
|
||||
stack.push(topEl);
|
||||
break;
|
||||
}
|
||||
case "/a": {
|
||||
let lastPop = stack.pop();
|
||||
while (lastPop.source !== "a") {
|
||||
lastPop = stack.pop();
|
||||
}
|
||||
topEl = stack[stack.length - 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodeToJsx(rootEl);
|
||||
}
|
||||
|
||||
function nodeToJsx(node: Node): React.ReactNode {
|
||||
switch (node.type) {
|
||||
case "span":
|
||||
return React.createElement(
|
||||
"span",
|
||||
{
|
||||
style: Object.keys(node.style).length === 0 ? undefined : node.style,
|
||||
},
|
||||
...node.children.map((child) =>
|
||||
typeof child === "string" ? child : nodeToJsx(child),
|
||||
),
|
||||
);
|
||||
case "a":
|
||||
return React.createElement(
|
||||
"a",
|
||||
{
|
||||
href: node.value,
|
||||
style: Object.keys(node.style).length === 0 ? undefined : node.style,
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
},
|
||||
...node.children.map((child) =>
|
||||
typeof child === "string" ? child : nodeToJsx(child),
|
||||
),
|
||||
);
|
||||
case "bitmap":
|
||||
return <GuiBitmap name={node.value} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 GuiBitmap({ name }: { name: string }) {
|
||||
const url = getBitmapUrl(name);
|
||||
if (url) {
|
||||
return <img src={url} alt="" className={styles.Bitmap} />;
|
||||
}
|
||||
if (/bullet/i.test(name)) {
|
||||
return <span className={styles.Bullet}>•</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const guiMarkupTagPattern = /<(?:font|color|bitmap|just|lmargin|a):/i;
|
||||
|
||||
/** Whether a string contains Torque GUI markup tags. */
|
||||
export function hasGuiMarkup(text: string): boolean {
|
||||
return guiMarkupTagPattern.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 children = useMemo(() => parseMarkup(markup), [markup]);
|
||||
|
||||
return <div className={styles.GuiMarkup}>{children}</div>;
|
||||
}
|
||||
240
src/components/MapInfoDialog.module.css
Normal file
240
src/components/MapInfoDialog.module.css
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
.Dialog {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
max-width: calc(100dvw - 40px);
|
||||
max-height: calc(100dvh - 40px);
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: 1fr auto;
|
||||
background: rgba(20, 37, 38, 0.8);
|
||||
border: 1px solid rgba(65, 131, 139, 0.6);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 50px rgba(0, 0, 0, 0.4),
|
||||
inset 0 0 60px rgba(1, 7, 13, 0.6);
|
||||
color: #bccec3;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.Body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Left {
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.PreviewImage {
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-left: 1px solid rgba(0, 190, 220, 0.25);
|
||||
}
|
||||
|
||||
.PreviewImageFloating {
|
||||
float: right;
|
||||
clear: right;
|
||||
margin: 0 0 16px 20px;
|
||||
max-height: 260px;
|
||||
max-width: 30%;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
color: #7dffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.MapMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
/* text-transform: uppercase; */
|
||||
}
|
||||
|
||||
.MapPlanet {
|
||||
color: rgba(219, 202, 168, 0.7);
|
||||
}
|
||||
|
||||
.MapQuote {
|
||||
margin: 16px 0;
|
||||
padding: 0 0 0 14px;
|
||||
border-left: 2px solid rgba(0, 190, 220, 0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.MapQuote p {
|
||||
margin: 0 0 4px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.MapQuote cite {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.MapBlurb {
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.Section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.SectionTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #7dffff;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 16px rgba(0, 210, 240, 0.25);
|
||||
}
|
||||
|
||||
.MusicTrack {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: rgba(202, 208, 172, 0.5);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.MusicTrack[data-playing="true"] {
|
||||
color: rgba(247, 253, 216, 0.7);
|
||||
}
|
||||
|
||||
.MusicButton {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgb(85, 118, 99);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.MusicTrack[data-playing="true"] .MusicButton {
|
||||
color: rgb(109, 255, 170);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.MusicTrack[data-playing="true"] .MusicButton:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid rgba(0, 190, 220, 0.25);
|
||||
background: rgba(2, 20, 21, 0.7);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
padding: 4px 18px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(41, 172, 156, 0.7),
|
||||
rgba(0, 80, 65, 0.7)
|
||||
);
|
||||
border: 1px solid rgba(41, 97, 84, 0.6);
|
||||
border-top-color: rgba(101, 185, 176, 0.5);
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(120, 220, 195, 0.2),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.3),
|
||||
0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
color: rgba(154, 239, 225, 0.9);
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.CloseButton:active {
|
||||
transform: translate(0, 1px);
|
||||
}
|
||||
|
||||
.Hint {
|
||||
font-size: 12px;
|
||||
color: rgba(201, 220, 216, 0.3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.MusicTrackName {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
.Body {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.Hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Left {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.PreviewImage {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import {
|
|||
GuiMarkup,
|
||||
filterMissionStringByMode,
|
||||
hasGuiMarkup,
|
||||
} from "../torqueGuiMarkup";
|
||||
} from "./GuiMarkup";
|
||||
import type * as AST from "../torqueScript/ast";
|
||||
import styles from "./MapInfoDialog.module.css";
|
||||
|
||||
function useParsedMission(name: string) {
|
||||
return useQuery({
|
||||
|
|
@ -71,50 +72,46 @@ function getBitmapUrl(
|
|||
function RawPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
className = "MapInfoDialog-preview",
|
||||
className = styles.PreviewImage,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isLoaded, setLoaded] = useState(false);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | undefined;
|
||||
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);
|
||||
.then(
|
||||
(bitmap) =>
|
||||
new Promise<Blob | null>((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
canvas.toBlob(resolve);
|
||||
}),
|
||||
)
|
||||
.then((blob) => {
|
||||
if (cancelled || !blob) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setObjectUrl(url);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={className}
|
||||
aria-label={alt}
|
||||
style={{ display: isLoaded ? "block" : "none" }}
|
||||
/>
|
||||
);
|
||||
if (!objectUrl) return null;
|
||||
|
||||
return <img src={objectUrl} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
function MusicPlayer({ track }: { track: string }) {
|
||||
|
|
@ -140,7 +137,7 @@ function MusicPlayer({ track }: { track: string }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="MapInfoDialog-musicTrack" data-playing={playing}>
|
||||
<div className={styles.MusicTrack} data-playing={playing}>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
|
|
@ -149,10 +146,10 @@ function MusicPlayer({ track }: { track: string }) {
|
|||
onPause={() => setPlaying(false)}
|
||||
onError={() => setAvailable(false)}
|
||||
/>
|
||||
<span className="MusicTrackName">{track}</span>
|
||||
<span className={styles.MusicTrackName}>{track}</span>
|
||||
{available && (
|
||||
<button
|
||||
className="MapInfoDialog-musicBtn"
|
||||
className={styles.MusicButton}
|
||||
onClick={toggle}
|
||||
aria-label={playing ? "Pause music" : "Play music"}
|
||||
>
|
||||
|
|
@ -236,7 +233,7 @@ export function MapInfoDialog({
|
|||
if (!quoteHasMarkup) {
|
||||
for (const line of rawQuote.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.match(/^-+\s/)) {
|
||||
if (trimmed.match(/^--[^-]/)) {
|
||||
quoteAttrib = trimmed.replace(/^-+\s*/, "").trim();
|
||||
} else if (trimmed) {
|
||||
quoteText += (quoteText ? "\n" : "") + trimmed;
|
||||
|
|
@ -245,10 +242,10 @@ export function MapInfoDialog({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="MapInfoDialog-overlay" onClick={onClose}>
|
||||
<div className={styles.Overlay} onClick={onClose}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="MapInfoDialog"
|
||||
className={styles.Dialog}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
|
@ -256,55 +253,55 @@ export function MapInfoDialog({
|
|||
aria-label="Map Information"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="MapInfoDialog-inner">
|
||||
<div className="MapInfoDialog-left">
|
||||
<div className={styles.Body}>
|
||||
<div className={styles.Left}>
|
||||
{bitmapUrl && isSinglePlayer && (
|
||||
<RawPreviewImage
|
||||
key={bitmapUrl}
|
||||
className="MapInfoDialog-preview--floated"
|
||||
className={styles.PreviewImageFloating}
|
||||
src={bitmapUrl}
|
||||
alt={`${displayName} preview`}
|
||||
/>
|
||||
)}
|
||||
<h1 className="MapInfoDialog-title">{displayName}</h1>
|
||||
<div className="MapInfoDialog-meta">
|
||||
<h1 className={styles.Title}>{displayName}</h1>
|
||||
<div className={styles.MapMeta}>
|
||||
{parsedMission?.planetName && (
|
||||
<span className="MapInfoDialog-planet">
|
||||
<span className={styles.MapPlanet}>
|
||||
{parsedMission.planetName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quoteHasMarkup ? (
|
||||
<blockquote className="MapInfoDialog-quote">
|
||||
<blockquote className={styles.MapQuote}>
|
||||
<GuiMarkup markup={rawQuote} />
|
||||
</blockquote>
|
||||
) : quoteText ? (
|
||||
<blockquote className="MapInfoDialog-quote">
|
||||
<blockquote className={styles.MapQuote}>
|
||||
<p>{quoteText}</p>
|
||||
{quoteAttrib && <cite>— {quoteAttrib}</cite>}
|
||||
</blockquote>
|
||||
) : null}
|
||||
|
||||
{parsedMission?.missionBlurb && (
|
||||
<div className="MapInfoDialog-blurb">
|
||||
<div className={styles.MapBlurb}>
|
||||
{hasGuiMarkup(parsedMission.missionBlurb) ? (
|
||||
<GuiMarkup markup={parsedMission.missionBlurb.trim()} />
|
||||
) : (
|
||||
<p>{parsedMission.missionBlurb.trim()}</p>
|
||||
parsedMission.missionBlurb.trim()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missionString && missionString.trim() && (
|
||||
<div className="MapInfoDialog-section">
|
||||
<div className={styles.Section}>
|
||||
<GuiMarkup markup={missionString} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedMission?.missionBriefing && (
|
||||
<div className="MapInfoDialog-section">
|
||||
<h2 className="MapInfoDialog-sectionTitle">Mission Briefing</h2>
|
||||
<div className={styles.Section}>
|
||||
<h2 className={styles.SectionTitle}>Mission Briefing</h2>
|
||||
<GuiMarkup markup={parsedMission.missionBriefing} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -313,21 +310,19 @@ export function MapInfoDialog({
|
|||
</div>
|
||||
|
||||
{bitmapUrl && !isSinglePlayer && (
|
||||
<div className="MapInfoDialog-right">
|
||||
<RawPreviewImage
|
||||
key={bitmapUrl}
|
||||
src={bitmapUrl}
|
||||
alt={`${displayName} preview`}
|
||||
/>
|
||||
</div>
|
||||
<RawPreviewImage
|
||||
key={bitmapUrl}
|
||||
src={bitmapUrl}
|
||||
alt={`${displayName} preview`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="MapInfoDialog-footer">
|
||||
<button className="MapInfoDialog-closeBtn" onClick={onClose}>
|
||||
<div className={styles.Footer}>
|
||||
<button className={styles.CloseButton} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<span className="MapInfoDialog-hint">I or Esc to close</span>
|
||||
<span className={styles.Hint}>I or Esc to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
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
|
||||
|
||||
const guiMarkupTagPattern = /<(?:font|color|bitmap|just|lmargin):/i;
|
||||
|
||||
/** Whether a string contains Torque GUI markup tags. */
|
||||
export function hasGuiMarkup(text: string): boolean {
|
||||
return guiMarkupTagPattern.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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