• בלוג
  • בואו נכתוב משחק איקס עיגול ב React עם Hooks

בואו נכתוב משחק איקס עיגול ב React עם Hooks

15/05/2021

בעקבות שאלה של תלמיד שהגיעה אליי למייל נזכרתי שמזמן לא כתבתי מימוש למשחק איקס עיגול בריאקט ובעצם המימוש האחרון שאפשר למצוא אם מחפשים אחורה פה בבלוג משתמש ב Redux. אז נכון פיתוח ב Redux זה תמיד כיף אבל כשמדובר במשחק פשוט כמו איקס עיגול אני חושב שיהיה מעניין לראות גם מימוש ריאקטי שלו רק באמצעות קומפוננטות.

1. מילה על מידע גלובאלי

בכל פיתוח יישום בריאקט ובמיוחד יישום שמחולק לקומפוננטות אנחנו מחלקים את המידע שהיישום מנהל למידע גלובאלי ומידע פנימי לקומפוננטות. החלוקה היא שרירותית במובן זה שאפשר לכתוב את אותו יישום בחלוקות שונות ומבחינת המשתמש לא יהיה הבדל בין הגירסאות. אבל, החלוקה כן משפיעה על מבנה הקוד ועל כמה קל יהיה לנו לממש פיצ'רים מסוימים בו.

צריך להגיד - אין גישה אחת נכונה וחשוב לקבוע איזה מידע יישמר גלובאלית ואיזה מידע מקומית בהתאמה ליישום ולשינויים הצפויים בו. כשהמידע נשמר בתור מידע גלובאלי קל לנו יותר לנהל אותו מכמה קומפוננטות במקביל. כשהמידע נשמר בתור מידע מקומי קל לנו לשכפל את הקומפוננטה ולנהל כמה גירסאות של אותו מידע במקביל.

בדוגמה של משחק איקס עיגול "המידע" של המשחק הוא מצב הלוח ומי השחקן הנוכחי. אם המידע הזה יישמר בצורה גלובאלית נוכל יחסית בקלות להוסיף עוד קומפוננטות ככל שנתקדם בפיתוח, למשל כפתור Undo, כפתור שמירת מצב המשחק ל Local Storage, רכיב ששומר נקודות ואלמנטים גלובאליים נוספים.

אם המידע נשמר מקומית בקומפוננטה קל לנו לשכפל את הקומפוננטה הזו ולמשל להציע מספר לוחות משחק שפועלים במקביל, וקל להוסיף לקומפוננטה התנהגות באמצעות Custom Hooks למשל כדי לשתף את לוח המשחק עם שרת מרוחק ולאפשר משחק ברשת.

אני אוהב להתחיל את הפיתוח בשמירת כל המידע בצורה מקומית בכל קומפוננטה ולאט לאט אם יש צורך מבחינת פיצ'רים להעביר מידע החוצה ולשמור אותו בתור מידע גלובאלי באמצעות Redux, MobX או Context. במימוש בפוסט זה העדפתי לשמור את כל המידע בתור מידע מקומי בתוך הקומפוננטות כי לא היה אף פיצ'ר שהצדיק שמירת מידע גלובאלי.

2. חלק 1: מימוש חלקי בתוך קומפוננטה אחת

הבסיס של משחק איקס עיגול הוא בסך הכל לוח בגודל 3 על 3 ושחקן נוכחי מתחלף בין X ל O. לחיצה על משבצת מציירת בה את הדמות של השחקן הנוכחי (אלא אם כן המשבצת כבר תפוסה) ומחליפה שחקן. מנצח השחקן הראשון שסיים שורה, עמודה או אלכסון.

בשביל להתחיל לכתוב את הקוד מספיק לנו לצייר לוח של 3 על 3, לזכור איזה שחקן תפס איזו משבצת ולאפשר לשחקנים לתפוס משבצות באמצעות לחיצה עליהן. גירסה זו של המשחק לא תזהה עדיין ניצחון אבל כן תתן לנו את הבסיס לפיתוח ותעזור לנו להבין על מה אנחנו מדברים.

