将工作功能 Javascript 井字游戏转换为 Class 以练习 OOP

Converting working functional Javascript Tic Tac Toe game to Class based to practice OOP

我正在为自己做一个练习,以便更好地理解 OOP 设计,方法是将一个可用的 Javascript 功能性 Tic Tac Toe 游戏与 AI 转换为一个基于 Class 的游戏。我陷入了一些常见问题,例如在 classes 中放置什么、单一事实来源、松散耦合等。不是在这里寻找完整的答案,而是寻找一些关于更好策略的提示?

这是原始的工作功能 TTT:

import "./styles.css";
// functional TIC TAC TOE
// Human is 'O'
// Player is 'X'

let ttt = {
  board: [], // array to hold the current game
  reset: function() {
    // reset board array and get HTML container
    ttt.board = [];
    const container = document.getElementById("ttt-game"); // the on div declared in HTML file
    container.innerHTML = "";

    // redraw swuares
    // create a for loop to build board
    for (let i = 0; i < 9; i++) {
      //  push board array with null
      ttt.board.push(null);
      // set square to create DOM element with 'div'
      let square = document.createElement("div");
      // insert "&nbsp;" non-breaking space to square
      square.innnerHTML = "&nbsp;";

      // set square.dataset.idx set to i of for loop
      square.dataset.idx = i;

      // build square id's with i from loop / 'ttt-' + i - concatnate iteration
      square.id = "ttt-" + i;
      // add click eventlistener to square to fire ttt.play();
      square.addEventListener("click", ttt.play);
      // appendChild with square (created element 'div') to container
      container.appendChild(square);
    }
  },

  play: function() {
    // ttt.play() : when the player selects a square
    // play is fired when player selects square
    // (A) Player's move - Mark with "O"
    // set move to this.dataset.idx

    let move = this.dataset.idx;

    // assign ttt.board array with move to 0
    ttt.board[move] = 0;
    // assign "O" to innerHTML for this
    this.innerHTML = "O";
    // add "Player" to a classList for this
    this.classList.add("Player");
    // remove the eventlistener 'click'  and fire ttt.play
    this.removeEventListener("click", ttt.play);

    // (B) No more moves available - draw
    // check to see if board is full
    if (ttt.board.indexOf(null) === -1) {
      // alert "No winner"
      alert("No Winner!");
      // ttt.reset();
      ttt.reset();
    } else {
      // (C) Computer's move - Mark with 'X'
      // capture move made with dumbAI or notBadAI
      move = ttt.dumbAI();
      // assign ttt.board array with move to 1
      ttt.board[move] = 1;
      // assign sqaure to AI move with id "ttt-" + move (concatenate)
      let square = document.getElementById("ttt-" + move);
      // assign "X" to innerHTML for this
      square.innerHTML = "X";

      // add "Computer" to a classList for this
      square.classList.add("Computer");
      // square removeEventListener click  and fire ttt.play
      square.removeEventListener("click", ttt.play);

      // (D) Who won?
      // assign win to null (null, "x", "O")
      let win = null;
      // Horizontal row checks
      for (let i = 0; i < 9; i += 3) {
        if (
          ttt.board[i] != null &&
          ttt.board[i + 1] != null &&
          ttt.board[i + 2] != null
        ) {
          if (
            ttt.board[i] == ttt.board[i + 1] &&
            ttt.board[i + 1] == ttt.board[i + 2]
          ) {
            win = ttt.board[i];
          }
        }
        if (win !== null) {
          break;
        }
      }
      // Vertical row checks
      if (win === null) {
        for (let i = 0; i < 3; i++) {
          if (
            ttt.board[i] !== null &&
            ttt.board[i + 3] !== null &&
            ttt.board[i + 6] !== null
          ) {
            if (
              ttt.board[i] === ttt.board[i + 3] &&
              ttt.board[i + 3] === ttt.board[i + 6]
            ) {
              win = ttt.board[i];
            }
            if (win !== null) {
              break;
            }
          }
        }
      }
      // Diaganal row checks
      if (win === null) {
        if (
          ttt.board[0] != null &&
          ttt.board[4] != null &&
          ttt.board[8] != null
        ) {
          if (ttt.board[0] == ttt.board[4] && ttt.board[4] == ttt.board[8]) {
            win = ttt.board[4];
          }
        }
      }
      if (win === null) {
        if (
          ttt.board[2] != null &&
          ttt.board[4] != null &&
          ttt.board[6] != null
        ) {
          if (ttt.board[2] == ttt.board[4] && ttt.board[4] == ttt.board[6]) {
            win = ttt.board[4];
          }
        }
      }
      // We have a winner
      if (win !== null) {
        alert("WINNER - " + (win === 0 ? "Player" : "Computer"));
        ttt.reset();
      }
    }
  },

  dumbAI: function() {
    // ttt.dumbAI() : dumb computer AI, randomly chooses an empty slot

    // Extract out all open slots
    let open = [];
    for (let i = 0; i < 9; i++) {
      if (ttt.board[i] === null) {
        open.push(i);
      }
    }

    // Randomly choose open slot
    const random = Math.floor(Math.random() * (open.length - 1));
    return open[random];
  },
  notBadAI: function() {
    // ttt.notBadAI() : AI with a little more intelligence

    // (A) Init
    var move = null;
    var check = function(first, direction, pc) {
      // checkH() : helper function, check possible winning row
      // PARAM square : first square number
      //       direction : "R"ow, "C"ol, "D"iagonal
      //       pc : 0 for player, 1 for computer

      var second = 0,
        third = 0;
      if (direction === "R") {
        second = first + 1;
        third = first + 2;
      } else if (direction === "C") {
        second = first + 3;
        third = first + 6;
      } else {
        second = 4;
        third = first === 0 ? 8 : 6;
      }

      if (
        ttt.board[first] === null &&
        ttt.board[second] === pc &&
        ttt.board[third] === pc
      ) {
        return first;
      } else if (
        ttt.board[first] === pc &&
        ttt.board[second] === null &&
        ttt.board[third] === pc
      ) {
        return second;
      } else if (
        ttt.board[first] === pc &&
        ttt.board[second] === pc &&
        ttt.board[third] === null
      ) {
        return third;
      }
      return null;
    };

    // (B) Priority #1 - Go for the win
    // (B1) Check horizontal rows
    for (let i = 0; i < 9; i += 3) {
      move = check(i, "R", 1);
      if (move !== null) {
        break;
      }
    }
    // (B2) Check vertical columns
    if (move === null) {
      for (let i = 0; i < 3; i++) {
        move = check(i, "C", 1);
        if (move !== null) {
          break;
        }
      }
    }
    // (B3) Check diagonal
    if (move === null) {
      move = check(0, "D", 1);
    }
    if (move === null) {
      move = check(2, "D", 1);
    }

    // (C) Priority #2 - Block player from winning
    // (C1) Check horizontal rows
    for (let i = 0; i < 9; i += 3) {
      move = check(i, "R", 0);
      if (move !== null) {
        break;
      }
    }
    // (C2) Check vertical columns
    if (move === null) {
      for (let i = 0; i < 3; i++) {
        move = check(i, "C", 0);
        if (move !== null) {
          break;
        }
      }
    }
    // (C3) Check diagonal
    if (move === null) {
      move = check(0, "D", 0);
    }
    if (move === null) {
      move = check(2, "D", 0);
    }
    // (D) Random move if nothing
    if (move === null) {
      move = ttt.dumbAI();
    }
    return move;
  }
};
document.addEventListener("DOMContentLoaded", ttt.reset());

