JS로 사과게임 만들기 3편 (완결)
- github repo: https://github.com/dpwls02142/drag-make-11
- 게임 하러가기: https://drag-make-11-front.vercel.app/
- 제작기간: 25년 2월 22일 ~ 25년 3월 2일 (7일)
(기본적인 로직을 제작한 기간은 위와 같으며, 다른 기능들은 점차 업데이트 할 예정.)
이번에 내가 좋아하는 스트리머를 메인으로 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()와 순서를 뒤바꾼다면 문제가 생긴다.
어떤 문제?
- drawBoard()를 지운다면,
- 이전 선택들이 누적되어 사과가 지워지는 과정이 깔끔하게 보여지지않는다.
- 이 때 깔끔하게 보여지지 않는다는건, 드래그한 부분이 중첩되어 이 부분의 숫자들이 가려진다는 의미다.
- 순서를 뒤바꾼다면,
- 선택 영역을 먼저 그리고 보드를 나중에 그리는거기 때문에
- 사용자가 선택한 부분이 결국 앞딴에서 보여지지가 않는다.
removeAppels()
다음으로 사과가 지워지는 과정은
- 사용자가 마우스 드래그를 뗐을 때(mouseup) removeApples()가 호출된다.
- removeApples() 안에선 reduce를 사용해 선택된 블록의 총합을 구하고,
- 만약 이게 앞서 선언한
TARGET_SUM
과 같다면 - forEach로 블록을 없애고 선택 블록 배열을 초기화 한다.
- 물론, 효과음 재생과 점수 갱신도 함께 이루어진다.
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();
}
게임 오버는
- 100점을 넘겼을 때와 그렇지 않을때로 처리해줬고,
- 100점을 넘기면 또 다른 팬아트를 추가적으로 볼 수 있게 했다 ㅎㅅㅎ
후기
기획적인 관점에서,
솔직히 이때까지 진행한 프로젝트들에서 실제 이용자를 경험해 본 적은 단 한 번도 없었다. 근데 팬카페에 사이트를 배포하며 인기글에도 올라가고, 어떤분께서는 후기도 올려주셔서 재밌었다. 감사합니다 ㅠㅅㅠ
어쩌면 내 생애 첫 배포작인셈이다. 사실 카페에 글을 올릴 때도 벌벌 떨리면서 올렸다. 내컴에서만 잘 돌아가는걸까봐 ㅋㅋㅋ
그래서 사용자 관점에서 좀 더 생각해보려고 노력하며 만들었다. 잘 됐는진 모르겠지만..
그리고 개발적인 관점에선,
솔직히 코드가 좀 더럽고 보기 불편하다고 생각이 든다.
그리고 생성형 AI의 도움을 많이 받아서 그런지, 뭔가 내가 짠 코드란 생각이 별로 들지 않았다.
그래서 포스팅을 하며 왜 굳이 이 메서드를 사용했고, 왜 이렇게 짠건지 다시 하나씩 검토해봤다.
아직 많이 부족하고.. 요새 현타가 계속 오기도 하는데 이 페이지를 배포한 날을 다시금 떠올려 보면, 그래도 개발이 적성에 맞는다는 생각이 든다 ㅎ. (아닌가 기획쪽이 맞는건가)
된다면 모바일 버전도 제작하고, 코드 리팩토링도 진행해볼 예정이다.
쨌든, 머든, 해피코딩~^,^