一、两版圣诞树代码
我这里调了有两版代码,两款 3D 圣诞树网页代码(多功能完整版 & 简易基础版),提供跨系统详细运行步骤,确保新手也能顺利上手,实现手势交互、粒子星云等核心功能。
1. 多功能版(含手势交互、照片管理、音乐上传等功能)

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grand Luxury Tree v17.6.5 - UI Hints</title>
<style>
/* ================= 1. 基础设置 ================= */
body { margin: 0; overflow: hidden; background-color: #050505; font-family: 'Microsoft YaHei', 'Songti SC', serif; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700&family=Great+Vibes&family=Monoton&family=Abril+Fatface&family=Ma+Shan+Zheng&display=swap');
/* ================= 2. UI 视觉 (V17.6.3 原版样式) ================= */
:root {
--glass-bg: rgba(20, 20, 25, 0.75);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-highlight: rgba(255, 255, 255, 0.15);
--accent-gold: #d4af37;
--accent-gold-gradient: linear-gradient(135deg, #fceea7 0%, #d4af37 100%);
--text-main: #ffffff;
--text-sub: #b0b0b5;
--panel-width: 210px;
--ui-scale: 0.9;
}
@media screen and (max-height: 800px) { :root { --ui-scale: 0.8; } }
@media screen and (min-width: 2000px) { :root { --ui-scale: 1.1; } }
:fullscreen { --ui-scale: 1.0; }
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0.5px 0.5px 0px var(--glass-highlight), inset -0.5px -0.5px 0px rgba(0,0,0,0.5);
border: 1px solid var(--glass-border);
overflow: hidden;
box-sizing: border-box;
}
#left-sidebar, .bottom-left-panel { transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.panel-hidden { transform: translateX(calc(-1 * var(--panel-width) - 50px)) scale(var(--ui-scale)) !important; opacity: 0 !important; pointer-events: none !important; }
.hidden { display: none !important; }
#left-sidebar { position: absolute; top: 15px; left: 15px; width: var(--panel-width); height: calc(100vh - 30px); display: flex; flex-direction: column; gap: 8px; z-index: 20; pointer-events: none; transform: scale(var(--ui-scale)); transform-origin: top left; }
.scroll-content { flex: 1; overflow-y: auto; pointer-events: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; scrollbar-width: none; }
.scroll-content::-webkit-scrollbar { display: none; }
.bottom-left-panel { position: absolute; bottom: 15px; left: 15px; pointer-events: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 5px; width: var(--panel-width); padding: 10px; box-sizing: border-box; transform: scale(var(--ui-scale)); transform-origin: bottom left; }
.bottom-left-panel .ui-title-main { grid-column: span 2; font-size: 11px; margin-bottom: 4px; padding-bottom: 2px; }
.bottom-left-panel .elegant-btn { width: 100%; font-size: 10px; height: 28px; }
/* 修复 input file 样式 */
.bottom-left-panel .elegant-btn { width: 100%; font-size: 10px; height: 28px; position: relative; }
.elegant-btn input[type="file"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.ui-title-main { font-family: 'SimSun', 'Songti SC', serif; font-size: 14px; font-weight: bold; color: #fff1c1; text-shadow: 0 0 10px rgba(212, 175, 55, 0.3); border-bottom: 1px solid rgba(212, 175, 55, 0.2); padding-bottom: 4px; margin-bottom: 6px; letter-spacing: 2px; text-align: center; }
.ui-title-sub { font-size: 12px; color: #b0b0b5; margin-top: 6px; margin-bottom: 3px; letter-spacing: 1px; font-weight: 700; text-transform: uppercase; }
.control-label { color: #aaa; font-size: 11px; font-weight: 700; }
.control-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 5px; }
.control-row { display: flex; flex-direction: column; gap: 1px; margin-bottom: 4px; }
.particle-scroll-container { max-height: 140px; overflow-y: auto; padding-right: 4px; margin-right: -4px; border-left: 2px solid rgba(212,175,55,0.1); padding-left: 6px; }
.particle-scroll-container::-webkit-scrollbar { width: 3px; }
.particle-scroll-container::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius: 2px; }
.particle-scroll-container::-webkit-scrollbar-thumb { background: rgba(212,175,55,0.4); border-radius: 2px; }
.elegant-btn { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(212, 175, 55, 0.3); color: #d4af37; padding: 5px 0; cursor: pointer; text-transform: uppercase; font-size: 10px; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; border-radius: 4px; box-sizing: border-box; position: relative; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; text-decoration: none; }
.elegant-btn:hover { background: rgba(212, 175, 55, 0.1); border-color: #fff1c1; color: #fff; box-shadow: 0 0 15px rgba(212, 175, 55, 0.4); text-shadow: 0 0 5px rgba(255, 255, 255, 0.8); }
.elegant-btn:active { transform: scale(0.98); }
.btn-red { border-color: #844; color: #eaa; }
.btn-red:hover { background: #522; box-shadow: 0 0 15px rgba(255, 50, 50, 0.3); }
input[type=range] { -webkit-appearance: none; width: 100%; background: transparent; margin: 3px 0; }
input[type=range]:focus { outline: none; }
input[type=range]::-webkit-slider-runnable-track { width: 100%; height: 1px; cursor: pointer; background: rgba(255, 255, 255, 0.2); border-radius: 1px; }
input[type=range]::-webkit-slider-thumb { height: 9px; width: 9px; border-radius: 50%; background: #d4af37; cursor: pointer; -webkit-appearance: none; margin-top: -4px; box-shadow: 0 0 5px rgba(212, 175, 55, 0.8); border: 1px solid #000; transition: transform 0.1s; }
input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.3); background: #fff; }
.input-glass { background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); color: #eebb66; padding: 3px; font-size: 10px; outline: none; transition: 0.3s; border-radius: 2px; width: 100%; box-sizing: border-box; text-align: center; height: 24px; }
.input-glass:focus { border-color: #d4af37; background: rgba(0,0,0,0.6); }
select.input-glass { cursor: pointer; }
select.input-glass option { background: #000; color: #d4af37; }
input[type="color"] { -webkit-appearance: none; border: none; width: 100%; height: 16px; cursor: pointer; padding: 0; background: none; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; }
#title-container { position: absolute; top: 10%; left: 50%; transform: translateX(-50%); text-align: center; pointer-events: auto; cursor: move; z-index: 50; transition: color 0.2s; user-select: none; padding: 10px; }
.title-line { margin: 0; transition: all 0.2s ease; white-space: nowrap; color: #fceea7; text-shadow: 0 0 30px rgba(255, 255, 255, 0.2); }
.top-left-panel { pointer-events: auto; display: flex; flex-direction: column; gap: 4px; padding: 10px; width: 100%; box-sizing: border-box; }
.interaction-grid { display: flex; gap: 4px; margin-bottom: 4px; }
.direction-pad { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.05); }
.dir-btn { background: rgba(255,255,255,0.03); border: 1px solid rgba(212,175,55,0.2); color: #d4af37; text-align: center; cursor: pointer; padding: 5px 0; border-radius: 2px; font-size: 11px; user-select: none; transition: 0.2s; }
.dir-btn:hover { background: rgba(212,175,55,0.2); color: #fff; box-shadow: 0 0 8px rgba(212,175,55,0.2); }
.dir-btn:active { background: #d4af37; color: #000; }
#top-right-controls { position: absolute; top: 15px; right: 15px; pointer-events: auto; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; z-index: 50; }
#webcam-wrapper { position: absolute; bottom: 15px; right: 15px; width: 160px; height: 120px; border: 1px solid rgba(212, 175, 55, 0.5); border-radius: 4px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.9); overflow: hidden; z-index: 20; pointer-events: auto; background: #000; transition: opacity 0.4s ease, transform 0.4s ease; }
#webcam-wrapper.camera-hidden { opacity: 0; pointer-events: none; transform: translateY(10px); }
#webcam-canvas { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); display: block; }
#cam-status { position: absolute; bottom: 5px; right: 5px; width: 6px; height: 6px; background: #550000; border-radius: 50%; box-shadow: 0 0 4px #ff0000; z-index: 30; transition: 0.2s; }
#cam-status.active { background: #00ff00; box-shadow: 0 0 6px #00ff00; }
#delete-manager { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 60; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; backdrop-filter: blur(10px); }
#photo-grid { display: flex; flex-wrap: wrap; gap: 15px; width: 70%; height: 60%; overflow-y: auto; justify-content: center; padding: 20px; border: 1px solid rgba(212,175,55,0.3); margin: 20px 0; background: rgba(0,0,0,0.5); border-radius: 8px; }
.photo-item { width: 80px; height: 80px; position: relative; border: 1px solid #d4af37; transition: 0.2s; cursor: pointer; }
.photo-item:hover { transform: scale(1.1); border-color: #fff; box-shadow: 0 0 15px rgba(212,175,55,0.5); }
.photo-thumb { width: 100%; height: 100%; object-fit: cover; }
.delete-x { position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; background: #900; color: white; border-radius: 50%; text-align: center; line-height: 18px; font-size: 12px; cursor: pointer; font-weight: bold; border: 1px solid #fff; }
.manager-title { color: #d4af37; font-size: 20px; font-family: 'Microsoft YaHei', serif; letter-spacing: 2px; }
#loader { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #050505; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: opacity 0.5s ease-out; }
.spinner { width: 50px; height: 50px; border: 1px solid rgba(212, 175, 55, 0.1); border-top: 2px solid #d4af37; border-radius: 50%; animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loader-text { color: #d4af37; font-size: 12px; letter-spacing: 4px; margin-top: 25px; font-family: 'Cinzel', serif; opacity: 0.8; }
#gesture-hint { position: absolute; bottom: 10px; width: 100%; text-align: center; color: rgba(212,175,55,0.7); font-size: 10px; pointer-events: none; text-shadow: 0 0 5px #000; z-index: 5; }
</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">SYSTEM INITIALIZING</div></div>
<div id="canvas-container"></div>
<div id="title-container"><h1 id="display-line1" class="title-line">Merry</h1><h1 id="display-line2" class="title-line">Christmas</h1></div>
<div id="ui-layer">
<div id="top-right-controls"><button class="elegant-btn glass-panel" id="fs-btn" onclick="toggleFullScreen()" style="padding: 6px 12px;">⛶ 全屏显示</button><button class="elegant-btn glass-panel" id="toggle-ui-btn" onclick="toggleUI()" style="padding: 6px 12px;">👁 隐藏界面</button></div>
<div id="left-sidebar">
<div class="top-left-panel glass-panel">
<div class="ui-title-main">场景定制</div>
<div class="ui-title-sub">祝福语录</div>
<div class="control-row"><input type="text" id="input-line1" class="input-glass" placeholder="第一行文字" oninput="updateTextConfig()"><input type="text" id="input-line2" class="input-glass" placeholder="第二行文字" oninput="updateTextConfig()" style="margin-top:2px;"></div>
<div class="ui-title-sub">字体风格</div>
<select id="font-select" class="input-glass" onchange="updateTextConfig()"><option value="style1">书法韵味</option><option value="style2">古典衬线</option><option value="style3">优雅手写</option><option value="style4">艺术线条</option><option value="style5">复古重磅</option></select>
<div class="control-row">
<div class="ui-title-sub" style="margin-top:6px;">大小 & 颜色</div>
<div style="display:flex; gap:8px; align-items:center;"><input type="range" min="50" max="250" value="100" id="slider-fontsize" oninput="updateTextConfig()"><input type="color" id="color-picker" value="#fceea7" oninput="updateTextConfig()" style="width:30px; height:20px; border:none;"></div>
</div>
<div class="ui-title-sub" style="border-top:1px solid rgba(255,255,255,0.05); padding-top:6px; margin-top:6px;">背景音乐</div>
<div style="display:flex; gap:8px; margin-bottom:4px;"><button class="elegant-btn" onclick="toggleMusicPlay()" id="play-btn" style="flex:2; font-size:14px;">⏯</button><button class="elegant-btn" onclick="replayMusic()" style="flex:1; font-size:14px;">⟲</button></div>
<div class="control-row"><span class="control-label" style="text-align:center;">音量调节</span><input type="range" min="0" max="100" value="50" id="slider-volume" oninput="updateVolume(this.value)"></div>
<div class="ui-title-sub" style="border-top:1px solid rgba(255,255,255,0.05); padding-top:6px; margin-top:6px;">粒子控制</div>
<div class="particle-scroll-container">
<div class="control-row"><span class="control-label">装饰密度 (树)</span><input type="range" min="500" max="3000" value="1500" id="slider-tree"></div>
<div class="control-row"><span class="control-label">星尘密度 (背景)</span><input type="range" min="500" max="5000" value="2500" id="slider-dust"></div>
<div class="control-row"><span class="control-label">雪花数量</span><input type="range" min="0" max="3000" step="100" value="1500" id="slider-snow-count" onchange="updateSnowSettings()"></div>
<div class="control-row"><span class="control-label">雪花大小</span><input type="range" min="0.05" max="0.3" step="0.01" value="0.12" id="slider-snow-size" onchange="updateSnowSettings()"></div>
<div class="control-row"><span class="control-label">下落速度</span><input type="range" min="1.0" max="8.0" step="0.5" value="3.5" id="slider-snow-speed" oninput="updateSnowSpeed(this.value)"></div>
</div>
<button class="elegant-btn" style="width:100%; margin-top:8px;" onclick="applyParticleSettings()">⚡ 重置场景</button>
</div>
<div class="top-left-panel glass-panel">
<div class="ui-title-main">交互中控</div>
<div class="ui-title-sub">形态切换</div>
<div class="interaction-grid"><button class="elegant-btn" style="flex:1;" onclick="setMode('TREE')">聚合 (Space)</button><button class="elegant-btn" style="flex:1;" onclick="setMode('SCATTER')">散开 (Z)</button></div>
<button class="elegant-btn" style="width:100%;" onclick="triggerPhotoGrab()">抓取照片 (X)</button>
<div class="control-row" style="margin-top:8px;"><span class="control-label">旋转速度 (散开)</span><input type="range" min="0.1" max="5.0" step="0.1" value="1.4" oninput="updateRotationSpeed(this.value)"></div>
<div class="direction-pad"><div></div><div class="dir-btn" onmousedown="startRotate('up')" onmouseup="stopRotate()" onmouseleave="stopRotate()">▲</div><div></div><div class="dir-btn" onmousedown="startRotate('left')" onmouseup="stopRotate()" onmouseleave="stopRotate()">◀</div><div class="dir-btn" onclick="resetRotation()" style="font-size:10px;">●</div><div class="dir-btn" onmousedown="startRotate('right')" onmouseup="stopRotate()" onmouseleave="stopRotate()">▶</div><div></div><div class="dir-btn" onmousedown="startRotate('down')" onmouseup="stopRotate()" onmouseleave="stopRotate()">▼</div><div></div></div>
</div>
</div>
<div class="bottom-left-panel glass-panel">
<div class="ui-title-main" style="font-size:12px; margin-bottom:5px;">资源管理</div>
<label class="elegant-btn"> + 上传照片 <input type="file" id="file-input" multiple accept="image/*"> </label>
<button class="elegant-btn" onclick="openDeleteManager()">▣ 管理照片</button>
<label class="elegant-btn" id="music-upload-label"> ♫ 背景音乐 <input type="file" id="music-input" accept=".mp3,audio/mpeg"> </label>
<button class="elegant-btn" id="toggle-cam-btn" onclick="toggleCameraDisplay()">📷 隐藏画面</button>
</div>
</div>
<div id="gesture-hint">正在初始化系统...</div>
<div id="webcam-wrapper"><canvas id="webcam-canvas" width="320" height="240"></canvas><div id="cam-status"></div></div>
<video id="webcam-video" autoplay playsinline muted style="display:none"></video>
<div id="delete-manager" class="hidden"><div class="manager-title">照片库管理</div><div id="photo-grid"></div><div class="manager-actions"><button class="elegant-btn btn-red glass-panel" onclick="clearAllPhotos()" style="padding: 8px 20px;">清空所有</button><button class="elegant-btn glass-panel" onclick="closeDeleteManager()" style="padding: 8px 20px;">关闭</button></div></div>
<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';
// Clean Database Name
const DB_NAME = "GrandTreeDB_v17_Clean";
const EXPORTED_DATA = null;
let CONFIG = {
colors: { bg: 0x000000, champagneGold: 0xffd966, deepGreen: 0x03180a, accentRed: 0x990000 },
particles: { count: 1500, dustCount: 2500, treeHeight: 24, treeRadius: 8 },
snow: { count: 1500, range: 70, speed: 3.5, sizeBase: 0.12, sizeVar: 0.1 },
camera: { z: 50 },
interaction: { rotationSpeed: 1.4, grabRadius: 0.25 }
};
const STATE = { mode: 'TREE', focusTarget: null, focusType: 0, hand: { detected: false, x: 0, y: 0 }, rotation: { x: 0, y: 0 }, uiVisible: true, cameraVisible: true };
let manualRotateState = { x: 0, y: 0 };
const FONT_STYLES = { 'style1': { font: "'Ma Shan Zheng', cursive", spacing: "4px", shadow: "2px 2px 8px rgba(180,50,50,0.8)", transform: "none" }, 'style2': { font: "'Cinzel', serif", spacing: "6px", shadow: "0 0 20px rgba(255,215,0,0.5)", transform: "none" }, 'style3': { font: "'Great Vibes', cursive", spacing: "1px", shadow: "0 0 15px rgba(255,200,255,0.7)", transform: "none" }, 'style4': { font: "'Monoton', cursive", spacing: "1px", shadow: "0 0 10px #fff", transform: "none" }, 'style5': { font: "'Abril Fatface', cursive", spacing: "0px", shadow: "0 5px 15px rgba(0,0,0,0.8)", transform: "none" } };
let db;
function initDB() { if(EXPORTED_DATA) return Promise.resolve(null); return new Promise(r=>{const q=indexedDB.open(DB_NAME,1);q.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains('photos'))d.createObjectStore('photos',{keyPath:"id"});if(!d.objectStoreNames.contains('music'))d.createObjectStore('music',{keyPath:"id"})};q.onsuccess=e=>{db=e.target.result;r(db)};q.onerror=()=>r(null)});}
function savePhotoToDB(b){if(!db)return null;const t=db.transaction('photos',"readwrite");const i=Date.now()+Math.random().toString();t.objectStore('photos').add({id:i,data:b});return i;}
function loadPhotosFromDB(){if(EXPORTED_DATA) return Promise.resolve(EXPORTED_DATA.photos || []); if(!db)return Promise.resolve([]); return new Promise(r=>{db.transaction('photos',"readonly").objectStore('photos').getAll().onsuccess=e=>r(e.target.result)});}
function deletePhotoFromDB(i){if(db)db.transaction('photos',"readwrite").objectStore('photos').delete(i);}
function clearPhotosDB(){if(db)db.transaction('photos',"readwrite").objectStore('photos').clear();}
function saveMusicToDB(b){if(!db)return;const t=db.transaction('music',"readwrite");t.objectStore('music').put({id:'bgm',data:b});}
function loadMusicFromDB(){if(EXPORTED_DATA && EXPORTED_DATA.music) return Promise.resolve(dataURLtoBlob(EXPORTED_DATA.music)); if(!db)return Promise.resolve(null);return new Promise(r=>{db.transaction('music',"readonly").objectStore('music').get('bgm').onsuccess=e=>r(e.target.result?e.target.result.data:null)});}
function dataURLtoBlob(dataurl) { var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); while(n--){ u8arr[n] = bstr.charCodeAt(n); } return new Blob([u8arr], {type:mime}); }
let scene, camera, renderer, composer, mainGroup, particleSystem=[], photoMeshGroup=new THREE.Group(), snowInstancedMesh, snowDummy=new THREE.Object3D(), snowData=[], clock=new THREE.Clock(), handLandmarker, videoElement, caneTexture, bgmAudio=new Audio(); bgmAudio.loop=true; let isMusicPlaying=false;
async function init() {
if (EXPORTED_DATA) {
document.body.classList.add('exported-mode');
CONFIG = EXPORTED_DATA.config;
setTimeout(() => applyTextConfig(EXPORTED_DATA.text.fontKey, EXPORTED_DATA.text.line1, EXPORTED_DATA.text.line2, EXPORTED_DATA.text.size, EXPORTED_DATA.text.color), 100);
}
initThree(); setupEnvironment(); setupLights(); createTextures(); createParticles(); createDust(); createSnow(); createDefaultPhotos(); setupPostProcessing(); setupEvents(); animate();
const loader = document.getElementById('loader');
if(loader) { loader.style.opacity = 0; setTimeout(() => loader.remove(), 500); }
try {
await initDB();
if(!EXPORTED_DATA) loadTextConfig();
const ps=await loadPhotosFromDB();
if(ps?.length>0){ photoMeshGroup.clear(); particleSystem=particleSystem.filter(p=>p.type!=='PHOTO'); ps.forEach(i=>createPhotoTexture(i.data, i.id)); }
const ms=await loadMusicFromDB();
if(ms){ bgmAudio.src=URL.createObjectURL(ms); if(EXPORTED_DATA) { document.body.addEventListener('click', () => { if(!isMusicPlaying) toggleMusicPlay(); }, { once: true }); } updatePlayBtnUI(false); }
} catch(e){console.warn(e);}
initMediaPipe();
initDraggableTitle();
setMode('TREE');
}
function initDraggableTitle() { const t=document.getElementById('title-container'); let d=false,o={x:0,y:0}; t.onmousedown=e=>{d=true;const r=t.getBoundingClientRect();o.x=e.clientX-r.left;o.y=e.clientY-r.top;t.style.transform='none';t.style.left=r.left+'px';t.style.top=r.top+'px'}; window.onmousemove=e=>{if(d){t.style.left=(e.clientX-o.x)+'px';t.style.top=(e.clientY-o.y)+'px'}}; window.onmouseup=()=>d=false; }
window.toggleUI=()=> { STATE.uiVisible=!STATE.uiVisible; document.getElementById('left-sidebar').classList.toggle('panel-hidden', !STATE.uiVisible); document.querySelector('.bottom-left-panel').classList.toggle('panel-hidden', !STATE.uiVisible); };
window.toggleCameraDisplay=()=> { STATE.cameraVisible=!STATE.cameraVisible; document.getElementById('webcam-wrapper').classList.toggle('camera-hidden', !STATE.cameraVisible); };
window.toggleFullScreen=()=> { if(!document.fullscreenElement)document.documentElement.requestFullscreen();else document.exitFullscreen(); };
function loadTextConfig() { const s=JSON.parse(localStorage.getItem('v16_text_config')); if(s){ document.getElementById('input-line1').value=s.line1||""; document.getElementById('input-line2').value=s.line2||""; document.getElementById('font-select').value=s.fontKey||"style1"; document.getElementById('slider-fontsize').value=s.size||100; document.getElementById('color-picker').value=s.color||"#fceea7"; applyTextConfig(s.fontKey,s.line1,s.line2,s.size,s.color); } else { document.getElementById('input-line1').value="Merry"; document.getElementById('input-line2').value="Christmas"; applyTextConfig("style1","Merry","Christmas",100,"#fceea7"); } }
window.updateTextConfig=()=> { const k=document.getElementById('font-select').value, l1=document.getElementById('input-line1').value, l2=document.getElementById('input-line2').value, s=document.getElementById('slider-fontsize').value, c=document.getElementById('color-picker').value; localStorage.setItem('v16_text_config',JSON.stringify({fontKey:k,line1:l1,line2:l2,size:s,color:c})); applyTextConfig(k,l1,l2,s,c); };
function applyTextConfig(k,l1,l2,s,c) { const st=FONT_STYLES[k]||FONT_STYLES['style1']; const t1=document.getElementById('display-line1'), t2=document.getElementById('display-line2'), ct=document.getElementById('title-container'); ct.style.fontFamily=st.font; t1.innerText=l1; t2.innerText=l2; t1.style.letterSpacing=st.spacing; t2.style.letterSpacing=st.spacing; t1.style.textShadow=st.shadow; t2.style.textShadow=st.shadow; t1.style.textTransform=st.transform; t2.style.textTransform=st.transform; t1.style.color=c; t2.style.color=c; t1.style.fontSize=(0.48*s)+"px"; t2.style.fontSize=(0.48*s)+"px"; }
window.toggleMusicPlay=()=> { if(!bgmAudio.src) return alert("请先上传音乐"); if(isMusicPlaying){ bgmAudio.pause(); isMusicPlaying=false; } else { bgmAudio.play(); isMusicPlaying=true; } updatePlayBtnUI(isMusicPlaying); };
window.replayMusic=()=> { if(!bgmAudio.src) return; bgmAudio.currentTime=0; bgmAudio.play(); isMusicPlaying=true; updatePlayBtnUI(true); };
window.updateVolume=(v)=> { bgmAudio.volume=v/100; };
function updatePlayBtnUI(p) { document.getElementById('play-btn').innerText = p ? "⏸" : "⏯"; }
window.updateRotationSpeed=(v)=> { CONFIG.interaction.rotationSpeed = parseFloat(v); };
window.setMode = function(mode) {
STATE.mode = mode;
STATE.focusTarget = null;
const hint = document.getElementById('gesture-hint');
if(mode === 'TREE') hint.innerText = "状态: 聚合 (圣诞树)";
else if(mode === 'SCATTER') hint.innerText = "状态: 散开 (星云)";
else if(mode === 'FOCUS') hint.innerText = "状态: 抓取照片";
}
window.triggerPhotoGrab=()=> {
let cp=null,md=Infinity;
STATE.focusType=Math.floor(Math.random()*4);
particleSystem.filter(p=>p.type==='PHOTO').forEach(p=>{
p.mesh.updateMatrixWorld();
const pos=new THREE.Vector3();
p.mesh.getWorldPosition(pos);
const sp=pos.project(camera);
const d=Math.hypot(sp.x,sp.y);
if(sp.z<1 && d<CONFIG.interaction.grabRadius){
if(d<md){md=d;cp=p.mesh;}
}
});
if(cp){
setMode('FOCUS');
STATE.focusTarget=cp;
} else {
setMode('SCATTER');
}
};
window.startRotate=(d)=> { if(d==='up')manualRotateState.x=-1; if(d==='down')manualRotateState.x=1; if(d==='left')manualRotateState.y=-1; if(d==='right')manualRotateState.y=1; };
window.stopRotate=()=> { manualRotateState={x:0,y:0}; };
window.resetRotation=()=> { STATE.rotation={x:0,y:0}; if(STATE.mode!=='TREE')setMode('TREE'); };
// ================= V17.6.5: UI + Keyboard =================
window.setupEvents = function() {
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
document.getElementById('file-input').addEventListener('change', (e) => {
const files = e.target.files; if(!files.length) return;
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = (ev) => {
const base64 = ev.target.result;
const id = savePhotoToDB(base64);
createPhotoTexture(base64, id);
}
reader.readAsDataURL(f);
});
});
document.getElementById('music-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
saveMusicToDB(file);
bgmAudio.src = URL.createObjectURL(file);
bgmAudio.play().then(() => { isMusicPlaying = true; updatePlayBtnUI(true); }).catch(console.error);
}
});
// --- Keyboard Controls ---
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const k = e.key.toLowerCase();
const code = e.code;
if (k === 'h') window.toggleUI();
if (code === 'Space') { e.preventDefault(); setMode('TREE'); }
if (k === 'z') setMode('SCATTER');
if (k === 'x') triggerPhotoGrab();
if (code === 'ArrowUp') manualRotateState.x = -1;
if (code === 'ArrowDown') manualRotateState.x = 1;
if (code === 'ArrowLeft') manualRotateState.y = -1;
if (code === 'ArrowRight') manualRotateState.y = 1;
});
window.addEventListener('keyup', (e) => {
const code = e.code;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code)) {
manualRotateState = { x: 0, y: 0 };
}
});
}
function initThree() { const c=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.01); 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,2)); renderer.toneMapping=THREE.ReinhardToneMapping; renderer.toneMappingExposure=2.2; c.appendChild(renderer.domElement); mainGroup=new THREE.Group(); scene.add(mainGroup); }
function setupEnvironment() { const p=new THREE.PMREMGenerator(renderer); scene.environment=p.fromScene(new RoomEnvironment(),0.04).texture; }
function setupLights() { scene.add(new THREE.AmbientLight(0xffffff,0.6)); const i=new THREE.PointLight(0xffaa00,2,20); i.position.set(0,5,0); mainGroup.add(i); const s1=new THREE.SpotLight(0xffcc66,1200); s1.position.set(30,40,40); s1.angle=0.5; s1.penumbra=0.5; scene.add(s1); const s2=new THREE.SpotLight(0x6688ff,600); s2.position.set(-30,20,-30); scene.add(s2); const f=new THREE.DirectionalLight(0xffeebb,0.8); f.position.set(0,0,50); scene.add(f); }
function setupPostProcessing() { const r=new RenderPass(scene,camera); const b=new UnrealBloomPass(new THREE.Vector2(window.innerWidth,window.innerHeight),1.5,0.4,0.85); b.threshold=0.7; b.strength=0.45; b.radius=0.4; composer=new EffectComposer(renderer); composer.addPass(r); composer.addPass(b); }
function createTextures() { const c=document.createElement('canvas'); c.width=128; c.height=128; const x=c.getContext('2d'); x.fillStyle='#ffffff'; x.fillRect(0,0,128,128); x.fillStyle='#880000'; x.beginPath(); for(let i=-128;i<256;i+=32){x.moveTo(i,0);x.lineTo(i+32,128);x.lineTo(i+16,128);x.lineTo(i-16,0);} x.fill(); caneTexture=new THREE.CanvasTexture(c); caneTexture.wrapS=caneTexture.wrapT=THREE.RepeatWrapping; caneTexture.repeat.set(3,3); }
window.updateSnowSettings=()=> { CONFIG.snow.sizeBase=parseFloat(document.getElementById('slider-snow-size').value); CONFIG.snow.count=parseInt(document.getElementById('slider-snow-count').value); createSnow(); };
window.updateSnowSpeed=(v)=> { CONFIG.snow.speed=parseFloat(v); };
function createSnow() {
if(snowInstancedMesh){scene.remove(snowInstancedMesh); snowInstancedMesh.geometry.dispose(); snowInstancedMesh.material.dispose(); snowInstancedMesh=null; snowData=[];}
if(CONFIG.snow.count<=0)return;
const g=new THREE.IcosahedronGeometry(CONFIG.snow.sizeBase,0);
const m=new THREE.MeshPhysicalMaterial({color:0xffffff,metalness:0,roughness:0.15,transmission:0.9,thickness:0.5,envMapIntensity:1.5,clearcoat:1,clearcoatRoughness:0.1,ior:1.33});
snowInstancedMesh=new THREE.InstancedMesh(g,m,CONFIG.snow.count); snowInstancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
for(let i=0;i<CONFIG.snow.count;i++){
snowDummy.position.set((Math.random()-0.5)*CONFIG.snow.range, Math.random()*CONFIG.snow.range, (Math.random()-0.5)*CONFIG.snow.range);
snowDummy.rotation.set(Math.random()*Math.PI,Math.random()*Math.PI,Math.random()*Math.PI);
const s=0.5+Math.random()*CONFIG.snow.sizeVar; snowDummy.scale.set(s,s,s); snowDummy.updateMatrix();
snowInstancedMesh.setMatrixAt(i,snowDummy.matrix);
snowData.push({vy:(Math.random()*0.5+0.8), rx:(Math.random()-0.5)*2, ry:(Math.random()-0.5)*2, rz:(Math.random()-0.5)*2});
} scene.add(snowInstancedMesh);
}
class Particle {
constructor(m,t,d=false){this.mesh=m;this.type=t;this.isDust=d;this.posTree=new THREE.Vector3();this.posScatter=new THREE.Vector3();this.baseScale=m.scale.x;this.photoId=null;const s=(t==='PHOTO')?0.3:2.0;this.spinSpeed=new THREE.Vector3((Math.random()-0.5)*s,(Math.random()-0.5)*s,(Math.random()-0.5)*s);this.calcPos();}
calcPos(){const h=CONFIG.particles.treeHeight;let t=Math.pow(Math.random(),0.8);const y=(t*h)-(h/2);let rm=Math.max(0.5,CONFIG.particles.treeRadius*(1.0-t));const a=t*50*Math.PI+Math.random()*Math.PI;const r=rm*(0.8+Math.random()*0.4);this.posTree.set(Math.cos(a)*r,y,Math.sin(a)*r);let rs=this.isDust?(12+Math.random()*20):(8+Math.random()*12);const th=Math.random()*Math.PI*2,ph=Math.acos(2*Math.random()-1);this.posScatter.set(rs*Math.sin(ph)*Math.cos(th),rs*Math.sin(ph)*Math.sin(th),rs*Math.cos(ph));}
update(dt,mode,ft){
let tg=this.posTree; if(mode==='SCATTER')tg=this.posScatter; else if(mode==='FOCUS'){if(this.mesh===ft){let off=new THREE.Vector3(0,1,38);if(STATE.focusType===1)off.set(-4,2,35);else if(STATE.focusType===2)off.set(3,0,32);else if(STATE.focusType===3)off.set(0,-2.5,30);const im=new THREE.Matrix4().copy(mainGroup.matrixWorld).invert();tg=off.applyMatrix4(im);}else tg=this.posScatter;}
const ls=(mode==='FOCUS'&&this.mesh===ft)?8.0:4.0; this.mesh.position.lerp(tg,ls*dt);
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,dt);this.mesh.rotation.z=THREE.MathUtils.lerp(this.mesh.rotation.z,0,dt);this.mesh.rotation.y+=0.5*dt;}
if(mode==='FOCUS'&&this.mesh===ft){this.mesh.lookAt(camera.position);if(STATE.focusType===1)this.mesh.rotateZ(0.38);if(STATE.focusType===2)this.mesh.rotateZ(-0.15);if(STATE.focusType===3)this.mesh.rotateX(-0.4);}
let s=this.baseScale;if(this.isDust){s=this.baseScale*(0.8+0.4*Math.sin(clock.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'){if(this.mesh===ft){if(STATE.focusType===2)s=3.5;else if(STATE.focusType===3)s=4.8;else s=3.0;}else s=this.baseScale*0.8;}this.mesh.scale.lerp(new THREE.Vector3(s,s,s),6*dt);
}
}
function createParticles() {
const sg=new THREE.SphereGeometry(0.5,32,32); const bg=new THREE.BoxGeometry(0.55,0.55,0.55); const c=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 cg=new THREE.TubeGeometry(c,16,0.08,8,false);
const gm=new THREE.MeshStandardMaterial({color:CONFIG.colors.champagneGold,metalness:1,roughness:0.1,envMapIntensity:2,emissive:0x443300,emissiveIntensity:0.3});
const grm=new THREE.MeshStandardMaterial({color:CONFIG.colors.deepGreen,metalness:0.2,roughness:0.8,emissive:0x002200,emissiveIntensity:0.2});
const rm=new THREE.MeshPhysicalMaterial({color:CONFIG.colors.accentRed,metalness:0.3,roughness:0.2,clearcoat:1,emissive:0x330000});
const cm=new THREE.MeshStandardMaterial({map:caneTexture,roughness:0.4});
for(let i=0;i<CONFIG.particles.count;i++){const r=Math.random();let m,t;if(r<0.4){m=new THREE.Mesh(bg,grm);t='BOX';}else if(r<0.7){m=new THREE.Mesh(bg,gm);t='GOLD_BOX';}else if(r<0.92){m=new THREE.Mesh(sg,gm);t='GOLD_SPHERE';}else if(r<0.97){m=new THREE.Mesh(sg,rm);t='RED';}else{m=new THREE.Mesh(cg,cm);t='CANE';}const s=0.4+Math.random()*0.5;m.scale.set(s,s,s);m.rotation.set(Math.random()*6,Math.random()*6,Math.random()*6);mainGroup.add(m);particleSystem.push(new Particle(m,t,false));}
const st=new THREE.Mesh(new THREE.OctahedronGeometry(1.2,0),new THREE.MeshStandardMaterial({color:0xffdd88,emissive:0xffaa00,emissiveIntensity:1,metalness:1,roughness:0}));st.position.set(0,CONFIG.particles.treeHeight/2+1.2,0);mainGroup.add(st);mainGroup.add(photoMeshGroup);
}
function createDust(){const g=new THREE.TetrahedronGeometry(0.08,0);const m=new THREE.MeshBasicMaterial({color:0xffeebb,transparent:true,opacity:0.8});for(let i=0;i<CONFIG.particles.dustCount;i++){const ms=new THREE.Mesh(g,m);ms.scale.setScalar(0.5+Math.random());mainGroup.add(ms);particleSystem.push(new Particle(ms,'DUST',true));}}
function createDefaultPhotos(){const c=document.createElement('canvas');c.width=512;c.height=512;const x=c.getContext('2d');x.fillStyle='#050505';x.fillRect(0,0,512,512);x.strokeStyle='#eebb66';x.lineWidth=15;x.strokeRect(20,20,472,472);x.font='500 60px Times New Roman';x.fillStyle='#eebb66';x.textAlign='center';x.fillText("JOYEUX",256,230);x.fillText("NOEL",256,300);createPhotoTexture(c.toDataURL(),'default');}
function createPhotoTexture(b,id){const i=new Image();i.src=b;i.onload=()=>{const t=new THREE.Texture(i);t.colorSpace=THREE.SRGBColorSpace;t.needsUpdate=true;addPhotoToScene(t,id,i);}}
function addPhotoToScene(t,id,imgObj){
const aspect=imgObj.width/imgObj.height; let w=1.2,h=1.2; if(aspect>1)h=w/aspect; else w=h*aspect;
const fg=new THREE.BoxGeometry(w+0.2,h+0.2,0.05); const fm=new THREE.MeshStandardMaterial({color:0xc5a059,metalness:0.6,roughness:0.5,envMapIntensity:0.5}); const f=new THREE.Mesh(fg,fm); const pg=new THREE.PlaneGeometry(w,h); const pm=new THREE.MeshBasicMaterial({map:t}); const p=new THREE.Mesh(pg,pm); p.position.z=0.04; const g=new THREE.Group(); g.add(f); g.add(p); const s=0.8; g.scale.set(s,s,s); photoMeshGroup.add(g); const pt=new Particle(g,'PHOTO',false); pt.photoId=id; pt.texture=t; particleSystem.push(pt);
}
window.applyParticleSettings=()=>{const ph=particleSystem.filter(p=>p.type==='PHOTO');const tr=[];mainGroup.children.forEach(c=>{if(c!==photoMeshGroup)tr.push(c)});tr.forEach(c=>mainGroup.remove(c));particleSystem=[...ph];CONFIG.particles.count=parseInt(document.getElementById('slider-tree').value);CONFIG.particles.dustCount=parseInt(document.getElementById('slider-dust').value);createParticles();createDust();createSnow();};
async function initMediaPipe(){videoElement=document.getElementById('webcam-video');if(navigator.mediaDevices?.getUserMedia){try{const s=await navigator.mediaDevices.getUserMedia({video:true});videoElement.srcObject=s;videoElement.onloadedmetadata=()=>{videoElement.play();renderWebcamPreview()};}catch(e){console.error(e)}}try{const v=await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm");handLandmarker=await HandLandmarker.createFromOptions(v,{baseOptions:{modelAssetPath:`https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,delegate:"GPU"},runningMode:"VIDEO",numHands:1});predictWebcam();}catch(e){console.warn(e)}}
function renderWebcamPreview(){const c=document.getElementById('webcam-canvas'),x=c.getContext('2d',{willReadFrequently:true});function d(){if(videoElement.readyState>=2)x.drawImage(videoElement,0,0,c.width,c.height);requestAnimationFrame(d)}d()}
let lvt=-1;async function predictWebcam(){if(videoElement&&videoElement.currentTime!==lvt&&handLandmarker){lvt=videoElement.currentTime;const r=handLandmarker.detectForVideo(videoElement,performance.now());processGestures(r);document.getElementById('cam-status').classList.toggle('active',r.landmarks.length>0);}requestAnimationFrame(predictWebcam);}
function processGestures(r){if(r.landmarks&&r.landmarks.length>0){STATE.hand.detected=true;const lm=r.landmarks[0];STATE.hand.x=(lm[9].x-0.5)*2;STATE.hand.y=(lm[9].y-0.5)*2;const thumb=lm[4],index=lm[8],wrist=lm[0],middle=lm[12];const pd=Math.hypot(thumb.x-index.x,thumb.y-index.y);const od=Math.hypot(middle.x-wrist.x,middle.y-wrist.y);if(STATE.mode==='FOCUS'){if(pd>0.1)setMode('SCATTER');return;}if(pd<0.05&&STATE.mode!=='FOCUS')triggerPhotoGrab();else if(od>0.4)setMode('SCATTER');else if(od<0.2)setMode('TREE');}else STATE.hand.detected=false;}
window.setupEvents=()=>{
window.addEventListener('resize',()=>{camera.aspect=window.innerWidth/window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth,window.innerHeight);composer.setSize(window.innerWidth,window.innerHeight)});
document.getElementById('file-input').addEventListener('change',e=>{Array.from(e.target.files).forEach(f=>{const r=new FileReader();r.onload=ev=>{const i=new Image();i.src=ev.target.result;i.onload=()=>{const id=savePhotoToDB(ev.target.result);createPhotoTexture(ev.target.result,id)}};r.readAsDataURL(f)})});
document.getElementById('music-input').addEventListener('change',e=>{const f=e.target.files[0];if(f){saveMusicToDB(f);bgmAudio.src=URL.createObjectURL(f);bgmAudio.play().then(()=>{isMusicPlaying=true;updatePlayBtnUI(true)}).catch(console.error)}});
// 键盘交互
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const code = e.code;
const k = e.key.toLowerCase();
if (k === 'h') window.toggleUI();
if (code === 'Space') { e.preventDefault(); setMode('TREE'); }
if (k === 'z') setMode('SCATTER');
if (k === 'x') triggerPhotoGrab();
if (code === 'ArrowUp') manualRotateState.x = -1;
if (code === 'ArrowDown') manualRotateState.x = 1;
if (code === 'ArrowLeft') manualRotateState.y = -1;
if (code === 'ArrowRight') manualRotateState.y = 1;
});
window.addEventListener('keyup', (e) => {
const code = e.code;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code)) manualRotateState = { x: 0, y: 0 };
});
}
window.openDeleteManager=async()=>{document.getElementById('delete-manager').classList.remove('hidden');const g=document.getElementById('photo-grid');g.innerHTML='';const ps=await loadPhotosFromDB();if(!ps||ps.length===0)g.innerHTML='<div style="color:#888;">暂无照片</div>';else ps.forEach(p=>{const d=document.createElement('div');d.className='photo-item';const i=document.createElement('img');i.className='photo-thumb';i.src=p.data;const b=document.createElement('div');b.className='delete-x';b.innerText='X';b.onclick=e=>{e.stopPropagation();confirmDelete(p.id,d)};d.appendChild(i);d.appendChild(b);g.appendChild(d)})}
window.confirmDelete=(id,el)=>{deletePhotoFromDB(id);el.remove();const p=particleSystem.find(pa=>pa.photoId===id);if(p){photoMeshGroup.remove(p.mesh);particleSystem.splice(particleSystem.indexOf(p),1)}}
window.clearAllPhotos=()=>{if(confirm("确定要清空所有照片吗?")){clearPhotosDB();particleSystem.filter(p=>p.type==='PHOTO').forEach(p=>photoMeshGroup.remove(p.mesh));particleSystem=particleSystem.filter(p=>p.type!=='PHOTO');window.openDeleteManager()}}
window.closeDeleteManager=()=>{document.getElementById('delete-manager').classList.add('hidden')}
function animate() {
requestAnimationFrame(animate); const dt=clock.getDelta(); const et=clock.getElapsedTime();
if(snowInstancedMesh&&STATE.mode==='TREE'){
snowInstancedMesh.visible=true;
for(let i=0;i<CONFIG.snow.count;i++){
snowInstancedMesh.getMatrixAt(i,snowDummy.matrix);snowDummy.matrix.decompose(snowDummy.position,snowDummy.quaternion,snowDummy.scale);const d=snowData[i];
snowDummy.position.y-=d.vy*CONFIG.snow.speed*dt;snowDummy.position.x+=Math.sin(et*0.5+i)*2.5*dt;snowDummy.position.z+=Math.cos(et*0.3+i)*1.5*dt;
snowDummy.rotation.x+=d.rx*dt;snowDummy.rotation.y+=d.ry*dt;snowDummy.rotation.z+=d.rz*dt;
if(snowDummy.position.y<-25){snowDummy.position.y=40;snowDummy.position.x=(Math.random()-0.5)*CONFIG.snow.range;snowDummy.position.z=(Math.random()-0.5)*CONFIG.snow.range;}
snowDummy.updateMatrix();snowInstancedMesh.setMatrixAt(i,snowDummy.matrix);
} snowInstancedMesh.instanceMatrix.needsUpdate=true;
} else if(snowInstancedMesh)snowInstancedMesh.visible=false;
// Updated Rotation Logic
if(manualRotateState.x!==0||manualRotateState.y!==0){const s=CONFIG.interaction.rotationSpeed*2.0;STATE.rotation.x+=manualRotateState.x*s*dt;STATE.rotation.y+=manualRotateState.y*s*dt;}
else if(STATE.mode==='SCATTER'&&STATE.hand.detected){const th=0.3,s=CONFIG.interaction.rotationSpeed;if(STATE.hand.x>th)STATE.rotation.y-=s*dt*(STATE.hand.x-th);else if(STATE.hand.x<-th)STATE.rotation.y-=s*dt*(STATE.hand.x+th);if(STATE.hand.y<-th)STATE.rotation.x+=s*dt*(-STATE.hand.y-th);else if(STATE.hand.y>th)STATE.rotation.x-=s*dt*(STATE.hand.y-th);}
else{if(STATE.mode==='TREE'){STATE.rotation.y+=0.3*dt;STATE.rotation.x+=(0-STATE.rotation.x)*2.0*dt;}else STATE.rotation.y+=0.1*dt;}
mainGroup.rotation.y=STATE.rotation.y;mainGroup.rotation.x=STATE.rotation.x;
particleSystem.forEach(p=>p.update(dt,STATE.mode,STATE.focusTarget));
composer.render();
}
init();
</script></body></html>
2. 简易版(仅核心 3D 互动圣诞树效果)

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Grand Luxury Tree - Interactive</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000000;
font-family: 'Times New Roman', serif;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
/* UI Layer optimized for non-blocking */
#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;
justify-content: flex-start;
gap: 6px;
padding-top: 14px;
/* PC端默认间距:顶部停留,避免遮挡树体 */
box-sizing: border-box;
}
.ui-hidden {
opacity: 0;
pointer-events: none !important;
}
/* Visual Hand Cursor */
#hand-cursor {
position: absolute;
width: 30px;
height: 30px;
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 50;
display: none;
/* Hidden by default */
transition: background-color 0.2s, border-color 0.2s, transform 0.1s;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
/* Cursor state when pinching */
#hand-cursor.active {
background-color: rgba(212, 175, 55, 0.9);
border-color: #d4af37;
transform: translate(-50%, -50%) scale(0.8);
box-shadow: 0 0 15px rgba(212, 175, 55, 0.8);
}
/* Loader */
#loader {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.8s ease-out;
}
.loader-text {
color: #d4af37;
font-size: 14px;
letter-spacing: 4px;
margin-top: 20px;
text-transform: uppercase;
font-weight: 100;
}
.spinner {
width: 40px;
height: 40px;
border: 1px solid rgba(212, 175, 55, 0.2);
border-top: 1px solid #d4af37;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Title */
h1 {
color: #fceea7;
font-size: 50px;
margin: 0;
font-weight: 400;
letter-spacing: 6px;
text-shadow: 0 0 50px rgba(252, 238, 167, 0.6);
background: linear-gradient(to bottom, #fff, #eebb66);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-family: 'Cinzel', 'Times New Roman', serif;
opacity: 0.9;
transition: opacity 0.5s ease;
text-align: center;
}
/* Controls */
.upload-wrapper {
margin-top: 4px;
pointer-events: auto;
text-align: center;
transition: opacity 0.5s ease;
}
.upload-btn {
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37;
padding: 10px 25px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 3px;
font-size: 10px;
transition: all 0.4s;
display: inline-block;
backdrop-filter: blur(5px);
}
.upload-btn:hover {
background: #d4af37;
color: #000;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
}
.hint-text {
color: rgba(212, 175, 55, 0.5);
font-size: 9px;
margin-top: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
#file-input {
display: none;
}
/* Webcam Debug (Hidden) */
#webcam-wrapper {
position: absolute;
bottom: 40px;
right: 40px;
width: 120px;
height: 90px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
opacity: 0;
pointer-events: none;
}
/* Mobile Tweaks */
@media (max-width: 768px) {
#ui-layer {
/* 关键修改:贴近顶部,避免遮挡中间的树 */
justify-content: flex-start;
padding-top: max(15px, env(safe-area-inset-top));
gap: 5px;
}
h1 {
font-size: 32px;
letter-spacing: 2px;
line-height: 1.1;
margin-bottom: 0;
}
.upload-wrapper {
margin-top: 5px;
}
.upload-btn {
padding: 8px 18px;
font-size: 9px;
letter-spacing: 2px;
}
.hint-text {
font-size: 8px;
}
}
/* --- Toolbar Styles --- */
#toolbar {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 15px;
z-index: 100;
}
.tool-btn {
width: 40px;
height: 40px;
border: 1px solid rgba(212, 175, 55, 0.6);
border-radius: 50%;
color: #d4af37;
font-size: 18px;
line-height: 1;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-family: 'Times New Roman', serif;
backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
user-select: none;
pointer-events: auto;
}
.tool-btn:hover {
box-shadow: 0 0 15px rgba(212, 175, 55, 0.6);
background: rgba(20, 20, 20, 0.8);
}
.tool-btn.active {
background: #d4af37;
color: #000;
box-shadow: 0 0 10px rgba(212, 175, 55, 0.4);
}
#help-modal {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.4s ease;
opacity: 1;
pointer-events: auto;
}
.modal-hidden {
opacity: 0 !important;
pointer-events: none !important;
}
.modal-content {
width: 80%;
max-width: 400px;
border: 1px solid #d4af37;
padding: 30px;
text-align: center;
background: rgba(20, 20, 20, 0.8);
box-shadow: 0 0 50px rgba(212, 175, 55, 0.2);
border-radius: 4px;
}
.modal-content h2 {
color: #fceea7;
margin: 0 0 20px 0;
font-family: 'Cinzel', serif;
font-size: 24px;
letter-spacing: 2px;
border-bottom: 1px solid rgba(212, 175, 55, 0.3);
padding-bottom: 15px;
}
.instruction-item {
display: flex;
align-items: center;
margin-bottom: 20px;
text-align: left;
color: #ddd;
}
.instruction-item .icon {
font-size: 30px;
margin-right: 20px;
width: 40px;
text-align: center;
}
.instruction-item .text strong {
display: block;
color: #d4af37;
font-size: 16px;
margin-bottom: 4px;
}
.instruction-item .text p {
margin: 0;
font-size: 12px;
color: #888;
line-height: 1.4;
}
#close-modal-btn {
margin-top: 10px;
padding: 10px 30px;
background: transparent;
border: 1px solid #d4af37;
color: #d4af37;
font-family: 'Cinzel', serif;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 2px;
}
#close-modal-btn:hover {
background: #d4af37;
color: #000;
}
</style>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap');
</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="hand-cursor"></div>
<audio
id="bgm"
src="./Christmas.mp3"
preload="auto"
loop
playsinline
style="display: none"
></audio>
<div id="loader">
<div class="spinner"></div>
<div class="loader-text">Loading Holiday Magic...</div>
</div>
<div id="canvas-container"></div>
<div id="ui-layer">
<h1>Merry Christmas</h1>
<div class="upload-wrapper">
<label class="upload-btn">
Add More Photos
<input type="file" id="file-input" multiple accept="image/*" />
</label>
<div class="hint-text">Press 'H' to Hide Controls</div>
<div
id="music-hint"
style="font-size: 8px; color: #666; margin-top: 5px"
>
(Tap screen to play music)
</div>
</div>
</div>
<div id="toolbar">
<div id="help-btn" class="tool-btn" title="Help Guide">?</div>
<div id="cursor-btn" class="tool-btn active" title="Toggle Hand Cursor">
◎
</div>
<div id="snap-btn" class="tool-btn" title="Take Screenshot">📷</div>
</div>
<div id="help-modal" class="modal-hidden">
<div class="modal-content">
<h2>How to Play</h2>
<div class="instruction-item">
<span class="icon">✊</span>
<div class="text">
<strong>Fist (握拳)</strong>
<p>Assemble the Tree<br />召唤圣诞树</p>
</div>
</div>
<div class="instruction-item">
<span class="icon">🖐️</span>
<div class="text">
<strong>Open Hand (张开手)</strong>
<p>Scatter Stars<br />散开变成星空</p>
</div>
</div>
<div class="instruction-item">
<span class="icon">👌</span>
<div class="text">
<strong>Pinch (捏合)</strong>
<p>Focus Photo<br />抓取/放大照片</p>
</div>
</div>
<div class="instruction-item">
<span class="icon">👋</span>
<div class="text">
<strong>Move Hand (移动手)</strong>
<p>Rotate Camera<br />旋转视角</p>
</div>
</div>
<button id="close-modal-btn">Got it</button>
</div>
</div>
<div id="webcam-wrapper">
<video id="webcam" autoplay playsinline style="display: none"></video>
<canvas id="webcam-preview"></canvas>
</div>
<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';
// --- CONFIG & STATE ---
// 请确保 photos 目录下有这些文件,或者修改为您的实际文件名
const DEFAULT_PHOTO_PATHS = [];
const CONFIG = {
colors: {
bg: 0x000000,
champagneGold: 0xffd966,
deepGreen: 0x03180a,
accentRed: 0x990000,
},
particles: {
count: 1500,
dustCount: 2500,
treeHeight: 24,
treeRadius: 8,
},
camera: { z: 50 },
};
const STATE = {
mode: 'TREE',
focusIndex: -1,
focusTarget: null,
hand: { detected: false, x: 0, y: 0 },
rotation: { x: 0, y: 0 },
cursorVisible: true,
// 新增:捏合防抖配置
pinch: {
counter: 0, // 当前连续捏合帧数
threshold: 5, // 必须保持 5 帧才触发 (防抖)
cooldown: 0, // 冷却时间
isPinching: false,
downThreshold: 0.04, // 进入捏合的阈值(更严格)
upThreshold: 0.08, // 退出捏合的阈值(迟滞,避免抖动)
},
};
let scene, camera, renderer, composer, bloomPass;
let mainGroup,
clock = new THREE.Clock();
let particleSystem = [];
let photoMeshGroup = new THREE.Group();
let handLandmarker, video, webcamCanvas, webcamCtx, caneTexture;
let cursorEl;
let raycaster = new THREE.Raycaster(); // 用于精准点击
const textureLoader = new THREE.TextureLoader();
async function init() {
cursorEl = document.getElementById('hand-cursor');
initThree();
setupEnvironment();
setupLights();
createTextures();
createParticles();
createDust();
if (DEFAULT_PHOTO_PATHS.length > 0) await loadDefaultGallery();
else createDefaultPhotos();
setupPostProcessing();
setupEvents();
try {
await initMediaPipe();
} catch (e) {
console.warn('Camera init failed:', e);
}
const loader = document.getElementById('loader');
loader.style.opacity = 0;
setTimeout(() => loader.remove(), 800);
animate();
}
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.01);
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',
preserveDrawingBuffer: true, // enable clean screenshots
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.7; // Lower exposure to avoid blown-out photos
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.6);
scene.add(ambient);
const innerLight = new THREE.PointLight(0xffaa00, 2, 20);
innerLight.position.set(0, 5, 0);
mainGroup.add(innerLight);
const spotGold = new THREE.SpotLight(0xffcc66, 1200);
spotGold.position.set(30, 40, 40);
spotGold.angle = 0.5;
spotGold.penumbra = 0.5;
scene.add(spotGold);
const spotBlue = new THREE.SpotLight(0x6688ff, 600);
spotBlue.position.set(-30, 20, -30);
scene.add(spotBlue);
const fill = new THREE.DirectionalLight(0xffeebb, 0.8);
fill.position.set(0, 0, 50);
scene.add(fill);
}
function setupPostProcessing() {
const renderScene = new RenderPass(scene, camera);
bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.35,
0.8
);
bloomPass.threshold = 0.8;
bloomPass.strength = 0.28;
bloomPass.radius = 0.35;
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 = '#880000';
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 createStarShape(outerRadius, innerRadius, points) {
const shape = new THREE.Shape();
const step = Math.PI / points;
shape.moveTo(0, outerRadius);
for (let i = 0; i < 2 * points; i++) {
const r = i % 2 === 0 ? outerRadius : innerRadius;
const a = i * step;
shape.lineTo(r * Math.sin(a), r * Math.cos(a));
}
shape.closePath();
return shape;
}
// --- PARTICLE SYSTEM ---
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;
const speedMult = type === 'PHOTO' ? 0.3 : 2.0; // PHOTO类型是图片
this.spinSpeed = new THREE.Vector3(
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult,
(Math.random() - 0.5) * speedMult
);
this.calculatePositions();
}
calculatePositions() {
const h = CONFIG.particles.treeHeight;
const halfH = h / 2;
let t = Math.pow(Math.random(), 0.8);
const y = t * h - halfH;
let rMax = Math.max(0.5, CONFIG.particles.treeRadius * (1.0 - t));
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() * 20
: 8 + Math.random() * 12;
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)
);
}
update(dt, mode, focusTargetMesh) {
let target = this.posTree;
if (mode === 'SCATTER') target = this.posScatter;
else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) {
const desiredWorldPos = new THREE.Vector3(0, 2, 35);
const invMatrix = new THREE.Matrix4()
.copy(mainGroup.matrixWorld)
.invert();
target = desiredWorldPos.applyMatrix4(invMatrix);
} else target = this.posScatter;
}
const lerpSpeed =
mode === 'FOCUS' && this.mesh === focusTargetMesh ? 5.0 : 2.0;
this.mesh.position.lerp(target, lerpSpeed * dt);
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,
dt
);
this.mesh.rotation.z = THREE.MathUtils.lerp(
this.mesh.rotation.z,
0,
dt
);
this.mesh.rotation.y += 0.5 * 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(clock.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') {
if (this.mesh === focusTargetMesh) s = 4.5; // 在FOCUS模式下,图片会放大
else s = this.baseScale * 0.8;
}
this.mesh.scale.lerp(new THREE.Vector3(s, s, s), 4 * dt);
}
}
function createParticles() {
const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32);
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, 16, 0.08, 8, false);
const goldMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.champagneGold,
metalness: 1.0,
roughness: 0.1,
envMapIntensity: 2.0,
emissive: 0x443300,
emissiveIntensity: 0.3,
});
const greenMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.deepGreen,
metalness: 0.2,
roughness: 0.8,
emissive: 0x002200,
emissiveIntensity: 0.2,
});
const redMat = new THREE.MeshPhysicalMaterial({
color: CONFIG.colors.accentRed,
metalness: 0.3,
roughness: 0.2,
clearcoat: 1.0,
emissive: 0x330000,
});
const candyMat = new THREE.MeshStandardMaterial({
map: caneTexture,
roughness: 0.4,
});
for (let i = 0; i < CONFIG.particles.count; i++) {
const rand = Math.random();
let mesh, type;
if (rand < 0.4) {
mesh = new THREE.Mesh(boxGeo, greenMat);
type = 'BOX';
} else if (rand < 0.7) {
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 starShape = createStarShape(1.5, 0.7, 5);
const extrudeSettings = {
depth: 0.4,
bevelEnabled: true,
bevelThickness: 0.1,
bevelSize: 0.1,
bevelSegments: 3,
};
const starGeo = new THREE.ExtrudeGeometry(starShape, extrudeSettings);
const starMat = new THREE.MeshStandardMaterial({
color: 0xffdd88,
emissive: 0xffaa00,
emissiveIntensity: 1.0,
metalness: 1.0,
roughness: 0.1,
});
const star = new THREE.Mesh(starGeo, starMat);
// Keep star upright facing camera; slight yaw for depth
star.rotation.set(0, Math.PI / 10, 0);
star.position.set(0, CONFIG.particles.treeHeight / 2 + 1.1, 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 = '#eebb66';
ctx.lineWidth = 15;
ctx.strokeRect(20, 20, 472, 472);
ctx.font = '500 60px Times New Roman';
ctx.fillStyle = '#eebb66';
ctx.textAlign = 'center';
ctx.fillText('Merry', 256, 230);
ctx.fillText('Christmas!', 256, 300);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(tex); // 添加默认照片到场景
}
/**
* 将图片纹理添加到Three.js场景中
* 这是渲染用户上传图片的核心函数
*/
function addPhotoToScene(texture) {
const img = texture.image || {}; // 获取图片信息
const aspect = img.width && img.height ? img.width / img.height : 1; // 计算宽高比
const maxEdge = 1.2;
const photoWidth = aspect >= 1 ? maxEdge : maxEdge * aspect; // 根据宽高比计算实际宽高
const photoHeight = aspect >= 1 ? maxEdge / aspect : maxEdge;
// 创建相框
const frameGeo = new THREE.BoxGeometry(
photoWidth + 0.18, // 相框比图片稍大
photoHeight + 0.18,
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(photoWidth, photoHeight);
const photoMat = new THREE.MeshBasicMaterial({
map: texture, // 将纹理应用到材质上
side: THREE.DoubleSide,
});
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中
photoMeshGroup.add(group);
// 将照片作为粒子添加到粒子系统中,使其能够参与动画
particleSystem.push(new Particle(group, 'PHOTO', false));
}
async function loadDefaultGallery() {
const loadTexture = (path) =>
new Promise((resolve, reject) => {
textureLoader.load(
path,
(t) => resolve(t),
undefined,
(e) => reject(e)
);
});
const results = await Promise.allSettled(
DEFAULT_PHOTO_PATHS.map((p) => loadTexture(p))
);
let successCount = 0;
results.forEach((res, idx) => {
if (res.status === 'fulfilled') {
const texture = res.value;
texture.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(texture);
successCount++;
} else {
console.warn('Missing photo:', DEFAULT_PHOTO_PATHS[idx]);
}
});
if (successCount === 0) createDefaultPhotos();
}
function handleImageUpload(e) {
const files = e.target.files;
if (!files.length) return;
Array.from(files).forEach((f) => {
const reader = new FileReader();
reader.onload = (ev) => {
textureLoader.load(ev.target.result, (t) => {
t.colorSpace = THREE.SRGBColorSpace;
addPhotoToScene(t); // 将上传的图片添加到场景
});
};
reader.readAsDataURL(f);
});
}
// --- MEDIAPIPE LOGIC ---
async function initMediaPipe() {
video = document.getElementById('webcam');
webcamCanvas = document.getElementById('webcam-preview');
webcamCtx = webcamCanvas.getContext('2d');
webcamCanvas.width = 160;
webcamCanvas.height = 120;
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm'
);
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: 'GPU',
},
runningMode: 'VIDEO',
numHands: 1,
});
if (navigator.mediaDevices?.getUserMedia) {
const constraints = {
video: {
facingMode: 'user',
width: { ideal: 640 },
height: { ideal: 480 },
},
};
try {
const stream = await navigator.mediaDevices.getUserMedia(
constraints
);
video.srcObject = stream;
video.onloadedmetadata = () => {
video.play();
predictWebcam();
};
} catch (err) {
console.error('Camera Error:', err);
}
}
}
let lastVideoTime = -1;
async function predictWebcam() {
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
if (handLandmarker) {
const result = handLandmarker.detectForVideo(
video,
performance.now()
);
processGestures(result);
}
}
requestAnimationFrame(predictWebcam);
}
function processGestures(result) {
// 1. Cooldown logic
if (STATE.pinch.cooldown > 0) STATE.pinch.cooldown--;
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;
// 更新光标位置 (将 -1..1 映射到屏幕像素)
updateCursor(STATE.hand.x, STATE.hand.y, true);
const thumb = lm[4];
const index = lm[8];
const wrist = lm[0];
const pinchDist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
const tips = [lm[8], lm[12], lm[16], lm[20]];
let avgDist = 0;
tips.forEach(
(t) => (avgDist += Math.hypot(t.x - wrist.x, t.y - wrist.y))
);
avgDist /= 4;
// --- 改进的捏合判定逻辑 (防抖 + 迟滞) ---
if (pinchDist < STATE.pinch.downThreshold) {
STATE.pinch.counter++; // 累积捏合帧数
cursorEl.classList.add('active'); // 光标变色提示
// 只有当持续捏合超过阈值,且不在冷却期,才真正触发
if (
STATE.pinch.counter > STATE.pinch.threshold &&
STATE.pinch.cooldown <= 0
) {
STATE.pinch.isPinching = true;
if (STATE.mode !== 'FOCUS') {
triggerFocusMode();
}
STATE.pinch.cooldown = 60; // 冷却 60 帧 (约1秒)
STATE.pinch.counter = 0; // 重置计数
}
} else if (pinchDist > STATE.pinch.upThreshold) {
// 松开到足够远,退出捏合状态
STATE.pinch.counter = 0;
STATE.pinch.isPinching = false;
cursorEl.classList.remove('active');
}
// 其他手势
if (avgDist < 0.25 && STATE.pinch.counter === 0) {
STATE.mode = 'TREE';
STATE.focusTarget = null;
} else if (avgDist > 0.4 && STATE.pinch.counter === 0) {
STATE.mode = 'SCATTER';
STATE.focusTarget = null;
}
} else {
STATE.hand.detected = false;
STATE.pinch.counter = 0;
STATE.pinch.isPinching = false;
cursorEl.classList.remove('active');
updateCursor(0, 0, false);
}
}
function updateCursor(x, y, visible) {
if (!visible || !STATE.cursorVisible) {
cursorEl.style.display = 'none';
return;
}
cursorEl.style.display = 'block';
// x, y 都是 -1 到 1。MediaPipe 的 x 也是镜像的。
// 屏幕坐标映射:
// x: -1(右) -> 1(左) (因为是自拍镜像) -> 实际屏幕 left: (1 - x)/2 * width
// y: -1(上) -> 1(下) -> 实际屏幕 top: (y + 1)/2 * height
// 注意:我们在 processGestures 里算的 x = (lm.x - 0.5)*2 已经是 -1(右) 到 1(左) 了
// 咱们反算回 0..1
const screenX = ((x * -1 + 1) / 2) * window.innerWidth;
const screenY = ((y + 1) / 2) * window.innerHeight;
cursorEl.style.left = `${screenX}px`;
cursorEl.style.top = `${screenY}px`;
}
function triggerFocusMode() {
STATE.mode = 'FOCUS';
// --- 核心优化:Raycasting 精准拾取 ---
// 1. 将手势坐标转换为 Three.js 的 NDC 坐标 (-1 到 1)
// MediaPipe Hand X: -1 (Right) to 1 (Left)
// ThreeJS Camera X: -1 (Left) to 1 (Right) -> 也就是 -HandX
// MediaPipe Hand Y: -1 (Top) to 1 (Bottom)
// ThreeJS Camera Y: 1 (Top) to -1 (Bottom) -> 也就是 -HandY
raycaster.setFromCamera(
new THREE.Vector2(-STATE.hand.x, -STATE.hand.y),
camera
);
// 2. 检测射线是否击中照片组
const intersects = raycaster.intersectObjects(
photoMeshGroup.children,
true
);
if (intersects.length > 0) {
// 找到了特定照片
// 我们的结构是 Group -> [Frame, PhotoMesh]
// 射线击中的是子 Mesh,需要向上找到它的父级 Group
let targetObj = intersects[0].object;
if (targetObj.parent && targetObj.parent.isGroup) {
STATE.focusTarget = targetObj.parent;
console.log('Selected specific photo!');
return;
}
}
// 3. 兜底:如果没抓到任何照片,随机选一张 (防止用户产生挫败感)
const photos = particleSystem.filter((p) => p.type === 'PHOTO');
if (photos.length) {
STATE.focusTarget =
photos[Math.floor(Math.random() * photos.length)].mesh;
}
}
function setupEvents() {
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// Upload
document
.getElementById('file-input')
.addEventListener('change', handleImageUpload);
// Music
const bgm = document.getElementById('bgm');
const musicHint = document.getElementById('music-hint');
const tryPlay = () =>
bgm
.play()
.then(() => {
if (musicHint) musicHint.style.display = 'none';
})
.catch(() => {});
tryPlay();
window.addEventListener('click', tryPlay, { once: true });
window.addEventListener('touchstart', tryPlay, { once: true });
// Toggle UI
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'h') {
const controls = document.querySelector('.upload-wrapper');
if (controls) controls.classList.toggle('ui-hidden');
}
});
// --- Toolbar logic ---
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const closeBtn = document.getElementById('close-modal-btn');
const cursorBtn = document.getElementById('cursor-btn');
const snapBtn = document.getElementById('snap-btn');
// Help
helpBtn.addEventListener('click', (e) => {
e.stopPropagation();
helpModal.classList.remove('modal-hidden');
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
helpModal.classList.add('modal-hidden');
});
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) helpModal.classList.add('modal-hidden');
});
// Cursor toggle
cursorBtn.addEventListener('click', (e) => {
e.stopPropagation();
STATE.cursorVisible = !STATE.cursorVisible;
cursorBtn.classList.toggle('active', STATE.cursorVisible);
if (!STATE.cursorVisible) cursorEl.style.display = 'none';
});
// Screenshot
snapBtn.addEventListener('click', (e) => {
e.stopPropagation();
takeScreenshot();
});
}
function takeScreenshot() {
// Render latest frame to buffer
composer.render();
// Base WebGL capture
const baseDataURL = renderer.domElement.toDataURL('image/png');
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
// Draw WebGL frame
ctx.drawImage(img, 0, 0);
// Overlay title only (skip buttons/hints)
const titleEl = document.querySelector('#ui-layer h1');
const title = titleEl?.innerText?.trim() || 'Merry Christmas';
if (title) {
const fontSize = Math.max(48, Math.min(120, canvas.width * 0.045));
ctx.font = `${fontSize}px "Cinzel", "Times New Roman", serif`;
ctx.fillStyle = 'rgba(252, 238, 167, 0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
ctx.shadowBlur = fontSize * 0.2;
ctx.fillText(title, canvas.width / 2, fontSize * 0.35);
ctx.shadowBlur = 0;
}
const finalURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
link.download = `Christmas-Memory-${timestamp}.png`;
link.href = finalURL;
link.click();
};
img.src = baseDataURL;
}
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
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) * 3.0 * dt;
STATE.rotation.x += (targetRotX - STATE.rotation.x) * 3.0 * dt;
} else {
if (STATE.mode === 'TREE') {
STATE.rotation.y += 0.3 * dt;
STATE.rotation.x += (0 - STATE.rotation.x) * 2.0 * dt;
} else {
STATE.rotation.y += 0.1 * dt;
}
}
mainGroup.rotation.y = STATE.rotation.y;
mainGroup.rotation.x = STATE.rotation.x;
// Soften bloom when focusing photos to keep images readable
bloomPass.strength = STATE.mode === 'FOCUS' ? 0.16 : 0.28;
particleSystem.forEach((p) =>
p.update(dt, STATE.mode, STATE.focusTarget)
);
composer.render();
}
init();
</script>
</body>
</html>
二、 代码运行流程
1、Windows系统
①复制代码
②桌面创建"文本文档"或"记事本"并打开
③粘贴代码,保存并退出
④右键-重命名-改后缀"txt"为"html"
⑤用以下浏览器打开(优先级:谷歌chrome --- edge --- 联想浏览器)
⑥允许摄像头访问权限,检查手势交互及其他功能是否正常运行
2、Macbook
①复制代码
②打开文本编辑器,点击新建文稿
③点击左上角"格式"---点击"制作纯文本"
④粘贴代码并保存--- 存储为:自命名以**.html为后缀**
⑤用以下浏览器打开(优先级:谷歌chrome --- Safari)
⑥允许摄像头访问权限,检查手势交互及其他功能是否正常运行
3、其他操作(不懂网上搜) 如果浏览器打不开,可以用VS code创建一个文本文件,然后再改成html格式即可。
制作不易,网上很多收费几块的,有帮助的话随便给我扫点,5毛都行哈哈哈,听个响我会开心很久!!
有问题评论区dd或者抖音私信,感谢阅览。
