feat: add GSAP animations and new sections for personal intro, skills tree, photography, and project showcase

This commit is contained in:
grtsinry43 2025-05-06 11:08:39 +08:00
parent 29f8921544
commit b7504d5acf
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
7 changed files with 1852 additions and 6 deletions

View File

@ -18,6 +18,11 @@ import CreativeCanvas from "@/components/creative-canvas"
import PersonalitySection from "@/components/sections/personality-section";
import {LampContainer} from "@/components/ui/lamp-container";
import CardSection from "@/components/sections/card-section";
import GsapSkillsTree from "@/components/sections/skill-tree";
import GsapProjectsShowcase from "@/components/sections/project-showcase";
import GsapPersonalIntro from "@/components/sections/personal-intro";
import GsapPhotographySection from "@/components/sections/photography-section";
import GsapRhythmGamesSection from "@/components/sections/rhythm-games-section";
export default function HomePage() {
const {theme} = useTheme()
@ -58,7 +63,7 @@ export default function HomePage() {
}, [mouseX, mouseY])
return (
<div className="min-h-screen bg-background overflow-hidden">
<div className="">
{/* Progress indicator */}
<motion.div className="fixed top-0 left-0 right-0 h-[1px] bg-primary z-[60] origin-left" style={{scaleX}}/>
@ -99,16 +104,16 @@ export default function HomePage() {
</div>
</header>
<main className="relative">
<main className="relative overflow-hidden">
<div className="relative w-full h-screen overflow-hidden">
<div className="relative w-full h-screen overflow-hidden flex items-center justify-center">
{/* Creative background canvas */}
<div className="absolute inset-0">
<CreativeCanvas mouseX={mouseXSpring} mouseY={mouseYSpring}/>
</div>
{/* Hero Section with 3D animation */}
<HeroSection/>
{/* Hero Section with GSAP animation */}
<GsapPersonalIntro/>
</div>
@ -137,6 +142,10 @@ export default function HomePage() {
{/* Skills Section with animation */}
<SkillsSection/>
<GsapSkillsTree/>
<GsapProjectsShowcase/>
{/* Projects Section with enhanced visuals */}
<ProjectsSection/>
@ -164,6 +173,12 @@ export default function HomePage() {
{/* Personality Section with animation */}
<PersonalitySection/>
{/*<GsapPersonalIntro/>*/}
<GsapPhotographySection/>
<GsapRhythmGamesSection/>
{/* Contact Section with glass morphism */}
<ContactSection/>
</main>

View File

@ -0,0 +1,261 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {AnimatePresence, motion, useScroll, useTransform} from "framer-motion"
import gsap from "gsap"
import {ScrollTrigger} from "gsap/ScrollTrigger"
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"
export default function GsapPersonalIntro() {
const containerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const imageRef = useRef<HTMLDivElement>(null)
const {theme, resolvedTheme} = useTheme()
const {scrollYProgress} = useScroll({
target: containerRef,
offset: ["start start", "end start"],
})
const y = useTransform(scrollYProgress, [0, 1], ["0%", "40%"])
const scale = useTransform(scrollYProgress, [0, 1], [1, 0.9])
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0])
useEffect(() => {
gsap.registerPlugin(ScrollTrigger, TextPlugin);
if (!containerRef.current) return;
const container = containerRef.current;
// 清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
const tl = gsap.timeline({
scrollTrigger: {
trigger: container,
start: "top 80%",
end: "bottom 20%",
toggleActions: "play none none reverse",
},
});
if (textRef.current) {
const headings = textRef.current.querySelectorAll("h3, h4");
const paragraphs = textRef.current.querySelectorAll("p");
const buttons = textRef.current.querySelectorAll("button, a");
const icons = textRef.current.querySelectorAll(".icon-item");
tl.from(headings, {opacity: 0, y: 50, duration: 0.8, stagger: 0.2, ease: "power3.out"})
.from(paragraphs, {opacity: 0, y: 30, duration: 0.6, stagger: 0.2, ease: "power2.out"}, "-=0.4")
.from(buttons, {
opacity: 0,
y: 20,
scale: 0.9,
duration: 0.5,
stagger: 0.1,
ease: "back.out(1.7)"
}, "-=0.3")
.from(icons, {
opacity: 0,
scale: 0,
rotation: -30,
duration: 0.6,
stagger: 0.1,
ease: "back.out(2)"
}, "-=0.4");
}
if (imageRef.current) {
const shapes = imageRef.current.querySelectorAll(".shape");
const particles = imageRef.current.querySelectorAll(".particle");
tl.from(shapes, {
opacity: 0,
scale: 0,
rotation: -60,
transformOrigin: "center",
duration: 0.8,
stagger: 0.1,
ease: "back.out(2)"
}, "-=0.8");
particles.forEach((particle) => {
gsap.to(particle, {
y: "random(-30, 30)",
x: "random(-30, 30)",
rotation: "random(-360, 360)",
duration: "random(3, 6)",
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
});
}
return () => {
// 确保清理所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
}, [theme, resolvedTheme]);
const words = ["全栈开发者", "设计者", "创造者"]
const [currentWord, setCurrentWord] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCurrentWord((prev) => (prev + 1) % words.length)
}, 2000)
return () => clearInterval(interval)
}, [])
return (
<motion.div
ref={containerRef}
className="relative py-24 md:py-32 overflow-hidden"
style={{y, scale, opacity}}
>
{/* Background elements */}
<div className="absolute inset-0 -z-10">
<div
className="absolute inset-0 bg-gradient-to-b from-background via-background/80 to-background opacity-20"/>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden">
<div className="parallax absolute top-10 left-1/4 w-64 h-64 rounded-full bg-primary/5 blur-3xl"/>
<div
className="parallax absolute bottom-20 right-1/5 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl"/>
<div
className="parallax absolute top-1/3 right-1/4 w-80 h-80 rounded-full bg-emerald-500/5 blur-3xl"/>
</div>
</div>
<div className="container px-4 mx-auto">
<div className="grid grid-cols-1 mt-12 lg:grid-cols-2 lg:mt-0 gap-12 items-center">
<div ref={textRef} className="space-y-8">
<div>
<h3 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
<p className="text-primary text-7xl">grtsinry43</p>
</h3>
<h4 className="text-2xl md:text-3xl font-semibold text-foreground/80">
{" "}
<AnimatePresence mode="wait">
<motion.span
className="inline-block relative text-primary"
animate={{opacity: [0.7, 1, 0.7]}}
key={"key-" + currentWord}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
}}
>
{words[currentWord]}
</motion.span>
</AnimatePresence>
</h4>
</div>
<p className="text-lg text-muted-foreground">
Web Android
</p>
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2 icon-item">
<div className="p-2 rounded-full bg-primary/10">
<Code className="h-5 w-5 text-primary"/>
</div>
<span></span>
</div>
<div className="flex items-center gap-2 icon-item">
<div className="p-2 rounded-full bg-pink-500/10">
<Heart className="h-5 w-5 text-pink-500"/>
</div>
<span></span>
</div>
<div className="flex items-center gap-2 icon-item">
<div className="p-2 rounded-full bg-amber-500/10">
<Coffee className="h-5 w-5 text-amber-500"/>
</div>
<span></span>
</div>
</div>
<div className="flex flex-wrap gap-4">
<Button asChild variant="default" className="rounded-full">
<a
href="https://github.com/grtsinry43"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Github className="h-5 w-5"/>
GitHub
</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<a href="mailto:grtsinry43@outlook.com" className="flex items-center gap-2">
<Mail className="h-5 w-5"/>
</a>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<a
href="https://blog.grtsinry43.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-5 w-5"/>
</a>
</Button>
</div>
</div>
<div ref={imageRef} className="relative">
<div className="relative z-10 mx-auto max-w-md">
<div className="profile-image relative rounded-2xl overflow-hidden">
<img
src="https://dogeoss.grtsinry43.com/img/author-removebg.png"
alt="grtsinry43"
style={resolvedTheme === "dark" ? {filter: "brightness(0.8)"} : {}}
className="w-full h-auto"
/>
{resolvedTheme === "dark" && (
<div
className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent"/>
)}
<div
className="absolute bottom-0 left-0 right-0 p-6 transform translate-y-full hover:translate-y-0 transition-transform duration-300">
<h4 className="text-xl font-bold text-white mb-2">grtsinry43</h4>
<p className="text-white/80"> & </p>
</div>
</div>
<div
className="shape absolute -top-6 -right-6 w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 rotate-12 shadow-lg"/>
<div
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,
}}
/>
))}
</div>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full -z-10">
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3/4 h-3/4 bg-primary/20 rounded-full blur-3xl"/>
</div>
</div>
</div>
</div>
</motion.div>
)
}

