본문 바로가기

Computer Programming/Javascript

🎮 자바스크립트로 2D 게임 개발하기 (블럭맞추기) | canvas, JQuery, localStorage 사용

1. 프로젝트 개요

웹미니 프로젝트로 html, css, javascript 그리고 JQuery를 이용해 간단한 게임을 개발하는 프로젝트를 진행했다.

 

https://developer.mozilla.org/en-US/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript

 

2D breakout game using pure JavaScript - Game development | MDN

In this step-by-step tutorial we create a simple MDN Breakout game written entirely in pure JavaScript and rendered on HTML <canvas>.

developer.mozilla.org

 

위의 Mozilla developer 사이트에서 공부하며 참고를 많이 했고, 해당 튜토리얼 이외에

1) 제이쿼리 사용

2) 로컬스토리지에 점수와 시간 저장

3) 저장된 데이터 중복 제거 및 정렬

4) 게임시작/ 새로고치 버튼

 

네 가지는 새로 고안해 기능을 추가했다. 


2. 사용 기술

HTML, CSS, JavaScript


3. 메인 화면

 

 

처음에 잡았던 컨셉이 옛날 콘솔 게임 화면이었기 때문에 픽셀이 그대로 드러나는 것 같은 이미지와 아이콘을 최대한 활용했다. 

 

게임 시작 전에는 이렇게 검은 화면에 빨간 글씨로 게임 시작을 유도하는 문구를 띄웠다. canvas 에서는 텍스트를 따로 쓸 수가 없어 fillText로 글씨를 채워줬다.

 

 

3-1. HTML 코드 작성하기

우선 처음은 html:5 기본 템플릿으로 작성했고, 필요한 스크립트가 있다면 추가했다.

 

 

제이쿼리 import (js 파일 import 이전에 제이쿼리를 먼저 import 해줘야 한다.)

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>

 

부트스트랩 import (버튼 디자인 이외에는 사용하지 않았다)

    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
      crossorigin="anonymous" />

나머지 js와 css 파일도 link 해주고 타이틀을 변경해주면 head 태그는 마무리된다.

 

 

 

<body>태그

  <body>
    <div class="main">
      <div class="gameMain">
        <h1 class="title">Breack out!</h1>
        <canvas id="gamezone" width="480" height="320"></canvas>
        <div class="buttons">
          <button class="startButton" id="startButton">Game Start!</button>
          <button class="cancelButton" id="cancelButton">
            <img src="../Assets/blockgame/undo.png" />
          </button>
        </div>
      </div>
      <div class="scores">
        <table id="scoreTable">
          <tr>
            <th>Score</th>
            <th>Date</th>
          </tr>
        </table>
      </div>
    </div>
  </body>

 

 

메인 화면을 나타내는 main div에 게임화면을 나타내는 Game main, 그리고 canvas 를 담을 gamezone div 들을 만들었다. 그리고 게임시작 버튼과 새로고침 버튼을 담을 buttons div를 따로 만들었다.

 

 

 

게임 화면 아래에는 현재까지의 스코어 기록들이 쭉 나열되야하기 때문에 table 태그를 넣어줬고, table은 제이쿼리로 append 해 줄 예정이었기 때문에 table head만 우선 작성했다.

 

 

3-2. CSS 코드 작성하기

 

canvas {
  background: rgba(238, 238, 238, 0.618);
  display: block;
  margin: 0 auto;
  height: 430px;
  position: relative;
}

 

 

canvas란, 자바스크립트와 css 를 이용해 웹 페이지에 그래픽을 그릴 수 있게끔 해주는 도구이다. 점, 선, 면을 그리거나 이미지를 추가하는데 사용된다. 도형을 움직이게 하는데 용이하기 때문에 canvas로 2d 또는 3d 애니메이션을 만들 수 있다.

 

<canvas id="gamezone" width="480" height="320"></canvas>

HTML 파일에서 위와같이 선언한 다음, css와 자바스크립트를 이용해 조작해주면 된다.

 

 

게임 화면의 경우에는

.main {
  height: 100vh;
  width: 100vw;
  background-image: url("../Assets/blockgame/background.jpg");
  background-position: center;
  background-size: cover;
  padding-top: 45px;
}
canvas {
  background: rgba(238, 238, 238, 0.618);
  display: block;
  margin: 0 auto;
  height: 430px;
  position: relative;
}
.title {
  text-align: center;
}

