Implementacja JavaScript Tic Tac Toe

Oct 01 2020

Próbowałem zająć się tworzeniem stron internetowych, więc szybko wdrożyłem grę Tic Tac Toe. Wydaje mi się to kłopotliwe, ale nie jestem pewien, co jest uważane za dobrą praktykę, a co nie. Jakieś złe praktyki w moim kodzie lub rzeczy, które mógłbym poprawić?

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>

Odpowiedzi

1 Stuart Oct 26 2020 at 17:10

Oto kilka sugestii i wersja próbująca zilustrować większość z tych punktów.

1. Rozdzielenie obaw

Powinien istnieć pewien rozdział między kodem, który obsługuje (i) wyświetlanie i (ii) podstawową logikę gry i reprezentację planszy. Ludzie często używają wzorców projektowych, takich jak kontroler widoku modelu .

Klasycznym sposobem ich rozdzielenia byłoby utworzenie klasy, modułu lub obiektu do wyświetlania, a innego do podstawowej logiki gry. Te dwa obiekty wywołują nawzajem swoje metody tylko w ograniczonych, dobrze zdefiniowanych przypadkach. Jeśli nie chcesz używać obiektów, możesz po prostu użyć nazw funkcji i komentarzy, aby uzyskać wyraźniejsze rozgraniczenie między obsługą wyświetlania / zdarzeń a logiką gry.

Zamiast tego jesteś w różnych miejscach, używając html zarówno do reprezentowania danych, jak i do wyświetlania. W przypadku prostych programów może to zadziałać, a nawet uprościć kod, ale nie jest dobre dla twojego programu, ponieważ masz funkcje, które chcesz obsługiwać zarówno „wyimaginowaną” tablicę, jak i rzeczywistą tablicę wyświetlaną na ekranie. W przypadku bardziej złożonych gier praca z reprezentacją tablicy w formacie HTML będzie coraz bardziej skomplikowana. Jeśli pozwolisz każdej funkcji bezpośrednio zmieniać lub odczytywać kod HTML, trudno będzie śledzić, skąd pochodzi dana zmiana. Również wiąże twój program zbyt ściśle z określoną reprezentacją. Na przykład wyobraź sobie, że chcesz użyć canvaslub svggrafiki zamiast tylko tekstu i CSS. Wymagałoby to przepisania wszystkiego przy obecnej konfiguracji.

getWinjest szczególnie mylący, ponieważ wydaje się działać zupełnie inaczej w zależności od tego, czy podasz mu parametr tablicy, czy nie. Być może próbujesz zoptymalizować, sprawdzając tylko wiersze zawierające określone x lub y, ale znacznie łatwiej jest napisać ogólną funkcję, która sprawdzi dowolną tablicę. Funkcja zwraca sekwencję, która jest zwycięską sekwencją dla wygranej lub całą siatką, jeśli jest to remis.

2. Małe funkcje, które robią jedną rzecz

Twoje funkcje wydają się nieczytelne, częściowo dlatego, że używają mieszanki reprezentacji tablicy na ekranie i oddzielnej reprezentacji tablicowej, aby sprawdzić, czy przestrzeń jest wypełniona, czy nie, ale także dlatego, że próbują działać w zbyt wielu różnych przypadkach. np. getWin(jak wspomniano powyżej) i getSpaceValue, który przyjmuje wartości xtakie jak null, element html lub liczba, a ytakże może być niezdefiniowany lub liczbą. Większość funkcji powinna akceptować dane wejściowe jednego typu (wyjątkiem jest czasami możliwość pominięcia argumentów w przypadku wartości domyślnych) i zwracać przewidywalną wartość zwracaną.

3. Używaj Arraywięcej nowoczesnych metod

filter, find, mapWszystko może pomóc pisać krótsze i bardziej czytelny kod (chociaż może również ulec uszkodzeniu, jeżeli są stosowane do pakowania zbyt wiele do jednego-liner)

