Created
December 23, 2025 19:24
-
-
Save feynlee/7236fbbe977d8b21d5ebadf07b84249e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Grand Luxury Tree v16.4 (Final Layout)</title> | |
| <style> | |
| /* --- Base styles --- */ | |
| body { margin: 0; overflow: hidden; background-color: #000000; font-family: 'Songti SC', 'SimSun', serif; } | |
| #canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; } | |
| /* Import curated artistic fonts */ | |
| @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'); | |
| /* --- UI layer --- */ | |
| #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; } | |
| /* Hidden/transition classes */ | |
| .panel-hidden { opacity: 0 !important; pointer-events: none !important; transform: translateX(-20px); } | |
| .hidden { display: none !important; } | |
| /* Button styles */ | |
| .elegant-btn { | |
| background: rgba(10, 10, 10, 0.85); border: 1px solid rgba(212, 175, 55, 0.4); | |
| color: #d4af37; padding: 8px 12px; cursor: pointer; | |
| text-transform: uppercase; letter-spacing: 1px; font-size: 11px; | |
| transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; | |
| backdrop-filter: blur(4px); text-decoration: none; min-width: 100px; | |
| font-family: 'Microsoft YaHei', sans-serif; pointer-events: auto; border-radius: 2px; | |
| box-sizing: border-box; | |
| } | |
| .elegant-btn:hover { background: #d4af37; color: #000; border-color: #d4af37; } | |
| .elegant-btn input[type="file"] { display: none !important; } | |
| .btn-red { border-color: #844; color: #eaa; } | |
| .btn-red:hover { background: #922; color: #fff; } | |
| #play-pause-btn { width: 100%; margin-bottom: 6px; font-weight: bold; border-color: #d4af37; padding: 8px; } | |
| /* --- Title (draggable) --- */ | |
| #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); | |
| } | |
| /* --- Left sidebar container (V16.4: auto alignment) --- */ | |
| #left-sidebar { | |
| position: absolute; top: 20px; left: 20px; width: 200px; | |
| display: flex; flex-direction: column; gap: 10px; /* Spacing between panel and gesture guide */ | |
| pointer-events: none; /* Container doesn't block; inner elements enable pointer events */ | |
| z-index: 20; | |
| } | |
| /* --- Top-left: settings panel (inside sidebar) --- */ | |
| .top-left-panel { | |
| pointer-events: auto; display: flex; flex-direction: column; gap: 8px; | |
| background: rgba(5, 5, 5, 0.8); padding: 12px; border-radius: 4px; | |
| border: 1px solid rgba(212, 175, 55, 0.2); backdrop-filter: blur(8px); | |
| transition: all 0.4s ease; | |
| font-family: 'Microsoft YaHei', sans-serif; | |
| width: 100%; box-sizing: border-box; /* Fill the sidebar width */ | |
| } | |
| .panel-header { color: #888; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; margin-bottom: 2px; font-size: 10px; letter-spacing: 1px; } | |
| .control-row { display: flex; flex-direction: column; gap: 3px; } | |
| .control-label { color: #aaa; font-size: 10px; } | |
| .input-dark { | |
| background: rgba(255,255,255,0.05); border: 1px solid #444; color: #eebb66; | |
| padding: 4px 6px; font-size: 11px; outline: none; transition: 0.2s; border-radius: 2px; | |
| } | |
| .input-dark:focus { border-color: #d4af37; background: rgba(255,255,255,0.1); } | |
| input[type="color"] { -webkit-appearance: none; border: none; width: 100%; height: 20px; cursor: pointer; padding: 0; background: none; } | |
| .slider { -webkit-appearance: none; width: 100%; height: 2px; background: rgba(255,255,255,0.15); outline: none; margin-top: 4px; } | |
| .slider::-webkit-slider-thumb { -webkit-appearance: none; width: 10px; height: 10px; background: #d4af37; border-radius: 50%; cursor: pointer; border: 1px solid #000; } | |
| select.input-dark { cursor: pointer; appearance: none; } | |
| select.input-dark option { background: #000; color: #d4af37; padding: 5px; } | |
| /* --- Gesture guide (V16.4: grid layout) --- */ | |
| .left-gesture-panel { | |
| pointer-events: none; opacity: 0.9; transition: all 0.4s ease; | |
| display: grid; grid-template-columns: 1fr 1fr; /* Two columns */ | |
| gap: 8px; width: 100%; box-sizing: border-box; | |
| } | |
| .gesture-item { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| background: rgba(10, 10, 10, 0.6); border: 1px solid rgba(212,175,55,0.3); | |
| border-radius: 4px; padding: 8px 4px; | |
| color: #d4af37; font-family: 'Microsoft YaHei', sans-serif; font-size: 10px; | |
| } | |
| .gesture-icon { font-size: 18px; margin-bottom: 2px; filter: drop-shadow(0 0 5px rgba(212,175,55,0.5)); } | |
| .gesture-text { opacity: 0.8; letter-spacing: 1px; } | |
| /* --- Bottom-left: media controls --- */ | |
| .bottom-left-panel { | |
| position: absolute; bottom: 20px; left: 20px; | |
| pointer-events: auto; display: flex; flex-direction: column; gap: 8px; | |
| transition: all 0.4s ease; width: 140px; | |
| } | |
| .bottom-left-panel .elegant-btn { width: 100%; } | |
| .bottom-left-panel .elegant-btn.bl-action { justify-content: flex-start; text-align: left; } | |
| /* Language toggle (EN / ZH) */ | |
| .lang-toggle { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| text-transform: none; | |
| } | |
| /* Don't use the global hover highlight for the language toggle */ | |
| .elegant-btn.lang-toggle:hover { | |
| background: rgba(10, 10, 10, 0.85); | |
| color: #d4af37; | |
| border-color: rgba(212, 175, 55, 0.4); | |
| } | |
| .lang-toggle .lang-tag { | |
| font-size: 11px; | |
| letter-spacing: 1px; | |
| opacity: 0.65; | |
| color: rgba(212,175,55,0.85); | |
| flex: 0 0 auto; | |
| } | |
| .lang-toggle .lang-tag.active { opacity: 1.0; color: #d4af37; } | |
| .lang-toggle .toggle-switch { position: relative; width: 42px; height: 18px; flex: 0 0 auto; } | |
| .lang-toggle .toggle-switch input { opacity: 0; width: 0; height: 0; } | |
| .lang-toggle .toggle-track { | |
| position: absolute; inset: 0; | |
| background: rgba(255,255,255,0.10); | |
| border: 1px solid rgba(212,175,55,0.35); | |
| border-radius: 999px; | |
| transition: 0.2s; | |
| } | |
| .lang-toggle .toggle-track:before { | |
| content: ""; | |
| position: absolute; | |
| width: 14px; height: 14px; | |
| left: 2px; top: 1px; | |
| background: #d4af37; | |
| border-radius: 50%; | |
| box-shadow: 0 0 6px rgba(212,175,55,0.5); | |
| transition: 0.2s; | |
| } | |
| .lang-toggle .toggle-switch input:checked + .toggle-track:before { transform: translateX(22px); } | |
| /* --- Top-right: view controls --- */ | |
| #top-right-controls { | |
| position: absolute; top: 20px; right: 20px; | |
| pointer-events: auto; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; z-index: 50; | |
| } | |
| #top-right-controls .elegant-btn { | |
| width: 180px; | |
| justify-content: flex-start; | |
| text-align: left; | |
| } | |
| /* --- Bottom-right: camera --- */ | |
| #webcam-wrapper { | |
| position: absolute; bottom: 20px; right: 20px; | |
| width: 180px; height: 135px; | |
| border: 1px solid rgba(212, 175, 55, 0.5); border-radius: 4px; | |
| box-shadow: 0 0 15px rgba(0, 0, 0, 0.8); | |
| 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; | |
| } | |
| /* Webcam view: black background + skeleton only */ | |
| #cam-status { display: none; } | |
| #cam-status.active { background: #00ff00; box-shadow: 0 0 6px #00ff00; } | |
| /* --- Modal --- */ | |
| #delete-manager { | |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| background: rgba(0,0,0,0.9); z-index: 60; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| pointer-events: auto; backdrop-filter: blur(4px); | |
| } | |
| #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.2); margin: 15px 0; background: rgba(20,20,20,0.4); | |
| border-radius: 4px; | |
| } | |
| .photo-item { width: 80px; height: 80px; position: relative; border: 1px solid #d4af37; transition: 0.1s; } | |
| .photo-item:hover { transform: scale(1.05); border-color: #fff; } | |
| .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 --- */ | |
| #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.4s ease-out; | |
| } | |
| .spinner { width: 40px; height: 40px; border: 1px solid rgba(212, 175, 55, 0.1); border-top: 1px solid #d4af37; border-radius: 50%; animation: spin 0.8s linear infinite; } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| .loader-text { color: #d4af37; font-size: 12px; letter-spacing: 3px; margin-top: 20px; font-family: 'Cinzel', serif; } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: #111; } | |
| ::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; } | |
| </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">V16.4 READY</div></div> | |
| <div id="canvas-container"></div> | |
| <div id="title-container" class="hidden"> | |
| <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" id="fs-btn" onclick="toggleFullScreen()">⛶ Fullscreen</button> | |
| <button class="elegant-btn" id="toggle-ui-btn" onclick="toggleUI()">👁 Hide UI</button> | |
| </div> | |
| <div id="left-sidebar"> | |
| <div class="top-left-panel"> | |
| <div class="panel-header" id="hdr-media-control" style="margin-top:8px;">Media</div> | |
| <button class="elegant-btn" id="play-pause-btn" onclick="toggleMusicPlay()">▶ Play Music</button> | |
| <div class="control-row"> | |
| <span class="control-label" id="lbl-volume">Volume: <span id="val-vol">50</span>%</span> | |
| <input type="range" min="0" max="100" value="50" class="slider" id="slider-volume" oninput="updateVolume(this.value)"> | |
| </div> | |
| <div class="panel-header" id="hdr-particle-system" style="margin-top:8px;">Particles</div> | |
| <div class="control-row"> | |
| <span class="control-label" id="lbl-tree-density">Ornament Density</span> | |
| <input type="range" min="500" max="6000" value="3200" class="slider" id="slider-tree"> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label" id="lbl-dust-density">Sparkle Density</span> | |
| <input type="range" min="500" max="5000" value="2500" class="slider" id="slider-dust"> | |
| </div> | |
| <button class="elegant-btn" id="btn-rebuild-scene" style="width:100%; margin-top:5px;" onclick="applyParticleSettings()">⚡ Rebuild Scene</button> | |
| </div> | |
| <div class="left-gesture-panel"> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">✊</div> | |
| <div class="gesture-text" id="gest-assemble">Assemble</div> | |
| </div> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">✋</div> | |
| <div class="gesture-text" id="gest-scatter">Scatter</div> | |
| </div> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">👌</div> | |
| <div class="gesture-text" id="gest-focus">Grab</div> | |
| </div> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">👍</div> | |
| <div class="gesture-text" id="gest-text-en">Text (EN)</div> | |
| </div> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">✌️</div> | |
| <div class="gesture-text" id="gest-text-cn">Text (中文)</div> | |
| </div> | |
| <div class="gesture-item"> | |
| <div class="gesture-icon">↔</div> | |
| <div class="gesture-text" id="gest-rotate">Rotate</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bottom-left-panel"> | |
| <label class="elegant-btn bl-action" id="btn-upload-photos-label"> | |
| <span id="btn-upload-photos-text">+ Upload Photos</span> | |
| <input type="file" id="file-input" multiple accept="image/*"> | |
| </label> | |
| <button class="elegant-btn bl-action" id="btn-photo-library" onclick="openDeleteManager()">▣ Photo Library</button> | |
| <label class="elegant-btn bl-action" id="music-upload-label"> | |
| <span id="btn-upload-music-text">♫ Change Music</span> | |
| <input type="file" id="music-input" accept=".mp3,audio/mpeg"> | |
| </label> | |
| <button class="elegant-btn bl-action" id="toggle-cam-btn" onclick="toggleCameraDisplay()">📷 Camera</button> | |
| <label class="elegant-btn lang-toggle" id="lang-toggle-wrap" title="UI Language"> | |
| <span class="lang-tag active" id="lang-tag-en">EN</span> | |
| <span class="toggle-switch"> | |
| <input type="checkbox" id="lang-toggle-switch" onchange="setLanguageFromToggle(this.checked)"> | |
| <span class="toggle-track"></span> | |
| </span> | |
| <span class="lang-tag" id="lang-tag-zh">中文</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div id="gesture-hint" style="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;"> | |
| Initializing... | |
| </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" id="dm-title">Photo Library</div> | |
| <div id="photo-grid"></div> | |
| <div class="manager-actions"> | |
| <button class="elegant-btn btn-red" id="dm-clear-all" onclick="clearAllPhotos()">Clear All</button> | |
| <button class="elegant-btn" id="dm-close" onclick="closeDeleteManager()">Close</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, GestureRecognizer } from '@mediapipe/tasks-vision'; | |
| const CONFIG = { | |
| colors: { bg: 0x000000, champagneGold: 0xffd966, deepGreen: 0x03180a, accentRed: 0x990000 }, | |
| particles: { count: 3200, dustCount: 2500, treeHeight: 24, treeRadius: 8 }, | |
| camera: { z: 60 }, | |
| interaction: { rotationSpeed: 1.4, grabRadius: 0.55 } | |
| }; | |
| const STATE = { | |
| mode: 'TREE', focusTarget: null, | |
| focusType: 0, | |
| hand: { detected: false, x: 0, y: 0, z: 0, scale: 0 }, | |
| rotation: { x: 0, y: 0 }, | |
| uiVisible: true, cameraVisible: true, | |
| scatterZoom: { active: false, baseZ: 0, refHandZ: null }, | |
| gesture: { | |
| current: 'TREE', candidate: 'TREE', stableFrames: 0, displayLines: [], lastLandmarks: null, | |
| pinch: { active: false, onFrames: 0, offFrames: 0, ratio: 1, dist: 1 }, | |
| prevHadHand: false, | |
| fistActive: false, | |
| thumbFlick: { active: false, phase: 'IDLE', rest: 'IN', start: 0, minAbsDx: 0, maxAbsDx: 0, lastTrigger: 0 } | |
| }, | |
| textPoints: [], | |
| textVariant: 'EN', // 'EN' (Merry/Christmas) or 'CN' (Chinese "Merry Christmas") | |
| fireworks: { enabled: false, until: 0, lastSpawn: 0 }, | |
| // Startup intro animation disabled (no spiral stream-in / settle-in sequence) | |
| intro: { active: false, start: 0, ran: true } | |
| }; | |
| const FONT_STYLES = { | |
| 'style1': { font: "'Ma Shan Zheng', cursive", spacing: "4px", shadow: "2px 2px 8px rgba(180,50,50,0.8)", transform: "none", weight: "normal" }, | |
| 'style2': { font: "'Cinzel', serif", spacing: "6px", shadow: "0 0 20px rgba(255,215,0,0.5)", transform: "uppercase", weight: "700" }, | |
| 'style3': { font: "'Great Vibes', cursive", spacing: "1px", shadow: "0 0 15px rgba(255,200,255,0.7)", transform: "none", weight: "normal" }, | |
| 'style4': { font: "'Monoton', cursive", spacing: "1px", shadow: "0 0 10px #fff, 0 0 20px #f0f", transform: "uppercase", weight: "normal" }, | |
| 'style5': { font: "'Abril Fatface', cursive", spacing: "0px", shadow: "0 5px 15px rgba(0,0,0,0.8)", transform: "none", weight: "normal" } | |
| }; | |
| // --- UI Language (EN / ZH) --- | |
| const UI_I18N = { | |
| en: { | |
| fsEnter: "⛶ Fullscreen", | |
| fsExit: "⛶ Exit Fullscreen", | |
| uiHide: "👁 Hide UI", | |
| uiShow: "👁 Show UI", | |
| hdrCustomText: "Custom Text", | |
| placeholderLine1: "Line 1 text", | |
| placeholderLine2: "Line 2 text", | |
| lblFontStyle: "Font Style (5 curated)", | |
| fontOpt1: "Calligraphy (Ma Shan Zheng)", | |
| fontOpt2: "Classic Serif (Cinzel)", | |
| fontOpt3: "Elegant Script (Great Vibes)", | |
| fontOpt4: "Neon Lines (Monoton)", | |
| fontOpt5: "Retro Bold (Abril Fatface)", | |
| lblFontSize: "Font Size", | |
| lblFontColor: "Font Color", | |
| hdrMedia: "Media", | |
| musicPlay: "▶ Play Music", | |
| musicPause: "❚❚ Pause Music", | |
| volumePrefix: "Volume:", | |
| hdrParticles: "Particles", | |
| lblTreeDensity: "Ornament Density", | |
| lblDustDensity: "Sparkle Density", | |
| rebuildScene: "⚡ Rebuild Scene", | |
| uploadPhotos: "+ Upload Photos", | |
| photoLibrary: "▣ Photo Library", | |
| changeMusic: "♫ Change Music", | |
| camera: "📷 Camera", | |
| langToChinese: "🌐 中文", | |
| langToEnglish: "🌐 English", | |
| dmTitle: "Photo Library", | |
| dmClearAll: "Clear All", | |
| dmClose: "Close", | |
| dmNoPhotos: "No photos", | |
| confirmClearAll: "Clear all photos?", | |
| alertUploadMusic: "Please upload music first.", | |
| hintInitializing: "Initializing...", | |
| hintCameraDenied: "Camera access denied", | |
| hintAiReady: "Hand tracking ready", | |
| hintAiFailed: "AI failed", | |
| hintWaiting: "Waiting for hand...", | |
| overlayDetecting: "Detecting...", | |
| overlayNoHand: "No hand", | |
| statusTree: "Mode: Tree", | |
| statusScatter: "Mode: Scatter", | |
| statusFocus: "Mode: Focus", | |
| statusTextEN: "Mode: Text (👍)", | |
| statusTextCN: "Mode: Chinese Text (✌)", | |
| gestAssemble: "Assemble", | |
| gestScatter: "Scatter", | |
| gestFocus: "Focus", | |
| gestRotate: "Rotate", | |
| gestTextEN: "Text (EN)", | |
| gestTextCN: "Text (中文)", | |
| gestTheme: "Thumb Flick" | |
| }, | |
| zh: { | |
| fsEnter: "⛶ 全屏显示", | |
| fsExit: "⛶ 退出全屏", | |
| uiHide: "👁 隐藏界面", | |
| uiShow: "👁 显示界面", | |
| hdrCustomText: "自定义文本", | |
| placeholderLine1: "第一行文字", | |
| placeholderLine2: "第二行文字", | |
| lblFontStyle: "字体样式 (5种精选)", | |
| fontOpt1: "书法韵味 (Ma Shan Zheng)", | |
| fontOpt2: "古典衬线 (Cinzel)", | |
| fontOpt3: "优雅手写 (Great Vibes)", | |
| fontOpt4: "艺术线条 (Monoton)", | |
| fontOpt5: "复古重磅 (Abril Fatface)", | |
| lblFontSize: "字体大小", | |
| lblFontColor: "字体颜色", | |
| hdrMedia: "媒体控制", | |
| musicPlay: "▶ 播放音乐", | |
| musicPause: "❚❚ 暂停音乐", | |
| volumePrefix: "音量:", | |
| hdrParticles: "粒子系统", | |
| lblTreeDensity: "装饰密度", | |
| lblDustDensity: "星尘密度", | |
| rebuildScene: "⚡ 重建场景", | |
| uploadPhotos: "+ 上传照片", | |
| photoLibrary: "▣ 照片库", | |
| changeMusic: "♫ 更换音乐", | |
| camera: "📷 摄像头", | |
| langToChinese: "🌐 中文", | |
| langToEnglish: "🌐 English", | |
| dmTitle: "照片库管理", | |
| dmClearAll: "清空所有", | |
| dmClose: "关闭", | |
| dmNoPhotos: "暂无照片", | |
| confirmClearAll: "确定要清空所有照片吗?", | |
| alertUploadMusic: "请先在左下角上传音乐", | |
| hintInitializing: "正在初始化系统...", | |
| hintCameraDenied: "摄像头权限被拒绝", | |
| hintAiReady: "手势识别就绪", | |
| hintAiFailed: "AI 加载失败", | |
| hintWaiting: "等待手势...", | |
| overlayDetecting: "识别中...", | |
| overlayNoHand: "无手", | |
| statusTree: "状态: 聚合 (圣诞树)", | |
| statusScatter: "状态: 散开 (星云)", | |
| statusFocus: "状态: 抓取 / 聚焦", | |
| statusTextEN: "状态: 文字成形 (👍)", | |
| statusTextCN: "状态: 中文文字成形 (✌)", | |
| gestAssemble: "聚合", | |
| gestScatter: "散开", | |
| gestFocus: "抓取", | |
| gestRotate: "旋转", | |
| gestTextEN: "英文文字", | |
| gestTextCN: "中文文字", | |
| gestTheme: "拇指横甩" | |
| } | |
| }; | |
| let UI_LANG = (localStorage.getItem('ui_lang') || 'en').toLowerCase(); | |
| if (UI_LANG !== 'en' && UI_LANG !== 'zh') UI_LANG = 'en'; | |
| function uiT(key) { | |
| const dict = UI_I18N[UI_LANG] || UI_I18N.en; | |
| return dict[key] ?? UI_I18N.en[key] ?? key; | |
| } | |
| function applyUILanguage(lang = UI_LANG) { | |
| UI_LANG = (lang || 'en').toLowerCase(); | |
| if (UI_LANG !== 'en' && UI_LANG !== 'zh') UI_LANG = 'en'; | |
| localStorage.setItem('ui_lang', UI_LANG); | |
| document.documentElement.lang = (UI_LANG === 'zh') ? 'zh-CN' : 'en'; | |
| // Top-right buttons | |
| const fsBtn = document.getElementById('fs-btn'); | |
| if (fsBtn) fsBtn.innerText = document.fullscreenElement ? uiT('fsExit') : uiT('fsEnter'); | |
| const uiBtn = document.getElementById('toggle-ui-btn'); | |
| if (uiBtn) uiBtn.innerText = STATE.uiVisible ? uiT('uiHide') : uiT('uiShow'); | |
| // Panels/labels | |
| const setText = (id, key) => { const el = document.getElementById(id); if (el) el.innerText = uiT(key); }; | |
| setText('hdr-custom-text', 'hdrCustomText'); | |
| setText('lbl-font-style', 'lblFontStyle'); | |
| setText('lbl-font-size', 'lblFontSize'); | |
| setText('lbl-font-color', 'lblFontColor'); | |
| setText('hdr-media-control', 'hdrMedia'); | |
| setText('hdr-particle-system', 'hdrParticles'); | |
| setText('lbl-tree-density', 'lblTreeDensity'); | |
| setText('lbl-dust-density', 'lblDustDensity'); | |
| setText('btn-rebuild-scene', 'rebuildScene'); | |
| // Placeholders | |
| const in1 = document.getElementById('input-line1'); if (in1) in1.placeholder = uiT('placeholderLine1'); | |
| const in2 = document.getElementById('input-line2'); if (in2) in2.placeholder = uiT('placeholderLine2'); | |
| // Font options | |
| const fontSel = document.getElementById('font-select'); | |
| if (fontSel) { | |
| Array.from(fontSel.options).forEach(opt => { | |
| if (opt.value === 'style1') opt.textContent = uiT('fontOpt1'); | |
| if (opt.value === 'style2') opt.textContent = uiT('fontOpt2'); | |
| if (opt.value === 'style3') opt.textContent = uiT('fontOpt3'); | |
| if (opt.value === 'style4') opt.textContent = uiT('fontOpt4'); | |
| if (opt.value === 'style5') opt.textContent = uiT('fontOpt5'); | |
| }); | |
| } | |
| // Volume label | |
| const vol = document.getElementById('val-vol'); | |
| const lblVol = document.getElementById('lbl-volume'); | |
| if (lblVol) { | |
| const v = vol ? vol.innerText : '50'; | |
| lblVol.innerHTML = `${uiT('volumePrefix')} <span id="val-vol">${v}</span>%`; | |
| } | |
| // Bottom-left actions | |
| const up = document.getElementById('btn-upload-photos-text'); if (up) up.innerText = uiT('uploadPhotos'); | |
| const pl = document.getElementById('btn-photo-library'); if (pl) pl.innerText = uiT('photoLibrary'); | |
| const mu = document.getElementById('btn-upload-music-text'); if (mu) mu.innerText = uiT('changeMusic'); | |
| const cam = document.getElementById('toggle-cam-btn'); if (cam) cam.innerText = uiT('camera'); | |
| // Gesture labels panel | |
| setText('gest-assemble', 'gestAssemble'); | |
| setText('gest-scatter', 'gestScatter'); | |
| setText('gest-focus', 'gestFocus'); | |
| setText('gest-rotate', 'gestRotate'); | |
| setText('gest-text-en', 'gestTextEN'); | |
| setText('gest-text-cn', 'gestTextCN'); | |
| // (gesture tile for thumb-flick removed) | |
| // Delete manager | |
| setText('dm-title', 'dmTitle'); | |
| setText('dm-clear-all', 'dmClearAll'); | |
| setText('dm-close', 'dmClose'); | |
| // Hint (only rewrite if it's still in the initial state) | |
| const hint = document.getElementById('gesture-hint'); | |
| if (hint && (hint.innerText.trim() === UI_I18N.en.hintInitializing || hint.innerText.trim() === UI_I18N.zh.hintInitializing)) { | |
| hint.innerText = uiT('hintInitializing'); | |
| } | |
| // Play/pause button depends on state | |
| updatePlayBtnUI(isMusicPlaying); | |
| // Language toggle switch + highlight | |
| const langSwitch = document.getElementById('lang-toggle-switch'); | |
| if (langSwitch) langSwitch.checked = (UI_LANG === 'zh'); | |
| const enTag = document.getElementById('lang-tag-en'); | |
| const zhTag = document.getElementById('lang-tag-zh'); | |
| if (enTag) enTag.classList.toggle('active', UI_LANG === 'en'); | |
| if (zhTag) zhTag.classList.toggle('active', UI_LANG === 'zh'); | |
| } | |
| window.setLanguageFromToggle = function(isChinese) { | |
| applyUILanguage(isChinese ? 'zh' : 'en'); | |
| }; | |
| // --- IndexedDB --- | |
| const DB_NAME = "GrandTreeDB_v16"; | |
| let db; | |
| function initDB() { | |
| return new Promise((resolve) => { | |
| const request = indexedDB.open(DB_NAME, 1); | |
| request.onupgradeneeded = (e) => { | |
| const db = e.target.result; | |
| if (!db.objectStoreNames.contains('photos')) db.createObjectStore('photos', { keyPath: "id" }); | |
| if (!db.objectStoreNames.contains('music')) db.createObjectStore('music', { keyPath: "id" }); | |
| }; | |
| request.onsuccess = (e) => { db = e.target.result; resolve(db); }; | |
| request.onerror = () => resolve(null); | |
| }); | |
| } | |
| function savePhotoToDB(base64) { | |
| if(!db) return null; | |
| const tx = db.transaction('photos', "readwrite"); | |
| const id = Date.now() + Math.random().toString(); | |
| tx.objectStore('photos').add({ id: id, data: base64 }); | |
| return id; | |
| } | |
| function loadPhotosFromDB() { | |
| if(!db) return Promise.resolve([]); | |
| return new Promise((r) => { | |
| db.transaction('photos', "readonly").objectStore('photos').getAll().onsuccess = (e) => r(e.target.result); | |
| }); | |
| } | |
| function deletePhotoFromDB(id) { if(db) db.transaction('photos', "readwrite").objectStore('photos').delete(id); } | |
| function clearPhotosDB() { if(db) db.transaction('photos', "readwrite").objectStore('photos').clear(); } | |
| function saveMusicToDB(blob) { | |
| if(!db) return; | |
| const tx = db.transaction('music', "readwrite"); | |
| tx.objectStore('music').put({ id: 'bgm', data: blob }); | |
| } | |
| function loadMusicFromDB() { | |
| 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); | |
| }); | |
| } | |
| let scene, camera, renderer, composer; | |
| let mainGroup, particleSystem = [], photoMeshGroup = new THREE.Group(); | |
| let clock = new THREE.Clock(); | |
| let gestureRecognizer, videoElement; | |
| let caneTexture; | |
| let bgmAudio = new Audio(); bgmAudio.loop = true; let isMusicPlaying = false; | |
| let treeStar = null; | |
| let treeStarLight = null; | |
| let sparkTexture = null; | |
| let fireworksGroup = null; | |
| let fireworkBursts = []; | |
| let sparkleGroup = null; | |
| let sparkleBursts = []; | |
| let starRays = null; | |
| let snowTexture = null; | |
| let petalTexture = null; | |
| let seasonGroup = null; | |
| let seasonSystem = { mode: 'none', points: null, vel: null, count: 0, bounds: null }; | |
| let themeIndex = 0; | |
| let ornamentMaterialSets = null; // [defaultSet, theme2Set, theme3Set] | |
| let NOW = 0; | |
| async function init() { | |
| initThree(); | |
| setupEnvironment(); | |
| setupLights(); | |
| createTextures(); | |
| createParticles(); | |
| createDust(); | |
| createDefaultPhotos(); | |
| setupPostProcessing(); | |
| setupEvents(); | |
| applyUILanguage(UI_LANG); // Default EN UI; user can toggle to Chinese (ZH) | |
| animate(); | |
| const loader = document.getElementById('loader'); | |
| if(loader) { loader.style.opacity = 0; setTimeout(() => loader.remove(), 500); } | |
| try { | |
| await initDB(); | |
| loadTextConfig(); | |
| const savedPhotos = await loadPhotosFromDB(); | |
| if(savedPhotos && savedPhotos.length > 0) { | |
| photoMeshGroup.clear(); | |
| particleSystem = particleSystem.filter(p => p.type !== 'PHOTO'); | |
| savedPhotos.forEach(item => createPhotoTexture(item.data, item.id)); | |
| } | |
| const savedMusic = await loadMusicFromDB(); | |
| if(savedMusic) { | |
| bgmAudio.src = URL.createObjectURL(savedMusic); | |
| updatePlayBtnUI(false); | |
| } | |
| } catch(e) { console.warn("Init Warning", e); } | |
| initMediaPipe(); | |
| initDraggableTitle(); | |
| } | |
| function initDraggableTitle() { | |
| const title = document.getElementById('title-container'); | |
| if (!title) return; | |
| let isDragging = false; | |
| let offset = { x: 0, y: 0 }; | |
| title.addEventListener('mousedown', (e) => { | |
| isDragging = true; | |
| const rect = title.getBoundingClientRect(); | |
| offset.x = e.clientX - rect.left; offset.y = e.clientY - rect.top; | |
| title.style.transform = 'none'; title.style.left = rect.left + 'px'; title.style.top = rect.top + 'px'; | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if(isDragging) { title.style.left = (e.clientX - offset.x) + 'px'; title.style.top = (e.clientY - offset.y) + 'px'; } | |
| }); | |
| window.addEventListener('mouseup', () => { isDragging = false; }); | |
| } | |
| window.toggleUI = function() { | |
| STATE.uiVisible = !STATE.uiVisible; | |
| const tl = document.querySelector('.top-left-panel'); | |
| const bl = document.querySelector('.bottom-left-panel'); | |
| const gest = document.querySelector('.left-gesture-panel'); | |
| const btn = document.getElementById('toggle-ui-btn'); | |
| if(!STATE.uiVisible) { | |
| tl.classList.add('panel-hidden'); bl.classList.add('panel-hidden'); gest.classList.add('panel-hidden'); btn.innerText = uiT('uiShow'); | |
| } else { | |
| tl.classList.remove('panel-hidden'); bl.classList.remove('panel-hidden'); gest.classList.remove('panel-hidden'); btn.innerText = uiT('uiHide'); | |
| } | |
| } | |
| window.toggleCameraDisplay = function() { | |
| STATE.cameraVisible = !STATE.cameraVisible; | |
| const cam = document.getElementById('webcam-wrapper'); | |
| if(STATE.cameraVisible) cam.classList.remove('camera-hidden'); else cam.classList.add('camera-hidden'); | |
| } | |
| window.toggleFullScreen = function() { | |
| const btn = document.getElementById('fs-btn'); | |
| if (!document.fullscreenElement) { | |
| document.documentElement.requestFullscreen(); | |
| btn.innerText = uiT('fsExit'); | |
| } else { | |
| document.exitFullscreen(); | |
| btn.innerText = uiT('fsEnter'); | |
| } | |
| } | |
| document.addEventListener('fullscreenchange', () => { | |
| const btn = document.getElementById('fs-btn'); | |
| if (!btn) return; | |
| if (!document.fullscreenElement) btn.innerText = uiT('fsEnter'); | |
| else btn.innerText = uiT('fsExit'); | |
| }); | |
| function loadTextConfig() { | |
| const saved = JSON.parse(localStorage.getItem('v16_text_config')); | |
| if(saved) { | |
| applyTextConfig(saved.fontKey, saved.line1, saved.line2, saved.size, saved.color); | |
| } else { | |
| applyTextConfig("style1", "Merry", "Christmas", 100, "#fceea7"); | |
| } | |
| } | |
| window.updateTextConfig = function() { | |
| // Custom Text UI removed | |
| return; | |
| } | |
| function applyTextConfig(key, l1, l2, size, color) { | |
| const style = FONT_STYLES[key] || FONT_STYLES['style1']; | |
| const t1 = document.getElementById('display-line1'); | |
| const t2 = document.getElementById('display-line2'); | |
| t1.innerText = l1; t2.innerText = l2; | |
| const container = document.getElementById('title-container'); | |
| container.style.fontFamily = style.font; | |
| t1.style.letterSpacing = style.spacing; t2.style.letterSpacing = style.spacing; | |
| t1.style.textShadow = style.shadow; t2.style.textShadow = style.shadow; | |
| t1.style.textTransform = style.transform; t2.style.textTransform = style.transform; | |
| t1.style.color = color; t2.style.color = color; | |
| t1.style.webkitTextFillColor = color; t2.style.webkitTextFillColor = color; | |
| t1.style.background = 'none'; t2.style.background = 'none'; | |
| if(style.transform.includes('rotate')) { t1.style.transform = style.transform; t2.style.transform = style.transform; } | |
| else { t1.style.transform = 'none'; t2.style.transform = 'none'; } | |
| t1.style.fontSize = (0.48 * size) + "px"; t2.style.fontSize = (0.48 * size) + "px"; | |
| if (STATE.mode === 'TEXT') buildTextShapeTargets(); | |
| } | |
| window.toggleMusicPlay = function() { | |
| if(!bgmAudio.src) return alert(uiT('alertUploadMusic')); | |
| if(isMusicPlaying) { bgmAudio.pause(); isMusicPlaying = false; } | |
| else { bgmAudio.play(); isMusicPlaying = true; } | |
| updatePlayBtnUI(isMusicPlaying); | |
| } | |
| window.updateVolume = function(val) { | |
| bgmAudio.volume = val / 100; | |
| document.getElementById('val-vol').innerText = val; | |
| } | |
| function updatePlayBtnUI(playing) { | |
| const btn = document.getElementById('play-pause-btn'); | |
| if (!btn) return; | |
| btn.innerText = playing ? uiT('musicPause') : uiT('musicPlay'); | |
| } | |
| 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" }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.toneMapping = THREE.ReinhardToneMapping; | |
| renderer.toneMappingExposure = 2.2; | |
| container.appendChild(renderer.domElement); | |
| mainGroup = new THREE.Group(); | |
| scene.add(mainGroup); | |
| // Background fireworks removed/disabled | |
| fireworksGroup = null; | |
| // Sparkle bursts for intro ornament "appear" effects (rotates with the tree) | |
| sparkleGroup = new THREE.Group(); | |
| mainGroup.add(sparkleGroup); | |
| // Seasonal falling effects (snow / petals), in world space so it permeates the whole view. | |
| seasonGroup = new THREE.Group(); | |
| seasonGroup.position.set(0, 0, 0); | |
| scene.add(seasonGroup); | |
| } | |
| function setupEnvironment() { | |
| const pmremGenerator = new THREE.PMREMGenerator(renderer); | |
| scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; | |
| } | |
| function setupLights() { | |
| scene.add(new THREE.AmbientLight(0xffffff, 0.6)); | |
| 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); | |
| const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85); | |
| bloomPass.threshold = 0.7; bloomPass.strength = 0.45; bloomPass.radius = 0.4; | |
| 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 = caneTexture.wrapT = THREE.RepeatWrapping; caneTexture.repeat.set(3, 3); | |
| // Firework sparkle texture (soft radial glow) | |
| const spark = document.createElement('canvas'); | |
| spark.width = 64; spark.height = 64; | |
| const sctx = spark.getContext('2d'); | |
| const g = sctx.createRadialGradient(32, 32, 0, 32, 32, 32); | |
| g.addColorStop(0.0, 'rgba(255,255,255,1)'); | |
| g.addColorStop(0.2, 'rgba(255,235,160,0.95)'); | |
| g.addColorStop(0.5, 'rgba(255,215,120,0.35)'); | |
| g.addColorStop(1.0, 'rgba(255,215,120,0)'); | |
| sctx.fillStyle = g; | |
| sctx.fillRect(0, 0, 64, 64); | |
| sparkTexture = new THREE.CanvasTexture(spark); | |
| sparkTexture.colorSpace = THREE.SRGBColorSpace; | |
| sparkTexture.needsUpdate = true; | |
| // Snow texture | |
| const snow = document.createElement('canvas'); | |
| snow.width = 64; snow.height = 64; | |
| const nctx = snow.getContext('2d'); | |
| const ng = nctx.createRadialGradient(32, 32, 0, 32, 32, 32); | |
| ng.addColorStop(0.0, 'rgba(255,255,255,1)'); | |
| ng.addColorStop(0.35, 'rgba(255,255,255,0.75)'); | |
| ng.addColorStop(1.0, 'rgba(255,255,255,0)'); | |
| nctx.fillStyle = ng; nctx.fillRect(0, 0, 64, 64); | |
| snowTexture = new THREE.CanvasTexture(snow); | |
| snowTexture.colorSpace = THREE.SRGBColorSpace; | |
| snowTexture.needsUpdate = true; | |
| // Petal texture (heart) | |
| const petal = document.createElement('canvas'); | |
| petal.width = 64; petal.height = 64; | |
| const pctx = petal.getContext('2d'); | |
| pctx.clearRect(0,0,64,64); | |
| // Heart-shaped rose petal | |
| pctx.translate(32, 34); | |
| pctx.rotate(-0.25); | |
| pctx.scale(0.95, 0.95); | |
| // Darker, redder petals | |
| const pg = pctx.createRadialGradient(0, -6, 3, 0, 4, 30); | |
| pg.addColorStop(0.0, 'rgba(255,205,215,0.95)'); | |
| pg.addColorStop(0.45, 'rgba(205,35,75,0.92)'); | |
| pg.addColorStop(1.0, 'rgba(205,35,75,0)'); | |
| pctx.fillStyle = pg; | |
| pctx.beginPath(); | |
| pctx.moveTo(0, -6); | |
| pctx.bezierCurveTo(0, -24, -24, -24, -24, -3); | |
| pctx.bezierCurveTo(-24, 12, -8, 22, 0, 30); | |
| pctx.bezierCurveTo(8, 22, 24, 12, 24, -3); | |
| pctx.bezierCurveTo(24, -24, 0, -24, 0, -6); | |
| pctx.closePath(); | |
| pctx.fill(); | |
| petalTexture = new THREE.CanvasTexture(petal); | |
| petalTexture.colorSpace = THREE.SRGBColorSpace; | |
| petalTexture.needsUpdate = true; | |
| } | |
| function createStarRaysSprite() { | |
| // Simple additive "light rays" sprite for the star flare | |
| const c = document.createElement('canvas'); | |
| c.width = 256; c.height = 256; | |
| const ctx = c.getContext('2d'); | |
| ctx.clearRect(0, 0, 256, 256); | |
| // radial glow | |
| const g = ctx.createRadialGradient(128, 128, 0, 128, 128, 128); | |
| g.addColorStop(0.0, 'rgba(255,240,190,0.95)'); | |
| g.addColorStop(0.25, 'rgba(255,210,120,0.35)'); | |
| g.addColorStop(1.0, 'rgba(255,210,120,0)'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0, 0, 256, 256); | |
| // spokes | |
| ctx.save(); | |
| ctx.translate(128, 128); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (let i = 0; i < 18; i++) { | |
| const a = (i / 18) * Math.PI * 2; | |
| const len = 110 + Math.random() * 35; | |
| const w = 10 + Math.random() * 10; | |
| ctx.rotate(a); | |
| const lg = ctx.createLinearGradient(0, 0, len, 0); | |
| lg.addColorStop(0, 'rgba(255,255,255,0)'); | |
| lg.addColorStop(0.25, 'rgba(255,255,255,0.25)'); | |
| lg.addColorStop(0.9, 'rgba(255,215,120,0)'); | |
| ctx.fillStyle = lg; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -w/2); | |
| ctx.lineTo(len, -w/4); | |
| ctx.lineTo(len, w/4); | |
| ctx.lineTo(0, w/2); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.setTransform(1,0,0,1,128,128); | |
| } | |
| ctx.restore(); | |
| const tex = new THREE.CanvasTexture(c); | |
| tex.colorSpace = THREE.SRGBColorSpace; | |
| tex.needsUpdate = true; | |
| const mat = new THREE.SpriteMaterial({ | |
| map: tex, | |
| transparent: true, | |
| opacity: 0, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const sprite = new THREE.Sprite(mat); | |
| sprite.scale.set(18, 18, 1); | |
| sprite.renderOrder = 999; | |
| return sprite; | |
| } | |
| function spawnFireworkBurst(opts = {}) { | |
| // Fireworks disabled | |
| return; | |
| const { | |
| count = 220, | |
| size = 0.65, | |
| speedMin = 7, | |
| speedMax = 18, | |
| duration = 1.8, | |
| gravity = 2.2, | |
| drag = 0.985, | |
| ringFrac = 0.28, | |
| upBias = 0.18 | |
| } = opts; | |
| const pos = new Float32Array(count * 3); | |
| const vel = new Float32Array(count * 3); | |
| const col = new Float32Array(count * 3); | |
| // Origin somewhere behind the tree | |
| const ox = (Math.random() - 0.5) * 34; | |
| const oy = 2 + Math.random() * 22; | |
| const oz = (Math.random() - 0.5) * 16; | |
| for (let i = 0; i < count; i++) { | |
| pos[i*3+0] = ox; | |
| pos[i*3+1] = oy; | |
| pos[i*3+2] = oz; | |
| // Direction: mix of ring bursts + full sphere, with upward bias (more dramatic spread) | |
| const theta = 2 * Math.PI * Math.random(); | |
| let phi; | |
| if (Math.random() < ringFrac) { | |
| phi = (Math.PI / 2) + (Math.random() - 0.5) * 0.45; // near-horizontal ring | |
| } else { | |
| phi = Math.acos(2 * Math.random() - 1); | |
| } | |
| const dx = Math.sin(phi) * Math.cos(theta); | |
| const dz = Math.sin(phi) * Math.sin(theta); | |
| const dy = Math.cos(phi) + upBias; | |
| const speed = speedMin + Math.random() * (speedMax - speedMin); | |
| vel[i*3+0] = dx * speed; | |
| vel[i*3+1] = dy * speed; | |
| vel[i*3+2] = dz * speed; | |
| // Warm gold + occasional white specular glints | |
| const glint = Math.random() < 0.18 ? 1.0 : 0.0; | |
| const r = 1.0; | |
| const g = glint ? 1.0 : (0.78 + Math.random() * 0.12); | |
| const b = glint ? 1.0 : (0.22 + Math.random() * 0.18); | |
| col[i*3+0] = r; | |
| col[i*3+1] = g; | |
| col[i*3+2] = b; | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); | |
| const mat = new THREE.PointsMaterial({ | |
| size, | |
| map: sparkTexture, | |
| transparent: true, | |
| opacity: 1.0, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| vertexColors: true | |
| }); | |
| const points = new THREE.Points(geo, mat); | |
| points.frustumCulled = false; | |
| fireworksGroup.add(points); | |
| // Specular flash light to make it more “stunning” | |
| const flash = new THREE.PointLight(0xffe6aa, 3.2, 55); | |
| flash.position.set(ox, oy, oz + 2.0); | |
| fireworksGroup.add(flash); | |
| fireworkBursts.push({ | |
| points, | |
| vel, | |
| start: NOW, | |
| duration, | |
| gravity, | |
| drag, | |
| baseSize: size, | |
| flash | |
| }); | |
| } | |
| function updateFireworks(dt) { | |
| // Fireworks disabled | |
| return; | |
| } | |
| function createStarMesh() { | |
| // 3D extruded 5-point star | |
| const spikes = 5; | |
| const outerR = 1.55; | |
| const innerR = 0.65; | |
| const depth = 0.35; | |
| const shape = new THREE.Shape(); | |
| for (let i = 0; i < spikes * 2; i++) { | |
| const r = (i % 2 === 0) ? outerR : innerR; | |
| // In Three.js, +Y is up. Use +90° offset so a spike points straight up. | |
| const a = (i / (spikes * 2)) * Math.PI * 2 + Math.PI / 2; | |
| const x = Math.cos(a) * r; | |
| const y = Math.sin(a) * r; | |
| if (i === 0) shape.moveTo(x, y); | |
| else shape.lineTo(x, y); | |
| } | |
| shape.closePath(); | |
| const geo = new THREE.ExtrudeGeometry(shape, { | |
| depth, | |
| bevelEnabled: true, | |
| bevelThickness: 0.08, | |
| bevelSize: 0.06, | |
| bevelSegments: 2, | |
| curveSegments: 10 | |
| }); | |
| geo.center(); | |
| const mat = new THREE.MeshPhysicalMaterial({ | |
| color: 0xffd966, | |
| metalness: 1.0, | |
| roughness: 0.12, | |
| clearcoat: 1.0, | |
| clearcoatRoughness: 0.05, | |
| emissive: 0xffcc55, | |
| emissiveIntensity: 1.4 | |
| }); | |
| const mesh = new THREE.Mesh(geo, mat); | |
| mesh.castShadow = false; | |
| mesh.receiveShadow = false; | |
| return mesh; | |
| } | |
| function startIntroSequence() { | |
| // Run only once on first load (not on "rebuild scene") | |
| if (STATE.intro && STATE.intro.ran) return; | |
| STATE.intro.ran = true; | |
| STATE.intro.active = true; | |
| STATE.intro.start = clock.elapsedTime; | |
| // Timings (seconds) | |
| STATE.intro.spiralDuration = 6.0; // two spiral lines stream & wrap into helices | |
| STATE.intro.settleDuration = 4.0; // other ornaments appear + settle | |
| STATE.intro.starDelay = 0.4; // wait after spirals complete | |
| STATE.intro.starFadeIn = 0.9; | |
| STATE.intro.starFlareHold = 1.0; | |
| STATE.intro.starFlareDecay = 0.5; | |
| // Prepare star to fade in later | |
| if (treeStar && treeStar.material) { | |
| treeStar.material.transparent = true; | |
| treeStar.material.opacity = 0; | |
| treeStar.material.emissiveIntensity = 0; | |
| } | |
| if (treeStarLight) treeStarLight.intensity = 0; | |
| if (!starRays && treeStar) { | |
| starRays = createStarRaysSprite(); | |
| starRays.position.copy(treeStar.position); | |
| mainGroup.add(starRays); | |
| } | |
| if (starRays && starRays.material) starRays.material.opacity = 0; | |
| // Mark spiral spheres ordering by their final t, so they "climb" in order. | |
| const gold = particleSystem.filter(p => p.type === 'GOLD_SPHERE').sort((a,b) => (a.mesh.userData.spiralT ?? 0) - (b.mesh.userData.spiralT ?? 0)); | |
| const red = particleSystem.filter(p => p.type === 'RED').sort((a,b) => (a.mesh.userData.spiralT ?? 0) - (b.mesh.userData.spiralT ?? 0)); | |
| gold.forEach((p, i) => { p.mesh.userData.spiralOrder = i; p.mesh.userData.spiralCount = gold.length; }); | |
| red.forEach((p, i) => { p.mesh.userData.spiralOrder = i; p.mesh.userData.spiralCount = red.length; }); | |
| // Start spirals as two fully lined-up "columns" (already in correct order), then the whole lines fly in and wrap upward. | |
| gold.forEach(p => { | |
| const tFinal = THREE.MathUtils.clamp(p.mesh.userData.spiralT ?? p.treeT ?? 0.5, 0, 1); | |
| p.mesh.scale.setScalar(p.baseScale); | |
| const w = linePosAt(tFinal, 'GOLD_SPHERE', new THREE.Vector3(), 0); | |
| p.mesh.position.copy(worldToMainLocal(w, w)); | |
| }); | |
| red.forEach(p => { | |
| const tFinal = THREE.MathUtils.clamp(p.mesh.userData.spiralT ?? p.treeT ?? 0.5, 0, 1); | |
| p.mesh.scale.setScalar(p.baseScale); | |
| const w = linePosAt(tFinal, 'RED', new THREE.Vector3(), 0); | |
| p.mesh.position.copy(worldToMainLocal(w, w)); | |
| }); | |
| // Other ornaments: assign appear timings tied to height (treeT) so they "fill in" as spirals climb. | |
| particleSystem.forEach(p => scheduleIntroForParticle(p)); | |
| } | |
| function scheduleIntroForParticle(p) { | |
| if (!STATE.intro || !STATE.intro.active) return; | |
| if (!p || p.isDust) return; | |
| const isSpiral = (p.type === 'GOLD_SPHERE' || p.type === 'RED'); | |
| if (isSpiral) return; | |
| p.intro = p.intro || {}; | |
| if (p.intro.scheduled) return; | |
| p.intro.scheduled = true; | |
| p.intro.sparked = false; | |
| const tH = (typeof p.treeT === 'number') ? p.treeT : 0.5; | |
| const appearBase = STATE.intro.start + (tH * (STATE.intro.spiralDuration * 0.95)); | |
| const targetStart = appearBase + (Math.random() * 0.4); | |
| const now = clock.elapsedTime; | |
| // If the particle is created late (e.g., photos loaded async), still fade it in smoothly. | |
| p.intro.appearStart = (now <= targetStart) ? targetStart : (now + Math.random() * 0.18); | |
| p.intro.appearDur = 1.8 + Math.random() * 1.2; | |
| p.intro.offset = new THREE.Vector3( | |
| (Math.random() - 0.5) * 1.8, | |
| (Math.random() - 0.5) * 0.8, | |
| (Math.random() - 0.5) * 1.8 | |
| ); | |
| // Start hidden and off in scatter so it "appears" with the rest. | |
| p.mesh.scale.setScalar(0.0001); | |
| p.mesh.position.copy(p.posScatter); | |
| } | |
| function spawnAppearSparkles(at, intensity = 1.0) { | |
| if (!sparkleGroup || !sparkTexture) return; | |
| const count = 46; | |
| const pos = new Float32Array(count * 3); | |
| const vel = new Float32Array(count * 3); | |
| const col = new Float32Array(count * 3); | |
| for (let i = 0; i < count; i++) { | |
| pos[i*3+0] = at.x; | |
| pos[i*3+1] = at.y; | |
| pos[i*3+2] = at.z; | |
| const theta = 2 * Math.PI * Math.random(); | |
| const phi = Math.acos(2 * Math.random() - 1); | |
| const dx = Math.sin(phi) * Math.cos(theta); | |
| const dy = Math.cos(phi); | |
| const dz = Math.sin(phi) * Math.sin(theta); | |
| const speed = (0.8 + Math.random() * 2.0) * intensity; | |
| vel[i*3+0] = dx * speed; | |
| vel[i*3+1] = dy * speed; | |
| vel[i*3+2] = dz * speed; | |
| const isWhite = Math.random() < 0.35; | |
| col[i*3+0] = 1.0; | |
| col[i*3+1] = isWhite ? 1.0 : (0.78 + Math.random() * 0.18); | |
| col[i*3+2] = isWhite ? 1.0 : (0.25 + Math.random() * 0.15); | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); | |
| const mat = new THREE.PointsMaterial({ | |
| size: 0.42, | |
| map: sparkTexture, | |
| transparent: true, | |
| opacity: 1.0, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| vertexColors: true | |
| }); | |
| const pts = new THREE.Points(geo, mat); | |
| pts.frustumCulled = false; | |
| sparkleGroup.add(pts); | |
| sparkleBursts.push({ points: pts, vel, start: NOW, duration: 1.4 + Math.random()*0.8, drag: 0.985 }); | |
| } | |
| function updateSparkles(dt) { | |
| for (let i = sparkleBursts.length - 1; i >= 0; i--) { | |
| const b = sparkleBursts[i]; | |
| const age = NOW - b.start; | |
| const p = age / b.duration; | |
| if (p >= 1) { | |
| sparkleGroup.remove(b.points); | |
| b.points.geometry.dispose(); | |
| b.points.material.dispose(); | |
| sparkleBursts.splice(i, 1); | |
| continue; | |
| } | |
| const positions = b.points.geometry.attributes.position.array; | |
| for (let j = 0; j < b.vel.length / 3; j++) { | |
| const ix = j*3; | |
| positions[ix+0] += b.vel[ix+0] * dt; | |
| positions[ix+1] += b.vel[ix+1] * dt; | |
| positions[ix+2] += b.vel[ix+2] * dt; | |
| b.vel[ix+0] *= b.drag; | |
| b.vel[ix+1] *= b.drag; | |
| b.vel[ix+2] *= b.drag; | |
| } | |
| b.points.geometry.attributes.position.needsUpdate = true; | |
| const fade = Math.pow(1 - p, 2.6); | |
| b.points.material.opacity = fade; | |
| b.points.material.size = 0.35 + 0.25 * Math.sin((NOW - b.start) * 18 + i); | |
| } | |
| } | |
| function ensureOrnamentMaterialSets() { | |
| // Centralized: default + theme2 + theme3 materials live here. | |
| // Theme materials preserve the *metalness* values from defaults by cloning the default materials. | |
| if (ornamentMaterialSets) return ornamentMaterialSets; | |
| // --- Defaults (used for themeIndex === 0) --- | |
| 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 greenMetalConeMat = new THREE.MeshStandardMaterial({ | |
| color: CONFIG.colors.deepGreen, | |
| metalness: 0.15, | |
| roughness: 0.82, | |
| envMapIntensity: 1.8, | |
| emissive: 0x001600, | |
| emissiveIntensity: 0.35 | |
| }); | |
| 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 }); | |
| const starMat = new THREE.MeshPhysicalMaterial({ | |
| color: 0xffd966, | |
| metalness: 1.0, | |
| roughness: 0.12, | |
| clearcoat: 1.0, | |
| clearcoatRoughness: 0.05, | |
| emissive: 0xffcc55, | |
| emissiveIntensity: 0 // intro removed: keep star non-emissive by default | |
| }); | |
| const photoFrameMat = new THREE.MeshStandardMaterial({ color: CONFIG.colors.champagneGold, metalness: 1.0, roughness: 0.1 }); | |
| const defaults = { | |
| GOLD_SPHERE: goldMat, | |
| GOLD_BOX: goldMat, | |
| RED: redMat, | |
| BOX: greenMat, | |
| GREEN_CONE: greenMetalConeMat, | |
| CANE: candyMat, | |
| STAR: starMat, | |
| PHOTO_FRAME: photoFrameMat | |
| }; | |
| const cloneWithColor = (base, colorHex, opts = {}) => { | |
| const m = base.clone(); | |
| if (m.color) m.color.setHex(colorHex); | |
| if (opts.emissiveHex != null && m.emissive) m.emissive.setHex(opts.emissiveHex); | |
| if (opts.emissiveIntensity != null && ('emissiveIntensity' in m)) m.emissiveIntensity = opts.emissiveIntensity; | |
| if (opts.emissiveScale != null && ('emissiveIntensity' in m)) m.emissiveIntensity *= opts.emissiveScale; | |
| if (opts.mapNull) { m.map = null; m.needsUpdate = true; } | |
| return m; | |
| }; | |
| // Theme palettes (easy to change) | |
| // Theme 2: magenta/cyan balls, light-blue other ornaments, white star + white photo frames | |
| const theme2 = { goldSphere: 0xff00ff, redSphere: 0x00ffff, other: 0xbfe7ff, star: 0xffffff, frame: 0xffffff }; | |
| // Theme 3: rosy red "everything", with deeper vibrant red balls, red star + red photo frames | |
| const theme3 = { goldSphere: 0xe13b62, redSphere: 0xd1002f, other: 0xff4f7a, star: 0xff66cc, frame: 0xff1b1b }; | |
| const buildThemeSet = (palette, opts = {}) => ({ | |
| GOLD_SPHERE: cloneWithColor(defaults.GOLD_SPHERE, palette.goldSphere, opts.ballOpts || {}), | |
| RED: cloneWithColor(defaults.RED, palette.redSphere, opts.ballOpts || {}), | |
| GOLD_BOX: cloneWithColor(defaults.GOLD_BOX, palette.other, opts.otherOpts || {}), | |
| BOX: cloneWithColor(defaults.BOX, palette.other, opts.otherOpts || {}), | |
| GREEN_CONE: cloneWithColor(defaults.GREEN_CONE, palette.other, opts.otherOpts || {}), | |
| CANE: cloneWithColor(defaults.CANE, palette.other, { ...(opts.otherOpts || {}), mapNull: true }), | |
| STAR: cloneWithColor(defaults.STAR, palette.star ?? 0xffffff, { emissiveScale: 0 }), | |
| PHOTO_FRAME: cloneWithColor(defaults.PHOTO_FRAME, palette.frame ?? 0xffffff, opts.frameOpts || {}) | |
| }); | |
| // Theme 2 wants lower emission on "other" ornaments (keep metalness the same as defaults). | |
| const theme2Opts = { | |
| otherOpts: { emissiveHex: 0x0a1a22, emissiveScale: 0.25 } | |
| }; | |
| // Theme 3: tint emissive slightly red so recolored greens don't glow green. | |
| const theme3Opts = { | |
| ballOpts: { emissiveHex: 0x440011, emissiveScale: 0.75 }, | |
| otherOpts: { emissiveHex: 0x220008, emissiveScale: 0.45 } | |
| }; | |
| ornamentMaterialSets = [ | |
| defaults, | |
| buildThemeSet(theme2, theme2Opts), | |
| buildThemeSet(theme3, theme3Opts) | |
| ]; | |
| return ornamentMaterialSets; | |
| } | |
| function setSeasonEffect(mode) { | |
| seasonSystem.mode = mode; | |
| if (!seasonGroup) return; | |
| if (mode === 'none') { | |
| if (seasonSystem.points) seasonSystem.points.visible = false; | |
| return; | |
| } | |
| // Init system lazily | |
| if (!seasonSystem.points) { | |
| const count = 1400; | |
| const pos = new Float32Array(count * 3); | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| const mat = new THREE.PointsMaterial({ | |
| size: 0.35, | |
| map: snowTexture || sparkTexture, | |
| transparent: true, | |
| opacity: 0.85, | |
| blending: THREE.NormalBlending, | |
| depthWrite: false, | |
| sizeAttenuation: true | |
| }); | |
| const pts = new THREE.Points(geo, mat); | |
| pts.frustumCulled = false; | |
| seasonGroup.add(pts); | |
| seasonSystem.points = pts; | |
| seasonSystem.vel = new Float32Array(count * 3); | |
| seasonSystem.depth = new Float32Array(count); | |
| seasonSystem.count = count; | |
| } | |
| // Configure for mode | |
| const isSnow = (mode === 'snow'); | |
| seasonSystem.points.material.map = isSnow ? (snowTexture || sparkTexture) : (petalTexture || sparkTexture); | |
| // Smaller snowflakes; petals keep a bit larger but still gentle | |
| seasonSystem.points.material.size = isSnow ? 0.18 : 0.48; | |
| seasonSystem.points.material.opacity = isSnow ? 0.82 : 0.78; | |
| seasonSystem.points.material.color.set(isSnow ? 0xffffff : 0xe02b53); | |
| seasonSystem.points.visible = true; | |
| // Respawn particles across the *whole view volume* so near ones look larger and the effect fills depth. | |
| const positions = seasonSystem.points.geometry.attributes.position.array; | |
| const v = seasonSystem.vel; | |
| const dArr = seasonSystem.depth; | |
| const fovRad = THREE.MathUtils.degToRad(camera.fov * 0.5); | |
| const nearD = 6; | |
| const farD = 160; | |
| const depthPow = isSnow ? 2.4 : 2.0; // bias to near => bigger particles nearer camera | |
| for (let i = 0; i < seasonSystem.count; i++) { | |
| const ix = i*3; | |
| const depth = nearD + (farD - nearD) * Math.pow(Math.random(), depthPow); | |
| dArr[i] = depth; | |
| const halfH = Math.tan(fovRad) * depth; | |
| const halfW = halfH * camera.aspect; | |
| positions[ix+0] = camera.position.x + (Math.random()*2 - 1) * halfW * 1.25; | |
| positions[ix+1] = camera.position.y + (Math.random()*2 - 1) * halfH * 1.25; | |
| positions[ix+2] = camera.position.z - depth; | |
| // Very slow fall | |
| const baseFall = isSnow ? 0.65 : 0.45; | |
| const depthFactor = 0.7 + 0.3 * (depth / farD); | |
| const fall = baseFall * depthFactor * (0.40 + Math.random() * 0.40); | |
| const drift = isSnow ? 0.18 : 0.42; | |
| v[ix+0] = (Math.random()-0.5) * drift; | |
| v[ix+1] = -fall; | |
| v[ix+2] = (Math.random()-0.5) * drift; | |
| } | |
| seasonSystem.points.geometry.attributes.position.needsUpdate = true; | |
| } | |
| function updateSeason(dt) { | |
| if (!seasonSystem.points || !seasonSystem.points.visible) return; | |
| const positions = seasonSystem.points.geometry.attributes.position.array; | |
| const v = seasonSystem.vel; | |
| const dArr = seasonSystem.depth; | |
| const isSnow = (seasonSystem.mode === 'snow'); | |
| const fovRad = THREE.MathUtils.degToRad(camera.fov * 0.5); | |
| for (let i = 0; i < seasonSystem.count; i++) { | |
| const ix = i*3; | |
| positions[ix+0] += v[ix+0] * dt; | |
| positions[ix+1] += v[ix+1] * dt; | |
| positions[ix+2] += v[ix+2] * dt; | |
| // Gentle swirl | |
| if (!isSnow) { | |
| v[ix+0] += Math.sin((NOW * 0.9) + i) * 0.006 * dt; | |
| v[ix+2] += Math.cos((NOW * 0.8) + i) * 0.006 * dt; | |
| } | |
| const depth = dArr ? dArr[i] : 60; | |
| const halfH = Math.tan(fovRad) * depth; | |
| const halfW = halfH * camera.aspect; | |
| const bottom = camera.position.y - halfH * 1.35; | |
| const top = camera.position.y + halfH * 1.35; | |
| if (positions[ix+1] < bottom) { | |
| // Respawn at top, keep depth so near/far distribution persists | |
| positions[ix+1] = top; | |
| positions[ix+0] = camera.position.x + (Math.random()*2 - 1) * halfW * 1.35; | |
| positions[ix+2] = camera.position.z - depth; | |
| } | |
| } | |
| seasonSystem.points.geometry.attributes.position.needsUpdate = true; | |
| } | |
| function applyTheme(idx) { | |
| ensureOrnamentMaterialSets(); | |
| themeIndex = idx; | |
| if (themeIndex === 0) setSeasonEffect('none'); | |
| if (themeIndex === 1) setSeasonEffect('snow'); | |
| if (themeIndex === 2) setSeasonEffect('petals'); | |
| const set = ornamentMaterialSets[themeIndex] || ornamentMaterialSets[0]; | |
| particleSystem.forEach(p => { | |
| if (!p || p.isDust || p.type === 'PHOTO') return; | |
| const mesh = p.mesh; | |
| if (!mesh) return; | |
| const mat = set[p.type] || set.BOX; | |
| if (mat) mesh.material = mat; | |
| }); | |
| // Star topper | |
| if (treeStar && set.STAR) { | |
| treeStar.material = set.STAR; | |
| if ('emissiveIntensity' in treeStar.material) treeStar.material.emissiveIntensity = 0; | |
| } | |
| if (treeStarLight) treeStarLight.intensity = 0; | |
| // Photo frames | |
| if (photoMeshGroup && set.PHOTO_FRAME) { | |
| photoMeshGroup.children.forEach(g => { | |
| if (!g || !g.children || g.children.length === 0) return; | |
| const frame = g.children[0]; | |
| if (frame && frame.material) frame.material = set.PHOTO_FRAME; | |
| }); | |
| } | |
| } | |
| function cycleTheme() { | |
| applyTheme((themeIndex + 1) % 3); | |
| } | |
| function spiralPosAt(t, type, out = new THREE.Vector3()) { | |
| const h = CONFIG.particles.treeHeight; | |
| const y = (t * h) - (h / 2); | |
| const rMax = Math.max(0.5, CONFIG.particles.treeRadius * (1.0 - t)); | |
| const spiralTurns = 9.5; | |
| const baseHelix = t * spiralTurns * Math.PI * 2; | |
| const phase = (type === 'RED') ? Math.PI : 0.0; | |
| const angle = baseHelix + phase; | |
| const r = rMax * 1.03; | |
| out.set(Math.cos(angle) * r, y, Math.sin(angle) * r); | |
| return out; | |
| } | |
| function linePosAt(tFinal, type, out = new THREE.Vector3(), fly = 0) { | |
| // Intro lines: | |
| // - RED: lower-left corner | |
| // - GOLD: lower-right corner | |
| // The line is already in correct order (y matches final t), then the whole line flies in. | |
| const t = THREE.MathUtils.clamp(tFinal, 0, 1); | |
| const h = CONFIG.particles.treeHeight; | |
| const ySpiral = (t * h) - (h / 2); | |
| const depthNear = 6.0; // close to camera (but still in front) | |
| const depthFar = 18.0; // a bit farther as the line "flies in" | |
| const depth = THREE.MathUtils.lerp(depthNear, depthFar, THREE.MathUtils.smoothstep(fly, 0, 1)); | |
| const vH = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * depth; | |
| const vW = vH * camera.aspect; | |
| const yStart = camera.position.y - vH * 0.5 + 0.25; // lower edge | |
| // Keep the line ordered immediately, but let it lift a touch from the bottom as it flies in. | |
| const y = THREE.MathUtils.lerp(yStart, ySpiral, 0.35 + 0.65 * t); | |
| const sign = (type === 'RED') ? -1 : 1; | |
| const xOff = sign * (vW * 0.5 + 2.8); // start slightly off-screen | |
| const xIn = sign * (vW * 0.5 - 0.65); // settle near visible corner edge | |
| const x = THREE.MathUtils.lerp(xOff, xIn, THREE.MathUtils.smoothstep(fly, 0, 1)); | |
| const z = camera.position.z - depth; | |
| out.set(x, y, z); | |
| return out; | |
| } | |
| function worldToMainLocal(world, out = new THREE.Vector3()) { | |
| // mainGroup is at origin; convert a desired world position into mainGroup-local space | |
| // so that after mainGroup rotation it still appears at the given world position. | |
| const invQ = mainGroup.quaternion.clone().invert(); | |
| return out.copy(world).applyQuaternion(invQ); | |
| } | |
| function updateIntro(dt) { | |
| if (!STATE.intro || !STATE.intro.active) return; | |
| const e = NOW - STATE.intro.start; | |
| const spiralDone = e >= STATE.intro.spiralDuration; | |
| const starStart = STATE.intro.spiralDuration + STATE.intro.starDelay; | |
| const starFadeInEnd = starStart + STATE.intro.starFadeIn; | |
| const starHoldEnd = starFadeInEnd + STATE.intro.starFlareHold; | |
| const starEnd = starHoldEnd + STATE.intro.starFlareDecay; | |
| // Star flare timeline | |
| if (treeStar && treeStar.material) { | |
| treeStar.material.transparent = true; | |
| if (e < starStart) { | |
| treeStar.material.opacity = 0; | |
| treeStar.material.emissiveIntensity = 0; | |
| if (treeStarLight) treeStarLight.intensity = 0; | |
| if (starRays && starRays.material) starRays.material.opacity = 0; | |
| } else if (e < starFadeInEnd) { | |
| const p = THREE.MathUtils.clamp((e - starStart) / STATE.intro.starFadeIn, 0, 1); | |
| const a = THREE.MathUtils.smoothstep(p, 0, 1); | |
| treeStar.material.opacity = a; | |
| treeStar.material.emissiveIntensity = 8.0 * a; | |
| if (treeStarLight) treeStarLight.intensity = 18.0 * a; | |
| if (starRays && starRays.material) starRays.material.opacity = 1.1 * a; | |
| } else if (e < starHoldEnd) { | |
| treeStar.material.opacity = 1; | |
| treeStar.material.emissiveIntensity = 8.0; | |
| if (treeStarLight) treeStarLight.intensity = 18.0; | |
| if (starRays && starRays.material) starRays.material.opacity = 1.1; | |
| } else if (e < starEnd) { | |
| const p = THREE.MathUtils.clamp((e - starHoldEnd) / STATE.intro.starFlareDecay, 0, 1); | |
| // Quicker fade-down | |
| const fade = Math.pow(1 - p, 3.2); | |
| treeStar.material.opacity = 1; | |
| treeStar.material.emissiveIntensity = 8.0 * fade; | |
| if (treeStarLight) treeStarLight.intensity = 18.0 * fade; | |
| if (starRays && starRays.material) starRays.material.opacity = 1.1 * fade; | |
| } else { | |
| // End state: no emission/light rays | |
| treeStar.material.opacity = 1; | |
| treeStar.material.emissiveIntensity = 0; | |
| if (treeStarLight) treeStarLight.intensity = 0; | |
| if (starRays && starRays.material) starRays.material.opacity = 0; | |
| STATE.intro.active = false; | |
| // Snap everything to final positions/scales to avoid lingering "hidden" ornaments | |
| particleSystem.forEach(p => { | |
| if (p.isDust) return; | |
| p.mesh.position.copy(p.posTree); | |
| p.mesh.scale.setScalar(p.baseScale); | |
| }); | |
| } | |
| } | |
| // Keep rays pinned to star position | |
| if (starRays && treeStar) starRays.position.copy(treeStar.position); | |
| // If spirals are done and most ornaments have appeared, we can consider intro logically complete even if star isn't created | |
| if (spiralDone && !treeStar) STATE.intro.active = false; | |
| } | |
| 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.posText = new THREE.Vector3(); | |
| this.baseScale = mesh.scale.x; | |
| this.photoId = null; | |
| this.treeT = 0.5; | |
| this.intro = null; | |
| this._tmp1 = new THREE.Vector3(); | |
| this._tmp2 = new THREE.Vector3(); | |
| const speedMult = (type === 'PHOTO') ? 0.3 : 2.0; | |
| this.spinSpeed = new THREE.Vector3((Math.random()-0.5)*speedMult, (Math.random()-0.5)*speedMult, (Math.random()-0.5)*speedMult); | |
| this.calculatePositions(); | |
| } | |
| calculatePositions() { | |
| const h = CONFIG.particles.treeHeight; | |
| let t = Math.pow(Math.random(), 0.8); | |
| // For spiral ornaments, distribute evenly along height to avoid gaps. | |
| if (this.mesh && this.mesh.userData && typeof this.mesh.userData.spiralT === 'number') { | |
| t = THREE.MathUtils.clamp(this.mesh.userData.spiralT, 0, 1); | |
| } | |
| this.treeT = t; | |
| const y = (t * h) - (h/2); | |
| let rMax = Math.max(0.5, CONFIG.particles.treeRadius * (1.0 - t)); | |
| // Default: loose helix + randomness | |
| let angle = t * 50 * Math.PI + Math.random() * Math.PI; | |
| let r = rMax * (0.8 + Math.random() * 0.4); | |
| // Special: make gold balls and red balls each form a clear spiral around the tree | |
| // GOLD_SPHERE: one helix; RED: another helix (phase shifted) | |
| const spiralTurns = 9.5; | |
| const baseHelix = t * spiralTurns * Math.PI * 2; | |
| if (this.type === 'GOLD_SPHERE') { | |
| angle = baseHelix + 0.0 + (Math.random() - 0.5) * 0.35; | |
| r = rMax * (0.98 + Math.random() * 0.10); | |
| } else if (this.type === 'RED') { | |
| angle = baseHelix + Math.PI + (Math.random() - 0.5) * 0.35; | |
| r = rMax * (0.98 + Math.random() * 0.10); | |
| } else if (this.type === 'CANE') { | |
| // Candy canes should live on the surface silhouette of the tree | |
| angle = baseHelix + (Math.random() - 0.5) * 1.0; | |
| r = rMax * (0.96 + Math.random() * 0.08); | |
| } else if (this.type === 'GREEN_CONE') { | |
| // More green metallic cones inside the body of the tree | |
| r = rMax * (0.35 + Math.random() * 0.45); | |
| angle = baseHelix + (Math.random() - 0.5) * 1.2; | |
| } | |
| 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)); | |
| this.posText.copy(this.posScatter); | |
| } | |
| update(dt, mode, focusTargetMesh) { | |
| // --- STARTUP INTRO CINEMATIC (TREE mode only) --- | |
| if (STATE.intro && STATE.intro.active && mode === 'TREE' && !this.isDust) { | |
| const e = NOW - STATE.intro.start; | |
| // Spiral streams: gold/red balls stream in as two lines and curl into their spiral paths. | |
| if (this.type === 'GOLD_SPHERE' || this.type === 'RED') { | |
| const tFinal = THREE.MathUtils.clamp(this.mesh?.userData?.spiralT ?? this.treeT ?? 0.5, 0, 1); | |
| const g = THREE.MathUtils.clamp(e / STATE.intro.spiralDuration, 0, 1); | |
| // Whole line flies in first (all spheres already ordered along the line) | |
| const fly = THREE.MathUtils.smoothstep(g, 0.00, 0.22); | |
| const lineWorld = linePosAt(tFinal, this.type, this._tmp1, fly); | |
| // Wrap into final spiral as the "head" climbs upward (bottom wraps first, then higher ones) | |
| const wrap = THREE.MathUtils.smoothstep(g, tFinal * 0.92 - 0.06, tFinal * 0.92 + 0.10); | |
| const spiralLocal = spiralPosAt(tFinal, this.type, this._tmp2); | |
| // Blend in WORLD space so the corners are stable even while the tree rotates. | |
| const q = mainGroup.quaternion; | |
| const spiralWorld = spiralLocal.applyQuaternion(q); | |
| lineWorld.lerp(spiralWorld, wrap); | |
| this.mesh.position.copy(worldToMainLocal(lineWorld, lineWorld)); | |
| // Keep them visible from the start; add a tiny ease-in just for polish | |
| const a = THREE.MathUtils.smoothstep(g, 0.00, 0.10); | |
| this.mesh.scale.setScalar(this.baseScale * a); | |
| return; | |
| } | |
| // Other ornaments: appear near final position, sparkle, then settle in. | |
| if (!this.intro || typeof this.intro.appearStart !== 'number') return; | |
| const p = THREE.MathUtils.clamp((NOW - this.intro.appearStart) / (this.intro.appearDur || 2.0), 0, 1); | |
| if (p <= 0) { | |
| this.mesh.scale.setScalar(0.0001); | |
| return; | |
| } | |
| const a = THREE.MathUtils.smoothstep(p, 0, 1); | |
| const from = this._tmp1.copy(this.posTree).add(this.intro.offset || this._tmp2.set(0,0,0)); | |
| from.lerp(this.posTree, a); | |
| this.mesh.position.copy(from); | |
| this.mesh.scale.setScalar(this.baseScale * a); | |
| if (!this.intro.sparked && a > 0.08 && (this.mesh.id % 9 === 0)) { | |
| this.intro.sparked = true; | |
| spawnAppearSparkles(this.posTree, 1.0); | |
| } | |
| return; | |
| } | |
| let target = this.posTree; | |
| if (mode === 'SCATTER') target = this.posScatter; | |
| else if (mode === 'TEXT') target = this.posText; | |
| else if (mode === 'FOCUS') { | |
| if (this.mesh === focusTargetMesh) { | |
| let offset = new THREE.Vector3(0, 1, 38); | |
| if (STATE.focusType === 1) offset.set(-4, 2, 35); | |
| else if (STATE.focusType === 2) offset.set(3, 0, 32); | |
| else if (STATE.focusType === 3) offset.set(0, -2.5, 30); | |
| const invMatrix = new THREE.Matrix4().copy(mainGroup.matrixWorld).invert(); | |
| target = offset.applyMatrix4(invMatrix); | |
| } else target = this.posScatter; | |
| } | |
| const lerpSpeed = | |
| (mode === 'FOCUS' && this.mesh === focusTargetMesh) ? 8.0 : | |
| (mode === 'TEXT' ? 7.0 : 4.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 === 'TEXT') { | |
| this.mesh.rotation.x = THREE.MathUtils.lerp(this.mesh.rotation.x, 0, dt); | |
| this.mesh.rotation.y = THREE.MathUtils.lerp(this.mesh.rotation.y, 0, dt); | |
| this.mesh.rotation.z = THREE.MathUtils.lerp(this.mesh.rotation.z, 0, 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); | |
| 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; | |
| if (mode === 'TEXT') s *= 0.3; | |
| } else if (mode === 'SCATTER' && this.type === 'PHOTO') s = this.baseScale * 2.5; | |
| else if (mode === 'FOCUS') { | |
| if (this.mesh === focusTargetMesh) { | |
| 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; | |
| } else if (mode === 'TEXT') { | |
| s = this.baseScale * 0.95; | |
| } | |
| this.mesh.scale.lerp(new THREE.Vector3(s,s,s), 6*dt); | |
| } | |
| } | |
| function createParticles() { | |
| treeStar = null; | |
| treeStarLight = null; | |
| if (starRays) { | |
| mainGroup.remove(starRays); | |
| if (starRays.material?.map) starRays.material.map.dispose(); | |
| starRays.material?.dispose?.(); | |
| starRays = null; | |
| } | |
| const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32); | |
| const boxGeo = new THREE.BoxGeometry(0.55, 0.55, 0.55); | |
| const coneGeo = new THREE.ConeGeometry(0.38, 0.95, 18, 1); | |
| 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.10, 10, false); | |
| // Materials are centralized (defaults + themes) | |
| const mats = ensureOrnamentMaterialSets(); | |
| const defaultMats = mats[0]; | |
| // Deterministic counts => denser spirals and fuller body. | |
| const total = CONFIG.particles.count; | |
| // More balls overall to keep the spirals continuously filled (especially in the wider lower tree). | |
| const goldSphereCount = Math.floor(total * 0.22); | |
| const redSphereCount = Math.floor(total * 0.14); | |
| const caneCount = Math.floor(total * 0.05); | |
| const greenConeCount = Math.floor(total * 0.34); | |
| const greenBoxCount = Math.floor(total * 0.16); | |
| const goldBoxCount = Math.max(0, total - (goldSphereCount + redSphereCount + caneCount + greenConeCount + greenBoxCount)); | |
| const add = (mesh, type, spiralT = null) => { | |
| if (spiralT !== null) mesh.userData.spiralT = spiralT; | |
| mainGroup.add(mesh); | |
| particleSystem.push(new Particle(mesh, type, false)); | |
| }; | |
| // Green metallic cones: dense body fill (inside-biased by Particle.calculatePositions) | |
| for (let i = 0; i < greenConeCount; i++) { | |
| const mesh = new THREE.Mesh(coneGeo, defaultMats.GREEN_CONE); | |
| const s = 0.42 + Math.random() * 0.55; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set((Math.random()-0.5)*0.15, Math.random()*Math.PI*2, (Math.random()-0.5)*0.15); | |
| add(mesh, 'GREEN_CONE'); | |
| } | |
| // Green cubes: extra body volume | |
| for (let i = 0; i < greenBoxCount; i++) { | |
| const mesh = new THREE.Mesh(boxGeo, defaultMats.BOX); | |
| const s = 0.38 + Math.random() * 0.55; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6); | |
| add(mesh, 'BOX'); | |
| } | |
| // Gold boxes: accent fill | |
| for (let i = 0; i < goldBoxCount; i++) { | |
| const mesh = new THREE.Mesh(boxGeo, defaultMats.GOLD_BOX); | |
| const s = 0.38 + Math.random() * 0.55; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6); | |
| add(mesh, 'GOLD_BOX'); | |
| } | |
| // Gold spheres spiral: evenly spaced along the helix (continuous look) | |
| for (let i = 0; i < goldSphereCount; i++) { | |
| const mesh = new THREE.Mesh(sphereGeo, defaultMats.GOLD_SPHERE); | |
| const s = 0.40 + Math.random() * 0.45; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6); | |
| // Bias distribution toward the bottom where circumference is larger (fewer perceived gaps). | |
| const u = (goldSphereCount <= 1) ? 0.5 : (i / (goldSphereCount - 1)); | |
| const t = 1 - Math.sqrt(1 - u); | |
| add(mesh, 'GOLD_SPHERE', t); | |
| } | |
| // Red spheres spiral: evenly spaced, phase-shifted in Particle.calculatePositions | |
| for (let i = 0; i < redSphereCount; i++) { | |
| const mesh = new THREE.Mesh(sphereGeo, defaultMats.RED); | |
| const s = 0.40 + Math.random() * 0.45; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6); | |
| const u = (redSphereCount <= 1) ? 0.5 : (i / (redSphereCount - 1)); | |
| const t = 1 - Math.sqrt(1 - u); | |
| add(mesh, 'RED', t); | |
| } | |
| // Candy canes: slightly bigger | |
| for (let i = 0; i < caneCount; i++) { | |
| const mesh = new THREE.Mesh(candyGeo, defaultMats.CANE); | |
| const s = 0.55 + Math.random() * 0.55; | |
| mesh.scale.set(s,s,s); | |
| mesh.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6); | |
| add(mesh, 'CANE'); | |
| } | |
| // Tree topper: shining golden star | |
| treeStar = createStarMesh(); | |
| treeStar.position.set(0, CONFIG.particles.treeHeight/2 + 1.55, 0); | |
| mainGroup.add(treeStar); | |
| treeStarLight = new THREE.PointLight(0xffdd88, 2.2, 18); | |
| treeStarLight.position.set(0, CONFIG.particles.treeHeight/2 + 1.55, 2.5); | |
| mainGroup.add(treeStarLight); | |
| // If intro already finished (or scene rebuilt), keep star non-emissive by default. | |
| if (STATE.intro && STATE.intro.ran && !STATE.intro.active) { | |
| if (treeStar.material) { | |
| treeStar.material.transparent = false; | |
| treeStar.material.opacity = 1; | |
| treeStar.material.emissiveIntensity = 0; | |
| } | |
| treeStarLight.intensity = 0; | |
| } | |
| mainGroup.add(photoMeshGroup); | |
| // Keep materials consistent if user already switched themes, and keep snow/petals consistent. | |
| applyTheme(themeIndex); | |
| } | |
| 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("JOYEUX", 256, 230); ctx.fillText("NOEL", 256, 300); | |
| createPhotoTexture(canvas.toDataURL(), 'default'); | |
| } | |
| function createPhotoTexture(base64, id) { | |
| const img = new Image(); | |
| img.src = base64; | |
| img.onload = () => { | |
| const tex = new THREE.Texture(img); | |
| tex.colorSpace = THREE.SRGBColorSpace; | |
| tex.needsUpdate = true; | |
| addPhotoToScene(tex, id, base64); | |
| } | |
| } | |
| function addPhotoToScene(texture, id, base64) { | |
| const frameGeo = new THREE.BoxGeometry(1.4, 1.4, 0.05); | |
| const frameMat = ensureOrnamentMaterialSets()[0].PHOTO_FRAME; | |
| const frame = new THREE.Mesh(frameGeo, frameMat); | |
| const photoGeo = new THREE.PlaneGeometry(1.2, 1.2); | |
| const photoMat = new THREE.MeshBasicMaterial({ map: texture }); | |
| const photo = new THREE.Mesh(photoGeo, photoMat); | |
| photo.position.z = 0.04; | |
| const group = new THREE.Group(); | |
| group.add(frame); group.add(photo); | |
| const s = 0.8; group.scale.set(s,s,s); | |
| photoMeshGroup.add(group); | |
| const p = new Particle(group, 'PHOTO', false); | |
| p.photoId = id; | |
| p.texture = texture; | |
| particleSystem.push(p); | |
| // If intro is running, make photos participate in the same "appear + settle + sparkles" sequence. | |
| scheduleIntroForParticle(p); | |
| // Apply active theme to the new photo frame immediately | |
| applyTheme(themeIndex); | |
| } | |
| window.applyParticleSettings = function() { | |
| const photos = particleSystem.filter(p => p.type === 'PHOTO'); | |
| const toRemove = []; | |
| mainGroup.children.forEach(c => { | |
| if(c !== photoMeshGroup) toRemove.push(c); | |
| }); | |
| toRemove.forEach(c => mainGroup.remove(c)); | |
| particleSystem = [...photos]; | |
| CONFIG.particles.count = parseInt(document.getElementById('slider-tree').value); | |
| CONFIG.particles.dustCount = parseInt(document.getElementById('slider-dust').value); | |
| createParticles(); | |
| createDust(); | |
| if (STATE.mode === 'TEXT') buildTextShapeTargets(); | |
| } | |
| async function initMediaPipe() { | |
| videoElement = document.getElementById('webcam-video'); | |
| const hint = document.getElementById('gesture-hint'); | |
| if (navigator.mediaDevices?.getUserMedia) { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ video: true }); | |
| videoElement.srcObject = stream; | |
| videoElement.onloadedmetadata = () => { | |
| videoElement.play(); | |
| renderWebcamPreview(); | |
| }; | |
| } catch (e) { | |
| console.error("Camera denied:", e); | |
| hint.innerText = uiT('hintCameraDenied'); | |
| } | |
| } | |
| try { | |
| const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"); | |
| gestureRecognizer = await GestureRecognizer.createFromOptions(vision, { | |
| baseOptions: { modelAssetPath: `https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task`, delegate: "GPU" }, | |
| runningMode: "VIDEO", numHands: 1 | |
| }); | |
| hint.innerText = uiT('hintAiReady'); | |
| predictWebcam(); | |
| } catch(e) { console.warn("AI Load Failed:", e); hint.innerText = uiT('hintAiFailed'); } | |
| } | |
| function renderWebcamPreview() { | |
| const canvas = document.getElementById('webcam-canvas'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| function draw() { | |
| // Black background + hand skeleton only (no mirrored camera feed, no text overlay) | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| if (STATE.gesture.lastLandmarks && STATE.gesture.lastLandmarks.length) { | |
| drawHandSkeleton(ctx, STATE.gesture.lastLandmarks); | |
| } | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| } | |
| function drawGestureOverlay(ctx) { | |
| // 1) Hand skeleton (landmarks) | |
| if (STATE.gesture.lastLandmarks && STATE.gesture.lastLandmarks.length) { | |
| drawHandSkeleton(ctx, STATE.gesture.lastLandmarks); | |
| } | |
| // 2) Gesture labels (make text readable despite CSS mirror) | |
| const baseLines = (STATE.gesture.displayLines && STATE.gesture.displayLines.length > 0) | |
| ? STATE.gesture.displayLines | |
| : (STATE.hand.detected ? [uiT('overlayDetecting')] : [uiT('overlayNoHand')]); | |
| // IMPORTANT: do not mutate STATE.gesture.displayLines (make a copy before pushing debug lines) | |
| const lines = baseLines.slice(0, 4); | |
| if (STATE.hand.detected) { | |
| const pr = STATE.gesture.pinch?.ratio ?? 0; | |
| const pd = STATE.gesture.pinch?.dist ?? 0; | |
| const pa = STATE.gesture.pinch?.active ? "ON" : "OFF"; | |
| const lm = STATE.gesture.lastLandmarks; | |
| if (lm && lm.length >= 10) { | |
| const wrist = lm[0]; | |
| const index = lm[8]; | |
| const handScale = Math.hypot(lm[9].x - wrist.x, lm[9].y - wrist.y) + 1e-6; | |
| const idxReach = Math.hypot(index.x - wrist.x, index.y - wrist.y) / handScale; | |
| const openDist = (Math.hypot(lm[8].x-wrist.x, lm[8].y-wrist.y)+Math.hypot(lm[12].x-wrist.x, lm[12].y-wrist.y)+Math.hypot(lm[16].x-wrist.x, lm[16].y-wrist.y)+Math.hypot(lm[20].x-wrist.x, lm[20].y-wrist.y))/4; | |
| const openRatio = openDist / handScale; | |
| const dW = (pt) => Math.hypot(pt.x - wrist.x, pt.y - wrist.y) / handScale; | |
| const ext = (tip, pip) => ((dW(lm[tip]) - dW(lm[pip])) > 0.18) ? 1 : 0; | |
| const extCount = ext(12,10) + ext(16,14) + ext(20,18); | |
| const tf = STATE.gesture.thumbFlick; | |
| const tfState = tf?.active ? "ARM" : "IDLE"; | |
| lines.push(`pinch ${pa} r=${pr.toFixed(2)} d=${pd.toFixed(3)} idx=${idxReach.toFixed(2)} open=${openRatio.toFixed(2)} ext=${extCount}/3`); | |
| lines.push(`thumbFlick ${tfState} dx=${((lm[4].x - wrist.x)/handScale).toFixed(2)} reach=${(dW(lm[4])).toFixed(2)}`); | |
| } else { | |
| lines.push(`pinch ${pa} r=${pr.toFixed(2)} d=${pd.toFixed(3)}`); | |
| } | |
| } | |
| ctx.save(); | |
| // Canvas element is mirrored via CSS (scaleX(-1)); flip drawing so text reads normally. | |
| ctx.translate(ctx.canvas.width, 0); | |
| ctx.scale(-1, 1); | |
| ctx.font = '12px "Microsoft YaHei", sans-serif'; | |
| const padding = 6; | |
| const lineHeight = 16; | |
| let maxWidth = 0; | |
| lines.forEach(l => { const w = ctx.measureText(l).width; if (w > maxWidth) maxWidth = w; }); | |
| const boxW = maxWidth + padding * 2; | |
| const boxH = lines.length * lineHeight + padding * 2; | |
| const x = 8, y = 8; | |
| ctx.globalAlpha = 0.65; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(x, y, boxW, boxH); | |
| ctx.globalAlpha = 1.0; | |
| ctx.fillStyle = '#d4af37'; | |
| lines.forEach((l, i) => ctx.fillText(l, x + padding, y + padding + (i+1) * lineHeight - 4)); | |
| ctx.restore(); | |
| } | |
| function drawHandSkeleton(ctx, lm) { | |
| const w = ctx.canvas.width; | |
| const h = ctx.canvas.height; | |
| const C = [ | |
| [0,1],[1,2],[2,3],[3,4], | |
| [0,5],[5,6],[6,7],[7,8], | |
| [0,9],[9,10],[10,11],[11,12], | |
| [0,13],[13,14],[14,15],[15,16], | |
| [0,17],[17,18],[18,19],[19,20], | |
| [5,9],[9,13],[13,17] | |
| ]; | |
| ctx.save(); | |
| ctx.lineWidth = 2.0; | |
| ctx.strokeStyle = 'rgba(212,175,55,0.95)'; | |
| ctx.shadowColor = 'rgba(212,175,55,0.6)'; | |
| ctx.shadowBlur = 6; | |
| ctx.beginPath(); | |
| C.forEach(([a,b]) => { | |
| const A = lm[a], B = lm[b]; | |
| if (!A || !B) return; | |
| ctx.moveTo(A.x * w, A.y * h); | |
| ctx.lineTo(B.x * w, B.y * h); | |
| }); | |
| ctx.stroke(); | |
| // keypoints | |
| ctx.shadowBlur = 0; | |
| ctx.fillStyle = 'rgba(255,255,255,0.9)'; | |
| lm.forEach(p => { | |
| if (!p) return; | |
| ctx.beginPath(); | |
| ctx.arc(p.x * w, p.y * h, 2.4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| ctx.restore(); | |
| } | |
| async function predictWebcam() { | |
| if (videoElement && gestureRecognizer && videoElement.readyState >= 2) { | |
| const result = gestureRecognizer.recognizeForVideo(videoElement, performance.now()); | |
| processGestures(result); | |
| if (result.landmarks && result.landmarks.length > 0) document.getElementById('cam-status').classList.add('active'); | |
| else document.getElementById('cam-status').classList.remove('active'); | |
| } | |
| requestAnimationFrame(predictWebcam); | |
| } | |
| // --- REALTIME ZERO-LATENCY GESTURES --- | |
| function processGestures(result) { | |
| const hint = document.getElementById('gesture-hint'); | |
| if (result.landmarks && result.landmarks.length > 0) { | |
| const now = clock.elapsedTime; | |
| STATE.hand.detected = true; | |
| const lm = result.landmarks[0]; | |
| STATE.gesture.lastLandmarks = lm; | |
| const rawX = (lm[9].x - 0.5) * 2; | |
| const rawY = (lm[9].y - 0.5) * 2; | |
| const smoothFactor = 0.25; | |
| STATE.hand.x = THREE.MathUtils.lerp(STATE.hand.x, rawX, smoothFactor); | |
| STATE.hand.y = THREE.MathUtils.lerp(STATE.hand.y, rawY, smoothFactor); | |
| const topGesture = (result.gestures && result.gestures[0] && result.gestures[0][0]) ? result.gestures[0][0] : null; | |
| const label = (topGesture?.categoryName || 'NONE').toUpperCase(); | |
| const labelScore = topGesture?.score ?? 0; | |
| const displayLines = []; | |
| if (result.gestures && result.gestures[0]) { | |
| result.gestures[0].slice(0,3).forEach(g => displayLines.push(`${g.categoryName} ${(g.score*100).toFixed(0)}%`)); | |
| } | |
| STATE.gesture.displayLines = displayLines; | |
| 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 openDist = 0; tips.forEach(t => openDist += Math.hypot(t.x - wrist.x, t.y - wrist.y)); openDist /= 4; | |
| // Normalize by hand size (wrist -> middle MCP) to make pinch more consistent across distances. | |
| const handScale = Math.hypot(lm[9].x - wrist.x, lm[9].y - wrist.y) + 1e-6; | |
| STATE.hand.scale = handScale; | |
| const pinchRatio = pinchDist / handScale; | |
| const indexReachRatio = Math.hypot(index.x - wrist.x, index.y - wrist.y) / handScale; | |
| const openRatio = openDist / handScale; | |
| STATE.gesture.pinch.dist = pinchDist; | |
| STATE.gesture.pinch.ratio = pinchRatio; | |
| // Hand depth signal for SCATTER zoom: use landmark z when available, fallback to -handScale. | |
| const rawZ = (lm[9] && typeof lm[9].z === 'number' && Number.isFinite(lm[9].z)) ? lm[9].z : (-handScale); | |
| if (!Number.isFinite(STATE.hand.z)) STATE.hand.z = rawZ; | |
| STATE.hand.z = THREE.MathUtils.lerp(STATE.hand.z, rawZ, 0.25); | |
| // Finger extension heuristic (rotation-robust): tip should be meaningfully farther from wrist than PIP. | |
| const distToWrist = (pt) => Math.hypot(pt.x - wrist.x, pt.y - wrist.y) / handScale; | |
| const isExtended = (tipIdx, pipIdx) => { | |
| const tip = lm[tipIdx], pip = lm[pipIdx]; | |
| if (!tip || !pip) return false; | |
| return (distToWrist(tip) - distToWrist(pip)) > 0.18; | |
| }; | |
| const middleExtended = isExtended(12, 10); | |
| const ringExtended = isExtended(16, 14); | |
| const pinkyExtended = isExtended(20, 18); | |
| const extendedCount = (middleExtended ? 1 : 0) + (ringExtended ? 1 : 0) + (pinkyExtended ? 1 : 0); | |
| // Pinch hysteresis (enter/exit thresholds) + small frame buffer | |
| // r = pinchRatio = (thumb-index distance) / (handScale). This is the primary signal (robust to camera distance). | |
| // d = pinchDist = raw thumb-index distance in normalized image coords. | |
| const PINCH_ON_RATIO = 0.22; | |
| const PINCH_OFF_RATIO = 0.30; | |
| const PINCH_MAX_DIST_GUARD = 0.12; // safety guard against weird spikes | |
| // Extra guard: closed fist can look like pinch (thumb crosses index). | |
| // Per request: prefer "photo pinch" where other 3 fingers are extended (reduce fist confusion). | |
| const PINCH_MIN_INDEX_REACH_RATIO = 0.85; | |
| const PINCH_MIN_OPEN_RATIO = 0.75; | |
| const pinchWantsOn = ( | |
| pinchRatio <= PINCH_ON_RATIO && | |
| pinchDist <= PINCH_MAX_DIST_GUARD && | |
| indexReachRatio >= PINCH_MIN_INDEX_REACH_RATIO && | |
| openRatio >= PINCH_MIN_OPEN_RATIO && | |
| extendedCount >= 2 | |
| ); | |
| // Exit pinch mostly based on ratio hysteresis; don't drop just because other fingers curl. | |
| const pinchWantsOff = (pinchRatio >= PINCH_OFF_RATIO); | |
| if (STATE.gesture.pinch.active) { | |
| if (pinchWantsOff) { STATE.gesture.pinch.offFrames += 1; STATE.gesture.pinch.onFrames = 0; } | |
| else { STATE.gesture.pinch.offFrames = 0; } | |
| if (STATE.gesture.pinch.offFrames >= 2) { STATE.gesture.pinch.active = false; STATE.gesture.pinch.offFrames = 0; } | |
| } else { | |
| if (pinchWantsOn) { STATE.gesture.pinch.onFrames += 1; STATE.gesture.pinch.offFrames = 0; } | |
| else { STATE.gesture.pinch.onFrames = 0; } | |
| if (STATE.gesture.pinch.onFrames >= 2) { STATE.gesture.pinch.active = true; STATE.gesture.pinch.onFrames = 0; } | |
| } | |
| // Focus release: when thumb/index tips separate, immediately release the focused photo. | |
| // (Don't wait for gesture debouncing; "open pinch" should feel instant.) | |
| if (STATE.mode === 'FOCUS' && pinchWantsOff) { | |
| STATE.gesture.pinch.active = false; | |
| STATE.gesture.pinch.onFrames = 0; | |
| STATE.gesture.pinch.offFrames = 0; | |
| STATE.focusTarget = null; | |
| STATE.mode = 'SCATTER'; | |
| STATE.gesture.current = 'SCATTER'; | |
| } | |
| // OPEN_PALM (scatter) should be responsive; use normalized openRatio so it works across camera distances. | |
| const OPEN_DIST_TO_TREE = 0.22; | |
| const OPEN_DIST_TO_SCATTER = 0.30; | |
| const OPEN_RATIO_TO_TREE = 1.05; | |
| const OPEN_RATIO_TO_SCATTER = 1.15; | |
| const CONF_TH = 0.60; // only trust classifier labels when confident (except OPEN_PALM below) | |
| const OPEN_PALM_CONF = 0.45; | |
| const openPalmByLabel = (labelScore >= OPEN_PALM_CONF && label === 'OPEN_PALM'); | |
| const openPalmByHeuristic = (extendedCount >= 2) && ( | |
| (openRatio >= OPEN_RATIO_TO_SCATTER) || | |
| (openDist >= OPEN_DIST_TO_SCATTER) | |
| ); | |
| const openPalm = openPalmByLabel || openPalmByHeuristic; | |
| const wasFist = !!STATE.gesture.fistActive; | |
| const fistLabel = (labelScore >= CONF_TH && label === 'CLOSED_FIST'); | |
| // --- Thumb horizontal flick: cycle theme --- | |
| // Supports both directions: | |
| // - IN -> OUT -> IN (extend then tuck) | |
| // - OUT -> IN -> OUT (thumb already extended, tuck then extend) | |
| // We detect a quick "excursion and return" relative to the current thumb rest state. | |
| const thumbTip = lm[4]; | |
| const dxN = (thumbTip.x - wrist.x) / handScale; | |
| const dyN = (thumbTip.y - wrist.y) / handScale; | |
| const thumbReach = distToWrist(thumbTip); | |
| const absDx = Math.abs(dxN); | |
| const absDy = Math.abs(dyN); | |
| // Flick eligibility: | |
| // - Default: fairly strict (thumb-only motion with hand somewhat closed) | |
| // - BUT: if you're already in SCATTER, allow flick even with OPEN_PALM so you can change themes while scattered. | |
| const allowOpenPalmFlick = (STATE.mode === 'SCATTER') && openPalm; | |
| const flickEligible = allowOpenPalmFlick || ((openRatio < 1.45) && (extendedCount <= 1)); | |
| const sidewaysLoose = absDx > absDy * (allowOpenPalmFlick ? 0.60 : 0.80); | |
| const sidewaysStrict = absDx > absDy * (allowOpenPalmFlick ? 0.85 : 1.00); | |
| const tf = STATE.gesture.thumbFlick; | |
| if (!tf.phase) tf.phase = 'IDLE'; | |
| if (!tf.rest) tf.rest = 'IN'; | |
| const cooldownOk = (now - (tf.lastTrigger || 0)) > 0.55; | |
| // Thresholds (tunable) | |
| const OUT_TH = 0.52; // consider thumb "out" when absDx is above this | |
| const IN_TH = 0.30; // consider thumb "in" when absDx is below this (or reach is small) | |
| const ARM_DELTA = 0.08; // move away from rest by this much to arm | |
| const TRIGGER_DROP = 0.12; // excursion size required (OUT->IN or IN->OUT) | |
| const RETURN_MARGIN = 0.04; // how close to rest to count as "returned" | |
| const WINDOW = 1.15; // seconds to complete the excursion+return | |
| const MIN_PHASE = 0.06; // prevent instant retriggers from jitter | |
| const outNow = flickEligible && sidewaysLoose && (thumbReach > 0.88) && (absDx > OUT_TH); | |
| const inNow = flickEligible && ((thumbReach < 0.86) || (absDx < IN_TH)); | |
| if (tf.phase === 'IDLE') { | |
| tf.active = false; | |
| const prevRest = tf.rest; | |
| // Arm based on the PREVIOUS rest state, so OUT->IN transitions don't lose the rest=OUT info. | |
| if (cooldownOk && flickEligible) { | |
| if (prevRest === 'OUT') { | |
| const baseOut = (typeof tf.maxAbsDx === 'number' && Number.isFinite(tf.maxAbsDx)) ? tf.maxAbsDx : absDx; | |
| const tuckedEnough = (absDx < (baseOut - ARM_DELTA)); | |
| if (tuckedEnough) { | |
| tf.phase = 'WAIT_OUT_RETURN'; | |
| tf.active = true; | |
| tf.start = now; | |
| tf.maxAbsDx = baseOut; // remember the out baseline | |
| tf.minAbsDx = absDx; // track minimum while tucked | |
| } | |
| } else { // rest IN | |
| const baseIn = (typeof tf.minAbsDx === 'number' && Number.isFinite(tf.minAbsDx)) ? tf.minAbsDx : absDx; | |
| const extendedEnough = (absDx > (baseIn + ARM_DELTA)) && sidewaysLoose && (thumbReach > 0.88); | |
| if (extendedEnough) { | |
| tf.phase = 'WAIT_IN_RETURN'; | |
| tf.active = true; | |
| tf.start = now; | |
| tf.minAbsDx = baseIn; // remember the in baseline | |
| tf.maxAbsDx = absDx; // track maximum while extended | |
| } | |
| } | |
| } | |
| // Update rest calibration AFTER arming check (use softer thresholds so "thumb already out" is recognized) | |
| if (outNow) { | |
| tf.rest = 'OUT'; | |
| tf.maxAbsDx = (typeof tf.maxAbsDx === 'number' && Number.isFinite(tf.maxAbsDx)) | |
| ? (0.85 * tf.maxAbsDx + 0.15 * absDx) | |
| : absDx; | |
| } else if (inNow) { | |
| tf.rest = 'IN'; | |
| tf.minAbsDx = (typeof tf.minAbsDx === 'number' && Number.isFinite(tf.minAbsDx)) | |
| ? (0.85 * tf.minAbsDx + 0.15 * absDx) | |
| : absDx; | |
| } | |
| } else if (tf.phase === 'WAIT_OUT_RETURN') { | |
| tf.active = true; | |
| tf.minAbsDx = Math.min(tf.minAbsDx ?? absDx, absDx); | |
| const dtF = now - (tf.start || now); | |
| const drop = (tf.maxAbsDx ?? absDx) - (tf.minAbsDx ?? absDx); | |
| const backOut = (outNow && sidewaysStrict) || (absDx > (tf.maxAbsDx ?? absDx) - RETURN_MARGIN); | |
| if (!cooldownOk || !flickEligible || dtF > WINDOW) { | |
| tf.phase = 'IDLE'; | |
| tf.active = false; | |
| } else if (dtF > MIN_PHASE && backOut && drop > TRIGGER_DROP) { | |
| cycleTheme(); | |
| tf.lastTrigger = now; | |
| tf.phase = 'IDLE'; | |
| tf.active = false; | |
| tf.rest = 'OUT'; | |
| tf.maxAbsDx = absDx; | |
| } | |
| } else if (tf.phase === 'WAIT_IN_RETURN') { | |
| tf.active = true; | |
| tf.maxAbsDx = Math.max(tf.maxAbsDx ?? absDx, absDx); | |
| const dtF = now - (tf.start || now); | |
| const rise = (tf.maxAbsDx ?? absDx) - (tf.minAbsDx ?? absDx); | |
| const backIn = inNow || (absDx < (tf.minAbsDx ?? absDx) + RETURN_MARGIN); | |
| if (!cooldownOk || !flickEligible || dtF > WINDOW) { | |
| tf.phase = 'IDLE'; | |
| tf.active = false; | |
| } else if (dtF > MIN_PHASE && backIn && rise > TRIGGER_DROP) { | |
| cycleTheme(); | |
| tf.lastTrigger = now; | |
| tf.phase = 'IDLE'; | |
| tf.active = false; | |
| tf.rest = 'IN'; | |
| tf.minAbsDx = absDx; | |
| } | |
| } else { | |
| tf.phase = 'IDLE'; | |
| tf.active = false; | |
| } | |
| let detectedGesture = 'NONE'; | |
| if (labelScore >= CONF_TH && label === 'THUMB_UP') detectedGesture = 'TEXT_EN'; | |
| else if (labelScore >= CONF_TH && label === 'VICTORY') detectedGesture = 'TEXT_CN'; | |
| else if (labelScore >= CONF_TH && label === 'CLOSED_FIST') { | |
| detectedGesture = 'TREE'; | |
| STATE.gesture.pinch.active = false; | |
| // Fireworks: ONLY trigger when transitioning from another gesture -> CLOSED_FIST. | |
| // Do NOT trigger when coming from "no hand / no gesture". | |
| const cameFromOtherGesture = !!STATE.gesture.prevHadHand && ( | |
| STATE.gesture.current === 'SCATTER' || | |
| STATE.gesture.current === 'FOCUS' || | |
| STATE.gesture.current === 'TEXT_EN' || | |
| STATE.gesture.current === 'TEXT_CN' | |
| ); | |
| // Fireworks removed | |
| STATE.gesture.fistActive = true; | |
| } | |
| else if (STATE.gesture.pinch.active) detectedGesture = 'FOCUS'; | |
| else if (openPalm) detectedGesture = 'SCATTER'; | |
| else if ((openRatio < OPEN_RATIO_TO_TREE) && (openDist < OPEN_DIST_TO_TREE)) detectedGesture = 'TREE'; | |
| else STATE.gesture.fistActive = false; | |
| // Fireworks removed | |
| if (detectedGesture === STATE.gesture.candidate) STATE.gesture.stableFrames += 1; | |
| else { STATE.gesture.candidate = detectedGesture; STATE.gesture.stableFrames = 1; } | |
| // More stability for pinch/text so we don't flicker between two close classes. | |
| const requiredFrames = | |
| detectedGesture === 'FOCUS' ? 3 : | |
| (detectedGesture === 'TEXT_EN' || detectedGesture === 'TEXT_CN') ? 3 : | |
| detectedGesture === 'SCATTER' ? 1 : 2; | |
| if (STATE.gesture.stableFrames >= requiredFrames && detectedGesture !== 'NONE' && detectedGesture !== STATE.gesture.current) { | |
| applyGestureMode(detectedGesture); | |
| STATE.gesture.current = detectedGesture; | |
| } | |
| if (STATE.gesture.current === 'FOCUS') hint.innerText = uiT('statusFocus'); | |
| else if (STATE.gesture.current === 'SCATTER') hint.innerText = uiT('statusScatter'); | |
| else if (STATE.gesture.current === 'TEXT_EN') hint.innerText = uiT('statusTextEN'); | |
| else if (STATE.gesture.current === 'TEXT_CN') hint.innerText = uiT('statusTextCN'); | |
| else hint.innerText = uiT('statusTree'); | |
| // Remember whether we had a hand in the previous frame (for edge-triggering effects) | |
| STATE.gesture.prevHadHand = true; | |
| } else { | |
| STATE.hand.detected = false; hint.innerText = uiT('hintWaiting'); | |
| STATE.gesture.candidate = 'NONE'; STATE.gesture.stableFrames = 0; | |
| STATE.gesture.displayLines = [uiT('overlayNoHand')]; | |
| STATE.gesture.lastLandmarks = null; | |
| STATE.gesture.pinch.active = false; | |
| STATE.gesture.pinch.onFrames = 0; | |
| STATE.gesture.pinch.offFrames = 0; | |
| STATE.gesture.prevHadHand = false; | |
| STATE.gesture.fistActive = false; | |
| // Fireworks removed | |
| STATE.gesture.thumbFlick.active = false; | |
| STATE.gesture.thumbFlick.phase = 'IDLE'; | |
| STATE.gesture.thumbFlick.rest = 'IN'; | |
| } | |
| } | |
| function applyGestureMode(mode) { | |
| const prevMode = STATE.mode; | |
| if (mode === 'FOCUS') { | |
| const target = findClosestPhoto(); | |
| if (target) { STATE.mode = 'FOCUS'; STATE.focusTarget = target; } | |
| } else if (mode === 'TEXT_EN') { | |
| STATE.textVariant = 'EN'; | |
| buildTextShapeTargets(); | |
| if (STATE.textPoints.length > 0) { STATE.mode = 'TEXT'; STATE.focusTarget = null; } | |
| } else if (mode === 'TEXT_CN') { | |
| STATE.textVariant = 'CN'; | |
| buildTextShapeTargets(); | |
| if (STATE.textPoints.length > 0) { STATE.mode = 'TEXT'; STATE.focusTarget = null; } | |
| } else if (mode === 'SCATTER') { | |
| STATE.mode = 'SCATTER'; STATE.focusTarget = null; | |
| if (prevMode !== 'SCATTER') { | |
| // Capture baseline zoom at the moment the tree explodes (enter SCATTER). | |
| STATE.scatterZoom.active = true; | |
| STATE.scatterZoom.baseZ = camera.position.z; | |
| STATE.scatterZoom.refHandZ = STATE.hand.detected ? STATE.hand.z : null; | |
| } | |
| } else if (mode === 'TREE') { | |
| STATE.mode = 'TREE'; STATE.focusTarget = null; | |
| } | |
| // Leaving SCATTER resets the scatter zoom state; camera will ease back to CONFIG.camera.z in animate(). | |
| if (prevMode === 'SCATTER' && STATE.mode !== 'SCATTER') { | |
| STATE.scatterZoom.active = false; | |
| STATE.scatterZoom.refHandZ = null; | |
| } | |
| } | |
| function findClosestPhoto() { | |
| let closestPhoto = null; let minScreenDist = 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 screenPos = pos.project(camera); | |
| const dist = Math.hypot(screenPos.x, screenPos.y); | |
| if (screenPos.z < 1 && dist < CONFIG.interaction.grabRadius && dist < minScreenDist) { | |
| minScreenDist = dist; closestPhoto = p.mesh; | |
| } | |
| }); | |
| return closestPhoto; | |
| } | |
| function buildTextShapeTargets() { | |
| const ornaments = particleSystem.filter(p => !p.isDust); | |
| if (!ornaments.length) return; | |
| let line1 = "Merry"; | |
| let line2 = "Christmas"; | |
| if (STATE.textVariant === 'CN') { | |
| line1 = "圣诞快乐"; | |
| line2 = ""; | |
| } else { | |
| line1 = document.getElementById('display-line1').innerText || "Merry"; | |
| line2 = document.getElementById('display-line2').innerText || "Christmas"; | |
| } | |
| const points = generateTextPoints(line1, line2, ornaments.length, STATE.textVariant); | |
| if (!points.length) return; | |
| STATE.textPoints = points; | |
| ornaments.forEach((p, i) => { | |
| const idx = i % STATE.textPoints.length; | |
| p.posText.copy(STATE.textPoints[idx]); | |
| }); | |
| } | |
| function generateTextPoints(line1, line2, targetCount, variant = 'EN') { | |
| // Better plan: build a true text mask, then sample points *inside the glyphs* (outline + fill). | |
| // This preserves letter shapes/holes and avoids “rectangle plane” artifacts. | |
| const needed = Math.max(1, targetCount | 0); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 1600; canvas.height = 800; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); // transparent bg | |
| // Render text into alpha channel (CN needs a Chinese-capable font + per-char spacing) | |
| const t1 = (line1 || "").trim(); | |
| const t2 = (line2 || "").trim(); | |
| const hasTwoLines = !!t2; | |
| const fontFamily = (variant === 'CN') | |
| ? `'Ma Shan Zheng','Songti SC','SimSun',serif` | |
| : `'Cinzel', serif`; | |
| const trackingFrac = (variant === 'CN') ? 0.16 : 0.04; // slightly more spacing for CN chars | |
| const measureTextWithTracking = (text, trackingPx) => { | |
| if (!text) return 0; | |
| let w = 0; | |
| for (let i = 0; i < text.length; i++) { | |
| w += ctx.measureText(text[i]).width; | |
| if (i !== text.length - 1) w += trackingPx; | |
| } | |
| return w; | |
| }; | |
| const drawTextWithTracking = (text, centerX, centerY, trackingPx) => { | |
| if (!text) return; | |
| const totalW = measureTextWithTracking(text, trackingPx); | |
| let x = centerX - totalW / 2; | |
| for (let i = 0; i < text.length; i++) { | |
| const ch = text[i]; | |
| ctx.fillText(ch, x + ctx.measureText(ch).width / 2, centerY); | |
| x += ctx.measureText(ch).width + trackingPx; | |
| } | |
| }; | |
| // Fit font size to canvas width so it always spells cleanly | |
| const maxTextWidth = canvas.width * 0.84; | |
| let fontSize = hasTwoLines ? 300 : 340; | |
| while (fontSize > 90) { | |
| ctx.font = `800 ${fontSize}px ${fontFamily}`; | |
| const trackingPx = fontSize * trackingFrac; | |
| const w1 = measureTextWithTracking(t1, trackingPx); | |
| const w2 = hasTwoLines ? measureTextWithTracking(t2, trackingPx) : 0; | |
| if (Math.max(w1, w2) <= maxTextWidth) break; | |
| fontSize -= 8; | |
| } | |
| ctx.font = `800 ${fontSize}px ${fontFamily}`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillStyle = '#fff'; | |
| const midY = canvas.height / 2; | |
| const lineGap = fontSize * 0.92; | |
| const trackingPx = fontSize * trackingFrac; | |
| if (hasTwoLines) { | |
| drawTextWithTracking(t1, canvas.width / 2, midY - lineGap / 2, trackingPx); | |
| drawTextWithTracking(t2, canvas.width / 2, midY + lineGap / 2, trackingPx); | |
| } else { | |
| drawTextWithTracking(t1, canvas.width / 2, midY, trackingPx); | |
| } | |
| const img = ctx.getImageData(0, 0, canvas.width, canvas.height).data; | |
| const ALPHA_TH = 25; | |
| // Bounding box for non-transparent pixels | |
| let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; | |
| for (let y = 0; y < canvas.height; y += 2) { | |
| for (let x = 0; x < canvas.width; x += 2) { | |
| const idx = (y * canvas.width + x) * 4; | |
| if (img[idx + 3] > ALPHA_TH) { | |
| if (x < minX) minX = x; | |
| if (y < minY) minY = y; | |
| if (x > maxX) maxX = x; | |
| if (y > maxY) maxY = y; | |
| } | |
| } | |
| } | |
| if (minX >= maxX || minY >= maxY) return []; | |
| const boxW = (maxX - minX) + 1; | |
| const boxH = (maxY - minY) + 1; | |
| const isOn = (x, y) => { | |
| if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return false; | |
| const idx = (y * canvas.width + x) * 4; | |
| return img[idx + 3] > ALPHA_TH; | |
| }; | |
| // Collect fill pixels and outline pixels (outline improves readability). | |
| // Also collect "interior" pixels (deep inside strokes) so we can guarantee stroke interiors are occupied. | |
| const fillPixels = []; | |
| const edgePixels = []; | |
| const interiorPixels = []; | |
| for (let y = minY; y <= maxY; y += 2) { | |
| for (let x = minX; x <= maxX; x += 2) { | |
| if (!isOn(x, y)) continue; | |
| fillPixels.push({ x, y }); | |
| // Edge: any neighbor off | |
| if ( | |
| !isOn(x - 2, y) || !isOn(x + 2, y) || | |
| !isOn(x, y - 2) || !isOn(x, y + 2) || | |
| !isOn(x - 2, y - 2) || !isOn(x + 2, y - 2) || | |
| !isOn(x - 2, y + 2) || !isOn(x + 2, y + 2) | |
| ) edgePixels.push({ x, y }); | |
| else interiorPixels.push({ x, y }); | |
| } | |
| } | |
| if (fillPixels.length === 0) return []; | |
| const stratifiedSample = (arr, count, cellSize) => { | |
| if (!arr.length || count <= 0) return []; | |
| const buckets = new Map(); | |
| for (const p of arr) { | |
| const bx = Math.floor((p.x - minX) / cellSize); | |
| const by = Math.floor((p.y - minY) / cellSize); | |
| const key = bx + "," + by; | |
| if (!buckets.has(key)) buckets.set(key, []); | |
| buckets.get(key).push(p); | |
| } | |
| // First pass: one per bucket (guarantees coverage / fewer gaps) | |
| const picked = []; | |
| const keys = Array.from(buckets.keys()); | |
| for (let i = keys.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| const tmp = keys[i]; keys[i] = keys[j]; keys[j] = tmp; | |
| } | |
| for (const key of keys) { | |
| const bucket = buckets.get(key); | |
| if (!bucket || !bucket.length) continue; | |
| picked.push(bucket[Math.floor(Math.random() * bucket.length)]); | |
| if (picked.length >= count) return picked; | |
| } | |
| // Fill remaining by sampling from random buckets (keeps distribution even) | |
| while (picked.length < count) { | |
| const key = keys[Math.floor(Math.random() * keys.length)]; | |
| const bucket = buckets.get(key); | |
| if (!bucket || !bucket.length) continue; | |
| picked.push(bucket[Math.floor(Math.random() * bucket.length)]); | |
| } | |
| return picked; | |
| }; | |
| // Allocation: | |
| // - keep some outline points for readability | |
| // - strongly prioritize interior so stroke interiors don't show holes | |
| const edgeCount = Math.min(needed, Math.max(120, Math.floor(needed * 0.18))); | |
| const remaining = needed - edgeCount; | |
| const interiorCount = Math.min(remaining, Math.floor(remaining * 0.85)); | |
| const fillCount = needed - edgeCount - interiorCount; | |
| // Use glyph "on" area to pick appropriate stratification cells (smaller cell => denser, fewer gaps) | |
| const approxOnArea = fillPixels.length * 4; // pixels were scanned at step=2 | |
| const interiorCell = Math.max(2, Math.floor(Math.sqrt(approxOnArea / Math.max(1, interiorCount)) * 0.85)); | |
| const fillCell = Math.max(2, Math.floor(Math.sqrt(approxOnArea / Math.max(1, fillCount)) * 0.75)); | |
| const edgeCell = Math.max(2, Math.floor(Math.sqrt(approxOnArea / Math.max(1, edgeCount)) * 1.05)); | |
| const interiorChosen = stratifiedSample(interiorPixels.length ? interiorPixels : fillPixels, interiorCount, interiorCell) | |
| .map(p => ({ ...p, type: 'INTERIOR' })); | |
| const fillChosen = stratifiedSample(fillPixels, fillCount, fillCell) | |
| .map(p => ({ ...p, type: 'FILL' })); | |
| const edgeChosen = stratifiedSample(edgePixels.length ? edgePixels : fillPixels, edgeCount, edgeCell) | |
| .map(p => ({ ...p, type: 'EDGE' })); | |
| // Coverage rebalancing: | |
| // Ensure "inner stroke" regions are occupied by moving some movable points (INTERIOR/FILL) | |
| // from over-crowded buckets into uncovered interior buckets. | |
| const ensureInteriorCoverage = (movable, fixed, candidates, cellSize) => { | |
| if (!candidates.length || !movable.length) return movable; | |
| const bucketKey = (p) => `${Math.floor((p.x - minX) / cellSize)},${Math.floor((p.y - minY) / cellSize)}`; | |
| // Map bucket -> candidate pixels | |
| const candBuckets = new Map(); | |
| for (const p of candidates) { | |
| const k = bucketKey(p); | |
| if (!candBuckets.has(k)) candBuckets.set(k, []); | |
| candBuckets.get(k).push(p); | |
| } | |
| // Count coverage from movable + fixed (fixed includes EDGE points) | |
| const coverage = new Map(); | |
| const addCov = (p) => { | |
| const k = bucketKey(p); | |
| coverage.set(k, (coverage.get(k) || 0) + 1); | |
| }; | |
| movable.forEach(addCov); | |
| fixed.forEach(addCov); | |
| // Build donor map: bucket -> indices in movable | |
| const donorsByBucket = new Map(); | |
| movable.forEach((p, idx) => { | |
| const k = bucketKey(p); | |
| if (!donorsByBucket.has(k)) donorsByBucket.set(k, []); | |
| donorsByBucket.get(k).push(idx); | |
| }); | |
| // Buckets we want to cover: all candidate buckets (interior mask) | |
| const missing = []; | |
| candBuckets.forEach((_, k) => { | |
| if (!coverage.has(k)) missing.push(k); | |
| }); | |
| // While we have missing buckets, steal from the most crowded donor bucket (count > 1) | |
| const donorKeys = () => Array.from(donorsByBucket.keys()); | |
| for (const missKey of missing) { | |
| // Find a donor bucket with more than 1 movable point | |
| let bestKey = null; | |
| let bestCount = 0; | |
| for (const k of donorKeys()) { | |
| const idxs = donorsByBucket.get(k); | |
| if (!idxs || idxs.length <= 1) continue; | |
| if (idxs.length > bestCount) { bestCount = idxs.length; bestKey = k; } | |
| } | |
| if (!bestKey) break; // can't rebalance further | |
| const missCandidates = candBuckets.get(missKey); | |
| if (!missCandidates || !missCandidates.length) continue; | |
| const newP = missCandidates[Math.floor(Math.random() * missCandidates.length)]; | |
| // Pop one donor index from best bucket and replace it | |
| const donorIdx = donorsByBucket.get(bestKey).pop(); | |
| const oldP = movable[donorIdx]; | |
| const oldKey = bucketKey(oldP); | |
| movable[donorIdx] = { x: newP.x, y: newP.y, type: oldP.type }; // keep type | |
| // Update donors maps | |
| const newKey = bucketKey(newP); | |
| if (!donorsByBucket.has(newKey)) donorsByBucket.set(newKey, []); | |
| donorsByBucket.get(newKey).push(donorIdx); | |
| // Update coverage map (move one point) | |
| coverage.set(oldKey, Math.max(0, (coverage.get(oldKey) || 1) - 1)); | |
| coverage.set(newKey, (coverage.get(newKey) || 0) + 1); | |
| } | |
| return movable; | |
| }; | |
| const coverCell = Math.max(2, Math.floor(interiorCell * 0.7)); | |
| ensureInteriorCoverage( | |
| [...interiorChosen, ...fillChosen], | |
| edgeChosen, | |
| interiorPixels.length ? interiorPixels : fillPixels, | |
| coverCell | |
| ); | |
| const chosen = [...interiorChosen, ...fillChosen, ...edgeChosen]; | |
| // Map into world space based on camera/frustum so it always fits the view. | |
| const planeZ = 14.0; // bring text forward (still in front of the tree origin) | |
| const depth = Math.max(5, camera.position.z - planeZ); | |
| const vH = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * depth; | |
| const vW = vH * camera.aspect; | |
| // Slightly smaller overall => denser ornament fill, easier to read. | |
| // CN a touch smaller than EN, and single-line gets a bit less height. | |
| const wScale = (variant === 'CN') ? 0.70 : 0.74; | |
| const hScale = (variant === 'CN') ? (hasTwoLines ? 0.44 : 0.36) : (hasTwoLines ? 0.48 : 0.38); | |
| const worldW = vW * wScale; | |
| const worldH = vH * hScale; | |
| const cx = minX + boxW / 2; | |
| const cy = minY + boxH / 2; | |
| const pts = chosen.map(p => { | |
| const nx = (p.x - cx) / (boxW / 2); | |
| const ny = (cy - p.y) / (boxH / 2); | |
| const x = nx * (worldW / 2); | |
| const y = ny * (worldH / 2) + 2.0; // lift slightly toward camera's y=2 | |
| const z = planeZ + (Math.random() - 0.5) * 0.8; // thickness | |
| return new THREE.Vector3(x, y, z); | |
| }); | |
| return pts; | |
| } | |
| 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); | |
| } | |
| }); | |
| window.addEventListener('keydown', (e) => { if (e.key.toLowerCase() === 'h') window.toggleUI(); }); | |
| } | |
| window.openDeleteManager = async function() { | |
| const modal = document.getElementById('delete-manager'); | |
| const grid = document.getElementById('photo-grid'); | |
| grid.innerHTML = ''; | |
| const photos = await loadPhotosFromDB(); | |
| if(!photos || photos.length === 0) grid.innerHTML = `<div style="color:#888;">${uiT('dmNoPhotos')}</div>`; | |
| else { | |
| photos.forEach((p) => { | |
| const div = document.createElement('div'); div.className = 'photo-item'; | |
| const img = document.createElement('img'); img.className = 'photo-thumb'; | |
| img.src = p.data; | |
| const btn = document.createElement('div'); btn.className = 'delete-x'; btn.innerText = 'X'; | |
| btn.onclick = () => confirmDelete(p.id, div); | |
| div.appendChild(img); div.appendChild(btn); grid.appendChild(div); | |
| }); | |
| } | |
| modal.classList.remove('hidden'); | |
| } | |
| window.confirmDelete = function(id, divElement) { | |
| deletePhotoFromDB(id); | |
| divElement.remove(); | |
| const p = particleSystem.find(part => part.photoId === id); | |
| if(p) { photoMeshGroup.remove(p.mesh); particleSystem.splice(particleSystem.indexOf(p), 1); } | |
| } | |
| window.clearAllPhotos = function() { | |
| if(confirm(uiT('confirmClearAll'))) { | |
| clearPhotosDB(); | |
| particleSystem.filter(p => p.type === 'PHOTO').forEach(p => photoMeshGroup.remove(p.mesh)); | |
| particleSystem = particleSystem.filter(p => p.type !== 'PHOTO'); | |
| window.openDeleteManager(); | |
| } | |
| } | |
| window.closeDeleteManager = function() { document.getElementById('delete-manager').classList.add('hidden'); } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = clock.getDelta(); | |
| const t = clock.elapsedTime; | |
| NOW = t; | |
| // Camera zoom in SCATTER mode, driven by hand distance to camera. | |
| // IMPORTANT: capture the zoom level at the moment the tree explodes (enter SCATTER) and only zoom relative to that. | |
| const scatterZoomLerp = 1 - Math.pow(0.10, dt * 60); | |
| const returnZoomLerp = 1 - Math.pow(0.05, dt * 60); | |
| if (STATE.mode === 'SCATTER') { | |
| if (!STATE.scatterZoom.active) { | |
| STATE.scatterZoom.active = true; | |
| STATE.scatterZoom.baseZ = camera.position.z; | |
| STATE.scatterZoom.refHandZ = STATE.hand.detected ? STATE.hand.z : null; | |
| } | |
| const baseZ = STATE.scatterZoom.baseZ || camera.position.z; | |
| if (STATE.hand.detected) { | |
| if (STATE.scatterZoom.refHandZ === null || !Number.isFinite(STATE.scatterZoom.refHandZ)) { | |
| STATE.scatterZoom.refHandZ = STATE.hand.z; | |
| } | |
| const dz = STATE.hand.z - STATE.scatterZoom.refHandZ; // more negative => closer to camera | |
| const Z_GAIN = 420; // converts landmark z delta to camera-z delta | |
| const ZOOM_IN_MAX = 32; | |
| const ZOOM_OUT_MAX = 70; | |
| const deltaCam = THREE.MathUtils.clamp(dz * Z_GAIN, -ZOOM_IN_MAX, ZOOM_OUT_MAX); | |
| const targetZ = THREE.MathUtils.clamp(baseZ + deltaCam, 12, 240); | |
| camera.position.z = THREE.MathUtils.lerp(camera.position.z, targetZ, scatterZoomLerp); | |
| } else { | |
| // No hand: keep the same zoom level as when the tree exploded. | |
| camera.position.z = THREE.MathUtils.lerp(camera.position.z, baseZ, returnZoomLerp); | |
| } | |
| } else { | |
| // Not in SCATTER: return to default camera z. | |
| camera.position.z = THREE.MathUtils.lerp(camera.position.z, CONFIG.camera.z, returnZoomLerp); | |
| STATE.scatterZoom.active = false; | |
| STATE.scatterZoom.refHandZ = null; | |
| } | |
| if (STATE.mode === 'SCATTER' && STATE.hand.detected) { | |
| const base = CONFIG.interaction.rotationSpeed; | |
| const horizSpeed = base * 2.8; // more sensitive horizontally | |
| const vertSpeed = base * 0.6; // less sensitive vertically | |
| STATE.rotation.y -= horizSpeed * dt * STATE.hand.x; | |
| STATE.rotation.x -= vertSpeed * dt * STATE.hand.y; | |
| } else if (STATE.mode === 'TEXT') { | |
| // Keep text readable/stable: smoothly settle rotations instead of auto-rotating. | |
| STATE.rotation.y += (0 - STATE.rotation.y) * 3.0 * dt; | |
| STATE.rotation.x += (0 - 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; | |
| // fireworks removed | |
| updateSparkles(dt); | |
| updateIntro(dt); | |
| updateSeason(dt); | |
| // Star topper: keep upright + gentle spin (lighting handled by intro flare / post-intro state) | |
| if (treeStar) { | |
| // Keep one spike pointing straight up: lock in-plane rotation (Z) to 0. | |
| treeStar.rotation.z = 0; | |
| treeStar.rotation.y += 0.22 * dt; | |
| treeStar.scale.setScalar(1.0); | |
| } | |
| particleSystem.forEach(p => p.update(dt, STATE.mode, STATE.focusTarget)); | |
| composer.render(); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment