t2-mapper/src/components/InspectorControls.tsx

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}&deg;</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>
);
}