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 { useState } from "react";
|
||||||
import { useGame } from "@/hooks/useGame";
|
import { useGame } from "@/hooks/useGame";
|
||||||
import VillageView from "@/components/game/VillageView";
|
import VillageView from "@/components/game/VillageView";
|
||||||
import { engine, catRate } from "@/lib/game/engine";
|
import { engine, effectiveRate, activeRecipe, fullnessPenalty } from "@/lib/game/engine";
|
||||||
import { APTITUDE_LABEL, PASSIVES, TOOL_IMG } from "@/lib/game/data";
|
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";
|
import type { Building, Cat, GameState } from "@/lib/game/types";
|
||||||
|
|
||||||
export default function Game() {
|
export default function Game() {
|
||||||
@@ -27,7 +34,11 @@ export default function Game() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<TopBar state={state} onAdopt={() => run(() => engine.adopt())} />
|
<TopBar
|
||||||
|
state={state}
|
||||||
|
onAdopt={() => run(() => engine.adopt())}
|
||||||
|
onUpgrade={() => run(() => engine.upgradeHall())}
|
||||||
|
/>
|
||||||
|
|
||||||
<VillageView />
|
<VillageView />
|
||||||
|
|
||||||
@@ -47,13 +58,19 @@ export default function Game() {
|
|||||||
<BuildingCard
|
<BuildingCard
|
||||||
key={b.id}
|
key={b.id}
|
||||||
building={b}
|
building={b}
|
||||||
cats={state.cats}
|
state={state}
|
||||||
selectedCat={selectedCat}
|
selectedCat={selectedCat}
|
||||||
onAssign={(catId) => run(() => engine.assign(catId, b.id))}
|
onAssign={(catId) => run(() => engine.assign(catId, b.id))}
|
||||||
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
||||||
onCollect={() => run(() => engine.collect(b.id))}
|
onCollect={() => run(() => engine.collect(b.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!state.buildings.some((b) => b.type === "kitchen") && (
|
||||||
|
<BuildKitchenCard
|
||||||
|
wood={state.resources.wood}
|
||||||
|
onBuild={() => run(() => engine.buildKitchen())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 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 (
|
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">
|
<section className="rounded-xl border border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||||
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
|
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
|
||||||
<span>🐟 {Math.floor(state.resources.fish)}</span>
|
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
|
||||||
<span>🪵 {Math.floor(state.resources.wood)}</span>
|
<span>🐟 {Math.floor(state.resources.fish)}</span>
|
||||||
<button
|
<span>🪵 {Math.floor(state.resources.wood)}</span>
|
||||||
onClick={onAdopt}
|
<span title="부엌에 보관 중인 요리">🍖 {meals}</span>
|
||||||
disabled={full}
|
<div className="ml-auto flex gap-2">
|
||||||
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"
|
<button
|
||||||
>
|
onClick={onUpgrade}
|
||||||
{full ? "수용 인원 가득 참" : "😻 입양하기"}
|
disabled={!next}
|
||||||
</button>
|
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>
|
</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({
|
function BuildingCard({
|
||||||
building,
|
building,
|
||||||
cats,
|
state,
|
||||||
selectedCat,
|
selectedCat,
|
||||||
onAssign,
|
onAssign,
|
||||||
onUnassign,
|
onUnassign,
|
||||||
onCollect,
|
onCollect,
|
||||||
}: {
|
}: {
|
||||||
building: Building;
|
building: Building;
|
||||||
cats: Cat[];
|
state: GameState;
|
||||||
selectedCat: Cat | null;
|
selectedCat: Cat | null;
|
||||||
onAssign: (catId: string) => void;
|
onAssign: (catId: string) => void;
|
||||||
onUnassign: (catId: string) => void;
|
onUnassign: (catId: string) => void;
|
||||||
onCollect: () => void;
|
onCollect: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const cats = state.cats;
|
||||||
|
const isKitchen = building.type === "kitchen";
|
||||||
|
const recipe = activeRecipe(state);
|
||||||
const assignedCats = building.assigned
|
const assignedCats = building.assigned
|
||||||
.map((id) => cats.find((c) => c.id === id))
|
.map((id) => cats.find((c) => c.id === id))
|
||||||
.filter((c): c is Cat => Boolean(c));
|
.filter((c): c is Cat => Boolean(c));
|
||||||
const totalRate = assignedCats.reduce(
|
const totalRate = assignedCats.reduce(
|
||||||
(sum, c) => sum + catRate(c, building.aptitude),
|
(sum, c) => sum + effectiveRate(c, building.aptitude),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const stored = Math.floor(building.stored);
|
const stored = Math.floor(building.stored);
|
||||||
@@ -143,9 +216,19 @@ function BuildingCard({
|
|||||||
필요 적성: {APTITUDE_LABEL[building.aptitude]}
|
필요 적성: {APTITUDE_LABEL[building.aptitude]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
{isKitchen ? (
|
||||||
생산 속도 {totalRate.toFixed(2)}/초
|
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{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">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
{assignedCats.map((cat) => (
|
{assignedCats.map((cat) => (
|
||||||
@@ -170,13 +253,19 @@ function BuildingCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{isKitchen ? (
|
||||||
onClick={onCollect}
|
<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">
|
||||||
disabled={stored <= 0}
|
{recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
|
||||||
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"
|
</div>
|
||||||
>
|
) : (
|
||||||
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
|
<button
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,10 +309,30 @@ function CatCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 💪 근면{" "}
|
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
|
||||||
{cat.stats.diligence}
|
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
|
||||||
</p>
|
</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 && (
|
{cat.passives.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{cat.passives.map((key) => {
|
{cat.passives.map((key) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const BUILDING_TILES: Record<string, { i: number; j: number }> = {
|
|||||||
hall: { i: 5, j: 3 },
|
hall: { i: 5, j: 3 },
|
||||||
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREES: Array<{ i: number; j: number }> = [
|
const TREES: Array<{ i: number; j: number }> = [
|
||||||
@@ -54,6 +55,8 @@ interface CatAnim {
|
|||||||
mode: "wander" | "toWork" | "working";
|
mode: "wander" | "toWork" | "working";
|
||||||
waitUntil: number;
|
waitUntil: number;
|
||||||
flip: boolean;
|
flip: boolean;
|
||||||
|
lastFullness?: number;
|
||||||
|
workBuildingId?: string | null;
|
||||||
}
|
}
|
||||||
interface Bubble {
|
interface Bubble {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -152,20 +155,40 @@ export default function VillageView() {
|
|||||||
anims.set(cat.id, anim);
|
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) {
|
if (cat.assignedTo) {
|
||||||
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
||||||
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
||||||
const spot = workSpot(cat.assignedTo, Math.max(0, slot));
|
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.tx = spot.x;
|
||||||
anim.ty = spot.y;
|
anim.ty = spot.y;
|
||||||
anim.mode = "toWork";
|
anim.mode = "toWork";
|
||||||
}
|
}
|
||||||
} else if (anim.mode !== "wander") {
|
} else {
|
||||||
anim.mode = "wander";
|
if (anim.workBuildingId) anim.workBuildingId = null;
|
||||||
const t = randomWalkTarget();
|
if (anim.mode !== "wander") {
|
||||||
anim.tx = t.x;
|
anim.mode = "wander";
|
||||||
anim.ty = t.y;
|
const t = randomWalkTarget();
|
||||||
|
anim.tx = t.x;
|
||||||
|
anim.ty = t.y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dx = anim.tx - anim.x;
|
const dx = anim.tx - anim.x;
|
||||||
@@ -363,7 +386,9 @@ export default function VillageView() {
|
|||||||
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;
|
||||||
const stored = Math.floor(b.stored);
|
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({
|
sprites.push({
|
||||||
y,
|
y,
|
||||||
draw: () => {
|
draw: () => {
|
||||||
@@ -382,7 +407,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 = `${b.resource === "fish" ? "🐟" : "🪵"}${stored}`;
|
const label = `${isKitchen ? "🍖" : 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();
|
||||||
|
|||||||
@@ -67,6 +67,51 @@ 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 interface RecipeDef {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
input: Partial<Record<"fish" | "wood", number>>; // 재료 (추후 획득처 확장)
|
||||||
|
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<Record<"fish" | "wood", number>>;
|
||||||
|
reqCat?: { level: number; count: number };
|
||||||
|
locked?: string; // 아직 구현 안 된 선행 요소 안내
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HALL_LEVELS: Record<number, HallLevelDef> = {
|
||||||
|
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 = [
|
export const CAT_NAMES = [
|
||||||
"치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두",
|
"치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두",
|
||||||
"감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이",
|
"감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이",
|
||||||
|
|||||||
+126
-3
@@ -4,8 +4,15 @@ import {
|
|||||||
BASE_RATE,
|
BASE_RATE,
|
||||||
BREEDS,
|
BREEDS,
|
||||||
CAT_NAMES,
|
CAT_NAMES,
|
||||||
|
EAT_THRESHOLD,
|
||||||
|
FULLNESS_DECAY_PER_SEC,
|
||||||
|
HALL_LEVELS,
|
||||||
|
KITCHEN_BUILD_COST,
|
||||||
|
KITCHEN_MEAL_CAP,
|
||||||
PASSIVES,
|
PASSIVES,
|
||||||
|
RECIPES,
|
||||||
type BreedDef,
|
type BreedDef,
|
||||||
|
type RecipeDef,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
|
||||||
const SAVE_KEY = "cat-village-save";
|
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;
|
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 = {
|
export const engine = {
|
||||||
init(): GameState {
|
init(): GameState {
|
||||||
if (state) return state;
|
if (state) return state;
|
||||||
@@ -142,7 +189,7 @@ export const engine = {
|
|||||||
for (const catId of building.assigned) {
|
for (const catId of building.assigned) {
|
||||||
const cat = state.cats.find((c) => c.id === catId);
|
const cat = state.cats.find((c) => c.id === catId);
|
||||||
if (!cat) continue;
|
if (!cat) continue;
|
||||||
produced += catRate(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 * 60;
|
||||||
@@ -152,8 +199,47 @@ export const engine = {
|
|||||||
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
|
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();
|
state.updatedAt = Date.now();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -164,6 +250,43 @@ export const engine = {
|
|||||||
return null;
|
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 {
|
assign(catId: string, buildingId: string): string | null {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const cat = state.cats.find((c) => c.id === catId);
|
const cat = state.cats.find((c) => c.id === catId);
|
||||||
@@ -193,7 +316,7 @@ export const engine = {
|
|||||||
collect(buildingId: string) {
|
collect(buildingId: string) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const building = state.buildings.find((b) => b.id === buildingId);
|
const building = state.buildings.find((b) => b.id === buildingId);
|
||||||
if (!building) return;
|
if (!building || !building.resource) return; // 부엌은 회수 대상 아님 (고양이가 직접 먹음)
|
||||||
const amount = Math.floor(building.stored);
|
const amount = Math.floor(building.stored);
|
||||||
if (amount <= 0) return;
|
if (amount <= 0) return;
|
||||||
building.stored -= amount;
|
building.stored -= amount;
|
||||||
|
|||||||
+4
-3
@@ -9,7 +9,7 @@ export type AptitudeKey =
|
|||||||
|
|
||||||
export type ResourceKey = "fish" | "wood";
|
export type ResourceKey = "fish" | "wood";
|
||||||
|
|
||||||
export type BuildingType = "dock" | "lumberyard";
|
export type BuildingType = "dock" | "lumberyard" | "kitchen";
|
||||||
|
|
||||||
export interface Cat {
|
export interface Cat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,9 +41,10 @@ export interface Building {
|
|||||||
level: number;
|
level: number;
|
||||||
maxSlots: number;
|
maxSlots: number;
|
||||||
aptitude: AptitudeKey; // 필요 적성
|
aptitude: AptitudeKey; // 필요 적성
|
||||||
resource: ResourceKey; // 산출 자원
|
resource?: ResourceKey; // 산출 자원 (생산 건물)
|
||||||
assigned: string[]; // cat id
|
assigned: string[]; // cat id
|
||||||
stored: number; // 회수 대기 산출물
|
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
||||||
|
progress?: number; // 부엌: 현재 요리 진행 작업량
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단