Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/utils/images_bf2042.py
T

267 lines
8.9 KiB
Python
Raw Normal View History

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