feat: Add Effect Lab page with an interactive Three.js sword stream animation.
This commit is contained in:
59
src/pages/effect-lab/EffectLab.css
Normal file
59
src/pages/effect-lab/EffectLab.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.effect-lab {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
/* Adjust based on navbar height */
|
||||
overflow: hidden;
|
||||
background-color: #050505;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.effect-lab canvas {
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.effect-lab-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.effect-lab-overlay h2 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
color: var(--text);
|
||||
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
|
||||
}
|
||||
|
||||
.active-mode {
|
||||
display: inline-block;
|
||||
background: rgba(68, 170, 221, 0.1);
|
||||
border: 1px solid rgba(68, 170, 221, 0.3);
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
color: #44aadd;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active-mode span {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.effect-lab-overlay p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
218
src/pages/effect-lab/EffectLab.jsx
Normal file
218
src/pages/effect-lab/EffectLab.jsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import './EffectLab.css';
|
||||
|
||||
const EffectLab = () => {
|
||||
const containerRef = useRef(null);
|
||||
const requestRef = useRef();
|
||||
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// --- Configuration ---
|
||||
const COUNT = 1500;
|
||||
const SWORD_COLOR = 0x44aadd;
|
||||
const SWORD_EMISSIVE = 0x112244;
|
||||
|
||||
// --- Helper: Random Range ---
|
||||
const rand = (min, max) => Math.random() * (max - min) + min;
|
||||
|
||||
// --- Setup Scene ---
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x050505, 0.002);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
camera.position.z = 80;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
// Tone mapping for better glow look
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
// --- Lighting ---
|
||||
const ambientLight = new THREE.AmbientLight(0x404040);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
|
||||
scene.add(pointLight);
|
||||
|
||||
// --- Geometry & Material ---
|
||||
// Sword shape: Cone stretched
|
||||
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
|
||||
geometry.rotateX(Math.PI / 2); // Point towards Z
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: SWORD_COLOR,
|
||||
emissive: SWORD_EMISSIVE,
|
||||
shininess: 100,
|
||||
flatShading: true,
|
||||
});
|
||||
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
||||
scene.add(mesh);
|
||||
|
||||
// --- Particle Data ---
|
||||
const dummy = new THREE.Object3D();
|
||||
const particles = [];
|
||||
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
particles.push({
|
||||
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
|
||||
vel: new THREE.Vector3(),
|
||||
acc: new THREE.Vector3(),
|
||||
// Orbit parameters
|
||||
angle: rand(0, Math.PI * 2),
|
||||
radius: rand(15, 30),
|
||||
speed: rand(0.02, 0.05),
|
||||
// Offset for natural movement
|
||||
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mouse & Interaction State ---
|
||||
const mouse = new THREE.Vector3();
|
||||
const target = new THREE.Vector3();
|
||||
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
|
||||
let isMouseDown = false;
|
||||
let time = 0;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||
raycaster.ray.intersectPlane(mousePlane, mouse);
|
||||
|
||||
// Allow light to follow mouse
|
||||
pointLight.position.copy(mouse);
|
||||
pointLight.position.z = 20;
|
||||
};
|
||||
|
||||
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
|
||||
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mousedown', handleMouseDown);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// --- Animation Loop ---
|
||||
const animate = () => {
|
||||
requestRef.current = requestAnimationFrame(animate);
|
||||
time += 0.01;
|
||||
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const p = particles[i];
|
||||
|
||||
// --- Behavior Logic ---
|
||||
if (isMouseDown) {
|
||||
// 1. ORBIT MODE: Rotate around mouse
|
||||
p.angle += p.speed + 0.02; // Spin faster
|
||||
|
||||
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
|
||||
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
|
||||
// Spiraling Z for depth
|
||||
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
|
||||
|
||||
target.set(orbitX, orbitY, orbitZ);
|
||||
|
||||
// Strong pull to orbit positions
|
||||
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
|
||||
|
||||
} else {
|
||||
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
|
||||
|
||||
// Add noise/wandering
|
||||
const noiseX = Math.sin(time + i * 0.1) * 5;
|
||||
const noiseY = Math.cos(time + i * 0.1) * 5;
|
||||
|
||||
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
|
||||
|
||||
// Gentle pull
|
||||
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
|
||||
}
|
||||
|
||||
// Physics update
|
||||
p.vel.add(p.acc);
|
||||
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
|
||||
p.pos.add(p.vel);
|
||||
|
||||
// Update Matrix
|
||||
dummy.position.copy(p.pos);
|
||||
|
||||
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
|
||||
// Blending lookAt target for smoother rotation
|
||||
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
|
||||
|
||||
// If moving very slowly, keep previous rotation to avoid jitter
|
||||
if (p.vel.lengthSq() > 0.01) {
|
||||
dummy.lookAt(lookPos);
|
||||
}
|
||||
|
||||
// Scale effect based on speed (stretch when fast)
|
||||
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
|
||||
dummy.scale.set(1, 1, speedScale);
|
||||
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
// --- Resize ---
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) return;
|
||||
const newWidth = containerRef.current.clientWidth;
|
||||
const newHeight = containerRef.current.clientHeight;
|
||||
|
||||
camera.aspect = newWidth / newHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(newWidth, newHeight);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mousedown', handleMouseDown);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
if (containerRef.current && renderer.domElement) {
|
||||
containerRef.current.removeChild(renderer.domElement);
|
||||
}
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="effect-lab" ref={containerRef}>
|
||||
<div className="effect-lab-overlay">
|
||||
<h2>Sword Stream</h2>
|
||||
<div className="active-mode">
|
||||
MODE: <span>{mode}</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Move</strong> to Guide |
|
||||
<strong>Click & Hold</strong> to Orbit & Charge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EffectLab;
|
||||
Reference in New Issue
Block a user