add score screen

This commit is contained in:
Brian Beck 2026-03-14 17:12:37 -07:00
parent 9c64e59971
commit d9c18334b2
56 changed files with 1660 additions and 817 deletions

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useRef, RefObject } from "react";
import { RiLandscapeFill } from "react-icons/ri";
import { FaRotateRight } from "react-icons/fa6";
import { LuClipboardList } from "react-icons/lu";
import { LuClipboardList, LuUsers } from "react-icons/lu";
import { Camera } from "three";
import {
useControls,
@ -26,6 +26,7 @@ export function InspectorControls({
missionName,
missionType,
onOpenMapInfo,
onOpenScoreScreen,
onOpenServerBrowser,
onChooseMap,
onCancelChoosingMap,
@ -36,6 +37,7 @@ export function InspectorControls({
missionName: string;
missionType: string;
onOpenMapInfo: () => void;
onOpenScoreScreen?: () => void;
onOpenServerBrowser?: () => void;
onChooseMap?: () => void;
onCancelChoosingMap?: () => void;
@ -163,6 +165,17 @@ export function InspectorControls({
<LuClipboardList />
<span className={styles.ButtonLabel}>Show map info</span>
</button>
{onOpenScoreScreen && (
<button
type="button"
className={styles.MapInfoButton}
aria-label="Show scores"
onClick={onOpenScoreScreen}
>
<LuUsers />
<span className={styles.ButtonLabel}>Show scores</span>
</button>
)}
</div>
<div className={styles.Accordions}>
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>

View file

@ -102,6 +102,10 @@ const ServerBrowser = createLazy(
"ServerBrowser",
() => import("@/src/components/ServerBrowser"),
);
const ScoreScreen = createLazy(
"ScoreScreen",
() => import("@/src/components/ScoreScreen"),
);
export function MapInspector() {
const [currentMission, setCurrentMission] = useMissionQueryState();
@ -111,6 +115,7 @@ export function MapInspector() {
const { missionName, missionType } = currentMission;
const [mapInfoOpen, setMapInfoOpen] = useState(false);
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
const [scoreScreenOpen, setScoreScreenOpen] = useState(false);
const [choosingMap, setChoosingMap] = useState(false);
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
@ -276,6 +281,9 @@ export function MapInspector() {
missionName={missionName}
missionType={missionType}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenScoreScreen={
hasStreamData ? () => setScoreScreenOpen(true) : undefined
}
onOpenServerBrowser={
features.live ? () => setServerBrowserOpen(true) : undefined
}
@ -300,7 +308,11 @@ export function MapInspector() {
<div className={styles.Content}>
<div className={styles.ThreeView}>
<ThreeCanvas
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
dpr={
mapInfoOpen || serverBrowserOpen || scoreScreenOpen
? 0.25
: undefined
}
onCreated={(state) => {
cameraRef.current = state.camera;
invalidateRef.current = state.invalidate;
@ -342,7 +354,7 @@ export function MapInspector() {
</TickProvider>
</ThreeCanvas>
</div>
{hasStreamData ? (
{hasStreamData && !scoreScreenOpen ? (
<Suspense>
<PlayerHUD />
</Suspense>
@ -386,6 +398,13 @@ export function MapInspector() {
</Suspense>
</ViewTransition>
) : null}
{scoreScreenOpen ? (
<ViewTransition>
<Suspense>
<ScoreScreen onClose={() => setScoreScreenOpen(false)} />
</Suspense>
</ViewTransition>
) : null}
</RecordingProvider>
</main>
);

View file

@ -42,6 +42,20 @@
image-rendering: pixelated;
}
.CompassClock {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: rgba(200, 240, 230, 0.9);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
pointer-events: none;
white-space: nowrap;
}
.Bars {
display: flex;
flex-direction: column;
@ -87,27 +101,27 @@
/* ── Team Scores (bottom-left) ── */
.TeamInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.TeamScores {
position: absolute;
bottom: 6px;
left: 6px;
font-size: 12px;
border: 1px solid rgba(128, 255, 200, 0.15);
border-collapse: collapse;
}
.ObserverCount {
position: absolute;
bottom: calc(100%);
display: block;
padding: 4px 6px;
font-size: 10px;
color: rgb(193, 228, 216);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
.TeamRow {
flex: 1 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px 4px 6px;
background: rgba(0, 50, 60, 0.65);
}
@ -119,6 +133,7 @@
min-width: 6em;
font-size: 12px;
font-weight: 500;
padding: 5px 6px;
}
.TeamNameFriendly {
@ -135,11 +150,14 @@
color: #fff;
font-weight: 500;
text-align: right;
padding: 0 10px;
border-left: 1px solid rgba(128, 255, 200, 0.15);
}
.TeamCount {
color: #9ba;
font-size: 9px;
color: rgb(125, 155, 150);
font-size: 11px;
padding: 0 6px;
}
/* ── Pack + Inventory HUD (bottom-right) ── */

View file

@ -3,14 +3,26 @@ 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");
function formatClockHud(clockMs: number): string {
const absSec = Math.abs(clockMs) / 1000;
const displaySec = clockMs < 0 ? Math.ceil(absSec) : Math.floor(absSec);
const mins = Math.floor(displaySec / 60);
const secs = displaySec % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function Compass() {
const yaw = useEngineSelector(
(state) => state.playback.streamSnapshot?.camera?.yaw,
);
const matchClockMs = useEngineSelector(
(state) => state.playback.streamSnapshot?.matchClockMs,
);
if (yaw == null) return null;
// The ring notch is the fixed heading indicator (always "forward" at top).
// The NSEW letters rotate to show world cardinal directions relative to
@ -26,6 +38,11 @@ function Compass() {
className={styles.CompassNSEW}
style={{ transform: `rotate(${-deg}deg)` }}
/>
{matchClockMs != null && (
<span className={styles.CompassClock}>
{formatClockHud(matchClockMs)}
</span>
)}
</div>
);
}
@ -216,6 +233,11 @@ function TeamScores() {
const playerSensorGroup = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerSensorGroup,
);
const observerCount = useEngineSelector(
(state) =>
state.playback.streamSnapshot?.playerRoster?.filter((p) => p.teamId <= 0)
.length ?? 0,
);
if (!teamScores?.length) return null;
// Sort: friendly team first (if known), then by teamId.
const sorted = [...teamScores].sort((a, b) => {
@ -226,33 +248,41 @@ function TeamScores() {
return a.teamId - b.teamId;
});
return (
<div className={styles.TeamScores}>
{sorted.map((team: TeamScore) => {
const isFriendly =
playerSensorGroup > 0 && team.teamId === playerSensorGroup;
const name =
team.name ||
(DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`);
return (
<div key={team.teamId} className={styles.TeamRow}>
<div className={styles.TeamInfo}>
<span
<table className={styles.TeamScores}>
<tbody>
{observerCount > 0 && (
<tr>
<td className={styles.ObserverCount} colSpan={3}>
{observerCount} {observerCount === 1 ? "observer" : "observers"}
</td>
</tr>
)}
{sorted.map((team: TeamScore) => {
const isFriendly =
playerSensorGroup > 0 && team.teamId === playerSensorGroup;
const name =
team.name ||
(DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`);
return (
<tr key={team.teamId} className={styles.TeamRow}>
<td
className={
isFriendly ? styles.TeamNameFriendly : styles.TeamNameEnemy
}
>
{name}
</span>{" "}
<span className={styles.TeamCount}>
{team.playerCount}{" "}
{team.playerCount === 1 ? "player" : "players"}
</span>
</div>
<span className={styles.TeamScore}>{team.score}</span>
</div>
);
})}
</div>
</td>
<td className={styles.TeamCount}>
({team.playerCount.toLocaleString()})
</td>
<td className={styles.TeamScore}>
{team.score.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
);
}

View file

@ -0,0 +1,236 @@
.Dialog {
composes: Dialog from "./GameDialog.module.css";
width: 600px;
min-height: 360px;
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto 1fr auto;
}
.Overlay {
composes: Overlay from "./GameDialog.module.css";
}
.TitleBar {
display: flex;
align-items: center;
padding: 7px 15px 6px 16px;
border-bottom: 1px solid rgba(0, 190, 220, 0.25);
background: rgba(32, 83, 85, 0.8);
color: rgba(133, 255, 222, 0.9);
box-shadow:
inset 0 2px 4px rgba(114, 255, 246, 0.2),
inset 0 -2px 5px rgba(20, 30, 31, 0.5);
}
.PlayerTotal {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
color: rgba(141, 219, 214, 0.8);
margin: 0 0 0 auto;
}
.Title {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
margin: 0;
text-transform: uppercase;
text-shadow: 0 -1px 0 rgba(10, 25, 26, 0.6);
}
.MatchClock {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
margin: 0 0 0 16px;
}
.Time {
color: rgba(133, 255, 222, 0.9);
}
.PlayersIcon {
font-size: 16px;
}
.ClockIcon {
font-size: 16px;
}
/* Table layout */
.TableWrapper {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.Table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
padding-bottom: 8px;
}
/* Sticky thead — works because TableWrapper is the scroll container */
.Table thead {
position: sticky;
top: 0;
z-index: 1;
}
/* Team name + score row */
.TeamHeaderRow th {
padding: 8px 16px;
font-weight: 500;
background: rgba(20, 37, 38, 0.95);
box-shadow: inset 0 -1px 0 rgba(0, 190, 220, 0.2);
}
.TeamName {
width: 50%;
font-size: 18px;
font-weight: 500;
color: #7dffff;
text-align: left;
}
.TeamScore {
font-size: 22px;
font-weight: 500;
color: #7dffff;
text-align: right;
}
/* Column sub-headers (Players / Score) */
.ColumnHeaderRow th {
padding: 6px 15px 7px 15px;
background: rgba(10, 25, 26, 0.95);
box-shadow: inset 0 -1px 0 rgba(0, 190, 220, 0.15);
font-size: 12px;
font-weight: 500;
color: rgba(125, 255, 255, 0.7);
text-transform: uppercase;
text-align: left;
}
.ColumnHeader {
}
.ColumnHeaderScore {
text-align: right !important;
}
.ColumnPing {
font-size: 10px;
font-weight: 500;
color: rgba(125, 255, 255, 0.4);
margin: 0 0 0 8px;
text-transform: none;
}
/* Player rows */
.PlayerBody td {
line-height: calc(16 / 13);
padding: 3px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.PlayerBody tr:last-child td {
border-bottom: none;
}
.PlayerBody tr:hover {
background: rgba(65, 131, 139, 0.08);
}
.PlayerName {
width: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.PlayerNameLocal {
composes: PlayerName;
color: #5dff8a;
}
.PlayerScore {
text-align: right;
white-space: nowrap;
font-weight: 500;
color: rgba(176, 213, 201, 0.8);
}
.PlayerScoreLocal {
composes: PlayerScore;
color: #5dff8a;
}
/* Divider between team columns */
.TeamHeaderRow th:nth-child(2),
.ColumnHeaderRow th:nth-child(2),
.PlayerBody td:nth-child(2),
.ObserverBody td:nth-child(2) {
border-right: 1px solid rgba(0, 190, 220, 0.15);
}
/* Observers */
.ObserverBody tr:first-child th {
box-shadow:
inset 0 1px 0 rgba(0, 190, 220, 0.15),
inset 0 -1px 0 rgba(0, 190, 220, 0.15);
}
.ObserverBody td {
padding: 2px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.ObserverBody tr:hover {
background: rgba(65, 131, 139, 0.08);
}
/* Footer */
.Footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
border-top: 1px solid rgba(0, 190, 220, 0.25);
background: rgba(2, 20, 21, 0.7);
flex-shrink: 0;
}
.CloseButton {
composes: DialogButton from "./GameDialog.module.css";
}
.Hint {
font-size: 12px;
color: rgba(201, 220, 216, 0.3);
margin-left: auto;
}
.Empty {
padding: 32px 16px;
text-align: center;
color: rgba(201, 220, 216, 0.3);
font-style: italic;
}
@media (max-width: 719px) {
.Hint,
.ColumnPing {
display: none;
}
}

View file

@ -0,0 +1,358 @@
import { useEffect, useRef, useMemo } from "react";
import { LuUsers } from "react-icons/lu";
import { IoMdStopwatch } from "react-icons/io";
import { useEngineSelector } from "../state/engineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { useDataSource } from "../state/gameEntityStore";
import type { PlayerRosterEntry, TeamScore } from "../stream/types";
import styles from "./ScoreScreen.module.css";
const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
3: "Starwolf",
4: "Diamond Sword",
5: "Blood Eagle",
6: "Phoenix",
};
function computePingStats(players: PlayerRosterEntry[]): {
avg: number;
dev: number;
} {
if (!players.length) return { avg: 0, dev: 0 };
const pings = players.map((p) => p.ping);
const avg = pings.reduce((a, b) => a + b, 0) / pings.length;
const variance =
pings.reduce((sum, p) => sum + (p - avg) ** 2, 0) / pings.length;
return { avg: Math.round(avg), dev: Math.round(Math.sqrt(variance)) };
}
function formatClock(totalSec: number): string {
const sign = totalSec < 0 ? "-" : "";
const abs = Math.abs(totalSec);
const mins = Math.floor(abs / 60);
const secs = Math.floor(abs % 60);
return `${sign}${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
/** Renders the match clock. Negative clockMs = counting down, positive = counting up. */
function MatchClock({ clockMs }: { clockMs: number }) {
// Match the C++ HudClockCtrl: display absolute value, sign determines direction.
const absSec = Math.abs(clockMs) / 1000;
const displaySec = clockMs < 0 ? Math.ceil(absSec) : Math.floor(absSec);
return (
<span className={styles.MatchClock}>
<IoMdStopwatch className={styles.ClockIcon} />{" "}
<span className={styles.Time}>{formatClock(displaySec)}</span>
</span>
);
}
function getTeamName(team: TeamScore): string {
return team.name || DEFAULT_TEAM_NAMES[team.teamId] || `Team ${team.teamId}`;
}
export function ScoreScreen({ onClose }: { onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const dataSource = useDataSource();
const isLive = dataSource === "live";
const connectedClientId = useEngineSelector(
(state) => state.playback.streamSnapshot?.connectedClientId,
);
const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores,
);
const playerRoster = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerRoster,
);
const playerSensorGroup = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerSensorGroup,
);
const matchClockMs = useEngineSelector(
(state) => state.playback.streamSnapshot?.matchClockMs,
);
// Focus and exit pointer lock on open
useEffect(() => {
dialogRef.current?.focus();
try {
document.exitPointerLock();
} catch {
/* expected */
}
}, []);
// Block keyboard events from reaching Three.js while open
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
e.stopImmediatePropagation();
};
const handleKeyUp = (e: KeyboardEvent) => {
e.stopImmediatePropagation();
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", handleKeyUp, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp, { capture: true });
};
}, [onClose]);
// Poll for scores every 4 seconds in live mode
useEffect(() => {
if (!isLive) return;
const request = () => {
liveConnectionStore.getState().sendCommand("getScores");
};
request();
const interval = setInterval(request, 4000);
return () => clearInterval(interval);
}, [isLive]);
// Group players by team, sorted by score descending
const { teamPlayers, observers } = useMemo(() => {
const teamPlayers = new Map<number, PlayerRosterEntry[]>();
const observers: PlayerRosterEntry[] = [];
if (playerRoster) {
for (const player of playerRoster) {
if (player.teamId > 0) {
const list = teamPlayers.get(player.teamId);
if (list) {
list.push(player);
} else {
teamPlayers.set(player.teamId, [player]);
}
} else {
observers.push(player);
}
}
}
for (const list of teamPlayers.values()) {
list.sort(
(a, b) =>
b.score - a.score || (a.name ?? "").localeCompare(b.name ?? ""),
);
}
observers.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
return { teamPlayers, observers };
}, [playerRoster]);
// Sort teams by natural order (team1, team2, etc.)
const sortedTeams = useMemo(() => {
if (!teamScores?.length) return [];
return [...teamScores].sort((a, b) => a.teamId - b.teamId);
}, [teamScores]);
const hasTeams = sortedTeams.length >= 2;
const team1 = sortedTeams[0];
const team2 = sortedTeams[1];
const team1Players = team1 ? (teamPlayers.get(team1.teamId) ?? []) : [];
const team2Players = team2 ? (teamPlayers.get(team2.teamId) ?? []) : [];
const team1Ping = useMemo(
() => computePingStats(team1Players),
[team1Players],
);
const team2Ping = useMemo(
() => computePingStats(team2Players),
[team2Players],
);
const maxRows = Math.max(team1Players.length, team2Players.length);
return (
<div className={styles.Overlay} onClick={onClose}>
<div
ref={dialogRef}
className={styles.Dialog}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Score Screen"
tabIndex={-1}
>
<header className={styles.TitleBar}>
<h2 className={styles.Title}>Score</h2>{" "}
<span className={styles.PlayerTotal}>
<LuUsers className={styles.PlayersIcon} />{" "}
{playerRoster?.length ?? 0} players
</span>{" "}
{matchClockMs != null && <MatchClock clockMs={matchClockMs} />}
</header>
{hasTeams ? (
<div className={styles.TableWrapper}>
<table className={styles.Table}>
<thead>
<tr className={styles.TeamHeaderRow}>
<th className={styles.TeamName}>{getTeamName(team1)}</th>
<th className={styles.TeamScore}>{team1.score}</th>
<th className={styles.TeamName}>{getTeamName(team2)}</th>
<th className={styles.TeamScore}>{team2.score}</th>
</tr>
<tr className={styles.ColumnHeaderRow}>
<th className={styles.ColumnHeader}>
<span>Players ({team1Players.length})</span>
{team1Players.length > 0 && (
<span className={styles.ColumnPing}>
{" "}
PING: {team1Ping.avg}&thinsp;&#177;&thinsp;
{team1Ping.dev}&thinsp;ms
</span>
)}
</th>
<th className={styles.ColumnHeaderScore}>Score</th>
<th className={styles.ColumnHeader}>
<span>Players ({team2Players.length})</span>
{team2Players.length > 0 && (
<span className={styles.ColumnPing}>
{" "}
PING: {team2Ping.avg}&thinsp;&#177;&thinsp;
{team2Ping.dev}&thinsp;ms
</span>
)}
</th>
<th className={styles.ColumnHeaderScore}>Score</th>
</tr>
</thead>
<tbody className={styles.PlayerBody}>
{Array.from({ length: maxRows }, (_, i) => {
const p1 = team1Players[i];
const p2 = team2Players[i];
const p1IsLocal =
connectedClientId != null &&
p1?.clientId === connectedClientId;
const p2IsLocal =
connectedClientId != null &&
p2?.clientId === connectedClientId;
return (
<tr key={`${p1?.clientId ?? ""}-${p2?.clientId ?? ""}`}>
<td
className={
p1IsLocal ? styles.PlayerNameLocal : styles.PlayerName
}
>
{p1?.name || (p1 ? "..." : "")}
</td>
<td
className={
p1IsLocal
? styles.PlayerScoreLocal
: styles.PlayerScore
}
>
{p1 != null ? p1.score : ""}
</td>
<td
className={
p2IsLocal ? styles.PlayerNameLocal : styles.PlayerName
}
>
{p2?.name || (p2 ? "..." : "")}
</td>
<td
className={
p2IsLocal
? styles.PlayerScoreLocal
: styles.PlayerScore
}
>
{p2 != null ? p2.score : ""}
</td>
</tr>
);
})}
</tbody>
{observers.length > 0 &&
(() => {
// Split into two columns, filling top-to-bottom then left-to-right.
const half = Math.ceil(observers.length / 2);
const obsRows = Math.ceil(observers.length / 2);
return (
<tbody className={styles.ObserverBody}>
<tr className={styles.ColumnHeaderRow}>
<th colSpan={2} className={styles.ColumnHeader}>
Observers ({observers.length})
</th>
<th colSpan={2} className={styles.ColumnHeader}>
&nbsp;
</th>
</tr>
{Array.from({ length: obsRows }, (_, i) => {
const o1 = observers[i];
const o2 = observers[i + half];
const o1IsLocal =
connectedClientId != null &&
o1?.clientId === connectedClientId;
const o2IsLocal =
connectedClientId != null &&
o2?.clientId === connectedClientId;
return (
<tr
key={`${o1?.clientId ?? ""}-${o2?.clientId ?? ""}`}
>
<td
className={
o1IsLocal
? styles.PlayerNameLocal
: styles.PlayerName
}
>
{o1?.name || (o1 ? "..." : "")}
</td>
<td
className={
o1IsLocal
? styles.PlayerScoreLocal
: styles.PlayerScore
}
>
{o1 != null ? o1.score : ""}
</td>
<td
className={
o2IsLocal
? styles.PlayerNameLocal
: styles.PlayerName
}
>
{o2?.name || ""}
</td>
<td
className={
o2IsLocal
? styles.PlayerScoreLocal
: styles.PlayerScore
}
>
{o2 != null ? o2.score : ""}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div>
) : (
<div className={styles.Empty}>
{playerRoster?.length
? "No team data available"
: "Waiting for player data\u2026"}
</div>
)}
<div className={styles.Footer}>
<button className={styles.CloseButton} onClick={onClose}>
Close
</button>
<span className={styles.Hint}>Esc to close</span>
</div>
</div>
</div>
);
}

View file

@ -59,6 +59,7 @@
min-height: 0;
border-collapse: collapse;
font-size: 13px;
user-select: none;
}
.Table th {

View file

@ -6,6 +6,7 @@ import {
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../stream/playbackUtils";
import { useSettings } from "./SettingsProvider";
import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter";
@ -102,6 +103,7 @@ export function StreamingController({
recording: StreamRecording;
}) {
const engineStore = useEngineStoreApi();
const { fov: userFov } = useSettings();
const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
@ -457,16 +459,14 @@ export function StreamingController({
}
if (
Number.isFinite(currentCamera.fov) &&
"isPerspectiveCamera" in state.camera &&
(state.camera as any).isPerspectiveCamera
) {
const perspectiveCamera = state.camera as any;
const fovValue =
previousCamera && Number.isFinite(previousCamera.fov)
? previousCamera.fov +
(currentCamera.fov - previousCamera.fov) * interpT
: currentCamera.fov;
// Use the user's FOV preference, matching how the real client applies
// $pref::Player::defaultFov locally. The stream's camera FOV is the
// recorder's setting (demos) or server default (live).
const fovValue = userFov;
const verticalFov = torqueHorizontalFovToThreeVerticalFov(
fovValue,
perspectiveCamera.aspect,