【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式

项目地址: https://github.com/JustDoIt0910/TankTrouble

Server地址:https://github.com/JustDoIt0910/TankTroubleServer

TankTrouble(坦克动荡) 是一款很有意思的小游戏,我是前段时间刷b站看到有人剪辑的精彩操作才知道的这个游戏。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第1张图片

规则很简单,发射炮弹击中对方,同时躲避炮弹,炮弹可以在墙壁反弹,每发炮弹反弹一定次数就会消失。每名玩家同一时刻最多有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 --------------------------------- 游戏中一些宏定义

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第2张图片

项目结构大致可以分为两层,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。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第3张图片

一个游戏对象的位置信息用 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(360 + cur.angle - rotationStep) % 360;
         //逆时针旋转状态
        if(movingStatus & ROTATING_CCW)
            next.angle = static_cast(cur.angle + rotationStep) % 360;
        //前进状态
        if(movingStatus & MOVING_FORWARD)
            next.pos = util::polar2Cart(next.angle, movingStep, cur.pos);
        //后退状态
        if(movingStatus & MOVING_BACKWARD)
            next.pos = util::polar2Cart(next.angle + 180, movingStep, cur.pos);
        return next;
    }
    
    //这是Tank的成员方法
    Object::PosInfo Tank::getNextPosition(int movingStep, int rotationStep)
    {
        Object::PosInfo next = getNextPosition(posInfo, movingStatus, movingStep, rotationStep);
        nextPos = next;
        return next;
    }
    
    void Tank::moveToNextPosition()
    {
        posInfo = nextPos;
        //recalculate()重新计算Tank四个顶点的坐标
        recalculate();
    }
    

注:movingStatus是以掩码形式表示的,可取值定义在Object.h中

#define MOVING_STATIONARY 1
#define MOVING_FORWARD 2
#define MOVING_BACKWARD 4
#define ROTATING_CW 8
#define ROTATING_CCW 16

通过位运算更改移动状态或判断移动状态。e.g.

//设置/取消前进状态
void Tank::forward(bool enable)
{
    if(enable)
    {
        movingStatus &= ~MOVING_BACKWARD;
        movingStatus |= MOVING_FORWARD;
    }
    else movingStatus &= ~MOVING_FORWARD;
}
//判断是否是前进状态
bool Tank::isForwarding() {return movingStatus & MOVING_FORWARD;}
3. 游戏对象的id分配

不同类型的游戏对象分配不同的id段,具体在 util/Id.cc 中:

Id.cc

int Id::globalTankId = 1;
int Id::globalBlockId = 11;
int Id::globalShellId = MAX_BLOCKS_NUM + 11;

int Id::getTankId()
{
    assert(globalTankId <= 10);
    return globalTankId++;
}

int Id::getBlockId() {return globalBlockId++;}

int Id::getShellId() {return globalShellId++;}

void Id::reset()
{
    Id::globalTankId = 1;
    Id::globalBlockId = 11;
    Id::globalShellId = MAX_BLOCKS_NUM + 11;
}

可以看到坦克的id在1 ~ 10,墙壁的id在11 ~ MAX_BLOCKS_NUM,剩下的id归炮弹使用。

注:游戏区域外边框也是有墙的,但因为它们每局都在,是静态的,不用动态生成,所以就规定上下边界墙id = -2,左右边界墙 id = -1。

4. 关于地图生成(Maze.h Maze.cc)

游戏区域在逻辑上是划分成7 * 11个格子的,墙都是位于格子的边上。在一回合开始时会先初始化随机地图,也就是确定哪些地方有墙,哪些地方是通路。地图生成算法要保证随机性,而且图中所有格子要是联通的,不能有死角。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第4张图片

我使用的算法是随机prim算法,其实就是最小生成树的思想。大致流程如下:初始时所有边都是有墙的,也就是说图中所有格子都是相互不通的(参考上图),从左上角格子开始,将与它相邻的一面墙加入到队列中,然后进入循环,每次从队列中随机挑选一面墙,打通它,这就相当于连了一条边,将墙对面那个格子加入最小生成树中,然后将新打通的格子相邻的墙加入队列中,循环直到队列为空。注:要记录一个格子是否已经被连通过(已经加入最小生成树中),如果之前被连通过,那么后面不能再从别的方向再次打通,否则最终图中就没有墙了。

