Phase B: 체력·강제 휴식·행복도·파업·고양이 집·햇볕 데크·오프라인 정산
- 체력(energy): 작업 중 분당 -2(튼튼함 -30%), 소진 시 강제 휴식 — 배치 해제 후 수면 포즈로 회복(분당 +20, 고양이 집 +30), 완충 시 자동 복귀 - 행복도: 포만 80↑ +1/분, 굶주림 -4/분, 고양이 집 부족 -2/분(바닥 25), 휴식 +3/분, 햇볕 데크 회복 1.5배, 인기쟁이 동료 버프 효과: 80↑ 속도 +10%, 50 미만 -30%, 20 미만 파업(배치 해제·재배치 불가) - 파업: 💢 배회, 행복도 40 회복 시 이전 일터로 자동 복귀 - 새 패시브: 낙천적(-40%)·까다로움(+50%)·인기쟁이 / 튼튼함·일중독 실효과화 - 새 건물: 고양이 집(정원 4, 목재100+돌20)·햇볕 데크(목재120+돌40) — 배치 없는 패시브 건물(전용 패널), 회관 Lv3 해금, 이모지 렌더(일러스트 대기) - 오프라인 정산: 최대 8시간을 60초 단위 시뮬레이션, 복귀 시 토스트 - UI: 고양이 카드에 체력(⚡)·행복도(💗) 바, 휴식/파업 상태 표시 - 뷰: 휴식 시 고양이 집으로 이동 후 수면 포즈+💤, 파업 시 💢 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+159
-10
@@ -3,6 +3,13 @@ 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,
|
||||
@@ -74,6 +81,11 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
|
||||
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)],
|
||||
@@ -84,12 +96,8 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
|
||||
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 },
|
||||
stats,
|
||||
status: { fullness: 100, happiness: 80, energy: stats.stamina },
|
||||
passives,
|
||||
assignedTo: null,
|
||||
};
|
||||
@@ -129,6 +137,7 @@ function createInitialState(): GameState {
|
||||
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
||||
|
||||
let state: GameState | null = null;
|
||||
let offlineMinutes: number | null = null;
|
||||
|
||||
function passiveSpeedMul(cat: Cat): number {
|
||||
return cat.passives.reduce((mul, key) => {
|
||||
@@ -150,9 +159,17 @@ export function fullnessPenalty(cat: Cat): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** 실효 산출량 (배고픔 페널티 반영) */
|
||||
/** 행복도 배율: 80↑ +10%, 20~49 -30% */
|
||||
export function happinessMul(cat: Cat): number {
|
||||
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);
|
||||
return catRate(cat, aptitudeKey) * fullnessPenalty(cat) * happinessMul(cat);
|
||||
}
|
||||
|
||||
function passiveFoodMul(cat: Cat): number {
|
||||
@@ -246,7 +263,10 @@ export const engine = {
|
||||
if (!state) state = createInitialState();
|
||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||
state.resources.stone ??= 0;
|
||||
for (const cat of state.cats) cat.aptitudePoints ??= 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 }) };
|
||||
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
||||
@@ -255,9 +275,26 @@ export const engine = {
|
||||
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;
|
||||
},
|
||||
|
||||
tick(dt: number) {
|
||||
if (!state) return;
|
||||
for (const building of state.buildings) {
|
||||
@@ -325,6 +362,108 @@ export const engine = {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 체력·휴식·행복도 (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);
|
||||
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 (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);
|
||||
}
|
||||
|
||||
state.updatedAt = Date.now();
|
||||
},
|
||||
|
||||
@@ -343,7 +482,10 @@ export const engine = {
|
||||
},
|
||||
|
||||
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
||||
build(type: "quarry" | "kitchen", pos: { i: number; j: number }): string | null {
|
||||
build(
|
||||
type: "quarry" | "kitchen" | "cat_house" | "sun_deck",
|
||||
pos: { i: number; j: number },
|
||||
): string | null {
|
||||
if (!state) return null;
|
||||
const def = BUILD_DEFS.find((d) => d.type === type);
|
||||
if (!def) return null;
|
||||
@@ -451,6 +593,13 @@ export const engine = {
|
||||
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)
|
||||
|
||||
새 이슈에서 참조
사용자 차단