Context

用户需要一个 Windows 桌面应用,用于批量解析 CS2 demo 文件,提取所有投掷物(烟雾弹、闪光弹、高爆手雷、燃烧弹/燃烧瓶、诱饵弹)的投掷信息,并在交互式 2D 地图上进行可视化(投掷点、热力图、轨迹线),支持多 demo 跨局对比、高频投掷物自动识别与高亮、以及丰富的过滤功能。

Credits 文件夹提供了三个参考项目:

  • cs-demo-manager:完整的 Electron+React 桌面应用,含地图雷达图、热力图渲染、坐标转换

  • demoparser-rust:高性能 Rust 解析器(npm: @laihoe/demoparser2),有 Node.js 绑定和专用 parseGrenades() 函数

  • demoinfocs-golang:Go 事件流解析器(备用参考)


技术选型

技术

说明

桌面框架

Electron 33+

成熟跨平台,Credits 有完整参考

前端

React 19 + TypeScript

组件化 UI,类型安全

状态管理

Zustand

轻量、简洁,比 Redux 更适合中等复杂度

Demo 解析

@laihoe/demoparser2 (Rust)

750MB/s 原生解析,parseGrenades() + parseEvents() API

本地数据库

SQLite (better-sqlite3)

零配置,嵌入式,支持跨 demo 聚合查询

地图渲染

HTML5 Canvas API

参照 cs-demo-manager 自定义渲染,无额外依赖

构建

Vite + electron-builder

快速 HMR,一键打包分发

样式

Tailwind CSS

实用优先,快速迭代


架构设计(Layered IPC)

┌─────────────────────────────────────────┐
│         Renderer Process (React)         │
│  ┌─────────┐ ┌──────────┐ ┌───────────┐ │
│  │MapView  │ │FilterBar │ │GrenadeList│ │
│  │(Canvas) │ │          │ │           │ │
│  └─────────┘ └──────────┘ └───────────┘ │
│         Zustand Store (UI state)         │
└──────────────────┬──────────────────────┘
                   │ IPC (preload.ts)
┌──────────────────┴──────────────────────┐
│          Main Process (Node.js)          │
│  ┌────────────┐ ┌─────────────────────┐ │
│  │ DemoParser │ │   DatabaseService   │ │
│  │ Service    │ │   (SQLite)          │ │
│  └────────────┘ └─────────────────────┘ │
│  ┌────────────────────────────────────┐  │
│  │   HFDetectionEngine                │  │
│  │   (高频投掷物检测)                  │  │
│  └────────────────────────────────────┘  │
└──────────────────────────────────────────┘

IPC 通信协议(Main ↔ Renderer):

  • demo:parse — 传入文件路径数组,返回解析进度

  • demo:parse-progress — 解析进度事件(percentage, currentFile)

  • demo:parse-complete — 解析完成,返回统计摘要

  • grenades:query — 按过滤条件查询投掷物数据

  • grenades:heatmap — 获取热力图数据点(按地图聚合)

  • grenades:trajectories — 获取轨迹数据(按投掷物ID)

  • grenades:high-frequency — 获取高频投掷物列表

  • maps:list — 获取可用地图列表

  • maps:metadata — 获取地图坐标转换参数


数据模型(SQLite Schema)

-- 解析过的 demo 文件记录
CREATE TABLE demos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  file_path TEXT NOT NULL UNIQUE,
  file_hash TEXT NOT NULL,          -- MD5/SHA256 防重复
  map_name TEXT NOT NULL,           -- de_dust2, de_mirage, etc.
  server_name TEXT,
  tick_rate INTEGER,                -- 64 or 128
  total_ticks INTEGER,
  parsed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 投掷物投掷记录(每次投掷一行)
CREATE TABLE grenade_throws (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  demo_id INTEGER NOT NULL REFERENCES demos(id) ON DELETE CASCADE,
  round_number INTEGER NOT NULL,
  tick INTEGER NOT NULL,
  grenade_type TEXT NOT NULL,       -- smoke, flash, he, molotov, incendiary, decoy
  thrower_name TEXT NOT NULL,
  thrower_steamid TEXT NOT NULL,
  thrower_team TEXT NOT NULL,       -- CT / T
  throw_x REAL NOT NULL,
  throw_y REAL NOT NULL,
  throw_z REAL NOT NULL,
  thrower_yaw REAL,
  thrower_pitch REAL,
  detonation_x REAL,               -- 落点/爆点
  detonation_y REAL,
  detonation_z REAL,
  detonation_tick INTEGER,
  trajectory_json TEXT              -- 轨迹点 JSON: [{x,y,z,tick},...]
);

