튜토리얼: 촌장 냥이가 안내하는 10단계 온보딩
- 새 게임: 회관만 있는 빈 마을(고양이 0·자원 0)에서 시작 - 촌장 냥이(코숏 스프라이트) + 말풍선 가이드 오버레이 (진행도 n/10, 태스크 칩) - 라인: 인사 → 첫 입양 → 부두 건설(무료·강물 위)·배치 → 둘째 입양 → 벌목장 건설(생선 30)·배치 → 부엌 건설(목재 60) → 회관 Lv2 증축 → 작별 - 조건 충족 시 자동 진행, 인사·작별은 버튼. 완료 후 자유 운영(step 99) - 부두·벌목장을 상점 구매 건물로 이동 (기존 세이브는 "보유 중"+튜토리얼 생략) - 튜토리얼 입양 후보는 필요 적성(낚시→벌목) 보장 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
+133
-2
@@ -31,6 +31,73 @@ import type { AptitudeKey, Building, Cat, GameState } from "@/lib/game/types";
|
|||||||
const RES_EMOJI: Record<string, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
const RES_EMOJI: Record<string, string> = { fish: "🐟", wood: "🪵", stone: "🪨" };
|
||||||
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
const RES_LABEL: Record<string, string> = { fish: "생선", wood: "목재", stone: "돌" };
|
||||||
|
|
||||||
|
// ---------- 튜토리얼 ----------
|
||||||
|
interface TutStep {
|
||||||
|
text: string;
|
||||||
|
task?: string;
|
||||||
|
check?: (s: GameState) => boolean;
|
||||||
|
manual?: boolean; // 버튼으로 진행
|
||||||
|
}
|
||||||
|
|
||||||
|
const TUTORIAL_STEPS: TutStep[] = [
|
||||||
|
{
|
||||||
|
text: "안녕! 난 이 마을의 촌장 냥이야. 여긴 아직 아무것도 없는 들판이지만... 우리 함께 아늑한 고양이 마을을 만들어보자!",
|
||||||
|
manual: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "먼저 첫 친구를 데려오자. 오른쪽 위 😻 입양 버튼을 눌러서 마음에 드는 아이를 골라줘. 낚시를 잘하는 친구면 좋겠다!",
|
||||||
|
task: "고양이 1마리 입양하기",
|
||||||
|
check: (s) => s.cats.length >= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "이제 먹을거리를 구해야지. 🛒 상점에서 부두를 골라 강물 위에 놓아줘. 첫 건물이니까 공짜야!",
|
||||||
|
task: "부두 건설하기 (강물 위)",
|
||||||
|
check: (s) => s.buildings.some((b) => b.type === "dock"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "뚝딱뚝딱... 부두가 완성되면 부두를 눌러서 친구를 배치해줘. 생선이 모이면 배지를 눌러 거둬들이는 것도 잊지 마!",
|
||||||
|
task: "부두에 고양이 배치하기",
|
||||||
|
check: (s) => {
|
||||||
|
const d = s.buildings.find((b) => b.type === "dock");
|
||||||
|
return !!d && d.constructionUntil === undefined && d.assigned.length >= 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "생선이 모이는 동안 친구를 한 마리 더 데려오자! 이번엔 벌목을 잘하는 아이면 좋겠어.",
|
||||||
|
task: "고양이 2마리째 입양하기",
|
||||||
|
check: (s) => s.cats.length >= 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "생선 30마리면 벌목장을 지을 수 있어. 목재는 모든 건설의 기본이란다!",
|
||||||
|
task: "벌목장 건설하기 (🐟 30)",
|
||||||
|
check: (s) => s.buildings.some((b) => b.type === "lumberyard"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "벌목장이 완성되면 벌목 잘하는 친구를 배치해줘!",
|
||||||
|
task: "벌목장에 고양이 배치하기",
|
||||||
|
check: (s) => {
|
||||||
|
const l = s.buildings.find((b) => b.type === "lumberyard");
|
||||||
|
return !!l && l.constructionUntil === undefined && l.assigned.length >= 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "일하다 보면 배가 고파져. 목재 60을 모아 부엌을 짓자. 요리 잘하는 친구가 있으면 배치해두면 알아서 밥을 챙겨 먹는단다.",
|
||||||
|
task: "부엌 건설하기 (🪵 60)",
|
||||||
|
check: (s) => s.buildings.some((b) => b.type === "kitchen"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "마지막이야! 회관을 눌러 Lv2로 증축하면 친구를 더 데려올 수 있어. 열심히 일한 친구는 레벨이 오르니까, 목재 100·생선 80을 모으면서 기다려보자.",
|
||||||
|
task: "마을 회관 Lv2 증축 (🪵100 · 🐟80 · Lv3 친구 1마리)",
|
||||||
|
check: (s) => s.hall.level >= 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "축하해! 이제 이 마을은 온전히 네 거야. 건물을 늘리고, 마을을 꾸미고, 친구들을 돌봐줘. 난 회관에 있을게 — 언제든 놀러 와! 🐾",
|
||||||
|
manual: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TUTORIAL_DONE = 99;
|
||||||
|
|
||||||
type Panel =
|
type Panel =
|
||||||
| { type: "building"; id: string }
|
| { type: "building"; id: string }
|
||||||
| { type: "hall" }
|
| { type: "hall" }
|
||||||
@@ -59,6 +126,17 @@ export default function Game() {
|
|||||||
if (msgTimer.current) clearTimeout(msgTimer.current);
|
if (msgTimer.current) clearTimeout(msgTimer.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 튜토리얼 자동 진행 (조건 충족 시)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state) return;
|
||||||
|
const step = state.tutorialStep ?? TUTORIAL_DONE;
|
||||||
|
if (step >= TUTORIAL_STEPS.length) return;
|
||||||
|
const def = TUTORIAL_STEPS[step];
|
||||||
|
if (def.check && def.check(state)) {
|
||||||
|
dispatch(() => engine.setTutorialStep(step + 1));
|
||||||
|
}
|
||||||
|
}, [state, dispatch]);
|
||||||
|
|
||||||
// 오프라인 정산 알림 (1회)
|
// 오프라인 정산 알림 (1회)
|
||||||
const offlineShown = useRef(false);
|
const offlineShown = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,7 +260,18 @@ export default function Game() {
|
|||||||
🐾 고양이
|
🐾 고양이
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCandidates(engine.generateCandidates())}
|
onClick={() =>
|
||||||
|
setCandidates(
|
||||||
|
engine.generateCandidates(
|
||||||
|
3,
|
||||||
|
state.tutorialStep === 1
|
||||||
|
? "fishing"
|
||||||
|
: state.tutorialStep === 4
|
||||||
|
? "logging"
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={state.cats.length >= state.hall.capacity}
|
disabled={state.cats.length >= state.hall.capacity}
|
||||||
className="nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50"
|
className="nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -199,6 +288,46 @@ export default function Game() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 촌장 냥이 튜토리얼 가이드 ── */}
|
||||||
|
{(state.tutorialStep ?? TUTORIAL_DONE) < TUTORIAL_STEPS.length && (() => {
|
||||||
|
const step = state.tutorialStep ?? 0;
|
||||||
|
const def = TUTORIAL_STEPS[step];
|
||||||
|
const isLast = step === TUTORIAL_STEPS.length - 1;
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute bottom-8 left-4 z-40 flex max-w-md items-end gap-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={catSprite("korean_shorthair", "idle")}
|
||||||
|
alt="촌장 냥이"
|
||||||
|
className="h-24 w-24 shrink-0 object-contain drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="panel-paper pointer-events-auto relative mb-2 rounded-2xl p-4 shadow-xl">
|
||||||
|
<div className="font-jua text-[13px] text-[#8a6316]">
|
||||||
|
🎗️ 촌장 냥이 <span className="font-num text-[11px] text-[#a3906c]">({Math.min(step + 1, TUTORIAL_STEPS.length)}/{TUTORIAL_STEPS.length})</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-[#3f2c14]">{def.text}</p>
|
||||||
|
{def.task && (
|
||||||
|
<div className="chip-nature mt-2 inline-block px-2.5 py-1 text-xs font-semibold">
|
||||||
|
📌 {def.task}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{def.manual && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
dispatch(() =>
|
||||||
|
engine.setTutorialStep(isLast ? TUTORIAL_DONE : step + 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="nature-btn mt-3 block px-5 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{isLast ? "고마워, 촌장 냥이! 🐾" : "좋아, 시작하자!"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── DB 상태 (좌하단 미니) ── */}
|
{/* ── DB 상태 (좌하단 미니) ── */}
|
||||||
<div className="absolute bottom-2 left-3 z-30 opacity-70">
|
<div className="absolute bottom-2 left-3 z-30 opacity-70">
|
||||||
<DbStatus />
|
<DbStatus />
|
||||||
@@ -472,7 +601,9 @@ function ShopPanel({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
||||||
<div className="font-num text-xs text-[#8a6316]">
|
<div className="font-num text-xs text-[#8a6316]">
|
||||||
{costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")}
|
{costEntries.length > 0
|
||||||
|
? costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")
|
||||||
|
: "무료"}
|
||||||
{` · ⏱ ${def.buildTime}초`}
|
{` · ⏱ ${def.buildTime}초`}
|
||||||
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
|
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-1
@@ -149,7 +149,7 @@ export const APT_POINT_EVERY = 3; // 3레벨마다 적성 포인트 1
|
|||||||
|
|
||||||
export interface BuildDef {
|
export interface BuildDef {
|
||||||
id: string; // 건물 인스턴스 id (단일 건설)
|
id: string; // 건물 인스턴스 id (단일 건설)
|
||||||
type: "quarry" | "kitchen" | "cat_house" | "sun_deck";
|
type: "dock" | "lumberyard" | "quarry" | "kitchen" | "cat_house" | "sun_deck";
|
||||||
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
kind?: "work" | "passive"; // passive: 고양이 배치 없음 (기본 work)
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -163,6 +163,16 @@ export interface BuildDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BUILD_DEFS: BuildDef[] = [
|
export const BUILD_DEFS: BuildDef[] = [
|
||||||
|
{
|
||||||
|
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
||||||
|
aptitude: "fishing", resource: "fish", maxSlots: 3, cost: {}, reqHallLv: 1, buildTime: 15,
|
||||||
|
desc: "낚시 적성 고양이가 생선을 잡습니다. 강물 위에 지어요. 첫 건물이라 무료!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
||||||
|
aptitude: "logging", resource: "wood", maxSlots: 3, cost: { fish: 30 }, reqHallLv: 1, buildTime: 30,
|
||||||
|
desc: "벌목 적성 고양이가 목재를 모읍니다. 목재는 모든 건설의 기본!",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
|
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
|
||||||
aptitude: "cooking", maxSlots: 2, cost: { wood: 60 }, reqHallLv: 1, buildTime: 60,
|
aptitude: "cooking", maxSlots: 2, cost: { wood: 60 }, reqHallLv: 1, buildTime: 60,
|
||||||
|
|||||||
+18
-24
@@ -106,30 +106,16 @@ function generateCat(forceAptitude?: AptitudeKey): Cat {
|
|||||||
|
|
||||||
// ---------- 초기 상태 ----------
|
// ---------- 초기 상태 ----------
|
||||||
|
|
||||||
function createBuildings(): Building[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
|
||||||
pos: { ...DEFAULT_BUILDING_POS.dock_1 },
|
|
||||||
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
|
||||||
assigned: [], stored: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
|
||||||
pos: { ...DEFAULT_BUILDING_POS.lumberyard_1 },
|
|
||||||
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
|
||||||
assigned: [], stored: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInitialState(): GameState {
|
function createInitialState(): GameState {
|
||||||
// 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지)
|
// 새 게임: 회관만 있는 빈 마을에서 촌장 냥이 튜토리얼로 시작
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
boardVersion: BOARD_VERSION,
|
||||||
|
tutorialStep: 0,
|
||||||
resources: { fish: 0, wood: 0, stone: 0 },
|
resources: { fish: 0, wood: 0, stone: 0 },
|
||||||
cats: [generateCat("fishing"), generateCat("logging")],
|
cats: [],
|
||||||
buildings: createBuildings(),
|
buildings: [],
|
||||||
|
decors: [],
|
||||||
hall: { level: 1, capacity: 3 },
|
hall: { level: 1, capacity: 3 },
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -291,6 +277,7 @@ export const engine = {
|
|||||||
for (const b of state.buildings)
|
for (const b of state.buildings)
|
||||||
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
b.pos ??= { ...(DEFAULT_BUILDING_POS[b.id] ?? { i: 4, j: 5 }) };
|
||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
// 보드 개편(크기·풋프린트 변경) 시 건물 위치를 새 기본값으로 재배치
|
||||||
if (state.boardVersion !== BOARD_VERSION) {
|
if (state.boardVersion !== BOARD_VERSION) {
|
||||||
for (const b of state.buildings)
|
for (const b of state.buildings)
|
||||||
@@ -317,6 +304,12 @@ export const engine = {
|
|||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 튜토리얼 진행 갱신 */
|
||||||
|
setTutorialStep(step: number) {
|
||||||
|
if (!state) return;
|
||||||
|
state.tutorialStep = step;
|
||||||
|
},
|
||||||
|
|
||||||
tick(dt: number) {
|
tick(dt: number) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
for (const building of state.buildings) {
|
for (const building of state.buildings) {
|
||||||
@@ -489,9 +482,9 @@ export const engine = {
|
|||||||
state.updatedAt = Date.now();
|
state.updatedAt = Date.now();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 입양 후보 생성 (아직 마을에 합류하지 않음) */
|
/** 입양 후보 생성 (아직 마을에 합류하지 않음). forceApt로 특정 적성 보장 */
|
||||||
generateCandidates(count = 3): Cat[] {
|
generateCandidates(count = 3, forceApt?: AptitudeKey): Cat[] {
|
||||||
return Array.from({ length: count }, () => generateCat());
|
return Array.from({ length: count }, () => generateCat(forceApt));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 후보 중 한 마리 입양 확정 */
|
/** 후보 중 한 마리 입양 확정 */
|
||||||
@@ -505,7 +498,7 @@ export const engine = {
|
|||||||
|
|
||||||
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
/** 공통 건설: 상점 구매 후 선택한 타일에 배치 (비용은 배치 확정 시 차감) */
|
||||||
build(
|
build(
|
||||||
type: "quarry" | "kitchen" | "cat_house" | "sun_deck",
|
type: "dock" | "lumberyard" | "quarry" | "kitchen" | "cat_house" | "sun_deck",
|
||||||
pos: { i: number; j: number },
|
pos: { i: number; j: number },
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
@@ -563,6 +556,7 @@ export const engine = {
|
|||||||
.join(", ")})`;
|
.join(", ")})`;
|
||||||
consume(state, def.cost);
|
consume(state, def.cost);
|
||||||
state.decors ??= [];
|
state.decors ??= [];
|
||||||
|
state.tutorialStep ??= 99; // 기존 마을은 튜토리얼 생략
|
||||||
// 같은 타일의 기존 장식 제거 후 배치
|
// 같은 타일의 기존 장식 제거 후 배치
|
||||||
state.decors = state.decors.filter(
|
state.decors = state.decors.filter(
|
||||||
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
(d) => !(d.pos.i === pos.i && d.pos.j === pos.j),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface DecoTile {
|
|||||||
export interface GameState {
|
export interface GameState {
|
||||||
version: 1;
|
version: 1;
|
||||||
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
||||||
|
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
||||||
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
||||||
resources: Record<ResourceKey, number>;
|
resources: Record<ResourceKey, number>;
|
||||||
cats: Cat[];
|
cats: Cat[];
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단