@cs2dak/react 1.0.0 → 1.1.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 +7 -0
- package/package.json +4 -4
- package/src/components/DataTable.tsx +272 -0
- package/src/components/EconomyPanel.tsx +13 -8
- package/src/components/EventBracket.tsx +204 -0
- package/src/components/HeatmapCanvas.tsx +26 -3
- package/src/components/KillFeed.tsx +1 -0
- package/src/components/MapRoles.test.ts +50 -0
- package/src/components/MapRoles.tsx +122 -0
- package/src/components/MatchWorkspace.test.ts +102 -6
- package/src/components/MatchWorkspace.tsx +1082 -149
- package/src/components/Pagination.tsx +54 -0
- package/src/components/Primitives.tsx +105 -0
- package/src/components/RadarFieldCanvas.test.ts +39 -0
- package/src/components/RadarFieldCanvas.tsx +322 -0
- package/src/components/ScoreboardTable.tsx +1 -1
- package/src/components/SeasonLeaderboard.test.ts +1 -1
- package/src/components/SeasonLeaderboard.tsx +9 -7
- package/src/components/TeamComparisonPanel.tsx +168 -0
- package/src/index.ts +19 -3
- package/src/theme.css +1847 -411
- package/src/components/EconomyConversionPanel.tsx +0 -53
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { MatchWorkspaceModel, WorkspaceReplayFrame, WorkspaceReplayRound, WorkspaceSpatialPoint } from "@cs2dak/contract";
|
|
2
|
-
import { displayWeaponName, sideLabel, economyLabelCn } from "@cs2dak/presentation";
|
|
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";
|
|
1
|
+
import type { MatchWorkspaceModel, WorkspaceReplayFrame, WorkspaceReplayLoadout, WorkspaceReplayRound, WorkspaceSpatialPoint } from "@cs2dak/contract";
|
|
2
|
+
import { displayWeaponName, sideLabel, economyLabelCn, buildMatchBuyQuality, buildMatchReportMarkdown, deriveReplayClock } from "@cs2dak/presentation";
|
|
3
|
+
import { getMapCalibration, worldToRadar, hasLowerLevel, levelAt, type MapLevel } from "@cs2dak/maps";
|
|
4
|
+
import { Activity, BarChart3, ChevronLeft, ChevronRight, Crosshair, Film, Gauge, ListChecks, Map as MapIcon, Pause, Play, ShieldCheck, Swords, Table2, Users } from "lucide-react";
|
|
5
5
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
6
6
|
import { EconomyPanel } from "./EconomyPanel";
|
|
7
7
|
import { HeatmapCanvas } from "./HeatmapCanvas";
|
|
@@ -10,29 +10,68 @@ import { ScoreboardTable } from "./ScoreboardTable";
|
|
|
10
10
|
|
|
11
11
|
export interface MatchWorkspaceProps {
|
|
12
12
|
model: MatchWorkspaceModel;
|
|
13
|
+
initialTarget?: { roundNumber: number; tick?: number } | null;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
type WorkspaceView = MatchWorkspaceModel["tabs"][number]["key"];
|
|
16
17
|
type HeatmapLayer = Extract<WorkspaceSpatialPoint["kind"], "death" | "kill" | "grenade">;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
/** 统计数字 → 2D 回放的跳转目标(v0.2 query-first)。 */
|
|
20
|
+
export interface ReplayTarget {
|
|
21
|
+
roundNumber: number;
|
|
22
|
+
/** 落到该 tick 附近的帧;缺省从回合开头播。 */
|
|
23
|
+
tick?: number;
|
|
24
|
+
/** 同一目标重复点击也要触发跳转,用自增序列区分。 */
|
|
25
|
+
seq: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const WORKSPACE_NAV: Array<{ key: WorkspaceView; label: string }> = [
|
|
29
|
+
{ key: "replay", label: "回放" },
|
|
30
|
+
{ key: "overview", label: "概览" },
|
|
31
|
+
{ key: "rounds", label: "回合" },
|
|
32
|
+
{ key: "players", label: "选手" },
|
|
33
|
+
{ key: "economy", label: "经济" },
|
|
34
|
+
{ key: "weapons", label: "武器" },
|
|
35
|
+
{ key: "duels", label: "对位" },
|
|
36
|
+
{ key: "map", label: "地图" }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function MatchWorkspace({ model, initialTarget }: MatchWorkspaceProps) {
|
|
40
|
+
// 顶部横向分段导航 + 单一全宽主区:一次只显示一个视图,避免宽回放与其它视图争抢宽度。
|
|
41
|
+
// 回放是默认首项(无回放流时退化为概览);EvidenceLink 跳转自动切到回放并定位 tick。
|
|
42
|
+
// 落地视图固定为概览仪表盘(KPI / 计分板 / 快捷入口);带 initialTarget 打开时才经 useEffect 跳到回放。
|
|
19
43
|
const [view, setView] = useState<WorkspaceView>("overview");
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
:
|
|
44
|
+
const [replayTarget, setReplayTarget] = useState<ReplayTarget | null>(null);
|
|
45
|
+
const openReplay = (roundNumber: number, tick?: number) => {
|
|
46
|
+
setReplayTarget((prev) => ({ roundNumber, tick, seq: (prev?.seq ?? 0) + 1 }));
|
|
47
|
+
setView("replay");
|
|
48
|
+
};
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!initialTarget) return;
|
|
51
|
+
openReplay(initialTarget.roundNumber, initialTarget.tick);
|
|
52
|
+
}, [initialTarget?.roundNumber, initialTarget?.tick]);
|
|
53
|
+
const replayHandler = model.replay.available ? openReplay : undefined;
|
|
54
|
+
const nav = model.replay.available ? WORKSPACE_NAV : WORKSPACE_NAV.filter((item) => item.key !== "replay");
|
|
23
55
|
|
|
24
56
|
return (
|
|
25
57
|
<main className="dak-shell">
|
|
26
58
|
<div className="dak-workspace">
|
|
27
59
|
<header className="dak-header dak-workspace-header">
|
|
28
60
|
<div>
|
|
29
|
-
<div className="dak-eyebrow">Match Workspace</div>
|
|
30
61
|
<h1 className="dak-title">{model.title}</h1>
|
|
31
62
|
<p className="dak-subtitle">{model.subtitle}</p>
|
|
32
63
|
</div>
|
|
33
64
|
<div className="dak-scoreblock">
|
|
34
65
|
<div className="dak-scoreline">{model.scoreline}</div>
|
|
35
66
|
<div className="dak-mapline">{model.mapName}</div>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className="dak-report-button"
|
|
70
|
+
onClick={() => downloadMatchReport(model)}
|
|
71
|
+
title="导出本场比赛报告(Markdown)"
|
|
72
|
+
>
|
|
73
|
+
导出报告
|
|
74
|
+
</button>
|
|
36
75
|
</div>
|
|
37
76
|
</header>
|
|
38
77
|
|
|
@@ -42,43 +81,46 @@ export function MatchWorkspace({ model }: MatchWorkspaceProps) {
|
|
|
42
81
|
))}
|
|
43
82
|
</section>
|
|
44
83
|
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
<nav className="dak-ws-nav" aria-label="比赛工作台视图">
|
|
85
|
+
{nav.map((item) => (
|
|
86
|
+
<button
|
|
87
|
+
key={item.key}
|
|
88
|
+
type="button"
|
|
89
|
+
className={view === item.key ? "dak-ws-navtab dak-ws-navtab-active" : "dak-ws-navtab"}
|
|
90
|
+
onClick={() => setView(item.key)}
|
|
91
|
+
>
|
|
92
|
+
{iconForTab(item.key)}
|
|
93
|
+
<span>{item.label}</span>
|
|
94
|
+
</button>
|
|
95
|
+
))}
|
|
96
|
+
</nav>
|
|
97
|
+
|
|
98
|
+
<section className="dak-ws-main">
|
|
99
|
+
{view === "replay" && model.replay.available && (
|
|
100
|
+
<ReplayViewer replay={model.replay} map={model.map.view} target={replayTarget} />
|
|
101
|
+
)}
|
|
102
|
+
{view === "overview" && <OverviewView model={model} onNavigate={setView} />}
|
|
103
|
+
{view === "rounds" && <RoundExplorer model={model} onOpenReplay={replayHandler} />}
|
|
104
|
+
{view === "players" && <PlayerStoryPanel model={model} onOpenReplay={replayHandler} />}
|
|
105
|
+
{view === "economy" && (
|
|
106
|
+
<div className="dak-stack">
|
|
68
107
|
<Panel title="经济走势">
|
|
69
|
-
<EconomyPanel points={model.economy} teamAName={model.teams.teamA.name} teamBName={model.teams.teamB.name} />
|
|
108
|
+
<EconomyPanel points={model.economy} teamAName={model.teams.teamA.name} teamBName={model.teams.teamB.name} onJumpRound={(roundNumber) => openReplay(roundNumber)} />
|
|
70
109
|
</Panel>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
110
|
+
<BuyQualityPanel model={model} />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
{view === "weapons" && <WeaponsView model={model} />}
|
|
114
|
+
{view === "duels" && <DuelsView model={model} />}
|
|
115
|
+
{view === "map" && <MapWorkspace model={model} />}
|
|
116
|
+
</section>
|
|
76
117
|
</div>
|
|
77
118
|
</main>
|
|
78
119
|
);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
function OverviewView({ model, onNavigate }: MatchWorkspaceProps & { onNavigate: (view: WorkspaceView) => void }) {
|
|
123
|
+
// 全宽 2 栏:左 = 主线 + 记分板,右 = 模块快捷入口 + 地图状态。
|
|
82
124
|
return (
|
|
83
125
|
<div className="dak-grid">
|
|
84
126
|
<section className="dak-stack">
|
|
@@ -94,12 +136,12 @@ function OverviewView({ model, onNavigate }: MatchWorkspaceProps & { onNavigate:
|
|
|
94
136
|
</Panel>
|
|
95
137
|
</section>
|
|
96
138
|
<aside className="dak-stack">
|
|
97
|
-
<Panel title="
|
|
139
|
+
<Panel title="快捷入口">
|
|
98
140
|
<div className="dak-module-actions">
|
|
99
|
-
<ModuleAction icon={<
|
|
100
|
-
<ModuleAction icon={<
|
|
101
|
-
<ModuleAction icon={<
|
|
102
|
-
<ModuleAction icon={<
|
|
141
|
+
<ModuleAction icon={<Film size={16} />} label="2D 回放" value={model.replay.available ? `${model.replay.sampleRate ?? 0} Hz` : "无回放"} detail={model.replay.available ? "走位 / 道具 / C4 时间线" : "导出时未附带回放流"} onClick={() => onNavigate("replay")} />
|
|
142
|
+
<ModuleAction icon={<ListChecks size={16} />} label="回合浏览" value={`${model.rounds.length} 回合`} detail="时间轴 + 选中回合事件" onClick={() => onNavigate("rounds")} />
|
|
143
|
+
<ModuleAction icon={<Users size={16} />} label="选手视角" value={`${model.players.length} 名选手`} detail="RR 拆解 + 逐回合事实" onClick={() => onNavigate("players")} />
|
|
144
|
+
<ModuleAction icon={<MapIcon size={16} />} label="地图图层" value={`${model.map.points.length} 点`} detail={model.map.status.message ?? "击杀 / 死亡 / 道具图层"} onClick={() => onNavigate("map")} />
|
|
103
145
|
</div>
|
|
104
146
|
</Panel>
|
|
105
147
|
<Panel title="地图状态">
|
|
@@ -110,10 +152,79 @@ function OverviewView({ model, onNavigate }: MatchWorkspaceProps & { onNavigate:
|
|
|
110
152
|
);
|
|
111
153
|
}
|
|
112
154
|
|
|
113
|
-
|
|
155
|
+
// ── 回合筛选器(v0.2 query-first 最小实现)────────────────────────────────
|
|
156
|
+
type RoundModel = MatchWorkspaceModel["rounds"][number];
|
|
157
|
+
|
|
158
|
+
interface RoundFilterState {
|
|
159
|
+
winnerSide: "all" | "ct" | "t";
|
|
160
|
+
economy: "all" | "pistol" | "eco" | "semi" | "force" | "full";
|
|
161
|
+
bombSite: "all" | "a" | "b" | "none";
|
|
162
|
+
endReason: string; // "all" 或具体 endReason
|
|
163
|
+
firstKill: "all" | "teamA" | "teamB";
|
|
164
|
+
special: { clutch: boolean; multiKill: boolean; wallbang: boolean; smoke: boolean };
|
|
165
|
+
playerSteamId64: string; // "" = 全部;匹配该选手有击杀/首杀/残局的回合
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ROUND_FILTER_DEFAULT: RoundFilterState = {
|
|
169
|
+
winnerSide: "all",
|
|
170
|
+
economy: "all",
|
|
171
|
+
bombSite: "all",
|
|
172
|
+
endReason: "all",
|
|
173
|
+
firstKill: "all",
|
|
174
|
+
special: { clutch: false, multiKill: false, wallbang: false, smoke: false },
|
|
175
|
+
playerSteamId64: ""
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const END_REASON_LABELS: Record<string, string> = {
|
|
179
|
+
target_bombed: "爆弹",
|
|
180
|
+
bomb_defused: "拆弹",
|
|
181
|
+
t_win: "T 歼灭",
|
|
182
|
+
ct_win: "CT 歼灭",
|
|
183
|
+
target_saved: "守时"
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function roundMatchesFilter(round: RoundModel, filter: RoundFilterState): boolean {
|
|
187
|
+
if (filter.winnerSide !== "all" && round.winnerSide !== filter.winnerSide) return false;
|
|
188
|
+
if (filter.economy !== "all" && round.teamAEconomy !== filter.economy && round.teamBEconomy !== filter.economy) return false;
|
|
189
|
+
if (filter.endReason !== "all" && round.endReason !== filter.endReason) return false;
|
|
190
|
+
const facets = round.facets;
|
|
191
|
+
if (filter.bombSite !== "all") {
|
|
192
|
+
if (!facets) return false;
|
|
193
|
+
if (filter.bombSite === "none" ? facets.bombSite !== null : facets.bombSite !== filter.bombSite) return false;
|
|
194
|
+
}
|
|
195
|
+
if (filter.firstKill !== "all" && facets?.firstKillTeamKey !== filter.firstKill) return false;
|
|
196
|
+
if (filter.special.clutch && !facets?.clutch) return false;
|
|
197
|
+
if (filter.special.multiKill && (facets?.maxKillsByOnePlayer ?? 0) < 3) return false;
|
|
198
|
+
if (filter.special.wallbang && (facets?.wallbangKills ?? 0) === 0) return false;
|
|
199
|
+
if (filter.special.smoke && (facets?.throughSmokeKills ?? 0) === 0) return false;
|
|
200
|
+
if (filter.playerSteamId64) {
|
|
201
|
+
const fact = round.playerFacts.find((f) => f.steamId64 === filter.playerSteamId64);
|
|
202
|
+
const involved = !!fact && (fact.kills > 0 || fact.openingDuel !== "none" || !fact.survived);
|
|
203
|
+
const isClutcher = facets?.clutch?.steamId64 === filter.playerSteamId64;
|
|
204
|
+
if (!involved && !isClutcher) return false;
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function FilterChip({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
|
210
|
+
return (
|
|
211
|
+
<button type="button" className={active ? "dak-sf-chip dak-sf-chip-active" : "dak-sf-chip"} onClick={onClick}>
|
|
212
|
+
{label}
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function RoundExplorer({ model, onOpenReplay }: MatchWorkspaceProps & { onOpenReplay?: (roundNumber: number, tick?: number) => void }) {
|
|
114
218
|
const [selectedRound, setSelectedRound] = useState(model.rounds[0]?.roundNumber ?? 1);
|
|
115
219
|
const [showAllEvents, setShowAllEvents] = useState(false);
|
|
116
|
-
const
|
|
220
|
+
const [filter, setFilter] = useState<RoundFilterState>(ROUND_FILTER_DEFAULT);
|
|
221
|
+
const filteredRounds = useMemo(
|
|
222
|
+
() => model.rounds.filter((row) => roundMatchesFilter(row, filter)),
|
|
223
|
+
[model.rounds, filter]
|
|
224
|
+
);
|
|
225
|
+
const round = model.rounds.find((row) => row.roundNumber === selectedRound)
|
|
226
|
+
?? filteredRounds[0]
|
|
227
|
+
?? model.rounds[0];
|
|
117
228
|
|
|
118
229
|
useEffect(() => {
|
|
119
230
|
setShowAllEvents(false);
|
|
@@ -126,12 +237,76 @@ function RoundExplorer({ model }: MatchWorkspaceProps) {
|
|
|
126
237
|
const eventLimit = 28;
|
|
127
238
|
const visibleEvents = showAllEvents ? round.events : round.events.slice(0, eventLimit);
|
|
128
239
|
const hiddenEventCount = round.events.length - visibleEvents.length;
|
|
240
|
+
const hasFacets = model.rounds.some((row) => row.facets);
|
|
241
|
+
const endReasons = [...new Set(model.rounds.map((row) => row.endReason))];
|
|
242
|
+
const filterActive = filteredRounds.length !== model.rounds.length;
|
|
243
|
+
const toggleSpecial = (key: keyof RoundFilterState["special"]) =>
|
|
244
|
+
setFilter((prev) => ({ ...prev, special: { ...prev.special, [key]: !prev.special[key] } }));
|
|
129
245
|
|
|
130
246
|
return (
|
|
131
247
|
<div className="dak-selection-layout">
|
|
132
|
-
<Panel title="回合时间线">
|
|
248
|
+
<Panel title="回合时间线" eyebrow={filterActive ? `筛选命中 ${filteredRounds.length}/${model.rounds.length} 回合` : undefined}>
|
|
249
|
+
<div className="dak-round-filterbar">
|
|
250
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="胜方阵营">
|
|
251
|
+
{(["all", "ct", "t"] as const).map((s) => (
|
|
252
|
+
<FilterChip key={s} active={filter.winnerSide === s} label={s === "all" ? "全部" : `${s.toUpperCase()} 胜`} onClick={() => setFilter((prev) => ({ ...prev, winnerSide: s }))} />
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="经济类型">
|
|
256
|
+
{(["all", "pistol", "eco", "force", "full"] as const).map((e) => (
|
|
257
|
+
<FilterChip key={e} active={filter.economy === e} label={e === "all" ? "全部经济" : economyLabelCn(e) || e} onClick={() => setFilter((prev) => ({ ...prev, economy: e }))} />
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
{hasFacets && (
|
|
261
|
+
<>
|
|
262
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="下包点">
|
|
263
|
+
{(["all", "a", "b", "none"] as const).map((s) => (
|
|
264
|
+
<FilterChip key={s} active={filter.bombSite === s} label={s === "all" ? "全部包点" : s === "none" ? "未下包" : `${s.toUpperCase()} 点`} onClick={() => setFilter((prev) => ({ ...prev, bombSite: s }))} />
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="首杀方">
|
|
268
|
+
<FilterChip active={filter.firstKill === "all"} label="首杀不限" onClick={() => setFilter((prev) => ({ ...prev, firstKill: "all" }))} />
|
|
269
|
+
<FilterChip active={filter.firstKill === "teamA"} label={`${model.teams.teamA.name} 首杀`} onClick={() => setFilter((prev) => ({ ...prev, firstKill: "teamA" }))} />
|
|
270
|
+
<FilterChip active={filter.firstKill === "teamB"} label={`${model.teams.teamB.name} 首杀`} onClick={() => setFilter((prev) => ({ ...prev, firstKill: "teamB" }))} />
|
|
271
|
+
</div>
|
|
272
|
+
<div className="dak-heatmap-side-filter" role="group" aria-label="高光条件">
|
|
273
|
+
<FilterChip active={filter.special.clutch} label="残局" onClick={() => toggleSpecial("clutch")} />
|
|
274
|
+
<FilterChip active={filter.special.multiKill} label="多杀 3+" onClick={() => toggleSpecial("multiKill")} />
|
|
275
|
+
<FilterChip active={filter.special.wallbang} label="穿墙杀" onClick={() => toggleSpecial("wallbang")} />
|
|
276
|
+
<FilterChip active={filter.special.smoke} label="穿烟杀" onClick={() => toggleSpecial("smoke")} />
|
|
277
|
+
</div>
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
280
|
+
<div className="dak-heatmap-side-filter">
|
|
281
|
+
<select
|
|
282
|
+
className="dak-round-filter-select"
|
|
283
|
+
value={filter.endReason}
|
|
284
|
+
onChange={(event) => setFilter((prev) => ({ ...prev, endReason: event.target.value }))}
|
|
285
|
+
aria-label="结束方式"
|
|
286
|
+
>
|
|
287
|
+
<option value="all">全部结束方式</option>
|
|
288
|
+
{endReasons.map((reason) => (
|
|
289
|
+
<option key={reason} value={reason}>{END_REASON_LABELS[reason] ?? reason}</option>
|
|
290
|
+
))}
|
|
291
|
+
</select>
|
|
292
|
+
<select
|
|
293
|
+
className="dak-round-filter-select"
|
|
294
|
+
value={filter.playerSteamId64}
|
|
295
|
+
onChange={(event) => setFilter((prev) => ({ ...prev, playerSteamId64: event.target.value }))}
|
|
296
|
+
aria-label="参与选手"
|
|
297
|
+
>
|
|
298
|
+
<option value="">全部选手</option>
|
|
299
|
+
{model.players.map((player) => (
|
|
300
|
+
<option key={player.row.steamId64} value={player.row.steamId64}>{player.row.name}</option>
|
|
301
|
+
))}
|
|
302
|
+
</select>
|
|
303
|
+
{filterActive && (
|
|
304
|
+
<FilterChip active={false} label="清除筛选" onClick={() => setFilter(ROUND_FILTER_DEFAULT)} />
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
133
308
|
<div className="dak-round-pills">
|
|
134
|
-
{
|
|
309
|
+
{filteredRounds.map((row) => (
|
|
135
310
|
<button
|
|
136
311
|
key={row.roundNumber}
|
|
137
312
|
className={row.roundNumber === round.roundNumber ? "dak-round-pill dak-round-pill-active" : "dak-round-pill"}
|
|
@@ -143,10 +318,16 @@ function RoundExplorer({ model }: MatchWorkspaceProps) {
|
|
|
143
318
|
<small>{row.scoreBefore}</small>
|
|
144
319
|
</button>
|
|
145
320
|
))}
|
|
321
|
+
{filteredRounds.length === 0 && <p className="dak-muted">没有匹配筛选条件的回合</p>}
|
|
146
322
|
</div>
|
|
147
323
|
</Panel>
|
|
148
324
|
<Panel title={`R${round.roundNumber} 详情`}>
|
|
149
325
|
<div className="dak-round-detail">
|
|
326
|
+
{onOpenReplay && (
|
|
327
|
+
<button className="dak-timeline-more" type="button" onClick={() => onOpenReplay(round.roundNumber)}>
|
|
328
|
+
▶ 在 2D 回放中打开本回合
|
|
329
|
+
</button>
|
|
330
|
+
)}
|
|
150
331
|
<div className="dak-fact-grid">
|
|
151
332
|
<Fact label="比分" value={round.scoreBefore} />
|
|
152
333
|
<Fact label="胜方" value={round.winnerSide.toUpperCase()} />
|
|
@@ -155,7 +336,13 @@ function RoundExplorer({ model }: MatchWorkspaceProps) {
|
|
|
155
336
|
</div>
|
|
156
337
|
<div className="dak-timeline">
|
|
157
338
|
{visibleEvents.map((event) => (
|
|
158
|
-
<div
|
|
339
|
+
<div
|
|
340
|
+
className={onOpenReplay ? "dak-timeline-row dak-timeline-row-link" : "dak-timeline-row"}
|
|
341
|
+
key={event.id}
|
|
342
|
+
onClick={onOpenReplay ? () => onOpenReplay(round.roundNumber, event.tick) : undefined}
|
|
343
|
+
role={onOpenReplay ? "button" : undefined}
|
|
344
|
+
title={onOpenReplay ? "点击跳到 2D 回放对应时刻" : undefined}
|
|
345
|
+
>
|
|
159
346
|
<span className="dak-mono dak-muted">{event.clockLabel}</span>
|
|
160
347
|
<span className="dak-badge">{event.type}</span>
|
|
161
348
|
<span>{event.label}</span>
|
|
@@ -178,7 +365,7 @@ function RoundExplorer({ model }: MatchWorkspaceProps) {
|
|
|
178
365
|
);
|
|
179
366
|
}
|
|
180
367
|
|
|
181
|
-
function PlayerStoryPanel({ model }: MatchWorkspaceProps) {
|
|
368
|
+
function PlayerStoryPanel({ model, onOpenReplay }: MatchWorkspaceProps & { onOpenReplay?: (roundNumber: number, tick?: number) => void }) {
|
|
182
369
|
const [selectedSteamId, setSelectedSteamId] = useState(model.players[0]?.row.steamId64 ?? "");
|
|
183
370
|
const selected = model.players.find((player) => player.row.steamId64 === selectedSteamId) ?? model.players[0];
|
|
184
371
|
|
|
@@ -224,10 +411,17 @@ function PlayerStoryPanel({ model }: MatchWorkspaceProps) {
|
|
|
224
411
|
</div>
|
|
225
412
|
))}
|
|
226
413
|
</div>
|
|
414
|
+
<RRExplainPanel model={model} steamId64={selected.row.steamId64} />
|
|
227
415
|
{selected.roundFacts.length > 0 && (
|
|
228
416
|
<div className="dak-player-roundfacts">
|
|
229
417
|
{selected.roundFacts.slice(0, 18).map((fact) => (
|
|
230
|
-
<article
|
|
418
|
+
<article
|
|
419
|
+
className={onOpenReplay ? "dak-player-round-card dak-player-round-card-link" : "dak-player-round-card"}
|
|
420
|
+
key={`${fact.steamId64}-${fact.roundNumber}`}
|
|
421
|
+
onClick={onOpenReplay ? () => onOpenReplay(fact.roundNumber) : undefined}
|
|
422
|
+
role={onOpenReplay ? "button" : undefined}
|
|
423
|
+
title={onOpenReplay ? "点击在 2D 回放中打开该回合" : undefined}
|
|
424
|
+
>
|
|
231
425
|
<div className="dak-player-round-head">
|
|
232
426
|
<span className="dak-badge">R{fact.roundNumber}</span>
|
|
233
427
|
<span>{sideLabel(fact.side)}</span>
|
|
@@ -250,6 +444,145 @@ function PlayerStoryPanel({ model }: MatchWorkspaceProps) {
|
|
|
250
444
|
);
|
|
251
445
|
}
|
|
252
446
|
|
|
447
|
+
const RR_INDICATOR_GROUPS = [
|
|
448
|
+
{
|
|
449
|
+
title: "Combat",
|
|
450
|
+
rows: [
|
|
451
|
+
["kills", "击杀"],
|
|
452
|
+
["deaths", "死亡"],
|
|
453
|
+
["assists", "助攻"],
|
|
454
|
+
["kpr", "KPR"],
|
|
455
|
+
["dpr", "DPR"],
|
|
456
|
+
["adr", "ADR"],
|
|
457
|
+
["hsPercent", "HS%"],
|
|
458
|
+
["kast", "KAST"],
|
|
459
|
+
["survivalRate", "存活率"],
|
|
460
|
+
["twoKillRounds", "2杀回合"],
|
|
461
|
+
["threeKillRounds", "3杀回合"],
|
|
462
|
+
["fourKillRounds", "4杀回合"],
|
|
463
|
+
["fiveKillRounds", "5杀回合"],
|
|
464
|
+
["multiKillRate", "多杀率"]
|
|
465
|
+
]
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
title: "Opening / Trade",
|
|
469
|
+
rows: [
|
|
470
|
+
["firstKillCount", "首杀"],
|
|
471
|
+
["firstDeathCount", "首死"],
|
|
472
|
+
["openingDuelWinRate", "首杀对决胜率"],
|
|
473
|
+
["tradeKillCount", "补枪"],
|
|
474
|
+
["tradeDeathCount", "被补枪"],
|
|
475
|
+
["tradeKillRate", "补枪率"],
|
|
476
|
+
["tradeDeathRate", "被补率"]
|
|
477
|
+
]
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
title: "Clutch / Weapon",
|
|
481
|
+
rows: [
|
|
482
|
+
["clutchAttempts", "残局尝试"],
|
|
483
|
+
["clutchWins", "残局胜利"],
|
|
484
|
+
["clutchWinRate", "残局胜率"],
|
|
485
|
+
["clutchFrequency", "残局频率"],
|
|
486
|
+
["clutchScore", "残局分"],
|
|
487
|
+
["clutchScoreRate", "残局分/回合"],
|
|
488
|
+
["vsOne.won", "1v1 胜"],
|
|
489
|
+
["vsTwo.won", "1v2 胜"],
|
|
490
|
+
["vsThree.won", "1v3 胜"],
|
|
491
|
+
["awpKills", "AWP 击杀"],
|
|
492
|
+
["awpKillsPerRound", "AWP K/R"],
|
|
493
|
+
["awpKillRate", "AWP 占比"],
|
|
494
|
+
["awpMultiKillRate", "AWP 多杀率"],
|
|
495
|
+
["awpDuelWinRate", "AWP 对决胜率"],
|
|
496
|
+
["sniperKills", "狙击击杀"],
|
|
497
|
+
["sniperKillRate", "狙击占比"]
|
|
498
|
+
]
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
title: "Utility / Economy",
|
|
502
|
+
rows: [
|
|
503
|
+
["utilityDamage", "道具伤害"],
|
|
504
|
+
["utilityDamagePerRound", "道具伤害/回合"],
|
|
505
|
+
["flashAssistCount", "闪光助攻"],
|
|
506
|
+
["enemyFlashDurationPerRound", "敌方白/回合"],
|
|
507
|
+
["teamFlashDurationPerRound", "队友白/回合"],
|
|
508
|
+
["blindDurationPerRound", "致盲/回合"],
|
|
509
|
+
["grenadeCount", "道具数"],
|
|
510
|
+
["grenadeCountPerRound", "道具/回合"],
|
|
511
|
+
["ecoRoundCount", "eco 局"],
|
|
512
|
+
["forceRoundCount", "force 局"],
|
|
513
|
+
["fullBuyRoundCount", "full 局"],
|
|
514
|
+
["pistolRoundCount", "手枪局"],
|
|
515
|
+
["avgEquipmentValue", "平均装备值"],
|
|
516
|
+
["combatDeathCount", "交火死亡"],
|
|
517
|
+
["bombDeathCount", "C4 死亡"],
|
|
518
|
+
["wallbangKillCount", "穿墙杀"],
|
|
519
|
+
["roundSwingTotal", "Swing 总量"],
|
|
520
|
+
["roundSwingPerKill", "Swing/K"]
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
] as const;
|
|
524
|
+
|
|
525
|
+
function RRExplainPanel({ model, steamId64 }: MatchWorkspaceProps & { steamId64: string }) {
|
|
526
|
+
const row = model.scoreboard.find((player) => player.steamId64 === steamId64);
|
|
527
|
+
if (!row) return null;
|
|
528
|
+
return (
|
|
529
|
+
<div className="dak-rr-explain">
|
|
530
|
+
<div className="dak-rr-explain-head">
|
|
531
|
+
<span>RR 解释</span>
|
|
532
|
+
<b className="dak-mono">{row.accountRR.toFixed(3)}</b>
|
|
533
|
+
<small>1.0 = 职业基线 · Raw {row.accountRRRaw.toFixed(3)}</small>
|
|
534
|
+
</div>
|
|
535
|
+
<div className="dak-rr-status">
|
|
536
|
+
<span>BuyDelta: {row.accountContextStatus.buyDelta === "available" ? "已启用" : "缺失"}</span>
|
|
537
|
+
<span>ManState: {row.accountContextStatus.manState === "available" ? "已启用" : "缺失"}</span>
|
|
538
|
+
<span>Combat context ×{row.accountCombatContextFactor.toFixed(2)}</span>
|
|
539
|
+
</div>
|
|
540
|
+
<div className="dak-rr-metric-groups">
|
|
541
|
+
{RR_INDICATOR_GROUPS.map((group) => (
|
|
542
|
+
<div className="dak-rr-metric-group" key={group.title}>
|
|
543
|
+
<h4>{group.title}</h4>
|
|
544
|
+
{group.rows.map(([key, label]) => {
|
|
545
|
+
const value = indicatorValue(row.indicators, key);
|
|
546
|
+
const width = indicatorBarWidth(value);
|
|
547
|
+
return (
|
|
548
|
+
<div className="dak-rr-metric-row" key={key}>
|
|
549
|
+
<span>{label}</span>
|
|
550
|
+
<div className="dak-rr-metric-track">
|
|
551
|
+
<i style={{ width: `${width}%` }} />
|
|
552
|
+
</div>
|
|
553
|
+
<b className="dak-mono">{formatIndicatorValue(value)}</b>
|
|
554
|
+
</div>
|
|
555
|
+
);
|
|
556
|
+
})}
|
|
557
|
+
</div>
|
|
558
|
+
))}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function indicatorValue(indicators: MatchWorkspaceModel["scoreboard"][number]["indicators"], key: string): number | null {
|
|
565
|
+
const value = key.split(".").reduce<unknown>((acc, part) => {
|
|
566
|
+
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
|
567
|
+
return undefined;
|
|
568
|
+
}, indicators);
|
|
569
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function indicatorBarWidth(value: number | null): number {
|
|
573
|
+
if (value == null) return 0;
|
|
574
|
+
if (value <= 1) return Math.max(2, Math.min(100, value * 100));
|
|
575
|
+
if (value <= 100) return Math.max(2, Math.min(100, value));
|
|
576
|
+
return 100;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function formatIndicatorValue(value: number | null): string {
|
|
580
|
+
if (value == null) return "—";
|
|
581
|
+
if (value >= 100) return value.toFixed(0);
|
|
582
|
+
if (value >= 10) return value.toFixed(1);
|
|
583
|
+
return value.toFixed(2);
|
|
584
|
+
}
|
|
585
|
+
|
|
253
586
|
function MapWorkspace({ model }: MatchWorkspaceProps) {
|
|
254
587
|
const renderableLayers = model.map.modes.filter((mode): mode is typeof mode & { key: HeatmapLayer } => (
|
|
255
588
|
mode.key === "death" || mode.key === "kill" || mode.key === "grenade"
|
|
@@ -294,32 +627,101 @@ function MapWorkspace({ model }: MatchWorkspaceProps) {
|
|
|
294
627
|
);
|
|
295
628
|
}
|
|
296
629
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
630
|
+
/** 2D 回放叠加图层开关(v0.2)。 */
|
|
631
|
+
interface ReplayLayerState {
|
|
632
|
+
trace: boolean;
|
|
633
|
+
killLines: boolean;
|
|
634
|
+
grenades: boolean;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const STANDARD_ROUND_SECONDS = 115;
|
|
638
|
+
|
|
639
|
+
function replayFrameIndexAtTick(round: WorkspaceReplayRound, tick: number): number {
|
|
640
|
+
const targetEndTick = round.targetEndTick ?? round.officialEndTick;
|
|
641
|
+
const endIndex = targetEndTick != null
|
|
642
|
+
? Math.max(round.frameCount - 1, Math.round((targetEndTick - round.startTick) / round.tickStep))
|
|
643
|
+
: Math.max(round.frameCount - 1, 0);
|
|
644
|
+
const index = Math.round((tick - round.startTick) / round.tickStep);
|
|
645
|
+
return Math.max(0, Math.min(endIndex, index));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function replayInitialFrameIndex(
|
|
649
|
+
round: WorkspaceReplayRound,
|
|
650
|
+
tickrate: number,
|
|
651
|
+
initialClockSeconds = STANDARD_ROUND_SECONDS,
|
|
652
|
+
): number {
|
|
653
|
+
const elapsedSeconds = Math.max(0, STANDARD_ROUND_SECONDS - initialClockSeconds);
|
|
654
|
+
return replayFrameIndexAtTick(round, round.freezeEndTick + elapsedSeconds * Math.max(tickrate, 1));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function ReplayViewer({ replay, map, target = null, initialClockSeconds = STANDARD_ROUND_SECONDS }: {
|
|
658
|
+
replay: MatchWorkspaceModel["replay"];
|
|
659
|
+
map: MatchWorkspaceModel["map"]["view"];
|
|
660
|
+
/** 统计跳回放:定位到某回合(可选定位 tick)。 */
|
|
661
|
+
target?: ReplayTarget | null;
|
|
662
|
+
/** 未指定 tick 时的初始比赛时钟;比赛工作台 1:55,教练工作台 1:35。 */
|
|
663
|
+
initialClockSeconds?: number;
|
|
664
|
+
}) {
|
|
665
|
+
const initialRoundNumber = target?.roundNumber ?? replay.rounds[0]?.roundNumber ?? 1;
|
|
666
|
+
const initialRound = replay.rounds.find((row) => row.roundNumber === initialRoundNumber) ?? replay.rounds[0];
|
|
667
|
+
const [roundNumber, setRoundNumber] = useState(initialRoundNumber);
|
|
668
|
+
const [frameIndex, setFrameIndex] = useState(() => {
|
|
669
|
+
if (!initialRound) return 0;
|
|
670
|
+
if (target?.tick != null) return replayFrameIndexAtTick(initialRound, target.tick);
|
|
671
|
+
return replayInitialFrameIndex(initialRound, replay.tickrate ?? 64, initialClockSeconds);
|
|
672
|
+
});
|
|
300
673
|
const [playing, setPlaying] = useState(false);
|
|
301
674
|
const [speed, setSpeed] = useState(1);
|
|
675
|
+
const [layers, setLayers] = useState<ReplayLayerState>({ trace: false, killLines: true, grenades: true });
|
|
676
|
+
// de_nuke / de_vertigo:上下双层雷达。当前层实心、另一层半透明幽灵显示,
|
|
677
|
+
// 道具效果与 C4 只画在所属层。
|
|
678
|
+
const calibration = getMapCalibration(map.name);
|
|
679
|
+
const dualLevel = !!(calibration && hasLowerLevel(calibration) && map.lowerRadarImageUrl);
|
|
680
|
+
const [level, setLevel] = useState<MapLevel>("upper");
|
|
681
|
+
const levelOf = (z: number): MapLevel => (calibration ? levelAt(z, calibration) : "upper");
|
|
302
682
|
const round = replay.rounds.find((row) => row.roundNumber === roundNumber) ?? replay.rounds[0];
|
|
303
683
|
|
|
684
|
+
// 有效帧范围:数据实际帧数;targetEndTick 优先延伸到下一回合 freeze/start 边界。
|
|
685
|
+
const lastDataFrameIndex = round ? Math.max(round.frameCount - 1, 0) : 0;
|
|
686
|
+
const targetEndFrameIndex = useMemo(() => {
|
|
687
|
+
if (!round) return lastDataFrameIndex;
|
|
688
|
+
const targetEndTick = round.targetEndTick ?? round.officialEndTick;
|
|
689
|
+
if (targetEndTick == null) return lastDataFrameIndex;
|
|
690
|
+
const computed = Math.round((targetEndTick - round.startTick) / round.tickStep);
|
|
691
|
+
return Math.max(lastDataFrameIndex, computed);
|
|
692
|
+
}, [round, lastDataFrameIndex]);
|
|
693
|
+
|
|
694
|
+
// 回合切换与统计跳转统一走同一定位规则;显式 tick 优先,否则使用调用方的初始比赛时钟。
|
|
304
695
|
useEffect(() => {
|
|
305
|
-
|
|
696
|
+
const selectedRound = replay.rounds.find((row) => row.roundNumber === roundNumber);
|
|
697
|
+
if (!selectedRound) return;
|
|
306
698
|
setPlaying(false);
|
|
307
|
-
|
|
699
|
+
const explicitTick = target?.roundNumber === roundNumber ? target.tick : undefined;
|
|
700
|
+
setFrameIndex(explicitTick != null
|
|
701
|
+
? replayFrameIndexAtTick(selectedRound, explicitTick)
|
|
702
|
+
: replayInitialFrameIndex(selectedRound, replay.tickrate ?? 64, initialClockSeconds));
|
|
703
|
+
}, [roundNumber, target?.seq, replay.rounds, replay.tickrate, initialClockSeconds]);
|
|
308
704
|
|
|
309
705
|
useEffect(() => {
|
|
310
|
-
if (
|
|
706
|
+
if (target && replay.rounds.some((row) => row.roundNumber === target.roundNumber)) {
|
|
707
|
+
setRoundNumber(target.roundNumber);
|
|
708
|
+
}
|
|
709
|
+
}, [target?.seq, replay.rounds]);
|
|
710
|
+
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (!playing || !round || targetEndFrameIndex <= 0) return undefined;
|
|
311
713
|
const msPerFrame = Math.max(35, 1000 / ((replay.sampleRate ?? 8) * speed));
|
|
312
714
|
const timer = window.setInterval(() => {
|
|
313
715
|
setFrameIndex((value) => {
|
|
314
|
-
if (value >=
|
|
716
|
+
if (value >= targetEndFrameIndex) {
|
|
315
717
|
setPlaying(false);
|
|
316
|
-
return
|
|
718
|
+
return targetEndFrameIndex;
|
|
317
719
|
}
|
|
318
720
|
return value + 1;
|
|
319
721
|
});
|
|
320
722
|
}, msPerFrame);
|
|
321
723
|
return () => window.clearInterval(timer);
|
|
322
|
-
}, [playing, replay.sampleRate,
|
|
724
|
+
}, [playing, replay.sampleRate, targetEndFrameIndex, speed]);
|
|
323
725
|
|
|
324
726
|
// Stable per-player numbers: teamA → 1-5, teamB → 6-0.
|
|
325
727
|
// Computed from round.players order so the mapping is consistent across frames.
|
|
@@ -335,27 +737,115 @@ function ReplayViewer({ replay, map }: { replay: MatchWorkspaceModel["replay"];
|
|
|
335
737
|
return <Panel title="2D 回放"><p className="dak-muted">该导出包不含回放流。</p></Panel>;
|
|
336
738
|
}
|
|
337
739
|
|
|
338
|
-
const currentFrameIndex = Math.min(frameIndex,
|
|
740
|
+
const currentFrameIndex = Math.min(frameIndex, targetEndFrameIndex);
|
|
741
|
+
// 数据帧 clamp:超出实际录制帧数时冻结在最后一帧
|
|
742
|
+
const dataFrameIndex = Math.min(currentFrameIndex, lastDataFrameIndex);
|
|
339
743
|
const currentTick = round.startTick + currentFrameIndex * round.tickStep;
|
|
744
|
+
const endTick = round.startTick + targetEndFrameIndex * round.tickStep;
|
|
745
|
+
const replayClock = deriveReplayClock(round, currentTick, replay.tickrate ?? 64);
|
|
746
|
+
|
|
747
|
+
// 2D 时间轴锚点:首杀 / 每次击杀 / 下包拆包(freeze end = 起点本身)
|
|
748
|
+
const anchors = useMemo(() => {
|
|
749
|
+
const list: { tick: number; kind: string; label: string }[] = [];
|
|
750
|
+
round.kills.forEach((kill, i) => {
|
|
751
|
+
list.push({ tick: kill.tick, kind: i === 0 ? "firstkill" : "kill", label: `${i === 0 ? "首杀" : "击杀"}:${kill.killerName ?? "?"} → ${kill.victimName}` });
|
|
752
|
+
});
|
|
753
|
+
if (round.bomb) {
|
|
754
|
+
list.push({ tick: round.bomb.plantTick, kind: "bomb", label: "下包" });
|
|
755
|
+
if (round.bomb.defuseTick != null) list.push({ tick: round.bomb.defuseTick, kind: "defuse", label: "拆包" });
|
|
756
|
+
}
|
|
757
|
+
return list.filter((a) => a.tick >= round.startTick && a.tick <= endTick);
|
|
758
|
+
}, [round, endTick]);
|
|
759
|
+
const seekTick = (tick: number) => {
|
|
760
|
+
setPlaying(false);
|
|
761
|
+
setFrameIndex(Math.max(0, Math.min(targetEndFrameIndex, Math.round((tick - round.startTick) / round.tickStep))));
|
|
762
|
+
};
|
|
763
|
+
// 帧数据访问用 dataFrameIndex(clamp 到实际录制范围),让超出部分冻结在最后帧
|
|
340
764
|
const currentPlayers = round.players
|
|
341
|
-
.map((player) => ({ player, frame: player.frames[
|
|
765
|
+
.map((player) => ({ player, frame: player.frames[dataFrameIndex] ?? player.frames.find((frame) => frame.alive) ?? player.frames[0] }))
|
|
342
766
|
.filter((row): row is { player: WorkspaceReplayRound["players"][number]; frame: WorkspaceReplayFrame } => Boolean(row.frame));
|
|
767
|
+
const selectedRoundIndex = Math.max(0, replay.rounds.findIndex((row) => row.roundNumber === round.roundNumber));
|
|
768
|
+
const seekRoundByOffset = (offset: number) => {
|
|
769
|
+
const next = replay.rounds[Math.max(0, Math.min(replay.rounds.length - 1, selectedRoundIndex + offset))];
|
|
770
|
+
if (next) setRoundNumber(next.roundNumber);
|
|
771
|
+
};
|
|
772
|
+
const rosterTeams = (["teamA", "teamB"] as const).map((teamKey) => ({
|
|
773
|
+
teamKey,
|
|
774
|
+
side: currentPlayers.find(({ player }) => player.teamKey === teamKey)?.player.side ?? null,
|
|
775
|
+
rows: [...currentPlayers]
|
|
776
|
+
.filter(({ player }) => player.teamKey === teamKey)
|
|
777
|
+
.sort((a, b) => {
|
|
778
|
+
const na = Number(playerNumbers[a.player.steamId64] ?? "99");
|
|
779
|
+
const nb = Number(playerNumbers[b.player.steamId64] ?? "99");
|
|
780
|
+
return (na === 0 ? 10 : na) - (nb === 0 ? 10 : nb);
|
|
781
|
+
})
|
|
782
|
+
}));
|
|
783
|
+
const renderRosterTeam = (team: typeof rosterTeams[number]) => (
|
|
784
|
+
<section className="dak-roster-team" key={team.teamKey} aria-label={`${team.teamKey} 当前状态`}>
|
|
785
|
+
<div className="dak-roster-team-head">
|
|
786
|
+
<span className={`dak-team-dot dak-team-dot-${team.teamKey}`} />
|
|
787
|
+
<b>{team.side ? team.side.toUpperCase() : team.teamKey}</b>
|
|
788
|
+
<small>{team.rows.length}/5</small>
|
|
789
|
+
</div>
|
|
790
|
+
<div className="dak-frame-player-list">
|
|
791
|
+
{team.rows.map(({ player, frame }) => {
|
|
792
|
+
// 装备严格跟随逐帧数据:道具丢出后即消失,捡/传枪后主武器即替换(不回退到开局 loadout)
|
|
793
|
+
const heldUtility = frame.grenades.length > 0
|
|
794
|
+
? heldUtilityLabel({ ...player.loadout, grenadeCount: frame.grenades.length, grenades: frame.grenades })
|
|
795
|
+
: null;
|
|
796
|
+
const heldGun = frame.weapon && isGunWeapon(frame.weapon) ? frame.weapon : null;
|
|
797
|
+
// frame.weapon 是武器原始 key,loadout 可能存展示名;统一到展示名空间再比较与渲染,避免误判「捡枪」并显示原始 key。
|
|
798
|
+
const heldGunLabel = heldGun ? displayWeaponName(heldGun) : null;
|
|
799
|
+
const primaryLabel = primaryLoadoutLabel(player.loadout);
|
|
800
|
+
const secondaryLabel = player.loadout.secondaryWeapon ? displayWeaponName(player.loadout.secondaryWeapon) : null;
|
|
801
|
+
const pickedUp = heldGunLabel != null && heldGunLabel !== primaryLabel && heldGunLabel !== secondaryLabel;
|
|
802
|
+
const primary = pickedUp ? heldGunLabel : primaryLabel;
|
|
803
|
+
return (
|
|
804
|
+
<div
|
|
805
|
+
className={`dak-frame-player-row${!frame.alive ? " dak-frame-player-row-dead" : ""}`}
|
|
806
|
+
key={player.steamId64}
|
|
807
|
+
>
|
|
808
|
+
<span className="dak-frame-player-num">{playerNumbers[player.steamId64] ?? "?"}</span>
|
|
809
|
+
<span className="dak-frame-player-name">{player.name}</span>
|
|
810
|
+
{frame.alive ? (
|
|
811
|
+
<small className="dak-frame-loadout">
|
|
812
|
+
<span className="dak-frame-primary">{primary}{pickedUp ? "*" : ""}</span>
|
|
813
|
+
{player.loadout.primaryWeapon && secondaryLabel && !pickedUp && <span>{secondaryLabel}</span>}
|
|
814
|
+
{heldUtility && <span>{heldUtility}</span>}
|
|
815
|
+
{frame.armor > 0 && <span>{frame.hasHelmet ? "全甲" : "半甲"}</span>}
|
|
816
|
+
{frame.hasDefuseKit && <span>kit</span>}
|
|
817
|
+
{frame.hasBomb && <span>C4</span>}
|
|
818
|
+
{frame.flashed && <span>白 {frame.flashRemainingSeconds.toFixed(1)}s</span>}
|
|
819
|
+
</small>
|
|
820
|
+
) : (
|
|
821
|
+
<small className="dak-frame-weapon">阵亡</small>
|
|
822
|
+
)}
|
|
823
|
+
{renderHpArmor(frame)}
|
|
824
|
+
</div>
|
|
825
|
+
);
|
|
826
|
+
})}
|
|
827
|
+
</div>
|
|
828
|
+
</section>
|
|
829
|
+
);
|
|
343
830
|
|
|
344
831
|
return (
|
|
345
832
|
<div className="dak-replay-layout">
|
|
346
|
-
<
|
|
347
|
-
<div className="dak-round-
|
|
348
|
-
{
|
|
349
|
-
<
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
</
|
|
358
|
-
|
|
833
|
+
<section className="dak-replay-controlbar" aria-label="回放控制">
|
|
834
|
+
<div className="dak-replay-round-nav">
|
|
835
|
+
<button className="dak-icon-button" type="button" onClick={() => seekRoundByOffset(-1)} disabled={selectedRoundIndex <= 0} aria-label="上一回合">
|
|
836
|
+
<ChevronLeft size={17} />
|
|
837
|
+
</button>
|
|
838
|
+
<label className="dak-round-select-label">
|
|
839
|
+
<span>回合</span>
|
|
840
|
+
<select className="dak-round-select" value={round.roundNumber} onChange={(event) => setRoundNumber(Number(event.target.value))}>
|
|
841
|
+
{replay.rounds.map((row) => (
|
|
842
|
+
<option key={row.roundNumber} value={row.roundNumber}>R{row.roundNumber} · {row.frameCount} 帧</option>
|
|
843
|
+
))}
|
|
844
|
+
</select>
|
|
845
|
+
</label>
|
|
846
|
+
<button className="dak-icon-button" type="button" onClick={() => seekRoundByOffset(1)} disabled={selectedRoundIndex >= replay.rounds.length - 1} aria-label="下一回合">
|
|
847
|
+
<ChevronRight size={17} />
|
|
848
|
+
</button>
|
|
359
849
|
</div>
|
|
360
850
|
<div className="dak-playback">
|
|
361
851
|
<button className="dak-play-button" type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? "暂停" : "播放"}>
|
|
@@ -364,87 +854,372 @@ function ReplayViewer({ replay, map }: { replay: MatchWorkspaceModel["replay"];
|
|
|
364
854
|
<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
855
|
<ChevronLeft size={17} />
|
|
366
856
|
</button>
|
|
367
|
-
<
|
|
368
|
-
className="dak-scrubber"
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
857
|
+
<div className="dak-scrubber-wrap">
|
|
858
|
+
<div className="dak-scrubber-anchors" aria-hidden="true">
|
|
859
|
+
{anchors.map((anchor, i) => (
|
|
860
|
+
<button
|
|
861
|
+
key={`${anchor.tick}-${i}`}
|
|
862
|
+
type="button"
|
|
863
|
+
className={`dak-scrubber-anchor dak-scrubber-anchor-${anchor.kind}`}
|
|
864
|
+
style={{ left: `${endTick > round.startTick ? ((anchor.tick - round.startTick) / (endTick - round.startTick)) * 100 : 0}%` }}
|
|
865
|
+
title={anchor.label}
|
|
866
|
+
onClick={() => seekTick(anchor.tick)}
|
|
867
|
+
/>
|
|
868
|
+
))}
|
|
869
|
+
</div>
|
|
870
|
+
<input
|
|
871
|
+
className="dak-scrubber"
|
|
872
|
+
type="range"
|
|
873
|
+
min={0}
|
|
874
|
+
max={targetEndFrameIndex}
|
|
875
|
+
value={currentFrameIndex}
|
|
876
|
+
onChange={(event) => setFrameIndex(Number(event.target.value))}
|
|
877
|
+
/>
|
|
878
|
+
</div>
|
|
879
|
+
<button className="dak-icon-button" type="button" onClick={() => setFrameIndex((value) => Math.min(targetEndFrameIndex, value + Math.max(1, Math.round((replay.sampleRate ?? 8) / 2))))} aria-label="前进">
|
|
376
880
|
<ChevronRight size={17} />
|
|
377
881
|
</button>
|
|
378
882
|
</div>
|
|
379
|
-
<div className="dak-
|
|
380
|
-
{
|
|
381
|
-
<
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
>
|
|
387
|
-
|
|
388
|
-
</
|
|
389
|
-
|
|
883
|
+
<div className="dak-replay-statusline" aria-label="当前回放状态">
|
|
884
|
+
<div className={`dak-replay-clock dak-replay-clock-${replayClock.phase}`}>
|
|
885
|
+
<span>{replayClock.label === "回合" ? "回合时间" : replayClock.label}</span>
|
|
886
|
+
<strong>{replayClock.display}</strong>
|
|
887
|
+
</div>
|
|
888
|
+
<div className="dak-replay-techline" aria-label="回放技术信息">
|
|
889
|
+
<span>R{round.roundNumber}</span>
|
|
890
|
+
<span>Tick {currentTick}</span>
|
|
891
|
+
<span>帧 {currentFrameIndex + 1}/{round.frameCount}</span>
|
|
892
|
+
<span>{replay.sampleRate ?? 0} Hz</span>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
<div className="dak-replay-togglebar">
|
|
896
|
+
<div className="dak-speed-group" role="group" aria-label="叠加图层">
|
|
897
|
+
{([
|
|
898
|
+
["trace", "走位轨迹"],
|
|
899
|
+
["killLines", "击杀连线"],
|
|
900
|
+
["grenades", "道具轨迹"]
|
|
901
|
+
] as const).map(([key, label]) => (
|
|
902
|
+
<button
|
|
903
|
+
key={key}
|
|
904
|
+
type="button"
|
|
905
|
+
className={layers[key] ? "dak-speed dak-speed-active" : "dak-speed"}
|
|
906
|
+
aria-pressed={layers[key]}
|
|
907
|
+
onClick={() => setLayers((prev) => ({ ...prev, [key]: !prev[key] }))}
|
|
908
|
+
>
|
|
909
|
+
{label}
|
|
910
|
+
</button>
|
|
911
|
+
))}
|
|
912
|
+
</div>
|
|
913
|
+
{dualLevel && (
|
|
914
|
+
<div className="dak-speed-group dak-speed-group-compact" role="group" aria-label="地图层级">
|
|
915
|
+
{(["upper", "lower"] as const).map((nextLevel) => (
|
|
916
|
+
<button
|
|
917
|
+
key={nextLevel}
|
|
918
|
+
type="button"
|
|
919
|
+
className={level === nextLevel ? "dak-speed dak-speed-active" : "dak-speed"}
|
|
920
|
+
onClick={() => setLevel(nextLevel)}
|
|
921
|
+
>
|
|
922
|
+
{nextLevel === "upper" ? "上层" : "下层"}
|
|
923
|
+
</button>
|
|
924
|
+
))}
|
|
925
|
+
</div>
|
|
926
|
+
)}
|
|
927
|
+
<div className="dak-speed-group dak-speed-group-compact" role="group" aria-label="播放速度">
|
|
928
|
+
{[0.5, 1, 2, 4].map((nextSpeed) => (
|
|
929
|
+
<button
|
|
930
|
+
key={nextSpeed}
|
|
931
|
+
type="button"
|
|
932
|
+
className={speed === nextSpeed ? "dak-speed dak-speed-active" : "dak-speed"}
|
|
933
|
+
onClick={() => setSpeed(nextSpeed)}
|
|
934
|
+
>
|
|
935
|
+
{nextSpeed}x
|
|
936
|
+
</button>
|
|
937
|
+
))}
|
|
938
|
+
</div>
|
|
390
939
|
</div>
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
<Fact label="拆弹器" value={replay.capabilities.hasDefuseKit ? "可显示" : "无状态"} />
|
|
396
|
-
<Fact label="C4 位置" value={replay.capabilities.hasBombPosition ? "可显示" : "暂不显示"} />
|
|
940
|
+
</section>
|
|
941
|
+
<aside className="dak-replay-roster-panel dak-replay-roster-left">
|
|
942
|
+
<div className="dak-roster-board">
|
|
943
|
+
{renderRosterTeam(rosterTeams[0])}
|
|
397
944
|
</div>
|
|
398
|
-
</
|
|
399
|
-
<
|
|
945
|
+
</aside>
|
|
946
|
+
<section className="dak-replay-stage-panel" aria-label={`R${round.roundNumber} 2D 回放`}>
|
|
400
947
|
<div
|
|
401
948
|
className="dak-replay-stage"
|
|
402
|
-
style={
|
|
949
|
+
style={(() => {
|
|
950
|
+
const url = dualLevel && level === "lower" ? map.lowerRadarImageUrl : map.radarImageUrl;
|
|
951
|
+
return url ? { backgroundImage: `url(${url})` } : undefined;
|
|
952
|
+
})()}
|
|
403
953
|
>
|
|
404
954
|
<div className="dak-replay-gridlines" aria-hidden="true" />
|
|
405
955
|
<KillFeed kills={round.kills} currentTick={currentTick} tickrate={replay.tickrate} />
|
|
956
|
+
{layers.trace && (
|
|
957
|
+
<svg className="dak-replay-trajectories" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
958
|
+
{currentPlayers.map(({ player, frame }) => {
|
|
959
|
+
if (!frame.alive) return null;
|
|
960
|
+
if (dualLevel && levelOf(frame.z) !== level) return null;
|
|
961
|
+
// 最近 ~10 秒的走位轨迹(8 Hz × 80 帧);clamp 到实际录制范围
|
|
962
|
+
const traceFrames = player.frames.slice(Math.max(0, dataFrameIndex - 80), dataFrameIndex + 1).filter((f) => f.alive);
|
|
963
|
+
if (traceFrames.length < 2) return null;
|
|
964
|
+
const points = traceFrames.map((f) => {
|
|
965
|
+
const pos = replayPointPercent(f, map);
|
|
966
|
+
return `${pos.x},${pos.y}`;
|
|
967
|
+
}).join(" ");
|
|
968
|
+
return <polyline key={`trace-${player.steamId64}`} className={`dak-replay-playertrace dak-replay-playertrace-${player.teamKey}`} points={points} />;
|
|
969
|
+
})}
|
|
970
|
+
</svg>
|
|
971
|
+
)}
|
|
972
|
+
{layers.killLines && (
|
|
973
|
+
<svg className="dak-replay-trajectories" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
974
|
+
{round.kills.map((kill, i) => {
|
|
975
|
+
// 击杀后保留 ~3 秒的连线
|
|
976
|
+
if (kill.killerX == null || kill.victimX == null) return null;
|
|
977
|
+
if (currentTick < kill.tick || currentTick > kill.tick + 3 * (replay.tickrate ?? 64)) return null;
|
|
978
|
+
if (dualLevel && kill.victimZ != null && levelOf(kill.victimZ) !== level) return null;
|
|
979
|
+
const from = replayPointPercent({ x: kill.killerX, y: kill.killerY ?? 0 }, map);
|
|
980
|
+
const to = replayPointPercent({ x: kill.victimX, y: kill.victimY ?? 0 }, map);
|
|
981
|
+
return (
|
|
982
|
+
<g key={`killline-${i}`}>
|
|
983
|
+
<line className="dak-replay-killline" x1={from.x} y1={from.y} x2={to.x} y2={to.y} />
|
|
984
|
+
<circle className="dak-replay-killline-victim" cx={to.x} cy={to.y} r={0.7} />
|
|
985
|
+
</g>
|
|
986
|
+
);
|
|
987
|
+
})}
|
|
988
|
+
</svg>
|
|
989
|
+
)}
|
|
990
|
+
{layers.grenades && <GrenadeEffectLayer round={round} currentTick={currentTick} tickrate={replay.tickrate ?? 64} map={map} level={dualLevel ? level : null} levelOf={levelOf} />}
|
|
991
|
+
<BombMarker bomb={round.bomb} currentTick={currentTick} tickrate={replay.tickrate ?? 64} map={map} offLevel={dualLevel && round.bomb != null && levelOf(round.bomb.z ?? 0) !== level} />
|
|
992
|
+
{(round.groundBombs ?? []).map((gb, gbIdx) => {
|
|
993
|
+
if (currentTick < gb.startTick || currentTick > gb.endTick) return null;
|
|
994
|
+
if (dualLevel && levelOf(gb.z ?? 0) !== level) return null;
|
|
995
|
+
return (
|
|
996
|
+
<span
|
|
997
|
+
key={`gb-${gbIdx}`}
|
|
998
|
+
className="dak-replay-bomb dak-replay-bomb-dropped"
|
|
999
|
+
style={{ left: `${replayPointPercent(gb, map).x}%`, top: `${replayPointPercent(gb, map).y}%` }}
|
|
1000
|
+
title="C4 掉落"
|
|
1001
|
+
aria-hidden="true"
|
|
1002
|
+
>
|
|
1003
|
+
c4
|
|
1004
|
+
</span>
|
|
1005
|
+
);
|
|
1006
|
+
})}
|
|
1007
|
+
{(round.groundDefusers ?? []).map((gd, gdIdx) => {
|
|
1008
|
+
if (currentTick < gd.startTick || currentTick > gd.endTick) return null;
|
|
1009
|
+
if (dualLevel && levelOf(gd.z ?? 0) !== level) return null;
|
|
1010
|
+
return (
|
|
1011
|
+
<span
|
|
1012
|
+
key={`gd-${gdIdx}`}
|
|
1013
|
+
className="dak-replay-defuser dak-replay-defuser-dropped"
|
|
1014
|
+
style={{ left: `${replayPointPercent(gd, map).x}%`, top: `${replayPointPercent(gd, map).y}%` }}
|
|
1015
|
+
title="拆弹器掉落"
|
|
1016
|
+
aria-hidden="true"
|
|
1017
|
+
>
|
|
1018
|
+
kit
|
|
1019
|
+
</span>
|
|
1020
|
+
);
|
|
1021
|
+
})}
|
|
406
1022
|
{currentPlayers.map(({ player, frame }) => (
|
|
407
1023
|
<div
|
|
408
1024
|
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" : ""}`}
|
|
1025
|
+
className={`dak-replay-token dak-replay-token-${player.teamKey}${!frame.alive ? " dak-replay-token-dead" : ""}${frame.flashed ? " dak-replay-token-flashed" : ""}${dualLevel && levelOf(frame.z) !== level ? " dak-replay-token-offlevel" : ""}`}
|
|
410
1026
|
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 ?
|
|
1027
|
+
title={`${playerNumbers[player.steamId64] ?? "?"} ${player.name} · ${frame.hp} HP${frame.hasDefuseKit ? " · 拆弹器" : ""}${frame.flashed ? ` · 白 ${frame.flashRemainingSeconds.toFixed(1)}s` : ""}`}
|
|
412
1028
|
>
|
|
413
|
-
|
|
1029
|
+
{frame.alive ? (
|
|
1030
|
+
<span style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>{playerNumbers[player.steamId64] ?? "?"}</span>
|
|
1031
|
+
) : (
|
|
1032
|
+
<svg className="dak-replay-dead-x" viewBox="0 0 10 10" width="14" height="14" aria-hidden="true" style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>
|
|
1033
|
+
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
1034
|
+
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
1035
|
+
</svg>
|
|
1036
|
+
)}
|
|
414
1037
|
{frame.hasDefuseKit && <i style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>kit</i>}
|
|
1038
|
+
{frame.hasBomb && <i className="dak-replay-c4-tag" style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>c4</i>}
|
|
415
1039
|
</div>
|
|
416
1040
|
))}
|
|
417
1041
|
</div>
|
|
418
|
-
</
|
|
419
|
-
<
|
|
420
|
-
<div className="dak-
|
|
421
|
-
{[
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1042
|
+
</section>
|
|
1043
|
+
<aside className="dak-replay-roster-panel dak-replay-roster-right">
|
|
1044
|
+
<div className="dak-roster-board">
|
|
1045
|
+
{renderRosterTeam(rosterTeams[1])}
|
|
1046
|
+
</div>
|
|
1047
|
+
</aside>
|
|
1048
|
+
</div>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** 导出 Markdown 比赛报告(浏览器下载,无副作用依赖)。 */
|
|
1053
|
+
function downloadMatchReport(model: MatchWorkspaceModel) {
|
|
1054
|
+
const md = buildMatchReportMarkdown(model);
|
|
1055
|
+
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
|
1056
|
+
const url = URL.createObjectURL(blob);
|
|
1057
|
+
const a = document.createElement("a");
|
|
1058
|
+
a.href = url;
|
|
1059
|
+
a.download = `${model.title.replace(/[\\/:*?"<>|]/g, "_")}-报告.md`;
|
|
1060
|
+
a.click();
|
|
1061
|
+
URL.revokeObjectURL(url);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** v0.3 Buy Quality:full/force/eco 胜率链 + 手枪局转化。 */
|
|
1065
|
+
function BuyQualityPanel({ model }: MatchWorkspaceProps) {
|
|
1066
|
+
const quality = useMemo(() => buildMatchBuyQuality(model.economy), [model.economy]);
|
|
1067
|
+
const conversionLabel = (cell: { rounds: number; wins: number }) =>
|
|
1068
|
+
cell.rounds > 0 ? `${cell.wins}/${cell.rounds}(${Math.round((cell.wins / cell.rounds) * 100)}%)` : "—";
|
|
1069
|
+
return (
|
|
1070
|
+
<Panel title="买局质量" eyebrow="各经济类型胜率 · 手枪局转化">
|
|
1071
|
+
<div className="dak-grid dak-grid-even">
|
|
1072
|
+
{([["teamA", model.teams.teamA.name, quality.teamA], ["teamB", model.teams.teamB.name, quality.teamB]] as const).map(([key, name, rows]) => (
|
|
1073
|
+
<div key={key}>
|
|
1074
|
+
<h4 className="dak-panel-eyebrow">{name}</h4>
|
|
1075
|
+
<table className="dak-table">
|
|
1076
|
+
<thead>
|
|
1077
|
+
<tr><th>经济</th><th className="dak-num">回合</th><th className="dak-num">胜</th><th className="dak-num">胜率</th><th aria-label="胜率条" /></tr>
|
|
1078
|
+
</thead>
|
|
1079
|
+
<tbody>
|
|
1080
|
+
{rows.map((row) => (
|
|
1081
|
+
<tr key={row.economy}>
|
|
1082
|
+
<td>{row.label}</td>
|
|
1083
|
+
<td className="dak-num dak-mono">{row.rounds}</td>
|
|
1084
|
+
<td className="dak-num dak-mono">{row.wins}</td>
|
|
1085
|
+
<td className="dak-num dak-mono">{row.winRatePercent == null ? "—" : `${row.winRatePercent.toFixed(0)}%`}</td>
|
|
1086
|
+
<td className="dak-weapon-bar-cell">
|
|
1087
|
+
{row.winRatePercent != null && <div className="dak-weapon-bar" style={{ width: `${row.winRatePercent}%` }} />}
|
|
1088
|
+
</td>
|
|
1089
|
+
</tr>
|
|
1090
|
+
))}
|
|
1091
|
+
</tbody>
|
|
1092
|
+
</table>
|
|
1093
|
+
<p className="dak-muted dak-note">手枪局转化(赢下手枪局后再下一城):{conversionLabel(quality.conversion[key])}</p>
|
|
1094
|
+
</div>
|
|
1095
|
+
))}
|
|
1096
|
+
</div>
|
|
1097
|
+
</Panel>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function WeaponsView({ model }: MatchWorkspaceProps) {
|
|
1102
|
+
if (model.weapons.length === 0) {
|
|
1103
|
+
return <Panel title="武器统计"><p className="dak-muted">暂无武器击杀数据</p></Panel>;
|
|
1104
|
+
}
|
|
1105
|
+
const maxKills = Math.max(...model.weapons.map((row) => row.kills));
|
|
1106
|
+
return (
|
|
1107
|
+
<Panel title="武器统计">
|
|
1108
|
+
<table className="dak-table">
|
|
1109
|
+
<thead>
|
|
1110
|
+
<tr>
|
|
1111
|
+
<th>武器</th>
|
|
1112
|
+
<th className="dak-num">击杀</th>
|
|
1113
|
+
<th aria-label="击杀占比" />
|
|
1114
|
+
<th className="dak-num">HS%</th>
|
|
1115
|
+
<th className="dak-num">伤害</th>
|
|
1116
|
+
<th className="dak-num">穿墙</th>
|
|
1117
|
+
<th className="dak-num">穿烟</th>
|
|
1118
|
+
<th className="dak-num">无镜</th>
|
|
1119
|
+
<th>头号使用者</th>
|
|
1120
|
+
</tr>
|
|
1121
|
+
</thead>
|
|
1122
|
+
<tbody>
|
|
1123
|
+
{model.weapons.map((row) => (
|
|
1124
|
+
<tr key={row.weapon}>
|
|
1125
|
+
<td>{row.label}</td>
|
|
1126
|
+
<td className="dak-num dak-mono">{row.kills}</td>
|
|
1127
|
+
<td className="dak-weapon-bar-cell">
|
|
1128
|
+
<div className="dak-weapon-bar" style={{ width: `${(row.kills / maxKills) * 100}%` }} />
|
|
1129
|
+
</td>
|
|
1130
|
+
<td className="dak-num dak-mono">{row.headshotPercent == null ? "—" : `${row.headshotPercent.toFixed(1)}%`}</td>
|
|
1131
|
+
<td className="dak-num dak-mono">{row.damage}</td>
|
|
1132
|
+
<td className="dak-num dak-mono">{row.wallbangKills || "—"}</td>
|
|
1133
|
+
<td className="dak-num dak-mono">{row.throughSmokeKills || "—"}</td>
|
|
1134
|
+
<td className="dak-num dak-mono">{row.noScopeKills || "—"}</td>
|
|
1135
|
+
<td className="dak-muted">
|
|
1136
|
+
{row.topKillerName ? `${row.topKillerName} (${row.topKillerKills})` : "—"}
|
|
1137
|
+
</td>
|
|
1138
|
+
</tr>
|
|
1139
|
+
))}
|
|
1140
|
+
</tbody>
|
|
1141
|
+
</table>
|
|
1142
|
+
</Panel>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function DuelsView({ model }: MatchWorkspaceProps) {
|
|
1147
|
+
const { players, matrix, openings } = model.duels;
|
|
1148
|
+
if (players.length === 0) {
|
|
1149
|
+
return <Panel title="对位"><p className="dak-muted">暂无对位数据</p></Panel>;
|
|
1150
|
+
}
|
|
1151
|
+
const maxCell = Math.max(1, ...matrix.flat());
|
|
1152
|
+
return (
|
|
1153
|
+
<div className="dak-stack">
|
|
1154
|
+
<Panel title="击杀矩阵" eyebrow="行 = 击杀者 · 列 = 被击杀者">
|
|
1155
|
+
<div className="dak-duel-scroll">
|
|
1156
|
+
<table className="dak-duel-matrix">
|
|
1157
|
+
<thead>
|
|
1158
|
+
<tr>
|
|
1159
|
+
<th aria-label="击杀者 \ 被击杀者" />
|
|
1160
|
+
{players.map((player) => (
|
|
1161
|
+
<th key={player.steamId64} className={`dak-duel-head dak-duel-${player.teamKey}`}>
|
|
1162
|
+
<span>{player.name}</span>
|
|
1163
|
+
</th>
|
|
1164
|
+
))}
|
|
1165
|
+
</tr>
|
|
1166
|
+
</thead>
|
|
1167
|
+
<tbody>
|
|
1168
|
+
{players.map((killer, killerIndex) => (
|
|
1169
|
+
<tr key={killer.steamId64}>
|
|
1170
|
+
<th className={`dak-duel-rowhead dak-duel-${killer.teamKey}`}>{killer.name}</th>
|
|
1171
|
+
{players.map((victim, victimIndex) => {
|
|
1172
|
+
const kills = matrix[killerIndex][victimIndex];
|
|
1173
|
+
const sameTeam = killer.teamKey === victim.teamKey;
|
|
1174
|
+
return (
|
|
1175
|
+
<td
|
|
1176
|
+
key={victim.steamId64}
|
|
1177
|
+
className={sameTeam ? "dak-duel-cell dak-duel-cell-same" : "dak-duel-cell"}
|
|
1178
|
+
style={kills > 0 && !sameTeam ? { background: `rgba(255, 122, 33, ${0.08 + (kills / maxCell) * 0.45})` } : undefined}
|
|
1179
|
+
title={`${killer.name} 击杀 ${victim.name} ×${kills}`}
|
|
1180
|
+
>
|
|
1181
|
+
{kills > 0 ? kills : ""}
|
|
1182
|
+
</td>
|
|
1183
|
+
);
|
|
1184
|
+
})}
|
|
1185
|
+
</tr>
|
|
1186
|
+
))}
|
|
1187
|
+
</tbody>
|
|
1188
|
+
</table>
|
|
446
1189
|
</div>
|
|
447
1190
|
</Panel>
|
|
1191
|
+
<Panel title="首杀尝试">
|
|
1192
|
+
<table className="dak-table">
|
|
1193
|
+
<thead>
|
|
1194
|
+
<tr>
|
|
1195
|
+
<th>选手</th>
|
|
1196
|
+
<th className="dak-num">首杀</th>
|
|
1197
|
+
<th className="dak-num">首死</th>
|
|
1198
|
+
<th className="dak-num">胜率</th>
|
|
1199
|
+
<th aria-label="胜率条" />
|
|
1200
|
+
</tr>
|
|
1201
|
+
</thead>
|
|
1202
|
+
<tbody>
|
|
1203
|
+
{[...openings]
|
|
1204
|
+
.sort((a, b) => (b.winRatePercent ?? -1) - (a.winRatePercent ?? -1))
|
|
1205
|
+
.map((row) => (
|
|
1206
|
+
<tr key={row.steamId64}>
|
|
1207
|
+
<td>
|
|
1208
|
+
<span className={`dak-team-dot dak-team-dot-${row.teamKey}`} /> {row.name}
|
|
1209
|
+
</td>
|
|
1210
|
+
<td className="dak-num dak-mono">{row.openingKills}</td>
|
|
1211
|
+
<td className="dak-num dak-mono">{row.openingDeaths}</td>
|
|
1212
|
+
<td className="dak-num dak-mono">{row.winRatePercent == null ? "—" : `${row.winRatePercent.toFixed(0)}%`}</td>
|
|
1213
|
+
<td className="dak-weapon-bar-cell">
|
|
1214
|
+
{row.winRatePercent != null && (
|
|
1215
|
+
<div className="dak-weapon-bar" style={{ width: `${row.winRatePercent}%` }} />
|
|
1216
|
+
)}
|
|
1217
|
+
</td>
|
|
1218
|
+
</tr>
|
|
1219
|
+
))}
|
|
1220
|
+
</tbody>
|
|
1221
|
+
</table>
|
|
1222
|
+
</Panel>
|
|
448
1223
|
</div>
|
|
449
1224
|
);
|
|
450
1225
|
}
|
|
@@ -541,19 +1316,12 @@ function iconForTab(key: WorkspaceView) {
|
|
|
541
1316
|
if (key === "rounds") return <ListChecks size={16} />;
|
|
542
1317
|
if (key === "players") return <Users size={16} />;
|
|
543
1318
|
if (key === "economy") return <BarChart3 size={16} />;
|
|
544
|
-
if (key === "
|
|
1319
|
+
if (key === "weapons") return <Crosshair size={16} />;
|
|
1320
|
+
if (key === "duels") return <Swords size={16} />;
|
|
1321
|
+
if (key === "map") return <MapIcon size={16} />;
|
|
545
1322
|
return <Film size={16} />;
|
|
546
1323
|
}
|
|
547
1324
|
|
|
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
1325
|
function summarizePlayerRoundFacts(facts: MatchWorkspaceModel["players"][number]["roundFacts"]) {
|
|
558
1326
|
return facts.reduce(
|
|
559
1327
|
(summary, fact) => ({
|
|
@@ -586,29 +1354,194 @@ function roundFactTags(fact: MatchWorkspaceModel["players"][number]["roundFacts"
|
|
|
586
1354
|
return tags.length > 0 ? tags : ["无 KAST"];
|
|
587
1355
|
}
|
|
588
1356
|
|
|
1357
|
+
/** 渲染 HP 条 + 护甲底色(HP 条盖在护甲层上;护甲层满宽时可区分全甲 vs 半甲)。 */
|
|
1358
|
+
function renderHpArmor(frame: WorkspaceReplayFrame) {
|
|
1359
|
+
const fullArmor = frame.hasHelmet && frame.armor >= 100;
|
|
1360
|
+
const armorColor = fullArmor ? "var(--dak-accent)" : "var(--dak-accent-b)";
|
|
1361
|
+
const armorLabel = !frame.hasHelmet ? "半甲" : frame.armor >= 100 ? "全甲" : `甲 ${frame.armor}`;
|
|
1362
|
+
return (
|
|
1363
|
+
<div className="dak-hp-bar-wrap" title={`${frame.hp} HP · ${armorLabel}`}>
|
|
1364
|
+
<div className="dak-hp-bar-track">
|
|
1365
|
+
<div className="dak-hp-bar" style={{ width: `${frame.hp}%`, background: hpBarColor(frame.hp) }} />
|
|
1366
|
+
</div>
|
|
1367
|
+
{frame.armor > 0 && (
|
|
1368
|
+
<div className="dak-armor-bar" style={{ width: `${frame.armor}%`, background: armorColor }} />
|
|
1369
|
+
)}
|
|
1370
|
+
</div>
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
589
1374
|
function hpBarColor(hp: number): string {
|
|
590
1375
|
if (hp > 60) return "var(--dak-ok)";
|
|
591
1376
|
if (hp > 30) return "var(--dak-warn)";
|
|
592
1377
|
return "var(--dak-danger)";
|
|
593
1378
|
}
|
|
594
1379
|
|
|
595
|
-
|
|
1380
|
+
// 刀/各类投掷物/C4 不算枪:用于判定逐帧手持是否应替换主武器显示
|
|
1381
|
+
function isGunWeapon(weapon: string): boolean {
|
|
1382
|
+
if (weapon === "c4" || weapon === "taser") return false;
|
|
1383
|
+
if (weapon.includes("knife") || weapon.includes("bayonet") || weapon === "karambit") return false;
|
|
1384
|
+
if (weapon.includes("grenade") || weapon === "flashbang" || weapon === "molotov" || weapon === "decoy") return false;
|
|
1385
|
+
return true;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function primaryLoadoutLabel(loadout: WorkspaceReplayLoadout): string {
|
|
1389
|
+
const weapon = loadout.primaryWeapon ?? loadout.secondaryWeapon;
|
|
1390
|
+
return weapon ? displayWeaponName(weapon) : "—";
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function heldUtilityLabel(loadout: WorkspaceReplayLoadout): string {
|
|
1394
|
+
if (loadout.grenades.length === 0) {
|
|
1395
|
+
return `道具 x${loadout.grenadeCount}`;
|
|
1396
|
+
}
|
|
1397
|
+
const counts = new Map<WorkspaceReplayLoadout["grenades"][number], number>();
|
|
1398
|
+
for (const grenade of loadout.grenades) {
|
|
1399
|
+
counts.set(grenade, (counts.get(grenade) ?? 0) + 1);
|
|
1400
|
+
}
|
|
1401
|
+
return [...counts.entries()]
|
|
1402
|
+
.map(([grenade, count]) => `${grenadeShortLabel(grenade)}${count > 1 ? count : ""}`)
|
|
1403
|
+
.join(" ");
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function grenadeShortLabel(grenade: WorkspaceReplayLoadout["grenades"][number]): string {
|
|
1407
|
+
if (grenade === "flashbang") return "闪";
|
|
1408
|
+
if (grenade === "smoke") return "烟";
|
|
1409
|
+
if (grenade === "molotov" || grenade === "incendiary") return "火";
|
|
1410
|
+
if (grenade === "hegrenade") return "雷";
|
|
1411
|
+
return "诱";
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function replayPointPercent(frame: { x: number; y: number }, map: MatchWorkspaceModel["map"]["view"]) {
|
|
596
1415
|
const calibration = getMapCalibration(map.name);
|
|
597
1416
|
if (calibration) {
|
|
598
1417
|
const radar = worldToRadar(frame, calibration);
|
|
599
1418
|
if (!radar.outOfBounds) {
|
|
600
1419
|
return {
|
|
601
|
-
|
|
602
|
-
|
|
1420
|
+
x: (radar.x / calibration.radarSize) * 100,
|
|
1421
|
+
y: (radar.y / calibration.radarSize) * 100
|
|
603
1422
|
};
|
|
604
1423
|
}
|
|
605
1424
|
}
|
|
606
1425
|
return {
|
|
607
|
-
|
|
608
|
-
|
|
1426
|
+
x: Math.max(4, Math.min(96, 50 + frame.x / 70)),
|
|
1427
|
+
y: Math.max(4, Math.min(96, 50 - frame.y / 70))
|
|
609
1428
|
};
|
|
610
1429
|
}
|
|
611
1430
|
|
|
1431
|
+
function replayFramePosition(frame: { x: number; y: number }, map: MatchWorkspaceModel["map"]["view"]) {
|
|
1432
|
+
const pos = replayPointPercent(frame, map);
|
|
1433
|
+
return { left: `${pos.x}%`, top: `${pos.y}%` };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** world 半径 → 相对舞台宽度的百分比;无标定时给一个保底视觉尺寸。 */
|
|
1437
|
+
function replayRadiusPercent(radiusUnits: number, map: MatchWorkspaceModel["map"]["view"]): number {
|
|
1438
|
+
const calibration = getMapCalibration(map.name);
|
|
1439
|
+
if (!calibration) return 4;
|
|
1440
|
+
return (radiusUnits / calibration.scale / calibration.radarSize) * 100;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// 效果消失 tick 缺失时的保底时长(秒);半径为近似游戏单位,只服务视觉示意。
|
|
1444
|
+
const GRENADE_EFFECT_DEFAULTS: Record<string, { durationSeconds: number; radiusUnits: number; kind: "smoke" | "fire" | "he" | "flash" | "decoy" }> = {
|
|
1445
|
+
smoke: { durationSeconds: 20, radiusUnits: 144, kind: "smoke" },
|
|
1446
|
+
molotov: { durationSeconds: 7, radiusUnits: 120, kind: "fire" },
|
|
1447
|
+
incendiary: { durationSeconds: 5.5, radiusUnits: 120, kind: "fire" },
|
|
1448
|
+
hegrenade: { durationSeconds: 0.7, radiusUnits: 90, kind: "he" },
|
|
1449
|
+
flashbang: { durationSeconds: 0.7, radiusUnits: 70, kind: "flash" },
|
|
1450
|
+
decoy: { durationSeconds: 15, radiusUnits: 30, kind: "decoy" }
|
|
1451
|
+
};
|
|
1452
|
+
|
|
1453
|
+
type ReplayRoundModel = MatchWorkspaceModel["replay"]["rounds"][number];
|
|
1454
|
+
|
|
1455
|
+
/** 道具效果(烟/火/爆/闪)+ 飞行轨迹叠加层,按 currentTick 过滤生命周期。
|
|
1456
|
+
* level 非 null 时(双层地图)效果只画在所属层,飞行物按当前帧 z 过滤。 */
|
|
1457
|
+
function GrenadeEffectLayer({ round, currentTick, tickrate, map, level = null, levelOf }: {
|
|
1458
|
+
round: ReplayRoundModel;
|
|
1459
|
+
currentTick: number;
|
|
1460
|
+
tickrate: number;
|
|
1461
|
+
map: MatchWorkspaceModel["map"]["view"];
|
|
1462
|
+
level?: MapLevel | null;
|
|
1463
|
+
levelOf?: (z: number) => MapLevel;
|
|
1464
|
+
}) {
|
|
1465
|
+
return (
|
|
1466
|
+
<>
|
|
1467
|
+
{round.grenades.map((row, index) => {
|
|
1468
|
+
const spec = GRENADE_EFFECT_DEFAULTS[row.grenade];
|
|
1469
|
+
if (!spec) return null;
|
|
1470
|
+
const endTick = row.destroyTick ?? row.effectTick + spec.durationSeconds * tickrate;
|
|
1471
|
+
if (currentTick < row.effectTick || currentTick > endTick) return null;
|
|
1472
|
+
if (level && levelOf && levelOf(row.effectZ ?? 0) !== level) return null;
|
|
1473
|
+
const sizePercent = replayRadiusPercent(spec.radiusUnits, map) * 2;
|
|
1474
|
+
const countdown = spec.kind === "smoke" ? Math.max(0, Math.ceil((endTick - currentTick) / tickrate)) : null;
|
|
1475
|
+
return (
|
|
1476
|
+
<span
|
|
1477
|
+
key={`fx-${index}`}
|
|
1478
|
+
className={`dak-replay-fx dak-replay-fx-${spec.kind}${row.throwerSide ? ` dak-replay-fx-${row.throwerSide}` : ""}`}
|
|
1479
|
+
style={{ ...replayFramePosition({ x: row.effectX, y: row.effectY }, map), width: `${sizePercent}%`, height: `${sizePercent}%` }}
|
|
1480
|
+
aria-hidden="true"
|
|
1481
|
+
>
|
|
1482
|
+
{countdown != null && <span className="dak-replay-fx-countdown">{countdown}</span>}
|
|
1483
|
+
</span>
|
|
1484
|
+
);
|
|
1485
|
+
})}
|
|
1486
|
+
<svg className="dak-replay-trajectories" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
1487
|
+
{round.projectiles.map((proj, index) => {
|
|
1488
|
+
const frameIdx = Math.floor((currentTick - proj.startTick) / round.tickStep);
|
|
1489
|
+
if (frameIdx < 0 || frameIdx >= proj.x.length) return null;
|
|
1490
|
+
const points = proj.x
|
|
1491
|
+
.slice(0, frameIdx + 1)
|
|
1492
|
+
.map((x, i) => {
|
|
1493
|
+
const pos = replayPointPercent({ x, y: proj.y[i] }, map);
|
|
1494
|
+
return `${pos.x},${pos.y}`;
|
|
1495
|
+
})
|
|
1496
|
+
.join(" ");
|
|
1497
|
+
return (
|
|
1498
|
+
<polyline
|
|
1499
|
+
key={`trail-${index}`}
|
|
1500
|
+
className={`dak-replay-trail dak-replay-trail-${proj.grenade}`}
|
|
1501
|
+
points={points}
|
|
1502
|
+
/>
|
|
1503
|
+
);
|
|
1504
|
+
})}
|
|
1505
|
+
</svg>
|
|
1506
|
+
{round.projectiles.map((proj, index) => {
|
|
1507
|
+
const frameIdx = Math.floor((currentTick - proj.startTick) / round.tickStep);
|
|
1508
|
+
if (frameIdx < 0 || frameIdx >= proj.x.length) return null;
|
|
1509
|
+
const projZ = proj.z?.[frameIdx];
|
|
1510
|
+
if (level && levelOf && projZ != null && levelOf(projZ) !== level) return null;
|
|
1511
|
+
return (
|
|
1512
|
+
<span
|
|
1513
|
+
key={`proj-${index}`}
|
|
1514
|
+
className={`dak-replay-projectile dak-replay-projectile-${proj.grenade}`}
|
|
1515
|
+
style={replayFramePosition({ x: proj.x[frameIdx], y: proj.y[frameIdx] }, map)}
|
|
1516
|
+
aria-hidden="true"
|
|
1517
|
+
/>
|
|
1518
|
+
);
|
|
1519
|
+
})}
|
|
1520
|
+
</>
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** 下包后的 C4 定点标记:拆除转绿、爆炸先闪后熄。 */
|
|
1525
|
+
function BombMarker({ bomb, currentTick, tickrate, map, offLevel = false }: {
|
|
1526
|
+
bomb: ReplayRoundModel["bomb"];
|
|
1527
|
+
currentTick: number;
|
|
1528
|
+
tickrate: number;
|
|
1529
|
+
map: MatchWorkspaceModel["map"]["view"];
|
|
1530
|
+
/** 双层地图且 C4 在另一层时隐藏。 */
|
|
1531
|
+
offLevel?: boolean;
|
|
1532
|
+
}) {
|
|
1533
|
+
if (!bomb || currentTick < bomb.plantTick || offLevel) return null;
|
|
1534
|
+
const defused = bomb.defuseTick != null && currentTick >= bomb.defuseTick;
|
|
1535
|
+
const exploded = !defused && bomb.explodeTick != null && currentTick >= bomb.explodeTick;
|
|
1536
|
+
const exploding = exploded && bomb.explodeTick != null && currentTick <= bomb.explodeTick + 2 * tickrate;
|
|
1537
|
+
const state = defused ? "defused" : exploded ? (exploding ? "exploding" : "exploded") : "armed";
|
|
1538
|
+
return (
|
|
1539
|
+
<span className={`dak-replay-bomb dak-replay-bomb-${state}`} style={replayFramePosition(bomb, map)} title={defused ? "C4 已拆除" : exploded ? "C4 已爆炸" : "C4 已安放"}>
|
|
1540
|
+
c4
|
|
1541
|
+
</span>
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
612
1545
|
export function AdminQaWorkspace({ model }: MatchWorkspaceProps) {
|
|
613
1546
|
return (
|
|
614
1547
|
<main className="dak-shell">
|