Add rank, friends, fishing, and camp treasure with T2 studio models and skill presentation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""Freeze exact input bytes, export the C++ trace, and bind both to a receipt."""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
def sha(data):return hashlib.sha256(data).hexdigest()
|
||||
def canonical(value):return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')
|
||||
|
||||
if __name__=='__main__':
|
||||
inputs=ROOT/'Tools/output/skill_slice_inputs';inputs.mkdir(parents=True,exist_ok=True)
|
||||
rows=[]
|
||||
for source,field in [('Config/Gameplay/greenfield.json','frozenGameplay'),('Config/Combat/skill_system_v1.proposal.json','skillDefinitions'),('Config/Combat/skill_slice.json','scenario')]:
|
||||
data=(ROOT/source).read_bytes();target=inputs/Path(source).name;target.write_bytes(data)
|
||||
rows.append(dict(source=source,snapshot=str(target.relative_to(ROOT)).replace('\\','/'),field=field,sourceSha256=sha(data),payloadSha256=sha(canonical(json.loads(data)))))
|
||||
target=ROOT/'AIClient/Assets/Bundles/Combat/SkillSlice/trace.json';target.parent.mkdir(parents=True,exist_ok=True)
|
||||
subprocess.run([str(ROOT/'AIServer/build-g1/Release/combat_slice_tests.exe'),*[str(ROOT/r['snapshot']) for r in rows],str(target)],cwd=ROOT,check=True)
|
||||
folder=ROOT/'Doc/Design/Combat/SkillSlice';folder.mkdir(parents=True,exist_ok=True)
|
||||
(folder/'export.json').write_text(json.dumps(dict(status='exported',inputs=rows,traceSha256=sha(target.read_bytes())),indent=2),encoding='utf-8')
|
||||
@@ -38,9 +38,9 @@ def submit_request(req):
|
||||
return response.json()
|
||||
raise TimeoutError('TangoFlux queue stayed full for 120 seconds')
|
||||
|
||||
def generate(row):
|
||||
def generate(row, seed_base=62100):
|
||||
index, (name, prompt, length) = row
|
||||
req = dict(prompt=prompt + ', single isolated event at the beginning, silence afterwards, no music, no speech, no ambience', duration=2, steps=50, guidance_scale=4.5, seed=62100+index)
|
||||
req = dict(prompt=prompt + ', single isolated event at the beginning, silence afterwards, no music, no speech, no ambience', duration=2, steps=50, guidance_scale=4.5, seed=seed_base+index)
|
||||
key = hashlib.sha256(json.dumps(req, sort_keys=True).encode()).hexdigest()
|
||||
receipt = OUT / (name+'.json')
|
||||
source = OUT / (name+'_source.wav')
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Finish the four outstanding TangoFlux skill sounds, preserving actual planning seeds."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import json
|
||||
import numpy as np
|
||||
import requests
|
||||
from scipy.io import wavfile
|
||||
import generate_enemy_sfx as audio
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
DESIGN=ROOT/'Doc/Design/Sound/BattleSkills/Partners'
|
||||
audio.OUT=ROOT/'Tools/output/partner_skill_sfx'
|
||||
audio.BUNDLE=ROOT/'AIClient/Assets/Bundles/Sound/SFX/BattleSkills'
|
||||
LENGTHS={'Sfx_Heal':.55,'Sfx_Slow':.5,'Sfx_Lightning':.38,'Sfx_EnemyHit':.26}
|
||||
def generate(item):return audio.generate((item['seed'],(item['id'],item['prompt'],LENGTHS[item['id']])),seed_base=0)
|
||||
if __name__=='__main__':
|
||||
status=requests.get(audio.BASE+'/api/status',timeout=15).json()
|
||||
if not status.get('ready') or status.get('error'):raise RuntimeError('TangoFlux not ready: '+str(status))
|
||||
for folder in (DESIGN,audio.OUT,audio.BUNDLE):folder.mkdir(parents=True,exist_ok=True)
|
||||
path=ROOT/'Doc/Design/Sound/BattleSkills/manifest.json';manifest=json.loads(path.read_text(encoding='utf-8'))
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:items=list(pool.map(generate,[item for item in manifest['items'] if item['id'] in LENGTHS]))
|
||||
receipt='Doc/Design/Sound/BattleSkills/Partners/generation.json'
|
||||
(ROOT/receipt).write_text(json.dumps(dict(status='generated',service=audio.BASE,guide=manifest['guide'],items=items),indent=2),encoding='utf-8')
|
||||
rows={item['id']:item for item in items}
|
||||
for item in manifest['items']:
|
||||
if item['id'] in rows:
|
||||
row=rows[item['id']];item['production']=dict(output=row['output'],sha256=row['outputSha256'],receipt=receipt,scope='formal_partner_skills',actualRequest='Actual seed matches the planning seed; full request in receipt.')
|
||||
manifest['status']='generated';path.write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
preview=[]
|
||||
for item in manifest['items']:
|
||||
_,data=wavfile.read(ROOT/item['production']['output']);preview.extend((data,np.zeros((13230,2),dtype=np.int16)))
|
||||
wavfile.write(DESIGN/'preview_all_nine.wav',44100,np.concatenate(preview))
|
||||
@@ -74,6 +74,8 @@ def main():
|
||||
mail()
|
||||
challenges()
|
||||
rank()
|
||||
friends()
|
||||
fishing()
|
||||
settings()
|
||||
s = Screen('UI_PrototypeBag', '冒险背包')
|
||||
for i, title in enumerate(['消耗品', '伙伴材料', '装饰', '活动道具']):
|
||||
@@ -381,7 +383,8 @@ def challenges():
|
||||
s.image('DetailPanel', s.name, 48, 1020, 984, 340)
|
||||
s.text('Detail', 'DetailPanel', 28, 20, 928, 300, '选择一场试炼', 28)
|
||||
s.button('Start', s.name, 48, 1390, 984, 108, '开始试炼')
|
||||
s.button('Capture', s.name, 48, 1520, 984, 96, '前往捕捉', False)
|
||||
s.button('Capture', s.name, 48, 1520, 480, 96, '前往捕捉', False)
|
||||
s.button('Fish', s.name, 552, 1520, 480, 96, '小镇钓鱼', False)
|
||||
s.text('Rules', s.name, 64, 1640, 952, 80, '不消耗弹珠,不改主线阵容。徽章只领一次,再打只刷新用时纪录。', 24, MUTED, 'Center')
|
||||
s.save('G4 属性挑战 / Candy。JSON→Prefab;五场元素试炼由 PrototypeFlow 绑定,不运行时拼接业务骨架。')
|
||||
|
||||
@@ -402,6 +405,51 @@ def rank():
|
||||
s.save('G4 冒险排行 / Candy。JSON→Prefab;主线榜和五场试炼榜由 PrototypeFlow 绑定,不运行时拼接业务骨架。')
|
||||
|
||||
|
||||
def friends():
|
||||
s = Screen('UI_PrototypeFriends', '好友')
|
||||
s.text('Summary', s.name, 48, 186, 984, 78, '正在读取好友…', 26, MUTED, 'Center')
|
||||
for i, label in enumerate(['好友', '收到', '发出', '屏蔽']):
|
||||
s.button(f'FriendTab{i}', s.name, 32 + i * 256, 274, 248, 76, label, False)
|
||||
s.add('Input_Pid', s.name, s.rect(48, 364, 640, 86), [
|
||||
dict(type='Image', sprite='ui_panel_content', imageType='Sliced', raycastTarget=True),
|
||||
dict(type='TMP_InputField', characterLimit=12, contentType='IntegerNumber', lineType='SingleLine', textComponent='Txt_PidValue', placeholderComponent='Txt_PidHint', placeholder='输入玩家编号'),
|
||||
])
|
||||
s.text('PidValue', 'Input_Pid', 24, 16, 592, 54, '', 30)
|
||||
s.text('PidHint', 'Input_Pid', 24, 16, 592, 54, '输入玩家编号', 30, MUTED)
|
||||
s.button('Lookup', s.name, 708, 364, 324, 86, '查找', False)
|
||||
for i in range(8):
|
||||
s.button(f'FriendRow{i}', s.name, 48, 468 + i * 78, 984, 72, '', False, 'ui_list_item_normal')
|
||||
s.nodes[-1]['components'][1]['spritePressed'] = 'ui_list_item_selected'
|
||||
s.text(f'FriendName{i}', f'Btn_FriendRow{i}', 24, 8, 640, 56, '探险家', 28)
|
||||
s.text(f'FriendMeta{i}', f'Btn_FriendRow{i}', 680, 8, 280, 56, '关系', 26, MUTED, 'Right')
|
||||
s.text('FriendEmpty', s.name, 64, 640, 952, 120, '还没有好友', 32, MUTED, 'Center')
|
||||
s.image('Detail', s.name, 48, 1104, 984, 220)
|
||||
s.text('DetailName', 'Detail', 24, 16, 936, 48, '选择一位探险家', 30)
|
||||
s.text('DetailBody', 'Detail', 24, 72, 936, 128, '查找编号后可以申请。', 26, MUTED)
|
||||
for i, (name, title) in enumerate([('Request', '申请'), ('Accept', '接受'), ('Decline', '拒绝'), ('Cancel', '撤回'), ('Remove', '删除')]):
|
||||
s.button(name, s.name, 48 + i * 200, 1344, 188, 80, title, False)
|
||||
for i, (name, title) in enumerate([('Block', '屏蔽'), ('Unblock', '解除'), ('Gift', '赠礼'), ('Visit', '看营地')]):
|
||||
s.button(name, s.name, 48 + i * 252, 1440, 240, 80, title, False)
|
||||
s.button('Treasure', s.name, 48, 1536, 984, 88, '营地寻宝', False)
|
||||
s.text('Rules', s.name, 48, 1648, 984, 150, '好友、申请和屏蔽各最多 20 人。每天最多送出 5 次、收到 5 次。寻宝每天 3 次,同一位好友每天一次,奖励不从对方营地扣除。名单超过 8 人时,用编号查找。', 24, MUTED, 'Center')
|
||||
s.save('G4 好友页 / Candy。JSON→Prefab;编号查找、申请、阵容、定额赠礼和营地寻宝由 PrototypeFlow 绑定,不运行时拼接业务骨架。')
|
||||
|
||||
|
||||
def fishing():
|
||||
s = Screen('UI_PrototypeFishing', '小镇钓鱼')
|
||||
s.text('Summary', s.name, 48, 186, 984, 84, '通过 1-1 后开放。每局消耗 1 枚弹珠,每天 5 局。', 26, MUTED, 'Center')
|
||||
s.image('Pond', s.name, 48, 286, 984, 700)
|
||||
for i in range(8):
|
||||
col, row = i % 2, i // 2
|
||||
s.button(f'Fish{i}', s.name, 72 + col * 480, 310 + row * 168, 456, 152, '鱼影', False)
|
||||
s.text('FishStatus', s.name, 48, 1010, 984, 120, '点击开始后,在鱼影出现的时间点收竿。', 28, MUTED, 'Center')
|
||||
s.button('Start', s.name, 48, 1150, 984, 108, '开始钓鱼')
|
||||
s.button('Finish', s.name, 48, 1280, 480, 96, '收杆', False)
|
||||
s.button('Abandon', s.name, 552, 1280, 480, 96, '放弃', False)
|
||||
s.text('Rules', s.name, 48, 1660, 984, 150, '积分由服务器按点击时间计算,客户端不能直接提交分数。3 分起有金币,8 分额外有糖果。低于 3 分不返还弹珠,可以再开一局。超时后只能放弃。', 24, MUTED, 'Center')
|
||||
s.save('G4 钓鱼页 / Candy。JSON→Prefab;鱼汛由服务器冻结,点击时间回传后结算,不运行时拼接业务骨架。')
|
||||
|
||||
|
||||
def mail():
|
||||
s = Screen('UI_PrototypeMail', '小镇邮件')
|
||||
s.image('MailWallet', s.name, 40, 200, 1000, 104)
|
||||
@@ -436,5 +484,9 @@ if __name__ == '__main__':
|
||||
challenges()
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == 'rank':
|
||||
rank()
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == 'friends':
|
||||
friends()
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == 'fishing':
|
||||
fishing()
|
||||
else:
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Five TangoFlux one-shots for the FireCat/RockBall skill slice; retain original jobs."""
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from scipy.io import wavfile
|
||||
import generate_enemy_sfx as audio
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DESIGN = ROOT / 'Doc/Design/Sound/BattleSkills/Slice'
|
||||
audio.OUT = ROOT / 'Tools/output/skill_slice_sfx'
|
||||
audio.BUNDLE = ROOT / 'AIClient/Assets/Bundles/Sound/SFX/BattleSkills'
|
||||
manifest = json.loads((ROOT/'Doc/Design/Sound/BattleSkills/manifest.json').read_text(encoding='utf-8'))
|
||||
lengths = {'Sfx_FireCharge': .48, 'Sfx_FireLaunch': .28, 'Sfx_FireHit': .4, 'Sfx_RockHit': .32, 'Sfx_Shield': .65}
|
||||
|
||||
if __name__ == '__main__':
|
||||
status = requests.get(audio.BASE+'/api/status', timeout=15).json()
|
||||
if not status.get('ready') or status.get('error'):
|
||||
raise RuntimeError('TangoFlux not ready: '+str(status))
|
||||
for folder in (DESIGN, audio.OUT, audio.BUNDLE): folder.mkdir(parents=True, exist_ok=True)
|
||||
# The shared generator records its actual seed and request hash, not the planning manifest seed.
|
||||
rows = [(r['id'], r['prompt'], lengths[r['id']]) for r in manifest['items'] if r['id'] in lengths]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
items = list(pool.map(audio.generate, enumerate(rows)))
|
||||
(DESIGN/'generation.json').write_text(json.dumps({'status': 'generated', 'service': audio.BASE, 'guide': manifest['guide'], 'items': items}, indent=2), encoding='utf-8')
|
||||
preview = []
|
||||
for item in items:
|
||||
_, samples = wavfile.read(ROOT/item['output'])
|
||||
preview.extend((samples, np.zeros((13230, 2), dtype=np.int16)))
|
||||
wavfile.write(DESIGN/'preview.wav', 44100, np.concatenate(preview))
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""T2 evolution accessory concept drafts (one head accessory per partner) via the LAN local Turbo API.
|
||||
|
||||
T2 keeps the validated T1 mesh, rig and clips and adds a bone-attached accessory, so each draft shows the accessory ALONE,
|
||||
as a single solid object for image-to-3D. Image 1 = the partner's own design (palette/element only), image 2 = FireCat style.
|
||||
Drafts go to Doc/Design/Character/{Partner}/T2/drafts/.
|
||||
|
||||
Usage:
|
||||
python Tools/generate_t2_accessory_designs.py # all ten
|
||||
python Tools/generate_t2_accessory_designs.py FireCat # selected only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from generate_enemy_designs import ROOT, run, status
|
||||
|
||||
CHAR = ROOT / "Doc/Design/Character"
|
||||
STYLE = CHAR / "FireCat/firecat.png"
|
||||
|
||||
COMMON = (
|
||||
"Image 1 shows the companion creature that will wear this accessory: use it ONLY for its palette and element theme, do NOT draw the "
|
||||
"creature. Image 2 is STYLE ONLY: match its friendly polished chibi 3D mobile-game render and clean rounded readable shapes. "
|
||||
"Draw exactly ONE small evolution accessory object ALONE, as a game asset for image-to-3D: a chunky solid head-top piece with a "
|
||||
"flat or gently curved base that would sit on top of a round creature head, compact and roughly as wide as it is tall. "
|
||||
"Every part is one continuous attached solid piece: no floating gems, no separate sparks, droplets, petals or particles, no thin "
|
||||
"wires, no chains, no strings. Centered, front three-quarter view from slightly above, whole object in frame with generous margin. "
|
||||
"Pure solid white #FFFFFF background to all corners, soft studio light, tiny contact shadow. Simple smooth stylized surfaces suitable "
|
||||
"for a 400-triangle model with a 512 texture. No creature, no head, no body, no text, no watermark, no UI, no stand, no scenery."
|
||||
)
|
||||
|
||||
ACCESSORIES = [
|
||||
{"id": "RockBall", "pet": 0, "seed": 100001, "subject": "A rock-element evolution crown: a ring of three chunky faceted cyan crystals rising from a rounded grey stone base, the middle crystal tallest."},
|
||||
{"id": "FireCat", "pet": 1, "seed": 101001, "subject": "A fire-element evolution crest: a single solid stylized flame shape in orange, yellow and red, three soft rounded flame tongues rising from a small golden band base."},
|
||||
{"id": "BudBud", "pet": 2, "seed": 102001, "subject": "A grass-element evolution bloom: one large open five-petal flower in soft yellow and white with a golden center, sitting on a small cluster of three thick mint leaves as the base."},
|
||||
{"id": "SparkBird", "pet": 3, "seed": 103001, "subject": "A lightning-element evolution crest: a small golden crown with three solid zigzag lightning-bolt points in bright yellow and cyan."},
|
||||
{"id": "BubbleTurtle", "pet": 4, "seed": 104001, "subject": "A water-element evolution crown: a small rounded coral crown in soft pink and aqua with two smooth pearl spheres embedded in the front, all fused into one piece."},
|
||||
{"id": "RockRhino", "pet": 5, "seed": 105001, "subject": "A rock-element evolution helm crest: a chunky faceted teal crystal ridge, three crystals in a row from front to back, set in a rounded grey stone base."},
|
||||
{"id": "FlameLion", "pet": 6, "seed": 106001, "subject": "A fire-element evolution crown: a small golden crown with five rounded solid flame-shaped points in orange and red, a red gem embedded in the front."},
|
||||
{"id": "VineSprite", "pet": 7, "seed": 107001, "subject": "A grass-element evolution wreath crown: a thick ring of overlapping forest-green leaves with three fused pink blossoms on the front, solid and chunky."},
|
||||
{"id": "ThunderLeopard", "pet": 8, "seed": 108001, "subject": "A lightning-element evolution crown: a small silver-violet crown with three solid cyan zigzag lightning-crystal spikes, the middle one tallest."},
|
||||
{"id": "TideWhale", "pet": 9, "seed": 109001, "subject": "A water-element evolution crown: a rounded seashell crown in pale blue and white, a spiral shell in the middle flanked by two smaller shells, fused into one piece with a pearl in front."},
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
wanted = set(sys.argv[1:])
|
||||
with requests.Session() as session:
|
||||
state = status(session)
|
||||
if not (state.get("connected") and state.get("ready")):
|
||||
raise SystemExit(f"local images not ready: {state}")
|
||||
for a in ACCESSORIES:
|
||||
if wanted and a["id"] not in wanted:
|
||||
continue
|
||||
refs = [CHAR / a["id"] / f"{a['id'].lower()}.png", STYLE]
|
||||
# run() writes into {design}/{id}/drafts, so id "T2" lands them in Doc/Design/Character/{Partner}/T2/drafts.
|
||||
rec = run({**a, "id": "T2", "partner": a["id"], "name": a["id"] + " T2 accessory", "role": "T2 evolution head accessory"}, session, design=CHAR / a["id"], refs=refs, common=COMMON)
|
||||
print(json.dumps({"id": a["id"], "status": rec["status"], "drafts": len(rec["drafts"]), "error": rec["error"]}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Offline packaging/skin transfer of the Studio trial; preserve original finished animations."""
|
||||
import argparse,hashlib,json,shutil,subprocess,sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=ROOT/'Tools/output/enemy_studio_trial/GreatHorn'
|
||||
ASSET='Assets/Bundles/Characters/Enemy/GreatHornStudio'
|
||||
BLENDER='D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
|
||||
sys.path.insert(0,str(ROOT/'Tools/model_tools'))
|
||||
from mt_run import run_blender_script
|
||||
sys.path.insert(0,str(ROOT/'Tools'))
|
||||
from enemy_character_pipeline import unity,script,wait_editor
|
||||
|
||||
def blender(file,args,log):
|
||||
with (OUT/log).open('w',encoding='utf-8') as f:
|
||||
subprocess.run([BLENDER,'-b','-t','1','--factory-startup','--python',str(ROOT/'Tools/model_tools'/file),'--',*map(str,args)],stdout=f,stderr=subprocess.STDOUT,timeout=600,check=True)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--play-check',action='store_true');args=parser.parse_args()
|
||||
receipt=json.loads((OUT/'trial.json').read_text(encoding='utf-8'))
|
||||
assert receipt['status'] in ('mesh_ready','edit_validated','validated')
|
||||
for relative,digest in receipt['originals'].items():assert hashlib.sha256((ROOT/relative).read_bytes()).hexdigest()==digest,relative+' changed'
|
||||
blender('bl_prepare_studio_trial.py',[OUT],'static_export.log')
|
||||
result=run_blender_script(BLENDER,'bl_convert.py',[str(OUT/'static/GreatHornStudio.fbx'),str(OUT/'xpbr'),'1024'])
|
||||
(OUT/'xpbr.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
|
||||
print('XPbr packaging complete',flush=True)
|
||||
blender('bl_transfer_skin.py',[ROOT/'Tools/output/enemy_dgx/GreatHorn/high_rig/rigged.glb',OUT/'xpbr/GreatHornStudio.fbx',OUT/'GreatHornStudio_Rigged.fbx','--low-up','Y','--align-bounds'],'skin_transfer.log')
|
||||
print('Original rig weight transfer complete',flush=True)
|
||||
unity('set_autotick',enable=True)
|
||||
if unity('editor_status')['playMode']!='stopped':raise RuntimeError('Editor is in Play; offline trial is ready, import after it stops')
|
||||
for name in ['GreatHornStudio.fbx','GreatHornStudio_base.png','GreatHornStudio_mix.png']:
|
||||
unity('import_asset',source=str(OUT/'xpbr'/name),path=ASSET+'/'+name,confirm=True)
|
||||
unity('import_asset',source=str(OUT/'GreatHornStudio_Rigged.fbx'),path=ASSET+'/Rigged/GreatHornStudio_Rigged.fbx',confirm=True)
|
||||
script('SetupCharacter','SetupCharacter.Run',['Enemy','GreatHornStudio',.8])
|
||||
script('GreatHornStudioTrial','GreatHornStudioTrial.Prepare',[])
|
||||
script('GreatHornStudioTrial','GreatHornStudioTrial.ExportFootConstraints',[])
|
||||
blender('bl_fit_studio_soles.py',[OUT],'sole_fit.log')
|
||||
unity('import_asset',source=str(OUT/'GreatHornStudio_Rigged.fbx'),path=ASSET+'/Rigged/GreatHornStudio_Rigged.fbx',confirm=True)
|
||||
script('GreatHornStudioTrial','GreatHornStudioTrial.Build',[])
|
||||
script('GreatHornStudioTrial','GreatHornStudioTrial.BuildReview',[])
|
||||
script('GreatHornStudioTrial','GreatHornStudioTrial.Capture',[])
|
||||
receipt['status']='edit_validated'
|
||||
if args.play_check:
|
||||
unity('editor_play')
|
||||
try:
|
||||
wait_editor('playing');script('GreatHornStudioTrial','GreatHornStudioTrial.Play',[]);receipt['status']='validated'
|
||||
finally:unity('editor_stop');wait_editor('stopped')
|
||||
for relative,digest in receipt['originals'].items():assert hashlib.sha256((ROOT/relative).read_bytes()).hexdigest()==digest,relative+' changed'
|
||||
receipt.update(soleFit=json.loads((OUT/'sole_fit.json').read_text(encoding='utf-8')),riggedSha256=hashlib.sha256((OUT/'GreatHornStudio_Rigged.fbx').read_bytes()).hexdigest(),originalResourcesUnchanged=True)
|
||||
(OUT/'trial.json').write_text(json.dumps(receipt,indent=2),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Isolated GreatHorn trial: real Hunyuan Studio lowpoly/UV, bake, conditional UV-preserving reduction."""
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
STUDIO=Path('D:/UD/AI/XModel/LLM3DGraphics')
|
||||
OUT=ROOT/'Tools/output/enemy_studio_trial/GreatHorn'
|
||||
SOURCE=ROOT/'Tools/output/enemy_3d/GreatHorn/greathorn.glb'
|
||||
sys.path.insert(0,str(ROOT/'Tools'))
|
||||
from hunyuan_3d import auth
|
||||
sys.path.insert(0,str(STUDIO))
|
||||
from backend import hunyuan_studio as studio
|
||||
from backend.retopology import _run_blender_step,compress_bake_maps,limit_studio_faces
|
||||
|
||||
def sha(path):return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
def update(**values):print(json.dumps(values,ensure_ascii=True),flush=True)
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
receipt=OUT/'trial.json'
|
||||
current=json.loads(receipt.read_text(encoding='utf-8')) if receipt.exists() else {}
|
||||
digest=sha(SOURCE)
|
||||
if current and current.get('sourceSha256')!=digest:raise ValueError('Trial source changed')
|
||||
original=ROOT/'AIClient/Assets/Bundles/Characters/Enemy/GreatHorn'
|
||||
originals=[original/'GreatHorn_Enemy.prefab',original/'GreatHorn_Animator.controller',*[original/'Clips'/f'{n}.anim' for n in ('Idle','Walk','Attack')], ROOT/'Tools/output/enemy_dgx/GreatHorn/high_rig/rigged.glb']
|
||||
snapshot={str(p.relative_to(ROOT)):sha(p) for p in originals}
|
||||
if current.get('originals') and current['originals']!=snapshot:raise ValueError('Original skeleton/animations changed during trial')
|
||||
current.update(actor='GreatHorn',source=str(SOURCE.relative_to(ROOT)),sourceSha256=digest,targetTriangles=2000,originals=snapshot,status='studio')
|
||||
receipt.write_text(json.dumps(current,indent=2),encoding='utf-8')
|
||||
shutil.copy2(SOURCE,OUT/'model.glb')
|
||||
# Reuse this project's existing authorised Hunyuan session through the installed
|
||||
# Studio integration. Credentials stay local and are never logged or copied.
|
||||
studio.is_logged_in=lambda client_id=None:auth.is_logged_in()
|
||||
studio.load_storage_state=lambda client_id=None:(json.loads(auth.storage_state_path().read_text(encoding='utf-8')),None)
|
||||
uv=OUT/'model_lowpoly_uv.glb'
|
||||
if uv.exists():studio.validate_uv_glb(uv)
|
||||
else:
|
||||
meta=json.loads((OUT/'hunyuan_studio.json').read_text(encoding='utf-8')) if (OUT/'hunyuan_studio.json').exists() else {}
|
||||
studio.process_existing(SOURCE,OUT,update,target_faces=2000,resume_low_id=meta.get('lowpoly_works_id'),resume_uv_id=meta.get('uv_works_id'))
|
||||
current.update(status='baking',studio=json.loads((OUT/'hunyuan_studio.json').read_text(encoding='utf-8')))
|
||||
receipt.write_text(json.dumps(current,indent=2),encoding='utf-8')
|
||||
blender='D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
|
||||
aligned=OUT/'model_lowpoly_uv_aligned.glb'
|
||||
if not aligned.exists():
|
||||
import subprocess
|
||||
with (OUT/'alignment.log').open('w',encoding='utf-8') as log:
|
||||
subprocess.run([blender,'-b','--factory-startup','--python',str(ROOT/'Tools/model_tools/bl_align_studio_uv.py'),'--',str(OUT)],stdout=log,stderr=subprocess.STDOUT,check=True,timeout=120)
|
||||
# A completed source alignment is mandatory before the ray projection.
|
||||
if not current.get('bakeAligned'):
|
||||
if (OUT/'model_baked.glb').exists():shutil.copy2(OUT/'model_baked.glb',OUT/'diagnostic_unaligned_baked.glb')
|
||||
_run_blender_step(OUT,blender,'blender_lowpoly_bake.py',(OUT/'model.glb',OUT/'model_baked.glb',1,1024,aligned),'bake.log',marker='Lowpoly bake saved to')
|
||||
compress_bake_maps(OUT)
|
||||
current['bakeAligned']=True
|
||||
shutil.copy2(OUT/'model_baked.glb',OUT/'model_ex.glb')
|
||||
mesh,decimation=limit_studio_faces(OUT,2000,blender,update)
|
||||
current.update(status='mesh_ready',triangles=len(mesh.faces),vertices=len(mesh.vertices),decimation=decimation,bakeAlignment=json.loads((OUT/'bake_alignment.json').read_text(encoding='utf-8')),finalSha256=sha(OUT/'model_ex.glb'))
|
||||
receipt.write_text(json.dumps(current,indent=2),encoding='utf-8')
|
||||
update(status='mesh_ready',triangles=len(mesh.faces),decimation=decimation)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Align Studio's centred/normalised mesh to its exact source high, retaining UVs."""
|
||||
import bpy,sys,json,hashlib
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from mathutils import Matrix,Vector
|
||||
|
||||
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
def load(path):
|
||||
before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False);objects=[o for o in bpy.data.objects if o not in before and o.type=='MESH'];assert len(objects)==1;return objects[0]
|
||||
def bounds(obj):
|
||||
points=[obj.matrix_world@v.co for v in obj.data.vertices];lo=Vector([min(p[i] for p in points) for i in range(3)]);hi=Vector([max(p[i] for p in points) for i in range(3)]);return lo,hi
|
||||
def uvhash(obj):return hashlib.sha256(np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
|
||||
high=load(folder/'model.glb');low=load(folder/'model_lowpoly_uv.glb');hlo,hhi=bounds(high);llo,lhi=bounds(low);scale=(hhi.z-hlo.z)/(lhi.z-llo.z);hc=Vector(((hlo.x+hhi.x)/2,(hlo.y+hhi.y)/2,hlo.z));lc=Vector(((llo.x+lhi.x)/2,(llo.y+lhi.y)/2,llo.z))
|
||||
before=uvhash(low);low.matrix_world=Matrix.Translation(hc)@Matrix.Scale(scale,4)@Matrix.Translation(-lc)@low.matrix_world
|
||||
bpy.ops.object.select_all(action='DESELECT');low.select_set(True);bpy.context.view_layer.objects.active=low;bpy.ops.object.transform_apply(location=True,rotation=True,scale=True);assert uvhash(low)==before
|
||||
bpy.ops.export_scene.gltf(filepath=str(folder/'model_lowpoly_uv_aligned.glb'),export_format='GLB',use_selection=True,export_materials='NONE')
|
||||
report=dict(scale=scale,highMin=list(hlo),highMax=list(hhi),studioMin=list(llo),studioMax=list(lhi),matrix=[list(row) for row in Matrix.Translation(hc)@Matrix.Scale(scale,4)@Matrix.Translation(-lc)],uvHashBefore=before,uvHashAfter=uvhash(low),uvUnchanged=True)
|
||||
(folder/'bake_alignment.json').write_text(json.dumps(report,indent=2),encoding='utf-8');print('MT_SUMMARY '+json.dumps(report))
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Read-only stage geometry/UV audit and native Blender render."""
|
||||
import bpy,sys,json
|
||||
from pathlib import Path
|
||||
from mathutils import Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
|
||||
reports=[]
|
||||
for name in ('model.glb','model_lowpoly_uv.glb','model_baked.glb','model_ex.glb'):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.gltf(filepath=str(folder/name))
|
||||
objects=[o for o in bpy.context.scene.objects if o.type=='MESH']
|
||||
vertices=[o.matrix_world@v.co for o in objects for v in o.data.vertices]
|
||||
minimum=Vector([min(v[i] for v in vertices) for i in range(3)]);maximum=Vector([max(v[i] for v in vertices) for i in range(3)])
|
||||
reports.append(dict(name=name,minimum=list(minimum),maximum=list(maximum),uvLayers=[[(u.name,len(u.data)) for u in o.data.uv_layers] for o in objects],materials=[s.material.name for o in objects for s in o.material_slots if s.material]))
|
||||
if name not in ('model.glb','model_ex.glb'):continue
|
||||
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=16;scene.render.resolution_x=640;scene.render.resolution_y=640;scene.render.resolution_percentage=100
|
||||
world=bpy.data.worlds.new('AuditWorld');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.45,.45,.45,1);world.node_tree.nodes['Background'].inputs[1].default_value=.7;scene.world=world
|
||||
center=(minimum+maximum)/2;extent=maximum.z-minimum.z
|
||||
camera=bpy.data.objects.new('AuditCamera',bpy.data.cameras.new('AuditCamera'));scene.collection.objects.link(camera);camera.location=center+Vector((.2,-3,1))*extent;camera.rotation_euler=(center-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.type='ORTHO';camera.data.ortho_scale=extent*1.5;scene.camera=camera
|
||||
for location,power in [((2,-3,4),500),((-3,-1,2),300)]:
|
||||
light=bpy.data.objects.new('AuditLight',bpy.data.lights.new('AuditLight','AREA'));scene.collection.objects.link(light);light.location=center+Vector(location)*extent;light.rotation_euler=(center-light.location).to_track_quat('-Z','Y').to_euler();light.data.energy=power;light.data.shape='DISK';light.data.size=extent*3
|
||||
scene.render.filepath=str(folder/('audit_'+Path(name).stem+'.png'));bpy.ops.render.render(write_still=True)
|
||||
(folder/'stage_geometry.json').write_text(json.dumps(reports,indent=2),encoding='utf-8')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Fit only offending new sole vertices by 1mm in game space; leave rig, UV and clips unchanged."""
|
||||
import bpy,sys,json,hashlib
|
||||
from pathlib import Path
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
import numpy as np
|
||||
|
||||
argv=sys.argv[sys.argv.index('--')+1:]
|
||||
folder=Path(argv[0]).resolve()
|
||||
name=argv[1] if len(argv)>1 else 'GreatHornStudio'
|
||||
# GreatHorn's reviewed fit touched exactly two soles; other actors fit whatever their own constraints flag.
|
||||
expected=int(argv[2]) if len(argv)>2 else (2 if name=='GreatHornStudio' else -1)
|
||||
rigged=folder/(name+'_Rigged.fbx')
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(rigged))
|
||||
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');low=next(o for o in bpy.context.scene.objects if o.type=='MESH');arm.animation_data_clear();arm.data.pose_position='REST';bpy.context.view_layer.update()
|
||||
world=[low.matrix_world@v.co for v in low.data.vertices];lo=Vector([min(v[i] for v in world) for i in range(3)]);hi=Vector([max(v[i] for v in world) for i in range(3)]);center=Vector(((lo.x+hi.x)/2,(lo.y+hi.y)/2,lo.z));height=hi.z-lo.z
|
||||
data=json.loads((folder/'foot_constraints.json').read_text(encoding='utf-8'));frames=np.array(data['frames']);targets=[]
|
||||
for j,v in enumerate(data['vertices']):
|
||||
weights=np.array(v['weights'])
|
||||
if weights.sum()>.95 and (frames[:,j,:]@weights).min()<-.003:targets.append(v)
|
||||
assert expected<0 or len(targets)==expected,'Re-inspect a changed sole fit instead of applying stale edits'
|
||||
normal=[(p-center)/height for p in world];candidates=[]
|
||||
for sx in (-1,1):
|
||||
for sz in (-1,1):
|
||||
tree=KDTree(len(normal))
|
||||
for i,p in enumerate(normal):tree.insert(Vector((sx*p.x,p.z,sz*p.y)),i)
|
||||
tree.balance();errors=[tree.find(Vector(v['normalizedBind']))[2] for v in data['vertices']];candidates.append((max(errors),sx,sz,tree))
|
||||
error,sx,sz,tree=min(candidates,key=lambda x:x[0]);assert error<.0001,('Unity/Blender bind coordinate mismatch',error)
|
||||
uv=lambda:hashlib.sha256(np.array([tuple(v.uv) for v in low.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
|
||||
uv_before=uv();geometry_before=[v.co.copy() for v in low.data.vertices];bone_before={b.name:list(b.matrix_local) for b in arm.data.bones}
|
||||
lift_game=.001;model_scale=data.get('modelScale',.758561)
|
||||
delta=low.matrix_world.inverted().to_3x3()@Vector((0,0,lift_game/model_scale));edited=set()
|
||||
for target in targets:
|
||||
matches=tree.find_range(Vector(target['normalizedBind']),.00001);assert matches
|
||||
for _,index,_ in matches:low.data.vertices[index].co+=delta;edited.add(index)
|
||||
low.data.update();assert uv()==uv_before
|
||||
for bone in arm.data.bones:assert all((a-b).length<1e-8 for a,b in zip(bone.matrix_local,bone_before[bone.name]))
|
||||
arm.data.pose_position='POSE'
|
||||
for bone in arm.pose.bones:bone.matrix_basis.identity()
|
||||
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(rigged),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')
|
||||
report=dict(method='local new sole fit to immutable original clips',gameSpaceLift=lift_game,blenderVertices=sorted(edited),unityVertices=[v['index'] for v in targets],coordinateMatchMax=error,uvUnchanged=uv()==uv_before,uvSha256=uv_before,bonesUnchanged=True,originalAnimationsUnchanged=True)
|
||||
(folder/'sole_fit.json').write_text(json.dumps(report,indent=2),encoding='utf-8');print('MT_SUMMARY '+json.dumps(report))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Convert the completed Studio/bake mesh to project Y-up FBX without touching its UVs."""
|
||||
import sys,json,shutil,hashlib
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
from bl_decimate import prepare_game_export,export_fbx_source_yup
|
||||
|
||||
argv=sys.argv[sys.argv.index('--')+1:]
|
||||
folder=Path(argv[0]).resolve()
|
||||
# Optional output actor name; GreatHornStudio keeps the original trial invocation working.
|
||||
name=argv[1] if len(argv)>1 else 'GreatHornStudio'
|
||||
out=folder/'static';out.mkdir(parents=True,exist_ok=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=str(folder/'model_ex.glb'))
|
||||
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
|
||||
assert len(meshes)==1,'Expected one final Studio mesh'
|
||||
low=meshes[0]
|
||||
def uv_hash():return hashlib.sha256(np.array([tuple(v.uv) for v in low.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
|
||||
before=uv_hash()
|
||||
low.name=name
|
||||
# Pure mesh plus conventionally named maps lets the project XPbr converter
|
||||
# consume the existing five baked maps without generating another UV layout.
|
||||
low.data.materials.clear()
|
||||
prepare_game_export(low,restore_axis=True)
|
||||
assert uv_hash()==before,'Coordinate conversion changed Studio UVs'
|
||||
bpy.ops.object.select_all(action='DESELECT');low.select_set(True);bpy.context.view_layer.objects.active=low
|
||||
export_fbx_source_yup(str(out/(name+'.fbx')))
|
||||
mapping={'diffuse':'color','normal':'normal','ao':'ao','metallic':'metallic','roughness':'roughness'}
|
||||
for src,dest in mapping.items():
|
||||
candidates=list((folder/'bake_maps').glob(src+'*.png'))
|
||||
assert len(candidates)==1,(src,candidates)
|
||||
shutil.copy2(candidates[0],out/(name+'_'+dest+'.png'))
|
||||
report=dict(uvHashBefore=before,uvHashAfter=uv_hash(),uvPreserved=True,vertices=len(low.data.vertices),triangles=sum(len(p.vertices)-2 for p in low.data.polygons))
|
||||
(folder/'static_export.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print('MT_SUMMARY '+json.dumps(report))
|
||||
@@ -218,8 +218,11 @@ def main():
|
||||
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=[]
|
||||
# Optional normalized box: on a re-topologised mesh the same falloff can leak across the body.
|
||||
within=rule.get('within')
|
||||
for i,distance in enumerate(geodesic):
|
||||
if distance>=fade:continue
|
||||
if within and any(not within[0][c]<=points[i][c]<=within[1][c] for c in range(3)):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}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Four remaining enemy Studio trials; clouds run serially, Unity mutations stay serial."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
WORKBENCH=Path('D:/UD/AI/XModel/LLM3DGraphics/.venv/Scripts/python.exe')
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser()
|
||||
ap.add_argument('--stage',choices=('cloud','finish','play','verify'),required=True)
|
||||
ap.add_argument('--actors',nargs='+')
|
||||
ap.add_argument('--play-check',action='store_true')
|
||||
a=ap.parse_args()
|
||||
config=json.loads((ROOT/'Config/Art/StudioTrials/Enemies.json').read_text(encoding='utf-8'))
|
||||
rows=[r for r in config['actors'] if not a.actors or r['actor'] in a.actors]
|
||||
if a.actors and set(a.actors)-{r['actor'] for r in rows}:raise ValueError('Unknown enemy')
|
||||
if a.stage=='play':
|
||||
from enemy_character_pipeline import unity,script,wait_editor
|
||||
if a.actors:raise ValueError('The combined Play check requires all four actors')
|
||||
unity('set_autotick',enable=True)
|
||||
if unity('editor_status')['playMode']!='stopped':raise RuntimeError('Preserve the current Play session; run this after it stops')
|
||||
unity('editor_play')
|
||||
try:
|
||||
wait_editor('playing');script('VerifyEnemyStudioBatch','VerifyEnemyStudioBatch.PlayAll',[])
|
||||
finally:unity('editor_stop');wait_editor('stopped')
|
||||
for row in rows:
|
||||
folder=ROOT/'Tools/output/studio_trial'/row['actor'];path=folder/'trial.json';receipt=json.loads(path.read_text(encoding='utf-8'))
|
||||
if json.loads((folder/'unity_verification.json').read_text(encoding='utf-8'))['status']!='pass':raise ValueError('Edit QA missing')
|
||||
for relative,digest in receipt['originals'].items():
|
||||
if hashlib.sha256((ROOT/relative).read_bytes()).hexdigest()!=digest:raise ValueError(relative+' changed')
|
||||
receipt['status']='validated';path.write_text(json.dumps(receipt,indent=2),encoding='utf-8')
|
||||
return
|
||||
for row in rows:
|
||||
actor=row['actor']
|
||||
if hashlib.sha256((ROOT/row['source']).read_bytes()).hexdigest()!=row['sourceSha256']:raise ValueError(actor+' source changed')
|
||||
if a.stage=='cloud':
|
||||
cmd=[str(WORKBENCH),'Tools/studio_lowpoly_trial.py','--actor',actor,'--kind','Enemy','--source',row['source'],'--rig',row['rig'],'--prefab',row['prefab'],'--tris',str(config['targetTriangles']),'--size',str(config['textureSize'])]
|
||||
elif a.stage=='finish':
|
||||
cmd=[sys.executable,'Tools/studio_lowpoly_finish.py','--actor',actor,'--height',str(row['height']),'--recipe',row['recipe']]
|
||||
if a.play_check:cmd.append('--play-check')
|
||||
else:
|
||||
folder=ROOT/'Tools/output/studio_trial'/actor
|
||||
receipt=json.loads((folder/'trial.json').read_text(encoding='utf-8'))
|
||||
if receipt['status']!='validated':raise ValueError(actor+' has not passed actual Play')
|
||||
for path,digest in receipt['originals'].items():
|
||||
if hashlib.sha256((ROOT/path).read_bytes()).hexdigest()!=digest:raise ValueError(path+' changed')
|
||||
for name in ('unity_verification.json','play_verification.json'):
|
||||
if json.loads((folder/name).read_text(encoding='utf-8'))['status']!='pass':raise ValueError(actor+' failed '+name)
|
||||
print('PASS '+actor+' validated; original protected hashes unchanged',flush=True)
|
||||
continue
|
||||
print('BEGIN '+actor+' '+a.stage,flush=True)
|
||||
child=subprocess.Popen(cmd,cwd=ROOT,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,encoding='utf-8',errors='replace')
|
||||
for line in child.stdout:print(actor+': '+line.rstrip(),flush=True)
|
||||
if child.wait():raise RuntimeError(actor+' '+a.stage+' failed; completed task records are retained for resume')
|
||||
print('COMPLETE '+actor+' '+a.stage,flush=True)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Offline packaging + original-skeleton rebind of a Studio trial (generalized from greathorn_studio_finish.py).
|
||||
|
||||
Studio mesh -> project Y-up FBX + XPbr -> weights from the ORIGINAL rig (bl_transfer_skin) -> Pipeline import ->
|
||||
StudioTrial.Prepare -> per-vertex sole constraints over the original clips -> local 1mm sole fit if needed -> Build/verify
|
||||
-> comparison review + capture -> optional Play check. Original prefab/controller/clips/rig hashes must stay unchanged.
|
||||
|
||||
python Tools/studio_lowpoly_finish.py --actor RockBall --height 0.55 --play-check
|
||||
"""
|
||||
import argparse, hashlib, json, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BLENDER = 'D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
|
||||
sys.path.insert(0, str(ROOT / 'Tools/model_tools'))
|
||||
from mt_run import run_blender_script
|
||||
sys.path.insert(0, str(ROOT / 'Tools'))
|
||||
from enemy_character_pipeline import unity, script, wait_editor
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--actor', required=True)
|
||||
ap.add_argument('--height', type=float, required=True)
|
||||
ap.add_argument('--play-check', action='store_true')
|
||||
ap.add_argument('--recipe', type=Path, help='character recipe whose skinOverrides/skinRegions must be re-applied to the new mesh')
|
||||
a = ap.parse_args()
|
||||
out = ROOT / 'Tools/output/studio_trial' / a.actor
|
||||
receipt = json.loads((out / 'trial.json').read_text(encoding='utf-8'))
|
||||
assert receipt['status'] in ('mesh_ready', 'edit_validated', 'validated'), receipt['status']
|
||||
kind, prefab, name = receipt['kind'], receipt['prefab'], a.actor + 'Studio'
|
||||
asset = f'Assets/Bundles/Characters/{kind}/{name}'
|
||||
entry = [a.actor, kind, prefab, a.height]
|
||||
|
||||
def unchanged():
|
||||
for relative, digest in receipt['originals'].items():
|
||||
assert hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() == digest, relative + ' changed'
|
||||
|
||||
def blender(file, args, log):
|
||||
with (out / log).open('w', encoding='utf-8') as f:
|
||||
subprocess.run([BLENDER, '-b', '-t', '1', '--factory-startup', '--python', str(ROOT / 'Tools/model_tools' / file), '--', *map(str, args)],
|
||||
stdout=f, stderr=subprocess.STDOUT, timeout=600, check=True)
|
||||
|
||||
unchanged()
|
||||
blender('bl_prepare_studio_trial.py', [out, name], 'static_export.log')
|
||||
result = run_blender_script(BLENDER, 'bl_convert.py', [str(out / f'static/{name}.fbx'), str(out / 'xpbr'), str(receipt.get('textureSize', 1024))])
|
||||
(out / 'xpbr.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
|
||||
print('XPbr packaging complete', flush=True)
|
||||
blender('bl_transfer_skin.py', [ROOT / receipt['rig'], out / f'xpbr/{name}.fbx', out / f'{name}_Rigged.fbx', '--low-up', 'Y', '--align-bounds', *(['--weight-overrides', ROOT / a.recipe] if a.recipe else [])], 'skin_transfer.log')
|
||||
print('Original rig weight transfer complete', flush=True)
|
||||
unity('set_autotick', enable=True)
|
||||
if unity('editor_status')['playMode'] != 'stopped': raise RuntimeError('Editor is in Play; offline trial is ready, import after it stops')
|
||||
for f in [f'{name}.fbx', f'{name}_base.png', f'{name}_mix.png']:
|
||||
unity('import_asset', source=str(out / 'xpbr' / f), path=asset + '/' + f, confirm=True)
|
||||
unity('import_asset', source=str(out / f'{name}_Rigged.fbx'), path=asset + f'/Rigged/{name}_Rigged.fbx', confirm=True)
|
||||
script('SetupCharacter', 'SetupCharacter.Run', [kind, name, a.height])
|
||||
script('StudioTrial', 'StudioTrial.Prepare', entry)
|
||||
script('StudioTrial', 'StudioTrial.ExportFootConstraints', entry)
|
||||
blender('bl_fit_studio_soles.py', [out, name], 'sole_fit.log')
|
||||
unity('import_asset', source=str(out / f'{name}_Rigged.fbx'), path=asset + f'/Rigged/{name}_Rigged.fbx', confirm=True)
|
||||
script('StudioTrial', 'StudioTrial.Build', entry)
|
||||
script('StudioTrial', 'StudioTrial.BuildReview', entry)
|
||||
script('StudioTrial', 'StudioTrial.Capture', entry)
|
||||
receipt['status'] = 'edit_validated'
|
||||
if a.play_check:
|
||||
unity('editor_play')
|
||||
try:
|
||||
wait_editor('playing'); script('StudioTrial', 'StudioTrial.Play', entry); receipt['status'] = 'validated'
|
||||
finally:
|
||||
unity('editor_stop'); wait_editor('stopped')
|
||||
unchanged()
|
||||
receipt.update(soleFit=json.loads((out / 'sole_fit.json').read_text(encoding='utf-8')),
|
||||
riggedSha256=hashlib.sha256((out / f'{name}_Rigged.fbx').read_bytes()).hexdigest(), originalResourcesUnchanged=True)
|
||||
(out / 'trial.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8')
|
||||
print('PASS studio trial', receipt['status'], flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Studio low-poly trial for any character (generalized from greathorn_studio_trial.py), steps 02-05 of the official flow:
|
||||
|
||||
02 Hunyuan 3D Studio low-poly topology -> 03 Studio UV unwrapping -> align to the exact source high -> 04 Blender bake
|
||||
(diffuse/normal/AO/metallic/roughness) onto the Studio UVs -> 05 UV-preserving decimation only when over target x1.3.
|
||||
|
||||
The original prefab, controller, clips and rig are hash-snapshotted and must stay unchanged; the Studio candidate is
|
||||
packaged and rebound to the original skeleton by studio_lowpoly_finish.py.
|
||||
|
||||
Run with the 3D workbench environment (it provides the Studio integration):
|
||||
D:/UD/AI/XModel/LLM3DGraphics/.venv/Scripts/python.exe Tools/studio_lowpoly_trial.py --actor RockBall --kind Animal \
|
||||
--source Tools/output/rockball_3d/rockball.glb --rig Tools/output/rockball_dgx/high_rig/rigged.glb --prefab RockBall_Rigged.prefab
|
||||
"""
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STUDIO = Path('D:/UD/AI/XModel/LLM3DGraphics')
|
||||
BLENDER = 'D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
|
||||
sys.path.insert(0, str(ROOT / 'Tools'))
|
||||
from hunyuan_3d import auth
|
||||
sys.path.insert(0, str(STUDIO))
|
||||
from backend import hunyuan_studio as studio
|
||||
from backend.retopology import _run_blender_step, compress_bake_maps, limit_studio_faces
|
||||
|
||||
|
||||
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
def update(**values): print(json.dumps(values, ensure_ascii=True), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--actor', required=True)
|
||||
ap.add_argument('--kind', default='Animal', choices=('Animal', 'Enemy'))
|
||||
ap.add_argument('--source', type=Path, required=True, help='the exact high model the original rig was made from')
|
||||
ap.add_argument('--rig', type=Path, required=True)
|
||||
ap.add_argument('--prefab', required=True, help='original animated prefab file name inside the actor folder')
|
||||
ap.add_argument('--tris', type=int, default=2000)
|
||||
ap.add_argument('--size', type=int, default=1024)
|
||||
a = ap.parse_args()
|
||||
out = ROOT / 'Tools/output/studio_trial' / a.actor
|
||||
source = (ROOT / a.source).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
receipt = out / 'trial.json'
|
||||
current = json.loads(receipt.read_text(encoding='utf-8')) if receipt.exists() else {}
|
||||
digest = sha(source)
|
||||
if current and current.get('sourceSha256') != digest: raise ValueError('Trial source changed')
|
||||
original = ROOT / f'AIClient/Assets/Bundles/Characters/{a.kind}/{a.actor}'
|
||||
originals = [original / a.prefab, original / f'{a.actor}_Animator.controller', *[original / 'Clips' / f'{n}.anim' for n in ('Idle', 'Walk', 'Attack')], ROOT / a.rig]
|
||||
snapshot = {str(p.relative_to(ROOT)): sha(p) for p in originals}
|
||||
if current.get('originals') and current['originals'] != snapshot: raise ValueError('Original skeleton/animations changed during trial')
|
||||
current.update(actor=a.actor, kind=a.kind, prefab=a.prefab, rig=str(a.rig).replace('\\', '/'), source=str(source.relative_to(ROOT)).replace('\\', '/'),
|
||||
sourceSha256=digest, targetTriangles=a.tris, textureSize=a.size, originals=snapshot, status='studio')
|
||||
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
|
||||
shutil.copy2(source, out / 'model.glb')
|
||||
# Reuse this project's authorised Hunyuan session through the installed Studio integration; credentials stay local.
|
||||
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
|
||||
studio.load_storage_state = lambda client_id=None: (json.loads(auth.storage_state_path().read_text(encoding='utf-8')), None)
|
||||
uv = out / 'model_lowpoly_uv.glb'
|
||||
if uv.exists(): studio.validate_uv_glb(uv)
|
||||
else:
|
||||
meta = json.loads((out / 'hunyuan_studio.json').read_text(encoding='utf-8')) if (out / 'hunyuan_studio.json').exists() else {}
|
||||
studio.process_existing(source, out, update, target_faces=a.tris, resume_low_id=meta.get('lowpoly_works_id'), resume_uv_id=meta.get('uv_works_id'))
|
||||
current.update(status='baking', studio=json.loads((out / 'hunyuan_studio.json').read_text(encoding='utf-8')))
|
||||
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
|
||||
aligned = out / 'model_lowpoly_uv_aligned.glb'
|
||||
if not aligned.exists():
|
||||
# Studio centres/normalises its mesh; align it to the source high before any ray projection.
|
||||
with (out / 'alignment.log').open('w', encoding='utf-8') as log:
|
||||
subprocess.run([BLENDER, '-b', '--factory-startup', '--python', str(ROOT / 'Tools/model_tools/bl_align_studio_uv.py'), '--', str(out)], stdout=log, stderr=subprocess.STDOUT, check=True, timeout=120)
|
||||
if not current.get('bakeAligned'):
|
||||
_run_blender_step(out, BLENDER, 'blender_lowpoly_bake.py', (out / 'model.glb', out / 'model_baked.glb', 1, a.size, aligned), 'bake.log', marker='Lowpoly bake saved to')
|
||||
compress_bake_maps(out)
|
||||
current['bakeAligned'] = True
|
||||
shutil.copy2(out / 'model_baked.glb', out / 'model_ex.glb')
|
||||
mesh, decimation = limit_studio_faces(out, a.tris, BLENDER, update)
|
||||
current.update(status='mesh_ready', triangles=len(mesh.faces), vertices=len(mesh.vertices), decimation=decimation,
|
||||
bakeAlignment=json.loads((out / 'bake_alignment.json').read_text(encoding='utf-8')), finalSha256=sha(out / 'model_ex.glb'))
|
||||
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
|
||||
update(status='mesh_ready', triangles=len(mesh.faces), decimation=decimation)
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""T2 accessory static stage: Hunyuan GLB -> 400-tri / 512 XPbr bake -> Characters/Animal/{Name}/{Name}T2/ -> SetupCharacter prefab.
|
||||
|
||||
Only the FBX and the two XPbr PNGs are copied; Unity creates the metas and SetupCharacter creates the Test/XPbr material and
|
||||
the static prefab. The accessory has no rig, so loose islands are fine. Placement on the head bone is BuildT2Visuals.BuildPrefabs.
|
||||
|
||||
Usage:
|
||||
python Tools/t2_accessory_static.py # every partner whose T2 GLB exists
|
||||
python Tools/t2_accessory_static.py FireCat BudBud # selected only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from enemy_static_pipeline import ROOT, run, unity
|
||||
|
||||
PARTNERS = ["RockBall", "FireCat", "BudBud", "SparkBird", "BubbleTurtle", "RockRhino", "FlameLion", "VineSprite", "ThunderLeopard", "TideWhale"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
wanted = sys.argv[1:] or PARTNERS
|
||||
for name in wanted:
|
||||
low = name.lower()
|
||||
glb = ROOT / f"Tools/output/t2_3d/{name}/{low}_t2.glb"
|
||||
if not glb.exists():
|
||||
print(f"{name}: no GLB yet, skipped", flush=True)
|
||||
continue
|
||||
game = ROOT / f"Tools/output/t2_game/{low}_t2_game"
|
||||
run([sys.executable, "Tools/model_tools/model_lowpoly_bake.py", str(glb), "-o", str(game), "--tris", "400", "--size", "512",
|
||||
"--callback-file", str(game / "bake_status.json")])
|
||||
xpbr = game / f"{low}_t2_xpbr"
|
||||
actor = name + "T2"
|
||||
dest = ROOT / f"AIClient/Assets/Bundles/Characters/Animal/{name}/{actor}"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(xpbr / f"{low}_t2.fbx", dest / f"{actor}.fbx")
|
||||
shutil.copy2(xpbr / f"{low}_t2_base.png", dest / f"{actor}_base.png")
|
||||
shutil.copy2(xpbr / f"{low}_t2_mix.png", dest / f"{actor}_mix.png")
|
||||
out = unity("AgentScripts/SetupCharacter.cs", "SetupCharacter.Run", [f"Animal/{name}", actor, 0.15])
|
||||
print(f"{name}: installed {dest.relative_to(ROOT)}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,7 +16,15 @@ class SkillDesignTests(unittest.TestCase):
|
||||
def test_covers_current_actors_without_claiming_runtime(self):
|
||||
r=validate(*self.data)
|
||||
self.assertEqual(r['skills'],len(self.data[3]['pets'])*2+len(self.data[3]['enemies']))
|
||||
self.assertFalse(r['runtimeIntegrated']);self.assertFalse(r['generatedAudio'])
|
||||
self.assertFalse(r['runtimeIntegrated']);self.assertTrue(r['generatedAudio'])
|
||||
self.assertEqual(r['generatedSfxCount'],9)
|
||||
|
||||
def test_generated_sound_requires_matching_file_hash_and_receipt(self):
|
||||
item=next(i for i in self.data[2]['items'] if i.get('production'))
|
||||
item['production']['sha256']='0'*64
|
||||
with self.assertRaisesRegex(ValueError,'hash mismatch'):validate(*self.data)
|
||||
self.setUp();self.data[2]['status']='partially_generated'
|
||||
with self.assertRaisesRegex(ValueError,'status does not match'):validate(*self.data)
|
||||
|
||||
def test_missing_actor_or_wrong_skill_kind_is_rejected(self):
|
||||
self.data[0]['skills'].pop()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Validate proposed battle skill contracts without touching the running game."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
from decimal import Decimal,ROUND_CEILING
|
||||
import json
|
||||
from pathlib import Path,PurePosixPath
|
||||
@@ -79,21 +80,32 @@ def validate(plan,view,sound,gameplay,root=ROOT):
|
||||
if cue['kind']=='animation':
|
||||
mark=assets[cue['asset']]['releaseNormalized']
|
||||
require(abs(mark-release/(release+recovery))<=1/(release+recovery),'Animation release marker is not aligned: '+skill['id'])
|
||||
require(sound['status']=='planned_not_generated','SFX design manifest must not claim generated assets')
|
||||
require(sound['status'] in ('planned_not_generated','partially_generated','generated'),'Invalid SFX production status')
|
||||
require(1<=sound['maxInflight']<=4,'TangoFlux in-flight limit exceeds guide')
|
||||
require(sound['sourceFormat']==dict(container='wav',sampleRate=44100,channels=2),'TangoFlux source format mismatch')
|
||||
items=index(sound['items'],'SFX')
|
||||
generated=0
|
||||
for item in items.values():
|
||||
require(item['prompt'].isascii() and 0<len(item['prompt'])<=2000,'SFX prompt must be specific English within service limit')
|
||||
require(1<=item.get('duration',sound['defaults']['duration'])<=30,'TangoFlux duration must be 1..30 seconds')
|
||||
require(type(item['seed']) is int,'Reproducible SFX seed required')
|
||||
production=item.get('production')
|
||||
if production:
|
||||
output=root/production['output'];receipt=root/production['receipt']
|
||||
require(output.is_file() and receipt.is_file(),'Generated SFX output/receipt missing')
|
||||
require(hashlib.sha256(output.read_bytes()).hexdigest()==production['sha256'],'Generated SFX hash mismatch')
|
||||
rows=json.loads(receipt.read_text(encoding='utf-8'))['items']
|
||||
require(any(r['id']==item['id'] and r['outputSha256']==production['sha256'] and r['job']['status']=='succeeded' for r in rows),'Generated SFX receipt mismatch')
|
||||
generated+=1
|
||||
expected='planned_not_generated' if generated==0 else 'generated' if generated==len(items) else 'partially_generated'
|
||||
require(sound['status']==expected,'SFX production status does not match receipts')
|
||||
for asset in assets.values():
|
||||
if asset['type']=='sfx':require(PurePosixPath(asset['path']).stem in items,'SFX production recipe missing: '+asset['id'])
|
||||
return dict(status='design_contract_validated',runtimeIntegrated=False,generatedAudio=False,
|
||||
return dict(status='design_contract_validated',runtimeIntegrated=False,generatedAudio=generated==len(items),generatedSfxCount=generated,
|
||||
skills=len(skills),pets=len(gameplay['pets']),enemies=len(gameplay['enemies']),stages=len(gameplay['stages']),
|
||||
presentationProfiles=len(profiles),existingAssets=sum(a['status']=='existing' for a in assets.values()),
|
||||
plannedAssets=sum(a['status']=='planned' for a in assets.values()),sfxRecipes=len(items),
|
||||
note='Configuration and reference validation only; no combat runtime or audio generation executed')
|
||||
note='Design proposal validation only; formal runtime integration is validated separately by validate_skill_runtime.py')
|
||||
|
||||
def load(path):return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Validate the formal skill table and its authored client bank without enabling proposal files."""
|
||||
import copy
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from validate_skill_design import validate,load,require
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
def main():
|
||||
path=ROOT/'Config/Combat/skill_runtime_v1.json';runtime=load(path)
|
||||
require(runtime['status']=='runtime' and runtime['runtimeEnabled'] is True and runtime['protocol']==1 and runtime['logicVersion']==1,'Unsupported formal runtime version')
|
||||
design=copy.deepcopy(runtime);design['status']='design_only';design['runtimeEnabled']=False
|
||||
result=validate(design,load(ROOT/'Config/Combat/skill_presentations_v1.proposal.json'),load(ROOT/'Doc/Design/Sound/BattleSkills/manifest.json'),load(ROOT/'Config/Gameplay/greenfield.json'))
|
||||
require((ROOT/'AIClient/Assets/Bundles/Combat/BattleSkillPresentation.prefab').is_file(),'Missing authored battle skill bank')
|
||||
# Runtime only accepts the finite, typed parameter vocabulary implemented by the C++ adapter.
|
||||
for template in runtime['templates']:
|
||||
for action in template['actions']:
|
||||
require(action['trigger']=='delivery_hit','Unsupported formal action phase')
|
||||
if action['type']=='apply_status':require(action['statusId'] in ('shield_main','slow_main'),'Unsupported formal status')
|
||||
result.update(formalPartnerProfiles=10,formalEffectKinds=15,formalAudioClips=9,status='formal_runtime_contract_validated',runtimeIntegrated=True,protocol=1,logicVersion=1,rulesVersion=3,runtimeSource=str(path.relative_to(ROOT)).replace('\\','/'),runtimeSourceSha256=hashlib.sha256(path.read_bytes()).hexdigest(),note='Formal runtime table is separate from disabled proposals; nine audio clips generated; ten formal partner presentation profiles validated by verify_partner_skills.py')
|
||||
target=ROOT/'Doc/Design/Combat/Runtime/config_validation.json';target.parent.mkdir(parents=True,exist_ok=True);target.write_text(json.dumps(result,indent=2),encoding='utf-8');print(json.dumps(result))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Audit the formal partner presentation bank and all nine TangoFlux sound receipts."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
DESIGN=ROOT/'Doc/Design/Combat/PartnerSkills'
|
||||
def load(path):return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def main():
|
||||
plan=load(ROOT/'Config/Combat/partner_presentations_v1.json')
|
||||
game=load(ROOT/'Config/Gameplay/greenfield.json')
|
||||
assert len(plan['profiles'])==len(game['pets'])==10
|
||||
assert [p['pet'] for p in plan['profiles']]==list(range(10))
|
||||
assert [e['id'] for e in plan['effects']]==list(range(5,15))
|
||||
assert len(set(e['path'] for e in plan['effects']))==10
|
||||
for e in plan['effects']:
|
||||
assert (ROOT/'AIClient'/e['path']).is_file()
|
||||
assert e['poolSize']==4 and .1<=e['lifetime']<=1
|
||||
assert e['loop']==(e['id'] in (5,14))
|
||||
assert e['beam']==(e['id'] in (8,13))
|
||||
for p in plan['profiles']:
|
||||
for name,value in p.items():
|
||||
if name.endswith('Effect'):assert -1<=value<15
|
||||
if name.endswith('Sound'):assert -1<=value<9
|
||||
assert p['abilityEffect']==p['pet']+5
|
||||
manifest=load(ROOT/'Doc/Design/Sound/BattleSkills/manifest.json')
|
||||
assert manifest['status']=='generated'
|
||||
assert {i['id'] for i in manifest['items']}=={'Sfx_'+s for s in plan['soundIds']}
|
||||
rows=[]
|
||||
for item in manifest['items']:
|
||||
production=item['production']
|
||||
receipt=load(ROOT/production['receipt'])
|
||||
record=next(r for r in receipt['items'] if r['id']==item['id'])
|
||||
target=ROOT/production['output']
|
||||
assert digest(target)==production['sha256']==record['outputSha256']
|
||||
assert digest(ROOT/record['source'])==record['sourceSha256']
|
||||
assert hashlib.sha256(json.dumps(record['request'],sort_keys=True).encode()).hexdigest()==record['requestHash']
|
||||
assert record['job']['status']=='succeeded'
|
||||
assert record['job']['seed']==record['request']['seed']
|
||||
if item['id'] in ('Sfx_Heal','Sfx_Slow','Sfx_Lightning','Sfx_EnemyHit'):assert item['seed']==record['request']['seed']
|
||||
rate,samples=wavfile.read(target);x=samples.astype(float)/32768
|
||||
assert rate==44100 and samples.dtype==np.int16 and x.ndim==2 and x.shape[1]==2
|
||||
assert .1<len(x)/rate<1 and np.isfinite(x).all()
|
||||
peak=float(np.abs(x).max());rms=float(20*np.log10(np.sqrt(np.mean(x*x))))
|
||||
assert peak<.95 and np.abs(x[:4410]).max()>.05 and np.abs(x[-10:]).max()<.003 and rms>-36
|
||||
rows.append(dict(id=item['id'],seconds=len(x)/rate,peakDb=20*np.log10(peak),rmsDb=rms,
|
||||
clippedSamples=int(np.sum(np.abs(x)>=.999)),sha256=digest(target),actualSeed=record['request']['seed']))
|
||||
assert len(rows)==9
|
||||
result=dict(status='pass',profiles=10,signaturePrefabs=10,poolRoots=76,voices=8,audio=rows,
|
||||
subjectiveListening='not_asserted',releaseLicenseReview=manifest['releaseLicenseReview'])
|
||||
DESIGN.mkdir(parents=True,exist_ok=True)
|
||||
(DESIGN/'audio_config_validation.json').write_text(json.dumps(result,indent=2)+'\n',encoding='utf-8')
|
||||
print('PASS ten partner profiles and prefabs; nine PCM sounds, source/request/output hashes, onset, fades, level, zero clipping')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Check the C++ slice trace, immutable inputs, and TangoFlux audio provenance."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
from export_skill_slice import canonical
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
DESIGN=ROOT/'Doc/Design/Combat/SkillSlice'
|
||||
|
||||
def main():
|
||||
DESIGN.mkdir(parents=True,exist_ok=True)
|
||||
trace_path=ROOT/'AIClient/Assets/Bundles/Combat/SkillSlice/trace.json'
|
||||
trace=json.loads(trace_path.read_text(encoding='utf-8'))
|
||||
exported=json.loads((DESIGN/'export.json').read_text(encoding='utf-8'))
|
||||
assert hashlib.sha256(trace_path.read_bytes()).hexdigest()==exported['traceSha256']
|
||||
for row in exported['inputs']:
|
||||
snapshot=(ROOT/row['snapshot']).read_bytes()
|
||||
assert hashlib.sha256(snapshot).hexdigest()==row['sourceSha256']
|
||||
assert trace[row['field']]==json.loads(snapshot)
|
||||
assert hashlib.sha256(canonical(trace[row['field']])).hexdigest()==row['payloadSha256']
|
||||
casts={};shots={};hits=set();deaths=set();last=-1
|
||||
for seq,e in enumerate(trace['events']):
|
||||
assert e['seq']==seq and e['tick']>=last
|
||||
last=e['tick'];kind=e['kind'];cast=e.get('cast')
|
||||
if kind=='cast_start':
|
||||
assert cast not in casts
|
||||
casts[cast]=e
|
||||
elif kind=='release': assert e['tick']==casts[cast]['release']
|
||||
elif kind=='projectile':
|
||||
key=(cast,e['target']);assert key not in shots
|
||||
shots[key]=e
|
||||
assert e['arrival']>e['tick']
|
||||
elif kind=='hit':
|
||||
key=(cast,e['target']);assert key not in hits
|
||||
hits.add(key)
|
||||
if e['source']==1: assert e['tick']==shots[key]['arrival']
|
||||
else: assert e['tick']==casts[cast]['release']
|
||||
elif kind=='death':
|
||||
assert e['target'] not in deaths
|
||||
deaths.add(e['target'])
|
||||
assert trace['events'][-1]['kind']=='battle_end' and trace['won']
|
||||
audio=json.loads((ROOT/'Doc/Design/Sound/BattleSkills/Slice/generation.json').read_text(encoding='utf-8'))
|
||||
rows=[]
|
||||
for item in audio['items']:
|
||||
source=ROOT/item['source'];target=ROOT/item['output']
|
||||
assert hashlib.sha256(source.read_bytes()).hexdigest()==item['sourceSha256']
|
||||
assert hashlib.sha256(target.read_bytes()).hexdigest()==item['outputSha256']
|
||||
assert hashlib.sha256(json.dumps(item['request'],sort_keys=True).encode()).hexdigest()==item['requestHash']
|
||||
assert item['job']['status']=='succeeded'
|
||||
rate,samples=wavfile.read(target);x=samples.astype(float)/32768
|
||||
assert rate==44100 and samples.dtype==np.int16 and x.shape[1]==2
|
||||
assert .1<len(x)/rate<1 and np.isfinite(x).all()
|
||||
assert np.max(np.abs(x))<.95 and np.max(np.abs(x[:4410]))>.05
|
||||
assert np.max(np.abs(x[-10:]))<.003
|
||||
rms=float(20*np.log10(np.sqrt(np.mean(x*x))));assert rms>-36
|
||||
rows.append(dict(id=item['id'],seconds=len(x)/rate,rmsDb=rms,clippedSamples=int(np.sum(np.abs(x)>=.999))))
|
||||
assert len(rows)==5
|
||||
report=dict(status='pass',events=len(trace['events']),casts=len(casts),projectiles=len(shots),hits=len(hits),deaths=len(deaths),traceSha256=hashlib.sha256(trace_path.read_bytes()).hexdigest(),frozenInputs={r['source']:r['sourceSha256'] for r in exported['inputs']},currentSourceChanged=[r['source'] for r in exported['inputs'] if hashlib.sha256((ROOT/r['source']).read_bytes()).hexdigest()!=r['sourceSha256']],audio=rows,subjectiveListening='not_asserted',productionIntegrated=False)
|
||||
(DESIGN/'validation.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print(f"PASS trace: {len(casts)} casts, {len(shots)} projectiles, {len(hits)} unique hits; five audio clips and frozen source hashes")
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Register the authoritative battle integration, preserving other work and legacy document bytes."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MARK='## 权威技能协议与正式战场接入(2026-10-21)'
|
||||
def read(path):return json.loads((ROOT/path).read_text(encoding='utf-8-sig'))
|
||||
def append(path,text):
|
||||
target=ROOT/path
|
||||
if MARK.encode('utf-8') not in target.read_bytes():
|
||||
with target.open('ab') as f:f.write(('\n\n'+MARK+'\n\n'+text.strip()+'\n').encode('utf-8'))
|
||||
|
||||
def main():
|
||||
folder=ROOT/'Doc/Design/Combat/Runtime'
|
||||
reports={key:read('Doc/Design/Combat/Runtime/'+key+'.json') for key in ('server_validation','unity_network','unity_domain','unity_anchors','unity_assets','config_validation')}
|
||||
assert all(r['status'] in ('pass','formal_runtime_contract_validated') for r in reports.values())
|
||||
deployment=read('Tools/output/combat_runtime/deployment.json');assert deployment['status']=='enabled' and deployment['gameStatesUnchanged']
|
||||
(folder/'deployment.json').write_text(json.dumps(deployment,indent=2),encoding='utf-8')
|
||||
text='''# 权威技能协议与正式战场接入
|
||||
|
||||
2026-10-21完成。独立火喵/岩团团样例已推广到正式战斗:十伙伴的普攻/主动技能及五敌人普攻,共25个技能;覆盖20主线关卡和5个属性挑战。8080本地开发服已更新,health返回combatProtocol=1;未发布Player或生产服。
|
||||
|
||||
## 版本与配置
|
||||
|
||||
- `Config/Combat/skill_runtime_v1.json` 是正式逻辑表,protocol=1、logicVersion=1,新战斗快照标记rulesVersion=3。原proposal保持design_only/runtimeEnabled=false,两个校验器分别报告设计和正式运行时状态,避免把旧设计文件当成正式入口。
|
||||
- `greenfield.json` 保持battleRules=2,兼容旧客户端及旧挑战。新版客户端只在battle.begin/challenge.begin发送combatProtocol=1;没有该字段的旧客户端仍得到原规则。新客户端连接旧服务端时,也能接收旧规则战斗。
|
||||
- CMake将正式技能表编入程序。开战原子保存原玩法配置、技能定义、gameplayHash、skillsHash、权威结果及完整事件轨迹;旧进行中战斗不会因新配置或程序重启重新计算。
|
||||
|
||||
## 逻辑内核
|
||||
|
||||
`combat_slice` 已由独立样例内核推广为共享纯数据内核,`combat_runtime` 从冻结玩法参数与正式技能表构造战斗。单位id为伙伴id或100+敌人槽;时间50ms整数tick、血量/攻击千分之一单位、位置毫米、倍率万分比。数值量化及前摇会改变新规则的节奏,不宣称与旧规则结果等价。
|
||||
|
||||
三波刷怪、多路前排阻挡/移动/漏怪、主动技能优先的单动作通道、前摇/释放/后摇、绝对冷却、投射物到达伤害、伤害/治疗/护盾/减速类型化行为、首领控制折扣、护盾和减速的刷新/过期均已接入。行为数组按actionId顺序执行;同cast/action/target不能重复命中。释放前死亡取消,释放后的火球独立存续,目标丢失不换目标,死亡和战斗结束只发一次。
|
||||
|
||||
开局预计算仍由服务端授权。battle.finish继续使用已冻结结果,忽略客户端won和elapsed字段;最早结算时间由readyAt约束,放弃/重试/首通奖励沿用原幂等事务。没有战中手动输入。
|
||||
|
||||
## 分页与恢复
|
||||
|
||||
| 操作 | 行为 |
|
||||
| --- | --- |
|
||||
| battle.begin/challenge.begin | 新客户端声明combatProtocol=1;返回snapshot和不含events的trace manifest |
|
||||
| GET /game/state | 返回原场次的traceId/traceHash/初态/终态摘要,供重连恢复 |
|
||||
| POST /game/command:battle.events | 按本玩家pendingBattle、activityId、traceId鉴权;cursor与limit读取事件,limit≤256 |
|
||||
| battle.finish/abandon | 原事务结算;关闭后删除活动轨迹,后续分页返回activity_missing |
|
||||
|
||||
分页读取不修改日历、revision、钱包或operation log。每页返回原始JSON payload字符串及SHA256;客户端逐页校验、按原顺序拼接并核对整条traceHash,再开始播放。错误页、缺页、重复/乱序事件或不支持的版本不会回退成本地算伤害。请求可重试,持久化命令队列与分页读取分开。恢复当前实现从头重播原轨迹,沿用同一battleId及readyAt,不重新开战或发奖。
|
||||
|
||||
事件上限50000,正式内核最多2400tick。当前120组矩阵最大3626事件/299734字节(事件JSON),按需分页预载后开始播放;不是把全事件塞进每次存档响应。
|
||||
|
||||
## 客户端与表现
|
||||
|
||||
- `CombatTrace.cs`:协议DTO、版本/实体/事件顺序校验;`PrototypeApi.LoadCombat`:授权分页、哈希校验与有界重试。
|
||||
- `PrototypeBattle.Trace.cs`:只读战斗模型。Tick只应用事件,不运行旧伤害/技能计算;血量、护盾、减速、死亡、漏怪、技能次数和统计都取服务器轨迹。
|
||||
- `PrototypeFlow`:新旧战斗分支共存。新分支由同一时钟驱动动作、弹道、VFX/SFX与数据;低帧率仍消费全部数据事件,跳过过期一次性声音。结束、放弃、换页与禁用清理池。
|
||||
- `BattleSkillPresentation.prefab`:5类特效、36个池实例、8个技能音源,复用已有5条TangoFlux音效和敌人反馈池。火焰伙伴有蓄力/发射/命中,其他投射物按元素着色;治疗/减速沿用现有标识。各伙伴更丰富的专属美术及余下4条计划音效未在本批补制。
|
||||
- 15种角色的attackImpact读取各自原制作配方,分段采样原Attack以对齐逻辑释放点;零前摇护盾保持Idle并播放护盾。AnimationEvent不授权伤害。
|
||||
- 战场持久化19个`SkillMuzzle`(10伙伴+9敌人槽),局部-Z为前,随角色朝向和T2体型缩放,可在Prefab调节;不在运行时创建挂点/材质/粒子对象。BuildPrototype重建、后续敌人替换均保留此约定。
|
||||
|
||||
## 验证
|
||||
|
||||
- 三组CTest全部通过:原样例、旧事务/规则5192项断言、正式技能协议与120组关卡/进化/阵容矩阵(覆盖25技能,超过30万项事件断言)。涵盖治疗相邻路线、首领减速、复合行为、未来波次、确定性、分页只读、账号隔离、快照变化不重算、旧客户端、属性挑战及幂等奖励。
|
||||
- 23份C++导出轨迹→C#只读模型,覆盖全部20关及3套T2终章阵容;最终血量、护盾、减速、位置、死亡/漏怪、统计完全一致,错误序列被拒绝。
|
||||
- 真正EventSystem UI+HTTP,18083专用数据库的1-5全T2六人阵容,1876事件。服务端进程重启后恢复相同battleId与traceHash,2×实播后最终状态与服务器奖励/统计一致;暂停、静音、火球、护盾、敌人攻击和结束清理通过。
|
||||
- 最终Prefab挂点实播通过,T2火喵发射点与持久化挂点世界坐标一致;截图 `Assets/Screenshots/battle_skills_anchors.png`。完整UI截图 `battle_skills_network.png`。
|
||||
- 真实账号与偏好已恢复。开发服更新前一致性SQLite备份及旧程序保留;重启后全部账号game_states与备份比较一致,包括已有冻结战斗。期间Windows旧进程文件锁曾延迟替换,确认进程完全退出后更新成功。部署回执 `deployment.json`。
|
||||
|
||||
矩阵用于验证执行、确定性和代表阵容可通关;首日实际解锁阵容的细致平衡、微信真机帧耗/内存、全部伙伴专属特效音效仍需后续验收。
|
||||
|
||||
## 入口
|
||||
|
||||
```powershell
|
||||
cmake --build AIServer/build-g1 --config Release --target aiserver combat_runtime_tests game_tests combat_slice_tests
|
||||
ctest --test-dir AIServer/build-g1 -C Release --output-on-failure
|
||||
AIServer/build-g1/Release/combat_runtime_tests.exe Doc/Design/Combat/Runtime/server_validation.json Tools/output/combat_runtime/traces
|
||||
python Tools/validate_skill_runtime.py
|
||||
```
|
||||
|
||||
Unity Pipeline:`BuildBattleSkills.Build`(制作/挂点)、`VerifyBattleRuntime.Assets`/`Domain`(检查);实战测试使用专用18083账号,Begin→重启专用服务→ResumeAndFinish→Cleanup,勿对真实账号运行测试入口。开发服实际程序在AIServer/build/Release,运行中Windows会锁定exe;不要直接构建覆盖运行中的文件。新版本应先在其他输出目录验证,等待旧进程完全退出后替换。
|
||||
'''
|
||||
(folder/'Production.md').write_text(text,encoding='utf-8')
|
||||
append('Doc/ResDesc_Config.md','''正式表 `Config/Combat/skill_runtime_v1.json`,protocol1/logicVersion1,25技能。`AIServer/src/combat_runtime.*`+共享`combat_slice.*`编入服务端,按冻结配置生成权威事件;`battle.events`最多256条只读分页,逐页/全轨迹SHA256,保留旧协议与幂等奖励。客户端 `CombatTrace.cs`/`PrototypeBattle.Trace.cs`、PrototypeApi.LoadCombat和PrototypeFlow消费事件,不计算新规则伤害。新开战斗快照rulesVersion3,旧客户端和旧挑战保留原规则。
|
||||
|
||||
制作/检查:`AIClient/AgentScripts/BuildBattleSkills.cs`、`VerifyBattleRuntime.cs`、`Tools/validate_skill_runtime.py`、`write_combat_runtime_report.py`;回执与制作记录 `Doc/Design/Combat/Runtime/`。8080开发服已启用,SQLite及旧程序备份路径见deployment.json。''')
|
||||
append('Doc/ResDesc_Effect.md','''新增 `AIClient/Assets/Bundles/Combat/BattleSkillPresentation.prefab` 并嵌入正式PrototypeArena,复用5种SkillSlice粒子Prefab,36池实例、8音源;火球按权威arrival tick移动及回收,按元素着色,cast/entity租约负责蓄力与护盾清理。19个SkillMuzzle经Pipeline持久化,局部-Z前向、随T2缩放;动画、特效与数据同钟,暂停/倍速/过期声音抑制/切场清理通过。治疗/减速沿用现有标识,完整专属特效仍待补。见 `Doc/Design/Combat/Runtime/Production.md`。''')
|
||||
append('Doc/ResDesc_Animation.md','''正式战斗复用原10伙伴/5敌人的Idle/Walk/Attack,没有改写成品动画。BattleSkillPresentation读取各角色原配方attackImpact,经Pipeline持久化15项映射,将原Attack前摇/释放/后摇分段对齐权威tick;零前摇护盾不强制跳到攻击中间姿势。AnimationEvent不决定伤害,旧规则继续原Animator播放。''')
|
||||
append('Doc/ResDesc_Model.md','''正式PrototypeArena新增19个可调SkillMuzzle空挂点(10伙伴+9敌人槽),Pipeline持久化、局部-Z前向,T2体型缩放同步作用于挂点;角色网格/骨架/原三动作不变。后续BuildPrototype重建和ApplyFormalEnemies替换保留挂点。实播回执 `Doc/Design/Combat/Runtime/unity_anchors.json`。''')
|
||||
append('Doc/ResDesc_Sound.md','''此前生成的 `Sound/SFX/BattleSkills/Sfx_{FireCharge,FireLaunch,FireHit,RockHit,Shield}.wav` 已从独立样例推广到正式BattleSkillPresentation的8音源池,按权威释放/命中/护盾事件播放;与敌人反馈池合计有界16音源,技能池总音量预算0.3。暂停/静音/低帧率过期声音跳过/结束和禁用清理通过。本批无新增音频,仍为5/9计划音效生成;源任务与TangoFlux许可记录沿用BattleSkills/Slice。''')
|
||||
append('Doc/ResDesc_Picture.md','''`AIClient/Assets/Screenshots/battle_skills_network.png`:1080×1920真实UI/HTTP的1-5全T2技能战斗,包含暂停/双速按钮及正式伙伴模型。`battle_skills_anchors.png`:900×1200最终Prefab持久化挂点与T2弹道实播检查。均由Unity Pipeline渲染,生成与检查入口VerifyBattleRuntime。''')
|
||||
append('Doc/ResDesc.md','''新技能协议与正式战场已接通,覆盖10伙伴/5敌人/20主线关/5属性挑战;新客户端按权威轨迹播放,旧规则保留。8080本地开发服已更新,原账号存档核对不变。见 [Runtime/Production.md](Design/Combat/Runtime/Production.md),相关资源登记见Config/Effect/Animation/Model/Sound/Picture。''')
|
||||
append('Doc/Design/G0_Implementation.md','''新客户端battle.begin/challenge.begin声明combatProtocol1,服务端冻结25技能与配置、结果和事件,分页只读获取。PrototypeBattle.Trace仅应用权威事件,统一动作/特效/音效及数据时钟;旧客户端与旧战斗仍走原规则。正式战场持久化技能池和19个可调挂点,T2缩放同步。
|
||||
|
||||
120组C++关卡/进化/阵容矩阵覆盖25技能;23份跨关轨迹在C#最终状态与统计一致;18083真实UI全T2的1-5经服务重启恢复相同场次,实播/结算通过。8080开发服已更新,旧程序与SQLite一致性备份保留,全部game_states核对不变。制作记录 `Doc/Design/Combat/Runtime/Production.md`。未发布Player或生产服,真机性能与完整伙伴专属音效特效仍待完成。''')
|
||||
append('Doc/Design/Combat/SkillSystem.md','''本阶段已完成正式 `skill_runtime_v1.json`、25技能权威内核、分页协议和客户端正式战场接入;8080开发服已启用。原proposal仍为设计样例,正式验证器为 `Tools/validate_skill_runtime.py`。旧挑战不重算,新客户端使用快照rulesVersion3;新旧规则数值节奏不等价。详细边界、测试、部署和待补美术见 [Runtime/Production.md](Runtime/Production.md)。''')
|
||||
append('Doc/Design/Combat/SkillSlice/Production.md','''后续已将独立样例推广到正式技能协议与全关战场,见 [../Runtime/Production.md](../Runtime/Production.md)。本页保留独立样例的原始制作记录;正式配置、分页、恢复与客户端入口以Runtime文档为准。''')
|
||||
append('GameDesign.md','''正式战斗新增客户端能力协商:combatProtocol1使用服务端冻结技能表与权威事件轨迹(快照rulesVersion3),统一动作/弹道/命中/数据处理;旧客户端/在途挑战继续原规则。现覆盖25技能、20主线关和5属性挑战。核心仍为自动战斗,胜负与幂等奖励由服务端决定。当前详细实施及剩余专属表现/真机验收见 `Doc/Design/Combat/Runtime/Production.md`。''')
|
||||
print('Registered authoritative skill protocol, formal battle presentation, testing and development deployment')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Register the completed four-enemy batch from actual generation and QA receipts."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
NAMES={'Hornlet':'小角怪','Dashlet':'疾跑怪','Shellhorn':'硬壳怪','HornCaptain':'角怪队长'}
|
||||
|
||||
def append(path,text):
|
||||
with (ROOT/path).open('ab') as f:f.write(('\n'+text+'\n').encode('utf-8'))
|
||||
|
||||
def main():
|
||||
config=json.loads((ROOT/'Config/Art/StudioTrials/Enemies.json').read_text(encoding='utf-8'));rows=[]
|
||||
for recipe in config['actors']:
|
||||
actor=recipe['actor'];folder=ROOT/'Tools/output/studio_trial'/actor
|
||||
receipt=json.loads((folder/'trial.json').read_text(encoding='utf-8'))
|
||||
edit=json.loads((folder/'unity_verification.json').read_text(encoding='utf-8'));play=json.loads((folder/'play_verification.json').read_text(encoding='utf-8'))
|
||||
assert receipt['status']=='validated' and edit['status']==play['status']=='pass'
|
||||
rows.append(dict(actor=actor,name=NAMES[actor],source=recipe['source'],sourceTriangles=recipe['sourceTriangles'],studioTriangles=receipt['studio']['actual_faces'],triangles=edit['triangles'],bones=edit['bones'],height=recipe['height'],bindErrorMm=edit['bindError']*1000,soleVertices=len(receipt['soleFit']['blenderVertices']),maxTilt=play['maxTilt'],motionSamples=edit['rows']))
|
||||
directory=ROOT/'Doc/Design/Character/StudioEnemies';directory.mkdir(parents=True,exist_ok=True)
|
||||
(directory/'batch_validation.json').write_text(json.dumps(dict(status='validated',actors=rows),ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
table='\n'.join(f"| {r['name']} {r['actor']} | {r['sourceTriangles']} | {r['studioTriangles']} | {r['triangles']} | {r['bones']} | {r['height']:.2f}m | {r['soleVertices']} |" for r in rows)
|
||||
text='''# 剩余四敌人Studio低模/UV及原动作复用
|
||||
|
||||
2026-10-21完成。用户明确授权四份高模上传腾讯混元3D Studio,批处理配方 `Config/Art/StudioTrials/Enemies.json`。沿用大角首领已验证的2~5步,交付四个独立Studio版本;原版模型、Avatar、控制器和成品动画保留,未替换正式战场。
|
||||
|
||||
| 角色 | 源高模面数 | Studio UV面数 | 最终三角面 | 原骨数 | 高度 | 局部脚底校正顶点数 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
'''+table+'''
|
||||
|
||||
## 制作过程
|
||||
|
||||
1. 官网Studio低模拓扑V1.5,三角面/低。逐角色上传自己的高模,再把精确下载的低模FBX提交Studio UV展开;保留任务ID、原始GLB/FBX/OBJ及UV布局PNG。
|
||||
2. 烘焙前对齐Studio居中/归一化的网格和原高模,使用等比缩放、底面中心平移;UV哈希前后一致。角怪队长使用 `horncaptain_main.glb`,与原22骨的生成源哈希完全一致;原始50k及清理版均保留。
|
||||
3. Blender在官网UV上烘焙1024²颜色/法线/AO/金属度/粗糙度。超过目标2000面的30%时再Collapse减面,保留UV层和贴图,不重复展开UV。小角怪得到1999面属于减面整数舍入,未添加无用面凑数。
|
||||
4. Blender转回各自原骨架,应用各自原角色配方中的权重规则,最多4个归一化权重。候选Prefab直接引用原Avatar、原AnimatorController及原Clips/Idle、Walk、Attack;没有重新生成、重新收尾或复制成品动作。
|
||||
5. 每角色重新检查实际蒙皮。仅在363帧约束中出现超过3毫米穿地的新脚底顶点才做游戏空间1毫米的局部几何校正;原骨骼、UV、动作与外层根Transform不变。无穿地的角色不套用大角首领的两个顶点编号。是否校正及数量以表格和每角色sole_fit.json为准。
|
||||
|
||||
## 资产与验证
|
||||
|
||||
- 四角色根目录 `AIClient/Assets/Bundles/Characters/Enemy/{Actor}Studio/`,包含 `{Actor}Studio.prefab`、`{Actor}Studio_Enemy.prefab`、静态/蒙皮FBX、XPbr材质和1024² base/mix贴图。
|
||||
- 独立场景 `AIClient/Assets/Bundles/Scenes/Review/Studio_{Actor}_Review.unity`;截图 `Assets/Screenshots/{actor小写}_studio_comparison.png`,1800×900,上原版/下Studio版,Static/Idle/Walk/Attack。场景以Additive生成、采样后关闭,不覆盖原角色总验收场景。
|
||||
- 五敌人总览 `Assets/Screenshots/enemy_studio_batch_overview.png`,对应独立场景 `Studio_Enemies_Overview.unity`;包含先前大角首领,统一0.8m展示高度仅供视觉比较,实际角色Prefab尺寸仍为表中数值。
|
||||
- 每角色均核对原骨路径和绑定局部TRS、原Avatar/控制器引用、原动作所有曲线绑定、蒙皮权重、底面和尺寸。绑定对照误差低于2毫米。
|
||||
- 三动作各121帧,共每角色363帧、四角色1452帧:没有塌陷或无效顶点;底面在±3毫米内,循环接缝小于1毫米,动作可见,倾角小于25°。Attack保留原动作的短距离前冲,不视作外层根漂移。
|
||||
- 同一次Play检查四个角色,各观察到Idle/Walk/Attack三状态、方向正确、外层角色根固定。原Prefab/控制器/成品动作/rig哈希再次复核一致。
|
||||
- 生成与QA回执 `Tools/output/studio_trial/{Actor}/`:trial.json、hunyuan_studio.json、studio_*Rsp.json、bake_alignment.json、model_decimated.json、static_export.json、sole_fit.json、unity_verification.json、play_verification.json。以status=validated为最终状态。
|
||||
|
||||
## 批处理复现
|
||||
|
||||
```powershell
|
||||
python Tools/process_enemy_studio_batch.py --stage cloud
|
||||
python Tools/process_enemy_studio_batch.py --stage finish
|
||||
python Tools/process_enemy_studio_batch.py --stage play
|
||||
python Tools/process_enemy_studio_batch.py --stage verify
|
||||
```
|
||||
|
||||
云端阶段调用已安装的3D工作台集成,依赖已有项目登录会话;本次四模型外传已获明确授权。Unity资产、导入设置、Prefab、材质和场景均经Pipeline,显式指定AIClient项目。原始下载与中间件保持可追溯,结果作为独立候选供比较;最终同面数的画质和目标设备性能应分别评估。
|
||||
'''
|
||||
(directory/'Production.md').write_text(text,encoding='utf-8')
|
||||
marker='## 四敌人Studio低模与原动作复用完成(2026-10-21)'
|
||||
if marker.encode('utf-8') not in (ROOT/'Doc/ResDesc_Model.md').read_bytes():
|
||||
append('Doc/ResDesc_Model.md',marker+'\n\n| Actor | High tris | Studio UV tris | Final tris | Bones | Height | Sole edits |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n'+table+'\n\n资源根目录 `AIClient/Assets/Bundles/Characters/Enemy/{Hornlet,Dashlet,Shellhorn,HornCaptain}Studio/`;各含静态Prefab/FBX、蒙皮FBX与Enemy入口、XPbr材质与base/mix。官网Studio低模/UV→Blender对齐烘焙→保留UV减面→各自原骨架绑定;原Avatar/控制器/三动作直接复用。Edit/Play及原资源哈希通过,正式战场未替换。详情 [StudioEnemies/Production.md](Design/Character/StudioEnemies/Production.md)。')
|
||||
append('Doc/ResDesc_Animation.md','## 四敌人Studio版原动作复用(2026-10-21)\n\n四个 `{Actor}Studio_Enemy.prefab` 直接引用各自原 `{Actor}_Animator.controller`、原Avatar及 `{Actor}/Clips/{Idle,Walk,Attack}.anim`;原成品动作文件哈希一致,本批无新增动画。小角怪/疾跑怪/角怪队长原22骨、硬壳怪原28骨;1452帧实际蒙皮采样、统一Play三状态/方向/外层根固定通过。报告 `Doc/Design/Character/StudioEnemies/Production.md`。')
|
||||
append('Doc/ResDesc_Picture.md','## 四敌人Studio贴图与对比图(2026-10-21)\n\n- `AIClient/Assets/Bundles/Characters/Enemy/{Actor}Studio/{Actor}Studio_base.png`/`_mix.png`:Hornlet、Dashlet、Shellhorn、HornCaptain各一套1024² XPbr,官网UV、Blender对齐高低模后五通道烘焙并打包,经Pipeline导入。\n- `AIClient/Assets/Screenshots/{hornlet,dashlet,shellhorn,horncaptain}_studio_comparison.png`:四张1800×900原版/Studio版静态与三动作对照,由Pipeline采样渲染并目视检查。\n- `AIClient/Assets/Screenshots/enemy_studio_batch_overview.png`:1800×900五敌人原版/Studio版总览;`Studio_Enemies_Overview.unity` 独立场景以0.8m展示高度比较,不修改实际角色尺寸。\n- 官网UV布局与五通道原稿位于 `Tools/output/studio_trial/{Actor}/`,详细来源与报告见 `Doc/Design/Character/StudioEnemies/Production.md`。')
|
||||
append('Doc/ResDesc.md','四个剩余敌人的Studio低模/UV、Blender对齐烘焙与原骨架三动作复用已完成,见 [StudioEnemies/Production.md](Design/Character/StudioEnemies/Production.md)。')
|
||||
append('Doc/Design/Character/EnemyCharacterProduction.md','## 四敌人Studio流程复验完成(2026-10-21)\n\nHornlet、Dashlet、Shellhorn、HornCaptain均按大角首领2~5步流程完成独立Studio版,绑定各自原骨架并直接复用原Avatar/控制器/三段成品动作。使用通用StudioTrial脚本按本角色骨数、尺寸及实际动作约束验收;禁止照搬大角首领骨编号或脚底顶点。生成、修正、Edit/Play及资源路径见 [StudioEnemies/Production.md](StudioEnemies/Production.md)。')
|
||||
catalog=ROOT/'Doc/ResDesc_Config.md';data=catalog.read_bytes();old='当前仅完成本地配方与来源/原动作前检,云端上传受自动审批拦截,正在等待用户确认这四份模型向腾讯混元3D Studio的外传授权;本条不代表四个Studio资产已完成。'.encode('utf-8');new='用户已明确授权四模型向腾讯混元3D Studio上传,四角色云端制作、本地绑定及Edit/Play均已完成;最终报告见 `Doc/Design/Character/StudioEnemies/Production.md`。'.encode('utf-8');catalog.write_bytes(data.replace(old,new))
|
||||
print('Registered four validated Studio enemies and production report')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Register this batch without rewriting unrelated or legacy-encoded resource descriptions."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MARK='## 五敌人战场替换与技能闭环样例(2026-10-21)'
|
||||
|
||||
def append(path,text,marker=MARK):
|
||||
target=ROOT/path
|
||||
if marker.encode('utf-8') not in target.read_bytes():
|
||||
with target.open('ab') as stream: stream.write(('\n\n'+text.strip()+'\n').encode('utf-8'))
|
||||
|
||||
def main():
|
||||
validation=json.loads((ROOT/'Doc/Design/Combat/SkillSlice/validation.json').read_text(encoding='utf-8'))
|
||||
play=json.loads((ROOT/'Doc/Design/Combat/SkillSlice/play.json').read_text(encoding='utf-8'))
|
||||
arena=json.loads((ROOT/'Doc/Design/Combat/EnemyCombatFx/studio_arena.json').read_text(encoding='utf-8'))
|
||||
assert all(r['status']=='pass' for r in (validation,play,arena))
|
||||
assert validation['traceSha256']==play['traceSha256']
|
||||
production='''# 火喵/岩团团技能闭环样例
|
||||
|
||||
2026-10-21完成独立样例。五敌人 Studio 模型已替换正式 PrototypeArena;本页的新技能逻辑则在独立验收场景运行。主线仍是 battleRules=2,未接 battle.begin/battle.events,未重启或部署开发服,未改变旧在途挑战、账号或奖励协议。
|
||||
|
||||
## 已完成
|
||||
|
||||
- `AIServer/src/combat_slice.hpp/.cpp`:纯 C++ 逻辑内核;50ms整数 tick,千分之一血量/攻击、毫米位置。实现施法通道、主动技能优先、前摇/释放/后摇、冷却、投射物、伤害/护盾、过期、死亡与取消。无 Unity、墙上时钟、数据库依赖;当前只编入独立 combat_slice_tests 目标,未编入线上 aiserver。
|
||||
- `Config/Combat/skill_slice.json`:隔离样例配置;岩团团前排、火喵后排、三个小角怪。敌人血量为原配置×2以覆盖护盾与近战,属于验收场景参数,不是20关的新平衡。原玩法和技能 proposal 的冻结副本保存在 trace 中,校验其内容及 SHA256。
|
||||
- `AIClient/Assets/Bundles/Combat/SkillSlice/trace.json`:由 C++ 导出,219为最后 tick、366事件、36次施法、12颗火球、30次唯一命中。客户端只播放事件,不再计算该样例的伤害。`Tools/export_skill_slice.py` 先保存输入原始字节到 `Tools/output/skill_slice_inputs/`,回执 `export.json` 记录输入及轨迹哈希;验证/Play回执绑定同一轨迹哈希,最新配置变化不会混入旧轨迹。
|
||||
- `SkillSlicePlayer.cs`:前摇、释放、到达和血量变化使用同一播放时钟;原 Attack 按各角色制作配方的 attackImpact 分段采样,火球在到达事件才扣血。拱形弹道仅影响外观;逻辑到达 tick 不变。已经释放的火球不因施法者死亡而撤回;释放前死亡取消。
|
||||
- 持久化 `SkillSlice.prefab`:五实体的模型/Muzzle/Animator绑定,5类特效、24个池实例、8个技能音源;复用现有敌人反馈池。蓄力按 castId、护盾按 entityId、火球按实例租约清理。音效开关、暂停/恢复、1×/2×、低帧率过期声音跳过、结束/禁用清理已实现。
|
||||
- 五条 TangoFlux 音效:FireCharge 0.48s、FireLaunch 0.28s、FireHit 0.40s、RockHit 0.32s、Shield 0.65s。按项目指南英文提示、2秒原稿、50步、guidance4.5、并发≤2生成,再裁切/淡入淡出。实际 seed/请求/任务ID/哈希以 `Doc/Design/Sound/BattleSkills/Slice/generation.json` 为准;原始计划 seed 保留在计划表中,没有冒称与生成请求相同。
|
||||
|
||||
## 验证与查看
|
||||
|
||||
- C++ 30项断言:起手无伤害、延迟到达、同 tick 零前摇护盾、动作互斥、后摇、冷却边界、死亡前取消、释放后存续、丢失目标不换目标、唯一死亡、护盾刷新不叠加、过期、元素防御、确定性与输入边界。
|
||||
- `Tools/verify_skill_slice.py`:事件顺序/唯一命中/火球到达 tick/冻结输入哈希、五条音效源稿及输出哈希、格式、起音、尾部淡出和零削波通过。人工听感未由自动检查代替;试听 `Doc/Design/Sound/BattleSkills/Slice/preview.wav`。
|
||||
- Unity实际 Play:火球到达前后血量检查通过;1×、2×、0.73秒低帧率步进的最终血量、护盾、位置、死亡/离场状态与 C++ 一致;暂停不前进、静音、重复结束、禁用清理通过。回执 `assets.json`、`play.json`、`validation.json`。
|
||||
- 直接打开 `Assets/Bundles/Scenes/Review/SkillSlice_Review.unity` 后 Play 可看独立样例。它不会载入账号或发奖励;结束后保持结果,重进 Play 可重播。未覆盖当前 Boot 场景。
|
||||
- 截图 `Assets/Screenshots/skill_slice_fireball.png`、`skill_slice_shield.png`。截图为暂停时刻,粒子额外推进30ms刷新几何,仅供视觉检查,不推进逻辑时钟。
|
||||
|
||||
## 复现
|
||||
|
||||
```powershell
|
||||
cmake --build AIServer/build-g1 --config Release --target combat_slice_tests
|
||||
python Tools/export_skill_slice.py
|
||||
python Tools/verify_skill_slice.py
|
||||
python Tools/validate_skill_design.py
|
||||
```
|
||||
|
||||
Unity经Pipeline依次运行 `BuildSkillSlice.Build`、`VerifySkillSlice.Assets`;Play中运行 `VerifySkillSlice.Play`,结束后停止。TangoFlux重制入口为 `Tools/generate_skill_slice_sfx.py`,有效源稿按请求哈希复用。
|
||||
|
||||
## 后续接入边界
|
||||
|
||||
完整25技能、治疗/减速、多路波次、trace分页和网络协议兼容仍待实现;不能把独立样例视为 battleRules=3 主线迁移完成。下一阶段将该内核接入服务端授权战斗与冻结配置,客户端加载权威轨迹,覆盖10伙伴/20关平衡及旧挑战恢复后再切换规则。当前计划音效清单为5/9已生成,Heal/Slow/Lightning/EnemyHit仍待制作。TangoFlux权重依项目指南为非商业研究用途,保留许可核查边界,未宣称商用放行。
|
||||
'''
|
||||
(ROOT/'Doc/Design/Combat/SkillSlice/Production.md').write_text(production,encoding='utf-8')
|
||||
append('Doc/ResDesc_Model.md',MARK+'''
|
||||
|
||||
正式 `PrototypeArena.prefab` 的9个敌人槽×5类型已全部改为 `{Hornlet,Dashlet,Shellhorn,HornCaptain,GreatHorn}Studio/*Studio_Enemy.prefab`;保留原 Avatar/控制器及底面、身高1.2的统一战场缩放。`BuildPrototype.FormalEnemies` 已同步,后续重建也使用 Studio 版。五类型实播 Walk/Attack 通过,敌人反馈产生158次特效、122次声音;内存隔离战斗不写账号。伙伴/T2和场景未重建。旧版资源继续保留。
|
||||
|
||||
新增独立技能场景/Prefab `AIClient/Assets/Bundles/Combat/SkillSlice/SkillSlice.prefab`、`Scenes/Review/SkillSlice_Review.unity`,复用岩团团、火喵和小角怪 Studio 模型,不生成新角色。说明 `Doc/Design/Combat/SkillSlice/Production.md`。''')
|
||||
append('Doc/ResDesc_Animation.md',MARK+'''
|
||||
|
||||
五敌人正式战场继续复用原 Avatar、AnimatorController 与 Idle/Walk/Attack,未改动画文件。独立 `SkillSlicePlayer` 根据逻辑释放 tick 和角色原配方 attackImpact 分段采样 Attack,将前摇/命中点/后摇对齐;动作不决定伤害。样例与正式主线规则分开,详见 `Doc/Design/Combat/SkillSlice/Production.md`。''')
|
||||
append('Doc/ResDesc_Effect.md',MARK+'''
|
||||
|
||||
新增 `AIClient/Assets/Bundles/Combat/SkillSlice/Vfx_Skill_{FireCharge,Fireball,FireHit,RockHit,Shield}.prefab`,分别为火焰蓄力、拱形飞行火球、火焰命中、石击、低亮度护盾;Unity Pipeline制作,复用 EnemyCombat/CombatGlow.mat 和 CombatRing.asset。`FireballCore.asset` 为 Pipeline 保存的 Unity 球形网格,用作火球主体粒子。独立 SkillSlice.prefab 持久化24个特效实例,租约及 cast/entity token控制清理;未接入主线。记录 `Doc/Design/Combat/SkillSlice/Production.md`。''')
|
||||
append('Doc/ResDesc_Sound.md',MARK+'''
|
||||
|
||||
新增 `AIClient/Assets/Bundles/Sound/SFX/BattleSkills/Sfx_{FireCharge,FireLaunch,FireHit,RockHit,Shield}.wav`,时长依次0.48/0.28/0.40/0.32/0.65秒;PCM16、44.1kHz立体声,TangoFlux 2秒/50步/guidance4.5/固定seed生成,65Hz高通、起音裁切、5ms淡入/50ms淡出,峰值约-1.94dBFS。原稿 `Tools/output/skill_slice_sfx/`;任务与实际请求哈希 `Doc/Design/Sound/BattleSkills/Slice/generation.json`,试听同目录 preview.wav。独立技能样例播放,8音源池、音效开关/暂停/切场清理通过;人工听感待试听。计划表现在5/9生成,4条仍计划。上游权重许可非商业研究,未声明商用许可。''')
|
||||
append('Doc/ResDesc_Picture.md',MARK+'''
|
||||
|
||||
- `AIClient/Assets/Screenshots/enemy_studio_arena.png`:五敌人 Studio 版在正式 PrototypeArena 的内存隔离实战截图,Unity Pipeline渲染。
|
||||
- `AIClient/Assets/Screenshots/skill_slice_fireball.png`、`skill_slice_shield.png`:独立技能样例中的火球飞行/护盾暂停时刻,Unity Pipeline渲染;用于动作/挂点/特效检查,不是业务UI。''')
|
||||
append('Doc/ResDesc_Config.md',MARK+'''
|
||||
|
||||
- `Config/Combat/skill_slice.json`:独立火喵/岩团团样例参数,productionEnabled=false。
|
||||
- `AIServer/src/combat_slice.hpp/.cpp`、`tests/combat_slice_tests.cpp`、CMake combat_slice_tests:整数tick的独立逻辑模块与30项检查/轨迹导出;未编入生产服务。
|
||||
- `AIClient/Assets/HotUpdate/Scripts/Gameplay/SkillSlicePlayer.cs`:消费不可变 C++ 事件的时钟/动作/特效音效播放器,不计算伤害与奖励。
|
||||
- `AIClient/AgentScripts/BuildSkillSlice.cs`、`VerifySkillSlice.cs`:Pipeline持久化制作与独立Play/截图/资源检查。
|
||||
- `Tools/generate_skill_slice_sfx.py`、`export_skill_slice.py`、`verify_skill_slice.py`、`write_skill_slice_report.py`:音频制作、冻结输入导出、来源与轨迹验证、资源登记。
|
||||
- `Doc/Design/Sound/BattleSkills/manifest.json`:5条产物关联实际生成回执,状态partially_generated;`Tools/validate_skill_design.py` 按文件哈希和成功任务校验,报告generatedSfxCount=5,不把9条全部标为完成。
|
||||
- `BuildPrototype.FormalEnemies` 与 `VerifyFormalEnemies` 已改为五 Studio 敌人;`VerifyEnemyCombatFx.PlayBattle(true)` 使用当前T2字段初始化的内存隔离实战验收。''')
|
||||
append('Doc/ResDesc.md',MARK+'''
|
||||
|
||||
五 Studio 敌人已替换正式战场并实播通过;火喵/岩团团独立技能样例完成动作、特效、五条TangoFlux音效与 C++ 逻辑事件闭环。完整记录 [SkillSlice/Production.md](Design/Combat/SkillSlice/Production.md),分类型登记见 Model/Animation/Effect/Sound/Picture/Config。主线仍为旧规则,样例未接网络战斗协议。''')
|
||||
append('Doc/Design/Character/StudioEnemies/Production.md',MARK+'''
|
||||
|
||||
此后五敌人已全部替换正式 `PrototypeArena`,9槽×5类型。`BuildPrototype.FormalEnemies` 与验收检查同步;原 Avatar/控制器/动作继续复用。五类在真实 `PrototypeFlow` 内存隔离战斗观察到 Walk/Attack,攻击/受击特效音效正常(158次特效、122次声音),截图 `Assets/Screenshots/enemy_studio_arena.png`。回执 `Doc/Design/Combat/EnemyCombatFx/studio_arena.json`。''')
|
||||
append('Doc/Design/Combat/SkillSystem.md',MARK+'''
|
||||
|
||||
火喵与岩团团已完成独立 C++ 事件轨迹→Unity 时钟/动作/5类特效/5条TangoFlux音效的可运行样例,详见 [SkillSlice/Production.md](SkillSlice/Production.md)。这一步未接主线 battle.begin/events,未切换 battleRules=3;原25技能的全关迁移仍待完成。计划音效清单现为5/9已生成、4/9待制作;正式主线与独立样例状态分别记录。''')
|
||||
append('Doc/Design/G0_Implementation.md',MARK+'''
|
||||
|
||||
五类敌人的 Studio 低模已替换正式战场,原Avatar/控制器/三动作不变;45个模型引用、尺寸/贴地和五类型 Walk/Attack、攻击受击反馈通过。保留现有伙伴及T2,不重建整体战场。
|
||||
|
||||
火喵/岩团团独立技能样例实现 C++ 50ms整数tick逻辑、动作通道和阶段、投射物到达伤害、护盾及特效音效播放。C++30项检查、Unity1×/2×/暂停/低帧率最终状态一致、静音和清理通过;生成5条TangoFlux音效。生产服务器和主线仍旧规则,未进行网络轨迹协议接入或20关规则迁移。记录 `Doc/Design/Combat/SkillSlice/Production.md`。''')
|
||||
print('Registered Studio arena integration and independent skill slice across six resource registries')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user