행복도 시스템 해금제: 회관 Lv3 + 햇볕 데크 설치 시 개방 (소프트락 수정)
- 해금 전: 행복도 증감·파업 로직 완전 비활성, 속도 배율 중립, UI에서 💗 숨김 → 초반에 행복도를 올릴 수단이 없어 파업 소프트락에 걸리던 문제 해결 - 해금 조건 달성 시 촌장 냥이가 재등장해 행복도 시스템 안내 (1회) - 해금 순간 전원 행복도 80으로 초기화 - 기존 세이브 마이그레이션: 조건 미충족이면 파업 해제 + 행복도 리셋, 이미 충족한 마을은 안내 생략 - 햇볕 데크 설명에 해금 역할 명시 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -369,6 +369,36 @@ export default function Game() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* ── 촌장 냥이: 행복도 시스템 안내 (해금 시 1회) ── */}
|
||||||
|
{state.happinessUnlocked &&
|
||||||
|
!state.happinessIntroSeen &&
|
||||||
|
(state.tutorialStep ?? TUTORIAL_DONE) >= TUTORIAL_STEPS.length && (
|
||||||
|
<div className="pointer-events-none absolute bottom-8 left-4 z-40 flex max-w-md items-end gap-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={catSprite("korean_shorthair", "idle")}
|
||||||
|
alt="촌장 냥이"
|
||||||
|
className="h-24 w-24 shrink-0 object-contain drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="panel-paper pointer-events-auto relative mb-2 rounded-2xl p-4 shadow-xl">
|
||||||
|
<div className="font-jua text-[13px] text-[#8a6316]">🎗️ 촌장 냥이</div>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-[#3f2c14]">
|
||||||
|
오랜만이야! 마을이 근사해졌네. 햇볕 데크도 생겼으니 이제
|
||||||
|
친구들의 <b>💗 행복도</b>를 챙길 때야. 배부르게 먹고 푹 쉬면
|
||||||
|
행복해지고, 굶거나 고양이 집이 모자라면 시무룩해져. 행복도가
|
||||||
|
너무 낮으면 <b>파업</b>할 수도 있으니 잘 돌봐줘! 80 이상이면
|
||||||
|
일도 10% 빨라진단다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(() => engine.markHappinessIntroSeen())}
|
||||||
|
className="nature-btn mt-3 block px-5 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
알겠어, 잘 돌볼게! 💗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── DB 상태 (좌하단 미니) ── */}
|
{/* ── DB 상태 (좌하단 미니) ── */}
|
||||||
<div className="absolute bottom-2 left-3 z-30 opacity-70">
|
<div className="absolute bottom-2 left-3 z-30 opacity-70">
|
||||||
<DbStatus />
|
<DbStatus />
|
||||||
@@ -464,6 +494,7 @@ export default function Game() {
|
|||||||
key={cat.id}
|
key={cat.id}
|
||||||
cat={cat}
|
cat={cat}
|
||||||
buildings={state.buildings}
|
buildings={state.buildings}
|
||||||
|
happinessUnlocked={state.happinessUnlocked}
|
||||||
onInvest={(catId, key) => run(() => engine.investAptitude(catId, key))}
|
onInvest={(catId, key) => run(() => engine.investAptitude(catId, key))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1147,10 +1178,12 @@ function CatCard({
|
|||||||
cat,
|
cat,
|
||||||
buildings,
|
buildings,
|
||||||
onInvest,
|
onInvest,
|
||||||
|
happinessUnlocked,
|
||||||
}: {
|
}: {
|
||||||
cat: Cat;
|
cat: Cat;
|
||||||
buildings: Building[];
|
buildings: Building[];
|
||||||
onInvest: (catId: string, key: AptitudeKey) => void;
|
onInvest: (catId: string, key: AptitudeKey) => void;
|
||||||
|
happinessUnlocked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const workplace = buildings.find((b) => b.id === cat.assignedTo);
|
const workplace = buildings.find((b) => b.id === cat.assignedTo);
|
||||||
const expNeeded = cat.level * 90;
|
const expNeeded = cat.level * 90;
|
||||||
@@ -1232,6 +1265,7 @@ function CatCard({
|
|||||||
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
|
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{happinessUnlocked && (
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
<div className="bar-track h-2 flex-1">
|
<div className="bar-track h-2 flex-1">
|
||||||
<div
|
<div
|
||||||
@@ -1248,6 +1282,7 @@ function CatCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{cat.passives.length > 0 && (
|
{cat.passives.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ export const BUILD_DEFS: BuildDef[] = [
|
|||||||
{
|
{
|
||||||
id: "sun_deck_1", type: "sun_deck", name: "햇볕 데크", emoji: "🌞", kind: "passive",
|
id: "sun_deck_1", type: "sun_deck", name: "햇볕 데크", emoji: "🌞", kind: "passive",
|
||||||
aptitude: "grooming", maxSlots: 0, cost: { wood: 120, stone: 40 }, reqHallLv: 3, buildTime: 90,
|
aptitude: "grooming", maxSlots: 0, cost: { wood: 120, stone: 40 }, reqHallLv: 3, buildTime: 90,
|
||||||
desc: "따끈한 햇살 아래 뒹굴뒹굴. 마을 전체의 행복도 회복이 1.5배 빨라집니다.",
|
desc: "따끈한 햇살 아래 뒹굴뒹굴. 설치하면 마을에 💗 행복도가 눈뜨고, 회복 속도도 1.5배!",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "market_1", type: "market", name: "장터", emoji: "🏪", kind: "passive",
|
id: "market_1", type: "market", name: "장터", emoji: "🏪", kind: "passive",
|
||||||
|
|||||||
+53
-3
@@ -158,8 +158,9 @@ export function fullnessPenalty(cat: Cat): number {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 행복도 배율: 80↑ +10%, 20~49 -30% */
|
/** 행복도 배율: 80↑ +10%, 50 미만 -30% (시스템 해금 전엔 중립) */
|
||||||
export function happinessMul(cat: Cat): number {
|
export function happinessMul(cat: Cat): number {
|
||||||
|
if (!state?.happinessUnlocked) return 1;
|
||||||
const h = cat.status.happiness;
|
const h = cat.status.happiness;
|
||||||
if (h >= 80) return 1.1;
|
if (h >= 80) return 1.1;
|
||||||
if (h < 50) return 0.7;
|
if (h < 50) return 0.7;
|
||||||
@@ -306,6 +307,20 @@ export const engine = {
|
|||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
state.coins ??= 0;
|
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) {
|
if (state.boardVersion !== BOARD_VERSION) {
|
||||||
for (const b of state.buildings)
|
for (const b of state.buildings)
|
||||||
@@ -332,6 +347,12 @@ export const engine = {
|
|||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 행복도 안내 확인 처리 */
|
||||||
|
markHappinessIntroSeen() {
|
||||||
|
if (!state) return;
|
||||||
|
state.happinessIntroSeen = true;
|
||||||
|
},
|
||||||
|
|
||||||
/** 튜토리얼 진행 갱신 (단계 진입 시 촌장 냥이의 지원 지급) */
|
/** 튜토리얼 진행 갱신 (단계 진입 시 촌장 냥이의 지원 지급) */
|
||||||
setTutorialStep(step: number) {
|
setTutorialStep(step: number) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
@@ -457,6 +478,18 @@ export const engine = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 행복도 시스템 해금: 회관 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) ----------
|
// ---------- 체력·휴식·행복도 (Phase B) ----------
|
||||||
const houses = state.buildings.filter(
|
const houses = state.buildings.filter(
|
||||||
(b) => b.type === "cat_house" && !isUnderConstruction(b),
|
(b) => b.type === "cat_house" && !isUnderConstruction(b),
|
||||||
@@ -500,7 +533,8 @@ export const engine = {
|
|||||||
const inHouse = houses.length > 0 && idx >= 0 && idx < houseCapacity;
|
const inHouse = houses.length > 0 && idx >= 0 && idx < houseCapacity;
|
||||||
const recover = inHouse ? HOUSE_RECOVER_PER_SEC : REST_RECOVER_PER_SEC;
|
const recover = inHouse ? HOUSE_RECOVER_PER_SEC : REST_RECOVER_PER_SEC;
|
||||||
st.energy = Math.min(cat.stats.stamina, st.energy + recover * dt);
|
st.energy = Math.min(cat.stats.stamina, st.energy + recover * dt);
|
||||||
st.happiness = Math.min(100, st.happiness + (3 / 60) * gainMul * dt);
|
if (state.happinessUnlocked)
|
||||||
|
st.happiness = Math.min(100, st.happiness + (3 / 60) * gainMul * dt);
|
||||||
if (st.energy >= cat.stats.stamina) tryReturn(cat);
|
if (st.energy >= cat.stats.stamina) tryReturn(cat);
|
||||||
} else if (cat.assignedTo) {
|
} else if (cat.assignedTo) {
|
||||||
st.energy = Math.max(0, st.energy - ENERGY_DRAIN_PER_SEC * sturdyMul * dt);
|
st.energy = Math.max(0, st.energy - ENERGY_DRAIN_PER_SEC * sturdyMul * dt);
|
||||||
@@ -519,7 +553,8 @@ export const engine = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 행복도 증감
|
// 행복도 증감 (시스템 해금 후에만)
|
||||||
|
if (state.happinessUnlocked) {
|
||||||
if (st.fullness >= 80)
|
if (st.fullness >= 80)
|
||||||
st.happiness = Math.min(100, st.happiness + (1 / 60) * gainMul * dt);
|
st.happiness = Math.min(100, st.happiness + (1 / 60) * gainMul * dt);
|
||||||
if (st.fullness <= 0)
|
if (st.fullness <= 0)
|
||||||
@@ -557,6 +592,7 @@ export const engine = {
|
|||||||
}
|
}
|
||||||
if (cat.state === "striking" && st.happiness >= STRIKE_RESUME)
|
if (cat.state === "striking" && st.happiness >= STRIKE_RESUME)
|
||||||
tryReturn(cat);
|
tryReturn(cat);
|
||||||
|
} // happinessUnlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
state.updatedAt = Date.now();
|
state.updatedAt = Date.now();
|
||||||
@@ -675,6 +711,20 @@ export const engine = {
|
|||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
state.coins ??= 0;
|
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(
|
state.decors = state.decors.filter(
|
||||||
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface GameState {
|
|||||||
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
||||||
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
||||||
coins?: number; // 냥코인
|
coins?: number; // 냥코인
|
||||||
|
happinessUnlocked?: boolean; // 행복도 시스템 해금 (회관 Lv3 + 햇볕 데크)
|
||||||
|
happinessIntroSeen?: boolean; // 촌장 냥이 행복도 안내를 봤는가
|
||||||
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
||||||
resources: Record<ResourceKey, number>;
|
resources: Record<ResourceKey, number>;
|
||||||
cats: Cat[];
|
cats: Cat[];
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단