基于 React + ES6 + Webpack + Node 实现的井字棋小游戏

本实例基于 React + ES6 + Webpack + Node 实现一个井字棋的小游戏,来进一步深刻理解 React 的组件化思想。

1. 效果预览

您可点击预览进行效果预览。
井字棋规则如下:

  • 下棋操作:A / B 两人轮流下棋,下好后该点显示下棋人的名字
  • 成功:当某人下的棋连成三点一线,则判定某人成功
  • 提示:棋盘上方提示处显示下一个下棋人是谁,判定某人成功后,提示出获胜者是谁,并且不可继续进行下棋操作
  • 重开游戏:点击棋盘上方刷新按钮,清楚当前棋盘数据,重新开始游戏
  • 步骤显示及步骤还原:右侧显示步骤按钮,点击即可调到对应步骤对应的棋盘面

2. 项目框架搭建

项目目录结构如下图:


  • 配置 package.json——安装依赖,配置 node 运行语句
    项目框架使用 React + ES6 + Webpack + Node 搭建,首先创建一个 package.json 文件,如下,已添加好所需要安装的依赖目录,直接 npm install 即可安装项目所需依赖。
{
  "name": "game",
  "version": "1.0.0",
  "description": "gama:tic-tac-toe",
  "main": "index.js",
  "scripts": {
    "start": "webpack-dev-server --devtool eval --progress --hot --inline --colors --content-base build",
    "build": "webpack --progress --colors",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "css-loader": "^0.28.10",
    "file-loader": "^1.1.10",
    "node-sass": "^4.7.2",
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-redux": "^5.0.6",
    "react-router": "^4.2.0",
    "redux": "^3.7.2",
    "redux-logger": "^3.0.6",
    "sass-loader": "^6.0.6",
    "style-loader": "^0.20.2",
    "url-loader": "^0.6.2"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-runtime": "^6.23.0",
    "babel-polyfill": "^6.26.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "babel-runtime": "^6.26.0",
    "html-webpack-plugin": "^2.30.1",
    "open-browser-webpack-plugin": "0.0.5",
    "webpack": "^3.10.0",
    "webpack-dev-server": "^2.9.7"
  }
}
  • 配置 webpack.config.js,配置项目编译、打包、浏览器自启动等功能
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const OpenBrowserPlugin = require('open-browser-webpack-plugin');
module.exports = {
    entry: './app.js',//
    output: {
        filename: "build/build.js"
    },
    devServer: {
        inline: true,
        port: 8060
    },
    plugins: [new HtmlWebpackPlugin({
        template: 'index.html'
    }),
        new webpack.HotModuleReplacementPlugin(),
        new OpenBrowserPlugin({url: 'http://localhost:8060'})//自动打开浏览器
    ],
    module: {
        rules: [{
            test: /\.js$/,
            exclude: /node_modules/,
            loader: 'babel-loader',
            query: {
                plugins: ['transform-runtime'],
                presets: ['es2015', 'react', 'stage-2']
            }
        }, {
            test: /\.css$/,
            loader: "style-loader!css-loader"
        }, {
            test: /\.scss$/,
            loader: "style-loader!css-loader!sass-loader"
        },{
            // 图片加载器
            test:/\.(png|jpg|gif|jpeg)$/,
            loader:'url-loader?limit=2048'
        }]
    }
};
  • 新建 index.html,作为主页面入口
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>my app</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>
  • 新建 src 文件夹存放项目代码,并在src 文件夹内新建 TicTacToe.js 文件,作为游戏区域外层组件
import React, {Component} from 'react';

class TicTacToe extends Component{
    render(){
        return(
            <div className='game'>
                tic-tac-toe
            </div>
        )
    }
}

export default TicTacToe;
  • 新建 index.scss 文件,在后续的入口文件中引用
  • 新建 app.js 文件作为项目入口文件,引用 src 内的 TicTacToe 组件及 index.scss 文件
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import TicTacToe from './src/TicTacToe';
import './index.scss';

class APP extends Component{
    render(){
        return(
            <TicTacToe/>
        )
    }
}
ReactDOM.render(<APP/>, document.getElementById('app'));
  • 至此,运行 npm start 即可启动项目,浏览器会自动打开 8060 端口,运行项目,效果如下图,接下来就可以进行组件划分。


