고양이 마을 1단계: 개체 생성·배치·틱 생산·수동 회수
- 품종 8종·패시브·랜덤 개체 생성 (기획서 4.2, 5장) - 부두/벌목장 배치, 적성 0 배치 불가, 적성 비선형 작업량 (4.4) - rAF+델타 타임 엔진(모듈 싱글턴), UI 300ms 동기화 (기술 메모) - 수동 회수(운반 수레 전), localStorage 저장(version 필드) - 회관 Lv1 수용 3마리, 입양 버튼 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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>
|
||||
);
|
||||
}
|
||||
새 이슈에서 참조
사용자 차단