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:
udx
2026-10-21 23:01:31 +08:00
co-authored by Cursor
parent 89e0fa246f
commit 079313fb06
677 changed files with 395371 additions and 9692 deletions
+20
View File
@@ -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')
+2 -2
View File
@@ -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')
+32
View File
@@ -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))
+53 -1
View File
@@ -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()
+32
View File
@@ -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))
+65
View File
@@ -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()
+52
View File
@@ -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()
+64
View File
@@ -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()
+19
View File
@@ -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')
+43
View File
@@ -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))
+3
View File
@@ -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}
+61
View File
@@ -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()
+77
View File
@@ -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()
+88
View File
@@ -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()
+45
View File
@@ -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()
+9 -1
View File
@@ -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()
+15 -3
View File
@@ -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'))
+23
View File
@@ -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()
+59
View File
@@ -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()
+64
View File
@@ -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()
+98
View File
@@ -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()
+70
View File
@@ -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()
+99
View File
@@ -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()