"""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)