자바스크립트로 횡스크롤(사이드 스크롤) 게임 만들기 — 캔버스 실습부터 성능 최적화까지

자바스크립트로 횡스크롤(사이드 스크롤) 게임 만들기 — 캔버스 실습부터 성능 최적화까지

자바스크립트 횡스크롤 게임 만들기

횡스크롤 게임 미리보기 화면

이 글은 HTML5 Canvas와 순수 자바스크립트만 사용해 횡스크롤(사이드 스크롤) 액션 게임을 만드는 과정을 단계별로 정리한 실전 튜토리얼입니다.
단순히 동작하는 예제 코드를 넘어서, 충돌 판정, 카메라(뷰포트) 이동, 성능 최적화까지 다룹니다.
유니티를 사용하지 않아도 캔버스를 바탕으로도 충분히 횡스크롤 게임을 구현할 수 있습니다.
그럼 바로 시작!

무엇을 만들 것인가 — 목표와 학습 포인트

최종 목표를 위한 설계 화면은 다음과 같습니다.

횡스크롤 게임 설계 화면
  • 왼쪽에서 오른쪽으로 진행되는 배경(무한 스크롤)
  • 플레이어(주인공) 이동, 점프, 중력 적용
  • 장애물과의 충돌 판정
  • 충돌 했을 때의 처리 방식
  • 스코어 표시 및 게임 오버 처리

requestAnimationFrame 기반 게임 루프, 캔버스 그리기 최적화, 간단한 물리(중력/점프), 충돌 박스(AABB) 검사 등을 코드로 작성해보도록 하겠습니다.

1) HTML 뼈대

간단한 HTML 구조입니다 — 캔버스와 컨트롤만 있으면 충분합니다.

횡스크롤 게임 HTML 화면

크게 어려운 부분은 없습니다.
게임 시작 화면과 게임 끝 화면을 만듭니다.
게임 시작 화면에는 간단한 게임 설명과 시작하기 버튼을 추가합니다.
게임 끝 화면에는 "GAME OVER" 문구와 점수, 그리고 다시하기 버튼을 추가합니다.
메인 게임 화면에는 canvas를 추가하고, 메인 게임 화면 상단에는 점수가 표시 될 수 있는 HUD를 추가합니다.

2) 실행 화면

횡스크롤 게임 실행 영상

3) 필수 개념 — 카메라(뷰포트)와 월드 좌표

횡스크롤에서는 화면(캔버스)게임의 월드 좌표를 분리하는 것이 핵심입니다. 플레이어의 x좌표가 증가하면 카메라(뷰포트)를 이동시켜 배경과 장애물이 반대로 움직이는 효과를 만듭니다.

4) 객체 생성

        
          /**
           * 게임 객체들
           */
          // 배경화면
          class Background {
              constructor(img, x, y, w, h, speed) {
                  this.img = img;
                  this.x = x;
                  this.y = y;
                  this.w = w;
                  this.h = h;
                  this.speed = speed;
              }

              draw() {
                  ctx.drawImage(this.img, this.x, this.y, this.w, this.h);
              }

              update() {
                  ctx.drawImage(this.img, -this.speed, 0);
                  ctx.drawImage(this.img, -this.speed + this.w, 0);
              }
          }

          // 플레이어
          class Player {
              constructor(img, x, y, w, h) {
                  this.img = img;
                  this.x = x;
                  this.y = y;
                  this.w = w;
                  this.h = h;
              }

              draw() {
                  ctx.drawImage(this.img, this.x, this.y, this.w, this.h);
              }

              update() {
                  this.draw();
              }
          }

          // 장애물
          class Obstacle {
              constructor(img, x, y, w, h, speed, passedChk) {
                  this.img = img;
                  this.x = x;
                  this.y = y;
                  this.w = w;
                  this.h = h;
                  this.speed = speed;
                  this.passedChk = passedChk;
              }

              draw() {
                  ctx.drawImage(this.img, this.x, this.y, this.w, this.h);
              }

              update() {
                  this.x -= this.speed;
                  ctx.drawImage(this.img, this.x, this.y, this.w, this.h);
              }
          }
        
      

플레이어, 장애물, 배경화면의 객체를 생성합니다. 각각의 객체를 생성해 놓으면 필요할 때마다 가져다 쓰기 편한 장점이 있습니다. 각각의 객체의 좌표값, 속도값, 크기, 높이 등을 입력해주고, draw(), update() 함수를 만들어서 객체를 그려주거나 프레임 단위로 반복적으로 그려줄 수 있도록 합니다.

5) 기초 게임 루프와 시간 관리

