고양이 마을 1단계: 개체 생성·배치·틱 생산·수동 회수
- 품종 8종·패시브·랜덤 개체 생성 (기획서 4.2, 5장) - 부두/벌목장 배치, 적성 0 배치 불가, 적성 비선형 작업량 (4.4) - rAF+델타 타임 엔진(모듈 싱글턴), UI 300ms 동기화 (기술 메모) - 수동 회수(운반 수레 전), localStorage 저장(version 필드) - 회관 Lv1 수용 3마리, 입양 버튼 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+6
@@ -39,3 +39,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# 로컬 테스트 산출물
|
||||
.playwright-mcp/
|
||||
*.png
|
||||
!public/*.png
|
||||
!src/**/*.png
|
||||
|
||||
+9
-35
@@ -1,51 +1,25 @@
|
||||
import Game from "@/components/game/Game";
|
||||
import DbStatus from "@/components/DbStatus";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* 헤더 */}
|
||||
<header className="border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<span className="text-lg font-bold">jskim</span>
|
||||
<nav className="flex gap-6 text-sm text-gray-600 dark:text-gray-300">
|
||||
<a href="/" className="hover:text-black dark:hover:text-white">홈</a>
|
||||
<a href="#" className="hover:text-black dark:hover:text-white">메뉴1</a>
|
||||
<a href="#" className="hover:text-black dark:hover:text-white">메뉴2</a>
|
||||
</nav>
|
||||
<span className="text-lg font-bold">🏡 고양이 마을</span>
|
||||
<span className="text-xs text-gray-400">방치형 경영 게임 — 1단계</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 메인 */}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-12">
|
||||
<section className="mb-12">
|
||||
<h1 className="mb-2 text-3xl font-bold">메인 페이지</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
jskim.vexplor.com — Next.js + PostgreSQL 기본 틀
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<DbStatus />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 콘텐츠 카드 자리 */}
|
||||
<section className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{["섹션 A", "섹션 B", "섹션 C"].map((title) => (
|
||||
<div
|
||||
key={title}
|
||||
className="rounded-xl border border-gray-200 p-6 dark:border-gray-800"
|
||||
>
|
||||
<h2 className="mb-2 font-semibold">{title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
콘텐츠가 들어갈 자리입니다.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
<Game />
|
||||
</main>
|
||||
|
||||
{/* 푸터 */}
|
||||
<footer className="border-t border-gray-200 py-6 text-center text-sm text-gray-400 dark:border-gray-800">
|
||||
jskim.vexplor.com · dev-pilots
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>jskim.vexplor.com · dev-pilots</span>
|
||||
<DbStatus />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useGame } from "@/hooks/useGame";
|
||||
import { engine, catRate } from "@/lib/game/engine";
|
||||
import { APTITUDE_LABEL, PASSIVES } from "@/lib/game/data";
|
||||
import type { Building, Cat, GameState } from "@/lib/game/types";
|
||||
|
||||
export default function Game() {
|
||||
const { state, dispatch } = useGame();
|
||||
const [selectedCatId, setSelectedCatId] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="py-24 text-center text-gray-400">마을 불러오는 중…</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedCat = state.cats.find((c) => c.id === selectedCatId) ?? null;
|
||||
|
||||
const run = (action: () => string | null | void) => {
|
||||
const error = dispatch(action);
|
||||
setMessage(error ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<TopBar state={state} onAdopt={() => run(() => engine.adopt())} />
|
||||
|
||||
{message && (
|
||||
<p className="rounded-lg bg-amber-50 px-4 py-2 text-sm text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
⚠️ {message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold">🏗️ 생산 건물</h2>
|
||||
<p className="mb-3 text-xs text-gray-400">
|
||||
고양이를 선택한 뒤 빈자리를 클릭해 배치하세요. 산출물은 직접 회수해야 합니다.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{state.buildings.map((b) => (
|
||||
<BuildingCard
|
||||
key={b.id}
|
||||
building={b}
|
||||
cats={state.cats}
|
||||
selectedCat={selectedCat}
|
||||
onAssign={(catId) => run(() => engine.assign(catId, b.id))}
|
||||
onUnassign={(catId) => run(() => engine.unassign(catId))}
|
||||
onCollect={() => run(() => engine.collect(b.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold">
|
||||
🐾 우리 고양이들 ({state.cats.length}/{state.hall.capacity})
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{state.cats.map((cat) => (
|
||||
<CatCard
|
||||
key={cat.id}
|
||||
cat={cat}
|
||||
buildings={state.buildings}
|
||||
selected={cat.id === selectedCatId}
|
||||
onSelect={() =>
|
||||
setSelectedCatId(cat.id === selectedCatId ? null : cat.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ state, onAdopt }: { state: GameState; onAdopt: () => void }) {
|
||||
const full = state.cats.length >= state.hall.capacity;
|
||||
return (
|
||||
<section className="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<span className="font-semibold">🏛️ 마을 회관 Lv{state.hall.level}</span>
|
||||
<span>🐱 {state.cats.length}/{state.hall.capacity}마리</span>
|
||||
<span>🐟 {Math.floor(state.resources.fish)}</span>
|
||||
<span>🪵 {Math.floor(state.resources.wood)}</span>
|
||||
<button
|
||||
onClick={onAdopt}
|
||||
disabled={full}
|
||||
className="ml-auto rounded-lg bg-orange-500 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{full ? "수용 인원 가득 참" : "😻 입양하기"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildingCard({
|
||||
building,
|
||||
cats,
|
||||
selectedCat,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onCollect,
|
||||
}: {
|
||||
building: Building;
|
||||
cats: Cat[];
|
||||
selectedCat: Cat | null;
|
||||
onAssign: (catId: string) => void;
|
||||
onUnassign: (catId: string) => void;
|
||||
onCollect: () => void;
|
||||
}) {
|
||||
const assignedCats = building.assigned
|
||||
.map((id) => cats.find((c) => c.id === id))
|
||||
.filter((c): c is Cat => Boolean(c));
|
||||
const totalRate = assignedCats.reduce(
|
||||
(sum, c) => sum + catRate(c, building.aptitude),
|
||||
0,
|
||||
);
|
||||
const stored = Math.floor(building.stored);
|
||||
const emptySlots = building.maxSlots - assignedCats.length;
|
||||
const canAssignSelected =
|
||||
selectedCat !== null &&
|
||||
selectedCat.assignedTo !== building.id &&
|
||||
selectedCat.aptitude[building.aptitude] > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 p-5 dark:border-gray-800">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<h3 className="font-semibold">
|
||||
{building.emoji} {building.name} Lv{building.level}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
필요 적성: {APTITUDE_LABEL[building.aptitude]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
생산 속도 {totalRate.toFixed(2)}/초
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{assignedCats.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => onUnassign(cat.id)}
|
||||
title="클릭하면 배치 해제"
|
||||
className="rounded-full bg-gray-100 px-3 py-1 text-sm hover:bg-red-100 dark:bg-gray-800 dark:hover:bg-red-950"
|
||||
>
|
||||
{cat.emoji} {cat.name} ✕
|
||||
</button>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={!canAssignSelected}
|
||||
onClick={() => selectedCat && onAssign(selectedCat.id)}
|
||||
className="rounded-full border border-dashed border-gray-300 px-3 py-1 text-sm text-gray-400 enabled:hover:border-orange-400 enabled:hover:text-orange-500 disabled:opacity-50 dark:border-gray-700"
|
||||
>
|
||||
{canAssignSelected ? `+ ${selectedCat.name} 배치` : "빈자리"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCollect}
|
||||
disabled={stored <= 0}
|
||||
className="w-full rounded-lg bg-sky-500 py-2 text-sm font-semibold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{building.resource === "fish" ? "🐟" : "🪵"} {stored}개 회수
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatCard({
|
||||
cat,
|
||||
buildings,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
cat: Cat;
|
||||
buildings: Building[];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const workplace = buildings.find((b) => b.id === cat.assignedTo);
|
||||
const expNeeded = cat.level * 60;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`rounded-xl border p-4 text-left transition ${
|
||||
selected
|
||||
? "border-orange-400 ring-2 ring-orange-200 dark:ring-orange-900"
|
||||
: "border-gray-200 hover:border-gray-300 dark:border-gray-800 dark:hover:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="font-semibold">
|
||||
{cat.emoji} {cat.name}{" "}
|
||||
<span className="text-xs font-normal text-gray-400">Lv{cat.level}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{cat.breedName}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 h-1.5 w-full overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-orange-400"
|
||||
style={{ width: `${Math.min(100, (cat.exp / expNeeded) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
🎣 낚시 {cat.aptitude.fishing} · 🪓 벌목 {cat.aptitude.logging} · 💪 근면{" "}
|
||||
{cat.stats.diligence}
|
||||
</p>
|
||||
|
||||
{cat.passives.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{cat.passives.map((key) => {
|
||||
const def = PASSIVES.find((p) => p.key === key);
|
||||
if (!def) return null;
|
||||
const color =
|
||||
def.kind === "positive"
|
||||
? "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300"
|
||||
: def.kind === "negative"
|
||||
? "bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300"
|
||||
: "bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300";
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
title={def.desc}
|
||||
className={`rounded px-1.5 py-0.5 text-[11px] ${color}`}
|
||||
>
|
||||
{def.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
{workplace ? (
|
||||
<span className="text-sky-600 dark:text-sky-400">
|
||||
{workplace.emoji} {workplace.name}에서 일하는 중
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">쉬는 중</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { engine } from "@/lib/game/engine";
|
||||
import type { GameState } from "@/lib/game/types";
|
||||
|
||||
const SYNC_INTERVAL_MS = 300;
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
const MAX_DT_SEC = 60;
|
||||
|
||||
/**
|
||||
* 게임 상태는 엔진(모듈 싱글턴)이 rAF + 델타 타임으로 계산하고,
|
||||
* React에는 300ms 간격으로만 스냅샷을 동기화한다 (리렌더 최소화).
|
||||
*/
|
||||
export function useGame() {
|
||||
const [state, setState] = useState<GameState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
engine.init();
|
||||
setState(engine.snapshot());
|
||||
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let lastSync = 0;
|
||||
|
||||
const loop = (t: number) => {
|
||||
const dt = Math.min((t - last) / 1000, MAX_DT_SEC);
|
||||
last = t;
|
||||
engine.tick(dt);
|
||||
if (t - lastSync >= SYNC_INTERVAL_MS) {
|
||||
lastSync = t;
|
||||
setState(engine.snapshot());
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const saveTimer = setInterval(() => engine.save(), SAVE_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearInterval(saveTimer);
|
||||
engine.save();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 액션 실행 후 즉시 스냅샷 반영 (클릭 반응성). 에러 메시지 반환 가능 */
|
||||
const dispatch = useCallback((action: () => string | null | void) => {
|
||||
const result = action() ?? null;
|
||||
setState(engine.snapshot());
|
||||
engine.save();
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
return { state, dispatch };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AptitudeKey } from "./types";
|
||||
|
||||
// 적성 레벨별 초당 작업량 (기획서 4.4) — BASE_RATE로 전체 속도 스케일 조정
|
||||
export const APTITUDE_RATE = [0, 1.0, 2.2, 4.8, 10.0, 21.0, 45.0];
|
||||
export const BASE_RATE = 0.2;
|
||||
|
||||
export const APTITUDE_LABEL: Record<AptitudeKey, string> = {
|
||||
fishing: "낚시",
|
||||
logging: "벌목",
|
||||
mining: "채굴",
|
||||
foraging: "채집",
|
||||
cooking: "요리",
|
||||
crafting: "제작",
|
||||
grooming: "손질",
|
||||
};
|
||||
|
||||
export interface BreedDef {
|
||||
key: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
rarity: number; // 별 개수
|
||||
weight: number; // 추첨 가중치
|
||||
bias: AptitudeKey[]; // 적성 편향
|
||||
aptitudeCap: number;
|
||||
appetiteMod: number;
|
||||
}
|
||||
|
||||
// 기획서 4.2 품종표
|
||||
export const BREEDS: BreedDef[] = [
|
||||
{ key: "korean_shorthair", name: "코리안 숏헤어", emoji: "🐱", rarity: 1, weight: 35, bias: [], aptitudeCap: 5, appetiteMod: -1 },
|
||||
{ key: "munchkin", name: "먼치킨", emoji: "😺", rarity: 1, weight: 25, bias: ["foraging", "grooming"], aptitudeCap: 5, appetiteMod: 0 },
|
||||
{ key: "norwegian_forest", name: "노르웨이 숲", emoji: "🐈", rarity: 2, weight: 15, bias: ["logging", "mining"], aptitudeCap: 5, appetiteMod: 1 },
|
||||
{ key: "bengal", name: "뱅갈", emoji: "🐆", rarity: 2, weight: 10, bias: ["fishing", "foraging"], aptitudeCap: 5, appetiteMod: 0 },
|
||||
{ key: "russian_blue", name: "러시안 블루", emoji: "🐈⬛", rarity: 2, weight: 8, bias: ["crafting", "cooking"], aptitudeCap: 5, appetiteMod: 0 },
|
||||
{ key: "persian", name: "페르시안", emoji: "😸", rarity: 3, weight: 4, bias: ["cooking", "grooming"], aptitudeCap: 5, appetiteMod: 0 },
|
||||
{ key: "sphynx", name: "스핑크스", emoji: "🐯", rarity: 3, weight: 2, bias: ["crafting", "mining"], aptitudeCap: 5, appetiteMod: 0 },
|
||||
{ key: "maine_coon", name: "메인쿤", emoji: "🦁", rarity: 4, weight: 1, bias: ["logging", "mining"], aptitudeCap: 6, appetiteMod: 2 },
|
||||
];
|
||||
|
||||
export interface PassiveDef {
|
||||
key: string;
|
||||
name: string;
|
||||
kind: "positive" | "negative" | "tradeoff";
|
||||
desc: string;
|
||||
speedMul: number; // 1단계에서는 작업 속도 효과만 적용
|
||||
}
|
||||
|
||||
// 기획서 5장 중 1단계에서 의미 있는 패시브 위주
|
||||
export const PASSIVES: PassiveDef[] = [
|
||||
{ key: "diligent", name: "부지런함", kind: "positive", desc: "작업 속도 +25%", speedMul: 1.25 },
|
||||
{ key: "sturdy", name: "튼튼함", kind: "positive", desc: "체력 소모 -30%", speedMul: 1 },
|
||||
{ key: "small_eater", name: "소식가", kind: "positive", desc: "식량 소모 -30%", speedMul: 1 },
|
||||
{ key: "lucky", name: "복덩이", kind: "positive", desc: "확률적으로 냥코인 획득", speedMul: 1 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export const CAT_NAMES = [
|
||||
"치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두",
|
||||
"감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이",
|
||||
"달이", "별이", "쿠키", "만두", "생강", "참깨", "누룽지", "조약돌",
|
||||
];
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { AptitudeKey, Building, Cat, GameState } from "./types";
|
||||
import {
|
||||
APTITUDE_RATE,
|
||||
BASE_RATE,
|
||||
BREEDS,
|
||||
CAT_NAMES,
|
||||
PASSIVES,
|
||||
type BreedDef,
|
||||
} from "./data";
|
||||
|
||||
const SAVE_KEY = "cat-village-save";
|
||||
const APTITUDE_KEYS: AptitudeKey[] = [
|
||||
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
|
||||
];
|
||||
|
||||
const rand = (min: number, max: number) =>
|
||||
Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
const pickWeighted = <T extends { weight: number }>(items: T[]): T => {
|
||||
const total = items.reduce((s, i) => s + i.weight, 0);
|
||||
let roll = Math.random() * total;
|
||||
for (const item of items) {
|
||||
roll -= item.weight;
|
||||
if (roll <= 0) return item;
|
||||
}
|
||||
return items[items.length - 1];
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const nextId = (prefix: string) =>
|
||||
`${prefix}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
||||
|
||||
// ---------- 고양이 생성 ----------
|
||||
|
||||
function generateCat(forceAptitude?: AptitudeKey): Cat {
|
||||
const breed: BreedDef = pickWeighted(BREEDS);
|
||||
|
||||
const aptitude = {} as Record<AptitudeKey, number>;
|
||||
for (const key of APTITUDE_KEYS) {
|
||||
let value = rand(0, 2);
|
||||
if (breed.bias.includes(key)) value += rand(1, 3);
|
||||
aptitude[key] = Math.min(value, breed.aptitudeCap);
|
||||
}
|
||||
if (forceAptitude && aptitude[forceAptitude] < 2) {
|
||||
aptitude[forceAptitude] = rand(2, 3);
|
||||
}
|
||||
|
||||
const passives: string[] = [];
|
||||
const pool = [...PASSIVES];
|
||||
const passiveCount = rand(0, 2);
|
||||
for (let i = 0; i < passiveCount; i++) {
|
||||
const idx = rand(0, pool.length - 1);
|
||||
passives.push(pool.splice(idx, 1)[0].key);
|
||||
}
|
||||
|
||||
return {
|
||||
id: nextId("cat"),
|
||||
name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)],
|
||||
breed: breed.key,
|
||||
breedName: breed.name,
|
||||
emoji: breed.emoji,
|
||||
level: 1,
|
||||
exp: 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 },
|
||||
passives,
|
||||
assignedTo: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 초기 상태 ----------
|
||||
|
||||
function createBuildings(): Building[] {
|
||||
return [
|
||||
{
|
||||
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
||||
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
{
|
||||
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
||||
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
// 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지)
|
||||
return {
|
||||
version: 1,
|
||||
resources: { fish: 0, wood: 0 },
|
||||
cats: [generateCat("fishing"), generateCat("logging")],
|
||||
buildings: createBuildings(),
|
||||
hall: { level: 1, capacity: 3 },
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
||||
|
||||
let state: GameState | null = null;
|
||||
|
||||
function passiveSpeedMul(cat: Cat): number {
|
||||
return cat.passives.reduce((mul, key) => {
|
||||
const def = PASSIVES.find((p) => p.key === key);
|
||||
return mul * (def?.speedMul ?? 1);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
/** 초당 산출량 = 적성 테이블 × 근면 배율 × 패시브 배율 × BASE_RATE */
|
||||
export function catRate(cat: Cat, aptitudeKey: AptitudeKey): number {
|
||||
const apt = APTITUDE_RATE[cat.aptitude[aptitudeKey]] ?? 0;
|
||||
return apt * (cat.stats.diligence / 100) * passiveSpeedMul(cat) * BASE_RATE;
|
||||
}
|
||||
|
||||
export const engine = {
|
||||
init(): GameState {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as GameState;
|
||||
if (saved.version === 1) state = saved;
|
||||
}
|
||||
} catch {
|
||||
// 저장 데이터가 깨졌으면 새로 시작
|
||||
}
|
||||
if (!state) state = createInitialState();
|
||||
return state;
|
||||
},
|
||||
|
||||
tick(dt: number) {
|
||||
if (!state) return;
|
||||
for (const building of state.buildings) {
|
||||
let produced = 0;
|
||||
for (const catId of building.assigned) {
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
if (!cat) continue;
|
||||
produced += catRate(cat, building.aptitude) * dt;
|
||||
// 배치 중 경험치: 초당 1
|
||||
cat.exp += dt;
|
||||
const needed = cat.level * 60;
|
||||
if (cat.exp >= needed) {
|
||||
cat.exp -= needed;
|
||||
cat.level += 1;
|
||||
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
|
||||
}
|
||||
}
|
||||
building.stored += produced;
|
||||
}
|
||||
state.updatedAt = Date.now();
|
||||
},
|
||||
|
||||
adopt(): string | null {
|
||||
if (!state) return null;
|
||||
if (state.cats.length >= state.hall.capacity) return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
|
||||
state.cats.push(generateCat());
|
||||
return null;
|
||||
},
|
||||
|
||||
assign(catId: string, buildingId: string): string | null {
|
||||
if (!state) return null;
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
const building = state.buildings.find((b) => b.id === buildingId);
|
||||
if (!cat || !building) return null;
|
||||
if (cat.aptitude[building.aptitude] === 0)
|
||||
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
||||
if (building.assigned.length >= building.maxSlots)
|
||||
return `${building.name} 자리가 가득 찼습니다`;
|
||||
// 기존 배치 해제 후 이동
|
||||
this.unassign(catId);
|
||||
building.assigned.push(catId);
|
||||
cat.assignedTo = building.id;
|
||||
return null;
|
||||
},
|
||||
|
||||
unassign(catId: string) {
|
||||
if (!state) return;
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
if (!cat) return;
|
||||
for (const b of state.buildings) {
|
||||
b.assigned = b.assigned.filter((id) => id !== catId);
|
||||
}
|
||||
cat.assignedTo = null;
|
||||
},
|
||||
|
||||
collect(buildingId: string) {
|
||||
if (!state) return;
|
||||
const building = state.buildings.find((b) => b.id === buildingId);
|
||||
if (!building) return;
|
||||
const amount = Math.floor(building.stored);
|
||||
if (amount <= 0) return;
|
||||
building.stored -= amount;
|
||||
state.resources[building.resource] += amount;
|
||||
},
|
||||
|
||||
save() {
|
||||
if (!state) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// 저장 실패는 치명적이지 않음
|
||||
}
|
||||
},
|
||||
|
||||
snapshot(): GameState | null {
|
||||
return state ? (JSON.parse(JSON.stringify(state)) as GameState) : null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
export type AptitudeKey =
|
||||
| "fishing"
|
||||
| "logging"
|
||||
| "mining"
|
||||
| "foraging"
|
||||
| "cooking"
|
||||
| "crafting"
|
||||
| "grooming";
|
||||
|
||||
export type ResourceKey = "fish" | "wood";
|
||||
|
||||
export type BuildingType = "dock" | "lumberyard";
|
||||
|
||||
export interface Cat {
|
||||
id: string;
|
||||
name: string;
|
||||
breed: string;
|
||||
breedName: string;
|
||||
emoji: string;
|
||||
level: number;
|
||||
exp: number;
|
||||
aptitude: Record<AptitudeKey, number>;
|
||||
stats: {
|
||||
diligence: number; // 50~150, 작업 속도 배율
|
||||
stamina: number; // 50~150 (1단계에서는 표시만)
|
||||
appetite: number; // 1~5 (1단계에서는 표시만)
|
||||
};
|
||||
status: {
|
||||
fullness: number;
|
||||
happiness: number;
|
||||
};
|
||||
passives: string[];
|
||||
assignedTo: string | null;
|
||||
}
|
||||
|
||||
export interface Building {
|
||||
id: string;
|
||||
type: BuildingType;
|
||||
name: string;
|
||||
emoji: string;
|
||||
level: number;
|
||||
maxSlots: number;
|
||||
aptitude: AptitudeKey; // 필요 적성
|
||||
resource: ResourceKey; // 산출 자원
|
||||
assigned: string[]; // cat id
|
||||
stored: number; // 회수 대기 산출물
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
version: 1;
|
||||
resources: Record<ResourceKey, number>;
|
||||
cats: Cat[];
|
||||
buildings: Building[];
|
||||
hall: { level: number; capacity: number };
|
||||
updatedAt: number;
|
||||
}
|
||||
새 이슈에서 참조
사용자 차단