Skip to content

Instantly share code, notes, and snippets.

@feynlee
Created December 23, 2025 19:24
Show Gist options
  • Select an option

  • Save feynlee/7236fbbe977d8b21d5ebadf07b84249e to your computer and use it in GitHub Desktop.

Select an option

Save feynlee/7236fbbe977d8b21d5ebadf07b84249e to your computer and use it in GitHub Desktop.
<!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