건설 방식 개편: 상점 구매 + 자유 배치 (클래시 오브 클랜 방식)
- 건물 위치가 게임 상태(building.pos)로 이동, 구세이브 자동 마이그레이션 - 🛒 상점 패널: 건물 목록·비용·해금 조건, 구매 → 배치 모드 진입 - 배치 모드: 배치 가능 타일 하이라이트, 호버 타일 초록/빨강 + 고스트, 클릭 확정(비용은 확정 시 차감), ESC/취소 버튼 - 배치 규칙: 물·나무·회관·기존 건물 차단, 부두는 물가 열 전용 - 기존 건물 📍 이동 기능 (건물 패널), 이동 시 배치된 고양이가 새 일터로 걸어감 - 공터(🚧) 방식 제거 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+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 ? (
|
||||
<>
|
||||
|
||||
새 이슈에서 참조
사용자 차단