בעבודה עם ריאקט אני מעדיף להשאיר את קוד הקומפוננטות כמה שיותר נקי ולכן אני מחלק את הקוד ל-2 קבצים: בקובץ game.js אני כותב את הפונקציונאליות עצמה של המשחק, ובקובץ TicTacToe.js אכתוב את הקומפוננטה. מתכנתים רבים רגילים שכשצריך לכתוב לוגיקה של משהו הם יתחילו מכתיבת קוד מונחה עצמים, אבל בריאקט הסיפור קצת יותר מסובך. המידע עצמו שהלוגיקה שלנו צריכה לעבוד עליו עשוי להישמר בתור חלק מהלוגיקה (למשל אם נכתוב את הקוד ב MobX), אבל גם עשוי להישמר בתוך הקומפוננטה באמצעות State. בשביל להשאיר את כל האופציות פתוחות אני מעדיף לכתוב ב game.js פונקציות עזר פשוטות שיקבלו את כל המידע מבחוץ ולא יכללו Side Effects.

במקרה של איקס עיגול זה קל ואני יכול להתחיל עם הקוד הבא לקובץ game.js:

import produce from "immer";

export function createEmptyBoard() {
  return [
    [" ", " ", " "],
    [" ", " ", " "],
    [" ", " ", " "]
  ];
}

export function getCellContents(board, row, column) {
  return board[row][column];
}

export function playerClickedOnSquare(board, player, row, column) {
  return produce(board, (draft) => {
    if (draft[row][column] === " ") {
      draft[row][column] = player;
    }
  });
}

export function nextPlayer(player = null) {
  switch (player) {
    case "O":
      return "X";
    case "X":
      return "O";
    default:
      return "X";
  }
}

מתוך 4 פונקציות בקובץ אני חושב שכולן מלבד playerClickedOnSquare הן די פשוטות. הפונקציה playerClickedOnSquare היא היחידה שעושה משהו יצירתי בגלל שהיא היחידה שעשויה היתה לכלול Side Effect. זיכרו שאנחנו רוצים לנהל את כל המידע בצורה חיצונית וחשוב לנו שהפונקציות לא ישנו את המידע שהן מקבלות או מידע חיצוני להן. אבל המשתנה board הוא בדיוק מצביע למערך דו-מימדי. כתיבה אליו תשנה את המערך, שינוי שיצא מגבולות הפונקציה.

כדי להישאר עם פונקציה נטולת Side Effects אני חייב לוותר על הכתיבה לזיכרון ולהחליף אותה ביצירת ערך חדש לגמרי שלא ישנה את המערך board המקורי. בשביל זה בחרתי להשתמש בספריה immer שיודעת להפוך פעולת כתיבה לזיכרון לפעולת העתקת אוביקטים והחזרת עותק של האוביקט המקורי עם העדכון. יש עוד דרכים לכתוב את הקוד הזה אבל אני חושב ש immer מציעה את הפיתרון הקל ביותר להבנה.

קוד הקומפוננטה עצמה לא דורש שום תחכום. הוא משתמש בקוד שכתבתי ב game כדי ליצור לוח משחק חדש ולשמור אותו במשתנה State, לקבל את השחקן הראשון למשתנה State נוסף, לצייר את הלוח וכשלוחצים על משבצת לעדכן את הלוח והשחקן בעזרת הפונקציות ב game. כך נראה הקוד:

import _ from "lodash";
import { useState } from "react";
import * as Game from "./game";

export default function TicTacToe(props) {
  const [board, setBoard] = useState(Game.createEmptyBoard());
  const [player, setPlayer] = useState("X");

  function play(row, column) {
    const nextBoard = Game.playerClickedOnSquare(board, player, row, column);
    if (nextBoard !== board) {
      setBoard(nextBoard);
      setPlayer(Game.nextPlayer(player));
    }
  }

  return (
    <>
      <p>It's player {player} turn</p>

      <div className="container">
        {_.range(3).map((row) =>
          _.range(3).map((column) => (
            <div className="square" onClick={play.bind(null, row, column)}>
              {Game.getCellContents(board, row, column)}
            </div>
          ))
        )}
      </div>
    </>
  );
}

