Phase A: 채석장·돌 자원·회관 Lv3·부엌 증축·훈제 생선·적성 포인트

- 돌 자원 + 채석장(목재 120, 회관 Lv2 해금, 채굴 3슬롯)
- 건설 시스템 공통화 (BUILD_DEFS): 공터 표시·건설 패널 데이터 기반
- 부엌 Lv2 증축 (목재 150+돌 60): 선반 15, 회관 Lv3와 함께 훈제 생선 해금
- 회관 Lv3: 목재 250+돌 150, 채석장·부엌 Lv2·Lv5↑ 2마리 → 수용 8
  (Lv4는 목공소 대기 잠금 예고)
- 적성 포인트: 3레벨마다 1 지급, 카드에서 원하는 적성에 투자 (품종 상한)
- 경험치 필요량 레벨×60 → ×90, 세이브 마이그레이션(stone·aptitudePoints)
- HUD 돌 칩, 자원 이모지/라벨 공통화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 15:01:12 +09:00
co-authored by Claude Fable 5
부모 590cb98ca7
커밋 9de2abfd85
5개의 변경된 파일과 304개의 추가작업 그리고 63개의 파일을 삭제
+161 -32
파일 보기
@@ -13,19 +13,25 @@ import {
import {
APTITUDE_LABEL,
BREEDS,
BUILD_DEFS,
catSprite,
HALL_LEVELS,
KITCHEN_BUILD_COST,
KITCHEN_MEAL_CAP,
KITCHEN_UPGRADE_COST,
mealCap,
PASSIVES,
RECIPES,
TOOL_IMG,
type BuildDef,
} from "@/lib/game/data";
import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types";
const RES_EMOJI: Record<string, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
type Panel =
| { type: "building"; id: string }
| { type: "hall" }
| { type: "kitchen_lot" }
| { type: "lot"; def: BuildDef }
| { type: "cats" }
| null;
@@ -68,8 +74,10 @@ export default function Game() {
<VillageView
onBuildingClick={(id) => {
if (id === "hall") setPanel({ type: "hall" });
else if (id === "kitchen_lot") setPanel({ type: "kitchen_lot" });
else setPanel({ type: "building", id });
else if (id.startsWith("lot_")) {
const def = BUILD_DEFS.find((d) => d.type === id.slice(4));
if (def) setPanel({ type: "lot", def });
} else setPanel({ type: "building", id });
}}
/>
@@ -91,6 +99,9 @@ export default function Game() {
<span className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85">
🪵 {Math.floor(state.resources.wood)}
</span>
<span className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85">
🪨 {Math.floor(state.resources.stone)}
</span>
<span
title="부엌에 보관 중인 요리"
className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85"
@@ -135,13 +146,20 @@ export default function Game() {
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
</SidePanel>
)}
{panel?.type === "kitchen_lot" && (
<SidePanel title="🚧 부엌 공터" onClose={() => setPanel(null)}>
<KitchenLotPanel
wood={state.resources.wood}
{panel?.type === "lot" && (
<SidePanel title={`🚧 ${panel.def.name} 공터`} onClose={() => setPanel(null)}>
<LotPanel
def={panel.def}
state={state}
onBuild={() => {
run(() => engine.buildKitchen());
setPanel({ type: "building", id: "kitchen_1" });
const err = dispatch(() => engine.build(panel.def.type));
if (err) {
setMessage(err);
if (msgTimer.current) clearTimeout(msgTimer.current);
msgTimer.current = setTimeout(() => setMessage(null), 3500);
} else {
setPanel({ type: "building", id: panel.def.id });
}
}}
/>
</SidePanel>
@@ -158,6 +176,7 @@ export default function Game() {
onAssign={(catId) => run(() => engine.assign(catId, openedBuilding.id))}
onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(openedBuilding.id))}
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
/>
</SidePanel>
)}
@@ -165,7 +184,12 @@ export default function Game() {
<SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}>
<div className="space-y-3">
{state.cats.map((cat) => (
<CatCard key={cat.id} cat={cat} buildings={state.buildings} />
<CatCard
key={cat.id}
cat={cat}
buildings={state.buildings}
onInvest={(catId, key) => run(() => engine.investAptitude(catId, key))}
/>
))}
</div>
</SidePanel>
@@ -246,7 +270,16 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
const ok = have >= (amount ?? 0);
return (
<li key={key} className={ok ? "text-emerald-600 dark:text-emerald-400" : ""}>
{ok ? "✓" : "·"} {key === "wood" ? "🪵 목재" : "🐟 생선"} {have}/{amount}
{ok ? "✓" : "·"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
</li>
);
})}
{(next.reqBuildings ?? []).map((req) => {
const b = state.buildings.find((x) => x.type === req.type);
const ok = Boolean(b && b.level >= (req.level ?? 1));
return (
<li key={req.label} className={ok ? "text-emerald-600 dark:text-emerald-400" : ""}>
{ok ? "✓" : "·"} {req.label}
</li>
);
})}
@@ -289,25 +322,54 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
);
}
/* ──────────────── 부엌 공터 패널 ──────────────── */
/* ──────────────── 공터 패널 (건설 공통) ──────────────── */
function KitchenLotPanel({ wood, onBuild }: { wood: number; onBuild: () => void }) {
const canBuild = wood >= KITCHEN_BUILD_COST.wood;
function LotPanel({
def,
state,
onBuild,
}: {
def: BuildDef;
state: GameState;
onBuild: () => void;
}) {
const costEntries = Object.entries(def.cost) as Array<[string, number]>;
const canBuild =
state.hall.level >= def.reqHallLv &&
costEntries.every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= amount,
);
return (
<div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={TOOL_IMG.cooking} alt="부엌" className="mx-auto h-24 object-contain opacity-70" />
<p className="text-sm text-gray-500 dark:text-gray-400">
부엌을 지으면 요리 적성 고양이가 생선으로 요리를 만들고, 배고픈
고양이들이 알아서 먹으러 옵니다.
</p>
<img
src={TOOL_IMG[def.aptitude]}
alt={def.name}
className="mx-auto h-24 object-contain opacity-70"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">{def.desc}</p>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-300">
{def.reqHallLv > 1 && (
<li className={state.hall.level >= def.reqHallLv ? "text-emerald-600 dark:text-emerald-400" : ""}>
{state.hall.level >= def.reqHallLv ? "✓" : "·"} 회관 Lv{def.reqHallLv} 필요
</li>
)}
{costEntries.map(([key, amount]) => {
const have = Math.floor(state.resources[key as keyof GameState["resources"]]);
return (
<li key={key} className={have >= amount ? "text-emerald-600 dark:text-emerald-400" : ""}>
{have >= amount ? "✓" : "·"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
</li>
);
})}
</ul>
<button
onClick={onBuild}
disabled={!canBuild}
className="w-full rounded-lg bg-amber-600 py-2.5 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>
);
@@ -321,12 +383,14 @@ function BuildingPanel({
onAssign,
onUnassign,
onCollect,
onUpgradeKitchen,
}: {
building: Building;
state: GameState;
onAssign: (catId: string) => void;
onUnassign: (catId: string) => void;
onCollect: () => void;
onUpgradeKitchen: () => void;
}) {
const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state);
@@ -360,7 +424,7 @@ function BuildingPanel({
<div>
{recipe.emoji} {recipe.name} (
{Object.entries(recipe.input)
.map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`)
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(" + ")}
→ 포만도 +{recipe.fullness})
</div>
@@ -381,9 +445,40 @@ function BuildingPanel({
{/* 산출/선반 */}
{isKitchen ? (
<div className="rounded-lg bg-amber-50 px-4 py-2.5 text-center text-sm font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-300">
{recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
</div>
<>
<div className="rounded-lg bg-amber-50 px-4 py-2.5 text-center text-sm font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-300">
{recipe.emoji} 선반 {stored}/{mealCap(building.level)} — 배고픈 고양이가 자동으로 먹습니다
</div>
{building.level < 2 && (
<div className="rounded-xl border border-gray-200 p-3 text-sm dark:border-gray-700">
<div className="mb-1 font-semibold">⬆️ 부엌 Lv2 증축</div>
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
선반 15칸으로 확장 · 회관 Lv3 달성 시 🐟 훈제 생선 해금
</p>
<button
onClick={onUpgradeKitchen}
className="w-full rounded-lg bg-emerald-600 py-2 text-xs font-semibold text-white hover:bg-emerald-700"
>
{Object.entries(KITCHEN_UPGRADE_COST)
.map(([k, v]) => `${RES_EMOJI[k]} ${v}`)
.join(" + ")}{" "}
로 증축
</button>
</div>
)}
{(() => {
const kitchenLv = building.level;
const lockedRecipe = RECIPES.find(
(r) => r.reqHallLv > state.hall.level || r.reqKitchenLv > kitchenLv,
);
return lockedRecipe ? (
<p className="text-xs text-gray-400">
🔒 {lockedRecipe.emoji} {lockedRecipe.name} — 회관 Lv
{lockedRecipe.reqHallLv} · 부엌 Lv{lockedRecipe.reqKitchenLv} 해금
</p>
) : null;
})()}
</>
) : (
<button
onClick={onCollect}
@@ -537,9 +632,18 @@ function PassiveBadge({ passiveKey }: { passiveKey: string }) {
/* ──────────────── 고양이 카드 (정보용) ──────────────── */
function CatCard({ cat, buildings }: { cat: Cat; buildings: Building[] }) {
function CatCard({
cat,
buildings,
onInvest,
}: {
cat: Cat;
buildings: Building[];
onInvest: (catId: string, key: AptitudeKey) => void;
}) {
const workplace = buildings.find((b) => b.id === cat.assignedTo);
const expNeeded = cat.level * 60;
const expNeeded = cat.level * 90;
const cap = BREEDS.find((b) => b.key === cat.breed)?.aptitudeCap ?? 5;
return (
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
@@ -564,9 +668,34 @@ function CatCard({ cat, buildings }: { cat: Cat; buildings: Building[] }) {
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
<div className="flex flex-wrap gap-1 text-xs">
{(Object.keys(APTITUDE_LABEL) as AptitudeKey[])
.filter((k) => cat.aptitude[k] > 0 || cat.aptitudePoints > 0)
.map((k) => (
<span
key={k}
className="flex items-center gap-0.5 rounded bg-sky-50 px-1.5 py-0.5 text-[11px] text-sky-700 dark:bg-sky-950 dark:text-sky-300"
>
{APTITUDE_LABEL[k]} {cat.aptitude[k]}
{cat.aptitudePoints > 0 && cat.aptitude[k] < cap && (
<button
onClick={() => onInvest(cat.id, k)}
title="적성 포인트 투자"
className="ml-0.5 rounded bg-sky-600 px-1 text-[10px] font-bold text-white hover:bg-sky-700"
>
+
</button>
)}
</span>
))}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
💪 근면 {cat.stats.diligence}
{cat.aptitudePoints > 0 && (
<span className="ml-2 rounded bg-orange-100 px-1.5 py-0.5 text-[11px] font-semibold text-orange-600 dark:bg-orange-950 dark:text-orange-300">
✨ 적성 포인트 {cat.aptitudePoints}
</span>
)}
</p>
<div className="mt-2 flex items-center gap-2">