목공소·상위 자재·장터 다중 슬롯 (경제 확장)
- 신규 자원: 판자(목재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 |
+127
-17
@@ -27,6 +27,8 @@ import {
|
||||
HALL_LEVELS,
|
||||
ICON_IMG,
|
||||
KITCHEN_UPGRADE_COST,
|
||||
CRAFT_RECIPES,
|
||||
MARKET_LEVELS,
|
||||
MEAL_PRICES,
|
||||
mealCap,
|
||||
REROLL_COST,
|
||||
@@ -38,8 +40,12 @@ import {
|
||||
} from "@/lib/game/data";
|
||||
import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types";
|
||||
|
||||
const RES_EMOJI: Record<string, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
||||
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
||||
const RES_EMOJI: Record<string, string> = {
|
||||
fish: "🐟", wood: "🪵", stone: "🪨", plank: "🪚", block: "🧱",
|
||||
};
|
||||
const RES_LABEL: Record<string, string> = {
|
||||
fish: "생선", wood: "목재", stone: "돌", plank: "판자", block: "석재 블록",
|
||||
};
|
||||
|
||||
// ---------- 튜토리얼 ----------
|
||||
interface TutStep {
|
||||
@@ -292,6 +298,14 @@ export default function Game() {
|
||||
["🐟", ICON_IMG.fish, Math.floor(state.resources.fish)],
|
||||
["🪵", ICON_IMG.wood, Math.floor(state.resources.wood)],
|
||||
["🪨", 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.coin, Math.floor(state.coins ?? 0)],
|
||||
] as Array<[string, string | null, string | number]>
|
||||
@@ -491,7 +505,11 @@ export default function Game() {
|
||||
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
|
||||
highlightAssign={tutTarget === "assign"}
|
||||
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={() => {
|
||||
setPanel(null);
|
||||
setPlacing({
|
||||
@@ -827,6 +845,8 @@ function BuildingPanel({
|
||||
highlightAssign,
|
||||
onRush,
|
||||
onSetSell,
|
||||
onUpgradeMarket,
|
||||
onSetCraft,
|
||||
}: {
|
||||
building: Building;
|
||||
state: GameState;
|
||||
@@ -837,7 +857,9 @@ function BuildingPanel({
|
||||
onMove: () => void;
|
||||
highlightAssign?: boolean;
|
||||
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 recipe = activeRecipe(state);
|
||||
@@ -897,6 +919,8 @@ function BuildingPanel({
|
||||
{ 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: "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",
|
||||
label: `${recipe.emoji} 요리(${recipe.name})`,
|
||||
@@ -906,41 +930,72 @@ function BuildingPanel({
|
||||
.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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[#6f5c39]">
|
||||
판매 품목을 정하면 분당 5개씩 자동으로 팔아 냥코인을 벌어요. 가공한
|
||||
요리일수록 값이 좋답니다!
|
||||
슬롯마다 품목을 정하면 슬롯당 분당 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">
|
||||
{slots.map((sel, slotIdx) => (
|
||||
<div key={slotIdx} className="space-y-1.5">
|
||||
<div className="font-jua text-xs text-[#8a6316]">
|
||||
📦 판매 슬롯 {slotIdx + 1}
|
||||
{sel && (
|
||||
<span className="font-num ml-1.5 text-[10px] text-[#5c8a2a]">
|
||||
{options.find((o) => o.key === sel)?.label} 판매 중
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{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" : ""
|
||||
onClick={() => onSetSell(slotIdx, sel === o.key ? null : o.key)}
|
||||
title={`개당 🪙${o.price} · 재고 ${o.stock}`}
|
||||
className={`card-nature px-1.5 py-1.5 text-center text-xs hover:brightness-105 ${
|
||||
sel === 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>
|
||||
<div className="font-jua text-[#3f2c14]">{o.label}</div>
|
||||
<div className="font-num text-[10px] text-[#8a6316]">
|
||||
🪙{o.price} · {o.stock}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!building.sellKey && (
|
||||
</div>
|
||||
))}
|
||||
{slots.every((k) => !k) && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (building.maxSlots === 0) {
|
||||
// 패시브 건물 — 배치 없음
|
||||
const restingHere = state.cats.filter((c) => c.state === "resting");
|
||||
@@ -993,6 +1048,37 @@ function BuildingPanel({
|
||||
return (
|
||||
<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="flex items-center justify-between">
|
||||
<span>
|
||||
@@ -1026,6 +1112,25 @@ function BuildingPanel({
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
@@ -1073,7 +1178,12 @@ function BuildingPanel({
|
||||
disabled={stored <= 0}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,8 +24,18 @@ import type {
|
||||
ResourceKey,
|
||||
} from "@/lib/game/types";
|
||||
|
||||
const RES_EMOJI: Record<ResourceKey, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
||||
const RES_ICON: Record<ResourceKey, string> = { fish: "fish", wood: "wood", stone: "stone" };
|
||||
const RES_EMOJI: Record<ResourceKey, string> = {
|
||||
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> = {
|
||||
"🌼": "daisy", "🌷": "tulip", "🌾": "wheat", "🍄": "mushroom", "🪨": "rock",
|
||||
};
|
||||
@@ -426,7 +436,7 @@ export default function VillageView({
|
||||
if (b && catRate(cat, b.aptitude) > 0) {
|
||||
bubblesRef.current.push({
|
||||
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) {
|
||||
drawPlankBadge(
|
||||
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;
|
||||
const s = engine.peek();
|
||||
const b = s?.buildings.find((bb) => bb.id === r.id);
|
||||
if (b && b.resource && Math.floor(b.stored) > 0 && s) {
|
||||
const before = Math.floor(s.resources[b.resource]);
|
||||
const resKey = b ? resourceOf(b) : null;
|
||||
if (b && resKey && Math.floor(b.stored) > 0 && s) {
|
||||
const before = Math.floor(s.resources[resKey]);
|
||||
const err = engine.collect(r.id);
|
||||
engine.save();
|
||||
const gained = Math.floor(s.resources[b.resource]) - before;
|
||||
const gained = Math.floor(s.resources[resKey]) - before;
|
||||
bubblesRef.current.push({
|
||||
x: r.x + r.w / 2, y: r.y, born: performance.now(),
|
||||
...(err
|
||||
? { text: "📦 창고 가득!" }
|
||||
: { icon: RES_ICON[b.resource], text: `+${gained}` }),
|
||||
: { icon: RES_ICON[resKey], text: `+${gained}` }),
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
+45
-5
@@ -35,6 +35,7 @@ export const BUILDING_IMG: Record<string, string> = {
|
||||
sun_deck: "/assets/buildings/sun_deck.png",
|
||||
market: "/assets/buildings/market.png",
|
||||
warehouse: "/assets/buildings/warehouse.png",
|
||||
workshop: "/assets/buildings/workshop.png",
|
||||
};
|
||||
|
||||
// 자연 소품 일러스트 (public/assets/nature/)
|
||||
@@ -58,6 +59,8 @@ export const ICON_IMG: Record<string, string> = {
|
||||
meal: "/assets/icons/meal.png",
|
||||
coin: "/assets/icons/coin.png",
|
||||
hammer: "/assets/icons/hammer.png",
|
||||
plank: "/assets/icons/plank.png",
|
||||
block: "/assets/icons/block.png",
|
||||
sweat: "/assets/icons/sweat.png",
|
||||
plate: "/assets/icons/plate.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 },
|
||||
market: { 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 };
|
||||
|
||||
@@ -192,7 +196,8 @@ export interface BuildDef {
|
||||
| "cat_house"
|
||||
| "sun_deck"
|
||||
| "market"
|
||||
| "warehouse";
|
||||
| "warehouse"
|
||||
| "workshop";
|
||||
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
||||
name: 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,
|
||||
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 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) ----------
|
||||
|
||||
// 판매가 (개당 냥코인)
|
||||
@@ -309,6 +345,8 @@ export const SELL_PRICES: Record<string, number> = {
|
||||
fish: 1,
|
||||
wood: 2,
|
||||
stone: 3,
|
||||
plank: 8, // 목재 5 → 판자 1: 원자재 대비 압축·부가가치
|
||||
block: 12,
|
||||
};
|
||||
// 요리 판매가 (레시피 키 기준)
|
||||
export const MEAL_PRICES: Record<string, number> = {
|
||||
@@ -341,9 +379,9 @@ export const STRIKE_RESUME = 40; // 이 이상 회복되면 복귀
|
||||
|
||||
export interface HallLevelDef {
|
||||
capacity: number;
|
||||
cost: Partial<Record<"fish" | "wood" | "stone", number>>;
|
||||
cost: Partial<Record<"fish" | "wood" | "stone" | "plank" | "block", 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; // 아직 구현 안 된 선행 요소 안내
|
||||
}
|
||||
|
||||
@@ -358,8 +396,10 @@ export const HALL_LEVELS: Record<number, HallLevelDef> = {
|
||||
],
|
||||
},
|
||||
4: {
|
||||
capacity: 12, cost: { stone: 400 }, reqCat: { level: 8, count: 3 },
|
||||
locked: "목공소(목판)가 추가된 뒤 해금됩니다",
|
||||
capacity: 12,
|
||||
cost: { plank: 60, block: 40 },
|
||||
reqCat: { level: 8, count: 2 },
|
||||
reqBuildings: [{ type: "workshop", label: "목공소 건설" }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+103
-22
@@ -37,6 +37,9 @@ import {
|
||||
HALL_TILE,
|
||||
isWaterTile,
|
||||
KITCHEN_UPGRADE_COST,
|
||||
CRAFT_RECIPES,
|
||||
MARKET_LEVELS,
|
||||
MARKET_MAX_LEVEL,
|
||||
mealCap,
|
||||
PASSIVES,
|
||||
PLACEMENT_RULES,
|
||||
@@ -48,7 +51,9 @@ import {
|
||||
import type { BuildingType } from "./types";
|
||||
|
||||
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[] = [
|
||||
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
|
||||
];
|
||||
@@ -124,7 +129,7 @@ function createInitialState(): GameState {
|
||||
boardVersion: BOARD_VERSION,
|
||||
tutorialStep: 0,
|
||||
coins: 0,
|
||||
resources: { fish: 0, wood: 0, stone: 0 },
|
||||
resources: { fish: 0, wood: 0, stone: 0, plank: 0, block: 0 },
|
||||
cats: [],
|
||||
buildings: [],
|
||||
decors: [],
|
||||
@@ -308,6 +313,16 @@ export const engine = {
|
||||
if (!state) state = createInitialState();
|
||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||
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) {
|
||||
cat.aptitudePoints ??= 0;
|
||||
cat.status.energy ??= cat.stats.stamina;
|
||||
@@ -456,38 +471,59 @@ export const engine = {
|
||||
building.stored += 1;
|
||||
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);
|
||||
}
|
||||
// 장터: 자동 판매
|
||||
if (
|
||||
building.type === "market" &&
|
||||
building.sellKey &&
|
||||
!isUnderConstruction(building)
|
||||
) {
|
||||
if (building.type === "market" && !isUnderConstruction(building)) {
|
||||
const keys = (building.sellKeys ?? []).filter(Boolean) as string[];
|
||||
if (keys.length > 0) {
|
||||
building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt;
|
||||
while (building.progress >= 1) {
|
||||
let sold = false;
|
||||
if (building.sellKey === "meals") {
|
||||
// 슬롯당 1개씩 동시 판매 (슬롯별 분당 5개)
|
||||
let soldAny = false;
|
||||
for (const sellKey of keys) {
|
||||
if (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;
|
||||
(state.coins ?? 0) +
|
||||
(MEAL_PRICES[activeRecipe(state).key] ?? 4);
|
||||
soldAny = true;
|
||||
}
|
||||
} else {
|
||||
const key = building.sellKey as keyof GameState["resources"];
|
||||
const key = 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;
|
||||
state.coins =
|
||||
(state.coins ?? 0) + (SELL_PRICES[sellKey] ?? 1);
|
||||
soldAny = true;
|
||||
}
|
||||
}
|
||||
if (!sold) {
|
||||
}
|
||||
if (!soldAny) {
|
||||
building.progress = 1; // 팔 것이 없으면 대기
|
||||
break;
|
||||
}
|
||||
@@ -495,6 +531,7 @@ export const engine = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 포만도 감소 (식욕 × 패시브)
|
||||
for (const cat of state.cats) {
|
||||
@@ -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;
|
||||
const m = state.buildings.find((b) => b.id === marketId);
|
||||
if (!m || m.type !== "market") return null;
|
||||
m.sellKey = key;
|
||||
m.progress = 0;
|
||||
m.sellKeys ??= [null];
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -889,14 +965,19 @@ export const engine = {
|
||||
collect(buildingId: string): string | null {
|
||||
if (!state) return null;
|
||||
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 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);
|
||||
if (amount <= 0)
|
||||
return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null;
|
||||
building.stored -= amount;
|
||||
state.resources[building.resource] += amount;
|
||||
state.resources[resKey] += amount;
|
||||
return null;
|
||||
},
|
||||
|
||||
|
||||
+6
-3
@@ -7,7 +7,7 @@ export type AptitudeKey =
|
||||
| "crafting"
|
||||
| "grooming";
|
||||
|
||||
export type ResourceKey = "fish" | "wood" | "stone";
|
||||
export type ResourceKey = "fish" | "wood" | "stone" | "plank" | "block";
|
||||
|
||||
export type BuildingType =
|
||||
| "dock"
|
||||
@@ -17,7 +17,8 @@ export type BuildingType =
|
||||
| "cat_house"
|
||||
| "sun_deck"
|
||||
| "market"
|
||||
| "warehouse";
|
||||
| "warehouse"
|
||||
| "workshop";
|
||||
|
||||
export interface Cat {
|
||||
id: string;
|
||||
@@ -59,7 +60,9 @@ export interface Building {
|
||||
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
||||
progress?: number; // 부엌: 현재 요리 진행 작업량
|
||||
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 {
|
||||
|
||||
새 이슈에서 참조
사용자 차단