UI 개편: 전체화면 마을 + 건물 클릭 패널
- 마을 캔버스가 뷰포트 전체를 채움 (ResizeObserver 스케일링) - 상단 HUD: 회관·자원 칩 + 고양이 목록·입양 버튼 (반투명 오버레이) - 건물 클릭 → 우측 플로팅 패널: 정보(적성·슬롯·속도·진행)·회수· 배치 가능한 고양이 목록(예상 속도순, 현재 일터 표시)에서 배치/해제 - 회관 클릭 → 증축 요건 체크리스트 패널, 부엌 미건설 시 공터(🚧) 클릭 → 건설 - 고양이 전체 목록은 서랍 패널로 이동, 기존 그리드 섹션 제거 - 배지 클릭 빠른 회수는 유지, 메시지는 토스트로 표시 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+1
-22
@@ -1,26 +1,5 @@
|
||||
import Game from "@/components/game/Game";
|
||||
import DbStatus from "@/components/DbStatus";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<span className="text-lg font-bold">🏡 고양이 마을</span>
|
||||
<span className="text-xs text-gray-400">방치형 경영 게임 — 1단계</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
<Game />
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-gray-200 py-6 text-center text-sm text-gray-400 dark:border-gray-800">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>jskim.vexplor.com · dev-pilots</span>
|
||||
<DbStatus />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
return <Game />;
|
||||
}
|
||||
|
||||
+535
-326
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGame } from "@/hooks/useGame";
|
||||
import VillageView from "@/components/game/VillageView";
|
||||
import { engine, effectiveRate, activeRecipe, fullnessPenalty } from "@/lib/game/engine";
|
||||
import DbStatus from "@/components/DbStatus";
|
||||
import {
|
||||
engine,
|
||||
effectiveRate,
|
||||
activeRecipe,
|
||||
fullnessPenalty,
|
||||
} from "@/lib/game/engine";
|
||||
import {
|
||||
APTITUDE_LABEL,
|
||||
BREEDS,
|
||||
@@ -14,36 +20,158 @@ import {
|
||||
PASSIVES,
|
||||
TOOL_IMG,
|
||||
} from "@/lib/game/data";
|
||||
import type { AptitudeKey } from "@/lib/game/types";
|
||||
import type { Building, Cat, GameState } from "@/lib/game/types";
|
||||
import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types";
|
||||
|
||||
type Panel =
|
||||
| { type: "building"; id: string }
|
||||
| { type: "hall" }
|
||||
| { type: "kitchen_lot" }
|
||||
| { type: "cats" }
|
||||
| null;
|
||||
|
||||
export default function Game() {
|
||||
const { state, dispatch } = useGame();
|
||||
const [selectedCatId, setSelectedCatId] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [panel, setPanel] = useState<Panel>(null);
|
||||
const [candidates, setCandidates] = useState<Cat[] | null>(null);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="py-24 text-center text-gray-400">마을 불러오는 중…</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedCat = state.cats.find((c) => c.id === selectedCatId) ?? null;
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const msgTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const run = (action: () => string | null | void) => {
|
||||
const error = dispatch(action);
|
||||
setMessage(error ?? null);
|
||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
||||
if (error) msgTimer.current = setTimeout(() => setMessage(null), 3500);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
||||
}, []);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-emerald-50 text-gray-400 dark:bg-gray-950">
|
||||
마을 불러오는 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meals = state.buildings
|
||||
.filter((b) => b.type === "kitchen")
|
||||
.reduce((sum, b) => sum + Math.floor(b.stored), 0);
|
||||
const openedBuilding =
|
||||
panel?.type === "building"
|
||||
? state.buildings.find((b) => b.id === panel.id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<TopBar
|
||||
state={state}
|
||||
onAdopt={() => setCandidates(engine.generateCandidates())}
|
||||
onUpgrade={() => run(() => engine.upgradeHall())}
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<VillageView
|
||||
onBuildingClick={(id) => {
|
||||
if (id === "hall") setPanel({ type: "hall" });
|
||||
else if (id === "kitchen_lot") setPanel({ type: "kitchen_lot" });
|
||||
else setPanel({ type: "building", id });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── 상단 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">
|
||||
<button
|
||||
onClick={() => setPanel({ type: "hall" })}
|
||||
className="rounded-full bg-white/85 px-4 py-2 text-sm font-bold shadow backdrop-blur hover:bg-white dark:bg-gray-900/85 dark:hover:bg-gray-900"
|
||||
>
|
||||
🏡 고양이 마을 · 회관 Lv{state.hall.level}
|
||||
</button>
|
||||
<span className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85">
|
||||
🐱 {state.cats.length}/{state.hall.capacity}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85">
|
||||
🐟 {Math.floor(state.resources.fish)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85">
|
||||
🪵 {Math.floor(state.resources.wood)}
|
||||
</span>
|
||||
<span
|
||||
title="부엌에 보관 중인 요리"
|
||||
className="rounded-full bg-white/85 px-3 py-2 text-sm shadow backdrop-blur dark:bg-gray-900/85"
|
||||
>
|
||||
🍖 {meals}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-auto flex gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setPanel(panel?.type === "cats" ? null : { type: "cats" })
|
||||
}
|
||||
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={() => setCandidates(engine.generateCandidates())}
|
||||
disabled={state.cats.length >= state.hall.capacity}
|
||||
className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
😻 입양
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 메시지 토스트 ── */}
|
||||
{message && (
|
||||
<div className="absolute left-1/2 top-16 z-50 -translate-x-1/2 rounded-full bg-amber-500/95 px-5 py-2 text-sm font-semibold text-white shadow-lg">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── DB 상태 (좌하단 미니) ── */}
|
||||
<div className="absolute bottom-2 left-3 z-30 opacity-70">
|
||||
<DbStatus />
|
||||
</div>
|
||||
|
||||
{/* ── 우측 패널 ── */}
|
||||
{panel?.type === "hall" && (
|
||||
<SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}>
|
||||
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
|
||||
</SidePanel>
|
||||
)}
|
||||
{panel?.type === "kitchen_lot" && (
|
||||
<SidePanel title="🚧 부엌 공터" onClose={() => setPanel(null)}>
|
||||
<KitchenLotPanel
|
||||
wood={state.resources.wood}
|
||||
onBuild={() => {
|
||||
run(() => engine.buildKitchen());
|
||||
setPanel({ type: "building", id: "kitchen_1" });
|
||||
}}
|
||||
/>
|
||||
</SidePanel>
|
||||
)}
|
||||
{openedBuilding && (
|
||||
<SidePanel
|
||||
title={`${openedBuilding.name} Lv${openedBuilding.level}`}
|
||||
icon={TOOL_IMG[openedBuilding.aptitude]}
|
||||
onClose={() => setPanel(null)}
|
||||
>
|
||||
<BuildingPanel
|
||||
building={openedBuilding}
|
||||
state={state}
|
||||
onAssign={(catId) => run(() => engine.assign(catId, openedBuilding.id))}
|
||||
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
||||
onCollect={() => run(() => engine.collect(openedBuilding.id))}
|
||||
/>
|
||||
</SidePanel>
|
||||
)}
|
||||
{panel?.type === "cats" && (
|
||||
<SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}>
|
||||
<div className="space-y-3">
|
||||
{state.cats.map((cat) => (
|
||||
<CatCard key={cat.id} cat={cat} buildings={state.buildings} />
|
||||
))}
|
||||
</div>
|
||||
</SidePanel>
|
||||
)}
|
||||
|
||||
{/* ── 입양 모달 ── */}
|
||||
{candidates && (
|
||||
<AdoptModal
|
||||
candidates={candidates}
|
||||
@@ -54,64 +182,334 @@ export default function Game() {
|
||||
onClose={() => setCandidates(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<VillageView />
|
||||
/* ──────────────── 공통 패널 셸 ──────────────── */
|
||||
|
||||
{message && (
|
||||
<p className="rounded-lg bg-amber-50 px-4 py-2 text-sm text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
⚠️ {message}
|
||||
</p>
|
||||
function SidePanel({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className="absolute bottom-3 right-3 top-16 z-40 flex w-full max-w-sm flex-col rounded-2xl border border-gray-200 bg-white/95 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/95">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4 dark:border-gray-800">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold">
|
||||
{icon && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={icon} alt="" className="h-8 w-8 object-contain" />
|
||||
)}
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────── 회관 패널 ──────────────── */
|
||||
|
||||
function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => void }) {
|
||||
const next = HALL_LEVELS[state.hall.level + 1];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
마을의 중심. 회관 레벨이 수용 인원과 건물·레시피 해금을 결정합니다.
|
||||
</p>
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-3 text-sm dark:bg-gray-800">
|
||||
현재 수용 인원 <b>{state.cats.length}/{state.hall.capacity}마리</b>
|
||||
</div>
|
||||
{next ? (
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<h3 className="mb-2 font-semibold">
|
||||
Lv{state.hall.level + 1} 증축 → 수용 {next.capacity}마리
|
||||
</h3>
|
||||
<ul className="mb-3 space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{Object.entries(next.cost).map(([key, amount]) => {
|
||||
const have = Math.floor(
|
||||
state.resources[key as keyof typeof state.resources],
|
||||
);
|
||||
const ok = have >= (amount ?? 0);
|
||||
return (
|
||||
<li key={key} className={ok ? "text-emerald-600 dark:text-emerald-400" : ""}>
|
||||
{ok ? "✓" : "·"} {key === "wood" ? "🪵 목재" : "🐟 생선"} {have}/{amount}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{next.reqCat && (
|
||||
<li
|
||||
className={
|
||||
state.cats.filter((c) => c.level >= next.reqCat!.level).length >=
|
||||
next.reqCat.count
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{state.cats.filter((c) => c.level >= next.reqCat!.level).length >=
|
||||
next.reqCat.count
|
||||
? "✓"
|
||||
: "·"}{" "}
|
||||
Lv{next.reqCat.level} 이상 고양이{" "}
|
||||
{state.cats.filter((c) => c.level >= next.reqCat!.level).length}/
|
||||
{next.reqCat.count}마리
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{next.locked ? (
|
||||
<p className="rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
🔒 {next.locked}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="w-full rounded-lg bg-emerald-600 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
🏗️ 증축하기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">회관이 최고 레벨입니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────── 부엌 공터 패널 ──────────────── */
|
||||
|
||||
function KitchenLotPanel({ wood, onBuild }: { wood: number; onBuild: () => void }) {
|
||||
const canBuild = wood >= KITCHEN_BUILD_COST.wood;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={TOOL_IMG.cooking} alt="부엌" className="mx-auto h-24 object-contain opacity-70" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
부엌을 지으면 요리 적성 고양이가 생선으로 요리를 만들고, 배고픈
|
||||
고양이들이 알아서 먹으러 옵니다.
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
🔨 짓기 — 🪵 {KITCHEN_BUILD_COST.wood}
|
||||
{canBuild ? "" : ` (보유 ${Math.floor(wood)})`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────── 건물 패널 (정보 + 배치) ──────────────── */
|
||||
|
||||
function BuildingPanel({
|
||||
building,
|
||||
state,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onCollect,
|
||||
}: {
|
||||
building: Building;
|
||||
state: GameState;
|
||||
onAssign: (catId: string) => void;
|
||||
onUnassign: (catId: string) => void;
|
||||
onCollect: () => void;
|
||||
}) {
|
||||
const isKitchen = building.type === "kitchen";
|
||||
const recipe = activeRecipe(state);
|
||||
const assignedCats = building.assigned
|
||||
.map((id) => state.cats.find((c) => c.id === id))
|
||||
.filter((c): c is Cat => Boolean(c));
|
||||
const totalRate = assignedCats.reduce(
|
||||
(sum, c) => sum + effectiveRate(c, building.aptitude),
|
||||
0,
|
||||
);
|
||||
const stored = Math.floor(building.stored);
|
||||
const slotsLeft = building.maxSlots - assignedCats.length;
|
||||
// 배치 후보: 이 건물 적성이 있는 모든 고양이 (다른 곳에서 일하는 고양이 포함)
|
||||
const candidates = state.cats
|
||||
.filter((c) => c.aptitude[building.aptitude] > 0 && c.assignedTo !== building.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
effectiveRate(b, building.aptitude) - effectiveRate(a, building.aptitude),
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
{isKitchen ? (
|
||||
<>
|
||||
<div>
|
||||
{recipe.emoji} {recipe.name} (
|
||||
{Object.entries(recipe.input)
|
||||
.map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`)
|
||||
.join(" + ")}
|
||||
→ 포만도 +{recipe.fullness})
|
||||
</div>
|
||||
<div>요리 속도 {totalRate.toFixed(2)}/초</div>
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-full bg-amber-400"
|
||||
style={{
|
||||
width: `${Math.min(100, ((building.progress ?? 0) / recipe.work) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>생산 속도 {totalRate.toFixed(2)}/초</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 산출/선반 */}
|
||||
{isKitchen ? (
|
||||
<div className="rounded-lg bg-amber-50 px-4 py-2.5 text-center text-sm font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
{recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCollect}
|
||||
disabled={stored <= 0}
|
||||
className="w-full rounded-lg bg-sky-500 py-2.5 text-sm font-semibold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 배치된 고양이 */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold">🏗️ 생산 건물</h2>
|
||||
<p className="mb-3 text-xs text-gray-400">
|
||||
고양이를 선택한 뒤 빈자리를 클릭해 배치하세요. 산출물은 직접 회수해야 합니다.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{state.buildings.map((b) => (
|
||||
<BuildingCard
|
||||
key={b.id}
|
||||
building={b}
|
||||
state={state}
|
||||
selectedCat={selectedCat}
|
||||
onAssign={(catId) => run(() => engine.assign(catId, b.id))}
|
||||
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
||||
onCollect={() => run(() => engine.collect(b.id))}
|
||||
<h3 className="mb-2 text-sm font-bold text-gray-500 dark:text-gray-400">
|
||||
일하는 중 ({assignedCats.length}/{building.maxSlots})
|
||||
</h3>
|
||||
{assignedCats.length === 0 && (
|
||||
<p className="text-xs text-gray-400">아직 아무도 없어요</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{assignedCats.map((cat) => (
|
||||
<CatRow
|
||||
key={cat.id}
|
||||
cat={cat}
|
||||
aptitude={building.aptitude}
|
||||
tag={null}
|
||||
action={{ label: "해제 ✕", style: "danger", onClick: () => onUnassign(cat.id) }}
|
||||
/>
|
||||
))}
|
||||
{!state.buildings.some((b) => b.type === "kitchen") && (
|
||||
<BuildKitchenCard
|
||||
wood={state.resources.wood}
|
||||
onBuild={() => run(() => engine.buildKitchen())}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 배치 가능한 고양이 */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold">
|
||||
🐾 우리 고양이들 ({state.cats.length}/{state.hall.capacity})
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{state.cats.map((cat) => (
|
||||
<CatCard
|
||||
key={cat.id}
|
||||
cat={cat}
|
||||
buildings={state.buildings}
|
||||
selected={cat.id === selectedCatId}
|
||||
onSelect={() =>
|
||||
setSelectedCatId(cat.id === selectedCatId ? null : cat.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<h3 className="mb-2 text-sm font-bold text-gray-500 dark:text-gray-400">
|
||||
배치 가능한 고양이
|
||||
</h3>
|
||||
{candidates.length === 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{APTITUDE_LABEL[building.aptitude]} 적성이 있는 고양이가 없어요
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{candidates.map((cat) => {
|
||||
const workplace = state.buildings.find((b) => b.id === cat.assignedTo);
|
||||
return (
|
||||
<CatRow
|
||||
key={cat.id}
|
||||
cat={cat}
|
||||
aptitude={building.aptitude}
|
||||
tag={workplace ? `${workplace.name}에서 일하는 중` : "쉬는 중"}
|
||||
action={{
|
||||
label: "배치",
|
||||
style: "primary",
|
||||
disabled: slotsLeft <= 0,
|
||||
onClick: () => onAssign(cat.id),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{slotsLeft <= 0 && candidates.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
슬롯이 가득 찼어요 — 먼저 해제하거나 건물을 증축하세요
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 패시브 스킬 배지 — 마우스오버 시 상세 툴팁 */
|
||||
/** 패널용 고양이 행 */
|
||||
function CatRow({
|
||||
cat,
|
||||
aptitude,
|
||||
tag,
|
||||
action,
|
||||
}: {
|
||||
cat: Cat;
|
||||
aptitude: AptitudeKey;
|
||||
tag: string | null;
|
||||
action: {
|
||||
label: string;
|
||||
style: "primary" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-gray-200 px-3 py-2 dark:border-gray-700">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={catSprite(cat.breed, "idle")}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">
|
||||
{cat.name}{" "}
|
||||
<span className="text-xs font-normal text-gray-400">Lv{cat.level}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{APTITUDE_LABEL[aptitude]} {cat.aptitude[aptitude]} ·{" "}
|
||||
{effectiveRate(cat, aptitude).toFixed(2)}/초
|
||||
{fullnessPenalty(cat) < 1 && (
|
||||
<span className="ml-1 text-red-500">배고픔</span>
|
||||
)}
|
||||
</div>
|
||||
{tag && <div className="text-[11px] text-gray-400">{tag}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
action.style === "primary"
|
||||
? "bg-orange-500 text-white hover:bg-orange-600"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-red-100 hover:text-red-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────── 패시브 배지 (툴팁) ──────────────── */
|
||||
|
||||
function PassiveBadge({ passiveKey }: { passiveKey: string }) {
|
||||
const def = PASSIVES.find((p) => p.key === passiveKey);
|
||||
if (!def) return null;
|
||||
@@ -137,7 +535,82 @@ function PassiveBadge({ passiveKey }: { passiveKey: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 입양 모달 — 후보 3마리 중 1마리 선택 */
|
||||
/* ──────────────── 고양이 카드 (정보용) ──────────────── */
|
||||
|
||||
function CatCard({ cat, buildings }: { cat: Cat; buildings: Building[] }) {
|
||||
const workplace = buildings.find((b) => b.id === cat.assignedTo);
|
||||
const expNeeded = cat.level * 60;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 font-semibold">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={catSprite(cat.breed, "idle")}
|
||||
alt=""
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{cat.name}{" "}
|
||||
<span className="text-xs font-normal text-gray-400">Lv{cat.level}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{cat.breedName}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 h-1.5 w-full overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-orange-400"
|
||||
style={{ width: `${Math.min(100, (cat.exp / expNeeded) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
|
||||
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
className={`h-full ${
|
||||
cat.status.fullness < 25 ? "bg-red-400" : "bg-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${cat.status.fullness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
|
||||
</span>
|
||||
{fullnessPenalty(cat) < 1 && (
|
||||
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[11px] text-red-600 dark:bg-red-950 dark:text-red-300">
|
||||
배고픔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cat.passives.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{cat.passives.map((key) => (
|
||||
<PassiveBadge key={key} passiveKey={key} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
{workplace ? (
|
||||
<span className="text-sky-600 dark:text-sky-400">
|
||||
{workplace.emoji} {workplace.name}에서 일하는 중
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">쉬는 중</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────── 입양 모달 ──────────────── */
|
||||
|
||||
function AdoptModal({
|
||||
candidates,
|
||||
onPick,
|
||||
@@ -231,273 +704,9 @@ function AdoptModal({
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-xs text-gray-400">
|
||||
한 마리만 선택할 수 있어요 · 능력치와 패시브에 마우스를 올리면 상세 정보가 보입니다
|
||||
한 마리만 선택할 수 있어요 · 패시브에 마우스를 올리면 상세 정보가 보입니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({
|
||||
state,
|
||||
onAdopt,
|
||||
onUpgrade,
|
||||
}: {
|
||||
state: GameState;
|
||||
onAdopt: () => void;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
const full = state.cats.length >= state.hall.capacity;
|
||||
const meals = state.buildings
|
||||
.filter((b) => b.type === "kitchen")
|
||||
.reduce((sum, b) => sum + Math.floor(b.stored), 0);
|
||||
const next = HALL_LEVELS[state.hall.level + 1];
|
||||
const nextLabel = next
|
||||
? `증축 Lv${state.hall.level + 1}: 목재 ${next.cost.wood ?? 0}${next.cost.fish ? `, 생선 ${next.cost.fish}` : ""}${next.reqCat ? `, Lv${next.reqCat.level}↑ ${next.reqCat.count}마리` : ""} → 수용 ${next.capacity}마리`
|
||||
: "최고 레벨";
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
|
||||
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
|
||||
<span>🐟 {Math.floor(state.resources.fish)}</span>
|
||||
<span>🪵 {Math.floor(state.resources.wood)}</span>
|
||||
<span title="부엌에 보관 중인 요리">🍖 {meals}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
disabled={!next}
|
||||
title={nextLabel}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
🏗️ 회관 증축
|
||||
</button>
|
||||
<button
|
||||
onClick={onAdopt}
|
||||
disabled={full}
|
||||
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{full ? "수용 가득 참" : "😻 입양하기"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">{nextLabel}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildKitchenCard({ wood, onBuild }: { wood: number; onBuild: () => void }) {
|
||||
const canBuild = wood >= KITCHEN_BUILD_COST.wood;
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-between rounded-xl border-2 border-dashed border-gray-300 p-5 dark:border-gray-700">
|
||||
<div className="mb-1 flex items-center gap-2 font-semibold">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={TOOL_IMG.cooking} alt="부엌" className="h-8 w-8 object-contain opacity-60" />
|
||||
부엌 (미건설)
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
요리 적성 고양이를 배치해 생선으로 요리를 만듭니다. 배고픈 고양이가 알아서 먹으러 와요.
|
||||
</p>
|
||||
<button
|
||||
onClick={onBuild}
|
||||
disabled={!canBuild}
|
||||
className="w-full rounded-lg bg-amber-600 py-2 text-sm font-semibold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
🔨 짓기 — 🪵 {KITCHEN_BUILD_COST.wood} {canBuild ? "" : `(보유 ${Math.floor(wood)})`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildingCard({
|
||||
building,
|
||||
state,
|
||||
selectedCat,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onCollect,
|
||||
}: {
|
||||
building: Building;
|
||||
state: GameState;
|
||||
selectedCat: Cat | null;
|
||||
onAssign: (catId: string) => void;
|
||||
onUnassign: (catId: string) => void;
|
||||
onCollect: () => void;
|
||||
}) {
|
||||
const cats = state.cats;
|
||||
const isKitchen = building.type === "kitchen";
|
||||
const recipe = activeRecipe(state);
|
||||
const assignedCats = building.assigned
|
||||
.map((id) => cats.find((c) => c.id === id))
|
||||
.filter((c): c is Cat => Boolean(c));
|
||||
const totalRate = assignedCats.reduce(
|
||||
(sum, c) => sum + effectiveRate(c, building.aptitude),
|
||||
0,
|
||||
);
|
||||
const stored = Math.floor(building.stored);
|
||||
const emptySlots = building.maxSlots - assignedCats.length;
|
||||
const canAssignSelected =
|
||||
selectedCat !== null &&
|
||||
selectedCat.assignedTo !== building.id &&
|
||||
selectedCat.aptitude[building.aptitude] > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 p-5 dark:border-gray-800">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={TOOL_IMG[building.aptitude]}
|
||||
alt={building.name}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
{building.name} Lv{building.level}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
필요 적성: {APTITUDE_LABEL[building.aptitude]}
|
||||
</span>
|
||||
</div>
|
||||
{isKitchen ? (
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{recipe.emoji} {recipe.name} (
|
||||
{Object.entries(recipe.input)
|
||||
.map(([k, v]) => `${k === "fish" ? "생선" : "목재"} ${v}`)
|
||||
.join(" + ")}
|
||||
) · 요리 속도 {totalRate.toFixed(2)}/초
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
생산 속도 {totalRate.toFixed(2)}/초
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{assignedCats.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => onUnassign(cat.id)}
|
||||
title="클릭하면 배치 해제"
|
||||
className="rounded-full bg-gray-100 px-3 py-1 text-sm hover:bg-red-100 dark:bg-gray-800 dark:hover:bg-red-950"
|
||||
>
|
||||
{cat.emoji} {cat.name} ✕
|
||||
</button>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={!canAssignSelected}
|
||||
onClick={() => selectedCat && onAssign(selectedCat.id)}
|
||||
className="rounded-full border border-dashed border-gray-300 px-3 py-1 text-sm text-gray-400 enabled:hover:border-orange-400 enabled:hover:text-orange-500 disabled:opacity-50 dark:border-gray-700"
|
||||
>
|
||||
{canAssignSelected ? `+ ${selectedCat.name} 배치` : "빈자리"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isKitchen ? (
|
||||
<div className="w-full rounded-lg bg-amber-50 py-2 text-center text-sm font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
{recipe.emoji} 선반 {stored}/{KITCHEN_MEAL_CAP} — 배고픈 고양이가 자동으로 먹습니다
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCollect}
|
||||
disabled={stored <= 0}
|
||||
className="w-full rounded-lg bg-sky-500 py-2 text-sm font-semibold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatCard({
|
||||
cat,
|
||||
buildings,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
cat: Cat;
|
||||
buildings: Building[];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const workplace = buildings.find((b) => b.id === cat.assignedTo);
|
||||
const expNeeded = cat.level * 60;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`rounded-xl border p-4 text-left transition ${
|
||||
selected
|
||||
? "border-orange-400 ring-2 ring-orange-200 dark:ring-orange-900"
|
||||
: "border-gray-200 hover:border-gray-300 dark:border-gray-800 dark:hover:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 font-semibold">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={catSprite(cat.breed, "idle")}
|
||||
alt=""
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{cat.name}{" "}
|
||||
<span className="text-xs font-normal text-gray-400">Lv{cat.level}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{cat.breedName}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 h-1.5 w-full overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-orange-400"
|
||||
style={{ width: `${Math.min(100, (cat.exp / expNeeded) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 🍳 요리{" "}
|
||||
{cat.aptitude.cooking} · 💪 근면 {cat.stats.diligence}
|
||||
</p>
|
||||
|
||||
{/* 포만도 */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
className={`h-full ${
|
||||
cat.status.fullness < 25 ? "bg-red-400" : "bg-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${cat.status.fullness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
|
||||
</span>
|
||||
{fullnessPenalty(cat) < 1 && (
|
||||
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[11px] text-red-600 dark:bg-red-950 dark:text-red-300">
|
||||
배고픔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cat.passives.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{cat.passives.map((key) => (
|
||||
<PassiveBadge key={key} passiveKey={key} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
{workplace ? (
|
||||
<span className="text-sky-600 dark:text-sky-400">
|
||||
{workplace.emoji} {workplace.name}에서 일하는 중
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">쉬는 중</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { engine, catRate } from "@/lib/game/engine";
|
||||
import { catSprite, TOOL_IMG, type CatFrame } from "@/lib/game/data";
|
||||
import type { AptitudeKey } from "@/lib/game/types";
|
||||
@@ -86,8 +86,14 @@ function workSpot(buildingId: string, slotIndex: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function VillageView() {
|
||||
export default function VillageView({
|
||||
onBuildingClick,
|
||||
}: {
|
||||
onBuildingClick?: (id: string) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const animsRef = useRef<Map<string, CatAnim>>(new Map());
|
||||
const bubblesRef = useRef<Bubble[]>([]);
|
||||
const toolImagesRef = useRef<Map<AptitudeKey, HTMLImageElement>>(new Map());
|
||||
@@ -96,8 +102,24 @@ export default function VillageView() {
|
||||
// src 키 기반 이미지 캐시 (품종별 스프라이트 지연 로드)
|
||||
const imageCacheRef = useRef<Map<string, HTMLImageElement | null>>(new Map());
|
||||
const hitRectsRef = useRef<
|
||||
Array<{ id: string; x: number; y: number; w: number; h: number }>
|
||||
Array<{ id: string; kind: "collect" | "open"; x: number; y: number; w: number; h: number }>
|
||||
>([]);
|
||||
const onBuildingClickRef = useRef(onBuildingClick);
|
||||
onBuildingClickRef.current = onBuildingClick;
|
||||
|
||||
// 전체화면 스케일 (컨테이너 크기에 맞춤)
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const update = () =>
|
||||
setScale(
|
||||
Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H) * 0.98,
|
||||
);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -368,11 +390,14 @@ export default function VillageView() {
|
||||
sprites.push({ y: y - 6, draw: () => drawEmoji(d.emoji, x, y + 4, 18) });
|
||||
}
|
||||
|
||||
hitRectsRef.current = [];
|
||||
|
||||
// 회관
|
||||
{
|
||||
const t = BUILDING_TILES.hall;
|
||||
const x = isoX(t.i, t.j);
|
||||
const y = isoY(t.i, t.j) + TH / 2;
|
||||
hitRectsRef.current.push({ id: "hall", kind: "open", x: x - 34, y: y - 40, w: 68, h: 70 });
|
||||
sprites.push({
|
||||
y,
|
||||
draw: () => {
|
||||
@@ -386,8 +411,25 @@ export default function VillageView() {
|
||||
});
|
||||
}
|
||||
|
||||
// 부엌 미건설 시 공터 표시
|
||||
if (!state.buildings.some((b) => b.type === "kitchen")) {
|
||||
const t = BUILDING_TILES.kitchen_1;
|
||||
const x = isoX(t.i, t.j);
|
||||
const y = isoY(t.i, t.j) + TH / 2;
|
||||
hitRectsRef.current.push({ id: "kitchen_lot", kind: "open", x: x - 30, y: y - 34, w: 60, h: 56 });
|
||||
sprites.push({
|
||||
y,
|
||||
draw: () => {
|
||||
drawEmoji("🚧", x, y + 2, 26);
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "rgba(107,114,128,0.9)";
|
||||
ctx.fillText("부엌 공터", x, y + 16);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 생산 건물 + 회수 배지
|
||||
hitRectsRef.current = [];
|
||||
for (const b of state.buildings) {
|
||||
const t = BUILDING_TILES[b.id];
|
||||
if (!t) continue;
|
||||
@@ -395,8 +437,11 @@ export default function VillageView() {
|
||||
const y = isoY(t.i, t.j) + TH / 2;
|
||||
const stored = Math.floor(b.stored);
|
||||
const isKitchen = b.type === "kitchen";
|
||||
if (!isKitchen)
|
||||
hitRectsRef.current.push({ id: b.id, x: x - 30, y: y - 44, w: 60, h: 72 });
|
||||
// 건물 본체 → 정보 패널
|
||||
hitRectsRef.current.push({ id: b.id, kind: "open", x: x - 30, y: y - 38, w: 60, h: 66 });
|
||||
// 배지 → 빠른 회수 (생산 건물만)
|
||||
if (!isKitchen && stored > 0)
|
||||
hitRectsRef.current.push({ id: b.id, kind: "collect", x: x - 40, y: y - 62, w: 80, h: 26 });
|
||||
sprites.push({
|
||||
y,
|
||||
draw: () => {
|
||||
@@ -512,19 +557,29 @@ export default function VillageView() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * CANVAS_W;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * CANVAS_H;
|
||||
const hit = (r: { x: number; y: number; w: number; h: number }) =>
|
||||
x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
|
||||
|
||||
// 1) 배지(빠른 회수) 우선
|
||||
for (const r of hitRectsRef.current) {
|
||||
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
|
||||
const state = engine.peek();
|
||||
const b = state?.buildings.find((bb) => bb.id === r.id);
|
||||
const amount = b ? Math.floor(b.stored) : 0;
|
||||
if (b && amount > 0) {
|
||||
engine.collect(r.id);
|
||||
engine.save();
|
||||
bubblesRef.current.push({
|
||||
x: r.x + r.w / 2, y: r.y, born: performance.now(),
|
||||
text: `+${amount} ${b.resource === "fish" ? "🐟" : "🪵"}`,
|
||||
});
|
||||
}
|
||||
if (r.kind !== "collect" || !hit(r)) continue;
|
||||
const s = engine.peek();
|
||||
const b = s?.buildings.find((bb) => bb.id === r.id);
|
||||
const amount = b ? Math.floor(b.stored) : 0;
|
||||
if (b && b.resource && amount > 0) {
|
||||
engine.collect(r.id);
|
||||
engine.save();
|
||||
bubblesRef.current.push({
|
||||
x: r.x + r.w / 2, y: r.y, born: performance.now(),
|
||||
text: `+${amount} ${b.resource === "fish" ? "🐟" : "🪵"}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 2) 건물 본체 → 정보 패널 열기
|
||||
for (const r of hitRectsRef.current) {
|
||||
if (r.kind === "open" && hit(r)) {
|
||||
onBuildingClickRef.current?.(r.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -538,14 +593,18 @@ export default function VillageView() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-linear-to-b from-sky-100 to-emerald-50 dark:border-gray-800 dark:from-sky-950 dark:to-emerald-950">
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="absolute inset-0 flex items-center justify-center overflow-hidden bg-linear-to-b from-sky-100 to-emerald-50 dark:from-sky-950 dark:to-emerald-950"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: CANVAS_W, height: CANVAS_H, display: "block", margin: "0 auto", cursor: "pointer" }}
|
||||
style={{
|
||||
width: CANVAS_W * scale,
|
||||
height: CANVAS_H * scale,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<p className="pb-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
건물 위 주황 배지를 클릭하면 산출물을 회수합니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
새 이슈에서 참조
사용자 차단