보드 확장 15×15 + 건물 풋프린트 + 부두 물 위 배치

- 보드 11×11 → 15×15, 강을 동쪽 2열로 확장 (흐름·반짝임도 2열)
- 건물 풋프린트: 회관 3×3, 부두·벌목장·채석장·부엌 2×2 (pos=좌상단 앵커)
  렌더·클릭 영역·작업 위치(반경)·산책 충돌 전부 풋프린트 기반
- 배치: canPlaceAt이 풋프린트 전 타일 검사 + 사각 겹침, 호버 시 풋프린트
  전체 하이라이트 + 실제 크기 고스트
- 부두: onWater 규칙(물 타일 위에만), 이미지 좌우 반전(강 방향)
- 보드 버전(v2) 마이그레이션: 기존 세이브 건물을 새 기본 위치로 재배치

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 16:01:32 +09:00
co-authored by Claude Fable 5
부모 256d367bcd
커밋 1f858352a0
5개의 변경된 파일과 160개의 추가작업 그리고 86개의 파일을 삭제
바이너리 파일은 표시되지 않음.

이전

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

이후

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

+91 -62
파일 보기
@@ -7,6 +7,7 @@ import {
BOARD_ROWS, BOARD_ROWS,
BUILDING_IMG, BUILDING_IMG,
catSprite, catSprite,
footprintOf,
HALL_TILE, HALL_TILE,
TOOL_IMG, TOOL_IMG,
TREE_TILES, TREE_TILES,
@@ -44,35 +45,40 @@ let originY = 56;
const isoX = (i: number, j: number) => originX + ((i - j) * TW) / 2; const isoX = (i: number, j: number) => originX + ((i - j) * TW) / 2;
const isoY = (i: number, j: number) => originY + ((i + j) * TH) / 2; const isoY = (i: number, j: number) => originY + ((i + j) * TH) / 2;
// 물 (동쪽 끝 열), 부두는 물가에 붙임 // 물 (동쪽 끝 2열) — 부두는 물 위에 배치
const isWater = (i: number, j: number) => i >= COLS - 1; const isWater = (i: number, j: number) => i >= COLS - 2;
const TREES = TREE_TILES; const TREES = TREE_TILES;
const DECOR: Array<{ i: number; j: number; emoji: string }> = [ const DECOR: Array<{ i: number; j: number; emoji: string }> = [
{ i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" }, { i: 9, j: 1, emoji: "🌼" }, { i: 4, j: 2, emoji: "🌷" },
{ i: 8, j: 8, emoji: "🌾" }, { i: 6, j: 9, emoji: "🍄" }, { i: 10, j: 11, emoji: "🌾" }, { i: 6, j: 12, emoji: "🍄" },
{ i: 1, j: 3, 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 }> = [ 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: -2, j: 3 }, { i: -3, j: 7 }, { i: -2, j: 11 }, { i: -1.5, j: 14.5 },
{ i: 1, j: 12.5 }, { i: 3.5, j: 12 }, { i: 6, j: 12.5 }, { i: 9, j: 12 }, { i: 1, j: 16.5 }, { i: 4, j: 16 }, { i: 7.5, j: 16.5 }, { i: 11, j: 16 },
{ i: 12, j: 11.5 }, { i: 1, j: -2.5 }, { i: 3.5, j: -2 }, { i: 6.5, j: -2.5 }, { i: 14, j: 15.5 }, { i: 1, j: -2.5 }, { i: 4.5, j: -2 }, { i: 8, j: -2.5 },
{ i: 9, j: -2 }, { i: 12, j: -1.5 }, { i: -1, j: -1 }, { i: 13, 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 }> = [ const OUTER_DECOR: Array<{ i: number; j: number; emoji: string }> = [
{ i: -1.5, j: 3.5, emoji: "🌼" }, { i: -2, j: 6.5, emoji: "🌷" }, { i: -1.5, j: 4.5, emoji: "🌼" }, { i: -2, j: 9, emoji: "🌷" },
{ i: 2, j: 11.8, emoji: "🌾" }, { i: 7.5, j: 12.2, emoji: "🌼" }, { i: 2.5, j: 15.8, emoji: "🌾" }, { i: 9.5, j: 16.2, emoji: "🌼" },
{ i: 5, j: -1.8, emoji: "🪨" }, { i: 10.5, j: -1.6, emoji: "🌾" }, { i: 6, j: -1.8, emoji: "🪨" }, { i: 13, j: -1.6, emoji: "🌾" },
{ i: -0.5, j: 9.5, emoji: "🍄" }, { i: 11, j: 12.3, 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) => const isBuildingTile = (s: GameState, i: number, j: number) =>
(HALL_TILE.i === i && HALL_TILE.j === j) || inFootprint(HALL_TILE, footprintOf("hall"), i, j) ||
s.buildings.some((b) => b.pos.i === i && b.pos.j === j); s.buildings.some((b) => inFootprint(b.pos, footprintOf(b.type), i, j));
const isWalkable = (s: GameState, i: number, j: number) => 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) && !isBuildingTile(s, i, j) &&
!TREES.some((t) => t.i === i && t.j === 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 }; 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) { 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; const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6;
return { return {
x: isoX(tile.i, tile.j) + Math.cos(angle) * 42, x: isoX(ci, cj) + Math.cos(angle) * radius,
y: isoY(tile.i, tile.j) + TH / 2 + Math.sin(angle) * 16 + 8, y: isoY(ci, cj) + TH / 2 + Math.sin(angle) * (radius * 0.4) + 8,
}; };
} }
@@ -400,10 +414,10 @@ export default function VillageView({
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
{ {
const a = vertex(COLS - 1, -J_EXT); const a = vertex(COLS - 2, -J_EXT);
const b = vertex(COLS, -J_EXT); const b = vertex(COLS, -J_EXT);
const c = vertex(COLS, ROWS + 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.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
ctx.lineTo(c.x, c.y); ctx.lineTo(c.x, c.y);
@@ -416,14 +430,14 @@ export default function VillageView({
if (water) { if (water) {
// 강 줄기 방향(상류→하류, 좌하향)으로 한 방향 흐름: // 강 줄기 방향(상류→하류, 좌하향)으로 한 방향 흐름:
// 컨텍스트를 강 방향으로 회전시킨 뒤 텍스처를 미러 타일링하며 스크롤 // 컨텍스트를 강 방향으로 회전시킨 뒤 텍스처를 미러 타일링하며 스크롤
const sx = isoX(COLS - 1, -J_EXT); const sx = isoX(COLS - 1.5, -J_EXT);
const sy = isoY(COLS - 1, -J_EXT) + TH / 2; const sy = isoY(COLS - 1.5, -J_EXT) + TH / 2;
const ex = isoX(COLS - 1, ROWS + J_EXT); const ex = isoX(COLS - 1.5, ROWS + J_EXT);
const ey = isoY(COLS - 1, ROWS + J_EXT) + TH / 2; const ey = isoY(COLS - 1.5, ROWS + J_EXT) + TH / 2;
const angle = Math.atan2(ey - sy, ex - sx); const angle = Math.atan2(ey - sy, ex - sx);
const riverLen = Math.hypot(ex - sx, ey - sy); const riverLen = Math.hypot(ex - sx, ey - sy);
const h = 220; // 강 폭 방향 커버 (클리핑되므로 넉넉히) const h = 300; // 강 폭 방향 커버 (2열, 클리핑되므로 넉넉히)
const w = (water.width / water.height) * h; const w = (water.width / water.height) * h;
const FLOW_SPEED = 22; // px/초 const FLOW_SPEED = 22; // px/초
const offset = ((now / 1000) * FLOW_SPEED) % (2 * w); const offset = ((now / 1000) * FLOW_SPEED) % (2 * w);
@@ -446,14 +460,16 @@ export default function VillageView({
ctx.fillRect(0, 0, worldW, worldH); ctx.fillRect(0, 0, worldW, worldH);
} }
// 은은한 반짝임 오버레이 (연장 구간 포함) // 은은한 반짝임 오버레이 (연장 구간·2열)
for (let j = -J_EXT; j < ROWS + J_EXT; j++) { for (let j = -J_EXT; j < ROWS + J_EXT; j++) {
const glint = 0.05 + 0.05 * Math.sin(now / 700 + j); for (const col of [COLS - 2, COLS - 1]) {
const glint = 0.05 + 0.05 * Math.sin(now / 700 + j + col);
ctx.beginPath(); ctx.beginPath();
tilePath(COLS - 1, j); tilePath(col, j);
ctx.fillStyle = `rgba(255,255,255,${glint})`; ctx.fillStyle = `rgba(255,255,255,${glint})`;
ctx.fill(); ctx.fill();
} }
}
ctx.restore(); ctx.restore();
} }
@@ -504,59 +520,67 @@ export default function VillageView({
hitRectsRef.current = []; hitRectsRef.current = [];
// 회관 // 회관 (3x3 풋프린트)
{ {
const t = HALL_TILE; const { ci, cj, fp } = footprintCenter(HALL_TILE, "hall");
const x = isoX(t.i, t.j); const x = isoX(ci, cj);
const y = isoY(t.i, t.j) + TH / 2; const y = isoY(ci, cj) + TH / 2;
hitRectsRef.current.push({ id: "hall", kind: "open", x: x - 54, y: y - 70, w: 108, h: 100 }); 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({ sprites.push({
y, y,
draw: () => { draw: () => {
const hallImg = getImage(BUILDING_IMG.hall); const hallImg = getImage(BUILDING_IMG.hall);
if (hallImg) { if (hallImg) {
const w = 118; const hh = (hallImg.height / hallImg.width) * imgW;
const hh = (hallImg.height / hallImg.width) * w; ctx.drawImage(hallImg, x - imgW / 2, baseY + 4 - hh, imgW, hh);
ctx.drawImage(hallImg, x - w / 2, y + 16 - hh, w, hh);
} else { } else {
shadow(x, y + 6, 26); shadow(x, y + 6, 26);
drawEmoji("🏛️", x, y + 8, 48); drawEmoji("🏛️", x, y + 8, 48);
} }
ctx.font = `13px ${juaFamily}`; ctx.font = `14px ${juaFamily}`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.strokeStyle = "rgba(255,252,238,0.75)"; ctx.strokeStyle = "rgba(255,252,238,0.75)";
ctx.lineWidth = 3; 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.lineWidth = 1;
ctx.fillStyle = "#4a3418"; 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; const placing = placingRef.current;
for (const b of state.buildings) { for (const b of state.buildings) {
if (placing?.moveId === b.id) continue; // 이동 중인 건물은 고스트로만 표시 if (placing?.moveId === b.id) continue; // 이동 중인 건물은 고스트로만 표시
const t = b.pos; const { ci, cj, fp } = footprintCenter(b.pos, b.type);
const x = isoX(t.i, t.j); const x = isoX(ci, cj);
const y = isoY(t.i, t.j) + TH / 2; 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 stored = Math.floor(b.stored);
const isKitchen = b.type === "kitchen"; 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) 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({ sprites.push({
y, y,
draw: () => { draw: () => {
const bImg = getImage(BUILDING_IMG[b.type]); const bImg = getImage(BUILDING_IMG[b.type]);
if (bImg) { if (bImg) {
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요) // 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
const w = 96; const h = (bImg.height / bImg.width) * imgW;
const h = (bImg.height / bImg.width) * w; ctx.drawImage(bImg, x - imgW / 2, baseY + 4 - h, imgW, h);
ctx.drawImage(bImg, x - w / 2, y + 14 - h, w, h);
} else { } else {
shadow(x, y + 6, 24); shadow(x, y + 6, 24);
const img = toolImagesRef.current.get(b.aptitude); const img = toolImagesRef.current.get(b.aptitude);
@@ -572,13 +596,13 @@ export default function VillageView({
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.strokeStyle = "rgba(255,252,238,0.75)"; ctx.strokeStyle = "rgba(255,252,238,0.75)";
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.strokeText(b.name, x, y + 24); ctx.strokeText(b.name, x, baseY + 17);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.fillStyle = "#4a3418"; ctx.fillStyle = "#4a3418";
ctx.fillText(b.name, x, y + 24); ctx.fillText(b.name, x, baseY + 17);
if (stored > 0) { if (stored > 0) {
const label = `${isKitchen ? "🍖" : RES_EMOJI[b.resource ?? "fish"]} ${stored}`; 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) { if (placing) {
// 배치 가능한 타일 은은하게 표시 // 배치 가능한 앵커 타일 은은하게 표시
for (let j = 0; j < ROWS; j++) { 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; if (!canPlaceAt(state, placing.type, i, j, placing.moveId)) continue;
ctx.beginPath(); ctx.beginPath();
tilePath(i, j); tilePath(i, j);
@@ -657,28 +681,33 @@ export default function VillageView({
} }
const hover = hoverTileRef.current; const hover = hoverTileRef.current;
if (hover) { if (hover) {
const fp = footprintOf(placing.type);
const valid = canPlaceAt(state, placing.type, hover.i, hover.j, placing.moveId); const valid = canPlaceAt(state, placing.type, hover.i, hover.j, placing.moveId);
// 풋프린트 전체 하이라이트
ctx.beginPath(); ctx.beginPath();
tilePath(hover.i, hover.j); for (let di = 0; di < fp.w; di++)
ctx.fillStyle = valid ? "rgba(52,211,153,0.5)" : "rgba(248,113,113,0.5)"; 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.fill();
ctx.strokeStyle = valid ? "#059669" : "#dc2626"; ctx.strokeStyle = valid ? "#059669" : "#dc2626";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.stroke(); ctx.stroke();
ctx.lineWidth = 1; ctx.lineWidth = 1;
// 고스트 건물 // 고스트 건물 (풋프린트 중심·크기)
const gx = isoX(hover.i, hover.j); const ci = hover.i + fp.w / 2 - 0.5;
const gy = isoY(hover.i, hover.j) + TH / 2; 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 = const gImg =
getImage(BUILDING_IMG[placing.type]) ?? getImage(BUILDING_IMG[placing.type]) ??
toolImagesRef.current.get(placing.aptitude); toolImagesRef.current.get(placing.aptitude);
ctx.globalAlpha = 0.75; ctx.globalAlpha = 0.75;
if (gImg) { if (gImg) {
const w = 96; const w = TW * fp.w * 0.94;
const h = (gImg.height / gImg.width) * w; 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 { } else {
drawEmoji("🏗️", gx, gy + 8, 40); drawEmoji("🏗️", gx, isoY(ci, cj) + TH / 2, 40);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
+25 -13
파일 보기
@@ -99,26 +99,38 @@ export const PASSIVES: PassiveDef[] = [
import type { BuildingType } from "./types"; import type { BuildingType } from "./types";
export const BOARD_COLS = 11; export const BOARD_COLS = 15;
export const BOARD_ROWS = 11; export const BOARD_ROWS = 15;
export const HALL_TILE = { i: 5, j: 3 }; export const BOARD_VERSION = 2; // 보드 개편 시 증가 → 세이브 위치 리셋
export const HALL_TILE = { i: 6, j: 3 };
export const TREE_TILES: Array<{ i: number; j: number }> = [ 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: 0, j: 9 }, { i: 1, j: 11 }, { i: 3, j: 12 }, { i: 0, j: 13 },
{ i: 1, j: 6 }, { i: 3, j: 7 }, { 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;
// 배치 규칙: 부두는 물가(강 바로 옆 열)에만 // 건물 풋프린트 (타일 단위, pos는 좌상단 앵커)
export type PlacementRule = "land" | "nearWater"; export const FOOTPRINTS: Record<string, { w: number; h: number }> = {
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<Record<BuildingType, PlacementRule>> = { export const PLACEMENT_RULES: Partial<Record<BuildingType, PlacementRule>> = {
dock: "nearWater", dock: "onWater",
}; };
// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치 // 보드 v2 기본 위치 (마이그레이션 시 재배치)
export const DEFAULT_BUILDING_POS: Record<string, { i: number; j: number }> = { export const DEFAULT_BUILDING_POS: Record<string, { i: number; j: number }> = {
dock_1: { i: 9, j: 5 }, dock_1: { i: 13, j: 6 },
lumberyard_1: { i: 2, j: 8 }, lumberyard_1: { i: 2, j: 9 },
kitchen_1: { i: 6, j: 7 }, kitchen_1: { i: 8, j: 8 },
quarry_1: { i: 1, j: 2 }, quarry_1: { i: 1, j: 2 },
}; };
+40 -8
파일 보기
@@ -5,6 +5,7 @@ import {
BASE_RATE, BASE_RATE,
BOARD_COLS, BOARD_COLS,
BOARD_ROWS, BOARD_ROWS,
BOARD_VERSION,
BREEDS, BREEDS,
BUILD_DEFS, BUILD_DEFS,
CAT_NAMES, CAT_NAMES,
@@ -13,7 +14,9 @@ import {
EXP_PER_LEVEL, EXP_PER_LEVEL,
FULLNESS_DECAY_PER_SEC, FULLNESS_DECAY_PER_SEC,
HALL_LEVELS, HALL_LEVELS,
footprintOf,
HALL_TILE, HALL_TILE,
isWaterTile,
KITCHEN_UPGRADE_COST, KITCHEN_UPGRADE_COST,
mealCap, mealCap,
PASSIVES, PASSIVES,
@@ -159,7 +162,17 @@ function passiveFoodMul(cat: Cat): number {
return mul; 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( export function canPlaceAt(
s: GameState, s: GameState,
type: BuildingType, type: BuildingType,
@@ -168,14 +181,27 @@ export function canPlaceAt(
excludeId?: string, excludeId?: string,
): boolean { ): boolean {
if (!Number.isInteger(i) || !Number.isInteger(j)) return false; if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
// 물 열 제외한 보드 범위 const fp = footprintOf(type);
if (i < 0 || j < 0 || i >= BOARD_COLS - 1 || j >= BOARD_ROWS) return false;
const rule = PLACEMENT_RULES[type] ?? "land"; 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; for (let di = 0; di < fp.w; di++) {
if (TREE_TILES.some((t) => t.i === i && t.j === j)) return false; for (let dj = 0; dj < fp.h; dj++) {
if (s.buildings.some((b) => b.id !== excludeId && b.pos.i === i && b.pos.j === j)) const x = i + di;
return false; 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; return true;
} }
@@ -218,6 +244,12 @@ export const engine = {
for (const cat of state.cats) cat.aptitudePoints ??= 0; for (const cat of state.cats) cat.aptitudePoints ??= 0;
for (const b of state.buildings) for (const b of state.buildings)
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) }; 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; return state;
}, },
+1
파일 보기
@@ -51,6 +51,7 @@ export interface Building {
export interface GameState { export interface GameState {
version: 1; version: 1;
boardVersion?: number; // 보드 개편 마이그레이션 마커
resources: Record<ResourceKey, number>; resources: Record<ResourceKey, number>;
cats: Cat[]; cats: Cat[];
buildings: Building[]; buildings: Building[];