Upgrade remaining battle UI icon gaps
This commit is contained in:
BIN
docs/battle-ui-icons-hq-gapfix-v1-atlas.png
Normal file
BIN
docs/battle-ui-icons-hq-gapfix-v1-atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 701 KiB |
BIN
docs/battle-ui-icons-hq-gapfix-v1-before-after.png
Normal file
BIN
docs/battle-ui-icons-hq-gapfix-v1-before-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/battle-ui-icons-hq-gapfix-v1-contact.png
Normal file
BIN
docs/battle-ui-icons-hq-gapfix-v1-contact.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
30
docs/battle-ui-icons-hq-gapfix-v1-report.md
Normal file
30
docs/battle-ui-icons-hq-gapfix-v1-report.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 701 KiB |
Reference in New Issue
Block a user