.gameMain {
  width: 800px;
  height: 700px;
  margin: auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 20px;
  background-image: url("../Assets/blockgame/pinpng.com-message-box-png-4212063.png");
  background-position: center;
  background-size: cover;
  position: relative;
}

.gamezone {
  height: 480px;
  width: 480px;
  border-radius: 10px;
  background-color: rgb(3, 155, 3);
}

.scores {
  color: rgba(238, 238, 238, 0.689);
  height: 300px;
  width: 750px;
  margin: 125px auto;
  background-color: black;
  border-radius: 10px;
  overflow: hidden;
}

위와같이 작성했고, 최대한 flex와 margin: auto를 통한 가운데 정렬 등 최근에 배운 것들 위주로 사용했다.

 

scores 라는 점수를 기록하는 테이블에는 overflow: hidden/scroll 중에 고민했었다. 기능상 scroll이 편하지만 스크롤 특성상 외관을 컨셉에 맞게 커스텀하기가 어려워서 hidden으로 고쳤다.

 

 

대신에 기록들은 잘 나온 점수가 맨 위로 배치되게끔 배열을 정리해뒀기 때문에 유저가 관심있어하는 데이터를 보기에는 충분할것이라고 생각한다..!

 

 

@import url("https://fonts.cdnfonts.com/css/public-pixel");
* {
  font-family: "Public Pixel", sans-serif;
}

 

폰트는 게임 컨셉에 맞게 픽셀 폰트로 검색해서 나온 폰트를 사용해 모든 텍스트에 적용했다.

 

 

⭐️ 3-3. 자바스크립트 코드 작성하기

먼저 선언해야하는 코드는 아래와 같다.

var canvas = document.getElementById("gamezone");
var ctx = canvas.getContext("2d"); //캔버스에 그리기 위해 실질적으로 사용되는 도구인 rendering context => 2d
var startButton = document.getElementById("startButton")
var cancelButton = document.getElementById("cancelButton")

게임을 그릴 canvas, canvas에 2d 속성 주기, 그리고 이벤트를 필요로하는 버튼 두 가지를 자바스크립트 파일로 가져왔다. 

 

그리고 캔버스에서 사용할 도형들의 크기나 거리를 상수로 지정해 선언한다.

var ballRadius = 10;
var x = canvas.width/2;
var y = canvas.height-30;
var dx = 2;
var dy = -2;

var paddleHeight = 10;
var paddleWidth = 75;
var paddleX = (canvas.width-paddleWidth)/2;
var brickRowCount = 3;
var brickColumnCount = 5;
var brickWidth = 75;
var brickHeight = 20;
var brickPadding = 10;
var brickOffsetTop = 30;
var brickOffsetLeft = 30;

 

이후 진행되는 '그리는'함수들은 모두 10밀리초에 한번씩 계속해서 실행된다. (setInterval 함수 사용)

빠른 속도로 반복해서 그려지고 지워짐으로써 도형들이 움직이는것 처럼 보이게 하여 애니메이션을 나타내는 방법을 사용하면 된다.

 

⚽️ 공을 움직여보자

 

아래는 공와 패들 (맨 아래 긴 직사각형)을 그리는 함수이다.

function drawBall() {
    ctx.beginPath();
    ctx.arc(x, y, ballRadius, 0, Math.PI*2);
    ctx.fillStyle = "#0000FF";
    ctx.fill();
    ctx.closePath();
}

function drawPaddle() {
    ctx.beginPath();
    ctx.rect(paddleX, canvas.height-paddleHeight, paddleWidth, paddleHeight);
    ctx.fillStyle = "#FF00FF";
    ctx.fill();
    ctx.closePath();
}

 

 

이제는 움직이는 공이 게임존의 사면에 닿았을 때 다른 방향으로 튕겨내는 코드를 작성해야한다.

 

캔버스는 위와같은 좌표를 가지고 있기 때문에 예를 들면 0보다 작을때, 그리고 height 보다 클 때 이런 식으로 조건문을 작성해야한다.

 

