- 해금 전: 행복도 증감·파업 로직 완전 비활성, 속도 배율 중립, UI에서 💗 숨김 → 초반에 행복도를 올릴 수단이 없어 파업 소프트락에 걸리던 문제 해결 - 해금 조건 달성 시 촌장 냥이가 재등장해 행복도 시스템 안내 (1회) - 해금 순간 전원 행복도 80으로 초기화 - 기존 세이브 마이그레이션: 조건 미충족이면 파업 해제 + 행복도 리셋, 이미 충족한 마을은 안내 생략 - 햇볕 데크 설명에 해금 역할 명시 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
871 라인
30 KiB
TypeScript
871 라인
30 KiB
TypeScript
import type { AptitudeKey, Building, Cat, GameState } from "./types";
|
|
import {
|
|
APT_POINT_EVERY,
|
|
APTITUDE_RATE,
|
|
BASE_RATE,
|
|
CAT_HOUSE_CAPACITY,
|
|
ENERGY_DRAIN_PER_SEC,
|
|
HOUSE_RECOVER_PER_SEC,
|
|
IDLE_RECOVER_PER_SEC,
|
|
REST_RECOVER_PER_SEC,
|
|
STRIKE_RESUME,
|
|
STRIKE_THRESHOLD,
|
|
BOARD_COLS,
|
|
BOARD_ROWS,
|
|
BOARD_VERSION,
|
|
BREEDS,
|
|
BUILD_DEFS,
|
|
CAT_NAMES,
|
|
ADOPT_BASE_COST,
|
|
ADOPT_COST_PER_CAT,
|
|
ADOPT_FREE_CATS,
|
|
BASE_STORAGE,
|
|
BUILDING_STORE_CAP,
|
|
DECO_DEFS,
|
|
DEFAULT_BUILDING_POS,
|
|
MARKET_RATE_PER_SEC,
|
|
MEAL_PRICES,
|
|
REROLL_COST,
|
|
RUSH_COST_PER_10S,
|
|
SELL_PRICES,
|
|
WAREHOUSE_STORAGE,
|
|
EAT_THRESHOLD,
|
|
EXP_PER_LEVEL,
|
|
FULLNESS_DECAY_PER_SEC,
|
|
HALL_LEVELS,
|
|
footprintOf,
|
|
HALL_TILE,
|
|
isWaterTile,
|
|
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);
|
|
}
|
|
|
|
const stats = {
|
|
diligence: rand(50, 150),
|
|
stamina: rand(50, 150),
|
|
appetite: Math.min(5, Math.max(1, rand(2, 4) + breed.appetiteMod)),
|
|
};
|
|
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,
|
|
status: { fullness: 100, happiness: 80, energy: stats.stamina },
|
|
passives,
|
|
assignedTo: null,
|
|
};
|
|
}
|
|
|
|
// ---------- 초기 상태 ----------
|
|
|
|
function createInitialState(): GameState {
|
|
// 새 게임: 회관만 있는 빈 마을에서 촌장 냥이 튜토리얼로 시작
|
|
return {
|
|
version: 1,
|
|
boardVersion: BOARD_VERSION,
|
|
tutorialStep: 0,
|
|
coins: 0,
|
|
resources: { fish: 0, wood: 0, stone: 0 },
|
|
cats: [],
|
|
buildings: [],
|
|
decors: [],
|
|
hall: { level: 1, capacity: 3 },
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
|
|
|
let state: GameState | null = null;
|
|
let offlineMinutes: number | 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;
|
|
}
|
|
|
|
/** 행복도 배율: 80↑ +10%, 50 미만 -30% (시스템 해금 전엔 중립) */
|
|
export function happinessMul(cat: Cat): number {
|
|
if (!state?.happinessUnlocked) return 1;
|
|
const h = cat.status.happiness;
|
|
if (h >= 80) return 1.1;
|
|
if (h < 50) return 0.7;
|
|
return 1;
|
|
}
|
|
|
|
/** 실효 산출량 (배고픔·행복도 반영) */
|
|
export function effectiveRate(cat: Cat, aptitudeKey: AptitudeKey): number {
|
|
return catRate(cat, aptitudeKey) * fullnessPenalty(cat) * happinessMul(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;
|
|
}
|
|
|
|
/** 건설 중인가 */
|
|
export function isUnderConstruction(b: Building): boolean {
|
|
return b.constructionUntil !== undefined && Date.now() < b.constructionUntil;
|
|
}
|
|
|
|
/** 사각 풋프린트 겹침 검사 */
|
|
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,
|
|
i: number,
|
|
j: number,
|
|
excludeId?: string,
|
|
): boolean {
|
|
if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
|
|
const fp = footprintOf(type);
|
|
const rule = PLACEMENT_RULES[type] ?? "land";
|
|
// 풋프린트 전 타일 검사
|
|
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;
|
|
}
|
|
|
|
/** 자원별 보관 상한 (창고로 확장) */
|
|
export function storageCap(s: GameState): number {
|
|
const warehouses = s.buildings.filter(
|
|
(b) => b.type === "warehouse" && !isUnderConstruction(b),
|
|
).length;
|
|
return BASE_STORAGE + warehouses * WAREHOUSE_STORAGE;
|
|
}
|
|
|
|
/** 다음 입양 비용 (처음 2마리 무료) */
|
|
export function adoptCost(s: GameState): number {
|
|
const n = s.cats.length;
|
|
if (n < ADOPT_FREE_CATS) return 0;
|
|
return ADOPT_BASE_COST + (n - ADOPT_FREE_CATS) * ADOPT_COST_PER_CAT;
|
|
}
|
|
|
|
/** 데코를 놓을 수 있는 타일인가 (잔디 위, 건물·나무·물 제외) */
|
|
export function canPlaceDecoAt(s: GameState, i: number, j: number): boolean {
|
|
if (!Number.isInteger(i) || !Number.isInteger(j)) return false;
|
|
if (i < 0 || j < 0 || i >= BOARD_COLS || j >= BOARD_ROWS) return false;
|
|
if (isWaterTile(i)) return false;
|
|
if (TREE_TILES.some((t) => t.i === i && t.j === j)) return false;
|
|
const hallFp = footprintOf("hall");
|
|
if (
|
|
i >= HALL_TILE.i && i < HALL_TILE.i + hallFp.w &&
|
|
j >= HALL_TILE.j && j < HALL_TILE.j + hallFp.h
|
|
)
|
|
return false;
|
|
for (const b of s.buildings) {
|
|
const fp = footprintOf(b.type);
|
|
if (i >= b.pos.i && i < b.pos.i + fp.w && j >= b.pos.j && j < b.pos.j + fp.h)
|
|
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;
|
|
cat.status.energy ??= cat.stats.stamina;
|
|
}
|
|
for (const b of state.buildings)
|
|
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
|
state.decors ??= [];
|
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
|
state.coins ??= 0;
|
|
if (state.happinessUnlocked === undefined) {
|
|
const deckBuilt = state.buildings.some(
|
|
(b) => b.type === "sun_deck" && b.constructionUntil === undefined,
|
|
);
|
|
state.happinessUnlocked = state.hall.level >= 3 && deckBuilt;
|
|
state.happinessIntroSeen = state.happinessUnlocked; // 기존 해금 유저는 안내 생략
|
|
if (!state.happinessUnlocked) {
|
|
// 해금 전 소프트락 방지: 파업 해제 + 행복도 초기화
|
|
for (const c of state.cats) {
|
|
if (c.state === "striking") c.state = null;
|
|
c.status.happiness = 80;
|
|
}
|
|
}
|
|
}
|
|
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
|
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;
|
|
}
|
|
// 오프라인 정산: 최대 8시간, 60초 단위로 시뮬레이션
|
|
const offlineSec = Math.min(
|
|
Math.max((Date.now() - state.updatedAt) / 1000, 0),
|
|
8 * 3600,
|
|
);
|
|
if (offlineSec > 120) {
|
|
const chunks = Math.floor(offlineSec / 60);
|
|
for (let k = 0; k < chunks; k++) this.tick(60);
|
|
offlineMinutes = Math.floor(offlineSec / 60);
|
|
}
|
|
return state;
|
|
},
|
|
|
|
/** 오프라인 정산 리포트 (1회성) */
|
|
consumeOfflineReport(): number | null {
|
|
const r = offlineMinutes;
|
|
offlineMinutes = null;
|
|
return r;
|
|
},
|
|
|
|
/** 행복도 안내 확인 처리 */
|
|
markHappinessIntroSeen() {
|
|
if (!state) return;
|
|
state.happinessIntroSeen = true;
|
|
},
|
|
|
|
/** 튜토리얼 진행 갱신 (단계 진입 시 촌장 냥이의 지원 지급) */
|
|
setTutorialStep(step: number) {
|
|
if (!state) return;
|
|
state.tutorialStep = step;
|
|
// 촌장 냥이의 자원 지원 — 대기 없이 다음 건설로 이어지도록
|
|
if (step === 5) state.resources.fish += 30; // 벌목장 재료
|
|
if (step === 7) state.resources.wood += 60; // 부엌 재료
|
|
if (step === 8) {
|
|
state.resources.wood += 100; // 회관 증축 재료
|
|
state.resources.fish += 80;
|
|
// 특훈: 가장 레벨 높은 친구를 Lv3으로
|
|
const best = [...state.cats].sort((a, b) => b.level - a.level)[0];
|
|
if (best && best.level < 3) {
|
|
best.level = 3;
|
|
best.exp = 0;
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
tick(dt: number) {
|
|
if (!state) return;
|
|
for (const building of state.buildings) {
|
|
// 건설 완료 체크
|
|
if (building.constructionUntil !== undefined) {
|
|
if (Date.now() >= building.constructionUntil) {
|
|
building.constructionUntil = undefined;
|
|
} else {
|
|
continue; // 건설 중 — 생산 없음
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
// 복덩이: 일하는 중 평균 100초에 1냥코인
|
|
if (cat.passives.includes("lucky") && Math.random() < dt / 100)
|
|
state.coins = (state.coins ?? 0) + 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 = Math.min(BUILDING_STORE_CAP, building.stored + produced);
|
|
}
|
|
// 장터: 자동 판매
|
|
if (
|
|
building.type === "market" &&
|
|
building.sellKey &&
|
|
!isUnderConstruction(building)
|
|
) {
|
|
building.progress = (building.progress ?? 0) + MARKET_RATE_PER_SEC * dt;
|
|
while (building.progress >= 1) {
|
|
let sold = false;
|
|
if (building.sellKey === "meals") {
|
|
const kitchen = state.buildings.find(
|
|
(b) => b.type === "kitchen" && b.stored >= 1,
|
|
);
|
|
if (kitchen) {
|
|
kitchen.stored -= 1;
|
|
state.coins =
|
|
(state.coins ?? 0) + (MEAL_PRICES[activeRecipe(state).key] ?? 4);
|
|
sold = true;
|
|
}
|
|
} else {
|
|
const key = building.sellKey as keyof GameState["resources"];
|
|
if (state.resources[key] >= 1) {
|
|
state.resources[key] -= 1;
|
|
state.coins = (state.coins ?? 0) + (SELL_PRICES[building.sellKey] ?? 1);
|
|
sold = true;
|
|
}
|
|
}
|
|
if (!sold) {
|
|
building.progress = 1; // 팔 것이 없으면 대기
|
|
break;
|
|
}
|
|
building.progress -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 포만도 감소 (식욕 × 패시브)
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 행복도 시스템 해금: 회관 Lv3 + 햇볕 데크 완공
|
|
if (
|
|
!state.happinessUnlocked &&
|
|
state.hall.level >= 3 &&
|
|
state.buildings.some(
|
|
(b) => b.type === "sun_deck" && !isUnderConstruction(b),
|
|
)
|
|
) {
|
|
state.happinessUnlocked = true;
|
|
for (const c of state.cats) c.status.happiness = 80; // 산뜻하게 시작
|
|
}
|
|
|
|
// ---------- 체력·휴식·행복도 (Phase B) ----------
|
|
const houses = state.buildings.filter(
|
|
(b) => b.type === "cat_house" && !isUnderConstruction(b),
|
|
);
|
|
const houseCapacity = houses.length * CAT_HOUSE_CAPACITY;
|
|
const hasDeck = state.buildings.some(
|
|
(b) => b.type === "sun_deck" && !isUnderConstruction(b),
|
|
);
|
|
const gainMul = hasDeck ? 1.5 : 1;
|
|
const restingCats = state.cats.filter((c) => c.state === "resting");
|
|
|
|
const tryReturn = (cat: Cat) => {
|
|
// 휴식·파업 종료 → 이전 일터로 자동 복귀 (자리가 남아 있으면)
|
|
const target = cat.returnTo
|
|
? state!.buildings.find((b) => b.id === cat.returnTo)
|
|
: undefined;
|
|
cat.state = null;
|
|
cat.returnTo = null;
|
|
if (
|
|
target &&
|
|
!isUnderConstruction(target) &&
|
|
target.assigned.length < target.maxSlots &&
|
|
cat.aptitude[target.aptitude] > 0
|
|
) {
|
|
target.assigned.push(cat.id);
|
|
cat.assignedTo = target.id;
|
|
}
|
|
};
|
|
|
|
for (const cat of state.cats) {
|
|
const st = cat.status;
|
|
const decayMul =
|
|
(cat.passives.includes("optimist") ? 0.6 : 1) *
|
|
(cat.passives.includes("picky") ? 1.5 : 1) *
|
|
(cat.passives.includes("workaholic") ? 1.4 : 1);
|
|
const sturdyMul = cat.passives.includes("sturdy") ? 0.7 : 1;
|
|
|
|
// 체력
|
|
if (cat.state === "resting") {
|
|
const idx = restingCats.indexOf(cat);
|
|
const inHouse = houses.length > 0 && idx >= 0 && idx < houseCapacity;
|
|
const recover = inHouse ? HOUSE_RECOVER_PER_SEC : REST_RECOVER_PER_SEC;
|
|
st.energy = Math.min(cat.stats.stamina, st.energy + recover * dt);
|
|
if (state.happinessUnlocked)
|
|
st.happiness = Math.min(100, st.happiness + (3 / 60) * gainMul * dt);
|
|
if (st.energy >= cat.stats.stamina) tryReturn(cat);
|
|
} else if (cat.assignedTo) {
|
|
st.energy = Math.max(0, st.energy - ENERGY_DRAIN_PER_SEC * sturdyMul * dt);
|
|
if (st.energy <= 0) {
|
|
// 강제 휴식: 배치 해제 후 회복
|
|
cat.returnTo = cat.assignedTo;
|
|
for (const b of state.buildings)
|
|
b.assigned = b.assigned.filter((id) => id !== cat.id);
|
|
cat.assignedTo = null;
|
|
cat.state = "resting";
|
|
}
|
|
} else {
|
|
st.energy = Math.min(
|
|
cat.stats.stamina,
|
|
st.energy + IDLE_RECOVER_PER_SEC * dt,
|
|
);
|
|
}
|
|
|
|
// 행복도 증감 (시스템 해금 후에만)
|
|
if (state.happinessUnlocked) {
|
|
if (st.fullness >= 80)
|
|
st.happiness = Math.min(100, st.happiness + (1 / 60) * gainMul * dt);
|
|
if (st.fullness <= 0)
|
|
st.happiness = Math.max(0, st.happiness - (4 / 60) * decayMul * dt);
|
|
// 고양이 집 부족: 하락하되 이 요인만으로는 파업까지 가지 않도록 바닥 25 유지
|
|
if (
|
|
state.hall.level >= 3 &&
|
|
state.cats.length > houseCapacity &&
|
|
st.happiness > 25
|
|
)
|
|
st.happiness = Math.max(25, st.happiness - (2 / 60) * decayMul * dt);
|
|
// 인기쟁이: 같은 건물 동료 행복도 상승
|
|
if (cat.assignedTo) {
|
|
const mates = state.cats.filter(
|
|
(c) =>
|
|
c.id !== cat.id &&
|
|
c.assignedTo === cat.assignedTo &&
|
|
c.passives.includes("popular"),
|
|
);
|
|
if (mates.length > 0)
|
|
st.happiness = Math.min(100, st.happiness + (0.5 / 60) * dt);
|
|
}
|
|
|
|
// 파업
|
|
if (
|
|
cat.state !== "striking" &&
|
|
cat.state !== "resting" &&
|
|
st.happiness < STRIKE_THRESHOLD
|
|
) {
|
|
cat.returnTo = cat.assignedTo;
|
|
for (const b of state.buildings)
|
|
b.assigned = b.assigned.filter((id) => id !== cat.id);
|
|
cat.assignedTo = null;
|
|
cat.state = "striking";
|
|
}
|
|
if (cat.state === "striking" && st.happiness >= STRIKE_RESUME)
|
|
tryReturn(cat);
|
|
} // happinessUnlocked
|
|
}
|
|
|
|
state.updatedAt = Date.now();
|
|
},
|
|
|
|
/** 입양 후보 생성 (아직 마을에 합류하지 않음). forceApt로 특정 적성 보장 */
|
|
generateCandidates(count = 3, forceApt?: AptitudeKey): Cat[] {
|
|
return Array.from({ length: count }, () => generateCat(forceApt));
|
|
},
|
|
|
|
/** 후보 중 한 마리 입양 확정 (3마리째부터 냥코인 소모) */
|
|
adoptCandidate(cat: Cat): string | null {
|
|
if (!state) return null;
|
|
if (state.cats.length >= state.hall.capacity)
|
|
return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
|
|
const cost = adoptCost(state);
|
|
if ((state.coins ?? 0) < cost)
|
|
return `냥코인이 부족합니다 (필요: 🪙${cost})`;
|
|
state.coins = (state.coins ?? 0) - cost;
|
|
state.cats.push(cat);
|
|
return null;
|
|
},
|
|
|
|
/** 입양 재추첨 비용 지불 */
|
|
payReroll(): string | null {
|
|
if (!state) return null;
|
|
if ((state.coins ?? 0) < REROLL_COST)
|
|
return `냥코인이 부족합니다 (필요: 🪙${REROLL_COST})`;
|
|
state.coins = (state.coins ?? 0) - REROLL_COST;
|
|
return null;
|
|
},
|
|
|
|
/** 즉시 완공 (남은 10초당 1냥코인) */
|
|
rushConstruction(buildingId: string): string | null {
|
|
if (!state) return null;
|
|
const b = state.buildings.find((x) => x.id === buildingId);
|
|
if (!b || b.constructionUntil === undefined) return null;
|
|
const remain = Math.max(0, b.constructionUntil - Date.now());
|
|
const cost = Math.max(1, Math.ceil(remain / 10000) * RUSH_COST_PER_10S);
|
|
if ((state.coins ?? 0) < cost)
|
|
return `냥코인이 부족합니다 (필요: 🪙${cost})`;
|
|
state.coins = (state.coins ?? 0) - cost;
|
|
b.constructionUntil = Date.now() - 1;
|
|
return null;
|
|
},
|
|
|
|
/** 장터 판매 품목 설정 */
|
|
setSellKey(marketId: string, key: string | null): string | null {
|
|
if (!state) return null;
|
|
const m = state.buildings.find((b) => b.id === marketId);
|
|
if (!m || m.type !== "market") return null;
|
|
m.sellKey = key;
|
|
m.progress = 0;
|
|
return null;
|
|
},
|
|
|
|
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
|
build(
|
|
type: BuildingType,
|
|
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,
|
|
constructionUntil: Date.now() + def.buildTime * 1000,
|
|
});
|
|
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;
|
|
},
|
|
|
|
/** 꾸미기 배치 (같은 타일의 기존 장식은 교체) */
|
|
placeDeco(kind: string, pos: { i: number; j: number }): string | null {
|
|
if (!state) return null;
|
|
const def = DECO_DEFS.find((d) => d.key === kind);
|
|
if (!def) return null;
|
|
if (!canPlaceDecoAt(state, 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.decors ??= [];
|
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
|
state.coins ??= 0;
|
|
if (state.happinessUnlocked === undefined) {
|
|
const deckBuilt = state.buildings.some(
|
|
(b) => b.type === "sun_deck" && b.constructionUntil === undefined,
|
|
);
|
|
state.happinessUnlocked = state.hall.level >= 3 && deckBuilt;
|
|
state.happinessIntroSeen = state.happinessUnlocked; // 기존 해금 유저는 안내 생략
|
|
if (!state.happinessUnlocked) {
|
|
// 해금 전 소프트락 방지: 파업 해제 + 행복도 초기화
|
|
for (const c of state.cats) {
|
|
if (c.state === "striking") c.state = null;
|
|
c.status.happiness = 80;
|
|
}
|
|
}
|
|
}
|
|
// 같은 타일의 기존 장식 제거 후 배치
|
|
state.decors = state.decors.filter(
|
|
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
|
);
|
|
state.decors.push({ id: nextId("deco"), kind, pos: { ...pos } });
|
|
return null;
|
|
},
|
|
|
|
/** 꾸미기 제거 (환불 없음) */
|
|
removeDeco(id: string): string | null {
|
|
if (!state?.decors) return null;
|
|
state.decors = state.decors.filter((d) => d.id !== id);
|
|
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 || isUnderConstruction(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 (isUnderConstruction(building))
|
|
return `${building.name}은(는) 아직 건설 중입니다`;
|
|
if (cat.state === "striking")
|
|
return `${cat.name}은(는) 파업 중이에요 (행복도를 올려주세요)`;
|
|
if (building.maxSlots === 0) return `${building.name}에는 배치할 수 없습니다`;
|
|
if (cat.state === "resting") {
|
|
cat.state = null;
|
|
cat.returnTo = 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): string | null {
|
|
if (!state) return null;
|
|
const building = state.buildings.find((b) => b.id === buildingId);
|
|
if (!building || !building.resource) return null; // 부엌은 회수 대상 아님
|
|
const cap = storageCap(state);
|
|
const space = Math.max(0, cap - Math.floor(state.resources[building.resource]));
|
|
const amount = Math.min(Math.floor(building.stored), space);
|
|
if (amount <= 0)
|
|
return space <= 0 ? "창고가 가득 찼어요! 장터에서 팔거나 창고를 지어주세요" : null;
|
|
building.stored -= amount;
|
|
state.resources[building.resource] += amount;
|
|
return null;
|
|
},
|
|
|
|
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;
|
|
},
|
|
};
|