骑驴找蚂蚁

全干工程师

Swoole和WebSocket之斗地主上篇进房间

这篇文章主要讲解通过SwoolewebSocket 来实现斗地主的用户进入房间功能,这也是实现斗地主的第一步。实现整个斗地主分为三篇文章来讲解这是第一篇。
在技术栈方面主要使用php, swoole, html5:webSocket, pixi.js, 数据存储采用redis。 主要实现效果如下图:

实现效果

开始

在开始之前我们默认已有php开始环境。我们只需要安装swoole扩展,redis扩展redis

  1. 安装swoole, redis.

  2. 下载pixijs库, 当然你可以使用远程的. (一个绘制2D图形的引擎)

  3. composer.json 编写, 之后执行安装命令即可composer install.


{
    "require": {
        "meshell/surf": "^1.0.5" //对swoole一个封装框架(https://github.com/TianLiangZhou/surf)
    },
    "autoload": {
        "psr-4": {
            "Landowner\\": "./app" //后台服务的文件目录
        }
    }
}
  1. 建立入口(index.html)文件,资源目录,服务目录和文件。如图:

程序目录

功能实现

我们需要实现websocket和后台服务的对接。制定协议格式,比如协议名,内容,返回值。

进入房间

一个斗地主流程比较完善应该是:

用户登录 -> 选择房间 -> 进入房间 -> 准备开始 -> 开始.

我们在这里的实现直接从进入房间开始。用户直接打开网页就是进入房间。

  • 功能分析

当前用户进入房间之前,我们需要在页面显示当前房间有多少人等待,进入成功之后我们需要告知其它用户有人进来了。从上面得知我们需要一个获取当前房间有多少人的协议,
这些人的状态是什么(准备中未准备),还需要一个进入房间的协议,该协议要实现广播告知其它用户,而客户端也需要监听进入房间的广播消息。

  • 服务端

我们先从服务端代码开始编写. 根据上面的分析我需要实现两个协议. surf也是一个MVC形式的框架. 我们只需要实现自己的业务控制器就行了.
在这里我们使用框架自带的json数据传输类型来解析.

  1. 入口文件landowner.php

<?php

require __DIR__ . '/vendor/autoload.php';
$config = [];
$config['setting'] = [
    'document_root' => __DIR__,
    'task_worker_num' => 1,
    ];
$config['server'] = 'webSocket';
$app = new \Surf\Application(__DIR__, [
    'app.config' => $config
]);
$app->register(new \Surf\Provider\RedisServiceProvider());
include __DIR__ . '/protocol.php'; //协议路由文件
try {
    $app->run();
} catch (\Surf\Exception\ServerNotFoundException $e) {

}
  1. 协议路由protocol.php
   <?php 
   use Landowner\Protocol\LandownerController;
   $app->addProtocol( //进入房间协议
       'enter.room', //协议名称
       LandownerController::class . ':enterRoom'
   );

   $app->addProtocol( //获取当前房间人数列表协议
       'room.player',
       LandownerController::class . ':roomPlayer'
   );
  1. 控制器的实现LandownerController.php

    <?php

    namespace Landowner\Protocol;

    use Pimple\Psr11\Container;
    use Surf\Mvc\Controller\WebSocketController;
    use Surf\Server\RedisConstant;
    use Surf\Task\PushTaskHandle;

    class LandownerController extends WebSocketController
    {
        const READY_KEY = 'ready:action';

        /**
         * @var null | \Redis
         */
        protected $redis = null;

        /**
         * LandownerController constructor.
         * @param Container $container
         * @param int $workerId
         */
        public function __construct(Container $container, $workerId = 0)
        {
            parent::__construct($container, $workerId);

            $this->redis = $this->container->get('redis');
        }


        /**
         * @param $body
         * @return array
         */
        public function enterRoom($body)
        {
            //框架自带类常量,获取当前的总数
            $count = $this->redis->sCard(RedisConstant::FULL_CONNECT_FD);
            $flag = 0;
            $players = [];
            if ($count > 3) { //限制3
                $flag = 500;
                $this->setIsClose(true); //主动断开
            } else {
                $allPlayer = $this->redis->sMembers(RedisConstant::FULL_CONNECT_FD);
                print_r($allPlayer);
                $otherPlayer = array_diff($allPlayer, [$this->frame->fd]);
                if ($otherPlayer) { //找出其它用户
                    foreach ($otherPlayer as $fd) {
                        $readyState = $this->redis->hGet(self::READY_KEY, $fd);
                        $players[] = [
                            'ready' => $readyState, //获取其它用户的状态
                            'playerId' => $fd, //用户id
                        ];
                    }
                    $this->task([
                        "from" => $otherPlayer,
                        "content" => json_encode([
                            "listen" => "enterRoom", //客户监听的协议名称
                            "content" => $this->frame->fd,
                        ])
                    ], PushTaskHandle::class); //通过任务给其它用户发送消息
                }
            }
            return [
                "flag" => $flag,
                "player" => $this->frame->fd,
                "otherPlayer" => $players,
                "requestId" => $body->requestId,
            ];
        }

        /**
         * 获取正在房间的用户,以及状态,客户端要据状态显示不同的文字 
         * 
         * @param $body
         * @return array
         */
        public function roomPlayer($body)
        {
            $count = $this->redis->sCard(RedisConstant::FULL_CONNECT_FD);
            $player = $this->redis->sMembers(RedisConstant::FULL_CONNECT_FD);
            $players = [];
            foreach ($player as $fd) {
                if ($fd == $this->frame->fd) {
                    continue;
                }
                $readyState = $this->redis->hGet(self::READY_KEY, $fd);
                $players[] =[
                    'ready' => $readyState,
                    'playerId'=> $fd,
                ];
            }
            return [
                "flag"   => 0,
                "count" => $count,
                "player" => $players,
                "requestId" => $body->requestId,
            ];
        }
       ...
    }
  • 客户端

