Add avatar prototype preview

This commit is contained in:
2026-04-11 22:23:37 +08:00
parent 0a764a3a86
commit 2a4d2c5e1b
8 changed files with 866 additions and 1 deletions
+146
View File
@@ -4,6 +4,10 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"@pixiv/three-vrm": "^3.4.2",
"three": "^0.180.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
@@ -507,6 +511,142 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@pixiv/three-vrm": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.4.2.tgz",
"integrity": "sha512-Q3WLZ2Keh3y1GOhxWFieQCXsi+O9cGoPJD7GxQI6VJIEGuWXF0ghPND7RNUd1/AM2/Al7ChuWyaqyGq7j5/0/w==",
"license": "MIT",
"dependencies": {
"@pixiv/three-vrm-core": "3.4.2",
"@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.4.2",
"@pixiv/three-vrm-materials-mtoon": "3.4.2",
"@pixiv/three-vrm-materials-v0compat": "3.4.2",
"@pixiv/three-vrm-node-constraint": "3.4.2",
"@pixiv/three-vrm-springbone": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-core": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.4.2.tgz",
"integrity": "sha512-ucdf5hzCcgc421UVjhBQK3VpnodDdOJFf/3RY9glD4RLj0iURZ4hTd4zKXhSITKSS/IisRgU4J2lDhtU0u7e3g==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.4.2",
"@pixiv/types-vrmc-vrm-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.4.2.tgz",
"integrity": "sha512-OuDBao5GRfAefPXK4w4su2AzM/yGvqYYfZQaQryyhSzQbA1UV8EpW4xfrWjdGJ4HiD98L9hVeObZrjZyq2ZVkA==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-mtoon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.4.2.tgz",
"integrity": "sha512-UH0ZwF3oWyxr+FwIc4CBdBKsGKfPsaDPt8/lplX+D8oExNZ4eKBSKUaNytV6XhxgWR5a0ZPoZJUzvsCzjv0BPQ==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.4.2",
"@pixiv/types-vrmc-materials-mtoon-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-materials-v0compat": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.4.2.tgz",
"integrity": "sha512-h1E/CB3h3f3iK0QnzzfeBy4C2GiiSkSf6q4OSYg26Qnyvl8iCPduqukm85yD5xuyDU3i1KzimuWj0n1Ah3s6kw==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.4.2",
"@pixiv/types-vrmc-materials-mtoon-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-node-constraint": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.4.2.tgz",
"integrity": "sha512-KjkvsZv91naCkblOttM46JdJP2UdKCd+fAxewnVAc+FWkS5qUA1fiKZhWz06JuO1yMVMY3/OohnIlDfRGtdq6A==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrmc-node-constraint-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/three-vrm-springbone": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.4.2.tgz",
"integrity": "sha512-4IUOHehnWkg2ZhipCGpSppB7lfR+/GuLVLp42N5jQi6Tf3keAm/tXwDeGgJO2o9AgcDW2Gq3/MFxkcSfvojKCw==",
"license": "MIT",
"dependencies": {
"@pixiv/types-vrm-0.0": "3.4.2",
"@pixiv/types-vrmc-springbone-1.0": "3.4.2",
"@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.4.2"
},
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/@pixiv/types-vrm-0.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.4.2.tgz",
"integrity": "sha512-Y8jELwRGYW5GkO0Q9SQ2XOwGHgbKiLB3bRedpRELCEJVki2vWWkNoK8MqyuXmBEk2pQdaU5YDJ1mDpdCNVaIDA==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.4.2.tgz",
"integrity": "sha512-kqo08aM9mPq9c9sfq8x+TxhrhuF41aaCeOoDAcSfwcOQgXrZt6ZFbyiiI9ViMpeuCZ8bSjjN6f5Opf3BwbYMLQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.4.2.tgz",
"integrity": "sha512-VVcY7SniFh+wj0fNA/MDWr7ST6AWoaKtlxzC/ZyQd28zxjRgGbe40OGdref64rBxGwIV8JfBXgtYUxrJ2BN5DQ==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.4.2.tgz",
"integrity": "sha512-DIkmUxeyaAl6iMiH20ug+ns2trnQPvi5lU0Hf3r/g4Ikq+8WRDcX3pggG1Uihr6xAy4S4lc7u5dS5lLDrAEsbw==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-springbone-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.4.2.tgz",
"integrity": "sha512-xbwsSPl31vltmYW9ADX6DxgnGZcXhhsqkcY4Jr6N0xCxH+H1V0qv5vNhN/+ntBMccQul35CtzNpXZohJ5zB1wA==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.4.2.tgz",
"integrity": "sha512-4leeV4KmG8cw98L0vAVrv5XsyGmIEr4iTr5rZ2dTOW2KJjoA458mBWnlKImj2n6bVCoqgExvt6IIi2AFnJgWjA==",
"license": "MIT"
},
"node_modules/@pixiv/types-vrmc-vrm-1.0": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.4.2.tgz",
"integrity": "sha512-AcbcDDtPevbmds2N7HUfPb/eKbMlbDaS4VADUNC2uO4pQDHa4vi1gDDxneYNFq4htQ2VUco9Yhqr5idp2MofOg==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -2346,6 +2486,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+4
View File
@@ -15,5 +15,9 @@
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"
},
"dependencies": {
"@pixiv/three-vrm": "^3.4.2",
"three": "^0.180.0"
}
}
+212 -1
View File
@@ -181,6 +181,7 @@ a:hover {
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.message-pane {
@@ -248,6 +249,216 @@ a:hover {
font-size: 9pt;
}
/* ── 聊天窗口虚拟形象体验卡 ───────────────────────── */
.chat-avatar-widget {
position: absolute;
right: 12px;
top: 12px;
width: min(292px, calc(100% - 24px));
z-index: 15;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(81, 124, 178, 0.5);
background: linear-gradient(180deg, rgba(252, 254, 255, 0.95), rgba(221, 236, 255, 0.93));
box-shadow: 0 18px 48px rgba(37, 66, 110, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.82);
backdrop-filter: blur(8px);
}
.chat-avatar-widget__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 8px;
background: linear-gradient(135deg, rgba(56, 108, 169, 0.95), rgba(117, 154, 214, 0.9));
color: #fff;
}
.chat-avatar-widget__title {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-avatar-widget__title strong {
font-size: 13px;
letter-spacing: 0.5px;
}
.chat-avatar-widget__title span {
font-size: 10px;
color: rgba(237, 245, 255, 0.92);
}
.chat-avatar-widget__minimize,
.chat-avatar-widget__controls button,
.chat-avatar-widget__dock {
border: none;
cursor: pointer;
font-family: inherit;
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.chat-avatar-widget__minimize {
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
color: #fff;
padding: 4px 10px;
font-size: 11px;
white-space: nowrap;
}
.chat-avatar-widget__minimize:hover,
.chat-avatar-widget__controls button:hover,
.chat-avatar-widget__dock:hover {
transform: translateY(-1px);
}
.chat-avatar-widget__body {
display: flex;
flex-direction: column;
gap: 9px;
padding: 10px;
}
.chat-avatar-widget__speech {
position: relative;
border-radius: 14px;
padding: 9px 11px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(241, 247, 255, 0.94));
color: #28456c;
font-size: 11px;
line-height: 1.55;
border: 1px solid rgba(150, 182, 221, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.chat-avatar-widget__speech::after {
content: "";
position: absolute;
right: 24px;
bottom: -7px;
width: 14px;
height: 14px;
background: #f8fbff;
border-right: 1px solid rgba(150, 182, 221, 0.55);
border-bottom: 1px solid rgba(150, 182, 221, 0.55);
transform: rotate(45deg);
}
.chat-avatar-widget__stage {
position: relative;
height: 286px;
overflow: hidden;
border-radius: 15px;
border: 1px solid rgba(123, 163, 215, 0.58);
background:
radial-gradient(circle at 50% 16%, rgba(249, 241, 248, 0.94), rgba(230, 220, 247, 0.92) 28%, rgba(194, 202, 231, 0.92) 62%, rgba(140, 157, 204, 0.96) 100%);
}
.chat-avatar-widget__stage::before {
content: "";
position: absolute;
inset: auto -22% -36% -22%;
height: 70%;
background: radial-gradient(circle, rgba(255, 205, 224, 0.5), rgba(255, 205, 224, 0) 70%);
pointer-events: none;
}
.chat-avatar-widget__stage canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.chat-avatar-widget__stage canvas:active {
cursor: grabbing;
}
.chat-avatar-widget__status {
position: absolute;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
width: calc(100% - 20px);
border-radius: 999px;
padding: 5px 10px;
text-align: center;
color: #fff;
font-size: 10px;
letter-spacing: 0.3px;
}
.chat-avatar-widget__controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.chat-avatar-widget__controls button {
border-radius: 11px;
padding: 8px 0;
font-size: 11px;
color: #184166;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(224, 237, 255, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 4px 10px rgba(96, 133, 183, 0.16);
}
.chat-avatar-widget__dock {
display: none;
align-items: center;
justify-content: center;
min-width: 88px;
margin: 0 0 0 auto;
border-radius: 999px;
padding: 8px 14px;
background: linear-gradient(135deg, #2b5d93, #4f89c2);
color: #fff;
font-size: 11px;
letter-spacing: 1px;
box-shadow: 0 10px 24px rgba(31, 64, 105, 0.28);
}
.chat-avatar-widget.is-collapsed {
width: auto;
background: transparent;
border: none;
box-shadow: none;
backdrop-filter: none;
}
.chat-avatar-widget.is-collapsed .chat-avatar-widget__header,
.chat-avatar-widget.is-collapsed .chat-avatar-widget__body {
display: none;
}
.chat-avatar-widget.is-collapsed .chat-avatar-widget__dock {
display: inline-flex;
}
@media (max-width: 960px) {
.chat-avatar-widget {
right: 10px;
top: 10px;
width: min(252px, calc(100% - 20px));
}
.chat-avatar-widget__stage {
height: 236px;
}
.chat-avatar-widget__speech {
font-size: 10px;
padding: 8px 10px;
}
.chat-avatar-widget__controls button {
padding: 7px 0;
font-size: 10px;
}
}
/* ── 底部输入工具栏 ─────────────────────────────── */
.input-bar {
height: auto;
@@ -901,4 +1112,4 @@ a:hover {
#mobile-drawer-users {
display: none !important;
}
}
}
+1
View File
@@ -1 +1,2 @@
import './bootstrap';
import './avatar-widget';
+469
View File
@@ -0,0 +1,469 @@
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();
}
+3
View File
@@ -149,6 +149,9 @@
<span class="msg-time">({{ now()->format('H:i:s') }})</span>
</div>
</div>
{{-- 聊天窗口内嵌虚拟形象体验卡 --}}
@include('chat.partials.avatar-widget')
</div>
{{-- 底部输入工具栏(layout/ 子目录维护) --}}
@@ -0,0 +1,30 @@
<div class="chat-avatar-widget" id="chat-avatar-widget">
<div class="chat-avatar-widget__header">
<div class="chat-avatar-widget__title">
<strong>虚拟形象</strong>
<span>聊天窗口演示版</span>
</div>
<button type="button" class="chat-avatar-widget__minimize" onclick="window.toggleChatAvatarWidgetPanel && window.toggleChatAvatarWidgetPanel()">
收起
</button>
</div>
<div class="chat-avatar-widget__body">
<div class="chat-avatar-widget__speech" id="chat-avatar-speech">这次我改成真实二次元模型预览,不再用手搓的小人占位。</div>
<div class="chat-avatar-widget__stage" id="chat-avatar-stage">
<canvas id="chat-avatar-canvas" aria-label="聊天虚拟形象预览"></canvas>
<div class="chat-avatar-widget__status" id="chat-avatar-status">二次元角色加载中...</div>
</div>
<div class="chat-avatar-widget__controls">
<button type="button" onclick="window.chatAvatarWidget && window.chatAvatarWidget.cycleModel()">换角</button>
<button type="button" onclick="window.chatAvatarWidget && window.chatAvatarWidget.cycleExpression()">神态</button>
<button type="button" onclick="window.chatAvatarWidget && window.chatAvatarWidget.triggerWave()">招手</button>
</div>
</div>
<button type="button" class="chat-avatar-widget__dock" onclick="window.toggleChatAvatarWidgetPanel && window.toggleChatAvatarWidgetPanel()">
展开形象
</button>
</div>
@@ -24,6 +24,7 @@
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像</div>
<div class="tool-btn" onclick="window.toggleChatAvatarWidgetPanel && window.toggleChatAvatarWidgetPanel()" title="虚拟形象体验">形象</div>
<div class="tool-btn" onclick="document.getElementById('settings-modal').style.display='flex'" title="个人设置">设置
</div>
<div class="tool-btn" onclick="window.open('{{ route('feedback.index') }}', '_blank')" title="反馈">反馈</div>