这是我目前基于 class 的版本:

import "./styles.css";

class Gameboard {
  constructor() {
    this.board = [];
    this.container = document.getElementById("ttt-game");
    this.container.innerHTML = "";
  }

  reset() {
    this.board = [];
  }

  build() {
    for (let i = 0; i < 9; i++) {
      this.board.push(null);
      const square = document.createElement("div");
      square.innerHTML = "&nbsp;";
      square.dataset.idx = i;
      square.id = "ttt-" + i;
      square.addEventListener("click", () => {
        // What method do I envoke here? 
        console.log(square) 
      });
      this.container.appendChild(square);
    }
  }
};

class Game {
  constructor() {
    this.gameBoard = new Gameboard();
    this.player = new Player();
    this.computer = new Computer();
  }

  play() {
    this.gameBoard.build();
  }
};

class Player {

};

class Computer {

};

class DumbAI {

};

const game = new Game();

document.addEventListener("DOMContentLoaded", game.play());

我的 HTML 文件非常简单,只有一个 <div id="ttt-game"></div> 开始,CSS 文件是 grid

我遇到的最大问题是在 Game 中捕获 squares。我应该把 eventListeners 放在哪里? (我的下一个项目是做一个React版本)。

这是我的想法,良好的、可维护的和可测试的代码看起来像:一堆小的、独立的函数,每个函数都有尽可能少的副作用。而不是让状态散布在应用程序周围,状态应该存在于一个单一的中央位置。

