470 lines
16 KiB
JavaScript
470 lines
16 KiB
JavaScript
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();
|
||
}
|