diff --git a/web/assets/hands.glb b/web/assets/hands.glb new file mode 100644 index 0000000..243efaf Binary files /dev/null and b/web/assets/hands.glb differ diff --git a/web/hands.js b/web/hands.js new file mode 100644 index 0000000..34fd3e2 --- /dev/null +++ b/web/hands.js @@ -0,0 +1,73 @@ +// 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 }); + }, + }; +} diff --git a/web/main.js b/web/main.js index 78f3ecc..4ed5204 100644 --- a/web/main.js +++ b/web/main.js @@ -10,6 +10,7 @@ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.j import { initPhysics, createPhysics } from './physics.js'; import { loadProp, loadFractured, fetchShelf, CATALOG } from './props.js'; import { createJuice, MATERIALS } from './juice.js'; +import { createHands } from './hands.js'; const $ = id => document.getElementById(id); const boot = $('boot'); @@ -323,8 +324,9 @@ function smashProp(prop, power = 1.1) { function onClick() { if (paused || !booted) return; - if (held) { throwHeld(); return; } + if (held) { hands?.special(); throwHeld(); return; } if (lookRecord) { pullRecord(nearestRecord()); return; } + hands?.attack(); if (lookProp) { smashProp(lookProp, 1.15); return; } } // pointer-lock mode: a click is a smash. In drag-look mode the drag handlers own the @@ -536,6 +538,8 @@ function w2() { return ROOM.w / 2; } function d2() { return ROOM.d / 2; } // ---------------- main loop ---------------- +let hands = null; +createHands(camera).then(h => { hands = h; }).catch(e => console.warn('hands failed', e)); const clock = new THREE.Clock(); let frame = 0; @@ -546,6 +550,10 @@ function stepFrame(dt) { camera.position.sub(lastJitter); if (!paused && !shareOpen) move(dt); + if (hands) { + const moving = !paused && vel.lengthSq() > 0; + hands.update(dt, moving, moving && (keys.ShiftLeft || keys.ShiftRight)); + } updatePull(dt); if (++frame % 4 === 0) updateLook(); @@ -569,7 +577,7 @@ function tick() { // Pointer Lock (e.g. embedded frames / headless smoke tests) and step it by hand. if (location.search.includes('debug')) { window.DESTROY = { - get scene() { return scene; }, get camera() { return camera; }, get phys() { return phys; }, juice, + get scene() { return scene; }, get camera() { return camera; }, get phys() { return phys; }, get hands() { return hands; }, juice, unpause() { paused = false; startEl.classList.add('hidden'); }, look(yaw, pitch = 0) { camera.rotation.set(pitch, yaw, 0, 'YXZ'); }, teleport(x, y, z) { camera.position.set(x, y, z); lastJitter.set(0, 0, 0); },