所以,我所做的是将您的代码分解成小函数。我已将状态拉入一个强制不变性的单一存储中。没有奇怪的中转站——应用程序状态改变,或者没有改变。如果它发生变化,整个游戏将被重新渲染。与 UI 交互的责任存在于单个 render 函数中。

并且您在问题中询问了 classes。 createGame 变为:

class Game { 
  constructor() { ... }, 
  start() { ... }, 
  reset() { ... },
  play() { ... }
}

createStore 变为:

class Store { 
  constructor() { ... }
  getState() { ... }, 
  setState() { ... } 
}

playAIplayHuman 变为:

class AIPlayer {
  constructor(store) { ... }
  play() { ... }
}

class HumanPlayer {
  constructor(store) { ... }
  play() { ... }
}

checkForWinner 变为:

class WinChecker {
  check(board) { ... }
}

...等等。

但我问了一个反问:添加这些 class 是否会给代码添加任何内容?在我看来,class 面向对象存在三个基本和内在的问题:

  1. 它引导您走上混合应用程序状态和功能的道路,
  2. 类 就像滚雪球 - 它们会增加功能并迅速变得过大,并且
  3. 人们很难想出有意义的 class 本体

以上所有意味着 classes 总是导致严重无法维护的代码。

我认为没有 new 和没有 this.

的代码通常更简单,更易于维护

index.js

import { createGame } from "./create-game.js";

const game = createGame("#ttt-game");
game.start();

创建-game.js

import { initialState } from "./initial-state.js";
import { createStore } from "./create-store.js";
import { render } from "./render.js";

const $ = document.querySelector.bind(document);

function start({ store, render }) {
  createGameLoop({ store, render })();
}

function createGameLoop({ store, render }) {
  let previousState = null;
  return function loop() {
    const state = store.getState();
    if (state !== previousState) {
      render(store);
      previousState = store.getState();
    }
    requestAnimationFrame(loop);
  };
}

export function createGame(selector) {
  const store = createStore({ ...initialState, el: $(selector) });
  return {
    start: () => start({ store, render })
  };
}

初始-state.js

export const initialState = {
  el: null,
  board: Array(9).fill(null),
  winner: null
};

创建-store.js

export function createStore(initialState) {
  let state = Object.freeze(initialState);
  return {
    getState() {
      return state;
    },
    setState(v) {
      state = Object.freeze(v);
    }
  };
}

render.js

import { onSquareClick } from "./on-square-click.js";
import { winners } from "./winners.js";
import { resetGame } from "./reset-game.js";

export function render(store) {
  const { el, board, winner } = store.getState();
  el.innerHTML = "";
  for (let i = 0; i < board.length; i++) {
    let square = document.createElement("div");
    square.id = `ttt-${i}`;
    square.innerText = board[i];
    square.classList = "square";
    if (!board[i]) {
      square.addEventListener("click", onSquareClick.bind(null, store));
    }
    el.appendChild(square);
  }

  if (winner) {
    const message =
      winner === winners.STALEMATE ? `Stalemate!` : `${winner} wins!`;
    const msgEL = document.createElement("div");
    msgEL.classList = "message";
    msgEL.innerText = message;
    msgEL.addEventListener("click", () => resetGame(store));
    el.appendChild(msgEL);
  }
}

正方形-click.js

import { play } from "./play.js";

export function onSquareClick(store, { target }) {
  const {
    groups: { move }
  } = /^ttt-(?<move>.*)/gi.exec(target.id);
  play({ move, store });
}

winners.js

export const winners = {
  HUMAN: "Human",
  AI: "AI",
  STALEMATE: "Stalemate"
};

重置-game.js

import { initialState } from "./initial-state.js";

export function resetGame(store) {
  const { el } = store.getState();
  store.setState({ ...initialState, el });
}

play.js

import { randomMove } from "./random-move.js";
import { checkForWinner } from "./check-for-winner.js";
import { checkForStalemate } from "./check-for-stalemate.js";
import { winners } from "./winners.js";

function playHuman({ move, store }) {
  const state = store.getState();
  const updatedBoard = [...state.board];
  updatedBoard[move] = "O";
  store.setState({ ...state, board: updatedBoard });
}

function playAI(store) {
  const state = store.getState();
  const move = randomMove(state.board);
  const updatedBoard = [...state.board];
  updatedBoard[move] = "X";
  store.setState({ ...state, board: updatedBoard });
}

