


오직 프론트 개발 연습을 위한 목적으로 개발하였으며, 상업용 사이트가 아님을 밝힙니다. 수익을 창출하지 않습니다. 게임의 아이디어 및 BGM에 대한 저작권은 ゲーム菜園에 있습니다. 게임 속 사과의 이미지는 chatGPT를 통해 제작되었습니다.
📜 구현해야 하는 기능 정리
- 시작 화면 🚩
→ 화면에 Start 버튼을 누르면 게임 화면으로 전환- 게임 화면 🕹️
→ 랜덤한 숫자들을 뿌려줘야 함
근데 무작정 랜덤은 아니고, 판의 숫자의 총합이 10의 배수로 딱 떨어지는 선에서 랜덤으로 나오게 지정해야 함
→ 사과의 개수는 가로×세로 = 17×10 =170개- 제한 시간 2분 타이머 기능 ⏱️
→ 몇 분 몇 초 남았는지 보여주기 (00:00 형식)
→ 시간 다 되면 game over 모달 띄우기 (모달 창에 최종 점수랑 처음 화면으로 돌아가는 버튼 넣기)- 점수판 기능 🔢
→ 점수 시스템: 2개 => +4점, 3개 => +6점, 4개 => +8점, 5개 이상(잘 나오지 X) => +10점- 합이 10이 되도록 화면을 드래그 했을 때 점수 상승 📈
→ 드래그 모양은 직선/사각형으로만 가능하고, 대각선은 X
→ 드래그 박스 안에 들어가는 숫자들을 합해서 10의 배수가 되어야 함
website icon은 FLATICON에서 다운!
사과 이미지는 chatGPT한테 원본과 비슷하게 그려달라고 했다. ☺️
패턴 배경은 원본 사과게임 사이트에서 개발자 도구(F12) 열어서 이것저것 뒤져보다가 얻었음.
그리고 bgm 음원 파일은 나무위키에 있길래 다운 받았습니당
🤔 만들면서 생겼던 문제점 & 새로 알게된 점
배경이 투명한 이미지인 사과 그림의 테두리에만 그림자를 적용하고 싶었는데...
box-shadow: 5px 5px 5px gray;
box-shadow를 넣으니까 png 파일을 빙 둘러서 네모나게 그림자가 적용되는 문제가 생겼다. (지금 보니까 애초에 이름부터가 요소의 박스에 그림자를 생성해준다는 거긴 하네..ㅋㅋ)
🤓 새로 알게된 기능
filter: drop-shadow(5px 5px 3px rgba(0, 0, 0, 0.3));
filter: drop-shadow(): 요소에 필터 효과로 그림자를 주며, 특히 투명한 배경의 이미지 모양을 따라 그림자가 생성됨
비정형 모양의 요소에 자연스럽게 그림자를 줄 때 유리하다.
사과 이미지를 button으로 사용하려고 했는데...
<div>
<button id="start-button">
<img id="start-apple" src="/Apple-Game/assets/apple-image.png" alt="apple">
<p id="start-text">Play</p>
</button>
</div>
#start-apple {
position: relative;
display: block;
width: 350px;
margin-top: 100px;
/* box-shadow: 5px 5px 5px gray; 요소의 사각형 경계에 적용됨 */
filter: drop-shadow(5px 5px 3px rgba(0, 0, 0, 0.3)); /* 사과 모양을 따라 그림자 */
}
#start-button {
background: none;
padding: 0;
margin: 0;
border: none;
cursor: pointer;
}
#start-text {
position: absolute; /* 이미지 위에 텍스트를 겹치게 */
font-family: "Montserrat", sans-serif;
font-size: 70px;
color: white;
pointer-events: none; /* 텍스트 클릭 방지 */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
어라.. png 파일의 투명한 배경을 클릭해도 버튼이 눌러짐
아직 Play가 사과 정가운데 있지 않은 것 같은 건 비밀..ㅎ
💭 자바스크립트로 불투명 부분만 클릭으로 인식할 수 있도록 해야겠구나!
button으로 감쌌던 코드를 div로 바꾸어 감쌌고, 자바스크립트 코드를 추가해줬다. 클릭한 위치가 투명한지, 불투명한지 판단하여 클릭 이벤트를 전달하는 로직을 사용하였다. 각 줄에 대한 설명은 주석을 상세히 달아가며 공부했다.
💭 내가 모르는 메서드가 참 많은 것 같다.. '블로그에 하나하나 정리해 가며 달달 외우는 것이 맞는 공부인가?' 싶은 의문이 계속 들었었는데
(막상 코딩하려고 보니 기억이 하나도 안 나서 ㅎㅎ;;), 계속 클론 코딩을 하면서 이 메서드가 실제로 어떻게 쓰이는지 알아가며 공부하니 습득에 더 도움이 되는 것 같다.
document.addEventListener('DOMContentLoaded', () => {
const apple = document.getElementById('start-apple');
const img = new Image();
img.src = apple.src;
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ★ 클릭한 위치가 투명한지, 불투명한지 판단하는 코드 ★
apple.parentElement.addEventListener('click', function(event) {
const rect = apple.getBoundingClientRect();
const x = event.clientX - rect.left
const y = event.clientY - rect.top;
const pixelX = x * (img.width / apple.width);
const pixelY = y * (img.height / apple.height);
const pixelData = ctx.getImageData(pixelX, pixelY, 1, 1).data;
// 투명한 픽셀이면 클릭 무시
// pixelData[3] = 투명도(A)
if (pixelData[3] === 0) {
event.preventDefault();
event.stopPropagation();
return;
}
// 불투명한 부분 클릭 시 동작
// 게임 시작 시 로직 추가
document.getElementById('start-screen').style.display = 'none';
// 화면에서 제거
});
};
});
🤓 새로 알게된 기능들
원본 코드에서는 주석 처리했으나
블로그에 옮겨붙이니 너무 길어 따로 정리
img.onload: 이미지가 로드될 때까지 대기하다가, 로드가 완료되면 지정된 함수를 실행하는 역할
ctx.drawImage(img, 0, 0): 사과 이미지(img)를 캔버스의 (0, 0) 좌표에 그대로 그리고, 이미지의 픽셀 데이터를 캔버스에 로드해getImageData로 분석할 수 있게 준비하는 과정
getBoundingClientRect: 해당 요소의 크기와 뷰포트 기준 위치 정보를 반환
event.clientX,event.clientY: 클릭 이벤트가 발생한 마우스의 x,y좌표 (뷰포트 기준)
ctx.getImageData(pixelX, pixelY, 1, 1).data;: (pixelX, pixelY) 위치에서 1x1 픽셀의 데이터를 가져옴
(pixelX, pixelY, 1, 1): 이 배열은 [R, G, B, A] 형식으로 구성되고,
R (인덱스 0): 빨간색 값 (0~255)
G (인덱스 1): 초록색 값 (0~255)
B (인덱스 2): 파란색 값 (0~255)
A (인덱스 3): 알파 값 (투명도, 0~255) 를 나타낸다.
event.preventDefault();: 이벤트의 기본 동작 중단
클릭 이벤트가 불필요하게 전파되거나 다른 동작을 트리거하지 않도록 방지
event.stopPropagation();: 이벤트가 부모 요소로 전파되는 것을 중단
투명한 부분 클릭 시 이벤트가start-button이나 다른 부모 요소로 전달되지 않도록 막아줌
불투명한 부분에서만 커서가 포인터로 변하게 하고 싶어!
CSS에서 사과 버튼에 cursor: pointer;를 적용해두었더니 투명한 부분에도 마우스를 대면 커서가 포인터로 변해서 마치 클릭해도 되는 것처럼 사용자를 헷갈리게 만들 수 있을 것 같다는 생각이 들었다.
그래서 투명한 배경 말고 사과를 누를 때만 커서가 포인터로 변하도록 자바스크립트에 코드를 추가하였다.
apple.parentElement.addEventListener('mousemove', function(event) {
const rect = apple.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const pixelX = x * (img.width / apple.width);
const pixelY = y * (img.height / apple.height);
const pixelData = ctx.getImageData(pixelX, pixelY, 1, 1).data;
// 불투명한 부분일 때 커서 포인터로 설정
/* apple.parentElement에 커서 스타일을 적용하여
클릭 가능한 부분임을 시각적으로 표시 */
if (pixelData[3] > 0) {
apple.parentElement.style.cursor = 'pointer';
} else {
apple.parentElement.style.cursor = 'default'; // 투명한 부분에서는 기본 커서
}
});
💭
mousemove라는 DOM 이벤트를 새로 알게 되었다. 종류가 굉장히 많던데, 다양한 DOM 이벤트를 연습할 수 있는 클론 코딩 주제가 무엇이 있을지 고민해봐야겠다..
label for = ""
input태그에 label을 붙이는 건 알고 있었지만, for은 처음 알게 되었다.
for을 붙이니, input checkbox는 물론이고 label을 클릭해도 checkbox가 checked 된다! 신기방기..
신기해서 텍스트로 된 label을 계속 누르니까
시퍼렇게.. 선택됨
내가 평소 웹서핑이나 게임을 할 때,, 사용자 입장에서 너무너무 킹받고 불편하게 느꼈던 포인트였기 때문에 해결 방법을 찾아보다가, user-select: none;에 대해 알게 되었다. CSS에 이 코드를 추가하니 정말로 글자가 선택이 되지 않았다! 🤗
CSS, 자바스크립트로 화면 전환 효과를 넣고 싶어!
시작화면에서 게임화면으로 넘어갈 때 화면 전환 효과를 넣으면 멋질 것 같았다. (원본 사과게임에는 없지만,, 전부터 한 번 쯤 화면 전환 효과 넣는 방법을 알고 싶었으니까 이번을 기회로 삼아 ㅎㅎ..)
CSS에 추가한 코드:
.fade-out {
animation: fadeOut 0.5s ease-out forwards;
}
.fade-in {
animation: fadeIn 0.5s ease-in forwards;
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; display: none; }
}
@keyframes fadeIn {
from { opacity: 0; display: none; }
to { opacity: 1; display: block; }
}
자바스크립트 로직 수정:
// 불투명한 부분 클릭 시 동작
// 게임 시작 시 로직 추가
const startScreen = document.getElementById('start-screen');
const gameScreen = document.getElementById('game-screen');
startScreen.classList.add('fade-out');
setTimeout(() => {
startScreen.style.display = 'none';
gameScreen.classList.add('fade-in');
gameScreen.style.display = 'block';
}, 500);
❓ HTML에서
class="fade-in"와 같이 명시적으로 클래스를 지정하지 않았는데, 왜 CSS에서.fade-in과 같이 클래스를 나타내는 것처럼 썼지?❗ JavaScript가 실행 중에 동적으로 클래스를 추가하잖아!
HTML에는fade-in이나fade-out과 같은 클래스가 없고, CSS에만 정의되어 있다. 따라서 초기에는 효과가 적용되지 않지만 사과 버튼을 클릭하면 JavaScript가 실행되며 페이드 아웃 및 페이드 인 동작이 진행된다.
🤓 새로 알게된 기능들
@keyframes: 애니메이션의 프레임을 정의, 애니메이션이 시작부터 끝까지 어떤 속성을 어떻게 변경할지 단계별로 지정할 수 있음
HTML 요소에 클래스를 추가하거나, JavaScript로 동적으로 클래스를 추가해 애니메이션을 트리거한다.@keyframes 애니메이션_이름 { from { /* 시작 상태 스타일 */ } to { /* 끝 상태 스타일 */ } }@keyframes 애니메이션_이름 { 0% { /* 시작 상태 스타일 */ } 50% { /* 중간 상태 스타일 */ } 100% { /* 끝 상태 스타일 */ } }👍🏻 장점: 부드러운 전환, 재사용성
classList: 보통 CSS에서 미리.fade-out처럼 애니메이션 정의된 클래스를 만들어두고, JavaScript에서 이 클래스를add()로 요소에 붙여서 애니메이션을 시작시킴
setTimeout(): 지정한 시간(ms) 후에 특정 코드를 실행시키는 함수
이 함수를 사용하면 애니메이션 끝날 때까지 기다렸다가 다음 코드를 실행할 수 있다.setTimeout(() => { // 여기에 실행할 코드 }, 500); // 500ms = 0.5초 후 실행
버튼 클릭 애니메이션도 만들어서 버튼 클릭 - 화면 전환 간 애니메이션 타이밍도 조정했다!
const startScreen = document.getElementById('start-screen');
const gameScreen = document.getElementById('game-screen');
// 버튼 클릭 효과
apple.classList.add('pulse');
setTimeout(() => {
apple.classList.remove('pulse');
// 화면 전환 효과
startScreen.classList.add('fade-out');
setTimeout(() => {
startScreen.style.display = 'none';
gameScreen.classList.add('fade-in');
gameScreen.style.display = 'block';
}, 500);
}, 400);
🍏 시작 화면 제작까지 완료된 모습

💭 원본과 비슷하게 잘 만들어가고 있는 것 같아 아주 뿌듯하다!
⛵ 로직 흐름
새로 알게 된 메서드보다는,, 수학적인 내용의 비중이 커서 코드와 주석 내용을 첨부합니다! AI와 함께하는 수학 시간..
공부하느라 주석이 조금 많은데, 협업할 때는 주석을 어느정도로 적어야 하는지 궁금하다! 해커톤 하면서 알게 되겠지..?

배포주소에서 자꾸 404 에러가 나서 이미지 주소를 수정해줬더니 해결됐다.
Apple-Game/assets/apple.png , assets/apple.png를 해봤었는데 404에러가 해결되지 않았었고, ./assets/apple.png로 바꾸니까 비로소 에러가 해결되었다.
🤓 아하!
원래 경로 (절대 경로):
assets/website-icon.png
➡️ 이 경로는 기본적으로 GitHub Pages에서는/Apple-Game/assets/website-icon.png로 인식된다. 하지만 배포된 경로는fruit-box.kro.kr에서 루트 경로로 접근하므로, 경로가 올바르게 매칭되지 않을 수 있다.수정한 경로 (상대 경로):
./assets/website-icon.png
➡️index.html파일이 있는 디렉토리를 기준으로 이미지가assets폴더에 존재한다면, 정확한 경로로 이미지를 불러올 수 있다.
💭 시험
(보다는 아마도 게으름)이슈로 거의 두 달(5월→7월)만에 복귀하였다... 사실 그 두 달 동안 멋사에서 React까지 진도를 나갔기 때문에 이전에 작성했던 코드들과 파일 구조가 너무 못나보여서 여기서 그만두고 React로 다른 걸 클론 코딩해볼까 했었는데.. 쓸모없는 공부도 아니고 회고와 주석을 잘 달아놨어서 코드도 잘 기억 나길래 이왕 시작한 거 끝내보기로 맘 먹었다! 파이팅📣📣
🚩 목표
드래그로 사각형 범위 내의 사과를 선택해 5px 노란색 테두리로 표시하고, 합계가 10의 배수일 때 사과를 제거할 것.
⛵ 로직 흐름
.game-apple에 마우스 이벤트 연결const apples = document.querySelectorAll('.game-apple');
apples.forEach(apple => {
apple.removeEventListener('mousedown', startDrag);
apple.removeEventListener('mouseover', dragOver);
apple.removeEventListener('mouseup', endDrag);
apple.addEventListener('mousedown', startDrag);
apple.addEventListener('mouseover', dragOver);
apple.addEventListener('mouseup', endDrag);
});mousedown)첫번째 사과 클릭 시 드래그 시작 상태로 설정
시작 셀 저장 + 선택 리스트 초기화
function startDrag(event) {
event.preventDefault();
isDragging = true;
selectedApples = [];
startCell = event.target.closest('.game-apple');
if (startCell) {
selectedApples.push(startCell);
startCell.classList.add('selected-apple');
}
}
mouseover)마우스가 다른 사과 위로 이동할 때마다 시작 셀 ~ 현재 셀 사이의 직사각형 범위 계산
해당 영역 내 사과들을 selectedApples에 저장
function dragOver(event) {
if (!isDragging || !startCell) return;
const currentCell = event.target.closest('.game-apple');
if (!currentCell) return;
selectedApples.forEach(apple => apple.classList.remove('selected-apple'));
selectedApples = [];
const startIndex = parseInt(startCell.dataset.index);
const currentIndex = parseInt(currentCell.dataset.index);
const startRow = Math.floor(startIndex / 17);
const startCol = startIndex % 17;
const currentRow = Math.floor(currentIndex / 17);
const currentCol = currentIndex % 17;
const minRow = Math.min(startRow, currentRow);
const maxRow = Math.max(startRow, currentRow);
const minCol = Math.min(startCol, currentCol);
const maxCol = Math.max(startCol, currentCol);
for (let row = minRow; row <= maxRow; row++) {
for (let col = minCol; col <= maxCol; col++) {
const index = row * 17 + col;
const apple = document.querySelector(`.game-apple[data-index="${index}"]`);
if (apple) {
selectedApples.push(apple);
apple.classList.add('selected-apple');
}
}
}
}
mouseup)드래그가 끝나면 선택된 사과들의 합 계산
조건 만족 시 해당 사과들을 제거 후 빈 셀로 대체
또는 선택 해제
function endDrag() {
if (!isDragging) return;
isDragging = false;
const sum = selectedApples.reduce((total, apple) => total + parseInt(apple.dataset.number), 0);
if (sum % 10 === 0 && sum > 0) {
const count = selectedApples.length;
score += count === 2 ? 4 : count === 3 ? 6 : count === 4 ? 8 : 10;
document.querySelector('#top-bar p:nth-child(1)').textContent = `현재 점수: ${score}`;
selectedApples.forEach(apple => {
const emptyDiv = document.createElement('div');
emptyDiv.style.width = '45px';
emptyDiv.style.height = '45px';
emptyDiv.style.background = 'none';
emptyDiv.dataset.index = apple.dataset.index;
emptyDiv.classList.add('empty-cell');
apple.replaceWith(emptyDiv);
});
checkPossibleMoves();
} else {
selectedApples.forEach(apple => apple.classList.remove('selected-apple'));
}
selectedApples = [];
startCell = null;
}
🤔 만들면서 생겼던 문제점 & 새로 알게된 점
"dragOver 중단: isDragging 또는 startCell 없음"이라고 찍혔고, 드래그를 하는 도중에는 "dragOver 중단: currentCell이 감지되지 않음"이라고 찍히는 것을 확인했다. 여러 곳에 콘솔을 찍어 확인한 결과 이 문제는 이벤트 대상 감지와 바인딩 범위 문제로 발생한 것이라는 점을 깨닫게 되었고, 코드를 수정한 결과 정상적으로 처리되었다.
emptyDiv라는 새로운 요소를 만들어서 빈자리를 채워버렸다.
💭 사실 남은 시간을 원본 게임처럼 타이머 바를 통해 시간이 줄어드는 걸 시각적으로 보여주고 싶었는데, 생각보다 구현이 어렵고 css 만지다가 머리 터질 것 같아서
남은 시간: 00:00형식으로 갈아탔다..ㅎㅎ
다른 건 다 잘 되는데 왜 타이머 시간이 안 흘러가지? 했는데
타이머 함수에만 ()를 안 붙여줬었다...ㄷㄷㄷ
이거 말고도 오타 찾느라 애먹었다 ㅠ
애초에 칠 때부터 심혈을 기울여서 오타 내지 않기로 다짐...😤
딱히 어려운 구현은 아니라서 이외에 다른 문제점은 없었다.
설정 바가 뭐냐면, 이전에 화면 하단에 만들어 두었던 Reset 버튼과 BGM 체크박스!
Reset 버튼을 누르면 시작 화면으로 돌아가게 했고,
BGM은 체크박스에 체크하면 노래가 나오고 체크 해제하면 노래가 멈추게 만들었다.

제한 시간이 다 끝나면 모달 창이 뜨도록 구현했다. 근데 이거 만들면서 계속 BGM 틀어놓는 중인데 노래가 넘 신난다.....🧑🏼🎤🎶
