focus-buddy / static /js /avatar.js
pocanman's picture
migrate to new repo
3006400
Raw
History Blame Contribute Delete
6 kB
// ── 3D Avatar (Three.js + GLB) ────────────────────────────────────────────────
// Loaded as an ES module. The GLB is inlined as base64 in window.__CHARACTER_GLB_B64
// (set by Python) so no static-file serving / CORS is required.
//
// Public API (attached to window for the runPyCmd eval channel + timer/bridge):
// window.avatarSetBase(name) — persistent looped state: 'idle' | 'working'
// window.avatarPlay(name) — one-shot reaction: 'celebrate' | 'questDone'
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Semantic event → GLB clip name. The GLB ships: Idle, Work, Cheer, Slash, Run, …
const CLIP_FOR = {
idle: 'Idle',
working: 'Work',
rest: 'Rest',
celebrate: 'Cheer',
questDone: 'Slash',
cheer: 'Cheer',
slash: 'Slash',
};
let _scene, _camera, _renderer, _mixer, _clock;
let _actions = {}; // clipName → THREE.AnimationAction
let _current = null; // currently fading-in action
let _baseName = 'idle'; // looped state to return to after one-shots
let _ready = false;
function _b64ToArrayBuffer(b64) {
const bin = atob(b64);
const len = bin.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function _fadeTo(action, { loop = true, fade = 0.3 } = {}) {
if (!action || action === _current) return;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.enabled = true;
action.fadeIn(fade);
action.play();
if (_current) _current.fadeOut(fade);
_current = action;
}
// Resolve a semantic/clip name to an actual action (case-insensitive fallback).
function _resolve(name) {
const clip = CLIP_FOR[name] || name;
if (_actions[clip]) return _actions[clip];
const lower = clip.toLowerCase();
for (const k in _actions) if (k.toLowerCase() === lower) return _actions[k];
return null;
}
function avatarSetBase(name) {
_baseName = name;
if (!_ready) return;
const action = _resolve(name) || _resolve('idle');
_fadeTo(action, { loop: true });
}
function avatarPlay(name) {
if (!_ready) return;
const action = _resolve(name);
if (!action) { console.warn('[avatar] no clip for', name); return; }
_fadeTo(action, { loop: false, fade: 0.15 });
const onFinished = (e) => {
if (e.action !== action) return;
_mixer.removeEventListener('finished', onFinished);
const base = _resolve(_baseName) || _resolve('idle');
_fadeTo(base, { loop: true });
};
_mixer.addEventListener('finished', onFinished);
}
window.avatarSetBase = avatarSetBase;
window.avatarPlay = avatarPlay;
function _frameModel(root) {
const box = new THREE.Box3().setFromObject(root);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// Center the model on the origin (feet a touch below middle for headroom).
root.position.x += -center.x;
root.position.z += -center.z;
root.position.y += -box.min.y - size.y * 0.5;
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.9;
_camera.position.set(0, size.y * 0.15 + 1, dist + 2);
_camera.lookAt(0, 0.5, 0);
}
function _resize(container) {
const w = container.clientWidth || 300;
const h = container.clientHeight || 240;
_camera.aspect = w / h;
_camera.updateProjectionMatrix();
_renderer.setSize(w, h, false);
}
function _init(container) {
_scene = new THREE.Scene();
_clock = new THREE.Clock();
_camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
_renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
_renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
_renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(_renderer.domElement);
// Lighting: warm key + cool fill + soft ambient for the dark RPG theme.
_scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const key = new THREE.DirectionalLight(0xfff0d0, 2.2);
key.position.set(2, 4, 3);
_scene.add(key);
const fill = new THREE.DirectionalLight(0x7c8cff, 0.8);
fill.position.set(-3, 1, -2);
_scene.add(fill);
const rim = new THREE.DirectionalLight(0xffd700, 0.6);
rim.position.set(0, 2, -4);
_scene.add(rim);
_resize(container);
new ResizeObserver(() => _resize(container)).observe(container);
const loader = new GLTFLoader();
let buffer;
try {
buffer = _b64ToArrayBuffer(window.__CHARACTER_GLB_B64 || '');
} catch (e) {
console.error('[avatar] bad GLB base64', e);
return;
}
loader.parse(buffer, '', (gltf) => {
const root = gltf.scene;
root.traverse((obj) => {
if (obj.isMesh && obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m, i) => {
if (i === 1) {
// Outline material — fully black, rendered on back faces
m.color.set(0x000000);
m.emissive?.set(0x000000);
}
m.side = THREE.FrontSide;
});
}
});
_scene.add(root);
_frameModel(root);
_mixer = new THREE.AnimationMixer(root);
gltf.animations.forEach((clip) => { _actions[clip.name] = _mixer.clipAction(clip); });
_ready = true;
avatarSetBase(_baseName); // honor any state the timer set before load finished
}, (err) => console.error('[avatar] GLTF parse failed', err));
function _animate() {
requestAnimationFrame(_animate);
if (_mixer) _mixer.update(_clock.getDelta());
_renderer.render(_scene, _camera);
}
_animate();
}
function _boot() {
const container = document.getElementById('avatar-container');
if (!container) { setTimeout(_boot, 200); return; }
if (container.dataset.avatarInit) return;
container.dataset.avatarInit = '1';
_init(container);
}
_boot();