אפשר לראות את החלק הראשון בפעולה בקישור: https://codesandbox.io/s/muddy-sea-4o46f?file=/src/App.js.

3. חלק 2: סיום המימוש וחלוקה לקומפוננטות

גירסת הקומפוננטה האחת עובדת אבל מתקרבת לתקרת הזכוכית של קומפוננטה. ככל שאוסיף עכשיו עוד לוגיקה לקוד הוא יתחיל להפוך לקשה לתחזוקה וקריאה. בנקודה הזאת אני אוהב לשבור את הקומפוננטה לתתי-קומפוננטות ולפצל את הפונקציונאליות ולפעמים גם חלק מהסטייט בין הקומפוננטות.

במקרה שלנו אי אפשר לפצל את הסטייט: כולם צריכים לדעת אם יש מנצח (ולכן צריכים את לוח המשחק) וכולם צריכים לדעת מי השחקן הנוכחי.

חלוקה אפשרית לקומפוננטות בתוך אילוצים אלה תהיה לבנות קומפוננטה של Status שמציגה את פרטי השחקן הנוכחי ואם יש מנצח אז את פרטי המנצח וכפתור עם אפשרות להתחיל משחק חדש; וקומפוננטה שניה של לוח המשחק שמציגה את הלוח ומאפשרת לשחקן לשחק. בחירת הקומפוננטות היא אחד הצדדים המעניינים של פיתוח בריאקט וחשוב להישאר גמישים בנושא ולא לפחד לשנות את החלוקה שבחרתם אם אתם רואים שהיא כבר לא עובדת.

הקומפוננטה הראשית שלי בחלוקה נראית כך:

export default function TicTacToe(props) {
  const [board, setBoard] = useState(Game.createEmptyBoard());
  const [player, setPlayer] = useState("X");

  function startNewGame() {
    setBoard(Game.createEmptyBoard());
  }

  return (
    <>
      <Status board={board} player={player} startNewGame={startNewGame} />

      <GameArea
        board={board}
        setBoard={setBoard}
        player={player}
        setPlayer={setPlayer}
        active={!Game.isWinner(board, Game.nextPlayer(player))}
      />
    </>
  );
}

התפקיד העיקרי של הקומפוננטה הראשית הוא ליצור את משתני ה State ולהעביר אותם לקומפוננטות פנימיות יותר כדי שיציגו אותם. הרבה פעמים נראה קומפוננטות כאלה משתמשות ב useReducer כדי להעביר פחות משתנים וליצור ממשק פחות מחייב כלפי קומפוננטות הילדים.

הקומפוננטה השניה Status היא די פשוטה ונראית כך:

function Status({ board, player, startNewGame }) {
  if (Game.isWinner(board, Game.nextPlayer(player))) {
    return (
      <p>
        Player {Game.nextPlayer(player)} Won!
        <button onClick={startNewGame}>New Game</button>
      </p>
    );
  }

  return <p>It's player {player} turn</p>;
}

וכאן אני חיברתי את המנגנון שמציג מי המנצח עם המנגנון שמציג מי השחקן הנוכחי. זאת בחירה כמובן ואפשר היה לחשב האם יש מנצח בקומפוננטה הראשית ולפי זה לבחור אם להציג קומפוננטת סטטוס אחת לתוך המשחק וקומפוננטת סטטוס אחרת למצב של משחק נגמר.

הקומפוננטה האחרונה והארוכה ביותר היא GameArea שאחראית על ציור לוח המשחק וטיפול בלחיצה על משבצות על הלוח:

