"use client"; import { useEffect, useRef } from "react"; import { engine, catRate } from "@/lib/game/engine"; import { TOOL_IMG } from "@/lib/game/data"; import type { AptitudeKey } from "@/lib/game/types"; // ---------- 아이소메트릭 그리드 ---------- 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 toolImagesRef = useRef>(new Map()); const waterImgRef = useRef(null); 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); // 도구 일러스트 프리로드 (로드 전에는 이모지로 폴백) for (const [key, src] of Object.entries(TOOL_IMG)) { const img = new Image(); img.src = src; img.onload = () => toolImagesRef.current.set(key as AptitudeKey, img); } // 강물 텍스처 (로드 전에는 단색 폴백) { const img = new Image(); img.src = "/assets/water.jpg"; img.onload = () => (waterImgRef.current = img); } 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); // 하늘 배경 없음(투명) — 타일부터 const tilePath = (i: number, j: number) => { const x = isoX(i, j); const y = isoY(i, j); 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(); }; for (let j = 0; j < ROWS; j++) { for (let i = 0; i < COLS; i++) { if (isWater(i, j)) continue; ctx.beginPath(); tilePath(i, j); ctx.fillStyle = (i + j) % 2 === 0 ? "#86c66b" : "#7dbd62"; ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.10)"; ctx.stroke(); } } // 강물 — 일러스트 텍스처를 물 타일 영역에 클리핑해서 채움 { ctx.save(); ctx.beginPath(); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) if (isWater(i, j)) tilePath(i, j); ctx.clip(); const water = waterImgRef.current; if (water) { // 물 영역 바운딩 박스를 덮도록 cover 배치 + 흐르는 방향으로 느린 드리프트 const left = isoX(COLS - 1, ROWS - 1) - TW / 2; const right = isoX(COLS - 1, 0) + TW / 2; const top = isoY(COLS - 1, 0); const bottom = isoY(COLS - 1, ROWS - 1) + TH; const boxW = right - left; const boxH = bottom - top; const scale = Math.max(boxW / water.width, boxH / water.height) * 1.15; const w = water.width * scale; const h = water.height * scale; // 강 줄기 방향(좌하향)으로 왕복 드리프트 const drift = Math.sin(now / 4000) * 14; ctx.drawImage( water, left + (boxW - w) / 2 - drift, top + (boxH - h) / 2 + drift / 2, w, h, ); } else { ctx.fillStyle = "#60a5fa"; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); } // 은은한 반짝임 오버레이 for (let j = 0; j < ROWS; j++) { const glint = 0.05 + 0.05 * Math.sin(now / 700 + j); ctx.beginPath(); tilePath(COLS - 1, j); ctx.fillStyle = `rgba(255,255,255,${glint})`; ctx.fill(); } ctx.restore(); } // 그릴 것들을 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); const img = toolImagesRef.current.get(b.aptitude); if (img) { // 세로가 긴 일러스트 — 높이 56px 기준으로 비율 유지 const h = 56; const w = (img.width / img.height) * h; ctx.drawImage(img, x - w / 2, y + 8 - h, w, h); } else { 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 (

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

); }