Add prototype gameplay loop with character/battlefield assets and server game APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 减面烘焙对比
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user