前面几节把零件都做出来了,本节把它们装进一个 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> 状态 & 计时
(地图) (两台坦克) (一堆子弹) (菜单/游戏中/结算)
装配的三条原则:
CGame只做"调度",不做"细节" —— 细节都在CTank、CBullet、CMap里;- 谁的数据谁管 ——
CGame不碰坦克的x、y,只调tank.move(); - 一帧就三步 ——
读输入 → 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 的帧数显示)
// ── 下面依次是:公有接口 / 私有帮手 ────────────────
...
};
注意两点:
#pragma once(16.3 讲过的头文件保护);CTank tanks[2]用数组,bullets用vector—— 坦克永远是 2 台(数量固定)→ 数组;子弹数量会变(有增有减)→vector。 "固定用数组、可变用容器",这条选择标准值得记住。
三、声明 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 项"被自己的子弹打死"能稳定复现 —— 这说明"反弹逻辑 + 碰撞判定 + 胜负判定"三条线全通了。
读结果:
- 如果 1~8 都过 —— 恭喜,你已经用 C++ 写完了一个完整的游戏;
- 如果某一项不过 —— 按顺序往回查:
画面不动 → 查
InvalidateRect;点不动 → 查onClick有没有被调;坦克卡死 → 查分轴移动;电脑发愣 → 查 BFS 的vis。
十一、30 秒实验
- 把
CGame::update()第一行if (state != ST_PLAYING) return;注释掉 → 菜单状态下坦克也在跑(虽然看不见,但数据在变)。体会"状态闸门"的作用。 - 把
render里的switch三个分支全换成drawWorld(g)→ 菜单状态下直接看到战场。这就是"画面由状态决定"的反证。 - 把
removeDeadBullets()那一步删掉 → 子弹越积越多,帧数慢慢往下掉。体会"清理"这一步不是可有可无。
十二、常见误区
- "
CGame自己实现移动逻辑" —— 不该。它只调tank.move(),细节留在各自的类里(封装)。 - "
bullets用固定数组也行" —— 要判空位、要手动挪。用vector+ erase-remove(13.3.2),一行搞定。 - "
update里先判胜负再处理子弹" —— 顺序反了。先移动/判定命中(①②)→ 清理(③)→ 判胜负(④),否则会漏判。 - "窗口里直接调
g_game.render(hdc)就行" —— 拿不到合法hdc。必须走InvalidateRect→WM_PAINT(16.4.4 的账)。 - "忘记
GdiplusShutdown没事" —— 资源不回收。和 15.5"打开要关"一样,是个纪律问题。 - "所有状态都在
update里if一遍太啰嗦" —— 啰嗦但清晰。状态机的好处正是"每个状态只做自己的事",别为了少写几个if把逻辑搅在一起。
自测三问(点开对答案)
1. **`CGame` 在架构里扮演什么角色?它自己该不该实现细节?** —— **它是"总指挥/调度者"**:**持有地图、坦克、子弹,并按帧驱动它们**。**它不该实现细节** —— **移动归 `CMovable` 子类、绘制归各自的 `draw`、地图判定归 `CMap`**。**`CGame` 只负责"按什么顺序调谁"。** 2. **每帧的 `update()` 分哪四步?顺序为什么不能换?** —— **① 坦克动 → ② 子弹飞并判命中 → ③ 清理死子弹 → ④ 判胜负**。**必须"先动、再清、后判"** —— **否则会出现"打中了却没判胜"或"拿脏数据判胜负"。** 3. **一个窗口怎么在不同界面之间切换?** —— **靠状态机**:**用一个 `state` 变量(`ST_MENU` / `ST_PLAYING` / `ST_OVER`),`update` 和 `render` 里按它分发**。**窗口本身没换,换的是"行为"** —— **这就是"状态管理"。**- ☐ 下一节(16.8 本章总结)
相关笔记
- 游戏功能的详细设计及使用举例 —— 上一节:造零件
- 本章总结 —— 下一节:本章知识地图 + 全书收官
- 创建游戏框架 —— 消息循环与 PeekMessage
- 绘图库GDI+的使用 —— GDI+ 初始化与绘制
- C++类 ——
CGame是一个"组合型"类 - 修正序列算法 —— erase-remove 清死亡子弹
- 面向对象的特点 —— 封装在这里落地
- 🔷 C++基础 · 索引 —— 全课路线图