创建游戏房间
联机逻辑开发进度:■■■■■■■□□□□□
本章结束开发进度:■■■■■■■■■□□□
上一章的答案
Logic
类:
<?php
...
class Logic
{
public function matchPlayer($playerId)
{
...
//发起一个Task尝试匹配
DataCenter::$server->task(['code' => TaskManager::TASK_CODE_FIND_PLAYER]);
}
}
Server
类:
<?php
...
class Server
{
...
public function onTask($server, $taskId, $srcWorkerId, $data)
{
DataCenter::log("onTask", $data);
$result = [];
switch ($data['code']) {
case TaskManager::TASK_CODE_FIND_PLAYER:
$ret = TaskManager::findPlayer();
if (!empty($ret)) {
$result['data'] = $ret;
}
break;
}
if (!empty($result)) {
$result['code'] = $data['code'];
return $result;
}
}
...
}
...
童鞋们的作业完成情况如何呢?
我们来再次梳理一下目前的匹配功能进度:
- 前端连接时发送
player_id
- 服务端连接时保存玩家信息
- 前端发送
code
为600
的指令 - 服务端将
player_id
放入匹配队列 - 服务端发起一个
Task
异步任务进行玩家匹配,当人数满足时弹出并返回两个player_id
到worker
进程
那下一步就很明显了,就是创建游戏房间。
创建房间分析
做题时间
- 在
Server
类的onFinish()
方法中,根据传入的code
,执行Logic
的createRoom()
方法。
Server
类:
<?php
...
class Server
{
...
public function onFinish($server, $taskId, $data)
{
DataCenter::log("onFinish", $data);
switch ($data['code']) {
case TaskManager::TASK_CODE_FIND_PLAYER:
$this->logic->createRoom($data['data']['red_player'],
$data['data']['blue_player']);
break;
}
}
}
...
显然,下一步就是完成这个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_2
的onFinish()
方法,worker_2
就会创建游戏房间并保存数据在DataCenter
的$global
变量中,那么玩家A
的worker_1
将会读取不到$global
中的游戏房间数据。
要解决这个问题有几个容易的方法:
- 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
后,发送开始游戏指令。
可以看到,调用bind()
方法绑定玩家后,客户端后续消息都会发送到被绑定的worker_2
,成功地解决了上面所说的进程内存隔离问题。
绑定玩家连接
做题时间
- 想要使用
bind()
方法,需先将dispatch_mode
设置为5
。 - 完成
Logic
的createRoom()
方法,生成一个room_id
,绑定连接fd
。 - 获取
$server
对象,向两个玩家分别发送房间room_id
。
Server
类:
<?php
...
class Server
{
...
const CONFIG = [
...
'dispatch_mode' => 5,
...
];
...
}
...
Logic
类:
<?php
...
class Logic
{
...
public function createRoom($redPlayer, $bluePlayer)
{
$roomId = uniqid('room_');
$this->bindRoomWorker($redPlayer, $roomId);
$this->bindRoomWorker($bluePlayer, $roomId);
}
private function bindRoomWorker($playerId, $roomId)
{
$playerFd = DataCenter::getPlayerFd($playerId);
DataCenter::$server->bind($playerFd, crc32($roomId));
DataCenter::$server->push($playerFd, $roomId);
}
}
童鞋们发现问题了吗?
没错,我们的push()
方法直接就把room_id
发过去了。又是这种问题:接收方无法识别该消息是何种消息。那么我们要如何处理呢?还是老套路,加code
协议码。一个更好的办法是,找一个类来专门管理与发送相关的变量和方法。
在Manager
文件夹下,新建Sender
类文件。
Sender
类:
<?php
namespace App\Manager;
class Sender
{
}
做题时间
- 在
Sender
类中新增MSG_ROOM_ID
常量,作为发送room_id
的code
。 - 新增方法
sendMessage($playerId, $code, $data = [])
,通过传入的$playerId
发送固定格式的消息到客户端。比较常规的内容需要有:code
、msg
、data
。 - 将
bindRoomWorker()
中发送房间room_id
的代码改为使用Sender
发送。
Sender
类:
<?php
...
class Sender
{
const MSG_ROOM_ID = 1001;
const CODE_MSG = [
self::MSG_ROOM_ID => '房间ID',
];
public static function sendMessage($playerId, $code, $data = [])
{
$message = [
'code' => $code,
'msg' => self::CODE_MSG[$code] ?? '',
'data' => $data
];
$playerFd = DataCenter::getPlayerFd($playerId);
if (empty($playerFd)) {
return;
}
DataCenter::$server->push($playerFd, json_encode($message));
}
}
Logic
类:
<?php
...
class Logic
{
...
private function bindRoomWorker($playerId, $roomId)
{
$playerFd = DataCenter::getPlayerFd($playerId);
DataCenter::$server->bind($playerFd, crc32($roomId));
Sender::sendMessage($playerId, Sender::MSG_ROOM_ID, ['room_id' => $roomId]);
}
}
这下我们的前端就能通过接收的code
来判断,究竟这条message
是房间ID数据
或者是游戏对战数据
。
我们来测试一下目前为止的代码有没有问题。重启Server
服务器,在浏览器打开两个游戏前端页面并点击匹配按钮。
[root@localhost app]# php Server.php
master start (listening on 0.0.0.0:8811)
server: onWorkStart,worker_id:4
server: onWorkStart,worker_id:5
server: onWorkStart,worker_id:6
server: onWorkStart,worker_id:7
server: onWorkStart,worker_id:0
server: onWorkStart,worker_id:1
server: onWorkStart,worker_id:2
server: onWorkStart,worker_id:3
[2019-04-21 15:59:46][INFO]: client open fd:3
[2019-04-21 15:59:50][INFO]: client open fd:3,message:{"code":600}
[2019-04-21 15:59:50][INFO]: onTask {"code":1}
[2019-04-21 15:59:50][INFO]: onFinish {"data":{"red_player":"player_177","blue_player":"player_181"},"code":1}
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
,导致程序报错。
初始化玩家数据
做题时间
- 在
DataCenter
中新增initDataCenter()
方法清除Redis
中的残余数据。 - 在
onStart
的时候调用initDataCenter()
方法。
DataCenter
类:
<?php
...
class DataCenter
{
...
public static function initDataCenter()
{
//清空匹配队列
$key = self::PREFIX_KEY . ':player_wait_list';
self::redis()->del($key);
//清空玩家ID
$key = self::PREFIX_KEY . ':player_id*';
$values = self::redis()->keys($key);
foreach ($values as $value) {
self::redis()->del($value);
}
//清空玩家FD
$key = self::PREFIX_KEY . ':player_fd*';
$values = self::redis()->keys($key);
foreach ($values as $value) {
self::redis()->del($value);
}
}
...
}
Server
类:
<?php
...
class Server
{
...
public function onStart($server)
{
...
DataCenter::initDataCenter();
}
...
}
...
现在再来一次,重启Server
服务器,在浏览器打开两个游戏前端页面并点击匹配按钮。
可以看到,前端成功接收到服务端发送的room_id
。
发送开始游戏指令
做题时间
- 在
Vue
的数据属性中新增roomId
,用于保存服务端发送的room_id
。 - 新增方法
startRoom()
,当服务端发来room_id
消息时,发送code
以及room_id
到服务端开始游戏。
本章留的Homework是前端功能,但是比较简单,请童鞋们尽力完成哦。
本章对应Github Commit
:第八章结束
当前目录结构:
HideAndSeek
├── app
│ ├── Lib
│ │ └── Redis.php
│ ├── Manager
│ │ ├── DataCenter.php
│ │ ├── Game.php
│ │ ├── Logic.php
│ │ ├── Sender.php
│ │ └── TaskManager.php
│ ├── Model
│ │ ├── Map.php
│ │ └── Player.php
│ └── Server.php
├── composer.json
├── composer.lock
├── frontend
│ └── index.html
├── test.php
└── vendor
├── autoload.php
└── composer