286 lines
15 KiB
Python
286 lines
15 KiB
Python
"""납품 인박스 → 게임 반영 (파일 작업만 · Unity 쪽은 끝에 출력하는 명령으로).
|
|
|
|
PD 2026-11-05: 「매번 수신하지 않아도, 요청한 작업이 완전히 완료된 경우 해당 리소스만 전달받는 체계」.
|
|
제작 세션은 묶음이 **완전히** 끝났을 때만 아래 json 한 장을 인박스에 쓴다(중간 보고 없음 · 채팅 메시지 없음).
|
|
|
|
인박스 = C:\\ArtRnD\\samguk_game\\deliveries\\<YYYYMMDD_HHMM>_<batch>.json
|
|
{
|
|
"batch": "bg" | "hgj" | "heroes_back" | "dz" | "cc" | "wei" | …,
|
|
"kind": "bg" | "poses" | "worldmap" | "ui",
|
|
"qa": "PASS",
|
|
"session": "<만든 세션 이름>",
|
|
# kind == "bg"
|
|
"file": "C:/ArtRnD/samguk_game/bg/battlefield_01.png", "md5": "<파일 md5>",
|
|
# kind == "poses"
|
|
"root": "C:/ArtRnD/samguk_game/poses", # <root>/<art>/<clip>.png
|
|
"arts": ["dz_infantry", "dz_spear", …],
|
|
"clips": ["front_idle", "front_ready", "front_action"], # back_* 가 있으면 함께
|
|
"md5_list": "C:/ArtRnD/samguk_game/poses/md5.txt" # 선택 · <root> 기준 상대 경로 · md5sum 형식
|
|
# kind == "worldmap" (챕터 월드맵 기획 확정판 · 챕터맵 보강 세션 · 2026-11-05)
|
|
"root": "C:/ArtRnD/samguk_chapmap/plan", # 선택(없으면 이 기본값) · maps[].map·anchors 의 기준
|
|
"maps": [{"chapter": 3, "id": "ch03", "ko": "군웅할거", "nodes": 15,
|
|
"map": "maps/ch03.png", "map_md5": "<md5>", "anchors": "anchors/ch03.json", "anchors_md5": "<md5>"}]
|
|
}
|
|
# kind == "ui" (UI 리뉴얼 B 신규 제작 · 세션 tks-ui-art · PD #1100 · 2026-11-06)
|
|
"root": "C:/ArtRnD/samguk_ui/<group>", # files 의 기준
|
|
"files": ["cur_gold.png", …] 또는 [{"file": "cur_gold.png", "md5": "<md5>"}, …], # 파일 이름(확장자 앞) = 테마 id
|
|
"md5_list": "C:/ArtRnD/samguk_ui/<group>/md5.txt" # 선택 · <root> 기준 상대 경로 · md5sum 형식
|
|
}
|
|
ui = <root>/<file> → Assets/UI/Samguk/<group>/<id>.png (group = batch 의 「ui_」 뒤 · 없으면 root 폴더 이름) ·
|
|
기믹 그림(id 가 gim_ 로 시작) → Assets/Resources/Gimmicks/Art/<id>.png(GimmickView 가 읽는 자리) ·
|
|
치환 기록 = Assets/UI/Samguk/ui_delivered.csv(id · group · 경로 · md5 · batch) · 테마 치환 테이블(Resources/UI/UITheme.asset)은
|
|
Unity 가 Samguk PNG 임포트 때 자동 갱신(Editor/UIThemeImporter · 수동 = 메뉴 TKS/Import UI Theme) — 화면 코드 변경 없음.
|
|
worldmap = 맵 PNG → Assets/Resources/ChapterMap/Maps/<id>.png · 좌표 json → Data/<id>.json(게임 노드 = stages[].at) ·
|
|
Data/world.json 의 같은 id 항목(길 roadX·roadY · 장소)을 좌표 json 으로 교체. 게임 챕터 → 맵 연결 = Assets/Data/Stages/chapters.csv map 열.
|
|
같은 맵 id 를 더 늦게 다시 납품한 json(_r2 등)이 미반영으로 있으면 앞의 것은 반영하지 않고 .superseded.json 으로 바꾼다.
|
|
|
|
실행: <ComfyUI venv python> -I -B -X utf8 Tools/apply_delivery.py list
|
|
<ComfyUI venv python> -I -B -X utf8 Tools/apply_delivery.py apply <json 경로> (반영 뒤 .applied.json 으로 이름 바꿈)
|
|
<ComfyUI venv python> -I -B -X utf8 Tools/apply_delivery.py apply --all (미반영 전부)
|
|
<ComfyUI venv python> -I -B -X utf8 Tools/apply_delivery.py apply --all --kind worldmap (그 kind 만)
|
|
반영 뒤 Unity: 메뉴 재임포트 → 비동기 테스트 → 커밋 (끝에 출력).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
INBOX = r"C:\ArtRnD\samguk_game\deliveries"
|
|
EXPORTER = r"C:\Ai\NerdNavisAi\.claude\skills\skeletal-anim-pipeline\files\scripts\rig_frames_export.py"
|
|
UNITS = os.path.join(PROJECT, "Assets", "Resources", "Units")
|
|
BOARD = os.path.join(PROJECT, "Assets", "Resources", "Board", "battlefield_01.png")
|
|
CHAPMAP = os.path.join(PROJECT, "Assets", "Resources", "ChapterMap")
|
|
WORLDMAP_ROOT = r"C:\ArtRnD\samguk_chapmap\plan"
|
|
SAMGUK_UI = os.path.join(PROJECT, "Assets", "UI", "Samguk")
|
|
GIMMICK_ART = os.path.join(PROJECT, "Assets", "Resources", "Gimmicks", "Art")
|
|
THEME_CSV = os.path.join(PROJECT, "Assets", "Data", "UI", "ui_theme.csv")
|
|
|
|
|
|
def md5_of(path):
|
|
h = hashlib.md5()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def pending():
|
|
os.makedirs(INBOX, exist_ok=True)
|
|
return sorted(f for f in os.listdir(INBOX)
|
|
if f.endswith(".json") and not f.endswith(".applied.json") and not f.endswith(".superseded.json"))
|
|
|
|
|
|
def superseded(files):
|
|
"""worldmap 납품 중 같은 맵 id 를 모두 더 늦은 미반영 납품이 덮는 것(파일명 = 시각순)."""
|
|
maps = {}
|
|
for f in files:
|
|
d = json.load(open(os.path.join(INBOX, f), encoding="utf-8"))
|
|
if d.get("kind") == "worldmap":
|
|
maps[f] = {m["id"] for m in d.get("maps", [])}
|
|
out = set()
|
|
for f, ids in maps.items():
|
|
later = set().union(*[v for g, v in maps.items() if g > f] or [set()])
|
|
if ids and ids <= later:
|
|
out.add(f)
|
|
return out
|
|
|
|
|
|
def check_md5_list(root, md5_list):
|
|
"""md5sum 형식(<md5> <상대경로>) 목록 검증 → (ok, bad)."""
|
|
ok, bad = 0, []
|
|
for line in open(md5_list, encoding="utf-8"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
digest, rel = line.split(None, 1)
|
|
rel = rel.lstrip("*").replace("/", os.sep)
|
|
p = os.path.join(root, rel)
|
|
if not os.path.exists(p) or md5_of(p) != digest.lower():
|
|
bad.append(rel)
|
|
else:
|
|
ok += 1
|
|
return ok, bad
|
|
|
|
|
|
def apply_bg(d):
|
|
src = d["file"]
|
|
if d.get("md5") and md5_of(src) != d["md5"].lower():
|
|
sys.exit("배경 md5 불일치: " + src)
|
|
os.makedirs(os.path.dirname(BOARD), exist_ok=True)
|
|
shutil.copyfile(src, BOARD)
|
|
print("bg ->", BOARD, "md5", md5_of(BOARD))
|
|
|
|
|
|
def apply_poses(d):
|
|
root = d["root"]
|
|
if d.get("md5_list"):
|
|
ok, bad = check_md5_list(root, d["md5_list"])
|
|
if bad:
|
|
sys.exit("md5 불일치 %d: %s" % (len(bad), ", ".join(bad[:5])))
|
|
print("md5 OK", ok)
|
|
clips = d.get("clips") or ["front_idle", "front_ready", "front_action"]
|
|
for art in d["arts"]:
|
|
src = os.path.join(root, art)
|
|
have = [c for c in clips if os.path.exists(os.path.join(src, c + ".png"))]
|
|
if not have:
|
|
sys.exit("포즈 없음: " + src)
|
|
out = os.path.join(UNITS, art)
|
|
if os.path.isdir(out):
|
|
shutil.rmtree(out) # 구판(리그 프레임·정지 그림) 제거 — git 에 있음
|
|
r = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", EXPORTER, "poseset", src, "--out", out, "--clips", ",".join(have)],
|
|
capture_output=True, text=True, encoding="utf-8")
|
|
if r.returncode != 0:
|
|
sys.exit("poseset 실패 " + art + "\n" + r.stdout + r.stderr)
|
|
print(r.stdout.strip().splitlines()[-1])
|
|
|
|
|
|
def apply_worldmap(d):
|
|
root = d.get("root") or WORLDMAP_ROOT
|
|
world_path = os.path.join(CHAPMAP, "Data", "world.json")
|
|
world = json.load(open(world_path, encoding="utf-8"))
|
|
spec = os.path.join(root, "spec", "chapters.json")
|
|
lm_scale = json.load(open(spec, encoding="utf-8")).get("lm_scale", 1.0) if os.path.exists(spec) else 1.0
|
|
for m in d["maps"]:
|
|
mid = m["id"]
|
|
pairs = [(m["map"], m.get("map_md5"), os.path.join(CHAPMAP, "Maps", mid + ".png")),
|
|
(m["anchors"], m.get("anchors_md5"), os.path.join(CHAPMAP, "Data", mid + ".json"))]
|
|
for rel, digest, dst in pairs:
|
|
src = os.path.join(root, rel.replace("/", os.sep))
|
|
if digest and md5_of(src) != digest.lower():
|
|
sys.exit("worldmap md5 불일치: " + src)
|
|
for rel, digest, dst in pairs:
|
|
shutil.copyfile(os.path.join(root, rel.replace("/", os.sep)), dst)
|
|
if digest and md5_of(dst) != digest.lower():
|
|
sys.exit("복사 뒤 md5 불일치: " + dst)
|
|
an = json.load(open(pairs[1][2], encoding="utf-8"))
|
|
# 장소 이름판 자리 = 장소 아래(chap_install.py 와 같은 식) · showLabels 가 꺼져 있으면 쓰지 않음
|
|
lms = [{"ko": L["ko"], "x": L["at"][0], "y": round(L["at"][1] + 120 * L.get("s", 1.0) * lm_scale, 1), "role": L.get("role", "")}
|
|
for L in an.get("landmarks", []) if L.get("ko")]
|
|
entry = {"id": mid, "no": m.get("chapter", an.get("no", 0)), "ko": m.get("ko", an.get("ko", "")), "years": "", "map": mid,
|
|
"roadX": [p[0] for p in an["road"]], "roadY": [p[1] for p in an["road"]], "landmarks": lms}
|
|
chapters = [c for c in world["chapters"] if c["id"] != mid] + [entry]
|
|
world["chapters"] = sorted(chapters, key=lambda c: (c["no"], c["id"]))
|
|
print("worldmap", mid, "->", pairs[0][2], "· 노드", len(an.get("stages", [])), "· md5 OK")
|
|
world["_doc"] = ("게임이 읽는 챕터 맵 데이터 · 맵별 길·장소 = Data/<id>.json(좌표 json) 에서 옮김 · 기획 확정판 맵은 "
|
|
"Tools/apply_delivery.py worldmap 이 갱신(원화 세션 가안 chap_install.py 를 다시 돌리면 가안으로 덮이니 쓰지 않는다) · "
|
|
"좌표 = 맵 px(왼쪽 위 원점 · y 아래로) · nodes·stageLink 는 게임이 읽지 않음(노드 = 스테이지 표 + Data/<id>.json stages[].at)")
|
|
open(world_path, "w", encoding="utf-8").write(json.dumps(world, ensure_ascii=False, indent=1))
|
|
|
|
|
|
def apply_ui(d):
|
|
root = d["root"]
|
|
if d.get("md5_list"):
|
|
ok, bad = check_md5_list(root, d["md5_list"])
|
|
if bad:
|
|
sys.exit("md5 불일치 %d: %s" % (len(bad), ", ".join(bad[:5])))
|
|
print("md5 OK", ok)
|
|
batch = str(d.get("batch", ""))
|
|
group = batch[3:] if batch.startswith("ui_") else os.path.basename(os.path.normpath(root))
|
|
known = set()
|
|
if os.path.exists(THEME_CSV):
|
|
for line in open(THEME_CSV, encoding="utf-8-sig"):
|
|
f = line.split(",")
|
|
if len(f) > 1 and f[0] in ("skin", "icon"):
|
|
known.add(f[1])
|
|
log_path = os.path.join(SAMGUK_UI, "ui_delivered.csv")
|
|
rows = {}
|
|
if os.path.exists(log_path):
|
|
for line in open(log_path, encoding="utf-8-sig").read().splitlines()[1:]:
|
|
if line.strip():
|
|
rows[line.split(",")[0]] = line
|
|
replaced, added = [], []
|
|
for item in d["files"]:
|
|
rel, digest = (item, None) if isinstance(item, str) else (item["file"], item.get("md5"))
|
|
src = os.path.join(root, rel.replace("/", os.sep))
|
|
if not rel.lower().endswith(".png"):
|
|
sys.exit("PNG 가 아님: " + rel)
|
|
if digest and md5_of(src) != digest.lower():
|
|
sys.exit("ui md5 불일치: " + src)
|
|
uid = os.path.splitext(os.path.basename(rel))[0]
|
|
dst = os.path.join(GIMMICK_ART, uid + ".png") if uid.startswith("gim_") else os.path.join(SAMGUK_UI, group, uid + ".png")
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
shutil.copyfile(src, dst)
|
|
m = md5_of(dst)
|
|
if digest and m != digest.lower():
|
|
sys.exit("복사 뒤 md5 불일치: " + dst)
|
|
(replaced if uid in known else added).append(uid)
|
|
try:
|
|
shown = os.path.relpath(dst, PROJECT)
|
|
except ValueError: # 다른 드라이브(시험)
|
|
shown = dst
|
|
rows[uid] = ",".join([uid, group, shown.replace(os.sep, "/"), m, batch])
|
|
os.makedirs(SAMGUK_UI, exist_ok=True)
|
|
with open(log_path, "w", encoding="utf-8-sig", newline="\n") as f:
|
|
f.write("id,group,path,md5,batch\n")
|
|
for k in sorted(rows):
|
|
f.write(rows[k] + "\n")
|
|
print("ui", group, "· 팩 치환", len(replaced), "· 새 id", len(added), (added[:8] if added else ""), "→", log_path)
|
|
|
|
|
|
def cmd_list(a):
|
|
files = pending()
|
|
old = superseded(files)
|
|
print("미반영 납품:", len(files))
|
|
for f in files:
|
|
d = json.load(open(os.path.join(INBOX, f), encoding="utf-8"))
|
|
print(" ", f, "|", d.get("kind"), d.get("batch"), "|", d.get("qa"), "|", d.get("session", ""), "| (뒤 납품으로 대체됨)" if f in old else "")
|
|
|
|
|
|
def cmd_apply(a):
|
|
files = pending()
|
|
targets = files if a.all else [os.path.basename(a.json)]
|
|
if a.kind:
|
|
targets = [f for f in targets if json.load(open(os.path.join(INBOX, f), encoding="utf-8")).get("kind") == a.kind]
|
|
if not targets:
|
|
print("반영할 납품 없음")
|
|
return
|
|
old = superseded(files)
|
|
for f in targets:
|
|
p = os.path.join(INBOX, f)
|
|
d = json.load(open(p, encoding="utf-8"))
|
|
if f in old:
|
|
print("대체됨(같은 맵의 뒤 납품이 있음) → .superseded.json:", f)
|
|
os.replace(p, p[:-5] + ".superseded.json")
|
|
continue
|
|
# QA 문구 뒤에 설명이 붙은 경우(「PASS(…)」) 도 통과로 본다
|
|
if not str(d.get("qa", "PASS")).startswith("PASS"):
|
|
print("건너뜀(QA 미통과):", f)
|
|
continue
|
|
print("==", f, d.get("kind"), d.get("batch"))
|
|
if d["kind"] == "bg":
|
|
apply_bg(d)
|
|
elif d["kind"] == "poses":
|
|
apply_poses(d)
|
|
elif d["kind"] == "worldmap":
|
|
apply_worldmap(d)
|
|
elif d["kind"] == "ui":
|
|
apply_ui(d)
|
|
else:
|
|
sys.exit("모르는 kind: " + str(d.get("kind")))
|
|
os.replace(p, p[:-5] + ".applied.json")
|
|
print("\n다음(Unity · PD 에디터 Play 중이면 먼저 editor.playmode stop):")
|
|
print(" unity command --project-path . editor invoke_menu --path 'TKS/Reimport Unit Art (유닛 아트 재임포트)' --result-only (Units·Board·ChapterMap)")
|
|
print(" unity command --project-path . editor invoke_menu --path 'TKS/Import UI Theme (UI 테마 임포트)' --result-only (ui · Samguk PNG 임포트 때 자동 · 확인용)")
|
|
print(" unity command --project-path . test run --mode playmode --async-tests --timeout 600 --result-only ; unity command --project-path . test status --result-only")
|
|
print(" git add -A && git commit (끝줄 Co-Authored-By: <실제 모델> <noreply@anthropic.com>)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sp = p.add_subparsers(dest="cmd", required=True)
|
|
l = sp.add_parser("list")
|
|
l.set_defaults(fn=cmd_list)
|
|
ap = sp.add_parser("apply")
|
|
ap.add_argument("json", nargs="?", default="")
|
|
ap.add_argument("--all", action="store_true")
|
|
ap.add_argument("--kind", default="", help="이 kind 만(bg · poses · worldmap · ui)")
|
|
ap.set_defaults(fn=cmd_apply)
|
|
a = p.parse_args()
|
|
if a.cmd == "apply" and not a.all and not a.json:
|
|
sys.exit("json 경로 또는 --all")
|
|
a.fn(a)
|