mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-12 23:14:58 +00:00
fix typecheck script, animations
This commit is contained in:
parent
642fce9c06
commit
ceb9fea9f4
120 changed files with 1308 additions and 911 deletions
|
|
@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
|||
import { IoCaretForward } from "react-icons/io5";
|
||||
import styles from "./Accordion.module.css";
|
||||
|
||||
export function AccordionGroup(props) {
|
||||
export function AccordionGroup(props: RadixAccordion.AccordionMultipleProps) {
|
||||
return <RadixAccordion.Root className={styles.AccordionGroup} {...props} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function resolveAudioProfile(
|
|||
? rawFilename
|
||||
: `${rawFilename}.wav`;
|
||||
|
||||
const descId = profileBlock.description as number | null;
|
||||
const descId = profileBlock!.description as number | null;
|
||||
const descBlock = descId != null ? getDb(descId) : undefined;
|
||||
const is3D = (descBlock?.is3D as boolean) ?? true;
|
||||
const isLooping = (descBlock?.isLooping as boolean) ?? false;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
|
|||
const camera = useThree((state) => state.camera);
|
||||
const [cameraIndex, setCameraIndex] = useState(-1);
|
||||
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
|
||||
const [initialViewState, setInitialViewState] = useState(() => ({
|
||||
const [initialViewState, setInitialViewState] = useState<{
|
||||
initialized: boolean;
|
||||
position: Vector3 | null;
|
||||
quarternion: Quaternion | null;
|
||||
}>(() => ({
|
||||
initialized: false,
|
||||
position: null,
|
||||
quarternion: null,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@
|
|||
scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 699px) {
|
||||
.ChatWindow {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.ChatMessage {
|
||||
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
|
||||
color: rgb(44, 172, 181);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function CopyCoordinatesButton({
|
|||
}: {
|
||||
cameraRef: RefObject<Camera | null>;
|
||||
missionName: string;
|
||||
missionType: string;
|
||||
missionType?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { fogEnabled } = useSettings();
|
||||
|
|
@ -34,7 +34,7 @@ export function CopyCoordinatesButton({
|
|||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
const camera = cameraRef.current;
|
||||
if (!camera) return;
|
||||
const hash = encodeViewHash(camera);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ function ForceFieldMesh({
|
|||
* Used by the unified EntityRenderer — does NOT read from TorqueObject/datablock.
|
||||
*/
|
||||
export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) {
|
||||
const data = entity.forceFieldData;
|
||||
const data = entity.forceFieldData!;
|
||||
const scale = data.dimensions;
|
||||
|
||||
const textureUrls = useMemo(
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ const IflTexture = memo(function IflTexture({
|
|||
const iflPath = `textures/${resourcePath}.ifl`;
|
||||
|
||||
const texture = useIflTexture(iflPath);
|
||||
const isOrganic = shapeName && isOrganicShape(shapeName);
|
||||
const isOrganic = !!(shapeName && isOrganicShape(shapeName));
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() =>
|
||||
|
|
@ -316,7 +316,7 @@ const StaticTexture = memo(function StaticTexture({
|
|||
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
|
||||
}, [resourcePath, shapeName]);
|
||||
|
||||
const isOrganic = shapeName && isOrganicShape(shapeName);
|
||||
const isOrganic = !!(shapeName && isOrganicShape(shapeName));
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
const anisotropy = useAnisotropy();
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function parseMarkup(input: string) {
|
|||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
topEl.children.push(token.value);
|
||||
topEl.children!.push(token.value);
|
||||
break;
|
||||
case "tag":
|
||||
switch (token.name) {
|
||||
|
|
@ -96,16 +96,16 @@ export function parseMarkup(input: string) {
|
|||
style: {},
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(span);
|
||||
topEl.children!.push(span);
|
||||
topEl = span;
|
||||
stack.push(topEl);
|
||||
break;
|
||||
}
|
||||
case "spop": {
|
||||
if (topEl.source !== "root") {
|
||||
let lastPop = stack.pop();
|
||||
let lastPop = stack.pop()!;
|
||||
while (lastPop.source !== "spush") {
|
||||
lastPop = stack.pop();
|
||||
lastPop = stack.pop()!;
|
||||
}
|
||||
topEl = stack[stack.length - 1];
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ export function parseMarkup(input: string) {
|
|||
case "font": {
|
||||
const fontSize = parseFontArgs(token.args).fontSize;
|
||||
if (!isUsed(topEl)) {
|
||||
topEl.style.fontSize = fontSize;
|
||||
topEl.style!.fontSize = fontSize;
|
||||
} else {
|
||||
const fontNode: Node = {
|
||||
type: "span",
|
||||
|
|
@ -139,7 +139,7 @@ export function parseMarkup(input: string) {
|
|||
style: { fontSize },
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(fontNode);
|
||||
topEl.children!.push(fontNode);
|
||||
topEl = fontNode;
|
||||
stack.push(topEl);
|
||||
}
|
||||
|
|
@ -147,7 +147,7 @@ export function parseMarkup(input: string) {
|
|||
}
|
||||
case "color":
|
||||
if (!isUsed(topEl)) {
|
||||
topEl.style.color = `#${token.args[0].trim()}`;
|
||||
topEl.style!.color = `#${token.args[0].trim()}`;
|
||||
} else {
|
||||
const colorNode: Node = {
|
||||
type: "span",
|
||||
|
|
@ -155,7 +155,7 @@ export function parseMarkup(input: string) {
|
|||
style: { color: `#${token.args[0].trim()}` },
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(colorNode);
|
||||
topEl.children!.push(colorNode);
|
||||
topEl = colorNode;
|
||||
stack.push(topEl);
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ export function parseMarkup(input: string) {
|
|||
type: "bitmap",
|
||||
value: token.args[0],
|
||||
};
|
||||
topEl.children.push(bitmap);
|
||||
topEl.children!.push(bitmap);
|
||||
break;
|
||||
}
|
||||
case "a": {
|
||||
|
|
@ -179,15 +179,15 @@ export function parseMarkup(input: string) {
|
|||
style: {},
|
||||
children: [],
|
||||
};
|
||||
topEl.children.push(link);
|
||||
topEl.children!.push(link);
|
||||
topEl = link;
|
||||
stack.push(topEl);
|
||||
break;
|
||||
}
|
||||
case "/a": {
|
||||
let lastPop = stack.pop();
|
||||
let lastPop = stack.pop()!;
|
||||
while (lastPop.source !== "a") {
|
||||
lastPop = stack.pop();
|
||||
lastPop = stack.pop()!;
|
||||
}
|
||||
topEl = stack[stack.length - 1];
|
||||
break;
|
||||
|
|
@ -204,9 +204,9 @@ function nodeToJsx(node: Node): React.ReactNode {
|
|||
return React.createElement(
|
||||
"span",
|
||||
{
|
||||
style: Object.keys(node.style).length === 0 ? undefined : node.style,
|
||||
style: Object.keys(node.style!).length === 0 ? undefined : node.style,
|
||||
},
|
||||
...node.children.map((child) =>
|
||||
...node.children!.map((child) =>
|
||||
typeof child === "string" ? child : nodeToJsx(child),
|
||||
),
|
||||
);
|
||||
|
|
@ -215,11 +215,11 @@ function nodeToJsx(node: Node): React.ReactNode {
|
|||
"a",
|
||||
{
|
||||
href: node.value,
|
||||
style: Object.keys(node.style).length === 0 ? undefined : node.style,
|
||||
style: Object.keys(node.style!).length === 0 ? undefined : node.style,
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
},
|
||||
...node.children.map((child) =>
|
||||
...node.children!.map((child) =>
|
||||
typeof child === "string" ? child : nodeToJsx(child),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,15 +35,15 @@ export const InspectorControls = memo(function InspectorControls({
|
|||
invalidateRef,
|
||||
}: {
|
||||
missionName: string;
|
||||
missionType: string;
|
||||
missionType?: string;
|
||||
onOpenMapInfo: () => void;
|
||||
onOpenScoreScreen?: () => void;
|
||||
onOpenServerBrowser?: () => void;
|
||||
onChooseMap?: () => void;
|
||||
onCancelChoosingMap?: () => void;
|
||||
choosingMap?: boolean;
|
||||
cameraRef: RefObject<Camera>;
|
||||
invalidateRef: RefObject<() => void>;
|
||||
cameraRef: RefObject<Camera | null>;
|
||||
invalidateRef: RefObject<(() => void) | null>;
|
||||
}) {
|
||||
const isTouch = useTouchDevice();
|
||||
const dataSource = useDataSource();
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ export const InteriorInstance = memo(function InteriorInstance({
|
|||
/>
|
||||
}
|
||||
onError={(error) => {
|
||||
log.error("Failed to load %s: %s", scene.interiorFile, error.message);
|
||||
log.error("Failed to load %s: %s", scene.interiorFile, (error as Error).message);
|
||||
}}
|
||||
>
|
||||
<DebugSuspense
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { BsFillLightningChargeFill } from "react-icons/bs";
|
||||
import { useLiveSelector, selectPing } from "../state/liveConnectionStore";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import styles from "./JoinServerButton.module.css";
|
||||
|
||||
function formatPing(ms: number): string {
|
||||
return `${ms.toLocaleString()} ms`;
|
||||
}
|
||||
|
||||
export function JoinServerButton({
|
||||
isActive,
|
||||
onOpenServerBrowser,
|
||||
|
|
@ -14,8 +10,6 @@ export function JoinServerButton({
|
|||
onOpenServerBrowser: () => void;
|
||||
}) {
|
||||
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
||||
// const serverName = useLiveSelector((s) => s.serverName);
|
||||
const ping = useLiveSelector(selectPing);
|
||||
const disconnectServer = useLiveSelector((s) => s.disconnectServer);
|
||||
|
||||
const isLive = gameStatus === "connected";
|
||||
|
|
@ -45,16 +39,9 @@ export function JoinServerButton({
|
|||
<>
|
||||
<span className={styles.TextLabel}>Live</span>
|
||||
<span className={styles.ButtonHint}>
|
||||
{isConnecting
|
||||
? "Connecting…"
|
||||
: ping != null
|
||||
? formatPing(ping)
|
||||
: "Join a game"}
|
||||
{isConnecting ? "Connecting…" : isLive ? "Connected" : "Join a game"}
|
||||
</span>
|
||||
</>
|
||||
{/* {isLive && ping != null && (
|
||||
<span className={styles.PingLabel}>{formatPing(ping)}</span>
|
||||
)} */}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useKeyboardControls } from "@react-three/drei";
|
||||
import { Controls } from "./MouseAndKeyboardHandler";
|
||||
import { type ControlName } from "./MouseAndKeyboardHandler";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import styles from "./KeyboardOverlay.module.css";
|
||||
|
|
@ -7,16 +7,16 @@ import styles from "./KeyboardOverlay.module.css";
|
|||
export function KeyboardOverlay() {
|
||||
const recording = useRecording();
|
||||
const liveReady = useLiveSelector((s) => s.liveReady);
|
||||
const forward = useKeyboardControls<Controls>((s) => s.forward);
|
||||
const backward = useKeyboardControls<Controls>((s) => s.backward);
|
||||
const left = useKeyboardControls<Controls>((s) => s.left);
|
||||
const right = useKeyboardControls<Controls>((s) => s.right);
|
||||
const up = useKeyboardControls<Controls>((s) => s.up);
|
||||
const down = useKeyboardControls<Controls>((s) => s.down);
|
||||
const lookUp = useKeyboardControls<Controls>((s) => s.lookUp);
|
||||
const lookDown = useKeyboardControls<Controls>((s) => s.lookDown);
|
||||
const lookLeft = useKeyboardControls<Controls>((s) => s.lookLeft);
|
||||
const lookRight = useKeyboardControls<Controls>((s) => s.lookRight);
|
||||
const forward = useKeyboardControls<ControlName>((s) => s.forward);
|
||||
const backward = useKeyboardControls<ControlName>((s) => s.backward);
|
||||
const left = useKeyboardControls<ControlName>((s) => s.left);
|
||||
const right = useKeyboardControls<ControlName>((s) => s.right);
|
||||
const up = useKeyboardControls<ControlName>((s) => s.up);
|
||||
const down = useKeyboardControls<ControlName>((s) => s.down);
|
||||
const lookUp = useKeyboardControls<ControlName>((s) => s.lookUp);
|
||||
const lookDown = useKeyboardControls<ControlName>((s) => s.lookDown);
|
||||
const lookLeft = useKeyboardControls<ControlName>((s) => s.lookLeft);
|
||||
const lookRight = useKeyboardControls<ControlName>((s) => s.lookRight);
|
||||
|
||||
// Show when no recording (map browsing) or during live mode once ready.
|
||||
// Hidden during demo playback and during live map transitions.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
}
|
||||
|
||||
.Left {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
|
@ -142,6 +144,8 @@
|
|||
}
|
||||
|
||||
.Footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
|
@ -167,7 +171,9 @@
|
|||
|
||||
@media (max-width: 719px) {
|
||||
.Body {
|
||||
display: block;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: "body";
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
|
@ -176,17 +182,24 @@
|
|||
}
|
||||
|
||||
.Left {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
grid-area: body;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.PreviewImage {
|
||||
width: auto;
|
||||
position: relative;
|
||||
grid-area: body;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 16px auto;
|
||||
border: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.CancelButton:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
@media (hover: hover) {
|
||||
.CancelButton:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.Sidebar {
|
||||
|
|
@ -102,6 +104,10 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ToggleSidebarButton:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ToggleSidebarButton[data-orientation="top"] {
|
||||
display: none;
|
||||
min-height: 48px;
|
||||
|
|
@ -109,8 +115,10 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.ToggleSidebarButton:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
@media (hover: hover) {
|
||||
.ToggleSidebarButton:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ToggleSidebarButton svg {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
startTransition,
|
||||
Suspense,
|
||||
useRef,
|
||||
lazy,
|
||||
|
|
@ -224,7 +223,7 @@ export function MapInspector() {
|
|||
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
onClick={(event) => {
|
||||
startTransition(() => setSidebarOpen((open) => !open));
|
||||
setSidebarOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
|
||||
|
|
@ -236,7 +235,7 @@ export function MapInspector() {
|
|||
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
onClick={(event) => {
|
||||
startTransition(() => setSidebarOpen((open) => !open));
|
||||
setSidebarOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
|
||||
|
|
@ -247,7 +246,7 @@ export function MapInspector() {
|
|||
<Activity mode={!hasStreamData || choosingMap ? "visible" : "hidden"}>
|
||||
<MissionSelect
|
||||
value={choosingMap ? "" : missionName}
|
||||
missionType={choosingMap ? "" : missionType}
|
||||
missionType={choosingMap ? "" : missionType ?? ""}
|
||||
onChange={changeMission}
|
||||
autoFocus={choosingMap}
|
||||
onCancel={handleCancelChoosingMap}
|
||||
|
|
@ -267,32 +266,30 @@ export function MapInspector() {
|
|||
</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}
|
||||
choosingMap={choosingMap}
|
||||
cameraRef={cameraRef}
|
||||
invalidateRef={invalidateRef}
|
||||
onOpenMapInfo={handleOpenMapInfo}
|
||||
onOpenScoreScreen={
|
||||
hasStreamData ? handleOpenScoreScreen : undefined
|
||||
}
|
||||
onOpenServerBrowser={
|
||||
features.live ? handleOpenServerBrowser : undefined
|
||||
}
|
||||
onChooseMap={handleChooseMap}
|
||||
onCancelChoosingMap={handleCancelChoosingMap}
|
||||
/>
|
||||
</div>
|
||||
</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}
|
||||
choosingMap={choosingMap}
|
||||
cameraRef={cameraRef}
|
||||
invalidateRef={invalidateRef}
|
||||
onOpenMapInfo={handleOpenMapInfo}
|
||||
onOpenScoreScreen={
|
||||
hasStreamData ? handleOpenScoreScreen : undefined
|
||||
}
|
||||
onOpenServerBrowser={
|
||||
features.live ? handleOpenServerBrowser : undefined
|
||||
}
|
||||
onChooseMap={handleChooseMap}
|
||||
onCancelChoosingMap={handleCancelChoosingMap}
|
||||
/>
|
||||
</div>
|
||||
</Activity>
|
||||
<InputProvider>
|
||||
<div className={styles.Content}>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export const Mission = memo(function Mission({
|
|||
|
||||
const missionContext = useMemo(
|
||||
() => ({
|
||||
metadata: parsedMission,
|
||||
metadata: parsedMission!,
|
||||
missionType,
|
||||
}),
|
||||
[parsedMission, missionType],
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const dirGroupNames: Record<string, string> = {
|
|||
interface MissionItem {
|
||||
resourcePath: string;
|
||||
missionName: string;
|
||||
displayName: string;
|
||||
displayName: string | null;
|
||||
sourcePath: string;
|
||||
groupName: string | null;
|
||||
missionTypes: string[];
|
||||
|
|
@ -244,7 +244,7 @@ export function MissionSelect({
|
|||
? filteredResults.missions.length === 0
|
||||
: filteredResults.groups.length === 0;
|
||||
|
||||
const renderItem = (mission) => {
|
||||
const renderItem = (mission: MissionItem) => {
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={mission.missionName}
|
||||
|
|
|
|||
|
|
@ -11,27 +11,29 @@ import { useCameras } from "./CamerasProvider";
|
|||
import { useInputContext } from "./InputContext";
|
||||
import { useTouchDevice } from "./useTouchDevice";
|
||||
|
||||
export enum Controls {
|
||||
forward = "forward",
|
||||
backward = "backward",
|
||||
left = "left",
|
||||
right = "right",
|
||||
up = "up",
|
||||
down = "down",
|
||||
lookUp = "lookUp",
|
||||
lookDown = "lookDown",
|
||||
lookLeft = "lookLeft",
|
||||
lookRight = "lookRight",
|
||||
camera1 = "camera1",
|
||||
camera2 = "camera2",
|
||||
camera3 = "camera3",
|
||||
camera4 = "camera4",
|
||||
camera5 = "camera5",
|
||||
camera6 = "camera6",
|
||||
camera7 = "camera7",
|
||||
camera8 = "camera8",
|
||||
camera9 = "camera9",
|
||||
}
|
||||
export const Controls = {
|
||||
forward: "forward",
|
||||
backward: "backward",
|
||||
left: "left",
|
||||
right: "right",
|
||||
up: "up",
|
||||
down: "down",
|
||||
lookUp: "lookUp",
|
||||
lookDown: "lookDown",
|
||||
lookLeft: "lookLeft",
|
||||
lookRight: "lookRight",
|
||||
camera1: "camera1",
|
||||
camera2: "camera2",
|
||||
camera3: "camera3",
|
||||
camera4: "camera4",
|
||||
camera5: "camera5",
|
||||
camera6: "camera6",
|
||||
camera7: "camera7",
|
||||
camera8: "camera8",
|
||||
camera9: "camera9",
|
||||
} as const;
|
||||
|
||||
export type ControlName = (typeof Controls)[keyof typeof Controls];
|
||||
|
||||
export const KEYBOARD_CONTROLS = [
|
||||
{ name: Controls.forward, keys: ["KeyW"] },
|
||||
|
|
@ -103,7 +105,7 @@ export function MouseAndKeyboardHandler() {
|
|||
invertDrag,
|
||||
} = useControls();
|
||||
const { onInput, mode } = useInputContext();
|
||||
const [subscribe, getKeys] = useKeyboardControls<Controls>();
|
||||
const [subscribe, getKeys] = useKeyboardControls<ControlName>();
|
||||
const camera = useThree((state) => state.camera);
|
||||
const gl = useThree((state) => state.gl);
|
||||
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
|
||||
|
|
|
|||
|
|
@ -8,17 +8,10 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.TopRight {
|
||||
.Compass {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.Compass {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -56,14 +49,19 @@
|
|||
}
|
||||
|
||||
.Bars {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 82px;
|
||||
width: 120px;
|
||||
max-width: 20%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.BarTrack {
|
||||
width: 120px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
|
@ -94,6 +92,16 @@
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.WeaponHUD .PackInvItem[data-active="false"] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-height: 699px) {
|
||||
.WeaponHUD .PackInvItem[data-active="false"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.WeaponSeparator {
|
||||
height: 6px;
|
||||
}
|
||||
|
|
@ -109,6 +117,12 @@
|
|||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@media (max-width: 499px), (pointer: coarse) {
|
||||
.TeamScores {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ObserverCount {
|
||||
position: absolute;
|
||||
bottom: calc(100%);
|
||||
|
|
@ -171,6 +185,12 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 499px), (pointer: coarse) {
|
||||
.PackInventoryHUD {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.PackInvItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -182,13 +202,9 @@
|
|||
gap: 1px;
|
||||
}
|
||||
|
||||
.PackInvItemActive {
|
||||
border-color: rgba(128, 255, 200, 0.5);
|
||||
box-shadow: 0 0 6px rgba(128, 255, 200, 0.3);
|
||||
}
|
||||
|
||||
.PackInvItemDim {
|
||||
opacity: 0.5;
|
||||
.PackInvItem[data-active="true"] {
|
||||
border-color: rgba(128, 255, 200, 0.6);
|
||||
box-shadow: 0 0 8px rgba(128, 255, 200, 0.4);
|
||||
}
|
||||
|
||||
.PackInvIcon {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { textureToUrl } from "../loaders";
|
|||
import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
|
||||
import styles from "./PlayerHUD.module.css";
|
||||
import { ChatWindow } from "./ChatWindow";
|
||||
import { LuUsers } from "react-icons/lu";
|
||||
|
||||
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
|
||||
const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
|
||||
|
|
@ -160,9 +159,7 @@ function WeaponSlotIcon({
|
|||
if (!info) return null;
|
||||
const isInfinite = slot.ammo < 0;
|
||||
return (
|
||||
<div
|
||||
className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`}
|
||||
>
|
||||
<div className={styles.PackInvItem} data-active={isSelected}>
|
||||
<img
|
||||
src={WEAPON_HUD_ICON_URLS.get(slot.index)!}
|
||||
alt={info.label}
|
||||
|
|
@ -259,7 +256,9 @@ function TeamScores() {
|
|||
)}
|
||||
{sorted.map((team: TeamScore) => {
|
||||
const isFriendly =
|
||||
playerSensorGroup > 0 && team.teamId === playerSensorGroup;
|
||||
playerSensorGroup != null &&
|
||||
playerSensorGroup > 0 &&
|
||||
team.teamId === playerSensorGroup;
|
||||
const name =
|
||||
team.name ||
|
||||
(DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`);
|
||||
|
|
@ -376,7 +375,8 @@ function PackAndInventoryHUD() {
|
|||
<div className={styles.PackInventoryHUD}>
|
||||
{packIconUrl && (
|
||||
<div
|
||||
className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`}
|
||||
className={styles.PackInvItem}
|
||||
data-active={backpackHud!.active ?? false}
|
||||
>
|
||||
<img src={packIconUrl} alt="" className={styles.PackInvIcon} />
|
||||
<span className={styles.PackInvCount}>
|
||||
|
|
@ -409,18 +409,17 @@ export function PlayerHUD() {
|
|||
const hasControlPlayer = useEngineSelector(
|
||||
(state) => !!state.playback.streamSnapshot?.controlPlayerGhostId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<div className={styles.TopRight}>
|
||||
{hasControlPlayer && (
|
||||
<div className={styles.Bars}>
|
||||
<HealthBar />
|
||||
<EnergyBar />
|
||||
</div>
|
||||
)}
|
||||
<Compass />
|
||||
</div>
|
||||
{hasControlPlayer && (
|
||||
<div className={styles.Bars}>
|
||||
<HealthBar />
|
||||
<EnergyBar />
|
||||
</div>
|
||||
)}
|
||||
<Compass />
|
||||
{hasControlPlayer && (
|
||||
<>
|
||||
<WeaponHUD />
|
||||
|
|
|
|||
|
|
@ -87,11 +87,16 @@ interface ActionAnimEntry {
|
|||
*
|
||||
* The engine builds its action list as:
|
||||
* 1. Table actions (0-7): found by searching for aliased names (root, run, etc.)
|
||||
* 2. Non-table actions (8+): remaining sequences in TSShapeConstructor order.
|
||||
* 2. Non-table actions (8+): ALL remaining shape sequences in order.
|
||||
*
|
||||
* The shape's sequence array contains DTS-embedded sequences (e.g. JetFlare,
|
||||
* Damage) BEFORE the TSShapeConstructor-loaded ones. These occupy non-table
|
||||
* action slots and shift all TSShapeConstructor non-table indices up.
|
||||
*/
|
||||
function buildActionAnimMap(
|
||||
sequences: string[],
|
||||
shapePrefix: string,
|
||||
embeddedNonTableCount: number = 0,
|
||||
): Map<number, ActionAnimEntry> {
|
||||
const result = new Map<number, ActionAnimEntry>();
|
||||
|
||||
|
|
@ -124,8 +129,9 @@ function buildActionAnimMap(
|
|||
}
|
||||
}
|
||||
|
||||
// Non-table actions: remaining entries in TSShapeConstructor order.
|
||||
let actionIdx = NUM_TABLE_ACTION_ANIMS;
|
||||
// Non-table actions: remaining entries in TSShapeConstructor order, offset
|
||||
// by embedded non-table sequences that precede them in the shape.
|
||||
let actionIdx = NUM_TABLE_ACTION_ANIMS + embeddedNonTableCount;
|
||||
for (let pi = 0; pi < parsed.length; pi++) {
|
||||
if (!tableEntryIndices.has(pi)) {
|
||||
result.set(actionIdx, parsed[pi]);
|
||||
|
|
@ -136,6 +142,52 @@ function buildActionAnimMap(
|
|||
return result;
|
||||
}
|
||||
|
||||
const TABLE_ACTION_NAME_SET = new Set(TABLE_ACTION_NAMES);
|
||||
|
||||
/**
|
||||
* Count DTS-embedded sequences that occupy non-table action slots. The engine's
|
||||
* shape sequence array starts with embedded sequences (e.g. JetFlare, Damage)
|
||||
* before TSShapeConstructor sequences. We detect them by comparing the GLB's
|
||||
* `dts_sequence_names` metadata with TSShapeConstructor-derived clip names.
|
||||
*/
|
||||
function countEmbeddedNonTableSequences(
|
||||
scene: Group,
|
||||
tscSequences: string[],
|
||||
shapePrefix: string,
|
||||
): number {
|
||||
const raw = scene.userData?.dts_sequence_names;
|
||||
if (typeof raw !== "string") return 0;
|
||||
let dtsNames: string[];
|
||||
try {
|
||||
dtsNames = JSON.parse(raw);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
if (!Array.isArray(dtsNames) || dtsNames.length === 0) return 0;
|
||||
|
||||
// Build set of clip names derived from TSShapeConstructor DSQ entries.
|
||||
const tscClipNames = new Set<string>();
|
||||
for (const entry of tscSequences) {
|
||||
const spaceIdx = entry.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
|
||||
if (!dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq")) continue;
|
||||
const clipName = dsqFile.slice(shapePrefix.length, -4);
|
||||
if (clipName) tscClipNames.add(clipName);
|
||||
}
|
||||
|
||||
// Embedded sequences come first in the DTS. Count leading entries that don't
|
||||
// match any TSShapeConstructor clip name, excluding any that are table actions.
|
||||
let count = 0;
|
||||
for (const name of dtsNames) {
|
||||
if (tscClipNames.has(name.toLowerCase())) break;
|
||||
if (!TABLE_ACTION_NAME_SET.has(name.toLowerCase())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
|
||||
function stopLoopingSound(
|
||||
soundRef: React.MutableRefObject<PositionalAudio | null>,
|
||||
|
|
@ -245,8 +297,47 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
if (!sequences) return new Map<number, ActionAnimEntry>();
|
||||
// Derive prefix: "heavy_male.dts" -> "heavy_male_"
|
||||
const stem = sn.replace(/\.dts$/i, "");
|
||||
return buildActionAnimMap(sequences, stem + "_");
|
||||
}, [engineStore, shapeName]);
|
||||
const prefix = stem + "_";
|
||||
const embeddedNonTable = countEmbeddedNonTableSequences(
|
||||
gltf.scene,
|
||||
sequences,
|
||||
prefix,
|
||||
);
|
||||
return buildActionAnimMap(sequences, prefix, embeddedNonTable);
|
||||
}, [engineStore, shapeName, gltf.scene]);
|
||||
|
||||
// Build a map of animation alias → cyclic flag from DTS metadata.
|
||||
// Non-cyclic sequences (fall, jet, jump, land) play once and clamp.
|
||||
const seqCyclicByAlias = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
const rawNames = gltf.scene.userData?.dts_sequence_names;
|
||||
const rawCyclic = gltf.scene.userData?.dts_sequence_cyclic;
|
||||
if (typeof rawNames === "string" && typeof rawCyclic === "string") {
|
||||
try {
|
||||
const names: string[] = JSON.parse(rawNames);
|
||||
const cyclic: boolean[] = JSON.parse(rawCyclic);
|
||||
// Map clip names → cyclic.
|
||||
const clipCyclic = new Map<string, boolean>();
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
clipCyclic.set(names[i].toLowerCase(), cyclic[i] ?? true);
|
||||
}
|
||||
// Map aliases → cyclic via the alias→clip mapping.
|
||||
if (shapeAliases) {
|
||||
for (const [alias, clipName] of shapeAliases) {
|
||||
const c = clipCyclic.get(clipName);
|
||||
if (c != null) map.set(alias, c);
|
||||
}
|
||||
}
|
||||
// Also include raw clip names so lookups by either name work.
|
||||
for (const [name, c] of clipCyclic) {
|
||||
if (!map.has(name)) map.set(name, c);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [gltf.scene, shapeAliases]);
|
||||
|
||||
useEffect(() => {
|
||||
const actions = getAliasedActions(gltf.animations, mixer, shapeAliases);
|
||||
|
|
@ -505,6 +596,17 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
const nextAction = actions.get(anim.animation.toLowerCase());
|
||||
|
||||
if (nextAction) {
|
||||
// Set loop mode from the DTS cyclic flag. Non-cyclic sequences
|
||||
// (fall, jet, jump, land) play once and hold their end pose.
|
||||
const isCyclic = seqCyclicByAlias.get(anim.animation) ?? true;
|
||||
if (isCyclic) {
|
||||
nextAction.setLoop(LoopRepeat, Infinity);
|
||||
nextAction.clampWhenFinished = false;
|
||||
} else {
|
||||
nextAction.setLoop(LoopOnce, 1);
|
||||
nextAction.clampWhenFinished = true;
|
||||
}
|
||||
|
||||
if (isPlaying && prevAction && prevAction !== nextAction) {
|
||||
prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
||||
nextAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const IFF_ENEMY_URL = textureToUrl("gui/hud_enemytriangle");
|
|||
|
||||
const _tmpVec = new Vector3();
|
||||
|
||||
const EMPTY_KEYFRAMES = [];
|
||||
const EMPTY_KEYFRAMES: never[] = [];
|
||||
|
||||
/**
|
||||
* Floating nameplate above a player model showing the entity name and a health
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
|
||||
.TableWrapper {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
width: 100%;
|
||||
min-height: 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
@ -77,16 +79,65 @@
|
|||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(125, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Table th:hover {
|
||||
color: #7dffff;
|
||||
}
|
||||
|
||||
.Table th:nth-child(2),
|
||||
.Table td:nth-child(2),
|
||||
.Table th:nth-child(3),
|
||||
.Table td:nth-child(3) {
|
||||
.Table th[data-column="server"] {
|
||||
width: 26%;
|
||||
}
|
||||
.Table th[data-column="players"] {
|
||||
width: 11%;
|
||||
}
|
||||
.Table th[data-column="ping"] {
|
||||
width: 8%;
|
||||
}
|
||||
.Table th[data-column="map"] {
|
||||
width: 22%;
|
||||
}
|
||||
.Table th[data-column="gameType"] {
|
||||
width: 19%;
|
||||
}
|
||||
.Table th[data-column="mod"] {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
.Table th[data-column="ping"],
|
||||
.Table td[data-column="ping"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 699px) {
|
||||
.CompactHidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.Table th[data-column="mod"],
|
||||
.Table td[data-column="mod"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 499px) {
|
||||
.Table th[data-column="gameType"],
|
||||
.Table td[data-column="gameType"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Table th[data-column="players"],
|
||||
.Table td[data-column="players"],
|
||||
.Table th[data-column="ping"],
|
||||
.Table td[data-column="ping"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +149,6 @@
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 340px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -199,10 +249,6 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.Table td {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,12 +102,30 @@ export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
|||
<table className={styles.Table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th onClick={() => handleSort("name")}>Server Name</th>
|
||||
<th onClick={() => handleSort("playerCount")}>Players</th>
|
||||
<th onClick={() => handleSort("ping")}>Ping</th>
|
||||
<th onClick={() => handleSort("mapName")}>Map</th>
|
||||
<th onClick={() => handleSort("gameType")}>Type</th>
|
||||
<th onClick={() => handleSort("mod")}>Mod</th>
|
||||
<th data-column="server" onClick={() => handleSort("name")}>
|
||||
Server Name
|
||||
</th>
|
||||
<th
|
||||
data-column="players"
|
||||
onClick={() => handleSort("playerCount")}
|
||||
>
|
||||
Players
|
||||
</th>
|
||||
<th data-column="ping" onClick={() => handleSort("ping")}>
|
||||
Ping
|
||||
</th>
|
||||
<th data-column="map" onClick={() => handleSort("mapName")}>
|
||||
Map
|
||||
</th>
|
||||
<th
|
||||
data-column="gameType"
|
||||
onClick={() => handleSort("gameType")}
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th data-column="mod" onClick={() => handleSort("mod")}>
|
||||
Mod
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -116,13 +134,12 @@ export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
|||
key={server.address}
|
||||
onClick={() => {
|
||||
setSelectedAddress(server.address);
|
||||
const form = document.forms["serverList"];
|
||||
const inputs: RadioNodeList =
|
||||
form.elements["serverAddress"];
|
||||
const form = document.forms.namedItem("serverList")!;
|
||||
const inputs = form.elements.namedItem("serverAddress") as RadioNodeList;
|
||||
const input = Array.from(inputs).find(
|
||||
(input) => input.value === server.address,
|
||||
);
|
||||
input.focus();
|
||||
input!.focus();
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
setSelectedAddress(server.address);
|
||||
|
|
@ -130,7 +147,7 @@ export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
|||
onClose();
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<td data-column="server">
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.HiddenRadio}
|
||||
|
|
@ -152,17 +169,21 @@ export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
|||
? styles.EmptyServer
|
||||
: undefined
|
||||
}
|
||||
data-column="players"
|
||||
>
|
||||
{server.playerCount} / {server.maxPlayers}
|
||||
{server.playerCount}
|
||||
<span className={styles.CompactHidden}>
|
||||
 / {server.maxPlayers}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<td data-column="ping">
|
||||
{browserToRelayPing != null
|
||||
? (server.ping + browserToRelayPing).toLocaleString()
|
||||
: "\u2014"}
|
||||
</td>
|
||||
<td>{server.mapName}</td>
|
||||
<td>{server.gameType}</td>
|
||||
<td>{server.mod}</td>
|
||||
<td data-column="map">{server.mapName}</td>
|
||||
<td data-column="gameType">{server.gameType}</td>
|
||||
<td data-column="mod">{server.mod}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted.length === 0 && !serversLoading && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
createContext,
|
||||
type Dispatch,
|
||||
ReactNode,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
|
|
@ -18,7 +20,7 @@ export const DEFAULT_MOUSE_SENSITIVITY = 32 / 16000; // 0.002
|
|||
export const MIN_MOUSE_SENSITIVITY = 1 / 16000;
|
||||
export const MAX_MOUSE_SENSITIVITY = 256 / 16000;
|
||||
|
||||
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
|
||||
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
export type TouchMode = "dualStick" | "moveLookStick";
|
||||
|
||||
|
|
@ -230,7 +232,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
let savedSettings: PersistedSettings = {};
|
||||
try {
|
||||
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
|
||||
savedSettings = JSON.parse(localStorage.getItem("settings") ?? "{}") || {};
|
||||
} catch (err) {
|
||||
// Ignore.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ function getArmThread(weaponShape: string | undefined): string {
|
|||
export function WeaponModel({ entity }: { entity: ShapeEntity }) {
|
||||
const shapeName = entity.weaponShape;
|
||||
const playerShapeName = entity.shapeName;
|
||||
const playerGltf = useStaticShape(playerShapeName);
|
||||
const weaponGltf = useStaticShape(shapeName);
|
||||
const playerGltf = useStaticShape(playerShapeName!);
|
||||
const weaponGltf = useStaticShape(shapeName!);
|
||||
|
||||
const mountTransform = useMemo(() => {
|
||||
// Get Mount0 from the player's posed skeleton with arm animation applied.
|
||||
|
|
@ -115,7 +115,7 @@ export function WeaponModel({ entity }: { entity: ShapeEntity }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<ShapeInfoProvider object={torqueObject} shapeName={shapeName} type="Item">
|
||||
<ShapeInfoProvider object={torqueObject} shapeName={shapeName!} type="Item">
|
||||
<group
|
||||
position={mountTransform.position}
|
||||
quaternion={mountTransform.quaternion}
|
||||
|
|
@ -212,7 +212,7 @@ function interpolateSize(
|
|||
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
||||
*/
|
||||
export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
|
||||
const playback = streamPlaybackStore.getState().playback;
|
||||
const playback = streamPlaybackStore.getState().playback!;
|
||||
const gltf = useStaticShape(entity.shapeName!);
|
||||
const anisotropy = useAnisotropy();
|
||||
const groupRef = useRef<Group>(null);
|
||||
|
|
|
|||
|
|
@ -143,13 +143,13 @@ function SkyBoxTexture({
|
|||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0])}
|
||||
args={[new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3]}
|
||||
count={3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-uv"
|
||||
array={new Float32Array([0, 0, 2, 0, 0, 2])}
|
||||
args={[new Float32Array([0, 0, 2, 0, 0, 2]), 2]}
|
||||
count={3}
|
||||
itemSize={2}
|
||||
/>
|
||||
|
|
@ -299,12 +299,12 @@ export function SkyBox({
|
|||
() =>
|
||||
detailMapList
|
||||
? [
|
||||
textureToUrl(detailMapList[1]), // +x
|
||||
textureToUrl(detailMapList[3]), // -x
|
||||
textureToUrl(detailMapList[4]), // +y
|
||||
textureToUrl(detailMapList[5]), // -y
|
||||
textureToUrl(detailMapList[0]), // +z
|
||||
textureToUrl(detailMapList[2]), // -z
|
||||
textureToUrl(detailMapList[1]!), // +x
|
||||
textureToUrl(detailMapList[3]!), // -x
|
||||
textureToUrl(detailMapList[4]!), // +y
|
||||
textureToUrl(detailMapList[5]!), // -y
|
||||
textureToUrl(detailMapList[0]!), // +z
|
||||
textureToUrl(detailMapList[2]!), // -z
|
||||
]
|
||||
: null,
|
||||
[detailMapList],
|
||||
|
|
@ -388,13 +388,13 @@ function SolidColorSky({
|
|||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0])}
|
||||
args={[new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3]}
|
||||
count={3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-uv"
|
||||
array={new Float32Array([0, 0, 2, 0, 0, 2])}
|
||||
args={[new Float32Array([0, 0, 2, 0, 0, 2]), 2]}
|
||||
count={3}
|
||||
itemSize={2}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
StreamRecording,
|
||||
StreamEntity,
|
||||
StreamSnapshot,
|
||||
StreamingPlayback,
|
||||
} from "../stream/types";
|
||||
import type { GameEntity } from "../state/gameEntityTypes";
|
||||
import { isSceneEntity } from "../state/gameEntityTypes";
|
||||
|
|
@ -108,7 +109,7 @@ export function StreamingController({
|
|||
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
|
||||
const streamRef = useRef(recording.streamingPlayback ?? null);
|
||||
const streamRef = useRef<StreamingPlayback | null>(recording.streamingPlayback ?? null);
|
||||
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
|
||||
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
|
|
@ -160,17 +161,17 @@ export function StreamingController({
|
|||
// Mutate render fields in-place on the existing entity object.
|
||||
// Components read these imperatively in useFrame — no React
|
||||
// re-render needed. This avoids store churn that starves Suspense.
|
||||
mutateRenderFields(renderEntity, entity);
|
||||
mutateRenderFields(renderEntity!, entity);
|
||||
}
|
||||
|
||||
nextMap.set(entity.id, renderEntity);
|
||||
nextMap.set(entity.id, renderEntity!);
|
||||
|
||||
// Keyframe update (mutable -- used for fallback position for
|
||||
// retained explosion entities and per-frame reads in useFrame).
|
||||
// Scene entities and None don't have keyframes.
|
||||
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None")
|
||||
if (isSceneEntity(renderEntity!) || renderEntity!.renderType === "None")
|
||||
continue;
|
||||
const keyframes = renderEntity.keyframes!;
|
||||
const keyframes = renderEntity!.keyframes!;
|
||||
if (keyframes.length === 0) {
|
||||
keyframes.push({
|
||||
time: snapshot.timeSec,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.MissionInfo {
|
||||
|
|
@ -37,8 +39,8 @@
|
|||
font-size: 12px;
|
||||
line-height: calc(14 / 12);
|
||||
text-align: right;
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 8px 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.Attribution,
|
||||
|
|
@ -87,3 +89,47 @@
|
|||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.Pulsing {
|
||||
animation: blink 1.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.ConnectionPing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 5px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.PingDot {
|
||||
composes: Pulsing;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.ConnectionPing[data-quality="good"] .PingDot {
|
||||
color: rgb(116, 255, 69);
|
||||
}
|
||||
|
||||
.ConnectionPing[data-quality="fine"] .PingDot {
|
||||
color: rgb(255, 158, 47);
|
||||
}
|
||||
|
||||
.ConnectionPing[data-quality="poor"] .PingDot {
|
||||
color: rgb(232, 63, 37);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import {
|
|||
import { engineStore } from "../state/engineStore";
|
||||
import {
|
||||
liveConnectionStore,
|
||||
selectPing,
|
||||
useLiveSelector,
|
||||
} from "../state/liveConnectionStore";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { LuCircleArrowOutUpLeft } from "react-icons/lu";
|
||||
import { BiSolidEject } from "react-icons/bi";
|
||||
import { formatPing } from "../stringUtils";
|
||||
import styles from "./StreamingMissionInfo.module.css";
|
||||
|
||||
export function StreamingMissionInfo() {
|
||||
|
|
@ -34,6 +36,7 @@ export function StreamingMissionInfo() {
|
|||
const isLiveConnected = useLiveSelector(
|
||||
(s) => s.gameStatus === "connected" || s.gameStatus === "authenticating",
|
||||
);
|
||||
const ping = useLiveSelector(selectPing);
|
||||
|
||||
const handleEject = useCallback(() => {
|
||||
engineStore.getState().setRecording(null);
|
||||
|
|
@ -65,6 +68,14 @@ export function StreamingMissionInfo() {
|
|||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{isLiveConnected && ping != null ? (
|
||||
<span
|
||||
className={styles.ConnectionPing}
|
||||
data-quality={ping < 150 ? "good" : ping < 300 ? "fine" : "poor"}
|
||||
>
|
||||
<span className={styles.PingDot} /> {formatPing(ping)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={styles.Metadata}>
|
||||
{isLive ? (
|
||||
isLiveConnected ? (
|
||||
|
|
@ -82,7 +93,7 @@ export function StreamingMissionInfo() {
|
|||
Recorded by <span className={styles.PlayerName}>{playerName}</span>{" "}
|
||||
on{" "}
|
||||
<span className={styles.RecordingDate}>
|
||||
{datePart.replace(/-/g, " ")}
|
||||
{datePart!.replace(/-/g, " ")}
|
||||
</span>{" "}
|
||||
at <span className={styles.RecordingDate}>{timePart}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
visibilityMask={primaryVisibilityMask}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap}
|
||||
lightmap={terrainLightmap ?? undefined}
|
||||
/>
|
||||
{/* Pooled tiles — single InstancedMesh, matrices updated in useFrame.
|
||||
All pooled tiles share the same geometry, material, and visibility mask.
|
||||
|
|
@ -675,7 +675,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
textureNames={terrain.textureNames}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap}
|
||||
lightmap={terrainLightmap ?? undefined}
|
||||
/>
|
||||
</instancedMesh>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
|
||||
import { createContext, type Dispatch, ReactNode, type SetStateAction, useContext, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Handle for querying terrain data.
|
||||
|
|
@ -34,7 +34,7 @@ export interface TerrainHandle {
|
|||
};
|
||||
}
|
||||
|
||||
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
|
||||
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
interface TerrainContextValue {
|
||||
terrain: TerrainHandle | null;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
|
|||
);
|
||||
|
||||
const onBeforeCompile = useCallback(
|
||||
(shader) => {
|
||||
(shader: any) => {
|
||||
updateTerrainTextureShader({
|
||||
shader,
|
||||
baseTextures,
|
||||
|
|
@ -105,6 +105,19 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
|
|||
],
|
||||
);
|
||||
|
||||
// Build a unique program cache key so Three.js recompiles the shader when
|
||||
// textures change between maps. Without this, onBeforeCompile may not be
|
||||
// called because the program cache hash (from toString()) is identical.
|
||||
const programCacheKey = useMemo(() => {
|
||||
const parts = [
|
||||
textureNames.join(","),
|
||||
detailTextureUrl ?? "none",
|
||||
lightmap ? lightmap.id : "nolm",
|
||||
baseTextures.map((t: any) => t.id).join(","),
|
||||
];
|
||||
return parts.join("|");
|
||||
}, [textureNames, detailTextureUrl, lightmap, baseTextures]);
|
||||
|
||||
// Ref for forcing shader recompilation
|
||||
const materialRef = useRef<MeshLambertMaterial>(null);
|
||||
|
||||
|
|
@ -121,6 +134,16 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
|
|||
}
|
||||
}, [debugMode]);
|
||||
|
||||
// Set customProgramCacheKey so Three.js distinguishes terrain shaders
|
||||
// across different maps even when the shader structure is identical.
|
||||
useEffect(() => {
|
||||
const mat = materialRef.current;
|
||||
if (mat) {
|
||||
mat.customProgramCacheKey = () => programCacheKey;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
}, [programCacheKey]);
|
||||
|
||||
// Key for shader structure changes (detail texture, lightmap)
|
||||
const materialKey = `${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
|
@ -78,11 +79,10 @@ export function useTick(callback: TickCallback) {
|
|||
if (!context) {
|
||||
throw new Error("useTick must be used within a TickProvider");
|
||||
}
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
const handleTick = useEffectEvent(callback);
|
||||
|
||||
useEffect(() => {
|
||||
return context.subscribe((tick) => callbackRef.current(tick));
|
||||
return context.subscribe(handleTick);
|
||||
}, [context]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ function applyNippleStyles(zone: HTMLElement) {
|
|||
|
||||
export function TouchJoystick() {
|
||||
const { touchMode } = useControls();
|
||||
const [moveZone, setMoveZone] = useState<HTMLDivElement>(null);
|
||||
const [lookZone, setLookZone] = useState<HTMLDivElement>(null);
|
||||
const [moveZone, setMoveZone] = useState<HTMLDivElement | null>(null);
|
||||
const [lookZone, setLookZone] = useState<HTMLDivElement | null>(null);
|
||||
const { moveState, lookState, setMoveState, setLookState } = useJoystick();
|
||||
|
||||
// Move joystick
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useWorldPosition } from "./useWorldPosition";
|
|||
|
||||
export function useDistanceFromCamera<T extends Object3D>(
|
||||
ref: RefObject<T>,
|
||||
): RefObject<number> {
|
||||
): RefObject<number | null> {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const distanceRef = useRef<number>(null);
|
||||
const worldPosRef = useWorldPosition(ref);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { useEffect, useEffectEvent } from "react";
|
||||
import { getMissionInfo, getMissionList } from "../manifest";
|
||||
import { usePlaybackActions } from "./RecordingProvider";
|
||||
import type { CurrentMission } from "./useQueryParams";
|
||||
|
||||
export function usePublicWindowAPI({ onChangeMission }) {
|
||||
declare global {
|
||||
interface Window {
|
||||
setMissionName?: (missionName: string) => void;
|
||||
getMissionList?: typeof getMissionList;
|
||||
getMissionInfo?: typeof getMissionInfo;
|
||||
loadDemoRecording?: ReturnType<typeof usePlaybackActions>["setRecording"];
|
||||
}
|
||||
}
|
||||
|
||||
export function usePublicWindowAPI({
|
||||
onChangeMission,
|
||||
}: {
|
||||
onChangeMission: (mission: CurrentMission) => void;
|
||||
}) {
|
||||
const { setRecording } = usePlaybackActions();
|
||||
const handleChangeMission = useEffectEvent(onChangeMission);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import hxDif from "@/generated/hxDif.cjs";
|
||||
|
||||
export function parseInteriorBuffer(arrayBuffer: ArrayBufferLike) {
|
||||
return hxDif.Dif.LoadFromArrayBuffer(arrayBuffer);
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ function write(o: { level: number; module?: string; msg: string }) {
|
|||
export const rootLogger = pino({
|
||||
name: "t2-mapper",
|
||||
level: "trace", // allow children to go as low as they want
|
||||
browser: { write },
|
||||
browser: { write: write as any },
|
||||
hooks: {
|
||||
logMethod(inputArgs, method) {
|
||||
// Stash the raw args so `write` can forward objects to console directly
|
||||
|
|
@ -99,8 +99,16 @@ export const rootLogger = pino({
|
|||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Logger with printf-style calls enabled. Our logMethod hook supports
|
||||
* `log.info("msg %s %o", arg1, arg2)` but pino's types don't allow it.
|
||||
*/
|
||||
export type Logger = pino.Logger & {
|
||||
[K in pino.Level]: (msg: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
/** Create a named child logger. */
|
||||
export function createLogger(name: string): pino.Logger {
|
||||
export function createLogger(name: string): Logger {
|
||||
const level = moduleLevels.get(name) ?? globalLevel;
|
||||
return rootLogger.child({ module: name }, { level });
|
||||
return rootLogger.child({ module: name }, { level }) as Logger;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ function extractCommentMetadata(ast: AST.Program): {
|
|||
}
|
||||
// Normalize section name to uppercase for consistent lookups
|
||||
currentSection = {
|
||||
name: marker.name.toUpperCase(),
|
||||
name: marker.name!.toUpperCase(),
|
||||
comments: [],
|
||||
};
|
||||
break;
|
||||
|
|
@ -147,7 +147,7 @@ export function parseMissionScript(script: string): ParsedMission {
|
|||
pragma.missiontypes
|
||||
?.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((name) => normalizedMissionTypes[name.toLowerCase()] ?? name) ??
|
||||
.map((name) => (normalizedMissionTypes as Record<string, string>)[name.toLowerCase()] ?? name) ??
|
||||
[],
|
||||
missionBriefing: getSection("MISSION BRIEFING"),
|
||||
briefingWav: pragma.briefingwav ?? null,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ const fragmentShader = /*glsl*/ `
|
|||
}
|
||||
`;
|
||||
|
||||
export function createSkyBoxMaterial({ uniforms }) {
|
||||
export function createSkyBoxMaterial({ uniforms }: { uniforms: any }) {
|
||||
return new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export const gameEntityStore = createStore<GameEntityState>()((set) => ({
|
|||
if (info.gameClassName) {
|
||||
const raw = info.gameClassName.replace(/Game$/i, "");
|
||||
updates.missionType =
|
||||
normalizedMissionTypes[raw.toLowerCase()] ?? raw;
|
||||
(normalizedMissionTypes as Record<string, string>)[raw.toLowerCase()] ?? raw;
|
||||
} else {
|
||||
updates.missionType = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1329,7 +1329,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
? rawFilename
|
||||
: `${rawFilename}.wav`;
|
||||
|
||||
const descId = profileBlock.description as number | undefined;
|
||||
const descId = profileBlock!.description as number | undefined;
|
||||
const descBlock =
|
||||
descId != null ? this.getDataBlockData(descId) : undefined;
|
||||
|
||||
|
|
@ -1442,7 +1442,6 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
if (!subBlock) continue;
|
||||
const subShape =
|
||||
(subBlock.dtsFileName as string | undefined) || undefined;
|
||||
if (!subShape) continue;
|
||||
const subLifetimeTicks =
|
||||
(subBlock.lifetimeMS as number | undefined) ?? 31;
|
||||
const offset = (subBlock.offset as number | undefined) ?? 0;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ interface DemoMissionInfo {
|
|||
recorderName: string | null;
|
||||
/** Recording date string from readplayerinfo row 2 (e.g. "May-4-2025 10:37PM"). */
|
||||
recordingDate: string | null;
|
||||
/** Client ID of the recorder. */
|
||||
recorderClientId: number | null;
|
||||
}
|
||||
|
||||
function extractMissionInfo(demoValues: string[]): DemoMissionInfo {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
NoneEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import type { SceneTSStatic } from "../scene/types";
|
||||
|
||||
|
|
@ -133,16 +134,23 @@ export function streamEntityToGameEntity(
|
|||
} satisfies PlayerEntity;
|
||||
}
|
||||
|
||||
// Explosion
|
||||
// Explosion — only render a shape if the datablock specifies one; particle-only
|
||||
// explosions (e.g. BlasterExplosion) still exist as entities for ParticleEffects.
|
||||
if (entity.type === "Explosion") {
|
||||
if (entity.dataBlock) {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Explosion",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
explosionDataBlockId: entity.explosionDataBlockId,
|
||||
faceViewer: entity.faceViewer,
|
||||
} satisfies ExplosionEntity;
|
||||
}
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Explosion",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
explosionDataBlockId: entity.explosionDataBlockId,
|
||||
faceViewer: entity.faceViewer,
|
||||
} satisfies ExplosionEntity;
|
||||
renderType: "None",
|
||||
} satisfies NoneEntity;
|
||||
}
|
||||
|
||||
// Force field
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ async function initializeIflMaterial(
|
|||
|
||||
let disposed = false;
|
||||
const prevOnBeforeRender = mesh.onBeforeRender;
|
||||
mesh.onBeforeRender = function (this: any, ...args: any[]) {
|
||||
mesh.onBeforeRender = function (this: any, ...args: Parameters<typeof mesh.onBeforeRender>) {
|
||||
prevOnBeforeRender?.apply(this, args);
|
||||
if (disposed) return;
|
||||
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
|
||||
|
|
|
|||
|
|
@ -32,13 +32,14 @@ function quaternionToBodyYaw(q: [number, number, number, number]): number {
|
|||
* orientation, and movement state flags.
|
||||
*
|
||||
* Matches the Tribes2.exe binary (build 25034) pickActionAnimation at
|
||||
* 0x005d6210. The binary checks in order:
|
||||
* 1. mFalling → FallAnim (4)
|
||||
* 2. contactTimer < 30 → velocity-based selection (run/back/side/root)
|
||||
* 3. jetting → JetAnim (5)
|
||||
* 4. else → RootAnim (0)
|
||||
* 0x005d6210. The engine checks in order:
|
||||
* 1. mFalling → FallAnim
|
||||
* 2. contactTimer >= 30 (airborne) → jetting ? JetAnim : RootAnim
|
||||
* 3. contactTimer < 30 (on ground) → velocity-based (run/back/side/root)
|
||||
*
|
||||
* Since we don't have contactTimer, falling=false + no velocity uses root.
|
||||
* We don't have contactTimer, so we approximate airborne detection: jetting
|
||||
* implies airborne, and significant vertical velocity without the falling flag
|
||||
* suggests the player left the ground (jumped, launched, etc.).
|
||||
*/
|
||||
export function pickMoveAnimation(
|
||||
velocity: [number, number, number] | undefined,
|
||||
|
|
@ -46,20 +47,32 @@ export function pickMoveAnimation(
|
|||
falling?: boolean,
|
||||
jetting?: boolean,
|
||||
): MoveAnimationResult {
|
||||
// Falling overrides everything.
|
||||
// 1. Falling overrides everything.
|
||||
if (falling) {
|
||||
return { animation: "fall", timeScale: 1 };
|
||||
}
|
||||
|
||||
// 2. Jetting always shows the jet animation (engine: contactTimer >= 30
|
||||
// && mJetting → JetAnim). Jetting implies airborne.
|
||||
if (jetting) {
|
||||
return { animation: "jet", timeScale: 1 };
|
||||
}
|
||||
|
||||
if (!velocity) {
|
||||
// No velocity data at all — use jetting or idle.
|
||||
if (jetting) return { animation: "jet", timeScale: 1 };
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
const [vx, vy, _vz] = velocity;
|
||||
const [vx, vy, vz] = velocity;
|
||||
|
||||
// Convert world velocity to player object space using body yaw.
|
||||
// Approximate the engine's contactTimer check: if the player has
|
||||
// significant vertical velocity, they're likely airborne (jumped, launched).
|
||||
// The engine would show root in this case (contactTimer >= 30, not jetting).
|
||||
// Use a threshold above the falling threshold (-10) to catch the gap.
|
||||
if (Math.abs(vz) > 2) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
// 3. On ground: convert world velocity to player object space using body yaw.
|
||||
// mWorldToObj.mulV(mVelocity) with a pure Z-axis rotation:
|
||||
// localX = vx*cos(rotZ) + vy*sin(rotZ)
|
||||
// localY = -vx*sin(rotZ) + vy*cos(rotZ)
|
||||
|
|
@ -80,8 +93,6 @@ export function pickMoveAnimation(
|
|||
|
||||
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
|
||||
if (maxDot < MOVE_THRESHOLD) {
|
||||
// Below movement threshold — jetting or idle.
|
||||
if (jetting) return { animation: "jet", timeScale: 1 };
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@
|
|||
export function normalizePath(pathString: string) {
|
||||
return pathString.replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
export function formatPing(ms: number): string {
|
||||
return `${ms.toLocaleString()} ms`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1027,7 +1027,7 @@ export function createRuntime(
|
|||
return null;
|
||||
}
|
||||
const segmentLower = segment.toLowerCase();
|
||||
const child = current._children.find(
|
||||
const child: TorqueObject | undefined = current._children.find(
|
||||
(c) => c._name?.toLowerCase() === segmentLower,
|
||||
);
|
||||
current = child ?? null;
|
||||
|
|
@ -1347,7 +1347,7 @@ export function createRuntime(
|
|||
|
||||
const loadPromise = (async () => {
|
||||
// Pass original path to loader - it handles its own normalization
|
||||
const source = await loader(ref);
|
||||
const source = await loader!(ref);
|
||||
if (source == null) {
|
||||
log.warn("Script not found: %s", ref);
|
||||
state.failedScripts.add(normalized);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue