207 lines
7.6 KiB
Python
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()
|