[TOC]
# 数据库的设计
```[sql]
-- ----------------------------
-- Table structure for rank
-- ----------------------------
DROP TABLE IF EXISTS `rank`;
CREATE TABLE `rank` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`room_id` int(11) UNSIGNED NULL DEFAULT 0 COMMENT '房间id',
`uid` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'uid',
`nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '昵称',
`stars` int(11) UNSIGNED NULL DEFAULT 0 COMMENT '星星数',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for room
-- ----------------------------
DROP TABLE IF EXISTS `room`;
CREATE TABLE `room` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '房间名',
`create_time` datetime(0) NULL DEFAULT NULL,
`last_hours` float(10, 2) NULL DEFAULT 0.00 COMMENT '持续时长',
`finish_time` datetime(0) NULL DEFAULT NULL COMMENT '结束时间',
`close_time` datetime(0) NULL DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for room_user_cards
-- ----------------------------
DROP TABLE IF EXISTS `room_user_cards`;
CREATE TABLE `room_user_cards` (
`room_id` int(11) UNSIGNED NOT NULL COMMENT '房间id',
`uid` int(11) UNSIGNED NOT NULL DEFAULT 0,
`compete_uid` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '对抗uid',
`used_order` int(11) NOT NULL DEFAULT -1 COMMENT '使用顺序',
`type` enum('石头','剪刀','布') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '石头',
`used` tinyint(1) UNSIGNED NULL DEFAULT 0 COMMENT '是否使用 0',
`create_time` datetime(0) NULL DEFAULT NULL,
`use_time` datetime(0) NULL DEFAULT NULL COMMENT '使用时间',
PRIMARY KEY (`room_id`, `uid`, `compete_uid`, `used_order`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for room_users
-- ----------------------------
DROP TABLE IF EXISTS `room_users`;
CREATE TABLE `room_users` (
`room_id` int(11) UNSIGNED NOT NULL COMMENT '房间id',
`uid` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户id',
`type` enum('human','ai') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'human' COMMENT '身份类型',
`stars` int(2) UNSIGNED NULL DEFAULT 0 COMMENT '星星数',
`left_cards` int(11) UNSIGNED NULL DEFAULT 12 COMMENT '剩余卡片数',
`status` enum('unknown','win','lose','draw') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'unknown' COMMENT '状态',
`gaming` tinyint(1) UNSIGNED NULL DEFAULT 0 COMMENT '是否比赛中 1-是 0-否',
PRIMARY KEY (`room_id`, `uid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`win_round` int(11) UNSIGNED NULL DEFAULT 0 COMMENT '赢的次数',
`create_time` datetime(0) NULL DEFAULT NULL,
`fd` int(11) UNSIGNED NULL DEFAULT 0 COMMENT 'fd',
`type` enum('human','ai') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'human' COMMENT '类型',
`online` tinyint(1) UNSIGNED NULL DEFAULT 1 COMMENT '1在线 0离线',
`online_time` datetime(0) NULL DEFAULT NULL,
`offline_time` datetime(0) NULL DEFAULT NULL COMMENT '离线时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
```
从数据产生来说吧
显示用户表,然后用户创建房间,然后房间里玩家出牌。一开始考虑的是类似动物世界的那种,大房间里好多人,淘汰后换别人比赛,所以要创建房间 指定每个房间几个人。持续多久。后来时间有限,先简化成自动创建房间 房间里2个人。
## 用户表 user
用户表主要是昵称作为唯一判断,没搞密码那套。type里区分human 和ai。开始只搞人机对战。后面其实也支持人人对战。
因为是webscoket 必然会存在 在线、离线问题。
按照以前做的思路,fd存 当前链接fd,搞一个登录消息,来自动更新online 状态和时间。
## 房间表 room
主要是create_time + lasthours 对应 finish_time 应结束时间, close_time 是实际结束时间。 动物世界里是1天,我这边默认1小时。
close_time 表示 最小对局, 2人一轮,12张牌打完、也可能3次就全赢 后的 实际结束时间。
房间里没存多少人,因为创建房间时候已经指定好,并随机添加了除创建者以外的人数的机器人。
## 房间用户表 room_users
记录本轮用户的星星、剩余卡牌 状态 是否比赛中等字段。 默认unknown,结束后是win、draw、lose。
## 出牌表 room_user_cards
先记录房间 和比赛者信息、后记录出牌顺序、出牌类型type,出牌时间等。开始是想分配机器人时自动将12张牌一次性插入的。后来想了想没必要全插,因为可能提前结束,多插记录也没必要。
# websocket实现
## swoole的安装
首先是swoole 扩展,linux 环境很好搞定 pecl install swoole 就行。问题这样必须弄一台服务器开发。为了提高效率,本来准备按官方手册里用mingw 来搞的。后来发现,swoole支持 wsl。
![](https://box.kancloud.cn/ee8d18a48eccc7585df4d856eb96583f_823x300.png)
于是本地 wsl里先装扩展,发现各种依赖出问题。最后用通过重置应用后,装上了。
> wsl 里与本地磁盘路径的对应 /mnt/c 对应C盘
> 当前linux 路径 对应 C:\Users<username>\\AppData\\Local\Packages\<group_name>\\LocalState
> 如 C:\Users\jay\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu18.04onWindows_79rhkp1fndgsc\LocalState\rootfs
## think-swoole的使用
首先在创建了一个tp5.1的项目,然后升级至最新版,听说修好了数据库断线重连问题。然后composer require think-swoole 就好。
### 配置服务控制器
在config目录里建一个swoole_server.php 的文件,配置一个控制器来作为swoole控制器:
~~~
<?php
return [
'swoole_class' => 'app\index\controller\Guess',
];
~~~
具体内容为:
~~~
<?php
namespace app\index\controller;
use app\index\model\User;
use Swoole\Process;
use think\Db;
use think\swoole\Server;
use think\facade\Env;
class Guess extends Server
{
protected $host = '0.0.0.0';
protected $port = 9502;
protected $serverType = 'socket';
public $option = [
'worker_num' => 4,
'task_worker_num' => 4,
'daemonize' => true,
'backlog' => 128,
'log_file' => './swoole.log',
'pid_file' => './master',
];
public $from_fd;
public $server;
/**
* 架构函数
* @access public
*/
public function init()
{
$pid = $this->getMasterPid();
if (!$this->isRunning($pid)) {
Env::set('pid_path', Env::get('root_path').'pid/');
$this->option['pid_file'] = str_ireplace('./', Env::get('pid_path'), $this->option['pid_file']);
User::offline(0);
$server = $this->swoole;
$process = new \swoole_process(function ($process) use ($server) {
new \console\InotifyReload($server);
ptrace(realpath(__DIR__));
file_put_contents(__DIR__.'/../../../pid/inotify', $process->pid);
$process->name('inotify');
});
}
$this->swoole->addProcess($process);
}
/**
* 判断PID是否在运行
* @access protected
* @param int $pid
* @return bool
*/
protected function isRunning($pid)
{
if (empty($pid)) {
return false;
}
Process::kill($pid, 0);
return !swoole_errno();
}
public function getMasterPid()
{
if (file_exists($this->option['pid_file'])) {
return file_get_contents($this->option['pid_file']);
} else {
return 0;
}
}
// 加密
public static function encode($arr)
{
$str = http_build_query($arr);
return bin2hex($str);
}
// 解析
public static function decode($binary)
{
return hex2bin($binary);
}
public function pretty_json($arr)
{
return json_encode($arr, JSON_UNESCAPED_UNICODE);
}
public function onWorkerStart($server, $worker_id)
{
// secho('start', 'worker_start');
// secho('root_path', Env::get('root_path'));
}
public function onShutdown($server)
{
file_put_contents($this->option['pid_file'], '0');
secho('stop', 'stoped');
ptrace('swoole 服务停止了');
}
public function onStart($server)
{
define('SUCCESS_MSG', '');
if (!isDarwin()) {
cli_set_process_title('swoole');
}
$managerPid = $server->manager_pid;
$shString = <<<SH
echo "Reloading..."
kill -USR1 {$managerPid}
echo "Reloaded"
SH;
$sh_file = './.reload_manager.sh';
file_put_contents($sh_file, $shString);
}
public function onConnect($server, $fd)
{
$time = datetime();
echo "server: handshake success with fd:{$fd} {$time}\n";
}
public function onWorkerExit($server, $worker_id)
{
echo "work exist " . datetime();
}
public function onClose($server, $fd)
{
User::offline_fd($fd);
$time = datetime();
echo "client {$fd} closed {$time}\n";
}
public function onFinish($server, $task_id, $data)
{
print_r('return_data');
print_r($data);
return true;
}
// 异步耗时任务
public function onTask($server, $task_id, $src_worker_id, $data)
{
$decode_str = self::decode($data);
secho('on_task', $decode_str);
\mb_parse_str($decode_str, $data);
$call = $data['op'] . '_task';
$message = new GuessMessage($server, $this->from_fd);
if (isset($data['op']) && method_exists($message, $call)) {
return $message->$call($server, $task_id, $src_worker_id, $data['data']);
} else {
secho('error task', sprintf('%s not exist', $call));
return '404';
}
}
public function onMessage($server, $frame)
{
$data = $frame->data;
$this->from_fd = $frame->fd;
$this->server = $server;
secho('msg_in', sprintf('#%d 的%s发来消息为 %s', $this->from_fd, datetime(), $data));
$data_arr = json_decode($data, 1);
$message = new GuessMessage($server, $this->from_fd);
if (!$data_arr) {
$message->error('wrong format!1');
} else {
if (isset($data_arr['op']) && method_exists($message, $data_arr['op'])) {
$call = $data_arr['op'];
unset($data_arr['op']);
Db::startTrans();
try {
$message->$call($server, $frame, $data_arr);
Db::commit();
} catch (\Exception $e) {
$info = $e->getMessage();
if($info == '对手已结束游戏'){
$message->error($info);
}else{
$message->error('server error', ['info'=>$e->getMessage()]);
}
Db::rollback();
echo $e->getMessage() . PHP_EOL;
echo $e->getTraceAsString();
}
} else {
if (isset($data_arr['op'])) {
$message->error('缺少op参数');
} else {
$message->error("{$data_arr['op']}对应的方法不存在");
}
}
}
return '';
}
}
~~~
里面有一些关键函数,在common.php里
~~~
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2016 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: 流年 <liu21st@gmail.com>
// +----------------------------------------------------------------------
// 应用公共文件
if (!function_exists('datetime')) {
// 方便生成当前日期函数
function datetime($str = 'now', $formart = 'Y-m-d H:i:s')
{
return @date($formart, strtotime($str));
}
}
/**
* 是否是mac系统
* @return bool
*/
function isDarwin()
{
if (PHP_OS == 'Darwin') {
return true;
}
return false;
}
function secho($title, $message)
{
ob_start();
if (is_string($message)) {
$message = ltrim($message);
$message = str_replace(PHP_EOL, '', $message);
}
print_r($message);
$content = ob_get_contents();
ob_end_clean();
$content = explode("\n", $content);
$send = "";
foreach ($content as $value) {
if (!empty($value)) {
$echo = "[{$title}] {$value}";
$send = $send . $echo;
echo $send . PHP_EOL;
// ob_end_clean();
}
}
}
if (!function_exists('is_online')) {
// 判断是否线上环境
function is_online()
{
if (PHP_SAPI == 'cli') {
return isset($_SERVER['LOGNAME']) && $_SERVER['LOGNAME'] != 'root';
} else {
return stripos($_SERVER['HTTP_HOST'], '39.108.156.37') !== false;
}
}
}
/**
* @param string $dev
* @return string
*/
function getServerIp($dev = 'eth0')
{
if (isDarwin()) {
return '0.0.0.0';
}
return exec("ip -4 addr show $dev | grep inet | awk '{print $2}' | cut -d / -f 1");
}
if (!function_exists('get_client_ip')) {
/**
* 获取客户端IP地址
* @param int $type 返回类型 0 返回IP地址 1 返回IPV4地址数字
* @param bool $adv 是否进行高级模式获取(有可能被伪装)
* @return mixed
*/
function get_client_ip($type = 0, $adv = false)
{
$type = $type ? 1 : 0;
static $ip = null;
if ($ip !== null) {
return $ip[$type];
}
if ($adv) {
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$arr = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$pos = array_search('unknown', $arr);
if (false !== $pos) {
unset($arr[$pos]);
}
$ip = trim($arr[0]);
} elseif (isset($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (isset($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
} elseif (isset($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
// IP地址合法验证
$long = sprintf("%u", ip2long($ip));
$ip = $long ? array($ip, $long) : array('0.0.0.0', 0);
return $ip[$type];
}
}
if (!function_exists('ptrace')) {
function ptrace($msg, $channel = 'normal')
{
echo var_export($msg, true).PHP_EOL;
return ;
}
}
~~~
### swoole的一些优化
#### 通过InotifyReload 实现的监听文件变化后自动重启worker
~~~
/**
* 架构函数
* @access public
*/
public function init()
{
$pid = $this->getMasterPid();
if (!$this->isRunning($pid)) {
User::offline(0);
$server = $this->swoole;
$process = new \swoole_process(function ($process) use ($server) {
new \console\InotifyReload($server);
file_put_contents('./pid/inotify', $process->pid);
$process->name('inotify');
});
}
$this->swoole->addProcess($process);
}
~~~
init初始化里判断是否运行,没运行就用swoole_process进程类加一个监听的进程。
加了一个 isRunning 方法,参考tp里think\swoole\command\Swoole 类。
InotifyReload 类内容:
~~~
<?php
namespace console;
use think\facade\Env;
class InotifyReload
{
const RELOAD_SIG = 'reload_sig';
// 监控的目录,默认是src
public $monitor_dir;
public $inotifyFd;
public $managePid;
public $server;
public function __construct($server)
{
secho("SYS", "已开启代码热重载");
$this->server = $server;
$root_path = Env::get('root_path');
$this->monitor_dir = realpath($root_path);
$this->cmd_dir = realpath($root_path . '../');
if (!extension_loaded('inotify')) {
\swoole_timer_after(1000, [$this, 'unUseInotify']);
} else {
$this->useInotify();
}
}
public function reload_queue()
{
return;
$cmd = "/usr/bin/php {$this->cmd_dir}/think queue:restart";
secho('RELOAD queue', 'cmd: ' . $cmd);
$output = @shell_exec($cmd);
secho('RELOAD queue result', var_export($output, 1));
}
public function useInotify()
{
global $monitor_files;
// 初始化inotify句柄
$this->inotifyFd = inotify_init();
// 设置为非阻塞
stream_set_blocking($this->inotifyFd, 0);
// 递归遍历目录里面的文件
$dir_iterator = new \RecursiveDirectoryIterator($this->monitor_dir);
$iterator = new \RecursiveIteratorIterator($dir_iterator);
foreach ($iterator as $file) {
// 只监控php文件
if (pathinfo($file, PATHINFO_EXTENSION) != 'php') {
continue;
}
// 把文件加入inotify监控,这里只监控了IN_MODIFY文件更新事件
$wd = inotify_add_watch($this->inotifyFd, $file, IN_MODIFY);
$monitor_files[$wd] = $file;
}
// 监控inotify句柄可读事件
\swoole_event_add($this->inotifyFd, function ($inotify_fd) {
global $monitor_files;
// 读取有哪些文件事件
$events = inotify_read($inotify_fd);
if ($events) {
// 检查哪些文件被更新了
foreach ($events as $ev) {
// 更新的文件
if (!array_key_exists($ev['wd'], $monitor_files)) {
continue;
}
$file = $monitor_files[$ev['wd']];
secho("RELOAD", $file . " update");
unset($monitor_files[$ev['wd']]);
// 需要把文件重新加入监控
if (is_file($file)) {
try {
$wd = inotify_add_watch($inotify_fd, $file, IN_MODIFY);
if (false != $wd) {
$monitor_files[$wd] = $file;
}
} catch (\exception $e) {
}
}
}
$this->reload_queue();
$this->server->reload();
}
}, null, SWOOLE_EVENT_READ);
}
public function unUseInotify()
{
secho("SYS", "非inotify模式,性能极低,不建议在正式环境启用。请安装inotify扩展");
if (isDarwin()) {
secho("SYS", "mac开启auto_reload可能会导致cpu占用过高。");
}
\swoole_timer_tick(1, function () {
global $last_mtime;
// recursive traversal directory
$dir_iterator = new \RecursiveDirectoryIterator($this->monitor_dir);
$iterator = new \RecursiveIteratorIterator($dir_iterator);
foreach ($iterator as $file) {
// only check php files
if (pathinfo($file, PATHINFO_EXTENSION) != 'php') {
continue;
}
if (!isset($last_mtime)) {
$last_mtime = $file->getMTime();
}
// check mtime
if ($last_mtime < $file->getMTime()) {
secho("RELOAD", $file . " update, old_time:{$last_mtime}, new_time:" . $file->getMTime());
//reload
$this->reload_queue();
$this->server->reload();
$last_mtime = $file->getMTime();
break;
}
}
});
}
}
~~~
这个是参考easeswoole里的类。
![](https://box.kancloud.cn/713c2401b8d512bded5cbe4ecee8a919_622x71.png)
> 为了性能 最好 pecl install inotify 装上扩展
为了调试业务重载也生效,把消息处理的一些单独写到一个控制器里了:
~~~
<?php
namespace app\index\controller;
use app\index\model\Room;
use app\index\model\RoomUserCards;
use app\index\model\RoomUsers;
use app\index\model\User;
use think\swoole\Server;
class GuessMessage
{
public $from_fd;
public $server;
public $egg_nickname = ['houmuyu'];
public $egg_name = '侯穆玉';
public function __construct($server, $from_fd)
{
$this->server = $server;
$this->from_fd = $from_fd;
}
public function pretty_json($arr)
{
return json_encode($arr, JSON_UNESCAPED_UNICODE);
}
/**
* 给客户端发消息
*
* @param string $msg
* @param boolean $boardcast
* @return boolean
*/
public function msg_out($msg, $boardcast = false)
{
if ($boardcast) {
secho('msg_out', datetime() . ' boardcast');
foreach ($this->server->getClientList() as $fd) {
$info = $this->server->getClientInfo($fd);
secho('msg_out', sprintf('#%d 的待收消息为 %s', $fd, $msg));
if ($info !== false && isset($info['websocket_status'])) {
$this->server->push($fd, $msg);
} else {
User::offline_fd($fd);
secho('msg_out', sprintf('#%d客户端离线', $fd));
}
}
return true;
} else {
$fd = $this->from_fd;
secho('msg_out', datetime() . ' single');
secho('msg_out', sprintf('#%d 的待收消息为 %s', $fd, $msg));
$info = $this->server->getClientInfo($fd);
if ($info !== false && isset($info['websocket_status'])) {
return $this->server->push($fd, $msg);
} else {
secho('msg_out', sprintf('#%d客户端离线', $fd));
User::offline_fd($fd);
}
return true;
}
}
public function success($msg, $data = [], $boardcast = false)
{
$msg = $this->pretty_json(['code' => 1, 'msg' => $msg, 'data' => $data]);
return $this->msg_out($msg, $boardcast);
}
public function error($msg, $data = [], $boardcast = false)
{
$msg = $this->pretty_json(['code' => 0, 'msg' => $msg, 'data' => $data]);
return $this->msg_out($msg, $boardcast);
}
/**
* 注册用户
* {"op":"reg_user", "nickname":"杨维杰"}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function reg_user($server, $frame, $data)
{
extract($data);
if ($exist = User::where('nickname', $nickname)->find()) {
if ($exist['online'] == 0) {
$exist->online = 1;
$exist->online_time = datetime();
$exist->fd = $frame->fd;
$exist->save();
$this->success('after_reg_user', ['uid' => $exist['id'], 'fd'=>$frame->fd, 'nickname'=>$nickname]);
} else {
$this->error('nickname重复');
}
} else {
$ret = User::create([
'nickname' => $nickname,
'fd' => $frame->fd,
'online_time' => datetime(),
]);
$this->success('after_reg_user', ['uid' => $ret->id, 'fd'=>$frame->fd, 'nickname'=>$nickname]);
}
}
/**
* 创建房间
* {"op":"create_room", "name":"room1", "hours":1, "number":2, "uid":1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function create_room($server, $frame, $data)
{
extract($data);
$exist = Room::where('name', $name)->find();
if ($exist) {
$this->error('房间名称重复');
} else {
$ret = Room::create([
'name' => $name,
'last_hours' => $hours,
]);
$room_id = $ret->id;
RoomUsers::assign($room_id, $uid, $number);
$this->success('after_create_room', ['room_id' => $room_id]);
}
}
/**
* 加入房间
* {"op":"join_game", "room_id":"1", "uid":1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function join_game($server, $frame, $data)
{
extract($data);
$exist = Room::get($room_id);
if (!$exist) {
$this->error('房间不存在');
} else {
if (RoomUsers::left_human($room_id)) {
$join = RoomUsers::join_human($room_id, $uid);
if ($join) {
$this->success(SUCCESS_MSG);
} else {
goto full;
}
} else {
full:
$this->error('房间已满');
}
}
}
/**
* 开始
* {"op":"begin", "room_id":"1", "uid":1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function begin($server, $frame, $data)
{
extract($data);
$exist = Room::get($room_id);
Room::where('id', $room_id)->update(['finish_time' => datetime("+{$exist->last_hours} hours")]);
$compete_uid = RoomUsers::get_compete_uid($room_id, $uid);
if ($compete_uid) {
RoomUsers::where('room_id', $room_id)
->where('uid', 'in', [$uid, $compete_uid])
->update(['gaming' => 1]);
} else {
$this->error('没有可比赛的对手');
}
$this->success('after_begin', [
'user_name' => User::where('id', $uid)->value('nickname'),
'compete_name' => User::where('id', $compete_uid)->value('nickname'),
'compete_uid' => $compete_uid
]);
}
/**
* 进入房间
* {"op":"enter_room", "room_id": 1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function enter_room($server, $frame, $data){
extract($data);
$count_down_cards = RoomUserCards::count_down_cards($data['room_id']);
$this->success('after_enter_room', [
'room_id' => $room_id,
'list' => RoomUsers::all(['room_id' => $room_id]),
'user_name' => User::where('id', $uid)->value('nickname'),
'compete_name' => User::where('id', $compete_uid)->value('nickname'),
'count_down_cards' => $count_down_cards,
'石头' => RoomUserCards::left_card('石头', $room_id, $uid),
'剪刀' => RoomUserCards::left_card('剪刀', $room_id, $uid),
'布' => RoomUserCards::left_card('布', $room_id, $uid)
]);
}
/**
* 测试任务
* {"op":"test_task"}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function test_task($server, $frame, $data)
{
extract($data);
// $data['op'] = 'testin';
// $data['data'] = ['fd' => $this->from_fd];
// $server->task(Guess::encode($data));
$this->success('after_test_task2' . Guess::encode($data));
}
public function testin_task($server, $task_id, $src_worker_id, $data)
{
$this->from_fd = $data['fd'];
$this->success('in task ' . Guess::encode($data));
echo 'in task';
}
/**
* 猜
* {"op":"do_guess", "room_id":"1", "uid":1, "compete_uid": 3, "type": "石头", "used": 1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function do_guess($server, $frame, $data)
{
extract($data);
$ret = RoomUserCards::create([
'uid' => $uid,
'room_id' => $room_id,
'compete_uid' => $compete_uid,
'type' => $type,
'used' => 0,
'used_order' => RoomUserCards::where('room_id', $room_id)->where('uid', $uid)->where('used', 1)->count() + 1,
'use_time' => datetime(),
]);
// 轮询
// 数据上报
$data = $ret->toArray();
$this->judge($data);
// 更新from 出牌记录
// 更新对手出牌记录
}
public function judge($data)
{
echo print_r($data, true);
$compaire_card = RoomUserCards::get_compare_cards($data);
// ptrace('compaire_card');
// ptrace($compaire_card);
if ($compaire_card) {
$result = RoomUserCards::judge($data, $compaire_card);
ptrace($result);
switch ($result) {
case 'win':
$ret_1 = RoomUsers::win($data['room_id'], $data['uid'], $data['compete_uid']);
break;
case 'draw':
$ret_1 = RoomUsers::draw($data['room_id'], $data['uid'], $data['compete_uid']);
break;
case 'lose':
$ret_1 = RoomUsers::lose($data['room_id'], $data['uid'], $data['compete_uid']);
break;
default:
break;
}
ptrace($ret_1);
RoomUserCards::where('room_id', $data['room_id'])
->where('uid', $data['uid'])
->where('compete_uid', $data['compete_uid'])
->where('used_order', $data['used_order'])
->update(['used' => 1, 'use_time' => datetime()]);
RoomUserCards::where('room_id', $compaire_card['room_id'])
->where('uid', $compaire_card['uid'])
->where('compete_uid', $compaire_card['compete_uid'])
->where('used_order', $compaire_card['used_order'])
->update(['used' => 1, 'use_time' => datetime()]);
$left_cards = [
'石头' => RoomUserCards::left_card('石头', $data['room_id'], $data['uid']),
'剪刀' => RoomUserCards::left_card('剪刀', $data['room_id'], $data['uid']),
'布' => RoomUserCards::left_card('布', $data['room_id'], $data['uid'])
];
$this->success('after_do_guess', ['result' => $result,'compete_type'=>$compaire_card['type'], 'left_cards'=>$left_cards]);
$count_down_cards = RoomUserCards::count_down_cards($data['room_id']);
$this->success('room_user_list', [
'room_id' => $data['room_id'],
'list' => RoomUsers::all(['room_id' => $data['room_id']]),
'count_down_cards' => $count_down_cards,
], true);
if ($ret_1 == 'win') {
$this->notify_win($data['uid'], $data['room_id']);
$this->notify_lose($data['compete_uid'], $data['room_id']);
$this->success('notify', ['info'=>sprintf('%s round %d %s赢了%s', datetime(),
$data['room_id'], User::where('id', $data['uid'])->value('nickname'), User::where('id', $data['compete_uid'])->value('nickname'))], true);
} elseif ($ret_1 == 'lose') {
$this->success('notify', ['info'=>sprintf('%s round %d %s赢了%s', datetime(), $data['room_id'], User::where('id', $data['compete_uid'])->value('nickname'), User::where('id', $data['uid'])->value('nickname'))], true);
$this->notify_lose($data['uid'], $data['room_id']);
$this->notify_win($data['compete_uid'], $data['room_id']);
} else {
if($count_down_cards == ['石头'=>0,'剪刀'=>0, '布'=>0]){
$this->notify_draw($data['uid'], $data['room_id']);
$this->notify_draw($data['compete_uid'], $data['room_id']);
$this->success('notify', ['info'=>sprintf('%s round %d %s和%s打平', datetime(), $data['room_id'], User::where('id', $data['uid'])->value('nickname'), User::where('id', $data['compete_uid'])->value('nickname'))], true);
}
}
} else {
// ptrace('else compaire_card');
// ptrace($compaire_card);
// ptrace(User::where('id', $data['compete_uid'])->value('type'));
// ptrace(false !== $compaire_card && User::where('id', $data['compete_uid'])->value('type') == 'ai');
if (false !== $compaire_card && User::where('id', $data['compete_uid'])->value('type') == 'ai') {
$card = RoomUserCards::get_random_card($data['room_id'], $data['compete_uid']);
// ptrace('random_user_cards');
// ptrace($card);
if ($card) {
$ret = RoomUserCards::create([
'room_id' => $data['room_id'],
'uid' => $data['compete_uid'],
'compete_uid' => $data['uid'],
'type' => $card['type'],
'used' => 0,
'used_order' => $card['used_order'],
'use_time' => null,
]);
secho('task', 'ai出牌后再次判断');
$this->judge($data);
} else {
$this->error('获取ai的下张牌失败');
}
}else{
if($compaire_card === false){
exception('对手已结束游戏');
$this->error('对手已经结束游戏');
}
}
}
}
public function notify_win($uid, $room_id)
{
$fd = User::where('id', $uid)->value('fd');
if ($fd) {
$egg_name = in_array(User::where('id', $uid)->value('nickname'), $this->egg_nickname)?$this->egg_name:'';
$ret = ['code' => 1, 'msg' => 'win', 'data'=>['info' => sprintf('You won at room %d, %s', $room_id, datetime()), 'room_id'=>$room_id, 'egg_name'=>$egg_name]];
$this->server->push($fd, $this->pretty_json($ret));
}
}
public function notify_draw($uid, $room_id){
$fd = User::where('id', $uid)->value('fd');
if ($fd) {
$egg_name = in_array(User::where('id', $uid)->value('nickname'), $this->egg_nickname)?$this->egg_name:'';
$ret = ['code' => 1, 'msg' => 'draw', 'data'=>['info' => sprintf('You draw at room %d, %s', $room_id, datetime()), 'room_id'=>$room_id, 'egg_name'=>$egg_name]];
$this->server->push($fd, $this->pretty_json($ret));
}
}
public function notify_lose($uid, $room_id)
{
$fd = User::where('id', $uid)->value('fd');
if ($fd) {
$egg_name = in_array(User::where('id', $uid)->value('nickname'), $this->egg_nickname)?$this->egg_name:'';
$ret = ['code' => 1, 'msg' => 'lose', 'data'=>['info' => sprintf('You lose at room %d, %s', $room_id, datetime()), 'room_id'=>$room_id, 'egg_name'=>$egg_name]];
$this->server->push($fd, $this->pretty_json($ret));
}
}
}
~~~
#### 整体架构
Guess类,实现onMessage、onClose 方法,onMessage里通过固定参数op来定位到GuessMessage类里的一个方法。
GuessageMessage 类里,先定义了 $server、$from_fd 用于以后方便回送消息给客户端。然后构造方法里传入。
辅助方法 pretty_json
~~~
public function pretty_json($arr)
{
return json_encode($arr, JSON_UNESCAPED_UNICODE);
}
~~~
msg_out(实现了广播):
~~~
/**
* 给客户端发消息
*
* @param string $msg
* @param boolean $boardcast
* @return boolean
*/
public function msg_out($msg, $boardcast = false)
{
if ($boardcast) {
secho('msg_out', datetime() . ' boardcast');
foreach ($this->server->getClientList() as $fd) {
$info = $this->server->getClientInfo($fd);
secho('msg_out', sprintf('#%d 的待收消息为 %s', $fd, $msg));
if ($info !== false && isset($info['websocket_status'])) {
$this->server->push($fd, $msg);
} else {
User::offline_fd($fd);
secho('msg_out', sprintf('#%d客户端离线', $fd));
}
}
return true;
} else {
$fd = $this->from_fd;
secho('msg_out', datetime() . ' single');
secho('msg_out', sprintf('#%d 的待收消息为 %s', $fd, $msg));
$info = $this->server->getClientInfo($fd);
if ($info !== false && isset($info['websocket_status'])) {
return $this->server->push($fd, $msg);
} else {
secho('msg_out', sprintf('#%d客户端离线', $fd));
User::offline_fd($fd);
}
return true;
}
}
~~~
覆写了success 和error方法:
~~~
public function success($msg, $data = [], $boardcast = false)
{
$msg = $this->pretty_json(['code' => 1, 'msg' => $msg, 'data' => $data]);
return $this->msg_out($msg, $boardcast);
}
public function error($msg, $data = [], $boardcast = false)
{
$msg = $this->pretty_json(['code' => 0, 'msg' => $msg, 'data' => $data]);
return $this->msg_out($msg, $boardcast);
}
~~~
然后就是处理各种入消息和出消息。
以注册用户(登录)为例:
~~~
/**
* 注册用户
* {"op":"reg_user", "nickname":"杨维杰"}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function reg_user($server, $frame, $data)
{
extract($data);
if ($exist = User::where('nickname', $nickname)->find()) {
if ($exist['online'] == 0) {
$exist->online = 1;
$exist->online_time = datetime();
$exist->fd = $frame->fd;
$exist->save();
$this->success('after_reg_user', ['uid' => $exist['id'], 'fd'=>$frame->fd, 'nickname'=>$nickname]);
} else {
$this->error('nickname重复');
}
} else {
$ret = User::create([
'nickname' => $nickname,
'fd' => $frame->fd,
'online_time' => datetime(),
]);
$this->success('after_reg_user', ['uid' => $ret->id, 'fd'=>$frame->fd, 'nickname'=>$nickname]);
}
}
~~~
传入的消息格式为json` {"op":"reg_user", "nickname":"杨维杰"}`,通过extract 来转换为对应变量,省着赋值各种变量。通过对入消息的处理,判断是否报错还是成功返回消息。用户离线了登录后更新为在线状态,否则一个客户端在线了,不允许同名用户再登录。一个user只对应一个fd。
##### 关于在线、离线
fd在断线重连后会自增,只有正确的fd才能正常发消息通知成功。为此websocket 必须自己实现一个登录消息,来绑定user 和fd 的关系。然后onClose和发消息时检测断开链接里清空绑定。
User模型里清空某个fd的方法:
~~~
public static function offline_fd($fd)
{
return self::where('fd', $fd)->update(['fd' => 0, 'online' => 0, 'offline_time' => datetime()]);
}
~~~
###### onClose
~~~
public function onClose($server, $fd)
{
User::offline_fd($fd);
echo "client {$fd} closed {$time}\n";
}
~~~
###### 发消息检测离线后清空fd
~~~
if ($info !== false && isset($info['websocket_status'])) {
$this->server->push($fd, $msg);
} else {
User::offline_fd($fd);
secho('msg_out', sprintf('#%d客户端离线', $fd));
}
~~~
###### ctrl+c 无法触发close 的处理
~~~
/**
* 架构函数
* @access public
*/
public function init()
{
$pid = $this->getMasterPid();
if (!$this->isRunning($pid)) {
User::offline(0);
~~~
init 没启动进程分支里,直接将所有fd置空,因为进入这里说明重启服务了fd全失效了。
##### 游戏流程
![](https://box.kancloud.cn/1337d053f8130bfcaad14471ff28048e_864x784.png)
##### 核心算法
###### 创建房间后的自动分配玩家
在 create_room 方法里:
`RoomUsers::assign($room_id, $uid, $number);`
我们看RoomUsers 里的assign 方法:
~~~
public static function assign($room_id, $uid, $number)
{
if ($number < 2) {
exception("房间里至少2个人");
}
self::create([
'uid' => $uid,
'type' => 'human',
'room_id' => $room_id,
'stars' => 3,
]);
$loop_num = $number - 1;
$faker = Factory::create('zh_CN');
for ($i = 0; $i < $loop_num; $i++) {
$nickname = $faker->name;
$uid = User::where('nickname', $nickname)->value('id');
if (!$uid) {
$ret = User::create([
'nickname' => $nickname,
'fd' => 0,
'type' => 'ai',
'online_time' => datetime(),
]);
$uid = $ret->id;
}
self::create([
'room_id' => $room_id,
'uid' => $uid,
'type' => 'ai',
'stars' => 3,
]);
}
return true;
}
~~~
其实就是先创建自己在这个房间里的用户,然后随机生成昵称,看用户表里是否有,没有就创建用户记录(type=ai 表示机器人)后拿到新的uid后 插入RoomUsers表。
begin里找到可用户的玩家后,将两个玩家RoomUser里gaming 状态标记为1。
后面预留了join_room 方法,同于替换ai为真人出牌。
###### 出牌后的自动出牌算法
~~~
/**
* 猜
* {"op":"do_guess", "room_id":"1", "uid":1, "compete_uid": 3, "type": "石头", "used": 1}
*
* @param object $server
* @param object $frame
* @param array $data
* @return void
*/
public function do_guess($server, $frame, $data)
{
extract($data);
$ret = RoomUserCards::create([
'uid' => $uid,
'room_id' => $room_id,
'compete_uid' => $compete_uid,
'type' => $type,
'used' => 0,
'used_order' => RoomUserCards::where('room_id', $room_id)->where('uid', $uid)->where('used', 1)->count() + 1,
'use_time' => datetime(),
]);
// 轮询
// 数据上报
$data = $ret->toArray();
$this->judge($data);
// 更新from 出牌记录
// 更新对手出牌记录
}
public function judge($data)
{
echo print_r($data, true);
$compaire_card = RoomUserCards::get_compare_cards($data);
// ptrace('compaire_card');
// ptrace($compaire_card);
if ($compaire_card) {
$result = RoomUserCards::judge($data, $compaire_card);
ptrace($result);
switch ($result) {
case 'win':
$ret_1 = RoomUsers::win($data['room_id'], $data['uid'], $data['compete_uid']);
break;
case 'draw':
$ret_1 = RoomUsers::draw($data['room_id'], $data['uid'], $data['compete_uid']);
break;
case 'lose':
$ret_1 = RoomUsers::lose($data['room_id'], $data['uid'], $data['compete_uid']);
break;
default:
break;
}
ptrace($ret_1);
RoomUserCards::where('room_id', $data['room_id'])
->where('uid', $data['uid'])
->where('compete_uid', $data['compete_uid'])
->where('used_order', $data['used_order'])
->update(['used' => 1, 'use_time' => datetime()]);
RoomUserCards::where('room_id', $compaire_card['room_id'])
->where('uid', $compaire_card['uid'])
->where('compete_uid', $compaire_card['compete_uid'])
->where('used_order', $compaire_card['used_order'])
->update(['used' => 1, 'use_time' => datetime()]);
$left_cards = [
'石头' => RoomUserCards::left_card('石头', $data['room_id'], $data['uid']),
'剪刀' => RoomUserCards::left_card('剪刀', $data['room_id'], $data['uid']),
'布' => RoomUserCards::left_card('布', $data['room_id'], $data['uid'])
];
$this->success('after_do_guess', ['result' => $result,'compete_type'=>$compaire_card['type'], 'left_cards'=>$left_cards]);
$count_down_cards = RoomUserCards::count_down_cards($data['room_id']);
$this->success('room_user_list', [
'room_id' => $data['room_id'],
'list' => RoomUsers::all(['room_id' => $data['room_id']]),
'count_down_cards' => $count_down_cards,
], true);
if ($ret_1 == 'win') {
$this->notify_win($data['uid'], $data['room_id']);
$this->notify_lose($data['compete_uid'], $data['room_id']);
$this->success('notify', ['info'=>sprintf('%s round %d %s赢了%s', datetime(),
$data['room_id'], User::where('id', $data['uid'])->value('nickname'), User::where('id', $data['compete_uid'])->value('nickname'))], true);
} elseif ($ret_1 == 'lose') {
$this->success('notify', ['info'=>sprintf('%s round %d %s赢了%s', datetime(), $data['room_id'], User::where('id', $data['compete_uid'])->value('nickname'), User::where('id', $data['uid'])->value('nickname'))], true);
$this->notify_lose($data['uid'], $data['room_id']);
$this->notify_win($data['compete_uid'], $data['room_id']);
} else {
if($count_down_cards == ['石头'=>0,'剪刀'=>0, '布'=>0]){
$this->notify_draw($data['uid'], $data['room_id']);
$this->notify_draw($data['compete_uid'], $data['room_id']);
$this->success('notify', ['info'=>sprintf('%s round %d %s和%s打平', datetime(), $data['room_id'], User::where('id', $data['uid'])->value('nickname'), User::where('id', $data['compete_uid'])->value('nickname'))], true);
}
}
} else {
// ptrace('else compaire_card');
// ptrace($compaire_card);
// ptrace(User::where('id', $data['compete_uid'])->value('type'));
// ptrace(false !== $compaire_card && User::where('id', $data['compete_uid'])->value('type') == 'ai');
if (false !== $compaire_card && User::where('id', $data['compete_uid'])->value('type') == 'ai') {
$card = RoomUserCards::get_random_card($data['room_id'], $data['compete_uid']);
// ptrace('random_user_cards');
// ptrace($card);
if ($card) {
$ret = RoomUserCards::create([
'room_id' => $data['room_id'],
'uid' => $data['compete_uid'],
'compete_uid' => $data['uid'],
'type' => $card['type'],
'used' => 0,
'used_order' => $card['used_order'],
'use_time' => null,
]);
secho('task', 'ai出牌后再次判断');
$this->judge($data);
} else {
$this->error('获取ai的下张牌失败');
}
}else{
if($compaire_card === false){
exception('对手已结束游戏');
$this->error('对手已经结束游戏');
}
}
}
}
~~~
**do_guess** 里就是用户选一张牌后,我先记录下来,主要逻辑在**judge**里。
首先获取对手的牌,就是找对手牌库里维使用的牌。获取不到的话,判断对手是ai 的话,从所有12张牌里排除已出过的牌,随机取一张。
1. 获取对手的下张牌
RoomUserCards::get_compare_cards
~~~
public static function get_compare_cards($data)
{
$compete_user = RoomUsers::where('uid', $data['compete_uid'])->where('room_id', $data['room_id'])->find();
if ($compete_user['status'] != RoomUsers::$UNKNOWN) {
return false;
}
$card = self::where('compete_uid', $data['uid'])
->where('room_id', $data['room_id'])
->where('uid', $data['compete_uid'])
->where('used', 0)
->order('used_order DESC')
->find() ?: [];
return $card;
}
~~~
比赛结束就返回false,找不到返回[]
2. 获取ai机器人的随机未用牌
RoomUserCard::get_random_card
~~~
public static function get_random_card($room_id, $uid)
{
$all = ['石头', '剪刀', '布', '石头', '剪刀', '布', '石头', '剪刀', '布', '石头', '剪刀', '布'];
$used_cards = self::where('room_id', $room_id)->where('uid', $uid)->where('used', 1)->column('type') ?: [];
// ptrace('used_cards');
// ptrace($used_cards);
$left_cards = $all;
if ($used_cards) {
foreach ($used_cards as $card) {
$index = array_search($card, $left_cards);
if ($index !== false) {
unset($left_cards[$index]);
}
}
}
// ptrace('left_cards');
// ptrace($left_cards);
if ($left_cards) {
$next_order = self::where('room_id', $room_id)->where('uid', $uid)->where('used', 1)->count() + 1;
shuffle($left_cards);
// ptrace('after_shuffle');
// ptrace($left_cards);
return array_merge([
'type' => $left_cards[0],
'room_id' => $room_id,
], ['used_order' => $next_order]);
}
return [];
}
~~~
然后judge里 判断两张牌的输赢,后将两个牌标记为已使用,并根据情况转移star
`$result = RoomUserCards::judge($data, $compaire_card);`
每次判定结束后,再判断同房间内用户的输赢,看某个人的stars是否为0 ,或者牌全出完了判断是否平局。
发现有胜负后,通知原房间和比赛的人胜负消息。 也就 notify_draw、notify_win 和notify_lose 三个方法。
当然每次判定后,返回最新的房间内卡牌剩余计数。
具体见源码。
至此后端猜拳消息全部结束。
##### onMessage 里的事务 和不使用task的原因
开始测试的时候由于代码错误多,总是出现脏数据,干脆写了try catch db 事务,出问题全部回滚。
开始judge 部分是通过task 实现的,但是task通信是跨进程的,from_fd 获取不到。只能同过产生task时传到数据里。
且无法在task出问题的时候 回滚事务。所以舍弃了。顺便说一下 task的返回在finish里可以接收到。