if(x + dx > canvas.width-ballRadius || x + dx < ballRadius) {
    dx = -dx;
}
if(y + dy > canvas.height-ballRadius || y + dy < ballRadius) {
    dy = -dy;
}

이렇게 작성하면 사면에 공이 닿았을 때 튕겨내는 기능을 추가할 수 있다.

 

 

▬ 패들을 움직여보자

패들이 공을 잡아냈을 때는 공이 다시 튕겨져나오고, 패들이 공을 잡아내지 못하고 놓쳐서 공이 아랫면에 닿았을 경우 gameover 가 되는 코드를 작성한다.

 

아래 코드는 패들 기능과 동시에 벽돌을 그리는 함수도 포함되어 있다. 이 함수가 바로 setInterval 함수의 콜백함수이다.

 

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height); //직전에 그린 원 지우기
    drawBricks();
    drawBall();
    drawPaddle();
    drawScore();
    collisionDetection();

    //공이 캔버스의 끝(모서리)에 닿았을 때 튕겨내기
    if(x + dx > canvas.width-ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }

    if(y + dy < ballRadius) {
        dy = -dy;
    }
    else if(y + dy > canvas.height-ballRadius) {
        if(x > paddleX && x < paddleX + paddleWidth) {
            dy = -dy;
        }
        else {
            // clearInterval(draw);
            save(score, today);
            location.reload();
        }
    }

    if(rightPressed && paddleX < canvas.width-paddleWidth) {
        paddleX += 7;
    }
    else if(leftPressed && paddleX > 0) {
        paddleX -= 7;
    }

    x += dx;  //x와 y축을 조금씩 바꾸면서 원이 이동하는 것 처럼 보이게 함
    y += dy;
}

위에서 주목해야할 코드는 공을 놓쳤을 때 else if문인데, save 함수를 같이 설명해야하기 때문에 우선 동작이 가능하도록 게임 작동 코드 먼저 설명하도록 하겠다.

 

 

 

벽돌을 그리는 함수는 아래코드를 참고하자. 

function drawBricks() {
    for(var c=0; c<brickColumnCount; c++) {
        for(var r=0; r<brickRowCount; r++) {
            if(bricks[c][r].status == 1) { //status = 1 (=공이 치지 않아 벽돌을 그려도 되는 상태)
                var brickX = (c*(brickWidth+brickPadding))+brickOffsetLeft;
                var brickY = (r*(brickHeight+brickPadding))+brickOffsetTop;
                bricks[c][r].x = brickX;
                bricks[c][r].y = brickY;
                ctx.beginPath();
                ctx.rect(brickX, brickY, brickWidth, brickHeight);
                ctx.fillStyle = "#800080";
                ctx.fill();
                ctx.closePath();
            }
        }
    }
}

배열을 중첩시켜 2차원 배열을 생성하고 미리 선언해뒀던 상수들을 가져와 rect()을 그려주면 된다. 이 함수도 역시 만약에 공이 부딪친 벽돌이라면 화면에서 없어져야하기 때문에 10밀리초마다 실행되는 draw()함수에 포함시켜야 한다.

 

 

패들을 이동시켜야하기 때문에 키보드의 39, 37 번에 이벤트 리스너를 추가한다.

 

document.addEventListener("keydown", keyDownHandler, false);
document.addEventListener("keyup", keyUpHandler, false);

각각의 코드는 방향키 양 옆을 나타낸다.

 

방향키를 눌렀을 때, 누른 후 뗐을 때를 나누어 코드를 작성한다.

 

function keyDownHandler(e) {
    if(e.keyCode == 39) {
        rightPressed = true;
    }
    else if(e.keyCode == 37) {
        leftPressed = true;
    }
}

function keyUpHandler(e) {
    if(e.keyCode == 39) {
        rightPressed = false;
    }
    else if(e.keyCode == 37) {
        leftPressed = false;
    }
}

 

rightPressed 와 leftPressed를 boolean 값으로 선언하고, 키가 눌림에 따라 해당 방향으로 이동할 수 있도록 x축과 y축을 이동시킨다.

if(rightPressed && paddleX < canvas.width-paddleWidth) {
        paddleX += 7;
    }
    else if(leftPressed && paddleX > 0) {
        paddleX -= 7;
    }

 

 

