feat: enhance skills section with GSAP animations and dynamic random items

This commit is contained in:
grtsinry43 2025-05-07 00:34:56 +08:00
parent b7504d5acf
commit 0b4479beff
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
7 changed files with 180 additions and 180 deletions

View File

@ -140,14 +140,16 @@ export default function HomePage() {
<AboutSection/>
{/* Skills Section with animation */}
<SkillsSection/>
{/*<SkillsSection/>*/}
<GsapSkillsTree/>
<motion.div>
<GsapSkillsTree/>
</motion.div>
<GsapProjectsShowcase/>
{/* Projects Section with enhanced visuals */}
<ProjectsSection/>
{/*<ProjectsSection/>*/}
<LampContainer className="rounded-none">
<motion.h1
@ -159,7 +161,7 @@ export default function HomePage() {
ease: "easeInOut",
}}
className="mt-8 bg-gradient-to-br from-slate-300 to-slate-500 py-4
font-bold bg-clip-text text-center text-4xl tracking-tight text-transparent md:text-6xl"
font-bold bg-clip-text text-center text-3xl tracking-tight text-transparent md:text-5xl"
>
<p className="mt-4">

View File

@ -8,6 +8,11 @@ import {TextPlugin} from "gsap/TextPlugin"
import {Github, Mail, ExternalLink, Code, Heart, Coffee} from "lucide-react"
import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes"
import dynamic from "next/dynamic";
const RandomItems = dynamic(() => (import('@/components/ui/random-items')), {
ssr: false,
});
export default function GsapPersonalIntro() {
const containerRef = useRef<HTMLDivElement>(null)
@ -15,6 +20,16 @@ export default function GsapPersonalIntro() {
const imageRef = useRef<HTMLDivElement>(null)
const {theme, resolvedTheme} = useTheme()
const [isDark, setIsDark] = useState<boolean>(false)
useEffect(() => {
if (resolvedTheme === "dark") {
setIsDark(true)
} else {
setIsDark(false)
}
}, [resolvedTheme]);
const {scrollYProgress} = useScroll({
target: containerRef,
offset: ["start start", "end start"],
@ -100,7 +115,7 @@ export default function GsapPersonalIntro() {
// 确保清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
}, [theme, resolvedTheme]);
}, [theme, resolvedTheme, isDark]);
const words = ["全栈开发者", "设计者", "创造者"]
const [currentWord, setCurrentWord] = useState(0)
@ -217,10 +232,10 @@ export default function GsapPersonalIntro() {
<img
src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
alt="grtsinry43"
style={resolvedTheme === "dark" ? {filter: "brightness(0.8)"} : {}}
style={isDark ? {filter: "brightness(0.8)"} : {}}
className="w-full h-auto"
/>
{resolvedTheme === "dark" && (
{isDark && (
<div
className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/>
)}
@ -236,17 +251,7 @@ export default function GsapPersonalIntro() {
className="shape absolute -bottom-8 -left-8 w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-green-500 shadow-lg"/>
<div
className="shape absolute top-1/2 -right-12 w-10 h-10 rounded-md bg-gradient-to-br from-amber-500 to-orange-500 rotate-45 shadow-lg"/>
{[...Array(10)].map((_, i) => (
<div
key={i}
className="particle absolute w-3 h-3 rounded-full bg-primary/30"
style={{
top: `${20 + Math.random() * 60}%`,
left: `${Math.random() * 100}%`,
opacity: 0.3 + Math.random() * 0.7,
}}
/>
))}
<RandomItems/>
</div>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10">

View File

@ -67,7 +67,6 @@ export default function PersonalitySection() {
return (
<section id="personality" ref={containerRef} className="relative min-h-screen py-24 md:py-32 overflow-hidden">
<div className="absolute inset-0 bg-neutral-50 dark:bg-neutral-950 z-0"/>
{/* Background elements */}
<div className="absolute inset-0 z-0 overflow-hidden">

View File

@ -321,7 +321,8 @@ export default function GsapProjectsShowcase() {
<div className="project-links flex gap-4">
{project.githubUrl && (
<Button asChild variant="outline" size="sm">
<Button asChild variant="outline" size="sm"
className="bg-primary/10 text-primary border-primary/20">
<a
href={project.githubUrl}
target="_blank"
@ -334,7 +335,7 @@ export default function GsapProjectsShowcase() {
</Button>
)}
{project.liveUrl && (
<Button asChild size="sm">
<Button asChild size="sm" variant="ghost">
<a
href={project.liveUrl}
target="_blank"

View File

@ -326,34 +326,6 @@ export default function GsapRhythmGamesSection() {
</motion.p>
{/* 图标展示 */}
<div className="flex flex-wrap justify-center gap-8 mb-16">
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-blue-500/10">
<Music className="h-8 w-8 text-blue-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-purple-500/10">
<Gamepad2 className="h-8 w-8 text-purple-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-green-500/10">
<Star className="h-8 w-8 text-green-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="icon-item flex flex-col items-center gap-2">
<div className="p-4 rounded-full bg-amber-500/10">
<Trophy className="h-8 w-8 text-amber-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
</div>
{/* 交互式音符动画画布 */}
<div className="relative h-[300px] mb-16">
<canvas ref={canvasRef} className="w-full h-full"/>

View File

@ -8,7 +8,7 @@ export default function GsapSkillsTree() {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const {theme} = useTheme()
const [activeSkill, setActiveSkill] = useState<string | null>(null)
const [activeSkill, setActiveSkill] = useState<string | null>(null) // 状态已声明,但更新逻辑未在原始代码中提供
const [isInView, setIsInView] = useState(false)
// 技能数据
@ -44,11 +44,12 @@ export default function GsapSkillsTree() {
]
useEffect(() => {
// 检测元素是否在视口中
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
// 可选:确保只触发一次
if (containerRef.current) observer.unobserve(containerRef.current);
}
},
{threshold: 0.2},
@ -66,172 +67,170 @@ export default function GsapSkillsTree() {
}, [])
useEffect(() => {
if (!isInView) return
if (!isInView) return;
// 动态导入GSAP以避免SSR问题
const loadGsap = async () => {
let animationFrameId: number;
let autoRotateTimeout: NodeJS.Timeout;
let resizeListener: () => void;
let mouseMoveListener: (e: MouseEvent) => void;
const loadGsapAndAnimate = async () => {
try {
const gsapModule = await import("gsap")
const ScrollTriggerModule = await import("gsap/ScrollTrigger")
const gsap = gsapModule.default
const ScrollTrigger = ScrollTriggerModule.default
// 注册ScrollTrigger插件
gsap.registerPlugin(ScrollTrigger)
if (!containerRef.current || !canvasRef.current) return
const container = containerRef.current
if (!canvasRef.current) return;
const canvas = canvasRef.current
const ctx = canvas.getContext("2d")
if (!ctx) return
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
let cssWidth = 0;
let cssHeight = 0;
// 设置canvas尺寸
const setCanvasSize = () => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * window.devicePixelRatio
canvas.height = rect.height * window.devicePixelRatio
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
}
const rect = canvas.getBoundingClientRect();
cssWidth = rect.width;
cssHeight = rect.height;
setCanvasSize()
window.addEventListener("resize", setCanvasSize)
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
// 创建3D技能球体
const skillSphereRadius = Math.min(canvas.width, canvas.height) * 0.3
const center = {x: canvas.width / 2, y: canvas.height / 2}
ctx.resetTransform();
ctx.scale(dpr, dpr);
};
setCanvasSize(); // Initial size
resizeListener = setCanvasSize; // Store for cleanup
window.addEventListener("resize", resizeListener);
const modelSpaceRadius = 100; // 抽象的模型空间半径
// 为每个技能分配3D位置
const skillNodes = skills.map((skill, index) => {
const phi = Math.acos(-1 + (2 * index) / skills.length)
const theta = Math.sqrt(skills.length * Math.PI) * phi
const phi = Math.acos(-1 + (2 * index) / skills.length);
const theta = Math.sqrt(skills.length * Math.PI) * phi;
return {
...skill,
x3d: skillSphereRadius * Math.cos(theta) * Math.sin(phi),
y3d: skillSphereRadius * Math.sin(theta) * Math.sin(phi),
z3d: skillSphereRadius * Math.cos(phi),
x3d: modelSpaceRadius * Math.cos(theta) * Math.sin(phi),
y3d: modelSpaceRadius * Math.sin(theta) * Math.sin(phi),
z3d: modelSpaceRadius * Math.cos(phi),
x2d: 0,
y2d: 0,
scale: 0,
opacity: 0,
}
})
};
});
// 动画参数
const rotation = {x: 0, y: 0}
const targetRotation = {x: 0, y: 0}
let autoRotate = true
let mouseX = 0
let mouseY = 0
const rotation = {x: 0, y: 0};
const targetRotation = {x: 0, y: 0};
let autoRotate = true;
let mouseX = 0;
let mouseY = 0;
// 鼠标移动事件
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect()
mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2
mouseMoveListener = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return; // Avoid division by zero if canvas not rendered
mouseX = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
mouseY = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
if (Math.abs(mouseX) > 0.1 || Math.abs(mouseY) > 0.1) {
autoRotate = false
targetRotation.x = mouseY * 0.5
targetRotation.y = mouseX * 0.5
// 5秒后恢复自动旋转
clearTimeout(autoRotateTimeout)
if (Math.abs(mouseX) > 0.05 || Math.abs(mouseY) > 0.05) { // Reduced sensitivity
autoRotate = false;
targetRotation.x = mouseY * 0.3; // Reduced rotation speed
targetRotation.y = mouseX * 0.3; // Reduced rotation speed
clearTimeout(autoRotateTimeout);
autoRotateTimeout = setTimeout(() => {
autoRotate = true
}, 5000)
autoRotate = true;
}, 7000); // Longer timeout
}
}
};
canvas.addEventListener("mousemove", mouseMoveListener);
let autoRotateTimeout: NodeJS.Timeout
canvas.addEventListener("mousemove", handleMouseMove)
// 绘制函数
const draw = () => {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.clearRect(0, 0, cssWidth, cssHeight); // 使用CSS尺寸清空
// 更新旋转
if (autoRotate) {
targetRotation.y += 0.003
targetRotation.y += 0.002; // 自动旋转速度
}
rotation.x += (targetRotation.x - rotation.x) * 0.05; // 平滑过渡
rotation.y += (targetRotation.y - rotation.y) * 0.05; // 平滑过渡
rotation.x += (targetRotation.x - rotation.x) * 0.05
rotation.y += (targetRotation.y - rotation.y) * 0.05
const cosX = Math.cos(rotation.x);
const sinX = Math.sin(rotation.x);
const cosY = Math.cos(rotation.y);
const sinY = Math.sin(rotation.y);
// 计算3D旋转
const cosX = Math.cos(rotation.x)
const sinX = Math.sin(rotation.x)
const cosY = Math.cos(rotation.y)
const sinY = Math.sin(rotation.y)
const currentCssCenter = {x: cssWidth / 2, y: cssHeight / 2};
const cssSphereDisplayRadius = Math.min(cssWidth, cssHeight) * 0.35; // 球体在屏幕上的显示半径
// 更新每个技能节点的位置
skillNodes.forEach((node) => {
// 应用旋转变换
const x = node.x3d
const y = node.y3d * cosX - node.z3d * sinX
const z = node.y3d * sinX + node.z3d * cosX
// 3D Rotation
const y_rotated = node.y3d * cosX - node.z3d * sinX;
const z_intermediate = node.y3d * sinX + node.z3d * cosX;
const x_final_model = node.x3d * cosY - z_intermediate * sinY;
const z_final_model = node.x3d * sinY + z_intermediate * cosY;
const y_final_model = y_rotated;
const x2 = x * cosY - z * sinY
const z2 = x * sinY + z * cosY
// Perspective scale and opacity
// z_final_model / modelSpaceRadius 将z值归一化到大约[-1, 1]范围
node.scale = (z_final_model / modelSpaceRadius + 2.5) / 3.5; // 调整分母和加值改变透视和大小范围
node.opacity = Math.max(0, (node.scale - 0.5) * 2); // 调整阈值使更多/更少的点可见
// 投影到2D
const scale = (z2 / skillSphereRadius + 2) / 3
node.x2d = center.x + x2 / window.devicePixelRatio
node.y2d = center.y + y / window.devicePixelRatio
node.scale = scale
node.opacity = (scale - 0.6) * 2.5
// 绘制技能节点
if (node.opacity > 0) {
ctx.save()
ctx.globalAlpha = node.opacity
// Project to 2D CSS coordinates
// (coord / modelRadius) * displayRadius: 将模型坐标按比例映射到显示半径
// * node.scale: 可选,应用透视缩放进一步影响位置(使远处的点更靠近中心)
node.x2d = currentCssCenter.x + (x_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale;
node.y2d = currentCssCenter.y + (y_final_model / modelSpaceRadius) * cssSphereDisplayRadius * node.scale;
// 节点大小基于技能等级
const nodeSize = (10 + node.level / 10) * scale
ctx.save();
ctx.globalAlpha = node.opacity;
// 绘制节点
ctx.fillStyle = node.color
ctx.beginPath()
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2)
ctx.fill()
const nodeSize = (8 + node.level / 15) * node.scale; // 调整基础大小和等级影响因子
// 绘制技能名称
ctx.font = `${12 * scale}px Arial`
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(node.name, node.x2d, node.y2d)
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2);
ctx.fill();
ctx.restore()
// 仅当节点足够大时显示文字,避免拥挤
if (nodeSize > 5) {
ctx.font = `${Math.min(14, nodeSize * 1) * node.scale}px Arial`; // 字体大小也受透视影响
ctx.fillStyle = theme === "dark" ? "#ffffff" : "#000000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(node.name, node.x2d, node.y2d + nodeSize + 5 * node.scale); // 文字略微向下偏移
}
ctx.restore();
}
})
});
animationFrameId = requestAnimationFrame(draw);
};
requestAnimationFrame(draw)
}
draw();
// 开始动画
draw()
// 清理函数
return () => {
window.removeEventListener("resize", setCanvasSize)
canvas.removeEventListener("mousemove", handleMouseMove)
clearTimeout(autoRotateTimeout)
}
} catch (error) {
console.error("Failed to load GSAP:", error)
console.error("Failed to load GSAP or run animation:", error);
}
}
};
loadGsap()
}, [isInView, theme])
loadGsapAndAnimate();
return () => {
cancelAnimationFrame(animationFrameId);
clearTimeout(autoRotateTimeout);
if (resizeListener) window.removeEventListener("resize", resizeListener);
if (canvasRef.current && mouseMoveListener) {
canvasRef.current.removeEventListener("mousemove", mouseMoveListener);
}
};
}, [isInView, theme, skills]); // skills 作为依赖,尽管在此处是常量
return (
<section id="skills" ref={containerRef} className="relative py-24 md:py-32 overflow-hidden bg-muted/30">
{/* 背景效果 */}
<div className="absolute inset-0 -z-10">
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/5 via-transparent to-transparent"/>
@ -270,19 +269,17 @@ export default function GsapSkillsTree() {
-
</motion.p>
{/* 3D技能球体 */}
<div className="relative w-full mb-16">
{/* 3D技能球体容器 - 应用居中和宽高比 */}
<div className="relative w-full max-w-xl md:max-w-2xl aspect-square mx-auto mb-16">
<canvas ref={canvasRef} className="w-full h-full cursor-move" style={{touchAction: "none"}}/>
{/* 技能说明 */}
<div
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-card/80 backdrop-blur-sm p-4 rounded-xl border border-border/50 shadow-lg">
<p className="text-sm text-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p>
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-card/80 backdrop-blur-sm p-3 rounded-lg border border-border/50 shadow-lg text-xs md:text-sm">
<p className="text-center">{activeSkill ? `${activeSkill}` : "移动鼠标探索技能球体"}</p>
</div>
</div>
{/* 技能分组说明 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{skillGroups.map((group, idx) => (
<motion.div
key={group.id}
@ -290,31 +287,32 @@ export default function GsapSkillsTree() {
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.6, delay: 0.2 + idx * 0.1}}
viewport={{once: true, amount: 0.3}}
className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/50 shadow-lg"
className="bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/20 shadow-sm border-l-sky-200"
>
<div className={`h-2 w-16 rounded-full bg-gradient-to-r ${group.color} mb-4`}></div>
<h4 className="text-xl font-bold mb-3">{group.name}</h4>
<h4 className="text-xl font-semibold mb-3 text-card-foreground">{group.name}</h4>
<div className="space-y-2">
{skills
.filter((skill) => skill.group === group.id)
.slice(0, 4)
.slice(0, 4) // 显示前4个
.map((skill) => (
<div key={skill.name} className="flex items-center justify-between">
<span className="text-sm">{skill.name}</span>
<span className="text-sm text-muted-foreground">{skill.name}</span>
<div className="flex items-center">
<div className="h-1.5 w-24 bg-muted rounded-full overflow-hidden">
<div
className="h-1.5 w-20 md:w-24 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r ${group.color}`}
style={{width: `${skill.level}%`}}
/>
</div>
<span
className="text-xs text-muted-foreground ml-2">{skill.level}%</span>
className="text-xs text-muted-foreground/80 ml-2 w-8 text-right">{skill.level}%</span>
</div>
</div>
))}
{skills.filter((skill) => skill.group === group.id).length > 4 && (
<div className="text-xs text-right text-muted-foreground">
<div className="text-xs text-right text-muted-foreground/70 mt-2">
+{skills.filter((skill) => skill.group === group.id).length - 4}
</div>
)}

View File

@ -0,0 +1,23 @@
"use client"
import React from 'react';
const RandomItems = () => {
return (
<>
{[...Array(10)].map((_, i) => (
<div
key={i}
className="particle absolute w-3 h-3 rounded-full bg-primary/30"
style={{
top: `${20 + Math.random() * 60}%`,
left: `${Math.random() * 100}%`,
opacity: 0.3 + Math.random() * 0.7,
}}
/>
))}
</>
);
};
export default RandomItems;