보드 확장 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

+94 -65
파일 보기
@@ -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;
}
+25 -13
파일 보기
@@ -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<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>> = {
dock: "nearWater",
dock: "onWater",
};
// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치
// 보드 v2 기본 위치 (마이그레이션 시 재배치)
export const DEFAULT_BUILDING_POS: Record<string, { i: number; j: number }> = {
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 },
};
+40 -8
파일 보기
@@ -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;
},
+1
파일 보기
@@ -51,6 +51,7 @@ export interface Building {
export interface GameState {
version: 1;
boardVersion?: number; // 보드 개편 마이그레이션 마커
resources: Record<ResourceKey, number>;
cats: Cat[];
buildings: Building[];