- 접속 시 intro.png 타이틀 화면, 중앙 하단 둥실거리는 시작하기 버튼 - 시작하기 클릭: 인트로가 1초간 투명해지며 아래 깔린 구름 레이어가 드러나고, 구름 두 장이 양옆으로 1.9초간 걷히며 마을 진입 - 구름은 CSS로 구성 (블러 처리한 본체 + 안쪽 가장자리 뭉게 퍼프 7개) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1490 라인
56 KiB
TypeScript
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>
|
|
);
|
|
}
|