update renderer to have cap status also tickets (#1325)

This commit is contained in:
NotSoToothless
2026-06-14 22:36:06 -07:00
committed by GitHub
parent 4b75ce1533
commit deb4e0fb12
4 changed files with 355 additions and 4 deletions
+168 -2
View File
@@ -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
View File
@@ -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:
+133
View File
@@ -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);
+20
View File
@@ -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;