🔷 第十六章 坦克动荡游戏
📄 一句话概括

本节是全章最长、最硬的一节,把 16.2 那张设计图一格一格填满代码: 对象分类(16.6.1)→ 可移动对象与方向(16.6.2)→ 坦克与子弹(16.6.5)→ 地图与轨迹(16.6.8)→ 画出会动的坦克(16.6.6)→ 碰撞与反弹(16.6.7 上半)→ 自动寻路(16.6.7 下半)→ 游戏菜单(16.6.3)→ 单人 / 双人对战(16.6.4)。 一句话主线:"能走、能打、能撞、能自己找路" —— 这四件事做完,游戏就齐了。

📝 本节和前面几节的关系

16.3 有工程、16.4 有循环、16.5 有画笔,本节往里填"内容"。 - 和 11.5「抽象类」的关系:CMovable 的纯虚函数 move(),本节才真正被各子类实现; - 和 13.3.3「排序算法」的关系:自动寻路的 BFS 用到了队列(std::queue); - 和 8.4「结构体数组」的关系:地图就是二维数组,只不过我们把它读成了字符串数组; - 和 15.3.3 的关系:地图文件一行一行读进来,getline 直接用。 一句话:前面学的每一件东西,本节都要用上至少一次。

一、游戏对象概述(16.6.1)

游戏世界里一共三类对象

类别 代表 特点 每帧要做什么
可移动对象 坦克、子弹 位置会变、有朝向 移动 + 绘制
静态对象 地图(墙、地板) 位置不变 只在开局加载,每帧画一次
界面对象 菜单、标题、帧数 不属于"游戏世界" 随状态显隐

为什么先分这三类? —— 因为它们的"更新频率"完全不同: 地图 = 开局读一次就再也不变;坦克子弹 = 每帧都要重算;菜单 = 只在菜单状态下画。 分不清这三类,代码就会乱成一锅粥。

抽象出父类:CMovable

class CMovable {
public:
    double x, y;          // 中心点位置(像素)
    double angle;         // 朝向(角度制,0° = 向右,顺时针为正)
    double speed;         // 每帧走多少像素
    bool   alive;

    CMovable() : x(0), y(0), angle(0), speed(0), alive(true) {}
    virtual ~CMovable() {}          // ★ 虚析构(11.4.2)

    virtual void move() = 0;        // ★ 纯虚:怎么动,子类定(11.5)
    virtual void draw(Graphics &g) = 0;
};

angle 用"角度制"还是"弧度制"? —— 本章统一用角度制(因为画图 API 用的多是角度), 只在调 cos/sin 的那一瞬间转成弧度(弧度 = 角度 × π / 180)。

二、游戏中可移动对象设计(16.6.2)

方向:屏幕上的角度长什么样

注意一个大坑:屏幕坐标的 y 轴是【向下】的,所以:

        -90° (上)
          │
 180° ────┼──── 0° (右)
          │
         90° (下)

方向向量:

const double PI = 3.14159265358979323846;

inline double rad(double deg) { return deg * PI / 180.0; }

// 朝 angle 方向、走 speed 像素,落点在哪
double nx = x + speed * std::cos(rad(angle));
double ny = y + speed * std::sin(rad(angle));

记住这一对公式,可移动对象就成了一半。

移动的通用套路:分轴移动

不要"一步到位" —— 要拆成"先试横向、再试纵向":

void CMovable::tryMove(double dx, double dy) {
    if (!blocked(x + dx, y)) x += dx;   // 横向走得通就走
    if (!blocked(x, y + dy)) y += dy;   // 纵向走得通就走
}

为什么拆? —— 因为撞墙时能"贴着墙滑行",而不是直接卡死:

做法 撞墙时的表现
一步到位 x+=dx; y+=dy; 整个移动被取消,坦克"顶"在墙上不动
分轴移动 横向被挡就只走纵向 → 顺着墙滑过去

这一个细节,就是"手感"好坏的差别。

三、坦克与子弹(16.6.5)

坦克:能转向、能前进、不能穿墙

class CTank : public CMovable {
public:
    int  hp;
    bool isComputer;               // 电脑坦克?(16.6.4 用)
    bool keyLeft, keyRight, keyUp; // 本帧的按键状态

