Rebuild battle unit sprites as raster sheets
This commit is contained in:
682
scripts/build-raster-unit-sprites.py
Normal file
682
scripts/build-raster-unit-sprites.py
Normal file
@@ -0,0 +1,682 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import colorsys
|
||||
import hashlib
|
||||
import math
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
SOURCE_DIR = ROOT / "docs" / "unit-sprite-art-sources"
|
||||
CONTACT_PATH = ROOT / "docs" / "unit-sprite-raster-contact-sheet-v1.png"
|
||||
ACTUAL_SIZE_PATH = ROOT / "docs" / "unit-sprite-raster-actual-size-v1.png"
|
||||
|
||||
FRAME_SIZE = 313
|
||||
PNG_PALETTE_COLORS = 192
|
||||
DIRECTIONS = ("south", "east", "north", "west")
|
||||
IDLE_FRAME_COUNT = 8
|
||||
MOVE_FRAME_COUNT = 8
|
||||
BASE_FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + MOVE_FRAME_COUNT
|
||||
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())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceGrid:
|
||||
name: str
|
||||
file: str
|
||||
x_lines: tuple[int, ...]
|
||||
y_lines_by_section: tuple[tuple[int, ...], tuple[int, ...]]
|
||||
attack_col: int
|
||||
|
||||
@property
|
||||
def base_cols(self) -> int:
|
||||
return max(4, min(6, self.attack_col))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitStyle:
|
||||
source: str
|
||||
section: int
|
||||
faction: str
|
||||
role: str
|
||||
rank: int
|
||||
target: tuple[int, int, int]
|
||||
secondary: tuple[int, int, int]
|
||||
scale: float
|
||||
bulk: float
|
||||
|
||||
|
||||
SOURCES: dict[str, SourceGrid] = {
|
||||
"infantry": SourceGrid(
|
||||
"infantry",
|
||||
"infantry-spearman-master.png",
|
||||
(134, 326, 518, 710, 902, 1094, 1289),
|
||||
((9, 140, 254, 370, 483), (498, 627, 743, 862, 981)),
|
||||
5,
|
||||
),
|
||||
"ranged": SourceGrid(
|
||||
"ranged",
|
||||
"archer-strategist-master.png",
|
||||
(161, 320, 477, 631, 785, 937, 1091, 1243),
|
||||
((18, 149, 268, 389, 510), (523, 655, 779, 904, 1011)),
|
||||
6,
|
||||
),
|
||||
"cavalry": SourceGrid(
|
||||
"cavalry",
|
||||
"cavalry-master.png",
|
||||
(34, 193, 352, 510, 670, 828, 991),
|
||||
((25, 205, 380, 559, 735), (768, 954, 1129, 1308, 1484)),
|
||||
5,
|
||||
),
|
||||
"rebel": SourceGrid(
|
||||
"rebel",
|
||||
"rebel-master.png",
|
||||
(160, 346, 535, 723, 912, 1100, 1289),
|
||||
((17, 138, 263, 390, 504), (518, 647, 769, 893, 1017)),
|
||||
5,
|
||||
),
|
||||
"nanman": SourceGrid(
|
||||
"nanman",
|
||||
"nanman-master.png",
|
||||
(18, 180, 343, 506, 667, 833, 997),
|
||||
((13, 183, 353, 519, 687), (711, 913, 1110, 1303, 1485)),
|
||||
5,
|
||||
),
|
||||
"commander": SourceGrid(
|
||||
"commander",
|
||||
"commander-master.png",
|
||||
(16, 174, 330, 486, 642, 795, 948),
|
||||
((9, 201, 389, 571, 757), (783, 977, 1162, 1347, 1510)),
|
||||
5,
|
||||
),
|
||||
}
|
||||
|
||||
REPRESENTATIVE_UNITS = (
|
||||
"unit-liu-bei",
|
||||
"unit-guan-yu",
|
||||
"unit-zhang-fei",
|
||||
"unit-shu-archer",
|
||||
"unit-shu-strategist",
|
||||
"unit-zhao-yun",
|
||||
"unit-rebel",
|
||||
"unit-rebel-archer",
|
||||
"unit-rebel-cavalry",
|
||||
"unit-wei-infantry",
|
||||
"unit-wu-cavalry",
|
||||
"unit-nanman-shaman",
|
||||
)
|
||||
|
||||
FACTION_TARGETS = {
|
||||
"shu": ((49, 124, 79), (219, 179, 76)),
|
||||
"wei": ((67, 83, 156), (213, 171, 77)),
|
||||
"wu": ((36, 132, 140), (219, 148, 76)),
|
||||
"rebel": ((143, 103, 55), (196, 143, 62)),
|
||||
"nanman": ((158, 84, 47), (210, 148, 66)),
|
||||
}
|
||||
|
||||
SIGNATURE_TARGETS = {
|
||||
"unit-liu-bei": ((48, 130, 78), (229, 190, 83)),
|
||||
"unit-guan-yu": ((32, 118, 64), (225, 181, 75)),
|
||||
"unit-zhang-fei": ((137, 47, 45), (222, 163, 68)),
|
||||
"unit-zhao-yun": ((89, 142, 184), (237, 230, 202)),
|
||||
"unit-zhuge-liang": ((82, 148, 136), (234, 229, 196)),
|
||||
"unit-cao-cao": ((63, 75, 150), (214, 174, 78)),
|
||||
"unit-lu-bu": ((145, 38, 50), (224, 164, 66)),
|
||||
"unit-meng-huo": ((170, 82, 43), (224, 157, 62)),
|
||||
"unit-ma-chao": ((98, 144, 186), (231, 219, 170)),
|
||||
"unit-ma-dai": ((83, 129, 164), (220, 200, 146)),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build final unit sprite sheets from generated raster source sheets.")
|
||||
parser.add_argument("--only", help="Comma-separated unit stems to rebuild.")
|
||||
parser.add_argument("--preview-only", action="store_true", help="Only write representative contact sheets.")
|
||||
args = parser.parse_args()
|
||||
|
||||
stems = list(REPRESENTATIVE_UNITS) if args.preview_only and not args.only else discover_unit_stems(normalize_only(args.only))
|
||||
library = RasterLibrary()
|
||||
|
||||
rendered: dict[str, tuple[Image.Image, Image.Image]] = {}
|
||||
for stem in stems:
|
||||
style = unit_style(stem)
|
||||
base_sheet = build_base_sheet(library, style)
|
||||
action_sheet = build_action_sheet(library, style)
|
||||
if stem in REPRESENTATIVE_UNITS:
|
||||
rendered[stem] = (base_sheet.copy(), action_sheet.copy())
|
||||
if not args.preview_only:
|
||||
save_png(base_sheet, UNIT_DIR / f"{stem}.png")
|
||||
save_png(action_sheet, UNIT_DIR / f"{stem}-actions.png")
|
||||
|
||||
for stem in REPRESENTATIVE_UNITS:
|
||||
if stem not in rendered:
|
||||
style = unit_style(stem)
|
||||
rendered[stem] = (build_base_sheet(library, style), build_action_sheet(library, style))
|
||||
|
||||
write_contact_sheet(rendered, CONTACT_PATH)
|
||||
write_actual_size_preview(rendered, ACTUAL_SIZE_PATH)
|
||||
print(f"Raster-built {len(stems)} unit sprite definitions.")
|
||||
print(f"Representative contact sheet: {CONTACT_PATH}")
|
||||
print(f"Actual-size preview: {ACTUAL_SIZE_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
|
||||
|
||||
|
||||
class RasterLibrary:
|
||||
def __init__(self) -> None:
|
||||
self.sources = {
|
||||
name: Image.open(SOURCE_DIR / grid.file).convert("RGB")
|
||||
for name, grid in SOURCES.items()
|
||||
}
|
||||
self.cell_cache: dict[tuple[str, int, str, int], Image.Image] = {}
|
||||
self.frame_cache: dict[tuple[UnitStyle, str, int], Image.Image] = {}
|
||||
|
||||
def frame(self, style: UnitStyle, direction: str, col: int) -> Image.Image:
|
||||
frame_key = (style, direction, col)
|
||||
if frame_key in self.frame_cache:
|
||||
return self.frame_cache[frame_key].copy()
|
||||
key = (style.source, style.section, direction, col)
|
||||
if key not in self.cell_cache:
|
||||
self.cell_cache[key] = self.extract_cell(SOURCES[style.source], style.section, direction, col)
|
||||
cell = self.cell_cache[key].copy()
|
||||
cell = apply_unit_variant(cell, style)
|
||||
frame = place_on_canvas(cell, style, direction)
|
||||
self.frame_cache[frame_key] = frame
|
||||
return frame.copy()
|
||||
|
||||
def extract_cell(self, grid: SourceGrid, section: int, direction: str, col: int) -> Image.Image:
|
||||
image = self.sources[grid.name]
|
||||
row = DIRECTIONS.index(direction)
|
||||
y_lines = grid.y_lines_by_section[section]
|
||||
x0 = grid.x_lines[col] + 2
|
||||
x1 = grid.x_lines[col + 1] - 2
|
||||
y0 = y_lines[row] + 2
|
||||
y1 = y_lines[row + 1] - 2
|
||||
crop = image.crop((x0, y0, x1, y1))
|
||||
transparent = remove_cell_background(crop)
|
||||
bbox = transparent.getbbox()
|
||||
if bbox:
|
||||
transparent = transparent.crop(bbox)
|
||||
return transparent
|
||||
|
||||
|
||||
def build_base_sheet(library: RasterLibrary, style: UnitStyle) -> Image.Image:
|
||||
sheet = Image.new("RGBA", (FRAME_SIZE * BASE_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
grid = SOURCES[style.source]
|
||||
idle_seq = idle_sequence(grid.base_cols)
|
||||
move_seq = move_sequence(grid.base_cols)
|
||||
for row, direction in enumerate(DIRECTIONS):
|
||||
for frame_index, col in enumerate(idle_seq):
|
||||
frame = library.frame(style, direction, col)
|
||||
frame = motion_adjust(frame, style, "idle", frame_index, len(idle_seq), direction)
|
||||
sheet.alpha_composite(frame, (frame_index * FRAME_SIZE, row * FRAME_SIZE))
|
||||
for frame_index, col in enumerate(move_seq):
|
||||
frame = library.frame(style, direction, col)
|
||||
frame = motion_adjust(frame, style, "move", frame_index, len(move_seq), direction)
|
||||
sheet.alpha_composite(frame, ((IDLE_FRAME_COUNT + frame_index) * FRAME_SIZE, row * FRAME_SIZE))
|
||||
return sheet
|
||||
|
||||
|
||||
def build_action_sheet(library: RasterLibrary, style: UnitStyle) -> Image.Image:
|
||||
sheet = Image.new("RGBA", (FRAME_SIZE * ACTION_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
grid = SOURCES[style.source]
|
||||
for row, direction in enumerate(DIRECTIONS):
|
||||
for action in ACTION_ORDER:
|
||||
seq = action_sequence(action, grid)
|
||||
start_col = ACTION_COLUMN_OFFSETS[action]
|
||||
for frame_index, source_col in enumerate(seq):
|
||||
frame = library.frame(style, direction, source_col)
|
||||
frame = motion_adjust(frame, style, action, frame_index, len(seq), direction)
|
||||
sheet.alpha_composite(frame, ((start_col + frame_index) * FRAME_SIZE, row * FRAME_SIZE))
|
||||
return sheet
|
||||
|
||||
|
||||
def idle_sequence(base_cols: int) -> list[int]:
|
||||
if base_cols >= 6:
|
||||
return [0, 1, 2, 3, 4, 5, 4, 2]
|
||||
return [0, 1, 2, 3, 4, 3, 2, 1]
|
||||
|
||||
|
||||
def move_sequence(base_cols: int) -> list[int]:
|
||||
if base_cols >= 6:
|
||||
return [1, 2, 3, 4, 5, 4, 2, 0]
|
||||
return [1, 2, 3, 4, 3, 2, 1, 0]
|
||||
|
||||
|
||||
def action_sequence(action: str, grid: SourceGrid) -> list[int]:
|
||||
attack = grid.attack_col
|
||||
ready = max(0, attack - 1)
|
||||
if action == "attack":
|
||||
return [0, 1, ready, attack, attack, ready, 2, 1, 0, 0]
|
||||
if action == "strategy":
|
||||
return [0, 1, ready, attack, attack, ready, 1, 0]
|
||||
if action == "item":
|
||||
return [0, 1, 2, ready, attack, ready, 1, 0]
|
||||
if action == "hurt":
|
||||
return [0, 1, 1, 0]
|
||||
return [0, 1, 2, ready, attack, 1]
|
||||
|
||||
|
||||
def motion_adjust(frame: Image.Image, style: UnitStyle, action: str, index: int, count: int, direction: str) -> Image.Image:
|
||||
result = frame.copy()
|
||||
phase = math.sin((index / max(1, count)) * math.tau)
|
||||
if action == "idle":
|
||||
return offset_frame(result, 0, -1 if phase > 0.55 else 0)
|
||||
if action == "move":
|
||||
stride = 3 if style.role == "cavalry" else 2
|
||||
dx, dy = direction_delta(direction)
|
||||
return offset_frame(result, int(dx * stride * phase), int(dy * stride * phase) - abs(int(phase)))
|
||||
if action == "attack":
|
||||
dx, dy = direction_delta(direction)
|
||||
lunge = max(0, math.sin((index / max(1, count - 1)) * math.pi)) * (9 if style.role == "cavalry" else 6)
|
||||
return offset_frame(result, int(dx * lunge), int(dy * lunge * 0.45))
|
||||
if action == "hurt":
|
||||
result = tint_rgba(result, (180, 55, 45), 0.2 + index * 0.04)
|
||||
dx, _ = direction_delta(direction)
|
||||
return offset_frame(result, int(-dx * (index + 1) * 2), -1)
|
||||
if action == "celebrate":
|
||||
lift = 4 if index in (2, 3) else 0
|
||||
return offset_frame(result, 0, -lift)
|
||||
if action in ("strategy", "item"):
|
||||
return brighten_frame(result, 1.0 + (0.05 if index in (3, 4) else 0))
|
||||
return result
|
||||
|
||||
|
||||
def direction_delta(direction: str) -> tuple[int, int]:
|
||||
return {
|
||||
"south": (0, 1),
|
||||
"east": (1, 0),
|
||||
"north": (0, -1),
|
||||
"west": (-1, 0),
|
||||
}[direction]
|
||||
|
||||
|
||||
def offset_frame(image: Image.Image, dx: int, dy: int) -> Image.Image:
|
||||
if dx == 0 and dy == 0:
|
||||
return image
|
||||
result = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
result.alpha_composite(image, (dx, dy))
|
||||
return result
|
||||
|
||||
|
||||
def brighten_frame(image: Image.Image, factor: float) -> Image.Image:
|
||||
rgb = ImageEnhance.Brightness(image.convert("RGB")).enhance(factor)
|
||||
rgb.putalpha(image.getchannel("A"))
|
||||
return rgb
|
||||
|
||||
|
||||
def remove_cell_background(image: Image.Image) -> Image.Image:
|
||||
rgb = image.convert("RGB")
|
||||
w, h = rgb.size
|
||||
border_samples: list[tuple[int, int, int]] = []
|
||||
for x in range(w):
|
||||
border_samples.append(rgb.getpixel((x, 0)))
|
||||
border_samples.append(rgb.getpixel((x, h - 1)))
|
||||
for y in range(h):
|
||||
border_samples.append(rgb.getpixel((0, y)))
|
||||
border_samples.append(rgb.getpixel((w - 1, y)))
|
||||
bg = tuple(sorted(channel)[len(channel) // 2] for channel in zip(*border_samples))
|
||||
visited = bytearray(w * h)
|
||||
alpha = bytearray([255] * (w * h))
|
||||
queue: deque[tuple[int, int]] = deque()
|
||||
for x in range(w):
|
||||
queue.append((x, 0))
|
||||
queue.append((x, h - 1))
|
||||
for y in range(h):
|
||||
queue.append((0, y))
|
||||
queue.append((w - 1, y))
|
||||
tolerance = 48
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
if x < 0 or y < 0 or x >= w or y >= h:
|
||||
continue
|
||||
index = y * w + x
|
||||
if visited[index]:
|
||||
continue
|
||||
visited[index] = 1
|
||||
pixel = rgb.getpixel((x, y))
|
||||
if color_distance(pixel, bg) > tolerance and not is_background_shadow(pixel, bg):
|
||||
continue
|
||||
alpha[index] = 0
|
||||
queue.append((x + 1, y))
|
||||
queue.append((x - 1, y))
|
||||
queue.append((x, y + 1))
|
||||
queue.append((x, y - 1))
|
||||
|
||||
rgba = rgb.convert("RGBA")
|
||||
rgba.putalpha(Image.frombytes("L", (w, h), bytes(alpha)).filter(ImageFilter.GaussianBlur(0.35)))
|
||||
return rgba
|
||||
|
||||
|
||||
def is_background_shadow(pixel: tuple[int, int, int], bg: tuple[int, int, int]) -> bool:
|
||||
pr, pg, pb = pixel
|
||||
br, bgc, bb = bg
|
||||
return pr < br + 18 and pg < bgc + 18 and pb < bb + 18 and max(pixel) - min(pixel) < 35
|
||||
|
||||
|
||||
def color_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
|
||||
return math.sqrt(sum((left - right) ** 2 for left, right in zip(a, b)))
|
||||
|
||||
|
||||
def apply_unit_variant(image: Image.Image, style: UnitStyle) -> Image.Image:
|
||||
seed = stable_int(f"{style.source}:{style.section}:{style.role}:{style.faction}:{style.rank}")
|
||||
strength = 0.26 + (style.rank * 0.04)
|
||||
if style.faction in {"rebel", "nanman"}:
|
||||
strength += 0.08
|
||||
if style.source == "commander":
|
||||
strength *= 0.72
|
||||
shifted = shift_hue(image, ((seed % 13) - 6) / 240.0)
|
||||
tinted = tint_rgba(shifted, style.target, strength)
|
||||
rgb = ImageEnhance.Contrast(tinted.convert("RGB")).enhance(1.04 + style.rank * 0.035)
|
||||
rgb = ImageEnhance.Brightness(rgb).enhance(1.12 + style.rank * 0.025)
|
||||
rgb.putalpha(tinted.getchannel("A"))
|
||||
return rgb
|
||||
|
||||
|
||||
def tint_rgba(image: Image.Image, color: tuple[int, int, int], strength: float) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
target = color
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
if max(r, g, b) - min(r, g, b) < 18 and r > 160:
|
||||
continue
|
||||
lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
||||
local_strength = strength * (0.55 + 0.35 * (1 - lum))
|
||||
pixels[x, y] = (
|
||||
clamp(r * (1 - local_strength) + target[0] * local_strength),
|
||||
clamp(g * (1 - local_strength) + target[1] * local_strength),
|
||||
clamp(b * (1 - local_strength) + target[2] * local_strength),
|
||||
a,
|
||||
)
|
||||
return rgba
|
||||
|
||||
|
||||
def shift_hue(image: Image.Image, amount: float) -> Image.Image:
|
||||
if abs(amount) < 0.001:
|
||||
return image.copy()
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||
if s < 0.12 or v > 0.88:
|
||||
continue
|
||||
h = (h + amount) % 1.0
|
||||
nr, ng, nb = colorsys.hsv_to_rgb(h, s, v)
|
||||
pixels[x, y] = (round(nr * 255), round(ng * 255), round(nb * 255), a)
|
||||
return rgba
|
||||
|
||||
|
||||
def place_on_canvas(sprite: Image.Image, style: UnitStyle, direction: str) -> Image.Image:
|
||||
bbox = sprite.getbbox()
|
||||
if bbox:
|
||||
sprite = sprite.crop(bbox)
|
||||
max_w, max_h = target_bounds(style, direction)
|
||||
scale = min(max_w / sprite.width, max_h / sprite.height) * style.scale
|
||||
scale = min(scale, max_w / sprite.width, max_h / sprite.height)
|
||||
new_size = (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale)))
|
||||
sprite = sprite.resize(new_size, Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0))
|
||||
x = (FRAME_SIZE - sprite.width) // 2
|
||||
if direction == "east":
|
||||
x += 4
|
||||
elif direction == "west":
|
||||
x -= 4
|
||||
bottom = 294 if style.role != "cavalry" else 296
|
||||
y = bottom - sprite.height
|
||||
canvas.alpha_composite(sprite, (x, y))
|
||||
return canvas
|
||||
|
||||
|
||||
def target_bounds(style: UnitStyle, direction: str) -> tuple[int, int]:
|
||||
if style.role == "cavalry":
|
||||
if direction in {"east", "west"}:
|
||||
return (304, 262)
|
||||
return (252, 276)
|
||||
if style.role == "strategist":
|
||||
return (190, 260)
|
||||
if style.role == "archer":
|
||||
return (198, 252)
|
||||
if style.role in {"lord", "officer"}:
|
||||
return (220, 268)
|
||||
if style.role == "spearman":
|
||||
return (226, 266)
|
||||
return (208, 258)
|
||||
|
||||
|
||||
def unit_style(stem: str) -> UnitStyle:
|
||||
lower = stem.lower()
|
||||
faction = infer_faction(lower)
|
||||
role = infer_role(lower)
|
||||
rank = infer_rank(lower)
|
||||
target, secondary = SIGNATURE_TARGETS.get(stem, FACTION_TARGETS[faction])
|
||||
source, section = source_for(stem, faction, role, rank)
|
||||
scale = 1.0
|
||||
bulk = 1.0
|
||||
if role == "strategist":
|
||||
scale *= 0.96
|
||||
bulk *= 0.9
|
||||
elif role == "archer":
|
||||
scale *= 0.98
|
||||
elif role == "cavalry":
|
||||
scale *= 1.0
|
||||
elif role in {"lord", "officer"}:
|
||||
scale *= 1.04
|
||||
if "ragged" in lower or "scout" in lower:
|
||||
scale *= 0.96
|
||||
if "veteran" in lower or "elite" in lower or "warlord" in lower:
|
||||
scale *= 1.03
|
||||
rank = max(rank, 1)
|
||||
if rank >= 2:
|
||||
scale *= 1.04
|
||||
return UnitStyle(source, section, faction, role, rank, target, secondary, scale, bulk)
|
||||
|
||||
|
||||
def source_for(stem: str, faction: str, role: str, rank: int) -> tuple[str, int]:
|
||||
lower = stem.lower()
|
||||
if faction == "nanman":
|
||||
if role == "strategist" or "shaman" in lower:
|
||||
return "nanman", 1
|
||||
return "nanman", 0
|
||||
if faction == "rebel":
|
||||
if role in {"archer", "strategist"}:
|
||||
return "rebel", 1
|
||||
return "rebel", 0
|
||||
if role == "cavalry":
|
||||
return "cavalry", 1 if any(token in lower for token in ("zhao-yun", "ma-chao", "ma-dai", "white", "elite")) else 0
|
||||
if role == "archer":
|
||||
return "ranged", 0
|
||||
if role == "strategist":
|
||||
return "ranged", 1
|
||||
if role in {"lord", "officer"} or rank >= 2:
|
||||
if any(token in lower for token in ("guan-yu", "zhang-fei", "wei-yan")):
|
||||
return "commander", 1
|
||||
return "commander", 0
|
||||
if role == "spearman":
|
||||
return "infantry", 1
|
||||
return "infantry", 0
|
||||
|
||||
|
||||
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:
|
||||
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", "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 infer_rank(lower: str) -> int:
|
||||
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")):
|
||||
return 2
|
||||
if any(token in lower for token in ("officer", "leader", "veteran", "elite", "guard", "warlord")):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def stable_int(value: str) -> int:
|
||||
return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:8], 16)
|
||||
|
||||
|
||||
def clamp(value: float) -> int:
|
||||
return max(0, min(255, round(value)))
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output = image
|
||||
if image.mode == "RGBA":
|
||||
output = image.quantize(colors=PNG_PALETTE_COLORS, method=Image.Quantize.FASTOCTREE)
|
||||
temporary_path = path.with_name(f".{path.stem}.tmp.png")
|
||||
output.save(temporary_path, optimize=True)
|
||||
temporary_path.replace(path)
|
||||
|
||||
|
||||
def write_contact_sheet(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None:
|
||||
cell = 66
|
||||
label_w = 148
|
||||
gap = 14
|
||||
rows_per_unit = 8
|
||||
width = label_w + cell * 10 + 36
|
||||
height = 34 + len(REPRESENTATIVE_UNITS) * (rows_per_unit * cell + gap) + 12
|
||||
sheet = Image.new("RGB", (width, height), (30, 36, 28))
|
||||
y = 18
|
||||
for stem in REPRESENTATIVE_UNITS:
|
||||
base, action = rendered[stem]
|
||||
draw_label(sheet, stem, 12, y + 8)
|
||||
for direction_index, direction in enumerate(DIRECTIONS):
|
||||
row_y = y + direction_index * cell
|
||||
draw_label(sheet, f"idle {direction}", 12, row_y + 28, small=True)
|
||||
for col in range(6):
|
||||
frame = base.crop((col * FRAME_SIZE, direction_index * FRAME_SIZE, (col + 1) * FRAME_SIZE, (direction_index + 1) * FRAME_SIZE))
|
||||
paste_thumb(sheet, frame, label_w + col * cell, row_y, cell)
|
||||
action_y = y + 4 * cell
|
||||
for direction_index, direction in enumerate(DIRECTIONS):
|
||||
row_y = action_y + direction_index * cell
|
||||
draw_label(sheet, f"atk {direction}", 12, row_y + 28, small=True)
|
||||
for col in range(6):
|
||||
frame_index = ACTION_COLUMN_OFFSETS["attack"] + col + 2
|
||||
frame = action.crop((frame_index * FRAME_SIZE, direction_index * FRAME_SIZE, (frame_index + 1) * FRAME_SIZE, (direction_index + 1) * FRAME_SIZE))
|
||||
paste_thumb(sheet, frame, label_w + col * cell, row_y, cell)
|
||||
y += rows_per_unit * cell + gap
|
||||
save_png(sheet, path)
|
||||
|
||||
|
||||
def write_actual_size_preview(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None:
|
||||
tile = 82
|
||||
cols = 6
|
||||
rows = math.ceil(len(REPRESENTATIVE_UNITS) / cols)
|
||||
width = 72 + cols * 150
|
||||
height = 72 + rows * 126
|
||||
image = Image.new("RGB", (width, height), (28, 36, 29))
|
||||
for x in range(0, width, tile):
|
||||
for y in range(height):
|
||||
image.putpixel((x, y), (50, 61, 48))
|
||||
for y in range(0, height, tile):
|
||||
for x in range(width):
|
||||
image.putpixel((x, y), (50, 61, 48))
|
||||
draw_label(image, "Actual tactical scale preview", 24, 18)
|
||||
for index, stem in enumerate(REPRESENTATIVE_UNITS):
|
||||
base, _ = rendered[stem]
|
||||
col = index % cols
|
||||
row = index // cols
|
||||
x = 40 + col * 150
|
||||
y = 58 + row * 126
|
||||
frame = base.crop((0, 0, FRAME_SIZE, FRAME_SIZE))
|
||||
paste_thumb(image, frame, x, y, 86)
|
||||
draw_label(image, stem.replace("unit-", ""), x, y + 88, small=True)
|
||||
save_png(image, path)
|
||||
|
||||
|
||||
def paste_thumb(sheet: Image.Image, frame: Image.Image, x: int, y: int, cell: int) -> None:
|
||||
frame = frame.copy()
|
||||
frame.thumbnail((cell - 6, cell - 6), Image.Resampling.LANCZOS)
|
||||
box = Image.new("RGB", (cell - 2, cell - 2), (43, 51, 39))
|
||||
sheet.paste(box, (x + 1, y + 1))
|
||||
px = x + (cell - frame.width) // 2
|
||||
py = y + cell - frame.height - 3
|
||||
sheet.paste(frame, (px, py), frame)
|
||||
|
||||
|
||||
def draw_label(image: Image.Image, text: str, x: int, y: int, small: bool = False) -> None:
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
draw = ImageDraw.Draw(image)
|
||||
font_size = 11 if small else 15
|
||||
font = None
|
||||
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
|
||||
try:
|
||||
font = ImageFont.truetype(candidate, font_size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
draw.text((x, y), text, fill=(223, 212, 169), font=font)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user