from __future__ import annotations import argparse import colorsys import hashlib import math from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageFont ROOT = Path(__file__).resolve().parents[1] UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" PREVIEW_PATH = ROOT / "docs" / "unit-sprite-direction-preview.png" FRAME_SIZE = 313 DRAW_SCALE = 2 IDLE_FRAME_COUNT = 8 MOVE_FRAME_COUNT = 8 BASE_FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + MOVE_FRAME_COUNT PALETTE_COLORS = 208 DIRECTIONS = ("south", "east", "north", "west") 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()) REPRESENTATIVE_UNITS = ( "unit-shu-infantry", "unit-shu-spearman", "unit-shu-archer", "unit-shu-strategist", "unit-shu-cavalry", "unit-zhao-yun", ) Color = tuple[int, int, int] Rgba = tuple[int, int, int, int] INK: Rgba = (12, 9, 8, 235) DEEP_INK: Rgba = (5, 4, 4, 245) GROUND_SHADOW: Rgba = (23, 19, 15, 80) @dataclass(frozen=True) class Palette: primary: Color secondary: Color accent: Color cloth: Color armor: Color horse: Color trim: Color @dataclass(frozen=True) class UnitPlan: stem: str faction: str role: str weapon: str palette: Palette skin: Color hair: Color metal: Color cape: bool helmet: bool banner: bool scale: float bulk: float rank: int @dataclass(frozen=True) class Motion: body_dx: float = 0 body_dy: float = 0 lean: float = 0 leg: float = 0 arm: float = 0 weapon: float = 0 raise_arm: float = 0 lunge: float = 0 impact: float = 0 horse_leg: float = 0 squash_x: float = 1 squash_y: float = 1 def main() -> None: parser = argparse.ArgumentParser( description=( "Draw original four-direction frame-by-frame tactical RPG unit sprite sheets. " "This does not transform the previous unit art or reuse the source atlas." ) ) parser.add_argument("--only", help="Comma-separated unit stems or filenames to redraw.") parser.add_argument("--out-dir", default=str(UNIT_DIR), help="Output directory for generated sprite sheets.") 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 representative preview sheet.") args = parser.parse_args() output_dir = Path(args.out_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) preview_path = Path(args.preview).resolve() preview_path.parent.mkdir(parents=True, exist_ok=True) only = normalize_only(args.only) stems = discover_unit_stems(only) preview_cache: dict[str, tuple[Image.Image, Image.Image]] = {} written = 0 for stem in stems: plan = unit_plan(stem) base_sheet = draw_base_sheet(plan) action_sheet = draw_action_sheet(plan) if stem in REPRESENTATIVE_UNITS: preview_cache[stem] = (base_sheet, action_sheet) if not args.preview_only: save_unit_png(base_sheet, output_dir / f"{stem}.png") save_unit_png(action_sheet, output_dir / f"{stem}-actions.png") written += 2 missing_preview = [stem for stem in REPRESENTATIVE_UNITS if stem not in preview_cache] for stem in missing_preview: plan = unit_plan(stem) preview_cache[stem] = (draw_base_sheet(plan), draw_action_sheet(plan)) write_preview_sheet(preview_cache, preview_path) action = "Previewed" if args.preview_only else "Redrew" print(f"{action} {len(stems)} unit sprite definitions; wrote {written} sprite sheets.") print(f"Preview contact sheet: {preview_path}") 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 draw_base_sheet(plan: UnitPlan) -> 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_FRAME_COUNT): frame = render_frame(plan, direction, "idle", frame_index, IDLE_FRAME_COUNT) sheet.alpha_composite(frame, (frame_index * FRAME_SIZE, row * FRAME_SIZE)) for frame_index in range(MOVE_FRAME_COUNT): frame = render_frame(plan, direction, "move", frame_index, MOVE_FRAME_COUNT) sheet.alpha_composite(frame, ((IDLE_FRAME_COUNT + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def draw_action_sheet(plan: UnitPlan) -> 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_col = ACTION_COLUMN_OFFSETS[action] for frame_index in range(frame_count): frame = render_frame(plan, direction, action, frame_index, frame_count) sheet.alpha_composite(frame, ((start_col + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def render_frame(plan: UnitPlan, direction: str, action: str, frame_index: int, frame_count: int) -> Image.Image: image = Image.new("RGBA", (FRAME_SIZE * DRAW_SCALE, FRAME_SIZE * DRAW_SCALE), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) motion = motion_for(plan, direction, action, frame_index, frame_count) body_center_x = 156 + motion.body_dx bottom_y = 280 + motion.body_dy draw_ground_shadow(draw, body_center_x, bottom_y, plan, direction, motion) if action in ("strategy", "item"): draw_support_effect(draw, action, direction, frame_index, frame_count, body_center_x, bottom_y) if plan.role == "cavalry": draw_cavalry(draw, plan, direction, body_center_x, bottom_y, motion, action) else: draw_human(draw, plan, direction, body_center_x, bottom_y, motion, action) if action == "attack": draw_attack_effect(draw, plan, direction, body_center_x, bottom_y, frame_index, frame_count) elif action == "hurt": draw_hurt_effect(draw, direction, body_center_x, bottom_y, frame_index) elif action == "celebrate": draw_celebrate_effect(draw, body_center_x, bottom_y, frame_index, frame_count) image = image.resize((FRAME_SIZE, FRAME_SIZE), Image.Resampling.LANCZOS) if action == "hurt": image = tint_alpha(image, (188, 38, 32), 0.22) image = finish_frame(image) return image def motion_for(plan: UnitPlan, direction: str, action: str, frame_index: int, frame_count: int) -> Motion: phase = frame_index / max(1, frame_count) * math.tau side_sign = 1 if direction == "east" else -1 if direction == "west" else 0 if action == "move": step = math.sin(phase) lift = abs(math.sin(phase)) mounted = plan.role == "cavalry" return Motion( body_dx=side_sign * step * (3.8 if mounted else 2.1), body_dy=-(2.4 if mounted else 1.7) * lift, lean=side_sign * step * (2.2 if mounted else 1.4), leg=step, arm=-step, weapon=math.cos(phase) * (4.8 if mounted else 3.2), horse_leg=step, squash_x=1 + (0.014 if mounted else 0.008) * math.cos(phase), squash_y=1 - (0.01 if mounted else 0.006) * math.cos(phase), ) if action == "attack": t = frame_index / max(1, frame_count - 1) windup = math.sin(min(1, t / 0.38) * math.pi) if t < 0.38 else 0 strike = math.sin(max(0, min(1, (t - 0.28) / 0.42)) * math.pi) recover = math.sin(max(0, min(1, (t - 0.68) / 0.32)) * math.pi) dx, dy = direction_vector(direction) lunge = strike * (25 if plan.role == "cavalry" else 17) - windup * 6 - recover * 3 return Motion( body_dx=dx * lunge + side_sign * windup * -4, body_dy=dy * lunge - strike * 4, lean=side_sign * (strike * 6 - windup * 4), leg=strike * 0.8 - recover * 0.35, arm=strike, weapon=strike * 12 - windup * 8, lunge=strike, impact=1 if 0.43 <= t <= 0.66 else 0, horse_leg=strike, ) if action == "hurt": values = (0.0, 1.0, 0.72, 0.24) power = values[min(frame_index, len(values) - 1)] dx, dy = direction_vector(direction) return Motion( body_dx=-dx * 18 * power, body_dy=-dy * 14 * power - 3 * power, lean=-side_sign * 7 * power, leg=-0.45 * power, arm=0.5 * power, weapon=-4 * power, impact=power, ) if action == "celebrate": lift = max(0.0, math.sin(frame_index / max(1, frame_count - 1) * math.pi)) sway = math.sin(frame_index / max(1, frame_count - 1) * math.tau) return Motion( body_dx=side_sign * sway * 2, body_dy=-lift * (13 if plan.role == "cavalry" else 10), lean=side_sign * sway * 2, leg=sway * 0.35, arm=sway * 0.3, raise_arm=lift, weapon=lift * 8, horse_leg=sway * 0.25, squash_y=1 + lift * 0.025, ) if action == "strategy": pulse = math.sin(phase) return Motion(body_dy=-3 - abs(pulse) * 2, arm=0.15, raise_arm=0.55 + abs(pulse) * 0.35, weapon=pulse * 3) if action == "item": lift = math.sin(min(1, frame_index / max(1, frame_count - 1)) * math.pi) return Motion(body_dy=-lift * 5, raise_arm=lift * 0.9, arm=lift * 0.4, weapon=lift * 4) breathe = math.sin(phase) mounted = plan.role == "cavalry" return Motion( body_dx=side_sign * breathe * (0.9 if mounted else 0.5), body_dy=-abs(breathe) * (1.6 if mounted else 1.0), lean=side_sign * breathe * (0.6 if mounted else 0.4), leg=breathe * 0.15, arm=breathe * 0.18, weapon=breathe * 2.2, horse_leg=breathe * 0.18, squash_x=1 + 0.004 * breathe, squash_y=1 - 0.006 * breathe, ) def draw_human( draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, motion: Motion, action: str, mounted: bool = False, ) -> None: scale = plan.scale * (0.82 if mounted else 1.0) bulk = plan.bulk foot_y = bottom leg_top_y = bottom - 56 * scale hip_y = bottom - 72 * scale chest_y = bottom - 122 * scale shoulder_y = bottom - 134 * scale head_y = bottom - 165 * scale side_sign = 1 if direction == "east" else -1 if direction == "west" else 0 facing_front = direction == "south" facing_back = direction == "north" side_view = direction in ("east", "west") leg_stride = motion.leg * (23 if side_view else 9) * scale arm_stride = motion.arm * (15 if side_view else 8) * scale raise_arm = motion.raise_arm lean = motion.lean if action == "celebrate": raise_arm = max(raise_arm, 0.75) if plan.cape and not side_view: cape_top = shoulder_y - 1 cape_bottom = bottom - 12 * scale cape_w = 38 * scale * bulk cape_color = with_alpha(darken(plan.palette.secondary, 0.76), 210 if facing_back else 155) polygon( draw, [ (cx - cape_w, cape_top), (cx + cape_w, cape_top), (cx + cape_w * 0.78 + math.sin(motion.weapon) * 1.5, cape_bottom), (cx - cape_w * 0.78 + math.cos(motion.weapon) * 1.5, cape_bottom), ], cape_color, outline=INK, width=3, ) if side_view: draw_human_side(draw, plan, direction, cx, bottom, scale, bulk, leg_stride, arm_stride, raise_arm, lean) elif facing_back: draw_human_back(draw, plan, cx, bottom, scale, bulk, leg_stride, arm_stride, raise_arm, lean) else: draw_human_front(draw, plan, cx, bottom, scale, bulk, leg_stride, arm_stride, raise_arm, lean) draw_weapon(draw, plan, direction, cx, bottom, scale, motion, action, mounted) def draw_human_front( draw: ImageDraw.ImageDraw, plan: UnitPlan, cx: float, bottom: float, scale: float, bulk: float, leg_stride: float, arm_stride: float, raise_arm: float, lean: float, ) -> None: hip_y = bottom - 72 * scale knee_y = bottom - 35 * scale shoulder_y = bottom - 134 * scale chest_y = bottom - 116 * scale head_y = bottom - 165 * scale stance = 16 * scale left_foot = (cx - stance - leg_stride * 0.18, bottom) right_foot = (cx + stance + leg_stride * 0.18, bottom - 2 * abs(math.sin(leg_stride / 20))) limb(draw, (cx - 12 * scale, hip_y), (cx - 17 * scale - leg_stride * 0.25, knee_y), left_foot, plan.palette.secondary, 14 * scale) limb(draw, (cx + 12 * scale, hip_y), (cx + 17 * scale + leg_stride * 0.25, knee_y), right_foot, plan.palette.secondary, 14 * scale) boot(draw, left_foot, 17 * scale) boot(draw, right_foot, 17 * scale) body_w = 62 * scale * bulk waist_w = 42 * scale * bulk polygon( draw, [ (cx - body_w / 2 + lean * 0.25, shoulder_y), (cx + body_w / 2 + lean * 0.25, shoulder_y), (cx + waist_w / 2 - lean * 0.15, hip_y), (cx - waist_w / 2 - lean * 0.15, hip_y), ], with_alpha(plan.palette.armor, 255), outline=INK, width=4, ) polygon( draw, [ (cx - 24 * scale, shoulder_y + 13 * scale), (cx, chest_y + 34 * scale), (cx + 24 * scale, shoulder_y + 13 * scale), (cx + 14 * scale, hip_y - 3 * scale), (cx - 14 * scale, hip_y - 3 * scale), ], with_alpha(plan.palette.primary, 235), outline=with_alpha(darken(plan.palette.primary, 0.48), 190), width=2, ) belt(draw, cx, hip_y, 50 * scale, plan.palette.accent) left_hand = (cx - 42 * scale - arm_stride * 0.18, bottom - (91 + 38 * raise_arm) * scale) right_hand = (cx + 42 * scale + arm_stride * 0.18, bottom - (92 + 40 * raise_arm) * scale) arm(draw, (cx - 30 * scale, shoulder_y + 10 * scale), left_hand, plan.palette.cloth, 13 * scale) arm(draw, (cx + 30 * scale, shoulder_y + 10 * scale), right_hand, plan.palette.cloth, 13 * scale) hand(draw, left_hand, plan.skin, 7 * scale) hand(draw, right_hand, plan.skin, 7 * scale) neck(draw, cx, head_y + 21 * scale, plan.skin, scale) head_front(draw, plan, cx + lean * 0.18, head_y, scale) def draw_human_back( draw: ImageDraw.ImageDraw, plan: UnitPlan, cx: float, bottom: float, scale: float, bulk: float, leg_stride: float, arm_stride: float, raise_arm: float, lean: float, ) -> None: hip_y = bottom - 72 * scale knee_y = bottom - 35 * scale shoulder_y = bottom - 134 * scale head_y = bottom - 165 * scale stance = 15 * scale left_foot = (cx - stance + leg_stride * 0.12, bottom) right_foot = (cx + stance - leg_stride * 0.12, bottom) limb(draw, (cx - 11 * scale, hip_y), (cx - 16 * scale + leg_stride * 0.18, knee_y), left_foot, darken(plan.palette.secondary, 0.84), 14 * scale) limb(draw, (cx + 11 * scale, hip_y), (cx + 16 * scale - leg_stride * 0.18, knee_y), right_foot, darken(plan.palette.secondary, 0.84), 14 * scale) boot(draw, left_foot, 16 * scale) boot(draw, right_foot, 16 * scale) body_w = 64 * scale * bulk waist_w = 44 * scale * bulk polygon( draw, [ (cx - body_w / 2 + lean * 0.16, shoulder_y), (cx + body_w / 2 + lean * 0.16, shoulder_y), (cx + waist_w / 2 - lean * 0.1, hip_y), (cx - waist_w / 2 - lean * 0.1, hip_y), ], with_alpha(darken(plan.palette.armor, 0.76), 255), outline=INK, width=4, ) line(draw, [(cx, shoulder_y + 8 * scale), (cx, hip_y - 6 * scale)], with_alpha(plan.palette.accent, 165), 3) belt(draw, cx, hip_y, 48 * scale, darken(plan.palette.accent, 0.82)) left_hand = (cx - 42 * scale + arm_stride * 0.12, bottom - (91 + 34 * raise_arm) * scale) right_hand = (cx + 42 * scale - arm_stride * 0.12, bottom - (92 + 36 * raise_arm) * scale) arm(draw, (cx - 31 * scale, shoulder_y + 9 * scale), left_hand, darken(plan.palette.cloth, 0.78), 13 * scale) arm(draw, (cx + 31 * scale, shoulder_y + 9 * scale), right_hand, darken(plan.palette.cloth, 0.78), 13 * scale) hand(draw, left_hand, darken(plan.skin, 0.82), 7 * scale) hand(draw, right_hand, darken(plan.skin, 0.82), 7 * scale) neck(draw, cx, head_y + 23 * scale, darken(plan.skin, 0.75), scale) head_back(draw, plan, cx + lean * 0.08, head_y, scale) def draw_human_side( draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, scale: float, bulk: float, leg_stride: float, arm_stride: float, raise_arm: float, lean: float, ) -> None: sign = 1 if direction == "east" else -1 hip_y = bottom - 72 * scale shoulder_y = bottom - 134 * scale head_y = bottom - 165 * scale rear_foot = (cx - sign * (14 * scale + leg_stride * 0.35), bottom) front_foot = (cx + sign * (17 * scale + leg_stride * 0.55), bottom - 2 * abs(math.sin(leg_stride / 22))) limb(draw, (cx - sign * 8 * scale, hip_y), (cx - sign * (15 * scale + leg_stride * 0.2), bottom - 38 * scale), rear_foot, darken(plan.palette.secondary, 0.76), 13 * scale) limb(draw, (cx + sign * 8 * scale, hip_y), (cx + sign * (18 * scale + leg_stride * 0.3), bottom - 36 * scale), front_foot, plan.palette.secondary, 14 * scale) boot(draw, rear_foot, 16 * scale) boot(draw, front_foot, 18 * scale, sign) torso_color = plan.palette.armor polygon( draw, [ (cx - sign * 22 * scale + lean * 0.1, shoulder_y + 2 * scale), (cx + sign * 31 * scale + lean * 0.4, shoulder_y + 9 * scale), (cx + sign * 22 * scale - lean * 0.18, hip_y), (cx - sign * 18 * scale - lean * 0.18, hip_y - 3 * scale), ], with_alpha(torso_color, 255), outline=INK, width=4, ) polygon( draw, [ (cx - sign * 12 * scale, shoulder_y + 12 * scale), (cx + sign * 23 * scale, shoulder_y + 16 * scale), (cx + sign * 13 * scale, hip_y - 5 * scale), (cx - sign * 12 * scale, hip_y - 6 * scale), ], with_alpha(plan.palette.primary, 222), outline=with_alpha(darken(plan.palette.primary, 0.48), 175), width=2, ) belt(draw, cx + sign * 3 * scale, hip_y - 2 * scale, 43 * scale, plan.palette.accent) rear_hand = (cx - sign * (31 * scale + arm_stride * 0.28), bottom - (98 + 26 * raise_arm) * scale) front_hand = (cx + sign * (47 * scale + arm_stride * 0.44), bottom - (96 + 41 * raise_arm) * scale) arm(draw, (cx - sign * 20 * scale, shoulder_y + 12 * scale), rear_hand, darken(plan.palette.cloth, 0.72), 11 * scale) arm(draw, (cx + sign * 25 * scale, shoulder_y + 13 * scale), front_hand, plan.palette.cloth, 12 * scale) hand(draw, rear_hand, darken(plan.skin, 0.85), 6 * scale) hand(draw, front_hand, plan.skin, 7 * scale) neck(draw, cx + sign * 10 * scale, head_y + 22 * scale, plan.skin, scale) head_side(draw, plan, direction, cx + sign * 13 * scale + lean * 0.22, head_y + 1 * scale, scale) def draw_cavalry( draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, motion: Motion, action: str, ) -> None: scale = plan.scale if direction in ("east", "west"): draw_horse_side(draw, plan, direction, cx, bottom, scale, motion) rider_bottom = bottom - 72 * scale draw_human(draw, plan, direction, cx - (8 if direction == "east" else -8) * scale, rider_bottom, motion, action, mounted=True) else: draw_horse_front_back(draw, plan, direction, cx, bottom, scale, motion) rider_bottom = bottom - 78 * scale draw_human(draw, plan, direction, cx, rider_bottom, motion, action, mounted=True) def draw_horse_side(draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, scale: float, motion: Motion) -> None: sign = 1 if direction == "east" else -1 horse = plan.palette.horse phase = motion.horse_leg body_y = bottom - 50 * scale body_w = 125 * scale body_h = 48 * scale ellipse(draw, (cx - body_w / 2, body_y - body_h / 2, cx + body_w / 2, body_y + body_h / 2), with_alpha(horse, 255), outline=INK, width=4) ellipse(draw, (cx - body_w * 0.45, body_y - body_h * 0.35, cx - body_w * 0.05, body_y + body_h * 0.42), with_alpha(darken(horse, 0.82), 235), outline=with_alpha(DEEP_INK[:3], 160), width=2) neck = [ (cx + sign * 43 * scale, body_y - 24 * scale), (cx + sign * 76 * scale, body_y - 58 * scale), (cx + sign * 91 * scale, body_y - 48 * scale), (cx + sign * 61 * scale, body_y - 11 * scale), ] polygon(draw, neck, with_alpha(horse, 255), outline=INK, width=4) ellipse(draw, (cx + sign * 73 * scale, body_y - 73 * scale, cx + sign * 115 * scale, body_y - 35 * scale), with_alpha(lighten(horse, 1.06), 255), outline=INK, width=4) ellipse(draw, (cx + sign * 101 * scale, body_y - 54 * scale, cx + sign * 128 * scale, body_y - 36 * scale), with_alpha(lighten(horse, 1.02), 255), outline=INK, width=3) polygon(draw, [(cx + sign * 83 * scale, body_y - 72 * scale), (cx + sign * 91 * scale, body_y - 91 * scale), (cx + sign * 98 * scale, body_y - 68 * scale)], with_alpha(darken(horse, 0.82), 255), outline=INK, width=2) ellipse(draw, (cx + sign * 111 * scale - 3 * scale, body_y - 51 * scale - 2 * scale, cx + sign * 111 * scale + 3 * scale, body_y - 51 * scale + 2 * scale), with_alpha((20, 15, 12), 255)) line(draw, [(cx + sign * 51 * scale, body_y - 38 * scale), (cx + sign * 79 * scale, body_y - 69 * scale)], with_alpha(darken(plan.hair, 0.65), 230), 8) line(draw, [(cx - sign * 64 * scale, body_y - 14 * scale), (cx - sign * 91 * scale, body_y - 35 * scale), (cx - sign * 103 * scale, body_y - 20 * scale)], with_alpha(darken(horse, 0.64), 230), 8) leg_roots = (-43, -17, 18, 44) for index, root in enumerate(leg_roots): stride = math.sin(math.asin(max(-1, min(1, phase))) + index * math.pi * 0.7) far = index in (0, 3) color = darken(horse, 0.74 if far else 0.92) root_x = cx + root * scale knee = (root_x + sign * stride * (13 if far else 17) * scale, bottom - 30 * scale) hoof = (root_x - sign * stride * (18 if far else 22) * scale, bottom) line(draw, [(root_x, body_y + 14 * scale), knee, hoof], with_alpha(color, 255), 11 if not far else 9) ellipse(draw, (hoof[0] - 8 * scale, hoof[1] - 5 * scale, hoof[0] + 10 * scale, hoof[1] + 2 * scale), with_alpha((25, 18, 13), 255), outline=INK, width=2) saddle_w = 55 * scale polygon(draw, [(cx - saddle_w / 2, body_y - 29 * scale), (cx + saddle_w / 2, body_y - 29 * scale), (cx + 31 * scale, body_y - 2 * scale), (cx - 31 * scale, body_y - 2 * scale)], with_alpha(plan.palette.secondary, 245), outline=INK, width=3) line(draw, [(cx - 26 * scale, body_y - 1 * scale), (cx - 10 * scale, body_y + 26 * scale)], with_alpha(plan.palette.accent, 210), 3) line(draw, [(cx + 24 * scale, body_y - 1 * scale), (cx + 8 * scale, body_y + 26 * scale)], with_alpha(plan.palette.accent, 210), 3) def draw_horse_front_back(draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, scale: float, motion: Motion) -> None: horse = plan.palette.horse front = direction == "south" body_y = bottom - 44 * scale ellipse(draw, (cx - 48 * scale, body_y - 35 * scale, cx + 48 * scale, body_y + 32 * scale), with_alpha(horse, 255), outline=INK, width=4) if front: ellipse(draw, (cx - 28 * scale, body_y - 76 * scale, cx + 28 * scale, body_y - 22 * scale), with_alpha(lighten(horse, 1.05), 255), outline=INK, width=4) polygon(draw, [(cx - 18 * scale, body_y - 72 * scale), (cx - 30 * scale, body_y - 94 * scale), (cx - 5 * scale, body_y - 76 * scale)], with_alpha(darken(horse, 0.82), 255), outline=INK, width=2) polygon(draw, [(cx + 18 * scale, body_y - 72 * scale), (cx + 30 * scale, body_y - 94 * scale), (cx + 5 * scale, body_y - 76 * scale)], with_alpha(darken(horse, 0.82), 255), outline=INK, width=2) ellipse(draw, (cx - 14 * scale, body_y - 52 * scale, cx - 8 * scale, body_y - 46 * scale), with_alpha((15, 12, 10), 255)) ellipse(draw, (cx + 8 * scale, body_y - 52 * scale, cx + 14 * scale, body_y - 46 * scale), with_alpha((15, 12, 10), 255)) else: ellipse(draw, (cx - 32 * scale, body_y - 2 * scale, cx + 32 * scale, body_y + 41 * scale), with_alpha(darken(horse, 0.88), 255), outline=INK, width=3) line(draw, [(cx, body_y + 19 * scale), (cx, body_y + 54 * scale)], with_alpha(darken(horse, 0.58), 235), 9) phase = motion.horse_leg for index, xoff in enumerate((-31, -12, 12, 31)): stride = math.sin(math.asin(max(-1, min(1, phase))) + index * math.pi * 0.75) root = (cx + xoff * scale, body_y + 8 * scale) knee = (cx + (xoff + stride * 7) * scale, bottom - 29 * scale) hoof = (cx + (xoff - stride * 9) * scale, bottom) color = horse if index in (1, 2) else darken(horse, 0.72) line(draw, [root, knee, hoof], with_alpha(color, 255), 10 if index in (1, 2) else 8) ellipse(draw, (hoof[0] - 8 * scale, hoof[1] - 5 * scale, hoof[0] + 8 * scale, hoof[1] + 2 * scale), with_alpha((24, 17, 13), 255), outline=INK, width=2) polygon(draw, [(cx - 36 * scale, body_y - 33 * scale), (cx + 36 * scale, body_y - 33 * scale), (cx + 28 * scale, body_y - 8 * scale), (cx - 28 * scale, body_y - 8 * scale)], with_alpha(plan.palette.secondary, 245), outline=INK, width=3) def draw_weapon( draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, scale: float, motion: Motion, action: str, mounted: bool, ) -> None: sign = 1 if direction == "east" else -1 if direction == "west" else 0 dx, dy = direction_vector(direction) attack = action == "attack" celebrate = action == "celebrate" forward = motion.lunge weapon = plan.weapon hand_y = bottom - (104 if mounted else 102) * scale - motion.raise_arm * 34 * scale hand_x = cx + (sign * (31 if mounted else 42) * scale if sign else 27 * scale) if weapon in ("spear", "lance", "halberd"): length = (112 if mounted else 102) * scale if direction == "south": start = (hand_x - 10 * scale, hand_y - 34 * scale + motion.weapon * 0.08) end = (hand_x + dx * 4 * scale, hand_y + length * (0.66 + 0.28 * forward)) elif direction == "north": start = (hand_x - 18 * scale, hand_y + 38 * scale) end = (hand_x - 24 * scale, hand_y - length * (0.73 + 0.22 * forward)) else: start = (hand_x - sign * 43 * scale, hand_y + 15 * scale) end = (hand_x + sign * length * (0.88 + 0.32 * forward), hand_y - 19 * scale - motion.weapon * 0.25) line(draw, [start, end], with_alpha((101, 66, 36), 255), 7 if not mounted else 8) line(draw, [start, end], with_alpha(lighten(plan.palette.accent, 1.12), 190), 2) spear_tip(draw, start, end, plan.metal, 18 * scale if weapon != "halberd" else 24 * scale) if weapon == "halberd": draw_halberd_hook(draw, start, end, plan.palette.accent, scale) return if weapon == "bow": bow_x = hand_x + sign * 9 * scale if sign else cx + 34 * scale bow_y = hand_y + 2 * scale if direction in ("east", "west"): arc_box = (bow_x - sign * 32 * scale, bow_y - 48 * scale, bow_x + sign * 26 * scale, bow_y + 49 * scale) normalized = normalize_box(arc_box) start, end = (-72, 72) if direction == "east" else (108, 252) arc(draw, normalized, start, end, with_alpha(plan.palette.accent, 255), 6) line(draw, [(bow_x, bow_y - 45 * scale), (bow_x, bow_y + 45 * scale)], with_alpha((233, 222, 186), 220), 2) if attack: line(draw, [(bow_x - sign * 22 * scale, bow_y), (bow_x + sign * 92 * scale, bow_y - 14 * scale)], with_alpha(plan.metal, 240), 4) spear_tip(draw, (bow_x, bow_y), (bow_x + sign * 102 * scale, bow_y - 15 * scale), plan.metal, 11 * scale) else: arc(draw, (cx - 50 * scale, hand_y - 22 * scale, cx + 50 * scale, hand_y + 42 * scale), 15 if direction == "south" else 195, 165 if direction == "south" else 345, with_alpha(plan.palette.accent, 255), 6) if attack: end = (cx + dx * 10 * scale, hand_y + dy * 92 * scale) line(draw, [(cx, hand_y), end], with_alpha(plan.metal, 240), 4) spear_tip(draw, (cx, hand_y), end, plan.metal, 11 * scale) return if weapon == "fan": fan_cx = hand_x + (sign * 8 * scale if sign else 0) fan_cy = hand_y - 4 * scale fan_r = (21 + 5 * motion.raise_arm) * scale for blade in range(5): angle = -68 + blade * 34 if direction == "north": angle += 180 elif direction == "east": angle -= 22 elif direction == "west": angle = 180 - angle + 22 end = (fan_cx + math.cos(math.radians(angle)) * fan_r, fan_cy + math.sin(math.radians(angle)) * fan_r) line(draw, [(fan_cx, fan_cy), end], with_alpha(plan.palette.accent, 230), 3) pieslice(draw, (fan_cx - fan_r, fan_cy - fan_r, fan_cx + fan_r, fan_cy + fan_r), 205 if direction != "west" else -25, 335 if direction != "west" else 155, with_alpha((237, 232, 207), 215), outline=INK, width=2) return blade_len = 58 * scale if direction == "south": start = (hand_x, hand_y + 2 * scale) end = (hand_x + 28 * scale + motion.weapon * 0.2, hand_y + blade_len * (0.58 + forward * 0.45)) elif direction == "north": start = (hand_x - 18 * scale, hand_y + 4 * scale) end = (hand_x - 40 * scale - motion.weapon * 0.12, hand_y - blade_len * (0.72 + forward * 0.38)) else: start = (hand_x - sign * 22 * scale, hand_y + 6 * scale) end = (hand_x + sign * blade_len * (0.82 + forward * 0.65), hand_y - 17 * scale) line(draw, [start, end], with_alpha(plan.metal, 255), 9) line(draw, [start, end], with_alpha((249, 247, 227), 170), 3) line(draw, [(start[0] - 11 * scale, start[1] + 5 * scale), (start[0] + 11 * scale, start[1] - 5 * scale)], with_alpha(plan.palette.accent, 245), 5) def draw_attack_effect( draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, bottom: float, frame_index: int, frame_count: int, ) -> None: t = frame_index / max(1, frame_count - 1) if t < 0.38 or t > 0.78: return alpha = round(210 * math.sin((t - 0.38) / 0.4 * math.pi)) warm = with_alpha(lighten(plan.palette.accent, 1.25), alpha) white = (255, 245, 211, max(0, alpha - 30)) dx, dy = direction_vector(direction) if plan.weapon == "bow": end = (cx + dx * 126 * DRAW_SCALE, bottom - 106 * DRAW_SCALE + dy * 96 * DRAW_SCALE) start = (cx + dx * 28 * DRAW_SCALE, bottom - 104 * DRAW_SCALE + dy * 18 * DRAW_SCALE) line_raw(draw, [start, end], white, 6 * DRAW_SCALE) line_raw(draw, [start, end], warm, 2 * DRAW_SCALE) return if plan.weapon == "fan": center = (cx * DRAW_SCALE + dx * 22 * DRAW_SCALE, (bottom - 122) * DRAW_SCALE + dy * 30 * DRAW_SCALE) for radius in (29, 48, 69): ellipse_raw(draw, (center[0] - radius * DRAW_SCALE, center[1] - radius * DRAW_SCALE, center[0] + radius * DRAW_SCALE, center[1] + radius * DRAW_SCALE), None, outline=with_alpha((96, 184, 222), max(40, alpha // 2)), width=3 * DRAW_SCALE) return if direction == "east": box = (cx + 3, bottom - 213, cx + 164, bottom - 45) start, end = -48, 68 elif direction == "west": box = (cx - 164, bottom - 213, cx - 3, bottom - 45) start, end = 112, 228 elif direction == "north": box = (cx - 104, bottom - 274, cx + 104, bottom - 76) start, end = 205, 335 else: box = (cx - 103, bottom - 166, cx + 103, bottom + 12) start, end = 25, 155 arc(draw, box, start, end, warm, 18) inset = (box[0] + 13, box[1] + 13, box[2] - 13, box[3] - 13) arc(draw, inset, start + 5, end - 5, white, 8) def draw_support_effect( draw: ImageDraw.ImageDraw, action: str, direction: str, frame_index: int, frame_count: int, cx: float, bottom: float, ) -> None: t = frame_index / max(1, frame_count - 1) pulse = math.sin(t * math.pi) center = (cx, bottom - 114) if action == "strategy": for radius, color in ((72 + pulse * 11, (75, 160, 213, 78)), (44 + pulse * 8, (221, 186, 93, 115)), (22 + pulse * 5, (244, 237, 191, 142))): ellipse(draw, (center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), None, outline=color, width=4) for angle in range(0, 360, 72): x = center[0] + math.cos(math.radians(angle + frame_index * 13)) * (62 + pulse * 6) y = center[1] + math.sin(math.radians(angle + frame_index * 13)) * (44 + pulse * 6) diamond(draw, x, y, 7, (239, 214, 128, 125)) elif action == "item": x = center[0] + direction_vector(direction)[0] * 27 y = center[1] - 8 - pulse * 22 rounded_rect(draw, (x - 19, y - 17, x + 19, y + 21), 6, (125, 80, 39, 230), outline=INK, width=3) line(draw, [(x - 26, y + 2), (x + 26, y + 2)], (230, 190, 82, 210), 4) ellipse(draw, (x - 10, y - 33, x + 10, y - 12), (76, 174, 112, 210), outline=(235, 242, 207, 220), width=2) def draw_hurt_effect(draw: ImageDraw.ImageDraw, direction: str, cx: float, bottom: float, frame_index: int) -> None: if frame_index <= 0: return dx, dy = direction_vector(direction) center = (cx + dx * 34, bottom - 123 + dy * 26) spikes = [] for index in range(12): angle = index / 12 * math.tau radius = 36 if index % 2 == 0 else 15 spikes.append((center[0] + math.cos(angle) * radius, center[1] + math.sin(angle) * radius)) polygon(draw, spikes, (234, 176, 72, 178), outline=(138, 32, 28, 230), width=2) line(draw, [(center[0] - 38, center[1] - 34), (center[0] + 38, center[1] + 34)], (255, 237, 199, 170), 5) def draw_celebrate_effect(draw: ImageDraw.ImageDraw, cx: float, bottom: float, frame_index: int, frame_count: int) -> None: t = frame_index / max(1, frame_count - 1) if frame_index in (1, 2, 4): center = (cx, bottom - 166 - math.sin(t * math.pi) * 10) ellipse(draw, (center[0] - 46, center[1] - 27, center[0] + 46, center[1] + 57), None, outline=(255, 220, 115, 120), width=5) for angle in range(20, 360, 72): x = center[0] + math.cos(math.radians(angle + frame_index * 9)) * 58 y = center[1] + math.sin(math.radians(angle + frame_index * 9)) * 42 diamond(draw, x, y, 8, (255, 226, 132, 150)) def draw_ground_shadow(draw: ImageDraw.ImageDraw, cx: float, bottom: float, plan: UnitPlan, direction: str, motion: Motion) -> None: width = 118 if plan.role == "cavalry" and direction in ("east", "west") else 72 if plan.role != "cavalry" else 88 height = 17 if plan.role != "cavalry" else 20 ellipse(draw, (cx - width / 2, bottom - height / 2 + 3, cx + width / 2, bottom + height / 2 + 3), GROUND_SHADOW) def limb(draw: ImageDraw.ImageDraw, start: tuple[float, float], mid: tuple[float, float], end: tuple[float, float], color: Color, width: float) -> None: line(draw, [start, mid, end], with_alpha(darken(color, 0.72), 255), width + 4) line(draw, [start, mid, end], with_alpha(color, 255), width) def arm(draw: ImageDraw.ImageDraw, shoulder: tuple[float, float], hand_pos: tuple[float, float], color: Color, width: float) -> None: elbow = ((shoulder[0] + hand_pos[0]) / 2, (shoulder[1] + hand_pos[1]) / 2 + 7 * DRAW_SCALE / DRAW_SCALE) line(draw, [shoulder, elbow, hand_pos], with_alpha(darken(color, 0.55), 255), width + 4) line(draw, [shoulder, elbow, hand_pos], with_alpha(color, 255), width) def hand(draw: ImageDraw.ImageDraw, pos: tuple[float, float], skin: Color, radius: float) -> None: ellipse(draw, (pos[0] - radius, pos[1] - radius, pos[0] + radius, pos[1] + radius), with_alpha(skin, 255), outline=INK, width=2) def boot(draw: ImageDraw.ImageDraw, pos: tuple[float, float], width: float, sign: int = 0) -> None: x, y = pos forward = sign * width * 0.28 ellipse(draw, (x - width / 2 + forward, y - width * 0.28, x + width / 2 + forward, y + width * 0.14), with_alpha((31, 22, 16), 255), outline=INK, width=2) def belt(draw: ImageDraw.ImageDraw, cx: float, y: float, width: float, color: Color) -> None: rounded_rect(draw, (cx - width / 2, y - 7, cx + width / 2, y + 4), 3, with_alpha((55, 33, 18), 238), outline=INK, width=2) rounded_rect(draw, (cx - 8, y - 8, cx + 8, y + 5), 2, with_alpha(color, 245), outline=DEEP_INK, width=1) def neck(draw: ImageDraw.ImageDraw, cx: float, y: float, skin: Color, scale: float) -> None: rounded_rect(draw, (cx - 9 * scale, y - 7 * scale, cx + 9 * scale, y + 13 * scale), 5 * scale, with_alpha(darken(skin, 0.9), 255), outline=INK, width=2) def head_front(draw: ImageDraw.ImageDraw, plan: UnitPlan, cx: float, cy: float, scale: float) -> None: ellipse(draw, (cx - 22 * scale, cy - 25 * scale, cx + 22 * scale, cy + 23 * scale), with_alpha(plan.skin, 255), outline=INK, width=3) if plan.helmet: helmet(draw, plan, cx, cy - 8 * scale, scale, "front") else: ellipse(draw, (cx - 24 * scale, cy - 29 * scale, cx + 24 * scale, cy - 1 * scale), with_alpha(plan.hair, 255), outline=INK, width=2) eye(draw, cx - 8 * scale, cy - 4 * scale, scale) eye(draw, cx + 8 * scale, cy - 4 * scale, scale) line(draw, [(cx - 7 * scale, cy + 11 * scale), (cx + 7 * scale, cy + 11 * scale)], with_alpha((73, 35, 25), 210), 2) if "guan-yu" in plan.stem or "zhang-fei" in plan.stem or plan.rank >= 2: line(draw, [(cx - 11 * scale, cy + 15 * scale), (cx, cy + 29 * scale), (cx + 11 * scale, cy + 15 * scale)], with_alpha(darken(plan.hair, 0.72), 220), 5) def head_back(draw: ImageDraw.ImageDraw, plan: UnitPlan, cx: float, cy: float, scale: float) -> None: ellipse(draw, (cx - 22 * scale, cy - 25 * scale, cx + 22 * scale, cy + 23 * scale), with_alpha(darken(plan.skin, 0.82), 255), outline=INK, width=3) if plan.helmet: helmet(draw, plan, cx, cy - 8 * scale, scale, "back") else: ellipse(draw, (cx - 26 * scale, cy - 30 * scale, cx + 26 * scale, cy + 10 * scale), with_alpha(plan.hair, 255), outline=INK, width=2) ellipse(draw, (cx - 10 * scale, cy - 38 * scale, cx + 10 * scale, cy - 20 * scale), with_alpha(darken(plan.hair, 0.72), 255), outline=INK, width=2) line(draw, [(cx - 13 * scale, cy + 2 * scale), (cx + 13 * scale, cy + 2 * scale)], with_alpha(darken(plan.palette.accent, 0.84), 160), 2) def head_side(draw: ImageDraw.ImageDraw, plan: UnitPlan, direction: str, cx: float, cy: float, scale: float) -> None: sign = 1 if direction == "east" else -1 ellipse(draw, (cx - 21 * scale, cy - 24 * scale, cx + 21 * scale, cy + 23 * scale), with_alpha(plan.skin, 255), outline=INK, width=3) polygon(draw, [(cx + sign * 15 * scale, cy - 6 * scale), (cx + sign * 32 * scale, cy + 1 * scale), (cx + sign * 14 * scale, cy + 7 * scale)], with_alpha(plan.skin, 255), outline=INK, width=2) if plan.helmet: helmet(draw, plan, cx - sign * 2 * scale, cy - 9 * scale, scale, "side", sign) else: ellipse(draw, (cx - 23 * scale, cy - 29 * scale, cx + 20 * scale, cy + 1 * scale), with_alpha(plan.hair, 255), outline=INK, width=2) ellipse(draw, (cx - sign * 22 * scale, cy - 3 * scale, cx - sign * 4 * scale, cy + 19 * scale), with_alpha(darken(plan.hair, 0.78), 245), outline=INK, width=2) eye(draw, cx + sign * 9 * scale, cy - 4 * scale, scale) line(draw, [(cx + sign * 5 * scale, cy + 12 * scale), (cx + sign * 16 * scale, cy + 12 * scale)], with_alpha((73, 35, 25), 210), 2) def helmet(draw: ImageDraw.ImageDraw, plan: UnitPlan, cx: float, cy: float, scale: float, view: str, sign: int = 1) -> None: metal = darken(plan.metal, 0.78) ellipse(draw, (cx - 25 * scale, cy - 18 * scale, cx + 25 * scale, cy + 17 * scale), with_alpha(metal, 255), outline=INK, width=3) rounded_rect(draw, (cx - 21 * scale, cy + 2 * scale, cx + 21 * scale, cy + 16 * scale), 3 * scale, with_alpha(plan.palette.accent, 235), outline=INK, width=2) if view == "side": line(draw, [(cx - sign * 3 * scale, cy - 19 * scale), (cx + sign * 23 * scale, cy - 35 * scale)], with_alpha(plan.palette.accent, 240), 4) else: line(draw, [(cx, cy - 21 * scale), (cx, cy - 42 * scale)], with_alpha(plan.palette.accent, 240), 5) def eye(draw: ImageDraw.ImageDraw, x: float, y: float, scale: float) -> None: ellipse(draw, (x - 2.2 * scale, y - 2.2 * scale, x + 2.2 * scale, y + 2.2 * scale), with_alpha((20, 16, 13), 255)) def spear_tip(draw: ImageDraw.ImageDraw, start: tuple[float, float], end: tuple[float, float], color: Color, size: float) -> None: sx, sy = start ex, ey = end angle = math.atan2(ey - sy, ex - sx) left = angle + math.radians(150) right = angle - math.radians(150) points = [ (ex, ey), (ex + math.cos(left) * size, ey + math.sin(left) * size), (ex - math.cos(angle) * size * 0.34, ey - math.sin(angle) * size * 0.34), (ex + math.cos(right) * size, ey + math.sin(right) * size), ] polygon(draw, points, with_alpha(color, 255), outline=INK, width=2) def draw_halberd_hook(draw: ImageDraw.ImageDraw, start: tuple[float, float], end: tuple[float, float], color: Color, scale: float) -> None: sx, sy = start ex, ey = end angle = math.atan2(ey - sy, ex - sx) base_x = ex - math.cos(angle) * 18 * scale base_y = ey - math.sin(angle) * 18 * scale normal = angle + math.pi / 2 hook = [ (base_x, base_y), (base_x + math.cos(normal) * 22 * scale, base_y + math.sin(normal) * 22 * scale), (base_x + math.cos(angle) * 11 * scale + math.cos(normal) * 8 * scale, base_y + math.sin(angle) * 11 * scale + math.sin(normal) * 8 * scale), ] polygon(draw, hook, with_alpha(color, 235), outline=INK, width=2) def finish_frame(frame: Image.Image) -> Image.Image: frame = clean_alpha(frame) alpha = frame.getchannel("A") if not alpha.getbbox(): return frame outer = ImageChops.subtract(alpha.filter(ImageFilter.MaxFilter(5)), alpha) inner = ImageChops.subtract(alpha, alpha.filter(ImageFilter.MinFilter(3))) rim = Image.new("RGBA", frame.size, (8, 6, 6, 0)) rim.putalpha(outer.point(lambda value: min(190, round(value * 0.72)))) inner_ink = Image.new("RGBA", frame.size, (24, 17, 13, 0)) inner_ink.putalpha(inner.point(lambda value: min(58, round(value * 0.2)))) result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) result.alpha_composite(rim) result.alpha_composite(frame) result.alpha_composite(inner_ink) result = ImageEnhance.Contrast(result).enhance(1.04) return clean_alpha(result) def tint_alpha(image: Image.Image, color: Color, strength: float) -> Image.Image: image = image.convert("RGBA") alpha = image.getchannel("A") overlay = Image.new("RGBA", image.size, (*color, 0)) overlay.putalpha(alpha.point(lambda value: min(255, round(value * strength)))) return Image.alpha_composite(image, overlay) def clean_alpha(image: Image.Image) -> Image.Image: image = image.convert("RGBA") red, green, blue, alpha = image.split() alpha = alpha.point(lambda value: 0 if value < 4 else 255 if value > 250 else value) return Image.merge("RGBA", (red, green, blue, alpha)) def save_unit_png(image: Image.Image, path: Path) -> None: image = image.convert("RGBA") indexed = image.quantize(colors=PALETTE_COLORS, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) indexed.save(path, optimize=True) def write_preview_sheet(cache: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None: thumb = 72 label_w = 230 gap = 6 row_h = thumb + 16 header_h = 34 actions = ("idle", "move", "attack") rows_per_unit = len(DIRECTIONS) * len(actions) width = label_w + 10 * (thumb + gap) + 24 height = len(REPRESENTATIVE_UNITS) * (header_h + rows_per_unit * row_h + 18) + 18 preview = Image.new("RGB", (width, height), (31, 34, 28)) draw = ImageDraw.Draw(preview) font = load_font(16) small_font = load_font(13) title_font = load_font(18) y = 12 for stem in REPRESENTATIVE_UNITS: base, actions_sheet = cache[stem] plan = unit_plan(stem) draw.rectangle((10, y, width - 10, y + header_h - 4), fill=(45, 51, 40), outline=(116, 96, 56)) draw.text((22, y + 6), f"{stem} role={plan.role} weapon={plan.weapon}", fill=(239, 223, 184), font=title_font) y += header_h for action in actions: for direction in DIRECTIONS: draw.text((22, y + 22), f"{action:<6} {direction}", fill=(218, 218, 201), font=small_font) frames = preview_frames(base, actions_sheet, action, direction) for index, frame in enumerate(frames): x = label_w + index * (thumb + gap) tile = checker_thumb(frame, thumb) preview.paste(tile, (x, y + 4)) draw.text((x + 2, y + thumb + 3), str(index), fill=(178, 171, 144), font=small_font) y += row_h y += 18 preview.save(path, optimize=True) def preview_frames(base: Image.Image, actions_sheet: Image.Image, action: str, direction: str) -> list[Image.Image]: row = DIRECTIONS.index(direction) if action == "idle": return [crop_frame(base, row, col) for col in range(IDLE_FRAME_COUNT)] if action == "move": return [crop_frame(base, row, IDLE_FRAME_COUNT + col) for col in range(MOVE_FRAME_COUNT)] start = ACTION_COLUMN_OFFSETS[action] count = ACTION_FRAME_COUNTS[action] return [crop_frame(actions_sheet, row, start + col) for col in range(count)] def checker_thumb(frame: Image.Image, size: int) -> Image.Image: bg = Image.new("RGB", (size, size), (47, 52, 42)) draw = ImageDraw.Draw(bg) cell = 12 for y in range(0, size, cell): for x in range(0, size, cell): if (x // cell + y // cell) % 2 == 0: draw.rectangle((x, y, x + cell - 1, y + cell - 1), fill=(39, 43, 35)) frame = frame.resize((size, size), Image.Resampling.LANCZOS) bg.paste(frame, (0, 0), frame) draw.rectangle((0, 0, size - 1, size - 1), outline=(100, 91, 65)) return bg 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)).convert("RGBA") def load_font(size: int) -> ImageFont.ImageFont: candidates = ( Path("C:/Windows/Fonts/arial.ttf"), Path("C:/Windows/Fonts/malgun.ttf"), Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), ) for candidate in candidates: if candidate.exists(): return ImageFont.truetype(str(candidate), size) return ImageFont.load_default() def unit_plan(stem: str) -> UnitPlan: lower = stem.lower() faction = infer_faction(lower) role = infer_role(lower) palette = faction_palette(faction, stable_int(stem)) skin = varied_skin(stem) hair = varied_hair(stem) metal = varied_metal(stem) weapon = weapon_for(role, lower) cape = role in {"lord", "officer", "strategist"} or any(token in lower for token in ("liu-bei", "cao-cao", "guan-yu", "lu-bu", "zhao-yun", "ma-chao")) helmet = role in {"lord", "officer", "spearman", "cavalry"} or any(token in lower for token in ("guard", "veteran", "elite", "leader")) banner = any(token in lower for token in ("banner", "leader", "warlord")) rank = 2 if any(token in lower for token in ("liu-bei", "guan-yu", "zhang-fei", "zhao-yun", "cao-cao", "lu-bu", "zhuge", "sima", "ma-chao", "meng-huo")) else 1 if any(token in lower for token in ("officer", "leader", "veteran", "elite", "guard")) else 0 scale = 1.18 bulk = 1.0 signature = signature_plan(stem) if signature: palette = signature.get("palette", palette) role = signature.get("role", role) weapon = signature.get("weapon", weapon) cape = signature.get("cape", cape) helmet = signature.get("helmet", helmet) scale = signature.get("scale", scale) bulk = signature.get("bulk", bulk) rank = max(rank, signature.get("rank", rank)) if role == "strategist": scale *= 0.96 bulk *= 0.88 elif role == "archer": scale *= 0.98 bulk *= 0.92 elif role == "cavalry": scale *= 1.02 bulk *= 0.98 elif role in {"spearman", "officer", "lord"}: scale *= 1.02 bulk *= 1.02 if "nanman" in lower or "meng-huo" in lower: bulk *= 1.08 if "ragged" in lower or "scout" in lower: scale *= 0.97 bulk *= 0.94 return UnitPlan(stem, faction, role, weapon, palette, skin, hair, metal, cape, helmet, banner, scale, bulk, rank) def signature_plan(stem: str) -> dict[str, object] | None: plans: dict[str, dict[str, object]] = { "unit-liu-bei": { "role": "lord", "weapon": "sword", "palette": Palette((50, 122, 76), (39, 79, 72), (215, 174, 79), (78, 126, 92), (91, 108, 98), (176, 156, 118), (234, 213, 142)), "cape": True, "helmet": True, "rank": 2, }, "unit-guan-yu": { "role": "spearman", "weapon": "halberd", "palette": Palette((34, 112, 63), (32, 74, 53), (222, 179, 75), (69, 130, 79), (78, 110, 88), (132, 104, 76), (229, 212, 141)), "cape": True, "helmet": True, "scale": 1.04, "bulk": 1.06, "rank": 2, }, "unit-zhang-fei": { "role": "spearman", "weapon": "spear", "palette": Palette((138, 45, 43), (73, 41, 46), (222, 168, 69), (137, 67, 53), (95, 91, 88), (116, 82, 62), (234, 211, 141)), "helmet": True, "scale": 1.04, "bulk": 1.1, "rank": 2, }, "unit-zhao-yun": { "role": "cavalry", "weapon": "lance", "palette": Palette((75, 132, 174), (238, 236, 218), (213, 178, 84), (90, 142, 183), (165, 174, 180), (213, 205, 185), (236, 221, 158)), "cape": True, "helmet": True, "rank": 2, }, "unit-cao-cao": { "role": "lord", "weapon": "sword", "palette": Palette((56, 68, 143), (42, 43, 77), (214, 174, 78), (70, 78, 133), (92, 100, 128), (83, 70, 65), (228, 211, 145)), "cape": True, "helmet": True, "rank": 2, }, "unit-lu-bu": { "role": "cavalry", "weapon": "halberd", "palette": Palette((146, 37, 49), (41, 37, 48), (224, 164, 66), (142, 60, 58), (98, 92, 95), (68, 58, 54), (235, 205, 133)), "cape": True, "helmet": True, "rank": 2, }, "unit-zhuge-liang": { "role": "strategist", "weapon": "fan", "palette": Palette((67, 132, 124), (225, 225, 203), (204, 171, 93), (82, 145, 133), (116, 136, 126), (167, 152, 123), (236, 226, 175)), "cape": True, "helmet": False, "rank": 2, }, "unit-huang-zhong": {"role": "archer", "weapon": "bow", "helmet": True, "rank": 2}, "unit-ma-chao": {"role": "cavalry", "weapon": "lance", "helmet": True, "rank": 2}, "unit-ma-dai": {"role": "cavalry", "weapon": "lance", "helmet": True, "rank": 1}, "unit-meng-huo": { "role": "officer", "weapon": "spear", "palette": Palette((171, 83, 43), (86, 64, 40), (218, 158, 66), (148, 91, 50), (102, 91, 72), (91, 68, 48), (229, 202, 127)), "helmet": True, "scale": 1.05, "bulk": 1.16, "rank": 2, }, } return plans.get(stem) def infer_faction(lower: str) -> str: if "nanman" in lower or "meng-huo" in lower: return "nanman" if lower.startswith("unit-wu-") or "lu-meng" in lower or "zhou-yu" in lower: return "wu" if lower.startswith("unit-wei-") or any(token in lower for token in ("cao-cao", "sima-yi")): return "wei" if "rebel" in lower or "yellow" in lower or "bandit" in lower: return "rebel" return "shu" def infer_role(lower: str) -> str: if "cavalry" in lower or any(token in lower for token in ("zhao-yun", "ma-chao", "ma-dai", "lu-bu")): return "cavalry" if "archer" in lower or "crossbow" in lower or "longbow" in lower or "huang-zhong" in lower: return "archer" if any(token in lower for token in ("strategist", "shaman", "quartermaster", "zhuge", "sima", "fa-zheng", "ma-liang", "yi-ji", "pang-tong")): return "strategist" if "spearman" in lower or any(token in lower for token in ("guan-yu", "zhang-fei", "jiang-wei", "yan-yan")): return "spearman" if any(token in lower for token in ("liu-bei", "cao-cao")): return "lord" if "leader" in lower or "officer" in lower or any(token in lower for token in ("meng-huo", "wei-yan", "wang-ping", "wu-yi", "li-yan", "huang-quan", "gong-zhi")): return "officer" return "infantry" def weapon_for(role: str, lower: str) -> str: if role == "cavalry": return "lance" if role == "archer": return "bow" if role == "strategist": return "fan" if role == "spearman": return "spear" if "axe" in lower or "nanman" in lower: return "spear" return "sword" def faction_palette(faction: str, seed: int) -> Palette: palettes = { "shu": Palette((55, 126, 76), (45, 83, 69), (210, 169, 77), (74, 124, 88), (93, 113, 98), (151, 121, 86), (229, 211, 143)), "wei": Palette((63, 72, 145), (46, 49, 82), (207, 168, 80), (73, 80, 132), (91, 101, 127), (83, 74, 69), (224, 207, 145)), "wu": Palette((36, 126, 135), (52, 79, 83), (214, 154, 82), (57, 134, 137), (88, 113, 119), (142, 114, 87), (229, 203, 139)), "rebel": Palette((185, 132, 44), (89, 71, 42), (202, 154, 63), (145, 107, 54), (101, 89, 70), (109, 82, 59), (222, 190, 116)), "nanman": Palette((165, 83, 43), (79, 61, 39), (213, 150, 60), (142, 87, 49), (103, 88, 67), (91, 68, 48), (224, 192, 111)), } palette = palettes[faction] jitter = ((seed % 9) - 4) * 0.015 return Palette( adjust_color(palette.primary, 1 + jitter, 1.02), adjust_color(palette.secondary, 1 - jitter * 0.5, 0.98), adjust_color(palette.accent, 1 + jitter * 0.4, 1.02), adjust_color(palette.cloth, 1 + jitter, 1.0), adjust_color(palette.armor, 1 - jitter, 1.0), adjust_color(palette.horse, 1 + jitter * 0.8, 1.0), adjust_color(palette.trim, 1 + jitter * 0.2, 1.0), ) def varied_skin(stem: str) -> Color: skins = ((189, 132, 86), (203, 149, 97), (177, 119, 78), (214, 160, 108)) return skins[stable_int(stem + ":skin") % len(skins)] def varied_hair(stem: str) -> Color: hairs = ((38, 29, 24), (55, 36, 25), (31, 31, 34), (67, 44, 30)) return hairs[stable_int(stem + ":hair") % len(hairs)] def varied_metal(stem: str) -> Color: metals = ((167, 172, 171), (188, 185, 171), (151, 162, 172), (176, 166, 151)) return metals[stable_int(stem + ":metal") % len(metals)] def stable_int(value: str) -> int: return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:12], 16) def direction_vector(direction: str) -> tuple[int, int]: if direction == "east": return (1, 0) if direction == "west": return (-1, 0) if direction == "north": return (0, -1) return (0, 1) def with_alpha(color: Color | Rgba, alpha: int = 255) -> Rgba: if len(color) == 4: return (color[0], color[1], color[2], alpha) return (color[0], color[1], color[2], alpha) def darken(color: Color, amount: float) -> Color: return tuple(clamp_channel(channel * amount) for channel in color) def lighten(color: Color, amount: float) -> Color: return tuple(clamp_channel(channel * amount + (255 * (amount - 1) * 0.12 if amount > 1 else 0)) for channel in color) def adjust_color(color: Color, brightness: float, saturation: float) -> Color: r, g, b = [channel / 255 for channel in color] h, s, v = colorsys.rgb_to_hsv(r, g, b) s = max(0, min(1, s * saturation)) v = max(0, min(1, v * brightness)) nr, ng, nb = colorsys.hsv_to_rgb(h, s, v) return (round(nr * 255), round(ng * 255), round(nb * 255)) def clamp_channel(value: float) -> int: return max(0, min(255, round(value))) def scale_point(point: tuple[float, float]) -> tuple[int, int]: return (round(point[0] * DRAW_SCALE), round(point[1] * DRAW_SCALE)) def scale_box(box: tuple[float, float, float, float]) -> tuple[int, int, int, int]: return ( round(box[0] * DRAW_SCALE), round(box[1] * DRAW_SCALE), round(box[2] * DRAW_SCALE), round(box[3] * DRAW_SCALE), ) def scaled_width(width: float) -> int: return max(1, round(width * DRAW_SCALE)) def polygon(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: Rgba, outline: Rgba | None = None, width: float = 1) -> None: scaled = [scale_point(point) for point in points] draw.polygon(scaled, fill=fill) if outline: draw.line(scaled + [scaled[0]], fill=outline, width=scaled_width(width), joint="curve") def line(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: Rgba, width: float) -> None: draw.line([scale_point(point) for point in points], fill=fill, width=scaled_width(width), joint="curve") def line_raw(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: Rgba, width: float) -> None: draw.line([(round(x), round(y)) for x, y in points], fill=fill, width=max(1, round(width)), joint="curve") def ellipse(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], fill: Rgba | None, outline: Rgba | None = None, width: float = 1) -> None: draw.ellipse(scale_box(normalize_box(box)), fill=fill, outline=outline, width=scaled_width(width) if outline else 1) def ellipse_raw(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], fill: Rgba | None, outline: Rgba | None = None, width: float = 1) -> None: draw.ellipse(tuple(round(v) for v in box), fill=fill, outline=outline, width=max(1, round(width)) if outline else 1) def rounded_rect(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], radius: float, fill: Rgba, outline: Rgba | None = None, width: float = 1) -> None: draw.rounded_rectangle(scale_box(box), radius=scaled_width(radius), fill=fill, outline=outline, width=scaled_width(width) if outline else 1) def arc(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], start: float, end: float, fill: Rgba, width: float) -> None: draw.arc(scale_box(normalize_box(box)), start=start, end=end, fill=fill, width=scaled_width(width)) def pieslice(draw: ImageDraw.ImageDraw, box: tuple[float, float, float, float], start: float, end: float, fill: Rgba, outline: Rgba | None = None, width: float = 1) -> None: draw.pieslice(scale_box(normalize_box(box)), start=start, end=end, fill=fill, outline=outline, width=scaled_width(width) if outline else 1) def diamond(draw: ImageDraw.ImageDraw, x: float, y: float, radius: float, fill: Rgba) -> None: polygon(draw, [(x, y - radius), (x + radius * 0.7, y), (x, y + radius), (x - radius * 0.7, y)], fill) def normalize_box(box: tuple[float, float, float, float]) -> tuple[float, float, float, float]: left, top, right, bottom = box return (min(left, right), min(top, bottom), max(left, right), max(top, bottom)) if __name__ == "__main__": main()