在客户端我们这里使用原生的webSocket进行通信,pixijs引擎进行渲染界面. 在这里封装了一个简单的websocket使用类.

整个socket类代码.


function Socket() {
    var isConnection = false, _this = this;
    var connection = function() {
        var host = location.host;
        if (host !== "example.loocode.com") {
            host = "192.168.56.101";
        }
        return new WebSocket("ws://" + host + ":9527");
    };

    this.reconnect = function() {
        this.socket = connection();
        this.socket.onopen = function () {
            isConnection = true;
            if (_this.openCallback) {
                _this.openCallback.call(_this);
            }
        };
        this.socket.onmessage = function(event) {
            _this.response(event); //监听消息
        };
        this.socket.onclose = function() {
            isConnection = false;
        };
        this.socket.onerror = function(e) {
            alert("connection websocket error!");
        };
    };
    this.response = function(event) {
        var response = null;
        try {
            response = JSON.parse(event.data);
        } catch (e) {
            console.log(e);
        }
        console.log(response);
        //协议返回
        if (typeof response.body === "object" && response.body !== undefined) {
            var requestId = response.body.requestId; 
            var callback = this.getRequestCallback(requestId);
            if (callback !== undefined) {
                callback.call(_this, response.body);
            }
        }
        //服务主动发送的监听
        if (typeof response.listen === "string" && response.listen !== undefined) {
            if (_this.listeners.hasOwnProperty(response.listen)) {
                _this.listeners[response.listen].call(_this, response.content);
            }
        }
    };
    this.isConnected = function() {
        return isConnection;
    };
    this.requestCallback = {};
    this.listeners = {};
    this.openCallback = null;
}

Socket.prototype = {
    addRequestCallback: function (id, callback) {
        this.requestCallback[id] = callback;
    },
    getRequestCallback: function(id) {
        if (this.requestCallback.hasOwnProperty(id)) {
            return this.requestCallback[id];
        }
        return undefined;
    },
    listen: function (name, callback) {
        this.listeners[name] = callback;
    },
    onOpenCallback: function(callback) {
        if (callback !== undefined && callback !== null) {
            this.openCallback = callback;
        }
    },
    send: function(protocol, body, callback) {
        var requestId = new Date().getTime();
        if (body === undefined || body === null) {
            body = {requestId: requestId};
        } else {
            body.requestId = requestId;
        }
        var requestBody = {
            "protocol": protocol,
            "body": body
        };
        if (!this.isConnected()) {
            this.reconnect();
        }
        if (callback !== undefined) {
            this.addRequestCallback(requestId, callback);
        }
        this.socket.send(JSON.stringify(requestBody));
    }
};

