Add avatar prototype preview
This commit is contained in:
Generated
+146
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 +1,2 @@
|
||||
import './bootstrap';
|
||||
import './avatar-widget';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user