allow selecting different game types

This commit is contained in:
Brian Beck 2025-12-14 11:06:57 -08:00
parent 7f75ed84da
commit 049566cdbb
56 changed files with 436 additions and 207 deletions

View file

@ -70,7 +70,6 @@ function createMaterialFromFlags(
const isTranslucent = flagNames.has("Translucent");
const isAdditive = flagNames.has("Additive");
const isSelfIlluminating = flagNames.has("SelfIlluminating");
const neverEnvMap = flagNames.has("NeverEnvMap");
// SelfIlluminating materials are unlit (use MeshBasicMaterial)
if (isSelfIlluminating) {
@ -334,21 +333,25 @@ export function DebugPlaceholder({
* pattern used across shape-rendering components.
*/
export function ShapeRenderer({
shapeName,
loadingColor = "yellow",
children,
}: {
shapeName: string | undefined;
loadingColor?: string;
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
if (!shapeName) {
return <DebugPlaceholder color="orange" />;
return (
<DebugPlaceholder color="orange" label={`${object._id}: <missing>`} />
);
}
return (
<ErrorBoundary
fallback={<DebugPlaceholder color="red" label={shapeName} />}
fallback={
<DebugPlaceholder color="red" label={`${object._id}: ${shapeName}`} />
}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModel />
@ -359,7 +362,7 @@ export function ShapeRenderer({
}
export const ShapeModel = memo(function ShapeModel() {
const { shapeName, isOrganic } = useShapeInfo();
const { object, shapeName, isOrganic } = useShapeInfo();
const { debugMode } = useDebug();
const { nodes } = useStaticShape(shapeName);
@ -501,7 +504,11 @@ export const ShapeModel = memo(function ShapeModel() {
) : null}
</Suspense>
))}
{debugMode ? <FloatingLabel>{shapeName}</FloatingLabel> : null}
{debugMode ? (
<FloatingLabel>
{object._id}: {shapeName}
</FloatingLabel>
) : null}
</group>
);
});

View file

@ -3,10 +3,18 @@ import { MissionSelect } from "./MissionSelect";
export function InspectorControls({
missionName,
missionType,
onChangeMission,
}: {
missionName: string;
onChangeMission: (name: string) => void;
missionType: string;
onChangeMission: ({
missionName,
missionType,
}: {
missionName: string;
missionType: string;
}) => void;
}) {
const {
fogEnabled,
@ -28,7 +36,11 @@ export function InspectorControls({
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<MissionSelect value={missionName} onChange={onChangeMission} />
<MissionSelect
value={missionName}
missionType={missionType}
onChange={onChangeMission}
/>
<div className="CheckboxField">
<input
id="fogInput"

View file

@ -186,7 +186,13 @@ function InteriorMesh({ node }: { node: Mesh }) {
}
export const InteriorModel = memo(
({ interiorFile }: { interiorFile: string }) => {
({
object,
interiorFile,
}: {
object: TorqueObject;
interiorFile: string;
}) => {
const { nodes } = useInterior(interiorFile);
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
@ -198,7 +204,11 @@ export const InteriorModel = memo(
.map(([name, node]: [string, any]) => (
<InteriorMesh key={name} node={node} />
))}
{debugMode ? <FloatingLabel>{interiorFile}</FloatingLabel> : null}
{debugMode ? (
<FloatingLabel>
{object._id}: {interiorFile}
</FloatingLabel>
) : null}
</group>
);
},
@ -239,10 +249,12 @@ export const InteriorInstance = memo(function InteriorInstance({
return (
<group position={position} quaternion={q} scale={scale}>
<ErrorBoundary
fallback={<DebugInteriorPlaceholder label={interiorFile} />}
fallback={
<DebugInteriorPlaceholder label={`${object._id}: ${interiorFile}`} />
}
>
<Suspense fallback={<InteriorPlaceholder color="orange" />}>
<InteriorModel interiorFile={interiorFile} />
<InteriorModel object={object} interiorFile={interiorFile} />
</Suspense>
</ErrorBoundary>
</group>

View file

@ -33,9 +33,9 @@ export function Item({ object }: { object: TorqueObject }) {
const label = isFlag && teamName ? `${teamName} Flag` : null;
return (
<ShapeInfoProvider shapeName={shapeName} type="Item">
<ShapeInfoProvider type="Item" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer shapeName={shapeName} loadingColor="pink">
<ShapeRenderer loadingColor="pink">
{label ? <FloatingLabel opacity={0.6}>{label}</FloatingLabel> : null}
</ShapeRenderer>
</group>

View file

@ -3,8 +3,8 @@ import picomatch from "picomatch";
import { loadMission } from "../loaders";
import { type ParsedMission } from "../mission";
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { renderObject } from "./renderObject";
import { memo, useEffect, useState } from "react";
import { SimObject } from "./SimObject";
import { memo, useEffect, useMemo, useState } from "react";
import { RuntimeProvider } from "./RuntimeProvider";
import {
createProgressTracker,
@ -20,6 +20,7 @@ import {
getResourceMap,
getSourceAndPath,
} from "../manifest";
import { MissionProvider } from "./MissionContext";
const loadScript = createScriptLoader();
// Shared cache for parsed scripts - survives runtime restarts
@ -30,7 +31,7 @@ const fileSystem: FileSystemHandler = {
return getResourceList()
.filter((path) => isMatch(path))
.map((resourceKey) => {
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
const [, actualPath] = getSourceAndPath(resourceKey);
return actualPath;
});
},
@ -56,6 +57,7 @@ interface ExecutedMissionState {
function useExecutedMission(
missionName: string,
missionType: string,
parsedMission: ParsedMission | undefined,
): ExecutedMissionState {
const [state, setState] = useState<ExecutedMissionState>({
@ -70,8 +72,6 @@ function useExecutedMission(
}
const controller = new AbortController();
// FIXME: Always just runs as the first game type for now...
const missionType = parsedMission.missionTypes[0];
// Create progress tracker and update state on changes
const progressTracker = createProgressTracker();
@ -138,19 +138,33 @@ function useExecutedMission(
interface MissionProps {
name: string;
missionType: string;
setMissionType: (type: string) => void;
onLoadingChange?: (isLoading: boolean, progress?: number) => void;
}
export const Mission = memo(function Mission({
name,
missionType,
onLoadingChange,
}: MissionProps) {
const { data: parsedMission } = useParsedMission(name);
const { missionGroup, runtime, progress } = useExecutedMission(
name,
missionType,
parsedMission,
);
const isLoading = !missionGroup || !runtime;
const isLoading = !parsedMission || !missionGroup || !runtime;
const missionContext = useMemo(
() => ({
metadata: parsedMission,
missionType,
missionGroup,
}),
[parsedMission, missionType, missionGroup],
);
useEffect(() => {
onLoadingChange?.(isLoading, progress);
@ -161,8 +175,10 @@ export const Mission = memo(function Mission({
}
return (
<RuntimeProvider runtime={runtime}>
{renderObject(missionGroup)}
</RuntimeProvider>
<MissionProvider value={missionContext}>
<RuntimeProvider runtime={runtime}>
<SimObject object={missionGroup} />
</RuntimeProvider>
</MissionProvider>
);
});

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
import { ParsedMission } from "../mission";
import { TorqueObject } from "../torqueScript";
export type MissionContextType = {
metadata: ParsedMission;
missionType: string;
missionGroup: TorqueObject;
};
const MissionContext = createContext<MissionContextType | null>(null);
export const MissionProvider = MissionContext.Provider;
export function useMission() {
return useContext(MissionContext);
}

View file

@ -132,7 +132,11 @@ function MissionItemContent({ mission }: { mission: MissionItem }) {
{mission.missionTypes.length > 0 && (
<span className="MissionSelect-itemTypes">
{mission.missionTypes.map((type) => (
<span key={type} className="MissionSelect-itemType">
<span
key={type}
className="MissionSelect-itemType"
data-mission-type={type}
>
{type}
</span>
))}
@ -148,19 +152,41 @@ function MissionItemContent({ mission }: { mission: MissionItem }) {
export function MissionSelect({
value,
missionType,
onChange,
}: {
value: string;
onChange: (missionName: string) => void;
missionType: string;
onChange: ({
missionName,
missionType,
}: {
missionName: string;
missionType: string | undefined;
}) => void;
}) {
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const missionTypeRef = useRef<string | null>(missionType);
const combobox = useComboboxStore({
resetValueOnHide: true,
selectedValue: value,
setSelectedValue: (newValue) => {
if (newValue) onChange(newValue);
if (newValue) {
let newMissionType = missionTypeRef.current;
const availableMissionTypes = getMissionInfo(newValue).missionTypes;
if (
!newMissionType ||
!availableMissionTypes.includes(newMissionType)
) {
newMissionType = availableMissionTypes[0];
}
onChange({
missionName: newValue,
missionType: newMissionType,
});
}
},
setValue: (value) => {
startTransition(() => setSearchValue(value));
@ -200,6 +226,40 @@ export function MissionSelect({
? filteredResults.missions.length === 0
: filteredResults.groups.length === 0;
const renderItem = (mission) => {
return (
<ComboboxItem
key={mission.missionName}
value={mission.missionName}
className="MissionSelect-item"
focusOnHover
onClick={(event) => {
if (event.target && event.target instanceof HTMLElement) {
const missionType = event.target.dataset.missionType;
if (missionType) {
missionTypeRef.current = missionType;
const isOnlyMissionTypeChange = mission.missionName === value;
if (isOnlyMissionTypeChange) {
// Need to trigger change ourselves, because Combobox sees this
// as no change.
onChange({
missionName: mission.missionName,
missionType,
});
}
} else {
missionTypeRef.current = null;
}
} else {
missionTypeRef.current = null;
}
}}
>
<MissionItemContent mission={mission} />
</ComboboxItem>
);
};
return (
<ComboboxProvider store={combobox}>
<div className="MissionSelect-inputWrapper">
@ -213,21 +273,23 @@ export function MissionSelect({
combobox.show();
}}
/>
<div className="MissionSelect-selectedValue">
<span className="MissionSelect-selectedName">{displayValue}</span>
{missionType && (
<span
className="MissionSelect-itemType"
data-mission-type={missionType}
>
{missionType}
</span>
)}
</div>
<kbd className="MissionSelect-shortcut">{isMac ? "⌘K" : "^K"}</kbd>
</div>
<ComboboxPopover gutter={4} fitViewport className="MissionSelect-popover">
<ComboboxList className="MissionSelect-list">
{filteredResults.type === "flat"
? filteredResults.missions.map((mission) => (
<ComboboxItem
key={mission.missionName}
value={mission.missionName}
className="MissionSelect-item"
focusOnHover
>
<MissionItemContent mission={mission} />
</ComboboxItem>
))
? filteredResults.missions.map(renderItem)
: filteredResults.groups.map(([groupName, missions]) =>
groupName ? (
<ComboboxGroup
@ -237,29 +299,11 @@ export function MissionSelect({
<ComboboxGroupLabel className="MissionSelect-groupLabel">
{groupName}
</ComboboxGroupLabel>
{missions.map((mission) => (
<ComboboxItem
key={mission.missionName}
value={mission.missionName}
className="MissionSelect-item"
focusOnHover
>
<MissionItemContent mission={mission} />
</ComboboxItem>
))}
{missions.map(renderItem)}
</ComboboxGroup>
) : (
<Fragment key="ungrouped">
{missions.map((mission) => (
<ComboboxItem
key={mission.missionName}
value={mission.missionName}
className="MissionSelect-item"
focusOnHover
>
<MissionItemContent mission={mission} />
</ComboboxItem>
))}
{missions.map(renderItem)}
</Fragment>
),
)}

View file

@ -1,4 +1,5 @@
import { createContext, ReactNode, useContext, useMemo } from "react";
import { TorqueObject } from "../torqueScript";
export type StaticShapeType = "TSStatic" | "StaticShape" | "Item" | "Turret";
@ -17,25 +18,44 @@ export function isOrganicShape(shapeName: string): boolean {
return ORGANIC_PATTERN.test(shapeName);
}
const ShapeInfoContext = createContext(null);
interface ShapeInfoContextValue {
object: TorqueObject;
shapeName: string;
type: StaticShapeType;
isOrganic: boolean;
}
export function useShapeInfo() {
return useContext(ShapeInfoContext);
const ShapeInfoContext = createContext<ShapeInfoContextValue | null>(null);
export function useShapeInfo(): ShapeInfoContextValue {
const context = useContext(ShapeInfoContext);
if (!context) {
throw new Error("useShapeInfo must be used within ShapeInfoProvider");
}
return context;
}
export function ShapeInfoProvider({
children,
object,
shapeName,
type,
}: {
object: TorqueObject;
children: ReactNode;
shapeName: string;
type: StaticShapeType;
}) {
const isOrganic = useMemo(() => isOrganicShape(shapeName), [shapeName]);
const context = useMemo(
() => ({ shapeName, type, isOrganic }),
[shapeName, type, isOrganic],
() => ({
object,
shapeName,
type,
isOrganic,
}),
[object, shapeName, type, isOrganic],
);
return (

View file

@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { renderObject } from "./renderObject";
import { SimObject } from "./SimObject";
export type SimGroupContextType = {
object: TorqueObject;
@ -51,7 +51,9 @@ export function SimGroup({ object }: { object: TorqueObject }) {
return (
<SimGroupContext.Provider value={simGroup}>
{(object._children ?? []).map((child, i) => renderObject(child, i))}
{(object._children ?? []).map((child, i) => (
<SimObject object={child} key={child._id} />
))}
</SimGroupContext.Provider>
);
}

View file

@ -1,4 +1,4 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { TerrainBlock } from "./TerrainBlock";
import { SimGroup } from "./SimGroup";
@ -12,6 +12,8 @@ import { Turret } from "./Turret";
import { WayPoint } from "./WayPoint";
import { Camera } from "./Camera";
import { useSettings } from "./SettingsProvider";
import { useMission } from "./MissionContext";
import { getProperty } from "../mission";
const AudioEmitter = lazy(() =>
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
@ -27,7 +29,7 @@ const ForceFieldBare = lazy(() =>
import("./ForceFieldBare").then((mod) => ({ default: mod.ForceFieldBare })),
);
// Not every map will have force fields.
// Not every map will have water.
const WaterBlock = lazy(() =>
import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
);
@ -49,10 +51,26 @@ const componentMap = {
WayPoint,
};
export function renderObject(object: TorqueObject, key?: string | number) {
export function SimObject({ object }: { object: TorqueObject }) {
const { missionType } = useMission();
// FIXME: In theory we could make sure TorqueScript is calling `hide()`
// based on the mission type already, which is built-in behavior, then just
// make sure we respect the hidden/visible state here. For now do it this way.
const shouldShowObject = useMemo(() => {
const missionTypesList = new Set(
(getProperty(object, "missionTypesList") ?? "")
.toLowerCase()
.split(/s+/)
.filter(Boolean),
);
return (
!missionTypesList.size || missionTypesList.has(missionType.toLowerCase())
);
}, [object, missionType]);
const Component = componentMap[object._className];
return Component ? (
<Suspense key={key}>
return shouldShowObject && Component ? (
<Suspense>
<Component object={object} />
</Suspense>
) : null;

View file

@ -6,9 +6,9 @@ import { Color, Fog } from "three";
import type { TorqueObject } from "../torqueScript";
import { getInt, getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
import { BASE_URL, loadDetailMapList, textureToUrl } from "../loaders";
import { loadDetailMapList, textureToUrl } from "../loaders";
import { CloudLayers } from "./CloudLayers";
import { parseFogState, type FogState, type FogVolume } from "./FogProvider";
import { parseFogState, type FogState } from "./FogProvider";
import { installCustomFogShader } from "../fogShader";
import {
globalFogUniforms,
@ -17,8 +17,6 @@ import {
resetGlobalFogUniforms,
} from "../globalFogUniforms";
const FALLBACK_TEXTURE_URL = `${BASE_URL}/black.png`;
// Track if fog shader has been installed (idempotent installation)
let fogShaderInstalled = false;

View file

@ -22,9 +22,9 @@ export function StaticShape({ object }: { object: TorqueObject }) {
}
return (
<ShapeInfoProvider shapeName={shapeName} type="StaticShape">
<ShapeInfoProvider type="StaticShape" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer shapeName={shapeName} />
<ShapeRenderer />
</group>
</ShapeInfoProvider>
);

View file

@ -16,9 +16,9 @@ export function TSStatic({ object }: { object: TorqueObject }) {
}
return (
<ShapeInfoProvider shapeName={shapeName} type="TSStatic">
<ShapeInfoProvider type="TSStatic" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer shapeName={shapeName} />
<ShapeRenderer />
</group>
</ShapeInfoProvider>
);

View file

@ -30,13 +30,17 @@ export function Turret({ object }: { object: TorqueObject }) {
}
return (
<ShapeInfoProvider shapeName={shapeName} type="Turret">
<ShapeInfoProvider type="Turret" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer shapeName={shapeName} />
<ShapeRenderer />
{barrelShapeName ? (
<ShapeInfoProvider shapeName={barrelShapeName} type="Turret">
<ShapeInfoProvider
type="Turret"
object={object}
shapeName={barrelShapeName}
>
<group position={[0, 1.5, 0]}>
<ShapeRenderer shapeName={barrelShapeName} />
<ShapeRenderer />
</group>
</ShapeInfoProvider>
) : null}

View file

@ -109,7 +109,7 @@ export const WaterBlock = memo(function WaterBlock({
// TODO: Use this for terrain intersection masking (reject water blocks where
// terrain height > surfaceZ + waveMagnitude/2). Requires TerrainProvider.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const surfaceZ = position[1] + scaleY;
// const surfaceZ = position[1] + scaleY;
// Wave magnitude affects terrain masking (Torque adds half to surface height)
const waveMagnitude = getFloat(object, "waveMagnitude") ?? 1.0;
@ -148,7 +148,10 @@ export const WaterBlock = memo(function WaterBlock({
// Matches fluidQuadTree.cc RunQuadTree():
// I = (s32)(m_Eye.X / 2048.0f);
// if( m_Eye.X < 0.0f ) I--;
const calculateReps = (camX: number, camZ: number): Array<[number, number]> => {
const calculateReps = (
camX: number,
camZ: number,
): Array<[number, number]> => {
// Convert camera to terrain space
const terrainCamX = camX + TERRAIN_OFFSET;
const terrainCamZ = camZ + TERRAIN_OFFSET;

View file

@ -2,10 +2,8 @@ import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import { FloatingLabel } from "./FloatingLabel";
import { useSimGroup } from "./SimGroup";
export function WayPoint({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
const position = useMemo(() => getPosition(object), [object]);
const label = getProperty(object, "name");

View file

@ -46,7 +46,11 @@ function createAtlas(textures: Texture[]): IflAtlas {
textures.forEach((tex, i) => {
const col = i % columns;
const row = Math.floor(i / columns);
ctx.drawImage(tex.image as CanvasImageSource, col * frameWidth, row * frameHeight);
ctx.drawImage(
tex.image as CanvasImageSource,
col * frameWidth,
row * frameHeight,
);
});
const texture = new CanvasTexture(canvas);