Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Validate and plan the authored battlefield scene pipeline.
No network or model generation is performed by this command.
"""
from __future__ import annotations
import argparse, json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "Doc/Design/Scene/Battlefield/battlefield_scene_manifest.json"
def load():
with MANIFEST.open(encoding="utf-8-sig") as f:
return json.load(f)
def validate(data, require_selection=False):
errors=[]
selected=data.get("selectedDesign", "")
ids={x.get("id") for x in data.get("designOptions", [])}
if require_selection and selected not in ids: errors.append("selectedDesign 必须先选择 A/B/C")
if selected and selected not in ids: errors.append(f"未知方案: {selected}")
limit=int(data.get("componentTypeLimit", 0))
allowed=set(data.get("allowedComponentTypes", []))
components=data.get("components", [])
types=[x.get("type") for x in components]
if len(types)!=len(set(types)): errors.append("components 存在重复 type")
if len(types)>limit or len(types)>15: errors.append(f"组件类型 {len(types)} 超过上限")
for t in types:
if t not in allowed: errors.append(f"未登记组件类型: {t}")
ground=data.get("ground", {})
if not ground.get("type")=="ground": errors.append("ground 必须独立登记为 type=ground")
if any(x.get("type")=="ground" for x in components): errors.append("ground 不得放入 components")
if not data.get("assembly", {}).get("outputPrefab"): errors.append("缺少 assembly.outputPrefab")
return errors
def design_ready(data, design_id):
option = next((x for x in data.get("designOptions", []) if x.get("id") == design_id), None)
generation = (option or {}).get("generation", {})
return (
option is not None
and generation.get("tool") == "Tools/gpt_image_generate.py"
and generation.get("model") == "gpt-image-2.5"
and generation.get("status") == "generated"
)
def components_ready(data):
ground = data.get("ground", {})
if ground.get("designGenerationStatus") != "generated":
return False
return bool(data.get("components")) and all(
item.get("generationStatus") in {"generated", "reused"} for item in data.get("components", [])
)
def plan(data):
selected=data.get("selectedDesign")
if not selected:
if data.get("requestedDesign"):
raise SystemExit(f"已记录用户选择 {data['requestedDesign']},但总览图尚未生成;请先运行 Tools/generate_battlefield_designs.py --design {data['requestedDesign']}")
raise SystemExit("请先选择方案:python Tools/battlefield_scene_pipeline.py select A")
if not design_ready(data, selected):
raise SystemExit("方案总览图尚未由 gpt-image-2.5 生成并登记,请先运行 Tools/generate_battlefield_designs.py")
if not components_ready(data):
raise SystemExit("地表或组件设计图尚未由 gpt-image-2.5 生成并登记,请先运行 Tools/generate_battlefield_components.py")
errs=validate(data, True)
if errs: raise SystemExit("\n".join(errs))
print(f"方案 {selected}:{next(x['name'] for x in data['designOptions'] if x['id']==selected)}")
print("地表:", data["ground"]["targetTriangles"], "tris -> Ground/<ground>.fbx")
for item in data["components"]:
status = item.get("generationStatus", "pending")
source = item.get("existingPrefab", "new model") if status == "reused" else "new model"
print(f"{item['type']}: {status} -> {source} -> --faces 50k -> --tris {item.get('targetTriangles',700)} -> Components/{item['type']}/")
print("Prefab:", data["assembly"]["outputPrefab"])
def main():
p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
sel=sub.add_parser("select"); sel.add_argument("design", choices=["A","B","C"])
sub.add_parser("validate"); sub.add_parser("plan")
args=p.parse_args(); data=load()
if args.cmd=="status":
components = data.get("components", [])
print(json.dumps({
"requestedDesign": data.get("requestedDesign"),
"selectedDesign": data.get("selectedDesign"),
"overviewGeneration": data.get("designGeneration", {}).get("status"),
"components": len(components),
"reusedComponents": [x.get("type") for x in components if x.get("generationStatus") == "reused"],
"newComponents": [x.get("type") for x in components if x.get("generationStatus") == "generated"],
"ground": bool(data.get("ground")),
"componentDesignGeneration": data.get("componentDesignGeneration", {}).get("status"),
"modelComponents": [x.get("type") for x in components if x.get("modelGenerationStatus") in {"baked", "imported"}],
"assemblyPrefab": data.get("assembly", {}).get("outputPrefab"),
},ensure_ascii=False))
elif args.cmd=="select":
if not design_ready(data, args.design):
raise SystemExit(f"方案 {args.design} 的设计图尚未由 gpt-image-2.5 生成,请先运行 Tools/generate_battlefield_designs.py --design {args.design}")
data["selectedDesign"]=args.design; MANIFEST.write_text(json.dumps(data,ensure_ascii=False,indent=2)+"\n",encoding="utf-8"); print("selected",args.design)
elif args.cmd=="validate":
errs=validate(data, False); print("PASS" if not errs else "FAIL\n"+"\n".join(errs)); raise SystemExit(1 if errs else 0)
else: plan(data)
if __name__=="__main__": main()
+8
View File
@@ -0,0 +1,8 @@
"""Shared companion/enemy animation production entry point.
Uses the existing tested recipe implementation; old enemy commands remain valid.
"""
from enemy_character_pipeline import main
if __name__ == '__main__':
main()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""非人角色:已验收游戏模 → TokenRig 绑骨 → UniMate 三动作(Idle/Walk/Attack)。
正确流程(与 GameResProductProc 二.3.B 一致):
1. 原混元网页 + model_lowpoly_bake + SetupCharacter 做出静态模(XPbr)
2. 本脚本只吃已验收网格(FBX/GLB),不再走 DGX Hunyuan3D 重做模
3. TokenRig + UniMate(经本机 LLM3DGraphics backend,SSH 到 DGXA)
示例:
python Tools/dgx3d_animal_pipeline.py \\
--mesh AIClient/Assets/Bundles/Characters/Animal/RockBall/RockBall.fbx \\
-o Tools/output/rockball_dgx \\
--name RockBall
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import uuid
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
REPO = Path(__file__).resolve().parents[1]
DEFAULT_LLM3D = Path(
os.environ.get("LLM3D_ROOT", r"D:\UD\AI\XModel\LLM3DGraphics")
)
DEFAULT_MOTIONS = [
("Idle", "a small bipedal character idles with a gentle breathing motion"),
("Walk", "a small bipedal character walks forward upright with alternating short steps and gentle arm swings"),
("Attack", "a small bipedal character makes a short forward body strike"),
]
_BLENDER_CONVERT = r'''
import sys
from pathlib import Path
import bpy
from mathutils import Matrix
from math import pi
argv = sys.argv[sys.argv.index("--") + 1:]
src, dst = Path(argv[0]), Path(argv[1])
bpy.ops.wm.read_factory_settings(use_empty=True)
ext = src.suffix.lower()
if ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=str(src))
# Project static FBX stores Y-up vertices. TokenRig/glTF requires Blender
# Z-up before the glTF exporter performs its Y-up conversion.
if any(o.type == 'ARMATURE' for o in bpy.context.scene.objects):
raise SystemExit('Expected approved STATIC mesh, not an already skinned FBX')
for obj in bpy.context.scene.objects:
if obj.parent is None:
obj.matrix_world = Matrix.Rotation(pi / 2, 4, 'X') @ obj.matrix_world
elif ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=str(src))
else:
raise SystemExit(f"unsupported: {ext}")
# Prefer sibling albedo if FBX came without embeds
parent = src.parent
for name in (src.stem + "_base.png", src.stem + "_color.png", "diffuse.png"):
tex = parent / name
if not tex.is_file():
continue
img = bpy.data.images.load(str(tex), check_existing=True)
for mat in bpy.data.materials:
mat.use_nodes = True
nt = mat.node_tree
nodes, links = nt.nodes, nt.links
principled = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
if principled is None:
continue
tex_node = nodes.new("ShaderNodeTexImage")
tex_node.image = img
links.new(tex_node.outputs["Color"], principled.inputs["Base Color"])
break
dst.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst), export_format="GLB", export_yup=True)
print("OK", dst)
'''
def _ensure_llm3d() -> Path:
root = DEFAULT_LLM3D.resolve()
if not (root / "backend" / "tokenrig_runner.py").is_file():
raise SystemExit(f"LLM3DGraphics 未找到: {root}(可用 LLM3D_ROOT 覆盖)")
if str(root) not in sys.path:
sys.path.insert(0, str(root))
return root
def _blender() -> Path:
_ensure_llm3d()
from backend.settings import blender_path
path = blender_path()
if not path:
raise SystemExit("未配置 Blender(LLM3DGraphics blender_path)")
return Path(path)
def require_dgx_backends() -> None:
_ensure_llm3d()
from backend.tokenrig_runner import tokenrig_ready
from backend.unimate_runner import unimate_ready
tr, uni = bool(tokenrig_ready()), bool(unimate_ready())
if not tr or not uni:
raise SystemExit(
f"DGX 能力红灯:tokenrig_ready={tr} unimate_ready={uni}\n"
"请先 nvsync connect DGXA --detach,并确认 SkinTokens/UniMate 就绪。"
)
def mesh_to_glb(mesh: Path, out_glb: Path) -> Path:
mesh = mesh.resolve()
out_glb = out_glb.resolve()
out_glb.parent.mkdir(parents=True, exist_ok=True)
if mesh.suffix.lower() == ".glb":
if mesh.resolve() != out_glb:
shutil.copy2(mesh, out_glb)
return out_glb
script = out_glb.parent / "_fbx_to_glb.py"
script.write_text(_BLENDER_CONVERT, encoding="utf-8")
blender = _blender()
proc = subprocess.run(
[
str(blender),
"--background",
"--factory-startup",
"--python",
str(script),
"--",
str(mesh),
str(out_glb),
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
if proc.returncode != 0 or not out_glb.is_file():
raise SystemExit(
f"FBX→GLB 失败 rc={proc.returncode}\n"
f"{(proc.stdout or '')[-1500:]}\n{(proc.stderr or '')[-1500:]}"
)
print(f" mesh glb={out_glb} ({out_glb.stat().st_size} bytes)", flush=True)
return out_glb
def extract_rigged_fbx(zip_or_fbx: Path, out_dir: Path) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
if zip_or_fbx.suffix.lower() == ".fbx":
dest = out_dir / zip_or_fbx.name
if zip_or_fbx.resolve() != dest.resolve():
shutil.copy2(zip_or_fbx, dest)
return dest
with zipfile.ZipFile(zip_or_fbx, "r") as zf:
zf.extractall(out_dir)
candidates = list(out_dir.rglob("model.fbx")) + list(out_dir.rglob("*.fbx"))
if not candidates:
raise FileNotFoundError(f"no fbx in {zip_or_fbx}")
for c in candidates:
if c.name.lower() == "model.fbx":
return c
return candidates[0]
def parse_motions(raw: Optional[str], name: str) -> List[Tuple[str, str]]:
if not raw:
if name.lower() != "rockball":
return [
("Idle", f"{name}宠物原地待机,轻微呼吸,可循环"),
("Walk", f"{name}宠物原地走路,可循环"),
("Attack", f"{name}宠物原地短促攻击"),
]
return list(DEFAULT_MOTIONS)
items = json.loads(raw)
return [(str(it["name"]), str(it["prompt"])) for it in items]
def run_pipeline(
mesh: Path,
out_dir: Path,
*,
name: str,
motions: List[Tuple[str, str]],
skip_gate: bool = False,
) -> dict:
mesh = mesh.resolve()
if not mesh.is_file():
raise SystemExit(f"mesh not found: {mesh}")
out_dir = out_dir.resolve()
out_dir.mkdir(parents=True, exist_ok=True)
if not skip_gate:
require_dgx_backends()
else:
print("WARNING: --skip-gate", flush=True)
_ensure_llm3d()
from backend.motion_branches import export_rigged_fbx
from backend.tokenrig_runner import run_tokenrig
import backend.unimate_runner as unimate_runner
from backend.unimate_runner import submit_unimate_job
# Preserve explicit English captions: upstream rewrites every English walk
# to "a dog" and gives Chinese 跳 priority over 走. Log submitted prompts.
original_normalize = unimate_runner.normalize_unimate_prompt
def normalize_prompt(prompt):
if not any('\u4e00' <= char <= '\u9fff' for char in prompt):
return prompt.strip()
return original_normalize(prompt)
unimate_runner.normalize_unimate_prompt = normalize_prompt
# DGXA 上 HF 偶发超时:强制离线用本地 flan-t5 缓存
_orig_ssh = unimate_runner._ssh
def _ssh_offline(host, cmd, timeout=120):
if "run_job.sh" in str(cmd):
cmd = (
"export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 "
"HF_DATASETS_OFFLINE=1; " + cmd
)
return _orig_ssh(host, cmd, timeout=timeout)
unimate_runner._ssh = _ssh_offline
job_id = uuid.uuid4().hex
job_dir = out_dir / job_id
job_dir.mkdir(parents=True, exist_ok=True)
log_path = job_dir / "pipeline.log"
print(f"job {job_id} mesh={mesh}", flush=True)
mesh_glb = mesh_to_glb(mesh, job_dir / "model_ex.glb")
print("TokenRig …", flush=True)
rig_path = run_tokenrig(
mesh_glb, job_dir, log_path, skeleton_template="original"
)
print(f" rigged={rig_path}", flush=True)
zip_cand = job_dir / "rigged_fbx.zip"
print("export rigged FBX zip …", flush=True)
exported = export_rigged_fbx(rig_path, zip_cand, log_path)
if exported is None or not zip_cand.is_file():
raise SystemExit("rigged FBX 导出失败,见 pipeline.log")
rig_extract = job_dir / "rigged"
model_fbx = extract_rigged_fbx(zip_cand, rig_extract)
named = job_dir / f"{name}_Rigged.fbx"
shutil.copy2(model_fbx, named)
anim_dir = job_dir / "anims"
anim_dir.mkdir(exist_ok=True)
anim_paths: Dict[str, str] = {}
for clip_name, prompt in motions:
print(f"animate {clip_name}: {prompt}", flush=True)
stamp = f"{job_id}_{clip_name.lower()}"
last_err: Optional[BaseException] = None
for attempt in range(1, 4):
try:
submit_unimate_job(
rig_path=rig_path,
prompt=prompt,
directory=job_dir,
log_path=log_path,
job_id=f"{stamp}_a{attempt}",
)
last_err = None
break
except Exception as exc: # noqa: BLE001
last_err = exc
print(f" UniMate {clip_name} attempt {attempt} failed: {exc}", flush=True)
time.sleep(5)
if last_err is not None:
raise SystemExit(f"UniMate {clip_name} failed: {last_err}") from last_err
src_fbx = job_dir / "animated.fbx"
if not src_fbx.is_file():
raise SystemExit(f"UniMate 未产出 animated.fbx ({clip_name})")
dest = anim_dir / f"{name}_{clip_name}.fbx"
shutil.copy2(src_fbx, dest)
anim_paths[clip_name] = str(dest.relative_to(out_dir))
glb = job_dir / "animated.glb"
if glb.is_file():
shutil.copy2(glb, dest.with_suffix(".glb"))
print(f" saved {dest}", flush=True)
manifest = {
"ok": True,
"name": name,
"job_id": job_id,
"source_mesh": str(mesh),
"pipeline": "approved_mesh→TokenRig→UniMate",
"rigged_fbx": str(named),
"rigged_zip": str(zip_cand) if zip_cand.is_file() else str(exported),
"anims": anim_paths,
"motions": [{"name": n, "prompt": p} for n, p in motions],
"hint": "Unity: SetupAnimalAnimator 挂回静态模 XPbr;动画用 Anims/*.fbx",
}
(out_dir / f"{name}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
(job_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(manifest, ensure_ascii=False, indent=2), flush=True)
return manifest
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(
description="已验收网格 → TokenRig + UniMate(不重做 DGX 模型)"
)
ap.add_argument(
"--mesh",
"-m",
type=Path,
required=False,
help="已验收游戏模 FBX/GLB(推荐 Bundles/.../{Name}.fbx)",
)
ap.add_argument("-o", "--out", type=Path, required=False)
ap.add_argument("--name", default=None, help="English asset name, e.g. RockBall")
ap.add_argument("--motions-json", default=None)
ap.add_argument("--skip-gate", action="store_true")
ap.add_argument(
"--caps-only",
action="store_true",
help="只检查 TokenRig/UniMate 就绪",
)
ap.add_argument(
"-i",
"--image",
type=Path,
default=None,
help=argparse.SUPPRESS, # 旧图生入口已废弃
)
args = ap.parse_args(argv)
if args.caps_only:
require_dgx_backends()
print(json.dumps({"tokenrig_ready": True, "unimate_ready": True}, indent=2))
return 0
if args.image is not None:
raise SystemExit(
"已废弃:勿再用 -i 设计图走 DGX Hunyuan3D 重做模。\n"
"请先按原流程烘焙验收静态模,再:\n"
" python Tools/dgx3d_animal_pipeline.py --mesh "
"AIClient/Assets/Bundles/Characters/Animal/{Name}/{Name}.fbx "
"-o Tools/output/{name}_dgx --name {Name}"
)
if not args.mesh or not args.out or not args.name:
raise SystemExit("需要 --mesh / -o / --name(或 --caps-only)")
motions = parse_motions(args.motions_json, args.name)
t0 = time.time()
run_pipeline(
args.mesh,
args.out,
name=args.name,
motions=motions,
skip_gate=args.skip_gate,
)
print(f"done in {time.time() - t0:.0f}s", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""尝试通过 NVIDIA Sync 连接 DGXA,直到 LLM3D capabilities 绿灯。"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import urlopen
DEFAULT_BASE = os.environ.get("LLM3D_BASE", "http://192.168.130.16:8008").rstrip("/")
NVSYNC = Path(
os.environ.get(
"NVSYNC_EXE",
str(
Path(os.environ.get("LOCALAPPDATA", ""))
/ "Programs"
/ "nvidia-sync"
/ "resources"
/ "bin"
/ "nvsync-amd64.exe"
),
)
)
def caps(base: str) -> dict:
with urlopen(f"{base}/api/capabilities", timeout=90) as r:
return json.loads(r.read().decode("utf-8"))
def is_ready(c: dict) -> bool:
dgx = c.get("dgx") or {}
return bool(dgx.get("tokenrig_ready") or dgx.get("unirig_ready")) and bool(
dgx.get("unimate_ready")
)
def try_nvsync_connect(alias: str = "DGXA") -> None:
if not NVSYNC.is_file():
print(f"nvsync missing: {NVSYNC}", flush=True)
return
print(f"nvsync connect {alias} --detach ...", flush=True)
try:
subprocess.run(
[str(NVSYNC), "connect", alias, "--detach"],
timeout=90,
check=False,
capture_output=True,
text=True,
)
except subprocess.TimeoutExpired:
print("nvsync connect timed out (device may be offline)", flush=True)
st = subprocess.run(
[str(NVSYNC), "status", alias],
timeout=30,
check=False,
capture_output=True,
text=True,
)
print(st.stdout or st.stderr or "(no status)", flush=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=DEFAULT_BASE)
ap.add_argument("--alias", default="DGXA")
ap.add_argument("--retries", type=int, default=30)
ap.add_argument("--interval", type=float, default=20.0)
ap.add_argument("--no-connect", action="store_true")
args = ap.parse_args()
for i in range(1, args.retries + 1):
print(f"=== attempt {i}/{args.retries} ===", flush=True)
if not args.no_connect:
try_nvsync_connect(args.alias)
try:
c = caps(args.base)
except Exception as exc: # noqa: BLE001
print(f"capabilities error: {exc}", flush=True)
time.sleep(args.interval)
continue
dgx = c.get("dgx") or {}
print(
json.dumps(
{
"tokenrig_ready": dgx.get("tokenrig_ready"),
"unimate_ready": dgx.get("unimate_ready"),
"hy3d_ready": dgx.get("hy3d_ready"),
"tokenrig_detail": dgx.get("tokenrig_detail"),
},
ensure_ascii=False,
),
flush=True,
)
if is_ready(c):
print("READY", flush=True)
return 0
time.sleep(args.interval)
print("NOT_READY", flush=True)
return 2
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""等待 DGXA → 已验收 RockBall.fbx → TokenRig+UniMate → 入库 → SetupAnimalAnimator。"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
NVSYNC = Path(
os.environ.get("LOCALAPPDATA", "")
) / "Programs/nvidia-sync/resources/bin/nvsync-amd64.exe"
def ssh_ready() -> bool:
try:
r = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "DGXA", "echo READY"],
capture_output=True,
text=True,
timeout=20,
)
return r.returncode == 0 and "READY" in (r.stdout or "")
except Exception:
return False
def nvsync_connect() -> None:
if not NVSYNC.is_file():
return
try:
subprocess.run(
[str(NVSYNC), "connect", "DGXA", "--detach"],
timeout=100,
check=False,
capture_output=True,
)
except subprocess.TimeoutExpired:
pass
def main() -> int:
print("=== wait DGXA ===", flush=True)
for attempt in range(1, 121):
print(f"attempt {attempt}", flush=True)
nvsync_connect()
if ssh_ready():
break
time.sleep(15)
else:
print("DGXA still unreachable after wait", flush=True)
return 2
caps = subprocess.run(
[sys.executable, str(REPO / "Tools/dgx3d_animal_pipeline.py"), "--caps-only"],
cwd=str(REPO),
)
if caps.returncode != 0:
return caps.returncode
mesh = REPO / "AIClient/Assets/Bundles/Characters/Animal/RockBall/RockBall.fbx"
cmd = [
sys.executable,
str(REPO / "Tools/dgx3d_animal_pipeline.py"),
"--mesh",
str(mesh),
"-o",
str(REPO / "Tools/output/rockball_dgx"),
"--name",
"RockBall",
]
print("RUN", " ".join(cmd), flush=True)
r = subprocess.run(cmd, cwd=str(REPO))
if r.returncode != 0:
return r.returncode
manifest = REPO / "Tools/output/rockball_dgx/RockBall_manifest.json"
r = subprocess.run(
[sys.executable, str(REPO / "Tools/install_animal_rigged.py"), "-m", str(manifest)],
cwd=str(REPO),
)
if r.returncode != 0:
return r.returncode
r = subprocess.run(
[
"unity",
"command",
"--project-path",
"D:/UD/AI/AIGame/AIClient",
"run_script",
"--file",
"AgentScripts/SetupAnimalAnimator.cs",
"--entry",
"SetupAnimalAnimator.SetupRockBall",
],
cwd=str(REPO),
)
return r.returncode
if __name__ == "__main__":
raise SystemExit(main())
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Download a curated Mixamo animation set for Y Bot (male) and X Bot (female)."""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
API = "https://www.mixamo.com/api/v1"
# Local filenames -> Mixamo search query + preferred exact name
ANIMATIONS = [
("Idle", "Idle", "Idle"),
("Walking", "Walking", "Walking"),
("Running", "Running", "Running"),
("Punching", "Punching", "Punching"),
("Kick", "Kicking", "Kicking"),
("SwordSlash", "Stable Sword Outward Slash", "Stable Sword Outward Slash"),
("HitReaction", "Hit Reaction", "Hit Reaction"),
("FallingFlatImpact", "Falling Flat Impact", "Falling Flat Impact"),
]
CHARACTERS = {
"Male": "Y Bot",
"Female": "X Bot",
}
def headers(token: str) -> dict[str, str]:
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-Api-Key": "mixamo2",
"X-Requested-With": "XMLHttpRequest",
}
def api_request(token: str, method: str, path: str, body: dict | None = None, query: dict | None = None):
url = f"{API}{path}"
if query:
url += "?" + urllib.parse.urlencode(query)
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers(token), method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
if not raw:
return resp.status, None
try:
return resp.status, json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
return resp.status, raw
except urllib.error.HTTPError as e:
raw = e.read()
try:
payload = json.loads(raw.decode("utf-8")) if raw else None
except json.JSONDecodeError:
payload = raw.decode("utf-8", errors="replace") if raw else None
return e.code, payload
def search_products(token: str, query: str, ptype: str, pages: int = 3, limit: int = 96) -> list[dict]:
seen: set[str] = set()
items: list[dict] = []
for page in range(1, pages + 1):
status, data = api_request(
token,
"GET",
"/products",
query={
"page": str(page),
"limit": str(limit),
"order": "",
"type": ptype,
"query": query,
},
)
if status != 200 or not isinstance(data, dict):
raise RuntimeError(f"search failed ({status}): {data}")
for item in data.get("results") or []:
pid = item.get("id")
if not pid or pid in seen:
continue
seen.add(pid)
items.append(item)
num_pages = (data.get("pagination") or {}).get("num_pages") or page
if page >= int(num_pages):
break
return items
def product_label(item: dict) -> str:
return (item.get("name") or item.get("description") or "").strip()
def pick_by_name(results: list[dict], preferred: str) -> dict:
preferred_l = preferred.lower().strip()
exact = [item for item in results if product_label(item).lower() == preferred_l]
if exact:
return exact[0]
contains = [item for item in results if preferred_l in product_label(item).lower()]
if contains:
# Prefer shortest label (closest to exact phrase)
contains.sort(key=lambda item: len(product_label(item)))
return contains[0]
if not results:
raise RuntimeError(f"no products for '{preferred}'")
return results[0]
def resolve_character(token: str, name: str) -> dict:
results = search_products(token, name, "Character", pages=1, limit=24)
item = pick_by_name(results, name)
return {"id": item["id"], "name": product_label(item) or name}
def resolve_animation(token: str, query: str, preferred: str) -> dict:
results = search_products(token, query, "Motion,MotionPack", pages=3)
motions = [r for r in results if (r.get("type") or "").lower() == "motion"]
pool = motions or results
item = pick_by_name(pool, preferred)
return {
"id": item["id"],
"name": product_label(item) or preferred,
"type": item.get("type"),
}
def get_product(token: str, anim_id: str, character_id: str) -> dict:
status, data = api_request(
token,
"GET",
f"/products/{anim_id}",
query={"similar": "0", "character_id": character_id},
)
if status != 200 or not isinstance(data, dict):
raise RuntimeError(f"product {anim_id} failed ({status}): {data}")
return data
def normalize_gms_hash(gms_hash: dict) -> dict:
"""Bake default params into the string form Mixamo export expects."""
out = dict(gms_hash)
params = out.get("params")
if isinstance(params, list):
vals: list[str] = []
for item in params:
if isinstance(item, (list, tuple)) and len(item) >= 2:
vals.append(str(item[1]))
else:
vals.append(str(item))
out["params"] = ",".join(vals)
elif isinstance(params, str) and "=" in params:
vals = []
for part in params.split(","):
part = part.strip()
if not part:
continue
vals.append(part.split("=", 1)[1] if "=" in part else part)
out["params"] = ",".join(vals)
return out
def export_animation(token: str, character_id: str, gms_hash: dict, product_name: str) -> None:
body = {
"character_id": character_id,
"gms_hash": [gms_hash],
"preferences": {
"format": "fbx7_unity",
"skin": "false",
"fps": "30",
"reducekf": "0",
},
"product_name": product_name,
"type": "Motion",
}
for attempt in range(8):
status, data = api_request(token, "POST", "/animations/export", body=body)
if status in (200, 202):
return
if status == 429:
time.sleep(1.5 * (attempt + 1))
continue
raise RuntimeError(f"export failed ({status}): {data}")
raise RuntimeError("export rate-limited too many times")
def monitor_export(token: str, character_id: str, timeout_s: float = 120.0) -> str:
deadline = time.time() + timeout_s
while time.time() < deadline:
status, data = api_request(token, "GET", f"/characters/{character_id}/monitor")
if status == 429:
time.sleep(2)
continue
if status != 200 or not isinstance(data, dict):
time.sleep(1.5)
continue
job_status = (data.get("status") or "").lower()
if job_status == "completed":
result = data.get("job_result")
if isinstance(result, str) and result.startswith("http"):
return result
if isinstance(result, dict):
url = result.get("url") or result.get("result") or result.get("message")
if isinstance(url, str) and url.startswith("http"):
return url
maybe = data.get("message")
if isinstance(maybe, str) and maybe.startswith("http"):
return maybe
raise RuntimeError(f"completed but no url: {data}")
if job_status == "failed":
raise RuntimeError(f"export monitor failed: {data}")
time.sleep(1.2)
raise TimeoutError(f"export monitor timeout for {character_id}")
def download_file(url: str, dest: Path) -> None:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=120) as resp:
data = resp.read()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
def safe_filename(name: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_")
def download_one(
token: str,
character: dict,
anim: dict,
local_name: str,
out_dir: Path,
skip_existing: bool,
) -> Path:
dest = out_dir / f"{local_name}.fbx"
if skip_existing and dest.exists() and dest.stat().st_size > 1000:
print(f" skip existing {dest}")
return dest
product = get_product(token, anim["id"], character["id"])
details = product.get("details") or {}
gms = details.get("gms_hash")
if not gms:
raise RuntimeError(f"no gms_hash for {anim['name']}: {product}")
gms = normalize_gms_hash(gms)
product_name = product.get("name") or anim["name"]
print(f" export {product_name} -> {character['name']}")
export_animation(token, character["id"], gms, product_name)
url = monitor_export(token, character["id"])
print(f" download {url[:80]}...")
download_file(url, dest)
print(f" saved {dest} ({dest.stat().st_size} bytes)")
return dest
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--token-file", type=Path, required=True)
parser.add_argument("--out", type=Path, default=Path("Tools/output/mixamo"))
parser.add_argument("--skip-existing", action="store_true", default=True)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
if not token:
print("empty token", file=sys.stderr)
return 1
print("Resolving characters...")
chars = {}
for gender, name in CHARACTERS.items():
chars[gender] = resolve_character(token, name)
print(f" {gender}: {chars[gender]['name']} ({chars[gender]['id']})")
print("Resolving animations...")
anims = []
for local_name, query, preferred in ANIMATIONS:
anim = resolve_animation(token, query, preferred)
anims.append((local_name, anim))
print(f" {local_name}: {anim['name']} ({anim['id']}) type={anim.get('type')}")
results = []
for gender, character in chars.items():
out_dir = args.out / gender
out_dir.mkdir(parents=True, exist_ok=True)
print(f"\n=== {gender} / {character['name']} ===")
for local_name, anim in anims:
try:
path = download_one(
token, character, anim, local_name, out_dir, args.skip_existing
)
results.append({"gender": gender, "anim": local_name, "path": str(path), "ok": True})
except Exception as e:
print(f" ERROR {local_name}: {e}")
results.append({"gender": gender, "anim": local_name, "error": str(e), "ok": False})
time.sleep(0.8)
manifest = args.out / "manifest.json"
manifest.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
ok = sum(1 for r in results if r["ok"])
print(f"\nDone: {ok}/{len(results)} ok. Manifest: {manifest}")
return 0 if ok == len(results) else 2
if __name__ == "__main__":
raise SystemExit(main())
+193
View File
@@ -0,0 +1,193 @@
"""Recipe-driven character art production: same rig -> three clips -> visual prefab -> QA.
Default is local cached production. --generate explicitly requests DGXA inference.
Unity-managed assets are imported/authored only through Unity Pipeline.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
REPO=Path(__file__).resolve().parents[1]
PROJECT=REPO/'AIClient'
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def resolve(path):
result=(REPO/path).resolve()
if not result.is_relative_to(REPO):
raise ValueError(f'Path leaves repository: {path}')
return result
def recipe(path):
r=json.loads(path.read_text(encoding='utf-8-sig'))
if r.get('schemaVersion')!=1 or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['id']):
raise ValueError('Unsupported schema / invalid actor id')
if r['profile']!='biped_short':
raise ValueError('This offline motion adapter supports biped_short. Add and validate a different anatomy adapter before using it.')
r.setdefault('usage','enemy_visual')
if r['usage'] not in ('enemy_visual','companion_visual'):
raise ValueError('Unsupported character usage')
r.setdefault('visualPrefab',r.get('enemyPrefab',''))
r.setdefault('attackStyle','body_slam')
if r['attackStyle'] not in ('body_slam','ranged_cast'):
raise ValueError('Unsupported attack style')
r.setdefault('attackEvent','OnCompanionAttackImpact' if r['usage']=='companion_visual' else 'OnEnemyAttackImpact')
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['attackEvent']):
raise ValueError('Invalid attack event')
if r.get('boneMappingReady') is False:
raise ValueError('Inspect this character\'s generated rig and complete its bone mapping before production')
if r['assetRoot']!=f"Assets/Bundles/Characters/{r['kind']}/{r['id']}":
raise ValueError('assetRoot must match kind/id')
for key in ('assetRoot','visualPrefab','staticPrefab','reviewScene'):
if not r[key].startswith('Assets/Bundles/') or '..' in Path(r[key]).parts:
raise ValueError(f'Invalid Unity asset path: {key}')
for key in ('approvedMesh','rigSource','outputDir'):
resolve(r[key])
if not (.05<r['height']<20 and 0<r['walkStride']<r['height'] and 0<r['walkLift']<r['height']*.2):
raise ValueError('Invalid dimensions/stride')
if not 0<r['attackWindup']<r['attackImpact']<r['attackRecover']<1:
raise ValueError('Invalid attack phase order')
if not 0<=r.get('attackReach',0)<r['height']*.25:
raise ValueError('Invalid attack reach')
if not 0<r.get('walkSourceWeight',1)<=1:
raise ValueError('Invalid Walk source weight')
if {m['name'] for m in r['motions']}!={'Idle','Walk','Attack'} or len(r['motions'])!=3:
raise ValueError('Exactly Idle, Walk and Attack required')
for m in r['motions']:
resolve(m['sourceDir'])
if not m['prompt'].isascii() or ('jump' in m['prompt'].lower() and m['name']=='Walk'):
raise ValueError('Use explicit English action captions; do not mix walk and jump')
return r
def unity(command,**kwargs):
args=['unity','--json','command','--project-path',str(PROJECT),command]
for k,v in kwargs.items():
args.extend(['--'+k, json.dumps(v,ensure_ascii=False) if isinstance(v,(list,dict,bool)) else str(v)])
proc=subprocess.run(args,cwd=REPO,capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=180)
if proc.returncode:
raise RuntimeError(proc.stderr or proc.stdout)
data=json.loads(proc.stdout)
result=data.get('data',{}).get('result')
if not data.get('success') or isinstance(result,dict) and result.get('success') is False:
raise RuntimeError(json.dumps(data,ensure_ascii=False))
return result
def script(file,entry,args):
result=unity('run_script',file='AgentScripts/'+file+'.cs',entry=entry,args=args)
print(result.get('result',result),flush=True)
return result
def wait_editor(play_mode):
deadline=time.monotonic()+60
while time.monotonic()<deadline:
try:
state=unity('editor_status')
if state['playMode']==play_mode and not state['compiling'] and not state.get('domainReloadInProgress'):
return
except RuntimeError:
pass # Pipeline reconnects during the expected domain reload.
time.sleep(.25)
raise TimeoutError('Editor did not reach '+play_mode)
def cached_sources(r):
rig_hash=sha(resolve(r['rigSource']))
receipts={}
for m in r['motions']:
folder=resolve(m['sourceDir']);receipt=folder/'motion_request.json'
if not receipt.is_file() and (folder/'walk_request.json').is_file():
receipt=folder/'walk_request.json'
if not (folder/'animated.fbx').is_file() and m.get('cachedFbx'):
# Only accept a cache whose accompanying source rig is byte-identical.
if sha(resolve(m['cachedRig']))!=rig_hash:
raise ValueError('Cached action was generated from another rig: '+m['name'])
folder.mkdir(parents=True,exist_ok=True)
shutil.copy2(resolve(m['cachedFbx']),folder/'animated.fbx')
receipt=folder/'motion_request.json'
receipt.write_text(json.dumps(dict(rig_sha256=rig_hash,motion=m['name'],
prompt=m['cachedPrompt'],cache_original=m['cachedFbx'],cache_rig=m['cachedRig'],
note='Existing same-rig generated seed. Offline authoring defines final action semantics.'),indent=2),encoding='utf-8')
if not receipt.is_file() or not (folder/'animated.fbx').is_file():
raise FileNotFoundError('Missing source motion/provenance: '+m['name'])
record=json.loads(receipt.read_text(encoding='utf-8-sig'))
if record['rig_sha256'].lower()!=rig_hash:
raise ValueError('Motion rig hash mismatch: '+m['name'])
if record.get('source_fbx_sha256') and record['source_fbx_sha256']!=sha(folder/'animated.fbx'):
raise ValueError('Motion source FBX hash mismatch: '+m['name'])
receipts[m['name']]=dict(source=str(folder/'animated.fbx'),sha256=sha(folder/'animated.fbx'),request=record)
return receipts
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--recipe',type=Path,required=True)
parser.add_argument('--generate',nargs='+',choices=('Idle','Walk','Attack'))
parser.add_argument('--generation-device',choices=('cuda','cpu'),default='cuda')
parser.add_argument('--build-review',action='store_true')
parser.add_argument('--play-check',action='store_true',help='With --build-review, verify all states in actual Play Mode and return to Edit Mode')
parser.add_argument('--validate-recipe',action='store_true')
args=parser.parse_args()
recipe_path=args.recipe.resolve();r=recipe(recipe_path)
if args.validate_recipe:
print('PASS recipe',r['id']);return
if args.play_check and not args.build_review:
parser.error('--play-check requires --build-review')
unity('set_autotick',enable=True)
status=unity('editor_status')
if status['playMode']!='stopped':raise RuntimeError('Use Edit Mode')
if args.generate:
subprocess.run([sys.executable,str(REPO/'Tools/generate_enemy_motions.py'),'--recipe',str(recipe_path),'--device',args.generation_device,'--motions',*args.generate],cwd=REPO,check=True)
sources=cached_sources(r)
output=resolve(r['outputDir']);output.mkdir(parents=True,exist_ok=True)
(output/'production_manifest.json').write_text(json.dumps(dict(schemaVersion=1,status='building',id=r['id'])),encoding='utf-8')
blender=os.environ.get('BLENDER_EXE',r'D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe')
rigged=output/(r['id']+'_Rigged.fbx')
subprocess.run([blender,'-b','--factory-startup','--python',str(REPO/'Tools/model_tools/bl_transfer_skin.py'),'--',
str(resolve(r['rigSource'])),str(resolve(r['approvedMesh'])),str(rigged),'--low-up',r['lowUp'],'--align-bounds',
'--weight-overrides',str(recipe_path)],cwd=REPO,check=True)
unity('import_asset',source=str(rigged),path=r['assetRoot']+'/Rigged/'+r['id']+'_Rigged.fbx',confirm=True)
for name,record in sources.items():
unity('import_asset',source=record['source'],path=r['assetRoot']+'/Anims/'+r['id']+'_'+name+'.fbx',confirm=True)
script('SetupAnimalAnimator','SetupAnimalAnimator.Run',[r['kind'],r['id'],r['height'],False])
script('FinalizeAnimalWalk','FinalizeAnimalWalk.RunFor',[r['assetRoot'],r['id'],r['height'],r['rootBone'],r['rightFoot'],r['leftFoot'],r['walkStride'],r['walkLift'],r['walkSeconds'],r['outputDir'],r.get('walkBindCalibration',False),r.get('walkSourceWeight',1)])
relative=recipe_path.relative_to(REPO).as_posix()
script('FinalizeEnemyMotions','FinalizeEnemyMotions.Run',[relative])
script('VerifyEnemyMotions','VerifyEnemyMotions.Run',[relative])
script('BuildEnemyReview','BuildEnemyReview.Publish',[relative])
if args.build_review:
script('BuildEnemyReview','BuildEnemyReview.Build',[relative])
script('BuildEnemyReview','BuildEnemyReview.Capture',[relative])
live_result=None
if args.play_check:
unity('editor_play')
try:
wait_editor('playing')
live_result=script('VerifyEnemyMotions','VerifyEnemyMotions.Live',[relative])['result']
prefix='CompanionReview_'+r['id'] if r['usage']=='companion_visual' else 'EnemyReview'
role='companion' if r['usage']=='companion_visual' else 'enemy'
unity('capture_game_view',camera=prefix+'Camera',width=1900,height=760,
save_path='Assets/Screenshots/'+role+'_'+r['id'].lower()+'_play.png')
finally:
unity('editor_stop')
wait_editor('stopped')
(output/'play_verification.txt').write_text(live_result,encoding='utf-8')
manifest=dict(schemaVersion=1,status='validated' if live_result else 'edit_validated',id=r['id'],usage=r['usage'],recipe=relative,
approved_mesh_sha256=sha(resolve(r['approvedMesh'])),rig_sha256=sha(resolve(r['rigSource'])),
sources=sources,rigged_fbx=str(rigged),visual_prefab=r['visualPrefab'],
motions=['Idle','Walk','Attack'],controller=dict(Motion='0=Idle,1=Walk',Attack='Trigger, one shot'),
attack_impact_seconds=r['attackSeconds']*r['attackImpact'],
qa=dict(edit='passed',play=live_result or 'not_run'),
report=str(output/'verification.txt'),generated_at_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
if r['usage']=='enemy_visual':manifest['enemy_prefab']=r['visualPrefab']
(output/'production_manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
print('PASS character production',r['visualPrefab'],flush=True)
if __name__=='__main__':
main()
+95
View File
@@ -0,0 +1,95 @@
"""Generate one UniMate motion from an existing rig, preserving caption/facing.
Project adapter only: does not modify the external LLM3DGraphics checkout.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import sys
import uuid
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--rig', type=Path, required=True)
parser.add_argument('--out', type=Path, required=True)
parser.add_argument('--face-right', required=True)
parser.add_argument('--face-left', required=True)
parser.add_argument('--motion', choices=('Idle','Walk','Attack'), default='Walk')
parser.add_argument('--duration', type=float, default=2)
parser.add_argument('--device', choices=('cuda','cpu'), default='cuda',
help='CPU fallback preserves other DGXA services when GPU memory is full')
parser.add_argument('--prompt')
args = parser.parse_args()
args.prompt = args.prompt or {
'Idle': 'a small bipedal character stands upright and idles with gentle breathing',
'Walk': 'a small bipedal character walks forward upright with alternating short steps and gentle arm swings',
'Attack': 'a small bipedal character performs one forward body slam attack and recovers to standing',
}[args.motion]
root = Path(os.environ.get('LLM3D_ROOT', r'D:\UD\AI\XModel\LLM3DGraphics'))
sys.path.insert(0, str(root))
import backend.unimate_runner as runner
args.out.mkdir(parents=True, exist_ok=True)
stamp=uuid.uuid4().hex
working=args.out/'generations'/stamp
working.mkdir(parents=True,exist_ok=False)
rig = args.rig.resolve()
if not rig.is_file():
raise SystemExit(f'Missing rig: {rig}')
# The upstream English normalizer replaces all walk captions with "a dog";
# the Chinese old caption contained 跳 and became a jump. Keep this caption.
runner.normalize_unimate_prompt = lambda caption: caption.strip()
original_cfg = runner._cfg
helper_name = 'aigame_' + args.motion.lower() + '_' + uuid.uuid4().hex[:8] + '_helpers'
runner._cfg = lambda: {**original_cfg(), 'unimate_helpers_dir': original_cfg()['unimate_jobs_dir'] + '/' + helper_name}
original_scp = runner._scp_to
def upload(local, remote, host, timeout=600):
if remote.endswith('/run_job.sh'):
text = Path(local).read_text(encoding='utf-8')
marker = 'face_r, face_l = _detect_face_pair(arm)'
if marker not in text:
raise RuntimeError('Upstream facing adapter changed; inspect before generating.')
text = text.replace(marker, f'face_r, face_l = {args.face_right!r}, {args.face_left!r}')
config_marker='cfg["dataset"]["dataset_list"] = ["objaverse"]'
if config_marker not in text:
raise RuntimeError('Upstream inference configuration changed; inspect device adapter.')
text=text.replace(config_marker,config_marker+'\ncfg["sampling"]["device"] = '+repr(args.device))
local = working / 'run_motion_job.sh'
local.write_text(text, encoding='utf-8', newline='\n')
return original_scp(local, remote, host, timeout)
runner._scp_to = upload
original_ssh = runner._ssh
def offline(host, command, timeout=120):
if 'run_job.sh' in command:
command = 'export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HF_DATASETS_OFFLINE=1; ' + command
return original_ssh(host, command, timeout)
runner._ssh = offline
provenance = dict(rig=str(rig), rig_sha256=hashlib.sha256(rig.read_bytes()).hexdigest(),
prompt=args.prompt, face_right=args.face_right, face_left=args.face_left,
motion=args.motion,duration=args.duration,device=args.device)
(working / 'motion_request.json').write_text(json.dumps(provenance, indent=2), encoding='utf-8')
print(json.dumps(provenance), flush=True)
result = runner.submit_unimate_job(rig_path=rig, prompt=args.prompt,
directory=working.resolve(), log_path=working.resolve() / 'pipeline.log',
job_id='aigame_' + args.motion.lower() + '_' + uuid.uuid4().hex, duration_sec=args.duration)
provenance['source_fbx_sha256']=hashlib.sha256((working/'animated.fbx').read_bytes()).hexdigest()
provenance['status']='generated'
provenance['remote_job']=result['remote_job']
# Promote artifacts only after successful inference; commit receipt last.
# A failed generation must never relabel an older cached FBX as a new result.
for file in list(working.iterdir()):
if file.is_file() and file.name!='motion_request.json':
os.replace(file,args.out/file.name)
receipt=working/'motion_request.json'
receipt.write_text(json.dumps(provenance,indent=2),encoding='utf-8')
os.replace(receipt,args.out/'motion_request.json')
if args.motion=='Walk':
(args.out/'walk_request.json').write_text(json.dumps(provenance,indent=2),encoding='utf-8')
print(json.dumps(result), flush=True)
if __name__ == '__main__':
main()
+222
View File
@@ -0,0 +1,222 @@
"""Generate the selected battlefield's ground and reusable prop design sheets.
The selected overview must already be generated by the project
``gpt-image-2.5`` flow. Each request uses the main Candy home illustration as
a style reference and the selected overview as a layout reference.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "Doc/Design/Scene/Battlefield/battlefield_scene_manifest.json"
PROMPTS_DIR = ROOT / "Doc/Design/Scene/Battlefield"
IMAGE_TOOL = ROOT / "Tools/gpt_image_generate.py"
ASSET_ROOT = ROOT / "AIClient/Assets/Bundles"
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8-sig"))
def save(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def generated_overview(manifest: dict, design_id: str) -> bool:
option = next((x for x in manifest.get("designOptions", []) if x.get("id") == design_id), None)
generation = (option or {}).get("generation", {})
return generation.get("model") == "gpt-image-2.5" and generation.get("status") == "generated"
def find_existing_prefab(component_type: str) -> str | None:
"""Return an authored prefab for a reusable component, if one exists.
The search is intentionally limited to the game asset root. Intermediate
GLB/FBX files in Tools/output are not considered reusable Unity components.
"""
if not component_type:
return None
candidates = sorted(ASSET_ROOT.rglob(f"{component_type}.prefab"))
if not candidates:
return None
preferred = [p for p in candidates if "Scenes" in p.parts and component_type in p.parts]
path = (preferred[0] if preferred else candidates[0]).resolve()
return str(path.relative_to(ROOT)).replace("\\", "/")
def ensure_component_entry(manifest: dict, item: dict) -> dict:
component_type = item.get("type")
allowed = manifest.setdefault("allowedComponentTypes", [])
if component_type and component_type not in allowed:
allowed.append(component_type)
component = next((x for x in manifest.setdefault("components", []) if x.get("type") == item.get("type")), None)
if component is None:
component = {
"type": item.get("type"),
"label": item.get("type"),
"targetTriangles": item.get("targetTriangles", 700),
"generationStatus": "pending",
}
manifest["components"].append(component)
for key in ("position", "rotation", "scale", "placements"):
if key in item:
component[key] = item[key]
return component
def main() -> int:
parser = argparse.ArgumentParser(description="Generate ground and prop design sheets for a battlefield design.")
parser.add_argument("--design", default="A", help="方案 ID;对应 Doc/Design/Scene/Battlefield/<ID>/battlefield_component_prompts.json")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--force-new-components",
action="store_true",
help="即使 Assets/Bundles 中已有同名组件 Prefab,也重新生成该组件设计图",
)
parser.add_argument(
"--use-references",
action="store_true",
help="上传主界面与总览图作为参考;默认关闭,避免外传项目内部图片",
)
parser.add_argument("--quality", default="auto", choices=["auto", "low", "medium", "high"])
args = parser.parse_args()
manifest = load(MANIFEST)
if not generated_overview(manifest, args.design) and not args.dry_run:
raise SystemExit(
f"方案 {args.design} 的总览图尚未由 gpt-image-2.5 生成;"
f"请先运行 python Tools/generate_battlefield_designs.py --design {args.design}"
)
if not generated_overview(manifest, args.design):
print("warning: dry-run uses the current overview path; actual generation requires the AI-generated overview")
prompt_path = PROMPTS_DIR / args.design / "battlefield_component_prompts.json"
if not prompt_path.is_file():
raise SystemExit(f"缺少方案 {args.design} 的组件拆分提示词:{prompt_path}")
prompt_data = load(prompt_path)
style = ROOT / prompt_data["styleReference"]
overview = ROOT / prompt_data["overviewReference"]
output_root = PROMPTS_DIR / args.design
jobs = [prompt_data["ground"], *prompt_data["components"]]
# A manifest describes one selected level at a time. Remove component
# entries from a previous design before adding this design's split list;
# shared types are retained so their authored Prefab can be reused.
job_types = {item.get("type") for item in prompt_data.get("components", [])}
manifest["components"] = [
component for component in manifest.get("components", []) if component.get("type") in job_types
]
reused = []
generated = []
for item in jobs:
component_type = item.get("type")
existing_prefab = (
find_existing_prefab(component_type)
if component_type and item.get("reuseExisting", True) is not False
else None
)
if component_type and existing_prefab and not args.force_new_components:
component = ensure_component_entry(manifest, item)
component.update(
{
"generationStatus": "reused",
"reuseExisting": True,
"existingPrefab": existing_prefab,
"prefab": component.get("prefab") or existing_prefab,
"designImage": component.get("designImage", ""),
}
)
reused.append(component_type)
print(f"[{component_type}] reuse {existing_prefab}; skip new design image")
continue
output = output_root / item["file"]
output.parent.mkdir(parents=True, exist_ok=True)
callback = output.with_name(output.stem + "_gpt_image_status.json")
command = [
sys.executable,
str(IMAGE_TOOL),
"--prompt",
item["prompt"],
"--model",
"gpt-image-2.5",
"--aspect-ratio",
"1:1",
"--quality",
args.quality,
"--output",
str(output),
"--callback-file",
str(callback),
"--result-only",
]
if args.use_references:
command[command.index("--model"):command.index("--model")] = [
"--image",
str(style),
"--image",
str(overview),
]
print(f"[{item.get('type', 'ground')}] {output}")
print(" ".join(json.dumps(part, ensure_ascii=False) for part in command))
if args.dry_run:
continue
try:
subprocess.run(command, cwd=ROOT, check=True)
except subprocess.CalledProcessError as exc:
print(
"component image generation failed; configure GRSAI_API_KEY or "
"GPT_IMAGE_API_KEY and rerun this command",
file=sys.stderr,
)
return exc.returncode or 1
rel_output = str(output.relative_to(ROOT)).replace("\\", "/")
rel_status = str(callback.relative_to(ROOT)).replace("\\", "/")
if "type" not in item:
manifest["ground"].update(
{
"designImage": rel_output,
"designGenerationStatus": "generated",
"designStatusFile": rel_status,
}
)
else:
component = ensure_component_entry(manifest, item)
component.update(
{
"designImage": rel_output,
"generationStatus": "generated",
"statusFile": rel_status,
"reuseExisting": False,
"existingPrefab": "",
"prefab": "",
"modelGenerationStatus": "pending",
}
)
generated.append(component_type)
if not args.dry_run:
manifest["componentDesignGeneration"] = {
"tool": "Tools/gpt_image_generate.py",
"model": "gpt-image-2.5",
"styleReference": prompt_data["styleReference"] if args.use_references else "prompt-described",
"overviewReference": prompt_data["overviewReference"] if args.use_references else "prompt-described",
"referenceUpload": "enabled" if args.use_references else "none",
"status": "generated" if generated else "reused",
"generatedAtUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"generatedComponents": generated,
"reusedComponents": reused,
}
save(MANIFEST, manifest)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+111
View File
@@ -0,0 +1,111 @@
"""Generate battlefield concept boards with the project image generation flow.
This script delegates image creation to ``Tools/gpt_image_generate.py`` with
the project-standard ``gpt-image-2.5`` model. It deliberately does not draw
placeholder diagrams with PIL. Use ``--dry-run`` to inspect requests without
calling the remote service.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "Doc/Design/Scene/Battlefield"
PROMPT_FILE = OUT / "battlefield_design_prompts.json"
IMAGE_TOOL = ROOT / "Tools/gpt_image_generate.py"
def load_prompts() -> dict:
return json.loads(PROMPT_FILE.read_text(encoding="utf-8-sig"))
def mark_generated(design_id: str, output: Path, status_file: Path) -> None:
manifest_path = OUT / "battlefield_scene_manifest.json"
if not manifest_path.is_file():
return
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
for option in manifest.get("designOptions", []):
if option.get("id") == design_id:
option.setdefault("generation", {}).update(
{
"tool": "Tools/gpt_image_generate.py",
"model": "gpt-image-2.5",
"status": "generated",
"output": str(output.relative_to(ROOT)).replace("\\", "/"),
"statusFile": str(status_file.relative_to(ROOT)).replace("\\", "/"),
"generatedAtUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
)
if manifest.get("requestedDesign") == design_id:
manifest["selectedDesign"] = design_id
manifest.setdefault("designGeneration", {}).update(
{
"tool": "Tools/generate_battlefield_designs.py",
"imageTool": "Tools/gpt_image_generate.py",
"model": "gpt-image-2.5",
"status": "generated",
"generatedAtUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"note": "已生成并覆盖总览图。",
}
)
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(
description="Use gpt-image-2.5 to generate the three battlefield design boards."
)
parser.add_argument("--design", choices=["A", "B", "C", "all"], default="all")
parser.add_argument("--dry-run", action="store_true", help="print requests without calling the image API")
parser.add_argument("--quality", default="auto", choices=["auto", "low", "medium", "high"])
args = parser.parse_args()
data = load_prompts()
selected = data["designs"] if args.design == "all" else [next(x for x in data["designs"] if x["id"] == args.design)]
OUT.mkdir(parents=True, exist_ok=True)
for item in selected:
output = OUT / item["file"]
callback = OUT / f"{output.stem}_gpt_image_status.json"
command = [
sys.executable,
str(IMAGE_TOOL),
"--prompt",
item["prompt"],
"--model",
"gpt-image-2.5",
"--aspect-ratio",
"1024x1024",
"--quality",
args.quality,
"--output",
str(output),
"--callback-file",
str(callback),
"--result-only",
]
print(f"[{item['id']}] {item['name']}")
print(" ".join(json.dumps(part, ensure_ascii=False) for part in command))
if args.dry_run:
continue
try:
subprocess.run(command, cwd=ROOT, check=True)
except subprocess.CalledProcessError as exc:
print(
"image generation failed; configure GRSAI_API_KEY or "
"GPT_IMAGE_API_KEY and rerun this command",
file=sys.stderr,
)
return exc.returncode or 1
mark_generated(item["id"], output, callback)
print(f"saved: {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+45
View File
@@ -0,0 +1,45 @@
"""Generate missing enemy actions sequentially on the recipe's SAME rig."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import sys
REPO=Path(__file__).resolve().parents[1]
def seal_generated_motion(folder):
"""Attach output hashes after the backend has completed its own manifest."""
receipt=folder/'motion_request.json'
record=json.loads(receipt.read_text(encoding='utf-8-sig'))
result=json.loads((folder/'unimate_manifest.json').read_text(encoding='utf-8-sig'))
if result['prompt']!=record['prompt']:
raise ValueError('Generated output caption differs from request')
record.update(status='generated',remote_job=result['remote_job'],
source_fbx_sha256=hashlib.sha256((folder/'animated.fbx').read_bytes()).hexdigest())
temporary=receipt.with_suffix('.pending.json')
temporary.write_text(json.dumps(record,indent=2),encoding='utf-8')
temporary.replace(receipt)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--recipe',type=Path,required=True)
parser.add_argument('--motions',nargs='+',choices=('Idle','Walk','Attack'),default=['Idle','Walk','Attack'])
parser.add_argument('--device',choices=('cuda','cpu'),default='cuda')
args=parser.parse_args()
recipe=json.loads(args.recipe.read_text(encoding='utf-8-sig'))
for motion in recipe['motions']:
if motion['name'] not in args.motions:
continue
command=[sys.executable,str(REPO/'Tools/generate_animal_walk.py'),
'--rig',str(REPO/recipe['rigSource']),'--out',str(REPO/motion['sourceDir']),
'--motion',motion['name'],'--duration',str(motion['duration']),
'--device',args.device,
'--face-right',recipe['faceRight'],'--face-left',recipe['faceLeft'],
'--prompt',motion['prompt']]
print('GENERATE',recipe['id'],motion['name'],flush=True)
subprocess.run(command,cwd=REPO,check=True)
seal_generated_motion(REPO/motion['sourceDir'])
if __name__=='__main__':
main()
+113
View File
@@ -0,0 +1,113 @@
"""Clash of Critters-inspired home layout; JSON is the source of every UI node."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ICONS = ['coin', 'diamond', 'energy', 'avatar', 'tasks', 'gift', 'mail', 'trophy',
'bag', 'home', 'battle', 'pets', 'chest', 'calendar', 'settings', 'pinball']
def generate():
theme = 'candy'
name = 'UI_MainHUD'
panel, primary, secondary, ink, muted, light = ('ui_panel_content', 'btn_primary_normal', 'btn_secondary_normal', '#5A4632', '#687C8C', '#F6F8FF')
nodes, events = [], []
def box(x, y, w, h, anchor=(0, 1), pivot=(0, 1)):
return dict(anchorMin=list(anchor), anchorMax=list(anchor), pivot=list(pivot), anchoredPosition=[x, y], sizeDelta=[w, h])
def full():
return dict(anchorMin=[0, 0], anchorMax=[1, 1], offsetMin=[0, 0], offsetMax=[0, 0])
def node(n, p, rect, comps=(), active=True):
nodes.append(dict(name=n, parent=p, active=active, rect=rect, components=list(comps)))
def image(sprite, color='#FFFFFF', ray=False, sliced=True):
return dict(type='Image', sprite=sprite, color=color, raycastTarget=ray, imageType='Sliced' if sliced else 'Simple')
def img(n, p, x, y, w, h, sprite, color='#FFFFFF', ray=False, sliced=True):
node(n, p, box(x, -y, w, h), [image(sprite, color, ray, sliced)])
def label(n, p, x, y, w, h, value, size=30, color=None, align='Left', outlined=False):
comps = [dict(type='TextMeshProUGUI', text=value, fontSize=size, color=color or ink, alignment=align, raycastTarget=False)]
if outlined:
comps.append(dict(type='Outline', effectColor='#102033CC', effectDistance=[1.5, -1.5]))
node(n, p, box(x, -y, w, h), comps)
def action(n, p, x, y, w, h, sprite, event, tint='#FFFFFF'):
b = dict(type='Button', transition='ColorTint', normalColor='#FFFFFF', highlightedColor='#F2FAFF', pressedColor='#C5D6C0', disabledColor='#B9C0C8', onClick=event)
if sprite in [primary, secondary]:
b = dict(type='Button', transition='SpriteSwap', spritePressed=sprite.replace('normal', 'pressed'), onClick=event)
node(n, p, box(x, -y, w, h), [image(sprite, tint, True), b])
events.append(dict(name=event, node=n))
panel_color = '#FFFFFF'
primary_color = '#FFFFFF'
node(name, None, full(), [dict(type='Canvas', sortingOrder=100), dict(type='CanvasScaler', referenceResolution=[1080, 1920], matchWidthOrHeight=0), dict(type='GraphicRaycaster')])
node('Img_Camp', name, full(), [image('home_camp', sliced=False)])
# A fixed portrait composition inside a stretch canvas remains readable on taller phones.
node('Layout_Top', name, box(0, 0, 1080, 460, (0.5, 1), (0.5, 1)))
node('Layout_Sides', name, box(0, 0, 1080, 650, (0.5, 0.5), (0.5, 0.5)))
node('Layout_Bottom', name, box(0, 0, 1080, 600, (0.5, 0), (0.5, 0)))
action('Btn_Profile', 'Layout_Top', 32, 48, 310, 134, panel, 'MainHUD.Profile', panel_color)
img('Img_Avatar', 'Btn_Profile', 10, 6, 114, 114, 'home_ico_avatar', sliced=False)
label('Txt_PlayerName', 'Btn_Profile', 132, 20, 158, 46, '探险家', 34)
label('Txt_Level', 'Btn_Profile', 132, 72, 158, 34, 'Lv. 1', 26, muted)
for key, x, value in [('coin', 356, '0'), ('diamond', 590, '0'), ('pinballs', 824, '20')]:
n = 'Btn_' + key.capitalize()
action(n, 'Layout_Top', x, 72, 220, 82, panel, 'MainHUD.' + key.capitalize(), panel_color)
img('Img_' + key.capitalize(), n, 8, 10, 60, 60, 'home_ico_' + ('pinball' if key == 'pinballs' else key), sliced=False)
label('Txt_' + key.capitalize(), n, 70, 14, 134, 50, value, 30, align='Center')
node('Panel_Top', name, box(0, -214, 452, 86, (0.5, 1), (0.5, 1)), [image('ui_panel_header', panel_color)])
label('Txt_TownTitle', 'Panel_Top', 30, 16, 392, 52, '小镇营地', 42, light, 'Center', True)
label('Txt_Status', 'Layout_Top', 290, 304, 500, 36, '欢迎回到小镇', 22, '#FFFFFF', 'Center', True)
node('Txt_Pid', 'Layout_Top', box(0, 0, 1, 1), [dict(type='TextMeshProUGUI', text='pid=-', fontSize=20, color=muted, raycastTarget=False)], False)
action('Btn_Tasks', 'Layout_Top', 32, 358, 458, 108, panel, 'MainHUD.Tasks', panel_color)
img('Img_TaskIcon', 'Btn_Tasks', 14, 10, 82, 82, 'home_ico_tasks', sliced=False)
label('Txt_TaskCaption', 'Btn_Tasks', 108, 14, 318, 36, '教学目标', 24, muted)
label('Txt_Task', 'Btn_Tasks', 108, 50, 318, 44, '发射第一枚弹珠', 28)
img('Dot_Tasks', 'Btn_Tasks', 422, 0, 24, 24, 'toggle_knob', '#FF5B5B', sliced=False)
action('Btn_Settings', 'Layout_Top', 948, 222, 96, 96, panel, 'MainHUD.Settings', panel_color)
img('Img_Settings', 'Btn_Settings', 16, 16, 64, 64, 'home_ico_settings', sliced=False)
for key, title, x, y, ico, dot in [
('Events', '活动', 32, 4, 'gift', True), ('Daily', '签到', 32, 174, 'calendar', True),
('Rank', '排行', 32, 344, 'trophy', False), ('Mail', '邮件', 902, 4, 'mail', True),
('Rewards', '宝箱', 902, 174, 'chest', True), ('Bag', '背包', 902, 344, 'bag', False)]:
n = 'Btn_' + key
action(n, 'Layout_Sides', x, y, 146, 148, panel, 'MainHUD.' + key, panel_color)
img('Img_' + key, n, 26, 8, 94, 94, 'home_ico_' + ico, sliced=False)
label('Txt_' + key, n, 8, 102, 130, 36, title, 28, align='Center')
if dot:
img('Dot_' + key, n, 116, 2, 24, 24, 'toggle_knob', '#FF5B5B', sliced=False)
img('Panel_Greeting', 'Layout_Sides', 428, 510, 394, 64, panel, panel_color)
label('Txt_Greeting', 'Panel_Greeting', 16, 10, 362, 44, '今天也一起出发吧!', 27, align='Center')
img('Panel_Adventure', 'Layout_Bottom', 32, 12, 1016, 320, panel, panel_color)
label('Txt_Chapter', 'Panel_Adventure', 30, 20, 600, 46, '第 1 章 · 绿野初程', 34)
label('Txt_Stage', 'Panel_Adventure', 740, 26, 240, 36, '当前关卡 1-1', 26, muted, 'Right')
label('Txt_RewardCaption', 'Panel_Adventure', 30, 80, 300, 34, '冒险奖励', 24, muted)
for key, x, value in [('coin', 24, '×80'), ('pinball', 162, '×3')]:
img('Img_Reward_' + key, 'Panel_Adventure', x, 116, 80, 80, 'home_ico_' + ('pinball' if key == 'pinballs' else key), sliced=False)
label('Txt_Reward_' + key, 'Panel_Adventure', x - 6, 200, 100, 36, value, 26, align='Center')
action('Btn_Adventure', 'Panel_Adventure', 362, 90, 618, 138, primary, 'MainHUD.Adventure', primary_color)
img('Img_Adventure', 'Btn_Adventure', 30, 22, 92, 92, 'home_ico_battle', sliced=False)
label('Txt_Adventure', 'Btn_Adventure', 134, 34, 450, 62, '开始冒险', 48, light, 'Center', True)
label('Txt_AdventureHint', 'Panel_Adventure', 362, 244, 618, 42, '首通另获糖果 40 · 不消耗体力', 25, muted, 'Center')
img('Panel_Navigation', 'Layout_Bottom', 32, 356, 1016, 180, panel, panel_color)
for i, (key, caption, ico) in enumerate([('Shop', '商店', 'chest'), ('Pets', '伙伴', 'pets'), ('Home', '营地', 'home'), ('Battle', '冒险', 'battle'), ('Pinball', '弹珠', 'pinball')]):
n = 'Btn_Nav' + key
action(n, 'Panel_Navigation', 12 + i * 198, 8, 194, 164, primary if key == 'Home' else panel, 'MainHUD.' + key, primary_color if key == 'Home' else panel_color)
img('Img_Nav' + key, n, 49, 8, 96, 96, 'home_ico_' + ico, sliced=False)
label('Txt_Nav' + key, n, 12, 114, 170, 40, caption, 30, light if key == 'Home' else ink, 'Center', key == 'Home')
# Last sibling overlays the scene but does not cover bottom navigation.
node('Panel_Reconnect', name, box(0, 632, 1016, 154, (0.5, 0), (0.5, 0)), [image(panel, panel_color, True)], False)
label('Txt_Reconnect', 'Panel_Reconnect', 26, 24, 638, 104, '网络已断开,正在重连…', 27)
action('Btn_Reconnect', 'Panel_Reconnect', 690, 34, 294, 88, primary, 'MainHUD.Reconnect', primary_color)
label('Txt_ReconnectBtn', 'Btn_Reconnect', 14, 16, 266, 56, '立即重连', 30, light, 'Center', True)
bindings = ['Txt_Status', 'Txt_Pid', 'Panel_Reconnect', 'Txt_Reconnect', 'Btn_Reconnect', 'Txt_PlayerName', 'Txt_Level', 'Txt_Coin', 'Txt_Diamond', 'Txt_Pinballs', 'Txt_Chapter', 'Txt_Stage', 'Txt_Task']
bindings += [e['node'] for e in events if e['node'] not in bindings]
sprites = {c[k] for n in nodes for c in n['components'] for k in ['sprite', 'spritePressed'] if c.get(k)}
def path(s):
folder = 'Home' if s.startswith('home_') else 'Common'
return f'Assets/Bundles/UI/Texture/{folder}/{s}.png'
doc = dict(schemaVersion=1, prefabName=name, prefabPath=f'Assets/Bundles/UI/Prefab/{name}.prefab',
description=f'HUD 主界面 / {theme}。参考 Clash of Critters 营地式入口布局,采用项目主题;1080×1920,JSON→Prefab。中央为原创营地插画,G0 资源与训练/教学关进度由 PrototypeGame 存档绑定,功能入口通过 MainHUD.* 事件接入。保留网络状态和重连绑定,无主题切换按钮。',
canvas=dict(canvasScaler=dict(referenceResolution=[1080, 1920], matchWidthOrHeight=0)),
assets=dict(sprites={s:path(s) for s in sorted(sprites)}), nodes=nodes, bindings=bindings, events=events)
(ROOT / 'Doc/UIPrefabCreater' / (name + '.json')).write_text(json.dumps(doc, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f'{name}: {len(nodes)} nodes, {len(events)} events')
if __name__ == '__main__':
generate()
+197
View File
@@ -0,0 +1,197 @@
"""Generate the Candy camp main-menu BGM using the project's DGX music guide.
One generation only; retain request, raw response and server metadata for review.
Requires only Python's standard library. Network access to DGXA is required.
"""
import argparse
import json
from pathlib import Path
import threading
import time
import urllib.parse
import urllib.request
import uuid
BASE = "http://192.168.132.46:7865"
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "Tools/output/main_menu_music"
PROMPT = (
"Original instrumental background music for a cozy, colorful Candy-style "
"mobile game's main menu: a sunny miniature village camp, cream cottages, "
"green meadows, a little fountain, friendly cute animal companions and a "
"gentle sense of preparing for an adventure. Warm, welcoming, playful and "
"relaxed, cheerful without being hyperactive. A memorable but understated "
"short pentatonic melody on soft marimba and wooden xylophone, answered by "
"a mellow flute or clarinet; light nylon-string acoustic guitar arpeggios, "
"pizzicato strings, rounded upright bass and very delicate shaker and "
"woodblock percussion. A few soft celesta sparkles, warm acoustic chamber "
"arrangement, moderate gently bouncing walking pace, clear major-key "
"harmonies. Instrumental only, no singing, no humming, no choir, no speech. "
"Small recurring A and B phrases with subtle variations, stable gentle "
"dynamics and lots of breathing space for menu interactions. Designed for "
"comfortable repeated listening, a short pickup and a final phrase that "
"naturally returns to the opening harmony, loop-friendly structure, no "
"dramatic buildup, no big finale, no heavy drums, no aggressive brass, "
"no distorted guitars, no EDM drops, no environmental sound effects. "
"Polished clean stereo game soundtrack."
)
def save(name, value):
(OUTPUT / name).write_text(
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
)
def get_json(path, payload=None):
request = urllib.request.Request(
BASE + path,
data=None if payload is None else json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
def main():
global OUTPUT, PROMPT
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=OUTPUT)
parser.add_argument("--prompt-file", type=Path)
args = parser.parse_args()
OUTPUT = args.output.resolve()
if args.prompt_file:
PROMPT = args.prompt_file.read_text(encoding="utf-8").strip()
if not PROMPT:
raise SystemExit("Music prompt must not be empty")
OUTPUT.mkdir(parents=True, exist_ok=True)
if (OUTPUT / "request.json").exists():
if not (OUTPUT / "joined.json").exists():
raise SystemExit("Existing request without receipt; inspect before submitting again.")
if (OUTPUT / "response.json").exists():
raise SystemExit("Existing response found; do not submit another generation.")
resume = json.loads((OUTPUT / "request.json").read_text(encoding="utf-8"))
else:
resume = None
config = get_json("/config")
info = get_json("/gradio_api/info")
parameters = info["named_endpoints"]["/generation_wrapper"]["parameters"]
components = {component["id"]: component for component in config["components"]}
dependency = next(
item for item in config["dependencies"] if item.get("api_name") == "generation_wrapper"
)
# Map public parameter names to live inputs, skipping hidden State entries.
# In this service parameter names retain the original component index.
overrides = {"param_0": PROMPT, "param_1": "", "param_6": 50,
"param_11": -1, "param_12": 1}
data = []
public_index = 0
for component_id in dependency["inputs"]:
component = components[component_id]
if component["type"] == "state":
data.append(None)
continue
parameter = parameters[public_index]
public_index += 1
data.append(overrides.get(parameter["parameter_name"],
component.get("props", {}).get("value")))
assert public_index == len(parameters), "Public parameter/input mismatch"
assert data[6] == 50 and data[12] == 1
session = str(uuid.uuid4())
payload = {"data": data, "fn_index": dependency["id"], "session_hash": session}
if resume:
payload = resume["payload"]
session = payload["session_hash"]
print("resuming existing job (no new generation)", flush=True)
else:
save("request.json", {"service": BASE, "protocol": config.get("protocol"),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"payload": payload, "parameters": parameters})
ready = threading.Event()
finished = threading.Event()
state = {"events": []}
def read_sse():
try:
url = BASE + "/gradio_api/queue/data?" + urllib.parse.urlencode({"session_hash": session})
deadline = time.monotonic() + 1800
while time.monotonic() < deadline:
with urllib.request.urlopen(url, timeout=1800) as response:
ready.set()
for raw_line in response:
line = raw_line.decode("utf-8").strip()
if not line.startswith("data:"):
continue
event = json.loads(line[5:])
message = event.get("msg")
if message == "heartbeat":
continue
# Gradio may reject an SSE subscription until join has
# created the session; reconnect to the SAME session.
if event.get("session_not_found"):
break
state["events"].append(event)
save("events.json", state["events"])
if message in {"estimation", "process_starts"}:
print(message, flush=True)
if message == "process_completed":
state["result"] = event
finished.set()
return
time.sleep(1)
except Exception as error:
state["error"] = repr(error)
finally:
ready.set()
finished.set()
reader = threading.Thread(target=read_sse, daemon=True)
reader.start()
if not ready.wait(60) or state.get("error"):
raise SystemExit(f"Could not open queue stream: {state.get('error')}")
if resume:
joined = json.loads((OUTPUT / "joined.json").read_text(encoding="utf-8"))
else:
joined = get_json("/gradio_api/queue/join", payload)
save("joined.json", joined)
print("submitted", joined["event_id"], flush=True)
if not finished.wait(1800):
raise SystemExit("Generation timeout; inspect the existing job before resubmitting.")
if state.get("error"):
raise SystemExit(state["error"])
result = state.get("result")
if not result or result.get("event_id") != joined["event_id"]:
raise SystemExit("No matching completion event")
save("response.json", result)
if not result.get("success", True):
raise SystemExit(json.dumps(result.get("output"), ensure_ascii=True))
files = {}
def walk(value):
if isinstance(value, dict):
url = value.get("url")
name = str(value.get("orig_name") or value.get("path") or "")
if url and name.lower().endswith((".mp3", ".json")):
files[url] = Path(name).name
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(result["output"]["data"])
if not any(name.endswith(".mp3") for name in files.values()):
raise SystemExit("No downloadable MP3 URL in response; inspect response.json")
for url, name in files.items():
# Use the URL returned by the service; never invent a file endpoint.
url = urllib.parse.urljoin(BASE, url)
urllib.request.urlretrieve(url, OUTPUT / name)
print("saved", name, flush=True)
save("downloaded.json", files)
print("Generation Complete", flush=True)
if __name__ == "__main__":
main()
+402
View File
@@ -0,0 +1,402 @@
"""Generate every G0 screen as project JSON. Runtime code only binds and updates these nodes."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG = json.loads((ROOT / 'Config/Gameplay/greenfield.json').read_text(encoding='utf-8'))
INK, MUTED = '#5A4632', '#687C8C'
class Screen:
def __init__(self, name, title=None, nav='back'):
self.name, self.nodes, self.events = name, [], []
self.add(name, None, self.full(), [dict(type='Canvas', sortingOrder=300), dict(type='CanvasScaler', referenceResolution=[1080, 1920], matchWidthOrHeight=0), dict(type='GraphicRaycaster')])
if title:
self.add('Background', name, self.full(), [dict(type='Image', color='#EAF3F8', raycastTarget=True)])
self.image('Header', name, 32, 40, 1016, 130, 'ui_panel_header')
title_x = 120 if nav == 'close' else 210
self.text('Title', 'Header', title_x, 24, 776 if nav == 'close' else 690, 70, title, 44, '#FFFFFF', 'Center', True)
if nav == 'back':
self.button('Back', name, 48, 62, 180, 86, '返回营地', False)
elif nav == 'close':
event = 'Prototype.Close'
self.add('Btn_Close', name, self.rect(940, 58, 80, 80), [
dict(type='Image', sprite='btn_close', imageType='Simple', color='#FFFFFF', raycastTarget=True, preserveAspect=True),
dict(type='Button', transition='ColorTint', onClick=event),
])
self.events.append(dict(name=event, node='Btn_Close'))
self.text('Footer', name, 40, 1848, 1000, 38, '玩法原型', 22, MUTED, 'Center')
@staticmethod
def full():
return dict(anchorMin=[0, 0], anchorMax=[1, 1], offsetMin=[0, 0], offsetMax=[0, 0])
@staticmethod
def rect(x, y, w, h):
return dict(anchorMin=[0, 1], anchorMax=[0, 1], pivot=[0, 1], anchoredPosition=[x, -y], sizeDelta=[w, h])
def add(self, name, parent, rect, comps=(), active=True):
self.nodes.append(dict(name=name, parent=parent, active=active, rect=rect, components=list(comps)))
def image(self, name, parent, x, y, w, h, sprite='ui_panel_content', color='#FFFFFF', ray=False, simple=False):
self.add(name, parent, self.rect(x, y, w, h), [dict(type='Image', sprite=sprite, imageType='Simple' if simple else 'Sliced', color=color, raycastTarget=ray, preserveAspect=simple)])
def text(self, name, parent, x, y, w, h, value, size=30, color=INK, align='Left', outline=False):
c = [dict(type='TextMeshProUGUI', text=value, fontSize=size, color=color, alignment=align, raycastTarget=False)]
if outline:
c.append(dict(type='Outline', effectColor='#102033CC', effectDistance=[1.2, -1.2]))
self.add('Txt_' + name, parent, self.rect(x, y, w, h), c)
def button(self, name, parent, x, y, w, h, title, primary=True, sprite=None, active=True):
sprite = sprite or ('btn_primary_normal' if primary else 'btn_secondary_normal')
event = 'Prototype.' + name
self.add('Btn_' + name, parent, self.rect(x, y, w, h), [dict(type='Image', sprite=sprite, imageType='Sliced', raycastTarget=True), dict(type='Button', transition='SpriteSwap', spritePressed=sprite.replace('_normal', '_pressed'), spriteDisabled='btn_primary_disabled', onClick=event)], active)
self.events.append(dict(name=event, node='Btn_' + name))
if title:
self.text(name, 'Btn_' + name, 12, 6, w - 24, h - 12, title, 32, '#FFFFFF', 'Center', True)
def save(self):
sprites = {c[k] for n in self.nodes for c in n['components'] for k in ['sprite', 'spritePressed', 'spriteDisabled'] if c.get(k)}
def asset(s):
folder = 'Prototype' if s.startswith('proto_') else 'Home' if s.startswith('home_') else 'Common'
return f'Assets/Bundles/UI/Texture/{folder}/{s}.png'
doc = dict(schemaVersion=1, prefabName=self.name, prefabPath=f'Assets/Bundles/UI/Prefab/{self.name}.prefab',
description='G0 玩法原型 / Candy。JSON→Prefab;PrototypeFlow 绑定节点并更新表现,不运行时拼接业务骨架。',
canvas=dict(canvasScaler=dict(referenceResolution=[1080, 1920], matchWidthOrHeight=0)),
assets=dict(sprites={s: asset(s) for s in sorted(sprites)}), nodes=self.nodes,
bindings=[n['name'] for n in self.nodes if n['name'].startswith(('Btn_', 'Txt_'))], events=self.events)
(ROOT / 'Doc/UIPrefabCreater' / (self.name + '.json')).write_text(json.dumps(doc, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(self.name, len(self.nodes), 'nodes')
def main():
chest()
mail()
settings()
s = Screen('UI_PrototypeBag', '冒险背包')
for i, title in enumerate(['消耗品', '伙伴材料', '装饰', '活动道具']):
s.button(f'BagTab{i}', s.name, 40 + i * 252, 200, 244, 92, title, False)
s.image('BagWallet', s.name, 40, 320, 1000, 112)
s.text('BagWallet', 'BagWallet', 24, 20, 952, 72, '金币 · 糖果 · 弹珠', 32, align='Center')
for i in range(4):
s.add(f'BagGroup{i}', s.name, s.rect(40, 480, 1000, 1120), active=i == 0)
s.image('TicketCard', 'BagGroup0', 0, 0, 1000, 450)
s.image('TicketIcon', 'TicketCard', 44, 48, 150, 150, 'home_ico_pets', simple=True)
s.text('Tickets', 'TicketCard', 220, 34, 732, 70, '伙伴捕捉券 ×0', 38)
s.text('TicketInfo', 'TicketCard', 220, 126, 732, 164, '数量、来源与用途', 28, MUTED)
s.button('UseTicket', 'TicketCard', 36, 332, 928, 92, '前往捕捉')
s.button('EarnTicket', 'BagGroup0', 0, 492, 1000, 108, '前往弹珠台获取捕捉券', False)
s.text('ConsumeRules', 'BagGroup0', 32, 650, 936, 174, '捕捉券用于伙伴收集\n金币、糖果、弹珠显示在上方钱包\n选择其他分类,查看伙伴碎片与营地装饰', 28, MUTED, 'Center')
for pet in range(len(CONFIG['pets'])):
card = f'BagPetCard{pet}'; col, row = pet % 2, pet // 2
s.image(card, 'BagGroup1', col * 508, row * 216, 492, 208)
s.image(f'BagPetPortrait{pet}', card, 12, 32, 128, 128, f'proto_pet_{pet}', simple=True)
s.text(f'BagPetName{pet}', card, 152, 14, 330, 52, '伙伴碎片', 28)
s.text(f'BagPetInfo{pet}', card, 152, 70, 330, 126, '用途与来源', 22, MUTED)
s.button(f'BagPet{pet}', card, 292, 130, 186, 66, '查看伙伴', False)
for d in CONFIG['camp']['decorations']:
i = d['id']; card = f'BagDecorCard{i}'
s.image(card, 'BagGroup2', 0, i * 330, 1000, 300)
s.image(f'BagDecorPortrait{i}', card, 28, 66, 156, 156, f'proto_decor_{i}', simple=True)
s.text(f'BagDecorName{i}', card, 210, 32, 534, 62, d['name'], 34)
s.text(f'BagDecorInfo{i}', card, 210, 116, 534, 138, '数量与位置', 26, MUTED)
s.button(f'BagDecor{i}', card, 770, 104, 204, 92, '前往布置', False)
s.image('EventEmptyIcon', 'BagGroup3', 350, 130, 300, 300, 'home_ico_gift', simple=True)
s.text('EventEmpty', 'BagGroup3', 32, 478, 936, 104, '暂无活动道具', 38, align='Center')
s.button('EventToConsumables', 'BagGroup3', 220, 660, 560, 108, '查看消耗品', False)
s.button('BagToShop', s.name, 48, 1660, 984, 108, '前往补给商店', False)
s.text('BagRules', s.name, 64, 1790, 952, 48, '选择分类,查看数量、来源与用途', 26, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeShop', '营地补给商店')
s.image('ShopWallet', s.name, 40, 200, 1000, 104)
s.text('ShopWallet', 'ShopWallet', 24, 16, 952, 72, '金币 · 糖果 · 弹珠', 32, align='Center')
for i, title in enumerate(['免费补给', '金币商品', '营地装饰']):
s.button(f'ShopTab{i}', s.name, 40 + i * 338, 330, 324, 92, title, False)
s.add(f'ShopGroup{i}', s.name, s.rect(40, 450, 1000, 1000), active=i == 0)
for g in CONFIG['shop']['goods']:
i = g['id']; group = 'ShopGroup0' if i == 0 else 'ShopGroup1'; y = 0 if i == 0 else (i - 1) * 430
card = f'GoodsCard{i}'
s.image(card, group, 0, y, 1000, 390)
s.image(f'GoodsIcon{i}', card, 28, 44, 140, 140, ['home_ico_pinball', 'home_ico_gift', 'home_ico_pets'][i], simple=True)
s.text(f'GoodsName{i}', card, 192, 22, 760, 64, g['name'], 38)
s.text(f'GoodsInfo{i}', card, 192, 100, 760, 148, '价格、内容与剩余次数', 28, MUTED)
s.button(f'BuyGoods{i}', card, 36, 278, 928, 92, '领取补给' if i == 0 else '查看购买')
s.text('FreeRules', 'ShopGroup0', 32, 456, 936, 212, '每日免费补给只领取一次,弹珠直接到账\n弹珠超过自然恢复上限时,恢复暂停\n捕捉券、伙伴材料可在背包查看来源与用途', 28, MUTED, 'Center')
s.button('ShopToTasks', 'ShopGroup0', 32, 736, 936, 106, '查看每日任务与奖励', False)
for d in CONFIG['camp']['decorations']:
i = d['id']; card = f'ShopDecorCard{i}'
s.image(card, 'ShopGroup2', 0, i * 330, 1000, 300)
s.image(f'ShopDecorPortrait{i}', card, 24, 62, 164, 164, f'proto_decor_{i}', simple=True)
s.text(f'ShopDecorName{i}', card, 212, 30, 538, 60, d['name'], 36)
s.text(f'ShopDecorInfo{i}', card, 212, 112, 538, 144, '拥有状态与来源', 27, MUTED)
s.button(f'ShopDecor{i}', card, 770, 104, 204, 92, '前往布置', False)
s.text('ShopReset', s.name, 64, 1440, 952, 80, 'UTC+8每日零点刷新限购', 28, MUTED, 'Center')
s.button('ShopToBag', s.name, 48, 1560, 984, 106, '查看冒险背包', False)
s.text('ShopRules', s.name, 64, 1710, 952, 104, '金币商品在确认后扣费,补给直接到账\n装饰前往建造台预览后确认放置', 27, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeTasks', '任务与目标')
s.image('TaskSummary', s.name, 40, 200, 1000, 110)
s.text('TaskSummary', 'TaskSummary', 24, 10, 952, 90, '每日任务 · 0 / 3\nUTC+8 每日零点重置', 29, align='Center')
for i in range(4):
key = 'Goal' if i == 0 else f'Daily{i-1}'
s.image('TaskCard' + str(i), s.name, 40, 340 + i * 290, 1000, 258)
s.text('GoalName' if i == 0 else f'DailyName{i-1}', 'TaskCard' + str(i), 28, 20, 684, 58, '主线教学 · 营地初守' if i == 0 else '每日任务', 34)
s.text('GoalInfo' if i == 0 else f'DailyInfo{i-1}', 'TaskCard' + str(i), 28, 92, 684, 132, '进度与奖励\n完成目标后可领取', 27, MUTED)
s.button('Claim' + key, 'TaskCard' + str(i), 744, 30, 216, 86, '领取奖励')
s.button('Go' + key, 'TaskCard' + str(i), 744, 146, 216, 86, '前往完成', False)
s.button('ToSignin', s.name, 48, 1540, 984, 108, '查看七日签到', False)
s.text('TaskRules', s.name, 64, 1680, 952, 132, '完成每日目标,领取卡片所示奖励\n每日未领任务奖励在重置后失效\n教学目标长期保留,奖励只领一次', 27, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeSignin', '七日签到')
s.image('SigninSummary', s.name, 40, 200, 1000, 130)
s.text('SigninSummary', 'SigninSummary', 24, 12, 952, 106, '累计登录 1 / 7 天\n每天首次进入计一次', 30, align='Center')
for i in range(7):
key = 'SigninCard' + str(i)
s.image(key, s.name, 40, 360 + i * 166, 1000, 156)
s.image('SigninIcon' + str(i), key, 26, 28, 100, 100, 'home_ico_pinball', simple=True)
s.text('SigninDay' + str(i), key, 154, 16, 548, 54, '累计登录第 ' + str(i+1) + ' 天', 34)
s.text('SigninReward' + str(i), key, 154, 84, 548, 46, '弹珠奖励', 28, MUTED)
s.button('Signin' + str(i), key, 744, 34, 216, 88, '领取奖励')
s.button('ToTasks', s.name, 48, 1570, 984, 102, '查看任务与目标', False)
s.text('SigninRules', s.name, 64, 1700, 952, 116, '每日按UTC+8零点划分 · 漏登不重置\n已解锁未领的奖励保留,不付费补签\n完成七日后停止累计,不重复开启', 26, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeCamp', '营地生活')
s.image('CampSummary', s.name, 40, 200, 1000, 144)
s.text('CampSummary', 'CampSummary', 28, 14, 944, 114, '小镇营地\n金币 · 弹珠', 32, align='Center')
for i, (key, title, icon) in enumerate([
('Tent', '主营帐', 'home_ico_home'), ('BallTree', '弹珠树', 'home_ico_pinball'),
('Training', '训练屋', 'home_ico_bag'), ('Construction', '建造台', 'home_ico_chest')]):
x, y = 40 + (i % 2) * 508, 386 + (i // 2) * 280
s.button(key, s.name, x, y, 492, 256, '', False, 'ui_panel_content')
s.image(key + 'Icon', 'Btn_' + key, 24, 24, 100, 100, icon, simple=True)
s.text(key + 'Name', 'Btn_' + key, 140, 36, 324, 56, title, 38)
s.text(key + 'Info', 'Btn_' + key, 28, 138, 436, 92, '探险家档案\n营地功能与冒险记录' if key == 'Tent' else '尚未开放', 25, MUTED)
s.image('RestArea', s.name, 40, 980, 1000, 610)
s.text('RestTitle', 'RestArea', 24, 24, 952, 60, '伙伴休息区', 32, align='Center')
for pet in range(len(CONFIG['pets'])):
col, row = pet % 5, pet // 5
s.button(f'RestPet{pet}', 'RestArea', 24 + col * 192, 96 + row * 236, 184, 228, '', False, 'ui_panel_content')
s.image(f'RestPortrait{pet}', f'Btn_RestPet{pet}', 24, 12, 136, 136, f'proto_pet_{pet}', simple=True)
s.text(f'RestName{pet}', f'Btn_RestPet{pet}', 6, 150, 172, 70, CONFIG['pets'][pet]['name'], 25, align='Center')
s.text('RestMessage', 'RestArea', 32, 394, 936, 92, '和伙伴打个招呼吧\n互动不产出资源', 28, align='Center')
s.button('Interact', 'RestArea', 220, 500, 560, 90, '和伙伴打招呼')
s.text('CampRules', s.name, 64, 1632, 952, 142, '弹珠树与顶部库存共用自然恢复\n主营帐不提供战力,训练屋复用共享训练\n通过1-5可用金币装饰营地', 27, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeBuild', '营地 · 建造台')
s.image('BuildWallet', s.name, 40, 200, 1000, 104)
s.text('BuildWallet', 'BuildWallet', 28, 18, 944, 64, '金币 · 装饰收藏', 32, align='Center')
s.text('Catalogue', s.name, 64, 330, 952, 56, '选择装饰 · 每种最多拥有一件', 30)
for d in CONFIG['camp']['decorations']:
i = d['id']
s.button(f'Decoration{i}', s.name, 40 + i * 338, 416, 324, 330, '', False, 'ui_panel_content')
s.image(f'DecorationPicture{i}', f'Btn_Decoration{i}', 70, 12, 184, 184, f'proto_decor_{i}', simple=True)
s.text(f'DecorationName{i}', f'Btn_Decoration{i}', 12, 210, 300, 50, d['name'], 32, align='Center')
s.text(f'DecorationInfo{i}', f'Btn_Decoration{i}', 12, 270, 300, 44, '金币 ' + str(d['coins']), 26, MUTED, 'Center')
s.text('Places', s.name, 64, 788, 952, 56, '营地位置 · 点击空位预览', 30)
for slot in range(3):
s.button(f'CampSlot{slot}', s.name, 40 + slot * 338, 880, 324, 360, '', False, 'ui_panel_content')
for i in range(3):
s.image(f'SlotPicture{slot}_{i}', f'Btn_CampSlot{slot}', 70, 10, 184, 184, f'proto_decor_{i}', simple=True)
s.text(f'CampSlotName{slot}', f'Btn_CampSlot{slot}', 12, 202, 300, 50, f'位置 {slot+1}', 32, align='Center')
s.text(f'CampSlotInfo{slot}', f'Btn_CampSlot{slot}', 12, 254, 300, 86, '空位', 26, MUTED, 'Center')
s.image('Preview', s.name, 40, 1270, 1000, 170)
s.text('Preview', 'Preview', 24, 18, 952, 134, '先选装饰,再选位置\n确认之前不扣费', 30, align='Center')
s.button('Place', s.name, 48, 1490, 984, 110, '确认购买')
s.button('CancelPreview', s.name, 48, 1630, 474, 98, '撤回预览', False)
s.button('Store', s.name, 558, 1630, 474, 98, '收回仓库', False)
s.text('BuildRules', s.name, 64, 1760, 952, 64, '只在确认购买时扣费 · 已拥有装饰免费移动', 25, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeStages', '冒险')
s.image('Progress', s.name, 40, 200, 1000, 104)
s.text('Progress', 'Progress', 28, 18, 944, 64, '章节进度', 32, align='Center')
for i, title in enumerate(['第一章 · 绿野初程', '第二章 · 溪谷守望']):
s.button(f'StageTab{i}', s.name, 48 + i * 500, 320, 476, 88, title, False)
for i in range(20):
row = i % 10
s.button(f'Stage{i+1}', s.name, 48, 420 + row * 124, 984, 112, '', False, 'ui_panel_content', active=i < 10)
s.text(f'StageName{i+1}', f'Btn_Stage{i+1}', 28, 8, 928, 46, '关卡', 31)
s.text(f'StageInfo{i+1}', f'Btn_Stage{i+1}', 28, 58, 928, 42, '敌人 · 建议训练 · 首通奖励', 22, MUTED)
s.text('Rules', s.name, 64, 1700, 952, 96, '火 → 草 → 岩 → 电 → 水 → 火 · 克制 ×1.25 / 被克制 ×0.80\n主线不消耗体力,首通奖励每关仅领一次', 25, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypePinball', '弹珠乐园')
s.image('Wallet', s.name, 40, 200, 1000, 104)
s.text('Wallet', 'Wallet', 28, 18, 944, 64, '弹珠 20 糖果 0 金币 0', 32, align='Center')
s.button('AimLeft', s.name, 56, 340, 220, 90, '向左瞄准', False)
s.text('Aim', s.name, 290, 344, 500, 80, '方向 0°', 32, align='Center')
s.button('AimRight', s.name, 804, 340, 220, 90, '向右瞄准', False)
s.image('Board', s.name, 100, 470, 880, 960, 'ui_panel_main')
for row in range(4):
for col in range(5):
x, y = .12 + col * .19 + (0 if row % 2 == 0 else .025), .72 - row * .16
s.image(f'Peg{row}_{col}', 'Board', x * 880 - 32, (1-y) * 960 - 32, 64, 64, 'toggle_knob', '#F8C960' if row % 2 else '#6DCFE8', simple=True)
for i, reward in enumerate(CONFIG['pinball']['candy']):
s.image(f'Bin{i}', 'Board', i * 176 + 8, 858, 160, 86, color='#FFF0C9')
s.text(f'Bin{i}', f'Bin{i}', 2, 2, 156, 80, f'糖果 {reward}\n金币 {CONFIG["pinball"]["coins"][i]}', 23, align='Center')
s.add('Ball', 'Board', dict(anchorMin=[0, 0], anchorMax=[0, 0], pivot=[.5, .5], anchoredPosition=[440, 912], sizeDelta=[38, 38]), [dict(type='Image', sprite='toggle_knob', imageType='Simple', color='#FF7575', raycastTarget=False)])
s.text('ShotResult', s.name, 60, 1450, 960, 82, '调整方向,发射后观察弹珠落入的奖励区', 29, align='Center')
s.button('Fire', s.name, 48, 1570, 620, 122, '发射弹珠 ×1')
s.button('Capture', s.name, 700, 1570, 332, 122, '捕捉伙伴', False)
s.text('CaptureProgress', s.name, 60, 1712, 960, 118, '首次发射赠送捕捉券;此后每十次发射获得一张', 25, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypeCapture', '伙伴捕捉')
s.image('Pool', s.name, 40, 200, 1000, 150)
s.text('Pool', 'Pool', 24, 12, 952, 126, '绿野伙伴池\n普通概率与本次概率由服务端计算', 29, align='Center')
for pet in range(len(CONFIG['pets'])):
col, row = pet % 2, pet // 2
s.image(f'CaptureCard{pet}', s.name, 48 + col * 496, 380 + row * 200, 488, 186)
s.image(f'CapturePortrait{pet}', f'CaptureCard{pet}', 12, 20, 128, 128, f'proto_pet_{pet}', simple=True)
s.text(f'CaptureName{pet}', f'CaptureCard{pet}', 150, 14, 326, 46, '伙伴', 28)
s.text(f'CaptureOdds{pet}', f'CaptureCard{pet}', 150, 66, 326, 108, '普通 10% · 本次 10%\n未收集', 23, MUTED)
s.button('Capture', s.name, 48, 1480, 984, 112, '使用捕捉券 ×1')
s.text('Pity', s.name, 64, 1620, 952, 168, '首次教学固定获得芽芽\n连续4次重复,下次保证未收集伙伴\n已集齐时保底暂停,重复转为20碎片', 27, MUTED, 'Center')
s.save()
s = Screen('UI_PrototypePets', '伙伴与训练')
s.image('Wallet', s.name, 40, 200, 1000, 104)
s.text('Wallet', 'Wallet', 28, 18, 944, 64, '共享训练 Lv.1 糖果 0', 32, align='Center')
for pet in range(len(CONFIG['pets'])):
col, row = pet % 2, pet // 2
s.button(f'Pet{pet}', s.name, 40 + col * 508, 330 + row * 214, 492, 202, '', False, 'ui_panel_content')
s.image(f'Portrait{pet}', f'Btn_Pet{pet}', 12, 24, 140, 140, f'proto_pet_{pet}', simple=True)
s.text(f'PetName{pet}', f'Btn_Pet{pet}', 164, 14, 316, 44, '伙伴', 26)
s.text(f'PetInfo{pet}', f'Btn_Pet{pet}', 164, 64, 316, 126, '元素 · 定位\n生命 · 攻击\n碎片 0/40', 22)
s.text('Selection', s.name, 64, 1432, 952, 100, '选择伙伴查看成长', 25, align='Center')
s.button('Train', s.name, 48, 1550, 474, 90, '共享训练')
s.button('Evolve', s.name, 556, 1550, 474, 90, '伙伴进化', False)
s.button('ToFormation', s.name, 48, 1660, 982, 105, '前往布阵')
s.save()
s = Screen('UI_PrototypeFormation', '冒险准备 · 布阵')
s.text('Instructions', s.name, 64, 210, 952, 110, '拖动伙伴部署/交换,拖回列表撤下\n路线随教学逐步开放', 27, align='Center')
s.image('RosterDropArea', s.name, 32, 320, 1016, 300, color='#F5F0DF')
for pet in range(len(CONFIG['pets'])):
col, row = pet % 5, pet // 5
s.button(f'Pet{pet}', s.name, 40 + col * 202, 330 + row * 140, 188, 132, '', False, 'ui_panel_content')
s.nodes[-1]['components'].append(dict(type='MonoBehaviour', typeName='HotUpdate.Gameplay.FormationDragHandle'))
s.image(f'Portrait{pet}', f'Btn_Pet{pet}', 10, 6, 88, 88, f'proto_pet_{pet}', simple=True)
s.text(f'PetName{pet}', f'Btn_Pet{pet}', 96, 30, 88, 74, '伙伴', 20, align='Center')
s.text('Selection', s.name, 60, 636, 660, 70, '已选择:岩团团', 27, align='Center')
s.button('RemovePet', s.name, 750, 630, 280, 80, '撤下所选', False)
for lane, x in [(0, 382), (1, 48), (2, 716)]:
for row in range(2):
slot = lane * 2 + row
s.button(f'Slot{slot}', s.name, x, 760 + row * 240, 316, 194, '', False, 'ui_panel_content')
s.nodes[-1]['components'].append(dict(type='MonoBehaviour', typeName='HotUpdate.Gameplay.FormationDragHandle'))
s.text(f'Slot{slot}', f'Btn_Slot{slot}', 14, 20, 288, 158, ('前排' if row == 0 else '后排') + '\n空位' if lane == 0 else '后续开放', 32, align='Center')
s.text('StageInfo', s.name, 64, 1250, 952, 220, '关卡敌人、提示与首通奖励', 26, MUTED, 'Center')
s.button('AutoDeploy', s.name, 48, 1500, 316, 112, '推荐布阵', False)
s.button('StartBattle', s.name, 396, 1500, 636, 112, '开始防守')
s.button('ToStages', s.name, 48, 1640, 984, 92, '返回选关', False)
s.add('DragGhost', s.name, dict(anchorMin=[.5,.5], anchorMax=[.5,.5], pivot=[.5,.5], anchoredPosition=[0,0], sizeDelta=[140,140]), [dict(type='Image', sprite='proto_pet_0', imageType='Simple', preserveAspect=True, color='#FFFFFFCC', raycastTarget=False)], active=False)
s.save()
s = Screen('UI_PrototypeBattle')
s.image('Top', s.name, 32, 48, 1016, 246)
s.text('BattleTitle', 'Top', 30, 24, 740, 62, '绿野初程 · 营地防守', 40)
s.text('BattleInfo', 'Top', 30, 108, 956, 98, '第 1 波 / 3\n防线 10 / 10', 32)
s.button('Abandon', s.name, 822, 76, 188, 84, '离开', False)
s.add('Bottom', s.name, dict(anchorMin=[.5, 0], anchorMax=[.5, 0], pivot=[.5, 0], anchoredPosition=[0, 64], sizeDelta=[1016, 414]), [dict(type='Image', sprite='ui_panel_content', imageType='Sliced', raycastTarget=False)])
s.text('Party', 'Bottom', 30, 18, 956, 220, '伙伴状态', 25)
s.button('Pause', 'Bottom', 30, 264, 450, 110, '暂停', False)
s.button('Speed', 'Bottom', 520, 264, 466, 110, '速度 ×1')
s.add('Result', s.name, s.full(), [dict(type='Image', color='#0B1724CC', raycastTarget=True)], active=False)
s.image('ResultPanel', 'Result', 100, 440, 880, 1010, 'ui_panel_popup')
s.text('ResultTitle', 'ResultPanel', 40, 40, 800, 82, '守护成功', 52, align='Center')
s.text('ResultInfo', 'ResultPanel', 50, 150, 780, 180, '奖励', 30, align='Center')
s.text('BattleReport', 'ResultPanel', 42, 350, 796, 290, '战斗统计', 25, MUTED, 'Center')
s.button('NextStage', 'ResultPanel', 40, 680, 800, 104, '下一关')
s.button('Retry', 'ResultPanel', 40, 836, 380, 112, '调整阵容', False)
s.button('Home', 'ResultPanel', 460, 836, 380, 112, '返回营地')
s.save()
def settings():
s = Screen('UI_PrototypeSettings', '游戏设置', nav='close')
s.image('AudioCard', s.name, 40, 200, 1000, 320)
s.text('AudioTitle', 'AudioCard', 36, 24, 928, 54, '声音', 36)
s.text('MusicInfo', 'AudioCard', 36, 100, 560, 70, '背景音乐', 30)
s.button('ToggleMusic', 'AudioCard', 640, 90, 320, 90, '开启')
s.text('SfxInfo', 'AudioCard', 36, 210, 560, 70, '游戏音效', 30)
s.button('ToggleSfx', 'AudioCard', 640, 200, 320, 90, '开启')
s.image('DisplayCard', s.name, 40, 560, 1000, 320)
s.text('DisplayTitle', 'DisplayCard', 36, 24, 928, 54, '画面与设备', 36)
s.text('QualityInfo', 'DisplayCard', 36, 100, 420, 70, '画质', 30)
s.button('CycleQuality', 'DisplayCard', 480, 90, 480, 90, '标准')
s.text('VibrateInfo', 'DisplayCard', 36, 210, 560, 70, '振动反馈', 30)
s.button('ToggleVibrate', 'DisplayCard', 640, 200, 320, 90, '开启')
s.image('AccountCard', s.name, 40, 920, 1000, 560)
s.text('AccountTitle', 'AccountCard', 36, 24, 928, 54, '账号信息', 36)
s.text('AccountInfo', 'AccountCard', 36, 100, 928, 220, '探险家 ID\n设备访客账号\n当前进度保存在云端', 28, MUTED)
s.button('Relogin', 'AccountCard', 36, 360, 928, 100, '重新登录本设备', False)
s.button('DeleteAccount', 'AccountCard', 36, 480, 928, 60, '删除账号(暂未开放)', False)
s.text('SettingsRules', s.name, 64, 1520, 952, 120, '音画与振动保存在本机\n退出或重新登录不会删除云端进度\n微信不支持的选项会自动隐藏', 26, MUTED, 'Center')
s.save()
def chest():
s = Screen('UI_PrototypeChest', '宝箱奖励')
s.image('ChestWallet', s.name, 40, 200, 1000, 104)
s.text('ChestWallet', 'ChestWallet', 20, 18, 960, 68, '金币 · 糖果 · 弹珠 · 捕捉券', 29, align='Center')
s.image('DailyCard', s.name, 40, 330, 1000, 230)
s.image('DailyIcon', 'DailyCard', 28, 40, 150, 150, 'home_ico_chest', simple=True)
s.text('DailyInfo', 'DailyCard', 200, 26, 540, 120, '每日宝箱\n今日可领取 1 个', 27)
s.button('ClaimDailyChest', 'DailyCard', 758, 60, 220, 110, '领取每日宝箱')
s.text('ChestSummary', s.name, 40, 566, 1000, 112, '已拥有 0 个宝箱\n首通掉落', 24, MUTED, 'Center')
tints = ['#FFF4D6', '#EAF3FF', '#F6ECFF']
for c in CONFIG['rewards']['chests']:
i = c['id']; y = 700 + i * 232; card = f'ChestCard{i}'
s.image(card, s.name, 40, y, 1000, 214, color=tints[i])
s.image(f'ChestPicture{i}', card, 24, 32, 140, 140, 'home_ico_chest', simple=True)
s.text(f'ChestName{i}', card, 186, 16, 520, 54, c['name'], 31)
s.text(f'ChestInfo{i}', card, 186, 74, 520, 124, '宝箱内容与来源', 23, MUTED)
s.button(f'OpenChest{i}', card, 738, 62, 236, 92, '打开宝箱')
s.add('ChestChoiceGroup', s.name, s.rect(40, 1410, 1000, 300))
s.text('ChestChoiceInfo', 'ChestChoiceGroup', 20, 0, 960, 66, '自选箱:先选择一种碎片,再打开宝箱', 25, MUTED, 'Center')
for i in range(10):
col, row = i % 5, i // 5
s.button(f'ChestChoice{i}', 'ChestChoiceGroup', 8 + col * 197, 74 + row * 108, 185, 96, '碎片', False)
s.button('GoStages', s.name, 48, 1740, 984, 86, '前往冒险获取更多宝箱', False)
s.text('ChestRules', s.name, 64, 1836, 952, 40, '宝箱永久有效 · 打开、扣箱与发奖在同一事务完成', 23, MUTED, 'Center')
s.save()
def mail():
s = Screen('UI_PrototypeMail', '小镇邮件')
s.image('MailWallet', s.name, 40, 200, 1000, 104)
s.text('MailWallet', 'MailWallet', 20, 18, 960, 68, '金币 · 糖果 · 弹珠 · 捕捉券', 29, align='Center')
for i in range(5):
s.button(f'Mail{i}', s.name, 48, 330 + i * 132, 984, 118, '', False, 'ui_list_item_normal')
s.nodes[-1]['components'][1]['spritePressed'] = 'ui_list_item_selected'
s.text(f'MailTitle{i}', f'Btn_Mail{i}', 28, 10, 926, 52, '营地来信', 32)
s.text(f'MailStatus{i}', f'Btn_Mail{i}', 28, 67, 926, 38, '未读 · 附件待领取', 25, MUTED)
s.text('MailEmpty', s.name, 64, 510, 952, 180, '暂无邮件\n收到的来信和补给会显示在这里', 34, MUTED, 'Center')
s.button('MailPrev', s.name, 48, 1010, 240, 82, '上一页', False)
s.text('MailPage', s.name, 300, 1020, 480, 62, '1 / 1 页', 28, align='Center')
s.button('MailNext', s.name, 792, 1010, 240, 82, '下一页', False)
s.image('MailDetail', s.name, 48, 1120, 984, 510)
s.text('MailDetailTitle', 'MailDetail', 28, 18, 928, 54, '请选择一封邮件', 34)
s.text('MailDetailStatus', 'MailDetail', 28, 82, 928, 38, '点击上方邮件查看正文和附件', 24, MUTED)
s.add('MailBodyScroll', 'MailDetail', s.rect(28, 140, 928, 170), [dict(type='ScrollRect', horizontal=False, vertical=True, viewport='MailBodyViewport', content='Txt_MailBody')])
s.add('MailBodyViewport', 'MailBodyScroll', s.full(), [dict(type='Image', color='#FFFFFF01', raycastTarget=True), dict(type='RectMask2D')])
s.add('Txt_MailBody', 'MailBodyViewport', dict(anchorMin=[0, 1], anchorMax=[1, 1], pivot=[0, 1], anchoredPosition=[0, 0], sizeDelta=[-8, 170]), [dict(type='TextMeshProUGUI', text='营地来信和奖励补给会显示在这里。', fontSize=28, color=INK, alignment='TopLeft', raycastTarget=False), dict(type='ContentSizeFitter', verticalFit='PreferredSize', horizontalFit='Unconstrained')])
s.text('MailAttachment', 'MailDetail', 28, 326, 928, 60, '附件', 27)
s.button('ClaimMail', 'MailDetail', 28, 406, 928, 84, '领取附件')
s.button('ClaimAllMail', s.name, 48, 1660, 984, 108, '一键领取')
s.text('MailRules', s.name, 64, 1790, 952, 48, '附件领取后到账 · 一键最多领取20封 · 正文可滑动', 24, MUTED, 'Center')
s.save()
if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'settings':
settings()
else:
main()
+4
View File
@@ -0,0 +1,4 @@
# Copy to Tools/gpt_image.env.local, fill in the key, and load it in your shell.
# PowerShell: $env:GRSAI_API_KEY = 'sk-...'
# cmd.exe: set GRSAI_API_KEY=sk-...
GRSAI_API_KEY=sk-your-key-here
+84 -4
View File
@@ -26,7 +26,9 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Union
DEFAULT_MODEL = "gpt-image-2.5"
VIP_MODEL = "gpt-image-2-vip"
DEFAULT_API_KEY = "sk-b69877bd82c6412ca4872351f428d580"
# API keys are deliberately read from the environment/CLI only. Keeping a key
# in this checked-in tool would expose it to every clone of the repository.
DEFAULT_API_KEY: Optional[str] = None
DEFAULT_BASE_URL = "https://grsai.dakka.com.cn" # 国内节点;全球: https://grsaiapi.com
DEFAULT_ASPECT_RATIO = "1024x1024"
DEFAULT_QUALITY = "auto"
@@ -42,6 +44,8 @@ VIP_MODELS = frozenset(
"gpt-image-2.5-sunburst",
}
)
SUPPORTED_MODELS = frozenset({"gpt-image-2", "gpt-image-2.5", *VIP_MODELS})
SUPPORTED_QUALITIES = frozenset({"auto", "low", "medium", "high", "xhigh", "max"})
class GenerateError(RuntimeError):
@@ -53,12 +57,17 @@ def _now_iso() -> str:
def _resolve_api_key(explicit: Optional[str] = None) -> str:
return (
key = (
explicit
or os.environ.get("GRSAI_API_KEY")
or os.environ.get("GPT_IMAGE_API_KEY")
or DEFAULT_API_KEY
)
if not key or not key.strip():
raise GenerateError(
"未配置 API key。请设置 GRSAI_API_KEY(或 GPT_IMAGE_API_KEY),"
"或通过 --api-key / JSON.api_key 传入。"
)
return key.strip()
def _normalize_base_url(base_url: str) -> str:
@@ -157,6 +166,63 @@ def http_json(
raise GenerateError(f"网络错误: {exc}") from exc
def http_stream_json(
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
body: Optional[Dict[str, Any]] = None,
timeout: float = 300.0,
) -> Dict[str, Any]:
"""Read a JSON/SSE response and return its last useful JSON object.
The provider documents ``replyType=stream`` as an event stream, but in
practice deployments return either NDJSON or SSE (``data: {...}``). The
parser accepts both forms and ignores keep-alive/comment lines.
"""
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req_headers = {
"Accept": "text/event-stream, application/x-ndjson, application/json",
**(headers or {}),
}
if body is not None:
req_headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
latest: Dict[str, Any] = {}
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
for raw_line in resp:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":") or line.startswith("event:"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
# Some gateways split a JSON object across lines. Keep
# consuming the stream; the final object is still useful.
continue
if isinstance(payload, dict):
latest.update(payload)
if not latest:
raise GenerateError("流式响应未包含有效 JSON 数据")
return latest
except urllib.error.HTTPError as exc:
err_body = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_body) if err_body else {}
except json.JSONDecodeError:
payload = {"error": err_body}
message = payload.get("error") or err_body or str(exc)
raise GenerateError(f"HTTP {exc.code}: {message}") from exc
except urllib.error.URLError as exc:
raise GenerateError(f"网络错误: {exc}") from exc
def download_file(url: str, dest: Path, timeout: float = 120.0) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
request = urllib.request.Request(url, headers={"User-Agent": "AIGame-gpt-image/1.0"})
@@ -247,6 +313,19 @@ def submit_generate(
background: Optional[str] = None,
mask: Optional[str] = None,
) -> Dict[str, Any]:
prompt = prompt.strip()
if not prompt:
raise GenerateError("prompt 不能为空")
if model not in SUPPORTED_MODELS:
raise GenerateError(
f"不支持的模型: {model}。可选模型: {', '.join(sorted(SUPPORTED_MODELS))}"
)
if quality not in SUPPORTED_QUALITIES:
raise GenerateError(
f"不支持的 quality: {quality}。可选值: {', '.join(sorted(SUPPORTED_QUALITIES))}"
)
if reply_type not in {"json", "stream", "async"}:
raise GenerateError("reply_type 必须是 json、stream 或 async")
image_refs = [normalize_image_ref(x) for x in (images or [])]
body: Dict[str, Any] = {
"model": model,
@@ -262,7 +341,8 @@ def submit_generate(
body["mask"] = normalize_image_ref(mask)
url = f"{_normalize_base_url(base_url)}/v1/api/generate"
return http_json(
request = http_stream_json if reply_type == "stream" else http_json
return request(
"POST",
url,
headers={"Authorization": f"Bearer {api_key}"},
+4 -9
View File
@@ -829,15 +829,10 @@ def generate_from_image(
except Exception:
pass
# 提交失败且页面有额度提示时,升级为换号登录
if "未发起生成请求" in str(exc) or "立即生成" in str(exc):
try:
_raise_if_quota(page=page, payloads=generations_bodies)
except HunyuanQuotaExceeded:
raise
# 无法确认额度时也当作需换号,方便用户打开登录页处理
raise HunyuanQuotaExceeded(
f"{exc}(已按额度/账号问题处理:将打开登录页换号)"
) from exc
# Only an explicit quota message should trigger account re-login.
# A disabled generate button can also mean that the uploaded image
# was rejected (for example, "?????"). Preserve that error so
# the caller can fix the input image without discarding a valid session.
raise
finally:
browser.close()
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""把 dgx3d_animal_pipeline 产物拷进 Unity Bundles(Rigged + Anims)。"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"-m",
"--manifest",
type=Path,
required=True,
help="Tools/output/{name}_dgx/{Name}_manifest.json",
)
ap.add_argument(
"--kind",
default="Animal",
help="Characters/{kind}/{Name}",
)
args = ap.parse_args()
man = json.loads(args.manifest.read_text(encoding="utf-8"))
name = man["name"]
dest_root = Path(f"AIClient/Assets/Bundles/Characters/{args.kind}/{name}")
rigged_dir = dest_root / "Rigged"
anim_dir = dest_root / "Anims"
rigged_dir.mkdir(parents=True, exist_ok=True)
anim_dir.mkdir(parents=True, exist_ok=True)
src_fbx = Path(man["rigged_fbx"])
if not src_fbx.is_file():
raise SystemExit(f"missing {src_fbx}")
dest_fbx = rigged_dir / f"{name}_Rigged.fbx"
shutil.copy2(src_fbx, dest_fbx)
# Copy sibling textures next to model.fbx if present
src_dir = src_fbx.parent
for tex in list(src_dir.glob("*.png")) + list(src_dir.glob("*.jpg")):
shutil.copy2(tex, rigged_dir / tex.name)
copied = {"rigged": str(dest_fbx), "anims": {}}
for clip, rel in (man.get("anims") or {}).items():
src = Path(rel)
if not src.is_file():
# try relative to manifest parent / out root
alt = args.manifest.parent / rel
src = alt if alt.is_file() else src
if not src.is_file():
raise SystemExit(f"missing anim {clip}: {rel}")
dest = anim_dir / f"{name}_{clip}.fbx"
shutil.copy2(src, dest)
copied["anims"][clip] = str(dest)
out = {
"ok": True,
"dest": str(dest_root),
"files": copied,
"next": (
f'unity command --project-path D:/UD/AI/AIGame/AIClient run_script '
f'--file AgentScripts/SetupAnimalAnimator.cs --entry {man.get("setup_entry", "SetupAnimalAnimator.Setup" + name)}'
),
"postprocess": man.get("postprocess", []),
}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32 -10
View File
@@ -1,19 +1,22 @@
#!/usr/bin/env python3
"""将角色 XPbr / 烘焙产物安装到 AIClient/Assets/Bundles/Characters/{ActorName}/。
"""将角色 XPbr / 烘焙产物安装到 Bundles/Characters。
人物:Characters/Human/{ActorName}/
不传 --kind 时落到 Characters/{ActorName}/(兼容 MaleExplorer)。
优先识别 XPbr 产物(推荐):
{ActorName}.fbx
{ActorName}_base.png # RGB albedo, A=AO/透明
{ActorName}_mix.png # RG sphere-normal, B=metallic, A=roughness
{ActorName}.mat # Test/XPbr
{ActorName}.prefab # Unity 菜单生成
{ActorName}.prefab # Pipeline SetupCharacter 生成
概念图留在 Doc/Design/Character/,不要拷进 Bundles。
兼容旧五通道烘焙目录(_color/_normal/_ao/_metallic/_roughness)。
用法:
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/actor_game/actor_xpbr
python Tools/install_character_bundles.py -a CaoCao --kind Human -b Tools/output/caocao_game/caocao_xpbr -c Doc/Design/Character/CaoCao/caocao.png
"""
from __future__ import annotations
@@ -35,22 +38,32 @@ def _pick_first(files: list[Path], predicate) -> Path | None:
return None
def _safe_seg(name: str, label: str) -> str:
text = (name or "").strip()
if not text or any(ch in text for ch in r'\/:*?"<>|'):
raise ValueError(f"非法 {label}: {name!r}")
return text
def install(
*,
actor: str,
bake_dir: Path,
kind: str | None = None,
concept: Path | None = None,
dry_run: bool = False,
) -> dict:
actor = actor.strip()
if not actor or any(ch in actor for ch in r'\/:*?"<>|'):
raise ValueError(f"非法 ActorName: {actor!r}")
actor = _safe_seg(actor, "ActorName")
kind = _safe_seg(kind, "Kind") if kind else None
bake_dir = bake_dir.expanduser().resolve()
if not bake_dir.is_dir():
raise FileNotFoundError(f"资源目录不存在: {bake_dir}")
dest = _repo_root() / "AIClient" / "Assets" / "Bundles" / "Characters" / actor
dest = _repo_root() / "AIClient" / "Assets" / "Bundles" / "Characters"
if kind:
dest = dest / kind
dest = dest / actor
files = list(bake_dir.iterdir())
sources: dict[str, Path] = {}
@@ -126,12 +139,14 @@ def install(
result = {
"ok": True,
"actor": actor,
"kind": kind,
"mode": mode,
"dest": str(dest.relative_to(_repo_root())).replace("\\", "/"),
"files": copied,
"hint": (
f"在 Unity 执行菜单 AIGame/M3/Setup Character Prefab + Art Collector "
f"生成挂 XPbr 的 {actor}.prefab;并登记 Doc/ResDesc_Model.md"
"Unity Pipeline: unity command run_script --file AgentScripts/SetupCharacter.cs "
f"--entry SetupCharacter.Run --args {json.dumps([kind or '', actor])}"
";并登记 Doc/ResDesc_Model.md"
),
}
if concept is not None:
@@ -141,7 +156,13 @@ def install(
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="安装角色资源到 Bundles/Characters/{Actor}")
p.add_argument("--actor", "-a", required=True, help="角色目录名 / 寻址名,如 MaleExplorer")
p.add_argument("--actor", "-a", required=True, help="角色目录名 / 寻址名,如 CaoCao")
p.add_argument(
"--kind",
"-k",
default=None,
help="人物用 Human,落到 Characters/{Kind}/{Actor};省略则为 Characters/{Actor}",
)
p.add_argument(
"--bake-dir",
"-b",
@@ -160,6 +181,7 @@ def main(argv: list[str] | None = None) -> int:
result = install(
actor=args.actor,
bake_dir=Path(args.bake_dir),
kind=args.kind,
concept=Path(args.concept) if args.concept else None,
dry_run=args.dry_run,
)
+3 -3
View File
@@ -38,9 +38,9 @@ python Tools/model_tools/model_translator.py Tools/output/prop_bake/prop/prop.fb
## 装入 Unity + 预设
```powershell
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/prop_game/prop_xpbr -c ref.png
# Unity 菜单:AIGame/M3/Setup Character Prefab + Art Collector
# 会挂 Test/XPbr,生成 Characters/MaleExplorer/MaleExplorer.prefab
python Tools/install_character_bundles.py -a CaoCao --kind Human -b Tools/output/caocao_game/caocao_xpbr -c Doc/Design/Character/CaoCao/caocao.png
# Unity Pipeline:run_script AgentScripts/SetupCharacter.cs --entry SetupCharacter.SetupCaoCao
# 寻址 Prefab=`CaoCao`,目录 Characters/Human/CaoCao
```
## 与 AIC#Project/ModelTranslator 减面烘焙对比
+45
View File
@@ -0,0 +1,45 @@
"""Read imported world geometry, transforms and bind matrices for rig diagnosis."""
import json
import sys
from pathlib import Path
import bpy
from mathutils import Vector
for arg in sys.argv[sys.argv.index('--') + 1:]:
bpy.ops.wm.read_factory_settings(use_empty=True)
path = Path(arg)
if path.suffix.lower() == '.fbx':
bpy.ops.import_scene.fbx(filepath=str(path))
else:
bpy.ops.import_scene.gltf(filepath=str(path))
print('FILE', str(path))
for o in bpy.data.objects:
if o.type not in ('MESH', 'ARMATURE'):
continue
print('OBJECT', o.name, o.type, 'parent', o.parent.name if o.parent else None,
'matrix', [list(v) for v in o.matrix_world])
if o.type == 'MESH':
points = [o.matrix_world @ v.co for v in o.data.vertices]
mn = [min(p[i] for p in points) for i in range(3)]
mx = [max(p[i] for p in points) for i in range(3)]
print('GEOMETRY', len(points), 'min', mn, 'max', mx,
'modifiers', [(m.type, m.object.name if getattr(m, 'object', None) else None) for m in o.modifiers])
links=[[] for _ in points]
for edge in o.data.edges:
a,b=edge.vertices;links[a].append(b);links[b].append(a)
unseen=set(range(len(points)));components=[]
while unseen:
stack=[unseen.pop()];indices=[]
while stack:
v=stack.pop();indices.append(v)
for n in links[v]:
if n in unseen:unseen.remove(n);stack.append(n)
components.append(indices)
for indices in sorted(components,key=len,reverse=True)[:12]:
print('COMPONENT',len(indices),'min',[min(points[v][i] for v in indices) for i in range(3)],
'max',[max(points[v][i] for v in indices) for i in range(3)])
else:
print('BONES', len(o.data.bones))
for b in o.data.bones:
print('BONE', b.name, 'parent', b.parent.name if b.parent else None, 'worldhead', list(o.matrix_world @ b.head_local),
'worldtail', list(o.matrix_world @ b.tail_local))
+267
View File
@@ -0,0 +1,267 @@
"""Transfer TokenRig armature+weights from a rigged source onto an approved low mesh.
Blender:
blender -b --factory-startup --python bl_transfer_skin.py -- \\
<rigged_src.glb|fbx> <low_mesh.fbx|glb> <out.fbx>
"""
from __future__ import annotations
import argparse
import json
import math
import heapq
import sys
from pathlib import Path
import bpy
from mathutils import Matrix, Vector
from mathutils.bvhtree import BVHTree
def _clear():
bpy.ops.wm.read_factory_settings(use_empty=True)
def _import(path: Path):
ext = path.suffix.lower()
before = set(bpy.data.objects)
if ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=str(path))
elif ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=str(path))
else:
raise SystemExit(f"unsupported {ext}")
return [o for o in bpy.data.objects if o not in before]
def _armatures(objs=None):
pool = objs if objs is not None else bpy.data.objects
return [o for o in pool if o.type == "ARMATURE"]
def _meshes(objs=None):
pool = objs if objs is not None else bpy.data.objects
# Skip TokenRig helper icosphere
out = []
for o in pool:
if o.type != "MESH":
continue
if o.name.lower().startswith("icosphere"):
continue
if len(o.data.polygons) < 32:
continue
out.append(o)
return out
def _largest_mesh(meshes):
return max(meshes, key=lambda m: len(m.data.polygons))
def main():
argv = sys.argv[sys.argv.index("--") + 1 :]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("rigged_src", type=Path)
parser.add_argument("low_mesh", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("--low-up", choices=("Y", "Z"), default="Y",
help="Project static FBX has Y-up vertex data; standard Blender/glTF imports use Z.")
parser.add_argument("--align-bounds", action="store_true",
help="Align low bottom-center and height to its source high rig (rig/animations stay unchanged).")
parser.add_argument('--weight-overrides',type=Path,help='Recipe with optional geodesic skinOverrides in approved mesh coordinates / height')
args = parser.parse_args(argv)
src_path, low_path, out_path = [p.resolve() for p in (args.rigged_src, args.low_mesh, args.out)]
_clear()
src_objs = _import(src_path)
arms = _armatures(src_objs)
src_meshes = _meshes(src_objs)
if not arms or not src_meshes:
raise SystemExit(f"source missing armature/mesh arms={len(arms)} meshes={len(src_meshes)}")
arm = arms[0]
donor = _largest_mesh(src_meshes)
# Bind weights must be transferred in REST, never from a sampled action.
arm.animation_data_clear()
arm.data.pose_position = "REST"
low_objs = _import(low_path)
low_meshes = _meshes(low_objs)
if not low_meshes:
raise SystemExit("low mesh missing")
low = _largest_mesh(low_meshes)
# The static pipeline deliberately stores Y-up vertices (see bl_decimate).
# A normal FBX re-import does NOT turn those vertices into Blender Z-up.
# TokenRig/glTF is imported into Blender Z-up. Align BEFORE transferring weights.
original_world = low.matrix_world.copy()
original_points=[original_world @ v.co for v in low.data.vertices]
axis = Matrix.Rotation(math.pi / 2, 4, "X") if args.low_up == "Y" else Matrix.Identity(4)
low.parent = None
low.matrix_world = Matrix.Identity(4)
low.data.transform(axis @ original_world)
low.data.update()
bpy.context.view_layer.update()
# Reject a different character/proxy unless it actually matches this low mesh.
# Bone names/counts alone cannot establish compatible bind geometry.
donor_points = [donor.matrix_world @ v.co for v in donor.data.vertices]
alignment = Matrix.Identity(4)
if args.align_bounds:
def bottom_center(points):
mn = Vector([min(p[i] for p in points) for i in range(3)])
mx = Vector([max(p[i] for p in points) for i in range(3)])
return Vector(((mn.x + mx.x)/2, (mn.y + mx.y)/2, mn.z)), mx.z - mn.z
source_center, source_height = bottom_center(donor_points)
low_center, low_height = bottom_center([v.co for v in low.data.vertices])
scale = source_height / low_height
alignment = Matrix.Translation(source_center) @ Matrix.Scale(scale, 4) @ Matrix.Translation(-low_center)
low.data.transform(alignment)
low.data.update()
tree = BVHTree.FromPolygons(donor_points, [list(p.vertices) for p in donor.data.polygons])
distances = [tree.find_nearest(v.co)[3] for v in low.data.vertices]
extent = max(max(p[i] for p in donor_points) - min(p[i] for p in donor_points) for i in range(3))
rms = math.sqrt(sum(d*d for d in distances) / len(distances))
if max(distances) > extent * .03 or rms > extent * .01:
raise SystemExit(f"bind geometry mismatch: max={max(distances):.6f} rms={rms:.6f} extent={extent:.6f}; "
"use this character's high rig and explicit --low-up")
# Remove extra imported armatures from low file (should be none)
for a in _armatures(low_objs):
bpy.data.objects.remove(a, do_unlink=True)
# Both are now in the SAME Blender world space; preserve this on parenting.
bpy.ops.object.select_all(action="DESELECT")
low.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE", keep_transform=True)
# Ensure vertex groups exist matching bones
for bone in arm.data.bones:
if bone.name not in low.vertex_groups:
low.vertex_groups.new(name=bone.name)
# Data transfer weights from donor
bpy.ops.object.select_all(action="DESELECT")
donor.select_set(True)
low.select_set(True)
bpy.context.view_layer.objects.active = low
mod = low.modifiers.new(name="WeightTransfer", type="DATA_TRANSFER")
mod.object = donor
mod.use_vert_data = True
mod.data_types_verts = {"VGROUP_WEIGHTS"}
mod.vert_mapping = "POLYINTERP_NEAREST"
bpy.ops.object.datalayout_transfer(modifier=mod.name)
bpy.ops.object.modifier_apply(modifier=mod.name)
# Some inferred rigs omit a tail and attach it to a nearby arm. Local,
# recipe-defined geodesic masks can bind that appendage to an existing
# stable bone without changing geometry, UVs or the animation skeleton.
override_reports=[]
if args.weight_overrides:
overrides=json.loads(args.weight_overrides.read_text(encoding='utf-8-sig')).get('skinOverrides',[])
extent=max(p.y for p in original_points)-min(p.y for p in original_points)
points=[p/extent for p in original_points]
adjacency=[[] for _ in points]
for edge in low.data.edges:
a,b=edge.vertices;distance=(points[a]-points[b]).length
adjacency[a].append((b,distance));adjacency[b].append((a,distance))
unseen=set(range(len(points)));components=[]
while unseen:
stack=[unseen.pop()];part=[]
while stack:
v=stack.pop();part.append(v)
for n,_ in adjacency[v]:
if n in unseen:unseen.remove(n);stack.append(n)
components.append(part)
main=max(components,key=len)
for rule in overrides:
if rule['bone'] not in arm.data.bones:raise ValueError('Override bone missing')
full,fade=rule['fullDistance'],rule['fadeDistance']
if not 0<=full<fade:raise ValueError('Invalid geodesic override falloff')
seed=Vector(rule['seed']);start=min(main,key=lambda i:(points[i]-seed).length_squared)
geodesic=[float('inf')]*len(points);geodesic[start]=0;queue=[(0,start)]
while queue:
distance,v=heapq.heappop(queue)
if distance!=geodesic[v]:continue
for n,cost in adjacency[v]:
trial=distance+cost
if trial<geodesic[n]:geodesic[n]=trial;heapq.heappush(queue,(trial,n))
for part in components:
if part is main:continue
for i in part:
geodesic[i]=min(geodesic[j]+(points[i]-points[j]).length for j in main)
target=low.vertex_groups[rule['bone']];affected=[]
for i,distance in enumerate(geodesic):
if distance>=fade:continue
weight=1 if distance<=full else (fade-distance)/(fade-full)
weight=weight*weight*(3-2*weight)
previous={g.group:g.weight for g in low.data.vertices[i].groups}
for group,value in previous.items():low.vertex_groups[group].add([i],value*(1-weight),'REPLACE')
target.add([i],previous.get(target.index,0)*(1-weight)+weight,'REPLACE')
affected.append(i)
override_reports.append(dict(bone=rule['bone'],affected=len(affected),seed_vertex=start,
normalized_min=[min(points[i][c] for i in affected) for c in range(3)],
normalized_max=[max(points[i][c] for i in affected) for c in range(3)]))
# Unity uses at most four weights here. Normalize after limiting, and reject
# unweighted vertices instead of silently exporting broken skinning.
bpy.ops.object.vertex_group_limit_total(limit=4)
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
unweighted = [v.index for v in low.data.vertices if sum(g.weight for g in v.groups) < .999]
if unweighted:
raise SystemExit(f"unweighted/unnormalized vertices: {unweighted[:20]}")
# Delete donor / helpers; keep arm + low
keep = {arm, low}
for obj in list(bpy.data.objects):
if obj not in keep:
bpy.data.objects.remove(obj, do_unlink=True)
# Armature modifier
if not any(m.type == "ARMATURE" for m in low.modifiers):
am = low.modifiers.new(name="Armature", type="ARMATURE")
am.object = arm
arm.data.pose_position = "POSE"
for bone in arm.pose.bones:
bone.matrix_basis = Matrix.Identity(4)
out_path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
low.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.fbx(
filepath=str(out_path),
use_selection=True,
object_types={"ARMATURE", "MESH"},
add_leaf_bones=False,
bake_anim=False,
armature_nodetype="NULL",
axis_forward="-Z",
axis_up="Y",
apply_scale_options="FBX_SCALE_ALL",
path_mode="COPY",
embed_textures=True,
mesh_smooth_type="FACE",
)
summary = {
"ok": True,
"out": str(out_path),
"bones": len(arm.data.bones),
"low_verts": len(low.data.vertices),
"low_polys": len(low.data.polygons),
"groups": len(low.vertex_groups),
"low_up": args.low_up,
"alignment_max": max(distances),
"alignment_rms": rms,
"source_rig": str(src_path),
"source_low": str(low_path),
"skin_overrides": override_reports,
"low_to_rig_world": [list(row) for row in alignment @ axis @ original_world],
}
out_path.with_suffix(".skin.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print("MT_SUMMARY " + json.dumps(summary, ensure_ascii=False))
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
"""Archive the DGX original and prepare a gapless-container loop before Unity import."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import imageio_ffmpeg
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / "Tools/output/main_menu_music"
DESIGN = ROOT / "Doc/Design/Sound/MainMenu"
RATE = 48000
def main():
global WORK, DESIGN
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--work", type=Path, default=WORK)
parser.add_argument("--design", type=Path, default=DESIGN)
parser.add_argument("--stem", default="CandyCamp_MainMenu")
parser.add_argument("--title", default="Candy Camp - Main Menu")
parser.add_argument("--crossfade-bars", type=float, default=2.0)
parser.add_argument("--target-rms-dbfs", type=float)
args = parser.parse_args()
WORK, DESIGN = args.work.resolve(), args.design.resolve()
if not args.stem.replace("_", "").isalnum():
raise SystemExit("Stem must contain letters, numbers and underscores only")
stem = args.stem
downloaded = json.loads((WORK / "downloaded.json").read_text(encoding="utf-8"))
source = WORK / next(name for name in downloaded.values() if name.endswith(".mp3"))
metadata = json.loads(source.with_suffix(".json").read_text(encoding="utf-8"))
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
decoded = subprocess.run(
[ffmpeg, "-v", "error", "-i", str(source), "-f", "f32le", "-acodec",
"pcm_f32le", "-ar", str(RATE), "-ac", "2", "-"],
capture_output=True, check=True,
)
pcm = np.frombuffer(decoded.stdout, dtype="<f4").reshape(-1, 2)
# Use LM-planned BPM and meter. Trim the final fade to the last full
# planned bar; overlap two bars. This smooths the waveform transition; it
# is not a claim of human-verified harmonic or rhythmic seamlessness.
bar_seconds = 60.0 / metadata["cot_bpm"] * int(metadata["cot_timesignature"])
end_seconds = np.floor((len(pcm) / RATE - 1.0) / bar_seconds) * bar_seconds
if args.crossfade_bars <= 0:
raise SystemExit("Crossfade bars must be positive")
overlap_seconds = args.crossfade_bars * bar_seconds
if end_seconds <= 2 * overlap_seconds:
raise SystemExit("Audio too short for selected crossfade")
n = round(overlap_seconds * RATE)
body = pcm[:round(end_seconds * RATE)]
t = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
blend = body[-n:] * np.cos(t * np.pi / 2) + body[:n] * np.sin(t * np.pi / 2)
loop = np.concatenate((body[n:-n], blend))
gain = min(1.0, 10 ** (-3 / 20) / float(np.max(np.abs(loop))))
if args.target_rms_dbfs is not None:
gain = min(gain, 10 ** (args.target_rms_dbfs / 20) / float(np.sqrt(np.mean(loop**2))))
loop = np.asarray(loop * gain, dtype="<f4")
output = WORK / (stem + "_Loop.ogg")
subprocess.run(
[ffmpeg, "-y", "-v", "error", "-f", "f32le", "-ar", str(RATE),
"-ac", "2", "-i", "-", "-c:a", "libvorbis", "-q:a", "5",
"-metadata", "title=" + args.title, "-metadata",
"comment=ACE-Step 1.5 XL SFT; cyclic crossfade", str(output)],
input=loop.tobytes(), capture_output=True, check=True,
)
verify = subprocess.run(
[ffmpeg, "-v", "error", "-i", str(output), "-f", "f32le", "-ar", str(RATE),
"-ac", "2", "-"], capture_output=True, check=True,
)
checked = np.frombuffer(verify.stdout, dtype="<f4").reshape(-1, 2)
assert np.isfinite(checked).all() and len(checked) > RATE * 30
assert np.max(np.abs(checked)) < 1.0, "Encoded output clips"
diffs = np.abs(np.diff(checked, axis=0))
seam_delta = float(np.max(np.abs(checked[-1] - checked[0])))
report = {
"date": "2026-10-20", "model": "acestep-v15-xl-sft",
"lm_model": "acestep-5Hz-lm-4B", "inference_steps": metadata["inference_steps"],
"seed": metadata["seed"], "planned_bpm": metadata["cot_bpm"],
"planned_key": metadata["cot_keyscale"], "planned_meter": metadata["cot_timesignature"],
"prompt": metadata["caption"], "lyrics": metadata["lyrics"],
"source": {"file": stem + "_Source.mp3", "seconds": len(pcm)/RATE,
"sample_rate": RATE, "channels": 2, "bitrate": "128 kbps",
"sha256": hashlib.sha256(source.read_bytes()).hexdigest()},
"loop": {"asset": "AIClient/Assets/Bundles/Sound/BGM/" + stem + "_Loop.ogg",
"seconds": len(checked)/RATE, "sample_rate": RATE, "channels": 2,
"bytes": output.stat().st_size, "codec": "Vorbis", "quality": 5,
"source_end_seconds": float(end_seconds), "crossfade_seconds": overlap_seconds,
"crossfade_planned_bars": args.crossfade_bars,
"target_rms_dbfs": args.target_rms_dbfs,
"gain_db": float(20*np.log10(gain)),
"decoded_peak_dbfs": float(20*np.log10(np.max(np.abs(checked)))),
"decoded_rms_dbfs": float(20*np.log10(np.sqrt(np.mean(checked**2)))),
"boundary_sample_delta": seam_delta,
"internal_sample_delta_p99": float(np.percentile(diffs, 99)),
"sha256": hashlib.sha256(output.read_bytes()).hexdigest()},
"verification": {"decode": "passed", "finite_samples": True, "clipping": False,
"musical_seam": "Cyclic crossfade applied; human listening review pending",
"runtime_playback": "Not wired; resource generation/import only"},
}
DESIGN.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, DESIGN / (stem + "_Source.mp3"))
shutil.copy2(source.with_suffix(".json"), DESIGN / "server_metadata.json")
(DESIGN / "generation.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print(json.dumps(report["loop"], indent=2))
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""Remove retired theme source files. Unity Assets are deleted separately via Pipeline."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def edit(rel, fn):
path = ROOT / rel
text = path.read_text(encoding='utf-8')
path.write_text(fn(text), encoding='utf-8')
def remove(rel):
path = (ROOT / rel).resolve()
assert path.is_relative_to(ROOT.resolve()) and not path.is_relative_to((ROOT / 'AIClient/Assets').resolve())
if not path.exists():
return
if path.is_dir():
for child in sorted(path.rglob('*'), key=lambda p: len(p.parts), reverse=True):
child.unlink() if child.is_file() else child.rmdir()
path.rmdir()
else:
path.unlink()
def main():
# Exact retired source paths; no wildcard deletion or Assets/.meta manipulation.
for rel in [
'Doc/UIPrefabCreater/UI_MainHUD_Studio.json', 'Doc/UIPrefabCreater/UI_ConfirmDialog_Studio.json',
'Doc/UIPrefabCreater/UI_MainHUD_Mint.json', 'Doc/UIPrefabCreater/UI_ConfirmDialog_Mint.json',
'Doc/UIPrefabCreater/UI_ThemeSwitcher.json',
'Doc/Config/version.studio.example.json', 'Doc/Config/version.mint.example.json',
'Doc/Design/UI/Studio', 'Doc/Design/UI/Mint', 'Doc/Design/UI/mint_sources.json',
'Doc/Design/UI/Home/UI_MainHUD_Studio.png', 'Doc/Design/UI/Home/UI_MainHUD_Mint.png',
'TempResource/commonv30_reference_sheet.png',
'AIClient/AgentScripts/BuildStudioUi.cs', 'AIClient/AgentScripts/BuildMintUi.cs', 'AIClient/AgentScripts/BuildUiAccess.cs',
'Tools/generate_studio_ui.py', 'Tools/generate_mint_ui.py', 'Tools/generate_mint_docs.py', 'Tools/generate_theme_switcher.py',
]:
remove(rel)
def generator(s):
if "def generate(theme='candy'):" not in s:
return s
start = s.index("def generate(theme='candy'):")
end = s.index(' nodes, events = [], []', start)
s = s[:start] + '''def generate():
theme = 'candy'
name = 'UI_MainHUD'
panel, primary, secondary, ink, muted, light = ('ui_panel_content', 'btn_primary_normal', 'btn_secondary_normal', '#5A4632', '#687C8C', '#F6F8FF')
''' + s[end:]
s = s.replace("theme == 'candy' and sprite", "sprite")
s = s.replace("panel_color = '#FFFFEF' if theme == 'mint' else '#FFFFFF'", "panel_color = '#FFFFFF'")
s = s.replace("primary_color = '#32DE92' if theme == 'mint' else '#FFFFFF'", "primary_color = '#FFFFFF'")
s = s.replace("'ui_panel_header' if theme == 'candy' else panel", "'ui_panel_header'")
s = s.replace("light if theme == 'candy' else ink", "light")
s = s.replace("theme == 'candy'", 'True')
s = s.replace("key == 'Home' and True", "key == 'Home'")
s = s.replace("folder = 'Home' if s.startswith('home_') else 'Mint' if s.startswith('mint_') else 'Studio' if s.startswith('studio_') else 'Common'", "folder = 'Home' if s.startswith('home_') else 'Common'")
s = s.replace(" for theme in ['candy', 'studio', 'mint']:\n generate(theme)", " generate()")
return s
edit('Tools/generate_home_ui.py', generator)
edit('AIClient/AgentScripts/BuildHomeUi.cs', lambda s: s.replace('static readonly string[] Names = { "UI_MainHUD", "UI_MainHUD_Studio", "UI_MainHUD_Mint" };', 'static readonly string[] Names = { "UI_MainHUD" };').replace('built 3 home HUD prefabs from JSON.', 'built Candy home HUD prefab from JSON.').replace('Rendered 3 theme previews, a tall-screen preview and reconnect preview:', 'Rendered Candy, tall-screen and reconnect previews:'))
def manager(s):
if ' /// <summary>预加载全部主题面板' not in s:
return s
start = s.index(' /// <summary>预加载全部主题面板')
end = s.index(' public void Close(', start)
return (s[:start] + s[end:]).replace(' public bool IsSwitchingTheme { get; private set; }\n', '')
edit('AIClient/Assets/HotUpdate/Scripts/UI/UiManager.cs', manager)
edit('AIClient/Assets/AOT/Scripts/Boot/BootSettings.cs', lambda s: s.replace('业务 UI 主题:candy(糖果风)/ studio(深绿工作室)/ mint(奶油薄荷绿);version.json 的 uiTheme 非空时覆盖。重新启动生效。', '业务 UI 当前仅支持 candy(糖果风);保留该字段兼容旧配置,其他值回退到 Candy。'))
edit('AIClient/Assets/AOT/Scripts/Boot/BootContext.cs', lambda s: s.replace('启动时选定的业务 UI 主题:candy / studio / mint。', '业务 UI 当前仅使用 candy;字段保留用于旧启动配置兼容。'))
edit('AIClient/Assets/AOT/Scripts/Patch/VersionInfo.cs', lambda s: s.replace('空值沿用 BootSettings;candy / studio / mint,重新启动生效。', '兼容旧版本字段;当前 UI 仅支持 candy,其他主题值回退到 Candy。'))
edit('AIClient/Assets/Editor/UI/SetupUiBundleCollector.cs', lambda s: s.replace('把所有主题的 UI Prefab 收进 Art', '把 Candy UI Prefab 收进 Art').replace('Candy + Studio + Mint UI themes', 'Candy UI').replace('Art/UI collects all three themes by prefab name', 'Art/UI collects Candy UI by prefab name'))
def overview(s):
if '并列主题:' not in s:
return s
start = s.index('并列主题:')
end = s.index('## 2.', start)
return s[:start] + '当前仅使用 **Candy 糖果风**。Studio / Mint 已按用户要求删除;保留旧 `uiTheme` 字段的读取兼容,其他值回退 Candy。当前主界面继续走 JSON→Prefab,详见 `Doc/Design/UI/UI_Themes.md`。\n\n' + s[end:]
edit('Doc/UI_AutoGen_Overview.md', overview)
def project(s):
if '## 并列 UI 主题' not in s:
return s
start = s.index('## 并列 UI 主题')
end = s.index('## 与图集方案关系', start)
return s[:start] + '## UI 风格\n\n- 当前仅使用 Candy 糖果风;Studio / Mint 主题与停用的切换入口已删除。\n- `BootSettings.UiTheme` / 远程 `version.json.uiTheme` 保留用于旧配置兼容,其他值回退 Candy。\n- 业务按逻辑名异步打开:`UiManager.OpenAsync`。\n- UI Prefab 收集进 Art/UI,图片与字体作为依赖进入资源包。\n\n' + s[end:]
edit('Doc/Rule/UnityUIProject.md', project)
generation = ROOT / 'Doc/Design/UI/Home/home_generation.json'
doc = json.loads(generation.read_text(encoding='utf-8'))
doc['ui']['prefabs'] = ['UI_MainHUD']
doc['validation']['previews'] = ['UI_MainHUD.png', 'UI_MainHUD_tall.png', 'UI_MainHUD_reconnect.png']
doc['themeRemoval'] = {'date': '2026-10-14', 'activeTheme': 'candy', 'removed': ['Studio', 'Mint', 'ThemeSwitcher'], 'note': 'Earlier multi-theme checks are historical; current builder only generates Candy.'}
generation.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print('Removed retired source/preview/config files; updated Candy-only generation and runtime compatibility.')
if __name__ == '__main__':
main()
+54
View File
@@ -0,0 +1,54 @@
"""Rig a same-character GLB on configured DGXA and retain source provenance.
Only explicit --generate starts inference. Reuses a byte-verified cached result.
The approved low-poly mesh is preserved and receives these weights later.
"""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import sys
import time
import uuid
REPO=Path(__file__).resolve().parents[1]
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source',type=Path,required=True)
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--generate',action='store_true')
args=parser.parse_args()
source=args.source.resolve();out=args.out.resolve()
if not source.is_relative_to(REPO) or not out.is_relative_to(REPO):
parser.error('Character source and output must stay in the repository')
if source.suffix.lower()!='.glb' or not source.is_file():
parser.error('Expected this character\'s source GLB')
source_hash=digest(source);receipt=out/'rig_request.json';target=out/'rigged.glb'
if receipt.is_file() and target.is_file():
record=json.loads(receipt.read_text(encoding='utf-8'))
if record['source_sha256']==source_hash and record['rig_sha256']==digest(target):
print('PASS cached same-source rig',target);return
if not args.generate:
parser.error('No verified cache. --generate explicitly requests DGXA rigging')
sys.path.insert(0,str(REPO/'Tools'))
from dgx3d_animal_pipeline import _ensure_llm3d
_ensure_llm3d()
from backend.tokenrig_runner import run_tokenrig
stage=out/'generations'/uuid.uuid4().hex;stage.mkdir(parents=True)
result=Path(run_tokenrig(source,stage,stage/'tokenrig.log',skeleton_template='original'))
if not result.is_file() or result.stat().st_size<100:
raise RuntimeError('Missing TokenRig result')
record=dict(status='generated',source=str(source.relative_to(REPO)),source_sha256=source_hash,
rig_sha256=digest(result),template='original',stage=str(stage.relative_to(REPO)),
generated_at_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
shutil.copy2(result,target)
temporary=receipt.with_suffix('.pending.json')
temporary.write_text(json.dumps(record,indent=2),encoding='utf-8');temporary.replace(receipt)
print('PASS same-source rig',target,flush=True)
if __name__=='__main__':main()
+94
View File
@@ -0,0 +1,94 @@
"""Run the four authored stages of the battlefield scene workflow.
The command is deliberately an orchestrator: it owns stage ordering and
status checks, while image/model/Unity work remains in the existing tools.
Existing Unity component prefabs are reused by default during stage 2.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "Doc/Design/Scene/Battlefield/battlefield_scene_manifest.json"
def load_manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8-sig"))
def design_ready(data: dict, design_id: str) -> bool:
option = next((x for x in data.get("designOptions", []) if x.get("id") == design_id), None)
generation = (option or {}).get("generation", {})
return generation.get("model") == "gpt-image-2.5" and generation.get("status") == "generated"
def run(command: list[str], dry_run: bool) -> None:
print("$", " ".join(f'"{part}"' if " " in part else part for part in command))
if not dry_run:
subprocess.run(command, cwd=ROOT, check=True)
def unity_command(entry: str) -> list[str]:
return [
"unity",
"command",
"--project-path",
str(ROOT / "AIClient"),
"run_script",
"--file",
"AgentScripts/BuildSceneReview.cs",
"--entry",
entry,
]
def main() -> int:
parser = argparse.ArgumentParser(description="Orchestrate design -> component sheets -> model plan -> Unity scene review")
parser.add_argument("--design", choices=["A", "B", "C"], required=True)
parser.add_argument("--dry-run", action="store_true", help="只打印阶段命令,不调用图像服务或 Unity")
parser.add_argument("--force-design", action="store_true", help="即使总览图已存在也重新生成")
parser.add_argument("--force-new-components", action="store_true", help="不复用已有组件,重新生成组件设计图")
parser.add_argument("--run-unity", action="store_true", help="阶段 4 直接调用已运行的 Unity Editor")
args = parser.parse_args()
data = load_manifest()
print("[1/4] 生成关卡总览设计图并等待选择")
if args.force_design or not design_ready(data, args.design):
command = [sys.executable, str(ROOT / "Tools/generate_battlefield_designs.py"), "--design", args.design]
if args.dry_run:
command.append("--dry-run")
run(command, args.dry_run)
else:
print(f"reuse overview: design {args.design} is already generated")
if not args.dry_run:
run([sys.executable, str(ROOT / "Tools/battlefield_scene_pipeline.py"), "select", args.design], False)
print("[2/4] 按总览图拆分地表和组件设计图(已有组件默认复用)")
command = [sys.executable, str(ROOT / "Tools/generate_battlefield_components.py"), "--design", args.design]
if args.dry_run:
command.append("--dry-run")
if args.force_new_components:
command.append("--force-new-components")
run(command, args.dry_run)
print("[3/4] 3D 模型生成与低模烘焙")
print("完成组件图审核后,按 manifest 中每个组件执行 Hunyuan 3D -> Blender 减面/烘焙 -> Assets/Bundles 导入。")
print("模型未登记到 manifest 前不会进入 Unity 场景阶段。")
print("[4/4] Unity 场景生成与人工审核")
print("$", " ".join(unity_command("BuildSceneReview.BuildAndOpenReview")))
if args.run_unity:
run(unity_command("BuildSceneReview.BuildAndOpenReview"), False)
else:
print("use --run-unity after the model stage, with Unity Editor and Pipeline running")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
# 已验收静态模 → TokenRig+UniMate → 入库 → SetupAnimalAnimator
$ErrorActionPreference = "Stop"
Set-Location (Split-Path $PSScriptRoot -Parent)
Write-Host "=== caps ==="
python Tools/dgx3d_animal_pipeline.py --caps-only
if ($LASTEXITCODE -ne 0) { throw "TokenRig/UniMate still red" }
Write-Host "=== pipeline RockBall (approved mesh) ==="
python Tools/dgx3d_animal_pipeline.py `
--mesh AIClient/Assets/Bundles/Characters/Animal/RockBall/RockBall.fbx `
-o Tools/output/rockball_dgx `
--name RockBall
if ($LASTEXITCODE -ne 0) { throw "pipeline failed" }
$manifest = Get-ChildItem Tools/output/rockball_dgx -Filter "RockBall_manifest.json" | Select-Object -First 1
if (-not $manifest) { throw "manifest missing" }
Write-Host "=== install bundles ==="
python Tools/install_animal_rigged.py -m $manifest.FullName --kind Animal
if ($LASTEXITCODE -ne 0) { throw "install failed" }
Write-Host "=== Unity SetupAnimalAnimator ==="
unity command --project-path D:/UD/AI/AIGame/AIClient run_script --file AgentScripts/SetupAnimalAnimator.cs --entry SetupAnimalAnimator.SetupRockBall
if ($LASTEXITCODE -ne 0) { throw "SetupAnimalAnimator failed" }
Write-Host "DONE RockBall approved-mesh → TokenRig+UniMate"
+60
View File
@@ -0,0 +1,60 @@
param([switch]$Generate, [switch]$BuildReview)
$ErrorActionPreference = 'Stop'
$repoPath = Split-Path -Parent $PSScriptRoot
$unityProject = Join-Path $repoPath 'AIClient'
$workPath = Join-Path $repoPath 'Tools/output/rockball_dgx/walk_verified'
$rigPath = Join-Path $repoPath 'Tools/output/rockball_dgx/high_rig/rigged.glb'
$lowPath = Join-Path $unityProject 'Assets/Bundles/Characters/Animal/RockBall/RockBall.fbx'
$blenderPath = if ($env:BLENDER_EXE) { $env:BLENDER_EXE } else { 'D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe' }
function Invoke-WalkUnity {
param([string[]]$CommandArgs)
$raw = & unity --json command --project-path $unityProject @CommandArgs
if ($LASTEXITCODE -ne 0) { throw "Unity CLI failed: $CommandArgs" }
$response = ($raw -join "`n") | ConvertFrom-Json
if (-not $response.success -or $response.data.result.success -eq $false) {
throw ($raw -join "`n")
}
$response.data.result
}
Push-Location -LiteralPath $repoPath
try {
Invoke-WalkUnity -CommandArgs @('set_autotick','--enable','true') | Out-Host
$editorState = Invoke-WalkUnity -CommandArgs @('editor_status')
if ($editorState.playMode -ne 'stopped' -or $editorState.compiling) { throw 'Use an idle Editor in Edit Mode.' }
if ($Generate) {
& python Tools/generate_animal_walk.py --rig $rigPath --out "$workPath/motion" --face-right bone_8 --face-left bone_10
if ($LASTEXITCODE -ne 0) { throw 'Walk generation failed' }
}
if (-not (Test-Path -LiteralPath "$workPath/motion/animated.fbx")) { throw 'Missing cached Walk: use -Generate first.' }
& $blenderPath -b --factory-startup --python Tools/model_tools/bl_transfer_skin.py -- $rigPath $lowPath "$workPath/RockBall_Rigged.fbx" --low-up Y --align-bounds
if ($LASTEXITCODE -ne 0) { throw 'Skin transfer failed' }
$assetRoot = 'Assets/Bundles/Characters/Animal/RockBall'
Invoke-WalkUnity -CommandArgs @('import_asset','--source',"$workPath/RockBall_Rigged.fbx",'--path',"$assetRoot/Rigged/RockBall_Rigged.fbx",'--confirm','true') | Out-Host
Invoke-WalkUnity -CommandArgs @('import_asset','--source',"$workPath/motion/animated.fbx",'--path',"$assetRoot/Anims/RockBall_Walk.fbx",'--confirm','true') | Out-Host
foreach ($step in @(
@('SetupAnimalAnimator','SetupAnimalAnimator.SetupRockBallWalk'),
@('FinalizeAnimalWalk','FinalizeAnimalWalk.Run'),
@('VerifyAnimalWalk','VerifyAnimalWalk.Run')
)) {
Invoke-WalkUnity -CommandArgs @('run_script','--file',"AgentScripts/$($step[0]).cs",'--entry',$step[1]) | Out-Host
}
if ($BuildReview) {
Invoke-WalkUnity -CommandArgs @('run_script','--file','AgentScripts/ReviewRockBallWalk.cs','--entry','ReviewRockBallWalk.Build') | Out-Host
}
$manifest = @{
ok = $true; name = 'RockBall'; pipeline = 'same-character-high-12bone -> aligned-approved-low -> UniMate biped Walk -> offline authored gait'
rigged_fbx = "$workPath/RockBall_Rigged.fbx"; source_mesh = $lowPath; source_rig = $rigPath
source_mesh_sha256 = (Get-FileHash -LiteralPath $lowPath -Algorithm SHA256).Hash
source_rig_sha256 = (Get-FileHash -LiteralPath $rigPath -Algorithm SHA256).Hash
anims = @{ Walk = "$workPath/motion/animated.fbx" }
setup_entry = 'SetupAnimalAnimator.SetupRockBallWalk'
postprocess = @('AgentScripts/FinalizeAnimalWalk.cs -> FinalizeAnimalWalk.Run','AgentScripts/VerifyAnimalWalk.cs -> VerifyAnimalWalk.Run')
notes = 'Walk only; old 19-bone Idle/Attack must not be connected to this 12-bone rig.'
}
$manifestPath = Join-Path $repoPath 'Tools/output/rockball_dgx/RockBall_manifest.json'
[IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false))
Write-Host 'PASS: RockBall Walk imported, authored and verified.'
}
finally { Pop-Location }
+27
View File
@@ -0,0 +1,27 @@
"""Unlock only the isolated UI-test account at the two camp progression boundaries."""
import json
from pathlib import Path
import sqlite3
import sys
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'account.txt').read_text(encoding='utf-8-sig').strip()
mode = sys.argv[1] if len(sys.argv) == 2 else ''
if test_root not in database.parents or not username.startswith('g1live_camp_') or mode not in ('rest', 'build', 'poor'):
raise RuntimeError('Refusing fixture outside isolated camp account or invalid boundary')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
state = json.loads(row[1])
if state['schema'] not in (4, 5, 6, 7, 8) or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish isolated pending activities before camp fixture')
state['cleared'] = True
state['clearedStages'] = [1, 1, 1, 0, 0] if mode == 'rest' else [1, 1, 1, 1, 1]
if mode == 'rest':
state.update(coins=1000, stages=[1, 1, 1, 1, 1], captureTutorialDone=True)
if mode == 'poor':
state['coins'] = 239
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated camp boundary:', mode)
+28
View File
@@ -0,0 +1,28 @@
"""Prepare chest UI cases only in the isolated chest account/database."""
import json
from pathlib import Path
import sqlite3
import sys
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'chest-db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'chest-account.txt').read_text(encoding='utf-8-sig').strip()
mode = sys.argv[1] if len(sys.argv) == 2 else ''
if test_root not in database.parents or not username.startswith('g1live_chest_') or mode not in ('choice', 'lost', 'empty'):
raise RuntimeError('Refusing non-isolated chest fixture')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
state = json.loads(row[1])
if state['schema'] != 8 or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish isolated pending activities first')
if mode == 'choice':
state['chests'] = [0, 1, 0]
elif mode == 'lost':
state['chests'] = [1, 0, 0]
else:
state['chests'] = [0, 0, 0]
state['chestDailyClaimed'] = 1
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated chest UI case:', mode)
+23
View File
@@ -0,0 +1,23 @@
"""Prepare only the isolated UI-test account at a deterministic pity boundary."""
import json
from pathlib import Path
import sqlite3
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'account.txt').read_text(encoding='utf-8-sig').strip()
if test_root not in database.parents or not username.startswith('g1live_'):
raise RuntimeError('Refusing to edit a database/account outside the isolated live fixture')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
if row is None:
raise RuntimeError('Isolated account has no game state')
state = json.loads(row[1])
if state['schema'] not in (3, 4, 5, 6, 7, 8) or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish pending test activities before preparing capture fixture')
state.update(tickets=10, stages=[1, 1, 1, 1, 0], captureTutorialDone=True, captureCount=4, duplicateStreak=4)
state['shards'][0] = 80
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated capture fixture: four duplicates, only water partner missing, ten tickets')
+28
View File
@@ -0,0 +1,28 @@
"""Set visual verification boundaries only in the isolated daily test account."""
import json
from pathlib import Path
import sqlite3
import sys
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'account.txt').read_text(encoding='utf-8-sig').strip()
mode = sys.argv[1] if len(sys.argv) == 2 else ''
if test_root not in database.parents or not username.startswith('g1live_daily_') or mode not in ('rest', 'signin3', 'complete'):
raise RuntimeError('Refusing to edit non-isolated daily fixture')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
state = json.loads(row[1])
if state['schema'] not in (5, 6, 7, 8) or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish isolated pending activities first')
if mode == 'rest':
state['clearedStages'] = [1, 1, 1, 0, 0]
elif mode == 'signin3':
state['signinDays'] = 3
else:
state['signinDays'] = 7
state['signinClaimed'] = [1] * 7
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated daily UI boundary:', mode)
+31
View File
@@ -0,0 +1,31 @@
"""Prepare only the isolated inventory UI account; never touch the real development DB."""
import json
from pathlib import Path
import sqlite3
import sys
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'account.txt').read_text(encoding='utf-8-sig').strip()
mode = sys.argv[1] if len(sys.argv) == 2 else ''
if test_root not in database.parents or not username.startswith('g1live_inventory_') or mode not in ('coins', 'decor', 'poor', 'reset'):
raise RuntimeError('Refusing non-isolated inventory fixture')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
state = json.loads(row[1])
if state['schema'] not in (6, 7, 8) or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish isolated pending activities first')
if mode == 'coins':
state.update(coins=1000, cleared=True, clearedStages=[1, 1, 1, 0, 0])
state['shards'][0] = 40
elif mode == 'decor':
state.update(clearedStages=[1, 1, 1, 1, 1], decorations=[1, 0, 0], campLayout=[-1, -1, -1])
elif mode == 'poor':
state['coins'] = 99
state['shopBought'][1] = 0
else:
state['dailyDay'] -= 1
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated inventory UI boundary:', mode)
+45
View File
@@ -0,0 +1,45 @@
"""Prepare mail UI cases only in the isolated mail account/database."""
import json
from pathlib import Path
import sqlite3
import sys
import time
root = Path(__file__).resolve().parents[1]
test_root = (root / 'Tools/output/g1-live').resolve()
database = Path((test_root / 'mail-db.path').read_text(encoding='utf-8-sig').strip()).resolve()
username = (test_root / 'mail-account.txt').read_text(encoding='utf-8-sig').strip()
mode = sys.argv[1] if len(sys.argv) == 2 else ''
if test_root not in database.parents or not username.startswith('g1live_mail_') or mode not in ('mixed', 'lost', 'empty', 'expired'):
raise RuntimeError('Refusing non-isolated mail fixture')
with sqlite3.connect(database) as conn:
row = conn.execute('SELECT u.pid,g.state FROM users u JOIN game_states g ON g.pid=u.pid WHERE u.username=?', (username,)).fetchone()
state = json.loads(row[1])
if state['schema'] != 7 or state.get('pendingShot') or state.get('pendingBattle'):
raise RuntimeError('Finish isolated pending activities first')
now = int(time.time())
def letter(i, reward=None, **flags):
m = dict(mailId='fixture_' + str(i), title='营地来信 ' + str(i),
body='伙伴们送来了问候。\n这是一封用于检查列表、附件和有效期的来信。',
reward=dict(coins=0, candy=0, balls=0, tickets=0), sentAt=now-60,
expiresAt=now+3600, read=False, claimed=False, expired=False)
if reward:
m['reward'].update(reward)
m.update(flags)
return m
if mode == 'mixed':
state['mails'] = [letter(0, dict(coins=7, balls=1, tickets=1)),
letter(1, dict(balls=500), expiresAt=now-1),
letter(2, dict(balls=500), read=True, claimed=True),
letter(3), letter(4, dict(balls=2)),
letter(5, dict(candy=20)), letter(6), letter(7)]
state['mails'][5]['body'] = '伙伴来信\n' + ('这段正文可以上下滑动阅读。\n' * 30)
elif mode == 'lost':
state['mails'] = [letter('lost', dict(balls=5))]
elif mode == 'expired':
state['mails'] = [letter('expired', dict(balls=500), expiresAt=now-1)]
else:
state['mails'] = []
state['revision'] += 1
conn.execute('UPDATE game_states SET state=? WHERE pid=?', (json.dumps(state, ensure_ascii=False), row[0]))
print('Prepared isolated mail UI case:', mode)
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = 'Stop'
$taskRoot = Split-Path -Parent $PSScriptRoot
$taskServer = Join-Path $taskRoot 'AIServer/build/Release/aiserver.exe'
$taskData = Join-Path $taskRoot 'AIServer/data/aiserver.db'
$taskLogs = Join-Path $taskRoot 'Tools/output'
try {
$taskHealth = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/health' -TimeoutSec 2
if ($taskHealth.ok -eq $true -and $taskHealth.service -eq 'aiserver') {
Write-Output 'AIServer already healthy at http://127.0.0.1:8080'
exit 0
}
} catch { }
if (-not (Test-Path -LiteralPath $taskServer)) {
throw 'Build AIServer first: cmake --build AIServer/build --config Release'
}
New-Item -ItemType Directory -Path $taskLogs -Force | Out-Null
$taskProcess = Start-Process -FilePath $taskServer -ArgumentList @(
'--host', '127.0.0.1', '--port', '8080', '--db', $taskData, '--daemon'
) -WorkingDirectory (Join-Path $taskRoot 'AIServer') -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $taskLogs 'aiserver-local.stdout.log') `
-RedirectStandardError (Join-Path $taskLogs 'aiserver-local.stderr.log')
for ($taskAttempt = 0; $taskAttempt -lt 15; $taskAttempt++) {
Start-Sleep -Milliseconds 200
if ($taskProcess.HasExited) { throw 'AIServer exited; inspect Tools/output/aiserver-local.stderr.log' }
try {
$taskHealth = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/health' -TimeoutSec 1
if ($taskHealth.ok -eq $true -and $taskHealth.service -eq 'aiserver') {
Write-Output "AIServer healthy at http://127.0.0.1:8080 (PID $($taskProcess.Id))"
exit 0
}
} catch { }
}
throw 'AIServer did not become healthy at http://127.0.0.1:8080'
+130
View File
@@ -0,0 +1,130 @@
"""Exercise the real AIServer HTTP boundary in an isolated database/process (no account logs)."""
import concurrent.futures
import http.client
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import time
import uuid
def main():
root = Path(__file__).resolve().parents[1]
exe = root / "AIServer/build-g1/Release/aiserver.exe"
with tempfile.TemporaryDirectory(prefix="g1-http-", dir=root / "Tools/output") as folder:
folder = Path(folder)
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
args = [str(exe), "--host", "127.0.0.1", "--port", str(port), "--db", str(folder / "test.db"), "--daemon"]
proc = None
def start():
nonlocal proc
proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
for _ in range(100):
if proc.poll() is not None:
raise RuntimeError("test server exited")
try:
if request("GET", "/health")[1].get("gameProtocol") == 1:
return
except OSError:
pass
time.sleep(.05)
raise RuntimeError("test server did not start")
def request(method, path, body=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
try:
payload = json.dumps(body) if body is not None else None
conn.request(method, path, payload, headers)
response = conn.getresponse()
return response.status, json.loads(response.read())
finally:
conn.close()
try:
start()
assert request("GET", "/game/state")[0] == 401
accounts = []
for name in ("alice", "bob"):
status, account = request("POST", "/register", {"username": "g1_" + name, "password": "testing-password-123"})
assert status == 200
accounts.append(account)
a, b = accounts
status, first = request("GET", "/game/state", token=a["token"])
assert status == 200 and first["state"]["balls"] == 20
command = {"requestId": uuid.uuid4().hex, "action": "shot.begin", "aim": 5, "revision": first["state"]["revision"], "pid": b["pid"]}
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
results = list(pool.map(lambda _: request("POST", "/game/command", command, a["token"]), range(24)))
assert all(status == 200 and value["ok"] and value["state"]["balls"] == 19 for status, value in results)
assert request("GET", "/game/state", token=b["token"])[1]["state"]["balls"] == 20
# Replay remains valid after process restart, without trusting a supplied pid.
proc.terminate(); proc.wait(timeout=5); start()
status, replay = request("POST", "/game/command", command, a["token"])
assert status == 200 and replay["replayed"] and replay["state"]["balls"] == 19
assert replay["shot"]["path"] == results[0][1]["shot"]["path"]
changed = dict(command, aim=10)
assert request("POST", "/game/command", changed, a["token"])[0] == 409
time.sleep(max(0, replay["shot"]["readyAt"] - time.time()) + .1)
finish = {"requestId": uuid.uuid4().hex, "action": "shot.finish", "activityId": command["requestId"], "revision": replay["state"]["revision"], "coins": 999999}
status, settled = request("POST", "/game/command", finish, a["token"])
assert status == 200 and settled["ok"]
assert settled["state"]["coins"] == replay["shot"]["coins"]
assert request("POST", "/game/command", finish, a["token"])[1]["state"] == settled["state"]
assert settled['state']['schema'] == 9 and len(settled['state']['mails']) == 2 and len(settled['state']['stages']) == 10 and len(settled['state']['clearedStages']) == 20
assert settled['state']['chests'] == [0, 0, 0] and settled['state']['chestDailyClaimed'] == 0 and len(settled['state']['shards']) == 10
mail_id = settled['state']['mails'][0]['mailId']
mail = {'requestId': uuid.uuid4().hex, 'action': 'mail.claim', 'mailId': mail_id,
'revision': settled['state']['revision'], 'reward': {'coins': 999999}}
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
receipts = list(pool.map(lambda _: request('POST', '/game/command', mail, a['token']), range(24)))
assert all(status == 200 and value['ok'] and value['result']['claimedCount'] == 1 for status, value in receipts)
claimed = receipts[0][1]
assert claimed['state']['balls'] == settled['state']['balls'] + 5
assert claimed['state']['candy'] == settled['state']['candy'] + 20
assert claimed['state']['coins'] == settled['state']['coins']
bob = request('GET', '/game/state', token=b['token'])[1]
assert not bob['state']['mails'][0]['claimed'] and bob['state']['balls'] == 20
proc.terminate(); proc.wait(timeout=5); start()
mail_replay = request('POST', '/game/command', mail, a['token'])[1]
assert mail_replay['replayed'] and mail_replay['result'] == claimed['result']
assert mail_replay['state']['balls'] == claimed['state']['balls']
mark = {'requestId': uuid.uuid4().hex, 'action': 'mail.read', 'mailId': mail_id, 'revision': bob['state']['revision']}
marked = request('POST', '/game/command', mark, b['token'])[1]
assert marked['ok'] and marked['state']['mails'][0]['read'] and marked['state']['balls'] == 20
# Daily chest adds a box without awarding contents; opening it awards once under retry.
daily = {'requestId': uuid.uuid4().hex, 'action': 'chest.claimDaily', 'revision': claimed['state']['revision']}
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
boxes = list(pool.map(lambda _: request('POST', '/game/command', daily, a['token']), range(24)))
assert all(status == 200 and value['ok'] and value['result']['count'] == 1 for status, value in boxes)
boxed = boxes[0][1]
assert boxed['state']['chests'] == [1, 0, 0] and boxed['state']['chestDailyClaimed'] == 1
assert boxed['state']['coins'] == claimed['state']['coins'] and boxed['state']['candy'] == claimed['state']['candy']
opened = {'requestId': uuid.uuid4().hex, 'action': 'chest.open', 'chest': 0, 'revision': boxed['state']['revision']}
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
receipts = list(pool.map(lambda _: request('POST', '/game/command', opened, a['token']), range(24)))
assert all(status == 200 and value['ok'] and value['result']['remaining'] == 0 for status, value in receipts)
rewarded = receipts[0][1]
assert rewarded['state']['chests'] == [0, 0, 0]
assert rewarded['state']['coins'] == boxed['state']['coins'] + 60
assert rewarded['state']['candy'] == boxed['state']['candy'] + 30
assert rewarded['state']['balls'] == boxed['state']['balls'] + 2
assert bob['state']['chests'] == [0, 0, 0] and bob['state']['chestDailyClaimed'] == 0
(folder / "maintenance.json").write_text('{"enabled":true,"message":"testing"}', encoding="utf-8")
assert request("GET", "/game/state", token=a["token"])[0] == 503
assert request("POST", "/game/command", finish, a["token"])[0] == 503
print("PASS HTTP: bearer auth, account isolation, 24 concurrent shot/mail/chest retries, restart receipts, mail read/claim, trajectory, once-only settlement, maintenance")
finally:
if proc and proc.poll() is None:
proc.terminate(); proc.wait(timeout=5)
if __name__ == "__main__":
main()
@@ -0,0 +1,85 @@
"""Focused guards: anatomy/phase validation and atomic generation promotion."""
import copy
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest import mock
REPO=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(REPO/'Tools'))
import enemy_character_pipeline as pipeline
import generate_animal_walk as generator
class EnemyProductionTests(unittest.TestCase):
def setUp(self):
self.data=json.loads((REPO/'Config/Art/EnemyCharacters/RockBall.json').read_text(encoding='utf-8'))
def test_recipe_rejects_wrong_anatomy_and_phase_order(self):
with mock.patch.object(Path,'read_text',return_value=json.dumps(self.data)):
self.assertEqual(pipeline.recipe(Path('fixture'))['id'],'RockBall')
for change in ({'profile':'quadruped_unvalidated'},{'attackImpact':.1},{'walkStride':10}):
data={**self.data,**change}
with mock.patch.object(Path,'read_text',return_value=json.dumps(data)),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def test_generation_failure_preserves_previous_artifacts_and_receipt(self):
self.run_generation(False)
def test_companion_role_and_legacy_enemy_recipe(self):
with mock.patch.object(Path,'read_text',return_value=json.dumps(self.data)):
legacy=pipeline.recipe(Path('fixture'))
self.assertEqual(legacy['visualPrefab'],self.data['enemyPrefab'])
self.assertEqual(legacy['attackEvent'],'OnEnemyAttackImpact')
companion={**self.data,'usage':'companion_visual','attackStyle':'ranged_cast',
'visualPrefab':'Assets/Bundles/Characters/Animal/FireCat/FireCat_Companion.prefab'}
companion.pop('enemyPrefab')
with mock.patch.object(Path,'read_text',return_value=json.dumps(companion)):
result=pipeline.recipe(Path('fixture'))
self.assertEqual(result['attackEvent'],'OnCompanionAttackImpact')
for change in ({'usage':'unknown'},{'boneMappingReady':False},{'attackStyle':'unknown'},
{'visualPrefab':'../outside.prefab'},{'attackReach':20}):
with mock.patch.object(Path,'read_text',return_value=json.dumps({**companion,**change})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def test_generation_success_commits_matching_receipt_last(self):
self.run_generation(True)
def run_generation(self,success):
root=REPO/'Tools/output/enemy_pipeline_tests'
root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as temporary:
base=Path(temporary).resolve()
self.assertTrue(base.is_relative_to(root.resolve()))
out=base/'motion';out.mkdir()
rig=base/'rig_fixture.dat';rig.write_text('same-rig-fixture')
(out/'animated.fbx').write_text('previous-fixture')
(out/'motion_request.json').write_text('{"status":"previous"}')
runner=types.ModuleType('backend.unimate_runner')
runner._cfg=lambda: {'unimate_jobs_dir':'/fixture'}
runner._scp_to=lambda *a,**k: None
runner._ssh=lambda *a,**k: None
def submit(**kwargs):
if not success:raise RuntimeError('fixture inference failure')
(kwargs['directory']/'animated.fbx').write_text('new-fixture')
return {'remote_job':'fixture-only-no-network'}
runner.submit_unimate_job=submit
backend=types.ModuleType('backend');backend.__path__=[];backend.unimate_runner=runner
argv=['generate_animal_walk','--rig',str(rig),'--out',str(out),'--motion','Attack','--face-right','right','--face-left','left']
with mock.patch.dict(sys.modules,{'backend':backend,'backend.unimate_runner':runner}),mock.patch.object(sys,'argv',argv):
if success:generator.main()
else:
with self.assertRaises(RuntimeError):generator.main()
record=json.loads((out/'motion_request.json').read_text())
if success:
self.assertEqual((out/'animated.fbx').read_text(),'new-fixture')
self.assertEqual(record['status'],'generated')
self.assertEqual(record['source_fbx_sha256'],hashlib.sha256((out/'animated.fbx').read_bytes()).hexdigest())
else:
self.assertEqual((out/'animated.fbx').read_text(),'previous-fixture')
self.assertEqual(record,{'status':'previous'})
if __name__=='__main__':unittest.main()