모질라 페이지에 따르면 이 이후로는 게임오버를 alert로 구현했지만, score을 저장한 뒤 localStorage에 저장해 게임페이지를 방문할 때 마다 저장되어 있는 게임 데이터를 불러오도록 하는 것이 더 좋을 것 같아 따로 구현해보았다.

 

 

📂 localStorage에 데이터를 저장해보자

 

gameover 일 경우 save() 함수를 실행한다.

function save(score, today) {
    var scores = {
        "id": today,
        "score": score,
        "date" : today.toLocaleDateString(),
    }
    records.push(scores)
    localStorage.setItem('blockgame_records',JSON.stringify(records))
}

gameover 직전의 score와 날짜를 받고, id를 설정해 하나의 오브젝트로 만든다.

그 후, 게임을 여러번 진행하고 여러번 gameover 될 때마다 하나의 배열에 해당 오브첵트를 push 한다.

 

push 하기 전, 로컬 스토리지에서 이미 가지고 있던 데이터 배열을 불러와야하기 때문에

배열 선언문의 가장 마지막에 아래의 코드를 추가했다. 

 

 

📚 localStorage에서 데이터를 불러오자

 

if (localStorage.getItem("blockgame_records")) {
    records = JSON.parse(localStorage.getItem("blockgame_records"))
    //같은 id, 같은 score 인 객체를 하나로 합치기
    records = records.reduce(function(acc, current) {
        if (acc.findIndex(({ id }) => id === current.id) === -1) {
            acc.push(current);
        }
        return acc;
    }, []);
    //score가 큰 객체부터 정렬하기
    records.sort(function(a,b) {
        return b.score - a.score
    })
    $("#scoreTable").empty;
    //정렬된 배열을 하나씩 테이블에 append 하기
    records.forEach((record) => {
        let score = record.score
        let date = record.date
        let temp_html = `
            <tr>
                <td>${score}</td>
                <td>${date}</td>
            </tr>
        `
        $("#scoreTable").append(temp_html)
    })
} else {
    records = []
}

 

같은 id로 여러 데이터들이 push 되는 것을 막기 위해 id가 같은 데이터끼리는 하나로 묶는 reduce 함수를 작성했다.

 

이후 score 가 가장 큰 순서대로 정렬해주어 테이블에는 가장 높은 점수부터 올라오게끔 구현했다.

 

저장된 데이터는 크롬 개발자도구(검사)의 Application 탭에서 key:value pair 로 저장되어있는 것을 확인할 수 있다.

 

save()함수 이후에는

location.reload() 함수를 실행하면서 페이지라 리로드된다.

리로드 되면서 가장 처음 실행하기로 되어있는 showText() 함수를 다시 실행시킨다.

 

function showText() {
    if (showGameStart == true) {
        ctx.fillStyle = 'black'
        ctx.fillRect(120, 100, 240, 60)
        ctx.font = '20px Arial'
        ctx.fillStyle = 'red'
        ctx.fillText(startText, 130, 140)
    } else {
        showGameStart = false
    }
}

 

그러면 아래와 같이 캔버스에 텍스트를 채워넣을 수 있게 된다.

 

 

 

 

🖱️  버튼에 이벤트 리스너를 추가해보자

처음 변수를 선언할 때 각각의 버튼들을 자바스크립트 파일에 가져왔다.

var startButton = document.getElementById("startButton")
var cancelButton = document.getElementById("cancelButton")

 

각각의 버튼에 이벤트 리스너를 추가하고, 해당하는 이벤트의 종류와 시행할 함수를 보내면 끝이다. 게임을 시작하면 캔버스에 그림을 그리는 draw 함수가 10밀리초에 한번씩 실행되고, "Press Start Button Below" 텍스트는 사라지도록 false 로 설정한다.

 

//게임 시작 전 세팅
showText();

//게임 시작
function gameInit() {
    showGameStart = false
    setInterval(draw, 10); //10밀리초마다 draw 함수 실행
}

//게임 다시 시작하기
function reload() {
    location.reload()
}

 

 

위의 코드를 맨 아래 추가하면 정상적으로 작동한다.

 

 


4. 마무리

팀 프로젝트 내에서 만든 미니프로젝트이기 때문에 아직 메인페이지 작성이 완료되지 않았다. 완료되는대로 배포 url 올려놓을 예정 ✍🏻😜