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>
This commit is contained in:
1 parent
614e1d7e00
commit
a31aab00e8
752 files changed
+6241
-2506
No files matched your search
@@ -0,0 +1,435 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user