setInterval 대신 requestAnimationFrame을 사용하면 브라우저 최적화에 유리합니다. 아래는 게임 루프의 핵심 구조입니다.

        
          /**
           * 루프
           */
          function loop() {
              if(state !== GAME_STATE.PLAY) return;

              // 캔버스 초기화
              ctx.clearRect(0, 0, canvas.width, canvas.height);

              // 배경화면
              background.update();

              // 플레이어
              playerVY += GRAVITY * duration;
              player.y += playerVY * duration;

              //// 플레이어 바닥 충돌
              let floorY = GROUND_Y_COORDINATE - player.h;
              if(player.y > floorY) {
                  player.y = floorY;
                  playerVY = 0;
                  isJumping = false;
              }
              player.update();

              // 장애물
              for(let i=0; i<obstacles.length; i++) {
                  obstacles[i].update();

                  // 장애물 지나가면 득점
                  if(!obstacles[i].passedChk) {
                      if(obstacles[i].x + obstacles[i].w < player.x) {
                          obstacles[i].passedChk = true;
                          score++;
                          hud.textContent = "점수 : " + score;
                      }
                  }

                  // 화면 밖으로 나간 장애물 제거
                  if(obstacles[i].x + obstacles[i].w < -50) {
                      obstacles.splice(i, 1);
                  }
              }

              // 장애물 생성
              if(intervalSpawn > 100 && firstOb === "first") {
                  firstOb = "not_first";
                  randomObstacleHeight = randomFunc(MAX_OBSTACLE_HEIGHT, MIN_OBSTACLE_HEIGHT);
                  obstacle = new Obstacle(assets.obstacle, canvas.width + 10, GROUND_Y_COORDINATE - randomObstacleHeight, 
                      OBSTACLE_WIDTH, randomObstacleHeight, OBSTACLE_SPEED, false);
                  obstacles.push(obstacle);
                  spwanTime = randomFunc(MIN_OBSTACLE_SPAWN, MAX_OBSTACLE_SPAWN);
              } else if(intervalSpawn > spwanTime && firstOb === "not_first"){
                  randomObstacleHeight = randomFunc(MAX_OBSTACLE_HEIGHT, MIN_OBSTACLE_HEIGHT);
                  obstacle = new Obstacle(assets.obstacle, canvas.width + 10, GROUND_Y_COORDINATE - randomObstacleHeight, 
                      OBSTACLE_WIDTH, randomObstacleHeight, OBSTACLE_SPEED, false);
                  obstacles.push(obstacle);
                  intervalSpawn = 0;
                  spwanTime = randomFunc(MIN_OBSTACLE_SPAWN, MAX_OBSTACLE_SPAWN);
              }

              // 플레이어 장애물 충돌 시 게임 오버
              for(let i=0; i<obstacles.length; i++) {
                  if(collisionFunc(player.x, player.y, 
                      player.w, player.h, 
                      obstacles[i].x, obstacles[i].y, 
                      obstacles[i].w, obstacles[i].h)) {
                      endFunc();
                      return;
                  }
              }

              intervalSpawn++;
              gameLoop = requestAnimationFrame(loop);
          }
        
      
  • requestAnimationFrame을 사용하여 loop 함수를 프레임 단위로 반복시킵니다. 추후에 장애물에 부딪혔을 때 requestAnimationFrame가 정지를 해야 되므로, gameLoop = requestAnimationFrame(loop) 이런식으로 변수에 넣어서 사용해주도록 합니다.
  • 앞서 만들었던 객체를 생성하고, 함수를 통해서 배경화면, 플레이어, 장애물을 그려줍니다.
  • 플레이어는 위로 점프를 뛰어야 하므로 중력이 작용하도록 GRAVITY 변수를 활용합니다.
  • 장애물도 같은 방식으로 생성하고, 플레이어가 장애물에 부딪히지 않고 넘어갔다면 득점이 되도록 합니다.
  • 장애물에 부딪혔을 경우에는 게임이 끝나도록 합니다.
팁: 장애물이 화면 밖으로 지나갔을 경우에는 파괴시켜주도록 합니다. 이 부분을 하지 않으면 계속 장애물이 배열에 쌓여서 성능을 느리게 만듭니다.

6) 장애물과 충돌 처리 (AABB)

충돌 검사에는 AABB(axis-aligned bounding box)를 사용하면 충분합니다. 장애물이 플레이어와 겹치면 게임오버 처리를 합니다.

        
          // 충돌 함수
          function collisionFunc(px, py, pw, ph, ox, oy, ow, oh) {
              let chk = true;
              if(px + pw < ox || px > ox + ow || py + ph < oy || py > oy + oh) {
                  chk = false;
              }

              return chk;
          }
        
      

7) 메인 함수 - 객체 생성 함수

위에서 만든 객체들을 게임이 시작하면 생성하는 함수입니다. 배경화면, 플레이어, 장애물의 각각의 속성값을 설정해줍니다.

        
          /**
           * 메인 함수(그리기)
           */
          function main() {
              // 캔버스 초기화
              ctx.clearRect(0, 0, canvas.width, canvas.height);

              // 배경 생성
              background = new Background(assets.background, 0, 0, 800, 300, BACKGROUND_SPEED);
              background.draw();

              // 플레이어 생성
              player = new Player(assets.player, PLAYER_X_COORDINATE, PLAYER_Y_COORDINATE, PLAYER_WIDTH, PLAYER_HEIGHT);
              player.draw();

              // 장애물 생성
              randomObstacleHeight = randomFunc(MAX_OBSTACLE_HEIGHT, MIN_OBSTACLE_HEIGHT);
              obstacle = new Obstacle(assets.obstacle, canvas.width + 10, GROUND_Y_COORDINATE - randomObstacleHeight, 
                  OBSTACLE_WIDTH, randomObstacleHeight);
              obstacle.draw();
          }
        
      