3. 组件划分

  • 从外到内划分,创建组件框架,完成引用,后进行功能完善
    React 最核心的思想就是组件化,那么首先我们将井字棋游戏区域,按照显示和功能划分为组件。划分时,我采用了从外到内的顺序。



    如上图,井字棋可划分为四部分,对应TicTacToe 、Header、Board、Steps四个组件。

4. 初步创建组件框架

  • 从内到外创建,然后在外层引用
Header 组件:头部提示区

功能:显示当前下棋人、提示有人连线成功、重开游戏
render 代码中,status 为当前提示文字,<button/> 为重开游戏按钮
新建 Header 组件

import React, {Component} from 'react';

class Header extends Component{
    render(){
        const { winner, master } = this.props;
        let status = winner ? `Winner is ${winner}!` : `Next Player: ${master}`;
        return(
            <div>
                {status}
                <button>重玩儿一把</button>
            </div>
        )
    }
}

export default Header;

Header 组件需要从上层传下 winner、master 数据。
在 TicTacToe 组件中调用

import React, {Component} from 'react';
import Header from './Header';

class TicTacToe extends Component{
    constructor(props){
        super(props);
        this.state = {
            master: 'A',
            winner: ''
        }
    }
    render(){
        const { master, winner } = this.state;
        return(
            <div className='game'>
                <Header
                    master={master}
                    winner={winner}
                />
            </div>
        )
    }
}

export default TicTacToe;

效果如下图:


Board 组件:棋盘组件,包含九个小棋盘格
import React, {Component} from 'react';

class Board extends Component{

    /**
     * 渲染多个棋盘格
     */
    renderSquares = () => {
        const { squares, onClickSquare } = this.props;
        let squaresDom = [];
        for(let i=0;i<9;i++){
            squaresDom.push(
                <button
                    key={i}
                    className='square'
                    onClick={e=>onClickSquare(e,i)}
                >
                    {squares[i]}
                </button>
            )
        };
        return squaresDom;
    }

    render(){
        return(
            <div className='board'>
                {this.renderSquares()}
            </div>
        )
    }
}

export default Board;

Board 组件需要从上层组件传入 squares、onClickSquare 数据。
下面在 TicTacToe 组件中调用 Board 组件,并添加 squares 数据和 onClickSquare 函数。

import React, {Component} from 'react';
import Header from './Header';
import Board from './Board';

class TicTacToe extends Component{
    constructor(props){
        super(props);
        this.state = {
            master: 'A',
            winner: '',
            squares: Array(9).fill(undefined)
        }
    }

    /**
     * 下棋触发
     */
    onClickSquare = (e, index) => {
        const {master, squares, winner} = this.state;
        if(squares[index] === undefined && !winner){
            let newSquares = Object.assign([], squares);
            newSquares[index] = master;
            let newMaster = '';
            switch (master){
                case 'A':
                    newMaster = 'B';
                    break;
                case 'B':
                    newMaster = 'A';
                    break;
                default:
                    break;
            }
            this.setState({
                master: newMaster,
                squares: newSquares
            })
        }
    }

    render(){
        const { master, winner, squares } = this.state;
        return(
            <div className='game'>
                <Header
                    master={master}
                    winner={winner}
                />
                <Board
                    squares={squares}
                    onClickSquare={this.onClickSquare}
                />
            </div>
        )
    }
}

export default TicTacToe;

在 index.scss 中加入样式:

.board{
  width: 120px;
  height: 120px;
  border: 1px solid #ccc;
  font-size: 0;
  .square{
    width: 40px;
    height: 40px;
    background: transparent;
    border: 1px solid #ccc;
    outline: none;
    cursor: pointer;
    vertical-align: bottom;
  }
}

运行结果如下:


Steps 组件:步骤列组件,进行一步操作则产生一个步骤条,点击步骤条则跳转到对应棋盘格状态
import React, {Component} from 'react';

class Steps extends Component{

    /**
     * 渲染步骤条
     */
    renderSteps = () => {
        const { history, turnToStep } = this.props;
        let steps = [];
        history['data'].forEach((obj, index) => {
            steps.push(
                <button
                    key={index}
                    onClick={e=>turnToStep(e, index)}
                    className='step-btn'
                >
                    step{index}
                </button>
            )
        })
        return steps;
    }

