rotate sky, organize missions into groups

This commit is contained in:
Brian Beck 2025-11-26 12:58:31 -08:00
parent 1b3ff5ff00
commit 6257ef57b6
7 changed files with 236 additions and 57 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,45 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import unzipper from "unzipper"; import { Dirent } from "node:fs";
import orderBy from "lodash.orderby";
import { normalizePath } from "@/src/stringUtils"; import { normalizePath } from "@/src/stringUtils";
import { parseMissionScript } from "@/src/mission";
const archiveFilePattern = /\.vl2$/i; const baseDir = process.env.BASE_DIR || "docs/base";
const baseDir = process.env.BASE_DIR || "GameData/base"; async function walkDirectory(
dir: string,
{
onFile,
onDir = () => true,
}: {
onFile: (fileInfo: {
dir: string;
entry: Dirent<string>;
fullPath: string;
}) => void | Promise<void>;
onDir?: (dirInfo: {
dir: string;
entry: Dirent<string>;
fullPath: string;
}) => boolean | Promise<boolean>;
}
): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
function isArchive(name: string) { for (const entry of entries) {
return archiveFilePattern.test(name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const shouldRecurse = await onDir({ dir, entry, fullPath });
if (shouldRecurse) {
await walkDirectory(fullPath, { onFile, onDir });
}
} else if (entry.isFile()) {
await onFile({ dir, entry, fullPath });
}
}
} }
/** /**
@ -43,45 +73,65 @@ async function buildManifest() {
const fileSources = new Map<string, string[]>(); const fileSources = new Map<string, string[]>();
const looseFiles: string[] = []; const looseFiles: string[] = [];
const archiveFiles: string[] = [];
for await (const entry of fs.glob(`${baseDir}/**/*`, { await walkDirectory(baseDir, {
withFileTypes: true, onFile: ({ fullPath }) => {
})) { looseFiles.push(normalizePath(fullPath));
if (entry.isFile()) { },
const fullPath = normalizePath(`${entry.parentPath}/${entry.name}`); onDir: ({ dir, entry, fullPath }) => {
if (isArchive(entry.name)) { return entry.name !== "@vl2";
archiveFiles.push(fullPath); },
} else { });
looseFiles.push(fullPath);
}
}
}
for (const filePath of looseFiles) { for (const filePath of looseFiles) {
const relativePath = normalizePath(path.relative(baseDir, filePath)); const relativePath = normalizePath(path.relative(baseDir, filePath));
fileSources.set(relativePath, [""]); fileSources.set(relativePath, [""]);
} }
archiveFiles.sort(); let archiveDirs: string[] = [];
for (const archivePath of archiveFiles) { await walkDirectory(`${baseDir}/@vl2`, {
const relativePath = normalizePath(path.relative(baseDir, archivePath)); onFile: () => {},
const archive = await unzipper.Open.file(archivePath); onDir: ({ dir, entry, fullPath }) => {
for (const archiveEntry of archive.files) { if (entry.name.endsWith(".vl2")) {
if (archiveEntry.type === "File") { archiveDirs.push(fullPath);
const filePath = normalizePath(archiveEntry.path);
const sources = fileSources.get(filePath) ?? [];
sources.push(relativePath);
fileSources.set(filePath, sources);
} }
} return true;
},
});
archiveDirs = orderBy(
archiveDirs,
[(fullPath) => path.basename(fullPath).toLowerCase()],
["asc"]
);
for (const archivePath of archiveDirs) {
const relativeArchivePath = normalizePath(
path.relative(`${baseDir}/@vl2`, archivePath)
);
await walkDirectory(archivePath, {
onFile: ({ dir, entry, fullPath }) => {
const filePath = normalizePath(path.relative(archivePath, fullPath));
const sources = fileSources.get(filePath) ?? [];
sources.push(relativeArchivePath);
fileSources.set(filePath, sources);
},
});
} }
const manifest: Record<string, string[]> = {}; const resources: Record<string, string[]> = {};
const missions: Record<
string,
{ resourcePath: string; displayName: string | null; missionTypes: string[] }
> = {};
const orderedFiles = Array.from(fileSources.keys()).sort(); const orderedFiles = Array.from(fileSources.keys()).sort();
for (const filePath of orderedFiles) { for (const filePath of orderedFiles) {
const sources = fileSources.get(filePath); const sources = fileSources.get(filePath);
manifest[filePath] = sources; resources[filePath] = sources;
const lastSource = sources[sources.length - 1];
console.log( console.log(
`${filePath}${sources[0] ? ` 📦 ${sources[0]}` : ""}${ `${filePath}${sources[0] ? ` 📦 ${sources[0]}` : ""}${
sources.length > 1 sources.length > 1
@ -92,9 +142,24 @@ async function buildManifest() {
: "" : ""
}` }`
); );
const resolvedPath = lastSource
? path.join(baseDir, "@vl2", lastSource, filePath)
: path.join(baseDir, filePath);
if (filePath.endsWith(".mis")) {
const missionScript = await fs.readFile(resolvedPath, "utf8");
const mission = parseMissionScript(missionScript);
const baseName = path.basename(filePath, ".mis");
missions[baseName] = {
resourcePath: filePath,
displayName: mission.displayName,
missionTypes: mission.missionTypes,
};
}
} }
return manifest; return { resources, missions };
} }
const { values } = parseArgs({ const { values } = parseArgs({

View file

@ -1,4 +1,5 @@
import { getResourceList } from "../manifest"; import { Fragment, useMemo } from "react";
import { getMissionInfo, getMissionList, getSource } from "../manifest";
import { useControls, useDebug, useSettings } from "./SettingsProvider"; import { useControls, useDebug, useSettings } from "./SettingsProvider";
import orderBy from "lodash.orderby"; import orderBy from "lodash.orderby";
@ -8,16 +9,60 @@ const excludeMissions = new Set([
"SkiFree_Randomizer", "SkiFree_Randomizer",
]); ]);
const missions = orderBy( const SOURCE_GROUP_NAMES = {
getResourceList() "Classic_maps_v1.vl2": "Classic",
.map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/)) "missions.vl2": "Official",
.filter(Boolean) "S5maps.vl2": "S5",
.map((match) => match[1]) "S8maps.vl2": "S8",
.filter((name) => !excludeMissions.has(name)), "SkiFreeGameType.vl2": "SkiFree",
[(name) => name.toLowerCase().replace(/_/g, " ")], "TR2final105-client.vl2": "Team Rabbit 2",
["asc"] "TWL-MapPack.vl2": "TWL",
"TWL2-MapPack.vl2": "TWL2",
"z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
"zAddOnsVL2s/TWL_T2arenaOfficialMaps.vl2": "Arena",
"zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2": "DMP (Discord Map Pack)",
};
const groupedMissions = getMissionList().reduce(
(groupMap, missionName) => {
const missionInfo = getMissionInfo(missionName);
const source = getSource(missionInfo.resourcePath);
const groupName = SOURCE_GROUP_NAMES[source] ?? null;
const groupMissions = groupMap.get(groupName) ?? [];
if (!excludeMissions.has(missionName)) {
groupMissions.push({
resourcePath: missionInfo.resourcePath,
missionName,
displayName: missionInfo.displayName,
});
groupMap.set(groupName, groupMissions);
}
return groupMap;
},
new Map<
string | null,
Array<{
resourcePath: string;
missionName: string;
displayName: string;
}>
>()
); );
groupedMissions.forEach((groupMissions, groupName) => {
groupedMissions.set(
groupName,
orderBy(
groupMissions,
[
(missionInfo) =>
(missionInfo.displayName || missionInfo.missionName).toLowerCase(),
],
["asc"]
)
);
});
export function InspectorControls({ export function InspectorControls({
missionName, missionName,
onChangeMission, onChangeMission,
@ -36,6 +81,19 @@ export function InspectorControls({
const { speedMultiplier, setSpeedMultiplier } = useControls(); const { speedMultiplier, setSpeedMultiplier } = useControls();
const { debugMode, setDebugMode } = useDebug(); const { debugMode, setDebugMode } = useDebug();
const groupedMissionOptions = useMemo(() => {
const groups = orderBy(
Array.from(groupedMissions.entries()),
[
([groupName]) =>
groupName === "Official" ? 0 : groupName == null ? 2 : 1,
([groupName]) => (groupName ? groupName.toLowerCase() : ""),
],
["asc", "asc"]
);
return groups;
}, []);
return ( return (
<div <div
id="controls" id="controls"
@ -48,9 +106,26 @@ export function InspectorControls({
value={missionName} value={missionName}
onChange={(event) => onChangeMission(event.target.value)} onChange={(event) => onChangeMission(event.target.value)}
> >
{missions.map((missionName) => ( {groupedMissionOptions.map(([groupName, groupMissions]) =>
<option key={missionName}>{missionName}</option> groupName ? (
))} <optgroup key={groupName} label={groupName}>
{groupMissions.map((mission) => (
<option key={mission.missionName} value={mission.missionName}>
{mission.displayName || mission.missionName}
</option>
))}
</optgroup>
) : (
<Fragment key="null">
<hr />
{groupMissions.map((mission) => (
<option key={mission.missionName} value={mission.missionName}>
{mission.displayName || mission.missionName}
</option>
))}
</Fragment>
)
)}
</select> </select>
<div className="CheckboxField"> <div className="CheckboxField">
<input <input

View file

@ -1,10 +1,11 @@
import { Suspense, useMemo, useEffect, useRef } from "react"; import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei"; import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide } from "three"; import { Color, ShaderMaterial, BackSide, Euler } from "three";
import { ConsoleObject, getProperty } from "../mission"; import { ConsoleObject, getProperty, getRotation } from "../mission";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders"; import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
import { useThree } from "@react-three/fiber";
const FALLBACK_URL = `${BASE_URL}/black.png`; const FALLBACK_URL = `${BASE_URL}/black.png`;
@ -119,6 +120,12 @@ export function SkyBox({
} }
}, [skyBox, fogColor, hasFog, shaderMaterial]); }, [skyBox, fogColor, hasFog, shaderMaterial]);
const { scene } = useThree();
useEffect(() => {
scene.backgroundRotation = new Euler(0, Math.PI / 2, 0);
}, []);
// If fog is disabled, just use the skybox as background // If fog is disabled, just use the skybox as background
if (!hasFog) { if (!hasFog) {
return <primitive attach="background" object={skyBox} />; return <primitive attach="background" object={skyBox} />;

View file

@ -1,5 +1,10 @@
import { parseImageFrameList } from "./ifl"; import { parseImageFrameList } from "./ifl";
import { getActualResourcePath, getSource } from "./manifest"; import {
findMissionPath,
getActualResourcePath,
getMissionInfo,
getSource,
} from "./manifest";
import { parseMissionScript } from "./mission"; import { parseMissionScript } from "./mission";
import { parseTerrainBuffer } from "./terrain"; import { parseTerrainBuffer } from "./terrain";
@ -78,7 +83,8 @@ export async function loadDetailMapList(name: string) {
} }
export async function loadMission(name: string) { export async function loadMission(name: string) {
const res = await fetch(getUrlForPath(`missions/${name}.mis`)); const missionInfo = getMissionInfo(name);
const res = await fetch(getUrlForPath(missionInfo.resourcePath));
const missionScript = await res.text(); const missionScript = await res.text();
return parseMissionScript(missionScript); return parseMissionScript(missionScript);
} }

View file

@ -1,7 +1,19 @@
import manifest from "../public/manifest.json"; import untypedManifest from "../public/manifest.json";
const manifest = untypedManifest as {
resources: Record<string, string[]>;
missions: Record<
string,
{
resourcePath: string;
displayName: string | null;
missionTypes: string[];
}
>;
};
export function getSource(resourcePath: string) { export function getSource(resourcePath: string) {
const sources = manifest[resourcePath]; const sources = manifest.resources[resourcePath];
if (sources && sources.length > 0) { if (sources && sources.length > 0) {
return sources[sources.length - 1]; return sources[sources.length - 1];
} else { } else {
@ -21,7 +33,7 @@ export function getActualResourcePath(resourcePath: string) {
} }
export function getActualResourcePathUncached(resourcePath: string) { export function getActualResourcePathUncached(resourcePath: string) {
if (manifest[resourcePath]) { if (manifest.resources[resourcePath]) {
return resourcePath; return resourcePath;
} }
const resourcePaths = getResourceList(); const resourcePaths = getResourceList();
@ -68,7 +80,7 @@ export function getActualResourcePathUncached(resourcePath: string) {
return resourcePath; return resourcePath;
} }
const _cachedResourceList = Object.keys(manifest).sort(); const _cachedResourceList = Object.keys(manifest.resources);
export function getResourceList() { export function getResourceList() {
return _cachedResourceList; return _cachedResourceList;
@ -82,3 +94,15 @@ export function getFilePath(resourcePath: string) {
return `public/base/${resourcePath}`; return `public/base/${resourcePath}`;
} }
} }
export function getMissionInfo(missionName: string) {
const missionInfo = manifest.missions[missionName];
if (!missionInfo) {
throw new Error(`Mission not found: ${missionName}`);
}
return missionInfo;
}
export function getMissionList() {
return Object.keys(manifest.missions);
}

View file

@ -1,7 +1,7 @@
import { Quaternion, Vector3 } from "three"; import { Quaternion, Vector3 } from "three";
import parser from "@/generated/mission.cjs"; import parser from "@/generated/mission.cjs";
const definitionComment = /^ (DisplayName|MissionTypes) = (.+)$/; const definitionComment = /^ (DisplayName|MissionTypes) = (.+)$/i;
const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/; const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/;
const sectionEndComment = /^--- ([A-Z ]+) END ---$/; const sectionEndComment = /^--- ([A-Z ]+) END ---$/;
@ -92,7 +92,7 @@ export function parseMissionScript(script) {
let section = { name: null, definitions: [] }; let section = { name: null, definitions: [] };
const mission: { const mission: {
pragma: Record<string, string>; pragma: Record<string, string | null>;
sections: Array<{ name: string | null; definitions: any[] }>; sections: Array<{ name: string | null; definitions: any[] }>;
} = { } = {
pragma: {}, pragma: {},
@ -150,8 +150,10 @@ export function parseMissionScript(script) {
} }
return { return {
displayName: mission.pragma.DisplayName ?? null, displayName:
missionTypes: mission.pragma.MissionTypes?.split(" ") ?? [], mission.pragma.DisplayName ?? mission.pragma.Displayname ?? null,
missionTypes:
mission.pragma.MissionTypes?.split(/\s+/).filter(Boolean) ?? [],
missionQuote: missionQuote:
mission.sections mission.sections
.find((section) => section.name === "MISSION QUOTE") .find((section) => section.name === "MISSION QUOTE")