배경을 잔디·강물로 전면 채움 (하늘색 여백 제거)

- 캔버스가 뷰포트를 픽셀 단위로 가득 채우고 월드 좌표계로 렌더
  (zoom = 보드 fit 배율, 보드는 월드 중앙 배치, 리사이즈 대응)
- 잔디 텍스처를 화면 전체에 커버, 격자 힌트는 플레이 보드에만 유지
- 강 스트립을 화면 밖까지 연장 (양방향 ±30타일) + 반짝임 오버레이 동일 적용
- 보드 밖 배경에 나무 16그루·꽃·바위 장식으로 숲 프레임 연출
- 클릭 좌표를 zoom 기반 월드 좌표로 변환

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 14:31:11 +09:00
co-authored by Claude Fable 5
부모 c50c266f2e
커밋 2d1ae603b4
+84 -51
파일 보기
@@ -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<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [size, setSize] = useState({ w: 0, h: 0 });
const animsRef = useRef<Map<string, CatAnim>>(new Map());
const bubblesRef = useRef<Bubble[]>([]);
const toolImagesRef = useRef<Map<AptitudeKey, HTMLImageElement>>(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 (
<div
ref={wrapRef}
className="absolute inset-0 flex items-center justify-center overflow-hidden bg-linear-to-b from-sky-100 to-emerald-50 dark:from-sky-950 dark:to-emerald-950"
>
<div ref={wrapRef} className="absolute inset-0 overflow-hidden bg-[#82c368]">
<canvas
ref={canvasRef}
style={{
width: CANVAS_W * scale,
height: CANVAS_H * scale,
cursor: "pointer",
}}
style={{ width: "100%", height: "100%", cursor: "pointer" }}
/>
</div>
);