    render(){
        return(
            <div className='steps'>
                {this.renderSteps()}
            </div>
        )
    }
}

export default Steps;

Steps 组件需要从上层组件传入 history、turnToStep 数据。
下面在 TicTacToe 组件中调用 Steps 组件,并添加 history 数据和 turnToStep 函数。

 import React, {Component} from 'react';
import Header from './Header';
import Board from './Board';
import Steps from './Steps';

class TicTacToe extends Component{
    constructor(props){
        super(props);
        this.state = {
            master: 'A',
            winner: '',
            squares: Array(9).fill(undefined),
            history: {
                flag: false,
                data:[{
                    master: 'A',
                    squares: Array(9).fill(undefined)
                }]
            }
        }
    }

    /**
     * 下棋触发
     * @returns {*}
     */
    onClickSquare = (e, index) => {
        const {master, squares, winner, history} = this.state;
        const { flag, data } = history;
        if(squares[index] === undefined && !winner){
            if(flag){
                let newMaster = data[data.length-1].master;
                let newSquares = data[data.length-1].squares;
                let newHistory = Object.assign([], history);
                newHistory.flag = false;
                this.setState({
                    master: newMaster,
                    squares: newSquares,
                    history: newHistory
                })
            }else{
                let newSquares = Object.assign([], squares);
                newSquares[index] = master;
                let newMaster = '';
                switch (master){
                    case 'A':
                        newMaster = 'B';
                        break;
                    case 'B':
                        newMaster = 'A';
                        break;
                    default:
                        break;
                }

                let newHistory = Object.assign([], history);
                newHistory.data.push({
                    master: newMaster,
                    squares: newSquares
                });

                this.setState({
                    master: newMaster,
                    squares: newSquares,
                    history: newHistory
                })
            }
        }
    }

    /**
     * 跳转到某一步
     * @returns {*}
     */
    turnToStep = (e, index) => {
        const { history } = this.state;
        const { master, squares } = this.state.history.data[index];
        let newHistory = Object.assign({}, history);
        newHistory.flag = true;
        this.setState({
            squares: squares,
            master: master,
            history: newHistory
        })
    }

    render(){
        const { master, winner, squares, history } = this.state;
        return(
            <div className='game'>
                <Header
                    master={master}
                    winner={winner}
                />
                <Board
                    squares={squares}
                    onClickSquare={this.onClickSquare}
                />
                <Steps
                    history={history}
                    turnToStep={this.turnToStep}
                />
            </div>
        )
    }
}

export default TicTacToe;

history.data 存储每一步点击的数据,history.flag 为是否点击了步骤条,若点击了步骤条,再下次点击棋盘时,先将期盼恢复为最后一次下棋结束的样子。
相应地,onClickSquare 函数也进行了更改。

5. 功能完善

基础组件已都已构建完毕,接下来进行功能完善。

  • 判断胜出功能
    思路:通过矩阵位置判断。下棋时,维护每个下棋人对应下棋的位置数据,通过位置数据与获胜位置数据匹配进行判断。
  • 再玩一把功能
    思路:将所有数据置为初始值即可。
    完善后的 TicTacToe 组件代码如下:
import React, {Component} from 'react';
import Board from './Board';
import Steps from './Steps';
import Header from './Header';

class TicTacToe extends Component{
    constructor(props){
        super(props);
        this.state = {
            master: 'A',
            squares: Array(9).fill(undefined),
            winner: '',
            history:[{
                master: 'A',
                squares: []
            }]
        };
        this.squaresA = [];
        this.squaresB = [];
    }

    /**
     * 下棋触发
     */
    clickSquare = (e, value, index) => {
        const { master, winner } = this.state;
        if(value === undefined && !winner){
            let newSquares = Object.assign([], this.state.squares);
            let newHistory = Object.assign([], this.state.history);
            newSquares[index] = master;
            let newMaster = '';
            if(master === 'A'){
                newMaster = 'B';
                this.squaresA.push(index);
            }else if(master === 'B'){
                newMaster = 'A';
                this.squaresB.push(index);
            }
            newHistory.push({
                master: newMaster,
                squares: newSquares
            });

            let winner = this.calculateWinner();

            this.setState({
                master: newMaster,
                squares: newSquares,
                winner: winner,
                history: newHistory
            });
        }

    };

