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:
parent
e588508436
commit
c2fad7eb53
BIN
web/assets/hands.glb
Normal file
BIN
web/assets/hands.glb
Normal file
Binary file not shown.
73
web/hands.js
Normal file
73
web/hands.js
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
12
web/main.js
12
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); },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user