// ── 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();