Post

JS로 사과게임 만들기 2편 (코드 분석하기)

이번에 내가 좋아하는 스트리머를 메인으로 canvas와 javascript를 활용해 사과게임을 모작하여 제작했다.

본편은 코드를 해부해 볼 예정이다. 진도는 initGame()의 요소까지 다룰 예정.

제작 배경 및 로직이 궁금하신 분은 1편을 보고 와주세요.

HTML

html 구조는 다음과 같다.

Image

페이지의 이동 없이 그냥 한 페이지 안에서 스타팅 화면과 게임 화면을 왔다리갔다리 하기 위해서 그냥 하나의 HTML 파일로 작성했다.

각 컨테이너의 클래스명은 다음과 같다.

  • 스타팅 화면: start-screen-container
  • 게임 화면: game-screen
  • 게임 오버 화면: game-over-screen

html 전체 코드가 궁금하신 분은 이쪽으로 » HTML 코드 보러가기

JS

그럼 다음으로 스크립트 파일을 살펴봅시담

데이터 선언

스크립트의 가장 상단에서는 dataset을 설정했다.

const는 상수다. 사과게임에서 변하지 않는 데이터 값엔 뭐가있을까? 사과를 생성할 행과 열의 개수와 제한시간, 숫자의 합이 있을거다. 따라서 이것들은 상수로 선언했다.

반대로 아래에 있는 항목들은 let으로 선언했다.

1
2
3
4
5
6
7
8
let apples = [];
let selectedApples = [];
let score = 0;
let isGameOver = false;
let timeLimit = INITIAL_TIME_LIMIT;
let timerInterval;
let isDragging = false;
let startX, startY;
  1. apples는 사과가 랜덤하게 생성될 배열이다. 사과 게임은 브라우저를 리로딩할 때마다 사과 안에 담겨있는 숫자의 배열값이 달라져야 한다. 따라서 let으로 선언했다.
  2. selectedApples 배열은 플레이어가 선택한 사과의 값이 들어가며 이 또한 매번 달라지기에 let으로 선언했다.
  3. score, GameOver, timeLimit, isDragging, startX, startY도 시시각각 변하니까.
  4. timerInterval은 setInterval을 활용해 타이머를 동작시키기 위해(1s마다 timeLimit을 감소시키기 위해) id를 저장하는 변수이다.

addEventListener

다음으로 addEventListener를 활용해 startButton이라는 id를 가진 요소를 클릭했을 시 game-screen의 display를 block으로 바꿔줬다. -> 한 화면에서 왔다리갔다리 할 거라고 했으니까.

근데 js에서만 display style을 바꿔주면 스타팅 화면에서 잠깐 게임화면이 번쩍 거리는 오류가 발생했었다. 그래서 css에서도 game-screen값을 아예 none으로 선언하고 다시 실행하니까 이 문제가 해결됐었다.

왜? css는 html 문서가 로드되는 즉시 파싱되어 요소가 적용된다. 반면, javascript는 html 문서가 전부 로드된 후 실행된다.

따라서 css에서 먼저 display style을 설정해줬다.

또한 게임이 실행되어야 하니까 initGame과 playBGM 모듈을 호출했다.

참고로 addEventListener 메서드는 DOM 요소에 event를 부여할 때 사용하는 메서드이며, addEventListener(event, 실행할 함수) 의 형태로 사용한다.

1
2
3
4
5
6
startButton.addEventListener('click', () => {
    document.querySelector('.start-screen-container').style.display = 'none';
    document.querySelector('.game-screen').style.display = 'block';
    initGame();
    playBGM();
});

addEventListener의 함수는 화살표 함수를 사용했다. 사실 여기서는 화살표 함수가 아니라 일반 함수를 사용해도 동작은 똑같을 것이다. 왜냐하면 여기선 this를 사용하지도 않고 있고, 그냥 DOM 요소의 style만 바꾸고 있기 때문이다.

근데 this를 바인딩 한다는게 정확히 뭘까?

arrow function과 일반 function의 차이점
  1. this란, function이나 object에서 “나 자신”을 가리키는 키워드다.
  2. 따라서 누가 이 함수를 호출했느냐에 따라 this가 결정된다.
  3. 객체에서 바로 함수를 호출하면 그 객체 자체를 가리키지만,
  4. 함수를 다른 변수에 할당해서 호출하면 undefined가 될 수 있다.
  5. 화살표 함수는 this를 바인딩하지 않는다. (==정적 바인딩) 즉, 화살표 함수가 호출 될 때마다 함수가 정의된 곳에서의 this를 그대로 참조한다.
  6. 반면 일반함수는 this를 동적으로 바인딩한다.

