목공소·상위 자재·장터 다중 슬롯 (경제 확장)

- 신규 자원: 판자(목재5→1, 판매 8코인)·석재 블록(돌5→1, 판매 12코인)
- 목공소(회관 Lv3, 제작 적성 2슬롯): 품목 선택 후 재료 소모하며
  자동 반복 가공 — 부엌과 동일한 조작 문법, 수거는 생산 건물과 동일
- 장터 증축: Lv1~3 = 판매 슬롯 1~3개, 슬롯별 품목 독립 판매(분당 5개)
  Lv2 비용 목재200+돌80, Lv3 비용 판자30+블록15
- 회관 Lv4 해금: 목공소 건설 + 판자60·블록40 + Lv8 고양이 2 (수용 12)
- HUD 판자·블록 필(목공소 보유 시), 채석장 회수 버튼 돌 이모지 수정
- 마이그레이션: sellKey→sellKeys[], resources.plank/block, craftKey
- 목공소 건물 이미지·아이콘은 임시(창고 변형) — 전용 에셋 교체 예정

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 17:17:50 +09:00
co-authored by Claude Fable 5
부모 ce8e3c8195
커밋 edc9a91e09
8개의 변경된 파일과 323개의 추가작업 그리고 78개의 파일을 삭제
바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 178 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 16 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 16 KiB

