@cs2dak/core 0.2.1 → 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/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +68 -88
- package/src/index.ts +41 -9
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +26 -149
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- package/src/workspace.ts +0 -726
package/src/mechanics.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import type { DemoPackage, PackageDamage } from "@cs2dak/contract";
|
|
2
|
+
import { decodeDelta } from "@cs2dak/contract";
|
|
3
|
+
import { staticLineOfSight, type TriangleBvh, type Vec3 } from "@cs2dak/maps";
|
|
4
|
+
import { buildDuelsSignals, type DuelRecord } from "./duels.js";
|
|
5
|
+
import {
|
|
6
|
+
EYE_HEIGHT,
|
|
7
|
+
TARGET_HEIGHT,
|
|
8
|
+
VIEW_CONE_HALF_DEGREES,
|
|
9
|
+
aimErrorDegrees,
|
|
10
|
+
decodeDuelWindow,
|
|
11
|
+
findWindowCovering,
|
|
12
|
+
frameIndexForTick,
|
|
13
|
+
isVisibleAt,
|
|
14
|
+
smokeBlocksRay,
|
|
15
|
+
tickAtFrame,
|
|
16
|
+
visibleOnsetFrame,
|
|
17
|
+
type VisibilityContext
|
|
18
|
+
} from "./duel-window.js";
|
|
19
|
+
import { createResolverFromPackage, type PlayerResolver } from "./resolve.js";
|
|
20
|
+
import { activeDamages, normalizeWeapon, round } from "./utils.js";
|
|
21
|
+
|
|
22
|
+
const BURST_GAP_SECONDS = 0.25;
|
|
23
|
+
const PRE_MOVE_START_SECONDS = 0.2; // 开枪前 200ms
|
|
24
|
+
const PRE_MOVE_END_SECONDS = 0.05; // 到开枪前 50ms(避开停稳瞬间)
|
|
25
|
+
const DAMAGE_MATCH_TICKS = 1;
|
|
26
|
+
/** 视为「开枪前确实在移动」的速度阈值(游戏单位/s)。低于此认为本就站定,不构成急停尝试。 */
|
|
27
|
+
const MOVEMENT_THRESHOLD = 100;
|
|
28
|
+
/** 反应时间上限:可见超过 1s 才开枪更像持续架枪/跟踪,不计入纯反应。 */
|
|
29
|
+
const MAX_REACTION_MS = 1000;
|
|
30
|
+
/** 预瞄误差「命中」阈值(度)。 */
|
|
31
|
+
const PREAIM_WITHIN_DEGREES = 5;
|
|
32
|
+
/** 急停「停稳」要求:开枪时速度需低于该比例的武器判定用最大移动速度。 */
|
|
33
|
+
const ACCURATE_MOVE_RATIO = 0.34;
|
|
34
|
+
|
|
35
|
+
export interface BurstLengthBuckets {
|
|
36
|
+
single: number;
|
|
37
|
+
short: number;
|
|
38
|
+
medium: number;
|
|
39
|
+
long: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WeaponCounterStrafeThreshold {
|
|
43
|
+
weapon: string;
|
|
44
|
+
/** 该武器用于急停判定的最大移动速度(游戏单位/s);AWP 按常见开镜开枪口径取 100。 */
|
|
45
|
+
weaponMaxSpeed: number | null;
|
|
46
|
+
/** 移动精度阈值比例;社区机制分析中常用约 34% 作为停稳近似。 */
|
|
47
|
+
accurateMoveRatio: number;
|
|
48
|
+
/** 停稳速度阈值(兼容旧字段名):weaponMaxSpeed × accurateMoveRatio。 */
|
|
49
|
+
maxSpeed: number;
|
|
50
|
+
source: "weapon" | "fallback";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 命中/尝试型指标:value 为百分比(successes/attempts×100),attempts=0 时 value=null。 */
|
|
54
|
+
export interface RateSample {
|
|
55
|
+
value: number | null;
|
|
56
|
+
successes: number;
|
|
57
|
+
attempts: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 中位型指标:value 为中位数,sampleSize 为有效样本数。 */
|
|
61
|
+
export interface MedianSample {
|
|
62
|
+
value: number | null;
|
|
63
|
+
sampleSize: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 预瞄误差:中位夹角 + ≤5° 命中比例。 */
|
|
67
|
+
export interface PreaimSample {
|
|
68
|
+
medianDegrees: number | null;
|
|
69
|
+
withinFiveRatio: number | null;
|
|
70
|
+
withinFiveCount: number;
|
|
71
|
+
sampleSize: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface MechanicsMetricSet {
|
|
75
|
+
/** 首发命中率:交火开火段第一发命中 / 交火开火段数。 */
|
|
76
|
+
firstShotHit: RateSample;
|
|
77
|
+
/** 扫射命中率:自动武器、burst≥5 的第 4 发起命中 / 总数;非自动武器为 null。 */
|
|
78
|
+
sprayHit: RateSample | null;
|
|
79
|
+
/** 急停成功率(移动后停稳口径):开枪前在移动且开枪时已停稳 / 开枪前在移动。 */
|
|
80
|
+
counterStrafe: RateSample;
|
|
81
|
+
/** one tap 率:满血单发终结 / 满血无第三方击杀。 */
|
|
82
|
+
oneTap: RateSample;
|
|
83
|
+
/** 击杀耗时:击杀开火段第一枪到击杀的中位耗时(ms)。 */
|
|
84
|
+
ttk: MedianSample;
|
|
85
|
+
/** 反应时间:敌人进入有效视野到首发开枪的中位耗时(ms)。 */
|
|
86
|
+
reaction: MedianSample;
|
|
87
|
+
/** 预瞄误差:捕获前准星与目标的三维夹角。 */
|
|
88
|
+
preaim: PreaimSample;
|
|
89
|
+
// ── 射击风格(非核心枪法排行)──
|
|
90
|
+
medianShotIntervalMs: number | null;
|
|
91
|
+
medianBurstIntervalMs: number | null;
|
|
92
|
+
burstLengthBuckets: BurstLengthBuckets;
|
|
93
|
+
firingPatternRatio: { tap: number; burst: number; spray: number };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PlayerMechanicsFact extends MechanicsMetricSet {
|
|
97
|
+
steamId64: string;
|
|
98
|
+
playerName: string;
|
|
99
|
+
teamKey: string;
|
|
100
|
+
weapon: string;
|
|
101
|
+
burstCount: number;
|
|
102
|
+
killCount: number;
|
|
103
|
+
cleanKillCount: number;
|
|
104
|
+
headshotKills: number;
|
|
105
|
+
cleanHeadshotKills: number;
|
|
106
|
+
shotCount: number;
|
|
107
|
+
counterStrafeThreshold: WeaponCounterStrafeThreshold;
|
|
108
|
+
// ── 跨场聚合用原始样本(presentation 按 steamId64+weapon concat 后重算中位/比例)──
|
|
109
|
+
ttkSamplesMs: number[];
|
|
110
|
+
reactionSamplesMs: number[];
|
|
111
|
+
preaimSamplesDeg: number[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface MechanicsSignals {
|
|
115
|
+
version: "cs2-demo-analysis-kit/mechanics-signals-0.2";
|
|
116
|
+
tickrate: number;
|
|
117
|
+
burstGapSeconds: number;
|
|
118
|
+
rows: PlayerMechanicsFact[];
|
|
119
|
+
visibilityAvailable: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface MechanicsSignalsOptions {
|
|
123
|
+
visibility?: TriangleBvh | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface FlatShot {
|
|
127
|
+
roundNumber: number;
|
|
128
|
+
playerIndex: number;
|
|
129
|
+
tick: number;
|
|
130
|
+
weapon: string;
|
|
131
|
+
vx: number;
|
|
132
|
+
vy: number;
|
|
133
|
+
x: number;
|
|
134
|
+
y: number;
|
|
135
|
+
z: number;
|
|
136
|
+
yaw: number;
|
|
137
|
+
pitch: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WEAPON_COUNTER_STRAFE_MAX_SPEED: Record<string, number> = {
|
|
141
|
+
ak47: 215,
|
|
142
|
+
galilar: 215,
|
|
143
|
+
galil: 215,
|
|
144
|
+
m4a1: 225,
|
|
145
|
+
m4a4: 225,
|
|
146
|
+
m4a1_silencer: 225,
|
|
147
|
+
famas: 220,
|
|
148
|
+
aug: 220,
|
|
149
|
+
sg556: 210,
|
|
150
|
+
sg553: 210,
|
|
151
|
+
awp: 100,
|
|
152
|
+
ssg08: 230,
|
|
153
|
+
scar20: 215,
|
|
154
|
+
g3sg1: 215,
|
|
155
|
+
deagle: 230,
|
|
156
|
+
revolver: 220,
|
|
157
|
+
glock: 240,
|
|
158
|
+
usp: 240,
|
|
159
|
+
usp_silencer: 240,
|
|
160
|
+
hkp2000: 240,
|
|
161
|
+
p2000: 240,
|
|
162
|
+
p250: 240,
|
|
163
|
+
fiveseven: 240,
|
|
164
|
+
tec9: 240,
|
|
165
|
+
cz75a: 240,
|
|
166
|
+
cz75: 240,
|
|
167
|
+
elite: 240,
|
|
168
|
+
mac10: 240,
|
|
169
|
+
mp9: 240,
|
|
170
|
+
bizon: 240,
|
|
171
|
+
ump45: 230,
|
|
172
|
+
p90: 230,
|
|
173
|
+
mp7: 220,
|
|
174
|
+
mp5sd: 235,
|
|
175
|
+
nova: 220,
|
|
176
|
+
xm1014: 215,
|
|
177
|
+
mag7: 225,
|
|
178
|
+
sawedoff: 210,
|
|
179
|
+
m249: 195,
|
|
180
|
+
negev: 150
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const FIREARM_WEAPONS = new Set([
|
|
184
|
+
"ak47", "m4a1", "m4a4", "m4a1_silencer", "aug", "sg556", "sg553", "famas", "galilar", "galil",
|
|
185
|
+
"awp", "ssg08", "scar20", "g3sg1", "deagle", "revolver", "glock", "usp_silencer", "usp",
|
|
186
|
+
"hkp2000", "p2000", "p250", "fiveseven", "tec9", "cz75a", "cz75", "elite", "mp9", "mp7",
|
|
187
|
+
"mp5sd", "ump45", "p90", "bizon", "mac10", "nova", "xm1014", "mag7", "sawedoff", "m249", "negev"
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/** 全自动连发武器(步枪 / 冲锋枪 / 机枪)。只有这些武器统计扫射命中率。 */
|
|
191
|
+
const AUTO_WEAPONS = new Set([
|
|
192
|
+
"ak47", "m4a1", "m4a4", "m4a1_silencer", "aug", "sg556", "sg553", "famas", "galilar", "galil",
|
|
193
|
+
"mp9", "mp7", "mp5sd", "ump45", "p90", "bizon", "mac10", "m249", "negev"
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const ONE_TAP_CAPABLE_WEAPONS = new Set([
|
|
197
|
+
"ak47", "sg556", "sg553",
|
|
198
|
+
"deagle", "revolver",
|
|
199
|
+
"awp", "ssg08", "scar20", "g3sg1"
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
function tickrateOf(pkg: DemoPackage): number {
|
|
203
|
+
return pkg.match.tickrate || pkg.manifest.tickrate || 64;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ticks(seconds: number, tickrate: number): number {
|
|
207
|
+
return Math.round(seconds * tickrate);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function percent(successes: number, attempts: number): number | null {
|
|
211
|
+
return attempts > 0 ? round((successes / attempts) * 100, 1) : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function rate(successes: number, attempts: number): RateSample {
|
|
215
|
+
return { value: percent(successes, attempts), successes, attempts };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function median(values: number[]): number | null {
|
|
219
|
+
if (values.length === 0) return null;
|
|
220
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
221
|
+
const mid = Math.floor(sorted.length / 2);
|
|
222
|
+
return sorted.length % 2 === 0 ? round((sorted[mid - 1]! + sorted[mid]!) / 2, 1) : round(sorted[mid]!, 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function planarSpeed(vx: number, vy: number): number {
|
|
226
|
+
return Math.hypot(vx, vy);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function counterStrafeThresholdForWeapon(weaponName: string): WeaponCounterStrafeThreshold {
|
|
230
|
+
const weapon = normalizeWeapon(weaponName);
|
|
231
|
+
const weaponMaxSpeed = WEAPON_COUNTER_STRAFE_MAX_SPEED[weapon] ?? null;
|
|
232
|
+
if (weaponMaxSpeed != null) {
|
|
233
|
+
return { weapon, weaponMaxSpeed, accurateMoveRatio: ACCURATE_MOVE_RATIO, maxSpeed: round(weaponMaxSpeed * ACCURATE_MOVE_RATIO, 1), source: "weapon" };
|
|
234
|
+
}
|
|
235
|
+
return { weapon, weaponMaxSpeed: null, accurateMoveRatio: ACCURATE_MOVE_RATIO, maxSpeed: 80, source: "fallback" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isFirearmWeapon(weapon: string): boolean {
|
|
239
|
+
return FIREARM_WEAPONS.has(normalizeWeapon(weapon));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function flattenShots(pkg: DemoPackage): FlatShot[] {
|
|
243
|
+
const shots = pkg.shots;
|
|
244
|
+
if (!shots) return [];
|
|
245
|
+
const angleScale = shots.meta.angleScale || 10;
|
|
246
|
+
const coordScale = shots.meta.coordScale || 1;
|
|
247
|
+
const out: FlatShot[] = [];
|
|
248
|
+
for (const track of shots.tracks) {
|
|
249
|
+
const shotTicks = decodeDelta(track.tick);
|
|
250
|
+
const x = decodeDelta(track.x);
|
|
251
|
+
const y = decodeDelta(track.y);
|
|
252
|
+
const z = decodeDelta(track.z);
|
|
253
|
+
const yaw = decodeDelta(track.yaw);
|
|
254
|
+
const pitch = decodeDelta(track.pitch);
|
|
255
|
+
for (let i = 0; i < shotTicks.length; i++) {
|
|
256
|
+
const weaponIdx = track.weapon[i] ?? -1;
|
|
257
|
+
const weapon = normalizeWeapon(weaponIdx >= 0 ? (shots.weaponDict[weaponIdx] ?? "") : "");
|
|
258
|
+
if (!isFirearmWeapon(weapon)) continue;
|
|
259
|
+
out.push({
|
|
260
|
+
roundNumber: track.roundNumber,
|
|
261
|
+
playerIndex: track.playerIndex,
|
|
262
|
+
tick: shotTicks[i]!,
|
|
263
|
+
weapon,
|
|
264
|
+
vx: track.vx[i] ?? 0,
|
|
265
|
+
vy: track.vy[i] ?? 0,
|
|
266
|
+
x: (x[i] ?? 0) / coordScale,
|
|
267
|
+
y: (y[i] ?? 0) / coordScale,
|
|
268
|
+
z: (z[i] ?? 0) / coordScale,
|
|
269
|
+
yaw: (yaw[i] ?? 0) / angleScale,
|
|
270
|
+
pitch: (pitch[i] ?? 0) / angleScale
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out.sort((a, b) => a.roundNumber - b.roundNumber || a.tick - b.tick);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function splitBursts(shots: FlatShot[], tickrate: number): FlatShot[][] {
|
|
278
|
+
const maxGap = ticks(BURST_GAP_SECONDS, tickrate);
|
|
279
|
+
const bursts: FlatShot[][] = [];
|
|
280
|
+
for (const shot of [...shots].sort((a, b) => a.tick - b.tick)) {
|
|
281
|
+
const current = bursts[bursts.length - 1];
|
|
282
|
+
if (!current || shot.tick - current[current.length - 1]!.tick > maxGap) bursts.push([shot]);
|
|
283
|
+
else current.push(shot);
|
|
284
|
+
}
|
|
285
|
+
return bursts;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function bucketBursts(bursts: FlatShot[][]): BurstLengthBuckets {
|
|
289
|
+
const buckets: BurstLengthBuckets = { single: 0, short: 0, medium: 0, long: 0 };
|
|
290
|
+
for (const burst of bursts) {
|
|
291
|
+
if (burst.length === 1) buckets.single += 1;
|
|
292
|
+
else if (burst.length <= 3) buckets.short += 1;
|
|
293
|
+
else if (burst.length <= 7) buckets.medium += 1;
|
|
294
|
+
else buckets.long += 1;
|
|
295
|
+
}
|
|
296
|
+
return buckets;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasDamageMatch(damages: PackageDamage[], shot: FlatShot): boolean {
|
|
300
|
+
return damages.some((damage) =>
|
|
301
|
+
damage.roundNumber === shot.roundNumber &&
|
|
302
|
+
damage.attackerIndex === shot.playerIndex &&
|
|
303
|
+
normalizeWeapon(damage.weapon) === shot.weapon &&
|
|
304
|
+
Math.abs(damage.tick - shot.tick) <= DAMAGE_MATCH_TICKS
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 在开枪 tick,射手视野锥 + LOS + 无烟内是否存在一名活着的敌人(用 shots.json 精确视角)。 */
|
|
309
|
+
function firstShotSeesEnemy(ctx: VisibilityContext, resolver: PlayerResolver, shot: FlatShot): boolean {
|
|
310
|
+
const window = findWindowCovering(ctx.pkg, shot.roundNumber, shot.tick);
|
|
311
|
+
if (!window) return false;
|
|
312
|
+
const view = decodeDuelWindow(ctx.pkg, window);
|
|
313
|
+
const frame = frameIndexForTick(view, shot.tick);
|
|
314
|
+
const shooter = resolver.byIndexOrNull(shot.playerIndex);
|
|
315
|
+
if (!shooter) return false;
|
|
316
|
+
const eye: Vec3 = { x: shot.x, y: shot.y, z: shot.z + EYE_HEIGHT };
|
|
317
|
+
for (const [index, track] of view.tracks) {
|
|
318
|
+
if (index === shot.playerIndex) continue;
|
|
319
|
+
const enemy = resolver.byIndexOrNull(index);
|
|
320
|
+
if (!enemy || enemy.teamKey === shooter.teamKey) continue;
|
|
321
|
+
if ((track.hp[frame] ?? 0) <= 0) continue;
|
|
322
|
+
const target: Vec3 = { x: track.x[frame] ?? 0, y: track.y[frame] ?? 0, z: (track.z[frame] ?? 0) + TARGET_HEIGHT };
|
|
323
|
+
const err = aimErrorDegrees(shot.yaw, shot.pitch, eye, target);
|
|
324
|
+
if (err == null || err > VIEW_CONE_HALF_DEGREES) continue;
|
|
325
|
+
if (ctx.visibility && !staticLineOfSight(ctx.visibility, eye, target)) continue;
|
|
326
|
+
if (smokeBlocksRay(ctx.pkg.grenades, shot.roundNumber, shot.tick, eye, target)) continue;
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 该 burst 是否为「交火 burst」:造成了敌方伤害,或首发开枪时视野内有敌人。 */
|
|
333
|
+
function isCombatBurst(ctx: VisibilityContext, resolver: PlayerResolver, damages: PackageDamage[], burst: FlatShot[]): boolean {
|
|
334
|
+
if (burst.some((shot) => hasDamageMatch(damages, shot))) return true;
|
|
335
|
+
return firstShotSeesEnemy(ctx, resolver, burst[0]!);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 开枪前 [S-200ms, S-50ms] 内,从 duels 连续轨迹(3 tick 差分)取峰值速度。无窗口/无轨迹返回 null。 */
|
|
339
|
+
function preMoveSpeed(ctx: VisibilityContext, shot: FlatShot, tickrate: number): number | null {
|
|
340
|
+
const window = findWindowCovering(ctx.pkg, shot.roundNumber, shot.tick);
|
|
341
|
+
if (!window) return null;
|
|
342
|
+
const view = decodeDuelWindow(ctx.pkg, window);
|
|
343
|
+
const track = view.tracks.get(shot.playerIndex);
|
|
344
|
+
if (!track) return null;
|
|
345
|
+
const startTick = shot.tick - ticks(PRE_MOVE_START_SECONDS, tickrate);
|
|
346
|
+
const endTick = shot.tick - ticks(PRE_MOVE_END_SECONDS, tickrate);
|
|
347
|
+
const k = 3;
|
|
348
|
+
const dtSeconds = (k * view.tickStep) / tickrate;
|
|
349
|
+
let peak = 0;
|
|
350
|
+
let sampled = false;
|
|
351
|
+
for (let tick = startTick; tick <= endTick; tick += view.tickStep) {
|
|
352
|
+
if (tick < view.startTick) continue;
|
|
353
|
+
const frame = frameIndexForTick(view, tick);
|
|
354
|
+
if (frame - k < 0) continue;
|
|
355
|
+
const distance = Math.hypot((track.x[frame] ?? 0) - (track.x[frame - k] ?? 0), (track.y[frame] ?? 0) - (track.y[frame - k] ?? 0));
|
|
356
|
+
peak = Math.max(peak, distance / dtSeconds);
|
|
357
|
+
sampled = true;
|
|
358
|
+
}
|
|
359
|
+
return sampled ? peak : null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function trackSpeedAtShot(ctx: VisibilityContext, shot: FlatShot, tickrate: number): number | null {
|
|
363
|
+
const window = findWindowCovering(ctx.pkg, shot.roundNumber, shot.tick);
|
|
364
|
+
if (!window) return null;
|
|
365
|
+
const view = decodeDuelWindow(ctx.pkg, window);
|
|
366
|
+
const track = view.tracks.get(shot.playerIndex);
|
|
367
|
+
if (!track) return null;
|
|
368
|
+
const frame = frameIndexForTick(view, shot.tick);
|
|
369
|
+
const dtSeconds = view.tickStep / tickrate;
|
|
370
|
+
const samples: number[] = [];
|
|
371
|
+
for (const prevFrame of [frame - 1, frame]) {
|
|
372
|
+
const nextFrame = prevFrame + 1;
|
|
373
|
+
if (prevFrame < 0 || nextFrame >= track.x.length) continue;
|
|
374
|
+
const distance = Math.hypot((track.x[nextFrame] ?? 0) - (track.x[prevFrame] ?? 0), (track.y[nextFrame] ?? 0) - (track.y[prevFrame] ?? 0));
|
|
375
|
+
samples.push(distance / dtSeconds);
|
|
376
|
+
}
|
|
377
|
+
return samples.length > 0 ? Math.min(...samples) : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 急停成功率(移动后停稳口径):对每个交火开火段第一发,若开枪前确实在移动则计一次尝试,
|
|
382
|
+
* 开枪时速度已降到武器判定用最大移动速度约 34% 以下则计一次成功。只把 attempt=true 的样本放入分母。
|
|
383
|
+
*/
|
|
384
|
+
function counterStrafeRate(ctx: VisibilityContext, combatBursts: FlatShot[][], weapon: string, tickrate: number): RateSample {
|
|
385
|
+
const threshold = counterStrafeThresholdForWeapon(weapon).maxSpeed;
|
|
386
|
+
let attempts = 0;
|
|
387
|
+
let successes = 0;
|
|
388
|
+
for (const burst of combatBursts) {
|
|
389
|
+
const shot = burst[0]!;
|
|
390
|
+
const pre = preMoveSpeed(ctx, shot, tickrate);
|
|
391
|
+
if (pre == null || pre <= MOVEMENT_THRESHOLD) continue; // 本就站定,不是急停尝试
|
|
392
|
+
attempts += 1;
|
|
393
|
+
const shotSpeed = trackSpeedAtShot(ctx, shot, tickrate) ?? planarSpeed(shot.vx, shot.vy);
|
|
394
|
+
if (shotSpeed <= threshold) successes += 1;
|
|
395
|
+
}
|
|
396
|
+
return rate(successes, attempts);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isCleanGunfightKill(duel: DuelRecord): boolean {
|
|
400
|
+
return !duel.thirdParty && !duel.throughSmoke && duel.penetratedObjects <= 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isOneTapEligible(duel: DuelRecord): boolean {
|
|
404
|
+
return ONE_TAP_CAPABLE_WEAPONS.has(normalizeWeapon(duel.weapon));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isCleanBurstForMechanics(burst: FlatShot[], duels: DuelRecord[]): boolean {
|
|
408
|
+
const first = burst[0]!;
|
|
409
|
+
const last = burst[burst.length - 1]!;
|
|
410
|
+
return !duels.some((duel) => {
|
|
411
|
+
const firstShotTick = duel.evidenceTicks.killerFirstShotTick;
|
|
412
|
+
if (firstShotTick == null) return false;
|
|
413
|
+
return duel.roundNumber === first.roundNumber
|
|
414
|
+
&& normalizeWeapon(duel.weapon) === first.weapon
|
|
415
|
+
&& firstShotTick >= first.tick
|
|
416
|
+
&& firstShotTick <= last.tick
|
|
417
|
+
&& !isCleanGunfightKill(duel);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function firingPatternRatio(buckets: BurstLengthBuckets, total: number): MechanicsMetricSet["firingPatternRatio"] {
|
|
422
|
+
if (total <= 0) return { tap: 0, burst: 0, spray: 0 };
|
|
423
|
+
return {
|
|
424
|
+
tap: round((buckets.single / total) * 100, 1),
|
|
425
|
+
burst: round(((buckets.short + buckets.medium) / total) * 100, 1),
|
|
426
|
+
spray: round((buckets.long / total) * 100, 1)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function medianShotIntervalMs(shots: FlatShot[], tickrate: number): number | null {
|
|
431
|
+
const intervals: number[] = [];
|
|
432
|
+
const sorted = [...shots].sort((a, b) => a.tick - b.tick);
|
|
433
|
+
for (let i = 1; i < sorted.length; i++) intervals.push(((sorted[i]!.tick - sorted[i - 1]!.tick) / tickrate) * 1000);
|
|
434
|
+
return median(intervals);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function medianBurstIntervalMs(bursts: FlatShot[][], tickrate: number): number | null {
|
|
438
|
+
const intervals: number[] = [];
|
|
439
|
+
for (let i = 1; i < bursts.length; i++) {
|
|
440
|
+
intervals.push(((bursts[i]![0]!.tick - bursts[i - 1]![bursts[i - 1]!.length - 1]!.tick) / tickrate) * 1000);
|
|
441
|
+
}
|
|
442
|
+
return median(intervals);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 反应时间 + 预瞄误差的原始样本:对每条击杀 duel,从击杀者首发反向找当前连续可见段的 onset。
|
|
447
|
+
* 反应 = onset → 首发;预瞄 = onset 前 1~3 帧准星与目标三维夹角。
|
|
448
|
+
* prefire(首发时不可见)、左截断(可见段延伸到窗口起点)、>1s 跟踪 均剔除。
|
|
449
|
+
* 返回原始数组而非聚合值,以便 presentation 跨场 concat 后再算中位/比例。
|
|
450
|
+
*/
|
|
451
|
+
function reactionAndPreaimSamples(ctx: VisibilityContext, duels: DuelRecord[], tickrate: number): { reactionMs: number[]; preaimErrors: number[] } {
|
|
452
|
+
const reactionMs: number[] = [];
|
|
453
|
+
const preaimErrors: number[] = [];
|
|
454
|
+
for (const duel of duels) {
|
|
455
|
+
const shotTick = duel.evidenceTicks.killerFirstShotTick;
|
|
456
|
+
if (shotTick == null) continue;
|
|
457
|
+
const window = findWindowCovering(ctx.pkg, duel.roundNumber, duel.tick);
|
|
458
|
+
if (!window) continue;
|
|
459
|
+
const view = decodeDuelWindow(ctx.pkg, window);
|
|
460
|
+
const shotFrame = reactionAnchorFrame(ctx, view, duel.killerIndex, duel.victimIndex, shotTick);
|
|
461
|
+
const onset = visibleOnsetFrame(ctx, view, duel.killerIndex, duel.victimIndex, shotFrame);
|
|
462
|
+
if (onset == null || onset === 0) continue; // prefire 或 左截断
|
|
463
|
+
const elapsed = ((shotTick - tickAtFrame(view, onset)) / tickrate) * 1000;
|
|
464
|
+
if (elapsed < 0 || elapsed > MAX_REACTION_MS) continue; // 跟踪/异常
|
|
465
|
+
reactionMs.push(round(elapsed, 1));
|
|
466
|
+
const error = preaimErrorAtOnset(view, duel.killerIndex, duel.victimIndex, onset);
|
|
467
|
+
if (error != null) preaimErrors.push(error);
|
|
468
|
+
}
|
|
469
|
+
return { reactionMs, preaimErrors };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function reactionAnchorFrame(
|
|
473
|
+
ctx: VisibilityContext,
|
|
474
|
+
view: ReturnType<typeof decodeDuelWindow>,
|
|
475
|
+
killerIndex: number,
|
|
476
|
+
victimIndex: number,
|
|
477
|
+
shotTick: number
|
|
478
|
+
): number {
|
|
479
|
+
const shotFrame = frameIndexForTick(view, shotTick);
|
|
480
|
+
if (isVisibleAt(ctx, view, killerIndex, victimIndex, shotFrame)) return shotFrame;
|
|
481
|
+
|
|
482
|
+
// 一枪终结时 firstShotTick == killTick,duels 帧里的 victim HP 已经落到 0。
|
|
483
|
+
// 这不是 prefire;用上一帧的存活状态判断开枪前最后一次有效可见。
|
|
484
|
+
const victim = view.tracks.get(victimIndex);
|
|
485
|
+
if (shotFrame > 0 && (victim?.hp[shotFrame] ?? 0) <= 0) return shotFrame - 1;
|
|
486
|
+
return shotFrame;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function preaimSampleFrom(preaimErrors: number[]): PreaimSample {
|
|
490
|
+
const withinFiveCount = preaimErrors.filter((value) => value <= PREAIM_WITHIN_DEGREES).length;
|
|
491
|
+
return {
|
|
492
|
+
medianDegrees: median(preaimErrors),
|
|
493
|
+
withinFiveRatio: percent(withinFiveCount, preaimErrors.length),
|
|
494
|
+
withinFiveCount,
|
|
495
|
+
sampleSize: preaimErrors.length
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function preaimErrorAtOnset(view: ReturnType<typeof decodeDuelWindow>, killerIndex: number, victimIndex: number, onset: number): number | null {
|
|
500
|
+
const killer = view.tracks.get(killerIndex);
|
|
501
|
+
const victim = view.tracks.get(victimIndex);
|
|
502
|
+
if (!killer || !victim) return null;
|
|
503
|
+
const samples: number[] = [];
|
|
504
|
+
for (const frame of [onset - 1, onset - 2, onset - 3]) {
|
|
505
|
+
if (frame < 0) continue;
|
|
506
|
+
const eye: Vec3 = { x: killer.x[frame] ?? 0, y: killer.y[frame] ?? 0, z: (killer.z[frame] ?? 0) + EYE_HEIGHT };
|
|
507
|
+
const target: Vec3 = { x: victim.x[frame] ?? 0, y: victim.y[frame] ?? 0, z: (victim.z[frame] ?? 0) + TARGET_HEIGHT };
|
|
508
|
+
const error = aimErrorDegrees(killer.yaw[frame] ?? 0, killer.pitch[frame] ?? 0, eye, target);
|
|
509
|
+
if (error != null) samples.push(error);
|
|
510
|
+
}
|
|
511
|
+
return median(samples);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function buildMechanicsSignals(
|
|
515
|
+
pkg: DemoPackage,
|
|
516
|
+
duels: DuelRecord[] = buildDuelsSignals(pkg).records,
|
|
517
|
+
options: MechanicsSignalsOptions = {}
|
|
518
|
+
): MechanicsSignals {
|
|
519
|
+
const resolver = createResolverFromPackage(pkg);
|
|
520
|
+
const tickrate = tickrateOf(pkg);
|
|
521
|
+
const ctx: VisibilityContext = { pkg, visibility: options.visibility };
|
|
522
|
+
const shots = flattenShots(pkg);
|
|
523
|
+
const damages = activeDamages(pkg);
|
|
524
|
+
const allZeroVelocity = shots.length > 0 && shots.every((shot) => shot.vx === 0 && shot.vy === 0);
|
|
525
|
+
|
|
526
|
+
// duels 按 killerIndex + weapon 归并,供击杀耗时 / one tap / 反应 / 预瞄 join。
|
|
527
|
+
const duelsByKey = new Map<string, DuelRecord[]>();
|
|
528
|
+
for (const duel of duels) {
|
|
529
|
+
const key = `${duel.killerIndex}:${normalizeWeapon(duel.weapon)}`;
|
|
530
|
+
const list = duelsByKey.get(key) ?? [];
|
|
531
|
+
list.push(duel);
|
|
532
|
+
duelsByKey.set(key, list);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const grouped = new Map<string, FlatShot[]>();
|
|
536
|
+
for (const shot of shots) {
|
|
537
|
+
const key = `${shot.playerIndex}:${shot.weapon}`;
|
|
538
|
+
const list = grouped.get(key) ?? [];
|
|
539
|
+
list.push(shot);
|
|
540
|
+
grouped.set(key, list);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const rows = [...grouped.entries()].map(([key, playerShots]): PlayerMechanicsFact | null => {
|
|
544
|
+
const colonIdx = key.indexOf(":");
|
|
545
|
+
const playerIdx = parseInt(key.slice(0, colonIdx), 10);
|
|
546
|
+
const weapon = key.slice(colonIdx + 1);
|
|
547
|
+
const player = resolver.byIndexOrNull(playerIdx);
|
|
548
|
+
if (!player) return null;
|
|
549
|
+
|
|
550
|
+
const bursts = splitBursts(playerShots, tickrate);
|
|
551
|
+
const combatBursts = bursts.filter((burst) => isCombatBurst(ctx, resolver, damages, burst));
|
|
552
|
+
const buckets = bucketBursts(bursts);
|
|
553
|
+
const playerDuels = duelsByKey.get(key) ?? [];
|
|
554
|
+
const cleanPlayerDuels = playerDuels.filter(isCleanGunfightKill);
|
|
555
|
+
const cleanCombatBursts = combatBursts.filter((burst) => isCleanBurstForMechanics(burst, playerDuels));
|
|
556
|
+
|
|
557
|
+
// 首发命中率(交火开火段第一发)
|
|
558
|
+
const firstShotHit = rate(
|
|
559
|
+
cleanCombatBursts.filter((burst) => hasDamageMatch(damages, burst[0]!)).length,
|
|
560
|
+
cleanCombatBursts.length
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// 扫射命中率(仅自动武器、burst≥5 的第 4 发起)
|
|
564
|
+
let sprayHit: RateSample | null = null;
|
|
565
|
+
if (AUTO_WEAPONS.has(weapon)) {
|
|
566
|
+
const sprayShots = cleanCombatBursts.filter((burst) => burst.length >= 5).flatMap((burst) => burst.slice(3));
|
|
567
|
+
sprayHit = rate(sprayShots.filter((shot) => hasDamageMatch(damages, shot)).length, sprayShots.length);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 急停成功率(移动后停稳);导出器无 velocity 时整体置空
|
|
571
|
+
const counterStrafe = allZeroVelocity ? rate(0, 0) : counterStrafeRate(ctx, cleanCombatBursts, weapon, tickrate);
|
|
572
|
+
|
|
573
|
+
// one tap 率(满血单发终结 / 满血无第三方击杀)
|
|
574
|
+
const cleanFullHp = cleanPlayerDuels.filter((duel) => duel.victimHealthBefore === 100);
|
|
575
|
+
const oneTapCandidates = cleanFullHp.filter(isOneTapEligible);
|
|
576
|
+
const oneTap = rate(oneTapCandidates.filter((duel) => duel.oneShotKill).length, oneTapCandidates.length);
|
|
577
|
+
|
|
578
|
+
// 击杀耗时中位(击杀开火段第一枪 → 击杀)
|
|
579
|
+
const ttkSamples = cleanPlayerDuels
|
|
580
|
+
.map((duel) => duel.ttkMs)
|
|
581
|
+
.filter((value): value is number => value != null);
|
|
582
|
+
const ttk: MedianSample = { value: median(ttkSamples), sampleSize: ttkSamples.length };
|
|
583
|
+
|
|
584
|
+
const { reactionMs, preaimErrors } = reactionAndPreaimSamples(ctx, cleanPlayerDuels, tickrate);
|
|
585
|
+
const reaction: MedianSample = { value: median(reactionMs), sampleSize: reactionMs.length };
|
|
586
|
+
const preaim = preaimSampleFrom(preaimErrors);
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
steamId64: player.steamId64,
|
|
590
|
+
playerName: player.name,
|
|
591
|
+
teamKey: player.teamKey,
|
|
592
|
+
weapon,
|
|
593
|
+
killCount: playerDuels.length,
|
|
594
|
+
cleanKillCount: cleanPlayerDuels.length,
|
|
595
|
+
headshotKills: playerDuels.filter((duel) => duel.headshot).length,
|
|
596
|
+
cleanHeadshotKills: cleanPlayerDuels.filter((duel) => duel.headshot).length,
|
|
597
|
+
burstCount: bursts.length,
|
|
598
|
+
shotCount: playerShots.length,
|
|
599
|
+
firstShotHit,
|
|
600
|
+
sprayHit,
|
|
601
|
+
counterStrafe,
|
|
602
|
+
oneTap,
|
|
603
|
+
ttk,
|
|
604
|
+
reaction,
|
|
605
|
+
preaim,
|
|
606
|
+
medianShotIntervalMs: medianShotIntervalMs(playerShots, tickrate),
|
|
607
|
+
medianBurstIntervalMs: medianBurstIntervalMs(bursts, tickrate),
|
|
608
|
+
burstLengthBuckets: buckets,
|
|
609
|
+
firingPatternRatio: firingPatternRatio(buckets, bursts.length),
|
|
610
|
+
counterStrafeThreshold: counterStrafeThresholdForWeapon(weapon),
|
|
611
|
+
ttkSamplesMs: ttkSamples,
|
|
612
|
+
reactionSamplesMs: reactionMs,
|
|
613
|
+
preaimSamplesDeg: preaimErrors
|
|
614
|
+
};
|
|
615
|
+
}).filter((row): row is PlayerMechanicsFact => row != null);
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
version: "cs2-demo-analysis-kit/mechanics-signals-0.2",
|
|
619
|
+
tickrate,
|
|
620
|
+
burstGapSeconds: BURST_GAP_SECONDS,
|
|
621
|
+
rows: rows.sort((a, b) => b.killCount - a.killCount || b.shotCount - a.shotCount || a.playerName.localeCompare(b.playerName)),
|
|
622
|
+
visibilityAvailable: options.visibility != null
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function derivePlayerMechanics(pkg: DemoPackage, options: MechanicsSignalsOptions = {}): PlayerMechanicsFact[] {
|
|
627
|
+
return buildMechanicsSignals(pkg, buildDuelsSignals(pkg, options).records, options).rows;
|
|
628
|
+
}
|