Post

JS로 사과게임 만들기 3편 (완결)

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

본편은 마지막편으로 사과(블록) 선택과 지워지는 과정, 게임 오버까지 다룰 예정이다.

제작 배경 및 로직이 궁금하신 분은 1편을,

사과 생성과 score, timer 로직이 궁금하신 분은 2편을 보고 와주세요.

selectApples()

원조 사과 게임은 사용자가 마우스 드래그를 할 때 사과 블록이 있든, 없든간에 사방팔방으로 드래그를 할 수 있다.

근데 1편에서도 적어놨듯 타임 아웃이 있는 게임이다 보니, 제한시간에 다다를때쯤이면 덜덜 떨리는 손과 함께 드래그도 잘 안 된다. « 물론 그정도는 아니긴 하긴 하지만 ㅎ

쨌든, 그래서 나는 블록이 있는 영역에서만 드래그가 될 수 있도록 하고 싶었다. 마치 드래그 위치가 마그넷처럼 블록에 딱 달라붙게끔 말이다.

그래서 fiter() 메서드로 얕은 복사본을 만들어 사용자가 드래그한 직사각형 영역이 사과를 선택할 수 있는 부분인지 아닌지 확인할 수 있도록 만들었다. 사과가 없는 부분은 아예 드래그를 할 수 없도록 말이다.

이 때 반복문을 사용해도 되는데 굳이 fiter 메서드를 사용한 이유는 그냥 가독성 때문에 사용했따 ㅎ

1
2
3
4
5
6
7
8
9
10
11
12
13
function selectApples(startX, startY, endX, endY) {
    const appleSize = getAppleSize();
    
    selectedApples = apples.filter(apple => {
        const appleCenterX = apple.x + (appleSize / 2);
        const appleCenterY = apple.y + (appleSize / 2);
        const withinX = appleCenterX >= startX && appleCenterX < endX;
        const withinY = appleCenterY >= startY && appleCenterY < endY;
        return withinX && withinY && apple.visible;
    });
    drawBoard();
    drawSelectionRect(startX, startY, endX, endY);
}

사과가 선택 됐는지, 안 됐는지를 판별하는 기준은 사과의 중심이 선택 됐다면 (appleCenterX, appleCenterY) 이벤트 리스너에서 클릭한 픽셀의 위치를 갖고와서 그리드 좌표로 변환해줬다.

1
2
3
function getGridIndex(x, y) {
    return { row: Math.floor(y / appleSize), col: Math.floor(x / appleSize) };
}

그리드 좌표로 변환한 이유

사과가 그리드 단위로 배치되어 있기 때문에 사용자가 어떤 위치에서 드래그를 해도 자연스럽게 드래그 될 수 있게 하기 위해서다.

mousedown은 왼쪽 마우스 버튼을 처음 눌렀을 때 실행되는 핸들러고, mousemove는 사용자가 마우스를 드래그 할 때 실행된다.

mousedown

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
canvas.addEventListener('mousedown', (e) => {
    if (isGameOver) return;
    
    if (!hasVisibleAppleAt(e.offsetX, e.offsetY)) return;
    isDragging = true;
    
    startX = e.offsetX;
    startY = e.offsetY;
    
    const startCell = getGridIndex(startX, startY);
    selectedApples = [];
    
    const firstApple = apples.find(apple => 
        Math.floor(apple.x / getAppleSize()) === startCell.col && 
        Math.floor(apple.y / getAppleSize()) === startCell.row && 
        apple.visible
    );
    
    if (firstApple) {
        selectedApples = [firstApple];
        drawBoard();
        drawSelectionRect(
            firstApple.x, 
            firstApple.y, 
            firstApple.x + getAppleSize(), 
            firstApple.y + getAppleSize()
        );
    }
});

mousemove

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
canvas.addEventListener('mousemove', (e) => {
    if (!isDragging || isGameOver) return;
    
    // 현재 마우스 위치
    const currentX = e.offsetX;
    const currentY = e.offsetY;
    
    const startCell = getGridIndex(startX, startY);
    const currentCell = getGridIndex(currentX, currentY);
    
    // 그리드 좌표로 변환
    const gridMinCol = Math.min(startCell.col, currentCell.col);
    const gridMaxCol = Math.max(startCell.col, currentCell.col);
    const gridMinRow = Math.min(startCell.row, currentCell.row);
    const gridMaxRow = Math.max(startCell.row, currentCell.row);
    
    // 그리드 좌표를 픽셀 좌표로 변환하여 선택 영역 계산
    const appleSize = getAppleSize();
    const selectionMinX = gridMinCol * appleSize;
    const selectionMaxX = (gridMaxCol + 1) * appleSize;
    const selectionMinY = gridMinRow * appleSize;
    const selectionMaxY = (gridMaxRow + 1) * appleSize;

    selectApples(selectionMinX, selectionMinY, selectionMaxX, selectionMaxY);
});

그래서 사과의 첫 시작점부터 끝점까지 선택이 됐는지 판단하고 이게 됐다면 drawBoard()를 호출한 후, drawSelectionRect()로 사용자가 드래그 한 부분을 표시해줬다.