    CTank(double x0, double y0, double a0)
        : hp(1), isComputer(false), keyLeft(false), keyRight(false), keyUp(false) {
        x = x0; y = y0; angle = a0; speed = 2.0;    // 每帧 2 像素
    }

    void move() override {
        if (keyLeft)  angle -= 3.0;                  // 左转
        if (keyRight) angle += 3.0;                  // 右转

        if (keyUp) {                                 // 前进(倒退就取负)
            double dx = speed * std::cos(rad(angle));
            double dy = speed * std::sin(rad(angle));
            tryMove(dx, dy);                         // ★ 分轴移动
        }
    }
    void draw(Graphics &g) override;                 // 见"五、画出移动的坦克"
};

两个设计决定:

  1. 按键状态存在对象里(keyLeft 等),不直接读键盘 —— 好处:电脑坦克只要"假装"把 keyUp 置 true,就能和人用同一套移动代码。
  2. 转向和前进分开 —— 可以让坦克原地打转(这符合"任意角度"的玩法)。

子弹:沿角度飞、撞墙反弹、有寿命

class CBullet : public CMovable {
public:
    int life;                                       // 还能活几帧

    CBullet(double x0, double y0, double a0): life(200) {
        x = x0; y = y0; angle = a0; speed = 5.0;    // 子弹比坦克快
    }

    void move() override {
        double dx = speed * std::cos(rad(angle));
        double dy = speed * std::sin(rad(angle));

        bool hitVertical   = blocked(x + dx, y);    // 横向走不动 = 撞上竖墙
        bool hitHorizontal = blocked(x, y + dy);    // 纵向走不动 = 撞上横墙

        if (hitVertical)   angle = 180.0 - angle;   // ★ 左右反
        if (hitHorizontal) angle = -angle;          // ★ 上下反

        // 没撞的方向照走(这样斜着撞墙也能正确反弹)
        if (!hitVertical)   x += dx;
        if (!hitHorizontal) y += dy;

        if (--life <= 0) alive = false;             // 到点自动消失(13.3 的 erase-remove 会清掉)
    }
    void draw(Graphics &g) override;
};

反弹的数学:为什么是那两行

方向向量是 (cos a, sin a),撞墙等于把其中一个分量取反:

撞的墙 哪个分量反 新角度
竖墙(左右挡) x 分量反:cos 变号 a' = 180° − a
横墙(上下挡) y 分量反:sin 变号 a' = −a

验算一下:a = 30°(右上),撞竖墙 → a' = 150°(左上)✅; a = 30° 撞横墙 → a' = −30°(右下)✅。和多边形反射定律是一致的。

子弹打完了要清掉 —— 用 erase-remove 惯用法(13.3.2 的账):

bullets.erase(
    std::remove_if(bullets.begin(), bullets.end(),
                   [](const CBullet &b) { return !b.alive; }),
    bullets.end());

四、画出游戏地图,控制运行轨迹(16.6.8)

地图 = 一个二维字符数组

class CMap {
public:
    std::vector<std::string> cells;     // cells[row][col]
    static const int CELL = 32;         // 每格 32×32 像素

    bool isWall(int row, int col) const {
        if (row < 0 || row >= (int)cells.size()) return true;              // 越界当墙
        if (col < 0 || col >= (int)cells[row].size()) return true;
        return cells[row][col] == '#';                                     // '#' 是墙
    }

    void load(const std::string &file) {
        std::ifstream in(file);
        cells.clear();
        std::string line;
        while (std::getline(in, line))            // ★ 15.3.3 的"读操作进条件"
            if (!line.empty()) cells.push_back(line);
    }
};

坐标 ↔ 格子:两行换算

// 像素坐标 → 格子
int col = (int)x / CMap::CELL;
int row = (int)y / CMap::CELL;

"控制运行轨迹"就靠它:每次要走之前,先算出"我要去的那个格子是不是墙":

bool CMovable::blocked(double px, double py) {
    int r = (int)py / CMap::CELL;
    int c = (int)px / CMap::CELL;
    return g_map.isWall(r, c);       // 全局地图(16.7 会改成从 CGame 传进来)
}

为什么越界当墙? —— 因为地图外面"什么都没有",坦克跑出去就没法处理了。 把越界当成墙,是最省事的"边界围栏"。

