Upgrade remaining battle UI icon gaps

This commit is contained in:
2026-07-10 00:01:27 +09:00
parent 3009ea3fe7
commit 0e3f2592c3
6 changed files with 148 additions and 47 deletions

View File

@@ -4,52 +4,42 @@ import math
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw, ImageFilter
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons.png"
SEED_ATLAS = ROOT / "docs" / "battle-ui-icons-hq-batch1-atlas.png"
TERRAIN_SOURCE = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.webp"
BASE_FRAME = 48
FRAME = 64
FRAME = 128
SCALE = 4
COLS = 5
ROWS = 6
REPLACEMENT_FRAMES: dict[int, tuple[str, Callable[[Image.Image], Image.Image]]] = {
4: ("attack", lambda atlas: compose_attack_icon(atlas)),
10: ("axe", lambda atlas: compose_axe_icon(atlas)),
18: ("terrain", lambda atlas: compose_terrain_icon()),
}
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
icons: list[tuple[str, Callable[[ImageDraw.ImageDraw], None]]] = [
("hp", draw_hp),
("might", draw_might),
("intelligence", draw_intelligence),
("leadership", draw_leadership),
("attack", draw_attack),
("defense", draw_defense),
("move", draw_move),
("mastery", draw_mastery),
("sword", draw_sword),
("spear", draw_spear),
("axe", draw_axe),
("bow", draw_bow),
("strategy", draw_strategy),
("horse", draw_horse),
("armor", draw_armor),
("accessory", draw_accessory),
("hit", draw_hit),
("critical", draw_critical),
("terrain", draw_terrain),
("counter", draw_counter),
("advantage", draw_advantage),
("success", draw_success),
("heal", draw_heal),
("focus", draw_focus),
("fire", draw_fire),
("shout", draw_shout),
]
cols = 5
rows = math.ceil(len(icons) / cols)
atlas = Image.new("RGBA", (cols * FRAME, rows * FRAME), (0, 0, 0, 0))
for index, (_name, draw_icon) in enumerate(icons):
tile = make_icon(draw_icon)
atlas.alpha_composite(tile, ((index % cols) * FRAME, (index // cols) * FRAME))
seed_path = SEED_ATLAS if SEED_ATLAS.exists() else OUT
if not seed_path.exists():
raise FileNotFoundError(f"missing seed atlas: {seed_path}")
atlas = Image.open(seed_path).convert("RGBA")
expected_size = (COLS * FRAME, ROWS * FRAME)
if atlas.size != expected_size:
raise ValueError(f"seed atlas must be {expected_size}, got {atlas.size}: {seed_path}")
for index, (_name, build_icon) in REPLACEMENT_FRAMES.items():
tile = build_icon(atlas)
frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME)
atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box)
atlas.alpha_composite(tile, frame_box)
atlas.save(OUT, optimize=True)
print(f"Wrote {OUT}")
@@ -62,6 +52,71 @@ def make_icon(draw_icon: Callable[[ImageDraw.ImageDraw], None]) -> Image.Image:
return image.resize((FRAME, FRAME), Image.Resampling.LANCZOS)
def atlas_frame(atlas: Image.Image, index: int) -> Image.Image:
x = (index % COLS) * FRAME
y = (index // COLS) * FRAME
return atlas.crop((x, y, x + FRAME, y + FRAME)).convert("RGBA")
def alpha_paste(base: Image.Image, overlay: Image.Image, x: int, y: int) -> None:
base.alpha_composite(overlay.convert("RGBA"), (x, y))
def scaled_overlay(image: Image.Image, size: int, angle: float = 0) -> Image.Image:
overlay = image.resize((size, size), Image.Resampling.LANCZOS)
if angle:
overlay = overlay.rotate(angle, resample=Image.Resampling.BICUBIC, expand=True)
return overlay
def compose_attack_icon(atlas: Image.Image) -> Image.Image:
tile = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
burst = scaled_overlay(atlas_frame(atlas, 17), 100)
burst_alpha = burst.getchannel("A").point(lambda value: int(value * 0.72))
burst.putalpha(burst_alpha)
alpha_paste(tile, burst, 6, 24)
sword = scaled_overlay(atlas_frame(atlas, 8), 122, -8)
alpha_paste(tile, sword, (FRAME - sword.width) // 2 + 3, (FRAME - sword.height) // 2 - 1)
draw = ImageDraw.Draw(tile, "RGBA")
draw.line([(24, 92), (50, 68), (83, 41), (108, 22)], fill=(207, 66, 43, 210), width=7)
draw.line([(28, 96), (58, 72), (95, 48), (116, 34)], fill=(255, 215, 111, 170), width=3)
return tile.filter(ImageFilter.UnsharpMask(radius=0.9, percent=80, threshold=2))
def compose_axe_icon(atlas: Image.Image) -> Image.Image:
tile = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
axe_source = atlas_frame(atlas, 1).transpose(Image.Transpose.FLIP_LEFT_RIGHT)
axe = scaled_overlay(axe_source, 124, 4)
alpha_paste(tile, axe, (FRAME - axe.width) // 2, (FRAME - axe.height) // 2 + 2)
return tile.filter(ImageFilter.UnsharpMask(radius=0.9, percent=80, threshold=2))
def compose_terrain_icon() -> Image.Image:
if not TERRAIN_SOURCE.exists():
return make_icon(draw_terrain)
source = Image.open(TERRAIN_SOURCE).convert("RGBA")
crop = source.crop((2440, 720, 3630, 1910)).resize((104, 104), Image.Resampling.LANCZOS)
crop = ImageEnhance.Color(crop).enhance(1.18)
crop = ImageEnhance.Contrast(crop).enhance(1.1)
crop = ImageEnhance.Brightness(crop).enhance(1.05)
tile = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
mask = Image.new("L", (104, 104), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, 103, 103), radius=22, fill=255)
tile.paste(crop, (12, 12), mask)
draw = ImageDraw.Draw(tile, "RGBA")
draw.rounded_rectangle((12, 12, 116, 116), radius=22, outline=(48, 35, 23, 235), width=5)
draw.rounded_rectangle((16, 16, 112, 112), radius=18, outline=(216, 183, 92, 215), width=3)
draw.line([(21, 93), (42, 83), (63, 88), (86, 79), (107, 85)], fill=(231, 193, 101, 170), width=4)
draw.line([(69, 97), (79, 90), (97, 94)], fill=(111, 190, 192, 150), width=4)
return tile.filter(ImageFilter.UnsharpMask(radius=1.0, percent=95, threshold=2))
def c(value: tuple[int, int, int, int] | tuple[int, int, int]) -> tuple[int, int, int, int]:
if len(value) == 3:
return (value[0], value[1], value[2], 255)
@@ -139,10 +194,14 @@ def draw_leadership(draw: ImageDraw.ImageDraw) -> None:
def draw_attack(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw)
line(draw, [(13, 34), (33, 14)], (108, 80, 49), 4)
poly(draw, [(35, 9), (39, 13), (25, 28), (21, 24)], (225, 226, 217), (70, 76, 78))
poly(draw, [(25, 25), (33, 32), (30, 36), (21, 28)], (216, 151, 73), (79, 46, 28))
line(draw, [(14, 34), (21, 41)], (244, 199, 104), 3)
line(draw, [(8, 35), (18, 28), (30, 18), (41, 8)], (113, 38, 30, 90), 9)
poly(draw, [(36, 6), (42, 12), (26, 30), (21, 25)], (227, 230, 220), (50, 54, 55))
poly(draw, [(34, 10), (39, 12), (26, 26), (24, 24)], (255, 255, 245, 170))
poly(draw, [(22, 27), (31, 35), (27, 40), (17, 31)], (210, 142, 66), (67, 37, 26))
line(draw, [(15, 39), (21, 32)], (246, 208, 111), 4)
line(draw, [(15, 34), (25, 42)], (83, 45, 31), 2)
poly(draw, [(13, 18), (20, 16), (18, 22), (28, 20), (20, 27), (22, 34), (15, 29), (7, 32), (12, 24)], (232, 84, 54, 190), (86, 30, 24))
line(draw, [(11, 20), (27, 35), (37, 38)], (255, 204, 85, 190), 2)
def draw_defense(draw: ImageDraw.ImageDraw) -> None:
@@ -191,9 +250,16 @@ def draw_spear(draw: ImageDraw.ImageDraw) -> None:
def draw_axe(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw)
line(draw, [(17, 39), (30, 10)], (112, 78, 48), 4)
poly(draw, [(27, 8), (42, 13), (39, 25), (28, 21), (24, 15)], (217, 221, 211), (65, 70, 73))
poly(draw, [(24, 15), (17, 19), (24, 23), (29, 20)], (183, 130, 64), (75, 45, 29))
line(draw, [(16, 40), (30, 9)], (102, 70, 42), 5)
line(draw, [(19, 38), (31, 12)], (190, 126, 62, 210), 2)
poly(draw, [(28, 8), (43, 12), (42, 22), (34, 27), (29, 21), (24, 16)], (213, 218, 213), (50, 54, 55))
poly(draw, [(28, 8), (17, 13), (17, 23), (27, 22), (31, 16)], (218, 223, 216), (50, 54, 55))
poly(draw, [(31, 11), (40, 14), (39, 20), (32, 23), (30, 18)], (255, 255, 240, 135))
poly(draw, [(25, 13), (19, 16), (20, 21), (27, 20), (29, 16)], (255, 255, 240, 120))
ellipse(draw, (24, 14, 33, 23), (202, 148, 69), (69, 42, 25))
line(draw, [(33, 24), (41, 31)], (154, 42, 35, 210), 3)
line(draw, [(29, 25), (33, 35)], (229, 82, 49, 190), 2)
line(draw, [(21, 31), (13, 39)], (62, 36, 26, 180), 2)
def draw_bow(draw: ImageDraw.ImageDraw) -> None:
@@ -262,10 +328,15 @@ def draw_critical(draw: ImageDraw.ImageDraw) -> None:
def draw_terrain(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw)
poly(draw, [(7, 35), (17, 20), (24, 32), (32, 14), (42, 35)], (81, 117, 69), (33, 54, 34))
poly(draw, [(7, 35), (18, 28), (30, 33), (42, 35), (42, 40), (7, 40)], (122, 102, 63), (56, 45, 31))
line(draw, [(17, 23), (19, 27)], (215, 206, 158), 1)
line(draw, [(32, 17), (34, 22)], (215, 206, 158), 1)
poly(draw, [(6, 37), (16, 21), (24, 34), (34, 14), (43, 37), (36, 43), (13, 43)], (42, 63, 47), (24, 34, 28))
poly(draw, [(8, 36), (17, 23), (24, 34), (18, 39)], (86, 125, 74), (30, 51, 35))
poly(draw, [(24, 34), (34, 16), (42, 36), (35, 40)], (67, 112, 74), (30, 51, 35))
poly(draw, [(15, 23), (18, 28), (21, 26), (17, 20)], (229, 218, 176, 230))
poly(draw, [(33, 17), (36, 25), (39, 24), (34, 14)], (235, 225, 188, 220))
poly(draw, [(6, 38), (17, 33), (25, 36), (37, 33), (44, 37), (44, 42), (6, 42)], (137, 106, 62), (55, 42, 29))
line(draw, [(13, 39), (20, 36), (30, 39), (39, 36)], (203, 168, 92, 200), 2)
line(draw, [(11, 35), (18, 32), (27, 34), (36, 32)], (88, 146, 96, 190), 2)
line(draw, [(27, 41), (32, 38), (39, 39)], (97, 169, 171, 190), 2)
def draw_counter(draw: ImageDraw.ImageDraw) -> None: