From 53e3db91591db46c4759cf50139e4eab23338b8a Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:40:19 -0700 Subject: [PATCH] add tss map images and update scoreboard again (#1331) --- BOT/scoreboard.py | 60 +++++++++++++++++++++++++++++++++++++---------- BOT/transform.py | 27 ++++++++++++++++++--- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/BOT/scoreboard.py b/BOT/scoreboard.py index 93a0e09..def2902 100644 --- a/BOT/scoreboard.py +++ b/BOT/scoreboard.py @@ -90,23 +90,44 @@ def _load_map_cached(path_str: str, blur_radius: int): 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.sqrt(xs ** 2 + ys ** 2) / np.sqrt(2) + 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 (spaces→_, .jpg), like SRE.""" - clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_name).replace(" ", "_") - target = f"{clean}.jpg" + """Match a TSS mission_name against SHARED/MAPS. + + Tries the ``TSS_.png`` variant first, then falls back to the plain + ``.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 - match = next((fn for fn in candidates if fn.lower() == target.lower()), None) - return str(MAPS_DIR / match) if match else 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 # --------------------------------------------------------------------------- @@ -129,7 +150,10 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: background = _load_map_cached(map_image_path, 2).copy() else: log.warning("[TSS-SB] No map image for %r; using flat background", map_name) - background = Image.new("RGBA", (1920, 1080), (25, 28, 32, 255)) + # 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 @@ -271,7 +295,7 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: 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) + _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 @@ -280,7 +304,7 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: # 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) + veh_y, vname_y, veh_icon, small, flipped=True) # stat values, vertically centered on the identity line stats = [ @@ -342,6 +366,9 @@ def _draw_segments(draw, x, y, segs, font): 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 @@ -365,14 +392,23 @@ def _vehicle_pairs_width(draw, units, size, font): return max(0, w - _VEH_PAIR_GAP) -def _draw_vehicle_pairs(overlay, draw, units, x, veh_y, vname_y, size, font): - """Draw each vehicle as an icon immediately followed by its translated name.""" +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) - x += draw.textbbox((0, 0), u["name"], font=font)[2] + _VEH_PAIR_GAP + 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 diff --git a/BOT/transform.py b/BOT/transform.py index 36ff393..e3cf8c4 100644 --- a/BOT/transform.py +++ b/BOT/transform.py @@ -52,21 +52,41 @@ def _translator(lang_column: str): return _t -def _build_units(units: list[dict[str, Any]], translate) -> list[dict[str, Any]]: - """Normalize a player's lineup: icon key (internal), translated name, used flag.""" +def _build_units(units: list[dict[str, Any]], translate, dead: set[str] | None = None) -> list[dict[str, Any]]: + """Normalize a player's lineup: icon key (internal), translated name, used/dead flags. + + Prefer an explicit per-unit ``dead``/``died`` flag if Spectra provides one; otherwise + fall back to the ``dead`` set cross-referenced from ``events.kills`` (see ``_dead_units_by_uid``). + """ + dead = dead or set() out: list[dict[str, Any]] = [] for u in units or []: internal = str(u.get("unit") or "").strip() if not internal: continue + flag = u.get("dead", u.get("died")) + is_dead = bool(flag) if flag is not None else internal in dead out.append({ "internal": internal, "name": translate(internal) or u.get("unit_normalized") or internal, "used": bool(u.get("used")), + "dead": is_dead, }) return out +def _dead_units_by_uid(game: dict[str, Any]) -> dict[str, set[str]]: + """Map uid -> set of internal unit names that died (were destroyed in a kill event).""" + out: dict[str, set[str]] = {} + kills = ((game.get("events") or {}).get("kills")) or [] + for k in kills: + uid = str(k.get("offended_uid") or "") + unit = str(k.get("offended_unit") or "").strip() + if uid and unit: + out.setdefault(uid, set()).add(unit) + return out + + def _pvp_index(tss: dict[str, Any]) -> dict[str, dict[str, Any]]: """Map uid -> {pvp_ratio, role} from the tss per-team roster blocks.""" idx: dict[str, dict[str, Any]] = {} @@ -96,6 +116,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "") translate = _translator(lang_column) pvp = _pvp_index(tss) + dead_units = _dead_units_by_uid(game) teams: dict[str, dict[str, Any]] = {} for slot in ("1", "2"): @@ -130,7 +151,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "") "score": int(p.get("score") or 0), "pvp_ratio": info.get("pvp_ratio"), "role": info.get("role"), - "units": _build_units(p.get("units") or [], translate), + "units": _build_units(p.get("units") or [], translate, dead_units.get(str(uid))), }) if not teams["1"]["players"] or not teams["2"]["players"]: