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>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 16:20:37 +09:00
co-authored by Claude Fable 5
부모 c50e12400b
커밋 1ff8d0b8ca
5개의 변경된 파일과 355개의 추가작업 그리고 45개의 파일을 삭제
+104 -9
파일 보기
@@ -57,6 +57,20 @@ export default function Game() {
if (msgTimer.current) clearTimeout(msgTimer.current);
}, []);
// 오프라인 정산 알림 (1회)
const offlineShown = useRef(false);
useEffect(() => {
if (!state || offlineShown.current) return;
offlineShown.current = true;
const mins = engine.consumeOfflineReport();
if (mins) {
setMessage(
`🌙 ${mins >= 60 ? `${Math.floor(mins / 60)}시간 ${mins % 60}분` : `${mins}분`} 동안 자리를 비웠어요 — 마을은 계속 돌아갔답니다!`,
);
msgTimer.current = setTimeout(() => setMessage(null), 6000);
}
}, [state]);
if (!state) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-[#f6eeda] font-jua text-lg text-[#8a7551]">
@@ -85,7 +99,7 @@ export default function Game() {
if (!placing) return;
const error = placing.moveId
? run(() => engine.moveBuilding(placing.moveId!, { i, j }))
: run(() => engine.build(placing.type as "quarry" | "kitchen", { i, j }));
: run(() => engine.build(placing.type as BuildDef["type"], { i, j }));
if (!error) setPlacing(null);
}}
onCancelPlace={() => setPlacing(null)}
@@ -200,8 +214,12 @@ export default function Game() {
)}
{openedBuilding && (
<SidePanel
title={`${openedBuilding.name} Lv${openedBuilding.level}`}
icon={BUILDING_IMG[openedBuilding.type] ?? TOOL_IMG[openedBuilding.aptitude]}
title={
BUILDING_IMG[openedBuilding.type]
? `${openedBuilding.name} Lv${openedBuilding.level}`
: `${openedBuilding.emoji} ${openedBuilding.name} Lv${openedBuilding.level}`
}
icon={BUILDING_IMG[openedBuilding.type]}
onClose={() => setPanel(null)}
>
<BuildingPanel
@@ -400,11 +418,18 @@ function ShopPanel({
className="card-nature flex items-center gap-3 p-3"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={BUILDING_IMG[def.type] ?? TOOL_IMG[def.aptitude]}
alt={def.name}
className={`h-14 w-14 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
/>
{BUILDING_IMG[def.type] ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={BUILDING_IMG[def.type]}
alt={def.name}
className={`h-14 w-14 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
/>
) : (
<span className={`grid h-14 w-14 shrink-0 place-items-center text-4xl ${built || !hallOk ? "opacity-40" : ""}`}>
{def.emoji}
</span>
)}
<div className="min-w-0 flex-1">
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
<div className="font-num text-xs text-[#8a6316]">
@@ -499,6 +524,50 @@ function BuildingPanel({
);
}
if (building.maxSlots === 0) {
// 패시브 건물 — 배치 없음
const restingHere = state.cats.filter((c) => c.state === "resting");
return (
<div className="space-y-4">
<p className="text-sm text-[#6f5c39]">
{BUILD_DEFS.find((d) => d.type === building.type)?.desc}
</p>
<div className="flex items-center justify-end">
<button
onClick={onMove}
className="wood-plank shrink-0 px-2.5 py-1 text-xs hover:brightness-110"
>
📍 이동
</button>
</div>
{building.type === "cat_house" && (
<section>
<h3 className="font-jua mb-2 text-sm text-[#8a7551]">
💤 쉬고 있는 고양이 ({restingHere.length})
</h3>
{restingHere.length === 0 && (
<p className="text-xs text-[#8a7551]">지금은 모두 기운이 넘쳐요!</p>
)}
<div className="space-y-2">
{restingHere.map((cat) => (
<div key={cat.id} className="card-nature flex items-center gap-3 px-3 py-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={catSprite(cat.breed, "sleep")} alt="" className="h-10 w-10 shrink-0 object-contain" />
<div className="min-w-0 flex-1">
<div className="font-jua truncate text-sm text-[#3f2c14]">{cat.name}</div>
<div className="font-num text-xs text-[#8a7551]">
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
</div>
</div>
</div>
))}
</div>
</section>
)}
</div>
);
}
return (
<div className="space-y-5">
{/* 정보 */}
@@ -807,6 +876,28 @@ function CatCard({
<span className="font-num text-[11px] text-[#8a7a58]">
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="bar-track h-2 flex-1">
<div
className="bar-fill-amber"
style={{ width: `${(cat.status.energy / cat.stats.stamina) * 100}%` }}
/>
</div>
<span className="font-num text-[11px] text-[#8a7a58]">
⚡ {Math.floor(cat.status.energy)}/{cat.stats.stamina}
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="bar-track h-2 flex-1">
<div
className={cat.status.happiness < 20 ? "bar-fill-red" : "bar-fill-green"}
style={{ width: `${cat.status.happiness}%`, background: cat.status.happiness >= 20 ? "linear-gradient(90deg,#e88bb8,#f7b7d4)" : undefined }}
/>
</div>
<span className="font-num text-[11px] text-[#8a7a58]">
💗 {Math.floor(cat.status.happiness)}
</span>
{fullnessPenalty(cat) < 1 && (
<span className="rounded-full bg-[rgba(214,106,74,.16)] px-2 py-0.5 text-[11px] text-[#b0472a]">
배고픔
@@ -823,7 +914,11 @@ function CatCard({
)}
<p className="mt-2 text-xs">
{workplace ? (
{cat.state === "resting" ? (
<span className="text-[#8a6316]">💤 지쳐서 휴식 중 (회복되면 복귀)</span>
) : cat.state === "striking" ? (
<span className="text-[#b0472a]">💢 파업 중! 행복도를 올려주세요</span>
) : workplace ? (
<span className="text-[#5c8a2a]">
{workplace.emoji} {workplace.name}에서 일하는 중
</span>