实现的客户业务代码


function Landowner() {
    this.app = new PIXI.Application( //初始化容器
        window.innerWidth,
        window.innerHeight,
        {
            backgroundColor: 0x1099bb, // 设置容器颜色
        }
    );
    this.socket = new Socket();
    this.playerCount = 0; //用户总数
    this.player = 0; //用户id
    this.playerReadyButton = {};
    this.playId = 0; //当前牌局id
}

Landowner.prototype = {
    start: function () {
        document.body.appendChild(this.app.view);
        this.listen(); //初始化监听
    },
    initRender: function (landowner) {
        /**
         * @var landowner Landowner
         */
        if (this.socket.isConnected()) {
            this.socket.send('room.player', {}, function(body) {
                if (body.count > 3) {
                    return ;
                }
                landowner.playerCount = body.count;
                for (var i = 0; i < body.player.length; i++) {
                    landowner.readyWorker(i + 2, !!body.player[i].ready, body.player[i].playerId);
                }
                this.send("enter.room", {}, function (body) {
                    if (body.flag === 0) {
                        landowner.player = body.player;
                        landowner.readyWorker(1, false, body.player);
                    }
                });
            });
        }
    },
    listen: function() {
        var _this = this;
        this.socket.listen("enterRoom", function(content) {
            _this.playerCount++;
            _this.readyWorker(_this.playerCount, false, content);
        });
        this.socket.listen("readyStatus", function(content) {
            /**
             * @var button Graphics
             */
            var button = _this.playerReadyButton[content.playerId];
            var text = button.getChildByName('text');
            text.text = content.ready ? "准备中" : "准备";
        });
        this.socket.listen("assignPoker", function(content) {
            _this.playId = content.playId;
            _this.renderBottomCard(content.landowner);
            _this.assignPoker(content.poker);
            for (var key in _this.playerReadyButton) {
                _this.playerReadyButton[key].destroy();
            }
        });
        this.socket.onOpenCallback(function() {
           _this.initRender(_this);
        });
        this.socket.reconnect();
    },
    readyWorker: function (offset, state, playId) {
        var _this = this;
        var button = new PIXI.Graphics()
            .beginFill(0x2fb44a)
            .drawRoundedRect(0, 0, 120, 60, 10)
            .endFill();
        var text = "准备";
        if (state === true) {
            text = "准备中";
        }
        var readyText = new PIXI.Text(text, new PIXI.TextStyle({
            fontFamily: "Arial",
            fontSize: 32,
            fill: "white",
        }));
        readyText.x = 60 - 32;
        readyText.y = 30 - 16;
        readyText.name = "text";
        button.addChild(readyText);
        button.interactive = true;
        button.buttonMode = true;
        var clickCounter = 1;
        if (offset === 1) {
            button.on('pointertap', function () {
                var ready = 0;
                if (clickCounter % 2) {
                    readyText.text = "取消";
                    ready = 1;
                } else {
                    readyText.text = "准备";
                }
                clickCounter++;
                _this.socket.send('player.ready', {
                    'ready': ready
                }, function (body) {
                    console.log(body);
                });
            });
        }
        var x = y = 0;
        y = window.innerHeight / 2;
        x = window.innerWidth / 2;
        if (offset === 2) {
            x = x + (x / 2);
            y = y - (y / 2);
        } else if (offset === 3) {
            x = x - (x / 2) - 60;
            y = y - (y / 2);
        } else {
            x = x - 60;
            y = y + (y / 2);
        }
        button.x = x;
        button.y = y;
        this.playerReadyButton[playId] = button;
        this.app.stage.addChild(button);
    },
}

入口文件index.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Landowner</title>
    <script src="js/pixi.js"></script>
</head>
<body>
    <script type="text/javascript" src="js/landowner.js"></script>
    <script type="text/javascript">
        new Landowner().start();
    </script>
</body>
</html>

以上服务端和客户端代码就完成了用户进入房间功能。下一期我们讲下准备,发牌的实现.

源码地址

https://github.com/TianLiangZhou/loocode-example/tree/master/landowner

效果地址

https://example.loocode.com/landowner/index.html

推荐阅读

  1. https://wiki.swoole.com/
  2. https://github.com/TianLiangZhou/surf
  3. https://pixijs.io/examples/
  4. https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API

留言