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>
이 커밋은 다음에 포함됨:
+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) => {
|
||||
|
||||
새 이슈에서 참조
사용자 차단