453 lines
18 KiB
Python
453 lines
18 KiB
Python
"""TSS scoreboard renderer.
|
|
|
|
Generates the match-result image posted by autologging. Mirrors SREBOT's visual
|
|
language (blurred map background, vignette, two team columns, stat icons) but is
|
|
TSS-native: team names instead of squadron tags, the full per-player vehicle lineup
|
|
(unused dimmed) instead of a single vehicle, and per-player pvp_ratio instead of
|
|
squadron points.
|
|
|
|
Input is the flat model from ``transform.build_scoreboard_model`` — this module never
|
|
touches the raw feed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from PIL.Image import Resampling
|
|
|
|
from . import SHARED_DIR
|
|
from .wl import get_tss_standings
|
|
|
|
log = logging.getLogger("tssbot.scoreboard")
|
|
|
|
MAPS_DIR = SHARED_DIR / "MAPS"
|
|
ICON_BASE_DIR = SHARED_DIR / "ICONS"
|
|
VEHICLE_ICON_DIR = ICON_BASE_DIR / "VEHICLES"
|
|
TEXT_FONT_PATH = SHARED_DIR / "FONTS" / "arial_unicode_ms.otf"
|
|
|
|
# Colors
|
|
WIN_FILL = (60, 255, 60, 255)
|
|
LOSS_FILL = (255, 60, 60, 255)
|
|
DRAW_FILL = (255, 255, 0, 255)
|
|
NEUTRAL_FILL = (255, 255, 255, 255)
|
|
GREY_FILL = (200, 200, 200, 255)
|
|
NAME_FILL = (250, 227, 200, 255)
|
|
DIM_FILL = (150, 150, 150, 255)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Asset caching (mirrors SREBOT)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FONTS: dict[str, ImageFont.FreeTypeFont] = {}
|
|
|
|
|
|
def load_fonts(base_width: int) -> dict[str, ImageFont.FreeTypeFont]:
|
|
global _FONTS
|
|
if _FONTS:
|
|
return _FONTS
|
|
_FONTS = {
|
|
"title": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.04)),
|
|
"team": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.03)),
|
|
"sub": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.019)),
|
|
"body": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.020)),
|
|
"stat": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.022)),
|
|
"info": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.016)),
|
|
"small": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.013)),
|
|
"veh": ImageFont.truetype(str(TEXT_FONT_PATH), int(base_width * 0.016)),
|
|
}
|
|
return _FONTS
|
|
|
|
|
|
@lru_cache(maxsize=256)
|
|
def _load_icon_cached(path_str: str, size: tuple, alpha: int):
|
|
img = Image.open(path_str).convert("RGBA")
|
|
if size:
|
|
img = img.resize(size, Resampling.LANCZOS)
|
|
if alpha < 255:
|
|
a = img.getchannel("A").point(lambda v: int(v * alpha / 255))
|
|
img.putalpha(a)
|
|
return img
|
|
|
|
|
|
def load_icon(path: Path, size: Optional[tuple] = None, alpha: int = 255) -> Image.Image:
|
|
return _load_icon_cached(str(path), tuple(size) if size else None, alpha)
|
|
|
|
|
|
@lru_cache(maxsize=24)
|
|
def _load_map_cached(path_str: str, blur_radius: int):
|
|
from PIL import ImageFilter
|
|
img = Image.open(path_str).convert("RGBA")
|
|
return img.filter(ImageFilter.GaussianBlur(blur_radius))
|
|
|
|
|
|
def _make_vignette(width: int, height: int, base_alpha=140, max_alpha=175, power=4) -> Image.Image:
|
|
"""Radial alpha mask that darkens toward the edges (matches SREBOT's vignette).
|
|
|
|
Each axis is normalized independently so the mid-edges — not just the corners —
|
|
reach ``max_alpha``, giving the whole frame a subtle edge vignette.
|
|
"""
|
|
ys = np.linspace(-1, 1, height)[:, None]
|
|
xs = np.linspace(-1, 1, width)[None, :]
|
|
dist = np.clip(np.sqrt(xs ** 2 + ys ** 2), 0, 1)
|
|
band = base_alpha + (max_alpha - base_alpha) * (dist ** power)
|
|
return Image.fromarray(np.clip(band, 0, 255).astype(np.uint8), mode="L")
|
|
|
|
|
|
def _resolve_map_image(map_name: str) -> Optional[str]:
|
|
"""Match a TSS mission_name against SHARED/MAPS.
|
|
|
|
Tries the ``TSS_<Map>.png`` variant first, then falls back to the plain
|
|
``<Map>.jpg``. WT lobby names often arrive as e.g. "Gladiators 1x1 - Kursk" (or
|
|
2x2, 3x3, …); the "Gladiators" word and any "NxN" token are stripped so they
|
|
all collapse down to the real map name ("Kursk").
|
|
"""
|
|
# Drop any leading "[...]" tag.
|
|
clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_name)
|
|
# Strip WT "Gladiators"/"NxN" lobby noise, keeping only the real map name.
|
|
clean = re.sub(r"(?i)\bgladiators\b", "", clean)
|
|
clean = re.sub(r"\b\d+\s*[xX]\s*\d+\b", "", clean)
|
|
# Drop leftover separators (leading/trailing dashes, spaces), then spaces→_.
|
|
clean = clean.strip(" -").replace(" ", "_")
|
|
|
|
try:
|
|
candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()}
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
for target in (f"TSS_{clean}.png", f"{clean}.jpg"):
|
|
match = next((fn for fn in candidates if fn.lower() == target.lower()), None)
|
|
if match:
|
|
return str(MAPS_DIR / match)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Render
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _bar_fill(bar_color: str) -> tuple:
|
|
return {"win": WIN_FILL, "loss": LOSS_FILL, "draw": DRAW_FILL}.get(bar_color, NEUTRAL_FILL)
|
|
|
|
|
|
def _strip_platform(name: str) -> str:
|
|
return name.replace("@live", "").replace("@psn", "").strip()
|
|
|
|
|
|
def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: str) -> None:
|
|
map_name = model.get("map", "")
|
|
map_image_path = _resolve_map_image(map_name)
|
|
|
|
if map_image_path:
|
|
background = _load_map_cached(map_image_path, 2).copy()
|
|
else:
|
|
log.warning("[TSS-SB] No map image for %r; using flat background", map_name)
|
|
# Match the map image resolution (2560x1440) so the fixed 0.5 downscale yields
|
|
# the same 1280x720 output — otherwise no-bg renders come out smaller and look
|
|
# blown-up (fonts scale with bg_w) when a viewer upscales them to a common width.
|
|
background = Image.new("RGBA", (2560, 1440), (25, 28, 32, 255))
|
|
|
|
bg_w, bg_h = background.size
|
|
|
|
band = _make_vignette(bg_w, bg_h)
|
|
overlay = Image.new("RGBA", (bg_w, bg_h), (0, 0, 0, 0))
|
|
black = Image.new("RGBA", (bg_w, bg_h), (0, 0, 0, 255))
|
|
overlay = Image.composite(black, overlay, band)
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
fonts = load_fonts(bg_w)
|
|
BODY = int(bg_w * 0.020)
|
|
STAT = int(bg_w * 0.022)
|
|
|
|
# ── Top-right metadata: timestamp + session id
|
|
padding = 15
|
|
ts_epoch = int(model.get("utc_timestamp") or 0)
|
|
ts_text = datetime.fromtimestamp(ts_epoch, tz=timezone.utc).strftime("%H:%M:%S - %Y-%m-%d UTC")
|
|
sid_text = model.get("session_id", "")
|
|
for i, txt in enumerate((ts_text, sid_text)):
|
|
bbox = draw.textbbox((0, 0), txt, font=fonts["info"])
|
|
x = bg_w - (bbox[2] - bbox[0]) - padding - 10
|
|
y = padding + 10 + i * ((bbox[3] - bbox[1]) + 20)
|
|
draw.text((x, y), txt, font=fonts["info"], fill=GREY_FILL)
|
|
|
|
# ── Top-center titles: map name, then tournament · mode
|
|
y = 50
|
|
title = map_name or "Unknown Map"
|
|
tb = draw.textbbox((0, 0), title, font=fonts["title"])
|
|
draw.text(((bg_w - (tb[2] - tb[0])) // 2, y), title, font=fonts["title"], fill=NEUTRAL_FILL)
|
|
# Gap off the title's full glyph box (not just measured height) so the subtitle
|
|
# clears descenders instead of clipping into the map name.
|
|
y += int(bg_w * 0.04) + 28
|
|
|
|
sub_parts = [p for p in (model.get("tournament_name"), model.get("mission_mode")) if p]
|
|
if sub_parts:
|
|
sub = " · ".join(sub_parts)
|
|
sb = draw.textbbox((0, 0), sub, font=fonts["sub"])
|
|
draw.text(((bg_w - (sb[2] - sb[0])) // 2, y), sub, font=fonts["sub"], fill=GREY_FILL)
|
|
y += (sb[3] - sb[1]) + 10
|
|
|
|
y_start = y + 150
|
|
|
|
# ── Layout: two team columns + separator
|
|
x_start = 55
|
|
col_width = (bg_w - x_start * 2) // 2
|
|
|
|
sep_x = x_start + col_width
|
|
draw.line([(sep_x, y_start - 60), (sep_x, bg_h - 50)], fill=_bar_fill(bar_color), width=5)
|
|
|
|
teams = model.get("teams", [])
|
|
is_draw = model.get("is_draw", False)
|
|
winner = model.get("winner")
|
|
|
|
def draw_team(idx: int, team: dict, start_x: int, section_width: int):
|
|
flipped = idx == 1 # second team mirrored to the right
|
|
players = sorted(team.get("players", []), key=lambda p: int(p.get("score") or 0), reverse=True)
|
|
|
|
team_name = team.get("team_name", "Team")
|
|
if is_draw:
|
|
header_fill = DRAW_FILL
|
|
elif winner is not None and team_name == winner:
|
|
header_fill = WIN_FILL
|
|
else:
|
|
header_fill = LOSS_FILL
|
|
|
|
header_y = y_start - 90
|
|
nb = draw.textbbox((0, 0), team_name, font=fonts["team"])
|
|
name_w = nb[2] - nb[0]
|
|
name_x = start_x if not flipped else start_x + section_width - name_w
|
|
draw.text((name_x, header_y), team_name, font=fonts["team"], fill=header_fill)
|
|
|
|
# W/L slot — per-tournament record under the team name. Coloured exactly like
|
|
# SREBOT: green W · grey dash · red L · grey dash · win-rate% on a red→green
|
|
# gradient. Placed below the team name's full glyph box (descender-inclusive)
|
|
# so names with p/g/y descenders don't clip into the record line.
|
|
standings = get_tss_standings(team.get("team_id"), model.get("tournament_id"))
|
|
if standings:
|
|
wl_segs = _winloss_segments(standings)
|
|
seg_w = _segments_width(draw, wl_segs, fonts["sub"])
|
|
wl_x = start_x if not flipped else start_x + section_width - seg_w
|
|
# Sit halfway between the original (descender-clipping) baseline and a
|
|
# full descender-clear drop — enough to clear p/g/y, no more.
|
|
name_full_h = fonts["team"].getbbox("ApgjyQ")[3]
|
|
wl_y = header_y + ((nb[3] - nb[1] + 18) + (name_full_h + 10)) // 2
|
|
_draw_segments(draw, wl_x, wl_y, wl_segs, fonts["sub"])
|
|
|
|
# Stat columns (rating now lives inline next to the player name).
|
|
col_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"]
|
|
icon_map = {
|
|
"Air": ICON_BASE_DIR / "fighter_icon.png",
|
|
"Ground": ICON_BASE_DIR / "tank_icon.png",
|
|
"Assists": ICON_BASE_DIR / "assists_icon.png",
|
|
"Deaths": ICON_BASE_DIR / "deaths_icon.png",
|
|
"Caps": ICON_BASE_DIR / "cap_icon.png",
|
|
}
|
|
# Column CENTERS — a tight stat block packed against the center separator
|
|
# (like SRE). Center-anchoring keeps 1- and 4-digit values aligned. Team 2
|
|
# mirrors across its own center axis so the board is symmetric.
|
|
n_cols = len(col_labels)
|
|
gutter = section_width * 0.035 # small gap off the center separator
|
|
stat_area = section_width * 0.30 # tight column block
|
|
right_edge = start_x + section_width - gutter
|
|
step = stat_area / n_cols
|
|
centers = [right_edge - stat_area + step * (i + 0.5) for i in range(n_cols)]
|
|
if flipped:
|
|
axis = start_x + section_width / 2.0
|
|
centers = [2 * axis - c for c in centers]
|
|
col_center = {lab: int(c) for lab, c in zip(col_labels, centers)}
|
|
|
|
ICON_SIZE = int(STAT * 1.1)
|
|
head_icon_y = header_y + 72
|
|
for label in col_labels:
|
|
cx = col_center[label]
|
|
ico = icon_map.get(label)
|
|
if ico:
|
|
try:
|
|
img = load_icon(ico, (ICON_SIZE, ICON_SIZE))
|
|
overlay.paste(img, (int(cx - ICON_SIZE // 2), int(head_icon_y)), img)
|
|
except Exception:
|
|
pass
|
|
|
|
# Player rows. Layout per row:
|
|
# top line — player name + inline (rating) at the column edge; stats
|
|
# lower line — vehicle icons next to their translated names
|
|
body = fonts["body"]
|
|
small = fonts["veh"]
|
|
name_h = body.getbbox("Ag")[3]
|
|
small_h = small.getbbox("Ag")[3]
|
|
veh_icon = int(BODY * 1.6)
|
|
y_off = header_y + ICON_SIZE + 95
|
|
for p in players:
|
|
name = _strip_platform(p.get("fake_nick") or p.get("nick") or "")
|
|
units = [u for u in p.get("units", []) if u.get("used")]
|
|
row_h = name_h + 12 + veh_icon + 12
|
|
line_y = y_off # player-name line (at the column edge)
|
|
id_mid = line_y + name_h // 2
|
|
veh_y = line_y + name_h + 12 # vehicle line (icons + names together)
|
|
vname_y = veh_y + (veh_icon - small_h) // 2
|
|
name_w = draw.textbbox((0, 0), name, font=body)[2]
|
|
rating_segs = _rating_segments(p.get("pvp_ratio"))
|
|
rating_w = _segments_width(draw, rating_segs, body)
|
|
|
|
if not flipped:
|
|
# name + rating at the left edge
|
|
draw.text((start_x, line_y), name, font=body, fill=NAME_FILL)
|
|
_draw_segments(draw, start_x + name_w + 12, line_y, rating_segs, body)
|
|
# below: each icon directly next to its own vehicle name
|
|
_draw_vehicle_pairs(overlay, draw, units, start_x, veh_y, vname_y, veh_icon, small, flipped=False)
|
|
else:
|
|
# name + rating at the right edge (rating to the left of the name)
|
|
name_x = start_x + section_width - name_w
|
|
draw.text((name_x, line_y), name, font=body, fill=NAME_FILL)
|
|
_draw_segments(draw, name_x - 12 - rating_w, line_y, rating_segs, body)
|
|
# below: icon+name pairs, the whole group right-aligned to the edge
|
|
total = _vehicle_pairs_width(draw, units, veh_icon, small)
|
|
_draw_vehicle_pairs(overlay, draw, units, start_x + section_width - total,
|
|
veh_y, vname_y, veh_icon, small, flipped=True)
|
|
|
|
# stat values, vertically centered on the identity line
|
|
stats = [
|
|
("Air", p.get("air_kills", 0)),
|
|
("Ground", p.get("ground_kills", 0)),
|
|
("Assists", p.get("assists", 0)),
|
|
("Deaths", p.get("deaths", 0)),
|
|
("Caps", p.get("captures", 0)),
|
|
]
|
|
for label, val in stats:
|
|
num = int(val)
|
|
fill = NEUTRAL_FILL
|
|
if label in ("Air", "Ground") and num > 0:
|
|
fill = WIN_FILL
|
|
elif label == "Deaths" and num > 0:
|
|
fill = (255, 20, 20, 255)
|
|
elif label == "Caps" and num > 0:
|
|
fill = DRAW_FILL
|
|
elif label == "Assists" and num > 0:
|
|
fill = (230, 150, 90, 255)
|
|
draw.text((col_center[label], id_mid), str(num), font=fonts["stat"], fill=fill, anchor="mm")
|
|
|
|
y_off += row_h + 14
|
|
|
|
draw_team(0, teams[0], x_start + 10, col_width)
|
|
draw_team(1, teams[1], x_start + col_width, col_width)
|
|
|
|
# ── Composite + downsample
|
|
final = Image.alpha_composite(background, overlay)
|
|
scale = 0.5
|
|
final = final.resize((int(bg_w * scale), int(bg_h * scale)), resample=Resampling.LANCZOS).convert("RGB")
|
|
final.save(output_path, format="PNG", compress_level=1)
|
|
|
|
|
|
RATING_FILL = (170, 200, 255, 255)
|
|
|
|
|
|
# W/L record colours — matched exactly to SREBOT's scoreboard.
|
|
WL_WIN_FILL = (0, 255, 0, 255)
|
|
WL_LOSS_FILL = (255, 60, 60, 255)
|
|
WL_DASH_FILL = (200, 200, 200, 255)
|
|
|
|
|
|
def _winrate_gradient(win_rate: float) -> tuple:
|
|
"""Red→yellow→green gradient for a win-rate percentage (SREBOT's get_gradient_color).
|
|
|
|
0% = red (255,0,0), 50% = yellow (255,255,0), 100% = green (0,255,0).
|
|
"""
|
|
win_rate = max(0.0, min(100.0, win_rate))
|
|
if win_rate <= 50:
|
|
return (255, int(255 * (win_rate / 50)), 0, 255)
|
|
return (int(255 * (1 - (win_rate - 50) / 50)), 255, 0, 255)
|
|
|
|
|
|
def _winloss_segments(standings: dict[str, int]) -> list[tuple[str, tuple]]:
|
|
"""Colored ``3W - 2L - 60%`` segments for the per-tournament record line.
|
|
|
|
Win count + 'W' green, ' - ' grey, loss count + 'L' red, ' - ' grey, then the
|
|
win-rate% on the red→green gradient — mirroring SREBOT's _draw_winloss.
|
|
"""
|
|
wins = int(standings.get("wins") or 0)
|
|
losses = int(standings.get("losses") or 0)
|
|
total = wins + losses
|
|
pct = (wins / total) * 100 if total else 0.0
|
|
return [
|
|
(f"{wins}W", WL_WIN_FILL),
|
|
(" - ", WL_DASH_FILL),
|
|
(f"{losses}L", WL_LOSS_FILL),
|
|
(" - ", WL_DASH_FILL),
|
|
(f"{pct:.0f}%", _winrate_gradient(pct)),
|
|
]
|
|
|
|
|
|
def _rating_segments(rating):
|
|
"""Build colored (text, fill) segments for the inline ``(1468)`` rating."""
|
|
rating_str = f"{int(round(rating))}" if isinstance(rating, (int, float)) else "—"
|
|
return [(f"({rating_str})", RATING_FILL)]
|
|
|
|
|
|
def _segments_width(draw, segs, font):
|
|
return sum(draw.textbbox((0, 0), s, font=font)[2] for s, _ in segs)
|
|
|
|
|
|
def _draw_segments(draw, x, y, segs, font):
|
|
for s, c in segs:
|
|
draw.text((x, y), s, font=font, fill=c)
|
|
x += draw.textbbox((0, 0), s, font=font)[2]
|
|
return x
|
|
|
|
|
|
_DEAD_LINE_COLOR = (220, 30, 30, 235)
|
|
|
|
|
|
def _paste_vehicle(overlay, unit, xy, size):
|
|
"""Paste a vehicle icon (unused ones dimmed), falling back to not_found."""
|
|
alpha = 255 if unit["used"] else 90
|
|
for path in (VEHICLE_ICON_DIR / f"{unit['internal'].lower()}.png", ICON_BASE_DIR / "not_found.png"):
|
|
try:
|
|
img = load_icon(path, (size, size), alpha=alpha)
|
|
overlay.paste(img, (int(xy[0]), int(xy[1])), img)
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
_VEH_PAIR_GAP = 22
|
|
|
|
|
|
def _vehicle_pairs_width(draw, units, size, font):
|
|
"""Total pixel width of the icon+name pairs (for right-aligning team 2)."""
|
|
w = 0
|
|
for u in units:
|
|
w += size + 4 + draw.textbbox((0, 0), u["name"], font=font)[2] + _VEH_PAIR_GAP
|
|
return max(0, w - _VEH_PAIR_GAP)
|
|
|
|
|
|
def _draw_vehicle_pairs(overlay, draw, units, x, veh_y, vname_y, size, font, flipped=False):
|
|
"""Draw each vehicle as an icon immediately followed by its translated name.
|
|
|
|
Destroyed vehicles get a red diagonal line struck through their name: top-left to
|
|
bottom-right for the left team, mirrored (top-right to bottom-left) for the right.
|
|
"""
|
|
x = int(x)
|
|
for u in units:
|
|
_paste_vehicle(overlay, u, (x, veh_y), size)
|
|
x += size + 4
|
|
draw.text((x, vname_y), u["name"], font=font, fill=NEUTRAL_FILL)
|
|
bbox = draw.textbbox((x, vname_y), u["name"], font=font)
|
|
if u.get("dead"):
|
|
x0, y0, x1, y1 = bbox
|
|
line = (x0, y1, x1, y0) if flipped else (x0, y0, x1, y1)
|
|
draw.line(line, fill=_DEAD_LINE_COLOR, width=max(2, font.size // 9))
|
|
x = bbox[2] + _VEH_PAIR_GAP
|
|
return x
|
|
|
|
|
|
async def create_scoreboard(model: dict[str, Any], output_path: str, bar_color: str = "") -> None:
|
|
"""Render the TSS scoreboard for a game model and write it to ``output_path``."""
|
|
await asyncio.to_thread(_create_scoreboard_sync, model, output_path, bar_color)
|