mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-16 08:55:52 +00:00
new UI, unify map/demo/live architecture more, cleanup
This commit is contained in:
parent
d9b5e30831
commit
4741f59582
146 changed files with 5477 additions and 3005 deletions
|
|
@ -24,3 +24,9 @@ T2_ACCOUNT_PASSWORD=
|
||||||
|
|
||||||
# Relay URL for the browser client (default: ws://localhost:8765)
|
# Relay URL for the browser client (default: ws://localhost:8765)
|
||||||
# NEXT_PUBLIC_RELAY_URL=ws://localhost:8765
|
# NEXT_PUBLIC_RELAY_URL=ws://localhost:8765
|
||||||
|
|
||||||
|
# Logging (comma-separated). Bare level sets global default; module:level
|
||||||
|
# pairs override specific modules. If only modules given, rest are silent.
|
||||||
|
# NEXT_PUBLIC_LOG=debug
|
||||||
|
# NEXT_PUBLIC_LOG=liveStreaming:debug,DebugSuspense:trace
|
||||||
|
# NEXT_PUBLIC_LOG=warn,liveStreaming:debug
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ This only needs to be done once — the volume persists across deploys.
|
||||||
**Environment variables** (all optional, with defaults):
|
**Environment variables** (all optional, with defaults):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
||||||
| `RELAY_PORT` | `8765` | WebSocket listen port |
|
| `RELAY_PORT` | `8765` | WebSocket listen port |
|
||||||
| `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets |
|
| `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets |
|
||||||
| `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest |
|
| `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest |
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<NuqsAdapter
|
<NuqsAdapter defaultOptions={{ clearOnDefault: false }}>
|
||||||
defaultOptions={{
|
|
||||||
clearOnDefault: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
512
app/page.tsx
512
app/page.tsx
|
|
@ -1,521 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
import {
|
import { Suspense } from "react";
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
Suspense,
|
|
||||||
useRef,
|
|
||||||
lazy,
|
|
||||||
} from "react";
|
|
||||||
import { Canvas, GLProps } from "@react-three/fiber";
|
|
||||||
import { NoToneMapping, SRGBColorSpace, PCFShadowMap, Camera } from "three";
|
|
||||||
import { Mission } from "@/src/components/Mission";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
// import { LiveConnectionProvider } from "@/src/components/LiveConnection";
|
||||||
ObserverControls,
|
import { FeaturesProvider } from "@/src/components/FeaturesProvider";
|
||||||
KEYBOARD_CONTROLS,
|
import { MapInspector } from "@/src/components/MapInspector";
|
||||||
} from "@/src/components/ObserverControls";
|
|
||||||
import { KeyboardOverlay } from "@/src/components/KeyboardOverlay";
|
|
||||||
import {
|
|
||||||
TouchJoystick,
|
|
||||||
TouchCameraMovement,
|
|
||||||
type JoystickState,
|
|
||||||
} from "@/src/components/TouchControls";
|
|
||||||
import { KeyboardControls } from "@react-three/drei";
|
|
||||||
import { InspectorControls } from "@/src/components/InspectorControls";
|
|
||||||
import { useTouchDevice } from "@/src/components/useTouchDevice";
|
|
||||||
import {
|
|
||||||
SettingsProvider,
|
|
||||||
useSettings,
|
|
||||||
} from "@/src/components/SettingsProvider";
|
|
||||||
import { ObserverCamera } from "@/src/components/ObserverCamera";
|
|
||||||
import { AudioProvider } from "@/src/components/AudioContext";
|
|
||||||
import { DebugElements } from "@/src/components/DebugElements";
|
|
||||||
import { CamerasProvider } from "@/src/components/CamerasProvider";
|
|
||||||
import {
|
|
||||||
RecordingProvider,
|
|
||||||
usePlaybackActions,
|
|
||||||
useRecording,
|
|
||||||
} from "@/src/components/RecordingProvider";
|
|
||||||
import { EntityScene } from "@/src/components/EntityScene";
|
|
||||||
import { TickProvider } from "@/src/components/TickProvider";
|
|
||||||
import { SceneLighting } from "@/src/components/SceneLighting";
|
|
||||||
import { PlayerHUD } from "@/src/components/PlayerHUD";
|
|
||||||
import { LiveConnectionProvider } from "@/src/components/LiveConnection";
|
|
||||||
import { useLiveSelector } from "@/src/state/liveConnectionStore";
|
|
||||||
import { ServerBrowser } from "@/src/components/ServerBrowser";
|
|
||||||
import {
|
|
||||||
FeaturesProvider,
|
|
||||||
useFeatures,
|
|
||||||
} from "@/src/components/FeaturesProvider";
|
|
||||||
|
|
||||||
// Lazy-load demo and live streaming modules — they pull in heavy dependencies
|
|
||||||
// (demo parser, streaming engine, particles) that aren't needed for mission-only mode.
|
|
||||||
const StreamPlayback = lazy(() =>
|
|
||||||
import("@/src/components/StreamPlayback").then((mod) => ({
|
|
||||||
default: mod.StreamPlayback,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const DemoPlaybackControls = lazy(() =>
|
|
||||||
import("@/src/components/DemoPlaybackControls").then((mod) => ({
|
|
||||||
default: mod.DemoPlaybackControls,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const LiveObserver = lazy(() =>
|
|
||||||
import("@/src/components/LiveObserver").then((mod) => ({
|
|
||||||
default: mod.LiveObserver,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const ChatSoundPlayer = lazy(() =>
|
|
||||||
import("@/src/components/ChatSoundPlayer").then((mod) => ({
|
|
||||||
default: mod.ChatSoundPlayer,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
import {
|
|
||||||
getMissionList,
|
|
||||||
getMissionInfo,
|
|
||||||
} from "@/src/manifest";
|
|
||||||
import { createParser, parseAsBoolean, useQueryState } from "nuqs";
|
|
||||||
import styles from "./page.module.css";
|
|
||||||
|
|
||||||
const MapInfoDialog = lazy(() =>
|
|
||||||
import("@/src/components/MapInfoDialog").then((mod) => ({
|
|
||||||
default: mod.MapInfoDialog,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Three.js has its own loaders for textures and models, but we need to load other
|
// Three.js has its own loaders for textures and models, but we need to load other
|
||||||
// stuff too, e.g. missions, terrains, and more. This client is used for those.
|
// stuff too, e.g. missions, terrains, and more. This client is used for those.
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
// Renderer settings to match Tribes 2's simple rendering pipeline.
|
|
||||||
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
|
|
||||||
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
|
|
||||||
const glSettings: GLProps = {
|
|
||||||
toneMapping: NoToneMapping,
|
|
||||||
outputColorSpace: SRGBColorSpace,
|
|
||||||
};
|
|
||||||
|
|
||||||
type CurrentMission = {
|
|
||||||
missionName: string;
|
|
||||||
missionType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultMission: CurrentMission = {
|
|
||||||
missionName: "RiverDance",
|
|
||||||
missionType: "CTF",
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseAsMissionWithType = createParser<CurrentMission>({
|
|
||||||
parse(query: string) {
|
|
||||||
const [missionName, missionType] = query.split("~");
|
|
||||||
let selectedMissionType = missionType;
|
|
||||||
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
|
|
||||||
if (!missionType || !availableMissionTypes.includes(missionType)) {
|
|
||||||
selectedMissionType = availableMissionTypes[0];
|
|
||||||
}
|
|
||||||
return { missionName, missionType: selectedMissionType };
|
|
||||||
},
|
|
||||||
serialize({ missionName, missionType }): string {
|
|
||||||
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
|
|
||||||
if (availableMissionTypes.length === 1) {
|
|
||||||
return missionName;
|
|
||||||
}
|
|
||||||
return `${missionName}~${missionType}`;
|
|
||||||
},
|
|
||||||
eq(a, b) {
|
|
||||||
return a.missionName === b.missionName && a.missionType === b.missionType;
|
|
||||||
},
|
|
||||||
}).withDefault(defaultMission);
|
|
||||||
|
|
||||||
function MapInspector() {
|
|
||||||
const [currentMission, setCurrentMission] = useQueryState(
|
|
||||||
"mission",
|
|
||||||
parseAsMissionWithType,
|
|
||||||
);
|
|
||||||
const [fogEnabledOverride, setFogEnabledOverride] = useQueryState(
|
|
||||||
"fog",
|
|
||||||
parseAsBoolean,
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearFogEnabledOverride = useCallback(() => {
|
|
||||||
setFogEnabledOverride(null);
|
|
||||||
}, [setFogEnabledOverride]);
|
|
||||||
|
|
||||||
const changeMission = useCallback(
|
|
||||||
(mission: CurrentMission) => {
|
|
||||||
window.location.hash = "";
|
|
||||||
clearFogEnabledOverride();
|
|
||||||
setCurrentMission(mission);
|
|
||||||
},
|
|
||||||
[setCurrentMission, clearFogEnabledOverride],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isTouch = useTouchDevice();
|
|
||||||
const features = useFeatures();
|
|
||||||
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
|
|
||||||
const liveReady = useLiveSelector((s) => s.liveReady);
|
|
||||||
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
|
||||||
const { missionName, missionType } = currentMission;
|
|
||||||
const [mapInfoOpen, setMapInfoOpen] = useState(false);
|
|
||||||
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
|
|
||||||
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
|
|
||||||
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
|
|
||||||
|
|
||||||
// During live join, show progress based on connection status.
|
|
||||||
// Relay status order: connecting → challenging → authenticating → connected.
|
|
||||||
// Once liveReady (first ghost arrives), loading is complete.
|
|
||||||
const liveLoadingProgress = hasLiveAdapter
|
|
||||||
? liveReady
|
|
||||||
? 1
|
|
||||||
: gameStatus === "connected" ? 0.8
|
|
||||||
: gameStatus === "authenticating" ? 0.6
|
|
||||||
: gameStatus === "challenging" ? 0.3
|
|
||||||
: gameStatus === "connecting" ? 0.2
|
|
||||||
: 0.1
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Reset stale mission progress when live mode takes over, so it can't
|
|
||||||
// flash through if liveLoadingProgress briefly becomes null.
|
|
||||||
useEffect(() => {
|
|
||||||
if (liveLoadingProgress != null) {
|
|
||||||
setMissionLoadingProgress(0);
|
|
||||||
}
|
|
||||||
}, [liveLoadingProgress != null]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const loadingProgress = liveLoadingProgress ?? missionLoadingProgress;
|
|
||||||
const isLoading = loadingProgress < 1;
|
|
||||||
|
|
||||||
// Keep the loading indicator visible briefly after reaching 100%
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading) {
|
|
||||||
setShowLoadingIndicator(true);
|
|
||||||
} else {
|
|
||||||
const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// For automation, like the t2-maps app!
|
|
||||||
window.setMissionName = (missionName: string) => {
|
|
||||||
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
|
|
||||||
changeMission({
|
|
||||||
missionName,
|
|
||||||
missionType: availableMissionTypes[0],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.getMissionList = getMissionList;
|
|
||||||
window.getMissionInfo = getMissionInfo;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
delete window.setMissionName;
|
|
||||||
delete window.getMissionList;
|
|
||||||
delete window.getMissionInfo;
|
|
||||||
};
|
|
||||||
}, [changeMission]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.code !== "KeyI" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.isContentEditable
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMapInfoOpen(true);
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKey);
|
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoadingChange = useCallback(
|
|
||||||
(_loading: boolean, progress: number = 0) => {
|
|
||||||
setMissionLoadingProgress(progress);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cameraRef = useRef<Camera | null>(null);
|
|
||||||
const joystickStateRef = useRef<JoystickState>({ angle: 0, force: 0 });
|
|
||||||
const joystickZoneRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const lookJoystickStateRef = useRef<JoystickState>({ angle: 0, force: 0 });
|
|
||||||
const lookJoystickZoneRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<main>
|
|
||||||
<RecordingProvider>
|
|
||||||
<SettingsProvider
|
|
||||||
fogEnabledOverride={fogEnabledOverride}
|
|
||||||
onClearFogEnabledOverride={clearFogEnabledOverride}
|
|
||||||
>
|
|
||||||
<KeyboardControls map={KEYBOARD_CONTROLS}>
|
|
||||||
<div id="canvasContainer" className={styles.CanvasContainer}>
|
|
||||||
{showLoadingIndicator && (
|
|
||||||
<div
|
|
||||||
id="loadingIndicator"
|
|
||||||
className={styles.LoadingIndicator}
|
|
||||||
data-complete={!isLoading}
|
|
||||||
>
|
|
||||||
<div className={styles.Spinner} />
|
|
||||||
<div className={styles.Progress}>
|
|
||||||
<div
|
|
||||||
className={styles.ProgressBar}
|
|
||||||
style={{ width: `${loadingProgress * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.ProgressText}>
|
|
||||||
{Math.round(loadingProgress * 100)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Canvas
|
|
||||||
frameloop="always"
|
|
||||||
gl={glSettings}
|
|
||||||
shadows={{ type: PCFShadowMap }}
|
|
||||||
onCreated={(state) => {
|
|
||||||
cameraRef.current = state.camera;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TickProvider>
|
|
||||||
<CamerasProvider>
|
|
||||||
<AudioProvider>
|
|
||||||
<MissionWhenIdle
|
|
||||||
missionName={missionName}
|
|
||||||
missionType={missionType}
|
|
||||||
onLoadingChange={handleLoadingChange}
|
|
||||||
/>
|
|
||||||
<SceneLighting />
|
|
||||||
<EntityScene missionType={missionType} />
|
|
||||||
<ObserverCamera />
|
|
||||||
<DebugElements />
|
|
||||||
<StreamingComponents
|
|
||||||
isTouch={isTouch}
|
|
||||||
joystickStateRef={joystickStateRef}
|
|
||||||
joystickZoneRef={joystickZoneRef}
|
|
||||||
lookJoystickStateRef={lookJoystickStateRef}
|
|
||||||
lookJoystickZoneRef={lookJoystickZoneRef}
|
|
||||||
/>
|
|
||||||
</AudioProvider>
|
|
||||||
</CamerasProvider>
|
|
||||||
</TickProvider>
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
|
||||||
<StreamingHUD />
|
|
||||||
{isTouch && (
|
|
||||||
<TouchJoystick
|
|
||||||
joystickState={joystickStateRef}
|
|
||||||
joystickZone={joystickZoneRef}
|
|
||||||
lookJoystickState={lookJoystickStateRef}
|
|
||||||
lookJoystickZone={lookJoystickZoneRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isTouch === false && <KeyboardOverlay />}
|
|
||||||
<InspectorControls
|
|
||||||
missionName={missionName}
|
|
||||||
missionType={missionType}
|
|
||||||
onChangeMission={changeMission}
|
|
||||||
onOpenMapInfo={() => setMapInfoOpen(true)}
|
|
||||||
onOpenServerBrowser={features.live ? () => setServerBrowserOpen(true) : undefined}
|
|
||||||
cameraRef={cameraRef}
|
|
||||||
isTouch={isTouch}
|
|
||||||
/>
|
|
||||||
{mapInfoOpen && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<MapInfoDialog
|
|
||||||
open={mapInfoOpen}
|
|
||||||
onClose={() => setMapInfoOpen(false)}
|
|
||||||
missionName={missionName}
|
|
||||||
missionType={missionType ?? ""}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
<ServerBrowserDialog
|
|
||||||
open={serverBrowserOpen}
|
|
||||||
onClose={() => setServerBrowserOpen(false)}
|
|
||||||
/>
|
|
||||||
<StreamingOverlay />
|
|
||||||
<DemoWindowAPI />
|
|
||||||
</KeyboardControls>
|
|
||||||
</SettingsProvider>
|
|
||||||
</RecordingProvider>
|
|
||||||
</main>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only mount Mission (TorqueScript runtime, .mis loading) when NOT streaming.
|
|
||||||
* During demo/live playback, all scene data comes from ghosts — no need for
|
|
||||||
* the heavy TorqueScript execution pipeline.
|
|
||||||
*/
|
|
||||||
function MissionWhenIdle({
|
|
||||||
missionName,
|
|
||||||
missionType,
|
|
||||||
onLoadingChange,
|
|
||||||
}: {
|
|
||||||
missionName: string;
|
|
||||||
missionType: string;
|
|
||||||
onLoadingChange: (isLoading: boolean, progress?: number) => void;
|
|
||||||
}) {
|
|
||||||
const recording = useRecording();
|
|
||||||
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
|
|
||||||
const isStreaming = recording != null || hasLiveAdapter;
|
|
||||||
|
|
||||||
if (isStreaming) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Mission
|
|
||||||
key={`${missionName}~${missionType}`}
|
|
||||||
name={missionName}
|
|
||||||
missionType={missionType}
|
|
||||||
onLoadingChange={onLoadingChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-Canvas components that depend on streaming mode. Mounts the appropriate
|
|
||||||
* controller (StreamPlayback or LiveObserver) and disables observer controls
|
|
||||||
* during streaming.
|
|
||||||
*/
|
|
||||||
function StreamingComponents({
|
|
||||||
isTouch,
|
|
||||||
joystickStateRef,
|
|
||||||
joystickZoneRef,
|
|
||||||
lookJoystickStateRef,
|
|
||||||
lookJoystickZoneRef,
|
|
||||||
}: {
|
|
||||||
isTouch: boolean | null;
|
|
||||||
joystickStateRef: React.RefObject<JoystickState>;
|
|
||||||
joystickZoneRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
lookJoystickStateRef: React.RefObject<JoystickState>;
|
|
||||||
lookJoystickZoneRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
}) {
|
|
||||||
const recording = useRecording();
|
|
||||||
const isLive = useLiveSelector((s) => s.adapter != null);
|
|
||||||
const isStreaming = recording != null || isLive;
|
|
||||||
|
|
||||||
// Show ObserverControls for: non-streaming mode, OR live mode.
|
|
||||||
// During live, ObserverControls provides the same camera controls
|
|
||||||
// (pointer lock, drag-to-rotate, WASD fly) and LiveObserver intercepts
|
|
||||||
// click-while-locked to cycle observed players instead of nextCamera.
|
|
||||||
// During demo playback, the demo drives the camera so no controls needed.
|
|
||||||
const showObserverControls = !isStreaming || isLive;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{recording && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<StreamPlayback />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{isLive && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<LiveObserver />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{isStreaming && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ChatSoundPlayer />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{showObserverControls && isTouch !== null && (
|
|
||||||
isTouch ? (
|
|
||||||
<TouchCameraMovement
|
|
||||||
joystickState={joystickStateRef}
|
|
||||||
joystickZone={joystickZoneRef}
|
|
||||||
lookJoystickState={lookJoystickStateRef}
|
|
||||||
lookJoystickZone={lookJoystickZoneRef}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ObserverControls />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HUD overlay — shown during streaming (demo or live). */
|
|
||||||
function StreamingHUD() {
|
|
||||||
const recording = useRecording();
|
|
||||||
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
|
|
||||||
if (!recording && !hasLiveAdapter) return null;
|
|
||||||
return <PlayerHUD isLive={hasLiveAdapter} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Playback controls overlay — only shown during demo playback. */
|
|
||||||
function StreamingOverlay() {
|
|
||||||
const recording = useRecording();
|
|
||||||
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
|
|
||||||
if (!recording || hasLiveAdapter) return null;
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DemoPlaybackControls />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server browser dialog connected to live state. */
|
|
||||||
function ServerBrowserDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const servers = useLiveSelector((s) => s.servers);
|
|
||||||
const serversLoading = useLiveSelector((s) => s.serversLoading);
|
|
||||||
const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
|
|
||||||
const listServers = useLiveSelector((s) => s.listServers);
|
|
||||||
const joinServer = useLiveSelector((s) => s.joinServer);
|
|
||||||
const settings = useSettings();
|
|
||||||
const handleJoin = useCallback(
|
|
||||||
(address: string) => {
|
|
||||||
joinServer(address, settings?.warriorName);
|
|
||||||
},
|
|
||||||
[joinServer, settings?.warriorName],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<ServerBrowser
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
servers={servers}
|
|
||||||
loading={serversLoading}
|
|
||||||
onRefresh={listServers}
|
|
||||||
onJoin={handleJoin}
|
|
||||||
wsPing={browserToRelayPing}
|
|
||||||
warriorName={settings?.warriorName ?? ""}
|
|
||||||
onWarriorNameChange={(name) => settings?.setWarriorName(name)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exposes `window.loadDemoRecording` for automation/testing. */
|
|
||||||
function DemoWindowAPI() {
|
|
||||||
const { setRecording } = usePlaybackActions();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.loadDemoRecording = setRecording;
|
|
||||||
return () => {
|
|
||||||
delete window.loadDemoRecording;
|
|
||||||
};
|
|
||||||
}, [setRecording]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<FeaturesProvider>
|
<FeaturesProvider>
|
||||||
<LiveConnectionProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MapInspector />
|
<MapInspector />
|
||||||
</LiveConnectionProvider>
|
</QueryClientProvider>
|
||||||
</FeaturesProvider>
|
</FeaturesProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,13 @@ import * as THREE from "three";
|
||||||
import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three";
|
import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei";
|
import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei";
|
||||||
import {
|
import { SettingsProvider, useDebug } from "@/src/components/SettingsProvider";
|
||||||
SettingsProvider,
|
|
||||||
useDebug,
|
|
||||||
} from "@/src/components/SettingsProvider";
|
|
||||||
import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape";
|
import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape";
|
||||||
import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider";
|
import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider";
|
||||||
import { DebugElements } from "@/src/components/DebugElements";
|
import { DebugElements } from "@/src/components/DebugElements";
|
||||||
import { TickProvider } from "@/src/components/TickProvider";
|
import { TickProvider } from "@/src/components/TickProvider";
|
||||||
import { ShapeSelect } from "@/src/components/ShapeSelect";
|
import { ShapeSelect } from "@/src/components/ShapeSelect";
|
||||||
import { engineStore, useEngineSelector } from "@/src/state";
|
import { engineStore, useEngineSelector } from "@/src/state/engineStore";
|
||||||
import {
|
import {
|
||||||
getResourceList,
|
getResourceList,
|
||||||
getResourceMap,
|
getResourceMap,
|
||||||
|
|
@ -328,7 +325,7 @@ function ShapeInspector() {
|
||||||
<TickProvider>
|
<TickProvider>
|
||||||
<SceneLighting />
|
<SceneLighting />
|
||||||
<Bounds fit clip observe margin={1.5}>
|
<Bounds fit clip observe margin={1.5}>
|
||||||
<Suspense fallback={null}>
|
<Suspense>
|
||||||
<ShapeViewer
|
<ShapeViewer
|
||||||
key={currentShape}
|
key={currentShape}
|
||||||
shapeName={currentShape}
|
shapeName={currentShape}
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,6 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100dvw;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
max-width: 80px;
|
max-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export default defineConfig(
|
||||||
...nextConfig,
|
...nextConfig,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
|
|
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./docs/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const nextConfig = (phase, { defaultConfig }): NextConfig => {
|
||||||
assetPrefix: "/t2-mapper/",
|
assetPrefix: "/t2-mapper/",
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
experimental: { viewTransition: true },
|
||||||
headers:
|
headers:
|
||||||
// TorqueScript files should be served as text. This won't affect what
|
// TorqueScript files should be served as text. This won't affect what
|
||||||
// GitHub Pages does with the static export, but it'll at least improve
|
// GitHub Pages does with the static export, but it'll at least improve
|
||||||
|
|
|
||||||
274
package-lock.json
generated
274
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.21",
|
"@ariakit/react": "^0.4.21",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|
@ -1964,6 +1965,279 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-accordion": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collapsible": "1.1.12",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collapsible": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-three/drei": {
|
"node_modules/@react-three/drei": {
|
||||||
"version": "10.7.7",
|
"version": "10.7.7",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.21",
|
"@ariakit/react": "^0.4.21",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,24 @@ import { BitStreamWriter } from "./BitStreamWriter.js";
|
||||||
|
|
||||||
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
|
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
|
||||||
const CSM_CHAR_FREQS: number[] = [
|
const CSM_CHAR_FREQS: number[] = [
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554,
|
||||||
2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3,
|
3, 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 0, 144,
|
||||||
394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39,
|
125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 185, 1, 149,
|
||||||
0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36,
|
309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 0, 2090, 344, 736, 993,
|
||||||
185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211,
|
2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 562, 3, 1775,
|
||||||
0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713,
|
1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
562, 3, 1775, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROB_BOOST = 1;
|
const PROB_BOOST = 1;
|
||||||
|
|
||||||
function isAlphaNumeric(c: number): boolean {
|
function isAlphaNumeric(c: number): boolean {
|
||||||
return (
|
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
||||||
(c >= 48 && c <= 57) ||
|
|
||||||
(c >= 65 && c <= 90) ||
|
|
||||||
(c >= 97 && c <= 122)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HuffLeaf {
|
interface HuffLeaf {
|
||||||
|
|
@ -62,7 +54,8 @@ function buildTables(): void {
|
||||||
leaves = [];
|
leaves = [];
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
leaves.push({
|
leaves.push({
|
||||||
pop: CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
|
pop:
|
||||||
|
CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
|
||||||
symbol: i,
|
symbol: i,
|
||||||
numBits: 0,
|
numBits: 0,
|
||||||
code: 0,
|
code: 0,
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,10 @@ export class T2csriAuth {
|
||||||
// Sanitize: must be hex only
|
// Sanitize: must be hex only
|
||||||
const challenge = this.encryptedChallenge.toLowerCase();
|
const challenge = this.encryptedChallenge.toLowerCase();
|
||||||
authLog.info(
|
authLog.info(
|
||||||
{ challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length },
|
{
|
||||||
|
challengeLen: challenge.length,
|
||||||
|
clientChallengeLen: this.clientChallenge.length,
|
||||||
|
},
|
||||||
"Auth: starting challenge decryption",
|
"Auth: starting challenge decryption",
|
||||||
);
|
);
|
||||||
for (let i = 0; i < challenge.length; i++) {
|
for (let i = 0; i < challenge.length; i++) {
|
||||||
|
|
@ -339,7 +342,11 @@ export class T2csriAuth {
|
||||||
const modulusHex = fields[3];
|
const modulusHex = fields[3];
|
||||||
|
|
||||||
authLog.debug(
|
authLog.debug(
|
||||||
{ encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length },
|
{
|
||||||
|
encryptedLen: challenge.length,
|
||||||
|
modulusLen: modulusHex?.length,
|
||||||
|
privateKeyLen: this.credentials.privateKey.length,
|
||||||
|
},
|
||||||
"Auth: RSA parameters",
|
"Auth: RSA parameters",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@ export interface CRCDataBlock {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest types (mirrored from src/manifest.ts)
|
// Manifest types (mirrored from src/manifest.ts)
|
||||||
type SourceTuple = [sourcePath: string] | [sourcePath: string, actualPath: string];
|
type SourceTuple =
|
||||||
|
| [sourcePath: string]
|
||||||
|
| [sourcePath: string, actualPath: string];
|
||||||
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
|
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
|
||||||
interface Manifest {
|
interface Manifest {
|
||||||
resources: Record<string, ResourceEntry>;
|
resources: Record<string, ResourceEntry>;
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
private nextSendEventSeq = 0;
|
private nextSendEventSeq = 0;
|
||||||
private pendingEvents: ClientEvent[] = [];
|
private pendingEvents: ClientEvent[] = [];
|
||||||
/** Events sent but not yet acked, keyed by packet sequence number. */
|
/** Events sent but not yet acked, keyed by packet sequence number. */
|
||||||
private sentEventsByPacket = new Map<number, { seq: number; event: ClientEvent }[]>();
|
private sentEventsByPacket = new Map<
|
||||||
|
number,
|
||||||
|
{ seq: number; event: ClientEvent }[]
|
||||||
|
>();
|
||||||
/** Events waiting to be sent (new or retransmitted from lost packets). */
|
/** Events waiting to be sent (new or retransmitted from lost packets). */
|
||||||
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
|
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
|
||||||
private stringTable = new ClientNetStringTable();
|
private stringTable = new ClientNetStringTable();
|
||||||
|
|
@ -178,7 +181,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
this.rawMessageCount++;
|
this.rawMessageCount++;
|
||||||
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
|
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
|
||||||
connLog.debug(
|
connLog.debug(
|
||||||
{ bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount },
|
{
|
||||||
|
bytes: msg.length,
|
||||||
|
firstByte: msg[0],
|
||||||
|
rawTotal: this.rawMessageCount,
|
||||||
|
},
|
||||||
"Raw UDP message received",
|
"Raw UDP message received",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +232,16 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
case 36: // ConnectAccept
|
case 36: // ConnectAccept
|
||||||
this.handleConnectAccept(msg);
|
this.handleConnectAccept(msg);
|
||||||
break;
|
break;
|
||||||
case 38: { // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
|
case 38: {
|
||||||
|
// Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
|
||||||
let reason = "Server disconnected";
|
let reason = "Server disconnected";
|
||||||
if (msg.length > 9) {
|
if (msg.length > 9) {
|
||||||
try {
|
try {
|
||||||
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
|
const data = new Uint8Array(
|
||||||
|
msg.buffer,
|
||||||
|
msg.byteOffset,
|
||||||
|
msg.byteLength,
|
||||||
|
);
|
||||||
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
|
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
|
||||||
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
|
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
|
||||||
const bs = new BitStream(data.subarray(9));
|
const bs = new BitStream(data.subarray(9));
|
||||||
|
|
@ -239,7 +251,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
// Fall back to default reason
|
// Fall back to default reason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connLog.warn({ reason, bytes: msg.length }, "Server sent Disconnect packet");
|
connLog.warn(
|
||||||
|
{ reason, bytes: msg.length },
|
||||||
|
"Server sent Disconnect packet",
|
||||||
|
);
|
||||||
this.setStatus("disconnected", reason);
|
this.setStatus("disconnected", reason);
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
break;
|
break;
|
||||||
|
|
@ -249,16 +264,24 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle ChallengeReject (type 28): U8(28) + U32(connectSeq) + ASCII reason. */
|
/** Handle ChallengeReject (type 28): U8(28) + U32(clientSeq) + HuffString(reason). */
|
||||||
private handleChallengeReject(msg: Buffer): void {
|
private handleChallengeReject(msg: Buffer): void {
|
||||||
|
if (msg.length < 5) return;
|
||||||
|
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
|
const seq = dv.getUint32(1, true);
|
||||||
|
if (seq !== this.clientConnectSequence) {
|
||||||
|
connLog.debug({ expected: this.clientConnectSequence, got: seq }, "ChallengeReject sequence mismatch, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let reason = "Challenge rejected";
|
let reason = "Challenge rejected";
|
||||||
if (msg.length > 5) {
|
if (msg.length > 5) {
|
||||||
const chars: number[] = [];
|
try {
|
||||||
for (let i = 5; i < msg.length && msg[i] !== 0; i++) {
|
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
chars.push(msg[i]);
|
const bs = new BitStream(data.subarray(5));
|
||||||
}
|
const parsed = bs.readString();
|
||||||
if (chars.length > 0) {
|
if (parsed) reason = parsed;
|
||||||
reason = String.fromCharCode(...chars);
|
} catch {
|
||||||
|
// Fall back to default reason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connLog.warn({ reason }, "ChallengeReject received");
|
connLog.warn({ reason }, "ChallengeReject received");
|
||||||
|
|
@ -269,18 +292,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
/** Handle ConnectChallengeResponse. */
|
/** Handle ConnectChallengeResponse. */
|
||||||
private handleChallengeResponse(msg: Buffer): void {
|
private handleChallengeResponse(msg: Buffer): void {
|
||||||
if (msg.length < 14) {
|
if (msg.length < 14) {
|
||||||
connLog.error(
|
connLog.error({ bytes: msg.length }, "ChallengeResponse too short");
|
||||||
{ bytes: msg.length },
|
|
||||||
"ChallengeResponse too short",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dv = new DataView(
|
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
msg.buffer,
|
|
||||||
msg.byteOffset,
|
|
||||||
msg.byteLength,
|
|
||||||
);
|
|
||||||
const serverProtocolVersion = dv.getUint32(1, true);
|
const serverProtocolVersion = dv.getUint32(1, true);
|
||||||
this.serverConnectSequence = dv.getUint32(5, true);
|
this.serverConnectSequence = dv.getUint32(5, true);
|
||||||
const echoedClientSeq = dv.getUint32(9, true);
|
const echoedClientSeq = dv.getUint32(9, true);
|
||||||
|
|
@ -354,14 +370,30 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle ConnectReject. */
|
/** Handle ConnectReject. */
|
||||||
|
/** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */
|
||||||
private handleConnectReject(msg: Buffer): void {
|
private handleConnectReject(msg: Buffer): void {
|
||||||
let reason = "Connection rejected";
|
if (msg.length < 9) return;
|
||||||
if (msg.length > 1) {
|
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
const chars: number[] = [];
|
const serverSeq = dv.getUint32(1, true);
|
||||||
for (let i = 1; i < msg.length && msg[i] !== 0; i++) {
|
const clientSeq = dv.getUint32(5, true);
|
||||||
chars.push(msg[i]);
|
if (serverSeq !== this.serverConnectSequence || clientSeq !== this.clientConnectSequence) {
|
||||||
|
connLog.debug(
|
||||||
|
{ expectedServer: this.serverConnectSequence, gotServer: serverSeq,
|
||||||
|
expectedClient: this.clientConnectSequence, gotClient: clientSeq },
|
||||||
|
"ConnectReject sequence mismatch, ignoring",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let reason = "Connection rejected";
|
||||||
|
if (msg.length > 9) {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
|
const bs = new BitStream(data.subarray(9));
|
||||||
|
const parsed = bs.readString();
|
||||||
|
if (parsed) reason = parsed;
|
||||||
|
} catch {
|
||||||
|
// Fall back to default reason
|
||||||
}
|
}
|
||||||
reason = String.fromCharCode(...chars);
|
|
||||||
}
|
}
|
||||||
connLog.warn({ reason }, "ConnectReject received");
|
connLog.warn({ reason }, "ConnectReject received");
|
||||||
this.setStatus("disconnected", reason);
|
this.setStatus("disconnected", reason);
|
||||||
|
|
@ -385,7 +417,6 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
|
|
||||||
// We still need to process the dnet header locally to track ack state
|
// We still need to process the dnet header locally to track ack state
|
||||||
this.processPacketForAcks(data);
|
this.processPacketForAcks(data);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process a packet's dnet header to maintain ack state. */
|
/** Process a packet's dnet header to maintain ack state. */
|
||||||
|
|
@ -415,7 +446,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
// The server's processRawPacket calls sendPingResponse on receiving a
|
// The server's processRawPacket calls sendPingResponse on receiving a
|
||||||
// PingPacket. Without this response, the server may time us out.
|
// PingPacket. Without this response, the server may time us out.
|
||||||
if (packetType === 1) {
|
if (packetType === 1) {
|
||||||
connLog.debug({ seq: seqNumber }, "Received PingPacket, sending ping response");
|
connLog.debug(
|
||||||
|
{ seq: seqNumber },
|
||||||
|
"Received PingPacket, sending ping response",
|
||||||
|
);
|
||||||
const pingResponse = this.protocol.buildPingPacket();
|
const pingResponse = this.protocol.buildPingPacket();
|
||||||
this.sendRaw(pingResponse);
|
this.sendRaw(pingResponse);
|
||||||
}
|
}
|
||||||
|
|
@ -472,19 +506,15 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle a parsed T2csri event from the browser. */
|
/** Handle a parsed T2csri event from the browser. */
|
||||||
handleAuthEvent(
|
handleAuthEvent(eventName: string, args: string[]): void {
|
||||||
eventName: string,
|
|
||||||
args: string[],
|
|
||||||
): void {
|
|
||||||
if (!this.auth) return;
|
if (!this.auth) return;
|
||||||
|
|
||||||
switch (eventName) {
|
switch (eventName) {
|
||||||
case "t2csri_pokeClient": {
|
case "t2csri_pokeClient": {
|
||||||
connLog.info("Auth: received pokeClient, sending certificate + challenge");
|
connLog.info(
|
||||||
const result = this.auth.onPokeClient(
|
"Auth: received pokeClient, sending certificate + challenge",
|
||||||
args[0] || "",
|
|
||||||
this.host,
|
|
||||||
);
|
);
|
||||||
|
const result = this.auth.onPokeClient(args[0] || "", this.host);
|
||||||
for (const cmd of result.commands) {
|
for (const cmd of result.commands) {
|
||||||
this.sendCommand(cmd.name, ...cmd.args);
|
this.sendCommand(cmd.name, ...cmd.args);
|
||||||
}
|
}
|
||||||
|
|
@ -512,10 +542,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
this.authDelayTimer = setTimeout(() => {
|
this.authDelayTimer = setTimeout(() => {
|
||||||
this.authDelayTimer = null;
|
this.authDelayTimer = null;
|
||||||
if (this._status !== "authenticating") return;
|
if (this._status !== "authenticating") return;
|
||||||
this.sendCommand(
|
this.sendCommand(result.command.name, ...result.command.args);
|
||||||
result.command.name,
|
|
||||||
...result.command.args,
|
|
||||||
);
|
|
||||||
this.enforceObserver();
|
this.enforceObserver();
|
||||||
this.setStatus("connected");
|
this.setStatus("connected");
|
||||||
}, delay);
|
}, delay);
|
||||||
|
|
@ -553,11 +580,20 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
basePath: string,
|
basePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
connLog.info(
|
connLog.info(
|
||||||
{ seed: `0x${(seed >>> 0).toString(16)}`, datablocks: datablocks.length, includeTextures },
|
{
|
||||||
|
seed: `0x${(seed >>> 0).toString(16)}`,
|
||||||
|
datablocks: datablocks.length,
|
||||||
|
includeTextures,
|
||||||
|
},
|
||||||
"Computing CRC over game files",
|
"Computing CRC over game files",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures);
|
const { crc, totalSize } = await computeGameCRC(
|
||||||
|
seed,
|
||||||
|
datablocks,
|
||||||
|
basePath,
|
||||||
|
includeTextures,
|
||||||
|
);
|
||||||
connLog.info(
|
connLog.info(
|
||||||
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
|
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
|
||||||
"CRC computed, sending response",
|
"CRC computed, sending response",
|
||||||
|
|
@ -586,7 +622,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
|
|
||||||
/** Send a commandToServer as a RemoteCommandEvent. */
|
/** Send a commandToServer as a RemoteCommandEvent. */
|
||||||
sendCommand(command: string, ...args: string[]): void {
|
sendCommand(command: string, ...args: string[]): void {
|
||||||
connLog.debug({ command, args, eventSeq: this.nextSendEventSeq }, "Sending commandToServer");
|
connLog.debug(
|
||||||
|
{ command, args, eventSeq: this.nextSendEventSeq },
|
||||||
|
"Sending commandToServer",
|
||||||
|
);
|
||||||
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
|
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
|
||||||
this.pendingEvents.push(...events);
|
this.pendingEvents.push(...events);
|
||||||
this.flushEvents();
|
this.flushEvents();
|
||||||
|
|
@ -608,9 +647,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
* Build and send a data packet that includes events from the send queue.
|
* Build and send a data packet that includes events from the send queue.
|
||||||
* Events stay tracked per-packet so they can be re-queued on loss.
|
* Events stay tracked per-packet so they can be re-queued on loss.
|
||||||
*/
|
*/
|
||||||
private sendDataPacketWithEvents(
|
private sendDataPacketWithEvents(move?: ClientMoveData): void {
|
||||||
move?: ClientMoveData,
|
|
||||||
): void {
|
|
||||||
const events = this.eventSendQueue.splice(0);
|
const events = this.eventSendQueue.splice(0);
|
||||||
if (events.length === 0) return;
|
if (events.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -631,8 +668,12 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
this.sentEventsByPacket.set(packetSeq, events);
|
this.sentEventsByPacket.set(packetSeq, events);
|
||||||
|
|
||||||
const moveData = move ?? {
|
const moveData = move ?? {
|
||||||
x: 0, y: 0, z: 0,
|
x: 0,
|
||||||
yaw: 0, pitch: 0, roll: 0,
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
roll: 0,
|
||||||
freeLook: false,
|
freeLook: false,
|
||||||
trigger: [false, false, false, false, false, false],
|
trigger: [false, false, false, false, false, false],
|
||||||
};
|
};
|
||||||
|
|
@ -702,7 +743,14 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
this.sendMoveCount++;
|
this.sendMoveCount++;
|
||||||
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
|
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
|
||||||
connLog.debug(
|
connLog.debug(
|
||||||
{ yaw: move.yaw, pitch: move.pitch, x: move.x, y: move.y, z: move.z, total: this.sendMoveCount },
|
{
|
||||||
|
yaw: move.yaw,
|
||||||
|
pitch: move.pitch,
|
||||||
|
x: move.x,
|
||||||
|
y: move.y,
|
||||||
|
z: move.z,
|
||||||
|
total: this.sendMoveCount,
|
||||||
|
},
|
||||||
"Sending move",
|
"Sending move",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -779,7 +827,8 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
|
||||||
let keepaliveCount = 0;
|
let keepaliveCount = 0;
|
||||||
this.keepaliveTimer = setInterval(() => {
|
this.keepaliveTimer = setInterval(() => {
|
||||||
keepaliveCount++;
|
keepaliveCount++;
|
||||||
if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate
|
if (keepaliveCount % 300 === 0) {
|
||||||
|
// ~10s at 32ms tick rate
|
||||||
connLog.info(
|
connLog.info(
|
||||||
{
|
{
|
||||||
dataPackets: this.dataPacketCount,
|
dataPackets: this.dataPacketCount,
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,12 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
|
||||||
if (info) {
|
if (info) {
|
||||||
pingResults.set(addr, info);
|
pingResults.set(addr, info);
|
||||||
masterLog.debug(
|
masterLog.debug(
|
||||||
{ addr, name: info.name, build: info.buildVersion, ping: info.ping },
|
{
|
||||||
|
addr,
|
||||||
|
name: info.name,
|
||||||
|
build: info.buildVersion,
|
||||||
|
ping: info.ping,
|
||||||
|
},
|
||||||
"Ping response",
|
"Ping response",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -246,10 +251,7 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
|
||||||
* U32 buildVersion (e.g. 25034)
|
* U32 buildVersion (e.g. 25034)
|
||||||
* HuffString serverName (24 chars max)
|
* HuffString serverName (24 chars max)
|
||||||
*/
|
*/
|
||||||
function parsePingResponse(
|
function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null {
|
||||||
data: Buffer,
|
|
||||||
sendTime?: number,
|
|
||||||
): PingInfo | null {
|
|
||||||
if (data.length < 7 || data[0] !== 16) return null;
|
if (data.length < 7 || data[0] !== 16) return null;
|
||||||
try {
|
try {
|
||||||
const bs = new BitStream(
|
const bs = new BitStream(
|
||||||
|
|
@ -301,7 +303,15 @@ function parseInfoResponse(data: Buffer): GameInfo | null {
|
||||||
const playerCount = bs.readU8();
|
const playerCount = bs.readU8();
|
||||||
const maxPlayers = bs.readU8();
|
const maxPlayers = bs.readU8();
|
||||||
const botCount = bs.readU8();
|
const botCount = bs.readU8();
|
||||||
return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount };
|
return {
|
||||||
|
mod,
|
||||||
|
gameType,
|
||||||
|
mapName,
|
||||||
|
status,
|
||||||
|
playerCount,
|
||||||
|
maxPlayers,
|
||||||
|
botCount,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,7 @@ export class ConnectionProtocol {
|
||||||
|
|
||||||
private _sendCount = 0;
|
private _sendCount = 0;
|
||||||
|
|
||||||
buildSendPacketHeader(
|
buildSendPacketHeader(packetType: number = DataPacket): BitStreamWriter {
|
||||||
packetType: number = DataPacket,
|
|
||||||
): BitStreamWriter {
|
|
||||||
const bs = new BitStreamWriter(1500);
|
const bs = new BitStreamWriter(1500);
|
||||||
|
|
||||||
// gameFlag — always true for data connection packets
|
// gameFlag — always true for data connection packets
|
||||||
|
|
@ -42,8 +40,7 @@ export class ConnectionProtocol {
|
||||||
|
|
||||||
// Increment send sequence
|
// Increment send sequence
|
||||||
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
|
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
|
||||||
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] =
|
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvd >>> 0;
|
||||||
this.lastSeqRecvd >>> 0;
|
|
||||||
|
|
||||||
// seqNumber (9 bits)
|
// seqNumber (9 bits)
|
||||||
bs.writeInt(this.lastSendSeq & 0x1ff, 9);
|
bs.writeInt(this.lastSendSeq & 0x1ff, 9);
|
||||||
|
|
@ -71,7 +68,8 @@ export class ConnectionProtocol {
|
||||||
|
|
||||||
this._sendCount++;
|
this._sendCount++;
|
||||||
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
|
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
|
||||||
const typeName = packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
|
const typeName =
|
||||||
|
packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
|
||||||
console.log(
|
console.log(
|
||||||
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
|
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
|
||||||
`highestAck=${this.lastSeqRecvd} type=${typeName} ` +
|
`highestAck=${this.lastSeqRecvd} type=${typeName} ` +
|
||||||
|
|
@ -131,8 +129,7 @@ export class ConnectionProtocol {
|
||||||
const isAcked =
|
const isAcked =
|
||||||
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
|
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
|
||||||
if (isAcked) {
|
if (isAcked) {
|
||||||
this.lastRecvAckAck =
|
this.lastRecvAckAck = this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
|
||||||
this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
|
|
||||||
}
|
}
|
||||||
if (this.onNotify) {
|
if (this.onNotify) {
|
||||||
this.onNotify(ackSeq, isAcked);
|
this.onNotify(ackSeq, isAcked);
|
||||||
|
|
@ -144,8 +141,7 @@ export class ConnectionProtocol {
|
||||||
this.highestAckedSeq = highestAck;
|
this.highestAckedSeq = highestAck;
|
||||||
|
|
||||||
const dispatchData =
|
const dispatchData =
|
||||||
this.lastSeqRecvd !== seqNumber &&
|
this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket;
|
||||||
header.packetType === DataPacket;
|
|
||||||
this.lastSeqRecvd = seqNumber;
|
this.lastSeqRecvd = seqNumber;
|
||||||
|
|
||||||
return { accepted: true, dispatchData };
|
return { accepted: true, dispatchData };
|
||||||
|
|
@ -168,9 +164,7 @@ export class ConnectionProtocol {
|
||||||
* The caller provides a callback that writes game data to the stream
|
* The caller provides a callback that writes game data to the stream
|
||||||
* after the dnet header.
|
* after the dnet header.
|
||||||
*/
|
*/
|
||||||
buildDataPacket(
|
buildDataPacket(writePayload: (bs: BitStreamWriter) => void): Uint8Array {
|
||||||
writePayload: (bs: BitStreamWriter) => void,
|
|
||||||
): Uint8Array {
|
|
||||||
const bs = this.buildSendPacketHeader(DataPacket);
|
const bs = this.buildSendPacketHeader(DataPacket);
|
||||||
writePayload(bs);
|
writePayload(bs);
|
||||||
return bs.getBuffer();
|
return bs.getBuffer();
|
||||||
|
|
@ -343,10 +337,7 @@ export class ClientNetStringTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a NetStringEvent to register a string with the server. */
|
/** Build a NetStringEvent to register a string with the server. */
|
||||||
export function buildNetStringEvent(
|
export function buildNetStringEvent(id: number, value: string): ClientEvent {
|
||||||
id: number,
|
|
||||||
value: string,
|
|
||||||
): ClientEvent {
|
|
||||||
return {
|
return {
|
||||||
classId: NetStringEventClassId,
|
classId: NetStringEventClassId,
|
||||||
write(bs: BitStreamWriter) {
|
write(bs: BitStreamWriter) {
|
||||||
|
|
@ -489,9 +480,7 @@ export function buildConnectRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Disconnect (type 38) OOB packet. */
|
/** Build a Disconnect (type 38) OOB packet. */
|
||||||
export function buildDisconnectPacket(
|
export function buildDisconnectPacket(connectSequence: number): Uint8Array {
|
||||||
connectSequence: number,
|
|
||||||
): Uint8Array {
|
|
||||||
const bs = new BitStreamWriter(64);
|
const bs = new BitStreamWriter(64);
|
||||||
bs.writeU8(38); // Disconnect type
|
bs.writeU8(38); // Disconnect type
|
||||||
bs.writeU32(connectSequence);
|
bs.writeU32(connectSequence);
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ const MANIFEST_PATH =
|
||||||
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
|
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
|
||||||
|
|
||||||
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
|
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
|
||||||
const MASTER_SERVER =
|
const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
|
||||||
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
|
|
||||||
|
|
||||||
/** HTTP server for health checks; WebSocket upgrades are handled separately. */
|
/** HTTP server for health checks; WebSocket upgrades are handled separately. */
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
|
@ -58,7 +57,9 @@ const httpServer = http.createServer(async (req, res) => {
|
||||||
|
|
||||||
const allOk = Object.values(checks).every((c) => c.ok);
|
const allOk = Object.values(checks).every((c) => c.ok);
|
||||||
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
|
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2));
|
res.end(
|
||||||
|
JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +88,11 @@ wss.on("connection", (ws) => {
|
||||||
const RETRY_DELAY_MS = 6000;
|
const RETRY_DELAY_MS = 6000;
|
||||||
const RETRYABLE_REASONS = ["Server is cycling mission"];
|
const RETRYABLE_REASONS = ["Server is cycling mission"];
|
||||||
|
|
||||||
async function connectToServer(ws: WebSocket, address: string, warriorName?: string): Promise<void> {
|
async function connectToServer(
|
||||||
|
ws: WebSocket,
|
||||||
|
address: string,
|
||||||
|
warriorName?: string,
|
||||||
|
): Promise<void> {
|
||||||
if (gameConnection) {
|
if (gameConnection) {
|
||||||
gameConnection.disconnect();
|
gameConnection.disconnect();
|
||||||
}
|
}
|
||||||
|
|
@ -95,9 +100,7 @@ wss.on("connection", (ws) => {
|
||||||
gameConnection = new GameConnection(address, { warriorName });
|
gameConnection = new GameConnection(address, { warriorName });
|
||||||
|
|
||||||
// Set mapName from the cached server list if available.
|
// Set mapName from the cached server list if available.
|
||||||
const cachedServer = cachedServers.find(
|
const cachedServer = cachedServers.find((s) => s.address === address);
|
||||||
(s) => s.address === address,
|
|
||||||
);
|
|
||||||
if (cachedServer?.mapName) {
|
if (cachedServer?.mapName) {
|
||||||
gameConnection.setMapName(cachedServer.mapName);
|
gameConnection.setMapName(cachedServer.mapName);
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +126,11 @@ wss.on("connection", (ws) => {
|
||||||
) {
|
) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
relayLog.info(
|
relayLog.info(
|
||||||
{ attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS },
|
{
|
||||||
|
attempt: retryCount,
|
||||||
|
maxRetries: MAX_RETRIES,
|
||||||
|
delay: RETRY_DELAY_MS,
|
||||||
|
},
|
||||||
"Retryable disconnect — will reconnect",
|
"Retryable disconnect — will reconnect",
|
||||||
);
|
);
|
||||||
sendToClient(ws, {
|
sendToClient(ws, {
|
||||||
|
|
@ -243,7 +250,10 @@ wss.on("connection", (ws) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "joinServer": {
|
case "joinServer": {
|
||||||
relayLog.info({ address: message.address, warriorName: message.warriorName }, "Join server requested");
|
relayLog.info(
|
||||||
|
{ address: message.address, warriorName: message.warriorName },
|
||||||
|
"Join server requested",
|
||||||
|
);
|
||||||
if (gameConnection) {
|
if (gameConnection) {
|
||||||
relayLog.info("Disconnecting existing game connection");
|
relayLog.info("Disconnecting existing game connection");
|
||||||
gameConnection.disconnect();
|
gameConnection.disconnect();
|
||||||
|
|
@ -285,10 +295,7 @@ wss.on("connection", (ws) => {
|
||||||
{ event: message.command },
|
{ event: message.command },
|
||||||
"Forwarding auth event from browser",
|
"Forwarding auth event from browser",
|
||||||
);
|
);
|
||||||
gameConnection.handleAuthEvent(
|
gameConnection.handleAuthEvent(message.command, message.args);
|
||||||
message.command,
|
|
||||||
message.args,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
relayLog.debug(
|
relayLog.debug(
|
||||||
{ command: message.command },
|
{ command: message.command },
|
||||||
|
|
@ -315,7 +322,10 @@ wss.on("connection", (ws) => {
|
||||||
case "sendCRCCompute": {
|
case "sendCRCCompute": {
|
||||||
if (gameConnection) {
|
if (gameConnection) {
|
||||||
relayLog.info(
|
relayLog.info(
|
||||||
{ datablocks: message.datablocks.length, includeTextures: message.includeTextures },
|
{
|
||||||
|
datablocks: message.datablocks.length,
|
||||||
|
includeTextures: message.includeTextures,
|
||||||
|
},
|
||||||
"Computing CRC from game files",
|
"Computing CRC from game files",
|
||||||
);
|
);
|
||||||
gameConnection.computeAndSendCRC(
|
gameConnection.computeAndSendCRC(
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,32 @@ export type ClientMessage =
|
||||||
| { type: "disconnect" }
|
| { type: "disconnect" }
|
||||||
| { type: "sendMove"; move: ClientMove }
|
| { type: "sendMove"; move: ClientMove }
|
||||||
| { type: "sendCommand"; command: string; args: string[] }
|
| { type: "sendCommand"; command: string; args: string[] }
|
||||||
| { type: "sendCRCResponse"; crcValue: number; field1: number; field2: number }
|
| {
|
||||||
| { type: "sendCRCCompute"; seed: number; field2: number; includeTextures: boolean; datablocks: { objectId: number; className: string; shapeName: string }[] }
|
type: "sendCRCResponse";
|
||||||
|
crcValue: number;
|
||||||
|
field1: number;
|
||||||
|
field2: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "sendCRCCompute";
|
||||||
|
seed: number;
|
||||||
|
field2: number;
|
||||||
|
includeTextures: boolean;
|
||||||
|
datablocks: { objectId: number; className: string; shapeName: string }[];
|
||||||
|
}
|
||||||
| { type: "sendGhostAck"; sequence: number; ghostCount: number }
|
| { type: "sendGhostAck"; sequence: number; ghostCount: number }
|
||||||
| { type: "wsPing"; ts: number };
|
| { type: "wsPing"; ts: number };
|
||||||
|
|
||||||
/** Messages from relay server to browser client. */
|
/** Messages from relay server to browser client. */
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: "serverList"; servers: ServerInfo[] }
|
| { type: "serverList"; servers: ServerInfo[] }
|
||||||
| { type: "status"; status: ConnectionStatus; message?: string; connectSequence?: number; mapName?: string }
|
| {
|
||||||
|
type: "status";
|
||||||
|
status: ConnectionStatus;
|
||||||
|
message?: string;
|
||||||
|
connectSequence?: number;
|
||||||
|
mapName?: string;
|
||||||
|
}
|
||||||
| { type: "gamePacket"; data: Uint8Array }
|
| { type: "gamePacket"; data: Uint8Array }
|
||||||
| { type: "ping"; ms: number }
|
| { type: "ping"; ms: number }
|
||||||
| { type: "wsPong"; ts: number }
|
| { type: "wsPong"; ts: number }
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,9 @@ for (const rel of weaponModels) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mpIdx === -1) {
|
if (mpIdx === -1) {
|
||||||
console.log(`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`);
|
console.log(
|
||||||
|
`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,10 @@ function fmt(v: number[]): string {
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const playerBuf = await fs.readFile(
|
const playerBuf = await fs.readFile(
|
||||||
"docs/base/@vl2/shapes.vl2/shapes/light_male.glb"
|
"docs/base/@vl2/shapes.vl2/shapes/light_male.glb",
|
||||||
);
|
);
|
||||||
const weaponBuf = await fs.readFile(
|
const weaponBuf = await fs.readFile(
|
||||||
"docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb"
|
"docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb",
|
||||||
);
|
);
|
||||||
|
|
||||||
const playerDoc = parseGlb(playerBuf);
|
const playerDoc = parseGlb(playerBuf);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ async function run({
|
||||||
try {
|
try {
|
||||||
await fs.stat(oggFile);
|
await fs.stat(oggFile);
|
||||||
continue; // .ogg already exists, skip
|
continue; // .ogg already exists, skip
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
inputFiles.push(wavFile);
|
inputFiles.push(wavFile);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ function printNodeTree(
|
||||||
doc: GltfDocument,
|
doc: GltfDocument,
|
||||||
nodeIndex: number,
|
nodeIndex: number,
|
||||||
depth: number,
|
depth: number,
|
||||||
visited: Set<number>
|
visited: Set<number>,
|
||||||
): void {
|
): void {
|
||||||
if (visited.has(nodeIndex)) return;
|
if (visited.has(nodeIndex)) return;
|
||||||
visited.add(nodeIndex);
|
visited.add(nodeIndex);
|
||||||
|
|
@ -101,12 +101,16 @@ function printNodeTree(
|
||||||
// Only show scale if non-identity
|
// Only show scale if non-identity
|
||||||
const s = node.scale;
|
const s = node.scale;
|
||||||
if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) {
|
if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) {
|
||||||
console.log(`${indent} S: ${formatVec3(node.scale as [number, number, number])}`);
|
console.log(
|
||||||
|
`${indent} S: ${formatVec3(node.scale as [number, number, number])}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show matrix if present
|
// Show matrix if present
|
||||||
if (node.matrix) {
|
if (node.matrix) {
|
||||||
console.log(`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`);
|
console.log(
|
||||||
|
`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
|
|
@ -130,18 +134,20 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
const skinCount = doc.skins?.length ?? 0;
|
const skinCount = doc.skins?.length ?? 0;
|
||||||
const animCount = doc.animations?.length ?? 0;
|
const animCount = doc.animations?.length ?? 0;
|
||||||
|
|
||||||
console.log(`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`);
|
console.log(
|
||||||
|
`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Show skins (skeletons)
|
// Show skins (skeletons)
|
||||||
if (doc.skins && doc.skins.length > 0) {
|
if (doc.skins && doc.skins.length > 0) {
|
||||||
console.log(`\n--- Skins ---`);
|
console.log(`\n--- Skins ---`);
|
||||||
for (let i = 0; i < doc.skins.length; i++) {
|
for (let i = 0; i < doc.skins.length; i++) {
|
||||||
const skin = doc.skins[i];
|
const skin = doc.skins[i];
|
||||||
console.log(`Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`);
|
|
||||||
console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
` Joint node indices: [${skin.joints.join(", ")}]`
|
`Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`,
|
||||||
);
|
);
|
||||||
|
console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
|
||||||
|
console.log(` Joint node indices: [${skin.joints.join(", ")}]`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +156,9 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
console.log(`\n--- Animations ---`);
|
console.log(`\n--- Animations ---`);
|
||||||
for (let i = 0; i < doc.animations.length; i++) {
|
for (let i = 0; i < doc.animations.length; i++) {
|
||||||
const anim = doc.animations[i];
|
const anim = doc.animations[i];
|
||||||
console.log(` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`);
|
console.log(
|
||||||
|
` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +186,18 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight interesting nodes
|
// Highlight interesting nodes
|
||||||
const keywords = ["eye", "mount", "hand", "cam", "head", "weapon", "muzzle", "node", "jet", "contrail"];
|
const keywords = [
|
||||||
|
"eye",
|
||||||
|
"mount",
|
||||||
|
"hand",
|
||||||
|
"cam",
|
||||||
|
"head",
|
||||||
|
"weapon",
|
||||||
|
"muzzle",
|
||||||
|
"node",
|
||||||
|
"jet",
|
||||||
|
"contrail",
|
||||||
|
];
|
||||||
const interesting: { index: number; name: string; node: GltfNode }[] = [];
|
const interesting: { index: number; name: string; node: GltfNode }[] = [];
|
||||||
if (doc.nodes) {
|
if (doc.nodes) {
|
||||||
for (let i = 0; i < doc.nodes.length; i++) {
|
for (let i = 0; i < doc.nodes.length; i++) {
|
||||||
|
|
@ -191,7 +210,9 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interesting.length > 0) {
|
if (interesting.length > 0) {
|
||||||
console.log(`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`);
|
console.log(
|
||||||
|
`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`,
|
||||||
|
);
|
||||||
for (const { index, name, node } of interesting) {
|
for (const { index, name, node } of interesting) {
|
||||||
console.log(` [${index}] "${name}"`);
|
console.log(` [${index}] "${name}"`);
|
||||||
console.log(` Translation: ${formatVec3(node.translation)}`);
|
console.log(` Translation: ${formatVec3(node.translation)}`);
|
||||||
|
|
@ -199,13 +220,17 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
console.log(` Rotation: ${formatQuat(node.rotation)}`);
|
console.log(` Rotation: ${formatQuat(node.rotation)}`);
|
||||||
}
|
}
|
||||||
if (node.scale) {
|
if (node.scale) {
|
||||||
console.log(` Scale: ${formatVec3(node.scale as [number, number, number])}`);
|
console.log(
|
||||||
|
` Scale: ${formatVec3(node.scale as [number, number, number])}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Find parent
|
// Find parent
|
||||||
if (doc.nodes) {
|
if (doc.nodes) {
|
||||||
for (let j = 0; j < doc.nodes.length; j++) {
|
for (let j = 0; j < doc.nodes.length; j++) {
|
||||||
if (doc.nodes[j].children?.includes(index)) {
|
if (doc.nodes[j].children?.includes(index)) {
|
||||||
console.log(` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`);
|
console.log(
|
||||||
|
` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +243,9 @@ async function inspectGlb(filePath: string): Promise<void> {
|
||||||
if (doc.nodes) {
|
if (doc.nodes) {
|
||||||
for (let i = 0; i < doc.nodes.length; i++) {
|
for (let i = 0; i < doc.nodes.length; i++) {
|
||||||
const node = doc.nodes[i];
|
const node = doc.nodes[i];
|
||||||
console.log(` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`);
|
console.log(
|
||||||
|
` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ if (!demoPath) {
|
||||||
console.error();
|
console.error();
|
||||||
console.error("Options:");
|
console.error("Options:");
|
||||||
console.error(" --no-headless Show the browser window");
|
console.error(" --no-headless Show the browser window");
|
||||||
console.error(" --wait, -w <s> Seconds to wait after loading (default: 10)");
|
console.error(
|
||||||
|
" --wait, -w <s> Seconds to wait after loading (default: 10)",
|
||||||
|
);
|
||||||
console.error(" --screenshot, -s Take a screenshot after loading");
|
console.error(" --screenshot, -s Take a screenshot after loading");
|
||||||
console.error();
|
console.error();
|
||||||
console.error("Examples:");
|
console.error("Examples:");
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,9 @@ async function downloadAccount(
|
||||||
const trimmed = buffer.trim();
|
const trimmed = buffer.trim();
|
||||||
|
|
||||||
if (trimmed === "RECOVERERROR") {
|
if (trimmed === "RECOVERERROR") {
|
||||||
reject(new Error("Auth server returned RECOVERERROR (malformed request)"));
|
reject(
|
||||||
|
new Error("Auth server returned RECOVERERROR (malformed request)"),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed === "NOTFOUND") {
|
if (trimmed === "NOTFOUND") {
|
||||||
|
|
@ -202,7 +204,11 @@ async function downloadAccount(
|
||||||
// Line 2: EXP: <encrypted_private_exponent>
|
// Line 2: EXP: <encrypted_private_exponent>
|
||||||
const lines = trimmed.split("\n");
|
const lines = trimmed.split("\n");
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
reject(new Error(`Unexpected response from auth server: ${trimmed.slice(0, 200)}`));
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unexpected response from auth server: ${trimmed.slice(0, 200)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +306,9 @@ async function main() {
|
||||||
|
|
||||||
const existingLines = await readEnvLines(envFilePath);
|
const existingLines = await readEnvLines(envFilePath);
|
||||||
const updatedLines = updateEnvLines(
|
const updatedLines = updateEnvLines(
|
||||||
existingLines.length > 0 ? existingLines : ["# Generated by scripts/t2-login.ts"],
|
existingLines.length > 0
|
||||||
|
? existingLines
|
||||||
|
: ["# Generated by scripts/t2-login.ts"],
|
||||||
{
|
{
|
||||||
T2_ACCOUNT_NAME: username,
|
T2_ACCOUNT_NAME: username,
|
||||||
T2_ACCOUNT_PASSWORD: password,
|
T2_ACCOUNT_PASSWORD: password,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@
|
||||||
|
|
||||||
import { queryServerList } from "../relay/masterQuery.js";
|
import { queryServerList } from "../relay/masterQuery.js";
|
||||||
|
|
||||||
const MASTER_SERVER =
|
const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
|
||||||
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`Master server: ${MASTER_SERVER}`);
|
console.log(`Master server: ${MASTER_SERVER}`);
|
||||||
|
|
@ -27,10 +26,7 @@ async function main() {
|
||||||
console.log(`Found ${servers.length} server(s):\n`);
|
console.log(`Found ${servers.length} server(s):\n`);
|
||||||
|
|
||||||
// Print as a table
|
// Print as a table
|
||||||
const nameWidth = Math.max(
|
const nameWidth = Math.max(11, ...servers.map((s) => s.name.length));
|
||||||
11,
|
|
||||||
...servers.map((s) => s.name.length),
|
|
||||||
);
|
|
||||||
const header = [
|
const header = [
|
||||||
"Server Name".padEnd(nameWidth),
|
"Server Name".padEnd(nameWidth),
|
||||||
"Map".padEnd(20),
|
"Map".padEnd(20),
|
||||||
|
|
|
||||||
67
src/components/Accordion.module.css
Normal file
67
src/components/Accordion.module.css
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
.AccordionGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
background: rgb(255, 255, 255, 0.1);
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.0417em;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TriggerIcon {
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Trigger[data-state="open"] .TriggerIcon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Content[data-state="open"] {
|
||||||
|
animation: slideDown 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Content[data-state="closed"] {
|
||||||
|
animation: slideUp 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/components/Accordion.tsx
Normal file
29
src/components/Accordion.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as RadixAccordion from "@radix-ui/react-accordion";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { IoCaretForward } from "react-icons/io5";
|
||||||
|
import styles from "./Accordion.module.css";
|
||||||
|
|
||||||
|
export function AccordionGroup(props) {
|
||||||
|
return <RadixAccordion.Root className={styles.AccordionGroup} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Accordion({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
label: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<RadixAccordion.Item value={value}>
|
||||||
|
<RadixAccordion.Trigger className={styles.Trigger}>
|
||||||
|
<IoCaretForward className={styles.TriggerIcon} /> {label}
|
||||||
|
</RadixAccordion.Trigger>
|
||||||
|
<RadixAccordion.Content className={styles.Content}>
|
||||||
|
<div className={styles.Body}>{children}</div>
|
||||||
|
</RadixAccordion.Content>
|
||||||
|
</RadixAccordion.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,8 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import { AudioListener, AudioLoader } from "three";
|
import { AudioListener, AudioLoader } from "three";
|
||||||
import { engineStore } from "../state";
|
import { engineStore } from "../state/engineStore";
|
||||||
|
import { useSettings } from "./SettingsProvider";
|
||||||
|
|
||||||
interface AudioContextType {
|
interface AudioContextType {
|
||||||
audioLoader: AudioLoader | null;
|
audioLoader: AudioLoader | null;
|
||||||
|
|
@ -21,7 +22,8 @@ const AudioContext = createContext<AudioContextType | undefined>(undefined);
|
||||||
* Must be rendered inside the Canvas component.
|
* Must be rendered inside the Canvas component.
|
||||||
*/
|
*/
|
||||||
export function AudioProvider({ children }: { children: ReactNode }) {
|
export function AudioProvider({ children }: { children: ReactNode }) {
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
|
const { audioVolume } = useSettings();
|
||||||
const [audioContext, setAudioContext] = useState<AudioContextType>({
|
const [audioContext, setAudioContext] = useState<AudioContextType>({
|
||||||
audioLoader: null,
|
audioLoader: null,
|
||||||
audioListener: null,
|
audioListener: null,
|
||||||
|
|
@ -41,8 +43,6 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||||
camera.add(listener);
|
camera.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
listener.setMasterVolume(0.8);
|
|
||||||
|
|
||||||
setAudioContext({
|
setAudioContext({
|
||||||
audioLoader,
|
audioLoader,
|
||||||
audioListener: listener,
|
audioListener: listener,
|
||||||
|
|
@ -51,7 +51,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||||
// Resume the AudioContext on user interaction to satisfy browser autoplay
|
// Resume the AudioContext on user interaction to satisfy browser autoplay
|
||||||
// policy. Without this, sounds won't play until the user clicks/taps.
|
// policy. Without this, sounds won't play until the user clicks/taps.
|
||||||
const resumeOnGesture = () => {
|
const resumeOnGesture = () => {
|
||||||
const ctx = listener?.context;
|
const ctx = listener.context;
|
||||||
if (!ctx || ctx.state !== "suspended") return;
|
if (!ctx || ctx.state !== "suspended") return;
|
||||||
ctx.resume().finally(() => {
|
ctx.resume().finally(() => {
|
||||||
document.removeEventListener("click", resumeOnGesture);
|
document.removeEventListener("click", resumeOnGesture);
|
||||||
|
|
@ -67,7 +67,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||||
const unsubscribe = engineStore.subscribe(
|
const unsubscribe = engineStore.subscribe(
|
||||||
(state) => state.playback.status,
|
(state) => state.playback.status,
|
||||||
(status) => {
|
(status) => {
|
||||||
const ctx = listener?.context;
|
const ctx = listener.context;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
if (status === "paused") {
|
if (status === "paused") {
|
||||||
ctx.suspend();
|
ctx.suspend();
|
||||||
|
|
@ -85,6 +85,10 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, [camera]);
|
}, [camera]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
audioContext.audioListener?.setMasterVolume(audioVolume);
|
||||||
|
}, [audioVolume, audioContext.audioListener]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AudioContext.Provider value={audioContext}>
|
<AudioContext.Provider value={audioContext}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,15 @@ import {
|
||||||
PositionalAudio,
|
PositionalAudio,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from "three";
|
} from "three";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import { audioToUrl } from "../loaders";
|
import { audioToUrl } from "../loaders";
|
||||||
import { useAudio } from "./AudioContext";
|
import { useAudio } from "./AudioContext";
|
||||||
import { useDebug, useSettings } from "./SettingsProvider";
|
import { useDebug, useSettings } from "./SettingsProvider";
|
||||||
import { FloatingLabel } from "./FloatingLabel";
|
import { FloatingLabel } from "./FloatingLabel";
|
||||||
import { engineStore } from "../state";
|
import { engineStore } from "../state/engineStore";
|
||||||
|
import { AudioEmitterEntity } from "../state/gameEntityTypes";
|
||||||
|
|
||||||
|
const log = createLogger("AudioEmitter");
|
||||||
|
|
||||||
// Global audio buffer cache shared across all audio components.
|
// Global audio buffer cache shared across all audio components.
|
||||||
export const audioBufferCache = new Map<string, AudioBuffer>();
|
export const audioBufferCache = new Map<string, AudioBuffer>();
|
||||||
|
|
@ -50,8 +54,16 @@ export function getSoundGeneration(): number {
|
||||||
export function stopAllTrackedSounds(): void {
|
export function stopAllTrackedSounds(): void {
|
||||||
_soundGeneration++;
|
_soundGeneration++;
|
||||||
for (const [sound] of _activeSounds) {
|
for (const [sound] of _activeSounds) {
|
||||||
try { sound.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_activeSounds.clear();
|
_activeSounds.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +160,11 @@ export function playOneShotSound(
|
||||||
sound.play();
|
sound.play();
|
||||||
sound.source!.onended = () => {
|
sound.source!.onended = () => {
|
||||||
_activeSounds.delete(sound);
|
_activeSounds.delete(sound);
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
parent.remove(sound);
|
parent.remove(sound);
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -160,7 +176,11 @@ export function playOneShotSound(
|
||||||
sound.play();
|
sound.play();
|
||||||
sound.source!.onended = () => {
|
sound.source!.onended = () => {
|
||||||
_activeSounds.delete(sound);
|
_activeSounds.delete(sound);
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -185,7 +205,7 @@ export function getCachedAudioBuffer(
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
(err: any) => {
|
(err: any) => {
|
||||||
console.error("Audio load error", audioUrl, err);
|
log.error("Audio load error %s: %o", audioUrl, err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -194,17 +214,7 @@ export function getCachedAudioBuffer(
|
||||||
export const AudioEmitter = memo(function AudioEmitter({
|
export const AudioEmitter = memo(function AudioEmitter({
|
||||||
entity,
|
entity,
|
||||||
}: {
|
}: {
|
||||||
entity: {
|
entity: AudioEmitterEntity;
|
||||||
audioFileName?: string;
|
|
||||||
audioVolume?: number;
|
|
||||||
audioMinDistance?: number;
|
|
||||||
audioMaxDistance?: number;
|
|
||||||
audioMinLoopGap?: number;
|
|
||||||
audioMaxLoopGap?: number;
|
|
||||||
audioIs3D?: boolean;
|
|
||||||
audioIsLooping?: boolean;
|
|
||||||
position?: [number, number, number];
|
|
||||||
};
|
|
||||||
}) {
|
}) {
|
||||||
const { debugMode } = useDebug();
|
const { debugMode } = useDebug();
|
||||||
const fileName = entity.audioFileName ?? "";
|
const fileName = entity.audioFileName ?? "";
|
||||||
|
|
@ -217,7 +227,8 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
const isLooping = entity.audioIsLooping ?? true;
|
const isLooping = entity.audioIsLooping ?? true;
|
||||||
|
|
||||||
const [x, y, z] = entity.position ?? [0, 0, 0];
|
const [x, y, z] = entity.position ?? [0, 0, 0];
|
||||||
const { scene, camera } = useThree();
|
const scene = useThree((state) => state.scene);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
const { audioLoader, audioListener } = useAudio();
|
const { audioLoader, audioListener } = useAudio();
|
||||||
const { audioEnabled } = useSettings();
|
const { audioEnabled } = useSettings();
|
||||||
|
|
||||||
|
|
@ -268,8 +279,16 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
try { sound.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
if (is3D) scene.remove(sound);
|
if (is3D) scene.remove(sound);
|
||||||
soundRef.current = null;
|
soundRef.current = null;
|
||||||
isLoadedRef.current = false;
|
isLoadedRef.current = false;
|
||||||
|
|
@ -306,7 +325,9 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
try {
|
try {
|
||||||
sound.play();
|
sound.play();
|
||||||
setupLooping(sound, gen);
|
setupLooping(sound, gen);
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}, gap);
|
}, gap);
|
||||||
} else {
|
} else {
|
||||||
loopGapIntervalRef.current = setTimeout(checkLoop, 100);
|
loopGapIntervalRef.current = setTimeout(checkLoop, 100);
|
||||||
|
|
@ -337,7 +358,9 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
try {
|
try {
|
||||||
sound.play();
|
sound.play();
|
||||||
setupLooping(sound, gen);
|
setupLooping(sound, gen);
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -346,7 +369,9 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
sound.play();
|
sound.play();
|
||||||
setupLooping(sound, gen);
|
setupLooping(sound, gen);
|
||||||
}
|
}
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -373,7 +398,11 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
} else if (!isNowInRange && wasInRange) {
|
} else if (!isNowInRange && wasInRange) {
|
||||||
isInRangeRef.current = false;
|
isInRangeRef.current = false;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
try { sound.stop(); } catch { /* expected */ }
|
try {
|
||||||
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -384,7 +413,11 @@ export const AudioEmitter = memo(function AudioEmitter({
|
||||||
|
|
||||||
if (!audioEnabled) {
|
if (!audioEnabled) {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
try { sound.stop(); } catch { /* expected */ }
|
try {
|
||||||
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
isInRangeRef.current = false;
|
isInRangeRef.current = false;
|
||||||
}
|
}
|
||||||
}, [audioEnabled]);
|
}, [audioEnabled]);
|
||||||
|
|
|
||||||
7
src/components/AudioEnabled.tsx
Normal file
7
src/components/AudioEnabled.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ReactNode, Suspense } from "react";
|
||||||
|
import { useSettings } from "./SettingsProvider";
|
||||||
|
|
||||||
|
export function AudioEnabled({ children }: { children: ReactNode }) {
|
||||||
|
const { audioEnabled } = useSettings();
|
||||||
|
return audioEnabled ? <Suspense>{children}</Suspense> : null;
|
||||||
|
}
|
||||||
|
|
@ -9,17 +9,12 @@ export function Camera({ entity }: { entity: CameraEntity }) {
|
||||||
|
|
||||||
const dataBlock = entity.cameraDataBlock;
|
const dataBlock = entity.cameraDataBlock;
|
||||||
const position = useMemo(
|
const position = useMemo(
|
||||||
() =>
|
() => (entity.position ? new Vector3(...entity.position) : new Vector3()),
|
||||||
entity.position
|
|
||||||
? new Vector3(...entity.position)
|
|
||||||
: new Vector3(),
|
|
||||||
[entity.position],
|
[entity.position],
|
||||||
);
|
);
|
||||||
const rotation = useMemo(
|
const rotation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
entity.rotation
|
entity.rotation ? new Quaternion(...entity.rotation) : new Quaternion(),
|
||||||
? new Quaternion(...entity.rotation)
|
|
||||||
: new Quaternion(),
|
|
||||||
[entity.rotation],
|
[entity.rotation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function useCameras() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CamerasProvider({ children }: { children: ReactNode }) {
|
export function CamerasProvider({ children }: { children: ReactNode }) {
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
const [cameraIndex, setCameraIndex] = useState(-1);
|
const [cameraIndex, setCameraIndex] = useState(-1);
|
||||||
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
|
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
|
||||||
const [initialViewState, setInitialViewState] = useState(() => ({
|
const [initialViewState, setInitialViewState] = useState(() => ({
|
||||||
|
|
@ -53,7 +53,8 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const unregisterCamera = useCallback((camera: CameraEntry) => {
|
const unregisterCamera = useCallback((camera: CameraEntry) => {
|
||||||
setCameraMap((prevCameraMap) => {
|
setCameraMap((prevCameraMap) => {
|
||||||
const { [camera.id]: _removedCamera, ...remainingCameras } = prevCameraMap;
|
const { [camera.id]: _removedCamera, ...remainingCameras } =
|
||||||
|
prevCameraMap;
|
||||||
return remainingCameras;
|
return remainingCameras;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
26
src/components/ChatInput.module.css
Normal file
26
src/components/ChatInput.module.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.InputForm {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0, 50, 60, 0.8);
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid rgba(78, 179, 167, 0.2);
|
||||||
|
border-radius: 0;
|
||||||
|
color: rgb(40, 231, 240);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input::placeholder {
|
||||||
|
color: rgba(44, 172, 181, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input:focus {
|
||||||
|
background: rgba(0, 50, 60, 0.9);
|
||||||
|
}
|
||||||
33
src/components/ChatInput.tsx
Normal file
33
src/components/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { liveConnectionStore } from "../state/liveConnectionStore";
|
||||||
|
import styles from "./ChatInput.module.css";
|
||||||
|
|
||||||
|
export function ChatInput() {
|
||||||
|
const [chatText, setChatText] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = chatText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
liveConnectionStore.getState().sendCommand("messageSent", text);
|
||||||
|
setChatText("");
|
||||||
|
},
|
||||||
|
[chatText],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.InputForm} onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
className={styles.Input}
|
||||||
|
type="text"
|
||||||
|
placeholder="Say something…"
|
||||||
|
value={chatText}
|
||||||
|
onChange={(e) => setChatText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,14 @@ import { useEffect, useRef } from "react";
|
||||||
import { Audio } from "three";
|
import { Audio } from "three";
|
||||||
import { audioToUrl } from "../loaders";
|
import { audioToUrl } from "../loaders";
|
||||||
import { useAudio } from "./AudioContext";
|
import { useAudio } from "./AudioContext";
|
||||||
import { getCachedAudioBuffer, getSoundGeneration, trackSound, untrackSound } from "./AudioEmitter";
|
import {
|
||||||
|
getCachedAudioBuffer,
|
||||||
|
getSoundGeneration,
|
||||||
|
trackSound,
|
||||||
|
untrackSound,
|
||||||
|
} from "./AudioEmitter";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import { useSettings } from "./SettingsProvider";
|
||||||
import { engineStore, useEngineSelector } from "../state";
|
import { engineStore, useEngineSelector } from "../state/engineStore";
|
||||||
import type { ChatMessage } from "../stream/types";
|
import type { ChatMessage } from "../stream/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,8 +18,7 @@ import type { ChatMessage } from "../stream/types";
|
||||||
*/
|
*/
|
||||||
export function ChatSoundPlayer() {
|
export function ChatSoundPlayer() {
|
||||||
const { audioLoader, audioListener } = useAudio();
|
const { audioLoader, audioListener } = useAudio();
|
||||||
const settings = useSettings();
|
const { audioEnabled } = useSettings();
|
||||||
const audioEnabled = settings?.audioEnabled ?? false;
|
|
||||||
const messages = useEngineSelector(
|
const messages = useEngineSelector(
|
||||||
(state) => state.playback.streamSnapshot?.chatMessages,
|
(state) => state.playback.streamSnapshot?.chatMessages,
|
||||||
);
|
);
|
||||||
|
|
@ -24,9 +28,7 @@ export function ChatSoundPlayer() {
|
||||||
const playedSetRef = useRef(new WeakSet<ChatMessage>());
|
const playedSetRef = useRef(new WeakSet<ChatMessage>());
|
||||||
// Track active voice chat sound per sender so a new voice bind from the
|
// Track active voice chat sound per sender so a new voice bind from the
|
||||||
// same player stops their previous one (matching Tribes 2 behavior).
|
// same player stops their previous one (matching Tribes 2 behavior).
|
||||||
const activeBySenderRef = useRef(
|
const activeBySenderRef = useRef(new Map<string, Audio<GainNode>>());
|
||||||
new Map<string, Audio<GainNode>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -58,9 +60,17 @@ export function ChatSoundPlayer() {
|
||||||
if (sender) {
|
if (sender) {
|
||||||
const prev = activeBySender.get(sender);
|
const prev = activeBySender.get(sender);
|
||||||
if (prev) {
|
if (prev) {
|
||||||
try { prev.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
|
prev.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
untrackSound(prev);
|
untrackSound(prev);
|
||||||
try { prev.disconnect(); } catch { /* already disconnected */ }
|
try {
|
||||||
|
prev.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
activeBySender.delete(sender);
|
activeBySender.delete(sender);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +85,11 @@ export function ChatSoundPlayer() {
|
||||||
// Clean up the source node once playback finishes.
|
// Clean up the source node once playback finishes.
|
||||||
sound.source!.onended = () => {
|
sound.source!.onended = () => {
|
||||||
untrackSound(sound);
|
untrackSound(sound);
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
if (sender && activeBySender.get(sender) === sound) {
|
if (sender && activeBySender.get(sender) === sound) {
|
||||||
activeBySender.delete(sender);
|
activeBySender.delete(sender);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/components/ChatWindow.module.css
Normal file
63
src/components/ChatWindow.module.css
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
.ChatContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
pointer-events: auto;
|
||||||
|
border: 1px solid rgba(44, 172, 181, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatWindow {
|
||||||
|
max-height: 12.5em;
|
||||||
|
min-height: 4em;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 50, 60, 0.65);
|
||||||
|
padding: 6px;
|
||||||
|
user-select: text;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
/* Thin scrollbar that doesn't take much space. */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage {
|
||||||
|
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
|
||||||
|
color: rgb(44, 172, 181);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* T2 GuiChatHudProfile fontColors palette (\c0–\c9). */
|
||||||
|
.ChatColor0 {
|
||||||
|
color: rgb(44, 172, 181);
|
||||||
|
}
|
||||||
|
.ChatColor1 {
|
||||||
|
color: rgb(4, 235, 105);
|
||||||
|
}
|
||||||
|
.ChatColor2 {
|
||||||
|
color: rgb(219, 200, 128);
|
||||||
|
}
|
||||||
|
.ChatColor3 {
|
||||||
|
color: rgb(77, 253, 95);
|
||||||
|
}
|
||||||
|
.ChatColor4 {
|
||||||
|
color: rgb(40, 231, 240);
|
||||||
|
}
|
||||||
|
.ChatColor5 {
|
||||||
|
color: rgb(200, 200, 50);
|
||||||
|
}
|
||||||
|
.ChatColor6 {
|
||||||
|
color: rgb(200, 200, 200);
|
||||||
|
}
|
||||||
|
.ChatColor7 {
|
||||||
|
color: rgb(220, 220, 20);
|
||||||
|
}
|
||||||
|
.ChatColor8 {
|
||||||
|
color: rgb(150, 150, 250);
|
||||||
|
}
|
||||||
|
.ChatColor9 {
|
||||||
|
color: rgb(60, 220, 150);
|
||||||
|
}
|
||||||
84
src/components/ChatWindow.tsx
Normal file
84
src/components/ChatWindow.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { lazy, memo, Suspense, useEffect, useRef } from "react";
|
||||||
|
import { useEngineSelector } from "../state/engineStore";
|
||||||
|
import { ChatMessage, ChatSegment } from "../stream/types";
|
||||||
|
import styles from "./ChatWindow.module.css";
|
||||||
|
|
||||||
|
const ChatInput = lazy(() =>
|
||||||
|
import("./ChatInput").then((mod) => ({ default: mod.ChatInput })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const EMPTY_MESSAGES: ChatMessage[] = [];
|
||||||
|
|
||||||
|
/** Map a colorCode to a CSS module class name (c0–c9 GuiChatHudProfile). */
|
||||||
|
const CHAT_COLOR_CLASSES: Record<number, string> = {
|
||||||
|
0: styles.ChatColor0,
|
||||||
|
1: styles.ChatColor1,
|
||||||
|
2: styles.ChatColor2,
|
||||||
|
3: styles.ChatColor3,
|
||||||
|
4: styles.ChatColor4,
|
||||||
|
5: styles.ChatColor5,
|
||||||
|
6: styles.ChatColor6,
|
||||||
|
7: styles.ChatColor7,
|
||||||
|
8: styles.ChatColor8,
|
||||||
|
9: styles.ChatColor9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function segmentColorClass(colorCode: number): string {
|
||||||
|
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatColorClass(msg: ChatMessage): string {
|
||||||
|
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
|
||||||
|
return CHAT_COLOR_CLASSES[msg.colorCode];
|
||||||
|
}
|
||||||
|
// Fallback: default to \c0 (teal). Messages with detected codes (like \c2
|
||||||
|
// for flag events) will match above; \c0 kill messages may lose their null
|
||||||
|
// byte color code, so the correct default for server messages is c0.
|
||||||
|
return CHAT_COLOR_CLASSES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatWindow = memo(function ChatWindow() {
|
||||||
|
const isLive = useEngineSelector(
|
||||||
|
(state) => state.playback.recording?.source === "live",
|
||||||
|
);
|
||||||
|
const messages = useEngineSelector(
|
||||||
|
(state) => state.playback.streamSnapshot?.chatMessages ?? EMPTY_MESSAGES,
|
||||||
|
);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const lastMessageId = messages[messages.length - 1]?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lastMessageId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.ChatContainer}>
|
||||||
|
<div ref={scrollRef} className={styles.ChatWindow}>
|
||||||
|
{messages.map((msg: ChatMessage) => (
|
||||||
|
<div key={msg.id} className={styles.ChatMessage} hidden={!msg.text}>
|
||||||
|
{msg.segments ? (
|
||||||
|
msg.segments.map((seg: ChatSegment, j: number) => (
|
||||||
|
<span key={j} className={segmentColorClass(seg.colorCode)}>
|
||||||
|
{seg.text}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className={chatColorClass(msg)}>
|
||||||
|
{msg.sender ? `${msg.sender}: ` : ""}
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isLive && (
|
||||||
|
<Suspense>
|
||||||
|
<ChatInput />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -471,16 +471,19 @@ export function CloudLayers({ scene }: CloudLayersProps) {
|
||||||
const { data: detailMapList } = useDetailMapList(materialList);
|
const { data: detailMapList } = useDetailMapList(materialList);
|
||||||
|
|
||||||
// From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95
|
// From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95
|
||||||
const visibleDistance = scene.visibleDistance > 0 ? scene.visibleDistance : 500;
|
const visibleDistance =
|
||||||
|
scene.visibleDistance > 0 ? scene.visibleDistance : 500;
|
||||||
const radius = visibleDistance * 0.95;
|
const radius = visibleDistance * 0.95;
|
||||||
|
|
||||||
const cloudSpeeds = useMemo(
|
const cloudSpeeds = useMemo(
|
||||||
() => scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
|
() =>
|
||||||
|
scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
|
||||||
[scene.cloudLayers],
|
[scene.cloudLayers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cloudHeights = useMemo(
|
const cloudHeights = useMemo(
|
||||||
() => scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
|
() =>
|
||||||
|
scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
|
||||||
[scene.cloudLayers],
|
[scene.cloudLayers],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -536,7 +539,7 @@ export function CloudLayers({ scene }: CloudLayersProps) {
|
||||||
{layers.map((layer, i) => {
|
{layers.map((layer, i) => {
|
||||||
const url = textureToUrl(layer.texture);
|
const url = textureToUrl(layer.texture);
|
||||||
return (
|
return (
|
||||||
<Suspense key={i} fallback={null}>
|
<Suspense key={i}>
|
||||||
<CloudLayer
|
<CloudLayer
|
||||||
textureUrl={url}
|
textureUrl={url}
|
||||||
radius={radius}
|
radius={radius}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,12 @@ export function CopyCoordinatesButton({
|
||||||
cameraRef,
|
cameraRef,
|
||||||
missionName,
|
missionName,
|
||||||
missionType,
|
missionType,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
cameraRef: RefObject<Camera | null>;
|
cameraRef: RefObject<Camera | null>;
|
||||||
missionName: string;
|
missionName: string;
|
||||||
missionType: string;
|
missionType: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { fogEnabled } = useSettings();
|
const { fogEnabled } = useSettings();
|
||||||
const [showCopied, setShowCopied] = useState(false);
|
const [showCopied, setShowCopied] = useState(false);
|
||||||
|
|
@ -57,15 +59,16 @@ export function CopyCoordinatesButton({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.Root}
|
className={styles.Root}
|
||||||
aria-label="Copy coordinates URL"
|
aria-label="Link to coordinates"
|
||||||
title="Copy coordinates URL"
|
title="Copy the current coordinates to URL"
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
|
disabled={disabled}
|
||||||
data-copied={showCopied ? "true" : "false"}
|
data-copied={showCopied ? "true" : "false"}
|
||||||
id="copyCoordinatesButton"
|
id="copyCoordinatesButton"
|
||||||
>
|
>
|
||||||
<FaMapPin className={styles.MapPin} />
|
<FaMapPin className={styles.MapPin} />
|
||||||
<FaClipboardCheck className={styles.ClipboardCheck} />
|
<FaClipboardCheck className={styles.ClipboardCheck} />
|
||||||
<span className={styles.ButtonLabel}> Copy coordinates URL</span>
|
<span className={styles.ButtonLabel}> Link to coordinates</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Stats, Html } from "@react-three/drei";
|
import { Stats, Html } from "@react-three/drei";
|
||||||
import { useDebug } from "./SettingsProvider";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { AxesHelper } from "three";
|
import { AxesHelper } from "three";
|
||||||
import styles from "./DebugElements.module.css";
|
import styles from "./DebugElements.module.css";
|
||||||
|
|
||||||
export function DebugElements() {
|
export function DebugElements() {
|
||||||
const { debugMode } = useDebug();
|
|
||||||
const axesRef = useRef<AxesHelper>(null);
|
const axesRef = useRef<AxesHelper>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -16,7 +14,7 @@ export function DebugElements() {
|
||||||
axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)");
|
axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)");
|
||||||
});
|
});
|
||||||
|
|
||||||
return debugMode ? (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stats className={styles.StatsPanel} />
|
<Stats className={styles.StatsPanel} />
|
||||||
<axesHelper ref={axesRef} args={[70]} renderOrder={999}>
|
<axesHelper ref={axesRef} args={[70]} renderOrder={999}>
|
||||||
|
|
@ -43,5 +41,5 @@ export function DebugElements() {
|
||||||
</span>
|
</span>
|
||||||
</Html>
|
</Html>
|
||||||
</>
|
</>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
src/components/DebugEnabled.tsx
Normal file
8
src/components/DebugEnabled.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { ReactNode, Suspense } from "react";
|
||||||
|
import { useDebug } from "./SettingsProvider";
|
||||||
|
|
||||||
|
export function DebugEnabled({ children }: { children: ReactNode }) {
|
||||||
|
const { debugMode } = useDebug();
|
||||||
|
|
||||||
|
return debugMode ? <Suspense>{children}</Suspense> : null;
|
||||||
|
}
|
||||||
50
src/components/DebugSuspense.tsx
Normal file
50
src/components/DebugSuspense.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Suspense, useEffect, type ReactNode } from "react";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
|
|
||||||
|
const log = createLogger("DebugSuspense");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspense wrapper that logs when a component suspends and resolves.
|
||||||
|
* Use in place of `<Suspense>` during debugging to track async loading.
|
||||||
|
*/
|
||||||
|
export function DebugSuspense({
|
||||||
|
name,
|
||||||
|
fallback = null,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
name={name}
|
||||||
|
fallback={
|
||||||
|
<DebugSuspenseFallback name={name}>{fallback}</DebugSuspenseFallback>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DebugSuspenseResolved name={name} />
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugSuspenseFallback({
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
log.debug("🛑 SUSPENDED: %s", name);
|
||||||
|
}, [name]);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugSuspenseResolved({ name }: { name: string }) {
|
||||||
|
useEffect(() => {
|
||||||
|
log.debug("✅ RESOLVED: %s", name);
|
||||||
|
}, [name]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
.Root {
|
.Root {
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export function DemoPlaybackControls() {
|
||||||
className={styles.PlayPause}
|
className={styles.PlayPause}
|
||||||
onClick={isPlaying ? pause : play}
|
onClick={isPlaying ? pause : play}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
|
autoFocus
|
||||||
>
|
>
|
||||||
{isPlaying ? "\u275A\u275A" : "\u25B6"}
|
{isPlaying ? "\u275A\u275A" : "\u25B6"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
import { lazy, memo, Suspense, useRef } from "react";
|
import { lazy, memo, useRef } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import type { Group } from "three";
|
import type { Group } from "three";
|
||||||
import type {
|
import type {
|
||||||
GameEntity,
|
GameEntity,
|
||||||
ShapeEntity as ShapeEntityType,
|
ShapeEntity as ShapeEntityType,
|
||||||
ForceFieldBareEntity as ForceFieldBareEntityType,
|
|
||||||
PlayerEntity as PlayerEntityType,
|
|
||||||
ExplosionEntity as ExplosionEntityType,
|
|
||||||
TracerEntity as TracerEntityType,
|
|
||||||
SpriteEntity as SpriteEntityType,
|
|
||||||
AudioEmitterEntity as AudioEmitterEntityType,
|
|
||||||
} from "../state/gameEntityTypes";
|
} from "../state/gameEntityTypes";
|
||||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
import { ShapeRenderer, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeRenderer } from "./GenericShape";
|
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
import type { StaticShapeType } from "./ShapeInfoProvider";
|
import type { StaticShapeType } from "./ShapeInfoProvider";
|
||||||
|
import { DebugSuspense } from "./DebugSuspense";
|
||||||
|
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
|
||||||
import { FloatingLabel } from "./FloatingLabel";
|
import { FloatingLabel } from "./FloatingLabel";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import { useSettings } from "./SettingsProvider";
|
||||||
import { Camera } from "./Camera";
|
import { Camera } from "./Camera";
|
||||||
|
|
@ -22,44 +17,51 @@ import { WayPoint } from "./WayPoint";
|
||||||
import { TerrainBlock } from "./TerrainBlock";
|
import { TerrainBlock } from "./TerrainBlock";
|
||||||
import { InteriorInstance } from "./InteriorInstance";
|
import { InteriorInstance } from "./InteriorInstance";
|
||||||
import { Sky } from "./Sky";
|
import { Sky } from "./Sky";
|
||||||
|
import { AudioEnabled } from "./AudioEnabled";
|
||||||
import type { TorqueObject } from "../torqueScript";
|
import type { TorqueObject } from "../torqueScript";
|
||||||
|
|
||||||
// Lazy-loaded heavy renderers
|
function createLazy<K extends string>(
|
||||||
const PlayerModel = lazy(() =>
|
name: K,
|
||||||
import("./PlayerModel").then((mod) => ({ default: mod.PlayerModel })),
|
loader: () => Promise<Record<K, React.ComponentType<any>>>,
|
||||||
|
): React.ComponentType<{ entity: GameEntity }> {
|
||||||
|
const LazyComponent = lazy(() =>
|
||||||
|
loader().then((mod) => {
|
||||||
|
const NamedComponent = mod[name];
|
||||||
|
return { default: NamedComponent };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
const LazyComponentWithSuspense = ({ entity }: { entity: GameEntity }) => {
|
||||||
|
return (
|
||||||
|
<DebugSuspense name={`${name}:${entity.id}`}>
|
||||||
|
<LazyComponent entity={entity} />
|
||||||
|
</DebugSuspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ExplosionShape = lazy(() =>
|
LazyComponentWithSuspense.displayName = `createLazy(${name})`;
|
||||||
import("./ShapeModel").then((mod) => ({
|
return LazyComponentWithSuspense;
|
||||||
default: mod.ExplosionShape,
|
}
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const TracerProjectile = lazy(() =>
|
const PlayerModel = createLazy("PlayerModel", () => import("./PlayerModel"));
|
||||||
import("./Projectiles").then((mod) => ({
|
const ExplosionShape = createLazy(
|
||||||
default: mod.TracerProjectile,
|
"ExplosionShape",
|
||||||
})),
|
() => import("./ShapeModel"),
|
||||||
);
|
);
|
||||||
|
const TracerProjectile = createLazy(
|
||||||
const SpriteProjectile = lazy(() =>
|
"TracerProjectile",
|
||||||
import("./Projectiles").then((mod) => ({
|
() => import("./Projectiles"),
|
||||||
default: mod.SpriteProjectile,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
const SpriteProjectile = createLazy(
|
||||||
const ForceFieldBareRenderer = lazy(() =>
|
"SpriteProjectile",
|
||||||
import("./ForceFieldBare").then((mod) => ({
|
() => import("./Projectiles"),
|
||||||
default: mod.ForceFieldBare,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
const ForceFieldBare = createLazy(
|
||||||
const AudioEmitter = lazy(() =>
|
"ForceFieldBare",
|
||||||
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
|
() => import("./ForceFieldBare"),
|
||||||
);
|
|
||||||
|
|
||||||
const WaterBlock = lazy(() =>
|
|
||||||
import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
|
|
||||||
);
|
);
|
||||||
|
const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
|
||||||
|
const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
|
||||||
|
const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
|
||||||
|
|
||||||
const TEAM_NAMES: Record<number, string> = {
|
const TEAM_NAMES: Record<number, string> = {
|
||||||
1: "Storm",
|
1: "Storm",
|
||||||
|
|
@ -81,17 +83,21 @@ export const EntityRenderer = memo(function EntityRenderer({
|
||||||
case "Shape":
|
case "Shape":
|
||||||
return <ShapeEntity entity={entity} />;
|
return <ShapeEntity entity={entity} />;
|
||||||
case "ForceFieldBare":
|
case "ForceFieldBare":
|
||||||
return <ForceFieldBareEntity entity={entity} />;
|
return <ForceFieldBare entity={entity} />;
|
||||||
case "Player":
|
case "Player":
|
||||||
return <PlayerEntity entity={entity} />;
|
return <PlayerModel entity={entity} />;
|
||||||
case "Explosion":
|
case "Explosion":
|
||||||
return <ExplosionEntity entity={entity} />;
|
return <ExplosionShape entity={entity} />;
|
||||||
case "Tracer":
|
case "Tracer":
|
||||||
return <TracerEntity entity={entity} />;
|
return <TracerProjectile entity={entity} />;
|
||||||
case "Sprite":
|
case "Sprite":
|
||||||
return <SpriteEntity entity={entity} />;
|
return <SpriteProjectile entity={entity} />;
|
||||||
case "AudioEmitter":
|
case "AudioEmitter":
|
||||||
return <AudioEntity entity={entity} />;
|
return (
|
||||||
|
<AudioEnabled>
|
||||||
|
<AudioEmitter entity={entity} />
|
||||||
|
</AudioEnabled>
|
||||||
|
);
|
||||||
case "Camera":
|
case "Camera":
|
||||||
return <Camera entity={entity} />;
|
return <Camera entity={entity} />;
|
||||||
case "WayPoint":
|
case "WayPoint":
|
||||||
|
|
@ -101,25 +107,21 @@ export const EntityRenderer = memo(function EntityRenderer({
|
||||||
case "InteriorInstance":
|
case "InteriorInstance":
|
||||||
return <InteriorInstance scene={entity.interiorData} />;
|
return <InteriorInstance scene={entity.interiorData} />;
|
||||||
case "Sky":
|
case "Sky":
|
||||||
return <Sky scene={entity.skyData} />;
|
return <Sky entity={entity} />;
|
||||||
case "Sun":
|
case "Sun":
|
||||||
// Sun lighting is handled by SceneLighting (rendered outside EntityScene)
|
// Sun lighting is handled by SceneLighting (rendered outside EntityScene)
|
||||||
return null;
|
return null;
|
||||||
case "WaterBlock":
|
case "WaterBlock":
|
||||||
return (
|
return <WaterBlock entity={entity} />;
|
||||||
<Suspense fallback={null}>
|
|
||||||
<WaterBlock scene={entity.waterData} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
case "MissionArea":
|
case "MissionArea":
|
||||||
return null;
|
return null;
|
||||||
case "None":
|
case "None":
|
||||||
return null;
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Shape Entity ──
|
|
||||||
|
|
||||||
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
||||||
const { animationEnabled } = useSettings();
|
const { animationEnabled } = useSettings();
|
||||||
const groupRef = useRef<Group>(null);
|
const groupRef = useRef<Group>(null);
|
||||||
|
|
@ -131,7 +133,9 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
||||||
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
|
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entity.shapeName) return null;
|
if (!entity.shapeName) {
|
||||||
|
throw new Error(`Shape entity missing shapeName: ${entity.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
const torqueObject = entity.runtimeObject as TorqueObject | undefined;
|
const torqueObject = entity.runtimeObject as TorqueObject | undefined;
|
||||||
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
|
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
|
||||||
|
|
@ -156,7 +160,10 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
||||||
type={shapeType}
|
type={shapeType}
|
||||||
>
|
>
|
||||||
<group ref={entity.rotate ? groupRef : undefined}>
|
<group ref={entity.rotate ? groupRef : undefined}>
|
||||||
<ShapeRenderer loadingColor={loadingColor} streamEntity={torqueObject ? undefined : entity}>
|
<ShapeRenderer
|
||||||
|
loadingColor={loadingColor}
|
||||||
|
streamEntity={torqueObject ? undefined : entity}
|
||||||
|
>
|
||||||
{flagLabel ? (
|
{flagLabel ? (
|
||||||
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
|
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -172,92 +179,26 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
||||||
</group>
|
</group>
|
||||||
</ShapeInfoProvider>
|
</ShapeInfoProvider>
|
||||||
)}
|
)}
|
||||||
|
{entity.weaponShape && (
|
||||||
|
<ShapeErrorBoundary
|
||||||
|
fallback={
|
||||||
|
<ShapePlaceholder color="red" label={entity.weaponShape} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DebugSuspense
|
||||||
|
name={`Weapon:${entity.id}/${entity.weaponShape}`}
|
||||||
|
fallback={
|
||||||
|
<ShapePlaceholder color="cyan" label={entity.weaponShape} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WeaponModel
|
||||||
|
shapeName={entity.weaponShape}
|
||||||
|
playerShapeName={entity.shapeName}
|
||||||
|
/>
|
||||||
|
</DebugSuspense>
|
||||||
|
</ShapeErrorBoundary>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
</ShapeInfoProvider>
|
</ShapeInfoProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Force Field Entity ──
|
|
||||||
|
|
||||||
function ForceFieldBareEntity({ entity }: { entity: ForceFieldBareEntityType }) {
|
|
||||||
if (!entity.forceFieldData) return null;
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ForceFieldBareRenderer
|
|
||||||
data={entity.forceFieldData}
|
|
||||||
scale={entity.forceFieldData.dimensions}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Player Entity ──
|
|
||||||
|
|
||||||
function PlayerEntity({ entity }: { entity: PlayerEntityType }) {
|
|
||||||
if (!entity.shapeName) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PlayerModel entity={entity} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Explosion Entity ──
|
|
||||||
|
|
||||||
function ExplosionEntity({ entity }: { entity: ExplosionEntityType }) {
|
|
||||||
const playback = streamPlaybackStore.getState().playback;
|
|
||||||
|
|
||||||
// ExplosionShape still expects a StreamEntity-shaped object.
|
|
||||||
// Adapt minimally until that component is also refactored.
|
|
||||||
const streamEntity = {
|
|
||||||
id: entity.id,
|
|
||||||
type: "Explosion" as const,
|
|
||||||
dataBlock: entity.shapeName,
|
|
||||||
position: entity.position,
|
|
||||||
rotation: entity.rotation,
|
|
||||||
faceViewer: entity.faceViewer,
|
|
||||||
explosionDataBlockId: entity.explosionDataBlockId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!entity.shapeName || !playback) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ExplosionShape entity={streamEntity as any} playback={playback} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tracer Entity ──
|
|
||||||
|
|
||||||
function TracerEntity({ entity }: { entity: TracerEntityType }) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<TracerProjectile entity={entity} visual={entity.visual} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sprite Entity ──
|
|
||||||
|
|
||||||
function SpriteEntity({ entity }: { entity: SpriteEntityType }) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SpriteProjectile visual={entity.visual} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audio Entity ──
|
|
||||||
|
|
||||||
function AudioEntity({ entity }: { entity: AudioEmitterEntityType }) {
|
|
||||||
const { audioEnabled } = useSettings();
|
|
||||||
if (!entity.audioFileName || !audioEnabled) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AudioEmitter entity={entity} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import { lazy, memo, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useRef, useState, useMemo } from "react";
|
||||||
import { Quaternion } from "three";
|
import { Quaternion } from "three";
|
||||||
import type { Group } from "three";
|
import type { Group } from "three";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useAllGameEntities } from "../state";
|
import { useAllGameEntities } from "../state/gameEntityStore";
|
||||||
import type { GameEntity, PositionedEntity, PlayerEntity } from "../state/gameEntityTypes";
|
import type {
|
||||||
|
GameEntity,
|
||||||
|
PositionedEntity,
|
||||||
|
PlayerEntity,
|
||||||
|
} from "../state/gameEntityTypes";
|
||||||
import { isSceneEntity } from "../state/gameEntityTypes";
|
import { isSceneEntity } from "../state/gameEntityTypes";
|
||||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||||
import { EntityRenderer } from "./EntityRenderer";
|
import { EntityRenderer } from "./EntityRenderer";
|
||||||
|
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
|
||||||
import { PlayerNameplate } from "./PlayerNameplate";
|
import { PlayerNameplate } from "./PlayerNameplate";
|
||||||
import { FlagMarker } from "./FlagMarker";
|
import { FlagMarker } from "./FlagMarker";
|
||||||
import { FloatingLabel } from "./FloatingLabel";
|
|
||||||
import { entityTypeColor } from "../stream/playbackUtils";
|
import { entityTypeColor } from "../stream/playbackUtils";
|
||||||
import { useDebug } from "./SettingsProvider";
|
import { useEngineSelector } from "../state/engineStore";
|
||||||
import { useEngineSelector } from "../state";
|
|
||||||
|
|
||||||
|
|
||||||
const WeaponModel = lazy(() =>
|
|
||||||
import("./ShapeModel").then((mod) => ({
|
|
||||||
default: mod.WeaponModel,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ONE rendering component tree for all game entities.
|
* The ONE rendering component tree for all game entities.
|
||||||
|
|
@ -27,39 +23,28 @@ const WeaponModel = lazy(() =>
|
||||||
* Data sources (mission .mis, demo .rec, live server) are controllers that
|
* Data sources (mission .mis, demo .rec, live server) are controllers that
|
||||||
* populate the store — this component doesn't know or care which is active.
|
* populate the store — this component doesn't know or care which is active.
|
||||||
*/
|
*/
|
||||||
export function EntityScene({ missionType }: { missionType?: string }) {
|
export function EntityScene() {
|
||||||
const debug = useDebug();
|
|
||||||
const debugMode = debug?.debugMode ?? false;
|
|
||||||
|
|
||||||
const rootRef = useCallback((node: Group | null) => {
|
const rootRef = useCallback((node: Group | null) => {
|
||||||
streamPlaybackStore.setState({ root: node });
|
streamPlaybackStore.setState({ root: node });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={rootRef}>
|
<group ref={rootRef}>
|
||||||
<EntityLayer missionType={missionType} debugMode={debugMode} />
|
<EntityLayer />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders all game entities. Uses an ID-stable selector so the component
|
/** Renders all game entities. Uses an ID-stable selector so the component
|
||||||
* only re-renders when entities are added or removed, not when their
|
* only re-renders when entities are added or removed, not when their
|
||||||
* fields change. Entity references are cached so that once an entity
|
* fields change. */
|
||||||
* renders and loads resources via Suspense, it keeps its reference stable. */
|
const EntityLayer = memo(function EntityLayer() {
|
||||||
const EntityLayer = memo(function EntityLayer({
|
|
||||||
missionType,
|
|
||||||
debugMode,
|
|
||||||
}: {
|
|
||||||
missionType?: string;
|
|
||||||
debugMode: boolean;
|
|
||||||
}) {
|
|
||||||
const entities = useAllGameEntities();
|
const entities = useAllGameEntities();
|
||||||
|
|
||||||
// Cache entity references by ID so that in-place field mutations
|
// Cache entity references by ID so that in-place field mutations
|
||||||
// (threads, colors, weapon shape) don't cause React to see a new
|
// (threads, colors, weapon shape) don't cause unnecessary remounts.
|
||||||
// object and remount Suspense boundaries. The cache IS updated when
|
// The cache IS updated when the store provides a genuinely new object
|
||||||
// the store provides a genuinely new object reference (identity
|
// reference (identity rebuild: armor change, datablock change, etc.).
|
||||||
// rebuild: armor change, datablock change, etc.).
|
|
||||||
const cacheRef = useRef(new Map<string, GameEntity>());
|
const cacheRef = useRef(new Map<string, GameEntity>());
|
||||||
const cache = cacheRef.current;
|
const cache = cacheRef.current;
|
||||||
|
|
||||||
|
|
@ -75,29 +60,10 @@ const EntityLayer = memo(function EntityLayer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const result: GameEntity[] = [];
|
|
||||||
const lowerType = missionType?.toLowerCase();
|
|
||||||
for (const entity of cache.values()) {
|
|
||||||
if (lowerType && entity.missionTypesList) {
|
|
||||||
const types = new Set(
|
|
||||||
entity.missionTypesList
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
if (types.size > 0 && !types.has(lowerType)) continue;
|
|
||||||
}
|
|
||||||
result.push(entity);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [entities, missionType]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filtered.map((entity) => (
|
{[...cache.values()].map((entity) => (
|
||||||
<EntityWrapper key={entity.id} entity={entity} debugMode={debugMode} />
|
<EntityWrapper key={entity.id} entity={entity} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -105,13 +71,11 @@ const EntityLayer = memo(function EntityLayer({
|
||||||
|
|
||||||
const EntityWrapper = memo(function EntityWrapper({
|
const EntityWrapper = memo(function EntityWrapper({
|
||||||
entity,
|
entity,
|
||||||
debugMode,
|
|
||||||
}: {
|
}: {
|
||||||
entity: GameEntity;
|
entity: GameEntity;
|
||||||
debugMode: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
// Scene infrastructure handles its own positioning — render directly.
|
// Scene infrastructure handles its own positioning and Suspense — render
|
||||||
// The named group allows the interpolation loop to identify and skip them.
|
// directly. The named group allows the interpolation loop to skip them.
|
||||||
if (isSceneEntity(entity)) {
|
if (isSceneEntity(entity)) {
|
||||||
return (
|
return (
|
||||||
<group name={entity.id}>
|
<group name={entity.id}>
|
||||||
|
|
@ -123,14 +87,13 @@ const EntityWrapper = memo(function EntityWrapper({
|
||||||
if (entity.renderType === "None") return null;
|
if (entity.renderType === "None") return null;
|
||||||
|
|
||||||
// From here, entity is a PositionedEntity
|
// From here, entity is a PositionedEntity
|
||||||
return <PositionedEntityWrapper entity={entity} debugMode={debugMode} />;
|
return <PositionedEntityWrapper entity={entity} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Renders the player nameplate, subscribing to controlPlayerGhostId
|
/** Renders the player nameplate, subscribing to controlPlayerGhostId
|
||||||
* internally so that PositionedEntityWrapper doesn't need to. This keeps
|
* internally so that PositionedEntityWrapper doesn't need to. Keeps
|
||||||
* engine store mutations from triggering synchronous selector evaluations
|
* engine store mutations from triggering synchronous selector evaluations
|
||||||
* on every positioned entity (which was starving Suspense retries for
|
* on every positioned entity. */
|
||||||
* shape GLB loading). */
|
|
||||||
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
|
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
|
||||||
const controlPlayerGhostId = useEngineSelector(
|
const controlPlayerGhostId = useEngineSelector(
|
||||||
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
|
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
|
||||||
|
|
@ -146,13 +109,19 @@ function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
|
||||||
function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
|
function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
|
||||||
const flagRef = useRef(false);
|
const flagRef = useRef(false);
|
||||||
const [isFlag, setIsFlag] = useState(() => {
|
const [isFlag, setIsFlag] = useState(() => {
|
||||||
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
|
const flags =
|
||||||
|
"targetRenderFlags" in entity
|
||||||
|
? (entity.targetRenderFlags as number | undefined)
|
||||||
|
: undefined;
|
||||||
return ((flags ?? 0) & 0x2) !== 0;
|
return ((flags ?? 0) & 0x2) !== 0;
|
||||||
});
|
});
|
||||||
flagRef.current = isFlag;
|
flagRef.current = isFlag;
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
|
const flags =
|
||||||
|
"targetRenderFlags" in entity
|
||||||
|
? (entity.targetRenderFlags as number | undefined)
|
||||||
|
: undefined;
|
||||||
const nowFlag = ((flags ?? 0) & 0x2) !== 0;
|
const nowFlag = ((flags ?? 0) & 0x2) !== 0;
|
||||||
if (nowFlag !== flagRef.current) {
|
if (nowFlag !== flagRef.current) {
|
||||||
flagRef.current = nowFlag;
|
flagRef.current = nowFlag;
|
||||||
|
|
@ -161,19 +130,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isFlag) return null;
|
if (!isFlag) return null;
|
||||||
return (
|
return <FlagMarker entity={entity} />;
|
||||||
<Suspense fallback={null}>
|
|
||||||
<FlagMarker entity={entity} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PositionedEntityWrapper({
|
function PositionedEntityWrapper({
|
||||||
entity,
|
entity,
|
||||||
debugMode,
|
|
||||||
}: {
|
}: {
|
||||||
entity: PositionedEntity;
|
entity: PositionedEntity;
|
||||||
debugMode: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const position = entity.position;
|
const position = entity.position;
|
||||||
const scale = entity.scale;
|
const scale = entity.scale;
|
||||||
|
|
@ -187,7 +150,12 @@ function PositionedEntityWrapper({
|
||||||
// Entities without a resolved shape get a wireframe placeholder.
|
// Entities without a resolved shape get a wireframe placeholder.
|
||||||
if (entity.renderType === "Shape" && !entity.shapeName) {
|
if (entity.renderType === "Shape" && !entity.shapeName) {
|
||||||
return (
|
return (
|
||||||
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}>
|
<group
|
||||||
|
name={entity.id}
|
||||||
|
position={position}
|
||||||
|
quaternion={quaternion}
|
||||||
|
scale={scale}
|
||||||
|
>
|
||||||
<mesh>
|
<mesh>
|
||||||
<sphereGeometry args={[0.3, 6, 4]} />
|
<sphereGeometry args={[0.3, 6, 4]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
|
|
@ -195,7 +163,6 @@ function PositionedEntityWrapper({
|
||||||
wireframe
|
wireframe
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{debugMode && <MissingShapeLabel entity={entity} />}
|
|
||||||
<FlagMarkerSlot entity={entity} />
|
<FlagMarkerSlot entity={entity} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|
@ -212,82 +179,22 @@ function PositionedEntityWrapper({
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
|
|
||||||
const shapeName = "shapeName" in entity ? entity.shapeName : undefined;
|
|
||||||
const weaponShape = "weaponShape" in entity ? entity.weaponShape : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}>
|
<group
|
||||||
|
name={entity.id}
|
||||||
|
position={position}
|
||||||
|
quaternion={quaternion}
|
||||||
|
scale={scale}
|
||||||
|
>
|
||||||
<group name="model">
|
<group name="model">
|
||||||
<ShapeErrorBoundary fallback={fallback}>
|
<ShapeErrorBoundary fallback={fallback}>
|
||||||
<Suspense fallback={fallback}>
|
|
||||||
<EntityRenderer entity={entity} />
|
<EntityRenderer entity={entity} />
|
||||||
</Suspense>
|
|
||||||
</ShapeErrorBoundary>
|
</ShapeErrorBoundary>
|
||||||
{isPlayer && (
|
{isPlayer && (
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PlayerNameplateIfVisible entity={entity as PlayerEntity} />
|
<PlayerNameplateIfVisible entity={entity as PlayerEntity} />
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
<FlagMarkerSlot entity={entity} />
|
<FlagMarkerSlot entity={entity} />
|
||||||
{debugMode && !shapeName && entity.renderType !== "Shape" && (
|
|
||||||
<MissingShapeLabel entity={entity} />
|
|
||||||
)}
|
|
||||||
</group>
|
</group>
|
||||||
{weaponShape && shapeName && !isPlayer && (
|
|
||||||
<group name="weapon">
|
|
||||||
<ShapeErrorBoundary fallback={null}>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<WeaponModel
|
|
||||||
shapeName={weaponShape}
|
|
||||||
playerShapeName={shapeName}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</ShapeErrorBoundary>
|
|
||||||
</group>
|
|
||||||
)}
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MissingShapeLabel({ entity }: { entity: GameEntity }) {
|
|
||||||
const bits: string[] = [];
|
|
||||||
bits.push(`${entity.id} (${entity.className})`);
|
|
||||||
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
|
|
||||||
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
|
|
||||||
bits.push(
|
|
||||||
entity.shapeHint
|
|
||||||
? `shapeHint ${entity.shapeHint}`
|
|
||||||
: "shapeHint <none resolved>",
|
|
||||||
);
|
|
||||||
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Error boundary that renders a fallback when shape loading fails. */
|
|
||||||
import { Component } from "react";
|
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
|
||||||
|
|
||||||
export class ShapeErrorBoundary extends Component<
|
|
||||||
{ children: ReactNode; fallback: ReactNode },
|
|
||||||
{ hasError: boolean }
|
|
||||||
> {
|
|
||||||
state = { hasError: false };
|
|
||||||
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
||||||
console.warn(
|
|
||||||
"[entity] Shape load failed:",
|
|
||||||
error.message,
|
|
||||||
info.componentStack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return this.props.fallback;
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { Html } from "@react-three/drei";
|
import { Html } from "@react-three/drei";
|
||||||
import { Group, Vector3 } from "three";
|
import { Group, Vector3 } from "three";
|
||||||
import { textureToUrl } from "../loaders";
|
import { textureToUrl } from "../loaders";
|
||||||
|
|
||||||
interface FlagEntity {
|
interface FlagEntity {
|
||||||
id: string;
|
id: string;
|
||||||
iffColor?: { r: number; g: number; b: number };
|
iffColor?: { r: number; g: number; b: number };
|
||||||
|
|
@ -10,7 +11,6 @@ interface FlagEntity {
|
||||||
import styles from "./FlagMarker.module.css";
|
import styles from "./FlagMarker.module.css";
|
||||||
|
|
||||||
const FLAG_ICON_HEIGHT = 1.5;
|
const FLAG_ICON_HEIGHT = 1.5;
|
||||||
|
|
||||||
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
|
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
|
||||||
|
|
||||||
const _tmpVec = new Vector3();
|
const _tmpVec = new Vector3();
|
||||||
|
|
@ -24,7 +24,7 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
|
||||||
const markerRef = useRef<Group>(null);
|
const markerRef = useRef<Group>(null);
|
||||||
const iconRef = useRef<HTMLDivElement>(null);
|
const iconRef = useRef<HTMLDivElement>(null);
|
||||||
const distRef = useRef<HTMLSpanElement>(null);
|
const distRef = useRef<HTMLSpanElement>(null);
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
// Tint imperatively — iffColor is mutated in-place by streaming playback.
|
// Tint imperatively — iffColor is mutated in-place by streaming playback.
|
||||||
|
|
@ -52,10 +52,12 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
|
||||||
<div
|
<div
|
||||||
ref={iconRef}
|
ref={iconRef}
|
||||||
className={styles.Icon}
|
className={styles.Icon}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
backgroundColor: initialColor,
|
backgroundColor: initialColor,
|
||||||
"--flag-icon-url": `url(${FLAG_ICON_URL})`,
|
"--flag-icon-url": `url(${FLAG_ICON_URL})`,
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,14 @@ export function fogStateFromScene(sky: SceneSky): FogState {
|
||||||
|
|
||||||
const enabled = visibleDistance > fogDistance;
|
const enabled = visibleDistance > fogDistance;
|
||||||
|
|
||||||
return { fogDistance, visibleDistance, fogColor, fogVolumes, fogLine, enabled };
|
return {
|
||||||
|
fogDistance,
|
||||||
|
visibleDistance,
|
||||||
|
fogColor,
|
||||||
|
fogVolumes,
|
||||||
|
fogLine,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
0
src/components/FollowControls.tsx
Normal file
0
src/components/FollowControls.tsx
Normal file
|
|
@ -1,4 +1,5 @@
|
||||||
import { Suspense, useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { DebugSuspense } from "./DebugSuspense";
|
||||||
import { useTexture } from "@react-three/drei";
|
import { useTexture } from "@react-three/drei";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,7 +11,10 @@ import {
|
||||||
RepeatWrapping,
|
RepeatWrapping,
|
||||||
} from "three";
|
} from "three";
|
||||||
import type { Texture } from "three";
|
import type { Texture } from "three";
|
||||||
import type { ForceFieldData } from "../state/gameEntityTypes";
|
import type {
|
||||||
|
ForceFieldBareEntity,
|
||||||
|
ForceFieldData,
|
||||||
|
} from "../state/gameEntityTypes";
|
||||||
import { textureToUrl } from "../loaders";
|
import { textureToUrl } from "../loaders";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import { useSettings } from "./SettingsProvider";
|
||||||
import {
|
import {
|
||||||
|
|
@ -126,13 +130,10 @@ function ForceFieldMesh({
|
||||||
* Renders a ForceFieldBare from pre-resolved ForceFieldData.
|
* Renders a ForceFieldBare from pre-resolved ForceFieldData.
|
||||||
* Used by the unified EntityRenderer — does NOT read from TorqueObject/datablock.
|
* Used by the unified EntityRenderer — does NOT read from TorqueObject/datablock.
|
||||||
*/
|
*/
|
||||||
export function ForceFieldBare({
|
export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) {
|
||||||
data,
|
const data = entity.forceFieldData;
|
||||||
scale,
|
const scale = data.dimensions;
|
||||||
}: {
|
|
||||||
data: ForceFieldData;
|
|
||||||
scale: [number, number, number];
|
|
||||||
}) {
|
|
||||||
const textureUrls = useMemo(
|
const textureUrls = useMemo(
|
||||||
() => data.textures.map((t) => textureToUrl(t)),
|
() => data.textures.map((t) => textureToUrl(t)),
|
||||||
[data.textures],
|
[data.textures],
|
||||||
|
|
@ -149,7 +150,8 @@ export function ForceFieldBare({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<DebugSuspense
|
||||||
|
name={`ForceField`}
|
||||||
fallback={
|
fallback={
|
||||||
<ForceFieldFallback
|
<ForceFieldFallback
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
|
@ -159,6 +161,6 @@ export function ForceFieldBare({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ForceFieldMesh scale={scale} data={data} />
|
<ForceFieldMesh scale={scale} data={data} />
|
||||||
</Suspense>
|
</DebugSuspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,33 @@
|
||||||
/* Shared button base for dialog actions (server browser, map info, etc.). */
|
.Dialog {
|
||||||
|
position: relative;
|
||||||
|
max-width: calc(100dvw - 40px);
|
||||||
|
max-height: calc(100dvh - 40px);
|
||||||
|
background: rgba(20, 37, 38, 0.8);
|
||||||
|
border: 1px solid rgba(65, 131, 139, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 50px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 0 60px rgba(1, 7, 13, 0.6);
|
||||||
|
color: #b0d5c9;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
outline: none;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.DialogButton {
|
.DialogButton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
|
|
@ -2,6 +2,7 @@ import { memo, Suspense, useEffect, useMemo, useRef } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { useGLTF, useTexture } from "@react-three/drei";
|
import { useGLTF, useTexture } from "@react-three/drei";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
|
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
|
||||||
import {
|
import {
|
||||||
MeshStandardMaterial,
|
MeshStandardMaterial,
|
||||||
|
|
@ -21,7 +22,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
import { setupTexture } from "../textureUtils";
|
import { setupTexture } from "../textureUtils";
|
||||||
import { useDebug, useSettings } from "./SettingsProvider";
|
import { useDebug, useSettings } from "./SettingsProvider";
|
||||||
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
||||||
import { useEngineSelector, effectNow, engineStore } from "../state";
|
import { useEngineSelector, effectNow, engineStore } from "../state/engineStore";
|
||||||
import { FloatingLabel } from "./FloatingLabel";
|
import { FloatingLabel } from "./FloatingLabel";
|
||||||
import {
|
import {
|
||||||
useIflTexture,
|
useIflTexture,
|
||||||
|
|
@ -39,6 +40,8 @@ import {
|
||||||
} from "../stream/playbackUtils";
|
} from "../stream/playbackUtils";
|
||||||
import type { ThreadState as StreamThreadState } from "../stream/types";
|
import type { ThreadState as StreamThreadState } from "../stream/types";
|
||||||
|
|
||||||
|
const log = createLogger("GenericShape");
|
||||||
|
|
||||||
/** Returns pausable time in seconds for demo mode, real time otherwise. */
|
/** Returns pausable time in seconds for demo mode, real time otherwise. */
|
||||||
function shapeNowSec(): number {
|
function shapeNowSec(): number {
|
||||||
const { recording } = engineStore.getState().playback;
|
const { recording } = engineStore.getState().playback;
|
||||||
|
|
@ -271,9 +274,7 @@ const StaticTexture = memo(function StaticTexture({
|
||||||
|
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!resourcePath) {
|
if (!resourcePath) {
|
||||||
console.warn(
|
log.warn("No resource_path found on \"%s\" — rendering fallback", shapeName);
|
||||||
`No resource_path was found on "${shapeName}" - rendering fallback.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
|
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
|
||||||
}, [resourcePath, shapeName]);
|
}, [resourcePath, shapeName]);
|
||||||
|
|
@ -662,10 +663,7 @@ export const ShapeModel = memo(function ShapeModel({
|
||||||
iflMeshAtlasRef.current.set(info.mesh, atlas);
|
iflMeshAtlasRef.current.set(info.mesh, atlas);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn(
|
log.warn("Failed to load IFL atlas for %s: %o", info.iflPath, err);
|
||||||
`[ShapeModel] Failed to load IFL atlas for ${info.iflPath}:`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [iflMeshes]);
|
}, [iflMeshes]);
|
||||||
|
|
|
||||||
37
src/components/InputHandlers.tsx
Normal file
37
src/components/InputHandlers.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { lazy, ReactNode, Suspense } from "react";
|
||||||
|
import { KeyboardControls } from "@react-three/drei";
|
||||||
|
import { JoystickProvider } from "./JoystickContext";
|
||||||
|
import { useTouchDevice } from "./useTouchDevice";
|
||||||
|
import {
|
||||||
|
KeyboardAndMouseHandler,
|
||||||
|
KEYBOARD_CONTROLS,
|
||||||
|
} from "./KeyboardAndMouseHandler";
|
||||||
|
|
||||||
|
const TouchHandler = lazy(() =>
|
||||||
|
import("@/src/components/TouchHandler").then((mod) => ({
|
||||||
|
default: mod.TouchHandler,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function InputProvider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<KeyboardControls map={KEYBOARD_CONTROLS}>
|
||||||
|
<JoystickProvider>{children}</JoystickProvider>
|
||||||
|
</KeyboardControls>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputHandlers() {
|
||||||
|
const isTouch = useTouchDevice();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<KeyboardAndMouseHandler />
|
||||||
|
{isTouch ? (
|
||||||
|
<Suspense>
|
||||||
|
<TouchHandler />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,57 +1,170 @@
|
||||||
.Controls {
|
.InspectorControls {
|
||||||
position: fixed;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 12px 8px 8px;
|
|
||||||
border-radius: 0 0 4px 0;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.231;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dropdown {
|
.Dropdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ButtonGroup {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ButtonGroup .IconButton {
|
||||||
|
flex: 1 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
font-size: 22px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ButtonGroup .IconButton svg {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ButtonGroup .IconButton[data-active="true"] {
|
||||||
|
background: rgb(5, 114, 177);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ButtonGroup .IconButton:not(:first-child) {
|
||||||
|
border-left: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ButtonGroup .IconButton:not(:last-child) {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.Group {
|
.Group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.CheckboxField,
|
|
||||||
.LabelledButton {
|
.LabelledButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.Field {
|
|
||||||
|
.CheckboxField {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 6px;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckboxField input[type="checkbox"] {
|
||||||
|
margin-left: 0;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckboxField .Label {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Description {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3333;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 2px 0 4px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CheckboxField .Description {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field select {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field output {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field label {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field .Control {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Field .Description {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.IconButton {
|
.IconButton {
|
||||||
|
flex: 1 1 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
height: 28px;
|
min-height: 32px;
|
||||||
margin: 0 0 0 -12px;
|
gap: 8px;
|
||||||
font-size: 15px;
|
margin: 0;
|
||||||
padding: 0;
|
font-family: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 8px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
border-right: 1px solid rgba(200, 200, 200, 0.3);
|
border-right: 1px solid rgba(200, 200, 200, 0.3);
|
||||||
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
|
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
background: rgba(3, 82, 147, 0.6);
|
background: rgba(3, 82, 147, 0.6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -60,92 +173,69 @@
|
||||||
background 0.2s,
|
background 0.2s,
|
||||||
border-color 0.2s;
|
border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.IconButton svg {
|
.IconButton svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.IconButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.IconButton:hover {
|
.IconButton:not(:disabled):hover {
|
||||||
background: rgba(0, 98, 179, 0.8);
|
background: rgba(0, 98, 179, 0.8);
|
||||||
border-color: rgba(255, 255, 255, 0.4);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.IconButton:not(:disabled):hover svg {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.IconButton:active,
|
}
|
||||||
|
|
||||||
|
.IconButton:not(:disabled):active,
|
||||||
.IconButton[aria-expanded="true"] {
|
.IconButton[aria-expanded="true"] {
|
||||||
background: rgba(0, 98, 179, 0.7);
|
background: rgba(0, 98, 179, 0.7);
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
transform: translate(0, 1px);
|
transform: translate(0, 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.IconButton[data-active="true"] {
|
.IconButton[data-active="true"] {
|
||||||
background: rgba(0, 117, 213, 0.9);
|
background: rgba(0, 117, 213, 0.9);
|
||||||
border-color: rgba(255, 255, 255, 0.4);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ButtonLabel {
|
.ButtonLabel {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ButtonHint {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.Toggle {
|
.Toggle {
|
||||||
composes: IconButton;
|
composes: IconButton;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MapInfoButton {
|
.MapInfoButton {
|
||||||
composes: IconButton;
|
composes: IconButton;
|
||||||
composes: LabelledButton;
|
composes: LabelledButton;
|
||||||
}
|
}
|
||||||
.MissionSelectWrapper {
|
|
||||||
}
|
.ForceRenderButton {
|
||||||
@media (max-width: 1279px) {
|
display: grid;
|
||||||
.Dropdown[data-open="false"] {
|
place-content: center;
|
||||||
display: none;
|
border: 0;
|
||||||
}
|
margin: 0;
|
||||||
.Dropdown {
|
padding: 0 2px;
|
||||||
position: absolute;
|
font-size: 16px;
|
||||||
top: calc(100% + 2px);
|
background: transparent;
|
||||||
left: 2px;
|
color: #4cb5ff;
|
||||||
right: 2px;
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: calc(100dvh - 56px);
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 12px;
|
|
||||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
.Group {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px 20px;
|
|
||||||
}
|
|
||||||
.LabelledButton {
|
|
||||||
width: auto;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.Controls {
|
|
||||||
right: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.MissionSelectWrapper {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.MissionSelectWrapper input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.Toggle {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
.Toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.LabelledButton .ButtonLabel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.MapInfoButton {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,62 @@
|
||||||
|
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 {
|
import {
|
||||||
useControls,
|
useControls,
|
||||||
useDebug,
|
useDebug,
|
||||||
useSettings,
|
useSettings,
|
||||||
type TouchMode,
|
type TouchMode,
|
||||||
} from "./SettingsProvider";
|
} from "./SettingsProvider";
|
||||||
import { MissionSelect } from "./MissionSelect";
|
|
||||||
import { useEffect, useState, useRef, RefObject } from "react";
|
|
||||||
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
||||||
import { LoadDemoButton } from "./LoadDemoButton";
|
import { LoadDemoButton } from "./LoadDemoButton";
|
||||||
import { JoinServerButton } from "./JoinServerButton";
|
import { JoinServerButton } from "./JoinServerButton";
|
||||||
import { useRecording } from "./RecordingProvider";
|
import { Accordion, AccordionGroup } from "./Accordion";
|
||||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
|
||||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
|
||||||
import { Camera } from "three";
|
|
||||||
import styles from "./InspectorControls.module.css";
|
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({
|
export function InspectorControls({
|
||||||
missionName,
|
missionName,
|
||||||
missionType,
|
missionType,
|
||||||
onChangeMission,
|
|
||||||
onOpenMapInfo,
|
onOpenMapInfo,
|
||||||
onOpenServerBrowser,
|
onOpenServerBrowser,
|
||||||
isTouch,
|
onChooseMap,
|
||||||
|
onCancelChoosingMap,
|
||||||
|
choosingMap,
|
||||||
cameraRef,
|
cameraRef,
|
||||||
|
invalidateRef,
|
||||||
}: {
|
}: {
|
||||||
missionName: string;
|
missionName: string;
|
||||||
missionType: string;
|
missionType: string;
|
||||||
onChangeMission: ({
|
|
||||||
missionName,
|
|
||||||
missionType,
|
|
||||||
}: {
|
|
||||||
missionName: string;
|
|
||||||
missionType: string;
|
|
||||||
}) => void;
|
|
||||||
onOpenMapInfo: () => void;
|
onOpenMapInfo: () => void;
|
||||||
onOpenServerBrowser?: () => void;
|
onOpenServerBrowser?: () => void;
|
||||||
isTouch: boolean | null;
|
onChooseMap?: () => void;
|
||||||
|
onCancelChoosingMap?: () => void;
|
||||||
|
choosingMap?: boolean;
|
||||||
cameraRef: RefObject<Camera>;
|
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 {
|
const {
|
||||||
fogEnabled,
|
fogEnabled,
|
||||||
setFogEnabled,
|
setFogEnabled,
|
||||||
|
|
@ -44,18 +64,25 @@ export function InspectorControls({
|
||||||
setFov,
|
setFov,
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
setAudioEnabled,
|
setAudioEnabled,
|
||||||
|
audioVolume,
|
||||||
|
setAudioVolume,
|
||||||
animationEnabled,
|
animationEnabled,
|
||||||
setAnimationEnabled,
|
setAnimationEnabled,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } =
|
const {
|
||||||
useControls();
|
speedMultiplier,
|
||||||
const { debugMode, setDebugMode } = useDebug();
|
setSpeedMultiplier,
|
||||||
const demoRecording = useRecording();
|
touchMode,
|
||||||
const isLive = useLiveSelector((s) => s.adapter != null);
|
setTouchMode,
|
||||||
const isStreaming = demoRecording != null || isLive;
|
invertScroll,
|
||||||
// Hide FOV/speed controls during .rec playback (faithfully replaying),
|
setInvertScroll,
|
||||||
// but show them in .mis browsing and live observer mode.
|
invertDrag,
|
||||||
const hideViewControls = isStreaming && !isLive;
|
setInvertDrag,
|
||||||
|
invertJoystick,
|
||||||
|
setInvertJoystick,
|
||||||
|
} = useControls();
|
||||||
|
const { debugMode, setDebugMode, renderOnDemand, setRenderOnDemand } =
|
||||||
|
useDebug();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
@ -81,34 +108,8 @@ export function InspectorControls({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={styles.InspectorControls}>
|
||||||
id="controls"
|
|
||||||
className={styles.Controls}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className={styles.MissionSelectWrapper}>
|
|
||||||
<MissionSelect
|
|
||||||
value={missionName}
|
|
||||||
missionType={missionType}
|
|
||||||
onChange={onChangeMission}
|
|
||||||
disabled={isStreaming}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div ref={focusAreaRef}>
|
<div ref={focusAreaRef}>
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
className={styles.Toggle}
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsOpen((isOpen) => !isOpen);
|
|
||||||
}}
|
|
||||||
aria-expanded={settingsOpen}
|
|
||||||
aria-controls="settingsPanel"
|
|
||||||
aria-label="Settings"
|
|
||||||
>
|
|
||||||
<FiSettings />
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
className={styles.Dropdown}
|
className={styles.Dropdown}
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
|
|
@ -118,93 +119,54 @@ export function InspectorControls({
|
||||||
onBlur={handleDropdownBlur}
|
onBlur={handleDropdownBlur}
|
||||||
data-open={settingsOpen}
|
data-open={settingsOpen}
|
||||||
>
|
>
|
||||||
<div className={styles.Group}>
|
<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
|
<CopyCoordinatesButton
|
||||||
missionName={missionName}
|
missionName={missionName}
|
||||||
missionType={missionType}
|
missionType={missionType}
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
|
disabled={!missionInManifest}
|
||||||
/>
|
/>
|
||||||
<LoadDemoButton />
|
|
||||||
{onOpenServerBrowser && (
|
|
||||||
<JoinServerButton onOpenServerBrowser={onOpenServerBrowser} />
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.MapInfoButton}
|
className={styles.MapInfoButton}
|
||||||
aria-label="Show map info"
|
aria-label="Show map info"
|
||||||
onClick={onOpenMapInfo}
|
onClick={onOpenMapInfo}
|
||||||
|
disabled={!missionInManifest}
|
||||||
>
|
>
|
||||||
<FiInfo />
|
<LuClipboardList />
|
||||||
<span className={styles.ButtonLabel}>Show map info</span>
|
<span className={styles.ButtonLabel}>Show map info</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.Group}>
|
<div className={styles.Accordions}>
|
||||||
<div className={styles.CheckboxField}>
|
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
|
||||||
<input
|
<Accordion value="controls" label="Controls">
|
||||||
id="fogInput"
|
|
||||||
type="checkbox"
|
|
||||||
checked={fogEnabled}
|
|
||||||
onChange={(event) => {
|
|
||||||
setFogEnabled(event.target.checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="fogInput">Fog?</label>
|
|
||||||
</div>
|
|
||||||
<div className={styles.CheckboxField}>
|
|
||||||
<input
|
|
||||||
id="audioInput"
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioEnabled}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAudioEnabled(event.target.checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="audioInput">Audio?</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.Group}>
|
|
||||||
<div className={styles.CheckboxField}>
|
|
||||||
<input
|
|
||||||
id="animationInput"
|
|
||||||
type="checkbox"
|
|
||||||
checked={animationEnabled}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAnimationEnabled(event.target.checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="animationInput">Animation?</label>
|
|
||||||
</div>
|
|
||||||
<div className={styles.CheckboxField}>
|
|
||||||
<input
|
|
||||||
id="debugInput"
|
|
||||||
type="checkbox"
|
|
||||||
checked={debugMode}
|
|
||||||
onChange={(event) => {
|
|
||||||
setDebugMode(event.target.checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="debugInput">Debug?</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.Group}>
|
|
||||||
{hideViewControls ? null : (
|
|
||||||
<div className={styles.Field}>
|
<div className={styles.Field}>
|
||||||
<label htmlFor="fovInput">FOV</label>
|
<label htmlFor="speedInput">Fly speed</label>
|
||||||
<input
|
|
||||||
id="fovInput"
|
|
||||||
type="range"
|
|
||||||
min={75}
|
|
||||||
max={120}
|
|
||||||
step={5}
|
|
||||||
value={fov}
|
|
||||||
onChange={(event) => setFov(parseInt(event.target.value))}
|
|
||||||
/>
|
|
||||||
<output htmlFor="fovInput">{fov}</output>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hideViewControls ? null : (
|
|
||||||
<div className={styles.Field}>
|
|
||||||
<label htmlFor="speedInput">Speed</label>
|
|
||||||
<input
|
<input
|
||||||
id="speedInput"
|
id="speedInput"
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -216,24 +178,209 @@ export function InspectorControls({
|
||||||
setSpeedMultiplier(parseFloat(event.target.value))
|
setSpeedMultiplier(parseFloat(event.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
{isTouch ? (
|
||||||
</div>
|
|
||||||
{isTouch && (
|
|
||||||
<div className={styles.Group}>
|
|
||||||
<div className={styles.Field}>
|
<div className={styles.Field}>
|
||||||
<label htmlFor="touchModeInput">Joystick:</label>{" "}
|
<label htmlFor="touchModeInput">Joystick</label>{" "}
|
||||||
<select
|
<select
|
||||||
id="touchModeInput"
|
id="touchModeInput"
|
||||||
value={touchMode}
|
value={touchMode}
|
||||||
onChange={(e) => setTouchMode(e.target.value as TouchMode)}
|
onChange={(e) =>
|
||||||
|
setTouchMode(e.target.value as TouchMode)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="dualStick">Dual Stick</option>
|
<option value="dualStick">Dual stick</option>
|
||||||
<option value="moveLookStick">Single Stick</option>
|
<option value="moveLookStick">Single stick</option>
|
||||||
</select>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { memo, Suspense, useMemo, useCallback, useEffect, useRef } from "react";
|
import { memo, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { DebugSuspense } from "./DebugSuspense";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import {
|
import {
|
||||||
Mesh,
|
Mesh,
|
||||||
Material,
|
Material,
|
||||||
|
|
@ -24,6 +26,8 @@ import { injectCustomFog } from "../fogShader";
|
||||||
import { globalFogUniforms } from "../globalFogUniforms";
|
import { globalFogUniforms } from "../globalFogUniforms";
|
||||||
import { injectInteriorLighting } from "../interiorMaterial";
|
import { injectInteriorLighting } from "../interiorMaterial";
|
||||||
|
|
||||||
|
const log = createLogger("InteriorInstance");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a .gltf file that was converted from a .dif, used for "interior" models.
|
* Load a .gltf file that was converted from a .dif, used for "interior" models.
|
||||||
*/
|
*/
|
||||||
|
|
@ -151,7 +155,8 @@ function InteriorMesh({ node }: { node: Mesh }) {
|
||||||
return (
|
return (
|
||||||
<mesh geometry={node.geometry} castShadow receiveShadow>
|
<mesh geometry={node.geometry} castShadow receiveShadow>
|
||||||
{node.material ? (
|
{node.material ? (
|
||||||
<Suspense
|
<DebugSuspense
|
||||||
|
label={`InteriorTexture:${Array.isArray(node.material) ? node.material[0]?.userData?.resource_path : node.material?.userData?.resource_path ?? "?"}`}
|
||||||
fallback={
|
fallback={
|
||||||
// Allow the mesh to render while the texture is still loading;
|
// Allow the mesh to render while the texture is still loading;
|
||||||
// show a wireframe placeholder.
|
// show a wireframe placeholder.
|
||||||
|
|
@ -174,7 +179,7 @@ function InteriorMesh({ node }: { node: Mesh }) {
|
||||||
lightMap={lightMaps[0]}
|
lightMap={lightMaps[0]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</DebugSuspense>
|
||||||
) : null}
|
) : null}
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
|
|
@ -253,18 +258,18 @@ export const InteriorInstance = memo(function InteriorInstance({
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
console.warn(
|
log.error("Failed to load %s: %s", scene.interiorFile, error.message);
|
||||||
`[interior] Failed to load ${scene.interiorFile}:`,
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<InteriorPlaceholder color="orange" />}>
|
<DebugSuspense
|
||||||
|
label={`InteriorModel:${scene.interiorFile}`}
|
||||||
|
fallback={<InteriorPlaceholder color="orange" />}
|
||||||
|
>
|
||||||
<InteriorModel
|
<InteriorModel
|
||||||
interiorFile={scene.interiorFile}
|
interiorFile={scene.interiorFile}
|
||||||
ghostIndex={scene.ghostIndex}
|
ghostIndex={scene.ghostIndex}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</DebugSuspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
.Root {
|
.Root {
|
||||||
composes: IconButton from "./InspectorControls.module.css";
|
composes: IconButton from "./InspectorControls.module.css";
|
||||||
composes: LabelledButton from "./InspectorControls.module.css";
|
composes: LabelledButton from "./InspectorControls.module.css";
|
||||||
padding: 0 5px;
|
|
||||||
}
|
}
|
||||||
/* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */
|
/* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */
|
||||||
.TextLabel {
|
.TextLabel {
|
||||||
|
|
@ -14,7 +13,7 @@
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
.LiveIcon {
|
.LiveIcon {
|
||||||
font-size: 15px;
|
/* font-size: 15px; */
|
||||||
}
|
}
|
||||||
.Pulsing {
|
.Pulsing {
|
||||||
animation: blink 1.2s ease-out infinite;
|
animation: blink 1.2s ease-out infinite;
|
||||||
|
|
@ -27,3 +26,7 @@
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ButtonHint {
|
||||||
|
composes: ButtonHint from "./InspectorControls.module.css";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { useLiveSelector, selectPing } from "../state/liveConnectionStore";
|
||||||
import styles from "./JoinServerButton.module.css";
|
import styles from "./JoinServerButton.module.css";
|
||||||
|
|
||||||
function formatPing(ms: number): string {
|
function formatPing(ms: number): string {
|
||||||
return ms >= 1000 ? ms.toLocaleString() + "ms" : ms + "ms";
|
return `${ms.toLocaleString()} ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JoinServerButton({
|
export function JoinServerButton({
|
||||||
|
isActive,
|
||||||
onOpenServerBrowser,
|
onOpenServerBrowser,
|
||||||
}: {
|
}: {
|
||||||
|
isActive: boolean;
|
||||||
onOpenServerBrowser: () => void;
|
onOpenServerBrowser: () => void;
|
||||||
}) {
|
}) {
|
||||||
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
||||||
|
|
@ -26,8 +28,8 @@ export function JoinServerButton({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.Root}
|
className={styles.Root}
|
||||||
aria-label={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"}
|
aria-label={isLive ? "Connected – click to disconnect" : "Join server"}
|
||||||
title={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"}
|
title={isLive ? "Connected – click to disconnect" : "Join server"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
disconnectServer();
|
disconnectServer();
|
||||||
|
|
@ -35,21 +37,20 @@ export function JoinServerButton({
|
||||||
onOpenServerBrowser();
|
onOpenServerBrowser();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-active={isLive ? "true" : undefined}
|
data-active={isActive}
|
||||||
>
|
>
|
||||||
<BsFillLightningChargeFill
|
<BsFillLightningChargeFill
|
||||||
className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`}
|
className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`}
|
||||||
/>
|
/>
|
||||||
{!isLive && (
|
<>
|
||||||
<span className={styles.TextLabel}>
|
<span className={styles.TextLabel}>Live</span>
|
||||||
{isConnecting ? "Connecting..." : "Connect"}
|
<span className={styles.ButtonHint}>
|
||||||
|
{ping != null ? formatPing(ping) : "Join a game"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</>
|
||||||
{isLive && ping != null && (
|
{/* {isLive && ping != null && (
|
||||||
<span className={styles.PingLabel}>
|
<span className={styles.PingLabel}>{formatPing(ping)}</span>
|
||||||
{formatPing(ping)}
|
)} */}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
69
src/components/JoystickContext.tsx
Normal file
69
src/components/JoystickContext.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type JoystickState = {
|
||||||
|
angle: number;
|
||||||
|
force: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JoystickContextType = {
|
||||||
|
moveState: RefObject<JoystickState>;
|
||||||
|
lookState: RefObject<JoystickState>;
|
||||||
|
setMoveState: (state: Partial<JoystickState>) => void;
|
||||||
|
setLookState: (state: Partial<JoystickState>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JoystickContext = createContext<JoystickContextType | null>(null);
|
||||||
|
|
||||||
|
export function useJoystick() {
|
||||||
|
const context = useContext(JoystickContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"No JoystickContext found. Did you forget to add a <JoystickProvider>?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoystickProvider({ children }: { children: ReactNode }) {
|
||||||
|
const moveState = useRef<JoystickState>({ angle: 0, force: 0 });
|
||||||
|
const lookState = useRef<JoystickState>({ angle: 0, force: 0 });
|
||||||
|
|
||||||
|
const setMoveState = useCallback(
|
||||||
|
({ angle, force }: Partial<JoystickState>) => {
|
||||||
|
if (angle != null) {
|
||||||
|
moveState.current.angle = angle;
|
||||||
|
}
|
||||||
|
if (force != null) {
|
||||||
|
moveState.current.force = force;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setLookState = useCallback(
|
||||||
|
({ angle, force }: Partial<JoystickState>) => {
|
||||||
|
if (angle != null) {
|
||||||
|
lookState.current.angle = angle;
|
||||||
|
}
|
||||||
|
if (force != null) {
|
||||||
|
lookState.current.force = force;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const context: JoystickContextType = useMemo(
|
||||||
|
() => ({ moveState, lookState, setMoveState, setLookState }),
|
||||||
|
[setMoveState, setLookState],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <JoystickContext value={context}>{children}</JoystickContext>;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useEffectEvent, useRef } from "react";
|
||||||
import { Euler, Vector3 } from "three";
|
import { Euler, Vector3 } from "three";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useKeyboardControls } from "@react-three/drei";
|
import { useKeyboardControls } from "@react-three/drei";
|
||||||
|
|
@ -29,6 +29,28 @@ export enum Controls {
|
||||||
camera9 = "camera9",
|
camera9 = "camera9",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const KEYBOARD_CONTROLS = [
|
||||||
|
{ name: Controls.forward, keys: ["KeyW"] },
|
||||||
|
{ name: Controls.backward, keys: ["KeyS"] },
|
||||||
|
{ name: Controls.left, keys: ["KeyA"] },
|
||||||
|
{ name: Controls.right, keys: ["KeyD"] },
|
||||||
|
{ name: Controls.up, keys: ["Space"] },
|
||||||
|
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
|
||||||
|
{ name: Controls.lookUp, keys: ["ArrowUp"] },
|
||||||
|
{ name: Controls.lookDown, keys: ["ArrowDown"] },
|
||||||
|
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
|
||||||
|
{ name: Controls.lookRight, keys: ["ArrowRight"] },
|
||||||
|
{ name: Controls.camera1, keys: ["Digit1"] },
|
||||||
|
{ name: Controls.camera2, keys: ["Digit2"] },
|
||||||
|
{ name: Controls.camera3, keys: ["Digit3"] },
|
||||||
|
{ name: Controls.camera4, keys: ["Digit4"] },
|
||||||
|
{ name: Controls.camera5, keys: ["Digit5"] },
|
||||||
|
{ name: Controls.camera6, keys: ["Digit6"] },
|
||||||
|
{ name: Controls.camera7, keys: ["Digit7"] },
|
||||||
|
{ name: Controls.camera8, keys: ["Digit8"] },
|
||||||
|
{ name: Controls.camera9, keys: ["Digit9"] },
|
||||||
|
];
|
||||||
|
|
||||||
const BASE_SPEED = 80;
|
const BASE_SPEED = 80;
|
||||||
const MIN_SPEED_ADJUSTMENT = 0.05;
|
const MIN_SPEED_ADJUSTMENT = 0.05;
|
||||||
const MAX_SPEED_ADJUSTMENT = 0.5;
|
const MAX_SPEED_ADJUSTMENT = 0.5;
|
||||||
|
|
@ -39,13 +61,39 @@ const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
|
||||||
export const MOUSE_SENSITIVITY = 0.003;
|
export const MOUSE_SENSITIVITY = 0.003;
|
||||||
export const ARROW_LOOK_SPEED = 1; // radians/sec
|
export const ARROW_LOOK_SPEED = 1; // radians/sec
|
||||||
|
|
||||||
function CameraMovement() {
|
export function KeyboardAndMouseHandler() {
|
||||||
const { speedMultiplier, setSpeedMultiplier } = useControls();
|
// Don't let KeyboardControls handle stuff when metaKey is held.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
// Let Cmd/Ctrl+K pass through for search focus.
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.metaKey) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKey, { capture: true });
|
||||||
|
window.addEventListener("keyup", handleKey, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKey, { capture: true });
|
||||||
|
window.removeEventListener("keyup", handleKey, { capture: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { speedMultiplier, setSpeedMultiplier, invertScroll, invertDrag } =
|
||||||
|
useControls();
|
||||||
const [subscribe, getKeys] = useKeyboardControls<Controls>();
|
const [subscribe, getKeys] = useKeyboardControls<Controls>();
|
||||||
const { camera, gl } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
|
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
|
||||||
const controlsRef = useRef<PointerLockControls | null>(null);
|
const controlsRef = useRef<PointerLockControls | null>(null);
|
||||||
|
|
||||||
|
const getInvertScroll = useEffectEvent(() => invertScroll);
|
||||||
|
const getInvertDrag = useEffectEvent(() => invertDrag);
|
||||||
|
|
||||||
// Scratch vectors/euler to avoid allocations each frame
|
// Scratch vectors/euler to avoid allocations each frame
|
||||||
const forwardVec = useRef(new Vector3());
|
const forwardVec = useRef(new Vector3());
|
||||||
const sideVec = useRef(new Vector3());
|
const sideVec = useRef(new Vector3());
|
||||||
|
|
@ -92,9 +140,10 @@ function CameraMovement() {
|
||||||
}
|
}
|
||||||
didDrag = true;
|
didDrag = true;
|
||||||
|
|
||||||
|
const dragSign = getInvertDrag() ? -1 : 1;
|
||||||
euler.setFromQuaternion(camera.quaternion, "YXZ");
|
euler.setFromQuaternion(camera.quaternion, "YXZ");
|
||||||
euler.y -= e.movementX * MOUSE_SENSITIVITY;
|
euler.y += dragSign * e.movementX * MOUSE_SENSITIVITY;
|
||||||
euler.x -= e.movementY * MOUSE_SENSITIVITY;
|
euler.x += dragSign * e.movementY * MOUSE_SENSITIVITY;
|
||||||
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
|
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
|
||||||
camera.quaternion.setFromEuler(euler);
|
camera.quaternion.setFromEuler(euler);
|
||||||
};
|
};
|
||||||
|
|
@ -154,7 +203,8 @@ function CameraMovement() {
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const direction = e.deltaY > 0 ? -1 : 1;
|
const scrollSign = getInvertScroll() ? -1 : 1;
|
||||||
|
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
|
||||||
|
|
||||||
const delta =
|
const delta =
|
||||||
// Helps normalize sensitivity; trackpad scrolling will have many small
|
// Helps normalize sensitivity; trackpad scrolling will have many small
|
||||||
|
|
@ -253,50 +303,3 @@ function CameraMovement() {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KEYBOARD_CONTROLS = [
|
|
||||||
{ name: Controls.forward, keys: ["KeyW"] },
|
|
||||||
{ name: Controls.backward, keys: ["KeyS"] },
|
|
||||||
{ name: Controls.left, keys: ["KeyA"] },
|
|
||||||
{ name: Controls.right, keys: ["KeyD"] },
|
|
||||||
{ name: Controls.up, keys: ["Space"] },
|
|
||||||
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
|
|
||||||
{ name: Controls.lookUp, keys: ["ArrowUp"] },
|
|
||||||
{ name: Controls.lookDown, keys: ["ArrowDown"] },
|
|
||||||
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
|
|
||||||
{ name: Controls.lookRight, keys: ["ArrowRight"] },
|
|
||||||
{ name: Controls.camera1, keys: ["Digit1"] },
|
|
||||||
{ name: Controls.camera2, keys: ["Digit2"] },
|
|
||||||
{ name: Controls.camera3, keys: ["Digit3"] },
|
|
||||||
{ name: Controls.camera4, keys: ["Digit4"] },
|
|
||||||
{ name: Controls.camera5, keys: ["Digit5"] },
|
|
||||||
{ name: Controls.camera6, keys: ["Digit6"] },
|
|
||||||
{ name: Controls.camera7, keys: ["Digit7"] },
|
|
||||||
{ name: Controls.camera8, keys: ["Digit8"] },
|
|
||||||
{ name: Controls.camera9, keys: ["Digit9"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ObserverControls() {
|
|
||||||
// Don't let KeyboardControls handle stuff when metaKey is held.
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
// Let Cmd/Ctrl+K pass through for search focus.
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.metaKey) {
|
|
||||||
e.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKey, { capture: true });
|
|
||||||
window.addEventListener("keyup", handleKey, { capture: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKey, { capture: true });
|
|
||||||
window.removeEventListener("keyup", handleKey, { capture: true });
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <CameraMovement />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.Root {
|
.Root {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useKeyboardControls } from "@react-three/drei";
|
import { useKeyboardControls } from "@react-three/drei";
|
||||||
import { Controls } from "./ObserverControls";
|
import { Controls } from "./KeyboardAndMouseHandler";
|
||||||
import { useRecording } from "./RecordingProvider";
|
import { useRecording } from "./RecordingProvider";
|
||||||
import styles from "./KeyboardOverlay.module.css";
|
import styles from "./KeyboardOverlay.module.css";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { disposeLiveConnection } from "../state/liveConnectionStore";
|
|
||||||
|
|
||||||
/** Cleanup-only provider — disposes the relay connection on unmount. */
|
|
||||||
export function LiveConnectionProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
return () => disposeLiveConnection();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
@ -2,19 +2,26 @@ import { useRef, useEffect } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { Vector3 } from "three";
|
import { Vector3 } from "three";
|
||||||
import { useKeyboardControls } from "@react-three/drei";
|
import { useKeyboardControls } from "@react-three/drei";
|
||||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
import { createLogger } from "../logger";
|
||||||
|
import {
|
||||||
|
liveConnectionStore,
|
||||||
|
useLiveSelector,
|
||||||
|
} from "../state/liveConnectionStore";
|
||||||
import { useEngineStoreApi } from "../state/engineStore";
|
import { useEngineStoreApi } from "../state/engineStore";
|
||||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||||
import { Controls, MOUSE_SENSITIVITY, ARROW_LOOK_SPEED } from "./ObserverControls";
|
import {
|
||||||
|
Controls,
|
||||||
|
MOUSE_SENSITIVITY,
|
||||||
|
ARROW_LOOK_SPEED,
|
||||||
|
} from "./KeyboardAndMouseHandler";
|
||||||
import { useControls } from "./SettingsProvider";
|
import { useControls } from "./SettingsProvider";
|
||||||
import { useTick, TICK_RATE } from "./TickProvider";
|
import { useTick, TICK_RATE } from "./TickProvider";
|
||||||
import {
|
import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers";
|
||||||
yawPitchToQuaternion,
|
|
||||||
MAX_PITCH,
|
|
||||||
} from "../stream/streamHelpers";
|
|
||||||
import type { StreamRecording, StreamCamera } from "../stream/types";
|
import type { StreamRecording, StreamCamera } from "../stream/types";
|
||||||
import type { LiveStreamAdapter } from "../stream/liveStreaming";
|
import type { LiveStreamAdapter } from "../stream/liveStreaming";
|
||||||
|
|
||||||
|
const log = createLogger("LiveObserver");
|
||||||
|
|
||||||
const TICK_INTERVAL = 1 / TICK_RATE;
|
const TICK_INTERVAL = 1 / TICK_RATE;
|
||||||
|
|
||||||
// Scratch objects to avoid per-frame allocations.
|
// Scratch objects to avoid per-frame allocations.
|
||||||
|
|
@ -50,7 +57,7 @@ export function LiveObserver() {
|
||||||
const store = useEngineStoreApi();
|
const store = useEngineStoreApi();
|
||||||
const { speedMultiplier } = useControls();
|
const { speedMultiplier } = useControls();
|
||||||
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
|
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
|
||||||
const { gl } = useThree();
|
const gl = useThree((state) => state.gl);
|
||||||
const [, getKeys] = useKeyboardControls<Controls>();
|
const [, getKeys] = useKeyboardControls<Controls>();
|
||||||
|
|
||||||
// Accumulated rotation deltas since last move was sent. Mouse events and
|
// Accumulated rotation deltas since last move was sent. Mouse events and
|
||||||
|
|
@ -73,15 +80,22 @@ export function LiveObserver() {
|
||||||
|
|
||||||
// Wire adapter to engine store.
|
// Wire adapter to engine store.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adapter && (gameStatus === "connected" || gameStatus === "authenticating")) {
|
if (
|
||||||
|
adapter &&
|
||||||
|
(gameStatus === "connected" || gameStatus === "authenticating")
|
||||||
|
) {
|
||||||
if (activeAdapterRef.current === adapter) return;
|
if (activeAdapterRef.current === adapter) return;
|
||||||
|
|
||||||
console.log("[LiveObserver] wiring adapter to engine store");
|
log.info("wiring adapter to engine store");
|
||||||
|
const liveState = liveConnectionStore.getState();
|
||||||
const liveRecording: StreamRecording = {
|
const liveRecording: StreamRecording = {
|
||||||
source: "live",
|
source: "live",
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
missionName: null,
|
missionName: liveState.mapName ?? null,
|
||||||
gameType: null,
|
gameType: null,
|
||||||
|
serverDisplayName: liveState.serverName ?? null,
|
||||||
|
recorderName: liveState.warriorName ?? null,
|
||||||
|
recordingDate: null,
|
||||||
streamingPlayback: adapter,
|
streamingPlayback: adapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,7 +106,11 @@ export function LiveObserver() {
|
||||||
predRef.current.initialized = false;
|
predRef.current.initialized = false;
|
||||||
predRef.current.lastSyncedCamera = null;
|
predRef.current.lastSyncedCamera = null;
|
||||||
} else if (!adapter && activeAdapterRef.current) {
|
} else if (!adapter && activeAdapterRef.current) {
|
||||||
|
// Only clear the recording if it's still the live one we set.
|
||||||
|
const current = store.getState().playback.recording;
|
||||||
|
if (current?.source === "live") {
|
||||||
store.getState().setRecording(null);
|
store.getState().setRecording(null);
|
||||||
|
}
|
||||||
activeAdapterRef.current = null;
|
activeAdapterRef.current = null;
|
||||||
predRef.current.initialized = false;
|
predRef.current.initialized = false;
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +183,7 @@ export function LiveObserver() {
|
||||||
if (!activeAdapterRef.current) return;
|
if (!activeAdapterRef.current) return;
|
||||||
|
|
||||||
activeAdapterRef.current.toggleObserverMode();
|
activeAdapterRef.current.toggleObserverMode();
|
||||||
console.log(`[LiveObserver] observer mode: ${activeAdapterRef.current.observerMode}`);
|
log.info("observer mode: %s", activeAdapterRef.current.observerMode);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
|
@ -274,12 +292,15 @@ export function LiveObserver() {
|
||||||
// Interpolate between previous and current tick prediction, then add
|
// Interpolate between previous and current tick prediction, then add
|
||||||
// pending (unconsumed) mouse/arrow deltas so rotation responds at frame
|
// pending (unconsumed) mouse/arrow deltas so rotation responds at frame
|
||||||
// rate rather than waiting for the next useTick to consume them.
|
// rate rather than waiting for the next useTick to consume them.
|
||||||
const interpYaw = pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current;
|
const interpYaw =
|
||||||
|
pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current;
|
||||||
const interpPitch = Math.max(
|
const interpPitch = Math.max(
|
||||||
-MAX_PITCH,
|
-MAX_PITCH,
|
||||||
Math.min(
|
Math.min(
|
||||||
MAX_PITCH,
|
MAX_PITCH,
|
||||||
pred.prevPitch + (pred.pitch - pred.prevPitch) * t + deltaPitchRef.current,
|
pred.prevPitch +
|
||||||
|
(pred.pitch - pred.prevPitch) * t +
|
||||||
|
deltaPitchRef.current,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -314,7 +335,9 @@ export function LiveObserver() {
|
||||||
if (_orbitDir.lengthSq() > 1e-8) {
|
if (_orbitDir.lengthSq() > 1e-8) {
|
||||||
_orbitDir.normalize();
|
_orbitDir.normalize();
|
||||||
const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4);
|
const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4);
|
||||||
state.camera.position.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance);
|
state.camera.position
|
||||||
|
.copy(_orbitTarget)
|
||||||
|
.addScaledVector(_orbitDir, orbitDistance);
|
||||||
state.camera.lookAt(_orbitTarget);
|
state.camera.lookAt(_orbitTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,13 +346,16 @@ export function LiveObserver() {
|
||||||
// from StreamingController's server snapshot interpolation).
|
// from StreamingController's server snapshot interpolation).
|
||||||
state.camera.quaternion.set(qx, qy, qz, qw);
|
state.camera.quaternion.set(qx, qy, qz, qw);
|
||||||
}
|
}
|
||||||
}, 1);
|
});
|
||||||
|
|
||||||
// Clean up on unmount.
|
// Clean up on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (activeAdapterRef.current) {
|
if (activeAdapterRef.current) {
|
||||||
|
const current = store.getState().playback.recording;
|
||||||
|
if (current?.source === "live") {
|
||||||
store.getState().setRecording(null);
|
store.getState().setRecording(null);
|
||||||
|
}
|
||||||
activeAdapterRef.current = null;
|
activeAdapterRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
composes: ButtonLabel from "./InspectorControls.module.css";
|
composes: ButtonLabel from "./InspectorControls.module.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
.DemoIcon {
|
.ButtonHint {
|
||||||
font-size: 19px;
|
composes: ButtonHint from "./InspectorControls.module.css";
|
||||||
|
}
|
||||||
|
|
||||||
|
.DemoIcon {
|
||||||
|
/* font-size: 20px; */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { MdOndemandVideo } from "react-icons/md";
|
import { MdOndemandVideo } from "react-icons/md";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
|
import { liveConnectionStore } from "../state/liveConnectionStore";
|
||||||
import { usePlaybackActions, useRecording } from "./RecordingProvider";
|
import { usePlaybackActions, useRecording } from "./RecordingProvider";
|
||||||
import { createDemoStreamingRecording } from "../stream/demoStreaming";
|
|
||||||
import styles from "./LoadDemoButton.module.css";
|
import styles from "./LoadDemoButton.module.css";
|
||||||
|
|
||||||
export function LoadDemoButton() {
|
const log = createLogger("LoadDemoButton");
|
||||||
|
|
||||||
|
export function LoadDemoButton({
|
||||||
|
isActive = false,
|
||||||
|
choosingMap = false,
|
||||||
|
onCancelChoosingMap,
|
||||||
|
}: {
|
||||||
|
isActive?: boolean;
|
||||||
|
choosingMap?: boolean;
|
||||||
|
onCancelChoosingMap?: () => void;
|
||||||
|
}) {
|
||||||
const recording = useRecording();
|
const recording = useRecording();
|
||||||
const isDemoLoaded = recording?.source === "demo";
|
const isDemoLoaded = recording?.source === "demo";
|
||||||
const { setRecording } = usePlaybackActions();
|
const { setRecording } = usePlaybackActions();
|
||||||
|
|
@ -12,14 +23,18 @@ export function LoadDemoButton() {
|
||||||
const parseTokenRef = useRef(0);
|
const parseTokenRef = useRef(0);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
|
if (choosingMap && isDemoLoaded) {
|
||||||
|
onCancelChoosingMap?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isDemoLoaded) {
|
if (isDemoLoaded) {
|
||||||
// Unload the current recording.
|
// Unload the recording/parser but leave entities frozen in the store.
|
||||||
parseTokenRef.current += 1;
|
parseTokenRef.current += 1;
|
||||||
setRecording(null);
|
setRecording(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}, [isDemoLoaded, setRecording]);
|
}, [isDemoLoaded, choosingMap, onCancelChoosingMap, setRecording]);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -31,14 +46,20 @@ export function LoadDemoButton() {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
const parseToken = parseTokenRef.current + 1;
|
const parseToken = parseTokenRef.current + 1;
|
||||||
parseTokenRef.current = parseToken;
|
parseTokenRef.current = parseToken;
|
||||||
|
const { createDemoStreamingRecording } =
|
||||||
|
await import("../stream/demoStreaming");
|
||||||
const recording = await createDemoStreamingRecording(buffer);
|
const recording = await createDemoStreamingRecording(buffer);
|
||||||
if (parseTokenRef.current !== parseToken) {
|
if (parseTokenRef.current !== parseToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Disconnect from any live server before loading the demo.
|
||||||
|
const liveState = liveConnectionStore.getState();
|
||||||
|
liveState.disconnectServer();
|
||||||
|
liveState.disconnectRelay();
|
||||||
// Metadata-first: mission/game-mode sync happens immediately.
|
// Metadata-first: mission/game-mode sync happens immediately.
|
||||||
setRecording(recording);
|
setRecording(recording);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load demo:", err);
|
log.error("Failed to load demo: %o", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setRecording],
|
[setRecording],
|
||||||
|
|
@ -59,12 +80,16 @@ export function LoadDemoButton() {
|
||||||
aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
|
aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
|
||||||
title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
|
title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
data-active={isDemoLoaded ? "true" : undefined}
|
data-active={isActive}
|
||||||
disabled={recording != null && !isDemoLoaded}
|
|
||||||
>
|
>
|
||||||
<MdOndemandVideo className={styles.DemoIcon} />
|
<MdOndemandVideo className={styles.DemoIcon} />
|
||||||
<span className={styles.ButtonLabel}>
|
<span className={styles.ButtonLabel}>Demo</span>
|
||||||
{isDemoLoaded ? "Unload demo" : "Demo"}
|
<span className={styles.ButtonHint}>
|
||||||
|
{choosingMap && isDemoLoaded
|
||||||
|
? "Return to demo"
|
||||||
|
: isDemoLoaded
|
||||||
|
? "Click to unload"
|
||||||
|
: "Load a .rec file"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
.CanvasContainer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.LoadingIndicator {
|
.LoadingIndicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
22
src/components/LoadingIndicator.tsx
Normal file
22
src/components/LoadingIndicator.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import styles from "./LoadingIndicator.module.css";
|
||||||
|
|
||||||
|
export function LoadingIndicator({
|
||||||
|
isLoading,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
progress: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.LoadingIndicator} data-complete={!isLoading}>
|
||||||
|
<div className={styles.Spinner} />
|
||||||
|
<div className={styles.Progress}>
|
||||||
|
<div
|
||||||
|
className={styles.ProgressBar}
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ProgressText}>{Math.round(progress * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,14 @@
|
||||||
.Dialog {
|
.Dialog {
|
||||||
position: relative;
|
composes: Dialog from "./GameDialog.module.css";
|
||||||
width: 800px;
|
width: 800px;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
max-width: calc(100dvw - 40px);
|
|
||||||
max-height: calc(100dvh - 40px);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100%;
|
grid-template-columns: 100%;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
background: rgba(20, 37, 38, 0.8);
|
|
||||||
border: 1px solid rgba(65, 131, 139, 0.6);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 50px rgba(0, 0, 0, 0.4),
|
|
||||||
inset 0 0 60px rgba(1, 7, 13, 0.6);
|
|
||||||
color: #bccec3;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow: hidden;
|
|
||||||
outline: none;
|
|
||||||
user-select: text;
|
|
||||||
-webkit-touch-callout: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Overlay {
|
.Overlay {
|
||||||
position: fixed;
|
composes: Overlay from "./GameDialog.module.css";
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Body {
|
.Body {
|
||||||
|
|
@ -99,7 +77,7 @@
|
||||||
.MapQuote cite {
|
.MapQuote cite {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(215, 237, 203, 0.5);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +103,7 @@
|
||||||
.MusicTrack {
|
.MusicTrack {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(202, 208, 172, 0.5);
|
color: rgba(215, 237, 203, 0.5);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -174,7 +152,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.CloseButton {
|
.CloseButton {
|
||||||
composes: DialogButton from "./DialogButton.module.css";
|
composes: DialogButton from "./GameDialog.module.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
.Hint {
|
.Hint {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ function getBitmapUrl(
|
||||||
try {
|
try {
|
||||||
const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`);
|
const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`);
|
||||||
return getUrlForPath(key);
|
return getUrlForPath(key);
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fall back to Load_<MissionName>.png convention (multiplayer missions)
|
// Fall back to Load_<MissionName>.png convention (multiplayer missions)
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,7 +60,9 @@ function getBitmapUrl(
|
||||||
`textures/gui/Load_${missionName}`,
|
`textures/gui/Load_${missionName}`,
|
||||||
);
|
);
|
||||||
return getUrlForPath(key);
|
return getUrlForPath(key);
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,12 +165,10 @@ function MusicPlayer({ track }: { track: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapInfoDialog({
|
export function MapInfoDialog({
|
||||||
open,
|
|
||||||
onClose,
|
onClose,
|
||||||
missionName,
|
missionName,
|
||||||
missionType,
|
missionType,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
missionName: string;
|
missionName: string;
|
||||||
missionType: string;
|
missionType: string;
|
||||||
|
|
@ -175,19 +177,18 @@ export function MapInfoDialog({
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
|
||||||
dialogRef.current?.focus();
|
dialogRef.current?.focus();
|
||||||
try {
|
try {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, []);
|
||||||
|
|
||||||
// While open: block keyboard events from reaching drei, and handle close keys.
|
// While open: block keyboard events from reaching drei, and handle close keys.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.code === "KeyI" || e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
onClose();
|
onClose();
|
||||||
} else if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -204,9 +205,7 @@ export function MapInfoDialog({
|
||||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
window.removeEventListener("keyup", handleKeyUp, { capture: true });
|
window.removeEventListener("keyup", handleKeyUp, { capture: true });
|
||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const missionGroupProps = parsedMission
|
const missionGroupProps = parsedMission
|
||||||
? getMissionGroupProps(parsedMission.ast)
|
? getMissionGroupProps(parsedMission.ast)
|
||||||
|
|
@ -322,7 +321,7 @@ export function MapInfoDialog({
|
||||||
<button className={styles.CloseButton} onClick={onClose}>
|
<button className={styles.CloseButton} onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.Hint}>I or Esc to close</span>
|
<span className={styles.Hint}>Esc to close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
169
src/components/MapInspector.module.css
Normal file
169
src/components/MapInspector.module.css
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
.Frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"sidebar toolbar"
|
||||||
|
"sidebar content"
|
||||||
|
"sidebar footer";
|
||||||
|
width: 100vw;
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
grid-area: toolbar;
|
||||||
|
background: rgb(25, 31, 31);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgb(70, 85, 85);
|
||||||
|
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 3;
|
||||||
|
view-transition-class: layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CancelButton {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CancelButton:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Sidebar {
|
||||||
|
position: relative;
|
||||||
|
grid-area: sidebar;
|
||||||
|
width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(25, 31, 31);
|
||||||
|
color: #fff;
|
||||||
|
border-right: 1px solid rgb(70, 85, 85);
|
||||||
|
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
grid-area: content;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ThreeView {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.PlayerBar {
|
||||||
|
position: relative;
|
||||||
|
grid-area: footer;
|
||||||
|
background: rgb(25, 31, 31);
|
||||||
|
color: #fff;
|
||||||
|
border-top: 1px solid rgb(70, 85, 85);
|
||||||
|
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 2px;
|
||||||
|
margin: 0 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton[data-orientation="top"] {
|
||||||
|
display: none;
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton:not(:disabled):hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.Frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"toolbar"
|
||||||
|
"content"
|
||||||
|
"footer";
|
||||||
|
}
|
||||||
|
|
||||||
|
.Sidebar {
|
||||||
|
justify-self: center;
|
||||||
|
grid-area: content;
|
||||||
|
grid-row: content-start / footer-end;
|
||||||
|
width: auto;
|
||||||
|
max-width: 500px;
|
||||||
|
height: calc(100% + 1px);
|
||||||
|
margin: 0 -1px 0 -1px;
|
||||||
|
border: 1px solid rgb(70, 85, 85);
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Toolbar {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton[data-orientation="left"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSidebarButton[data-orientation="top"] {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
387
src/components/MapInspector.tsx
Normal file
387
src/components/MapInspector.tsx
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
startTransition,
|
||||||
|
Suspense,
|
||||||
|
useRef,
|
||||||
|
lazy,
|
||||||
|
Activity,
|
||||||
|
ViewTransition,
|
||||||
|
} from "react";
|
||||||
|
import { Camera } from "three";
|
||||||
|
import { InspectorControls } from "@/src/components/InspectorControls";
|
||||||
|
import { MissionSelect } from "@/src/components/MissionSelect";
|
||||||
|
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
|
||||||
|
import { SettingsProvider } from "@/src/components/SettingsProvider";
|
||||||
|
import { ObserverCamera } from "@/src/components/ObserverCamera";
|
||||||
|
import { AudioProvider } from "@/src/components/AudioContext";
|
||||||
|
import { CamerasProvider } from "@/src/components/CamerasProvider";
|
||||||
|
import {
|
||||||
|
RecordingProvider,
|
||||||
|
useRecording,
|
||||||
|
} from "@/src/components/RecordingProvider";
|
||||||
|
import { EntityScene } from "@/src/components/EntityScene";
|
||||||
|
import { TickProvider } from "@/src/components/TickProvider";
|
||||||
|
import { SceneLighting } from "@/src/components/SceneLighting";
|
||||||
|
import { useFeatures } from "@/src/components/FeaturesProvider";
|
||||||
|
import {
|
||||||
|
liveConnectionStore,
|
||||||
|
useLiveSelector,
|
||||||
|
} from "@/src/state/liveConnectionStore";
|
||||||
|
import { usePublicWindowAPI } from "@/src/components/usePublicWindowAPI";
|
||||||
|
import {
|
||||||
|
CurrentMission,
|
||||||
|
useFogQueryState,
|
||||||
|
useMissionQueryState,
|
||||||
|
} from "@/src/components/useQueryParams";
|
||||||
|
import { ThreeCanvas, InvalidateFunction } from "@/src/components/ThreeCanvas";
|
||||||
|
import { InputHandlers, InputProvider } from "./InputHandlers";
|
||||||
|
import { VisualInput } from "./VisualInput";
|
||||||
|
import { LoadingIndicator } from "./LoadingIndicator";
|
||||||
|
import { AudioEnabled } from "./AudioEnabled";
|
||||||
|
import { DebugEnabled } from "./DebugEnabled";
|
||||||
|
import { engineStore } from "../state/engineStore";
|
||||||
|
import {
|
||||||
|
gameEntityStore,
|
||||||
|
useDataSource,
|
||||||
|
useMissionName,
|
||||||
|
useMissionType,
|
||||||
|
} from "../state/gameEntityStore";
|
||||||
|
import { getMissionInfo } from "../manifest";
|
||||||
|
import {
|
||||||
|
LuPanelLeftClose,
|
||||||
|
LuPanelLeftOpen,
|
||||||
|
LuPanelTopClose,
|
||||||
|
LuPanelTopOpen,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import styles from "./MapInspector.module.css";
|
||||||
|
|
||||||
|
function createLazy(
|
||||||
|
name: string,
|
||||||
|
loader: () => Promise<{
|
||||||
|
[name]: React.ComponentType<any>;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return lazy(() => loader().then((mod) => ({ default: mod[name] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const StreamingController = createLazy(
|
||||||
|
"StreamingController",
|
||||||
|
() => import("@/src/components/StreamingController"),
|
||||||
|
);
|
||||||
|
const DemoPlaybackControls = createLazy(
|
||||||
|
"DemoPlaybackControls",
|
||||||
|
() => import("@/src/components/DemoPlaybackControls"),
|
||||||
|
);
|
||||||
|
const DebugElements = createLazy(
|
||||||
|
"DebugElements",
|
||||||
|
() => import("@/src/components/DebugElements"),
|
||||||
|
);
|
||||||
|
const Mission = createLazy("Mission", () => import("@/src/components/Mission"));
|
||||||
|
const LiveObserver = createLazy(
|
||||||
|
"LiveObserver",
|
||||||
|
() => import("@/src/components/LiveObserver"),
|
||||||
|
);
|
||||||
|
const ChatSoundPlayer = createLazy(
|
||||||
|
"ChatSoundPlayer",
|
||||||
|
() => import("@/src/components/ChatSoundPlayer"),
|
||||||
|
);
|
||||||
|
const PlayerHUD = createLazy(
|
||||||
|
"PlayerHUD",
|
||||||
|
() => import("@/src/components/PlayerHUD"),
|
||||||
|
);
|
||||||
|
const MapInfoDialog = createLazy(
|
||||||
|
"MapInfoDialog",
|
||||||
|
() => import("@/src/components/MapInfoDialog"),
|
||||||
|
);
|
||||||
|
const ServerBrowser = createLazy(
|
||||||
|
"ServerBrowser",
|
||||||
|
() => import("@/src/components/ServerBrowser"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function MapInspector() {
|
||||||
|
const [currentMission, setCurrentMission] = useMissionQueryState();
|
||||||
|
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
|
||||||
|
|
||||||
|
const clearFogEnabledOverride = useCallback(() => {
|
||||||
|
setFogEnabledOverride(null);
|
||||||
|
}, [setFogEnabledOverride]);
|
||||||
|
const features = useFeatures();
|
||||||
|
const { missionName, missionType } = currentMission;
|
||||||
|
const [mapInfoOpen, setMapInfoOpen] = useState(false);
|
||||||
|
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [choosingMap, setChoosingMap] = useState(false);
|
||||||
|
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
|
||||||
|
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
|
||||||
|
|
||||||
|
const changeMission = useCallback(
|
||||||
|
(mission: CurrentMission) => {
|
||||||
|
window.location.hash = "";
|
||||||
|
clearFogEnabledOverride();
|
||||||
|
setChoosingMap(false);
|
||||||
|
// Disconnect from any live server, unload any active recording, and
|
||||||
|
// clear stream state before loading the new mission in map mode.
|
||||||
|
const liveState = liveConnectionStore.getState();
|
||||||
|
liveState.disconnectServer();
|
||||||
|
liveState.disconnectRelay();
|
||||||
|
engineStore.getState().setRecording(null);
|
||||||
|
gameEntityStore.getState().endStreaming();
|
||||||
|
setCurrentMission(mission);
|
||||||
|
},
|
||||||
|
[setCurrentMission, clearFogEnabledOverride],
|
||||||
|
);
|
||||||
|
|
||||||
|
usePublicWindowAPI({ onChangeMission: changeMission });
|
||||||
|
|
||||||
|
const recording = useRecording();
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const hasStreamData = dataSource === "demo" || dataSource === "live";
|
||||||
|
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
|
||||||
|
|
||||||
|
// Sync the mission query param when streaming data provides a mission name.
|
||||||
|
const streamMissionName = useMissionName();
|
||||||
|
const streamMissionType = useMissionType();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasStreamData || !streamMissionName) return;
|
||||||
|
try {
|
||||||
|
const info = getMissionInfo(streamMissionName);
|
||||||
|
const matchedType =
|
||||||
|
streamMissionType && info.missionTypes.includes(streamMissionType)
|
||||||
|
? streamMissionType
|
||||||
|
: undefined;
|
||||||
|
setCurrentMission({
|
||||||
|
missionName: streamMissionName,
|
||||||
|
missionType: matchedType,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Mission not in manifest — remove the query param.
|
||||||
|
setCurrentMission(null);
|
||||||
|
}
|
||||||
|
}, [hasStreamData, streamMissionName, streamMissionType, setCurrentMission]);
|
||||||
|
|
||||||
|
// Cancel "choosing map" when a new recording loads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (recording) {
|
||||||
|
setChoosingMap(false);
|
||||||
|
}
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
const loadingProgress = missionLoadingProgress;
|
||||||
|
const isLoading = loadingProgress < 1;
|
||||||
|
|
||||||
|
// Keep the loading indicator visible briefly after reaching 100%
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
setShowLoadingIndicator(true);
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const handleLoadingChange = useCallback(
|
||||||
|
(_loading: boolean, progress: number = 0) => {
|
||||||
|
setMissionLoadingProgress(progress);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cameraRef = useRef<Camera | null>(null);
|
||||||
|
const invalidateRef = useRef<InvalidateFunction | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.Frame}>
|
||||||
|
<RecordingProvider>
|
||||||
|
<SettingsProvider
|
||||||
|
fogEnabledOverride={fogEnabledOverride}
|
||||||
|
onClearFogEnabledOverride={clearFogEnabledOverride}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className={styles.Toolbar}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ToggleSidebarButton}
|
||||||
|
data-orientation="top"
|
||||||
|
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||||
|
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||||
|
onClick={(event) => {
|
||||||
|
startTransition(() => setSidebarOpen((open) => !open));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ToggleSidebarButton}
|
||||||
|
data-orientation="left"
|
||||||
|
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||||
|
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||||
|
onClick={(event) => {
|
||||||
|
startTransition(() => setSidebarOpen((open) => !open));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
|
||||||
|
</button>
|
||||||
|
<Activity
|
||||||
|
mode={hasStreamData && !choosingMap ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<StreamingMissionInfo />
|
||||||
|
</Activity>
|
||||||
|
<Activity
|
||||||
|
mode={!hasStreamData || choosingMap ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<MissionSelect
|
||||||
|
value={choosingMap ? "" : missionName}
|
||||||
|
missionType={choosingMap ? "" : missionType}
|
||||||
|
onChange={changeMission}
|
||||||
|
autoFocus={choosingMap}
|
||||||
|
/>
|
||||||
|
{choosingMap && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.CancelButton}
|
||||||
|
onClick={() => {
|
||||||
|
setChoosingMap(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Activity>
|
||||||
|
</header>
|
||||||
|
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
|
||||||
|
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
|
||||||
|
<ViewTransition>
|
||||||
|
<div
|
||||||
|
className={styles.Sidebar}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-open={sidebarOpen}
|
||||||
|
>
|
||||||
|
<InspectorControls
|
||||||
|
missionName={missionName}
|
||||||
|
missionType={missionType}
|
||||||
|
onOpenMapInfo={() => setMapInfoOpen(true)}
|
||||||
|
onOpenServerBrowser={
|
||||||
|
features.live ? () => setServerBrowserOpen(true) : undefined
|
||||||
|
}
|
||||||
|
onChooseMap={
|
||||||
|
hasStreamData
|
||||||
|
? () => {
|
||||||
|
setChoosingMap(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onCancelChoosingMap={() => {
|
||||||
|
setChoosingMap(false);
|
||||||
|
}}
|
||||||
|
choosingMap={choosingMap}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
invalidateRef={invalidateRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ViewTransition>
|
||||||
|
</Activity>
|
||||||
|
<InputProvider>
|
||||||
|
<div className={styles.Content}>
|
||||||
|
<div className={styles.ThreeView}>
|
||||||
|
<ThreeCanvas
|
||||||
|
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
|
||||||
|
onCreated={(state) => {
|
||||||
|
cameraRef.current = state.camera;
|
||||||
|
invalidateRef.current = state.invalidate;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TickProvider>
|
||||||
|
<CamerasProvider>
|
||||||
|
<InputHandlers />
|
||||||
|
<AudioProvider>
|
||||||
|
<SceneLighting />
|
||||||
|
<Suspense>
|
||||||
|
<EntityScene />
|
||||||
|
</Suspense>
|
||||||
|
<ObserverCamera />
|
||||||
|
<AudioEnabled>
|
||||||
|
<ChatSoundPlayer />
|
||||||
|
</AudioEnabled>
|
||||||
|
<DebugEnabled>
|
||||||
|
<DebugElements />
|
||||||
|
</DebugEnabled>
|
||||||
|
{recording ? (
|
||||||
|
<Suspense>
|
||||||
|
<StreamingController recording={recording} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{!hasStreamData ? (
|
||||||
|
<Suspense>
|
||||||
|
<Mission
|
||||||
|
key={`${missionName}~${missionType}`}
|
||||||
|
name={missionName}
|
||||||
|
missionType={missionType}
|
||||||
|
onLoadingChange={handleLoadingChange}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{hasLiveAdapter ? (
|
||||||
|
<Suspense>
|
||||||
|
<LiveObserver />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</AudioProvider>
|
||||||
|
</CamerasProvider>
|
||||||
|
</TickProvider>
|
||||||
|
</ThreeCanvas>
|
||||||
|
</div>
|
||||||
|
{hasStreamData ? (
|
||||||
|
<Suspense>
|
||||||
|
<PlayerHUD />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
<VisualInput />
|
||||||
|
{showLoadingIndicator && (
|
||||||
|
<LoadingIndicator
|
||||||
|
isLoading={isLoading}
|
||||||
|
progress={loadingProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InputProvider>
|
||||||
|
<footer
|
||||||
|
className={styles.PlayerBar}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{recording?.source === "demo" ? (
|
||||||
|
<Suspense>
|
||||||
|
<DemoPlaybackControls />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</footer>
|
||||||
|
{mapInfoOpen ? (
|
||||||
|
<Suspense>
|
||||||
|
<MapInfoDialog
|
||||||
|
onClose={() => setMapInfoOpen(false)}
|
||||||
|
missionName={missionName}
|
||||||
|
missionType={missionType ?? ""}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{serverBrowserOpen ? (
|
||||||
|
<Suspense>
|
||||||
|
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</SettingsProvider>
|
||||||
|
</RecordingProvider>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,9 +19,13 @@ import {
|
||||||
getSourceAndPath,
|
getSourceAndPath,
|
||||||
} from "../manifest";
|
} from "../manifest";
|
||||||
import { MissionProvider } from "./MissionContext";
|
import { MissionProvider } from "./MissionContext";
|
||||||
import { engineStore, gameEntityStore } from "../state";
|
import { engineStore } from "../state/engineStore";
|
||||||
|
import { gameEntityStore } from "../state/gameEntityStore";
|
||||||
import { ignoreScripts } from "../torqueScript/ignoreScripts";
|
import { ignoreScripts } from "../torqueScript/ignoreScripts";
|
||||||
import { walkMissionTree } from "../stream/missionEntityBridge";
|
import { walkMissionTree } from "../stream/missionEntityBridge";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
|
|
||||||
|
const log = createLogger("Mission");
|
||||||
|
|
||||||
const loadScript = createScriptLoader();
|
const loadScript = createScriptLoader();
|
||||||
// Shared cache for parsed scripts - survives runtime restarts
|
// Shared cache for parsed scripts - survives runtime restarts
|
||||||
|
|
@ -105,8 +109,16 @@ function useExecutedMission(
|
||||||
engineStore.getState().setRuntime(runtime);
|
engineStore.getState().setRuntime(runtime);
|
||||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||||
if (missionGroup) {
|
if (missionGroup) {
|
||||||
const gameEntities = walkMissionTree(missionGroup, runtime);
|
const gameEntities = walkMissionTree(
|
||||||
|
missionGroup,
|
||||||
|
runtime,
|
||||||
|
missionType,
|
||||||
|
);
|
||||||
gameEntityStore.getState().setAllEntities(gameEntities);
|
gameEntityStore.getState().setAllEntities(gameEntities);
|
||||||
|
gameEntityStore.getState().setMissionInfo({
|
||||||
|
missionName,
|
||||||
|
missionType: missionType ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setState({ ready: true, runtime, progress: 1 });
|
setState({ ready: true, runtime, progress: 1 });
|
||||||
})
|
})
|
||||||
|
|
@ -114,7 +126,7 @@ function useExecutedMission(
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("Mission runtime failed to become ready:", err);
|
log.error("Mission runtime failed to become ready: %o", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe as soon as the runtime exists so no mutation batches are missed
|
// Subscribe as soon as the runtime exists so no mutation batches are missed
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin: 10px 10px 10px 4px;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Shortcut {
|
.Shortcut {
|
||||||
|
|
@ -41,7 +43,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input::placeholder {
|
.Input::placeholder {
|
||||||
color: transparent;
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #777;
|
||||||
|
/* color: transparent; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.SelectedValue {
|
.SelectedValue {
|
||||||
|
|
@ -63,6 +68,7 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: calc(18 / 14);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -158,6 +164,7 @@
|
||||||
.ItemType {
|
.ItemType {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: calc(13 / 10);
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 157, 0, 0.4);
|
background: rgba(255, 157, 0, 0.4);
|
||||||
|
|
@ -179,3 +186,13 @@
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Fragment,
|
Fragment,
|
||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
ComboboxGroup,
|
ComboboxGroup,
|
||||||
ComboboxGroupLabel,
|
ComboboxGroupLabel,
|
||||||
useComboboxStore,
|
useComboboxStore,
|
||||||
|
useStoreState,
|
||||||
} from "@ariakit/react";
|
} from "@ariakit/react";
|
||||||
import { matchSorter } from "match-sorter";
|
import { matchSorter } from "match-sorter";
|
||||||
import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest";
|
import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest";
|
||||||
|
|
@ -154,6 +156,7 @@ export function MissionSelect({
|
||||||
missionType,
|
missionType,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
|
autoFocus,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
missionType: string;
|
missionType: string;
|
||||||
|
|
@ -165,6 +168,7 @@ export function MissionSelect({
|
||||||
missionType: string | undefined;
|
missionType: string | undefined;
|
||||||
}) => void;
|
}) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -195,6 +199,8 @@ export function MissionSelect({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isOpen = useStoreState(combobox, "open");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
|
@ -264,17 +270,23 @@ export function MissionSelect({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboboxProvider store={combobox}>
|
<ComboboxProvider store={combobox}>
|
||||||
|
<Activity mode={isOpen ? "visible" : "hidden"}>
|
||||||
|
<div className={styles.Backdrop} />
|
||||||
|
</Activity>
|
||||||
<div className={styles.InputWrapper}>
|
<div className={styles.InputWrapper}>
|
||||||
<Combobox
|
<Combobox
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
autoSelect
|
autoSelect
|
||||||
|
autoFocus={autoFocus}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={displayValue}
|
placeholder={selectedMission ? undefined : "Choose a map…"}
|
||||||
className={styles.Input}
|
className={styles.Input}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
try {
|
try {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
combobox.show();
|
combobox.show();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
@ -283,6 +295,7 @@ export function MissionSelect({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{selectedMission && (
|
||||||
<div className={styles.SelectedValue}>
|
<div className={styles.SelectedValue}>
|
||||||
<span className={styles.SelectedName}>{displayValue}</span>
|
<span className={styles.SelectedName}>{displayValue}</span>
|
||||||
{missionType && (
|
{missionType && (
|
||||||
|
|
@ -291,6 +304,7 @@ export function MissionSelect({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
|
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
|
||||||
</div>
|
</div>
|
||||||
<ComboboxPopover
|
<ComboboxPopover
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ import {
|
||||||
particleFragmentShader,
|
particleFragmentShader,
|
||||||
} from "../particles/shaders";
|
} from "../particles/shaders";
|
||||||
import type { EmitterDataResolved } from "../particles/types";
|
import type { EmitterDataResolved } from "../particles/types";
|
||||||
import type {
|
import type { StreamSnapshot, StreamingPlayback } from "../stream/types";
|
||||||
StreamSnapshot,
|
import { createLogger } from "../logger";
|
||||||
StreamingPlayback,
|
|
||||||
} from "../stream/types";
|
const log = createLogger("ParticleEffects");
|
||||||
import { useDebug, useSettings } from "./SettingsProvider";
|
import { useDebug, useSettings } from "./SettingsProvider";
|
||||||
import { useAudio } from "./AudioContext";
|
import { useAudio } from "./AudioContext";
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,7 +50,7 @@ import {
|
||||||
trackSound,
|
trackSound,
|
||||||
untrackSound,
|
untrackSound,
|
||||||
} from "./AudioEmitter";
|
} from "./AudioEmitter";
|
||||||
import { effectNow, engineStore } from "../state";
|
import { effectNow, engineStore } from "../state/engineStore";
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
|
|
||||||
|
|
@ -96,9 +96,15 @@ function getParticleTexture(textureName: string): Texture {
|
||||||
// ── Debug geometry (reusable) ──
|
// ── Debug geometry (reusable) ──
|
||||||
|
|
||||||
const _debugOriginGeo = new SphereGeometry(1, 6, 6);
|
const _debugOriginGeo = new SphereGeometry(1, 6, 6);
|
||||||
const _debugOriginMat = new MeshBasicMaterial({ color: 0xff0000, wireframe: true });
|
const _debugOriginMat = new MeshBasicMaterial({
|
||||||
|
color: 0xff0000,
|
||||||
|
wireframe: true,
|
||||||
|
});
|
||||||
const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3);
|
const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3);
|
||||||
const _debugParticleMat = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
|
const _debugParticleMat = new MeshBasicMaterial({
|
||||||
|
color: 0x00ff00,
|
||||||
|
wireframe: true,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Explosion wireframe sphere geometry (reusable) ──
|
// ── Explosion wireframe sphere geometry (reusable) ──
|
||||||
|
|
||||||
|
|
@ -116,7 +122,10 @@ interface ActiveExplosionSphere {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a text label sprite for an explosion sphere. */
|
/** Create a text label sprite for an explosion sphere. */
|
||||||
function createExplosionLabel(text: string, color: number): { sprite: Sprite; material: SpriteMaterial } {
|
function createExplosionLabel(
|
||||||
|
text: string,
|
||||||
|
color: number,
|
||||||
|
): { sprite: Sprite; material: SpriteMaterial } {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
const fontSize = 32;
|
const fontSize = 32;
|
||||||
|
|
@ -431,10 +440,10 @@ function getExplosionColor(dataBlock: string | undefined): number {
|
||||||
* Each entry is `{x, y, z}` with values in range 0–16000 (scale multiplier).
|
* Each entry is `{x, y, z}` with values in range 0–16000 (scale multiplier).
|
||||||
* Falls back to `particleRadius` or a default of 5.
|
* Falls back to `particleRadius` or a default of 5.
|
||||||
*/
|
*/
|
||||||
function getExplosionRadius(
|
function getExplosionRadius(expBlock: Record<string, unknown>): number {
|
||||||
expBlock: Record<string, unknown>,
|
const sizes = expBlock.sizes as
|
||||||
): number {
|
| Array<{ x: number; y: number; z: number }>
|
||||||
const sizes = expBlock.sizes as Array<{ x: number; y: number; z: number }> | undefined;
|
| undefined;
|
||||||
if (Array.isArray(sizes) && sizes.length > 0) {
|
if (Array.isArray(sizes) && sizes.length > 0) {
|
||||||
let maxVal = 0;
|
let maxVal = 0;
|
||||||
for (const s of sizes) {
|
for (const s of sizes) {
|
||||||
|
|
@ -552,14 +561,17 @@ function checkShaderCompilation(
|
||||||
material: ShaderMaterial,
|
material: ShaderMaterial,
|
||||||
label: string,
|
label: string,
|
||||||
): void {
|
): void {
|
||||||
const props = renderer.properties.get(material) as { currentProgram?: { program: WebGLProgram } };
|
const props = renderer.properties.get(material) as {
|
||||||
|
currentProgram?: { program: WebGLProgram };
|
||||||
|
};
|
||||||
const program = props.currentProgram;
|
const program = props.currentProgram;
|
||||||
if (!program) return; // Not yet compiled.
|
if (!program) return; // Not yet compiled.
|
||||||
const glProgram = program!.program;
|
const glProgram = program!.program;
|
||||||
const glContext = renderer.getContext();
|
const glContext = renderer.getContext();
|
||||||
if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) {
|
if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) {
|
||||||
console.error(
|
log.error(
|
||||||
`[ParticleFX] Shader LINK ERROR (${label}):`,
|
"Shader LINK ERROR (%s): %s",
|
||||||
|
label,
|
||||||
glContext.getProgramInfoLog(glProgram),
|
glContext.getProgramInfoLog(glProgram),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -847,7 +859,8 @@ export function ParticleEffects({
|
||||||
group.add(sphereMesh);
|
group.add(sphereMesh);
|
||||||
|
|
||||||
const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`;
|
const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`;
|
||||||
const { sprite: labelSprite, material: labelMat } = createExplosionLabel(labelText, color);
|
const { sprite: labelSprite, material: labelMat } =
|
||||||
|
createExplosionLabel(labelText, color);
|
||||||
labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]);
|
labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]);
|
||||||
labelSprite.frustumCulled = false;
|
labelSprite.frustumCulled = false;
|
||||||
group.add(labelSprite);
|
group.add(labelSprite);
|
||||||
|
|
@ -891,9 +904,8 @@ export function ParticleEffects({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp denormalized velocity values (parser bug workaround).
|
// Clamp denormalized velocity values (parser bug workaround).
|
||||||
const initVelocity = Math.abs(swData.velocity) > 1e-10
|
const initVelocity =
|
||||||
? swData.velocity
|
Math.abs(swData.velocity) > 1e-10 ? swData.velocity : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
activeShockwavesRef.current.push({
|
activeShockwavesRef.current.push({
|
||||||
entityId: entity.id as string,
|
entityId: entity.id as string,
|
||||||
|
|
@ -910,7 +922,6 @@ export function ParticleEffects({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect projectile entities with trail emitters (maintainEmitterId).
|
// Detect projectile entities with trail emitters (maintainEmitterId).
|
||||||
|
|
@ -918,7 +929,10 @@ export function ParticleEffects({
|
||||||
for (const entity of snapshot.entities) {
|
for (const entity of snapshot.entities) {
|
||||||
currentEntityIds.add(entity.id);
|
currentEntityIds.add(entity.id);
|
||||||
|
|
||||||
if (!entity.maintainEmitterId || trailEntitiesRef.current.has(entity.id)) {
|
if (
|
||||||
|
!entity.maintainEmitterId ||
|
||||||
|
trailEntitiesRef.current.has(entity.id)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trailEntitiesRef.current.add(entity.id);
|
trailEntitiesRef.current.add(entity.id);
|
||||||
|
|
@ -933,7 +947,10 @@ export function ParticleEffects({
|
||||||
? [...entity.position]
|
? [...entity.position]
|
||||||
: [0, 0, 0];
|
: [0, 0, 0];
|
||||||
|
|
||||||
const emitter = new EmitterInstance(emitterData, MAX_PARTICLES_PER_EMITTER);
|
const emitter = new EmitterInstance(
|
||||||
|
emitterData,
|
||||||
|
MAX_PARTICLES_PER_EMITTER,
|
||||||
|
);
|
||||||
|
|
||||||
const texture = getParticleTexture(emitterData.particles.textureName);
|
const texture = getParticleTexture(emitterData.particles.textureName);
|
||||||
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
|
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
|
||||||
|
|
@ -980,7 +997,11 @@ export function ParticleEffects({
|
||||||
|
|
||||||
// One-time shader compilation check.
|
// One-time shader compilation check.
|
||||||
if (!entry.shaderChecked) {
|
if (!entry.shaderChecked) {
|
||||||
checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream");
|
checkShaderCompilation(
|
||||||
|
gl,
|
||||||
|
entry.material,
|
||||||
|
entry.isBurst ? "burst" : "stream",
|
||||||
|
);
|
||||||
entry.shaderChecked = true;
|
entry.shaderChecked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1166,7 +1187,13 @@ export function ParticleEffects({
|
||||||
// ── Audio: explosion impact sounds ──
|
// ── Audio: explosion impact sounds ──
|
||||||
// Only process new audio events while playing to avoid triggering
|
// Only process new audio events while playing to avoid triggering
|
||||||
// sounds during pause (existing sounds are frozen via AudioContext.suspend).
|
// sounds during pause (existing sounds are frozen via AudioContext.suspend).
|
||||||
if (isPlaying && audioEnabled && audioLoader && audioListener && groupRef.current) {
|
if (
|
||||||
|
isPlaying &&
|
||||||
|
audioEnabled &&
|
||||||
|
audioLoader &&
|
||||||
|
audioListener &&
|
||||||
|
groupRef.current
|
||||||
|
) {
|
||||||
for (const entity of snapshot.entities) {
|
for (const entity of snapshot.entities) {
|
||||||
if (
|
if (
|
||||||
entity.type !== "Explosion" ||
|
entity.type !== "Explosion" ||
|
||||||
|
|
@ -1205,7 +1232,11 @@ export function ParticleEffects({
|
||||||
const projSounds = projectileSoundsRef.current;
|
const projSounds = projectileSoundsRef.current;
|
||||||
|
|
||||||
for (const entity of snapshot.entities) {
|
for (const entity of snapshot.entities) {
|
||||||
if (entity.type !== "Projectile" || !entity.dataBlockId || !entity.position) {
|
if (
|
||||||
|
entity.type !== "Projectile" ||
|
||||||
|
!entity.dataBlockId ||
|
||||||
|
!entity.position
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (projSounds.has(entity.id)) {
|
if (projSounds.has(entity.id)) {
|
||||||
|
|
@ -1268,8 +1299,16 @@ export function ParticleEffects({
|
||||||
for (const [entityId, sound] of projSounds) {
|
for (const [entityId, sound] of projSounds) {
|
||||||
if (!currentEntityIds.has(entityId)) {
|
if (!currentEntityIds.has(entityId)) {
|
||||||
untrackSound(sound);
|
untrackSound(sound);
|
||||||
try { sound.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
groupRef.current?.remove(sound);
|
groupRef.current?.remove(sound);
|
||||||
projSounds.delete(entityId);
|
projSounds.delete(entityId);
|
||||||
}
|
}
|
||||||
|
|
@ -1360,8 +1399,16 @@ export function ParticleEffects({
|
||||||
// Clean up projectile sounds.
|
// Clean up projectile sounds.
|
||||||
for (const [, sound] of projectileSoundsRef.current) {
|
for (const [, sound] of projectileSoundsRef.current) {
|
||||||
untrackSound(sound);
|
untrackSound(sound);
|
||||||
try { sound.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
if (group) group.remove(sound);
|
if (group) group.remove(sound);
|
||||||
}
|
}
|
||||||
projectileSoundsRef.current.clear();
|
projectileSoundsRef.current.clear();
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Top-right cluster: compass + bars ── */
|
|
||||||
|
|
||||||
.TopRight {
|
.TopRight {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 56px;
|
top: 10px;
|
||||||
right: 8px;
|
right: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -87,103 +85,12 @@
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chat Window (top-left) ── */
|
|
||||||
|
|
||||||
.ChatContainer {
|
|
||||||
position: absolute;
|
|
||||||
top: 56px;
|
|
||||||
left: 0;
|
|
||||||
max-width: 420px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
pointer-events: auto;
|
|
||||||
border: 1px solid rgba(44, 172, 181, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatWindow {
|
|
||||||
max-width: 450px;
|
|
||||||
max-height: 12.5em;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: rgba(0, 50, 60, 0.65);
|
|
||||||
padding: 6px 8px;
|
|
||||||
user-select: text;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.333333;
|
|
||||||
/* Thin scrollbar that doesn't take much space. */
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatMessage {
|
|
||||||
padding: 1px 0;
|
|
||||||
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
|
|
||||||
color: rgb(44, 172, 181);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatInputForm {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatInput {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(0, 50, 60, 0.8);
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid rgba(78, 179, 167, 0.2);
|
|
||||||
border-radius: 0;
|
|
||||||
color: rgb(40, 231, 240);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatInput::placeholder {
|
|
||||||
color: rgba(44, 172, 181, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ChatInput:focus {
|
|
||||||
background: rgba(0, 50, 60, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* T2 GuiChatHudProfile fontColors palette (\c0–\c9). */
|
|
||||||
.ChatColor0 {
|
|
||||||
color: rgb(44, 172, 181);
|
|
||||||
}
|
|
||||||
.ChatColor1 {
|
|
||||||
color: rgb(4, 235, 105);
|
|
||||||
}
|
|
||||||
.ChatColor2 {
|
|
||||||
color: rgb(219, 200, 128);
|
|
||||||
}
|
|
||||||
.ChatColor3 {
|
|
||||||
color: rgb(77, 253, 95);
|
|
||||||
}
|
|
||||||
.ChatColor4 {
|
|
||||||
color: rgb(40, 231, 240);
|
|
||||||
}
|
|
||||||
.ChatColor5 {
|
|
||||||
color: rgb(200, 200, 50);
|
|
||||||
}
|
|
||||||
.ChatColor6 {
|
|
||||||
color: rgb(200, 200, 200);
|
|
||||||
}
|
|
||||||
.ChatColor7 {
|
|
||||||
color: rgb(220, 220, 20);
|
|
||||||
}
|
|
||||||
.ChatColor8 {
|
|
||||||
color: rgb(150, 150, 250);
|
|
||||||
}
|
|
||||||
.ChatColor9 {
|
|
||||||
color: rgb(60, 220, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Team Scores (bottom-left) ── */
|
/* ── Team Scores (bottom-left) ── */
|
||||||
|
|
||||||
.TeamScores {
|
.TeamScores {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 130px;
|
bottom: 8px;
|
||||||
left: 0;
|
left: 8px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +133,7 @@
|
||||||
|
|
||||||
.PackInventoryHUD {
|
.PackInventoryHUD {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100px;
|
bottom: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,16 @@
|
||||||
import { useRef, useEffect, useState, useCallback } from "react";
|
import { useEngineSelector } from "../state/engineStore";
|
||||||
import { useRecording } from "./RecordingProvider";
|
|
||||||
import { useEngineSelector } from "../state";
|
|
||||||
import { textureToUrl } from "../loaders";
|
import { textureToUrl } from "../loaders";
|
||||||
import { liveConnectionStore } from "../state/liveConnectionStore";
|
import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
|
||||||
import type {
|
|
||||||
ChatSegment,
|
|
||||||
ChatMessage,
|
|
||||||
StreamEntity,
|
|
||||||
TeamScore,
|
|
||||||
WeaponsHudSlot,
|
|
||||||
} from "../stream/types";
|
|
||||||
import styles from "./PlayerHUD.module.css";
|
import styles from "./PlayerHUD.module.css";
|
||||||
// ── Compass ──
|
import { ChatWindow } from "./ChatWindow";
|
||||||
|
|
||||||
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
|
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
|
||||||
const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
|
const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
|
||||||
function Compass({ yaw }: { yaw: number | undefined }) {
|
|
||||||
|
function Compass() {
|
||||||
|
const yaw = useEngineSelector(
|
||||||
|
(state) => state.playback.streamSnapshot?.camera?.yaw,
|
||||||
|
);
|
||||||
if (yaw == null) return null;
|
if (yaw == null) return null;
|
||||||
// The ring notch is the fixed heading indicator (always "forward" at top).
|
// The ring notch is the fixed heading indicator (always "forward" at top).
|
||||||
// The NSEW letters rotate to show world cardinal directions relative to
|
// The NSEW letters rotate to show world cardinal directions relative to
|
||||||
|
|
@ -33,41 +29,51 @@ function Compass({ yaw }: { yaw: number | undefined }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Health / Energy bars ──
|
|
||||||
function HealthBar({ value }: { value: number }) {
|
function HealthBar() {
|
||||||
const pct = Math.max(0, Math.min(100, value * 100));
|
const health = useEngineSelector(
|
||||||
|
(state) => state.playback.streamSnapshot?.status?.health,
|
||||||
|
);
|
||||||
|
if (health == null) return null;
|
||||||
|
const pct = Math.max(0, Math.min(100, health * 100));
|
||||||
return (
|
return (
|
||||||
<div className={styles.BarTrack}>
|
<div className={styles.BarTrack}>
|
||||||
<div className={styles.BarFillHealth} style={{ width: `${pct}%` }} />
|
<div className={styles.BarFillHealth} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function EnergyBar({ value }: { value: number }) {
|
|
||||||
const pct = Math.max(0, Math.min(100, value * 100));
|
function EnergyBar() {
|
||||||
|
const energy = useEngineSelector(
|
||||||
|
(state) => state.playback.streamSnapshot?.status?.energy,
|
||||||
|
);
|
||||||
|
if (energy == null) return null;
|
||||||
|
const pct = Math.max(0, Math.min(100, energy * 100));
|
||||||
return (
|
return (
|
||||||
<div className={styles.BarTrack}>
|
<div className={styles.BarTrack}>
|
||||||
<div className={styles.BarFillEnergy} style={{ width: `${pct}%` }} />
|
<div className={styles.BarFillEnergy} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Reticle ──
|
|
||||||
const RETICLE_TEXTURES: Record<string, string> = {
|
const RETICLE_TEXTURES: Record<string, string> = {
|
||||||
weapon_sniper: "gui/hud_ret_sniper",
|
weapon_sniper: "gui/hud_ret_sniper",
|
||||||
weapon_shocklance: "gui/hud_ret_shocklance",
|
weapon_shocklance: "gui/hud_ret_shocklance",
|
||||||
weapon_targeting: "gui/hud_ret_targlaser",
|
weapon_targeting: "gui/hud_ret_targlaser",
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeWeaponName(shape: string | undefined): string {
|
function normalizeWeaponName(shape: string | undefined): string {
|
||||||
if (!shape) return "";
|
if (!shape) return "";
|
||||||
return shape.replace(/\.dts$/i, "").toLowerCase();
|
return shape.replace(/\.dts$/i, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function Reticle() {
|
function Reticle() {
|
||||||
const weaponShape = useEngineSelector((state) => {
|
const weaponShape = useEngineSelector((state) => {
|
||||||
const snap = state.playback.streamSnapshot;
|
const snap = state.playback.streamSnapshot;
|
||||||
if (!snap || snap.camera?.mode !== "first-person") return undefined;
|
if (!snap || snap.camera?.mode !== "first-person") return undefined;
|
||||||
const ctrl = snap.controlPlayerGhostId;
|
const ctrl = snap.controlPlayerGhostId;
|
||||||
if (!ctrl) return undefined;
|
if (!ctrl) return undefined;
|
||||||
return snap.entities.find((e: StreamEntity) => e.id === ctrl)
|
return snap.entities.find((e: StreamEntity) => e.id === ctrl)?.weaponShape;
|
||||||
?.weaponShape;
|
|
||||||
});
|
});
|
||||||
if (weaponShape === undefined) return null;
|
if (weaponShape === undefined) return null;
|
||||||
const weapon = normalizeWeaponName(weaponShape);
|
const weapon = normalizeWeaponName(weaponShape);
|
||||||
|
|
@ -75,7 +81,6 @@ function Reticle() {
|
||||||
if (textureName) {
|
if (textureName) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.Reticle}>
|
<div className={styles.Reticle}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={textureToUrl(textureName)}
|
src={textureToUrl(textureName)}
|
||||||
alt=""
|
alt=""
|
||||||
|
|
@ -90,7 +95,7 @@ function Reticle() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Weapon HUD (right side weapon list) ──
|
|
||||||
/** Maps $WeaponsHudData indices to simple icon textures (no baked background)
|
/** Maps $WeaponsHudData indices to simple icon textures (no baked background)
|
||||||
* and labels. Mortar uses hud_new_ because no simple variant exists. */
|
* and labels. Mortar uses hud_new_ because no simple variant exists. */
|
||||||
const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
|
const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
|
||||||
|
|
@ -114,6 +119,7 @@ const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
|
||||||
16: { icon: "gui/hud_shocklance", label: "Shocklance" },
|
16: { icon: "gui/hud_shocklance", label: "Shocklance" },
|
||||||
17: { icon: "gui/hud_new_mortar", label: "Mortar" },
|
17: { icon: "gui/hud_new_mortar", label: "Mortar" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Precompute URLs so we don't call textureToUrl on every render.
|
// Precompute URLs so we don't call textureToUrl on every render.
|
||||||
const WEAPON_HUD_ICON_URLS = new Map(
|
const WEAPON_HUD_ICON_URLS = new Map(
|
||||||
Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [
|
Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [
|
||||||
|
|
@ -121,9 +127,11 @@ const WEAPON_HUD_ICON_URLS = new Map(
|
||||||
textureToUrl(w.icon),
|
textureToUrl(w.icon),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Targeting laser HUD indices (standard + TR2 variants). */
|
/** Targeting laser HUD indices (standard + TR2 variants). */
|
||||||
const TARGETING_LASER_INDICES = new Set([9, 14, 15]);
|
const TARGETING_LASER_INDICES = new Set([9, 14, 15]);
|
||||||
const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity");
|
const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity");
|
||||||
|
|
||||||
function WeaponSlotIcon({
|
function WeaponSlotIcon({
|
||||||
slot,
|
slot,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -138,14 +146,12 @@ function WeaponSlotIcon({
|
||||||
<div
|
<div
|
||||||
className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`}
|
className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={WEAPON_HUD_ICON_URLS.get(slot.index)!}
|
src={WEAPON_HUD_ICON_URLS.get(slot.index)!}
|
||||||
alt={info.label}
|
alt={info.label}
|
||||||
className={styles.PackInvIcon}
|
className={styles.PackInvIcon}
|
||||||
/>
|
/>
|
||||||
{isInfinite ? (
|
{isInfinite ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={INFINITY_ICON_URL}
|
src={INFINITY_ICON_URL}
|
||||||
alt="\u221E"
|
alt="\u221E"
|
||||||
|
|
@ -157,6 +163,7 @@ function WeaponSlotIcon({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeaponHUD() {
|
function WeaponHUD() {
|
||||||
const weaponsHud = useEngineSelector(
|
const weaponsHud = useEngineSelector(
|
||||||
(state) => state.playback.streamSnapshot?.weaponsHud,
|
(state) => state.playback.streamSnapshot?.weaponsHud,
|
||||||
|
|
@ -191,7 +198,7 @@ function WeaponHUD() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Team Scores (bottom-left) ──
|
|
||||||
/** Default team names from serverDefaults.cs. */
|
/** Default team names from serverDefaults.cs. */
|
||||||
const DEFAULT_TEAM_NAMES: Record<number, string> = {
|
const DEFAULT_TEAM_NAMES: Record<number, string> = {
|
||||||
1: "Storm",
|
1: "Storm",
|
||||||
|
|
@ -201,6 +208,7 @@ const DEFAULT_TEAM_NAMES: Record<number, string> = {
|
||||||
5: "Blood Eagle",
|
5: "Blood Eagle",
|
||||||
6: "Phoenix",
|
6: "Phoenix",
|
||||||
};
|
};
|
||||||
|
|
||||||
function TeamScores() {
|
function TeamScores() {
|
||||||
const teamScores = useEngineSelector(
|
const teamScores = useEngineSelector(
|
||||||
(state) => state.playback.streamSnapshot?.teamScores,
|
(state) => state.playback.streamSnapshot?.teamScores,
|
||||||
|
|
@ -242,101 +250,7 @@ function TeamScores() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Chat Window (top-left) ──
|
|
||||||
/** Map a colorCode to a CSS module class name (c0–c9 GuiChatHudProfile). */
|
|
||||||
const CHAT_COLOR_CLASSES: Record<number, string> = {
|
|
||||||
0: styles.ChatColor0,
|
|
||||||
1: styles.ChatColor1,
|
|
||||||
2: styles.ChatColor2,
|
|
||||||
3: styles.ChatColor3,
|
|
||||||
4: styles.ChatColor4,
|
|
||||||
5: styles.ChatColor5,
|
|
||||||
6: styles.ChatColor6,
|
|
||||||
7: styles.ChatColor7,
|
|
||||||
8: styles.ChatColor8,
|
|
||||||
9: styles.ChatColor9,
|
|
||||||
};
|
|
||||||
function segmentColorClass(colorCode: number): string {
|
|
||||||
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
|
|
||||||
}
|
|
||||||
function chatColorClass(msg: ChatMessage): string {
|
|
||||||
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
|
|
||||||
return CHAT_COLOR_CLASSES[msg.colorCode];
|
|
||||||
}
|
|
||||||
// Fallback: default to \c0 (teal). Messages with detected codes (like \c2
|
|
||||||
// for flag events) will match above; \c0 kill messages may lose their null
|
|
||||||
// byte color code, so the correct default for server messages is c0.
|
|
||||||
return CHAT_COLOR_CLASSES[0];
|
|
||||||
}
|
|
||||||
function ChatWindow({ isLive }: { isLive: boolean }) {
|
|
||||||
const messages = useEngineSelector(
|
|
||||||
(state) => state.playback.streamSnapshot?.chatMessages,
|
|
||||||
);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const prevCountRef = useRef(0);
|
|
||||||
const [chatText, setChatText] = useState("");
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new messages arrive.
|
|
||||||
const msgCount = messages?.length ?? 0;
|
|
||||||
useEffect(() => {
|
|
||||||
if (msgCount > prevCountRef.current && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
prevCountRef.current = msgCount;
|
|
||||||
}, [msgCount]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const text = chatText.trim();
|
|
||||||
if (!text) return;
|
|
||||||
liveConnectionStore.getState().sendCommand("messageSent", text);
|
|
||||||
setChatText("");
|
|
||||||
},
|
|
||||||
[chatText],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasMessages = !!messages?.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.ChatContainer}>
|
|
||||||
{hasMessages && (
|
|
||||||
<div ref={scrollRef} className={styles.ChatWindow}>
|
|
||||||
{messages!.map((msg: ChatMessage, i: number) => (
|
|
||||||
<div key={msg.id} className={styles.ChatMessage}>
|
|
||||||
{msg.segments ? (
|
|
||||||
msg.segments.map((seg: ChatSegment, j: number) => (
|
|
||||||
<span key={j} className={segmentColorClass(seg.colorCode)}>
|
|
||||||
{seg.text}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className={chatColorClass(msg)}>
|
|
||||||
{msg.sender ? `${msg.sender}: ` : ""}
|
|
||||||
{msg.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isLive && (
|
|
||||||
<form className={styles.ChatInputForm} onSubmit={handleSubmit}>
|
|
||||||
<input
|
|
||||||
className={styles.ChatInput}
|
|
||||||
type="text"
|
|
||||||
placeholder="Say something…"
|
|
||||||
value={chatText}
|
|
||||||
onChange={(e) => setChatText(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// ── Backpack + Inventory HUD (bottom-right) ──
|
// ── Backpack + Inventory HUD (bottom-right) ──
|
||||||
/** Maps $BackpackHudData indices to icon textures. */
|
/** Maps $BackpackHudData indices to icon textures. */
|
||||||
const BACKPACK_ICONS: Record<number, string> = {
|
const BACKPACK_ICONS: Record<number, string> = {
|
||||||
|
|
@ -429,7 +343,6 @@ function PackAndInventoryHUD() {
|
||||||
<div
|
<div
|
||||||
className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`}
|
className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={packIconUrl} alt="" className={styles.PackInvIcon} />
|
<img src={packIconUrl} alt="" className={styles.PackInvIcon} />
|
||||||
<span className={styles.PackInvCount}>
|
<span className={styles.PackInvCount}>
|
||||||
{backpackHud!.text || "\u00A0"}
|
{backpackHud!.text || "\u00A0"}
|
||||||
|
|
@ -442,7 +355,6 @@ function PackAndInventoryHUD() {
|
||||||
if (!info || !iconUrl) return null;
|
if (!info || !iconUrl) return null;
|
||||||
return (
|
return (
|
||||||
<div key={slotId} className={styles.PackInvItem}>
|
<div key={slotId} className={styles.PackInvItem}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={iconUrl}
|
src={iconUrl}
|
||||||
alt={info.label}
|
alt={info.label}
|
||||||
|
|
@ -457,32 +369,31 @@ function PackAndInventoryHUD() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// ── Main HUD ──
|
|
||||||
export function PlayerHUD({ isLive = false }: { isLive?: boolean } = {}) {
|
export function PlayerHUD() {
|
||||||
const recording = useRecording();
|
const hasControlPlayer = useEngineSelector(
|
||||||
const streamSnapshot = useEngineSelector(
|
(state) => !!state.playback.streamSnapshot?.controlPlayerGhostId,
|
||||||
(state) => state.playback.streamSnapshot,
|
|
||||||
);
|
);
|
||||||
if (!recording && !isLive) return null;
|
|
||||||
const status = streamSnapshot?.status;
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.PlayerHUD}>
|
<div className={styles.PlayerHUD}>
|
||||||
<ChatWindow isLive={isLive} />
|
<ChatWindow />
|
||||||
{status && (
|
|
||||||
<>
|
|
||||||
<div className={styles.TopRight}>
|
<div className={styles.TopRight}>
|
||||||
|
{hasControlPlayer && (
|
||||||
<div className={styles.Bars}>
|
<div className={styles.Bars}>
|
||||||
<HealthBar value={status.health} />
|
<HealthBar />
|
||||||
<EnergyBar value={status.energy} />
|
<EnergyBar />
|
||||||
</div>
|
</div>
|
||||||
<Compass yaw={streamSnapshot?.camera?.yaw} />
|
)}
|
||||||
|
<Compass />
|
||||||
</div>
|
</div>
|
||||||
|
{hasControlPlayer && (
|
||||||
|
<>
|
||||||
<WeaponHUD />
|
<WeaponHUD />
|
||||||
<PackAndInventoryHUD />
|
<PackAndInventoryHUD />
|
||||||
<TeamScores />
|
|
||||||
<Reticle />
|
<Reticle />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<TeamScores />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,8 +26,9 @@ import { pickMoveAnimation } from "../stream/playerAnimation";
|
||||||
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
|
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
|
||||||
import type { WeaponAnimState } from "../stream/weaponStateMachine";
|
import type { WeaponAnimState } from "../stream/weaponStateMachine";
|
||||||
import { getAliasedActions } from "../torqueScript/shapeConstructor";
|
import { getAliasedActions } from "../torqueScript/shapeConstructor";
|
||||||
import { useStaticShape } from "./GenericShape";
|
import { useStaticShape, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeErrorBoundary } from "./EntityScene";
|
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
|
||||||
|
import { DebugSuspense } from "./DebugSuspense";
|
||||||
import { useAudio } from "./AudioContext";
|
import { useAudio } from "./AudioContext";
|
||||||
import {
|
import {
|
||||||
resolveAudioProfile,
|
resolveAudioProfile,
|
||||||
|
|
@ -39,7 +40,7 @@ import {
|
||||||
} from "./AudioEmitter";
|
} from "./AudioEmitter";
|
||||||
import { audioToUrl } from "../loaders";
|
import { audioToUrl } from "../loaders";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import { useSettings } from "./SettingsProvider";
|
||||||
import { useEngineStoreApi, useEngineSelector } from "../state";
|
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
|
||||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||||
import type { PlayerEntity } from "../state/gameEntityTypes";
|
import type { PlayerEntity } from "../state/gameEntityTypes";
|
||||||
|
|
||||||
|
|
@ -60,8 +61,16 @@ function getArmThread(weaponShape: string | undefined): string {
|
||||||
const NUM_TABLE_ACTION_ANIMS = 8;
|
const NUM_TABLE_ACTION_ANIMS = 8;
|
||||||
|
|
||||||
/** Table action names in engine order (indices 0-7). */
|
/** Table action names in engine order (indices 0-7). */
|
||||||
const TABLE_ACTION_NAMES = ["root", "run", "back", "side", "fall", "jet", "jump", "land"];
|
const TABLE_ACTION_NAMES = [
|
||||||
|
"root",
|
||||||
|
"run",
|
||||||
|
"back",
|
||||||
|
"side",
|
||||||
|
"fall",
|
||||||
|
"jet",
|
||||||
|
"jump",
|
||||||
|
"land",
|
||||||
|
];
|
||||||
|
|
||||||
interface ActionAnimEntry {
|
interface ActionAnimEntry {
|
||||||
/** GLB clip name (lowercase, e.g. "diehead"). */
|
/** GLB clip name (lowercase, e.g. "diehead"). */
|
||||||
|
|
@ -90,7 +99,10 @@ function buildActionAnimMap(
|
||||||
const spaceIdx = entry.indexOf(" ");
|
const spaceIdx = entry.indexOf(" ");
|
||||||
if (spaceIdx === -1) continue;
|
if (spaceIdx === -1) continue;
|
||||||
const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
|
const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
|
||||||
const alias = entry.slice(spaceIdx + 1).trim().toLowerCase();
|
const alias = entry
|
||||||
|
.slice(spaceIdx + 1)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq"))
|
if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq"))
|
||||||
continue;
|
continue;
|
||||||
const clipName = dsqFile.slice(shapePrefix.length, -4);
|
const clipName = dsqFile.slice(shapePrefix.length, -4);
|
||||||
|
|
@ -131,8 +143,16 @@ function stopLoopingSound(
|
||||||
const sound = soundRef.current;
|
const sound = soundRef.current;
|
||||||
if (!sound) return;
|
if (!sound) return;
|
||||||
untrackSound(sound);
|
untrackSound(sound);
|
||||||
try { sound.stop(); } catch { /* already stopped */ }
|
try {
|
||||||
try { sound.disconnect(); } catch { /* already disconnected */ }
|
sound.stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sound.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
parent?.remove(sound);
|
parent?.remove(sound);
|
||||||
soundRef.current = null;
|
soundRef.current = null;
|
||||||
stateRef.current = -1;
|
stateRef.current = -1;
|
||||||
|
|
@ -152,13 +172,12 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
const gltf = useStaticShape(shapeName!);
|
const gltf = useStaticShape(shapeName!);
|
||||||
const shapeAliases = useEngineSelector((state) => {
|
const shapeAliases = useEngineSelector((state) => {
|
||||||
const sn = shapeName?.toLowerCase();
|
const sn = shapeName?.toLowerCase();
|
||||||
return sn
|
return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
|
||||||
? state.runtime.sequenceAliases.get(sn)
|
|
||||||
: undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
|
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
|
||||||
const { clonedScene, mixer, mount0, mount1, iflInitializers } = useMemo(() => {
|
const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
|
||||||
|
useMemo(() => {
|
||||||
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
||||||
const iflInits = processShapeScene(scene);
|
const iflInits = processShapeScene(scene);
|
||||||
|
|
||||||
|
|
@ -175,12 +194,21 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
|
|
||||||
let m0: Object3D | null = null;
|
let m0: Object3D | null = null;
|
||||||
let m1: Object3D | null = null;
|
let m1: Object3D | null = null;
|
||||||
|
let m2: Object3D | null = null;
|
||||||
scene.traverse((n) => {
|
scene.traverse((n) => {
|
||||||
if (!m0 && n.name === "Mount0") m0 = n;
|
if (!m0 && n.name === "Mount0") m0 = n;
|
||||||
if (!m1 && n.name === "Mount1") m1 = n;
|
if (!m1 && n.name === "Mount1") m1 = n;
|
||||||
|
if (!m2 && n.name === "Mount2") m2 = n;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { clonedScene: scene, mixer: mix, mount0: m0, mount1: m1, iflInitializers: iflInits };
|
return {
|
||||||
|
clonedScene: scene,
|
||||||
|
mixer: mix,
|
||||||
|
mount0: m0,
|
||||||
|
mount1: m1,
|
||||||
|
mount2: m2,
|
||||||
|
iflInitializers: iflInits,
|
||||||
|
};
|
||||||
}, [gltf]);
|
}, [gltf]);
|
||||||
|
|
||||||
// Build case-insensitive clip lookup with alias support.
|
// Build case-insensitive clip lookup with alias support.
|
||||||
|
|
@ -226,7 +254,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
// place) since multiple player entities share the same GLTF cache.
|
// place) since multiple player entities share the same GLTF cache.
|
||||||
|
|
||||||
// Head blend actions.
|
// Head blend actions.
|
||||||
const blendRefs: typeof blendActionsRef.current = { head: null, headside: null };
|
const blendRefs: typeof blendActionsRef.current = {
|
||||||
|
head: null,
|
||||||
|
headside: null,
|
||||||
|
};
|
||||||
for (const { key, names } of [
|
for (const { key, names } of [
|
||||||
{ key: "head" as const, names: ["head"] },
|
{ key: "head" as const, names: ["head"] },
|
||||||
{ key: "headside" as const, names: ["headside"] },
|
{ key: "headside" as const, names: ["headside"] },
|
||||||
|
|
@ -318,6 +349,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
);
|
);
|
||||||
const packShapeRef = useRef(entity.packShape);
|
const packShapeRef = useRef(entity.packShape);
|
||||||
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
|
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
|
||||||
|
const flagShapeRef = useRef(entity.flagShape);
|
||||||
|
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
|
||||||
|
|
||||||
// Per-frame animation selection and mixer update.
|
// Per-frame animation selection and mixer update.
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
|
@ -329,6 +362,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
packShapeRef.current = entity.packShape;
|
packShapeRef.current = entity.packShape;
|
||||||
setCurrentPackShape(entity.packShape);
|
setCurrentPackShape(entity.packShape);
|
||||||
}
|
}
|
||||||
|
if (entity.flagShape !== flagShapeRef.current) {
|
||||||
|
flagShapeRef.current = entity.flagShape;
|
||||||
|
setCurrentFlagShape(entity.flagShape);
|
||||||
|
}
|
||||||
const playback = engineStore.getState().playback;
|
const playback = engineStore.getState().playback;
|
||||||
const isPlaying = playback.status === "playing";
|
const isPlaying = playback.status === "playing";
|
||||||
const time = streamPlaybackStore.getState().time;
|
const time = streamPlaybackStore.getState().time;
|
||||||
|
|
@ -343,9 +380,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
isDeadRef.current = true;
|
isDeadRef.current = true;
|
||||||
|
|
||||||
// The server sends the death animation as an actionAnim index.
|
// The server sends the death animation as an actionAnim index.
|
||||||
const deathEntry = kf.actionAnim != null
|
const deathEntry =
|
||||||
? actionAnimMap.get(kf.actionAnim)
|
kf.actionAnim != null ? actionAnimMap.get(kf.actionAnim) : undefined;
|
||||||
: undefined;
|
|
||||||
if (deathEntry) {
|
if (deathEntry) {
|
||||||
const deathAction = actions.get(deathEntry.clipName);
|
const deathAction = actions.get(deathEntry.clipName);
|
||||||
if (deathAction) {
|
if (deathAction) {
|
||||||
|
|
@ -368,7 +404,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
isDeadRef.current = false;
|
isDeadRef.current = false;
|
||||||
actionAnimRef.current = undefined;
|
actionAnimRef.current = undefined;
|
||||||
|
|
||||||
const deathAction = actions.get(currentAnimRef.current.name.toLowerCase());
|
const deathAction = actions.get(
|
||||||
|
currentAnimRef.current.name.toLowerCase(),
|
||||||
|
);
|
||||||
if (deathAction) {
|
if (deathAction) {
|
||||||
deathAction.stop();
|
deathAction.stop();
|
||||||
deathAction.setLoop(LoopRepeat, Infinity);
|
deathAction.setLoop(LoopRepeat, Infinity);
|
||||||
|
|
@ -386,8 +424,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
const prevActionAnim = actionAnimRef.current;
|
const prevActionAnim = actionAnimRef.current;
|
||||||
if (!isDeadRef.current && actionAnim !== prevActionAnim) {
|
if (!isDeadRef.current && actionAnim !== prevActionAnim) {
|
||||||
actionAnimRef.current = actionAnim;
|
actionAnimRef.current = actionAnim;
|
||||||
const isNonTableAction = actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS;
|
const isNonTableAction =
|
||||||
const wasNonTableAction = prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS;
|
actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS;
|
||||||
|
const wasNonTableAction =
|
||||||
|
prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS;
|
||||||
|
|
||||||
if (isNonTableAction) {
|
if (isNonTableAction) {
|
||||||
// Start or change action animation.
|
// Start or change action animation.
|
||||||
|
|
@ -395,7 +435,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const actionAction = actions.get(entry.clipName);
|
const actionAction = actions.get(entry.clipName);
|
||||||
if (actionAction) {
|
if (actionAction) {
|
||||||
const prevAction = actions.get(currentAnimRef.current.name.toLowerCase());
|
const prevAction = actions.get(
|
||||||
|
currentAnimRef.current.name.toLowerCase(),
|
||||||
|
);
|
||||||
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
||||||
actionAction.setLoop(LoopOnce, 1);
|
actionAction.setLoop(LoopOnce, 1);
|
||||||
actionAction.clampWhenFinished = true;
|
actionAction.clampWhenFinished = true;
|
||||||
|
|
@ -421,7 +463,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If atEnd, clamp the action animation at its final frame.
|
// If atEnd, clamp the action animation at its final frame.
|
||||||
if (actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS && kf?.actionAtEnd) {
|
if (
|
||||||
|
actionAnim != null &&
|
||||||
|
actionAnim >= NUM_TABLE_ACTION_ANIMS &&
|
||||||
|
kf?.actionAtEnd
|
||||||
|
) {
|
||||||
const entry = actionAnimMap.get(actionAnim);
|
const entry = actionAnimMap.get(actionAnim);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const actionAction = actions.get(entry.clipName);
|
const actionAction = actions.get(entry.clipName);
|
||||||
|
|
@ -432,7 +478,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movement animation selection (skip while dead or playing action anim).
|
// Movement animation selection (skip while dead or playing action anim).
|
||||||
const playingActionAnim = actionAnimRef.current != null &&
|
const playingActionAnim =
|
||||||
|
actionAnimRef.current != null &&
|
||||||
actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS;
|
actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS;
|
||||||
if (!isDeadRef.current && !playingActionAnim) {
|
if (!isDeadRef.current && !playingActionAnim) {
|
||||||
const anim = pickMoveAnimation(
|
const anim = pickMoveAnimation(
|
||||||
|
|
@ -518,24 +565,52 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||||
<primitive object={clonedScene} />
|
<primitive object={clonedScene} />
|
||||||
</group>
|
</group>
|
||||||
{currentWeaponShape && mount0 && (
|
{currentWeaponShape && mount0 && (
|
||||||
<ShapeErrorBoundary key={currentWeaponShape} fallback={null}>
|
<ShapeErrorBoundary
|
||||||
<Suspense fallback={null}>
|
key={currentWeaponShape}
|
||||||
<AnimatedWeaponModel
|
fallback={<ShapePlaceholder color="red" label={currentWeaponShape} />}
|
||||||
|
>
|
||||||
|
<DebugSuspense
|
||||||
|
label={`Weapon:${entity.id}/${currentWeaponShape}`}
|
||||||
|
fallback={
|
||||||
|
<ShapePlaceholder color="cyan" label={currentWeaponShape} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WeaponModel
|
||||||
entity={entity}
|
entity={entity}
|
||||||
weaponShape={currentWeaponShape}
|
weaponShape={currentWeaponShape}
|
||||||
mount0={mount0}
|
mount0={mount0}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</DebugSuspense>
|
||||||
</ShapeErrorBoundary>
|
</ShapeErrorBoundary>
|
||||||
)}
|
)}
|
||||||
{currentPackShape && mount1 && (
|
{currentPackShape && mount1 && (
|
||||||
<ShapeErrorBoundary key={currentPackShape} fallback={null}>
|
<ShapeErrorBoundary
|
||||||
<Suspense fallback={null}>
|
key={currentPackShape}
|
||||||
<MountedPackModel
|
fallback={<ShapePlaceholder color="red" label={currentPackShape} />}
|
||||||
packShape={currentPackShape}
|
>
|
||||||
mountBone={mount1}
|
<DebugSuspense
|
||||||
/>
|
label={`Pack:${entity.id}/${currentPackShape}`}
|
||||||
</Suspense>
|
fallback={
|
||||||
|
<ShapePlaceholder color="cyan" label={currentPackShape} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PackModel packShape={currentPackShape} mountBone={mount1} />
|
||||||
|
</DebugSuspense>
|
||||||
|
</ShapeErrorBoundary>
|
||||||
|
)}
|
||||||
|
{currentFlagShape && mount2 && (
|
||||||
|
<ShapeErrorBoundary
|
||||||
|
key={currentFlagShape}
|
||||||
|
fallback={<ShapePlaceholder color="red" label={currentFlagShape} />}
|
||||||
|
>
|
||||||
|
<DebugSuspense
|
||||||
|
label={`Flag:${entity.id}/${currentFlagShape}`}
|
||||||
|
fallback={
|
||||||
|
<ShapePlaceholder color="cyan" label={currentFlagShape} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PackModel packShape={currentFlagShape} mountBone={mount2} />
|
||||||
|
</DebugSuspense>
|
||||||
</ShapeErrorBoundary>
|
</ShapeErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -556,7 +631,9 @@ function buildSeqIndexToName(
|
||||||
try {
|
try {
|
||||||
const names: string[] = JSON.parse(raw);
|
const names: string[] = JSON.parse(raw);
|
||||||
return names.map((n) => n.toLowerCase());
|
return names.map((n) => n.toLowerCase());
|
||||||
} catch { /* fall through */ }
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return animations.map((a) => a.name.toLowerCase());
|
return animations.map((a) => a.name.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
@ -571,7 +648,7 @@ function buildSeqIndexToName(
|
||||||
* from the entity inside useFrame, since these fields are mutated per-tick
|
* from the entity inside useFrame, since these fields are mutated per-tick
|
||||||
* without triggering React re-renders.
|
* without triggering React re-renders.
|
||||||
*/
|
*/
|
||||||
function AnimatedWeaponModel({
|
function WeaponModel({
|
||||||
entity,
|
entity,
|
||||||
weaponShape,
|
weaponShape,
|
||||||
mount0,
|
mount0,
|
||||||
|
|
@ -584,8 +661,13 @@ function AnimatedWeaponModel({
|
||||||
const weaponGltf = useStaticShape(weaponShape);
|
const weaponGltf = useStaticShape(weaponShape);
|
||||||
|
|
||||||
// Clone weapon with skeleton bindings, create dedicated mixer.
|
// Clone weapon with skeleton bindings, create dedicated mixer.
|
||||||
const { weaponClone, weaponMixer, seqIndexToName, visNodesBySequence, weaponIflInitializers } =
|
const {
|
||||||
useMemo(() => {
|
weaponClone,
|
||||||
|
weaponMixer,
|
||||||
|
seqIndexToName,
|
||||||
|
visNodesBySequence,
|
||||||
|
weaponIflInitializers,
|
||||||
|
} = useMemo(() => {
|
||||||
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
|
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
|
||||||
const iflInits = processShapeScene(clone);
|
const iflInits = processShapeScene(clone);
|
||||||
|
|
||||||
|
|
@ -760,8 +842,10 @@ function AnimatedWeaponModel({
|
||||||
audioListener &&
|
audioListener &&
|
||||||
animState.soundDataBlockIds.length > 0
|
animState.soundDataBlockIds.length > 0
|
||||||
) {
|
) {
|
||||||
const getDb = playback.recording?.streamingPlayback.getDataBlockData
|
const getDb =
|
||||||
.bind(playback.recording.streamingPlayback);
|
playback.recording?.streamingPlayback.getDataBlockData.bind(
|
||||||
|
playback.recording.streamingPlayback,
|
||||||
|
);
|
||||||
if (getDb) {
|
if (getDb) {
|
||||||
for (const soundDbId of animState.soundDataBlockIds) {
|
for (const soundDbId of animState.soundDataBlockIds) {
|
||||||
const resolved = resolveAudioProfile(soundDbId, getDb);
|
const resolved = resolveAudioProfile(soundDbId, getDb);
|
||||||
|
|
@ -795,7 +879,9 @@ function AnimatedWeaponModel({
|
||||||
loopingSoundRef.current = sound;
|
loopingSoundRef.current = sound;
|
||||||
loopingSoundStateRef.current = currentIdx;
|
loopingSoundStateRef.current = currentIdx;
|
||||||
});
|
});
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
playOneShotSound(
|
playOneShotSound(
|
||||||
|
|
@ -814,7 +900,6 @@ function AnimatedWeaponModel({
|
||||||
if (spinActionRef.current) {
|
if (spinActionRef.current) {
|
||||||
spinActionRef.current.timeScale = animState.spinTimeScale;
|
spinActionRef.current.timeScale = animState.spinTimeScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance the weapon mixer.
|
// Advance the weapon mixer.
|
||||||
|
|
@ -898,9 +983,8 @@ function applyWeaponAnim(
|
||||||
// Scale animation to fit the state timeout if requested.
|
// Scale animation to fit the state timeout if requested.
|
||||||
if (animState.scaleAnimation && animState.timeoutValue > 0) {
|
if (animState.scaleAnimation && animState.timeoutValue > 0) {
|
||||||
const clipDuration = action.getClip().duration;
|
const clipDuration = action.getClip().duration;
|
||||||
action.timeScale = clipDuration > 0
|
action.timeScale =
|
||||||
? clipDuration / animState.timeoutValue
|
clipDuration > 0 ? clipDuration / animState.timeoutValue : 1;
|
||||||
: 1;
|
|
||||||
} else {
|
} else {
|
||||||
action.timeScale = animState.reverse ? -1 : 1;
|
action.timeScale = animState.reverse ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
@ -921,7 +1005,7 @@ function applyWeaponAnim(
|
||||||
* mounted images (no state machine or animation) — just positioned via
|
* mounted images (no state machine or animation) — just positioned via
|
||||||
* the pack shape's Mountpoint node inverse offset, same as weapons.
|
* the pack shape's Mountpoint node inverse offset, same as weapons.
|
||||||
*/
|
*/
|
||||||
function MountedPackModel({
|
function PackModel({
|
||||||
packShape,
|
packShape,
|
||||||
mountBone,
|
mountBone,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const _tmpVec = new Vector3();
|
||||||
*/
|
*/
|
||||||
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
||||||
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
|
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
const groupRef = useRef<Object3D>(null);
|
const groupRef = useRef<Object3D>(null);
|
||||||
const iffContainerRef = useRef<HTMLDivElement>(null);
|
const iffContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const nameContainerRef = useRef<HTMLDivElement>(null);
|
const nameContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -75,7 +75,10 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
||||||
if (!shouldBeVisible) return;
|
if (!shouldBeVisible) return;
|
||||||
|
|
||||||
// Hide nameplate when player is dead.
|
// Hide nameplate when player is dead.
|
||||||
const kf = getKeyframeAtTime(keyframes, streamPlaybackStore.getState().time);
|
const kf = getKeyframeAtTime(
|
||||||
|
keyframes,
|
||||||
|
streamPlaybackStore.getState().time,
|
||||||
|
);
|
||||||
const health = kf?.health ?? 1;
|
const health = kf?.health ?? 1;
|
||||||
if (kf?.damageState != null && kf.damageState >= 1) {
|
if (kf?.damageState != null && kf.damageState >= 1) {
|
||||||
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";
|
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
setQuaternionFromDir,
|
setQuaternionFromDir,
|
||||||
} from "../stream/playbackUtils";
|
} from "../stream/playbackUtils";
|
||||||
import { textureToUrl } from "../loaders";
|
import { textureToUrl } from "../loaders";
|
||||||
import type { TracerVisual, SpriteVisual } from "../stream/types";
|
import { SpriteEntity, TracerEntity } from "../state/gameEntityTypes";
|
||||||
|
|
||||||
const _tracerDir = new Vector3();
|
const _tracerDir = new Vector3();
|
||||||
const _tracerDirFromCam = new Vector3();
|
const _tracerDirFromCam = new Vector3();
|
||||||
|
|
@ -26,7 +26,8 @@ const _tracerEnd = new Vector3();
|
||||||
const _tracerWorldPos = new Vector3();
|
const _tracerWorldPos = new Vector3();
|
||||||
const _upY = new Vector3(0, 1, 0);
|
const _upY = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
|
export function SpriteProjectile({ entity }: { entity: SpriteEntity }) {
|
||||||
|
const { visual } = entity;
|
||||||
const url = textureToUrl(visual.texture);
|
const url = textureToUrl(visual.texture);
|
||||||
const texture = useTexture(url, (tex) => {
|
const texture = useTexture(url, (tex) => {
|
||||||
const t = Array.isArray(tex) ? tex[0] : tex;
|
const t = Array.isArray(tex) ? tex[0] : tex;
|
||||||
|
|
@ -37,7 +38,12 @@ export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
|
||||||
// Convert sRGB datablock color to linear for Three.js material.
|
// Convert sRGB datablock color to linear for Three.js material.
|
||||||
const color = useMemo(
|
const color = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Color().setRGB(visual.color.r, visual.color.g, visual.color.b, SRGBColorSpace),
|
new Color().setRGB(
|
||||||
|
visual.color.r,
|
||||||
|
visual.color.g,
|
||||||
|
visual.color.b,
|
||||||
|
SRGBColorSpace,
|
||||||
|
),
|
||||||
[visual.color.r, visual.color.g, visual.color.b],
|
[visual.color.r, visual.color.g, visual.color.b],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -55,13 +61,8 @@ export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TracerProjectile({
|
export function TracerProjectile({ entity }: { entity: TracerEntity }) {
|
||||||
entity,
|
const { visual } = entity;
|
||||||
visual,
|
|
||||||
}: {
|
|
||||||
entity: { keyframes?: Array<{ position?: [number, number, number]; velocity?: [number, number, number] }>; direction?: [number, number, number] };
|
|
||||||
visual: TracerVisual;
|
|
||||||
}) {
|
|
||||||
const tracerRef = useRef<Mesh>(null);
|
const tracerRef = useRef<Mesh>(null);
|
||||||
const tracerPosRef = useRef<BufferAttribute>(null);
|
const tracerPosRef = useRef<BufferAttribute>(null);
|
||||||
const crossRef = useRef<Mesh>(null);
|
const crossRef = useRef<Mesh>(null);
|
||||||
|
|
@ -167,14 +168,12 @@ export function TracerProjectile({
|
||||||
/>
|
/>
|
||||||
<bufferAttribute
|
<bufferAttribute
|
||||||
attach="attributes-uv"
|
attach="attributes-uv"
|
||||||
args={[
|
args={[new Float32Array([0, 0, 0, 1, 1, 1, 1, 0]), 2]}
|
||||||
new Float32Array([
|
/>
|
||||||
0, 0, 0, 1, 1, 1, 1, 0,
|
<bufferAttribute
|
||||||
]),
|
attach="index"
|
||||||
2,
|
args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]}
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
|
|
||||||
</bufferGeometry>
|
</bufferGeometry>
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
map={tracerTexture}
|
map={tracerTexture}
|
||||||
|
|
@ -192,24 +191,19 @@ export function TracerProjectile({
|
||||||
attach="attributes-position"
|
attach="attributes-position"
|
||||||
args={[
|
args={[
|
||||||
new Float32Array([
|
new Float32Array([
|
||||||
-0.5, 0, -0.5,
|
-0.5, 0, -0.5, 0.5, 0, -0.5, 0.5, 0, 0.5, -0.5, 0, 0.5,
|
||||||
0.5, 0, -0.5,
|
|
||||||
0.5, 0, 0.5,
|
|
||||||
-0.5, 0, 0.5,
|
|
||||||
]),
|
]),
|
||||||
3,
|
3,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<bufferAttribute
|
<bufferAttribute
|
||||||
attach="attributes-uv"
|
attach="attributes-uv"
|
||||||
args={[
|
args={[new Float32Array([0, 0, 0, 1, 1, 1, 1, 0]), 2]}
|
||||||
new Float32Array([
|
/>
|
||||||
0, 0, 0, 1, 1, 1, 1, 0,
|
<bufferAttribute
|
||||||
]),
|
attach="index"
|
||||||
2,
|
args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]}
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
|
|
||||||
</bufferGeometry>
|
</bufferGeometry>
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
map={crossTexture}
|
map={crossTexture}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, type ReactNode } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
import type { StreamRecording } from "../stream/types";
|
import type { StreamRecording } from "../stream/types";
|
||||||
import { useEngineSelector } from "../state";
|
import { useEngineSelector } from "../state/engineStore";
|
||||||
|
|
||||||
export function RecordingProvider({ children }: { children: ReactNode }) {
|
export function RecordingProvider({ children }: { children: ReactNode }) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { createContext, ReactNode, useContext } from "react";
|
import { createContext, ReactNode, useContext } from "react";
|
||||||
import type { TorqueRuntime } from "../torqueScript";
|
import type { TorqueRuntime } from "../torqueScript";
|
||||||
|
|
||||||
|
|
||||||
const RuntimeContext = createContext<TorqueRuntime | null>(null);
|
const RuntimeContext = createContext<TorqueRuntime | null>(null);
|
||||||
|
|
||||||
export interface RuntimeProviderProps {
|
export interface RuntimeProviderProps {
|
||||||
|
|
@ -24,4 +23,3 @@ export function useRuntime(): TorqueRuntime {
|
||||||
}
|
}
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Color, Vector3 } from "three";
|
import { Color, Vector3 } from "three";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import { useSceneSun } from "../state/gameEntityStore";
|
import { useSceneSun } from "../state/gameEntityStore";
|
||||||
import { torqueToThree } from "../scene/coordinates";
|
import { torqueToThree } from "../scene/coordinates";
|
||||||
import { updateGlobalSunUniforms } from "../globalSunUniforms";
|
import { updateGlobalSunUniforms } from "../globalSunUniforms";
|
||||||
|
|
||||||
|
const log = createLogger("SceneLighting");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders scene-global lights (directional sun + ambient) derived from the
|
* Renders scene-global lights (directional sun + ambient) derived from the
|
||||||
* Sun entity in the game entity store. Rendered outside EntityScene so that
|
* Sun entity in the game entity store. Rendered outside EntityScene so that
|
||||||
|
|
@ -14,6 +17,25 @@ import { updateGlobalSunUniforms } from "../globalSunUniforms";
|
||||||
export function SceneLighting() {
|
export function SceneLighting() {
|
||||||
const sunData = useSceneSun();
|
const sunData = useSceneSun();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sunData) {
|
||||||
|
log.debug(
|
||||||
|
"sunData: dir=(%s, %s, %s) color=(%s, %s, %s) ambient=(%s, %s, %s)",
|
||||||
|
sunData.direction.x.toFixed(3),
|
||||||
|
sunData.direction.y.toFixed(3),
|
||||||
|
sunData.direction.z.toFixed(3),
|
||||||
|
sunData.color.r.toFixed(3),
|
||||||
|
sunData.color.g.toFixed(3),
|
||||||
|
sunData.color.b.toFixed(3),
|
||||||
|
sunData.ambient.r.toFixed(3),
|
||||||
|
sunData.ambient.g.toFixed(3),
|
||||||
|
sunData.ambient.b.toFixed(3),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.debug("No sunData — using fallback ambient #888");
|
||||||
|
}
|
||||||
|
}, [sunData]);
|
||||||
|
|
||||||
if (!sunData) {
|
if (!sunData) {
|
||||||
// Fallback lighting when no Sun entity exists yet
|
// Fallback lighting when no Sun entity exists yet
|
||||||
return <ambientLight color="#888888" intensity={1.0} />;
|
return <ambientLight color="#888888" intensity={1.0} />;
|
||||||
|
|
@ -22,7 +44,11 @@ export function SceneLighting() {
|
||||||
return <SunLighting sunData={sunData} />;
|
return <SunLighting sunData={sunData} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SunLighting({ sunData }: { sunData: NonNullable<ReturnType<typeof useSceneSun>> }) {
|
function SunLighting({
|
||||||
|
sunData,
|
||||||
|
}: {
|
||||||
|
sunData: NonNullable<ReturnType<typeof useSceneSun>>;
|
||||||
|
}) {
|
||||||
const direction = useMemo(() => {
|
const direction = useMemo(() => {
|
||||||
const [x, y, z] = torqueToThree(sunData.direction);
|
const [x, y, z] = torqueToThree(sunData.direction);
|
||||||
const len = Math.sqrt(x * x + y * y + z * z);
|
const len = Math.sqrt(x * x + y * y + z * z);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,14 @@
|
||||||
.Dialog {
|
.Dialog {
|
||||||
position: relative;
|
composes: Dialog from "./GameDialog.module.css";
|
||||||
width: 860px;
|
width: 860px;
|
||||||
height: 560px;
|
height: 560px;
|
||||||
max-width: calc(100dvw - 40px);
|
|
||||||
max-height: calc(100dvh - 40px);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100%;
|
grid-template-columns: 100%;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
background: rgba(20, 37, 38, 0.8);
|
|
||||||
border: 1px solid rgba(65, 131, 139, 0.6);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 50px rgba(0, 0, 0, 0.4),
|
|
||||||
inset 0 0 60px rgba(1, 7, 13, 0.6);
|
|
||||||
color: #b0d5c9;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow: hidden;
|
|
||||||
outline: none;
|
|
||||||
user-select: text;
|
|
||||||
-webkit-touch-callout: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Overlay {
|
.Overlay {
|
||||||
position: fixed;
|
composes: Overlay from "./GameDialog.module.css";
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Header {
|
.Header {
|
||||||
|
|
@ -50,8 +28,18 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.HiddenRadio {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.RefreshButton {
|
.RefreshButton {
|
||||||
composes: DialogButton from "./DialogButton.module.css";
|
composes: DialogButton from "./GameDialog.module.css";
|
||||||
padding: 3px 14px;
|
padding: 3px 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +62,7 @@
|
||||||
|
|
||||||
.Table th {
|
.Table th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: rgba(10, 25, 26, 0.95);
|
background: rgba(10, 25, 26, 0.95);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
|
@ -100,6 +89,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table td {
|
.Table td {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
padding: 3px 12px;
|
padding: 3px 12px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -109,15 +100,19 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table tbody tr {
|
.Table td.EmptyServer {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Table tbody tr:not(.Empty) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table tbody tr:hover {
|
.Table tbody tr:not(.Empty):hover {
|
||||||
background: rgba(65, 131, 139, 0.12);
|
background: rgba(65, 131, 139, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Selected {
|
.Table tbody tr:has(input:checked) {
|
||||||
background: rgba(93, 255, 225, 0.9) !important;
|
background: rgba(93, 255, 225, 0.9) !important;
|
||||||
color: #1e2828;
|
color: #1e2828;
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +123,7 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Empty {
|
.Empty td {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(201, 220, 216, 0.3);
|
color: rgba(201, 220, 216, 0.3);
|
||||||
padding: 32px 12px !important;
|
padding: 32px 12px !important;
|
||||||
|
|
@ -146,12 +141,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.JoinButton {
|
.JoinButton {
|
||||||
composes: DialogButton from "./DialogButton.module.css";
|
composes: DialogButton from "./GameDialog.module.css";
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CloseButton {
|
.CloseButton {
|
||||||
composes: Secondary from "./DialogButton.module.css";
|
composes: Secondary from "./GameDialog.module.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
.WarriorField {
|
.WarriorField {
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,47 @@
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import type { ServerInfo } from "../../relay/types";
|
import type { ServerInfo } from "../../relay/types";
|
||||||
import styles from "./ServerBrowser.module.css";
|
import styles from "./ServerBrowser.module.css";
|
||||||
export function ServerBrowser({
|
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||||
open,
|
import { useSettings } from "./SettingsProvider";
|
||||||
onClose,
|
|
||||||
servers,
|
export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
||||||
loading,
|
const servers = useLiveSelector((s) => s.servers);
|
||||||
onRefresh,
|
const serversLoading = useLiveSelector((s) => s.serversLoading);
|
||||||
onJoin,
|
const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
|
||||||
wsPing,
|
const listServers = useLiveSelector((s) => s.listServers);
|
||||||
warriorName,
|
const joinServer = useLiveSelector((s) => s.joinServer);
|
||||||
onWarriorNameChange,
|
const { warriorName, setWarriorName } = useSettings();
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
servers: ServerInfo[];
|
|
||||||
loading: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
onJoin: (address: string) => void;
|
|
||||||
/** Browser↔relay RTT to add to server pings for effective latency. */
|
|
||||||
wsPing?: number | null;
|
|
||||||
warriorName: string;
|
|
||||||
onWarriorNameChange: (name: string) => void;
|
|
||||||
}) {
|
|
||||||
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
||||||
|
const handleJoinSelected = () => {
|
||||||
|
if (selectedAddress) {
|
||||||
|
joinServer(selectedAddress, warriorName);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = (address: string) => {
|
||||||
|
joinServer(address, warriorName);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
|
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const onRefreshRef = useRef(onRefresh);
|
|
||||||
onRefreshRef.current = onRefresh;
|
|
||||||
const didAutoRefreshRef = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
|
||||||
dialogRef.current?.focus();
|
dialogRef.current?.focus();
|
||||||
try {
|
try {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
} catch {
|
} catch {
|
||||||
/* expected */
|
/* expected */
|
||||||
}
|
}
|
||||||
} else {
|
}, []);
|
||||||
didAutoRefreshRef.current = false;
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
// Refresh on open if no servers cached
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && servers.length === 0 && !didAutoRefreshRef.current) {
|
listServers();
|
||||||
didAutoRefreshRef.current = true;
|
}, [listServers]);
|
||||||
onRefreshRef.current();
|
|
||||||
}
|
|
||||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
// Block keyboard events from reaching Three.js while open
|
// Block keyboard events from reaching Three.js while open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
|
|
@ -60,7 +50,8 @@ export function ServerBrowser({
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown, true);
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
}, [open, onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSort = useCallback(
|
const handleSort = useCallback(
|
||||||
(key: keyof ServerInfo) => {
|
(key: keyof ServerInfo) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
|
|
@ -85,15 +76,6 @@ export function ServerBrowser({
|
||||||
});
|
});
|
||||||
}, [servers, sortDir, sortKey]);
|
}, [servers, sortDir, sortKey]);
|
||||||
|
|
||||||
const handleJoin = useCallback(() => {
|
|
||||||
if (selectedAddress) {
|
|
||||||
onJoin(selectedAddress);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [selectedAddress, onJoin, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.Overlay} onClick={onClose}>
|
<div className={styles.Overlay} onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -109,13 +91,14 @@ export function ServerBrowser({
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className={styles.RefreshButton}
|
className={styles.RefreshButton}
|
||||||
onClick={onRefresh}
|
onClick={listServers}
|
||||||
disabled={loading}
|
disabled={serversLoading}
|
||||||
>
|
>
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.TableWrapper}>
|
<div className={styles.TableWrapper}>
|
||||||
|
<form name="serverList" onSubmit={handleJoinSelected}>
|
||||||
<table className={styles.Table}>
|
<table className={styles.Table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -131,30 +114,50 @@ export function ServerBrowser({
|
||||||
{sorted.map((server) => (
|
{sorted.map((server) => (
|
||||||
<tr
|
<tr
|
||||||
key={server.address}
|
key={server.address}
|
||||||
className={
|
onClick={() => {
|
||||||
selectedAddress === server.address
|
setSelectedAddress(server.address);
|
||||||
? styles.Selected
|
const form = document.forms["serverList"];
|
||||||
: undefined
|
const inputs: RadioNodeList =
|
||||||
}
|
form.elements["serverAddress"];
|
||||||
onClick={() => setSelectedAddress(server.address)}
|
const input = Array.from(inputs).find(
|
||||||
|
(input) => input.value === server.address,
|
||||||
|
);
|
||||||
|
input.focus();
|
||||||
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
setSelectedAddress(server.address);
|
setSelectedAddress(server.address);
|
||||||
onJoin(server.address);
|
handleJoin(server.address);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className={styles.HiddenRadio}
|
||||||
|
name="serverAddress"
|
||||||
|
value={server.address}
|
||||||
|
checked={selectedAddress === server.address}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSelectedAddress(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{server.passwordRequired && (
|
{server.passwordRequired && (
|
||||||
<span className={styles.PasswordIcon}>🔒</span>
|
<span className={styles.PasswordIcon}>🔒</span>
|
||||||
)}
|
)}
|
||||||
{server.name}
|
{server.name}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td
|
||||||
{server.playerCount}/{server.maxPlayers}
|
className={
|
||||||
|
server.playerCount === 0
|
||||||
|
? styles.EmptyServer
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{server.playerCount} / {server.maxPlayers}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{wsPing != null
|
{browserToRelayPing != null
|
||||||
? (server.ping + wsPing).toLocaleString()
|
? (server.ping + browserToRelayPing).toLocaleString()
|
||||||
: "\u2014"}
|
: "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td>{server.mapName}</td>
|
<td>{server.mapName}</td>
|
||||||
|
|
@ -162,22 +165,19 @@ export function ServerBrowser({
|
||||||
<td>{server.mod}</td>
|
<td>{server.mod}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sorted.length === 0 && !loading && (
|
{sorted.length === 0 && !serversLoading && (
|
||||||
<tr>
|
<tr className={styles.Empty}>
|
||||||
<td colSpan={6} className={styles.Empty}>
|
<td colSpan={6}>No servers found</td>
|
||||||
No servers found
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{loading && sorted.length === 0 && (
|
{serversLoading && sorted.length === 0 && (
|
||||||
<tr>
|
<tr className={styles.Empty}>
|
||||||
<td colSpan={6} className={styles.Empty}>
|
<td colSpan={6}>Querying master server…</td>
|
||||||
Querying master server...
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.Footer}>
|
<div className={styles.Footer}>
|
||||||
<div className={styles.WarriorField}>
|
<div className={styles.WarriorField}>
|
||||||
|
|
@ -189,7 +189,7 @@ export function ServerBrowser({
|
||||||
className={styles.WarriorInput}
|
className={styles.WarriorInput}
|
||||||
type="text"
|
type="text"
|
||||||
value={warriorName}
|
value={warriorName}
|
||||||
onChange={(e) => onWarriorNameChange(e.target.value)}
|
onChange={(e) => setWarriorName(e.target.value)}
|
||||||
placeholder="Name thyself…"
|
placeholder="Name thyself…"
|
||||||
maxLength={24}
|
maxLength={24}
|
||||||
/>
|
/>
|
||||||
|
|
@ -199,7 +199,7 @@ export function ServerBrowser({
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleJoin}
|
onClick={handleJoinSelected}
|
||||||
disabled={!selectedAddress}
|
disabled={!selectedAddress}
|
||||||
className={styles.JoinButton}
|
className={styles.JoinButton}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,15 @@ type SettingsContext = {
|
||||||
setAnimationEnabled: StateSetter<boolean>;
|
setAnimationEnabled: StateSetter<boolean>;
|
||||||
warriorName: string;
|
warriorName: string;
|
||||||
setWarriorName: StateSetter<string>;
|
setWarriorName: StateSetter<string>;
|
||||||
|
audioVolume: number;
|
||||||
|
setAudioVolume: StateSetter<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DebugContext = {
|
type DebugContext = {
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
setDebugMode: StateSetter<boolean>;
|
setDebugMode: StateSetter<boolean>;
|
||||||
|
renderOnDemand: boolean;
|
||||||
|
setRenderOnDemand: StateSetter<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ControlsContext = {
|
type ControlsContext = {
|
||||||
|
|
@ -39,6 +43,12 @@ type ControlsContext = {
|
||||||
setSpeedMultiplier: StateSetter<number>;
|
setSpeedMultiplier: StateSetter<number>;
|
||||||
touchMode: TouchMode;
|
touchMode: TouchMode;
|
||||||
setTouchMode: StateSetter<TouchMode>;
|
setTouchMode: StateSetter<TouchMode>;
|
||||||
|
invertScroll: boolean;
|
||||||
|
setInvertScroll: StateSetter<boolean>;
|
||||||
|
invertDrag: boolean;
|
||||||
|
setInvertDrag: StateSetter<boolean>;
|
||||||
|
invertJoystick: boolean;
|
||||||
|
setInvertJoystick: StateSetter<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContext | null>(null);
|
const SettingsContext = createContext<SettingsContext | null>(null);
|
||||||
|
|
@ -55,6 +65,10 @@ type PersistedSettings = {
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
touchMode?: TouchMode;
|
touchMode?: TouchMode;
|
||||||
warriorName?: string;
|
warriorName?: string;
|
||||||
|
audioVolume?: number;
|
||||||
|
invertScroll?: boolean;
|
||||||
|
invertDrag?: boolean;
|
||||||
|
invertJoystick?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
|
|
@ -83,10 +97,15 @@ export function SettingsProvider({
|
||||||
const [speedMultiplier, setSpeedMultiplier] = useState(1);
|
const [speedMultiplier, setSpeedMultiplier] = useState(1);
|
||||||
const [fov, setFov] = useState(90);
|
const [fov, setFov] = useState(90);
|
||||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||||
|
const [audioVolume, setAudioVolume] = useState(0.75);
|
||||||
const [animationEnabled, setAnimationEnabled] = useState(true);
|
const [animationEnabled, setAnimationEnabled] = useState(true);
|
||||||
const [debugMode, setDebugMode] = useState(false);
|
const [debugMode, setDebugMode] = useState(false);
|
||||||
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
|
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
|
||||||
const [warriorName, setWarriorName] = useState("MapGenius");
|
const [warriorName, setWarriorName] = useState("MapGenius");
|
||||||
|
const [invertScroll, setInvertScroll] = useState(false);
|
||||||
|
const [invertDrag, setInvertDrag] = useState(false);
|
||||||
|
const [invertJoystick, setInvertJoystick] = useState(false);
|
||||||
|
const [renderOnDemand, setRenderOnDemand] = useState(false);
|
||||||
|
|
||||||
const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback(
|
const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback(
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|
@ -110,6 +129,8 @@ export function SettingsProvider({
|
||||||
setAnimationEnabled,
|
setAnimationEnabled,
|
||||||
warriorName,
|
warriorName,
|
||||||
setWarriorName,
|
setWarriorName,
|
||||||
|
audioVolume,
|
||||||
|
setAudioVolume,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
fogEnabled,
|
fogEnabled,
|
||||||
|
|
@ -120,21 +141,46 @@ export function SettingsProvider({
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
animationEnabled,
|
animationEnabled,
|
||||||
warriorName,
|
warriorName,
|
||||||
|
audioVolume,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debugContext: DebugContext = useMemo(
|
const debugContext: DebugContext = useMemo(
|
||||||
() => ({ debugMode, setDebugMode }),
|
() => ({
|
||||||
[debugMode, setDebugMode],
|
debugMode,
|
||||||
|
setDebugMode,
|
||||||
|
renderOnDemand,
|
||||||
|
setRenderOnDemand,
|
||||||
|
}),
|
||||||
|
[debugMode, setDebugMode, renderOnDemand],
|
||||||
);
|
);
|
||||||
|
|
||||||
const controlsContext: ControlsContext = useMemo(
|
const controlsContext: ControlsContext = useMemo(
|
||||||
() => ({ speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode }),
|
() => ({
|
||||||
[speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode],
|
speedMultiplier,
|
||||||
|
setSpeedMultiplier,
|
||||||
|
touchMode,
|
||||||
|
setTouchMode,
|
||||||
|
invertScroll,
|
||||||
|
setInvertScroll,
|
||||||
|
invertDrag,
|
||||||
|
setInvertDrag,
|
||||||
|
invertJoystick,
|
||||||
|
setInvertJoystick,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
speedMultiplier,
|
||||||
|
setSpeedMultiplier,
|
||||||
|
touchMode,
|
||||||
|
setTouchMode,
|
||||||
|
invertScroll,
|
||||||
|
invertDrag,
|
||||||
|
invertJoystick,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Read persisted settings from localStorage.
|
// Read persisted settings from localStorage.
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
let savedSettings: PersistedSettings = {};
|
let savedSettings: PersistedSettings = {};
|
||||||
try {
|
try {
|
||||||
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
|
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
|
||||||
|
|
@ -157,7 +203,9 @@ export function SettingsProvider({
|
||||||
setHighQualityFog(savedSettings.highQualityFog);
|
setHighQualityFog(savedSettings.highQualityFog);
|
||||||
}
|
}
|
||||||
if (savedSettings.speedMultiplier != null) {
|
if (savedSettings.speedMultiplier != null) {
|
||||||
setSpeedMultiplier(savedSettings.speedMultiplier);
|
setSpeedMultiplier(
|
||||||
|
Math.max(0, Math.min(1, savedSettings.speedMultiplier)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (savedSettings.fov != null) {
|
if (savedSettings.fov != null) {
|
||||||
setFov(savedSettings.fov);
|
setFov(savedSettings.fov);
|
||||||
|
|
@ -168,6 +216,18 @@ export function SettingsProvider({
|
||||||
if (savedSettings.warriorName != null) {
|
if (savedSettings.warriorName != null) {
|
||||||
setWarriorName(savedSettings.warriorName);
|
setWarriorName(savedSettings.warriorName);
|
||||||
}
|
}
|
||||||
|
if (savedSettings.audioVolume != null) {
|
||||||
|
setAudioVolume(savedSettings.audioVolume);
|
||||||
|
}
|
||||||
|
if (savedSettings.invertScroll != null) {
|
||||||
|
setInvertScroll(savedSettings.invertScroll);
|
||||||
|
}
|
||||||
|
if (savedSettings.invertDrag != null) {
|
||||||
|
setInvertDrag(savedSettings.invertDrag);
|
||||||
|
}
|
||||||
|
if (savedSettings.invertJoystick != null) {
|
||||||
|
setInvertJoystick(savedSettings.invertJoystick);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist settings to localStorage with debouncing to avoid excessive writes
|
// Persist settings to localStorage with debouncing to avoid excessive writes
|
||||||
|
|
@ -191,6 +251,10 @@ export function SettingsProvider({
|
||||||
debugMode,
|
debugMode,
|
||||||
touchMode,
|
touchMode,
|
||||||
warriorName,
|
warriorName,
|
||||||
|
audioVolume,
|
||||||
|
invertScroll,
|
||||||
|
invertDrag,
|
||||||
|
invertJoystick,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("settings", JSON.stringify(settingsToSave));
|
localStorage.setItem("settings", JSON.stringify(settingsToSave));
|
||||||
|
|
@ -214,6 +278,10 @@ export function SettingsProvider({
|
||||||
debugMode,
|
debugMode,
|
||||||
touchMode,
|
touchMode,
|
||||||
warriorName,
|
warriorName,
|
||||||
|
audioVolume,
|
||||||
|
invertScroll,
|
||||||
|
invertDrag,
|
||||||
|
invertJoystick,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
28
src/components/ShapeErrorBoundary.tsx
Normal file
28
src/components/ShapeErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Component } from "react";
|
||||||
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
|
|
||||||
|
const log = createLogger("ShapeErrorBoundary");
|
||||||
|
|
||||||
|
/** Error boundary that renders a fallback when shape loading fails. */
|
||||||
|
export class ShapeErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode; fallback: ReactNode },
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
log.error("Shape load failed: %s %s", error.message, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import {
|
import { AnimationMixer, LoopOnce, Quaternion, Vector3 } from "three";
|
||||||
AnimationMixer,
|
|
||||||
LoopOnce,
|
|
||||||
Quaternion,
|
|
||||||
Vector3,
|
|
||||||
} from "three";
|
|
||||||
import type { Group, Material } from "three";
|
import type { Group, Material } from "three";
|
||||||
import { effectNow, engineStore } from "../state";
|
import { effectNow, engineStore } from "../state/engineStore";
|
||||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
import {
|
import {
|
||||||
_r90,
|
_r90,
|
||||||
|
|
@ -21,14 +16,11 @@ import {
|
||||||
updateAtlasFrame,
|
updateAtlasFrame,
|
||||||
} from "./useIflTexture";
|
} from "./useIflTexture";
|
||||||
import type { IflAtlas } from "./useIflTexture";
|
import type { IflAtlas } from "./useIflTexture";
|
||||||
import {
|
import { ShapeRenderer, useStaticShape } from "./GenericShape";
|
||||||
ShapeRenderer,
|
|
||||||
useStaticShape,
|
|
||||||
} from "./GenericShape";
|
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
import type { TorqueObject } from "../torqueScript";
|
import type { TorqueObject } from "../torqueScript";
|
||||||
import type { StreamEntity } from "../stream/types";
|
import type { ExplosionEntity } from "../state/gameEntityTypes";
|
||||||
import type { StreamingPlayback } from "../stream/types";
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map weapon shape to the arm blend animation (armThread).
|
* Map weapon shape to the arm blend animation (armThread).
|
||||||
|
|
@ -167,7 +159,13 @@ function extractSizeKeyframes(expBlock: Record<string, unknown>): {
|
||||||
const rawTimes = expBlock.times as number[] | undefined;
|
const rawTimes = expBlock.times as number[] | undefined;
|
||||||
|
|
||||||
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
|
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
|
||||||
return { times: [0, 1], sizes: [[1, 1, 1], [1, 1, 1]] };
|
return {
|
||||||
|
times: [0, 1],
|
||||||
|
sizes: [
|
||||||
|
[1, 1, 1],
|
||||||
|
[1, 1, 1],
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// sizes are packed as value*100 integers on the wire; divide by 100.
|
// sizes are packed as value*100 integers on the wire; divide by 100.
|
||||||
|
|
@ -210,14 +208,9 @@ function interpolateSize(
|
||||||
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
|
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
|
||||||
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
||||||
*/
|
*/
|
||||||
export function ExplosionShape({
|
export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
|
||||||
entity,
|
const playback = streamPlaybackStore.getState().playback;
|
||||||
playback,
|
const gltf = useStaticShape(entity.shapeName!);
|
||||||
}: {
|
|
||||||
entity: StreamEntity;
|
|
||||||
playback: StreamingPlayback;
|
|
||||||
}) {
|
|
||||||
const gltf = useStaticShape(entity.dataBlock!);
|
|
||||||
const groupRef = useRef<Group>(null);
|
const groupRef = useRef<Group>(null);
|
||||||
const startTimeRef = useRef(effectNow());
|
const startTimeRef = useRef(effectNow());
|
||||||
// eslint-disable-next-line react-hooks/purity
|
// eslint-disable-next-line react-hooks/purity
|
||||||
|
|
@ -256,7 +249,9 @@ export function ExplosionShape({
|
||||||
const iflInfos: IflInfo[] = [];
|
const iflInfos: IflInfo[] = [];
|
||||||
scene.traverse((node: any) => {
|
scene.traverse((node: any) => {
|
||||||
if (!node.isMesh || !node.material) return;
|
if (!node.isMesh || !node.material) return;
|
||||||
const mat = Array.isArray(node.material) ? node.material[0] : node.material;
|
const mat = Array.isArray(node.material)
|
||||||
|
? node.material[0]
|
||||||
|
: node.material;
|
||||||
if (!mat?.userData) return;
|
if (!mat?.userData) return;
|
||||||
const flags = new Set<string>(mat.userData.flag_names ?? []);
|
const flags = new Set<string>(mat.userData.flag_names ?? []);
|
||||||
const rp: string | undefined = mat.userData.resource_path;
|
const rp: string | undefined = mat.userData.resource_path;
|
||||||
|
|
@ -270,12 +265,13 @@ export function ExplosionShape({
|
||||||
: undefined,
|
: undefined,
|
||||||
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
|
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
|
||||||
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
|
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
|
||||||
toolBegin: ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
|
toolBegin:
|
||||||
|
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
processShapeScene(scene, entity.dataBlock);
|
processShapeScene(scene, entity.shapeName);
|
||||||
|
|
||||||
// Collect vis-animated nodes keyed by sequence name.
|
// Collect vis-animated nodes keyed by sequence name.
|
||||||
const visNodes: VisNode[] = [];
|
const visNodes: VisNode[] = [];
|
||||||
|
|
@ -290,7 +286,12 @@ export function ExplosionShape({
|
||||||
return;
|
return;
|
||||||
// Only include vis nodes tied to the "ambient" sequence.
|
// Only include vis nodes tied to the "ambient" sequence.
|
||||||
if (seqName === "ambient") {
|
if (seqName === "ambient") {
|
||||||
visNodes.push({ mesh: node, keyframes: kf, duration: dur, cyclic: !!ud.vis_cyclic });
|
visNodes.push({
|
||||||
|
mesh: node,
|
||||||
|
keyframes: kf,
|
||||||
|
duration: dur,
|
||||||
|
cyclic: !!ud.vis_cyclic,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -340,7 +341,9 @@ export function ExplosionShape({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disable frustum culling (explosion may scale beyond bounds).
|
// Disable frustum culling (explosion may scale beyond bounds).
|
||||||
scene.traverse((child) => { child.frustumCulled = false; });
|
scene.traverse((child) => {
|
||||||
|
child.frustumCulled = false;
|
||||||
|
});
|
||||||
|
|
||||||
return { scene, mixer, visNodes, iflInfos, materials };
|
return { scene, mixer, visNodes, iflInfos, materials };
|
||||||
}, [gltf, expBlock]);
|
}, [gltf, expBlock]);
|
||||||
|
|
@ -369,8 +372,8 @@ export function ExplosionShape({
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
const playbackState = engineStore.getState().playback;
|
const playbackState = engineStore.getState().playback;
|
||||||
const effectDelta = playbackState.status === "playing"
|
const effectDelta =
|
||||||
? delta * playbackState.rate : 0;
|
playbackState.status === "playing" ? delta * playbackState.rate : 0;
|
||||||
|
|
||||||
const elapsed = effectNow() - startTimeRef.current;
|
const elapsed = effectNow() - startTimeRef.current;
|
||||||
const t = Math.min(elapsed / lifetimeMS, 1);
|
const t = Math.min(elapsed / lifetimeMS, 1);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
import {
|
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||||
startTransition,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
|
|
@ -166,7 +160,9 @@ export function ShapeSelect({
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
try {
|
try {
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
} catch { /* expected */ }
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
combobox.show();
|
combobox.show();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
@ -197,9 +193,7 @@ export function ShapeSelect({
|
||||||
{shapes.map(renderItem)}
|
{shapes.map(renderItem)}
|
||||||
</ComboboxGroup>
|
</ComboboxGroup>
|
||||||
))}
|
))}
|
||||||
{noResults && (
|
{noResults && <div className={styles.NoResults}>No shapes found</div>}
|
||||||
<div className={styles.NoResults}>No shapes found</div>
|
|
||||||
)}
|
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
</ComboboxPopover>
|
</ComboboxPopover>
|
||||||
</ComboboxProvider>
|
</ComboboxProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Suspense, useMemo, useEffect, useRef } from "react";
|
import { memo, Suspense, useMemo, useEffect, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useThree, useFrame } from "@react-three/fiber";
|
import { useThree, useFrame } from "@react-three/fiber";
|
||||||
import { useCubeTexture } from "@react-three/drei";
|
import { useCubeTexture } from "@react-three/drei";
|
||||||
import { Color, Fog } from "three";
|
import { Color, Fog } from "three";
|
||||||
import type { SceneSky } from "../scene/types";
|
import { createLogger } from "../logger";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import { useSettings } from "./SettingsProvider";
|
||||||
import { loadDetailMapList, textureToUrl } from "../loaders";
|
import { loadDetailMapList, textureToUrl } from "../loaders";
|
||||||
import { CloudLayers } from "./CloudLayers";
|
import { CloudLayers } from "./CloudLayers";
|
||||||
|
|
@ -16,10 +16,13 @@ import {
|
||||||
resetGlobalFogUniforms,
|
resetGlobalFogUniforms,
|
||||||
} from "../globalFogUniforms";
|
} from "../globalFogUniforms";
|
||||||
|
|
||||||
|
const log = createLogger("Sky");
|
||||||
|
|
||||||
// Track if fog shader has been installed (idempotent installation)
|
// Track if fog shader has been installed (idempotent installation)
|
||||||
let fogShaderInstalled = false;
|
let fogShaderInstalled = false;
|
||||||
|
|
||||||
import type { Color3 } from "../scene/types";
|
import type { Color3 } from "../scene/types";
|
||||||
|
import { SkyEntity } from "../state/gameEntityTypes";
|
||||||
|
|
||||||
/** Convert a Color3 to [sRGB Color, linear Color]. */
|
/** Convert a Color3 to [sRGB Color, linear Color]. */
|
||||||
function color3ToThree(c: Color3): [Color, Color] {
|
function color3ToThree(c: Color3): [Color, Color] {
|
||||||
|
|
@ -33,10 +36,25 @@ function color3ToThree(c: Color3): [Color, Color] {
|
||||||
* Load a .dml file, used to list the textures for different faces of a skybox.
|
* Load a .dml file, used to list the textures for different faces of a skybox.
|
||||||
*/
|
*/
|
||||||
function useDetailMapList(name: string) {
|
function useDetailMapList(name: string) {
|
||||||
return useQuery({
|
const result = useQuery({
|
||||||
queryKey: ["detailMapList", name],
|
queryKey: ["detailMapList", name],
|
||||||
queryFn: () => loadDetailMapList(name),
|
queryFn: () => {
|
||||||
|
log.debug("Loading detail map list: %s", name);
|
||||||
|
return loadDetailMapList(name);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
log.debug(
|
||||||
|
"DML query status: %s%s%s file=%s",
|
||||||
|
result.status,
|
||||||
|
result.error ? ` error=${result.error.message}` : "",
|
||||||
|
result.data ? ` (${result.data.length} entries)` : " (no data)",
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}, [result.status, result.error, result.data, name]);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,7 +74,7 @@ function SkyBoxTexture({
|
||||||
fogColor?: Color;
|
fogColor?: Color;
|
||||||
fogState?: FogState;
|
fogState?: FogState;
|
||||||
}) {
|
}) {
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
|
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
|
||||||
|
|
||||||
const enableFog = !!fogColor;
|
const enableFog = !!fogColor;
|
||||||
|
|
@ -320,7 +338,7 @@ function SolidColorSky({
|
||||||
fogColor?: Color;
|
fogColor?: Color;
|
||||||
fogState?: FogState;
|
fogState?: FogState;
|
||||||
}) {
|
}) {
|
||||||
const { camera } = useThree();
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
const enableFog = !!fogColor;
|
const enableFog = !!fogColor;
|
||||||
|
|
||||||
|
|
@ -515,7 +533,8 @@ function DynamicFog({
|
||||||
fogState: FogState;
|
fogState: FogState;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { scene, camera } = useThree();
|
const scene = useThree((state) => state.scene);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
const fogRef = useRef<Fog | null>(null);
|
const fogRef = useRef<Fog | null>(null);
|
||||||
|
|
||||||
// Pack fog volume data once (it doesn't change during runtime)
|
// Pack fog volume data once (it doesn't change during runtime)
|
||||||
|
|
@ -594,29 +613,43 @@ function DynamicFog({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sky({ scene }: { scene: SceneSky }) {
|
export const Sky = memo(function Sky({ entity }: { entity: SkyEntity }) {
|
||||||
|
const { skyData } = entity;
|
||||||
|
log.debug(
|
||||||
|
"Rendering: materialList=%s, useSkyTextures=%s",
|
||||||
|
skyData.materialList,
|
||||||
|
skyData.useSkyTextures,
|
||||||
|
);
|
||||||
const { fogEnabled } = useSettings();
|
const { fogEnabled } = useSettings();
|
||||||
|
|
||||||
// Skybox textures
|
// Skybox textures
|
||||||
const materialList = scene.materialList || undefined;
|
const materialList = skyData.materialList || undefined;
|
||||||
|
|
||||||
const skySolidColor = useMemo(
|
const skySolidColor = useMemo(
|
||||||
() => color3ToThree(scene.skySolidColor),
|
() => color3ToThree(skyData.skySolidColor),
|
||||||
[scene.skySolidColor],
|
[skyData.skySolidColor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const useSkyTextures = scene.useSkyTextures;
|
const useSkyTextures = skyData.useSkyTextures;
|
||||||
|
|
||||||
// Parse full fog state from typed scene sky
|
// Parse full fog state from typed scene sky
|
||||||
const fogState = useMemo(
|
const fogState = useMemo(() => fogStateFromScene(skyData), [skyData]);
|
||||||
() => fogStateFromScene(scene),
|
|
||||||
[scene],
|
log.debug(
|
||||||
|
"fogState: fogColor=(%s, %s, %s) visibleDistance=%d fogDistance=%d enabled=%s volumes=%d",
|
||||||
|
skyData.fogColor.r.toFixed(3),
|
||||||
|
skyData.fogColor.g.toFixed(3),
|
||||||
|
skyData.fogColor.b.toFixed(3),
|
||||||
|
skyData.visibleDistance,
|
||||||
|
skyData.fogDistance,
|
||||||
|
fogState.enabled,
|
||||||
|
fogState.fogVolumes.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get sRGB fog color for background
|
// Get sRGB fog color for background
|
||||||
const fogColor = useMemo(
|
const fogColor = useMemo(
|
||||||
() => color3ToThree(scene.fogColor),
|
() => color3ToThree(skyData.fogColor),
|
||||||
[scene.fogColor],
|
[skyData.fogColor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const skyColor = skySolidColor || fogColor;
|
const skyColor = skySolidColor || fogColor;
|
||||||
|
|
@ -629,7 +662,8 @@ export function Sky({ scene }: { scene: SceneSky }) {
|
||||||
|
|
||||||
// Set scene background color directly using useThree
|
// Set scene background color directly using useThree
|
||||||
// This ensures the gap between fogged terrain and skybox blends correctly
|
// This ensures the gap between fogged terrain and skybox blends correctly
|
||||||
const { scene: threeScene, gl } = useThree();
|
const threeScene = useThree((state) => state.scene);
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasFogParams) {
|
if (hasFogParams) {
|
||||||
// Use effective fog color for background (matches terrain fog)
|
// Use effective fog color for background (matches terrain fog)
|
||||||
|
|
@ -655,7 +689,7 @@ export function Sky({ scene }: { scene: SceneSky }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{materialList && useSkyTextures && materialList.length > 0 ? (
|
{materialList && useSkyTextures && materialList.length > 0 ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense>
|
||||||
{/* Key forces remount when mission changes to clear texture caches */}
|
{/* Key forces remount when mission changes to clear texture caches */}
|
||||||
<SkyBox
|
<SkyBox
|
||||||
key={materialList}
|
key={materialList}
|
||||||
|
|
@ -674,7 +708,7 @@ export function Sky({ scene }: { scene: SceneSky }) {
|
||||||
) : null}
|
) : null}
|
||||||
{/* Cloud layers render independently of skybox textures */}
|
{/* Cloud layers render independently of skybox textures */}
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CloudLayers scene={scene} />
|
<CloudLayers scene={skyData} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{/* Always render DynamicFog when mission has fog params.
|
{/* Always render DynamicFog when mission has fog params.
|
||||||
Pass fogEnabled to control visibility - this avoids shader recompilation
|
Pass fogEnabled to control visibility - this avoids shader recompilation
|
||||||
|
|
@ -684,4 +718,4 @@ export function Sky({ scene }: { scene: SceneSky }) {
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { useRecording } from "./RecordingProvider";
|
|
||||||
import { StreamingController } from "./StreamingController";
|
|
||||||
|
|
||||||
export function StreamPlayback() {
|
|
||||||
const recording = useRecording();
|
|
||||||
|
|
||||||
if (!recording) return null;
|
|
||||||
return <StreamingController recording={recording} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import {
|
import { Quaternion, Vector3 } from "three";
|
||||||
Quaternion,
|
|
||||||
Vector3,
|
|
||||||
} from "three";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_EYE_HEIGHT,
|
DEFAULT_EYE_HEIGHT,
|
||||||
STREAM_TICK_SEC,
|
STREAM_TICK_SEC,
|
||||||
|
|
@ -14,7 +11,7 @@ import { shapeToUrl } from "../loaders";
|
||||||
import { ParticleEffects } from "./ParticleEffects";
|
import { ParticleEffects } from "./ParticleEffects";
|
||||||
import { PlayerEyeOffset } from "./PlayerModel";
|
import { PlayerEyeOffset } from "./PlayerModel";
|
||||||
import { stopAllTrackedSounds } from "./AudioEmitter";
|
import { stopAllTrackedSounds } from "./AudioEmitter";
|
||||||
import { useEngineStoreApi, advanceEffectClock } from "../state";
|
import { useEngineStoreApi, advanceEffectClock } from "../state/engineStore";
|
||||||
import { gameEntityStore } from "../state/gameEntityStore";
|
import { gameEntityStore } from "../state/gameEntityStore";
|
||||||
import {
|
import {
|
||||||
streamPlaybackStore,
|
streamPlaybackStore,
|
||||||
|
|
@ -33,7 +30,9 @@ type EntityById = Map<string, StreamEntity>;
|
||||||
|
|
||||||
/** Safely access a field that exists only on some GameEntity variants. */
|
/** Safely access a field that exists only on some GameEntity variants. */
|
||||||
function getField(entity: GameEntity, field: string): string | undefined {
|
function getField(entity: GameEntity, field: string): string | undefined {
|
||||||
return (entity as unknown as Record<string, unknown>)[field] as string | undefined;
|
return (entity as unknown as Record<string, unknown>)[field] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mutate render-affecting fields on an entity in-place from stream data.
|
/** Mutate render-affecting fields on an entity in-place from stream data.
|
||||||
|
|
@ -49,6 +48,7 @@ function mutateRenderFields(
|
||||||
e.threads = stream.threads;
|
e.threads = stream.threads;
|
||||||
e.weaponShape = stream.weaponShape;
|
e.weaponShape = stream.weaponShape;
|
||||||
e.packShape = stream.packShape;
|
e.packShape = stream.packShape;
|
||||||
|
e.flagShape = stream.flagShape;
|
||||||
e.falling = stream.falling;
|
e.falling = stream.falling;
|
||||||
e.jetting = stream.jetting;
|
e.jetting = stream.jetting;
|
||||||
e.weaponImageState = stream.weaponImageState;
|
e.weaponImageState = stream.weaponImageState;
|
||||||
|
|
@ -98,8 +98,11 @@ const _billboardFlip = new Quaternion(0, 1, 0, 0); // 180° around Y
|
||||||
const _orbitDir = new Vector3();
|
const _orbitDir = new Vector3();
|
||||||
const _orbitTarget = new Vector3();
|
const _orbitTarget = new Vector3();
|
||||||
const _orbitCandidate = new Vector3();
|
const _orbitCandidate = new Vector3();
|
||||||
|
export function StreamingController({
|
||||||
export function StreamingController({ recording }: { recording: StreamRecording }) {
|
recording,
|
||||||
|
}: {
|
||||||
|
recording: StreamRecording;
|
||||||
|
}) {
|
||||||
const engineStore = useEngineStoreApi();
|
const engineStore = useEngineStoreApi();
|
||||||
const playbackClockRef = useRef(0);
|
const playbackClockRef = useRef(0);
|
||||||
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||||
|
|
@ -117,30 +120,42 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
|
|
||||||
const prevMap = entityMapRef.current;
|
const prevMap = entityMapRef.current;
|
||||||
const nextMap = new Map<string, GameEntity>();
|
const nextMap = new Map<string, GameEntity>();
|
||||||
let shouldRebuild = false;
|
|
||||||
|
|
||||||
for (const entity of snapshot.entities) {
|
for (const entity of snapshot.entities) {
|
||||||
let renderEntity = prevMap.get(entity.id);
|
let renderEntity = prevMap.get(entity.id);
|
||||||
|
|
||||||
// Identity change -> new component (unmount/remount).
|
// Identity change -> new component (unmount/remount).
|
||||||
// Compare fields that, when changed, require a full entity rebuild.
|
// Compare fields that, when changed, require a full entity rebuild.
|
||||||
|
// Only compare shapeName for entity types that actually use it.
|
||||||
|
// Scene entities (Terrain, Interior, Sky, etc.), ForceFieldBare,
|
||||||
|
// AudioEmitter, WayPoint, and Camera don't have shapeName on their
|
||||||
|
// GameEntity, so comparing against entity.dataBlock would always
|
||||||
|
// mismatch and trigger a needless rebuild every frame.
|
||||||
|
const hasShapeName =
|
||||||
|
renderEntity &&
|
||||||
|
(renderEntity.renderType === "Shape" ||
|
||||||
|
renderEntity.renderType === "Player" ||
|
||||||
|
renderEntity.renderType === "Explosion");
|
||||||
|
|
||||||
const needsNewIdentity =
|
const needsNewIdentity =
|
||||||
!renderEntity ||
|
!renderEntity ||
|
||||||
renderEntity.className !== (entity.className ?? entity.type) ||
|
renderEntity.className !== (entity.className ?? entity.type) ||
|
||||||
renderEntity.ghostIndex !== entity.ghostIndex ||
|
renderEntity.ghostIndex !== entity.ghostIndex ||
|
||||||
renderEntity.dataBlockId !== entity.dataBlockId ||
|
renderEntity.dataBlockId !== entity.dataBlockId ||
|
||||||
renderEntity.shapeHint !== entity.shapeHint ||
|
renderEntity.shapeHint !== entity.shapeHint ||
|
||||||
getField(renderEntity, "shapeName") !== entity.dataBlock ||
|
(hasShapeName &&
|
||||||
|
entity.dataBlock != null &&
|
||||||
|
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
|
||||||
// weaponShape changes only force rebuild for non-Player shapes
|
// weaponShape changes only force rebuild for non-Player shapes
|
||||||
// (turrets, vehicles). Players handle weapon changes internally
|
// (turrets, vehicles). Players handle weapon changes internally
|
||||||
// via PlayerModel's Mount0 bone, and rebuilding on weapon change
|
// via PlayerModel's Mount0 bone, and rebuilding on weapon change
|
||||||
// would lose animation state (death animations, etc.).
|
// would lose animation state (death animations, etc.).
|
||||||
(renderEntity.renderType !== "Player" &&
|
(renderEntity.renderType !== "Player" &&
|
||||||
|
hasShapeName &&
|
||||||
getField(renderEntity, "weaponShape") !== entity.weaponShape);
|
getField(renderEntity, "weaponShape") !== entity.weaponShape);
|
||||||
|
|
||||||
if (needsNewIdentity) {
|
if (needsNewIdentity) {
|
||||||
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
|
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
|
||||||
shouldRebuild = true;
|
|
||||||
} else {
|
} else {
|
||||||
// Mutate render fields in-place on the existing entity object.
|
// Mutate render fields in-place on the existing entity object.
|
||||||
// Components read these imperatively in useFrame — no React
|
// Components read these imperatively in useFrame — no React
|
||||||
|
|
@ -153,7 +168,8 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
// Keyframe update (mutable -- used for fallback position for
|
// Keyframe update (mutable -- used for fallback position for
|
||||||
// retained explosion entities and per-frame reads in useFrame).
|
// retained explosion entities and per-frame reads in useFrame).
|
||||||
// Scene entities and None don't have keyframes.
|
// Scene entities and None don't have keyframes.
|
||||||
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None") continue;
|
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None")
|
||||||
|
continue;
|
||||||
const keyframes = renderEntity.keyframes!;
|
const keyframes = renderEntity.keyframes!;
|
||||||
if (keyframes.length === 0) {
|
if (keyframes.length === 0) {
|
||||||
keyframes.push({
|
keyframes.push({
|
||||||
|
|
@ -189,12 +205,12 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Entity removed (or retention expired).
|
|
||||||
shouldRebuild = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect new entities added.
|
// Only push to store when the entity set changed (adds/removes).
|
||||||
if (nextMap.size !== prevMap.size) shouldRebuild = true;
|
const shouldRebuild =
|
||||||
|
nextMap.size !== prevMap.size ||
|
||||||
|
[...nextMap.keys()].some((id) => !prevMap.has(id));
|
||||||
|
|
||||||
entityMapRef.current = nextMap;
|
entityMapRef.current = nextMap;
|
||||||
if (shouldRebuild) {
|
if (shouldRebuild) {
|
||||||
|
|
@ -202,7 +218,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextFirstPersonShape: string | null = null;
|
let nextFirstPersonShape: string | null = null;
|
||||||
if (snapshot.camera?.mode === "first-person" && snapshot.camera.controlEntityId) {
|
if (
|
||||||
|
snapshot.camera?.mode === "first-person" &&
|
||||||
|
snapshot.camera.controlEntityId
|
||||||
|
) {
|
||||||
const entity = nextMap.get(snapshot.camera.controlEntityId);
|
const entity = nextMap.get(snapshot.camera.controlEntityId);
|
||||||
const sn = entity ? getField(entity, "shapeName") : undefined;
|
const sn = entity ? getField(entity, "shapeName") : undefined;
|
||||||
if (sn) {
|
if (sn) {
|
||||||
|
|
@ -231,13 +250,31 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
|
|
||||||
const stream = streamRef.current;
|
const stream = streamRef.current;
|
||||||
streamPlaybackStore.setState({ playback: stream });
|
streamPlaybackStore.setState({ playback: stream });
|
||||||
gameEntityStore.getState().beginStreaming();
|
gameEntityStore.getState().beginStreaming(recording.source);
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
engineStore.getState().setPlaybackStreamSnapshot(null);
|
engineStore.getState().setPlaybackStreamSnapshot(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update gameEntityStore when mission info arrives via server messages
|
||||||
|
// (MsgMissionDropInfo, MsgLoadInfo, MsgClientReady).
|
||||||
|
stream.onMissionInfoChange = () => {
|
||||||
|
gameEntityStore.getState().setMissionInfo({
|
||||||
|
missionDisplayName: stream.missionDisplayName ?? undefined,
|
||||||
|
missionTypeDisplayName: stream.missionTypeDisplayName ?? undefined,
|
||||||
|
gameClassName: stream.gameClassName ?? undefined,
|
||||||
|
recorderName: stream.connectedPlayerName ?? undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save pre-populated mission info before reset clears it.
|
||||||
|
const savedMissionDisplayName = stream.missionDisplayName;
|
||||||
|
const savedMissionTypeDisplayName = stream.missionTypeDisplayName;
|
||||||
|
const savedGameClassName = stream.gameClassName;
|
||||||
|
const savedServerDisplayName = stream.serverDisplayName;
|
||||||
|
const savedConnectedPlayerName = stream.connectedPlayerName;
|
||||||
|
|
||||||
// Reset the stream cursor for demo playback (replay from the beginning).
|
// Reset the stream cursor for demo playback (replay from the beginning).
|
||||||
// For live streams, skip reset — the adapter is already receiving packets
|
// For live streams, skip reset — the adapter is already receiving packets
|
||||||
// and has accumulated protocol state (net strings, target info, sensor
|
// and has accumulated protocol state (net strings, target info, sensor
|
||||||
|
|
@ -245,6 +282,26 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
if (recording.source !== "live") {
|
if (recording.source !== "live") {
|
||||||
stream.reset();
|
stream.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore mission info fields that were parsed from the initial block
|
||||||
|
// (demoValues) — reset() clears them but they won't be re-sent.
|
||||||
|
stream.missionDisplayName = savedMissionDisplayName;
|
||||||
|
stream.missionTypeDisplayName = savedMissionTypeDisplayName;
|
||||||
|
stream.gameClassName = savedGameClassName;
|
||||||
|
stream.serverDisplayName = savedServerDisplayName;
|
||||||
|
stream.connectedPlayerName = savedConnectedPlayerName;
|
||||||
|
|
||||||
|
gameEntityStore.getState().setMissionInfo({
|
||||||
|
missionName: recording.missionName ?? undefined,
|
||||||
|
missionTypeDisplayName: recording.gameType ?? undefined,
|
||||||
|
missionDisplayName: savedMissionDisplayName ?? undefined,
|
||||||
|
gameClassName: savedGameClassName ?? undefined,
|
||||||
|
serverDisplayName:
|
||||||
|
savedServerDisplayName ?? recording.serverDisplayName ?? undefined,
|
||||||
|
recorderName:
|
||||||
|
savedConnectedPlayerName ?? recording.recorderName ?? undefined,
|
||||||
|
recordingDate: recording.recordingDate ?? undefined,
|
||||||
|
});
|
||||||
// Preload weapon effect shapes (explosions) so they're cached before
|
// Preload weapon effect shapes (explosions) so they're cached before
|
||||||
// the first projectile detonates -- otherwise the GLB fetch latency
|
// the first projectile detonates -- otherwise the GLB fetch latency
|
||||||
// causes the short-lived explosion entity to expire before it renders.
|
// causes the short-lived explosion entity to expire before it renders.
|
||||||
|
|
@ -264,9 +321,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopAllTrackedSounds();
|
stopAllTrackedSounds();
|
||||||
gameEntityStore.getState().endStreaming();
|
// Null out streamRef so useFrame stops syncing entities.
|
||||||
|
streamRef.current = null;
|
||||||
|
// Don't call endStreaming() or clear the snapshot — leave entities,
|
||||||
|
// HUD, and chat in place as a frozen snapshot after disconnect.
|
||||||
resetStreamPlayback();
|
resetStreamPlayback();
|
||||||
engineStore.getState().setPlaybackStreamSnapshot(null);
|
|
||||||
};
|
};
|
||||||
}, [recording, engineStore, syncRenderableEntities]);
|
}, [recording, engineStore, syncRenderableEntities]);
|
||||||
|
|
||||||
|
|
@ -279,9 +338,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
const isPlaying = playback.status === "playing";
|
const isPlaying = playback.status === "playing";
|
||||||
const requestedTimeSec = playback.timeMs / 1000;
|
const requestedTimeSec = playback.timeMs / 1000;
|
||||||
const externalSeekWhilePaused =
|
const externalSeekWhilePaused =
|
||||||
!isPlaying && Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
|
!isPlaying &&
|
||||||
|
Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
|
||||||
const externalSeekWhilePlaying =
|
const externalSeekWhilePlaying =
|
||||||
isPlaying && Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
|
isPlaying &&
|
||||||
|
Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
|
||||||
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
|
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
|
||||||
if (isSeeking) {
|
if (isSeeking) {
|
||||||
// Sync stream cursor to UI/programmatic seek.
|
// Sync stream cursor to UI/programmatic seek.
|
||||||
|
|
@ -334,7 +395,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
|
|
||||||
streamPlaybackStore.setState({ time: playbackClockRef.current });
|
streamPlaybackStore.setState({ time: playbackClockRef.current });
|
||||||
if (snapshot.exhausted && isPlaying) {
|
if (snapshot.exhausted && isPlaying) {
|
||||||
playbackClockRef.current = Math.min(playbackClockRef.current, snapshot.timeSec);
|
playbackClockRef.current = Math.min(
|
||||||
|
playbackClockRef.current,
|
||||||
|
snapshot.timeSec,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncRenderableEntities(renderCurrent);
|
syncRenderableEntities(renderCurrent);
|
||||||
|
|
@ -404,7 +468,8 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
const perspectiveCamera = state.camera as any;
|
const perspectiveCamera = state.camera as any;
|
||||||
const fovValue =
|
const fovValue =
|
||||||
previousCamera && Number.isFinite(previousCamera.fov)
|
previousCamera && Number.isFinite(previousCamera.fov)
|
||||||
? previousCamera.fov + (currentCamera.fov - previousCamera.fov) * interpT
|
? previousCamera.fov +
|
||||||
|
(currentCamera.fov - previousCamera.fov) * interpT
|
||||||
: currentCamera.fov;
|
: currentCamera.fov;
|
||||||
const verticalFov = torqueHorizontalFovToThreeVerticalFov(
|
const verticalFov = torqueHorizontalFovToThreeVerticalFov(
|
||||||
fovValue,
|
fovValue,
|
||||||
|
|
@ -436,7 +501,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
// snapshot lifetime) won't be in the snapshot entity map. Fall back
|
// snapshot lifetime) won't be in the snapshot entity map. Fall back
|
||||||
// to their last-known keyframe position from the render entity.
|
// to their last-known keyframe position from the render entity.
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
const kfs = renderEntity && "keyframes" in renderEntity ? renderEntity.keyframes : undefined;
|
const kfs =
|
||||||
|
renderEntity && "keyframes" in renderEntity
|
||||||
|
? renderEntity.keyframes
|
||||||
|
: undefined;
|
||||||
if (kfs?.[0]?.position) {
|
if (kfs?.[0]?.position) {
|
||||||
const kf = kfs[0];
|
const kf = kfs[0];
|
||||||
child.visible = true;
|
child.visible = true;
|
||||||
|
|
@ -463,11 +531,17 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
const iz = pz + (cz - pz) * interpT;
|
const iz = pz + (cz - pz) * interpT;
|
||||||
child.position.set(iy, iz, ix);
|
child.position.set(iy, iz, ix);
|
||||||
} else {
|
} else {
|
||||||
child.position.set(entity.position[1], entity.position[2], entity.position[0]);
|
child.position.set(
|
||||||
|
entity.position[1],
|
||||||
|
entity.position[2],
|
||||||
|
entity.position[0],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity.faceViewer) {
|
if (entity.faceViewer) {
|
||||||
child.quaternion.copy(state.camera.quaternion).multiply(_billboardFlip);
|
child.quaternion
|
||||||
|
.copy(state.camera.quaternion)
|
||||||
|
.multiply(_billboardFlip);
|
||||||
} else if (entity.visual?.kind === "tracer") {
|
} else if (entity.visual?.kind === "tracer") {
|
||||||
child.quaternion.identity();
|
child.quaternion.identity();
|
||||||
} else if (entity.rotation) {
|
} else if (entity.rotation) {
|
||||||
|
|
@ -486,7 +560,13 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
const mode = currentCamera?.mode;
|
const mode = currentCamera?.mode;
|
||||||
// In live mode, LiveObserver handles orbit positioning from predicted
|
// In live mode, LiveObserver handles orbit positioning from predicted
|
||||||
// angles so the orbit responds at frame rate. Skip here to avoid fighting.
|
// angles so the orbit responds at frame rate. Skip here to avoid fighting.
|
||||||
if (!freeFly && !isLive && mode === "third-person" && root && currentCamera?.orbitTargetId) {
|
if (
|
||||||
|
!freeFly &&
|
||||||
|
!isLive &&
|
||||||
|
mode === "third-person" &&
|
||||||
|
root &&
|
||||||
|
currentCamera?.orbitTargetId
|
||||||
|
) {
|
||||||
const targetGroup = root.children.find(
|
const targetGroup = root.children.find(
|
||||||
(child) => child.name === currentCamera.orbitTargetId,
|
(child) => child.name === currentCamera.orbitTargetId,
|
||||||
);
|
);
|
||||||
|
|
@ -530,7 +610,9 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
if (hasDirection) {
|
if (hasDirection) {
|
||||||
_orbitDir.normalize();
|
_orbitDir.normalize();
|
||||||
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
|
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
|
||||||
_orbitCandidate.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance);
|
_orbitCandidate
|
||||||
|
.copy(_orbitTarget)
|
||||||
|
.addScaledVector(_orbitDir, orbitDistance);
|
||||||
|
|
||||||
state.camera.position.copy(_orbitCandidate);
|
state.camera.position.copy(_orbitCandidate);
|
||||||
state.camera.lookAt(_orbitTarget);
|
state.camera.lookAt(_orbitTarget);
|
||||||
|
|
@ -538,12 +620,19 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!freeFly && mode === "first-person" && root && currentCamera?.controlEntityId) {
|
if (
|
||||||
|
!freeFly &&
|
||||||
|
mode === "first-person" &&
|
||||||
|
root &&
|
||||||
|
currentCamera?.controlEntityId
|
||||||
|
) {
|
||||||
const playerGroup = root.children.find(
|
const playerGroup = root.children.find(
|
||||||
(child) => child.name === currentCamera.controlEntityId,
|
(child) => child.name === currentCamera.controlEntityId,
|
||||||
);
|
);
|
||||||
if (playerGroup) {
|
if (playerGroup) {
|
||||||
_tmpVec.copy(eyeOffsetRef.current).applyQuaternion(playerGroup.quaternion);
|
_tmpVec
|
||||||
|
.copy(eyeOffsetRef.current)
|
||||||
|
.applyQuaternion(playerGroup.quaternion);
|
||||||
state.camera.position.add(_tmpVec);
|
state.camera.position.add(_tmpVec);
|
||||||
} else {
|
} else {
|
||||||
state.camera.position.y += eyeOffsetRef.current.y;
|
state.camera.position.y += eyeOffsetRef.current.y;
|
||||||
|
|
@ -567,8 +656,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
|
||||||
snapshotRef={currentTickSnapshotRef}
|
snapshotRef={currentTickSnapshotRef}
|
||||||
/>
|
/>
|
||||||
{firstPersonShape && (
|
{firstPersonShape && (
|
||||||
<Suspense fallback={null}>
|
<Suspense>
|
||||||
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} />
|
<PlayerEyeOffset
|
||||||
|
shapeName={firstPersonShape}
|
||||||
|
eyeOffsetRef={eyeOffsetRef}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
90
src/components/StreamingMissionInfo.module.css
Normal file
90
src/components/StreamingMissionInfo.module.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
.Header {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MissionInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 10px auto 10px 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
color: #fff;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MissionName {
|
||||||
|
composes: SelectedName from "./MissionSelect.module.css";
|
||||||
|
}
|
||||||
|
|
||||||
|
.MissionType {
|
||||||
|
composes: ItemType from "./MissionSelect.module.css";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MissionTypeDisplayName {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: calc(14 / 12);
|
||||||
|
text-align: right;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Attribution,
|
||||||
|
.ServerInfo {
|
||||||
|
color: #83938b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.PlayerName,
|
||||||
|
.RecordingDate,
|
||||||
|
.ServerName {
|
||||||
|
color: #eceae7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActionButton {
|
||||||
|
composes: IconButton from "./InspectorControls.module.css";
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
font-size: 16px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 2px;
|
||||||
|
/* display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer; */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .ActionButton:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.EjectIcon {
|
||||||
|
font-size: 21px;
|
||||||
|
margin-top: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.Metadata {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/components/StreamingMissionInfo.tsx
Normal file
118
src/components/StreamingMissionInfo.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
useDataSource,
|
||||||
|
useMissionDisplayName,
|
||||||
|
useMissionType,
|
||||||
|
useMissionTypeDisplayName,
|
||||||
|
useRecorderName,
|
||||||
|
useRecordingDate,
|
||||||
|
useServerDisplayName,
|
||||||
|
} from "../state/gameEntityStore";
|
||||||
|
import { engineStore } from "../state/engineStore";
|
||||||
|
import {
|
||||||
|
liveConnectionStore,
|
||||||
|
useLiveSelector,
|
||||||
|
} from "../state/liveConnectionStore";
|
||||||
|
import { useRecording } from "./RecordingProvider";
|
||||||
|
import { LuCircleArrowOutUpLeft } from "react-icons/lu";
|
||||||
|
import { BiSolidEject } from "react-icons/bi";
|
||||||
|
import styles from "./StreamingMissionInfo.module.css";
|
||||||
|
|
||||||
|
export function StreamingMissionInfo() {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const missionDisplayName = useMissionDisplayName();
|
||||||
|
const missionType = useMissionType();
|
||||||
|
const missionTypeDisplayName = useMissionTypeDisplayName();
|
||||||
|
const serverName = useServerDisplayName();
|
||||||
|
const playerName = useRecorderName();
|
||||||
|
const dateString = useRecordingDate();
|
||||||
|
const [datePart, timePart] = dateString
|
||||||
|
? dateString.split(" ")
|
||||||
|
: [null, null];
|
||||||
|
const isLive = dataSource === "live";
|
||||||
|
const recording = useRecording();
|
||||||
|
const isLiveConnected = useLiveSelector(
|
||||||
|
(s) => s.gameStatus === "connected" || s.gameStatus === "authenticating",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEject = useCallback(() => {
|
||||||
|
engineStore.getState().setRecording(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(() => {
|
||||||
|
const liveState = liveConnectionStore.getState();
|
||||||
|
liveState.disconnectServer();
|
||||||
|
liveState.disconnectRelay();
|
||||||
|
engineStore.getState().setRecording(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.Header}>
|
||||||
|
<div className={styles.MissionInfo}>
|
||||||
|
{missionDisplayName && missionType ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.MissionName}>{missionDisplayName}</span>
|
||||||
|
{missionType && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span
|
||||||
|
className={styles.MissionType}
|
||||||
|
data-mission-type={missionType}
|
||||||
|
>
|
||||||
|
{missionTypeDisplayName === "LCTF" ? "LCTF" : missionType}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.Metadata}>
|
||||||
|
{isLive ? (
|
||||||
|
playerName ? (
|
||||||
|
<div className={styles.Attribution}>
|
||||||
|
Connected as{" "}
|
||||||
|
<span className={styles.PlayerName}>{playerName}</span>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : playerName && dateString ? (
|
||||||
|
<div className={styles.Attribution}>
|
||||||
|
Recorded by <span className={styles.PlayerName}>{playerName}</span>{" "}
|
||||||
|
on{" "}
|
||||||
|
<span className={styles.RecordingDate}>
|
||||||
|
{datePart.replace(/-/g, " ")}
|
||||||
|
</span>{" "}
|
||||||
|
at <span className={styles.RecordingDate}>{timePart}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{serverName ? (
|
||||||
|
<div className={styles.ServerInfo}>
|
||||||
|
Server: <span className={styles.ServerName}>{serverName}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{dataSource === "demo" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ActionButton}
|
||||||
|
title="Unload demo"
|
||||||
|
aria-label="Unload demo"
|
||||||
|
onClick={handleEject}
|
||||||
|
disabled={!recording}
|
||||||
|
>
|
||||||
|
<BiSolidEject className={styles.EjectIcon} />
|
||||||
|
</button>
|
||||||
|
) : isLive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ActionButton}
|
||||||
|
title="Disconnect"
|
||||||
|
aria-label="Disconnect"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={!isLiveConnected}
|
||||||
|
>
|
||||||
|
<LuCircleArrowOutUpLeft />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import type { SceneTSStatic } from "../scene/types";
|
import type { SceneTSStatic } from "../scene/types";
|
||||||
|
|
||||||
|
const log = createLogger("TSStatic");
|
||||||
import {
|
import {
|
||||||
torqueToThree,
|
torqueToThree,
|
||||||
torqueScaleToThree,
|
torqueScaleToThree,
|
||||||
|
|
@ -18,10 +21,7 @@ export function TSStatic({ scene }: { scene: SceneTSStatic }) {
|
||||||
);
|
);
|
||||||
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
|
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
|
||||||
if (!scene.shapeName) {
|
if (!scene.shapeName) {
|
||||||
console.error(
|
log.error("TSStatic missing shapeName for ghostIndex %d", scene.ghostIndex);
|
||||||
"<TSStatic> missing shapeName for ghostIndex",
|
|
||||||
scene.ghostIndex,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ShapeInfoProvider type="TSStatic" shapeName={scene.shapeName}>
|
<ShapeInfoProvider type="TSStatic" shapeName={scene.shapeName}>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ import {
|
||||||
Vector3,
|
Vector3,
|
||||||
} from "three";
|
} from "three";
|
||||||
import type { SceneTerrainBlock } from "../scene/types";
|
import type { SceneTerrainBlock } from "../scene/types";
|
||||||
|
import { createLogger } from "../logger";
|
||||||
import { torqueToThree } from "../scene/coordinates";
|
import { torqueToThree } from "../scene/coordinates";
|
||||||
|
|
||||||
|
const log = createLogger("TerrainBlock");
|
||||||
import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
|
import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
|
||||||
import { loadTerrain } from "../loaders";
|
import { loadTerrain } from "../loaders";
|
||||||
import { uint16ToFloat32 } from "../arrayUtils";
|
import { uint16ToFloat32 } from "../arrayUtils";
|
||||||
|
|
@ -413,10 +416,25 @@ function generateTerrainLightmap(
|
||||||
* Load a .ter file, used for terrain heightmap and texture info.
|
* Load a .ter file, used for terrain heightmap and texture info.
|
||||||
*/
|
*/
|
||||||
function useTerrain(terrainFile: string) {
|
function useTerrain(terrainFile: string) {
|
||||||
return useQuery({
|
const result = useQuery({
|
||||||
queryKey: ["terrain", terrainFile],
|
queryKey: ["terrain", terrainFile],
|
||||||
queryFn: () => loadTerrain(terrainFile),
|
queryFn: () => {
|
||||||
|
log.debug("Loading terrain: %s", terrainFile);
|
||||||
|
return loadTerrain(terrainFile);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
log.debug(
|
||||||
|
"Query status: %s%s%s file=%s",
|
||||||
|
result.status,
|
||||||
|
result.error ? ` error=${result.error.message}` : "",
|
||||||
|
result.data ? " (data ready)" : " (no data)",
|
||||||
|
terrainFile,
|
||||||
|
);
|
||||||
|
}, [result.status, result.error, result.data, terrainFile]);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get visibleDistance from the Sky scene object, used to determine how far
|
* Get visibleDistance from the Sky scene object, used to determine how far
|
||||||
|
|
@ -607,6 +625,13 @@ export const TerrainBlock = memo(function TerrainBlock({
|
||||||
!sharedDisplacementMap ||
|
!sharedDisplacementMap ||
|
||||||
!sharedAlphaTextures
|
!sharedAlphaTextures
|
||||||
) {
|
) {
|
||||||
|
log.debug(
|
||||||
|
"Not ready: terrain=%s geometry=%s displacement=%s alpha=%s",
|
||||||
|
!!terrain,
|
||||||
|
!!sharedGeometry,
|
||||||
|
!!sharedDisplacementMap,
|
||||||
|
!!sharedAlphaTextures,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
41
src/components/ThreeCanvas.tsx
Normal file
41
src/components/ThreeCanvas.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Canvas, GLProps, RootState } from "@react-three/fiber";
|
||||||
|
import { NoToneMapping, PCFShadowMap, SRGBColorSpace } from "three";
|
||||||
|
import { useDebug } from "./SettingsProvider";
|
||||||
|
|
||||||
|
export type InvalidateFunction = RootState["invalidate"];
|
||||||
|
|
||||||
|
// Renderer settings to match Tribes 2's simple rendering pipeline.
|
||||||
|
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
|
||||||
|
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
|
||||||
|
const glSettings: GLProps = {
|
||||||
|
toneMapping: NoToneMapping,
|
||||||
|
outputColorSpace: SRGBColorSpace,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreeCanvas({
|
||||||
|
children,
|
||||||
|
renderOnDemand: renderOnDemandFromProps = false,
|
||||||
|
dpr: dprFromProps,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
dpr?: number;
|
||||||
|
renderOnDemand?: boolean;
|
||||||
|
onCreated?: (state: RootState) => void;
|
||||||
|
}) {
|
||||||
|
const { renderOnDemand: renderOnDemandFromSettings } = useDebug();
|
||||||
|
const renderOnDemand = renderOnDemandFromProps || renderOnDemandFromSettings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas
|
||||||
|
frameloop={renderOnDemand ? "demand" : "always"}
|
||||||
|
dpr={dprFromProps}
|
||||||
|
gl={glSettings}
|
||||||
|
shadows={{ type: PCFShadowMap }}
|
||||||
|
onCreated={onCreated}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
import { useEffect, useRef, type RefObject } from "react";
|
|
||||||
import { Euler, Vector3 } from "three";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import type nipplejs from "nipplejs";
|
|
||||||
import { useControls } from "./SettingsProvider";
|
|
||||||
import styles from "./TouchControls.module.css";
|
|
||||||
|
|
||||||
/** Apply styles to nipplejs-generated `.back` and `.front` elements imperatively. */
|
|
||||||
function applyNippleStyles(zone: HTMLElement) {
|
|
||||||
const back = zone.querySelector<HTMLElement>(".back");
|
|
||||||
if (back) {
|
|
||||||
back.style.background = "rgba(3, 79, 76, 0.6)";
|
|
||||||
back.style.border = "1px solid rgba(0, 219, 223, 0.5)";
|
|
||||||
back.style.boxShadow = "inset 0 0 10px rgba(0, 0, 0, 0.7)";
|
|
||||||
}
|
|
||||||
const front = zone.querySelector<HTMLElement>(".front");
|
|
||||||
if (front) {
|
|
||||||
front.style.background =
|
|
||||||
"radial-gradient(circle at 50% 50%, rgba(23, 247, 198, 0.9) 0%, rgba(9, 184, 170, 0.95) 100%)";
|
|
||||||
front.style.border = "2px solid rgba(255, 255, 255, 0.4)";
|
|
||||||
front.style.boxShadow =
|
|
||||||
"0 2px 4px rgba(0, 0, 0, 0.5), 0 1px 1px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 2px rgba(0, 0, 0, 0.3)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_SPEED = 80;
|
|
||||||
const LOOK_SENSITIVITY = 0.004;
|
|
||||||
const STICK_LOOK_SENSITIVITY = 2.5;
|
|
||||||
const DUAL_MOVE_DEADZONE = 0.08;
|
|
||||||
const DUAL_LOOK_DEADZONE = 0.15;
|
|
||||||
const SINGLE_STICK_DEADZONE = 0.15;
|
|
||||||
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
|
|
||||||
|
|
||||||
export type JoystickState = {
|
|
||||||
angle: number;
|
|
||||||
force: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SharedProps = {
|
|
||||||
joystickState: RefObject<JoystickState>;
|
|
||||||
joystickZone: RefObject<HTMLDivElement | null>;
|
|
||||||
lookJoystickState: RefObject<JoystickState>;
|
|
||||||
lookJoystickZone: RefObject<HTMLDivElement | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Renders the joystick zone(s). Place inside canvasContainer, outside Canvas. */
|
|
||||||
export function TouchJoystick({
|
|
||||||
joystickState,
|
|
||||||
joystickZone,
|
|
||||||
lookJoystickState,
|
|
||||||
lookJoystickZone,
|
|
||||||
}: SharedProps) {
|
|
||||||
const { touchMode } = useControls();
|
|
||||||
// Move joystick
|
|
||||||
useEffect(() => {
|
|
||||||
const zone = joystickZone.current;
|
|
||||||
if (!zone) return;
|
|
||||||
|
|
||||||
let manager: nipplejs.JoystickManager | null = null;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
import("nipplejs").then((mod) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
manager = mod.default.create({
|
|
||||||
zone,
|
|
||||||
mode: "static",
|
|
||||||
position: { left: "70px", bottom: "70px" },
|
|
||||||
size: 120,
|
|
||||||
restOpacity: 0.9,
|
|
||||||
});
|
|
||||||
|
|
||||||
applyNippleStyles(zone);
|
|
||||||
|
|
||||||
manager.on("move", (_event, data) => {
|
|
||||||
joystickState.current.angle = data.angle.radian;
|
|
||||||
joystickState.current.force = Math.min(1, data.force);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.on("end", () => {
|
|
||||||
joystickState.current.force = 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
manager?.destroy();
|
|
||||||
};
|
|
||||||
}, [joystickState, joystickZone, touchMode]);
|
|
||||||
|
|
||||||
// Look joystick (dual stick mode only)
|
|
||||||
useEffect(() => {
|
|
||||||
if (touchMode !== "dualStick") return;
|
|
||||||
|
|
||||||
const zone = lookJoystickZone.current;
|
|
||||||
if (!zone) return;
|
|
||||||
|
|
||||||
let manager: nipplejs.JoystickManager | null = null;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
import("nipplejs").then((mod) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
manager = mod.default.create({
|
|
||||||
zone,
|
|
||||||
mode: "static",
|
|
||||||
position: { right: "70px", bottom: "70px" },
|
|
||||||
size: 120,
|
|
||||||
restOpacity: 0.9,
|
|
||||||
});
|
|
||||||
|
|
||||||
applyNippleStyles(zone);
|
|
||||||
|
|
||||||
manager.on("move", (_event, data) => {
|
|
||||||
lookJoystickState.current.angle = data.angle.radian;
|
|
||||||
lookJoystickState.current.force = Math.min(1, data.force);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.on("end", () => {
|
|
||||||
lookJoystickState.current.force = 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
manager?.destroy();
|
|
||||||
};
|
|
||||||
}, [touchMode, lookJoystickState, lookJoystickZone]);
|
|
||||||
|
|
||||||
const blurActiveElement = () => {
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (touchMode === "dualStick") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={joystickZone}
|
|
||||||
className={styles.Left}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
onTouchStart={blurActiveElement}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={lookJoystickZone}
|
|
||||||
className={styles.Right}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
onTouchStart={blurActiveElement}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={joystickZone}
|
|
||||||
className={styles.Joystick}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
onTouchStart={blurActiveElement}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles touch look and joystick-driven movement. Place inside Canvas. */
|
|
||||||
export function TouchCameraMovement({
|
|
||||||
joystickState,
|
|
||||||
joystickZone,
|
|
||||||
lookJoystickState,
|
|
||||||
}: SharedProps) {
|
|
||||||
const { speedMultiplier, touchMode } = useControls();
|
|
||||||
const { camera, gl } = useThree();
|
|
||||||
|
|
||||||
// Touch look state
|
|
||||||
const euler = useRef(new Euler(0, 0, 0, "YXZ"));
|
|
||||||
const lookTouchId = useRef<number | null>(null);
|
|
||||||
const lastTouchPos = useRef({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
// Scratch vectors
|
|
||||||
const forwardVec = useRef(new Vector3());
|
|
||||||
const sideVec = useRef(new Vector3());
|
|
||||||
const moveVec = useRef(new Vector3());
|
|
||||||
|
|
||||||
// Initialize euler from current camera rotation on mount
|
|
||||||
useEffect(() => {
|
|
||||||
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
|
|
||||||
}, [camera]);
|
|
||||||
|
|
||||||
// Touch-drag look handling (moveLookStick mode)
|
|
||||||
useEffect(() => {
|
|
||||||
if (touchMode !== "moveLookStick") return;
|
|
||||||
|
|
||||||
const canvas = gl.domElement;
|
|
||||||
|
|
||||||
const isTouchOnJoystick = (touch: Touch) => {
|
|
||||||
const zone = joystickZone.current;
|
|
||||||
if (!zone) return false;
|
|
||||||
const rect = zone.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
touch.clientX >= rect.left &&
|
|
||||||
touch.clientX <= rect.right &&
|
|
||||||
touch.clientY >= rect.top &&
|
|
||||||
touch.clientY <= rect.bottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
|
||||||
if (lookTouchId.current !== null) return;
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const touch = e.changedTouches[i];
|
|
||||||
if (!isTouchOnJoystick(touch)) {
|
|
||||||
lookTouchId.current = touch.identifier;
|
|
||||||
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
|
||||||
if (lookTouchId.current === null) return;
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const touch = e.changedTouches[i];
|
|
||||||
if (touch.identifier === lookTouchId.current) {
|
|
||||||
const dx = touch.clientX - lastTouchPos.current.x;
|
|
||||||
const dy = touch.clientY - lastTouchPos.current.y;
|
|
||||||
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
|
|
||||||
|
|
||||||
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
|
|
||||||
euler.current.y -= dx * LOOK_SENSITIVITY;
|
|
||||||
euler.current.x -= dy * LOOK_SENSITIVITY;
|
|
||||||
euler.current.x = Math.max(
|
|
||||||
-MAX_PITCH,
|
|
||||||
Math.min(MAX_PITCH, euler.current.x),
|
|
||||||
);
|
|
||||||
camera.quaternion.setFromEuler(euler.current);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = (e: TouchEvent) => {
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
if (e.changedTouches[i].identifier === lookTouchId.current) {
|
|
||||||
lookTouchId.current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
||||||
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
|
|
||||||
canvas.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
||||||
canvas.addEventListener("touchcancel", handleTouchEnd, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canvas.removeEventListener("touchstart", handleTouchStart);
|
|
||||||
canvas.removeEventListener("touchmove", handleTouchMove);
|
|
||||||
canvas.removeEventListener("touchend", handleTouchEnd);
|
|
||||||
canvas.removeEventListener("touchcancel", handleTouchEnd);
|
|
||||||
lookTouchId.current = null;
|
|
||||||
};
|
|
||||||
}, [camera, gl.domElement, joystickZone, touchMode]);
|
|
||||||
|
|
||||||
useFrame((_state, delta) => {
|
|
||||||
const { force, angle } = joystickState.current;
|
|
||||||
|
|
||||||
if (touchMode === "dualStick") {
|
|
||||||
// Right stick → camera rotation
|
|
||||||
const look = lookJoystickState.current;
|
|
||||||
if (look.force > DUAL_LOOK_DEADZONE) {
|
|
||||||
const lookForce =
|
|
||||||
(look.force - DUAL_LOOK_DEADZONE) / (1 - DUAL_LOOK_DEADZONE);
|
|
||||||
const lookX = Math.cos(look.angle);
|
|
||||||
const lookY = Math.sin(look.angle);
|
|
||||||
|
|
||||||
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
|
|
||||||
euler.current.y -= lookX * lookForce * STICK_LOOK_SENSITIVITY * delta;
|
|
||||||
euler.current.x += lookY * lookForce * STICK_LOOK_SENSITIVITY * delta;
|
|
||||||
euler.current.x = Math.max(
|
|
||||||
-MAX_PITCH,
|
|
||||||
Math.min(MAX_PITCH, euler.current.x),
|
|
||||||
);
|
|
||||||
camera.quaternion.setFromEuler(euler.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left stick → movement
|
|
||||||
if (force > DUAL_MOVE_DEADZONE) {
|
|
||||||
const moveForce =
|
|
||||||
(force - DUAL_MOVE_DEADZONE) / (1 - DUAL_MOVE_DEADZONE);
|
|
||||||
const speed = BASE_SPEED * speedMultiplier * moveForce;
|
|
||||||
const joyX = Math.cos(angle);
|
|
||||||
const joyY = Math.sin(angle);
|
|
||||||
|
|
||||||
camera.getWorldDirection(forwardVec.current);
|
|
||||||
forwardVec.current.normalize();
|
|
||||||
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
|
|
||||||
|
|
||||||
moveVec.current
|
|
||||||
.set(0, 0, 0)
|
|
||||||
.addScaledVector(forwardVec.current, joyY)
|
|
||||||
.addScaledVector(sideVec.current, -joyX);
|
|
||||||
|
|
||||||
if (moveVec.current.lengthSq() > 0) {
|
|
||||||
moveVec.current.normalize().multiplyScalar(speed * delta);
|
|
||||||
camera.position.add(moveVec.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (touchMode === "moveLookStick") {
|
|
||||||
if (force > 0) {
|
|
||||||
// Move forward at half the configured speed.
|
|
||||||
const speed = BASE_SPEED * speedMultiplier * 0.5;
|
|
||||||
camera.getWorldDirection(forwardVec.current);
|
|
||||||
forwardVec.current.normalize();
|
|
||||||
moveVec.current.copy(forwardVec.current).multiplyScalar(speed * delta);
|
|
||||||
camera.position.add(moveVec.current);
|
|
||||||
|
|
||||||
if (force >= SINGLE_STICK_DEADZONE) {
|
|
||||||
// Outer zone: also control camera look (yaw + pitch).
|
|
||||||
const lookX = Math.cos(angle);
|
|
||||||
const lookY = Math.sin(angle);
|
|
||||||
const lookForce =
|
|
||||||
(force - SINGLE_STICK_DEADZONE) / (1 - SINGLE_STICK_DEADZONE);
|
|
||||||
|
|
||||||
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
|
|
||||||
euler.current.y -=
|
|
||||||
lookX * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
|
|
||||||
euler.current.x +=
|
|
||||||
lookY * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
|
|
||||||
euler.current.x = Math.max(
|
|
||||||
-MAX_PITCH,
|
|
||||||
Math.min(MAX_PITCH, euler.current.x),
|
|
||||||
);
|
|
||||||
camera.quaternion.setFromEuler(euler.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue