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

- 건물 위치가 게임 상태(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 ? (
<>
+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>
);
+27
파일 보기
@@ -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;
+1
파일 보기
@@ -39,6 +39,7 @@ export interface Building {
type: BuildingType;
name: string;
emoji: string;
pos: { i: number; j: number }; // 보드 타일 좌표 (자유 배치)
level: number;
maxSlots: number;
aptitude: AptitudeKey; // 필요 적성