from __future__ import annotations import argparse import math from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageChops, ImageDraw from PIL.PngImagePlugin import PngInfo ROOT = Path(__file__).resolve().parents[1] UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" PREVIEW_PATH = ROOT / "tmp" / "readable-tactical-unit-preview.png" FRAME_SIZE = 313 DIRECTIONS = ("south", "east", "north", "west") IDLE_FRAMES = 8 WALK_FRAMES = 8 BASE_FRAMES_PER_DIRECTION = IDLE_FRAMES + WALK_FRAMES ACTION_ORDER = ("attack", "strategy", "item", "hurt", "celebrate") ACTION_FRAME_COUNTS = { "attack": 10, "strategy": 8, "item": 8, "hurt": 4, "celebrate": 6, } ACTION_COLUMN_OFFSETS = { action: sum(ACTION_FRAME_COUNTS[previous] for previous in ACTION_ORDER[:index]) for index, action in enumerate(ACTION_ORDER) } ACTION_FRAMES_PER_DIRECTION = sum(ACTION_FRAME_COUNTS.values()) PROCESSING_VERSION = "readable-tactical-v2" Color = tuple[int, int, int] Rgba = tuple[int, int, int, int] INK: Color = (11, 10, 8) DARK_INK: Color = (4, 4, 4) HIGHLIGHT: Color = (245, 220, 132) METAL: Color = (207, 213, 202) METAL_DARK: Color = (92, 98, 91) LEATHER: Color = (95, 58, 34) SKIN: Color = (201, 148, 94) SKIN_DARK: Color = (126, 78, 49) @dataclass(frozen=True) class Palette: primary: Color dark: Color light: Color accent: Color cloth: Color armor: Color horse: Color @dataclass(frozen=True) class UnitSpec: stem: str faction: str role: str weapon: str palette: Palette bulk: float scale: float rank: int cape: bool helmet: bool banner: bool shield: bool turban: bool hair: Color skin: Color @dataclass(frozen=True) class Motion: dx: float = 0 dy: float = 0 lean: float = 0 arm: float = 0 step: float = 0 lunge: float = 0 raise_arm: float = 0 weapon_swing: float = 0 hurt_shift: float = 0 def main() -> None: parser = argparse.ArgumentParser( description="Redraw tactical unit sprite sheets as bold, readable, original map sprites." ) parser.add_argument("--only", help="Comma-separated unit stems or file names to redraw.") parser.add_argument("--preview", default=str(PREVIEW_PATH), help="Preview contact sheet path.") parser.add_argument("--preview-only", action="store_true", help="Write only the preview contact sheet.") args = parser.parse_args() only = normalize_only(args.only) stems = discover_unit_stems(only) rendered: dict[str, tuple[Image.Image, Image.Image]] = {} for stem in stems: spec = unit_spec(stem) base = draw_base_sheet(spec) actions = draw_action_sheet(spec) rendered[stem] = (base.copy(), actions.copy()) if not args.preview_only: save_sprite_sheet(base, UNIT_DIR / f"{stem}.png") save_sprite_sheet(actions, UNIT_DIR / f"{stem}-actions.png") write_preview(rendered, Path(args.preview)) verb = "Previewed" if args.preview_only else "Redrew" print(f"{verb} {len(stems)} readable tactical unit definitions.") print(f"Preview contact sheet: {Path(args.preview).resolve()}") def normalize_only(raw: str | None) -> set[str]: if not raw: return set() return { entry.strip().removesuffix(".png").removesuffix("-actions") for entry in raw.split(",") if entry.strip() } def discover_unit_stems(only: set[str]) -> list[str]: stems = [ path.stem for path in sorted(UNIT_DIR.glob("unit-*.png")) if not path.name.endswith("-actions.png") ] if only: stems = [stem for stem in stems if stem in only] if not stems: raise ValueError("No unit sprite sheets matched the requested selection.") return stems def unit_spec(stem: str) -> UnitSpec: lower = stem.lower() faction = faction_for(lower) role = role_for(lower) weapon = weapon_for(lower, role) rank = 1 + int(any(token in lower for token in ("veteran", "guard", "elite", "warlord", "iron", "bronze"))) rank += int(any(token in lower for token in ("lu-bu", "cao-cao", "zhao-yun", "ma-chao", "guan-yu", "zhang-fei"))) palette = palette_for(stem, faction, role, rank) cape = role in ("lord", "officer", "strategist") or any(token in lower for token in ("liu-bei", "cao-cao", "lu-bu")) helmet = role not in ("strategist", "shaman") and "rebel-ragged" not in lower banner = "banner" in lower or role == "lord" or "cao-cao" in lower shield = "shield" in lower or role == "infantry" turban = faction == "rebel" or "yellow" in lower bulk = 1.0 if role in ("spearman", "officer", "lord"): bulk += 0.08 if role == "cavalry": bulk += 0.16 if any(token in lower for token in ("zhang-fei", "meng-huo", "lu-bu", "warlord")): bulk += 0.14 if role in ("archer", "strategist", "shaman"): bulk -= 0.06 scale = 1.06 + min(rank - 1, 2) * 0.04 hair = (44, 31, 23) skin = SKIN if faction == "nanman": skin = (169, 96, 58) hair = (30, 22, 18) return UnitSpec( stem=stem, faction=faction, role=role, weapon=weapon, palette=palette, bulk=bulk, scale=scale, rank=rank, cape=cape, helmet=helmet, banner=banner, shield=shield, turban=turban, hair=hair, skin=skin, ) def faction_for(lower: str) -> str: if "nanman" in lower or "meng-huo" in lower: return "nanman" if "rebel" in lower: return "rebel" if "wei-" in lower or any(token in lower for token in ("cao-cao", "sima-yi")): return "wei" if "wu-" in lower or "lu-meng" in lower: return "wu" return "shu" def role_for(lower: str) -> str: if any(token in lower for token in ("zhuge", "sima", "strategist", "shaman", "fa-zheng", "ma-liang", "yi-ji", "pang-tong")): return "strategist" if any(token in lower for token in ("archer", "longbow", "crossbow", "huang-zhong")): return "archer" if any(token in lower for token in ("cavalry", "zhao-yun", "ma-chao", "ma-dai", "lu-bu")): return "cavalry" if any(token in lower for token in ("spearman", "guan-yu", "zhang-fei", "jiang-wei", "yan-yan", "wei-yan")): return "spearman" if "liu-bei" in lower: return "lord" if any(token in lower for token in ("officer", "leader", "cao-cao", "gong-zhi", "huang-quan", "li-yan", "wu-yi", "wang-ping")): return "officer" return "infantry" def weapon_for(lower: str, role: str) -> str: if "crossbow" in lower: return "crossbow" if role == "archer": return "bow" if role == "strategist": return "fan" if "guan-yu" in lower: return "glaive" if any(token in lower for token in ("spearman", "zhang-fei", "jiang-wei", "yan-yan", "wei-yan")): return "spear" if role == "cavalry": return "lance" if role == "infantry" and "shield" in lower: return "sword" if role == "infantry": return "sword" return "sword" def palette_for(stem: str, faction: str, role: str, rank: int) -> Palette: lower = stem.lower() unique: dict[str, Palette] = { "unit-liu-bei": Palette((45, 122, 74), (21, 61, 43), (94, 176, 112), (224, 178, 66), (205, 190, 134), (74, 96, 82), (108, 78, 48)), "unit-guan-yu": Palette((27, 106, 58), (12, 54, 32), (70, 158, 88), (220, 174, 62), (42, 128, 70), (70, 86, 66), (88, 64, 45)), "unit-zhang-fei": Palette((132, 43, 43), (63, 20, 24), (194, 73, 62), (226, 143, 48), (69, 44, 39), (87, 75, 67), (90, 55, 38)), "unit-zhao-yun": Palette((218, 219, 205), (77, 92, 106), (246, 244, 224), (96, 151, 190), (108, 156, 184), (155, 164, 156), (210, 206, 190)), "unit-zhuge-liang": Palette((88, 151, 136), (37, 73, 67), (160, 211, 186), (229, 205, 96), (212, 206, 164), (74, 91, 84), (114, 91, 66)), "unit-cao-cao": Palette((62, 70, 145), (27, 32, 78), (116, 124, 202), (218, 166, 62), (67, 69, 94), (86, 92, 118), (80, 59, 49)), "unit-lu-bu": Palette((150, 37, 46), (66, 16, 22), (216, 69, 76), (231, 164, 54), (73, 41, 45), (109, 92, 83), (84, 55, 45)), "unit-meng-huo": Palette((167, 84, 42), (76, 40, 23), (222, 122, 57), (231, 173, 67), (96, 76, 43), (96, 73, 54), (90, 60, 43)), } if stem in unique: return unique[stem] base = { "shu": Palette((49, 126, 76), (21, 62, 42), (96, 178, 104), (218, 174, 64), (88, 120, 74), (76, 88, 76), (102, 76, 48)), "wei": Palette((68, 78, 150), (28, 34, 83), (116, 128, 208), (216, 166, 68), (77, 82, 114), (87, 94, 125), (72, 58, 52)), "wu": Palette((38, 129, 133), (18, 67, 73), (77, 182, 177), (224, 142, 66), (62, 121, 123), (75, 94, 93), (93, 64, 48)), "rebel": Palette((154, 111, 49), (73, 50, 26), (213, 159, 67), (220, 179, 58), (139, 88, 47), (94, 73, 55), (97, 66, 43)), "nanman": Palette((171, 84, 45), (76, 39, 25), (225, 126, 62), (217, 171, 64), (113, 92, 49), (90, 72, 54), (90, 62, 45)), }[faction] if role == "strategist": base = Palette( lighten(base.primary, 0.14), base.dark, lighten(base.light, 0.1), base.accent, lighten(base.cloth, 0.2), base.armor, base.horse, ) if role == "cavalry" and "white" in lower: base = Palette( lighten(base.primary, 0.3), base.dark, (238, 236, 216), base.accent, lighten(base.cloth, 0.2), base.armor, (205, 199, 178), ) if rank > 1: base = Palette( lighten(base.primary, 0.06), darken(base.dark, 0.05), lighten(base.light, 0.06), lighten(base.accent, 0.08), base.cloth, lighten(base.armor, 0.08), base.horse, ) return base def lighten(color: Color, amount: float) -> Color: return tuple(min(255, round(channel + (255 - channel) * amount)) for channel in color) # type: ignore[return-value] def darken(color: Color, amount: float) -> Color: return tuple(max(0, round(channel * (1 - amount))) for channel in color) # type: ignore[return-value] def draw_base_sheet(spec: UnitSpec) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * BASE_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) for row, direction in enumerate(DIRECTIONS): for frame_index in range(IDLE_FRAMES): frame = render_frame(spec, direction, "idle", frame_index, IDLE_FRAMES) sheet.alpha_composite(frame, (frame_index * FRAME_SIZE, row * FRAME_SIZE)) for frame_index in range(WALK_FRAMES): frame = render_frame(spec, direction, "walk", frame_index, WALK_FRAMES) sheet.alpha_composite(frame, ((IDLE_FRAMES + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def draw_action_sheet(spec: UnitSpec) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * ACTION_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) for row, direction in enumerate(DIRECTIONS): for action in ACTION_ORDER: frame_count = ACTION_FRAME_COUNTS[action] start = ACTION_COLUMN_OFFSETS[action] for frame_index in range(frame_count): frame = render_frame(spec, direction, action, frame_index, frame_count) sheet.alpha_composite(frame, ((start + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def render_frame(spec: UnitSpec, direction: str, action: str, frame_index: int, frame_count: int) -> Image.Image: image = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) motion = motion_for(action, frame_index, frame_count) cx = 156 + motion.dx bottom = 282 + motion.dy if direction == "east": cx += motion.lunge elif direction == "west": cx -= motion.lunge elif direction == "south": bottom += motion.lunge * 0.35 else: bottom -= motion.lunge * 0.25 if action == "strategy": draw_strategy_effect(draw, direction, cx, bottom, frame_index, frame_count, spec) elif action == "item": draw_item_effect(draw, cx, bottom, frame_index, frame_count, spec) if spec.role == "cavalry": draw_cavalry(draw, spec, direction, cx, bottom, motion, action) else: draw_human(draw, spec, direction, cx, bottom, motion, action) if action == "attack": draw_attack_effect(draw, spec, direction, cx, bottom, frame_index, frame_count) elif action == "hurt": draw_hurt_mark(draw, cx, bottom, frame_index) elif action == "celebrate": draw_celebrate_mark(draw, cx, bottom, frame_index, frame_count, spec) return flatten_alpha(image) def motion_for(action: str, frame_index: int, frame_count: int) -> Motion: phase = 2 * math.pi * frame_index / max(1, frame_count) bob = math.sin(phase) * 3 step = math.sin(phase) * 1 if action == "walk": return Motion(dy=bob, step=step, arm=-step) if action == "attack": t = frame_index / max(1, frame_count - 1) lunge = 26 * math.sin(math.pi * t) return Motion(dy=-4 * math.sin(math.pi * t), arm=1.0, lunge=lunge, weapon_swing=t) if action == "strategy": return Motion(dy=-2 + math.sin(phase) * 2, raise_arm=1.0) if action == "item": return Motion(dy=math.sin(phase) * 2, raise_arm=0.7) if action == "hurt": return Motion(dx=-9 if frame_index % 2 else 9, dy=2, hurt_shift=1.0) if action == "celebrate": return Motion(dy=-4 + math.sin(phase) * 3, raise_arm=1.0) return Motion(dy=math.sin(phase) * 1.2) def draw_human(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: float, bottom: float, motion: Motion, action: str) -> None: scale = spec.scale bulk = spec.bulk torso_top = bottom - 176 * scale torso_bottom = bottom - 60 * scale head_y = bottom - 207 * scale shoulder = 50 * bulk * scale waist = 42 * bulk * scale side = 1 if direction == "east" else -1 if direction == "west" else 0 back = direction == "north" if spec.banner: draw_banner(draw, cx - 54 * scale if side == 0 else cx - side * 46 * scale, bottom - 190 * scale, spec) if spec.cape: cape_points = [ (cx - shoulder * 0.85, torso_top + 16), (cx + shoulder * 0.85, torso_top + 16), (cx + shoulder * 0.62, torso_bottom + 52), (cx - shoulder * 0.62, torso_bottom + 52), ] draw_poly(draw, cape_points, darken(spec.palette.primary, 0.22), INK, 8) draw.line( (cx - shoulder * 0.44, torso_top + 24, cx - shoulder * 0.28, torso_bottom + 42), fill=darken(spec.palette.primary, 0.38), width=max(3, round(4 * scale)), ) draw.line( (cx + shoulder * 0.38, torso_top + 26, cx + shoulder * 0.2, torso_bottom + 44), fill=lighten(spec.palette.primary, 0.12), width=max(2, round(3 * scale)), ) leg_color = darken(spec.palette.primary, 0.15) draw_leg_pair(draw, cx, bottom, waist, scale, leg_color, motion.step, side, spec) torso = [ (cx - shoulder, torso_top), (cx + shoulder, torso_top), (cx + waist, torso_bottom), (cx + waist * 0.6, torso_bottom + 34 * scale), (cx - waist * 0.6, torso_bottom + 34 * scale), (cx - waist, torso_bottom), ] draw_poly(draw, torso, spec.palette.primary, INK, 10) draw_poly( draw, [ (cx - shoulder * 0.95, torso_top + 12), (cx - shoulder * 0.2, torso_top + 10), (cx - waist * 0.16, torso_bottom + 20), (cx - waist * 0.76, torso_bottom + 20), ], darken(spec.palette.primary, 0.17), darken(spec.palette.primary, 0.32), 2, ) draw_poly( draw, [ (cx + shoulder * 0.18, torso_top + 10), (cx + shoulder * 0.86, torso_top + 13), (cx + waist * 0.68, torso_bottom + 20), (cx + waist * 0.18, torso_bottom + 16), ], lighten(spec.palette.primary, 0.08), lighten(spec.palette.primary, 0.16), 2, ) draw_poly( draw, [ (cx - shoulder * 0.54, torso_top + 8), (cx + shoulder * 0.54, torso_top + 8), (cx + waist * 0.42, torso_bottom - 4), (cx - waist * 0.42, torso_bottom - 4), ], spec.palette.light if spec.role == "strategist" else spec.palette.armor, INK, 5, ) draw_poly( draw, [ (cx - shoulder * 0.38, torso_top + 10), (cx, torso_top + 39 * scale), (cx + shoulder * 0.38, torso_top + 10), (cx + shoulder * 0.18, torso_top + 20), (cx, torso_top + 60 * scale), (cx - shoulder * 0.18, torso_top + 20), ], spec.palette.cloth if spec.role != "strategist" else (226, 220, 176), INK, 3, ) draw_rect(draw, (cx - waist, torso_bottom - 8, cx + waist, torso_bottom + 8), spec.palette.accent, INK, 5) draw.line((cx - waist * 0.78, torso_bottom + 6, cx + waist * 0.78, torso_bottom + 6), fill=lighten(spec.palette.accent, 0.24), width=3) draw.line((cx, torso_top + 8, cx, torso_bottom - 8), fill=darken(spec.palette.primary, 0.24), width=5) if spec.role in ("officer", "lord", "spearman"): draw_armor_skirt(draw, cx, torso_bottom + 7 * scale, waist, scale, spec) arm_y = torso_top + 30 * scale left_hand = (cx - shoulder - 18 * scale, arm_y + 48 * scale + motion.arm * 8) right_hand = (cx + shoulder + 18 * scale, arm_y + 48 * scale - motion.arm * 8) if side > 0: right_hand = (cx + shoulder + 42 * scale, arm_y + 26 * scale - motion.arm * 4) elif side < 0: left_hand = (cx - shoulder - 42 * scale, arm_y + 26 * scale + motion.arm * 4) if motion.raise_arm: right_hand = (right_hand[0], right_hand[1] - 42 * motion.raise_arm) left_hand = (left_hand[0], left_hand[1] - 22 * motion.raise_arm) left_shoulder = (cx - shoulder * 0.78, arm_y) right_shoulder = (cx + shoulder * 0.78, arm_y) draw_limb(draw, left_shoulder, left_hand, spec.palette.cloth, 16) draw_limb(draw, right_shoulder, right_hand, spec.palette.cloth, 16) draw_shoulder_guard(draw, left_shoulder[0], left_shoulder[1] - 1 * scale, scale, spec, -1) draw_shoulder_guard(draw, right_shoulder[0], right_shoulder[1] - 1 * scale, scale, spec, 1) draw_cuff(draw, left_hand, scale, spec) draw_cuff(draw, right_hand, scale, spec) draw_ellipse(draw, around(left_hand, 10), spec.skin, INK, 4) draw_ellipse(draw, around(right_hand, 10), spec.skin, INK, 4) if spec.shield and direction != "north": shield_x = left_hand[0] - 6 if side >= 0 else right_hand[0] + 6 shield_y = (left_hand[1] + right_hand[1]) / 2 + 4 draw_shield(draw, shield_x, shield_y, spec) elif spec.role == "archer": draw_quiver(draw, cx - shoulder * 0.64, torso_top + 46 * scale, scale, spec) draw_weapon(draw, spec, direction, left_hand, right_hand, cx, bottom, motion, action) draw_head(draw, spec, direction, cx + side * 8 * scale, head_y, scale, back) def draw_cavalry(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: float, bottom: float, motion: Motion, action: str) -> None: scale = spec.scale side = 1 if direction == "east" else -1 if direction == "west" else 0 horse_color = spec.palette.horse horse_dark = darken(horse_color, 0.28) if side: body = (cx - 82 * scale, bottom - 112 * scale, cx + 78 * scale, bottom - 44 * scale) draw_round(draw, body, horse_color, INK, 10, 26) draw_round(draw, (cx - 62 * scale, bottom - 105 * scale, cx + 44 * scale, bottom - 56 * scale), lighten(horse_color, 0.1), darken(horse_color, 0.3), 3, 18) head_x = cx + side * 88 * scale draw_ellipse(draw, (head_x - 28 * scale, bottom - 140 * scale, head_x + 28 * scale, bottom - 82 * scale), horse_color, INK, 9) draw_poly(draw, [(head_x + side * 14 * scale, bottom - 138 * scale), (head_x + side * 40 * scale, bottom - 158 * scale), (head_x + side * 28 * scale, bottom - 118 * scale)], horse_color, INK, 7) draw.line((head_x - side * 22 * scale, bottom - 118 * scale, head_x + side * 28 * scale, bottom - 102 * scale), fill=darken(horse_color, 0.42), width=max(4, round(5 * scale))) draw.line((head_x - side * 20 * scale, bottom - 132 * scale, cx + side * 18 * scale, bottom - 102 * scale), fill=INK, width=max(3, round(4 * scale))) draw_ellipse(draw, around((head_x + side * 10 * scale, bottom - 122 * scale), 4 * scale), DARK_INK, DARK_INK, 1) draw_limb(draw, (cx - 52 * scale, bottom - 48 * scale), (cx - 68 * scale + motion.step * 12, bottom - 4 * scale), horse_dark, 15) draw_limb(draw, (cx - 18 * scale, bottom - 46 * scale), (cx - 8 * scale - motion.step * 16, bottom - 2 * scale), horse_dark, 15) draw_limb(draw, (cx + 26 * scale, bottom - 46 * scale), (cx + 42 * scale + motion.step * 16, bottom - 3 * scale), horse_dark, 15) draw_limb(draw, (cx + 58 * scale, bottom - 48 * scale), (cx + 66 * scale - motion.step * 12, bottom - 2 * scale), horse_dark, 15) draw_limb(draw, (cx - side * 82 * scale, bottom - 92 * scale), (cx - side * 112 * scale, bottom - 126 * scale), spec.palette.dark, 13) else: body = (cx - 62 * scale, bottom - 128 * scale, cx + 62 * scale, bottom - 40 * scale) draw_round(draw, body, horse_color, INK, 10, 28) draw_round(draw, (cx - 42 * scale, bottom - 118 * scale, cx + 42 * scale, bottom - 58 * scale), lighten(horse_color, 0.1), darken(horse_color, 0.3), 3, 18) draw_ellipse(draw, (cx - 40 * scale, bottom - 164 * scale, cx + 40 * scale, bottom - 96 * scale), horse_color, INK, 9) draw_ellipse(draw, around((cx - 16 * scale, bottom - 130 * scale), 4 * scale), DARK_INK, DARK_INK, 1) draw_ellipse(draw, around((cx + 16 * scale, bottom - 130 * scale), 4 * scale), DARK_INK, DARK_INK, 1) draw_limb(draw, (cx - 38 * scale, bottom - 48 * scale), (cx - 56 * scale, bottom - 2 * scale), horse_dark, 16) draw_limb(draw, (cx + 38 * scale, bottom - 48 * scale), (cx + 56 * scale, bottom - 2 * scale), horse_dark, 16) draw_limb(draw, (cx - 12 * scale, bottom - 46 * scale), (cx - 16 * scale, bottom - 2 * scale), horse_dark, 13) draw_limb(draw, (cx + 12 * scale, bottom - 46 * scale), (cx + 16 * scale, bottom - 2 * scale), horse_dark, 13) draw_saddle(draw, cx, bottom - 98 * scale, scale, spec, side) rider_bottom = bottom - 88 * scale rider_spec = UnitSpec( **{ **spec.__dict__, "role": "officer", "shield": False, "banner": False, "scale": spec.scale * 0.82, "bulk": max(0.95, spec.bulk - 0.08), } ) draw_human(draw, rider_spec, direction, cx, rider_bottom, motion, action) def draw_leg_pair(draw: ImageDraw.ImageDraw, cx: float, bottom: float, waist: float, scale: float, color: Color, step: float, side: int, spec: UnitSpec) -> None: if side: left_start = (cx - 18 * scale, bottom - 60 * scale) left_end = (cx - 34 * scale - step * 6, bottom - 7 * scale) right_start = (cx + 20 * scale, bottom - 60 * scale) right_end = (cx + 36 * scale + step * 7, bottom - 7 * scale) else: left_start = (cx - waist * 0.45, bottom - 62 * scale) left_end = (cx - 36 * scale - step * 7, bottom - 8 * scale) right_start = (cx + waist * 0.45, bottom - 62 * scale) right_end = (cx + 36 * scale + step * 7, bottom - 8 * scale) draw_limb(draw, left_start, left_end, color, 18) draw_limb(draw, right_start, right_end, color, 18) draw_limb(draw, (left_start[0], left_start[1] + 25 * scale), (left_end[0], left_end[1] + 1 * scale), darken(color, 0.18), 6) draw_limb(draw, (right_start[0], right_start[1] + 25 * scale), (right_end[0], right_end[1] + 1 * scale), lighten(color, 0.08), 5) if spec.rank > 1: draw_rect(draw, (cx - 50 * scale, bottom - 58 * scale, cx - 20 * scale, bottom - 37 * scale), spec.palette.armor, INK, 3) draw_rect(draw, (cx + 20 * scale, bottom - 58 * scale, cx + 50 * scale, bottom - 37 * scale), spec.palette.armor, INK, 3) draw_ellipse(draw, (cx - 58 * scale, bottom - 18 * scale, cx - 16 * scale, bottom + 2 * scale), darken(color, 0.2), INK, 4) draw_ellipse(draw, (cx + 16 * scale, bottom - 18 * scale, cx + 58 * scale, bottom + 2 * scale), darken(color, 0.2), INK, 4) def draw_head(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: float, cy: float, scale: float, back: bool) -> None: r = 27 * scale draw_ellipse(draw, (cx - r, cy - r, cx + r, cy + r), spec.skin, INK, 8) if not back: draw_ellipse(draw, (cx - r * 0.42, cy - r * 0.18, cx + r * 0.78, cy + r * 0.62), lighten(spec.skin, 0.16), spec.skin, 1) draw.line((cx - r * 0.62, cy + r * 0.1, cx + r * 0.64, cy + r * 0.12), fill=SKIN_DARK, width=max(2, round(2 * scale))) if spec.turban: draw_round(draw, (cx - r * 1.15, cy - r * 0.98, cx + r * 1.15, cy - r * 0.33), spec.palette.accent, INK, 5, 10) draw.line((cx - r * 0.9, cy - r * 0.36, cx + r * 0.9, cy - r * 0.36), fill=darken(spec.palette.accent, 0.25), width=5) knot_x = cx + r * 0.82 draw_poly(draw, [(knot_x, cy - r * 0.72), (knot_x + r * 0.36, cy - r * 0.42), (knot_x, cy - r * 0.25)], spec.palette.accent, INK, 3) elif spec.helmet: draw_poly(draw, [(cx - r * 1.08, cy - r * 0.45), (cx, cy - r * 1.34), (cx + r * 1.08, cy - r * 0.45)], spec.palette.armor, INK, 6) draw_rect(draw, (cx - r * 0.92, cy - r * 0.58, cx + r * 0.92, cy - r * 0.22), spec.palette.accent, INK, 4) if spec.rank > 1: draw.line((cx, cy - r * 1.32, cx, cy - r * 1.78), fill=INK, width=max(4, round(5 * scale))) draw.line((cx, cy - r * 1.32, cx, cy - r * 1.72), fill=spec.palette.accent, width=max(2, round(3 * scale))) else: draw_ellipse(draw, (cx - r * 1.08, cy - r * 1.1, cx + r * 1.08, cy - r * 0.1), spec.hair, INK, 5) draw_ellipse(draw, (cx - r * 0.35, cy - r * 1.48, cx + r * 0.35, cy - r * 0.92), spec.hair, INK, 4) if not back: eye_y = cy - r * 0.04 brow = 5 if spec.role in ("lord", "officer", "spearman", "cavalry") else 4 draw.line((cx - r * 0.58, eye_y - r * 0.14, cx - r * 0.14, eye_y - r * 0.04), fill=DARK_INK, width=brow) draw.line((cx + r * 0.14, eye_y - r * 0.04, cx + r * 0.58, eye_y - r * 0.14), fill=DARK_INK, width=brow) draw.line((cx - r * 0.48, eye_y + r * 0.05, cx - r * 0.22, eye_y + r * 0.06), fill=DARK_INK, width=3) draw.line((cx + r * 0.22, eye_y + r * 0.06, cx + r * 0.48, eye_y + r * 0.05), fill=DARK_INK, width=3) draw.line((cx + r * 0.02, cy + r * 0.05, cx - r * 0.06, cy + r * 0.25), fill=SKIN_DARK, width=3) if spec.stem in ("unit-guan-yu", "unit-zhang-fei", "unit-cao-cao", "unit-lu-bu") or spec.role == "officer": beard_color = spec.hair if spec.stem == "unit-guan-yu": beard_color = (31, 24, 18) draw.line((cx - r * 0.44, cy + r * 0.39, cx + r * 0.44, cy + r * 0.39), fill=beard_color, width=max(6, round(7 * scale))) draw.line((cx, cy + r * 0.42, cx, cy + r * 1.12), fill=beard_color, width=max(6, round(8 * scale))) draw.line((cx - r * 0.22, cy + r * 0.56, cx - r * 0.1, cy + r * 0.95), fill=lighten(beard_color, 0.15), width=2) def draw_weapon( draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, left_hand: tuple[float, float], right_hand: tuple[float, float], cx: float, bottom: float, motion: Motion, action: str, ) -> None: side = 1 if direction == "east" else -1 if direction == "west" else 0 hand = right_hand if direction != "west" else left_hand if spec.weapon in ("spear", "glaive", "lance"): if side: start = (hand[0] - side * 32, hand[1] + 34) end = (hand[0] + side * (106 + motion.lunge * 0.6), hand[1] - 54) elif direction == "north": start = (cx + 36, bottom - 48) end = (cx - 32, bottom - 236) else: start = (cx + 45, bottom - 38) end = (cx - 38, bottom - 238) draw_limb(draw, start, end, (92, 59, 31), 11) draw_limb(draw, (start[0], start[1] - 8), (end[0], end[1] + 8), (143, 91, 44), 4) blade = 30 if spec.weapon != "glaive" else 42 draw_poly( draw, [ (end[0], end[1] - blade), (end[0] + (18 if side >= 0 else -18), end[1] + 4), (end[0], end[1] + 18), (end[0] - (18 if side >= 0 else -18), end[1] + 4), ], METAL, INK, 5, ) draw.line((end[0] - 6, end[1] - blade * 0.64, end[0] + 8, end[1] + 8), fill=(246, 249, 232), width=3) if spec.weapon in ("spear", "glaive"): tassel_color = (196, 38, 34) if spec.faction != "rebel" else spec.palette.accent draw.line((end[0] - 17, end[1] + 22, end[0] - 32, end[1] + 48), fill=INK, width=7) draw.line((end[0] - 17, end[1] + 22, end[0] - 32, end[1] + 48), fill=tassel_color, width=4) draw.line((end[0] + 17, end[1] + 22, end[0] + 31, end[1] + 46), fill=INK, width=7) draw.line((end[0] + 17, end[1] + 22, end[0] + 31, end[1] + 46), fill=lighten(tassel_color, 0.14), width=4) if spec.weapon == "glaive": draw_poly(draw, [(end[0] + 5, end[1] - 6), (end[0] + 40, end[1] + 22), (end[0] + 8, end[1] + 35)], METAL, INK, 5) draw.line((end[0] + 11, end[1] + 1, end[0] + 31, end[1] + 20), fill=(246, 249, 232), width=3) elif spec.weapon in ("bow", "crossbow"): bow_cx = hand[0] + (side or 1) * 22 bow_cy = hand[1] - 22 if spec.weapon == "crossbow": draw_limb(draw, (bow_cx - 44, bow_cy), (bow_cx + 44, bow_cy), (100, 57, 30), 12) draw_limb(draw, (bow_cx, bow_cy - 32), (bow_cx, bow_cy + 32), (100, 57, 30), 9) draw_limb(draw, (bow_cx - 64, bow_cy - 6), (bow_cx + 72, bow_cy - 6), METAL, 5) else: bbox = (bow_cx - 42, bow_cy - 72, bow_cx + 42, bow_cy + 72) draw.arc(bbox, 255 if side < 0 else -75, 105 if side < 0 else 75, fill=INK, width=14) draw.arc(bbox, 255 if side < 0 else -75, 105 if side < 0 else 75, fill=(118, 68, 34), width=7) draw.arc((bbox[0] + 7, bbox[1] + 7, bbox[2] - 7, bbox[3] - 7), 255 if side < 0 else -75, 105 if side < 0 else 75, fill=(193, 122, 55), width=3) draw.line((bow_cx, bow_cy - 68, bow_cx, bow_cy + 68), fill=METAL_DARK, width=3) draw_limb(draw, (bow_cx - 48 * (side or -1), bow_cy), (bow_cx + 62 * (side or 1), bow_cy - 8), METAL, 5) elif spec.weapon == "fan": fan_cx = hand[0] fan_cy = hand[1] - 16 fan_points = [ (fan_cx, fan_cy), (fan_cx + 56, fan_cy - 44), (fan_cx + 78, fan_cy + 20), (fan_cx + 10, fan_cy + 32), ] if direction == "west": fan_points = [(2 * fan_cx - x, y) for x, y in fan_points] draw_poly(draw, fan_points, (230, 220, 172), INK, 6) draw_poly(draw, [(fan_cx, fan_cy), fan_points[1], fan_points[2]], (246, 237, 190), (205, 184, 96), 2) for angle in (-36, -15, 8, 28): dx = math.cos(math.radians(angle)) * 66 dy = math.sin(math.radians(angle)) * 66 if direction == "west": dx *= -1 draw.line((fan_cx, fan_cy, fan_cx + dx, fan_cy + dy), fill=darken(spec.palette.accent, 0.1), width=3) else: if side: end = (hand[0] + side * (76 + motion.lunge * 0.4), hand[1] - 56) elif direction == "north": end = (hand[0] - 38, hand[1] - 72) else: end = (hand[0] + 52, hand[1] - 74) draw_limb(draw, hand, end, METAL, 10) draw_limb(draw, hand, end, (246, 249, 232), 4) draw_rect(draw, (hand[0] - 22, hand[1] - 8, hand[0] + 22, hand[1] + 8), spec.palette.accent, INK, 4) def draw_shield(draw: ImageDraw.ImageDraw, cx: float, cy: float, spec: UnitSpec) -> None: points = [(cx, cy - 42), (cx + 34, cy - 22), (cx + 24, cy + 38), (cx, cy + 54), (cx - 24, cy + 38), (cx - 34, cy - 22)] draw_poly(draw, points, spec.palette.armor, INK, 7) draw.line((cx, cy - 30, cx, cy + 38), fill=spec.palette.accent, width=5) draw.line((cx - 20, cy - 14, cx + 20, cy + 2), fill=lighten(spec.palette.armor, 0.18), width=4) draw_ellipse(draw, around((cx, cy + 6), 8), spec.palette.accent, INK, 3) def draw_banner(draw: ImageDraw.ImageDraw, x: float, y: float, spec: UnitSpec) -> None: draw_limb(draw, (x, y + 100), (x, y - 34), (89, 55, 30), 8) flag = [(x, y - 34), (x + 58, y - 22), (x + 46, y + 28), (x, y + 18)] draw_poly(draw, flag, spec.palette.accent, INK, 5) draw.line((x + 11, y - 13, x + 42, y - 7), fill=darken(spec.palette.accent, 0.35), width=5) draw.line((x + 8, y + 8, x + 39, y + 12), fill=lighten(spec.palette.accent, 0.2), width=4) def draw_armor_skirt(draw: ImageDraw.ImageDraw, cx: float, y: float, waist: float, scale: float, spec: UnitSpec) -> None: plate_w = 20 * scale for index, offset in enumerate((-0.56, 0, 0.56)): x = cx + waist * offset fill = spec.palette.armor if index != 1 else lighten(spec.palette.armor, 0.08) points = [(x - plate_w, y - 8 * scale), (x + plate_w, y - 8 * scale), (x + plate_w * 0.74, y + 34 * scale), (x - plate_w * 0.74, y + 34 * scale)] draw_poly(draw, points, fill, INK, 3) draw.line((x, y - 3 * scale, x, y + 26 * scale), fill=darken(fill, 0.25), width=max(2, round(2 * scale))) def draw_shoulder_guard(draw: ImageDraw.ImageDraw, x: float, y: float, scale: float, spec: UnitSpec, side: int) -> None: box = (x - 22 * scale, y - 17 * scale, x + 22 * scale, y + 15 * scale) draw_ellipse(draw, box, spec.palette.armor, INK, 4) draw.line((x - side * 14 * scale, y - 6 * scale, x + side * 12 * scale, y + 4 * scale), fill=lighten(spec.palette.armor, 0.18), width=max(2, round(3 * scale))) def draw_cuff(draw: ImageDraw.ImageDraw, hand: tuple[float, float], scale: float, spec: UnitSpec) -> None: draw_rect( draw, (hand[0] - 14 * scale, hand[1] - 15 * scale, hand[0] + 14 * scale, hand[1] - 2 * scale), darken(spec.palette.cloth, 0.14), INK, 2, ) def draw_quiver(draw: ImageDraw.ImageDraw, x: float, y: float, scale: float, spec: UnitSpec) -> None: draw_round(draw, (x - 13 * scale, y - 24 * scale, x + 17 * scale, y + 50 * scale), LEATHER, INK, 4, 8) for offset in (-9, 0, 9): draw_limb(draw, (x + offset * scale, y - 42 * scale), (x + offset * scale + 8 * scale, y - 12 * scale), METAL, 3) draw.line((x - 6 * scale, y - 12 * scale, x + 10 * scale, y + 32 * scale), fill=lighten(LEATHER, 0.18), width=max(2, round(3 * scale))) def draw_saddle(draw: ImageDraw.ImageDraw, cx: float, y: float, scale: float, spec: UnitSpec, side: int) -> None: draw_round(draw, (cx - 46 * scale, y - 19 * scale, cx + 46 * scale, y + 18 * scale), darken(spec.palette.cloth, 0.18), INK, 5, 10) draw_round(draw, (cx - 32 * scale, y - 12 * scale, cx + 32 * scale, y + 12 * scale), spec.palette.accent, darken(spec.palette.accent, 0.34), 2, 8) if side: draw.line((cx - side * 14 * scale, y + 13 * scale, cx - side * 22 * scale, y + 43 * scale), fill=INK, width=max(3, round(4 * scale))) draw.line((cx - side * 14 * scale, y + 13 * scale, cx - side * 22 * scale, y + 43 * scale), fill=LEATHER, width=max(2, round(2 * scale))) def draw_attack_effect(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: float, bottom: float, frame_index: int, frame_count: int) -> None: t = frame_index / max(1, frame_count - 1) if t < 0.28 or t > 0.82: return side = 1 if direction == "east" else -1 if direction == "west" else 0 color = lighten(spec.palette.accent, 0.18) if spec.weapon in ("bow", "crossbow", "fan"): return if side: points = [(cx + side * 24, bottom - 190), (cx + side * 126, bottom - 132), (cx + side * 104, bottom - 72)] elif direction == "north": points = [(cx - 70, bottom - 142), (cx + 4, bottom - 238), (cx + 80, bottom - 154)] else: points = [(cx - 78, bottom - 168), (cx + 8, bottom - 72), (cx + 88, bottom - 166)] draw.line(points, fill=INK, width=17, joint="curve") draw.line(points, fill=color, width=9, joint="curve") draw.line(points, fill=(250, 238, 180), width=4, joint="curve") def draw_strategy_effect(draw: ImageDraw.ImageDraw, direction: str, cx: float, bottom: float, frame_index: int, frame_count: int, spec: UnitSpec) -> None: if frame_index % 2: return center = (cx, bottom - 130) radius = 62 + 5 * (frame_index % 3) draw.ellipse((center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), outline=INK, width=8) draw.ellipse((center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), outline=spec.palette.accent, width=4) def draw_item_effect(draw: ImageDraw.ImageDraw, cx: float, bottom: float, frame_index: int, frame_count: int, spec: UnitSpec) -> None: y = bottom - 226 - 4 * math.sin(frame_index / max(1, frame_count) * 2 * math.pi) draw_ellipse(draw, (cx - 18, y - 18, cx + 18, y + 18), spec.palette.accent, INK, 5) def draw_hurt_mark(draw: ImageDraw.ImageDraw, cx: float, bottom: float, frame_index: int) -> None: color = (219, 52, 42) for dx, dy in ((-52, -178), (55, -156), (-18, -220)): draw.line((cx + dx - 10, bottom + dy - 10, cx + dx + 10, bottom + dy + 10), fill=INK, width=8) draw.line((cx + dx + 10, bottom + dy - 10, cx + dx - 10, bottom + dy + 10), fill=INK, width=8) draw.line((cx + dx - 10, bottom + dy - 10, cx + dx + 10, bottom + dy + 10), fill=color, width=4) draw.line((cx + dx + 10, bottom + dy - 10, cx + dx - 10, bottom + dy + 10), fill=color, width=4) def draw_celebrate_mark(draw: ImageDraw.ImageDraw, cx: float, bottom: float, frame_index: int, frame_count: int, spec: UnitSpec) -> None: angle = frame_index / max(1, frame_count) * 2 * math.pi for index in range(3): x = cx + math.cos(angle + index * 2.1) * 62 y = bottom - 214 + math.sin(angle + index * 2.1) * 22 draw_poly(draw, [(x, y - 10), (x + 8, y), (x, y + 10), (x - 8, y)], spec.palette.accent, INK, 3) def draw_limb(draw: ImageDraw.ImageDraw, start: tuple[float, float], end: tuple[float, float], fill: Color, width: int) -> None: draw.line((start[0], start[1], end[0], end[1]), fill=INK, width=width + 8) draw.line((start[0], start[1], end[0], end[1]), fill=fill, width=width) def draw_poly(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: Color, outline: Color, width: int) -> None: draw.line(points + [points[0]], fill=outline, width=width, joint="curve") draw.polygon(points, fill=fill) draw.line(points + [points[0]], fill=outline, width=max(2, width // 2), joint="curve") def draw_rect(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], fill: Color, outline: Color, width: int) -> None: draw.rectangle(box, fill=fill, outline=outline, width=width) def draw_round(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], fill: Color, outline: Color, width: int, radius: int) -> None: draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) def draw_ellipse(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], fill: Color, outline: Color, width: int) -> None: draw.ellipse(box, fill=fill, outline=outline, width=width) def around(point: tuple[float, float], radius: float) -> tuple[float, float, float, float]: return (point[0] - radius, point[1] - radius, point[0] + radius, point[1] + radius) def flatten_alpha(image: Image.Image) -> Image.Image: image = image.convert("RGBA") alpha = image.getchannel("A").point(lambda value: 0 if value < 8 else 255) clear = Image.new("RGBA", image.size, (0, 0, 0, 0)) image.putalpha(alpha) return Image.composite(image, clear, alpha) def save_sprite_sheet(image: Image.Image, path: Path) -> None: image = flatten_alpha(image) png_info = PngInfo() png_info.add_text("heros_unit_sprite", PROCESSING_VERSION) image.save(path, optimize=True, pnginfo=png_info) def write_preview(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) stems = list(rendered.keys()) thumb = 64 cols = 16 rows_per_unit = 8 label_h = 20 width = cols * thumb + 190 height = max(1, len(stems)) * (rows_per_unit * thumb + label_h) preview = Image.new("RGBA", (width, height), (28, 32, 27, 255)) draw = ImageDraw.Draw(preview) for unit_index, stem in enumerate(stems): y0 = unit_index * (rows_per_unit * thumb + label_h) spec = unit_spec(stem) draw.rectangle((0, y0, width, y0 + label_h), fill=(48, 57, 39)) draw.text((8, y0 + 4), f"{stem} role={spec.role} weapon={spec.weapon}", fill=(232, 224, 190)) base, actions = rendered[stem] frames: list[Image.Image] = [] for row in range(4): for col in range(BASE_FRAMES_PER_DIRECTION): frames.append(crop_frame(base, row, col)) action_frames: list[Image.Image] = [] for row in range(4): for col in range(10): action_frames.append(crop_frame(actions, row, col)) sample_frames = frames[:64] + action_frames[:64] for index, frame in enumerate(sample_frames[: rows_per_unit * cols]): col = index % cols row = index // cols x = 190 + col * thumb y = y0 + label_h + row * thumb tile = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (80, 94, 58, 255)) grid = ImageDraw.Draw(tile) grid.line((0, 0, FRAME_SIZE, 0), fill=(23, 31, 24), width=8) grid.line((0, 0, 0, FRAME_SIZE), fill=(23, 31, 24), width=8) tile.alpha_composite(frame) preview.alpha_composite(tile.resize((thumb, thumb), Image.Resampling.LANCZOS), (x, y)) preview.save(path, optimize=True) def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image: return sheet.crop((col * FRAME_SIZE, row * FRAME_SIZE, (col + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)) if __name__ == "__main__": main()