创建游戏房间

联机逻辑开发进度:■■■■■■■□□□□□

本章结束开发进度:■■■■■■■■■□□□

上一章的答案

Logic类:

  1. <?php
  2. ...
  3. class Logic
  4. {
  5. public function matchPlayer($playerId)
  6. {
  7. ...
  8. //发起一个Task尝试匹配
  9. DataCenter::$server->task(['code' => TaskManager::TASK_CODE_FIND_PLAYER]);
  10. }
  11. }

Server类:

  1. <?php
  2. ...
  3. class Server
  4. {
  5. ...
  6. public function onTask($server, $taskId, $srcWorkerId, $data)
  7. {
  8. DataCenter::log("onTask", $data);
  9. $result = [];
  10. switch ($data['code']) {
  11. case TaskManager::TASK_CODE_FIND_PLAYER:
  12. $ret = TaskManager::findPlayer();
  13. if (!empty($ret)) {
  14. $result['data'] = $ret;
  15. }
  16. break;
  17. }
  18. if (!empty($result)) {
  19. $result['code'] = $data['code'];
  20. return $result;
  21. }
  22. }
  23. ...
  24. }
  25. ...

童鞋们的作业完成情况如何呢?

我们来再次梳理一下目前的匹配功能进度:

  • 前端连接时发送player_id
  • 服务端连接时保存玩家信息
  • 前端发送code600的指令
  • 服务端将player_id放入匹配队列
  • 服务端发起一个Task异步任务进行玩家匹配,当人数满足时弹出并返回两个player_idworker进程

8 创建游戏房间 - 图1

那下一步就很明显了,就是创建游戏房间。

创建房间分析

做题时间

  1. Server类的onFinish()方法中,根据传入的code,执行LogiccreateRoom()方法。

Server类:

  1. <?php
  2. ...
  3. class Server
  4. {
  5. ...
  6. public function onFinish($server, $taskId, $data)
  7. {
  8. DataCenter::log("onFinish", $data);
  9. switch ($data['code']) {
  10. case TaskManager::TASK_CODE_FIND_PLAYER:
  11. $this->logic->createRoom($data['data']['red_player'],
  12. $data['data']['blue_player']);
  13. break;
  14. }
  15. }
  16. }
  17. ...

显然,下一步就是完成这个createRoom()方法,匹配机制就大功告成了。但是真的这么简单吗?下面我们要思考一件事情。

我们的匹配队列是存放在Redis中的,无论哪个worker都可以读取,但游戏数据是存放在内存中的,在启动Swoole Worker时设置了'worker_num' => 4,有多个worker进程,这会产生什么效果呢?就是进程内存隔离

Swoole多进程共享数据:wiki.swoole.com/wiki/page/8…

小提示:默认情况下,一个客户端连接在onOpen时就会绑定一个worker进程,后续所有message交互都会发送到同一个worker进程。

比如,玩家A进入了worker_1,而玩家B进入了worker_2。他们的匹配队列用的同一个Redis List,假如worker_2发起Task异步任务并成功获取到两个玩家,就会回调到worker_2onFinish()方法,worker_2就会创建游戏房间并保存数据在DataCenter$global变量中,那么玩家Aworker_1将会读取不到$global中的游戏房间数据。

8 创建游戏房间 - 图2

要解决这个问题有几个容易的方法:

  • A:说个啥子废话直接把多进程改成单进程就好啦!
  • B:数据不放内存不就得了嘛?找个Redis爱咋放咋放。
  • C:使用Swoole自带的Table或其他的共享内存方式。
  • D:将两个玩家绑定到同一个worker中。

显然,A方法过于粗暴,没想到竟说出如此粗鄙之语

B方法性能不太够,当有成千上万玩家的时候,性能肯定是不如内存存取的。

C方法性能应该能够满足,但随之而来却产生了其他的成本问题:一个是学习成本、一个是数据管理成本。当数据量多的时候,要考虑多个进程之间锁要如何管理,这就会弄得非常复杂,不适合于做一个小游戏。

