건설 방식 개편: 상점 구매 + 자유 배치 (클래시 오브 클랜 방식)
- 건물 위치가 게임 상태(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 { useEffect, useRef, useState } from "react";
|
||||||
import { useGame } from "@/hooks/useGame";
|
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 DbStatus from "@/components/DbStatus";
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
@@ -31,7 +31,7 @@ const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", ston
|
|||||||
type Panel =
|
type Panel =
|
||||||
| { type: "building"; id: string }
|
| { type: "building"; id: string }
|
||||||
| { type: "hall" }
|
| { type: "hall" }
|
||||||
| { type: "lot"; def: BuildDef }
|
| { type: "shop" }
|
||||||
| { type: "cats" }
|
| { type: "cats" }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ export default function Game() {
|
|||||||
const { state, dispatch } = useGame();
|
const { state, dispatch } = useGame();
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
const [panel, setPanel] = useState<Panel>(null);
|
||||||
const [candidates, setCandidates] = useState<Cat[] | null>(null);
|
const [candidates, setCandidates] = useState<Cat[] | null>(null);
|
||||||
|
const [placing, setPlacing] = useState<PlacingState | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const msgTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const msgTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export default function Game() {
|
|||||||
setMessage(error ?? null);
|
setMessage(error ?? null);
|
||||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
if (msgTimer.current) clearTimeout(msgTimer.current);
|
||||||
if (error) msgTimer.current = setTimeout(() => setMessage(null), 3500);
|
if (error) msgTimer.current = setTimeout(() => setMessage(null), 3500);
|
||||||
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
@@ -74,13 +76,33 @@ export default function Game() {
|
|||||||
<VillageView
|
<VillageView
|
||||||
onBuildingClick={(id) => {
|
onBuildingClick={(id) => {
|
||||||
if (id === "hall") setPanel({ type: "hall" });
|
if (id === "hall") setPanel({ type: "hall" });
|
||||||
else if (id.startsWith("lot_")) {
|
else setPanel({ type: "building", id });
|
||||||
const def = BUILD_DEFS.find((d) => d.type === id.slice(4));
|
|
||||||
if (def) setPanel({ type: "lot", def });
|
|
||||||
} 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 ── */}
|
{/* ── 상단 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-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">
|
<div className="pointer-events-auto flex flex-wrap items-center gap-2">
|
||||||
@@ -110,6 +132,14 @@ export default function Game() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto flex gap-2">
|
<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
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPanel(panel?.type === "cats" ? null : { type: "cats" })
|
setPanel(panel?.type === "cats" ? null : { type: "cats" })
|
||||||
@@ -146,20 +176,13 @@ export default function Game() {
|
|||||||
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
|
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} />
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
)}
|
)}
|
||||||
{panel?.type === "lot" && (
|
{panel?.type === "shop" && (
|
||||||
<SidePanel title={`🚧 ${panel.def.name} 공터`} onClose={() => setPanel(null)}>
|
<SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}>
|
||||||
<LotPanel
|
<ShopPanel
|
||||||
def={panel.def}
|
|
||||||
state={state}
|
state={state}
|
||||||
onBuild={() => {
|
onBuy={(def) => {
|
||||||
const err = dispatch(() => engine.build(panel.def.type));
|
setPanel(null);
|
||||||
if (err) {
|
setPlacing({ type: def.type, aptitude: def.aptitude });
|
||||||
setMessage(err);
|
|
||||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
|
||||||
msgTimer.current = setTimeout(() => setMessage(null), 3500);
|
|
||||||
} else {
|
|
||||||
setPanel({ type: "building", id: panel.def.id });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
@@ -177,6 +200,14 @@ export default function Game() {
|
|||||||
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
||||||
onCollect={() => run(() => engine.collect(openedBuilding.id))}
|
onCollect={() => run(() => engine.collect(openedBuilding.id))}
|
||||||
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
|
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
|
||||||
|
onMove={() => {
|
||||||
|
setPanel(null);
|
||||||
|
setPlacing({
|
||||||
|
type: openedBuilding.type,
|
||||||
|
aptitude: openedBuilding.aptitude,
|
||||||
|
moveId: openedBuilding.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
)}
|
)}
|
||||||
@@ -322,55 +353,63 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ──────────────── 공터 패널 (건설 공통) ──────────────── */
|
/* ──────────────── 건물 상점 ──────────────── */
|
||||||
|
|
||||||
function LotPanel({
|
function ShopPanel({
|
||||||
def,
|
|
||||||
state,
|
state,
|
||||||
onBuild,
|
onBuy,
|
||||||
}: {
|
}: {
|
||||||
def: BuildDef;
|
|
||||||
state: GameState;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<p className="text-xs text-gray-400">
|
||||||
<img
|
구매 후 마을의 원하는 타일을 클릭해 배치합니다. 비용은 배치 확정 시 차감돼요.
|
||||||
src={TOOL_IMG[def.aptitude]}
|
</p>
|
||||||
alt={def.name}
|
{BUILD_DEFS.map((def) => {
|
||||||
className="mx-auto h-24 object-contain opacity-70"
|
const built = state.buildings.some((b) => b.type === def.type);
|
||||||
/>
|
const hallOk = state.hall.level >= def.reqHallLv;
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">{def.desc}</p>
|
const costEntries = Object.entries(def.cost) as Array<[string, number]>;
|
||||||
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
const affordable = costEntries.every(
|
||||||
{def.reqHallLv > 1 && (
|
([key, amount]) =>
|
||||||
<li className={state.hall.level >= def.reqHallLv ? "text-emerald-600 dark:text-emerald-400" : ""}>
|
state.resources[key as keyof GameState["resources"]] >= amount,
|
||||||
{state.hall.level >= def.reqHallLv ? "✓" : "·"} 회관 Lv{def.reqHallLv} 필요
|
);
|
||||||
</li>
|
return (
|
||||||
)}
|
<div
|
||||||
{costEntries.map(([key, amount]) => {
|
key={def.type}
|
||||||
const have = Math.floor(state.resources[key as keyof GameState["resources"]]);
|
className="flex items-center gap-3 rounded-xl border border-gray-200 p-3 dark:border-gray-700"
|
||||||
return (
|
>
|
||||||
<li key={key} className={have >= amount ? "text-emerald-600 dark:text-emerald-400" : ""}>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{have >= amount ? "✓" : "·"} {RES_EMOJI[key]} {RES_LABEL[key]} {have}/{amount}
|
<img
|
||||||
</li>
|
src={TOOL_IMG[def.aptitude]}
|
||||||
);
|
alt={def.name}
|
||||||
})}
|
className={`h-12 w-12 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
|
||||||
</ul>
|
/>
|
||||||
<button
|
<div className="min-w-0 flex-1">
|
||||||
onClick={onBuild}
|
<div className="text-sm font-semibold">{def.name}</div>
|
||||||
disabled={!canBuild}
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
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"
|
{costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")}
|
||||||
>
|
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
|
||||||
🔨 짓기
|
</div>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -384,6 +423,7 @@ function BuildingPanel({
|
|||||||
onUnassign,
|
onUnassign,
|
||||||
onCollect,
|
onCollect,
|
||||||
onUpgradeKitchen,
|
onUpgradeKitchen,
|
||||||
|
onMove,
|
||||||
}: {
|
}: {
|
||||||
building: Building;
|
building: Building;
|
||||||
state: GameState;
|
state: GameState;
|
||||||
@@ -391,6 +431,7 @@ function BuildingPanel({
|
|||||||
onUnassign: (catId: string) => void;
|
onUnassign: (catId: string) => void;
|
||||||
onCollect: () => void;
|
onCollect: () => void;
|
||||||
onUpgradeKitchen: () => void;
|
onUpgradeKitchen: () => void;
|
||||||
|
onMove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isKitchen = building.type === "kitchen";
|
const isKitchen = building.type === "kitchen";
|
||||||
const recipe = activeRecipe(state);
|
const recipe = activeRecipe(state);
|
||||||
@@ -415,9 +456,18 @@ function BuildingPanel({
|
|||||||
<div className="space-y-5">
|
<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 className="rounded-lg bg-gray-50 px-4 py-3 text-sm leading-relaxed dark:bg-gray-800">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
필요 적성: <b>{APTITUDE_LABEL[building.aptitude]}</b> · 슬롯{" "}
|
<span>
|
||||||
{assignedCats.length}/{building.maxSlots}
|
필요 적성: <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>
|
</div>
|
||||||
{isKitchen ? (
|
{isKitchen ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+131
-58
@@ -1,15 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { engine, catRate } from "@/lib/game/engine";
|
import { engine, catRate, canPlaceAt } from "@/lib/game/engine";
|
||||||
import { BUILD_DEFS, catSprite, TOOL_IMG, type CatFrame } from "@/lib/game/data";
|
import {
|
||||||
import type { AptitudeKey, ResourceKey } from "@/lib/game/types";
|
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: "🪨" };
|
const RES_EMOJI: Record<ResourceKey, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
||||||
|
|
||||||
|
export interface PlacingState {
|
||||||
|
type: BuildingType;
|
||||||
|
aptitude: AptitudeKey; // 고스트 이미지용
|
||||||
|
moveId?: string; // 있으면 기존 건물 이동
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 아이소메트릭 그리드 ----------
|
// ---------- 아이소메트릭 그리드 ----------
|
||||||
const COLS = 11;
|
const COLS = BOARD_COLS;
|
||||||
const ROWS = 11;
|
const ROWS = BOARD_ROWS;
|
||||||
const TW = 64; // 타일 가로
|
const TW = 64; // 타일 가로
|
||||||
const TH = 32; // 타일 세로
|
const TH = 32; // 타일 세로
|
||||||
const BOARD_H = ((COLS + ROWS) * TH) / 2; // 보드 픽셀 높이
|
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 isWater = (i: number, j: number) => i >= COLS - 1;
|
||||||
|
|
||||||
// 건물 위치 (타일 좌표)
|
const TREES = TREE_TILES;
|
||||||
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 DECOR: Array<{ i: number; j: number; emoji: string }> = [
|
const DECOR: Array<{ i: number; j: number; emoji: string }> = [
|
||||||
{ i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" },
|
{ i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" },
|
||||||
{ i: 8, j: 8, emoji: "🌾" }, { i: 6, j: 9, 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: "🌷" },
|
{ i: -0.5, j: 9.5, emoji: "🍄" }, { i: 11, j: 12.3, emoji: "🌷" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isBuildingTile = (i: number, j: number) =>
|
const isBuildingTile = (s: GameState, i: number, j: number) =>
|
||||||
Object.values(BUILDING_TILES).some((t) => t.i === i && t.j === j);
|
(HALL_TILE.i === i && HALL_TILE.j === j) ||
|
||||||
const isWalkable = (i: number, j: number) =>
|
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 &&
|
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);
|
!TREES.some((t) => t.i === i && t.j === j);
|
||||||
|
|
||||||
// ---------- 고양이 이동 상태 (렌더 전용, 저장 안 함) ----------
|
// ---------- 고양이 이동 상태 (렌더 전용, 저장 안 함) ----------
|
||||||
@@ -87,17 +96,17 @@ interface Bubble {
|
|||||||
|
|
||||||
const WALK_SPEED = 42; // px/초
|
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++) {
|
for (let tries = 0; tries < 30; tries++) {
|
||||||
const i = Math.floor(Math.random() * (COLS - 1));
|
const i = Math.floor(Math.random() * (COLS - 1));
|
||||||
const j = Math.floor(Math.random() * ROWS);
|
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 };
|
return { x: isoX(5, 5), y: isoY(5, 5) + TH / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function workSpot(buildingId: string, slotIndex: number) {
|
function workSpot(building: Building | undefined, slotIndex: number) {
|
||||||
const tile = BUILDING_TILES[buildingId] ?? BUILDING_TILES.hall;
|
const tile = building?.pos ?? HALL_TILE;
|
||||||
// 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치
|
// 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치
|
||||||
const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6;
|
const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6;
|
||||||
return {
|
return {
|
||||||
@@ -108,8 +117,14 @@ function workSpot(buildingId: string, slotIndex: number) {
|
|||||||
|
|
||||||
export default function VillageView({
|
export default function VillageView({
|
||||||
onBuildingClick,
|
onBuildingClick,
|
||||||
|
placing,
|
||||||
|
onPlace,
|
||||||
|
onCancelPlace,
|
||||||
}: {
|
}: {
|
||||||
onBuildingClick?: (id: string) => void;
|
onBuildingClick?: (id: string) => void;
|
||||||
|
placing?: PlacingState | null;
|
||||||
|
onPlace?: (i: number, j: number) => void;
|
||||||
|
onCancelPlace?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -126,6 +141,13 @@ export default function VillageView({
|
|||||||
>([]);
|
>([]);
|
||||||
const onBuildingClickRef = useRef(onBuildingClick);
|
const onBuildingClickRef = useRef(onBuildingClick);
|
||||||
onBuildingClickRef.current = 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(() => {
|
useEffect(() => {
|
||||||
@@ -204,7 +226,7 @@ export default function VillageView({
|
|||||||
alive.add(cat.id);
|
alive.add(cat.id);
|
||||||
let anim = anims.get(cat.id);
|
let anim = anims.get(cat.id);
|
||||||
if (!anim) {
|
if (!anim) {
|
||||||
const start = randomWalkTarget();
|
const start = randomWalkTarget(state);
|
||||||
anim = { ...start, tx: start.x, ty: start.y, mode: "wander", waitUntil: 0, flip: false };
|
anim = { ...start, tx: start.x, ty: start.y, mode: "wander", waitUntil: 0, flip: false };
|
||||||
anims.set(cat.id, anim);
|
anims.set(cat.id, anim);
|
||||||
}
|
}
|
||||||
@@ -223,23 +245,23 @@ export default function VillageView({
|
|||||||
if (cat.assignedTo) {
|
if (cat.assignedTo) {
|
||||||
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
||||||
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
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) {
|
if (anim.workBuildingId !== cat.assignedTo) {
|
||||||
anim.workBuildingId = cat.assignedTo;
|
anim.workBuildingId = cat.assignedTo;
|
||||||
anim.tx = spot.x;
|
anim.tx = spot.x;
|
||||||
anim.ty = spot.y;
|
anim.ty = spot.y;
|
||||||
anim.mode = "toWork";
|
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.tx = spot.x;
|
||||||
anim.ty = spot.y;
|
anim.ty = spot.y;
|
||||||
anim.mode = "toWork";
|
if (anim.mode === "working") anim.mode = "toWork";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (anim.workBuildingId) anim.workBuildingId = null;
|
if (anim.workBuildingId) anim.workBuildingId = null;
|
||||||
if (anim.mode !== "wander") {
|
if (anim.mode !== "wander") {
|
||||||
anim.mode = "wander";
|
anim.mode = "wander";
|
||||||
const t = randomWalkTarget();
|
const t = randomWalkTarget(state);
|
||||||
anim.tx = t.x;
|
anim.tx = t.x;
|
||||||
anim.ty = t.y;
|
anim.ty = t.y;
|
||||||
}
|
}
|
||||||
@@ -272,7 +294,7 @@ export default function VillageView({
|
|||||||
anim.waitUntil = now + 800 + Math.random() * 2600;
|
anim.waitUntil = now + 800 + Math.random() * 2600;
|
||||||
} else if (now >= anim.waitUntil) {
|
} else if (now >= anim.waitUntil) {
|
||||||
anim.waitUntil = 0;
|
anim.waitUntil = 0;
|
||||||
const t = randomWalkTarget();
|
const t = randomWalkTarget(state);
|
||||||
anim.tx = t.x;
|
anim.tx = t.x;
|
||||||
anim.ty = t.y;
|
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 x = isoX(t.i, t.j);
|
||||||
const y = isoY(t.i, t.j) + TH / 2;
|
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 });
|
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) {
|
for (const b of state.buildings) {
|
||||||
const t = BUILDING_TILES[b.id];
|
if (placing?.moveId === b.id) continue; // 이동 중인 건물은 고스트로만 표시
|
||||||
if (!t) continue;
|
const t = b.pos;
|
||||||
const x = isoX(t.i, t.j);
|
const x = isoX(t.i, t.j);
|
||||||
const y = isoY(t.i, t.j) + TH / 2;
|
const y = isoY(t.i, t.j) + TH / 2;
|
||||||
const stored = Math.floor(b.stored);
|
const stored = Math.floor(b.stored);
|
||||||
@@ -579,6 +581,45 @@ export default function VillageView({
|
|||||||
sprites.sort((a, b) => a.y - b.y);
|
sprites.sort((a, b) => a.y - b.y);
|
||||||
for (const s of sprites) s.draw();
|
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;
|
const bubbles = bubblesRef.current;
|
||||||
for (let k = bubbles.length - 1; k >= 0; k--) {
|
for (let k = bubbles.length - 1; k >= 0; k--) {
|
||||||
@@ -598,7 +639,31 @@ export default function VillageView({
|
|||||||
|
|
||||||
raf = requestAnimationFrame(draw);
|
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) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
|
// 배치 모드: 타일 선택
|
||||||
|
if (placingRef.current) {
|
||||||
|
const t = tileFromEvent(e);
|
||||||
|
onPlaceRef.current?.(t.i, t.j);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / zoom;
|
const x = (e.clientX - rect.left) / zoom;
|
||||||
const y = (e.clientY - rect.top) / zoom;
|
const y = (e.clientY - rect.top) / zoom;
|
||||||
@@ -630,10 +695,14 @@ export default function VillageView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
canvas.addEventListener("click", onClick);
|
canvas.addEventListener("click", onClick);
|
||||||
|
canvas.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
canvas.removeEventListener("click", onClick);
|
canvas.removeEventListener("click", onClick);
|
||||||
|
canvas.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [size]);
|
}, [size]);
|
||||||
|
|
||||||
@@ -641,7 +710,11 @@ export default function VillageView({
|
|||||||
<div ref={wrapRef} className="absolute inset-0 overflow-hidden bg-[#82c368]">
|
<div ref={wrapRef} className="absolute inset-0 overflow-hidden bg-[#82c368]">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={{ width: "100%", height: "100%", cursor: "pointer" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
cursor: placing ? "crosshair" : "pointer",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,6 +86,33 @@ export const PASSIVES: PassiveDef[] = [
|
|||||||
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
|
{ 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
|
export const EXP_PER_LEVEL = 90; // 레벨업 필요 경험치 = 레벨 × 90
|
||||||
|
|||||||
+47
-2
@@ -3,20 +3,27 @@ import {
|
|||||||
APT_POINT_EVERY,
|
APT_POINT_EVERY,
|
||||||
APTITUDE_RATE,
|
APTITUDE_RATE,
|
||||||
BASE_RATE,
|
BASE_RATE,
|
||||||
|
BOARD_COLS,
|
||||||
|
BOARD_ROWS,
|
||||||
BREEDS,
|
BREEDS,
|
||||||
BUILD_DEFS,
|
BUILD_DEFS,
|
||||||
CAT_NAMES,
|
CAT_NAMES,
|
||||||
|
DEFAULT_BUILDING_POS,
|
||||||
EAT_THRESHOLD,
|
EAT_THRESHOLD,
|
||||||
EXP_PER_LEVEL,
|
EXP_PER_LEVEL,
|
||||||
FULLNESS_DECAY_PER_SEC,
|
FULLNESS_DECAY_PER_SEC,
|
||||||
HALL_LEVELS,
|
HALL_LEVELS,
|
||||||
|
HALL_TILE,
|
||||||
KITCHEN_UPGRADE_COST,
|
KITCHEN_UPGRADE_COST,
|
||||||
mealCap,
|
mealCap,
|
||||||
PASSIVES,
|
PASSIVES,
|
||||||
|
PLACEMENT_RULES,
|
||||||
RECIPES,
|
RECIPES,
|
||||||
|
TREE_TILES,
|
||||||
type BreedDef,
|
type BreedDef,
|
||||||
type RecipeDef,
|
type RecipeDef,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
import type { BuildingType } from "./types";
|
||||||
|
|
||||||
const SAVE_KEY = "cat-village-save";
|
const SAVE_KEY = "cat-village-save";
|
||||||
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
||||||
@@ -91,11 +98,13 @@ function createBuildings(): Building[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
||||||
|
pos: { ...DEFAULT_BUILDING_POS.dock_1 },
|
||||||
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
||||||
assigned: [], stored: 0,
|
assigned: [], stored: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
||||||
|
pos: { ...DEFAULT_BUILDING_POS.lumberyard_1 },
|
||||||
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
||||||
assigned: [], stored: 0,
|
assigned: [], stored: 0,
|
||||||
},
|
},
|
||||||
@@ -150,6 +159,26 @@ function passiveFoodMul(cat: Cat): number {
|
|||||||
return mul;
|
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 {
|
export function activeRecipe(s: GameState): RecipeDef {
|
||||||
const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1;
|
const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1;
|
||||||
@@ -187,6 +216,8 @@ export const engine = {
|
|||||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||||
state.resources.stone ??= 0;
|
state.resources.stone ??= 0;
|
||||||
for (const cat of state.cats) cat.aptitudePoints ??= 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;
|
return state;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -266,8 +297,8 @@ export const engine = {
|
|||||||
return null;
|
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;
|
if (!state) return null;
|
||||||
const def = BUILD_DEFS.find((d) => d.type === type);
|
const def = BUILD_DEFS.find((d) => d.type === type);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
@@ -275,6 +306,8 @@ export const engine = {
|
|||||||
return `${def.name}은(는) 이미 지어져 있습니다`;
|
return `${def.name}은(는) 이미 지어져 있습니다`;
|
||||||
if (state.hall.level < def.reqHallLv)
|
if (state.hall.level < def.reqHallLv)
|
||||||
return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`;
|
return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`;
|
||||||
|
if (!canPlaceAt(state, def.type, pos.i, pos.j))
|
||||||
|
return "그곳에는 지을 수 없습니다";
|
||||||
const missing = Object.entries(def.cost).filter(
|
const missing = Object.entries(def.cost).filter(
|
||||||
([key, amount]) =>
|
([key, amount]) =>
|
||||||
state!.resources[key as keyof GameState["resources"]] < (amount ?? 0),
|
state!.resources[key as keyof GameState["resources"]] < (amount ?? 0),
|
||||||
@@ -286,12 +319,24 @@ export const engine = {
|
|||||||
consume(state, def.cost);
|
consume(state, def.cost);
|
||||||
state.buildings.push({
|
state.buildings.push({
|
||||||
id: def.id, type: def.type, name: def.name, emoji: def.emoji,
|
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,
|
level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
|
||||||
resource: def.resource, assigned: [], stored: 0, progress: 0,
|
resource: def.resource, assigned: [], stored: 0, progress: 0,
|
||||||
});
|
});
|
||||||
return null;
|
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: 훈제 생선 해금 조건 + 선반 확장 */
|
/** 부엌 증축 Lv1→2: 훈제 생선 해금 조건 + 선반 확장 */
|
||||||
upgradeKitchen(): string | null {
|
upgradeKitchen(): string | null {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface Building {
|
|||||||
type: BuildingType;
|
type: BuildingType;
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
pos: { i: number; j: number }; // 보드 타일 좌표 (자유 배치)
|
||||||
level: number;
|
level: number;
|
||||||
maxSlots: number;
|
maxSlots: number;
|
||||||
aptitude: AptitudeKey; // 필요 적성
|
aptitude: AptitudeKey; // 필요 적성
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단