- 调整移动端手势识别阈值,提升识别准确率 - 增加移动端专属手势指令:左右滑动控制前后移动 - 优化手势影响范围计算逻辑,增强触摸屏设备体验 - 完善摄像头权限检查机制,改善用户授权流程 - 支持通过点击提示文字唤起控制台界面 - 统一控制台显示/隐藏逻辑,提高代码可维护性 - 调整响应式布局参数,适配更多设备屏幕尺寸
1357 lines
53 KiB
HTML
1357 lines
53 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>🎄 圣诞快乐 🎉 - Honesty 的 3D 魔法回忆</title>
|
||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||
<link rel="icon" href="favicon.ico">
|
||
<link rel="apple-touch-icon" href="./images/logo.png">
|
||
|
||
<meta name="description"
|
||
content="在这个充满节日气氛的 3D 圣诞树世界里,Honesty 邀请您上传照片,共同点亮美好的回忆。——来自Honesty的节日祝福。">
|
||
<meta name="keywords" content="Honesty,HeHouHui,HeHui,圣诞,Christmas,3D,Threejs,WebXR">
|
||
<link rel="canonical" href="https://www.hehouhui.cn/christmas.html">
|
||
<meta name="author" content="Honesty">
|
||
|
||
<!-- Open Graph / Facebook -->
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:url" content="https://www.hehouhui.cn/christmas.html">
|
||
<meta property="og:title" content="🎄 圣诞快乐 🎉 - Honesty 的 3D 魔法回忆">
|
||
<meta property="og:description"
|
||
content="Honesty 邀请你上传照片,共同点亮美好的节日回忆。点击进入 3D 互动体验!">
|
||
<meta property="og:image" content="https://www.hehouhui.cn/images/avatar.jpeg">
|
||
<meta property="og:site_name" content="Honesty的个人主页">
|
||
|
||
<!-- Twitter -->
|
||
<meta property="twitter:card" content="summary_large_image">
|
||
<meta property="twitter:url" content="https://www.hehouhui.cn/christmas.html">
|
||
<meta property="twitter:title" content="🎄 圣诞快乐 🎉 - Honesty 的 3D 魔法回忆">
|
||
<meta property="twitter:description"
|
||
content="Honesty 邀请你上传照片,共同点亮美好的节日回忆。点击进入 3D 互动体验!">
|
||
<meta property="twitter:image" content="https://www.hehouhui.cn/images/avatar.jpeg">
|
||
<meta property="twitter:site" content="@Honesty861024">
|
||
|
||
<!-- 微信小程序/朋友圈分享 -->
|
||
<meta property="wechat:image" content="https://www.hehouhui.cn/images/avatar.jpeg">
|
||
<meta property="wechat:title" content="🎄 圣诞快乐 🎉 - Honesty 的 3D 魔法回忆">
|
||
<meta property="wechat:description"
|
||
content="在这个充满节日气氛的 3D 圣诞树世界里,Honesty 邀请您上传照片,共同点亮美好的回忆。">
|
||
|
||
<link rel="alternate" hreflang="zh-cn" href="https://www.hehouhui.cn/christmas.html">
|
||
<link rel="alternate" hreflang="x-default" href="https://www.hehouhui.cn/christmas.html">
|
||
|
||
<style>
|
||
/* 引入优雅字体 */
|
||
/* 使用国内可快速访问的字体资源替换 Google Fonts */
|
||
@import url('https://fonts.loli.net/css2?family=Cinzel:wght@400;700&family=Noto+Sans+SC:wght@300;500&display=swap');
|
||
|
||
/* 背景颜色深度优化: 从纯黑 (#000000) 调整为极深午夜蓝 (#03050B) */
|
||
body {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
background-color: #03050B; /* 调整为极深午夜蓝,增强冷暖对比 */
|
||
font-family: 'Noto Sans SC', 'Cinzel', 'Times New Roman', serif;
|
||
}
|
||
|
||
#canvas-container {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* --- UI Layer and Permanent Title --- */
|
||
#ui-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding-top: 5vh; /* 标题固定在顶部 */
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 标题样式:永久显示,使用金色渐变 */
|
||
h1 {
|
||
pointer-events: auto; /* 确保不影响其他交互,但本身不响应点击 */
|
||
color: #fceea7;
|
||
font-size: 56px;
|
||
margin: 0;
|
||
font-weight: 400;
|
||
letter-spacing: 6px;
|
||
text-shadow: 0 0 50px rgba(252, 238, 167, 0.6);
|
||
/* 奢华金色渐变 */
|
||
background: linear-gradient(to bottom, #fff8e8, #fceea7);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
font-family: 'Cinzel', 'Noto Sans SC', serif;
|
||
opacity: 1;
|
||
transition: opacity 0.5s ease;
|
||
margin-bottom: 20px; /* 标题与控制区间隔 */
|
||
}
|
||
|
||
/* 控制面板容器:居中偏上,可隐藏 */
|
||
#controls-container {
|
||
margin-top: 10px; /* 确保在标题下方 */
|
||
background: rgba(10, 10, 10, 0.2); /* 更轻微的背景,增加透明度 */
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid rgba(252, 238, 167, 0.1); /* 更细的边框 */
|
||
padding: 20px 30px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 0 30px rgba(252, 238, 167, 0.05);
|
||
pointer-events: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
transition: opacity 0.5s ease;
|
||
}
|
||
|
||
.ui-hidden {
|
||
opacity: 0;
|
||
pointer-events: none !important;
|
||
}
|
||
|
||
/* --- Loading --- */
|
||
#loader {
|
||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: #03050B; /* 使用新的背景色 */
|
||
z-index: 100;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
transition: opacity 0.8s ease-out;
|
||
}
|
||
.loader-text {
|
||
color: #fceea7; /* 调整为更亮的金色 */
|
||
font-size: 14px; letter-spacing: 4px; margin-top: 20px;
|
||
text-transform: uppercase; font-weight: 100;
|
||
}
|
||
.spinner {
|
||
width: 40px; height: 40px; border: 1px solid rgba(252, 238, 167, 0.2);
|
||
border-top: 1px solid #fceea7; border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||
|
||
/* --- Upload Button --- */
|
||
.upload-wrapper {
|
||
margin-top: 15px;
|
||
pointer-events: auto;
|
||
text-align: center;
|
||
}
|
||
.upload-btn {
|
||
background: rgba(252, 238, 167, 0.1); /* 轻微金色透明背景 */
|
||
border: 1px solid rgba(252, 238, 167, 0.5);
|
||
color: #fceea7; /* 调整为更亮的金色文字 */
|
||
padding: 12px 30px; /* 增大点击区域 */
|
||
cursor: pointer;
|
||
text-transform: none;
|
||
letter-spacing: 2px;
|
||
font-size: 14px;
|
||
transition: all 0.4s;
|
||
display: inline-block;
|
||
backdrop-filter: blur(3px);
|
||
border-radius: 6px;
|
||
}
|
||
/* 修复上传按钮背景色为白色的问题 */
|
||
.upload-btn input[type="file"] {
|
||
background: transparent;
|
||
}
|
||
.upload-btn:hover {
|
||
background: #fceea7; /* 鼠标悬停时使用更亮的金色 */
|
||
color: #03050B;
|
||
box-shadow: 0 0 25px rgba(252, 238, 167, 0.8);
|
||
}
|
||
.hint-text {
|
||
color: rgba(252, 238, 167, 0.8);
|
||
font-size: 11px;
|
||
margin-top: 10px;
|
||
letter-spacing: 1px;
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
}
|
||
|
||
/* --- Audio Control (保持不变) --- */
|
||
#audio-control {
|
||
position: absolute; bottom: 30px; left: 30px; width: 40px; height: 40px;
|
||
background: rgba(20, 20, 20, 0.6); border: 1px solid rgba(252, 238, 167, 0.4);
|
||
border-radius: 50%; color: #fceea7;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 16px; cursor: pointer; z-index: 15; transition: all 0.3s; pointer-events: auto;
|
||
}
|
||
#audio-control:hover { background: #fceea7; color: #03050B; }
|
||
|
||
/* --- 响应式设计优化 (针对不同设备尺寸) --- */
|
||
|
||
/* 超小设备 (小于 480px) */
|
||
@media (max-width: 479px) {
|
||
h1 {
|
||
font-size: 28px;
|
||
letter-spacing: 2px;
|
||
padding: 0 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
#controls-container {
|
||
width: 95%;
|
||
padding: 12px 15px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.upload-btn {
|
||
font-size: 11px;
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
#audio-control {
|
||
width: 36px;
|
||
height: 36px;
|
||
bottom: 15px;
|
||
left: 15px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.hint-text {
|
||
font-size: 10px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.gesture-guide h3 {
|
||
font-size: 12px;
|
||
margin: 10px 0 5px;
|
||
}
|
||
|
||
.gesture-guide ul {
|
||
font-size: 10px;
|
||
padding-left: 12px;
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.controls-hint {
|
||
font-size: 10px;
|
||
left: 10%;
|
||
bottom: 3%;
|
||
}
|
||
}
|
||
|
||
/* 小设备 (480px 到 768px) */
|
||
@media (min-width: 480px) and (max-width: 768px) {
|
||
h1 {
|
||
font-size: 34px;
|
||
letter-spacing: 3px;
|
||
text-align: center;
|
||
}
|
||
|
||
#controls-container {
|
||
width: 90%;
|
||
padding: 15px 20px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.upload-btn {
|
||
font-size: 12px;
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
#audio-control {
|
||
bottom: 20px;
|
||
left: 20px;
|
||
}
|
||
|
||
.controls-hint {
|
||
font-size: 11px;
|
||
left: 12%;
|
||
}
|
||
}
|
||
|
||
/* 平板设备 (769px 到 1024px) */
|
||
@media (min-width: 769px) and (max-width: 1024px) {
|
||
h1 {
|
||
font-size: 46px;
|
||
letter-spacing: 5px;
|
||
}
|
||
|
||
#controls-container {
|
||
width: 80%;
|
||
padding: 20px 25px;
|
||
}
|
||
|
||
.upload-btn {
|
||
font-size: 13px;
|
||
padding: 12px 25px;
|
||
}
|
||
|
||
.hint-text {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.gesture-guide h3 {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.gesture-guide ul {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
/* 大屏幕设备 (大于 1024px) */
|
||
@media (min-width: 1025px) {
|
||
h1 {
|
||
font-size: 56px;
|
||
letter-spacing: 6px;
|
||
}
|
||
|
||
#controls-container {
|
||
width: auto;
|
||
max-width: 500px;
|
||
padding: 20px 30px;
|
||
}
|
||
|
||
.upload-btn {
|
||
font-size: 14px;
|
||
padding: 12px 30px;
|
||
}
|
||
}
|
||
|
||
/* 添加手势操作说明样式 */
|
||
.gesture-guide {
|
||
margin-top: 15px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid rgba(252, 238, 167, 0.2);
|
||
width: 100%;
|
||
}
|
||
|
||
/* 添加左侧提示文字样式 */
|
||
.controls-hint {
|
||
position: fixed;
|
||
left: 15%;
|
||
bottom: 3%;
|
||
color: rgba(252, 238, 167, 0.7);
|
||
font-size: 12px;
|
||
z-index: 11;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|
||
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
|
||
"@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/+esm"
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loader">
|
||
<div class="spinner"></div>
|
||
<div class="loader-text">加载节日魔法中...</div>
|
||
</div>
|
||
|
||
<div id="canvas-container"></div>
|
||
|
||
<div id="ui-layer">
|
||
<h1>🎄 圣诞快乐 🎁 - Honesty</h1>
|
||
|
||
<div id="controls-container" class="ui-hidden">
|
||
<div id="mode-status" class="hint-text" style="font-size: 12px; color: #fff8e8; opacity: 0.9; letter-spacing: 1px;">
|
||
当前模式:随机漂浮 (SCATTER)
|
||
</div>
|
||
|
||
<div class="upload-wrapper">
|
||
<label class="upload-btn">
|
||
添加你的回忆 (Add Memories)
|
||
<input type="file" id="file-input" hidden="hidden" multiple accept="image/*">
|
||
</label>
|
||
<div class="hint-text">
|
||
提示: 按 'H' 键隐藏控制台 | 尝试手势交互 (MediaPipe)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加手势操作说明 -->
|
||
<div class="gesture-guide">
|
||
<h3 style="color: #fceea7; margin: 15px 0 8px; font-size: 14px; font-weight: 500;">手势操作指南</h3>
|
||
<ul style="text-align: left; color: rgba(252, 238, 167, 0.85); font-size: 11px; padding-left: 15px; margin: 0 0 10px; line-height: 1.5;">
|
||
<li>握拳 👊 - 粒子聚合成圣诞树</li>
|
||
<li>全部手指捏合 ✋ - 抓取最近照片并放大</li>
|
||
<li>拇指+食指捏合 ☝️ - 粒子向前移动</li>
|
||
<li>拇指+无名指+小指捏合 🤏 - 粒子向后移动</li>
|
||
<li>手掌向上 🙌 - 粒子右旋转</li>
|
||
<li>手掌向下 👇 - 粒子左旋转</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div id="audio-control" style="opacity: 0;">
|
||
<span id="audio-icon">🔇</span>
|
||
</div>
|
||
<!-- 添加左侧提示文字 -->
|
||
<div class="controls-hint" id="controls-hint">按 H 键/点击唤起操作台</div>
|
||
|
||
<div id="webcam-wrapper">
|
||
<video id="webcam" autoplay playsinline style="display:none;"></video>
|
||
<canvas id="webcam-preview"></canvas>
|
||
</div>
|
||
|
||
<audio id="bg-music" loop preload="auto">
|
||
<source src="data/christmas.mp3" type="audio/mpeg">
|
||
您的浏览器不支持音频元素。
|
||
</audio>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
|
||
import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
|
||
import {UnrealBloomPass} from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||
import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
|
||
import {FilesetResolver, HandLandmarker} from '@mediapipe/tasks-vision';
|
||
|
||
// --- CONFIGURATION ---
|
||
const CONFIG = {
|
||
colors: {
|
||
bg: 0x03050B, // 极深午夜蓝
|
||
champagneGold: 0xffeebb, // 更亮的金色
|
||
deepGreen: 0x03180a,
|
||
accentRed: 0xcc0000, // 略微提亮红色
|
||
},
|
||
particles: {
|
||
count: 1000,
|
||
dustCount: 2000,
|
||
treeHeight: 24,
|
||
treeRadius: 8
|
||
},
|
||
camera: {
|
||
z: 50
|
||
},
|
||
// 添加动画优化配置
|
||
animation: {
|
||
responseSpeed: 8.0, // 进一步提高手势响应速度
|
||
scatterRotation: 0.5, // 散射模式下的旋转速度
|
||
treeRotation: 0.8, // 树模式下的旋转速度
|
||
positionLerp: 5.0, // 位置插值速度
|
||
scaleLerp: 8.0, // 缩放插值速度
|
||
noiseStrength: 0.2, // 噪声扰动强度
|
||
gestureInfluence: 0.7 // 手势对粒子的影响强度
|
||
}
|
||
};
|
||
|
||
const STATE = {
|
||
mode: 'SCATTER', // SCATTER, TREE, FOCUS
|
||
focusTarget: null,
|
||
hand: {detected: false, x: 0, y: 0},
|
||
rotation: {x: 0, y: 0},
|
||
uiVisible: true,
|
||
gestureCommand: null // 存储当前手势指令
|
||
};
|
||
|
||
let scene, camera, renderer, composer;
|
||
let mainGroup;
|
||
let clock = new THREE.Clock();
|
||
let particleSystem = [];
|
||
let photoMeshGroup = new THREE.Group();
|
||
let handLandmarker, video;
|
||
let caneTexture;
|
||
let lastHandGesture = null;
|
||
const modeStatusElement = document.getElementById('mode-status');
|
||
|
||
// --- Particle Class ---
|
||
class Particle {
|
||
constructor(mesh, type, isDust = false) {
|
||
this.mesh = mesh;
|
||
this.type = type;
|
||
this.isDust = isDust;
|
||
this.posTree = new THREE.Vector3();
|
||
this.posScatter = new THREE.Vector3();
|
||
this.baseScale = mesh.scale.x;
|
||
|
||
// 为每个粒子添加噪声偏移,使运动更加自然
|
||
this.noiseOffset = new THREE.Vector3(
|
||
Math.random() * 100,
|
||
Math.random() * 100,
|
||
Math.random() * 100
|
||
);
|
||
|
||
const speedMult = (type === 'PHOTO') ? 0.3 : 2.0;
|
||
this.spinSpeed = new THREE.Vector3(
|
||
(Math.random() - 0.5) * speedMult,
|
||
(Math.random() - 0.5) * speedMult,
|
||
(Math.random() - 0.5) * speedMult
|
||
);
|
||
this.calculatePositions();
|
||
this.mesh.userData.particleType = type;
|
||
|
||
// 添加粒子对手势指令的响应属性
|
||
this.gestureResponsiveness = 0.5 + Math.random() * 0.5; // 粒子对手势的敏感度
|
||
}
|
||
|
||
calculatePositions() {
|
||
const h = CONFIG.particles.treeHeight;
|
||
const halfH = h / 2;
|
||
let t = Math.random();
|
||
t = Math.pow(t, 0.8);
|
||
const y = (t * h) - halfH;
|
||
let rMax = CONFIG.particles.treeRadius * (1.0 - t);
|
||
if (rMax < 0.5) rMax = 0.5;
|
||
const angle = t * 50 * Math.PI + Math.random() * Math.PI;
|
||
const r = rMax * (0.8 + Math.random() * 0.4);
|
||
this.posTree.set(Math.cos(angle) * r, y, Math.sin(angle) * r);
|
||
|
||
// 限制散射模式下粒子的分布范围,防止超出屏幕
|
||
let rScatter = this.isDust ? (12 + Math.random() * 15) : (8 + Math.random() * 10);
|
||
// 根据屏幕尺寸调整最大范围
|
||
let maxRScatter = 25;
|
||
if (window.innerWidth <= 480) {
|
||
maxRScatter = 15; // 小屏幕设备上减小范围
|
||
} else if (window.innerWidth <= 768) {
|
||
maxRScatter = 20; // 中等屏幕设备
|
||
}
|
||
// 限制最大范围,防止照片飞出屏幕
|
||
rScatter = Math.min(rScatter, maxRScatter);
|
||
const theta = Math.random() * Math.PI * 2;
|
||
const phi = Math.acos(2 * Math.random() - 1);
|
||
this.posScatter.set(
|
||
rScatter * Math.sin(phi) * Math.cos(theta),
|
||
rScatter * Math.sin(phi) * Math.sin(theta),
|
||
rScatter * Math.cos(phi)
|
||
);
|
||
}
|
||
|
||
// 添加噪声函数,用于创建更自然的运动
|
||
noise(t) {
|
||
return Math.sin(t) * 0.5 + Math.sin(t * 0.37) * 0.25 + Math.sin(t * 0.19) * 0.125;
|
||
}
|
||
|
||
update(dt, mode, focusTargetMesh, elapsedTime, handState, gestureCommand) {
|
||
let target = this.posScatter;
|
||
if (mode === 'TREE') target = this.posTree;
|
||
else if (mode === 'FOCUS') {
|
||
if (this.mesh === focusTargetMesh) {
|
||
// 根据屏幕尺寸调整焦点照片的位置和距离
|
||
let focusZ = 35;
|
||
if (window.innerWidth <= 480) {
|
||
focusZ = 25; // 小屏幕设备上更靠近摄像机
|
||
} else if (window.innerWidth <= 768) {
|
||
focusZ = 30; // 中等屏幕设备
|
||
}
|
||
|
||
const desiredWorldPos = new THREE.Vector3(0, 2, focusZ);
|
||
const invMatrix = new THREE.Matrix4().copy(mainGroup.matrixWorld).invert();
|
||
target = desiredWorldPos.applyMatrix4(invMatrix);
|
||
} else {
|
||
target = this.posScatter;
|
||
}
|
||
}
|
||
|
||
// 在散射模式下添加噪声扰动,让粒子运动更生动
|
||
if (mode === 'SCATTER') {
|
||
// 根据屏幕尺寸调整噪声强度
|
||
let noiseStrength = CONFIG.animation.noiseStrength;
|
||
if (window.innerWidth <= 480) {
|
||
noiseStrength *= 0.7; // 小屏幕设备上减小噪声
|
||
} else if (window.innerWidth <= 768) {
|
||
noiseStrength *= 0.8; // 中等屏幕设备
|
||
}
|
||
|
||
const noiseX = this.noise(elapsedTime * 0.5 + this.noiseOffset.x) * noiseStrength;
|
||
const noiseY = this.noise(elapsedTime * 0.5 + this.noiseOffset.y) * noiseStrength;
|
||
const noiseZ = this.noise(elapsedTime * 0.5 + this.noiseOffset.z) * noiseStrength;
|
||
|
||
target.x += noiseX;
|
||
target.y += noiseY;
|
||
target.z += noiseZ;
|
||
|
||
// 限制粒子位置,防止飞出屏幕
|
||
let maxDistance = 30;
|
||
// 根据屏幕尺寸调整粒子分布范围
|
||
if (window.innerWidth <= 480) {
|
||
maxDistance = 20; // 小屏幕设备上减小范围
|
||
} else if (window.innerWidth <= 768) {
|
||
maxDistance = 25; // 中等屏幕设备
|
||
}
|
||
|
||
if (target.length() > maxDistance) {
|
||
target.normalize().multiplyScalar(maxDistance);
|
||
}
|
||
|
||
// 实时手势影响 - 只关注当前手势位置,不使用历史轨迹
|
||
// 在触摸屏设备上增加手势灵敏度
|
||
if (handState.detected) {
|
||
// 将2D手势坐标转换为3D空间中的影响点
|
||
// 根据屏幕尺寸调整手势影响范围
|
||
let handInfluenceRange = 15;
|
||
if (window.innerWidth <= 480) {
|
||
handInfluenceRange = 10; // 小屏幕设备上减小影响范围
|
||
} else if (window.innerWidth <= 768) {
|
||
handInfluenceRange = 12; // 中等屏幕设备
|
||
}
|
||
|
||
const handInfluencePoint = new THREE.Vector3(
|
||
handState.x * handInfluenceRange, // 调整影响范围
|
||
handState.y * handInfluenceRange,
|
||
0
|
||
);
|
||
|
||
// 在触摸屏设备上增强手势影响
|
||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||
handInfluenceRange *= 1.5; // 触摸屏设备增强影响范围
|
||
}
|
||
|
||
// 计算粒子与手势影响点之间的距离
|
||
const distance = this.mesh.position.distanceTo(handInfluencePoint);
|
||
// 根据屏幕尺寸调整最大影响距离
|
||
let maxInfluenceDistance = 8.0;
|
||
if (window.innerWidth <= 480) {
|
||
maxInfluenceDistance = 6.0; // 小屏幕设备上减小影响范围
|
||
} else if (window.innerWidth <= 768) {
|
||
maxInfluenceDistance = 7.0; // 中等屏幕设备
|
||
}
|
||
|
||
// 如果粒子在影响范围内,则受到手势牵引
|
||
if (distance < maxInfluenceDistance) {
|
||
// 计算影响强度(距离越近影响越强)
|
||
const influenceStrength = (1.0 - distance / maxInfluenceDistance) *
|
||
CONFIG.animation.gestureInfluence *
|
||
this.gestureResponsiveness;
|
||
|
||
// 计算从粒子指向手势点的向量
|
||
const direction = new THREE.Vector3()
|
||
.subVectors(handInfluencePoint, this.mesh.position)
|
||
.normalize();
|
||
|
||
// 根据手势指令调整方向
|
||
switch (gestureCommand) {
|
||
case 'LEFT_ROTATE':
|
||
// 左旋转时,粒子围绕手势点逆时针移动
|
||
direction.applyAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI/2);
|
||
break;
|
||
case 'RIGHT_ROTATE':
|
||
// 右旋转时,粒子围绕手势点顺时针移动
|
||
direction.applyAxisAngle(new THREE.Vector3(0, 0, 1), -Math.PI/2);
|
||
break;
|
||
case 'MOVE_FORWARD':
|
||
// 往前时,粒子朝向手势点移动
|
||
// 方向已经是朝向手势点了,无需额外处理
|
||
break;
|
||
case 'MOVE_BACKWARD':
|
||
// 往后时,粒子远离手势点移动
|
||
direction.multiplyScalar(-1);
|
||
break;
|
||
default:
|
||
// 默认情况下,粒子轻微朝向手势点移动
|
||
direction.multiplyScalar(0.5);
|
||
}
|
||
|
||
// 应用影响到目标位置
|
||
// 在触摸屏设备上增强手势影响
|
||
let influenceMultiplier = 2.0;
|
||
if (('ontouchstart' in window || navigator.maxTouchPoints > 0) && window.innerWidth <= 1024) {
|
||
influenceMultiplier = 3.0; // 触摸屏设备增强影响
|
||
}
|
||
|
||
target.add(
|
||
direction.multiplyScalar(influenceStrength * influenceMultiplier)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据屏幕尺寸调整插值速度
|
||
let lerpSpeed = (mode === 'FOCUS' && this.mesh === focusTargetMesh) ? 7.0 : CONFIG.animation.positionLerp;
|
||
if (window.innerWidth <= 480) {
|
||
lerpSpeed *= 1.2; // 小屏幕设备上加快动画速度
|
||
} else if (window.innerWidth <= 768) {
|
||
lerpSpeed *= 1.1; // 中等屏幕设备
|
||
}
|
||
|
||
this.mesh.position.lerp(target, Math.min(lerpSpeed * dt, 1.0));
|
||
|
||
if (mode === 'SCATTER') {
|
||
this.mesh.rotation.x += this.spinSpeed.x * dt;
|
||
this.mesh.rotation.y += this.spinSpeed.y * dt;
|
||
this.mesh.rotation.z += this.spinSpeed.z * dt;
|
||
} else if (mode === 'TREE') {
|
||
this.mesh.rotation.x = THREE.MathUtils.lerp(this.mesh.rotation.x, 0, Math.min(dt * 5, 1.0));
|
||
this.mesh.rotation.z = THREE.MathUtils.lerp(this.mesh.rotation.z, 0, Math.min(dt * 5, 1.0));
|
||
this.mesh.rotation.y += 1.2 * dt;
|
||
}
|
||
|
||
if (mode === 'FOCUS' && this.mesh === focusTargetMesh) {
|
||
this.mesh.lookAt(camera.position);
|
||
}
|
||
|
||
let s = this.baseScale;
|
||
if (this.isDust) {
|
||
s = this.baseScale * (0.8 + 0.4 * Math.sin(elapsedTime * 4 + this.mesh.id));
|
||
if (mode === 'TREE') s = 0;
|
||
} else if (mode === 'SCATTER' && this.type === 'PHOTO') {
|
||
s = this.baseScale * 2.5;
|
||
} else if (mode === 'FOCUS') {
|
||
// 根据屏幕尺寸调整焦点照片的缩放比例
|
||
let focusScale = 4.5;
|
||
if (window.innerWidth <= 480) {
|
||
focusScale = 3.0; // 小屏幕设备上较小的缩放比例
|
||
} else if (window.innerWidth <= 768) {
|
||
focusScale = 3.5; // 中等屏幕设备
|
||
}
|
||
|
||
if (this.mesh === focusTargetMesh) s = focusScale;
|
||
else s = this.baseScale * 0.8;
|
||
}
|
||
|
||
// 根据屏幕尺寸调整缩放插值速度
|
||
let scaleLerpSpeed = CONFIG.animation.scaleLerp;
|
||
if (window.innerWidth <= 480) {
|
||
scaleLerpSpeed *= 1.2; // 小屏幕设备上加快缩放动画
|
||
} else if (window.innerWidth <= 768) {
|
||
scaleLerpSpeed *= 1.1; // 中等屏幕设备
|
||
}
|
||
|
||
this.mesh.scale.lerp(new THREE.Vector3(s, s, s), Math.min(scaleLerpSpeed * dt, 1.0));
|
||
}
|
||
}
|
||
// --- End of Particle Class ---
|
||
|
||
function initThree() {
|
||
const container = document.getElementById('canvas-container');
|
||
scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(CONFIG.colors.bg);
|
||
// 背景沉浸式优化: 强化雾效,使其颜色与背景色匹配,增加景深
|
||
scene.fog = new THREE.FogExp2(CONFIG.colors.bg, 0.015);
|
||
|
||
camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||
camera.position.set(0, 2, CONFIG.camera.z);
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" });
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||
renderer.toneMapping = THREE.ReinhardToneMapping;
|
||
renderer.toneMappingExposure = 2.5; // 略微增加曝光,配合Bloom提升亮度
|
||
container.appendChild(renderer.domElement);
|
||
|
||
mainGroup = new THREE.Group();
|
||
scene.add(mainGroup);
|
||
}
|
||
|
||
function setupEnvironment() {
|
||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
|
||
}
|
||
|
||
function setupLights() {
|
||
// 调整灯光强度,适应更暗的背景
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
||
scene.add(ambient);
|
||
|
||
const innerLight = new THREE.PointLight(0xffaa00, 3, 20); // 增强内部点光
|
||
innerLight.position.set(0, 5, 0);
|
||
mainGroup.add(innerLight);
|
||
|
||
const spotGold = new THREE.SpotLight(0xffddaa, 1800); // 增强聚光灯
|
||
spotGold.position.set(30, 40, 40);
|
||
spotGold.angle = 0.5;
|
||
spotGold.penumbra = 0.5;
|
||
scene.add(spotGold);
|
||
|
||
const spotBlue = new THREE.SpotLight(0x6688ff, 800); // 增强冷色侧光
|
||
spotBlue.position.set(-30, 20, -30);
|
||
scene.add(spotBlue);
|
||
}
|
||
|
||
function setupPostProcessing() {
|
||
const renderScene = new RenderPass(scene, camera);
|
||
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.4, 0.85);
|
||
bloomPass.threshold = 0.7; // 调整阈值,使更多粒子发光
|
||
bloomPass.strength = 0.45; // 增强发光强度
|
||
bloomPass.radius = 0.45;
|
||
|
||
composer = new EffectComposer(renderer);
|
||
composer.addPass(renderScene);
|
||
composer.addPass(bloomPass);
|
||
}
|
||
|
||
function createTextures() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 128; canvas.height = 128;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 128, 128);
|
||
ctx.fillStyle = '#cc0000'; // 使用新的红色
|
||
ctx.beginPath();
|
||
for (let i = -128; i < 256; i += 32) {
|
||
ctx.moveTo(i, 0); ctx.lineTo(i + 32, 128); ctx.lineTo(i + 16, 128); ctx.lineTo(i - 16, 0);
|
||
}
|
||
ctx.fill();
|
||
caneTexture = new THREE.CanvasTexture(canvas);
|
||
caneTexture.wrapS = THREE.RepeatWrapping; caneTexture.wrapT = THREE.RepeatWrapping;
|
||
caneTexture.repeat.set(3, 3);
|
||
}
|
||
|
||
function createParticles() {
|
||
const sphereGeo = new THREE.SphereGeometry(0.5, 16, 16);
|
||
const boxGeo = new THREE.BoxGeometry(0.55, 0.55, 0.55);
|
||
const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(0, -0.5, 0), new THREE.Vector3(0, 0.3, 0), new THREE.Vector3(0.1, 0.5, 0), new THREE.Vector3(0.3, 0.4, 0)]);
|
||
const candyGeo = new THREE.TubeGeometry(curve, 8, 0.08, 4, false);
|
||
|
||
// 材质颜色更新,使用更亮的金色
|
||
const goldMat = new THREE.MeshStandardMaterial({
|
||
color: CONFIG.colors.champagneGold,
|
||
metalness: 1.0, roughness: 0.1,
|
||
envMapIntensity: 2.5, // 增强环境反射
|
||
emissive: 0x886600, // 增强自发光,配合 Bloom
|
||
emissiveIntensity: 0.5,
|
||
flatShading: false
|
||
});
|
||
|
||
const greenMat = new THREE.MeshStandardMaterial({
|
||
color: CONFIG.colors.deepGreen,
|
||
metalness: 0.2, roughness: 0.8,
|
||
emissive: 0x002200, emissiveIntensity: 0.2,
|
||
flatShading: false
|
||
});
|
||
|
||
const redMat = new THREE.MeshPhysicalMaterial({
|
||
color: CONFIG.colors.accentRed,
|
||
metalness: 0.3, roughness: 0.2, clearcoat: 1.0,
|
||
emissive: 0x550000, // 增强红色自发光
|
||
flatShading: false
|
||
});
|
||
|
||
const candyMat = new THREE.MeshStandardMaterial({ map: caneTexture, roughness: 0.4, flatShading: false });
|
||
|
||
for (let i = 0; i < CONFIG.particles.count; i++) {
|
||
const rand = Math.random();
|
||
let mesh, type;
|
||
|
||
if (rand < 0.40) { mesh = new THREE.Mesh(boxGeo, greenMat); type = 'BOX'; }
|
||
else if (rand < 0.70) { mesh = new THREE.Mesh(boxGeo, goldMat); type = 'GOLD_BOX'; }
|
||
else if (rand < 0.92) { mesh = new THREE.Mesh(sphereGeo, goldMat); type = 'GOLD_SPHERE'; }
|
||
else if (rand < 0.97) { mesh = new THREE.Mesh(sphereGeo, redMat); type = 'RED'; }
|
||
else { mesh = new THREE.Mesh(candyGeo, candyMat); type = 'CANE'; }
|
||
|
||
const s = 0.4 + Math.random() * 0.5;
|
||
mesh.scale.set(s, s, s);
|
||
mesh.rotation.set(Math.random() * 6, Math.random() * 6, Math.random() * 6);
|
||
mainGroup.add(mesh);
|
||
particleSystem.push(new Particle(mesh, type, false));
|
||
}
|
||
|
||
// 星星材质更新,增强发光
|
||
const starGeo = new THREE.OctahedronGeometry(1.2, 0);
|
||
const starMat = new THREE.MeshStandardMaterial({
|
||
color: 0xffdd88,
|
||
emissive: 0xffaa00,
|
||
emissiveIntensity: 2.0, // 大幅增强星星的自发光
|
||
metalness: 1.0, roughness: 0
|
||
});
|
||
const star = new THREE.Mesh(starGeo, starMat);
|
||
star.position.set(0, CONFIG.particles.treeHeight / 2 + 1.2, 0);
|
||
mainGroup.add(star);
|
||
mainGroup.add(photoMeshGroup);
|
||
}
|
||
|
||
function createDust() {
|
||
// 灰尘粒子材质优化
|
||
const geo = new THREE.TetrahedronGeometry(0.08, 0);
|
||
const mat = new THREE.MeshBasicMaterial({ color: 0xffeebb, transparent: true, opacity: 0.8 });
|
||
for (let i = 0; i < CONFIG.particles.dustCount; i++) {
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.scale.setScalar(0.5 + Math.random());
|
||
mainGroup.add(mesh);
|
||
particleSystem.push(new Particle(mesh, 'DUST', true));
|
||
}
|
||
}
|
||
|
||
function createDefaultPhotos() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 512; canvas.height = 512;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#050505'; ctx.fillRect(0, 0, 512, 512);
|
||
ctx.strokeStyle = '#fceea7'; // 边框使用更亮的金色
|
||
ctx.lineWidth = 15; ctx.strokeRect(20, 20, 472, 472);
|
||
ctx.font = '500 60px Cinzel, Times New Roman';
|
||
ctx.fillStyle = '#fceea7';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText("JOYEUX", 256, 230);
|
||
ctx.fillText("W.F", 256, 300);
|
||
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
addPhotoToScene(tex);
|
||
}
|
||
|
||
function addPhotoToScene(texture) {
|
||
const frameGeo = new THREE.BoxGeometry(1.4, 1.4, 0.05);
|
||
// 相框材质使用更亮的金色
|
||
const frameMat = new THREE.MeshStandardMaterial({ color: CONFIG.colors.champagneGold, metalness: 1.0, roughness: 0.1 });
|
||
const frame = new THREE.Mesh(frameGeo, frameMat);
|
||
|
||
const photoGeo = new THREE.PlaneGeometry(1.2, 1.2);
|
||
const photoMat = new THREE.MeshBasicMaterial({map: texture});
|
||
const photo = new THREE.Mesh(photoGeo, photoMat);
|
||
photo.position.z = 0.04;
|
||
|
||
const group = new THREE.Group();
|
||
group.add(frame);
|
||
group.add(photo);
|
||
|
||
const s = 0.8;
|
||
group.scale.set(s, s, s);
|
||
|
||
photoMeshGroup.add(group);
|
||
particleSystem.push(new Particle(group, 'PHOTO', false));
|
||
}
|
||
|
||
function handleImageUpload(e) {
|
||
const files = e.target.files;
|
||
if (!files.length) return;
|
||
Array.from(files).forEach(f => {
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
new THREE.TextureLoader().load(ev.target.result, (t) => {
|
||
t.colorSpace = THREE.SRGBColorSpace;
|
||
addPhotoToScene(t);
|
||
STATE.mode = 'SCATTER';
|
||
STATE.focusTarget = null;
|
||
updateModeStatus(STATE.mode);
|
||
});
|
||
}
|
||
reader.readAsDataURL(f);
|
||
});
|
||
}
|
||
|
||
// --- MediaPipe and Gesture Processing ---
|
||
async function initMediaPipe() {
|
||
video = document.getElementById('webcam');
|
||
const vision = await FilesetResolver.forVisionTasks(
|
||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"
|
||
);
|
||
// 使用 Google Cloud Storage 上的模型文件
|
||
handLandmarker = await HandLandmarker.createFromOptions(vision, {
|
||
baseOptions: {
|
||
modelAssetPath: `data/hand_landmarker.task`,
|
||
delegate: "GPU"
|
||
},
|
||
runningMode: "VIDEO",
|
||
numHands: 1
|
||
});
|
||
|
||
// 检查摄像头权限状态
|
||
try {
|
||
const permissionStatus = await navigator.permissions.query({name: 'camera'});
|
||
if (permissionStatus.state === 'granted') {
|
||
// 如果已经授予权限,直接启动摄像头
|
||
startWebcam();
|
||
} else if (permissionStatus.state === 'prompt') {
|
||
// 如果需要用户授权,显示提示让用户主动点击启动
|
||
showCameraPermissionPrompt();
|
||
} else {
|
||
// 权限被拒绝,显示提示
|
||
console.warn("摄像头权限已被拒绝");
|
||
}
|
||
} catch (error) {
|
||
// 浏览器不支持权限查询API,直接尝试启动摄像头
|
||
startWebcam();
|
||
}
|
||
}
|
||
|
||
// 启动摄像头
|
||
async function startWebcam() {
|
||
if (navigator.mediaDevices?.getUserMedia) {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({video: true});
|
||
video.srcObject = stream;
|
||
video.addEventListener("loadeddata", predictWebcam);
|
||
} catch (error) {
|
||
console.warn("无法访问摄像头 (MediaPipe 功能受限):", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 显示摄像头权限提示
|
||
function showCameraPermissionPrompt() {
|
||
// 创建提示元素
|
||
const promptDiv = document.createElement('div');
|
||
promptDiv.id = 'camera-prompt';
|
||
promptDiv.innerHTML = `
|
||
<div style="position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||
background: rgba(0, 0, 0, 0.8); color: white; padding: 15px; border-radius: 10px;
|
||
text-align: center; z-index: 1000; backdrop-filter: blur(5px);">
|
||
<p>需要摄像头权限以启用手势控制功能</p>
|
||
<button id="enable-camera" style="background: #4CAF50; color: white; border: none;
|
||
padding: 10px 20px; border-radius: 5px; cursor: pointer;
|
||
margin-top: 10px;">启用摄像头</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(promptDiv);
|
||
|
||
// 添加点击事件
|
||
document.getElementById('enable-camera').addEventListener('click', async () => {
|
||
try {
|
||
await startWebcam();
|
||
promptDiv.remove();
|
||
} catch (error) {
|
||
console.error("启动摄像头失败:", error);
|
||
}
|
||
});
|
||
}
|
||
|
||
let lastVideoTime = -1;
|
||
async function predictWebcam() {
|
||
if (video.currentTime !== lastVideoTime) {
|
||
lastVideoTime = video.currentTime;
|
||
if (handLandmarker && video.readyState >= 2) {
|
||
const result = handLandmarker.detectForVideo(video, performance.now());
|
||
processGestures(result);
|
||
}
|
||
}
|
||
requestAnimationFrame(predictWebcam);
|
||
}
|
||
|
||
const MODE_TEXT = {
|
||
'SCATTER': '当前模式:随机漂浮 (SCATTER)',
|
||
'TREE': '当前模式:圣诞树形态 (TREE)',
|
||
'FOCUS': '当前模式:回忆聚焦 (FOCUS)'
|
||
};
|
||
|
||
function updateModeStatus(mode) {
|
||
if (modeStatusElement) {
|
||
modeStatusElement.textContent = MODE_TEXT[mode] || MODE_TEXT['SCATTER'];
|
||
}
|
||
}
|
||
|
||
function processGestures(result) {
|
||
let newMode = 'SCATTER';
|
||
let newFocusTarget = null;
|
||
let newGestureCommand = null;
|
||
|
||
if (result.landmarks && result.landmarks.length > 0) {
|
||
STATE.hand.detected = true;
|
||
const lm = result.landmarks[0];
|
||
STATE.hand.x = (lm[9].x - 0.5) * 2;
|
||
STATE.hand.y = (lm[9].y - 0.5) * 2;
|
||
|
||
const thumb = lm[4];
|
||
const index = lm[8];
|
||
const middle = lm[12];
|
||
const ring = lm[16];
|
||
const pinky = lm[20];
|
||
const wrist = lm[0];
|
||
|
||
// 计算各手指尖与拇指的距离(稍微放宽条件以便更容易触发)
|
||
const thumbIndexDist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
|
||
const thumbMiddleDist = Math.hypot(thumb.x - middle.x, thumb.y - middle.y);
|
||
const thumbRingDist = Math.hypot(thumb.x - ring.x, thumb.y - ring.y);
|
||
const thumbPinkyDist = Math.hypot(thumb.x - pinky.x, thumb.y - pinky.y);
|
||
|
||
// 计算手指尖到手腕的距离(用于检测握拳)
|
||
const tips = [index, middle, ring, pinky];
|
||
let avgTipDist = tips.reduce((sum, t) => sum + Math.hypot(t.x - wrist.x, t.y - wrist.y), 0) / 4;
|
||
|
||
// 更精确的手势识别逻辑(调整阈值以便更容易识别)
|
||
// 根据屏幕尺寸调整手势识别阈值,在移动设备上适当放宽条件
|
||
let pinchThreshold = 0.07;
|
||
let fistThreshold = 0.25;
|
||
|
||
if (window.innerWidth <= 1024) { // 移动端和小屏幕设备
|
||
pinchThreshold = 0.09; // 放宽捏合条件
|
||
fistThreshold = 0.30; // 放宽握拳条件
|
||
}
|
||
|
||
const isIndexPinching = thumbIndexDist < pinchThreshold;
|
||
const isMiddlePinching = thumbMiddleDist < pinchThreshold;
|
||
const isRingPinching = thumbRingDist < pinchThreshold;
|
||
const isPinkyPinching = thumbPinkyDist < pinchThreshold;
|
||
|
||
// 握拳检测(所有指尖都靠近手腕)
|
||
const isFist = avgTipDist < fistThreshold;
|
||
|
||
// 手势指令识别
|
||
if (isIndexPinching && !isMiddlePinching && !isRingPinching && !isPinkyPinching) {
|
||
// 只有拇指和食指捏合 -> 往前移动
|
||
newGestureCommand = 'MOVE_FORWARD';
|
||
} else if (!isIndexPinching && !isMiddlePinching && isRingPinching && isPinkyPinching) {
|
||
// 只有拇指与无名指、小指捏合 -> 往后移动
|
||
newGestureCommand = 'MOVE_BACKWARD';
|
||
} else if (!isFist && tips.every(t => t.y < wrist.y - 0.1)) {
|
||
// 所有指尖都在手腕上方(手向上)-> 右旋转
|
||
newGestureCommand = 'RIGHT_ROTATE';
|
||
} else if (!isFist && tips.some(t => t.y > wrist.y + 0.1)) {
|
||
// 有些指尖在手腕下方(手向下)-> 左旋转
|
||
newGestureCommand = 'LEFT_ROTATE';
|
||
|
||
// 为移动设备增加简单的手势识别
|
||
} else if (window.innerWidth <= 1024 && !isFist && tips.some(t => t.x < wrist.x - 0.1)) {
|
||
// 在移动设备上,手指在手腕左侧 -> 向前移动
|
||
newGestureCommand = 'MOVE_FORWARD';
|
||
} else if (window.innerWidth <= 1024 && !isFist && tips.some(t => t.x > wrist.x + 0.1)) {
|
||
// 在移动设备上,手指在手腕右侧 -> 向后移动
|
||
newGestureCommand = 'MOVE_BACKWARD';
|
||
}
|
||
|
||
// 模式切换逻辑(修复冲突问题)
|
||
if (isIndexPinching && isMiddlePinching && isRingPinching && isPinkyPinching) {
|
||
// 所有手指都捏合 -> 进入焦点模式(显示图片)
|
||
newMode = 'FOCUS';
|
||
if (STATE.mode !== 'FOCUS' || !STATE.focusTarget) {
|
||
// 查找距离中心最近的照片
|
||
const photos = particleSystem.filter(p => p.type === 'PHOTO');
|
||
if (photos.length) {
|
||
// 计算每个照片与中心的距离
|
||
let closestPhoto = null;
|
||
let minDistance = Infinity;
|
||
|
||
photos.forEach(photo => {
|
||
// 获取照片在3D空间中的位置
|
||
const worldPos = new THREE.Vector3();
|
||
photo.mesh.getWorldPosition(worldPos);
|
||
|
||
// 计算与中心的距离
|
||
const distance = worldPos.length();
|
||
if (distance < minDistance) {
|
||
minDistance = distance;
|
||
closestPhoto = photo.mesh;
|
||
}
|
||
});
|
||
|
||
newFocusTarget = closestPhoto || photos[Math.floor(Math.random() * photos.length)].mesh;
|
||
}
|
||
} else {
|
||
newFocusTarget = STATE.focusTarget;
|
||
}
|
||
} else if (isFist) {
|
||
// 握拳 -> 树模式
|
||
newMode = 'TREE';
|
||
} else {
|
||
// 其他情况 -> 散射模式
|
||
newMode = 'SCATTER';
|
||
}
|
||
|
||
// 更新手势指令
|
||
STATE.gestureCommand = newGestureCommand;
|
||
} else {
|
||
STATE.hand.detected = false;
|
||
STATE.gestureCommand = null;
|
||
}
|
||
|
||
// 快速模式切换
|
||
if (newMode !== lastHandGesture) {
|
||
lastHandGesture = newMode;
|
||
setTimeout(() => {
|
||
if (newMode === lastHandGesture) {
|
||
if (STATE.mode !== newMode) {
|
||
STATE.mode = newMode;
|
||
STATE.focusTarget = newFocusTarget;
|
||
updateModeStatus(STATE.mode);
|
||
}
|
||
}
|
||
}, 30); // 极快的模式切换延迟
|
||
}
|
||
}
|
||
|
||
function handleWindowResize() {
|
||
// 监听窗口大小变化,更新渲染器和相机
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
composer.setSize(window.innerWidth, window.innerHeight);
|
||
}
|
||
|
||
// --- Event Handling ---
|
||
function setupEvents() {
|
||
let resizeTimeout;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(resizeTimeout);
|
||
resizeTimeout = setTimeout(handleWindowResize, 100);
|
||
});
|
||
document.getElementById('file-input').addEventListener('change', handleImageUpload);
|
||
|
||
// H 键控制控制台的显示/隐藏 (标题保持不变)
|
||
const controlsContainer = document.getElementById('controls-container');
|
||
const controlsHint = document.getElementById('controls-hint');
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key.toLowerCase() === 'h') {
|
||
toggleControls();
|
||
}
|
||
});
|
||
|
||
// 为提示文字添加点击事件
|
||
if (controlsHint) {
|
||
controlsHint.addEventListener('click', toggleControls);
|
||
}
|
||
|
||
// 页面加载完成后,默认隐藏控制面板
|
||
window.addEventListener('load', () => {
|
||
if (controlsContainer) {
|
||
controlsContainer.classList.add('ui-hidden');
|
||
STATE.uiVisible = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 控制操作台显示/隐藏的函数
|
||
function toggleControls() {
|
||
const controlsContainer = document.getElementById('controls-container');
|
||
if (controlsContainer) {
|
||
controlsContainer.classList.toggle('ui-hidden');
|
||
STATE.uiVisible = !controlsContainer.classList.contains('ui-hidden');
|
||
}
|
||
}
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
const dt = Math.min(clock.getDelta(), 0.1);
|
||
const elapsedTime = clock.getElapsedTime();
|
||
|
||
// Rotation Logic - 进一步提高手势响应速度
|
||
if (STATE.mode === 'SCATTER' && STATE.hand.detected) {
|
||
const targetRotY = STATE.hand.x * Math.PI * 0.9;
|
||
const targetRotX = STATE.hand.y * Math.PI * 0.25;
|
||
// 进一步提高插值速度
|
||
STATE.rotation.y += (targetRotY - STATE.rotation.y) * Math.min(CONFIG.animation.responseSpeed * dt, 1.0);
|
||
STATE.rotation.x += (targetRotX - STATE.rotation.x) * Math.min(CONFIG.animation.responseSpeed * dt, 1.0);
|
||
} else {
|
||
if (STATE.mode === 'TREE') {
|
||
// 提高树模式旋转速度
|
||
STATE.rotation.y += CONFIG.animation.treeRotation * dt;
|
||
STATE.rotation.x += (0 - STATE.rotation.x) * Math.min(5.0 * dt, 1.0);
|
||
} else {
|
||
// 提高散射模式旋转速度
|
||
STATE.rotation.y += CONFIG.animation.scatterRotation * dt;
|
||
}
|
||
}
|
||
|
||
mainGroup.rotation.y = STATE.rotation.y;
|
||
mainGroup.rotation.x = STATE.rotation.x;
|
||
|
||
// 传递更多参数用于粒子更新
|
||
particleSystem.forEach(p => p.update(
|
||
dt,
|
||
STATE.mode,
|
||
STATE.focusTarget,
|
||
elapsedTime,
|
||
STATE.hand,
|
||
STATE.gestureCommand
|
||
));
|
||
composer.render();
|
||
}
|
||
|
||
// --- MAIN INIT ---
|
||
async function init() {
|
||
initThree();
|
||
setupEnvironment();
|
||
setupLights();
|
||
createTextures();
|
||
createParticles();
|
||
createDust();
|
||
createDefaultPhotos();
|
||
setupPostProcessing();
|
||
setupEvents();
|
||
await initMediaPipe();
|
||
|
||
const loader = document.getElementById('loader');
|
||
loader.style.opacity = 0;
|
||
setTimeout(() => loader.remove(), 800);
|
||
|
||
animate();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
|
||
<script>
|
||
// --- 音频播放控制优化脚本 (保持原样) ---
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const audio = document.getElementById('bg-music');
|
||
const audioControl = document.getElementById('audio-control');
|
||
const audioIcon = document.getElementById('audio-icon');
|
||
|
||
let isPlaying = false;
|
||
let isMuted = true;
|
||
|
||
function updateAudioState() {
|
||
audio.muted = isMuted;
|
||
if (isPlaying) {
|
||
audioIcon.textContent = isMuted ? '🔇' : '🎵';
|
||
audioControl.style.opacity = 1;
|
||
} else {
|
||
audioIcon.textContent = '▶️';
|
||
audioControl.style.opacity = 1;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const playPromise = audio.play();
|
||
if (playPromise !== undefined) {
|
||
playPromise.then(_ => {
|
||
isPlaying = true;
|
||
isMuted = false;
|
||
audio.muted = false;
|
||
updateAudioState();
|
||
}).catch(error => {
|
||
isPlaying = false;
|
||
isMuted = true;
|
||
updateAudioState();
|
||
});
|
||
}
|
||
} catch (error) {
|
||
isPlaying = false;
|
||
isMuted = true;
|
||
updateAudioState();
|
||
}
|
||
function autoPlay() {
|
||
audio.play().then(() => {
|
||
isPlaying = true;
|
||
isMuted = false;
|
||
updateAudioState();
|
||
}).catch(e => {
|
||
console.error("Autoplay failed:", e);
|
||
});
|
||
}
|
||
audioControl.addEventListener('click', function () {
|
||
if (audio.paused && !isPlaying) {
|
||
audio.play().then(() => {
|
||
isPlaying = true;
|
||
isMuted = false;
|
||
updateAudioState();
|
||
}).catch(e => {
|
||
console.error("Manual play failed:", e);
|
||
});
|
||
} else if (isPlaying) {
|
||
isMuted = !isMuted;
|
||
audio.muted = isMuted;
|
||
updateAudioState();
|
||
} else {
|
||
audio.play().then(() => {
|
||
isPlaying = true;
|
||
isMuted = false;
|
||
updateAudioState();
|
||
});
|
||
}
|
||
});
|
||
document.addEventListener('click', autoPlay);
|
||
document.addEventListener('keydown', autoPlay);
|
||
audio.addEventListener('play', () => { isPlaying = true; updateAudioState(); });
|
||
audio.addEventListener('pause', () => { isPlaying = false; updateAudioState(); });
|
||
});
|
||
</script>
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const script = document.createElement('script');
|
||
script.src = "//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.abbr.min.js";
|
||
script.async = true;
|
||
document.head.appendChild(script);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|