commit 06a481421882f598e6973b7a225932c9fd95fdc2 Author: type-two Date: Sun Aug 30 13:30:30 2026 +1000 thesehands v0.1: shared FP hands — merged 10-clip GLB, three.js module, Godot recipe, Blender merge tooling Co-Authored-By: Claude Fable 5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c15fe93 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# THESEHANDS + +First-person hands for every GODVERSE game. One rig, many clips, vendored everywhere. + +- `assets/hands.glb` — FAB "Sci Fi Hands" merged to 10 clips (3.2MB, web-crushed + textures): Idle, Idle_Fight, Attack_01-04, Energy_Attack_01, Get, Hide*, Walk_01, + Run_01, Computer_Idle*. (*some variants — run `tools/merge_hands.py` to remix.) +- `web/hands.js` — the three.js module (source of truth; games vendor it). + Live users: destroyulator/web (smash toy, digalot.fyi/destroy), 90sDJsim (flag-gated), + thriftgod (flag-gated). +- `godot/INTEGRATION.md` — the Godot recipe; reference impl in destroyulator/game. +- `tools/merge_hands.py` + `tex_bind.py` — Blender headless: merge per-clip GLBs + (converted from the unity packs) into one multi-clip GLB with textures rebound. + Also how future clip sets (DJ MOCAP incoming) get added: convert clip FBX->GLB, + list it in the merge, re-export, bump the GLB here, re-vendor. + +Source packs live in assetzoo: `purchased/unity/fps_weapons/` (pistol, knife, +grenade, shield variants ready for the same treatment). diff --git a/assets/hands.glb b/assets/hands.glb new file mode 100644 index 0000000..243efaf Binary files /dev/null and b/assets/hands.glb differ diff --git a/godot/INTEGRATION.md b/godot/INTEGRATION.md new file mode 100644 index 0000000..29c5a9b --- /dev/null +++ b/godot/INTEGRATION.md @@ -0,0 +1,14 @@ +# Godot integration — clip-driven FP hands + +Reference implementation: `destroyulator/game/scripts/ViewModel.gd` (the +`_init_clip_arms` / `_set_clip_mode` / `_clip_locomotion` functions) with headless +probe `destroyulator/game/dev/probe_scifi.gd`. + +Recipe: +1. Copy `assets/hands.glb` into your project (e.g. `res://assets/viewmodel/scifi_hands.glb`). +2. Instantiate the GLB scene as a child of your `Camera3D`, + `position = Vector3(0, -1.55, 0.25)`, `rotate_y(PI)`. +3. Find its `AnimationPlayer`; set `loop_mode = Animation.LOOP_LINEAR` on + Idle / Idle_Fight / Walk_01 / Run_01. +4. Stance: play `Idle_Fight`. Swing: play a random `Attack_0X`; on + `animation_finished` return to stance. Locomotion: crossfade Walk/Run by speed. diff --git a/tools/merge_hands.py b/tools/merge_hands.py new file mode 100644 index 0000000..2aead93 --- /dev/null +++ b/tools/merge_hands.py @@ -0,0 +1,32 @@ +import bpy, sys, os +# args: (clips = @names) +argv = sys.argv[sys.argv.index("--")+1:] +DIR, BASE, OUT, CLIPS = argv[0], argv[1], argv[2], argv[3].split(",") +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=os.path.join(DIR, BASE)) +arm = next(o for o in bpy.data.objects if o.type == "ARMATURE") +arm.animation_data_create() +base_actions = list(bpy.data.actions) +for a in base_actions: + a.name = BASE.split("@")[1].replace(".glb", "") +def stash(action, name): + action.name = name + tr = arm.animation_data.nla_tracks.new() + tr.name = name + tr.strips.new(name, 1, action) + tr.mute = True +for a in base_actions: + stash(a, a.name) +for clip in CLIPS: + f = os.path.join(DIR, f"Sci_Fi_Hands_mesh_Empty@{clip}.glb") + pre_a = set(bpy.data.actions); pre_o = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=f) + for o in [o for o in bpy.data.objects if o not in pre_o]: + bpy.data.objects.remove(o, do_unlink=True) + for a in [a for a in bpy.data.actions if a not in pre_a]: + stash(a, clip) +arm.animation_data.action = None +exec(open("/tmp/tex_bind.py").read()); bind() +bpy.ops.export_scene.gltf(filepath=OUT, export_format="GLB", export_animations=True) +import json +print("MERGED", OUT, os.path.getsize(OUT)//1024, "KB, clips:", [t.name for t in arm.animation_data.nla_tracks]) diff --git a/tools/tex_bind.py b/tools/tex_bind.py new file mode 100644 index 0000000..8e3fd46 --- /dev/null +++ b/tools/tex_bind.py @@ -0,0 +1,38 @@ +import bpy, os +TEXROOT = os.path.expanduser("~/game_assets/unitypacks_extracted/sci_fi_hands/Assets/Sci_Fi_Hands/Textures") +_CACHE = {} +def _load(path, maxs, noncolor=False): + if path in _CACHE: return _CACHE[path] + img = bpy.data.images.load(path) + if noncolor: img.colorspace_settings.name = "Non-Color" + w, h = img.size + if max(w, h) > maxs: + f = maxs / max(w, h) + img.scale(int(w*f), int(h*f)) + _CACHE[path] = img + return img + +def bind(): + print("MATERIALS:", [m.name for m in bpy.data.materials]) + for mat in bpy.data.materials: + n = mat.name.lower() + pref = "FPS_Sci_Fi_Hands_Details" if "detail" in n else ("Sci_Fi_Shield" if "shield" in n else "FPS_Sci_Fi_Hands") + sub = os.path.join(TEXROOT, pref) + if not os.path.isdir(sub): continue + alb = os.path.join(sub, pref + "_Albedo_01.tga") + nrm = os.path.join(sub, pref + "_Normals.tga") + if not os.path.exists(alb): continue + mat.use_nodes = True + nt = mat.node_tree + bsdf = next((x for x in nt.nodes if x.type == "BSDF_PRINCIPLED"), None) + if not bsdf: continue + timg = nt.nodes.new("ShaderNodeTexImage") + timg.image = _load(alb, 1024) + nt.links.new(timg.outputs["Color"], bsdf.inputs["Base Color"]) + if os.path.exists(nrm): + nimg = nt.nodes.new("ShaderNodeTexImage") + nimg.image = _load(nrm, 512, noncolor=True) + nmap = nt.nodes.new("ShaderNodeNormalMap") + nt.links.new(nimg.outputs["Color"], nmap.inputs["Color"]) + nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) + print("BOUND", mat.name, "->", pref) diff --git a/web/hands.js b/web/hands.js new file mode 100644 index 0000000..ce7e871 --- /dev/null +++ b/web/hands.js @@ -0,0 +1,103 @@ +// thesehands/web/hands.js — reusable first-person hands viewmodel (three.js). +// Source of truth lives in the `thesehands` repo; games VENDOR this file + a GLB +// from assets/. Never hand-edit a vendored copy — edit here and re-sync. +// +// The default GLB (assets/hands.glb) is the FAB "Sci Fi Hands" pack merged to ten +// clips. Future clip sets (DJ mocap!) merge in with tools/merge_hands.py and either +// extend the default map or ship as their own GLB. +// +// Usage: +// import { createHands } from './hands.js'; +// const hands = await createHands(camera, { url: 'assets/hands.glb' }); +// hands.update(dt, moving, running); // every frame +// hands.attack(); // punch (random from clipMap.attacks) +// hands.special(); // energy attack +// hands.interact(); // hover-over-gear loop (Computer_Idle) +// hands.draw(); hands.hide(); // raise / lower the hands +// hands.play('AnyClipName', { once: true }); // escape hatch +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +const DEFAULTS = { + url: 'assets/hands.glb', + position: new THREE.Vector3(0, -1.55, 0.25), + rotationY: Math.PI, + scale: 1.0, + renderOrder: 5, + clipMap: { + idle: 'Idle', + idleCombat: 'Idle_Fight', + walk: 'Walk_01', + run: 'Run_01', + attacks: ['Attack_01', 'Attack_02', 'Attack_03', 'Attack_04'], + special: 'Energy_Attack_01', + draw: 'Get', + hide: 'Hide', + interact: 'Computer_Idle', + }, +}; + +export async function createHands(camera, opts = {}) { + const cfg = { ...DEFAULTS, ...opts, clipMap: { ...DEFAULTS.clipMap, ...(opts.clipMap || {}) } }; + const gltf = await new GLTFLoader().loadAsync(cfg.url); + const rig = gltf.scene; + rig.position.copy(cfg.position); + rig.rotation.y = cfg.rotationY; + rig.scale.setScalar(cfg.scale); + rig.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.renderOrder = cfg.renderOrder; } }); + camera.add(rig); + + const mixer = new THREE.AnimationMixer(rig); + const clips = {}; + for (const c of gltf.animations) clips[c.name] = mixer.clipAction(c); + const has = n => !!n && Object.prototype.hasOwnProperty.call(clips, n); + + const M = cfg.clipMap; + let current = null; + let busy = false; // a one-shot (attack/draw/interact-exit) owns the rig + let interacting = false; + let lastLocomotion = M.idle; + + function play(name, { once = false, fade = 0.12 } = {}) { + if (!has(name)) return null; + const a = clips[name]; + if (current === a && !once) return a; + if (current) current.fadeOut(fade); + a.reset().fadeIn(fade); + if (once) { a.setLoop(THREE.LoopOnce); a.clampWhenFinished = true; } + a.play(); + current = a; + return a; + } + + mixer.addEventListener('finished', () => { + busy = false; + play(interacting && has(M.interact) ? M.interact : lastLocomotion, { fade: 0.15 }); + }); + + if (has(M.draw)) { busy = true; play(M.draw, { once: true }); } + else play(M.idle); + + return { + rig, + cfg, + update(dt, moving = false, running = false) { + const want = running ? M.run : moving ? M.walk : (interacting ? M.interact : M.idle); + lastLocomotion = has(want) ? want : M.idle; + if (!busy) play(lastLocomotion, { fade: 0.18 }); + mixer.update(dt); + }, + attack() { + const pool = (M.attacks || []).filter(has); + if (!pool.length) return; + busy = true; + play(pool[Math.floor(Math.random() * pool.length)], { once: true, fade: 0.05 }); + }, + special() { if (has(M.special)) { busy = true; play(M.special, { once: true, fade: 0.05 }); } }, + draw() { if (has(M.draw)) { busy = true; play(M.draw, { once: true }); } }, + hide() { if (has(M.hide)) { busy = true; play(M.hide, { once: true }); } }, + interact(on = true) { interacting = on; }, + play, + setVisible(v) { rig.visible = v; }, + }; +}