D方法只需要几行代码就能完成,实现成本最低。

Swoole为我们提供了一个bind()方法,就可将连接绑定到固定的一个worker来处理。不了解bind()方法的童鞋请先阅读一下官方文档,尤其是时序问题

Server bind:wiki.swoole.com/wiki/page/3…

到目前位置,创建房间的流程就是:

  • 生成一个房间room_id
  • 使用bind()方法将task寻找到的两位玩家连接的fd绑定到room_id算出的同一个int值。
  • 将生成的room_id发送到玩家客户端。
  • 客户端获取到room_id后,发送开始游戏指令

8 创建游戏房间 - 图3

可以看到,调用bind()方法绑定玩家后,客户端后续消息都会发送到被绑定的worker_2,成功地解决了上面所说的进程内存隔离问题。

绑定玩家连接

做题时间

  1. 想要使用bind()方法,需先将dispatch_mode设置为5
  2. 完成LogiccreateRoom()方法,生成一个room_id,绑定连接fd
  3. 获取$server对象,向两个玩家分别发送房间room_id

Server类:

  1. <?php
  2. ...
  3. class Server
  4. {
  5. ...
  6. const CONFIG = [
  7. ...
  8. 'dispatch_mode' => 5,
  9. ...
  10. ];
  11. ...
  12. }
  13. ...

Logic类:

  1. <?php
  2. ...
  3. class Logic
  4. {
  5. ...
  6. public function createRoom($redPlayer, $bluePlayer)
  7. {
  8. $roomId = uniqid('room_');
  9. $this->bindRoomWorker($redPlayer, $roomId);
  10. $this->bindRoomWorker($bluePlayer, $roomId);
  11. }
  12. private function bindRoomWorker($playerId, $roomId)
  13. {
  14. $playerFd = DataCenter::getPlayerFd($playerId);
  15. DataCenter::$server->bind($playerFd, crc32($roomId));
  16. DataCenter::$server->push($playerFd, $roomId);
  17. }
  18. }

童鞋们发现问题了吗?

没错,我们的push()方法直接就把room_id发过去了。又是这种问题:接收方无法识别该消息是何种消息。那么我们要如何处理呢?还是老套路,加code协议码。一个更好的办法是,找一个类来专门管理与发送相关的变量和方法。

Manager文件夹下,新建Sender类文件。

Sender类:

  1. <?php
  2. namespace App\Manager;
  3. class Sender
  4. {
  5. }

做题时间

  1. Sender类中新增MSG_ROOM_ID常量,作为发送room_idcode
  2. 新增方法sendMessage($playerId, $code, $data = []),通过传入的$playerId发送固定格式的消息到客户端。比较常规的内容需要有:codemsgdata
  3. bindRoomWorker()中发送房间room_id的代码改为使用Sender发送。

Sender类:

  1. <?php
  2. ...
  3. class Sender
  4. {
  5. const MSG_ROOM_ID = 1001;
  6. const CODE_MSG = [
  7. self::MSG_ROOM_ID => '房间ID',
  8. ];
  9. public static function sendMessage($playerId, $code, $data = [])
  10. {
  11. $message = [
  12. 'code' => $code,
  13. 'msg' => self::CODE_MSG[$code] ?? '',
  14. 'data' => $data
  15. ];
  16. $playerFd = DataCenter::getPlayerFd($playerId);
  17. if (empty($playerFd)) {
  18. return;
  19. }
  20. DataCenter::$server->push($playerFd, json_encode($message));
  21. }
  22. }

Logic类:

  1. <?php
  2. ...
  3. class Logic
  4. {
  5. ...
  6. private function bindRoomWorker($playerId, $roomId)
  7. {
  8. $playerFd = DataCenter::getPlayerFd($playerId);
  9. DataCenter::$server->bind($playerFd, crc32($roomId));
  10. Sender::sendMessage($playerId, Sender::MSG_ROOM_ID, ['room_id' => $roomId]);
  11. }
  12. }

