From 2c9e89eee21cafe5125045105bc841a53a316224 Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Tue, 19 May 2026 15:37:19 -0700 Subject: [PATCH] update renderer and web viewer to correctly cut map and show caps (#1261) --- BOT/gob.py | 1066 +++++++++++++++++++++++++++----- scripts/build_map_mode_info.py | 399 ++++++++++++ web/public/js/replay-canvas.js | 219 ++++--- 3 files changed, 1448 insertions(+), 236 deletions(-) create mode 100644 scripts/build_map_mode_info.py diff --git a/BOT/gob.py b/BOT/gob.py index 1c8d7f3..d36fee5 100644 --- a/BOT/gob.py +++ b/BOT/gob.py @@ -21,7 +21,6 @@ import os import subprocess import sys import threading -import urllib.request from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from functools import lru_cache @@ -90,11 +89,12 @@ if _env_path.exists(): CANVAS_MIN = 1024 # Minimum canvas resolution (px) CANVAS_MAX = 4096 # Maximum canvas resolution (px) MIN_OUTPUT = 1024 # Target minimum crop output size (px) -FPS = 24 # Video frames per second +FPS = 22 # Video frames per second SPEED = 4 # Playback speed multiplier (4× = 1min sim → 15s video) +MAP_PAD_PX = 100 # Expand resolved map bounds by this many pixels per side TRAIL_MS = 18_000 # Ground trail length (ms, video time) -AIR_TRAIL_MS = 4_000 # Aircraft trail length (ms, video time) -DRONE_TRAIL_MS = 2_000 # Drone trail length (ms, video time) +AIR_TRAIL_MS = 2_500 # Aircraft trail length (ms, video time) +DRONE_TRAIL_MS = 1_000 # Drone trail length (ms, video time) DOT_R = 5 # Player dot radius (px) DRONE_R = 3 # Drone dot radius (px) AIR_R = 4 # Aircraft triangle half-size (px) @@ -113,6 +113,10 @@ LOSE_COLOR = (220, 30, 30) # red MINIMAPS_DIR = SHARED_DIR / "MAPS" / "MINIMAPS" LEVELS_DIR = SHARED_DIR / "MAPS" / "LEVELS" ICONS_DIR = SHARED_DIR / "ICONS" +BOTS_DIR = SHARED_DIR.parent +WT_LEVELS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "aces.vromfs.bin_u" / "levels" +WT_MISSIONS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "mis.vromfs.bin_u" +LOCAL_MISSIONS_DIR = LEVELS_DIR / "MISSIONS" _tl = threading.local() @@ -164,7 +168,8 @@ class Sprite: h: Sprite height in pixels. w: Sprite width in pixels. """ - pm: np.ndarray # (h, w, 3) uint8 premultiplied RGB + pm: np.ndarray # (h, w, 3) uint8 premultiplied RGB + pm16: np.ndarray # (h, w, 3) uint16 premultiplied RGB (cached for blending math) ia: np.ndarray # (h, w, 1) uint16 inverse alpha, pre-cast h: int w: int @@ -172,19 +177,18 @@ class Sprite: def make_sprite(rgba: np.ndarray) -> Sprite: a = rgba[..., 3:4].astype(np.uint16) - pm = ((rgba[..., :3].astype(np.uint16) * a) >> 8).astype(np.uint8) + pm16 = (rgba[..., :3].astype(np.uint16) * a) >> 8 + pm = pm16.astype(np.uint8) ia = (255 - rgba[..., 3:4]).astype(np.uint16) - return Sprite(pm=pm, ia=ia, h=rgba.shape[0], w=rgba.shape[1]) + return Sprite(pm=pm, pm16=pm16, ia=ia, h=rgba.shape[0], w=rgba.shape[1]) def make_black_sprite(spr: Sprite) -> Sprite: """Create a black version of a sprite (RGB=0, same alpha).""" pm = np.zeros_like(spr.pm) - ia = np.full_like(spr.ia, 255, dtype=np.uint16) - # Reconstruct alpha from inverse alpha, then rebuild - alpha = 255 - spr.ia.astype(np.int16) - ia = (255 - alpha).astype(np.uint16) - return Sprite(pm=pm, ia=ia, h=spr.h, w=spr.w) + pm16 = np.zeros_like(spr.pm16) + ia = spr.ia.copy() + return Sprite(pm=pm, pm16=pm16, ia=ia, h=spr.h, w=spr.w) def make_bare_black_sprite(icon_key: str, size: int, highlight_pad: int) -> Sprite: @@ -391,7 +395,7 @@ def blit(buf: np.ndarray, spr: Sprite, x: int, y: int) -> None: return sy1, sx1 = y1 - y, x1 - x sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) - pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) + pm = spr.pm16[sy1:sy2, sx1:sx2] buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) ).astype(np.uint8) @@ -409,8 +413,8 @@ def blit_alpha(buf: np.ndarray, spr: Sprite, x: int, y: int, alpha: float) -> No sy1, sx1 = y1 - y, x1 - x sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) a16 = int(alpha * 256) - pm = ((spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) * a16) >> 8).astype(np.uint16) - ia = 255 - (((255 - spr.ia[sy1:sy2, sx1:sx2].astype(np.uint16)) * a16) >> 8) + pm = (spr.pm16[sy1:sy2, sx1:sx2] * a16) >> 8 + ia = 255 - (((255 - spr.ia[sy1:sy2, sx1:sx2]) * a16) >> 8) buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * ia) >> 8)).astype(np.uint8) @@ -425,7 +429,7 @@ def blit_batch(buf: np.ndarray, items: list[tuple[Sprite, int, int]]) -> None: continue sy1 = y1 - y; sx1 = x1 - x sy2 = sy1 + (y2 - y1); sx2 = sx1 + (x2 - x1) - pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) + pm = spr.pm16[sy1:sy2, sx1:sx2] buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) ).astype(np.uint8) @@ -519,67 +523,794 @@ class VideoCtx: dmg_target_spr: Sprite | None = None -# ── Map load ─────────────────────────────────────────────────────────────────── +# ── Map / LevelDef load ──────────────────────────────────────────────────────── -NANACHI_ASSETS = "https://thunder.nanachi.party/assets/maps" -NANACHI_API_URL = "https://thunder.nanachi.party" -NANACHI_VISUAL_TOKEN = os.getenv("NANACHI_VISUAL_TOKEN", "") +def _clean_map_key(raw: str) -> str: + key = Path(raw).name.strip().rstrip("*") + if key.endswith(".png"): + key = key[:-4] + return key -def load_map_image(level_path: str, canvas: int = CANVAS_MIN) -> Image.Image | None: - """Load tankmap PNG from local MINIMAPS, falling back to Nanachi CDN.""" - stem = Path(level_path).stem # e.g. "avg_ardennes_snow" - path = MINIMAPS_DIR / (stem + "_tankmap.png") - if not path.exists(): - url = f"{NANACHI_ASSETS}/tankmap/full/{level_path}" - print(f" Not local, downloading from Nanachi …", end=" ", flush=True) - try: - urllib.request.urlretrieve(url, path) - print("ok") - except Exception as e: - print(f"failed ({e})") - return None - return Image.open(path).convert("RGB").resize( - (canvas, canvas), Image.Resampling.LANCZOS - ) - - -# ── Coordinate transform ─────────────────────────────────────────────────────── - -def _fetch_nanachi_level_def(session_id: int) -> dict | None: - """Fallback: fetch LevelDef from Nanachi API.""" - auth = f"Bearer {NANACHI_VISUAL_TOKEN}" - url = f"{NANACHI_API_URL}/api/0/visuals/session/{session_id}/interactive" - req = urllib.request.Request(url, headers={"Authorization": auth}) +def _load_json_file(path: Path) -> dict | None: try: - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - return data.get("LevelDef") + return json.loads(path.read_text()) except Exception as e: - print(f"failed ({e})") + print(f" Failed to parse {path.name}: {e}") return None -def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: - """Load tankMapCoord0/1 from local .blkx, falling back to Nanachi API.""" - stem = Path(level_path).stem - blkx = LEVELS_DIR / (stem + ".blkx") - if blkx.exists(): +def _normalize_mission_relpath(path: str) -> str: + rel = str(path or "").strip().replace("\\", "/").lstrip("/") + if rel[:9].lower() == "gamedata/": + rel = "gamedata/" + rel[9:] + return rel.lower() + + +def _mission_path_candidates(path: str) -> list[Path]: + rel = _normalize_mission_relpath(path) + if not rel: + return [] + out: list[Path] = [] + + def add(p: Path) -> None: + if p not in out: + out.append(p) + + def add_variants(root: Path) -> None: + raw = root / rel + suffix = raw.suffix.lower() + if suffix == ".blk": + add(raw.with_suffix(".blkx")) + add(raw) + elif suffix == ".blkx": + add(raw) + add(raw.with_suffix(".blk")) + else: + add(raw) + add(raw.with_suffix(".blkx")) + add(raw.with_suffix(".blk")) + + # Prefer local mission bundle in SHARED/MAPS/LEVELS/MISSIONS. + add_variants(LOCAL_MISSIONS_DIR) + # Fallback to full datamine clone for anything not yet copied locally. + add_variants(WT_MISSIONS_DIR) + return out + + +def _load_mission_def_from_path(path: str) -> tuple[dict | None, Path | None]: + for p in _mission_path_candidates(path): + if not p.exists(): + continue + data = _load_json_file(p) + if isinstance(data, dict): + return data, p + return None, None + + +def load_mission_def(level_settings_path: str) -> tuple[dict | None, Path | None]: + """Load mission settings .blkx referenced by replay Mission.LevelSettings.""" + return _load_mission_def_from_path(level_settings_path) + + +def _mission_use_alt_map_coord(mission_def: dict | None) -> bool: + """Return whether mission settings request alternative tank map coords.""" + if not isinstance(mission_def, dict): + return False + ms = mission_def.get("mission_settings") + if not isinstance(ms, dict): + return False + mission_cfg = ms.get("mission") + if not isinstance(mission_cfg, dict): + return False + val = mission_cfg.get("useAlternativeMapCoord") + if isinstance(val, bool): + return val + per_diff = mission_cfg.get("mission") + if isinstance(per_diff, list): + for item in per_diff: + if isinstance(item, dict) and item.get("useAlternativeMapCoord") is True: + return True + return False + + +def _mission_level_override(mission_def: dict | None) -> str: + if not isinstance(mission_def, dict): + return "" + ms = mission_def.get("mission_settings") + if not isinstance(ms, dict): + return "" + mission_cfg = ms.get("mission") + if not isinstance(mission_cfg, dict): + return "" + lvl = mission_cfg.get("level", "") + return lvl if isinstance(lvl, str) else "" + + +def _import_paths(mission_def: dict) -> list[str]: + imports = mission_def.get("imports") + if not isinstance(imports, dict): + return [] + import_record = imports.get("import_record") + records = import_record if isinstance(import_record, list) else [import_record] + out: list[str] = [] + for rec in records: + if not isinstance(rec, dict): + continue + f = rec.get("file") + if isinstance(f, str) and f.strip(): + out.append(f) + return out + + +def _collect_mission_areas(root_def: dict | None, root_path: Path | None) -> dict[str, dict]: + if not isinstance(root_def, dict): + return {} + areas: dict[str, dict] = {} + queue: list[tuple[dict, Path | None]] = [(root_def, root_path)] + seen: set[Path] = set() + if root_path: + seen.add(root_path.resolve()) + + while queue: + cur_def, _ = queue.pop(0) + cur_areas = cur_def.get("areas") + if isinstance(cur_areas, dict): + for name, area in cur_areas.items(): + if isinstance(name, str) and isinstance(area, dict) and name not in areas: + areas[name] = area + + for rel in _import_paths(cur_def): + child_def, child_path = _load_mission_def_from_path(rel) + if not isinstance(child_def, dict): + continue + resolved = child_path.resolve() if child_path else None + if resolved and resolved in seen: + continue + if resolved: + seen.add(resolved) + queue.append((child_def, child_path)) + return areas + + +def _mission_battle_area_targets(mission_def: dict | None) -> list[str]: + if not isinstance(mission_def, dict): + return [] + ms = mission_def.get("mission_settings") + if not isinstance(ms, dict): + return [] + out: list[str] = [] + seen: set[str] = set() + + def add(target: str) -> None: + if target and target not in seen: + seen.add(target) + out.append(target) + + def walk(node: Any) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key == "battleArea" and isinstance(value, dict): + target = value.get("target") + if isinstance(target, str): + add(target) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(ms) + return out + + +def _battle_type_candidates(battle_type: str) -> list[str]: + bt = _clean_map_key(battle_type).lower() + if not bt: + return [] + mode = bt.split("_")[-1] + out: list[str] = [] + if mode: + out.append(f"{mode}_battle_area_arcade") + out.append(f"{mode}_battle_area_hardcore") + out.append(f"{mode}_battle_area") + if "dom" in bt: + out.append("dom_battle_area_arcade") + if "conq" in bt: + out.append("conq_battle_area_arcade") + dedup: list[str] = [] + seen: set[str] = set() + for name in out: + if name not in seen: + seen.add(name) + dedup.append(name) + return dedup + + +def _select_battle_area_name(mission_def: dict | None, + areas: dict[str, dict], + battle_type: str) -> str: + if not areas: + return "" + + for target in _mission_battle_area_targets(mission_def): + if target in areas: + return target + + for name in _battle_type_candidates(battle_type): + if name in areas: + return name + return "" + + +def _box_bounds_from_tm(area: dict) -> tuple[list[float], list[float]] | None: + if area.get("type") != "Box": + return None + tm = area.get("tm") + if not isinstance(tm, list) or len(tm) < 4: + return None + try: + axes = np.array(tm[:3], dtype=np.float64) + center = np.array(tm[3], dtype=np.float64) + except Exception: + return None + if axes.shape != (3, 3) or center.shape[0] < 3: + return None + + xs: list[float] = [] + zs: list[float] = [] + for sx in (-0.5, 0.5): + for sy in (-0.5, 0.5): + for sz in (-0.5, 0.5): + pt = center + sx * axes[0] + sy * axes[1] + sz * axes[2] + xs.append(float(pt[0])) + zs.append(float(pt[2])) + + x0, x1 = min(xs), max(xs) + z0, z1 = min(zs), max(zs) + if x1 <= x0 or z1 <= z0: + return None + return [x0, z0], [x1, z1] + + +def resolve_world_bounds(level_def: dict, + use_alt_map_coord: bool, + mission_def: dict | None, + mission_def_path: Path | None, + battle_type: str, + ) -> tuple[list[float], list[float], str]: + tc0, tc1, coord_src = select_tank_coords(level_def, use_alt_map_coord) + areas = _collect_mission_areas(mission_def, mission_def_path) + battle_name = _select_battle_area_name(mission_def, areas, battle_type) + if not battle_name: + return tc0, tc1, coord_src + + area = areas.get(battle_name) + if not isinstance(area, dict): + return tc0, tc1, coord_src + + bounds = _box_bounds_from_tm(area) + if bounds is None: + return tc0, tc1, coord_src + c0, c1 = bounds + return c0, c1, f"battleArea:{battle_name}" + + +def _capture_mode_prefixes(battle_type: str) -> list[str]: + bt = _clean_map_key(battle_type).lower() + if "conq" in bt: + return ["conq_capture_area_"] + if "dom" in bt: + return ["dom_capture_area_"] + if "bttl" in bt: + return ["bttl_t1_capture_area_", "bttl_t2_capture_area_"] + return [] + + +def _capture_sort_key(name: str) -> tuple[int, str]: + digits = "".join(ch for ch in name if ch.isdigit()) + return (int(digits) if digits else 999, name) + + +def _capture_radius_from_tm(tm: list) -> float: + try: + a = np.array(tm[0], dtype=np.float64) + b = np.array(tm[2], dtype=np.float64) + ra = float(np.hypot(a[0], a[2])) + rb = float(np.hypot(b[0], b[2])) + return max(ra, rb) + except Exception: + return 0.0 + + +def _capture_tm_vectors(tm: list) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None: + try: + a0 = np.array(tm[0], dtype=np.float64) + a1 = np.array(tm[1], dtype=np.float64) + a2 = np.array(tm[2], dtype=np.float64) + c = np.array(tm[3], dtype=np.float64) + except Exception: + return None + if a0.shape[0] < 3 or a1.shape[0] < 3 or a2.shape[0] < 3 or c.shape[0] < 3: + return None + return a0, a1, a2, c + + +def resolve_capture_areas(mission_def: dict | None, mission_def_path: Path | None, + battle_type: str) -> list[dict]: + """Resolve gameplay capture areas from mission imports (mode-specific).""" + areas = _collect_mission_areas(mission_def, mission_def_path) + if not areas: + return [] + + prefixes = _capture_mode_prefixes(battle_type) + capture_names: list[str] = [] + for name, area in areas.items(): + if not isinstance(area, dict): + continue + lname = name.lower() + if "capture_area" not in lname or lname.startswith("briefing_"): + continue + if prefixes and not any(lname.startswith(p) for p in prefixes): + continue + capture_names.append(name) + + if not capture_names: + return [] + + # Keep one variant per area base (prefer arcade, then realistic, hardcore). + suffix_prio = ["_arcade", "_realistic", "_hardcore", "_simulator", ""] + groups: dict[str, dict[str, str]] = {} + for name in capture_names: + lname = name.lower() + suffix = "" + base = lname + for sfx in suffix_prio[:-1]: + if lname.endswith(sfx): + suffix = sfx + base = lname[:-len(sfx)] + break + groups.setdefault(base, {})[suffix] = name + + chosen: list[str] = [] + for base in sorted(groups.keys(), key=_capture_sort_key): + variants = groups[base] + pick = "" + for sfx in suffix_prio: + if sfx in variants: + pick = variants[sfx] + break + if pick: + chosen.append(pick) + + out: list[dict] = [] + for name in chosen: + area = areas.get(name) + if not isinstance(area, dict): + continue + tm = area.get("tm") + if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list): + continue try: - data = json.loads(blkx.read_text()) - if "tankMapCoord0" in data and "tankMapCoord1" in data: - return data - except Exception as e: - print(f" Failed to parse level def: {e}") - # Fallback to Nanachi API - if session_id: - print("not local, trying Nanachi …", end=" ", flush=True) - ld = _fetch_nanachi_level_def(session_id) - if ld and "tankMapCoord0" in ld and "tankMapCoord1" in ld: - return ld + cx = float(tm[3][0]) + cz = float(tm[3][2]) + except Exception: + continue + tm_vecs = _capture_tm_vectors(tm) + if tm_vecs is not None: + a0, _a1, a2, center = tm_vecs + tm_data = { + "a0": [float(a0[0]), float(a0[1]), float(a0[2])], + "a2": [float(a2[0]), float(a2[1]), float(a2[2])], + "center": [float(center[0]), float(center[1]), float(center[2])], + } + else: + tm_data = None + out.append({ + "name": name, + "type": str(area.get("type", "")), + "x": cx, + "z": cz, + "radius": _capture_radius_from_tm(tm), + "tm": tm_data, + }) + # If exactly one cap is a strong outlier (>2x median sibling radius), + # halve only that cap (including tm X/Z basis so rendered outline matches). + if len(out) >= 3: + radii = sorted(float(c["radius"]) for c in out if float(c["radius"]) > 0.0) + if len(radii) >= 3: + median_r = radii[len(radii) // 2] + if median_r > 0.0: + outlier = [ + i for i, c in enumerate(out) + if float(c.get("radius", 0.0)) > (2.0 * median_r) + ] + if len(outlier) == 1: + oi = outlier[0] + out[oi]["radius"] = float(out[oi]["radius"]) * 0.5 + tm = out[oi].get("tm") + if isinstance(tm, dict): + for key in ("a0", "a2"): + vec = tm.get(key) + if isinstance(vec, list) and len(vec) >= 3: + tm[key] = [float(vec[0]) * 0.5, float(vec[1]) * 0.5, float(vec[2]) * 0.5] + return out + + +def _world_to_map_px(x: float, z: float, x0: float, z0: float, xr: float, zr: float, canvas: int) -> tuple[int, int]: + px = int(round((x - x0) / xr * canvas)) + py = int(round((z0 + zr - z) / zr * canvas)) + return px, py + + +def _cap_outline_points_px(cap: dict, x0: float, z0: float, xr: float, zr: float, canvas: int) -> list[tuple[int, int]]: + tm = cap.get("tm") + if not isinstance(tm, dict): + return [] + a0 = tm.get("a0") + a2 = tm.get("a2") + center = tm.get("center") + if not (isinstance(a0, list) and isinstance(a2, list) and isinstance(center, list)): + return [] + try: + ax0, az0 = float(a0[0]), float(a0[2]) + ax2, az2 = float(a2[0]), float(a2[2]) + cx, cz = float(center[0]), float(center[2]) + except Exception: + return [] + + cap_type = str(cap.get("type", "")).lower() + points: list[tuple[int, int]] = [] + if cap_type in {"sphere", "cylinder"}: + # Project the transformed local unit circle in XZ using tm basis vectors. + steps = 64 + for i in range(steps): + t = (2.0 * math.pi * i) / steps + wx = cx + math.cos(t) * ax0 + math.sin(t) * ax2 + wz = cz + math.cos(t) * az0 + math.sin(t) * az2 + points.append(_world_to_map_px(wx, wz, x0, z0, xr, zr, canvas)) + return points + + if cap_type == "box": + # Top-down rectangle from transformed local XZ square corners. + corners = [(-0.5, -0.5), (0.5, -0.5), (0.5, 0.5), (-0.5, 0.5)] + for sx, sz in corners: + wx = cx + sx * ax0 + sz * ax2 + wz = cz + sx * az0 + sz * az2 + points.append(_world_to_map_px(wx, wz, x0, z0, xr, zr, canvas)) + return points + + return [] + + +def _map_name_candidates(level_path: str, level_def: dict | None, + battle_type: str = "", level_settings_path: str = "") -> list[str]: + stem = Path(level_path).stem + out: list[str] = [] + seen: set[str] = set() + + def add(name: str) -> None: + if not name or name in seen: + return + seen.add(name) + out.append(name) + + if isinstance(level_def, dict): + tank_custom = level_def.get("customLevelTankMap", "") + if isinstance(tank_custom, str) and tank_custom: + add(_clean_map_key(tank_custom)) + + full_custom = level_def.get("customLevelMap", "") + if isinstance(full_custom, str) and full_custom: + full_key = _clean_map_key(full_custom) + add(full_key) + if full_key.endswith("_map"): + add(full_key[:-4] + "_tankmap") + + # Mission-specific identifiers from replay fields (mode/scenario variants). + if battle_type: + bkey = _clean_map_key(battle_type) + add(f"{bkey}_tankmap") + add(f"{bkey}_map") + add(bkey) + if level_settings_path: + mkey = _clean_map_key(Path(level_settings_path).stem) + add(f"{mkey}_tankmap") + add(f"{mkey}_map") + add(mkey) + + add(f"{stem}_tankmap") + add(stem) + return out + + +def _bounds_almost_equal(c0: list[float], c1: list[float], + r0: list[float], r1: list[float], + eps: float = 1e-6) -> bool: + return ( + abs(float(c0[0]) - float(r0[0])) <= eps + and abs(float(c0[1]) - float(r0[1])) <= eps + and abs(float(c1[0]) - float(r1[0])) <= eps + and abs(float(c1[1]) - float(r1[1])) <= eps + ) + + +def _expand_bounds_by_pixels(c0: list[float], c1: list[float], + canvas: int, pad_px: int) -> tuple[list[float], list[float]]: + if canvas <= 0 or pad_px <= 0: + return [float(c0[0]), float(c0[1])], [float(c1[0]), float(c1[1])] + + x_lo = min(float(c0[0]), float(c1[0])) + x_hi = max(float(c0[0]), float(c1[0])) + z_lo = min(float(c0[1]), float(c1[1])) + z_hi = max(float(c0[1]), float(c1[1])) + xr = x_hi - x_lo + zr = z_hi - z_lo + if xr <= 0.0 or zr <= 0.0: + return [float(c0[0]), float(c0[1])], [float(c1[0]), float(c1[1])] + + pad_x = xr * (float(pad_px) / float(canvas)) + pad_z = zr * (float(pad_px) / float(canvas)) + return [x_lo - pad_x, z_lo - pad_z], [x_hi + pad_x, z_hi + pad_z] + + +def _clamp_bounds_to_base(c0: list[float], c1: list[float], + base_c0: list[float], base_c1: list[float]) -> tuple[list[float], list[float]]: + bx_lo = min(float(base_c0[0]), float(base_c1[0])) + bx_hi = max(float(base_c0[0]), float(base_c1[0])) + bz_lo = min(float(base_c0[1]), float(base_c1[1])) + bz_hi = max(float(base_c0[1]), float(base_c1[1])) + + x_lo = min(float(c0[0]), float(c1[0])) + x_hi = max(float(c0[0]), float(c1[0])) + z_lo = min(float(c0[1]), float(c1[1])) + z_hi = max(float(c0[1]), float(c1[1])) + + x_lo = max(bx_lo, min(x_lo, bx_hi)) + x_hi = max(bx_lo, min(x_hi, bx_hi)) + z_lo = max(bz_lo, min(z_lo, bz_hi)) + z_hi = max(bz_lo, min(z_hi, bz_hi)) + + if x_hi <= x_lo: + x_lo, x_hi = bx_lo, bx_hi + if z_hi <= z_lo: + z_lo, z_hi = bz_lo, bz_hi + return [x_lo, z_lo], [x_hi, z_hi] + + +def _crop_map_to_world_bounds(img: Image.Image, + base_c0: list[float], base_c1: list[float], + render_c0: list[float], render_c1: list[float], + ) -> Image.Image: + w, h = img.size + bx0, bz0 = float(base_c0[0]), float(base_c0[1]) + bx1, bz1 = float(base_c1[0]), float(base_c1[1]) + rx0, rz0 = float(render_c0[0]), float(render_c0[1]) + rx1, rz1 = float(render_c1[0]), float(render_c1[1]) + dx = bx1 - bx0 + dz = bz1 - bz0 + if dx == 0 or dz == 0: + return img + + x_lo = min(rx0, rx1) + x_hi = max(rx0, rx1) + z_lo = min(rz0, rz1) + z_hi = max(rz0, rz1) + + u0 = (x_lo - bx0) / dx + u1 = (x_hi - bx0) / dx + v0 = (z_lo - bz0) / dz + v1 = (z_hi - bz0) / dz + + left = int(np.floor(np.clip(min(u0, u1), 0.0, 1.0) * w)) + right = int(np.ceil(np.clip(max(u0, u1), 0.0, 1.0) * w)) + top = int(np.floor((1.0 - np.clip(max(v0, v1), 0.0, 1.0)) * h)) + bottom = int(np.ceil((1.0 - np.clip(min(v0, v1), 0.0, 1.0)) * h)) + + left = max(0, min(left, w - 1)) + right = max(left + 1, min(right, w)) + top = max(0, min(top, h - 1)) + bottom = max(top + 1, min(bottom, h)) + return img.crop((left, top, right, bottom)) + + +@lru_cache(maxsize=8) +def _load_capture_icon_rgba(label: str) -> Image.Image | None: + letter = (label or "").strip().lower() + candidates: list[Path] = [] + if len(letter) == 1 and "a" <= letter <= "z": + candidates.append(ICONS_DIR / f"capture_{letter}.png") + candidates.append(ICONS_DIR / "cap_icon.png") + for path in candidates: + if not path.exists(): + continue + try: + return Image.open(path).convert("RGBA") + except Exception: + continue return None +def _draw_capture_icon(out: Image.Image, label: str, px: int, py: int, + size: int, alpha: int = 120) -> None: + base = _load_capture_icon_rgba(label) + if base is None: + return + s = max(8, int(size)) + icon = base.resize((s, s), Image.Resampling.LANCZOS) + if alpha < 255: + scale = alpha / 255.0 + a_arr = np.asarray(icon.getchannel("A"), dtype=np.float32) + a_arr = np.clip(np.round(a_arr * scale), 0.0, 255.0).astype(np.uint8) + icon.putalpha(Image.fromarray(a_arr, mode="L")) + out.paste(icon, (px - s // 2, py - s // 2), icon) + + +def _pick_contrast_outline_color(img: Image.Image, sample_points: list[tuple[int, int]]) -> tuple[int, int, int]: + w, h = img.size + if not sample_points: + return (255, 255, 255) + pixels = img.load() + if pixels is None: + return (255, 255, 255) + lumas: list[float] = [] + for x, y in sample_points: + if x < 0 or y < 0 or x >= w or y >= h: + continue + raw = pixels[x, y] + if isinstance(raw, int): + r = g = b = raw + elif isinstance(raw, tuple): + if len(raw) >= 3: + r, g, b = int(raw[0]), int(raw[1]), int(raw[2]) + elif len(raw) == 1: + r = g = b = int(raw[0]) + else: + continue + else: + continue + lumas.append(0.2126 * r + 0.7152 * g + 0.0722 * b) + if not lumas: + return (255, 255, 255) + avg = sum(lumas) / len(lumas) + return (255, 255, 255) if avg < 128.0 else (0, 0, 0) + + +def _draw_capture_areas(img: Image.Image, capture_areas: list[dict], + c0: list[float], c1: list[float]) -> Image.Image: + if not capture_areas: + return img + x0, z0 = float(c0[0]), float(c0[1]) + x1, z1 = float(c1[0]), float(c1[1]) + xr = x1 - x0 + zr = z1 - z0 + if xr == 0 or zr == 0: + return img + + out = img.copy() + draw = ImageDraw.Draw(out) + canvas = out.size[0] + labels = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + stroke_w = 3 + + for idx, cap in enumerate(capture_areas): + cx = float(cap.get("x", 0.0)) + cz = float(cap.get("z", 0.0)) + px, py = _world_to_map_px(cx, cz, x0, z0, xr, zr, canvas) + outline_points = _cap_outline_points_px(cap, x0, z0, xr, zr, canvas) + label = labels[idx] if idx < len(labels) else str(idx + 1) + icon_size = 18 + rp = 8 + if len(outline_points) >= 3: + outline = _pick_contrast_outline_color(out, outline_points) + draw.line(outline_points + [outline_points[0]], fill=outline, width=stroke_w) + # Keep icon area at ~1/4 of cap area (diamond area ~= s^2/2). + area2 = 0.0 + n_pts = len(outline_points) + for i in range(n_pts): + x0p, y0p = outline_points[i] + x1p, y1p = outline_points[(i + 1) % n_pts] + area2 += float(x0p * y1p - x1p * y0p) + cap_area = abs(area2) * 0.5 + if cap_area > 0.0: + icon_size = max(10, min(canvas, int(round(math.sqrt(cap_area / 2.0))))) + else: + rr = max(0.0, float(cap.get("radius", 0.0))) + rp = int(round(rr * ((canvas / xr + canvas / zr) * 0.5))) + rp = max(8, rp) + if px < -rp or py < -rp or px > canvas + rp or py > canvas + rp: + continue + ring_samples = [ + (px + rp, py), (px - rp, py), (px, py + rp), (px, py - rp), + (px + int(rp * 0.707), py + int(rp * 0.707)), + (px - int(rp * 0.707), py + int(rp * 0.707)), + (px + int(rp * 0.707), py - int(rp * 0.707)), + (px - int(rp * 0.707), py - int(rp * 0.707)), + ] + outline = _pick_contrast_outline_color(out, ring_samples) + draw.ellipse((px - rp, py - rp, px + rp, py + rp), outline=outline, width=stroke_w) + cap_area = math.pi * float(rp * rp) + icon_size = max(10, min(canvas, int(round(math.sqrt(cap_area / 2.0))))) + _draw_capture_icon(out, label, px, py, icon_size, alpha=90) + return out + + +def load_map_image(level_path: str, level_def: dict | None, + battle_type: str = "", level_settings_path: str = "", + base_coords: tuple[list[float], list[float]] | None = None, + render_coords: tuple[list[float], list[float]] | None = None, + canvas: int = CANVAS_MIN) -> Image.Image | None: + """Load a local minimap PNG using level-def hints for tankmap naming.""" + for map_key in _map_name_candidates(level_path, level_def, battle_type, level_settings_path): + path = MINIMAPS_DIR / f"{map_key}.png" + if path.exists(): + print(f" Map image : {path.name}") + img = Image.open(path).convert("RGB") + if ( + base_coords is not None + and render_coords is not None + and not _bounds_almost_equal( + base_coords[0], base_coords[1], + render_coords[0], render_coords[1], + ) + ): + img = _crop_map_to_world_bounds( + img, + base_coords[0], base_coords[1], + render_coords[0], render_coords[1], + ) + print(f" Map crop : world X=[{render_coords[0][0]}, {render_coords[1][0]}] " + f"Z=[{render_coords[0][1]}, {render_coords[1][1]}]") + return img.resize((canvas, canvas), Image.Resampling.LANCZOS) + return None + + +def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: + """Load tankMapCoord0/1 from local .blkx files (including datamine clone).""" + del session_id # kept in signature for call-site compatibility + stem = Path(level_path).stem + candidates = [ + LEVELS_DIR / f"{stem}.blkx", + LEVELS_DIR / "DATAMINE" / f"{stem}.blkx", + WT_LEVELS_DIR / f"{stem}.blkx", + ] + for blkx in candidates: + if not blkx.exists(): + continue + data = _load_json_file(blkx) + if not isinstance(data, dict): + continue + if "tankMapCoord0" in data and "tankMapCoord1" in data: + print(f" LevelDef : {blkx}") + return data + print(f" LevelDef missing tank coords: {blkx.name}") + return None + + +def select_tank_coords(level_def: dict, use_alt_map_coord: bool + ) -> tuple[list[float], list[float], str]: + """Pick tank coordinate bounds according to mission mode flags.""" + if use_alt_map_coord: + ac0 = level_def.get("aiTanksMapCoord0") + ac1 = level_def.get("aiTanksMapCoord1") + if ( + isinstance(ac0, list) and len(ac0) >= 2 + and isinstance(ac1, list) and len(ac1) >= 2 + ): + return ac0, ac1, "aiTanksMapCoord" + + tc0 = level_def.get("tankMapCoord0") + tc1 = level_def.get("tankMapCoord1") + if ( + isinstance(tc0, list) and len(tc0) >= 2 + and isinstance(tc1, list) and len(tc1) >= 2 + ): + return tc0, tc1, "tankMapCoord" + raise ValueError("LevelDef missing usable tank map coordinates") + + +# ── Coordinate transform ─────────────────────────────────────────────────────── + + class CoordTransform: """Transforms world coordinates (X, Z) to pixel coordinates on the canvas. @@ -1043,15 +1774,18 @@ def merge_sprites(top: Sprite, bottom: Sprite, gap: int = -2) -> Sprite: w = max(top.w, bottom.w) h = top.h + gap + bottom.h pm = np.zeros((h, w, 3), dtype=np.uint8) + pm16 = np.zeros((h, w, 3), dtype=np.uint16) ia = np.full((h, w, 1), 255, dtype=np.uint16) # top sprite pm[:top.h, :top.w] = top.pm + pm16[:top.h, :top.w] = top.pm16 ia[:top.h, :top.w] = top.ia # bottom sprite y_off = top.h + gap pm[y_off:y_off + bottom.h, :bottom.w] = bottom.pm + pm16[y_off:y_off + bottom.h, :bottom.w] = bottom.pm16 ia[y_off:y_off + bottom.h, :bottom.w] = bottom.ia - return Sprite(pm=pm, ia=ia, h=h, w=w) + return Sprite(pm=pm, pm16=pm16, ia=ia, h=h, w=w) def _short_model(model_name: str) -> str: @@ -1324,7 +2058,7 @@ def draw_icon_sprites(buf: np.ndarray, fi: int, def draw_dead_icon_sprites(buf: np.ndarray, fi: int, icon_sprites: list, dead_sprites: list, - is_dead: np.ndarray, death_fade: np.ndarray, + is_dead: np.ndarray, death_fade: np.ndarray | None, last_dead_px: np.ndarray, last_dead_py: np.ndarray, rot_caches: list | None = None, @@ -1347,9 +2081,12 @@ def draw_dead_icon_sprites(buf: np.ndarray, fi: int, spr = icon_sprites[i] dspr = dead_sprites[i] x, y = px_i - spr.w // 2, py_i - spr.h // 2 - fade = float(death_fade[i, fi]) blit(buf, dspr, x, y) - if fade > 0: + if death_fade is not None: + fade = float(death_fade[i, fi]) + else: + fade = 0.0 + if fade > 0.0: blit_alpha(buf, spr, x, y, fade) @@ -1516,7 +2253,7 @@ def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx, if ctx.player_icon_sprites: draw_dead_icon_sprites(buf, fi, ctx.player_icon_sprites, ctx.player_dead_sprites, - ctx.is_dead, ctx.death_fade, + ctx.is_dead, None, ctx.last_dead_px, ctx.last_dead_py) draw_icon_sprites(buf, fi, ctx.px_all, ctx.py_all, ctx.player_icon_sprites, is_dead=ctx.is_dead) @@ -1578,7 +2315,7 @@ def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx, if ctx.air_is_crashed is not None: draw_dead_icon_sprites(buf, fi, ctx.air_icon_sprites, ctx.air_dead_sprites, - ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type] + ctx.air_is_crashed, None, ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type] rot_caches=ctx.air_rot_caches, dead_rot_caches=ctx.air_dead_rot_caches, @@ -1764,33 +2501,48 @@ def render_gob( print(f"Duration : {duration_s:.1f}s → ~{n_frames/fps:.0f}s video ({n_frames} frames)") - # Load map coordinate bounds (local datamine, fallback to Nanachi API) - level = d["Mission"]["Level"] + # Load mode/scenario info from replay mission block + mission = d.get("Mission", {}) + level = mission.get("Level", "") + level_settings_path = mission.get("LevelSettings", "") + battle_type = mission.get("BattleType", "") + mission_def, mission_def_path = load_mission_def(level_settings_path) + use_alt_map_coord = _mission_use_alt_map_coord(mission_def) + level_override = _mission_level_override(mission_def) + if level_override: + level = level_override + if mission_def_path: + print(f"MissionDef : {mission_def_path}") + if level_settings_path: + print(f"BattleType : {battle_type or '(n/a)'}") + print(f"AltMapCoord : {'on' if use_alt_map_coord else 'off'}") + + # Load map coordinate bounds from local level defs session_id = d.get("SessionID", 0) print("Loading LevelDef …", end=" ", flush=True) level_def = load_level_coords(level, session_id) - if level_def: - tc0 = level_def["tankMapCoord0"] - tc1 = level_def["tankMapCoord1"] - print(f"ok X=[{tc0[0]}, {tc1[0]}] Z=[{tc0[1]}, {tc1[1]}]") - else: - tc0, tc1 = [0.0, 0.0], [4096.0, 4096.0] - print("not found — falling back to [0, 4096]") - - # Pick canvas size dynamically: estimate player extent in world coords - all_x = [s["X"] for p in active for s in p["_samples"]] - all_z = [s["Z"] for p in active for s in p["_samples"]] - world_span_x = max(all_x) - min(all_x) - world_span_z = max(all_z) - min(all_z) - map_range_x = tc1[0] - tc0[0] - map_range_z = tc1[1] - tc0[1] - frac = max(world_span_x / map_range_x, world_span_z / map_range_z) if map_range_x and map_range_z else 1.0 - # Scale canvas so player activity ≈ MIN_OUTPUT pixels - ideal_canvas = int(MIN_OUTPUT / max(frac, 0.1)) - canvas = max(CANVAS_MIN, min(CANVAS_MAX, ideal_canvas & ~1)) + if not level_def: + raise ValueError(f"Missing local LevelDef for level '{level}'") + canvas = CANVAS_MIN + base_tc0, base_tc1, _ = select_tank_coords(level_def, use_alt_map_coord) + tc0, tc1, coord_src = resolve_world_bounds( + level_def, + use_alt_map_coord, + mission_def, + mission_def_path, + battle_type, + ) + tc0, tc1 = _expand_bounds_by_pixels(tc0, tc1, canvas=canvas, pad_px=MAP_PAD_PX) + tc0, tc1 = _clamp_bounds_to_base(tc0, tc1, base_tc0, base_tc1) + print(f"ok ({coord_src}) X=[{tc0[0]}, {tc1[0]}] Z=[{tc0[1]}, {tc1[1]}]") + capture_areas = resolve_capture_areas(mission_def, mission_def_path, battle_type) + if capture_areas: + print("Capture : " + ", ".join( + f"{c['name']}({c['type']})" for c in capture_areas + )) xfm = CoordTransform(tc0[0], tc0[1], tc1[0], tc1[1], canvas=canvas) - print(f"Canvas : {canvas}px (players use ~{frac*100:.0f}% of map)") + print(f"Canvas : {canvas}px (full tank map)") print("Pre-computing positions …", end=" ", flush=True) px, py = precompute_positions(active, xfm, frame_times) @@ -1888,73 +2640,25 @@ def render_gob( air_is_hit = air_hit_fade = air_is_crashed = air_crash_frame = None air_last_dead_px = air_last_dead_py = None - dark = Image.new("RGB", (canvas, canvas), (18, 22, 28)) - print("Loading tank map …", end=" ", flush=True) - map_img = load_map_image(level, canvas) - print("ok" if map_img else "not found — dark background") - - bg = make_bg(map_img or dark) - - # ── Auto-crop to player activity ── - PAD = 80 - all_px_valid = px[px >= 0] - all_py_valid = py[py >= 0] - if all_px_valid.size > 0: - cx0 = max(0, int(all_px_valid.min()) - PAD) - cy0 = max(0, int(all_py_valid.min()) - PAD) - cx1 = min(canvas, int(all_px_valid.max()) + PAD) - cy1 = min(canvas, int(all_py_valid.max()) + PAD) - else: - cx0, cy0, cx1, cy1 = 0, 0, canvas, canvas - raw_w = cx1 - cx0 - raw_h = cy1 - cy0 - side = max(raw_w, raw_h, MIN_OUTPUT) - side = (side + 1) & ~1 # round up to even (libx264 requirement) - # Centre the square on the activity region, clamped to canvas - mid_x = (cx0 + cx1) // 2 - mid_y = (cy0 + cy1) // 2 - cx0 = max(0, mid_x - side // 2) - cy0 = max(0, mid_y - side // 2) - if cx0 + side > canvas: - cx0 = max(0, canvas - side) - if cy0 + side > canvas: - cy0 = max(0, canvas - side) - cx1, cy1 = cx0 + side, cy0 + side - crop_w = crop_h = side - print(f"Crop : ({cx0},{cy0}) → ({cx1},{cy1}) {crop_w}×{crop_h}px") - - # Shift all coordinates into crop-space so we draw on a smaller buffer - px -= cx0 - py -= cy0 - last_dead_px = (last_dead_px - cx0).astype(np.int16) - last_dead_py = (last_dead_py - cy0).astype(np.int16) - if px_dr is not None and py_dr is not None: - px_dr -= cx0 - py_dr -= cy0 - if drone_last_dead_px is not None and drone_last_dead_py is not None: - drone_last_dead_px = (drone_last_dead_px - cx0).astype(np.int16) - drone_last_dead_py = (drone_last_dead_py - cy0).astype(np.int16) - if px_air is not None and py_air is not None: - px_air -= cx0 - py_air -= cy0 - if air_last_dead_px is not None and air_last_dead_py is not None: - air_last_dead_px = (air_last_dead_px - cx0).astype(np.int16) - air_last_dead_py = (air_last_dead_py - cy0).astype(np.int16) - # Invalidate out-of-bounds positions - oob = (px < 0) | (px >= side) | (py < 0) | (py >= side) - px[oob] = -1; py[oob] = -1 - if px_dr is not None and py_dr is not None: - oob_dr = (px_dr < 0) | (px_dr >= side) | (py_dr < 0) | (py_dr >= side) - px_dr[oob_dr] = -1; py_dr[oob_dr] = -1 - if px_air is not None and py_air is not None: - oob_air = (px_air < 0) | (px_air >= side) | (py_air < 0) | (py_air >= side) - px_air[oob_air] = -1; py_air[oob_air] = -1 - # Crop the background - bg = bg[cy0:cy1, cx0:cx1].copy() + map_img = load_map_image( + level, + level_def, + battle_type=battle_type, + level_settings_path=level_settings_path, + base_coords=(base_tc0, base_tc1), + render_coords=(tc0, tc1), + canvas=canvas, + ) + if not map_img: + print("not found") + raise ValueError(f"No local tankmap image for level '{level}'") + map_img = _draw_capture_areas(map_img, capture_areas, tc0, tc1) + print("ok") + bg = make_bg(map_img) # Scale icons and text — reference size is 1024px (original design target) - scale = side / 1024.0 + scale = canvas / 1024.0 s_dot_r = max(2, int(DOT_R * scale + 0.5)) s_drone_r = max(1, int(DRONE_R * scale + 0.5)) s_font_sz = max(8, int(11 * scale + 0.5)) @@ -1966,7 +2670,7 @@ def render_gob( font = load_font(s_font_sz) kbf = precompute_kills(kills, xfm, t_start, ms_per_frame, n_frames, fps, font, - offset_x=cx0, offset_y=cy0) + offset_x=0, offset_y=0) dbf = precompute_damages(damages, active, px, py, t_start, ms_per_frame, n_frames, fps) name_sprs = make_name_sprites([p["Name"] for p in active], font) model_sprs = make_name_sprites([_short_model(p["_model"]) for p in active], font) @@ -2087,7 +2791,7 @@ def render_gob( drone_dy=s_drone_dy, drone_dx=s_drone_dx, air_dy=s_air_dy, air_dx=s_air_dx, dot_r=s_dot_r, drone_r=s_drone_r, air_r=s_air_r, - canvas=side, + canvas=canvas, player_icon_sprites=player_icon_sprs, player_dead_sprites=player_dead_sprs, air_icon_sprites=air_icon_sprs, @@ -2106,7 +2810,7 @@ def render_gob( render_one_ctx(fi, buf, ctx, *shared) return buf.tobytes() - ff = open_ffmpeg(out_path, fps, crop_w, crop_h) + ff = open_ffmpeg(out_path, fps, canvas, canvas) assert ff.stdin is not None fd = ff.stdin.fileno() @@ -2132,6 +2836,13 @@ def render_gob( # ── GOB loading helpers ─────────────────────────────────────────────────────── +def _decode_gob_bytes(raw: bytes) -> str: + """Decode replay byte strings while trimming fixed-width padding bytes.""" + core = raw.split(b"\x00", 1)[0] + text = core.decode("utf-8", errors="replace") + return text.rstrip("".join(chr(i) for i in range(0x00, 0x20)) + "\x7f") + + def _gob_to_dict(obj: object) -> Any: """Recursively convert pygob namedtuples to plain dicts.""" if isinstance(obj, tuple) and hasattr(obj, '_fields'): # type: ignore[union-attr] @@ -2140,11 +2851,11 @@ def _gob_to_dict(obj: object) -> Any: return [_gob_to_dict(i) for i in obj] elif isinstance(obj, dict): return { - (k.decode('utf-8', errors='replace') if isinstance(k, bytes) else k): _gob_to_dict(v) + (_decode_gob_bytes(k) if isinstance(k, bytes) else k): _gob_to_dict(v) for k, v in obj.items() } elif isinstance(obj, bytes): - return obj.decode('utf-8', errors='replace') + return _decode_gob_bytes(obj) return obj @@ -2266,12 +2977,29 @@ def export_replay_json(gob_path: Path) -> dict: players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])} team_won = d.get("TeamWon", 0) - level_path = d.get("Mission", {}).get("Level", "") + mission = d.get("Mission", {}) + level_path = mission.get("Level", "") + level_settings_path = mission.get("LevelSettings", "") + battle_type = mission.get("BattleType", "") + mission_def, mission_def_path = load_mission_def(level_settings_path) + use_alt_map_coord = _mission_use_alt_map_coord(mission_def) + level_override = _mission_level_override(mission_def) + if level_override: + level_path = level_override session_id = d.get("SessionID", 0) level_data = load_level_coords(level_path, session_id=session_id) + capture_areas = resolve_capture_areas(mission_def, mission_def_path, battle_type) if level_data: - c0 = level_data["tankMapCoord0"] - c1 = level_data["tankMapCoord1"] + c0, c1, _ = resolve_world_bounds( + level_data, + use_alt_map_coord, + mission_def, + mission_def_path, + battle_type, + ) + c0, c1 = _expand_bounds_by_pixels(c0, c1, canvas=CANVAS_MIN, pad_px=MAP_PAD_PX) + base_c0, base_c1, _ = select_tank_coords(level_data, use_alt_map_coord) + c0, c1 = _clamp_bounds_to_base(c0, c1, base_c0, base_c1) level_coords = {"x0": c0[0], "z0": c0[1], "x1": c1[0], "z1": c1[1]} else: level_coords = {"x0": 0, "z0": 0, "x1": 4096, "z1": 4096} @@ -2384,8 +3112,14 @@ def export_replay_json(gob_path: Path) -> dict: out = { "teamWon": team_won, - "mission": {"level": Path(level_path).stem if level_path else ""}, + "mission": { + "level": Path(level_path).stem if level_path else "", + "battleType": battle_type or "", + "levelSettings": level_settings_path or "", + "useAlternativeMapCoord": use_alt_map_coord, + }, "levelCoords": level_coords, + "captureAreas": capture_areas, "players": players_out, "entities": entities_out, "kills": kills_out, diff --git a/scripts/build_map_mode_info.py b/scripts/build_map_mode_info.py new file mode 100644 index 0000000..0962150 --- /dev/null +++ b/scripts/build_map_mode_info.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +""" +Build map/mode info JSON for tank missions with planes + capture zones. + +Output shape: +{ + "": { + "": [ + { + "info": { ... } + } + ] + } +} +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +BOTS_DIR = SCRIPT_DIR.parent.parent + +DATAMINE_LEVELS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "aces.vromfs.bin_u" / "levels" +DATAMINE_MISSIONS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "mis.vromfs.bin_u" +MISSION_ROOT = DATAMINE_MISSIONS_DIR / "gamedata" / "missions" / "cta" / "tanks" + +SHARED_LEVELS_DIR = BOTS_DIR / "SHARED" / "MAPS" / "LEVELS" +SHARED_MISSIONS_DIR = SHARED_LEVELS_DIR / "MISSIONS" + +SKIP_PARTS = {"mainareas", "battleareas", "spawnareas"} + + +def _clean_map_key(raw: str) -> str: + key = Path(raw).name.strip().rstrip("*") + if key.endswith(".png"): + key = key[:-4] + return key + + +def _normalize_rel(path: str) -> str: + rel = str(path or "").strip().replace("\\", "/").lstrip("/") + if rel[:9].lower() == "gamedata/": + rel = "gamedata/" + rel[9:] + return rel.lower() + + +def _load_json(path: Path, cache: dict[Path, Any]) -> Any: + if path in cache: + return cache[path] + try: + cache[path] = json.loads(path.read_text()) + except Exception: + cache[path] = None + return cache[path] + + +def _repo_rel(path: Path) -> str: + try: + return str(path.resolve().relative_to(BOTS_DIR.resolve())).replace("\\", "/") + except Exception: + return str(path) + + +def _datamine_level_path(level_path: str) -> str: + rel = str(level_path or "").replace("\\", "/").lstrip("/") + if rel.lower().startswith("levels/"): + return f"War-Thunder-Datamine/aces.vromfs.bin_u/{rel}" + return f"War-Thunder-Datamine/{rel}" if rel else rel + + +def _resolve_mission_file(rel: str) -> Path | None: + norm = _normalize_rel(rel) + if not norm: + return None + for root in (SHARED_MISSIONS_DIR, DATAMINE_MISSIONS_DIR): + raw = root / norm + if raw.suffix.lower() == ".blk": + cands = [raw.with_suffix(".blkx"), raw] + elif raw.suffix.lower() == ".blkx": + cands = [raw, raw.with_suffix(".blk")] + else: + cands = [raw, raw.with_suffix(".blkx"), raw.with_suffix(".blk")] + for p in cands: + if p.exists(): + return p + return None + + +def _import_paths(mission_def: dict) -> list[str]: + imports = mission_def.get("imports") + if not isinstance(imports, dict): + return [] + import_record = imports.get("import_record") + records = import_record if isinstance(import_record, list) else [import_record] + out: list[str] = [] + for rec in records: + if not isinstance(rec, dict): + continue + f = rec.get("file") + if isinstance(f, str) and f.strip(): + out.append(f) + return out + + +def _collect_defs(root_file: Path, cache: dict[Path, Any]) -> tuple[dict[str, dict], list[str], list[str]]: + root_def = _load_json(root_file, cache) + if not isinstance(root_def, dict): + return {}, [], [] + + areas: dict[str, dict] = {} + files_used: list[str] = [] + imports_used: list[str] = [] + queue: list[Path] = [root_file] + seen: set[Path] = set() + + while queue: + cur_file = queue.pop(0).resolve() + if cur_file in seen: + continue + seen.add(cur_file) + files_used.append(str(cur_file)) + + cur_def = _load_json(cur_file, cache) + if not isinstance(cur_def, dict): + continue + + cur_areas = cur_def.get("areas") + if isinstance(cur_areas, dict): + for name, area in cur_areas.items(): + if isinstance(name, str) and isinstance(area, dict) and name not in areas: + areas[name] = area + + for rel in _import_paths(cur_def): + imports_used.append(_normalize_rel(rel)) + p = _resolve_mission_file(rel) + if p is not None: + queue.append(p) + + return areas, files_used, sorted(set(imports_used)) + + +def _radius_from_tm(tm: list) -> float: + try: + ax = float(tm[0][0]) + az = float(tm[0][2]) + bx = float(tm[2][0]) + bz = float(tm[2][2]) + ra = (ax * ax + az * az) ** 0.5 + rb = (bx * bx + bz * bz) ** 0.5 + return max(ra, rb) + except Exception: + return 0.0 + + +def _capture_prefixes(mission_type: str, mode_key: str) -> list[str]: + m = (mission_type or "").lower() + k = (mode_key or "").lower() + if "conq" in m or "conq" in k: + return ["conq_capture_area_"] + if "dom" in m or "dom" in k: + return ["dom_capture_area_"] + if "bttl" in m or "bttl" in k: + return ["bttl_t1_capture_area_", "bttl_t2_capture_area_"] + return ["capture_area_"] + + +def _capture_sort(name: str) -> tuple[int, str]: + digits = "".join(ch for ch in name if ch.isdigit()) + return (int(digits) if digits else 999, name) + + +def _pick_capture_areas(areas: dict[str, dict], mission_type: str, mode_key: str) -> list[dict]: + prefixes = _capture_prefixes(mission_type, mode_key) + names: list[str] = [] + for name, area in areas.items(): + if not isinstance(area, dict): + continue + lname = name.lower() + if "capture_area" not in lname or lname.startswith("briefing_"): + continue + if not any(lname.startswith(p) for p in prefixes): + continue + names.append(name) + + if not names: + return [] + + suffix_prio = ["_arcade", "_realistic", "_hardcore", "_simulator", ""] + groups: dict[str, dict[str, str]] = {} + for name in names: + lname = name.lower() + suffix = "" + base = lname + for sfx in suffix_prio[:-1]: + if lname.endswith(sfx): + suffix = sfx + base = lname[:-len(sfx)] + break + groups.setdefault(base, {})[suffix] = name + + chosen: list[str] = [] + for base in sorted(groups.keys(), key=_capture_sort): + variants = groups[base] + for sfx in suffix_prio: + if sfx in variants: + chosen.append(variants[sfx]) + break + + out: list[dict] = [] + for name in chosen: + area = areas.get(name) + if not isinstance(area, dict): + continue + tm = area.get("tm") + if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list): + continue + try: + cx = float(tm[3][0]) + cz = float(tm[3][2]) + except Exception: + continue + out.append({ + "name": name, + "type": str(area.get("type", "")), + "x": cx, + "z": cz, + "radius": _radius_from_tm(tm), + }) + return out + + +def _battle_areas(areas: dict[str, dict]) -> list[dict]: + out: list[dict] = [] + for name, area in areas.items(): + if not isinstance(area, dict): + continue + lname = name.lower() + if "battle_area" not in lname or lname.startswith("briefing_"): + continue + tm = area.get("tm") + if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list): + continue + try: + cx = float(tm[3][0]) + cz = float(tm[3][2]) + except Exception: + continue + out.append({ + "name": name, + "type": str(area.get("type", "")), + "x": cx, + "z": cz, + "radius": _radius_from_tm(tm), + }) + out.sort(key=lambda x: x["name"]) + return out + + +def _level_info(level_stem: str, cache: dict[Path, Any]) -> dict: + cands = [ + DATAMINE_LEVELS_DIR / f"{level_stem}.blkx", + SHARED_LEVELS_DIR / f"{level_stem}.blkx", + SHARED_LEVELS_DIR / "DATAMINE" / f"{level_stem}.blkx", + ] + for p in cands: + if not p.exists(): + continue + data = _load_json(p, cache) + if not isinstance(data, dict): + continue + return { + "file": _repo_rel(p), + "tankMapCoord0": data.get("tankMapCoord0"), + "tankMapCoord1": data.get("tankMapCoord1"), + "aiTanksMapCoord0": data.get("aiTanksMapCoord0"), + "aiTanksMapCoord1": data.get("aiTanksMapCoord1"), + "customLevelTankMap": data.get("customLevelTankMap"), + "customLevelMap": data.get("customLevelMap"), + } + return {} + + +def _has_planes(allowed: dict | None, imports_used: list[str]) -> bool: + if isinstance(allowed, dict): + val = allowed.get("isAirplanesAllowed") + if isinstance(val, bool): + return val + return any("air_spawns" in p for p in imports_used) + + +def _has_tanks(allowed: dict | None) -> bool: + if isinstance(allowed, dict): + val = allowed.get("isTanksAllowed") + if isinstance(val, bool): + return val + return True + + +def _mode_key(mission_cfg: dict, mission_file: Path) -> str: + postfix = mission_cfg.get("postfix") + if isinstance(postfix, str) and postfix.strip(): + return postfix.lstrip("_") + mtype = mission_cfg.get("type") + if isinstance(mtype, str) and mtype.strip(): + return mtype + return mission_file.stem + + +def should_skip_mission_file(path: Path) -> bool: + if path.name.startswith("template_"): + return True + parts = {p.lower() for p in path.parts} + if SKIP_PARTS & parts: + return True + return False + + +def build_index() -> dict[str, dict[str, list[dict[str, Any]]]]: + cache: dict[Path, Any] = {} + output: dict[str, dict[str, list[dict[str, Any]]]] = {} + + for mission_file in sorted(MISSION_ROOT.rglob("*.blkx")): + if should_skip_mission_file(mission_file): + continue + + mission_def = _load_json(mission_file, cache) + if not isinstance(mission_def, dict): + continue + mission_settings = mission_def.get("mission_settings") + if not isinstance(mission_settings, dict): + continue + mission_cfg = mission_settings.get("mission") + if not isinstance(mission_cfg, dict): + continue + + level_path = mission_cfg.get("level", "") + if not isinstance(level_path, str) or not level_path.strip(): + continue + level_stem = Path(level_path).stem + mode = _mode_key(mission_cfg, mission_file) + mission_type = str(mission_cfg.get("type", "")) + allowed = mission_cfg.get("allowedUnitTypes") + + areas, files_used, imports_used = _collect_defs(mission_file, cache) + captures = _pick_capture_areas(areas, mission_type, mode) + if not captures: + continue + + has_tanks = _has_tanks(allowed if isinstance(allowed, dict) else None) + has_planes = _has_planes(allowed if isinstance(allowed, dict) else None, imports_used) + if not (has_tanks and has_planes): + continue + + battle_areas = _battle_areas(areas) + info = { + "mission_file": _repo_rel(mission_file), + "level_path": _datamine_level_path(level_path), + "mission_type": mission_type, + "mode_postfix": mission_cfg.get("postfix", ""), + "allowed_unit_types": allowed if isinstance(allowed, dict) else {}, + "useAlternativeMapCoord": mission_cfg.get("useAlternativeMapCoord"), + "capture_areas": captures, + "battle_areas": battle_areas, + "level_def": _level_info(level_stem, cache), + "imports_count": len(imports_used), + "files_used_count": len(files_used), + } + + output.setdefault(level_stem, {}).setdefault(mode, []).append({"info": info}) + + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build map_name -> mode -> [info] JSON") + parser.add_argument( + "--out", + type=Path, + default=SCRIPT_DIR.parent / "map_mode_info.json", + help="Output JSON path", + ) + args = parser.parse_args() + + data = build_index() + args.out.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + + maps = len(data) + modes = sum(len(v) for v in data.values()) + entries = sum(len(items) for v in data.values() for items in v.values()) + print(f"Wrote {args.out} (maps={maps}, modes={modes}, entries={entries})") + + +if __name__ == "__main__": + main() diff --git a/web/public/js/replay-canvas.js b/web/public/js/replay-canvas.js index d61ac0c..3396869 100644 --- a/web/public/js/replay-canvas.js +++ b/web/public/js/replay-canvas.js @@ -12,6 +12,9 @@ const RC = { WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,', DOT_R: 5, AIR_R: 4, DRONE_R: 3, }; +const RC_CAP_STROKE_PX = 3; +const RC_CAP_ICON_ALPHA = 0.35; +const RC_CAP_ICON_MIN_SIZE = 10; function replayT(key) { return (window.__t && window.__t(key)) || key; @@ -39,6 +42,7 @@ class ReplayCanvas { this._groundCoords = data.levelCoords; this._airCoords = data.mapCoords || null; this._fullMapLevel = data.fullMapLevel || null; + this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : []; this._mode = 'ground'; const hasAircraft = data.entities.some(e => e.type === 'aircraft'); this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft); @@ -47,7 +51,7 @@ class ReplayCanvas { this.z0 = data.levelCoords.z0; this.xRange = data.levelCoords.x1 - data.levelCoords.x0; this.zRange = data.levelCoords.z1 - data.levelCoords.z0; - // Default map source rect (full image) — overwritten by _computeAutocrop + // Source rect for minimap image (full image by default) this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; this.players = {}; @@ -74,9 +78,6 @@ class ReplayCanvas { }); } - // Zoom into the area players actually use (like the video maker autocrop) - this._computeAutocrop(); - // Pre-compute deaths this._computeDeaths(); this.currentTime = this.tStart; @@ -110,70 +111,6 @@ class ReplayCanvas { ]; } - /** Recompute x0/z0/xRange/zRange to zoom into entity activity. - * In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */ - _computeAutocrop() { - const origX0 = this.x0, origZ0 = this.z0; - const origXR = this.xRange, origZR = this.zRange; - - const airMode = this._mode === 'air'; - let minX = Infinity, maxX = -Infinity; - let minZ = Infinity, maxZ = -Infinity; - for (const ent of this.entities) { - if (airMode) { - if (ent.type !== 'aircraft' && ent.type !== 'drone') continue; - } else { - if (ent.type !== 'ground') continue; - } - const { positions } = ent; - for (let i = 0; i < positions.length; i += 2) { - const x = positions[i], z = positions[i + 1]; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; - } - } - if (!isFinite(minX)) return; // no relevant positions — keep full map - - // Padding: 10% of span or 50 world units, whichever is larger - const span = Math.max(maxX - minX, maxZ - minZ); - const pad = Math.max(50, span * 0.10); - minX -= pad; maxX += pad; - minZ -= pad; maxZ += pad; - - // Expand to square, with a minimum of 15% of the map range - const minSide = Math.max(origXR, origZR) * 0.15; - let side = Math.max(maxX - minX, maxZ - minZ, minSide); - const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2; - minX = midX - side / 2; maxX = midX + side / 2; - minZ = midZ - side / 2; maxZ = midZ + side / 2; - - // Clamp to LevelDef bounds (shift first, then hard-clamp) - const x1 = origX0 + origXR, z1 = origZ0 + origZR; - if (minX < origX0) { maxX += origX0 - minX; minX = origX0; } - if (maxX > x1) { minX -= maxX - x1; maxX = x1; } - if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; } - if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; } - minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1); - minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1); - - // Fractional source rect for the minimap image - // Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0) - this._mapSrc = { - u: (minX - origX0) / origXR, - v: (z1 - maxZ) / origZR, - w: (maxX - minX) / origXR, - h: (maxZ - minZ) / origZR, - }; - - // Apply new bounds — worldToPixel will now map this sub-region to the full canvas - this.x0 = minX; - this.z0 = minZ; - this.xRange = maxX - minX; - this.zRange = maxZ - minZ; - } - getPositionAtTime(entity, time) { const { times, positions } = entity; if (time < times[0] || time > times[times.length - 1]) return null; @@ -533,6 +470,140 @@ class ReplayCanvas { ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh); } + _capOutlinePoints(cap) { + const tm = cap?.tm; + if (!tm || !Array.isArray(tm.a0) || !Array.isArray(tm.a2) || !Array.isArray(tm.center)) return []; + const ax0 = Number(tm.a0[0]), az0 = Number(tm.a0[2]); + const ax2 = Number(tm.a2[0]), az2 = Number(tm.a2[2]); + const cx = Number(tm.center[0]), cz = Number(tm.center[2]); + if (!Number.isFinite(ax0) || !Number.isFinite(az0) || !Number.isFinite(ax2) || !Number.isFinite(az2) + || !Number.isFinite(cx) || !Number.isFinite(cz)) return []; + + const capType = String(cap?.type || '').toLowerCase(); + const points = []; + if (capType === 'sphere' || capType === 'cylinder') { + const steps = 64; + for (let i = 0; i < steps; i++) { + const t = (2 * Math.PI * i) / steps; + const wx = cx + Math.cos(t) * ax0 + Math.sin(t) * ax2; + const wz = cz + Math.cos(t) * az0 + Math.sin(t) * az2; + points.push(this.worldToPixel(wx, wz)); + } + return points; + } + if (capType === 'box') { + const corners = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; + for (const [sx, sz] of corners) { + const wx = cx + sx * ax0 + sz * ax2; + const wz = cz + sx * az0 + sz * az2; + points.push(this.worldToPixel(wx, wz)); + } + return points; + } + return []; + } + + _pickContrastOutlineColor(points) { + if (!points || points.length === 0) return '#fff'; + const W = this.canvasSize; + const H = this.canvasSize; + let lumaSum = 0; + let n = 0; + for (const [px, py] of points) { + const x = Math.max(0, Math.min(W - 1, Math.round(px))); + const y = Math.max(0, Math.min(H - 1, Math.round(py))); + try { + const d = this.mapCtx.getImageData(x, y, 1, 1).data; + const luma = 0.2126 * d[0] + 0.7152 * d[1] + 0.0722 * d[2]; + lumaSum += luma; + n++; + } catch (_) { + continue; + } + } + if (n === 0) return '#fff'; + return (lumaSum / n) < 128 ? '#fff' : '#000'; + } + + _capIconForLabel(label) { + const key = String(label || '').toLowerCase(); + if (!this._capIconCache) return null; + return this._capIconCache[`capture_${key}`] || this._capIconCache.cap_icon || null; + } + + _drawCaptureIcon(label, px, py, size) { + const img = this._capIconForLabel(label); + if (!img || !img.naturalWidth) return; + const s = Math.max(RC_CAP_ICON_MIN_SIZE, Math.round(size)); + this.mapCtx.save(); + this.mapCtx.globalAlpha = RC_CAP_ICON_ALPHA; + this.mapCtx.drawImage(img, px - s / 2, py - s / 2, s, s); + this.mapCtx.restore(); + } + + _drawCaptureAreasOnMap() { + if (this._mode !== 'ground') return; + if (!this.captureAreas || this.captureAreas.length === 0) return; + + const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + for (let i = 0; i < this.captureAreas.length; i++) { + const cap = this.captureAreas[i]; + const label = labels[i] || String(i + 1); + const cx = Number(cap?.x || 0); + const cz = Number(cap?.z || 0); + const [px, py] = this.worldToPixel(cx, cz); + const outlinePoints = this._capOutlinePoints(cap); + + let iconSize = 18; + if (outlinePoints.length >= 3) { + const outline = this._pickContrastOutlineColor(outlinePoints); + this.mapCtx.strokeStyle = outline; + this.mapCtx.lineWidth = RC_CAP_STROKE_PX; + this.mapCtx.beginPath(); + this.mapCtx.moveTo(outlinePoints[0][0], outlinePoints[0][1]); + for (let p = 1; p < outlinePoints.length; p++) { + this.mapCtx.lineTo(outlinePoints[p][0], outlinePoints[p][1]); + } + this.mapCtx.closePath(); + this.mapCtx.stroke(); + + let area2 = 0; + const nPts = outlinePoints.length; + for (let p = 0; p < nPts; p++) { + const a = outlinePoints[p]; + const b = outlinePoints[(p + 1) % nPts]; + area2 += a[0] * b[1] - b[0] * a[1]; + } + const capArea = Math.abs(area2) * 0.5; + if (capArea > 0) { + iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2)))); + } + } else { + const rr = Math.max(0, Number(cap?.radius || 0)); + let rp = Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5)); + rp = Math.max(8, rp); + if (px < -rp || py < -rp || px > this.canvasSize + rp || py > this.canvasSize + rp) continue; + + const ringSamples = [ + [px + rp, py], [px - rp, py], [px, py + rp], [px, py - rp], + [px + Math.round(rp * 0.707), py + Math.round(rp * 0.707)], + [px - Math.round(rp * 0.707), py + Math.round(rp * 0.707)], + [px + Math.round(rp * 0.707), py - Math.round(rp * 0.707)], + [px - Math.round(rp * 0.707), py - Math.round(rp * 0.707)], + ]; + const outline = this._pickContrastOutlineColor(ringSamples); + this.mapCtx.strokeStyle = outline; + this.mapCtx.lineWidth = RC_CAP_STROKE_PX; + this.mapCtx.beginPath(); + this.mapCtx.arc(px, py, rp, 0, Math.PI * 2); + this.mapCtx.stroke(); + const capArea = Math.PI * rp * rp; + iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2)))); + } + this._drawCaptureIcon(label, px, py, iconSize); + } + } + async _loadEntityIcons() { this._iconCache = {}; this._iconDebug = {}; @@ -587,12 +658,21 @@ class ReplayCanvas { } else { this._airMapImg = null; } + this._capIconCache = { + cap_icon: await loadImg('/api/icons/type/cap_icon'), + }; + const capLetters = new Set(); + for (let i = 0; i < this.captureAreas.length && i < 26; i++) capLetters.add(String.fromCharCode(97 + i)); + for (const letter of capLetters) { + this._capIconCache[`capture_${letter}`] = await loadImg(`/api/icons/type/capture_${letter}`); + } this._drawMapToCanvas(); } _drawMapToCanvas() { const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg; + this.mapCtx.clearRect(0, 0, this.canvasSize, this.canvasSize); if (!img) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); @@ -602,6 +682,7 @@ class ReplayCanvas { const sx = u * img.naturalWidth, sy = v * img.naturalHeight; const sw = w * img.naturalWidth, sh = h * img.naturalHeight; this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize); + this._drawCaptureAreasOnMap(); } setMode(mode) { @@ -617,8 +698,6 @@ class ReplayCanvas { this.zRange = coords.z1 - coords.z0; this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; - // Recompute autocrop for the new entity filter - this._computeAutocrop(); // Redraw map background with new crop region this._drawMapToCanvas(); // Recompute death positions in new coordinate space