"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 = { fish: "๐ŸŸ", wood: "๐Ÿชต", stone: "๐Ÿชจ" }; const RES_LABEL: Record = { 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(null); const [showIntro, setShowIntro] = useState(true); const [candidates, setCandidates] = useState(null); const [placing, setPlacing] = useState(null); const [message, setMessage] = useState(null); const msgTimer = useRef | 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 (
๋งˆ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ
); } 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 (
{/* โ”€โ”€ ์ธํŠธ๋กœ (์‹œ์ž‘ํ•˜๊ธฐ โ†’ ๊ตฌ๋ฆ„ ๊ฑทํž˜) โ”€โ”€ */} {showIntro && setShowIntro(false)} />} { 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 && ( setPanel(panel?.type === "chat" ? null : { type: "chat" }) } /> )} {/* โ”€โ”€ ๋‹‰๋„ค์ž„ ์ž…๋ ฅ (์ธ์‚ฌ ํ›„, ๋ฏธ์ž…๋ ฅ ์‹œ ๊ฒŒ์ž„ ์ฐจ๋‹จ) โ”€โ”€ */} {!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && ( dispatch(() => undefined)} /> )} {/* โ”€โ”€ ๋ฐฐ์น˜ ๋ชจ๋“œ ํžŒํŠธ โ”€โ”€ */} {placing && (
๐Ÿ“ {placing.deco ? "๊พธ๋ฐ€ ํƒ€์ผ์„ ํด๋ฆญํ•˜์„ธ์š” (์—ฐ์† ๋ฐฐ์น˜ ๊ฐ€๋Šฅ)" : `${placing.moveId ? "์˜ฎ๊ธธ" : "๋ฐฐ์น˜ํ• "} ์œ„์น˜๋ฅผ ํด๋ฆญํ•˜์„ธ์š”`} {placing.type === "dock" && " (๋ถ€๋‘๋Š” ๋ฌผ๊ฐ€์—๋งŒ)"}
)} {/* โ”€โ”€ ์ƒ๋‹จ HUD (์–ด๋‘์šด ๋‚˜๋ฌด ๋ฐ”) โ”€โ”€ */}
{/* ๋งค๋‹ฌ๋ฆฐ ๋‚˜๋ฌด ํŒ์ž ํƒ€์ดํ‹€ */}
{/* ์ž์› pill */}
{( [ ["๐Ÿฑ", 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]) => ( {img ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : ( emoji )} {value} ))}
{/* โ”€โ”€ ๋ฉ”์‹œ์ง€ ํ† ์ŠคํŠธ โ”€โ”€ */} {message && (
โš ๏ธ {message}
)} {/* โ”€โ”€ ์ดŒ์žฅ ๋ƒฅ์ด ํŠœํ† ๋ฆฌ์–ผ ๊ฐ€์ด๋“œ โ”€โ”€ */} {(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 (
{/* eslint-disable-next-line @next/next/no-img-element */} ์ดŒ์žฅ ๋ƒฅ์ด
๐ŸŽ—๏ธ ์ดŒ์žฅ ๋ƒฅ์ด ({Math.min(step + 1, TUTORIAL_STEPS.length)}/{TUTORIAL_STEPS.length})

{def.text}

{def.task && (
๐Ÿ“Œ {def.task}
)} {def.manual && ( )}
); })()} {/* โ”€โ”€ ์ดŒ์žฅ ๋ƒฅ์ด: ํ–‰๋ณต๋„ ์‹œ์Šคํ…œ ์•ˆ๋‚ด (ํ•ด๊ธˆ ์‹œ 1ํšŒ) โ”€โ”€ */} {state.happinessUnlocked && !state.happinessIntroSeen && (state.tutorialStep ?? TUTORIAL_DONE) >= TUTORIAL_STEPS.length && (
{/* eslint-disable-next-line @next/next/no-img-element */} ์ดŒ์žฅ ๋ƒฅ์ด
๐ŸŽ—๏ธ ์ดŒ์žฅ ๋ƒฅ์ด

์˜ค๋žœ๋งŒ์ด์•ผ! ๋งˆ์„์ด ๊ทผ์‚ฌํ•ด์กŒ๋„ค. ํ–‡๋ณ• ๋ฐํฌ๋„ ์ƒ๊ฒผ์œผ๋‹ˆ ์ด์ œ ์นœ๊ตฌ๋“ค์˜ ๐Ÿ’— ํ–‰๋ณต๋„๋ฅผ ์ฑ™๊ธธ ๋•Œ์•ผ. ๋ฐฐ๋ถ€๋ฅด๊ฒŒ ๋จน๊ณ  ํ‘น ์‰ฌ๋ฉด ํ–‰๋ณตํ•ด์ง€๊ณ , ๊ตถ๊ฑฐ๋‚˜ ๊ณ ์–‘์ด ์ง‘์ด ๋ชจ์ž๋ผ๋ฉด ์‹œ๋ฌด๋ฃฉํ•ด์ ธ. ํ–‰๋ณต๋„๊ฐ€ ๋„ˆ๋ฌด ๋‚ฎ์œผ๋ฉด ํŒŒ์—…ํ•  ์ˆ˜๋„ ์žˆ์œผ๋‹ˆ ์ž˜ ๋Œ๋ด์ค˜! 80 ์ด์ƒ์ด๋ฉด ์ผ๋„ 10% ๋นจ๋ผ์ง„๋‹จ๋‹ค.

)} {/* โ”€โ”€ DB ์ƒํƒœ (์ขŒํ•˜๋‹จ ๋ฏธ๋‹ˆ) โ”€โ”€ */}
{/* โ”€โ”€ ์šฐ์ธก ํŒจ๋„ โ”€โ”€ */} {panel?.type === "chat" && ( setPanel(null)}> )} {panel?.type === "hall" && ( setPanel(null)}> run(() => engine.upgradeHall())} highlightUpgrade={tutTarget === "hall-upgrade"} /> )} {panel?.type === "shop" && ( setPanel(null)}> { setPanel(null); setPlacing({ type: def.type, aptitude: def.aptitude }); }} onBuyDeco={(key) => { setPanel(null); setPlacing({ deco: key }); }} /> )} {openedBuilding && ( setPanel(null)} > 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, }); }} /> )} {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 ( setPanel(null)}>
{/* eslint-disable-next-line @next/next/no-img-element */} {def.name}

๋งˆ์„์„ ๊พธ๋ฏธ๋Š” ๋ฐ”๋‹ฅ ์žฅ์‹์ด์—์š”.

); })()} {panel?.type === "cats" && ( setPanel(null)}>
{state.cats.map((cat) => ( run(() => engine.investAptitude(catId, key))} /> ))}
)} {/* โ”€โ”€ ์ž…์–‘ ๋ชจ๋‹ฌ โ”€โ”€ */} {candidates && ( 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)} /> )}
); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๊ณตํ†ต ํŒจ๋„ ์…ธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function SidePanel({ title, icon, onClose, children, }: { title: string; icon?: string; onClose: () => void; children: React.ReactNode; }) { return ( ); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ํšŒ๊ด€ ํŒจ๋„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function HallPanel({ state, onUpgrade, highlightUpgrade, }: { state: GameState; onUpgrade: () => void; highlightUpgrade?: boolean; }) { const next = HALL_LEVELS[state.hall.level + 1]; return (

๋งˆ์„์˜ ์ค‘์‹ฌ. ํšŒ๊ด€ ๋ ˆ๋ฒจ์ด ์ˆ˜์šฉ ์ธ์›๊ณผ ๊ฑด๋ฌผยท๋ ˆ์‹œํ”ผ ํ•ด๊ธˆ์„ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.

ํ˜„์žฌ ์ˆ˜์šฉ ์ธ์› {state.cats.length}/{state.hall.capacity}๋งˆ๋ฆฌ
{next ? (

Lv{state.hall.level + 1} ์ฆ์ถ• โ†’ ์ˆ˜์šฉ {next.capacity}๋งˆ๋ฆฌ

    {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 (
  • {ok ? "โœ“" : "ยท"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
  • ); })} {(next.reqBuildings ?? []).map((req) => { const b = state.buildings.find((x) => x.type === req.type); const ok = Boolean(b && b.level >= (req.level ?? 1)); return (
  • {ok ? "โœ“" : "ยท"} {req.label}
  • ); })} {next.reqCat && (
  • 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}๋งˆ๋ฆฌ
  • )}
{next.locked ? (

๐Ÿ”’ {next.locked}

) : ( )}
) : (

ํšŒ๊ด€์ด ์ตœ๊ณ  ๋ ˆ๋ฒจ์ž…๋‹ˆ๋‹ค.

)}
); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๊ฑด๋ฌผ ์ƒ์  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function ShopPanel({ state, onBuy, onBuyDeco, highlightType, }: { state: GameState; onBuy: (def: BuildDef) => void; onBuyDeco: (key: string) => void; highlightType?: string | null; }) { return (

๊ตฌ๋งค ํ›„ ๋งˆ์„์˜ ์›ํ•˜๋Š” ํƒ€์ผ์„ ํด๋ฆญํ•ด ๋ฐฐ์น˜ํ•ฉ๋‹ˆ๋‹ค. ๋น„์šฉ์€ ๋ฐฐ์น˜ ํ™•์ • ์‹œ ์ฐจ๊ฐ๋ผ์š”.

๐Ÿ—๏ธ ๊ฑด๋ฌผ

{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 (
{/* eslint-disable-next-line @next/next/no-img-element */} {BUILDING_IMG[def.type] ? ( /* eslint-disable-next-line @next/next/no-img-element */ {def.name} ) : ( {def.emoji} )}
{def.name}
{costEntries.length > 0 ? costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ") : "๋ฌด๋ฃŒ"} {` ยท โฑ ${def.buildTime}์ดˆ`} {def.reqHallLv > 1 && ` ยท ํšŒ๊ด€ Lv${def.reqHallLv}`}
{def.desc}
{built ? ( ๋ณด์œ  ์ค‘ ) : ( )}
); })}

๐ŸŒฟ ๊พธ๋ฏธ๊ธฐ (์—ฌ๋Ÿฌ ๊ฐœ ๋ฐฐ์น˜ ๊ฐ€๋Šฅ)

{DECO_DEFS.map((def) => { const affordable = Object.entries(def.cost).every( ([key, amount]) => state.resources[key as keyof GameState["resources"]] >= (amount ?? 0), ); return ( ); })}
); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๊ฑด๋ฌผ ํŒจ๋„ (์ •๋ณด + ๋ฐฐ์น˜) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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 (
{/* eslint-disable-next-line @next/next/no-img-element */} ๊ฑด์„ค ์ค‘

๐Ÿ”จ ์—ด์‹ฌํžˆ ์ง“๋Š” ์ค‘์ด์—์š”โ€ฆ

๋‚จ์€ ์‹œ๊ฐ„ {Math.floor(remainSec / 60)}:{String(remainSec % 60).padStart(2, "0")}

); } 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 (

ํŒ๋งค ํ’ˆ๋ชฉ์„ ์ •ํ•˜๋ฉด ๋ถ„๋‹น 5๊ฐœ์”ฉ ์ž๋™์œผ๋กœ ํŒ”์•„ ๋ƒฅ์ฝ”์ธ์„ ๋ฒŒ์–ด์š”. ๊ฐ€๊ณตํ•œ ์š”๋ฆฌ์ผ์ˆ˜๋ก ๊ฐ’์ด ์ข‹๋‹ต๋‹ˆ๋‹ค!

{options.map((o) => ( ))}
{!building.sellKey && (

์ง€๊ธˆ์€ ์•„๋ฌด๊ฒƒ๋„ ํŒ”์ง€ ์•Š์•„์š”

)}
); } if (building.maxSlots === 0) { // ํŒจ์‹œ๋ธŒ ๊ฑด๋ฌผ โ€” ๋ฐฐ์น˜ ์—†์Œ const restingHere = state.cats.filter((c) => c.state === "resting"); return (

{BUILD_DEFS.find((d) => d.type === building.type)?.desc}

{building.type === "warehouse" && (
ํ˜„์žฌ ๋ณด๊ด€ ์ƒํ•œ: {storageCap(state)} / ์ž์›
)} {building.type === "cat_house" && (

๐Ÿ’ค ์‰ฌ๊ณ  ์žˆ๋Š” ๊ณ ์–‘์ด ({restingHere.length})

{restingHere.length === 0 && (

์ง€๊ธˆ์€ ๋ชจ๋‘ ๊ธฐ์šด์ด ๋„˜์ณ์š”!

)}
{restingHere.map((cat) => (
{/* eslint-disable-next-line @next/next/no-img-element */}
{cat.name}
โšก {Math.floor(cat.status.energy)}/{cat.stats.stamina}
))}
)}
); } return (
{/* ์ •๋ณด */}
ํ•„์š” ์ ์„ฑ: {APTITUDE_LABEL[building.aptitude]} ยท ์Šฌ๋กฏ{" "} {assignedCats.length}/{building.maxSlots}
{isKitchen ? ( <>
{recipe.emoji} {recipe.name} ( {Object.entries(recipe.input) .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`) .join(" + ")} โ†’ ํฌ๋งŒ๋„ +{recipe.fullness})
์š”๋ฆฌ ์†๋„ {totalRate.toFixed(2)}/์ดˆ
) : (
์ƒ์‚ฐ ์†๋„ {totalRate.toFixed(2)}/์ดˆ
)}
{/* ์‚ฐ์ถœ/์„ ๋ฐ˜ */} {isKitchen ? ( <>
{recipe.emoji} ์„ ๋ฐ˜ {stored}/{mealCap(building.level)} โ€” ๋ฐฐ๊ณ ํ”ˆ ๊ณ ์–‘์ด๊ฐ€ ์ž๋™์œผ๋กœ ๋จน์Šต๋‹ˆ๋‹ค
{building.level < 2 && (
โฌ†๏ธ ๋ถ€์—Œ Lv2 ์ฆ์ถ•

์„ ๋ฐ˜ 15์นธ์œผ๋กœ ํ™•์žฅ ยท ํšŒ๊ด€ Lv3 ๋‹ฌ์„ฑ ์‹œ ๐ŸŸ ํ›ˆ์ œ ์ƒ์„  ํ•ด๊ธˆ

)} {(() => { const kitchenLv = building.level; const lockedRecipe = RECIPES.find( (r) => r.reqHallLv > state.hall.level || r.reqKitchenLv > kitchenLv, ); return lockedRecipe ? (

๐Ÿ”’ {lockedRecipe.emoji} {lockedRecipe.name} โ€” ํšŒ๊ด€ Lv {lockedRecipe.reqHallLv} ยท ๋ถ€์—Œ Lv{lockedRecipe.reqKitchenLv} ํ•ด๊ธˆ

) : null; })()} ) : ( )} {/* ๋ฐฐ์น˜๋œ ๊ณ ์–‘์ด */}

์ผํ•˜๋Š” ์ค‘ ({assignedCats.length}/{building.maxSlots})

{assignedCats.length === 0 && (

์•„์ง ์•„๋ฌด๋„ ์—†์–ด์š”

)}
{assignedCats.map((cat) => ( onUnassign(cat.id) }} /> ))}
{/* ๋ฐฐ์น˜ ๊ฐ€๋Šฅํ•œ ๊ณ ์–‘์ด */}

๋ฐฐ์น˜ ๊ฐ€๋Šฅํ•œ ๊ณ ์–‘์ด

{candidates.length === 0 && (

{APTITUDE_LABEL[building.aptitude]} ์ ์„ฑ์ด ์žˆ๋Š” ๊ณ ์–‘์ด๊ฐ€ ์—†์–ด์š”

)}
{candidates.map((cat) => { const workplace = state.buildings.find((b) => b.id === cat.assignedTo); return ( onAssign(cat.id), }} /> ); })}
{slotsLeft <= 0 && candidates.length > 0 && (

์Šฌ๋กฏ์ด ๊ฐ€๋“ ์ฐผ์–ด์š” โ€” ๋จผ์ € ํ•ด์ œํ•˜๊ฑฐ๋‚˜ ๊ฑด๋ฌผ์„ ์ฆ์ถ•ํ•˜์„ธ์š”

)}
); } /** ํŒจ๋„์šฉ ๊ณ ์–‘์ด ํ–‰ */ 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 (
{/* eslint-disable-next-line @next/next/no-img-element */}
{cat.name}{" "} Lv{cat.level}
{APTITUDE_LABEL[aptitude]} {cat.aptitude[aptitude]} ยท{" "} {effectiveRate(cat, aptitude).toFixed(2)}/์ดˆ {fullnessPenalty(cat) < 1 && ( ๋ฐฐ๊ณ ํ”” )}
{tag &&
{tag}
}
); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ํŒจ์‹œ๋ธŒ ๋ฐฐ์ง€ (ํˆดํŒ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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 ( {def.name} {def.name} ({kindLabel}) {def.desc} ); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๊ณ ์–‘์ด ์นด๋“œ (์ •๋ณด์šฉ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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 (
{/* eslint-disable-next-line @next/next/no-img-element */} {cat.name}{" "} Lv{cat.level} {cat.breedName}
{(Object.keys(APTITUDE_LABEL) as AptitudeKey[]) .filter((k) => cat.aptitude[k] > 0 || cat.aptitudePoints > 0) .map((k) => ( {APTITUDE_LABEL[k]} {cat.aptitude[k]} {cat.aptitudePoints > 0 && cat.aptitude[k] < cap && ( )} ))}

๐Ÿ’ช ๊ทผ๋ฉด {cat.stats.diligence} {cat.aptitudePoints > 0 && ( โœจ ์ ์„ฑ ํฌ์ธํŠธ {cat.aptitudePoints} )}

๐Ÿฝ๏ธ {Math.floor(cat.status.fullness)} (์‹์š• {cat.stats.appetite})
โšก {Math.floor(cat.status.energy)}/{cat.stats.stamina}
{happinessUnlocked && (
= 20 ? "linear-gradient(90deg,#e88bb8,#f7b7d4)" : undefined }} />
๐Ÿ’— {Math.floor(cat.status.happiness)} {fullnessPenalty(cat) < 1 && ( ๋ฐฐ๊ณ ํ”” )}
)} {cat.passives.length > 0 && (
{cat.passives.map((key) => ( ))}
)}

{cat.state === "resting" ? ( ๐Ÿ’ค ์ง€์ณ์„œ ํœด์‹ ์ค‘ (ํšŒ๋ณต๋˜๋ฉด ๋ณต๊ท€) ) : cat.state === "striking" ? ( ๐Ÿ’ข ํŒŒ์—… ์ค‘! ํ–‰๋ณต๋„๋ฅผ ์˜ฌ๋ ค์ฃผ์„ธ์š” ) : workplace ? ( {workplace.emoji} {workplace.name}์—์„œ ์ผํ•˜๋Š” ์ค‘ ) : ( ์‰ฌ๋Š” ์ค‘ )}

); } /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ์ž…์–‘ ๋ชจ๋‹ฌ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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 (
e.stopPropagation()} >

๐Ÿ˜ป ์ž…์–‘ํ•  ๊ณ ์–‘์ด๋ฅผ ์„ ํƒํ•˜์„ธ์š”{" "} {cost > 0 ? `(๋น„์šฉ ๐Ÿช™${cost})` : "(๋ฌด๋ฃŒ!)"}

{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 (
{/* eslint-disable-next-line @next/next/no-img-element */} {cat.name}
{cat.name}
{cat.breedName}{" "} {"โ˜…".repeat(breed?.rarity ?? 1)}
๐Ÿ’ช ๊ทผ๋ฉด {cat.stats.diligence} ยท โค๏ธ ์ฒด๋ ฅ {cat.stats.stamina} ยท ๐Ÿฝ๏ธ ์‹์š• {cat.stats.appetite}
{aptitudes.length > 0 ? ( aptitudes.map((k) => ( {APTITUDE_LABEL[k]} {cat.aptitude[k]} )) ) : ( ์ ์„ฑ ์—†์Œโ€ฆ )}
{cat.passives.length > 0 ? ( cat.passives.map((key) => ) ) : ( ํŒจ์‹œ๋ธŒ ์—†์Œ )}
); })}
{rerollUnlocked && (
)}

ํ•œ ๋งˆ๋ฆฌ๋งŒ ์„ ํƒํ•  ์ˆ˜ ์žˆ์–ด์š” ยท ํŒจ์‹œ๋ธŒ์— ๋งˆ์šฐ์Šค๋ฅผ ์˜ฌ๋ฆฌ๋ฉด ์ƒ์„ธ ์ •๋ณด๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค

); }