= 20 ? "linear-gradient(90deg,#e88bb8,#f7b7d4)" : undefined }}
+ />
+
배고픔
@@ -823,7 +914,11 @@ function CatCard({
)}
- {workplace ? (
+ {cat.state === "resting" ? (
+ 💤 지쳐서 휴식 중 (회복되면 복귀)
+ ) : cat.state === "striking" ? (
+ 💢 파업 중! 행복도를 올려주세요
+ ) : workplace ? (
{workplace.emoji} {workplace.name}에서 일하는 중
diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx
index a01d499..fd73ca0 100644
--- a/src/components/game/VillageView.tsx
+++ b/src/components/game/VillageView.tsx
@@ -303,7 +303,40 @@ export default function VillageView({
}
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 slot = building ? building.assigned.indexOf(cat.id) : 0;
const spot = workSpot(building, Math.max(0, slot));
@@ -340,7 +373,7 @@ export default function VillageView({
anim.mode = "working";
} else if (anim.mode === "working") {
// 일하는 중 — 생산 말풍선 (약 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);
if (b && catRate(cat, b.aptitude) > 0) {
bubblesRef.current.push({
@@ -599,21 +632,16 @@ export default function VillageView({
ctx.fillText(`${b.name} 건설 중`, x, baseY + 17);
return;
}
- const bImg = getImage(BUILDING_IMG[b.type]);
+ const bImg = BUILDING_IMG[b.type]
+ ? getImage(BUILDING_IMG[b.type])
+ : undefined;
if (bImg) {
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
const h = (bImg.height / bImg.width) * imgW;
ctx.drawImage(bImg, x - imgW / 2, baseY + 4 - h, imgW, h);
} else {
shadow(x, y + 6, 24);
- const img = toolImagesRef.current.get(b.aptitude);
- 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);
- }
+ drawEmoji(b.emoji, x, y + 8, TW * fp.w * 0.55);
}
ctx.font = `13px ${juaFamily}`;
ctx.textAlign = "center";
@@ -650,15 +678,18 @@ export default function VillageView({
const atKitchen =
working &&
state.buildings.find((b) => b.id === cat.assignedTo)?.type === "kitchen";
- const frameName: CatFrame = working
- ? atKitchen
- ? "cook"
- : "work"
- : moving
- ? Math.floor(now / 200) % 2 === 0
- ? "walk1"
- : "walk2"
- : "idle";
+ const frameName: CatFrame =
+ cat.state === "resting" && !moving
+ ? "sleep"
+ : working && !cat.state
+ ? atKitchen
+ ? "cook"
+ : "work"
+ : moving
+ ? Math.floor(now / 200) % 2 === 0
+ ? "walk1"
+ : "walk2"
+ : "idle";
// 품종 스프라이트 → (cook 없으면 work) → 이모지 순 폴백
let sprite = getImage(catSprite(cat.breed, frameName));
if (!sprite && frameName === "cook")
@@ -682,7 +713,7 @@ export default function VillageView({
ctx.lineWidth = 1;
ctx.fillStyle = "rgba(74,52,24,0.9)";
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 gx = isoX(ci, cj);
const gBase = isoY(ci, cj) + TH / 2 + (fp.h * TH) / 2;
- const gImg =
- getImage(BUILDING_IMG[placing.type]) ??
- toolImagesRef.current.get(placing.aptitude);
+ const gImg = BUILDING_IMG[placing.type]
+ ? getImage(BUILDING_IMG[placing.type])
+ : undefined;
ctx.globalAlpha = 0.75;
if (gImg) {
const w = TW * fp.w * 0.94;
diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts
index dc26527..ec7888e 100644
--- a/src/lib/game/data.ts
+++ b/src/lib/game/data.ts
@@ -94,6 +94,9 @@ export const PASSIVES: PassiveDef[] = [
{ key: "lazy", name: "게으름뱅이", kind: "negative", desc: "작업 속도 -30%", speedMul: 0.7 },
{ key: "big_eater", name: "대식가", kind: "negative", desc: "식량 소모 +50%", speedMul: 1 },
{ 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 = {
lumberyard: { w: 2, h: 2 },
quarry: { 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 };
@@ -144,7 +149,8 @@ export const APT_POINT_EVERY = 3; // 3레벨마다 적성 포인트 1
export interface BuildDef {
id: string; // 건물 인스턴스 id (단일 건설)
- type: "quarry" | "kitchen";
+ type: "quarry" | "kitchen" | "cat_house" | "sun_deck";
+ kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
name: string;
emoji: string;
aptitude: AptitudeKey;
@@ -167,6 +173,16 @@ export const BUILD_DEFS: BuildDef[] = [
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2, buildTime: 120,
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 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 {
diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts
index d6cc3cf..3d27626 100644
--- a/src/lib/game/engine.ts
+++ b/src/lib/game/engine.ts
@@ -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)
diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts
index df33c89..d9d6c01 100644
--- a/src/lib/game/types.ts
+++ b/src/lib/game/types.ts
@@ -9,7 +9,13 @@ export type AptitudeKey =
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 {
id: string;
@@ -29,9 +35,12 @@ export interface Cat {
status: {
fullness: number;
happiness: number;
+ energy: number; // 현재 체력 (최대 = stats.stamina)
};
passives: string[];
assignedTo: string | null;
+ state?: "resting" | "striking" | null; // 강제 휴식 / 파업
+ returnTo?: string | null; // 휴식·파업 종료 후 복귀할 건물
}
export interface Building {