React + FastAPI로 웹 게임 만들기 #2 - Canvas로 배경과 캐릭터 그리기

React + Python FastAPI로
웹 게임 만들기 #2

Canvas로 배경과 캐릭터 그리기 — useRef, requestAnimationFrame, Canvas API

1 Canvas API란?

Canvas API는 JavaScript로 도화지에 직접 그림을 그리는 방법이다. HTML에 <canvas> 태그를 놓으면 브라우저가 빈 도화지를 만들어주고, JS로 그 위에 원, 사각형, 선 등 뭐든 그릴 수 있다.

Canvas 기본 사용법
// 1. canvas 요소 가져오기
const canvas = document.getElementById('myCanvas')

// 2. 2D 컨텍스트 가져오기 (붓 역할)
const ctx = canvas.getContext('2d')

// 3. 색 지정 후 사각형 그리기
ctx.fillStyle = '#ff0000'
ctx.fillRect(10, 10, 100, 50)
//           x   y   너비  높이
💡
왜 Canvas를 썼나?
게임처럼 매 프레임마다 화면이 바뀌는 경우, React의 state/render 방식은 너무 느리다. Canvas는 JS가 직접 픽셀을 그리므로 초당 60프레임도 부드럽게 처리할 수 있다.

2 React에서 Canvas 쓰는 법 (useRef)

React에서는 DOM 요소에 직접 접근할 때 useRef를 사용한다. canvas도 DOM 요소이므로 useRef로 참조를 잡아야 한다.

src/App.jsx
import { useEffect, useRef } from 'react'

export default function App() {
  // canvas DOM 요소를 가리키는 ref 생성
  const canvasRef = useRef(null)

  useEffect(() => {
    // 컴포넌트가 마운트된 후 canvas에 접근
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')

    // 여기서 그리기 시작!
    ctx.fillStyle = '#00eeff'
    ctx.fillRect(50, 50, 100, 100)
  }, [])  // [] = 처음 한 번만 실행

  return (
    <canvas
      ref={canvasRef}  // ref 연결
      width={720}
      height={260}
    />
  )
}
⚠️
useEffect가 필요한 이유:
React는 JSX를 렌더링한 뒤에 DOM이 생긴다. useEffect 없이 바로 canvasRef.current에 접근하면 아직 DOM이 없어서 null이 된다. useEffect 안에서 접근해야 canvas가 준비된 상태이다.

3 배경 그리기 — 하늘, 땅, 네온 라인

배경은 세 가지로 구성된다. 위에서 아래로 그라데이션 하늘, 어두운 땅, 그리고 경계선을 네온처럼 빛나게 하는 shadowBlur다.

배경 그리기
const W = 720   // 캔버스 너비
const H = 260   // 캔버스 높이
const GROUND = 210  // 땅 Y 위치

function drawBackground() {
  // ① 하늘 — 위아래 그라데이션
  const sky = ctx.createLinearGradient(0, 0, 0, GROUND)
  sky.addColorStop(0, '#04040f')   // 위: 거의 검정
  sky.addColorStop(1, '#0c0c28')   // 아래: 진한 남색
  ctx.fillStyle = sky
  ctx.fillRect(0, 0, W, GROUND)

  // ② 땅
  ctx.fillStyle = '#050f08'
  ctx.fillRect(0, GROUND, W, H - GROUND)

  // ③ 땅 경계선 — 네온 발광 효과
  ctx.strokeStyle = '#00ff88'
  ctx.lineWidth = 2
  ctx.shadowBlur = 12        // 빛 번짐 크기
  ctx.shadowColor = '#00ff88' // 빛 색깔
  ctx.beginPath()
  ctx.moveTo(0, GROUND)
  ctx.lineTo(W, GROUND)
  ctx.stroke()
  ctx.shadowBlur = 0  // 다음 그림에 영향 안 주도록 초기화
}
💡
shadowBlur로 네온 효과 내기
ctx.shadowBlur = 12ctx.shadowColor = '#00ff88'을 설정하면 이후 그리는 모든 도형에 빛 번짐이 생긴다. 반드시 사용 후 shadowBlur = 0으로 초기화해야 다음 도형에 번지지 않는다.

4 별 반짝임 — Math.sin()으로 애니메이션

별은 단순한 점(원)이다. 반짝이는 효과는 Math.sin()으로 투명도를 물결처럼 변화시켜서 만든다.

