1. 프로젝트 소개
- 프로젝트 설명 : 귀여운 동물인 "웜뱃"을 컨셉으로 한 "3 매치 퍼즐 게임" 입니다.
- 프로젝트 모티브 : 애니팡 시리즈, 캔디 크러쉬 사가, 캔디 크러쉬 소다
- 사용 기술 : HTML, CSS, JavaScript (프론트엔드만을 사용)
- 사용 에셋 출처 : Chat GPT 이미지 생성 이미지 활용
- 개발 이유 :
- 1. 프론트엔드만을 사용한 이유
- : 깃허브 정적 디렉토리 배포 기능을 사용해서 배포하기 위함입니다.
- 2. 프로젝트 키워드인 "웜뱃", "3 매치 퍼즐 게임" 선정 이유
- 1. 최근 관심을 갖게된 "웜뱃"이란 동물이 귀엽고 확실한 캐릭터성을 갖고있기 때문입니다.
- 2. 고전적이면서도 중독적인 게임 장르가 "3 매치 퍼즐 게임"이기 때문입니다.
- 3. "3 매치 퍼즐 게임"은 Grid 형태로 구성하기가 적합하기 때문입니다.
- 4. "3 매치 퍼즐 게임"은 로직 구현과 UI 상호작용이 명확하게 드러나서, 프론트엔드 공부에 좋은 주제이기 때문입니다.
- 1. 프론트엔드만을 사용한 이유
2. 게임 규칙 설명
- 플레이 규칙 :
- 7x7 크기의 게임 필드에서 5종류의 웜뱃이 무작위의 위치에 배치되어 게임이 시작됩니다.
- 이때, 초기 배치 상태에서 같은 종류의 웜뱃은 가로 / 세로 어느 방향으로도 3개가 연속되어 배치되지 않습니다.
- 가로 / 세로를 기준으로 같은 종류의 블록을 3개이상 연결(매치)하면 사라집니다.
- 퍼즐이 매치되어 사라지면, 위에 있던 퍼즐들이 아래로 당겨져 빈 공간을 채웁니다. (중력 효과)
- 이후 필드 상단에 발생한 빈 공간은 새롭게 생성된 퍼즐들로 채워져 게임이 계속 진행됩니다.
- 제한 시간이 끝날때 까지 게임은 계속해서 진행되고, 게임 종료 후 최종 점수가 보여집니다.
- 7x7 크기의 게임 필드에서 5종류의 웜뱃이 무작위의 위치에 배치되어 게임이 시작됩니다.
- 점수 산정 방식 :
- 매치된 블록의 수에 따라 추가되는 점수가 달라집니다.
- 매치된 블록이 3개일 경우 : 블록당 10점
- 매치된 블록이 4개일 경우 : 블록당 15점
- 매치된 블록이 5 ~ 6개일 경우 : 블록당 20점
- 매치된 블록이 7개 이상일 경우 : 블록당 25점
- 제한 시간 : 45초
- 효과 (애니메이션)
- 'SWAP' 효과 : 인접한 블록간 위치 교환(SWAP)시 발생하는 애니메이션 효과
- 'MATCH-CLEAR' 효과 : 3개 이상의 블록이 매치되었을때 매치된 블록이 사라지는 애니메이션 효과
3. 기능 구성 설명
◆ Intro / Game / Result Section 분리하여 구성
"Catch the Wombat!"에서와 같이 단건의 HTML 파일로 3개의 페이지 효과를 구현하기 위해 섹션으로 구분하는 방식을 채택하였다. (https://notorious.tistory.com/453)
◆ 7x7 게임필드 구성
<!-- game area 7X7 -->
<div id="game-area">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<!--cell 42개 중략...-->
</div>
div 태그를 이용해서 7x7 셀을 구성합니다.
const cells = document.querySelectorAll(".cell");
좌표는 (y, x)의 형태가 아니라, 0~48까지 인덱스 형태로 갖는다.
◆ 매치(Match) 감지
/**1. 3개 이상 일치하는 블록을 찾는 메서드 (image src를 기준으로 비교) */
function checkMatches() {
const matchedIndices = new Set();
//row check
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize - 2; col++) {
const idx = row * boardSize + col;
const img1 = cells[idx].querySelector("img")?.src;
const img2 = cells[idx + 1].querySelector("img")?.src;
const img3 = cells[idx + 2].querySelector("img")?.src;
if (img1 && img1 === img2 && img2 === img3) {
matchedIndices.add(idx);
matchedIndices.add(idx + 1);
matchedIndices.add(idx + 2);
let k = 3;
while (col + k < boardSize && cells[idx + k].querySelector("img")?.src === img1) {
matchedIndices.add(idx + k);
k++;
}
col += k - 1;
}
}
}
//column check
for (let col = 0; col < boardSize; col++) {
for (let row = 0; row < boardSize - 2; row++) {
const idx = row * boardSize + col;
const img1 = cells[idx].querySelector("img")?.src;
const img2 = cells[idx + boardSize].querySelector("img")?.src;
const img3 = cells[idx + boardSize * 2].querySelector("img")?.src;
if (img1 && img1 === img2 && img2 === img3) {
matchedIndices.add(idx);
matchedIndices.add(idx + boardSize);
matchedIndices.add(idx + boardSize * 2);
// 연속 검사
let k = 3;
while (row + k < boardSize && cells[idx + boardSize * k].querySelector("img")?.src === img1) {
matchedIndices.add(idx + boardSize * k);
k++;
}
row += k - 1;
}
}
}
return [...matchedIndices];
}
- "checkMatches()" 메서드는 게임필드 전체에 매치된 블록을 모두 찾아 배열의 형태로 반환하는 기능을 수행한다.
- "matchedIndices" : Set() 타입을 이용해서 매치된 블록의 좌표 저장
- Set() 타입을 이용한 이유 : 좌표가 중복 저장되지 않는다. 예를 들어 십자가 형태로 매치가 된다면, 세로의 중간 부분, 가로의 중간 부분이 서로 겹치게 된다. 이렇다면, 실제로 매치된 블록은 5개지만, 매치된 블록이 세로 3개, 가로 3개가 각각 저장될 수 있다. 따라서 Set()을 이용해서 중복되는 좌표없이 실제 매치된 블록만 저장되도록 한다.
- 가로방향을 기준으로 먼저 탐색하고, 세로방향을 기준으로 탐색하여, 중복된 좌표를 모두 추출하여 배열의 형태로 반환한다.
◆ 블록 인접 감지
블록 인접 감지는 "스왑(Swap) 기능" 실행을 위해 필수적이다.
스왑은 인접한 블록간에만 가능하기 때문이다.
/**isAdjacent(index1, index2) - 두 블록이 인접한지 확인 */
function isAdjacent(index1, index2) {
const row1 = Math.floor(index1 / boardSize);
const col1 = index1 % boardSize;
const row2 = Math.floor(index2 / boardSize);
const col2 = index2 % boardSize;
const rowDiff = Math.abs(row1 - row2);
const colDiff = Math.abs(col1 - col2);
return (rowDiff + colDiff === 1); //상하좌우 중 하나만 차이
}
◆ 스왑(Swap) 기능 구현 & 블록 제거 & 점수 계산
/**handleBlockClick() - 블록 클릭 처리 */
function handleBlockClick(index) {
if (isAnimating) return; //애니메이션 작동 중에는 불가!
if (firstSelected === null) {
firstSelected = index;
highlightCell(index);
} else {
if (firstSelected === index) {
unhighlightCell(index);
firstSelected = null;
return;
}
if (isAdjacent(firstSelected, index)) {
animateSwap(firstSelected, index, () => {
swapBlocks(firstSelected, index); // 임시 스왑
const matches = checkMatches();
if (matches.length > 0) {
processMatches();
} else {
animateSwap(firstSelected, index, () => {
swapBlocks(firstSelected, index);
});
}
});
}
unhighlightCell(firstSelected); // 시각적 선택 해제
firstSelected = null;
}
}
/**3. swap method */
function swapBlocks(index1, index2) {
const img1 = cells[index1].querySelector("img");
const img2 = cells[index2].querySelector("img");
if (img1 && img2) {
const tempSrc = img1.src;
img1.src = img2.src;
img2.src = tempSrc;
}
}
블록 스왑시에는 무조건 스왑하면 안된다.
만일 스왑은 스왑 후 매치가 일어나야만 가능하다.
매치를 야기하지 않는 스왑은 일어나선 안된다.
이를 구현하기 위해서는 임시로 스왑을 진행한 후, 매치가 일어난다면, 해당 스왑을 적용한다.
만일 매치가 일어나지 않았다면, 스왑을 적용하지 않는다.
스왑이 매치를 야기했다면(매치된 블록의 좌표를 저장하는 배열의 길이가 0 초과라면) 스왑을 적용하고 매치된 블록을 제거한다.
◆ 블록 "중력 효과" 처리 & 새 블록 채우기
/**칸 삭제 -> 빈칸 아래로 땡기기 -> 다시 칸 삭제 연쇄반응응 */
function processMatches() {
let matches = checkMatches();
if (matches.length === 0) return;
// 점수 반영: 블록 하나당 10점
// score += matches.length * 10;
score += addScore(matches.length);
updateScoreDisplay();
animateMatchedBlocks(matches, () => {
removeMatches(matches); //1. 매치된 블록 지우기
collapseBoard(); //2. 빈칸 내려서 채우기
refillBoard(); //3. 빈칸 새 이미지로 채우기
setTimeout(processMatches, 200);
});
}
/**4. removeMatches method -> 매치된 블록 제거 및 점수 추가 (img 태그 삭제 방식!) */
function removeMatches(matchedIndices) {
matchedIndices.forEach(idx => {
const img = cells[idx].querySelector("img");
if (img) {
img.remove();
}
})
}
/**5. collapseBoard -> 위의 블록들이 아래로 떨어지도록 처리 */
function collapseBoard() {
for (let col = 0; col < boardSize; col++) {
for (let row = boardSize - 1; row >= 0; row--) {
const idx = row * boardSize + col;
const cell = cells[idx];
if (!cell.querySelector("img")) {
// 위로 올라가며 img가 있는 셀을 찾기
for (let upperRow = row - 1; upperRow >= 0; upperRow--) {
const upperIdx = upperRow * boardSize + col;
const upperCell = cells[upperIdx];
const img = upperCell.querySelector("img");
if (img) {
// 위의 img 태그를 잘라서 아래로 이동
upperCell.removeChild(img);
cell.appendChild(img);
break;
}
}
}
}
}
}
/**6. refillBoard -> 빈칸에 새로운 블록 생성 */
function refillBoard() {
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
if (!cell.querySelector("img")) {
const newImg = document.createElement("img");
newImg.src = getRandomImage();
newImg.classList.add("wombat-img");
newImg.draggable = false; //드래그 방지
cell.appendChild(newImg);
}
}
}
◆ 제한 시간 설정
const limitTime = 45000; //45 seconds
function startGame() {
score = 0;
document.getElementById("score").textContent = score;
showSection("game");
generateBoard(); //게임 보드 채우기
startGauge();
// 45초 후 게임 종료
setTimeout(() => {
endGame();
}, limitTime);
}
4. 결과물
GitHub 저장소 주소 : https://github.com/yashin20/front-games/tree/main/wombat-n-berries
front-games/wombat-n-berries at main · yashin20/front-games
프런트엔드 게임 모음. Contribute to yashin20/front-games development by creating an account on GitHub.
github.com
접속 URL : https://yashin20.github.io/front-games/wombat-n-berries/wombat-n-berries.html
Wombat & Berries
Game Rules 🧩 이 게임은 3매치 퍼즐 게임입니다. 같은 웜뱃 캐릭터를 가로 또는 세로로 3개 이상 맞추면 점수를 얻을 수 있어요! ⏱ 제한 시간 안에 최대한 많은 짝을 만들어 고득점에 도전하세요.
yashin20.github.io