initGame()

initGame에선 사과를 초기화하고, 타이머를 실행하고, 스코어를 설정한다.

initApples()

사과(블록)을 생성하는 부분부터 봐보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function initApples() {
    apples = [];
    const appleSize = getAppleSize();

    for (let row = 0; row < ROWS; row++) {
        for (let col = 0; col < COLS; col++) {
            
            let number = getRandomNumber();

            // 15% 확률로 가로로 11의 합이 되는 경우 생성
            if (col < COLS - 1 && Math.random() < 0.15) {
                const complement = TARGET_SUM - number;
                if (complement > 0 && complement <= 9) {
                    apples.push({
                        x: col * appleSize,
                        y: row * appleSize,
                        number: number,
                        visible: true,
                    });
                    apples.push({
                        x: (col + 1) * appleSize,
                        y: row * appleSize,
                        number: complement,
                        visible: true,
                    });
                    col++;
                    continue;
                }
            }

            apples.push({
                x: col * appleSize,
                y: row * appleSize,
                number: number,
                visible: true,
            });
        }
    }
    drawBoard();
}
  1. 일단 apples 배열을 초기화한다. 아니, 앞에서 let apples = [] 로 빈 배열을 만들었음서 왜 여기서 또 초기화를 하느냐?고 묻는다면, 게임이 새로 시작 될 때마다 사과를 새로 생성해야 되기 때문에, 혹시나 이전에 남아있는 사과 숫자가 있을까봐 싹 지우는거다.
  2. 그 다음엔 보드의 각 칸을 하나씩 확인하기 위해 세로(row)와 가로(col)를 반복문으로 돈다.
  3. 이제 반복문을 돌면서 각 블록 칸에 숫자를 넣어주는데, 이 숫자는 getRandomNumber() 함수에서 랜덤으로 만든다. Math.floor(Math.random() * 9) + 1
    • Math.random() 함수의 return type은 float, 실수 형태이다. 근데 블록 위의 숫자엔 정수형(1-9) 숫자만 있어야되지 않는가. 따라서 floor 함수를 사용해서 소수점 뒤 숫자를 버린다.
    • 즉 Math.random() * 9로 나온 값은 0 이상 9 미만의 실수고,
    • 그 값을 Math.floor()로 내림하면 0부터 8까지의 정수가 된다.
    • 그 후 그 값에 + 1을 하면 최종적으로 1부터 9까지의 숫자가 랜덤하게 생성된다.
  4. 그리고 15%의 확률로 가로로 숫자 합이 11이 되는 경우를 생성한다. 예를 들어, 만약 number가 6이면, 그 옆 칸에 5가 와서 두 칸의 합이 11이 되도록 만든다. 만약 11을 만들지 못했다면 그냥 아무 숫자를 넣는다.

  5. 마지막으로 화면에 블록을 그리기 위해 drawBoard() 함수를 호출한다.

다음으로 Score 함수.

Score()
1
2
3
4
function updateScore(points) {
    score += points;
    scoreDisplay.textContent = ${score};
}

updateScore()는 removeApples()에서 sum값이 target_sum값과 동일하다면 블록이 지워진 그 길이만큼 score를 update한다. updateScore(removedCount); 즉, 내가 블록을 2개 지웠으면 2점이 업데이트 되고, 3개 지웠으면 3점 업데이트 되고… 지운 개수만큼 점수를 update하는거다.

다음으로 Timer.

Timer()
1
2
3
4
5
6
7
8
9
10
11
12
13
function startTimer() {
    clearInterval(timerInterval);
    timerInterval = setInterval(() => {
        timeLimit--;
        updateTimerDisplay();
        
        if (timeLimit <= 0) {
            endGame();
            playBreak();
            stopBGM();
        }
    }, 1000);
}

사실 timer 부분 설명은 앞에서 timerInterval을 선언한 이유와 같다. setInterval() 함수로 지정된 시간 간격(여기서는 1000ms = 1초)마다 timeLimit을 1씩 감소시키고 updateTimerDisplay() 함수로 화면에 표시되는 시간을 갱신한다. 그러고 시간이 0이 되면 이제 게임오버 ㅡㅅㅡ

참고로 여기서 timeLimit을 감소시킬 때 전위연산자를 사용하든, 후위연산자를 사용하든 상관 없을 것이다. 왜냐면 줄어드는 시점 자체가 그리 중요하지 않기 때문이다.

오늘은 여기까지. 다음편에서 계속 …

This post is licensed under CC BY 4.0 by the author.