1369 lines
60 KiB
Python
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()
|