8) 컨트롤(조작키) 함수

플레이어는 세로축으로 점프를 합니다. 저는 spacebar를 통해서 이를 조작할 수 있도록 하였습니다.

        
          /**
           * 조작키 설정
           */
          function handleKeyDown(e) {
              if(e.code === 'Space') {
                  e.preventDefault();
                  isJumpKeyDown = true;
                  jump();
              }
          }
          function handleKeyUp(e) {
              if(e.code === 'Space') {
                  e.preventDefault();
                  isJumpKeyDown = false;
                  if(playerVY < 0) {
                      playerVY *= JUMP_CUTOFF;
                  }
              }
          }
          window.addEventListener('keydown', handleKeyDown);
          window.addEventListener('keyup', handleKeyUp);
        
      
팁: 저는 스페이스바를 통해서만 조작할 수 있도록 구현하였지만, 모바일에서도 이 부분이 적용되게 하려면 touchstart, touchend 이벤트 핸들러를 추가하시면 됩니다.

9) 기타 함수들

점프 함수는 사용자가 스페이스바를 눌렀을 때 실행되는 함수입니다. Y좌표값에 중력, 속력값을 계산하여 플레이어가 점프 뛸 수 있도록 합니다.

제어 함수는 게임을 처음 시작하거나 다시 시작할 때, 게임이 종료되었을 때 실행되는 함수입니다. 플레이어가 장애물에 닿아서 게임이 종료되었을 때 endFunc() 함수가 실행되고, cancelAnimationFrame(gameLoop)도 실행됩니다.

        
          // 점프 함수
          function jump() {
              if(state !== GAME_STATE.PLAY) return;
              if(isJumping) return;
              if(player.y >= GROUND_Y_COORDINATE - player.h - 0.01) {
                  playerVY = -JUMP_VELOCITY;
                  isJumping = true;
              }
          }
          
          /**
           * 게임 제어 함수
           */
          function startFunc() {
              if(state === GAME_STATE.PLAY) return;
              initFunc();
              state = GAME_STATE.PLAY;
              startPage.classList.add("hidden");
              endPage.classList.add("hidden");
              gameLoop = requestAnimationFrame(loop);
          }

          function endFunc() {
              state = GAME_STATE.END;
              totalScoreBoard.textContent = "점수 : " + score;
              endPage.classList.remove("hidden");
              cancelAnimationFrame(gameLoop);
          }
        
      

10) 성능 최적화 팁

  • Canvas에 매 프레임 전체를 다시 그리지만, 불필요한 레이어는 캐싱 (오프스크린 캔버스 사용 고려)
  • 이미지 크기를 적절히 줄여서 로드 시간을 단축
  • 디바이스 픽셀 비율(window.devicePixelRatio) 고려 — 스케일링 처리
  • 이벤트 핸들러는 최소화 — 입력은 키다운/키업으로만 처리

11) 디버깅에서 자주 만나는 문제와 해결책

문제: 프레임 드랍(버벅임) — 보통 이미지 과다나 복잡한 충돌 검사 때문입니다.
해결: 충돌 검사 필터링(시야에 보이는 객체만 검사), offscreen canvas로 정적 배경 캐시, requestAnimationFrame에서 로직 분리(업데이트와 렌더 분리)

12) 확장 아이디어 — 프로젝트를 성장시키는 방법

  • 레벨 시스템: 맵 데이터를 JSON으로 만들어 여러 레벨을 로드
  • 단순한 장애물이 아니라 크기가 변하거나 모양이 변하는 장애물 생성
  • 아이템 드롭/점수 시스템 및 랭킹 저장(localStorage)
  • 장애물의 다가오는 속도에 변화를 주어 난이도 조절
  • 웹오디오 API로 배경음악과 효과음 추가

13) 정리

횡스크롤 게임의 대표격이라 할 수 있는 쿠키런 같은 게임을 생각하면서 만들어보았습니다. 가장 심플한 기능들만 추가하여 제작을 하였고, 추후에 더 발전시킬 수 있는 여지가 많은 코드인 것 같습니다. 이러한 게임 같은 경우에는 유니티를 통해 개발하는 것이 보편적이지만, HTML의 캔버스 기능을 활용하여도 충분히 만들 수 있습니다. 자신이 웹페이지를 만들고 있고, 그 곳에 간단한 게임을 추가하고 싶다면 이러한 종류의 게임을 만들어서 추가해도 괜찮을 것 같습니다.

다양한 프로젝트를 만들어보세요.

전체 예제 코드가 필요하신 분은 댓글 남겨주세요.

댓글 쓰기

다음 이전