ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[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里可以接收到。