JavaScript - 지뢰찾기 예제, maximum call stack size exceeded , 옵셔널 체이닝

Jenna·2022년 12월 26일
1

javascript

목록 보기
14/16

지뢰찾기 게임 예제


지뢰찾기 게임을 통해서 알아보는 maximum call stack size exceeded문제와 옵셔널 체이닝


📌 순서도 그리기



📌 코드보기


<style>
    table {
        border-collapse: collapse;
    }

    td {
        border: 1px solid #bbb;
        text-align: center;
        line-height: 20px;
        width: 20px;
        height: 20px;
        background: #888;
    }

    td.opened {
        background: white;
    }

    td.flag {
        background: red;
    }

    td.question {
        background: orange;
    }
</style>

<body>
    <form id="form">
        <input placeholder="가로 줄" id="row" size="5" />
        <input placeholder="세로 줄" id="cell" size="5" />
        <input placeholder="지뢰" id="mine" size="5" />
        <button>생성</button>
    </form>
    <div id="timer">0초</div>
    <table id="table">
        <tbody></tbody>
    </table>
    <div id="result"></div>
    <script>
        const $form = document.querySelector('#form');
        const $timer = document.querySelector('#timer');
        const $tbody = document.querySelector('#table tbody');
        const $result = document.querySelector('#result');
        let row;
        let cell;
        let mine;
        const CODE = {
            NORMAL: -1, //닫힌 칸 지뢰없음
            QUESTION: -2,
            FLAG: -3,
            QUESTION_MINE: -4,
            FLAG_MINE: -5,
            MINE: -6,
            OPENED: 0, //0이상이면 다 모두 열린 칸  
        }


        let data;
        let openCount //내가 몇칸을 열고있는지 
        let startTime;
        let interval;

        function onSubmit(event) {
            event.preventDefault();
            row = parseInt(event.target.row.value);
            cell = parseInt(event.target.cell.value);
            mine = parseInt(event.target.mine.value);
            openCount = 0;
            clearInterval(interval);
            $tbody.innerHTML = '';
            drawTable();
            firstClick = true;
            startTime = new Date();
            interval = setInterval(() => {
                const time = Math.floor((new Date() - startTime) / 1000);
                $timer.textContent = `${time}`;
            }, 1000);
        }
        $form.addEventListener('submit', onSubmit);
        function plantMine() {
            const candidate = Array(row * cell).fill().map((arr, i) => {
                return i;
            });
            const shuffle = [];
            while (candidate.length > row * cell - mine) { //10개만 뽑겠다 
                const chosen = candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0];
                shuffle.push(chosen);
            }
            const data = [];
            for (let i = 0; i < row; i++) {
                const rowData = [];
                data.push(rowData);
                for (let j = 0; j < cell; j++) {
                    rowData.push(CODE.NORMAL); //일단 지뢰없는 닫힌칸으로 채워주기 
                }
            }
            //shuffle = [85, 19, 93]
            for (let k = 0; k < shuffle.length; k++) {
                const ver = Math.floor(shuffle[k] / cell); //(85 /10) 몇번째 줄인지 알아내기 위해 
                const hor = shuffle[k] % cell; // 85%10 = 5번째 칸 
                data[ver][hor] = CODE.MINE;
            }
            return data;
        }
        //우측클릭으로 깃발심기
        function onRightClick(event) {
            event.preventDefault();
            const target = event.target;
            const rowIndex = target.parentNode.rowIndex;
            const cellIndex = target.cellIndex;
            const cellData = data[rowIndex][cellIndex];
            if (cellData === CODE.MINE) {//지뢰면
                data[rowIndex][cellIndex] = CODE.QUESTION_MINE; //물음표 지뢰로
                target.className = 'question';
                target.textContent = '?';
            } else if (cellData === CODE.QUESTION_MINE) { //물음표 지뢰면
                data[rowIndex][cellIndex] = CODE.FLAG_MINE; //깃발지뢰로
                target.className = 'flag';
                target.textContent = '!';
            } else if (cellData === CODE.FLAG_MINE) { //깃발지뢰면
                data[rowIndex][cellIndex] = CODE.MINE //지뢰로
                target.className = '';
                // target.textContent = 'X';
            } else if (cellData === CODE.NORMAL) { //닫힌 칸이면
                data[rowIndex][cellIndex] = CODE.QUESTION; //물음표로
                target.className = 'question';
                target.textContent = '?';
            } else if (cellData === CODE.QUESTION) {//물음표면
                data[rowIndex][cellIndex] = CODE.FLAG; // 깃발로
                target.className = 'flag';
                target.textContent = '!';
            } else if (cellData === CODE.FLAG) { //깃발이면
                data[rowIndex][cellIndex] = CODE.NORMAL; //닫힌칸으로
                target.className = '';
                target.textContent = '';
            }
        }
        //1 2 3
        //4 5 6
        //7 8 9 
        // ?. = 옵셔널 체이닝 if와 같이 보호해주는 역할 
        // 앞에있는것이 참인 값이면 뒤코드를 실행하고, 거짓인 값이면 코드를 통째로 undefined를 만들어버림.
        function countMine(rowIndex, cellIndex) { //지뢰세기 
            const mines = [CODE.MINE, CODE.QUESTION_MINE, CODE.FLAG_MINE];
            let i = 0;
            mines.includes(data[rowIndex - 1]?.[cellIndex - 1]) && i++; //나 자신이 5번칸일때 1번칸 / 앞에것이 존재하면 i++ 실행
            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번칸
            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 open(rowIndex, cellIndex) {
            if (data[rowIndex]?.[cellIndex] >= CODE.OPENED) return;
            //code.opened 가 0 이니까, 0~8까지가 나오면 이미 연 칸. 이미 연칸은 다시 열지않게 하는 코드 
            //data[rowIndex]가 undefined일수도 있으니까 ?. 넣어주기 
            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;
            openCount++;
            console.log(openCount);
            if (openCount === row * cell - mine) {
                const time = (new Date() - startTime) / 1000;
                clearInterval(interval);
                $tbody.removeEventListener('contextmenu', onRightClick);
                $tbody.removeEventListener('click', onLeftClick);
                setTimeout(() => {
                    alert(`승리했습니다! ${time}초가 걸렸습니다.`);
                }, 500);
            }
            return count;
        }

        function openAround(rI, cI) { //재귀함수 
            // maximum call stack size exceeded 문제 
            // 문제 해결: 호출스택부분이 넘쳤으니 백그라운드, 태스크큐로 옮겨주기 (setTimeout 실행)
            // => 이렇게하면 한번 연 칸을 또 열어주게되서 무한루프에 갇혀 에러가난다. => 이미 연 칸은 무시 

            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)
        }
        let normalCellFound = false;
        let searched;
        let firstClick = true;
        function transferMine(rI, cI) {
            if (normalCellFound) return; //이미 빈칸을 찾았으면 종료
            if (rI < 0 || rI >= row || cI < 0 || cI >= cell) return; //실수로 -1되는거 막아줌 undefined안나오게 
            if (searched[rI][cI]) return; //이미 찾은 칸이면 종료
            if (data[rI][cI] === CODE.NORMAL) {// 빈칸인 경우
                normalCellFound = true;
                data[rI][cI] = CODE.MINE;
            } else { //지뢰칸인 경우 8방향 탐색 
                searched[rI][cI] = true;
                transferMine(rI - 1, cI - 1);
                transferMine(rI - 1, cI);
                transferMine(rI - 1, cI + 1);
                transferMine(rI, cI - 1);
                transferMine(rI, cI + 1);
                transferMine(rI + 1, cI - 1);
                transferMine(rI + 1, cI);
                transferMine(rI + 1, cI + 1);
            }
        }
        function showMines() {
            const mines = [CODE.MINE, CODE.QUESTION_MINE, CODE.FLAG_MINE];
            data.forEach((row, rowIndex) => {
                row.forEach((cell, cellIndex) => {
                    if (mines.includes(cell)) {
                        $tbody.children[rowIndex].children[cellIndex].textContent = 'X'
                    }
                });
            });
        }
        function onLeftClick(event) {
            const target = event.target; //td태그
            const rowIndex = target.parentNode.rowIndex;
            const cellIndex = target.cellIndex;
            let cellData = data[rowIndex][cellIndex];
            if (firstClick) {
                firstClick = false;
                searched = Array(row).fill().map(() => []);
                if (cellData === CODE.MINE) { //첫 클릭이 지뢰면
                    transferMine(rowIndex, cellIndex); // 지뢰옮기기
                    data[rowIndex][cellIndex] = CODE.NORMAL; //지금칸을 빈칸으로 
                    cellData = CODE.NORMAL;
                }
            }
            if (cellData === CODE.NORMAL) {//닫힌칸이면
                openAround(rowIndex, cellIndex); //내칸을 먼저 열고 주변칸이 비어있으면 같이 여는 함수 
                // const count = countMine(rowIndex, cellIndex);
                // target.textContent = count || ''; //count ?? '' = nullish coalescing
                // target.className = 'opened';
                // data[rowIndex][cellIndex] = count;
            } else if (cellData === CODE.MINE) { //지뢰칸이면
                showMines();
                target.textContent = '펑';
                target.className = 'opened';
                clearInterval(interval);
                $tbody.removeEventListener('contextmenu', onRightClick);
                $tbody.removeEventListener('click', onLeftClick);
            } //나머지는 무시
        }
        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);
                $tbody.addEventListener('contextmenu', onRightClick); //이벤트버블링 때문에 
                $tbody.addEventListener('click', onLeftClick);
            });
        }
    </script>