+135 -25
파일 보기
@@ -27,6 +27,8 @@ import {
HALL_LEVELS, HALL_LEVELS,
ICON_IMG, ICON_IMG,
KITCHEN_UPGRADE_COST, KITCHEN_UPGRADE_COST,
CRAFT_RECIPES,
MARKET_LEVELS,
MEAL_PRICES, MEAL_PRICES,
mealCap, mealCap,
REROLL_COST, REROLL_COST,
@@ -38,8 +40,12 @@ import {
} 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_EMOJI: Record<string, string> = {
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" }; fish: "🐟", wood: "🪵", stone: "🪨", plank: "🪚", block: "🧱",
};
const RES_LABEL: Record<string, string> = {
fish: "생선", wood: "목재", stone: "돌", plank: "판자", block: "석재 블록",
};
// ---------- 튜토리얼 ---------- // ---------- 튜토리얼 ----------
interface TutStep { interface TutStep {
@@ -292,6 +298,14 @@ export default function Game() {
["🐟", ICON_IMG.fish, Math.floor(state.resources.fish)], ["🐟", ICON_IMG.fish, Math.floor(state.resources.fish)],
["🪵", ICON_IMG.wood, Math.floor(state.resources.wood)], ["🪵", ICON_IMG.wood, Math.floor(state.resources.wood)],
["🪨", ICON_IMG.stone, Math.floor(state.resources.stone)], ["🪨", ICON_IMG.stone, Math.floor(state.resources.stone)],
...(state.buildings.some((b) => b.type === "workshop") ||
(state.resources.plank ?? 0) > 0 ||
(state.resources.block ?? 0) > 0
? ([
["🪚", ICON_IMG.plank, Math.floor(state.resources.plank ?? 0)],
["🧱", ICON_IMG.block, Math.floor(state.resources.block ?? 0)],
] as Array<[string, string | null, string | number]>)
: []),
["🍖", ICON_IMG.meal, meals], ["🍖", ICON_IMG.meal, meals],
["🪙", ICON_IMG.coin, Math.floor(state.coins ?? 0)], ["🪙", ICON_IMG.coin, Math.floor(state.coins ?? 0)],
] as Array<[string, string | null, string | number]> ] as Array<[string, string | null, string | number]>
@@ -491,7 +505,11 @@ export default function Game() {
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())} onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
highlightAssign={tutTarget === "assign"} highlightAssign={tutTarget === "assign"}
onRush={() => run(() => engine.rushConstruction(openedBuilding.id))} onRush={() => run(() => engine.rushConstruction(openedBuilding.id))}
onSetSell={(key) => run(() => engine.setSellKey(openedBuilding.id, key))} onSetSell={(slot, key) =>
run(() => engine.setSellSlot(openedBuilding.id, slot, key))
}
onUpgradeMarket={() => run(() => engine.upgradeMarket())}
onSetCraft={(key) => run(() => engine.setCraftKey(openedBuilding.id, key))}
onMove={() => { onMove={() => {
setPanel(null); setPanel(null);
setPlacing({ setPlacing({
@@ -827,6 +845,8 @@ function BuildingPanel({
highlightAssign, highlightAssign,
onRush, onRush,
onSetSell, onSetSell,
onUpgradeMarket,
onSetCraft,
}: { }: {
building: Building; building: Building;
state: GameState; state: GameState;
@@ -837,7 +857,9 @@ function BuildingPanel({
onMove: () => void; onMove: () => void;
highlightAssign?: boolean; highlightAssign?: boolean;
onRush: () => void; onRush: () => void;
onSetSell: (key: string | null) => void; onSetSell: (slot: number, key: string | null) => void;
onUpgradeMarket: () => void;
onSetCraft: (key: string | null) => void;
}) { }) {
const isKitchen = building.type === "kitchen"; const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state); const recipe = activeRecipe(state);
@@ -897,6 +919,8 @@ function BuildingPanel({
{ key: "fish", label: "🐟 생선", price: SELL_PRICES.fish, stock: Math.floor(state.resources.fish) }, { 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: "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: "stone", label: "🪨 돌", price: SELL_PRICES.stone, stock: Math.floor(state.resources.stone) },
{ key: "plank", label: "🪚 판자", price: SELL_PRICES.plank, stock: Math.floor(state.resources.plank ?? 0) },
{ key: "block", label: "🧱 석재 블록", price: SELL_PRICES.block, stock: Math.floor(state.resources.block ?? 0) },
{ {
key: "meals", key: "meals",
label: `${recipe.emoji} 요리(${recipe.name})`, label: `${recipe.emoji} 요리(${recipe.name})`,
@@ -906,41 +930,72 @@ function BuildingPanel({
.reduce((sum, b) => sum + Math.floor(b.stored), 0), .reduce((sum, b) => sum + Math.floor(b.stored), 0),
}, },
]; ];
const sellKeys = building.sellKeys ?? [null];
const slots = Array.from({ length: building.level }, (_, i) => sellKeys[i] ?? null);
const nextMarket = MARKET_LEVELS[building.level + 1];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-[#6f5c39]"> <p className="text-sm text-[#6f5c39]">
판매 품목을 정하면 분당 5개씩 자동으로 팔아 냥코인을 벌어요. 가공한 슬롯마다 품목을 정하면 슬롯당 분당 5개씩 자동으로 팔아요. 가공한
요리일수록 값이 좋답니다! 자재·요리일수록 값이 좋답니다!
</p> </p>
<div className="flex items-center justify-end"> <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 onClick={onMove} className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110">
📍 이동 📍 이동
</button> </button>
</div> </div>
<div className="space-y-2"> {slots.map((sel, slotIdx) => (
{options.map((o) => ( <div key={slotIdx} className="space-y-1.5">
<button <div className="font-jua text-xs text-[#8a6316]">
key={o.key} 📦 판매 슬롯 {slotIdx + 1}
onClick={() => onSetSell(building.sellKey === o.key ? null : o.key)} {sel && (
className={`card-nature flex w-full items-center justify-between px-3.5 py-2.5 text-sm hover:brightness-105 ${ <span className="font-num ml-1.5 text-[10px] text-[#5c8a2a]">
building.sellKey === o.key ? "tut-highlight" : "" {options.find((o) => o.key === sel)?.label} 판매 중
}`} </span>
> )}
<span className="font-jua text-[#3f2c14]">{o.label}</span> </div>
<span className="font-num text-xs text-[#8a6316]"> <div className="grid grid-cols-3 gap-1.5">
개당 🪙{o.price} · 재고 {o.stock} {options.map((o) => (
{building.sellKey === o.key && " · 판매 중!"} <button
</span> key={o.key}
</button> onClick={() => onSetSell(slotIdx, sel === o.key ? null : o.key)}
))} title={`개당 🪙${o.price} · 재고 ${o.stock}`}
</div> className={`card-nature px-1.5 py-1.5 text-center text-xs hover:brightness-105 ${
{!building.sellKey && ( sel === o.key ? "tut-highlight" : ""
}`}
>
<div className="font-jua text-[#3f2c14]">{o.label}</div>
<div className="font-num text-[10px] text-[#8a6316]">
🪙{o.price} · {o.stock}
</div>
</button>
))}
</div>
</div>
))}
{slots.every((k) => !k) && (
<p className="text-center text-xs text-[#a3906c]">지금은 아무것도 팔지 않아요</p> <p className="text-center text-xs text-[#a3906c]">지금은 아무것도 팔지 않아요</p>
)} )}
{nextMarket && (
<button
onClick={onUpgradeMarket}
className="amber-btn w-full py-2 text-sm"
>
⬆️ 장터 Lv{building.level + 1} 증축 — 판매 슬롯 +1 (
{Object.entries(nextMarket.cost)
.map(([k, v]) => `${RES_EMOJI[k] ?? k}${v}`)
.join(" ")}
)
</button>
)}
{!nextMarket && (
<p className="text-center text-xs text-[#a3906c]">최고 레벨 장터예요! (슬롯 3개)</p>
)}
</div> </div>
); );
} }
if (building.maxSlots === 0) { if (building.maxSlots === 0) {
// 패시브 건물 — 배치 없음 // 패시브 건물 — 배치 없음
const restingHere = state.cats.filter((c) => c.state === "resting"); const restingHere = state.cats.filter((c) => c.state === "resting");
@@ -993,6 +1048,37 @@ function BuildingPanel({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* 정보 */} {/* 정보 */}
{building.type === "workshop" && (
<div className="space-y-2">
<p className="text-sm text-[#6f5c39]">
품목을 정하면 배치된 고양이가 재료를 소모하며 계속 가공해요.
</p>
{CRAFT_RECIPES.map((r) => {
const active = building.craftKey === r.key;
return (
<button
key={r.key}
onClick={() => onSetCraft(active ? null : r.key)}
className={`card-nature flex w-full items-center justify-between px-3.5 py-2.5 text-sm hover:brightness-105 ${
active ? "tut-highlight" : ""
}`}
>
<span className="font-jua text-[#3f2c14]">
{r.emoji} {r.name}
</span>
<span className="font-num text-xs text-[#8a6316]">
{Object.entries(r.input)
.map(([k, v]) => `${RES_EMOJI[k] ?? k}${v}`)
.join(" ")}{" "}
→ 1개 · 판매 🪙{SELL_PRICES[r.key]}
{active && " · 생산 중!"}
</span>
</button>
);
})}
</div>
)}
<div className="card-nature px-4 py-3 text-sm leading-relaxed"> <div className="card-nature px-4 py-3 text-sm leading-relaxed">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span> <span>
@@ -1026,6 +1112,25 @@ function BuildingPanel({
/> />
</div> </div>
</> </>
) : building.type === "workshop" ? (
(() => {
const craft = CRAFT_RECIPES.find((r) => r.key === building.craftKey);
return (
<>
<div>가공 속도 {totalRate.toFixed(2)}/초</div>
{craft && (
<div className="bar-track mt-1 h-2 w-full">
<div
className="bar-fill-amber"
style={{
width: `${Math.min(100, ((building.progress ?? 0) / craft.work) * 100)}%`,
}}
/>
</div>
)}
</>
);
})()
) : ( ) : (
<div>생산 속도 {totalRate.toFixed(2)}/초</div> <div>생산 속도 {totalRate.toFixed(2)}/초</div>
)} )}
@@ -1073,7 +1178,12 @@ function BuildingPanel({
disabled={stored <= 0} disabled={stored <= 0}
className="amber-btn w-full py-2.5 text-sm disabled:cursor-not-allowed disabled:opacity-40" className="amber-btn w-full py-2.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
> >
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수 {RES_EMOJI[
(building.type === "workshop"
? (building.craftKey ?? "wood")
: (building.resource ?? "wood")) as string
] ?? "📦"}{" "}
{stored}개 회수
</button> </button>
)} )}
+19 -8
파일 보기
@@ -24,8 +24,18 @@ import type {
ResourceKey, ResourceKey,
} from "@/lib/game/types"; } from "@/lib/game/types";
const RES_EMOJI: Record<ResourceKey, string> = { fish: "🐟", wood: "🪵", stone: "🪨" }; const RES_EMOJI: Record<ResourceKey, string> = {
const RES_ICON: Record<ResourceKey, string> = { fish: "fish", wood: "wood", stone: "stone" }; fish: "🐟", wood: "🪵", stone: "🪨", plank: "🪚", block: "🧱",
};
const RES_ICON: Record<ResourceKey, string> = {
fish: "fish", wood: "wood", stone: "stone", plank: "plank", block: "block",
};
/** 건물이 산출하는 자원 키 (목공소는 선택 품목) */
const resourceOf = (b: Building): ResourceKey | null =>
b.type === "workshop"
? ((b.craftKey as ResourceKey | null) ?? null)
: (b.resource ?? null);
const EMOJI_TO_NATURE: Record<string, string> = { const EMOJI_TO_NATURE: Record<string, string> = {
"🌼": "daisy", "🌷": "tulip", "🌾": "wheat", "🍄": "mushroom", "🪨": "rock", "🌼": "daisy", "🌷": "tulip", "🌾": "wheat", "🍄": "mushroom", "🪨": "rock",
}; };
@@ -426,7 +436,7 @@ export default function VillageView({
if (b && catRate(cat, b.aptitude) > 0) { if (b && catRate(cat, b.aptitude) > 0) {
bubblesRef.current.push({ bubblesRef.current.push({
x: anim.x, y: anim.y - 34, born: now, x: anim.x, y: anim.y - 34, born: now,
icon: b.type === "kitchen" ? "pan" : RES_ICON[b.resource ?? "fish"], icon: b.type === "kitchen" ? "pan" : RES_ICON[resourceOf(b) ?? "fish"],
}); });
} }
} }
@@ -759,7 +769,7 @@ export default function VillageView({
if (stored > 0) { if (stored > 0) {
drawPlankBadge( drawPlankBadge(
x, y - imgW * 0.55, `${stored}`, x, y - imgW * 0.55, `${stored}`,
isKitchen ? "meal" : RES_ICON[b.resource ?? "fish"], isKitchen ? "meal" : RES_ICON[resourceOf(b) ?? "fish"],
); );
} }
}, },
@@ -1006,16 +1016,17 @@ 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);
if (b && b.resource && Math.floor(b.stored) > 0 && s) { const resKey = b ? resourceOf(b) : null;
const before = Math.floor(s.resources[b.resource]); if (b && resKey && Math.floor(b.stored) > 0 && s) {
const before = Math.floor(s.resources[resKey]);
const err = engine.collect(r.id); const err = engine.collect(r.id);
engine.save(); engine.save();
const gained = Math.floor(s.resources[b.resource]) - before; const gained = Math.floor(s.resources[resKey]) - 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(),
...(err ...(err
? { text: "📦 창고 가득!" } ? { text: "📦 창고 가득!" }
: { icon: RES_ICON[b.resource], text: `+${gained}` }), : { icon: RES_ICON[resKey], text: `+${gained}` }),
}); });
} }
return; return;
+45 -5
파일 보기
@@ -35,6 +35,7 @@ export const BUILDING_IMG: Record<string, string> = {
sun_deck: "/assets/buildings/sun_deck.png", sun_deck: "/assets/buildings/sun_deck.png",
market: "/assets/buildings/market.png", market: "/assets/buildings/market.png",
warehouse: "/assets/buildings/warehouse.png", warehouse: "/assets/buildings/warehouse.png",
workshop: "/assets/buildings/workshop.png",
}; };
// 자연 소품 일러스트 (public/assets/nature/) // 자연 소품 일러스트 (public/assets/nature/)
@@ -58,6 +59,8 @@ export const ICON_IMG: Record<string, string> = {
meal: "/assets/icons/meal.png", meal: "/assets/icons/meal.png",
coin: "/assets/icons/coin.png", coin: "/assets/icons/coin.png",
hammer: "/assets/icons/hammer.png", hammer: "/assets/icons/hammer.png",
plank: "/assets/icons/plank.png",
block: "/assets/icons/block.png",
sweat: "/assets/icons/sweat.png", sweat: "/assets/icons/sweat.png",
plate: "/assets/icons/plate.png", plate: "/assets/icons/plate.png",
sleep: "/assets/icons/sleep.png", sleep: "/assets/icons/sleep.png",
@@ -158,6 +161,7 @@ export const FOOTPRINTS: Record<string, { w: number; h: number }> = {
sun_deck: { w: 2, h: 2 }, sun_deck: { w: 2, h: 2 },
market: { w: 2, h: 2 }, market: { w: 2, h: 2 },
warehouse: { w: 2, h: 2 }, warehouse: { w: 2, h: 2 },
workshop: { 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 };
@@ -192,7 +196,8 @@ export interface BuildDef {
| "cat_house" | "cat_house"
| "sun_deck" | "sun_deck"
| "market" | "market"
| "warehouse"; | "warehouse"
| "workshop";
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work) kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
name: string; name: string;
emoji: string; emoji: string;
@@ -246,6 +251,11 @@ export const BUILD_DEFS: BuildDef[] = [
aptitude: "crafting", maxSlots: 0, cost: { wood: 200, stone: 100 }, reqHallLv: 3, buildTime: 120, aptitude: "crafting", maxSlots: 0, cost: { wood: 200, stone: 100 }, reqHallLv: 3, buildTime: 120,
desc: "자원 보관 상한을 +500 늘려줍니다. 꽉 찬 창고는 생산을 멈추게 해요.", desc: "자원 보관 상한을 +500 늘려줍니다. 꽉 찬 창고는 생산을 멈추게 해요.",
}, },
{
id: "workshop_1", type: "workshop", name: "목공소", emoji: "🪚",
aptitude: "crafting", maxSlots: 2, cost: { wood: 150, stone: 80 }, reqHallLv: 3, buildTime: 120,
desc: "제작 적성 고양이가 목재·돌을 판자와 석재 블록으로 가공합니다. 회관 Lv4의 열쇠!",
},
]; ];
// ---------- 꾸미기 (바닥 장식) ---------- // ---------- 꾸미기 (바닥 장식) ----------
@@ -302,6 +312,32 @@ 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; // × 식욕 수치
// ---------- 목공소 (상위 자재 가공) ----------
export interface CraftDef {
key: "plank" | "block";
name: string;
emoji: string;
input: Partial<Record<"fish" | "wood" | "stone", number>>;
work: number; // 1개당 필요 작업량
}
export const CRAFT_RECIPES: CraftDef[] = [
{ key: "plank", name: "판자", emoji: "🪚", input: { wood: 5 }, work: 6 },
{ key: "block", name: "석재 블록", emoji: "🧱", input: { stone: 5 }, work: 8 },
];
// ---------- 장터 증축 (레벨 = 판매 슬롯 수) ----------
export const MARKET_LEVELS: Record<
number,
{ cost: Partial<Record<"fish" | "wood" | "stone" | "plank" | "block", number>> }
> = {
2: { cost: { wood: 200, stone: 80 } },
3: { cost: { plank: 30, block: 15 } },
};
export const MARKET_MAX_LEVEL = 3;
// ---------- 경제 (Phase C) ---------- // ---------- 경제 (Phase C) ----------
// 판매가 (개당 냥코인) // 판매가 (개당 냥코인)
@@ -309,6 +345,8 @@ export const SELL_PRICES: Record<string, number> = {
fish: 1, fish: 1,
wood: 2, wood: 2,
stone: 3, stone: 3,
plank: 8, // 목재 5 → 판자 1: 원자재 대비 압축·부가가치
block: 12,
}; };
// 요리 판매가 (레시피 키 기준) // 요리 판매가 (레시피 키 기준)
export const MEAL_PRICES: Record<string, number> = { export const MEAL_PRICES: Record<string, number> = {
@@ -341,9 +379,9 @@ export const STRIKE_RESUME = 40; // 이 이상 회복되면 복귀
export interface HallLevelDef { export interface HallLevelDef {
capacity: number; capacity: number;
cost: Partial<Record<"fish" | "wood" | "stone", number>>; cost: Partial<Record<"fish" | "wood" | "stone" | "plank" | "block", number>>;
reqCat?: { level: number; count: number }; reqCat?: { level: number; count: number };
reqBuildings?: Array<{ type: "quarry" | "kitchen"; level?: number; label: string }>; reqBuildings?: Array<{ type: "quarry" | "kitchen" | "workshop"; level?: number; label: string }>;
locked?: string; // 아직 구현 안 된 선행 요소 안내 locked?: string; // 아직 구현 안 된 선행 요소 안내
} }
@@ -358,8 +396,10 @@ export const HALL_LEVELS: Record<number, HallLevelDef> = {
], ],
}, },
4: { 4: {
capacity: 12, cost: { stone: 400 }, reqCat: { level: 8, count: 3 }, capacity: 12,
locked: "목공소(목판)가 추가된 뒤 해금됩니다", cost: { plank: 60, block: 40 },
reqCat: { level: 8, count: 2 },
reqBuildings: [{ type: "workshop", label: "목공소 건설" }],
}, },
}; };
+118 -37
파일 보기
@@ -37,6 +37,9 @@ import {
HALL_TILE, HALL_TILE,
isWaterTile, isWaterTile,
KITCHEN_UPGRADE_COST, KITCHEN_UPGRADE_COST,
CRAFT_RECIPES,
MARKET_LEVELS,
MARKET_MAX_LEVEL,
mealCap, mealCap,
PASSIVES, PASSIVES,
PLACEMENT_RULES, PLACEMENT_RULES,
@@ -48,7 +51,9 @@ import {
import type { BuildingType } from "./types"; import type { BuildingType } from "./types";
const SAVE_KEY = "cat-village-save"; const SAVE_KEY = "cat-village-save";
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" }; const RES_LABEL: Record<string, string> = {
fish: "생선", wood: "목재", stone: "돌", plank: "판자", block: "석재 블록",
};
const APTITUDE_KEYS: AptitudeKey[] = [ const APTITUDE_KEYS: AptitudeKey[] = [
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming", "fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
]; ];
@@ -124,7 +129,7 @@ function createInitialState(): GameState {
boardVersion: BOARD_VERSION, boardVersion: BOARD_VERSION,
tutorialStep: 0, tutorialStep: 0,
coins: 0, coins: 0,
resources: { fish: 0, wood: 0, stone: 0 }, resources: { fish: 0, wood: 0, stone: 0, plank: 0, block: 0 },
cats: [], cats: [],
buildings: [], buildings: [],
decors: [], decors: [],
@@ -308,6 +313,16 @@ export const engine = {
if (!state) state = createInitialState(); if (!state) state = createInitialState();
// 구버전 세이브 마이그레이션 (필드 보강) // 구버전 세이브 마이그레이션 (필드 보강)
state.resources.stone ??= 0; state.resources.stone ??= 0;
state.resources.plank ??= 0;
state.resources.block ??= 0;
for (const b of state.buildings) {
// 장터: 단일 sellKey → 슬롯 배열 (레벨 = 슬롯 수)
if (b.type === "market" && b.sellKeys === undefined) {
b.sellKeys = [b.sellKey ?? null];
b.sellKey = undefined;
}
if (b.type === "workshop") b.craftKey ??= null;
}
for (const cat of state.cats) { for (const cat of state.cats) {
cat.aptitudePoints ??= 0; cat.aptitudePoints ??= 0;
cat.status.energy ??= cat.stats.stamina; cat.status.energy ??= cat.stats.stamina;
@@ -456,42 +471,64 @@ export const engine = {
building.stored += 1; building.stored += 1;
building.progress -= recipe.work; building.progress -= recipe.work;
} }
} else { } else if (building.type === "workshop") {
// 목공소: 선택 품목을 재료 소모하며 반복 가공 (부엌과 동일 패턴)
const recipe = CRAFT_RECIPES.find((r) => r.key === building.craftKey);
if (recipe) {
building.progress = (building.progress ?? 0) + produced;
while (building.progress >= recipe.work) {
if (
building.stored >= BUILDING_STORE_CAP ||
!canConsume(state, recipe.input)
) {
building.progress = recipe.work; // 재료·적재 대기
break;
}
consume(state, recipe.input);
building.stored += 1;
building.progress -= recipe.work;
}
}
} else if (building.type !== "market") {
// 건물 적재 한도 — 가득 차면 생산 정지 (회수 유도) // 건물 적재 한도 — 가득 차면 생산 정지 (회수 유도)
building.stored = Math.min(BUILDING_STORE_CAP, building.stored + produced); building.stored = Math.min(BUILDING_STORE_CAP, building.stored + produced);
} }
// 장터: 자동 판매 // 장터: 자동 판매
if ( if (building.type === "market" && !isUnderConstruction(building)) {
building.type === "market" && const keys = (building.sellKeys ?? []).filter(Boolean) as string[];
building.sellKey && if (keys.length > 0) {
!isUnderConstruction(building) building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt;
) { while (building.progress >= 1) {
building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt; // 슬롯당 1개씩 동시 판매 (슬롯별 분당 5개)
while (building.progress >= 1) { let soldAny = false;
let sold = false; for (const sellKey of keys) {
if (building.sellKey === "meals") { if (sellKey === "meals") {
const kitchen = state.buildings.find( const kitchen = state.buildings.find(
(b) => b.type === "kitchen" && b.stored >= 1, (b) => b.type === "kitchen" && b.stored >= 1,
); );
if (kitchen) { if (kitchen) {
kitchen.stored -= 1; kitchen.stored -= 1;
state.coins = state.coins =
(state.coins ?? 0) + (MEAL_PRICES[activeRecipe(state).key] ?? 4); (state.coins ?? 0) +
sold = true; (MEAL_PRICES[activeRecipe(state).key] ?? 4);
soldAny = true;
}
} else {
const key = sellKey as keyof GameState["resources"];
if (state.resources[key] >= 1) {
state.resources[key] -= 1;
state.coins =
(state.coins ?? 0) + (SELL_PRICES[sellKey] ?? 1);
soldAny = true;
}
}
} }
} else { if (!soldAny) {
const key = building.sellKey as keyof GameState["resources"]; building.progress = 1; // 팔 것이 없으면 대기
if (state.resources[key] >= 1) { break;
state.resources[key] -= 1;
state.coins = (state.coins ?? 0) + (SELL_PRICES[building.sellKey] ?? 1);
sold = true;
} }
building.progress -= 1;
} }
if (!sold) {
building.progress = 1; // 팔 것이 없으면 대기
break;
}
building.progress -= 1;
} }
} }
} }
@@ -685,12 +722,51 @@ export const engine = {
}, },
/** 장터 판매 품목 설정 */ /** 장터 판매 품목 설정 */
setSellKey(marketId: string, key: string | null): string | null { /** 장터 슬롯별 판매 품목 설정 */
setSellSlot(marketId: string, slot: number, key: string | null): string | null {
if (!state) return null; if (!state) return null;
const m = state.buildings.find((b) => b.id === marketId); const m = state.buildings.find((b) => b.id === marketId);
if (!m || m.type !== "market") return null; if (!m || m.type !== "market") return null;
m.sellKey = key; m.sellKeys ??= [null];
m.progress = 0; if (slot < 0 || slot >= m.level) return null;
while (m.sellKeys.length < m.level) m.sellKeys.push(null);
m.sellKeys[slot] = key;
return null;
},
/** 장터 증축 (레벨 = 판매 슬롯 수) */
upgradeMarket(): string | null {
if (!state) return null;
const m = state.buildings.find((b) => b.type === "market");
if (!m) return "장터가 없습니다";
if (m.level >= MARKET_MAX_LEVEL) return "장터가 최고 레벨입니다";
const def = MARKET_LEVELS[m.level + 1];
if (!def) return "장터가 최고 레벨입니다";
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);
m.level += 1;
m.sellKeys ??= [null];
while (m.sellKeys.length < m.level) m.sellKeys.push(null);
return null;
},
/** 목공소 생산 품목 설정 */
setCraftKey(workshopId: string, key: string | null): string | null {
if (!state) return null;
const w = state.buildings.find((b) => b.id === workshopId);
if (!w || w.type !== "workshop") return null;
if (key !== null && !CRAFT_RECIPES.some((r) => r.key === key)) return null;
if (w.craftKey !== key && Math.floor(w.stored) > 0)
return "먼저 쌓인 완성품을 수거해주세요";
w.craftKey = key;
w.progress = 0;
return null; return null;
}, },
@@ -889,14 +965,19 @@ export const engine = {
collect(buildingId: string): string | null { collect(buildingId: string): string | null {
if (!state) return null; 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 null; // 부엌은 회수 대상 아님 // 목공소는 선택 품목이, 생산 건물은 고정 자원이 회수 대상
const resKey =
building?.type === "workshop"
? (building.craftKey as keyof GameState["resources"] | null)
: (building?.resource ?? null);
if (!building || !resKey) return null; // 부엌은 회수 대상 아님
const cap = storageCap(state); const cap = storageCap(state);
const space = Math.max(0, cap - Math.floor(state.resources[building.resource])); const space = Math.max(0, cap - Math.floor(state.resources[resKey]));
const amount = Math.min(Math.floor(building.stored), space); const amount = Math.min(Math.floor(building.stored), space);
if (amount <= 0) if (amount <= 0)
return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null; return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null;
building.stored -= amount; building.stored -= amount;
state.resources[building.resource] += amount; state.resources[resKey] += amount;
return null; return null;
}, },
+6 -3
파일 보기
@@ -7,7 +7,7 @@ export type AptitudeKey =
| "crafting" | "crafting"
| "grooming"; | "grooming";
export type ResourceKey = "fish" | "wood" | "stone"; export type ResourceKey = "fish" | "wood" | "stone" | "plank" | "block";
export type BuildingType = export type BuildingType =
| "dock" | "dock"
@@ -17,7 +17,8 @@ export type BuildingType =
| "cat_house" | "cat_house"
| "sun_deck" | "sun_deck"
| "market" | "market"
| "warehouse"; | "warehouse"
| "workshop";
export interface Cat { export interface Cat {
id: string; id: string;
@@ -59,7 +60,9 @@ export interface Building {
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수 stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
progress?: number; // 부엌: 현재 요리 진행 작업량 progress?: number; // 부엌: 현재 요리 진행 작업량
constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태) constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태)
sellKey?: string | null; // 장터: 판매 품목 (fish/wood/stone/meals) sellKey?: string | null; // (구버전) 장터 판매 품목 — sellKeys로 마이그레이션됨
sellKeys?: (string | null)[]; // 장터: 슬롯별 판매 품목 (레벨 = 슬롯 수)
craftKey?: string | null; // 목공소: 생산 품목 (plank/block)
} }
export interface DecoTile { export interface DecoTile {