파일
project/src/components/game/Game.tsx
T
kjs그리고Claude Fable 5 9a37cc5e6c 인트로 화면: 타이틀 일러스트 + 구름 걷힘 연출
- 접속 시 intro.png 타이틀 화면, 중앙 하단 둥실거리는 시작하기 버튼
- 시작하기 클릭: 인트로가 1초간 투명해지며 아래 깔린 구름 레이어가
  드러나고, 구름 두 장이 양옆으로 1.9초간 걷히며 마을 진입
- 구름은 CSS로 구성 (블러 처리한 본체 + 안쪽 가장자리 뭉게 퍼프 7개)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:56:43 +09:00

1490 라인
56 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useGame } from "@/hooks/useGame";
import VillageView, { type PlacingState } from "@/components/game/VillageView";
import DbStatus from "@/components/DbStatus";
import NicknameModal from "@/components/game/NicknameModal";
import ChatTicker from "@/components/game/ChatTicker";
import ChatPanel from "@/components/game/ChatPanel";
import IntroScreen from "@/components/game/IntroScreen";
import {
engine,
effectiveRate,
activeRecipe,
adoptCost,
fullnessPenalty,
isUnderConstruction,
storageCap,
} from "@/lib/game/engine";
import {
APTITUDE_LABEL,
BREEDS,
BUILD_DEFS,
BUILDING_IMG,
catSprite,
DECO_DEFS,
HALL_LEVELS,
ICON_IMG,
KITCHEN_UPGRADE_COST,
MEAL_PRICES,
mealCap,
REROLL_COST,
SELL_PRICES,
PASSIVES,
RECIPES,
TOOL_IMG,
type BuildDef,
} from "@/lib/game/data";
import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types";
const RES_EMOJI: Record<string, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
// ---------- 튜토리얼 ----------
interface TutStep {
text: string;
task?: string;
check?: (s: GameState) => boolean;
manual?: boolean; // 버튼으로 진행
}
const TUTORIAL_STEPS: TutStep[] = [
{
text: "안녕! 난 이 마을의 촌장 냥이야. 여긴 아직 아무것도 없는 들판이지만... 우리 함께 아늑한 고양이 마을을 만들어보자!",
manual: true,
},
{
text: "먼저 첫 친구를 데려오자. 오른쪽 위 😻 입양 버튼을 눌러서 마음에 드는 아이를 골라줘. 낚시를 잘하는 친구면 좋겠다!",
task: "고양이 1마리 입양하기",
check: (s) => s.cats.length >= 1,
},
{
text: "이제 먹을거리를 구해야지. 🛒 상점에서 부두를 골라 강물 위에 놓아줘. 첫 건물이니까 공짜야!",
task: "부두 건설하기 (강물 위)",
check: (s) => s.buildings.some((b) => b.type === "dock"),
},
{
text: "뚝딱뚝딱... 부두가 완성되면 부두를 눌러서 친구를 배치해줘. 생선이 모이면 배지를 눌러 거둬들이는 것도 잊지 마!",
task: "부두에 고양이 배치하기",
check: (s) => {
const d = s.buildings.find((b) => b.type === "dock");
return !!d && d.constructionUntil === undefined && d.assigned.length >= 1;
},
},
{
text: "생선이 모이는 동안 친구를 한 마리 더 데려오자! 이번엔 벌목을 잘하는 아이면 좋겠어.",
task: "고양이 2마리째 입양하기",
check: (s) => s.cats.length >= 2,
},
{
text: "이제 벌목장을 짓자! 재료로 생선 30마리가 필요한데... 짜잔, 내가 미리 준비해뒀어. 🛒 상점에서 벌목장을 사서 마음에 드는 곳에 놓아줘!",
task: "벌목장 건설하기 (🐟 30 — 촌장 냥이가 지원!)",
check: (s) => s.buildings.some((b) => b.type === "lumberyard"),
},
{
text: "벌목장이 완성되면 벌목 잘하는 친구를 배치해줘!",
task: "벌목장에 고양이 배치하기",
check: (s) => {
const l = s.buildings.find((b) => b.type === "lumberyard");
return !!l && l.constructionUntil === undefined && l.assigned.length >= 1;
},
},
{
text: "일하다 보면 배가 고파지지. 부엌을 짓자 — 목재 60은 내가 지원해줄게! 요리 잘하는 친구를 배치해두면 다들 알아서 밥을 챙겨 먹는단다.",
task: "부엌 건설하기 (🪵 60 — 촌장 냥이가 지원!)",
check: (s) => s.buildings.some((b) => b.type === "kitchen"),
},
{
text: "마지막이야! 증축 재료(목재 100·생선 80)는 마을 발전 기금에서 내가 쏠게. 열심히 일한 친구에겐 특훈까지 시켜뒀단다. 회관을 눌러 Lv2로 증축해봐!",
task: "마을 회관 Lv2 증축하기",
check: (s) => s.hall.level >= 2,
},
{
text: "축하해! 이제 이 마을은 온전히 네 거야. 아 참 — 정착 지원금으로 냥코인 🪙200을 문패 아래 두고 갈게. 새 친구를 데려오거나 장터를 짓는 데 쓰렴! 난 회관에 있을게 🐾",
manual: true,
},
];
const TUTORIAL_DONE = 99;
type Panel =
| { type: "building"; id: string }
| { type: "hall" }
| { type: "shop" }
| { type: "cats" }
| { type: "deco"; id: string }
| { type: "chat" }
| null;
export default function Game() {
const { state, dispatch } = useGame();
const [panel, setPanel] = useState<Panel>(null);
const [showIntro, setShowIntro] = useState(true);
const [candidates, setCandidates] = useState<Cat[] | null>(null);
const [placing, setPlacing] = useState<PlacingState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const msgTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const run = (action: () => string | null | void) => {
const error = dispatch(action);
setMessage(error ?? null);
if (msgTimer.current) clearTimeout(msgTimer.current);
if (error) msgTimer.current = setTimeout(() => setMessage(null), 3500);
return error;
};
useEffect(() => () => {
if (msgTimer.current) clearTimeout(msgTimer.current);
}, []);
// 튜토리얼 자동 진행 (조건 충족 시)
useEffect(() => {
if (!state) return;
const step = state.tutorialStep ?? TUTORIAL_DONE;
if (step >= TUTORIAL_STEPS.length) return;
const def = TUTORIAL_STEPS[step];
if (def.check && def.check(state)) {
dispatch(() => engine.setTutorialStep(step + 1));
}
}, [state, dispatch]);
// 오프라인 정산 알림 (1회)
const offlineShown = useRef(false);
useEffect(() => {
if (!state || offlineShown.current) return;
offlineShown.current = true;
const mins = engine.consumeOfflineReport();
if (mins) {
setMessage(
`🌙 ${mins >= 60 ? `${Math.floor(mins / 60)}시간 ${mins % 60}분` : `${mins}분`} 동안 자리를 비웠어요 — 마을은 계속 돌아갔답니다!`,
);
msgTimer.current = setTimeout(() => setMessage(null), 6000);
}
}, [state]);
if (!state) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-[#f6eeda] font-jua text-lg text-[#8a7551]">
마을 불러오는 중…
</div>
);
}
const meals = state.buildings
.filter((b) => b.type === "kitchen")
.reduce((sum, b) => sum + Math.floor(b.stored), 0);
const openedBuilding =
panel?.type === "building"
? state.buildings.find((b) => b.id === panel.id)
: undefined;
// 튜토리얼: 지금 눌러야 할 곳 하이라이트 타깃
const tutStep = state.tutorialStep ?? TUTORIAL_DONE;
const tutTarget: string | null = (() => {
if (tutStep >= TUTORIAL_STEPS.length) return null;
const dock = state.buildings.find((b) => b.type === "dock");
const lumber = state.buildings.find((b) => b.type === "lumberyard");
switch (tutStep) {
case 1:
case 4:
return candidates ? "adopt-pick" : "adopt";
case 2:
return panel?.type === "shop" ? "shop-dock" : placing ? null : "shop";
case 3:
if (!dock) return null;
if (panel?.type === "building" && panel.id === dock.id) return "assign";
return `canvas:${dock.id}`;
case 5:
return panel?.type === "shop" ? "shop-lumberyard" : placing ? null : "shop";
case 6:
if (!lumber) return null;
if (panel?.type === "building" && panel.id === lumber.id) return "assign";
return `canvas:${lumber.id}`;
case 7:
return panel?.type === "shop" ? "shop-kitchen" : placing ? null : "shop";
case 8:
return panel?.type === "hall" ? "hall-upgrade" : "canvas:hall";
default:
return null;
}
})();
const tut = (key: string) => (tutTarget === key ? " tut-highlight" : "");
return (
<div className="fixed inset-0 overflow-hidden">
{/* ── 인트로 (시작하기 → 구름 걷힘) ── */}
{showIntro && <IntroScreen onDone={() => setShowIntro(false)} />}
<VillageView
onBuildingClick={(id) => {
if (id === "hall") setPanel({ type: "hall" });
else if (id.startsWith("deco:")) setPanel({ type: "deco", id: id.slice(5) });
else setPanel({ type: "building", id });
}}
placing={placing}
onPlace={(i, j) => {
if (!placing) return;
if (placing.deco) {
run(() => engine.placeDeco(placing.deco!, { i, j }));
return; // 연속 배치 (ESC/취소로 종료)
}
const error = placing.moveId
? run(() => engine.moveBuilding(placing.moveId!, { i, j }))
: run(() => engine.build(placing.type as BuildDef["type"], { i, j }));
if (!error) setPlacing(null);
}}
onCancelPlace={() => setPlacing(null)}
tutHighlight={
tutTarget?.startsWith("canvas:") ? tutTarget.slice(7) : null
}
/>
{/* ── 실시간 마을 채팅 티커 (닉네임 확정 후) ── */}
{state.nickname && state.syncToken && (
<ChatTicker
onOpen={() =>
setPanel(panel?.type === "chat" ? null : { type: "chat" })
}
/>
)}
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
{!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && (
<NicknameModal onDone={() => dispatch(() => undefined)} />
)}
{/* ── 배치 모드 힌트 ── */}
{placing && (
<div className="wood-plank font-jua absolute bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 px-5 py-2.5 text-sm">
📍 {placing.deco
? "꾸밀 타일을 클릭하세요 (연속 배치 가능)"
: `${placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요`}
{placing.type === "dock" && " (부두는 물가에만)"}
<button
onClick={() => setPlacing(null)}
className="wood-pill px-3 py-1 text-xs hover:brightness-125"
>
취소 (ESC)
</button>
</div>
)}
{/* ── 상단 HUD (어두운 나무 바) ── */}
<div className="nature-topbar pointer-events-none absolute inset-x-0 top-0 z-30 h-[52px]">
<div className="flex h-full items-start justify-between gap-2 px-4">
{/* 매달린 나무 판자 타이틀 */}
<div className="pointer-events-auto relative mt-0 flex items-start gap-3.5">
<button
onClick={() => setPanel({ type: "hall" })}
className="wood-plank relative mt-1 flex items-center gap-2 px-4 py-2"
>
<span className="absolute -top-1 left-4 h-4 w-[3px] -rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
<span className="absolute -top-1 right-4 h-4 w-[3px] rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
<span className="font-jua text-[17px] tracking-wide [text-shadow:0_1px_2px_rgba(0,0,0,.45)]">
🏡 {state.nickname ? `${state.nickname}의 마을` : "고양이 마을"} · 회관 Lv{state.hall.level}
</span>
</button>
{/* 자원 pill */}
<div className="mt-2 flex flex-wrap items-center gap-2">
{(
[
["🐱", null, `${state.cats.length}/${state.hall.capacity}`],
["🐟", ICON_IMG.fish, Math.floor(state.resources.fish)],
["🪵", ICON_IMG.wood, Math.floor(state.resources.wood)],
["🪨", ICON_IMG.stone, Math.floor(state.resources.stone)],
["🍖", ICON_IMG.meal, meals],
["🪙", ICON_IMG.coin, Math.floor(state.coins ?? 0)],
] as Array<[string, string | null, string | number]>
).map(([emoji, img, value]) => (
<span
key={emoji}
className="wood-pill flex items-center gap-1.5 py-1 pl-1 pr-3"
>
<span className="wood-pill-icon grid h-6 w-6 place-items-center text-[13px]">
{img ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={img} alt="" className="h-4 w-4 object-contain" />
) : (
emoji
)}
</span>
<span className="font-num text-sm leading-none">{value}</span>
</span>
))}
</div>
</div>
<div className="pointer-events-auto mt-2 flex gap-2">
<button
onClick={() =>
setPanel(panel?.type === "shop" ? null : { type: "shop" })
}
className={"wood-pill font-jua flex items-center gap-1.5 px-4 py-2 text-[15px] hover:brightness-125" + tut("shop")}
>
🛒 상점
</button>
<button
onClick={() =>
setPanel(panel?.type === "cats" ? null : { type: "cats" })
}
className="wood-pill font-jua flex items-center gap-1.5 px-4 py-2 text-[15px] hover:brightness-125"
>
🐾 고양이
</button>
<button
onClick={() => {
const needCook =
state.buildings.some((b) => b.type === "kitchen") &&
!state.cats.some((c) => c.aptitude.cooking > 0);
setCandidates(
engine.generateCandidates(
3,
state.tutorialStep === 1
? "fishing"
: state.tutorialStep === 4
? "logging"
: needCook
? "cooking"
: undefined,
),
);
}}
disabled={state.cats.length >= state.hall.capacity}
className={"nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50" + tut("adopt")}
>
😻 입양
</button>
</div>
</div>
</div>
{/* ── 메시지 토스트 ── */}
{message && (
<div className="wood-plank font-jua absolute left-1/2 top-16 z-50 -translate-x-1/2 px-5 py-2 text-sm">
⚠️ {message}
</div>
)}
{/* ── 촌장 냥이 튜토리얼 가이드 ── */}
{(state.tutorialStep ?? TUTORIAL_DONE) < TUTORIAL_STEPS.length && (() => {
const step = state.tutorialStep ?? 0;
const def = TUTORIAL_STEPS[step];
const isLast = step === TUTORIAL_STEPS.length - 1;
return (
<div className="pointer-events-none absolute bottom-8 left-4 z-40 flex max-w-md items-end gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={catSprite("korean_shorthair", "idle")}
alt="촌장 냥이"
className="h-24 w-24 shrink-0 object-contain drop-shadow-lg"
/>
<div className="panel-paper pointer-events-auto relative mb-2 rounded-2xl p-4 shadow-xl">
<div className="font-jua text-[13px] text-[#8a6316]">
🎗️ 촌장 냥이 <span className="font-num text-[11px] text-[#a3906c]">({Math.min(step + 1, TUTORIAL_STEPS.length)}/{TUTORIAL_STEPS.length})</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-[#3f2c14]">{def.text}</p>
{def.task && (
<div className="chip-nature mt-2 inline-block px-2.5 py-1 text-xs font-semibold">
📌 {def.task}
</div>
)}
{def.manual && (
<button
onClick={() =>
dispatch(() =>
engine.setTutorialStep(isLast ? TUTORIAL_DONE : step + 1),
)
}
className="nature-btn mt-3 block px-5 py-1.5 text-sm"
>
{isLast ? "고마워, 촌장 냥이! 🐾" : "좋아, 시작하자!"}
</button>
)}
</div>
</div>
);
})()}
{/* ── 촌장 냥이: 행복도 시스템 안내 (해금 시 1회) ── */}
{state.happinessUnlocked &&
!state.happinessIntroSeen &&
(state.tutorialStep ?? TUTORIAL_DONE) >= TUTORIAL_STEPS.length && (
<div className="pointer-events-none absolute bottom-8 left-4 z-40 flex max-w-md items-end gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={catSprite("korean_shorthair", "idle")}
alt="촌장 냥이"
className="h-24 w-24 shrink-0 object-contain drop-shadow-lg"
/>
<div className="panel-paper pointer-events-auto relative mb-2 rounded-2xl p-4 shadow-xl">
<div className="font-jua text-[13px] text-[#8a6316]">🎗️ 촌장 냥이</div>
<p className="mt-1 text-sm leading-relaxed text-[#3f2c14]">
오랜만이야! 마을이 근사해졌네. 햇볕 데크도 생겼으니 이제
친구들의 <b>💗 행복도</b>를 챙길 때야. 배부르게 먹고 푹 쉬면
행복해지고, 굶거나 고양이 집이 모자라면 시무룩해져. 행복도가
너무 낮으면 <b>파업</b>할 수도 있으니 잘 돌봐줘! 80 이상이면
일도 10% 빨라진단다.
</p>
<button
onClick={() => dispatch(() => engine.markHappinessIntroSeen())}
className="nature-btn mt-3 block px-5 py-1.5 text-sm"
>
알겠어, 잘 돌볼게! 💗
</button>
</div>
</div>
)}
{/* ── DB 상태 (좌하단 미니) ── */}
<div className="absolute bottom-2 left-3 z-30 opacity-70">
<DbStatus />
</div>
{/* ── 우측 패널 ── */}
{panel?.type === "chat" && (
<SidePanel title="💬 마을 채팅" onClose={() => setPanel(null)}>
<ChatPanel nickname={state.nickname ?? ""} token={state.syncToken ?? ""} />
</SidePanel>
)}
{panel?.type === "hall" && (
<SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}>
<HallPanel
state={state}
onUpgrade={() => run(() => engine.upgradeHall())}
highlightUpgrade={tutTarget === "hall-upgrade"}
/>
</SidePanel>
)}
{panel?.type === "shop" && (
<SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}>
<ShopPanel
state={state}
highlightType={
tutTarget?.startsWith("shop-") ? tutTarget.slice(5) : null
}
onBuy={(def) => {
setPanel(null);
setPlacing({ type: def.type, aptitude: def.aptitude });
}}
onBuyDeco={(key) => {
setPanel(null);
setPlacing({ deco: key });
}}
/>
</SidePanel>
)}
{openedBuilding && (
<SidePanel
title={
BUILDING_IMG[openedBuilding.type]
? `${openedBuilding.name} Lv${openedBuilding.level}`
: `${openedBuilding.emoji} ${openedBuilding.name} Lv${openedBuilding.level}`
}
icon={BUILDING_IMG[openedBuilding.type]}
onClose={() => setPanel(null)}
>
<BuildingPanel
building={openedBuilding}
state={state}
onAssign={(catId) => run(() => engine.assign(catId, openedBuilding.id))}
onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(openedBuilding.id))}
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
highlightAssign={tutTarget === "assign"}
onRush={() => run(() => engine.rushConstruction(openedBuilding.id))}
onSetSell={(key) => run(() => engine.setSellKey(openedBuilding.id, key))}
onMove={() => {
setPanel(null);
setPlacing({
type: openedBuilding.type,
aptitude: openedBuilding.aptitude,
moveId: openedBuilding.id,
});
}}
/>
</SidePanel>
)}
{panel?.type === "deco" && (() => {
const deco = state.decors?.find((d) => d.id === panel.id);
const def = deco ? DECO_DEFS.find((x) => x.key === deco.kind) : undefined;
if (!deco || !def) return null;
return (
<SidePanel title={`🌿 ${def.name}`} onClose={() => setPanel(null)}>
<div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={def.img} alt={def.name} className="mx-auto h-24 object-contain" />
<p className="text-center text-sm text-[#6f5c39]">마을을 꾸미는 바닥 장식이에요.</p>
<button
onClick={() => {
run(() => engine.removeDeco(deco.id));
setPanel(null);
}}
className="wood-plank w-full py-2 text-sm hover:brightness-110"
>
🗑 치우기
</button>
</div>
</SidePanel>
);
})()}
{panel?.type === "cats" && (
<SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}>
<div className="space-y-3">
{state.cats.map((cat) => (
<CatCard
key={cat.id}
cat={cat}
buildings={state.buildings}
happinessUnlocked={state.happinessUnlocked}
onInvest={(catId, key) => run(() => engine.investAptitude(catId, key))}
/>
))}
</div>
</SidePanel>
)}
{/* ── 입양 모달 ── */}
{candidates && (
<AdoptModal
candidates={candidates}
highlightPick={tutTarget === "adopt-pick"}
cost={adoptCost(state)}
coins={Math.floor(state.coins ?? 0)}
rerollUnlocked={state.buildings.some(
(b) => b.type === "market" && b.constructionUntil === undefined,
)}
onReroll={() => {
const err = run(() => engine.payReroll());
if (!err) {
const needCook =
state.buildings.some((b) => b.type === "kitchen") &&
!state.cats.some((c) => c.aptitude.cooking > 0);
setCandidates(
engine.generateCandidates(
3,
state.tutorialStep === 1
? "fishing"
: state.tutorialStep === 4
? "logging"
: needCook
? "cooking"
: undefined,
),
);
}
}}
onPick={(cat) => {
const err = run(() => engine.adoptCandidate(cat));
if (!err) setCandidates(null);
}}
onClose={() => setCandidates(null)}
/>
)}
</div>
);
}
/* ──────────────── 공통 패널 셸 ──────────────── */
function SidePanel({
title,
icon,
onClose,
children,
}: {
title: string;
icon?: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<aside className="panel-wood absolute bottom-3 right-3 top-15 z-40 w-full max-w-sm p-2.5">
{/* 잎사귀 장식 */}
<span className="leaf left-2 top-14 h-6 w-6 rotate-[-28deg]" />
<span className="leaf leaf-bright left-1 top-20 h-4 w-4 rotate-24" />
<span className="leaf right-2 top-52 h-5 w-5 rotate-140" />
<span className="leaf leaf-bright bottom-2 left-24 h-5 w-5 rotate-[-140deg]" />
<span className="leaf leaf-bright bottom-1.5 right-32 h-4 w-4 rotate-[-200deg]" />
<div className="panel-paper relative flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[rgba(126,95,48,.18)] px-5 py-3.5">
<h2 className="font-jua flex items-center gap-2 text-[19px] text-[#4a3418]">
{icon && (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={icon} alt="" className="h-8 w-8 object-contain" />
)}
{title}
</h2>
<button
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full bg-linear-to-b from-[#e8d6ae] to-[#cdb488] text-sm text-[#6b4a20] shadow hover:brightness-105"
>
✕
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">{children}</div>
</div>
</aside>
);
}
/* ──────────────── 회관 패널 ──────────────── */
function HallPanel({
state,
onUpgrade,
highlightUpgrade,
}: {
state: GameState;
onUpgrade: () => void;
highlightUpgrade?: boolean;
}) {
const next = HALL_LEVELS[state.hall.level + 1];
return (
<div className="space-y-4">
<p className="text-sm text-[#6f5c39]">
마을의 중심. 회관 레벨이 수용 인원과 건물·레시피 해금을 결정합니다.
</p>
<div className="card-nature px-4 py-3 text-sm">
현재 수용 인원 <b>{state.cats.length}/{state.hall.capacity}마리</b>
</div>
{next ? (
<div className="card-nature p-4">
<h3 className="font-jua mb-2 text-[16px] text-[#4a3418]">
Lv{state.hall.level + 1} 증축 → 수용 {next.capacity}마리
</h3>
<ul className="mb-3 space-y-1 text-sm text-[#6f5c39]">
{Object.entries(next.cost).map(([key, amount]) => {
const have = Math.floor(
state.resources[key as keyof typeof state.resources],
);
const ok = have >= (amount ?? 0);
return (
<li key={key} className={ok ? "text-[#5c8a2a]" : ""}>
{ok ? "✓" : "·"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
</li>
);
})}
{(next.reqBuildings ?? []).map((req) => {
const b = state.buildings.find((x) => x.type === req.type);
const ok = Boolean(b && b.level >= (req.level ?? 1));
return (
<li key={req.label} className={ok ? "text-[#5c8a2a]" : ""}>
{ok ? "✓" : "·"} {req.label}
</li>
);
})}
{next.reqCat && (
<li
className={
state.cats.filter((c) => c.level >= next.reqCat!.level).length >=
next.reqCat.count
? "text-emerald-600 dark:text-emerald-400"
: ""
}
>
{state.cats.filter((c) => c.level >= next.reqCat!.level).length >=
next.reqCat.count
? "✓"
: "·"}{" "}
Lv{next.reqCat.level} 이상 고양이{" "}
{state.cats.filter((c) => c.level >= next.reqCat!.level).length}/
{next.reqCat.count}마리
</li>
)}
</ul>
{next.locked ? (
<p className="chip-nature px-3 py-2 text-xs">
🔒 {next.locked}
</p>
) : (
<button
onClick={onUpgrade}
className={"nature-btn w-full py-2 text-sm" + (highlightUpgrade ? " tut-highlight" : "")}
>
🏗️ 증축하기
</button>
)}
</div>
) : (
<p className="text-sm text-[#a3906c]">회관이 최고 레벨입니다.</p>
)}
</div>
);
}
/* ──────────────── 건물 상점 ──────────────── */
function ShopPanel({
state,
onBuy,
onBuyDeco,
highlightType,
}: {
state: GameState;
onBuy: (def: BuildDef) => void;
onBuyDeco: (key: string) => void;
highlightType?: string | null;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-[#8a7551]">
구매 후 마을의 원하는 타일을 클릭해 배치합니다. 비용은 배치 확정 시 차감돼요.
</p>
<h3 className="font-jua text-sm text-[#8a7551]">🏗️ 건물</h3>
{BUILD_DEFS.map((def) => {
const built = state.buildings.some((b) => b.type === def.type);
const hallOk = state.hall.level >= def.reqHallLv;
const costEntries = Object.entries(def.cost) as Array<[string, number]>;
const affordable = costEntries.every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= amount,
);
return (
<div
key={def.type}
className="card-nature flex items-center gap-3 p-3"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{BUILDING_IMG[def.type] ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={BUILDING_IMG[def.type]}
alt={def.name}
className={`h-14 w-14 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
/>
) : (
<span className={`grid h-14 w-14 shrink-0 place-items-center text-4xl ${built || !hallOk ? "opacity-40" : ""}`}>
{def.emoji}
</span>
)}
<div className="min-w-0 flex-1">
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
<div className="font-num text-xs text-[#8a6316]">
{costEntries.length > 0
? costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")
: "무료"}
{` · ⏱ ${def.buildTime}초`}
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
</div>
<div className="mt-0.5 line-clamp-2 text-[11px] text-[#8a7551]">{def.desc}</div>
</div>
{built ? (
<span className="chip-nature shrink-0 px-3 py-1.5 text-xs">
보유 중
</span>
) : (
<button
onClick={() => onBuy(def)}
disabled={!hallOk || !affordable}
className={"amber-btn shrink-0 px-3.5 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-40" + (highlightType === def.type ? " tut-highlight" : "")}
>
{!hallOk ? `회관 Lv${def.reqHallLv}` : !affordable ? "자원 부족" : "구매"}
</button>
)}
</div>
);
})}
<h3 className="font-jua mt-4 text-sm text-[#8a7551]">🌿 꾸미기 (여러 개 배치 가능)</h3>
<div className="grid grid-cols-3 gap-2">
{DECO_DEFS.map((def) => {
const affordable = Object.entries(def.cost).every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= (amount ?? 0),
);
return (
<button
key={def.key}
onClick={() => onBuyDeco(def.key)}
disabled={!affordable}
className="card-nature flex flex-col items-center gap-1 p-2 hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-40"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={def.img} alt={def.name} className="h-12 w-12 object-contain" />
<span className="font-jua text-[12px] text-[#3f2c14]">{def.name}</span>
<span className="font-num text-[10px] text-[#8a6316]">
{Object.entries(def.cost)
.map(([k, v]) => `${RES_EMOJI[k]} ${v}`)
.join(" ")}
</span>
</button>
);
})}
</div>
</div>
);
}
/* ──────────────── 건물 패널 (정보 + 배치) ──────────────── */
function BuildingPanel({
building,
state,
onAssign,
onUnassign,
onCollect,
onUpgradeKitchen,
onMove,
highlightAssign,
onRush,
onSetSell,
}: {
building: Building;
state: GameState;
onAssign: (catId: string) => void;
onUnassign: (catId: string) => void;
onCollect: () => void;
onUpgradeKitchen: () => void;
onMove: () => void;
highlightAssign?: boolean;
onRush: () => void;
onSetSell: (key: string | null) => void;
}) {
const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state);
const assignedCats = building.assigned
.map((id) => state.cats.find((c) => c.id === id))
.filter((c): c is Cat => Boolean(c));
const totalRate = assignedCats.reduce(
(sum, c) => sum + effectiveRate(c, building.aptitude),
0,
);
const stored = Math.floor(building.stored);
const slotsLeft = building.maxSlots - assignedCats.length;
// 배치 후보: 이 건물 적성이 있는 모든 고양이 (다른 곳에서 일하는 고양이 포함)
const candidates = state.cats
.filter((c) => c.aptitude[building.aptitude] > 0 && c.assignedTo !== building.id)
.sort(
(a, b) =>
effectiveRate(b, building.aptitude) - effectiveRate(a, building.aptitude),
);
if (isUnderConstruction(building)) {
const def = BUILD_DEFS.find((d) => d.type === building.type);
const total = (def?.buildTime ?? 60) * 1000;
const remainMs = Math.max(0, (building.constructionUntil ?? 0) - Date.now());
const pct = Math.min(100, ((total - remainMs) / total) * 100);
const remainSec = Math.ceil(remainMs / 1000);
return (
<div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={BUILDING_IMG.construction}
alt="건설 중"
className="mx-auto h-28 object-contain"
/>
<p className="text-center text-sm text-[#6f5c39]">
🔨 열심히 짓는 중이에요…
</p>
<div className="bar-track h-3 w-full">
<div className="bar-fill-amber" style={{ width: `${pct}%` }} />
</div>
<p className="font-num text-center text-sm text-[#8a6316]">
남은 시간 {Math.floor(remainSec / 60)}:{String(remainSec % 60).padStart(2, "0")}
</p>
<button
onClick={onRush}
className="amber-btn w-full py-2 text-sm"
>
⚡ 즉시 완공 — 🪙 {Math.max(1, Math.ceil(remainMs / 10000))}
</button>
</div>
);
}
if (building.type === "market") {
// 장터: 판매 품목 선택
const options: Array<{ key: string; label: string; price: number; stock: number }> = [
{ key: "fish", label: "🐟 생선", price: SELL_PRICES.fish, stock: Math.floor(state.resources.fish) },
{ key: "wood", label: "🪵 목재", price: SELL_PRICES.wood, stock: Math.floor(state.resources.wood) },
{ key: "stone", label: "🪨 돌", price: SELL_PRICES.stone, stock: Math.floor(state.resources.stone) },
{
key: "meals",
label: `${recipe.emoji} 요리(${recipe.name})`,
price: MEAL_PRICES[recipe.key] ?? 4,
stock: state.buildings
.filter((b) => b.type === "kitchen")
.reduce((sum, b) => sum + Math.floor(b.stored), 0),
},
];
return (
<div className="space-y-4">
<p className="text-sm text-[#6f5c39]">
판매 품목을 정하면 분당 5개씩 자동으로 팔아 냥코인을 벌어요. 가공한
요리일수록 값이 좋답니다!
</p>
<div className="flex items-center justify-end">
<button onClick={onMove} className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110">
📍 이동
</button>
</div>
<div className="space-y-2">
{options.map((o) => (
<button
key={o.key}
onClick={() => onSetSell(building.sellKey === o.key ? null : o.key)}
className={`card-nature flex w-full items-center justify-between px-3.5 py-2.5 text-sm hover:brightness-105 ${
building.sellKey === o.key ? "tut-highlight" : ""
}`}
>
<span className="font-jua text-[#3f2c14]">{o.label}</span>
<span className="font-num text-xs text-[#8a6316]">
개당 🪙{o.price} · 재고 {o.stock}
{building.sellKey === o.key && " · 판매 중!"}
</span>
</button>
))}
</div>
{!building.sellKey && (
<p className="text-center text-xs text-[#a3906c]">지금은 아무것도 팔지 않아요</p>
)}
</div>
);
}
if (building.maxSlots === 0) {
// 패시브 건물 — 배치 없음
const restingHere = state.cats.filter((c) => c.state === "resting");
return (
<div className="space-y-4">
<p className="text-sm text-[#6f5c39]">
{BUILD_DEFS.find((d) => d.type === building.type)?.desc}
</p>
<div className="flex items-center justify-end">
<button
onClick={onMove}
className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110"
>
📍 이동
</button>
</div>
{building.type === "warehouse" && (
<div className="card-nature px-4 py-3 text-center text-sm">
현재 보관 상한: <b className="font-num">{storageCap(state)}</b> / 자원
</div>
)}
{building.type === "cat_house" && (
<section>
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
💤 쉬고 있는 고양이 ({restingHere.length})
</h3>
{restingHere.length === 0 && (
<p className="text-xs text-[#8a7551]">지금은 모두 기운이 넘쳐요!</p>
)}
<div className="space-y-2">
{restingHere.map((cat) => (
<div key={cat.id} className="card-nature flex items-center gap-3 px-3 py-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={catSprite(cat.breed, "sleep")} alt="" className="h-10 w-10 shrink-0 object-contain" />
<div className="min-w-0 flex-1">
<div className="font-jua truncate text-sm text-[#3f2c14]">{cat.name}</div>
<div className="font-num text-xs text-[#8a7551]">
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
</div>
</div>
</div>
))}
</div>
</section>
)}
</div>
);
}
return (
<div className="space-y-5">
{/* 정보 */}
<div className="card-nature px-4 py-3 text-sm leading-relaxed">
<div className="flex items-center justify-between">
<span>
필요 적성: <b>{APTITUDE_LABEL[building.aptitude]}</b> · 슬롯{" "}
{assignedCats.length}/{building.maxSlots}
</span>
<button
onClick={onMove}
title="건물 위치 옮기기"
className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110"
>
📍 이동
</button>
</div>
{isKitchen ? (
<>
<div>
{recipe.emoji} {recipe.name} (
{Object.entries(recipe.input)
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(" + ")}
→ 포만도 +{recipe.fullness})
</div>
<div>요리 속도 {totalRate.toFixed(2)}/초</div>
<div className="bar-track mt-1 h-2 w-full">
<div
className="bar-fill-amber"
style={{
width: `${Math.min(100, ((building.progress ?? 0) / recipe.work) * 100)}%`,
}}
/>
</div>
</>
) : (
<div>생산 속도 {totalRate.toFixed(2)}/초</div>
)}
</div>
{/* 산출/선반 */}
{isKitchen ? (
<>
<div className="card-nature px-4 py-2.5 text-center text-sm font-semibold text-[#8a6316]">
{recipe.emoji} 선반 {stored}/{mealCap(building.level)} — 배고픈 고양이가 자동으로 먹습니다
</div>
{building.level < 2 && (
<div className="card-nature p-3 text-sm">
<div className="font-jua mb-1 text-[15px] text-[#3f2c14]">⬆️ 부엌 Lv2 증축</div>
<p className="mb-2 text-xs text-[#6f5c39]">
선반 15칸으로 확장 · 회관 Lv3 달성 시 🐟 훈제 생선 해금
</p>
<button
onClick={onUpgradeKitchen}
className="nature-btn w-full py-2 text-xs"
>
{Object.entries(KITCHEN_UPGRADE_COST)
.map(([k, v]) => `${RES_EMOJI[k]} ${v}`)
.join(" + ")}{" "}
로 증축
</button>
</div>
)}
{(() => {
const kitchenLv = building.level;
const lockedRecipe = RECIPES.find(
(r) => r.reqHallLv > state.hall.level || r.reqKitchenLv > kitchenLv,
);
return lockedRecipe ? (
<p className="text-xs text-[#8a7551]">
🔒 {lockedRecipe.emoji} {lockedRecipe.name} — 회관 Lv
{lockedRecipe.reqHallLv} · 부엌 Lv{lockedRecipe.reqKitchenLv} 해금
</p>
) : null;
})()}
</>
) : (
<button
onClick={onCollect}
disabled={stored <= 0}
className="amber-btn w-full py-2.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
>
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
</button>
)}
{/* 배치된 고양이 */}
<section>
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
일하는 중 ({assignedCats.length}/{building.maxSlots})
</h3>
{assignedCats.length === 0 && (
<p className="text-xs text-[#8a7551]">아직 아무도 없어요</p>
)}
<div className="space-y-2">
{assignedCats.map((cat) => (
<CatRow
key={cat.id}
cat={cat}
aptitude={building.aptitude}
tag={null}
action={{ label: "해제 ✕", style: "danger", onClick: () => onUnassign(cat.id) }}
/>
))}
</div>
</section>
{/* 배치 가능한 고양이 */}
<section>
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
배치 가능한 고양이
</h3>
{candidates.length === 0 && (
<p className="text-xs text-[#8a7551]">
{APTITUDE_LABEL[building.aptitude]} 적성이 있는 고양이가 없어요
</p>
)}
<div className="space-y-2">
{candidates.map((cat) => {
const workplace = state.buildings.find((b) => b.id === cat.assignedTo);
return (
<CatRow
key={cat.id}
cat={cat}
aptitude={building.aptitude}
tag={workplace ? `${workplace.name}에서 일하는 중` : "쉬는 중"}
action={{
label: "배치",
style: "primary",
disabled: slotsLeft <= 0,
highlight: highlightAssign,
onClick: () => onAssign(cat.id),
}}
/>
);
})}
</div>
{slotsLeft <= 0 && candidates.length > 0 && (
<p className="mt-2 text-xs text-[#8a6316]">
슬롯이 가득 찼어요 — 먼저 해제하거나 건물을 증축하세요
</p>
)}
</section>
</div>
);
}
/** 패널용 고양이 행 */
function CatRow({
cat,
aptitude,
tag,
action,
}: {
cat: Cat;
aptitude: AptitudeKey;
tag: string | null;
action: {
label: string;
style: "primary" | "danger";
disabled?: boolean;
highlight?: boolean;
onClick: () => void;
};
}) {
return (
<div className="card-nature flex items-center gap-3 px-3 py-2">
<span className="card-accent" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={catSprite(cat.breed, "idle")}
alt=""
className="h-10 w-10 shrink-0 object-contain"
/>
<div className="min-w-0 flex-1">
<div className="font-jua truncate text-sm text-[#3f2c14]">
{cat.name}{" "}
<span className="font-num text-xs text-[#8a7551]">Lv{cat.level}</span>
</div>
<div className="text-xs text-[#6f5c39]">
{APTITUDE_LABEL[aptitude]} {cat.aptitude[aptitude]} ·{" "}
{effectiveRate(cat, aptitude).toFixed(2)}/초
{fullnessPenalty(cat) < 1 && (
<span className="ml-1 text-[#b0472a]">배고픔</span>
)}
</div>
{tag && <div className="text-[11px] text-[#a3906c]">{tag}</div>}
</div>
<button
onClick={action.onClick}
disabled={action.disabled}
className={`shrink-0 px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${
action.style === "primary"
? "nature-btn"
: "wood-plank hover:brightness-110"
}${action.highlight ? " tut-highlight" : ""}`}
>
{action.label}
</button>
</div>
);
}
/* ──────────────── 패시브 배지 (툴팁) ──────────────── */
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>
);
}
/* ──────────────── 고양이 카드 (정보용) ──────────────── */
function CatCard({
cat,
buildings,
onInvest,
happinessUnlocked,
}: {
cat: Cat;
buildings: Building[];
onInvest: (catId: string, key: AptitudeKey) => void;
happinessUnlocked?: boolean;
}) {
const workplace = buildings.find((b) => b.id === cat.assignedTo);
const expNeeded = cat.level * 90;
const cap = BREEDS.find((b) => b.key === cat.breed)?.aptitudeCap ?? 5;
return (
<div className="card-nature p-4">
<span className="card-accent" />
<div className="mb-1 flex items-center justify-between">
<span className="font-jua flex items-center gap-1.5 text-[16px] text-[#3f2c14]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={catSprite(cat.breed, "idle")}
alt=""
className="h-7 w-7 object-contain"
/>
{cat.name}{" "}
<span className="font-num text-xs text-[#8a7551]">Lv{cat.level}</span>
</span>
<span className="text-xs text-[#a3906c]">{cat.breedName}</span>
</div>
<div className="bar-track mb-2 h-1.5 w-full">
<div
className="bar-fill-amber"
style={{ width: `${Math.min(100, (cat.exp / expNeeded) * 100)}%` }}
/>
</div>
<div className="flex flex-wrap gap-1 text-xs">
{(Object.keys(APTITUDE_LABEL) as AptitudeKey[])
.filter((k) => cat.aptitude[k] > 0 || cat.aptitudePoints > 0)
.map((k) => (
<span
key={k}
className="chip-nature flex items-center gap-0.5 px-1.5 py-0.5 text-[11px]"
>
{APTITUDE_LABEL[k]} {cat.aptitude[k]}
{cat.aptitudePoints > 0 && cat.aptitude[k] < cap && (
<button
onClick={() => onInvest(cat.id, k)}
title="적성 포인트 투자"
className="ml-0.5 rounded bg-[#5d8f22] px-1 text-[10px] font-bold text-white hover:bg-[#4f8420]"
>
+
</button>
)}
</span>
))}
</div>
<p className="mt-1 text-xs text-[#6f5c39]">
💪 근면 {cat.stats.diligence}
{cat.aptitudePoints > 0 && (
<span className="ml-2 rounded-full bg-[rgba(214,164,60,.2)] px-2 py-0.5 text-[11px] font-semibold text-[#8a6316]">
✨ 적성 포인트 {cat.aptitudePoints}
</span>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<div className="bar-track h-2 flex-1">
<div
className={cat.status.fullness < 25 ? "bar-fill-red" : "bar-fill-green"}
style={{ width: `${cat.status.fullness}%` }}
/>
</div>
<span className="font-num text-[11px] text-[#8a7a58]">
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="bar-track h-2 flex-1">
<div
className="bar-fill-amber"
style={{ width: `${(cat.status.energy / cat.stats.stamina) * 100}%` }}
/>
</div>
<span className="font-num text-[11px] text-[#8a7a58]">
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
</span>
</div>
{happinessUnlocked && (
<div className="mt-1.5 flex items-center gap-2">
<div className="bar-track h-2 flex-1">
<div
className={cat.status.happiness < 20 ? "bar-fill-red" : "bar-fill-green"}
style={{ width: `${cat.status.happiness}%`, background: cat.status.happiness >= 20 ? "linear-gradient(90deg,#e88bb8,#f7b7d4)" : undefined }}
/>
</div>
<span className="font-num text-[11px] text-[#8a7a58]">
💗 {Math.floor(cat.status.happiness)}
</span>
{fullnessPenalty(cat) < 1 && (
<span className="rounded-full bg-[rgba(214,106,74,.16)] px-2 py-0.5 text-[11px] text-[#b0472a]">
배고픔
</span>
)}
</div>
)}
{cat.passives.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{cat.passives.map((key) => (
<PassiveBadge key={key} passiveKey={key} />
))}
</div>
)}
<p className="mt-2 text-xs">
{cat.state === "resting" ? (
<span className="text-[#8a6316]">💤 지쳐서 휴식 중 (회복되면 복귀)</span>
) : cat.state === "striking" ? (
<span className="text-[#b0472a]">💢 파업 중! 행복도를 올려주세요</span>
) : workplace ? (
<span className="text-[#5c8a2a]">
{workplace.emoji} {workplace.name}에서 일하는 중
</span>
) : (
<span className="text-[#a3906c]">쉬는 중</span>
)}
</p>
</div>
);
}
/* ──────────────── 입양 모달 ──────────────── */
function AdoptModal({
candidates,
onPick,
onClose,
highlightPick,
cost,
onReroll,
coins,
rerollUnlocked,
}: {
candidates: Cat[];
onPick: (cat: Cat) => void;
onClose: () => void;
highlightPick?: boolean;
cost: number;
onReroll: () => void;
coins: number;
rerollUnlocked?: boolean;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="panel-wood max-h-[90vh] w-full max-w-3xl overflow-hidden p-2.5"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-paper max-h-[calc(90vh-20px)] overflow-y-auto p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-jua text-xl text-[#4a3418]">
😻 입양할 고양이를 선택하세요{" "}
<span className="font-num text-sm text-[#8a6316]">
{cost > 0 ? `(비용 🪙${cost})` : "(무료!)"}
</span>
</h2>
<button
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full bg-linear-to-b from-[#e8d6ae] to-[#cdb488] text-sm text-[#6b4a20] shadow hover:brightness-105"
>
✕
</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="card-nature flex flex-col p-4"
>
<div className="mb-2 text-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={catSprite(cat.breed, "idle")}
alt={cat.name}
className="mx-auto h-20 w-auto object-contain drop-shadow-sm"
/>
<div className="font-jua mt-1 text-[17px] text-[#3f2c14]">{cat.name}</div>
<div className="text-xs text-[#a3906c]">
{cat.breedName}{" "}
<span className="text-amber-400">
{"★".repeat(breed?.rarity ?? 1)}
</span>
</div>
</div>
<div className="chip-nature mb-2 px-3 py-2 text-xs leading-relaxed">
💪 근면 {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="chip-nature px-1.5 py-0.5 text-[11px]"
>
{APTITUDE_LABEL[k]} {cat.aptitude[k]}
</span>
))
) : (
<span className="text-[11px] text-[#a3906c]">적성 없음…</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-[#a3906c]">패시브 없음</span>
)}
</div>
<button
onClick={() => onPick(cat)}
className={"nature-btn mt-auto py-2 text-sm" + (highlightPick ? " tut-highlight" : "")}
>
이 아이로 할래요{cost > 0 ? ` (🪙${cost})` : ""}
</button>
</div>
);
})}
</div>
{rerollUnlocked && (
<div className="mt-4 flex items-center justify-center gap-3">
<button
onClick={onReroll}
disabled={coins < REROLL_COST}
className="wood-plank px-4 py-1.5 text-xs hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
🎲 다른 아이들 보기 (🪙{REROLL_COST})
</button>
</div>
)}
<p className="mt-3 text-center text-xs text-[#8a7551]">
한 마리만 선택할 수 있어요 · 패시브에 마우스를 올리면 상세 정보가 보입니다
</p>
</div>
</div>
</div>
);
}