Javascript

[JavaScript] 자바스크립트 12장-재귀 함수 사용하기_지뢰 찾기 게임

양치개발자 2023. 6. 28. 23:29
반응형

12-1. 지뢰 찾기 순서도 그리기

🔆 지뢰 찾기란?

테이블 모야으이 칸을 만들고 폭탄을 무작위로 배치해야 합니다. 그리고 칸을 클릭하 주변 지뢰 개수를 표시하고, 주변 칸이 빈칸이면 한 번에 모든 칸을 여는 작업도 필요합니다. 또한, 마우스 오른쪽 버튼으로 칸을 클릭해 물음표나 깃발 표시도 해봅니다. 기능이 다양하므로 코드도 상당히 길겠죠?
호출 스택과 이벤트 루프의 원리를 활용해 실행 순서를 파악할 수 있다.

🔆 순서도 작성

  1. 시작
  2. 가로,세로 수에 따라 테이블 생성
  3. 칸에 현명하게 지뢰 심기
  4. 대기

12-2. 지뢰 심기

가로 10칸, 세로 10줄, 지뢰 10개로 시작합니다. 지뢰 데이터를 표현하는 배열을 만들고 반복문을 돌면서 tbody 태그 안에 tr 과 td 태그를 만들어 넣으면 됩니다. 데이터와 화면 표현은 같이 해야된다.
 

칸 상태에 따른 코드

종류 코드 숫자 코드 이름
열린 칸 0 ~ 8 OPEND
닫힌 칸(지뢰 X) -1 NOMAL
물음표 칸(지뢰 X) -2 QUESTION
깃발 칸(지뢰 X) -3 FLAG
물음표 칸(지뢰 O) -4 QUESTION_MINE
깃발 칸(지뢰 O) -5 FLAG_MINE
닫힌 칸(지뢰 O) -6 MINE

 

10 X 10 표 생성하기

 

랜덤 배열 생성

이 랜덤 배열 값을 지뢰로 만들 것이다.
 
표 생성을 할 때, 2차원 배열로 하는 방법고, 1차원 배열 방법이 있다.

  • 2차원 배열은 엑셀처럼 보기 편한 장점이 있다
  • 1차원 배열은 배열변수명[x][y] 이렇게 값을 찾아야하는 불편함이 있다.

 

data 배열

 

테이블 생성 함수

 function drawTable() {
            data = plantMine();
            data.forEach((row) => {
                const $tr = document.createElement('tr');
                row.forEach((cell) => {
                    const $td = document.createElement('td');
                    if(cell === CODE.MINE){
                        $td.textContent = 'X'; // 개발 편의를 위해
                    };
                    $tr.append($td);
                });
                $tbody.append($tr);
            });
        };
        drawTable();

 

결과 화면

12-3. 우클릭으로 깃발 꽃기

난이도가 낮은 우클릭부터 구현해 봅시다. 깃발 이미지가 없으므로 깃발은 느낌표로 표시하겠습니다.
지금까지 클릭 시 click 이벤트를 연결했지만, 우클릭 이벤트는 contextmenu 이벤트가 따로 존재합니다.
단, 브라우저 화면에서는 우클릭하면 메뉴가 뜨는 기본 동작이 있습니다.
이 기본 동작을 없애야(preventDefault) 우클릭이 원하는 대로 동작합니다.
 

코드 구현

