@cs2dak/react 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/package.json +26 -0
- package/src/components/DemoAnalysisDashboard.tsx +178 -0
- package/src/components/EconomyConversionPanel.tsx +54 -0
- package/src/components/EconomyPanel.tsx +158 -0
- package/src/components/HeatmapCanvas.tsx +351 -0
- package/src/components/KillFeed.tsx +42 -0
- package/src/components/MatchWorkspace.test.ts +110 -0
- package/src/components/MatchWorkspace.tsx +643 -0
- package/src/components/QaReportPanel.tsx +31 -0
- package/src/components/RoundTimeline.tsx +35 -0
- package/src/components/ScoreboardTable.test.ts +47 -0
- package/src/components/ScoreboardTable.tsx +80 -0
- package/src/index.ts +9 -0
- package/src/theme.css +1721 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import type { MatchWorkspaceModel, WorkspaceReplayFrame, WorkspaceReplayRound, WorkspaceSpatialPoint } from "@cs2dak/contract";
|
|
2
|
+
import { displayWeaponName } from "@cs2dak/core";
|
|
3
|
+
import { getMapCalibration, worldToRadar } from "@cs2dak/maps";
|
|
4
|
+
import { Activity, BarChart3, ChevronLeft, ChevronRight, Crosshair, Film, Gauge, ListChecks, Map, Pause, Play, ShieldCheck, Table2, Users } from "lucide-react";
|
|
5
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
6
|
+
import { EconomyPanel } from "./EconomyPanel";
|
|
7
|
+
import { HeatmapCanvas } from "./HeatmapCanvas";
|
|
8
|
+
import { KillFeed } from "./KillFeed";
|
|
9
|
+
import { ScoreboardTable } from "./ScoreboardTable";
|
|
10
|
+
|
|
11
|
+
export interface MatchWorkspaceProps {
|
|
12
|
+
model: MatchWorkspaceModel;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WorkspaceView = MatchWorkspaceModel["tabs"][number]["key"];
|
|
16
|
+
type HeatmapLayer = Extract<WorkspaceSpatialPoint["kind"], "death" | "kill" | "grenade">;
|
|
17
|
+
|
|
18
|
+
export function MatchWorkspace({ model }: MatchWorkspaceProps) {
|
|
19
|
+
const [view, setView] = useState<WorkspaceView>("overview");
|
|
20
|
+
const replayDetail = model.replay.available
|
|
21
|
+
? `${model.replay.sampleRate ?? 0} Hz · ${model.replay.rounds.length} 回合`
|
|
22
|
+
: "无回放流";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<main className="dak-shell">
|
|
26
|
+
<div className="dak-workspace">
|
|
27
|
+
<header className="dak-header dak-workspace-header">
|
|
28
|
+
<div>
|
|
29
|
+
<div className="dak-eyebrow">Match Workspace</div>
|
|
30
|
+
<h1 className="dak-title">{model.title}</h1>
|
|
31
|
+
<p className="dak-subtitle">{model.subtitle}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="dak-scoreblock">
|
|
34
|
+
<div className="dak-scoreline">{model.scoreline}</div>
|
|
35
|
+
<div className="dak-mapline">{model.mapName}</div>
|
|
36
|
+
</div>
|
|
37
|
+
</header>
|
|
38
|
+
|
|
39
|
+
<section className="dak-kpi-strip" aria-label="Match KPIs">
|
|
40
|
+
{model.overview.kpis.map((kpi) => (
|
|
41
|
+
<Metric key={kpi.key} icon={iconForKpi(kpi.key)} label={kpi.label} value={kpi.value} detail={kpi.detail} />
|
|
42
|
+
))}
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<div className="dak-workbench">
|
|
46
|
+
<aside className="dak-workbench-sidebar">
|
|
47
|
+
<nav className="dak-tabs" aria-label="Match workspace views">
|
|
48
|
+
{model.tabs.map((tab) => (
|
|
49
|
+
<button
|
|
50
|
+
key={tab.key}
|
|
51
|
+
className={view === tab.key ? "dak-tab dak-tab-active" : "dak-tab"}
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => setView(tab.key)}
|
|
54
|
+
>
|
|
55
|
+
{iconForTab(tab.key)}
|
|
56
|
+
<span>{tab.label}</span>
|
|
57
|
+
<small>{detailForTab(tab.key, model, replayDetail)}</small>
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</nav>
|
|
61
|
+
</aside>
|
|
62
|
+
|
|
63
|
+
<section className="dak-workbench-main">
|
|
64
|
+
{view === "overview" && <OverviewView model={model} onNavigate={setView} />}
|
|
65
|
+
{view === "rounds" && <RoundExplorer model={model} />}
|
|
66
|
+
{view === "players" && <PlayerStoryPanel model={model} />}
|
|
67
|
+
{view === "economy" && (
|
|
68
|
+
<Panel title="经济走势">
|
|
69
|
+
<EconomyPanel points={model.economy} teamAName={model.teams.teamA.name} teamBName={model.teams.teamB.name} />
|
|
70
|
+
</Panel>
|
|
71
|
+
)}
|
|
72
|
+
{view === "map" && <MapWorkspace model={model} />}
|
|
73
|
+
{view === "replay" && <ReplayViewer replay={model.replay} map={model.map.view} />}
|
|
74
|
+
</section>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</main>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function OverviewView({ model, onNavigate }: MatchWorkspaceProps & { onNavigate: (view: WorkspaceView) => void }) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="dak-grid">
|
|
84
|
+
<section className="dak-stack">
|
|
85
|
+
<Panel title="比赛主线">
|
|
86
|
+
<div className="dak-story-list">
|
|
87
|
+
{model.overview.story.map((line) => (
|
|
88
|
+
<p key={line}>{line}</p>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</Panel>
|
|
92
|
+
<Panel title="选手数据 / RR">
|
|
93
|
+
<ScoreboardTable rows={model.scoreboard} />
|
|
94
|
+
</Panel>
|
|
95
|
+
</section>
|
|
96
|
+
<aside className="dak-stack">
|
|
97
|
+
<Panel title="模块入口">
|
|
98
|
+
<div className="dak-module-actions">
|
|
99
|
+
<ModuleAction icon={<ListChecks size={16} />} label="回合浏览" value={`${model.rounds.length} 回合`} detail="横向 timeline + selected round events" onClick={() => onNavigate("rounds")} />
|
|
100
|
+
<ModuleAction icon={<Users size={16} />} label="选手视角" value={`${model.players.length} 名选手`} detail="RR breakdown + round facts" onClick={() => onNavigate("players")} />
|
|
101
|
+
<ModuleAction icon={<Map size={16} />} label="地图图层" value={`${model.map.points.length} 点`} detail={model.map.status.message ?? "kill/death/grenade layers"} onClick={() => onNavigate("map")} />
|
|
102
|
+
<ModuleAction icon={<Film size={16} />} label="2D 回放" value={model.replay.available ? `${model.replay.sampleRate ?? 0} Hz` : "无回放"} detail={model.replay.capabilities.hasDefuseKit ? "含拆弹器状态" : "无拆弹器状态"} onClick={() => onNavigate("replay")} />
|
|
103
|
+
</div>
|
|
104
|
+
</Panel>
|
|
105
|
+
<Panel title="地图状态">
|
|
106
|
+
<MapModeList model={model} />
|
|
107
|
+
</Panel>
|
|
108
|
+
</aside>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function RoundExplorer({ model }: MatchWorkspaceProps) {
|
|
114
|
+
const [selectedRound, setSelectedRound] = useState(model.rounds[0]?.roundNumber ?? 1);
|
|
115
|
+
const [showAllEvents, setShowAllEvents] = useState(false);
|
|
116
|
+
const round = model.rounds.find((row) => row.roundNumber === selectedRound) ?? model.rounds[0];
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
setShowAllEvents(false);
|
|
120
|
+
}, [selectedRound]);
|
|
121
|
+
|
|
122
|
+
if (!round) {
|
|
123
|
+
return <Panel title="回合浏览"><p className="dak-muted">暂无回合数据</p></Panel>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const eventLimit = 28;
|
|
127
|
+
const visibleEvents = showAllEvents ? round.events : round.events.slice(0, eventLimit);
|
|
128
|
+
const hiddenEventCount = round.events.length - visibleEvents.length;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="dak-selection-layout">
|
|
132
|
+
<Panel title="回合时间线">
|
|
133
|
+
<div className="dak-round-pills">
|
|
134
|
+
{model.rounds.map((row) => (
|
|
135
|
+
<button
|
|
136
|
+
key={row.roundNumber}
|
|
137
|
+
className={row.roundNumber === round.roundNumber ? "dak-round-pill dak-round-pill-active" : "dak-round-pill"}
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => setSelectedRound(row.roundNumber)}
|
|
140
|
+
>
|
|
141
|
+
<span>R{row.roundNumber}</span>
|
|
142
|
+
<b>{row.winnerSide.toUpperCase()}</b>
|
|
143
|
+
<small>{row.scoreBefore}</small>
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
</Panel>
|
|
148
|
+
<Panel title={`R${round.roundNumber} 详情`}>
|
|
149
|
+
<div className="dak-round-detail">
|
|
150
|
+
<div className="dak-fact-grid">
|
|
151
|
+
<Fact label="比分" value={round.scoreBefore} />
|
|
152
|
+
<Fact label="胜方" value={round.winnerSide.toUpperCase()} />
|
|
153
|
+
<Fact label="A 队经济" value={round.teamAEconomy} />
|
|
154
|
+
<Fact label="B 队经济" value={round.teamBEconomy} />
|
|
155
|
+
</div>
|
|
156
|
+
<div className="dak-timeline">
|
|
157
|
+
{visibleEvents.map((event) => (
|
|
158
|
+
<div className="dak-timeline-row" key={event.id}>
|
|
159
|
+
<span className="dak-mono dak-muted">{event.clockLabel}</span>
|
|
160
|
+
<span className="dak-badge">{event.type}</span>
|
|
161
|
+
<span>{event.label}</span>
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
{hiddenEventCount > 0 && (
|
|
165
|
+
<button className="dak-timeline-more" type="button" onClick={() => setShowAllEvents(true)}>
|
|
166
|
+
展开剩余 {hiddenEventCount} 条事件
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
{showAllEvents && round.events.length > eventLimit && (
|
|
170
|
+
<button className="dak-timeline-more" type="button" onClick={() => setShowAllEvents(false)}>
|
|
171
|
+
收起到前 {eventLimit} 条
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</Panel>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function PlayerStoryPanel({ model }: MatchWorkspaceProps) {
|
|
182
|
+
const [selectedSteamId, setSelectedSteamId] = useState(model.players[0]?.row.steamId64 ?? "");
|
|
183
|
+
const selected = model.players.find((player) => player.row.steamId64 === selectedSteamId) ?? model.players[0];
|
|
184
|
+
|
|
185
|
+
if (!selected) {
|
|
186
|
+
return <Panel title="选手视角"><p className="dak-muted">暂无选手数据</p></Panel>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const roundSummary = summarizePlayerRoundFacts(selected.roundFacts);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className="dak-selection-layout">
|
|
193
|
+
<Panel title="选手列表">
|
|
194
|
+
<div className="dak-player-list">
|
|
195
|
+
{model.players.map((player) => (
|
|
196
|
+
<button
|
|
197
|
+
key={player.row.steamId64}
|
|
198
|
+
className={player.row.steamId64 === selected.row.steamId64 ? "dak-player-button dak-player-button-active" : "dak-player-button"}
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => setSelectedSteamId(player.row.steamId64)}
|
|
201
|
+
>
|
|
202
|
+
<span>{player.row.name}</span>
|
|
203
|
+
<b>{player.row.accountRR.toFixed(3)}</b>
|
|
204
|
+
</button>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
</Panel>
|
|
208
|
+
<Panel title={`${selected.row.name} 个人故事`}>
|
|
209
|
+
<div className="dak-story-list">
|
|
210
|
+
{selected.summary.map((line) => <p key={line}>{line}</p>)}
|
|
211
|
+
</div>
|
|
212
|
+
<div className="dak-player-round-summary">
|
|
213
|
+
<Fact label="回合" value={`${selected.roundFacts.length}`} />
|
|
214
|
+
<Fact label="存活" value={`${roundSummary.survived}`} />
|
|
215
|
+
<Fact label="首杀" value={`${roundSummary.openingKills}`} />
|
|
216
|
+
<Fact label="补枪" value={`${roundSummary.tradeKills}`} />
|
|
217
|
+
</div>
|
|
218
|
+
<div className="dak-breakdown">
|
|
219
|
+
{selected.rrBreakdown.map((part) => (
|
|
220
|
+
<div className="dak-breakdown-row" key={part.key}>
|
|
221
|
+
<span>{part.label}</span>
|
|
222
|
+
<meter min="-1" max="1" value={Math.max(-1, Math.min(1, part.value))} />
|
|
223
|
+
<b className="dak-mono">{part.value.toFixed(3)}</b>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
{selected.roundFacts.length > 0 && (
|
|
228
|
+
<div className="dak-player-roundfacts">
|
|
229
|
+
{selected.roundFacts.slice(0, 18).map((fact) => (
|
|
230
|
+
<article className="dak-player-round-card" key={`${fact.steamId64}-${fact.roundNumber}`}>
|
|
231
|
+
<div className="dak-player-round-head">
|
|
232
|
+
<span className="dak-badge">R{fact.roundNumber}</span>
|
|
233
|
+
<span>{sideLabel(fact.side)}</span>
|
|
234
|
+
<b className="dak-mono">{fact.kills}/{fact.deaths}/{fact.assists}</b>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="dak-player-round-meta">
|
|
237
|
+
<span>{economyLabel(fact.economyType)}</span>
|
|
238
|
+
<span>{fact.survived ? "存活" : "阵亡"}</span>
|
|
239
|
+
{fact.openingDuel !== "none" && <span>{openingDuelLabel(fact.openingDuel)}</span>}
|
|
240
|
+
</div>
|
|
241
|
+
<div className="dak-player-round-tags">
|
|
242
|
+
{roundFactTags(fact).map((tag) => <span key={tag}>{tag}</span>)}
|
|
243
|
+
</div>
|
|
244
|
+
</article>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</Panel>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function MapWorkspace({ model }: MatchWorkspaceProps) {
|
|
254
|
+
const renderableLayers = model.map.modes.filter((mode): mode is typeof mode & { key: HeatmapLayer } => (
|
|
255
|
+
mode.key === "death" || mode.key === "kill" || mode.key === "grenade"
|
|
256
|
+
));
|
|
257
|
+
const [layer, setLayer] = useState<HeatmapLayer>(renderableLayers[0]?.key ?? "death");
|
|
258
|
+
const heatmapPoints = model.map.points
|
|
259
|
+
.filter((point): point is WorkspaceSpatialPoint & { kind: "kill" | "death" | "grenade" } => (
|
|
260
|
+
point.kind === "kill" || point.kind === "death" || point.kind === "grenade"
|
|
261
|
+
));
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="dak-grid dak-grid-even">
|
|
265
|
+
<Panel title="地图热力图">
|
|
266
|
+
<HeatmapCanvas
|
|
267
|
+
map={model.map.view}
|
|
268
|
+
points={heatmapPoints}
|
|
269
|
+
players={model.players.map((p) => ({ steamId64: p.row.steamId64, name: p.row.name, teamKey: p.row.teamKey }))}
|
|
270
|
+
mode={layer}
|
|
271
|
+
onModeChange={setLayer}
|
|
272
|
+
/>
|
|
273
|
+
</Panel>
|
|
274
|
+
<Panel title="空间图层">
|
|
275
|
+
<div className="dak-layer-controls" role="radiogroup" aria-label="地图图层">
|
|
276
|
+
{renderableLayers.map((mode) => (
|
|
277
|
+
<button
|
|
278
|
+
key={mode.key}
|
|
279
|
+
type="button"
|
|
280
|
+
role="radio"
|
|
281
|
+
aria-checked={layer === mode.key}
|
|
282
|
+
className={layer === mode.key ? "dak-layer-button dak-layer-button-active" : "dak-layer-button"}
|
|
283
|
+
onClick={() => setLayer(mode.key)}
|
|
284
|
+
>
|
|
285
|
+
<span>{mode.label}</span>
|
|
286
|
+
<b className="dak-mono">{mode.count}</b>
|
|
287
|
+
</button>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
<MapPendingList model={model} renderedKinds={["death", "kill", "grenade"]} />
|
|
291
|
+
{model.map.status.message && <p className="dak-muted dak-note">{model.map.status.message}</p>}
|
|
292
|
+
</Panel>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ReplayViewer({ replay, map }: { replay: MatchWorkspaceModel["replay"]; map: MatchWorkspaceModel["map"]["view"] }) {
|
|
298
|
+
const [roundNumber, setRoundNumber] = useState(replay.rounds[0]?.roundNumber ?? 1);
|
|
299
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
300
|
+
const [playing, setPlaying] = useState(false);
|
|
301
|
+
const [speed, setSpeed] = useState(1);
|
|
302
|
+
const round = replay.rounds.find((row) => row.roundNumber === roundNumber) ?? replay.rounds[0];
|
|
303
|
+
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
setFrameIndex(0);
|
|
306
|
+
setPlaying(false);
|
|
307
|
+
}, [roundNumber]);
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!playing || !round || round.frameCount <= 1) return undefined;
|
|
311
|
+
const msPerFrame = Math.max(35, 1000 / ((replay.sampleRate ?? 8) * speed));
|
|
312
|
+
const timer = window.setInterval(() => {
|
|
313
|
+
setFrameIndex((value) => {
|
|
314
|
+
if (value >= round.frameCount - 1) {
|
|
315
|
+
setPlaying(false);
|
|
316
|
+
return round.frameCount - 1;
|
|
317
|
+
}
|
|
318
|
+
return value + 1;
|
|
319
|
+
});
|
|
320
|
+
}, msPerFrame);
|
|
321
|
+
return () => window.clearInterval(timer);
|
|
322
|
+
}, [playing, replay.sampleRate, round?.frameCount, speed]);
|
|
323
|
+
|
|
324
|
+
// Stable per-player numbers: teamA → 1-5, teamB → 6-0.
|
|
325
|
+
// Computed from round.players order so the mapping is consistent across frames.
|
|
326
|
+
const playerNumbers = useMemo(() => {
|
|
327
|
+
const result: Record<string, string> = {};
|
|
328
|
+
if (!round) return result;
|
|
329
|
+
round.players.filter((p) => p.teamKey === "teamA").forEach((p, i) => { result[p.steamId64] = String(i + 1); });
|
|
330
|
+
round.players.filter((p) => p.teamKey === "teamB").forEach((p, i) => { result[p.steamId64] = String((i + 6) % 10); });
|
|
331
|
+
return result;
|
|
332
|
+
}, [round]);
|
|
333
|
+
|
|
334
|
+
if (!replay.available || !round) {
|
|
335
|
+
return <Panel title="2D 回放"><p className="dak-muted">该导出包不含回放流。</p></Panel>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const currentFrameIndex = Math.min(frameIndex, Math.max(round.frameCount - 1, 0));
|
|
339
|
+
const currentTick = round.startTick + currentFrameIndex * round.tickStep;
|
|
340
|
+
const currentPlayers = round.players
|
|
341
|
+
.map((player) => ({ player, frame: player.frames[currentFrameIndex] ?? player.frames.find((frame) => frame.alive) ?? player.frames[0] }))
|
|
342
|
+
.filter((row): row is { player: WorkspaceReplayRound["players"][number]; frame: WorkspaceReplayFrame } => Boolean(row.frame));
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<div className="dak-replay-layout">
|
|
346
|
+
<Panel title="回放控制">
|
|
347
|
+
<div className="dak-round-pills">
|
|
348
|
+
{replay.rounds.map((row) => (
|
|
349
|
+
<button
|
|
350
|
+
key={row.roundNumber}
|
|
351
|
+
className={row.roundNumber === round.roundNumber ? "dak-round-pill dak-round-pill-active" : "dak-round-pill"}
|
|
352
|
+
type="button"
|
|
353
|
+
onClick={() => setRoundNumber(row.roundNumber)}
|
|
354
|
+
>
|
|
355
|
+
<span>R{row.roundNumber}</span>
|
|
356
|
+
<small>{row.frameCount} 帧</small>
|
|
357
|
+
</button>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
<div className="dak-playback">
|
|
361
|
+
<button className="dak-play-button" type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? "暂停" : "播放"}>
|
|
362
|
+
{playing ? <Pause size={18} /> : <Play size={18} />}
|
|
363
|
+
</button>
|
|
364
|
+
<button className="dak-icon-button" type="button" onClick={() => setFrameIndex((value) => Math.max(0, value - Math.max(1, Math.round((replay.sampleRate ?? 8) / 2))))} aria-label="后退">
|
|
365
|
+
<ChevronLeft size={17} />
|
|
366
|
+
</button>
|
|
367
|
+
<input
|
|
368
|
+
className="dak-scrubber"
|
|
369
|
+
type="range"
|
|
370
|
+
min={0}
|
|
371
|
+
max={Math.max(round.frameCount - 1, 0)}
|
|
372
|
+
value={currentFrameIndex}
|
|
373
|
+
onChange={(event) => setFrameIndex(Number(event.target.value))}
|
|
374
|
+
/>
|
|
375
|
+
<button className="dak-icon-button" type="button" onClick={() => setFrameIndex((value) => Math.min(round.frameCount - 1, value + Math.max(1, Math.round((replay.sampleRate ?? 8) / 2))))} aria-label="前进">
|
|
376
|
+
<ChevronRight size={17} />
|
|
377
|
+
</button>
|
|
378
|
+
</div>
|
|
379
|
+
<div className="dak-speed-group" role="group" aria-label="播放速度">
|
|
380
|
+
{[0.5, 1, 2, 4].map((nextSpeed) => (
|
|
381
|
+
<button
|
|
382
|
+
key={nextSpeed}
|
|
383
|
+
type="button"
|
|
384
|
+
className={speed === nextSpeed ? "dak-speed dak-speed-active" : "dak-speed"}
|
|
385
|
+
onClick={() => setSpeed(nextSpeed)}
|
|
386
|
+
>
|
|
387
|
+
{nextSpeed}x
|
|
388
|
+
</button>
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
<div className="dak-replay-meta">
|
|
392
|
+
<Fact label="Tick" value={`${currentTick}`} />
|
|
393
|
+
<Fact label="帧" value={`${currentFrameIndex + 1}/${round.frameCount}`} />
|
|
394
|
+
<Fact label="采样率" value={`${replay.sampleRate ?? 0} Hz`} />
|
|
395
|
+
<Fact label="拆弹器" value={replay.capabilities.hasDefuseKit ? "可显示" : "无状态"} />
|
|
396
|
+
<Fact label="C4 位置" value={replay.capabilities.hasBombPosition ? "可显示" : "暂不显示"} />
|
|
397
|
+
</div>
|
|
398
|
+
</Panel>
|
|
399
|
+
<Panel title={`R${round.roundNumber} 2D 回放`}>
|
|
400
|
+
<div
|
|
401
|
+
className="dak-replay-stage"
|
|
402
|
+
style={map.radarImageUrl ? { backgroundImage: `url(${map.radarImageUrl})` } : undefined}
|
|
403
|
+
>
|
|
404
|
+
<div className="dak-replay-gridlines" aria-hidden="true" />
|
|
405
|
+
<KillFeed kills={round.kills} currentTick={currentTick} tickrate={replay.tickrate} />
|
|
406
|
+
{currentPlayers.map(({ player, frame }) => (
|
|
407
|
+
<div
|
|
408
|
+
key={player.steamId64}
|
|
409
|
+
className={`dak-replay-token dak-replay-token-${player.teamKey}${!frame.alive ? " dak-replay-token-dead" : ""}${frame.flashed ? " dak-replay-token-flashed" : ""}`}
|
|
410
|
+
style={{ ...replayFramePosition(frame, map), transform: `translate(-50%, -50%) rotate(${90 - frame.yaw}deg)` }}
|
|
411
|
+
title={`${playerNumbers[player.steamId64] ?? "?"} ${player.name} · ${frame.hp} HP${frame.hasDefuseKit ? " · 拆弹器" : ""}${frame.flashed ? " · flashed" : ""}`}
|
|
412
|
+
>
|
|
413
|
+
<span style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>{playerNumbers[player.steamId64] ?? "?"}</span>
|
|
414
|
+
{frame.hasDefuseKit && <i style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>kit</i>}
|
|
415
|
+
</div>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</Panel>
|
|
419
|
+
<Panel title="当前帧选手">
|
|
420
|
+
<div className="dak-frame-player-list">
|
|
421
|
+
{[...currentPlayers]
|
|
422
|
+
.sort((a, b) => {
|
|
423
|
+
// Sort by assigned player number; treat 0 (teamB slot 5) as 10 so order is 1-9,0
|
|
424
|
+
const na = Number(playerNumbers[a.player.steamId64] ?? "99");
|
|
425
|
+
const nb = Number(playerNumbers[b.player.steamId64] ?? "99");
|
|
426
|
+
return (na === 0 ? 10 : na) - (nb === 0 ? 10 : nb);
|
|
427
|
+
})
|
|
428
|
+
.map(({ player, frame }) => (
|
|
429
|
+
<div
|
|
430
|
+
className={`dak-frame-player-row${!frame.alive ? " dak-frame-player-row-dead" : ""}`}
|
|
431
|
+
key={player.steamId64}
|
|
432
|
+
>
|
|
433
|
+
<span className={`dak-team-dot dak-team-dot-${player.teamKey}`} />
|
|
434
|
+
<span className="dak-frame-player-num">{playerNumbers[player.steamId64] ?? "?"}</span>
|
|
435
|
+
<span className="dak-frame-player-name">{player.name}</span>
|
|
436
|
+
<div className="dak-hp-bar-wrap" title={`${frame.hp} HP`}>
|
|
437
|
+
<div className="dak-hp-bar" style={{ width: `${frame.hp}%`, background: hpBarColor(frame.hp) }} />
|
|
438
|
+
</div>
|
|
439
|
+
<small>
|
|
440
|
+
{frame.alive
|
|
441
|
+
? `${frame.weapon ? displayWeaponName(frame.weapon) : "—"}${frame.hasDefuseKit ? " · kit" : ""}${frame.flashed ? " · flashed" : ""}`
|
|
442
|
+
: "阵亡"}
|
|
443
|
+
</small>
|
|
444
|
+
</div>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
</Panel>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function MapModeList({ model, mutedKinds = [] }: MatchWorkspaceProps & { mutedKinds?: WorkspaceSpatialPoint["kind"][] }) {
|
|
453
|
+
return (
|
|
454
|
+
<div className="dak-mode-list">
|
|
455
|
+
{model.map.modes.map((mode) => (
|
|
456
|
+
<div className={mutedKinds.includes(mode.key) ? "dak-mode-row dak-mode-row-muted" : "dak-mode-row"} key={mode.key}>
|
|
457
|
+
<span>{mode.label}</span>
|
|
458
|
+
<b className="dak-mono">{mode.count}</b>
|
|
459
|
+
</div>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function MapPendingList({ model, renderedKinds }: MatchWorkspaceProps & { renderedKinds: WorkspaceSpatialPoint["kind"][] }) {
|
|
466
|
+
const pending = model.map.modes.filter((mode) => !renderedKinds.includes(mode.key));
|
|
467
|
+
if (pending.length === 0) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
return (
|
|
471
|
+
<div className="dak-pending-layers">
|
|
472
|
+
<div className="dak-panel-eyebrow">暂未渲染为交互图层</div>
|
|
473
|
+
{pending.map((mode) => (
|
|
474
|
+
<div className="dak-mode-row dak-mode-row-disabled" key={mode.key} aria-disabled="true">
|
|
475
|
+
<span>{mode.label}</span>
|
|
476
|
+
<b className="dak-mono">{mode.count}</b>
|
|
477
|
+
</div>
|
|
478
|
+
))}
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function ModuleAction({ icon, label, value, detail, onClick }: { icon: ReactNode; label: string; value: string; detail: string; onClick: () => void }) {
|
|
484
|
+
return (
|
|
485
|
+
<button className="dak-module-action" type="button" onClick={onClick}>
|
|
486
|
+
<div className="dak-module-icon">{icon}</div>
|
|
487
|
+
<div>
|
|
488
|
+
<b>{label}</b>
|
|
489
|
+
<span>{value}</span>
|
|
490
|
+
<small>{detail}</small>
|
|
491
|
+
</div>
|
|
492
|
+
</button>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function Metric({ icon, label, value, detail }: { icon: ReactNode; label: string; value: string; detail: string }) {
|
|
497
|
+
return (
|
|
498
|
+
<div className="dak-metric">
|
|
499
|
+
<div className="dak-metric-icon">{icon}</div>
|
|
500
|
+
<div>
|
|
501
|
+
<div className="dak-metric-label">{label}</div>
|
|
502
|
+
<div className="dak-metric-value">{value}</div>
|
|
503
|
+
<div className="dak-metric-detail">{detail}</div>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function Panel({ title, eyebrow, children }: { title: string; eyebrow?: string; children: ReactNode }) {
|
|
510
|
+
return (
|
|
511
|
+
<section className="dak-panel">
|
|
512
|
+
<div className="dak-panel-header">
|
|
513
|
+
<div>
|
|
514
|
+
{eyebrow && <div className="dak-panel-eyebrow">{eyebrow}</div>}
|
|
515
|
+
<h2 className="dak-panel-title">{title}</h2>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
<div className="dak-panel-body">{children}</div>
|
|
519
|
+
</section>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function Fact({ label, value }: { label: string; value: string }) {
|
|
524
|
+
return (
|
|
525
|
+
<div className="dak-fact">
|
|
526
|
+
<span>{label}</span>
|
|
527
|
+
<b>{value}</b>
|
|
528
|
+
</div>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function iconForKpi(key: string) {
|
|
533
|
+
if (key === "topRR") return <Gauge size={16} />;
|
|
534
|
+
if (key === "topADR") return <Crosshair size={16} />;
|
|
535
|
+
if (key === "rounds") return <ListChecks size={16} />;
|
|
536
|
+
return <Activity size={16} />;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function iconForTab(key: WorkspaceView) {
|
|
540
|
+
if (key === "overview") return <Table2 size={16} />;
|
|
541
|
+
if (key === "rounds") return <ListChecks size={16} />;
|
|
542
|
+
if (key === "players") return <Users size={16} />;
|
|
543
|
+
if (key === "economy") return <BarChart3 size={16} />;
|
|
544
|
+
if (key === "map") return <Map size={16} />;
|
|
545
|
+
return <Film size={16} />;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function detailForTab(key: WorkspaceView, model: MatchWorkspaceModel, replayDetail: string) {
|
|
549
|
+
if (key === "overview") return model.scoreline;
|
|
550
|
+
if (key === "rounds") return `${model.rounds.length} rounds`;
|
|
551
|
+
if (key === "players") return `${model.players.length} players`;
|
|
552
|
+
if (key === "economy") return `${model.economy.length} rows`;
|
|
553
|
+
if (key === "map") return `${model.map.points.length} points`;
|
|
554
|
+
return replayDetail;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function summarizePlayerRoundFacts(facts: MatchWorkspaceModel["players"][number]["roundFacts"]) {
|
|
558
|
+
return facts.reduce(
|
|
559
|
+
(summary, fact) => ({
|
|
560
|
+
survived: summary.survived + (fact.survived ? 1 : 0),
|
|
561
|
+
openingKills: summary.openingKills + (fact.openingDuel === "won" ? 1 : 0),
|
|
562
|
+
tradeKills: summary.tradeKills + fact.tradeKills
|
|
563
|
+
}),
|
|
564
|
+
{ survived: 0, openingKills: 0, tradeKills: 0 }
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function sideLabel(side: string) {
|
|
569
|
+
return side === "t" ? "进攻方" : "防守方";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function economyLabel(type: string | null) {
|
|
573
|
+
const labels: Record<string, string> = {
|
|
574
|
+
pistol: "手枪局",
|
|
575
|
+
eco: "ECO",
|
|
576
|
+
semi: "半起",
|
|
577
|
+
force: "强起",
|
|
578
|
+
full: "长枪",
|
|
579
|
+
conversion: "长枪"
|
|
580
|
+
};
|
|
581
|
+
return type ? labels[type] ?? type : "未知经济";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function openingDuelLabel(openingDuel: string) {
|
|
585
|
+
if (openingDuel === "won") return "首杀";
|
|
586
|
+
if (openingDuel === "lost") return "首死";
|
|
587
|
+
return openingDuel;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function roundFactTags(fact: MatchWorkspaceModel["players"][number]["roundFacts"][number]) {
|
|
591
|
+
const labels: Record<string, string> = {
|
|
592
|
+
kill: "击杀",
|
|
593
|
+
assist: "助攻",
|
|
594
|
+
survive: "存活",
|
|
595
|
+
trade: "被补枪"
|
|
596
|
+
};
|
|
597
|
+
const tags = fact.kastTags.map((tag) => labels[tag] ?? tag);
|
|
598
|
+
if (fact.tradeKills > 0) tags.push(`补枪 +${fact.tradeKills}`);
|
|
599
|
+
if (fact.tradedDeaths > 0) tags.push("死亡被补");
|
|
600
|
+
if (fact.flashAssists > 0) tags.push(`闪助 +${fact.flashAssists}`);
|
|
601
|
+
return tags.length > 0 ? tags : ["无 KAST"];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function hpBarColor(hp: number): string {
|
|
605
|
+
if (hp > 60) return "var(--dak-ok)";
|
|
606
|
+
if (hp > 30) return "var(--dak-warn)";
|
|
607
|
+
return "var(--dak-danger)";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function replayFramePosition(frame: WorkspaceReplayFrame, map: MatchWorkspaceModel["map"]["view"]) {
|
|
611
|
+
const calibration = getMapCalibration(map.name);
|
|
612
|
+
if (calibration) {
|
|
613
|
+
const radar = worldToRadar(frame, calibration);
|
|
614
|
+
if (!radar.outOfBounds) {
|
|
615
|
+
return {
|
|
616
|
+
left: `${(radar.x / calibration.radarSize) * 100}%`,
|
|
617
|
+
top: `${(radar.y / calibration.radarSize) * 100}%`
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
left: `${Math.max(4, Math.min(96, 50 + frame.x / 70))}%`,
|
|
623
|
+
top: `${Math.max(4, Math.min(96, 50 - frame.y / 70))}%`
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function AdminQaWorkspace({ model }: MatchWorkspaceProps) {
|
|
628
|
+
return (
|
|
629
|
+
<main className="dak-shell">
|
|
630
|
+
<div className="dak-workspace">
|
|
631
|
+
<Panel title="Admin QA">
|
|
632
|
+
<div className="dak-qa-summary">
|
|
633
|
+
<ShieldCheck size={16} />
|
|
634
|
+
<span>{model.adminQa.ok ? "通过" : "需要检查"}</span>
|
|
635
|
+
<span>{model.adminQa.summary.issueCount} issue(s)</span>
|
|
636
|
+
<span>{model.adminQa.summary.errorCount} error(s)</span>
|
|
637
|
+
<span>{model.adminQa.summary.warningCount} warning(s)</span>
|
|
638
|
+
</div>
|
|
639
|
+
</Panel>
|
|
640
|
+
</div>
|
|
641
|
+
</main>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { QaReport } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
export interface QaReportPanelProps {
|
|
4
|
+
report: QaReport;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function QaReportPanel({ report }: QaReportPanelProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="dak-qa">
|
|
10
|
+
<div className="dak-qa-summary">
|
|
11
|
+
<span className={report.ok ? "dak-qa-ok" : "dak-qa-warn"}>
|
|
12
|
+
{report.ok ? "QA passed" : "QA has issues"}
|
|
13
|
+
</span>
|
|
14
|
+
<span>{report.summary.issueCount} issue(s)</span>
|
|
15
|
+
<span>{report.summary.errorCount} error(s)</span>
|
|
16
|
+
<span>{report.summary.warningCount} warning(s)</span>
|
|
17
|
+
</div>
|
|
18
|
+
{report.issues.length > 0 && (
|
|
19
|
+
<div className="dak-qa-list">
|
|
20
|
+
{report.issues.map((issue) => (
|
|
21
|
+
<div className="dak-qa-row" key={`${issue.code}-${issue.message}`}>
|
|
22
|
+
<span className="dak-badge">{issue.severity}</span>
|
|
23
|
+
<span className="dak-mono dak-muted">{issue.code}</span>
|
|
24
|
+
<span>{issue.message}</span>
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|