별 데이터 생성 + 그리기
// 컴포넌트 바깥에서 한 번만 생성 (렌더마다 새로 만들면 위치가 바뀜)
const STARS = Array.from({ length: 60 }, () => ({
  x: Math.random() * W,              // 랜덤 X 위치
  y: Math.random() * (GROUND - 20),  // 하늘 영역 안에만
  r: Math.random() * 1.5 + 0.3,      // 별 크기 0.3~1.8
  phase: Math.random() * Math.PI * 2, // 깜빡임 시작 위치 (별마다 다르게)
}))

// tick: 매 프레임마다 1씩 올라가는 숫자
STARS.forEach(s => {
  // sin은 -1~1 사이를 물결처럼 변함
  // abs로 음수 제거 → 0~1 범위로 만듦
  const alpha = 0.3 + 0.7 * Math.abs(Math.sin(tick * 0.02 + s.phase))
  //             ↑최소       ↑최대추가   ↑tick으로 변함   ↑별마다 다른 타이밍

  ctx.beginPath()
  ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
  ctx.fillStyle = `rgba(190, 210, 255, ${alpha})`
  ctx.fill()
})
🔬 Math.sin() 작동 원리
tick 값 sin 결과 alpha (투명도) 별 상태
0 0.0 0.30 🔅 어두움
39 0.7 0.79 🌟 밝아짐
78 1.0 1.00 ✨ 최대 밝기
157 0.0 0.30 🔅 다시 어두움

phase가 별마다 다르기 때문에 같은 tick에도 각 별이 다른 밝기를 가진다. 이것이 자연스러운 반짝임을 만드는 핵심이다.

5 캐릭터 그리기 — 사각형 5개로 로봇

캐릭터는 복잡해 보이지만 사각형 5개를 쌓은 것이다. fillRect만으로 충분히 캐릭터처럼 보이게 만들 수 있다.

몸통
fillRect 1개
청록색
바이저 배경
fillRect 1개
검정
바이저 유리
fillRect 1개
반투명 하늘색
왼쪽 다리
fillRect 1개
tick으로 길이 변함
오른쪽 다리
fillRect 1개
왼쪽과 반대 타이밍
캐릭터 그리기
const PX = 80   // 캐릭터 X (고정)
const PW = 34   // 캐릭터 너비
const PH = 46   // 캐릭터 높이

function drawPlayer() {
  const x = PX
  const y = GROUND - PH  // 땅 바로 위에 서있게

  // 달리는 동작: sin/cos으로 다리 길이를 교대로 변화
  const legL = Math.sin(tick * 0.2) * 5  // 왼쪽: sin
  const legR = Math.cos(tick * 0.2) * 5  // 오른쪽: cos (90도 차이)

  ctx.save()  // 현재 설정 저장

  // 네온 발광
  ctx.shadowBlur = 20
  ctx.shadowColor = '#00eeff'

  // ① 몸통
  ctx.fillStyle = '#00eeff'
  ctx.fillRect(x, y, PW, PH)

  // ② 바이저 배경 (검정)
  ctx.fillStyle = '#001122'
  ctx.fillRect(x + 6, y + 8, 22, 14)

  // ③ 바이저 유리 (반투명)
  ctx.fillStyle = 'rgba(0, 240, 255, 0.5)'
  ctx.fillRect(x + 7, y + 9, 20, 12)

  // ④⑤ 다리 (tick으로 길이 변화 → 달리는 효과)
  ctx.fillStyle = '#009aaa'
  ctx.fillRect(x + 4,  y + PH, 12, 8 + legL)
  ctx.fillRect(x + 18, y + PH, 12, 8 + legR)

  ctx.restore()  // 저장한 설정으로 복원
}
ctx.save() / ctx.restore() 왜 쓰나?
shadowBlur, fillStyle 같은 설정은 한 번 바꾸면 이후 모든 그림에 영향을 준다. save()로 설정을 저장하고 restore()로 되돌리면, 캐릭터 그리는 동안만 설정이 적용되고 배경 같은 다른 요소엔 영향을 주지 않는다.

6 requestAnimationFrame으로 움직임 만들기

별 반짝임과 다리 움직임을 만들려면 매 프레임마다 화면을 다시 그려야 한다. requestAnimationFrame이 그 역할을 한다.

게임 루프
let tick = 0
let animId  // 취소할 때 필요한 ID