<script>
	...
    		// 우클릭 함수
        function onRightClick(event) {
            event.preventDefault() // 우클릭 기본 방지
            const target = event.target; // target 변수 지정
            const rowIndex = target.parentNode.rowIndex; // 몇 번째 줄, target.parentNode = $tr
            const cellIndex = target.cellIndex; // 몇 번째 칸
            const cellData = data[rowIndex][cellIndex]; // 셀값은 2차원 배열 값임

            if (cellData === CODE.MINE) { // 지뢰이면 (-6)
                data[rowIndex][cellIndex] = CODE.QUESTION_MINE; // 물음표 지뢰로 (-4)
                target.className = 'question'; // question 클래스 추가
                target.textContent = '?'; // ? 글씨 추가

            } else if (cellData === CODE.QUESTION_MINE) { // 물음표 지뢰이면 (-6)
                data[rowIndex][cellIndex] = CODE.FLAG_MINE; // 깃발 지뢰로 (-5)
                target.className = 'flag'; // flag 클래스 추가
                target.textContent = '!'; // ! 글씨 추가

            } else if (cellData === CODE.FLAG_MINE) { // 깃발 지뢰 (-5)
                data[rowIndex][cellIndex] = CODE.MINE; // 지뢰로 (-6)
                target.clasName = '';
                target.textContent = '';

            } else if (cellData === CODE.NORMAL) { // 닫힌 칸이면 (-1)
                data[rowIndex][cellIndex] = CODE.QUESTION; // 물음표 (-2)
                target.className = 'question'; // question 클래스 추가
                target.textContent = '?'; // ? 글씨 추가

            } else if (cellData === CODE.QUESTION) { // 물음표면 (-2)
                data[rowIndex][cellIndex] = CODE.FLAG; // 깃발으로 (-3)
                target.className = 'flag'; // flag 클래스 추가
                target.textContent = '!'; // ! 글씨 추가

            } else if (cellData === CODE.FLAG) { // 깃발으로 (-3)
                data[rowIndex][cellIndex] = CODE.NORMAL; // 닫힌 칸으로 (-1)
                target.className = '';
                target.textContent = '';
            }

        }
        
        $tbody.addEventListener('contextmenu', onRightClick); // 이벤트 버블링을 이용을 해서 tbody를 한 번만 해도 된다.
        
    ...
</script>

 

 

결과 화면

 

12-4. 주변 지뢰 개수 세기(optional chaining)

좌클릭도 구현해봅시다. 마우스 왼쪽 버튼을 클릭할 때, 해당 칸에 지뢰가 있는지 없는지는 확인
지뢰가 없는 칸경우부터 구현하겠습니다. 지뢰가 없는 칸이라
 
 

옵셔널 체이닝(optional chaining, 선택적 연결)

?. 연산자

 

 
배열에서 -1번째는 값이 없기 때문에 undefined가 됩니다
즉, undefined(-1)가 되는 것이기 때문에 에러가 발생합니다.
그것을 해결하기 위해서 옵셔닝 체이닝이라는 문법이 있다.
 

<script>
	...
		// 지뢰 갯수 함수
        function countMine(rowIndex, cellIndex) {
            const mines = [ CODE.MINE, CODE.QUESTION_MINE, CODE.FLAG_MINE ];
            let i = 0;
            // 앞에 값이 존재하면 i++를 실행함
            mines.includes(data[rowIndex - 1] ?. [cellIndex - 1]) && i++; // 1번칸
            mines.includes(data[rowIndex - 1] ?. [cellIndex]) && i++; // 2번칸
            mines.includes(data[rowIndex - 1] ?. [cellIndex + 1]) && i++; // 3번칸
            mines.includes(data[rowIndex][cellIndex - 1]) && i++; // 4번칸
            // 5번칸은 자기 자신
            mines.includes(data[rowIndex][cellIndex + 1]) && i++; // 6번칸
            mines.includes(data[rowIndex + 1] ?. [cellIndex - 1]) && i++; // 7번칸
            mines.includes(data[rowIndex + 1] ?. [cellIndex]) && i++; // 8번칸
            mines.includes(data[rowIndex + 1] ?. [cellIndex + 1]) && i++; // 9번칸
            return i;
        };
	...
    
    	function drawTable() {
        	...
        		$tbody.addEventListener('click', onLeftClick);
        	...
            }
</script>

 

12-5. nullish coalescing, 논리 연산자의 진짜 뜻

nullish coalescing(병합 연산자, ??)

 

  • ||
A ?? B

A가 값이 있으면 A값 호출 , 없으면 B값 호출 (즉, A가 true 이면 A값 나옴,  A가 flase 이면 B값 나옴)
 

예시

 

실행 화면

 

  • ??
A ?? B

A가 null 도 아니고 undefined도 아니면 A, 그 외의 경우는 B
 

실행 화면

 

  • &&
A && B

(즉, A가 true 이면 B값 나옴)
 

예시

 
 

최종 정리

