Canvas를 이용한 browser 공튀기기

2025년 06월 09일

브라우저에서 공튀기기

canvas와 web animation API - requestAnimationFrame을 이용하여 브라우저의 끝에 닿으면 튕기는 애니메이션을 React로 구현 하였습니다.

개발 환경 및 참고 자료

  • React19
  • typescript
  • css

이 프로젝트는 Interactive Developer youtube 채널을 참고하여 구현 하였습니다.

개발

useCanvasApp.ts

useCanvasApp을 만들어서 Canvas를 정의하고 requestsAnimationFrame을 사용하여 기본적인 애니메이션의 시작을 만들었습니다.

Canvas

const canvas = useRef<HTMLCanvasElement>(null);
const ctx = useRef<CanvasRenderingContext2D | null>(null);
const rafRef = useRef<number | null>(null);
  • canvas: Canvas 엘리먼트 참조
  • ctx: Canvas 렌더링 컨텍스트 참조
  • rafRef: requestAnimationFrame의 ID를 저장하는 참조

Canvas Size

const resize = () => {
    if (!canvas.current) return;
 
    const width = document.body.clientWidth;
    const height = document.body.clientHeight;
 
    setStageWidth(width);
    setStageHeight(height);
 
    canvas.current.width = width * 2;
    canvas.current.height = height * 2;
 
    if (ctx.current) {
        ctx.current.scale(2, 2);
    }
};

document.body 브라우저에서 문서의 본문(body) 영역의 내부 크기를 측정하는데 사용합니다.
document.body.clientWidth : 본문 넓이
document.body.clientHeight : 본문 높이

브라우저는 사용자에의해, 사용환경에 의해 가변적일 수 있습니다.
그리고 Retina 디스플레이는 같은 크기에 2배의 해상도를 가질 수 있기에 x2를 해주었습니다.

Animation Control

const startAnimation = (callback: () => void) => {
    const animate = () => {
        if (!ctx.current) return;
 
        ctx.current.clearRect(0, 0, stageWidth, stageHeight);
 
        callback();
 
        rafRef.current = requestAnimationFrame(animate);
    };
 
    animate();
};
  • 애니메이션을 시작하는 함수
    애니메이션 동작 초기에 캔버스가 있는지 확인하고, 캔버스의 이전 프레임을 지웁니다.(clearRect)
    callback() 부분에는 사용자가 전달한 애니메이션이 들어오게 됩니다.
    requestAnimationFrame을 통해서 애니메이션 프레임을 요청 합니다. 반환된 프레임을 rafRef에 저장합니다.
    animate 함수로 애니메이션을 실행하게 됩니다.

이후 실행 순서는

  • 캔버스 지우기
  • 애니메이션 실행
  • 다음 프레임

이 과정을 계속해서 반복하게 됩니다.

const stopAnimation = () => {
    if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
    }
};

애니메이션이 있는지 확인하고 있다면 cancelAnimationFrame을 사용해서 예약된 애니메이션을 중지 합니다.

useEffect(() => {
    if (!canvas.current) return;
 
    ctx.current = canvas.current.getContext("2d");
 
    resize();
    window.addEventListener("resize", resize);
 
    return () => {
        window.removeEventListener("resize", resize);
        stopAnimation();
    };
}, []);

useEffect를 사용해서 브라우저가 페이지에 진입했을 때 캔버스를 초기화 하고 캔버스 크기를 설정(resize())
addEventListener로 브라우저 크기가 변경될 때 마다 캔버스 크기를 조절 합니다.
addEventListener는 중첩되기에 페이지를 벗어낫을때 이벤트를 정리하는 removeEventListener를 넣어주고 마무리 합니다.

Ball.ts

캔버스에 사용될 공을 만드는 코드입니다.

type Props = {
    radius: number;
    speed: number;
    stageWidth: number;
    stageHeight: number;
    ctx: React.MutableRefObject<CanvasRenderingContext2D | null>;
    startAnimation: (callback: () => void) => void;
    stopAnimation: () => void;
};
 
  • Props:
    • radius : 반지름
    • speed : 속도
    • stageWidth : 캔버스 넓이
    • stageHeight : 캔버스 높이
    • ctx : 캔버스 컨텍스트
    • startAnimation : 애니메이션 시작 함수
    • stopAnimation : 애니메이션 중지 함수

ball location

const x = useRef(radius + Math.random() * (stageWidth - radius * 2));
const y = useRef(radius + Math.random() * (stageHeight - radius * 2));
const vx = useRef(speed * (Math.random() - 0.5));
const vy = useRef(speed * (Math.random() - 0.5));
  • x : 공의 초기 위치 x 축
  • y : 공의 초기 위치 y 축
  • vx : 공의 x축의 속도
  • vy : 공의 y축의 속도

draw ball

const draw = () => {
        if (!ctx.current) return;
 
        x.current += vx.current;
        y.current += vy.current;
 
        const minX = radius;
        const maxX = stageWidth - radius;
        const minY = radius;
        const maxY = stageHeight - radius;
 
        if (x.current <= minX || x.current >= maxX) {
            vx.current *= -1;
            x.current += vx.current;
        } else if (y.current <= minY || y.current >= maxY) {
            vy.current *= -1;
            y.current += vy.current;
        }
 
        ctx.current.beginPath();
        ctx.current.fillStyle = "#fdd700";
        ctx.current.arc(x.current, y.current, radius, 0, 2 * Math.PI, false);
        ctx.current.fill();
    };
 

프레임마다 공을 그리고 위치를 업데이트 하는 함수 입니다.
x.current, y.current: x축과 y축에 속도만큼 증가 시킵니다.
minX : 반지름
maxX : 오른쪽 최대 좌표
minY : 반지름
maxY : 아래 최대 좌표

캔버스는 왼쪽 끝과 위 끝을 좌표 0으로 잡는다. 따라서 왼쪽은 최대 반지름 만큼 지정해야하며 오른쪽은 브라우저 크기에서 반지름을 뺀 만큼 잡아야 합니다.

조건문으로 공이 충돌하고 반사되는 조건입니다.
공이 브라우저 끝에 도달하면 -1을 곱해서 위치를 업데이트 합니다.

beginPath() : 새로운 경로를 그림
fillStyle : 색채우기 설정
arc : 원 그리기
fill : 색 채우기

App.tsx

function App() {
    const { canvas, ctx, stageWidth, stageHeight, startAnimation, stopAnimation } = useCanvasApp();
 
    return (
        <div className='App'>
            <canvas ref={canvas} />
            {stageWidth > 0 && stageHeight > 0 && (
                <Ball
                    radius={30}
                    speed={15}
                    stageWidth={stageWidth}
                    stageHeight={stageHeight}
                    ctx={ctx}
                    startAnimation={startAnimation}
                    stopAnimation={stopAnimation}
                />
            )}
        </div>
    );
}

만들어둔 훅과 컴포넌트를 사용해서 캔버스 영역을 생성하고 Ball컴포넌트에 브라우저 사이즈와 캔버스 렌더링컨택스트, 함수 등을 넣어주면 브라우저는 정상적으로 튀기는 공을 렌더링 하게 됩니다.

다음글에선 벽을 만들어서 공이 부딫히는 영역을 추가하도록하겠습니다.