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 { import {
APTITUDE_LABEL, APTITUDE_LABEL,
BREEDS, BREEDS,
BUILD_DEFS,
catSprite, catSprite,
HALL_LEVELS, HALL_LEVELS,
KITCHEN_BUILD_COST, KITCHEN_UPGRADE_COST,
KITCHEN_MEAL_CAP, mealCap,
PASSIVES, PASSIVES,
RECIPES,
TOOL_IMG, TOOL_IMG,
type BuildDef,
} from "@/lib/game/data"; } from "@/lib/game/data";
import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types"; 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 Panel =
| { type: "building"; id: string } | { type: "building"; id: string }
| { type: "hall" } | { type: "hall" }
| { type: "kitchen_lot" } | { type: "lot"; def: BuildDef }
| { type: "cats" } | { type: "cats" }
| null; | null;
@@ -68,8 +74,10 @@ export default function Game() {
<VillageView <VillageView
onBuildingClick={(id) => { onBuildingClick={(id) => {
if (id === "hall") setPanel({ type: "hall" }); if (id === "hall") setPanel({ type: "hall" });
else if (id === "kitchen_lot") setPanel({ type: "kitchen_lot" }); else if (id.startsWith("lot_")) {
else setPanel({ type: "building", id }); 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"> <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)} 🪵 {Math.floor(state.resources.wood)}
</span> </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 <span
title="부엌에 보관 중인 요리" title="부엌에 보관 중인 요리"
className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85" 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())} /> <HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
</SidePanel> </SidePanel>
)} )}
{panel?.type === "kitchen_lot" && ( {panel?.type === "lot" && (
<SidePanel title="🚧 부엌 공터" onClose={() => setPanel(null)}> <SidePanel title={`🚧 ${panel.def.name} 공터`} onClose={() => setPanel(null)}>
<KitchenLotPanel <LotPanel
wood={state.resources.wood} def={panel.def}
state={state}
onBuild={() => { onBuild={() => {
run(() => engine.buildKitchen()); const err = dispatch(() => engine.build(panel.def.type));
setPanel({ type: "building", id: "kitchen_1" }); 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> </SidePanel>
@@ -158,6 +176,7 @@ export default function Game() {
onAssign={(catId) => run(() => engine.assign(catId, openedBuilding.id))} onAssign={(catId) => run(() => engine.assign(catId, openedBuilding.id))}
onUnassign={(catId) => run(() => engine.unassign(catId))} onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(openedBuilding.id))} onCollect={() => run(() => engine.collect(openedBuilding.id))}
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
/> />
</SidePanel> </SidePanel>
)} )}
@@ -165,7 +184,12 @@ export default function Game() {
<SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}> <SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}>
<div className="space-y-3"> <div className="space-y-3">
{state.cats.map((cat) => ( {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> </div>
</SidePanel> </SidePanel>
@@ -246,7 +270,16 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
const ok = have >= (amount ?? 0); const ok = have >= (amount ?? 0);
return ( return (
<li key={key} className={ok ? "text-emerald-600 dark:text-emerald-400" : ""}> <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> </li>
); );
})} })}
@@ -289,25 +322,54 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
); );
} }
/* ──────────────── 부엌 공터 패널 ──────────────── */ /* ──────────────── 공터 패널 (건설 공통) ──────────────── */
function KitchenLotPanel({ wood, onBuild }: { wood: number; onBuild: () => void }) { function LotPanel({
const canBuild = wood >= KITCHEN_BUILD_COST.wood; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={TOOL_IMG.cooking} alt="부엌" className="mx-auto h-24 object-contain opacity-70" /> <img
<p className="text-sm text-gray-500 dark:text-gray-400"> src={TOOL_IMG[def.aptitude]}
부엌을 지으면 요리 적성 고양이가 생선으로 요리를 만들고, 배고픈 alt={def.name}
고양이들이 알아서 먹으러 옵니다. className="mx-auto h-24 object-contain opacity-70"
</p> />
<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 <button
onClick={onBuild} onClick={onBuild}
disabled={!canBuild} 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" 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> </button>
</div> </div>
); );
@@ -321,12 +383,14 @@ function BuildingPanel({
onAssign, onAssign,
onUnassign, onUnassign,
onCollect, onCollect,
onUpgradeKitchen,
}: { }: {
building: Building; building: Building;
state: GameState; state: GameState;
onAssign: (catId: string) => void; onAssign: (catId: string) => void;
onUnassign: (catId: string) => void; onUnassign: (catId: string) => void;
onCollect: () => void; onCollect: () => void;
onUpgradeKitchen: () => void;
}) { }) {
const isKitchen = building.type === "kitchen"; const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state); const recipe = activeRecipe(state);
@@ -360,7 +424,7 @@ function BuildingPanel({
<div> <div>
{recipe.emoji} {recipe.name} ( {recipe.emoji} {recipe.name} (
{Object.entries(recipe.input) {Object.entries(recipe.input)
.map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`) .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(" + ")} .join(" + ")}
→ 포만도 +{recipe.fullness}) → 포만도 +{recipe.fullness})
</div> </div>
@@ -381,9 +445,40 @@ function BuildingPanel({
{/* 산출/선반 */} {/* 산출/선반 */}
{isKitchen ? ( {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 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">
</div> {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 <button
onClick={onCollect} 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 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 ( return (
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700"> <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> </div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <div className="flex flex-wrap gap-1 text-xs">
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "} {(Object.keys(APTITUDE_LABEL) as AptitudeKey[])
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence} .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> </p>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
+16 -10
파일 보기
@@ -2,8 +2,10 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { engine, catRate } from "@/lib/game/engine"; import { engine, catRate } from "@/lib/game/engine";
import { catSprite, TOOL_IMG, type CatFrame } from "@/lib/game/data"; import { BUILD_DEFS, catSprite, TOOL_IMG, type CatFrame } from "@/lib/game/data";
import type { AptitudeKey } from "@/lib/game/types"; import type { AptitudeKey, ResourceKey } from "@/lib/game/types";
const RES_EMOJI: Record<ResourceKey, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
// ---------- 아이소메트릭 그리드 ---------- // ---------- 아이소메트릭 그리드 ----------
const COLS = 11; const COLS = 11;
@@ -30,6 +32,7 @@ const BUILDING_TILES: Record<string, { i: number; j: number }> = {
dock_1: { i: 9, j: 5 }, dock_1: { i: 9, j: 5 },
lumberyard_1: { i: 2, j: 8 }, lumberyard_1: { i: 2, j: 8 },
kitchen_1: { i: 6, j: 7 }, kitchen_1: { i: 6, j: 7 },
quarry_1: { i: 1, j: 2 },
}; };
const TREES: Array<{ i: number; j: number }> = [ const TREES: Array<{ i: number; j: number }> = [
@@ -260,7 +263,7 @@ export default function VillageView({
bubblesRef.current.push({ bubblesRef.current.push({
x: anim.x, y: anim.y - 34, born: now, x: anim.x, y: anim.y - 34, born: now,
text: text:
b.type === "kitchen" ? "🍳" : b.resource === "fish" ? "🐟" : "🪵", b.type === "kitchen" ? "🍳" : RES_EMOJI[b.resource ?? "fish"],
}); });
} }
} }
@@ -451,12 +454,15 @@ export default function VillageView({
}); });
} }
// 부엌 미건설 시 공터 표시 // 미건설 건물 공터 표시 (회관 레벨 충족 시)
if (!state.buildings.some((b) => b.type === "kitchen")) { for (const def of BUILD_DEFS) {
const t = BUILDING_TILES.kitchen_1; if (state.hall.level < def.reqHallLv) continue;
if (state.buildings.some((b) => b.type === def.type)) continue;
const t = BUILDING_TILES[def.id];
if (!t) continue;
const x = isoX(t.i, t.j); const x = isoX(t.i, t.j);
const y = isoY(t.i, t.j) + TH / 2; const y = isoY(t.i, t.j) + TH / 2;
hitRectsRef.current.push({ id: "kitchen_lot", kind: "open", x: x - 30, y: y - 34, w: 60, h: 56 }); hitRectsRef.current.push({ id: `lot_${def.type}`, kind: "open", x: x - 30, y: y - 34, w: 60, h: 56 });
sprites.push({ sprites.push({
y, y,
draw: () => { draw: () => {
@@ -464,7 +470,7 @@ export default function VillageView({
ctx.font = "11px sans-serif"; ctx.font = "11px sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillStyle = "rgba(107,114,128,0.9)"; ctx.fillStyle = "rgba(107,114,128,0.9)";
ctx.fillText("부엌 공터", x, y + 16); ctx.fillText(`${def.name} 공터`, x, y + 16);
}, },
}); });
} }
@@ -500,7 +506,7 @@ export default function VillageView({
ctx.fillStyle = "#374151"; ctx.fillStyle = "#374151";
ctx.fillText(b.name, x, y + 24); ctx.fillText(b.name, x, y + 24);
if (stored > 0) { if (stored > 0) {
const label = `${isKitchen ? "🍖" : b.resource === "fish" ? "🐟" : "🪵"}${stored}`; const label = `${isKitchen ? "🍖" : RES_EMOJI[b.resource ?? "fish"]}${stored}`;
ctx.font = "bold 12px sans-serif"; ctx.font = "bold 12px sans-serif";
const w = ctx.measureText(label).width + 12; const w = ctx.measureText(label).width + 12;
ctx.beginPath(); ctx.beginPath();
@@ -610,7 +616,7 @@ export default function VillageView({
engine.save(); engine.save();
bubblesRef.current.push({ bubblesRef.current.push({
x: r.x + r.w / 2, y: r.y, born: performance.now(), x: r.x + r.w / 2, y: r.y, born: performance.now(),
text: `+${amount} ${b.resource === "fish" ? "🐟" : "🪵"}`, text: `+${amount} ${RES_EMOJI[b.resource]}`,
}); });
} }
return; return;
+52 -5
파일 보기
@@ -86,13 +86,46 @@ export const PASSIVES: PassiveDef[] = [
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 }, { key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
]; ];
// ---------- 성장 ----------
export const EXP_PER_LEVEL = 90; // 레벨업 필요 경험치 = 레벨 × 90
export const APT_POINT_EVERY = 3; // 3레벨마다 적성 포인트 1
// ---------- 건설 가능한 건물 ----------
export interface BuildDef {
id: string; // 건물 인스턴스 id (단일 건설)
type: "quarry" | "kitchen";
name: string;
emoji: string;
aptitude: AptitudeKey;
resource?: "fish" | "wood" | "stone";
maxSlots: number;
cost: Partial<Record<"fish" | "wood" | "stone", number>>;
reqHallLv: number;
desc: string;
}
export const BUILD_DEFS: BuildDef[] = [
{
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
aptitude: "cooking", maxSlots: 2, cost: { wood: 60 }, reqHallLv: 1,
desc: "요리 적성 고양이가 생선으로 요리를 만들고, 배고픈 고양이들이 알아서 먹으러 옵니다.",
},
{
id: "quarry_1", type: "quarry", name: "채석장", emoji: "⛏️",
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2,
desc: "채굴 적성 고양이가 돌을 캡니다. 돌은 회관 증축과 중급 건물에 필요합니다.",
},
];
// ---------- 요리 (부엌) ---------- // ---------- 요리 (부엌) ----------
export interface RecipeDef { export interface RecipeDef {
key: string; key: string;
name: string; name: string;
emoji: string; emoji: string;
input: Partial<Record<"fish" | "wood", number>>; // 재료 (추후 획득처 확장) input: Partial<Record<"fish" | "wood" | "stone", number>>; // 재료 (추후 획득처 확장)
work: number; // 요리 1개당 필요 작업량 work: number; // 요리 1개당 필요 작업량
fullness: number; // 먹었을 때 포만도 회복량 fullness: number; // 먹었을 때 포만도 회복량
reqHallLv: number; // 해금 조건: 회관 레벨 reqHallLv: number; // 해금 조건: 회관 레벨
@@ -112,8 +145,10 @@ export const RECIPES: RecipeDef[] = [
}, },
]; ];
export const KITCHEN_BUILD_COST = { wood: 60 }; export const KITCHEN_MEAL_CAPS = [10, 15]; // 부엌 레벨별 선반 보관 수
export const KITCHEN_MEAL_CAP = 10; // 요리 선반 최대 보관 수 export const KITCHEN_UPGRADE_COST = { wood: 150, stone: 60 }; // Lv1→2
export const mealCap = (kitchenLv: number) =>
KITCHEN_MEAL_CAPS[kitchenLv - 1] ?? KITCHEN_MEAL_CAPS[KITCHEN_MEAL_CAPS.length - 1];
export const EAT_THRESHOLD = 65; // 이 포만도 이하면 먹으러 감 export const EAT_THRESHOLD = 65; // 이 포만도 이하면 먹으러 감
export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치 export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치
@@ -121,14 +156,26 @@ export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치
export interface HallLevelDef { export interface HallLevelDef {
capacity: number; capacity: number;
cost: Partial<Record<"fish" | "wood", number>>; cost: Partial<Record<"fish" | "wood" | "stone", number>>;
reqCat?: { level: number; count: number }; reqCat?: { level: number; count: number };
reqBuildings?: Array<{ type: "quarry" | "kitchen"; level?: number; label: string }>;
locked?: string; // 아직 구현 안 된 선행 요소 안내 locked?: string; // 아직 구현 안 된 선행 요소 안내
} }
export const HALL_LEVELS: Record<number, HallLevelDef> = { export const HALL_LEVELS: Record<number, HallLevelDef> = {
2: { capacity: 5, cost: { wood: 100, fish: 80 }, reqCat: { level: 3, count: 1 } }, 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: "돌 자원(채석장)이 추가된 뒤 해금됩니다" }, 3: {
capacity: 8, cost: { wood: 250, stone: 150 },
reqCat: { level: 5, count: 2 },
reqBuildings: [
{ type: "quarry", label: "채석장 건설" },
{ type: "kitchen", level: 2, label: "부엌 Lv2" },
],
},
4: {
capacity: 12, cost: { stone: 400 }, reqCat: { level: 8, count: 3 },
locked: "목공소(목판)가 추가된 뒤 해금됩니다",
},
}; };
export const CAT_NAMES = [ export const CAT_NAMES = [
+72 -14
파일 보기
@@ -1,14 +1,17 @@
import type { AptitudeKey, Building, Cat, GameState } from "./types"; import type { AptitudeKey, Building, Cat, GameState } from "./types";
import { import {
APT_POINT_EVERY,
APTITUDE_RATE, APTITUDE_RATE,
BASE_RATE, BASE_RATE,
BREEDS, BREEDS,
BUILD_DEFS,
CAT_NAMES, CAT_NAMES,
EAT_THRESHOLD, EAT_THRESHOLD,
EXP_PER_LEVEL,
FULLNESS_DECAY_PER_SEC, FULLNESS_DECAY_PER_SEC,
HALL_LEVELS, HALL_LEVELS,
KITCHEN_BUILD_COST, KITCHEN_UPGRADE_COST,
KITCHEN_MEAL_CAP, mealCap,
PASSIVES, PASSIVES,
RECIPES, RECIPES,
type BreedDef, type BreedDef,
@@ -16,6 +19,7 @@ import {
} from "./data"; } from "./data";
const SAVE_KEY = "cat-village-save"; const SAVE_KEY = "cat-village-save";
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
const APTITUDE_KEYS: AptitudeKey[] = [ const APTITUDE_KEYS: AptitudeKey[] = [
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming", "fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
]; ];
@@ -68,6 +72,7 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
emoji: breed.emoji, emoji: breed.emoji,
level: 1, level: 1,
exp: 0, exp: 0,
aptitudePoints: 0,
aptitude, aptitude,
stats: { stats: {
diligence: rand(50, 150), diligence: rand(50, 150),
@@ -101,7 +106,7 @@ function createInitialState(): GameState {
// 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지) // 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지)
return { return {
version: 1, version: 1,
resources: { fish: 0, wood: 0 }, resources: { fish: 0, wood: 0, stone: 0 },
cats: [generateCat("fishing"), generateCat("logging")], cats: [generateCat("fishing"), generateCat("logging")],
buildings: createBuildings(), buildings: createBuildings(),
hall: { level: 1, capacity: 3 }, hall: { level: 1, capacity: 3 },
@@ -179,6 +184,9 @@ export const engine = {
// 저장 데이터가 깨졌으면 새로 시작 // 저장 데이터가 깨졌으면 새로 시작
} }
if (!state) state = createInitialState(); if (!state) state = createInitialState();
// 구버전 세이브 마이그레이션 (필드 보강)
state.resources.stone ??= 0;
for (const cat of state.cats) cat.aptitudePoints ??= 0;
return state; return state;
}, },
@@ -192,11 +200,12 @@ export const engine = {
produced += effectiveRate(cat, building.aptitude) * dt; produced += effectiveRate(cat, building.aptitude) * dt;
// 배치 중 경험치: 초당 1 // 배치 중 경험치: 초당 1
cat.exp += dt; cat.exp += dt;
const needed = cat.level * 60; const needed = cat.level * EXP_PER_LEVEL;
if (cat.exp >= needed) { if (cat.exp >= needed) {
cat.exp -= needed; cat.exp -= needed;
cat.level += 1; cat.level += 1;
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3)); cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
if (cat.level % APT_POINT_EVERY === 0) cat.aptitudePoints += 1;
} }
} }
if (building.type === "kitchen") { if (building.type === "kitchen") {
@@ -204,7 +213,7 @@ export const engine = {
const recipe = activeRecipe(state); const recipe = activeRecipe(state);
building.progress = (building.progress ?? 0) + produced; building.progress = (building.progress ?? 0) + produced;
while (building.progress >= recipe.work) { while (building.progress >= recipe.work) {
if (building.stored >= KITCHEN_MEAL_CAP || !canConsume(state, recipe.input)) { if (building.stored >= mealCap(building.level) || !canConsume(state, recipe.input)) {
building.progress = recipe.work; // 재료·선반 대기 상태로 캡 building.progress = recipe.work; // 재료·선반 대기 상태로 캡
break; break;
} }
@@ -257,20 +266,65 @@ export const engine = {
return null; return null;
}, },
buildKitchen(): string | null { /** 공통 건설: BUILD_DEFS 기반 (부엌·채석장 …) */
build(type: "quarry" | "kitchen"): string | null {
if (!state) return null; if (!state) return null;
if (state.buildings.some((b) => b.type === "kitchen")) return "부엌은 이미 지어져 있습니다"; const def = BUILD_DEFS.find((d) => d.type === type);
if (state.resources.wood < KITCHEN_BUILD_COST.wood) if (!def) return null;
return `목재가 부족합니다 (필요: ${KITCHEN_BUILD_COST.wood}, 보유: ${Math.floor(state.resources.wood)})`; if (state.buildings.some((b) => b.type === type))
state.resources.wood -= KITCHEN_BUILD_COST.wood; return `${def.name}은(는) 이미 지어져 있습니다`;
if (state.hall.level < def.reqHallLv)
return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`;
const missing = Object.entries(def.cost).filter(
([key, amount]) =>
state!.resources[key as keyof GameState["resources"]] < (amount ?? 0),
);
if (missing.length > 0)
return `자원이 부족합니다 (${missing
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(", ")})`;
consume(state, def.cost);
state.buildings.push({ state.buildings.push({
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳", id: def.id, type: def.type, name: def.name, emoji: def.emoji,
level: 1, maxSlots: 2, aptitude: "cooking", level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
assigned: [], stored: 0, progress: 0, resource: def.resource, assigned: [], stored: 0, progress: 0,
}); });
return null; return null;
}, },
/** 부엌 증축 Lv1→2: 훈제 생선 해금 조건 + 선반 확장 */
upgradeKitchen(): string | null {
if (!state) return null;
const kitchen = state.buildings.find((b) => b.type === "kitchen");
if (!kitchen) return "부엌이 없습니다";
if (kitchen.level >= 2) return "부엌이 최고 레벨입니다";
const missing = Object.entries(KITCHEN_UPGRADE_COST).filter(
([key, amount]) =>
state!.resources[key as keyof GameState["resources"]] < amount,
);
if (missing.length > 0)
return `자원이 부족합니다 (${missing
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(", ")})`;
consume(state, KITCHEN_UPGRADE_COST);
kitchen.level = 2;
return null;
},
/** 적성 포인트 투자 (품종 상한까지) */
investAptitude(catId: string, key: AptitudeKey): string | null {
if (!state) return null;
const cat = state.cats.find((c) => c.id === catId);
if (!cat) return null;
if (cat.aptitudePoints <= 0) return "적성 포인트가 없습니다";
const cap = BREEDS.find((b) => b.key === cat.breed)?.aptitudeCap ?? 5;
if (cat.aptitude[key] >= cap)
return `${cat.breedName}의 적성 상한(${cap})에 도달했습니다`;
cat.aptitude[key] += 1;
cat.aptitudePoints -= 1;
return null;
},
upgradeHall(): string | null { upgradeHall(): string | null {
if (!state) return null; if (!state) return null;
const next = HALL_LEVELS[state.hall.level + 1]; const next = HALL_LEVELS[state.hall.level + 1];
@@ -280,7 +334,11 @@ export const engine = {
for (const [key, amount] of Object.entries(next.cost)) { for (const [key, amount] of Object.entries(next.cost)) {
const have = state.resources[key as keyof typeof state.resources]; const have = state.resources[key as keyof typeof state.resources];
if (have < (amount ?? 0)) if (have < (amount ?? 0))
missing.push(`${key === "wood" ? "목재" : "생선"} ${Math.floor(have)}/${amount}`); missing.push(`${RES_LABEL[key] ?? key} ${Math.floor(have)}/${amount}`);
}
for (const req of next.reqBuildings ?? []) {
const b = state.buildings.find((x) => x.type === req.type);
if (!b || b.level < (req.level ?? 1)) missing.push(req.label);
} }
if (next.reqCat) { if (next.reqCat) {
const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length; const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length;
+3 -2
파일 보기
@@ -7,9 +7,9 @@ export type AptitudeKey =
| "crafting" | "crafting"
| "grooming"; | "grooming";
export type ResourceKey = "fish" | "wood"; export type ResourceKey = "fish" | "wood" | "stone";
export type BuildingType = "dock" | "lumberyard" | "kitchen"; export type BuildingType = "dock" | "lumberyard" | "kitchen" | "quarry";
export interface Cat { export interface Cat {
id: string; id: string;
@@ -19,6 +19,7 @@ export interface Cat {
emoji: string; emoji: string;
level: number; level: number;
exp: number; exp: number;
aptitudePoints: number; // 3레벨마다 1 지급, 원하는 적성에 투자
aptitude: Record<AptitudeKey, number>; aptitude: Record<AptitudeKey, number>;
stats: { stats: {
diligence: number; // 50~150, 작업 속도 배율 diligence: number; // 50~150, 작업 속도 배율