Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+491
View File
@@ -0,0 +1,491 @@
"""Build three original IWT2-inspired, rigged low-poly boss assets."""
from __future__ import annotations
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
OUT_ROOT = ROOT / "game_assets/models/original/bosses"
FPS = 30
PARTS: list[bpy.types.Object] = []
RUNTIME_MATERIALS: list[bpy.types.Material] = []
def reset_scene() -> None:
global PARTS, RUNTIME_MATERIALS
PARTS = []
RUNTIME_MATERIALS = []
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.cameras, bpy.data.lights, bpy.data.actions):
for block in list(blocks):
blocks.remove(block)
def material(name, color, metallic=0.0, roughness=0.5, emission=None, strength=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = color
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
if emission:
bsdf.inputs["Emission Color"].default_value = emission
bsdf.inputs["Emission Strength"].default_value = strength
return mat
def prepare_materials(specs):
mats = {name: material(f"M_{name}", **settings) for name, settings in specs.items()}
RUNTIME_MATERIALS.extend(mats.values())
return mats
def finish(obj, name, mat, bone, smooth=True):
obj.name = name
obj.data.name = f"{name}_Mesh"
for runtime_mat in RUNTIME_MATERIALS:
obj.data.materials.append(runtime_mat)
mat_index = next(index for index, candidate in enumerate(RUNTIME_MATERIALS) if candidate.name == mat.name)
for polygon in obj.data.polygons:
polygon.material_index = mat_index
polygon.use_smooth = smooth
bone_group = obj.vertex_groups.new(name=bone)
bone_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
mat_group = obj.vertex_groups.new(name=f"__MAT_{mat_index}")
mat_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
PARTS.append(obj)
return obj
def ellipsoid(name, location, scale, mat, bone, subdivisions=1, rotation=(0, 0, 0)):
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone)
def cone(name, start, end, radius_start, radius_end, mat, bone, vertices=7):
start_v, end_v = Vector(start), Vector(end)
direction = end_v - start_v
bpy.ops.mesh.primitive_cone_add(
vertices=vertices,
radius1=radius_start,
radius2=radius_end,
depth=direction.length,
location=(start_v + end_v) * 0.5,
)
obj = bpy.context.object
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
obj.rotation_mode = "XYZ"
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return finish(obj, name, mat, bone, smooth=False)
def plate(name, location, scale, rotation, mat, bone, vertices=6):
bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=1, radius2=0.58, depth=0.42, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone, smooth=False)
def armature(name, specs):
data = bpy.data.armatures.new(f"{name}_Rig")
rig = bpy.data.objects.new(f"{name}_Rig", data)
bpy.context.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
for bone_name, head, tail, parent in specs:
bone = data.edit_bones.new(bone_name)
bone.head, bone.tail = head, tail
if parent:
bone.parent = data.edit_bones[parent]
bpy.ops.object.mode_set(mode="POSE")
for bone in rig.pose.bones:
bone.rotation_mode = "XYZ"
bpy.ops.object.mode_set(mode="OBJECT")
return rig
def join_parts(name, rig):
bpy.ops.object.select_all(action="DESELECT")
for obj in PARTS:
obj.select_set(True)
bpy.context.view_layer.objects.active = PARTS[0]
bpy.ops.object.join()
body = bpy.context.object
body.name = f"{name}_Body"
body.data.name = f"{name}_Body_Mesh"
material_groups = {g.index: int(g.name.removeprefix("__MAT_")) for g in body.vertex_groups if g.name.startswith("__MAT_")}
face_materials = []
for polygon in body.data.polygons:
vertex = body.data.vertices[polygon.vertices[0]]
face_materials.append(next(material_groups[a.group] for a in vertex.groups if a.group in material_groups))
body.data.materials.clear()
for mat in RUNTIME_MATERIALS:
body.data.materials.append(mat)
for polygon, index in zip(body.data.polygons, face_materials, strict=True):
polygon.material_index = index
for group in [g for g in body.vertex_groups if g.name.startswith("__MAT_")]:
body.vertex_groups.remove(group)
modifier = body.modifiers.new(f"{name}_Armature", "ARMATURE")
modifier.object = rig
body.parent = rig
return body
def reset_pose(rig):
for bone in rig.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_pose(rig, frame, rotations=None, locations=None, scales=None):
reset_pose(rig)
for name, value in (rotations or {}).items():
rig.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
for name, value in (locations or {}).items():
rig.pose.bones[name].location = value
for name, value in (scales or {}).items():
rig.pose.bones[name].scale = value
for bone in rig.pose.bones:
bone.keyframe_insert("location", frame=frame, group=bone.name)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
bone.keyframe_insert("scale", frame=frame, group=bone.name)
def actions(rig, definitions):
rig.animation_data_create()
metadata = {}
for name, end_frame, loop, poses in definitions:
action = bpy.data.actions.new(name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start, action.frame_end, action.use_cyclic = 1, end_frame, loop
rig.animation_data.action = action
for pose in poses:
key_pose(rig, **pose)
rig.animation_data.action = None
metadata[name] = {"frames": end_frame, "seconds": round(end_frame / FPS, 3), "loop": loop}
return metadata
def select_only(objects):
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def collision(asset_id, specs, out_dir):
objects = []
for name, location, scale in specs:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location)
obj = bpy.context.object
obj.name = f"COLLISION_{name}"
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
objects.append(obj)
select_only(objects)
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}_collision.glb"), export_format="GLB", use_selection=True,
export_animations=False, export_skins=False, export_materials="NONE", export_apply=True, export_yup=True,
)
for obj in objects:
obj.hide_render = True
obj.hide_set(True)
return objects
def look_at(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def studio():
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = scene.render.resolution_y = 760
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.fps = FPS
scene.world.use_nodes = True
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.005, 0.007, 0.012, 1)
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.3
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
scene.camera = camera
for name, location, color, energy, size in [
("Key", (5, -7, 7), (1, 0.34, 0.1), 1200, 5),
("Fill", (-5, -3, 4), (0.12, 0.32, 1), 800, 4),
("Rim", (1, 6, 6), (1, 0.12, 0.02), 1150, 3),
]:
data = bpy.data.lights.new(name, "AREA")
data.energy, data.color, data.shape, data.size = energy, color, "DISK", size
obj = bpy.data.objects.new(name, data)
obj.location = location
bpy.context.collection.objects.link(obj)
look_at(obj, (0, 0, 1.5))
floor_mat = material("M_PreviewFloor", color=(0.014, 0.018, 0.026, 1), roughness=0.85)
bpy.ops.mesh.primitive_plane_add(size=28, location=(0, 0, 0))
floor = bpy.context.object
floor.data.materials.append(floor_mat)
return camera
def render_previews(rig, camera, out_dir, asset_id, target, distance, special_clip, special_frame):
preview_dir = out_dir / "previews"
preview_dir.mkdir(parents=True, exist_ok=True)
for filename, position, clip, frame in [
(f"{asset_id}_three_quarter.png", (distance * 0.62, -distance, distance * 0.48), "Idle", 15),
(f"{asset_id}_side.png", (distance, 0, distance * 0.42), "Idle", 15),
(f"{asset_id}_{special_clip.lower()}.png", (distance * 0.62, -distance, distance * 0.48), special_clip, special_frame),
]:
rig.animation_data.action = bpy.data.actions[clip]
bpy.context.scene.frame_set(frame)
camera.location = position
camera.data.lens = 58
look_at(camera, target)
bpy.context.scene.render.filepath = str(preview_dir / filename)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(asset_id, display_name, rig, body, clips, collision_specs, preview_target, preview_distance, special_clip, special_frame):
out_dir = OUT_ROOT / asset_id
out_dir.mkdir(parents=True, exist_ok=True)
select_only([rig, body])
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}.glb"), export_format="GLB", use_selection=True,
export_animations=True, export_animation_mode="ACTIONS", export_frame_range=True,
export_skins=True, export_morph=False, export_yup=True, export_apply=False,
export_cameras=False, export_lights=False,
)
collision(asset_id, collision_specs, out_dir)
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
metadata = {
"name": display_name,
"assetId": asset_id,
"authoringTool": bpy.app.version_string,
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": body.name,
"armature": rig.name,
"collisionAsset": f"{asset_id}_collision.glb",
"materials": [mat.name for mat in body.data.materials],
"trianglesApprox": triangles,
},
"animations": clips,
}
(out_dir / f"{asset_id}.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
camera = studio()
render_previews(rig, camera, out_dir, asset_id, preview_target, preview_distance, special_clip, special_frame)
bpy.ops.wm.save_as_mainfile(filepath=str(out_dir / f"{asset_id}.blend"))
print(f"BUILT={asset_id} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def build_ram():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.028, 0.034, 1), "metallic": 0.28, "roughness": 0.4},
"Basalt": {"color": (0.10, 0.09, 0.09, 1), "metallic": 0.14, "roughness": 0.62},
"Armor": {"color": (0.18, 0.16, 0.16, 1), "metallic": 0.22, "roughness": 0.48},
"Lava": {"color": (1, 0.08, 0.003, 1), "roughness": 0.2, "emission": (1, 0.025, 0, 1), "strength": 9},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None), ("Body", (0, 0, 1.35), (0, 0, 2.5), "Root"),
("Head", (0, -0.75, 2.05), (0, -1.75, 2.05), "Body"), ("Horn.L", (-0.45, -1.45, 2.35), (-1.2, -1.5, 2.55), "Head"),
("Horn.R", (0.45, -1.45, 2.35), (1.2, -1.5, 2.55), "Head"), ("Leg.FL", (-0.7, -0.75, 1.45), (-0.78, -0.8, 0.35), "Body"),
("Leg.FR", (0.7, -0.75, 1.45), (0.78, -0.8, 0.35), "Body"), ("Leg.BL", (-0.7, 0.8, 1.35), (-0.78, 0.8, 0.3), "Body"),
("Leg.BR", (0.7, 0.8, 1.35), (0.78, 0.8, 0.3), "Body"), ("Tail", (0, 1.2, 1.75), (0, 2.25, 1.35), "Body"),
]
rig = armature("ObsidianRam", specs)
ellipsoid("BodyCore", (0, 0.1, 1.95), (1.18, 1.48, 0.86), mats["Basalt"], "Body", 2)
ellipsoid("ShoulderMass", (0, -0.62, 2.08), (1.28, 0.82, 1.0), mats["Obsidian"], "Body", 2)
ellipsoid("Head", (0, -1.48, 1.93), (0.72, 0.78, 0.62), mats["Armor"], "Head", 2)
cone("Snout", (0, -1.7, 1.92), (0, -2.25, 1.72), 0.48, 0.22, mats["Obsidian"], "Head")
ellipsoid("Eye.L", (-0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
for side, suffix in [(-1, "L"), (1, "R")]:
points = [(side * 0.48, -1.55, 2.33), (side * 1.05, -1.62, 2.67), (side * 1.45, -1.44, 2.35), (side * 1.43, -1.25, 1.93), (side * 1.02, -1.45, 1.78)]
for index in range(len(points) - 1):
cone(f"Horn{suffix}_{index}", points[index], points[index + 1], 0.19 - index * 0.025, 0.15 - index * 0.025, mats["Obsidian"], f"Horn.{suffix}", 8)
for front, y, bone in [(True, -0.72, f"Leg.F{suffix}"), (False, 0.82, f"Leg.B{suffix}")]:
x = side * 0.78
cone(f"Leg{suffix}_{'F' if front else 'B'}", (x, y, 1.45), (x * 1.05, y, 0.35), 0.34, 0.24, mats["Basalt"], bone, 7)
ellipsoid(f"Hoof{suffix}_{'F' if front else 'B'}", (x * 1.06, y - 0.18, 0.25), (0.34, 0.46, 0.24), mats["Obsidian"], bone)
cone("Tail", (0, 1.1, 1.72), (0, 2.22, 1.3), 0.38, 0.12, mats["Basalt"], "Tail", 7)
for index, y in enumerate((-0.7, -0.15, 0.4, 0.95)):
plate(f"BackPlate{index}", (0, y, 2.72 + index * 0.03), (0.92 - index * 0.08, 0.62, 0.32), (math.radians(82), 0, 0), mats["Armor"], "Body")
cone(f"BackSpike{index}", (0, y, 2.86), (0, y + 0.05, 3.38 - index * 0.06), 0.17, 0, mats["Obsidian"], "Body", 6)
for side in (-1, 1):
cone(f"ShoulderSpike{side}", (side * 0.9, -0.55, 2.55), (side * 1.48, -0.5, 2.82), 0.18, 0, mats["Obsidian"], "Body", 6)
for index, (start, end) in enumerate([((-0.12, -1.96, 2.22), (0.1, -1.98, 1.78)), ((-0.55, -0.98, 2.54), (-0.35, -0.99, 1.93)), ((0.48, 0.42, 2.55), (0.25, 0.45, 1.95))]):
cone(f"LavaCrack{index}", start, end, 0.035, 0.02, mats["Lava"], "Head" if index == 0 else "Body", 5)
body = join_parts("ObsidianRam", rig)
clips = actions(rig, ram_actions())
export_asset("obsidian-ram-golem", "Obsidian Ram Golem", rig, body, clips,
[("Body", (0, 0, 1.7), (1.4, 1.75, 1.35)), ("Head", (0, -1.55, 1.85), (1.45, 0.85, 0.8))],
(0, 0, 1.6), 8.5, "Quake", 16)
def ram_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"Leg.FL": (18, 0, 0), "Leg.BR": (18, 0, 0), "Leg.FR": (-18, 0, 0), "Leg.BL": (-18, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}]),
("Charge", 30, False, [{"frame": 1}, {"frame": 8, "locations": {"Root": (0, 0.08, -0.12)}, "rotations": {"Head": (28, 0, 0), "Body": (8, 0, 0)}}, {"frame": 15, "locations": {"Root": (0, -0.18, 0.03)}, "rotations": {"Head": (18, 0, 0), "Leg.FL": (-24, 0, 0), "Leg.FR": (24, 0, 0)}}, {"frame": 23, "locations": {"Root": (0, -0.12, 0.08)}, "rotations": {"Head": (12, 0, 0), "Leg.FL": (24, 0, 0), "Leg.FR": (-24, 0, 0)}}, {"frame": 30}]),
("Quake", 36, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0.08, 0.18)}, "rotations": {"Body": (-18, 0, 0), "Head": (-20, 0, 0)}}, {"frame": 17, "locations": {"Root": (0, -0.08, -0.18)}, "rotations": {"Body": (24, 0, 0), "Head": (34, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.03)}, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 36}]),
("ArmorShatter", 42, False, [{"frame": 1}, {"frame": 10, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Head": (-12, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (0, 0, 10), "Horn.L": (0, -12, 0), "Horn.R": (0, 12, 0)}}, {"frame": 23, "rotations": {"Body": (0, 0, -8)}}, {"frame": 31, "rotations": {"Body": (0, 0, 4)}}, {"frame": 42}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.16, -0.08)}, "rotations": {"Body": (-15, 0, 12), "Head": (20, 0, -10)}}, {"frame": 13, "rotations": {"Body": (7, 0, -6)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.15, 0.1, -0.35)}, "rotations": {"Root": (0, 35, 32), "Head": (26, 0, 10)}}, {"frame": 42, "locations": {"Root": (0.25, 0.1, -1.15)}, "rotations": {"Root": (0, 58, 82), "Head": (42, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}, {"frame": 70, "locations": {"Root": (0.25, 0.1, -1.2)}, "rotations": {"Root": (0, 58, 82), "Head": (44, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}]),
]
def build_cinderback():
reset_scene()
mats = prepare_materials({
"Charcoal": {"color": (0.035, 0.03, 0.03, 1), "metallic": 0.18, "roughness": 0.55},
"Plate": {"color": (0.16, 0.10, 0.075, 1), "metallic": 0.2, "roughness": 0.48},
"Underbody": {"color": (0.18, 0.018, 0.01, 1), "roughness": 0.66},
"Lava": {"color": (1, 0.07, 0.002, 1), "roughness": 0.18, "emission": (1, 0.02, 0, 1), "strength": 10},
"Claw": {"color": (0.55, 0.32, 0.15, 1), "roughness": 0.4},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None), ("Body", (0, 0, 1.0), (0, 0, 1.95), "Root"),
("Head", (0, -1.0, 1.25), (0, -1.9, 1.18), "Body"), ("Tail.1", (0, 1.05, 1.2), (0, 1.8, 1.05), "Body"),
("Tail.2", (0, 1.75, 1.05), (0, 2.45, 0.8), "Tail.1"),
("Leg.FL", (-0.65, -0.75, 0.95), (-0.82, -0.82, 0.25), "Body"), ("Leg.FR", (0.65, -0.75, 0.95), (0.82, -0.82, 0.25), "Body"),
("Leg.BL", (-0.65, 0.65, 0.95), (-0.82, 0.7, 0.25), "Body"), ("Leg.BR", (0.65, 0.65, 0.95), (0.82, 0.7, 0.25), "Body"),
]
rig = armature("Cinderback", specs)
ellipsoid("Underbody", (0, 0, 1.12), (1.05, 1.62, 0.65), mats["Underbody"], "Body", 2)
ellipsoid("Head", (0, -1.42, 1.2), (0.68, 0.78, 0.54), mats["Plate"], "Head", 2)
cone("Snout", (0, -1.58, 1.2), (0, -2.05, 1.05), 0.4, 0.18, mats["Charcoal"], "Head")
ellipsoid("Eye.L", (-0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
for index, y in enumerate((-1.02, -0.58, -0.12, 0.34, 0.78, 1.16)):
width = 0.93 - abs(index - 2.5) * 0.055
plate(f"ShellPlate{index}", (0, y, 1.73 + math.sin(index / 5 * math.pi) * 0.18), (width, 0.53, 0.28), (math.radians(82), 0, 0), mats["Plate"], "Body")
cone(f"ShellSpike{index}", (0, y, 1.9), (0, y, 2.35 + math.sin(index / 5 * math.pi) * 0.15), 0.13, 0, mats["Charcoal"], "Body", 6)
for side, suffix in [(-1, "L"), (1, "R")]:
for front, y, bone in [(True, -0.68, f"Leg.F{suffix}"), (False, 0.68, f"Leg.B{suffix}")]:
x = side * 0.7
ellipsoid(f"LegMass{suffix}{front}", (x, y, 0.78), (0.38, 0.48, 0.42), mats["Charcoal"], bone)
cone(f"Leg{suffix}{front}", (x, y, 0.72), (side * 0.92, y - 0.12, 0.22), 0.22, 0.14, mats["Underbody"], bone)
for toe in (-0.12, 0, 0.12):
cone(f"Toe{suffix}{front}{toe}", (side * 0.92 + toe, y - 0.22, 0.2), (side * 0.95 + toe, y - 0.58, 0.11), 0.05, 0, mats["Claw"], bone, 5)
cone("Tail1", (0, 1.0, 1.12), (0, 1.85, 0.88), 0.42, 0.27, mats["Plate"], "Tail.1")
cone("Tail2", (0, 1.78, 0.88), (0, 2.48, 0.68), 0.29, 0.06, mats["Charcoal"], "Tail.2")
for index, (x, y, z) in enumerate([(-0.48, -0.82, 1.56), (0.45, -0.28, 1.68), (-0.42, 0.28, 1.72), (0.38, 0.8, 1.55)]):
cone(f"LavaCrack{index}", (x, y, z), (x * 0.65, y + 0.18, z - 0.32), 0.035, 0.018, mats["Lava"], "Body", 5)
body = join_parts("Cinderback", rig)
clips = actions(rig, cinderback_actions())
export_asset("cinderback-ricochet", "Cinderback Ricochet", rig, body, clips,
[("Body", (0, 0, 1.05), (1.25, 1.85, 0.95)), ("Roll", (0, 0, 1.2), (1.45, 1.45, 1.45))],
(0, 0, 1.15), 8.3, "ArmorSlam", 15)
def cinderback_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 17, "rotations": {"Leg.FL": (14, 0, 0), "Leg.BR": (14, 0, 0), "Leg.FR": (-14, 0, 0), "Leg.BL": (-14, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 32, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}]),
("Curl", 28, False, [{"frame": 1}, {"frame": 9, "locations": {"Root": (0, 0, 0.12)}, "rotations": {"Head": (-42, 0, 0), "Tail.1": (50, 0, 0), "Tail.2": (65, 0, 0), "Leg.FL": (45, 0, 0), "Leg.FR": (45, 0, 0), "Leg.BL": (-45, 0, 0), "Leg.BR": (-45, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.02, 1.02, 1.02)}, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}, {"frame": 28, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}]),
("Ricochet", 30, True, [{"frame": 1, "rotations": {"Root": (0, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 8, "rotations": {"Root": (90, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 16, "rotations": {"Root": (180, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 23, "rotations": {"Root": (270, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 30, "rotations": {"Root": (360, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}]),
("ArmorSlam", 38, False, [{"frame": 1}, {"frame": 11, "locations": {"Root": (0, 0.1, 0.22)}, "rotations": {"Body": (-22, 0, 0), "Head": (-18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.08, -0.16)}, "rotations": {"Body": (28, 0, 0), "Head": (34, 0, 0)}}, {"frame": 24, "rotations": {"Body": (-8, 0, 0)}}, {"frame": 38}]),
("Recover", 24, False, [{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (18, 0, 0)}}, {"frame": 8, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 16, "rotations": {"Body": (2, 0, 0)}}, {"frame": 24}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.14, -0.08)}, "rotations": {"Body": (-12, 0, 11), "Head": (22, 0, -10)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 66, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.25)}, "rotations": {"Root": (0, 28, 35), "Head": (24, 0, 0)}}, {"frame": 42, "locations": {"Root": (0.18, 0.1, -0.8)}, "rotations": {"Root": (0, 50, 78), "Head": (38, 0, 0), "Tail.1": (-25, 0, 0)}}, {"frame": 66, "locations": {"Root": (0.18, 0.1, -0.84)}, "rotations": {"Root": (0, 50, 78), "Head": (40, 0, 0), "Tail.1": (-28, 0, 0)}}]),
]
def build_scorpion():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.025, 0.03, 1), "metallic": 0.2, "roughness": 0.5},
"Ochre": {"color": (0.42, 0.19, 0.045, 1), "metallic": 0.16, "roughness": 0.44},
"Gold": {"color": (0.85, 0.38, 0.035, 1), "metallic": 0.3, "roughness": 0.28},
"Glass": {"color": (1, 0.38, 0.02, 1), "roughness": 0.12, "emission": (1, 0.16, 0.01, 1), "strength": 4},
"Bone": {"color": (0.58, 0.38, 0.17, 1), "roughness": 0.38},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.4), None), ("Body", (0, 0, 0.85), (0, 0, 1.55), "Root"),
("Head", (0, -0.65, 1.0), (0, -1.35, 0.92), "Body"), ("Claw.L", (-0.55, -0.8, 1.0), (-1.45, -1.6, 0.9), "Body"),
("Claw.R", (0.55, -0.8, 1.0), (1.45, -1.6, 0.9), "Body"), ("LegFront.L", (-0.5, -0.25, 0.8), (-1.4, -0.55, 0.25), "Body"),
("LegFront.R", (0.5, -0.25, 0.8), (1.4, -0.55, 0.25), "Body"), ("LegBack.L", (-0.5, 0.45, 0.8), (-1.4, 0.75, 0.25), "Body"),
("LegBack.R", (0.5, 0.45, 0.8), (1.4, 0.75, 0.25), "Body"),
("Tail.1", (0, 0.65, 1.0), (0, 1.35, 1.35), "Body"), ("Tail.2", (0, 1.3, 1.35), (0, 1.75, 2.0), "Tail.1"),
("Tail.3", (0, 1.7, 2.0), (0, 1.5, 2.68), "Tail.2"), ("Tail.4", (0, 1.5, 2.68), (0, 0.82, 3.0), "Tail.3"),
("Stinger", (0, 0.82, 3.0), (0, 0.18, 2.52), "Tail.4"),
]
rig = armature("SandglassScorpion", specs)
ellipsoid("Abdomen", (0, 0.28, 1.0), (0.92, 1.12, 0.54), mats["Obsidian"], "Body", 2)
ellipsoid("Torso", (0, -0.55, 0.98), (0.76, 0.7, 0.48), mats["Ochre"], "Body", 2)
ellipsoid("Head", (0, -1.12, 0.9), (0.52, 0.48, 0.36), mats["Obsidian"], "Head", 1)
ellipsoid("Hourglass", (0, 0.15, 1.48), (0.42, 0.55, 0.24), mats["Glass"], "Body", 2)
ellipsoid("HourglassWaist", (0, 0.15, 1.5), (0.44, 0.13, 0.26), mats["Obsidian"], "Body", 1)
for side, suffix in [(-1, "L"), (1, "R")]:
ellipsoid(f"Eye{suffix}", (side * 0.31, -1.42, 1.02), (0.06, 0.04, 0.06), mats["Glass"], "Head")
cone(f"ClawArm{suffix}", (side * 0.45, -0.65, 1.0), (side * 1.25, -1.3, 0.9), 0.22, 0.16, mats["Ochre"], f"Claw.{suffix}")
ellipsoid(f"ClawBody{suffix}", (side * 1.55, -1.55, 0.9), (0.62, 0.75, 0.42), mats["Gold"], f"Claw.{suffix}", 1, rotation=(0, 0, math.radians(side * 8)))
ellipsoid(f"ClawGlass{suffix}", (side * 1.55, -1.68, 0.95), (0.37, 0.42, 0.22), mats["Glass"], f"Claw.{suffix}")
cone(f"ClawTipOuter{suffix}", (side * 1.62, -1.98, 0.92), (side * 1.9, -2.45, 0.8), 0.2, 0, mats["Bone"], f"Claw.{suffix}")
cone(f"ClawTipInner{suffix}", (side * 1.42, -1.98, 0.88), (side * 1.25, -2.32, 0.76), 0.14, 0, mats["Bone"], f"Claw.{suffix}")
for index, (y, z) in enumerate([(-0.65, 0.7), (-0.18, 0.62), (0.32, 0.62), (0.78, 0.68)]):
bone = f"LegFront.{suffix}" if index < 2 else f"LegBack.{suffix}"
start = (side * 0.52, y, z + 0.18)
joint = (side * (1.08 + index * 0.08), y + (index - 1.5) * 0.12, z)
end = (side * (1.45 + index * 0.09), y + (index - 1.5) * 0.24, 0.16)
cone(f"Leg{suffix}{index}A", start, joint, 0.14, 0.1, mats["Ochre"], bone, 6)
cone(f"Leg{suffix}{index}B", joint, end, 0.1, 0.035, mats["Obsidian"], bone, 6)
tail_points = [(0, 0.62, 1.02), (0, 1.35, 1.36), (0, 1.75, 2.0), (0, 1.5, 2.68), (0, 0.82, 3.0), (0, 0.18, 2.52)]
tail_bones = ["Tail.1", "Tail.2", "Tail.3", "Tail.4", "Stinger"]
for index, bone in enumerate(tail_bones):
cone(f"TailSegment{index}", tail_points[index], tail_points[index + 1], 0.32 - index * 0.045, 0.25 - index * 0.04, mats["Ochre"] if index < 4 else mats["Gold"], bone, 8)
if index < 4:
plate(f"TailPlate{index}", tail_points[index + 1], (0.38 - index * 0.03, 0.32, 0.2), (0, 0, 0), mats["Gold"], bone)
cone("StingerTip", (0, 0.18, 2.52), (0, -0.15, 2.18), 0.18, 0, mats["Glass"], "Stinger", 7)
body = join_parts("SandglassScorpion", rig)
clips = actions(rig, scorpion_actions())
export_asset("sandglass-scorpion", "Sandglass Scorpion", rig, body, clips,
[("Body", (0, -0.1, 0.85), (1.15, 1.45, 0.8)), ("Claws", (0, -1.55, 0.9), (2.15, 1.0, 0.65)), ("Tail", (0, 1.0, 2.0), (0.65, 1.4, 1.35))],
(0, -0.1, 1.3), 9.5, "Eruption", 16)
def scorpion_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, 4), "Claw.L": (0, 0, -3), "Claw.R": (0, 0, 3)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, -4), "Claw.L": (0, 0, 3), "Claw.R": (0, 0, -3)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"LegFront.L": (16, 0, -5), "LegBack.R": (16, 0, 5), "LegFront.R": (-16, 0, 5), "LegBack.L": (-16, 0, -5)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}]),
("ClawAttack", 28, False, [{"frame": 1}, {"frame": 9, "rotations": {"Claw.L": (-10, 18, 30), "Claw.R": (-10, -18, -30)}}, {"frame": 14, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Claw.L": (16, -10, -38), "Claw.R": (16, 10, 38), "Head": (-8, 0, 0)}}, {"frame": 21, "rotations": {"Claw.L": (5, 0, -12), "Claw.R": (5, 0, 12)}}, {"frame": 28}]),
("Burrow", 34, False, [{"frame": 1}, {"frame": 10, "locations": {"Root": (0, 0, -0.15)}, "rotations": {"Claw.L": (35, 0, 0), "Claw.R": (35, 0, 0), "Tail.1": (-20, 0, 0)}}, {"frame": 20, "locations": {"Root": (0, 0, -0.62)}, "scales": {"Body": (0.92, 0.92, 0.92)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}, {"frame": 34, "locations": {"Root": (0, 0, -0.72)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}]),
("Eruption", 38, False, [{"frame": 1}, {"frame": 11, "rotations": {"Tail.2": (-18, 0, 0), "Tail.3": (-22, 0, 0), "Tail.4": (24, 0, 0), "Stinger": (18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.05, 0.1)}, "rotations": {"Tail.2": (22, 0, 0), "Tail.3": (34, 0, 0), "Tail.4": (-42, 0, 0), "Stinger": (-38, 0, 0)}}, {"frame": 24, "rotations": {"Tail.3": (10, 0, 0), "Tail.4": (-12, 0, 0)}}, {"frame": 38}]),
("Hourglass", 44, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "rotations": {"Body": (-10, 0, 0), "Claw.L": (0, 0, -24), "Claw.R": (0, 0, 24), "Tail.3": (-18, 0, 0)}}, {"frame": 20, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Claw.L": (0, 0, 38), "Claw.R": (0, 0, -38), "Tail.3": (22, 0, 0)}}, {"frame": 30, "scales": {"Body": (0.96, 0.96, 0.96)}, "rotations": {"Claw.L": (0, 0, -16), "Claw.R": (0, 0, 16)}}, {"frame": 44}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.12, -0.08)}, "rotations": {"Body": (-12, 0, 10), "Claw.L": (-15, 0, -18), "Claw.R": (-15, 0, 18), "Tail.2": (18, 0, 0)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.3)}, "rotations": {"Root": (0, 30, 38), "Tail.2": (-28, 0, 0), "Claw.L": (25, 0, -30), "Claw.R": (-10, 0, 30)}}, {"frame": 44, "locations": {"Root": (0.18, 0.1, -0.78)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-48, 0, 0), "Tail.3": (-32, 0, 0), "Claw.L": (38, 0, -42), "Claw.R": (12, 0, 38)}}, {"frame": 70, "locations": {"Root": (0.18, 0.1, -0.82)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-50, 0, 0), "Tail.3": (-34, 0, 0), "Claw.L": (40, 0, -44), "Claw.R": (14, 0, 40)}}]),
]
def main():
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_ram()
build_cinderback()
build_scorpion()
if __name__ == "__main__":
main()
+709
View File
@@ -0,0 +1,709 @@
"""Build animation-ready GLBs for the locally-authored inspired creature set.
The legacy source FBX files use ASCII encoding, which modern Blender cannot read.
Assimp converts each DAE to glTF without changing the source assets; Blender then
adds reusable actions and exports a texture-packed GLB beside each source model.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
FPS = 30
CLIP_SPECS = {
"Idle": {"frames": 60, "loop": True},
"Move": {"frames": 36, "loop": True},
"Attack": {"frames": 36, "loop": False},
"HitReact": {"frames": 24, "loop": False},
"Death": {"frames": 72, "loop": False},
}
@dataclass(frozen=True)
class AssetProfile:
asset_id: str
display_name: str
body: tuple[str, ...]
creature: str
red_eyes_roles: bool = False
PROFILES = (
AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"),
AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"),
AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"),
AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"),
AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"),
AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"),
AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True),
)
RED_EYES = {
"body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"),
"head": ("bone0025", "bone0031", "bone0047", "bone0073"),
"jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"),
"wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"),
"wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"),
"arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"),
"arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"),
"leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"),
"leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"),
"tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"),
}
ULTIMATE_HEAD_CHAINS = {
"head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",),
"head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",),
"head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",),
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights):
for block in list(blocks):
blocks.remove(block)
def parse_smd_bones(path: Path, count: int) -> list[str]:
nodes: list[tuple[int, str, int]] = []
in_nodes = False
for line in path.read_text(errors="replace").splitlines():
if line == "nodes":
in_nodes = True
continue
if in_nodes and line == "end":
break
if not in_nodes:
continue
match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line)
if match:
nodes.append((int(match.group(1)), match.group(2), int(match.group(3))))
if len(nodes) < count:
raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones")
return [name for _, name, _ in nodes[-count:]]
def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]:
source_dir = MODEL_ROOT / profile.asset_id
dae_path = next(source_dir.glob("MMD_*.dae"))
smd_path = dae_path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name:
temp_dir = Path(temp_dir_name)
gltf_path = temp_dir / "model.gltf"
subprocess.run(
["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"],
check=True,
capture_output=True,
text=True,
)
for image_path in source_dir.glob("*.png"):
shutil.copy2(image_path, temp_dir / image_path.name)
bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True)
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
raise RuntimeError(f"{profile.asset_id}: expected one armature")
rig = armatures[0]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and (
obj.parent == rig
or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers)
)
]
if not meshes:
raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh")
for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]:
bpy.data.objects.remove(helper, do_unlink=True)
source_names = parse_smd_bones(smd_path, len(rig.data.bones))
old_names = [bone.name for bone in rig.data.bones]
for old_name, source_name in zip(old_names, source_names, strict=True):
rig.data.bones[old_name].name = source_name
for mesh in meshes:
group = mesh.vertex_groups.get(old_name)
if group:
group.name = source_name
rig.name = f"{profile.asset_id}_Rig"
rig.data.name = f"{profile.asset_id}_Armature"
for index, mesh in enumerate(meshes):
mesh.name = f"{profile.asset_id}_Mesh_{index:02d}"
mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}"
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
rig.animation_data_clear()
return rig, meshes, dae_path
def side(name: str) -> str | None:
lower = name.lower()
if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "l"
if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "r"
if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "l"
if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "r"
return None
def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]:
names = [bone.name for bone in rig.data.bones]
if profile.red_eyes_roles:
return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()}
roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")}
roles["body"] = [name for name in profile.body if name in names]
for name in names:
lower = name.lower()
bone_side = side(name)
if "wing" in lower:
if bone_side:
roles[f"wing_{bone_side}"].append(name)
elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")):
if bone_side:
roles[f"arm_{bone_side}"].append(name)
elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower):
if bone_side:
roles[f"leg_{bone_side}"].append(name)
if "tail" in lower:
roles["tail"].append(name)
if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}):
roles["head"].append(name)
if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")):
roles["jaw"].append(name)
if profile.creature == "wraith":
roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")]
roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")]
roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names)
roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names)
if profile.creature == "insect":
roles["head"].extend(name for name in names if "sensor" in name.lower())
if profile.asset_id == "blue-eyes-ultimate-dragon":
for role, chain in ULTIMATE_HEAD_CHAINS.items():
roles[role] = [name for name in chain if name in names]
return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()}
def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]:
result = {}
for index, name in enumerate(roles.get(role, [])[:limit]):
factor = falloff ** index
result[name] = tuple(component * factor for component in degrees)
return result
def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]:
merged: dict[str, tuple[float, float, float]] = {}
for values in maps:
for name, rotation in values.items():
previous = merged.get(name, (0.0, 0.0, 0.0))
merged[name] = tuple(previous[index] + rotation[index] for index in range(3))
return merged
def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5))
wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5))
tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82)
tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82)
legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2))
return {
"Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)],
"Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))],
}
def ultimate_head_pose(
roles: dict[str, list[str]],
left: tuple[float, float, float],
center: tuple[float, float, float],
right: tuple[float, float, float],
) -> dict[str, tuple[float, float, float]]:
"""Bend each long neck as a chain; rotating only head bones reads as static."""
result: dict[str, tuple[float, float, float]] = {}
weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22)
for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True):
pitch, yaw, roll = motion
for index, name in enumerate(roles.get(role, [])):
weight = weights[min(index, len(weights) - 1)]
result[name] = (roll * weight, pitch * weight, yaw * weight)
return result
def ultimate_living_heads(
roles: dict[str, list[str]],
progress: float,
intensity: float = 1.0,
) -> dict[str, tuple[float, float, float]]:
"""Use distinct harmonic timing so three heads never bob in lockstep."""
phase = math.tau * progress
left = (
intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)),
intensity * (2.0 * math.sin(phase * 2 + 0.75)),
intensity * 0.8 * math.sin(phase + 0.2),
)
center = (
intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)),
intensity * (1.5 * math.sin(phase * 3 + 1.4)),
intensity * 0.65 * math.sin(phase + 2.6),
)
right = (
intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)),
intensity * (1.9 * math.sin(phase * 2 + 3.7)),
intensity * 0.75 * math.sin(phase + 4.55),
)
return ultimate_head_pose(roles, left, center, right)
def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]:
"""Flap around local Z only; local Y sweeps these wings around body."""
result: dict[str, tuple[float, float, float]] = {}
for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)):
for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)):
result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight)
return result
def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]:
"""Jaw hinges use local Y; local X would twist along snout."""
return {
"BEUD_LChin": (0.0, left, 0.0),
"BEUD_Chin": (0.0, center, 0.0),
"BEUD_RChin": (0.0, right, 0.0),
}
def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
idle = []
for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60):
progress = (frame - 1) / 59
idle.append((frame, merge(
ultimate_living_heads(roles, progress),
ultimate_wings(1.8 * math.sin(math.tau * progress)),
rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82),
)))
move = []
for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36):
progress = (frame - 1) / 35
stride = math.sin(math.tau * progress)
move.append((frame, merge(
ultimate_living_heads(roles, progress, 1.2),
ultimate_wings(7.0 * stride),
rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82),
rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2),
rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2),
)))
attack_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2),
(10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4),
(14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3),
(17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4),
(21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2),
(28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1),
(36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
attack = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
ultimate_jaws(*jaws),
ultimate_wings(wing),
))
for frame, left, center, right, jaws, wing in attack_specs
]
hit_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4),
(7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2),
(11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1),
(16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0),
(24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
hit_react = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in hit_specs
]
death_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2),
(25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3),
(42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7),
(58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9),
(72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9),
)
death = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in death_specs
]
return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death}
def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
"""Author Red-Eyes motion around its unusually oriented local bone axes.
This rig's wing bones do not share the local axes used by the named dragon
rigs. Rotating every wing segment as one role makes each membrane chain curl
forward like an arm. Instead, rotate the two shoulder roots in mirrored
armature-space directions and add only a small elbow fold. Both sides use
identical keyframe timing so they read as one aerodynamic flap.
"""
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = {
"bone0016": (38.0, 32.8, 38.3),
"bone0033": (-38.0, -32.8, 38.3),
"bone0006": (-11.3, 14.0, -0.4),
"bone0012": (11.3, -14.0, -0.4),
}
wings_down = {
"bone0016": (-12.8, -28.5, -13.1),
"bone0033": (12.8, 28.5, -13.1),
"bone0006": (7.6, -9.3, -1.3),
"bone0012": (-7.6, 9.3, -1.3),
}
wings_recover = {
"bone0016": (15.5, 16.0, 15.7),
"bone0033": (-15.5, -16.0, 15.7),
"bone0006": (-5.0, 6.0, -0.2),
"bone0012": (5.0, -6.0, -0.2),
}
tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78)
tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78)
legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2))
# The neck runs forward/down in armature space. Its local Z axis is the
# pitch axis: negative lifts the head for anticipation; positive drives the
# snout down through the bite. Jaw roots open during the windup, then close
# at impact instead of swinging the forelimbs as the attack silhouette.
head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)}
head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)}
head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)}
jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)}
jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)}
return {
"Idle": [
(1, neutral),
(14, wings_up),
(28, neutral),
(42, wings_down),
(54, wings_recover),
(60, neutral),
],
"Move": [
(1, merge(wings_up, legs_a, tail_l)),
(9, neutral),
(18, merge(wings_down, legs_b, tail_r)),
(27, neutral),
(36, merge(wings_up, legs_a, tail_l)),
],
"Attack": [
(1, neutral),
(8, merge(wings_up, head_lift, jaw_open)),
(14, merge(wings_down, head_bite, jaw_open)),
(17, merge(wings_down, head_bite, jaw_closed)),
(23, merge(wings_recover, head_follow_through)),
(30, neutral),
(36, neutral),
],
"HitReact": [
(1, neutral),
(5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)),
(13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)),
(24, neutral),
],
"Death": [
(1, neutral),
(18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)),
(44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))),
(72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)),
],
}
def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3))
arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3))
step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3))
step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3))
return {
"Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)],
"Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))],
}
def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4))
legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4))
wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3))
wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3))
return {
"Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)],
"Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))],
}
def wraith_tentacles(
roles: dict[str, list[str]],
sweep: float,
curl: float,
lift: float = 0,
phase: float = 0,
) -> dict[str, tuple[float, float, float]]:
"""Pose every independently rigged vine chain instead of one flattened pair."""
result: dict[str, tuple[float, float, float]] = {}
for role, side_sign in (("arm_l", -1), ("arm_r", 1)):
chains: dict[str, list[str]] = {}
for name in roles.get(role, []):
match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE)
if match:
chains.setdefault(match.group(1).lower(), []).append(name)
for chain_index, chain_name in enumerate(sorted(chains)):
chain = chains[chain_name]
pair_direction = -1 if chain_index % 2 else 1
chain_phase = phase + chain_index * 0.85
for bone_index, name in enumerate(chain):
progress = bone_index / max(1, len(chain) - 1)
tip_weight = 0.38 + progress * 0.62
wave = math.sin(chain_phase + progress * math.pi * 1.65)
result[name] = (
lift * pair_direction * (1 - progress * 0.45),
side_sign * sweep * tip_weight * 0.42,
side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight,
)
return result
def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2)
idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2)
drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65)
drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65)
windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1)
lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35)
recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75)
collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5)
return {
"Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)],
"Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))],
}
def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]:
pose_factory = {
"dragon": dragon_poses,
"guardian": guardian_poses,
"insect": insect_poses,
"wraith": wraith_poses,
}[profile.creature]
if profile.asset_id == "blue-eyes-ultimate-dragon":
definitions = ultimate_dragon_poses(roles)
elif profile.red_eyes_roles:
definitions = red_eyes_dragon_poses(roles)
else:
definitions = pose_factory(roles)
rig.animation_data_create()
metadata = {}
for clip_name, poses in definitions.items():
action = bpy.data.actions.new(clip_name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start = 1
action.frame_end = CLIP_SPECS[clip_name]["frames"]
action.use_cyclic = CLIP_SPECS[clip_name]["loop"]
rig.animation_data.action = action
animated_names = sorted({name for _, rotations in poses for name in rotations})
for frame, rotations in poses:
for name in animated_names:
bone = rig.pose.bones.get(name)
if not bone:
continue
bone.rotation_mode = "XYZ"
degrees = rotations.get(name, (0.0, 0.0, 0.0))
bone.rotation_euler = tuple(math.radians(component) for component in degrees)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
metadata[clip_name] = {
"frames": CLIP_SPECS[clip_name]["frames"],
"seconds": CLIP_SPECS[clip_name]["frames"] / FPS,
"loop": CLIP_SPECS[clip_name]["loop"],
}
rig.animation_data.action = None
for bone in rig.pose.bones:
bone.rotation_euler = (0, 0, 0)
return metadata
def select_only(objects: list[bpy.types.Object]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]:
depsgraph = bpy.context.evaluated_depsgraph_get()
points: list[Vector] = []
for mesh in meshes:
evaluated = mesh.evaluated_get(depsgraph)
evaluated_mesh = evaluated.to_mesh()
points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices)
evaluated.to_mesh_clear()
minimum = Vector(tuple(min(point[index] for point in points) for index in range(3)))
maximum = Vector(tuple(max(point[index] for point in points) for index in range(3)))
return minimum, maximum
def look_at(obj: bpy.types.Object, target: Vector) -> None:
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None:
rig.animation_data.action = bpy.data.actions["Attack"]
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
minimum, maximum = world_bounds(meshes)
center = (minimum + maximum) * 0.5
radius = (maximum - minimum).length * 0.5
size = radius * 2
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.world.color = (0.008, 0.012, 0.02)
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera.data.lens = 65
view_direction = Vector((0.8, -1.05, 0.42)).normalized()
half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens))
camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45)
look_at(camera, center)
scene.camera = camera
for name, offset, color, energy, radius in (
("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0),
("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0),
("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5),
):
light_data = bpy.data.lights.new(name, "AREA")
light_data.energy = energy
light_data.color = color
light_data.shape = "DISK"
light_data.size = size * radius
light = bpy.data.objects.new(name, light_data)
light.location = center + Vector(offset) * size
bpy.context.collection.objects.link(light)
look_at(light, center)
out_path.parent.mkdir(parents=True, exist_ok=True)
scene.render.filepath = str(out_path)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None:
source_dir = MODEL_ROOT / profile.asset_id
output_path = source_dir / f"{profile.asset_id}-animated.glb"
select_only([rig, *meshes])
bpy.ops.export_scene.gltf(
filepath=str(output_path),
export_format="GLB",
use_selection=True,
export_animations=True,
export_animation_mode="ACTIONS",
export_frame_range=True,
export_skins=True,
export_morph=True,
export_yup=True,
export_apply=False,
export_cameras=False,
export_lights=False,
export_image_format="AUTO",
)
triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons)
metadata = {
"name": profile.display_name,
"assetId": profile.asset_id,
"source": source_path.name,
"format": "glTF 2.0 binary (GLB)",
"authoringTool": bpy.app.version_string,
"fps": FPS,
"trianglesApprox": triangles,
"animations": clips,
"animatedBones": {role: names for role, names in roles.items() if names},
}
(source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png")
print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
bpy.context.scene.render.fps = FPS
requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES}
if unknown_ids:
raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}")
selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids]
for profile in selected_profiles:
reset_scene()
rig, meshes, source_path = convert_and_import(profile)
roles = build_roles(profile, rig)
clips = create_actions(profile, rig, roles)
export_asset(profile, rig, meshes, source_path, clips, roles)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""Print rig and mesh details for the locally-authored inspired creature models."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def inspect_model(path: Path) -> dict[str, object]:
reset_scene()
source_path = path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix="thor-yugioh-inspect-") as temp_dir:
converted_path = Path(temp_dir) / "model.glb"
subprocess.run(
["assimp", "export", str(source_path), str(converted_path), "-f", "glb2"],
check=True,
capture_output=True,
text=True,
)
bpy.ops.import_scene.gltf(filepath=str(converted_path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
bounds = []
for mesh in meshes:
bounds.extend(mesh.matrix_world @ Vector(corner) for corner in mesh.bound_box)
return {
"asset": path.parent.name,
"source": source_path.name,
"armatures": [
{
"name": rig.name,
"bones": [bone.name for bone in rig.data.bones],
"actions": [action.name for action in bpy.data.actions],
}
for rig in armatures
],
"meshes": [mesh.name for mesh in meshes],
"materials": sorted({slot.material.name for mesh in meshes for slot in mesh.material_slots if slot.material}),
"bounds": {
"min": [round(min(point[index] for point in bounds), 4) for index in range(3)],
"max": [round(max(point[index] for point in bounds), 4) for index in range(3)],
} if bounds else None,
}
def main() -> None:
report = [inspect_model(path) for path in sorted(MODEL_ROOT.glob("*/MMD_*.fbx"))]
print("YUGIOH_MODEL_REPORT=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,240 @@
"""Re-import and validate every generated creature animation GLB."""
from __future__ import annotations
import json
from pathlib import Path
import re
import struct
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
def read_glb_json(path: Path) -> dict[str, object]:
data = path.read_bytes()
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
if magic != b"glTF" or version != 2 or total_length != len(data):
raise RuntimeError(f"{path}: invalid GLB header")
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
if chunk_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
def read_glb_binary(path: Path) -> bytes:
data = path.read_bytes()
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
binary_header = 20 + json_length
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
if binary_type != 0x004E4942:
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
binary_start = binary_header + 8
return data[binary_start:binary_start + binary_length]
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
accessor = document["accessors"][index]
if accessor["componentType"] != 5126:
raise RuntimeError(f"Accessor {index}: expected float data")
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
component_count = component_counts[accessor["type"]]
view = document["bufferViews"][accessor["bufferView"]]
element_size = component_count * 4
stride = view.get("byteStride", element_size)
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
return [
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
for item in range(accessor["count"])
]
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
first = values[0]
return any(
abs(component - first[index]) > tolerance
for value in values[1:]
for index, component in enumerate(value)
)
def validate_pumpking_tentacles(path: Path) -> int:
document = read_glb_json(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
tentacle_nodes = {
index for index, node in enumerate(nodes)
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
}
if not tentacle_nodes:
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
for animation in animations:
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
missing = tentacle_nodes - targeted
if missing:
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
return len(tentacle_nodes)
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
document = read_glb_json(path)
binary = read_glb_binary(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
head_chains = (
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
)
animated_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(1, 4)
}
static_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(4, 9)
}
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
for animation in animations:
variation = {}
rotation_values = {}
for channel in animation.get("channels", []):
if channel["target"].get("path") != "rotation":
continue
name = nodes[channel["target"]["node"]].get("name", "")
accessor_index = animation["samplers"][channel["sampler"]]["output"]
values = float_accessor(document, binary, accessor_index)
rotation_values[name] = values
variation[name] = values_vary(values)
for chain in head_chains:
static = {name for name in chain if not variation.get(name, False)}
if static:
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
static_roots = {name for name in animated_wings if not variation.get(name, False)}
moving_tips = {name for name in static_wings if variation.get(name, False)}
if static_roots or moving_tips:
raise RuntimeError(
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
f"moving wing tips {sorted(moving_tips)}"
)
if animation.get("name") in {"Idle", "Move", "Attack"}:
peak_frames = {}
for name in head_tips:
values = rotation_values[name]
start = values[0]
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
peak_frames[name] = distances.index(max(distances))
if len(set(peak_frames.values())) != len(head_tips):
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
return {
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
"wingBonesPerClip": len(animated_wings),
"staggeredHeadClips": 3,
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
for image in list(bpy.data.images):
bpy.data.images.remove(image)
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
rig.animation_data.action = bpy.data.actions["Attack"]
def sample(frame: int) -> dict[str, Vector]:
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
return {
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
"head": rig.pose.bones["bone0073"].tail.copy(),
}
windup = sample(8)
downstroke = sample(14)
impact = sample(17)
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
head_drop = windup["head"].z - impact["head"].z
head_lunge = windup["head"].y - impact["head"].y
if left_flap < 0.2 or right_flap < 0.2:
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
if head_drop < 0.01 or head_lunge < 0.05:
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
return {
"leftWingDrop": round(left_flap, 4),
"rightWingDrop": round(right_flap, 4),
"headDrop": round(head_drop, 4),
"headLunge": round(head_lunge, 4),
}
def validate(path: Path) -> dict[str, object]:
reset_scene()
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and any(
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
)
]
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
if len(armatures) != 1:
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
if not meshes:
raise RuntimeError(f"{path}: no render meshes")
if set(clips) != EXPECTED_CLIPS:
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
if any(end <= start for start, end in clips.values()):
raise RuntimeError(f"{path}: empty animation range in {clips}")
if not bpy.data.images:
raise RuntimeError(f"{path}: no packed texture images")
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
report = {
"asset": path.parent.name,
"sizeBytes": path.stat().st_size,
"bones": len(armatures[0].data.bones),
"meshes": len(meshes),
"images": len(bpy.data.images),
"dimensions": dimensions,
"clips": clips,
}
if path.parent.name == "pumpking-the-king-of-ghosts":
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
if path.parent.name == "blue-eyes-ultimate-dragon":
report.update(validate_ultimate_dragon(path))
if path.parent.name == "red-eyes-black-dragon":
report["motion"] = validate_red_eyes_motion(armatures[0])
return report
def main() -> None:
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
if len(paths) != 7:
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
report = [validate(path) for path in paths]
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()