mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 20:25:01 +00:00
rotate sky, organize missions into groups
This commit is contained in:
parent
1b3ff5ff00
commit
6257ef57b6
File diff suppressed because one or more lines are too long
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue