react井字棋---最全井字棋小游戏教程

上一期我们利用create-react-app搭建了好了一个react项目,这期我们通过跟随React官方教程--编写一个“井字棋"小游戏,来熟悉react的基本用法。

首先来看下“井字棋”的最终实现效果:

image
image.gif

从演示中我们可以看到,这个游戏大致有以下功能:

1. 切换玩家;

2. 判定胜负;

3. 高亮获胜棋子;

4. 按步骤悔棋;

制作棋盘

首先我们需要制作一个棋盘。

在项目中新建一个Board.js文件:

image
image.gif

import React from "react";
import "./style.css";

function Board() {
  return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
        <button className="btn"></button>
      </div>
  );
}

export default Board;

image.gif

Board.js

.btn {
  width: 100px;
  height: 100px;
  border: 1px solid;
  vertical-align: middle;
  font-size: 30px;
  font-weight: 600;
}
.board {
  width: 300px;
  margin-right: 20px;
}
image.gif

css

import React from 'react';
import Board from './components/Board';

function App() {
  return (
    <div className="App">
        <Board />
    </div>
  );
} 

export default App;

image.gif

app.js引入

image
image.gif

不同于Vue的模板语法,react的这种写法叫做jsx语法,可以在js中直接编写dom标签。在渲染时,这种写法会被编译成 React.createElement('div',{className: 'board'})。

现在Board.js只有一个function Board,这种组件被称之为函数组件

这种写法明显冗余,我们希望通过循环来生成9个方格,在Vue中可以使用v-for,那么在react中应该怎么做呢?

import React from "react";
import "./style.css";

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        squares: Array(9).fill(""),
    };
  }
  render() {
    return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {this.state.squares.map((el, index) => {
          return <button className="btn" key={index}></button>;
        })}
      </div>
    );
  }
}

export default Board;

image.gif

首先我们将function Board 改成class Board 将其变为class组件。在constructor中定义一个state数据,其key为squares,是一个长度为9的值为空的数组。然后用map方法循环数组,输出dom节点。

接着我们将button标签拆分成单独的组件Square.js,以便学习父子组件的传值方式。

import React from "react";
import "./style.css";

function Square(props) {
  return (
    <button className="btn">
    </button>
  );
}
export default Square;

image.gif

Square.js

import React from "react";
import Square from "./Square"
import "./style.css";

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        squares: Array(9).fill(""),
    };
  }
  render() {
    return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {this.state.squares.map((el, index) => {
          return <Square key={index}></Square>;
        })}
      </div>
    );
  }
}

export default Board;

image.gif

Board.js

组件传值与事件交互

首先需要将玩家信息“X”or“O”传递给子组件。父组件通过绑定一个字段,子组件通过props接收。

import React from "react";
import Square from "./Square"
import "./style.css";

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        squares: Array(9).fill(""),
        player: "X"
    };
  }
  render() {
    return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {this.state.squares.map((el, index) => {
          return <Square key={index} player={this.state.player}></Square>;
        })}
      </div>
    );
  }
}

export default Board;

image.gif
import React from "react";
import "./style.css";

function Square(props) {
    console.log(props);
  return (
    <button className="btn">
        {props.player}
    </button>
  );
}
export default Square;

image.gif
image
image.gif

子组件拿到数据了,但是我们需要的是,每次点击时显示棋子并切换玩家。

思路是这样的:子组件在初始化时,拿空数据,每次点击时,修改当前节点的数据,并切换下一个玩家。

定义一个切换玩家的函数:changePlayer

  changePlayer(index) {
      let player = this.state.player === "X" ? "O" : "X";
      let squares = [...this.state.squares];
      squares[index] = player;
      this.setState({
          player,
          squares
      })
  }

image.gif

在react中是不允许直接修改state中的值的,必须通过setState来实现赋值,这个函数是异步的。

在react中,当数据改变时会触发render函数重新执行,生成虚拟dom,然后对比虚拟dom是否有改变,若有则重新渲染dom节点。

image
image.gif

react组件生命周期

然后在子组件中通过属性传递事件函数

  render() {
    return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {this.state.squares.map((el, index) => {
          return <Square key={index} player={el} changePlayer={() => {this.changePlayer(index)}}></Square>;
        })}
      </div>
    );
  }
}

image.gif

向事件处理程序传递参数,需要注意this指向的问题,可以使用箭头函数或bind(this)来保持this的指向:

image
image.gif

我个人更倾向于第一种。

接着在子组件中绑定click事件:

import React from "react";
import "./style.css";

function Square(props) {
  return (
    <button className="btn" onClick={props.changePlayer}>
        {props.player}
    </button>
  );
}
export default Square;

image.gif
image
image.gif

这样基本的切换玩家功能就实现了。

判定胜负

“井字棋”的规则很简单,就是横、竖、对角线三子成棋,即为胜利。

我们先添加一些提示语:Next player:X/O

在玩家胜利时将其切换为:Winner is :X/O

定义winner与winnerArr(成棋的棋子的index数组)

 constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(""),
      player: "X",
      winner: "",
      winnerArr: [],
    };
  }
image.gif

通过条件判定,切换title

 render() {
    let { player, squares, winner } = this.state;
    let title = "";
    if (!winner) {
      title = <p>Next player:{player}</p>;
    } else {
      title = <p>Winner is:{winner}</p>;
    }
    return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {title}
        {squares.map((el, index) => {
          return (
            <Square
              key={index}
              player={el}
              changePlayer={() => {
                this.changePlayer(index);
              }}
            ></Square>
          );
        })}
      </div>
    );
  }

