건설 방식 개편: 상점 구매 + 자유 배치 (클래시 오브 클랜 방식)

- 건물 위치가 게임 상태(building.pos)로 이동, 구세이브 자동 마이그레이션
- 🛒 상점 패널: 건물 목록·비용·해금 조건, 구매 → 배치 모드 진입
- 배치 모드: 배치 가능 타일 하이라이트, 호버 타일 초록/빨강 + 고스트,
  클릭 확정(비용은 확정 시 차감), ESC/취소 버튼
- 배치 규칙: 물·나무·회관·기존 건물 차단, 부두는 물가 열 전용
- 기존 건물 📍 이동 기능 (건물 패널), 이동 시 배치된 고양이가 새 일터로 걸어감
- 공터(🚧) 방식 제거

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 15:21:44 +09:00
co-authored by Claude Fable 5
부모 9de2abfd85
커밋 ecfccfbe70
5개의 변경된 파일과 321개의 추가작업 그리고 125개의 파일을 삭제
+27
파일 보기
@@ -86,6 +86,33 @@ export const PASSIVES: PassiveDef[] = [
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
];
// ---------- 마을 보드 ----------
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 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 },
];
export const isWaterTile = (i: number) => i >= BOARD_COLS - 1;
// 배치 규칙: 부두는 물가(강 바로 옆 열)에만
export type PlacementRule = "land" | "nearWater";
export const PLACEMENT_RULES: Partial<Record<BuildingType, PlacementRule>> = {
dock: "nearWater",
};
// 구버전 세이브(위치 없음) 마이그레이션용 기본 위치
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 },
quarry_1: { i: 1, j: 2 },
};
// ---------- 성장 ----------
export const EXP_PER_LEVEL = 90; // 레벨업 필요 경험치 = 레벨 × 90
+47 -2
파일 보기
@@ -3,20 +3,27 @@ import {
APT_POINT_EVERY,
APTITUDE_RATE,
BASE_RATE,
BOARD_COLS,
BOARD_ROWS,
BREEDS,
BUILD_DEFS,
CAT_NAMES,
DEFAULT_BUILDING_POS,
EAT_THRESHOLD,
EXP_PER_LEVEL,
FULLNESS_DECAY_PER_SEC,
HALL_LEVELS,
HALL_TILE,
KITCHEN_UPGRADE_COST,
mealCap,
PASSIVES,
PLACEMENT_RULES,
RECIPES,
TREE_TILES,
type BreedDef,
type RecipeDef,
} from "./data";
import type { BuildingType } from "./types";
const SAVE_KEY = "cat-village-save";
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
@@ -91,11 +98,13 @@ function createBuildings(): Building[] {
return [
{
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
pos: { ...DEFAULT_BUILDING_POS.dock_1 },
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
assigned: [], stored: 0,
},
{
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
pos: { ...DEFAULT_BUILDING_POS.lumberyard_1 },
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
assigned: [], stored: 0,
},
@@ -150,6 +159,26 @@ function passiveFoodMul(cat: Cat): number {
return mul;
}
/** 해당 타일에 건물을 놓을 수 있는가 (이동 시 excludeId로 자기 자신 제외) */
export function canPlaceAt(
s: GameState,
type: BuildingType,
i: number,
j: number,
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 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;
return true;
}
/** 현재 해금된 레시피 중 가장 마지막 것 (부엌이 요리할 메뉴) */
export function activeRecipe(s: GameState): RecipeDef {
const kitchenLv = s.buildings.find((b) => b.type === "kitchen")?.level ?? 1;
@@ -187,6 +216,8 @@ export const engine = {
// 구버전 세이브 마이그레이션 (필드 보강)
state.resources.stone ??= 0;
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 }) };
return state;
},
@@ -266,8 +297,8 @@ export const engine = {
return null;
},
/** 공통 건설: BUILD_DEFS 기반 (부엌·채석장 …) */
build(type: "quarry" | "kitchen"): string | null {
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
build(type: "quarry" | "kitchen", pos: { i: number; j: number }): string | null {
if (!state) return null;
const def = BUILD_DEFS.find((d) => d.type === type);
if (!def) return null;
@@ -275,6 +306,8 @@ export const engine = {
return `${def.name}은(는) 이미 지어져 있습니다`;
if (state.hall.level < def.reqHallLv)
return `회관 Lv${def.reqHallLv}부터 지을 수 있습니다`;
if (!canPlaceAt(state, def.type, pos.i, pos.j))
return "그곳에는 지을 수 없습니다";
const missing = Object.entries(def.cost).filter(
([key, amount]) =>
state!.resources[key as keyof GameState["resources"]] < (amount ?? 0),
@@ -286,12 +319,24 @@ export const engine = {
consume(state, def.cost);
state.buildings.push({
id: def.id, type: def.type, name: def.name, emoji: def.emoji,
pos: { i: pos.i, j: pos.j },
level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
resource: def.resource, assigned: [], stored: 0, progress: 0,
});
return null;
},
/** 건물 이동 (자유 배치) */
moveBuilding(id: string, pos: { i: number; j: number }): string | null {
if (!state) return null;
const building = state.buildings.find((b) => b.id === id);
if (!building) return null;
if (!canPlaceAt(state, building.type, pos.i, pos.j, id))
return "그곳으로는 옮길 수 없습니다";
building.pos = { i: pos.i, j: pos.j };
return null;
},
/** 부엌 증축 Lv1→2: 훈제 생선 해금 조건 + 선반 확장 */
upgradeKitchen(): string | null {
if (!state) return null;
+1
파일 보기
@@ -39,6 +39,7 @@ export interface Building {
type: BuildingType;
name: string;
emoji: string;
pos: { i: number; j: number }; // 보드 타일 좌표 (자유 배치)
level: number;
maxSlots: number;
aptitude: AptitudeKey; // 필요 적성