꾸미기 시스템: 바닥 장식 타일 9종 (deco.png)

- deco 시트 9분할 (흙마당·바위 터·돌바닥·나무 데크·자갈길·그루터기 쉼터·
  꽃잔디·돌광장·나무길), 원본은 assets-src 보관
- 상점에 🌿 꾸미기 섹션 (3열 그리드, 여러 개 배치 가능)
- 구매 → 배치 모드: 연속 배치 지원 (클릭마다 비용 차감, ESC로 종료),
  같은 타일 재배치 시 교체, 건물·나무·물 타일 차단
- 정사각 탑뷰 아트를 아핀 변환으로 아이소 다이아몬드에 매핑해 바닥에 밀착
- 배치된 장식 클릭 → 패널에서 🗑 치우기 (건물 히트가 우선)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 17:44:47 +09:00
co-authored by Claude Fable 5
부모 1ff8d0b8ca
커밋 a90d9b217f
15개의 변경된 파일과 214개의 추가작업 그리고 10개의 파일을 삭제
바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 1.4 MiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 112 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 78 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 70 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 89 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 72 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 91 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 78 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 79 KiB

바이너리 파일은 표시되지 않음.

이후

너비:  |  높이:  |  크기: 82 KiB

+67 -1
파일 보기
@@ -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() {
<VillageView
onBuildingClick={(id) => {
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 && (
<div className="wood-plank font-jua absolute bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 px-5 py-2.5 text-sm">
📍 {placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요
📍 {placing.deco
? "꾸밀 타일을 클릭하세요 (연속 배치 가능)"
: `${placing.moveId ? "옮길" : "배치할"} 위치를 클릭하세요`}
{placing.type === "dock" && " (부두는 물가에만)"}
<button
onClick={() => setPlacing(null)}
@@ -209,6 +218,10 @@ export default function Game() {
setPanel(null);
setPlacing({ type: def.type, aptitude: def.aptitude });
}}
onBuyDeco={(key) => {
setPanel(null);
setPlacing({ deco: key });
}}
/>
</SidePanel>
)}
@@ -240,6 +253,29 @@ export default function Game() {
/>
</SidePanel>
)}
{panel?.type === "deco" && (() => {
const deco = state.decors?.find((d) => d.id === panel.id);
const def = deco ? DECO_DEFS.find((x) => x.key === deco.kind) : undefined;
if (!deco || !def) return null;
return (
<SidePanel title={`🌿 ${def.name}`} onClose={() => setPanel(null)}>
<div className="space-y-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={def.img} alt={def.name} className="mx-auto h-24 object-contain" />
<p className="text-center text-sm text-[#6f5c39]">마을을 꾸미는 바닥 장식이에요.</p>
<button
onClick={() => {
run(() => engine.removeDeco(deco.id));
setPanel(null);
}}
className="wood-plank w-full py-2 text-sm hover:brightness-110"
>
🗑 치우기
</button>
</div>
</SidePanel>
);
})()}
{panel?.type === "cats" && (
<SidePanel title={`🐾 우리 고양이들 (${state.cats.length}/${state.hall.capacity})`} onClose={() => setPanel(null)}>
<div className="space-y-3">
@@ -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 (
<div className="space-y-3">
<p className="text-xs text-[#8a7551]">
구매 후 마을의 원하는 타일을 클릭해 배치합니다. 비용은 배치 확정 시 차감돼요.
</p>
<h3 className="font-jua text-sm text-[#8a7551]">🏗️ 건물</h3>
{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({
</div>
);
})}
<h3 className="font-jua mt-4 text-sm text-[#8a7551]">🌿 꾸미기 (여러 개 배치 가능)</h3>
<div className="grid grid-cols-3 gap-2">
{DECO_DEFS.map((def) => {
const affordable = Object.entries(def.cost).every(
([key, amount]) =>
state.resources[key as keyof GameState["resources"]] >= (amount ?? 0),
);
return (
<button
key={def.key}
onClick={() => onBuyDeco(def.key)}
disabled={!affordable}
className="card-nature flex flex-col items-center gap-1 p-2 hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-40"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={def.img} alt={def.name} className="h-12 w-12 object-contain" />
<span className="font-jua text-[12px] text-[#3f2c14]">{def.name}</span>
<span className="font-num text-[10px] text-[#8a6316]">
{Object.entries(def.cost)
.map(([k, v]) => `${RES_EMOJI[k]} ${v}`)
.join(" ")}
</span>
</button>
);
})}
</div>
</div>
);
}
+66 -9
파일 보기
@@ -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<ResourceKey, string> = { 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);
+21
파일 보기
@@ -185,6 +185,27 @@ export const BUILD_DEFS: BuildDef[] = [
},
];
// ---------- 꾸미기 (바닥 장식) ----------
export interface DecoDef {
key: string;
name: string;
cost: Partial<Record<"fish" | "wood" | "stone", number>>;
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 {
+53
파일 보기
@@ -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;
+7
파일 보기
@@ -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<ResourceKey, number>;
cats: Cat[];
buildings: Building[];