function GameArea({ board, player, setBoard, setPlayer, active }) {
  function play(row, column) {
    if (!active) {
      return;
    }

    const nextBoard = Game.playerClickedOnSquare(board, player, row, column);
    if (nextBoard !== board) {
      setBoard(nextBoard);
      setPlayer(Game.nextPlayer(player));
    }
  }

  return (
    <div className="container">
      {_.range(3).map((row) =>
        _.range(3).map((column) => (
          <div className="square" onClick={play.bind(null, row, column)}>
            {Game.getCellContents(board, row, column)}
          </div>
        ))
      )}
    </div>
  );
}

בזמן קריאת הקוד שימו לב לקטע הבא מתוך הקומפוננטה הראשית (קטע דומה מופיע גם בקומפוננטת הסטטוס):

      <GameArea
         ... 
        active={!Game.isWinner(board, Game.nextPlayer(player))}
      />

בשביל לבדוק אם יש מנצח אנחנו בודקים האם "השחקן הבא" הוא המנצח. נסו לחשוב - למה "השחקן הבא" ולא "השחקן הנוכחי"? מי השחקן הנוכחי ומי השחקן הבא בכל רגע נתון? והאם יכול להיות מצב ש"השחקן הנוכחי" הוא גם המנצח?

לסיום הוספתי גם פונקציה ב game.js כדי לבדוק אם יש מנצח במשחק וזה הקוד שלה:

import produce from "immer";

export function createEmptyBoard() {
  return [
    [" ", " ", " "],
    [" ", " ", " "],
    [" ", " ", " "]
  ];
}

export function getCellContents(board, row, column) {
  return board[row][column];
}

export function playerClickedOnSquare(board, player, row, column) {
  return produce(board, (draft) => {
    if (draft[row][column] === " ") {
      draft[row][column] = player;
    }
  });
}

export function nextPlayer(player = null) {
  switch (player) {
    case "O":
      return "X";
    case "X":
      return "O";
    default:
      return "X";
  }
}

export function isWinner(board, player) {
  for (let i = 0; i < 3; i++) {
    if (
      board[i][0] === board[i][1] &&
      board[i][1] === board[i][2] &&
      board[i][2] === player
    ) {
      return true;
    }
    if (
      board[0][i] === board[1][i] &&
      board[1][i] === board[2][i] &&
      board[2][i] === player
    ) {
      return true;
    }
  }

  if (
    board[0][0] === board[1][1] &&
    board[1][1] === board[2][2] &&
    board[2][2] === player
  ) {
    return true;
  }
  if (
    board[0][2] === board[1][1] &&
    board[1][1] === board[2][0] &&
    board[2][0] === player
  ) {
    return true;
  }
  return false;
}

את הקוד המלא לחלק השני תוכלו למצוא בקישור: https://codesandbox.io/s/dawn-hill-7xqxn?file=/src/App.js.

4. רעיונות לשיפור להמשך

דרך טובה לוודא שהבנתם קטע קוד מסוים היא להרחיב אותו ולנסות להוסיף פונקציונאליות. הנה כמה רעיונות לשיפור הקוד מהפוסט:

  1. החליפו את העברת המשתנים מהקומפוננטה הראשית לילדים ב useReducer כדי לראות את היתרונות והחסרונות בשימוש בכל מנגנון.

  2. שימרו נקודות והוסיפו בתחתית המסך לוח שמראה כמה פעמים X ניצח וכמה O.

  3. אפשרו לפני תחילת משחק לכל שחקן לבחור לעצמו את השם.

  4. הוסיפו אפשרות לשמור את מצב הלוח ל Local Storage.

  5. הוסיפו אפשרות למשחק סימולטני על מספר לוחות במקביל. המשחק נגמר רק כשמושג ניצחון בכל הלוחות, והמנצח של המשחק הוא זה שניצח ברוב הלוחות. השמירה ל Local Storage עכשיו צריכה לשמור את מצב כל הלוחות.

  6. הוסיפו כפתור Undo שמבטל את המהלך האחרון.

  7. כששחקן מנצח הציגו את השורה, העמודה או האלכסון שניצח בצבע מהבהב.