Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/utils/render_bf2042.py
T
sansenhoshiandClaude Code a31aab00e8 refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:06 +08:00

436 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BF2042 战绩卡渲染(tracker.gg 通道专用)。
- 生涯卡 ``build_bf2042_career_card``:照旧 project(dev/bf2042)的
`bf_2042_gen_pic` 版式重绘(1920×1080 四面板 + TOP5 武器/载具);
- 数据槽位用旧字段契约(见 ``services.processing.build_bf2042_career_card``),
没有的字段由数据层填 "–" 占位;
- 榜单卡 ``build_bf2042_list_card``:轻量文字榜单(武器/载具/模式)。
"""
import io
import cv2
import numpy as np
from PIL import Image, ImageDraw
from .images import load_avatar
from .images_bf2042 import (
bf2042_logo,
load_bf2042_font,
no_pats_icon,
object_icon,
pick_background,
platform_icon,
specialist_icon,
)
__all__ = ["build_bf2042_career_card", "build_bf2042_list_card"]
# 配色(旧版风格)
_WHITE = (255, 255, 255, 255)
_PANEL = (0, 0, 0, 150)
_GREEN = (144, 238, 144, 255) # lightgreen 标题
_SKY = (135, 206, 235, 255) # skyblue 名字
_GOLD = (255, 215, 0, 255)
_LINE_TOP = (204, 255, 0, 255) # TOP3 黄绿
_LINE_REST = (102, 204, 255, 255) # TOP4-5 蓝
_VERDICT_COLOR = {"gold": _GOLD, "skyblue": _SKY}
def _draw_rect(img: Image.Image, box, radius: int, fill) -> None:
"""圆角半透明面板(旧项目 draw_rect 的等价实现)。"""
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
ImageDraw.Draw(overlay).rounded_rectangle(box, radius=radius, fill=fill)
img.alpha_composite(overlay)
def _fit(draw: ImageDraw.ImageDraw, text: str, font, max_w: float) -> str:
"""超宽截断加省略号。"""
text = str(text)
if draw.textlength(text, font=font) <= max_w:
return text
while text and draw.textlength(text + "…", font=font) > max_w:
text = text[:-1]
return text + "…"
def _paste(img: Image.Image, source: Image.Image, pos) -> None:
img.alpha_composite(source, (int(pos[0]), int(pos[1])))
def _paste_circle(img: Image.Image, source: Image.Image, pos, radius: int) -> None:
"""贴图并做圆角。"""
src = source.convert("RGBA")
mask = Image.new("L", src.size, 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, src.width, src.height), radius=radius, fill=255
)
img.paste(src, (int(pos[0]), int(pos[1])), mask)
def _qq_avatar(qq_id: str, size: int) -> Image.Image | None:
"""QQ 头像(load_avatar 带缓存与占位兜底);取不到返回 None。"""
if not qq_id:
return None
try:
bgra = load_avatar(str(qq_id), size)
if bgra is None or not getattr(bgra, "size", 0):
return None
rgba = cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGBA)
return Image.fromarray(np.ascontiguousarray(rgba), "RGBA")
except Exception: # noqa: BLE001 - 头像拿不到就回内置占位
return None
def _paste_ic_logo(img: Image.Image) -> None:
"""底部信息栏(旧 project `paste_ic_logo` 的移植,按需精简)。
保留:数据来源(tracker.gg)/ 插件作者 / 查询时间 / 友情合作(贴吧官群)。
已按用户要求去掉:logo 图、友情赞助两段、铁幕重工。
"""
import datetime
_draw_rect(img, (25, 1050, 1895, 1075), 1, _PANEL)
draw = ImageDraw.Draw(img)
f_en = load_bf2042_font(20, "en")
f_ch = load_bf2042_font(20, "ch")
f_ch_s = load_bf2042_font(18, "ch")
# 左:数据来源
draw.text((30, 1050), "Data Source : TRACKER.GG", fill=_WHITE, font=f_en)
# 中:插件作者(左组与右侧块之间居中)
info1, info2 = "Plugin Designed By", "SANSENHOSHI"
f_en_s = load_bf2042_font(18, "en")
w1 = draw.textlength(info1, font=f_en_s)
w2 = draw.textlength(info2, font=f_en_s)
x0 = (360 + 1180 - (w1 + w2 + 20)) / 2
draw.text((x0, 1052), info1, fill=_WHITE, font=f_en_s)
draw.text((x0 + w1 + 20, 1052), info2, fill=_SKY, font=f_en_s)
# 右:查询时间 + 友情合作(从右往左排)
now = datetime.datetime.now()
t_text = "查询时间: " + now.strftime("%Y-%m-%d %H:%M:%S")
p2 = "贴吧官群:1019165018"
w_t = draw.textlength(t_text, font=f_ch)
x_p2 = 1870 - round(draw.textlength(p2, font=f_ch_s))
x_coop = round(x_p2 - draw.textlength("友情合作:", font=f_ch_s) - 24)
x_time = round(x_coop - w_t - 40)
draw.text((x_time, 1052), t_text, fill=_WHITE, font=f_ch)
draw.text((x_coop, 1052), "友情合作:", fill=_WHITE, font=f_ch_s)
draw.text((x_p2, 1052), p2, fill=(153, 204, 0, 255), font=f_ch_s)
def build_bf2042_career_card(card: dict):
"""生涯卡:1920×1080,返回 BytesIO(PNG)。
版式照旧 `bf_2042_gen_pic`:
[玩家信息 768] [最佳专家 550] [对局数据 552] y 25~262
[生涯数据 1870×285 四列 + 鉴定] y 275~560
[TOP5 武器 | TOP5 载具] y 575~1044
"""
card = card or {}
qq_id = str(card.get("_qq_id") or "")
support = bool(card.get("_support"))
# 1) 背景(特权用户个人图库 > 公共库随机)
new_img = pick_background(qq_id, support).convert("RGBA")
# 2) 右上角 logo
logo = bf2042_logo(130, 130)
_paste_circle(new_img, logo, (1762, 30), 10)
# 3) 面板
_draw_rect(new_img, (25, 25, 793, 262), 10, _PANEL) # 玩家信息
_draw_rect(new_img, (808, 25, 1358, 262), 10, _PANEL) # 最佳专家
_draw_rect(new_img, (1373, 25, 1895, 262), 10, _PANEL) # 对局数据
_draw_rect(new_img, (25, 275, 1895, 560), 10, _PANEL) # 生涯数据
_draw_rect(new_img, (25, 575, 1895, 1044), 10, _PANEL) # TOP5
draw = ImageDraw.Draw(new_img)
f22 = load_bf2042_font(22, "ch")
f24 = load_bf2042_font(24, "ch")
f26 = load_bf2042_font(26, "ch")
f28 = load_bf2042_font(28, "ch")
f30 = load_bf2042_font(30, "ch")
f28e = load_bf2042_font(28, "en")
f32e = load_bf2042_font(32, "en")
f38 = load_bf2042_font(38, "ch")
# ---- ① 玩家信息 ----
avatar = _qq_avatar(qq_id, 140) or no_pats_icon(140, 140)
_paste_circle(new_img, avatar, (30, 30), 10)
draw.text((196, 36), "玩家:", fill=_WHITE, font=f28)
name = _fit(draw, card.get("userName", "—"), f32e, 320)
draw.text((268, 32), name, fill=_WHITE, font=f32e)
draw.text((196, 84), "LEVEL: ", fill=_WHITE, font=f30)
draw.text((292, 84), str(card.get("level", "—")), fill=_WHITE, font=f30)
plat = platform_icon(card.get("platform", "origin"), 34, 34)
_paste(new_img, plat, (199, 130))
draw.text((240, 132), "游玩时长:", fill=_WHITE, font=f26)
draw.text((378, 128), f"{card.get('secondsPlayed', '—')} H", fill=_WHITE, font=f30)
# ---- ② 最佳专家 ----
best = card.get("bestClass") or {}
draw.text((826, 62), "最 佳", fill=_GREEN, font=f38)
draw.text((826, 117), "专 家", fill=_GREEN, font=f38)
if best:
icon = specialist_icon(best.get("characterName", ""), 88, 88)
_paste(new_img, icon, (924, 42))
draw.text(
(1024, 46),
str(best.get("displayName") or best.get("characterName", "—")),
fill=_SKY,
font=f28,
)
draw.text(
(1024, 86), f"K/D:{best.get('killDeath', '—')}", fill=_WHITE, font=f22
)
draw.text((1024, 118), f"KPM:{best.get('kpm', '—')}", fill=_WHITE, font=f22)
draw.text((1024, 150), f"击杀:{best.get('kills', '—')}", fill=_WHITE, font=f22)
draw.text(
(1024, 182),
f"时长:{best.get('secondsPlayed', 0) // 3600} H",
fill=_WHITE,
font=f22,
)
else:
draw.text((924, 120), "暂无数据", fill=_WHITE, font=f30)
# ---- ③ 对局数据(旧三行:场数 / MVP·最佳小队 / 处决) ----
x3 = 1396
draw.text(
(x3, 58), f"游玩场数: {card.get('matchesPlayed', '—')}", fill=_WHITE, font=f28
)
draw.text((x3, 118), f"MVP: {card.get('mvp', '—')}", fill=_WHITE, font=f28)
draw.text(
(x3, 178), f"处决: {card.get('playerTakeDowns', '—')}", fill=_WHITE, font=f28
)
# ---- ④ 生涯数据(四列) ----
def cell(x: float, y: float, label: str, value) -> None:
draw.text((x, y), label, fill=_WHITE, font=f28)
draw.text((x + 232, y), _fit(draw, str(value), f28, 205), fill=_WHITE, font=f28)
y0 = 290
step = 50
col1, col2, col3, col4 = 52, 500, 948, 1373
cell(col1, y0, "K/D:", card.get("killDeath", "—"))
cell(col1, y0 + step, "真实 K/D:", card.get("infantryKillDeath", "—"))
cell(col1, y0 + step * 2, "击杀:", card.get("kills", "—"))
cell(
col1,
y0 + step * 3,
"载具击杀:",
(card.get("dividedKills") or {}).get("vehicle", "—"),
)
cell(col1, y0 + step * 4, "死亡数:", card.get("deaths", "—"))
cell(col2, y0, "KPM:", card.get("killsPerMinute", "—"))
cell(col2, y0 + step, "真实 KPM:", card.get("realKpm", "—"))
cell(col2, y0 + step * 2, "爆头率:", card.get("headShots", "—"))
cell(col2, y0 + step * 3, "命中率:", card.get("accuracy", "—"))
cell(col2, y0 + step * 4, "胜率:", card.get("winPercent", "—"))
cell(col3, y0, "AI 击杀:", (card.get("dividedKills") or {}).get("ai", "—"))
cell(col3, y0 + step, "场均击杀:", card.get("killsPerMatch", "—"))
cell(col3, y0 + step * 2, "急救数:", card.get("revives", "—"))
cell(col3, y0 + step * 3, "标记敌人数:", card.get("enemiesSpotted", "—"))
cell(col3, y0 + step * 4, "摧毁载具数:", card.get("vehiclesDestroyed", "—"))
draw.text((col4, y0 - 8), "机器棱鉴定(仅供参考):", fill=_WHITE, font=f22)
verdict = str(card.get("verdict") or "未知")
color = _VERDICT_COLOR.get(str(card.get("verdictColor") or ""), _WHITE)
draw.multiline_text((col4, y0 + 28), verdict, fill=color, font=f26, spacing=6)
draw.text((col4, y0 + 140), "联BAN查询:", fill=_WHITE, font=f24)
draw.text((col4, y0 + 178), "–", fill=_GOLD, font=f24)
# ---- ⑤ TOP5 武器 / 载具 ----
draw.line((950, 600, 950, 1032), fill=_WHITE, width=3)
def draw_top_list(
col_x: float,
rows: list[dict],
icon_size: tuple[int, int],
name_key: str,
name_w: float,
title: str,
is_vehicle: bool,
) -> None:
draw.text((col_x, 588), title, fill=_GREEN, font=f30)
row_y = 630
for i, row in enumerate(rows[:5]):
color = _LINE_TOP if i < 3 else _LINE_REST
draw.line((col_x, row_y + 6, col_x, row_y + 72), fill=color, width=5)
icon = object_icon(row.get(name_key, ""), *icon_size)
_paste(new_img, icon, (col_x + 14, row_y + 4))
name_x = col_x + 14 + icon_size[0] + 16
label = _fit(draw, str(row.get(name_key, "—")), f28e, name_w)
draw.text((name_x, row_y + 2), label, fill=_WHITE, font=f28e)
draw.text(
(name_x, row_y + 40),
f"击杀:{row.get('kills', '—')}",
fill=_WHITE,
font=f26,
)
if is_vehicle:
draw.text(
(name_x + 190, row_y + 2),
f"KPM:{row.get('killsPerMinute', '—')}",
fill=_WHITE,
font=f22,
)
draw.text(
(name_x + 190, row_y + 40),
f"摧毁数:{row.get('destroyed', '—')}",
fill=_WHITE,
font=f22,
)
else:
draw.text(
(name_x + 180, row_y + 2),
f"爆头率:{row.get('headshots', '—')}",
fill=_WHITE,
font=f22,
)
draw.text(
(name_x + 180, row_y + 40),
f"命中率:{row.get('accuracy', '—')}",
fill=_WHITE,
font=f22,
)
draw.text(
(name_x + 360, row_y + 2),
f"KPM:{row.get('killsPerMinute', '—')}",
fill=_WHITE,
font=f22,
)
draw.text(
(name_x + 360, row_y + 40),
f"时长:{row.get('timeEquipped', 0) // 3600} H",
fill=_WHITE,
font=f22,
)
row_y += 82
draw_top_list(
55, card.get("weapons") or [], (150, 66), "weaponName", 170, "TOP5 武器", False
)
draw_top_list(
985,
card.get("vehicles") or [],
(240, 66),
"vehicleName",
180,
"TOP5 载具",
True,
)
# 底部信息栏(数据来源/赞助/作者/时间/合作)
_paste_ic_logo(new_img)
b_io = io.BytesIO()
new_img.convert("RGB").save(b_io, format="PNG")
return b_io
def build_bf2042_list_card(info: dict):
"""榜单卡:武器 / 载具 / 模式共用(轻量文字版)。
info 形如 {"玩家名称":.., "标题":"武器", "列名":[...], "行":[{"名称","标签","值":[...]}]},
数值列统一右对齐;名称过长自动截断,类别跟在名字后面同排显示。
"""
rows = info.get("行") or []
columns = info.get("列名") or []
count = max(len(columns), 1)
width = 1100
pad = 50
value_left = 470
gap = 24
bg = (20, 24, 30, 255)
accent = (0, 168, 224, 255)
fg = (236, 240, 244, 255)
muted = (150, 160, 172, 255)
f_s = load_bf2042_font(28, "ch")
f_xl = load_bf2042_font(44, "ch")
header_h = 170
rows_y = header_h + 80
row_h = 46
height = rows_y + max(len(rows), 1) * row_h + 30
card = Image.new("RGBA", (width, height), bg)
draw = ImageDraw.Draw(card)
draw.text((pad, 40), f"战地2042 · {info.get('玩家名称', '—')}", fill=fg, font=f_xl)
draw.text(
(pad, 112),
f"{info.get('标题', '')}榜 · 前 {len(rows)} 名",
fill=muted,
font=f_s,
)
draw.line((pad, header_h, width - pad, header_h), fill=accent, width=3)
widths = []
for i in range(count):
widest = draw.textlength(columns[i], font=f_s) if i < len(columns) else 0
for row in rows:
values = row.get("值") or []
if i < len(values):
widest = max(widest, draw.textlength(str(values[i]), font=f_s))
widths.append(widest)
avail = (width - pad) - value_left
room = avail - gap * (count - 1)
scale = min(1.0, room / sum(widths)) if sum(widths) else 1.0
widths = [w * scale for w in widths]
edges = []
cursor = float(width - pad)
for w in reversed(widths):
edges.append(cursor)
cursor -= w + gap
edges.reverse()
name_w = max(200.0, (edges[0] - widths[0]) - gap - pad)
def cell(text: str, right_x: float, row_y: int, color) -> None:
text = str(text)
draw.text(
(right_x - draw.textlength(text, font=f_s), row_y),
text,
fill=color,
font=f_s,
)
head_y = header_h + 30
draw.text((pad, head_y), "名称", fill=muted, font=f_s)
for name, edge in zip(columns, edges):
cell(name, edge, head_y, muted)
draw.line((pad, head_y + 34, width - pad, head_y + 34), fill=muted, width=1)
y = rows_y
if not rows:
draw.text((pad, y), "(无数据)", fill=muted, font=f_s)
for rank, row in enumerate(rows, 1):
label = _fit(draw, f"{rank}. {row.get('名称', '—')}", f_s, name_w)
draw.text((pad, y), label, fill=fg, font=f_s)
tag = row.get("标签")
if tag:
used = draw.textlength(label, font=f_s)
suffix = _fit(draw, f" · {tag}", f_s, name_w - used)
draw.text((pad + used, y), suffix, fill=muted, font=f_s)
for value, edge in zip(row.get("值") or [], edges):
cell(value, edge, y, fg)
y += row_h
b_io = io.BytesIO()
card.save(b_io, format="PNG")
return b_io