为了方便表示一个格子,定义Grid结构:

Maze.h

struct Grid
{
    int x, y;
    Grid(int _x, int _y): x(_x), y(_y) {}
    int id() const {return y * HORIZON_GRID_NUMBER + x;}
};

id就是对格子x,y坐标简单的hash。

定义地图:

std::vector> map;

map[grid_id1][grid_id2] = 0,表示id为grid_id1的格子和id为grid_id2的格子被墙隔开

map[grid_id1][grid_id2] = 1,表示id为grid_id1的格子和id为grid_id2的格子中间没有墙

地图生成部分(随机prim)

static int dx[] = {0, -1, 0, 1};
static int dy[] = {-1, 0, 1, 0};
int vis[HORIZON_GRID_NUMBER][VERTICAL_GRID_NUMBER];

void Maze::generate()
{
    map = std::vector(MAX_GRID_ID, std::vector(MAX_GRID_ID, 0));
    memset(vis, 0 ,sizeof(vis));
    //一面墙用pair表示,pair两个元素分别是墙两侧的两个格子
    std::vector> walls;
    vis[0][0] = 1;
    //这里是把左上角格子的右侧墙加入队列
    walls.emplace_back(Grid(0, 0), Grid(1, 0));
    while(!walls.empty())
    {
        //随机选取一面墙
        int n = util::getRandomNumber(0, walls.size() - 1);
        std::pair wall = walls[n];
        walls.erase(walls.begin() + n);
        //判断墙对面的格子之前有没有被连通过
        if(!vis[wall.second.x][wall.second.y])
        {
            //打通这面墙
            map[wall.first.id()][wall.second.id()] = map[wall.second.id()][wall.first.id()] = 1;
            //标记墙对面格子已打通,避免重复打通
            vis[wall.second.x][wall.second.y] = 1;
        }
        //下面把新打通格子相邻的墙加入队列
        for(int i = 0; i < 4; i++)
        {
            int nx = wall.second.x + dx[i];
            int ny = wall.second.y + dy[i];
            if(nx < 0 || nx >= HORIZON_GRID_NUMBER || ny < 0 || ny >= VERTICAL_GRID_NUMBER)
                continue;
            if(vis[nx][ny]) continue;
            Grid next(nx, ny);
            if(map[wall.second.id()][next.id()] == 0)
                walls.emplace_back(wall.second, next);
        }
    }
}

block(墙壁)初始化部分,根据生成好的map,返回所有剩余墙壁的位置列表,pair两个元素分别是一个block的起始坐标和结束坐标(头和尾)

std::vector> Maze::getBlockPositions()
{
    std::vector> blocks;
    for(int y = 0; y < VERTICAL_GRID_NUMBER; y++)
        for(int x = 0; x < HORIZON_GRID_NUMBER - 1; x++)
            if(map[Grid(x, y).id()][Grid(x + 1, y).id()] == 0)
                blocks.emplace_back(util::Vec((x + 1) * GRID_SIZE, y * GRID_SIZE),
                                    util::Vec((x + 1) * GRID_SIZE, (y + 1) * GRID_SIZE));
    
    for(int x = 0; x < HORIZON_GRID_NUMBER; x++)
        for(int y = 0; y < VERTICAL_GRID_NUMBER - 1; y++)
            if(map[Grid(x, y).id()][Grid(x, y + 1).id()] == 0)
                blocks.emplace_back(util::Vec(x * GRID_SIZE, (y + 1) * GRID_SIZE),
                                    util::Vec((x + 1) * GRID_SIZE, (y + 1) * GRID_SIZE));
    return blocks;
}

三、Controller的设计

Controller是一个纯虚类,有两个子类 LocalController 和 OnlineController,分别负责单机模式和online模式。Controller包含了它们共同的数据成员和接口。

Controller.h

class Controller
{
public:
    typedef std::unique_ptr ObjectPtr;
    typedef std::unordered_map ObjectList;
    typedef std::shared_ptr ObjectListPtr;
    typedef std::unordered_map BlockList;
    Controller();

    //启动Controller(创建controlThread,开启事件循环)
    virtual void start() = 0;
    
