꾸미기 시스템: 바닥 장식 타일 9종 (deco.png)
- deco 시트 9분할 (흙마당·바위 터·돌바닥·나무 데크·자갈길·그루터기 쉼터· 꽃잔디·돌광장·나무길), 원본은 assets-src 보관 - 상점에 🌿 꾸미기 섹션 (3열 그리드, 여러 개 배치 가능) - 구매 → 배치 모드: 연속 배치 지원 (클릭마다 비용 차감, ESC로 종료), 같은 타일 재배치 시 교체, 건물·나무·물 타일 차단 - 정사각 탑뷰 아트를 아핀 변환으로 아이소 다이아몬드에 매핑해 바닥에 밀착 - 배치된 장식 클릭 → 패널에서 🗑 치우기 (건물 히트가 우선) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
이후 너비: | 높이: | 크기: 1.4 MiB |
|
이후 너비: | 높이: | 크기: 112 KiB |
|
이후 너비: | 높이: | 크기: 78 KiB |
|
이후 너비: | 높이: | 크기: 70 KiB |
|
이후 너비: | 높이: | 크기: 89 KiB |
|
이후 너비: | 높이: | 크기: 72 KiB |
|
이후 너비: | 높이: | 크기: 91 KiB |
|
이후 너비: | 높이: | 크기: 78 KiB |
|
이후 너비: | 높이: | 크기: 79 KiB |
|
이후 너비: | 높이: | 크기: 82 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||