Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,169 @@
#!/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()