mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-21 13:21:05 +00:00
add score screen
This commit is contained in:
parent
9c64e59971
commit
d9c18334b2
56 changed files with 1660 additions and 817 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) ── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
236
src/components/ScoreScreen.module.css
Normal file
236
src/components/ScoreScreen.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
358
src/components/ScoreScreen.tsx
Normal file
358
src/components/ScoreScreen.tsx
Normal 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} ± 
|
||||
{team1Ping.dev} 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} ± 
|
||||
{team2Ping.dev} 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}>
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
min-height: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.Table th {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import type {
|
|||
StreamingPlayback,
|
||||
InventoryHudSlot,
|
||||
PendingAudioEvent,
|
||||
PlayerRosterEntry,
|
||||
TeamScore,
|
||||
WeaponsHudSlot,
|
||||
WeaponImageState,
|
||||
|
|
@ -214,7 +215,14 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
protected backpackHud = { packIndex: -1, active: false, text: "" };
|
||||
protected inventoryHud = { slots: new Map<number, number>(), activeSlot: -1 };
|
||||
protected teamScores: TeamScore[] = [];
|
||||
protected playerRoster = new Map<number, { name: string; teamId: number }>();
|
||||
protected playerRoster = new Map<
|
||||
number,
|
||||
{ name: string; teamId: number; score: number; ping: number; packetLoss: number }
|
||||
>();
|
||||
/** Stream time (seconds) when the clock was last set. */
|
||||
protected clockAnchorStreamSec: number | null = null;
|
||||
/** Duration in ms passed to setTime (0 = count-up, >0 = count-down). */
|
||||
protected clockDurationMs: number = 0;
|
||||
|
||||
// ── Mission info (from server messages) ──
|
||||
/** Mission display name (e.g. "Riverdance"), from MsgMissionDropInfo/MsgLoadInfo. */
|
||||
|
|
@ -227,6 +235,8 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
serverDisplayName: string | null = null;
|
||||
/** Server-assigned name of the connected/recording player. */
|
||||
connectedPlayerName: string | null = null;
|
||||
/** Client ID of the connected player (from MsgClientJoin "Welcome" message). */
|
||||
connectedClientId: number | null = null;
|
||||
/** Called when mission info changes (mission name, game type, etc.). */
|
||||
onMissionInfoChange?: () => void;
|
||||
|
||||
|
|
@ -337,12 +347,16 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.inventoryHud = { slots: new Map(), activeSlot: -1 };
|
||||
this.teamScores = [];
|
||||
this.playerRoster.clear();
|
||||
this.clockAnchorStreamSec = null;
|
||||
this.clockDurationMs = 0;
|
||||
this.nextExplosionId = 0;
|
||||
this.missionDisplayName = null;
|
||||
this.missionTypeDisplayName = null;
|
||||
this.gameClassName = null;
|
||||
this.serverDisplayName = null;
|
||||
this.connectedPlayerName = null;
|
||||
// Note: connectedPlayerName and connectedClientId are NOT cleared here —
|
||||
// they are connection-level state set once from the "Welcome" MsgClientJoin,
|
||||
// and should persist across mission changes.
|
||||
}
|
||||
|
||||
// ── Net string resolution ──
|
||||
|
|
@ -516,7 +530,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
const pendingTargetId = this.pendingNameTags.get(id);
|
||||
if (pendingTargetId != null) {
|
||||
this.pendingNameTags.delete(id);
|
||||
const name = stripTaggedStringMarkup(value);
|
||||
const name = stripTaggedStringMarkup(value).trim();
|
||||
this.targetNames.set(pendingTargetId, name);
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.targetId === pendingTargetId) {
|
||||
|
|
@ -534,7 +548,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
if (targetId != null && nameTag != null) {
|
||||
const resolved = this.netStrings.get(nameTag);
|
||||
if (resolved) {
|
||||
this.targetNames.set(targetId, stripTaggedStringMarkup(resolved));
|
||||
this.targetNames.set(targetId, stripTaggedStringMarkup(resolved).trim());
|
||||
} else {
|
||||
// NetStringEvent hasn't arrived yet — defer resolution.
|
||||
this.pendingNameTags.set(nameTag, targetId);
|
||||
|
|
@ -1806,7 +1820,10 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
if (args.length < 2) return;
|
||||
const msgType = this.resolveNetString(args[0]);
|
||||
|
||||
if (msgType === "MsgTeamScoreIs" && args.length >= 4) {
|
||||
if (
|
||||
(msgType === "MsgTeamScoreIs" || msgType === "MsgTeamScore") &&
|
||||
args.length >= 4
|
||||
) {
|
||||
const teamId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const newScore = parseInt(this.resolveNetString(args[3]), 10);
|
||||
if (!isNaN(teamId) && !isNaN(newScore)) {
|
||||
|
|
@ -1817,11 +1834,12 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
}
|
||||
} else if (msgType === "MsgCTFAddTeam" && args.length >= 6) {
|
||||
const teamIdx = parseInt(this.resolveNetString(args[2]), 10);
|
||||
// Wire order: args[2]=teamId (1-based), args[3]=teamName,
|
||||
// args[4]=flagStatus, args[5]=teamScore
|
||||
const teamId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const teamName = stripTaggedStringMarkup(this.resolveNetString(args[3]));
|
||||
const score = parseInt(this.resolveNetString(args[5]), 10);
|
||||
if (!isNaN(teamIdx)) {
|
||||
const teamId = teamIdx + 1;
|
||||
if (!isNaN(teamId) && teamId > 0) {
|
||||
const existing = this.teamScores.find((t) => t.teamId === teamId);
|
||||
if (existing) {
|
||||
existing.name = teamName;
|
||||
|
|
@ -1843,10 +1861,14 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
).trim();
|
||||
const clientId = parseInt(this.resolveNetString(args[3]), 10);
|
||||
if (!isNaN(clientId)) {
|
||||
const existing = this.playerRoster.get(clientId);
|
||||
// The real client (message.cs handleClientJoin) creates a fresh
|
||||
// ScriptObject with score=0, overwriting any previous entry.
|
||||
this.playerRoster.set(clientId, {
|
||||
name,
|
||||
teamId: existing?.teamId ?? 0,
|
||||
teamId: 0,
|
||||
score: 0,
|
||||
ping: 0,
|
||||
packetLoss: 0,
|
||||
});
|
||||
this.onRosterChanged();
|
||||
}
|
||||
|
|
@ -1859,27 +1881,66 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
);
|
||||
if (msgFormat.includes("Welcome to Tribes")) {
|
||||
this.connectedPlayerName = name;
|
||||
this.connectedClientId = clientId;
|
||||
this.onMissionInfoChange?.();
|
||||
}
|
||||
}
|
||||
} else if (msgType === "MsgClientDrop" && args.length >= 3) {
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
} else if (msgType === "MsgClientDrop" && args.length >= 4) {
|
||||
// Wire order: args[2]=clientName, args[3]=clientId
|
||||
const clientId = parseInt(this.resolveNetString(args[3]), 10);
|
||||
if (!isNaN(clientId)) {
|
||||
this.playerRoster.delete(clientId);
|
||||
this.onRosterChanged();
|
||||
}
|
||||
} else if (msgType === "MsgClientJoinTeam" && args.length >= 4) {
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const teamId = parseInt(this.resolveNetString(args[3]), 10);
|
||||
} else if (msgType === "MsgClientJoinTeam" && args.length >= 6) {
|
||||
// Wire order: args[2]=clientName, args[3]=teamName, args[4]=clientId, args[5]=teamId
|
||||
const clientId = parseInt(this.resolveNetString(args[4]), 10);
|
||||
const teamId = parseInt(this.resolveNetString(args[5]), 10);
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
const existing = this.playerRoster.get(clientId);
|
||||
if (existing) {
|
||||
existing.teamId = teamId;
|
||||
} else {
|
||||
this.playerRoster.set(clientId, { name: "", teamId });
|
||||
this.playerRoster.set(clientId, {
|
||||
name: "",
|
||||
teamId,
|
||||
score: 0,
|
||||
ping: 0,
|
||||
packetLoss: 0,
|
||||
});
|
||||
}
|
||||
this.onRosterChanged();
|
||||
}
|
||||
} else if (msgType === "MsgPlayerScore" && args.length >= 5) {
|
||||
// Wire order: args[2]=clientId, args[3]=score, args[4]=ping, args[5]=packetLoss
|
||||
// Only update existing roster entries — the real client (scoreList.cs
|
||||
// handlePlayerScore) warns and ignores scores for unknown clients.
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
if (!isNaN(clientId)) {
|
||||
const existing = this.playerRoster.get(clientId);
|
||||
if (existing) {
|
||||
const score = parseInt(this.resolveNetString(args[3]), 10);
|
||||
const ping = parseInt(this.resolveNetString(args[4]), 10);
|
||||
const packetLoss = parseInt(
|
||||
this.resolveNetString(args[5] ?? ""),
|
||||
10,
|
||||
);
|
||||
if (!isNaN(score)) existing.score = score;
|
||||
if (!isNaN(ping)) existing.ping = ping;
|
||||
if (!isNaN(packetLoss)) existing.packetLoss = packetLoss;
|
||||
this.onRosterChanged();
|
||||
}
|
||||
}
|
||||
} else if (msgType === "MsgSystemClock" && args.length >= 4) {
|
||||
// Wire order: args[2]=timeLimitMinutes, args[3]=timeRemainingMS
|
||||
// The real client calls clockHud.setTime(timeRemainingMS / 60000).
|
||||
// setTime(0) → count-up clock (pre-match elapsed).
|
||||
// setTime(N) → count-down clock (N minutes remaining).
|
||||
const timeRemainingMS = parseFloat(this.resolveNetString(args[3]));
|
||||
this.clockAnchorStreamSec = this.getTimeSec();
|
||||
this.clockDurationMs = Number.isFinite(timeRemainingMS)
|
||||
? timeRemainingMS
|
||||
: 0;
|
||||
} else if (msgType === "MsgMissionDropInfo" && args.length >= 5) {
|
||||
// messageClient(%cl, 'MsgMissionDropInfo', ..., $MissionDisplayName, $MissionTypeDisplayName, $ServerName)
|
||||
const missionDisplayName = stripTaggedStringMarkup(
|
||||
|
|
@ -2098,12 +2159,26 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the match clock value in ms, mirroring HudClockCtrl's actualTimeMS.
|
||||
* Negative = counting down (remaining), positive = counting up (elapsed).
|
||||
* Returns null if no clock has been set.
|
||||
*/
|
||||
protected computeMatchClockMs(timeSec: number): number | null {
|
||||
if (this.clockAnchorStreamSec == null) return null;
|
||||
const elapsedMs = (timeSec - this.clockAnchorStreamSec) * 1000;
|
||||
// actualTimeMS = -clockDurationMs + elapsed
|
||||
// duration=0 → positive (count-up), duration>0 → starts negative (count-down)
|
||||
return -this.clockDurationMs + elapsedMs;
|
||||
}
|
||||
|
||||
/** Build HUD arrays for snapshot. */
|
||||
protected buildHudState(): {
|
||||
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
|
||||
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
|
||||
backpackHud: BackpackHudState | null;
|
||||
teamScores: TeamScore[];
|
||||
playerRoster: PlayerRosterEntry[];
|
||||
} {
|
||||
const weaponsHud = {
|
||||
slots: Array.from(this.weaponsHud.slots.entries()).map(
|
||||
|
|
@ -2131,7 +2206,12 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
|
||||
}
|
||||
|
||||
return { weaponsHud, inventoryHud, backpackHud, teamScores };
|
||||
const playerRoster: PlayerRosterEntry[] = [];
|
||||
for (const [clientId, entry] of this.playerRoster) {
|
||||
playerRoster.push({ clientId, ...entry });
|
||||
}
|
||||
|
||||
return { weaponsHud, inventoryHud, backpackHud, teamScores, playerRoster };
|
||||
}
|
||||
|
||||
/** Build filtered chat and audio event arrays for the current time. */
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
StreamRecording,
|
||||
StreamSnapshot,
|
||||
TeamScore,
|
||||
PlayerRosterEntry,
|
||||
WeaponsHudSlot,
|
||||
InventoryHudSlot,
|
||||
BackpackHudState,
|
||||
|
|
@ -57,6 +58,7 @@ function extractMissionInfo(demoValues: string[]): DemoMissionInfo {
|
|||
let serverDisplayName: string | null = null;
|
||||
let mod: string | null = null;
|
||||
let recorderName: string | null = null;
|
||||
let recorderClientId: number = NaN;
|
||||
let recordingDate: string | null = null;
|
||||
|
||||
for (let i = 0; i < demoValues.length; i++) {
|
||||
|
|
@ -71,8 +73,9 @@ function extractMissionInfo(demoValues: string[]): DemoMissionInfo {
|
|||
if (!value) continue;
|
||||
|
||||
if (value.startsWith("1\t")) {
|
||||
// Row 1: "1\ttime\trecorderName\tteam\tplayerId"
|
||||
// Row 1: "1\tclientId\trecorderName\tteamName\tguid"
|
||||
const fields = value.split("\t");
|
||||
if (fields[1]) recorderClientId = parseInt(fields[1], 10);
|
||||
if (fields[2]) recorderName = stripTaggedStringMarkup(fields[2]).trim();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -101,6 +104,9 @@ function extractMissionInfo(demoValues: string[]): DemoMissionInfo {
|
|||
serverDisplayName,
|
||||
mod,
|
||||
recorderName,
|
||||
recorderClientId: Number.isFinite(recorderClientId)
|
||||
? recorderClientId
|
||||
: null,
|
||||
recordingDate,
|
||||
};
|
||||
}
|
||||
|
|
@ -113,8 +119,13 @@ interface ParsedDemoValues {
|
|||
activeSlot: number;
|
||||
} | null;
|
||||
teamScores: TeamScore[];
|
||||
playerRoster: Map<number, { name: string; teamId: number }>;
|
||||
playerRoster: Map<
|
||||
number,
|
||||
{ name: string; teamId: number; score: number; ping: number; packetLoss: number }
|
||||
>;
|
||||
chatMessages: string[];
|
||||
/** Value from clockHud.getTime() — minutes passed to setTime(). */
|
||||
clockTimeMin: number | null;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +144,7 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
teamScores: [],
|
||||
playerRoster: new Map(),
|
||||
chatMessages: [],
|
||||
clockTimeMin: null,
|
||||
gravity: -20,
|
||||
};
|
||||
if (!demoValues.length) return result;
|
||||
|
|
@ -152,11 +164,14 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
const playerCountByTeam = new Map<number, number>();
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const name = fields[0] ?? "";
|
||||
const name = stripTaggedStringMarkup(fields[0] ?? "").trim();
|
||||
const clientId = parseInt(fields[2], 10);
|
||||
const teamId = parseInt(fields[4], 10);
|
||||
const score = parseInt(fields[5], 10) || 0;
|
||||
const ping = parseInt(fields[6], 10) || 0;
|
||||
const packetLoss = parseInt(fields[7], 10) || 0;
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
result.playerRoster.set(clientId, { name, teamId });
|
||||
result.playerRoster.set(clientId, { name, teamId, score, ping, packetLoss });
|
||||
}
|
||||
if (!isNaN(teamId) && teamId > 0) {
|
||||
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
|
||||
|
|
@ -262,9 +277,15 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
}
|
||||
}
|
||||
|
||||
// CLOCK: 1 value
|
||||
// CLOCK: 1 value — "isVisible\tremainingMinutes"
|
||||
if (idx >= demoValues.length) return result;
|
||||
next();
|
||||
{
|
||||
const clockFields = next().split("\t");
|
||||
const timeMin = parseFloat(clockFields[1] ?? "");
|
||||
if (Number.isFinite(timeMin)) {
|
||||
result.clockTimeMin = timeMin;
|
||||
}
|
||||
}
|
||||
|
||||
// CHAT: always 10 entries
|
||||
for (let i = 0; i < 10; i++) {
|
||||
|
|
@ -345,6 +366,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
teamScoresGen: number;
|
||||
rosterGen: number;
|
||||
teamScores: TeamScore[];
|
||||
playerRoster: PlayerRosterEntry[];
|
||||
weaponsHudGen: number;
|
||||
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
|
||||
inventoryHudGen: number;
|
||||
|
|
@ -472,7 +494,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
if (entry.name) {
|
||||
this.targetNames.set(
|
||||
entry.targetId,
|
||||
stripTaggedStringMarkup(entry.name),
|
||||
stripTaggedStringMarkup(entry.name).trim(),
|
||||
);
|
||||
}
|
||||
this.targetTeams.set(entry.targetId, entry.sensorGroup);
|
||||
|
|
@ -672,6 +694,11 @@ class StreamingPlayback extends StreamEngine {
|
|||
}
|
||||
this.teamScores = parsed.teamScores;
|
||||
this.playerRoster = new Map(parsed.playerRoster);
|
||||
if (parsed.clockTimeMin != null) {
|
||||
// Reproduce clockHud.setTime(getTime()) at demo start (timeSec=0).
|
||||
this.clockAnchorStreamSec = 0;
|
||||
this.clockDurationMs = parsed.clockTimeMin * 60 * 1000;
|
||||
}
|
||||
// Seed chat messages from demoValues
|
||||
for (const rawLine of parsed.chatMessages) {
|
||||
const segments = parseColorSegments(rawLine);
|
||||
|
|
@ -912,12 +939,14 @@ class StreamingPlayback extends StreamEngine {
|
|||
: null;
|
||||
|
||||
let teamScores: TeamScore[];
|
||||
let playerRoster: PlayerRosterEntry[];
|
||||
if (
|
||||
prev &&
|
||||
prev.teamScoresGen === this._teamScoresGen &&
|
||||
prev.rosterGen === this._rosterGen
|
||||
) {
|
||||
teamScores = prev.teamScores;
|
||||
playerRoster = prev.playerRoster;
|
||||
} else {
|
||||
teamScores = this.teamScores.map((ts) => ({ ...ts }));
|
||||
const teamCounts = new Map<number, number>();
|
||||
|
|
@ -929,12 +958,17 @@ class StreamingPlayback extends StreamEngine {
|
|||
for (const ts of teamScores) {
|
||||
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
|
||||
}
|
||||
playerRoster = [];
|
||||
for (const [clientId, entry] of this.playerRoster) {
|
||||
playerRoster.push({ clientId, ...entry });
|
||||
}
|
||||
}
|
||||
|
||||
this._snap = {
|
||||
teamScoresGen: this._teamScoresGen,
|
||||
rosterGen: this._rosterGen,
|
||||
teamScores,
|
||||
playerRoster,
|
||||
weaponsHudGen: this._weaponsHudGen,
|
||||
weaponsHud,
|
||||
inventoryHudGen: this._inventoryHudGen,
|
||||
|
|
@ -959,6 +993,9 @@ class StreamingPlayback extends StreamEngine {
|
|||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
playerRoster,
|
||||
connectedClientId: this.connectedClientId,
|
||||
matchClockMs: this.computeMatchClockMs(timeSec),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1026,6 +1063,7 @@ export async function createDemoStreamingRecording(
|
|||
playback.gameClassName = info.gameClassName;
|
||||
playback.serverDisplayName = info.serverDisplayName;
|
||||
playback.connectedPlayerName = info.recorderName;
|
||||
playback.connectedClientId = info.recorderClientId;
|
||||
|
||||
return {
|
||||
source: "demo",
|
||||
|
|
|
|||
|
|
@ -320,6 +320,11 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
}
|
||||
}
|
||||
|
||||
/** Request updated scores from the server (triggers MsgPlayerScore messages). */
|
||||
requestScores(): void {
|
||||
this.relay.sendCommand("getScores", []);
|
||||
}
|
||||
|
||||
/** Get the player list (for observer cycling UI). */
|
||||
getPlayerList(): PlayerListEntry[] {
|
||||
const entries: PlayerListEntry[] = [];
|
||||
|
|
@ -608,7 +613,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
const entities = this.buildEntityList();
|
||||
const timeSec = this.currentTimeSec;
|
||||
const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec);
|
||||
const { weaponsHud, inventoryHud, backpackHud, teamScores } =
|
||||
const { weaponsHud, inventoryHud, backpackHud, teamScores, playerRoster } =
|
||||
this.buildHudState();
|
||||
|
||||
// Default observer camera if none exists
|
||||
|
|
@ -636,6 +641,9 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
playerRoster,
|
||||
connectedClientId: this.connectedClientId,
|
||||
matchClockMs: this.computeMatchClockMs(timeSec),
|
||||
};
|
||||
|
||||
this._snapshot = snapshot;
|
||||
|
|
|
|||
|
|
@ -219,6 +219,15 @@ export interface TeamScore {
|
|||
playerCount: number;
|
||||
}
|
||||
|
||||
export interface PlayerRosterEntry {
|
||||
clientId: number;
|
||||
name: string;
|
||||
teamId: number;
|
||||
score: number;
|
||||
ping: number;
|
||||
packetLoss: number;
|
||||
}
|
||||
|
||||
export interface BackpackHudState {
|
||||
/** Index into the $BackpackHudData table, or -1 if no pack. */
|
||||
packIndex: number;
|
||||
|
|
@ -269,6 +278,14 @@ export interface StreamSnapshot {
|
|||
};
|
||||
/** Team scores aggregated from the PLAYERLIST demoValues section. */
|
||||
teamScores: TeamScore[];
|
||||
/** Player roster from MsgClientJoin / MsgPlayerScore messages. */
|
||||
playerRoster: PlayerRosterEntry[];
|
||||
/** Client ID of the connected/recording player, for highlighting in roster. */
|
||||
connectedClientId: number | null;
|
||||
/** Match clock value in milliseconds, mirroring HudClockCtrl's actualTimeMS.
|
||||
* Negative = counting down (remaining time), positive = counting up (elapsed).
|
||||
* Null if no clock has been set. Pauses/seeks with playback. */
|
||||
matchClockMs: number | null;
|
||||
}
|
||||
|
||||
export interface StreamingPlayback {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue