파일
project/src/components/game/Game.tsx
T
kjs그리고Claude Fable 5 348ca5ded7 쿼터뷰 마을 뷰 v1: 아이소메트릭 캔버스 + 고양이 이동·작업 애니메이션
- 11x11 아이소 타일 (잔디·물가·나무·장식), 회관/부두/벌목장 배치
- 고양이: 미배치 시 마을 배회, 배치 시 건물로 걸어가 작업(바운스+💦)
- 생산 말풍선, 건물 배지 클릭으로 산출물 회수
- 렌더 루프는 engine.peek() 라이브 참조 사용 (매 프레임 클론 회피)
- 컬러 이모지에 fillStyle 알파가 적용되는 문제 수정, 페이지 타이틀 변경

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:02:17 +09:00

257 라인
8.5 KiB
TypeScript

"use client";
import { useState } from "react";
import { useGame } from "@/hooks/useGame";
import VillageView from "@/components/game/VillageView";
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())} />
<VillageView />
{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>
);
}