五、画出移动的坦克(16.6.6)

"会动"的秘密:每帧重新算位置、重新画

没有"移动"这个动作 —— 只有"擦掉旧的、画上新的":

第 1 帧:算位置 → 画在 (100,100)
第 2 帧:算位置 → 画在 (102,100)
第 3 帧:算位置 → 画在 (104,100)
...        (上一帧的图早被"整幅重画"覆盖掉了)

所以 render 的第一件事永远是"清屏"或"铺背景"(16.5 的 Demo 就是先 g.Clear 再铺渐变)。

让坦克"转过头":GDI+ 的坐标变换

void CTank::draw(Graphics &g) {
    GraphicsState st = g.Save();                 // ★ 保存当前坐标系
    g.TranslateTransform((REAL)x, (REAL)y);      // 把原点搬到坦克中心
    g.RotateTransform((REAL)angle);              // ★ 整体旋转

    SolidBrush body(Color(255, 60, 180, 90));
    g.FillRectangle(&body, -14, -14, 28, 28);    // 以"中心"为基准画车身

    Pen pen(Color(255, 230, 230, 230), 4.0f);
    g.DrawLine(&pen, 0, 0, 22, 0);               // 炮管朝 +x(也就是 angle 方向)

    g.Restore(st);                               // ★ 恢复坐标系(不影响别的对象)
}

三步曲:Save → 平移旋转 → Restore。 Restore 不能忘 —— 不然下一个对象会接着用这个"歪掉的坐标系"。

六、碰撞检测与子弹反弹(16.6.7 上半)

两个最常用的碰撞判定

① 圆与圆(子弹打坦克、坦克撞坦克):

bool hitCircle(double x1, double y1, double r1,
               double x2, double y2, double r2) {
    double dx = x1 - x2, dy = y1 - y2;
    double rr = r1 + r2;
    return dx * dx + dy * dy <= rr * rr;    // ★ 比平方,省一次开方
}

② 圆与矩形(更精确的"子弹撞墙格"):

bool hitCircleRect(double cx, double cy, double r,
                   double rx, double ry, double rw, double rh) {
    // 1. 找矩形上离圆心最近的点
    double nx = std::max(rx, std::min(cx, rx + rw));
    double ny = std::max(ry, std::min(cy, ry + rh));
    // 2. 算圆心到这个点的距离
    double dx = cx - nx, dy = cy - ny;
    return dx * dx + dy * dy <= r * r;
}

为什么比平方不开方? —— 开方是慢操作,而"比大小"不需要真的算出距离。 d² ≤ r² 和 d ≤ r 完全等价,所以能省就省(每帧要对几十个对象算,省下来是实实在在的)。

一帧里的判定顺序

① 子弹 vs 墙      → 反弹 / 消失
② 子弹 vs 坦克    → 坦克掉血,子弹消失
③ 子弹 vs 子弹    → 两颗都消失(可选,本章略)

顺序有讲究:子弹先和墙算完,位置修正了,再去判"有没有打中坦克" —— 否则可能出现"穿墙击中"。

七、自动寻路(16.6.7 下半)

电脑坦克的"脑子":先找路,再开火

自动寻路的问题:从电脑坦克所在的格子,走到玩家所在的格子,最短路径的第一步往哪走?

答案:广度优先搜索(BFS) —— 一层一层往外扩,第一次碰到目标时的那条路,一定是最短的。

#include <queue>

// 返回"下一步该去的格子";找不到就返回 (-1,-1)
struct Cell { int r, c; };

