feat: add GSAP animations and new sections for personal intro, skills tree, photography, and project showcase
This commit is contained in:
parent
29f8921544
commit
b7504d5acf
@ -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>
|
||||
|
||||
261
src/components/sections/personal-intro.tsx
Normal file
261
src/components/sections/personal-intro.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
389
src/components/sections/photography-section.tsx
Normal file
389
src/components/sections/photography-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
384
src/components/sections/project-showcase.tsx
Normal file
384
src/components/sections/project-showcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
450
src/components/sections/rhythm-games-section.tsx
Normal file
450
src/components/sections/rhythm-games-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
328
src/components/sections/skill-tree.tsx
Normal file
328
src/components/sections/skill-tree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user