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

- 신규 자원: 판자(목재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개의 파일을 삭제
+118 -37
파일 보기
@@ -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,42 +471,64 @@ 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)
) {
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;
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) {
// 슬롯당 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);
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 {
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 (!soldAny) {
building.progress = 1; // 팔 것이 없으면 대기
break;
}
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;
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;
},