@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.
@@ -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
- export function MatchWorkspace({ model }: MatchWorkspaceProps) {
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 replayDetail = model.replay.available
21
- ? `${model.replay.sampleRate ?? 0} Hz · ${model.replay.rounds.length} 回合`
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
- <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" && (
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
- {view === "map" && <MapWorkspace model={model} />}
73
- {view === "replay" && <ReplayViewer replay={model.replay} map={model.map.view} />}
74
- </section>
75
- </div>
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={<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")} />
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
- function RoundExplorer({ model }: MatchWorkspaceProps) {
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 round = model.rounds.find((row) => row.roundNumber === selectedRound) ?? model.rounds[0];
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
- {model.rounds.map((row) => (
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 className="dak-timeline-row" key={event.id}>
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 className="dak-player-round-card" key={`${fact.steamId64}-${fact.roundNumber}`}>
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
- 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);
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
- setFrameIndex(0);
696
+ const selectedRound = replay.rounds.find((row) => row.roundNumber === roundNumber);
697
+ if (!selectedRound) return;
306
698
  setPlaying(false);
307
- }, [roundNumber]);
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 (!playing || !round || round.frameCount <= 1) return undefined;
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 >= round.frameCount - 1) {
716
+ if (value >= targetEndFrameIndex) {
315
717
  setPlaying(false);
316
- return round.frameCount - 1;
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, round?.frameCount, speed]);
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, Math.max(round.frameCount - 1, 0));
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[currentFrameIndex] ?? player.frames.find((frame) => frame.alive) ?? player.frames[0] }))
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
- <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
- ))}
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
- <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="前进">
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-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
- ))}
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
- <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 ? "可显示" : "暂不显示"} />
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
- </Panel>
399
- <Panel title={`R${round.roundNumber} 2D 回放`}>
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={map.radarImageUrl ? { backgroundImage: `url(${map.radarImageUrl})` } : undefined}
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 ? " · flashed" : ""}`}
1027
+ title={`${playerNumbers[player.steamId64] ?? "?"} ${player.name} · ${frame.hp} HP${frame.hasDefuseKit ? " · 拆弹器" : ""}${frame.flashed ? ` · ${frame.flashRemainingSeconds.toFixed(1)}s` : ""}`}
412
1028
  >
413
- <span style={{ transform: `rotate(${frame.yaw - 90}deg)` }}>{playerNumbers[player.steamId64] ?? "?"}</span>
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
- </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
- ))}
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 === "map") return <Map size={16} />;
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
- function replayFramePosition(frame: WorkspaceReplayFrame, map: MatchWorkspaceModel["map"]["view"]) {
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
- left: `${(radar.x / calibration.radarSize) * 100}%`,
602
- top: `${(radar.y / calibration.radarSize) * 100}%`
1420
+ x: (radar.x / calibration.radarSize) * 100,
1421
+ y: (radar.y / calibration.radarSize) * 100
603
1422
  };
604
1423
  }
605
1424
  }
606
1425
  return {
607
- left: `${Math.max(4, Math.min(96, 50 + frame.x / 70))}%`,
608
- top: `${Math.max(4, Math.min(96, 50 - frame.y / 70))}%`
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">