web: first-person sci-fi hands viewmodel (FAB Sci Fi Hands pack) — camera-parented rig with Idle/Walk/Run locomotion, 4 random punch clips on smash, Energy_Attack on record throw, Get draw-in on boot; merged 10-clip GLB with rebound textures (3.2MB)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-30 12:03:35 +10:00
parent e588508436
commit c2fad7eb53
3 changed files with 83 additions and 2 deletions

BIN
web/assets/hands.glb Normal file

Binary file not shown.

73
web/hands.js Normal file
View File

@ -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 });
},
};
}

View File

@ -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); },