diff --git a/public/assets/buildings/dock.png b/public/assets/buildings/dock.png index e657069..bed272b 100644 Binary files a/public/assets/buildings/dock.png and b/public/assets/buildings/dock.png differ diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx index 5d15663..43ce923 100644 --- a/src/components/game/VillageView.tsx +++ b/src/components/game/VillageView.tsx @@ -7,6 +7,7 @@ import { BOARD_ROWS, BUILDING_IMG, catSprite, + footprintOf, HALL_TILE, TOOL_IMG, TREE_TILES, @@ -44,35 +45,40 @@ let originY = 56; const isoX = (i: number, j: number) => originX + ((i - j) * TW) / 2; const isoY = (i: number, j: number) => originY + ((i + j) * TH) / 2; -// 물 (동쪽 끝 열), 부두는 물가에 붙임 -const isWater = (i: number, j: number) => i >= COLS - 1; +// 물 (동쪽 끝 2열) — 부두는 물 위에 배치 +const isWater = (i: number, j: number) => i >= COLS - 2; 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: "🍄" }, - { i: 1, j: 3, emoji: "🪨" }, + { i: 9, j: 1, emoji: "🌼" }, { i: 4, j: 2, emoji: "🌷" }, + { i: 10, j: 11, emoji: "🌾" }, { i: 6, j: 12, emoji: "🍄" }, + { i: 1, j: 5, emoji: "🪨" }, { i: 11, j: 8, emoji: "🌼" }, + { i: 5, j: 8, emoji: "🌷" }, { i: 2, j: 13, emoji: "🌾" }, ]; // 보드 밖 배경 장식 (마을을 둘러싼 숲·들판 느낌) const OUTER_TREES: Array<{ i: number; j: number }> = [ - { i: -2, j: 2 }, { i: -3, j: 5 }, { i: -2, j: 8 }, { i: -1.5, j: 11 }, - { i: 1, j: 12.5 }, { i: 3.5, j: 12 }, { i: 6, j: 12.5 }, { i: 9, j: 12 }, - { i: 12, j: 11.5 }, { i: 1, j: -2.5 }, { i: 3.5, j: -2 }, { i: 6.5, j: -2.5 }, - { i: 9, j: -2 }, { i: 12, j: -1.5 }, { i: -1, j: -1 }, { i: 13, j: 2.5 }, + { i: -2, j: 3 }, { i: -3, j: 7 }, { i: -2, j: 11 }, { i: -1.5, j: 14.5 }, + { i: 1, j: 16.5 }, { i: 4, j: 16 }, { i: 7.5, j: 16.5 }, { i: 11, j: 16 }, + { i: 14, j: 15.5 }, { i: 1, j: -2.5 }, { i: 4.5, j: -2 }, { i: 8, j: -2.5 }, + { i: 11.5, j: -2 }, { i: 15, j: -1.5 }, { i: -1, j: -1 }, { i: 16.5, j: 2.5 }, ]; const OUTER_DECOR: Array<{ i: number; j: number; emoji: string }> = [ - { i: -1.5, j: 3.5, emoji: "🌼" }, { i: -2, j: 6.5, emoji: "🌷" }, - { i: 2, j: 11.8, emoji: "🌾" }, { i: 7.5, j: 12.2, emoji: "🌼" }, - { i: 5, j: -1.8, emoji: "🪨" }, { i: 10.5, j: -1.6, emoji: "🌾" }, - { i: -0.5, j: 9.5, emoji: "🍄" }, { i: 11, j: 12.3, emoji: "🌷" }, + { i: -1.5, j: 4.5, emoji: "🌼" }, { i: -2, j: 9, emoji: "🌷" }, + { i: 2.5, j: 15.8, emoji: "🌾" }, { i: 9.5, j: 16.2, emoji: "🌼" }, + { i: 6, j: -1.8, emoji: "🪨" }, { i: 13, j: -1.6, emoji: "🌾" }, + { i: -0.5, j: 12.5, emoji: "🍄" }, { i: 13.5, j: 16.3, emoji: "🌷" }, ]; +const inFootprint = ( + pos: { i: number; j: number }, fp: { w: number; h: number }, + i: number, j: number, +) => i >= pos.i && i < pos.i + fp.w && j >= pos.j && j < pos.j + fp.h; 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); + inFootprint(HALL_TILE, footprintOf("hall"), i, j) || + s.buildings.some((b) => inFootprint(b.pos, footprintOf(b.type), i, j)); const isWalkable = (s: GameState, i: number, j: number) => - i >= 0 && i < COLS - 1 && j >= 0 && j < ROWS && + i >= 0 && i < COLS - 2 && j >= 0 && j < ROWS && !isBuildingTile(s, i, j) && !TREES.some((t) => t.i === i && t.j === j); @@ -106,13 +112,21 @@ function randomWalkTarget(s: GameState): { x: number; y: number } { return { x: isoX(5, 5), y: isoY(5, 5) + TH / 2 }; } +/** 풋프린트 중심의 타일 좌표 (소수 허용) */ +function footprintCenter(pos: { i: number; j: number }, type: string) { + const fp = footprintOf(type); + return { ci: pos.i + fp.w / 2 - 0.5, cj: pos.j + fp.h / 2 - 0.5, fp }; +} + function workSpot(building: Building | undefined, slotIndex: number) { - const tile = building?.pos ?? HALL_TILE; + const pos = building?.pos ?? HALL_TILE; + const { ci, cj, fp } = footprintCenter(pos, building?.type ?? "hall"); + const radius = 26 + fp.w * 16; // 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치 const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6; return { - x: isoX(tile.i, tile.j) + Math.cos(angle) * 42, - y: isoY(tile.i, tile.j) + TH / 2 + Math.sin(angle) * 16 + 8, + x: isoX(ci, cj) + Math.cos(angle) * radius, + y: isoY(ci, cj) + TH / 2 + Math.sin(angle) * (radius * 0.4) + 8, }; } @@ -400,10 +414,10 @@ export default function VillageView({ ctx.save(); ctx.beginPath(); { - const a = vertex(COLS - 1, -J_EXT); + const a = vertex(COLS - 2, -J_EXT); const b = vertex(COLS, -J_EXT); const c = vertex(COLS, ROWS + J_EXT); - const d = vertex(COLS - 1, ROWS + J_EXT); + const d = vertex(COLS - 2, ROWS + J_EXT); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); @@ -416,14 +430,14 @@ export default function VillageView({ if (water) { // 강 줄기 방향(상류→하류, 좌하향)으로 한 방향 흐름: // 컨텍스트를 강 방향으로 회전시킨 뒤 텍스처를 미러 타일링하며 스크롤 - const sx = isoX(COLS - 1, -J_EXT); - const sy = isoY(COLS - 1, -J_EXT) + TH / 2; - const ex = isoX(COLS - 1, ROWS + J_EXT); - const ey = isoY(COLS - 1, ROWS + J_EXT) + TH / 2; + const sx = isoX(COLS - 1.5, -J_EXT); + const sy = isoY(COLS - 1.5, -J_EXT) + TH / 2; + const ex = isoX(COLS - 1.5, ROWS + J_EXT); + const ey = isoY(COLS - 1.5, ROWS + J_EXT) + TH / 2; const angle = Math.atan2(ey - sy, ex - sx); const riverLen = Math.hypot(ex - sx, ey - sy); - const h = 220; // 강 폭 방향 커버 (클리핑되므로 넉넉히) + const h = 300; // 강 폭 방향 커버 (2열, 클리핑되므로 넉넉히) const w = (water.width / water.height) * h; const FLOW_SPEED = 22; // px/초 const offset = ((now / 1000) * FLOW_SPEED) % (2 * w); @@ -446,13 +460,15 @@ export default function VillageView({ ctx.fillRect(0, 0, worldW, worldH); } - // 은은한 반짝임 오버레이 (연장 구간 포함) + // 은은한 반짝임 오버레이 (연장 구간·2열) for (let j = -J_EXT; j < ROWS + J_EXT; j++) { - const glint = 0.05 + 0.05 * Math.sin(now / 700 + j); - ctx.beginPath(); - tilePath(COLS - 1, j); - ctx.fillStyle = `rgba(255,255,255,${glint})`; - ctx.fill(); + for (const col of [COLS - 2, COLS - 1]) { + const glint = 0.05 + 0.05 * Math.sin(now / 700 + j + col); + ctx.beginPath(); + tilePath(col, j); + ctx.fillStyle = `rgba(255,255,255,${glint})`; + ctx.fill(); + } } ctx.restore(); } @@ -504,59 +520,67 @@ export default function VillageView({ hitRectsRef.current = []; - // 회관 + // 회관 (3x3 풋프린트) { - 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 - 54, y: y - 70, w: 108, h: 100 }); + const { ci, cj, fp } = footprintCenter(HALL_TILE, "hall"); + const x = isoX(ci, cj); + const y = isoY(ci, cj) + TH / 2; + const imgW = TW * fp.w * 0.94; + const baseY = y + (fp.h * TH) / 2; + hitRectsRef.current.push({ + id: "hall", kind: "open", + x: x - imgW / 2, y: y - imgW * 0.62, w: imgW, h: imgW * 0.62 + (fp.h * TH) / 2, + }); sprites.push({ y, draw: () => { const hallImg = getImage(BUILDING_IMG.hall); if (hallImg) { - const w = 118; - const hh = (hallImg.height / hallImg.width) * w; - ctx.drawImage(hallImg, x - w / 2, y + 16 - hh, w, hh); + const hh = (hallImg.height / hallImg.width) * imgW; + ctx.drawImage(hallImg, x - imgW / 2, baseY + 4 - hh, imgW, hh); } else { shadow(x, y + 6, 26); drawEmoji("🏛️", x, y + 8, 48); } - ctx.font = `13px ${juaFamily}`; + ctx.font = `14px ${juaFamily}`; ctx.textAlign = "center"; ctx.strokeStyle = "rgba(255,252,238,0.75)"; ctx.lineWidth = 3; - ctx.strokeText(`마을 회관 Lv${state.hall.level}`, x, y + 24); + ctx.strokeText(`마을 회관 Lv${state.hall.level}`, x, baseY + 18); ctx.lineWidth = 1; ctx.fillStyle = "#4a3418"; - ctx.fillText(`마을 회관 Lv${state.hall.level}`, x, y + 24); + ctx.fillText(`마을 회관 Lv${state.hall.level}`, x, baseY + 18); }, }); } - // 생산 건물 + 회수 배지 + // 생산 건물 + 회수 배지 (풋프린트 기반) const placing = placingRef.current; for (const b of state.buildings) { 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 { ci, cj, fp } = footprintCenter(b.pos, b.type); + const x = isoX(ci, cj); + const y = isoY(ci, cj) + TH / 2; + const imgW = TW * fp.w * 0.94; + const baseY = y + (fp.h * TH) / 2; const stored = Math.floor(b.stored); const isKitchen = b.type === "kitchen"; // 건물 본체 → 정보 패널 - hitRectsRef.current.push({ id: b.id, kind: "open", x: x - 44, y: y - 52, w: 88, h: 80 }); + hitRectsRef.current.push({ + id: b.id, kind: "open", + x: x - imgW / 2, y: y - imgW * 0.6, w: imgW, h: imgW * 0.6 + (fp.h * TH) / 2, + }); // 배지 → 빠른 회수 (생산 건물만) if (!isKitchen && stored > 0) - hitRectsRef.current.push({ id: b.id, kind: "collect", x: x - 40, y: y - 62, w: 80, h: 26 }); + hitRectsRef.current.push({ id: b.id, kind: "collect", x: x - 46, y: y - imgW * 0.72, w: 92, h: 26 }); sprites.push({ y, draw: () => { const bImg = getImage(BUILDING_IMG[b.type]); if (bImg) { // 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요) - const w = 96; - const h = (bImg.height / bImg.width) * w; - ctx.drawImage(bImg, x - w / 2, y + 14 - h, w, h); + const h = (bImg.height / bImg.width) * imgW; + ctx.drawImage(bImg, x - imgW / 2, baseY + 4 - h, imgW, h); } else { shadow(x, y + 6, 24); const img = toolImagesRef.current.get(b.aptitude); @@ -572,13 +596,13 @@ export default function VillageView({ ctx.textAlign = "center"; ctx.strokeStyle = "rgba(255,252,238,0.75)"; ctx.lineWidth = 3; - ctx.strokeText(b.name, x, y + 24); + ctx.strokeText(b.name, x, baseY + 17); ctx.lineWidth = 1; ctx.fillStyle = "#4a3418"; - ctx.fillText(b.name, x, y + 24); + ctx.fillText(b.name, x, baseY + 17); if (stored > 0) { const label = `${isKitchen ? "🍖" : RES_EMOJI[b.resource ?? "fish"]} ${stored}`; - drawPlankBadge(x, y - 40, label); + drawPlankBadge(x, y - imgW * 0.55, label); } }, }); @@ -645,9 +669,9 @@ export default function VillageView({ // --- 배치 모드 오버레이 (고스트 + 유효/무효 하이라이트) --- if (placing) { - // 배치 가능한 타일 은은하게 표시 + // 배치 가능한 앵커 타일 은은하게 표시 for (let j = 0; j < ROWS; j++) { - for (let i = 0; i < COLS - 1; i++) { + for (let i = 0; i < COLS; i++) { if (!canPlaceAt(state, placing.type, i, j, placing.moveId)) continue; ctx.beginPath(); tilePath(i, j); @@ -657,28 +681,33 @@ 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); + // 풋프린트 전체 하이라이트 ctx.beginPath(); - tilePath(hover.i, hover.j); - ctx.fillStyle = valid ? "rgba(52,211,153,0.5)" : "rgba(248,113,113,0.5)"; + for (let di = 0; di < fp.w; di++) + for (let dj = 0; dj < fp.h; dj++) tilePath(hover.i + di, hover.j + dj); + ctx.fillStyle = valid ? "rgba(52,211,153,0.45)" : "rgba(248,113,113,0.45)"; 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 ci = hover.i + fp.w / 2 - 0.5; + 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 = getImage(BUILDING_IMG[placing.type]) ?? toolImagesRef.current.get(placing.aptitude); ctx.globalAlpha = 0.75; if (gImg) { - const w = 96; + const w = TW * fp.w * 0.94; const h = (gImg.height / gImg.width) * w; - ctx.drawImage(gImg, gx - w / 2, gy + 14 - h, w, h); + ctx.drawImage(gImg, gx - w / 2, gBase + 4 - h, w, h); } else { - drawEmoji("🏗️", gx, gy + 8, 40); + drawEmoji("🏗️", gx, isoY(ci, cj) + TH / 2, 40); } ctx.globalAlpha = 1; } diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts index feced28..14f8ace 100644 --- a/src/lib/game/data.ts +++ b/src/lib/game/data.ts @@ -99,26 +99,38 @@ export const PASSIVES: PassiveDef[] = [ 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 BOARD_COLS = 15; +export const BOARD_ROWS = 15; +export const BOARD_VERSION = 2; // 보드 개편 시 증가 → 세이브 위치 리셋 +export const HALL_TILE = { i: 6, 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 }, + { i: 0, j: 9 }, { i: 1, j: 11 }, { i: 3, j: 12 }, { i: 0, j: 13 }, + { i: 1, j: 8 }, { i: 4, j: 13 }, { i: 11, j: 1 }, { i: 2, j: 0 }, ]; -export const isWaterTile = (i: number) => i >= BOARD_COLS - 1; +// 강은 동쪽 2열 +export const isWaterTile = (i: number) => i >= BOARD_COLS - 2; -// 배치 규칙: 부두는 물가(강 바로 옆 열)에만 -export type PlacementRule = "land" | "nearWater"; +// 건물 풋프린트 (타일 단위, pos는 좌상단 앵커) +export const FOOTPRINTS: Record = { + hall: { w: 3, h: 3 }, + dock: { w: 2, h: 2 }, + lumberyard: { w: 2, h: 2 }, + quarry: { w: 2, h: 2 }, + kitchen: { w: 2, h: 2 }, +}; +export const footprintOf = (type: string) => FOOTPRINTS[type] ?? { w: 1, h: 1 }; + +// 배치 규칙: 부두는 물 위에만 +export type PlacementRule = "land" | "onWater"; export const PLACEMENT_RULES: Partial> = { - dock: "nearWater", + dock: "onWater", }; -// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치 +// 보드 v2 기본 위치 (마이그레이션 시 재배치) export const DEFAULT_BUILDING_POS: Record = { - dock_1: { i: 9, j: 5 }, - lumberyard_1: { i: 2, j: 8 }, - kitchen_1: { i: 6, j: 7 }, + dock_1: { i: 13, j: 6 }, + lumberyard_1: { i: 2, j: 9 }, + kitchen_1: { i: 8, j: 8 }, quarry_1: { i: 1, j: 2 }, }; diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index cd4e5f3..362a08a 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -5,6 +5,7 @@ import { BASE_RATE, BOARD_COLS, BOARD_ROWS, + BOARD_VERSION, BREEDS, BUILD_DEFS, CAT_NAMES, @@ -13,7 +14,9 @@ import { EXP_PER_LEVEL, FULLNESS_DECAY_PER_SEC, HALL_LEVELS, + footprintOf, HALL_TILE, + isWaterTile, KITCHEN_UPGRADE_COST, mealCap, PASSIVES, @@ -159,7 +162,17 @@ function passiveFoodMul(cat: Cat): number { return mul; } -/** 해당 타일에 건물을 놓을 수 있는가 (이동 시 excludeId로 자기 자신 제외) */ +/** 사각 풋프린트 겹침 검사 */ +function rectsOverlap( + a: { i: number; j: number; w: number; h: number }, + b: { i: number; j: number; w: number; h: number }, +): boolean { + return ( + a.i < b.i + b.w && b.i < a.i + a.w && a.j < b.j + b.h && b.j < a.j + a.h + ); +} + +/** 해당 앵커(좌상단)에 건물 풋프린트를 놓을 수 있는가 */ export function canPlaceAt( s: GameState, type: BuildingType, @@ -168,14 +181,27 @@ export function canPlaceAt( 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 fp = footprintOf(type); 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; + // 풋프린트 전 타일 검사 + for (let di = 0; di < fp.w; di++) { + for (let dj = 0; dj < fp.h; dj++) { + const x = i + di; + const y = j + dj; + if (x < 0 || y < 0 || x >= BOARD_COLS || y >= BOARD_ROWS) return false; + const water = isWaterTile(x); + if (rule === "onWater" ? !water : water) return false; + if (TREE_TILES.some((t) => t.i === x && t.j === y)) return false; + } + } + const rect = { i, j, w: fp.w, h: fp.h }; + const hallFp = footprintOf("hall"); + if (rectsOverlap(rect, { ...HALL_TILE, w: hallFp.w, h: hallFp.h })) return false; + for (const b of s.buildings) { + if (b.id === excludeId) continue; + const bfp = footprintOf(b.type); + if (rectsOverlap(rect, { ...b.pos, w: bfp.w, h: bfp.h })) return false; + } return true; } @@ -218,6 +244,12 @@ export const engine = { 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 }) }; + // 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치 + if (state.boardVersion !== BOARD_VERSION) { + for (const b of state.buildings) + b.pos = { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 6 }) }; + state.boardVersion = BOARD_VERSION; + } return state; }, diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index 24bf62d..eb376b0 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -51,6 +51,7 @@ export interface Building { export interface GameState { version: 1; + boardVersion?: number; // 보드 개편 마이그레이션 마커 resources: Record; cats: Cat[]; buildings: Building[];