thesehands/web/hands.js
2026-08-30 13:30:30 +10:00

104 lines
3.8 KiB
JavaScript

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