#!/usr/bin/env python3 """ Assemble MetroCity v1 layered sprites (body + outfit + hair) into OPC-compatible char_0.png … char_5.png sheets. Expected OPC layout (see BootScene.ts): - Frame size 16×32, 7 columns × 3 rows = 112×96 - Row 0: facing down (frames 0–6 of that row) - Row 1: facing up - Row 2: facing right (left uses flipX in Agent.ts) MetroCity row layout: **768×32 = 24 frames × 32px** (NOT 16px). Slicing at 16px would show only the left or right half of each sprite (“cut in half” in-game). After compositing a row, each 32×32 source cell is resampled to 16×32 (nearest neighbor) for the legacy OPC character sheet layout. Per 24 frames: every 12 frames = one rotation — down(3)+left(3)+right(3)+up(3); frames 12–23 repeat that with a second walk cycle (type/read variety). OPC expects 7 slots per facing: walk 0–2, idle 1, type 3–4, read 5–6, etc. We map: - Down: 0,1,2 (walk) + 12,13,14 (second down cycle for type) + 1 (read tail / idle) - Up: 9,10,11 + 21,22,23 + 10 - Right: 6,7,8 + 18,19,20 + 7 Do NOT treat “12 frames per direction” along the X axis — that was the bug that mixed left/right sprites into the down row (“split” characters). """ from __future__ import annotations import argparse import shutil from pathlib import Path from PIL import Image try: _NEAREST = Image.Resampling.NEAREST except AttributeError: # Pillow < 9.1 _NEAREST = Image.NEAREST FRAME_W = 16 FRAME_H = 32 COLS = 7 ROWS = 3 # Source cells in MetroCity sheets (before scaling down for OPC) METRO_FRAME_W = 32 METRO_FRAME_H = 32 # Seven source frame indices per OPC row (within one MetroCity **24-frame** line, 32px each) OPC_DOWN_FRAMES = (0, 1, 2, 12, 13, 14, 1) OPC_UP_FRAMES = (9, 10, 11, 21, 22, 23, 10) OPC_RIGHT_FRAMES = (6, 7, 8, 18, 19, 20, 7) def metro_frame_to_opc(sheet_row: Image.Image, fi: int) -> Image.Image: """Take one 32×32 MetroCity cell for OPC.""" x0 = fi * METRO_FRAME_W cell = sheet_row.crop((x0, 0, x0 + METRO_FRAME_W, METRO_FRAME_H)) if cell.size != (METRO_FRAME_W, METRO_FRAME_H): raise ValueError(f"bad crop at fi={fi}: {cell.size}") if cell.size == (FRAME_W, FRAME_H): return cell return cell.resize((FRAME_W, FRAME_H), _NEAREST) def paste_frames(sheet_row: Image.Image, indices: tuple[int, ...]) -> Image.Image: if len(indices) != COLS: raise ValueError(f"need {COLS} frame indices, got {len(indices)}") w_px = sheet_row.size[0] n_frames = w_px // METRO_FRAME_W out = Image.new("RGBA", (COLS * FRAME_W, FRAME_H)) for col, fi in enumerate(indices): if fi < 0 or fi >= n_frames: raise ValueError(f"frame index {fi} out of range for row with {n_frames} cells") cell = metro_frame_to_opc(sheet_row, fi) out.paste(cell, (col * FRAME_W, 0)) return out def build_opc_sheet(sheet_row: Image.Image) -> Image.Image: out = Image.new("RGBA", (COLS * FRAME_W, ROWS * FRAME_H)) out.paste(paste_frames(sheet_row, OPC_DOWN_FRAMES), (0, 0)) out.paste(paste_frames(sheet_row, OPC_UP_FRAMES), (0, FRAME_H)) out.paste(paste_frames(sheet_row, OPC_RIGHT_FRAMES), (0, 2 * FRAME_H)) return out def composite_row(body: Image.Image, outfit: Image.Image, hair: Image.Image) -> Image.Image: r = body.copy() r = Image.alpha_composite(r, outfit) r = Image.alpha_composite(r, hair) return r def main() -> None: ap = argparse.ArgumentParser() ap.add_argument( "--metro-root", type=Path, default=Path("/root/autodl-tmp/MetroCity/MetroCity"), help="Path to MetroCity (v1) folder containing CharacterModel/, Hair/, Outfits/", ) ap.add_argument( "--out-dir", type=Path, default=Path(__file__).resolve().parents[1] / "frontend_src" / "public" / "assets" / "characters", help="Where to write char_0.png … char_5.png", ) ap.add_argument( "--backup", action="store_true", help="Copy existing char_*.png to characters_opc_backup next to out-dir before overwrite", ) args = ap.parse_args() metro = args.metro_root body_path = metro / "CharacterModel" / "Character Model.png" hairs_path = metro / "Hair" / "Hairs.png" out_dir = args.out_dir out_dir.mkdir(parents=True, exist_ok=True) if args.backup: bak = out_dir.parent / "characters_opc_backup" bak.mkdir(parents=True, exist_ok=True) for i in range(6): src = out_dir / f"char_{i}.png" if src.exists(): shutil.copy2(src, bak / src.name) body = Image.open(body_path).convert("RGBA") hairs = Image.open(hairs_path).convert("RGBA") bw, bh = body.size if bw != 768 or bh % FRAME_H != 0: raise SystemExit(f"Unexpected Character Model size {bw}×{bh}, expected width 768") body_rows = bh // FRAME_H hair_rows = hairs.size[1] // FRAME_H if body_rows < 6: raise SystemExit(f"Character Model has only {body_rows} rows, need 6") for i in range(6): outfit_path = metro / "Outfits" / f"Outfit{i + 1}.png" if not outfit_path.exists(): raise SystemExit(f"Missing {outfit_path}") outfit = Image.open(outfit_path).convert("RGBA") if outfit.size != (768, FRAME_H): raise SystemExit(f"Unexpected outfit size {outfit.size} for {outfit_path.name}") y0 = i * FRAME_H body_row = body.crop((0, y0, 768, y0 + FRAME_H)) outfit_row = outfit.crop((0, 0, 768, FRAME_H)) hair_i = min(i, hair_rows - 1) hy = hair_i * FRAME_H hair_row = hairs.crop((0, hy, 768, hy + FRAME_H)) merged = composite_row(body_row, outfit_row, hair_row) sheet = build_opc_sheet(merged) dest = out_dir / f"char_{i}.png" sheet.save(dest) print(f"Wrote {dest} ({sheet.size[0]}×{sheet.size[1]})") if __name__ == "__main__": main()