From 2d1ae603b41d891622a7ac25e9662b459092c072 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 13 Aug 2026 14:31:11 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=EA=B2=BD=EC=9D=84=20=EC=9E=94?= =?UTF-8?q?=EB=94=94=C2=B7=EA=B0=95=EB=AC=BC=EB=A1=9C=20=EC=A0=84=EB=A9=B4?= =?UTF-8?q?=20=EC=B1=84=EC=9B=80=20(=ED=95=98=EB=8A=98=EC=83=89=20?= =?UTF-8?q?=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캔버스가 뷰포트를 픽셀 단위로 가득 채우고 월드 좌표계로 렌더 (zoom = 보드 fit 배율, 보드는 월드 중앙 배치, 리사이즈 대응) - 잔디 텍스처를 화면 전체에 커버, 격자 힌트는 플레이 보드에만 유지 - 강 스트립을 화면 밖까지 연장 (양방향 ±30타일) + 반짝임 오버레이 동일 적용 - 보드 밖 배경에 나무 16그루·꽃·바위 장식으로 숲 프레임 연출 - 클릭 좌표를 zoom 기반 월드 좌표로 변환 Co-Authored-By: Claude Fable 5 --- src/components/game/VillageView.tsx | 135 +++++++++++++++++----------- 1 file changed, 84 insertions(+), 51 deletions(-) diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx index e33e752..e8226cf 100644 --- a/src/components/game/VillageView.tsx +++ b/src/components/game/VillageView.tsx @@ -10,13 +10,16 @@ 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 BOARD_H = ((COLS + ROWS) * TH) / 2; // 보드 픽셀 높이 +const CANVAS_W = ((COLS + ROWS) * TW) / 2 + 16; // 줌 산정 기준(보드+여백) +const CANVAS_H = BOARD_H + 104; -const isoX = (i: number, j: number) => ORIGIN_X + ((i - j) * TW) / 2; -const isoY = (i: number, j: number) => PAD_Y + ((i + j) * TH) / 2; +// 화면 크기에 따라 매 프레임 전 갱신되는 월드 원점 (보드가 화면 중앙에 오도록) +let originX = ((ROWS * TW) / 2) + 8; +let originY = 56; + +const isoX = (i: number, j: number) => originX + ((i - j) * TW) / 2; +const isoY = (i: number, j: number) => originY + ((i + j) * TH) / 2; // 물 (동쪽 끝 열), 부두는 물가에 붙임 const isWater = (i: number, j: number) => i >= COLS - 1; @@ -39,6 +42,20 @@ const DECOR: Array<{ i: number; j: number; emoji: string }> = [ { i: 1, j: 3, emoji: "🪨" }, ]; +// 보드 밖 배경 장식 (마을을 둘러싼 숲·들판 느낌) +const OUTER_TREES: Array<{ i: number; j: number }> = [ + { i: -2, j: 2 }, { i: -3, j: 5 }, { i: -2, j: 8 }, { i: -1.5, j: 11 }, + { i: 1, j: 12.5 }, { i: 3.5, j: 12 }, { i: 6, j: 12.5 }, { i: 9, j: 12 }, + { i: 12, j: 11.5 }, { i: 1, j: -2.5 }, { i: 3.5, j: -2 }, { i: 6.5, j: -2.5 }, + { i: 9, j: -2 }, { i: 12, j: -1.5 }, { i: -1, j: -1 }, { i: 13, j: 2.5 }, +]; +const OUTER_DECOR: Array<{ i: number; j: number; emoji: string }> = [ + { i: -1.5, j: 3.5, emoji: "🌼" }, { i: -2, j: 6.5, emoji: "🌷" }, + { i: 2, j: 11.8, emoji: "🌾" }, { i: 7.5, j: 12.2, emoji: "🌼" }, + { i: 5, j: -1.8, emoji: "🪨" }, { i: 10.5, j: -1.6, emoji: "🌾" }, + { i: -0.5, j: 9.5, emoji: "🍄" }, { i: 11, j: 12.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) => @@ -93,7 +110,7 @@ export default function VillageView({ }) { const canvasRef = useRef(null); const wrapRef = useRef(null); - const [scale, setScale] = useState(1); + const [size, setSize] = useState({ w: 0, h: 0 }); const animsRef = useRef>(new Map()); const bubblesRef = useRef([]); const toolImagesRef = useRef>(new Map()); @@ -112,9 +129,7 @@ export default function VillageView({ const el = wrapRef.current; if (!el) return; const update = () => - setScale( - Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H) * 0.98, - ); + setSize({ w: el.clientWidth, h: el.clientHeight }); update(); const ro = new ResizeObserver(update); ro.observe(el); @@ -123,14 +138,21 @@ export default function VillageView({ useEffect(() => { const canvas = canvasRef.current; - if (!canvas) return; + if (!canvas || size.w === 0 || size.h === 0) 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); + // 보드(+여백)가 화면에 들어오는 배율로 확대하고, 월드가 화면 전체를 덮게 함 + const zoom = Math.min(size.w / CANVAS_W, size.h / CANVAS_H) * 0.98; + const worldW = size.w / zoom; + const worldH = size.h / zoom; + canvas.width = size.w * dpr; + canvas.height = size.h * dpr; + ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, 0, 0); + // 보드를 월드 중앙에 배치 + originX = worldW / 2; + originY = (worldH - BOARD_H) / 2; // 도구 일러스트 프리로드 (로드 전에는 이모지로 폴백) for (const [key, src] of Object.entries(TOOL_IMG)) { @@ -256,9 +278,8 @@ export default function VillageView({ for (const id of anims.keys()) if (!alive.has(id)) anims.delete(id); // --- 그리기 --- - ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); + ctx.clearRect(0, 0, worldW, worldH); - // 하늘 배경 없음(투명) — 타일부터 const tilePath = (i: number, j: number) => { const x = isoX(i, j); const y = isoY(i, j); @@ -268,25 +289,24 @@ export default function VillageView({ ctx.lineTo(x - TW / 2, y + TH / 2); ctx.closePath(); }; + // 격자 꼭짓점 (i,j) 좌표 — 강 스트립 확장에 사용 + const vertex = (i: number, j: number) => ({ + x: originX + ((i - j) * TW) / 2, + y: originY + ((i + j) * TH) / 2, + }); - // 잔디 — 일러스트 텍스처를 잔디 타일 영역에 클리핑해서 채움 + // 잔디 — 화면 전체를 잔디로 채움 (마을 밖도 들판) { - 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 grass = grassImgRef.current; if (grass) { - const scale = Math.max(CANVAS_W / grass.width, CANVAS_H / grass.height); - ctx.drawImage(grass, 0, 0, grass.width * scale, grass.height * scale); + const s = Math.max(worldW / grass.width, worldH / grass.height); + ctx.drawImage(grass, 0, 0, grass.width * s, grass.height * s); } else { ctx.fillStyle = "#82c368"; - ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + ctx.fillRect(0, 0, worldW, worldH); } - // 타일 격자 힌트 (배치 가독성 유지) + // 타일 격자 힌트 — 플레이 가능한 보드 영역에만 for (let j = 0; j < ROWS; j++) { for (let i = 0; i < COLS; i++) { if (isWater(i, j)) continue; @@ -300,25 +320,34 @@ export default function VillageView({ ctx.stroke(); } } - ctx.restore(); } - // 강물 — 일러스트 텍스처를 물 타일 영역에 클리핑해서 채움 + // 강물 — 강 줄기를 화면 끝까지 연장한 스트립에 텍스처 채움 { + const J_EXT = 30; // 화면 밖까지 강을 연장할 타일 수 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); + { + const a = vertex(COLS - 1, -J_EXT); + const b = vertex(COLS, -J_EXT); + const c = vertex(COLS, ROWS + J_EXT); + const d = vertex(COLS - 1, ROWS + J_EXT); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.lineTo(c.x, c.y); + ctx.lineTo(d.x, d.y); + ctx.closePath(); + } ctx.clip(); const water = waterImgRef.current; if (water) { // 강 줄기 방향(상류→하류, 좌하향)으로 한 방향 흐름: // 컨텍스트를 강 방향으로 회전시킨 뒤 텍스처를 미러 타일링하며 스크롤 - const sx = isoX(COLS - 1, 0); - const sy = isoY(COLS - 1, 0) + TH / 2; - const ex = isoX(COLS - 1, ROWS - 1); - const ey = isoY(COLS - 1, ROWS - 1) + TH / 2; + const sx = isoX(COLS - 1, -J_EXT); + const sy = isoY(COLS - 1, -J_EXT) + TH / 2; + const ex = isoX(COLS - 1, ROWS + J_EXT); + const ey = isoY(COLS - 1, ROWS + J_EXT) + TH / 2; const angle = Math.atan2(ey - sy, ex - sx); const riverLen = Math.hypot(ex - sx, ey - sy); @@ -330,7 +359,7 @@ export default function VillageView({ ctx.save(); ctx.translate(sx, sy); ctx.rotate(angle); - const copies = Math.ceil((riverLen + TW * 3) / w) + 2; + const copies = Math.ceil(riverLen / w) + 3; for (let k = -2; k < copies; k++) { const xk = k * w - offset; ctx.save(); @@ -342,11 +371,11 @@ export default function VillageView({ ctx.restore(); } else { ctx.fillStyle = "#60a5fa"; - ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + ctx.fillRect(0, 0, worldW, worldH); } - // 은은한 반짝임 오버레이 - for (let j = 0; j < ROWS; j++) { + // 은은한 반짝임 오버레이 (연장 구간 포함) + for (let j = -J_EXT; j < ROWS + J_EXT; j++) { const glint = 0.05 + 0.05 * Math.sin(now / 700 + j); ctx.beginPath(); tilePath(COLS - 1, j); @@ -389,6 +418,17 @@ export default function VillageView({ const y = isoY(d.i, d.j) + TH / 2; sprites.push({ y: y - 6, draw: () => drawEmoji(d.emoji, x, y + 4, 18) }); } + // 보드 밖 배경 장식 (마을을 둘러싼 숲) + for (const t of OUTER_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, 15); drawEmoji("🌲", x, y + 6, 38); } }); + } + for (const d of OUTER_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) }); + } hitRectsRef.current = []; @@ -555,8 +595,8 @@ export default function VillageView({ 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; + const x = (e.clientX - rect.left) / zoom; + const y = (e.clientY - rect.top) / zoom; const hit = (r: { x: number; y: number; w: number; h: number }) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; @@ -590,20 +630,13 @@ export default function VillageView({ cancelAnimationFrame(raf); canvas.removeEventListener("click", onClick); }; - }, []); + }, [size]); return ( -
+
);