Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/assets/png/Backgrounds/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"Bg Ocean": bg1,
Expand Down
66 changes: 52 additions & 14 deletions src/components/CollectionPage/GrowthTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";

interface FishStage {
stage: string;
Expand All @@ -9,20 +9,58 @@ interface GrowthTimelineProps {
fishList: FishStage[];
}

const GrowthTimeline: React.FC<GrowthTimelineProps> = ({ fishList }) => (
<div className="grid grid-cols-3 gap-6">
{fishList.map((fish, idx) => (
<div key={idx} className="flex flex-col items-center">
<div
className="flex h-24 w-24 items-center justify-center rounded-xl border border-[#89482D] bg-[#E6D3B3]"
style={{ boxShadow: "-4px 4px 6px 0 rgba(0, 0, 0, 0.3) inset" }}
>
<img src={fish.img} alt={fish.stage} className="h-13 w-13 object-contain" />
// 개별 단계를 렌더링하는 컴포넌트 (SVG 애니메이션 활성화용)
const GrowthStageItem: React.FC<{ fish: FishStage }> = ({ fish }) => {
const [svgContent, setSvgContent] = useState<string>("");
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 (
<div className="flex flex-col items-center">
<div
className="flex h-20 w-20 items-center justify-center rounded-xl border border-[#89482D] bg-[#E6D3B3] sm:h-24 sm:w-24"
style={{ boxShadow: "-4px 4px 6px 0 rgba(0, 0, 0, 0.3) inset" }}
>
<div className="relative flex h-full w-full items-center justify-center p-2">
{isQuestion ? (
<img src={fish.img} alt={fish.stage} className="h-10 w-10 object-contain opacity-80" />
) : (
<div
className="pointer-events-none h-full w-full object-contain"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
)}
</div>
<span className="font-vt323 mt-2 text-[1.4rem] text-[#89482D]">{fish.stage}</span>
</div>
))}
</div>
);
<span className="font-vt323 mt-2 text-[1.1rem] text-[#89482D] sm:text-[1.4rem]">
{fish.stage}
</span>
</div>
);
};

const GrowthTimeline: React.FC<GrowthTimelineProps> = ({ fishList }) => {
return (
<div className="flex w-full items-center justify-center" style={{ height: "300px" }}>
<div className="grid grid-cols-3 gap-6">
{fishList.map((fish, idx) => (
<GrowthStageItem key={`${fish.stage}-${idx}`} fish={fish} />
))}
</div>
</div>
);
};

export default GrowthTimeline;
104 changes: 85 additions & 19 deletions src/components/CollectionPage/fishCage.tsx
Original file line number Diff line number Diff line change
@@ -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<FishCageProps> = ({ 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<string>("");
const [pos, setPos] = useState({ x: 50, y: 55 });
const [facing, setFacing] = useState(1);
const isQuestion = fish?.includes("questionSquare");

return (
<div className="relative flex h-35 w-35 items-center justify-center">
{/* 어항 배경 */}
<img src="/images/collection/fishcagecut.png" alt="fish cage" className="h-35 w-35" />
// 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 (
<div className="relative flex h-36 w-36 items-center justify-center">
{/* 어항 틀 */}
<img
src={fish ? fish : "/images/collection/questionSquare.png"}
alt="fish"
className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 object-contain transition-all duration-200 ${imageClass}`}
src="/images/collection/fishcagecut.png"
alt="cage"
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
/>

{/* 물고기 영역 */}
<div
className={`pointer-events-none absolute flex items-center justify-center transition-all duration-1000 ease-in-out ${
isSelected ? "z-10 h-20 w-20" : "z-0 h-12 w-12"
}`}
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: `translate(-50%, -50%) scaleX(${facing})`,
}}
>
{isQuestion ? (
<img
src={fish}
alt="locked"
className="h-full w-full object-contain opacity-30 grayscale"
/>
) : (
<div className="h-full w-full" dangerouslySetInnerHTML={{ __html: svgContent }} />
)}
</div>
</div>
);
};
Expand Down
74 changes: 44 additions & 30 deletions src/components/CollectionPage/fishGrowthCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -26,22 +26,35 @@ const FishGrowthCard: React.FC<FishGrowthCardProps> = ({
starImg = "/images/shop/starfish.png",
fishList,
}) => {
const [mainSvg, setMainSvg] = useState<string>("");
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<RepoInfo[]>(() => {
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 (
Expand All @@ -51,13 +64,11 @@ const FishGrowthCard: React.FC<FishGrowthCardProps> = ({

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);
}
Expand All @@ -73,42 +84,45 @@ const FishGrowthCard: React.FC<FishGrowthCardProps> = ({
backgroundSize: "contain",
}}
>
<div className="flex h-full w-full flex-col items-center justify-center px-6 py-6">
<div className="flex h-full w-full flex-col items-center justify-center px-6 pt-8 pb-8">
{isEmpty ? (
<div className="flex h-full flex-1 items-center justify-center">
<span className="font-bungee text-center text-[2rem] tracking-widest text-[#89482D]">
Choose a fish!
</span>
</div>
) : (
<>
<div className="mb-8 flex w-full items-center justify-center gap-4">
<div className="flex flex-col items-center gap-8">
<div className="font-bungee text-[2.2rem] tracking-widest text-[#89482D]">
{selectedFishGroup.group_code}
</div>

<div className="flex w-full items-center justify-center gap-4 px-8">
<img src={mainImg} alt="fish" className="h-20 w-20 object-contain" />
<div className="flex w-full flex-col items-center">
<div className="mb-4 flex flex-col items-center gap-4 sm:gap-6">
<div className="font-bungee text-[1.8rem] tracking-widest text-[#89482D] sm:text-[2.2rem]">
{selectedFishGroup.group_code}
</div>

<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<div>
<RepoSelect
value={currentRepoValue}
onChange={handleRepoChange}
customRepos={availableRepos} // 추가된 props
/>
</div>
<div className="flex items-center justify-center gap-4 px-4 sm:px-8">
{/* 메인 이미지 영역: 애니메이션 적용 */}
<div className="flex h-20 w-20 items-center justify-center sm:h-25 sm:w-25">
{mainSvg ? (
<div className="h-full w-full" dangerouslySetInnerHTML={{ __html: mainSvg }} />
) : (
<img src={mainImg} alt="fish" className="h-full w-full object-contain" />
)}
</div>

<div className="flex items-center gap-2 text-[1.5rem] text-[#89482D]">
Rarity: <img src={starImg} alt="star" className="h-6 w-6" /> 1
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<RepoSelect
value={currentRepoValue}
onChange={handleRepoChange}
customRepos={availableRepos}
/>
<div className="flex items-center gap-2 pl-1 text-[1.2rem] text-[#89482D] sm:text-[1.5rem]">
Rarity: <img src={starImg} alt="star" className="h-5 w-5 sm:h-6 sm:w-6" /> 1
</div>
</div>
</div>
</div>

<GrowthTimeline fishList={fishList ?? []} />
</>
</div>
)}
</div>
</div>
Expand Down
32 changes: 26 additions & 6 deletions src/pages/CollectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -52,21 +59,34 @@ 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) => ({
stage: stage.name,
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",
Expand All @@ -87,7 +107,7 @@ const CollectionPage: React.FC = () => {
}}
>
<Header />
<main className="mx-auto flex max-w-6xl flex-col gap-12 px-6 pt-28 pb-20">
<main className="mx-auto flex max-w-6xl flex-col gap-12 px-6 pt-28 pb-28">
<LogoText
text="COLLECTION"
className="font-Bungee text-shadow mb-12 text-center text-6xl drop-shadow-[0_3px_0_rgba(0,0,0,0.25)]"
Expand Down