thesehands v0.1: shared FP hands — merged 10-clip GLB, three.js module, Godot recipe, Blender merge tooling
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
06a4814218
18
README.md
Normal file
18
README.md
Normal file
@ -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).
|
||||||
BIN
assets/hands.glb
Normal file
BIN
assets/hands.glb
Normal file
Binary file not shown.
14
godot/INTEGRATION.md
Normal file
14
godot/INTEGRATION.md
Normal file
@ -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.
|
||||||
32
tools/merge_hands.py
Normal file
32
tools/merge_hands.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import bpy, sys, os
|
||||||
|
# args: <dir> <base_clip_file> <out.glb> <clip1,clip2,...> (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])
|
||||||
38
tools/tex_bind.py
Normal file
38
tools/tex_bind.py
Normal file
@ -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)
|
||||||
103
web/hands.js
Normal file
103
web/hands.js
Normal file
@ -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; },
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user