Skip to content

Instantly share code, notes, and snippets.

@cyysky
Created January 12, 2026 12:35
Show Gist options
  • Select an option

  • Save cyysky/bab6f8efd129b1cdaec06466108d8832 to your computer and use it in GitHub Desktop.

Select an option

Save cyysky/bab6f8efd129b1cdaec06466108d8832 to your computer and use it in GitHub Desktop.
A cyber-xianxia immersive experience with 3D sword arrays and hand gesture interaction.

万剑归宗 - 赛博修仙

A cyber-xianxia immersive experience with 3D sword arrays and hand gesture interaction.

Overview

"万剑归宗" (Ten Thousand Swords Return to the Source) is an interactive visual art piece that blends traditional Chinese cultivation aesthetics with modern cyberpunk elements. Users can control a constellation of floating swords through hand gestures, creating a mesmerizing display of light and motion.

Features

  • 3D Sword Array: Thousands of ethereal swords floating in formation, creating dynamic patterns
  • Hand Gesture Control: Use your hands to interact with the sword formation via webcam
  • Particle Effects: Explosive burst particles that respond to your movements
  • Immersive Audio: Atmospheric sound effects that respond to interactions
  • Cyber-Xianxia Aesthetic: A fusion of ancient cultivation fantasy with futuristic visuals

Tech Stack

  • Three.js: 3D rendering and particle system
  • MediaPipe Hands: Real-time hand landmark detection and gesture recognition
  • WebGL: Hardware-accelerated graphics
  • Web Audio API: Dynamic sound synthesis

How to Run

  1. Open wanjian_guizong.html in a modern web browser (Chrome/Edge recommended)
  2. Allow camera access when prompted (for hand gesture control)
  3. Wave your hand in front of the camera to interact with the swords
  4. Use hand gestures to control sword formation and trigger effects

Controls

  • Hand Movement: Guide the sword formation
  • Hand Gestures: Trigger particle bursts and visual effects

Requirements

  • Modern browser with WebGL support
  • Webcam for gesture interaction
  • A reasonably lit environment for hand detection

Browser Compatibility

  • Chrome/Edge (recommended)
  • Firefox
  • Safari

License

MIT License

{
"project": {
"name": "Cyber Xianxia - Wan Jian Gui Zong Gesture Control",
"description": "Create a real-time web-based interactive demo that simulates the famous 'Ten Thousand Swords Return to the Sect' scene from the xianxia novel 'A Record of a Mortal's Journey to Immortality'. User controls hundreds of flying swords with hand gestures (palm open = disperse swords, fist clench = swords gather/attack forward, two hands circle = swords rotate in vortex, finger pointing = direct sword beam attack). Use dramatic oriental fantasy aesthetic with glowing cyan/blue sword trails, golden particles, immortal energy mist.",
"technologies": [
"HTML5",
"JavaScript (ES6+)",
"Three.js (latest)",
"MediaPipe Hands (for real-time multi-hand landmark detection)",
"dat.gui (optional for debug controls)",
"No external frameworks like React/Vue"
],
"target_device": "Desktop + Webcam (mobile support bonus but secondary)"
},
"requirements": {
"hand_detection": {
"library": "MediaPipe Hands",
"settings": {
"maxNumHands": 2,
"modelComplexity": 1,
"minDetectionConfidence": 0.7,
"minTrackingConfidence": 0.6
},
"key_landmarks": {
"wrist": true,
"index_tip": "pointing direction",
"thumb_tip": "clench detection helper",
"palm_center": "main control point",
"fingers_spread": "open palm detection"
}
},
"visual_style": {
"background": "dark cosmic void gradient #000814 → #0a1a2f with subtle nebula stars",
"sword_model": "simple elegant chinese jian sword (low-poly or procedural)",
"sword_color": "glowing cyan #00f0ff with white core, blue bloom",
"trail": "long particle trail with glow, fade from bright cyan to dark blue",
"particles": "golden immortal qi sparks + white energy wisps when swords gather",
"effects": [
"god rays / volumetric light when swords converge",
"shockwave ring on fist clench attack",
"dramatic camera shake on impact",
"bloom + chromatic aberration for immortal feel"
]
},
"gesture_mapping": {
"open_palm_both_hands": "disperse swords in wide formation (defensive circle around user)",
"fist_clench_both_hands": "all swords rapidly converge to center then shoot forward in beam",
"single_hand_pointing": "direct one powerful sword beam in pointing direction",
"two_hands_clockwise_circle": "swords rotate in vortex around palm center",
"hands_spread_apart": "swords fan out in 180° arc",
"hands_come_together": "swords merge into one giant energy sword",
"idle_no_hands": "swords slowly orbit in elegant idle formation"
},
"physics_and_animation": {
"sword_count": 300,
"movement": "lerp + spring + perlin noise for organic flying sword feel",
"speed": "fast convergence (0.4s), smooth dispersion (1.2s)",
"collision": "simple particle burst on 'impact' point when attacking"
}
},
"technical_constraints": {
"performance": "must run 60fps on mid-range laptop (RTX 3060 or equivalent)",
"file_size": "keep under 2MB if possible (use procedural geometry)",
"dependencies": "only CDN versions of three.js + mediapipe hands"
},
"output_format": {
"type": "complete_single_file_html",
"structure": "<!DOCTYPE html> ... <script> all logic here </script>",
"include": [
"full working code with comments",
"error handling for no webcam",
"simple loading screen",
"optional dat.gui debug panel for sword count/speed/glow"
],
"bonus_features": [
"add sound effects? (optional, free sword whoosh + energy hum)",
"mobile touch fallback (two finger pinch = converge)"
]
},
"tone": "epic xianxia cultivation fantasy, dramatic, immersive, satisfying power fantasy feel",
"inspiration_references": [
"classic wuxia flying sword scenes",
"existing three.js particle swarms",
"cyberpunk + oriental fusion aesthetic",
"similar gesture particle demos by RJ Chicago / xiaowo1800"
],
"final_instruction": "Generate the COMPLETE single-file HTML code with embedded Three.js + MediaPipe that implements this exact experience. Make it beautiful, performant and magical. Add Chinese title '万剑归宗 - 赛博修仙' at the top. Good luck, fellow cultivator!"
}
<!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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment