diff --git a/assets-src/deco_sheet.png b/assets-src/deco_sheet.png new file mode 100644 index 0000000..9287ee7 Binary files /dev/null and b/assets-src/deco_sheet.png differ diff --git a/public/assets/deco/boardwalk.png b/public/assets/deco/boardwalk.png new file mode 100644 index 0000000..177f37f Binary files /dev/null and b/public/assets/deco/boardwalk.png differ diff --git a/public/assets/deco/boulders.png b/public/assets/deco/boulders.png new file mode 100644 index 0000000..b4a87bd Binary files /dev/null and b/public/assets/deco/boulders.png differ diff --git a/public/assets/deco/deck.png b/public/assets/deco/deck.png new file mode 100644 index 0000000..31d5065 Binary files /dev/null and b/public/assets/deco/deck.png differ diff --git a/public/assets/deco/dirt.png b/public/assets/deco/dirt.png new file mode 100644 index 0000000..ac52547 Binary files /dev/null and b/public/assets/deco/dirt.png differ diff --git a/public/assets/deco/flower_grass.png b/public/assets/deco/flower_grass.png new file mode 100644 index 0000000..9386bd1 Binary files /dev/null and b/public/assets/deco/flower_grass.png differ diff --git a/public/assets/deco/pavement.png b/public/assets/deco/pavement.png new file mode 100644 index 0000000..3848307 Binary files /dev/null and b/public/assets/deco/pavement.png differ diff --git a/public/assets/deco/pebbles.png b/public/assets/deco/pebbles.png new file mode 100644 index 0000000..3c8dde7 Binary files /dev/null and b/public/assets/deco/pebbles.png differ diff --git a/public/assets/deco/plaza.png b/public/assets/deco/plaza.png new file mode 100644 index 0000000..a8371b9 Binary files /dev/null and b/public/assets/deco/plaza.png differ diff --git a/public/assets/deco/stump.png b/public/assets/deco/stump.png new file mode 100644 index 0000000..ebd2dc8 Binary files /dev/null and b/public/assets/deco/stump.png differ diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 9487075..5b5842a 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -17,6 +17,7 @@ import { BUILD_DEFS, BUILDING_IMG, catSprite, + DECO_DEFS, HALL_LEVELS, KITCHEN_UPGRADE_COST, mealCap, @@ -35,6 +36,7 @@ type Panel = | { type: "hall" } | { type: "shop" } | { type: "cats" } + | { type: "deco"; id: string } | null; export default function Game() { @@ -92,11 +94,16 @@ export default function Game() { { if (id === "hall") setPanel({ type: "hall" }); + else if (id.startsWith("deco:")) setPanel({ type: "deco", id: id.slice(5) }); else setPanel({ type: "building", id }); }} placing={placing} onPlace={(i, j) => { if (!placing) return; + if (placing.deco) { + run(() => engine.placeDeco(placing.deco!, { i, j })); + return; // 연속 배치 (ESC/취소로 종료) + } const error = placing.moveId ? run(() => engine.moveBuilding(placing.moveId!, { i, j })) : run(() => engine.build(placing.type as BuildDef["type"], { i, j })); @@ -108,7 +115,9 @@ export default function Game() { {/* ── 배치 모드 힌트 ── */} {placing && (
- 📍 {placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요 + 📍 {placing.deco + ? "꾸밀 타일을 클릭하세요 (연속 배치 가능)" + : `${placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요`} {placing.type === "dock" && " (부두는 물가에만)"} +
+ + ); + })()} {panel?.type === "cats" && ( setPanel(null)}>
@@ -395,15 +431,18 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo function ShopPanel({ state, onBuy, + onBuyDeco, }: { state: GameState; onBuy: (def: BuildDef) => void; + onBuyDeco: (key: string) => void; }) { return (

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

+

🏗️ 건물

{BUILD_DEFS.map((def) => { const built = state.buildings.some((b) => b.type === def.type); const hallOk = state.hall.level >= def.reqHallLv; @@ -455,6 +494,33 @@ function ShopPanel({
); })} + +

🌿 꾸미기 (여러 개 배치 가능)

+
+ {DECO_DEFS.map((def) => { + const affordable = Object.entries(def.cost).every( + ([key, amount]) => + state.resources[key as keyof GameState["resources"]] >= (amount ?? 0), + ); + return ( + + ); + })} +
); } diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx index fd73ca0..27d9acb 100644 --- a/src/components/game/VillageView.tsx +++ b/src/components/game/VillageView.tsx @@ -1,12 +1,13 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { engine, catRate, canPlaceAt, isUnderConstruction } from "@/lib/game/engine"; +import { engine, catRate, canPlaceAt, canPlaceDecoAt, isUnderConstruction } from "@/lib/game/engine"; import { BOARD_COLS, BOARD_ROWS, BUILDING_IMG, catSprite, + DECO_DEFS, footprintOf, HALL_TILE, TOOL_IMG, @@ -24,9 +25,10 @@ import type { const RES_EMOJI: Record = { fish: "🐟", wood: "🪵", stone: "🪨" }; export interface PlacingState { - type: BuildingType; - aptitude: AptitudeKey; // 고스트 이미지용 + type?: BuildingType; // 건물 배치·이동 + aptitude?: AptitudeKey; // (미사용 폴백용) moveId?: string; // 있으면 기존 건물 이동 + deco?: string; // 데코 배치 (DECO_DEFS 키) } // ---------- 아이소메트릭 그리드 ---------- @@ -225,6 +227,29 @@ export default function VillageView({ let raf = 0; let last = performance.now(); + // 정사각 탑뷰 타일을 아이소 다이아몬드로 아핀 매핑해 그리기 + const drawDecoTile = ( + img: HTMLImageElement, + i: number, + j: number, + alpha = 1, + ) => { + const x0 = isoX(i, j); + const y0 = isoY(i, j); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.transform( + TW / 2 / img.width, + TH / 2 / img.width, + -(TW / 2) / img.height, + TH / 2 / img.height, + x0, + y0, + ); + ctx.drawImage(img, 0, 0); + ctx.restore(); + }; + // next/font가 생성한 실제 폰트 패밀리명 (캔버스 텍스트용) const juaFamily = getComputedStyle(document.documentElement) @@ -506,6 +531,14 @@ export default function VillageView({ ctx.restore(); } + // 바닥 꾸미기 타일 + for (const deco of state.decors ?? []) { + const def = DECO_DEFS.find((d) => d.key === deco.kind); + if (!def) continue; + const img = getImage(def.img); + if (img) drawDecoTile(img, deco.pos.i, deco.pos.j); + } + // 그릴 것들을 y 순으로 정렬해 겹침 처리 type Sprite = { y: number; draw: () => void }; const sprites: Sprite[] = []; @@ -724,9 +757,13 @@ export default function VillageView({ // --- 배치 모드 오버레이 (고스트 + 유효/무효 하이라이트) --- if (placing) { // 배치 가능한 앵커 타일 은은하게 표시 + const anchorOk = (i: number, j: number) => + placing.deco + ? canPlaceDecoAt(state, i, j) + : canPlaceAt(state, placing.type!, i, j, placing.moveId); for (let j = 0; j < ROWS; j++) { for (let i = 0; i < COLS; i++) { - if (!canPlaceAt(state, placing.type, i, j, placing.moveId)) continue; + if (!anchorOk(i, j)) continue; ctx.beginPath(); tilePath(i, j); ctx.fillStyle = "rgba(255,255,255,0.16)"; @@ -734,9 +771,18 @@ export default function VillageView({ } } const hover = hoverTileRef.current; - if (hover) { - const fp = footprintOf(placing.type); - const valid = canPlaceAt(state, placing.type, hover.i, hover.j, placing.moveId); + if (hover && placing.deco) { + const valid = canPlaceDecoAt(state, hover.i, hover.j); + ctx.beginPath(); + tilePath(hover.i, hover.j); + ctx.fillStyle = valid ? "rgba(52,211,153,0.45)" : "rgba(248,113,113,0.45)"; + ctx.fill(); + const dDef = DECO_DEFS.find((d) => d.key === placing.deco); + const dImg = dDef ? getImage(dDef.img) : undefined; + if (dImg) drawDecoTile(dImg, hover.i, hover.j, 0.75); + } else if (hover) { + const fp = footprintOf(placing.type!); + const valid = canPlaceAt(state, placing.type!, hover.i, hover.j, placing.moveId); // 풋프린트 전체 하이라이트 ctx.beginPath(); for (let di = 0; di < fp.w; di++) @@ -752,8 +798,8 @@ export default function VillageView({ const cj = hover.j + fp.h / 2 - 0.5; const gx = isoX(ci, cj); const gBase = isoY(ci, cj) + TH / 2 + (fp.h * TH) / 2; - const gImg = BUILDING_IMG[placing.type] - ? getImage(BUILDING_IMG[placing.type]) + const gImg = BUILDING_IMG[placing.type!] + ? getImage(BUILDING_IMG[placing.type!]) : undefined; ctx.globalAlpha = 0.75; if (gImg) { @@ -840,6 +886,17 @@ export default function VillageView({ return; } } + // 3) 바닥 꾸미기 → 제거 패널 + { + const s2 = engine.peek(); + if (s2?.decors) { + const t = tileFromEvent(e); + const deco = s2.decors.find( + (d) => d.pos.i === t.i && d.pos.j === t.j, + ); + if (deco) onBuildingClickRef.current?.(`deco:${deco.id}`); + } + } }; canvas.addEventListener("click", onClick); canvas.addEventListener("mousemove", onMove); diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts index ec7888e..bcaf457 100644 --- a/src/lib/game/data.ts +++ b/src/lib/game/data.ts @@ -185,6 +185,27 @@ export const BUILD_DEFS: BuildDef[] = [ }, ]; +// ---------- 꾸미기 (바닥 장식) ---------- + +export interface DecoDef { + key: string; + name: string; + cost: Partial>; + img: string; +} + +export const DECO_DEFS: DecoDef[] = [ + { key: "dirt", name: "흙마당", cost: { wood: 10 }, img: "/assets/deco/dirt.png" }, + { key: "boulders", name: "바위 터", cost: { stone: 15 }, img: "/assets/deco/boulders.png" }, + { key: "pavement", name: "돌바닥", cost: { stone: 25 }, img: "/assets/deco/pavement.png" }, + { key: "deck", name: "나무 데크", cost: { wood: 30 }, img: "/assets/deco/deck.png" }, + { key: "pebbles", name: "자갈길", cost: { stone: 12 }, img: "/assets/deco/pebbles.png" }, + { key: "stump", name: "그루터기 쉼터", cost: { wood: 20 }, img: "/assets/deco/stump.png" }, + { key: "flower_grass", name: "꽃잔디", cost: { wood: 15 }, img: "/assets/deco/flower_grass.png" }, + { key: "plaza", name: "돌광장", cost: { stone: 30 }, img: "/assets/deco/plaza.png" }, + { key: "boardwalk", name: "나무길", cost: { wood: 25 }, img: "/assets/deco/boardwalk.png" }, +]; + // ---------- 요리 (부엌) ---------- export interface RecipeDef { diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index 3d27626..cb75179 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -16,6 +16,7 @@ import { BREEDS, BUILD_DEFS, CAT_NAMES, + DECO_DEFS, DEFAULT_BUILDING_POS, EAT_THRESHOLD, EXP_PER_LEVEL, @@ -227,6 +228,26 @@ export function canPlaceAt( return true; } +/** 데코를 놓을 수 있는 타일인가 (잔디 위, 건물·나무·물 제외) */ +export function canPlaceDecoAt(s: GameState, i: number, j: number): boolean { + if (!Number.isInteger(i) || !Number.isInteger(j)) return false; + if (i < 0 || j < 0 || i >= BOARD_COLS || j >= BOARD_ROWS) return false; + if (isWaterTile(i)) return false; + if (TREE_TILES.some((t) => t.i === i && t.j === j)) return false; + const hallFp = footprintOf("hall"); + if ( + i >= HALL_TILE.i && i < HALL_TILE.i + hallFp.w && + j >= HALL_TILE.j && j < HALL_TILE.j + hallFp.h + ) + return false; + for (const b of s.buildings) { + const fp = footprintOf(b.type); + if (i >= b.pos.i && i < b.pos.i + fp.w && j >= b.pos.j && j < b.pos.j + fp.h) + return false; + } + return true; +} + /** 현재 해금된 레시피 중 가장 마지막 것 (부엌이 요리할 메뉴) */ export function activeRecipe(s: GameState): RecipeDef { const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1; @@ -269,6 +290,7 @@ export const engine = { } for (const b of state.buildings) b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) }; + state.decors ??= []; // 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치 if (state.boardVersion !== BOARD_VERSION) { for (const b of state.buildings) @@ -525,6 +547,37 @@ export const engine = { return null; }, + /** 꾸미기 배치 (같은 타일의 기존 장식은 교체) */ + placeDeco(kind: string, pos: { i: number; j: number }): string | null { + if (!state) return null; + const def = DECO_DEFS.find((d) => d.key === kind); + if (!def) return null; + if (!canPlaceDecoAt(state, pos.i, pos.j)) return "그곳에는 놓을 수 없습니다"; + const missing = Object.entries(def.cost).filter( + ([key, amount]) => + state!.resources[key as keyof GameState["resources"]] < (amount ?? 0), + ); + if (missing.length > 0) + return `자원이 부족합니다 (${missing + .map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`) + .join(", ")})`; + consume(state, def.cost); + state.decors ??= []; + // 같은 타일의 기존 장식 제거 후 배치 + state.decors = state.decors.filter( + (d) => !(d.pos.i === pos.i && d.pos.j === pos.j), + ); + state.decors.push({ id: nextId("deco"), kind, pos: { ...pos } }); + return null; + }, + + /** 꾸미기 제거 (환불 없음) */ + removeDeco(id: string): string | null { + if (!state?.decors) return null; + state.decors = state.decors.filter((d) => d.id !== id); + 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 d9d6c01..19fa0dd 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -59,9 +59,16 @@ export interface Building { constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태) } +export interface DecoTile { + id: string; + kind: string; // DECO_DEFS 키 + pos: { i: number; j: number }; +} + export interface GameState { version: 1; boardVersion?: number; // 보드 개편 마이그레이션 마커 + decors?: DecoTile[]; // 꾸미기 바닥 타일 resources: Record; cats: Cat[]; buildings: Building[];