☰
C++编写实现飞机大战
2026/10/26 2:27:31 网站建设 项目流程

前言


「飞机大战」是最常见的编程练手项目之一:玩家控制一架飞机,发射子弹,击落从上方落下的敌机,统计得分。听起来需要图形库,实际上游戏的核心——对象状态、主循环、碰撞检测、渲染——与图形 API 无关,完全可以用标准 C++ 写成可移植的逻辑层。


常见的误解是「写游戏必须先用<graphics.h>或 EasyX」。这些库确实能画出彩色图形,但它们是平台相关的:<graphics.h>是古老的 BGI/Turbo C 遗产,现代 Windows 上通常指 EasyX;<conio.h>是 Windows 控制台专用,Linux 上没有。把逻辑和渲染耦合在一起,换平台就得重写。


本文采用「逻辑核心 + 字符渲染」的结构:游戏状态用标准容器维护,每帧按固定时间步更新,最后把状态画到一个字符画布上。这样代码在 C++17 下只依赖标准库,读者可以先把逻辑跑通,再按需替换渲染层。实时键盘输入需要平台 API,本文会说明这一点,但示例用脚本化输入,保证可复现。


一、对象与状态


一个最小可玩的飞机大战包含三类对象:




对象状态行为



玩家位置左右移动、发射子弹

子弹位置、速度向上飞行,出界销毁

敌机位置、速度向下飞行,出界或被打中销毁



位置用二维向量表示。为了简单,这里用double存坐标,渲染时再转成整数格子:


// 适用于 C++17
struct Vec2 {
double x = 0.0;
double y = 0.0;
};

struct Bullet {
Vec2 pos;
double vy = -30.0; // 向上为负
};

struct Enemy {
Vec2 pos;
double vy = 8.0;
};

「存活」状态不放在对象里,而是用「从容器中移除」表示。这样遍历时不需要额外判断,代价是移除操作要小心迭代器失效。


二、主循环与时间步


游戏循环的基本结构是:


while running:
input = poll_input()
update(input, dt)
render()

关键在dt(delta time,距上一帧的秒数)。如果每帧固定移动「一个单位」,游戏速度就会随帧率变化:60 帧每秒时敌人每秒走 60 格,30 帧时只走 30 格。正确做法是让速度乘以dt,使位移与真实时间成正比。


本文的step接受一个固定的dt,由调用方决定。真实游戏里通常用std::chrono::steady_clock计算两次循环之间的间隔,并对过大的dt做截断(比如上限 0.1 秒),防止程序卡顿后对象「瞬移」。


三、碰撞检测与渲染


最简单的是轴对齐包围盒(axis-aligned bounding box, AABB):两个矩形在 x、y 方向都有重叠即判定碰撞。对字符格子的游戏,可以直接用「距离小于一个格子」近似:


// 适用于 C++17
bool hit(const Vec2& a, const Vec2& b) {
return std::abs(a.x - b.x) < 1.0 && std::abs(a.y - b.y) < 1.0;
}

这种判定对快速移动的子弹可能「穿透」:如果一帧内子弹从敌人上方直接跳到下方,两个采样点都不在碰撞范围内,就漏判了。解决办法是连续碰撞检测(扫掠检测),或者限制单帧位移不超过一个格子。本文的示例速度较低,离散检测足够。


3.1 渲染


把游戏状态画到一个kHeight行、kWidth列的字符画布上:


// 适用于 C++17
std::vector<std::string> canvas(kHeight, std::string(kWidth, ' '));

每个对象根据坐标写入一个字符。写入前必须做边界检查,否则canvas[cy][cx]越界是未定义行为:


// 适用于 C++17
auto plot = [&](double x, double y, char c) {
const int cx = static_cast<int>(x);
const int cy = static_cast<int>(y);
if (cx >= 0 && cx < kWidth && cy >= 0 && cy < kHeight) {
canvas[cy][cx] = c;
}
};

字符渲染的刷新方式是「清屏 + 重画」。在终端里直接输出换行会导致画面滚动,真实项目通常用平台 API 把光标移回左上角,或者用双缓冲减少闪烁。本文只负责生成一帧的内容,输出方式交给调用方。


四、实时输入与平台差异


上面的示例用脚本化输入,是为了让逻辑可复现。真实的飞机大战需要非阻塞键盘输入,这部分无法只用标准 C++ 完成:



  • Windows 控制台:<conio.h>的_kbhit()和_getch(),平台相关。

  • Linux/macOS 终端:需要<termios.h>修改终端模式,或用 ncurses 之类的库。

  • 图形界面:EasyX(Windows)、SFML、SDL、Qt 等,各自有事件循环。


这些都属于平台/第三方依赖,使用时要明确标注适用平台。逻辑层保持不变,只替换输入和渲染,这是分层的价值。


完整示例


下面的程序把上面几部分组合起来,用脚本化的输入模拟 200 帧,最后打印得分和一帧画面。


// 适用于 C++17
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>

struct Vec2 {
double x = 0.0;
double y = 0.0;
};

struct Bullet {
Vec2 pos;
double vy = -30.0;
};

struct Enemy {
Vec2 pos;
double vy = 8.0;
};

