Rebuild battle unit sprites as raster sheets

This commit is contained in:
2026-06-29 11:12:38 +09:00
parent b05b7d45c2
commit f7670a1ed1
194 changed files with 778 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,96 @@
# Unit Sprite Raster Pipeline v1
This pass replaces the active 91 battle unit sheets with raster source art derived from the approved v1 sprite art direction.
## Inputs
- `docs/unit-sprite-quality-contact-sheet-v1.png`
- `docs/unit-sprite-quality-actual-size-v1.png`
- `docs/unit-sprite-art-sources/infantry-spearman-master.png`
- `docs/unit-sprite-art-sources/archer-strategist-master.png`
- `docs/unit-sprite-art-sources/cavalry-master.png`
- `docs/unit-sprite-art-sources/rebel-master.png`
- `docs/unit-sprite-art-sources/nanman-master.png`
- `docs/unit-sprite-art-sources/commander-master.png`
The art source sheets are hand-painted-style raster generations created for this project. They are not traced from or copied out of existing copyrighted game assets.
## Build Script
`scripts/build-raster-unit-sprites.py` packs the generated raster sources into the runtime sprite sheet format.
The script uses PIL for production processing only:
- crop source cells from the raster source sheets
- remove source-sheet backgrounds
- place sprites on a fixed transparent 313x313 canvas
- keep a shared bottom anchor
- create faction and rank color variants
- add small frame offsets for idle, move, attack, hurt, strategy, item, and celebrate timing
- palette-optimize generated PNGs to 192 colors while preserving alpha
- save sprite sheets and preview sheets
It does not draw unit bodies from procedural shapes.
## Runtime Format
The generated files keep the existing Phaser mapping intact.
- Frame size: `313x313`
- Base sheet size: `5008x1252`
- Base row order: `south`, `east`, `north`, `west`
- Base columns per direction: 8 idle frames, then 8 move frames
- Action sheet size: `11268x1252`
- Action row order: `south`, `east`, `north`, `west`
- Action columns per direction:
- attack: 10 frames
- strategy: 8 frames
- item: 8 frames
- hurt: 4 frames
- celebrate: 6 frames
## Outputs
- `src/assets/images/units/unit-*.png`
- `src/assets/images/units/unit-*-actions.png`
- `docs/unit-sprite-raster-contact-sheet-v1.png`
- `docs/unit-sprite-raster-actual-size-v1.png`
The representative contact sheet covers 12 units across commander, infantry, spearman, archer, strategist, cavalry, rebel, Wei, Wu, and Nanman styles.
## Commands
Preview only:
```powershell
python scripts\build-raster-unit-sprites.py --preview-only
```
Rebuild all active unit sheets:
```powershell
python scripts\build-raster-unit-sprites.py
```
Rebuild selected unit stems:
```powershell
python scripts\build-raster-unit-sprites.py --only unit-liu-bei,unit-zhao-yun
```
## Verification Performed
- Rebuilt all 91 active unit definitions.
- Verified every base sheet is `5008x1252`.
- Verified every action sheet is `11268x1252`.
- Reduced the generated runtime unit PNG set from roughly 452 MB to roughly 91 MB with palette PNG encoding.
- Ran `pnpm build`.
- Browser-verified the first battle and Red Cliffs vanguard battle at desktop canvas size.
- Confirmed idle frame changes within the running battle scene.
- Confirmed attack, hurt, and celebrate poses resolve to `-actions` sheets.
- Confirmed no browser console errors during the verification run.
Verification screenshots:
- `docs/unit-sprite-battle-first-battle-v1.png`
- `docs/unit-sprite-battle-redcliffs-v1.png`

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

After

Width:  |  Height:  |  Size: 1007 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 KiB

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 765 KiB

Some files were not shown because too many files have changed in this diff Show More