export function play({ move, store }) {
  playHuman({ move, store });

  if (checkForWinner(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.HUMAN });
    return;
  }

  if (checkForStalemate(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.STALEMATE });
    return;
  }

  playAI(store);

  if (checkForWinner(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.AI });
    return;
  }
}

运行版本:

const $ = document.querySelector.bind(document);

const winners = {
  HUMAN: "Human",
  AI: "AI",
  STALEMATE: "Stalemate"
};

function randomMove(board) {
  let open = [];
  for (let i = 0; i < board.length; i++) {
    if (board[i] === null) {
      open.push(i);
    }
  }
  const random = Math.floor(Math.random() * (open.length - 1));
  return open[random];
}

function onSquareClick(store, target) {
  const {
    groups: { move }
  } = /^ttt-(?<move>.*)/gi.exec(target.id);
  play({ move, store });
}

function render(store) {
  const { el, board, winner } = store.getState();
  el.innerHTML = "";
  for (let i = 0; i < board.length; i++) {
    let square = document.createElement("div");
    square.id = `ttt-${i}`;
    square.innerText = board[i];
    square.classList = "square";
    if (!board[i]) {
      square.addEventListener("click", ({ target }) =>
        onSquareClick(store, target)
      );
    }
    el.appendChild(square);
  }

  if (winner) {
    const message =
      winner === winners.STALEMATE ? `Stalemate!` : `${winner} wins!`;
    const msgEL = document.createElement("div");
    msgEL.classList = "message";
    msgEL.innerText = message;
    msgEL.addEventListener("click", () => resetGame(store));
    el.appendChild(msgEL);
  }
}

function resetGame(store) {
  const { el } = store.getState();
  store.setState({ ...initialState, el });
}

function playHuman({ move, store }) {
  const state = store.getState();
  const updatedBoard = [...state.board];
  updatedBoard[move] = "O";
  store.setState({ ...state, board: updatedBoard });
}

function playAI(store) {
  const state = store.getState();
  const move = randomMove(state.board);
  const updatedBoard = [...state.board];
  updatedBoard[move] = "X";
  store.setState({ ...state, board: updatedBoard });
}

const patterns = [
  [0,1,2], [3,4,5], [6,7,8],
  [0,4,8], [2,4,6],
  [0,3,6], [1,4,7], [2,5,8]
];

function checkForWinner(store) {
  const { board } = store.getState();
  return patterns.find(([a,b,c]) => 
    board[a] === board[b] && 
    board[a] === board[c] && 
    board[a]);
}

function checkForStalemate(store) {
  const { board } = store.getState();
  return board.indexOf(null) === -1;
}

function play({ move, store }) {
  playHuman({ move, store });

  if (checkForWinner(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.HUMAN });
    return;
  }

  if (checkForStalemate(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.STALEMATE });
    return;
  }

  playAI(store);

  if (checkForWinner(store)) {
    const state = store.getState();
    store.setState({ ...state, winner: winners.AI });
    return;
  }
}

function createStore(initialState) {
  let state = Object.freeze(initialState);
  return {
    getState() {
      return state;
    },
    setState(v) {
      state = Object.freeze(v);
    }
  };
}

function start({ store, render }) {
  createGameLoop({ store, render })();
}

function createGameLoop({ store, render }) {
  let previousState = null;
  return function loop() {
    const state = store.getState();
    if (state !== previousState) {
      render(store);
      previousState = store.getState();
    }
    requestAnimationFrame(loop);
  };
}

const initialState = {
  el: null,
  board: Array(9).fill(null),
  winner: null
};

function createGame(selector) {
  const store = createStore({ ...initialState, el: $(selector) });
  return {
    start: () => start({ store, render })
  };
}

const game = createGame("#ttt-game");
game.start();
* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
  font-size: 0;
}
div.container {
  width: 150px;
  height: 150px;
  box-shadow: 0 0 0 5px red inset;
}
div.square {
  font-family: sans-serif;
  font-size: 26px;
  color: gray;
  text-align: center;
  line-height: 50px;
  vertical-align: middle;
  cursor: grab;
  display: inline-block;
  width: 50px;
  height: 50px;
  box-shadow: 0 0 0 2px black inset;
}
div.message {
  font-family: sans-serif;
  font-size: 26px;
  color: white;
  text-align: center;
  line-height: 100px;
  vertical-align: middle;
  cursor: grab;
  position: fixed;
  top: calc(50% - 50px);
  left: 0;
  height: 100px;
  width: 100%;
  background-color: rgba(100, 100, 100, 0.7);
}
<div class="container" id="ttt-game"></div>