267 lines
8.9 KiB
Python
267 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)
|