2단계: 부엌·요리·포만도·자동 식사·회관 증축

- 부엌 건설(목재 60), 요리 적성 배치, 물고기 구이(생선 2→구이 1, 포만도 +40)
- 레시피 해금 구조: 회관/부엌 레벨 조건 (훈제 생선은 회관 Lv3 예고)
- 포만도: 식욕×0.03/초 감소(소식가/대식가 반영), 배고프면 부엌에서 자동 식사
- 배고픔 페널티: 포만도 25 미만 0.7배, 0이면 0.3배
- 회관 증축 Lv2(목재100+생선80+Lv3 고양이) → 수용 5마리, Lv3은 채석장 대기
- UI: 증축 버튼·요건 표시, 부엌 카드(레시피·선반), 고양이 포만도 바
- 마을 뷰: 부엌 타일(냄비), 🍖 배지, 식사 🍽️ 버블, 재배치 시 이동 버그 수정

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 13:38:57 +09:00
co-authored by Claude Fable 5
부모 aa289c3616
커밋 59e337ab90
5개의 변경된 파일과 349개의 추가작업 그리고 46개의 파일을 삭제
+141 -32
파일 보기
@@ -3,8 +3,15 @@
import { useState } from "react";
import { useGame } from "@/hooks/useGame";
import VillageView from "@/components/game/VillageView";
import { engine, catRate } from "@/lib/game/engine";
import { APTITUDE_LABEL, PASSIVES, TOOL_IMG } from "@/lib/game/data";
import { engine, effectiveRate, activeRecipe, fullnessPenalty } from "@/lib/game/engine";
import {
APTITUDE_LABEL,
HALL_LEVELS,
KITCHEN_BUILD_COST,
KITCHEN_MEAL_CAP,
PASSIVES,
TOOL_IMG,
} from "@/lib/game/data";
import type { Building, Cat, GameState } from "@/lib/game/types";
export default function Game() {
@@ -27,7 +34,11 @@ export default function Game() {
return (
<div className="space-y-8">
<TopBar state={state} onAdopt={() => run(() => engine.adopt())} />
<TopBar
state={state}
onAdopt={() => run(() => engine.adopt())}
onUpgrade={() => run(() => engine.upgradeHall())}
/>
<VillageView />
@@ -47,13 +58,19 @@ export default function Game() {
<BuildingCard
key={b.id}
building={b}
cats={state.cats}
state={state}
selectedCat={selectedCat}
onAssign={(catId) => run(() => engine.assign(catId, b.id))}
onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(b.id))}
/>
))}
{!state.buildings.some((b) => b.type === "kitchen") && (
<BuildKitchenCard
wood={state.resources.wood}
onBuild={() => run(() => engine.buildKitchen())}
/>
)}
</div>
</section>
@@ -79,45 +96,101 @@ export default function Game() {
);
}
function TopBar({ state, onAdopt }: { state: GameState; onAdopt: () => void }) {
function TopBar({
state,
onAdopt,
onUpgrade,
}: {
state: GameState;
onAdopt: () => void;
onUpgrade: () => void;
}) {
const full = state.cats.length >= state.hall.capacity;
const meals = state.buildings
.filter((b) => b.type === "kitchen")
.reduce((sum, b) => sum + Math.floor(b.stored), 0);
const next = HALL_LEVELS[state.hall.level + 1];
const nextLabel = next
? `증축 Lv${state.hall.level + 1}: 목재 ${next.cost.wood ?? 0}${next.cost.fish ? `, 생선 ${next.cost.fish}` : ""}${next.reqCat ? `, Lv${next.reqCat.level}↑ ${next.reqCat.count}마리` : ""} → 수용 ${next.capacity}마리`
: "최고 레벨";
return (
<section className="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-gray-200 px-5 py-4 dark:border-gray-800">
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
<span>🐟 {Math.floor(state.resources.fish)}</span>
<span>🪵 {Math.floor(state.resources.wood)}</span>
<button
onClick={onAdopt}
disabled={full}
className="ml-auto rounded-lg bg-orange-500 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{full ? "수용 인원 가득 참" : "😻 입양하기"}
</button>
<section className="rounded-xl border border-gray-200 px-5 py-4 dark:border-gray-800">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
<span>🐟 {Math.floor(state.resources.fish)}</span>
<span>🪵 {Math.floor(state.resources.wood)}</span>
<span title="부엌에 보관 중인 요리">🍖 {meals}</span>
<div className="ml-auto flex gap-2">
<button
onClick={onUpgrade}
disabled={!next}
title={nextLabel}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-40"
>
🏗️ 회관 증축
</button>
<button
onClick={onAdopt}
disabled={full}
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{full ? "수용 가득 참" : "😻 입양하기"}
</button>
</div>
</div>
<p className="mt-2 text-xs text-gray-400">{nextLabel}</p>
</section>
);
}
function BuildKitchenCard({ wood, onBuild }: { wood: number; onBuild: () => void }) {
const canBuild = wood >= KITCHEN_BUILD_COST.wood;
return (
<div className="flex flex-col items-start justify-between rounded-xl border-2 border-dashed border-gray-300 p-5 dark:border-gray-700">
<div className="mb-1 flex items-center gap-2 font-semibold">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={TOOL_IMG.cooking} alt="부엌" className="h-8 w-8 object-contain opacity-60" />
부엌 (미건설)
</div>
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
요리 적성 고양이를 배치해 생선으로 요리를 만듭니다. 배고픈 고양이가 알아서 먹으러 와요.
</p>
<button
onClick={onBuild}
disabled={!canBuild}
className="w-full rounded-lg bg-amber-600 py-2 text-sm font-semibold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
>
🔨 짓기 — 🪵 {KITCHEN_BUILD_COST.wood} {canBuild ? "" : `(보유 ${Math.floor(wood)})`}
</button>
</div>
);
}
function BuildingCard({
building,
cats,
state,
selectedCat,
onAssign,
onUnassign,
onCollect,
}: {
building: Building;
cats: Cat[];
state: GameState;
selectedCat: Cat | null;
onAssign: (catId: string) => void;
onUnassign: (catId: string) => void;
onCollect: () => void;
}) {
const cats = state.cats;
const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state);
const assignedCats = building.assigned
.map((id) => cats.find((c) => c.id === id))
.filter((c): c is Cat => Boolean(c));
const totalRate = assignedCats.reduce(
(sum, c) => sum + catRate(c, building.aptitude),
(sum, c) => sum + effectiveRate(c, building.aptitude),
0,
);
const stored = Math.floor(building.stored);
@@ -143,9 +216,19 @@ function BuildingCard({
필요 적성: {APTITUDE_LABEL[building.aptitude]}
</span>
</div>
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
생산 속도 {totalRate.toFixed(2)}/초
</p>
{isKitchen ? (
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
{recipe.emoji} {recipe.name} (
{Object.entries(recipe.input)
.map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`)
.join(" + ")}
) · 요리 속도 {totalRate.toFixed(2)}/초
</p>
) : (
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
생산 속도 {totalRate.toFixed(2)}/초
</p>
)}
<div className="mb-4 flex flex-wrap gap-2">
{assignedCats.map((cat) => (
@@ -170,13 +253,19 @@ function BuildingCard({
))}
</div>
<button
onClick={onCollect}
disabled={stored <= 0}
className="w-full rounded-lg bg-sky-500 py-2 text-sm font-semibold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
</button>
{isKitchen ? (
<div className="w-full rounded-lg bg-amber-50 py-2 text-center text-sm font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-300">
{recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
</div>
) : (
<button
onClick={onCollect}
disabled={stored <= 0}
className="w-full rounded-lg bg-sky-500 py-2 text-sm font-semibold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
</button>
)}
</div>
);
}
@@ -220,10 +309,30 @@ function CatCard({
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 💪 근면{" "}
{cat.stats.diligence}
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
</p>
{/* 포만도 */}
<div className="mt-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
<div
className={`h-full ${
cat.status.fullness < 25 ? "bg-red-400" : "bg-emerald-400"
}`}
style={{ width: `${cat.status.fullness}%` }}
/>
</div>
<span className="text-[11px] text-gray-400">
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
</span>
{fullnessPenalty(cat) < 1 && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[11px] text-red-600 dark:bg-red-950 dark:text-red-300">
배고픔
</span>
)}
</div>
{cat.passives.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{cat.passives.map((key) => {