Phase C: 냥코인 경제 — 장터·창고 상한·입양 비용·즉시 완공
- 냥코인(🪙) 재화 + HUD 표시, 복덩이 패시브 실효과(일하는 중 평균 100초당 1) - 장터 건물(목재 120, 회관 Lv2): 판매 품목 선택 → 분당 5개 자동 판매 가격: 생선 1·목재 2·돌 3·구이 4·훈제 10 — 가공할수록 이득 - 창고 상한: 자원별 기본 500, 창고 건물(+500)로 확장. 가득 차면 회수 차단 (부분 회수·"창고 가득" 피드백), 건물 적재도 150 한도로 생산 정지 - 입양 비용: 3마리째부터 100+(n-2)×50, 모달에 비용 표시. 재추첨 🎲 30코인 - 즉시 완공: 건설 중 패널에서 남은 10초당 1코인 - 요리 판매는 부엌 선반에서 차감 — "먹일까 팔까" 선택 완성 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+114
-5
@@ -8,8 +8,10 @@ import {
|
|||||||
engine,
|
engine,
|
||||||
effectiveRate,
|
effectiveRate,
|
||||||
activeRecipe,
|
activeRecipe,
|
||||||
|
adoptCost,
|
||||||
fullnessPenalty,
|
fullnessPenalty,
|
||||||
isUnderConstruction,
|
isUnderConstruction,
|
||||||
|
storageCap,
|
||||||
} from "@/lib/game/engine";
|
} from "@/lib/game/engine";
|
||||||
import {
|
import {
|
||||||
APTITUDE_LABEL,
|
APTITUDE_LABEL,
|
||||||
@@ -20,7 +22,10 @@ import {
|
|||||||
DECO_DEFS,
|
DECO_DEFS,
|
||||||
HALL_LEVELS,
|
HALL_LEVELS,
|
||||||
KITCHEN_UPGRADE_COST,
|
KITCHEN_UPGRADE_COST,
|
||||||
|
MEAL_PRICES,
|
||||||
mealCap,
|
mealCap,
|
||||||
|
REROLL_COST,
|
||||||
|
SELL_PRICES,
|
||||||
PASSIVES,
|
PASSIVES,
|
||||||
RECIPES,
|
RECIPES,
|
||||||
TOOL_IMG,
|
TOOL_IMG,
|
||||||
@@ -264,6 +269,7 @@ export default function Game() {
|
|||||||
["🪵", Math.floor(state.resources.wood)],
|
["🪵", Math.floor(state.resources.wood)],
|
||||||
["🪨", Math.floor(state.resources.stone)],
|
["🪨", Math.floor(state.resources.stone)],
|
||||||
["🍖", meals],
|
["🍖", meals],
|
||||||
|
["🪙", Math.floor(state.coins ?? 0)],
|
||||||
].map(([icon, value]) => (
|
].map(([icon, value]) => (
|
||||||
<span
|
<span
|
||||||
key={icon as string}
|
key={icon as string}
|
||||||
@@ -414,6 +420,8 @@ export default function Game() {
|
|||||||
onCollect={() => run(() => engine.collect(openedBuilding.id))}
|
onCollect={() => run(() => engine.collect(openedBuilding.id))}
|
||||||
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
|
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
|
||||||
highlightAssign={tutTarget === "assign"}
|
highlightAssign={tutTarget === "assign"}
|
||||||
|
onRush={() => run(() => engine.rushConstruction(openedBuilding.id))}
|
||||||
|
onSetSell={(key) => run(() => engine.setSellKey(openedBuilding.id, key))}
|
||||||
onMove={() => {
|
onMove={() => {
|
||||||
setPanel(null);
|
setPanel(null);
|
||||||
setPlacing({
|
setPlacing({
|
||||||
@@ -468,9 +476,25 @@ export default function Game() {
|
|||||||
<AdoptModal
|
<AdoptModal
|
||||||
candidates={candidates}
|
candidates={candidates}
|
||||||
highlightPick={tutTarget === "adopt-pick"}
|
highlightPick={tutTarget === "adopt-pick"}
|
||||||
|
cost={adoptCost(state)}
|
||||||
|
coins={Math.floor(state.coins ?? 0)}
|
||||||
|
onReroll={() => {
|
||||||
|
const err = run(() => engine.payReroll());
|
||||||
|
if (!err)
|
||||||
|
setCandidates(
|
||||||
|
engine.generateCandidates(
|
||||||
|
3,
|
||||||
|
state.tutorialStep === 1
|
||||||
|
? "fishing"
|
||||||
|
: state.tutorialStep === 4
|
||||||
|
? "logging"
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
onPick={(cat) => {
|
onPick={(cat) => {
|
||||||
run(() => engine.adoptCandidate(cat));
|
const err = run(() => engine.adoptCandidate(cat));
|
||||||
setCandidates(null);
|
if (!err) setCandidates(null);
|
||||||
}}
|
}}
|
||||||
onClose={() => setCandidates(null)}
|
onClose={() => setCandidates(null)}
|
||||||
/>
|
/>
|
||||||
@@ -721,6 +745,8 @@ function BuildingPanel({
|
|||||||
onUpgradeKitchen,
|
onUpgradeKitchen,
|
||||||
onMove,
|
onMove,
|
||||||
highlightAssign,
|
highlightAssign,
|
||||||
|
onRush,
|
||||||
|
onSetSell,
|
||||||
}: {
|
}: {
|
||||||
building: Building;
|
building: Building;
|
||||||
state: GameState;
|
state: GameState;
|
||||||
@@ -730,6 +756,8 @@ function BuildingPanel({
|
|||||||
onUpgradeKitchen: () => void;
|
onUpgradeKitchen: () => void;
|
||||||
onMove: () => void;
|
onMove: () => void;
|
||||||
highlightAssign?: boolean;
|
highlightAssign?: boolean;
|
||||||
|
onRush: () => void;
|
||||||
|
onSetSell: (key: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const isKitchen = building.type === "kitchen";
|
const isKitchen = building.type === "kitchen";
|
||||||
const recipe = activeRecipe(state);
|
const recipe = activeRecipe(state);
|
||||||
@@ -773,6 +801,62 @@ function BuildingPanel({
|
|||||||
<p className="font-num text-center text-sm text-[#8a6316]">
|
<p className="font-num text-center text-sm text-[#8a6316]">
|
||||||
남은 시간 {Math.floor(remainSec / 60)}:{String(remainSec % 60).padStart(2, "0")}
|
남은 시간 {Math.floor(remainSec / 60)}:{String(remainSec % 60).padStart(2, "0")}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onRush}
|
||||||
|
className="amber-btn w-full py-2 text-sm"
|
||||||
|
>
|
||||||
|
⚡ 즉시 완공 — 🪙 {Math.max(1, Math.ceil(remainMs / 10000))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (building.type === "market") {
|
||||||
|
// 장터: 판매 품목 선택
|
||||||
|
const options: Array<{ key: string; label: string; price: number; stock: number }> = [
|
||||||
|
{ key: "fish", label: "🐟 생선", price: SELL_PRICES.fish, stock: Math.floor(state.resources.fish) },
|
||||||
|
{ key: "wood", label: "🪵 목재", price: SELL_PRICES.wood, stock: Math.floor(state.resources.wood) },
|
||||||
|
{ key: "stone", label: "🪨 돌", price: SELL_PRICES.stone, stock: Math.floor(state.resources.stone) },
|
||||||
|
{
|
||||||
|
key: "meals",
|
||||||
|
label: `${recipe.emoji} 요리(${recipe.name})`,
|
||||||
|
price: MEAL_PRICES[recipe.key] ?? 4,
|
||||||
|
stock: state.buildings
|
||||||
|
.filter((b) => b.type === "kitchen")
|
||||||
|
.reduce((sum, b) => sum + Math.floor(b.stored), 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-[#6f5c39]">
|
||||||
|
판매 품목을 정하면 분당 5개씩 자동으로 팔아 냥코인을 벌어요. 가공한
|
||||||
|
요리일수록 값이 좋답니다!
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button onClick={onMove} className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110">
|
||||||
|
📍 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => onSetSell(building.sellKey === o.key ? null : o.key)}
|
||||||
|
className={`card-nature flex w-full items-center justify-between px-3.5 py-2.5 text-sm hover:brightness-105 ${
|
||||||
|
building.sellKey === o.key ? "tut-highlight" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-jua text-[#3f2c14]">{o.label}</span>
|
||||||
|
<span className="font-num text-xs text-[#8a6316]">
|
||||||
|
개당 🪙{o.price} · 재고 {o.stock}
|
||||||
|
{building.sellKey === o.key && " · 판매 중!"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!building.sellKey && (
|
||||||
|
<p className="text-center text-xs text-[#a3906c]">지금은 아무것도 팔지 않아요</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -793,6 +877,11 @@ function BuildingPanel({
|
|||||||
📍 이동
|
📍 이동
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{building.type === "warehouse" && (
|
||||||
|
<div className="card-nature px-4 py-3 text-center text-sm">
|
||||||
|
현재 보관 상한: <b className="font-num">{storageCap(state)}</b> / 자원
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{building.type === "cat_house" && (
|
{building.type === "cat_house" && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
|
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
|
||||||
@@ -1192,11 +1281,17 @@ function AdoptModal({
|
|||||||
onPick,
|
onPick,
|
||||||
onClose,
|
onClose,
|
||||||
highlightPick,
|
highlightPick,
|
||||||
|
cost,
|
||||||
|
onReroll,
|
||||||
|
coins,
|
||||||
}: {
|
}: {
|
||||||
candidates: Cat[];
|
candidates: Cat[];
|
||||||
onPick: (cat: Cat) => void;
|
onPick: (cat: Cat) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
highlightPick?: boolean;
|
highlightPick?: boolean;
|
||||||
|
cost: number;
|
||||||
|
onReroll: () => void;
|
||||||
|
coins: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1209,7 +1304,12 @@ function AdoptModal({
|
|||||||
>
|
>
|
||||||
<div className="panel-paper max-h-[calc(90vh-20px)] overflow-y-auto p-5">
|
<div className="panel-paper max-h-[calc(90vh-20px)] overflow-y-auto p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-jua text-xl text-[#4a3418]">😻 입양할 고양이를 선택하세요</h2>
|
<h2 className="font-jua text-xl text-[#4a3418]">
|
||||||
|
😻 입양할 고양이를 선택하세요{" "}
|
||||||
|
<span className="font-num text-sm text-[#8a6316]">
|
||||||
|
{cost > 0 ? `(비용 🪙${cost})` : "(무료!)"}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="grid h-8 w-8 place-items-center rounded-full bg-linear-to-b from-[#e8d6ae] to-[#cdb488] text-sm text-[#6b4a20] shadow hover:brightness-105"
|
className="grid h-8 w-8 place-items-center rounded-full bg-linear-to-b from-[#e8d6ae] to-[#cdb488] text-sm text-[#6b4a20] shadow hover:brightness-105"
|
||||||
@@ -1276,13 +1376,22 @@ function AdoptModal({
|
|||||||
onClick={() => onPick(cat)}
|
onClick={() => onPick(cat)}
|
||||||
className={"nature-btn mt-auto py-2 text-sm" + (highlightPick ? " tut-highlight" : "")}
|
className={"nature-btn mt-auto py-2 text-sm" + (highlightPick ? " tut-highlight" : "")}
|
||||||
>
|
>
|
||||||
이 아이로 할래요
|
이 아이로 할래요{cost > 0 ? ` (🪙${cost})` : ""}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-center text-xs text-[#8a7551]">
|
<div className="mt-4 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onReroll}
|
||||||
|
disabled={coins < REROLL_COST}
|
||||||
|
className="wood-plank px-4 py-1.5 text-xs hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
🎲 다른 아이들 보기 (🪙{REROLL_COST})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-center text-xs text-[#8a7551]">
|
||||||
한 마리만 선택할 수 있어요 · 패시브에 마우스를 올리면 상세 정보가 보입니다
|
한 마리만 선택할 수 있어요 · 패시브에 마우스를 올리면 상세 정보가 보입니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -908,13 +908,16 @@ export default function VillageView({
|
|||||||
if (r.kind !== "collect" || !hit(r)) continue;
|
if (r.kind !== "collect" || !hit(r)) continue;
|
||||||
const s = engine.peek();
|
const s = engine.peek();
|
||||||
const b = s?.buildings.find((bb) => bb.id === r.id);
|
const b = s?.buildings.find((bb) => bb.id === r.id);
|
||||||
const amount = b ? Math.floor(b.stored) : 0;
|
if (b && b.resource && Math.floor(b.stored) > 0 && s) {
|
||||||
if (b && b.resource && amount > 0) {
|
const before = Math.floor(s.resources[b.resource]);
|
||||||
engine.collect(r.id);
|
const err = engine.collect(r.id);
|
||||||
engine.save();
|
engine.save();
|
||||||
|
const gained = Math.floor(s.resources[b.resource]) - before;
|
||||||
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} ${RES_EMOJI[b.resource]}`,
|
text: err
|
||||||
|
? "📦 창고 가득!"
|
||||||
|
: `+${gained} ${RES_EMOJI[b.resource]}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
+44
-1
@@ -123,6 +123,8 @@ export const FOOTPRINTS: Record<string, { w: number; h: number }> = {
|
|||||||
kitchen: { w: 2, h: 2 },
|
kitchen: { w: 2, h: 2 },
|
||||||
cat_house: { w: 2, h: 2 },
|
cat_house: { w: 2, h: 2 },
|
||||||
sun_deck: { w: 2, h: 2 },
|
sun_deck: { w: 2, h: 2 },
|
||||||
|
market: { w: 2, h: 2 },
|
||||||
|
warehouse: { w: 2, h: 2 },
|
||||||
};
|
};
|
||||||
export const footprintOf = (type: string) => FOOTPRINTS[type] ?? { w: 1, h: 1 };
|
export const footprintOf = (type: string) => FOOTPRINTS[type] ?? { w: 1, h: 1 };
|
||||||
|
|
||||||
@@ -149,7 +151,15 @@ export const APT_POINT_EVERY = 3; // 3레벨마다 적성 포인트 1
|
|||||||
|
|
||||||
export interface BuildDef {
|
export interface BuildDef {
|
||||||
id: string; // 건물 인스턴스 id (단일 건설)
|
id: string; // 건물 인스턴스 id (단일 건설)
|
||||||
type: "dock" | "lumberyard" | "quarry" | "kitchen" | "cat_house" | "sun_deck";
|
type:
|
||||||
|
| "dock"
|
||||||
|
| "lumberyard"
|
||||||
|
| "quarry"
|
||||||
|
| "kitchen"
|
||||||
|
| "cat_house"
|
||||||
|
| "sun_deck"
|
||||||
|
| "market"
|
||||||
|
| "warehouse";
|
||||||
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -193,6 +203,16 @@ export const BUILD_DEFS: BuildDef[] = [
|
|||||||
aptitude: "grooming", maxSlots: 0, cost: { wood: 120, stone: 40 }, reqHallLv: 3, buildTime: 90,
|
aptitude: "grooming", maxSlots: 0, cost: { wood: 120, stone: 40 }, reqHallLv: 3, buildTime: 90,
|
||||||
desc: "따끈한 햇살 아래 뒹굴뒹굴. 마을 전체의 행복도 회복이 1.5배 빨라집니다.",
|
desc: "따끈한 햇살 아래 뒹굴뒹굴. 마을 전체의 행복도 회복이 1.5배 빨라집니다.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "market_1", type: "market", name: "장터", emoji: "🏪", kind: "passive",
|
||||||
|
aptitude: "crafting", maxSlots: 0, cost: { wood: 120 }, reqHallLv: 2, buildTime: 60,
|
||||||
|
desc: "자원과 요리를 냥코인으로 바꿔줍니다. 판매 품목을 정하면 분당 5개씩 자동 판매!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "warehouse_1", type: "warehouse", name: "창고", emoji: "📦", kind: "passive",
|
||||||
|
aptitude: "crafting", maxSlots: 0, cost: { wood: 200, stone: 100 }, reqHallLv: 3, buildTime: 120,
|
||||||
|
desc: "자원 보관 상한을 +500 늘려줍니다. 꽉 찬 창고는 생산을 멈추게 해요.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- 꾸미기 (바닥 장식) ----------
|
// ---------- 꾸미기 (바닥 장식) ----------
|
||||||
@@ -249,6 +269,29 @@ export const mealCap = (kitchenLv: number) =>
|
|||||||
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; // × 식욕 수치
|
||||||
|
|
||||||
|
// ---------- 경제 (Phase C) ----------
|
||||||
|
|
||||||
|
// 판매가 (개당 냥코인)
|
||||||
|
export const SELL_PRICES: Record<string, number> = {
|
||||||
|
fish: 1,
|
||||||
|
wood: 2,
|
||||||
|
stone: 3,
|
||||||
|
};
|
||||||
|
// 요리 판매가 (레시피 키 기준)
|
||||||
|
export const MEAL_PRICES: Record<string, number> = {
|
||||||
|
grilled_fish: 4,
|
||||||
|
smoked_fish: 10,
|
||||||
|
};
|
||||||
|
export const MARKET_RATE_PER_SEC = 5 / 60; // 분당 5개 판매
|
||||||
|
export const BASE_STORAGE = 500; // 자원별 기본 보관 상한
|
||||||
|
export const WAREHOUSE_STORAGE = 500; // 창고 1채당 추가 상한
|
||||||
|
export const BUILDING_STORE_CAP = 150; // 건물별 산출물 적재 한도 (가득 차면 생산 정지)
|
||||||
|
export const ADOPT_FREE_CATS = 2; // 처음 2마리는 무료
|
||||||
|
export const ADOPT_BASE_COST = 100;
|
||||||
|
export const ADOPT_COST_PER_CAT = 50;
|
||||||
|
export const REROLL_COST = 30; // 입양 재추첨
|
||||||
|
export const RUSH_COST_PER_10S = 1; // 즉시 완공: 남은 10초당 1코인
|
||||||
|
|
||||||
// ---------- 체력·행복도 (Phase B) ----------
|
// ---------- 체력·행복도 (Phase B) ----------
|
||||||
|
|
||||||
export const ENERGY_DRAIN_PER_SEC = 2 / 60; // 작업 중 체력 소모 (분당 2)
|
export const ENERGY_DRAIN_PER_SEC = 2 / 60; // 작업 중 체력 소모 (분당 2)
|
||||||
|
|||||||
+116
-8
@@ -16,8 +16,19 @@ import {
|
|||||||
BREEDS,
|
BREEDS,
|
||||||
BUILD_DEFS,
|
BUILD_DEFS,
|
||||||
CAT_NAMES,
|
CAT_NAMES,
|
||||||
|
ADOPT_BASE_COST,
|
||||||
|
ADOPT_COST_PER_CAT,
|
||||||
|
ADOPT_FREE_CATS,
|
||||||
|
BASE_STORAGE,
|
||||||
|
BUILDING_STORE_CAP,
|
||||||
DECO_DEFS,
|
DECO_DEFS,
|
||||||
DEFAULT_BUILDING_POS,
|
DEFAULT_BUILDING_POS,
|
||||||
|
MARKET_RATE_PER_SEC,
|
||||||
|
MEAL_PRICES,
|
||||||
|
REROLL_COST,
|
||||||
|
RUSH_COST_PER_10S,
|
||||||
|
SELL_PRICES,
|
||||||
|
WAREHOUSE_STORAGE,
|
||||||
EAT_THRESHOLD,
|
EAT_THRESHOLD,
|
||||||
EXP_PER_LEVEL,
|
EXP_PER_LEVEL,
|
||||||
FULLNESS_DECAY_PER_SEC,
|
FULLNESS_DECAY_PER_SEC,
|
||||||
@@ -112,6 +123,7 @@ function createInitialState(): GameState {
|
|||||||
version: 1,
|
version: 1,
|
||||||
boardVersion: BOARD_VERSION,
|
boardVersion: BOARD_VERSION,
|
||||||
tutorialStep: 0,
|
tutorialStep: 0,
|
||||||
|
coins: 0,
|
||||||
resources: { fish: 0, wood: 0, stone: 0 },
|
resources: { fish: 0, wood: 0, stone: 0 },
|
||||||
cats: [],
|
cats: [],
|
||||||
buildings: [],
|
buildings: [],
|
||||||
@@ -214,6 +226,21 @@ export function canPlaceAt(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 자원별 보관 상한 (창고로 확장) */
|
||||||
|
export function storageCap(s: GameState): number {
|
||||||
|
const warehouses = s.buildings.filter(
|
||||||
|
(b) => b.type === "warehouse" && !isUnderConstruction(b),
|
||||||
|
).length;
|
||||||
|
return BASE_STORAGE + warehouses * WAREHOUSE_STORAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다음 입양 비용 (처음 2마리 무료) */
|
||||||
|
export function adoptCost(s: GameState): number {
|
||||||
|
const n = s.cats.length;
|
||||||
|
if (n < ADOPT_FREE_CATS) return 0;
|
||||||
|
return ADOPT_BASE_COST + (n - ADOPT_FREE_CATS) * ADOPT_COST_PER_CAT;
|
||||||
|
}
|
||||||
|
|
||||||
/** 데코를 놓을 수 있는 타일인가 (잔디 위, 건물·나무·물 제외) */
|
/** 데코를 놓을 수 있는 타일인가 (잔디 위, 건물·나무·물 제외) */
|
||||||
export function canPlaceDecoAt(s: GameState, i: number, j: number): boolean {
|
export function canPlaceDecoAt(s: GameState, i: number, j: number): boolean {
|
||||||
if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
|
if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
|
||||||
@@ -278,6 +305,7 @@ export const engine = {
|
|||||||
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
|
state.coins ??= 0;
|
||||||
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
||||||
if (state.boardVersion !== BOARD_VERSION) {
|
if (state.boardVersion !== BOARD_VERSION) {
|
||||||
for (const b of state.buildings)
|
for (const b of state.buildings)
|
||||||
@@ -349,6 +377,9 @@ 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));
|
||||||
if (cat.level % APT_POINT_EVERY === 0) cat.aptitudePoints += 1;
|
if (cat.level % APT_POINT_EVERY === 0) cat.aptitudePoints += 1;
|
||||||
}
|
}
|
||||||
|
// 복덩이: 일하는 중 평균 100초에 1냥코인
|
||||||
|
if (cat.passives.includes("lucky") && Math.random() < dt / 100)
|
||||||
|
state.coins = (state.coins ?? 0) + 1;
|
||||||
}
|
}
|
||||||
if (building.type === "kitchen") {
|
if (building.type === "kitchen") {
|
||||||
// 요리: 재료가 있고 선반에 자리가 있을 때만 진행
|
// 요리: 재료가 있고 선반에 자리가 있을 때만 진행
|
||||||
@@ -364,7 +395,42 @@ export const engine = {
|
|||||||
building.progress -= recipe.work;
|
building.progress -= recipe.work;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
building.stored += produced;
|
// 건물 적재 한도 — 가득 차면 생산 정지 (회수 유도)
|
||||||
|
building.stored = Math.min(BUILDING_STORE_CAP, building.stored + produced);
|
||||||
|
}
|
||||||
|
// 장터: 자동 판매
|
||||||
|
if (
|
||||||
|
building.type === "market" &&
|
||||||
|
building.sellKey &&
|
||||||
|
!isUnderConstruction(building)
|
||||||
|
) {
|
||||||
|
building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt;
|
||||||
|
while (building.progress >= 1) {
|
||||||
|
let sold = false;
|
||||||
|
if (building.sellKey === "meals") {
|
||||||
|
const kitchen = state.buildings.find(
|
||||||
|
(b) => b.type === "kitchen" && b.stored >= 1,
|
||||||
|
);
|
||||||
|
if (kitchen) {
|
||||||
|
kitchen.stored -= 1;
|
||||||
|
state.coins =
|
||||||
|
(state.coins ?? 0) + (MEAL_PRICES[activeRecipe(state).key] ?? 4);
|
||||||
|
sold = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const key = building.sellKey as keyof GameState["resources"];
|
||||||
|
if (state.resources[key] >= 1) {
|
||||||
|
state.resources[key] -= 1;
|
||||||
|
state.coins = (state.coins ?? 0) + (SELL_PRICES[building.sellKey] ?? 1);
|
||||||
|
sold = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sold) {
|
||||||
|
building.progress = 1; // 팔 것이 없으면 대기
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
building.progress -= 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,18 +567,55 @@ export const engine = {
|
|||||||
return Array.from({ length: count }, () => generateCat(forceApt));
|
return Array.from({ length: count }, () => generateCat(forceApt));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 후보 중 한 마리 입양 확정 */
|
/** 후보 중 한 마리 입양 확정 (3마리째부터 냥코인 소모) */
|
||||||
adoptCandidate(cat: Cat): string | null {
|
adoptCandidate(cat: Cat): string | null {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
if (state.cats.length >= state.hall.capacity)
|
if (state.cats.length >= state.hall.capacity)
|
||||||
return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
|
return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
|
||||||
|
const cost = adoptCost(state);
|
||||||
|
if ((state.coins ?? 0) < cost)
|
||||||
|
return `냥코인이 부족합니다 (필요: 🪙${cost})`;
|
||||||
|
state.coins = (state.coins ?? 0) - cost;
|
||||||
state.cats.push(cat);
|
state.cats.push(cat);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 입양 재추첨 비용 지불 */
|
||||||
|
payReroll(): string | null {
|
||||||
|
if (!state) return null;
|
||||||
|
if ((state.coins ?? 0) < REROLL_COST)
|
||||||
|
return `냥코인이 부족합니다 (필요: 🪙${REROLL_COST})`;
|
||||||
|
state.coins = (state.coins ?? 0) - REROLL_COST;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 즉시 완공 (남은 10초당 1냥코인) */
|
||||||
|
rushConstruction(buildingId: string): string | null {
|
||||||
|
if (!state) return null;
|
||||||
|
const b = state.buildings.find((x) => x.id === buildingId);
|
||||||
|
if (!b || b.constructionUntil === undefined) return null;
|
||||||
|
const remain = Math.max(0, b.constructionUntil - Date.now());
|
||||||
|
const cost = Math.max(1, Math.ceil(remain / 10000) * RUSH_COST_PER_10S);
|
||||||
|
if ((state.coins ?? 0) < cost)
|
||||||
|
return `냥코인이 부족합니다 (필요: 🪙${cost})`;
|
||||||
|
state.coins = (state.coins ?? 0) - cost;
|
||||||
|
b.constructionUntil = Date.now() - 1;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 장터 판매 품목 설정 */
|
||||||
|
setSellKey(marketId: string, key: string | null): string | null {
|
||||||
|
if (!state) return null;
|
||||||
|
const m = state.buildings.find((b) => b.id === marketId);
|
||||||
|
if (!m || m.type !== "market") return null;
|
||||||
|
m.sellKey = key;
|
||||||
|
m.progress = 0;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
||||||
build(
|
build(
|
||||||
type: "dock" | "lumberyard" | "quarry" | "kitchen" | "cat_house" | "sun_deck",
|
type: BuildingType,
|
||||||
pos: { i: number; j: number },
|
pos: { i: number; j: number },
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
@@ -571,6 +674,7 @@ export const engine = {
|
|||||||
consume(state, def.cost);
|
consume(state, def.cost);
|
||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
|
state.coins ??= 0;
|
||||||
// 같은 타일의 기존 장식 제거 후 배치
|
// 같은 타일의 기존 장식 제거 후 배치
|
||||||
state.decors = state.decors.filter(
|
state.decors = state.decors.filter(
|
||||||
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
||||||
@@ -682,14 +786,18 @@ export const engine = {
|
|||||||
cat.assignedTo = null;
|
cat.assignedTo = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
collect(buildingId: string) {
|
collect(buildingId: string): string | null {
|
||||||
if (!state) return;
|
if (!state) return null;
|
||||||
const building = state.buildings.find((b) => b.id === buildingId);
|
const building = state.buildings.find((b) => b.id === buildingId);
|
||||||
if (!building || !building.resource) return; // 부엌은 회수 대상 아님 (고양이가 직접 먹음)
|
if (!building || !building.resource) return null; // 부엌은 회수 대상 아님
|
||||||
const amount = Math.floor(building.stored);
|
const cap = storageCap(state);
|
||||||
if (amount <= 0) return;
|
const space = Math.max(0, cap - Math.floor(state.resources[building.resource]));
|
||||||
|
const amount = Math.min(Math.floor(building.stored), space);
|
||||||
|
if (amount <= 0)
|
||||||
|
return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null;
|
||||||
building.stored -= amount;
|
building.stored -= amount;
|
||||||
state.resources[building.resource] += amount;
|
state.resources[building.resource] += amount;
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
|
|||||||
+5
-1
@@ -15,7 +15,9 @@ export type BuildingType =
|
|||||||
| "kitchen"
|
| "kitchen"
|
||||||
| "quarry"
|
| "quarry"
|
||||||
| "cat_house"
|
| "cat_house"
|
||||||
| "sun_deck";
|
| "sun_deck"
|
||||||
|
| "market"
|
||||||
|
| "warehouse";
|
||||||
|
|
||||||
export interface Cat {
|
export interface Cat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -57,6 +59,7 @@ export interface Building {
|
|||||||
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
||||||
progress?: number; // 부엌: 현재 요리 진행 작업량
|
progress?: number; // 부엌: 현재 요리 진행 작업량
|
||||||
constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태)
|
constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태)
|
||||||
|
sellKey?: string | null; // 장터: 판매 품목 (fish/wood/stone/meals)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecoTile {
|
export interface DecoTile {
|
||||||
@@ -69,6 +72,7 @@ export interface GameState {
|
|||||||
version: 1;
|
version: 1;
|
||||||
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
||||||
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
||||||
|
coins?: number; // 냥코인
|
||||||
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
||||||
resources: Record<ResourceKey, number>;
|
resources: Record<ResourceKey, number>;
|
||||||
cats: Cat[];
|
cats: Cat[];
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단