// hands.js — first-person sci-fi hands (FAB "Sci Fi Hands" pack, merged to one GLB // with clips: Idle, Idle_Fight, Attack_01-04, Energy_Attack_01, Get, Walk_01, Run_01). // Camera-parented viewmodel: idle/walk bob from the pack's own clips, punch on smash. import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; const TUNE = { pos: new THREE.Vector3(0, -1.55, 0.25), rotY: Math.PI, // pack forward is +Z; camera looks down -Z scale: 1.0, }; const ATTACKS = ['Attack_01', 'Attack_02', 'Attack_03', 'Attack_04']; export async function createHands(camera) { const gltf = await new GLTFLoader().loadAsync('assets/hands.glb'); const rig = gltf.scene; rig.position.copy(TUNE.pos); rig.rotation.y = TUNE.rotY; rig.scale.setScalar(TUNE.scale); // render on top-ish: keep depth test but bump renderOrder so close geometry // rarely swallows the arms; frustum culling off (always in view). rig.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.renderOrder = 5; } }); 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 => Object.prototype.hasOwnProperty.call(clips, n); let current = null; let attacking = false; 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', () => { attacking = false; play(lastLocomotion, { fade: 0.15 }); }); let lastLocomotion = 'Idle'; // draw the hands on boot if the pack has a Get clip if (has('Get')) { attacking = true; play('Get', { once: true }); } else play('Idle'); return { rig, tune: TUNE, update(dt, moving = false, running = false) { const want = running ? 'Run_01' : moving ? 'Walk_01' : 'Idle'; lastLocomotion = has(want) ? want : 'Idle'; if (!attacking) play(lastLocomotion, { fade: 0.18 }); mixer.update(dt); }, attack() { attacking = true; play(ATTACKS[Math.floor(Math.random() * ATTACKS.length)], { once: true, fade: 0.05 }); }, special() { attacking = true; play('Energy_Attack_01', { once: true, fade: 0.05 }); }, }; }