fix typecheck script, animations

This commit is contained in:
Brian Beck 2026-03-16 18:16:34 -07:00
parent 642fce9c06
commit ceb9fea9f4
120 changed files with 1308 additions and 911 deletions

View file

@ -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} />;
}

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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);

View file

@ -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(

View file

@ -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();

View file

@ -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),
),
);

View file

@ -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();

View file

@ -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

View file

@ -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>
);
}

View file

@ -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.

View file

@ -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 {

View file

@ -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 {

View file

@ -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}>

View file

@ -179,7 +179,7 @@ export const Mission = memo(function Mission({
const missionContext = useMemo(
() => ({
metadata: parsedMission,
metadata: parsedMission!,
missionType,
}),
[parsedMission, missionType],

View file

@ -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}

View file

@ -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();

View file

@ -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 {

View file

@ -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 />

View file

@ -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();

View file

@ -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

View file

@ -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;
}

View file

@ -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}&thinsp;/&thinsp;{server.maxPlayers}
{server.playerCount}
<span className={styles.CompactHidden}>
&thinsp;/&thinsp;{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 && (

View file

@ -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.
}

View file

@ -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);

View file

@ -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}
/>

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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>
</>

View file

@ -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;

View file

@ -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"}`;

View file

@ -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]);
}

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -1,5 +0,0 @@
import hxDif from "@/generated/hxDif.cjs";
export function parseInteriorBuffer(arrayBuffer: ArrayBufferLike) {
return hxDif.Dif.LoadFromArrayBuffer(arrayBuffer);
}

View file

@ -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;
}

View file

@ -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,

View file

@ -116,7 +116,7 @@ const fragmentShader = /*glsl*/ `
}
`;
export function createSkyBoxMaterial({ uniforms }) {
export function createSkyBoxMaterial({ uniforms }: { uniforms: any }) {
return new ShaderMaterial({
uniforms,
vertexShader,

View file

@ -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;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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

View file

@ -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()));

View file

@ -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 };
}

View file

@ -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`;
}

View file

@ -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);