여기서 만약 drawBoard()를 지우거나, 사용자가 선택한 부분을 보여주는 drawSelectionRect()와 순서를 뒤바꾼다면 문제가 생긴다.

어떤 문제?

  1. drawBoard()를 지운다면,
    • 이전 선택들이 누적되어 사과가 지워지는 과정이 깔끔하게 보여지지않는다.
    • 이 때 깔끔하게 보여지지 않는다는건, 드래그한 부분이 중첩되어 이 부분의 숫자들이 가려진다는 의미다.
  2. 순서를 뒤바꾼다면,
    • 선택 영역을 먼저 그리고 보드를 나중에 그리는거기 때문에
    • 사용자가 선택한 부분이 결국 앞딴에서 보여지지가 않는다.

removeAppels()

다음으로 사과가 지워지는 과정은

  1. 사용자가 마우스 드래그를 뗐을 때(mouseup) removeApples()가 호출된다.
  2. removeApples() 안에선 reduce를 사용해 선택된 블록의 총합을 구하고,
  3. 만약 이게 앞서 선언한 TARGET_SUM과 같다면
  4. forEach로 블록을 없애고 선택 블록 배열을 초기화 한다.
  5. 물론, 효과음 재생과 점수 갱신도 함께 이루어진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function removeApples() {
    if (isGameOver) return;

    const sum = selectedApples.reduce((acc, apple) => acc + apple.number, 0);
    
    if (sum === TARGET_SUM) {
        const removedCount = selectedApples.length;
        selectedApples.forEach(apple => apple.visible = false);
        updateScore(removedCount);
        drawBoard();
        selectedApples = [];
        playDrop();
    } else {
        selectedApples = [];
        drawBoard();
    }
}

canvas.addEventListener('mouseup', () => {
    isDragging = false;
    removeApples();
});

forEach는 배열의 각 요소에 대한 작업을 수행하기 때문에 블록을 하나씩 지우는데에 필요하다고 생각되어 사용했고,

reduce는 주로 누적합을 구하는데 사용하기땜에 썼다.

물론 이 둘도 메서드 안 쓰고 그냥 for문으로 처리할 수 있다.

endGame()

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
function endGame() {
    isGameOver = true;
    clearInterval(timerInterval);
    
    const gameOverScreen = document.getElementById('game-over-screen');
    const finalScoreElement = document.getElementById('final-score');
    const endingImg = document.querySelector('.ending-img');
    const retryButton = document.getElementById('retry-button');
    
    if (score >= 100) {
        endingImg.classList.remove('hidden');
        finalScoreElement.textContent = `${score}점! 뭉탱대 수석 입학 축하한다맨이야`;
    } else {
        endingImg.classList.add('hidden');
        finalScoreElement.textContent = `${score}점 오옹 나이스~`;
    }
    
    // 게임 오버
    gameOverScreen.classList.remove('hidden');
    
    // 다시하기
    retryButton.addEventListener('click', resetGame, { once: true });
}

function resetGame() {
    const gameOverScreen = document.getElementById('game-over-screen');
    gameOverScreen.classList.add('hidden');

    isDragging = false;
    startX = 0;
    startY = 0;

    initGame();
    playBGM();
}

게임 오버는

  1. 100점을 넘겼을 때와 그렇지 않을때로 처리해줬고,
  2. 100점을 넘기면 또 다른 팬아트를 추가적으로 볼 수 있게 했다 ㅎㅅㅎ

후기

기획적인 관점에서,

솔직히 이때까지 진행한 프로젝트들에서 실제 이용자를 경험해 본 적은 단 한 번도 없었다. 근데 팬카페에 사이트를 배포하며 인기글에도 올라가고, 어떤분께서는 후기도 올려주셔서 재밌었다. 감사합니다 ㅠㅅㅠ

Image

Image

어쩌면 내 생애 첫 배포작인셈이다. 사실 카페에 글을 올릴 때도 벌벌 떨리면서 올렸다. 내컴에서만 잘 돌아가는걸까봐 ㅋㅋㅋ

그래서 사용자 관점에서 좀 더 생각해보려고 노력하며 만들었다. 잘 됐는진 모르겠지만..

그리고 개발적인 관점에선,

솔직히 코드가 좀 더럽고 보기 불편하다고 생각이 든다.

그리고 생성형 AI의 도움을 많이 받아서 그런지, 뭔가 내가 짠 코드란 생각이 별로 들지 않았다.

alt text

그래서 포스팅을 하며 왜 굳이 이 메서드를 사용했고, 왜 이렇게 짠건지 다시 하나씩 검토해봤다.

아직 많이 부족하고.. 요새 현타가 계속 오기도 하는데 이 페이지를 배포한 날을 다시금 떠올려 보면, 그래도 개발이 적성에 맞는다는 생각이 든다 ㅎ. (아닌가 기획쪽이 맞는건가)

된다면 모바일 버전도 제작하고, 코드 리팩토링도 진행해볼 예정이다.

쨌든, 머든, 해피코딩~^,^

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