class Game {
public:
static constexpr int kWidth = 40;
static constexpr int kHeight = 20;
static constexpr double kPlayerSpeed = 20.0;

Game() { player_.pos = Vec2{kWidth / 2.0, kHeight - 2.0}; }

void step(int moveDir, bool fire, double dt) {
// 玩家移动,限制在画布内
player_.pos.x += moveDir * kPlayerSpeed * dt;
player_.pos.x = std::clamp(player_.pos.x, 0.0, kWidth - 1.0);

// 发射,带冷却
cooldown_ -= dt;
if (fire && cooldown_ <= 0.0) {
bullets_.push_back(Bullet{Vec2{player_.pos.x, player_.pos.y - 1.0}});
cooldown_ = 0.2;
}

// 定时生成敌机
spawnTimer_ -= dt;
if (spawnTimer_ <= 0.0) {
rng_ = rng_ * 1103515245u + 12345u;
const double x = static_cast<double>(rng_ % kWidth);
enemies_.push_back(Enemy{Vec2{x, 0.0}});
spawnTimer_ = 0.5;
}

// 更新位置
for (auto& b : bullets_) {
b.pos.y += b.vy * dt;
}
for (auto& e : enemies_) {
e.pos.y += e.vy * dt;
}

// 碰撞检测:标记命中
for (auto& b : bullets_) {
for (auto& e : enemies_) {
if (std::abs(b.pos.x - e.pos.x) < 1.0 &&
std::abs(b.pos.y - e.pos.y) < 1.0) {
b.pos.y = -100.0; // 移出画面,稍后统一清理
e.pos.y = 100.0;
++score_;
}
}
}

// 统一清理出界对象
bullets_.erase(
std::remove_if(bullets_.begin(), bullets_.end(),
[](const Bullet& b) { return b.pos.y < 0.0 || b.pos.y > kHeight; }),
bullets_.end());
enemies_.erase(
std::remove_if(enemies_.begin(), enemies_.end(),
[](const Enemy& e) { return e.pos.y < 0.0 || e.pos.y > kHeight; }),
enemies_.end());
}

std::vector<std::string> render() const {
std::vector<std::string> canvas(kHeight, std::string(kWidth, ' '));
auto plot = [&](double x, double y, char c) {
const int cx = static_cast<int>(x);
const int cy = static_cast<int>(y);
if (cx >= 0 && cx < kWidth && cy >= 0 && cy < kHeight) {
canvas[cy][cx] = c;
}
};
for (const auto& b : bullets_) plot(b.pos.x, b.pos.y, '|');
for (const auto& e : enemies_) plot(e.pos.x, e.pos.y, 'V');
plot(player_.pos.x, player_.pos.y, 'A');
return canvas;
}

int score() const { return score_; }

private:
Vec2 player_;
std::vector<Bullet> bullets_;
std::vector<Enemy> enemies_;
double cooldown_ = 0.0;
double spawnTimer_ = 0.0;
std::uint32_t rng_ = 12345u;
int score_ = 0;
};

int main() {
Game game;
const double dt = 1.0 / 20.0;

for (int frame = 0; frame < 200; ++frame) {
const int dir = ((frame / 20) % 2 == 0) ? 1 : -1;
const bool fire = (frame % 4 == 0);
game.step(dir, fire, dt);
}

for (const auto& line : game.render()) {
std::cout << line << '\n';
}
std::cout << "score = " << game.score() << '\n';
return 0;
}

说明几点:



  • std::clamp需要<algorithm>和 C++17。

  • static constexpr成员在 C++17 起是隐式内联的,类外不需要再定义。

  • 碰撞后没有立即从vector中删除,而是把坐标移到画面外,最后统一erase。这是为了避免「边遍历边删除」导致的迭代器失效。erase(remove_if(...), end())是标准的 erase-remove 惯用法。

  • 随机数用了一个简单的线性同余生成器,只为让敌机位置有变化,不追求统计质量。生产代码应当用<random>。

  • 得分只统计命中次数,不涉及具体数值平衡。


按标准语义,程序会打印 20 行字符画布和一行得分;具体得分取决于脚本化输入的轨迹,这里不预先给出数字,因为那需要逐帧模拟才能确定。


常见坑点



  1. ❌ 把逻辑和<graphics.h>绑死:


``cpp #include <graphics.h> // 平台相关,且不是标准头`` ✅ 逻辑层只用标准库,渲染层单独封装并标注平台。



  1. ❌ 用固定位移代替dt:


``cpp enemy.pos.y += 1; // 帧率越高,敌人越快`✅enemy.pos.y += speed * dt;`。



  1. ❌ 边遍历vector边erase:


``cpp for (auto it = v.begin(); it != v.end(); ++it) { if (dead(*it)) v.erase(it); // 迭代器失效 }`` ✅ 用 erase-remove 惯用法,先标记后统一删除。



  1. ❌ 字符坐标不检查边界就写入:


``cpp canvas[cy][cx] = 'V'; // 越界是 UB`✅ 写入前判断0 <= cx < width && 0 <= cy < height`。



  1. ❌ 用==判断碰撞:


``cpp if (bullet.pos.x == enemy.pos.x) // 浮点几乎不会相等`` ✅ 用范围判断或距离平方比较。



  1. ❌ 每帧system("cls")清屏。


✅ 用平台的光标定位 API,或输出足够的换行;system调用开销大且有注入风险。



  1. ❌ 子弹速度过快导致穿透。


✅ 限制单帧位移,或做扫掠检测。



  1. ❌ 以为Sleep(16)就能稳定 60 帧。


✅ 用实际经过的时间计算dt,并对异常大的间隔做截断。


总结




模块职责实现要点



对象位置、速度简单 struct + 容器

主循环输入、更新、渲染用dt保证速度与帧率无关

碰撞命中判定AABB 或距离阈值,注意穿透

渲染状态转字符画布边界检查,双缓冲

输入键盘平台 API,单独封装



飞机大战的复杂度不在画面,而在结构:对象怎么存、循环怎么推进、碰撞怎么判、渲染怎么解耦。把逻辑层写成只依赖标准库的纯函数式更新,图形部分随时可以替换;这比一上来就调图形库更能锻炼工程能力。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询