Files
heros_web/scripts/redraw-unit-sprites.py

1369 lines
60 KiB
Python

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()