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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:44 +09:00

454 라인
15 KiB
TypeScript

import type { AptitudeKey, Building, Cat, GameState } from "./types";
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: "돌" };
const APTITUDE_KEYS: AptitudeKey[] = [
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
];
const rand = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1)) + min;
const pickWeighted = <T extends { weight: number }>(items: T[]): T => {
const total = items.reduce((s, i) => s + i.weight, 0);
let roll = Math.random() * total;
for (const item of items) {
roll -= item.weight;
if (roll <= 0) return item;
}
return items[items.length - 1];
};
let seq = 0;
const nextId = (prefix: string) =>
`${prefix}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
// ---------- 고양이 생성 ----------
function generateCat(forceAptitude?: AptitudeKey): Cat {
const breed: BreedDef = pickWeighted(BREEDS);
const aptitude = {} as Record<AptitudeKey, number>;
for (const key of APTITUDE_KEYS) {
let value = rand(0, 2);
if (breed.bias.includes(key)) value += rand(1, 3);
aptitude[key] = Math.min(value, breed.aptitudeCap);
}
if (forceAptitude && aptitude[forceAptitude] < 2) {
aptitude[forceAptitude] = rand(2, 3);
}
const passives: string[] = [];
const pool = [...PASSIVES];
const passiveCount = rand(0, 2);
for (let i = 0; i < passiveCount; i++) {
const idx = rand(0, pool.length - 1);
passives.push(pool.splice(idx, 1)[0].key);
}
return {
id: nextId("cat"),
name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)],
breed: breed.key,
breedName: breed.name,
emoji: breed.emoji,
level: 1,
exp: 0,
aptitudePoints: 0,
aptitude,
stats: {
diligence: rand(50, 150),
stamina: rand(50, 150),
appetite: Math.min(5, Math.max(1, rand(2, 4) + breed.appetiteMod)),
},
status: { fullness: 100, happiness: 80 },
passives,
assignedTo: null,
};
}
// ---------- 초기 상태 ----------
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,
},
];
}
function createInitialState(): GameState {
// 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지)
return {
version: 1,
resources: { fish: 0, wood: 0, stone: 0 },
cats: [generateCat("fishing"), generateCat("logging")],
buildings: createBuildings(),
hall: { level: 1, capacity: 3 },
updatedAt: Date.now(),
};
}
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
let state: GameState | null = null;
function passiveSpeedMul(cat: Cat): number {
return cat.passives.reduce((mul, key) => {
const def = PASSIVES.find((p) => p.key === key);
return mul * (def?.speedMul ?? 1);
}, 1);
}
/** 초당 산출량 = 적성 테이블 × 근면 배율 × 패시브 배율 × BASE_RATE */
export function catRate(cat: Cat, aptitudeKey: AptitudeKey): number {
const apt = APTITUDE_RATE[cat.aptitude[aptitudeKey]] ?? 0;
return apt * (cat.stats.diligence / 100) * passiveSpeedMul(cat) * BASE_RATE;
}
/** 배고픔 페널티: 0이면 0.3배, 25 미만이면 0.7배 */
export function fullnessPenalty(cat: Cat): number {
if (cat.status.fullness <= 0) return 0.3;
if (cat.status.fullness < 25) return 0.7;
return 1;
}
/** 실효 산출량 (배고픔 페널티 반영) */
export function effectiveRate(cat: Cat, aptitudeKey: AptitudeKey): number {
return catRate(cat, aptitudeKey) * fullnessPenalty(cat);
}
function passiveFoodMul(cat: Cat): number {
let mul = 1;
if (cat.passives.includes("small_eater")) mul *= 0.7;
if (cat.passives.includes("big_eater")) mul *= 1.5;
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;
const unlocked = RECIPES.filter(
(r) => r.reqHallLv <= s.hall.level && r.reqKitchenLv <= kitchenLv,
);
return unlocked[unlocked.length - 1] ?? RECIPES[0];
}
function canConsume(s: GameState, input: RecipeDef["input"]): boolean {
return Object.entries(input).every(
([key, amount]) => s.resources[key as keyof typeof s.resources] >= (amount ?? 0),
);
}
function consume(s: GameState, input: RecipeDef["input"]) {
for (const [key, amount] of Object.entries(input)) {
s.resources[key as keyof typeof s.resources] -= amount ?? 0;
}
}
export const engine = {
init(): GameState {
if (state) return state;
try {
const raw = localStorage.getItem(SAVE_KEY);
if (raw) {
const saved = JSON.parse(raw) as GameState;
if (saved.version === 1) state = saved;
}
} catch {
// 저장 데이터가 깨졌으면 새로 시작
}
if (!state) state = createInitialState();
// 구버전 세이브 마이그레이션 (필드 보강)
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;
},
tick(dt: number) {
if (!state) return;
for (const building of state.buildings) {
let produced = 0;
for (const catId of building.assigned) {
const cat = state.cats.find((c) => c.id === catId);
if (!cat) continue;
produced += effectiveRate(cat, building.aptitude) * dt;
// 배치 중 경험치: 초당 1
cat.exp += dt;
const needed = cat.level * EXP_PER_LEVEL;
if (cat.exp >= needed) {
cat.exp -= needed;
cat.level += 1;
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
if (cat.level % APT_POINT_EVERY === 0) cat.aptitudePoints += 1;
}
}
if (building.type === "kitchen") {
// 요리: 재료가 있고 선반에 자리가 있을 때만 진행
const recipe = activeRecipe(state);
building.progress = (building.progress ?? 0) + produced;
while (building.progress >= recipe.work) {
if (building.stored >= mealCap(building.level) || !canConsume(state, recipe.input)) {
building.progress = recipe.work; // 재료·선반 대기 상태로 캡
break;
}
consume(state, recipe.input);
building.stored += 1;
building.progress -= recipe.work;
}
} else {
building.stored += produced;
}
}
// 포만도 감소 (식욕 × 패시브)
for (const cat of state.cats) {
const decay = cat.stats.appetite * FULLNESS_DECAY_PER_SEC * passiveFoodMul(cat);
cat.status.fullness = Math.max(0, cat.status.fullness - decay * dt);
}
// 배고픈 고양이가 부엌에서 자동 식사 (배고픈 순서대로)
const kitchens = state.buildings.filter(
(b) => b.type === "kitchen" && b.stored >= 1,
);
if (kitchens.length > 0) {
const recipe = activeRecipe(state);
const hungry = [...state.cats]
.filter((c) => c.status.fullness <= EAT_THRESHOLD)
.sort((a, b) => a.status.fullness - b.status.fullness);
for (const cat of hungry) {
const kitchen = kitchens.find((k) => k.stored >= 1);
if (!kitchen) break;
kitchen.stored -= 1;
cat.status.fullness = Math.min(100, cat.status.fullness + recipe.fullness);
}
}
state.updatedAt = Date.now();
},
/** 입양 후보 생성 (아직 마을에 합류하지 않음) */
generateCandidates(count = 3): Cat[] {
return Array.from({ length: count }, () => generateCat());
},
/** 후보 중 한 마리 입양 확정 */
adoptCandidate(cat: Cat): string | null {
if (!state) return null;
if (state.cats.length >= state.hall.capacity)
return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
state.cats.push(cat);
return 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;
if (state.buildings.some((b) => b.type === type))
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),
);
if (missing.length > 0)
return `자원이 부족합니다 (${missing
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(", ")})`;
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;
const kitchen = state.buildings.find((b) => b.type === "kitchen");
if (!kitchen) return "부엌이 없습니다";
if (kitchen.level >= 2) return "부엌이 최고 레벨입니다";
const missing = Object.entries(KITCHEN_UPGRADE_COST).filter(
([key, amount]) =>
state!.resources[key as keyof GameState["resources"]] < amount,
);
if (missing.length > 0)
return `자원이 부족합니다 (${missing
.map(([k, v]) => `${RES_LABEL[k] ?? k} ${v}`)
.join(", ")})`;
consume(state, KITCHEN_UPGRADE_COST);
kitchen.level = 2;
return null;
},
/** 적성 포인트 투자 (품종 상한까지) */
investAptitude(catId: string, key: AptitudeKey): string | null {
if (!state) return null;
const cat = state.cats.find((c) => c.id === catId);
if (!cat) return null;
if (cat.aptitudePoints <= 0) return "적성 포인트가 없습니다";
const cap = BREEDS.find((b) => b.key === cat.breed)?.aptitudeCap ?? 5;
if (cat.aptitude[key] >= cap)
return `${cat.breedName}의 적성 상한(${cap})에 도달했습니다`;
cat.aptitude[key] += 1;
cat.aptitudePoints -= 1;
return null;
},
upgradeHall(): string | null {
if (!state) return null;
const next = HALL_LEVELS[state.hall.level + 1];
if (!next) return "회관이 최고 레벨입니다";
if (next.locked) return `회관 Lv${state.hall.level + 1}: ${next.locked}`;
const missing: string[] = [];
for (const [key, amount] of Object.entries(next.cost)) {
const have = state.resources[key as keyof typeof state.resources];
if (have < (amount ?? 0))
missing.push(`${RES_LABEL[key] ?? key} ${Math.floor(have)}/${amount}`);
}
for (const req of next.reqBuildings ?? []) {
const b = state.buildings.find((x) => x.type === req.type);
if (!b || b.level < (req.level ?? 1)) missing.push(req.label);
}
if (next.reqCat) {
const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length;
if (count < next.reqCat.count)
missing.push(`Lv${next.reqCat.level} 이상 고양이 ${count}/${next.reqCat.count}마리`);
}
if (missing.length > 0) return `증축 조건 부족 — ${missing.join(", ")}`;
consume(state, next.cost);
state.hall.level += 1;
state.hall.capacity = next.capacity;
return null;
},
assign(catId: string, buildingId: string): string | null {
if (!state) return null;
const cat = state.cats.find((c) => c.id === catId);
const building = state.buildings.find((b) => b.id === buildingId);
if (!cat || !building) return null;
if (cat.aptitude[building.aptitude] === 0)
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
if (building.assigned.length >= building.maxSlots)
return `${building.name} 자리가 가득 찼습니다`;
// 기존 배치 해제 후 이동
this.unassign(catId);
building.assigned.push(catId);
cat.assignedTo = building.id;
return null;
},
unassign(catId: string) {
if (!state) return;
const cat = state.cats.find((c) => c.id === catId);
if (!cat) return;
for (const b of state.buildings) {
b.assigned = b.assigned.filter((id) => id !== catId);
}
cat.assignedTo = null;
},
collect(buildingId: string) {
if (!state) return;
const building = state.buildings.find((b) => b.id === buildingId);
if (!building || !building.resource) return; // 부엌은 회수 대상 아님 (고양이가 직접 먹음)
const amount = Math.floor(building.stored);
if (amount <= 0) return;
building.stored -= amount;
state.resources[building.resource] += amount;
},
save() {
if (!state) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} catch {
// 저장 실패는 치명적이지 않음
}
},
snapshot(): GameState | null {
return state ? (JSON.parse(JSON.stringify(state)) as GameState) : null;
},
/** 렌더 루프용 라이브 참조 (읽기 전용으로만 사용할 것 — 매 프레임 클론 비용 회피) */
peek(): GameState | null {
return state;
},
};