@cs2dak/presentation 1.0.0 → 2.0.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/src/workspace.ts CHANGED
@@ -9,11 +9,15 @@ import {
9
9
  type TeamKey,
10
10
  type WorkspaceKillEvent,
11
11
  type WorkspaceReplayFrame,
12
+ type WorkspaceReplayLoadout,
13
+ type WorkspaceReplayRound,
12
14
  type WorkspaceSpatialPoint
13
15
  } from "@cs2dak/contract";
16
+ import { decodeDelta } from "@cs2dak/contract";
14
17
  import { groupBy, nameForSteamId, round, normalizeWeapon, isNamedWeapon } from "./workspace-utils.js";
15
18
  import { displayWeaponName } from "./weapons.js";
16
- import { analyzeDemoPackage, normalizeDemoPackage } from "@cs2dak/core";
19
+ import { activeDamages, analyzeDemoPackage, normalizeDemoPackage } from "@cs2dak/core";
20
+ import { getMapCalibration, hasLowerLevel } from "@cs2dak/maps";
17
21
 
18
22
  export function buildDemoViewModel(bundle: AnalysisBundle) {
19
23
  return {
@@ -22,6 +26,7 @@ export function buildDemoViewModel(bundle: AnalysisBundle) {
22
26
  map: {
23
27
  name: bundle.mapName,
24
28
  radarImageUrl: radarImageUrlForMap(bundle.mapName),
29
+ lowerRadarImageUrl: lowerRadarImageUrlForMap(bundle.mapName),
25
30
  calibrated: bundle.heatmap.length > 0
26
31
  },
27
32
  scoreline: `${bundle.teams.teamA.score}:${bundle.teams.teamB.score}`,
@@ -62,6 +67,8 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
62
67
  { key: "rounds", label: "回合" },
63
68
  { key: "players", label: "选手" },
64
69
  { key: "economy", label: "经济" },
70
+ { key: "weapons", label: "武器" },
71
+ { key: "duels", label: "对位" },
65
72
  { key: "map", label: "地图" },
66
73
  { key: "replay", label: "回放" }
67
74
  ],
@@ -79,7 +86,8 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
79
86
  teamAEconomy: roundRow.teamAEconomy,
80
87
  teamBEconomy: roundRow.teamBEconomy,
81
88
  events: eventsByRound.get(roundRow.roundNumber) ?? [],
82
- playerFacts: factsByRound.get(roundRow.roundNumber) ?? []
89
+ playerFacts: factsByRound.get(roundRow.roundNumber) ?? [],
90
+ facets: buildRoundFacets(pkg, roundRow.roundNumber)
83
91
  })),
84
92
  players: bundle.scoreboard.map((row) => ({
85
93
  row,
@@ -88,6 +96,7 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
88
96
  rrBreakdown: [
89
97
  { key: "combat", label: "Combat", value: row.accountBreakdown.combat },
90
98
  { key: "trade", label: "Trade", value: row.accountBreakdown.trade },
99
+ { key: "mapControl", label: "MapControl", value: row.accountBreakdown.mapControl },
91
100
  { key: "clutch", label: "Clutch", value: row.accountBreakdown.clutch },
92
101
  { key: "objective", label: "Objective", value: row.accountBreakdown.objective },
93
102
  { key: "utility", label: "Utility", value: row.accountBreakdown.utility }
@@ -95,16 +104,140 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
95
104
  roundFacts: factsByPlayer.get(row.steamId64) ?? []
96
105
  })),
97
106
  economy: bundle.economy,
107
+ weapons: buildWorkspaceWeapons(pkg),
108
+ duels: buildWorkspaceDuels(pkg, bundle),
98
109
  map: buildWorkspaceMap(pkg, view.map, bundle.heatmap),
99
110
  replay: buildWorkspaceReplay(pkg),
100
111
  adminQa: bundle.qa
101
112
  });
102
113
  }
103
114
 
