목공소·상위 자재·장터 다중 슬롯 (경제 확장)
- 신규 자원: 판자(목재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>
이 커밋은 다음에 포함됨:
바이너리 파일은 표시되지 않음.
|
이후 너비: | 높이: | 크기: 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단