feat: Add Effect Lab page with an interactive Three.js sword stream animation.
This commit is contained in:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -2921,6 +2922,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
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;
|
||||||
@@ -6,6 +6,7 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
|||||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,12 @@ export const navLinks = [
|
|||||||
path: '/travel',
|
path: '/travel',
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'lab',
|
||||||
|
label: 'Lab',
|
||||||
|
path: '/lab',
|
||||||
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appRoutes = [
|
export const appRoutes = [
|
||||||
@@ -65,4 +72,8 @@ export const appRoutes = [
|
|||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lab',
|
||||||
|
element: <EffectLab />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user