// 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; }, }; }