有趣的html网页代码
加载中...|
圣诞科技树
效果
网站:https://notes.ksah.cn/grand-luxury-tree.htmlhtml
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Grand Luxury Tree Final v2</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000000;
font-family: 'Times New Roman', serif;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
/* UI Overlay - Minimalist */
#ui-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40px;
box-sizing: border-box;
/* Remove transition here as we don't hide the whole layer anymore */
}
/* When hidden class is applied to specific elements */
.ui-hidden {
opacity: 0;
pointer-events: none !important;
}
/* Loading */
#loader {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.8s ease-out;
}
.loader-text {
color: #d4af37;
font-size: 14px;
letter-spacing: 4px;
margin-top: 20px;
text-transform: uppercase;
font-weight: 100;
}
.spinner {
width: 40px;
height: 40px;
border: 1px solid rgba(212, 175, 55, 0.2);
border-top: 1px solid #d4af37;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Typography - Centerpiece */
h1 {
color: #fceea7;
font-size: 56px;
margin: 0;
font-weight: 400;
letter-spacing: 6px;
text-shadow: 0 0 50px rgba(252, 238, 167, 0.6);
background: linear-gradient(to bottom, #fff, #eebb66);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-family: 'Cinzel', 'Times New Roman', serif;
opacity: 0.9;
transition: opacity 0.5s ease; /* Ensure smooth transitions if needed */
}
/* Upload Button - Restored & Elegant */
.upload-wrapper {
position: absolute;
top: 40px;
right: 40px;
pointer-events: auto;
text-align: center;
transition: opacity 0.5s ease; /* Add transition for smooth hiding */
}
.upload-btn {
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37;
padding: 10px 25px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 3px;
font-size: 10px;
transition: all 0.4s;
display: inline-block;
backdrop-filter: blur(5px);
}
.upload-btn:hover {
background: #d4af37;
color: #000;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
}
.hint-text {
color: rgba(212, 175, 55, 0.5);
font-size: 9px;
margin-top: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
#file-input {
display: none;
}
/* Webcam feedback */
#webcam-wrapper {
position: absolute;
bottom: 40px;
right: 40px;
width: 160px;
height: 120px;
border: 2px solid rgba(212, 175, 55, 0.6);
border-radius: 4px;
overflow: hidden;
opacity: 1;
pointer-events: none;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
z-index: 50;
}
#webcam-preview {
width: 100%;
height: 100%;
display: block;
}
/* 帮助面板 - 左侧 */
#help-panel {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(212, 175, 55, 0.4);
border-radius: 8px;
padding: 20px;
max-width: 280px;
pointer-events: auto;
font-size: 13px;
line-height: 1.6;
transition: opacity 0.5s ease;
}
#help-panel h3 {
color: #d4af37;
margin: 0 0 15px 0;
border-bottom: 1px solid #d4af37;
padding-bottom: 10px;
font-size: 16px;
}
#help-panel p {
margin: 8px 0;
color: #ccc;
}
#help-panel code {
background: rgba(212, 175, 55, 0.15);
padding: 2px 6px;
border-radius: 3px;
color: #d4af37;
font-family: monospace;
}
#help-panel hr {
border: none;
border-top: 1px solid #d4af37;
margin: 12px 0;
}
/* UI隐藏类 */
.ui-hidden #help-panel,
.ui-hidden .upload-wrapper {
opacity: 0;
pointer-events: none;
}
#webcam-wrapper.ui-hidden {
opacity: 0;
pointer-events: none !important;
}
/* 图片管理按钮 - 左下角 */
#manage-photos-btn {
position: absolute;
bottom: 40px;
left: 40px;
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37;
padding: 10px 25px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 3px;
font-size: 10px;
transition: all 0.4s;
display: inline-block;
backdrop-filter: blur(5px);
pointer-events: auto;
}
#manage-photos-btn:hover {
background: #d4af37;
color: #000;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
}
/* 图片管理面板 */
#photo-manager {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: auto;
}
#photo-manager.hidden {
display: none;
}
#manager-header {
color: #d4af37;
font-size: 24px;
margin-bottom: 30px;
text-align: center;
font-family: 'Cinzel', 'Times New Roman', serif;
}
#photo-grid {
display: grid;
grid-template-columns: repeat(4, 150px);
gap: 20px;
max-width: 700px;
max-height: 70vh;
overflow-y: auto;
padding: 20px;
}
.photo-item {
position: relative;
width: 150px;
height: 150px;
border: 2px solid #d4af37;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.photo-item img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.delete-btn {
position: absolute;
top: -10px;
right: -10px;
width: 24px;
height: 24px;
background: #cc0000;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: bold;
font-size: 14px;
border: 2px solid #000;
z-index: 1001;
}
#close-manager {
margin-top: 30px;
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(212, 175, 55, 0.4);
color: #d4af37;
padding: 10px 25px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 3px;
font-size: 10px;
transition: all 0.4s;
backdrop-filter: blur(5px);
}
#close-manager:hover {
background: #d4af37;
color: #000;
box-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
}
</style>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap');
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/+esm"
}
}
</script>
</head>
<body>
<div id="loader">
<div class="spinner"></div>
<div class="loader-text">Loading Holiday Magic</div>
</div>
<div id="canvas-container"></div>
<div id="ui-layer">
<!-- 左侧帮助面板 -->
<div id="help-panel">
<h3>🎄 控制说明</h3>
<p><code>👊</code> <b>握拳</b>:聚合树形</p>
<p><code>🖐️</code> <b>张开</b>:粒子散开</p>
<p><code>🤏</code> <b>捏夹</b>:照片聚焦</p>
<p><code>更多资料</code> <b></b>:⬇️⬇️⬇️</p>
<p><code>💖</code> <b>分享站</b>:<a href="https://notes.ksah.cn" target="_blank" rel="noopener noreferrer" style="color: #007bff; text-decoration: none; transition: color 0.3s;">时光笔记</a></p>
<hr />
<p><code>H</code> 键:隐藏UI</p>
<p style="font-size: 11px; color: #999; margin-top: 12px">
💡 可在上方上传照片
</p>
</div>
<h1>Merry Christmas</h1>
<div class="upload-wrapper">
<label class="upload-btn">
上传照片
<input type="file" id="file-input" multiple accept="image/*" />
</label>
<div class="hint-text">按 'H' 隐藏控制面板</div>
</div>
<button id="manage-photos-btn" class="manage-photos-btn">
管理照片
</button>
</div>
<!-- 图片管理面板 -->
<div id="photo-manager" class="hidden">
<h2 id="manager-header">照片管理</h2>
<div id="photo-grid"></div>
<button id="close-manager">关闭管理器</button>
</div>
<!-- Webcam hidden structure -->
<div id="webcam-wrapper">
<video id="webcam" autoplay muted playsinline style="display: none"></video>
<canvas id="webcam-preview"></canvas>
</div>
<script type="module">
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision'
// --- CONFIGURATION ---
const CONFIG = {
colors: {
bg: 0x000000,
champagneGold: 0xffd966,
deepGreen: 0x03180a,
accentRed: 0x990000,
},
particles: {
count: 1500,
dustCount: 2500,
treeHeight: 24,
treeRadius: 8,
},
camera: {
z: 50,
},
}
const STATE = {
mode: 'TREE',
focusIndex: -1,
focusTarget: null,
hand: { detected: false, x: 0, y: 0 },
rotation: { x: 0, y: 0 },
}
let scene, camera, renderer, composer
let mainGroup
let clock = new THREE.Clock()
let particleSystem = []
let photoMeshGroup = new THREE.Group()
let handLandmarker, video, webcamCanvas, webcamCtx
let caneTexture
async function init() {
initThree()
setupEnvironment()
setupLights()
createTextures()
createParticles()
createDust()
createDefaultPhotos()
setupPostProcessing()
setupEvents()
await initMediaPipe()
const loader = document.getElementById('loader')
loader.style.opacity = 0
setTimeout(() => loader.remove(), 800)
animate()
}
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)
}
function setupEnvironment() {
const pmremGenerator = new THREE.PMREMGenerator(renderer)
scene.environment = pmremGenerator.fromScene(
new RoomEnvironment(),
0.04
).texture
}
function setupLights() {
const ambient = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambient)
const innerLight = new THREE.PointLight(0xffaa00, 2, 20)
innerLight.position.set(0, 5, 0)
mainGroup.add(innerLight)
const spotGold = new THREE.SpotLight(0xffcc66, 1200)
spotGold.position.set(30, 40, 40)
spotGold.angle = 0.5
spotGold.penumbra = 0.5
scene.add(spotGold)
const spotBlue = new THREE.SpotLight(0x6688ff, 600)
spotBlue.position.set(-30, 20, -30)
scene.add(spotBlue)
const fill = new THREE.DirectionalLight(0xffeebb, 0.8)
fill.position.set(0, 0, 50)
scene.add(fill)
}
function setupPostProcessing() {
const renderScene = new RenderPass(scene, camera)
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 = THREE.RepeatWrapping
caneTexture.wrapT = THREE.RepeatWrapping
caneTexture.repeat.set(3, 3)
}
class Particle {
constructor(mesh, type, isDust = false) {
this.mesh = mesh
this.type = type
this.isDust = isDust
this.posTree = new THREE.Vector3()
this.posScatter = new THREE.Vector3()
this.baseScale = mesh.scale.x
// Individual Spin Speed
// Photos spin slower to be readable
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() {
// TREE: Tight Spiral
const h = CONFIG.particles.treeHeight
const halfH = h / 2
let t = Math.random()
t = Math.pow(t, 0.8)
const y = t * h - halfH
let rMax = CONFIG.particles.treeRadius * (1.0 - t)
if (rMax < 0.5) rMax = 0.5
const angle = t * 50 * Math.PI + Math.random() * Math.PI
const r = rMax * (0.8 + Math.random() * 0.4)
this.posTree.set(Math.cos(angle) * r, y, Math.sin(angle) * r)
// SCATTER: 3D Sphere
let rScatter = this.isDust
? 12 + Math.random() * 20
: 8 + Math.random() * 12
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
this.posScatter.set(
rScatter * Math.sin(phi) * Math.cos(theta),
rScatter * Math.sin(phi) * Math.sin(theta),
rScatter * Math.cos(phi)
)
}
update(dt, mode, focusTargetMesh) {
let target = this.posTree
if (mode === 'SCATTER') target = this.posScatter
else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) {
const desiredWorldPos = new THREE.Vector3(0, 2, 35)
const invMatrix = new THREE.Matrix4()
.copy(mainGroup.matrixWorld)
.invert()
target = desiredWorldPos.applyMatrix4(invMatrix)
} else {
target = this.posScatter
}
}
// Movement Easing
const lerpSpeed =
mode === 'FOCUS' && this.mesh === focusTargetMesh ? 5.0 : 2.0
this.mesh.position.lerp(target, lerpSpeed * dt)
// Rotation Logic - CRITICAL: Ensure spin happens in Scatter
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 // Added Z for more natural tumble
} else if (mode === 'TREE') {
// Reset rotations slowly
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)
}
// Scale Logic
let s = this.baseScale
if (this.isDust) {
s =
this.baseScale *
(0.8 + 0.4 * Math.sin(clock.elapsedTime * 4 + this.mesh.id))
if (mode === 'TREE') s = 0
} else if (mode === 'SCATTER' && this.type === 'PHOTO') {
// Large preview size in scatter
s = this.baseScale * 2.5
} else if (mode === 'FOCUS') {
if (this.mesh === focusTargetMesh) s = 4.5
else s = this.baseScale * 0.8
}
this.mesh.scale.lerp(new THREE.Vector3(s, s, s), 4 * dt)
}
}
// --- CREATION ---
function createParticles() {
const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32)
const boxGeo = new THREE.BoxGeometry(0.55, 0.55, 0.55)
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, -0.5, 0),
new THREE.Vector3(0, 0.3, 0),
new THREE.Vector3(0.1, 0.5, 0),
new THREE.Vector3(0.3, 0.4, 0),
])
const candyGeo = new THREE.TubeGeometry(curve, 16, 0.08, 8, false)
const goldMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.champagneGold,
metalness: 1.0,
roughness: 0.1,
envMapIntensity: 2.0,
emissive: 0x443300,
emissiveIntensity: 0.3,
})
const greenMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.deepGreen,
metalness: 0.2,
roughness: 0.8,
emissive: 0x002200,
emissiveIntensity: 0.2,
})
const redMat = new THREE.MeshPhysicalMaterial({
color: CONFIG.colors.accentRed,
metalness: 0.3,
roughness: 0.2,
clearcoat: 1.0,
emissive: 0x330000,
})
const candyMat = new THREE.MeshStandardMaterial({
map: caneTexture,
roughness: 0.4,
})
for (let i = 0; i < CONFIG.particles.count; i++) {
const rand = Math.random()
let mesh, type
if (rand < 0.4) {
mesh = new THREE.Mesh(boxGeo, greenMat)
type = 'BOX'
} else if (rand < 0.7) {
mesh = new THREE.Mesh(boxGeo, goldMat)
type = 'GOLD_BOX'
} else if (rand < 0.92) {
mesh = new THREE.Mesh(sphereGeo, goldMat)
type = 'GOLD_SPHERE'
} else if (rand < 0.97) {
mesh = new THREE.Mesh(sphereGeo, redMat)
type = 'RED'
} else {
mesh = new THREE.Mesh(candyGeo, candyMat)
type = 'CANE'
}
const s = 0.4 + Math.random() * 0.5
mesh.scale.set(s, s, s)
mesh.rotation.set(
Math.random() * 6,
Math.random() * 6,
Math.random() * 6
)
mainGroup.add(mesh)
particleSystem.push(new Particle(mesh, type, false))
}
const starGeo = new THREE.OctahedronGeometry(1.2, 0)
const starMat = new THREE.MeshStandardMaterial({
color: 0xffdd88,
emissive: 0xffaa00,
emissiveIntensity: 1.0,
metalness: 1.0,
roughness: 0,
})
const star = new THREE.Mesh(starGeo, starMat)
star.position.set(0, CONFIG.particles.treeHeight / 2 + 1.2, 0)
mainGroup.add(star)
mainGroup.add(photoMeshGroup)
}
function createDust() {
const geo = new THREE.TetrahedronGeometry(0.08, 0)
const mat = new THREE.MeshBasicMaterial({
color: 0xffeebb,
transparent: true,
opacity: 0.8,
})
for (let i = 0; i < CONFIG.particles.dustCount; i++) {
const mesh = new THREE.Mesh(geo, mat)
mesh.scale.setScalar(0.5 + Math.random())
mainGroup.add(mesh)
particleSystem.push(new Particle(mesh, 'DUST', true))
}
}
function createDefaultPhotos() {
const canvas = document.createElement('canvas')
canvas.width = 512
canvas.height = 512
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#050505'
ctx.fillRect(0, 0, 512, 512)
ctx.strokeStyle = '#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('By', 256, 230)
ctx.fillText('Youhun', 256, 300)
const tex = new THREE.CanvasTexture(canvas)
tex.colorSpace = THREE.SRGBColorSpace
addPhotoToScene(tex)
}
function addPhotoToScene(texture) {
const frameGeo = new THREE.BoxGeometry(1.4, 1.4, 0.05)
const frameMat = new THREE.MeshStandardMaterial({
color: CONFIG.colors.champagneGold,
metalness: 1.0,
roughness: 0.1,
})
const frame = new THREE.Mesh(frameGeo, frameMat)
const photoGeo = new THREE.PlaneGeometry(1.2, 1.2)
const photoMat = new THREE.MeshBasicMaterial({ map: texture })
const photo = new THREE.Mesh(photoGeo, photoMat)
photo.position.z = 0.04
const group = new THREE.Group()
group.add(frame)
group.add(photo)
const s = 0.8
group.scale.set(s, s, s)
photoMeshGroup.add(group)
particleSystem.push(new Particle(group, 'PHOTO', false))
}
function handleImageUpload(e) {
const files = e.target.files
if (!files.length) return
Array.from(files).forEach((f) => {
const reader = new FileReader()
reader.onload = (ev) => {
new THREE.TextureLoader().load(ev.target.result, (t) => {
t.colorSpace = THREE.SRGBColorSpace
addPhotoToScene(t)
})
}
reader.readAsDataURL(f)
})
}
// --- MEDIAPIPE ---
async function initMediaPipe() {
video = document.getElementById('webcam')
webcamCanvas = document.getElementById('webcam-preview')
webcamCtx = webcamCanvas.getContext('2d')
webcamCanvas.width = 160
webcamCanvas.height = 120
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm'
)
handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: 'GPU',
},
runningMode: 'VIDEO',
numHands: 1,
})
if (navigator.mediaDevices?.getUserMedia) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
})
video.srcObject = stream
video.muted = true
// 重要:确保播放视频
const playPromise = video.play()
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log('Video playing successfully')
requestAnimationFrame(predictWebcam)
})
.catch((err) => {
console.error('Video play failed:', err)
})
}
} catch (error) {
console.error('Failed to access camera:', error)
}
}
}
let lastVideoTime = -1
async function predictWebcam() {
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime
if (handLandmarker) {
const result = handLandmarker.detectForVideo(
video,
performance.now()
)
processGestures(result)
}
}
// Draw video frame to canvas
if (video.readyState === video.HAVE_ENOUGH_DATA) {
webcamCtx.drawImage(video, 0, 0, webcamCanvas.width, webcamCanvas.height)
}
requestAnimationFrame(predictWebcam)
}
function processGestures(result) {
if (result.landmarks && result.landmarks.length > 0) {
STATE.hand.detected = true
const lm = result.landmarks[0]
STATE.hand.x = (lm[9].x - 0.5) * 2
STATE.hand.y = (lm[9].y - 0.5) * 2
const thumb = lm[4]
const index = lm[8]
const wrist = lm[0]
const palm = lm[9] // 手掌中心
// 捏夹距离:大拇指和食指
const pinchDist = Math.hypot(thumb.x - index.x, thumb.y - index.y)
// 握拳检测:计算所有手指尖到手掌中心的距离
const fingerTips = [lm[8], lm[12], lm[16], lm[20]]
let fistDistance = 0
fingerTips.forEach((tip) => {
fistDistance += Math.hypot(tip.x - palm.x, tip.y - palm.y)
})
fistDistance /= 4
// 张开手势:手指尖到手腕的平均距离
const tips = [lm[8], lm[12], lm[16], lm[20]]
let avgDist = 0
tips.forEach(
(t) => (avgDist += Math.hypot(t.x - wrist.x, t.y - wrist.y))
)
avgDist /= 4
// 修改逻辑:优先检查握拳(更严格),避免与捏夹冲突
if (fistDistance < 0.18) {
// 握拳:手指都靠近手掌中心
STATE.mode = 'TREE'
STATE.focusTarget = null
} else if (pinchDist < 0.045 && avgDist > 0.28) {
// 捏夹:大拇指和食指靠很近,且不是握拳状态
if (STATE.mode !== 'FOCUS') {
STATE.mode = 'FOCUS'
const photos = particleSystem.filter((p) => p.type === 'PHOTO')
if (photos.length)
STATE.focusTarget =
photos[Math.floor(Math.random() * photos.length)].mesh
}
} else if (avgDist > 0.4) {
// 张开手势
STATE.mode = 'SCATTER'
STATE.focusTarget = null
}
} else {
STATE.hand.detected = false
}
}
function setupEvents() {
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
composer.setSize(window.innerWidth, window.innerHeight)
})
document
.getElementById('file-input')
.addEventListener('change', handleImageUpload)
// 图片管理功能
document
.getElementById('manage-photos-btn')
.addEventListener('click', showPhotoManager)
document
.getElementById('close-manager')
.addEventListener('click', hidePhotoManager)
// Toggle UI logic - Hide/Show all UI controls
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'h') {
const uiLayer = document.getElementById('ui-layer')
const webcamWrapper = document.getElementById('webcam-wrapper')
if (uiLayer) {
uiLayer.classList.toggle('ui-hidden')
}
if (webcamWrapper) {
webcamWrapper.classList.toggle('ui-hidden')
}
}
})
}
// 显示图片管理面板
function showPhotoManager() {
updatePhotoGrid()
document.getElementById('photo-manager').classList.remove('hidden')
}
// 隐藏图片管理面板
function hidePhotoManager() {
document.getElementById('photo-manager').classList.add('hidden')
}
// 更新图片网格
function updatePhotoGrid() {
const photoGrid = document.getElementById('photo-grid')
photoGrid.innerHTML = ''
// 获取所有照片粒子
const photoParticles = particleSystem.filter(p => p.type === 'PHOTO')
photoParticles.forEach((particle, index) => {
const photoItem = document.createElement('div')
photoItem.className = 'photo-item'
// 获取照片纹理
const photoMesh = particle.mesh.children[1];
let imgUrl = '';
if (photoMesh && photoMesh.material && photoMesh.material.map) {
const texture = photoMesh.material.map;
if (texture.image && texture.image.currentSrc) {
imgUrl = texture.image.currentSrc;
} else if (texture.image && texture.image.src) {
imgUrl = texture.image.src;
}
}
photoItem.innerHTML = `
<img src="${imgUrl}" alt="Photo ${index + 1}" onerror="this.src='data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" viewBox=\"0 0 24 24\"><rect width=\"24\" height=\"24\" fill=\"%23222\"/><text x=\"12\" y=\"16\" font-family=\"Arial\" font-size=\"12\" fill=\"%23ddd\" text-anchor=\"middle\">No Image</text></svg>'">
<div class="delete-btn" data-index="${index}">×</div>
`
photoGrid.appendChild(photoItem)
})
// 添加删除按钮事件监听器
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function() {
const index = parseInt(this.getAttribute('data-index'))
deletePhoto(index)
})
})
}
// 删除照片
function deletePhoto(index) {
// 获取所有照片粒子
const photoParticles = particleSystem.filter(p => p.type === 'PHOTO')
if (index >= 0 && index < photoParticles.length) {
const particleToDelete = photoParticles[index]
// 从场景中移除网格
if (particleToDelete.mesh && particleToDelete.mesh.parent) {
particleToDelete.mesh.parent.remove(particleToDelete.mesh)
}
// 从particleSystem数组中移除粒子
const particleIndex = particleSystem.indexOf(particleToDelete)
if (particleIndex > -1) {
particleSystem.splice(particleIndex, 1)
}
// 从photoMeshGroup中移除
if (photoMeshGroup && particleToDelete.mesh) {
photoMeshGroup.remove(particleToDelete.mesh)
}
// 更新照片网格
updatePhotoGrid()
}
}
function animate() {
requestAnimationFrame(animate)
const dt = clock.getDelta()
// Rotation Logic
if (STATE.mode === 'SCATTER' && STATE.hand.detected) {
const targetRotY = STATE.hand.x * Math.PI * 0.9
const targetRotX = STATE.hand.y * Math.PI * 0.25
STATE.rotation.y += (targetRotY - STATE.rotation.y) * 3.0 * dt
STATE.rotation.x += (targetRotX - STATE.rotation.x) * 3.0 * dt
} else {
if (STATE.mode === 'TREE') {
STATE.rotation.y += 0.3 * dt
STATE.rotation.x += (0 - STATE.rotation.x) * 2.0 * dt
} else {
STATE.rotation.y += 0.1 * dt
}
}
mainGroup.rotation.y = STATE.rotation.y
mainGroup.rotation.x = STATE.rotation.x
particleSystem.forEach((p) =>
p.update(dt, STATE.mode, STATE.focusTarget)
)
composer.render()
}
init()
</script>
</body>
</html>