diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..00a640c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "고양이 마을", + description: "방치형 고양이 마을 경영 게임", }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 9960fe3..f87f09d 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -2,6 +2,7 @@ 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"; @@ -28,6 +29,8 @@ export default function Game() {
run(() => engine.adopt())} /> + + {message && (

⚠️ {message} diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx new file mode 100644 index 0000000..bb9b64c --- /dev/null +++ b/src/components/game/VillageView.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { engine, catRate } from "@/lib/game/engine"; + +// ---------- 아이소메트릭 그리드 ---------- +const COLS = 11; +const ROWS = 11; +const TW = 64; // 타일 가로 +const TH = 32; // 타일 세로 +const PAD_Y = 56; +const CANVAS_W = ((COLS + ROWS) * TW) / 2 + 16; +const CANVAS_H = ((COLS + ROWS) * TH) / 2 + PAD_Y + 48; +const ORIGIN_X = (ROWS * TW) / 2 + 8; + +const isoX = (i: number, j: number) => ORIGIN_X + ((i - j) * TW) / 2; +const isoY = (i: number, j: number) => PAD_Y + ((i + j) * TH) / 2; + +// 물 (동쪽 끝 열), 부두는 물가에 붙임 +const isWater = (i: number, j: number) => i >= COLS - 1; + +// 건물 위치 (타일 좌표) +const BUILDING_TILES: Record = { + hall: { i: 5, j: 3 }, + dock_1: { i: 9, j: 5 }, + lumberyard_1: { i: 2, j: 8 }, +}; + +const TREES: Array<{ i: number; j: number }> = [ + { i: 0, j: 7 }, { i: 1, j: 9 }, { i: 3, j: 9 }, { i: 0, j: 9 }, + { i: 1, j: 6 }, { i: 3, j: 7 }, +]; +const DECOR: Array<{ i: number; j: number; emoji: string }> = [ + { i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" }, + { i: 8, j: 8, emoji: "🌾" }, { i: 6, j: 9, emoji: "🍄" }, + { i: 1, j: 3, emoji: "🪨" }, +]; + +const isBuildingTile = (i: number, j: number) => + Object.values(BUILDING_TILES).some((t) => t.i === i && t.j === j); +const isWalkable = (i: number, j: number) => + i >= 0 && i < COLS - 1 && j >= 0 && j < ROWS && + !isBuildingTile(i, j) && + !TREES.some((t) => t.i === i && t.j === j); + +// ---------- 고양이 이동 상태 (렌더 전용, 저장 안 함) ---------- +interface CatAnim { + x: number; + y: number; + tx: number; + ty: number; + mode: "wander" | "toWork" | "working"; + waitUntil: number; + flip: boolean; +} +interface Bubble { + x: number; + y: number; + born: number; + text: string; +} + +const WALK_SPEED = 42; // px/초 + +function randomWalkTarget(): { x: number; y: number } { + for (let tries = 0; tries < 30; tries++) { + const i = Math.floor(Math.random() * (COLS - 1)); + const j = Math.floor(Math.random() * ROWS); + if (isWalkable(i, j)) return { x: isoX(i, j), y: isoY(i, j) + TH / 2 }; + } + return { x: isoX(5, 5), y: isoY(5, 5) + TH / 2 }; +} + +function workSpot(buildingId: string, slotIndex: number) { + const tile = BUILDING_TILES[buildingId] ?? BUILDING_TILES.hall; + // 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치 + const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6; + return { + x: isoX(tile.i, tile.j) + Math.cos(angle) * 42, + y: isoY(tile.i, tile.j) + TH / 2 + Math.sin(angle) * 16 + 8, + }; +} + +export default function VillageView() { + const canvasRef = useRef(null); + const animsRef = useRef>(new Map()); + const bubblesRef = useRef([]); + const hitRectsRef = useRef< + Array<{ id: string; x: number; y: number; w: number; h: number }> + >([]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = CANVAS_W * dpr; + canvas.height = CANVAS_H * dpr; + ctx.scale(dpr, dpr); + + let raf = 0; + let last = performance.now(); + + const draw = (now: number) => { + const dt = Math.min((now - last) / 1000, 0.1); + last = now; + const state = engine.peek(); + if (!state) { + raf = requestAnimationFrame(draw); + return; + } + + // --- 고양이 이동 갱신 --- + const anims = animsRef.current; + const alive = new Set(); + for (const cat of state.cats) { + alive.add(cat.id); + let anim = anims.get(cat.id); + if (!anim) { + const start = randomWalkTarget(); + anim = { ...start, tx: start.x, ty: start.y, mode: "wander", waitUntil: 0, flip: false }; + anims.set(cat.id, anim); + } + + if (cat.assignedTo) { + const building = state.buildings.find((b) => b.id === cat.assignedTo); + const slot = building ? building.assigned.indexOf(cat.id) : 0; + const spot = workSpot(cat.assignedTo, Math.max(0, slot)); + if (anim.mode !== "working" && (anim.tx !== spot.x || anim.ty !== spot.y)) { + anim.tx = spot.x; + anim.ty = spot.y; + anim.mode = "toWork"; + } + } else if (anim.mode !== "wander") { + anim.mode = "wander"; + const t = randomWalkTarget(); + anim.tx = t.x; + anim.ty = t.y; + } + + const dx = anim.tx - anim.x; + const dy = anim.ty - anim.y; + const dist = Math.hypot(dx, dy); + if (dist > 2) { + const step = Math.min(WALK_SPEED * dt, dist); + anim.x += (dx / dist) * step; + anim.y += (dy / dist) * step; + anim.flip = dx < 0; + } else if (anim.mode === "toWork") { + anim.mode = "working"; + } else if (anim.mode === "working") { + // 일하는 중 — 생산 말풍선 (약 2.5초에 한 번) + if (Math.random() < dt / 2.5 && cat.assignedTo) { + const b = state.buildings.find((x) => x.id === cat.assignedTo); + if (b && catRate(cat, b.aptitude) > 0) { + bubblesRef.current.push({ + x: anim.x, y: anim.y - 34, born: now, + text: b.resource === "fish" ? "🐟" : "🪵", + }); + } + } + } else if (anim.mode === "wander") { + if (anim.waitUntil === 0) { + anim.waitUntil = now + 800 + Math.random() * 2600; + } else if (now >= anim.waitUntil) { + anim.waitUntil = 0; + const t = randomWalkTarget(); + anim.tx = t.x; + anim.ty = t.y; + } + } + } + for (const id of anims.keys()) if (!alive.has(id)) anims.delete(id); + + // --- 그리기 --- + ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); + + // 하늘 배경 없음(투명) — 타일부터 + for (let j = 0; j < ROWS; j++) { + for (let i = 0; i < COLS; i++) { + const x = isoX(i, j); + const y = isoY(i, j); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + TW / 2, y + TH / 2); + ctx.lineTo(x, y + TH); + ctx.lineTo(x - TW / 2, y + TH / 2); + ctx.closePath(); + if (isWater(i, j)) { + const shimmer = 0.85 + 0.15 * Math.sin(now / 700 + j); + ctx.fillStyle = `rgba(96, 165, 250, ${shimmer})`; + } else { + ctx.fillStyle = (i + j) % 2 === 0 ? "#86c66b" : "#7dbd62"; + } + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.10)"; + ctx.stroke(); + } + } + + // 그릴 것들을 y 순으로 정렬해 겹침 처리 + type Sprite = { y: number; draw: () => void }; + const sprites: Sprite[] = []; + + const drawEmoji = (emoji: string, x: number, y: number, size: number, flip = false) => { + ctx.save(); + ctx.translate(x, y); + if (flip) ctx.scale(-1, 1); + ctx.font = `${size}px serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + // 컬러 이모지에도 fillStyle의 알파가 적용되므로 반드시 불투명으로 리셋 + ctx.fillStyle = "#111"; + ctx.fillText(emoji, 0, 0); + ctx.restore(); + }; + const shadow = (x: number, y: number, w: number) => { + ctx.beginPath(); + ctx.ellipse(x, y, w, w * 0.35, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.15)"; + ctx.fill(); + }; + + for (const t of TREES) { + const x = isoX(t.i, t.j); + const y = isoY(t.i, t.j) + TH / 2; + sprites.push({ y, draw: () => { shadow(x, y + 4, 14); drawEmoji("🌲", x, y + 6, 34); } }); + } + for (const d of DECOR) { + const x = isoX(d.i, d.j); + const y = isoY(d.i, d.j) + TH / 2; + sprites.push({ y: y - 6, draw: () => drawEmoji(d.emoji, x, y + 4, 18) }); + } + + // 회관 + { + const t = BUILDING_TILES.hall; + const x = isoX(t.i, t.j); + const y = isoY(t.i, t.j) + TH / 2; + sprites.push({ + y, + draw: () => { + shadow(x, y + 6, 26); + drawEmoji("🏛️", x, y + 8, 48); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillStyle = "#374151"; + ctx.fillText(`마을 회관 Lv${state.hall.level}`, x, y + 24); + }, + }); + } + + // 생산 건물 + 회수 배지 + hitRectsRef.current = []; + for (const b of state.buildings) { + const t = BUILDING_TILES[b.id]; + if (!t) continue; + const x = isoX(t.i, t.j); + const y = isoY(t.i, t.j) + TH / 2; + const stored = Math.floor(b.stored); + hitRectsRef.current.push({ id: b.id, x: x - 30, y: y - 44, w: 60, h: 72 }); + sprites.push({ + y, + draw: () => { + shadow(x, y + 6, 24); + drawEmoji(b.emoji, x, y + 8, 42); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillStyle = "#374151"; + ctx.fillText(b.name, x, y + 24); + if (stored > 0) { + const label = `${b.resource === "fish" ? "🐟" : "🪵"}${stored}`; + ctx.font = "bold 12px sans-serif"; + const w = ctx.measureText(label).width + 12; + ctx.beginPath(); + ctx.roundRect(x - w / 2, y - 58, w, 20, 10); + ctx.fillStyle = "#f97316"; + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.fillText(label, x, y - 43); + } + }, + }); + } + + // 고양이 + for (const cat of state.cats) { + const anim = anims.get(cat.id); + if (!anim) continue; + const working = anim.mode === "working"; + const moving = Math.hypot(anim.tx - anim.x, anim.ty - anim.y) > 2; + const bob = working + ? Math.sin(now / 130) * 3 + : moving + ? Math.abs(Math.sin(now / 110)) * -3 + : 0; + sprites.push({ + y: anim.y, + draw: () => { + shadow(anim.x, anim.y + 2, 10); + drawEmoji(cat.emoji, anim.x, anim.y + bob, 30, anim.flip); + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(55,65,81,0.85)"; + ctx.fillText(cat.name, anim.x, anim.y + 12); + if (working) drawEmoji("💦", anim.x + 12, anim.y - 20 + bob, 10); + }, + }); + } + + sprites.sort((a, b) => a.y - b.y); + for (const s of sprites) s.draw(); + + // 생산 말풍선 + const bubbles = bubblesRef.current; + for (let k = bubbles.length - 1; k >= 0; k--) { + const bub = bubbles[k]; + const age = (now - bub.born) / 1200; + if (age >= 1) { + bubbles.splice(k, 1); + continue; + } + ctx.globalAlpha = 1 - age; + drawEmoji(bub.text, bub.x, bub.y - age * 22, 14); + ctx.globalAlpha = 1; + } + + raf = requestAnimationFrame(draw); + }; + + raf = requestAnimationFrame(draw); + + const onClick = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * CANVAS_W; + const y = ((e.clientY - rect.top) / rect.height) * CANVAS_H; + for (const r of hitRectsRef.current) { + if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { + const state = engine.peek(); + const b = state?.buildings.find((bb) => bb.id === r.id); + const amount = b ? Math.floor(b.stored) : 0; + if (b && amount > 0) { + engine.collect(r.id); + engine.save(); + bubblesRef.current.push({ + x: r.x + r.w / 2, y: r.y, born: performance.now(), + text: `+${amount} ${b.resource === "fish" ? "🐟" : "🪵"}`, + }); + } + return; + } + } + }; + canvas.addEventListener("click", onClick); + + return () => { + cancelAnimationFrame(raf); + canvas.removeEventListener("click", onClick); + }; + }, []); + + return ( +

+ +

+ 건물 위 주황 배지를 클릭하면 산출물을 회수합니다 +

+
+ ); +} diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index f619d94..2e92686 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -212,4 +212,9 @@ export const engine = { snapshot(): GameState | null { return state ? (JSON.parse(JSON.stringify(state)) as GameState) : null; }, + + /** 렌더 루프용 라이브 참조 (읽기 전용으로만 사용할 것 — 매 프레임 클론 비용 회피) */ + peek(): GameState | null { + return state; + }, };