Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/utils/images_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

268 lines
8.9 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 卡片素材加载(背景库/物品图标/专家头像/兵种/logo/字体)。
素材 2026-10 从旧 project(dev/bf2042)整体迁移到 ``res/bf2042/``,
这里提供"名字 → 图片"的统一入口:
- 物品图标(tracker.gg 的名字 vs 本地文件名)按 精确 → 归一化(去空格/
连字符、大小写) → 别名表 → 占位图 四级回退;
- 背景:特权用户(bindings 表 bf2042_support=1)优先个人图库,否则公共库随机;
- 所有图片按需缩放并缓存(避免每次出图重复解码)。
纯 PIL,不依赖 nonebot,可脱离插件包单测(用 importlib 裸加载)。
"""
from __future__ import annotations
import os
import random
import re
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageFont
from ..paths import (
BF2042_BG_DIR,
BF2042_CLASS_ICON_DIR,
BF2042_DEV_LOGO_DIR,
BF2042_LOGO_DIR,
BF2042_OBJECT_ICON_DIR,
BF2042_PLATFORM_DIR,
BF2042_SPECIALIST_ICON_DIR,
RES_DIR,
)
__all__ = [
"object_icon",
"specialist_icon",
"class_icon",
"pick_background",
"platform_icon",
"bf2042_logo",
"dev_logo",
"no_pats_icon",
"load_bf2042_font",
]
# tracker.gg 名字 → 本地文件名(仅收录名字对不上的;实测全量扫描的差集)
_ICON_ALIASES = {
"bren": "Bren LMG",
"ghostmaker 10": "Ghostmaker R10",
"rpt-31": "RPT-3",
"brawler": "CAV-BRAWLER",
}
_FONT_DIR = RES_DIR / "font"
def _normalize(name: str) -> str:
"""去空格/连字符、统一小写,用于宽松匹配。"""
return re.sub(r"[\s\-_]", "", (name or "").strip()).lower()
@lru_cache(maxsize=1)
def _icon_index() -> dict[str, Path]:
"""本地图标索引:归一化文件名 → 路径。"""
index: dict[str, Path] = {}
if BF2042_OBJECT_ICON_DIR.exists():
for f in BF2042_OBJECT_ICON_DIR.iterdir():
if f.suffix.lower() in (".png", ".webp", ".jpg"):
index[_normalize(f.stem)] = f
return index
@lru_cache(maxsize=512)
def _load_scaled(path_str: str, width: int, height: int) -> Image.Image:
"""等比缩放到 (width, height) 框内(contain),居中垫透明边。
素材形状不一(专家头像 300×360、武器/载具横条),直接 resize 会拉伸;
这里统一按比例适配后居中,给渲染层一个固定尺寸的贴图。
"""
img = Image.open(path_str).convert("RGBA")
if width and height:
scale = min(width / img.width, height / img.height)
new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
resized = img.resize(new_size, Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
canvas.alpha_composite(
resized,
dest=((width - new_size[0]) // 2, (height - new_size[1]) // 2),
)
canvas.load()
return canvas
if width:
img = img.resize(
(width, max(1, int(img.height * width / img.width))),
Image.Resampling.LANCZOS,
)
img.load() # 立即解码,后续缓存持有真实像素
return img
def _placeholder(width: int, height: int) -> Image.Image:
return _load_scaled(str(BF2042_OBJECT_ICON_DIR / "default.png"), width, height)
def object_icon(name: str, width: int = 0, height: int = 0) -> Image.Image:
"""物品(武器/载具)图标。四 级回退:精确 → 归一化 → 别名 → 占位图。"""
key = (name or "").strip()
if key:
# 1) 精确/大小写
direct = BF2042_OBJECT_ICON_DIR / f"{key}.png"
if direct.exists():
return _load_scaled(str(direct), width, height)
# 2) 归一化
hit = _icon_index().get(_normalize(key))
if hit:
return _load_scaled(str(hit), width, height)
# 3) 别名表
alias = _ICON_ALIASES.get(key.lower()) or _ICON_ALIASES.get(_normalize(key))
if alias:
alias_hit = _icon_index().get(_normalize(alias))
if alias_hit:
return _load_scaled(str(alias_hit), width, height)
# 4) 占位
return _placeholder(width, height)
@lru_cache(maxsize=64)
def _specialist_index() -> dict[str, Path]:
"""specialist_icon/ 索引:专家头像(500×500 方形,卡片用这套)。"""
index: dict[str, Path] = {}
if BF2042_SPECIALIST_ICON_DIR.exists():
for f in BF2042_SPECIALIST_ICON_DIR.iterdir():
if f.suffix.lower() in (".png", ".webp", ".jpg"):
index[_normalize(f.stem)] = f
return index
def specialist_icon(name: str, width: int = 0, height: int = 0) -> Image.Image:
"""专家头像(tracker.gg soldier 段的 metadata.name,如 "Falck")。
用 specialist_icon/(方形头像)那套素材,不是 classes/(竖版立绘)。
"""
key = _normalize(name or "")
hit = _specialist_index().get(key)
if hit:
return _load_scaled(str(hit), width, height)
fallback = BF2042_SPECIALIST_ICON_DIR / "No-Pats.png"
if not fallback.exists():
fallback = BF2042_CLASS_ICON_DIR / "No-Pats.png"
return _load_scaled(str(fallback), width, height)
def class_icon(category: str, width: int = 0, height: int = 0) -> Image.Image:
"""兵种图标(Assault/Support/Recon/Engineer);取不到回 No-Pats。"""
mapping = {
"assault": "ui/Assault_Icon_small.png",
"support": "ui/Support_Icon_small.png",
"recon": "ui/Recon_Icon_small.png",
"engineer": "ui/Engineer_Icon_small.png",
}
rel = mapping.get((category or "").strip().lower())
if rel and (BF2042_CLASS_ICON_DIR / rel).exists():
return _load_scaled(str(BF2042_CLASS_ICON_DIR / rel), width, height)
return _load_scaled(str(BF2042_CLASS_ICON_DIR / "No-Pats.png"), width, height)
def no_pats_icon(width: int = 0, height: int = 0) -> Image.Image:
"""默认头像占位(EA 头像暂不可用,一律用这张)。"""
return _load_scaled(str(BF2042_CLASS_ICON_DIR / "No-Pats.png"), width, height)
def _random_image_from(directory: Path) -> Image.Image | None:
try:
files = [
f
for f in os.listdir(directory)
if Path(f).suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
]
except OSError:
return None
if not files:
return None
return _load_scaled(str(directory / random.choice(files)), 1920, 1080)
@lru_cache(maxsize=8)
def _common_bg_list() -> tuple[str, ...]:
try:
return tuple(
f
for f in os.listdir(BF2042_BG_DIR / "common")
if Path(f).suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
)
except OSError:
return ()
def pick_background(qq_id: str, support: bool) -> Image.Image:
"""生涯卡背景。
特权用户(bindings.bf2042_support=1)优先个人图库 ``bg/user/{qq}/``;
没有个人图或个人图库为空 → 公共库 ``bg/common/`` 随机。
"""
if support and qq_id:
personal = BF2042_BG_DIR / "user" / str(qq_id)
img = _random_image_from(personal)
if img is not None:
return img
files = _common_bg_list()
if files:
return _load_scaled(
str(BF2042_BG_DIR / "common" / random.choice(files)), 1920, 1080
)
# 素材缺失时的兜底:纯色深底
return Image.new("RGBA", (1920, 1080), (20, 24, 30, 255))
def platform_icon(platform: str, width: int = 40, height: int = 40) -> Image.Image:
"""平台图标;slug 与旧 project 文件名对齐(origin/steam/xbl→xbox/psn→playstation)。"""
slug = (platform or "").lower()
name = {
"origin": "origin.png",
"pc": "origin.png",
"steam": "steam.png",
"xbl": "xbox.png",
"xboxone": "xbox.png",
"psn": "playstation.png",
"ps4": "playstation.png",
}.get(slug, "origin.png")
path = BF2042_PLATFORM_DIR / name
if not path.exists():
path = BF2042_PLATFORM_DIR / "origin.png"
return _load_scaled(str(path), width, height)
@lru_cache(maxsize=8)
def bf2042_logo(width: int = 0, height: int = 0) -> Image.Image:
path = BF2042_LOGO_DIR / "bf2042logo.png"
return _load_scaled(str(path), width, height)
@lru_cache(maxsize=8)
def dev_logo(width: int = 0, height: int = 0) -> Image.Image:
# 旧 project img/dev_logo 下只有一个文件,名字不固定,取第一个
try:
files = sorted(os.listdir(BF2042_DEV_LOGO_DIR))
files = [
f for f in files if Path(f).suffix.lower() in (".png", ".webp", ".jpg")
]
if files:
return _load_scaled(str(BF2042_DEV_LOGO_DIR / files[0]), width, height)
except OSError:
pass
return Image.new("RGBA", (width or 1, height or 1), (0, 0, 0, 0))
@lru_cache(maxsize=64)
def load_bf2042_font(size: int, kind: str = "ch") -> ImageFont.FreeTypeFont:
"""卡片字体。
kind: ch=中文(NotoSansSCMedium-4) / en=英数(BF_Modernista-Bold)。
"""
name = "NotoSansSCMedium-4.ttf" if kind == "ch" else "BF_Modernista-Bold.ttf"
path = _FONT_DIR / name
return ImageFont.truetype(str(path), size)