bayuMIR/tools/extract_juexue_skill_icons.py
2026-06-24 21:00:14 +08:00

207 lines
7.6 KiB
Python

from __future__ import annotations
import csv
import json
import re
from collections import deque
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
CSV_PATH = ROOT / "csv" / "cfg_\u7edd\u5b66\u4fee\u70bc.csv"
BASE = ROOT / "client" / "dev" / "res" / "custom" / "47"
SRC = BASE / "wulin_juexue_skill_icons" / "source_skill_icon_sheet_alpha.png"
OUT = BASE / "wulin_juexue_skill_icons"
ICON_DIR = OUT / "icons"
CARD_DIR = OUT / "cards"
CONTACT = OUT / "skill_icons_contact_sheet.png"
CARD_CONTACT = OUT / "skill_cards_contact_sheet.png"
CENTERS_X = [138, 359, 581, 803, 1025, 1247, 1468]
CENTERS_Y = [177, 429, 681]
ICON_SIZE = 184
def slug_name(text: str) -> str:
return re.sub(r'[\\/:*?"<>|\\s]+', "_", text).strip("_")
def load_skills() -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
lines = CSV_PATH.read_text(encoding="gbk").splitlines()
header: list[str] | None = None
for line in lines:
if line.startswith("///index,"):
header = next(csv.reader([line.removeprefix("///")]))
continue
if not line or line.startswith("//"):
continue
if header and line[0].isdigit():
values = next(csv.reader([line]))
row = dict(zip(header, values))
rows.append(row)
return rows
def circle_alpha(size: int, inset: int = 2) -> Image.Image:
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((inset, inset, size - inset - 1, size - inset - 1), fill=255)
return mask
def trim(image: Image.Image, pad: int = 2) -> Image.Image:
bbox = image.getchannel("A").getbbox()
if not bbox:
return image
return image.crop(
(
max(0, bbox[0] - pad),
max(0, bbox[1] - pad),
min(image.width, bbox[2] + pad),
min(image.height, bbox[3] + pad),
)
)
def keep_largest_alpha_component(image: Image.Image, threshold: int = 8) -> Image.Image:
image = image.convert("RGBA")
alpha = image.getchannel("A")
width, height = image.size
alpha_pixels = alpha.load()
seen = bytearray(width * height)
components: list[list[tuple[int, int]]] = []
for y in range(height):
for x in range(width):
index = y * width + x
if seen[index] or alpha_pixels[x, y] <= threshold:
continue
queue: deque[tuple[int, int]] = deque([(x, y)])
seen[index] = 1
comp: list[tuple[int, int]] = []
while queue:
cx, cy = queue.popleft()
comp.append((cx, cy))
for nx, ny in ((cx - 1, cy), (cx + 1, cy), (cx, cy - 1), (cx, cy + 1)):
if 0 <= nx < width and 0 <= ny < height:
nindex = ny * width + nx
if not seen[nindex] and alpha_pixels[nx, ny] > threshold:
seen[nindex] = 1
queue.append((nx, ny))
components.append(comp)
if not components:
return image
keep = set(max(components, key=len))
rgba = image.load()
for y in range(height):
for x in range(width):
if alpha_pixels[x, y] > threshold and (x, y) not in keep:
r, g, b, _ = rgba[x, y]
rgba[x, y] = (r, g, b, 0)
return image
def make_icon(source: Image.Image, center_x: int, center_y: int) -> Image.Image:
half = ICON_SIZE // 2
crop = source.crop((center_x - half, center_y - half, center_x + half, center_y + half)).convert("RGBA")
alpha = crop.getchannel("A")
mask = circle_alpha(ICON_SIZE)
crop.putalpha(Image.composite(alpha, Image.new("L", crop.size, 0), mask))
crop = keep_largest_alpha_component(crop)
return trim(crop)
def make_card(icon: Image.Image, card: Image.Image, class_id: int) -> Image.Image:
result = card.copy().convert("RGBA")
target = (22, 20, 101, 101) if result.width <= 145 else (23, 21, 102, 102)
tx, ty, tw, th = target
scale = min(tw / icon.width, th / icon.height)
resized = icon.resize((int(icon.width * scale), int(icon.height * scale)), Image.Resampling.LANCZOS)
icon_layer = Image.new("RGBA", result.size, (0, 0, 0, 0))
icon_layer.alpha_composite(resized, (tx + (tw - resized.width) // 2, ty + (th - resized.height) // 2))
result.alpha_composite(icon_layer)
return result
def draw_contact(paths: list[Path], out: Path, cell: tuple[int, int], cols: int = 7) -> None:
rows = (len(paths) + cols - 1) // cols
canvas = Image.new("RGBA", (cell[0] * cols, cell[1] * rows), (46, 46, 46, 255))
draw = ImageDraw.Draw(canvas)
for yy in range(0, canvas.height, 10):
for xx in range(0, canvas.width, 10):
color = (68, 68, 68, 255) if (xx // 10 + yy // 10) % 2 == 0 else (48, 48, 48, 255)
draw.rectangle((xx, yy, xx + 9, yy + 9), fill=color)
try:
font = ImageFont.truetype("C:/Windows/Fonts/NotoSansSC-VF.ttf", 12)
except OSError:
font = ImageFont.load_default()
for index, path in enumerate(paths):
image = Image.open(path).convert("RGBA")
col, row = index % cols, index // cols
x, y = col * cell[0], row * cell[1]
scale = min(1, (cell[0] - 20) / image.width, (cell[1] - 38) / image.height)
thumb = image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))), Image.Resampling.LANCZOS)
canvas.alpha_composite(thumb, (x + (cell[0] - thumb.width) // 2, y + 8))
draw.text((x + 6, y + cell[1] - 26), path.stem[:20], font=font, fill=(236, 236, 236, 255))
canvas.convert("RGB").save(out)
def main() -> None:
ICON_DIR.mkdir(parents=True, exist_ok=True)
CARD_DIR.mkdir(parents=True, exist_ok=True)
skills = load_skills()
if len(skills) != 21:
raise ValueError(f"Expected 21 skills, got {len(skills)}")
source = Image.open(SRC).convert("RGBA")
card_blue = Image.open(BASE / "wulin_juexue_generated_assets" / "transparent_slices" / "slot_card_blue.png").convert("RGBA")
card_gold = Image.open(BASE / "wulin_juexue_generated_assets" / "transparent_slices" / "slot_card_gold.png").convert("RGBA")
manifest = []
icon_paths: list[Path] = []
card_paths: list[Path] = []
for idx, skill in enumerate(skills):
row, col = divmod(idx, 7)
icon_id = int(skill["icon"])
name = skill["name"]
icon = make_icon(source, CENTERS_X[col], CENTERS_Y[row])
icon_path = ICON_DIR / f"icon_{icon_id:02d}_{slug_name(name)}.png"
icon.save(icon_path)
icon_paths.append(icon_path)
class_id = int(skill["calss"])
card_base = card_blue if class_id % 2 else card_gold
card = make_card(icon, card_base, class_id)
card_path = CARD_DIR / f"skill_card_{icon_id:02d}_{slug_name(name)}.png"
card.save(card_path)
card_paths.append(card_path)
manifest.append(
{
"index": int(skill["index"]),
"name": name,
"effect": skill["effec"],
"class": class_id,
"icon": icon_id,
"icon_file": str(icon_path.relative_to(OUT)).replace("\\", "/"),
"card_file": str(card_path.relative_to(OUT)).replace("\\", "/"),
}
)
(OUT / "skill_icon_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
draw_contact(icon_paths, CONTACT, (180, 164))
draw_contact(card_paths, CARD_CONTACT, (170, 224))
print(ICON_DIR)
print(CARD_DIR)
print(OUT / "skill_icon_manifest.json")
print(CONTACT)
print(CARD_CONTACT)
if __name__ == "__main__":
main()