4. Użyj funkcji narzędziowych

Możesz nie chcieć używać biblioteki takiej jak lodash, ale kilka standardowych funkcji narzędziowych sprawiłoby, że twój kod byłby bardziej zwięzły i czytelny, a korzystanie z popularnej biblioteki oznacza, że ​​inni programiści będą w stanie odczytać twój kod i szybko zrozumieć, co robi. Na przykład maxBy lodasha przydałoby się w wyborze optymalnego ruchu AI. Możesz łatwo napisać własne, jeśli nie chcesz korzystać z biblioteki.

5. Kod określający wygraną

Twój kod działa i jest w pewnym sensie sprytny, ale bardzo zawiły! Czasami mijasz współrzędne aktualnego ruchu, ale w pewnym momencie mijasz j, jwspółrzędne, które w tajemniczy sposób nadal działają. Prostszym i bardziej przejrzystym sposobem sprawdzenia wygranej jest zapisanie tablicy możliwych zwycięskich sekwencji ( [[0, 1, 2], [3, 4, 5], ...]), a następnie znalezienie pierwszej sekwencji, dla której ustawione są wszystkie kwadraty planszy 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));
}

Jeśli możesz podać współrzędne bieżącego ruchu, możesz napisać potencjalnie bardziej wydajną wersję tego ruchu, która sprawdza tylko (maks.) 4 linie przechodzące przez te współrzędne:

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

W praktyce dla tablicy 3x3 ta optymalizacja raczej nie poprawi szybkości (i może ją pogorszyć, ponieważ linestablica musi być za każdym razem odbudowywana).

6. Programy obsługi zdarzeń html

Nie umieszczaj programów obsługi zdarzeń w kodzie HTML, gdy większość logiki jest w JavaScript. Jest zbyt brudny i zagmatwany. Umieść całą obsługę zdarzeń w javascript.

7. Uwagi / dokumentacja

Udostępniając swój kod i prosząc o opinię, należy go udokumentować. W najprostszym przypadku może to być wiersz lub dwa komentarze wyjaśniające, co robi każda funkcja i ogólną strukturę. Ale istnieje standardowy sposób dokumentowania zwany jsdoc ( zobacz także to wprowadzenie ). Jest to szczególnie ważne, jeśli z jakiegoś powodu nadal potrzebujesz funkcji, które działają w zaskakujący sposób.

Reiki Oct 16 2020 at 07:51

Aby zapoznać się z najlepszymi praktykami, trzeba było dużo i zdobyć mnóstwo opinii. W każdym razie próba programowania zbyt daleko wykraczająca poza twoje zrozumienie nie jest dobra, więc nie będę wchodził w szczegóły. Ogólnie rzecz biorąc, pierwszą rzeczą, która przychodzi mi do głowy, jest to, że jeśli naprawdę poważnie podchodzisz do programowania, bądź dobry dla siebie w przyszłości. Programowanie zaczyna się jako proces myślowy, a Twoje myśli będą się zmieniać wraz ze wzrostem umiejętności. Zostaw liberalne notatki (komentarze do kodu) i sześć miesięcy lub sześć lat od teraz, kiedy (a może ja!) Ponownie odwiedzisz kod, możesz zrekonstruować proces, który stworzył kod.

Uznanie za nazywanie funkcji i zmiennych, aby ich cel był jasny. Twój styl wcięcia i kodowania jest spójny i to jest ważne. Teraz wyrób sobie nawyk zostawiania linii lub dwóch dla każdego bloku kodu.

Programuję dla sieci od kilku dekad i nie uwierzysz w stres związany z próbą wprowadzenia drobnych zmian w 600 liniach logiki, których nie rozumiesz, kiedy firma, w której pracujesz, zarabia milion dolarów rok i zatrudnia kilkadziesiąt osób. Albo szok, gdy zdasz sobie sprawę, że to twoje własne programowanie!