Files
chatroom/resources/js/avatar-widget.js
2026-04-11 22:23:37 +08:00

470 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { VRMLoaderPlugin } from '@pixiv/three-vrm';
function initChatAvatarWidget() {
const widget = document.getElementById('chat-avatar-widget');
const stage = document.getElementById('chat-avatar-stage');
const canvas = document.getElementById('chat-avatar-canvas');
const speech = document.getElementById('chat-avatar-speech');
const status = document.getElementById('chat-avatar-status');
if (!widget || !stage || !canvas || !speech || !status || window.chatAvatarWidgetInitialized) {
return;
}
const avatarPresets = [
{
label: '暗夜系',
speech: '这套暗系角色会更接近你刚才给我的参考方向。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Darkness_Shibu.vrm',
twist: -0.2,
},
{
label: '冷灰系',
speech: '这一套更偏高冷一些,但还是偏可爱,不如暗夜系成熟。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Sendagaya_Shino.vrm',
twist: -0.08,
},
{
label: '甜酷系',
speech: '这套会更明艳一些,适合后面继续往甜辣路线改。',
url: 'https://cdn.jsdelivr.net/gh/madjin/vrm-samples@master/vroid/beta/Victoria_Rubin.vrm',
twist: -0.12,
},
];
const expressions = [
{ label: '冷淡', preset: 'neutral', speech: '现在先用冷感表情看整体质感。' },
{ label: '浅笑', preset: 'happy', speech: '带一点笑意以后,气质会柔一点。' },
{ label: '慵懒', preset: 'relaxed', speech: '这种神态更适合成熟一点的虚拟人物。' },
{ label: '惊讶', preset: 'surprised', speech: '表情系统后面还能接聊天互动和动作触发。' },
];
const state = {
collapsed: false,
dragging: false,
dragStartX: 0,
manualRotateY: -0.18,
hoverYaw: 0,
hoverPitch: 0,
waveUntil: 0,
modelIndex: 0,
expressionIndex: 0,
};
let scene = null;
let camera = null;
let renderer = null;
let clock = null;
let mixerRoot = null;
let avatarSlot = null;
let loader = null;
let currentVrm = null;
let currentArmBones = null;
let resizeObserver = null;
let speechTimer = null;
const sparkles = [];
function setSpeech(text) {
speech.textContent = text;
}
function setStatus(text, isError = false) {
status.textContent = text;
status.style.display = text ? 'block' : 'none';
status.style.background = isError ? 'rgba(153, 27, 27, 0.84)' : 'rgba(15, 23, 42, 0.55)';
}
function getHumanoidBone(humanoid, name) {
return humanoid.getRawBoneNode?.(name) ?? humanoid.getNormalizedBoneNode?.(name) ?? null;
}
function fitAvatarToStage(vrmScene) {
const box = new THREE.Box3().setFromObject(vrmScene);
const size = box.getSize(new THREE.Vector3());
const targetHeight = 1.96;
const targetWidth = 1.22;
const scale = Math.min(
targetHeight / Math.max(size.y, 0.001),
targetWidth / Math.max(size.x, 0.001)
);
vrmScene.scale.setScalar(scale);
vrmScene.updateMatrixWorld(true);
const scaledBox = new THREE.Box3().setFromObject(vrmScene);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const floorY = -1.02;
vrmScene.position.set(
-scaledCenter.x,
floorY - scaledBox.min.y - 0.03,
-scaledCenter.z
);
}
function collectArmBones(vrm) {
const humanoid = vrm.humanoid;
if (!humanoid) {
return null;
}
return {
leftUpperArm: getHumanoidBone(humanoid, 'leftUpperArm'),
leftLowerArm: getHumanoidBone(humanoid, 'leftLowerArm'),
rightUpperArm: getHumanoidBone(humanoid, 'rightUpperArm'),
rightLowerArm: getHumanoidBone(humanoid, 'rightLowerArm'),
neck: getHumanoidBone(humanoid, 'neck'),
spine: getHumanoidBone(humanoid, 'spine'),
};
}
function resetExpressions() {
if (!currentVrm?.expressionManager) {
return;
}
['neutral', 'happy', 'relaxed', 'surprised', 'angry', 'sad'].forEach((name) => {
currentVrm.expressionManager.setValue(name, 0);
});
}
function applyExpression() {
const expression = expressions[state.expressionIndex];
if (!expression) {
return;
}
resetExpressions();
if (currentVrm?.expressionManager && expression.preset !== 'neutral') {
currentVrm.expressionManager.setValue(expression.preset, 1);
}
setSpeech(`${avatarPresets[state.modelIndex].label} ${expression.label}${expression.speech}`);
}
function loadAvatar(index) {
const preset = avatarPresets[index];
if (!preset || !loader || !avatarSlot) {
return;
}
setStatus(`正在加载 ${preset.label}...`);
setSpeech(preset.speech);
if (currentVrm?.scene) {
avatarSlot.remove(currentVrm.scene);
}
currentVrm = null;
currentArmBones = null;
loader.load(
preset.url,
(gltf) => {
const vrm = gltf.userData.vrm;
if (!vrm) {
setStatus('模型加载成功,但未识别到 VRM 数据。', true);
return;
}
currentVrm = vrm;
currentVrm.scene.rotation.y = Math.PI + preset.twist;
fitAvatarToStage(currentVrm.scene);
avatarSlot.add(currentVrm.scene);
currentArmBones = collectArmBones(currentVrm);
state.expressionIndex = 0;
applyExpression();
setStatus('');
},
undefined,
(error) => {
console.error('VRM 角色加载失败', error);
setStatus('二次元角色加载失败,可能是外部模型资源被拦截了。', true);
}
);
}
function buildScene() {
scene = new THREE.Scene();
clock = new THREE.Clock();
camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);
camera.position.set(0, 0.4, 4.35);
camera.lookAt(0, -0.02, 0);
renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.88;
scene.add(new THREE.HemisphereLight('#f5ecf7', '#8098cb', 1.15));
const keyLight = new THREE.DirectionalLight('#fff6fb', 1.45);
keyLight.position.set(2.4, 4.2, 4.2);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight('#f0d6ff', 0.72);
fillLight.position.set(-2.8, 1.6, 3.1);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight('#c4cfff', 0.56);
rimLight.position.set(-2.4, 2.2, -2.4);
scene.add(rimLight);
mixerRoot = new THREE.Group();
scene.add(mixerRoot);
const pedestal = new THREE.Mesh(
new THREE.CylinderGeometry(0.94, 1.1, 0.22, 36),
new THREE.MeshToonMaterial({ color: '#eadce8' })
);
pedestal.position.set(0, -1.12, 0);
mixerRoot.add(pedestal);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.78, 0.04, 12, 38),
new THREE.MeshToonMaterial({ color: '#d7b2d3' })
);
ring.position.set(0, -0.98, 0);
ring.rotation.x = Math.PI / 2;
mixerRoot.add(ring);
avatarSlot = new THREE.Group();
mixerRoot.add(avatarSlot);
const sparkleMaterialA = new THREE.MeshToonMaterial({ color: '#fff4b0' });
const sparkleMaterialB = new THREE.MeshToonMaterial({ color: '#ffffff' });
for (let i = 0; i < 18; i++) {
const sparkle = new THREE.Mesh(
new THREE.OctahedronGeometry(0.045, 0),
i % 2 === 0 ? sparkleMaterialA : sparkleMaterialB
);
const angle = (Math.PI * 2 * i) / 18;
const radius = 1.08 + (i % 3) * 0.16;
sparkle.position.set(Math.cos(angle) * radius, -0.12 + (i % 5) * 0.32, Math.sin(angle) * radius * 0.5);
sparkle.userData.baseY = sparkle.position.y;
sparkle.userData.speed = 0.9 + (i % 4) * 0.18;
mixerRoot.add(sparkle);
sparkles.push(sparkle);
}
}
function resizeStage() {
if (!renderer || !camera) {
return;
}
const width = Math.max(stage.clientWidth, 1);
const height = Math.max(stage.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function updatePose(elapsed) {
if (!currentArmBones) {
return;
}
const spineLean = Math.sin(elapsed * 1.1) * 0.012;
const neckTurn = state.hoverYaw * 0.18 + Math.sin(elapsed * 0.7) * 0.05;
const neckNod = state.hoverPitch * 0.08;
if (currentArmBones.spine) {
currentArmBones.spine.rotation.z = spineLean;
currentArmBones.spine.rotation.x = 0.04;
}
if (currentArmBones.neck) {
currentArmBones.neck.rotation.y = neckTurn;
currentArmBones.neck.rotation.x = neckNod;
}
const baseLeftArmZ = 1.22;
const baseRightArmZ = -1.22;
const baseUpperArmX = 0.12;
const baseLowerArmZ = 0.08;
if (currentArmBones.leftUpperArm) {
currentArmBones.leftUpperArm.rotation.z = baseLeftArmZ + Math.sin(elapsed * 1.2 + 0.3) * 0.02;
currentArmBones.leftUpperArm.rotation.x = baseUpperArmX;
currentArmBones.leftUpperArm.rotation.y = -0.1;
}
if (currentArmBones.leftLowerArm) {
currentArmBones.leftLowerArm.rotation.z = baseLowerArmZ;
currentArmBones.leftLowerArm.rotation.x = -0.06;
}
const isWaving = elapsed < state.waveUntil;
if (currentArmBones.rightUpperArm) {
currentArmBones.rightUpperArm.rotation.z = isWaving
? -0.54 - Math.sin(elapsed * 10.5) * 0.22
: baseRightArmZ - Math.sin(elapsed * 1.15) * 0.02;
currentArmBones.rightUpperArm.rotation.x = isWaving ? 0.32 : baseUpperArmX;
currentArmBones.rightUpperArm.rotation.y = isWaving ? 0.22 : 0.1;
}
if (currentArmBones.rightLowerArm) {
currentArmBones.rightLowerArm.rotation.z = isWaving
? -0.58 - Math.cos(elapsed * 10.5) * 0.18
: -baseLowerArmZ;
currentArmBones.rightLowerArm.rotation.x = isWaving ? -0.18 : -0.06;
}
}
function animate() {
const elapsed = clock.getElapsedTime();
clock.getDelta();
mixerRoot.rotation.y = state.manualRotateY + Math.sin(elapsed * 0.42) * 0.08;
mixerRoot.position.y = Math.sin(elapsed * 1.3) * 0.03;
sparkles.forEach((sparkle, index) => {
sparkle.rotation.x += 0.016 + index * 0.0005;
sparkle.rotation.y += 0.024 + index * 0.0007;
sparkle.position.y = sparkle.userData.baseY + Math.sin(elapsed * sparkle.userData.speed + index) * 0.05;
});
if (currentVrm) {
updatePose(elapsed);
}
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
function bindPointer() {
const updateHover = (event) => {
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
state.hoverYaw = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
state.hoverPitch = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
};
stage.addEventListener('pointerdown', (event) => {
state.dragging = true;
state.dragStartX = event.clientX;
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener('pointermove', (event) => {
updateHover(event);
if (!state.dragging) {
return;
}
const deltaX = event.clientX - state.dragStartX;
state.dragStartX = event.clientX;
state.manualRotateY += deltaX * 0.011;
});
const releasePointer = () => {
state.dragging = false;
};
stage.addEventListener('pointerup', releasePointer);
stage.addEventListener('pointercancel', releasePointer);
stage.addEventListener('pointerleave', () => {
state.hoverYaw *= 0.7;
state.hoverPitch *= 0.7;
});
}
function scheduleSpeech() {
const idleLines = [
'现在这版已经是实打实的二次元角色资源,不再是代码拼图。',
'如果这条路对味,下一步就能做成“每个用户自己的形象”。',
'后面还可以接换装、亲密度、称号和专属动作。',
];
let idleIndex = 0;
clearInterval(speechTimer);
speechTimer = window.setInterval(() => {
if (state.dragging || state.collapsed) {
return;
}
idleIndex = (idleIndex + 1) % idleLines.length;
setSpeech(idleLines[idleIndex]);
}, 6400);
}
function init() {
buildScene();
resizeStage();
bindPointer();
scheduleSpeech();
loader = new GLTFLoader();
loader.crossOrigin = 'anonymous';
loader.register((parser) => new VRMLoaderPlugin(parser));
loadAvatar(state.modelIndex);
animate();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => resizeStage());
resizeObserver.observe(stage);
} else {
window.addEventListener('resize', resizeStage);
}
window.chatAvatarWidgetInitialized = true;
}
window.chatAvatarWidget = {
cycleModel: () => {
state.modelIndex = (state.modelIndex + 1) % avatarPresets.length;
loadAvatar(state.modelIndex);
},
cycleExpression: () => {
state.expressionIndex = (state.expressionIndex + 1) % expressions.length;
applyExpression();
},
triggerWave: () => {
if (!clock) {
return;
}
state.waveUntil = clock.getElapsedTime() + 2.2;
setSpeech('现在这个动作只是演示,后面可以接聊天触发和专属姿态。');
},
toggleCollapsed: () => {
state.collapsed = !state.collapsed;
widget.classList.toggle('is-collapsed', state.collapsed);
},
};
window.toggleChatAvatarWidgetPanel = () => {
window.chatAvatarWidget.toggleCollapsed();
};
try {
init();
} catch (error) {
console.error('聊天虚拟形象初始化失败', error);
setStatus('二次元角色初始化失败,可能是本地依赖或外部模型资源加载失败。', true);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatAvatarWidget, { once: true });
} else {
initChatAvatarWidget();
}