feat(visualizer): add multiple visualizers with mouse tracking and beat response

- Add BaseVisualizer class for shared visualizer logic
- Add Helix visualizer (DNA helix with rungs, beat compression/bounce)
- Add Tunnel visualizer (ring tunnel with depth parallax)
- Refactor SphereVisualizer to extend BaseVisualizer
- Add mouse tracking to all visualizers (canvas-relative, centered)
- Add visualizer switching via logo click (persisted to localStorage)
- Add beat-responsive bouncing and compression effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-29 21:11:38 +01:00
parent d46e54b592
commit e50c8a2503
9 changed files with 609 additions and 55 deletions

View File

@@ -1,10 +1,19 @@
/**
* WebGL Visualizer Scene
* Three.js particle system that reacts to audio frequency data
* Three.js scene manager with multiple selectable visualizers
*/
import * as THREE from 'three';
import { ParticleSystem } from './particles.js';
import { SphereVisualizer } from './particles.js';
import { TunnelVisualizer } from './tunnel.js';
import { HelixVisualizer } from './helix.js';
// Available visualizer classes
const VISUALIZERS = [
SphereVisualizer,
TunnelVisualizer,
HelixVisualizer
];
export class Visualizer {
constructor(canvas, audioManager) {
@@ -13,6 +22,14 @@ export class Visualizer {
this.running = false;
this.time = 0;
// Mouse tracking (normalized -1 to 1)
this.mouse = { x: 0, y: 0 };
this.mouseActive = false;
// Visualizer management
this.visualizers = [];
this.currentIndex = 0;
if (!canvas) {
console.warn('Visualizer: No canvas provided');
return;
@@ -44,17 +61,93 @@ export class Visualizer {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Particles
this.particles = new ParticleSystem(5000);
this.scene.add(this.particles.mesh);
// Initialize all visualizers
this.visualizers = VISUALIZERS.map(VisualizerClass => {
return new VisualizerClass(this.scene);
});
// Load saved visualizer preference
const savedIndex = localStorage.getItem('pivoine-visualizer');
if (savedIndex !== null) {
this.currentIndex = parseInt(savedIndex, 10) % this.visualizers.length;
}
// Show the current visualizer
this.visualizers[this.currentIndex].show();
// Event listeners
window.addEventListener('resize', () => this.resize());
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
// Start animation
this.start();
}
/**
* Get list of visualizer names
*/
getVisualizerNames() {
return VISUALIZERS.map(V => V.name);
}
/**
* Get current visualizer name
*/
getCurrentName() {
return VISUALIZERS[this.currentIndex].name;
}
/**
* Get current visualizer index
*/
getCurrentIndex() {
return this.currentIndex;
}
/**
* Set active visualizer by index
*/
setVisualizer(index) {
if (index < 0 || index >= this.visualizers.length) return;
if (index === this.currentIndex) return;
// Hide current
this.visualizers[this.currentIndex].hide();
// Show new
this.currentIndex = index;
this.visualizers[this.currentIndex].show();
// Save preference
localStorage.setItem('pivoine-visualizer', index.toString());
}
/**
* Cycle to next visualizer
*/
nextVisualizer() {
const nextIndex = (this.currentIndex + 1) % this.visualizers.length;
this.setVisualizer(nextIndex);
return this.getCurrentName();
}
/**
* Cycle to previous visualizer
*/
prevVisualizer() {
const prevIndex = (this.currentIndex - 1 + this.visualizers.length) % this.visualizers.length;
this.setVisualizer(prevIndex);
return this.getCurrentName();
}
onMouseMove(e) {
// Normalize to -1 to 1 relative to canvas
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.mouseActive = true;
}
resize() {
if (!this.camera || !this.renderer) return;
@@ -89,8 +182,12 @@ export class Visualizer {
bands = this.audioManager.getFrequencyBands();
}
// Update particles with audio data
this.particles.update(bands, this.time);
// Update current visualizer with audio data and mouse position
const currentViz = this.visualizers[this.currentIndex];
if (currentViz) {
const mouse = this.mouseActive ? this.mouse : { x: 0, y: 0 };
currentViz.update(bands, this.time, mouse);
}
// Subtle camera movement
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
@@ -102,7 +199,7 @@ export class Visualizer {
destroy() {
this.running = false;
this.particles?.dispose();
this.visualizers.forEach(v => v.dispose());
this.renderer?.dispose();
}
}