Add formal enemies, remaining formal partners and synthesized UI SFX

- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
+38
View File
@@ -0,0 +1,38 @@
"""Drop loose mesh islands that TokenRig discards (thin floating tail tips, specks) so the baked game mesh and the rig agree.
blender -b --factory-startup --python Tools/model_tools/bl_keep_main_island.py -- in.glb out.glb [--min-fraction 0.05]
Islands are found after welding UV-seam duplicates (1e-4); only the welding used for the test, the export keeps the original split vertices and UVs.
"""
import argparse, json, sys
from pathlib import Path
import bmesh, bpy
p = argparse.ArgumentParser(); p.add_argument('source', type=Path); p.add_argument('output', type=Path)
p.add_argument('--min-fraction', type=float, default=.05, help='keep islands with at least this fraction of the largest island vertices')
a = p.parse_args(sys.argv[sys.argv.index('--') + 1:])
bpy.ops.wm.read_factory_settings(use_empty=True); bpy.ops.import_scene.gltf(filepath=str(a.source.resolve()))
report = []
for o in [o for o in bpy.data.objects if o.type == 'MESH']:
bm = bmesh.new(); bm.from_mesh(o.data); bm.verts.ensure_lookup_table()
# Union-find over original vertices, joining edges and co-located (seam-split) vertices.
parent = list(range(len(bm.verts)))
def find(i):
while parent[i] != i: parent[i] = parent[parent[i]]; i = parent[i]
return i
def union(i, j): parent[find(i)] = find(j)
for e in bm.edges: union(e.verts[0].index, e.verts[1].index)
cells = {}
for v in bm.verts:
key = tuple(round(c / 1e-4) for c in v.co)
if key in cells: union(v.index, cells[key])
else: cells[key] = v.index
sizes = {}
for v in bm.verts: r = find(v.index); sizes[r] = sizes.get(r, 0) + 1
largest = max(sizes.values()); keep = {r for r, n in sizes.items() if n >= largest * a.min_fraction}
drop = [v for v in bm.verts if find(v.index) not in keep]
report.append(dict(object=o.name, islands=len(sizes), kept=len(keep), droppedVertices=len(drop), droppedIslandSizes=sorted((n for r, n in sizes.items() if r not in keep), reverse=True)[:20]))
bmesh.ops.delete(bm, geom=drop, context='VERTS'); bm.to_mesh(o.data); bm.free(); o.data.update()
a.output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(a.output.resolve()), export_format='GLB')
rec = dict(source=str(a.source), output=str(a.output), minFraction=a.min_fraction, objects=report, note='Only loose islands removed; remaining geometry, UVs and textures unchanged.')
a.output.with_suffix('.islands.json').write_text(json.dumps(rec, indent=2), encoding='utf-8'); print(json.dumps(rec))
+29
View File
@@ -0,0 +1,29 @@
"""Rigidly orient a generated character before baking/rigging, preserving UVs."""
import argparse
import json
import math
from pathlib import Path
import sys
import bpy
from mathutils import Matrix,Vector
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('source',type=Path);p.add_argument('output',type=Path)
p.add_argument('--blender-yaw',type=float,required=True)
a=p.parse_args(sys.argv[sys.argv.index('--')+1:])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(a.source.resolve()))
if any(o.type=='ARMATURE' for o in bpy.data.objects):raise ValueError('Orient the static high source before rigging')
rotation=Matrix.Rotation(math.radians(a.blender_yaw),4,'Z')
roots=[o for o in bpy.context.scene.objects if o.parent is None]
for o in roots:o.matrix_world=rotation@o.matrix_world
bpy.context.view_layer.update()
points=[o.matrix_world@v.co for o in bpy.context.scene.objects if o.type=='MESH' for v in o.data.vertices]
mn=Vector([min(p[c] for p in points) for c in range(3)]);mx=Vector([max(p[c] for p in points) for c in range(3)])
offset=Matrix.Translation(-Vector(((mn.x+mx.x)/2,(mn.y+mx.y)/2,mn.z)))
for o in roots:o.matrix_world=offset@o.matrix_world
a.output.parent.mkdir(parents=True,exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(a.output.resolve()),export_format='GLB')
record=dict(source=str(a.source),output=str(a.output),blender_yaw_degrees=a.blender_yaw,orientation_note='Verify final forward in Unity views: FBX handedness conversion must be included.',height=mx.z-mn.z,mesh_unchanged_except_rigid_transform=True)
a.output.with_suffix('.orientation.json').write_text(json.dumps(record,indent=2),encoding='utf-8')
print(json.dumps(record))
+27
View File
@@ -157,6 +157,33 @@ def main():
# recipe-defined geodesic masks can bind that appendage to an existing
# stable bone without changing geometry, UVs or the animation skeleton.
override_reports=[]
# Explicit source-space regions can bind a rigid carapace without altering
# geometry/UVs. The recipe must be fitted to this character's inspected mesh.
if args.weight_overrides:
rules=json.loads(args.weight_overrides.read_text(encoding='utf-8-sig')).get('skinRegions',[])
extent=max(p.y for p in original_points)-min(p.y for p in original_points)
points=[p/extent for p in original_points]
for rule in rules:
if rule['bone'] not in arm.data.bones:raise ValueError('Region bone missing')
lo,hi=rule['min'],rule['max'];feather=rule.get('feather',0)
if feather<0 or any(lo[c]>=hi[c] for c in range(3)):raise ValueError('Invalid rigid region')
target=low.vertex_groups[rule['bone']];affected=[];full=[]
for i,p in enumerate(points):
distance=max(max(lo[c]-p[c],0,p[c]-hi[c]) for c in range(3))
if distance>feather:continue
weight=1 if distance==0 else 1-distance/feather
weight=weight*weight*(3-2*weight)
previous={g.group:g.weight for g in low.data.vertices[i].groups}
for group,value in previous.items():low.vertex_groups[group].add([i],value*(1-weight),'REPLACE')
target.add([i],previous.get(target.index,0)*(1-weight)+weight,'REPLACE')
affected.append(i)
if distance==0:full.append(i)
if not full:raise ValueError('Rigid region selected no vertices')
override_reports.append(dict(kind='source_space_region',bone=rule['bone'],affected=len(affected),full_weight_vertices=len(full),
normalized_min=[min(points[i][c] for i in affected) for c in range(3)],
normalized_max=[max(points[i][c] for i in affected) for c in range(3)]))
# More precise geodesic appendage overrides run after broad spatial regions.
if args.weight_overrides:
overrides=json.loads(args.weight_overrides.read_text(encoding='utf-8-sig')).get('skinOverrides',[])
extent=max(p.y for p in original_points)-min(p.y for p in original_points)