3D 圣诞树网页代码

一、两版圣诞树代码

我这里调了有两版代码,两款 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或者抖音私信,感谢阅览。

相关推荐
春日见4 小时前
眼在手上外参标定保姆级教学(vscode + opencv)
linux·运维·服务器·数码相机·opencv·ubuntu·3d
一晌小贪欢7 小时前
【圣诞快乐 Merry Christmas】 3D 粒子变形圣诞体验
3d·html·h5·html5·圣诞网页·粒子虚幻·虚幻粒子页面
暴风鱼划水10 小时前
三维重建【5】3D Gaussian Splatting:3R-GS论文解读
3d·3dgs·高斯泼溅·sfm
GIS数据转换器1 天前
空天地一体化边坡监测及安全预警系统
大数据·人工智能·安全·机器学习·3d·无人机
野区捕龙为宠1 天前
unity 实现3D空间音效特性
3d·unity·游戏引擎
CreasyChan1 天前
unity射线与几何检测 - “与世界的交互”
算法·游戏·3d·unity·数学基础
"YOUDIG"2 天前
从算法到3D美学——一站式生成个性化手办风格照片
算法·3d
sbjdhjd3 天前
开源分享 | 超浪漫 3D 圣诞树立体动画(附零基础使用教程)
3d·青少年编程·开源·html·节日
lrh30253 天前
Custom SRP - 16 Render Scale
3d·unity·srp·render pipeline·render scale