diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 7502813..4c58a5d 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -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 = { 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(null); const [candidates, setCandidates] = useState(null); + const [placing, setPlacing] = useState(null); const [message, setMessage] = useState(null); const msgTimer = useRef | 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() { { 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 && ( +
+ 📍 {placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요 + {placing.type === "dock" && " (부두는 물가에만)"} + +
+ )} + {/* ── 상단 HUD ── */}
@@ -110,6 +132,14 @@ export default function Game() {
+ +
+

+ 구매 후 마을의 원하는 타일을 클릭해 배치합니다. 비용은 배치 확정 시 차감돼요. +

+ {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 ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {def.name} +
+
{def.name}
+
+ {costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")} + {def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`} +
+
{def.desc}
+
+ {built ? ( + + 보유 중 + + ) : ( + + )} +
+ ); + })}
); } @@ -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({
{/* 정보 */}
-
- 필요 적성: {APTITUDE_LABEL[building.aptitude]} · 슬롯{" "} - {assignedCats.length}/{building.maxSlots} +
+ + 필요 적성: {APTITUDE_LABEL[building.aptitude]} · 슬롯{" "} + {assignedCats.length}/{building.maxSlots} + +
{isKitchen ? ( <> diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx index 47511ff..6dfcfcb 100644 --- a/src/components/game/VillageView.tsx +++ b/src/components/game/VillageView.tsx @@ -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 = { 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 = { - 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(null); const wrapRef = useRef(null); @@ -126,6 +141,13 @@ export default function VillageView({ >([]); const onBuildingClickRef = useRef(onBuildingClick); onBuildingClickRef.current = onBuildingClick; + const placingRef = useRef(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({
); diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts index 0c94e03..0cc2f13 100644 --- a/src/lib/game/data.ts +++ b/src/lib/game/data.ts @@ -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> = { + dock: "nearWater", +}; + +// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치 +export const DEFAULT_BUILDING_POS: Record = { + 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 diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index 6ab2b30..cd4e5f3 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -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 = { 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; diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index 18af9a4..24bf62d 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -39,6 +39,7 @@ export interface Building { type: BuildingType; name: string; emoji: string; + pos: { i: number; j: number }; // 보드 타일 좌표 (자유 배치) level: number; maxSlots: number; aptitude: AptitudeKey; // 필요 적성