Files
heros_web/scripts/build-raster-unit-sprites.py

683 lines
24 KiB
Python

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()