Fork me on GitHub

Unity-井字棋

Homework_1

Tic Tac Toe

亮点:页面跳转,单人模式,双人模式

游戏内容: 井字棋
技术限制: 仅允许使用 IMGUI 构建 UI

本次作业是利用 Unity 制作 Tic Tac Toe 游戏。这个技术限制是不让我们使用 Unity 自带的视图操作工具进行制作,而是利用 code 来达到目的。将本次作业分为两个模块:

  1. 界面模块
  2. 逻辑模块

我们先将基础界面创建好,再进行逻辑代码的实现。

实现过程

  1. 基础界面
    井字棋游戏的主要界面是它的井字区域。我们利用九个按钮来实现这个井字区域。

    1. 添加按钮并居中
      我们使用OnGUI()来实现控件的添加。添加按钮控件使用如下函数:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      /*Prototype:
      GUI.Button(Rect position, string text);
      */
      int bHeight = 100;
      int bWidth = 100;
      float height = Screen.height * 0.5f - 150;
      float width = Screen.width * 0.5f - 150;

      GUI.Button(new Rect(width, height, bWidth, bHeight), "");

      其中,Screen.height 和 Screen.width 是当前窗口的高和宽,利用这两个参数,我们设置按钮的位置局中。要注意到的是,参数 position 给定的是按钮左上角的位置,进行简单的数学分析,就可以得到按钮中心点的位置设置方法。

    2. 基础页面
      单个按钮添加完毕,接下来就完成井字区域和Reset按钮的添加,以及界面欢迎词和游戏结束提示词的添加。代码如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      //UI Style parameters
      GUIStyle tStyle = new GUIStyle {
      fontSize = 50,
      fontStyle = FontStyle.Bold
      };
      GUIStyle mStyle = new GUIStyle {
      fontSize = 25,
      fontStyle = FontStyle.Bold
      };
      mStyle.normal.textColor = Color.red;

      GUI.Button(new Rect(width + bWidth / 2, height + 3.5f * bHeight, bWidth * 2, bHeight / 2), "Reset");

      GUI.Label(new Rect(width + 50, height - 75, 100, 100), msg, msgStyle);

      GUI.Label(new Rect(width + 20, height - 150, 100, 100), "Tic Tac Toe", tStyle);

      for (int i = 0; i < 3; ++i) {
      for (int j = 0; j < 3; ++j) {
      GUI.Button(new Rect(width + i * bWidth, height + j * bHeight, bWidth, bHeight), "")
      }
      }

      GUI添加控件的构造函数的参数有一个 GUIStyle 类型的参数,可以用来设置控件的样式。推荐使用变量来进行样式设置(如上代码),这样可以使得代码维护更为方便。

  2. 逻辑实现
    基础界面实现完毕,接下来我们来完成逻辑代码的实现。

    • 辅助参数设计

      1
      2
      3
      4
      public enum Player { player0, player1, player2};
      private bool playing = true;
      private bool turn = true;
      private Player[,] symbol = new Player [3,3];

      枚举变量帮助判断无人获胜,先手获胜,后手获胜;playing判断游戏是否结束;turn判断轮到哪一位玩家;symbol用来判断哪位玩家占据井字区域的那一块。

    • symbol 填充

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      for (int i = 0; i < 3; ++i) {
      for (int j = 0; j < 3; ++j) {
      if (symbol[i, j] == Player.player1) {
      GUI.Button(new Rect(width + i * bWidth, height + j * bHeight, bWidth, bHeight), "X");
      } else if (symbol[i, j] == Player.player2) {
      GUI.Button(new Rect(width + i * bWidth, height + j * bHeight, bWidth, bHeight), "O");
      } else {
      if (GUI.Button(new Rect(width + i * bWidth, height + j * bHeight, bWidth, bHeight), "")) {
      if (playing) {
      symbol[i, j] = turn ? Player.player1 : Player.player2;
      turn = !turn;
      }
      }
      }
      }
      }

      每次刷新页面,可以分出三种情况:

      1. 当前区域被一号玩家占据,更新为 X
      2. 当前区域被二号玩家占据,更新为 O
      3. 当前区域未被占据,且被点击,则更新符号,交换回合
    • 获胜判断
      采用枚举法进行获胜的判断,如果无人获胜,返回0号玩家。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      private Player Check() {
      //Row check
      for (int i = 0; i < 3; ++i) {
      if (symbol[i, 0] != Player.player0 &&
      symbol[i, 0] == symbol[i, 1] &&
      symbol[i, 1] == symbol[i, 2]) {
      return symbol[i, 0];
      }
      }
      //Column check
      for (int j = 0; j < 3; ++j) {
      if (symbol[0, j] != Player.player0 &&
      symbol[0, j] == symbol[1, j] &&
      symbol[1, j] == symbol[2, j]) {
      return symbol[0, j];
      }
      }
      //Cross line check
      if (symbol[1, 1] != Player.player0) {
      if (symbol[1, 1] == symbol[0, 0] && symbol[1, 1] == symbol[2, 2] ||
      symbol[1, 1] == symbol[0, 2] && symbol[1, 1] == symbol[2, 0]) {
      return symbol[1, 1];
      }
      }
      return Player.player0;
      }

      有人获胜时,显示获胜信息(写在OnGUI()函数内)。

      1
      2
      3
      4
      5
      6
      7
        //Check if someone wins
      if (winner != Player.player0) {
      msg = (winner == Player.player1 ? "Player1(X) Wins!" : "Player2(O) Wins!");
      GUI.Label(new Rect(width + 50, height - 75, 100, 100), msg, mStyle);
      playing = !playing;
      GUI.enabled = false;
      }
    • 界面重置
      将所有辅助变量重置为初始值。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      // Reset the screen
      private void Reset() {
      playing = true;
      turn = true;
      for (int i = 0; i < 3; ++i) {
      for (int j = 0; j < 3; ++j) {
      symbol[i,j] = Player.player0;
      }
      }
      }

      点击Reset按钮时,同样进行重置(写在OnGUI()函数内)。

      1
      2
      3
      4
      5
      //Reset button
      if (GUI.Button(new Rect(width + bWidth / 2, height + 3.5f * bHeight, bWidth * 2, bHeight / 2), "Reset")) {
      Reset();
      return;
      }
  3. 进阶游戏

    • 欢迎页面与页面跳转

      在Unity项目中,新建一个场景,当作初始页面。然后为其添加一个 C# script 文件,为其创建跳转按钮。界面设计,在此略过。首先,在 unity 中,场景跳转依靠 Application.LoadLevel() 函数。

      1
      2
      3
      /*有两个重载函数*/
      Application.LoadLevel(int screenId);
      Application.LoadLevel(string screenName);

      为了让这个函数能够找到我们写的场景,我们需要先做这样一件事:File->Build Setting->将所有 Screen 拖入其中。然后我们就可以完成起始页面的逻辑代码实现了,如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      float height = Screen.height * 0.5f;
      float width = Screen.width * 0.5f;
      int bHeight = 100;
      int bWidth = 150;

      if (GUI.Button(new Rect(width - bWidth / 2 - 100, height - bHeight / 2, bWidth, bHeight), "One Player Mode")) {
      Application.LoadLevel("OnePlayerMode");
      }

      if (GUI.Button(new Rect(width - bWidth / 2 + 100, height - bHeight / 2, bWidth, bHeight), "Two Player Mode")) {
      Application.LoadLevel("TwoPlayersMode");
      }
    • 单人模式

      这里,我们需要实现一个 “AI” 来和玩家进行游戏。看起来工作很复杂,但可以分解为两个部分,游戏回合的执行过程和 “AI” 的动作执行

      • 游戏回合的执行过程

        执行过程并不复杂,更改部分就是不同回合的执行动作过程。在 Two Players Mode 的基础代码下,对回合进行判断。在 “AI” turn 的情况下,分解动作执行过程:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        if (能获胜) {
        获胜
        } else {
        if (对手下一步获胜) {
        堵住对手
        } else {
        下一步棋子
        }
        }

        封装函数之后,大致回合代码如下:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
        if (symbol[i, j] == Player.player1) {
        //Change to X
        } else if (symbol[i, j] == Player.player2) {
        //Change to O
        } else {
        if (playing) {
        if (turn) {
        //Player turn
        //Listening for click event
        turn = false;
        } else {
        GUI.Button(new Rect(width + i * bWidth, height + j * bHeight, bWidth, bHeight), "");

        a_x = a_y = -1;
        CanWin();
        if (a_x == -1 && a_y == -1) {
        Block();
        }
        if (a_x == -1 && a_y == -1) {
        RandomStep();
        }
        if (a_x != -1 && a_y != -1 && symbol[a_x, a_y] == Player.player0) {
        symbol[a_x, a_y] = Player.player2;
        }
        turn = true;
        }
        }
        }
        }
        }
      • “AI” 动作执行
        可以获胜,我们需要对三个情况进行遍历:对角线,行和列。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        private void CanWin() {
        //cross line to win
        if (symbol[1, 1] == Player.player2) {
        if (symbol[0, 0] == Player.player2 &&
        symbol[2, 2] == Player.player0) {
        a_x = 2;
        a_y = 2;
        return;
        }
        if (symbol[2, 2] == Player.player2 &&
        symbol[0, 0] == Player.player0) {
        a_x = 0;
        a_y = 0;
        return;
        }
        if (symbol[2, 0] == Player.player2 &&
        symbol[0, 2] == Player.player0) {
        a_x = 0;
        a_y = 2;
        return;
        }
        if (symbol[0, 2] == Player.player2 &&
        symbol[2, 0] == Player.player0) {
        a_x = 2;
        a_y = 0;
        return;
        }
        }

        for (int i = 0; i < 3; ++i) {
        int row = i;
        int col = i;
        //row to win
        if (symbol[row, 0] == Player.player2) {
        if (symbol[row, 1] == Player.player2 &&
        symbol[row, 2] == Player.player0) {
        a_x = row;
        a_y = 2;
        return;
        }
        if (symbol[row, 2] == Player.player2 &&
        symbol[row, 1] == Player.player0) {
        a_x = row;
        a_y = 1;
        return;
        }
        }
        if (symbol[row, 1] == Player.player2) {
        if (symbol[row, 2] == Player.player2 &&
        symbol[row, 0] == Player.player0) {
        a_x = row;
        a_y = 0;
        return;
        }
        }
        //column to win
        if (symbol[0, col] == Player.player2) {
        if (symbol[1, col] == Player.player2 &&
        symbol[2, col] == Player.player0) {
        a_x = 2;
        a_y = col;
        return;
        }
        if (symbol[2, col] == Player.player2 &&
        symbol[1, col] == Player.player0) {
        a_x = 1;
        a_y = col;
        return;
        }
        }
        if (symbol[1, col] == Player.player2) {
        if (symbol[2, col] == Player.player2 &&
        symbol[0, col] == Player.player0) {
        a_x = 0;
        a_y = col;
        return;
        }
        }
        }
        }

        没错,对于封堵对手,其实就是上面函数的翻版,将判断条件改成对手就行了。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        private void Block() {
        //cross line to win
        //...
        for (int i = 0; i < 3; ++i) {
        int row = i;
        int col = i;
        //row to win
        //...
        //column to win
        //...
        }
        }

        下一步棋。除却上面两个函数的算法外,这是另一个决定这个 “AI” 智能程度的函数。技术较菜加时间因素,只能实现随机在空白区域下一步棋。在这个函数实现过程中,容易出现访问越界,选取到已填充区块和暴力穷举程序崩溃等问题,请善用Debug.Log()进行辅助。这里采用每次遍历井字区域并存储空白区域坐标,再在坐标内进行随机选取,避免越界,暴力循环的问题。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        private void RandomStep() {
        List<int> row = new List<int>();
        List<int> col = new List<int>();
        int count = 0;
        for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
        if (symbol[i,j] == Player.player0) {
        row.Add(i);
        col.Add(j);
        count++;
        }
        }
        }
        if (count != 0) {
        System.Random ran = new System.Random();
        int index = ran.Next(0, count);
        a_x = row[index];
        a_y = col[index];

        } else {
        a_x = a_y = -1;
        }
        }

效果图:

Welcome
OnePlayer
TwoPlayers

最后附上 github 项目链接:TicTacToe