115
+ /** 回合筛选与时间轴锚点的派生事实(v0.2 query-first)。 */
116
+ function buildRoundFacets(pkg: DemoPackage, roundNumber: number) {
117
+ const kills = pkg.kills
118
+ .filter((kill) => kill.roundNumber === roundNumber)
119
+ .sort((a, b) => a.tick - b.tick);
120
+ const bombs = pkg.bombs.filter((bomb) => bomb.roundNumber === roundNumber);
121
+ const plant = bombs.find((bomb) => bomb.type === "planted") ?? null;
122
+ const defuse = bombs.find((bomb) => bomb.type === "defused") ?? null;
123
+ const clutch = pkg.clutches.find((row) => row.roundNumber === roundNumber) ?? null;
124
+ const firstKill = kills[0] ?? null;
125
+
126
+ const killsByPlayer = new Map<string, number>();
127
+ for (const kill of kills) {
128
+ const killerPlayer = kill.killerIndex != null ? pkg.players[kill.killerIndex] : undefined;
129
+ const victimPlayer = pkg.players[kill.victimIndex];
130
+ if (!killerPlayer || !victimPlayer || killerPlayer.teamKey === victimPlayer.teamKey) continue;
131
+ killsByPlayer.set(killerPlayer.steamId64, (killsByPlayer.get(killerPlayer.steamId64) ?? 0) + 1);
132
+ }
133
+
134
+ return {
135
+ bombSite: plant?.site ?? null,
136
+ bombPlantTick: plant?.tick ?? null,
137
+ bombDefuseTick: defuse?.tick ?? null,
138
+ firstKillTick: firstKill?.tick ?? null,
139
+ firstKillSteamId64: firstKill && firstKill.killerIndex != null ? (pkg.players[firstKill.killerIndex]?.steamId64 ?? null) : null,
140
+ firstKillTeamKey: firstKill && firstKill.killerIndex != null ? (pkg.players[firstKill.killerIndex]?.teamKey ?? null) : null,
141
+ clutch: clutch
142
+ ? {
143
+ steamId64: pkg.players[clutch.clutcherIndex]?.steamId64 ?? "",
144
+ teamKey: pkg.players[clutch.clutcherIndex]?.teamKey ?? "",
145
+ opponentCount: clutch.opponentCount,
146
+ won: clutch.won,
147
+ tick: clutch.tick
148
+ }
149
+ : null,
150
+ maxKillsByOnePlayer: Math.max(0, ...killsByPlayer.values()),
151
+ wallbangKills: kills.filter((kill) => kill.penetratedObjects > 0).length,
152
+ throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length
153
+ };
154
+ }
155
+
156
+ /** 比赛级武器统计:击杀来自 kills.json,伤害来自 damages.json(healthDamage 口径)。 */
157
+ function buildWorkspaceWeapons(pkg: DemoPackage) {
158
+ const killsByWeapon = groupBy(
159
+ pkg.kills.filter((kill) => isNamedWeapon(kill.weapon)),
160
+ (kill) => normalizeWeapon(kill.weapon)
161
+ );
162
+ const damageByWeapon = new Map<string, number>();
163
+ for (const damage of activeDamages(pkg)) {
164
+ if (!isNamedWeapon(damage.weapon)) continue;
165
+ const key = normalizeWeapon(damage.weapon);
166
+ damageByWeapon.set(key, (damageByWeapon.get(key) ?? 0) + damage.healthDamage);
167
+ }
168
+
169
+ return [...killsByWeapon.entries()]
170
+ .map(([weapon, kills]) => {
171
+ const killerCounts = new Map<string, number>();
172
+ for (const kill of kills) {
173
+ if (kill.killerIndex == null) continue;
174
+ const killer = pkg.players[kill.killerIndex];
175
+ if (killer) killerCounts.set(killer.steamId64, (killerCounts.get(killer.steamId64) ?? 0) + 1);
176
+ }
177
+ const topKiller = [...killerCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
178
+ const headshots = kills.filter((kill) => kill.headshot).length;
179
+ return {
180
+ weapon,
181
+ label: displayWeaponName(weapon),
182
+ kills: kills.length,
183
+ headshotPercent: kills.length > 0 ? round((headshots / kills.length) * 100, 1) : null,
184
+ damage: damageByWeapon.get(weapon) ?? 0,
185
+ wallbangKills: kills.filter((kill) => kill.penetratedObjects > 0).length,
186
+ noScopeKills: kills.filter((kill) => kill.noScope).length,
187
+ throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length,
188
+ topKillerName: topKiller ? nameForSteamId(pkg, topKiller[0]) : null,
189
+ topKillerKills: topKiller ? topKiller[1] : 0
190
+ };
191
+ })
192
+ .sort((a, b) => b.kills - a.kills);
193
+ }
194
+
195
+ /** 对位:10x10 击杀矩阵(teamA 在前)+ 开局对枪统计(来自 playerRoundFacts)。 */
196
+ function buildWorkspaceDuels(pkg: DemoPackage, bundle: AnalysisBundle) {
197
+ const players = [...bundle.scoreboard]
198
+ .sort((a, b) => (a.teamKey === b.teamKey ? b.accountRR - a.accountRR : a.teamKey === "teamA" ? -1 : 1))
199
+ .map((row) => ({ steamId64: row.steamId64, name: row.name, teamKey: row.teamKey }));
200
+ const indexBySteamId = new Map(players.map((player, index) => [player.steamId64, index]));
201
+
202
+ const matrix = players.map(() => players.map(() => 0));
203
+ for (const kill of pkg.kills) {
204
+ if (kill.killerIndex == null) continue;
205
+ const killer = pkg.players[kill.killerIndex];
206
+ const victim = pkg.players[kill.victimIndex];
207
+ if (!killer || !victim) continue;
208
+ const killerIdx = indexBySteamId.get(killer.steamId64);
209
+ const victimIdx = indexBySteamId.get(victim.steamId64);
210
+ if (killerIdx == null || victimIdx == null) continue;
211
+ matrix[killerIdx][victimIdx] += 1;
212
+ }
213
+
214
+ const factsByPlayer = groupBy(bundle.playerRoundFacts, (fact) => fact.steamId64);
215
+ const openings = players.map((player) => {
216
+ const facts = factsByPlayer.get(player.steamId64) ?? [];
217
+ const openingKills = facts.filter((fact) => fact.openingDuel === "won").length;
218
+ const openingDeaths = facts.filter((fact) => fact.openingDuel === "lost").length;
219
+ const total = openingKills + openingDeaths;
220
+ return {
221
+ ...player,
222
+ openingKills,
223
+ openingDeaths,
224
+ winRatePercent: total > 0 ? round((openingKills / total) * 100, 1) : null
225
+ };
226
+ });
227
+
228
+ return { players, matrix, openings };
229
+ }
230
+
104
231
  function radarImageUrlForMap(mapName: string): string | null {
105
- const knownMaps = new Set(["de_ancient", "de_anubis", "de_dust2", "de_inferno", "de_mirage", "de_nuke", "de_overpass"]);
232
+ // 有标定即有底图:apps public/maps/radars/ MAP_CALIBRATIONS 保持同套地图。
106
233
  // Relative path so it resolves from both http:// dev server and file:// pywebview.
107
- return knownMaps.has(mapName) ? `./maps/radars/${mapName}.png` : null;
234
+ return getMapCalibration(mapName) ? `./maps/radars/${mapName}.png` : null;
235
+ }
236
+
237
+ /** 双层地图(标定带 lowerLevelMaxUnits)的下层底图({map}_lower.png)。 */
238
+ function lowerRadarImageUrlForMap(mapName: string): string | null {
239
+ const calibration = getMapCalibration(mapName);
240
+ return calibration && hasLowerLevel(calibration) ? `./maps/radars/${mapName}_lower.png` : null;
108
241
  }
109
242
 
110
243
  function buildWorkspaceKpis(bundle: AnalysisBundle) {
@@ -177,11 +310,11 @@ function sideLabelZh(side: "ct" | "t" | null): string {
177
310
  const STORY_COPY = {
178
311
  headline: {
179
312
  /** 比分差 → 动词。 */
180
- verb: (margin: number) => (margin >= 8 ? "碾压" : margin <= 2 ? "险胜" : "拿下"),
313
+ verb: (margin: number) => (margin >= 8 ? "大比分战胜" : margin <= 2 ? "险胜" : "战胜"),
181
314
  comeback: (firstHalf: number, secondHalf: number) =>
182
- `上半场只拿到 ${firstHalf} 分被压着打,换边后连下 ${secondHalf} 分完成翻盘`,
315
+ `上半场拿到 ${firstHalf} 分,换边后取得 ${secondHalf} 分完成比分反超`,
183
316
  frontRunner: (firstHalf: number, secondHalf: number) =>
184
- `上半场就先声夺人拿下 ${firstHalf} 分,下半场再补 ${secondHalf} 分锁死悬念`,
317
+ `上半场拿到 ${firstHalf} 分,下半场再取得 ${secondHalf} 分`,
185
318
  even: (firstHalf: number, secondHalf: number) =>
186
319
  `上半场拿 ${firstHalf} 分、下半场再添 ${secondHalf} 分`,
187
320
  overtime: (overtimeWins: number) => `,加时又咬下 ${overtimeWins} 分才分出胜负`,
@@ -193,26 +326,26 @@ const STORY_COPY = {
193
326
  context: (sideLabel: string, crossSwitch: boolean, ownedByWinner: boolean) =>
194
327
  !ownedByWinner ? "" : crossSwitch ? "横跨换边" : `靠 ${sideLabel} 半场`,
195
328
  line: (startRound: number, endRound: number, length: number, team: string, context: string) =>
196
- `真正拉开差距的是 R${startRound}-R${endRound} 那波 ${length} 连胜,${team}${context}借此锁定胜局。`
329
+ `${team} R${startRound}-R${endRound} 取得 ${length} 连胜${context ? `(${context})` : ""},这是本场最长连胜段。`
197
330
  },
198
331
  pistol: {
199
- sweep: "这场比赛几乎已经拿下一半。",
200
- none: "这场胜利完全是靠后面一分分追回来的。",
201
- split: "另一个交还给对手,靠后续长枪局把节奏拉了回来。",
332
+ sweep: "两次手枪局均取胜。",
333
+ none: "两次手枪局均未取胜,胜局来自后续回合。",
334
+ split: "双方各取一次手枪局。",
202
335
  line: (total: number, winner: string, wins: number, tail: string) =>
203
336
  `${total} 个手枪局 ${winner} 拿下 ${wins} 个,${tail}`
204
337
  },
205
338
  clutch: {
206
339
  line: (who: string, teamTag: string, round: number, opponents: number) =>
207
- `最精彩的残局是 ${who}${teamTag}在 R${round} 1v${opponents} 残局翻盘,这种回合最能鼓舞士气。`
340
+ `样本中人数规模最大的残局是 ${who}${teamTag}在 R${round} 完成的 1v${opponents}。`
208
341
  },
209
342
  entry: {
210
343
  line: (name: string, entryKills: number) =>
211
- `${name} 是队里首杀最多的选手,${entryKills} 次首杀大多由他先行完成。`
344
+ `${name} 记录到 ${entryKills} 次首杀,为队内最多。`
212
345
  },
213
346
  lowBuy: {
214
347
  line: (winner: string, count: number, round: number) =>
215
- `${winner} 还在 ${count} 个eco或半起中完成了对对手的翻盘(如 R${round}),这种以弱胜强是对对手经济的严重打击。`
348
+ `${winner} ${count} 个低经济回合中取胜,代表回合为 R${round}。`
216
349
  },
217
350
  closeout: {
218
351
  reason: {
@@ -223,21 +356,22 @@ const STORY_COPY = {
223
356
  target_saved: "拖到时间耗尽"
224
357
  } as Record<string, string>,
225
358
  line: (winner: string, count: number, total: number, reason: string) =>
226
- `${winner} 的胜局里有 ${count}/${total} 个靠${reason}收尾,打法偏好相当鲜明。`
359
+ `${winner} 的胜局中有 ${count}/${total} 个以${reason}结束。`
227
360
  },
228
361
  mvp: {
229
362
  dom: {
230
- combat: "纯粹的枪法压制",
231
- trade: "默契的补枪联动",
232
- clutch: "残局里的收割能力",
233
- objective: "对包点目标的推进",
234
- utility: "道具开路的支援"
363
+ combat: "Combat 项",
364
+ trade: "Trade 项",
365
+ mapControl: "MapControl 项",
366
+ clutch: "Clutch 项",
367
+ objective: "Objective 项",
368
+ utility: "Utility 项"
235
369
  } as Record<string, string>,
236
370
  winnerAdrTail: (name: string, adr: string) => `;场均伤害最高的则是 ${name}(ADR ${adr})`,
237
371
  winner: (name: string, kda: string, adr: string, rr: string, dom: string, adrTail: string) =>
238
- `胜者这边 ${name} 打得最稳,${kda}、场均 ${adr} 伤害,全场最高的 RR ${rr} 主要来自${dom}${adrTail}。`,
372
+ `胜方 RR 最高的是 ${name}(${kda}、ADR ${adr}RR ${rr}),六账户分解中${dom}最突出${adrTail}。`,
239
373
  loser: (rr: string, kda: string, name: string, team: string, dom: string) =>
240
- `值得一提的是,全场 RR 最高(${rr},${kda})的 ${name} 来自落败的 ${team},凭${dom}撑起了大部分火力,可惜独木难支。`
374
+ `全场 RR 最高的是落败方 ${team} ${name}(${rr},${kda}),六账户分解中${dom}最突出。`
241
375
  }
242
376
  } as const;
243
377
 
@@ -415,7 +549,7 @@ function clutchBeat(ctx: StoryContext): StoryBeat | null {
415
549
  const won = ctx.pkg.clutches.filter((row) => row.won && (row.opponentCount ?? 0) >= 2);
416
550
  if (won.length === 0) return null;
417
551
  const top = [...won].sort((a, b) => (b.opponentCount ?? 0) - (a.opponentCount ?? 0))[0]!;
418
- const player = ctx.pkg.players.find((row) => row.steamId64 === top.clutcherSteamId64);
552
+ const player = ctx.pkg.players[top.clutcherIndex];
419
553
  const who = player?.name ?? "某选手";
420
554
  const teamName = player
421
555
  ? player.teamKey === "teamA"
@@ -547,29 +681,15 @@ function buildWorkspaceMap(pkg: DemoPackage, view: ReturnType<typeof buildDemoVi
547
681
  y: bomb.position.y,
548
682
  z: bomb.position.z,
549
683
  roundNumber: bomb.roundNumber,
550
- teamKey: bomb.actorTeamKey,
551
- steamId64: bomb.actorSteamId64,
684
+ teamKey: bomb.actorIndex != null ? (pkg.players[bomb.actorIndex]?.teamKey ?? null) : null,
685
+ steamId64: bomb.actorIndex != null ? (pkg.players[bomb.actorIndex]?.steamId64 ?? null) : null,
552
686
  kind: "bomb",
553
687
  side: null,
554
688
  grenadeType: null
555
689
  }));
556
- const positionPoints: WorkspaceSpatialPoint[] = (pkg.positions1s ?? [])
557
- .filter((row) => row.position && (row.position.x !== 0 || row.position.y !== 0))
558
- .map((row) => ({
559
- x: row.position?.x ?? 0,
560
- y: row.position?.y ?? 0,
561
- z: row.position?.z ?? 0,
562
- roundNumber: row.roundNumber,
563
- teamKey: row.teamKey,
564
- steamId64: row.steamId64,
565
- kind: "position",
566
- side: null,
567
- grenadeType: null
568
- }));
569
690
  const points: WorkspaceSpatialPoint[] = [
570
691
  ...heatmap.map((point) => ({ ...point, kind: point.kind })),
571
- ...bombPoints,
572
- ...positionPoints
692
+ ...bombPoints
573
693
  ];
574
694
  const count = (kind: WorkspaceSpatialPoint["kind"]) => points.filter((point) => point.kind === kind).length;
575
695
  const hasPositionData = points.length > 0;
@@ -605,51 +725,127 @@ function buildWorkspaceReplay(pkg: DemoPackage) {
605
725
  tickrate: null,
606
726
  rounds: [],
607
727
  capabilities: {
608
- hasDefuseKit: false,
609
- hasBombPosition: false
728
+ hasDefuseKit: false
610
729
  }
611
730
  };
612
731
  }
613
732
 
614
733
  const killsByRound = groupBy(pkg.kills, (k) => k.roundNumber);
734
+ const grenadesByRound = groupBy(pkg.grenades, (g) => g.roundNumber);
735
+ const bombsByRound = groupBy(pkg.bombs, (b) => b.roundNumber);
736
+ const roundsByNumber = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
737
+ const endTickByRound = new Map(pkg.rounds.map((r) => [r.roundNumber, r.endTick]));
738
+ const sortedPackageRounds = [...pkg.rounds].sort((a, b) => a.startTick - b.startTick);
739
+ const targetEndTickByRound = new Map<number, number>();
740
+ sortedPackageRounds.forEach((round, index) => {
741
+ targetEndTickByRound.set(
742
+ round.roundNumber,
743
+ sortedPackageRounds[index + 1]?.startTick ?? round.endTick
744
+ );
745
+ });
746
+ const economyByRoundPlayer = new Map(pkg.playerEconomies.map((row) => [`${row.roundNumber}:${row.playerIndex}`, row]));
615
747
 
616
748
  let hasDefuseKit = false;
617
- const rounds = replay.rounds.map((roundRow) => ({
618
- roundNumber: roundRow.roundNumber,
619
- startTick: roundRow.startTick,
620
- tickStep: roundRow.tickStep,
621
- frameCount: roundRow.frameCount,
622
- kills: buildRoundKills(pkg, killsByRound.get(roundRow.roundNumber) ?? []),
623
- players: roundRow.players.map((player) => {
624
- const frames: WorkspaceReplayFrame[] = [];
625
- for (let index = 0; index < roundRow.frameCount; index += 1) {
626
- const flags = player.flags[index] ?? 0;
627
- const frame = {
628
- tick: roundRow.startTick + index * roundRow.tickStep,
629
- x: player.x[index] ?? 0,
630
- y: player.y[index] ?? 0,
631
- z: player.z[index] ?? 0,
632
- yaw: player.yaw[index] ?? 0,
633
- hp: player.hp[index] ?? 0,
634
- weapon: weaponNameForIndex(replay.weaponDict, player.weapon[index] ?? -1),
635
- alive: (flags & 1) !== 0,
636
- flashed: (flags & 8) !== 0,
637
- hasDefuseKit: (flags & 4) !== 0
638
- };
639
- if (frame.hasDefuseKit) {
640
- hasDefuseKit = true;
749
+ const rounds = replay.rounds.map((roundRow) => {
750
+ const sourceRound = roundsByNumber.get(roundRow.roundNumber);
751
+ const sideForTeam = (teamKey: TeamKey | null | undefined) => {
752
+ if (!sourceRound || !teamKey) return null;
753
+ return teamKey === "teamA" ? sourceRound.teamASide : sourceRound.teamBSide;
754
+ };
755
+
756
+ const officialEndTick = endTickByRound.get(roundRow.roundNumber);
757
+ const targetEndTick = targetEndTickByRound.get(roundRow.roundNumber) ?? officialEndTick;
758
+
759
+ return {
760
+ roundNumber: roundRow.roundNumber,
761
+ startTick: roundRow.startTick,
762
+ freezeEndTick: sourceRound?.freezeEndTick ?? roundRow.startTick,
763
+ tickStep: roundRow.tickStep,
764
+ frameCount: roundRow.frameCount,
765
+ officialEndTick,
766
+ targetEndTick,
767
+ kills: buildRoundKills(pkg, killsByRound.get(roundRow.roundNumber) ?? []),
768
+ grenades: (grenadesByRound.get(roundRow.roundNumber) ?? []).map((row) => ({
769
+ grenade: row.grenade,
770
+ throwerSide: sideForTeam(pkg.players[row.throwerIndex]?.teamKey),
771
+ throwTick: row.throwTick,
772
+ effectTick: row.effectTick,
773
+ destroyTick: row.destroyTick,
774
+ throwX: row.throwPosition.x,
775
+ throwY: row.throwPosition.y,
776
+ effectX: row.effectPosition.x,
777
+ effectY: row.effectPosition.y,
778
+ effectZ: row.effectPosition.z
779
+ })),
780
+ // v2.3+ 导出包才带飞行轨迹;旧包置空,渲染端按"无数据"处理
781
+ // v3 起 x/y/z 为差分编码(首元素绝对值 + 后续 delta),前缀和解码后 × coordScale 还原游戏单位
782
+ projectiles: (roundRow.projectiles ?? []).map((proj) => ({
783
+ grenade: proj.grenade,
784
+ startTick: proj.startTick,
785
+ x: decodeDelta(proj.x).map((v) => v * (replay.meta.coordScale || 1)),
786
+ y: decodeDelta(proj.y).map((v) => v * (replay.meta.coordScale || 1)),
787
+ z: decodeDelta(proj.z ?? []).map((v) => v * (replay.meta.coordScale || 1))
788
+ })),
789
+ bomb: buildRoundBomb(bombsByRound.get(roundRow.roundNumber) ?? []),
790
+ groundBombs: buildGroundBombs(
791
+ bombsByRound.get(roundRow.roundNumber) ?? [],
792
+ targetEndTick ?? roundRow.startTick + roundRow.frameCount * roundRow.tickStep
793
+ ),
794
+ groundDefusers: buildGroundDefusers(
795
+ roundRow,
796
+ replay.meta.coordScale || 1,
797
+ sourceRound?.freezeEndTick ?? roundRow.startTick,
798
+ targetEndTick ?? roundRow.startTick + roundRow.frameCount * roundRow.tickStep
799
+ ),
800
+ players: roundRow.players.map((player) => {
801
+ // v3 起 x/y/z/yaw 为差分编码,flash 改为独立列(0.1 秒单位,flags 不再含致盲位)
802
+ const coordScale = replay.meta.coordScale || 1;
803
+ const angleScale = replay.meta.angleScale || 1;
804
+ const xs = decodeDelta(player.x);
805
+ const ys = decodeDelta(player.y);
806
+ const zs = decodeDelta(player.z);
807
+ const yaws = decodeDelta(player.yaw);
808
+ const replayPlayer = pkg.players[player.playerIndex];
809
+ const economy = economyByRoundPlayer.get(`${roundRow.roundNumber}:${player.playerIndex}`);
810
+ // 此位选手本回合是否戴头盔(player-economies 逐回合数据,全帧相同)
811
+ const hasHelmet = economy?.hasHelmet ?? false;
812
+ const loadout = loadoutForEconomy(economy);
813
+ const frames: WorkspaceReplayFrame[] = [];
814
+ for (let index = 0; index < roundRow.frameCount; index += 1) {
815
+ const flags = player.flags[index] ?? 0;
816
+ const frame = {
817
+ tick: roundRow.startTick + index * roundRow.tickStep,
818
+ x: (xs[index] ?? 0) * coordScale,
819
+ y: (ys[index] ?? 0) * coordScale,
820
+ z: (zs[index] ?? 0) * coordScale,
821
+ yaw: (yaws[index] ?? 0) / angleScale,
822
+ hp: player.hp[index] ?? 0,
823
+ armor: player.armor?.[index] ?? 0,
824
+ weapon: weaponNameForIndex(replay.weaponDict, player.weapon[index] ?? -1),
825
+ grenades: normalizeHeldGrenades((player as { grenades?: unknown[][] }).grenades?.[index]),
826
+ alive: (flags & 1) !== 0,
827
+ flashed: (player.flash?.[index] ?? 0) > 0,
828
+ flashRemainingSeconds: Math.max(0, (player.flash?.[index] ?? 0) / 10),
829
+ hasDefuseKit: (flags & 4) !== 0,
830
+ hasBomb: (flags & 2) !== 0,
831
+ hasHelmet
832
+ };
833
+ if (frame.hasDefuseKit) {
834
+ hasDefuseKit = true;
835
+ }
836
+ frames.push(frame);
641
837
  }
642
- frames.push(frame);
643
- }
644
- return {
645
- steamId64: player.steamId64,
646
- name: nameForSteamId(pkg, player.steamId64) ?? player.steamId64,
647
- teamKey: player.teamKey,
648
- side: player.side,
649
- frames
650
- };
651
- })
652
- }));
838
+ return {
839
+ steamId64: replayPlayer?.steamId64 ?? String(player.playerIndex),
840
+ name: replayPlayer?.name ?? `Player ${player.playerIndex}`,
841
+ teamKey: replayPlayer?.teamKey ?? "teamA",
842
+ side: sideForTeam(replayPlayer?.teamKey) ?? "t",
843
+ loadout,
844
+ frames
845
+ };
846
+ })
847
+ };
848
+ });
653
849
 
654
850
  return {
655
851
  available: true,
@@ -657,12 +853,118 @@ function buildWorkspaceReplay(pkg: DemoPackage) {
657
853
  tickrate: replay.meta.tickrate,
658
854
  rounds,
659
855
  capabilities: {
660
- hasDefuseKit,
661
- hasBombPosition: false
856
+ hasDefuseKit
662
857
  }
663
858
  };
664
859
  }
665
860
 
861
+ const HELD_GRENADE_TYPES = new Set(["flashbang", "smoke", "molotov", "incendiary", "hegrenade", "decoy"]);
862
+
863
+ function loadoutForEconomy(economy: DemoPackage["playerEconomies"][number] | undefined): WorkspaceReplayLoadout {
864
+ const maybeGrenades = (economy as { grenades?: unknown } | undefined)?.grenades;
865
+ const rawGrenades = Array.isArray(maybeGrenades) ? maybeGrenades : [];
866
+ return {
867
+ primaryWeapon: economy?.primaryWeapon ?? null,
868
+ secondaryWeapon: economy?.secondaryWeapon ?? null,
869
+ grenadeCount: economy?.grenadeCount ?? 0,
870
+ grenades: rawGrenades.filter((value): value is WorkspaceReplayLoadout["grenades"][number] =>
871
+ typeof value === "string" && HELD_GRENADE_TYPES.has(value)
872
+ )
873
+ };
874
+ }
875
+
876
+ function normalizeHeldGrenades(value: unknown): WorkspaceReplayLoadout["grenades"] {
877
+ if (!Array.isArray(value)) return [];
878
+ return value.filter((item): item is WorkspaceReplayLoadout["grenades"][number] =>
879
+ typeof item === "string" && HELD_GRENADE_TYPES.has(item)
880
+ );
881
+ }
882
+
883
+ /** 从 bombs.json 取该回合 C4 锚点:plant 位置定格,defuse/explode 决定终态。 */
884
+ function buildRoundBomb(events: DemoPackage["bombs"]): WorkspaceReplayRound["bomb"] {
885
+ const plant = events.find((event) => event.type === "planted");
886
+ if (!plant) return null;
887
+ return {
888
+ plantTick: plant.tick,
889
+ x: plant.position.x,
890
+ y: plant.position.y,
891
+ z: plant.position.z,
892
+ defuseTick: events.find((event) => event.type === "defused")?.tick ?? null,
893
+ explodeTick: events.find((event) => event.type === "exploded")?.tick ?? null
894
+ };
895
+ }
896
+
897
+ /** dropped → 下一次 picked_up / planted / exploded / defused 构成一段地面区间。 */
898
+ function buildGroundBombs(events: DemoPackage["bombs"], roundEndTick: number): WorkspaceReplayRound["groundBombs"] {
899
+ const sorted = [...events].sort((a, b) => a.tick - b.tick);
900
+ const intervals: { startTick: number; endTick: number; x: number; y: number; z: number }[] = [];
901
+ for (let i = 0; i < sorted.length; i += 1) {
902
+ if (sorted[i].type !== "dropped") continue;
903
+ const drop = sorted[i];
904
+ // 找下一个相关事件:picked_up / planted / defused / exploded
905
+ const next = sorted.slice(i + 1).find((e) =>
906
+ e.type === "picked_up" || e.type === "planted" || e.type === "defused" || e.type === "exploded"
907
+ );
908
+ intervals.push({
909
+ startTick: drop.tick,
910
+ endTick: next?.tick ?? roundEndTick,
911
+ x: drop.position.x,
912
+ y: drop.position.y,
913
+ z: drop.position.z
914
+ });
915
+ }
916
+ return intervals;
917
+ }
918
+
919
+ /**
920
+ * 拆弹器掉落区间:CS2 中 kit 只在持有者阵亡时落地(不可手动丢弃)。
921
+ * 逐帧扫描每名玩家的存活位(flags&1)与持 kit 位(flags&4):
922
+ * - 阵亡掉落:上一帧"存活且持 kit" → 本帧"阵亡" ⇒ 在上一帧位置生成一个落地 kit;
923
+ * - 被捡起:某玩家存活且 kit 由无变有(freezeEnd 之后,排除买阶段初始持有)⇒ 关闭最早一个落地 kit(FIFO)。
924
+ * 未被捡起的落地 kit 持续到回合结束(符合"钳子一直留在地上")。
925
+ */
926
+ function buildGroundDefusers(
927
+ roundRow: NonNullable<DemoPackage["replay"]>["rounds"][number],
928
+ coordScale: number,
929
+ freezeEndTick: number,
930
+ roundEndTick: number
931
+ ): WorkspaceReplayRound["groundDefusers"] {
932
+ const decoded = roundRow.players.map((p) => ({
933
+ x: decodeDelta(p.x),
934
+ y: decodeDelta(p.y),
935
+ z: decodeDelta(p.z),
936
+ flags: p.flags
937
+ }));
938
+ const result: WorkspaceReplayRound["groundDefusers"] = [];
939
+ const active: Array<{ startTick: number; x: number; y: number; z: number }> = [];
940
+ for (let i = 1; i < roundRow.frameCount; i += 1) {
941
+ const tick = roundRow.startTick + i * roundRow.tickStep;
942
+ if (tick < freezeEndTick) continue;
943
+ for (const p of decoded) {
944
+ const prev = p.flags[i - 1] ?? 0;
945
+ const cur = p.flags[i] ?? 0;
946
+ const prevAlive = (prev & 1) !== 0;
947
+ const prevKit = (prev & 4) !== 0;
948
+ const curAlive = (cur & 1) !== 0;
949
+ const curKit = (cur & 4) !== 0;
950
+ if (prevAlive && prevKit && !curAlive) {
951
+ active.push({
952
+ startTick: tick,
953
+ x: (p.x[i - 1] ?? 0) * coordScale,
954
+ y: (p.y[i - 1] ?? 0) * coordScale,
955
+ z: (p.z[i - 1] ?? 0) * coordScale
956
+ });
957
+ }
958
+ if (curAlive && !prevKit && curKit && active.length > 0) {
959
+ const dropped = active.shift()!;
960
+ result.push({ ...dropped, endTick: tick });
961
+ }
962
+ }
963
+ }
964
+ for (const dropped of active) result.push({ ...dropped, endTick: roundEndTick });
965
+ return result;
966
+ }
967
+
666
968
  function weaponNameForIndex(weaponDict: string[], index: number): string | null {
667
969
  const raw = index >= 0 ? weaponDict[index] ?? null : null;
668
970
  if (!raw) {
@@ -679,18 +981,28 @@ function buildRoundKills(pkg: DemoPackage, kills: DemoPackage["kills"]): Workspa
679
981
  return kills.map((kill, index) => {
680
982
  const activeRaw = kill.killerActiveWeapon;
681
983
  const weaponRaw = activeRaw && isNamedWeapon(normalizeWeapon(activeRaw)) ? activeRaw : kill.weapon;
984
+ const killerSteamId = kill.killerIndex != null ? pkg.players[kill.killerIndex]?.steamId64 ?? null : null;
985
+ const victimSteamId = pkg.players[kill.victimIndex]?.steamId64 ?? "";
986
+ const killerTeamKey = kill.killerIndex != null ? (pkg.players[kill.killerIndex]?.teamKey ?? null) : null;
682
987
  return {
683
988
  id: `kf-${kill.roundNumber}-${kill.tick}-${index}`,
684
989
  tick: kill.tick,
685
- killerName: nameForSteamId(pkg, kill.killerSteamId64),
686
- killerTeamKey: kill.killerTeamKey,
687
- victimName: nameForSteamId(pkg, kill.victimSteamId64) ?? kill.victimSteamId64,
990
+ killerName: nameForSteamId(pkg, killerSteamId),
991
+ killerTeamKey,
992
+ victimName: nameForSteamId(pkg, victimSteamId) ?? victimSteamId,
688
993
  weapon: displayWeaponName(weaponRaw),
689
994
  headshot: kill.headshot,
690
995
  throughSmoke: kill.throughSmoke,
691
996
  noScope: kill.noScope,
692
997
  flashAssist: kill.flashAssist,
693
- tradeKill: kill.tradeKill
998
+ tradeKill: kill.tradeKill,
999
+ wallbang: kill.penetratedObjects > 0,
1000
+ killerX: kill.killerPosition?.x ?? null,
1001
+ killerY: kill.killerPosition?.y ?? null,
1002
+ killerZ: kill.killerPosition?.z ?? null,
1003
+ victimX: kill.victimPosition.x,
1004
+ victimY: kill.victimPosition.y,
1005
+ victimZ: kill.victimPosition.z
694
1006
  };
695
1007
  });
696
1008
  }
package/src/labels.ts DELETED
@@ -1,3 +0,0 @@
1
- export function sideLabel(side: string): string {
2
- return side === "t" ? "进攻方" : "防守方";
3
- }