    //由view层调用,返回某一时刻游戏中对象(包括所有坦克和炮弹)的快照,用于绘制
    ObjectListPtr getObjects();
    
    //用于分发事件(这里是键盘事件)
    void dispatchEvent(ev::Event* event);
    
   // 由view层调用,返回游戏中的墙,用于绘制
    BlockList* getBlocks();
    
    //由view层调用,返回玩家信息
    std::vector getPlaysInfo();
    virtual void quitGame() {}

    virtual ~Controller();

protected:
    //某一时刻游戏中对象(包括所有坦克和炮弹)的快照,用于提供给view层进行绘制
    std::mutex mu;
    ObjectListPtr snapshot;

    //游戏中的墙
    std::mutex blocksMu;
    BlockList blocks;

    //游戏中玩家的信息(昵称,得分)
    std::mutex playersInfoMu;
    std::map playersInfo;
    
    std::condition_variable cv;
    bool started;
    
    // Control 线程
    std::thread controlThread;
    // Control 线程中的事件循环,游戏中定时任务,键盘事件都由它处理
    ev::reactor::EventLoop* controlLoop;
};
 
  

LocalController的主要成员如下:

LocalController.h

class LocalController : public Controller {
public:
    LocalController();
    ~LocalController() override;
    void start() override;

private:
    void restart(double delay);
    
    //初始化所有游戏状态
    void initAll();
    
    void run();
    
    // 将所有游戏对象移动到下一位置
    void moveAll();
    
    //键盘事件的handler,控制玩家坦克的移动状态
    void controlEventHandler(ev::Event* event);
    
    //有人机(smith)调用,用于更新躲避、攻击策略
    void updateStrategy(Strategy* strategy);
    
    void fire(Tank* tank);

    //检查某颗炮弹的下一位置会不会发生碰撞(调用下面的两个函数)
    int checkShellCollision(const Object::PosInfo& curPos, const Object::PosInfo& nextPos);
     //检查某颗炮弹的下一位置会不会碰到墙壁
    int checkShellBlockCollision(const Object::PosInfo& curPos, const Object::PosInfo& nextPos);
     //检查某颗炮弹的下一位置会不会碰到坦克
    int checkShellTankCollision(const Object::PosInfo& curPos, const Object::PosInfo& nextPos);

    //检查某个坦克下一位置会不会碰到墙
    int checkTankBlockCollision(const Object::PosInfo& curPos, const Object::PosInfo& nextPos);
    
    //获得某颗炮弹碰墙反弹后的位置信息
    Object::PosInfo getBouncedPosition(const Object::PosInfo& cur, const Object::PosInfo& next, int blockId);

    //(每回合开始时)初始化游戏中的墙
    void initBlocks();
    //(每回合开始时)生成双方的随机位置
    static std::vector getRandomPositions(int num);
    
    //获取人机的当前位置
    bool getSmithPosition(Object::PosInfo& pos);
    //获取玩家的当前位置
    bool getMyPosition(Object::PosInfo& pos);

    //地图生成的迷宫类
    Maze maze;
    
    std::vector deletedObjs;
    //存储游戏对象(所有坦克,炮弹)的容器
    ObjectList objects;
    
    //下面两个成员是用来优化碰撞检测的,后面会说明
    
    //表示当一个炮弹处于(x, y)这个格子,向某个方向(上下左右,左上,右上,左下,右下,共8个方向)运动时,可能碰撞的墙的id列表
    std::vector shellPossibleCollisionBlocks[HORIZON_GRID_NUMBER][VERTICAL_GRID_NUMBER][8];
     //表示当一个坦克处于(x, y)这个格子运动时,可能碰撞的墙的id列表
    std::vector tankPossibleCollisionBlocks[HORIZON_GRID_NUMBER][VERTICAL_GRID_NUMBER];
    
    //全局步数,表示当前经历了几轮移动,这个变量是人机用的。
    uint64_t globalSteps;

    friend class AgentSmith;
    friend class DodgeStrategy;
    friend class ContactStrategy;
    friend class AttackStrategy;
    
    //AgentSmith就是人机
    std::unique_ptr smith;
    
    int danger;
    