어떤 경우가 뒤에 값을 쓰는 경우

  • && 연산자를 쓸 경우, 앞에 값이 true이면 뒤에 값을 써라
  • ||연산자는 앞에 값이 false이면 뒤에 값을 써라
  • ??연산자는 앞에 null, undefined이면 뒤게 값을 써라

 

12-6. 주변 칸 한번에 열기(재귀, Maximum call stack size exceeded 해결)

클릭시 지뢰가 없으면 주변에 모든 방향 열리는 것으로 만듬

<script>
	...
    	function open(rowIndex, cellIndex) {
            const target = $tbody.children[rowIndex]?.children[cellIndex];
            if (!target) {
                return;
            }

            const count = countMine(rowIndex, cellIndex);
            target.textContent = count || '';
            target.className = 'opened';
            data[rowIndex][cellIndex] = count;
            return count;
        };

            // 주변 오픈 함수
            // 재귀 함수 : 자기 함수 안에 자기 함수 호출
            function openAround(rI, cI) {
                const count = open(rI, cI)
                if (count === 0) {
                    openAround(rI - 1, cI - 1);
                    openAround(rI - 1, cI);
                    openAround(rI - 1, cI + 1);
                    openAround(rI, cI - 1);
                    openAround(rI, cI + 1);
                    openAround(rI + 1, cI - 1);
                    openAround(rI + 1, cI);
                    openAround(rI + 1, cI + 1);
                };
        };

    ...
</script>

 

 

결과 화면 

JavaScript에서는 이런 에러가 자주 발생한

호출스택의 최대 크기를 초과했을 때, 발생하는 오류이다

onLeftClick 함수가 제일 먼저 호출되고, 그 안에서 openAround 함수가 호출됩니다. 따라서 호출 스택에서

onLeftClick 함수 위에 openAround 함수가 올라가 있습니다. 그 다음에 open 함수가 호출 스택으로 들어갑니
 

