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 FRAME_SIZE = 313 GRID_SIZE = 4 PALETTE_COLORS = 224 ROOT = Path(__file__).resolve().parents[1] UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" SOURCE_ATLAS = ROOT / "src" / "assets" / "images" / "unit-sources" / "three-kingdoms-unit-atlas.png" DIRECTIONS = ("south", "east", "north", "west") ACTION_ORDER = ("attack", "strategy", "item", "hurt", "celebrate") IDLE_FRAME_COUNT = 8 MOVE_FRAME_COUNT = 8 BASE_FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + MOVE_FRAME_COUNT ACTION_FRAME_COUNTS = { "attack": 10, "strategy": 8, "item": 8, "hurt": 4, "celebrate": 6, } ACTION_FRAME_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()) @dataclass(frozen=True) class Template: atlas_index: int role: str target_height: int target_width: int @dataclass(frozen=True) class UnitPlan: template: str tint: tuple[int, int, int] tint_strength: float saturation: float = 1.0 brightness: float = 1.0 scale: float = 1.0 @dataclass(frozen=True) class RoleSpec: height: int max_width: int bottom_padding: int idle_scale_x: tuple[float, float, float, float] idle_scale_y: tuple[float, float, float, float] idle_dx: tuple[int, int, int, int] TEMPLATES: dict[str, Template] = { "shu-lord": Template(0, "infantry", 286, 274), "green-polearm": Template(1, "polearm", 292, 282), "red-spear": Template(2, "polearm", 292, 282), "white-cavalry": Template(3, "cavalry", 292, 304), "wei-lord": Template(4, "infantry", 286, 274), "red-cavalry": Template(5, "cavalry", 292, 304), "strategist": Template(6, "strategist", 276, 250), "rebel-infantry": Template(7, "infantry", 286, 274), "rebel-archer": Template(8, "archer", 282, 276), "rebel-cavalry": Template(9, "cavalry", 292, 304), "wu-officer": Template(10, "infantry", 286, 274), "nanman-officer": Template(11, "polearm", 292, 282), } ROLE_SPECS: dict[str, RoleSpec] = { "infantry": RoleSpec(286, 274, 4, (1.0, 0.992, 0.986, 0.996, 1.008, 1.015, 1.006, 1.0), (1.0, 1.012, 1.02, 1.012, 0.998, 0.99, 0.997, 1.0), (0, 0, -1, 0, 0, 1, 0, 0)), "polearm": RoleSpec(292, 282, 4, (1.0, 0.992, 0.986, 0.996, 1.008, 1.014, 1.006, 1.0), (1.0, 1.012, 1.02, 1.012, 0.998, 0.99, 0.997, 1.0), (0, 0, -1, 0, 0, 1, 0, 0)), "cavalry": RoleSpec(292, 304, 3, (1.0, 1.008, 1.014, 1.006, 0.996, 0.988, 0.996, 1.0), (1.0, 1.01, 1.016, 1.008, 0.998, 0.99, 0.998, 1.0), (0, 1, 1, 0, 0, -1, -1, 0)), "strategist": RoleSpec(276, 250, 5, (1.0, 0.993, 0.988, 0.997, 1.006, 1.012, 1.005, 1.0), (1.0, 1.012, 1.019, 1.012, 0.998, 0.994, 0.998, 1.0), (0, 0, -1, 0, 0, 1, 0, 0)), "archer": RoleSpec(282, 276, 4, (1.0, 0.993, 0.987, 0.996, 1.006, 1.012, 1.004, 1.0), (1.0, 1.011, 1.018, 1.01, 0.998, 0.992, 0.998, 1.0), (0, 0, -1, 0, 0, 1, 0, 0)), } SIGNATURE_PLANS: dict[str, UnitPlan] = { "unit-liu-bei": UnitPlan("shu-lord", (42, 142, 72), 0.04, 1.04, 1.02, 1.0), "unit-guan-yu": UnitPlan("green-polearm", (34, 132, 70), 0.04, 1.03, 1.02, 1.0), "unit-zhang-fei": UnitPlan("red-spear", (152, 42, 42), 0.05, 1.04, 1.02, 1.0), "unit-zhao-yun": UnitPlan("white-cavalry", (42, 116, 184), 0.06, 1.04, 1.03, 1.0), "unit-cao-cao": UnitPlan("wei-lord", (52, 58, 122), 0.06, 1.02, 1.01, 1.0), "unit-lu-bu": UnitPlan("red-cavalry", (140, 36, 54), 0.05, 1.05, 1.02, 1.0), "unit-zhuge-liang": UnitPlan("strategist", (42, 126, 118), 0.05, 1.02, 1.03, 1.0), "unit-pang-tong": UnitPlan("strategist", (80, 126, 68), 0.09, 1.02, 1.0, 1.0), "unit-sima-yi": UnitPlan("strategist", (58, 62, 132), 0.16, 1.04, 0.96, 1.0), "unit-huang-zhong": UnitPlan("rebel-archer", (54, 124, 64), 0.28, 1.04, 1.02, 1.0), "unit-ma-chao": UnitPlan("white-cavalry", (54, 118, 194), 0.1, 1.04, 1.04, 1.0), "unit-ma-dai": UnitPlan("white-cavalry", (98, 122, 156), 0.22, 1.02, 0.98, 1.0), "unit-meng-huo": UnitPlan("nanman-officer", (192, 88, 42), 0.1, 1.04, 1.02, 1.0), "unit-jiang-wei": UnitPlan("green-polearm", (44, 140, 124), 0.18, 1.04, 1.02, 1.0), "unit-wei-yan": UnitPlan("green-polearm", (132, 42, 58), 0.28, 1.05, 0.98, 1.0), } FACTION_TINTS = { "shu": (42, 142, 76), "wei": (64, 72, 150), "wu": (28, 132, 142), "nanman": (188, 88, 42), "rebel": (202, 150, 36), } def main() -> None: parser = argparse.ArgumentParser(description="Redraw unit sprite sheets from a high-quality generated 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. Defaults to the project unit asset directory.") args = parser.parse_args() if not SOURCE_ATLAS.exists(): raise FileNotFoundError(f"Missing source atlas: {SOURCE_ATLAS}") output_dir = Path(args.out_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) only = {entry.strip().removesuffix(".png") for entry in args.only.split(",") if entry.strip()} if args.only else set() atlas_sprites = extract_source_sprites(Image.open(SOURCE_ATLAS).convert("RGBA")) stems = [ path.stem for path in sorted(UNIT_DIR.glob("unit-*.png")) if not path.name.endswith("-actions.png") ] written = 0 for stem in stems: if only and stem not in only: continue plan = unit_plan(stem) template = TEMPLATES[plan.template] source = atlas_sprites[template.atlas_index] source = tint_sprite(source, plan) sheet = draw_base_sheet(source, template, stem, plan) save_unit_png(sheet, output_dir / f"{stem}.png") save_unit_png(draw_action_sheet(sheet, template, stem), output_dir / f"{stem}-actions.png") written += 2 print(f"Redrew {written} unit sprite sheets from {SOURCE_ATLAS}") def extract_source_sprites(atlas: Image.Image) -> list[Image.Image]: cell_width = atlas.width // 4 cell_height = atlas.height // 3 sprites: list[Image.Image] = [] for index in range(12): col = index % 4 row = index // 4 cell = atlas.crop((col * cell_width, row * cell_height, (col + 1) * cell_width, (row + 1) * cell_height)) sprite = remove_chroma_key(cell) bbox = sprite.getbbox() if not bbox: raise ValueError(f"Could not extract atlas sprite {index}") sprite = sprite.crop(expand_box(bbox, sprite.size, 10)) sprites.append(sprite) return sprites def remove_chroma_key(image: Image.Image) -> Image.Image: image = image.convert("RGBA") pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = pixels[x, y] green_dominance = green - max(red, blue) is_key_green = green > 158 and red < 118 and blue < 118 and green_dominance > 58 if is_key_green: fade = min(255, max(0, round((green_dominance - 58) * 4.8))) alpha = max(0, alpha - fade) if alpha <= 18: pixels[x, y] = (red, green, blue, 0) continue if alpha > 0 and green > 150 and red < 132 and blue < 132 and green_dominance > 48: green = round(max(red, blue) + green_dominance * 0.38) pixels[x, y] = (red, green, blue, alpha) alpha = image.getchannel("A") alpha = alpha.filter(ImageFilter.MinFilter(3)).filter(ImageFilter.MaxFilter(3)) red, green, blue, _ = image.split() cleaned = Image.merge("RGBA", (red, green, blue, alpha.point(lambda value: 0 if value < 9 else value))) cleaned = despill_key_edges(cleaned) return remove_detached_artifacts(cleaned) def despill_key_edges(image: Image.Image) -> Image.Image: image = image.convert("RGBA") source = image.copy() src = source.load() pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = src[x, y] if alpha <= 0: continue green_dominance = green - max(red, blue) if green <= 120 or green_dominance <= 34: continue touches_transparency = False for ny in range(max(0, y - 1), min(height, y + 2)): for nx in range(max(0, x - 1), min(width, x + 2)): if src[nx, ny][3] <= 8: touches_transparency = True break if touches_transparency: break if not touches_transparency: continue green = min(green, max(red, blue) + 18) pixels[x, y] = (red, green, blue, alpha) return image def draw_base_sheet(source: Image.Image, template: Template, stem: str, plan: UnitPlan) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * BASE_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) fitted = fit_sprite(source, template, plan) for row, direction in enumerate(DIRECTIONS): directed = orient_sprite(fitted, direction) for col in range(IDLE_FRAME_COUNT): frame = frame_variant(directed, template, stem, direction, "idle", col) sheet.alpha_composite(frame, (col * FRAME_SIZE, row * FRAME_SIZE)) for frame_index in range(MOVE_FRAME_COUNT): col = IDLE_FRAME_COUNT + frame_index frame = frame_variant(directed, template, stem, direction, "move", frame_index) sheet.alpha_composite(frame, (col * FRAME_SIZE, row * FRAME_SIZE)) return sheet def fit_sprite(source: Image.Image, template: Template, plan: UnitPlan) -> Image.Image: bbox = source.getbbox() if not bbox: return source subject = source.crop(bbox) width, height = subject.size spec = ROLE_SPECS[template.role] target_height = round(spec.height * plan.scale) proportional_width = round(width * (target_height / height)) target_width = min(round(spec.max_width * plan.scale), max(1, proportional_width)) new_size = (max(1, target_width), max(1, target_height)) subject = subject.resize(new_size, Image.Resampling.LANCZOS) subject = subject.filter(ImageFilter.UnsharpMask(radius=0.65, percent=95, threshold=2)) return subject def orient_sprite(sprite: Image.Image, direction: str) -> Image.Image: if direction == "east": return side_variant(sprite) if direction == "west": return ImageOps_mirror(side_variant(sprite)) if direction == "north": return back_variant(sprite) return sprite def frame_variant(sprite: Image.Image, template: Template, stem: str, direction: str, pose: str, frame_index: int) -> Image.Image: canvas = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) seed = stable_int(f"{stem}:{direction}") spec = ROLE_SPECS[template.role] if pose == "move": frame = move_pose(sprite, spec, template.role, direction, frame_index) dx = move_body_dx(template.role, direction, frame_index) else: frame = idle_pose(sprite, spec, direction, frame_index) dx = spec.idle_dx[frame_index % len(spec.idle_dx)] if direction == "west": dx = -dx if direction == "north": dx = round(dx * 0.5) dx += seed % 3 - 1 bottom = FRAME_SIZE - spec.bottom_padding left = round((FRAME_SIZE - frame.width) / 2 + dx) top = round(bottom - frame.height) canvas.alpha_composite(frame, (left, top)) if pose == "move": draw_step_contact(canvas, template.role, direction, frame_index) return finish_frame(canvas) def idle_pose(sprite: Image.Image, spec: RoleSpec, direction: str, frame_index: int) -> Image.Image: index = frame_index % IDLE_FRAME_COUNT sx = spec.idle_scale_x[index] sy = spec.idle_scale_y[index] width = max(1, round(sprite.width * sx)) height = max(1, round(sprite.height * sy)) resized = sprite.resize((width, height), Image.Resampling.LANCZOS) sway = 1.2 if direction in ("south", "north") else 1.8 return row_wave(resized, sway, index / IDLE_FRAME_COUNT * math.tau, upper_bias=0.88) def move_pose(sprite: Image.Image, spec: RoleSpec, role: str, direction: str, frame_index: int) -> Image.Image: index = frame_index % MOVE_FRAME_COUNT phase = index / MOVE_FRAME_COUNT * math.tau stride = math.sin(phase) contact = abs(math.cos(phase)) mounted = role == "cavalry" sx = 1.0 + (0.025 if mounted else 0.018) * math.cos(phase) sy = 1.0 + (0.018 if mounted else 0.012) * contact width = max(1, round(sprite.width * sx)) height = max(1, round(sprite.height * sy)) resized = sprite.resize((width, height), Image.Resampling.LANCZOS) upper, lower = split_subject(resized, 0.58 if mounted else 0.62) upper_shift = round(stride * (3 if mounted else 2)) lower_shift = -round(stride * (5 if mounted else 4)) if direction == "west": upper_shift = -upper_shift lower_shift = -lower_shift if direction == "north": upper_shift = round(upper_shift * 0.5) lower_shift = round(lower_shift * 0.5) composed = Image.new("RGBA", (resized.width + 16, resized.height), (0, 0, 0, 0)) composed.alpha_composite(upper, (8 + upper_shift, 0)) composed.alpha_composite(row_wave(lower, 2.6 if mounted else 2.0, phase + math.pi, upper_bias=0.4), (8 + lower_shift, upper.height)) return row_wave(composed, 3.4 if mounted else 2.4, phase, upper_bias=0.7) def split_subject(sprite: Image.Image, split_ratio: float) -> tuple[Image.Image, Image.Image]: split_y = max(1, min(sprite.height - 1, round(sprite.height * split_ratio))) return sprite.crop((0, 0, sprite.width, split_y)), sprite.crop((0, split_y, sprite.width, sprite.height)) def row_wave(sprite: Image.Image, amplitude: float, phase: float, upper_bias: float) -> Image.Image: if amplitude <= 0: return sprite padding = math.ceil(abs(amplitude)) + 3 output = Image.new("RGBA", (sprite.width + padding * 2, sprite.height), (0, 0, 0, 0)) denominator = max(1, sprite.height - 1) for y in range(sprite.height): vertical = y / denominator weight = (1 - vertical) * upper_bias + vertical * (1 - upper_bias) dx = round(math.sin(phase + vertical * math.pi * 1.6) * amplitude * weight) output.alpha_composite(sprite.crop((0, y, sprite.width, y + 1)), (padding + dx, y)) bbox = output.getbbox() return output.crop(bbox) if bbox else output def wave_frame_subject(frame: Image.Image, amplitude: float, phase: float, upper_bias: float) -> Image.Image: bbox = frame.getbbox() if not bbox: return frame subject = frame.crop(bbox) waved = row_wave(subject, amplitude, phase, upper_bias) canvas = Image.new("RGBA", frame.size, (0, 0, 0, 0)) left = bbox[0] - max(0, (waved.width - subject.width) // 2) top = bbox[3] - waved.height canvas.alpha_composite(waved, (left, top)) return canvas def move_body_dx(role: str, direction: str, frame_index: int) -> int: if direction in ("north", "south"): return 0 mounted = role == "cavalry" stride = math.sin(frame_index / MOVE_FRAME_COUNT * math.tau) return round(stride * (4 if mounted else 3)) def draw_step_contact(canvas: Image.Image, role: str, direction: str, frame_index: int) -> None: draw = ImageDraw.Draw(canvas) phase = frame_index / MOVE_FRAME_COUNT * math.tau contact = abs(math.cos(phase)) if contact < 0.42: return y = FRAME_SIZE - 7 center = FRAME_SIZE // 2 mounted = role == "cavalry" spread = 46 if mounted else 28 alpha = round(54 + contact * 52) color = (54, 45, 34, alpha) if direction in ("east", "west"): draw.ellipse((center - spread, y - 5, center - spread + 26, y + 2), fill=color) draw.ellipse((center + spread - 26, y - 4, center + spread, y + 2), fill=color) else: draw.ellipse((center - spread // 2, y - 5, center + spread // 2, y + 2), fill=color) def draw_action_sheet(base_sheet: Image.Image, template: Template, stem: str) -> Image.Image: output = 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_index, action in enumerate(ACTION_ORDER): frame_count = ACTION_FRAME_COUNTS[action] start_col = ACTION_FRAME_OFFSETS[action] for frame_index in range(frame_count): idle_index = min(IDLE_FRAME_COUNT - 1, round(frame_index * (IDLE_FRAME_COUNT - 1) / max(1, frame_count - 1))) frame = base_sheet.crop((idle_index * FRAME_SIZE, row * FRAME_SIZE, (idle_index + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)).convert("RGBA") output.alpha_composite( action_frame(frame, template, stem, direction, action, frame_index, frame_count), ((start_col + frame_index) * FRAME_SIZE, row * FRAME_SIZE) ) return output def action_frame(frame: Image.Image, template: Template, stem: str, direction: str, action: str, frame_index: int, frame_count: int) -> Image.Image: if action == "hurt": return hurt_frame(frame, direction, frame_index) if action == "celebrate": return celebrate_frame(frame, direction, frame_index, frame_count) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) if action == "attack": impact_start = max(3, round(frame_count * 0.42)) impact_end = min(frame_count - 1, impact_start + 3) if impact_start <= frame_index <= impact_end: draw_attack_effect(draw, template, direction, frame_index - impact_start + 2) lunge_values = (0, -3, -6, 2, 11, 19, 15, 9, 4, 0) mounted_lunge_values = (0, -4, -8, 4, 16, 26, 20, 12, 5, 0) lift_values = (0, 0, -2, -5, -6, -4, -2, -1, 0, 0) lunge = sequence_value(mounted_lunge_values if template.role == "cavalry" else lunge_values, frame_index, frame_count) lift = sequence_value(lift_values, frame_index, frame_count) dx, dy = direction_offset(direction, lunge) body = offset_subject(wave_frame_subject(frame, 3.0 if template.role == "cavalry" else 2.2, frame_index / frame_count * math.tau, 0.82), dx, dy + lift) result = Image.alpha_composite(effect, body) elif action == "strategy": draw_strategy_effect(draw, frame_index, frame_count) lift = sequence_value((0, -2, -5, -7, -5, -3, -1, 0), frame_index, frame_count) result = Image.alpha_composite(effect, offset_subject(frame, 0, lift)) else: draw_item_effect(draw, direction, frame_index, frame_count) lift = sequence_value((0, -1, -3, -5, -4, -2, -1, 0), frame_index, frame_count) result = Image.alpha_composite(offset_subject(frame, 0, lift), effect) return finish_frame(result) def draw_attack_effect(draw: ImageDraw.ImageDraw, template: Template, direction: str, frame_index: int = 2) -> None: warm = (239, 177, 68, 160) bright = (255, 237, 184, 205) red = (178, 52, 42, 132) if template.role == "archer": shafts = { "east": ((122, 132), (286, 98)), "west": ((191, 132), (27, 98)), "north": ((170, 179), (135, 35)), "south": ((143, 112), (180, 283)), } start, end = shafts[direction] line(draw, [start, end], bright, 6 if frame_index == 2 else 4) line(draw, [start, end], red, 3 if frame_index == 2 else 2) draw.polygon(arrow_tip(end, start, 19), fill=bright, outline=(25, 17, 14, 210)) return if template.role == "strategist": draw_strategy_effect(draw, frame_index) return arcs = { "east": ((132, 58, 303, 224), -55, 55), "west": ((10, 58, 181, 224), 125, 235), "north": ((50, 14, 267, 182), 204, 336), "south": ((45, 113, 269, 295), 25, 155), } box, start, end = arcs[direction] width = 17 if frame_index == 2 else 11 draw.arc(box, start=start, end=end, fill=warm, width=width) inset = (box[0] + 15, box[1] + 15, box[2] - 15, box[3] - 15) draw.arc(inset, start=start + 4, end=end - 4, fill=bright, width=max(4, width - 10)) def draw_strategy_effect(draw: ImageDraw.ImageDraw, frame_index: int = 0, frame_count: int = 4) -> None: center = (156, 151) pulse = round((1 - math.cos(frame_index / max(1, frame_count - 1) * math.tau)) * 9) for radius, color, width in ((88 + pulse, (39, 94, 156, 66), 4), (58 + pulse // 2, (46, 142, 174, 106), 4), (28 + pulse // 3, (236, 184, 74, 134), 5)): draw.ellipse((center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), outline=color, width=width) for angle in range(0, 360, 60): x = center[0] + math.cos(math.radians(angle)) * 72 y = center[1] + math.sin(math.radians(angle)) * 72 draw.polygon([(x, y - 7), (x + 5, y), (x, y + 7), (x - 5, y)], fill=(236, 184, 74, 126)) def draw_item_effect(draw: ImageDraw.ImageDraw, direction: str, frame_index: int = 0, frame_count: int = 4) -> None: positions = { "south": (184, 145), "east": (220, 146), "north": (126, 137), "west": (92, 146), } x, y = positions[direction] y += sequence_value((0, -5, -11, -15, -12, -7, -2, 0), frame_index, frame_count) draw.rounded_rectangle((x - 20, y - 16, x + 20, y + 20), radius=7, fill=(126, 79, 39, 235), outline=(25, 18, 14, 235), width=3) line(draw, [(x - 27, y + 1), (x + 27, y + 1)], (226, 178, 72, 196), 4) draw.ellipse((x - 11, y - 31, x + 11, y - 9), fill=(76, 178, 112, 200), outline=(24, 18, 14, 220), width=2) def hurt_frame(frame: Image.Image, direction: str, frame_index: int = 0) -> Image.Image: knockback = [0, -10, -15, -5][frame_index] dx, dy = direction_offset(direction, knockback) body = offset_subject(frame, dx, dy + [0, -2, 2, 0][frame_index]) body = ImageEnhance.Color(body).enhance(0.74) alpha = body.getchannel("A") red = Image.new("RGBA", body.size, (188, 42, 36, 0)) red.putalpha(alpha.point(lambda value: min(86, value // 3))) body = Image.alpha_composite(body, red) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) if frame_index == 0: return finish_frame(body) cx, cy = (194, 122) if direction in ("west", "north") else (120, 122) cy += -4 if frame_index == 1 else 4 spikes = [(cx, cy - 31), (cx + 9, cy - 9), (cx + 34, cy - 6), (cx + 13, cy + 8), (cx + 23, cy + 31), (cx, cy + 15), (cx - 24, cy + 30), (cx - 14, cy + 7), (cx - 34, cy - 6), (cx - 9, cy - 9)] draw.polygon(spikes, fill=(232, 176, 72, 190), outline=(138, 31, 31, 220)) line(draw, [(cx - 38, cy - 34), (cx + 36, cy + 34)], (255, 231, 181, 166), 5) return finish_frame(Image.alpha_composite(body, effect)) def celebrate_frame(frame: Image.Image, direction: str, frame_index: int = 0, frame_count: int = 4) -> Image.Image: lift = sequence_value((0, -6, -11, -5, -9, 0), frame_index, frame_count) sway = sequence_value((0, -2, 1, 3, -1, 0), frame_index, frame_count) if direction == "west": sway = -sway body = offset_subject(frame, sway, lift) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) cx = 156 + sway cy = 92 + lift if frame_index in (1, 2, 4): draw.ellipse((cx - 42, cy - 26, cx + 42, cy + 58), outline=(255, 220, 114, 118), width=5) for angle in range(20, 360, 72): x = cx + math.cos(math.radians(angle)) * 54 y = cy + math.sin(math.radians(angle)) * 40 draw.polygon([(x, y - 8), (x + 5, y), (x, y + 8), (x - 5, y)], fill=(255, 226, 132, 148)) return finish_frame(Image.alpha_composite(body, effect)) def sequence_value(values: tuple[int, ...], frame_index: int, frame_count: int) -> int: if frame_count <= 1 or len(values) <= 1: return values[0] position = frame_index * (len(values) - 1) / max(1, frame_count - 1) left = math.floor(position) right = min(len(values) - 1, left + 1) mix = position - left return round(values[left] * (1 - mix) + values[right] * mix) def unit_plan(stem: str) -> UnitPlan: if stem in SIGNATURE_PLANS: return SIGNATURE_PLANS[stem] faction = infer_faction(stem) role = infer_role(stem) tint = FACTION_TINTS[faction] strength = 0.16 if faction == "rebel": strength = 0.12 if faction == "nanman": strength = 0.16 if faction == "shu": strength = 0.08 if faction == "wei": strength = 0.2 if faction == "wu": strength = 0.18 template_key = template_for(faction, role, stem) saturation = 1.03 if "veteran" in stem or "elite" in stem or "warlord" in stem else 1.0 brightness = 1.03 if "white" in stem else 1.0 return UnitPlan(template_key, tint, strength, saturation, brightness, 1.0) def template_for(faction: str, role: str, stem: str) -> str: if faction == "nanman": if role == "strategist" or "shaman" in stem: return "nanman-officer" return "nanman-officer" if role in {"officer", "cavalry", "polearm"} else "rebel-infantry" if faction == "wu": if role == "strategist": return "strategist" if role == "cavalry": return "rebel-cavalry" if role == "archer": return "rebel-archer" return "wu-officer" if faction == "wei": if role == "strategist": return "strategist" if role == "cavalry": return "red-cavalry" if role == "archer": return "rebel-archer" return "wei-lord" if role == "officer" else "rebel-infantry" if faction == "rebel": if role == "cavalry": return "rebel-cavalry" if role == "archer": return "rebel-archer" if role == "officer" or role == "polearm": return "rebel-infantry" return "rebel-infantry" if role == "cavalry": return "white-cavalry" if role == "archer": return "rebel-archer" if role == "strategist": return "strategist" if role == "polearm": return "green-polearm" return "shu-lord" if role == "officer" else "green-polearm" def infer_faction(stem: str) -> str: lower = stem.lower() if "nanman" in lower or "meng-huo" in lower: return "nanman" if lower.startswith("unit-wu-") or "lu-meng" 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(stem: str) -> str: lower = stem.lower() 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 "huang-zhong" in lower or "crossbow" in lower or "longbow" in lower: return "archer" if any(token in lower for token in ("strategist", "shaman", "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 "polearm" if "leader" in lower or "officer" in lower or any(token in lower for token in ("liu-bei", "cao-cao", "meng-huo", "wei-yan", "wang-ping", "wu-yi", "li-yan", "huang-quan", "gong-zhi")): return "officer" return "infantry" def tint_sprite(image: Image.Image, plan: UnitPlan) -> Image.Image: image = image.convert("RGBA") if plan.tint_strength <= 0: return image pixels = image.load() tint_h, tint_s, tint_v = colorsys.rgb_to_hsv(plan.tint[0] / 255, plan.tint[1] / 255, plan.tint[2] / 255) for y in range(image.height): for x in range(image.width): red, green, blue, alpha = pixels[x, y] if alpha <= 8 or is_skin(red, green, blue) or is_neutral_metal(red, green, blue): continue hue, sat, val = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255) strength = plan.tint_strength * (0.45 + min(0.55, sat)) hue = hue * (1 - strength) + tint_h * strength sat = min(1.0, sat * plan.saturation + tint_s * strength * 0.45) val = min(1.0, val * plan.brightness + tint_v * strength * 0.08) nr, ng, nb = colorsys.hsv_to_rgb(hue, sat, val) pixels[x, y] = (round(nr * 255), round(ng * 255), round(nb * 255), alpha) return image def back_variant(sprite: Image.Image) -> Image.Image: result = sprite.copy() result = result.resize((max(1, round(result.width * 0.96)), result.height), Image.Resampling.LANCZOS) overlay = Image.new("RGBA", result.size, (18, 20, 26, 0)) overlay.putalpha(result.getchannel("A").point(lambda value: min(48, value // 6))) result = Image.alpha_composite(result, overlay) return result def side_variant(sprite: Image.Image) -> Image.Image: result = sprite.resize((max(1, round(sprite.width * 0.9)), sprite.height), Image.Resampling.LANCZOS) overlay = Image.new("RGBA", result.size, (16, 16, 22, 0)) overlay.putalpha(result.getchannel("A").point(lambda value: min(18, value // 18))) return Image.alpha_composite(result, overlay) def finish_frame(frame: Image.Image) -> Image.Image: frame = clean_alpha(frame) alpha = frame.getchannel("A") if not alpha.getbbox(): return frame ink_alpha = ImageChops.subtract(alpha.filter(ImageFilter.MaxFilter(3)), alpha) rim = Image.new("RGBA", frame.size, (10, 8, 10, 0)) rim.putalpha(ink_alpha.point(lambda value: min(180, round(value * 0.65)))) result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) result.alpha_composite(rim) result.alpha_composite(frame) return clean_alpha(result) 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 < 5 else (255 if value > 250 else value)) return Image.merge("RGBA", (red, green, blue, alpha)) def remove_detached_artifacts(image: Image.Image) -> Image.Image: alpha = image.getchannel("A") pixels = alpha.load() width, height = alpha.size visited = bytearray(width * height) components: list[tuple[int, tuple[int, int, int, int], list[tuple[int, int]]]] = [] for y in range(height): for x in range(width): index = y * width + x if visited[index] or pixels[x, y] <= 8: continue stack = [(x, y)] visited[index] = 1 points: list[tuple[int, int]] = [] left = right = x top = bottom = y while stack: px, py = stack.pop() points.append((px, py)) left = min(left, px) right = max(right, px) top = min(top, py) bottom = max(bottom, py) for nx, ny in ((px + 1, py), (px - 1, py), (px, py + 1), (px, py - 1)): if nx < 0 or nx >= width or ny < 0 or ny >= height: continue ni = ny * width + nx if visited[ni] or pixels[nx, ny] <= 8: continue visited[ni] = 1 stack.append((nx, ny)) components.append((len(points), (left, top, right + 1, bottom + 1), points)) if not components: return image components.sort(key=lambda component: component[0], reverse=True) keep_points = set(components[0][2]) largest_area, largest_bbox, _ = components[0] for area, bbox, points in components[1:]: if area >= largest_area * 0.08 or bbox_distance(largest_bbox, bbox) < 12: keep_points.update(points) cleaned_alpha = Image.new("L", image.size, 0) cleaned_pixels = cleaned_alpha.load() source_pixels = alpha.load() for x, y in keep_points: cleaned_pixels[x, y] = source_pixels[x, y] red, green, blue, _ = image.split() return Image.merge("RGBA", (red, green, blue, cleaned_alpha)) def expand_box(box: tuple[int, int, int, int], size: tuple[int, int], padding: int) -> tuple[int, int, int, int]: left, top, right, bottom = box width, height = size return (max(0, left - padding), max(0, top - padding), min(width, right + padding), min(height, bottom + padding)) def bbox_distance(left_box: tuple[int, int, int, int], right_box: tuple[int, int, int, int]) -> int: left_a, top_a, right_a, bottom_a = left_box left_b, top_b, right_b, bottom_b = right_box dx = max(left_a - right_b, left_b - right_a, 0) dy = max(top_a - bottom_b, top_b - bottom_a, 0) return max(dx, dy) def offset_subject(frame: Image.Image, dx: int, dy: int) -> Image.Image: canvas = Image.new("RGBA", frame.size, (0, 0, 0, 0)) bbox = frame.getbbox() if not bbox: return frame subject = frame.crop(bbox) canvas.alpha_composite(subject, (bbox[0] + dx, bbox[1] + dy)) return canvas def direction_offset(direction: str, amount: int) -> tuple[int, int]: if direction == "east": return amount, 0 if direction == "west": return -amount, 0 if direction == "north": return 0, -amount return 0, amount def arrow_tip(end: tuple[float, float], start: tuple[float, float], size: float) -> list[tuple[float, float]]: ex, ey = end sx, sy = start angle = math.atan2(ey - sy, ex - sx) left = angle + math.radians(150) right = angle - math.radians(150) return [ (ex, ey), (ex + math.cos(left) * size, ey + math.sin(left) * size), (ex - math.cos(angle) * size * 0.45, ey - math.sin(angle) * size * 0.45), (ex + math.cos(right) * size, ey + math.sin(right) * size), ] def line(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: tuple[int, int, int, int], width: int) -> None: draw.line([(round(x), round(y)) for x, y in points], fill=fill, width=width, joint="curve") def is_skin(red: int, green: int, blue: int) -> bool: return red > 110 and green > 55 and blue > 32 and red > green * 1.1 and green > blue * 1.05 def is_neutral_metal(red: int, green: int, blue: int) -> bool: return abs(red - green) < 18 and abs(green - blue) < 22 and red > 118 def stable_int(value: str) -> int: return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:8], 16) def ImageOps_mirror(image: Image.Image) -> Image.Image: from PIL import ImageOps return ImageOps.mirror(image) def save_unit_png(image: Image.Image, path: Path) -> None: image = clean_alpha(image) indexed = image.quantize(colors=PALETTE_COLORS, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) indexed.save(path, optimize=True) if __name__ == "__main__": main()