diff --git a/docs/battle-ui-icons-hq-gapfix-v1-atlas.png b/docs/battle-ui-icons-hq-gapfix-v1-atlas.png new file mode 100644 index 0000000..d84c100 Binary files /dev/null and b/docs/battle-ui-icons-hq-gapfix-v1-atlas.png differ diff --git a/docs/battle-ui-icons-hq-gapfix-v1-before-after.png b/docs/battle-ui-icons-hq-gapfix-v1-before-after.png new file mode 100644 index 0000000..88c8061 Binary files /dev/null and b/docs/battle-ui-icons-hq-gapfix-v1-before-after.png differ diff --git a/docs/battle-ui-icons-hq-gapfix-v1-contact.png b/docs/battle-ui-icons-hq-gapfix-v1-contact.png new file mode 100644 index 0000000..7815913 Binary files /dev/null and b/docs/battle-ui-icons-hq-gapfix-v1-contact.png differ diff --git a/docs/battle-ui-icons-hq-gapfix-v1-report.md b/docs/battle-ui-icons-hq-gapfix-v1-report.md new file mode 100644 index 0000000..733b667 --- /dev/null +++ b/docs/battle-ui-icons-hq-gapfix-v1-report.md @@ -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. diff --git a/scripts/build-battle-ui-icons.py b/scripts/build-battle-ui-icons.py index 6187c69..cd82121 100644 --- a/scripts/build-battle-ui-icons.py +++ b/scripts/build-battle-ui-icons.py @@ -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: diff --git a/src/assets/images/ui/battle-ui-icons.png b/src/assets/images/ui/battle-ui-icons.png index fd68646..d84c100 100644 Binary files a/src/assets/images/ui/battle-ui-icons.png and b/src/assets/images/ui/battle-ui-icons.png differ