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

前面几节把零件都做出来了,本节把它们装进一个 CGame 类: 引进所有声明(16.7.1)→ 定公有接口(16.7.2)→ 定私有帮手(16.7.3)→ 实现公有方法(16.7.4)→ 实现初始化 / 逻辑 / 绘制 / 辅助四组方法(16.7.5 ~ 16.7.8)→ 把它接进主窗口(16.7.9)。 一句话:本节是"装配车间" —— 不造新零件,只把它们拧成一台能跑的游戏。

📝 本节和前面几节的关系

16.6 写的是"零件怎么造"(CTank、CBullet、CMap 各自的逻辑), 本节写的是"谁来指挥它们" —— 这个"指挥"就是 CGame。 - 和 10.1「C++类」的关系:CGame 就是一个"组合"型类 —— 它自己没什么逻辑,全靠持有别人(has-a); - 和 13.1 的关系:CGame 里的 std::vector<CBullet> 是一个容器成员, 构造 / 析构 / 清空都不用你操心(这是"用容器代替裸数组"的最大好处); - 和 16.4 的关系:那节的 WndProc 里直接调 g_game.render(), 本节把那条线正式接好(16.7.9)。 一句话:本节之后,游戏第一次"完整地跑得起来"。

一、本节的装配思路

一张图看清"谁管谁"

          主窗口(WndProc / 消息循环)
                    │  持有
                    ▼
              ┌───────────┐
              │  CGame    │ ← 总指挥
              └─────┬─────┘
        ┌───────────┼────────────┬──────────────┐
        ▼           ▼            ▼              ▼
    CMmap      CTank[2]     vector<CBullet>   状态 & 计时
   (地图)    (两台坦克)     (一堆子弹)     (菜单/游戏中/结算)

装配的三条原则:

  1. CGame 只做"调度",不做"细节" —— 细节都在 CTank、CBullet、CMap 里;
  2. 谁的数据谁管 —— CGame 不碰坦克的 x、y,只调 tank.move();
  3. 一帧就三步 —— 读输入 → update() → render()。

二、引进所有游戏对象声明(16.7.1)

// CGame.h
#pragma once

#include <vector>
#include <string>
#include <gdiplus.h>
#include "CMap.h"
#include "CTank.h"
#include "CBullet.h"

using namespace Gdiplus;

enum GameState { ST_MENU, ST_PLAYING, ST_OVER };

class CGame {
public:
    // ── 数据成员:它持有的所有"零件" ──────────────────
    CMap                 map;
    CTank                tanks[2];
    std::vector<CBullet> bullets;

    GameState state;              // 现在在哪个状态
    int       winner;             // 谁赢了(-1 = 还没分出来)
    int       frame;              // 帧计数(16.5.3 的帧数显示)

    // ── 下面依次是:公有接口 / 私有帮手 ────────────────
    ...
};

注意两点:

三、声明 CGame 的公有方法(16.7.2)

class CGame {
public:
    // ── 生命周期 ──────────────────────────────────────
    void init();                              // 开局准备
    void reset();                             // 重开一局

    // ── 每帧被调用的两个 ──────────────────────────────
    void update();                            // 更新一帧
    void render(HDC hdc);                     // 绘制一帧

    // ── 来自窗口的"外部事件" ──────────────────────────
    void onKey(bool down, int vk);            // 键盘
    void onClick(int mx, int my);             // 鼠标左键
    void setViewSize(int w, int h);           // 窗口大小变了
};

公有方法就是 CGame 对外的"控制面板" —— 窗口只会碰这几个函数, 它不关心里面有多少坦克、子弹怎么飞。

四、声明私有方法(16.7.3)

private:
    // ── 状态流转 ──────────────────────────────────────
    void startGame(bool twoPlayers);          // 从菜单进入游戏
    void endGame(int who);                    // 分出胜负

    // ── 逻辑帮手 ──────────────────────────────────────
    void updateBullets();                     // 子弹:移动 + 反弹 + 命中
    void updateTanks();                       // 坦克:移动 + 电脑思考
    void removeDeadBullets();                 // 清掉该消失的子弹
    void checkVictory();                      // 判定胜负

    // ── 绘制帮手 ──────────────────────────────────────
    void drawWorld(Graphics &g);              // 画游戏世界
    void drawMenu(Graphics &g);               // 画菜单
    void drawGameOver(Graphics &g);           // 画结算

    // ── 工具 ──────────────────────────────────────────
    int  hitTestMenu(int mx, int my);         // 菜单命中测试
    bool blocked(double x, double y) const;   // 这个点是不是墙
    void fire(CTank &t);                      // 开一炮