-- 索引
CREATE INDEX idx_grenade_throws_map ON grenade_throws(demo_id, grenade_type);
CREATE INDEX idx_grenade_throws_team ON grenade_throws(thrower_team);
CREATE INDEX idx_grenade_throws_steamid ON grenade_throws(thrower_steamid);

-- 高频投掷物检测结果(缓存)
CREATE TABLE high_frequency_grenades (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  map_name TEXT NOT NULL,
  grenade_type TEXT NOT NULL,
  center_x REAL NOT NULL,          -- 聚类中心
  center_y REAL NOT NULL,
  radius REAL NOT NULL,            -- 聚类半径(游戏单位)
  throw_count INTEGER NOT NULL,    -- 出现次数
  demo_count INTEGER NOT NULL,     -- 跨 demo 数
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

项目文件结构

cs2-grenade-analyzer/
├── electron/                      # Main process
│   ├── main.ts                    # Electron 入口
│   ├── preload.ts                 # IPC bridge (contextBridge)
│   ├── services/
│   │   ├── demo-parser.service.ts # 封装 @laihoe/demoparser2
│   │   ├── database.service.ts    # SQLite CRUD
│   │   ├── hf-detection.service.ts # 高频检测引擎
│   │   └── map.service.ts         # 地图元数据管理
│   └── ipc/
│       └── handlers.ts            # IPC handler 注册
├── src/                           # Renderer (React)
│   ├── App.tsx
│   ├── main.tsx
│   ├── components/
│   │   ├── layout/
│   │   │   ├── AppLayout.tsx      # 主布局(侧边栏 + 内容区)
│   │   │   ├── Sidebar.tsx        # 文件管理侧栏
│   │   │   └── Toolbar.tsx        # 顶部工具栏
│   │   ├── map/
│   │   │   ├── MapCanvas.tsx      # 核心地图 Canvas 组件
│   │   │   ├── HeatmapLayer.tsx   # 热力图渲染层
│   │   │   ├── TrajectoryLayer.tsx # 轨迹线渲染层
│   │   │   ├── GrenadeMarkers.tsx # 投掷点标记
│   │   │   └── MapControls.tsx    # 缩放/重置控件
│   │   ├── filters/
│   │   │   ├── FilterPanel.tsx    # 过滤面板容器
│   │   │   ├── TeamFilter.tsx     # CT/T 切换
│   │   │   ├── PlayerFilter.tsx   # 玩家搜索/选择
│   │   │   ├── GrenadeTypeFilter.tsx # 投掷物类型多选
│   │   │   └── RoundFilter.tsx    # 回合范围滑块
│   │   ├── panels/
│   │   │   ├── DemoImporter.tsx   # 拖拽/选择 demo 文件
│   │   │   ├── GrenadeListPanel.tsx # 投掷物列表(可点击跳转地图位置)
│   │   │   ├── HFGrenadePanel.tsx  # 高频投掷物高亮面板
│   │   │   └── StatsPanel.tsx     # 统计数据面板
│   │   └── common/
│   │       ├── ProgressBar.tsx
│   │       └── Tooltip.tsx
│   ├── hooks/
│   │   ├── useMapCanvas.ts        # Canvas 交互逻辑(缩放/平移)
│   │   ├── useGrenadeData.ts      # 数据查询 hook
│   │   └── useHeatmap.ts          # 热力图计算 hook
│   ├── store/
│   │   ├── appStore.ts            # Zustand 主 store
│   │   └── types.ts               # 全局类型定义
│   ├── utils/
│   │   ├── coordinate.ts          # 游戏坐标 → 雷达像素转换
│   │   ├── heatmap.ts             # 热力图渲染算法
│   │   └── clustering.ts          # DBSCAN 聚类算法
│   └── styles/
│       └── globals.css            # Tailwind 入口
├── assets/
│   └── maps/
│       ├── radars/                # 从 cs-demo-manager 复制
│       │   ├── de_dust2.png
│       │   ├── de_mirage.png
│       │   └── ...
│       └── map-metadata.json     # 每张地图的 posX, posY, scale, thresholdZ
├── package.json
├── vite.config.ts
├── electron-builder.json5
├── tailwind.config.ts
└── tsconfig.json

核心模块设计

1. Demo 解析服务 (demo-parser.service.ts)

职责:封装 @laihoe/demoparser2,提供批量解析能力

工作流程

用户选择文件 → 逐文件解析 → 提取投掷物数据 → 写入 SQLite → 通知 UI

解析策略(每个 demo 文件):

  1. 调用 parseEvents(path, ["flashbang_detonate", "hegrenade_detonate", "smokegrenade_detonate", "inferno_startburn", "decoy_started"]) 获取所有投掷物事件(含引爆位置、投掷者信息)

  2. 调用 parseGrenades(path) 获取每颗投掷物的飞行轨迹点

  3. 调用 parseHeader(path) 获取地图名、tick rate 等元数据

  4. 匹配轨迹 → 投掷者 → 引爆事件,组装完整 grenade_throws 记录

  5. 写入 SQLite,发送 IPC 进度更新

关键实现

// 核心解析流程(伪代码)
async parseDemo(filePath: string): Promise<void> {
  const header = parseHeader(filePath);
  const grenadeEvents = parseEvents(filePath, GRENADE_EVENT_TYPES, ["X", "Y", "Z"]);
  const trajectories = parseGrenades(filePath);

  // 按轨迹 entity_id 关联投掷者与引爆点
  const merged = this.mergeTrajectoryWithEvents(trajectories, grenadeEvents);

  // 批量写入 DB
  await db.insertGrenadeThrows(filePath, header, merged);
}

2. 坐标转换系统 (coordinate.ts)

直接复用 cs-demo-manager 的转换逻辑

// 地图元数据(从 cs-demo-manager/static/default-maps.ts 提取)
interface MapMetadata {
  name: string;
  posX: number;    // 地图偏移 X(如 de_dust2: -2476)
  posY: number;    // 地图偏移 Y(如 de_dust2: 3239)
  scale: number;   // 缩放因子(如 de_dust2: 4.4)
  thresholdZ: number;  // Z 阈值(区分上下层)
  radarSize: number;   // 雷达图尺寸(1024 或 2048)
}

// 游戏坐标 → 雷达像素坐标
function gameToRadar(gameX: number, gameY: number, map: MapMetadata, imageWidth: number): { x: number, y: number } {
  const x = ((gameX - map.posX) / map.scale) * imageWidth / map.radarSize;
  const y = ((map.posY - gameY) / map.scale) * imageWidth / map.radarSize;
  return { x, y };
}

地图资产来源:从 cs-demo-manager-main/static/images/maps/cs2/radars/ 复制 PNG 雷达图,从 src/shared/shared/types/default-maps.ts 提取坐标参数到 map-metadata.json

3. 地图 Canvas 渲染 (MapCanvas.tsx)

三层渲染架构

┌─────────────────────────┐
│  Layer 3: UI Overlay    │  ← 标记、标签、工具提示
├─────────────────────────┤
│  Layer 2: Data Overlay  │  ← 热力图 / 轨迹线 / 投掷点标记
├─────────────────────────┤
│  Layer 1: Radar Image   │  ← 底图(PNG 雷达图)
└─────────────────────────┘

Canvas 交互

  • 鼠标滚轮缩放(0.1x ~ 5x)

  • 鼠标拖拽平移

  • 点击投掷物标记显示详情 tooltip

  • 支持切换热力图/轨迹/标记的显示/隐藏

热力图渲染(参照 cs-demo-manager 的 HeatmapRenderer):

  • 自定义 simpleheat 算法:径向渐变 + 混合模式

  • 颜色梯度:蓝 → 青 → 绿 → 黄 → 红

  • 可配置参数:半径、透明度、模糊度

轨迹线渲染

  • parseGrenades() 返回的轨迹点,按 tick 顺序绘制折线

  • 颜色编码:烟雾=灰、闪光=白、高爆=红、燃烧=橙、诱饵=黄

  • 高频投掷物用发光效果 + 更粗线条

4. 高频投掷物检测引擎 (hf-detection.service.ts)

算法:基于密度的空间聚类(DBSCAN 变体)

检测维度

  • 空间位置(throw_x, throw_y)— 同一投掷位置

  • 投掷物类型 — 同一种道具

  • 可选:落点位置(detonation_x, detonation_y)— 同一落点

流程

1. 查询同一地图的所有投掷记录(跨 demo)
2. 按 grenade_type 分组
3. 对每组运行 DBSCAN 聚类(epsilon = 用户配置半径,如 100 游戏单位)
4. 聚类大小 >= 用户阈值(如出现 3 次)→ 标记为高频
5. 计算聚类中心和半径,写入 high_frequency_grenades 表
6. 在地图上用特殊标记高亮显示

可配置参数(UI 提供):

  • 最小出现次数(minCount):默认 3

  • 聚类半径(epsilon):默认 100 游戏单位

  • 是否考虑落点位置:是/否

5. 过滤系统

过滤条件(全部组合 AND 逻辑):

  • 地图选择(下拉菜单)

  • 队伍过滤:CT / T / 全部

  • 玩家过滤:搜索框 + 多选下拉

  • 投掷物类型:多选(烟雾弹/闪光弹/高爆手雷/燃烧弹/燃烧瓶/诱饵弹)

  • 回合范围:滑块(如 round 1-15, 16-30)

  • 高频过滤:仅显示高频 / 仅显示非高频 / 全部

实现:过滤参数通过 Zustand store 管理,变化时触发 IPC grenades:query 查询,结果更新 Canvas 渲染。


UI 布局设计

┌──────────────────────────────────────────────────────────┐
│  Toolbar: [导入Demo] [地图选择▼] [过滤▼] [高频设置⚙]     │
├────────────┬─────────────────────────────────────────────┤
│            │                                             │
│  Sidebar   │          Map Canvas (核心区域)               │
│            │                                             │
│ ┌────────┐ │   ┌─────┐   ┌─────┐                        │
│ │Demo列表│ │   │ ● → │   │ ● → │  ← 投掷点+轨迹         │
│ │        │ │   └─────┘   └─────┘                        │
│ │✓demo1  │ │       ╔═══════════╗                        │
│ │✓demo2  │ │       ║ 热力图覆盖 ║                        │
│ │ demo3  │ │       ╚═══════════╝                        │
│ │        │ │                                             │
│ ├────────┤ │          [+/-] 缩放控件                     │
│ │过滤面板│ │          [热力图] [轨迹] [标记] ← 图层切换   │
│ │CT / T  │ │                                             │
│ │玩家▼   │ │                                             │
│ │类型☑   │ │                                             │
│ └────────┘ │                                             │
│            │                                             │
├────────────┴─────────────────────────────────────────────┤
│  Bottom Panel: 投掷物列表 / 高频道具面板 / 统计           │
│  ┌──────────┬──────────┬──────────┐                      │
│  │ 全部道具  │ 高频道具  │ 统计数据  │                      │
│  │ ☐ Smoke  │ ★ Flash  │ 总计: 156│                      │
│  │ ☐ Flash  │ ★ Smoke  │ 高频: 12 │                      │
│  └──────────┴──────────┴──────────┘                      │
└──────────────────────────────────────────────────────────┘

分阶段开发计划

Phase 1: 项目骨架与基础解析(~2-3天)

  1. 初始化 Electron + React + Vite + TypeScript 项目

  2. 配置 Tailwind CSS

  3. 搭建 Layered IPC 架构(main / preload / renderer)

  4. 集成 @laihoe/demoparser2,实现单文件解析

  5. 集成 better-sqlite3,创建数据库 schema

  6. 复制地图雷达图和坐标元数据

Phase 2: 核心可视化(~3-4天)

  1. 实现 MapCanvas 基础组件(加载雷达底图)

  2. 实现坐标转换工具

  3. 实现缩放/平移交互

  4. 实现投掷物标记渲染

  5. 实现轨迹线渲染

  6. 实现热力图渲染

Phase 3: 多文件解析与聚合(~2天)

  1. 实现多文件批量导入(拖拽 + 文件选择)

  2. 实现解析进度通知

  3. 实现跨 demo 数据聚合查询

  4. 实现地图选择切换

Phase 4: 过滤与高频检测(~2-3天)

  1. 实现完整过滤面板(队伍/玩家/类型/回合)

  2. 实现 DBSCAN 高频检测算法

  3. 实现高频投掷物高亮渲染

  4. 实现高频设置配置面板

Phase 5: 打磨与分发(~2天)

  1. UI 细节打磨(动画、响应式布局、暗色主题)

  2. 错误处理与边界情况

  3. electron-builder 配置(Windows NSIS 安装包)

  4. 自动更新(electron-updater)

  5. 性能优化(大数据量虚拟列表、Canvas 脏矩形渲染)


关键复用清单

来源

复用内容

文件路径

cs-demo-manager

雷达图 PNG

static/images/maps/cs2/radars/*.png

cs-demo-manager

地图坐标元数据

src/shared/shared/types/default-maps.ts

cs-demo-manager

坐标转换算法

getScaledCoordinateX/Y 函数

cs-demo-manager

热力图渲染算法

src/ui/shared/heatmap-renderer.ts

cs-demo-manager

Canvas 交互模式

useInteractiveCanvas hook

demoparser-rust

解析器核心

@laihoe/demoparser2 npm 包


验证方案

  1. 解析验证:使用已知 demo 文件,对比 parseGrenades() 输出与手动观看回放的投掷物数量

  2. 坐标验证:在地图上标记已知位置的投掷物(如 Dust2 A 大道烟雾),确认标记位置正确

  3. 热力图验证:导入 10+ 同地图 demo,检查热力图热点区域是否合理

  4. 高频检测验证:设置阈值=3,验证同一道具在 3 个 demo 中出现后被正确标记

  5. 过滤验证:组合各种过滤条件,确认结果集正确

  6. 性能验证:批量导入 50 个 demo(~2GB),确认解析时间和 UI 响应性

  7. 分发验证:在干净 Windows 机器上安装并运行


功能迭代

---
name: Bugs Fixed
description: 开发过程中修复的关键 bug 及根因分析,避免同类问题复现
type: feedback
originSessionId: dae1f375-6fd2-4651-8ba1-3583d6292554
---
## Bug 1: 导入 demo 后无数据(投掷物 0 条)

**现象**: 解析成功但数据库中 grenade_throws 表为空
**根因**: `parseGrenades()` 返回字段名是 `grenade_entity_id`,代码用了 `entity_id`,导致所有实体分组为空
**修复**: 改用 `tp.grenade_entity_id`
**How to apply**: 使用任何第三方库时,先用小脚本验证实际返回的字段名,不要假设

## Bug 2: 坐标偏移 — 全部缩小一半

**现象**: 投掷物标记聚集在雷达图左上角 1/4 区域
**根因**: 雷达图实际 1024x1024,但 map.service.ts 中 radarSize 全部设为 2048。公式 `(imgWidth / radarSize)` = `1024/2048 = 0.5`,坐标缩小一半
**修复**: radarSize 全部改为 1024
**How to apply**: 硬编码的常量必须与实际资产尺寸匹配

## Bug 3: 坐标偏移 — 图片未加载时坐标翻倍

**现象**: 间歇性出现投掷物标记超出雷达图范围
**根因**: MapCanvas 中 `img?.width || 2048`,图片未加载时回退到 2048,公式 `(2048 / 1024) = 2`,坐标翻倍
**修复**: 改为 `img?.width || metadata.radarSize`
**How to apply**: 回退值应来自配置数据而非硬编码魔术数字

## Bug 4: 所有投掷物队伍都是 T

**现象**: 没有 CT 队伍的投掷物
**根因**: `parseGrenades()` 不返回 team 数据,`determineTeam()` 函数默认返回 'T'。`user_team_num` 列在部分 demo 中不可用
**修复**: 用 `player_team` 事件构建 steamid→[{tick, team}] 映射,按 tick 查找投掷者队伍
**How to apply**: 不依赖单一数据源,用独立事件构建交叉验证

## Bug 5: ESM/CJS 兼容性

**现象**: 多种报错 — `exports is not defined`、`Cannot find module`、`require("electron") returns string`
**根因**: Electron 33 主进程只支持 CJS;`package.json` 的 `"type": "module"` 导致 `.js` 被视为 ESM;环境变量 `ELECTRON_RUN_AS_NODE=1` 让 Electron 变成普通 Node
**修复**: 移除 `"type": "module"`;tsconfig 编译为 CJS;启动前 unset ELECTRON_RUN_AS_NODE
**How to apply**: Electron 项目不用 ESM,用 `declare const __dirname` 代替 `import.meta.url`

## Bug 6: 过滤器空选择返回全部数据

**现象**: 取消所有投掷物类型勾选后,显示全部而非空结果
**根因**: `queryGrenades` 中空数组跳过 IN 条件,SQL 无类型过滤
**修复**: 空数组时添加 `1 = 0` 条件

## Bug 7: better-sqlite3 NODE_MODULE_VERSION 不匹配

**现象**: `was compiled against a different Node.js version using NODE_MODULE_VERSION 130`
**根因**: better-sqlite3 编译给 Node v24 (137),但 Electron 33 用 Node v20 (130)
**修复**: `npx electron-rebuild`
**How to apply**: 每次 `npm install` 或升级 Electron 后需要 rebuild 原生模块

## Bug 8: 投掷→爆开匹配完全错乱(entityid 匹配失效)

**现象**: Aleksib round2 闪光弹从匪家中路飞到 B 区棺材,tick 差 15 万(41 分钟)
**根因**: 原代码用 `entityid`(爆开事件)匹配 `grenade_entity_id`(轨迹数据)。但 CS2 **复用 entity ID** — 同一个 ID 在不同 round 可分配给完全不同的投掷物。导致 round 2 的投掷配上了 round 15 的爆开
**修复**: 完全放弃 entityid 匹配,改为 (steamid + grenadeType + tick 时间接近度 10 秒内) 匹配
**How to apply**: 永远不要假设 Source 2 的 entity ID 全局唯一,它们会在同一局中被复用

## Bug 9: Entity ID 复用导致同一实体仅保留第一轮数据

**现象**: 总共 475 个爆开事件但只解析出 375 个投掷,约 100 条数据丢失;w0nderful R22 作为 CT 显示从 T 家二楼扔烟雾弹到棺材
**根因**: `parseGrenades()` 对同一 entity ID 只存第一个非 null 坐标点。当 entity ID 在 round 5 被复用时,round 5 的投掷数据被跳过(entityData.has(eid) 已存在)。导致后续 round 的投掷归零或归到错误位置
**修复**: 追踪每个 entity 的 null→非null 状态转换。每次从背包→飞行(null→有坐标)记为一次独立投掷,允许多次转换
**How to apply**: 处理 Source 2 实体数据时必须考虑 ID 复用,不能以 entity ID 为 key 做 Map 去重

## Bug 10: Fallback 手雷全部堆在 (0,0) — 字段名错误

**现象**: 104 个手雷 throwX/throwY 全部为 0,标记聚集在雷达图原点
**根因**: `parseEvents()` 返回的玩家位置字段是 `user_X`/`user_Y`/`user_Z`(带 `user_` 前缀),代码用了 `evt.X`(无前缀),结果 undefined→NaN→0
**修复**: 改为 `evt.user_X`、`evt.user_Y`、`evt.user_Z`
**How to apply**: 第三方库的输出字段名必须用调试脚本验证,不能靠猜测命名规范

## Bug 11: incendiary/molotov 类型不匹配导致无法配对

**现象**: CT 燃烧弹(incendiary)全部无法匹配到爆开事件,全部走 fallback 路径
**根因**: `parseGrenades()` 对 CT 燃烧弹返回类型含 "incendiary"→映射为 `'incendiary'`;而爆开事件 `inferno_startburn`→映射为 `'molotov'`。两者类型不同,匹配失败
**修复**: 匹配时 `normalizeType()` 将 `'incendiary'` 统一为 `'molotov'`
**How to apply**: 同一游戏机制的两种变体(CT/T 燃烧弹)在数据源中可能有不同标识,匹配逻辑需做归一化

## Bug 12: CT/T 队伍标签反复错误

**现象**: 多次修复仍无法正确标注队伍,部分玩家完全无队伍数据
**根因**: `player_team` 事件的 `team` 字段编号不稳定,与 `parseTicks.team_num` 使用相反的映射。部分玩家(如 zweih)完全没有 `player_team` 事件
**修复**: 彻底弃用 `player_team` 事件,改用 `parseTicks()` 的 `team_num` 字段。经 spawn 位置验证:`team_num=2 → T`,`team_num=3 → CT`
**How to apply**: `player_team` 事件不可靠(编号不一致+部分玩家无事件),队伍检测必须用 `parseTicks.team_num`

## Bug 13: setpos 位置偏移导致玩家卡墙

**现象**: 复制 setpos 指令到游戏中,玩家被传送到墙里或墙上
**根因**: `parseGrenades()` 返回的是**手雷实体**的第一个飞行坐标(在手前方/上方),不是玩家脚底位置。手雷出生点相对玩家有偏移(尤其贴墙时手雷出生在墙另一侧)
**修复**: 用 `parseTicks(filePath, ['X', 'Y', 'Z', 'pitch', 'yaw', 'steamid', 'tick'], throwTicks)` 获取投掷时刻的**玩家自身位置和视角**,替换手雷实体坐标作为 throwX/Y/Z
**How to apply**: `parseGrenades()` 的坐标是投掷物实体坐标,不是玩家坐标。需要玩家精确位置时必须用 `parseTicks()` 获取玩家数据

## Bug 14: 燃烧瓶/烟雾弹爆开位置跨多个 round 偏移

**现象**: apEX R2 在中路投出的燃烧瓶,爆开位置显示在 B 区(实际应在 A 小),detonation_tick 与 throw_tick 差 36000 ticks(9 分钟)
**根因**: `parseGrenades()` 对燃烧瓶/烟雾弹实体在**爆开后仍持续追踪**(火灾/烟雾实体存在时间长),轨迹数据从 tick 7972 一直延续到 tick 44013,中间没有 null 间隙。`inFlight` 状态一直为 true,`lastTick` 被更新到火灾消失的 tick,导致爆开位置 fallback 使用了完全错误的坐标
**修复**: 追踪 lastPoint 时加 tick 窗口限制:只接受投掷后 1000 tick(~15 秒)内的轨迹数据,超过的视为爆开后持续追踪,不更新 lastTick/lastX/lastY/lastZ
**How to apply**: `parseGrenades()` 对持续性实体(烟雾/火灾)的追踪远超投掷→爆开的时间窗口,必须限制轨迹数据的时间范围

## Bug 15: "仅显示高频投掷物"过滤器未生效

**现象**: 勾选"仅显示高频投掷物"后没有任何反应,数据不变
**根因**: `FilterPanel.applyFilters()` 传给 `queryGrenades` IPC 的参数缺少 `hfOnly` 字段。勾选后只更新了 store 状态但没传给后端 SQL 查询
**修复**: 在 IPC 调用中加上 `hfOnly: f.hfOnly || undefined`
**How to apply**: 过滤器的 UI 状态和实际查询参数必须同步传递,不能只更新 store 不传参

## Bug 16: 点击高频投掷物列表不立即高亮雷达图

**现象**: 点击右侧高频投掷物列表中的条目,雷达图不立即高亮,需要缩放/拖拽后才触发
**根因**: `MapCanvas` 中 `renderFrame` 的 `useCallback` 和 `useEffect` 依赖数组都缺少 `selectedHFCluster`
**修复**: 将 `selectedHFCluster` 加入两个依赖数组
**How to apply**: Canvas 重绘依赖所有影响渲染的状态变量,新增状态时必须同步更新依赖列表

## Bug 17: 打包后雷达图不显示

**现象**: 打包 exe 安装后,选择地图只有轨迹没有雷达底图
**根因**: 前端用 `/maps/radars/xxx.png` 相对路径加载图片,打包后没有 Vite dev server 提供静态文件服务,`file://` 协议下找不到
**修复**: 通过 IPC 通道 `maps:radar-path` 获取主进程的绝对路径,用 `file:///` 协议加载;dev 模式下仍用相对路径(`window.location.protocol` 判断)
**How to apply**: Electron 打包后资源路径必须用绝对路径 + file:// 协议,不能用相对路径

## Bug 18: parseTicks 批量查询 tick 偏移导致 setang 丢失

**现象**: 部分投掷物的 `thrower_yaw`/`thrower_pitch` 为 null,右键复制没有 setang
**根因**: `parseTicks()` 批量查询大量 tick 时返回的 tick 值可能和输入不一致(如输入 116851 返回 116853)。之前用 `${steamid}_${tick}` 精确匹配,差 1 tick 就失败
**修复**: 改为按 steamid 分组后 ±5 tick 范围最近匹配(`findClosestTickData`)
**How to apply**: `parseTicks()` 批量查询时返回的 tick 不保证和输入精确一致,必须用最近邻匹配而非精确匹配

## Bug 19: Fallback 路径产生不存在的虚假投掷物

**现象**: ropz 在某个 demo 的 R16 没有投掷任何道具,但解析结果中显示了多颗(包括 setpos 面壁的情况)
**根因**: `smokegrenade_detonate` 等事件可能被错误归属、或来自热身/其他上下文。没有 `parseGrenades()` 轨迹确认的 detonation 事件不可靠,不应作为投掷记录
**修复**: 彻底移除 fallback 路径,只保留有 trajectory 数据的投掷记录
**How to apply**: 只有 `parseGrenades()` 确认有轨迹的投掷物才是真实数据,仅靠 detonation 事件会产生虚假记录

## Bug 20: 回合编号偏移 +1(warmup 导致)

**现象**: tick 75605 的 mezii 烟雾弹显示在 round 11,实际应该是 round 10
**根因**: demo 中存在 warmup 的额外 `round_start` 事件(round=1 出现两次),用数组索引 `r + 1` 计算回合号会多算一个
**修复**: 改用 `round_start` 事件自带的 `round` 字段直接获取回合号,并对相同 round 号的多个事件去重
**How to apply**: CS2 demo 中 warmup 会产生额外的 round_start 事件,不能用数组索引计算回合号,必须用事件自带的 round 字段

## Feature: 高频投掷物右键菜单查看成员投掷

**功能**: 右键高频投掷物列表条目,弹出该聚类下所有投掷记录,每条显示"复制 [投掷人] 投掷坐标",点击复制 setpos;setang
**实现**: database.service.ts 新增 queryGrenadesByIds(ids); handlers.ts 新增 grenades:by-ids IPC; HFGrenadePanel.tsx onContextMenu 解析 grenadeIds JSON 后 IPC 查询并显示菜单
**How to apply**: HF 聚类的 grenadeIds 字段存成员 ID 的 JSON 数组,可用于关联查询

## Feature: 区分燃烧瓶 (molotov) 和燃烧弹 (incendiary)

**功能**: 解析时根据玩家实际持有的道具实体类型区分 molotov/incendiary,而非简单按阵营判断
**背景**: parseGrenades() 在飞行阶段统一返回 CMolotovProjectile,不区分 T方 molotov 和 CT方 incendiary。玩家可捡起敌方道具,故不能仅靠阵营判断
**实现**: parseGrenades() 返回三种火焰类实体:CMolotovGrenade(T方持有,x=null)、CIncendiaryGrenade(CT方持有,x=null)、CMolotovProjectile(飞行,x有值)。构建 steamid→sorted tick→type 查找表,投掷检测时用二分查找找到 throwTick 之前最近的持有条目,确定 molotov/incendiary。测试 95/95 全部匹配(delta=0或1 tick)
**关键文件**: electron/services/demo-parser.service.ts 中的 resolveFireType() 函数
**How to apply**: 火焰类道具类型必须从持有实体(CMolotovGrenade/CIncendiaryGrenade)推断,不能从飞行实体(CMolotovProjectile)或玩家阵营判断

## Bug 22: HF 聚类圆圈不受过滤器影响

**现象**: 筛选投掷物类型(如只选闪光弹)时,非闪光弹的高频聚类虚线圆圈仍然显示
**根因**: drawHFHighlights() 直接遍历全部 hfGrenades 绘制,未检查当前过滤后的 grenades 列表中是否包含该类型
**修复**: 绘制前从当前 grenades 提取 activeTypes 集合,过滤 hfGrenades 只绘制类型匹配的圆圈
**How to apply**: HF 聚类可视化应与主过滤器联动,用 filtered grenades 的类型集合过滤 HF 聚类