基于Qt的泰拉开服器-TmodServerHelperPlus 开发背景 我开有长期游戏服务器,初衷就是看到一些学生党和工作党闲余时间想一起联机,但一般的游戏联机都比较麻烦,因此想提供一个供大家游玩的服务器,起初是由我室友提供的资金,后来是群友集资,我来作为代理腐竹维护服务器,目前开有 Minecraft 和 Terraria。
可以说目前主流的联机游戏,我几乎都开过,但是时间久了,就会觉得很多不方便之处:
有些游戏的服务端开服步骤麻烦,要选择一些不适用的选项,比如是否启用steam服务器,我自己都开服了还选他干嘛捏,所以我需要能自动一键开服的功能。
游戏的服务端时常因为模组的问题崩溃,而我也不可能时时刻刻守在一边,虽然可以手机远程连接桌面进行重启,但也不是时刻都有时间,所以需要崩溃自动重启的功能。
大部分服务端都要同时开模组,模组是原版游戏的拓展,可以大大丰富游戏的内容,而模组也提供了可以配置的文件,一般都是json格式,对我来说一个一个打开配置文件修改保存过于繁琐,希望能像excel修改表格一样清晰易操作。
开服也需要定义生成世界的各种环境,因此需要快捷的定义功能。
作为试做,我以Terraria的Tmodoader Server服务端为目标,针对该服务端开发了一款泰拉瑞亚服务端运维桌面平台。
需求分析 分析 核心是,解决开发背景里提到的4个核心问题。
需求 开发泰拉服务器服务端运维平台
实现服务端的一键启动、停止、强制停止、重启功能。 实现服务端的崩溃自动重启功能。 实现服务端配置便捷修改功能。 技术选型 由于是适用于Windows端的运维平台,且关乎服务端运行,因此选择占用尽可能小、效率尽可能高的技术,Qt + C++完美满足了上述需求。
Qt(开发UI)+ C++
软件界面设计 软件界面采用 Qt Designer 设计。 图标采用 Photoshop(PS)设计。
Logo设计 共设计设计了两版logo,第一版是这样子的:
但是最终没有采用,因为软件UI是上面这样的,两者风格不搭,一个现代化风格的logo配上陈年老酿GUI,想想还是算了,于是又重新设计一版:
这样两个复古风格的就能玩到一块去了。
至于为什么要设计成这种类齿轮的样式呢,请容我娓娓道来:
首先这个软件是专门为 tModLoader 服务端做适配的(PS:后面为了方便简称 Tmod),那么 Tmod 的图标是长这样的:
看到没,它的图标就是个齿轮,意思也很容易猜,因为 Tmod 是 Terraria (泰拉瑞亚)这款游戏的模组加载器,只要你玩模组,必须通过 Tmod,这个齿轮的意思显然就是对 Terraria 的修改拓展的意思,而我设计的 logo,外层是齿轮,内部有个手摇曲柄,手摇曲柄可以给齿轮施加外力,让它转得更快。这不就对应着开发这款软件的目的嘛——高效管理 Tmod 服务端!
软件界面设计 主界面 该界面包含了管理一个服务端的基础功能,启动、关闭、重启、崩溃自动重启。
左侧终端信息可以显示服务端输出,方便查看服务器状态。下面可以显示当前所开的存档世界的基础信息,旁边是显示当前服务器里的玩家。
右边,指定tmod目录按钮是为了获取到tmod目录,下面是当前tmod检测状态,没检测到服务端程序就会显示红色,再下面就是服务端基础管理功能了。
服务端配置界面 这是开服配置界面,正常情况下,用服务端直接开服的话,这下面的选项要一个一个选,还是在控制台窗口,有多麻烦可想而知。
模组配置界面 用于快捷修改模组的配置。
核心功能及难点实现 启动服务器 1 2 3 4 5 6 7 8 9 10 11 12 void TmodServerHelperPlus::OnStartServerButtonClicked () { ui.logPlainText->clear (); OnStopServerButtonClicked (); QString box = tmodServerPath + "/LaunchUtils/busybox64.exe" ; QStringList arguments; arguments << "sh" << tmodServerPath + "/start-tModLoaderServer.sh" << "-nosteam" ; tmodServerProcess->start (box, arguments, QProcess::Unbuffered | QIODevice::ReadOnly); active = true ; }
停止服务器 1 2 3 4 5 6 7 void TmodServerHelperPlus::OnStopServerButtonClicked () { active = false ; tmodServerProcess->kill (); KillCustomProcess (tmodServerChildProcessName); }
强制关闭服务器 1 2 3 4 5 6 7 8 9 void TmodServerHelperPlus::OnForceStopServerButtonClicked () { active = false ; tmodServerProcess->kill (); KillCustomProcess (tmodServerChildProcessName); ui.logPlainText->appendPlainText ("已强制终止服务器" ); }
强制关闭指定进程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 void TmodServerHelperPlus::KillCustomProcess (QString processName) { HANDLE hProcessSnap; PROCESSENTRY32 pe32; ui.logPlainText->appendPlainText ("开始查杀 " + processName + " 进程" ); hProcessSnap = CreateToolhelp32Snapshot (TH32CS_SNAPPROCESS, 0 ); if (hProcessSnap == INVALID_HANDLE_VALUE) { ui.logPlainText->appendPlainText ("获取系统进程快照失败" ); return ; } pe32.dwSize = sizeof (PROCESSENTRY32); if (!Process32First (hProcessSnap, &pe32)) { ui.logPlainText->appendPlainText ("无法检索第一个进程" ); CloseHandle (hProcessSnap); return ; } do { QString exeFile = QString::fromWCharArray (pe32.szExeFile); if (exeFile.compare (processName, Qt::CaseInsensitive) == 0 ) { HANDLE hProcess = OpenProcess (PROCESS_TERMINATE, FALSE, pe32.th32ProcessID); if (hProcess != NULL ) { if (TerminateProcess (hProcess, 0 )) { ui.logPlainText->appendPlainText ("已终止进程 : " + exeFile); } else { ui.logPlainText->appendPlainText ("终止进程失败 : " + exeFile); } CloseHandle (hProcess); } else { ui.logPlainText->appendPlainText ("打开进程失败" ); } break ; } } while (Process32Next (hProcessSnap, &pe32)); }
监控输出 & 监控进程 这段是为了实现获取玩家在线情况以及崩溃重启,原理是监控每一句输出,检测到关键词就记录,比如玩家进入游戏会有“playerID has joined”,那么检测“has joined”并且记录前面的玩家id,这就意味着该玩家进入了服务器,同理“playerID has disconnected”就是玩家退出。同理崩溃的时候会有相关崩溃信息,检测到以后就执行自动重启(关闭再启动)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 void TmodServerHelperPlus::Slot_ReadyReadOutput () { if (ui.logPlainText->blockCount () > 300 ) { ui.logPlainText->clear (); } QByteArray output = tmodServerProcess->readAllStandardOutput (); QString outputString (output) ; QStringList outputStringList = outputString.split ("\n" ); bool isCrash = false ; for (QString crashString : crashStringList) { if (outputString.contains (crashString)) { isCrash = true ; break ; } } QFont font; font.setFamily ("微软雅黑" ); font.setPointSize (10 ); for (QString s : outputStringList) { QString info = s.trimmed (); if (!info.isEmpty ()) { ui.logPlainText->appendPlainText (info); if (info.contains ("is connecting" )) { playerIP = (info.split (" " ))[0 ]; } if (info.contains ("has joined" )) { playerSteamID = (info.split (" " ))[0 ]; if (!playerIP.isEmpty () && !playerSteamID.isEmpty ()) { QTableWidgetItem* ip = new QTableWidgetItem (playerIP); QTableWidgetItem* id = new QTableWidgetItem (playerSteamID); ip->setFont (font); id->setFont (font); ip->setTextAlignment (Qt::AlignCenter); id->setTextAlignment (Qt::AlignCenter); int rowCount = ui.currentPlayersTableWidget->rowCount (); ui.currentPlayersTableWidget->insertRow (rowCount); ui.currentPlayersTableWidget->setItem (rowCount, 0 , ip); ui.currentPlayersTableWidget->setItem (rowCount, 1 , id); playerIP = "" ; playerSteamID = "" ; } } else if (info.contains ("has left" )) { int rowCount = ui.currentPlayersTableWidget->rowCount (); for (int i = 0 ; i < rowCount; ++i) { QTableWidgetItem* item = ui.currentPlayersTableWidget->item (i, 1 ); if (item) { if (item->text () == (info.split (" " ))[0 ]) { ui.currentPlayersTableWidget->removeRow (i); } } } } } } if (isCrash) { Slot_RestartServer (); return ; } }
效益 极大方便腐竹运维游戏服务端。
项目演示