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