add tss map images and update scoreboard again (#1331)
This commit is contained in:
+47
-11
@@ -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:
|
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]
|
ys = np.linspace(-1, 1, height)[:, None]
|
||||||
xs = np.linspace(-1, 1, width)[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)
|
band = base_alpha + (max_alpha - base_alpha) * (dist ** power)
|
||||||
return Image.fromarray(np.clip(band, 0, 255).astype(np.uint8), mode="L")
|
return Image.fromarray(np.clip(band, 0, 255).astype(np.uint8), mode="L")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_map_image(map_name: str) -> Optional[str]:
|
def _resolve_map_image(map_name: str) -> Optional[str]:
|
||||||
"""Match a TSS mission_name against SHARED/MAPS (spaces→_, .jpg), like SRE."""
|
"""Match a TSS mission_name against SHARED/MAPS.
|
||||||
clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_name).replace(" ", "_")
|
|
||||||
target = f"{clean}.jpg"
|
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:
|
try:
|
||||||
candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()}
|
candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
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)
|
match = next((fn for fn in candidates if fn.lower() == target.lower()), None)
|
||||||
return str(MAPS_DIR / match) if match else 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()
|
background = _load_map_cached(map_image_path, 2).copy()
|
||||||
else:
|
else:
|
||||||
log.warning("[TSS-SB] No map image for %r; using flat background", map_name)
|
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
|
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.text((start_x, line_y), name, font=body, fill=NAME_FILL)
|
||||||
_draw_segments(draw, start_x + name_w + 12, line_y, rating_segs, body)
|
_draw_segments(draw, start_x + name_w + 12, line_y, rating_segs, body)
|
||||||
# below: each icon directly next to its own vehicle name
|
# 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:
|
else:
|
||||||
# name + rating at the right edge (rating to the left of the name)
|
# name + rating at the right edge (rating to the left of the name)
|
||||||
name_x = start_x + section_width - name_w
|
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
|
# below: icon+name pairs, the whole group right-aligned to the edge
|
||||||
total = _vehicle_pairs_width(draw, units, veh_icon, small)
|
total = _vehicle_pairs_width(draw, units, veh_icon, small)
|
||||||
_draw_vehicle_pairs(overlay, draw, units, start_x + section_width - total,
|
_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
|
# stat values, vertically centered on the identity line
|
||||||
stats = [
|
stats = [
|
||||||
@@ -342,6 +366,9 @@ def _draw_segments(draw, x, y, segs, font):
|
|||||||
return x
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
_DEAD_LINE_COLOR = (220, 30, 30, 235)
|
||||||
|
|
||||||
|
|
||||||
def _paste_vehicle(overlay, unit, xy, size):
|
def _paste_vehicle(overlay, unit, xy, size):
|
||||||
"""Paste a vehicle icon (unused ones dimmed), falling back to not_found."""
|
"""Paste a vehicle icon (unused ones dimmed), falling back to not_found."""
|
||||||
alpha = 255 if unit["used"] else 90
|
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)
|
return max(0, w - _VEH_PAIR_GAP)
|
||||||
|
|
||||||
|
|
||||||
def _draw_vehicle_pairs(overlay, draw, units, x, veh_y, vname_y, size, font):
|
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."""
|
"""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)
|
x = int(x)
|
||||||
for u in units:
|
for u in units:
|
||||||
_paste_vehicle(overlay, u, (x, veh_y), size)
|
_paste_vehicle(overlay, u, (x, veh_y), size)
|
||||||
x += size + 4
|
x += size + 4
|
||||||
draw.text((x, vname_y), u["name"], font=font, fill=NEUTRAL_FILL)
|
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
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -52,21 +52,41 @@ def _translator(lang_column: str):
|
|||||||
return _t
|
return _t
|
||||||
|
|
||||||
|
|
||||||
def _build_units(units: list[dict[str, Any]], translate) -> list[dict[str, Any]]:
|
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 flag."""
|
"""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]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for u in units or []:
|
for u in units or []:
|
||||||
internal = str(u.get("unit") or "").strip()
|
internal = str(u.get("unit") or "").strip()
|
||||||
if not internal:
|
if not internal:
|
||||||
continue
|
continue
|
||||||
|
flag = u.get("dead", u.get("died"))
|
||||||
|
is_dead = bool(flag) if flag is not None else internal in dead
|
||||||
out.append({
|
out.append({
|
||||||
"internal": internal,
|
"internal": internal,
|
||||||
"name": translate(internal) or u.get("unit_normalized") or internal,
|
"name": translate(internal) or u.get("unit_normalized") or internal,
|
||||||
"used": bool(u.get("used")),
|
"used": bool(u.get("used")),
|
||||||
|
"dead": is_dead,
|
||||||
})
|
})
|
||||||
return out
|
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]]:
|
def _pvp_index(tss: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
"""Map uid -> {pvp_ratio, role} from the tss per-team roster blocks."""
|
"""Map uid -> {pvp_ratio, role} from the tss per-team roster blocks."""
|
||||||
idx: dict[str, dict[str, Any]] = {}
|
idx: dict[str, dict[str, Any]] = {}
|
||||||
@@ -96,6 +116,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>")
|
|||||||
|
|
||||||
translate = _translator(lang_column)
|
translate = _translator(lang_column)
|
||||||
pvp = _pvp_index(tss)
|
pvp = _pvp_index(tss)
|
||||||
|
dead_units = _dead_units_by_uid(game)
|
||||||
|
|
||||||
teams: dict[str, dict[str, Any]] = {}
|
teams: dict[str, dict[str, Any]] = {}
|
||||||
for slot in ("1", "2"):
|
for slot in ("1", "2"):
|
||||||
@@ -130,7 +151,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>")
|
|||||||
"score": int(p.get("score") or 0),
|
"score": int(p.get("score") or 0),
|
||||||
"pvp_ratio": info.get("pvp_ratio"),
|
"pvp_ratio": info.get("pvp_ratio"),
|
||||||
"role": info.get("role"),
|
"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"]:
|
if not teams["1"]["players"] or not teams["2"]["players"]:
|
||||||
|
|||||||
Reference in New Issue
Block a user