690 lines
28 KiB
Python
690 lines
28 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
|
|
|
|
|
|
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")
|
|
|
|
|
|
@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.985, 1.015, 1.0), (1.0, 1.018, 0.992, 1.01), (0, 0, 0, 0)),
|
|
"polearm": RoleSpec(292, 282, 4, (1.0, 0.986, 1.014, 1.0), (1.0, 1.018, 0.992, 1.01), (0, 0, 0, 0)),
|
|
"cavalry": RoleSpec(292, 304, 3, (1.0, 1.012, 0.988, 1.008), (1.0, 1.014, 0.99, 1.008), (0, 1, 0, -1)),
|
|
"strategist": RoleSpec(276, 250, 5, (1.0, 0.988, 1.012, 1.0), (1.0, 1.018, 0.994, 1.01), (0, 0, 0, 0)),
|
|
"archer": RoleSpec(282, 276, 4, (1.0, 0.986, 1.012, 1.0), (1.0, 1.016, 0.992, 1.01), (0, 0, 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 * GRID_SIZE, FRAME_SIZE * GRID_SIZE), (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(GRID_SIZE):
|
|
frame = frame_variant(directed, template, stem, direction, col)
|
|
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, 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]
|
|
frame = idle_pose(sprite, spec, frame_index)
|
|
dx = spec.idle_dx[frame_index]
|
|
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))
|
|
return finish_frame(canvas)
|
|
|
|
|
|
def idle_pose(sprite: Image.Image, spec: RoleSpec, frame_index: int) -> Image.Image:
|
|
sx = spec.idle_scale_x[frame_index]
|
|
sy = spec.idle_scale_y[frame_index]
|
|
width = max(1, round(sprite.width * sx))
|
|
height = max(1, round(sprite.height * sy))
|
|
return sprite.resize((width, height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def draw_action_sheet(base_sheet: Image.Image, template: Template, stem: str) -> Image.Image:
|
|
output = Image.new("RGBA", (FRAME_SIZE * GRID_SIZE * len(ACTION_ORDER), FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0))
|
|
for row, direction in enumerate(DIRECTIONS):
|
|
for action_index, action in enumerate(ACTION_ORDER):
|
|
for frame_index in range(GRID_SIZE):
|
|
frame = base_sheet.crop((frame_index * FRAME_SIZE, row * FRAME_SIZE, (frame_index + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)).convert("RGBA")
|
|
output.alpha_composite(
|
|
action_frame(frame, template, stem, direction, action, frame_index),
|
|
((action_index * GRID_SIZE + 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) -> Image.Image:
|
|
if action == "hurt":
|
|
return hurt_frame(frame, direction, frame_index)
|
|
if action == "celebrate":
|
|
return celebrate_frame(frame, direction, frame_index)
|
|
|
|
effect = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(effect)
|
|
if action == "attack":
|
|
if frame_index >= 2:
|
|
draw_attack_effect(draw, template, direction, frame_index)
|
|
lunge = [0, 5, 13, 7][frame_index] if template.role != "cavalry" else [0, 8, 18, 10][frame_index]
|
|
lift = [0, -2, -4, -1][frame_index]
|
|
dx, dy = direction_offset(direction, lunge)
|
|
body = offset_subject(frame, dx, dy + lift)
|
|
result = Image.alpha_composite(effect, body)
|
|
elif action == "strategy":
|
|
draw_strategy_effect(draw, frame_index)
|
|
result = Image.alpha_composite(effect, offset_subject(frame, 0, [0, -3, -5, -2][frame_index]))
|
|
else:
|
|
draw_item_effect(draw, direction, frame_index)
|
|
result = Image.alpha_composite(offset_subject(frame, 0, [0, -2, -3, -1][frame_index]), 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) -> None:
|
|
center = (156, 151)
|
|
pulse = (0, 8, 16, 6)[frame_index]
|
|
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) -> None:
|
|
positions = {
|
|
"south": (184, 145),
|
|
"east": (220, 146),
|
|
"north": (126, 137),
|
|
"west": (92, 146),
|
|
}
|
|
x, y = positions[direction]
|
|
y += [0, -8, -12, -6][frame_index]
|
|
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) -> Image.Image:
|
|
lift = [0, -8, -3, -11][frame_index]
|
|
sway = [0, -2, 2, 0][frame_index]
|
|
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, 3):
|
|
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 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()
|