JavaScript Tic Tac Toe 구현

Oct 01 2020

저는 웹 개발에 뛰어 들려고 노력해 왔기 때문에 빠른 Tic Tac Toe 구현을 만들었습니다. 그것은 나에게 지저분하게 느껴지지만 실제로 좋은 관행으로 간주되는 것과 그렇지 않은 것이 무엇인지 잘 모르겠습니다. 내 코드에서 잘못된 연습이나 개선 할 수있는 사항이 있습니까?

https://github.com/jason-shepherd/tictactoe

//Get the board, record, and select html elements
const gridSpaces = document.querySelectorAll('[data-spaces]');
const recordText = document.querySelector('[data-record]');
const difficultySelect = document.querySelector('[data-select]')

const gridWidth = Math.sqrt(gridSpaces.length);

let opponent = "O"
let player = "X"

let difficulty;
let record = {
    X: 0,
    O: 0,
    ties: 0
}
let moveCount = 0;
let inPlay = true;

function init() {
    updateDifficulty();
    //Init the board spaces with an event listener
    for(let i = 0; i < gridSpaces.length; i++) {
        gridSpaces[i].addEventListener('click', () => {
            if(!inPlay) {
                reset();
                return;
            }
            if(getSpaceValue(i) != '') return;

            //Player's move
            setSpaceValue(i, player);
            gridSpaces[i].style.cursor = "default";
            win = getWin(Math.floor(i % gridWidth), Math.floor(i / gridWidth), player);
            displayWin(win, player);
            moveCount++;
            
            //AI move
            if(inPlay) {
                if(difficulty != 0)
                    makeAiMove();
                else 
                    player = player == "O" ? "X" : "O";
            }
        });
    }
}

function checkRowSpace(index, x, y, board) {
    return getGridSpace(index, y, board);
}

function checkColSpace(index, x, y, board) {
    return getGridSpace(x, index, board);
}

function checkDiagonal(index, x, y, board) {
    if(x == y)
        return getGridSpace(index, index, board);
    else
        return null;
}

function checkAntiDiagonal(index, x, y, board) {
    if(x + y == gridWidth - 1)
        return getGridSpace(index, gridWidth - 1 - index, board);
    else
        return null
}

const checkFunctions = [checkRowSpace, checkColSpace, checkDiagonal, checkAntiDiagonal];
function getWin(x, y, currentPlayer, board) {
    let winSequence = [];

    for(let i = 0; i < 4; i++) {
        for(let j = 0; j < gridWidth; j++) {
            let currentSpace = checkFunctions[i](j, x, y, board);
            
            if(board == undefined) {
                if(getSpaceValue(currentSpace) != currentPlayer) {
                    winSequence = [];
                    break;
                }
            } else if(currentSpace != currentPlayer) {
                winSequence = [];
                break;
            }

            winSequence.push(currentSpace);
            if(j == gridWidth - 1) {
                return winSequence;
            }
        }
    }

    if(moveCount == Math.pow(gridWidth, 2) - 1) {
        return gridSpaces; 
    }

    return winSequence;

}

function displayWin(win, currentPlayer) {
    if(win.length !== 0) {
        let condition = "win";

        if(win.length === gridSpaces.length) {
            record.ties++;
            condition = "draw";
        } else {
            record[currentPlayer]++;
        }
        recordText.textContent = `X ${record.X}-${record.ties}-${record.O} O`;

        win.forEach(space => {
            space.firstChild.classList.add(condition);
        });

        gridSpaces.forEach(space => {
            space.style.cursor = "pointer";
        });
        inPlay = false;
        return;
    }
}

function makeAiMove() {
    let bestVal = -11;
    let bestMove;
    let newBoard = [];

    gridSpaces.forEach(space => {
        newBoard.push(getSpaceValue(space));
    });
    
    let possibleMoves = getBoardChildren(newBoard, "O");
    if(difficulty != 9)
        possibleMoves.sort((a, b) => {return 0.5 - Math.random()})
    possibleMoves.forEach(child => {
        let value = minimax(child, difficulty, false);
        if(value > bestVal) {
            bestVal = value;
            bestMove = child;
        }
    });

    for(let i = 0; i < bestMove.length; i++) {
        if(getSpaceValue(i) != bestMove[i]) {
            setSpaceValue(i, 'O');
            let win = getWin(Math.floor(i % gridWidth), Math.floor(i / gridWidth), opponent);
            displayWin(win, opponent);
        }
    }
    moveCount++;
}

function minimax(board, depth, maximizingPlayer) {
    let score = scoreBoard(board, depth);
    if(depth == 0 || isTerminating(board) || score != 0)
        return score;
    if(maximizingPlayer) {
        let value = -10;
        getBoardChildren(board, opponent).forEach(child => {
            value = Math.max(value, minimax(child, depth - 1, false));
        });
        return value;
    } else {
        let value = 10;
        getBoardChildren(board, player).forEach(child => {
            value = Math.min(value, minimax(child, depth - 1, true));
        });
        return value;
    }
}

