From 0cbaa09223a4b1c915a89491b3611a80374ab5c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:06:27 +0800 Subject: [PATCH 1/4] docs: spec for individual buy classification, held-AWP rate, merged layout Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VhXbxCyuCVE1dBhvLGAeeW --- ...25-buy-classification-awp-layout-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-buy-classification-awp-layout-design.md diff --git a/docs/superpowers/specs/2026-06-25-buy-classification-awp-layout-design.md b/docs/superpowers/specs/2026-06-25-buy-classification-awp-layout-design.md new file mode 100644 index 0000000..f49c49d --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-buy-classification-awp-layout-design.md @@ -0,0 +1,104 @@ +# 设计:个人买局分类 + 持狙占比 + 合并布局 + +日期:2026-06-25 +状态:已批准设计,待写实现计划 + +## 背景与目标 + +CS-Scout 2.0 当前把回合分为 Pistol/Full/Eco(按全队平均装备值),每名对手一张卡片、 +三宫格(Pistol/Full/Eco)、每卡各自带 CT/T tabs;AWP 率口径为"仅 CT 方 AWP 击杀 / CT 方 +总击杀",数据稀疏导致几乎总是 0%。 + +本次改动三件事: + +1. 回合分类改为**个人判定**,去掉 Eco,丢弃个人装备过低的局,只保留 **Pistol** 和 **Buy**。 +2. AWP 率改为**持狙回合占比**,修掉一直 0% 的问题。 +3. 前端重构:顶部**统一 CT/T 切换**;**手枪局把 5 名对手合并到一张图**置顶;每名对手卡片只留 + 一张**买局**图。 + +## 1. 回合分类(`parse.py` `classify_rounds`) + +判定点不变:每回合 freeze_end 快照,字段 `current_equip_value`。 + +- **改个人判定**:用目标玩家个人的 `current_equip_value`,不再算全队平均。 +- 类型 3 种 → 2 种: + - **Pistol** — 每半场首回合(现有 `pistol_num` / 换边逻辑不变;无条件保留,不受 2000 门槛限制)。 + - **Buy** — 非手枪局且个人装备 `>= EQ_BUY_MIN`(2000)。 + - 非手枪局且个人装备 `< 2000` → **丢弃**:`rtype = None`,不进入 JSON。 +- **换边检测不能被丢弃局打断**:手枪局判定依赖 `prev_side`。因此即使某局被丢弃,`side` 仍按 + 真实 CT/T 计算并用于 `prev_side` 跟踪,只把 `rtype` 置 `None` 表示丢弃。否则下一局会被 + 误判为半场首局(=手枪局)。 +- **下游过滤**:`parse_positions` / `parse_grenades_for_rounds` / `parse_deaths_for_rounds` + 当前的 `active = [r for r in classified if r["side"]]` 改为 + `if r["side"] and r["rtype"]`,从而排除被丢弃的局。 +- `config.py` 新增 `EQ_BUY_MIN = 2000`。`EQ_FULL_BUY` 不再被 `classify_rounds` 使用(保留常量, + 避免触碰旧引用;如确认无其他引用可一并删除)。 + +## 2. AWP 率 → 持狙回合占比(`combat.py`) + +- 废弃旧指标(CT 方 AWP 击杀 / CT 方击杀)。 +- 新指标:**持狙回合数 / 总回合数 × 100**。 +- **"持狙"判定**:在该回合的采样 tick 上读目标的 `inventory`(demoparser2 tick 字段,返回武器名 + 列表),任一采样点的 inventory 含 AWP(匹配 `awp` / `weapon_awp`,大小写不敏感)即记该回合为 + 持狙回合。 + - 实现前先用真实 demo 验证 `inventory` 字段可用及其武器名格式。 + - **回退方案**:若 `inventory` 不可用,退化为"该回合内目标有 AWP 击杀"(player_death, + attacker=目标 且 weapon=awp,tick 落在回合窗口)。 +- **分母 = 该玩家出场的全部回合**(CT + T,不限经济,含被分类层丢弃的 eco 局)。即用 + `classify_rounds` 结果里 `side` 非空的全部回合,不要求 `rtype`。 +- `parse_combat_stats` 返回 `{kd, awp_rounds, total_rounds}`。 +- `aggregate_combat_stats`:`awp_rate = round(sum(awp_rounds)/sum(total_rounds)*100, 1)`, + 分母 0 时为 0.0。`kd` 聚合不变。 + +## 3. 前端布局 + +### 统一 CT/T 切换(`index.html` header + `app.js`) +- 在 header 的 `.ctrls`(与播放/进度条同排)放一个 CT/T 切换(两个按钮,默认 CT)。 +- 该开关控制页面上**所有** canvas 的 side:手枪合并图 + 每个对手买局图。一次只看一边。 +- 移除每张卡片内的 CT/T tabs。 + +### 手枪局合并区(`#pistol`,置于 `#cards` 上方) +- **一张大 canvas**,叠加本次扫描到的最多 5 名对手的**手枪局**路径。 +- **每名玩家一种颜色** + 文字图例(玩家名 ↔ 颜色)。 +- 受顶部统一开关控制:显示当前 side 的手枪局。 +- 实现:构造合并轮次列表 = 各玩家 `rounds` 中 `rtype=="Pistol"` 的局,每局打上该玩家颜色 tag, + 作为单个 `ReplayPlayer` 的 `rounds`,`rtype="Pistol"`。 + +### 每名对手卡片(`#cards`) +- 保留:玩家名、K/D、AWP%(持狙占比)、回合数。 +- 去掉:CT/T tabs、Pistol/Full/Eco 三宫格。 +- 只留**一张 canvas**,叠加该玩家所有 **Buy** 局,`rtype="Buy"`,按 side 上色。 + +### `replay.js`(最小改动) +- 支持 per-round 颜色覆盖:dot/X/arrow 颜色取 `r.color || SIDE_COLOR[this.side]` + (把 `col` 计算移入按 round 循环内)。单人买局图不带 `color`,仍按 side 上色;合并图每局带 + `color`。 +- `setFilter(side, rtype)` 不变。统一开关切 side 时,对每个 `ReplayPlayer` 调用 + `setFilter(side, 该图固定 rtype)`(合并图固定 Pistol,买局图固定 Buy)。 +- `RTYPES = ["Pistol","Full","Eco"]` 常量移除/改写为新布局逻辑。 + +## 影响文件 + +| 文件 | 改动 | +|------|------| +| `server/config.py` | 新增 `EQ_BUY_MIN = 2000` | +| `server/parse.py` | `classify_rounds` 个人判定 + 丢弃逻辑;3 个下游过滤条件加 `and r["rtype"]` | +| `server/combat.py` | AWP 改持狙占比(inventory 检测 + 回退);返回值与聚合调整 | +| `server/templates/index.html` | header 加统一 CT/T 开关;main 加 `#pistol` 区 | +| `server/static/app.js` | 新布局:合并手枪图、单买局图、统一开关接线;移除三宫格/每卡 tabs | +| `server/static/replay.js` | per-round 颜色覆盖 | +| `server/player_json.py` | 无需改(rtype 自然变 Pistol/Buy) | + +## 验证 + +- 单元/集成:对 fixture demo `demos_analysis/g161-...de_mirage.dem` 验证 + `classify_rounds` 只产出 Pistol/Buy/None,个人判定生效;`parse_combat_stats` 持狙占比对已知 + AWPer(如 76561199755381652,26 次 AWP 击杀)> 0。 +- 视觉:重启 `web_server.py`,跑一次真实扫描或用 `replay_test.html`,确认顶部统一开关切换全部 + canvas、手枪合并图多色叠加、每卡单买局图正常。 + +## 不做(YAGNI) + +- 不恢复 Force Buy 档。 +- 不做按回合逐个开关(每图整体叠加即可)。 +- 不改下载/管线/5E 抓取逻辑。 From 6da70d665fb25275bda220238f613a916d72bca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:38:29 +0800 Subject: [PATCH 2/4] feat: per-player buy classification, AWP-hold rate, merged-pistol layout - classify_rounds: judge economy on the target's own freeze-end equip, not team average. Two kept types only: Pistol (half opener, always kept) and Buy (personal equip >= EQ_BUY_MIN=2000). Non-pistol rounds below the floor are dropped (rtype=None) but keep their real side so half/pistol tracking holds. Downstream position/grenade/death filters now require side AND rtype. - combat: replace sparse CT-side AWP-kill ratio with AWP-hold rate = rounds where the player ever held an AWP (inventory tick field) / total rounds played (both sides). Returns {awp_rounds, total_rounds}; aggregate sums both. - frontend: unified CT/T toggle in the header drives every canvas at once; merged pistol overlay on top (all scanned players' pistols, one color each + legend); each player card keeps a single Buy canvas. replay.js honors a per-round color override for the merged view. - tests + CLAUDE.md updated to the new schema. Verified on the fixture demo (AWP-hold 52.9% for the team AWPer) and via a seeded headless-browser run of both CT and T views. 23 passed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VhXbxCyuCVE1dBhvLGAeeW --- CLAUDE.md | 53 ++++++++------ server/combat.py | 63 ++++++++--------- server/config.py | 3 +- server/parse.py | 22 +++--- server/static/app.js | 95 ++++++++++++++++++-------- server/static/replay.js | 2 +- server/templates/index.html | 21 +++++- server/tests/test_combat.py | 8 +-- server/tests/test_parse_rounds.py | 5 +- server/tests/test_pipeline_assembly.py | 4 +- server/tests/test_player_json.py | 4 +- 11 files changed, 180 insertions(+), 100 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5c8caa..a3f049a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ web_server.py (Flask, port 5000) ▼ output/player_{domain}.json + output/analysis_summary.json ▼ -Browser fetches /api/player/ → ReplayPlayer canvas (9s loop, CT/T × Pistol/Full/Eco) +Browser fetches /api/player/ → ReplayPlayer canvas (looping, unified CT/T; merged-pistol + per-player Buy) ``` ### Module Map (`server/`) @@ -40,7 +40,7 @@ Browser fetches /api/player/ → ReplayPlayer canvas (9s loop, CT/T × P | `maps.py` | Runtime map loader: `load_map(name)`, `game_to_pixel(transform,gx,gy)`, `available_maps()` | | `setup_maps.py` | One-time: pull radar.png + transform from awpy into `data/maps//` | | `parse.py` | `get_round_table`, `classify_rounds` (3-type CT/T), `parse_positions`, `parse_grenades_for_rounds`, `parse_demo` (merger) | -| `combat.py` | `parse_combat_stats` (K/D + CT AWP), `aggregate_combat_stats` | +| `combat.py` | `parse_combat_stats` (K/D + AWP-hold rate), `aggregate_combat_stats` | | `player_json.py` | `build(...)` — assembles per-player JSON from rounds + combat | | `pipeline.py` | `run(usernames, map_name, ...)`, demo dedup index, `cleanup_demos`, `download_and_extract`, `assemble_round_offset` | | `api_client.py` | 5E scraping: `search_player`, `get_demos_by_domain(domain, map_name, count)`, `get_steamid_for_player`, `download_demo` | @@ -87,12 +87,18 @@ if total `.dem` size > 30 GB, deletes oldest files until ≤ 10 GB. - `game_to_pixel(transform, gx, gy)` = `((gx-pos_x)/scale, (pos_y-gy)/scale)` — Y axis inverted. - `available_maps()` lists dirs under `MAPS_DIR` that contain `meta.json`. -### Round Classification (parse.classify_rounds) — 3 types, CT and T +### Round Classification (parse.classify_rounds) — 2 kept types, CT and T -At each `round_freeze_end`, for the target steamid: `side` ∈ {CT, T}. Economy: -- **Pistol** — first round of each half-segment (side flips into a new half). -- **Full** — team-avg `current_equip_value` ≥ `EQ_FULL_BUY` (3800). -- **Eco** — otherwise. (No Force Buy in 2.0.) +At each `round_freeze_end`, for the target steamid: `side` ∈ {CT, T}. Economy is judged on +the target's **own** `current_equip_value` (not team average): +- **Pistol** — first round of each half-segment (side flips into a new half). Always kept, + regardless of equip. +- **Buy** — non-pistol with personal equip ≥ `EQ_BUY_MIN` (2000). +- Non-pistol with personal equip < 2000 → **dropped**: `rtype=None`, excluded from JSON. + The round still carries its real `side` so half-segment (pistol) tracking isn't broken. + +Downstream `parse_positions`/`parse_grenades_for_rounds`/`parse_deaths_for_rounds` filter on +`r["side"] and r["rtype"]`, so dropped rounds produce no path/grenades/death. ### Position Sampling (parse.parse_positions) @@ -108,9 +114,11 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s ### Combat Stats (combat.py) - **K/D** — global scoreboard: `kills_total`/`deaths_total` at last `round_end` tick; averaged across demos. -- **AWP rate** — CT-side AWP kills / CT-side total kills. CT rounds via `classify_rounds` `fe_tick`/`end_tick`. - `SNIPER_EVENT_NAMES = {"awp","ssg08","g3sg1","scar20"}` (plain event `weapon` names). - Aggregated as `sum(awp_kills)/sum(ct_kills)`. +- **AWP rate** — **AWP-hold rate**: rounds where the player ever held an AWP / total rounds played + (both sides, any economy). "Held" = the `inventory` tick field (weapon display names) contains + `"AWP"` at any sample tick across the round window. Per demo returns `{awp_rounds, total_rounds}`; + aggregated as `sum(awp_rounds)/sum(total_rounds)`. (Replaces the old CT-side AWP-kill ratio, which + was too sparse and read ~0% for most players.) ### Endpoints (web_server.py) @@ -143,7 +151,7 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s ] } ``` -`side` ∈ {CT,T}; `rtype` ∈ {Pistol,Full,Eco}; all `t` are seconds from freeze_end; coords are game coords. +`side` ∈ {CT,T}; `rtype` ∈ {Pistol,Buy}; all `t` are seconds from freeze_end; coords are game coords. `output/analysis_summary.json`: ```json @@ -155,16 +163,21 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s ### Frontend (templates/index.html + static/app.js + static/replay.js) -- Sticky 50px header + left sidebar (map ``, 5 username inputs, depth, key, scan button, status, failed list) + main panel. +- Main panel = a **merged pistol overlay** on top (`#pistol`: one canvas overlaying *all* scanned + players' Pistol rounds, each player a distinct color + legend) followed by per-player cards. +- The header CT/T toggle drives `side` on **every** canvas at once (merged pistol + each card's buy + canvas); one side is shown at a time. There are no per-card tabs. - `app.js`: `loadMaps()` fills the dropdown; `run()` POSTs `/api/analyze`; `poll()` hits `/api/status` - every 2s and adds a card per result via `/api/player/`. -- Each card: K/D, AWP%, round count; CT/T tabs; 3 canvases (Pistol/Full/Eco). Switching tab calls - `setFilter(side, rtype)` on each `ReplayPlayer`. + every 2s and adds a card per result via `/api/player/`. Each card: K/D, AWP-hold %, round + count, and a single **Buy** canvas. The merged overlay grows a shared `pistolRounds` array as + players load; the toggle calls `setFilter(side, fixedRtype)` on each registered `ReplayPlayer`. - `replay.js` `ReplayPlayer(canvas, {radar, transform, rounds, side, rtype})`: - overlays all matching rounds on a `PLAYBACK_S=9`s loop (45s game time, 5× speed). **No fading trails.** - Draws grenade in-flight arcs, landing dots, and range circles (smoke/molotov) during `[land_t, expire_t]`. - Methods: `setFilter`, `toggleRound`, `start`, `stop`. `static/replay_test.html` is a standalone fixture. + overlays all matching rounds on a `PLAYBACK_S` loop (`WINDOW_S` game time accelerated). **No fading + trails.** Per-round `color` overrides the side color (used by the merged pistol overlay). Draws + grenade in-flight arcs, landing dots, and range circles (smoke/molotov) during `[land_t, expire_t]`. + Methods: `setFilter`, `toggleRound`, `drawAt`. `static/replay_test.html` is a standalone fixture. ### Known Data Limitations @@ -232,7 +245,7 @@ Always cast result to `pd.DataFrame` and cast `steamid` to `str`. | File | Purpose | |------|---------| -| `server/config.py` | `HOST/PORT/SECRET_KEY`, paths, `MAPS_DIR`, `TICK_RATE=64`, `WINDOW_S=45`, `SAMPLE_EVERY=8`, `EQ_FULL_BUY=3800` | +| `server/config.py` | `HOST/PORT/SECRET_KEY`, paths, `MAPS_DIR`, `TICK_RATE=64`, `WINDOW_S=20`, `SAMPLE_EVERY=8`, `EQ_BUY_MIN=2000` (per-player buy floor; `EQ_FULL_BUY` legacy/unused) | | `server/data/maps//radar.png` | Radar background (generated by setup_maps.py) | | `server/data/maps//meta.json` | Coordinate transform `{pos_x,pos_y,scale}` | | `server/demos_opponents/` | Downloaded .dem files + `.demo_index.json` | diff --git a/server/combat.py b/server/combat.py index 14e66de..dc3a073 100644 --- a/server/combat.py +++ b/server/combat.py @@ -1,12 +1,12 @@ -"""Combat stats: global K/D (scoreboard) + CT-side AWP rate.""" +"""Combat stats: global K/D (scoreboard) + AWP-hold rate (share of rounds the +player held an AWP, both sides).""" import logging -import numpy as np import pandas as pd from demoparser2 import DemoParser +import config import parse log = logging.getLogger("combat") -SNIPER_EVENT_NAMES = {"awp", "ssg08", "g3sg1", "scar20"} def parse_combat_stats(path, steamid): sid = str(steamid) @@ -37,39 +37,40 @@ def parse_combat_stats(path, steamid): rounds = parse.get_round_table(evts) classified = parse.classify_rounds(p, rounds, {sid}) if rounds else [] - ct_rounds = [r for r in classified if r["side"] == "CT"] - if not ct_rounds: - return {"kd": kd_val, "ct_kills": 0, "awp_kills": 0} + # Denominator: every round the player was present for (both sides, any economy). + played = [r for r in classified if r["side"]] + awp_rounds = _count_awp_hold_rounds(p, played, sid) + return {"kd": kd_val, "awp_rounds": awp_rounds, "total_rounds": len(played)} - fe = np.array([r["fe_tick"] for r in ct_rounds]) - end = np.array([r["end_tick"] for r in ct_rounds]) - def in_ct(ticks): - t = ticks.values[:, None] - m = (t >= fe) & (t <= end) - return m.any(axis=1) - - ct_kills = awp_kills = 0 - dd = evts.get("player_death") - if dd is not None: - if not isinstance(dd, pd.DataFrame): - dd = pd.DataFrame(dd) - if not dd.empty and "attacker_steamid" in dd.columns: - dd["attacker_steamid"] = dd["attacker_steamid"].astype(str) - dd["user_steamid"] = dd["user_steamid"].astype(str) - dd = dd[in_ct(dd["tick"])] - kills = dd[(dd["attacker_steamid"] == sid) & - (dd["attacker_steamid"] != dd["user_steamid"])] - ct_kills = len(kills) - if "weapon" in kills.columns: - awp_kills = int(kills["weapon"].isin(SNIPER_EVENT_NAMES).sum()) - return {"kd": kd_val, "ct_kills": ct_kills, "awp_kills": awp_kills} +def _count_awp_hold_rounds(parser, played, sid): + """Number of `played` rounds where the player ever held an AWP. Samples the + `inventory` tick field (weapon display names) across each round window.""" + if not played: + return 0 + span = config.WINDOW_S * config.TICK_RATE + sample_ticks, tick_round = [], {} + for r in played: + for t in range(r["fe_tick"], min(r["fe_tick"] + span, r["end_tick"]), config.SAMPLE_EVERY): + sample_ticks.append(t); tick_round[t] = r["official_num"] + if not sample_ticks: + return 0 + df = parser.parse_ticks(["inventory", "steamid"], ticks=sample_ticks) + if not isinstance(df, pd.DataFrame): + df = pd.DataFrame(df) + df["steamid"] = df["steamid"].astype(str) + df = df[df["steamid"] == sid] + held = set() + for tick, inv in zip(df["tick"], df["inventory"]): + if inv is not None and "AWP" in inv: + held.add(tick_round[int(tick)]) + return len(held) def aggregate_combat_stats(stats_list): valid = [s for s in stats_list if s is not None] if not valid: return None kd = round(sum(s["kd"] for s in valid) / len(valid), 2) - tk = sum(s["ct_kills"] for s in valid) - ak = sum(s["awp_kills"] for s in valid) - awp_rate = round(ak / tk * 100, 1) if tk > 0 else 0.0 + tr = sum(s["total_rounds"] for s in valid) + ar = sum(s["awp_rounds"] for s in valid) + awp_rate = round(ar / tr * 100, 1) if tr > 0 else 0.0 return {"kd": kd, "awp_rate": awp_rate} diff --git a/server/config.py b/server/config.py index 4f2ccfc..f5fbf2d 100644 --- a/server/config.py +++ b/server/config.py @@ -16,7 +16,8 @@ TICK_RATE = 64 WINDOW_S = 20 # per-round capture window (seconds from freeze_end) SAMPLE_EVERY = 8 # downsample stride in ticks (~8Hz) -EQ_FULL_BUY = 3800 # team-avg equip value threshold for Full vs Eco +EQ_FULL_BUY = 3800 # (legacy) team-avg threshold; no longer used by classify_rounds +EQ_BUY_MIN = 2000 # per-player equip value floor: below this a non-pistol round is dropped # Server HOST = "0.0.0.0" diff --git a/server/parse.py b/server/parse.py index f1f458a..3f0ab22 100644 --- a/server/parse.py +++ b/server/parse.py @@ -21,8 +21,12 @@ def get_round_table(evts): def classify_rounds(parser, rounds, target_sids): """Classify each round's side (for the target) and economy type. - Pistol = first round of each half (each time target's side flips into a new - half-start). CT and T both classified. Returns side+rtype per round.""" + Two kept types, judged on the target's *own* freeze-end equip value: + - Pistol = first round of each half-segment (side flips into a new half). + - Buy = non-pistol with personal equip >= EQ_BUY_MIN. + Non-pistol rounds with personal equip < EQ_BUY_MIN are dropped: rtype=None + (the round still carries its real side so half-segment tracking is unbroken). + Returns side+rtype per round.""" if not rounds: return [] fe_ticks = [r["fe_tick"] for r in rounds] @@ -45,12 +49,12 @@ def classify_rounds(parser, rounds, target_sids): side = "CT" if tgt["team_name"].iloc[0] == "CT" else "T" if side != prev_side: # entering a new half-segment on this side pistol_num[side] = r["official_num"] + prev_side = side if r["official_num"] == pistol_num[side]: - rtype = "Pistol" + rtype = "Pistol" # always kept, regardless of equip floor else: - avg_eq = g[g["team_name"] == tgt["team_name"].iloc[0]]["current_equip_value"].mean() - rtype = "Full" if avg_eq >= config.EQ_FULL_BUY else "Eco" - prev_side = side + personal_eq = tgt["current_equip_value"].iloc[0] + rtype = "Buy" if personal_eq >= config.EQ_BUY_MIN else None result.append({**r, "side": side, "rtype": rtype}) return result @@ -59,7 +63,7 @@ def parse_positions(parser, classified, target_steamid): """Sample target's X/Y every SAMPLE_EVERY ticks across [fe, fe+WINDOW_S] for each classified round (side != None). Returns per-round path lists.""" sid = str(target_steamid) - active = [r for r in classified if r["side"]] + active = [r for r in classified if r["side"] and r["rtype"]] if not active: return [] span = config.WINDOW_S * config.TICK_RATE @@ -102,7 +106,7 @@ def parse_positions(parser, classified, target_steamid): def parse_grenades_for_rounds(parser, classified, target_steamid): sid = str(target_steamid) - active = [r for r in classified if r["side"]] + active = [r for r in classified if r["side"] and r["rtype"]] if not active: return {} g = parser.parse_grenades() @@ -145,7 +149,7 @@ def parse_deaths_for_rounds(evts, classified, target_steamid): only. Dead players keep reporting a frozen position to the window end, so death must come from player_death events. Returns {official_num: death_t}.""" sid = str(target_steamid) - active = [r for r in classified if r["side"]] + active = [r for r in classified if r["side"] and r["rtype"]] out = {} dd = evts.get("player_death") if dd is None: diff --git a/server/static/app.js b/server/static/app.js index e8b6f28..a3b7ee0 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1,11 +1,24 @@ const $ = s => document.querySelector(s); -const RTYPES = ["Pistol","Full","Eco"]; -let players = {}; // domain -> {data, ps:{rtype:ReplayPlayer}, side} +let players = {}; // domain -> {data, buyPlayer} + +// Per-player colors for the merged pistol overlay + card accents. +const PLAYER_COLORS = ["#4aa3ff","#ffc23b","#5ad469","#ff6b9d","#b48cff"]; // ── Global playback clock: one loop drives every canvas in lockstep ────────── const allPlayers = []; // every ReplayPlayer on the page const clock = { elapsed: 0, playing: true, last: null }; +// ── Unified CT/T state: one toggle drives every canvas's side ──────────────── +let curSide = "CT"; +const sideTargets = []; // [{rp, rtype}] — each canvas keeps its own fixed rtype +function applySide(){ for(const {rp,rtype} of sideTargets) rp.setFilter(curSide, rtype); } + +// Merged pistol overlay: one canvas, all scanned players' pistol rounds, each +// round pre-tagged with its player color. We grow this shared array as players +// load; the ReplayPlayer reads it live every frame. +let pistolRounds = []; +let pistolPlayer = null; + function tick(ts){ if(clock.last === null) clock.last = ts; const dt = (ts - clock.last) / 1000; clock.last = ts; @@ -22,12 +35,20 @@ requestAnimationFrame(tick); function wireControls(){ const btn = $("#playpause"), scrub = $("#scrub"); - if(!btn || !scrub) return; // tolerate a stale/mismatched template - btn.onclick = () => { clock.playing = !clock.playing; clock.last = null; }; - scrub.addEventListener("input", e => { - clock.playing = false; - clock.elapsed = (+e.target.value / 1000) * PLAYBACK_S; - }); + if(btn && scrub){ + btn.onclick = () => { clock.playing = !clock.playing; clock.last = null; }; + scrub.addEventListener("input", e => { + clock.playing = false; + clock.elapsed = (+e.target.value / 1000) * PLAYBACK_S; + }); + } + const ct = $("#sideCT"), t = $("#sideT"); + if(ct && t){ + const set = (side, on, off) => { curSide = side; on.classList.add("active"); + off.classList.remove("active"); applySide(); }; + ct.onclick = () => set("CT", ct, t); + t.onclick = () => set("T", t, ct); + } } async function loadMaps(){ @@ -43,7 +64,12 @@ async function run(){ headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); const j = await r.json(); if(j.error){ $("#status").textContent = "错误:"+j.error; return; } - $("#cards").innerHTML=""; players={}; allPlayers.length=0; poll(); + // reset all view state for a fresh scan + $("#cards").innerHTML=""; players={}; allPlayers.length=0; sideTargets.length=0; + pistolRounds.length=0; pistolPlayer=null; curSide="CT"; + $("#sideCT").classList.add("active"); $("#sideT").classList.remove("active"); + $("#pistol").style.display="none"; $("#pistolLegend").innerHTML=""; + poll(); } async function poll(){ @@ -54,32 +80,45 @@ async function poll(){ if(s.status === "running") setTimeout(poll, 2000); } +// Lazily build the merged pistol overlay once the first player's map is known. +function ensurePistolOverlay(data){ + if(pistolPlayer) return; + const cv = $("#pistolCanvas"); + pistolPlayer = new ReplayPlayer(cv, {radar:data.radar, transform:data.transform, + rounds:pistolRounds, side:curSide, rtype:"Pistol"}); + allPlayers.push(pistolPlayer); + sideTargets.push({rp:pistolPlayer, rtype:"Pistol"}); + $("#pistol").style.display="block"; +} + async function addCard(res){ const r = await fetch("/api/player/"+res.domain); const data = await r.json(); + const idx = Object.keys(players).length; + const color = PLAYER_COLORS[idx % PLAYER_COLORS.length]; + + ensurePistolOverlay(data); + // merge this player's pistol rounds (tagged with their color) into the overlay + for(const rd of data.rounds) if(rd.rtype==="Pistol") pistolRounds.push({...rd, color}); + $("#pistolLegend").insertAdjacentHTML("beforeend", + `${data.username}`); + + // per-player card: stats + a single Buy-round canvas const card = document.createElement("div"); card.className="card"; const cs = data.combat_stats||{}; - card.innerHTML = `
${data.username} + card.innerHTML = `
${data.username} K/D ${cs.kd??"-"} - AWP ${cs.awp_rate??"-"}% + 持狙 ${cs.awp_rate??"-"}% ${data.round_count} 回合
-
-
-
${RTYPES.map(rt=>`