...
	function onLeftClick(event) {
    	...
        	if (cellData === CODE.NORMAL) { // 닫힌 칸이면(-1)
                openAround(rowIndex, cellIndex); // 클릭 근처 칸 다 열림
                const count = countMine(rowIndex, cellIndex);
                target.textContent = count || '';
                target.className = 'opened';
        ...
    }
...

 function openAround(rI, cI) {
            const count = open(rI, cI)
            if (count === 0) {
                openAround(rI - 1, cI - 1);
                openAround(rI - 1, cI);
                openAround(rI - 1, cI + 1);
                openAround(rI, cI - 1);
                openAround(rI, cI + 1);
                openAround(rI + 1, cI - 1);
                openAround(rI + 1, cI);
                openAround(rI + 1, cI + 1);
            };
        };

기존에 openAround 함수 호출 시 생기는 이미지 


 호출 스택을 코드로 보는 법(재귀 함수로 하는 법)


 이 문제를 해결하는 방법은 비동기 함수를 사용하는 것이다

대표적으로 사용하는 비동기 함수인 setTimeout인 것으로 함

호출 스택을 백그라운드와 테스크 큐한테 넘어 주면 좋을 거 같다

function openAround(rI, cI) {
            setTimeout(() => {
                const count = open(rI, cI)
                if (count === 0) {
                    openAround(rI - 1, cI - 1);
                    openAround(rI - 1, cI);
                    openAround(rI - 1, cI + 1);
                    openAround(rI, cI - 1);
                    openAround(rI, cI + 1);
                    openAround(rI + 1, cI - 1);
                    openAround(rI + 1, cI);
                    openAround(rI + 1, cI + 1);
                };
            }, 0);
        };

이 문제를 해결 했는데 브라우저가 렉이 걸리고 느려지는 현상이 발생한다

 

12-7. 승리 조건 체크하기(재귀 최적화)

 

setTImeout 함수로 실행으로 실행 되는 것을 그림으로 표현 한것

 

조금 느리고 버벅 버리는 현상이 일어나는 이유는 내가 클릭한 박스 기준으로 주변이 openAround하고

오른쪽에 있는 박스도 주변에 또 열리고 왼쪽도 마찬가지라서 그 현상이 반복이 되서 그런 것이다.

그래서 해결 방법이 한 번 연 칸은 안 열면 되는게 아닌가?

 

...
	let openCount = 0; // 열린된 카운트 변수
    
            // 해당 위치 오픈하는 함수
        function open(rowIndex, cellIndex) {
            // $tbody의 자식 중에서 rowIndex에 해당하는 행 선택, 그 행 중에서 cellIndex에 해당되는 셀 선택 / 없으면 undefined 해당됨
            const target = $tbody.children[rowIndex]?.children[cellIndex];
            // target값이 없으면 함수 실행 X
            if (!target) {
                return;
            }
            const count = countMine(rowIndex, cellIndex); //  현재 위치 주변에 있는 지뢰의 개수를 계산하고 count 값 저장
            target.textContent = count || '';  // target의 텍스트는 count 값 설정 / 0이면 빈 문자열 
            target.className = 'opened'; // opened이라는 클래스 추가
            data[rowIndex][cellIndex] = count; // data 배열에 count 값으로 업데이트
            openCount++; // openCount 값 1씩 증가
            console.log(openCount);
            return count; // 주변에 있는 지뢰의 개수 반환
        };
...

브라우저가 에러가 나서 걸렸습니다.

 

 

모서리쪽에 있는 칸을 오픈을 할 때에는 모서리 위쪽에 있는 칸은 에러가 난다

if (data[rowIndex]?.[cellIndex] >= CODE.OPENED) return;

라고 ?.(옵셔널 체이닝)으로 undefined 해결하였습니다.

 

승리하는 법 만들기

let startTime = new Date(); // 시작초
        const interval = setInterval(() => {
            const time = Math.floor((new Date() - startTime) / 1000); // 현재시간 - 시작 시간 / 1000으로 하면 초로 나옴 물론 정수로
            $timer.textContent = `${time}초`;
        }, 1000);
        
        
        // 해당 위치 오픈하는 함수
        function open(rowIndex, cellIndex) {
            // 이미 열린 셀인 경우 함수 실행을 중단합니다.
            if (data[rowIndex]?.[cellIndex] >= CODE.OPENED) return;
            // $tbody의 자식 중에서 rowIndex에 해당하는 행 선택, 그 행 중에서 cellIndex에 해당되는 셀 선택 / 없으면 undefined 해당됨
            const target = $tbody.children[rowIndex]?.children[cellIndex];
            // target값이 없으면 함수 실행 X
            if (!target) {
                return;
            }
            const count = countMine(rowIndex, cellIndex); //  현재 위치 주변에 있는 지뢰의 개수를 계산하고 count 값 저장
            target.textContent = count || '';  // target의 텍스트는 count 값 설정 / 0이면 빈 문자열 
            target.className = 'opened'; // opened이라는 클래스 추가
            data[rowIndex][cellIndex] = count; // data 배열에 count 값으로 업데이트
            openCount++; // openCount 값 1씩 증가
            console.log(openCount);
            
            // 승리 했을 시 조건
            if (openCount === row * cell - mine) { // 100 - 10(승리하였을 때)
                const time = (new Date() - startTime) / 1000; // 초 단위로 하기
                clearInterval(interval); // 타이머 멈춤
                // 이벤트 버블링으로 좌,우 클릭 한 번만 $tody 막으면 됨
                $tbody.removeEventListener('contextmenu', onRightClick);
                $tbody.removeEventListener('click', onLeftClick);
                setTimeout(() => { // 화면이 바낄수 있는 시간을 줌
                    alert(`승리했습니다! ${time}초가 걸렸습니다.`);
                }, 500); // 0.5초, 화면에 먼저 그릴 시간을 주는 것임
            }
            return count; // 주변에 있는 지뢰의 개수 반환
        };
        
        
        // 좌클릭 함수에도
        function onLeftClick(event) {
            const target = event.target;  // 클릭된 td 태그 가져옴
            const rowIndex = target.parentNode.rowIndex; // 몇 번째 줄, target.parentNode = $tr
            const cellIndex = target.cellIndex; // 몇 번째 칸
            const cellData = data[rowIndex][cellIndex]; // 셀값은 2차원 배열 값임
            if (cellData === CODE.NORMAL) { // 닫힌 칸이면(-1)
                openAround(rowIndex, cellIndex); // 클릭 근처 칸 다 열림
            } else if (cellData === CODE.MINE) { // 지뢰 칸이면
                target.textContent = '펑'; // 셀의 텍스트 내용을 '평'으로 설정
                target.className = 'opened'; // 셀의 클래스를 'opened'로 설정하여 열린 상태로 표시
                clearInterval(interval); // 지뢰 클릭하면 타이머 멈추면됨
                $tbody.removeEventListener('contextmenu', onRightClick);
                $tbody.removeEventListener('click', onLeftClick);
            } // 나머지는 무시
            // 아무 동작도 안 함
        }

승리 조건이 내가 지뢰빼고 다 찾은 경우랑 내가 지뢰를 왼쪽 클릭으로 한 경우 두 가지가 있는데

이 경우를 시간 초를 재서 하도록 구현 하였다.

 

12-8. 줄,칸, 지뢰 개수 입력받기

우선 우리가 임의로 지정을 하였는데 사용자한테 데이터를 받아서 하는게 더 좋을거 같아서 form태그 추가하였습니다

 <form id="form">
        <input placeholder="가로 줄" id="row" size="5" />
        <input placeholder="세로 줄" id="cell" size="5" />
        <input placeholder="지뢰" id="mine" size="5" />
        <button>생성</button>
    </form>
    
 <script>
 	const $form = document.querySelector('#form');
    
    // 사용자가 직접 할 수 있도록
        let data; // 데이터 변수
        let openCount;// 열린된 카운트 변수
        let startTime; // 시작시작
        let interval; // 시간 죄는 것
        
        function onSubmit() {
            event.preventDefault(); // 기본 클릭 방지
            row = parseInt(event.target.row.value); // parseInt로 이벤트 타겟의 row값을 숫자로 변환
            cell = parseInt(event.target.cell.value); // parseInt로 이벤트 타겟의 cell값을 숫자로 변환
            mine = parseInt(event.target.mine.value); // parseInt로 이벤트 타겟의 지뢰값을 숫자로 변환
            openCount = 0;
            clearInterval(interval);
            $tbody.innerHTML = ''; // $tbody 초기화
            drawTable(); // 게임 테이블 생성
            startTime = new Date(); // 시작초
            interval = setInterval(() => { // 걸린 시간 재는것
                const time = Math.floor((new Date() - startTime) / 1000); // 현재 시간과 시작 시간을 차이를 계산하여 초 단위로 반올림한 값
                $timer.textContent = `${time}초`;
            }, 1000);
        };
        $form.addEventListener('submit', onSubmit);
 </script>

 

진짜로 게임처럼 출시를 할 때,에는 X표시를 없애는 좋음

function drawTable() {
	// $td.textContent = 'X'; // 개발 편의를 위해
    이 코드를 주석 처를 한다
}

최종 결과

 

배운 내용 정리하기

1. contextmenu 이벤트

지금까지 마우스 클릭 이벤트는 모두 좌클릭이였습니다. 마우스 우클릭 이벤트는 따로 있습니다.

좌클릭 이벤트는 click 이고, 우클릭 이벤트는 contextmenu입니다. contextmenu 이벤트는

기본적으로 브라우저 메뉴를 띄우므로 기본 동작을 막으려면 event.preventDeafult 메서드를 호출해야

2. 옵셔널 체이닝

?.는 옵셔널 체이닝(optional chaining)이라는 문법이다. 앞에 있는 것이 참인 값이면 뒤 코드를 실행

거짓 값이면 코드를 통째로 undefined를 만들어버립니다. 객체나 배열뿐만 아니라 함수에도 옵셔널 체이닝 적용할 수 있다

속성에 접근하거나 호출하려는 것이 거짓인 값인지 아닌지 의심될 때 옵셔널 체이닝 적욯

 

3. 재귀 함수(recursive function)

어떤 함수의 내부에서 자기 자신을 다시 호출하는 함수

재귀 함수를 사용할 때, 호출 스택의 최대 크기를 초과하는 경우가 빈번하게 발생합니다.

이때 Maximum call stack size exceeded 오류가 발생하는데, setTimeout과 같은 비동기 함수를 사용해 해결할 수 있따.

 

재귀 함수를 사용할 때는 연산량이 많으면 브라우저가 느려지는 현상이 발생하므로

연산량을 최소화 할 수 있께 코드 작성한다

반응형