为什么这些方法要私有? —— 因为外面不需要知道"清子弹"是单独一步,它只要调 update() 就够了。 这就是封装(9.2.3「面向对象的特点」)的实际用法:把复杂度关在门里。

五、实现初始化方法(16.7.5)

void CGame::init() {
    map.load("res\\map.txt");                     // 15 章:从文件读地图
    state  = ST_MENU;                             // ★ 一进来先进菜单
    winner = -1;
    frame  = 0;
    bullets.clear();
    imgTank = Image::FromFile(L"res\\tank.png");  // 16.5.4:素材
    if (imgTank && imgTank->GetLastStatus() != Ok) { delete imgTank; imgTank = nullptr; }
}

void CGame::startGame(bool twoPlayers) {
    tanks[0] = CTank(64, 64, 0);                  // 左上角,朝右
    tanks[1] = CTank(64 * 10, 64 * 6, 180);       // 右下角,朝左
    tanks[1].isComputer = !twoPlayers;            // ★ 单人模式 → 玩家 2 交给电脑
    bullets.clear();
    winner = -1;
    state  = ST_PLAYING;                          // ★ 状态切换
}

void CGame::reset() {
    for (auto &t : tanks) t.hp = 1;               // 回血
    winner = -1;
    state  = ST_MENU;                             // 回菜单
}

看 startGame 最后两行 —— "进入游戏"这件事,本质就是把 state 从 ST_MENU 改成 ST_PLAYING。 状态管理(16.1 说的第三个思路)在这里落地。

六、实现游戏逻辑处理方法(16.7.6)

void CGame::update() {
    if (state != ST_PLAYING) return;              // ★ 不在游戏中,什么都不更新
    ++frame;

    updateTanks();                                // ① 坦克动(含电脑思考)
    updateBullets();                              // ② 子弹飞 + 反弹 + 命中
    removeDeadBullets();                          // ③ 清掉该消失的子弹
    checkVictory();                               // ④ 判胜负(可能切到 ST_OVER)
}

void CGame::updateTanks() {
    for (int i = 0; i < 2; ++i) {
        if (tanks[i].isComputer)                  // ★ 电脑:先"思考"再移动
            tanks[i].think(map, tanks[1 - i]);
        tanks[i].move();                          //    人和电脑,同一套 move
    }
    // 坦克撞坦克:简单处理成"谁也过不去"
    if (hitCircle(tanks[0].x, tanks[0].y, 14, tanks[1].x, tanks[1].y, 14)) {
        // 把后动的那台推回上一帧位置(最简单可靠的做法)
        tanks[1].x = tanks[1].lastX;
        tanks[1].y = tanks[1].lastY;
    }
}

void CGame::updateBullets() {
    for (auto &b : bullets) {
        b.lastX = b.x; b.lastY = b.y;             // ★ 记住旧位置("回退"用)
        b.move();                                  // 内部含撞墙反弹(16.6.5)

        // 子弹 vs 坦克
        for (auto &t : tanks) {
            if (hitCircle(b.x, b.y, 5, t.x, t.y, 14)) {
                t.hp = 0;                          // 一击致命
                b.alive = false;
            }
        }
    }
}

void CGame::removeDeadBullets() {
    // erase-remove 惯用法(13.3.2)
    bullets.erase(
        std::remove_if(bullets.begin(), bullets.end(),
                       [](const CBullet &b) { return !b.alive; }),
        bullets.end());
}

void CGame::checkVictory() {
    if (tanks[0].hp <= 0 && tanks[1].hp <= 0) endGame(-1);   // 同归于尽
    else if (tanks[1].hp <= 0)                endGame(0);
    else if (tanks[0].hp <= 0)                endGame(1);
}

void CGame::endGame(int who) {
    winner = who;
    state  = ST_OVER;                             // ★ 切到结算状态
}