Cell nextStep(const CMap &m, Cell from, Cell to) {
    int H = (int)m.cells.size(), W = (int)m.cells[0].size();
    std::vector<std::vector<Cell>> pre(H, std::vector<Cell>(W, {-1, -1}));
    std::vector<std::vector<bool>> vis(H, std::vector<bool>(W, false));

    std::queue<Cell> q;
    q.push(from);  vis[from.r][from.c] = true;

    const int dr[4] = {-1, 1, 0, 0};      // 上 下 左 右
    const int dc[4] = { 0, 0, -1, 1};

    while (!q.empty()) {
        Cell cur = q.front(); q.pop();
        if (cur.r == to.r && cur.c == to.c) break;      // 到了,停

        for (int k = 0; k < 4; ++k) {
            int nr = cur.r + dr[k], nc = cur.c + dc[k];
            if (nr < 0 || nr >= H || nc < 0 || nc >= W) continue;
            if (vis[nr][nc] || m.isWall(nr, nc)) continue;   // 走过或墙 → 跳过
            vis[nr][nc] = true;
            pre[nr][nc] = cur;                                // ★ 记下"我是从哪儿来的"
            q.push({nr, nc});
        }
    }

    if (!vis[to.r][to.c]) return {-1, -1};                    // 走不到(被墙隔开)
    // 从终点往回倒,找到"起点的直接后继"
    Cell p = to;
    while (pre[p.r][p.c].r != -1 && !(pre[p.r][p.c].r == from.r && pre[p.r][p.c].c == from.c))
        p = pre[p.r][p.c];
    return p;
}

BFS 的关键就在两行:

有了"下一步",电脑坦克就活了

void CTank::think(const CMap &m, const CTank &enemy) {
    Cell me{(int)y / CMap::CELL, (int)x / CMap::CELL};
    Cell tg{(int)enemy.y / CMap::CELL, (int)enemy.x / CMap::CELL};
    Cell nx = nextStep(m, me, tg);
    if (nx.r < 0) return;                    // 走不到,原地不动

    // 把"格子方向"翻译成"按键" —— 复用人类的移动代码!
    double tx = nx.c * CMap::CELL + CMap::CELL / 2.0;
    double ty = nx.r * CMap::CELL + CMap::CELL / 2.0;
    double want = std::atan2(ty - y, tx - x) * 180.0 / PI;   // 目标角度

    // 先转向,再前进(简单粗暴,但有效)
    double diff = want - angle;
    while (diff >  180) diff -= 360;
    while (diff < -180) diff += 360;
    keyLeft  = (diff < -5);
    keyRight = (diff >  5);
    keyUp    = (std::fabs(diff) <= 5);
}

注意最后三行:电脑坦克并没有"另一套移动逻辑",它只是"自己按了键" —— 这就是本节第一部分那个设计决定(把按键状态存进对象)的回报。

八、设计游戏菜单(16.6.3)

菜单 = 几个矩形 + 一个状态机

enum GameState { ST_MENU, ST_PLAYING, ST_OVER };

struct MenuItem { double x, y, w, h; const wchar_t *text; };

MenuItem g_items[3] = {
    {240, 220, 200, 40, L"单人游戏"},
    {240, 280, 200, 40, L"双人对战"},
    {240, 340, 200, 40, L"退出"},
};

关键规矩:画和点,用同一份坐标。 这样就不会"看着点到了、其实没点到":

void DrawMenu(Graphics &g) {                    // 画
    for (int i = 0; i < 3; ++i)
        g.DrawString(g_items[i].text, -1, &g_itemFont,
                     PointF((REAL)g_items[i].x, (REAL)g_items[i].y), &g_white);
}

int HitTest(int mx, int my) {                   // 点
    for (int i = 0; i < 3; ++i) {
        const MenuItem &it = g_items[i];
        if (mx >= it.x && mx <= it.x + it.w &&
            my >= it.y && my <= it.y + it.h) return i;
    }
    return -1;
}

状态机让"同一个窗口做不同的事":

void CGame::onClick(int mx, int my) {
    if (state != ST_MENU) return;              // 不在菜单,点击不作数
    switch (HitTest(mx, my)) {
    case 0: startGame(false); break;           // 单人
    case 1: startGame(true);  break;           // 双人
    case 2: PostQuitMessage(0); break;         // 退出
    }
}

void CGame::render(HDC hdc) {
    Graphics g(hdc);
    if (state == ST_MENU)      DrawMenu(g);
    else if (state == ST_PLAYING) drawWorld(g);
    else                          DrawGameOver(g);
}

"状态"这个词,本章第一次真正有用 —— 不是"窗口变了",而是"窗口没变、行为变了"。

九、单人游戏与双人对战(16.6.4)

差别只在"谁按键"

