건설 방식 개편: 상점 구매 + 자유 배치 (클래시 오브 클랜 방식)
- 건물 위치가 게임 상태(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 ? (
|
||||
<>
|
||||
|
||||
+131
-58
@@ -1,15 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { engine, catRate } from "@/lib/game/engine";
|
||||
import { BUILD_DEFS, catSprite, TOOL_IMG, type CatFrame } from "@/lib/game/data";
|
||||
import type { AptitudeKey, ResourceKey } from "@/lib/game/types";
|
||||
import { engine, catRate, canPlaceAt } from "@/lib/game/engine";
|
||||
import {
|
||||
BOARD_COLS,
|
||||
BOARD_ROWS,
|
||||
catSprite,
|
||||
HALL_TILE,
|
||||
TOOL_IMG,
|
||||
TREE_TILES,
|
||||
type CatFrame,
|
||||
} from "@/lib/game/data";
|
||||
import type {
|
||||
AptitudeKey,
|
||||
Building,
|
||||
BuildingType,
|
||||
GameState,
|
||||
ResourceKey,
|
||||
} from "@/lib/game/types";
|
||||
|
||||
const RES_EMOJI: Record<ResourceKey, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
||||
|
||||
export interface PlacingState {
|
||||
type: BuildingType;
|
||||
aptitude: AptitudeKey; // 고스트 이미지용
|
||||
moveId?: string; // 있으면 기존 건물 이동
|
||||
}
|
||||
|
||||
// ---------- 아이소메트릭 그리드 ----------
|
||||
const COLS = 11;
|
||||
const ROWS = 11;
|
||||
const COLS = BOARD_COLS;
|
||||
const ROWS = BOARD_ROWS;
|
||||
const TW = 64; // 타일 가로
|
||||
const TH = 32; // 타일 세로
|
||||
const BOARD_H = ((COLS + ROWS) * TH) / 2; // 보드 픽셀 높이
|
||||
@@ -26,19 +46,7 @@ const isoY = (i: number, j: number) => originY + ((i + j) * TH) / 2;
|
||||
// 물 (동쪽 끝 열), 부두는 물가에 붙임
|
||||
const isWater = (i: number, j: number) => i >= COLS - 1;
|
||||
|
||||
// 건물 위치 (타일 좌표)
|
||||
const BUILDING_TILES: Record<string, { i: number; j: number }> = {
|
||||
hall: { i: 5, j: 3 },
|
||||
dock_1: { i: 9, j: 5 },
|
||||
lumberyard_1: { i: 2, j: 8 },
|
||||
kitchen_1: { i: 6, j: 7 },
|
||||
quarry_1: { i: 1, j: 2 },
|
||||
};
|
||||
|
||||
const TREES: Array<{ i: number; j: number }> = [
|
||||
{ i: 0, j: 7 }, { i: 1, j: 9 }, { i: 3, j: 9 }, { i: 0, j: 9 },
|
||||
{ i: 1, j: 6 }, { i: 3, j: 7 },
|
||||
];
|
||||
const TREES = TREE_TILES;
|
||||
const DECOR: Array<{ i: number; j: number; emoji: string }> = [
|
||||
{ i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" },
|
||||
{ i: 8, j: 8, emoji: "🌾" }, { i: 6, j: 9, emoji: "🍄" },
|
||||
@@ -59,11 +67,12 @@ const OUTER_DECOR: Array<{ i: number; j: number; emoji: string }> = [
|
||||
{ i: -0.5, j: 9.5, emoji: "🍄" }, { i: 11, j: 12.3, emoji: "🌷" },
|
||||
];
|
||||
|
||||
const isBuildingTile = (i: number, j: number) =>
|
||||
Object.values(BUILDING_TILES).some((t) => t.i === i && t.j === j);
|
||||
const isWalkable = (i: number, j: number) =>
|
||||
const isBuildingTile = (s: GameState, i: number, j: number) =>
|
||||
(HALL_TILE.i === i && HALL_TILE.j === j) ||
|
||||
s.buildings.some((b) => b.pos.i === i && b.pos.j === j);
|
||||
const isWalkable = (s: GameState, i: number, j: number) =>
|
||||
i >= 0 && i < COLS - 1 && j >= 0 && j < ROWS &&
|
||||
!isBuildingTile(i, j) &&
|
||||
!isBuildingTile(s, i, j) &&
|
||||
!TREES.some((t) => t.i === i && t.j === j);
|
||||
|
||||
// ---------- 고양이 이동 상태 (렌더 전용, 저장 안 함) ----------
|
||||
@@ -87,17 +96,17 @@ interface Bubble {
|
||||
|
||||
const WALK_SPEED = 42; // px/초
|
||||
|
||||
function randomWalkTarget(): { x: number; y: number } {
|
||||
function randomWalkTarget(s: GameState): { x: number; y: number } {
|
||||
for (let tries = 0; tries < 30; tries++) {
|
||||
const i = Math.floor(Math.random() * (COLS - 1));
|
||||
const j = Math.floor(Math.random() * ROWS);
|
||||
if (isWalkable(i, j)) return { x: isoX(i, j), y: isoY(i, j) + TH / 2 };
|
||||
if (isWalkable(s, i, j)) return { x: isoX(i, j), y: isoY(i, j) + TH / 2 };
|
||||
}
|
||||
return { x: isoX(5, 5), y: isoY(5, 5) + TH / 2 };
|
||||
}
|
||||
|
||||
function workSpot(buildingId: string, slotIndex: number) {
|
||||
const tile = BUILDING_TILES[buildingId] ?? BUILDING_TILES.hall;
|
||||
function workSpot(building: Building | undefined, slotIndex: number) {
|
||||
const tile = building?.pos ?? HALL_TILE;
|
||||
// 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치
|
||||
const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6;
|
||||
return {
|
||||
@@ -108,8 +117,14 @@ function workSpot(buildingId: string, slotIndex: number) {
|
||||
|
||||
export default function VillageView({
|
||||
onBuildingClick,
|
||||
placing,
|
||||
onPlace,
|
||||
onCancelPlace,
|
||||
}: {
|
||||
onBuildingClick?: (id: string) => void;
|
||||
placing?: PlacingState | null;
|
||||
onPlace?: (i: number, j: number) => void;
|
||||
onCancelPlace?: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,6 +141,13 @@ export default function VillageView({
|
||||
>([]);
|
||||
const onBuildingClickRef = useRef(onBuildingClick);
|
||||
onBuildingClickRef.current = onBuildingClick;
|
||||
const placingRef = useRef<PlacingState | null | undefined>(placing);
|
||||
placingRef.current = placing;
|
||||
const onPlaceRef = useRef(onPlace);
|
||||
onPlaceRef.current = onPlace;
|
||||
const onCancelPlaceRef = useRef(onCancelPlace);
|
||||
onCancelPlaceRef.current = onCancelPlace;
|
||||
const hoverTileRef = useRef<{ i: number; j: number } | null>(null);
|
||||
|
||||
// 전체화면 스케일 (컨테이너 크기에 맞춤)
|
||||
useEffect(() => {
|
||||
@@ -204,7 +226,7 @@ export default function VillageView({
|
||||
alive.add(cat.id);
|
||||
let anim = anims.get(cat.id);
|
||||
if (!anim) {
|
||||
const start = randomWalkTarget();
|
||||
const start = randomWalkTarget(state);
|
||||
anim = { ...start, tx: start.x, ty: start.y, mode: "wander", waitUntil: 0, flip: false };
|
||||
anims.set(cat.id, anim);
|
||||
}
|
||||
@@ -223,23 +245,23 @@ export default function VillageView({
|
||||
if (cat.assignedTo) {
|
||||
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
||||
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
||||
const spot = workSpot(cat.assignedTo, Math.max(0, slot));
|
||||
// 다른 건물로 재배치되면 일하던 중이어도 새 일터로 이동
|
||||
const spot = workSpot(building, Math.max(0, slot));
|
||||
// 재배치·건물 이동 시 새 일터로 걸어감
|
||||
if (anim.workBuildingId !== cat.assignedTo) {
|
||||
anim.workBuildingId = cat.assignedTo;
|
||||
anim.tx = spot.x;
|
||||
anim.ty = spot.y;
|
||||
anim.mode = "toWork";
|
||||
} else if (anim.mode !== "working" && (anim.tx !== spot.x || anim.ty !== spot.y)) {
|
||||
} else if (Math.hypot(anim.tx - spot.x, anim.ty - spot.y) > 4) {
|
||||
anim.tx = spot.x;
|
||||
anim.ty = spot.y;
|
||||
anim.mode = "toWork";
|
||||
if (anim.mode === "working") anim.mode = "toWork";
|
||||
}
|
||||
} else {
|
||||
if (anim.workBuildingId) anim.workBuildingId = null;
|
||||
if (anim.mode !== "wander") {
|
||||
anim.mode = "wander";
|
||||
const t = randomWalkTarget();
|
||||
const t = randomWalkTarget(state);
|
||||
anim.tx = t.x;
|
||||
anim.ty = t.y;
|
||||
}
|
||||
@@ -272,7 +294,7 @@ export default function VillageView({
|
||||
anim.waitUntil = now + 800 + Math.random() * 2600;
|
||||
} else if (now >= anim.waitUntil) {
|
||||
anim.waitUntil = 0;
|
||||
const t = randomWalkTarget();
|
||||
const t = randomWalkTarget(state);
|
||||
anim.tx = t.x;
|
||||
anim.ty = t.y;
|
||||
}
|
||||
@@ -437,7 +459,7 @@ export default function VillageView({
|
||||
|
||||
// 회관
|
||||
{
|
||||
const t = BUILDING_TILES.hall;
|
||||
const t = HALL_TILE;
|
||||
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 });
|
||||
@@ -454,31 +476,11 @@ export default function VillageView({
|
||||
});
|
||||
}
|
||||
|
||||
// 미건설 건물 공터 표시 (회관 레벨 충족 시)
|
||||
for (const def of BUILD_DEFS) {
|
||||
if (state.hall.level < def.reqHallLv) continue;
|
||||
if (state.buildings.some((b) => b.type === def.type)) continue;
|
||||
const t = BUILDING_TILES[def.id];
|
||||
if (!t) continue;
|
||||
const x = isoX(t.i, t.j);
|
||||
const y = isoY(t.i, t.j) + TH / 2;
|
||||
hitRectsRef.current.push({ id: `lot_${def.type}`, 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(`${def.name} 공터`, x, y + 16);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 생산 건물 + 회수 배지
|
||||
const placing = placingRef.current;
|
||||
for (const b of state.buildings) {
|
||||
const t = BUILDING_TILES[b.id];
|
||||
if (!t) continue;
|
||||
if (placing?.moveId === b.id) continue; // 이동 중인 건물은 고스트로만 표시
|
||||
const t = b.pos;
|
||||
const x = isoX(t.i, t.j);
|
||||
const y = isoY(t.i, t.j) + TH / 2;
|
||||
const stored = Math.floor(b.stored);
|
||||
@@ -579,6 +581,45 @@ export default function VillageView({
|
||||
sprites.sort((a, b) => a.y - b.y);
|
||||
for (const s of sprites) s.draw();
|
||||
|
||||
// --- 배치 모드 오버레이 (고스트 + 유효/무효 하이라이트) ---
|
||||
if (placing) {
|
||||
// 배치 가능한 타일 은은하게 표시
|
||||
for (let j = 0; j < ROWS; j++) {
|
||||
for (let i = 0; i < COLS - 1; i++) {
|
||||
if (!canPlaceAt(state, placing.type, i, j, placing.moveId)) continue;
|
||||
ctx.beginPath();
|
||||
tilePath(i, j);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.16)";
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
const hover = hoverTileRef.current;
|
||||
if (hover) {
|
||||
const valid = canPlaceAt(state, placing.type, hover.i, hover.j, placing.moveId);
|
||||
ctx.beginPath();
|
||||
tilePath(hover.i, hover.j);
|
||||
ctx.fillStyle = valid ? "rgba(52,211,153,0.5)" : "rgba(248,113,113,0.5)";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = valid ? "#059669" : "#dc2626";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
// 고스트 건물
|
||||
const gx = isoX(hover.i, hover.j);
|
||||
const gy = isoY(hover.i, hover.j) + TH / 2;
|
||||
const img = toolImagesRef.current.get(placing.aptitude);
|
||||
ctx.globalAlpha = 0.75;
|
||||
if (img) {
|
||||
const h = 56;
|
||||
const w = (img.width / img.height) * h;
|
||||
ctx.drawImage(img, gx - w / 2, gy + 8 - h, w, h);
|
||||
} else {
|
||||
drawEmoji("🏗️", gx, gy + 8, 40);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 생산 말풍선
|
||||
const bubbles = bubblesRef.current;
|
||||
for (let k = bubbles.length - 1; k >= 0; k--) {
|
||||
@@ -598,7 +639,31 @@ export default function VillageView({
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
|
||||
const tileFromEvent = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / zoom;
|
||||
const y = (e.clientY - rect.top) / zoom;
|
||||
const iF = ((x - originX) / (TW / 2) + (y - originY) / (TH / 2)) / 2;
|
||||
const jF = ((y - originY) / (TH / 2) - (x - originX) / (TW / 2)) / 2;
|
||||
return { i: Math.floor(iF), j: Math.floor(jF) };
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!placingRef.current) return;
|
||||
hoverTileRef.current = tileFromEvent(e);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && placingRef.current) onCancelPlaceRef.current?.();
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
// 배치 모드: 타일 선택
|
||||
if (placingRef.current) {
|
||||
const t = tileFromEvent(e);
|
||||
onPlaceRef.current?.(t.i, t.j);
|
||||
return;
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / zoom;
|
||||
const y = (e.clientY - rect.top) / zoom;
|
||||
@@ -630,10 +695,14 @@ export default function VillageView({
|
||||
}
|
||||
};
|
||||
canvas.addEventListener("click", onClick);
|
||||
canvas.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
canvas.removeEventListener("click", onClick);
|
||||
canvas.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
@@ -641,7 +710,11 @@ export default function VillageView({
|
||||
<div ref={wrapRef} className="absolute inset-0 overflow-hidden bg-[#82c368]">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: "100%", height: "100%", cursor: "pointer" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: placing ? "crosshair" : "pointer",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,33 @@ export const PASSIVES: PassiveDef[] = [
|
||||
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
|
||||
];
|
||||
|
||||
// ---------- 마을 보드 ----------
|
||||
|
||||
import type { BuildingType } from "./types";
|
||||
|
||||
export const BOARD_COLS = 11;
|
||||
export const BOARD_ROWS = 11;
|
||||
export const HALL_TILE = { i: 5, j: 3 };
|
||||
export const TREE_TILES: Array<{ i: number; j: number }> = [
|
||||
{ i: 0, j: 7 }, { i: 1, j: 9 }, { i: 3, j: 9 }, { i: 0, j: 9 },
|
||||
{ i: 1, j: 6 }, { i: 3, j: 7 },
|
||||
];
|
||||
export const isWaterTile = (i: number) => i >= BOARD_COLS - 1;
|
||||
|
||||
// 배치 규칙: 부두는 물가(강 바로 옆 열)에만
|
||||
export type PlacementRule = "land" | "nearWater";
|
||||
export const PLACEMENT_RULES: Partial<Record<BuildingType, PlacementRule>> = {
|
||||
dock: "nearWater",
|
||||
};
|
||||
|
||||
// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치
|
||||
export const DEFAULT_BUILDING_POS: Record<string, { i: number; j: number }> = {
|
||||
dock_1: { i: 9, j: 5 },
|
||||
lumberyard_1: { i: 2, j: 8 },
|
||||
kitchen_1: { i: 6, j: 7 },
|
||||
quarry_1: { i: 1, j: 2 },
|
||||
};
|
||||
|
||||
// ---------- 성장 ----------
|
||||
|
||||
export const EXP_PER_LEVEL = 90; // 레벨업 필요 경험치 = 레벨 × 90
|
||||
|
||||
+47
-2
@@ -3,20 +3,27 @@ import {
|
||||
APT_POINT_EVERY,
|
||||
APTITUDE_RATE,
|
||||
BASE_RATE,
|
||||
BOARD_COLS,
|
||||
BOARD_ROWS,
|
||||
BREEDS,
|
||||
BUILD_DEFS,
|
||||
CAT_NAMES,
|
||||
DEFAULT_BUILDING_POS,
|
||||
EAT_THRESHOLD,
|
||||
EXP_PER_LEVEL,
|
||||
FULLNESS_DECAY_PER_SEC,
|
||||
HALL_LEVELS,
|
||||
HALL_TILE,
|
||||
KITCHEN_UPGRADE_COST,
|
||||
mealCap,
|
||||
PASSIVES,
|
||||
PLACEMENT_RULES,
|
||||
RECIPES,
|
||||
TREE_TILES,
|
||||
type BreedDef,
|
||||
type RecipeDef,
|
||||
} from "./data";
|
||||
import type { BuildingType } from "./types";
|
||||
|
||||
const SAVE_KEY = "cat-village-save";
|
||||
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
||||
@@ -91,11 +98,13 @@ function createBuildings(): Building[] {
|
||||
return [
|
||||
{
|
||||
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
||||
pos: { ...DEFAULT_BUILDING_POS.dock_1 },
|
||||
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
{
|
||||
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
||||
pos: { ...DEFAULT_BUILDING_POS.lumberyard_1 },
|
||||
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
@@ -150,6 +159,26 @@ function passiveFoodMul(cat: Cat): number {
|
||||
return mul;
|
||||
}
|
||||
|
||||
/** 해당 타일에 건물을 놓을 수 있는가 (이동 시 excludeId로 자기 자신 제외) */
|
||||
export function canPlaceAt(
|
||||
s: GameState,
|
||||
type: BuildingType,
|
||||
i: number,
|
||||
j: number,
|
||||
excludeId?: string,
|
||||
): boolean {
|
||||
if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
|
||||
// 물 열 제외한 보드 범위
|
||||
if (i < 0 || j < 0 || i >= BOARD_COLS - 1 || j >= BOARD_ROWS) return false;
|
||||
const rule = PLACEMENT_RULES[type] ?? "land";
|
||||
if (rule === "nearWater" && i !== BOARD_COLS - 2) return false; // 부두는 물가 열만
|
||||
if (HALL_TILE.i === i && HALL_TILE.j === j) return false;
|
||||
if (TREE_TILES.some((t) => t.i === i && t.j === j)) return false;
|
||||
if (s.buildings.some((b) => b.id !== excludeId && b.pos.i === i && b.pos.j === j))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 현재 해금된 레시피 중 가장 마지막 것 (부엌이 요리할 메뉴) */
|
||||
export function activeRecipe(s: GameState): RecipeDef {
|
||||
const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1;
|
||||
@@ -187,6 +216,8 @@ export const engine = {
|
||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||
state.resources.stone ??= 0;
|
||||
for (const cat of state.cats) cat.aptitudePoints ??= 0;
|
||||
for (const b of state.buildings)
|
||||
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
||||
return state;
|
||||
},
|
||||
|
||||
@@ -266,8 +297,8 @@ export const engine = {
|
||||
return null;
|
||||
},
|
||||
|
||||
/** 공통 건설: BUILD_DEFS 기반 (부엌·채석장 …) */
|
||||
build(type: "quarry" | "kitchen"): string | null {
|
||||
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
||||
build(type: "quarry" | "kitchen", pos: { i: number; j: number }): string | null {
|
||||
if (!state) return null;
|
||||
const def = BUILD_DEFS.find((d) => d.type === type);
|
||||
if (!def) return null;
|
||||
@@ -275,6 +306,8 @@ export const engine = {
|
||||
return `${def.name}은(는) 이미 지어져 있습니다`;
|
||||
if (state.hall.level < def.reqHallLv)
|
||||
return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`;
|
||||
if (!canPlaceAt(state, def.type, pos.i, pos.j))
|
||||
return "그곳에는 지을 수 없습니다";
|
||||
const missing = Object.entries(def.cost).filter(
|
||||
([key, amount]) =>
|
||||
state!.resources[key as keyof GameState["resources"]] < (amount ?? 0),
|
||||
@@ -286,12 +319,24 @@ export const engine = {
|
||||
consume(state, def.cost);
|
||||
state.buildings.push({
|
||||
id: def.id, type: def.type, name: def.name, emoji: def.emoji,
|
||||
pos: { i: pos.i, j: pos.j },
|
||||
level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
|
||||
resource: def.resource, assigned: [], stored: 0, progress: 0,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
|
||||
/** 건물 이동 (자유 배치) */
|
||||
moveBuilding(id: string, pos: { i: number; j: number }): string | null {
|
||||
if (!state) return null;
|
||||
const building = state.buildings.find((b) => b.id === id);
|
||||
if (!building) return null;
|
||||
if (!canPlaceAt(state, building.type, pos.i, pos.j, id))
|
||||
return "그곳으로는 옮길 수 없습니다";
|
||||
building.pos = { i: pos.i, j: pos.j };
|
||||
return null;
|
||||
},
|
||||
|
||||
/** 부엌 증축 Lv1→2: 훈제 생선 해금 조건 + 선반 확장 */
|
||||
upgradeKitchen(): string | null {
|
||||
if (!state) return null;
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Building {
|
||||
type: BuildingType;
|
||||
name: string;
|
||||
emoji: string;
|
||||
pos: { i: number; j: number }; // 보드 타일 좌표 (자유 배치)
|
||||
level: number;
|
||||
maxSlots: number;
|
||||
aptitude: AptitudeKey; // 필요 적성
|
||||
|
||||
새 이슈에서 참조
사용자 차단