581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import * as THREE from 'three';
|
|
import { gsap } from 'gsap';
|
|
|
|
interface Particle {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
originalX: number;
|
|
originalY: number;
|
|
originalColor: THREE.Color;
|
|
delay: number;
|
|
}
|
|
|
|
const createErrorParticles = (width: number, height: number) => {
|
|
const particles: Particle[] = [];
|
|
const positionArray: number[] = [];
|
|
const colorArray: number[] = [];
|
|
|
|
const errorColor = new THREE.Color(0.8, 0, 0); // 更深的红色
|
|
|
|
// X 形状的两条线
|
|
const lines = [
|
|
// 左上到右下的线
|
|
{ start: [-1, 1], end: [1, -1] },
|
|
// 右上到左下的线
|
|
{ start: [1, 1], end: [-1, -1] }
|
|
];
|
|
|
|
// 每条线上的粒子数量
|
|
const particlesPerLine = 50;
|
|
|
|
lines.forEach(line => {
|
|
for (let i = 0; i < particlesPerLine; i++) {
|
|
const t = i / (particlesPerLine - 1);
|
|
const x = line.start[0] + (line.end[0] - line.start[0]) * t;
|
|
const y = line.start[1] + (line.end[1] - line.start[1]) * t;
|
|
|
|
// 添加一些随机偏移
|
|
const randomOffset = 0.1;
|
|
const randomX = x + (Math.random() - 0.5) * randomOffset;
|
|
const randomY = y + (Math.random() - 0.5) * randomOffset;
|
|
|
|
// 缩放到适合容器的大小
|
|
const scaledX = randomX * (width * 0.3);
|
|
const scaledY = randomY * (height * 0.3);
|
|
|
|
particles.push({
|
|
x: scaledX,
|
|
y: scaledY,
|
|
z: 0,
|
|
originalX: scaledX,
|
|
originalY: scaledY,
|
|
originalColor: errorColor,
|
|
delay: 0
|
|
});
|
|
|
|
// 随机初始位置
|
|
positionArray.push(
|
|
(Math.random() - 0.5) * width * 2,
|
|
(Math.random() - 0.5) * height * 2,
|
|
(Math.random() - 0.5) * 100
|
|
);
|
|
|
|
// 随机初始颜色
|
|
colorArray.push(errorColor.r, errorColor.g, errorColor.b);
|
|
}
|
|
});
|
|
|
|
return { particles, positionArray, colorArray };
|
|
};
|
|
|
|
// 添加笑脸粒子生成函数
|
|
const createSmileParticles = (width: number, height: number) => {
|
|
const particles: Particle[] = [];
|
|
const positionArray: number[] = [];
|
|
const colorArray: number[] = [];
|
|
|
|
// 调整笑脸参数
|
|
const radius = Math.min(width, height) * 0.35; // 脸部大小
|
|
const particlesCount = 400; // 轮廓粒子数量
|
|
|
|
// 修改颜色为更深的金色
|
|
const particleColor = new THREE.Color(0.8, 0.6, 0); // 更深的金色
|
|
|
|
// 创建圆形脸部轮廓
|
|
for (let i = 0; i < particlesCount / 2; i++) {
|
|
const angle = (i / (particlesCount / 2)) * Math.PI * 2;
|
|
const x = Math.cos(angle) * radius;
|
|
const y = Math.sin(angle) * radius;
|
|
|
|
particles.push({
|
|
x, y, z: 0,
|
|
originalX: x,
|
|
originalY: y,
|
|
originalColor: particleColor,
|
|
delay: 0
|
|
});
|
|
|
|
positionArray.push(
|
|
(Math.random() - 0.5) * width * 2,
|
|
(Math.random() - 0.5) * height * 2,
|
|
(Math.random() - 0.5) * 100
|
|
);
|
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
|
}
|
|
|
|
// 眼睛参数
|
|
const eyeOffset = radius * 0.2; // 眼睛水平间距
|
|
const eyeY = radius * 0.2; // 眼睛垂直位置
|
|
const eyeSize = radius * 0.08; // 眼睛大小
|
|
|
|
// 创建实心眼睛
|
|
[-1, 1].forEach(side => {
|
|
// 创建密集的点来填充眼睛
|
|
for (let i = 0; i < 30; i++) {
|
|
const r = Math.random() * eyeSize; // 随机半径
|
|
const angle = Math.random() * Math.PI * 2; // 随机角度
|
|
const x = side * eyeOffset + Math.cos(angle) * r;
|
|
const y = eyeY + Math.sin(angle) * r;
|
|
|
|
particles.push({
|
|
x, y, z: 0,
|
|
originalX: x,
|
|
originalY: y,
|
|
originalColor: particleColor,
|
|
delay: 0
|
|
});
|
|
|
|
positionArray.push(
|
|
(Math.random() - 0.5) * width * 2,
|
|
(Math.random() - 0.5) * height * 2,
|
|
(Math.random() - 0.5) * 100
|
|
);
|
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
|
}
|
|
});
|
|
|
|
// 嘴巴参数
|
|
const smileWidth = radius * 0.5; // 嘴巴宽度
|
|
const smileY = -radius * 0.3; // 将嘴巴位置向下移动更多
|
|
const smilePoints = 40; // 嘴巴粒子数量
|
|
|
|
// 创建微笑
|
|
for (let i = 0; i < smilePoints; i++) {
|
|
const t = i / (smilePoints - 1);
|
|
const x = (t * 2 - 1) * smileWidth;
|
|
|
|
// 简单的抛物线,向上弯曲的笑脸
|
|
const y = smileY + (Math.pow(x / smileWidth, 2) * radius * 0.2);
|
|
|
|
particles.push({
|
|
x, y, z: 0,
|
|
originalX: x,
|
|
originalY: y,
|
|
originalColor: particleColor,
|
|
delay: 0
|
|
});
|
|
|
|
positionArray.push(
|
|
(Math.random() - 0.5) * width * 2,
|
|
(Math.random() - 0.5) * height * 2,
|
|
(Math.random() - 0.5) * 100
|
|
);
|
|
colorArray.push(particleColor.r, particleColor.g, particleColor.b);
|
|
}
|
|
|
|
return { particles, positionArray, colorArray };
|
|
};
|
|
|
|
// 在文件开头添加新的 helper 函数
|
|
const easeOutCubic = (t: number) => {
|
|
return 1 - Math.pow(1 - t, 3);
|
|
};
|
|
|
|
const customEase = (t: number) => {
|
|
return t < 0.5
|
|
? 4 * t * t * t
|
|
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
};
|
|
|
|
export const ParticleImage = ({ src, onLoad, onError }: {
|
|
src?: string;
|
|
onLoad?: () => void;
|
|
onError?: () => void;
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const sceneRef = useRef<THREE.Scene>();
|
|
const cameraRef = useRef<THREE.OrthographicCamera>();
|
|
const rendererRef = useRef<THREE.WebGLRenderer>();
|
|
const animationFrameRef = useRef<number>();
|
|
|
|
// 添加 resize 处理函数
|
|
const handleResize = useCallback(() => {
|
|
if (!containerRef.current || !cameraRef.current || !rendererRef.current) return;
|
|
|
|
const width = containerRef.current.offsetWidth;
|
|
const height = containerRef.current.offsetHeight;
|
|
|
|
const camera = cameraRef.current;
|
|
camera.left = width / -2.1;
|
|
camera.right = width / 2.1;
|
|
camera.top = height / 2.1;
|
|
camera.bottom = height / -2.1;
|
|
camera.updateProjectionMatrix();
|
|
|
|
rendererRef.current.setSize(width, height);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
console.log('Current src:', src);
|
|
|
|
const width = containerRef.current.offsetWidth;
|
|
const height = containerRef.current.offsetHeight;
|
|
|
|
const scene = new THREE.Scene();
|
|
sceneRef.current = scene;
|
|
|
|
const camera = new THREE.OrthographicCamera(
|
|
width / -1.5, // 扩大视野范围,从 -2 改为 -1.5
|
|
width / 1.5, // 扩大视野范围,从 2 改为 1.5
|
|
height / 1.5, // 扩大视野范围
|
|
height / -1.5, // 扩大视野范围
|
|
1,
|
|
1000
|
|
);
|
|
camera.position.z = 100;
|
|
cameraRef.current = camera;
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
alpha: true,
|
|
antialias: true
|
|
});
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.setSize(width, height);
|
|
rendererRef.current = renderer;
|
|
containerRef.current.appendChild(renderer.domElement);
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
const material = new THREE.PointsMaterial({
|
|
size: 1.2,
|
|
vertexColors: true,
|
|
transparent: true,
|
|
opacity: 1,
|
|
sizeAttenuation: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
depthTest: false
|
|
});
|
|
|
|
// 检查是否应该显示笑脸
|
|
if (src === '') {
|
|
console.log('Showing smile animation');
|
|
const { particles, positionArray, colorArray } = createSmileParticles(width, height);
|
|
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
|
|
|
const points = new THREE.Points(geometry, material);
|
|
scene.add(points);
|
|
|
|
const positionAttribute = geometry.attributes.position;
|
|
|
|
particles.forEach((particle, i) => {
|
|
const i3 = i * 3;
|
|
gsap.to(positionAttribute.array, {
|
|
duration: 1,
|
|
delay: Math.random() * 0.3,
|
|
[i3]: particle.originalX,
|
|
[i3 + 1]: particle.originalY,
|
|
[i3 + 2]: 0,
|
|
ease: "back.out(1.7)",
|
|
onUpdate: () => {
|
|
positionAttribute.needsUpdate = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
// 启动动画循环
|
|
const animate = () => {
|
|
if (renderer && scene && camera) {
|
|
renderer.render(scene, camera);
|
|
}
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
};
|
|
animate();
|
|
|
|
return;
|
|
}
|
|
|
|
// 创建错误动画函数
|
|
const showErrorAnimation = () => {
|
|
if (!scene) return;
|
|
|
|
const { particles, positionArray, colorArray } = createErrorParticles(width, height);
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
|
|
|
const points = new THREE.Points(geometry, material);
|
|
scene.clear(); // 清除现有内容
|
|
scene.add(points);
|
|
|
|
const positionAttribute = geometry.attributes.position;
|
|
|
|
particles.forEach((particle, i) => {
|
|
const i3 = i * 3;
|
|
gsap.to(positionAttribute.array, {
|
|
duration: 0.6,
|
|
delay: Math.random() * 0.2,
|
|
[i3]: particle.originalX,
|
|
[i3 + 1]: particle.originalY,
|
|
[i3 + 2]: 0,
|
|
ease: "back.out(1.7)",
|
|
onUpdate: () => {
|
|
positionAttribute.needsUpdate = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
onError?.();
|
|
};
|
|
|
|
// 加载图片
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
showErrorAnimation();
|
|
}, 5000); // 5秒超时
|
|
|
|
img.onload = () => {
|
|
clearTimeout(timeoutId);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (ctx) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
// 计算图片绘制尺寸和位置,确保不会超出容器
|
|
const imgAspect = img.width / img.height;
|
|
const containerAspect = width / height;
|
|
|
|
let drawWidth = width;
|
|
let drawHeight = height;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
|
|
if (imgAspect > containerAspect) {
|
|
// 图片较宽,以容器宽度为准,确保高度不超出
|
|
drawWidth = width;
|
|
drawHeight = width / imgAspect;
|
|
offsetY = (height - drawHeight) / 2;
|
|
} else {
|
|
// 图片较高,以容器高度为准,确保宽度不超出
|
|
drawHeight = height;
|
|
drawWidth = height * imgAspect;
|
|
offsetX = (width - drawWidth) / 2;
|
|
}
|
|
|
|
// 绘制图片
|
|
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
|
|
const particles: Particle[] = [];
|
|
const positionArray = [];
|
|
const colorArray = [];
|
|
const samplingGap = Math.ceil(Math.max(width, height) / 100); // 动态采样间隔,确保粒子数量适中
|
|
|
|
// 采样图片像素
|
|
for (let y = 0; y < height; y += samplingGap) {
|
|
for (let x = 0; x < width; x += samplingGap) {
|
|
const i = (y * width + x) * 4;
|
|
const r = imageData.data[i] / 255;
|
|
const g = imageData.data[i + 1] / 255;
|
|
const b = imageData.data[i + 2] / 255;
|
|
const a = imageData.data[i + 3] / 255;
|
|
|
|
if (a > 0.3) {
|
|
// 计算距离中心的距离,用于动画延迟
|
|
const distanceToCenter = Math.sqrt(
|
|
Math.pow(x - width/2, 2) +
|
|
Math.pow(y - height/2, 2)
|
|
);
|
|
const maxDistance = Math.sqrt(Math.pow(width/2, 2) + Math.pow(height/2, 2));
|
|
const normalizedDistance = distanceToCenter / maxDistance;
|
|
|
|
const px = x - width / 2;
|
|
const py = height / 2 - y;
|
|
|
|
particles.push({
|
|
x: px,
|
|
y: py,
|
|
z: 0,
|
|
originalX: px,
|
|
originalY: py,
|
|
originalColor: new THREE.Color(r, g, b),
|
|
delay: normalizedDistance * 0.3 // 基于距离的延迟
|
|
});
|
|
|
|
// 随机初始位置(根据距离调整范围)
|
|
const spread = 1 - normalizedDistance * 0.5; // 距离越远,初始扩散越小
|
|
positionArray.push(
|
|
(Math.random() - 0.5) * width * spread,
|
|
(Math.random() - 0.5) * height * spread,
|
|
(Math.random() - 0.5) * 50 * spread
|
|
);
|
|
|
|
colorArray.push(r, g, b);
|
|
}
|
|
}
|
|
}
|
|
|
|
scene.clear();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
|
|
|
const points = new THREE.Points(geometry, material);
|
|
scene.add(points);
|
|
|
|
// 动画
|
|
const positionAttribute = geometry.attributes.position;
|
|
const colorAttribute = geometry.attributes.color;
|
|
|
|
let completedAnimations = 0;
|
|
const totalAnimations = particles.length * 2; // 位置和颜色动画
|
|
|
|
const checkComplete = () => {
|
|
completedAnimations++;
|
|
if (completedAnimations === totalAnimations) {
|
|
onLoad?.(); // 所有画完成后调用 onLoad
|
|
}
|
|
};
|
|
|
|
particles.forEach((particle, i) => {
|
|
const i3 = i * 3;
|
|
|
|
// 位置动画
|
|
gsap.to(positionAttribute.array, {
|
|
duration: 1.2 + Math.random() * 0.3, // 减少随机性范围
|
|
delay: particle.delay, // 使用基于距离的延迟
|
|
[i3]: particle.originalX,
|
|
[i3 + 1]: particle.originalY,
|
|
[i3 + 2]: 0,
|
|
ease: customEase,
|
|
onUpdate: () => {
|
|
positionAttribute.needsUpdate = true;
|
|
},
|
|
onComplete: checkComplete
|
|
});
|
|
|
|
// 颜色动画
|
|
gsap.to(colorAttribute.array, {
|
|
duration: 1,
|
|
delay: particle.delay + 0.2, // 稍微延迟颜色变化
|
|
[i3]: particle.originalColor.r,
|
|
[i3 + 1]: particle.originalColor.g,
|
|
[i3 + 2]: particle.originalColor.b,
|
|
ease: "power2.inOut",
|
|
onUpdate: () => {
|
|
colorAttribute.needsUpdate = true;
|
|
},
|
|
onComplete: checkComplete
|
|
});
|
|
});
|
|
|
|
// 修改动画序列部分
|
|
const timeline = gsap.timeline({
|
|
defaults: { ease: "power2.inOut" }
|
|
});
|
|
|
|
const imgElement = document.querySelector(`img[src="${src}"]`) as HTMLImageElement;
|
|
if (imgElement) {
|
|
// 设置初始状态
|
|
gsap.set(imgElement, { opacity: 0 });
|
|
|
|
timeline
|
|
.to(imgElement, {
|
|
opacity: 1,
|
|
duration: 0.8,
|
|
delay: 1.6
|
|
})
|
|
.to(material, {
|
|
opacity: 0,
|
|
duration: 0.8
|
|
}, "-=0.6"); // 提前开始消失
|
|
}
|
|
}
|
|
};
|
|
|
|
img.onerror = () => {
|
|
clearTimeout(timeoutId);
|
|
showErrorAnimation();
|
|
};
|
|
|
|
img.src = src || '';
|
|
|
|
// 动画循环
|
|
const animate = () => {
|
|
if (renderer && scene && camera) {
|
|
renderer.render(scene, camera);
|
|
}
|
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
};
|
|
animate();
|
|
|
|
// 添加 resize 监听
|
|
const resizeObserver = new ResizeObserver(handleResize);
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
// 添加窗口 resize 监听
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
}
|
|
if (renderer && containerRef.current) {
|
|
containerRef.current.removeChild(renderer.domElement);
|
|
renderer.dispose();
|
|
}
|
|
// 清除所有 GSAP 动画
|
|
gsap.killTweensOf(geometry.attributes.position?.array);
|
|
|
|
// 移除 resize 监听
|
|
if (containerRef.current) {
|
|
resizeObserver.unobserve(containerRef.current);
|
|
}
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, [src, onError, handleResize]);
|
|
|
|
return <div ref={containerRef} className="w-full h-full" />;
|
|
};
|
|
|
|
// 图片加载组件
|
|
export const ImageLoader = ({ src, alt, className }: {
|
|
src?: string;
|
|
alt: string;
|
|
className: string;
|
|
}) => {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [hasError, setHasError] = useState(false);
|
|
|
|
return (
|
|
<div className="relative w-[100px] md:w-[140px] h-[100px] md:h-[140px] shrink-0 overflow-hidden">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(10,37,77)] via-[rgb(8,27,57)] to-[rgb(2,8,23)] rounded-lg overflow-hidden">
|
|
<ParticleImage
|
|
src={src}
|
|
onLoad={() => setIsLoading(false)}
|
|
onError={() => {
|
|
setIsLoading(false);
|
|
setHasError(true);
|
|
}}
|
|
/>
|
|
</div>
|
|
{!hasError && (
|
|
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
className={`
|
|
w-full h-full object-cover
|
|
transition-opacity duration-1000
|
|
${className}
|
|
${isLoading ? 'opacity-0' : 'opacity-100'}
|
|
`}
|
|
style={{
|
|
visibility: isLoading ? 'hidden' : 'visible',
|
|
objectFit: 'cover',
|
|
objectPosition: 'center'
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |