3657 lines
144 KiB
Python
3657 lines
144 KiB
Python
"""
|
||
render_replay.py
|
||
|
||
Handles replay JSON files: renders MP4 videos and exports slim JSON for the
|
||
web canvas replay viewer. Output mode is picked from the output file extension.
|
||
|
||
Usage:
|
||
python -m BOT.render_replay <replay_data.json> <out.mp4> # render video
|
||
python -m BOT.render_replay <replay_data.json> <out.json> # export json
|
||
|
||
Public API:
|
||
render_gob(d, out_path, fps, speed, n_workers, progress_cb)
|
||
load_gob_file(gob_path)
|
||
export_replay_json(gob_path)
|
||
"""
|
||
|
||
# Standard Library Imports
|
||
import json
|
||
import math
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from dataclasses import dataclass, field
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Optional
|
||
|
||
# Third-Party Library Imports
|
||
import numpy as np
|
||
|
||
from . import SHARED_DIR
|
||
from .utils import REPLAYS_DIR
|
||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||
|
||
try:
|
||
from data_parser import (
|
||
LangTableReader as _LangTableReader,
|
||
WeaponTableReader as _WeaponTableReader,
|
||
apply_vehicle_name_filters as _apply_filters,
|
||
)
|
||
_lang = _LangTableReader("English")
|
||
_weapons = _WeaponTableReader("English")
|
||
def _translate_vehicle(internal: str) -> str:
|
||
name = _lang.get_translate(internal)
|
||
return _apply_filters(name) if name else internal
|
||
def _translate_weapon(internal: str) -> str:
|
||
return _weapons.get_translate(internal) or internal
|
||
except Exception:
|
||
def _translate_vehicle(internal: str) -> str: # type: ignore[misc]
|
||
return internal
|
||
def _translate_weapon(internal: str) -> str: # type: ignore[misc]
|
||
return internal
|
||
|
||
# Prune verbose suffixes from translated weapon names
|
||
_WEAPON_PRUNE = [
|
||
" air-to-ground missiles",
|
||
" air-to-ground missile",
|
||
" air-to-air missiles",
|
||
" air-to-air missile",
|
||
" anti-radiation missile",
|
||
" anti-tank guided missile",
|
||
" anti-tank guided missiles",
|
||
" guided bomb",
|
||
" guided bombs",
|
||
]
|
||
_orig_translate_weapon = _translate_weapon
|
||
def _translate_weapon(internal: str) -> str: # type: ignore[misc] # noqa: F811
|
||
name = _orig_translate_weapon(internal)
|
||
for suffix in _WEAPON_PRUNE:
|
||
if name.endswith(suffix):
|
||
name = name[:-len(suffix)]
|
||
break
|
||
return name
|
||
|
||
# Load .env from repo root
|
||
_env_path = Path(__file__).parent.parent / ".env"
|
||
if _env_path.exists():
|
||
for _line in _env_path.read_text().splitlines():
|
||
if "=" in _line and not _line.startswith("#"):
|
||
_k, _, _v = _line.partition("=")
|
||
os.environ.setdefault(_k.strip(), _v.strip())
|
||
|
||
# ── Config ─────────────────────────────────────────────────────────────────────
|
||
|
||
CANVAS_MIN = 1024 # Minimum canvas resolution (px)
|
||
CANVAS_MAX = 4096 # Maximum canvas resolution (px)
|
||
MIN_OUTPUT = 1024 # Target minimum crop output size (px)
|
||
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 = 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)
|
||
TANK_ICON_SIZE = 15 # Ground vehicle icon size (px, at 1024px reference)
|
||
AIR_ICON_SIZE = 20 # Aircraft icon size (px, at 1024px reference)
|
||
ICON_HIGHLIGHT_PAD = 3 # Highlight border padding around icon (px, at 1024px reference)
|
||
ICON_HIGHLIGHT_COLOR = (200, 200, 200, 120) # RGBA highlight backdrop color
|
||
KILL_TTL = 4_000 # Kill marker display duration (ms, video time)
|
||
GHOST_TTL = 3_000 # Death fade-to-black duration (ms, video time)
|
||
WIPE_GRACE = 90 # Extra frames rendered after a team wipe
|
||
N_WORKERS = min(8, os.cpu_count() or 4) # Thread pool size for parallel frame rendering
|
||
|
||
WIN_COLOR = (0, 200, 0) # green
|
||
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()
|
||
|
||
# ── Vehicle-type icon system ──────────────────────────────────────────────────
|
||
|
||
# Map raw unittags tags → icon key (checked in order, first match wins)
|
||
_TAG_TO_ICON = {
|
||
"type_spaa": "spaa",
|
||
"type_light_tank": "light_tank",
|
||
"type_tank_destroyer": "tank_destroyer",
|
||
"type_heavy_tank": "heavy_tank",
|
||
"type_medium_tank": "medium_tank",
|
||
"type_missile_tank": "tank_destroyer",
|
||
"type_jet_bomber": "jet_bomber",
|
||
"type_bomber": "bomber",
|
||
"type_strike_aircraft": "fighter",
|
||
"type_jet_fighter": "jet",
|
||
"type_fighter": "fighter",
|
||
"type_strike_ucav": "drone",
|
||
"type_helicopter": "helicopter",
|
||
}
|
||
|
||
# Icon key → PNG filename (relative to ICONS_DIR)
|
||
_ICON_FILES = {
|
||
"light_tank": "light.png",
|
||
"medium_tank": "medium.png",
|
||
"heavy_tank": "heavy.png",
|
||
"tank_destroyer": "tank_destroyer.png",
|
||
"spaa": "spaa.png",
|
||
"fighter": "FALLBACKS/fighter_icon.png",
|
||
"jet": "FALLBACKS/jet_icon.png",
|
||
"jet_bomber": "FALLBACKS/jet_bomber_icon.png",
|
||
"bomber": "FALLBACKS/bomber_icon.png",
|
||
"drone": "drone.png",
|
||
"helicopter": "FALLBACKS/helicopter_icon.png",
|
||
"unknown": "tank_icon.png",
|
||
}
|
||
|
||
|
||
# ── Premultiplied sprite ───────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class Sprite:
|
||
"""Premultiplied RGBA sprite for fast alpha compositing onto RGB buffers.
|
||
|
||
Attributes:
|
||
pm: Premultiplied RGB array, shape (h, w, 3), uint8.
|
||
ia: Inverse alpha array, shape (h, w, 1), uint16.
|
||
h: Sprite height in pixels.
|
||
w: Sprite width in pixels.
|
||
"""
|
||
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
|
||
|
||
|
||
def make_sprite(rgba: np.ndarray) -> Sprite:
|
||
a = rgba[..., 3:4].astype(np.uint16)
|
||
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, 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)
|
||
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:
|
||
"""Create a black icon sprite with no highlight glow — just the bare silhouette.
|
||
|
||
The sprite is sized to match the highlighted version (with padding) so it can be
|
||
used as a drop-in replacement when the highlight fades away.
|
||
"""
|
||
rgba = _load_icon_rgba(icon_key)
|
||
src_h, src_w = rgba.shape[:2]
|
||
scale_f = size / max(src_h, src_w)
|
||
new_w = max(1, int(src_w * scale_f + 0.5))
|
||
new_h = max(1, int(src_h * scale_f + 0.5))
|
||
img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||
arr = np.asarray(img).copy()
|
||
# Zero out RGB, keep alpha — black silhouette
|
||
arr[..., :3] = 0
|
||
if highlight_pad > 0:
|
||
# Embed in padded canvas to match highlighted sprite dimensions
|
||
out_h, out_w = new_h + highlight_pad * 2, new_w + highlight_pad * 2
|
||
canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8)
|
||
canvas[highlight_pad:highlight_pad + new_h, highlight_pad:highlight_pad + new_w] = arr
|
||
arr = canvas
|
||
return make_sprite(arr)
|
||
|
||
|
||
def _get_unit_tags(internal_name: str) -> list[str] | None:
|
||
"""Get raw tags for a vehicle from UnitTags, returns None if not found."""
|
||
from data_parser import UnitTags
|
||
return UnitTags.get()._get_tags(internal_name)
|
||
|
||
|
||
MINIS_DIR = SHARED_DIR / "ICONS" / "MINIS"
|
||
|
||
|
||
_AIRCRAFT_TAGS = {"air", "aircraft", "helicopter"}
|
||
|
||
|
||
def get_icon_key(model_name: str) -> str:
|
||
"""Map a ModelName (e.g. 'tankModels/ussr_t_34') to an icon key.
|
||
|
||
For aircraft/helicopters, tries a per-vehicle mini icon first
|
||
('{internal}_ico.png' in MINIS/), then falls back to tag-based.
|
||
Ground vehicles always use tag-based icons.
|
||
"""
|
||
internal = model_name.split("/")[-1]
|
||
tags = _get_unit_tags(internal)
|
||
if tags:
|
||
tag_set = set(tags)
|
||
# Only aircraft/helis get per-vehicle mini icons
|
||
if tag_set & _AIRCRAFT_TAGS:
|
||
mini_path = MINIS_DIR / f"{internal}_ico.png"
|
||
if mini_path.exists():
|
||
return f"mini:{internal}"
|
||
# Tag-based fallback (used for all ground vehicles, and aircraft without a mini)
|
||
for tag, icon in _TAG_TO_ICON.items():
|
||
if tag in tag_set:
|
||
return icon
|
||
return "unknown"
|
||
|
||
|
||
@lru_cache(maxsize=512)
|
||
def _load_icon_rgba(icon_key: str) -> np.ndarray:
|
||
"""Load an icon PNG as RGBA numpy array, cropped to content bounds. Cached."""
|
||
if icon_key.startswith("mini:"):
|
||
internal = icon_key[5:]
|
||
path = MINIS_DIR / f"{internal}_ico.png"
|
||
else:
|
||
filename = _ICON_FILES.get(icon_key, _ICON_FILES["unknown"])
|
||
path = ICONS_DIR / filename
|
||
arr = np.asarray(Image.open(path).convert("RGBA")).copy()
|
||
# Crop to bounding box of non-transparent pixels
|
||
alpha = arr[..., 3]
|
||
rows = np.any(alpha > 0, axis=1)
|
||
cols = np.any(alpha > 0, axis=0)
|
||
if rows.any() and cols.any():
|
||
y0, y1 = np.where(rows)[0][[0, -1]]
|
||
x0, x1 = np.where(cols)[0][[0, -1]]
|
||
arr = arr[y0:y1 + 1, x0:x1 + 1].copy()
|
||
return arr
|
||
|
||
|
||
def make_tinted_icon_sprite(icon_key: str, color: tuple[int, int, int],
|
||
size: int, highlight_pad: int = 0,
|
||
highlight_color: tuple[int, int, int, int] = (0, 0, 0, 0),
|
||
) -> Sprite:
|
||
"""Load an icon, resize preserving aspect ratio, tint, add outline highlight."""
|
||
rgba = _load_icon_rgba(icon_key)
|
||
src_h, src_w = rgba.shape[:2]
|
||
# Resize to fit within `size` height, preserving aspect ratio
|
||
scale_f = size / max(src_h, src_w)
|
||
new_w = max(1, int(src_w * scale_f + 0.5))
|
||
new_h = max(1, int(src_h * scale_f + 0.5))
|
||
img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||
arr = np.asarray(img).copy().astype(np.float32)
|
||
# Tint: multiply RGB by color/255, preserving alpha
|
||
arr[..., 0] *= color[0] / 255.0
|
||
arr[..., 1] *= color[1] / 255.0
|
||
arr[..., 2] *= color[2] / 255.0
|
||
tinted = np.clip(arr, 0, 255).astype(np.uint8)
|
||
if highlight_pad <= 0 or highlight_color[3] == 0:
|
||
return make_sprite(tinted)
|
||
# Build outline highlight by painting the highlight color at offsets around the icon
|
||
pad = highlight_pad
|
||
out_h, out_w = new_h + pad * 2, new_w + pad * 2
|
||
canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8)
|
||
tinted_img = Image.fromarray(tinted)
|
||
hl_layer = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0))
|
||
# Stamp the icon alpha at each offset around the center to create an outline
|
||
for dy in range(-pad, pad + 1):
|
||
for dx in range(-pad, pad + 1):
|
||
if dx * dx + dy * dy > pad * pad:
|
||
continue
|
||
hl_layer.paste(tinted_img, (pad + dx, pad + dy), tinted_img)
|
||
# Replace RGB with highlight color, keep the merged alpha capped
|
||
hl_arr = np.asarray(hl_layer).copy()
|
||
mask = hl_arr[..., 3] > 0
|
||
hl_arr[mask, 0] = highlight_color[0]
|
||
hl_arr[mask, 1] = highlight_color[1]
|
||
hl_arr[mask, 2] = highlight_color[2]
|
||
hl_arr[..., 3] = np.minimum(hl_arr[..., 3], highlight_color[3])
|
||
# Composite tinted icon on top of highlight
|
||
bg = Image.fromarray(hl_arr)
|
||
bg.paste(tinted_img, (pad, pad), tinted_img)
|
||
return make_sprite(np.asarray(bg).copy())
|
||
|
||
|
||
def load_target_sprite(filename: str, size: int) -> Sprite:
|
||
"""Load a target icon PNG, resize, return as Sprite."""
|
||
path = ICONS_DIR / filename
|
||
rgba = np.asarray(Image.open(path).convert("RGBA").resize(
|
||
(size, size), Image.Resampling.LANCZOS
|
||
)).copy()
|
||
return make_sprite(rgba)
|
||
|
||
|
||
# ── Rotated sprite cache for aircraft ─────────────────────────────────────────
|
||
|
||
ROTATION_STEPS = 72 # one sprite every 5°
|
||
|
||
def _sprite_to_rgba(spr: Sprite) -> np.ndarray:
|
||
"""Reconstruct RGBA array from a premultiplied Sprite."""
|
||
alpha = (255 - spr.ia[..., 0]).astype(np.uint8)
|
||
rgba = np.zeros((spr.h, spr.w, 4), dtype=np.uint8)
|
||
safe_a = np.maximum(alpha, 1).astype(np.float32)
|
||
rgba[..., 0] = np.clip(spr.pm[..., 0].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8)
|
||
rgba[..., 1] = np.clip(spr.pm[..., 1].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8)
|
||
rgba[..., 2] = np.clip(spr.pm[..., 2].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8)
|
||
rgba[..., 3] = alpha
|
||
return rgba
|
||
|
||
|
||
def make_rotation_cache(spr: Sprite) -> list[Sprite]:
|
||
"""Pre-compute rotated sprites at ROTATION_STEPS even angles. Index 0 = 0°, etc."""
|
||
rgba = _sprite_to_rgba(spr)
|
||
img = Image.fromarray(rgba)
|
||
cache: list[Sprite] = []
|
||
for i in range(ROTATION_STEPS):
|
||
deg = i * (360.0 / ROTATION_STEPS)
|
||
# PIL rotates CCW; we want CW rotation for heading, so negate
|
||
rot = img.rotate(-deg, resample=Image.Resampling.BILINEAR, expand=True)
|
||
cache.append(make_sprite(np.asarray(rot).copy()))
|
||
return cache
|
||
|
||
|
||
def precompute_headings(px: np.ndarray, py: np.ndarray) -> np.ndarray:
|
||
"""Compute heading angle (degrees, 0=up/north, CW) per entity per frame.
|
||
|
||
Returns (n_entities, n_frames) float32 array. -1 where invalid.
|
||
"""
|
||
n_ents, n_frames = px.shape
|
||
headings = np.full((n_ents, n_frames), -1.0, dtype=np.float32)
|
||
for i in range(n_ents):
|
||
last_heading = 0.0
|
||
for f in range(1, n_frames):
|
||
if px[i, f] < 0 or px[i, f - 1] < 0:
|
||
headings[i, f] = last_heading
|
||
continue
|
||
dx = float(px[i, f] - px[i, f - 1])
|
||
dy = float(py[i, f] - py[i, f - 1])
|
||
if abs(dx) < 0.5 and abs(dy) < 0.5:
|
||
headings[i, f] = last_heading
|
||
continue
|
||
# atan2(dx, -dy): 0=up, 90=right, 180=down, 270=left
|
||
deg = math.degrees(math.atan2(dx, -dy)) % 360
|
||
last_heading = deg
|
||
headings[i, f] = deg
|
||
# Fill frame 0 with frame 1's heading
|
||
if n_frames > 1:
|
||
headings[i, 0] = headings[i, 1]
|
||
return headings
|
||
|
||
|
||
def heading_to_rot_index(deg: float) -> int:
|
||
"""Convert a heading in degrees to a rotation cache index."""
|
||
return int(round(deg / (360.0 / ROTATION_STEPS))) % ROTATION_STEPS
|
||
|
||
|
||
def blit(buf: np.ndarray, spr: Sprite, x: int, y: int) -> None:
|
||
x1, y1 = max(x, 0), max(y, 0)
|
||
H, W = buf.shape[0], buf.shape[1]
|
||
x2, y2 = min(x + spr.w, W), min(y + spr.h, H)
|
||
if x1 >= x2 or y1 >= y2:
|
||
return
|
||
sy1, sx1 = y1 - y, x1 - x
|
||
sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1)
|
||
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)
|
||
|
||
|
||
def blit_alpha(buf: np.ndarray, spr: Sprite, x: int, y: int, alpha: float) -> None:
|
||
"""Like blit() but with an additional [0,1] alpha multiplier."""
|
||
if alpha <= 0:
|
||
return
|
||
x1, y1 = max(x, 0), max(y, 0)
|
||
H, W = buf.shape[0], buf.shape[1]
|
||
x2, y2 = min(x + spr.w, W), min(y + spr.h, H)
|
||
if x1 >= x2 or y1 >= y2:
|
||
return
|
||
sy1, sx1 = y1 - y, x1 - x
|
||
sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1)
|
||
a16 = int(alpha * 256)
|
||
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)
|
||
|
||
|
||
def blit_batch(buf: np.ndarray, items: list[tuple[Sprite, int, int]]) -> None:
|
||
"""Blit multiple sprites in one call — avoids per-call Python/numpy overhead."""
|
||
H = buf.shape[0]
|
||
W = buf.shape[1]
|
||
for spr, x, y in items:
|
||
x1 = max(x, 0); y1 = max(y, 0)
|
||
x2 = min(x + spr.w, W); y2 = min(y + spr.h, H)
|
||
if x1 >= x2 or y1 >= y2:
|
||
continue
|
||
sy1 = y1 - y; sx1 = x1 - x
|
||
sy2 = sy1 + (y2 - y1); sx2 = sx1 + (x2 - x1)
|
||
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)
|
||
|
||
|
||
# ── VideoCtx — all pre-computed data for one output video ─────────────────────
|
||
|
||
@dataclass
|
||
class VideoCtx:
|
||
"""Pre-computed rendering context for one replay video.
|
||
|
||
Holds all interpolated positions, colors, death states, kill/damage events,
|
||
label sprites, and the baked background for every frame so that
|
||
render_one_ctx can draw each frame without re-computing anything.
|
||
"""
|
||
n_players: int
|
||
px_all: np.ndarray # (n_players, n_frames) int16, -1=absent
|
||
py_all: np.ndarray
|
||
colors_arr: np.ndarray # (n_players, 3) uint8
|
||
trail_colors_arr: np.ndarray # (n_players, 3) uint8
|
||
is_dead: np.ndarray # (n_players, n_frames) bool
|
||
death_frame: np.ndarray # (n_players,) int32 — frame of death, or n_frames
|
||
death_fade: np.ndarray # (n_players, n_frames) float32 — 1→0 over ghost ttl
|
||
last_dead_px: np.ndarray # (n_players,) int16 — last known x at death
|
||
last_dead_py: np.ndarray # (n_players,) int16 — last known y at death
|
||
kills_by_frame: list # per-frame kill events
|
||
damages_by_frame: list # per-frame damage events
|
||
label_sprites: list # list[Sprite] combined name+vehicle per player
|
||
bg_arr: np.ndarray # pre-baked background RGB
|
||
end_frame: int
|
||
n_drones: int
|
||
px_drone: np.ndarray | None
|
||
py_drone: np.ndarray | None
|
||
drone_colors_arr: np.ndarray | None
|
||
drone_trail_colors: np.ndarray | None
|
||
drone_sprites: list # list[Sprite]
|
||
drone_is_hit: np.ndarray | None = None # True from kill onward (for fade)
|
||
drone_hit_fade: np.ndarray | None = None # 1→0 from kill to crash
|
||
drone_is_crashed: np.ndarray | None = None # True after path ends
|
||
drone_crash_frame: np.ndarray | None = None
|
||
drone_last_dead_px: np.ndarray | None = None
|
||
drone_last_dead_py: np.ndarray | None = None
|
||
drone_alt: np.ndarray | None = None
|
||
drone_alt_sprites: dict = field(default_factory=dict)
|
||
n_aircraft: int = 0
|
||
px_air: np.ndarray | None = None
|
||
py_air: np.ndarray | None = None
|
||
air_colors_arr: np.ndarray | None = None
|
||
air_trail_colors: np.ndarray | None = None
|
||
air_sprites: list = field(default_factory=list)
|
||
air_is_hit: np.ndarray | None = None # True from kill onward
|
||
air_hit_fade: np.ndarray | None = None # 1→0 from kill to crash
|
||
air_is_crashed: np.ndarray | None = None # True after path ends
|
||
air_crash_frame: np.ndarray | None = None
|
||
air_last_dead_px: np.ndarray | None = None
|
||
air_last_dead_py: np.ndarray | None = None
|
||
air_alt: np.ndarray | None = None
|
||
air_alt_sprites: dict = field(default_factory=dict)
|
||
air_trail_f: int = 1
|
||
drone_trail_f: int = 1
|
||
# Scaled circle masks
|
||
dot_dy: np.ndarray = field(default_factory=lambda: DOT_DY)
|
||
dot_dx: np.ndarray = field(default_factory=lambda: DOT_DX)
|
||
shadow_dy: np.ndarray = field(default_factory=lambda: SHADOW_DY)
|
||
shadow_dx: np.ndarray = field(default_factory=lambda: SHADOW_DX)
|
||
drone_dy: np.ndarray = field(default_factory=lambda: DRONE_DY)
|
||
drone_dx: np.ndarray = field(default_factory=lambda: DRONE_DX)
|
||
air_dy: np.ndarray = field(default_factory=lambda: AIR_DY)
|
||
air_dx: np.ndarray = field(default_factory=lambda: AIR_DX)
|
||
dot_r: int = DOT_R
|
||
drone_r: int = DRONE_R
|
||
air_r: int = AIR_R
|
||
canvas: int = CANVAS_MIN
|
||
# Per-player vehicle icon sprites (tinted to player color)
|
||
player_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_players
|
||
player_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions
|
||
air_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_aircraft
|
||
air_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions
|
||
# Per-aircraft rotation caches and heading angles
|
||
air_rot_caches: list = field(default_factory=list) # list[list[Sprite]], per-aircraft
|
||
air_dead_rot_caches: list = field(default_factory=list) # list[list[Sprite]], black versions
|
||
air_headings: np.ndarray | None = None # (n_aircraft, n_frames) float32
|
||
# Per-drone icon sprites + rotation
|
||
drone_icon_sprites: list = field(default_factory=list)
|
||
drone_dead_sprites: list = field(default_factory=list)
|
||
drone_rot_caches: list = field(default_factory=list)
|
||
drone_dead_rot_caches: list = field(default_factory=list)
|
||
drone_headings: np.ndarray | None = None
|
||
# Kill/damage target icons
|
||
kill_target_spr: Sprite | None = None
|
||
dmg_target_spr: Sprite | None = None
|
||
|
||
|
||
# ── Map / LevelDef load ────────────────────────────────────────────────────────
|
||
|
||
def _clean_map_key(raw: str) -> str:
|
||
key = Path(raw).name.strip().rstrip("*")
|
||
if key.endswith(".png"):
|
||
key = key[:-4]
|
||
return key
|
||
|
||
|
||
def _load_json_file(path: Path) -> dict | None:
|
||
try:
|
||
import gzip as _gzip
|
||
raw = path.read_bytes()
|
||
if path.suffix == ".gz":
|
||
raw = _gzip.decompress(raw)
|
||
return json.loads(raw)
|
||
except Exception as e:
|
||
print(f" Failed to parse {path.name}: {e}")
|
||
return None
|
||
|
||
|
||
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_realistic")
|
||
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_realistic")
|
||
out.append("dom_battle_area_arcade")
|
||
out.append("dom_battle_area_hardcore")
|
||
if "conq" in bt:
|
||
out.append("conq_battle_area_realistic")
|
||
out.append("conq_battle_area_arcade")
|
||
out.append("conq_battle_area_hardcore")
|
||
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 _battle_area_variant_priority(name: str) -> int:
|
||
ln = name.lower()
|
||
if "realistic" in ln:
|
||
return 0
|
||
if "hardcore" in ln:
|
||
return 1
|
||
if "arcade" in ln:
|
||
return 2
|
||
if "briefing" in ln or "brief_" in ln:
|
||
return 3
|
||
return 4
|
||
|
||
|
||
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 _ground_points_for_bounds_fit(ground_entities: list[dict]) -> list[tuple[float, float]]:
|
||
pts: list[tuple[float, float]] = []
|
||
for ent in ground_entities:
|
||
for sample in ent.get("Path", []):
|
||
try:
|
||
pts.append((float(sample["X"]), float(sample["Z"])))
|
||
except Exception:
|
||
continue
|
||
return pts
|
||
|
||
|
||
def _bounds_coverage(points: list[tuple[float, float]], c0: list[float], c1: list[float]) -> float:
|
||
if not points:
|
||
return 0.0
|
||
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]))
|
||
inside = 0
|
||
for x, z in points:
|
||
if x_lo <= x <= x_hi and z_lo <= z <= z_hi:
|
||
inside += 1
|
||
return inside / float(len(points))
|
||
|
||
|
||
def _fit_world_bounds_to_ground_activity(c0: list[float], c1: list[float], coord_src: str,
|
||
mission_def: dict | None, mission_def_path: Path | None,
|
||
battle_type: str, ground_entities: list[dict],
|
||
) -> tuple[list[float], list[float], str]:
|
||
pts = _ground_points_for_bounds_fit(ground_entities)
|
||
if not pts:
|
||
return c0, c1, coord_src
|
||
areas = _collect_mission_areas(mission_def, mission_def_path)
|
||
if not areas:
|
||
return c0, c1, coord_src
|
||
|
||
names: list[str] = []
|
||
seen: set[str] = set()
|
||
for name in _mission_battle_area_targets(mission_def):
|
||
if name in areas and name not in seen:
|
||
seen.add(name)
|
||
names.append(name)
|
||
for name in _battle_type_candidates(battle_type):
|
||
if name in areas and name not in seen:
|
||
seen.add(name)
|
||
names.append(name)
|
||
if not names:
|
||
return c0, c1, coord_src
|
||
|
||
candidates: list[tuple[str, list[float], list[float], float, float]] = []
|
||
for name in names:
|
||
area = areas.get(name)
|
||
if not isinstance(area, dict):
|
||
continue
|
||
bounds = _box_bounds_from_tm(area)
|
||
if bounds is None:
|
||
continue
|
||
bc0, bc1 = bounds
|
||
cov = _bounds_coverage(pts, bc0, bc1)
|
||
area_sz = abs((float(bc1[0]) - float(bc0[0])) * (float(bc1[1]) - float(bc0[1])))
|
||
candidates.append((name, bc0, bc1, cov, area_sz))
|
||
if not candidates:
|
||
return c0, c1, coord_src
|
||
|
||
cur_cov = _bounds_coverage(pts, c0, c1)
|
||
best_name, best_c0, best_c1, best_cov, _ = sorted(
|
||
candidates, key=lambda t: (-t[3], _battle_area_variant_priority(t[0]), t[4])
|
||
)[0]
|
||
# Switch when current bounds miss noticeable movement.
|
||
if best_cov > cur_cov + 0.02:
|
||
return best_c0, best_c1, f"battleArea:{best_name}|fit={best_cov:.3f}"
|
||
return c0, c1, coord_src
|
||
|
||
|
||
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:
|
||
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 = (255, 255, 255)
|
||
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 = (255, 255, 255)
|
||
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.
|
||
|
||
Args:
|
||
x0: World X origin (left edge of the map).
|
||
z0: World Z origin (bottom edge of the map).
|
||
x1: World X extent (right edge of the map).
|
||
z1: World Z extent (top edge of the map).
|
||
canvas: Canvas size in pixels (square).
|
||
"""
|
||
def __init__(self, x0: float = 0, z0: float = 0, x1: float = 4096, z1: float = 4096,
|
||
canvas: int = CANVAS_MIN):
|
||
self.x0 = x0
|
||
self.z0 = z0
|
||
self.x_range = x1 - x0
|
||
self.z_range = z1 - z0
|
||
self.canvas = canvas
|
||
|
||
def world_to_px(self, x: np.ndarray, z: np.ndarray):
|
||
"""Convert world X/Z arrays to pixel coordinates on the canvas.
|
||
|
||
Args:
|
||
x: World X positions as a numpy array.
|
||
z: World Z positions as a numpy array.
|
||
|
||
Returns:
|
||
Tuple of (px, py) int16 numpy arrays in pixel space.
|
||
"""
|
||
px = ((x - self.x0) / self.x_range * self.canvas).astype(np.int16)
|
||
py = ((self.z0 + self.z_range - z) / self.z_range * self.canvas).astype(np.int16)
|
||
return px, py
|
||
|
||
def point(self, x: float, z: float) -> tuple[int, int]:
|
||
xi, zi = self.world_to_px(np.array([x]), np.array([z]))
|
||
return int(np.clip(xi[0], 0, self.canvas - 1)), int(np.clip(zi[0], 0, self.canvas - 1))
|
||
|
||
|
||
# ── Pre-computation ────────────────────────────────────────────────────────────
|
||
|
||
def precompute_positions(players: list[dict], xfm: CoordTransform,
|
||
frame_times: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||
"""Interpolate player world positions onto the frame time grid and convert to pixels.
|
||
|
||
Args:
|
||
players: List of player dicts, each containing a '_samples' key with
|
||
time-stamped X/Z path data.
|
||
xfm: Coordinate transform for world-to-pixel conversion.
|
||
frame_times: 1-D array of frame timestamps in milliseconds.
|
||
|
||
Returns:
|
||
Tuple of (px_all, py_all) int16 arrays, shape (n_players, n_frames).
|
||
-1 indicates the player is absent at that frame.
|
||
"""
|
||
n_p, n_f = len(players), len(frame_times)
|
||
px_all = np.full((n_p, n_f), -1, dtype=np.int16)
|
||
py_all = np.full((n_p, n_f), -1, dtype=np.int16)
|
||
for i, p in enumerate(players):
|
||
s = p["_samples"]
|
||
t = np.array([x["Time"] for x in s], dtype=np.float64)
|
||
xa = np.array([x["X"] for x in s], dtype=np.float64)
|
||
za = np.array([x["Z"] for x in s], dtype=np.float64)
|
||
mask = (frame_times >= t[0]) & (frame_times <= t[-1])
|
||
if not mask.any():
|
||
continue
|
||
xi, zi = np.interp(frame_times[mask], t, xa), np.interp(frame_times[mask], t, za)
|
||
pxi, pyi = xfm.world_to_px(xi, zi)
|
||
in_bounds = (pxi >= 0) & (pxi < xfm.canvas) & (pyi >= 0) & (pyi < xfm.canvas)
|
||
full_mask = np.where(mask)[0][in_bounds]
|
||
px_all[i, full_mask] = pxi[in_bounds]
|
||
py_all[i, full_mask] = pyi[in_bounds]
|
||
return px_all, py_all
|
||
|
||
|
||
def precompute_altitudes(players: list[dict], frame_times: np.ndarray) -> np.ndarray:
|
||
"""Return (n_players, n_frames) int16 array of altitude in metres. -1 = absent."""
|
||
n_p, n_f = len(players), len(frame_times)
|
||
alt_all = np.full((n_p, n_f), -1, dtype=np.int16)
|
||
for i, p in enumerate(players):
|
||
s = p["_samples"]
|
||
t = np.array([x["Time"] for x in s], dtype=np.float64)
|
||
ya = np.array([x["Y"] for x in s], dtype=np.float64)
|
||
mask = (frame_times >= t[0]) & (frame_times <= t[-1])
|
||
if not mask.any():
|
||
continue
|
||
yi = np.interp(frame_times[mask], t, ya)
|
||
alt_all[i, mask] = np.clip(yi, 0, 32767).astype(np.int16)
|
||
return alt_all
|
||
|
||
|
||
def precompute_kills(kills: list[dict], xfm: CoordTransform,
|
||
t_start: float, ms_per_frame: float,
|
||
n_frames: int, fps: int,
|
||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
||
offset_x: int = 0, offset_y: int = 0,
|
||
pid_pos: dict[int, tuple[np.ndarray, np.ndarray]] | None = None,
|
||
) -> list[list[tuple]]:
|
||
"""
|
||
Returns per-frame list of tuples:
|
||
(vx, vy, kx, ky, age_frac, label_sprite | None)
|
||
label_sprite is shown for the first half of the kill TTL then fades out.
|
||
offset_x/y: crop origin to shift coordinates into crop-space.
|
||
pid_pos: optional {PlayerID: (px_arr, py_arr)} for tracking moving entities.
|
||
When provided, kill lines follow the entity's interpolated position
|
||
each frame instead of using the static kill snapshot position.
|
||
"""
|
||
out: list[list[tuple]] = [[] for _ in range(n_frames)]
|
||
kill_f = int(math.ceil(KILL_TTL * fps / 1000.0))
|
||
for k in kills:
|
||
vp = k.get("VictimPosition")
|
||
if not vp:
|
||
continue
|
||
kf = int((k["Time"] - t_start) / ms_per_frame)
|
||
if not (0 <= kf < n_frames):
|
||
continue
|
||
# Static fallback positions from the kill event
|
||
svx, svy = xfm.point(vp["X"], vp["Z"])
|
||
svx -= offset_x; svy -= offset_y
|
||
kp = k.get("KillerPosition")
|
||
if kp:
|
||
skx, sky = xfm.point(kp["X"], kp["Z"])
|
||
skx -= offset_x; sky -= offset_y
|
||
else:
|
||
skx, sky = -1, -1
|
||
# Look up tracked position arrays for killer/victim
|
||
kid = k.get("KillerID", 0)
|
||
vid = k.get("VictimID", 0)
|
||
k_tracked = pid_pos.get(kid) if pid_pos and kid else None
|
||
v_tracked = pid_pos.get(vid) if pid_pos and vid else None
|
||
killer_model = k.get("KillerModel", "")
|
||
weapon = k.get("Weapon", "")
|
||
label = make_kill_label(killer_model, weapon, font) if killer_model else None
|
||
for df in range(kill_f):
|
||
f = kf + df
|
||
if f >= n_frames:
|
||
break
|
||
# Use tracked position if available and valid at this frame
|
||
if v_tracked is not None and int(v_tracked[0][f]) >= 0:
|
||
vx, vy = int(v_tracked[0][f]), int(v_tracked[1][f])
|
||
else:
|
||
vx, vy = svx, svy
|
||
if k_tracked is not None and int(k_tracked[0][f]) >= 0:
|
||
kx, ky = int(k_tracked[0][f]), int(k_tracked[1][f])
|
||
else:
|
||
kx, ky = skx, sky
|
||
out[f].append((vx, vy, kx, ky, df / kill_f, label))
|
||
return out
|
||
|
||
|
||
DMG_TTL = 2_000 # Damage line display duration (ms, video time)
|
||
|
||
|
||
def precompute_damages(damages: list[dict], active: list[dict],
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
t_start: float, ms_per_frame: float,
|
||
n_frames: int, fps: int,
|
||
) -> list[list[tuple]]:
|
||
"""Build per-frame damage line events from raw damage reports.
|
||
|
||
Each tuple is (ox, oy, vx, vy, age_frac) where positions are looked
|
||
up from px_all/py_all at the damage time.
|
||
|
||
Args:
|
||
damages: Raw damage report dicts from the replay.
|
||
active: Active player dicts (used for PlayerID-to-index mapping).
|
||
px_all: Precomputed pixel X positions, shape (n_players, n_frames).
|
||
py_all: Precomputed pixel Y positions, shape (n_players, n_frames).
|
||
t_start: Replay start time in milliseconds.
|
||
ms_per_frame: Milliseconds per video frame.
|
||
n_frames: Total number of video frames.
|
||
fps: Frames per second.
|
||
|
||
Returns:
|
||
List of lists, one per frame, containing damage line tuples.
|
||
"""
|
||
pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)}
|
||
out: list[list[tuple]] = [[] for _ in range(n_frames)]
|
||
dmg_f = int(math.ceil(DMG_TTL * fps / 1000.0))
|
||
for dr in damages:
|
||
off_id = dr.get("OffenderID", 0)
|
||
vic_id = dr.get("OffendedID", 0)
|
||
if off_id not in pid_to_idx or vic_id not in pid_to_idx:
|
||
continue
|
||
oi = pid_to_idx[off_id]
|
||
vi = pid_to_idx[vic_id]
|
||
kf = int((dr["Time"] - t_start) / ms_per_frame)
|
||
if not (0 <= kf < n_frames):
|
||
continue
|
||
ox, oy = int(px_all[oi, kf]), int(py_all[oi, kf])
|
||
vx, vy = int(px_all[vi, kf]), int(py_all[vi, kf])
|
||
if ox < 0 or oy < 0 or vx < 0 or vy < 0:
|
||
continue
|
||
for df in range(dmg_f):
|
||
f = kf + df
|
||
if f >= n_frames:
|
||
break
|
||
out[f].append((ox, oy, vx, vy, df / dmg_f))
|
||
return out
|
||
|
||
|
||
def precompute_deaths(active: list[dict], kills: list[dict],
|
||
t_start: float, ms_per_frame: float,
|
||
n_frames: int, fps: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||
"""Compute per-player death state, fade-to-black curve, and last known position.
|
||
|
||
Args:
|
||
active: Active ground player dicts.
|
||
kills: Kill event dicts from the replay.
|
||
t_start: Replay start time in ms.
|
||
ms_per_frame: Milliseconds per video frame.
|
||
n_frames: Total video frames.
|
||
fps: Frames per second.
|
||
px_all: Precomputed pixel X positions.
|
||
py_all: Precomputed pixel Y positions.
|
||
|
||
Returns:
|
||
Tuple of (is_dead, death_frame, death_fade, last_dead_px, last_dead_py).
|
||
"""
|
||
pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)}
|
||
n_p = len(active)
|
||
ghost_f = max(1, int(GHOST_TTL * fps / 1000.0))
|
||
is_dead = np.zeros((n_p, n_frames), dtype=bool)
|
||
death_frame = np.full(n_p, n_frames, dtype=np.int32)
|
||
death_fade = np.zeros((n_p, n_frames), dtype=np.float32)
|
||
last_dead_px = np.full(n_p, -1, dtype=np.int16)
|
||
last_dead_py = np.full(n_p, -1, dtype=np.int16)
|
||
for k in kills:
|
||
vid = k.get("VictimID", 0)
|
||
if vid not in pid_to_idx:
|
||
continue
|
||
idx = pid_to_idx[vid]
|
||
kf = int((k["Time"] - t_start) / ms_per_frame)
|
||
if not (0 <= kf < n_frames):
|
||
continue
|
||
is_dead[idx, kf + 1:] = True
|
||
death_frame[idx] = kf
|
||
# Find last valid position at or before death
|
||
for f in range(kf, -1, -1):
|
||
if px_all[idx, f] >= 0:
|
||
last_dead_px[idx] = px_all[idx, f]
|
||
last_dead_py[idx] = py_all[idx, f]
|
||
break
|
||
# Fade from 1.0 (full color) to 0.0 (black) over ghost_f video frames
|
||
fade_len = min(ghost_f, n_frames - kf - 1)
|
||
death_fade[idx, kf + 1:kf + 1 + fade_len] = np.maximum(
|
||
0.0, 1.0 - np.arange(1, fade_len + 1) / ghost_f
|
||
)
|
||
# After fade completes, stays 0.0 (black)
|
||
return is_dead, death_frame, death_fade, last_dead_px, last_dead_py
|
||
|
||
|
||
def precompute_air_deaths(active: list[dict], kills: list[dict],
|
||
t_start: float, ms_per_frame: float,
|
||
n_frames: int, fps: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||
"""Death state for aircraft/drones: they keep moving after kill until path ends (crash).
|
||
|
||
Returns:
|
||
is_hit: (n, n_frames) bool — True from kill onward (for color fade)
|
||
hit_fade: (n, n_frames) float32 — 1→0 from kill to crash
|
||
is_crashed: (n, n_frames) bool — True only after path data ends
|
||
crash_frame: (n,) int32 — frame when path ends
|
||
last_px/py: (n,) int16 — position at crash (last valid path point)
|
||
"""
|
||
pid_to_idx: dict[int, int] = {}
|
||
eidx_to_idx: dict[int, int] = {}
|
||
for i, p in enumerate(active):
|
||
pid = p.get("PlayerID", 0)
|
||
if pid:
|
||
pid_to_idx[pid] = i
|
||
eidx = p.get("EntityIndex", 0)
|
||
if eidx:
|
||
eidx_to_idx[eidx] = i
|
||
n_p = len(active)
|
||
is_hit = np.zeros((n_p, n_frames), dtype=bool)
|
||
hit_fade = np.ones((n_p, n_frames), dtype=np.float32)
|
||
is_crashed = np.zeros((n_p, n_frames), dtype=bool)
|
||
crash_frame = np.full(n_p, n_frames, dtype=np.int32)
|
||
last_px = np.full(n_p, -1, dtype=np.int16)
|
||
last_py = np.full(n_p, -1, dtype=np.int16)
|
||
|
||
# Find last valid position frame for each entity
|
||
for i in range(n_p):
|
||
for f in range(n_frames - 1, -1, -1):
|
||
if px_all[i, f] >= 0:
|
||
crash_frame[i] = f
|
||
last_px[i] = px_all[i, f]
|
||
last_py[i] = py_all[i, f]
|
||
break
|
||
# Mark crashed after last valid frame
|
||
cf = int(crash_frame[i])
|
||
if cf < n_frames - 1:
|
||
is_crashed[i, cf + 1:] = True
|
||
|
||
ghost_f = max(1, int(GHOST_TTL * fps / 1000.0))
|
||
hit_set: set[int] = set()
|
||
|
||
for k in kills:
|
||
# Match by PlayerID first, then by EntityIndex (for drones with PlayerID=0)
|
||
vid = k.get("VictimID", 0)
|
||
idx = pid_to_idx.get(vid)
|
||
if idx is None:
|
||
veidx = k.get("VictimEntityIndex", 0)
|
||
idx = eidx_to_idx.get(veidx)
|
||
if idx is None:
|
||
continue
|
||
kf = int((k["Time"] - t_start) / ms_per_frame)
|
||
if not (0 <= kf < n_frames):
|
||
continue
|
||
hit_set.add(idx)
|
||
is_hit[idx, kf:] = True
|
||
# Fade from 1.0 → 0.0 between kill frame and crash frame
|
||
cf = int(crash_frame[idx])
|
||
fade_len = cf - kf
|
||
if fade_len > 0:
|
||
n_slots = min(fade_len + 1, n_frames - kf)
|
||
hit_fade[idx, kf:kf + n_slots] = np.maximum(
|
||
0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len
|
||
)
|
||
hit_fade[idx, cf + 1:] = 0.0
|
||
|
||
# For entities not matched by any kill (e.g. drones with PlayerID=0),
|
||
# fade to black over GHOST_TTL before their path ends (crash)
|
||
for i in range(n_p):
|
||
if i in hit_set:
|
||
continue
|
||
cf = int(crash_frame[i])
|
||
if cf >= n_frames:
|
||
continue # path never ends in this clip
|
||
fade_start = max(0, cf - ghost_f)
|
||
fade_len = cf - fade_start
|
||
if fade_len > 0:
|
||
is_hit[i, fade_start:] = True
|
||
n_slots = min(fade_len + 1, n_frames - fade_start)
|
||
hit_fade[i, fade_start:fade_start + n_slots] = np.maximum(
|
||
0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len
|
||
)
|
||
hit_fade[i, cf + 1:] = 0.0
|
||
|
||
return is_hit, hit_fade, is_crashed, crash_frame, last_px, last_py
|
||
|
||
|
||
def find_team_wipe_frame(active: list[dict], is_dead: np.ndarray, n_frames: int) -> int:
|
||
"""Find the first frame where all players on any one team are dead.
|
||
|
||
Args:
|
||
active: Active player dicts (must contain a 'Team' key).
|
||
is_dead: Boolean array, shape (n_players, n_frames).
|
||
n_frames: Total video frames.
|
||
|
||
Returns:
|
||
Frame index of the first team wipe, or n_frames if none occurs.
|
||
"""
|
||
team_groups: dict[int, list[int]] = {}
|
||
for i, p in enumerate(active):
|
||
team_groups.setdefault(p["Team"], []).append(i)
|
||
for f in range(n_frames):
|
||
for idxs in team_groups.values():
|
||
if idxs and all(is_dead[idx, f] for idx in idxs):
|
||
return f
|
||
return n_frames
|
||
|
||
|
||
def build_drone_list(d: dict, ground_active: list[dict], xfm: CoordTransform,
|
||
px_ground: np.ndarray, py_ground: np.ndarray,
|
||
frame_times: np.ndarray, t_start: float,
|
||
ms_per_frame: float, colors_arr: np.ndarray) -> list[dict]:
|
||
n_frames = len(frame_times)
|
||
drones = []
|
||
for e in d["Entities"]:
|
||
if "ucav" not in e["ModelName"].lower() or not e["Path"]:
|
||
continue
|
||
spawn_t = e["Path"][0]["Time"]
|
||
spawn_f = max(0, min(int((spawn_t - t_start) / ms_per_frame), n_frames - 1))
|
||
spx, spy = xfm.point(e["Path"][0]["X"], e["Path"][0]["Z"])
|
||
best_idx, best_dist = 0, float("inf")
|
||
for i in range(len(ground_active)):
|
||
pxi, pyi = int(px_ground[i, spawn_f]), int(py_ground[i, spawn_f])
|
||
if pxi < 0:
|
||
continue
|
||
dist = (pxi - spx) ** 2 + (pyi - spy) ** 2
|
||
if dist < best_dist:
|
||
best_dist, best_idx = dist, i
|
||
color = (colors_arr[best_idx].astype(np.float32) * 0.75).astype(np.uint8)
|
||
drones.append({"entity": e, "color": color,
|
||
"_samples": e["Path"], "EntityIndex": e["EntityIndex"]})
|
||
return drones
|
||
|
||
|
||
# ── Circle masks ───────────────────────────────────────────────────────────────
|
||
|
||
def make_circle_masks(r: int) -> tuple[np.ndarray, np.ndarray]:
|
||
y, x = np.ogrid[-r:r+1, -r:r+1]
|
||
mask = x*x + y*y <= r*r
|
||
ys, xs = np.where(mask)
|
||
return (ys - r).astype(np.int32), (xs - r).astype(np.int32)
|
||
|
||
|
||
SHADOW_DY, SHADOW_DX = make_circle_masks(DOT_R + 1)
|
||
DOT_DY, DOT_DX = make_circle_masks(DOT_R)
|
||
DRONE_DY, DRONE_DX = make_circle_masks(DRONE_R)
|
||
|
||
|
||
def make_triangle_masks(r: int) -> tuple[np.ndarray, np.ndarray]:
|
||
"""Downward-pointing triangle of half-size r. Returns (dy, dx) offsets."""
|
||
pts = []
|
||
for y in range(-r, r + 1):
|
||
# width narrows linearly from full at top (-r) to point at bottom (+r)
|
||
half_w = int(r * (r - y) / (2 * r)) if r else 0
|
||
for x in range(-half_w, half_w + 1):
|
||
pts.append((y, x))
|
||
if not pts:
|
||
pts = [(0, 0)]
|
||
arr = np.array(pts, dtype=np.int32)
|
||
return arr[:, 0], arr[:, 1]
|
||
|
||
|
||
AIR_DY, AIR_DX = make_triangle_masks(AIR_R)
|
||
|
||
_TRAIL_DY = np.array([0, 0, 1, 1], dtype=np.int32)
|
||
_TRAIL_DX = np.array([0, 1, 0, 1], dtype=np.int32)
|
||
|
||
|
||
# ── Sprite factories ───────────────────────────────────────────────────────────
|
||
|
||
def _render_rgba(w: float, h: float, draw_fn) -> np.ndarray:
|
||
img = Image.new("RGBA", (int(w), int(h)), (0, 0, 0, 0))
|
||
draw_fn(ImageDraw.Draw(img))
|
||
return np.asarray(img).copy()
|
||
|
||
|
||
def make_name_sprites(names: list[str],
|
||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> list[Sprite]:
|
||
"""Render a list of text strings into premultiplied Sprite objects.
|
||
|
||
Args:
|
||
names: Text labels to render.
|
||
font: PIL font used for rendering.
|
||
|
||
Returns:
|
||
List of Sprite objects, one per name.
|
||
"""
|
||
dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||
PAD = 3 # padding each side (covers stroke_width=1 + margin)
|
||
out = []
|
||
for name in names:
|
||
bb = dummy.textbbox((0, 0), name, font=font)
|
||
w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2
|
||
def _draw(d, _n=name, _x=bb[0], _y=bb[1]):
|
||
d.text((PAD - _x, PAD - _y), _n, font=font,
|
||
fill=(255, 255, 255, 220),
|
||
stroke_width=1, stroke_fill=(0, 0, 0, 200))
|
||
out.append(make_sprite(_render_rgba(w, h, _draw)))
|
||
return out
|
||
|
||
|
||
def merge_sprites(top: Sprite, bottom: Sprite, gap: int = -2) -> Sprite:
|
||
"""Stack two sprites vertically with a gap, return a single combined 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, pm16=pm16, ia=ia, h=h, w=w)
|
||
|
||
|
||
def _short_model(model_name: str) -> str:
|
||
"""Return human-readable vehicle name, falling back to internal ID."""
|
||
internal = model_name.split("/")[-1]
|
||
return _translate_vehicle(internal)
|
||
|
||
|
||
def make_kill_label(killer_model: str, weapon: str,
|
||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite:
|
||
"""Render a kill event label showing the killer's vehicle and weapon.
|
||
|
||
Args:
|
||
killer_model: Internal model path of the killer vehicle.
|
||
weapon: Internal weapon identifier.
|
||
font: PIL font used for rendering.
|
||
|
||
Returns:
|
||
A Sprite containing the rendered kill label text.
|
||
"""
|
||
text = f"{_short_model(killer_model)} [{_translate_weapon(weapon)}]"
|
||
dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||
bb = dummy.textbbox((0, 0), text, font=font)
|
||
PAD = 3
|
||
w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2
|
||
def _draw(d, _x=bb[0], _y=bb[1]):
|
||
d.text((PAD - _x, PAD - _y), text, font=font,
|
||
fill=(255, 230, 100, 230),
|
||
stroke_width=1, stroke_fill=(0, 0, 0, 200))
|
||
return make_sprite(_render_rgba(w, h, _draw))
|
||
|
||
|
||
def make_hud_sprite(team_won: int,
|
||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite:
|
||
"""Render a HUD overlay sprite displaying which team won.
|
||
|
||
Args:
|
||
team_won: Winning team index.
|
||
font: PIL font used for rendering.
|
||
|
||
Returns:
|
||
A Sprite containing the "Team N wins" text.
|
||
"""
|
||
text = f"Team {team_won} wins"
|
||
dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||
bb = dummy.textbbox((0, 0), text, font=font)
|
||
PAD = 3
|
||
w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2
|
||
def _draw(d, _x=bb[0], _y=bb[1]):
|
||
d.text((PAD - _x, PAD - _y), text, font=font,
|
||
fill=(160, 255, 120, 230),
|
||
stroke_width=1, stroke_fill=(0, 0, 0, 180))
|
||
return make_sprite(_render_rgba(w, h, _draw))
|
||
|
||
|
||
def make_time_sprites(max_secs: int, speed: float,
|
||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
||
) -> dict[int, Sprite]:
|
||
dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||
out: dict[int, Sprite] = {}
|
||
for sec in range(max_secs + 1):
|
||
m, s = divmod(sec, 60)
|
||
text = f"{m:02d}:{s:02d} ×{speed:.0f}"
|
||
bb = dummy.textbbox((0, 0), text, font=font)
|
||
w, h = bb[2] - bb[0] + 4, bb[3] - bb[1] + 4
|
||
def _draw(d, _t=text):
|
||
d.text((2, 2), _t, font=font, fill=(210, 210, 210, 230))
|
||
out[sec] = make_sprite(_render_rgba(w, h, _draw))
|
||
return out
|
||
|
||
|
||
# ── Drawing helpers ────────────────────────────────────────────────────────────
|
||
|
||
def draw_all_trails_np(buf: np.ndarray, fi: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
trail_f: int, trail_colors_arr: np.ndarray,
|
||
is_dead: np.ndarray, death_frame: np.ndarray,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
n_players = px_all.shape[0]
|
||
all_vx, all_vy, all_cols = [], [], []
|
||
for pi in range(n_players):
|
||
# Dead players: anchor trail at death frame; alive: current frame
|
||
if is_dead[pi, fi]:
|
||
ef = int(death_frame[pi])
|
||
else:
|
||
ef = fi
|
||
start = max(0, ef - trail_f)
|
||
pxs = px_all[pi, start:ef + 1]
|
||
pys = py_all[pi, start:ef + 1]
|
||
valid = (pxs >= 0) & (pys >= 0)
|
||
if not valid.any():
|
||
continue
|
||
vx = pxs[valid].astype(np.int32)
|
||
vy = pys[valid].astype(np.int32)
|
||
trail_len = ef + 1 - start
|
||
ti = np.where(valid)[0]
|
||
bright = ((ti + 1).astype(np.float32) / trail_len) ** 0.3
|
||
faded = (trail_colors_arr[pi] * bright[:, None]).astype(np.uint8)
|
||
all_vx.append(vx)
|
||
all_vy.append(vy)
|
||
all_cols.append(faded)
|
||
if not all_vx:
|
||
return
|
||
vx = np.concatenate(all_vx)
|
||
vy = np.concatenate(all_vy)
|
||
cols = np.concatenate(all_cols)
|
||
all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel()
|
||
all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel()
|
||
buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0)
|
||
|
||
|
||
def draw_all_dots_np(buf: np.ndarray, fi: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
colors_arr: np.ndarray, shadow_color: np.ndarray,
|
||
is_dead: np.ndarray | None = None,
|
||
shadow_dy: np.ndarray = SHADOW_DY, shadow_dx: np.ndarray = SHADOW_DX,
|
||
dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
cxs = px_all[:, fi].astype(np.int32)
|
||
cys = py_all[:, fi].astype(np.int32)
|
||
valid = (cxs >= 0) & (cys >= 0)
|
||
if is_dead is not None:
|
||
valid &= ~is_dead[:, fi]
|
||
if not valid.any():
|
||
return
|
||
cx = cxs[valid]; cy = cys[valid]
|
||
sy = np.clip(cy[:, None] + shadow_dy, 0, canvas - 1)
|
||
sx = np.clip(cx[:, None] + shadow_dx, 0, canvas - 1)
|
||
buf[sy.ravel(), sx.ravel()] = shadow_color
|
||
nd = len(dot_dy)
|
||
dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1)
|
||
dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1)
|
||
buf[dy.ravel(), dx.ravel()] = np.repeat(colors_arr[valid], nd, axis=0)
|
||
|
||
|
||
def draw_dead_dots_np(buf: np.ndarray, fi: int,
|
||
colors_arr: np.ndarray,
|
||
is_dead: np.ndarray, death_fade: np.ndarray,
|
||
last_dead_px: np.ndarray, last_dead_py: np.ndarray,
|
||
dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX,
|
||
canvas: int = CANVAS_MIN,
|
||
) -> None:
|
||
"""Draw dead player dots: fade from color to black, then stay black."""
|
||
dead = is_dead[:, fi]
|
||
if not dead.any():
|
||
return
|
||
cxs = last_dead_px[dead].astype(np.int32)
|
||
cys = last_dead_py[dead].astype(np.int32)
|
||
valid = (cxs >= 0) & (cys >= 0)
|
||
if not valid.any():
|
||
return
|
||
cx, cy = cxs[valid], cys[valid]
|
||
fade = death_fade[dead, fi][valid]
|
||
cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8)
|
||
nd = len(dot_dy)
|
||
dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1)
|
||
dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1)
|
||
buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0)
|
||
|
||
|
||
def draw_drone_dots_np(buf: np.ndarray, fi: int,
|
||
px_drone: np.ndarray, py_drone: np.ndarray,
|
||
drone_colors: np.ndarray,
|
||
is_crashed: np.ndarray | None = None,
|
||
hit_fade: np.ndarray | None = None,
|
||
drone_dy: np.ndarray = DRONE_DY, drone_dx: np.ndarray = DRONE_DX,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
cxs = px_drone[:, fi].astype(np.int32)
|
||
cys = py_drone[:, fi].astype(np.int32)
|
||
valid = (cxs >= 0) & (cys >= 0)
|
||
if is_crashed is not None:
|
||
valid &= ~is_crashed[:, fi]
|
||
if not valid.any():
|
||
return
|
||
cx = cxs[valid]; cy = cys[valid]
|
||
cols = drone_colors[valid]
|
||
if hit_fade is not None:
|
||
fade = hit_fade[valid, fi]
|
||
cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8)
|
||
nd = len(drone_dy)
|
||
dy = np.clip(cy[:, None] + drone_dy, 0, canvas - 1)
|
||
dx = np.clip(cx[:, None] + drone_dx, 0, canvas - 1)
|
||
buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0)
|
||
|
||
|
||
def draw_aircraft_np(buf: np.ndarray, fi: int,
|
||
px_air: np.ndarray, py_air: np.ndarray,
|
||
air_colors: np.ndarray,
|
||
is_crashed: np.ndarray | None = None,
|
||
hit_fade: np.ndarray | None = None,
|
||
air_dy: np.ndarray = AIR_DY, air_dx: np.ndarray = AIR_DX,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
"""Draw aircraft as small triangles, fading to black after hit."""
|
||
cxs = px_air[:, fi].astype(np.int32)
|
||
cys = py_air[:, fi].astype(np.int32)
|
||
valid = (cxs >= 0) & (cys >= 0)
|
||
if is_crashed is not None:
|
||
valid &= ~is_crashed[:, fi]
|
||
if not valid.any():
|
||
return
|
||
cx = cxs[valid]; cy = cys[valid]
|
||
cols = air_colors[valid]
|
||
if hit_fade is not None:
|
||
fade = hit_fade[valid, fi]
|
||
cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8)
|
||
nd = len(air_dy)
|
||
dy = np.clip(cy[:, None] + air_dy, 0, canvas - 1)
|
||
dx = np.clip(cx[:, None] + air_dx, 0, canvas - 1)
|
||
buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0)
|
||
|
||
|
||
def draw_dead_entities_np(buf: np.ndarray, fi: int,
|
||
colors_arr: np.ndarray,
|
||
is_dead: np.ndarray, death_fade: np.ndarray,
|
||
last_dead_px: np.ndarray, last_dead_py: np.ndarray,
|
||
mask_dy: np.ndarray, mask_dx: np.ndarray,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
"""Draw dead entity markers (circle or triangle) fading from color to black."""
|
||
dead = is_dead[:, fi]
|
||
if not dead.any():
|
||
return
|
||
cxs = last_dead_px[dead].astype(np.int32)
|
||
cys = last_dead_py[dead].astype(np.int32)
|
||
valid = (cxs >= 0) & (cys >= 0)
|
||
if not valid.any():
|
||
return
|
||
cx, cy = cxs[valid], cys[valid]
|
||
fade = death_fade[dead, fi][valid]
|
||
cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8)
|
||
nd = len(mask_dy)
|
||
dy = np.clip(cy[:, None] + mask_dy, 0, canvas - 1)
|
||
dx = np.clip(cx[:, None] + mask_dx, 0, canvas - 1)
|
||
buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0)
|
||
|
||
|
||
def draw_icon_sprites(buf: np.ndarray, fi: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
icon_sprites: list,
|
||
is_dead: np.ndarray | None = None,
|
||
is_crashed: np.ndarray | None = None,
|
||
hit_fade: np.ndarray | None = None,
|
||
rot_caches: list | None = None,
|
||
headings: np.ndarray | None = None) -> None:
|
||
"""Draw per-entity icon sprites instead of circles/triangles."""
|
||
n = px_all.shape[0]
|
||
for i in range(n):
|
||
px_i, py_i = int(px_all[i, fi]), int(py_all[i, fi])
|
||
if px_i < 0 or py_i < 0:
|
||
continue
|
||
if is_dead is not None and is_dead[i, fi]:
|
||
continue
|
||
if is_crashed is not None and is_crashed[i, fi]:
|
||
continue
|
||
if rot_caches and headings is not None and headings[i, fi] >= 0:
|
||
spr = rot_caches[i][heading_to_rot_index(headings[i, fi])]
|
||
else:
|
||
spr = icon_sprites[i]
|
||
alpha = 1.0
|
||
if hit_fade is not None:
|
||
alpha = float(hit_fade[i, fi])
|
||
if alpha <= 0:
|
||
continue
|
||
x, y = px_i - spr.w // 2, py_i - spr.h // 2
|
||
if alpha < 1.0:
|
||
blit_alpha(buf, spr, x, y, alpha)
|
||
else:
|
||
blit(buf, spr, x, y)
|
||
|
||
|
||
def draw_dead_icon_sprites(buf: np.ndarray, fi: int,
|
||
icon_sprites: list,
|
||
dead_sprites: list,
|
||
is_dead: np.ndarray, death_fade: np.ndarray | None,
|
||
last_dead_px: np.ndarray,
|
||
last_dead_py: np.ndarray,
|
||
rot_caches: list | None = None,
|
||
dead_rot_caches: list | None = None,
|
||
headings: np.ndarray | None = None) -> None:
|
||
"""Draw dead entity icons fading from color to black at their last known position."""
|
||
n = len(icon_sprites)
|
||
for i in range(n):
|
||
if not is_dead[i, fi]:
|
||
continue
|
||
px_i, py_i = int(last_dead_px[i]), int(last_dead_py[i])
|
||
if px_i < 0 or py_i < 0:
|
||
continue
|
||
# Pick rotated sprite if available (use last valid heading)
|
||
if rot_caches and dead_rot_caches and headings is not None and headings[i, fi] >= 0:
|
||
ri = heading_to_rot_index(headings[i, fi])
|
||
spr = rot_caches[i][ri]
|
||
dspr = dead_rot_caches[i][ri]
|
||
else:
|
||
spr = icon_sprites[i]
|
||
dspr = dead_sprites[i]
|
||
x, y = px_i - spr.w // 2, py_i - spr.h // 2
|
||
blit(buf, dspr, x, y)
|
||
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)
|
||
|
||
|
||
def draw_air_trails_np(buf: np.ndarray, fi: int,
|
||
px_all: np.ndarray, py_all: np.ndarray,
|
||
trail_f: int, trail_colors: np.ndarray,
|
||
is_crashed: np.ndarray | None, crash_frame: np.ndarray | None,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
"""Short trails for aircraft/drones with line interpolation between frames.
|
||
Anchors at crash_frame once crashed (path ended)."""
|
||
n_ents = px_all.shape[0]
|
||
all_vx, all_vy, all_cols = [], [], []
|
||
CM = canvas - 1
|
||
for ei in range(n_ents):
|
||
if is_crashed is not None and crash_frame is not None and is_crashed[ei, fi]:
|
||
ef = int(crash_frame[ei])
|
||
else:
|
||
ef = fi
|
||
start = max(0, ef - trail_f)
|
||
pxs = px_all[ei, start:ef + 1]
|
||
pys = py_all[ei, start:ef + 1]
|
||
valid = (pxs >= 0) & (pys >= 0)
|
||
if not valid.any():
|
||
continue
|
||
vx = pxs[valid].astype(np.int32)
|
||
vy = pys[valid].astype(np.int32)
|
||
trail_len = ef + 1 - start
|
||
ti = np.where(valid)[0]
|
||
# Interpolate lines between consecutive valid points to fill gaps
|
||
if len(vx) >= 2:
|
||
seg_vx, seg_vy, seg_bright = [], [], []
|
||
for si in range(len(vx) - 1):
|
||
x0, y0, x1, y1 = vx[si], vy[si], vx[si + 1], vy[si + 1]
|
||
dist = max(abs(x1 - x0), abs(y1 - y0))
|
||
if dist <= 1:
|
||
seg_vx.append(x0)
|
||
seg_vy.append(y0)
|
||
seg_bright.append((ti[si] + 1) / trail_len)
|
||
else:
|
||
n_pts = min(dist, 64) # cap to avoid huge arrays
|
||
t = np.arange(n_pts, dtype=np.float32) / n_pts
|
||
seg_vx.append(np.clip((x0 + (x1 - x0) * t).astype(np.int32), 0, CM))
|
||
seg_vy.append(np.clip((y0 + (y1 - y0) * t).astype(np.int32), 0, CM))
|
||
b0 = (ti[si] + 1) / trail_len
|
||
b1 = (ti[si + 1] + 1) / trail_len
|
||
seg_bright.append(b0 + (b1 - b0) * t)
|
||
# Last point
|
||
seg_vx.append(vx[-1])
|
||
seg_vy.append(vy[-1])
|
||
seg_bright.append((ti[-1] + 1) / trail_len)
|
||
vx_interp = np.concatenate([np.atleast_1d(s) for s in seg_vx])
|
||
vy_interp = np.concatenate([np.atleast_1d(s) for s in seg_vy])
|
||
bright = np.concatenate([np.atleast_1d(s) for s in seg_bright])
|
||
else:
|
||
vx_interp, vy_interp = vx, vy
|
||
bright = np.array([(ti[0] + 1) / trail_len], dtype=np.float32)
|
||
bright = bright ** 0.3
|
||
faded = (trail_colors[ei] * bright[:, None]).astype(np.uint8)
|
||
all_vx.append(vx_interp)
|
||
all_vy.append(vy_interp)
|
||
all_cols.append(faded)
|
||
if not all_vx:
|
||
return
|
||
vx = np.concatenate(all_vx)
|
||
vy = np.concatenate(all_vy)
|
||
cols = np.concatenate(all_cols)
|
||
all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel()
|
||
all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel()
|
||
buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0)
|
||
|
||
|
||
def draw_kill_events(buf: np.ndarray, events: list[tuple],
|
||
kill_target_spr: Sprite | None = None,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
CM = canvas - 1
|
||
full_line = np.array([255, 30, 30], dtype=np.float32)
|
||
_LINE_OFFSETS = [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)]
|
||
for (vx, vy, kx, ky, age_frac, label) in events:
|
||
alpha = 1.0 - age_frac
|
||
# Kill line from killer to victim
|
||
if kx >= 0 and ky >= 0:
|
||
pts = max(abs(vx - kx), abs(vy - ky)) + 1
|
||
if pts >= 2:
|
||
t = np.arange(pts, dtype=np.float32) / (pts - 1)
|
||
cx = (kx + (vx - kx) * t).astype(np.int32)
|
||
cy = (ky + (vy - ky) * t).astype(np.int32)
|
||
for odx, ody in _LINE_OFFSETS:
|
||
xs = np.clip(cx + odx, 0, CM)
|
||
ys = np.clip(cy + ody, 0, CM)
|
||
bg = buf[ys, xs].astype(np.float32)
|
||
buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8)
|
||
# Target icon at victim position (replaces starburst)
|
||
if kill_target_spr is not None:
|
||
blit_alpha(buf, kill_target_spr,
|
||
vx - kill_target_spr.w // 2, vy - kill_target_spr.h // 2,
|
||
alpha)
|
||
# Label
|
||
if label is not None:
|
||
label_alpha = 1.0 if age_frac < 0.8 else max(0.0, 1.0 - (age_frac - 0.8) / 0.2)
|
||
blit_alpha(buf, label, vx - label.w // 2, vy - label.h - 14, label_alpha)
|
||
|
||
|
||
def draw_damage_events(buf: np.ndarray, events: list[tuple],
|
||
dmg_target_spr: Sprite | None = None,
|
||
canvas: int = CANVAS_MIN) -> None:
|
||
CM = canvas - 1
|
||
full_line = np.array([255, 255, 80], dtype=np.float32)
|
||
for (ox, oy, vx, vy, age_frac) in events:
|
||
alpha = 1.0 - age_frac
|
||
pts = max(abs(vx - ox), abs(vy - oy)) + 1
|
||
if pts >= 2:
|
||
t = np.arange(pts, dtype=np.float32) / (pts - 1)
|
||
xs = np.clip((ox + (vx - ox) * t).astype(np.int32), 0, CM)
|
||
ys = np.clip((oy + (vy - oy) * t).astype(np.int32), 0, CM)
|
||
bg = buf[ys, xs].astype(np.float32)
|
||
buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8)
|
||
# Target icon at victim position
|
||
if dmg_target_spr is not None:
|
||
blit_alpha(buf, dmg_target_spr,
|
||
vx - dmg_target_spr.w // 2, vy - dmg_target_spr.h // 2,
|
||
alpha)
|
||
|
||
|
||
# ── Per-frame render for one VideoCtx ─────────────────────────────────────────
|
||
|
||
def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx,
|
||
shadow_color: np.ndarray, trail_f: int) -> None:
|
||
"""Render a single video frame into the provided buffer.
|
||
|
||
Draws background, trails, damage/kill events, dots (alive + dead),
|
||
drones, aircraft, and player name labels in compositing order.
|
||
|
||
Args:
|
||
fi: Frame index to render.
|
||
buf: Mutable RGB numpy array, shape (canvas, canvas, 3).
|
||
ctx: Pre-computed VideoCtx with all per-frame data.
|
||
shadow_color: RGB color for dot shadows, shape (3,).
|
||
trail_f: Number of trailing frames to draw behind each player.
|
||
"""
|
||
np.copyto(buf, ctx.bg_arr)
|
||
|
||
cv = ctx.canvas
|
||
# Ground trails
|
||
draw_all_trails_np(buf, fi, ctx.px_all, ctx.py_all, trail_f, ctx.trail_colors_arr,
|
||
ctx.is_dead, ctx.death_frame, cv)
|
||
# Drone trails (very short, line-interpolated)
|
||
if ctx.n_drones and ctx.drone_trail_colors is not None:
|
||
assert ctx.px_drone is not None and ctx.py_drone is not None
|
||
draw_air_trails_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_trail_f,
|
||
ctx.drone_trail_colors, ctx.drone_is_crashed, ctx.drone_crash_frame, cv)
|
||
# Aircraft trails (short, line-interpolated)
|
||
if ctx.n_aircraft and ctx.air_trail_colors is not None:
|
||
assert ctx.px_air is not None and ctx.py_air is not None
|
||
draw_air_trails_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_trail_f,
|
||
ctx.air_trail_colors, ctx.air_is_crashed, ctx.air_crash_frame, cv)
|
||
|
||
if ctx.damages_by_frame[fi]:
|
||
draw_damage_events(buf, ctx.damages_by_frame[fi], ctx.dmg_target_spr, cv)
|
||
|
||
if ctx.kills_by_frame[fi]:
|
||
draw_kill_events(buf, ctx.kills_by_frame[fi], ctx.kill_target_spr, cv)
|
||
|
||
# Ground: dead icons + alive icons
|
||
if ctx.player_icon_sprites:
|
||
draw_dead_icon_sprites(buf, fi, ctx.player_icon_sprites,
|
||
ctx.player_dead_sprites,
|
||
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)
|
||
else:
|
||
draw_dead_dots_np(buf, fi, ctx.colors_arr, ctx.is_dead, ctx.death_fade,
|
||
ctx.last_dead_px, ctx.last_dead_py,
|
||
ctx.dot_dy, ctx.dot_dx, cv)
|
||
draw_all_dots_np(buf, fi, ctx.px_all, ctx.py_all,
|
||
ctx.colors_arr, shadow_color, ctx.is_dead,
|
||
ctx.shadow_dy, ctx.shadow_dx, ctx.dot_dy, ctx.dot_dx, cv)
|
||
|
||
# Drones: freeze at hit position, fade to transparent over ~2-3s
|
||
if ctx.n_drones:
|
||
assert ctx.px_drone is not None and ctx.py_drone is not None
|
||
assert ctx.drone_colors_arr is not None
|
||
if ctx.drone_icon_sprites:
|
||
n_dr = ctx.px_drone.shape[0]
|
||
for di in range(n_dr):
|
||
is_hit = ctx.drone_is_hit is not None and ctx.drone_is_hit[di, fi]
|
||
if is_hit:
|
||
# Frozen at hit position, fading out
|
||
if ctx.drone_last_dead_px is None or ctx.drone_last_dead_py is None or ctx.drone_hit_fade is None:
|
||
continue
|
||
dpx, dpy = int(ctx.drone_last_dead_px[di]), int(ctx.drone_last_dead_py[di])
|
||
fade = float(ctx.drone_hit_fade[di, fi])
|
||
if fade <= 0 or dpx < 0 or dpy < 0:
|
||
continue
|
||
else:
|
||
# Alive — draw at current position
|
||
dpx, dpy = int(ctx.px_drone[di, fi]), int(ctx.py_drone[di, fi])
|
||
fade = 1.0
|
||
if dpx < 0 or dpy < 0:
|
||
continue
|
||
if ctx.drone_rot_caches and ctx.drone_headings is not None and ctx.drone_headings[di, fi] >= 0:
|
||
spr = ctx.drone_rot_caches[di][heading_to_rot_index(ctx.drone_headings[di, fi])]
|
||
else:
|
||
spr = ctx.drone_icon_sprites[di]
|
||
x, y = dpx - spr.w // 2, dpy - spr.h // 2
|
||
if fade < 1.0:
|
||
blit_alpha(buf, spr, x, y, fade)
|
||
else:
|
||
blit(buf, spr, x, y)
|
||
else:
|
||
# Fallback to dots if no icon sprites
|
||
if ctx.drone_is_crashed is not None:
|
||
draw_dead_entities_np(buf, fi, ctx.drone_colors_arr,
|
||
ctx.drone_is_crashed, ctx.drone_hit_fade, # type: ignore[arg-type]
|
||
ctx.drone_last_dead_px, ctx.drone_last_dead_py, # type: ignore[arg-type]
|
||
ctx.drone_dy, ctx.drone_dx, cv)
|
||
draw_drone_dots_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_colors_arr,
|
||
ctx.drone_is_crashed, ctx.drone_hit_fade,
|
||
ctx.drone_dy, ctx.drone_dx, cv)
|
||
|
||
# Aircraft: dead icons + alive icons
|
||
if ctx.n_aircraft:
|
||
assert ctx.px_air is not None and ctx.py_air is not None
|
||
assert ctx.air_colors_arr is not None
|
||
if ctx.air_icon_sprites:
|
||
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, 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,
|
||
headings=ctx.air_headings)
|
||
draw_icon_sprites(buf, fi, ctx.px_air, ctx.py_air,
|
||
ctx.air_icon_sprites,
|
||
is_crashed=ctx.air_is_crashed,
|
||
hit_fade=ctx.air_hit_fade,
|
||
rot_caches=ctx.air_rot_caches,
|
||
headings=ctx.air_headings)
|
||
else:
|
||
if ctx.air_is_crashed is not None:
|
||
draw_dead_entities_np(buf, fi, ctx.air_colors_arr,
|
||
ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type]
|
||
ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type]
|
||
ctx.air_dy, ctx.air_dx, cv)
|
||
draw_aircraft_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_colors_arr,
|
||
ctx.air_is_crashed, ctx.air_hit_fade,
|
||
ctx.air_dy, ctx.air_dx, cv)
|
||
|
||
items: list[tuple[Sprite, int, int]] = []
|
||
for i in range(ctx.n_players):
|
||
px_i, py_i = int(ctx.px_all[i, fi]), int(ctx.py_all[i, fi])
|
||
if px_i >= 0 and py_i >= 0:
|
||
ls = ctx.label_sprites[i]
|
||
icon_w = ctx.player_icon_sprites[i].w if ctx.player_icon_sprites else ctx.dot_r * 2
|
||
items.append((ls, px_i + icon_w // 2 + 3, py_i - ls.h // 2))
|
||
if ctx.n_drones:
|
||
assert ctx.px_drone is not None and ctx.py_drone is not None
|
||
for i in range(ctx.n_drones):
|
||
px_i, py_i = int(ctx.px_drone[i, fi]), int(ctx.py_drone[i, fi])
|
||
if px_i >= 0 and py_i >= 0:
|
||
if ctx.drone_is_crashed is not None and ctx.drone_is_crashed[i, fi]:
|
||
continue
|
||
ds = ctx.drone_sprites[i]
|
||
items.append((ds, px_i + ctx.drone_r + 2, py_i - ds.h // 2))
|
||
if ctx.n_aircraft:
|
||
assert ctx.px_air is not None and ctx.py_air is not None
|
||
assert ctx.air_alt is not None
|
||
for i in range(ctx.n_aircraft):
|
||
px_i, py_i = int(ctx.px_air[i, fi]), int(ctx.py_air[i, fi])
|
||
if px_i >= 0 and py_i >= 0:
|
||
if ctx.air_is_crashed is not None and ctx.air_is_crashed[i, fi]:
|
||
continue
|
||
# Name+model label to the right
|
||
ls = ctx.air_sprites[i]
|
||
air_icon_w = ctx.air_icon_sprites[i].w if ctx.air_icon_sprites else ctx.air_r * 2
|
||
items.append((ls, px_i + air_icon_w // 2 + 3, py_i - ls.h // 2))
|
||
# Altitude label to the left
|
||
alt_m = int(ctx.air_alt[i, fi])
|
||
if alt_m >= 0:
|
||
alt_key = alt_m // 10 * 10
|
||
alt_spr = ctx.air_alt_sprites.get(alt_key)
|
||
if alt_spr is not None:
|
||
items.append((alt_spr, px_i - air_icon_w // 2 - 3 - alt_spr.w, py_i - alt_spr.h // 2))
|
||
if items:
|
||
blit_batch(buf, items)
|
||
|
||
|
||
def _get_thread_buf(tmpl: np.ndarray) -> np.ndarray:
|
||
"""Thread-local render buffer."""
|
||
if not hasattr(_tl, "buf"):
|
||
_tl.buf = np.empty_like(tmpl)
|
||
return _tl.buf
|
||
|
||
|
||
# ── FFmpeg / font ──────────────────────────────────────────────────────────────
|
||
|
||
def open_ffmpeg(output_path: Path, fps: int, w: int = CANVAS_MIN, h: int = CANVAS_MIN) -> subprocess.Popen:
|
||
"""Open an FFmpeg subprocess that reads raw RGB24 frames from stdin and encodes to H.264 MP4.
|
||
|
||
Args:
|
||
output_path: Destination MP4 file path.
|
||
fps: Frames per second for the output video.
|
||
w: Frame width in pixels.
|
||
h: Frame height in pixels.
|
||
|
||
Returns:
|
||
A Popen handle whose stdin accepts raw RGB24 frame bytes.
|
||
"""
|
||
return subprocess.Popen([
|
||
"ffmpeg", "-y",
|
||
"-f", "rawvideo", "-vcodec", "rawvideo",
|
||
"-s", f"{w}x{h}", "-pix_fmt", "rgb24",
|
||
"-r", str(fps), "-i", "pipe:0",
|
||
"-vcodec", "libx264", "-preset", "ultrafast",
|
||
"-crf", "34", "-pix_fmt", "yuv420p", "-threads", "4",
|
||
"-movflags", "+faststart",
|
||
str(output_path),
|
||
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
|
||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||
for p in ["/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
"/usr/share/fonts/dejavu/DejaVuSans.ttf"]:
|
||
if Path(p).exists():
|
||
return ImageFont.truetype(p, size)
|
||
return ImageFont.load_default()
|
||
|
||
|
||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
def assign_colors(active: list[dict], team_won: int
|
||
) -> tuple[np.ndarray, np.ndarray]:
|
||
"""Assign per-player RGB colors based on team membership (winner vs loser palette).
|
||
|
||
Args:
|
||
active: Active player dicts, each with a 'Team' key.
|
||
team_won: The winning team index.
|
||
|
||
Returns:
|
||
Tuple of (colors_arr, trail_colors_arr), both uint8 arrays of shape
|
||
(n_players, 3). Trail colors are dimmed to 60% brightness.
|
||
"""
|
||
colors_list = []
|
||
for p in active:
|
||
if p["Team"] == team_won:
|
||
colors_list.append(WIN_COLOR)
|
||
else:
|
||
colors_list.append(LOSE_COLOR)
|
||
colors_arr = np.array(colors_list, dtype=np.uint8)
|
||
return colors_arr, (colors_arr * 0.6).astype(np.uint8)
|
||
|
||
|
||
def make_bg(map_pil: Image.Image) -> np.ndarray:
|
||
return np.asarray(map_pil).copy()
|
||
|
||
|
||
# ── Public render function ─────────────────────────────────────────────────────
|
||
|
||
def render_gob(
|
||
d: dict,
|
||
out_path: Path,
|
||
fps: int = FPS,
|
||
speed: float = SPEED,
|
||
n_workers: int = N_WORKERS,
|
||
progress_cb: Optional[Callable[[int], None]] = None,
|
||
) -> None:
|
||
"""
|
||
Render a replay dict to an MP4 file.
|
||
|
||
Args:
|
||
d: Parsed replay dict (normalized by load_gob_file)
|
||
out_path: Output MP4 path
|
||
fps: Frames per second
|
||
speed: Playback speed multiplier
|
||
n_workers: Thread pool size for parallel frame render
|
||
progress_cb: Optional callback called with int 0-100 during render
|
||
"""
|
||
players_by_id = {p["PlayerID"]: p for p in d["Players"]}
|
||
team_won = d["TeamWon"]
|
||
|
||
def build_active(entities: list[dict]) -> list[dict]:
|
||
out = []
|
||
for e in entities:
|
||
if e.get("PlayerID", 0) == 0:
|
||
continue
|
||
p = dict(players_by_id[e["PlayerID"]])
|
||
p["_samples"] = e["Path"]
|
||
p["_model"] = e["ModelName"]
|
||
out.append(p)
|
||
return out
|
||
|
||
ground_ents = [e for e in d["Entities"] if e.get("PlayerID", 0) != 0
|
||
and e["ModelName"].startswith("tankModels/")]
|
||
active = build_active(ground_ents)
|
||
|
||
if not active:
|
||
raise ValueError("No ground unit entities found in replay.")
|
||
|
||
print(f"Ground : {len(active)}")
|
||
|
||
# Time grid
|
||
all_t = [s["Time"] for e in ground_ents for s in e["Path"]]
|
||
t_start = float(min(all_t))
|
||
t_end = float(max(all_t))
|
||
ms_per_frame = (1000.0 / fps) * speed
|
||
n_frames = int(math.ceil((t_end - t_start) / ms_per_frame))
|
||
frame_times = t_start + np.arange(n_frames, dtype=np.float64) * ms_per_frame
|
||
trail_f = max(1, int(TRAIL_MS * fps / 1000.0))
|
||
duration_s = (t_end - t_start) / 1000.0
|
||
|
||
print(f"Duration : {duration_s:.1f}s → ~{n_frames/fps:.0f}s video ({n_frames} frames)")
|
||
|
||
# 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 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, coord_src = _fit_world_bounds_to_ground_activity(
|
||
tc0, tc1, coord_src,
|
||
mission_def, mission_def_path, battle_type,
|
||
ground_ents,
|
||
)
|
||
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 (full tank map)")
|
||
|
||
print("Pre-computing positions …", end=" ", flush=True)
|
||
px, py = precompute_positions(active, xfm, frame_times)
|
||
print("done")
|
||
|
||
kills = [k for k in d.get("Kills", []) if t_start <= k["Time"] <= t_end]
|
||
damages = [dr for dr in d.get("DamageReports", []) if t_start <= dr["Time"] <= t_end]
|
||
print(f"Events : {len(kills)} kills, {len(damages)} damage reports")
|
||
|
||
is_dead, death_frame, death_fade, last_dead_px, last_dead_py = precompute_deaths(
|
||
active, kills, t_start, ms_per_frame, n_frames, fps, px, py)
|
||
|
||
wipe_f = find_team_wipe_frame(active, is_dead, n_frames)
|
||
end_frame = min(wipe_f + WIPE_GRACE, n_frames)
|
||
if wipe_f < n_frames:
|
||
print(f"Team wipe : frame {wipe_f} (~{wipe_f/fps:.1f}s video)")
|
||
|
||
colors, trail_colors = assign_colors(active, team_won)
|
||
shadow_color = np.array([0, 0, 0], dtype=np.uint8)
|
||
|
||
drones = build_drone_list(d, active, xfm, px, py,
|
||
frame_times, t_start, ms_per_frame, colors)
|
||
n_drones = len(drones)
|
||
if n_drones:
|
||
px_dr, py_dr = precompute_positions(drones, xfm, frame_times)
|
||
drone_alt = precompute_altitudes(drones, frame_times)
|
||
drone_cols = np.array([dr["color"] for dr in drones], dtype=np.uint8)
|
||
drone_trail_cols = (drone_cols * 0.6).astype(np.uint8)
|
||
# Match drone kills by EntityIndex (since PlayerID=0)
|
||
drone_is_hit, drone_hit_fade, drone_is_crashed, drone_crash_frame, \
|
||
drone_last_dead_px, drone_last_dead_py = \
|
||
precompute_air_deaths(drones, kills, t_start, ms_per_frame, n_frames, fps, px_dr, py_dr)
|
||
# Override: drones freeze at kill/path-end and fade to transparent over ~2.5s
|
||
drone_fade_frames = max(1, int(2.5 * fps))
|
||
for i in range(len(drones)):
|
||
hit_frames = np.where(drone_is_hit[i])[0]
|
||
if len(hit_frames):
|
||
# Kill-matched: freeze at kill frame
|
||
hf = hit_frames[0]
|
||
else:
|
||
# Unmatched: freeze at path end
|
||
cf = int(drone_crash_frame[i])
|
||
if cf >= n_frames:
|
||
continue # path extends beyond video, drone stays alive
|
||
hf = cf
|
||
# Apply 2.5s fade from freeze point
|
||
drone_is_hit[i, :] = False
|
||
drone_is_hit[i, hf:] = True
|
||
n_slots = min(drone_fade_frames + 1, n_frames - hf)
|
||
drone_hit_fade[i, :] = 1.0
|
||
drone_hit_fade[i, hf:hf + n_slots] = np.maximum(
|
||
0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / drone_fade_frames)
|
||
drone_hit_fade[i, hf + n_slots:] = 0.0
|
||
# Freeze position at hit/end frame
|
||
drone_last_dead_px[i] = px_dr[i, min(hf, px_dr.shape[1] - 1)]
|
||
drone_last_dead_py[i] = py_dr[i, min(hf, py_dr.shape[1] - 1)]
|
||
# Also stop the trail at this frame
|
||
drone_crash_frame[i] = hf
|
||
drone_is_crashed[i, :] = False
|
||
if hf < n_frames - 1:
|
||
drone_is_crashed[i, hf + 1:] = True
|
||
print(f"Drones : {n_drones}")
|
||
else:
|
||
px_dr = py_dr = drone_cols = drone_trail_cols = drone_alt = None
|
||
drone_is_hit = drone_hit_fade = drone_is_crashed = drone_crash_frame = None
|
||
drone_last_dead_px = drone_last_dead_py = None
|
||
|
||
# Aircraft — non-tank, non-drone entities with path data
|
||
air_ents = [e for e in d["Entities"]
|
||
if e.get("PlayerID", 0) != 0
|
||
and not e["ModelName"].startswith("tankModels/")
|
||
and "ucav" not in e["ModelName"].lower()
|
||
and e.get("Path")]
|
||
air_active = build_active(air_ents)
|
||
n_aircraft = len(air_active)
|
||
if n_aircraft:
|
||
px_air, py_air = precompute_positions(air_active, xfm, frame_times)
|
||
air_alt = precompute_altitudes(air_active, frame_times)
|
||
# Air death: keep flying after hit, fade to black, crash at end of path
|
||
air_is_hit, air_hit_fade, air_is_crashed, air_crash_frame, \
|
||
air_last_dead_px, air_last_dead_py = \
|
||
precompute_air_deaths(air_active, kills, t_start, ms_per_frame, n_frames, fps, px_air, py_air)
|
||
# Assign colors: match team using same win/lose scheme
|
||
air_cols_list = []
|
||
for p in air_active:
|
||
if p["Team"] == team_won:
|
||
air_cols_list.append(WIN_COLOR)
|
||
else:
|
||
air_cols_list.append(LOSE_COLOR)
|
||
air_cols = np.array(air_cols_list, dtype=np.uint8)
|
||
air_trail_cols = (air_cols * 0.6).astype(np.uint8)
|
||
print(f"Air : {n_aircraft}")
|
||
else:
|
||
px_air = py_air = air_cols = air_trail_cols = air_alt = None
|
||
air_is_hit = air_hit_fade = air_is_crashed = air_crash_frame = None
|
||
air_last_dead_px = air_last_dead_py = None
|
||
|
||
print("Loading tank map …", end=" ", flush=True)
|
||
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 = 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))
|
||
s_air_r = max(2, int(AIR_R * scale + 0.5))
|
||
s_shadow_dy, s_shadow_dx = make_circle_masks(s_dot_r + 1)
|
||
s_dot_dy, s_dot_dx = make_circle_masks(s_dot_r)
|
||
s_drone_dy, s_drone_dx = make_circle_masks(s_drone_r)
|
||
s_air_dy, s_air_dx = make_triangle_masks(s_air_r)
|
||
|
||
font = load_font(s_font_sz)
|
||
kbf = precompute_kills(kills, xfm, t_start, ms_per_frame, n_frames, fps, font,
|
||
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)
|
||
label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(name_sprs, model_sprs)]
|
||
drone_spr = make_name_sprites(["(Drone)"] * n_drones, font)
|
||
# Build altitude sprite cache — shared between drones and aircraft
|
||
all_alt_vals: set[int] = set()
|
||
if drone_alt is not None:
|
||
all_alt_vals.update(int(v) // 10 * 10 for v in drone_alt[drone_alt >= 0])
|
||
if air_alt is not None:
|
||
all_alt_vals.update(int(v) // 10 * 10 for v in air_alt[air_alt >= 0])
|
||
alt_sprites: dict[int, Sprite] = {}
|
||
for a in all_alt_vals:
|
||
sprs = make_name_sprites([f"(Alt: {a}m)"], font)
|
||
alt_sprites[a] = sprs[0]
|
||
|
||
# Build per-player vehicle icon sprites (tinted to player color)
|
||
icon_size = max(12, int(TANK_ICON_SIZE * scale + 0.5))
|
||
hl_pad = max(1, int(ICON_HIGHLIGHT_PAD * scale + 0.5))
|
||
player_icon_sprs: list[Sprite] = []
|
||
player_icon_keys: list[str] = []
|
||
for i, p in enumerate(active):
|
||
ik = get_icon_key(p["_model"])
|
||
player_icon_keys.append(ik)
|
||
c = (int(colors[i][0]), int(colors[i][1]), int(colors[i][2]))
|
||
player_icon_sprs.append(make_tinted_icon_sprite(
|
||
ik, c, icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR))
|
||
# Dead sprites: bare black silhouette (no highlight glow)
|
||
player_dead_sprs = [make_bare_black_sprite(ik, icon_size, hl_pad)
|
||
for ik in player_icon_keys]
|
||
print(f"Icons : {len(player_icon_sprs)} ground @ {icon_size}px (+{hl_pad}px highlight)")
|
||
|
||
# Build per-aircraft icon sprites
|
||
air_icon_sprs: list[Sprite] = []
|
||
if n_aircraft:
|
||
assert air_cols is not None
|
||
air_name_sprs = make_name_sprites([p["Name"] for p in air_active], font)
|
||
air_model_sprs = make_name_sprites([_short_model(p["_model"]) for p in air_active], font)
|
||
air_label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(air_name_sprs, air_model_sprs)]
|
||
air_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5))
|
||
for i, p in enumerate(air_active):
|
||
ik = get_icon_key(p["_model"])
|
||
c = (int(air_cols[i][0]), int(air_cols[i][1]), int(air_cols[i][2]))
|
||
air_icon_sprs.append(make_tinted_icon_sprite(
|
||
ik, c, air_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR))
|
||
air_dead_sprs = [make_black_sprite(s) for s in air_icon_sprs]
|
||
air_rot_caches = [make_rotation_cache(s) for s in air_icon_sprs]
|
||
air_dead_rot_caches = [make_rotation_cache(s) for s in air_dead_sprs]
|
||
assert px_air is not None and py_air is not None
|
||
air_headings = precompute_headings(px_air, py_air)
|
||
print(f"Icons : {n_aircraft} aircraft @ {air_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)")
|
||
else:
|
||
air_label_sprs = []
|
||
air_dead_sprs = []
|
||
air_rot_caches = []
|
||
air_dead_rot_caches = []
|
||
air_headings = None
|
||
|
||
# Build per-drone icon sprites with rotation (like aircraft)
|
||
drone_icon_sprs: list[Sprite] = []
|
||
drone_dead_sprs_list: list[Sprite] = []
|
||
drone_rot_caches: list = []
|
||
drone_dead_rot_caches: list = []
|
||
drone_headings_arr: np.ndarray | None = None
|
||
drone_icon_keys: list[str] = []
|
||
if n_drones:
|
||
drone_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5))
|
||
for dr in drones:
|
||
ik = get_icon_key(dr["entity"]["ModelName"])
|
||
drone_icon_keys.append(ik)
|
||
c = (int(dr["color"][0]), int(dr["color"][1]), int(dr["color"][2]))
|
||
drone_icon_sprs.append(make_tinted_icon_sprite(
|
||
ik, c, drone_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR))
|
||
# Drones: bare black silhouette (no glow) when dead
|
||
drone_dead_sprs_list = [make_bare_black_sprite(ik, drone_icon_size, hl_pad)
|
||
for ik in drone_icon_keys]
|
||
drone_rot_caches = [make_rotation_cache(s) for s in drone_icon_sprs]
|
||
drone_dead_rot_caches = [make_rotation_cache(s) for s in drone_dead_sprs_list]
|
||
if px_dr is not None and py_dr is not None:
|
||
drone_headings_arr = precompute_headings(px_dr, py_dr)
|
||
print(f"Icons : {n_drones} drones @ {drone_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)")
|
||
|
||
# Load target icons for kill/damage events
|
||
target_size = max(12, int(20 * scale + 0.5))
|
||
kill_target = load_target_sprite("target_red.png", target_size)
|
||
dmg_target = load_target_sprite("target_yellow.png", target_size)
|
||
|
||
air_trail_f = max(1, int(AIR_TRAIL_MS * fps / 1000.0))
|
||
drone_trail_f = max(1, int(DRONE_TRAIL_MS * fps / 1000.0))
|
||
|
||
ctx = VideoCtx(
|
||
n_players=len(active), px_all=px, py_all=py,
|
||
colors_arr=colors, trail_colors_arr=trail_colors,
|
||
is_dead=is_dead, death_frame=death_frame, death_fade=death_fade,
|
||
last_dead_px=last_dead_px, last_dead_py=last_dead_py,
|
||
kills_by_frame=kbf, damages_by_frame=dbf, label_sprites=label_sprs,
|
||
bg_arr=bg, end_frame=end_frame,
|
||
n_drones=n_drones, px_drone=px_dr, py_drone=py_dr,
|
||
drone_colors_arr=drone_cols, drone_trail_colors=drone_trail_cols,
|
||
drone_sprites=drone_spr,
|
||
drone_icon_sprites=drone_icon_sprs, drone_dead_sprites=drone_dead_sprs_list,
|
||
drone_rot_caches=drone_rot_caches, drone_dead_rot_caches=drone_dead_rot_caches,
|
||
drone_headings=drone_headings_arr,
|
||
drone_is_hit=drone_is_hit, drone_hit_fade=drone_hit_fade,
|
||
drone_is_crashed=drone_is_crashed, drone_crash_frame=drone_crash_frame,
|
||
drone_last_dead_px=drone_last_dead_px, drone_last_dead_py=drone_last_dead_py,
|
||
drone_alt=drone_alt, drone_alt_sprites=alt_sprites,
|
||
n_aircraft=n_aircraft, px_air=px_air, py_air=py_air,
|
||
air_colors_arr=air_cols, air_trail_colors=air_trail_cols,
|
||
air_sprites=air_label_sprs,
|
||
air_is_hit=air_is_hit, air_hit_fade=air_hit_fade,
|
||
air_is_crashed=air_is_crashed, air_crash_frame=air_crash_frame,
|
||
air_last_dead_px=air_last_dead_px, air_last_dead_py=air_last_dead_py,
|
||
air_alt=air_alt, air_alt_sprites=alt_sprites,
|
||
air_trail_f=air_trail_f, drone_trail_f=drone_trail_f,
|
||
dot_dy=s_dot_dy, dot_dx=s_dot_dx,
|
||
shadow_dy=s_shadow_dy, shadow_dx=s_shadow_dx,
|
||
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=canvas,
|
||
player_icon_sprites=player_icon_sprs,
|
||
player_dead_sprites=player_dead_sprs,
|
||
air_icon_sprites=air_icon_sprs,
|
||
air_dead_sprites=air_dead_sprs,
|
||
air_rot_caches=air_rot_caches,
|
||
air_dead_rot_caches=air_dead_rot_caches,
|
||
air_headings=air_headings,
|
||
kill_target_spr=kill_target,
|
||
dmg_target_spr=dmg_target,
|
||
)
|
||
|
||
shared = (shadow_color, trail_f)
|
||
|
||
def render_frame(fi: int) -> bytes:
|
||
buf = _get_thread_buf(ctx.bg_arr)
|
||
render_one_ctx(fi, buf, ctx, *shared)
|
||
return buf.tobytes()
|
||
|
||
ff = open_ffmpeg(out_path, fps, canvas, canvas)
|
||
assert ff.stdin is not None
|
||
fd = ff.stdin.fileno()
|
||
|
||
BATCH = n_workers * 8
|
||
|
||
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
||
for chunk_start in range(0, end_frame, BATCH):
|
||
chunk_end = min(chunk_start + BATCH, end_frame)
|
||
futs = [pool.submit(render_frame, fi)
|
||
for fi in range(chunk_start, chunk_end)]
|
||
for fut in futs:
|
||
os.write(fd, fut.result())
|
||
if progress_cb is not None:
|
||
pct = int(chunk_end / end_frame * 100)
|
||
progress_cb(pct)
|
||
|
||
ff.stdin.close()
|
||
ff.wait()
|
||
|
||
sz = out_path.stat().st_size / 1_048_576
|
||
print(f"\nDone → {out_path} ({sz:.1f} MB)")
|
||
|
||
|
||
# ── Replay loading helpers ────────────────────────────────────────────────────
|
||
|
||
def _to_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _unit_to_model_name(unit_name: str) -> str:
|
||
internal = (unit_name or "").strip()
|
||
if not internal:
|
||
return "tankModels/unknown"
|
||
tags = _get_unit_tags(internal) or []
|
||
tag_set = set(tags)
|
||
if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
|
||
return f"airModels/{internal}_ucav"
|
||
if tag_set & {
|
||
"air",
|
||
"aircraft",
|
||
"helicopter",
|
||
"type_jet_bomber",
|
||
"type_bomber",
|
||
"type_strike_aircraft",
|
||
"type_jet_fighter",
|
||
"type_fighter",
|
||
"type_helicopter",
|
||
}:
|
||
return f"airModels/{internal}"
|
||
return f"tankModels/{internal}"
|
||
|
||
|
||
def _path_sample_to_dict(sample: Any) -> dict[str, float] | None:
|
||
if isinstance(sample, dict):
|
||
if {"Time", "X", "Z"}.issubset(sample.keys()):
|
||
return {
|
||
"Time": float(sample.get("Time", 0.0)),
|
||
"X": float(sample.get("X", 0.0)),
|
||
"Y": float(sample.get("Y", 0.0)),
|
||
"Z": float(sample.get("Z", 0.0)),
|
||
}
|
||
if {"t", "x", "z"}.issubset(sample.keys()):
|
||
return {
|
||
"Time": float(sample.get("t", 0.0)),
|
||
"X": float(sample.get("x", 0.0)),
|
||
"Y": float(sample.get("y", 0.0)),
|
||
"Z": float(sample.get("z", 0.0)),
|
||
}
|
||
return None
|
||
|
||
if isinstance(sample, (list, tuple)) and len(sample) >= 4:
|
||
return {
|
||
"Time": float(sample[0]),
|
||
"X": float(sample[1]),
|
||
"Y": float(sample[2]),
|
||
"Z": float(sample[3]),
|
||
}
|
||
return None
|
||
|
||
|
||
def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str, float] | None:
|
||
if not path:
|
||
return None
|
||
if time_ms <= path[0]["Time"]:
|
||
return path[0]
|
||
prev = path[0]
|
||
for pt in path[1:]:
|
||
if pt["Time"] >= time_ms:
|
||
return pt
|
||
prev = pt
|
||
return prev
|
||
|
||
|
||
def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
||
players_src = replay.get("players") or {}
|
||
if not isinstance(players_src, dict):
|
||
players_src = {}
|
||
|
||
players_out: list[dict[str, Any]] = []
|
||
winner_tag = str(replay.get("winner") or "")
|
||
loser_tag = str(replay.get("loser") or "")
|
||
winner_team = 0
|
||
loser_team = 0
|
||
|
||
for uid_str, pdata in players_src.items():
|
||
if not isinstance(pdata, dict):
|
||
continue
|
||
pid = _to_int(pdata.get("uid") or uid_str, 0)
|
||
team = _to_int(pdata.get("team"), 0)
|
||
tag = str(pdata.get("tag") or "")
|
||
if winner_team == 0 and tag == winner_tag:
|
||
winner_team = team
|
||
if loser_team == 0 and tag == loser_tag:
|
||
loser_team = team
|
||
players_out.append({
|
||
"PlayerID": pid,
|
||
"Name": str(pdata.get("name") or f"Player#{pid}"),
|
||
"Team": team,
|
||
"Clan": tag,
|
||
})
|
||
|
||
if winner_team == 0:
|
||
winner_team = 1 if loser_team != 1 else 2
|
||
|
||
entities_src = replay.get("entities") or []
|
||
if not isinstance(entities_src, list):
|
||
entities_src = []
|
||
|
||
entities_out: list[dict[str, Any]] = []
|
||
uid_to_entity_index: dict[int, int] = {}
|
||
|
||
for idx, ent in enumerate(entities_src, start=1):
|
||
if not isinstance(ent, dict):
|
||
continue
|
||
uid = _to_int(ent.get("uid"), 0)
|
||
unit = str(ent.get("unit") or "")
|
||
path_raw = ent.get("path") or []
|
||
if not isinstance(path_raw, list):
|
||
continue
|
||
path: list[dict[str, float]] = []
|
||
for sample in path_raw:
|
||
parsed = _path_sample_to_dict(sample)
|
||
if parsed is not None:
|
||
path.append(parsed)
|
||
if not path:
|
||
continue
|
||
entity_index = _to_int(ent.get("entity_index") or ent.get("entityIndex"), idx)
|
||
uid_to_entity_index.setdefault(uid, entity_index)
|
||
entities_out.append({
|
||
"EntityIndex": entity_index,
|
||
"PlayerID": uid,
|
||
"ModelName": _unit_to_model_name(unit),
|
||
"Path": path,
|
||
})
|
||
|
||
entity_paths_by_uid: dict[int, list[dict[str, float]]] = {
|
||
e["PlayerID"]: e["Path"] for e in entities_out if e.get("PlayerID")
|
||
}
|
||
|
||
events = replay.get("events") or {}
|
||
if isinstance(events, str):
|
||
try:
|
||
events = json.loads(events)
|
||
except json.JSONDecodeError:
|
||
events = {}
|
||
if not isinstance(events, dict):
|
||
events = {}
|
||
|
||
kills_out: list[dict[str, Any]] = []
|
||
for kill in (events.get("kills") or []):
|
||
if not isinstance(kill, dict):
|
||
continue
|
||
victim_id = _to_int(kill.get("offended_uid"), 0)
|
||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||
kill_time = float(kill.get("time") or 0.0)
|
||
victim_path = entity_paths_by_uid.get(victim_id, [])
|
||
killer_path = entity_paths_by_uid.get(killer_id, [])
|
||
victim_pos = _position_at_time(victim_path, kill_time)
|
||
killer_pos = _position_at_time(killer_path, kill_time)
|
||
payload: dict[str, Any] = {
|
||
"Time": kill_time,
|
||
"VictimID": victim_id,
|
||
"KillerID": killer_id,
|
||
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
|
||
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
|
||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
|
||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
|
||
"crashed": bool(kill.get("crashed", False)),
|
||
}
|
||
if victim_pos:
|
||
payload["VictimPosition"] = {
|
||
"X": float(victim_pos["X"]),
|
||
"Y": float(victim_pos["Y"]),
|
||
"Z": float(victim_pos["Z"]),
|
||
}
|
||
if killer_pos:
|
||
payload["KillerPosition"] = {
|
||
"X": float(killer_pos["X"]),
|
||
"Y": float(killer_pos["Y"]),
|
||
"Z": float(killer_pos["Z"]),
|
||
}
|
||
kills_out.append(payload)
|
||
|
||
damages_out: list[dict[str, Any]] = []
|
||
for dmg in (events.get("damage") or []):
|
||
if not isinstance(dmg, dict):
|
||
continue
|
||
damages_out.append({
|
||
"Time": float(dmg.get("time") or 0.0),
|
||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||
"Afire": bool(dmg.get("afire", False)),
|
||
})
|
||
|
||
mission_mode = str(replay.get("mission_mode") or "")
|
||
difficulty = str(replay.get("difficulty") or "")
|
||
battle_type = mission_mode or difficulty
|
||
|
||
return {
|
||
"SessionID": _to_int(replay.get("_id") or replay.get("id"), 0),
|
||
"TeamWon": winner_team,
|
||
"Mission": {
|
||
"Level": str(replay.get("level_path") or ""),
|
||
"LevelSettings": str(replay.get("mission_path") or ""),
|
||
"BattleType": battle_type,
|
||
},
|
||
"Players": players_out,
|
||
"Entities": entities_out,
|
||
"Kills": kills_out,
|
||
"DamageReports": damages_out,
|
||
}
|
||
|
||
|
||
def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
||
teams_src = replay.get("teams") or []
|
||
if not isinstance(teams_src, list):
|
||
teams_src = []
|
||
|
||
players_out: list[dict[str, Any]] = []
|
||
winner_sq = str(replay.get("winning_team_squadron") or "")
|
||
winner_team = 1
|
||
|
||
for idx, team in enumerate(teams_src[:2], start=1):
|
||
if not isinstance(team, dict):
|
||
continue
|
||
team_sq = str(team.get("squadron") or "")
|
||
if team_sq and team_sq == winner_sq:
|
||
winner_team = idx
|
||
for p in (team.get("players") or []):
|
||
if not isinstance(p, dict):
|
||
continue
|
||
pid = _to_int(p.get("uid"), 0)
|
||
if pid <= 0:
|
||
continue
|
||
players_out.append({
|
||
"PlayerID": pid,
|
||
"Name": str(p.get("nick") or f"Player#{pid}"),
|
||
"Team": idx,
|
||
"Clan": str(team.get("squadron_tagged") or team_sq),
|
||
})
|
||
|
||
entities_src = replay.get("entities") or []
|
||
if not isinstance(entities_src, list):
|
||
entities_src = []
|
||
|
||
entities_out: list[dict[str, Any]] = []
|
||
uid_to_entity_index: dict[int, int] = {}
|
||
for idx, ent in enumerate(entities_src, start=1):
|
||
if not isinstance(ent, dict):
|
||
continue
|
||
uid = _to_int(ent.get("uid"), 0)
|
||
unit = str(ent.get("unit") or "")
|
||
path_raw = ent.get("path") or []
|
||
if not isinstance(path_raw, list):
|
||
continue
|
||
path: list[dict[str, float]] = []
|
||
for sample in path_raw:
|
||
parsed = _path_sample_to_dict(sample)
|
||
if parsed is not None:
|
||
path.append(parsed)
|
||
if not path:
|
||
continue
|
||
entity_index = _to_int(ent.get("entity_index") or ent.get("entityIndex"), idx)
|
||
uid_to_entity_index.setdefault(uid, entity_index)
|
||
entities_out.append({
|
||
"EntityIndex": entity_index,
|
||
"PlayerID": uid,
|
||
"ModelName": _unit_to_model_name(unit),
|
||
"Path": path,
|
||
})
|
||
|
||
entity_paths_by_uid: dict[int, list[dict[str, float]]] = {
|
||
e["PlayerID"]: e["Path"] for e in entities_out if e.get("PlayerID")
|
||
}
|
||
|
||
events = replay.get("events") or {}
|
||
if isinstance(events, str):
|
||
try:
|
||
events = json.loads(events)
|
||
except json.JSONDecodeError:
|
||
events = {}
|
||
if not isinstance(events, dict):
|
||
events = {}
|
||
|
||
kills_out: list[dict[str, Any]] = []
|
||
for kill in (events.get("kills") or []):
|
||
if not isinstance(kill, dict):
|
||
continue
|
||
victim_id = _to_int(kill.get("offended_uid"), 0)
|
||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||
kill_time = float(kill.get("time") or 0.0)
|
||
victim_path = entity_paths_by_uid.get(victim_id, [])
|
||
killer_path = entity_paths_by_uid.get(killer_id, [])
|
||
victim_pos = _position_at_time(victim_path, kill_time)
|
||
killer_pos = _position_at_time(killer_path, kill_time)
|
||
payload: dict[str, Any] = {
|
||
"Time": kill_time,
|
||
"VictimID": victim_id,
|
||
"KillerID": killer_id,
|
||
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
|
||
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
|
||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
|
||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
|
||
"crashed": bool(kill.get("crashed", False)),
|
||
}
|
||
if victim_pos:
|
||
payload["VictimPosition"] = {
|
||
"X": float(victim_pos["X"]),
|
||
"Y": float(victim_pos["Y"]),
|
||
"Z": float(victim_pos["Z"]),
|
||
}
|
||
if killer_pos:
|
||
payload["KillerPosition"] = {
|
||
"X": float(killer_pos["X"]),
|
||
"Y": float(killer_pos["Y"]),
|
||
"Z": float(killer_pos["Z"]),
|
||
}
|
||
kills_out.append(payload)
|
||
|
||
damages_out: list[dict[str, Any]] = []
|
||
for dmg in (events.get("damage") or []):
|
||
if not isinstance(dmg, dict):
|
||
continue
|
||
damages_out.append({
|
||
"Time": float(dmg.get("time") or 0.0),
|
||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||
"Afire": bool(dmg.get("afire", False)),
|
||
})
|
||
|
||
return {
|
||
"SessionID": _to_int(replay.get("session_id_dec") or replay.get("session_id_hex"), 0),
|
||
"TeamWon": winner_team,
|
||
"Mission": {
|
||
"Level": str(replay.get("level_path") or ""),
|
||
"LevelSettings": str(replay.get("mission_path") or ""),
|
||
"BattleType": str(replay.get("mode") or replay.get("difficulty") or ""),
|
||
},
|
||
"Players": players_out,
|
||
"Entities": entities_out,
|
||
"Kills": kills_out,
|
||
"DamageReports": damages_out,
|
||
}
|
||
|
||
|
||
def load_gob_file(replay_path: Path) -> dict[str, Any]:
|
||
"""Load a replay .json or .json.gz file and normalize it for render/export routines."""
|
||
import gzip as _gzip
|
||
raw = replay_path.read_bytes()
|
||
if replay_path.suffix == ".gz":
|
||
raw = _gzip.decompress(raw)
|
||
data = json.loads(raw.decode("utf-8"))
|
||
if isinstance(data, dict) and {"Players", "Entities", "Mission"}.issubset(data.keys()):
|
||
return data
|
||
if isinstance(data, dict):
|
||
if {"teams", "events", "entities"}.issubset(data.keys()):
|
||
return _convert_local_replay_to_render_dict(data)
|
||
return _convert_ws_replay_to_render_dict(data)
|
||
raise ValueError(f"Unsupported replay payload in {replay_path}")
|
||
|
||
|
||
# ── JSON export (slim dict for the web canvas replay viewer) ──────────────────
|
||
|
||
def _entity_type(model_name: str) -> str:
|
||
if model_name.startswith("tankModels/"):
|
||
return "ground"
|
||
if "ucav" in model_name.lower():
|
||
return "drone"
|
||
return "aircraft"
|
||
|
||
|
||
_TAG_TO_ICON_KEY = [
|
||
("type_spaa", "spaa"),
|
||
("type_light_tank", "light"),
|
||
("type_tank_destroyer", "tank_destroyer"),
|
||
("type_heavy_tank", "heavy"),
|
||
("type_medium_tank", "medium"),
|
||
("type_missile_tank", "tank_destroyer"),
|
||
("type_bomber", "bomber_icon"),
|
||
("type_strike_aircraft", "fighter_icon"),
|
||
("type_jet_fighter", "jet_icon"),
|
||
("type_fighter", "fighter_icon"),
|
||
("type_strike_ucav", "drone"),
|
||
("type_helicopter", "helicopter_icon"),
|
||
]
|
||
|
||
|
||
def _vehicle_icon_key(model_name: str) -> str:
|
||
"""Return an icon key for ground vehicles: light, medium, heavy, spaa, tank_destroyer, drone.
|
||
Aircraft return tag-based fallback keys (fighter_icon, bomber_icon, etc.)."""
|
||
internal = model_name.split("/")[-1]
|
||
if "ucav" in model_name.lower():
|
||
return "drone"
|
||
tags = _get_unit_tags(internal)
|
||
if tags:
|
||
tag_set = set(tags)
|
||
for tag, icon in _TAG_TO_ICON_KEY:
|
||
if tag in tag_set:
|
||
return icon
|
||
return "medium"
|
||
|
||
|
||
def _vehicle_mini_icon(model_name: str) -> str | None:
|
||
"""Return the mini icon filename for aircraft (e.g. 'spitfire_ix_ico'), or None."""
|
||
internal = model_name.split("/")[-1]
|
||
mini_path = Path(__file__).resolve().parent / "ICONS" / "MINIS" / f"{internal}_ico.png"
|
||
if mini_path.exists():
|
||
return f"mini:{internal}_ico"
|
||
return None
|
||
|
||
|
||
def _subsample_path(path: list[dict], threshold: float = 1.0) -> list[dict]:
|
||
"""Keep first, last, and points that moved >= threshold world units."""
|
||
if len(path) <= 2:
|
||
return path
|
||
out = [path[0]]
|
||
lx, lz = path[0]["X"], path[0]["Z"]
|
||
for pt in path[1:-1]:
|
||
dx = pt["X"] - lx
|
||
dz = pt["Z"] - lz
|
||
if dx * dx + dz * dz >= threshold * threshold:
|
||
out.append(pt)
|
||
lx, lz = pt["X"], pt["Z"]
|
||
out.append(path[-1])
|
||
return out
|
||
|
||
|
||
def _resolve_drone_team(drone_entity: dict, ground_entities: list[dict],
|
||
players_by_id: dict) -> int:
|
||
"""Return team index for a drone by finding nearest ground player at spawn."""
|
||
if not drone_entity.get("Path") or not ground_entities:
|
||
return 0
|
||
spawn_t = drone_entity["Path"][0]["Time"]
|
||
spawn_x = drone_entity["Path"][0]["X"]
|
||
spawn_z = drone_entity["Path"][0]["Z"]
|
||
|
||
best_team = 0
|
||
best_dist = float("inf")
|
||
for ge in ground_entities:
|
||
pid = ge.get("PlayerID", 0)
|
||
if pid == 0 or pid not in players_by_id:
|
||
continue
|
||
closest_pt = None
|
||
closest_dt = float("inf")
|
||
for pt in ge.get("Path", []):
|
||
dt = abs(pt["Time"] - spawn_t)
|
||
if dt < closest_dt:
|
||
closest_dt = dt
|
||
closest_pt = pt
|
||
if closest_pt is None:
|
||
continue
|
||
dx = closest_pt["X"] - spawn_x
|
||
dz = closest_pt["Z"] - spawn_z
|
||
dist = dx * dx + dz * dz
|
||
if dist < best_dist:
|
||
best_dist = dist
|
||
best_team = players_by_id[pid].get("Team", 0)
|
||
return best_team
|
||
|
||
|
||
def export_replay_json(replay_path: Path) -> dict:
|
||
"""Load a replay file and produce a slim dict for the web viewer."""
|
||
d = load_gob_file(replay_path)
|
||
|
||
players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])}
|
||
team_won = d.get("TeamWon", 0)
|
||
|
||
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)
|
||
tank_map_coords = None
|
||
ground_entities_for_bounds = [e for e in d.get("Entities", [])
|
||
if e.get("PlayerID", 0) != 0
|
||
and e.get("ModelName", "").startswith("tankModels/")
|
||
and e.get("Path")]
|
||
capture_areas = resolve_capture_areas(mission_def, mission_def_path, battle_type)
|
||
if level_data:
|
||
base_c0, base_c1, _ = select_tank_coords(level_data, use_alt_map_coord)
|
||
tank_map_coords = {"x0": base_c0[0], "z0": base_c0[1], "x1": base_c1[0], "z1": base_c1[1]}
|
||
c0, c1, _ = resolve_world_bounds(
|
||
level_data,
|
||
use_alt_map_coord,
|
||
mission_def,
|
||
mission_def_path,
|
||
battle_type,
|
||
)
|
||
c0, c1, _ = _fit_world_bounds_to_ground_activity(
|
||
c0, c1, "json",
|
||
mission_def, mission_def_path, battle_type,
|
||
ground_entities_for_bounds,
|
||
)
|
||
c0, c1 = _expand_bounds_by_pixels(c0, c1, canvas=CANVAS_MIN, pad_px=MAP_PAD_PX)
|
||
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}
|
||
|
||
map_coords = None
|
||
full_map_level = None
|
||
if level_data and "mapCoord0" in level_data and "mapCoord1" in level_data:
|
||
mc0 = level_data["mapCoord0"]
|
||
mc1 = level_data["mapCoord1"]
|
||
if isinstance(mc0[0], list):
|
||
mc0 = mc0[0]
|
||
if isinstance(mc1[0], list):
|
||
mc1 = mc1[0]
|
||
map_coords = {"x0": mc0[0], "z0": mc0[1], "x1": mc1[0], "z1": mc1[1]}
|
||
stem = Path(level_path).stem if level_path else ""
|
||
custom = level_data.get("customLevelMap", "")
|
||
if custom:
|
||
full_map_level = custom.rstrip("*")
|
||
else:
|
||
full_map_level = stem + "_map" if stem else None
|
||
|
||
players_out = []
|
||
for p in d.get("Players", []):
|
||
player = {
|
||
"id": p["PlayerID"],
|
||
"name": p.get("Name", ""),
|
||
"team": p.get("Team", 0),
|
||
}
|
||
clan = p.get("ClanTag", "")
|
||
if clan:
|
||
player["clan"] = clan
|
||
players_out.append(player)
|
||
|
||
ground_entities = [e for e in d.get("Entities", [])
|
||
if e.get("PlayerID", 0) != 0
|
||
and e["ModelName"].startswith("tankModels/")
|
||
and e.get("Path")]
|
||
|
||
entities_out = []
|
||
for e in d.get("Entities", []):
|
||
path = e.get("Path", [])
|
||
if not path:
|
||
continue
|
||
pid = e.get("PlayerID", 0)
|
||
etype = _entity_type(e["ModelName"])
|
||
|
||
if pid == 0 and etype != "drone":
|
||
continue
|
||
|
||
internal = e["ModelName"].split("/")[-1]
|
||
vehicle_name = _translate_vehicle(internal)
|
||
|
||
if etype == "drone":
|
||
drone_team = _resolve_drone_team(e, ground_entities, players_by_id)
|
||
else:
|
||
drone_team = None
|
||
|
||
sampled = _subsample_path(path)
|
||
path_out = [{"t": round(pt["Time"]), "x": round(pt["X"], 1),
|
||
"z": round(pt["Z"], 1), "y": round(pt.get("Y", 0), 1)}
|
||
for pt in sampled]
|
||
|
||
icon_key = _vehicle_icon_key(e["ModelName"])
|
||
ent = {
|
||
"playerId": pid,
|
||
"entityIndex": e.get("EntityIndex", 0),
|
||
"type": etype,
|
||
"iconKey": icon_key,
|
||
"vehicleName": vehicle_name,
|
||
"path": path_out,
|
||
}
|
||
if etype == "aircraft":
|
||
mini = _vehicle_mini_icon(e["ModelName"])
|
||
if mini:
|
||
ent["miniIcon"] = mini
|
||
if drone_team is not None:
|
||
ent["droneTeam"] = drone_team
|
||
entities_out.append(ent)
|
||
|
||
kills_out = []
|
||
for k in d.get("Kills", []):
|
||
kill = {
|
||
"time": round(k.get("Time", 0)),
|
||
"victimId": k.get("VictimID", 0),
|
||
"killerId": k.get("KillerID", 0),
|
||
"victimEntityIndex": k.get("VictimEntityIndex", 0),
|
||
"weapon": _translate_weapon(k.get("Weapon", "")),
|
||
}
|
||
vp = k.get("VictimPosition")
|
||
if vp:
|
||
kill["victimPos"] = {"x": round(vp.get("X", 0), 1), "z": round(vp.get("Z", 0), 1)}
|
||
kp = k.get("KillerPosition")
|
||
if kp:
|
||
kill["killerPos"] = {"x": round(kp.get("X", 0), 1), "z": round(kp.get("Z", 0), 1)}
|
||
km = k.get("KillerModel", "")
|
||
if km:
|
||
kill["killerVehicle"] = _translate_vehicle(km.split("/")[-1])
|
||
vm = k.get("VictimModel", "")
|
||
if vm:
|
||
kill["victimVehicle"] = _translate_vehicle(vm.split("/")[-1])
|
||
kills_out.append(kill)
|
||
|
||
damages_out = []
|
||
for dm in d.get("DamageReports", []):
|
||
damages_out.append({
|
||
"time": round(dm.get("Time", 0)),
|
||
"offenderId": dm.get("OffenderID", 0),
|
||
"offendedId": dm.get("OffendedID", 0),
|
||
})
|
||
|
||
out = {
|
||
"teamWon": team_won,
|
||
"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,
|
||
"damages": damages_out,
|
||
}
|
||
if tank_map_coords:
|
||
out["tankMapCoords"] = tank_map_coords
|
||
if map_coords and full_map_level:
|
||
out["mapCoords"] = map_coords
|
||
out["fullMapLevel"] = full_map_level
|
||
return out
|
||
|
||
|
||
# ── Main (CLI wrapper) ─────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
"""CLI entry point: render a replay JSON to MP4, or export a slim viewer JSON.
|
||
|
||
Output mode is selected by the output file extension: `.json` → json export,
|
||
anything else → mp4 render. Supports --profile for cProfile hotspot analysis.
|
||
"""
|
||
import argparse
|
||
parser = argparse.ArgumentParser(description="Render replay_data JSON to MP4")
|
||
parser.add_argument("replay", nargs="?", help="Path to replay_data .json")
|
||
parser.add_argument("out", nargs="?", help="Output .mp4 path")
|
||
parser.add_argument("--fps", type=int, default=FPS)
|
||
parser.add_argument("--speed", type=float, default=SPEED)
|
||
parser.add_argument("--workers", type=int, default=N_WORKERS)
|
||
parser.add_argument("--profile", action="store_true",
|
||
help="Run with cProfile and print top 40 hotspots")
|
||
args = parser.parse_args()
|
||
|
||
if args.replay:
|
||
replay_path = Path(args.replay)
|
||
else:
|
||
candidates = sorted(REPLAYS_DIR.glob("*/replay_data.json.gz"))
|
||
if not candidates:
|
||
candidates = sorted(REPLAYS_DIR.glob("*.json"))
|
||
if not candidates:
|
||
sys.exit(f"No replay .json files in {REPLAYS_DIR}")
|
||
replay_path = candidates[0]
|
||
|
||
out_path = Path(args.out) if args.out else replay_path.parent / "replay_video.mp4"
|
||
|
||
if out_path.suffix.lower() == ".json":
|
||
data = export_replay_json(replay_path)
|
||
raw = json.dumps(data, separators=(",", ":"))
|
||
out_path.write_text(raw, encoding="utf-8")
|
||
print(f"Exported {len(raw):,} bytes to {out_path}")
|
||
return
|
||
|
||
print(f"Input : {replay_path}")
|
||
print(f"Output : {out_path}")
|
||
print(f"Settings : {args.fps}fps {args.speed:.0f}× {args.workers} threads")
|
||
|
||
d = load_gob_file(replay_path)
|
||
|
||
if args.profile:
|
||
import cProfile
|
||
import pstats
|
||
import io
|
||
import time
|
||
|
||
# Phase 1: profile just the prep (everything before frame loop)
|
||
# We do this by profiling render_gob with 0 workers trick — not feasible,
|
||
# so profile the whole thing and the stats will show us.
|
||
print("\n=== PROFILING ===\n")
|
||
t0 = time.perf_counter()
|
||
pr = cProfile.Profile()
|
||
pr.enable()
|
||
render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers)
|
||
pr.disable()
|
||
wall = time.perf_counter() - t0
|
||
|
||
print(f"\n{'='*70}")
|
||
print(f"Total wall time: {wall:.2f}s")
|
||
print(f"{'='*70}\n")
|
||
|
||
s = io.StringIO()
|
||
ps = pstats.Stats(pr, stream=s)
|
||
ps.strip_dirs().sort_stats("cumulative")
|
||
ps.print_stats(40)
|
||
print(s.getvalue())
|
||
|
||
s2 = io.StringIO()
|
||
ps2 = pstats.Stats(pr, stream=s2)
|
||
ps2.strip_dirs().sort_stats("tottime")
|
||
ps2.print_stats(40)
|
||
print("\n--- Sorted by total time ---\n")
|
||
print(s2.getvalue())
|
||
else:
|
||
render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|