입양 모달: 랜덤 후보 3마리 중 선택
- 입양하기 → 모달에 후보 3마리 (능력치·품종 희귀도·적성·패시브 전부 랜덤) - 패시브 배지 마우스오버 시 상세 툴팁 (이름·긍정/부정/복합 구분·효과) - PassiveBadge 컴포넌트로 통합, 고양이 카드에서도 동일 툴팁 사용 - 모달 밖 클릭/✕로 취소 가능 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단