function getBoardChildren(board, currentPlayer) {
    let children = [];
    for(let i = 0; i < board.length; i++) {
        if(board[i] == '') {
            board[i] = currentPlayer;
            children.push([...board]);
            board[i] = '';
        }
    }
    return children;
}

function isTerminating(board) {
    for(let i = 0; i < board.length; i++) {
        if(board[i] == '')
            return false;
    }
    return true;
}

function scoreBoard(board, depth) {
    let currentPlayer = "O";
    for(let i = 0; i < 2; i++) {
        for(let j = 0; j < 3; j++) {
            if(getWin(j, j, currentPlayer, board).length == 3) {
                if(currentPlayer == "O")
                    return 10 - (difficulty - depth);
                else
                    return -10 + (difficulty - depth);
            }
        }
        currentPlayer = "X";
    }
    return 0;
}

function updateDifficulty() {
    if(difficultySelect.value != "friend") {
        switch(difficultySelect.value) {
            case "easy":
                difficulty = 1;
                break;
            case "medium":
                difficulty = 4;
                break;
            case "unbeatable":
                difficulty = 9;
                break;
        }
        if(player == "O") {
            player = "X";
            makeAiMove();
        }
    } else {
        difficulty = 0;
    }
}

function reset() {
    player = "X";
    moveCount = 0;
    inPlay = true;

    for(let i = 0; i < gridSpaces.length; i++) {
        gridSpaces[i].firstChild.classList.remove("win");
        gridSpaces[i].firstChild.classList.remove("draw");
        setSpaceValue(i, "");
    }
}

function getSpaceValue(x, y, board) {
    if(x == null)
        return;
    else if(typeof x === 'object')
        return x.firstChild.textContent;
    else if(y == undefined)
        return gridSpaces[x].firstChild.textContent;
    else
        return gridSpaces[y * gridWidth + x].firstChild.textContent;
}

function setSpaceValue(index, value) {
    gridSpaces[index].firstChild.textContent = value;
}

function getGridSpace(x, y, board) {
    if(board != undefined)
        return board[y * gridWidth + x];
    else
        return gridSpaces[y * gridWidth + x];
}

init();
body {
   background-color: #353A47;
}

.tictactoe-container {
    background-color: #353A47;
    width: 77vh;
    height: auto;
    position: absolute;
    left: 50%;
    top: 20%;
    transform: translate(-50%, -20%);
}

.grid-container {
    background-color: #2B303B;
    display: inline-grid;
    width: auto;
    height: auto;
    grid-gap: 1vh;
    grid-template-columns: repeat(3, 25vh);
    grid-template-rows: repeat(3, 25vh);
}

.grid-item {
    background-color: #353A47;
    color: #F0F7EE;
    display: flex;
    cursor: pointer;
    justify-content: center;
    align-items: center;
    font-family: 'Varela Round', sans-serif;
    font-weight: bold;
    font-size: 25vh;
}

.record {
    color: #F0F7EE;
    font-weight: bold;
    text-align: center;
    font-family: 'Varela Round', sans-serif;
    font-size: 10vh;
    white-space: nowrap;
    margin: 2vh auto;
}

.ai-select {
    color: #F0F7EE;
    background-color: #353A47;
    font-size: 3vh;
    width: 40%;
    height: 15%;
    margin: 1vh 30%;
}

.win {
    color: #4BB3FD;
    animation: shake 0.5s;
    animation-iteration-count: 3;
}

.draw {
    color: #FF312E;
    animation: shake 0.5s;
    animation-iteration-count: 6;
}

@keyframes shake {
    0% { transform: translate(1px, 1px) rotate(0deg); }
    10% { transform: translate(-1px, -2px) rotate(-1deg); }
    20% { transform: translate(-3px, 0px) rotate(1deg); }
    30% { transform: translate(3px, 2px) rotate(0deg); }
    40% { transform: translate(1px, -1px) rotate(1deg); }
    50% { transform: translate(-1px, 2px) rotate(-1deg); }
    60% { transform: translate(-3px, 1px) rotate(0deg); }
    70% { transform: translate(3px, 1px) rotate(-1deg); }
    80% { transform: translate(-1px, -1px) rotate(1deg); }
    90% { transform: translate(1px, 2px) rotate(0deg); }
    100% { transform: translate(1px, -2px) rotate(-1deg); }
}

.noselect {
    -webkit-touch-callout: none; /* iOS Safari */
    -webkit-user-select: none; /* Safari */
    -khtml-user-select: none; /* Konqueror HTML */
    -moz-user-select: none; /* Old versions of Firefox */
    -ms-user-select: none; /* Internet Explorer/Edge */
    user-select: none; /* Non-prefixed version, currently
       supported by Chrome, Edge, Opera and Firefox */
}