update() 就是 16.2 那张流程图的"更新"一格,四步顺序是固定的: 先让东西动(①②)→ 再清理(③)→ 最后判结果(④)。

为什么先清理再判胜负? —— 因为"子弹击中坦克"是在 ② 里发生的, ③ 把死掉的子弹清掉,④ 才好在干净的状态下判断。顺序错了会出现"看起来打中了但没判胜"。

七、实现游戏绘图处理方法(16.7.7)

void CGame::render(HDC hdc) {
    Graphics g(hdc);
    g.SetSmoothingMode(SmoothingModeAntiAlias);

    switch (state) {
    case ST_MENU:    drawMenu(g);     break;
    case ST_PLAYING: drawWorld(g);    break;
    case ST_OVER:    drawGameOver(g); break;
    }
}

void CGame::drawWorld(Graphics &g) {
    // ① 背景(渐变,16.5.5)
    LinearGradientBrush bg(Point(0, 0), Point(0, 600),
                           Color(255, 10, 20, 40), Color(255, 40, 80, 120));
    g.FillRectangle(&bg, 0, 0, 800, 600);

    // ② 地图(在下面,盖不住角色)
    map.draw(g);

    // ③ 坦克、子弹(注意:后画的在上面)
    for (auto &t : tanks)  t.draw(g);
    for (auto &b : bullets) b.draw(g);

    // ④ 帧数(16.5.3)
    drawFrameCount(g);
}

render 的结构最简:一个 switch (state) 分发到三张"画面"。

这就是状态机在绘制上的样子 —— 同一个 render 函数,三种表现(多态的思想,用 switch 实现了)。

八、实现辅助方法(16.7.8)

// 菜单命中测试(16.6.3)
int CGame::hitTestMenu(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;
}

// 点是不是墙(16.6.8)
bool CGame::blocked(double x, double y) const {
    return map.isWall((int)y / CMap::CELL, (int)x / CMap::CELL);
}

// 开炮(16.6.4)
void CGame::fire(CTank &t) {
    if (!t.hp) return;
    double rad0 = t.angle * PI / 180.0;
    bullets.push_back(CBullet(t.x + 22 * std::cos(rad0),
                              t.y + 22 * std::sin(rad0), t.angle));
}

// 帧数显示(16.5.3)
void CGame::drawFrameCount(Graphics &g) {
    FontFamily ff(L"Microsoft YaHei");
    Font f(&ff, 16, FontStyleRegular, UnitPixel);
    SolidBrush yellow(Color(255, 255, 255, 0));
    wchar_t buf[64];
    swprintf_s(buf, L"帧: %d", frame);
    g.DrawString(buf, -1, &f, PointF(10, 10), &yellow);
}

辅助方法的特点:都很短、都被别的方法反复调用。 把它们单独拎出来,是为了 update/render 读起来像"提纲"而不是"代码堆"。

九、在主窗口中完成全部游戏功能(16.7.9)

最后一步:把 CGame 接进窗口 —— 三处钩子 + 一个循环。

// ── 全局 ────────────────────────────────────────────
CGame g_game;

// ── 窗口消息:把"外部事件"转给 CGame ────────────────
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    switch (msg) {
    case WM_KEYDOWN:  g_game.onKey(true,  (int)wParam); return 0;   // ★ 钩子 1
    case WM_KEYUP:    g_game.onKey(false, (int)wParam); return 0;
    case WM_LBUTTONDOWN: g_game.onClick(LOWORD(lParam), HIWORD(lParam)); return 0;  // ★ 钩子 2
    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hWnd, &ps);
        g_game.render(hdc);                                          // ★ 钩子 3
        EndPaint(hWnd, &ps);
        return 0;
    }
    case WM_DESTROY: PostQuitMessage(0); return 0;
    }
    return DefWindowProc(hWnd, msg, wParam, lParam);
}

