From f5ae61a8792470192fb90086284e56643a85f0d2 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 12 Aug 2026 17:44:28 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=A0=EC=96=91=EC=9D=B4=20=EB=A7=88?= =?UTF-8?q?=EC=9D=84=201=EB=8B=A8=EA=B3=84:=20=EA=B0=9C=EC=B2=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=C2=B7=EB=B0=B0=EC=B9=98=C2=B7=ED=8B=B1=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=C2=B7=EC=88=98=EB=8F=99=20=ED=9A=8C=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품종 8종·패시브·랜덤 개체 생성 (기획서 4.2, 5장) - 부두/벌목장 배치, 적성 0 배치 불가, 적성 비선형 작업량 (4.4) - rAF+델타 타임 엔진(모듈 싱글턴), UI 300ms 동기화 (기술 메모) - 수동 회수(운반 수레 전), localStorage 저장(version 필드) - 회관 Lv1 수용 3마리, 입양 버튼 Co-Authored-By: Claude Fable 5 --- .gitignore | 6 + src/app/page.tsx | 44 ++---- src/components/game/Game.tsx | 253 +++++++++++++++++++++++++++++++++++ src/hooks/useGame.ts | 55 ++++++++ src/lib/game/data.ts | 63 +++++++++ src/lib/game/engine.ts | 215 +++++++++++++++++++++++++++++ src/lib/game/types.ts | 56 ++++++++ 7 files changed, 657 insertions(+), 35 deletions(-) create mode 100644 src/components/game/Game.tsx create mode 100644 src/hooks/useGame.ts create mode 100644 src/lib/game/data.ts create mode 100644 src/lib/game/engine.ts create mode 100644 src/lib/game/types.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..900ae04 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# 로컬 테스트 산출물 +.playwright-mcp/ +*.png +!public/*.png +!src/**/*.png diff --git a/src/app/page.tsx b/src/app/page.tsx index 66c27a8..a2484ff 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,51 +1,25 @@ +import Game from "@/components/game/Game"; import DbStatus from "@/components/DbStatus"; export default function Home() { return (
- {/* 헤더 */}
- jskim - + 🏡 고양이 마을 + 방치형 경영 게임 — 1단계
- {/* 메인 */} -
-
-

메인 페이지

-

- jskim.vexplor.com — Next.js + PostgreSQL 기본 틀 -

-
- -
-
- - {/* 콘텐츠 카드 자리 */} -
- {["섹션 A", "섹션 B", "섹션 C"].map((title) => ( -
-

{title}

-

- 콘텐츠가 들어갈 자리입니다. -

-
- ))} -
+
+
- {/* 푸터 */}
- jskim.vexplor.com · dev-pilots +
+ jskim.vexplor.com · dev-pilots + +
); diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx new file mode 100644 index 0000000..9960fe3 --- /dev/null +++ b/src/components/game/Game.tsx @@ -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(null); + const [message, setMessage] = useState(null); + + if (!state) { + return ( +
마을 불러오는 중…
+ ); + } + + const selectedCat = state.cats.find((c) => c.id === selectedCatId) ?? null; + + const run = (action: () => string | null | void) => { + const error = dispatch(action); + setMessage(error ?? null); + }; + + return ( +
+ run(() => engine.adopt())} /> + + {message && ( +

+ ⚠️ {message} +

+ )} + +
+

🏗️ 생산 건물

+

+ 고양이를 선택한 뒤 빈자리를 클릭해 배치하세요. 산출물은 직접 회수해야 합니다. +

+
+ {state.buildings.map((b) => ( + run(() => engine.assign(catId, b.id))} + onUnassign={(catId) => run(() => engine.unassign(catId))} + onCollect={() => run(() => engine.collect(b.id))} + /> + ))} +
+
+ +
+

+ 🐾 우리 고양이들 ({state.cats.length}/{state.hall.capacity}) +

+
+ {state.cats.map((cat) => ( + + setSelectedCatId(cat.id === selectedCatId ? null : cat.id) + } + /> + ))} +
+
+
+ ); +} + +function TopBar({ state, onAdopt }: { state: GameState; onAdopt: () => void }) { + const full = state.cats.length >= state.hall.capacity; + return ( +
+ 🏛️ 마을 회관 Lv{state.hall.level} + 🐱 {state.cats.length}/{state.hall.capacity}마리 + 🐟 {Math.floor(state.resources.fish)} + 🪵 {Math.floor(state.resources.wood)} + +
+ ); +} + +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 ( +
+
+

+ {building.emoji} {building.name} Lv{building.level} +

+ + 필요 적성: {APTITUDE_LABEL[building.aptitude]} + +
+

+ 생산 속도 {totalRate.toFixed(2)}/초 +

+ +
+ {assignedCats.map((cat) => ( + + ))} + {Array.from({ length: emptySlots }).map((_, i) => ( + + ))} +
+ + +
+ ); +} + +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 ( + + ); +} diff --git a/src/hooks/useGame.ts b/src/hooks/useGame.ts new file mode 100644 index 0000000..8db7c6b --- /dev/null +++ b/src/hooks/useGame.ts @@ -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(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 }; +} diff --git a/src/lib/game/data.ts b/src/lib/game/data.ts new file mode 100644 index 0000000..7546c9f --- /dev/null +++ b/src/lib/game/data.ts @@ -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 = { + 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 = [ + "치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두", + "감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이", + "달이", "별이", "쿠키", "만두", "생강", "참깨", "누룽지", "조약돌", +]; diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts new file mode 100644 index 0000000..f619d94 --- /dev/null +++ b/src/lib/game/engine.ts @@ -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 = (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; + 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; + }, +}; diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts new file mode 100644 index 0000000..35fe7ea --- /dev/null +++ b/src/lib/game/types.ts @@ -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; + 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; + cats: Cat[]; + buildings: Building[]; + hall: { level: number; capacity: number }; + updatedAt: number; +}