from __future__ import annotations from pathlib import Path from typing import Iterable from PIL import Image, ImageDraw, ImageFilter, ImageFont from psd_tools import PSDImage ROOT = Path(__file__).resolve().parents[1] ASSET_DIR = ROOT / "client" / "dev" / "res" / "custom" / "47" SOURCE_PSD = ASSET_DIR / "\u6b66\u6797\u7edd\u5b66.psd" OUTPUT = ASSET_DIR / "wulin_juexue_design_fully_layered.psd" PREVIEW = ASSET_DIR / "wulin_juexue_design_fully_layered_preview.png" POPUP_PREVIEW = ASSET_DIR / "wulin_juexue_design_fully_layered_popup_preview.png" CANVAS = (816, 582) CONTENT = (51, 104, 733, 447) FONT_REGULAR = Path("C:/Windows/Fonts/NotoSansSC-VF.ttf") FONT_BOLD = Path("C:/Windows/Fonts/simhei.ttf") def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: path = FONT_BOLD if bold and FONT_BOLD.exists() else FONT_REGULAR return ImageFont.truetype(str(path), size) def text_size(draw: ImageDraw.ImageDraw, text: str, fnt: ImageFont.FreeTypeFont) -> tuple[int, int]: box = draw.textbbox((0, 0), text, font=fnt) return box[2] - box[0], box[3] - box[1] def make_layer(size: tuple[int, int]) -> tuple[Image.Image, ImageDraw.ImageDraw]: img = Image.new("RGBA", size, (0, 0, 0, 0)) return img, ImageDraw.Draw(img) def add(psd: PSDImage, image: Image.Image, name: str, x: int, y: int, opacity: int = 255) -> None: if image.getbbox() is None: return psd.create_pixel_layer(image.convert("RGBA"), name=name, top=y, left=x, opacity=opacity) def layer_rect( psd: PSDImage, name: str, box: tuple[int, int, int, int], fill: tuple[int, int, int, int], outline: tuple[int, int, int, int] | None = None, radius: int = 0, width: int = 1, ) -> None: x, y, w, h = box img, draw = make_layer((w, h)) rect = (0, 0, w - 1, h - 1) if radius: draw.rounded_rectangle(rect, radius=radius, fill=fill, outline=outline, width=width) else: draw.rectangle(rect, fill=fill, outline=outline, width=width) add(psd, img, name, x, y) def layer_text( psd: PSDImage, name: str, text: str, x: int, y: int, size: int, color: tuple[int, int, int, int], bold: bool = False, max_width: int | None = None, align: str = "left", ) -> None: fnt = font(size, bold) probe, probe_draw = make_layer((1, 1)) w, h = text_size(probe_draw, text, fnt) if max_width is not None: w = min(max_width, w) img_w = max(4, (max_width or w) + 4) img_h = max(4, h + 8) img, draw = make_layer((img_w, img_h)) tx = 2 if align == "center": real_w, _ = text_size(draw, text, fnt) tx = max(2, (img_w - real_w) // 2) draw.text((tx, 0), text, font=fnt, fill=color) add(psd, img, name, x, y) def layer_line( psd: PSDImage, name: str, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int, int], width: int = 1, ) -> None: x, y = min(x1, x2), min(y1, y2) w, h = abs(x2 - x1) + width + 2, abs(y2 - y1) + width + 2 img, draw = make_layer((w, h)) draw.line((x1 - x + 1, y1 - y + 1, x2 - x + 1, y2 - y + 1), fill=color, width=width) add(psd, img, name, x, y) def glow_rect(psd: PSDImage, name: str, box: tuple[int, int, int, int], color: tuple[int, int, int, int], radius: int = 8) -> None: x, y, w, h = box img, draw = make_layer((w + 24, h + 24)) draw.rounded_rectangle((12, 12, w + 11, h + 11), radius=radius, outline=color, width=3) img = img.filter(ImageFilter.GaussianBlur(7)) add(psd, img, name, x - 12, y - 12) def pill(psd: PSDImage, name: str, text: str, x: int, y: int, w: int, color: tuple[int, int, int, int]) -> None: layer_rect(psd, f"{name}_Bg", (x, y, w, 24), color, (222, 201, 131, 120), 7) layer_text(psd, f"{name}_Text", text, x, y + 2, 13, (252, 237, 183, 255), True, w, "center") def source_layer_image(layer) -> Image.Image: bbox = layer.bbox image = layer.composite() canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) canvas.alpha_composite(image.convert("RGBA"), (bbox[0], bbox[1])) return canvas def draw_slots(psd: PSDImage) -> None: slots = [ ("Slot01", "归元掌", "已学习", "壹", (83, 252), True), ("Slot02", "破军剑意", "已学习", "贰", (248, 252), True), ("Slot03", "未洗炼", "空槽", "叁", (83, 355), False), ("Slot04", "待解锁", "需佩戴秘籍", "肆", (248, 355), False), ] for key, title, status, num, (x, y), active in slots: glow = (235, 181, 61, 150) if active else (62, 172, 216, 90) glow_rect(psd, f"{key}_Glow", (x, y, 150, 80), glow, 9) fill = (35, 24, 21, 225) if active else (22, 26, 34, 210) outline = (230, 174, 75, 190) if active else (83, 119, 134, 170) layer_rect(psd, f"{key}_Card", (x, y, 150, 80), fill, outline, 8, 2) layer_text(psd, f"{key}_CornerNo", num, x + 11, y + 8, 20, (245, 221, 149, 255), True) layer_text(psd, f"{key}_Name", title, x + 42, y + 12, 17, (255, 238, 183, 255), True, 95) layer_text(psd, f"{key}_Status", status, x + 42, y + 42, 13, (137, 229, 245, 230), False, 95) if not active: layer_rect(psd, f"{key}_LockIcon_Back", (x + 116, y + 14, 20, 24), (5, 7, 11, 120), (148, 183, 182, 160), 4) layer_text(psd, f"{key}_LockIcon", "锁", x + 116, y + 13, 15, (205, 224, 214, 230), True, 20, "center") def draw_candidate_rows(psd: PSDImage) -> None: rows = [ ("Candidate01", "归元掌", "掌法", "50600"), ("Candidate02", "破军剑意", "剑意", "50603"), ("Candidate03", "玄冰指", "指法", "50606"), ("Candidate04", "金钟罩", "护体", "50702"), ("Candidate05", "龙象劲", "内功", "50708"), ("Candidate06", "踏雪无痕", "身法", "50611"), ] x, y = 69, 169 for i, (key, name, tag, effect_id) in enumerate(rows): yy = y + i * 45 fill = (40, 31, 26, 218) if i == 0 else (24, 27, 32, 205) outline = (222, 169, 79, 165) if i == 0 else (80, 107, 116, 130) layer_rect(psd, f"{key}_RowBg", (x, yy, 174, 36), fill, outline, 6) layer_rect(psd, f"{key}_EffectSwatch", (x + 8, yy + 6, 25, 24), (24, 105, 133, 180), (102, 221, 236, 180), 5) layer_line(psd, f"{key}_EffectStrokeA", x + 13, yy + 24, x + 29, yy + 9, (126, 236, 255, 210), 2) layer_line(psd, f"{key}_EffectStrokeB", x + 16, yy + 11, x + 29, yy + 25, (246, 198, 82, 190), 2) layer_text(psd, f"{key}_Name", name, x + 39, yy + 5, 15, (252, 235, 180, 255), True, 75) layer_text(psd, f"{key}_Tag", tag, x + 114, yy + 5, 12, (152, 235, 244, 230), False, 34, "center") layer_text(psd, f"{key}_EffectId", effect_id, x + 115, yy + 20, 10, (184, 162, 112, 230), False, 42, "center") def draw_showcase(psd: PSDImage) -> None: cx, cy = 603, 252 glow_rect(psd, "Showcase_CircleGlow", (cx - 74, cy - 74, 148, 148), (62, 216, 237, 150), 74) for idx, radius in enumerate([72, 55, 38]): img, draw = make_layer((radius * 2 + 8, radius * 2 + 8)) draw.ellipse((4, 4, radius * 2 + 3, radius * 2 + 3), outline=(89, 224, 242, 125 - idx * 20), width=2) add(psd, img, f"Showcase_Ring_{idx+1}", cx - radius - 4, cy - radius - 4) layer_line(psd, "Showcase_SwordLight_01", cx - 45, cy + 47, cx + 43, cy - 48, (128, 239, 255, 230), 4) layer_line(psd, "Showcase_SwordLight_02", cx - 20, cy + 55, cx + 23, cy - 52, (248, 202, 74, 190), 3) layer_text(psd, "Showcase_EffectId", "Effect 50603", cx - 47, cy + 77, 12, (145, 231, 241, 230), False, 94, "center") def draw_button(psd: PSDImage, key: str, text: str, x: int, y: int, w: int, primary: bool = False) -> None: fill = (139, 78, 38, 235) if primary else (38, 58, 65, 220) outline = (252, 198, 93, 210) if primary else (102, 188, 198, 170) layer_rect(psd, f"{key}_Bg", (x, y, w, 34), fill, outline, 7, 2) layer_text(psd, f"{key}_Text", text, x, y + 6, 15, (255, 241, 193, 255), True, w, "center") def draw_unlock_popup(psd: PSDImage) -> None: layer_rect(psd, "Popup_DimOverlay", (0, 0, 816, 582), (0, 0, 0, 92), None, 0) x, y, w, h = 198, 172, 420, 261 glow_rect(psd, "Popup_OuterGlow", (x, y, w, h), (230, 172, 67, 140), 10) layer_rect(psd, "Popup_Frame", (x, y, w, h), (31, 24, 23, 246), (231, 177, 83, 230), 9, 2) layer_rect(psd, "Popup_TitleBar", (x + 10, y + 10, w - 20, 38), (75, 45, 29, 235), (235, 194, 97, 150), 7) layer_text(psd, "Popup_TitleText", "解锁绝学槽位", x + 15, y + 17, 18, (255, 235, 176, 255), True, w - 30, "center") layer_text(psd, "Popup_Subtitle", "佩戴武林秘籍后,可消耗材料开启新的绝学槽", x + 34, y + 60, 14, (165, 232, 239, 240), False, 352, "center") layer_rect(psd, "Popup_ConditionBox", (x + 30, y + 90, 360, 58), (18, 26, 31, 220), (80, 142, 150, 130), 7) layer_text(psd, "Popup_ConditionTitle", "解锁条件", x + 46, y + 99, 15, (248, 216, 142, 255), True) layer_text(psd, "Popup_ConditionValue", "角色 4 转以上 / 已佩戴武林秘籍", x + 142, y + 100, 14, (212, 238, 225, 255), False, 220) layer_text(psd, "Popup_ConditionState", "满足", x + 331, y + 99, 14, (119, 239, 163, 255), True, 42, "center") layer_rect(psd, "Popup_CostBox", (x + 30, y + 158, 360, 42), (22, 20, 17, 220), (160, 122, 62, 130), 7) layer_text(psd, "Popup_CostTitle", "消耗", x + 46, y + 167, 15, (248, 216, 142, 255), True) layer_text(psd, "Popup_CostValue", "灵石 x1000", x + 143, y + 167, 15, (255, 238, 190, 255), False) layer_text(psd, "Popup_CostOwned", "当前 1280", x + 278, y + 168, 13, (156, 229, 240, 240), False) draw_button(psd, "Popup_CancelButton", "取消", x + 83, y + 214, 110, False) draw_button(psd, "Popup_ConfirmButton", "确认解锁", x + 226, y + 214, 120, True) def main() -> None: psd = PSDImage.new("RGB", CANVAS, color=(0, 0, 0)) source = PSDImage.open(SOURCE_PSD) source_names = ["Frame_Source", "ContentFrame_Source", "Title_Source"] for source_name, layer in zip(source_names, source): add(psd, source_layer_image(layer), source_name, 0, 0) layer_rect(psd, "Content_Backdrop_Tint", CONTENT, (5, 8, 12, 82), None, 0) layer_rect(psd, "Top_Status_Bar", (69, 118, 696, 36), (17, 29, 32, 225), (91, 178, 185, 130), 8) pill(psd, "Equip_State", "已佩戴 武林秘籍", 83, 124, 132, (54, 95, 55, 230)) layer_text(psd, "Equip_State_Desc", "可解锁与洗炼,学习到的绝学将展示在秘籍装备上", 228, 126, 14, (202, 231, 219, 245), False, 380) layer_text(psd, "Power_Label", "绝学战力", 630, 121, 13, (160, 218, 226, 220), False, 70, "center") layer_text(psd, "Power_Value", "28600", 621, 137, 18, (255, 214, 112, 255), True, 88, "center") layer_rect(psd, "LeftPanel_Bg", (69, 162, 190, 359), (13, 17, 22, 218), (89, 135, 142, 140), 8) layer_text(psd, "LeftPanel_Title", "绝学候选池", 80, 169, 17, (255, 230, 163, 255), True, 116) layer_text(psd, "LeftPanel_Note", "洗炼随机获得其一", 81, 193, 12, (134, 210, 221, 220), False, 130) layer_line(psd, "LeftPanel_Divider", 80, 217, 246, 217, (141, 112, 62, 135), 1) draw_candidate_rows(psd) layer_rect(psd, "CenterPanel_Bg", (273, 162, 236, 359), (12, 15, 20, 214), (91, 135, 142, 135), 8) layer_text(psd, "CenterPanel_Title", "秘籍绝学槽", 289, 169, 17, (255, 230, 163, 255), True, 112) layer_text(psd, "CenterPanel_Note", "槽位解锁后才可洗炼", 290, 193, 12, (134, 210, 221, 220), False, 145) layer_line(psd, "CenterPanel_Divider", 289, 217, 493, 217, (141, 112, 62, 135), 1) draw_slots(psd) layer_rect(psd, "RightPanel_Bg", (522, 162, 243, 359), (14, 17, 23, 218), (91, 135, 142, 140), 8) layer_text(psd, "RightPanel_Title", "当前绝学展示", 539, 169, 17, (255, 230, 163, 255), True, 130) layer_text(psd, "RightPanel_Note", "动态特效区:GUI:Effect_Create", 540, 193, 12, (134, 210, 221, 220), False, 172) layer_line(psd, "RightPanel_Divider", 539, 217, 748, 217, (141, 112, 62, 135), 1) draw_showcase(psd) layer_text(psd, "Skill_Name", "破军剑意", 540, 344, 22, (255, 231, 156, 255), True, 195, "center") layer_text(psd, "Skill_Type", "剑意类 · 攻击绝学", 548, 378, 14, (131, 230, 240, 245), False, 180, "center") layer_text(psd, "Skill_Attr01", "攻击 +180 暴击伤害 +5%", 547, 406, 14, (230, 230, 210, 245), False, 186) layer_text(psd, "Skill_Attr02", "洗炼后替换当前槽位绝学", 547, 431, 13, (184, 160, 109, 245), False, 186) draw_button(psd, "Main_WashButton", "洗炼一次", 542, 474, 94, True) draw_button(psd, "Main_SaveButton", "保存绝学", 648, 474, 94, False) draw_unlock_popup(psd) psd.composite().save(POPUP_PREVIEW) for layer in psd: if layer.name.startswith("Popup_"): layer.visible = False psd.save(OUTPUT) psd.composite().save(PREVIEW) print(OUTPUT) print(PREVIEW) print(POPUP_PREVIEW) if __name__ == "__main__": main()