${rt}

-
`).join("")}
`; +

买局

+
`; $("#cards").appendChild(card); - const ps = {}; players[res.domain] = {data, ps, side:"CT"}; - for(const rt of RTYPES){ - const cv = card.querySelector(`canvas[data-rt="${rt}"]`); - const p = new ReplayPlayer(cv,{radar:data.radar,transform:data.transform, - rounds:data.rounds,side:"CT",rtype:rt}); - allPlayers.push(p); ps[rt]=p; - } - card.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{ - card.querySelectorAll(".tabs button").forEach(x=>x.classList.remove("active")); - b.classList.add("active"); - const side=b.dataset.s; players[res.domain].side=side; - for(const rt of RTYPES) ps[rt].setFilter(side, rt); - }); + + const cv = card.querySelector('canvas[data-rt="Buy"]'); + const buyPlayer = new ReplayPlayer(cv,{radar:data.radar,transform:data.transform, + rounds:data.rounds, side:curSide, rtype:"Buy"}); + allPlayers.push(buyPlayer); + sideTargets.push({rp:buyPlayer, rtype:"Buy"}); + players[res.domain] = {data, buyPlayer}; } $("#run").onclick = run; diff --git a/server/static/replay.js b/server/static/replay.js index d8a00f9..fc61fd7 100644 --- a/server/static/replay.js +++ b/server/static/replay.js @@ -46,8 +46,8 @@ class ReplayPlayer { ctx.clearRect(0,0,this.cv.width,this.cv.height); if(this.imgReady) ctx.drawImage(this.img,0,0); else { ctx.fillStyle="#1a1a2e"; ctx.fillRect(0,0,this.cv.width,this.cv.height); } - const col=SIDE_COLOR[this.side]; for(const r of this._rounds()){ + const col = r.color || SIDE_COLOR[this.side]; // merged views color per player // grenades: range circles, landing, in-flight arc for(const n of (r.grenades||[])){ if(gt>=n.land_t && gt
CS-Scout 2.0 — 对手回放分析 + + + 0.0 / 20.0s @@ -48,7 +60,14 @@
-
+
+ +
+
diff --git a/server/tests/test_combat.py b/server/tests/test_combat.py index 1d96b0f..46837ec 100644 --- a/server/tests/test_combat.py +++ b/server/tests/test_combat.py @@ -4,11 +4,11 @@ def test_aggregate_math(): out = combat.aggregate_combat_stats([ - {"kd": 1.0, "ct_kills": 10, "awp_kills": 5}, - {"kd": 2.0, "ct_kills": 10, "awp_kills": 0}, + {"kd": 1.0, "awp_rounds": 5, "total_rounds": 10}, + {"kd": 2.0, "awp_rounds": 0, "total_rounds": 10}, ]) assert out["kd"] == 1.5 - assert out["awp_rate"] == 25.0 # 5/20 + assert out["awp_rate"] == 25.0 # 5/20 rounds held an AWP def test_aggregate_empty(): assert combat.aggregate_combat_stats([None, None]) is None @@ -28,4 +28,4 @@ def test_parse_combat_real(): s = combat.parse_combat_stats(FIXTURE, sid) assert s is not None assert isinstance(s["kd"], float) and s["kd"] >= 0 - assert s["ct_kills"] >= 0 and 0 <= s["awp_kills"] <= s["ct_kills"] + assert s["total_rounds"] >= 0 and 0 <= s["awp_rounds"] <= s["total_rounds"] diff --git a/server/tests/test_parse_rounds.py b/server/tests/test_parse_rounds.py index af3e058..e7ca2c0 100644 --- a/server/tests/test_parse_rounds.py +++ b/server/tests/test_parse_rounds.py @@ -36,5 +36,8 @@ def test_classify_both_sides_and_pistol(parser_and_evts): sides = {c["side"] for c in classified if c["side"]} assert sides == {"CT", "T"} # player appears on both sides rtypes = {c["rtype"] for c in classified if c["rtype"]} - assert rtypes <= {"Pistol", "Full", "Eco"} + assert rtypes <= {"Pistol", "Buy"} # 2.0: only pistol + buy kept assert "Pistol" in rtypes # at least the opening pistol + # rounds may be dropped (rtype=None) but keep their real side for half-tracking + assert any(c["side"] and not c["rtype"] for c in classified) or \ + all(c["rtype"] for c in classified if c["side"]) diff --git a/server/tests/test_pipeline_assembly.py b/server/tests/test_pipeline_assembly.py index ad3a3a9..05c3019 100644 --- a/server/tests/test_pipeline_assembly.py +++ b/server/tests/test_pipeline_assembly.py @@ -3,8 +3,8 @@ import pipeline def test_round_offset_dedups_across_demos(): - a = [{"side":"CT","rtype":"Full","official_num":1,"path":[],"grenades":[]}] - b = [{"side":"CT","rtype":"Full","official_num":1,"path":[],"grenades":[]}] + a = [{"side":"CT","rtype":"Buy","official_num":1,"path":[],"grenades":[]}] + b = [{"side":"CT","rtype":"Buy","official_num":1,"path":[],"grenades":[]}] ra = pipeline.assemble_round_offset(a, 0) rb = pipeline.assemble_round_offset(b, 1) assert ra[0]["official_num"] == 1 diff --git a/server/tests/test_player_json.py b/server/tests/test_player_json.py index 1a14c97..160db08 100644 --- a/server/tests/test_player_json.py +++ b/server/tests/test_player_json.py @@ -7,7 +7,7 @@ def test_build_schema(tmp_path, monkeypatch): (d / "radar.png").write_bytes(b"x") (d / "meta.json").write_text(json.dumps({"transform":{"pos_x":-3230.0,"pos_y":1713.0,"scale":5.0}})) monkeypatch.setattr(config, "MAPS_DIR", str(tmp_path / "maps")) - rounds = [{"side":"CT","rtype":"Full","official_num":3, + rounds = [{"side":"CT","rtype":"Buy","official_num":3, "path":[[0.0,-1.0,2.0]], "grenades":[{"type":"smoke","throw_t":1.0,"land_t":2.0, "arc":[[1.0,0.0,0.0]],"land":[0.0,0.0],"expire_t":20.0}]}] @@ -18,5 +18,5 @@ def test_build_schema(tmp_path, monkeypatch): assert out["combat_stats"]["kd"] == 1.2 assert out["round_count"] == 1 r0 = out["rounds"][0] - assert r0["side"]=="CT" and r0["rtype"]=="Full" and r0["round_id"]==3 + assert r0["side"]=="CT" and r0["rtype"]=="Buy" and r0["round_id"]==3 assert r0["grenades"][0]["type"]=="smoke" From af5f577604d0ca1e26f8f13d942fe4b6cc8eda5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:28:32 +0800 Subject: [PATCH 3/4] feat: tabbed single-canvas layout (merged pistol + 5 opponents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the scroll-through stack with a top button row that switches between one large canvas at a time: 手枪局 (merged pistol overlay) + one tab per scanned opponent (their Buy rounds). Only the active view renders to screen; the unified CT/T header toggle still drives every canvas. Canvas is height-bounded (max-height: calc(100vh - 170px)) so it fills the panel. Verified via a 5-player seeded headless-browser run: 6 tabs, exactly one view visible, 830x830 active canvas, per-opponent stats header, switching works. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VhXbxCyuCVE1dBhvLGAeeW --- server/static/app.js | 61 +++++++++++++++++++++++++------------ server/templates/index.html | 30 ++++++++---------- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/server/static/app.js b/server/static/app.js index a3b7ee0..a63cb62 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -13,6 +13,28 @@ let curSide = "CT"; const sideTargets = []; // [{rp, rtype}] — each canvas keeps its own fixed rtype function applySide(){ for(const {rp,rtype} of sideTargets) rp.setFilter(curSide, rtype); } +// ── View switcher: one big canvas at a time, picked via top buttons ────────── +// views[i] = {id, btn, panel}. id = "pistol" or a player domain. +const views = []; +let activeView = null; +function selectView(id){ + activeView = id; + for(const v of views){ + const on = v.id === id; + v.panel.classList.toggle("active", on); + v.btn.classList.toggle("active", on); + } +} +function addView(id, label, panel){ + const btn = document.createElement("button"); + btn.textContent = label; + btn.onclick = () => selectView(id); + $("#viewtabs").appendChild(btn); + $("#stage").appendChild(panel); + views.push({id, btn, panel}); + if(activeView === null) selectView(id); // first view becomes active +} + // Merged pistol overlay: one canvas, all scanned players' pistol rounds, each // round pre-tagged with its player color. We grow this shared array as players // load; the ReplayPlayer reads it live every frame. @@ -65,10 +87,10 @@ async function run(){ const j = await r.json(); if(j.error){ $("#status").textContent = "错误:"+j.error; return; } // reset all view state for a fresh scan - $("#cards").innerHTML=""; players={}; allPlayers.length=0; sideTargets.length=0; - pistolRounds.length=0; pistolPlayer=null; curSide="CT"; + $("#viewtabs").innerHTML=""; $("#stage").innerHTML=""; + players={}; allPlayers.length=0; sideTargets.length=0; views.length=0; + pistolRounds.length=0; pistolPlayer=null; activeView=null; curSide="CT"; $("#sideCT").classList.add("active"); $("#sideT").classList.remove("active"); - $("#pistol").style.display="none"; $("#pistolLegend").innerHTML=""; poll(); } @@ -76,48 +98,49 @@ async function poll(){ const r = await fetch("/api/status"); const s = await r.json(); $("#status").textContent = s.message || s.status; $("#failed").innerHTML = (s.failed||[]).map(f=>`✗ ${f.username}: ${f.reason}`).join("
"); - for(const res of (s.results||[])) if(!players[res.domain]) await addCard(res); + for(const res of (s.results||[])) if(!players[res.domain]) await addPlayer(res); if(s.status === "running") setTimeout(poll, 2000); } -// Lazily build the merged pistol overlay once the first player's map is known. -function ensurePistolOverlay(data){ +// Lazily build the merged pistol view once the first player's map is known. +function ensurePistolView(data){ if(pistolPlayer) return; - const cv = $("#pistolCanvas"); + const panel = document.createElement("div"); panel.className="view"; + panel.innerHTML = `
手枪局 · 全队合并 +
`; + const cv = panel.querySelector("canvas"); pistolPlayer = new ReplayPlayer(cv, {radar:data.radar, transform:data.transform, rounds:pistolRounds, side:curSide, rtype:"Pistol"}); allPlayers.push(pistolPlayer); sideTargets.push({rp:pistolPlayer, rtype:"Pistol"}); - $("#pistol").style.display="block"; + addView("pistol", "手枪局", panel); } -async function addCard(res){ +async function addPlayer(res){ const r = await fetch("/api/player/"+res.domain); const data = await r.json(); const idx = Object.keys(players).length; const color = PLAYER_COLORS[idx % PLAYER_COLORS.length]; - ensurePistolOverlay(data); + ensurePistolView(data); // merge this player's pistol rounds (tagged with their color) into the overlay for(const rd of data.rounds) if(rd.rtype==="Pistol") pistolRounds.push({...rd, color}); $("#pistolLegend").insertAdjacentHTML("beforeend", `${data.username}`); - // per-player card: stats + a single Buy-round canvas - const card = document.createElement("div"); card.className="card"; + // per-player view: stats header + one big Buy-round canvas const cs = data.combat_stats||{}; - card.innerHTML = `
${data.username} + const panel = document.createElement("div"); panel.className="view"; + panel.innerHTML = `
${data.username} K/D ${cs.kd??"-"} 持狙 ${cs.awp_rate??"-"}% - ${data.round_count} 回合
-

买局

-
`; - $("#cards").appendChild(card); - - const cv = card.querySelector('canvas[data-rt="Buy"]'); + ${data.round_count} 回合 · 买局
+ `; + const cv = panel.querySelector("canvas"); const buyPlayer = new ReplayPlayer(cv,{radar:data.radar,transform:data.transform, rounds:data.rounds, side:curSide, rtype:"Buy"}); allPlayers.push(buyPlayer); sideTargets.push({rp:buyPlayer, rtype:"Buy"}); + addView(res.domain, data.username, panel); players[res.domain] = {data, buyPlayer}; } diff --git a/server/templates/index.html b/server/templates/index.html index 70fd51b..2cbc92f 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -12,14 +12,7 @@ color:#eee;border:1px solid #333;border-radius:4px;box-sizing:border-box} button{width:100%;padding:8px;margin-top:8px;background:#3a6df0;color:#fff; border:0;border-radius:4px;cursor:pointer} - .card{background:#16162a;border:1px solid #2a2a44;border-radius:8px; - padding:12px;margin-bottom:16px} - .tabs button{width:auto;display:inline-block;margin-right:6px;background:#26264a} - .tabs button.active{background:#3a6df0} - .grid{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px} - .cell{flex:1;min-width:260px} - .cell h4{margin:4px 0;font-size:13px;color:#9ad} - canvas{width:100%;background:#1a1a2e;border-radius:4px} + canvas{background:#1a1a2e;border-radius:4px;display:block} .stat{display:inline-block;margin-right:16px;font-size:14px} #failed{color:#ff6b6b;font-size:13px;margin-top:12px} .ctrls{margin-left:auto;display:flex;align-items:center;gap:10px;font-weight:400;font-size:13px} @@ -28,13 +21,18 @@ .ctrls #timelbl{color:#9ad;font-family:monospace;min-width:96px;text-align:right} .sidetoggle button{width:46px;background:#26264a} .sidetoggle button.active{background:#3a6df0} - .section-title{margin:4px 0 8px;font-size:14px;color:#9ad;font-weight:600} - #pistol{background:#16162a;border:1px solid #2a2a44;border-radius:8px;padding:12px;margin-bottom:16px} - #pistol canvas{max-width:520px} + /* view-switcher tabs: one big canvas at a time */ + #viewtabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px} + #viewtabs button{width:auto;margin:0;padding:6px 14px;background:#26264a;font-size:14px} + #viewtabs button.active{background:#3a6df0} + .view{display:none} + .view.active{display:block} + .viewhead{font-size:14px;color:#9ad;margin-bottom:8px;min-height:20px} + .viewhead b{font-size:16px} + .view canvas{width:auto;max-width:100%;max-height:calc(100vh - 170px);margin:0 auto} .legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:8px;font-size:13px} .legend span{display:inline-flex;align-items:center;gap:6px} .legend i{width:12px;height:12px;border-radius:3px;display:inline-block} - .card .grid canvas{max-width:520px}
CS-Scout 2.0 — 对手回放分析 @@ -61,12 +59,8 @@
- -
+
+
From fc6cf0434ce20608fd793d21274b996f706cda25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 01:56:49 +0800 Subject: [PATCH 4/4] feat: grenade SVG icons, area art, playback speed, glass UI redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replay.js: white in-flight glyphs along nade arcs; smoke/molotov post-detonation area art (map_smoke/inferno) scaled to coverage radius (smoke 171.81, molotov 80.5 game-units); /icons lazy-load + cache - web_server.py + config.py: ICONS_DIR + GET /icons/ route - app.js: global playback speed control (1/2/4/8x); refreshed player color palette; stat labels (K/D, 持狙, Buy Round) - index.html: frosted-glass UI redesign, sticky header, speed toggle - radar/icons/: grenade SVG assets (smoke, flash, he, molotov, inferno, etc.) - CLAUDE.md: document /icons route and in-flight icon behavior Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 +- radar/icons/flashbang.svg | 4 + radar/icons/hegrenade.svg | 3 + radar/icons/incgrenade.svg | 3 + radar/icons/inferno.svg | 29 ++ radar/icons/map_smoke.svg | 525 +++++++++++++++++++++++++++++++++++ radar/icons/molotov.svg | 4 + radar/icons/smokegrenade.svg | 3 + server/config.py | 2 + server/static/app.js | 25 +- server/static/replay.js | 59 +++- server/templates/index.html | 155 ++++++++--- server/web_server.py | 6 + 13 files changed, 772 insertions(+), 52 deletions(-) create mode 100644 radar/icons/flashbang.svg create mode 100644 radar/icons/hegrenade.svg create mode 100644 radar/icons/incgrenade.svg create mode 100644 radar/icons/inferno.svg create mode 100644 radar/icons/map_smoke.svg create mode 100644 radar/icons/molotov.svg create mode 100644 radar/icons/smokegrenade.svg diff --git a/CLAUDE.md b/CLAUDE.md index a3f049a..4e52350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,7 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s - `GET /api/player/` — that player's `player_{domain}.json` (404 if missing). - `GET /api/results` — raw `analysis_summary.json` (no path normalization in 2.0). - `GET /output/` — serves output JSON. `GET /maps/` — serves radar from `MAPS_DIR`. +- `GET /icons/` — serves grenade icon SVGs from `ICONS_DIR` (repo-root `radar/icons/`). - `GET /` — `index.html`. ### JSON Contract @@ -177,7 +178,10 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s overlays all matching rounds on a `PLAYBACK_S` loop (`WINDOW_S` game time accelerated). **No fading trails.** Per-round `color` overrides the side color (used by the merged pistol overlay). Draws grenade in-flight arcs, landing dots, and range circles (smoke/molotov) during `[land_t, expire_t]`. - Methods: `setFilter`, `toggleRound`, `drawAt`. `static/replay_test.html` is a standalone fixture. + While a nade is airborne (`[throw_t, land_t)`) it also draws a **white SVG icon** at the arc head + (smoke/flash/he/molotov, served from `/icons/.svg`, drop-shadow for contrast); the icon + disappears on detonation (no decoy icon). Source SVGs live in repo-root `radar/icons/`. + Methods: `setFilter`, `toggleRound`, `drawAt`, `_drawNadeIcon`. `static/replay_test.html` is a standalone fixture. ### Known Data Limitations diff --git a/radar/icons/flashbang.svg b/radar/icons/flashbang.svg new file mode 100644 index 0000000..aa6c75e --- /dev/null +++ b/radar/icons/flashbang.svg @@ -0,0 +1,4 @@ + + + + diff --git a/radar/icons/hegrenade.svg b/radar/icons/hegrenade.svg new file mode 100644 index 0000000..0373a20 --- /dev/null +++ b/radar/icons/hegrenade.svg @@ -0,0 +1,3 @@ + + + diff --git a/radar/icons/incgrenade.svg b/radar/icons/incgrenade.svg new file mode 100644 index 0000000..1b64c11 --- /dev/null +++ b/radar/icons/incgrenade.svg @@ -0,0 +1,3 @@ + + + diff --git a/radar/icons/inferno.svg b/radar/icons/inferno.svg new file mode 100644 index 0000000..fc37f31 --- /dev/null +++ b/radar/icons/inferno.svg @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/radar/icons/map_smoke.svg b/radar/icons/map_smoke.svg new file mode 100644 index 0000000..d4f9eeb --- /dev/null +++ b/radar/icons/map_smoke.svg @@ -0,0 +1,525 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/radar/icons/molotov.svg b/radar/icons/molotov.svg new file mode 100644 index 0000000..f0a0365 --- /dev/null +++ b/radar/icons/molotov.svg @@ -0,0 +1,4 @@ + + + + diff --git a/radar/icons/smokegrenade.svg b/radar/icons/smokegrenade.svg new file mode 100644 index 0000000..f438432 --- /dev/null +++ b/radar/icons/smokegrenade.svg @@ -0,0 +1,3 @@ + + + diff --git a/server/config.py b/server/config.py index f5fbf2d..c6c8d52 100644 --- a/server/config.py +++ b/server/config.py @@ -11,6 +11,8 @@ # Map data MAPS_DIR = os.path.join(DATA_DIR, "maps") +# Grenade icon SVGs (repo-root radar/icons, one level above server/) +ICONS_DIR = os.path.join(BASE_DIR, "..", "radar", "icons") # Analysis parameters TICK_RATE = 64 diff --git a/server/static/app.js b/server/static/app.js index a63cb62..69cbdf2 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -2,11 +2,11 @@ const $ = s => document.querySelector(s); let players = {}; // domain -> {data, buyPlayer} // Per-player colors for the merged pistol overlay + card accents. -const PLAYER_COLORS = ["#4aa3ff","#ffc23b","#5ad469","#ff6b9d","#b48cff"]; +const PLAYER_COLORS = ["#b24987","#e8da5d","#d88945","#99c6e3","#17897e"]; // ── Global playback clock: one loop drives every canvas in lockstep ────────── const allPlayers = []; // every ReplayPlayer on the page -const clock = { elapsed: 0, playing: true, last: null }; +const clock = { elapsed: 0, playing: true, last: null, speed: 1 }; // ── Unified CT/T state: one toggle drives every canvas's side ──────────────── let curSide = "CT"; @@ -44,7 +44,7 @@ let pistolPlayer = null; function tick(ts){ if(clock.last === null) clock.last = ts; const dt = (ts - clock.last) / 1000; clock.last = ts; - if(clock.playing) clock.elapsed = (clock.elapsed + dt) % PLAYBACK_S; + if(clock.playing) clock.elapsed = (clock.elapsed + dt*clock.speed) % PLAYBACK_S; const gt = clock.elapsed / PLAYBACK_S * WINDOW_S; for(const p of allPlayers) p.drawAt(gt); const scrub = $("#scrub"), lbl = $("#timelbl"), btn = $("#playpause"); @@ -71,6 +71,14 @@ function wireControls(){ ct.onclick = () => set("CT", ct, t); t.onclick = () => set("T", t, ct); } + const speed = $("#speed"); + if(speed){ + const btns = [...speed.querySelectorAll("button")]; + for(const b of btns) b.onclick = () => { + clock.speed = +b.dataset.x; + for(const x of btns) x.classList.toggle("active", x === b); + }; + } } async function loadMaps(){ @@ -106,14 +114,13 @@ async function poll(){ function ensurePistolView(data){ if(pistolPlayer) return; const panel = document.createElement("div"); panel.className="view"; - panel.innerHTML = `
手枪局 · 全队合并 -
`; + panel.innerHTML = `
`; const cv = panel.querySelector("canvas"); pistolPlayer = new ReplayPlayer(cv, {radar:data.radar, transform:data.transform, rounds:pistolRounds, side:curSide, rtype:"Pistol"}); allPlayers.push(pistolPlayer); sideTargets.push({rp:pistolPlayer, rtype:"Pistol"}); - addView("pistol", "手枪局", panel); + addView("pistol", "手枪局(全队)", panel); } async function addPlayer(res){ @@ -131,9 +138,9 @@ async function addPlayer(res){ const cs = data.combat_stats||{}; const panel = document.createElement("div"); panel.className="view"; panel.innerHTML = `
${data.username} - K/D ${cs.kd??"-"} - 持狙 ${cs.awp_rate??"-"}% - ${data.round_count} 回合 · 买局
+ K/D ${cs.kd??"-"} + 持狙 ${cs.awp_rate??"-"}% + Buy Round ${data.round_count} `; const cv = panel.querySelector("canvas"); const buyPlayer = new ReplayPlayer(cv,{radar:data.radar,transform:data.transform, diff --git a/server/static/replay.js b/server/static/replay.js index fc61fd7..417e777 100644 --- a/server/static/replay.js +++ b/server/static/replay.js @@ -2,10 +2,25 @@ // on a 9s loop (45s game time accelerated 5x). No fading trails. const PLAYBACK_S = 10, WINDOW_S = 20; const DOT_R = 10; // player dot radius (px) -const SIDE_COLOR = { CT: "#4aa3ff", T: "#ffc23b" }; +const SIDE_COLOR = { CT: "#99c6e3", T: "#e8da5d" }; const NADE_COLOR = { smoke:"#dddddd", flash:"#fff27a", he:"#ff6b6b", molotov:"#ff8c42", decoy:"#9b8cff" }; -const NADE_R = { smoke:90, molotov:70 }; // game-units radius for range circles +const NADE_R = { smoke:171.81, molotov:80.5 }; // game-units coverage radius (molotov 70×1.15; smoke 90×1.15×1.66) +// Grenade SVG icons served from /icons (lazy-loaded, shared across instances). +// - NADE_ICON_SRC: white in-flight glyphs, drawn along the arc while airborne. +// - NADE_AREA_SRC: post-detonation area art (smoke cloud / inferno fire), drawn +// at the landing spot during [land_t, expire_t], scaled to the coverage diameter. +const NADE_ICON_SRC = { smoke:"smokegrenade.svg", flash:"flashbang.svg", + he:"hegrenade.svg", molotov:"molotov.svg" }; +const NADE_AREA_SRC = { smoke:"map_smoke.svg", molotov:"inferno.svg" }; +const NADE_ICON_H = 22; // in-flight icon height (px); width keeps SVG aspect +const _iconCache = {}; // filename -> HTMLImageElement +function _icon(file){ + if(!file) return null; + let img = _iconCache[file]; + if(!img){ img = new Image(); img.src = "/icons/" + file; _iconCache[file] = img; } + return img; +} class ReplayPlayer { constructor(canvas, opts) { @@ -48,15 +63,18 @@ class ReplayPlayer { ctx.fillRect(0,0,this.cv.width,this.cv.height); } for(const r of this._rounds()){ const col = r.color || SIDE_COLOR[this.side]; // merged views color per player - // grenades: range circles, landing, in-flight arc + // grenades: in-flight arc+glyph, then post-detonation area art / landing marker for(const n of (r.grenades||[])){ if(gt>=n.land_t && gt=n.throw_t && gt -CS-Scout 2.0 +CS-Scout -
CS-Scout 2.0 — 对手回放分析 +
+ CS-Scout 2.0 - + 0.0 / 20.0s + + +
diff --git a/server/web_server.py b/server/web_server.py index a51cd57..81a4e67 100644 --- a/server/web_server.py +++ b/server/web_server.py @@ -9,6 +9,7 @@ GET /api/results — saved summary GET /output/ — serve output JSON GET /maps/ — serve radar images + GET /icons/ — serve grenade icon SVGs GET / — web UI """ @@ -113,6 +114,11 @@ def serve_maps(filename): return send_from_directory(config.MAPS_DIR, filename) +@app.route("/icons/") +def serve_icons(filename): + return send_from_directory(config.ICONS_DIR, filename) + + # ── Background runner ───────────────────────────────────────────────────────── def _run_analysis(usernames, map_name, max_demos=10):