    //由人机生成的躲避策略,接近(接敌)策略,攻击策略
    //LocalController在每次moveAll时要执行这些策略,具体后面说明
    std::unique_ptr smithDodgeStrategy;
    std::unique_ptr smithContactStrategy;
    std::unique_ptr smithAttackStrategy;
};

LocalController 中有三种定时任务:

  • 移动所有游戏对象到下一位置(moveAll),周期为 0.01 s
  • 运行人机的威胁检测和躲避策略生成逻辑,周期为 0.1 s
  • 运行人机的攻击策略生成逻辑,周期为 0.8 s

如图(LocalController.cc),定时任务在run()中设置

void LocalController::run()
{
    ev::reactor::EventLoop loop;
    controlLoop = &loop;
    {
        std::unique_lock lk(mu);
        started = true;
        cv.notify_all();
    }

    loop.runEvery(0.01, [this]{this->moveAll();});

    loop.runEvery(0.1, [this]() -> void {
        Object::PosInfo smithPos;
        if(!getSmithPosition(smithPos)) return;
        AgentSmith::PredictingShellList shells = smith->getIncomingShells(smithPos);
        smith->ballisticsPredict(shells, globalSteps);
        smith->getDodgeStrategy(smithPos, globalSteps);
    });

    loop.runEvery(0.8, [this] () -> void {
        Object::PosInfo smithPos, myPos;
        if(!getSmithPosition(smithPos) || !getMyPosition(myPos)) return;
        smith->attack(smithPos, myPos, globalSteps);
    });

    loop.loop();
    controlLoop = nullptr;
}

其中第一个定时任务是游戏的主要逻辑,也就是moveAll。下面是moveAll()方法中主要部分:

void LocalController::moveAll()
{
    globalSteps++;
    deletedObjs.clear();
    bool attacking = false;
    //遍历对象列表里所有游戏对象
    for(auto& entry: objects)
    {
        std::unique_ptr& obj = entry.second;
        
        //对于人机,这里要执行它生成的躲避,接近,攻击策略
        if(obj->id() == AI_TANK_ID)
        {
            auto* smithTank = dynamic_cast(obj.get());
            if(smithDodgeStrategy)
            {
                if(smithDodgeStrategy->update(this, smithTank, globalSteps))
                {
                    if(smithAttackStrategy) smithAttackStrategy->cancelAttack();
                    danger = 50;
                }
                else danger = danger == 0 ? danger : danger - 1;
            }
            if(smithAttackStrategy && !danger)
            {
                if(smithAttackStrategy->update(this, smithTank, globalSteps))
                    attacking = true;
            }
            if(smithContactStrategy && !danger && !attacking)
                smithContactStrategy->update(this, smithTank, globalSteps);
        }
        
        
        //获取当前对象的当前位置cur和下一位置next
        Object::PosInfo next = obj->getNextPosition(0, 0);
        Object::PosInfo cur = obj->getCurrentPosition();
        bool countdown = false;
        //如果当前对象是炮弹
        if(obj->type() == OBJ_SHELL)
        {
            auto* shell = dynamic_cast(obj.get());
            //判断它是否会撞上其他东西(有可能是墙壁,有可能是坦克,要根据返回的id判断)
            int id = checkShellCollision(cur, next);
            //碰到墙壁
            if(id < 0 || id > MAX_TANK_ID)
            {
                //将这个炮弹的下一位置重置为反弹后的位置
                obj->resetNextPosition(getBouncedPosition(cur, next, id));
                countdown = true;
            }
            //如果碰到坦克
            else if(id)
            {
                if((id != shell->tankId() || shell->ttl() < Shell::INITIAL_TTL))
                {
                    //这里不能直接删除这个炮弹对象和被击中的坦克对象,否则会造成迭代器失效,
                    //正确做法是记录下它们的id,遍历结束后统一删除
                    deletedObjs.push_back(id);
                    deletedObjs.push_back(shell->id());
                    //计分
                    if(id == PLAYER_TANK_ID)
                        playersInfo[AI_TANK_ID].score_++;
                    else
                        playersInfo[PLAYER_TANK_ID].score_++;
                    //一秒钟之后开始下一回合
                    restart(1.0);
                    break;
                }
            }
            //判断炮弹生命期是不是减到0了
            if(countdown && shell->countDown() <= 0)
            {
                //删除这颗炮弹
                deletedObjs.push_back(shell->id());
                //对应坦克可以获得剩余子弹
                if(objects.find(shell->tankId()) != objects.end())
                    dynamic_cast(objects[shell->tankId()].get())->getRemainShell();
            }
        }
        //如果当前对象是坦克
        else
        {
            auto* tank = dynamic_cast(obj.get());
            //判断它的下一位置会不会撞墙
            int id = checkTankBlockCollision(cur, next);
            //如果下一位置会撞墙,重置下一位置为当前位置,即不移动(被墙挡住)
            if(id)
                obj->resetNextPosition(cur);
        }
        //对象移动到下一位置
        obj->moveToNextPosition();
    }
    //这里遍历objects结束,可以安全地删除对象
    for(int id: deletedObjs)
        objects.erase(id);

    //下面以copy-on-write方式更新snapshot中的数据快照
}
 
  