image.gif

判定获胜者函数:该函数将所有获胜可能的index值组合穷举出来,再从棋盘上取index组合中的三个值,三值相等即为胜者,游戏结束。

  // 判断获胜者
  calculateWinner(squares) {
    const lines = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8],
      [0, 3, 6],
      [1, 4, 7],
      [2, 5, 8],
      [0, 4, 8],
      [2, 4, 6],
    ];
    for (let i = 0; i < lines.length; i++) {
      const [a, b, c] = lines[i];
      if (
        squares[a] &&
        squares[a] === squares[b] &&
        squares[a] === squares[c]
      ) {
        return {
          squares: squares[a],
          winnerArr: lines[i],
        };
      }
    }
    return null;
  }

image.gif

该函数在每次下棋动作都应被调用:

changePlayer(index) {
    if (this.state.winner) {
      return;
    }
    let player = this.state.player === "X" ? "O" : "X";
    let squares = [...this.state.squares];
    squares[index] = player;
    this.setState({
      player,
      squares,
    });
    let winner = this.calculateWinner(squares);
    if (winner) {
      this.setState({
        winner: winner.squares,
        winnerArr: winner.winnerArr,
      });
    }
  }

image.gif
image
image.gif

至此,判定胜者功能完成。

高亮获胜棋子

这个功能涉及动态添加className。

首先定义一个动态返回className的函数:

  getClassName(index) {
    let { winner, winnerArr } = this.state;
    if (winner) {
      for (let i = 0; i < 3; i++) {
        if (winnerArr[i] === index) {
          return "winner-square";
        }
      }
      return "";
    } else {
      return "";
    }
  }

image.gif

在组件中绑定:

 return (
      <div className="board">
        <h1>井字棋游戏--React</h1>
        {title}
        {squares.map((el, index) => {
          return (
            <Square
              key={index}
              player={el}
              changePlayer={() => {
                this.changePlayer(index);
              }}
              winnerClass={this.getClassName(index)}
            ></Square>
          );
        })}
      </div>
    );

image.gif

子组件内:

function Square(props) {
  return (
    <button className={`btn ${props.winnerClass}`} onClick={props.changePlayer}>
        {props.player}
    </button>
  );
}

image.gif
.winner-square {
  background-color: aquamarine;
}

image.gif

注意动态绑定className的语法:**{btn ${props.winnerClass}} **是es6的模板字符串语法。当然还有其他表示方式,但是这种方式较为简洁,个人更推荐。

image
image.gif

按步骤悔棋

其实这个功能只要将每一步的数据保存在一个数组当中,点击悔棋列表,将数据切换回去即可。

定义history保存初始状态值:

constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(""),
      player: "X",
      winner: "",
      history: [
        {
          squares: Array(9).fill(""),
          player: "X",
        },
      ],
      step: 1,
      winnerArr: [],
    };
  }

image.gif

在切换玩家函数中保存每一步的数据:

// 切换玩家
  changePlayer(i) {
    if (this.state.winner) {
      return;
    }
    let squares = [...this.state.squares];
    let history = this.state.history.slice(0, this.state.step);
    if (squares[i]) {
      return;
    }
    let player = this.state.player === "X" ? "O" : "X";
    squares[i] = this.state.player;
    history.push({
      squares,
      player,
    });
    // setState方法是异步执行的
    this.setState({
      player,
      squares,
      history,
      step: history.length,
    });
    let winner = this.calculateWinner(squares);
    if (winner) {
      this.setState({
        winner: winner.squares,
        winnerArr: winner.winnerArr
      });
    }
  }

image.gif

返回到某一步:

 backTo(i) {
    this.setState((state) => {
      return {
        winner: "",
        squares: state.history[i].squares,
        player: state.history[i].player,
        step: i + 1,
      };
    });
  }

image.gif

添加悔棋dom节点,并添加触发事件:

// 每次数据更新都会触发执行
  render() {
    let { player, squares, winner, history } = this.state;
    let title = "";
    if (!winner) {
      title = <p>Next player:{player}</p>;
    } else {
      title = <p>Winner is:{winner}</p>;
    }
    return (
      <Fragment>
        <h1>井字棋游戏--React</h1>
        {title}
        <div className="flex">
          <div className="board">
            {squares.map((el, index) => {
              return (
                <Square
                  changePlayer={() => this.changePlayer(index)}
                  key={index}
                  player={el}
                  index={index}
                  winnerClass={this.getClassName(index)}
                />
              );
            })}
          </div>
          <div className="back_step">
            <p>悔棋</p>
            {history.map((el, i) => {
              return (
                <button
                  key={i}
                  onClick={() => {
                    this.backTo(i);
                  }}
                >
                  {i === 0 ? "Back to game start" : "Back to No:" + i + " step"}
                </button>
              );
            })}
          </div>
        </div>
      </Fragment>
    );
  }

image.gif
image
image.gif

至此功能就全部实现了!

需要获取源码的小伙伴,请进入公众号回复“react” 即可获取GitHub地址。

image
image.gif

小伙伴们加油!我们下期再见!

image
image.gif


全栈攻城狮进阶

关注微信公众号,第一时间获取好文章!

所有人都祝你快乐,我只愿你遍历山河,觉得人间值得。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350