View File

@ -0,0 +1,389 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {motion, AnimatePresence} from "framer-motion"
import {Camera, Film, Scissors, ImageIcon, ChevronLeft, ChevronRight} from "lucide-react"
import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes"
export default function GsapPhotographySection() {
const containerRef = useRef<HTMLDivElement>(null)
const {theme} = useTheme()
const [activeIndex, setActiveIndex] = useState(0)
const [isInView, setIsInView] = useState(false)
const [direction, setDirection] = useState(0)
// 摄影作品数据
const photographyItems = [
{
id: 1,
title: "城市风光",
description: "城市建筑与自然光影的完美结合",
image: "/placeholder.svg?height=600&width=800",
tags: ["城市", "建筑", "光影"],
},
{
id: 2,
title: "自然风景",
description: "大自然的壮丽景色与细腻纹理",
image: "/placeholder.svg?height=600&width=800",
tags: ["自然", "风景", "纹理"],
},
{
id: 3,
title: "人像摄影",
description: "捕捉人物神态与情感的瞬间",
image: "/placeholder.svg?height=600&width=800",
tags: ["人像", "情感", "瞬间"],
},
]
// 检测元素是否在视口中
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
}
},
{threshold: 0.2},
)
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => {
if (containerRef.current) {
observer.unobserve(containerRef.current)
}
}
}, [])
// GSAP动画
useEffect(() => {
if (!isInView) return
const loadGsap = 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) return
const container = containerRef.current
const titleElements = container.querySelectorAll(".section-title")
const iconElements = container.querySelectorAll(".icon-item")
// 标题动画
gsap.fromTo(
titleElements,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
stagger: 0.2,
ease: "power3.out",
scrollTrigger: {
trigger: container,
start: "top 70%",
},
},
)
// 图标动画
gsap.fromTo(
iconElements,
{scale: 0.5, opacity: 0},
{
scale: 1,
opacity: 1,
duration: 0.6,
stagger: 0.1,
ease: "back.out(1.7)",
scrollTrigger: {
trigger: container,
start: "top 70%",
},
},
)
// 创建浮动动画
iconElements.forEach((icon, index) => {
gsap.to(icon, {
y: "random(-10, 10)",
duration: 2 + index * 0.2,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
})
})
return () => {
// 清理动画
ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
}
} catch (error) {
console.error("Failed to load GSAP:", error)
}
}
loadGsap()
}, [isInView])
// 切换到下一张幻灯片
const nextSlide = () => {
setDirection(1)
setActiveIndex((prev) => (prev + 1) % photographyItems.length)
}
// 切换到上一张幻灯片
const prevSlide = () => {
setDirection(-1)
setActiveIndex((prev) => (prev - 1 + photographyItems.length) % photographyItems.length)
}
// 自动播放幻灯片
useEffect(() => {
if (!isInView) return
const interval = setInterval(() => {
nextSlide()
}, 5000)
return () => clearInterval(interval)
}, [isInView, activeIndex])
// 幻灯片动画变体
const slideVariants = {
enter: (direction: number) => ({
x: direction > 0 ? "100%" : "-100%",
opacity: 0,
scale: 0.9,
}),
center: {
x: 0,
opacity: 1,
scale: 1,
transition: {
x: {duration: 0.8, ease: [0.16, 1, 0.3, 1]},
opacity: {duration: 0.7},
scale: {duration: 0.7, ease: [0.16, 1, 0.3, 1]},
},
},
exit: (direction: number) => ({
x: direction < 0 ? "100%" : "-100%",
opacity: 0,
scale: 0.9,
transition: {
x: {duration: 0.8, ease: [0.16, 1, 0.3, 1]},
opacity: {duration: 0.7},
scale: {duration: 0.7, ease: [0.16, 1, 0.3, 1]},
},
}),
}
// 内容动画变体
const contentVariants = {
hidden: {opacity: 0, y: 20},
visible: (delay: number) => ({
opacity: 1,
y: 0,
transition: {
delay,
duration: 0.6,
ease: [0.16, 1, 0.3, 1],
},
}),
}
return (
<section id="photography" ref={containerRef} className="relative min-h-screen py-24 md:py-32 overflow-hidden">
{/* 背景效果 */}
<div className="absolute inset-0 -z-10 bg-muted/20">
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-purple-500/5 via-transparent to-transparent"/>
</div>
<div className="container px-4 mx-auto relative z-10">
<motion.div
initial={{opacity: 0, y: 40}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8}}
viewport={{once: true, amount: 0.3}}
className="flex items-center justify-center space-x-2 mb-12"
>
<div className="h-px w-12 bg-primary/60"/>
<h2 className="text-lg font-medium text-primary"></h2>
<div className="h-px w-12 bg-primary/60"/>
</motion.div>
<motion.h3
className="section-title text-3xl md:text-4xl font-bold tracking-tight text-center mb-16"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.2}}
viewport={{once: true, amount: 0.3}}
>
<span className="text-primary"></span>
</motion.h3>
{/* 图标展示 */}
<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-purple-500/10">
<Camera 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-blue-500/10">
<Film 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-green-500/10">
<Scissors 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">
<ImageIcon className="h-8 w-8 text-amber-500"/>
</div>
<span className="text-sm font-medium"></span>
</div>
</div>
{/* 全屏幻灯片 */}
<div className="relative h-[600px] mb-16 overflow-hidden rounded-2xl shadow-2xl">
{/* 幻灯片导航按钮 */}
<Button
variant="outline"
size="icon"
className="absolute left-4 top-1/2 -translate-y-1/2 z-20 rounded-full h-12 w-12 bg-background/30 backdrop-blur-sm border-white/20 text-white hover:bg-background/50"
onClick={prevSlide}
>
<ChevronLeft className="h-6 w-6"/>
</Button>
<Button
variant="outline"
size="icon"
className="absolute right-4 top-1/2 -translate-y-1/2 z-20 rounded-full h-12 w-12 bg-background/30 backdrop-blur-sm border-white/20 text-white hover:bg-background/50"
onClick={nextSlide}
>
<ChevronRight className="h-6 w-6"/>
</Button>
{/* 幻灯片指示器 */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 flex gap-2">
{photographyItems.map((_, index) => (
<button
key={index}
className={`w-3 h-3 rounded-full transition-all ${
index === activeIndex ? "bg-white w-8" : "bg-white/50"
}`}
onClick={() => {
setDirection(index > activeIndex ? 1 : -1)
setActiveIndex(index)
}}
/>
))}
</div>
{/* 幻灯片内容 */}
<AnimatePresence initial={false} custom={direction}>
{photographyItems.map(
(item, index) =>
index === activeIndex && (
<motion.div
key={item.id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
className="absolute inset-0"
>
{/* 幻灯片背景图片 */}
<div className="absolute inset-0">
<div
className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent z-10"/>
<img
src={item.image || "/placeholder.svg"}
alt={item.title}
className="w-full h-full object-cover"
/>
</div>
{/* 幻灯片内容 */}
<div className="absolute bottom-0 left-0 right-0 p-12 z-20">
<motion.h4
custom={0.2}
variants={contentVariants}
initial="hidden"
animate="visible"
className="text-4xl font-bold text-white mb-4"
>
{item.title}
</motion.h4>
<motion.p
custom={0.4}
variants={contentVariants}
initial="hidden"
animate="visible"
className="text-xl text-white/80 mb-6 max-w-2xl"
>
{item.description}
</motion.p>
<motion.div
custom={0.6}
variants={contentVariants}
initial="hidden"
animate="visible"
className="flex flex-wrap gap-2 mb-6"
>
{item.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-white/20 backdrop-blur-sm px-3 py-1 text-sm font-medium text-white"
>
{tag}
</span>
))}
</motion.div>
</div>
</motion.div>
),
)}
</AnimatePresence>
</div>
{/* 底部CTA */}
<motion.div
initial={{opacity: 0, y: 30}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8, delay: 0.5}}
viewport={{once: true, amount: 0.3}}
className="text-center"
>
<p className="text-lg mb-6"></p>
<Button asChild size="lg" className="rounded-full px-8">
<a href="#contact"></a>
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,384 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {motion} from "framer-motion"
import {Github, ExternalLink, ArrowRight} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {useTheme} from "next-themes"
interface Project {
id: number
title: string
description: string
tags: string[]
image: string
githubUrl?: string
liveUrl?: string
}
export default function GsapProjectsShowcase() {
const containerRef = useRef<HTMLDivElement>(null)
const horizontalRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [isInView, setIsInView] = useState(false)
const {theme} = useTheme()
// Project data
const projects: Project[] = [
{
id: 1,
title: "grtblog",
description:
"个人博客站点,一站式快速解决方案—一套源,扩展性强,快速部署的博客框架,使用 Nextjs + SpringBoot 构建,致力于轻松搭建个性化你的博客站点",
tags: ["React", "Next.js", "Spring Boot", "Umi.js", "Socket.io", "Radix UI"],
image: "/placeholder.svg?height=600&width=800",
githubUrl: "https://github.com/grtsinry43/grtblog",
liveUrl: "https://grtblog.js.org",
},
{
id: 2,
title: "csu-dynamic-youth",
description: "途有青,去追山!学校官微毕业节的微信网页程序,统计参与次数与时长,生成排行榜,完成完成打卡",
tags: ["微信小程序", "Vue.js", "微信API"],
image: "/placeholder.svg?height=600&width=800",
githubUrl: "#",
},
{
id: 3,
title: "Portfolio Website",
description: "个人作品集网站,展示我的项目和技能,使用 Next.js 和 Framer Motion 构建",
tags: ["Next.js", "Framer Motion", "Tailwind CSS", "TypeScript"],
image: "/placeholder.svg?height=600&width=800",
githubUrl: "#",
liveUrl: "#",
},
{
id: 4,
title: "AI Chat Application",
description: "基于人工智能的聊天应用,支持多种语言和自然语言处理",
tags: ["React", "Node.js", "OpenAI API", "WebSockets"],
image: "/placeholder.svg?height=600&width=800",
githubUrl: "#",
},
]
// 检测元素是否在视口中
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
}
},
{threshold: 0.1},
)
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => {
if (containerRef.current) {
observer.unobserve(containerRef.current)
}
}
}, [])
// 计算水平滚动容器的宽度
useEffect(() => {
if (horizontalRef.current) {
// 计算所有项目卡片的总宽度 + 间距
const totalWidth = projects.length * 600 + (projects.length - 1) * 40
setContainerWidth(totalWidth)
}
}, [projects.length])
// 设置水平滚动
useEffect(() => {
if (!isInView) return
const loadGsap = 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 || !horizontalRef.current) return
// 创建水平滚动动画
const scrollTween = gsap.to(horizontalRef.current, {
x: () => -(containerWidth - window.innerWidth + 100),
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: () => `+=${containerWidth}`,
pin: true,
scrub: 1,
invalidateOnRefresh: true,
anticipatePin: 1,
},
})
// 为每个项目卡片创建视差效果
const projectCards = horizontalRef.current.querySelectorAll(".project-card")
projectCards.forEach((card, i) => {
const image = card.querySelector(".project-image")
const content = card.querySelector(".project-content")
const title = card.querySelector(".project-title")
const desc = card.querySelector(".project-desc")
const tags = card.querySelector(".project-tags")
const links = card.querySelector(".project-links")
// 图片视差效果
if (image) {
gsap.fromTo(
image,
{y: 50, opacity: 0},
{
y: 0,
opacity: 1,
duration: 1,
scrollTrigger: {
containerAnimation: scrollTween,
trigger: card,
start: "left center",
toggleActions: "play none none reverse",
},
},
)
}
// 内容视差效果 - 依次显示
if (title) {
gsap.fromTo(
title,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
delay: 0.2,
scrollTrigger: {
containerAnimation: scrollTween,
trigger: card,
start: "left center",
toggleActions: "play none none reverse",
},
},
)
}
if (desc) {
gsap.fromTo(
desc,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
delay: 0.4,
scrollTrigger: {
containerAnimation: scrollTween,
trigger: card,
start: "left center",
toggleActions: "play none none reverse",
},
},
)
}
if (tags) {
gsap.fromTo(
tags,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
delay: 0.6,
scrollTrigger: {
containerAnimation: scrollTween,
trigger: card,
start: "left center",
toggleActions: "play none none reverse",
},
},
)
}
if (links) {
gsap.fromTo(
links,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
delay: 0.8,
scrollTrigger: {
containerAnimation: scrollTween,
trigger: card,
start: "left center",
toggleActions: "play none none reverse",
},
},
)
}
})
return () => {
// 清理动画
scrollTween.kill()
ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
}
} catch (error) {
console.error("Failed to load GSAP:", error)
}
}
loadGsap()
}, [isInView, containerWidth])
return (
<section id="projects" ref={containerRef} className="relative min-h-screen overflow-hidden">
<div className="absolute top-0 left-0 w-full py-24 z-10 pointer-events-none">
<div className="container px-4 mx-auto">
<motion.div
initial={{opacity: 0, y: 40}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8}}
viewport={{once: true, amount: 0.3}}
className="flex items-center justify-center space-x-2 mb-8 pointer-events-auto"
>
<div className="h-px w-12 bg-primary/60"/>
<h2 className="text-lg font-medium text-primary"></h2>
<div className="h-px w-12 bg-primary/60"/>
</motion.div>
<motion.h3
className="text-3xl md:text-4xl font-bold tracking-tight text-center mb-12 pointer-events-auto"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.2}}
viewport={{once: true, amount: 0.3}}
>
<span className="bg-gradient-to-r from-emerald-600 to-blue-600 bg-clip-text text-transparent">
</span>
</motion.h3>
<motion.p
className="text-center text-muted-foreground max-w-2xl mx-auto mb-16 pointer-events-auto"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.3}}
viewport={{once: true, amount: 0.3}}
>
-
</motion.p>
</div>
</div>
{/* 水平滚动容器 */}
<div
ref={horizontalRef}
className="absolute top-0 left-0 h-screen w-fit flex items-center gap-10 pl-[calc(50vw-300px)] pr-[100px] pt-[250px]"
>
{projects.map((project, index) => (
<div
key={project.id}
className="project-card flex-shrink-0 w-[600px] h-[500px] bg-card/90 backdrop-blur-sm rounded-2xl overflow-hidden shadow-xl border border-border/50 relative"
>
<div className="project-image absolute inset-0 z-0">
<div
className="absolute inset-0 bg-gradient-to-t from-card via-card/80 to-transparent z-10"/>
<img
src={project.image || "/placeholder.svg"}
alt={project.title}
className="w-full h-full object-cover"
/>
</div>
<div className="relative z-20 flex flex-col justify-end h-full p-8">
<h4 className="project-title text-3xl font-bold mb-4">{project.title}</h4>
<p className="project-desc text-lg text-muted-foreground mb-6">{project.description}</p>
<div className="project-tags flex flex-wrap gap-2 mb-6">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline"
className="bg-primary/10 text-primary border-primary/20">
{tag}
</Badge>
))}
</div>
<div className="project-links flex gap-4">
{project.githubUrl && (
<Button asChild variant="outline" size="sm">
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Github className="h-4 w-4"/>
GitHub
</a>
</Button>
)}
{project.liveUrl && (
<Button asChild size="sm">
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4"/>
</a>
</Button>
)}
</div>
{/* 项目序号 */}
<div className="absolute top-8 right-8 text-8xl font-bold text-primary/10">
{String(index + 1).padStart(2, "0")}
</div>
</div>
</div>
))}
{/* 结束提示 */}
<div className="flex-shrink-0 w-[300px] h-[500px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-4"></p>
<Button asChild>
<a href="#contact" className="flex items-center gap-2">
<ArrowRight className="h-4 w-4"/>
</a>
</Button>
</div>
</div>
</div>
{/* 滚动提示 */}
<div className="absolute bottom-10 left-1/2 transform -translate-x-1/2 z-10 text-center">
<motion.div
animate={{y: [0, 10, 0]}}
transition={{duration: 1.5, repeat: Number.POSITIVE_INFINITY}}
className="text-sm text-muted-foreground"
>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,450 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {motion} from "framer-motion"
import {Music, Star, Trophy, Gamepad2} from "lucide-react"
import {Button} from "@/components/ui/button"
import {useTheme} from "next-themes"
export default function GsapRhythmGamesSection() {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const {theme} = useTheme()
const [activeGame, setActiveGame] = useState(0)
const [isInView, setIsInView] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
// 音游数据
const rhythmGames = [
{
id: 1,
name: "Arcaea",
description: "一款独特的音乐节奏游戏,以其创新的轨道系统和精美的视觉效果而闻名",
image: "/placeholder.svg?height=400&width=400",
level: "Expert",
achievements: ["最高分数: 9,950,000+", "Pure Memory x 15", "EX+ Rating"],
color: "#5d7bf7",
trackColor: "#8e9fff",
},
{
id: 2,
name: "Cytus II",
description: "由雷亚游戏开发的音乐节奏游戏,以其独特的扫描线机制和丰富的剧情而著名",
image: "/placeholder.svg?height=400&width=400",
level: "Advanced",
achievements: ["Million Master x 20", "TP 99.50+", "All characters unlocked"],
color: "#4cc2ff",
trackColor: "#7ad5ff",
},
{
id: 3,
name: "Phigros",
description: "一款具有创新判定线机制的音乐节奏游戏,以其高难度和视觉效果而受到玩家喜爱",
image: "/placeholder.svg?height=400&width=400",
level: "Master",
achievements: ["Phi级别达成率 95%+", "全曲目解锁", "EX评级 x 25"],
color: "#ff5a87",
trackColor: "#ff8daa",
},
{
id: 4,
name: "BanG Dream!",
description: "以日本偶像乐队为主题的音乐节奏游戏,结合了角色培养和社交元素",
image: "/placeholder.svg?height=400&width=400",
level: "Intermediate",
achievements: ["Full Combo x 30", "All bands at max level", "Event ranking top 1000"],
color: "#ffaa44",
trackColor: "#ffc477",
},
]
// 检测元素是否在视口中
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
} else {
setIsPlaying(false)
}
},
{threshold: 0.2},
)
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => {
if (containerRef.current) {
observer.unobserve(containerRef.current)
}
}
}, [])
// 音符动画
useEffect(() => {
if (!isInView || !canvasRef.current) return
const canvas = canvasRef.current
const ctx = canvas.getContext("2d")
if (!ctx) return
// 设置canvas尺寸
const setCanvasSize = () => {
canvas.width = canvas.clientWidth * window.devicePixelRatio
canvas.height = canvas.clientHeight * window.devicePixelRatio
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
}
setCanvasSize()
window.addEventListener("resize", setCanvasSize)
// 音符类
class Note {
x: number
y: number
size: number
speed: number
color: string
rotation: number
rotationSpeed: number
constructor(canvasWidth: number, canvasHeight: number, color: string) {
this.x = Math.random() * canvasWidth
this.y = canvasHeight + Math.random() * 100
this.size = 5 + Math.random() * 15
this.speed = 1 + Math.random() * 3
this.color = color
this.rotation = Math.random() * Math.PI * 2
this.rotationSpeed = (Math.random() - 0.5) * 0.1
}
update() {
this.y -= this.speed
this.rotation += this.rotationSpeed
}
draw(ctx: CanvasRenderingContext2D) {
ctx.save()
ctx.translate(this.x, this.y)
ctx.rotate(this.rotation)
// 绘制音符
ctx.fillStyle = this.color
ctx.beginPath()
// 音符头部
ctx.ellipse(0, 0, this.size, this.size * 0.8, 0, 0, Math.PI * 2)
ctx.fill()
// 音符尾部
ctx.fillRect(this.size - 2, -this.size * 0.8, 2, -this.size * 2)
ctx.restore()
}
isOffScreen() {
return this.y < -this.size * 3
}
}
// 创建音符
const notes: Note[] = []
const currentGame = rhythmGames[activeGame]
const noteColor = currentGame.color
// 动画循环
let animationId: number
let lastTime = 0
let noteInterval = 0
const animate = (timestamp: number) => {
if (!isPlaying) return
const deltaTime = timestamp - lastTime
lastTime = timestamp
// 每隔一段时间添加新音符
noteInterval += deltaTime
if (noteInterval > 200) {
notes.push(new Note(canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio, noteColor))
noteInterval = 0
}
// 清空画布
ctx.clearRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio)
// 更新和绘制音符
for (let i = notes.length - 1; i >= 0; i--) {
notes[i].update()
notes[i].draw(ctx)
// 移除离开屏幕的音符
if (notes[i].isOffScreen()) {
notes.splice(i, 1)
}
}
animationId = requestAnimationFrame(animate)
}
// 开始动画
if (isPlaying) {
animationId = requestAnimationFrame(animate)
}
return () => {
window.removeEventListener("resize", setCanvasSize)
cancelAnimationFrame(animationId)
}
}, [isInView, isPlaying, activeGame])
// GSAP动画
useEffect(() => {
if (!isInView) return
const loadGsap = 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) return
const container = containerRef.current
const titleElements = container.querySelectorAll(".section-title")
const iconElements = container.querySelectorAll(".icon-item")
const gameCards = container.querySelectorAll(".game-card")
// 标题动画
gsap.fromTo(
titleElements,
{y: 30, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
stagger: 0.2,
ease: "power3.out",
scrollTrigger: {
trigger: container,
start: "top 70%",
},
},
)
// 图标动画
gsap.fromTo(
iconElements,
{scale: 0.5, opacity: 0},
{
scale: 1,
opacity: 1,
duration: 0.6,
stagger: 0.1,
ease: "back.out(1.7)",
scrollTrigger: {
trigger: container,
start: "top 70%",
},
},
)
// 游戏卡片动画
gsap.fromTo(
gameCards,
{y: 50, opacity: 0},
{
y: 0,
opacity: 1,
duration: 0.8,
stagger: 0.2,
ease: "power3.out",
scrollTrigger: {
trigger: container,
start: "top 70%",
},
},
)
return () => {
// 清理动画
ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
}
} catch (error) {
console.error("Failed to load GSAP:", error)
}
}
loadGsap()
}, [isInView])
return (
<section id="rhythm-games" 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_left,_var(--tw-gradient-stops))] from-blue-500/5 via-transparent to-transparent"/>
</div>
<div className="container px-4 mx-auto relative z-10">
<motion.div
initial={{opacity: 0, y: 40}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8}}
viewport={{once: true, amount: 0.3}}
className="flex items-center justify-center space-x-2 mb-12"
>
<div className="h-px w-12 bg-primary/60"/>
<h2 className="text-lg font-medium text-primary"></h2>
<div className="h-px w-12 bg-primary/60"/>
</motion.div>
<motion.h3
className="section-title text-3xl md:text-4xl font-bold tracking-tight text-center mb-8"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.2}}
viewport={{once: true, amount: 0.3}}
>
<span className="text-primary"></span>
</motion.h3>
<motion.p
className="section-title text-center text-muted-foreground max-w-2xl mx-auto mb-16"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.3}}
viewport={{once: true, amount: 0.3}}
>
</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"/>
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-lg text-muted-foreground"></p>
</div>
)}
</div>
{/* 游戏卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
{rhythmGames.map((game, index) => (
<motion.div
key={game.id}
className={`game-card relative bg-card/80 backdrop-blur-sm p-6 rounded-2xl border border-border/50 shadow-lg overflow-hidden cursor-pointer transition-all duration-300 ${
activeGame === index ? "ring-2 ring-offset-2" : ""
}`}
style={{
// ringColor: game.color,
}}
whileHover={{y: -5}}
onClick={() => {
setActiveGame(index)
setIsPlaying(true)
}}
>
{/* 背景装饰 */}
<div className="absolute top-0 left-0 right-0 h-1 opacity-70"
style={{backgroundColor: game.color}}/>
<div className="flex items-center gap-4 mb-4">
<div className="flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden">
<img src={game.image || "/placeholder.svg"} alt={game.name}
className="w-full h-full object-cover"/>
</div>
<div>
<h4 className="text-xl font-bold">{game.name}</h4>
<div className="flex items-center text-sm">
<span className="inline-block w-2 h-2 rounded-full mr-2"
style={{backgroundColor: game.color}}/>
<span>{game.level}</span>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">{game.description}</p>
{/* 成就进度条 */}
<div className="space-y-3">
{game.achievements.map((achievement, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-xs">
<span>{achievement}</span>
</div>
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{backgroundColor: game.trackColor}}
initial={{width: 0}}
whileInView={{width: `${70 + Math.random() * 30}%`}}
transition={{duration: 1, delay: 0.2 + i * 0.1}}
viewport={{once: true}}
/>
</div>
</div>
))}
</div>
{/* 点击提示 */}
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground"></div>
</motion.div>
))}
</div>
{/* 底部CTA */}
<motion.div
initial={{opacity: 0, y: 30}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8, delay: 0.5}}
viewport={{once: true, amount: 0.3}}
className="text-center"
>
<p className="text-lg mb-6"></p>
<Button asChild size="lg" className="rounded-full px-8">
<a href="#contact">ID</a>
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,328 @@
"use client"
import {useRef, useEffect, useState} from "react"
import {motion} from "framer-motion"
import {useTheme} from "next-themes"
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 [isInView, setIsInView] = useState(false)
// 技能数据
const skills = [
// Web开发技能
{name: "React", level: 95, group: "web", color: "#61DAFB"},
{name: "Next.js", level: 90, group: "web", color: "#000000"},
{name: "Vue.js", level: 85, group: "web", color: "#4FC08D"},
{name: "TypeScript", level: 90, group: "web", color: "#3178C6"},
{name: "JavaScript", level: 95, group: "web", color: "#F7DF1E"},
{name: "HTML/CSS", level: 95, group: "web", color: "#E34F26"},
// 后端开发技能
{name: "Spring Boot", level: 60, group: "backend", color: "#6DB33F"},
{name: "Spring Security", level: 60, group: "backend", color: "#6DB33F"},
{name: "Java", level: 60, group: "backend", color: "#007396"},
{name: "Node.js", level: 75, group: "backend", color: "#339933"},
{name: "MySQL", level: 70, group: "backend", color: "#4479A1"},
{name: "PostgreSQL", level: 65, group: "backend", color: "#336791"},
// Android开发技能
{name: "Kotlin", level: 70, group: "android", color: "#7F52FF"},
{name: "Jetpack Compose", level: 65, group: "android", color: "#4285F4"},
{name: "Kotlin Multiplatform", level: 60, group: "android", color: "#7F52FF"},
{name: "Android SDK", level: 75, group: "android", color: "#3DDC84"},
]
// 技能分组
const skillGroups = [
{id: "web", name: "Web开发", color: "from-blue-500 to-violet-500"},
{id: "backend", name: "后端开发", color: "from-green-500 to-emerald-500"},
{id: "android", name: "Android开发", color: "from-amber-500 to-orange-500"},
]
useEffect(() => {
// 检测元素是否在视口中
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsInView(true)
}
},
{threshold: 0.2},
)
if (containerRef.current) {
observer.observe(containerRef.current)
}
return () => {
if (containerRef.current) {
observer.unobserve(containerRef.current)
}
}
}, [])
useEffect(() => {
if (!isInView) return
// 动态导入GSAP以避免SSR问题
const loadGsap = 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
const canvas = canvasRef.current
const ctx = canvas.getContext("2d")
if (!ctx) return
// 设置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)
}
setCanvasSize()
window.addEventListener("resize", setCanvasSize)
// 创建3D技能球体
const skillSphereRadius = Math.min(canvas.width, canvas.height) * 0.3
const center = {x: canvas.width / 2, y: canvas.height / 2}
// 为每个技能分配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
return {
...skill,
x3d: skillSphereRadius * Math.cos(theta) * Math.sin(phi),
y3d: skillSphereRadius * Math.sin(theta) * Math.sin(phi),
z3d: skillSphereRadius * 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 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
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)
autoRotateTimeout = setTimeout(() => {
autoRotate = true
}, 5000)
}
}
let autoRotateTimeout: NodeJS.Timeout
canvas.addEventListener("mousemove", handleMouseMove)
// 绘制函数
const draw = () => {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新旋转
if (autoRotate) {
targetRotation.y += 0.003
}
rotation.x += (targetRotation.x - rotation.x) * 0.05
rotation.y += (targetRotation.y - rotation.y) * 0.05
// 计算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)
// 更新每个技能节点的位置
skillNodes.forEach((node) => {
// 应用旋转变换
const x = node.x3d
const y = node.y3d * cosX - node.z3d * sinX
const z = node.y3d * sinX + node.z3d * cosX
const x2 = x * cosY - z * sinY
const z2 = x * sinY + z * cosY
// 投影到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
// 节点大小基于技能等级
const nodeSize = (10 + node.level / 10) * scale
// 绘制节点
ctx.fillStyle = node.color
ctx.beginPath()
ctx.arc(node.x2d, node.y2d, nodeSize, 0, Math.PI * 2)
ctx.fill()
// 绘制技能名称
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.restore()
}
})
requestAnimationFrame(draw)
}
// 开始动画
draw()
// 清理函数
return () => {
window.removeEventListener("resize", setCanvasSize)
canvas.removeEventListener("mousemove", handleMouseMove)
clearTimeout(autoRotateTimeout)
}
} catch (error) {
console.error("Failed to load GSAP:", error)
}
}
loadGsap()
}, [isInView, theme])
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"/>
</div>
<div className="container px-4 mx-auto relative z-10">
<motion.div
initial={{opacity: 0, y: 40}}
whileInView={{opacity: 1, y: 0}}
transition={{duration: 0.8}}
viewport={{once: true, amount: 0.3}}
className="flex items-center justify-center space-x-2 mb-12"
>
<div className="h-px w-12 bg-primary/60"/>
<h2 className="text-lg font-medium text-primary"></h2>
<div className="h-px w-12 bg-primary/60"/>
</motion.div>
<motion.h3
className="text-3xl md:text-4xl font-bold tracking-tight text-center mb-8"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.2}}
viewport={{once: true, amount: 0.3}}
>
<span className="text-primary"></span>
</motion.h3>
<motion.p
className="text-center text-muted-foreground max-w-2xl mx-auto mb-16"
initial={{opacity: 0}}
whileInView={{opacity: 1}}
transition={{duration: 0.8, delay: 0.3}}
viewport={{once: true, amount: 0.3}}
>
-
</motion.p>
{/* 3D技能球体 */}
<div className="relative w-full 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>
</div>
</div>
{/* 技能分组说明 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{skillGroups.map((group, idx) => (
<motion.div
key={group.id}
initial={{opacity: 0, y: 30}}
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"
>
<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>
<div className="space-y-2">
{skills
.filter((skill) => skill.group === group.id)
.slice(0, 4)
.map((skill) => (
<div key={skill.name} className="flex items-center justify-between">
<span className="text-sm">{skill.name}</span>
<div className="flex items-center">
<div className="h-1.5 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>
</div>
</div>
))}
{skills.filter((skill) => skill.group === group.id).length > 4 && (
<div className="text-xs text-right text-muted-foreground">
+{skills.filter((skill) => skill.group === group.id).length - 4}
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
</section>
)
}

View File

@ -70,12 +70,31 @@ const config = {
transform: "translate(calc(-50% - 0.5rem))",
},
},
float: {
"0%, 100%": {
transform: "translateY(0) scale(1)",
},
"50%": {
transform: "translateY(-20px) scale(1.5)",
},
},
wave: {
"0%": {transform: "rotate(0.0deg)"},
"10%": {transform: "rotate(14.0deg)"},
"20%": {transform: "rotate(-8.0deg)"},
"30%": {transform: "rotate(14.0deg)"},
"40%": {transform: "rotate(-4.0deg)"},
"50%": {transform: "rotate(10.0deg)"},
"60%": {transform: "rotate(0.0deg)"},
"100%": {transform: "rotate(0.0deg)"},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
scroll: "scroll var(--animation-duration, 40s) var(--animation-direction, forwards) linear infinite",
float: "float 6s ease-in-out infinite",
wave: "wave 2.5s ease-in-out infinite",
},
},
},