Implementação JavaScript Tic Tac Toe

Oct 01 2020

Tenho tentado entrar no desenvolvimento da web, então fiz uma rápida implementação do jogo da velha. Parece complicado para mim, mas não tenho certeza do que é realmente considerado uma boa prática e o que não é. Alguma prática ruim em meu código ou coisas que eu poderia melhorar?

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>

Respostas

1 Stuart Oct 26 2020 at 17:10

Aqui estão algumas sugestões e uma versão que tenta ilustrar a maioria desses pontos.

1. Separação de preocupações

Deve haver alguma separação entre o código que manipula (i) a exibição e (ii) a lógica do jogo subjacente e a representação do tabuleiro. As pessoas costumam usar padrões de projeto como model-view-controller .

Uma forma clássica de separá-los seria fazer uma classe, módulo ou objeto para a tela e outro para a lógica do jogo subjacente. Os dois objetos chamam os métodos um do outro apenas em casos limitados e bem definidos. Se você não quiser usar objetos, pode apenas usar nomes de função e comentários para ter uma demarcação mais clara entre a exibição / manipulação de eventos e a lógica do jogo.

Em vez disso, você está em vários pontos usando o html para representar os dados e também a exibição. Para programas simples, isso pode funcionar e até mesmo simplificar o código, mas não é ótimo para o seu programa porque você tem funções que deseja que sejam capazes de lidar com uma placa 'imaginária' e a placa real mostrada na tela. Para jogos mais complexos, será cada vez mais complicado trabalhar com a representação html do tabuleiro. Se você permitir que todas as funções alterem ou leiam o html diretamente, torna-se difícil rastrear de onde uma alteração específica está vindo. Também vincula seu programa muito estreitamente a uma representação particular. Por exemplo, imagine que você deseja usar gráficos canvasou em svgvez de apenas texto e css. Isso exigiria reescrever tudo com a configuração atual.

getWiné particularmente confuso, pois parece funcionar de forma completamente diferente dependendo se você passa o parâmetro da placa ou não. Você pode estar tentando otimizar verificando apenas as linhas que contêm x ou y específicos, mas é muito mais fácil escrever uma função genérica que verificará qualquer quadro. A função retorna uma sequência que é a sequência vencedora para uma vitória ou a grade inteira se for um empate.

2. Pequenas funções que fazem uma coisa

Suas funções parecem confusas, em parte porque estão usando uma mistura da representação na tela do quadro e uma representação de matriz separada para descobrir se um espaço está preenchido ou não, mas também porque estão tentando funcionar em muitos casos diferentes. por exemplo, getWin(como mencionado acima) e getSpaceValue, que aceita valores de xque são nulos, um elemento html ou um número e ytambém pode ser indefinido ou um número. A maioria das funções deve aceitar entradas de um único tipo (uma exceção às vezes é permitir que os argumentos sejam omitidos com os padrões) e retornar um valor de retorno previsível.

3. Use Arraymétodos modernos mais

filter, find, mapPodem todos ajudar a escrever mais curto e mais legível código (embora também pode se tornar ilegível se usado para embalar demasiado em um one-liner)

4. Use funções utilitárias

Você pode não querer usar uma biblioteca como o lodash, mas algumas funções de utilitário padrão tornariam seu código mais conciso e legível, e usar uma biblioteca popular significa que outros programadores serão capazes de ler seu código e entender rapidamente o que ele está fazendo. Por exemplo, maxBy de Lodash seria útil na escolha do movimento AI ideal. Você pode escrever seu próprio facilmente se não quiser usar uma biblioteca.

5. Código para determinar uma vitória

Seu código funciona e é inteligente, mas muito complicado! Às vezes você passa j, jpelas coordenadas do movimento atual, mas em um ponto você passa pelas coordenadas, o que misteriosamente ainda funciona. Uma maneira mais simples e transparente de verificar se há uma vitória é armazenar uma série de possíveis sequências vencedoras ( [[0, 1, 2], [3, 4, 5], ...]) e, em seguida, encontrar a primeira sequência para a qual todos os quadrados do tabuleiro estão definidos 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));
}

Se você puder fornecer as coordenadas do movimento atual, poderá escrever uma versão potencialmente mais eficiente disso, que verifica apenas as (máx.) 4 linhas que passam por essas coordenadas:

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));
}

Na prática, para uma placa 3x3, é improvável que essa otimização melhore a velocidade (e pode piorá-la, pois o linesarray precisa ser reconstruído a cada vez).

6. manipuladores de eventos html

Não coloque manipuladores de eventos no html quando a maior parte de sua lógica está em javascript. É muito bagunçado e confuso. Coloque todo o tratamento de eventos em javascript.

7. Comentários / documentação

Como você está compartilhando seu código e pedindo feedback, você deve documentar seu código. Na forma mais simples, isso poderia ser uma linha ou duas de comentários explicando o que cada função faz e a estrutura geral. Mas existe uma forma padrão de documentar chamada jsdoc ( veja também esta introdução ). Isso é especialmente importante se você, por algum motivo, ainda precisar de funções que funcionem de maneiras surpreendentes.

Reiki Oct 16 2020 at 07:51

Entrar nas melhores práticas exigiria volumes e obteria muitas opiniões. De qualquer forma, tentar programar muito além de sua compreensão não é bom, então não entrarei em detalhes. Em geral, a primeira coisa que me ocorre é que, se você realmente quer programar, seja bom para o seu futuro eu. A programação começa como um processo de pensamento e seus pensamentos mudarão à medida que suas habilidades crescerem. Deixe notas liberais (comentários de código) e daqui a seis meses ou seis anos, quando você (ou talvez eu!) Revisitar seu código, você pode reconstruir o processo que fez o código.

Parabéns por nomear suas funções e variáveis ​​para que sua intenção seja clara. Seu estilo de recuo e codificação é consistente e isso é importante. Agora, adquira o hábito de deixar uma ou duas linhas para cada bloco de código.

Eu tenho programado para a web por algumas décadas e você não vai acreditar no estresse de tentar fazer uma pequena mudança em 600 linhas de lógica que você não entende quando a empresa para a qual você trabalha fatura um milhão por ano e emprega dezenas de pessoas. Ou o choque ao perceber que é sua própria programação!