destroyulator/tools/gen_fps_arms.py
Monster Robot Party 61757d24bc LANE6: rigged FPS viewmodel, weapon loadout + material matrix, 4 HUD styles
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>
2026-07-31 16:34:00 +10:00

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()