diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx
index fcb73a5..f9fb787 100644
--- a/src/components/game/Game.tsx
+++ b/src/components/game/Game.tsx
@@ -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 (
- 🏛️ 마을 회관 Lv{state.hall.level}
- 🐱 {state.cats.length}/{state.hall.capacity}마리
- 🐟 {Math.floor(state.resources.fish)}
- 🪵 {Math.floor(state.resources.wood)}
-
+
+
+
🏛️ 마을 회관 Lv{state.hall.level}
+
🐱 {state.cats.length}/{state.hall.capacity}마리
+
🐟 {Math.floor(state.resources.fish)}
+
🪵 {Math.floor(state.resources.wood)}
+
🍖 {meals}
+
+
+
+
+
+ {nextLabel}
);
}
+function BuildKitchenCard({ wood, onBuild }: { wood: number; onBuild: () => void }) {
+ const canBuild = wood >= KITCHEN_BUILD_COST.wood;
+ return (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+ 부엌 (미건설)
+
+
+ 요리 적성 고양이를 배치해 생선으로 요리를 만듭니다. 배고픈 고양이가 알아서 먹으러 와요.
+
+
+
+ );
+}
+
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]}
-
- 생산 속도 {totalRate.toFixed(2)}/초
-
+ {isKitchen ? (
+
+ {recipe.emoji} {recipe.name} (
+ {Object.entries(recipe.input)
+ .map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`)
+ .join(" + ")}
+ ) · 요리 속도 {totalRate.toFixed(2)}/초
+
+ ) : (
+
+ 생산 속도 {totalRate.toFixed(2)}/초
+
+ )}
{assignedCats.map((cat) => (
@@ -170,13 +253,19 @@ function BuildingCard({
))}
-
+ {isKitchen ? (
+
+ {recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
+
+ ) : (
+
+ )}
);
}
@@ -220,10 +309,30 @@ function CatCard({
- 🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 💪 근면{" "}
- {cat.stats.diligence}
+ 🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
+ {cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
+ {/* 포만도 */}
+
+
+
+ 🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
+
+ {fullnessPenalty(cat) < 1 && (
+
+ 배고픔
+
+ )}
+
+
{cat.passives.length > 0 && (
{cat.passives.map((key) => {
diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx
index aed35d3..dde048a 100644
--- a/src/components/game/VillageView.tsx
+++ b/src/components/game/VillageView.tsx
@@ -26,6 +26,7 @@ const BUILDING_TILES: Record = {
hall: { i: 5, j: 3 },
dock_1: { i: 9, j: 5 },
lumberyard_1: { i: 2, j: 8 },
+ kitchen_1: { i: 6, j: 7 },
};
const TREES: Array<{ i: number; j: number }> = [
@@ -54,6 +55,8 @@ interface CatAnim {
mode: "wander" | "toWork" | "working";
waitUntil: number;
flip: boolean;
+ lastFullness?: number;
+ workBuildingId?: string | null;
}
interface Bubble {
x: number;
@@ -152,20 +155,40 @@ export default function VillageView() {
anims.set(cat.id, anim);
}
+ // 식사 감지 → 🍽️ 버블
+ if (
+ anim.lastFullness !== undefined &&
+ cat.status.fullness > anim.lastFullness + 15
+ ) {
+ bubblesRef.current.push({
+ x: anim.x, y: anim.y - 40, born: now, text: "🍽️",
+ });
+ }
+ anim.lastFullness = cat.status.fullness;
+
if (cat.assignedTo) {
const building = state.buildings.find((b) => b.id === cat.assignedTo);
const slot = building ? building.assigned.indexOf(cat.id) : 0;
const spot = workSpot(cat.assignedTo, Math.max(0, slot));
- if (anim.mode !== "working" && (anim.tx !== spot.x || anim.ty !== spot.y)) {
+ // 다른 건물로 재배치되면 일하던 중이어도 새 일터로 이동
+ if (anim.workBuildingId !== cat.assignedTo) {
+ anim.workBuildingId = cat.assignedTo;
+ anim.tx = spot.x;
+ anim.ty = spot.y;
+ anim.mode = "toWork";
+ } else if (anim.mode !== "working" && (anim.tx !== spot.x || anim.ty !== spot.y)) {
anim.tx = spot.x;
anim.ty = spot.y;
anim.mode = "toWork";
}
- } else if (anim.mode !== "wander") {
- anim.mode = "wander";
- const t = randomWalkTarget();
- anim.tx = t.x;
- anim.ty = t.y;
+ } else {
+ if (anim.workBuildingId) anim.workBuildingId = null;
+ if (anim.mode !== "wander") {
+ anim.mode = "wander";
+ const t = randomWalkTarget();
+ anim.tx = t.x;
+ anim.ty = t.y;
+ }
}
const dx = anim.tx - anim.x;
@@ -363,7 +386,9 @@ export default function VillageView() {
const x = isoX(t.i, t.j);
const y = isoY(t.i, t.j) + TH / 2;
const stored = Math.floor(b.stored);
- hitRectsRef.current.push({ id: b.id, x: x - 30, y: y - 44, w: 60, h: 72 });
+ const isKitchen = b.type === "kitchen";
+ if (!isKitchen)
+ hitRectsRef.current.push({ id: b.id, x: x - 30, y: y - 44, w: 60, h: 72 });
sprites.push({
y,
draw: () => {
@@ -382,7 +407,7 @@ export default function VillageView() {
ctx.fillStyle = "#374151";
ctx.fillText(b.name, x, y + 24);
if (stored > 0) {
- const label = `${b.resource === "fish" ? "🐟" : "🪵"}${stored}`;
+ const label = `${isKitchen ? "🍖" : b.resource === "fish" ? "🐟" : "🪵"}${stored}`;
ctx.font = "bold 12px sans-serif";
const w = ctx.measureText(label).width + 12;
ctx.beginPath();
diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts
index 0f96807..287d4d6 100644
--- a/src/lib/game/data.ts
+++ b/src/lib/game/data.ts
@@ -67,6 +67,51 @@ export const PASSIVES: PassiveDef[] = [
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
];
+// ---------- 요리 (부엌) ----------
+
+export interface RecipeDef {
+ key: string;
+ name: string;
+ emoji: string;
+ input: Partial>; // 재료 (추후 획득처 확장)
+ work: number; // 요리 1개당 필요 작업량
+ fullness: number; // 먹었을 때 포만도 회복량
+ reqHallLv: number; // 해금 조건: 회관 레벨
+ reqKitchenLv: number; // 해금 조건: 부엌 레벨
+}
+
+export const RECIPES: RecipeDef[] = [
+ {
+ key: "grilled_fish", name: "물고기 구이", emoji: "🍖",
+ input: { fish: 2 }, work: 3, fullness: 40,
+ reqHallLv: 1, reqKitchenLv: 1,
+ },
+ {
+ key: "smoked_fish", name: "훈제 생선", emoji: "🐟",
+ input: { fish: 3 }, work: 6, fullness: 70,
+ reqHallLv: 3, reqKitchenLv: 2, // 아직 잠김 — 회관·부엌 증축으로 해금 예정
+ },
+];
+
+export const KITCHEN_BUILD_COST = { wood: 60 };
+export const KITCHEN_MEAL_CAP = 10; // 요리 선반 최대 보관 수
+export const EAT_THRESHOLD = 65; // 이 포만도 이하면 먹으러 감
+export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치
+
+// ---------- 마을 회관 증축 ----------
+
+export interface HallLevelDef {
+ capacity: number;
+ cost: Partial>;
+ reqCat?: { level: number; count: number };
+ locked?: string; // 아직 구현 안 된 선행 요소 안내
+}
+
+export const HALL_LEVELS: Record = {
+ 2: { capacity: 5, cost: { wood: 100, fish: 80 }, reqCat: { level: 3, count: 1 } },
+ 3: { capacity: 8, cost: { wood: 400 }, reqCat: { level: 5, count: 2 }, locked: "돌 자원(채석장)이 추가된 뒤 해금됩니다" },
+};
+
export const CAT_NAMES = [
"치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두",
"감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이",
diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts
index 2e92686..ff9da3e 100644
--- a/src/lib/game/engine.ts
+++ b/src/lib/game/engine.ts
@@ -4,8 +4,15 @@ import {
BASE_RATE,
BREEDS,
CAT_NAMES,
+ EAT_THRESHOLD,
+ FULLNESS_DECAY_PER_SEC,
+ HALL_LEVELS,
+ KITCHEN_BUILD_COST,
+ KITCHEN_MEAL_CAP,
PASSIVES,
+ RECIPES,
type BreedDef,
+ type RecipeDef,
} from "./data";
const SAVE_KEY = "cat-village-save";
@@ -119,6 +126,46 @@ export function catRate(cat: Cat, aptitudeKey: AptitudeKey): number {
return apt * (cat.stats.diligence / 100) * passiveSpeedMul(cat) * BASE_RATE;
}
+/** 배고픔 페널티: 0이면 0.3배, 25 미만이면 0.7배 */
+export function fullnessPenalty(cat: Cat): number {
+ if (cat.status.fullness <= 0) return 0.3;
+ if (cat.status.fullness < 25) return 0.7;
+ return 1;
+}
+
+/** 실효 산출량 (배고픔 페널티 반영) */
+export function effectiveRate(cat: Cat, aptitudeKey: AptitudeKey): number {
+ return catRate(cat, aptitudeKey) * fullnessPenalty(cat);
+}
+
+function passiveFoodMul(cat: Cat): number {
+ let mul = 1;
+ if (cat.passives.includes("small_eater")) mul *= 0.7;
+ if (cat.passives.includes("big_eater")) mul *= 1.5;
+ return mul;
+}
+
+/** 현재 해금된 레시피 중 가장 마지막 것 (부엌이 요리할 메뉴) */
+export function activeRecipe(s: GameState): RecipeDef {
+ const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1;
+ const unlocked = RECIPES.filter(
+ (r) => r.reqHallLv <= s.hall.level && r.reqKitchenLv <= kitchenLv,
+ );
+ return unlocked[unlocked.length - 1] ?? RECIPES[0];
+}
+
+function canConsume(s: GameState, input: RecipeDef["input"]): boolean {
+ return Object.entries(input).every(
+ ([key, amount]) => s.resources[key as keyof typeof s.resources] >= (amount ?? 0),
+ );
+}
+
+function consume(s: GameState, input: RecipeDef["input"]) {
+ for (const [key, amount] of Object.entries(input)) {
+ s.resources[key as keyof typeof s.resources] -= amount ?? 0;
+ }
+}
+
export const engine = {
init(): GameState {
if (state) return state;
@@ -142,7 +189,7 @@ export const engine = {
for (const catId of building.assigned) {
const cat = state.cats.find((c) => c.id === catId);
if (!cat) continue;
- produced += catRate(cat, building.aptitude) * dt;
+ produced += effectiveRate(cat, building.aptitude) * dt;
// 배치 중 경험치: 초당 1
cat.exp += dt;
const needed = cat.level * 60;
@@ -152,8 +199,47 @@ export const engine = {
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
}
}
- building.stored += produced;
+ if (building.type === "kitchen") {
+ // 요리: 재료가 있고 선반에 자리가 있을 때만 진행
+ const recipe = activeRecipe(state);
+ building.progress = (building.progress ?? 0) + produced;
+ while (building.progress >= recipe.work) {
+ if (building.stored >= KITCHEN_MEAL_CAP || !canConsume(state, recipe.input)) {
+ building.progress = recipe.work; // 재료·선반 대기 상태로 캡
+ break;
+ }
+ consume(state, recipe.input);
+ building.stored += 1;
+ building.progress -= recipe.work;
+ }
+ } else {
+ building.stored += produced;
+ }
}
+
+ // 포만도 감소 (식욕 × 패시브)
+ for (const cat of state.cats) {
+ const decay = cat.stats.appetite * FULLNESS_DECAY_PER_SEC * passiveFoodMul(cat);
+ cat.status.fullness = Math.max(0, cat.status.fullness - decay * dt);
+ }
+
+ // 배고픈 고양이가 부엌에서 자동 식사 (배고픈 순서대로)
+ const kitchens = state.buildings.filter(
+ (b) => b.type === "kitchen" && b.stored >= 1,
+ );
+ if (kitchens.length > 0) {
+ const recipe = activeRecipe(state);
+ const hungry = [...state.cats]
+ .filter((c) => c.status.fullness <= EAT_THRESHOLD)
+ .sort((a, b) => a.status.fullness - b.status.fullness);
+ for (const cat of hungry) {
+ const kitchen = kitchens.find((k) => k.stored >= 1);
+ if (!kitchen) break;
+ kitchen.stored -= 1;
+ cat.status.fullness = Math.min(100, cat.status.fullness + recipe.fullness);
+ }
+ }
+
state.updatedAt = Date.now();
},
@@ -164,6 +250,43 @@ export const engine = {
return null;
},
+ buildKitchen(): string | null {
+ if (!state) return null;
+ if (state.buildings.some((b) => b.type === "kitchen")) return "부엌은 이미 지어져 있습니다";
+ if (state.resources.wood < KITCHEN_BUILD_COST.wood)
+ return `목재가 부족합니다 (필요: ${KITCHEN_BUILD_COST.wood}, 보유: ${Math.floor(state.resources.wood)})`;
+ state.resources.wood -= KITCHEN_BUILD_COST.wood;
+ state.buildings.push({
+ id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
+ level: 1, maxSlots: 2, aptitude: "cooking",
+ assigned: [], stored: 0, progress: 0,
+ });
+ return null;
+ },
+
+ upgradeHall(): string | null {
+ if (!state) return null;
+ const next = HALL_LEVELS[state.hall.level + 1];
+ if (!next) return "회관이 최고 레벨입니다";
+ if (next.locked) return `회관 Lv${state.hall.level + 1}: ${next.locked}`;
+ const missing: string[] = [];
+ for (const [key, amount] of Object.entries(next.cost)) {
+ const have = state.resources[key as keyof typeof state.resources];
+ if (have < (amount ?? 0))
+ missing.push(`${key === "wood" ? "목재" : "생선"} ${Math.floor(have)}/${amount}`);
+ }
+ if (next.reqCat) {
+ const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length;
+ if (count < next.reqCat.count)
+ missing.push(`Lv${next.reqCat.level} 이상 고양이 ${count}/${next.reqCat.count}마리`);
+ }
+ if (missing.length > 0) return `증축 조건 부족 — ${missing.join(", ")}`;
+ consume(state, next.cost);
+ state.hall.level += 1;
+ state.hall.capacity = next.capacity;
+ return null;
+ },
+
assign(catId: string, buildingId: string): string | null {
if (!state) return null;
const cat = state.cats.find((c) => c.id === catId);
@@ -193,7 +316,7 @@ export const engine = {
collect(buildingId: string) {
if (!state) return;
const building = state.buildings.find((b) => b.id === buildingId);
- if (!building) return;
+ if (!building || !building.resource) return; // 부엌은 회수 대상 아님 (고양이가 직접 먹음)
const amount = Math.floor(building.stored);
if (amount <= 0) return;
building.stored -= amount;
diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts
index 35fe7ea..fcc758f 100644
--- a/src/lib/game/types.ts
+++ b/src/lib/game/types.ts
@@ -9,7 +9,7 @@ export type AptitudeKey =
export type ResourceKey = "fish" | "wood";
-export type BuildingType = "dock" | "lumberyard";
+export type BuildingType = "dock" | "lumberyard" | "kitchen";
export interface Cat {
id: string;
@@ -41,9 +41,10 @@ export interface Building {
level: number;
maxSlots: number;
aptitude: AptitudeKey; // 필요 적성
- resource: ResourceKey; // 산출 자원
+ resource?: ResourceKey; // 산출 자원 (생산 건물)
assigned: string[]; // cat id
- stored: number; // 회수 대기 산출물
+ stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
+ progress?: number; // 부엌: 현재 요리 진행 작업량
}
export interface GameState {