这下我们的前端就能通过接收的code来判断,究竟这条message房间ID数据或者是游戏对战数据

我们来测试一下目前为止的代码有没有问题。重启Server服务器,在浏览器打开两个游戏前端页面并点击匹配按钮。

  1. [root@localhost app]# php Server.php
  2. master start (listening on 0.0.0.0:8811)
  3. server: onWorkStart,worker_id:4
  4. server: onWorkStart,worker_id:5
  5. server: onWorkStart,worker_id:6
  6. server: onWorkStart,worker_id:7
  7. server: onWorkStart,worker_id:0
  8. server: onWorkStart,worker_id:1
  9. server: onWorkStart,worker_id:2
  10. server: onWorkStart,worker_id:3
  11. [2019-04-21 15:59:46][INFO]: client open fd3
  12. [2019-04-21 15:59:50][INFO]: client open fd3message:{"code":600}
  13. [2019-04-21 15:59:50][INFO]: onTask {"code":1}
  14. [2019-04-21 15:59:50][INFO]: onFinish {"data":{"red_player":"player_177","blue_player":"player_181"},"code":1}
  15. PHP Warning: Swoole\WebSocket\Server::push(): the connected client of connection[9] is not a websocket client or closed. in /mnt/htdocs/HideAndSeek_teach/app/Manager/Sender.php on line 31

显然,程序报错了。这是因为我们启动服务器时,没有清除之前残余的玩家信息,push()时通过playerId获取fd时获取到了错误的fd,导致程序报错。

初始化玩家数据

做题时间

  1. DataCenter中新增initDataCenter()方法清除Redis中的残余数据。
  2. onStart的时候调用initDataCenter()方法。

DataCenter类:

  1. <?php
  2. ...
  3. class DataCenter
  4. {
  5. ...
  6. public static function initDataCenter()
  7. {
  8. //清空匹配队列
  9. $key = self::PREFIX_KEY . ':player_wait_list';
  10. self::redis()->del($key);
  11. //清空玩家ID
  12. $key = self::PREFIX_KEY . ':player_id*';
  13. $values = self::redis()->keys($key);
  14. foreach ($values as $value) {
  15. self::redis()->del($value);
  16. }
  17. //清空玩家FD
  18. $key = self::PREFIX_KEY . ':player_fd*';
  19. $values = self::redis()->keys($key);
  20. foreach ($values as $value) {
  21. self::redis()->del($value);
  22. }
  23. }
  24. ...
  25. }

Server类:

  1. <?php
  2. ...
  3. class Server
  4. {
  5. ...
  6. public function onStart($server)
  7. {
  8. ...
  9. DataCenter::initDataCenter();
  10. }
  11. ...
  12. }
  13. ...

现在再来一次,重启Server服务器,在浏览器打开两个游戏前端页面并点击匹配按钮。

8 创建游戏房间 - 图4

可以看到,前端成功接收到服务端发送的room_id

发送开始游戏指令

做题时间

  1. Vue的数据属性中新增roomId,用于保存服务端发送的room_id
  2. 新增方法startRoom(),当服务端发来room_id消息时,发送code以及room_id到服务端开始游戏。

本章留的Homework是前端功能,但是比较简单,请童鞋们尽力完成哦。

本章对应Github Commit第八章结束

当前目录结构:

  1. HideAndSeek
  2. ├── app
  3. ├── Lib
  4. └── Redis.php
  5. ├── Manager
  6. ├── DataCenter.php
  7. ├── Game.php
  8. ├── Logic.php
  9. ├── Sender.php
  10. └── TaskManager.php
  11. ├── Model
  12. ├── Map.php
  13. └── Player.php
  14. └── Server.php
  15. ├── composer.json
  16. ├── composer.lock
  17. ├── frontend
  18. └── index.html
  19. ├── test.php
  20. └── vendor
  21. ├── autoload.php
  22. └── composer