Approve Shu infantry handpainted sprite v8
This commit is contained in:
608
scripts/assemble-handpaint-shu-infantry-quality-sample.py
Normal file
608
scripts/assemble-handpaint-shu-infantry-quality-sample.py
Normal 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()
|
||||
306
scripts/audit-unit-sprite-sheets.py
Normal file
306
scripts/audit-unit-sprite-sheets.py
Normal 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()
|
||||
141
scripts/check-unit-sprite-approval-gate.py
Normal file
141
scripts/check-unit-sprite-approval-gate.py
Normal 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())
|
||||
243
scripts/check-unit-sprite-art-quality.py
Normal file
243
scripts/check-unit-sprite-art-quality.py
Normal 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())
|
||||
259
scripts/check-unit-sprite-workflow-consistency.py
Normal file
259
scripts/check-unit-sprite-workflow-consistency.py
Normal 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())
|
||||
101
scripts/promote-unit-sprite-approval.py
Normal file
101
scripts/promote-unit-sprite-approval.py
Normal 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())
|
||||
158
scripts/render-current-sprite-approval-board.py
Normal file
158
scripts/render-current-sprite-approval-board.py
Normal 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()
|
||||
202
scripts/render-unit-sprite-review-boards.py
Normal file
202
scripts/render-unit-sprite-review-boards.py
Normal 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()
|
||||
219
scripts/write-unit-sprite-batch-queue.py
Normal file
219
scripts/write-unit-sprite-batch-queue.py
Normal 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()
|
||||
173
scripts/write-unit-sprite-next-steps.py
Normal file
173
scripts/write-unit-sprite-next-steps.py
Normal 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()
|
||||
Reference in New Issue
Block a user