| |
| |
| |
| |
| |
| |
| |
| import * as THREE from 'three'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
|
|
| |
| 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 = {}; |
| let _current = null; |
| let _baseName = 'idle'; |
| 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; |
| } |
|
|
| |
| 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()); |
|
|
| |
| 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); |
|
|
| |
| _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) { |
| |
| 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); |
| }, (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(); |
|
|