@media screen and (orientation:portrait) {
    .tictactoe-container {
        width: 77vw;
    }

    .grid-container {
        grid-gap: 1vw;
        grid-template-columns: repeat(3, 25vw);
        grid-template-rows: repeat(3, 25vw);
    }

    .grid-item {
        font-size: 25vw;
    }
    
    .record {
        font-size: 10vw;
    }

    .ai-select {
        font-size: 3vw;
    }
}
<!DOCTYPE html>
<html>
    <meta charset="UTF-8" name="viewport" content="width=device-width, initial-scale=1">
    <head>
        <title>Tic Tac Toe</title>
        <link rel="stylesheet" type = "text/css" href = "style.css">
        <link href="https://fonts.googleapis.com/css2?family=Varela+Round&display=swap" rel="stylesheet"> 
    </head>
    <body>
        <div class="tictactoe-container">
            <p data-record class="record noselect">X 0-0-0 O</p>
            <div class="grid-container noselect">
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
                <div data-spaces class="grid-item"><p></p></div>
            </div>
            <select data-select class="ai-select" onchange="updateDifficulty()">
                <option value="easy">Easy</option>
                <option value="medium" selected="selected">Medium</option>
                <option value="unbeatable">Unbeatable</option>
                <option value="friend">Play with a friend</option>
            </select>
        </div>
        <script src="tictactoe.js" defer></script>
    </body>
</html>

답변

1 Stuart Oct 26 2020 at 17:10

다음은 이러한 요점의 대부분을 설명 하는 몇 가지 제안과 버전 입니다.

1. 관심사 분리

(i) 디스플레이와 (ii) 기본 게임 로직 및 보드 표현을 처리하는 코드 사이에는 약간의 분리가 있어야합니다. 사람들은 종종 model-view-controller 와 같은 디자인 패턴을 사용 합니다.

그것들을 분리하는 고전적인 방법은 디스플레이를위한 클래스, 모듈 또는 객체를 만들고 기본 게임 로직을위한 다른 하나를 만드는 것입니다. 두 개체는 제한적이고 잘 정의 된 경우에만 서로의 메서드를 호출합니다. 객체를 사용하지 않으려면 함수 이름과 주석을 사용하여 디스플레이 / 이벤트 처리와 게임 로직 사이에 명확한 구분을 할 수 있습니다.

대신 데이터와 디스플레이를 모두 표현하기 위해 html을 사용하는 다양한 지점에 있습니다. 간단한 프로그램의 경우 작동하고 코드를 단순화 할 수도 있지만 '가상'보드와 화면에 표시된 실제 보드를 모두 처리 할 수있는 기능이 있기 때문에 프로그램에 적합하지 않습니다. 더 복잡한 게임의 경우 보드의 HTML 표현으로 작업하는 것이 점점 더 복잡해질 것입니다. 모든 함수가 html을 직접 변경하거나 읽을 수 있도록 허용하면 특정 변경의 출처를 추적하기가 어려워집니다. 또한 프로그램을 특정 표현에 너무 가깝게 바인딩합니다. 예를 들어, 텍스트와 CSS 대신 canvas또는 svg그래픽 을 사용하고 싶다고 가정 해보십시오. 이를 위해서는 현재 설정으로 모든 것을 다시 작성해야합니다.

getWin보드 매개 변수를 전달했는지 여부에 따라 완전히 다르게 작동하는 것처럼 보이기 때문에 특히 혼란 스럽습니다. 특정 x 또는 y를 포함하는 행만 확인하여 최적화하려고 할 수 있지만 모든 보드를 확인하는 일반 함수를 작성하는 것이 훨씬 쉽습니다. 이 함수는 승리에 대한 승리 시퀀스 또는 무승부 인 경우 전체 그리드 인 시퀀스를 반환합니다.

2. 한 가지를 수행하는 작은 기능

부분적으로는 보드의 화면 표현과 별도의 배열 표현을 혼합하여 공간이 채워 졌는지 여부를 확인하지만 너무 많은 다른 경우에 작업을 시도하기 때문에 기능이 복잡해 보입니다. 예 getWin(위에서 언급했듯이) 및는 null, html 요소 또는 숫자 getSpaceValue값을 허용 하며 정의되지 않거나 x숫자 일 y수도 있습니다. 대부분의 함수는 단일 유형의 입력을 허용하고 (경우에 따라 기본값으로 인수를 생략 할 수있는 예외가 있음) 예측 가능한 반환 값을 반환해야합니다.

3. 현대적인 Array방법 사용 more

filter, find, map할 수있는 모든 도움을 쓰기 더 짧고 읽을 수있는 코드 (한 라이너에 너무 많은 팩을 사용하는 경우도 읽을 수 없게 될 수 있지만)

