mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-16 02:40:59 +00:00
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { useEffect, useState, useRef, RefObject } from "react";
|
|
import { RiLandscapeFill } from "react-icons/ri";
|
|
import { FaRotateRight } from "react-icons/fa6";
|
|
import { LuClipboardList } from "react-icons/lu";
|
|
import { Camera } from "three";
|
|
import {
|
|
useControls,
|
|
useDebug,
|
|
useSettings,
|
|
type TouchMode,
|
|
} from "./SettingsProvider";
|
|
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
|
import { LoadDemoButton } from "./LoadDemoButton";
|
|
import { JoinServerButton } from "./JoinServerButton";
|
|
import { Accordion, AccordionGroup } from "./Accordion";
|
|
import styles from "./InspectorControls.module.css";
|
|
import { useTouchDevice } from "./useTouchDevice";
|
|
import { useRecording } from "./RecordingProvider";
|
|
import { useDataSource, useMissionName } from "../state/gameEntityStore";
|
|
import { useLiveSelector } from "../state/liveConnectionStore";
|
|
import { hasMission } from "../manifest";
|
|
|
|
const DEFAULT_PANELS = ["controls", "preferences", "audio"];
|
|
|
|
export function InspectorControls({
|
|
missionName,
|
|
missionType,
|
|
onOpenMapInfo,
|
|
onOpenServerBrowser,
|
|
onChooseMap,
|
|
onCancelChoosingMap,
|
|
choosingMap,
|
|
cameraRef,
|
|
invalidateRef,
|
|
}: {
|
|
missionName: string;
|
|
missionType: string;
|
|
onOpenMapInfo: () => void;
|
|
onOpenServerBrowser?: () => void;
|
|
onChooseMap?: () => void;
|
|
onCancelChoosingMap?: () => void;
|
|
choosingMap?: boolean;
|
|
cameraRef: RefObject<Camera>;
|
|
invalidateRef: RefObject<() => void>;
|
|
}) {
|
|
const isTouch = useTouchDevice();
|
|
const dataSource = useDataSource();
|
|
const recording = useRecording();
|
|
const storeMissionName = useMissionName();
|
|
const hasStreamData = dataSource === "demo" || dataSource === "live";
|
|
// When streaming, the URL query param may not reflect the actual map.
|
|
// Use the store's mission name (from the server) for the manifest check.
|
|
const effectiveMissionName = hasStreamData ? storeMissionName : missionName;
|
|
const missionInManifest = effectiveMissionName
|
|
? hasMission(effectiveMissionName)
|
|
: false;
|
|
const isLiveConnected = useLiveSelector(
|
|
(s) => s.gameStatus === "connected" || s.gameStatus === "authenticating",
|
|
);
|
|
const {
|
|
fogEnabled,
|
|
setFogEnabled,
|
|
fov,
|
|
setFov,
|
|
audioEnabled,
|
|
setAudioEnabled,
|
|
audioVolume,
|
|
setAudioVolume,
|
|
animationEnabled,
|
|
setAnimationEnabled,
|
|
} = useSettings();
|
|
const {
|
|
speedMultiplier,
|
|
setSpeedMultiplier,
|
|
touchMode,
|
|
setTouchMode,
|
|
invertScroll,
|
|
setInvertScroll,
|
|
invertDrag,
|
|
setInvertDrag,
|
|
invertJoystick,
|
|
setInvertJoystick,
|
|
} = useControls();
|
|
const { debugMode, setDebugMode, renderOnDemand, setRenderOnDemand } =
|
|
useDebug();
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const focusAreaRef = useRef<HTMLDivElement>(null);
|
|
// Focus the panel when it opens.
|
|
useEffect(() => {
|
|
if (settingsOpen) {
|
|
dropdownRef.current?.focus();
|
|
}
|
|
}, [settingsOpen]);
|
|
const handleDropdownBlur = (e: React.FocusEvent) => {
|
|
const relatedTarget = e.relatedTarget as Node | null;
|
|
if (relatedTarget && focusAreaRef.current?.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
setSettingsOpen(false);
|
|
};
|
|
// Close on Escape and return focus to the gear button.
|
|
const handlePanelKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setSettingsOpen(false);
|
|
buttonRef.current?.focus();
|
|
}
|
|
};
|
|
return (
|
|
<div className={styles.InspectorControls}>
|
|
<div ref={focusAreaRef}>
|
|
<div
|
|
className={styles.Dropdown}
|
|
ref={dropdownRef}
|
|
id="settingsPanel"
|
|
tabIndex={-1}
|
|
onKeyDown={handlePanelKeyDown}
|
|
onBlur={handleDropdownBlur}
|
|
data-open={settingsOpen}
|
|
>
|
|
<div className={styles.Tools}>
|
|
<div className={styles.ButtonGroup}>
|
|
<button
|
|
type="button"
|
|
className={styles.IconButton}
|
|
data-active={
|
|
(dataSource === "map" && !recording) || choosingMap
|
|
}
|
|
onClick={onChooseMap}
|
|
>
|
|
<RiLandscapeFill />
|
|
<span className={styles.ButtonLabel}>Explore</span>
|
|
<span className={styles.ButtonHint}>Browse maps</span>
|
|
</button>
|
|
<LoadDemoButton
|
|
isActive={!choosingMap && recording?.source === "demo"}
|
|
choosingMap={choosingMap}
|
|
onCancelChoosingMap={onCancelChoosingMap}
|
|
/>
|
|
{onOpenServerBrowser && (
|
|
<JoinServerButton
|
|
isActive={!choosingMap && isLiveConnected}
|
|
onOpenServerBrowser={onOpenServerBrowser}
|
|
/>
|
|
)}
|
|
</div>
|
|
<CopyCoordinatesButton
|
|
missionName={missionName}
|
|
missionType={missionType}
|
|
cameraRef={cameraRef}
|
|
disabled={!missionInManifest}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={styles.MapInfoButton}
|
|
aria-label="Show map info"
|
|
onClick={onOpenMapInfo}
|
|
disabled={!missionInManifest}
|
|
>
|
|
<LuClipboardList />
|
|
<span className={styles.ButtonLabel}>Show map info</span>
|
|
</button>
|
|
</div>
|
|
<div className={styles.Accordions}>
|
|
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
|
|
<Accordion value="controls" label="Controls">
|
|
<div className={styles.Field}>
|
|
<label htmlFor="speedInput">Fly speed</label>
|
|
<input
|
|
id="speedInput"
|
|
type="range"
|
|
min={1}
|
|
max={100}
|
|
step={1}
|
|
value={Math.round(speedMultiplier * 100)}
|
|
onChange={(event) =>
|
|
setSpeedMultiplier(parseFloat(event.target.value) / 100)
|
|
}
|
|
/>
|
|
<p className={styles.Description}>
|
|
How fast you move in free-flying mode.
|
|
{isTouch === false
|
|
? " Use your scroll wheel or trackpad to adjust while flying."
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
{isTouch ? (
|
|
<div className={styles.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>
|
|
<p className={styles.Description}>
|
|
Single stick has a unified move + look control. Dual stick
|
|
has independent move + look.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{isTouch === false ? (
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="invertScroll"
|
|
type="checkbox"
|
|
checked={invertScroll}
|
|
onChange={(event) => {
|
|
setInvertScroll(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="invertScroll">
|
|
Invert scroll direction
|
|
</label>
|
|
<p className={styles.Description}>
|
|
Reverse which scroll direction increases and decreases fly
|
|
speed.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{isTouch ? (
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="invertJoystick"
|
|
type="checkbox"
|
|
checked={invertJoystick}
|
|
onChange={(event) => {
|
|
setInvertJoystick(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="invertJoystick">
|
|
Invert joystick direction
|
|
</label>
|
|
<p className={styles.Description}>
|
|
Reverse joystick look direction.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="invertDrag"
|
|
type="checkbox"
|
|
checked={invertDrag}
|
|
onChange={(event) => {
|
|
setInvertDrag(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="invertDrag">
|
|
Invert drag direction
|
|
</label>
|
|
<p className={styles.Description}>
|
|
Reverse how dragging the viewport aims the camera.
|
|
</p>
|
|
</div>
|
|
</Accordion>
|
|
<Accordion value="preferences" label="Preferences">
|
|
<div className={styles.Field}>
|
|
<label htmlFor="fovInput">FOV</label>
|
|
<div className={styles.Control}>
|
|
<output htmlFor="fovInput">{fov}°</output>
|
|
<input
|
|
id="fovInput"
|
|
type="range"
|
|
min={75}
|
|
max={120}
|
|
step={5}
|
|
value={fov}
|
|
onChange={(event) => setFov(parseInt(event.target.value))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Accordion>
|
|
<Accordion value="audio" label="Audio">
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="audioInput"
|
|
type="checkbox"
|
|
checked={audioEnabled}
|
|
onChange={(event) => {
|
|
setAudioEnabled(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="audioInput">
|
|
Enable audio
|
|
</label>
|
|
</div>
|
|
<div className={styles.Field}>
|
|
<label htmlFor="volumeInput">Master volume</label>
|
|
<div className={styles.Control}>
|
|
<output htmlFor="volumeInput">
|
|
{Math.round(audioVolume * 100)}%
|
|
</output>
|
|
<input
|
|
id="volumeInput"
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
value={audioVolume}
|
|
onChange={(event) =>
|
|
setAudioVolume(parseFloat(event.target.value))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Accordion>
|
|
<Accordion value="graphics" label="Graphics">
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="fogInput"
|
|
type="checkbox"
|
|
checked={fogEnabled}
|
|
onChange={(event) => {
|
|
setFogEnabled(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="fogInput">
|
|
Enable fog
|
|
</label>
|
|
</div>
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="animationInput"
|
|
type="checkbox"
|
|
checked={animationEnabled}
|
|
onChange={(event) => {
|
|
setAnimationEnabled(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="animationInput">
|
|
Enable animations
|
|
</label>
|
|
</div>
|
|
</Accordion>
|
|
<Accordion value="debug" label="Debug">
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="debugInput"
|
|
type="checkbox"
|
|
checked={debugMode}
|
|
onChange={(event) => {
|
|
setDebugMode(event.target.checked);
|
|
}}
|
|
/>
|
|
<label className={styles.Label} htmlFor="debugInput">
|
|
Render debug visuals
|
|
</label>
|
|
</div>
|
|
<div className={styles.CheckboxField}>
|
|
<input
|
|
id="onDemandInput"
|
|
type="checkbox"
|
|
checked={renderOnDemand}
|
|
onChange={(event) => {
|
|
setRenderOnDemand(event.target.checked);
|
|
}}
|
|
/>
|
|
<div className={styles.Label}>
|
|
<label htmlFor="onDemandInput">Render on demand </label>
|
|
<button
|
|
type="button"
|
|
className={styles.ForceRenderButton}
|
|
title="Force render"
|
|
aria-label="Force render"
|
|
onClick={() => invalidateRef.current?.()}
|
|
>
|
|
<FaRotateRight />
|
|
</button>
|
|
</div>
|
|
<p className={styles.Description}>
|
|
Significantly decreases CPU and GPU usage by only rendering
|
|
frames when requested. Helpful when developing parts of the
|
|
app unrelated to rendering.
|
|
</p>
|
|
</div>
|
|
</Accordion>
|
|
</AccordionGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|