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>
이 커밋은 다음에 포함됨:
+100
-5
@@ -57,6 +57,20 @@ export default function Game() {
|
|||||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
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) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-[#f6eeda] font-jua text-lg text-[#8a7551]">
|
<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;
|
if (!placing) return;
|
||||||
const error = placing.moveId
|
const error = placing.moveId
|
||||||
? run(() => engine.moveBuilding(placing.moveId!, { i, j }))
|
? 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);
|
if (!error) setPlacing(null);
|
||||||
}}
|
}}
|
||||||
onCancelPlace={() => setPlacing(null)}
|
onCancelPlace={() => setPlacing(null)}
|
||||||
@@ -200,8 +214,12 @@ export default function Game() {
|
|||||||
)}
|
)}
|
||||||
{openedBuilding && (
|
{openedBuilding && (
|
||||||
<SidePanel
|
<SidePanel
|
||||||
title={`${openedBuilding.name} Lv${openedBuilding.level}`}
|
title={
|
||||||
icon={BUILDING_IMG[openedBuilding.type] ?? TOOL_IMG[openedBuilding.aptitude]}
|
BUILDING_IMG[openedBuilding.type]
|
||||||
|
? `${openedBuilding.name} Lv${openedBuilding.level}`
|
||||||
|
: `${openedBuilding.emoji} ${openedBuilding.name} Lv${openedBuilding.level}`
|
||||||
|
}
|
||||||
|
icon={BUILDING_IMG[openedBuilding.type]}
|
||||||
onClose={() => setPanel(null)}
|
onClose={() => setPanel(null)}
|
||||||
>
|
>
|
||||||
<BuildingPanel
|
<BuildingPanel
|
||||||
@@ -400,11 +418,18 @@ function ShopPanel({
|
|||||||
className="card-nature flex items-center gap-3 p-3"
|
className="card-nature flex items-center gap-3 p-3"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{BUILDING_IMG[def.type] ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={BUILDING_IMG[def.type] ?? TOOL_IMG[def.aptitude]}
|
src={BUILDING_IMG[def.type]}
|
||||||
alt={def.name}
|
alt={def.name}
|
||||||
className={`h-14 w-14 shrink-0 object-contain ${built || !hallOk ? "opacity-40" : ""}`}
|
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="min-w-0 flex-1">
|
||||||
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
||||||
<div className="font-num text-xs text-[#8a6316]">
|
<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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* 정보 */}
|
{/* 정보 */}
|
||||||
@@ -807,6 +876,28 @@ function CatCard({
|
|||||||
<span className="font-num text-[11px] text-[#8a7a58]">
|
<span className="font-num text-[11px] text-[#8a7a58]">
|
||||||
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
|
🍽️ {Math.floor(cat.status.fullness)} (식욕 {cat.stats.appetite})
|
||||||
</span>
|
</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 && (
|
{fullnessPenalty(cat) < 1 && (
|
||||||
<span className="rounded-full bg-[rgba(214,106,74,.16)] px-2 py-0.5 text-[11px] text-[#b0472a]">
|
<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">
|
<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]">
|
<span className="text-[#5c8a2a]">
|
||||||
{workplace.emoji} {workplace.name}에서 일하는 중
|
{workplace.emoji} {workplace.name}에서 일하는 중
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -303,7 +303,40 @@ export default function VillageView({
|
|||||||
}
|
}
|
||||||
anim.lastFullness = cat.status.fullness;
|
anim.lastFullness = cat.status.fullness;
|
||||||
|
|
||||||
if (cat.assignedTo) {
|
if (cat.state === "resting") {
|
||||||
|
// 고양이 집이 있으면 그 곁에서, 없으면 그 자리에서 잠
|
||||||
|
const house = state.buildings.find(
|
||||||
|
(b) => b.type === "cat_house" && b.constructionUntil === undefined,
|
||||||
|
);
|
||||||
|
if (house) {
|
||||||
|
const spot = workSpot(house, 0);
|
||||||
|
if (Math.hypot(anim.tx - spot.x, anim.ty - spot.y) > 4) {
|
||||||
|
anim.tx = spot.x;
|
||||||
|
anim.ty = spot.y;
|
||||||
|
anim.mode = "toWork";
|
||||||
|
}
|
||||||
|
} else if (anim.mode !== "working") {
|
||||||
|
anim.tx = anim.x;
|
||||||
|
anim.ty = anim.y;
|
||||||
|
anim.mode = "working"; // 제자리 수면 (working 모드 재활용: 이동 정지)
|
||||||
|
}
|
||||||
|
anim.workBuildingId = null;
|
||||||
|
// 💤 버블
|
||||||
|
if (Math.random() < dt / 4) {
|
||||||
|
bubblesRef.current.push({ x: anim.x + 10, y: anim.y - 36, born: now, text: "💤" });
|
||||||
|
}
|
||||||
|
} else if (cat.state === "striking") {
|
||||||
|
anim.workBuildingId = null;
|
||||||
|
if (anim.mode !== "wander") {
|
||||||
|
anim.mode = "wander";
|
||||||
|
const t = randomWalkTarget(state);
|
||||||
|
anim.tx = t.x;
|
||||||
|
anim.ty = t.y;
|
||||||
|
}
|
||||||
|
if (Math.random() < dt / 5) {
|
||||||
|
bubblesRef.current.push({ x: anim.x + 10, y: anim.y - 36, born: now, text: "💢" });
|
||||||
|
}
|
||||||
|
} else if (cat.assignedTo) {
|
||||||
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
||||||
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
||||||
const spot = workSpot(building, Math.max(0, slot));
|
const spot = workSpot(building, Math.max(0, slot));
|
||||||
@@ -340,7 +373,7 @@ export default function VillageView({
|
|||||||
anim.mode = "working";
|
anim.mode = "working";
|
||||||
} else if (anim.mode === "working") {
|
} else if (anim.mode === "working") {
|
||||||
// 일하는 중 — 생산 말풍선 (약 2.5초에 한 번)
|
// 일하는 중 — 생산 말풍선 (약 2.5초에 한 번)
|
||||||
if (Math.random() < dt / 2.5 && cat.assignedTo) {
|
if (Math.random() < dt / 2.5 && cat.assignedTo && !cat.state) {
|
||||||
const b = state.buildings.find((x) => x.id === cat.assignedTo);
|
const b = state.buildings.find((x) => x.id === cat.assignedTo);
|
||||||
if (b && catRate(cat, b.aptitude) > 0) {
|
if (b && catRate(cat, b.aptitude) > 0) {
|
||||||
bubblesRef.current.push({
|
bubblesRef.current.push({
|
||||||
@@ -599,21 +632,16 @@ export default function VillageView({
|
|||||||
ctx.fillText(`${b.name} 건설 중`, x, baseY + 17);
|
ctx.fillText(`${b.name} 건설 중`, x, baseY + 17);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bImg = getImage(BUILDING_IMG[b.type]);
|
const bImg = BUILDING_IMG[b.type]
|
||||||
|
? getImage(BUILDING_IMG[b.type])
|
||||||
|
: undefined;
|
||||||
if (bImg) {
|
if (bImg) {
|
||||||
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
|
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
|
||||||
const h = (bImg.height / bImg.width) * imgW;
|
const h = (bImg.height / bImg.width) * imgW;
|
||||||
ctx.drawImage(bImg, x - imgW / 2, baseY + 4 - h, imgW, h);
|
ctx.drawImage(bImg, x - imgW / 2, baseY + 4 - h, imgW, h);
|
||||||
} else {
|
} else {
|
||||||
shadow(x, y + 6, 24);
|
shadow(x, y + 6, 24);
|
||||||
const img = toolImagesRef.current.get(b.aptitude);
|
drawEmoji(b.emoji, x, y + 8, TW * fp.w * 0.55);
|
||||||
if (img) {
|
|
||||||
const h = 56;
|
|
||||||
const w = (img.width / img.height) * h;
|
|
||||||
ctx.drawImage(img, x - w / 2, y + 8 - h, w, h);
|
|
||||||
} else {
|
|
||||||
drawEmoji(b.emoji, x, y + 8, 42);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ctx.font = `13px ${juaFamily}`;
|
ctx.font = `13px ${juaFamily}`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
@@ -650,7 +678,10 @@ export default function VillageView({
|
|||||||
const atKitchen =
|
const atKitchen =
|
||||||
working &&
|
working &&
|
||||||
state.buildings.find((b) => b.id === cat.assignedTo)?.type === "kitchen";
|
state.buildings.find((b) => b.id === cat.assignedTo)?.type === "kitchen";
|
||||||
const frameName: CatFrame = working
|
const frameName: CatFrame =
|
||||||
|
cat.state === "resting" && !moving
|
||||||
|
? "sleep"
|
||||||
|
: working && !cat.state
|
||||||
? atKitchen
|
? atKitchen
|
||||||
? "cook"
|
? "cook"
|
||||||
: "work"
|
: "work"
|
||||||
@@ -682,7 +713,7 @@ export default function VillageView({
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.fillStyle = "rgba(74,52,24,0.9)";
|
ctx.fillStyle = "rgba(74,52,24,0.9)";
|
||||||
ctx.fillText(cat.name, anim.x, anim.y + 14);
|
ctx.fillText(cat.name, anim.x, anim.y + 14);
|
||||||
if (working) drawEmoji("💦", anim.x + 14, anim.y - 30 + bob, 11);
|
if (working && !cat.state) drawEmoji("💦", anim.x + 14, anim.y - 30 + bob, 11);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -721,9 +752,9 @@ export default function VillageView({
|
|||||||
const cj = hover.j + fp.h / 2 - 0.5;
|
const cj = hover.j + fp.h / 2 - 0.5;
|
||||||
const gx = isoX(ci, cj);
|
const gx = isoX(ci, cj);
|
||||||
const gBase = isoY(ci, cj) + TH / 2 + (fp.h * TH) / 2;
|
const gBase = isoY(ci, cj) + TH / 2 + (fp.h * TH) / 2;
|
||||||
const gImg =
|
const gImg = BUILDING_IMG[placing.type]
|
||||||
getImage(BUILDING_IMG[placing.type]) ??
|
? getImage(BUILDING_IMG[placing.type])
|
||||||
toolImagesRef.current.get(placing.aptitude);
|
: undefined;
|
||||||
ctx.globalAlpha = 0.75;
|
ctx.globalAlpha = 0.75;
|
||||||
if (gImg) {
|
if (gImg) {
|
||||||
const w = TW * fp.w * 0.94;
|
const w = TW * fp.w * 0.94;
|
||||||
|
|||||||
+27
-1
@@ -94,6 +94,9 @@ export const PASSIVES: PassiveDef[] = [
|
|||||||
{ key: "lazy", name: "게으름뱅이", kind: "negative", desc: "작업 속도 -30%", speedMul: 0.7 },
|
{ key: "lazy", name: "게으름뱅이", kind: "negative", desc: "작업 속도 -30%", speedMul: 0.7 },
|
||||||
{ key: "big_eater", name: "대식가", kind: "negative", desc: "식량 소모 +50%", speedMul: 1 },
|
{ key: "big_eater", name: "대식가", kind: "negative", desc: "식량 소모 +50%", speedMul: 1 },
|
||||||
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
|
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
|
||||||
|
{ key: "optimist", name: "낙천적", kind: "positive", desc: "행복도 감소 속도 -40%", speedMul: 1 },
|
||||||
|
{ key: "picky", name: "까다로움", kind: "negative", desc: "행복도 감소 속도 +50%", speedMul: 1 },
|
||||||
|
{ key: "popular", name: "인기쟁이", kind: "positive", desc: "같은 건물 동료의 행복도가 서서히 상승", speedMul: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- 마을 보드 ----------
|
// ---------- 마을 보드 ----------
|
||||||
@@ -118,6 +121,8 @@ export const FOOTPRINTS: Record<string, { w: number; h: number }> = {
|
|||||||
lumberyard: { w: 2, h: 2 },
|
lumberyard: { w: 2, h: 2 },
|
||||||
quarry: { w: 2, h: 2 },
|
quarry: { w: 2, h: 2 },
|
||||||
kitchen: { w: 2, h: 2 },
|
kitchen: { w: 2, h: 2 },
|
||||||
|
cat_house: { w: 2, h: 2 },
|
||||||
|
sun_deck: { w: 2, h: 2 },
|
||||||
};
|
};
|
||||||
export const footprintOf = (type: string) => FOOTPRINTS[type] ?? { w: 1, h: 1 };
|
export const footprintOf = (type: string) => FOOTPRINTS[type] ?? { w: 1, h: 1 };
|
||||||
|
|
||||||
@@ -144,7 +149,8 @@ export const APT_POINT_EVERY = 3; // 3레벨마다 적성 포인트 1
|
|||||||
|
|
||||||
export interface BuildDef {
|
export interface BuildDef {
|
||||||
id: string; // 건물 인스턴스 id (단일 건설)
|
id: string; // 건물 인스턴스 id (단일 건설)
|
||||||
type: "quarry" | "kitchen";
|
type: "quarry" | "kitchen" | "cat_house" | "sun_deck";
|
||||||
|
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
aptitude: AptitudeKey;
|
aptitude: AptitudeKey;
|
||||||
@@ -167,6 +173,16 @@ export const BUILD_DEFS: BuildDef[] = [
|
|||||||
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2, buildTime: 120,
|
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2, buildTime: 120,
|
||||||
desc: "채굴 적성 고양이가 돌을 캡니다. 돌은 회관 증축과 중급 건물에 필요합니다.",
|
desc: "채굴 적성 고양이가 돌을 캡니다. 돌은 회관 증축과 중급 건물에 필요합니다.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "cat_house_1", type: "cat_house", name: "고양이 집", emoji: "🛖", kind: "passive",
|
||||||
|
aptitude: "grooming", maxSlots: 0, cost: { wood: 100, stone: 20 }, reqHallLv: 3, buildTime: 90,
|
||||||
|
desc: "지친 고양이가 푹 쉬는 보금자리. 휴식 회복이 빨라지고(분당 +30) 행복도 하락을 막아줍니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sun_deck_1", type: "sun_deck", name: "햇볕 데크", emoji: "🌞", kind: "passive",
|
||||||
|
aptitude: "grooming", maxSlots: 0, cost: { wood: 120, stone: 40 }, reqHallLv: 3, buildTime: 90,
|
||||||
|
desc: "따끈한 햇살 아래 뒹굴뒹굴. 마을 전체의 행복도 회복이 1.5배 빨라집니다.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- 요리 (부엌) ----------
|
// ---------- 요리 (부엌) ----------
|
||||||
@@ -202,6 +218,16 @@ export const mealCap = (kitchenLv: number) =>
|
|||||||
export const EAT_THRESHOLD = 65; // 이 포만도 이하면 먹으러 감
|
export const EAT_THRESHOLD = 65; // 이 포만도 이하면 먹으러 감
|
||||||
export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치
|
export const FULLNESS_DECAY_PER_SEC = 0.03; // × 식욕 수치
|
||||||
|
|
||||||
|
// ---------- 체력·행복도 (Phase B) ----------
|
||||||
|
|
||||||
|
export const ENERGY_DRAIN_PER_SEC = 2 / 60; // 작업 중 체력 소모 (분당 2)
|
||||||
|
export const REST_RECOVER_PER_SEC = 20 / 60; // 휴식 회복 (분당 20)
|
||||||
|
export const HOUSE_RECOVER_PER_SEC = 30 / 60; // 고양이 집 휴식 회복 (분당 30)
|
||||||
|
export const IDLE_RECOVER_PER_SEC = 10 / 60; // 배회 중 자연 회복 (분당 10)
|
||||||
|
export const CAT_HOUSE_CAPACITY = 4; // 고양이 집 1채당 휴식 정원
|
||||||
|
export const STRIKE_THRESHOLD = 20; // 행복도가 이 밑이면 파업
|
||||||
|
export const STRIKE_RESUME = 40; // 이 이상 회복되면 복귀
|
||||||
|
|
||||||
// ---------- 마을 회관 증축 ----------
|
// ---------- 마을 회관 증축 ----------
|
||||||
|
|
||||||
export interface HallLevelDef {
|
export interface HallLevelDef {
|
||||||
|
|||||||
+159
-10
@@ -3,6 +3,13 @@ import {
|
|||||||
APT_POINT_EVERY,
|
APT_POINT_EVERY,
|
||||||
APTITUDE_RATE,
|
APTITUDE_RATE,
|
||||||
BASE_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_COLS,
|
||||||
BOARD_ROWS,
|
BOARD_ROWS,
|
||||||
BOARD_VERSION,
|
BOARD_VERSION,
|
||||||
@@ -74,6 +81,11 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
|
|||||||
passives.push(pool.splice(idx, 1)[0].key);
|
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 {
|
return {
|
||||||
id: nextId("cat"),
|
id: nextId("cat"),
|
||||||
name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)],
|
name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)],
|
||||||
@@ -84,12 +96,8 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
|
|||||||
exp: 0,
|
exp: 0,
|
||||||
aptitudePoints: 0,
|
aptitudePoints: 0,
|
||||||
aptitude,
|
aptitude,
|
||||||
stats: {
|
stats,
|
||||||
diligence: rand(50, 150),
|
status: { fullness: 100, happiness: 80, energy: stats.stamina },
|
||||||
stamina: rand(50, 150),
|
|
||||||
appetite: Math.min(5, Math.max(1, rand(2, 4) + breed.appetiteMod)),
|
|
||||||
},
|
|
||||||
status: { fullness: 100, happiness: 80 },
|
|
||||||
passives,
|
passives,
|
||||||
assignedTo: null,
|
assignedTo: null,
|
||||||
};
|
};
|
||||||
@@ -129,6 +137,7 @@ function createInitialState(): GameState {
|
|||||||
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
||||||
|
|
||||||
let state: GameState | null = null;
|
let state: GameState | null = null;
|
||||||
|
let offlineMinutes: number | null = null;
|
||||||
|
|
||||||
function passiveSpeedMul(cat: Cat): number {
|
function passiveSpeedMul(cat: Cat): number {
|
||||||
return cat.passives.reduce((mul, key) => {
|
return cat.passives.reduce((mul, key) => {
|
||||||
@@ -150,9 +159,17 @@ export function fullnessPenalty(cat: Cat): number {
|
|||||||
return 1;
|
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 {
|
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 {
|
function passiveFoodMul(cat: Cat): number {
|
||||||
@@ -246,7 +263,10 @@ export const engine = {
|
|||||||
if (!state) state = createInitialState();
|
if (!state) state = createInitialState();
|
||||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||||
state.resources.stone ??= 0;
|
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)
|
for (const b of state.buildings)
|
||||||
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
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 }) };
|
b.pos = { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 6 }) };
|
||||||
state.boardVersion = BOARD_VERSION;
|
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;
|
return state;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 오프라인 정산 리포트 (1회성) */
|
||||||
|
consumeOfflineReport(): number | null {
|
||||||
|
const r = offlineMinutes;
|
||||||
|
offlineMinutes = null;
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
|
||||||
tick(dt: number) {
|
tick(dt: number) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
for (const building of state.buildings) {
|
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();
|
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;
|
if (!state) return null;
|
||||||
const def = BUILD_DEFS.find((d) => d.type === type);
|
const def = BUILD_DEFS.find((d) => d.type === type);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
@@ -451,6 +593,13 @@ export const engine = {
|
|||||||
if (!cat || !building) return null;
|
if (!cat || !building) return null;
|
||||||
if (isUnderConstruction(building))
|
if (isUnderConstruction(building))
|
||||||
return `${building.name}은(는) 아직 건설 중입니다`;
|
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)
|
if (cat.aptitude[building.aptitude] === 0)
|
||||||
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
||||||
if (building.assigned.length >= building.maxSlots)
|
if (building.assigned.length >= building.maxSlots)
|
||||||
|
|||||||
+10
-1
@@ -9,7 +9,13 @@ export type AptitudeKey =
|
|||||||
|
|
||||||
export type ResourceKey = "fish" | "wood" | "stone";
|
export type ResourceKey = "fish" | "wood" | "stone";
|
||||||
|
|
||||||
export type BuildingType = "dock" | "lumberyard" | "kitchen" | "quarry";
|
export type BuildingType =
|
||||||
|
| "dock"
|
||||||
|
| "lumberyard"
|
||||||
|
| "kitchen"
|
||||||
|
| "quarry"
|
||||||
|
| "cat_house"
|
||||||
|
| "sun_deck";
|
||||||
|
|
||||||
export interface Cat {
|
export interface Cat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,9 +35,12 @@ export interface Cat {
|
|||||||
status: {
|
status: {
|
||||||
fullness: number;
|
fullness: number;
|
||||||
happiness: number;
|
happiness: number;
|
||||||
|
energy: number; // 현재 체력 (최대 = stats.stamina)
|
||||||
};
|
};
|
||||||
passives: string[];
|
passives: string[];
|
||||||
assignedTo: string | null;
|
assignedTo: string | null;
|
||||||
|
state?: "resting" | "striking" | null; // 강제 휴식 / 파업
|
||||||
|
returnTo?: string | null; // 휴식·파업 종료 후 복귀할 건물
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Building {
|
export interface Building {
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단