🎮 사이드 프로젝트
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프레임도 부드럽게 처리할 수 있다.
게임처럼 매 프레임마다 화면이 바뀌는 경우, 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이 생긴다.
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 = 12와 ctx.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는 처음엔 낯설지만, 결국 "지우고 다시 그린다"의 반복이다.
이 단순한 원리 위에 게임이 만들어진다.
'Python' 카테고리의 다른 글
| React + FastAPI로 웹 게임 만들기 #4 - Render + Vercel 배포 (0) | 2026.04.24 |
|---|---|
| React + FastAPI로 웹 게임 만들기 #3 - 완성 (0) | 2026.04.23 |
| React + Python FastAPI로웹 게임 만들기 #1 (1) | 2026.04.22 |
| 🌤️날씨 데이터(2) - 저장하고API 서버까지 만들기 (0) | 2026.03.25 |
| 🌤️날씨 데이터(1) - 날씨 정보 가져오기 (외부 API 호출) (0) | 2026.03.25 |