// ── 主循环:16.4 的骨架 + CGame ─────────────────────
int APIENTRY wWinMain(...) {
    GdiplusStartupInput gi;
    ULONG_PTR token;
    GdiplusStartup(&token, &gi, NULL);        // 16.5.2 初始化

    // ... 建窗口(16.4.1)...

    g_game.init();

    DWORD last = GetTickCount();
    bool  running = true;
    MSG   msg;
    while (running) {
        if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
            if (msg.message == WM_QUIT) running = false;
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        } else {
            DWORD now = GetTickCount();
            if (now - last < 16) { Sleep(1); continue; }
            last = now;

            g_game.update();                              // 更新一帧
            InvalidateRect(hwnd, NULL, FALSE);            // 请求重画
        }
    }

    GdiplusShutdown(token);                   // 16.5.2 关闭
    return (int)msg.wParam;
}

至此,"消息 → 事件 → 逻辑 → 画面"这条线彻底接通了:

键盘/鼠标  →  WM_ 消息  →  CGame::onKey/onClick  →  改数据
                                    ↑
           每一帧: CGame::update(改数据)→ InvalidateRect → WM_PAINT → render(画数据)

十、动手 Demo:完整跑通一局

验收清单(一项一项打勾):

# 验收项 通过标准
1 程序启动 出现渐变背景的主菜单,三个选项显示正常
2 鼠标悬停 / 点击 点"单人游戏"能进入游戏,点"退出"能关掉程序
3 坦克能动 W A D 控制坦克走、转,撞墙能贴墙滑行
4 能开炮 空格发射,子弹撞墙反弹
5 能误伤自己 子弹反弹回来打中自己 → 立刻结算
6 电脑会追人 单人模式下,电脑坦克绕过墙靠近你
7 结算与重开 一方出局 → 显示"玩家 X 获胜"→ 能回菜单再来一局
8 帧数显示 左上角帧数稳定在 60 上下

看到什么算成功:第 5 项"被自己的子弹打死"能稳定复现 —— 这说明"反弹逻辑 + 碰撞判定 + 胜负判定"三条线全通了。

读结果:

十一、30 秒实验

  1. 把 CGame::update() 第一行 if (state != ST_PLAYING) return; 注释掉 → 菜单状态下坦克也在跑(虽然看不见,但数据在变)。体会"状态闸门"的作用。
  2. 把 render 里的 switch 三个分支全换成 drawWorld(g) → 菜单状态下直接看到战场。这就是"画面由状态决定"的反证。
  3. 把 removeDeadBullets() 那一步删掉 → 子弹越积越多,帧数慢慢往下掉。体会"清理"这一步不是可有可无。

十二、常见误区

  1. "CGame 自己实现移动逻辑" —— 不该。它只调 tank.move(),细节留在各自的类里(封装)。
  2. "bullets 用固定数组也行" —— 要判空位、要手动挪。用 vector + erase-remove(13.3.2),一行搞定。
  3. "update 里先判胜负再处理子弹" —— 顺序反了。先移动/判定命中(①②)→ 清理(③)→ 判胜负(④),否则会漏判。
  4. "窗口里直接调 g_game.render(hdc) 就行" —— 拿不到合法 hdc。必须走 InvalidateRect → WM_PAINT(16.4.4 的账)。
  5. "忘记 GdiplusShutdown 没事" —— 资源不回收。和 15.5"打开要关"一样,是个纪律问题。
  6. "所有状态都在 update 里 if 一遍太啰嗦" —— 啰嗦但清晰。状态机的好处正是"每个状态只做自己的事",别为了少写几个 if 把逻辑搅在一起。
自测三问(点开对答案) 1. **`CGame` 在架构里扮演什么角色?它自己该不该实现细节?** —— **它是"总指挥/调度者"**:**持有地图、坦克、子弹,并按帧驱动它们**。**它不该实现细节** —— **移动归 `CMovable` 子类、绘制归各自的 `draw`、地图判定归 `CMap`**。**`CGame` 只负责"按什么顺序调谁"。** 2. **每帧的 `update()` 分哪四步?顺序为什么不能换?** —— **① 坦克动 → ② 子弹飞并判命中 → ③ 清理死子弹 → ④ 判胜负**。**必须"先动、再清、后判"** —— **否则会出现"打中了却没判胜"或"拿脏数据判胜负"。** 3. **一个窗口怎么在不同界面之间切换?** —— **靠状态机**:**用一个 `state` 变量(`ST_MENU` / `ST_PLAYING` / `ST_OVER`),`update` 和 `render` 里按它分发**。**窗口本身没换,换的是"行为"** —— **这就是"状态管理"。**

相关笔记