模式 玩家 1 的按键 玩家 2 的按键
双人对战 W 前进 / A D 转向 方向键 ↑ ← →
单人游戏 W A D 电脑(think(),上一节)
void CGame::onKey(bool down, int vk) {
    if (!tanks[0].isComputer) {                 // 玩家 1
        if (vk == 'W') tanks[0].keyUp    = down;
        if (vk == 'A') tanks[0].keyLeft  = down;
        if (vk == 'D') tanks[0].keyRight = down;
    }
    if (!tanks[1].isComputer) {                 // 玩家 2
        if (vk == VK_UP)    tanks[1].keyUp    = down;
        if (vk == VK_LEFT)  tanks[1].keyLeft  = down;
        if (vk == VK_RIGHT) tanks[1].keyRight = down;
    }
    if (down && vk == VK_SPACE)                 // 空格:玩家 1 开火
        fire(tanks[0]);
}

开火就是"在坦克炮口位置生成一颗子弹":

void CGame::fire(CTank &t) {
    double muzzle = 22;                                    // 炮口离中心 22 像素
    double bx = t.x + muzzle * std::cos(rad(t.angle));
    double by = t.y + muzzle * std::sin(rad(t.angle));
    bullets.push_back(CBullet(bx, by, t.angle));           // ★ vector 自动扩容(13.1)
}

isComputer 这一个 bool,就把"单人 / 双人"两种模式统一了 —— 人的按键来自键盘,电脑的按键来自 think(),之后走完全一样的代码。 这就是"设计得好,代码自己会简化"。

十、动手 Demo:让坦克"走起来、弹起来、追起来"

分三步验证,一次只加一件事:

第 1 步:坦克能走、撞墙能停

第 2 步:子弹能反弹

第 3 步:电脑坦克会追人

读结果:

十一、30 秒实验

  1. 把子弹的 speed 从 5.0 改成 1.0 → 子弹慢得像蜗牛,反弹看得一清二楚。这是调参最快的"慢动作模式"。
  2. 把 hitCircle 里的 dx*dx + dy*dy 改成 std::sqrt(dx*dx + dy*dy) 再比半径 → 结果一样,但每帧慢一点。体会"能不开方就不开方"。
  3. 把 BFS 里的 vis[nr][nc] = true 注释掉 → 电脑坦克开始"绕圈"或不走了。这就是"不记录访问过"的后果。

十二、常见误区

  1. "角度用弧度更方便" —— 本章统一用角度制,只在 cos/sin 前转弧度。混用是 bug 的第一来源。
  2. "撞墙就整个移动取消" —— 会卡死。要分轴移动(先试横向再试纵向),才能贴墙滑行。
  3. "反弹就是 angle = -angle" —— 只对横墙。撞竖墙要 angle = 180 − angle —— 两个都写,斜着撞才正确。
  4. "Graphics 旋转完不用恢复" —— 必须 Restore。否则后面画的每个对象都跟着歪。
  5. "碰撞检测必须开方算距离" —— 比平方就行(d² ≤ r²)。每帧算几十次,省下来很可观。
  6. "菜单得用按钮控件" —— 不用。矩形 + 命中测试足矣,而且画成什么样完全由你定(16.4.3 的账)。
  7. "单人模式和双人模式要写两套逻辑" —— 不用。一个 isComputer 标志 + 一个 think() 就统一了。
  8. "电脑坦克可以不走格子、直接朝玩家走" —— 会穿墙。必须走 BFS 找格子路径,否则它要么撞墙、要么作弊。
自测三问(点开对答案) 1. **坦克撞墙时,为什么要"分轴移动"?** —— **为了避免卡死**。**一步到位地移动,只要落点进墙就整步取消,坦克会"顶"在墙上不动**;**分轴移动则是"横向被挡就只走纵向",于是能顺着墙滑行** —— **这是手感的关键。** 2. **子弹撞竖墙和撞横墙,角度分别怎么变?** —— **撞竖墙:`a' = 180° − a`**(**x 分量取反**);**撞横墙:`a' = −a`**(**y 分量取反**)。**两个方向都要写,斜着撞才正确。** 3. **BFS 寻路里 `vis` 和 `pre` 两个数组各管什么?** —— **`vis` 管"这个格子来过没有"(防止绕圈)**;**`pre` 管"我是从哪儿来的"(用来从终点倒推出整条最短路)**。**少任何一个,寻路都不成立。**

相关笔记