update renderer to have cap status also tickets (#1325)
This commit is contained in:
+168
-2
@@ -32,7 +32,7 @@ import numpy as np
|
|||||||
|
|
||||||
from . import SHARED_DIR
|
from . import SHARED_DIR
|
||||||
from .utils import REPLAYS_DIR
|
from .utils import REPLAYS_DIR
|
||||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from data_parser import (
|
from data_parser import (
|
||||||
@@ -519,6 +519,8 @@ class VideoCtx:
|
|||||||
# Kill/damage target icons
|
# Kill/damage target icons
|
||||||
kill_target_spr: Sprite | None = None
|
kill_target_spr: Sprite | None = None
|
||||||
dmg_target_spr: Sprite | None = None
|
dmg_target_spr: Sprite | None = None
|
||||||
|
# Dynamic capture-state fills (per-cap, per-frame); empty for pre-v2 replays
|
||||||
|
cap_overlays: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ── Map / LevelDef load ────────────────────────────────────────────────────────
|
# ── Map / LevelDef load ────────────────────────────────────────────────────────
|
||||||
@@ -1088,6 +1090,12 @@ def _map_name_candidates(level_path: str, level_def: dict | None,
|
|||||||
seen.add(name)
|
seen.add(name)
|
||||||
out.append(name)
|
out.append(name)
|
||||||
|
|
||||||
|
# The canonical ground map is "<level stem>_tankmap" — this is exactly what the
|
||||||
|
# web viewer requests (/api/match/minimap/<stem>), so try it first to stay in
|
||||||
|
# sync with the canvas. customLevelMap points at the full/thumbnail map and
|
||||||
|
# must not win over the real tankmap for a ground render.
|
||||||
|
add(f"{stem}_tankmap")
|
||||||
|
|
||||||
if isinstance(level_def, dict):
|
if isinstance(level_def, dict):
|
||||||
tank_custom = level_def.get("customLevelTankMap", "")
|
tank_custom = level_def.get("customLevelTankMap", "")
|
||||||
if isinstance(tank_custom, str) and tank_custom:
|
if isinstance(tank_custom, str) and tank_custom:
|
||||||
@@ -1328,6 +1336,110 @@ def _draw_capture_areas(img: Image.Image, capture_areas: list[dict],
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Capture-state fill (dynamic, per-frame) ────────────────────────────────────
|
||||||
|
# Colors match the web canvas (RC.WIN / RC.LOSE).
|
||||||
|
CAP_FILL_WIN = (0, 200, 0)
|
||||||
|
CAP_FILL_LOSE = (220, 30, 30)
|
||||||
|
CAP_FILL_ALPHA = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _precompute_cap_overlays(capture_areas: list[dict], zones: dict, winner_slot: int,
|
||||||
|
frame_times: np.ndarray, c0: list[float], c1: list[float],
|
||||||
|
canvas: int) -> list[dict]:
|
||||||
|
"""Build per-cap, per-frame fill data from the Spectra zone `cap` timelines.
|
||||||
|
|
||||||
|
`cap` sign is the owning team slot (positive == slot 2, negative == slot 1);
|
||||||
|
a cap is the winner's when that slot == ``winner_slot``. capture_areas is
|
||||||
|
index-mapped to
|
||||||
|
zone letters A/B/C…; each overlay carries the diamond outline (for clipping),
|
||||||
|
center, and per-frame fill fraction + winner/loser flag.
|
||||||
|
"""
|
||||||
|
overlays: list[dict] = []
|
||||||
|
if not capture_areas or not isinstance(zones, dict) or not zones:
|
||||||
|
return overlays
|
||||||
|
x0, z0 = float(c0[0]), float(c0[1])
|
||||||
|
x1, z1 = float(c1[0]), float(c1[1])
|
||||||
|
xr, zr = x1 - x0, z1 - z0
|
||||||
|
if xr == 0 or zr == 0:
|
||||||
|
return overlays
|
||||||
|
labels = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
for idx, cap in enumerate(capture_areas):
|
||||||
|
letter = labels[idx] if idx < len(labels) else str(idx + 1)
|
||||||
|
series = zones.get(letter)
|
||||||
|
if not series:
|
||||||
|
continue
|
||||||
|
ts = np.array([float(p[0]) for p in series], dtype=np.float64)
|
||||||
|
vs = np.array([float(p[1]) for p in series], dtype=np.float64)
|
||||||
|
order = np.argsort(ts)
|
||||||
|
ts, vs = ts[order], vs[order]
|
||||||
|
# Zero-order hold: a cap holds its last recorded value until the next
|
||||||
|
# change. Samples are only emitted when the value changes, so linear
|
||||||
|
# interpolation across the long initial [0,0]→first-change gap would show
|
||||||
|
# a false early fill before any player has reached the point.
|
||||||
|
idx = np.clip(np.searchsorted(ts, frame_times, side="right") - 1, 0, len(vs) - 1)
|
||||||
|
val = vs[idx]
|
||||||
|
frac = np.clip(np.abs(val) / 100.0, 0.0, 1.0).astype(np.float32)
|
||||||
|
# cap sign is the owning team slot: negative == slot 1, positive == slot 2
|
||||||
|
# (verified against unit occupancy in the zone).
|
||||||
|
owner_slot = np.where(val > 0, 2, 1)
|
||||||
|
is_winner = (owner_slot == winner_slot)
|
||||||
|
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 = _cap_outline_points_px(cap, x0, z0, xr, zr, canvas)
|
||||||
|
rp = 0
|
||||||
|
if len(outline) < 3:
|
||||||
|
outline = []
|
||||||
|
rr = max(0.0, float(cap.get("radius", 0.0)))
|
||||||
|
rp = max(8, int(round(rr * ((canvas / xr + canvas / zr) * 0.5))))
|
||||||
|
overlays.append({
|
||||||
|
"cx": int(px), "cy": int(py), "rp": int(rp),
|
||||||
|
"outline": outline, "frac": frac, "is_winner": is_winner,
|
||||||
|
})
|
||||||
|
return overlays
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_cap_fill_np(buf: np.ndarray, overlay: dict, fi: int) -> None:
|
||||||
|
"""Alpha-composite a single cap's radial (pie) fill into the frame buffer."""
|
||||||
|
frac = float(overlay["frac"][fi])
|
||||||
|
if frac <= 0.01:
|
||||||
|
return
|
||||||
|
rgb = CAP_FILL_WIN if bool(overlay["is_winner"][fi]) else CAP_FILL_LOSE
|
||||||
|
cx, cy = overlay["cx"], overlay["cy"]
|
||||||
|
outline = overlay["outline"]
|
||||||
|
if outline:
|
||||||
|
xs = [p[0] for p in outline]
|
||||||
|
ys = [p[1] for p in outline]
|
||||||
|
minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
|
||||||
|
else:
|
||||||
|
rp = overlay["rp"]
|
||||||
|
minx, maxx, miny, maxy = cx - rp, cx + rp, cy - rp, cy + rp
|
||||||
|
minx = max(0, minx)
|
||||||
|
miny = max(0, miny)
|
||||||
|
maxx = min(buf.shape[1] - 1, maxx)
|
||||||
|
maxy = min(buf.shape[0] - 1, maxy)
|
||||||
|
w, h = maxx - minx, maxy - miny
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return
|
||||||
|
R = max(w, h)
|
||||||
|
pie = Image.new("L", (w, h), 0)
|
||||||
|
end = -90.0 + 360.0 * frac
|
||||||
|
ImageDraw.Draw(pie).pieslice(
|
||||||
|
[cx - minx - R, cy - miny - R, cx - minx + R, cy - miny + R],
|
||||||
|
-90.0, end, fill=255)
|
||||||
|
if outline:
|
||||||
|
poly = Image.new("L", (w, h), 0)
|
||||||
|
ImageDraw.Draw(poly).polygon([(px - minx, py - miny) for px, py in outline], fill=255)
|
||||||
|
pie = ImageChops.multiply(pie, poly)
|
||||||
|
mask = (np.asarray(pie, dtype=np.float32) / 255.0) * CAP_FILL_ALPHA
|
||||||
|
if not mask.any():
|
||||||
|
return
|
||||||
|
region = buf[miny:maxy, minx:maxx].astype(np.float32)
|
||||||
|
color = np.array(rgb, dtype=np.float32)
|
||||||
|
m = mask[:, :, None]
|
||||||
|
buf[miny:maxy, minx:maxx] = (region * (1.0 - m) + color * m).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
def load_map_image(level_path: str, level_def: dict | None,
|
def load_map_image(level_path: str, level_def: dict | None,
|
||||||
battle_type: str = "", level_settings_path: str = "",
|
battle_type: str = "", level_settings_path: str = "",
|
||||||
base_coords: tuple[list[float], list[float]] | None = None,
|
base_coords: tuple[list[float], list[float]] | None = None,
|
||||||
@@ -2322,6 +2434,11 @@ def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx,
|
|||||||
"""
|
"""
|
||||||
np.copyto(buf, ctx.bg_arr)
|
np.copyto(buf, ctx.bg_arr)
|
||||||
|
|
||||||
|
# Dynamic capture-state fills (drawn on the map, beneath players)
|
||||||
|
if ctx.cap_overlays:
|
||||||
|
for ov in ctx.cap_overlays:
|
||||||
|
_draw_cap_fill_np(buf, ov, fi)
|
||||||
|
|
||||||
cv = ctx.canvas
|
cv = ctx.canvas
|
||||||
# Ground trails
|
# Ground trails
|
||||||
draw_all_trails_np(buf, fi, ctx.px_all, ctx.py_all, trail_f, ctx.trail_colors_arr,
|
draw_all_trails_np(buf, fi, ctx.px_all, ctx.py_all, trail_f, ctx.trail_colors_arr,
|
||||||
@@ -2753,6 +2870,11 @@ def render_gob(
|
|||||||
print("not found")
|
print("not found")
|
||||||
raise ValueError(f"No local tankmap image for level '{level}'")
|
raise ValueError(f"No local tankmap image for level '{level}'")
|
||||||
map_img = _draw_capture_areas(map_img, capture_areas, tc0, tc1)
|
map_img = _draw_capture_areas(map_img, capture_areas, tc0, tc1)
|
||||||
|
cap_overlays = _precompute_cap_overlays(
|
||||||
|
capture_areas, d.get("Zones", {}), _to_int(d.get("WinnerSlot"), 0),
|
||||||
|
frame_times, tc0, tc1, canvas)
|
||||||
|
if cap_overlays:
|
||||||
|
print(f"Cap state : {len(cap_overlays)} zones with live ownership")
|
||||||
print("ok")
|
print("ok")
|
||||||
bg = make_bg(map_img)
|
bg = make_bg(map_img)
|
||||||
|
|
||||||
@@ -2900,6 +3022,7 @@ def render_gob(
|
|||||||
air_headings=air_headings,
|
air_headings=air_headings,
|
||||||
kill_target_spr=kill_target,
|
kill_target_spr=kill_target,
|
||||||
dmg_target_spr=dmg_target,
|
dmg_target_spr=dmg_target,
|
||||||
|
cap_overlays=cap_overlays,
|
||||||
)
|
)
|
||||||
|
|
||||||
shared = (shadow_color, trail_f)
|
shared = (shadow_color, trail_f)
|
||||||
@@ -3133,6 +3256,23 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
|||||||
difficulty = str(replay.get("difficulty") or "")
|
difficulty = str(replay.get("difficulty") or "")
|
||||||
battle_type = mission_mode or difficulty
|
battle_type = mission_mode or difficulty
|
||||||
|
|
||||||
|
# Zones (positive cap == team slot 1) and tickets (keyed by slot) are kept in
|
||||||
|
# their original slot space; readers compare the owning slot to WinnerSlot.
|
||||||
|
# center/radius are dropped — geometry comes from the mission .blk.
|
||||||
|
zones_src = replay.get("zones") or {}
|
||||||
|
tickets_src = replay.get("tickets") or {}
|
||||||
|
zones_out = {
|
||||||
|
letter: zdata.get("cap", [])
|
||||||
|
for letter, zdata in zones_src.items()
|
||||||
|
if isinstance(zdata, dict)
|
||||||
|
} if isinstance(zones_src, dict) else {}
|
||||||
|
tickets_out = tickets_src if isinstance(tickets_src, dict) else {}
|
||||||
|
team_slot_names = {}
|
||||||
|
if winner_team:
|
||||||
|
team_slot_names[str(winner_team)] = winner_tag
|
||||||
|
if loser_team:
|
||||||
|
team_slot_names[str(loser_team)] = loser_tag
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"SessionID": _to_int(replay.get("_id") or replay.get("id"), 0),
|
"SessionID": _to_int(replay.get("_id") or replay.get("id"), 0),
|
||||||
"TeamWon": winner_team,
|
"TeamWon": winner_team,
|
||||||
@@ -3145,6 +3285,10 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"Entities": entities_out,
|
"Entities": entities_out,
|
||||||
"Kills": kills_out,
|
"Kills": kills_out,
|
||||||
"DamageReports": damages_out,
|
"DamageReports": damages_out,
|
||||||
|
"Zones": zones_out,
|
||||||
|
"Tickets": tickets_out,
|
||||||
|
"WinnerSlot": winner_team,
|
||||||
|
"TeamSlotNames": team_slot_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3267,6 +3411,12 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
|||||||
"Afire": bool(dmg.get("afire", False)),
|
"Afire": bool(dmg.get("afire", False)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Zones (letter -> cap timeline, positive == slot 1), tickets (slot -> timeline),
|
||||||
|
# the winning slot, and slot -> name all stay in slot space for the readers.
|
||||||
|
zones_out = replay.get("zones") if isinstance(replay.get("zones"), dict) else {}
|
||||||
|
tickets_out = replay.get("tickets") if isinstance(replay.get("tickets"), dict) else {}
|
||||||
|
team_slot_names = replay.get("team_slot_names") if isinstance(replay.get("team_slot_names"), dict) else {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"SessionID": _to_int(replay.get("session_id_dec") or replay.get("session_id_hex"), 0),
|
"SessionID": _to_int(replay.get("session_id_dec") or replay.get("session_id_hex"), 0),
|
||||||
"TeamWon": winner_team,
|
"TeamWon": winner_team,
|
||||||
@@ -3279,6 +3429,10 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
|||||||
"Entities": entities_out,
|
"Entities": entities_out,
|
||||||
"Kills": kills_out,
|
"Kills": kills_out,
|
||||||
"DamageReports": damages_out,
|
"DamageReports": damages_out,
|
||||||
|
"Zones": zones_out,
|
||||||
|
"Tickets": tickets_out,
|
||||||
|
"WinnerSlot": _to_int(replay.get("winner_team_slot"), 0),
|
||||||
|
"TeamSlotNames": team_slot_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3466,7 +3620,7 @@ def export_replay_json(replay_path: Path) -> dict:
|
|||||||
"name": p.get("Name", ""),
|
"name": p.get("Name", ""),
|
||||||
"team": p.get("Team", 0),
|
"team": p.get("Team", 0),
|
||||||
}
|
}
|
||||||
clan = p.get("ClanTag", "")
|
clan = p.get("ClanTag") or p.get("Clan") or ""
|
||||||
if clan:
|
if clan:
|
||||||
player["clan"] = clan
|
player["clan"] = clan
|
||||||
players_out.append(player)
|
players_out.append(player)
|
||||||
@@ -3548,6 +3702,14 @@ def export_replay_json(replay_path: Path) -> dict:
|
|||||||
"offendedId": dm.get("OffendedID", 0),
|
"offendedId": dm.get("OffendedID", 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Capture-zone ownership + ticket timelines (Spectra v2+); empty for older
|
||||||
|
# replays. Kept in slot space: cap sign / ticket keys / team names are all
|
||||||
|
# keyed by team slot, and `winnerSlot` says which slot won.
|
||||||
|
capture_state = d.get("Zones") if isinstance(d.get("Zones"), dict) else {}
|
||||||
|
tickets_series = d.get("Tickets") if isinstance(d.get("Tickets"), dict) else {}
|
||||||
|
team_names = d.get("TeamSlotNames") if isinstance(d.get("TeamSlotNames"), dict) else {}
|
||||||
|
winner_slot = d.get("WinnerSlot", 0)
|
||||||
|
|
||||||
out = {
|
out = {
|
||||||
"teamWon": team_won,
|
"teamWon": team_won,
|
||||||
"mission": {
|
"mission": {
|
||||||
@@ -3558,6 +3720,10 @@ def export_replay_json(replay_path: Path) -> dict:
|
|||||||
},
|
},
|
||||||
"levelCoords": level_coords,
|
"levelCoords": level_coords,
|
||||||
"captureAreas": capture_areas,
|
"captureAreas": capture_areas,
|
||||||
|
"captureState": capture_state,
|
||||||
|
"tickets": tickets_series,
|
||||||
|
"teamNames": team_names,
|
||||||
|
"winnerSlot": winner_slot,
|
||||||
"players": players_out,
|
"players": players_out,
|
||||||
"entities": entities_out,
|
"entities": entities_out,
|
||||||
"kills": kills_out,
|
"kills": kills_out,
|
||||||
|
|||||||
+34
-2
@@ -96,8 +96,8 @@ def replay_data_path(session_id: str | int) -> Path:
|
|||||||
# When enabled, the unmodified Spectra payload is stored next to the transformed
|
# When enabled, the unmodified Spectra payload is stored next to the transformed
|
||||||
# replay (as RAW_replay_data.json.gz) so it can be pulled and re-processed later.
|
# replay (as RAW_replay_data.json.gz) so it can be pulled and re-processed later.
|
||||||
# Toggle off by setting SRE_STORE_RAW_REPLAY=0 in the environment.
|
# Toggle off by setting SRE_STORE_RAW_REPLAY=0 in the environment.
|
||||||
STORE_RAW_REPLAY = os.getenv("SRE_STORE_RAW_REPLAY", "1").strip().lower() not in (
|
STORE_RAW_REPLAY = os.getenv("SRE_STORE_RAW_REPLAY", "0").strip().lower() in (
|
||||||
"0", "false", "no", "off", "",
|
"1", "true", "yes", "on",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1273,6 +1273,10 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
|
|
||||||
winner_players: List[Dict[str, Any]] = []
|
winner_players: List[Dict[str, Any]] = []
|
||||||
loser_players: List[Dict[str, Any]] = []
|
loser_players: List[Dict[str, Any]] = []
|
||||||
|
# Original Spectra team slots of the winner/loser, captured from the same
|
||||||
|
# tag match below — used to normalise zones/tickets into winner-first space.
|
||||||
|
winner_team_slot: Optional[str] = None
|
||||||
|
loser_team_slot: Optional[str] = None
|
||||||
|
|
||||||
for uid_str, pdata in players_dict.items():
|
for uid_str, pdata in players_dict.items():
|
||||||
try:
|
try:
|
||||||
@@ -1307,8 +1311,12 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
# Assign to winner or loser by comparing tag
|
# Assign to winner or loser by comparing tag
|
||||||
if tag == winner_winged:
|
if tag == winner_winged:
|
||||||
winner_players.append(player_entry)
|
winner_players.append(player_entry)
|
||||||
|
if winner_team_slot is None:
|
||||||
|
winner_team_slot = str(pdata.get("team"))
|
||||||
elif tag == loser_winged:
|
elif tag == loser_winged:
|
||||||
loser_players.append(player_entry)
|
loser_players.append(player_entry)
|
||||||
|
if loser_team_slot is None:
|
||||||
|
loser_team_slot = str(pdata.get("team"))
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
logging.warning(f"Skipping bad player UID {uid_str}: {e}")
|
logging.warning(f"Skipping bad player UID {uid_str}: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1469,6 +1477,26 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
session_id_hex = ""
|
session_id_hex = ""
|
||||||
|
|
||||||
|
# Capture-zone ownership + ticket timelines (Spectra v2+), kept in their
|
||||||
|
# original team-slot space: cap sign is the owning slot (positive == slot
|
||||||
|
# "2", negative == slot "1"), and tickets stay keyed by slot ("1"/"2"). Readers decide
|
||||||
|
# win/loss by comparing the owning slot to `winner_team_slot` — no sign
|
||||||
|
# flipping or re-keying. center/radius are dropped (geometry comes from
|
||||||
|
# the mission .blk in resolve_capture_areas()).
|
||||||
|
raw_zones = replay.get("zones") or {}
|
||||||
|
raw_tickets = replay.get("tickets") or {}
|
||||||
|
zones = {
|
||||||
|
letter: zdata.get("cap", [])
|
||||||
|
for letter, zdata in raw_zones.items()
|
||||||
|
if isinstance(zdata, dict)
|
||||||
|
} if isinstance(raw_zones, dict) else {}
|
||||||
|
tickets = raw_tickets if isinstance(raw_tickets, dict) else {}
|
||||||
|
team_slot_names = {}
|
||||||
|
if winner_team_slot:
|
||||||
|
team_slot_names[winner_team_slot] = winner_squadron
|
||||||
|
if loser_team_slot:
|
||||||
|
team_slot_names[loser_team_slot] = loser_squadron
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"winning_team_squadron": winner_squadron,
|
"winning_team_squadron": winner_squadron,
|
||||||
"losing_team_squadron": loser_squadron,
|
"losing_team_squadron": loser_squadron,
|
||||||
@@ -1509,6 +1537,10 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
"mission_path": replay.get("mission_path"),
|
"mission_path": replay.get("mission_path"),
|
||||||
"difficulty": replay.get("difficulty"),
|
"difficulty": replay.get("difficulty"),
|
||||||
"type": replay.get("type", ""),
|
"type": replay.get("type", ""),
|
||||||
|
"zones": zones,
|
||||||
|
"tickets": tickets,
|
||||||
|
"winner_team_slot": winner_team_slot,
|
||||||
|
"team_slot_names": team_slot_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const RC = {
|
|||||||
const RC_CAP_STROKE_PX = 3;
|
const RC_CAP_STROKE_PX = 3;
|
||||||
const RC_CAP_ICON_ALPHA = 0.35;
|
const RC_CAP_ICON_ALPHA = 0.35;
|
||||||
const RC_CAP_ICON_MIN_SIZE = 10;
|
const RC_CAP_ICON_MIN_SIZE = 10;
|
||||||
|
const RC_CAP_FILL_ALPHA = 0.5;
|
||||||
|
|
||||||
function replayT(key) {
|
function replayT(key) {
|
||||||
return (window.__t && window.__t(key)) || key;
|
return (window.__t && window.__t(key)) || key;
|
||||||
@@ -44,6 +45,13 @@ class ReplayCanvas {
|
|||||||
this._airCoords = data.mapCoords || null;
|
this._airCoords = data.mapCoords || null;
|
||||||
this._fullMapLevel = data.fullMapLevel || null;
|
this._fullMapLevel = data.fullMapLevel || null;
|
||||||
this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : [];
|
this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : [];
|
||||||
|
// Dynamic capture-state + tickets timelines (Spectra v2+); null if absent
|
||||||
|
this._captureState = (data.captureState && typeof data.captureState === 'object'
|
||||||
|
&& Object.keys(data.captureState).length) ? data.captureState : null;
|
||||||
|
this._tickets = (data.tickets && typeof data.tickets === 'object'
|
||||||
|
&& Object.keys(data.tickets).length) ? data.tickets : null;
|
||||||
|
this._teamNames = (data.teamNames && typeof data.teamNames === 'object') ? data.teamNames : {};
|
||||||
|
this._winnerSlot = Number(data.winnerSlot) || 0; // winning team slot (1/2)
|
||||||
this._mode = 'ground';
|
this._mode = 'ground';
|
||||||
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
||||||
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
||||||
@@ -179,6 +187,9 @@ class ReplayCanvas {
|
|||||||
const center = document.createElement('div');
|
const center = document.createElement('div');
|
||||||
center.className = 'rc-center';
|
center.className = 'rc-center';
|
||||||
|
|
||||||
|
// Tickets meter above the battle view (its top aligns with the team panels)
|
||||||
|
this._buildTicketsBar(center);
|
||||||
|
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
this.canvas.width = this.canvasSize;
|
this.canvas.width = this.canvasSize;
|
||||||
this.canvas.height = this.canvasSize;
|
this.canvas.height = this.canvasSize;
|
||||||
@@ -233,6 +244,7 @@ class ReplayCanvas {
|
|||||||
this._updatePanelDeathStates();
|
this._updatePanelDeathStates();
|
||||||
this._updateBattleLog();
|
this._updateBattleLog();
|
||||||
this.render();
|
this.render();
|
||||||
|
this._updateTicketsBar(this.currentTime);
|
||||||
});
|
});
|
||||||
controls.querySelectorAll('.rc-sp').forEach(btn => {
|
controls.querySelectorAll('.rc-sp').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -651,6 +663,125 @@ class ReplayCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dynamic capture state ──────────────────────────────────────────────
|
||||||
|
_capSeriesForIndex(i) {
|
||||||
|
if (!this._captureState) return null;
|
||||||
|
const letter = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i];
|
||||||
|
return (letter && this._captureState[letter]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_interpSeries(series, t, step = false) {
|
||||||
|
if (!series || !series.length) return null;
|
||||||
|
if (t <= series[0][0]) return series[0][1];
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
if (t >= last[0]) return last[1];
|
||||||
|
for (let i = 1; i < series.length; i++) {
|
||||||
|
if (series[i][0] >= t) {
|
||||||
|
// Zero-order hold for caps: hold last value until the next change
|
||||||
|
// (avoids a false early fill across the initial [0,0] gap).
|
||||||
|
if (step) return series[i - 1][1];
|
||||||
|
const t0 = series[i - 1][0], v0 = series[i - 1][1];
|
||||||
|
const t1 = series[i][0], v1 = series[i][1];
|
||||||
|
const f = t1 === t0 ? 0 : (t - t0) / (t1 - t0);
|
||||||
|
return v0 + (v1 - v0) * f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
_capCircleRadiusPx(cap) {
|
||||||
|
const rr = Math.max(0, Number(cap?.radius || 0));
|
||||||
|
let rp = Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5));
|
||||||
|
return Math.max(8, rp);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawCaptureState(ctx, t) {
|
||||||
|
if (this._mode !== 'ground' || !this._captureState) return;
|
||||||
|
for (let i = 0; i < this.captureAreas.length; i++) {
|
||||||
|
const series = this._capSeriesForIndex(i);
|
||||||
|
if (!series) continue;
|
||||||
|
const val = this._interpSeries(series, t, true); // step-hold
|
||||||
|
if (val === null) continue;
|
||||||
|
const frac = Math.min(1, Math.abs(val) / 100);
|
||||||
|
if (frac <= 0.01) continue;
|
||||||
|
// cap sign is the owning team slot (positive == slot 2, negative == slot 1)
|
||||||
|
const ownerSlot = val > 0 ? 2 : 1;
|
||||||
|
const color = ownerSlot === this._winnerSlot ? RC.WIN : RC.LOSE;
|
||||||
|
const cap = this.captureAreas[i];
|
||||||
|
const [cx, cy] = this.worldToPixel(Number(cap?.x || 0), Number(cap?.z || 0));
|
||||||
|
const outline = this._capOutlinePoints(cap);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
if (outline.length >= 3) {
|
||||||
|
ctx.moveTo(outline[0][0], outline[0][1]);
|
||||||
|
for (let p = 1; p < outline.length; p++) ctx.lineTo(outline[p][0], outline[p][1]);
|
||||||
|
ctx.closePath();
|
||||||
|
} else {
|
||||||
|
ctx.arc(cx, cy, this._capCircleRadiusPx(cap), 0, Math.PI * 2);
|
||||||
|
}
|
||||||
|
ctx.clip();
|
||||||
|
// Radial (pie) sweep from top, clockwise, proportional to capture %
|
||||||
|
const start = -Math.PI / 2;
|
||||||
|
const end = start + 2 * Math.PI * frac;
|
||||||
|
ctx.globalAlpha = RC_CAP_FILL_ALPHA;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.arc(cx, cy, this.canvasSize, start, end);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tickets meter ──────────────────────────────────────────────────────
|
||||||
|
_teamSlotName(slot) {
|
||||||
|
return (this._teamNames && this._teamNames[String(slot)])
|
||||||
|
|| (slot === this._winnerSlot ? 'Winner' : 'Loser');
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTicketsBar(center) {
|
||||||
|
if (!this._tickets) return;
|
||||||
|
const winSlot = this._winnerSlot || 1;
|
||||||
|
const loseSlot = winSlot === 1 ? 2 : 1;
|
||||||
|
this._tkWinSlot = winSlot;
|
||||||
|
this._tkLoseSlot = loseSlot;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'rc-tickets';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="rc-tk-side rc-tk-win">
|
||||||
|
<span class="rc-tk-name">${this._esc(this._teamSlotName(winSlot))}</span>
|
||||||
|
<span class="rc-tk-val rc-tk-val-win">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="rc-tk-track">
|
||||||
|
<div class="rc-tk-fill rc-tk-fill-win"></div>
|
||||||
|
<div class="rc-tk-fill rc-tk-fill-lose"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rc-tk-side rc-tk-lose">
|
||||||
|
<span class="rc-tk-val rc-tk-val-lose">0</span>
|
||||||
|
<span class="rc-tk-name">${this._esc(this._teamSlotName(loseSlot))}</span>
|
||||||
|
</div>`;
|
||||||
|
center.appendChild(bar);
|
||||||
|
this.ticketsBar = bar;
|
||||||
|
this._tkWinFill = bar.querySelector('.rc-tk-fill-win');
|
||||||
|
this._tkLoseFill = bar.querySelector('.rc-tk-fill-lose');
|
||||||
|
this._tkWinVal = bar.querySelector('.rc-tk-val-win');
|
||||||
|
this._tkLoseVal = bar.querySelector('.rc-tk-val-lose');
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTicketsBar(t) {
|
||||||
|
if (!this._tickets || !this.ticketsBar) return;
|
||||||
|
const w = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkWinSlot)], t) ?? 0));
|
||||||
|
const l = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkLoseSlot)], t) ?? 0));
|
||||||
|
const total = w + l;
|
||||||
|
const wPct = total > 0 ? (w / total) * 100 : 50;
|
||||||
|
this._tkWinFill.style.width = wPct.toFixed(1) + '%';
|
||||||
|
this._tkLoseFill.style.width = (100 - wPct).toFixed(1) + '%';
|
||||||
|
this._tkWinVal.textContent = w;
|
||||||
|
this._tkLoseVal.textContent = l;
|
||||||
|
}
|
||||||
|
|
||||||
async _loadEntityIcons() {
|
async _loadEntityIcons() {
|
||||||
this._iconCache = {};
|
this._iconCache = {};
|
||||||
this._iconDebug = {};
|
this._iconDebug = {};
|
||||||
@@ -774,6 +905,7 @@ class ReplayCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.render();
|
this.render();
|
||||||
|
this._updateTicketsBar(this.currentTime);
|
||||||
this._updateControls();
|
this._updateControls();
|
||||||
// Update panel death states + battle log every ~250ms
|
// Update panel death states + battle log every ~250ms
|
||||||
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
|
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
|
||||||
@@ -815,6 +947,7 @@ class ReplayCanvas {
|
|||||||
const t = this.currentTime;
|
const t = this.currentTime;
|
||||||
this._updateCanvasHighlight();
|
this._updateCanvasHighlight();
|
||||||
ctx.drawImage(this.mapCanvas, 0, 0);
|
ctx.drawImage(this.mapCanvas, 0, 0);
|
||||||
|
this._drawCaptureState(ctx, t);
|
||||||
this._drawTrails(ctx, t);
|
this._drawTrails(ctx, t);
|
||||||
this._drawDamageLines(ctx, t);
|
this._drawDamageLines(ctx, t);
|
||||||
this._drawKillLines(ctx, t);
|
this._drawKillLines(ctx, t);
|
||||||
|
|||||||
@@ -496,6 +496,26 @@
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
}
|
}
|
||||||
|
.rc-tickets {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
.rc-tk-side { display: flex; align-items: center; gap: 0.35rem; flex: 0 0 auto; min-width: 0; }
|
||||||
|
.rc-tk-lose { justify-content: flex-end; }
|
||||||
|
.rc-tk-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 8rem; }
|
||||||
|
.rc-tk-win .rc-tk-name { color: #5cdf5c; }
|
||||||
|
.rc-tk-lose .rc-tk-name { color: #e85555; }
|
||||||
|
.rc-tk-val { font-variant-numeric: tabular-nums; opacity: 0.85; min-width: 2.6rem; }
|
||||||
|
.rc-tk-win .rc-tk-val { text-align: right; }
|
||||||
|
.rc-tk-track { flex: 1 1 auto; display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: rgba(255,255,255,0.08); }
|
||||||
|
.rc-tk-fill { height: 100%; transition: width 0.1s linear; }
|
||||||
|
.rc-tk-fill-win { background: #2a8f2a; }
|
||||||
|
.rc-tk-fill-lose { background: #b22020; }
|
||||||
.rc-controls {
|
.rc-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user