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 (
- run(() => engine.adopt())} /> + run(() => engine.adopt())} + onUpgrade={() => run(() => engine.upgradeHall())} + /> @@ -47,13 +58,19 @@ export default function Game() { 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") && ( + run(() => engine.buildKitchen())} + /> + )}
@@ -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 ( -
- 🏛️ 마을 회관 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 {