104 lines
3.8 KiB
JavaScript
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; },
|
|
};
|
|
}
|