LocalController遍历objects中的所有对象,如果这个对象是人机,要特殊处理,因为人机如何移动取决于当前的smithDodgeStrategy(躲避策略),smithContactStrategy(接近策略)和smithAttackStrategy(攻击策略),要先调用每个策略的 update() 方法,update() 方法可能会改变人机的运动状态,此外,这三个策略也是存在优先级的,躲避策略优先级最高(优先自保),攻击策略其次,接近策略优先级最低。如果高优先级策略的update()方法改变了移动状态,那么将不再执行低优先级策略的update(),原因也很好理解,保证高优先级的策略生效。关于人机具体的逻辑后面再说。

对于一般游戏对象,先判断当前对象类型是Tank还是Shell,是Shell的话,判断它下一位置会不会碰到墙或者击中坦克,如果碰墙,重置下一位置为反弹后的位置,如果击中坦克,删除这颗炮弹和被击中的坦克,本回合结束,处理完计分后,restart下一回合。如果是Tank,只需要判断它下一位置会不会碰墙,如果碰墙就不移动。

碰撞检测的实现

​ 几何图形的碰撞判断都在Math.cc中实现

  • 炮弹与墙的碰撞检测和反弹计算

    其实游戏中的墙是由一根根固定长度和宽度的Block组合而成,所以炮弹与墙碰撞可以简化为圆形和矩形的重叠判断

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第5张图片

图中 v1, v2 是矩形两个方向的单位向量,当然不要求矩形是水平或者垂直,只是因为游戏中所有Block都是横平竖直的。计算v在两个方向向量上的投影长度,当 v 在 v2 上投影长度小于 W/2 + r,在 v1 上投影小于 H/2 + r,且矩形中心与圆形中心距离小于 R + r 时,一定会发生重叠。代码如下:

Math.cc

//参数中vec1, vec2对应图中v1, v2,rectCenter是矩形中心,circleCenter是圆心,width,height是矩形宽高,
//r 是圆形半径
bool checkRectCircleCollision(const Vec& vec1, const Vec& vec2,
                              const Vec& rectCenter, const Vec& circleCenter,
                              int width, int height, double r)
{
    Vec v = Vec(circleCenter.x() - rectCenter.x(), circleCenter.y() - rectCenter.y());
    double d1 = std::abs(v * vec2);
    double d2 = std::abs(v * vec1);
    double d3 = sqrt(pow(static_cast(width) / 2, 2) + pow(static_cast(height) / 2, 2));
    double d = v.norm();
    if(d1 < static_cast(width) / 2 + r && d2 < static_cast(height) / 2 + r && d < d3 + r)
        return true;
    return false;
}

那么怎么计算反弹方向呢?我用到一点包围盒的思想,将矩形外围看作有一个由四条线段 b1 , b2 , b3, b4 组成的包围盒, 线段与矩形外围的距离是炮弹的半径。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第6张图片

