diff --git a/src/pages/_views/03-World.tsx b/src/pages/_views/03-World.tsx index ef31acf..fe66b71 100644 --- a/src/pages/_views/03-World.tsx +++ b/src/pages/_views/03-World.tsx @@ -1,431 +1,480 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useStore } from "@nanostores/react"; import { viewIndex, readyToTouch } from "../../components/store/rootLayoutStore.ts"; import { directions } from "../../components/store/lineDecoratorStore.ts"; import { IconArrow } from "../../components/SvgIcons.tsx"; import PortraitBottomGradientMask from "../../components/PortraitBottomGradientMask"; +import config from "../../../arknights.config.tsx"; + +const items = config.rootPage.WORLD.items; + +// 将 AshParticles 的动画逻辑抽离到自定义 hook +function useAshParticlesAnimation(count: number, canvasRef: React.RefObject) { + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + const particles: { + x: number; + y: number; + size: number; + speed: number; + initialY: number; + }[] = []; + + for (let i = 0; i < count; i++) { + const y = canvas.height + Math.random() * 100; + particles.push({ + x: Math.random() * canvas.width, + y: y, + initialY: y, + size: Math.random() * 1 + 1.2, + speed: Math.random() * 0.2 + }); + } + + function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); + } + + function animate() { + if (!ctx || !canvas) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + + particles.forEach(particle => { + // 主粒子 + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + ctx.fill(); + + // 模糊 + for (let i = 0; i < 3; i++) { + ctx.globalAlpha = 0.01; + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size + i * 0.5, 0, Math.PI * 2); + ctx.fill(); + } -const items = [ - {title: "源石", subTitle: "ORIGINIUMS", imageUrl: "/images/03-world/originiums.png", description: '大地被起因不明的天灾四处肆虐,经由天灾卷过的土地上出现了大量的神秘矿物——"源石"。依赖于技术的进步,源石蕴含的能量投入工业后使得文明顺利迈入现代,与此同时,源石本身也催生出"感染者"的存在。'}, - {title: "源石技艺", subTitle: "ORIGINIUM ARTS", imageUrl: "/images/03-world/originium_arts.png", description: "源石技艺的描述..."}, - {title: "整合运动", subTitle: "REUNION", imageUrl: "/images/03-world/reunion.png", description: "整合运动的描述..."}, - {title: "感染者", subTitle: "INFECTED", imageUrl: "/images/03-world/infected.png", description: "感染者的描述..."}, - {title: "移动城邦", subTitle: "NOMADIC CITY", imageUrl: "/images/03-world/nomadic_city.png", description: "移动城邦的描述..."}, - {title: "罗德岛", subTitle: "RHODES ISLAND", imageUrl: "/images/03-world/rhodes_island.png", description: "罗德岛的描述..."}, -]; + const totalDistance = canvas.height * 0.2; + const currentDistance = particle.initialY - particle.y; + const progress = Math.min(currentDistance / totalDistance, 1); + const easeProgress = easeOutCubic(progress); -function List({ onItemSelect }: { onItemSelect: (index: number) => void }) { - const [isExiting, setIsExiting] = useState(false); - const listRef = useRef(null); - const [activeImage, setActiveImage] = useState(null); - const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); - const [targetPosition, setTargetPosition] = useState({ x: 0, y: 0 }); - const animationFrameRef = useRef(null); - const isFirstMove = useRef(true); // 用一个变量来修复首次加载会导致图片位置错误的问题 - - const handleMouseMove = (e: React.MouseEvent) => { - if (listRef.current) { - const rect = listRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const imgWidth = 1024; - const imgHeight = 1024; - - // TODO: 一个奇怪的偏移,需要更好的方法 - const imgOffsetX = 350; - const imgOffsetY = 0; - const imgOffsetXPercentage = 75; - const imgOffsetYPercentage = 0; - const newPosition = { - x: x - imgWidth / 2 + (imgOffsetX * imgOffsetXPercentage / 100), - y: y - imgHeight / 2 + (imgOffsetY * imgOffsetYPercentage / 100) - }; - - if (isFirstMove.current) { - setImagePosition(newPosition); - isFirstMove.current = false; - } - - setTargetPosition(newPosition); - - const itemHeight = rect.height / items.length; - const index = Math.floor(y / itemHeight); - if (index >= 0 && index < items.length) { - setActiveImage(items[index].imageUrl); - } else { - setActiveImage(null); - } + const speed = particle.speed * (10 - 9 * easeProgress); + particle.y -= speed; + + if (particle.y < 0) { + particle.y = canvas.height; + particle.x = Math.random() * canvas.width; + particle.initialY = particle.y; } + }); + + requestAnimationFrame(animate); + } + + animate(); + + const handleResize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; }; - const handleMouseLeave = () => { - setActiveImage(null); - isFirstMove.current = true; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); }; + }, [count]); +} - useEffect(() => { - const animatePosition = () => { - setImagePosition(prevPos => { - const dx = targetPosition.x - prevPos.x; - const dy = targetPosition.y - prevPos.y; - return { - x: prevPos.x + dx * 0.1, - y: prevPos.y + dy * 0.1 - }; - }); - animationFrameRef.current = requestAnimationFrame(animatePosition); - }; +function AshParticles({ count = 20 }: { count?: number }) { + const canvasRef = useRef(null); + useAshParticlesAnimation(count, canvasRef); - if (activeImage) { - animationFrameRef.current = requestAnimationFrame(animatePosition); - } + return ; +} - return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - }; - }, [targetPosition, activeImage]); +// 使用 useMemo 优化 items 的渲染 +const MemoizedItem = React.memo(Item); - const handleItemClick = (index: number) => { - setIsExiting(true); - setTimeout(() => { - onItemSelect(index); - }, 500); // 退出动画时间 +function List({ onItemSelect }: { onItemSelect: (index: number) => void }) { + const [isExiting, setIsExiting] = useState(false); + const [exitingIndex, setExitingIndex] = useState(null); + const listRef = useRef(null); + const [activeImage, setActiveImage] = useState(null); + const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); + const [targetPosition, setTargetPosition] = useState({ x: 0, y: 0 }); + const animationFrameRef = useRef(null); + const isFirstMove = useRef(true); // 用一个变量来修复首次加载会导致图片位置错误的问题 + const isFirstLoad = useRef(true); + + const handleMouseMove = (e: React.MouseEvent) => { + if (listRef.current) { + const rect = listRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const imgWidth = 1024; + const imgHeight = 1024; + + // TODO: 一个奇怪的偏移,需要更好的方法 + const imgOffsetX = 350; + const imgOffsetY = 0; + const imgOffsetXPercentage = 75; + const imgOffsetYPercentage = 0; + const newPosition = { + x: x - imgWidth / 2 + (imgOffsetX * imgOffsetXPercentage / 100), + y: y - imgHeight / 2 + (imgOffsetY * imgOffsetYPercentage / 100) + }; + + if (isFirstMove.current) { + setImagePosition(newPosition); + isFirstMove.current = false; + } + + setTargetPosition(newPosition); + + const itemHeight = rect.height / items.length; + const index = Math.floor(y / itemHeight); + if (index >= 0 && index < items.length) { + setActiveImage(items[index].imageUrl); + } else { + setActiveImage(null); + } + } + }; + + const handleMouseLeave = () => { + setActiveImage(null); + isFirstMove.current = true; + }; + + useEffect(() => { + const animatePosition = () => { + setImagePosition(prevPos => { + const dx = targetPosition.x - prevPos.x; + const dy = targetPosition.y - prevPos.y; + return { + x: prevPos.x + dx * 0.1, + y: prevPos.y + dy * 0.1 + }; + }); + animationFrameRef.current = requestAnimationFrame(animatePosition); }; - const itemAnimationDuration = 300; // 每个元素的动画时间(毫秒) - const itemAnimationDelay = 50; // 元素之间的延迟时间(毫秒) - - return ( -
- {items.map(({title, subTitle}, index) => ( - handleItemClick(index)} - isExiting={isExiting} - /> - ))} - {activeImage && ( - Active item - )} -
- ); + if (activeImage) { + animationFrameRef.current = requestAnimationFrame(animatePosition); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [targetPosition, activeImage]); + + const itemAnimationDuration = 300; // 每个元素的动画时间 + const itemAnimationDelay = 50; // 元素之间的延迟时间 + const initialDelay = 800; + + const handleItemClick = (index: number) => { + setIsExiting(true); + setExitingIndex(index); + setTimeout(() => { + onItemSelect(index); + }, 500 + items.length * itemAnimationDelay); + }; + + const memoizedItems = useMemo(() => items.map(({title, subTitle}: {title: string, subTitle: string}, index: number) => ( + handleItemClick(index)} + isExiting={isExiting} + exitingIndex={exitingIndex} + index={index} + /> + )), [isExiting, exitingIndex]); + + useEffect(() => { + return () => { + isFirstLoad.current = false; + }; + }, []); + + return ( +
+ {memoizedItems} + {activeImage && ( + Active item + )} +
+ ); } -function Item({title, subTitle, delay, onClick, isExiting}: { - title: string; - subTitle: string, - delay: number, - onClick: () => void, - isExiting: boolean +function Item({title, subTitle, delay, onClick, isExiting, exitingIndex, index}: { + title: string; + subTitle: string, + delay: number, + onClick: () => void, + isExiting: boolean, + exitingIndex: number | null, + index: number }) { - const $viewIndex = useStore(viewIndex) - const [active, setActive] = useState(false) - const [isVisible, setIsVisible] = useState(false) - const itemDom = useRef(null); - - useEffect(() => { - if ($viewIndex === 3) { - const timer = setTimeout(() => { - setIsVisible(true); - }, delay); - return () => clearTimeout(timer); - } else { - setIsVisible(false); - } - }, [$viewIndex, delay]); - - return ( -
setActive(true)} - onMouseLeave={() => setActive(false)} - onClick={onClick} - > -
- {subTitle} -
-
- {title} -
-
- {subTitle} -
-
- ) + const $viewIndex = useStore(viewIndex) + const [active, setActive] = useState(false) + const [isVisible, setIsVisible] = useState(false) + const itemRef = useRef(null); + + useEffect(() => { + if ($viewIndex === 3) { + const timer = setTimeout(() => { + setIsVisible(true); + }, delay); + return () => clearTimeout(timer); + } else { + setIsVisible(false); + } + }, [$viewIndex, delay]); + + const exitDelay = isExiting ? (index - (exitingIndex ?? 0)) * 50 : 0; + + return ( + setActive(true)} + onMouseLeave={() => setActive(false)} + onClick={(e) => { + e.preventDefault(); + onClick(); + }} + aria-label={`${title} - ${subTitle}`} + > +
+ {subTitle} +
+
+ {title} +
+
+ {subTitle} +
+
+ ) } function Details({item, onBack, onPrevious, onNext}: { - item: typeof items[0], - onBack: () => void, - onPrevious: () => void, - onNext: () => void + item: typeof items[0], + onBack: () => void, + onPrevious: () => void, + onNext: () => void }) { - return <> -
-
-
-
- {item.title} -
-
- {item.subTitle} -
-
- {item.description} -
-
- - + return <> +
+
+
+
+ {item.title}
-
-
- {items.map((_, index) => ( -
- ))} -
+
+ {item.subTitle}
-
- -
-
返回
-
GO BACK
-
+
+ {item.description}
- -} - -// TODO: 把颗粒集中 -function AshParticles({ count = 20 }: { count?: number }) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - - const particles: { - x: number; - y: number; - size: number; - speed: number; - initialY: number; - }[] = []; - - for (let i = 0; i < count; i++) { - const y = canvas.height + Math.random() * 100; - particles.push({ - x: Math.random() * canvas.width, - y: y, - initialY: y, - size: Math.random() * 1 + 1.2, - speed: Math.random() * 0.2 - }); - } - - function easeOutCubic(t: number): number { - return 1 - Math.pow(1 - t, 3); - } - - function animate() { - if (!ctx || !canvas) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - - particles.forEach(particle => { - // 主粒子 - ctx.globalAlpha = 0.7; - ctx.beginPath(); - ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); - ctx.fill(); - - // 模糊 - for (let i = 0; i < 3; i++) { - ctx.globalAlpha = 0.01; - ctx.beginPath(); - ctx.arc(particle.x, particle.y, particle.size + i * 0.5, 0, Math.PI * 2); - ctx.fill(); - } - - const totalDistance = canvas.height * 0.2; - const currentDistance = particle.initialY - particle.y; - const progress = Math.min(currentDistance / totalDistance, 1); - const easeProgress = easeOutCubic(progress); - - const speed = particle.speed * (10 - 9 * easeProgress); - particle.y -= speed; - - if (particle.y < 0) { - particle.y = canvas.height; - particle.x = Math.random() * canvas.width; - particle.initialY = particle.y; - } - }); - - requestAnimationFrame(animate); - } - - animate(); - - const handleResize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [count]); - - return ; +
+ { + e.preventDefault(); + onPrevious(); + }} + aria-label="上一个" + > + + + { + e.preventDefault(); + onNext(); + }} + aria-label="下一个" + > + + +
+
+
+ {items.map((_: any, index: React.Key | null | undefined) => ( +
+ ))} +
+
+ { + e.preventDefault(); + onBack(); + }} + aria-label="返回" + > + +
+
返回
+
GO BACK
+
+
+ } +// 使用 useCallback 优化回调函数 export default function World() { - const $viewIndex = useStore(viewIndex) - const $readyToTouch = useStore(readyToTouch) - const world = useRef(null) - const [selectedItemIndex, setSelectedItemIndex] = useState(null); - const [active, setActive] = useState(false); - - useEffect(() => { - const isActive = $viewIndex === 3 && $readyToTouch; - if (isActive) { - directions.set({top: false, right: true, bottom: true, left: false}) - } - setActive(isActive); - }, [$viewIndex, $readyToTouch]) - - const handleItemSelect = (index: number) => { - setSelectedItemIndex(index); - }; - - const handleBack = () => { - setSelectedItemIndex(null); - }; - - const handlePrevious = () => { - setSelectedItemIndex((prevIndex) => - prevIndex === null ? null : (prevIndex - 1 + items.length) % items.length - ); - }; - - const handleNext = () => { - setSelectedItemIndex((prevIndex) => - prevIndex === null ? null : (prevIndex + 1) % items.length - ); - }; + const $viewIndex = useStore(viewIndex) + const $readyToTouch = useStore(readyToTouch) + const world = useRef(null) + const [selectedItemIndex, setSelectedItemIndex] = useState(null); + const [active, setActive] = useState(false); + + useEffect(() => { + const isActive = $viewIndex === 3 && $readyToTouch; + if (isActive) { + directions.set({top: false, right: true, bottom: true, left: false}) + } + setActive(isActive); + }, [$viewIndex, $readyToTouch]) + + const handleItemSelect = useCallback((index: number) => { + setSelectedItemIndex(index); + }, []); + + const handleBack = useCallback(() => { + setSelectedItemIndex(null); + }, []); + + const handlePrevious = useCallback(() => { + setSelectedItemIndex((prevIndex) => + prevIndex === null ? null : (prevIndex - 1 + items.length) % items.length + ); + }, []); - return ( -
-
- -
- {selectedItemIndex === null ? ( - - ) : ( -
- )} -
- WORLD -
-
-
- {items.map((_, index) => ( -
handleItemSelect(index)} - /> - ))} -
-
- + const handleNext = useCallback(() => { + setSelectedItemIndex((prevIndex) => + prevIndex === null ? null : (prevIndex + 1) % items.length + ); + }, []); + + return ( +
+
+ +
+ {selectedItemIndex === null ? ( + + ) : ( +
+ )} +
+ WORLD +
+ + +
+ ) +} \ No newline at end of file