- 旧平铺模块(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>
268 lines
8.9 KiB
Python
268 lines
8.9 KiB
Python
"""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)
|