在某一次碰撞检测中,可以得到一颗Shell当前的位置和下一步的位置,这两个位置的中心会构成一条线段,将这条线段依次与包围盒的4个边求交点,有交点的那条包围盒线段就是Shell下一步将会碰到的矩形边,根据这条边是水平还是竖直,就可以得到反弹后的方向了。

  • 坦克与墙的碰撞检测

    可以简化为矩形与矩形的重叠检测。这里用到的是投影法。关于投影法判断矩形重叠,限于篇幅讲不太清楚, 这篇文章讲的比较清楚:

    投影法判断旋转矩形重叠

    实现代码如下:

    Math.cc

    bool checkRectRectCollision(double angle1, Vec center1, double W1, double H1,
                                								double angle2, Vec center2, double W2, double H2)
    {
        std::pair units1 = getUnitVectors(angle1);
        std::pair units2 = getUnitVectors(angle2);
        Vec axis1 = units1.first; Vec axis2 = units1.second;
        Vec axis3 = units2.first; Vec axis4 = units2.second;
        Vec v = center1 - center2;
        double projV = std::abs(v * axis1);
        double projRadius = std::abs(axis3 * axis1) * H2 / 2 + std::abs(axis4 * axis1) * W2 / 2;
        if(projRadius + H1 / 2 <= projV)
            return false;
        projV = std::abs(v * axis2);
        projRadius = std::abs(axis3 * axis2) * H2 / 2 + std::abs(axis4 * axis2) * W2 / 2;
        if(projRadius + W1 / 2 <= projV)
            return false;
    
        projV = std::abs(v * axis3);
        projRadius = std::abs(axis1 * axis3) * H1 / 2 + std::abs(axis2 * axis3) * W1 / 2;
        if(projRadius + H2 / 2 <= projV)
            return false;
        projV = std::abs(v * axis4);
        projRadius = std::abs(axis1 * axis4) * H1 / 2 + std::abs(axis2 * axis4) * W1 / 2;
        if(projRadius + W2 / 2 <= projV)
            return false;
        return true;
    }
    
  • 一点优化措施

    如果每个对象的每一次移动,都要与地图中所有Block进行碰撞检测,那未免有些蠢,其实一个对象在位置和运动方向确定的情况下,只有一小部分墙壁是可能发生碰撞的,即需要判断的。比如图中情况

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第7张图片

这种情况下只需要判断左上角四个Block就好了。所以可以维护两个可能碰撞表,记录在某个格子,向某个方向运动时的可能碰撞列表(Tank 没有方向这一维,因为它几何形状比较不规则),在生成地图后,初始化blocks时打表。

std::vector shellPossibleCollisionBlocks[HORIZON_GRID_NUMBER][VERTICAL_GRID_NUMBER][8];
std::vector tankPossibleCollisionBlocks[HORIZON_GRID_NUMBER][VERTICAL_GRID_NUMBER];
控制事件处理

gui线程通过Controller的dispatchEvent()将键盘事件传递给Controller

void Controller::dispatchEvent(ev::Event* event) {controlLoop->dispatchEvent(event);}

它调用了EventLoop的dispatchEvent(),这个方法是线程安全的,可以在不同于loop thread的其他线程调用,传入一个表示事件类型的Event指针,EventLoop中注册的事件处理函数会被调用。
LocalController的控制事件处理函数如下:

void LocalController::controlEventHandler(ev::Event *event)
    {
        if(objects.find(PLAYER_TANK_ID) == objects.end()) return;
        auto* ce = dynamic_cast(event);
        Tank* me = dynamic_cast(objects[PLAYER_TANK_ID].get());
        switch (ce->operation())
        {
            case ControlEvent::Forward: me->forward(true); break;
            case ControlEvent::Backward: me->backward(true); break;
            case ControlEvent::RotateCW: me->rotateCW(true); break;
            case ControlEvent::RotateCCW: me->rotateCCW(true); break;
            case ControlEvent::StopForward: me->forward(false); break;
            case ControlEvent::StopBackward: me->backward(false); break;
            case ControlEvent::StopRotateCW: me->rotateCW(false); break;
            case ControlEvent::StopRotateCCW: me->rotateCCW(false); break;
            case ControlEvent::Fire: fire(me); break;
        }
    }

就是根据不同的ControlEvent修改玩家坦克的移动状态。

四、人机的实现