4. 유틸리티 기능 사용

lodash와 같은 라이브러리를 사용하고 싶지 않을 수도 있지만 몇 가지 표준 유틸리티 함수를 사용하면 코드를 더 간결하고 읽기 쉽게 만들 수 있으며 인기있는 라이브러리를 사용하면 다른 프로그래머가 코드를 읽고 수행중인 작업을 빠르게 이해할 수 있습니다. 예를 들어 lodash의 maxBy 는 최적의 AI 이동을 선택하는 데 유용합니다. 라이브러리를 사용하고 싶지 않다면 쉽게 직접 작성할 수 있습니다.

5. 승리를 결정하는 코드

귀하의 코드는 작동하고 영리하지만 매우 복잡합니다! 때때로 당신은 현재 움직임 j, j의 좌표 를 전달하지만, 한 지점 에서는 신비하게도 여전히 작동하는 좌표로 전달 합니다. 승리를 확인하는 더 간단하고 투명한 방법은 가능한 승리 시퀀스 ( [[0, 1, 2], [3, 4, 5], ...]) 의 배열을 저장 한 다음 모든 보드 사각형이로 설정된 첫 번째 시퀀스를 찾는 것 player입니다.

const range = [...Array(gridWidth).keys()];
const lines = [
    ...range.map(i => range.map(j => i * gridWidth + j)),   // rows
    ...range.map(i => range.map(j => i + j * gridWidth)),   // columns
    range.map(j => j * (gridWidth + 1)),                // diagonal
    range.map(j => (gridWidth - 1) * (j + 1))           // antidiagonal
    ];
function win(player, board) {
    return lines.find(line => line.every(i => board[i] === player));
}

현재 이동의 좌표를 제공 할 수 있다면 해당 좌표를 통과하는 (최대) 4 개 라인 만 확인하는 더 효율적인 버전을 작성할 수 있습니다.

const range = [...Array(gridWidth).keys()];
function win(player, x, y, board) {
    let lines = [
        range.map(i => y * gridWidth + i),      // current row
        range.map(i => i * gridWidth + x)       // current column
        ];
    if (x === y) {              // diagonal
        lines.push(range.map(i => i * gridWidth + i));
    }
    if (gridWidth - x === y) {  // antidiagonal
        lines.push(range.map(i => (gridWidth - 1) * (i + 1)));
    }
    return lines.find(line => line.every(i => board[i] === player));
}

실제로 3x3 보드의 경우이 최적화는 속도를 향상시키지 않을 것입니다 ( lines매번 어레이를 재 구축 해야하므로 속도를 악화시킬 수 있음).

6. html 이벤트 핸들러

대부분의 로직이 자바 스크립트에있을 때 html에 이벤트 핸들러를 넣지 마세요. 너무 지저분하고 혼란 스럽습니다. 모든 이벤트 처리를 자바 스크립트에 넣으십시오.

7. 의견 / 문서

코드를 공유하고 피드백을 요청할 때 코드를 문서화해야합니다. 간단히 말해서 이것은 각 기능의 기능과 전체 구조를 설명하는 한두 줄의 주석 일 수 있습니다. 그러나 jsdoc 이라는 표준 문서화 방법이 있습니다 ( 이 소개 참조 ). 어떤 이유로 든 놀라운 방식으로 작동하는 기능이 여전히 필요한 경우 특히 중요합니다.

Reiki Oct 16 2020 at 07:51

모범 사례에 들어가려면 많은 양과 많은 의견이 필요합니다. 어쨌든, 당신의 이해를 넘어서는 프로그램을 너무 많이 시도하는 것은 좋지 않기 때문에 구체적으로 다루지 않겠습니다. 일반적으로 저에게 가장 먼저 일어나는 일은 프로그래밍에 대해 진지하게 생각한다면 미래의 자신에게 잘 대처한다는 것입니다. 프로그래밍은 사고 과정으로 시작되며 기술이 성장함에 따라 사고가 바뀝니다. 자유주의 사항 (코드 주석)을 남기고 6 개월 또는 6 년 후에 코드를 다시 방문하면 코드를 만든 프로세스를 재구성 할 수 있습니다.

함수와 변수의 이름을 지정하면 의도가 명확 해집니다. 들여 쓰기 및 코딩 스타일은 일관성이 있으며 중요합니다. 이제 각 코드 블록에 대해 한두 줄을 남기는 습관을들이십시오.

저는 수십 년 동안 웹 프로그래밍을 해왔고 여러분은 여러분이 이해하지 못하는 600 줄의 논리를 약간 변경하려는 스트레스를 믿을 수 없을 것입니다. 수십 명의 직원을 고용하고 있습니다. 아니면 자신의 프로그래밍이라는 것을 깨달을 때의 충격!