From eda6442c6c83cc1d10d134d39266929a28ee1df8 Mon Sep 17 00:00:00 2001 From: suhyun Date: Sat, 20 Dec 2025 00:22:02 +0900 Subject: [PATCH] [feat] add animation to collection --- src/assets/png/Backgrounds/index.ts | 6 +- .../CollectionPage/GrowthTimeline.tsx | 66 ++++++++--- src/components/CollectionPage/fishCage.tsx | 104 ++++++++++++++---- .../CollectionPage/fishGrowthCard.tsx | 74 ++++++++----- src/pages/CollectionPage.tsx | 32 +++++- 5 files changed, 210 insertions(+), 72 deletions(-) diff --git a/src/assets/png/Backgrounds/index.ts b/src/assets/png/Backgrounds/index.ts index ea034b6..dacc8ef 100644 --- a/src/assets/png/Backgrounds/index.ts +++ b/src/assets/png/Backgrounds/index.ts @@ -1,6 +1,6 @@ -import bg1 from "@/assets/png/Backgrounds/bg-1.png"; -import bg2 from "@/assets/png/Backgrounds/bg-2.png"; -import bg3 from "@/assets/png/Backgrounds/bg-3.png"; +import bg1 from "@/assets/png/Backgrounds/bg-deep-1.png"; +import bg2 from "@/assets/png/Backgrounds/bg-deep-2.png"; +import bg3 from "@/assets/png/Backgrounds/bg-ocean.png"; const BACKGROUND_IMAGES: Record = { "Bg Ocean": bg1, diff --git a/src/components/CollectionPage/GrowthTimeline.tsx b/src/components/CollectionPage/GrowthTimeline.tsx index ec9e814..9c3e4b0 100644 --- a/src/components/CollectionPage/GrowthTimeline.tsx +++ b/src/components/CollectionPage/GrowthTimeline.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; interface FishStage { stage: string; @@ -9,20 +9,58 @@ interface GrowthTimelineProps { fishList: FishStage[]; } -const GrowthTimeline: React.FC = ({ fishList }) => ( -
- {fishList.map((fish, idx) => ( -
-
- {fish.stage} +// 개별 단계를 렌더링하는 컴포넌트 (SVG 애니메이션 활성화용) +const GrowthStageItem: React.FC<{ fish: FishStage }> = ({ fish }) => { + const [svgContent, setSvgContent] = useState(""); + const isQuestion = fish.img.includes("questionSquare"); + + useEffect(() => { + if (isQuestion) return; + + fetch(fish.img) + .then((res) => res.text()) + .then((data) => { + // SVG 내 애니메이션 ID 충돌 방지를 위해 고유 ID 생성 + const uniqueId = Math.random().toString(36).substr(2, 9); + setSvgContent(data.replaceAll(/\*\{id\}/g, uniqueId)); + }) + .catch((err) => console.error("SVG fetch error:", err)); + }, [fish.img, isQuestion]); + + return ( +
+
+
+ {isQuestion ? ( + {fish.stage} + ) : ( +
+ )}
- {fish.stage}
- ))} -
-); + + {fish.stage} + +
+ ); +}; + +const GrowthTimeline: React.FC = ({ fishList }) => { + return ( +
+
+ {fishList.map((fish, idx) => ( + + ))} +
+
+ ); +}; export default GrowthTimeline; diff --git a/src/components/CollectionPage/fishCage.tsx b/src/components/CollectionPage/fishCage.tsx index ccf4193..2de308c 100644 --- a/src/components/CollectionPage/fishCage.tsx +++ b/src/components/CollectionPage/fishCage.tsx @@ -1,31 +1,97 @@ -// src/components/collection/EmptyCage.tsx -import React from "react"; +import React, { useEffect, useState } from "react"; interface FishCageProps { - fish?: string; - isSelected?: boolean; + fish?: string; // SVG 이미지 경로 + isSelected: boolean; } const FishCage: React.FC = ({ fish, isSelected }) => { - // 1. fish 유무에 따라 기본 크기 클래스를 설정합니다. - // 2. fish가 있을 때만 선택(isSelected)에 따른 크기 변화를 줍니다. - const imageClass = fish - ? isSelected - ? "h-35 w-35" - : "h-30 w-30" // 물고기가 있을 때 크기 (선택 시 더 커짐) - : "h-15 w-15 opacity-80"; // 물음표(빈 칸)일 때 크기 (고정) + const [svgContent, setSvgContent] = useState(""); + const [pos, setPos] = useState({ x: 50, y: 55 }); + const [facing, setFacing] = useState(1); + const isQuestion = fish?.includes("questionSquare"); - return ( -
- {/* 어항 배경 */} - fish cage + // 1. SVG 소스 가져오기 (애니메이션 활성화를 위해 인라이닝) + useEffect(() => { + if (!fish || isQuestion) { + setSvgContent(""); + return; + } + fetch(fish) + .then((res) => res.text()) + .then((data) => { + const uniqueId = Math.random().toString(36).substr(2, 9); + setSvgContent(data.replaceAll(/\*\{id\}/g, uniqueId)); + }) + .catch((err) => console.error("SVG fetch error:", err)); + }, [fish, isQuestion]); + + // 2. 어항 내부 이동 로직 + useEffect(() => { + // 선택되었거나 물음표일 때는 이동하지 않고 중앙 고정 + if (isSelected || isQuestion || !fish) { + setPos({ x: 50, y: 55 }); + return; + } + + let cancelled = false; + + const move = async () => { + while (!cancelled && !isSelected) { + const targetX = Math.random() * 40 + 30; // 30% ~ 70% 사이 + const targetY = Math.random() * 20 + 40; // 40% ~ 60% 사이 + + // 함수형 업데이트를 사용하여 의존성 문제 해결 + setPos((prev) => { + const dx = targetX - prev.x; + if (Math.abs(dx) > 1) { + setFacing(dx > 0 ? 1 : -1); + } + return { x: targetX, y: targetY }; + }); - {/* 물고기 또는 물음표 이미지 */} + // 랜덤한 대기 시간 + await new Promise((res) => setTimeout(res, Math.random() * 2000 + 2000)); + } + }; + + move(); + + return () => { + cancelled = true; + }; + }, [isSelected, fish, isQuestion]); + + return ( +
+ {/* 어항 틀 */} fish + + {/* 물고기 영역 */} +
+ {isQuestion ? ( + locked + ) : ( +
+ )} +
); }; diff --git a/src/components/CollectionPage/fishGrowthCard.tsx b/src/components/CollectionPage/fishGrowthCard.tsx index c3b3da7..5ca0b11 100644 --- a/src/components/CollectionPage/fishGrowthCard.tsx +++ b/src/components/CollectionPage/fishGrowthCard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import GrowthTimeline from "./GrowthTimeline"; import RepoSelect from "../CollectionPage/RepoSelect"; import { UserFish } from "../../apis/collection"; @@ -26,22 +26,35 @@ const FishGrowthCard: React.FC = ({ starImg = "/images/shop/starfish.png", fishList, }) => { + const [mainSvg, setMainSvg] = useState(""); const isEmpty = !selectedFishGroup || !fishList || fishList.length === 0; - // ✅ 1. 현재 선택된 물고기(group_code)가 존재하는 레포지토리 목록만 추출 + + // ✅ 메인 이미지(선택된 물고기) SVG 애니메이션 활성화 + useEffect(() => { + if (mainImg && !mainImg.includes("questionSquare")) { + fetch(mainImg) + .then((res) => res.text()) + .then((data) => { + const uniqueId = "main-" + Math.random().toString(36).substr(2, 5); + setMainSvg(data.replaceAll(/\*\{id\}/g, uniqueId)); + }) + .catch((err) => console.error(err)); + } else { + setMainSvg(""); + } + }, [mainImg]); + const availableRepos = useMemo(() => { if (!selectedFishGroup) return []; - - // 전체 물고기 데이터 중 현재 물고기와 같은 종류인 데이터들만 필터링 return allFishData .filter((fish) => fish.group_code === selectedFishGroup.group_code) .map((fish) => ({ - id: fish.id.toString(), // Select 내부 매칭용 ID + id: fish.id.toString(), fullName: fish.repository_full_name, contributions: fish.maturity, })); }, [selectedFishGroup, allFishData]); - // ✅ 2. 현재 선택된 레포지토리 값 매칭 const currentRepoValue = useMemo(() => { if (!selectedFishGroup || availableRepos.length === 0) return null; return ( @@ -51,13 +64,11 @@ const FishGrowthCard: React.FC = ({ const handleRepoChange = (r: RepoInfo | null) => { if (!r || !selectedFishGroup) return; - const targetFish = allFishData.find( (fish) => fish.group_code === selectedFishGroup.group_code && fish.repository_full_name === r.fullName, ); - if (targetFish) { onFishDataUpdate(targetFish); } @@ -73,7 +84,7 @@ const FishGrowthCard: React.FC = ({ backgroundSize: "contain", }} > -
+
{isEmpty ? (
@@ -81,34 +92,37 @@ const FishGrowthCard: React.FC = ({
) : ( - <> -
-
-
- {selectedFishGroup.group_code} -
- -
- fish +
+
+
+ {selectedFishGroup.group_code} +
-
-
- -
+
+ {/* 메인 이미지 영역: 애니메이션 적용 */} +
+ {mainSvg ? ( +
+ ) : ( + fish + )} +
-
- Rarity: star 1 -
+
+ +
+ Rarity: star 1
+ - +
)}
diff --git a/src/pages/CollectionPage.tsx b/src/pages/CollectionPage.tsx index f7c9da6..ea6ecaa 100644 --- a/src/pages/CollectionPage.tsx +++ b/src/pages/CollectionPage.tsx @@ -40,9 +40,16 @@ const CollectionPage: React.FC = () => { const getFishImage = (fish: UserFish | null) => { if (!fish) return undefined; + + // 특정 group_code 리스트 + const fishbunGroups = ["SPFishbun", "RBFishbun", "CPFishbun"]; + try { + // 해당 그룹에 속하면 Fishbun 폴더를 참조하고, 아니면 기존처럼 group_code 폴더를 참조 + const folderName = fishbunGroups.includes(fish.group_code) ? "Fishbun" : fish.group_code; + return new URL( - `../assets/svg/FishSprites/${fish.group_code}/${fish.group_code}_1.svg`, + `../assets/svg/FishSprites/${folderName}/${fish.group_code}_${fish.maturity}.svg`, import.meta.url, ).href; } catch { @@ -52,13 +59,25 @@ const CollectionPage: React.FC = () => { const getGrowthTimelineData = (fish: UserFish | null) => { if (!fish) return []; + + const fishbunGroups = ["SPFishbun", "RBFishbun", "CPFishbun"]; + const isFishbun = fishbunGroups.includes(fish.group_code); + + // ✅ 특정 그룹이면 "Fishbun", 아니면 자기 자신의 group_code를 폴더명으로 사용 + const folderName = isFishbun ? "Fishbun" : fish.group_code; + + // ✅ Fishbun 그룹이면 3단계까지만, 아니면 6단계까지 정의 const stages = [ { name: "Hatchling", threshold: 1, suffix: "_1" }, { name: "Juvenile", threshold: 2, suffix: "_2" }, { name: "Youngling", threshold: 3, suffix: "_3" }, - { name: "Adult", threshold: 4, suffix: "_4" }, - { name: "Advanced", threshold: 5, suffix: "_5" }, - { name: "Master", threshold: 6, suffix: "_6" }, + ...(!isFishbun + ? [ + { name: "Adult", threshold: 4, suffix: "_4" }, + { name: "Advanced", threshold: 5, suffix: "_5" }, + { name: "Master", threshold: 6, suffix: "_6" }, + ] + : []), ]; return stages.map((stage) => ({ @@ -66,7 +85,8 @@ const CollectionPage: React.FC = () => { img: fish.maturity >= stage.threshold ? new URL( - `../assets/svg/FishSprites/${fish.group_code}/${fish.group_code}${stage.suffix}.svg`, + /* ✅ folderName 변수를 사용하여 경로를 동적으로 변경합니다 */ + `../assets/svg/FishSprites/${folderName}/${fish.group_code}${stage.suffix}.svg`, import.meta.url, ).href : "/images/collection/questionSquare.png", @@ -87,7 +107,7 @@ const CollectionPage: React.FC = () => { }} >
-
+