Hands/POV - Cut a real first-person arms rig out of the GODVERSE modular character kit (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime. Textures shrunk to 1k; 4.2 MB. - ViewModel.gd instances that rig ONCE PER HAND and places each instance so its own hand bone lands on the grip — no IK. Grip orientation is measured off the rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so it survives re-tuning grip_rest_rot instead of needing new euler angles. - Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon heft, idle breathe, landing dip, weapon lower/raise on swap. - Sleeve material overridden (donor asset is a fantasy leather bracer); hand material forced non-metallic (its spec/gloss maps rendered skin as bronze). Weapons - Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the weapon-vs-material matrix from the founding chat. - Smashable moves from binary hits_to_break to hp/toughness, giving three outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet. - The hit lands at Weapon.contact THROUGH the swing, not on the click — that delay is most of why the sledge feels different from the cutter. - Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H. HUD - Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills in line items) and Dev, over one shared data feed. Scoring + combo multipliers. Dev harness (not shipped) - macOS screen-recording perms aren't available to the CLI, so the game records itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie, and dev/probe_*.gd print rig/scale/placement numbers. Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's already-unit side length, halving every box — which detached the bat's blade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
8.3 KiB
Python
226 lines
8.3 KiB
Python
"""Cut a first-person ARMS rig out of the GODVERSE modular character kit.
|
|
|
|
Source: `character_kit_modular/exports/franken_kachujin_ch01hands.glb` — the ch01 hands
|
|
grafted onto a Kachujin body, all deforming through the standard 65-bone `mixamorig:`
|
|
skeleton (see character_kit_modular/README.md, "GODVERSE socket standard v1").
|
|
|
|
We want the viewmodel, so we keep the two hand meshes plus the arm/sleeve region of the
|
|
body, drop the rest of the character, and shrink the textures. The ARMATURE IS KEPT
|
|
WHOLE AND UNRENAMED — that is the kit's contract, and it is also what lets ViewModel.gd
|
|
pose individual finger bones (`mixamorig:RightHandIndex1` …) per weapon grip at runtime.
|
|
|
|
/Applications/Blender.app/Contents/MacOS/Blender --background \
|
|
--python tools/gen_fps_arms.py -- [--render]
|
|
|
|
Output: game/assets/viewmodel/fps_arms.glb
|
|
"""
|
|
|
|
import bpy
|
|
import bmesh
|
|
import math
|
|
import os
|
|
import sys
|
|
from mathutils import Vector
|
|
|
|
SRC = os.path.expanduser(
|
|
"~/Documents/character_kit_modular/exports/franken_kachujin_ch01hands.glb")
|
|
OUT_DIR = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"game", "assets", "viewmodel")
|
|
OUT = os.path.join(OUT_DIR, "fps_arms.glb")
|
|
|
|
RENDER = "--render" in sys.argv
|
|
|
|
# Previews are build artefacts, not game assets — keep them OUT of the Godot project
|
|
# or the importer picks them up as textures.
|
|
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
|
|
|
|
KEEP_MESHES = {"ch01_hand_L", "ch01_hand_R"}
|
|
# Body verts to keep: everything the arm bones drive (this is the shirt sleeve).
|
|
# Split per side, because the viewmodel instances this rig once PER HAND and hides the
|
|
# other side — which is impossible if both sleeves share one mesh.
|
|
SLEEVE_SIDES = {
|
|
"arms_sleeve_L": ["mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand"],
|
|
"arms_sleeve_R": ["mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand"],
|
|
}
|
|
WEIGHT_MIN = 0.35
|
|
TEX_MAX = 1024
|
|
|
|
|
|
def log(*a):
|
|
print("[arms]", *a)
|
|
|
|
|
|
def find_armature():
|
|
for o in bpy.data.objects:
|
|
if o.type == 'ARMATURE':
|
|
return o
|
|
return None
|
|
|
|
|
|
def _cut_to_groups(obj, group_names):
|
|
"""Delete every vertex not driven by `group_names`. Returns the surviving count."""
|
|
gidx = {g.name: g.index for g in obj.vertex_groups}
|
|
want = set(gidx[n] for n in group_names if n in gidx)
|
|
if not want:
|
|
return 0
|
|
bm = bmesh.new()
|
|
bm.from_mesh(obj.data)
|
|
bm.verts.ensure_lookup_table()
|
|
deform = bm.verts.layers.deform.active
|
|
if deform is None:
|
|
bm.free()
|
|
raise SystemExit("mesh has no deform weights")
|
|
doomed = [v for v in bm.verts
|
|
if sum(wt for gi, wt in v[deform].items() if gi in want) < WEIGHT_MIN]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(obj.data)
|
|
n = len(bm.verts)
|
|
bm.free()
|
|
obj.data.update()
|
|
return n
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
log("importing", os.path.basename(SRC))
|
|
bpy.ops.import_scene.gltf(filepath=SRC)
|
|
|
|
arm = find_armature()
|
|
if arm is None:
|
|
raise SystemExit("no armature in source")
|
|
log("armature:", arm.name, "bones:", len(arm.data.bones))
|
|
|
|
# The kit README is explicit: imported GLBs arrive posed with an action assigned.
|
|
# Measuring or cutting before resetting to rest gives garbage.
|
|
arm.data.pose_position = 'REST'
|
|
if arm.animation_data:
|
|
arm.animation_data_clear()
|
|
for ob in bpy.data.objects:
|
|
if ob.animation_data:
|
|
ob.animation_data_clear()
|
|
|
|
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
|
log("meshes:", [(o.name, len(o.data.vertices)) for o in meshes])
|
|
|
|
body = None
|
|
for o in meshes:
|
|
if o.name in KEEP_MESHES:
|
|
continue
|
|
if o.name.lower().startswith("kachujin") and len(o.data.vertices) > 2000:
|
|
body = o # the torso/limbs mesh (the shirt)
|
|
else:
|
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
|
|
kept = [o for o in bpy.data.objects if o.type == 'MESH']
|
|
log("kept meshes:", [o.name for o in kept])
|
|
|
|
# ---- trim the body down to a left and a right sleeve ------------------------
|
|
# bmesh rather than edit-mode operators: toggling modes to push a per-vertex
|
|
# selection through bpy.ops is fragile (it silently deleted the whole mesh), and
|
|
# the deform layer gives the weights directly.
|
|
if body is not None:
|
|
for name, groups in SLEEVE_SIDES.items():
|
|
side = body.copy()
|
|
side.data = body.data.copy()
|
|
side.name = name
|
|
bpy.context.collection.objects.link(side)
|
|
if _cut_to_groups(side, groups) == 0:
|
|
bpy.data.objects.remove(side, do_unlink=True)
|
|
log("sleeve", name, "empty — dropped")
|
|
else:
|
|
log("sleeve", name, "verts:", len(side.data.vertices))
|
|
bpy.data.objects.remove(body, do_unlink=True)
|
|
body = None
|
|
|
|
# ---- shrink textures -------------------------------------------------------
|
|
for img in list(bpy.data.images):
|
|
if img.users == 0:
|
|
bpy.data.images.remove(img)
|
|
continue
|
|
if max(img.size) > TEX_MAX:
|
|
w, h = img.size
|
|
s = TEX_MAX / float(max(w, h))
|
|
img.scale(max(int(w * s), 1), max(int(h * s), 1))
|
|
log("resized", img.name, "->", tuple(img.size))
|
|
|
|
# ---- report the arm rest pose so ViewModel.gd can be authored against it ----
|
|
ebones = arm.data.bones
|
|
for n in ("mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
|
|
"mixamorig:RightHandIndex1", "mixamorig:RightHandThumb1"):
|
|
b = ebones.get(n)
|
|
if b:
|
|
log("rest %-28s head=%s len=%.4f" % (
|
|
n, tuple(round(x, 4) for x in b.head_local), b.length))
|
|
|
|
total = sum(len(o.data.vertices) for o in bpy.data.objects if o.type == 'MESH')
|
|
log("total verts:", total)
|
|
|
|
# ---- export ----------------------------------------------------------------
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=OUT,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False, # keep the armature modifier live (skinned export)
|
|
export_skins=True,
|
|
export_animations=False,
|
|
export_yup=True,
|
|
export_image_format='JPEG',
|
|
)
|
|
log("wrote", OUT, "%.1f MB" % (os.path.getsize(OUT) / 1e6))
|
|
|
|
if RENDER:
|
|
render_preview()
|
|
|
|
|
|
def render_preview():
|
|
scene = bpy.context.scene
|
|
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
|
|
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
|
|
if want in engines:
|
|
scene.render.engine = want
|
|
break
|
|
scene.render.resolution_x = 800
|
|
scene.render.resolution_y = 800
|
|
if scene.world is None:
|
|
scene.world = bpy.data.worlds.new("preview")
|
|
scene.world.use_nodes = True
|
|
bg = scene.world.node_tree.nodes.get("Background")
|
|
if bg:
|
|
bg.inputs[0].default_value = (0.06, 0.06, 0.07, 1.0)
|
|
|
|
lo = Vector((1e9,) * 3)
|
|
hi = Vector((-1e9,) * 3)
|
|
for o in scene.objects:
|
|
if o.type != 'MESH':
|
|
continue
|
|
for c in o.bound_box:
|
|
w = o.matrix_world @ Vector(c)
|
|
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
|
|
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
|
|
centre = (lo + hi) * 0.5
|
|
radius = max((hi - lo).length * 0.5, 0.01)
|
|
dist = radius * 2.6
|
|
d = Vector((0.55, -0.78, 0.30)).normalized()
|
|
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
|
|
cam = bpy.context.active_object
|
|
cam.data.lens = 60
|
|
cam.rotation_mode = 'QUATERNION'
|
|
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
|
|
scene.camera = cam
|
|
for off, e, s in ((Vector((1.0, -1.0, 1.0)), 60.0, 1.2),
|
|
(Vector((-1.1, -0.4, 0.2)), 18.0, 1.8)):
|
|
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
|
|
L = bpy.context.active_object
|
|
L.data.energy = e * (dist ** 2)
|
|
L.data.size = s * radius
|
|
scene.render.filepath = os.path.join(PREVIEW_DIR, "fps_arms.png")
|
|
bpy.ops.render.render(write_still=True)
|
|
|
|
|
|
main()
|