    /**
     * 点击步骤按钮后,跳转到对应的棋盘格状态
     */
    onStepClick = (e, data) => {
        const { master, squares } = data;
        this.setState({
            squares: squares,
            master: master
        });
    }

    /**
     * 判断是否有人连线成功
     */
    calculateWinner = () => {
        let winner = '';
        const { master } = this.state;
        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],
        ];

        let squares = (master === 'A') ? this.squaresA : this.squaresB;

        lines.forEach(arr => {
            if(squares.indexOf(arr[0]) !== -1 && squares.indexOf(arr[1]) !== -1 && squares.indexOf(arr[2]) !== -1){
                winner = master;
            }
        });
        return winner;
    }

    /**
     * 重新开始一把游戏
     */
    refreshGame = () => {
        this.squaresA = [];
        this.squaresB = [];
        this.setState({
            master: 'A',
            squares: Array(9).fill(null),
            winner: '',
            history:[{
                master: 'A',
                squares: []
            }]
        })
    }

    render(){
        const { master, squares, winner, history } = this.state;
        return(
            <div className='game'>
                <Header
                    winner={winner}
                    master={master}
                    refreshGame={this.refreshGame}
                />
                <Board
                    clickSquare={this.clickSquare}
                    master={master}
                    squares={squares}
                    winner={winner}
                />
                <Steps
                    history={history}
                    onStepClick={this.onStepClick}
                />
            </div>
        )
    }
}

export default TicTacToe;

再给 Header 组件 的 button 添加点击事件。

import React, {Component} from 'react';

class Header extends Component{
    render(){
        const { winner, master, refreshGame } = this.props;
        let status = winner ? `Winner is ${winner}!` : `Next Player: ${master}`;
        return(
            <div className='title'>
                {status}
                <button className='refresh' onClick={refreshGame}/>
            </div>
        )
    }
}

export default Header;

至此,功能完善完毕。

6. 样式调整

最后再进行样式调整。

.game{
  width: 500px;
  height: 380px;
  position: absolute;
  top: 50%;
  left: 50%;
  margin-top: -190px;
  margin-left: -250px;
  .title{
    height: 80px;
    line-height: 80px;
    font-size: 30px;
    text-indent: 50px;
    .refresh{
      width: 30px;
      height: 32px;
      outline: none;
      border: none;
      cursor: pointer;
      margin-left: 100px;
      vertical-align: middle;
      background: url("./src/image/refresh.png")no-repeat 0 0/30px 30px;
    }
  }
  .board{
    width: 300px;
    border: 1px solid #ccc;
    font-size: 0;
    float: left;
    .square{
      width: 100px;
      height: 100px;
      line-height: 60px;
      background: transparent;
      border: 1px solid #ccc;
      outline: none;
      cursor: pointer;
      vertical-align: middle;
    }
  }
  .steps{
    width: 198px;
    float: right;
    .step-btn{
      height: 28px;
      width: 85%;
      background: transparent;
      outline: none;
      border: 1px solid #ccc;
      margin-left: 15%;
      margin-top: 2px;
      border-radius: 5px;
    }
  }
}

效果如下:


完整代码地址

ps: 还有一个更完整的 井字棋/五子棋 切换小游戏,预览网址代码地址

本文参考:
https://reactjs.org/tutorial/tutorial.html

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 172,077评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,652评论 18 139
  • 好久不见一部够分量的港产警匪片。 好在,就要有了。 本届上影节开幕片《寒战2》。 也是Sir年度期待港产之一。 这...
    Sir电影阅读 1,835评论 8 25
  • “燕子去,,有再来的时候;杨柳枯了,有再首的时候;桃花谢了,有再开的时候。但是,聪明的你告诉我,我们的日子为什...
    暮光之城茵阅读 484评论 0 0
  • 人品好,香港回上海在两场大雨之间起飞下降。在梅雨季节平安回到上海。 超个人呼吸群吵得我火也大了,这都什么啊还能放心...
    莎珈阅读 276评论 2 2