人机是最有趣的部分了。它的逻辑分为危险躲避,接近敌人,瞄准攻击三部分。AgentSmith.cc中的代码负责作出躲避、接近、攻击的最优决策。但是从决策到对象真正移动之间还需要一个执行者,就是smithAI下的各种Strategy了,DodgeStrategy是躲避决策执行者,ContactStrategy是接近决策执行者,AttackStrategy是攻击决策执行者,决策以不同的形式保存在strategy中。

对于DodgeStrategy, 一次完整的躲避决策由一个命令队列表示,比如

​ {ROTATE_CW, 3}, {MOVE_FORWARD, 15}

表示先顺时针旋转三个步长,再前进15个步长。AgentSmith只负责生成这些命令,由DodgeStrategy负责在正确的时间改变坦克的移动状态,也就是执行命令

ContactStrategy和AttackStrategy也类似,只不过一个存的是A*算法生成的路径点,一个存的是瞄准角度而已。总之AgentSmith和Strategy的关系就类似于高级指挥官与基层军官的关系。

1. Smith的躲避决策算法

LocalController中有个变量对于人机至关重要,globalSteps,64位无符号整数,初始为0,每个移动周期会自增1,smith需要它来推算未来某个时间点炮弹的位置,strategy也需要知道globalSteps才能确定某个命令是否已经执行了足够的步数。

威胁检测

Smith会定期对距离自己一定范围之内炮弹进行弹道预测,因为炮弹会反弹,需要将弹道分成 一段一段的结构考虑。定义"弹道段"结构BallisticSegment

     struct BallisticSegment
     {
         int shellId; //炮弹的id
         int seq;		//段序号,表示当前段是整条弹道中的第几段
         KeyPoint start; //起点坐标
         KeyPoint end; //终点坐标
         util::Vec center;
         double angle; //炮弹在这段弹道上的移动方向
         double length; //这段长度
         double distanceToTarget; //段的起点与smith的距离,这个值决定躲避的优先级
         
         BallisticSegment(int id, int seq, KeyPoint s, KeyPoint e, double len, double a, double dis):
         	shellId(id), seq(seq), start(std::move(s)), end(std::move(e)),
         	length(len), angle(a), distanceToTarget(dis)
             {center = util::Vec((start.second.x() + end.second.x()) / 2,
                                 (start.second.y() + end.second.y()) / 2);}
         
         static BallisticSegment invalid(){return {-1, -1, KeyPoint(), KeyPoint(), 0, 0, 0};}
         
         bool isValid() const {return shellId != -1;}
     };
 typedef std::vector Ballistic; //一条完整弹道
 typedef std::unordered_map Ballistics; // 炮弹id -> 弹道

如何判断某条“弹道段”对自己有威胁呢?可以将一条BallisticSegment视作一个宽度为炮弹直径的长方形,如果它与smith有重叠,那smith就处于这颗炮弹的“炮线”上,这个段将会被放入威胁列表中。

最后smith会选择躲避起始点距离自己最近的那个段,因为这颗炮弹应该是最早到达的。

下边这个例子中,两条蓝色段属于一条弹道,两条红色段属于另一条弹道。浅蓝色和深红色的段是有威胁段,由于红色段距离近,所以深红色段是当前要躲避的段。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第8张图片

决策生成

定位了威胁,接下来就是确定要如何移动才能使得smith与威胁段不相交。除了暴力模拟我没有想出来更好的方法,但是可以通过一些前置的计算来尽量保证模拟中的第一种可行方案即为最优方案,这样可以尽早结束模拟。smith会依次尝试以下三种移动方式(躲避决策生成的代码都比较冗长,就不贴出来了):

  • 不移动,只旋转

    当炮线比较靠近边缘时,旋转一般是耗时最短的选择

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第9张图片

  • 先旋转,再移动

    仅靠旋转躲不开时,就要尝试移动到炮线的一侧去。那是向左侧躲避最优还是向右侧最优?这就需要一点向量计算了。

【c++实现单机/多人联机 TankTrouble(坦克动荡) (一) —— 单机模式_第10张图片

v是炮弹运动方向向量,v2是从炮弹中心坐标指向坦克中心坐标的向量,根据v与v2的叉乘的正负,可以知道v2在v的左侧还是右侧。明显左边情况向左侧躲避最优,右边情况向右侧躲避最优。

