- 11x11 아이소 타일 (잔디·물가·나무·장식), 회관/부두/벌목장 배치 - 고양이: 미배치 시 마을 배회, 배치 시 건물로 걸어가 작업(바운스+💦) - 생산 말풍선, 건물 배지 클릭으로 산출물 회수 - 렌더 루프는 engine.peek() 라이브 참조 사용 (매 프레임 클론 회피) - 컬러 이모지에 fillStyle 알파가 적용되는 문제 수정, 페이지 타이틀 변경 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
257 라인
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|