Approve Shu infantry handpainted sprite v8

This commit is contained in:
2026-07-01 10:40:38 +09:00
parent 7b27d7aa9a
commit 199bc47192
51 changed files with 10323 additions and 1 deletions

View File

@@ -0,0 +1,608 @@
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "docs" / "handpaint-batch2-shu-infantry-quality-restart-source-v3.png"
WORK_DIR = ROOT / "tmp" / "handpaint-shu-infantry-quality-sample"
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
BATTLE_REFERENCE = DOCS_DIR / "handpaint-batch1-liu-guan-size-match-battle.png"
STEM = "unit-shu-infantry"
OUTPUT_PREFIX = "handpaint-batch2-shu-infantry-quality-restart-v3"
CONTACT_TITLE = "Shu Infantry Quality Restart Sample v3"
BEFORE_TITLE = "Shu Infantry Previous / Quality Restart Sample v3"
BEFORE_SCALE_LABEL = "Previous / v3 at battle scale"
REPORT_TITLE = "Shu Infantry Quality Restart Sample v3"
SAMPLE_LABEL = "shu sample"
COMPARE_STEMS = ["unit-liu-bei", "unit-guan-yu", "unit-zhang-fei", "unit-rebel", STEM]
COMPARE_LABELS = ["liu-bei", "guan-yu", "zhang-fei", "rebel", "shu-infantry"]
FRAME = 313
DIRECTIONS = ("south", "east", "north", "west")
BASE_FRAMES_PER_DIRECTION = 16
ACTION_COUNTS = {
"attack": 10,
"strategy": 8,
"item": 8,
"hurt": 4,
"celebrate": 6,
}
ACTION_OFFSETS = {
"attack": 0,
"strategy": 10,
"item": 18,
"hurt": 26,
"celebrate": 30,
}
ACTION_FRAMES_PER_DIRECTION = sum(ACTION_COUNTS.values())
def remove_magenta(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
rgb = arr[:, :, :3].astype(np.int16)
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
key = (r > 200) & (b > 200) & (g < 115)
near_key = (r > 145) & (b > 135) & (g < 140) & ((r + b - g * 2) > 145)
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
fringe = (alpha > 0) & (r > 150) & (b > 150) & (g < 140)
arr[:, :, 0] = np.where(fringe, np.minimum(arr[:, :, 0], 90), arr[:, :, 0])
arr[:, :, 2] = np.where(fringe, np.minimum(arr[:, :, 2], 90), arr[:, :, 2])
arr[:, :, 3] = alpha
return Image.fromarray(arr, "RGBA")
def keep_useful_components(image: Image.Image, allow_detached_effects = False) -> Image.Image:
arr = np.array(image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
height, width = alpha.shape
visited = np.zeros_like(alpha, dtype=bool)
components: list[tuple[int, int, int, int, int, float, float]] = []
for start_y in range(height):
for start_x in range(width):
if visited[start_y, start_x] or not alpha[start_y, start_x]:
continue
stack = [(start_x, start_y)]
visited[start_y, start_x] = True
xs: list[int] = []
ys: list[int] = []
while stack:
x, y = stack.pop()
xs.append(x)
ys.append(y)
for ny in (y - 1, y, y + 1):
if ny < 0 or ny >= height:
continue
for nx in (x - 1, x, x + 1):
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
continue
visited[ny, nx] = True
stack.append((nx, ny))
area = len(xs)
if area < 24:
continue
left, right = min(xs), max(xs)
top, bottom = min(ys), max(ys)
components.append((area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2))
if not components:
return image
anchor = max(components, key=lambda item: item[0])
anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor
expanded = (left - 95, top - 90, right + 95, bottom + 82)
keep = np.zeros_like(alpha, dtype=bool)
for component in components:
area, c_left, c_top, c_right, c_bottom, cx, cy = component
near_anchor = c_right >= expanded[0] and c_left <= expanded[2] and c_bottom >= expanded[1] and c_top <= expanded[3]
big_effect = area >= max(550, anchor_area * 0.07)
useful = component == anchor or (near_anchor and area >= 55) or (allow_detached_effects and big_effect)
if useful:
keep[c_top : c_bottom + 1, c_left : c_right + 1] |= alpha[c_top : c_bottom + 1, c_left : c_right + 1]
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def keep_largest_component(image: Image.Image) -> Image.Image:
arr = np.array(image.convert("RGBA"))
alpha = arr[:, :, 3] > 0
height, width = alpha.shape
visited = np.zeros_like(alpha, dtype=bool)
best_mask: np.ndarray | None = None
best_area = 0
for start_y in range(height):
for start_x in range(width):
if visited[start_y, start_x] or not alpha[start_y, start_x]:
continue
stack = [(start_x, start_y)]
visited[start_y, start_x] = True
mask = np.zeros_like(alpha, dtype=bool)
area = 0
while stack:
x, y = stack.pop()
mask[y, x] = True
area += 1
for ny in (y - 1, y, y + 1):
if ny < 0 or ny >= height:
continue
for nx in (x - 1, x, x + 1):
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
continue
visited[ny, nx] = True
stack.append((nx, ny))
if area > best_area:
best_area = area
best_mask = mask
if best_mask is None:
return image
arr[:, :, 3] = np.where(best_mask, arr[:, :, 3], 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def crop_cell(source: Image.Image, row: int, col: int) -> Image.Image:
width, height = source.size
left = round(col * width / 6)
right = round((col + 1) * width / 6)
top = round(row * height / 2)
bottom = round((row + 1) * height / 2)
pad_x = 0
pad_y = 6
return source.crop((max(0, left - pad_x), max(0, top - pad_y), min(width, right + pad_x), min(height, bottom + pad_y)))
def crop_subject(image: Image.Image, allow_detached_effects = False) -> Image.Image:
rgba = keep_useful_components(remove_magenta(image), allow_detached_effects)
return trim_to_alpha(rgba, pad=7)
def trim_to_alpha(image: Image.Image, pad: int = 0) -> Image.Image:
rgba = image.convert("RGBA")
alpha = np.array(rgba.getchannel("A"))
ys, xs = np.where(alpha > 0)
if len(xs) == 0:
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
left = max(int(xs.min()) - pad, 0)
top = max(int(ys.min()) - pad, 0)
right = min(int(xs.max()) + pad + 1, rgba.width)
bottom = min(int(ys.max()) + pad + 1, rgba.height)
return solidify_alpha(rgba.crop((left, top, right, bottom)))
def solidify_alpha(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
arr = np.array(rgba)
arr[:, :, 3] = np.where(arr[:, :, 3] > 20, 255, 0).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def fit_frame(source: Image.Image, max_width: int, max_height: int, bottom: int = 304, x_offset: int = 0, allow_detached_effects = False) -> Image.Image:
subject = crop_subject(source, allow_detached_effects)
if subject.width <= 1 or subject.height <= 1:
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
scale = min(max_width / subject.width, max_height / subject.height)
size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale)))
subject = solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS))
subject = trim_to_alpha(subject)
if not allow_detached_effects:
subject = keep_largest_component(subject)
subject = trim_to_alpha(subject)
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
x = (FRAME - subject.width) // 2 + x_offset
y = bottom - subject.height
frame.alpha_composite(subject, (x, y))
return solidify_alpha(frame)
def mirror(frame: Image.Image) -> Image.Image:
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def load_pose_frames() -> dict[str, Image.Image]:
source = Image.open(SOURCE).convert("RGB")
cells = {(row, col): crop_cell(source, row, col) for row in range(2) for col in range(6)}
return {
"idle_front": fit_frame(cells[(0, 0)], 252, 300),
"walk_front": fit_frame(cells[(0, 1)], 252, 300),
"walk_side": fit_frame(cells[(0, 2)], 266, 300),
"walk_back": fit_frame(cells[(0, 3)], 254, 300),
"ready_side": fit_frame(cells[(0, 4)], 266, 300),
"attack_ready": fit_frame(cells[(0, 5)], 284, 294),
"attack_slash": fit_frame(cells[(1, 0)], 300, 294, allow_detached_effects=True),
"attack_thrust": fit_frame(cells[(1, 1)], 292, 294),
"command": keep_largest_component(fit_frame(cells[(1, 2)], 264, 300)),
"hurt": fit_frame(cells[(1, 3)], 252, 294),
"victory": keep_largest_component(fit_frame(cells[(1, 4)], 264, 300)),
"walk_side_alt": fit_frame(cells[(1, 5)], 266, 300),
}
def build_base_sheet(poses: dict[str, Image.Image]) -> Image.Image:
rows = {
"south": {
"idle": [poses["idle_front"]] * 8,
"walk": [poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"]],
},
"east": {
"idle": [poses["ready_side"]] * 8,
"walk": [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]],
},
"north": {
"idle": [poses["walk_back"]] * 8,
"walk": [poses["walk_back"]] * 8,
},
"west": {
"idle": [mirror(poses["ready_side"])] * 8,
"walk": [mirror(frame) for frame in [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]]],
},
}
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, direction in enumerate(DIRECTIONS):
frames = rows[direction]["idle"] + rows[direction]["walk"]
for col, frame in enumerate(frames):
sheet.alpha_composite(solidify_alpha(frame), (col * FRAME, row * FRAME))
return solidify_alpha(sheet)
def directed(frame: Image.Image, direction: str) -> Image.Image:
if direction == "west":
return mirror(frame)
return frame
def build_action_sheet(poses: dict[str, Image.Image]) -> Image.Image:
attack = [
poses["attack_ready"],
poses["attack_ready"],
poses["attack_thrust"],
poses["attack_slash"],
poses["attack_slash"],
poses["attack_thrust"],
poses["ready_side"],
poses["walk_side_alt"],
poses["ready_side"],
poses["ready_side"],
]
strategy = [poses["command"], poses["command"], poses["victory"], poses["command"], poses["command"], poses["victory"], poses["command"], poses["idle_front"]]
item = [poses["idle_front"], poses["command"], poses["victory"], poses["command"], poses["idle_front"], poses["command"], poses["victory"], poses["idle_front"]]
hurt = [poses["hurt"]] * 4
celebrate = [poses["victory"], poses["command"], poses["victory"], poses["command"], poses["victory"], poses["idle_front"]]
groups = {
"attack": attack,
"strategy": strategy,
"item": item,
"hurt": hurt,
"celebrate": celebrate,
}
sheet = Image.new("RGBA", (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
for row, direction in enumerate(DIRECTIONS):
for action, frames in groups.items():
offset = ACTION_OFFSETS[action]
for index, frame in enumerate(frames):
action_frame = directed(frame, direction)
if direction == "south" and action == "attack":
action_frame = frame
if direction == "north" and action in {"attack", "strategy", "item", "celebrate"}:
action_frame = poses["walk_back"] if action == "attack" else poses["command"]
sheet.alpha_composite(solidify_alpha(action_frame), ((offset + index) * FRAME, row * FRAME))
return solidify_alpha(sheet)
def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image:
image = Image.new("RGBA", size, (64, 84, 53, 255))
draw = ImageDraw.Draw(image)
for y in range(0, size[1], tile):
for x in range(0, size[0], tile):
color = (75, 97, 60, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (55, 72, 47, 255)
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
return image
def font(size: int = 12):
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
bg = checker_background((size, size), 8)
scaled = solidify_alpha(frame).resize((size, size), Image.Resampling.LANCZOS)
bg.alpha_composite(scaled, (0, 0))
return bg
def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image:
return sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME))
def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
picks = [
("idle", crop_frame(base_sheet, 0, 0)),
("walk S", crop_frame(base_sheet, 0, 8)),
("walk E", crop_frame(base_sheet, 1, 8)),
("walk N", crop_frame(base_sheet, 2, 8)),
("walk W", crop_frame(base_sheet, 3, 8)),
("atk 1", crop_frame(action_sheet, 1, 0)),
("atk 4", crop_frame(action_sheet, 1, 3)),
("atk 7", crop_frame(action_sheet, 1, 6)),
("cmd", crop_frame(action_sheet, 0, ACTION_OFFSETS["strategy"] + 2)),
("item", crop_frame(action_sheet, 0, ACTION_OFFSETS["item"] + 1)),
("hurt", crop_frame(action_sheet, 0, ACTION_OFFSETS["hurt"])),
("win", crop_frame(action_sheet, 0, ACTION_OFFSETS["celebrate"] + 1)),
]
thumb = 132
pad = 18
label_h = 24
cols = 6
rows = 2
out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad) + 18), (20, 24, 22, 255))
draw = ImageDraw.Draw(out)
draw.text((pad, 5), CONTACT_TITLE, fill=(240, 226, 178, 255), font=font(14))
for index, (label, frame) in enumerate(picks):
x = pad + (index % cols) * (thumb + pad)
y = 24 + pad + (index // cols) * (thumb + label_h + pad)
out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y))
draw.text((x, y + thumb + 5), label, fill=(238, 224, 178, 255), font=font(11))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-contact.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_before_after(base_sheet: Image.Image) -> Path:
before = Image.open(WORK_DIR / f"before-{STEM}.png").convert("RGBA").crop((0, 0, FRAME, FRAME))
after = crop_frame(base_sheet, 0, 0)
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
draw = ImageDraw.Draw(out)
draw.text((30, 18), BEFORE_TITLE, fill=(238, 224, 178, 255), font=font(14))
out.alpha_composite(draw_frame_on_bg(before, 160), (30, 48))
out.alpha_composite(draw_frame_on_bg(after, 160), (220, 48))
draw.text((30, 218), BEFORE_SCALE_LABEL, fill=(238, 224, 178, 255), font=font(12))
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-before-after.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_animation_gif(base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
frames: list[Image.Image] = []
for index in range(10):
canvas = checker_background((540, 190), 10)
idle = crop_frame(base_sheet, 0, index % 8)
walk = crop_frame(base_sheet, 1, 8 + index % 8)
attack = crop_frame(action_sheet, 1, index)
for x, frame in ((22, idle), (190, walk), (358, attack)):
canvas.alpha_composite(frame.resize((150, 150), Image.Resampling.LANCZOS), (x, 25))
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=160))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-animation.gif"
frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=120, loop=0)
return path
def save_scale_compare() -> Path:
stems = COMPARE_STEMS
labels = COMPARE_LABELS
tile = 50
zoom = 72
gap = 22
width = 28 + len(stems) * (zoom + gap)
height = 210
out = Image.new("RGBA", (width, height), (19, 25, 21, 255))
draw = ImageDraw.Draw(out)
draw.text((18, 10), "Battle-scale readability compare", fill=(240, 226, 178, 255), font=font(14))
for index, stem in enumerate(stems):
sheet = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA")
frame = crop_frame(sheet, 0, 0)
x = 18 + index * (zoom + gap)
out.alpha_composite(draw_frame_on_bg(frame, zoom), (x, 38))
out.alpha_composite(draw_frame_on_bg(frame, tile), (x + 11, 122))
draw.text((x, 180), labels[index], fill=(226, 218, 175, 255), font=font(10))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-scale-compare.png"
out.convert("RGB").save(path, optimize=True)
return path
def save_battle_composite(base_sheet: Image.Image) -> Path:
battle = Image.open(BATTLE_REFERENCE).convert("RGBA")
frame = crop_frame(base_sheet, 0, 0)
draw = ImageDraw.Draw(battle)
display = 72
small = 50
# Empty-ish field cells near the approved ally sprites. This is a review
# composite only; the gameplay roster is left untouched during sample review.
battle.alpha_composite(solidify_alpha(frame).resize((display, display), Image.Resampling.LANCZOS), (207, 535))
draw.text((207, 612), SAMPLE_LABEL, fill=(255, 248, 218, 255), font=font(12), stroke_width=2, stroke_fill=(20, 18, 16, 230))
panel_x, panel_y = 936, 542
draw.rounded_rectangle((panel_x, panel_y, panel_x + 205, panel_y + 116), radius=4, fill=(17, 23, 31, 235), outline=(214, 181, 99, 220), width=2)
draw.text((panel_x + 14, panel_y + 12), "Composite scale check", fill=(240, 226, 178, 255), font=font(13))
draw.text((panel_x + 14, panel_y + 32), "not gameplay", fill=(198, 204, 200, 255), font=font(11))
battle.alpha_composite(draw_frame_on_bg(frame, display), (panel_x + 18, panel_y + 56))
battle.alpha_composite(draw_frame_on_bg(frame, small), (panel_x + 114, panel_y + 67))
draw.text((panel_x + 20, panel_y + 132), "72px", fill=(238, 224, 178, 255), font=font(10))
draw.text((panel_x + 117, panel_y + 132), "50px", fill=(238, 224, 178, 255), font=font(10))
path = DOCS_DIR / f"{OUTPUT_PREFIX}-battle-composite.png"
battle.convert("RGB").save(path, optimize=True)
return path
def validate(path: Path, expected: tuple[int, int], rows: int, cols: int) -> dict[str, int | str]:
image = Image.open(path).convert("RGBA")
if image.size != expected:
raise ValueError(f"{path}: expected {expected}, got {image.size}")
alpha = np.array(image.getchannel("A"))
partial = int(np.count_nonzero((alpha > 0) & (alpha < 255)))
opaque = int(np.count_nonzero(alpha == 255))
if partial:
raise ValueError(f"{path.name}: partial alpha {partial}")
widths: list[int] = []
heights: list[int] = []
bottoms: list[int] = []
border_hits = 0
empty_frames = 0
for row in range(rows):
for col in range(cols):
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
ys, xs = np.where(frame > 0)
if len(xs) == 0:
empty_frames += 1
continue
widths.append(int(xs.max() - xs.min() + 1))
heights.append(int(ys.max() - ys.min() + 1))
bottoms.append(int(ys.max()))
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
border_hits += 1
return {
"partial_alpha": partial,
"opaque_pixels": opaque,
"border_hits": border_hits,
"empty_frames": empty_frames,
"min_width": min(widths) if widths else 0,
"max_width": max(widths) if widths else 0,
"min_height": min(heights) if heights else 0,
"max_height": max(heights) if heights else 0,
"min_bottom": min(bottoms) if bottoms else 0,
"max_bottom": max(bottoms) if bottoms else 0,
"status": "ok" if border_hits == 0 and empty_frames == 0 else "needs-review",
}
def copy_before_once() -> None:
WORK_DIR.mkdir(parents=True, exist_ok=True)
for suffix in ("", "-actions"):
source = UNIT_DIR / f"{STEM}{suffix}.png"
target = WORK_DIR / f"before-{STEM}{suffix}.png"
if not target.exists():
target.write_bytes(source.read_bytes())
def project_path(value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else ROOT / path
def split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Assemble a 6x2 hand-painted source into 313px unit sprite sheets.")
parser.add_argument("--source", default=str(SOURCE.relative_to(ROOT)))
parser.add_argument("--stem", default=STEM)
parser.add_argument("--work-dir", default=str(WORK_DIR.relative_to(ROOT)))
parser.add_argument("--output-prefix", default=OUTPUT_PREFIX)
parser.add_argument("--battle-reference", default=str(BATTLE_REFERENCE.relative_to(ROOT)))
parser.add_argument("--contact-title", default=CONTACT_TITLE)
parser.add_argument("--before-title", default=BEFORE_TITLE)
parser.add_argument("--before-scale-label", default=BEFORE_SCALE_LABEL)
parser.add_argument("--report-title", default=REPORT_TITLE)
parser.add_argument("--sample-label", default=SAMPLE_LABEL)
parser.add_argument("--compare-stems", default=",".join(COMPARE_STEMS))
parser.add_argument("--compare-labels", default=",".join(COMPARE_LABELS))
return parser.parse_args()
def configure(args: argparse.Namespace) -> None:
global SOURCE, WORK_DIR, BATTLE_REFERENCE, STEM, OUTPUT_PREFIX
global CONTACT_TITLE, BEFORE_TITLE, BEFORE_SCALE_LABEL, REPORT_TITLE, SAMPLE_LABEL
global COMPARE_STEMS, COMPARE_LABELS
SOURCE = project_path(args.source)
WORK_DIR = project_path(args.work_dir)
BATTLE_REFERENCE = project_path(args.battle_reference)
STEM = args.stem
OUTPUT_PREFIX = args.output_prefix
CONTACT_TITLE = args.contact_title
BEFORE_TITLE = args.before_title
BEFORE_SCALE_LABEL = args.before_scale_label
REPORT_TITLE = args.report_title
SAMPLE_LABEL = args.sample_label
COMPARE_STEMS = split_csv(args.compare_stems)
COMPARE_LABELS = split_csv(args.compare_labels)
if len(COMPARE_STEMS) != len(COMPARE_LABELS):
raise ValueError("--compare-stems and --compare-labels must have the same number of entries")
def main() -> None:
configure(parse_args())
copy_before_once()
poses = load_pose_frames()
base_sheet = build_base_sheet(poses)
action_sheet = build_action_sheet(poses)
base_path = UNIT_DIR / f"{STEM}.png"
action_path = UNIT_DIR / f"{STEM}-actions.png"
base_sheet.save(base_path, optimize=True)
action_sheet.save(action_path, optimize=True)
contact = save_contact_sheet(base_sheet, action_sheet)
before_after = save_before_after(base_sheet)
animation = save_animation_gif(base_sheet, action_sheet)
scale_compare = save_scale_compare()
battle_composite = save_battle_composite(base_sheet)
base_result = validate(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), BASE_FRAMES_PER_DIRECTION)
action_result = validate(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), ACTION_FRAMES_PER_DIRECTION)
report = DOCS_DIR / f"{OUTPUT_PREFIX}-report.md"
report.write_text(
"\n".join(
[
f"# {REPORT_TITLE}",
"",
f"- source: `{SOURCE.name}`",
f"- base status: {base_result['status']}",
f"- action status: {action_result['status']}",
f"- base partial alpha: {base_result['partial_alpha']}, opaque pixels: {base_result['opaque_pixels']}",
f"- action partial alpha: {action_result['partial_alpha']}, opaque pixels: {action_result['opaque_pixels']}",
f"- base frame border hits: {base_result['border_hits']}",
f"- action frame border hits: {action_result['border_hits']}",
f"- base empty frames: {base_result['empty_frames']}",
f"- action empty frames: {action_result['empty_frames']}",
f"- base bbox width range: {base_result['min_width']}..{base_result['max_width']}",
f"- base bbox height range: {base_result['min_height']}..{base_result['max_height']}",
f"- base bottom range: {base_result['min_bottom']}..{base_result['max_bottom']}",
f"- action bbox width range: {action_result['min_width']}..{action_result['max_width']}",
f"- action bbox height range: {action_result['min_height']}..{action_result['max_height']}",
f"- action bottom range: {action_result['min_bottom']}..{action_result['max_bottom']}",
f"- contact: `{contact.name}`",
f"- animation: `{animation.name}`",
f"- before/after: `{before_after.name}`",
f"- scale compare: `{scale_compare.name}`",
f"- battle composite: `{battle_composite.name}`",
"- manual checks: run build and local browser verification after generation",
"- note: battle composite is a scale review image; gameplay roster is unchanged during sample approval",
"",
]
),
encoding="utf-8",
)
print(f"Wrote {base_path}")
print(f"Wrote {action_path}")
print(f"Wrote {contact}")
print(f"Wrote {animation}")
print(f"Wrote {before_after}")
print(f"Wrote {scale_compare}")
print(f"Wrote {battle_composite}")
print(f"Wrote {report}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
FRAME = 313
BASE_ROWS = 4
BASE_COLS = 16
ACTION_ROWS = 4
ACTION_COLS = 36
BATTLE_TILE_PX = 50
DEFAULT_MANIFEST = {
"approvedBaseline": [
"unit-liu-bei",
"unit-guan-yu",
"unit-zhang-fei",
"unit-rebel",
"unit-rebel-archer",
"unit-rebel-cavalry",
],
"pendingApproval": ["unit-shu-infantry"],
}
@dataclass
class SheetMetrics:
key: str
exists: bool
size: tuple[int, int] | None = None
partial_alpha: int | None = None
opaque_pixels: int | None = None
border_hits: int | None = None
min_width: int | None = None
max_width: int | None = None
min_height: int | None = None
max_height: int | None = None
min_bottom: int | None = None
max_bottom: int | None = None
empty_frames: int | None = None
width_swing: int | None = None
height_swing: int | None = None
bottom_swing: int | None = None
battle_min_width_px: float | None = None
battle_max_width_px: float | None = None
battle_min_height_px: float | None = None
battle_max_height_px: float | None = None
status: str = "missing"
def measure_sheet(path: Path, key: str, rows: int, cols: int, check_motion_stability = False) -> SheetMetrics:
if not path.exists():
return SheetMetrics(key=key, exists=False)
image = Image.open(path).convert("RGBA")
alpha = np.array(image.getchannel("A"))
metrics = SheetMetrics(
key=key,
exists=True,
size=image.size,
partial_alpha=int(np.count_nonzero((alpha > 0) & (alpha < 255))),
opaque_pixels=int(np.count_nonzero(alpha == 255)),
border_hits=0,
empty_frames=0,
status="ok",
)
expected_size = (FRAME * cols, FRAME * rows)
widths: list[int] = []
heights: list[int] = []
bottoms: list[int] = []
if image.size != expected_size:
metrics.status = f"bad-size expected={expected_size}"
return metrics
for row in range(rows):
for col in range(cols):
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
ys, xs = np.where(frame > 0)
if len(xs) == 0:
metrics.empty_frames = (metrics.empty_frames or 0) + 1
continue
widths.append(int(xs.max() - xs.min() + 1))
heights.append(int(ys.max() - ys.min() + 1))
bottoms.append(int(ys.max()))
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
metrics.border_hits = (metrics.border_hits or 0) + 1
if widths:
metrics.min_width = min(widths)
metrics.max_width = max(widths)
metrics.min_height = min(heights)
metrics.max_height = max(heights)
metrics.min_bottom = min(bottoms)
metrics.max_bottom = max(bottoms)
metrics.width_swing = metrics.max_width - metrics.min_width
metrics.height_swing = metrics.max_height - metrics.min_height
metrics.bottom_swing = metrics.max_bottom - metrics.min_bottom
metrics.battle_min_width_px = round(metrics.min_width * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_max_width_px = round(metrics.max_width * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_min_height_px = round(metrics.min_height * BATTLE_TILE_PX / FRAME, 1)
metrics.battle_max_height_px = round(metrics.max_height * BATTLE_TILE_PX / FRAME, 1)
issues = []
if metrics.partial_alpha:
issues.append(f"partial-alpha={metrics.partial_alpha}")
if metrics.border_hits:
issues.append(f"border-hits={metrics.border_hits}")
if metrics.empty_frames:
issues.append(f"empty-frames={metrics.empty_frames}")
if (
check_motion_stability
and metrics.max_height is not None
and metrics.min_height is not None
and metrics.max_height - metrics.min_height > 48
):
issues.append(f"height-swing={metrics.max_height - metrics.min_height}")
if (
check_motion_stability
and metrics.max_bottom is not None
and metrics.min_bottom is not None
and metrics.max_bottom - metrics.min_bottom > 36
):
issues.append(f"bottom-swing={metrics.max_bottom - metrics.min_bottom}")
metrics.status = "ok" if not issues else "; ".join(issues)
return metrics
def classify(stem: str) -> str:
if stem in {"unit-liu-bei", "unit-guan-yu", "unit-zhang-fei"}:
return "batch1-approved-core"
if stem in {"unit-rebel", "unit-rebel-archer", "unit-rebel-cavalry"}:
return "batch1-approved-rebel"
if stem.startswith("unit-shu-"):
return "batch2-shu"
if stem.startswith("unit-wei-"):
return "batch3-wei"
if stem.startswith("unit-wu-"):
return "batch4-wu"
if stem.startswith("unit-rebel-"):
return "batch5-rebel-other"
if stem.startswith("unit-nanman-") or stem == "unit-meng-huo":
return "batch6-nanman-special"
return "batch7-unique-officer"
def unit_stems() -> list[str]:
return sorted(path.stem for path in UNIT_DIR.glob("unit-*.png") if not path.stem.endswith("-actions"))
def load_manifest(path: Path = MANIFEST_PATH) -> dict:
if not path.exists():
return DEFAULT_MANIFEST
manifest = json.loads(path.read_text(encoding="utf-8"))
return {
**DEFAULT_MANIFEST,
**manifest,
}
def review_stage(stem: str, manifest: dict) -> str:
if stem in set(manifest.get("approvedBaseline", [])):
return "approved-baseline"
if stem in set(manifest.get("pendingApproval", [])):
return "pending-approval"
return "queued-rework"
def audit() -> tuple[list[dict], dict[str, int], dict[str, int]]:
rows: list[dict] = []
counts: dict[str, int] = defaultdict(int)
stage_counts: dict[str, int] = defaultdict(int)
manifest = load_manifest()
for stem in unit_stems():
base = measure_sheet(UNIT_DIR / f"{stem}.png", stem, BASE_ROWS, BASE_COLS, check_motion_stability=True)
actions = measure_sheet(UNIT_DIR / f"{stem}-actions.png", f"{stem}-actions", ACTION_ROWS, ACTION_COLS)
batch = classify(stem)
stage = review_stage(stem, manifest)
counts[batch] += 1
stage_counts[stage] += 1
rows.append(
{
"batch": batch,
"review_stage": stage,
"stem": stem,
"base": asdict(base),
"actions": asdict(actions),
}
)
return rows, dict(sorted(counts.items())), dict(sorted(stage_counts.items()))
def write_markdown(rows: list[dict], counts: dict[str, int], stage_counts: dict[str, int], path: Path) -> None:
lines = [
"# Unit Sprite Rework Audit",
"",
"This file is generated by `scripts/audit-unit-sprite-sheets.py`.",
"",
f"Review stages are loaded from `{MANIFEST_PATH.relative_to(ROOT).as_posix()}`.",
"",
"Visual review boards are generated by `scripts/render-unit-sprite-review-boards.py`.",
"",
"- Review index: `unit-sprite-review-index.png`",
"- Batch 1 core: `unit-sprite-review-batch1-approved-core.png`",
"- Batch 1 rebel: `unit-sprite-review-batch1-approved-rebel.png`",
"- Batch 2 Shu: `unit-sprite-review-batch2-shu.png`",
"- Batch 3 Wei: `unit-sprite-review-batch3-wei.png`",
"- Batch 4 Wu: `unit-sprite-review-batch4-wu.png`",
"- Batch 5 rebel other: `unit-sprite-review-batch5-rebel-other.png`",
"- Batch 6 Nanman/special: `unit-sprite-review-batch6-nanman-special.png`",
"- Batch 7 unique officer: `unit-sprite-review-batch7-unique-officer.png`",
"",
"## Batch Counts",
"",
]
for batch, count in counts.items():
lines.append(f"- {batch}: {count}")
lines.extend(["", "## Review Stage Counts", ""])
for stage, count in stage_counts.items():
lines.append(f"- {stage}: {count}")
lines.extend(
[
"",
"## Batch Plan",
"",
"- batch1-approved-core/rebel: already approved baseline, use as quality reference.",
"- batch2-shu: Shu generic infantry/spearman/archer/cavalry/strategist/officer variants.",
"- batch3-wei: Wei generic infantry/archer/cavalry/strategist/officer variants.",
"- batch4-wu: Wu generic infantry/archer/cavalry/strategist/officer variants.",
"- batch5-rebel-other: remaining rebel/bandit/yellow-turban variants outside approved batch1.",
"- batch6-nanman-special: Nanman and special tribal unit variants.",
"- batch7-unique-officer: unique officer sheets not covered by earlier batches.",
"",
"## Review Stage",
"",
"- `approved-baseline`: approved hand-painted reference units.",
"- `pending-approval`: technically prepared, waiting for user approval before expansion/commit/deploy.",
"- `queued-rework`: listed for later hand-painted rework; technical `ok` does not mean visually approved.",
"",
"## Sheet Status",
"",
"| batch | stage | stem | base | actions | base bbox | action bbox | battle base px | motion swing |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for row in rows:
base = row["base"]
actions = row["actions"]
base_bbox = f'{base.get("min_width")}..{base.get("max_width")} x {base.get("min_height")}..{base.get("max_height")}'
action_bbox = f'{actions.get("min_width")}..{actions.get("max_width")} x {actions.get("min_height")}..{actions.get("max_height")}'
battle_base = (
f'{base.get("battle_min_width_px")}..{base.get("battle_max_width_px")} x '
f'{base.get("battle_min_height_px")}..{base.get("battle_max_height_px")}'
)
motion = (
f'w{base.get("width_swing")} '
f'h{base.get("height_swing")} '
f'b{base.get("bottom_swing")}'
)
lines.append(
f'| {row["batch"]} | {row["review_stage"]} | `{row["stem"]}` | {base["status"]} | {actions["status"]} | {base_bbox} | {action_bbox} | {battle_base} | {motion} |'
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Audit 313px unit sprite sheets.")
parser.add_argument("--json", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.json")
parser.add_argument("--markdown", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.md")
args = parser.parse_args()
rows, counts, stage_counts = audit()
args.json.write_text(
json.dumps(
{
"manifest": MANIFEST_PATH.relative_to(ROOT).as_posix(),
"counts": counts,
"stage_counts": stage_counts,
"rows": rows,
},
indent=2,
),
encoding="utf-8",
)
write_markdown(rows, counts, stage_counts, args.markdown)
print(f"Wrote {args.json}")
print(f"Wrote {args.markdown}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
def load_json(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def sorted_unique(values: list[str]) -> list[str]:
return sorted(dict.fromkeys(values))
def row_by_stem(audit: dict) -> dict[str, dict]:
return {row["stem"]: row for row in audit.get("rows", [])}
def validate_manifest(manifest: dict, audit: dict) -> list[str]:
issues: list[str] = []
rows = row_by_stem(audit)
approved = sorted_unique(manifest.get("approvedBaseline", []))
pending = sorted_unique(manifest.get("pendingApproval", []))
next_after = sorted_unique(manifest.get("nextAfterApproval", []))
overlap = sorted(set(approved) & set(pending))
if overlap:
issues.append(f"units cannot be both approved and pending: {', '.join(overlap)}")
for stem in approved + pending + next_after:
if stem not in rows:
issues.append(f"manifest references unknown unit sheet: {stem}")
for stem in approved:
row = rows.get(stem)
if not row:
continue
if row["base"]["status"] != "ok" or row["actions"]["status"] != "ok":
issues.append(f"approved unit has technical issue: {stem}")
if row["review_stage"] != "approved-baseline":
issues.append(f"approved unit has wrong review stage in audit: {stem} -> {row['review_stage']}")
for stem in pending:
row = rows.get(stem)
if not row:
continue
if row["base"]["status"] != "ok" or row["actions"]["status"] != "ok":
issues.append(f"pending sample has technical issue: {stem}")
if row["review_stage"] != "pending-approval":
issues.append(f"pending sample has wrong review stage in audit: {stem} -> {row['review_stage']}")
return issues
def release_blockers(manifest: dict, audit: dict) -> list[str]:
blockers: list[str] = []
pending = sorted_unique(manifest.get("pendingApproval", []))
if pending and manifest.get("approvalPolicy", {}).get("holdCommitPushDeployUntilBatchApproved", True):
blockers.append(f"pending approval units block commit/push/deploy: {', '.join(pending)}")
stage_counts = audit.get("stage_counts", {})
if stage_counts.get("pending-approval", 0) != len(pending):
blockers.append(
"audit pending-approval count does not match manifest "
f"({stage_counts.get('pending-approval', 0)} != {len(pending)})"
)
return blockers
def sample_summary(manifest: dict, audit: dict) -> list[str]:
rows = row_by_stem(audit)
pending = sorted_unique(manifest.get("pendingApproval", []))
lines = [
"Unit sprite sample gate: OK",
f"approved baseline: {len(manifest.get('approvedBaseline', []))}",
f"pending approval: {len(pending)}",
f"queued rework: {audit.get('stage_counts', {}).get('queued-rework', 0)}",
]
for stem in pending:
row = rows.get(stem)
if row:
base = row["base"]
lines.append(
f"{stem}: {row['review_stage']}, base={base['status']}, "
f"battle={base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x "
f"{base.get('battle_min_height_px')}..{base.get('battle_max_height_px')}"
)
return lines
def main() -> int:
parser = argparse.ArgumentParser(description="Check sprite rework approval gates.")
parser.add_argument(
"--mode",
choices=("sample", "release"),
default="sample",
help="sample allows pending approval; release fails while pending approval remains.",
)
args = parser.parse_args()
try:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
except Exception as exc:
print(f"Unit sprite approval gate: FAILED\n- {exc}")
return 1
issues = validate_manifest(manifest, audit)
if issues:
print("Unit sprite approval gate: FAILED")
for issue in issues:
print(f"- {issue}")
return 1
if args.mode == "release":
blockers = release_blockers(manifest, audit)
if blockers:
print("Unit sprite release gate: BLOCKED")
for blocker in blockers:
print(f"- {blocker}")
return 2
print("Unit sprite release gate: OK")
return 0
print("\n".join(sample_summary(manifest, audit)))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
DEFAULT_REPORT = DOCS_DIR / "unit-sprite-art-quality-gate.md"
REQUIRED_SUFFIXES = [
"contact.png",
"animation.gif",
"before-after.png",
"scale-compare.png",
"battle-composite.png",
"debug-preview-battle-check.png",
]
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def row_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {row["stem"]: row for row in audit.get("rows", [])}
def rel(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def infer_prefix(candidate: dict[str, Any]) -> str:
artifact_prefix = str(candidate.get("artifactPrefix", "")).strip()
if artifact_prefix:
return artifact_prefix
source = Path(candidate.get("source", "")).name
version = str(candidate.get("version", "")).strip()
if source and version and source.endswith(f"-source-{version}.png"):
return source.removesuffix(f"-source-{version}.png") + f"-{version}"
if source.endswith(".png"):
return source.removesuffix(".png")
raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source")
def pending_artifacts(candidate: dict[str, Any]) -> dict[str, Path]:
prefix = infer_prefix(candidate)
artifacts = {
"source": ROOT / candidate["source"],
"approval packet": ROOT / candidate["approvalPacket"],
"current approval board": DOCS_DIR / "unit-sprite-current-approval-board.png",
}
for suffix in REQUIRED_SUFFIXES:
artifacts[suffix] = DOCS_DIR / f"{prefix}-{suffix}"
return artifacts
def metric_issue(stem: str, label: str, value: float | int | None, op: str, limit: float) -> str | None:
if value is None:
return f"{stem}: missing metric `{label}`"
numeric = float(value)
passed = numeric >= limit if op == ">=" else numeric <= limit
if passed:
return None
return f"{stem}: `{label}` is {numeric:g}, expected {op} {limit:g}"
def validate_row(stem: str, row: dict[str, Any]) -> list[str]:
issues: list[str] = []
base = row["base"]
actions = row["actions"]
for key, sheet in (("base", base), ("actions", actions)):
if sheet["status"] != "ok":
issues.append(f"{stem}: {key} sheet status is `{sheet['status']}`")
for field in ("partial_alpha", "border_hits", "empty_frames"):
if sheet.get(field) not in (0, None):
issues.append(f"{stem}: {key} `{field}` is {sheet.get(field)}, expected 0")
thresholds = [
("base battle_min_width_px", base.get("battle_min_width_px"), ">=", 22.0),
("base battle_max_width_px", base.get("battle_max_width_px"), ">=", 32.0),
("base battle_min_height_px", base.get("battle_min_height_px"), ">=", 43.0),
("base battle_max_height_px", base.get("battle_max_height_px"), "<=", 48.0),
("base height_swing", base.get("height_swing"), "<=", 18.0),
("base bottom_swing", base.get("bottom_swing"), "<=", 16.0),
("action battle_max_width_px", actions.get("battle_max_width_px"), ">=", 38.0),
("action battle_max_height_px", actions.get("battle_max_height_px"), ">=", 40.0),
("action battle_max_height_px", actions.get("battle_max_height_px"), "<=", 48.0),
("action bottom_swing", actions.get("bottom_swing"), "<=", 16.0),
]
for label, value, op, limit in thresholds:
issue = metric_issue(stem, label, value, op, limit)
if issue:
issues.append(issue)
return issues
def validate_candidate(stem: str, candidate: dict[str, Any], row: dict[str, Any]) -> tuple[list[str], list[str]]:
issues = validate_row(stem, row)
evidence: list[str] = []
artifacts = pending_artifacts(candidate)
for label, path in artifacts.items():
if not path.exists():
issues.append(f"{stem}: missing {label}: {rel(path)}")
else:
evidence.append(f"- {label}: `{rel(path)}`")
packet_path = artifacts.get("approval packet")
if packet_path and packet_path.exists():
packet = packet_path.read_text(encoding="utf-8")
required_text = [
"unit-sprite-current-approval-board.png",
"Local browser console errors: `0`",
f"debugSpritePreview={stem}",
]
for text in required_text:
if text not in packet:
issues.append(f"{stem}: approval packet missing `{text}`")
board_path = artifacts.get("current approval board")
contact_path = artifacts.get("contact.png")
if board_path and board_path.exists() and contact_path and contact_path.exists():
if board_path.stat().st_mtime < contact_path.stat().st_mtime:
issues.append(f"{stem}: current approval board is older than contact sheet")
return issues, evidence
def write_report(
report_path: Path,
manifest: dict[str, Any],
audit: dict[str, Any],
candidate_results: list[tuple[str, list[str], list[str]]],
) -> None:
lines = [
"# Unit Sprite Art Quality Gate",
"",
"This file is generated by `scripts/check-unit-sprite-art-quality.py`.",
"",
"This gate catches obvious sprite-art failures before user approval. It does not replace manual visual approval.",
"",
f"- Manifest: `{rel(MANIFEST_PATH)}`",
f"- Audit: `{rel(AUDIT_PATH)}`",
f"- Approved baseline units: {len(manifest.get('approvedBaseline', []))}",
f"- Pending approval units: {len(manifest.get('pendingApproval', []))}",
f"- Queued rework units: {audit.get('stage_counts', {}).get('queued-rework', 0)}",
"",
"## Checked Rules",
"",
"- Required approval artifacts exist, including contact sheet, GIF, scale comparison, battle preview, and current approval board.",
"- Base/action sheets have no partial alpha, border hits, or empty frames.",
"- 50px battle-scale silhouette is not too small or too tall.",
"- Idle/walk base frames stay vertically stable.",
"- Approval packet points at the current approval board and browser debug preview evidence.",
"",
]
failed = False
for stem, issues, evidence in candidate_results:
lines.extend([f"## {stem}", ""])
if issues:
failed = True
lines.append("Status: FAILED")
lines.append("")
lines.append("Issues:")
for issue in issues:
lines.append(f"- {issue}")
else:
lines.append("Status: OK")
lines.append("")
if evidence:
lines.append("Evidence:")
lines.extend(evidence)
lines.append("")
lines.extend(["## Result", "", "FAILED" if failed else "OK", ""])
report_path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Check pending unit sprite art-quality evidence.")
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
args = parser.parse_args()
try:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
except Exception as exc:
print(f"Unit sprite art quality gate: FAILED\n- {exc}")
return 1
rows = row_by_stem(audit)
candidates = manifest.get("currentApprovalCandidate", {})
pending = manifest.get("pendingApproval", [])
candidate_results: list[tuple[str, list[str], list[str]]] = []
if not pending:
candidate_results.append(("none", [], ["- no pending approval units: nothing to check"]))
for stem in pending:
row = rows.get(stem)
candidate = candidates.get(stem)
if not row:
candidate_results.append((stem, [f"{stem}: missing from audit"], []))
continue
if not candidate:
candidate_results.append((stem, [f"{stem}: missing currentApprovalCandidate entry"], []))
continue
issues, evidence = validate_candidate(stem, candidate, row)
candidate_results.append((stem, issues, evidence))
args.report.parent.mkdir(parents=True, exist_ok=True)
write_report(args.report, manifest, audit, candidate_results)
failed = any(issues for _, issues, _ in candidate_results)
if failed:
print("Unit sprite art quality gate: FAILED")
for stem, issues, _ in candidate_results:
for issue in issues:
print(f"- {issue}")
print(f"Wrote {args.report}")
return 1
print("Unit sprite art quality gate: OK")
for stem, _, _ in candidate_results:
print(f"- {stem}")
print(f"Wrote {args.report}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
NEXT_STEPS_PATH = DOCS_DIR / "unit-sprite-next-steps.md"
PIPELINE_PATH = DOCS_DIR / "unit-sprite-handpaint-pipeline-guide.md"
CHECKLIST_PATH = DOCS_DIR / "unit-sprite-batch-output-checklist.md"
PLAN_PATH = DOCS_DIR / "unit-sprite-rework-plan.md"
CURRENT_BOARD_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png"
ART_GATE_PATH = DOCS_DIR / "unit-sprite-art-quality-gate.md"
REPORT_PATH = DOCS_DIR / "unit-sprite-workflow-consistency.md"
REQUIRED_BEFORE_APPROVAL_COMMANDS = [
"python scripts\\audit-unit-sprite-sheets.py",
"python scripts\\render-unit-sprite-review-boards.py",
"python scripts\\render-current-sprite-approval-board.py",
"python scripts\\check-unit-sprite-art-quality.py",
"python scripts\\check-unit-sprite-approval-gate.py --mode sample",
"python scripts\\write-unit-sprite-next-steps.py",
"python scripts\\check-unit-sprite-workflow-consistency.py",
]
REQUIRED_AFTER_APPROVAL_COMMANDS = [
"python scripts\\audit-unit-sprite-sheets.py",
"python scripts\\render-unit-sprite-review-boards.py",
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
"pnpm build",
]
FORBIDDEN_AFTER_APPROVAL_COMMANDS = [
"python scripts\\render-current-sprite-approval-board.py",
"python scripts\\check-unit-sprite-art-quality.py",
]
def rel(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"missing required file: {rel(path)}")
return json.loads(path.read_text(encoding="utf-8"))
def read_text(path: Path) -> str:
if not path.exists():
raise FileNotFoundError(f"missing required file: {rel(path)}")
return path.read_text(encoding="utf-8")
def rows_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {row["stem"]: row for row in audit.get("rows", [])}
def unique_issues(label: str, values: list[str]) -> list[str]:
issues: list[str] = []
duplicates = sorted({value for value in values if values.count(value) > 1})
for value in duplicates:
issues.append(f"{label} contains duplicate entry: {value}")
return issues
def validate_manifest(manifest: dict[str, Any], audit: dict[str, Any]) -> list[str]:
issues: list[str] = []
rows = rows_by_stem(audit)
approved = manifest.get("approvedBaseline", [])
pending = manifest.get("pendingApproval", [])
next_after = manifest.get("nextAfterApproval", [])
candidates = manifest.get("currentApprovalCandidate", {})
for label, values in (
("approvedBaseline", approved),
("pendingApproval", pending),
("nextAfterApproval", next_after),
):
if not isinstance(values, list):
issues.append(f"{label} must be a list")
continue
issues.extend(unique_issues(label, values))
overlap_pairs = [
("approvedBaseline", "pendingApproval", set(approved) & set(pending)),
("approvedBaseline", "nextAfterApproval", set(approved) & set(next_after)),
("pendingApproval", "nextAfterApproval", set(pending) & set(next_after)),
]
for left, right, overlap in overlap_pairs:
if overlap:
issues.append(f"{left} and {right} overlap: {', '.join(sorted(overlap))}")
for stem in approved + pending + next_after:
if stem not in rows:
issues.append(f"manifest references unknown unit sheet: {stem}")
if set(candidates) != set(pending):
issues.append(
"currentApprovalCandidate keys must exactly match pendingApproval "
f"({sorted(candidates)} != {sorted(pending)})"
)
for stem in pending:
candidate = candidates.get(stem)
if not isinstance(candidate, dict):
issues.append(f"{stem}: currentApprovalCandidate must be an object")
continue
for field in ("version", "source", "artifactPrefix", "approvalPacket"):
if not str(candidate.get(field, "")).strip():
issues.append(f"{stem}: candidate missing `{field}`")
for field in ("source", "approvalPacket"):
value = str(candidate.get(field, "")).strip()
if value and not (ROOT / value).exists():
issues.append(f"{stem}: candidate `{field}` does not exist: {value}")
return issues
def validate_docs(manifest: dict[str, Any]) -> list[str]:
issues: list[str] = []
next_steps = read_text(NEXT_STEPS_PATH)
pipeline = read_text(PIPELINE_PATH)
checklist = read_text(CHECKLIST_PATH)
plan = read_text(PLAN_PATH)
combined_core_docs = "\n".join([next_steps, pipeline, checklist, plan])
for command in REQUIRED_BEFORE_APPROVAL_COMMANDS:
if command not in next_steps:
issues.append(f"next steps missing before-approval command: {command}")
pending = manifest.get("pendingApproval", [])
candidates = manifest.get("currentApprovalCandidate", {})
dynamic_after_commands = REQUIRED_AFTER_APPROVAL_COMMANDS.copy()
for stem in pending:
candidate = candidates.get(stem, {}) if isinstance(candidates, dict) else {}
version = candidate.get("version") if isinstance(candidate, dict) else None
command = f"python scripts\\promote-unit-sprite-approval.py --unit {stem}"
if version:
command += f" --expected-version {version}"
dynamic_after_commands.insert(0, command)
for command in dynamic_after_commands:
if command not in next_steps:
issues.append(f"next steps missing after-approval command: {command}")
after_section = next_steps.split("## Commands After Approval", 1)[-1].split("## First Task", 1)[0]
for command in FORBIDDEN_AFTER_APPROVAL_COMMANDS:
if command in after_section:
issues.append(f"after-approval commands still include pending-only command: {command}")
stale_patterns = [
"unit-shu-infantry` v2",
"Shu infantry v2",
"approved Shu infantry v2",
"v2 restart sample",
]
for pattern in stale_patterns:
if pattern in combined_core_docs:
issues.append(f"core docs contain stale reference: {pattern}")
for stem in pending:
if f"debugSpritePreview={stem}" not in combined_core_docs and stem == "unit-shu-infantry":
issues.append(f"core docs missing debug preview reference for current pending unit: {stem}")
if "scripts/check-unit-sprite-art-quality.py" not in checklist:
issues.append("batch output checklist does not include art quality gate")
if "scripts/check-unit-sprite-art-quality.py" not in plan:
issues.append("rework plan does not include art quality gate")
return issues
def validate_artifact_freshness(manifest: dict[str, Any]) -> list[str]:
issues: list[str] = []
generated_paths = [ART_GATE_PATH]
if manifest.get("pendingApproval", []):
generated_paths.append(CURRENT_BOARD_PATH)
for path in generated_paths:
if not path.exists():
issues.append(f"missing generated workflow artifact: {rel(path)}")
continue
for source in (MANIFEST_PATH, AUDIT_PATH):
if source.exists() and path.stat().st_mtime < source.stat().st_mtime:
issues.append(f"{rel(path)} is older than {rel(source)}")
return issues
def write_report(issues: list[str], manifest: dict[str, Any] | None = None) -> None:
pending = manifest.get("pendingApproval", []) if manifest else []
lines = [
"# Unit Sprite Workflow Consistency",
"",
"This file is generated by `scripts/check-unit-sprite-workflow-consistency.py`.",
"",
"The check validates that manifest state, approval docs, and gate command lists describe the same workflow.",
"",
"## Result",
"",
"FAILED" if issues else "OK",
"",
]
if issues:
lines.extend(["## Issues", ""])
lines.extend(f"- {issue}" for issue in issues)
lines.append("")
else:
lines.extend(
[
"## Checked",
"",
f"- Manifest: `{rel(MANIFEST_PATH)}`",
f"- Audit: `{rel(AUDIT_PATH)}`",
f"- Next steps: `{rel(NEXT_STEPS_PATH)}`",
f"- Pipeline guide: `{rel(PIPELINE_PATH)}`",
f"- Checklist: `{rel(CHECKLIST_PATH)}`",
f"- Rework plan: `{rel(PLAN_PATH)}`",
f"- Art quality gate: `{rel(ART_GATE_PATH)}`",
"",
]
)
if pending:
lines.insert(-2, f"- Current board: `{rel(CURRENT_BOARD_PATH)}`")
else:
lines.insert(-2, "- Current board: skipped because there is no pending approval unit.")
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
issues: list[str] = []
manifest: dict[str, Any] | None = None
try:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
issues.extend(validate_manifest(manifest, audit))
issues.extend(validate_docs(manifest))
issues.extend(validate_artifact_freshness(manifest))
except Exception as exc:
issues.append(str(exc))
write_report(issues, manifest)
if issues:
print("Unit sprite workflow consistency: FAILED")
for issue in issues:
print(f"- {issue}")
print(f"Wrote {REPORT_PATH}")
return 1
print("Unit sprite workflow consistency: OK")
print(f"Wrote {REPORT_PATH}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = ROOT / "docs" / "unit-sprite-rework-manifest.json"
AUDIT_PATH = ROOT / "docs" / "unit-sprite-rework-audit.json"
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def write_manifest(manifest: dict[str, Any]) -> None:
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def audit_row(audit: dict[str, Any], unit: str) -> dict[str, Any] | None:
return next((row for row in audit.get("rows", []) if row.get("stem") == unit), None)
def require_ok_sample(unit: str, audit: dict[str, Any]) -> None:
row = audit_row(audit, unit)
if row is None:
raise ValueError(f"{unit}: missing from audit")
if row.get("review_stage") != "pending-approval":
raise ValueError(f"{unit}: expected pending-approval stage, got {row.get('review_stage')}")
if row.get("base", {}).get("status") != "ok" or row.get("actions", {}).get("status") != "ok":
raise ValueError(f"{unit}: base/actions audit must both be ok before promotion")
def promote(manifest: dict[str, Any], unit: str, expected_version: str | None) -> dict[str, Any]:
pending = list(manifest.get("pendingApproval", []))
approved = list(manifest.get("approvedBaseline", []))
candidates = manifest.get("currentApprovalCandidate", {})
candidate = candidates.get(unit, {})
if unit not in pending:
raise ValueError(f"{unit}: not listed in pendingApproval")
if unit in approved:
raise ValueError(f"{unit}: already listed in approvedBaseline")
if expected_version and candidate.get("version") != expected_version:
raise ValueError(
f"{unit}: expected candidate version {expected_version}, got {candidate.get('version')}"
)
manifest["pendingApproval"] = [item for item in pending if item != unit]
manifest["approvedBaseline"] = sorted([*approved, unit])
if isinstance(candidates, dict):
candidates.pop(unit, None)
manifest["currentApprovalCandidate"] = candidates
return manifest
def main() -> int:
parser = argparse.ArgumentParser(
description="Promote a user-approved pending unit sprite sample into approvedBaseline."
)
parser.add_argument("--unit", required=True, help="Unit texture key, for example unit-shu-infantry.")
parser.add_argument("--expected-version", help="Optional candidate version guard, for example v3.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and print the resulting manifest status without writing the file.",
)
args = parser.parse_args()
try:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
require_ok_sample(args.unit, audit)
next_manifest = promote(manifest, args.unit, args.expected_version)
except Exception as exc:
print(f"Unit sprite approval promotion: FAILED\n- {exc}")
return 1
approved_count = len(next_manifest.get("approvedBaseline", []))
pending_count = len(next_manifest.get("pendingApproval", []))
if args.dry_run:
print("Unit sprite approval promotion: DRY RUN OK")
else:
write_manifest(next_manifest)
print("Unit sprite approval promotion: OK")
print(f"- promoted: {args.unit}")
print(f"- approved baseline: {approved_count}")
print(f"- pending approval: {pending_count}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
OUTPUT_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png"
def font(size: int = 14):
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def audit_row(audit: dict[str, Any], stem: str) -> dict[str, Any] | None:
return next((row for row in audit.get("rows", []) if row.get("stem") == stem), None)
def infer_prefix(candidate: dict[str, Any]) -> str:
artifact_prefix = str(candidate.get("artifactPrefix", "")).strip()
if artifact_prefix:
return artifact_prefix
source = Path(candidate.get("source", "")).name
version = candidate.get("version")
if source and version and source.endswith(f"-source-{version}.png"):
return source.removesuffix(f"-source-{version}.png") + f"-{version}"
if source and source.endswith(".png"):
return source.removesuffix(".png")
raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source")
def fit_image(path: Path, box: tuple[int, int], title: str) -> Image.Image:
width, height = box
panel = Image.new("RGBA", (width, height), (24, 30, 27, 255))
draw = ImageDraw.Draw(panel)
draw.rectangle((0, 0, width - 1, height - 1), outline=(105, 120, 94, 255), width=1)
draw.text((12, 10), title, fill=(240, 226, 178, 255), font=font(14))
if not path.exists():
draw.text((12, 44), f"Missing: {path.name}", fill=(230, 128, 104, 255), font=font(12))
return panel
image = Image.open(path).convert("RGBA")
image.thumbnail((width - 24, height - 56), Image.Resampling.LANCZOS)
x = (width - image.width) // 2
y = 44 + (height - 56 - image.height) // 2
panel.alpha_composite(image, (x, y))
return panel
def write_text_block(draw: ImageDraw.ImageDraw, x: int, y: int, lines: list[str], fill=(222, 214, 174, 255)) -> None:
small = font(12)
for index, line in enumerate(lines):
draw.text((x, y + index * 21), line, fill=fill, font=small)
def render_board() -> Path:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
pending = manifest.get("pendingApproval", [])
if not pending:
raise ValueError("no pendingApproval unit to render")
stem = pending[0]
candidates = manifest.get("currentApprovalCandidate", {})
candidate = candidates.get(stem)
if not candidate:
raise ValueError(f"{stem}: missing currentApprovalCandidate entry")
prefix = infer_prefix(candidate)
row = audit_row(audit, stem)
base = row.get("base", {}) if row else {}
paths = {
"Contact Sheet": DOCS_DIR / f"{prefix}-contact.png",
"50px / 72px Scale": DOCS_DIR / f"{prefix}-scale-compare.png",
"Debug Battle Preview": DOCS_DIR / f"{prefix}-debug-preview-battle-check.png",
"Approved + Pending Board": DOCS_DIR / "unit-sprite-review-approved-and-pending.png",
}
width = 1280
height = 1320
pad = 24
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
draw = ImageDraw.Draw(out)
title_font = font(24)
body_font = font(13)
draw.text((pad, 18), f"Current Unit Sprite Approval Board: {stem} {candidate.get('version', '')}", fill=(245, 232, 183, 255), font=title_font)
draw.text(
(pad, 52),
"Generated from the current manifest. This board is for visual approval only; it does not promote, commit, push, or deploy.",
fill=(178, 166, 127, 255),
font=body_font,
)
info_lines = [
f"status: pending-approval",
f"source: {Path(candidate.get('source', '')).name}",
f"approval packet: {Path(candidate.get('approvalPacket', '')).name}",
"art quality gate: unit-sprite-art-quality-gate.md",
f"base status: {base.get('status', 'unknown')}",
f"battle bbox: {base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x {base.get('battle_min_height_px')}..{base.get('battle_max_height_px')} px",
"release gate: blocked until explicit approval",
]
write_text_block(draw, pad, 84, info_lines)
top_y = 230
left_w = 888
right_w = width - pad * 3 - left_w
out.alpha_composite(fit_image(paths["Contact Sheet"], (left_w, 450), "Contact Sheet"), (pad, top_y))
out.alpha_composite(fit_image(paths["50px / 72px Scale"], (right_w, 216), "50px / 72px Scale"), (pad * 2 + left_w, top_y))
out.alpha_composite(fit_image(paths["Approved + Pending Board"], (right_w, 216), "Approved + Pending"), (pad * 2 + left_w, top_y + 234))
bottom_y = top_y + 474
out.alpha_composite(fit_image(paths["Debug Battle Preview"], (width - pad * 2, 430), "Real Battle Debug Preview"), (pad, bottom_y))
command_y = bottom_y + 446
draw.text((pad, command_y), "After explicit approval:", fill=(240, 226, 178, 255), font=font(15))
commands = [
f"python scripts\\promote-unit-sprite-approval.py --unit {stem} --expected-version {candidate.get('version')}",
"python scripts\\audit-unit-sprite-sheets.py",
"python scripts\\render-unit-sprite-review-boards.py",
"python scripts\\check-unit-sprite-art-quality.py",
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
]
write_text_block(draw, pad, command_y + 28, commands, fill=(203, 214, 198, 255))
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
out.convert("RGB").save(OUTPUT_PATH, optimize=True)
return OUTPUT_PATH
def main() -> None:
path = render_board()
print(f"Wrote {path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import json
from collections import defaultdict
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
DOCS_DIR = ROOT / "docs"
AUDIT_JSON = DOCS_DIR / "unit-sprite-rework-audit.json"
FRAME = 313
def font(size: int = 12):
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def checker_background(size: tuple[int, int], tile: int = 8) -> Image.Image:
image = Image.new("RGBA", size, (57, 76, 48, 255))
draw = ImageDraw.Draw(image)
for y in range(0, size[1], tile):
for x in range(0, size[0], tile):
color = (68, 90, 55, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (48, 64, 43, 255)
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
return image
def frame_for(stem: str, row = 0, col = 0) -> Image.Image:
image = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA")
return image.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME))
def render_sprite(frame: Image.Image, size: int) -> Image.Image:
tile = checker_background((size, size))
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
tile.alpha_composite(scaled, (0, 0))
return tile
def label_for(stem: str) -> str:
return stem.replace("unit-", "")
def stage_label(stage: str) -> str:
return {
"approved-baseline": "approved baseline",
"pending-approval": "pending sample",
"queued-rework": "old queued asset",
}.get(stage, stage)
def stage_color(stage: str) -> tuple[int, int, int, int]:
if stage == "approved-baseline":
return (103, 151, 93, 255)
if stage == "pending-approval":
return (218, 176, 84, 255)
return (90, 101, 91, 255)
def render_batch_board(batch: str, rows: list[dict]) -> Path:
rows = sorted(rows, key=lambda item: item["stem"])
columns = 5
card_w = 172
card_h = 142
pad = 18
header_h = 54
width = pad + columns * card_w + pad
height = header_h + ((len(rows) + columns - 1) // columns) * card_h + pad
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
draw = ImageDraw.Draw(out)
title_font = font(18)
body_font = font(11)
small_font = font(9)
draw.text((pad, 16), f"{batch} review board", fill=(240, 226, 178, 255), font=title_font)
for index, row in enumerate(rows):
x = pad + (index % columns) * card_w
y = header_h + (index // columns) * card_h
stem = row["stem"]
base_status = row["base"]["status"]
action_status = row["actions"]["status"]
status_ok = base_status == "ok" and action_status == "ok"
stage = row.get("review_stage", "queued-rework")
border = stage_color(stage) if status_ok else (156, 105, 67, 255)
draw.rectangle((x, y, x + card_w - 12, y + card_h - 12), fill=(24, 30, 27, 255), outline=border, width=1)
frame = frame_for(stem)
out.alpha_composite(render_sprite(frame, 72), (x + 10, y + 10))
out.alpha_composite(render_sprite(frame, 50), (x + 94, y + 21))
draw.text((x + 10, y + 88), label_for(stem), fill=(238, 224, 178, 255), font=body_font)
status_text = stage_label(stage) if status_ok else "technical issue"
draw.text((x + 10, y + 108), status_text, fill=border, font=small_font)
if stage == "queued-rework":
draw.text((x + 94, y + 75), "not new", fill=(151, 137, 105, 255), font=small_font)
path = DOCS_DIR / f"unit-sprite-review-{batch}.png"
out.convert("RGB").save(path, optimize=True)
return path
def render_deliverable_board(rows: list[dict]) -> Path:
deliverables = [
row
for row in rows
if row.get("review_stage") in {"approved-baseline", "pending-approval"}
]
order = {"approved-baseline": 0, "pending-approval": 1}
deliverables = sorted(deliverables, key=lambda item: (order[item["review_stage"]], item["stem"]))
columns = 5
card_w = 172
card_h = 148
pad = 18
header_h = 70
width = pad + columns * card_w + pad
height = header_h + ((len(deliverables) + columns - 1) // columns) * card_h + pad
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
draw = ImageDraw.Draw(out)
draw.text((pad, 16), "Approved + Pending Sprite Deliverables", fill=(240, 226, 178, 255), font=font(18))
draw.text(
(pad, 42),
"Queued old assets are intentionally excluded from this board.",
fill=(174, 161, 124, 255),
font=font(11),
)
for index, row in enumerate(deliverables):
x = pad + (index % columns) * card_w
y = header_h + (index // columns) * card_h
stem = row["stem"]
stage = row.get("review_stage", "queued-rework")
border = stage_color(stage)
draw.rectangle((x, y, x + card_w - 12, y + card_h - 12), fill=(24, 30, 27, 255), outline=border, width=1)
frame = frame_for(stem)
out.alpha_composite(render_sprite(frame, 72), (x + 10, y + 10))
out.alpha_composite(render_sprite(frame, 50), (x + 94, y + 21))
draw.text((x + 10, y + 88), label_for(stem), fill=(238, 224, 178, 255), font=font(11))
draw.text((x + 10, y + 110), stage_label(stage), fill=border, font=font(9))
path = DOCS_DIR / "unit-sprite-review-approved-and-pending.png"
out.convert("RGB").save(path, optimize=True)
return path
def render_all_board(groups: dict[str, list[dict]], paths: list[Path]) -> Path:
thumb_w = 212
thumb_h = 118
pad = 18
cols = 2
width = pad + cols * thumb_w + pad
height = 58 + ((len(paths) + cols - 1) // cols) * thumb_h + pad
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
draw = ImageDraw.Draw(out)
draw.text((pad, 16), "Unit Sprite Batch Review Index", fill=(240, 226, 178, 255), font=font(18))
for index, path in enumerate(paths):
batch = path.stem.replace("unit-sprite-review-", "")
x = pad + (index % cols) * thumb_w
y = 58 + (index // cols) * thumb_h
preview = Image.open(path).convert("RGBA")
preview.thumbnail((190, 84), Image.Resampling.LANCZOS)
draw.rectangle((x, y, x + thumb_w - 12, y + thumb_h - 12), fill=(24, 30, 27, 255), outline=(85, 95, 75, 255))
out.alpha_composite(preview, (x + 10, y + 10))
approved_count = sum(1 for row in groups[batch] if row.get("review_stage") == "approved-baseline")
pending_count = sum(1 for row in groups[batch] if row.get("review_stage") == "pending-approval")
draw.text(
(x + 10, y + 92),
f"{batch}: {approved_count} approved, {pending_count} pending",
fill=(238, 224, 178, 255),
font=font(10),
)
path = DOCS_DIR / "unit-sprite-review-index.png"
out.convert("RGB").save(path, optimize=True)
return path
def main() -> None:
audit = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
groups: dict[str, list[dict]] = defaultdict(list)
all_rows: list[dict] = []
for row in audit["rows"]:
groups[row["batch"]].append(row)
all_rows.append(row)
paths = [render_batch_board(batch, groups[batch]) for batch in sorted(groups)]
deliverables = render_deliverable_board(all_rows)
index = render_all_board(groups, paths)
print(f"Wrote {index}")
print(f"Wrote {deliverables}")
for path in paths:
print(f"Wrote {path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
MARKDOWN_PATH = DOCS_DIR / "unit-sprite-batch-queue.md"
JSON_PATH = DOCS_DIR / "unit-sprite-batch-queue.json"
BATCH_ORDER = [
"batch1-approved-core",
"batch1-approved-rebel",
"batch2-shu",
"batch3-wei",
"batch4-wu",
"batch5-rebel-other",
"batch6-nanman-special",
"batch7-unique-officer",
]
BATCH_LABELS = {
"batch1-approved-core": "Batch 1 approved core heroes",
"batch1-approved-rebel": "Batch 1 approved rebel references",
"batch2-shu": "Batch 2 Shu generic units",
"batch3-wei": "Batch 3 Wei generic units",
"batch4-wu": "Batch 4 Wu generic units",
"batch5-rebel-other": "Batch 5 remaining rebel/bandit units",
"batch6-nanman-special": "Batch 6 Nanman/special units",
"batch7-unique-officer": "Batch 7 unique officers",
}
SPEC_BY_STEM = {
"unit-shu-spearman": "unit-sprite-batch2-shu-spearman-spec.md",
"unit-shu-archer": "unit-sprite-batch2-shu-archer-spec.md",
"unit-shu-cavalry": "unit-sprite-batch2-shu-cavalry-spec.md",
"unit-shu-strategist": "unit-sprite-batch2-shu-strategist-spec.md",
"unit-shu-officer": "unit-sprite-batch2-shu-officer-spec.md",
}
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def row_sort_key(row: dict[str, Any], manifest: dict[str, Any]) -> tuple[int, int, str]:
stem = row["stem"]
pending = manifest.get("pendingApproval", [])
next_after = manifest.get("nextAfterApproval", [])
if stem in pending:
return (0, pending.index(stem), stem)
if stem in next_after:
return (1, next_after.index(stem), stem)
if row.get("review_stage") == "approved-baseline":
return (3, 0, stem)
return (2, 0, stem)
def summarize_status(row: dict[str, Any]) -> str:
base = row["base"]["status"]
actions = row["actions"]["status"]
if base == "ok" and actions == "ok":
return "technical ok"
return f"base {base}; actions {actions}"
def queue_data(manifest: dict[str, Any], audit: dict[str, Any]) -> dict[str, Any]:
rows = audit.get("rows", [])
batches: list[dict[str, Any]] = []
pending = set(manifest.get("pendingApproval", []))
approved = set(manifest.get("approvedBaseline", []))
next_after = set(manifest.get("nextAfterApproval", []))
for batch in BATCH_ORDER:
batch_rows = [row for row in rows if row.get("batch") == batch]
batch_rows = sorted(batch_rows, key=lambda row: row_sort_key(row, manifest))
stage_counts = Counter(row.get("review_stage", "queued-rework") for row in batch_rows)
units: list[dict[str, Any]] = []
for row in batch_rows:
stem = row["stem"]
queue_role = "approved reference"
if stem in pending:
queue_role = "current approval sample"
elif stem in next_after:
queue_role = "next primary queue"
elif stem not in approved:
queue_role = "queued rework"
units.append(
{
"stem": stem,
"reviewStage": row.get("review_stage"),
"queueRole": queue_role,
"baseStatus": row["base"]["status"],
"actionStatus": row["actions"]["status"],
"battleBasePx": [
row["base"].get("battle_min_width_px"),
row["base"].get("battle_max_width_px"),
row["base"].get("battle_min_height_px"),
row["base"].get("battle_max_height_px"),
],
"spec": SPEC_BY_STEM.get(stem),
}
)
batches.append(
{
"key": batch,
"label": BATCH_LABELS.get(batch, batch),
"total": len(batch_rows),
"stageCounts": dict(sorted(stage_counts.items())),
"units": units,
}
)
return {
"schema": 1,
"manifest": MANIFEST_PATH.relative_to(ROOT).as_posix(),
"audit": AUDIT_PATH.relative_to(ROOT).as_posix(),
"approvedBaseline": manifest.get("approvedBaseline", []),
"pendingApproval": manifest.get("pendingApproval", []),
"nextAfterApproval": manifest.get("nextAfterApproval", []),
"stageCounts": audit.get("stage_counts", {}),
"batches": batches,
}
def write_markdown(data: dict[str, Any]) -> None:
lines = [
"# Unit Sprite Batch Queue",
"",
"This file is generated by `scripts/write-unit-sprite-batch-queue.py`.",
"",
"Use this as the long-running queue for the full hand-painted sprite rework. It is inventory and sequencing, not approval by itself.",
"",
"## Current State",
"",
f"- Approved baseline units: {len(data['approvedBaseline'])}",
f"- Pending approval units: {len(data['pendingApproval'])}",
f"- Queued rework units: {data.get('stageCounts', {}).get('queued-rework', 0)}",
f"- Manifest: `{data['manifest']}`",
f"- Audit: `{data['audit']}`",
"",
]
pending = data["pendingApproval"]
if pending:
lines.extend(
[
"## Approval Blocker",
"",
"Do not start the next unit, commit, push, or deploy while these units are pending:",
"",
]
)
for stem in pending:
lines.append(f"- `{stem}`")
lines.append("")
lines.extend(["## Next After Approval", ""])
next_after = data["nextAfterApproval"]
if next_after:
for index, stem in enumerate(next_after, start=1):
spec = SPEC_BY_STEM.get(stem)
spec_note = f" - spec `{spec}`" if spec else ""
lines.append(f"{index}. `{stem}`{spec_note}")
else:
lines.append("No next unit is currently defined.")
lines.append("")
lines.extend(["## Batch Queue", ""])
for batch in data["batches"]:
lines.extend(
[
f"### {batch['label']}",
"",
f"- Batch key: `{batch['key']}`",
f"- Total units: {batch['total']}",
f"- Stage counts: {', '.join(f'{key} {value}' for key, value in batch['stageCounts'].items()) or 'none'}",
"",
"| unit | role | stage | status | battle base px | spec |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for unit in batch["units"]:
battle = unit["battleBasePx"]
battle_text = f"{battle[0]}..{battle[1]} x {battle[2]}..{battle[3]}"
spec = f"`{unit['spec']}`" if unit["spec"] else ""
status = (
"technical ok"
if unit["baseStatus"] == "ok" and unit["actionStatus"] == "ok"
else f"base {unit['baseStatus']}; actions {unit['actionStatus']}"
)
lines.append(
f"| `{unit['stem']}` | {unit['queueRole']} | {unit['reviewStage']} | {status} | {battle_text} | {spec} |"
)
lines.append("")
MARKDOWN_PATH.write_text("\n".join(lines), encoding="utf-8")
def main() -> None:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
data = queue_data(manifest, audit)
JSON_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_markdown(data)
print(f"Wrote {JSON_PATH}")
print(f"Wrote {MARKDOWN_PATH}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
DEFAULT_OUTPUT = DOCS_DIR / "unit-sprite-next-steps.md"
SPEC_BY_STEM = {
"unit-shu-spearman": "unit-sprite-batch2-shu-spearman-spec.md",
"unit-shu-archer": "unit-sprite-batch2-shu-archer-spec.md",
"unit-shu-cavalry": "unit-sprite-batch2-shu-cavalry-spec.md",
"unit-shu-strategist": "unit-sprite-batch2-shu-strategist-spec.md",
"unit-shu-officer": "unit-sprite-batch2-shu-officer-spec.md",
}
def load_json(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
return json.loads(path.read_text(encoding="utf-8"))
def row_by_stem(audit: dict) -> dict[str, dict]:
return {row["stem"]: row for row in audit.get("rows", [])}
def command_block(commands: list[str]) -> list[str]:
return ["```powershell", *commands, "```"]
def write_report(output: Path) -> None:
manifest = load_json(MANIFEST_PATH)
audit = load_json(AUDIT_PATH)
rows = row_by_stem(audit)
approved = manifest.get("approvedBaseline", [])
pending = manifest.get("pendingApproval", [])
candidates = manifest.get("currentApprovalCandidate", {})
next_after = manifest.get("nextAfterApproval", [])
stage_counts = audit.get("stage_counts", {})
lines = [
"# Unit Sprite Next Steps",
"",
"This file is generated by `scripts/write-unit-sprite-next-steps.py`.",
"",
"## Current Gate",
"",
f"- Approved baseline units: {len(approved)}",
f"- Pending approval units: {len(pending)}",
f"- Queued rework units: {stage_counts.get('queued-rework', 0)}",
f"- Manifest: `{MANIFEST_PATH.relative_to(ROOT).as_posix()}`",
f"- Audit: `{AUDIT_PATH.relative_to(ROOT).as_posix()}`",
"- Batch inventory boards include old queued assets; use them for inventory/regression checks, not approval.",
"",
]
if pending:
lines.insert(-2, "- Current approval board: `docs/unit-sprite-current-approval-board.png`")
else:
lines.insert(-2, "- Current approval board: not generated until the next pending sample.")
if pending:
lines.extend(
[
"## Approval Blocker",
"",
"Do not expand to the next unit, commit, push, or deploy while these units remain pending:",
"",
]
)
for stem in pending:
row = rows.get(stem)
if row:
base = row["base"]
lines.append(
f"- `{stem}`: {row['review_stage']}, base `{base['status']}`, "
f"battle bbox `{base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x "
f"{base.get('battle_min_height_px')}..{base.get('battle_max_height_px')}`"
)
else:
lines.append(f"- `{stem}`: missing from audit")
lines.append("")
promote_commands = []
if pending:
unit = pending[0]
version = candidates.get(unit, {}).get("version") if isinstance(candidates, dict) else None
command = f"python scripts\\promote-unit-sprite-approval.py --unit {unit}"
if version:
command += f" --expected-version {version}"
promote_commands.append(command)
lines.extend(
[
"## Next Unit Queue",
"",
]
)
if next_after:
for index, stem in enumerate(next_after, start=1):
row = rows.get(stem)
status = "missing from audit"
if row:
status = f"{row['review_stage']}, base `{row['base']['status']}`, actions `{row['actions']['status']}`"
spec = SPEC_BY_STEM.get(stem)
spec_note = f", spec `{spec}`" if spec else ""
lines.append(f"{index}. `{stem}` - {status}{spec_note}")
else:
lines.append("No queued next unit is defined in the manifest.")
lines.extend(
[
"",
"## Commands Before Approval",
"",
*command_block(
[
"python scripts\\audit-unit-sprite-sheets.py",
"python scripts\\render-unit-sprite-review-boards.py",
"python scripts\\render-current-sprite-approval-board.py",
"python scripts\\check-unit-sprite-art-quality.py",
"python scripts\\check-unit-sprite-approval-gate.py --mode sample",
"python scripts\\write-unit-sprite-next-steps.py",
"python scripts\\check-unit-sprite-workflow-consistency.py",
]
),
"",
"## Commands After Approval",
"",
"After user approval, promote the approved unit in `unit-sprite-rework-manifest.json`, regenerate outputs, and only then use the release gate.",
"",
*command_block(
[
*promote_commands,
"python scripts\\audit-unit-sprite-sheets.py",
"python scripts\\render-unit-sprite-review-boards.py",
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
"pnpm build",
]
),
"",
"## First Task After Current Approval",
"",
]
)
if next_after:
first = next_after[0]
spec = SPEC_BY_STEM.get(first, "")
lines.append(f"- Start with `{first}`.")
if spec:
lines.append(f"- Use spec: `{spec}`.")
lines.append("- Generate source art, assemble sheets, produce contact/GIF/before-after/scale/battle screenshots, then rerun the gates.")
else:
lines.append("- No first task is defined.")
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Write the next-step report for sprite rework.")
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
write_report(args.out)
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()