배경을 잔디·강물로 전면 채움 (하늘색 여백 제거)
- 캔버스가 뷰포트를 픽셀 단위로 가득 채우고 월드 좌표계로 렌더 (zoom = 보드 fit 배율, 보드는 월드 중앙 배치, 리사이즈 대응) - 잔디 텍스처를 화면 전체에 커버, 격자 힌트는 플레이 보드에만 유지 - 강 스트립을 화면 밖까지 연장 (양방향 ±30타일) + 반짝임 오버레이 동일 적용 - 보드 밖 배경에 나무 16그루·꽃·바위 장식으로 숲 프레임 연출 - 클릭 좌표를 zoom 기반 월드 좌표로 변환 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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>
|
||||
);
|
||||
|
||||
새 이슈에서 참조
사용자 차단