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

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,30 @@
# Battle UI Icon HQ Gapfix v1
Date: 2026-07-09
## Scope
- Reworked the remaining low-detail battle UI atlas frames: attack (4), axe (10), and terrain (18).
- Kept the runtime atlas layout unchanged at 5 columns x 6 rows, 128 px per frame.
- Updated the generator to seed from the existing high-quality battle UI atlas and replace only the gap frames.
- Built the new icons from project-owned visual material and existing project atlas frames.
## Outputs
- `src/assets/images/ui/battle-ui-icons.png`
- `docs/battle-ui-icons-hq-gapfix-v1-atlas.png`
- `docs/battle-ui-icons-hq-gapfix-v1-before-after.png`
- `docs/battle-ui-icons-hq-gapfix-v1-contact.png`
## Verification
- `python -m py_compile scripts\build-battle-ui-icons.py` passed.
- `pnpm run verify:static-data` passed with the bundled workspace runtime.
- `pnpm run build` passed with the bundled workspace runtime.
- Browser preview passed at `http://127.0.0.1:4191/heros_web/?debug&debugBattle=first-battle-zhuo-commandery&debugBattleSetup=attack-preview&v=ui-icon-gapfix`.
- Deployment and battle screens rendered with 0 console errors; Guan Yu's unit panel showed the updated attack and terrain icon surfaces cleanly at in-game UI scale.
## Notes
- Pixel comparison against `docs/battle-ui-icons-hq-batch1-atlas.png` changed only frames 4, 10, and 18.
- The terrain icon uses a cropped detail from the project battle map source instead of a low-detail vector sketch.

View File

@@ -4,52 +4,42 @@ import math
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageEnhance, ImageFilter
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons.png" 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 BASE_FRAME = 48
FRAME = 64 FRAME = 128
SCALE = 4 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: def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True) OUT.parent.mkdir(parents=True, exist_ok=True)
icons: list[tuple[str, Callable[[ImageDraw.ImageDraw], None]]] = [
("hp", draw_hp), seed_path = SEED_ATLAS if SEED_ATLAS.exists() else OUT
("might", draw_might), if not seed_path.exists():
("intelligence", draw_intelligence), raise FileNotFoundError(f"missing seed atlas: {seed_path}")
("leadership", draw_leadership),
("attack", draw_attack), atlas = Image.open(seed_path).convert("RGBA")
("defense", draw_defense), expected_size = (COLS * FRAME, ROWS * FRAME)
("move", draw_move), if atlas.size != expected_size:
("mastery", draw_mastery), raise ValueError(f"seed atlas must be {expected_size}, got {atlas.size}: {seed_path}")
("sword", draw_sword),
("spear", draw_spear), for index, (_name, build_icon) in REPLACEMENT_FRAMES.items():
("axe", draw_axe), tile = build_icon(atlas)
("bow", draw_bow), frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME)
("strategy", draw_strategy), atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box)
("horse", draw_horse), atlas.alpha_composite(tile, frame_box)
("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))
atlas.save(OUT, optimize=True) atlas.save(OUT, optimize=True)
print(f"Wrote {OUT}") 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) 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]: def c(value: tuple[int, int, int, int] | tuple[int, int, int]) -> tuple[int, int, int, int]:
if len(value) == 3: if len(value) == 3:
return (value[0], value[1], value[2], 255) 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: def draw_attack(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw) icon_shadow(draw)
line(draw, [(13, 34), (33, 14)], (108, 80, 49), 4) line(draw, [(8, 35), (18, 28), (30, 18), (41, 8)], (113, 38, 30, 90), 9)
poly(draw, [(35, 9), (39, 13), (25, 28), (21, 24)], (225, 226, 217), (70, 76, 78)) poly(draw, [(36, 6), (42, 12), (26, 30), (21, 25)], (227, 230, 220), (50, 54, 55))
poly(draw, [(25, 25), (33, 32), (30, 36), (21, 28)], (216, 151, 73), (79, 46, 28)) poly(draw, [(34, 10), (39, 12), (26, 26), (24, 24)], (255, 255, 245, 170))
line(draw, [(14, 34), (21, 41)], (244, 199, 104), 3) 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: 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: def draw_axe(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw) icon_shadow(draw)
line(draw, [(17, 39), (30, 10)], (112, 78, 48), 4) line(draw, [(16, 40), (30, 9)], (102, 70, 42), 5)
poly(draw, [(27, 8), (42, 13), (39, 25), (28, 21), (24, 15)], (217, 221, 211), (65, 70, 73)) line(draw, [(19, 38), (31, 12)], (190, 126, 62, 210), 2)
poly(draw, [(24, 15), (17, 19), (24, 23), (29, 20)], (183, 130, 64), (75, 45, 29)) 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: 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: def draw_terrain(draw: ImageDraw.ImageDraw) -> None:
icon_shadow(draw) icon_shadow(draw)
poly(draw, [(7, 35), (17, 20), (24, 32), (32, 14), (42, 35)], (81, 117, 69), (33, 54, 34)) poly(draw, [(6, 37), (16, 21), (24, 34), (34, 14), (43, 37), (36, 43), (13, 43)], (42, 63, 47), (24, 34, 28))
poly(draw, [(7, 35), (18, 28), (30, 33), (42, 35), (42, 40), (7, 40)], (122, 102, 63), (56, 45, 31)) poly(draw, [(8, 36), (17, 23), (24, 34), (18, 39)], (86, 125, 74), (30, 51, 35))
line(draw, [(17, 23), (19, 27)], (215, 206, 158), 1) poly(draw, [(24, 34), (34, 16), (42, 36), (35, 40)], (67, 112, 74), (30, 51, 35))
line(draw, [(32, 17), (34, 22)], (215, 206, 158), 1) 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: def draw_counter(draw: ImageDraw.ImageDraw) -> None:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 701 KiB