|
<!DOCTYPE html> |
|
<html lang="zh-CN"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>万剑归宗 - 赛博修仙</title> |
|
<style> |
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
} |
|
|
|
body { |
|
overflow: hidden; |
|
background: #000814; |
|
font-family: 'Microsoft YaHei', 'SimHei', sans-serif; |
|
} |
|
|
|
#canvas-container { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
z-index: 1; |
|
} |
|
|
|
canvas { |
|
display: block; |
|
} |
|
|
|
/* Title Overlay */ |
|
#title-overlay { |
|
position: fixed; |
|
top: 20px; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
z-index: 100; |
|
text-align: center; |
|
pointer-events: none; |
|
} |
|
|
|
#title-overlay h1 { |
|
font-size: 2.5rem; |
|
background: linear-gradient(135deg, #00f0ff, #00ff88, #ffd700); |
|
-webkit-background-clip: text; |
|
-webkit-text-fill-color: transparent; |
|
background-clip: text; |
|
text-shadow: 0 0 30px rgba(0, 240, 255, 0.5); |
|
letter-spacing: 0.3em; |
|
animation: titleGlow 3s ease-in-out infinite; |
|
} |
|
|
|
#title-overlay .subtitle { |
|
font-size: 1rem; |
|
color: rgba(0, 240, 255, 0.6); |
|
margin-top: 5px; |
|
letter-spacing: 0.2em; |
|
} |
|
|
|
@keyframes titleGlow { |
|
0%, 100% { filter: brightness(1); } |
|
50% { filter: brightness(1.3); } |
|
} |
|
|
|
/* Loading Screen */ |
|
#loading-screen { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background: linear-gradient(135deg, #000814 0%, #0a1a2f 50%, #000814 100%); |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
align-items: center; |
|
z-index: 1000; |
|
transition: opacity 0.8s ease; |
|
} |
|
|
|
#loading-screen.hidden { |
|
opacity: 0; |
|
pointer-events: none; |
|
} |
|
|
|
.loading-sword { |
|
width: 100px; |
|
height: 4px; |
|
background: linear-gradient(90deg, transparent, #00f0ff, #00ff88, #00f0ff, transparent); |
|
border-radius: 2px; |
|
animation: swordCharge 2s ease-in-out infinite; |
|
position: relative; |
|
} |
|
|
|
.loading-sword::before { |
|
content: '⚔️'; |
|
position: absolute; |
|
left: 50%; |
|
top: -40px; |
|
transform: translateX(-50%); |
|
font-size: 2rem; |
|
animation: float 1s ease-in-out infinite; |
|
} |
|
|
|
@keyframes swordCharge { |
|
0%, 100% { opacity: 0.5; transform: scaleX(0.8); } |
|
50% { opacity: 1; transform: scaleX(1); } |
|
} |
|
|
|
@keyframes float { |
|
0%, 100% { transform: translateX(-50%) translateY(0); } |
|
50% { transform: translateX(-50%) translateY(-10px); } |
|
} |
|
|
|
#loading-text { |
|
color: #00f0ff; |
|
margin-top: 30px; |
|
font-size: 1.2rem; |
|
letter-spacing: 0.2em; |
|
} |
|
|
|
/* Hand Status */ |
|
#hand-status { |
|
position: fixed; |
|
bottom: 20px; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
z-index: 100; |
|
display: flex; |
|
gap: 15px; |
|
pointer-events: none; |
|
} |
|
|
|
.hand-indicator { |
|
padding: 8px 16px; |
|
background: rgba(0, 240, 255, 0.1); |
|
border: 1px solid rgba(0, 240, 255, 0.3); |
|
border-radius: 20px; |
|
color: #00f0ff; |
|
font-size: 0.85rem; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.hand-indicator.active { |
|
background: rgba(0, 240, 255, 0.3); |
|
box-shadow: 0 0 20px rgba(0, 240, 255, 0.5); |
|
} |
|
|
|
/* Gesture Guide */ |
|
#gesture-guide { |
|
position: fixed; |
|
top: 100px; |
|
right: 20px; |
|
z-index: 100; |
|
background: rgba(0, 0, 0, 0.6); |
|
backdrop-filter: blur(10px); |
|
border: 1px solid rgba(0, 240, 255, 0.2); |
|
border-radius: 15px; |
|
padding: 20px; |
|
color: #fff; |
|
font-size: 0.85rem; |
|
pointer-events: none; |
|
max-width: 280px; |
|
} |
|
|
|
#gesture-guide h3 { |
|
color: #00f0ff; |
|
margin-bottom: 15px; |
|
font-size: 1rem; |
|
border-bottom: 1px solid rgba(0, 240, 255, 0.3); |
|
padding-bottom: 10px; |
|
} |
|
|
|
.gesture-item { |
|
display: flex; |
|
align-items: center; |
|
margin: 10px 0; |
|
opacity: 0.6; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.gesture-item.active { |
|
opacity: 1; |
|
color: #00ff88; |
|
} |
|
|
|
.gesture-icon { |
|
width: 40px; |
|
height: 40px; |
|
background: rgba(0, 240, 255, 0.1); |
|
border-radius: 10px; |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
margin-right: 12px; |
|
font-size: 1.2rem; |
|
} |
|
|
|
.gesture-text { |
|
flex: 1; |
|
} |
|
|
|
.gesture-name { |
|
font-weight: bold; |
|
color: #00f0ff; |
|
} |
|
|
|
.gesture-desc { |
|
font-size: 0.75rem; |
|
color: rgba(255, 255, 255, 0.6); |
|
} |
|
|
|
/* Error Message */ |
|
#error-message { |
|
position: fixed; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
background: rgba(255, 50, 50, 0.1); |
|
border: 1px solid rgba(255, 50, 50, 0.5); |
|
border-radius: 15px; |
|
padding: 30px; |
|
color: #ff6b6b; |
|
text-align: center; |
|
z-index: 2000; |
|
display: none; |
|
} |
|
|
|
#error-message h2 { |
|
margin-bottom: 15px; |
|
} |
|
|
|
/* Webcam Preview */ |
|
#webcam-preview { |
|
position: fixed; |
|
bottom: 80px; |
|
left: 20px; |
|
width: 200px; |
|
height: 150px; |
|
border: 1px solid rgba(0, 240, 255, 0.3); |
|
border-radius: 10px; |
|
overflow: hidden; |
|
z-index: 100; |
|
transform: scaleX(-1); |
|
opacity: 0.8; |
|
} |
|
|
|
#webcam-preview video { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
} |
|
|
|
#webcam-preview.hidden { |
|
display: none; |
|
} |
|
|
|
/* Performance toggle */ |
|
#perf-toggle { |
|
position: fixed; |
|
bottom: 20px; |
|
right: 20px; |
|
z-index: 100; |
|
background: rgba(0, 0, 0, 0.6); |
|
border: 1px solid rgba(0, 240, 255, 0.3); |
|
border-radius: 8px; |
|
padding: 10px 15px; |
|
color: #00f0ff; |
|
font-size: 0.8rem; |
|
cursor: pointer; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
#perf-toggle:hover { |
|
background: rgba(0, 240, 255, 0.2); |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<!-- Loading Screen --> |
|
<div id="loading-screen"> |
|
<div class="loading-sword"></div> |
|
<div id="loading-text">正在凝聚剑意...</div> |
|
</div> |
|
|
|
<!-- Title --> |
|
<div id="title-overlay"> |
|
<h1>万剑归宗</h1> |
|
<div class="subtitle">赛博修仙 · CYBER XIANXIA</div> |
|
</div> |
|
|
|
<!-- Hand Status --> |
|
<div id="hand-status"> |
|
<div class="hand-indicator" id="left-hand">左手 - 未检测</div> |
|
<div class="hand-indicator" id="right-hand">右手 - 未检测</div> |
|
<div class="hand-indicator" id="current-gesture">当前: 待机</div> |
|
</div> |
|
|
|
<!-- Webcam Preview --> |
|
<div id="webcam-preview" class="hidden"> |
|
<video id="webcam-video" autoplay playsinline></video> |
|
</div> |
|
|
|
<!-- Gesture Guide --> |
|
<div id="gesture-guide"> |
|
<h3>🧘 手势指南</h3> |
|
<div class="gesture-item" data-gesture="open_palm"> |
|
<div class="gesture-icon">✋</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">张开手掌</div> |
|
<div class="gesture-desc">剑阵分散防御</div> |
|
</div> |
|
</div> |
|
<div class="gesture-item" data-gesture="fist"> |
|
<div class="gesture-icon">👊</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">握紧拳头</div> |
|
<div class="gesture-desc">万剑归一冲击</div> |
|
</div> |
|
</div> |
|
<div class="gesture-item" data-gesture="pointing"> |
|
<div class="gesture-icon">👆</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">单手食指指</div> |
|
<div class="gesture-desc">剑气激光射击</div> |
|
</div> |
|
</div> |
|
<div class="gesture-item" data-gesture="circle"> |
|
<div class="gesture-icon">🔄</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">双手画圆</div> |
|
<div class="gesture-desc">剑 vortex 旋转</div> |
|
</div> |
|
</div> |
|
<div class="gesture-item" data-gesture="spread"> |
|
<div class="gesture-icon">🤲</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">双手分开</div> |
|
<div class="gesture-desc">扇形剑阵展开</div> |
|
</div> |
|
</div> |
|
<div class="gesture-item" data-gesture="together"> |
|
<div class="gesture-icon">🙏</div> |
|
<div class="gesture-text"> |
|
<div class="gesture-name">双手合十</div> |
|
<div class="gesture-desc">巨剑合体</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<!-- Performance Toggle --> |
|
<button id="perf-toggle">⚡ 性能模式</button> |
|
|
|
<!-- 3D Canvas --> |
|
<div id="canvas-container"></div> |
|
|
|
<!-- Error Message --> |
|
<div id="error-message"> |
|
<h2>❌ 无法访问摄像头</h2> |
|
<p>请确保已授权摄像头权限,并使用支持 WebRTC 的浏览器</p> |
|
<button onclick="location.reload()" style="margin-top:15px;padding:10px 20px;background:rgba(0,240,255,0.2);border:1px solid #00f0ff;color:#00f0ff;border-radius:5px;cursor:pointer;">重试</button> |
|
</div> |
|
|
|
<!-- Three.js --> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
|
|
|
<!-- MediaPipe Hands --> |
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script> |
|
|
|
<script> |
|
// ============================================ |
|
// 万剑归宗 - 赛博修仙 |
|
// CYBER XIANXIA - TEN THOUSAND SWORDS RETURN |
|
// ============================================ |
|
|
|
(function() { |
|
'use strict'; |
|
|
|
// Configuration |
|
const CONFIG = { |
|
SWORD_COUNT: 300, |
|
TRAIL_LENGTH: 15, |
|
GLOW_INTENSITY: 2.0, |
|
CONVERGE_SPEED: 0.4, |
|
DISPERSE_SPEED: 1.2, |
|
ORBIT_SPEED: 0.3, |
|
CIRCLE_SPEED: 2.0, |
|
PARTICLE_COUNT: 2000, |
|
QUALITY: 'high' // 'low', 'medium', 'high' |
|
}; |
|
|
|
// Global State |
|
let scene, camera, renderer, clock; |
|
let swords = []; |
|
let particles = []; |
|
let trails = []; |
|
let handLandmarks = { left: null, right: null }; |
|
let currentGesture = 'idle'; |
|
let previousGesture = 'idle'; |
|
let gestureCooldown = 0; |
|
let circleAngle = 0; |
|
let giantSword = null; |
|
let bloomPass, composer; |
|
let isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); |
|
|
|
// Audio Context (for procedural sounds) |
|
let audioCtx = null; |
|
|
|
// DOM Elements |
|
const container = document.getElementById('canvas-container'); |
|
const loadingScreen = document.getElementById('loading-screen'); |
|
const leftHandInd = document.getElementById('left-hand'); |
|
const rightHandInd = document.getElementById('right-hand'); |
|
const gestureInd = document.getElementById('current-gesture'); |
|
const webcamPreview = document.getElementById('webcam-preview'); |
|
const videoElement = document.getElementById('webcam-video'); |
|
const errorMessage = document.getElementById('error-message'); |
|
|
|
// ============================================ |
|
// AUDIO SYSTEM (Procedural Sounds) |
|
// ============================================ |
|
function initAudio() { |
|
try { |
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
|
} catch (e) { |
|
console.log('Web Audio API not supported'); |
|
} |
|
} |
|
|
|
function playSwordWhoosh(intensity = 1) { |
|
if (!audioCtx) return; |
|
|
|
const osc = audioCtx.createOscillator(); |
|
const gain = audioCtx.createGain(); |
|
const filter = audioCtx.createBiquadFilter(); |
|
|
|
osc.type = 'sawtooth'; |
|
osc.frequency.setValueAtTime(100 + intensity * 200, audioCtx.currentTime); |
|
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.3); |
|
|
|
filter.type = 'lowpass'; |
|
filter.frequency.setValueAtTime(2000, audioCtx.currentTime); |
|
filter.frequency.exponentialRampToValueAtTime(200, audioCtx.currentTime + 0.3); |
|
|
|
gain.gain.setValueAtTime(0.1 * intensity, audioCtx.currentTime); |
|
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3); |
|
|
|
osc.connect(filter); |
|
filter.connect(gain); |
|
gain.connect(audioCtx.destination); |
|
|
|
osc.start(); |
|
osc.stop(audioCtx.currentTime + 0.3); |
|
} |
|
|
|
function playEnergyHum(duration = 1) { |
|
if (!audioCtx) return; |
|
|
|
const osc = audioCtx.createOscillator(); |
|
const gain = audioCtx.createGain(); |
|
|
|
osc.type = 'sine'; |
|
osc.frequency.setValueAtTime(80, audioCtx.currentTime); |
|
|
|
gain.gain.setValueAtTime(0.05, audioCtx.currentTime); |
|
gain.gain.linearRampToValueAtTime(0.08, audioCtx.currentTime + duration * 0.3); |
|
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + duration); |
|
|
|
osc.connect(gain); |
|
gain.connect(audioCtx.destination); |
|
|
|
osc.start(); |
|
osc.stop(audioCtx.currentTime + duration); |
|
} |
|
|
|
function playImpactSound() { |
|
if (!audioCtx) return; |
|
|
|
const osc = audioCtx.createOscillator(); |
|
const gain = audioCtx.createGain(); |
|
|
|
osc.type = 'sine'; |
|
osc.frequency.setValueAtTime(200, audioCtx.currentTime); |
|
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.5); |
|
|
|
gain.gain.setValueAtTime(0.3, audioCtx.currentTime); |
|
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5); |
|
|
|
osc.connect(gain); |
|
gain.connect(audioCtx.destination); |
|
|
|
osc.start(); |
|
osc.stop(audioCtx.currentTime + 0.5); |
|
} |
|
|
|
// ============================================ |
|
// THREE.JS SETUP |
|
// ============================================ |
|
function initThree() { |
|
// Scene |
|
scene = new THREE.Scene(); |
|
scene.fog = new THREE.FogExp2(0x000814, 0.008); |
|
|
|
// Camera |
|
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); |
|
camera.position.set(0, 5, 15); |
|
camera.lookAt(0, 0, 0); |
|
|
|
// Renderer |
|
renderer = new THREE.WebGLRenderer({ |
|
antialias: CONFIG.QUALITY !== 'low', |
|
alpha: true, |
|
powerPreference: 'high-performance' |
|
}); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
|
renderer.setClearColor(0x000814); |
|
container.appendChild(renderer.domElement); |
|
|
|
// Clock |
|
clock = new THREE.Clock(); |
|
|
|
// Lights |
|
const ambientLight = new THREE.AmbientLight(0x00f0ff, 0.3); |
|
scene.add(ambientLight); |
|
|
|
const pointLight1 = new THREE.PointLight(0x00f0ff, 1, 50); |
|
pointLight1.position.set(10, 10, 10); |
|
scene.add(pointLight1); |
|
|
|
const pointLight2 = new THREE.PointLight(0xffd700, 0.5, 50); |
|
pointLight2.position.set(-10, -5, 10); |
|
scene.add(pointLight2); |
|
|
|
// Create background stars |
|
createStarfield(); |
|
|
|
// Initialize swords |
|
initSwords(); |
|
|
|
// Initialize particles |
|
initParticles(); |
|
|
|
// Create giant sword for special attacks |
|
createGiantSword(); |
|
|
|
// Handle resize |
|
window.addEventListener('resize', onWindowResize); |
|
} |
|
|
|
function createStarfield() { |
|
const starsGeometry = new THREE.BufferGeometry(); |
|
const starsMaterial = new THREE.PointsMaterial({ |
|
color: 0xffffff, |
|
size: 0.1, |
|
transparent: true, |
|
opacity: 0.8 |
|
}); |
|
|
|
const starsVertices = []; |
|
for (let i = 0; i < 2000; i++) { |
|
const x = (Math.random() - 0.5) * 200; |
|
const y = (Math.random() - 0.5) * 200; |
|
const z = (Math.random() - 0.5) * 200; |
|
starsVertices.push(x, y, z); |
|
} |
|
|
|
starsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starsVertices, 3)); |
|
const stars = new THREE.Points(starsGeometry, starsMaterial); |
|
scene.add(stars); |
|
} |
|
|
|
// ============================================ |
|
// SWORD SYSTEM |
|
// ============================================ |
|
function initSwords() { |
|
const swordGeometry = createSwordGeometry(); |
|
const swordMaterial = createSwordMaterial(); |
|
|
|
for (let i = 0; i < CONFIG.SWORD_COUNT; i++) { |
|
const sword = new THREE.Mesh(swordGeometry, swordMaterial.clone()); |
|
|
|
// Initial position - random sphere distribution |
|
const theta = Math.random() * Math.PI * 2; |
|
const phi = Math.acos(2 * Math.random() - 1); |
|
const radius = 8 + Math.random() * 4; |
|
|
|
sword.position.x = radius * Math.sin(phi) * Math.cos(theta); |
|
sword.position.y = radius * Math.sin(phi) * Math.sin(theta); |
|
sword.position.z = radius * Math.cos(phi); |
|
|
|
// Random rotation |
|
sword.rotation.x = Math.random() * Math.PI; |
|
sword.rotation.y = Math.random() * Math.PI; |
|
sword.rotation.z = Math.random() * Math.PI; |
|
|
|
// Sword properties |
|
sword.userData = { |
|
id: i, |
|
targetPosition: sword.position.clone(), |
|
velocity: new THREE.Vector3(), |
|
orbitAngle: theta, |
|
orbitSpeed: 0.1 + Math.random() * 0.2, |
|
orbitRadius: radius, |
|
orbitY: sword.position.y, |
|
trail: [], |
|
trailMesh: null, |
|
phase: Math.random() * Math.PI * 2 |
|
}; |
|
|
|
// Create trail |
|
createTrailForSword(sword); |
|
|
|
swords.push(sword); |
|
scene.add(sword); |
|
} |
|
} |
|
|
|
function createSwordGeometry() { |
|
// Create a simple Chinese jian sword |
|
const group = new THREE.Group(); |
|
|
|
// Blade |
|
const bladeShape = new THREE.Shape(); |
|
bladeShape.moveTo(0, 0); |
|
bladeShape.lineTo(0.03, 0); |
|
bladeShape.lineTo(0.02, 1.5); |
|
bladeShape.lineTo(0, 1.6); |
|
bladeShape.lineTo(-0.02, 1.5); |
|
bladeShape.lineTo(-0.03, 0); |
|
bladeShape.lineTo(0, 0); |
|
|
|
const extrudeSettings = { |
|
steps: 1, |
|
depth: 0.005, |
|
bevelEnabled: true, |
|
bevelThickness: 0.002, |
|
bevelSize: 0.002, |
|
bevelSegments: 2 |
|
}; |
|
|
|
const bladeGeometry = new THREE.ExtrudeGeometry(bladeShape, extrudeSettings); |
|
const blade = new THREE.Mesh(bladeGeometry); |
|
blade.rotation.x = Math.PI / 2; |
|
blade.position.z = -0.8; |
|
group.add(blade); |
|
|
|
// Handle |
|
const handleGeometry = new THREE.CylinderGeometry(0.04, 0.05, 0.6, 8); |
|
const handle = new THREE.Mesh(handleGeometry); |
|
handle.rotation.x = Math.PI / 2; |
|
handle.position.z = -1.6; |
|
group.add(handle); |
|
|
|
// Guard |
|
const guardGeometry = new THREE.CylinderGeometry(0.1, 0.08, 0.08, 8); |
|
const guard = new THREE.Mesh(guardGeometry); |
|
guard.rotation.x = Math.PI / 2; |
|
guard.position.z = -1.2; |
|
group.add(guard); |
|
|
|
// Pommel |
|
const pommelGeometry = new THREE.SphereGeometry(0.06, 8, 8); |
|
const pommel = new THREE.Mesh(pommelGeometry); |
|
pommel.position.z = -1.9; |
|
group.add(pommel); |
|
|
|
// Create unified geometry from group |
|
const swordGeometry = new THREE.CylinderGeometry(0.01, 0.03, 2, 4); |
|
swordGeometry.rotateX(Math.PI / 2); |
|
|
|
return swordGeometry; |
|
} |
|
|
|
function createSwordMaterial() { |
|
return new THREE.MeshBasicMaterial({ |
|
color: 0x00f0ff, |
|
transparent: true, |
|
opacity: 0.9, |
|
blending: THREE.AdditiveBlending |
|
}); |
|
} |
|
|
|
function createTrailForSword(sword) { |
|
const trailGeometry = new THREE.BufferGeometry(); |
|
const trailPositions = new Float32Array(CONFIG.TRAIL_LENGTH * 3); |
|
const trailColors = new Float32Array(CONFIG.TRAIL_LENGTH * 3); |
|
|
|
trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3)); |
|
trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3)); |
|
|
|
const trailMaterial = new THREE.LineBasicMaterial({ |
|
vertexColors: true, |
|
transparent: true, |
|
opacity: 0.6, |
|
blending: THREE.AdditiveBlending |
|
}); |
|
|
|
const trail = new THREE.Line(trailGeometry, trailMaterial); |
|
trail.frustumCulled = false; |
|
scene.add(trail); |
|
|
|
sword.userData.trailMesh = trail; |
|
sword.userData.trailPositions = []; |
|
} |
|
|
|
function createGiantSword() { |
|
const giantGeometry = new THREE.CylinderGeometry(0.3, 0.5, 15, 8); |
|
giantGeometry.rotateX(Math.PI / 2); |
|
|
|
const giantMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0x00f0ff, |
|
transparent: true, |
|
opacity: 0, |
|
blending: THREE.AdditiveBlending |
|
}); |
|
|
|
giantSword = new THREE.Mesh(giantGeometry, giantMaterial); |
|
giantSword.position.set(0, 0, 0); |
|
scene.add(giantSword); |
|
|
|
// Glow effect |
|
const glowGeometry = new THREE.CylinderGeometry(0.5, 0.8, 15, 8); |
|
glowGeometry.rotateX(Math.PI / 2); |
|
const glowMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0x00ff88, |
|
transparent: true, |
|
opacity: 0, |
|
blending: THREE.AdditiveBlending |
|
}); |
|
giantSword.userData.glow = new THREE.Mesh(glowGeometry, glowMaterial); |
|
scene.add(giantSword.userData.glow); |
|
} |
|
|
|
// ============================================ |
|
// PARTICLE SYSTEM |
|
// ============================================ |
|
function initParticles() { |
|
const particleGeometry = new THREE.BufferGeometry(); |
|
const positions = new Float32Array(CONFIG.PARTICLE_COUNT * 3); |
|
const colors = new Float32Array(CONFIG.PARTICLE_COUNT * 3); |
|
const sizes = new Float32Array(CONFIG.PARTICLE_COUNT); |
|
|
|
for (let i = 0; i < CONFIG.PARTICLE_COUNT; i++) { |
|
positions[i * 3] = (Math.random() - 0.5) * 50; |
|
positions[i * 3 + 1] = (Math.random() - 0.5) * 50; |
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 50; |
|
|
|
// Golden immortal qi colors |
|
const colorChoice = Math.random(); |
|
if (colorChoice < 0.4) { |
|
colors[i * 3] = 1; |
|
colors[i * 3 + 1] = 0.84; |
|
colors[i * 3 + 2] = 0; |
|
} else if (colorChoice < 0.7) { |
|
colors[i * 3] = 0; |
|
colors[i * 3 + 1] = 1; |
|
colors[i * 3 + 2] = 0.53; |
|
} else { |
|
colors[i * 3] = 0; |
|
colors[i * 3 + 1] = 0.94; |
|
colors[i * 3 + 2] = 1; |
|
} |
|
|
|
sizes[i] = Math.random() * 3; |
|
} |
|
|
|
particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
|
particleGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); |
|
particleGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); |
|
|
|
const particleMaterial = new THREE.PointsMaterial({ |
|
size: 0.1, |
|
vertexColors: true, |
|
transparent: true, |
|
opacity: 0.8, |
|
blending: THREE.AdditiveBlending, |
|
sizeAttenuation: true |
|
}); |
|
|
|
const particleSystem = new THREE.Points(particleGeometry, particleMaterial); |
|
particleSystem.userData = { |
|
velocities: [], |
|
originalPositions: [] |
|
}; |
|
|
|
for (let i = 0; i < CONFIG.PARTICLE_COUNT; i++) { |
|
particleSystem.userData.velocities.push(new THREE.Vector3( |
|
(Math.random() - 0.5) * 0.02, |
|
(Math.random() - 0.5) * 0.02, |
|
(Math.random() - 0.5) * 0.02 |
|
)); |
|
} |
|
|
|
particles.push(particleSystem); |
|
scene.add(particleSystem); |
|
} |
|
|
|
function createBurstParticles(position, count = 100, color = new THREE.Color(0x00f0ff)) { |
|
const burstGeometry = new THREE.BufferGeometry(); |
|
const positions = new Float32Array(count * 3); |
|
const velocities = []; |
|
|
|
for (let i = 0; i < count; i++) { |
|
positions[i * 3] = position.x; |
|
positions[i * 3 + 1] = position.y; |
|
positions[i * 3 + 2] = position.z; |
|
|
|
const theta = Math.random() * Math.PI * 2; |
|
const phi = Math.random() * Math.PI; |
|
const speed = 0.1 + Math.random() * 0.3; |
|
|
|
velocities.push(new THREE.Vector3( |
|
Math.sin(phi) * Math.cos(theta) * speed, |
|
Math.sin(phi) * Math.sin(theta) * speed, |
|
Math.cos(phi) * speed |
|
)); |
|
} |
|
|
|
burstGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
|
|
|
const burstMaterial = new THREE.PointsMaterial({ |
|
size: 0.15, |
|
color: color, |
|
transparent: true, |
|
opacity: 1, |
|
blending: THREE.AdditiveBlending, |
|
sizeAttenuation: true |
|
}); |
|
|
|
const burst = new THREE.Points(burstGeometry, burstMaterial); |
|
burst.userData = { velocities, life: 1 }; |
|
|
|
scene.add(burst); |
|
return burst; |
|
} |
|
|
|
// ============================================ |
|
// HAND DETECTION & GESTURE RECOGNITION |
|
// ============================================ |
|
function initMediaPipe() { |
|
const hands = new Hands({ |
|
locateFile: (file) => { |
|
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; |
|
} |
|
}); |
|
|
|
hands.setOptions({ |
|
maxNumHands: 2, |
|
modelComplexity: 1, |
|
minDetectionConfidence: 0.7, |
|
minTrackingConfidence: 0.6 |
|
}); |
|
|
|
hands.onResults(onHandResults); |
|
|
|
// Initialize camera |
|
const camera = new Camera(videoElement, { |
|
onFrame: async () => { |
|
await hands.send({ image: videoElement }); |
|
}, |
|
width: 640, |
|
height: 480 |
|
}); |
|
|
|
camera.start() |
|
.then(() => { |
|
console.log('Camera initialized'); |
|
webcamPreview.classList.remove('hidden'); |
|
setTimeout(() => { |
|
loadingScreen.classList.add('hidden'); |
|
initAudio(); |
|
}, 2000); |
|
}) |
|
.catch((err) => { |
|
console.error('Camera error:', err); |
|
errorMessage.style.display = 'block'; |
|
// Fallback to demo mode without camera |
|
loadingScreen.classList.add('hidden'); |
|
}); |
|
} |
|
|
|
function onHandResults(results) { |
|
handLandmarks.left = null; |
|
handLandmarks.right = null; |
|
|
|
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { |
|
for (let i = 0; i < results.multiHandLandmarks.length; i++) { |
|
const handedness = results.multiHandedness[i].label; |
|
const landmarks = results.multiHandLandmarks[i]; |
|
|
|
if (handedness === 'Left') { |
|
handLandmarks.left = landmarks; |
|
} else { |
|
handLandmarks.right = landmarks; |
|
} |
|
} |
|
} |
|
|
|
updateHandIndicators(); |
|
recognizeGesture(); |
|
} |
|
|
|
function updateHandIndicators() { |
|
if (handLandmarks.left) { |
|
leftHandInd.textContent = `左手 - 已检测`; |
|
leftHandInd.classList.add('active'); |
|
} else { |
|
leftHandInd.textContent = `左手 - 未检测`; |
|
leftHandInd.classList.remove('active'); |
|
} |
|
|
|
if (handLandmarks.right) { |
|
rightHandInd.textContent = `右手 - 已检测`; |
|
rightHandInd.classList.add('active'); |
|
} else { |
|
rightHandInd.textContent = `右手 - 未检测`; |
|
rightHandInd.classList.remove('active'); |
|
} |
|
} |
|
|
|
function recognizeGesture() { |
|
if (gestureCooldown > 0) { |
|
gestureCooldown--; |
|
return; |
|
} |
|
|
|
const leftHand = handLandmarks.left; |
|
const rightHand = handLandmarks.right; |
|
|
|
// Both hands detected |
|
if (leftHand && rightHand) { |
|
const leftGesture = getSingleHandGesture(leftHand); |
|
const rightGesture = getSingleHandGesture(rightHand); |
|
|
|
// Get hand positions |
|
const leftPalm = getPalmCenter(leftHand); |
|
const rightPalm = getPalmCenter(rightHand); |
|
const handsDistance = leftPalm.distanceTo(rightPalm); |
|
|
|
// Calculate hand movement for circle detection |
|
if (!leftHand.userData) leftHand.userData = { prevPos: leftPalm.clone(), circleAngle: 0 }; |
|
if (!rightHand.userData) rightHand.userData = { prevPos: rightPalm.clone(), circleAngle: 0 }; |
|
|
|
const leftMovement = leftPalm.clone().sub(leftHand.userData.prevPos); |
|
const rightMovement = rightPalm.clone().sub(rightHand.userData.prevPos); |
|
|
|
leftHand.userData.prevPos = leftPalm.clone(); |
|
rightHand.userData.prevPos = rightPalm.clone(); |
|
|
|
// Hands together - Giant sword |
|
if (handsDistance < 0.15) { |
|
changeGesture('together'); |
|
} |
|
// Hands spread apart - Fan formation |
|
else if (handsDistance > 0.5) { |
|
changeGesture('spread'); |
|
} |
|
// Both fists - Beam attack |
|
else if (leftGesture === 'fist' && rightGesture === 'fist') { |
|
changeGesture('fist_beam'); |
|
} |
|
// Both open palms - Disperse |
|
else if (leftGesture === 'open' && rightGesture === 'open') { |
|
changeGesture('open_palm'); |
|
} |
|
// Circle motion - Vortex |
|
else if (Math.abs(leftMovement.x) > 0.01 || Math.abs(rightMovement.x) > 0.01) { |
|
changeGesture('circle'); |
|
} |
|
// Default: both open = disperse |
|
else if (leftGesture === 'open' || rightGesture === 'open') { |
|
changeGesture('open_palm'); |
|
} |
|
} |
|
// Only left hand |
|
else if (leftHand) { |
|
const gesture = getSingleHandGesture(leftHand); |
|
if (gesture === 'pointing') { |
|
changeGesture('pointing_left'); |
|
} else if (gesture === 'fist') { |
|
changeGesture('fist_left'); |
|
} else if (gesture === 'open') { |
|
changeGesture('open_palm_left'); |
|
} |
|
} |
|
// Only right hand |
|
else if (rightHand) { |
|
const gesture = getSingleHandGesture(rightHand); |
|
if (gesture === 'pointing') { |
|
changeGesture('pointing_right'); |
|
} else if (gesture === 'fist') { |
|
changeGesture('fist_right'); |
|
} else if (gesture === 'open') { |
|
changeGesture('open_palm_right'); |
|
} |
|
} else { |
|
changeGesture('idle'); |
|
} |
|
} |
|
|
|
function getSingleHandGesture(landmarks) { |
|
const wrist = landmarks[0]; |
|
const indexTip = landmarks[8]; |
|
const indexPip = landmarks[6]; |
|
const middleTip = landmarks[12]; |
|
const middlePip = landmarks[10]; |
|
const ringTip = landmarks[16]; |
|
const ringPip = landmarks[14]; |
|
const pinkyTip = landmarks[20]; |
|
const pinkyPip = landmarks[18]; |
|
const thumbTip = landmarks[4]; |
|
const thumbIp = landmarks[3]; |
|
|
|
// Check if fingers are curled |
|
const indexCurled = indexTip.y > indexPip.y + 0.02; |
|
const middleCurled = middleTip.y > middlePip.y + 0.02; |
|
const ringCurled = ringTip.y > ringPip.y + 0.02; |
|
const pinkyCurled = pinkyTip.y > pinkyPip.y + 0.02; |
|
|
|
// Check finger spread for open palm |
|
const fingersSpread = !indexCurled && !middleCurled && !ringCurled && !pinkyCurled; |
|
|
|
// Check pointing (only index extended) |
|
const isPointing = !indexCurled && middleCurled && ringCurled && pinkyCurled; |
|
|
|
// Check fist (all fingers curled) |
|
const isFist = indexCurled && middleCurled && ringCurled && pinkyCurled; |
|
|
|
if (isPointing) return 'pointing'; |
|
if (isFist) return 'fist'; |
|
if (fingersSpread) return 'open'; |
|
return 'unknown'; |
|
} |
|
|
|
function getPalmCenter(landmarks) { |
|
const palmIndices = [0, 1, 5, 9, 13, 17]; |
|
let x = 0, y = 0, z = 0; |
|
|
|
for (const i of palmIndices) { |
|
x += landmarks[i].x; |
|
y += landmarks[i].y; |
|
z += landmarks[i].z; |
|
} |
|
|
|
return new THREE.Vector3( |
|
(x / palmIndices.length - 0.5) * 10, |
|
-(y / palmIndices.length - 0.5) * 10, |
|
5 + (z / palmIndices.length) * 5 |
|
); |
|
} |
|
|
|
function changeGesture(newGesture) { |
|
if (newGesture !== previousGesture) { |
|
previousGesture = currentGesture; |
|
currentGesture = newGesture; |
|
gestureCooldown = 20; // Frames to wait before next gesture change |
|
|
|
// Update UI |
|
updateGestureUI(newGesture); |
|
|
|
// Play sound effects based on gesture |
|
if (newGesture === 'fist_beam' || newGesture === 'fist_left' || newGesture === 'fist_right') { |
|
playEnergyHum(1); |
|
} else if (newGesture === 'open_palm' || newGesture === 'spread') { |
|
playSwordWhoosh(0.5); |
|
} else if (newGesture === 'pointing_left' || newGesture === 'pointing_right') { |
|
playEnergyHum(0.5); |
|
} |
|
|
|
// Update guide UI |
|
document.querySelectorAll('.gesture-item').forEach(el => { |
|
el.classList.remove('active'); |
|
if (el.dataset.gesture === newGesture.replace('_left', '').replace('_right', '')) { |
|
el.classList.add('active'); |
|
} |
|
}); |
|
} |
|
} |
|
|
|
function updateGestureUI(gesture) { |
|
const gestureNames = { |
|
'idle': '待机中', |
|
'open_palm': '✋ 剑阵分散', |
|
'fist_beam': '👊 万剑冲击', |
|
'pointing_left': '👆 剑气射击', |
|
'pointing_right': '👆 剑气射击', |
|
'circle': '🔄 剑刃漩涡', |
|
'spread': '🤲 扇形展开', |
|
'together': '🙏 巨剑合体', |
|
'fist_left': '👊 单手握拳', |
|
'fist_right': '👊 单手握拳', |
|
'open_palm_left': '✋ 左手张开', |
|
'open_palm_right': '✋ 右手张开' |
|
}; |
|
|
|
gestureInd.textContent = `当前: ${gestureNames[gesture] || gesture}`; |
|
} |
|
|
|
// ============================================ |
|
// SWORD ANIMATION SYSTEM |
|
// ============================================ |
|
function updateSwords(deltaTime) { |
|
const time = clock.getElapsedTime(); |
|
|
|
// Target position based on gesture |
|
let targetCenter = new THREE.Vector3(0, 0, 0); |
|
let formationType = 'orbit'; |
|
let attackTarget = null; |
|
let attackDirection = null; |
|
|
|
// Process hand positions for target calculation |
|
if (handLandmarks.left) { |
|
const leftPalm = getPalmCenter(handLandmarks.left); |
|
if (currentGesture.includes('pointing')) { |
|
targetCenter = leftPalm.clone(); |
|
attackDirection = getPointingDirection(handLandmarks.left); |
|
} else if (currentGesture === 'circle') { |
|
targetCenter = leftPalm.clone(); |
|
} |
|
} |
|
|
|
if (handLandmarks.right) { |
|
const rightPalm = getPalmCenter(handLandmarks.right); |
|
if (currentGesture === 'together') { |
|
targetCenter = rightPalm.clone().add(leftPalm ? leftPalm.clone() : new THREE.Vector3()).multiplyScalar(0.5); |
|
} else if (currentGesture === 'spread') { |
|
targetCenter = rightPalm.clone(); |
|
} |
|
} |
|
|
|
if (handLandmarks.left && handLandmarks.right) { |
|
const leftPalm = getPalmCenter(handLandmarks.left); |
|
const rightPalm = getPalmCenter(handLandmarks.right); |
|
const center = leftPalm.clone().add(rightPalm).multiplyScalar(0.5); |
|
|
|
if (currentGesture === 'fist_beam') { |
|
targetCenter = center; |
|
attackDirection = new THREE.Vector3(0, 0, 1); |
|
} else if (currentGesture === 'open_palm') { |
|
targetCenter = center; |
|
} |
|
} |
|
|
|
// Update each sword |
|
swords.forEach((sword, i) => { |
|
const data = sword.userData; |
|
const currentPos = sword.position.clone(); |
|
const lerpFactor = getLerpFactor(currentGesture); |
|
|
|
switch (currentGesture) { |
|
case 'idle': |
|
// Elegant orbiting formation |
|
data.orbitAngle += deltaTime * CONFIG.ORBIT_SPEED * data.orbitSpeed; |
|
const orbitX = Math.cos(data.orbitAngle) * data.orbitRadius; |
|
const orbitZ = Math.sin(data.orbitAngle) * data.orbitRadius; |
|
const orbitY = data.orbitY + Math.sin(time * 0.5 + data.phase) * 2; |
|
data.targetPosition.set(orbitX, orbitY, orbitZ); |
|
sword.lookAt(new THREE.Vector3(0, orbitY, 0)); |
|
break; |
|
|
|
case 'open_palm': |
|
case 'open_palm_left': |
|
case 'open_palm_right': |
|
// Wide defensive circle |
|
const spreadAngle = (i / CONFIG.SWORD_COUNT) * Math.PI * 2; |
|
const spreadRadius = 8 + Math.sin(time + i * 0.1) * 0.5; |
|
data.targetPosition.set( |
|
Math.cos(spreadAngle) * spreadRadius, |
|
Math.sin(spreadAngle * 2) * 3, |
|
Math.sin(spreadAngle) * spreadRadius - 3 |
|
); |
|
sword.lookAt(new THREE.Vector3(0, 0, 0)); |
|
break; |
|
|
|
case 'fist_beam': |
|
case 'fist_left': |
|
case 'fist_right': |
|
// Rapid convergence then beam attack |
|
if (currentPos.length() > 2) { |
|
data.targetPosition.copy(currentPos).multiplyScalar(0.85); |
|
} else { |
|
// Swords gather at center |
|
const gatherAngle = (i / CONFIG.SWORD_COUNT) * Math.PI * 2; |
|
const gatherRadius = 0.5 + Math.sin(i * 0.5) * 0.3; |
|
data.targetPosition.set( |
|
Math.cos(gatherAngle) * gatherRadius, |
|
(i / CONFIG.SWORD_COUNT - 0.5) * 3, |
|
Math.sin(gatherAngle) * gatherRadius |
|
); |
|
} |
|
break; |
|
|
|
case 'pointing_left': |
|
case 'pointing_right': |
|
// Single powerful beam in pointing direction |
|
const beamAngle = (i / CONFIG.SWORD_COUNT) * Math.PI * 2; |
|
data.targetPosition.set( |
|
targetCenter.x + Math.cos(beamAngle) * 0.5, |
|
targetCenter.y + (i / CONFIG.SWORD_COUNT - 0.5) * 2, |
|
targetCenter.z |
|
); |
|
break; |
|
|
|
case 'circle': |
|
case 'vortex': |
|
// Spiral vortex around palm center |
|
circleAngle += deltaTime * CONFIG.CIRCLE_SPEED; |
|
const vortexRadius = 3 + Math.sin(time * 2) * 0.5; |
|
const vortexHeight = Math.sin(time * 3 + i * 0.1) * 2; |
|
const vortexAngle = circleAngle + (i / CONFIG.SWORD_COUNT) * Math.PI * 8; |
|
data.targetPosition.set( |
|
targetCenter.x + Math.cos(vortexAngle) * vortexRadius, |
|
targetCenter.y + vortexHeight, |
|
targetCenter.z + Math.sin(vortexAngle) * vortexRadius |
|
); |
|
sword.rotation.z += deltaTime * 5; |
|
break; |
|
|
|
case 'spread': |
|
// Fan formation |
|
const fanAngle = ((i / CONFIG.SWORD_COUNT) - 0.5) * Math.PI; |
|
data.targetPosition.set( |
|
targetCenter.x + Math.sin(fanAngle) * 5, |
|
targetCenter.y + Math.sin(time + i * 0.1) * 0.5, |
|
targetCenter.z - 5 + Math.cos(fanAngle) * 2 |
|
); |
|
sword.lookAt(targetCenter); |
|
break; |
|
|
|
case 'together': |
|
// Merge into giant sword |
|
const mergeProgress = Math.min(1, (1 - currentPos.length() / 8) * 2); |
|
data.targetPosition.set( |
|
(Math.random() - 0.5) * mergeProgress, |
|
(Math.random() - 0.5) * mergeProgress + (i / CONFIG.SWORD_COUNT - 0.5) * 10 * mergeProgress, |
|
(Math.random() - 0.5) * mergeProgress |
|
); |
|
break; |
|
} |
|
|
|
// Smooth interpolation to target |
|
sword.position.lerp(data.targetPosition, lerpFactor); |
|
|
|
// Update trail |
|
updateTrail(sword, deltaTime); |
|
}); |
|
|
|
// Special: Giant sword appear |
|
if (currentGesture === 'together') { |
|
giantSword.material.opacity = Math.min(1, giantSword.material.opacity + deltaTime * 2); |
|
giantSword.userData.glow.material.opacity = giantSword.material.opacity * 0.3; |
|
giantSword.rotation.y += deltaTime * 0.5; |
|
} else { |
|
giantSword.material.opacity = Math.max(0, giantSword.material.opacity - deltaTime * 2); |
|
giantSword.userData.glow.material.opacity = giantSword.material.opacity * 0.3; |
|
} |
|
} |
|
|
|
function getLerpFactor(gesture) { |
|
switch (gesture) { |
|
case 'fist_beam': |
|
case 'fist_left': |
|
case 'fist_right': |
|
return 0.15; // Fast convergence |
|
case 'pointing_left': |
|
case 'pointing_right': |
|
return 0.1; |
|
case 'circle': |
|
return 0.08; |
|
default: |
|
return 0.05; // Slow, elegant movement |
|
} |
|
} |
|
|
|
function getPointingDirection(landmarks) { |
|
const wrist = new THREE.Vector3(landmarks[0].x, landmarks[0].y, 0); |
|
const indexTip = new THREE.Vector3(landmarks[8].x, landmarks[8].y, 0); |
|
return indexTip.sub(wrist).normalize(); |
|
} |
|
|
|
function updateTrail(sword, deltaTime) { |
|
const data = sword.userData; |
|
const trail = data.trailMesh; |
|
const positions = data.trailPositions; |
|
|
|
// Add current position |
|
positions.unshift(sword.position.clone()); |
|
if (positions.length > CONFIG.TRAIL_LENGTH) { |
|
positions.pop(); |
|
} |
|
|
|
// Update geometry |
|
const posArray = trail.geometry.attributes.position.array; |
|
const colorArray = trail.geometry.attributes.color.array; |
|
|
|
for (let i = 0; i < CONFIG.TRAIL_LENGTH; i++) { |
|
const pos = positions[i] || positions[positions.length - 1] || sword.position; |
|
posArray[i * 3] = pos.x; |
|
posArray[i * 3 + 1] = pos.y; |
|
posArray[i * 3 + 2] = pos.z; |
|
|
|
// Gradient color from cyan to dark blue |
|
const fade = 1 - (i / CONFIG.TRAIL_LENGTH); |
|
colorArray[i * 3] = 0 * fade; // R |
|
colorArray[i * 3 + 1] = 0.94 * fade; // G |
|
colorArray[i * 3 + 2] = 1 * fade; // B |
|
} |
|
|
|
trail.geometry.attributes.position.needsUpdate = true; |
|
trail.geometry.attributes.color.needsUpdate = true; |
|
} |
|
|
|
// ============================================ |
|
// PARTICLE ANIMATION |
|
// ============================================ |
|
function updateParticles(deltaTime) { |
|
const time = clock.getElapsedTime(); |
|
|
|
// Ambient particles |
|
if (particles[0]) { |
|
const positions = particles[0].geometry.attributes.position.array; |
|
const velocities = particles[0].userData.velocities; |
|
|
|
for (let i = 0; i < CONFIG.PARTICLE_COUNT; i++) { |
|
const i3 = i * 3; |
|
|
|
// Add velocity |
|
positions[i3] += velocities[i].x; |
|
positions[i3 + 1] += velocities[i].y; |
|
positions[i3 + 2] += velocities[i].z; |
|
|
|
// Slight attraction to center |
|
velocities[i].x -= positions[i3] * 0.0001; |
|
velocities[i].y -= positions[i3 + 1] * 0.0001; |
|
velocities[i].z -= positions[i3 + 2] * 0.0001; |
|
|
|
// Wrap around bounds |
|
if (Math.abs(positions[i3]) > 25) positions[i3] *= -0.9; |
|
if (Math.abs(positions[i3 + 1]) > 25) positions[i3 + 1] *= -0.9; |
|
if (Math.abs(positions[i3 + 2]) > 25) positions[i3 + 2] *= -0.9; |
|
} |
|
|
|
particles[0].geometry.attributes.position.needsUpdate = true; |
|
particles[0].rotation.y = time * 0.02; |
|
} |
|
} |
|
|
|
// ============================================ |
|
// ANIMATION LOOP |
|
// ============================================ |
|
let burstParticles = []; |
|
|
|
function animate() { |
|
requestAnimationFrame(animate); |
|
|
|
const deltaTime = Math.min(clock.getDelta(), 0.1); |
|
const time = clock.getElapsedTime(); |
|
|
|
// Update sword positions based on gestures |
|
updateSwords(deltaTime); |
|
|
|
// Update ambient particles |
|
updateParticles(deltaTime); |
|
|
|
// Update burst particles |
|
burstParticles = burstParticles.filter(burst => { |
|
burst.userData.life -= deltaTime; |
|
|
|
if (burst.userData.life <= 0) { |
|
scene.remove(burst); |
|
burst.geometry.dispose(); |
|
burst.material.dispose(); |
|
return false; |
|
} |
|
|
|
const positions = burst.geometry.attributes.position.array; |
|
const velocities = burst.userData.velocities; |
|
|
|
for (let i = 0; i < velocities.length; i++) { |
|
const i3 = i * 3; |
|
positions[i3] += velocities[i].x; |
|
positions[i3 + 1] += velocities[i].y; |
|
positions[i3 + 2] += velocities[i].z; |
|
} |
|
|
|
burst.geometry.attributes.position.needsUpdate = true; |
|
burst.material.opacity = burst.userData.life; |
|
|
|
return true; |
|
}); |
|
|
|
// Sword material pulse effect |
|
swords.forEach((sword, i) => { |
|
const pulse = Math.sin(time * 3 + i * 0.1) * 0.1 + 0.9; |
|
sword.material.opacity = 0.7 + pulse * 0.3; |
|
sword.material.emissiveIntensity = pulse; |
|
}); |
|
|
|
// Camera subtle movement |
|
camera.position.x = Math.sin(time * 0.1) * 0.5; |
|
camera.position.y = 5 + Math.sin(time * 0.15) * 0.3; |
|
camera.lookAt(0, 0, 0); |
|
|
|
// Render |
|
renderer.render(scene, camera); |
|
} |
|
|
|
function onWindowResize() { |
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
} |
|
|
|
// ============================================ |
|
// TOUCH FALLBACK (Mobile) |
|
// ============================================ |
|
function initTouchFallback() { |
|
let touchStartDist = 0; |
|
let lastTouchTime = 0; |
|
|
|
document.addEventListener('touchstart', (e) => { |
|
if (e.touches.length === 2) { |
|
touchStartDist = Math.hypot( |
|
e.touches[0].clientX - e.touches[1].clientX, |
|
e.touches[0].clientY - e.touches[1].clientY |
|
); |
|
} |
|
}); |
|
|
|
document.addEventListener('touchmove', (e) => { |
|
if (e.touches.length === 2) { |
|
const currentDist = Math.hypot( |
|
e.touches[0].clientX - e.touches[1].clientX, |
|
e.touches[0].clientY - e.touches[1].clientY |
|
); |
|
|
|
const diff = currentDist - touchStartDist; |
|
|
|
if (diff > 50) { |
|
changeGesture('spread'); |
|
} else if (diff < -50) { |
|
changeGesture('together'); |
|
} |
|
} |
|
}); |
|
|
|
document.addEventListener('touchend', (e) => { |
|
if (e.touches.length === 0 && Date.now() - lastTouchTime > 500) { |
|
changeGesture('idle'); |
|
} |
|
lastTouchTime = Date.now(); |
|
}); |
|
} |
|
|
|
// ============================================ |
|
// DEBUG CONTROLS (dat.gui alternative) |
|
// ============================================ |
|
function initDebugControls() { |
|
const toggle = document.getElementById('perf-toggle'); |
|
|
|
const qualities = ['low', 'medium', 'high']; |
|
let qualityIndex = 2; |
|
|
|
toggle.addEventListener('click', () => { |
|
qualityIndex = (qualityIndex + 1) % qualities.length; |
|
CONFIG.QUALITY = qualities[qualityIndex]; |
|
toggle.textContent = `⚡ 性能模式: ${CONFIG.QUALITY.toUpperCase()}`; |
|
|
|
if (CONFIG.QUALITY === 'low') { |
|
renderer.setPixelRatio(1); |
|
} else if (CONFIG.QUALITY === 'medium') { |
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); |
|
} else { |
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
|
} |
|
}); |
|
} |
|
|
|
// ============================================ |
|
// INITIALIZATION |
|
// ============================================ |
|
function init() { |
|
initThree(); |
|
initMediaPipe(); |
|
initTouchFallback(); |
|
initDebugControls(); |
|
animate(); |
|
|
|
// Keyboard shortcuts for demo without camera |
|
document.addEventListener('keydown', (e) => { |
|
const keyMap = { |
|
'1': 'idle', |
|
'2': 'open_palm', |
|
'3': 'fist_beam', |
|
'4': 'pointing_left', |
|
'5': 'circle', |
|
'6': 'spread', |
|
'7': 'together' |
|
}; |
|
|
|
if (keyMap[e.key]) { |
|
// Simulate hand detection for demo |
|
handLandmarks.left = e.key !== 'spread' ? { some: 'data' } : null; |
|
handLandmarks.right = ['open_palm', 'spread', 'together', 'circle'].includes(keyMap[e.key]) ? { some: 'data' } : null; |
|
changeGesture(keyMap[e.key]); |
|
} |
|
}); |
|
|
|
console.log('万剑归宗 - 赛博修仙 initialized!'); |
|
console.log('Press 1-7 keys to simulate gestures without camera'); |
|
} |
|
|
|
// Start when DOM is ready |
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', init); |
|
} else { |
|
init(); |
|
} |
|
|
|
})(); |
|
</script> |
|
</body> |
|
</html> |