건설 방식 개편: 상점 구매 + 자유 배치 (클래시 오브 클랜 방식)

- 건물 위치가 게임 상태(building.pos)로 이동, 구세이브 자동 마이그레이션
- 🛒 상점 패널: 건물 목록·비용·해금 조건, 구매 → 배치 모드 진입
- 배치 모드: 배치 가능 타일 하이라이트, 호버 타일 초록/빨강 + 고스트,
  클릭 확정(비용은 확정 시 차감), ESC/취소 버튼
- 배치 규칙: 물·나무·회관·기존 건물 차단, 부두는 물가 열 전용
- 기존 건물 📍 이동 기능 (건물 패널), 이동 시 배치된 고양이가 새 일터로 걸어감
- 공터(🚧) 방식 제거

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 15:21:44 +09:00
co-authored by Claude Fable 5
부모 9de2abfd85
커밋 ecfccfbe70
5개의 변경된 파일과 321개의 추가작업 그리고 125개의 파일을 삭제
+115 -65
파일 보기
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useGame } from "@/hooks/useGame";
import VillageView from "@/components/game/VillageView";
import VillageView, { type PlacingState } from "@/components/game/VillageView";
import DbStatus from "@/components/DbStatus";
import {
engine,
@@ -31,7 +31,7 @@ const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", ston
type Panel =
| { type: "building"; id: string }
| { type: "hall" }
| { type: "lot"; def: BuildDef }
| { type: "shop" }
| { type: "cats" }
| null;
@@ -39,6 +39,7 @@ export default function Game() {
const { state, dispatch } = useGame();
const [panel, setPanel] = useState<Panel>(null);
const [candidates, setCandidates] = useState<Cat[] | null>(null);
const [placing, setPlacing] = useState<PlacingState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const msgTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -47,6 +48,7 @@ export default function Game() {
setMessage(error ?? null);
if (msgTimer.current) clearTimeout(msgTimer.current);
if (error) msgTimer.current = setTimeout(() => setMessage(null), 3500);
return error;
};
useEffect(() => () => {
@@ -74,13 +76,33 @@ export default function Game() {
<VillageView
onBuildingClick={(id) => {
if (id === "hall") setPanel({ type: "hall" });
else if (id.startsWith("lot_")) {
const def = BUILD_DEFS.find((d) => d.type === id.slice(4));
if (def) setPanel({ type: "lot", def });
} else setPanel({ type: "building", id });
else setPanel({ type: "building", id });
}}
placing={placing}
onPlace={(i, j) => {
if (!placing) return;
const error = placing.moveId
? run(() => engine.moveBuilding(placing.moveId!, { i, j }))
: run(() => engine.build(placing.type as "quarry" | "kitchen", { i, j }));
if (!error) setPlacing(null);
}}
onCancelPlace={() => setPlacing(null)}
/>
{/* ── 배치 모드 힌트 ── */}
{placing && (
<div className="absolute bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-full bg-gray-900/90 px-5 py-2.5 text-sm font-semibold text-white shadow-lg">
📍 {placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요
{placing.type === "dock" && " (부두는 물가에만)"}
<button
onClick={() => setPlacing(null)}
className="rounded-full bg-white/20 px-3 py-1 text-xs hover:bg-white/30"
>
취소 (ESC)
</button>
</div>
)}
{/* ── 상단 HUD ── */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex flex-wrap items-start justify-between gap-2 p-3">
<div className="pointer-events-auto flex flex-wrap items-center gap-2">
@@ -110,6 +132,14 @@ export default function Game() {
</span>
</div>
<div className="pointer-events-auto flex gap-2">
<button
onClick={() =>
setPanel(panel?.type === "shop" ? null : { type: "shop" })
}
className="rounded-full bg-white/85 px-4 py-2 text-sm font-semibold shadow backdrop-blur hover:bg-white dark:bg-gray-900/85 dark:hover:bg-gray-900"
>
🛒 상점
</button>
<button
onClick={() =>
setPanel(panel?.type === "cats" ? null : { type: "cats" })
@@ -146,20 +176,13 @@ export default function Game() {
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
</SidePanel>
)}
{panel?.type === "lot" && (
<SidePanel title={`🚧 ${panel.def.name} 공터`} onClose={() => setPanel(null)}>
<LotPanel
def={panel.def}
{panel?.type === "shop" && (
<SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}>
<ShopPanel
state={state}
onBuild={() => {
const err = dispatch(() => engine.build(panel.def.type));
if (err) {
setMessage(err);
if (msgTimer.current) clearTimeout(msgTimer.current);
msgTimer.current = setTimeout(() => setMessage(null), 3500);
} else {
setPanel({ type: "building", id: panel.def.id });
}
onBuy={(def) => {
setPanel(null);
setPlacing({ type: def.type, aptitude: def.aptitude });
}}
/>
</SidePanel>
@@ -177,6 +200,14 @@ export default function Game() {
onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(openedBuilding.id))}
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
onMove={() => {
setPanel(null);
setPlacing({
type: openedBuilding.type,
aptitude: openedBuilding.aptitude,
moveId: openedBuilding.id,
});
}}
/>
</SidePanel>
)}
@@ -322,55 +353,63 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
);
}
/* ──────────────── 공터 패널 (건설 공통) ──────────────── */
/* ──────────────── 건물 상점 ──────────────── */
function LotPanel({
def,
function ShopPanel({
state,
onBuild,
onBuy,
}: {
def: BuildDef;
state: GameState;
onBuild: () => void;
onBuy: (def: BuildDef) => void;
}) {
const costEntries = Object.entries(def.cost) as Array<[string, number]>;
const canBuild =
state.hall.level >= def.reqHallLv &&
costEntries.every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= amount,
);
return (
<div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={TOOL_IMG[def.aptitude]}
alt={def.name}
className="mx-auto h-24 object-contain opacity-70"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">{def.desc}</p>
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-300">
{def.reqHallLv > 1 && (
<li className={state.hall.level >= def.reqHallLv ? "text-emerald-600 dark:text-emerald-400" : ""}>
{state.hall.level >= def.reqHallLv ? "✓" : "·"} 회관 Lv{def.reqHallLv} 필요
</li>
)}
{costEntries.map(([key, amount]) => {
const have = Math.floor(state.resources[key as keyof GameState["resources"]]);
return (
<li key={key} className={have >= amount ? "text-emerald-600 dark:text-emerald-400" : ""}>
{have >= amount ? "✓" : "·"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
</li>
);
})}
</ul>
<button
onClick={onBuild}
disabled={!canBuild}
className="w-full rounded-lg bg-amber-600 py-2.5 text-sm font-semibold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
>
🔨 짓기
</button>
<div className="space-y-3">
<p className="text-xs text-gray-400">
구매 후 마을의 원하는 타일을 클릭해 배치합니다. 비용은 배치 확정 시 차감돼요.
</p>
{BUILD_DEFS.map((def) => {
const built = state.buildings.some((b) => b.type === def.type);
const hallOk = state.hall.level >= def.reqHallLv;
const costEntries = Object.entries(def.cost) as Array<[string, number]>;
const affordable = costEntries.every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= amount,
);
return (
<div
key={def.type}
className="flex items-center gap-3 rounded-xl border border-gray-200 p-3 dark:border-gray-700"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={TOOL_IMG[def.aptitude]}
alt={def.name}
className={`h-12 w-12 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{def.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")}
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
</div>
<div className="mt-0.5 line-clamp-2 text-[11px] text-gray-400">{def.desc}</div>
</div>
{built ? (
<span className="shrink-0 rounded-lg bg-gray-100 px-3 py-1.5 text-xs text-gray-400 dark:bg-gray-800">
보유 중
</span>
) : (
<button
onClick={() => onBuy(def)}
disabled={!hallOk || !affordable}
className="shrink-0 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{!hallOk ? `회관 Lv${def.reqHallLv}` : !affordable ? "자원 부족" : "구매"}
</button>
)}
</div>
);
})}
</div>
);
}
@@ -384,6 +423,7 @@ function BuildingPanel({
onUnassign,
onCollect,
onUpgradeKitchen,
onMove,
}: {
building: Building;
state: GameState;
@@ -391,6 +431,7 @@ function BuildingPanel({
onUnassign: (catId: string) => void;
onCollect: () => void;
onUpgradeKitchen: () => void;
onMove: () => void;
}) {
const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state);
@@ -415,9 +456,18 @@ function BuildingPanel({
<div className="space-y-5">
{/* 정보 */}
<div className="rounded-lg bg-gray-50 px-4 py-3 text-sm leading-relaxed dark:bg-gray-800">
<div>
필요 적성: <b>{APTITUDE_LABEL[building.aptitude]}</b> · 슬롯{" "}
{assignedCats.length}/{building.maxSlots}
<div className="flex items-center justify-between">
<span>
필요 적성: <b>{APTITUDE_LABEL[building.aptitude]}</b> · 슬롯{" "}
{assignedCats.length}/{building.maxSlots}
</span>
<button
onClick={onMove}
title="건물 위치 옮기기"
className="shrink-0 rounded-lg bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-600 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
📍 이동
</button>
</div>
{isKitchen ? (
<>