입양 모달: 랜덤 후보 3마리 중 선택

- 입양하기 → 모달에 후보 3마리 (능력치·품종 희귀도·적성·패시브 전부 랜덤)
- 패시브 배지 마우스오버 시 상세 툴팁 (이름·긍정/부정/복합 구분·효과)
- PassiveBadge 컴포넌트로 통합, 고양이 카드에서도 동일 툴팁 사용
- 모달 밖 클릭/✕로 취소 가능

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 13:46:33 +09:00
co-authored by Claude Fable 5
부모 92ffa858c3
커밋 ef7f702402
2개의 변경된 파일과 150개의 추가작업 그리고 23개의 파일을 삭제
+140 -20
파일 보기
@@ -6,18 +6,21 @@ import VillageView from "@/components/game/VillageView";
import { engine, effectiveRate, activeRecipe, fullnessPenalty } from "@/lib/game/engine"; import { engine, effectiveRate, activeRecipe, fullnessPenalty } from "@/lib/game/engine";
import { import {
APTITUDE_LABEL, APTITUDE_LABEL,
BREEDS,
HALL_LEVELS, HALL_LEVELS,
KITCHEN_BUILD_COST, KITCHEN_BUILD_COST,
KITCHEN_MEAL_CAP, KITCHEN_MEAL_CAP,
PASSIVES, PASSIVES,
TOOL_IMG, TOOL_IMG,
} from "@/lib/game/data"; } from "@/lib/game/data";
import type { AptitudeKey } from "@/lib/game/types";
import type { Building, Cat, GameState } from "@/lib/game/types"; import type { Building, Cat, GameState } from "@/lib/game/types";
export default function Game() { export default function Game() {
const { state, dispatch } = useGame(); const { state, dispatch } = useGame();
const [selectedCatId, setSelectedCatId] = useState<string | null>(null); const [selectedCatId, setSelectedCatId] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [candidates, setCandidates] = useState<Cat[] | null>(null);
if (!state) { if (!state) {
return ( return (
@@ -36,10 +39,21 @@ export default function Game() {
<div className="space-y-8"> <div className="space-y-8">
<TopBar <TopBar
state={state} state={state}
onAdopt={() => run(() => engine.adopt())} onAdopt={() => setCandidates(engine.generateCandidates())}
onUpgrade={() => run(() => engine.upgradeHall())} onUpgrade={() => run(() => engine.upgradeHall())}
/> />
{candidates && (
<AdoptModal
candidates={candidates}
onPick={(cat) => {
run(() => engine.adoptCandidate(cat));
setCandidates(null);
}}
onClose={() => setCandidates(null)}
/>
)}
<VillageView /> <VillageView />
{message && ( {message && (
@@ -96,6 +110,128 @@ export default function Game() {
); );
} }
/** 패시브 스킬 배지 — 마우스오버 시 상세 툴팁 */
function PassiveBadge({ passiveKey }: { passiveKey: string }) {
const def = PASSIVES.find((p) => p.key === passiveKey);
if (!def) return null;
const color =
def.kind === "positive"
? "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300"
: def.kind === "negative"
? "bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300"
: "bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300";
const kindLabel =
def.kind === "positive" ? "긍정" : def.kind === "negative" ? "부정" : "복합";
return (
<span className={`group relative cursor-help rounded px-1.5 py-0.5 text-[11px] ${color}`}>
{def.name}
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-1.5 hidden w-48 rounded-lg bg-gray-900 px-3 py-2 text-left text-[11px] leading-relaxed text-white shadow-lg group-hover:block dark:bg-gray-700">
<span className="mb-0.5 block font-semibold">
{def.name} <span className="font-normal opacity-70">({kindLabel})</span>
</span>
{def.desc}
<span className="absolute left-3 top-full border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
</span>
</span>
);
}
/** 입양 모달 — 후보 3마리 중 1마리 선택 */
function AdoptModal({
candidates,
onPick,
onClose,
}: {
candidates: Cat[];
onPick: (cat: Cat) => void;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">😻 입양할 고양이를 선택하세요</h2>
<button
onClick={onClose}
className="rounded-lg px-3 py-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
✕
</button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{candidates.map((cat) => {
const breed = BREEDS.find((b) => b.key === cat.breed);
const aptitudes = (Object.keys(APTITUDE_LABEL) as AptitudeKey[]).filter(
(k) => cat.aptitude[k] > 0,
);
return (
<div
key={cat.id}
className="flex flex-col rounded-xl border border-gray-200 p-4 dark:border-gray-700"
>
<div className="mb-2 text-center">
<div className="text-4xl">{cat.emoji}</div>
<div className="mt-1 font-bold">{cat.name}</div>
<div className="text-xs text-gray-400">
{cat.breedName}{" "}
<span className="text-amber-400">
{"★".repeat(breed?.rarity ?? 1)}
</span>
</div>
</div>
<div className="mb-2 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-relaxed dark:bg-gray-800">
💪 근면 {cat.stats.diligence} · ❤️ 체력 {cat.stats.stamina} · 🍽️
식욕 {cat.stats.appetite}
</div>
<div className="mb-2 flex flex-wrap gap-1">
{aptitudes.length > 0 ? (
aptitudes.map((k) => (
<span
key={k}
className="rounded bg-sky-50 px-1.5 py-0.5 text-[11px] text-sky-700 dark:bg-sky-950 dark:text-sky-300"
>
{APTITUDE_LABEL[k]} {cat.aptitude[k]}
</span>
))
) : (
<span className="text-[11px] text-gray-400">적성 없음…</span>
)}
</div>
<div className="mb-3 flex min-h-6 flex-wrap gap-1">
{cat.passives.length > 0 ? (
cat.passives.map((key) => <PassiveBadge key={key} passiveKey={key} />)
) : (
<span className="text-[11px] text-gray-400">패시브 없음</span>
)}
</div>
<button
onClick={() => onPick(cat)}
className="mt-auto rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
>
이 아이로 할래요
</button>
</div>
);
})}
</div>
<p className="mt-4 text-center text-xs text-gray-400">
한 마리만 선택할 수 있어요 · 능력치와 패시브에 마우스를 올리면 상세 정보가 보입니다
</p>
</div>
</div>
);
}
function TopBar({ function TopBar({
state, state,
onAdopt, onAdopt,
@@ -335,25 +471,9 @@ function CatCard({
{cat.passives.length > 0 && ( {cat.passives.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{cat.passives.map((key) => { {cat.passives.map((key) => (
const def = PASSIVES.find((p) => p.key === key); <PassiveBadge key={key} passiveKey={key} />
if (!def) return null; ))}
const color =
def.kind === "positive"
? "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300"
: def.kind === "negative"
? "bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300"
: "bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300";
return (
<span
key={key}
title={def.desc}
className={`rounded px-1.5 py-0.5 text-[11px] ${color}`}
>
{def.name}
</span>
);
})}
</div> </div>
)} )}
+10 -3
파일 보기
@@ -243,10 +243,17 @@ export const engine = {
state.updatedAt = Date.now(); state.updatedAt = Date.now();
}, },
adopt(): string | null { /** 입양 후보 생성 (아직 마을에 합류하지 않음) */
generateCandidates(count = 3): Cat[] {
return Array.from({ length: count }, () => generateCat());
},
/** 후보 중 한 마리 입양 확정 */
adoptCandidate(cat: Cat): string | null {
if (!state) return null; if (!state) return null;
if (state.cats.length >= state.hall.capacity) return "수용 인원이 가득 찼습니다 (회관 증축 필요)"; if (state.cats.length >= state.hall.capacity)
state.cats.push(generateCat()); return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
state.cats.push(cat);
return null; return null;
}, },