</body>

📌 코드 공부하기


📍 maximum call stack size exceeded


maximum call stack size exceeded
재귀함수가 반복될때 생기는 문제 호출 스택 부분에 재귀함수가 쌓여 터지게 된다.
문제해결 방법 : 호출 스택 부분이 넘쳤으니, 백그라운드, 태스크 큐로 옮겨서 부담을 덜어준다.
-> setTimeout실행해주기
=> 이렇게 하면 지뢰찾기에서 한번 연 칸을 또 열어주게되어 무한루프에 갇혀 에러가 발생한다.
=> 이미 열어준 칸은 무시하기


  1. 재귀함수 부분을 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)
        }
  1. 중복되지 않게 처리해준다.
if (data[rowIndex]?.[cellIndex] >= CODE.OPENED) return;

code.opened가 0 이니까, 0~8까지 나오면 이미 연칸이 된다.
이미 연칸은 다시 열지않게 하는 코드를 작성해준다.


📍 옵셔널 체이닝


옵셔널 체이닝(optional chaining) ?.을 사용하면 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있다.
if와 같이 에러가 나지 않게 보호해주는 역할을 한다.
앞에 있는 조건이 참인 값이면 뒤 코드를 실행하고, 거짓인 값이면 코드를 통째로 undefined를 만들어버린다.


 function countMine(rowIndex, cellIndex) { //지뢰세기 
            const mines = [CODE.MINE, CODE.QUESTION_MINE, CODE.FLAG_MINE];
            let i = 0;
            mines.includes(data[rowIndex - 1]?.[cellIndex - 1]) && i++; //나 자신이 5번칸일때 1번칸 / 앞에것이 존재하면 i++ 실행
            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번칸
            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;
        }

위 코드에서 옵셔널 체이닝을 활용하였는데, 지뢰찾기에서 자신을 기준으로 사방을 검사할때 앞,옆,위,아래가 없는 칸이 있으면 undefined처리가 되므로 안전하게 옵셔널 체이닝을 사용하였다.

profile
connecting the dots 💫

0개의 댓글