function loop() {
  tick++  // 프레임마다 1씩 증가

  // 이전 프레임 지우기
  ctx.clearRect(0, 0, W, H)

  // 다시 그리기
  drawBackground()  // 배경 + 별
  drawPlayer()      // 캐릭터

  // 다음 프레임에 loop 다시 호출 (1초에 60번)
  animId = requestAnimationFrame(loop)
}

loop()  // 시작!

// 컴포넌트 언마운트 시 정리 (메모리 누수 방지)
return () => cancelAnimationFrame(animId)
⚠️
setInterval 대신 requestAnimationFrame을 쓰는 이유:
setInterval은 탭이 백그라운드여도 계속 실행된다. requestAnimationFrame은 브라우저가 화면을 그릴 준비가 됐을 때만 실행되어 성능이 훨씬 좋고, 탭이 숨겨지면 자동으로 멈춘다.

7 최종 코드 & 결과

위 내용을 모두 합친 최종 코드다.

src/App.jsx — 최종
import { useEffect, useRef } from 'react'

const W = 720, H = 260, GROUND = 210
const PX = 80, PW = 34, PH = 46

const STARS = Array.from({ length: 60 }, () => ({
  x: Math.random() * W,
  y: Math.random() * (GROUND - 20),
  r: Math.random() * 1.5 + 0.3,
  phase: Math.random() * Math.PI * 2,
}))

export default function App() {
  const canvasRef = useRef(null)

  useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    let tick = 0, animId

    function drawBackground() {
      const sky = ctx.createLinearGradient(0, 0, 0, GROUND)
      sky.addColorStop(0, '#04040f')
      sky.addColorStop(1, '#0c0c28')
      ctx.fillStyle = sky
      ctx.fillRect(0, 0, W, GROUND)
      STARS.forEach(s => {
        const alpha = 0.3 + 0.7 * Math.abs(Math.sin(tick * 0.02 + s.phase))
        ctx.beginPath()
        ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
        ctx.fillStyle = `rgba(190,210,255,${alpha})`
        ctx.fill()
      })
      ctx.fillStyle = '#050f08'
      ctx.fillRect(0, GROUND, W, H - GROUND)
      ctx.strokeStyle = '#00ff88'
      ctx.lineWidth = 2
      ctx.shadowBlur = 12
      ctx.shadowColor = '#00ff88'
      ctx.beginPath()
      ctx.moveTo(0, GROUND)
      ctx.lineTo(W, GROUND)
      ctx.stroke()
      ctx.shadowBlur = 0
    }

    function drawPlayer() {
      const y = GROUND - PH
      const legL = Math.sin(tick * 0.2) * 5
      const legR = Math.cos(tick * 0.2) * 5
      ctx.save()
      ctx.shadowBlur = 20
      ctx.shadowColor = '#00eeff'
      ctx.fillStyle = '#00eeff'
      ctx.fillRect(PX, y, PW, PH)
      ctx.fillStyle = '#001122'
      ctx.fillRect(PX + 6, y + 8, 22, 14)
      ctx.fillStyle = 'rgba(0,240,255,0.5)'
      ctx.fillRect(PX + 7, y + 9, 20, 12)
      ctx.fillStyle = '#009aaa'
      ctx.fillRect(PX + 4,  y + PH, 12, 8 + legL)
      ctx.fillRect(PX + 18, y + PH, 12, 8 + legR)
      ctx.restore()
    }

    function loop() {
      tick++
      ctx.clearRect(0, 0, W, H)
      drawBackground()
      drawPlayer()
      animId = requestAnimationFrame(loop)
    }

    loop()
    return () => cancelAnimationFrame(animId)
  }, [])

  return (
    <div style={{ background: '#000', minHeight: '100vh',
      display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <canvas ref={canvasRef} width={W} height={H}
        style={{ border: '2px solid #00ff88', boxShadow: '0 0 30px #00ff8844' }} />
    </div>
  )
}

  • 완료 Canvas API + useRef로 도화지 준비
  • 완료 그라데이션 하늘 + 네온 땅 경계선
  • 완료 Math.sin()으로 별 60개 반짝임 애니메이션
  • 완료 사각형 5개로 달리는 캐릭터 구현
  • 완료 requestAnimationFrame 게임 루프
  • 다음편 점프 기능 + 중력 구현
  • 다음편 장애물 생성 + 충돌 감지

Canvas는 처음엔 낯설지만, 결국 "지우고 다시 그린다"의 반복이다.

이 단순한 원리 위에 게임이 만들어진다.

▶ 다음편: 점프 기능과 중력 구현하기