本节是全章最长、最硬的一节,把 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; // 见"五、画出移动的坦克"
};
两个设计决定:
- 按键状态存在对象里(
keyLeft等),不直接读键盘 —— 好处:电脑坦克只要"假装"把keyUp置true,就能和人用同一套移动代码。 - 转向和前进分开 —— 可以让坦克原地打转(这符合"任意角度"的玩法)。
子弹:沿角度飞、撞墙反弹、有寿命
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 的关键就在两行:
vis数组:防止绕圈(不然会在两个格子之间反复横跳,永远出不来);pre数组:记住"我从哪儿来" —— 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 步:坦克能走、撞墙能停
- 用
WASD开一辆坦克撞墙; - 看到什么算成功:撞上墙后"贴着墙滑行",而不是完全卡死(分轴移动的功劳)。
第 2 步:子弹能反弹
- 朝一面墙开火,看它弹回来;
- 看到什么算成功:子弹撞竖墙后左右对称地弹回(
a' = 180° − a); 斜着打墙角时,两个方向都反(两颗子弹同时发,一颗打竖墙、一颗打横墙,对比角度)。
第 3 步:电脑坦克会追人
- 打开单人模式,站着不动,看电脑坦克怎么绕过来;
- 看到什么算成功:它会绕过墙、沿最短路径靠近你(不是傻乎乎撞墙); 你换个位置躲,它会重新规划路线(每帧重算 BFS)。
读结果:
- 第 1 步的"贴墙滑行" —— 是"手感"的第一块基石,值得多花十分钟调;
- 第 2 步最值得玩:故意把坦克停在斜面上,体验"被自己的反弹子弹打中" —— 这就是这个游戏的灵魂;
- 第 3 步如果电脑坦克"抖"(原地左右摇摆)—— 是因为
diff在 ±5 度边界反复翻越; 加一点"滞后"(比如 10 度内不转向)就稳了。
十一、30 秒实验
- 把子弹的
speed从 5.0 改成 1.0 → 子弹慢得像蜗牛,反弹看得一清二楚。这是调参最快的"慢动作模式"。 - 把
hitCircle里的dx*dx + dy*dy改成std::sqrt(dx*dx + dy*dy)再比半径 → 结果一样,但每帧慢一点。体会"能不开方就不开方"。 - 把 BFS 里的
vis[nr][nc] = true注释掉 → 电脑坦克开始"绕圈"或不走了。这就是"不记录访问过"的后果。
十二、常见误区
- "角度用弧度更方便" —— 本章统一用角度制,只在
cos/sin前转弧度。混用是 bug 的第一来源。 - "撞墙就整个移动取消" —— 会卡死。要分轴移动(先试横向再试纵向),才能贴墙滑行。
- "反弹就是
angle = -angle" —— 只对横墙。撞竖墙要angle = 180 − angle—— 两个都写,斜着撞才正确。 - "
Graphics旋转完不用恢复" —— 必须Restore。否则后面画的每个对象都跟着歪。 - "碰撞检测必须开方算距离" —— 比平方就行(
d² ≤ r²)。每帧算几十次,省下来很可观。 - "菜单得用按钮控件" —— 不用。矩形 + 命中测试足矣,而且画成什么样完全由你定(16.4.3 的账)。
- "单人模式和双人模式要写两套逻辑" —— 不用。一个
isComputer标志 + 一个think()就统一了。 - "电脑坦克可以不走格子、直接朝玩家走" —— 会穿墙。必须走 BFS 找格子路径,否则它要么撞墙、要么作弊。
自测三问(点开对答案)
1. **坦克撞墙时,为什么要"分轴移动"?** —— **为了避免卡死**。**一步到位地移动,只要落点进墙就整步取消,坦克会"顶"在墙上不动**;**分轴移动则是"横向被挡就只走纵向",于是能顺着墙滑行** —— **这是手感的关键。** 2. **子弹撞竖墙和撞横墙,角度分别怎么变?** —— **撞竖墙:`a' = 180° − a`**(**x 分量取反**);**撞横墙:`a' = −a`**(**y 分量取反**)。**两个方向都要写,斜着撞才正确。** 3. **BFS 寻路里 `vis` 和 `pre` 两个数组各管什么?** —— **`vis` 管"这个格子来过没有"(防止绕圈)**;**`pre` 管"我是从哪儿来的"(用来从终点倒推出整条最短路)**。**少任何一个,寻路都不成立。**- ☐ 下一节(16.7 实现完整的游戏)
相关笔记
- 绘图库GDI+的使用 —— 上一节:画笔画布
- 实现完整的游戏 —— 下一节:把所有零件装进
CGame - 抽象类 ——
CMovable的纯虚函数 - 利用虚函数实现动态绑定 —— 虚析构与多态
- 向量类模板 ——
vector装子弹 - 修正序列算法 —— erase-remove 清死亡子弹
- 读取文本文件 —— 读地图文件
- 结构体数组 —— 地图就是二维数据
- 🔷 C++基础 · 索引 —— 全课路线图