注:并不是只要能躲开当前威胁就可行,在模拟每一步时也要计算坦克会不会碰墙,会不会撞上炮弹,如果会,pass掉当前方案,尝试旋转到下一个角度再移动

  • 边转弯边移动

    兜底的方案,同样也是模拟。

决策执行

每一轮移动中,smithDodgeStrategy 的 update() 方法被调用,它会从命令队列头取出命令,判断该命令是否已经执行了足够的步数,如果是,将其弹出,将坦克移动状态设置为下一命令指定的移动状态,这样持续直到命令队列为空。

说明一下躲避策略的命令结构:

 enum DodgeOperation {DODGE_CMD_MOVE_FORWARD, DODGE_CMD_MOVE_BACKWARD,
                 DODGE_CMD_ROTATE_CW, DODGE_CMD_ROTATE_CCW,
                 DODGE_CMD_FORWARD_CW, DODGE_CMD_FORWARD_CCW,
                 DODGE_CMD_BACKWARD_CW, DODGE_CMD_BACKWARD_CCW};
 
 struct DodgeCommand
 {
     DodgeOperation op;
     uint64_t step;
     uint64_t targetStep;
 };

op是躲避动作,step是这个动作持续的步数,targetStep 用来判断该命令是否执行完指定的步数,当 globalSteps == targetStep 时,该命令执行完成。当生成命令的时候 targetStep 设置为0,当在update() 方法中第一次执行该命令时,会设置 targetStep 为正确的值 (当时的 globalSteps + cmd.steps).

 //简化版本,省略一些不重要代码
 bool DodgeStrategy::update(LocalController* ctl, Tank* tank, uint64_t globalStep)
 {
     if(cmds.empty())
         return false;
     tank->stop();
     //取队头的命令
     DodgeCommand cmd = cmds.front();
     cmds.pop_front();
     Object::PosInfo cur = tank->getCurrentPosition();
     //该命令第一次执行
     if(cmd.targetStep == 0)
     {
         //设置该命令的停止步数
         cmd.targetStep = globalStep + cmd.step;
         cmds.push_front(cmd);
     }
     //根据命令的op设置人机的移动状态
     switch (cmd.op)
     {
         case DODGE_CMD_ROTATE_CW:
             if(globalStep < cmd.targetStep)
             {
                 tank->rotateCW(true);
                 cmds.push_front(cmd);
             }
             break;
         case DODGE_CMD_ROTATE_CCW:
             if(globalStep < cmd.targetStep)
             {
                 tank->rotateCCW(true);
                 cmds.push_front(cmd);
             }
             break;
         case DODGE_CMD_MOVE_FORWARD:
             if(globalStep < cmd.targetStep)
             {
                 tank->forward(true);
                 cmds.push_front(cmd);
             }
             break;
         case DODGE_CMD_MOVE_BACKWARD:
             if(globalStep < cmd.targetStep)
             {
                 tank->backward(true);
                 cmds.push_front(cmd);
             }
             break;
             //其他命令也是同理
             ......
     }
     return true;
 }

注:smith的决策更新的速度是比较快的,这常常导致一个决策没有执行完就被覆盖成新的,不过这并不是坏事,因为它每次做出的都是局部最优解,一般不会与上一次决策冲突,即使冲突也是因为发现了更优的策略,这会使smith的躲避更加灵活

  • Smith的路线规划

    人机要确定对方的位置并且寻找最短的接近路线,我使用的是经典的A*算法,具体实现在AStar.h AStar.cc中。

  • Smith攻击策略

    进入攻击范围后smith会开始寻找瞄准角度,也是模拟+弹道预测。

五、GUI部分

因为我也是第一次用gtkmm,所以也是边看tutorial边写的,我看的tutorial在这里:
Programming with gtkmm 3
Window是主窗口,管理views,当有键盘事件时,生成一个ControlEvent,并dispatch给Controller。
在单机模式中,views只用到了EntryView(选择模式的界面)和GameView(游戏界面)。
GameView通过Controller提供的getObjects()、getBlocks()等数据接口,获取到游戏数据,绘制出来就好了。绘图操作可以参考gtkmm绘图

protocol中是与网络通信相关的协议与序列化机制,放到下篇online模式再说

你可能感兴趣的:(c++,c++,linux)