项目地址: https://github.com/JustDoIt0910/TankTrouble
Server地址:https://github.com/JustDoIt0910/TankTroubleServer
TankTrouble(坦克动荡) 是一款很有意思的小游戏,我是前段时间刷b站看到有人剪辑的精彩操作才知道的这个游戏。
规则很简单,发射炮弹击中对方,同时躲避炮弹,炮弹可以在墙壁反弹,每发炮弹反弹一定次数就会消失。每名玩家同一时刻最多有5发炮弹在场上(只有当先前的炮弹消失后才能继续开火)。无论被谁的炮弹击中都会死亡。
我用c++17和gtkmm 3.0 实现了这个游戏,支持单机模式和多人online。在单机模式下,你的对手是一个躲闪和攻击技能都很强的AI,在online模式下,可以建立多人房间,和好友联机对战。
这里有完整演示 demo
(注: 我的实现是linux版本,在ubuntu 18 和 22.04上测试过,ubuntu 22.04 自带gtkmm-3.0, 不必额外安装,否则需要安装gtkmm-3.0)
apt-get install libgtkmm-3.0-dev
下面是单机模式下效果片段(黑色是人机,红色是玩家)
这一篇先说说整个PC端的设计与单机模式的实现,下篇再说online模式和服务器的实现
一、 项目结构
- controller ------------------ 可以理解为后端,负责游戏逻辑,数据更新
- LocalController.h LocalController.cc --------------- 单机模式下的controller, 负责游戏所有逻辑
- OnlineController.h OnlineController.cc ----------- 联网模式下的controller, 负责和服务器交互,更新数据
- ev ----------------------------- 参考muduo实现的极简事件驱动网络库,游戏逻辑在单独线程中驱动,独立于gui线程。联网模式下还要负责网络通信
- event ---------------------------------- 将游戏中的操作封装成事件,方便融合进事件驱动模型
- protocol ------------------------------ 通信协议部分,只在联网模式下用到
- smithAI -------------------------------- 人机的所有逻辑,包括危险躲避,索敌,攻击等
- util -------------------------------------- 主要是数学工具,包含游戏中用到的几何、向量计算、碰撞检测等
- view ------------------------------------ 类比前端,所有gui界面
- Controller.h Controller.cc------- LocalController和OnlineController的基类
- Maze.h Maze.cc -------------------- 地图生成算法
- Object.h Object.cc ---------------- 游戏中对象的多态基类
- Tank.h Tank.cc --------------------- 坦克对象,继承Object
- Shell.h Shell.cc --------------------- 炮弹对象,继承Object
- Block.h Block.cc ------------------- 墙,不继承Object,根据地图生成,单独管理
- Window.h Window.cc ----------- gui主类,管理所有views
- defs.h --------------------------------- 游戏中一些宏定义
项目结构大致可以分为两层,Window和其管理的所有View,是gui部分,负责与用户交互,渲染游戏画面等等,这部分运行在gtkmm的gui主线程中。另一部分是控制层,负责单机和online的游戏逻辑,这部分运行在单独的control thread,由一个事件循环驱动(control loop)。
之所以把Controller放到独立于gui主线程的另一个线程中,由我自己的事件驱动库驱动,主要有几个考虑
- 符合线程的单一职责原则
- gui线程中不应该执行相对耗时的计算,单机模式中人机的计算量还是比较大的,都塞进gui线程中会降低界面响应,online模式更不用说了,gui线程处理网络通信肯定是不合适的。
- 可以让view和controller完全解耦,view层不需要知道当前是LocalController还是OnlineController在向它提供数据,它的职责仅仅是拿到数据,绘制,有键盘事件。向controller报告,view层的代码不需要为不同Controller作修改。
所以LocalController和OnlineController都继承Controller,对外暴露统一的接口。区别在于游戏对象是自己管理还是从服务器拿的而已。
二、游戏对象和地图的实现
1. 游戏对象定义
对象类图如下,Object是Tank和Shell的虚基类,定义了共有的数据成员,包括当前位置 posInfo,下一步位置 nextPos,
颜色 color,对象id,以及移动状态movingStatus。Tank独有的数据成员是它的四个顶点坐标,还有剩余炮弹数,Shell的 ttl 是生命期,每反弹一次减一,减到0就会消失,tankId 是发射这颗炮弹的坦克的id。
一个游戏对象的位置信息用 PosInfo 结构体表示
struct PosInfo
{
PosInfo(const util::Vec& p, double a): pos(p), angle(a){}
PosInfo& operator=(const PosInfo& info) = default;
bool operator==(const PosInfo& info) const {return (pos == info.pos && angle == info.angle);}
PosInfo(): PosInfo(util::Vec(0.0, 0.0), 0){}
static PosInfo invalid() {return PosInfo{util::Vec(DBL_MAX, DBL_MAX), DBL_MAX};}
bool isValid() const
{return (pos.x() != DBL_MAX && pos.y() != DBL_MAX && angle != DBL_MAX);}
util::Vec pos;
double angle;
};
其中 pos 是中心坐标,angle是相对于x轴正半轴的顺时针旋转角(°),所有与游戏对象相关的数学计算也都离不开这两个参数。
除此之外一个Object一定有其移动状态,对于坦克来说,有前进(forward),后退(backward),顺时针旋转(rotateCW),逆时针旋转(rotateCCW)等,对于炮弹则只有前进状态。Tank和Shell都实现Object中的虚函数 draw(), getCurrentPosition(), getNextPosition() 等等,LocalController存储并管理一个std::unique_ptr 的多态列表。
2. 对象的移动
Tank 和 Shell都重写了getNextPosition()方法,这个方法会根据对象的移动状态 movingStatus 和步长,计算下一步的位置。已知当前坐标(x, y),旋转角angle,计算下一步的坐标,是由util::polar2Cart() 完成的,方法名是"极坐标转笛卡尔坐标"的缩写,因为可以将下一位置看作以当前坐标(x, y)为极点O,ρ = 步长,θ = angle的极坐标,将它转换为直角坐标就是下一步的位置了
util/Math.cc
double deg2Rad(double deg){return deg * M_PI / 180;}
Vec polar2Cart(double theta, double p, Vec O)
{
double x = O.x() + cos(deg2Rad(theta)) * p;
double y = O.y() - sin(deg2Rad(theta)) * p;
return Vec(x, y);
}
-
炮弹的移动
炮弹只有一种前进状态,而且几何形状是圆形,所以移动很简单,计算出下一位置坐标就好了。
//这个是Shell的静态方法,因为有时候需要在没有对象实例的情况下,仅仅进行位置的一些计算,就需要对应的静态方法,下边Tank的也同理 Object::PosInfo Shell::getNextPosition(const Object::PosInfo& cur, int movingStep, int rotationStep) { if(movingStep == 0) movingStep = SHELL_MOVING_STEP; Object::PosInfo next = cur; next.pos = util::polar2Cart(cur.angle, movingStep, cur.pos); return next; } //这是Shell的成员方法 Object::PosInfo Shell::getNextPosition(int movingStep, int rotationStep) { Object::PosInfo next = getNextPosition(posInfo, movingStep, rotationStep); nextPos = next; return next; } void Shell::moveToNextPosition() {posInfo = nextPos;}
-
坦克的移动
坦克移动状态复杂一些,可以同时处于旋转和移动状态,而且每次移动后要重新计算矩形四个顶点坐标。
//这个是Tank的静态方法 Object::PosInfo Tank::getNextPosition(const Object::PosInfo& cur, int movingStatus, int movingStep, int rotationStep) { if(movingStep == 0) movingStep = TANK_MOVING_STEP; if(rotationStep == 0) rotationStep = Tank::ROTATING_STEP; Object::PosInfo next = cur; //顺时针旋转状态 if(movingStatus & ROTATING_CW) next.angle = static_cast<int>(360 + cur.angle - rotationStep) % 360; //逆时针旋转状态 if(movin