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