Files
OpenOPC/opc/plugins/office_ui/scripts/build_metro_city_characters.py
T
2026-07-01 17:56:31 +08:00

170 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 06 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 1223 repeat that with a second walk cycle (type/read variety).
OPC expects 7 slots per facing: walk 02, idle 1, type 34, read 56, 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()