From 2a4d2c5e1b0fff1e2bee1299a7f3bd74ea269e80 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 11 Apr 2026 22:23:37 +0800 Subject: [PATCH] Add avatar prototype preview --- package-lock.json | 146 ++++++ package.json | 4 + public/css/chat.css | 213 +++++++- resources/js/app.js | 1 + resources/js/avatar-widget.js | 469 ++++++++++++++++++ resources/views/chat/frame.blade.php | 3 + .../chat/partials/avatar-widget.blade.php | 30 ++ .../chat/partials/layout/toolbar.blade.php | 1 + 8 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 resources/js/avatar-widget.js create mode 100644 resources/views/chat/partials/avatar-widget.blade.php diff --git a/package-lock.json b/package-lock.json index 7ba3b33..16585a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 27271e3..21e97a0 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/css/chat.css b/public/css/chat.css index c88a6ee..87c1a37 100644 --- a/public/css/chat.css +++ b/public/css/chat.css @@ -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; } -} \ No newline at end of file +} diff --git a/resources/js/app.js b/resources/js/app.js index e59d6a0..a80ccbe 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,2 @@ import './bootstrap'; +import './avatar-widget'; diff --git a/resources/js/avatar-widget.js b/resources/js/avatar-widget.js new file mode 100644 index 0000000..ecee12c --- /dev/null +++ b/resources/js/avatar-widget.js @@ -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(); +} diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index acca144..bd7d915 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -149,6 +149,9 @@ ({{ now()->format('H:i:s') }}) + + {{-- 聊天窗口内嵌虚拟形象体验卡 --}} + @include('chat.partials.avatar-widget') {{-- 底部输入工具栏(layout/ 子目录维护) --}} diff --git a/resources/views/chat/partials/avatar-widget.blade.php b/resources/views/chat/partials/avatar-widget.blade.php new file mode 100644 index 0000000..ec4d9be --- /dev/null +++ b/resources/views/chat/partials/avatar-widget.blade.php @@ -0,0 +1,30 @@ +
+
+
+ 虚拟形象 + 聊天窗口演示版 +
+ +
+ +
+
这次我改成真实二次元模型预览,不再用手搓的小人占位。
+ +
+ +
二次元角色加载中...
+
+ +
+ + + +
+
+ + +
diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index c7f508c..1825763 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -24,6 +24,7 @@
婚姻
好友
头像
+
形象
设置
反馈