C++ CMD 互动动画:按键触发爆炸效果
🧨 第2章 鞭炮连锁引爆
📌 摘要
本章我们做一串"会自己传话"的鞭炮:引线从屏幕左边一路烧到右边,烧到哪颗鞭炮,那颗就炸;炸开的火星和冲击波又会去点隔壁的鞭炮,于是一颗接一颗炸成一长串,像多米诺骨牌倒下一样壮观。我们会学会用 vector<int> toExplode 这个"传话队列"实现连锁传播、用浮点数 burnPos 让引线烧得平滑、用半圆公式让粒子向上飞溅、用背景红黄闪烁模仿爆炸闪光,最后全部炸完自动重置再来一轮。这是整个系列里逻辑最复杂、也最好玩的一篇。
📹 完整版运行视频 --- 鞭炮连锁引爆完整演示,包含引线燃烧、连锁反应、粒子飞散、闪光音效、自动重置全流程,Windows 控制台演示。
CMD_Firecracker
📚 目录
- [📌 摘要](#📌 摘要)
- [🛠️ 编译环境说明](#🛠️ 编译环境说明)
- [1. 🖥️ 搭好画板 --- 窗口检测、隐藏光标、双缓冲框架](#1. 🖥️ 搭好画板 — 窗口检测、隐藏光标、双缓冲框架)
- [2. 📍 摆一排鞭炮 --- struct Firecracker 与均匀分布](#2. 📍 摆一排鞭炮 — struct Firecracker 与均匀分布)
- [3. 〰️ 引线烧起来 --- burnPos 浮点递增与双色绘制](#3. 〰️ 引线烧起来 — burnPos 浮点递增与双色绘制)
- [4. ⌨️ 狂按加速烧 --- _kbhit 连读与 KEY_BOOST](#4. ⌨️ 狂按加速烧 — _kbhit 连读与 KEY_BOOST)
- [5. 💥 点燃第一个 --- 阈值检测与三态切换](#5. 💥 点燃第一个 — 阈值检测与三态切换)
- [6. 🔗 连锁引爆 --- 队列传播与去重(核心难点)](#6. 🔗 连锁引爆 — 队列传播与去重(核心难点))
- [7. 🎆 爆炸粒子 --- 向上半球飞散与重力](#7. 🎆 爆炸粒子 — 向上半球飞散与重力)
- [8. ⚡ 闪光与音效 --- 背景闪烁与 Beep](#8. ⚡ 闪光与音效 — 背景闪烁与 Beep)
- [9. 🔄 全炸完重来 --- allDone 与 respawnDelay](#9. 🔄 全炸完重来 — allDone 与 respawnDelay)
- [10. 🧩 拼合步骤 --- 从零件到成品](#10. 🧩 拼合步骤 — 从零件到成品)
- [11. 🏆 完整版出炉 --- 完整代码与模块对照表](#11. 🏆 完整版出炉 — 完整代码与模块对照表)
- [12. ✨ 架构总结 --- 多结构体协作与事件驱动](#12. ✨ 架构总结 — 多结构体协作与事件驱动)
- [📖 头文件速查](#📖 头文件速查)
- [🎉 系列完结](#🎉 系列完结)
🛠️ 编译环境说明
本篇代码在 Windows + MinGW (g++) 下编译运行,使用的是 Windows 控制台专用的双缓冲 API(WriteConsoleOutput、GetConsoleScreenBufferInfo)。如果你还没配好环境,请先看第 0 章《编译环境与第一个方块》,里面有详细的 g++ 安装步骤和编译命令。
编译命令(保存为 Firecracker.cpp 后):
bash
g++ Firecracker.cpp -o Firecracker.exe -std=c++11
./Firecracker.exe
运行前请把控制台窗口拉到至少 50 列宽、15 行高,否则程序会提示"请拉大控制台窗口"并退出。
跨平台提示 :本系列依赖 <windows.h> 和 <conio.h>,属于 Windows 专属。在 Linux / macOS 下想跑同样效果,可以把 <conio.h> 换成 <ncurses.h>,把 WriteConsoleOutput 换成 ncurses 的 printw + refresh() 双缓冲思路。核心的"连锁传播队列"逻辑与平台无关,所以即使换库,本章学到的最难的连锁引爆思想仍然适用。
1. 🖥️ 搭好画板 --- 窗口检测、隐藏光标、双缓冲框架
🎯 我们要做什么
每次画画之前,我们都得先把画板支起来、把那根烦人的光标藏起来、再准备一张"看不见的草稿纸"。在 CMD 动画里,这三件事就是:检测窗口大小 (画板够不够大)、隐藏光标 (别让闪烁的小白条打扰画面)、建立双缓冲(先在内存里画完整张图,再一口气贴到屏幕上,这样不会闪屏)。这一节我们把这三块"画板准备"工作搭好。
下面这段代码是整个程序的"开场仪式",负责把画板摆正:
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
return 0;
}
🐧 Linux / macOS 用户看这里
Windows 用的是
GetStdHandle+CONSOLE_CURSOR_INFO+GetConsoleScreenBufferInfo来初始化画板,Linux/macOS 上对应的是 ncurses 库的initscr()+curs_set(0)+getmaxx/getmaxy,双缓冲则用erase()+mvaddch+refresh()三步走。颜色系统也要从 Windows 的位运算换成init_pair+COLOR_PAIR。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp#include <ncurses.h> #include <unistd.h> // 颜色对编号:fg(0..7) × bgMode(0黑/1红/2黄) int colorPair(int fg, int bgMode) { return fg * 3 + bgMode + 1; } int main() { initscr(); // 进入 ncurses 模式(替代 GetStdHandle) cbreak(); // 关闭行缓冲,按键立即可读 noecho(); // 不回显按键 curs_set(0); // 隐藏光标(替代 HideCursor) nodelay(stdscr, TRUE); // getch 非阻塞(替代 _kbhit) keypad(stdscr, TRUE); // 启用功能键 // 颜色初始化(ncurses 要预先注册颜色对) if (has_colors()) { start_color(); int bgTab[3] = {COLOR_BLACK, COLOR_RED, COLOR_YELLOW}; for (int bg = 0; bg < 3; ++bg) for (int fg = 0; fg < 8; ++fg) init_pair(colorPair(fg, bg), fg, bgTab[bg]); } int winWidth = getmaxx(stdscr); // 替代 GetConsoleScreenBufferInfo int winHeight = getmaxy(stdscr); if (winWidth < 50 || winHeight < 15) { endwin(); cout << "请拉大终端窗口!" << endl; return 1; } // ... 主循环:erase() 清屏 → mvaddch 画图 → refresh() 贴屏(双缓冲) endwin(); // 退出 ncurses(必须调用,恢复终端) return 0; }对照关系:
initscr()=GetStdHandle、curs_set(0)=HideCursor()、getmaxx/getmaxy=GetConsoleScreenBufferInfo、erase()+refresh()=WriteConsoleOutput双缓冲。
📝 逐行看懂这段代码
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);:拿到"标准输出"的把手。你可以把HANDLE想成一扇门的钥匙牌,有了它才能让 Windows 知道你要操控哪一块屏幕。void HideCursor():自定义函数,专门用来藏光标。CONSOLE_CURSOR_INFO ci;:声明一个"光标信息"小卡片。GetConsoleCursorInfo(hOut, &ci);:先读出当前光标长什么样(比如是不是闪烁的)。ci.bVisible = false;:把"是否可见"这一栏改成否。SetConsoleCursorInfo(hOut, &ci);:把改好的卡片塞回去,光标就消失了。srand((unsigned)time(NULL));:给随机数"喂一颗种子",种子是当前时间,这样每次运行随机结果都不同。后面爆炸粒子的颜色、速度都靠它。CONSOLE_SCREEN_BUFFER_INFO csbi;:声明一个"屏幕信息卡",用来问窗口多大。GetConsoleScreenBufferInfo(hOut, &csbi);:把当前窗口的尺寸等信息填进卡里。winLeft / winTop:窗口可见区域的左上角坐标(通常是 0,但多屏时可能不是)。winWidth = ... Right - winLeft + 1:可见宽度。为什么要+1?因为坐标从 0 算起,比如 Left=0、Right=79,那宽度其实是 80 个格子,所以79 - 0 + 1 = 80。if (winWidth < 50 || winHeight < 15):太小的窗口装不下 10 颗鞭炮和粒子,所以直接劝退。return 1;:返回非零表示"出错了,没正常跑完"。
💡 背后的原理
什么是"双缓冲"?为什么能防闪屏?
想象你在黑板上画画。如果你画一笔、擦一笔、再画一笔,台下观众会看到粉笔灰乱飞、画面忽明忽暗,体验很差。聪明的做法是:先在一张"草稿纸"上把整幅画偷偷画好,等下课铃响的瞬间,把草稿纸"啪"一下贴到黑板上------观众只看到一张完整的画,一点也不闪。
CMD 双缓冲就是这个思路:我们用 vector<CHAR_INFO> screenBuf(一维数组,存的就是每个格子的字符和颜色)当"草稿纸",所有绘制操作都先写进这个数组,最后一步用 WriteConsoleOutput 把整张草稿纸一口气"贴"到屏幕上。这样 30 FPS 的动画也不会闪。
HANDLE 是什么?为什么到处都要它?
HANDLE 是 Windows 给你的一张"通行证"。Windows 系统里有成百上千个对象(窗口、文件、画刷、屏幕缓冲区......),你不直接碰它们,而是拿着"通行证"告诉系统"我要操作这个对象"。GetStdHandle(STD_OUTPUT_HANDLE) 就是去窗口领证处,领一张"标准输出(也就是这块 CMD 屏幕)的通行证",后面所有画图函数都要出示这张证。
为什么窗口尺寸要用 srWindow 而不是 dwSize?
CONSOLE_SCREEN_BUFFER_INFO 里有两套尺寸:dwSize 是整个缓冲区大小(可能上千行,可以往上滚),srWindow 是当前你能看见的那一小块 。我们画画只关心观众看得见的部分,所以用 srWindow。如果用 dwSize,你画在看不见的地方就白画了。
⚠️ 注意事项
- 光标隐藏必须放在
main开头,否则第一帧就会看到光标在闪。 - 窗口检测的
+1很容易漏,漏了会让宽度少算一格,最后一列画不上。 GetStdHandle在某些重定向场景(比如把输出 pipe 到文件)会返回无效句柄,那时画图会失败;本系列默认直接在 CMD 里跑,没问题。
2. 📍 摆一排鞭炮 --- struct Firecracker 与均匀分布
🎯 我们要做什么
画板支好之后,我们要往上面"摆一排鞭炮"。在现实里,鞭炮是一颗颗独立的小东西,每颗都有自己的位置、有"还没点着 / 正在炸 / 已经炸完"三种状态。在 C++ 里,描述这种"一类东西、每个有各自的属性"最好的工具就是 struct(结构体)。这一节我们定义鞭炮结构体,然后用一个循环把 10 颗鞭炮均匀地排在屏幕底排。
cpp
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
// ------ 在 main 内 ------
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行(在鞭炮上方一行)
const int MAX_BOMBS = 10;
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
🐧 Linux / macOS 用户看这里
Windows 用
screenBuf[idx].Char.AsciiChar+Attributes写鞭炮字符,Linux/macOS 上对应的是 ncurses 的mvaddch(y, x, ch | COLOR_PAIR(n)),把字符和颜色用|拼成一个chtype。struct Firecracker一字不改。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// struct Firecracker 完全相同,不依赖平台 struct Firecracker { int x, state, explodeTimer; }; // 生成逻辑一样(均匀分布),绘制时用 mvaddch 替代 screenBuf: for (size_t i = 0; i < bombs.size(); ++i) { if (bombs[i].state == 0) mvaddch(BOMB_Y, bombs[i].x, '*' | COLOR_PAIR(colorPair(COLOR_RED, bgMode))); } // bgMode 在绘制阶段决定(0黑/1红/2黄),见第8章对照关系:
screenBuf[idx] = ...→mvaddch(y, x, ch | COLOR_PAIR(n)),struct 和均匀分布公式不变。
📝 逐行看懂这段代码
struct Firecracker { ... };:定义一个名叫"鞭炮"的结构体,把一颗鞭炮需要的所有信息打包在一起。int x;:这颗鞭炮在第几列(横向坐标)。int state;:状态码,0 = 安安静静等着、1 = 正在噼里啪啦、2 = 炸完了变空。int explodeTimer;:爆炸还能持续多少帧,倒数到 0 就从"爆炸中"切到"已爆完"。const int BOMB_Y = winHeight - 3;:鞭炮放在倒数第 3 行,给底部留两行:一行画引线提示、一行画提示文字。const int FUSE_Y = BOMB_Y - 1;:引线画在鞭炮上面一行,像真鞭炮那样引线从顶上伸出来。const int MAX_BOMBS = 10;:一共摆 10 颗,固定数量方便算位置。vector<Firecracker> bombs;:用一个"动态数组"装这 10 颗鞭炮,方便后面遍历。bombs.reserve(MAX_BOMBS);:提前预留 10 个位置,避免push_back时反复扩容搬家,跑得更快。b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));:均匀分布公式的核心,下面详细讲。b.state = 0; b.explodeTimer = 0;:刚摆好都是没点着的、计时器为 0。bombs.push_back(b);:把这颗鞭炮塞进数组末尾。
💡 背后的原理
均匀分布公式:为什么是 (i+1) * (winWidth / (MAX_BOMBS+1))?
想象一条长 80 厘米的绳子,要均匀挂 10 颗鞭炮。如果第 1 颗挂在 0 厘米处(最左端),最后一颗挂在 80 厘米处(最右端),那就是 i * (winWidth / (MAX_BOMBS-1)),两端顶到边。
但我们希望鞭炮两端都留点空白 (左边给引线起点、右边别顶死),所以采用"把宽度切成 MAX_BOMBS+1 等份、鞭炮放在每等份的分割点上"的做法:把 80 切成 11 段,每段约 7.3 格,第 1 颗在 7.3、第 2 颗在 14.6......第 10 颗在 72.7。这样左右两边都自然留白,看起来最舒服。i+1 是因为第 0 颗要从第 1 个分割点算起。
struct vs 普通变量:为什么要打包?
如果不打包,你得写 int bomb1_x, bomb1_state, ...; int bomb2_x, ...; 一颗鞭炮三个变量,10 颗就 30 个变量,乱成一锅粥。struct 把"一颗鞭炮的全部信息"装进一个盒子里,于是你只需 bombs[i].x 就能拿到第 i 颗的横坐标,干净、不容易错。这是"把数据按真实事物归类"的思想,后面我们还会定义第二个结构体 Particle(粒子),两个结构体一起协作完成整个动画。
vector::reserve 是什么?为什么有用?
vector 默认是"装多少长多少",容量不够时会偷偷找一块更大的内存、把旧数据搬过去。搬家很费时间。reserve(10) 提前告诉它"我最多要 10 个,你先准备好",它就一次到位,后面 push_back 不再搬家,循环跑得又快又稳。
⚠️ 注意事项
winWidth / (MAX_BOMBS + 1)是整数除法,会丢小数,所以鞭炮位置是近似均匀,不太精确,但视觉上够用了。想要更精确可以用浮点位置,但绘制时还要取整,反而麻烦。BOMB_Y和FUSE_Y必须在winHeight确定之后才能算,所以代码顺序不能乱。- 鞭炮数量改了(比如改成 20 颗),均匀公式里的
MAX_BOMBS + 1也要同步改,否则间距就错了。
3. 〰️ 引线烧起来 --- burnPos 浮点递增与双色绘制
🎯 我们要做什么
鞭炮摆好了,但它们不会自己炸,得有一根"引线"先把火引过去。在屏幕上,引线就是 FUSE_Y 那一行的一排 - 字符。我们要让这根引线"从左往右慢慢烧":烧过的部分变灰、没烧的部分还亮黄。要做到"慢慢烧、烧得平滑",关键是用一个浮点数 burnPos 记录火烧到了第几格,每帧只往前蹭一点点。
cpp
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
double burnPos = 0.0; // 当前引线燃烧到的 x 坐标
// ------ 主循环里每帧 ------
burnPos += BASE_BURN_SPEED; // 基础燃烧,每帧前进 0.08 格
// ------ 绘制引线部分 ------
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
// 已燃烧部分(暗灰色)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8 | (bgColor & 0xF0);
} else if (x < winWidth - 1) {
// 未燃烧部分(亮黄)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14 | (bgColor & 0xF0);
}
}
🐧 Linux / macOS 用户看这里
Windows 用
screenBuf[idx].Attributes = 8 | (bgColor & 0xF0)拼灰前景+背景,Linux/macOS 上对应的是 ncurses 的COLOR_PAIR(colorPair(COLOR_WHITE, bgMode)),颜色对在初始化时预先注册好,运行时直接取用。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// burnPos 浮点递增逻辑完全一样,绘制引线用 mvaddch + COLOR_PAIR: for (int x = 0; x < winWidth; ++x) { if (x <= (int)burnPos) // 已燃烧(灰)------ COLOR_WHITE 在多数终端显示为灰 mvaddch(FUSE_Y, x, '-' | COLOR_PAIR(colorPair(COLOR_WHITE, bgMode))); else if (x < winWidth - 1) // 未燃烧(黄) mvaddch(FUSE_Y, x, '-' | COLOR_PAIR(colorPair(COLOR_YELLOW, bgMode))); }对照关系:
screenBuf[idx] = {'-', 8|(bg&0xF0)}→mvaddch(y, x, '-' | COLOR_PAIR(...)),burnPos 逻辑不变。
📝 逐行看懂这段代码
const double BASE_BURN_SPEED = 0.08;:每帧引线往前烧 0.08 格。注意是double(小数),不是整数。double burnPos = 0.0;:火烧到的"浮点位置",从 0 开始。因为每帧只加 0.08,所以它会在 0.08、0.16、0.24......慢慢长,肉眼几乎看不出它在动,但几秒后就能烧到第一颗鞭炮。burnPos += BASE_BURN_SPEED;:每帧都让火前进一小步,这是"自动燃烧"的来源。for (int x = 0; x < winWidth; ++x):逐格扫描引线那一行。int idx = FUSE_Y * winWidth + x;:把"第 y 行第 x 列"换算成"草稿纸数组"里的一维下标。公式是行号 * 宽度 + 列号。if (x <= (int)burnPos):这一格在火烧到的地方的左边(含),说明已经烧过,画暗灰色。screenBuf[idx].Char.AsciiChar = '-';:引线统一用-表示。screenBuf[idx].Attributes = 8 | (bgColor & 0xF0);:颜色 8 是暗灰;bgColor & 0xF0是保留背景色的高 4 位(爆炸时背景会闪红/黄,这里要保住它)。else if (x < winWidth - 1):还没烧到的部分,画亮黄。winWidth - 1是留最后一格不画,避免越界。
💡 背后的原理
为什么用浮点数 burnPos,而不是整数?
整数每帧至少加 1,意味着每帧引线就"跳"一整格,画面会一格一格地"啪啪"前进,很不平滑。用浮点数 0.08,引线位置在 7.0、7.08、7.16 之间悄悄挪动,虽然绘制时取整还是画在第 7 格,但累积到第 13 帧左右 burnPos 才跨过 1.0,于是画面上引线"每 12 帧才前进一格",看起来像在慢慢爬,非常自然。浮点数让我们能在"离散的字符格"上模拟连续运动,这是所有 CMD 动画的核心技巧。
一维下标换算:行号 * 宽度 + 列号
屏幕是二维的(第几行第几列),但 CHAR_INFO 数组是一维的(第 0、1、2......个)。怎么对应?把屏幕想成"一行一行铺开的长条":第 0 行的格子排最前面,接着是第 1 行......所以第 y 行第 x 列在长条里的位置就是 y * 宽度 + x。这个公式后面会反复用到,务必记住。
颜色值的位运算:8 | (bgColor & 0xF0) 是什么意思?
CMD 一个格子的颜色是一个 WORD(16 位),低 4 位是前景色(字的颜色),高 4 位是背景色。8 是灰色前景(低 4 位 = 1000);bgColor & 0xF0 是把背景色的高 4 位取出来、低 4 位清零;两者用 |(按位或)拼起来,就得到"灰字 + 当前背景"的完整颜色。这样爆炸时背景闪红/黄,引线字色仍是灰,互不干扰。
⚠️ 注意事项
(int)burnPos是"向下取整",所以burnPos = 7.9时画到第 7 格,等burnPos越过 8.0 才画到第 8 格,这是正常的"跳格"。- 别忘了每帧都
burnPos += BASE_BURN_SPEED,否则引线永远不动。 - 如果把
BASE_BURN_SPEED调太大(比如 2.0),引线会"瞬移",烧到鞭炮的判定也会跳过中间的鞭炮,导致连锁不连续。
4. ⌨️ 狂按加速烧 --- _kbhit 连读与 KEY_BOOST
🎯 我们要做什么
光靠引线自己慢慢烧太无聊了,我们想让玩家"狂敲键盘就能加速烧"。这就像你给篝火扇风:扇一下火旺一点,狂扇火就呼呼地烧。在程序里,我们要做的是"每按一次键,引线位置就额外往前窜一大截"。这就需要用 _kbhit()(键盘有没有被敲?)和 _getch()(把敲的那下读出来)这两个 conio 工具。
cpp
#include <conio.h>
// ------ 常量 ------
const double KEY_BOOST = 0.35; // 按键加速量
// ------ 主循环里 ------
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键(方向键等)
if (_kbhit()) _getch(); // 把扩展码的第二个字节吃掉
}
burnPos += KEY_BOOST; // 任意键都加速燃烧
}
if (quit) break;
burnPos += BASE_BURN_SPEED; // 基础燃烧(上一节的内容)
🐧 Linux / macOS 用户看这里
Windows 用
_kbhit()探测 +_getch()读取,还要手动处理 224 扩展码;Linux/macOS 上对应的是 ncurses 的nodelay(stdscr, TRUE)+getch()(无按键返回ERR),开了keypad后方向键自动翻译成KEY_*,不用再管扩展码。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cppnodelay(stdscr, TRUE); // getch 非阻塞(替代 _kbhit) int ch; while ((ch = getch()) != ERR) { // ERR = 无按键,等价于 !_kbhit() if (ch == 27) { quit = true; break; } // ESC 退出 burnPos += KEY_BOOST; // 任意键加速 } if (quit) break; burnPos += BASE_BURN_SPEED; // 注:keypad 开启后方向键自动转 KEY_*,不用手动处理 224 扩展码对照关系:
_kbhit()+_getch()→nodelay+getch()(返回ERR= 无按键),224 扩展码处理由keypad自动搞定。
📝 逐行看懂这段代码
#include <conio.h>:引入控制台输入工具箱,里面有_kbhit和_getch。const double KEY_BOOST = 0.35;:每按一次键,引线额外往前窜 0.35 格------比基础燃烧 0.08 快 4 倍多,所以狂按会明显感觉到火跑得飞快。while (_kbhit()):_kbhit()返回"键盘缓冲区里有没有按键等着读"。有就一直读,直到读完。这样玩家在一帧内狂敲 5 下,5 下都会被算进去,不会漏。int ch = _getch();:读出一个按键的 ASCII 码。注意它不回显(不会在屏幕上印出字符),正好适合游戏。if (ch == 27) { quit = true; break; }:27 是 ESC 的 ASCII 码,按 ESC 就把"退出旗"立起来,跳出循环。if (ch == 224):224 是"扩展键"的标志码。方向键、F1-F12 等会发两个字节:先 224,再一个具体码。我们要把第二个字节也读掉,否则它会污染下一帧的按键。if (_kbhit()) _getch();:如果还有第二个字节就吃掉它,相当于"把扩展键的尾巴扫干净"。burnPos += KEY_BOOST;:不管按的是什么键(除 ESC 外),都给引线加 0.35。所以连按空格、回车、字母键都能加速。
💡 背后的原理
_kbhit 和 _getch 是什么关系?
_kbhit 像门铃,只告诉你"有人按了没",但不告诉你是谁按的;_getch 才是开门,把按的那个人(那个键)请进来。如果只用 _getch,它会"堵在那里等"------你不按键程序就不动,游戏就卡死了。所以游戏循环的标准写法是:先用 _kbhit 探一下,有才 _getch 读出来,没有就跳过,继续游戏。这叫非阻塞输入。
为什么用 while 而不是 if?
如果用 if (_kbhit()),一帧最多只读 1 个键。但人手很快,一帧(33 毫秒)里可能敲了 3、4 下,if 只能吃 1 下,其余浪费。用 while (_kbhit()) 把这一帧里所有按键全部读光、全部转化成加速,玩家才会觉得"我敲得越快,火跑得越快"。这是"狂按加速"手感的关键。
扩展键为什么要读两次?
普通字母键只发 1 个字节(比如 'A' 是 65)。但功能键(方向键、F1-F12、Home/End 等)发 2 个字节 :先是 224(或 0),后面跟一个具体码。如果你只 _getch 一次读到 224 就走人,第二个字节会留在缓冲区里,被下一帧当成"又按了个键"误处理。所以看到 224 就要再 _getch 一次把尾巴扫掉。这是 conio 编程的经典坑。
⚠️ 注意事项
_kbhit/_getch是 Windows/conio 专属,标准 C++ 没有,移植到 Linux 要换 ncurses 的getch/nodelay。- ESC 必须先判
ch == 27再break,否则按 ESC 也会触发后面的burnPos += KEY_BOOST,逻辑就乱了。 - 别忘了处理完输入后还要
burnPos += BASE_BURN_SPEED,否则你不按键引线就不烧了。
5. 💥 点燃第一个 --- 阈值检测与三态切换
🎯 我们要做什么
引线一路烧啊烧,总有个瞬间它会烧到第一颗鞭炮。程序怎么知道"烧到了"?很简单:拿引线烧到的位置 burnPos 跟每颗鞭炮的 x 比,只要 burnPos >= x,就说明火已经舔到这颗鞭炮了,该让它"开始炸"。鞭炮一旦被点着,就会经历三种状态:未燃 → 爆炸中 → 已爆完,像人的一生"少年 → 壮年 → 老年",每个状态画法不同。
这一节我们先讲"第一颗怎么被点燃",下一节再讲"炸了之后怎么传染给邻居"。
cpp
// ========== 2. 点燃鞭炮及连锁反应 ==========
// 使用队列处理连锁引爆
vector<int> toExplode; // 存储本次要引爆的鞭炮索引
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 0 && burnPos >= bombs[i].x) {
toExplode.push_back(i);
}
}
这一小段是"扫描全排鞭炮,把已经被引线烧到的挑出来,塞进 toExplode 这个待引爆名单"。名单里的鞭炮马上就要被引爆(具体引爆动作在下一节)。
🐧 Linux / macOS 用户看这里
Windows 和 ncurses 在"点燃判定"上没有差异------
state切换、burnPos >= x阈值检测都是纯 C++ 逻辑,与平台无关。ncurses 版这段代码一字不改。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// 状态切换逻辑完全一样,与平台无关 vector<int> toExplode; for (size_t i = 0; i < bombs.size(); ++i) { if (bombs[i].state == 0 && burnPos >= bombs[i].x) toExplode.push_back(i); } // 三态机 0→1→2 的切换、阈值检测用 >= 不用 ==, // 这些都是纯 C++ 逻辑,Windows 和 ncurses 版一模一样对照关系:逻辑完全相同,无需任何替换。
📝 逐行看懂这段代码
vector<int> toExplode;:一个"待引爆名单",存的是鞭炮的下标(第几颗),不是鞭炮本身。每帧新建一个空名单。for (size_t i = 0; i < bombs.size(); ++i):遍历 10 颗鞭炮。用size_t是因为bombs.size()返回无符号类型,避免编译警告。if (bombs[i].state == 0 && burnPos >= bombs[i].x):两个条件同时满足 才算"该点这颗":state == 0:它现在还是"未燃"状态,没被点过(如果已经在炸或炸完了就别再点)。burnPos >= bombs[i].x:引线烧到的位置已经到或超过了这颗鞭炮的横坐标,说明火够得着它。
toExplode.push_back(i);:把这颗的下标塞进名单,后面统一引爆。
💡 背后的原理
三态机:state 0/1/2 是怎么回事?
鞭炮的一生被切成三个阶段,用一个整数 state 表示:
| state 值 | 含义 | 画面表现 |
|---|---|---|
| 0 | 未燃 | 红色 * 静静站着 |
| 1 | 爆炸中 | 不画鞭炮本体(让粒子盖住),开始撒火星 |
| 2 | 已爆完 | 完全不画(位置空出来) |
切换顺序:0 → 1(被点着的瞬间,由本节逻辑触发)、1 → 2(炸够 12 帧后,由计时器触发)。这种"用整数编码有限个状态、按规则切换"的设计叫有限状态机(FSM),是游戏里写 NPC、写敌人 AI 的通用套路。
阈值检测:为什么用 >= 而不是 ==?
如果写 burnPos == bombs[i].x,那只有 burnPos 恰好等于 鞭炮坐标那一帧才会触发。但 burnPos 是浮点数、每帧加 0.08,它很可能从 7.04 跳到 7.12,直接跳过 7.0 这个整数点,于是 == 永远不成立,鞭炮永远不会被点着!用 >= 表示"只要火烧到或越过它就行",绝不会漏。比较浮点数永远别用 ==,要用 >= 或 <=,这是血泪教训。
为什么要先收集名单,而不是点着一颗就立刻处理?
因为如果边遍历边修改 bombs 的状态,会打乱循环逻辑。先把"本帧要点燃的"全部挑出来放进名单,再统一处理,是更稳妥的"先收集后处理"模式。而且下一节你会看到,这个名单还会"越长越大"------邻居被传染进来,于是名单本身就成了连锁传播的载体。
⚠️ 注意事项
- 一定要加
state == 0这个条件,否则同一颗鞭炮会被重复点燃,粒子会刷爆内存。 burnPos >= bombs[i].x的>=别写成>,否则火烧到正上方那一帧不会触发,会有 1 帧延迟。- 名单
toExplode是每帧新建的局部变量,出了这一帧就销毁,不会跨帧残留。
6. 🔗 连锁引爆 --- 队列传播与去重(核心难点)
🎯 我们要做什么
这一节是全篇最难也最精彩的部分。现实里一串鞭炮为什么能"噼里啪啦"连着炸?因为一颗炸开时,它的火星和冲击波会溅到隔壁那颗,把隔壁也点着,隔壁又溅到再隔壁......像多米诺骨牌一样一倒一大片。
在程序里,我们要模拟这个"传染"过程。办法是:用一个叫 toExplode 的名单当"传话队列",处理名单里某颗鞭炮时,顺手把它还没炸的邻居也加进名单末尾,于是名单越滚越长,直到所有该炸的都炸完。这个技巧叫"队列式广度传播",是图论里 BFS(广度优先搜索)的雏形。
cpp
// 连锁引爆:遍历队列,将相邻未燃鞭炮加入
for (size_t idx = 0; idx < toExplode.size(); ++idx) {
int i = toExplode[idx];
if (bombs[i].state != 0) continue; // 可能已被这一轮处理过
// 引爆当前鞭炮
bombs[i].state = 1;
bombs[i].explodeTimer = 12; // 爆炸持续12帧
// 生成粒子(具体代码见下一节,这里省略)
// ...
// 音效
Beep(600 + rand() % 400, 60);
// 检查相邻鞭炮(左右各3格内)
for (size_t j = 0; j < bombs.size(); ++j) {
if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) {
// 避免重复加入队列
bool already = false;
for (size_t k = 0; k < toExplode.size(); ++k)
if (toExplode[k] == (int)j) { already = true; break; }
if (!already) toExplode.push_back(j);
}
}
}
🐧 Linux / macOS 用户看这里
连锁引爆是全篇核心难点,但它是纯
vector操作(队列传播 + 去重),与平台完全无关。ncurses 版唯一的区别是:音效用beep()替代Beep(),粒子渲染用mvaddch(见第7章)。传播逻辑一行不改。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// 连锁逻辑完全一样(都是 vector 操作),只有音效不同 for (size_t idx = 0; idx < toExplode.size(); ++idx) { int i = toExplode[idx]; if (bombs[i].state != 0) continue; bombs[i].state = 1; bombs[i].explodeTimer = 12; // 生成粒子(逻辑一样,color 改为 ncurses 常量,见第7章)... beep(); // 替代 Beep(600 + rand() % 400, 60) // 传染邻居 + 去重检查,逻辑一行不改 for (size_t j = 0; j < bombs.size(); ++j) { if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) { bool already = false; for (size_t k = 0; k < toExplode.size(); ++k) if (toExplode[k] == (int)j) { already = true; break; } if (!already) toExplode.push_back(j); } } }对照关系:
Beep(频率, 时长)→beep(),队列传播 + 去重逻辑完全一样。
📝 逐行看懂这段代码
for (size_t idx = 0; idx < toExplode.size(); ++idx):注意循环上限是toExplode.size(),而这个 size 在循环体里会变大(因为我们往里 push 新鞭炮)。所以这个循环不会只跑初始那几颗,而是会一直跑到名单里所有鞭炮都处理完。这是"边遍历边增长"的队列模式。int i = toExplode[idx];:拿出当前要处理的鞭炮下标。if (bombs[i].state != 0) continue;:保险检查。虽然收集名单时已确保是 state==0,但循环跑到这里它可能已经被这一轮的别处引爆过,状态变了,就跳过,避免重复引爆。bombs[i].state = 1;:正式点燃------从"未燃"切到"爆炸中"。bombs[i].explodeTimer = 12;:给它 12 帧的"爆炸寿命",12 帧后自动转 state==2。Beep(600 + rand() % 400, 60);:发出一声"哔",频率在 600~1000 赫兹之间随机,所以每颗炸的声音都不一样,更有真实感。时长 60 毫秒。for (size_t j = 0; j < bombs.size(); ++j):扫描所有鞭炮,找当前这颗i的邻居。if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3):邻居判定------还是未燃 且 横坐标差不超过 3 格。abs取绝对值,所以左右都算。bool already = false;:准备做"去重检查"。for (size_t k = 0; k < toExplode.size(); ++k) if (toExplode[k] == (int)j):翻一遍名单,看j是不是已经在里面了。if (!already) toExplode.push_back(j);:如果不在名单里,就把它加到名单末尾,下一轮循环自然会处理它------这就是"传染"的来源。
💡 背后的原理
多米诺骨牌比喻:理解"边遍历边增长"的队列
想象 10 块多米诺骨牌排成一排。你推倒第 1 块(引线烧到它)。第 1 块倒下时会撞到第 2 块,于是第 2 块也得倒;第 2 块倒下又撞第 3 块......
在程序里,"待倒下名单"一开始只有 [第1块]。我们处理第 1 块时,发现它邻居第 2 块还没倒,就把第 2 块加进名单末尾,名单变成 [第1, 第2]。循环继续走到第 2 块,处理它时又把第 3 块加进来,名单变 [第1, 第2, 第3]......如此滚雪球,名单一直长,直到没有新邻居可加,循环自然结束。
关键魔法在于:for 的循环上限 toExplode.size() 每次都会重新求值 ,而我们在循环体里 push_back 让 size 变大,于是循环会"追着"新加进来的鞭炮继续跑。如果上限在循环开始时就固定(比如 int n = toExplode.size(); for(...; i<n; ...)),那就只会处理最初那批,连锁就断了------这是个超级常见的坑。
流程图:连锁传播怎么滚起来的
下面用文字流程图说明一帧内连锁是怎么从"引线烧到第 3 颗"滚成"全排都炸"的(假设 10 颗间距都 ≤ 3):
本帧 burnPos 烧到第 3 颗
|
v
收集名单: toExplode = [3] (只有第3颗被引线点到)
|
v
处理第3颗: state[3]=1, 撒粒子, 哔一声
扫邻居 -> 发现第2、第4颗state==0且|x差|<=3
去重检查 -> 都不在名单
名单变成: [3, 2, 4]
|
v
处理第2颗(名单第2个): state[2]=1, 撒粒子
扫邻居 -> 发现第1颗未燃且近
名单变成: [3, 2, 4, 1]
|
v
处理第4颗(名单第3个): state[4]=1, 撒粒子
扫邻居 -> 发现第5颗
名单变成: [3, 2, 4, 1, 5]
|
v
处理第1颗 -> 邻居第0? 没有, 第2已在名单 -> 不加
处理第5颗 -> 加第6颗
... 一直滚 ...
|
v
处理第10颗 -> 邻居第9已在名单 -> 不加, 名单不再增长
|
v
循环结束: 这一帧内 10 颗全部被点燃!
注意:这一切发生在同一帧内(33 毫秒里),所以玩家看到的是"砰"一下全排同时亮起来,其实程序里是一颗传染一颗地滚过去的,只是滚得太快肉眼看不出来。
去重逻辑:为什么名单里不能有重复?
假设第 3 颗把第 4 颗加进名单,第 5 颗处理时又发现第 4 颗是它邻居(因为第 4、5 间距也 ≤ 3),如果不去重,第 4 颗会被加第二次。名单里有两个"第 4 颗",循环会处理它两次:第一次把它 state 改成 1,撒一轮粒子;第二次虽然 continue 跳过了引爆,但白白跑了一圈检查。粒子数翻倍、性能下降,还可能让音效重复。所以加之前先扫一遍名单看在不在,这是"去重"。
更高效的写法是用一个 bool inQueue[10] 数组来标记,O(1) 就能判断在不在,而不是 O(n) 地扫名单。本代码用了朴素扫描是为了易懂,鞭炮只有 10 颗,扫一遍也很快。
邻居判定的距离阈值:为什么是 3?
鞭炮均匀分布在约 80 列宽里、10 颗,间距约 7~8 格。如果阈值 < 间距,那只有引线烧到的才会炸,没有连锁(每颗鞭炮隔太远,炸不到邻居)。如果阈值 = 间距,刚好相邻能传染。代码用 3 是因为......等等,3 < 7?这不对吧?
其实这里有个设计取舍:阈值 3 意味着只有引线烧到的那颗会炸 ,相邻鞭炮因为相距 7~8 格 > 3,不会被直接传染。所以本代码的"连锁"其实是靠引线一路烧过去 ,每烧到一颗就炸一颗,引线烧得快(玩家狂按)就连锁炸得快。真正的"火星传染邻居"在间距小的场景下才成立。你可以把 MAX_BOMBS 调大(比如 40 颗)让间距变小,或者把阈值调大(比如 8),就能看到真正的"一颗炸开把邻居也点着"的多米诺效果。理解这个阈值的意义,比记住数字更重要。
⚠️ 注意事项
- 循环上限必须是
toExplode.size()而不是事先存好的常量,否则连锁会被截断在最初那批。这是本节最容易踩的坑,请反复体会。 continue那行不能省,否则同一颗可能被引爆两次。- 去重检查虽然 O(n),但 10 颗无所谓;鞭炮上千颗时一定要换
bool标记数组。 abs函数来自<cstdlib>或<cmath>,记得 include。Beep在循环里密集调用会很吵也很卡,鞭炮多时可以考虑只在"新加入名单"时响一声。
7. 🎆 爆炸粒子 --- 向上半球飞散与重力
🎯 我们要做什么
鞭炮炸了,光有"哔"一声和背景闪一下还不够过瘾,得有火星四溅的画面!我们要在每颗鞭炮被引爆的瞬间,撒出一大把"粒子"------它们从鞭炮位置出发,主要往上飞(像烟花),同时受重力影响慢慢往下掉,飞一会儿就消失。每个粒子有自己的位置、速度、寿命、颜色、字符,独立运动。
先定义粒子结构体,再看生成和更新逻辑。
cpp
// 爆炸粒子
struct Particle {
double x, y;
double vx, vy;
int life;
WORD color;
char ch;
};
// ------ 在 main 内 ------
vector<Particle> particles;
particles.reserve(1000);
// ------ 引爆某颗鞭炮时生成粒子(在连锁循环里)------
int numParticles = 50 + rand() % 30; // 50~80 个
for (int p = 0; p < numParticles; ++p) {
Particle part;
part.x = bombs[i].x;
part.y = BOMB_Y;
double angle = (rand() % 180) * 3.14159 / 180.0; // 0~180度
if (rand() % 2) angle = -angle; // 偶尔向下偏
double speed = (rand() % 300) / 100.0 + 0.8; // 0.8~3.8
part.vx = cos(angle) * speed;
part.vy = -fabs(sin(angle)) * speed - 0.5; // 主要向上
part.life = rand() % 25 + 15; // 15~40 帧
static int brightColors[] = {9,10,11,12,13,14,15};
part.color = brightColors[rand() % 7]; // 随机亮色
part.ch = "@#$%&*"[rand() % 6]; // 随机字符
particles.push_back(part);
}
// ------ 每帧更新所有粒子 ------
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
p.vy += 0.15; // 重力:垂直速度每帧加0.15(向下)
p.x += p.vx;
p.y += p.vy;
p.life--; // 寿命减1
}
// 清除死亡粒子
vector<Particle> alive;
for (size_t i = 0; i < particles.size(); ++i)
if (particles[i].life > 0) alive.push_back(particles[i]);
particles = alive;
🐧 Linux / macOS 用户看这里
Windows 用
WORD color存颜色编号 9~15(亮色),绘制用screenBuf[idx].Attributes = p.color | (bgColor & 0xF0);Linux/macOS 上对应的是 ncurses 的int color存COLOR_*常量,绘制用mvaddch(py, px, ch | COLOR_PAIR(n) | A_BOLD)。A_BOLD用来弥补 ncurses 没有单独"亮色"的遗憾。物理逻辑完全不变。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// struct Particle 的 color 字段从 WORD 改为 int(存 ncurses 颜色常量) struct Particle { double x, y; double vx, vy; int life; int color; // COLOR_RED 等 ncurses 常量(替代 Windows 9~15 亮色) char ch; }; // 粒子物理更新逻辑完全一样,颜色用 ncurses 常量: static int brightColors[] = { COLOR_BLUE, COLOR_GREEN, COLOR_CYAN, COLOR_RED, COLOR_MAGENTA, COLOR_YELLOW, COLOR_WHITE }; part.color = brightColors[rand() % 7]; // 绘制粒子用 mvaddch(A_BOLD 弥补 ncurses 没有"亮色"): for (size_t i = 0; i < particles.size(); ++i) { Particle& p = particles[i]; int px = (int)(p.x + 0.5); int py = (int)(p.y + 0.5); if (px >= 0 && px < winWidth && py >= 0 && py < winHeight) mvaddch(py, px, p.ch | COLOR_PAIR(colorPair(p.color, bgMode)) | A_BOLD); } // 帧率用 usleep(30000) 替代 Sleep(30)对照关系:
WORD color(9~15) →int color(COLOR_*)、screenBuf[idx] = ...→mvaddch(...,|A_BOLD)、Sleep(30)→usleep(30000),重力/抛物线逻辑不变。
📝 逐行看懂这段代码
struct Particle { ... };:定义粒子结构体,含位置x,y、速度vx,vy、寿命life、颜色color、显示字符ch。particles.reserve(1000);:提前预留 1000 个粒子位置,因为连锁时一下会撒几百个。int numParticles = 50 + rand() % 30;:每颗鞭炮炸出 50~80 个粒子(rand()%30是 0~29)。part.x = bombs[i].x; part.y = BOMB_Y;:粒子从鞭炮所在位置出发。double angle = (rand() % 180) * 3.14159 / 180.0;:随机一个 0~180 度的角度,转成弧度(因为cos/sin用弧度)。if (rand() % 2) angle = -angle;:一半概率把角度取负,让粒子可以朝左下或右下,但下一行的fabs会强制 vy 主要向上,所以这里只是让水平方向更随机。double speed = (rand() % 300) / 100.0 + 0.8;:随机速度 0.8~3.8。part.vx = cos(angle) * speed;:水平速度 = 速度在 x 轴的投影。part.vy = -fabs(sin(angle)) * speed - 0.5;:垂直速度。sin(angle)是 y 方向投影,fabs取绝对值保证非负,前面加负号让 vy 为负(CMD 里 y 轴向下,负值代表向上飞)。-0.5是额外给一个向上的初速,让粒子更有"冲上天"的感觉。这就是"向上半球飞散"的精髓。part.life = rand() % 25 + 15;:寿命 15~40 帧。static int brightColors[] = {9,10,11,12,13,14,15};:亮蓝、亮绿、亮青、亮红、亮紫、亮黄、亮白七种鲜艳颜色。static让数组只初始化一次。part.color = brightColors[rand() % 7];:随机挑一种。part.ch = "@#$%&*"[rand() % 6];:从字符串字面量里随机挑一个字符当粒子形状。- 更新部分:
p.vy += 0.15;:每帧垂直速度加 0.15,模拟重力。vy 本来是负的(向上),慢慢被加成 0、再变正(向下),粒子就先升后落。p.x += p.vx; p.y += p.vy;:位置加上速度,标准运动公式。p.life--;:寿命减 1,到 0 就该消失。- 清除部分:建一个
alive新数组,只把life > 0的粒子拷过去,再赋值回particles,等于把死的扔掉。
💡 背后的原理
为什么用 -fabs(sin) 实现"向上半球"?
球面飞散的完整版是:粒子朝四面八方均匀飞。但烟花/鞭炮的火星主要是往上飞(地面挡着下方)。怎么用一行公式实现"主要向上"?
sin(angle) 在 0~180 度范围是 010(非负),加 fabs 还是 010,前面加负号变成 0-10,也就是 vy 永远 ≤ 0(永远向上或水平),绝不下飞。这就把飞散范围限制在了"上半球"。再 -0.5 给一个保底向上速度,即使角度接近 0 或 180(水平方向)也会稍微往上飘,看起来更自然。
如果去掉 fabs,sin(angle) 在 90~180 度是正的,vy 会变正(向下),粒子就会往地下钻,不科学。所以 fabs 是"把下半球翻到上半球"的关键。
重力:为什么是 vy += 0.15 而不是 y += 0.15?
重力作用在"速度"上,不是直接作用在"位置"上。物理上,重力让物体获得向下的加速度 ,于是每帧速度增加一点,位置才跟着变。如果直接 y += 0.15,那所有粒子匀速下落,没有"先升后落"的弧线,不真实。用 vy += 0.15 再 y += vy,粒子先以负 vy(向上)冲,vy 慢慢被重力加到 0(最高点),再变正(开始下落),走出漂亮的抛物线,像真火星。
粒子生命周期:为什么必须清理死的?
粒子有寿命,寿命到 0 就该消失。如果不清理,particles 数组会无限增长,几十秒后几万个死粒子还在数组里占内存、还在被遍历更新(虽然画不出),程序越来越卡。每帧用 alive 过滤一次,把死的扔掉,数组只保留活着的,内存和性能都稳。这是"对象池"思想的简化版。
字符串字面量当数组:"@#$%&*"[rand()%6] 是什么操作?
C/C++ 里字符串字面量 "abc" 就是字符数组 {'a','b','c','\0'},所以可以直接用下标访问:"abc"[1] 是 'b'。"@#$%&*"[rand()%6] 就是随机取这 6 个字符中的一个,省得单独声明数组。是个常用小技巧。
⚠️ 注意事项
3.14159写死不太精确,可以用M_PI(需#define _USE_MATH_DEFINES再 include<cmath>),但本代码这么写够用了。static int brightColors[]在函数内声明加static是为了避免每次循环重新构造数组,性能更好。- 粒子数量别贪多,连锁时 10 颗 * 80 个 = 800 粒子已经不少,再多会卡。
- 清理死粒子用了"新建数组再赋值"的笨办法,其实用
std::remove_if+erase更优雅,但本写法最易懂。
8. ⚡ 闪光与音效 --- 背景闪烁与 Beep
🎯 我们要做什么
爆炸光有火星不够,还得有"轰"的一下整屏闪光、还有"哔"的声响。我们要做两件事:当有任何鞭炮正在炸时,让整个背景在红色和黄色之间快速交替闪烁 (模拟爆炸的火光),每引爆一颗就 Beep 一声(模拟爆炸声)。这两样加在一起,画面立刻有了"年味"。
cpp
// ------ 引爆时(连锁循环里)------
Beep(600 + rand() % 400, 60); // 频率600~1000Hz, 时长60ms
// ------ 更新爆炸计时器后 ------
bool anyExploding = false;
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 1) {
bombs[i].explodeTimer--;
if (bombs[i].explodeTimer <= 0) {
bombs[i].state = 2;
} else {
anyExploding = true; // 还有正在炸的
}
}
}
// 更新背景闪烁计时器
if (anyExploding) {
flashTimer = 8; // 刷新闪烁时间
}
if (flashTimer > 0) {
flashTimer--;
flashToggle = !flashToggle; // 每帧翻转颜色
} else {
flashToggle = false;
}
// ------ 绘制时确定背景色 ------
WORD bgColor = 0x00; // 黑色背景
if (flashTimer > 0) {
bgColor = flashToggle ? 0x40 : 0x60; // 红/黄背景
}
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = bgColor; // 整屏背景色
}
🐧 Linux / macOS 用户看这里
Windows 用
WORD bgColor = 0x40/0x60位运算设背景,再遍历screenBuf整屏刷背景色;Linux/macOS 上对应的是 ncurses 的bkgdset()+erase()两步清屏。音效用beep()替代Beep()(注意beep()不能控频率/时长)。闪烁翻转逻辑完全不变。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cppbeep(); // 替代 Beep(600 + rand() % 400, 60) // 闪烁逻辑一样,背景用 bkgdset + erase(替代遍历 screenBuf 刷背景色): int bgMode = 0; // 0黑 1红 2黄 if (flashTimer > 0) bgMode = flashToggle ? 1 : 2; // 红黄交替 bkgdset(COLOR_PAIR(colorPair(COLOR_BLACK, bgMode))); erase(); // 用当前背景色清屏(替代遍历 screenBuf 刷背景) // 提示文字用 attron + mvaddstr(替代逐字符写 screenBuf): attron(COLOR_PAIR(colorPair(COLOR_GREEN, bgMode))); mvaddstr(hintY, hintX, "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出"); attroff(COLOR_PAIR(colorPair(COLOR_GREEN, bgMode)));对照关系:
Beep(频率,时长)→beep()、screenBuf[i].Attributes = bgColor→bkgdset()+erase()、逐字符写提示 →attron()+mvaddstr(),flashToggle 翻转逻辑不变。
📝 逐行看懂这段代码
Beep(600 + rand() % 400, 60);:Beep(频率, 时长)。频率随机 600~1000 赫兹,时长 60 毫秒。频率越高声音越尖。bool anyExploding = false;:先假设没有正在炸的。- 遍历所有鞭炮:如果
state == 1(爆炸中),就给它的计时器减 1。 if (bombs[i].explodeTimer <= 0) bombs[i].state = 2;:计时器归零,从"爆炸中"切到"已爆完"。else { anyExploding = true; }:还有在炸的,标记一下。if (anyExploding) flashTimer = 8;:只要还有鞭炮在炸,就把闪烁计时器刷满 8 帧。这保证只要爆炸在持续,背景就一直闪。if (flashTimer > 0):闪烁计时器还剩时间。flashTimer--;:每帧扣 1。flashToggle = !flashToggle;:布尔翻转------上一帧红,这帧黄,下帧又红......形成红黄交替。else flashToggle = false;:时间到,停止闪烁(但此时flashTimer已是 0,下一行才用到,逻辑上等价于黑背景)。WORD bgColor = 0x00;:默认黑背景。if (flashTimer > 0) bgColor = flashToggle ? 0x40 : 0x60;:还在闪的话,根据flashToggle选 0x40(红背景)或 0x60(黄背景)。0x40= 高 4 位 0100 = 红背景;0x60= 高 4 位 0110 = 黄背景。- 遍历草稿纸所有格子,把背景色统一刷成
bgColor,字符清成空格------这就是"整屏闪光"。
💡 背后的原理
颜色编码:0x40 和 0x60 怎么来的?
CMD 颜色是一个字节,高 4 位背景、低 4 位前景。常见背景色编码:
| 值 | 二进制高4位 | 颜色 |
|---|---|---|
| 0x00 | 0000 | 黑 |
| 0x10 | 0001 | 深蓝 |
| 0x20 | 0010 | 深绿 |
| 0x40 | 0100 | 深红 |
| 0x50 | 0101 | 深紫 |
| 0x60 | 0110 | 深黄(褐) |
| 0x70 | 0111 | 深白(灰) |
所以 0x40 是红背景、0x60 是黄背景,交替起来就是"红黄闪烁",像爆炸火光。注意这是背景色 ,前景字色(引线灰、鞭炮红、粒子亮色)会通过 | (bgColor & 0xF0) 保住这个背景。
flashToggle 为什么用布尔翻转而不是计数器?
用布尔 !flashToggle 每帧翻转,效果是"红-黄-红-黄-红-黄-红-黄"8 帧。如果用计数器 ++flashCount 再 % 2,效果一样但多一步取模。布尔翻转最简洁,且语义清楚------"开/关"两种状态。这种"双态翻转"在游戏里做闪烁、做呼吸灯、做无敌帧闪烁都用得上。
Beep 频率为什么要随机?
固定频率的 Beep 听起来像机器蜂鸣,很死板。600 + rand()%400 让每颗鞭炮的声音频率都不一样,加上连锁时密集 Beep,听起来就是"噼里啪啦"的鞭炮声,更有年味。随机是给声音加"颗粒感"的廉价手段。
flashTimer = 8 的"刷新"机制
if (anyExploding) flashTimer = 8; 的意思是:只要还有鞭炮在炸,就把闪烁时间重新加满 。这样连锁炸 10 颗、每颗炸 12 帧时,anyExploding 会持续为真很久,闪烁一直续命,直到所有鞭炮都炸完(state 全变 2),flashTimer 不再被刷新,8 帧后自然熄灭。这是"持续事件维持效果,事件结束效果自然衰减"的经典模式。
⚠️ 注意事项
Beep在 Linux 下没有,需要换cout << '\a'或 ncurses 的beep()。flashToggle的初值是false,第一帧闪出来是红色(0x40),无所谓,肉眼分不清。- 背景闪烁会让整屏所有格子的背景色都变,所以前面画引线、鞭炮、粒子时都要用
| (bgColor & 0xF0)保住背景,否则会被覆盖成黑背景,闪烁就看不到了。 Beep是同步阻塞的(响完 60ms 才返回),连锁时密集调用会拖慢帧率,鞭炮很多时考虑用异步或限频。
9. 🔄 全炸完重来 --- allDone 与 respawnDelay
🎯 我们要做什么
鞭炮全部炸完之后,画面就空了,总不能就让程序停在那儿。我们要让程序自动检测"是不是全炸完了",全炸完后等一小会儿(冷却),然后自动把所有鞭炮重置成未燃、引线归零、粒子清空,重新开始一轮。这样玩家可以反复看爆炸,像循环播放的烟花秀。
cpp
// ========== 5. 全部鞭炮炸完则重置 ==========
bool allDone = true;
for (size_t i = 0; i < bombs.size(); ++i)
if (bombs[i].state != 2) { allDone = false; break; }
if (allDone) {
respawnDelay++;
if (respawnDelay > 40) { // 约1.3秒后刷新
burnPos = 0.0;
particles.clear();
for (size_t i = 0; i < bombs.size(); ++i) {
bombs[i].state = 0;
bombs[i].explodeTimer = 0;
}
respawnDelay = 0;
flashTimer = 0;
}
}
🐧 Linux / macOS 用户看这里
重置逻辑(
allDone检测 +respawnDelay冷却 + 归零)是纯 C++ 逻辑,与平台完全无关。ncurses 版这段代码一字不改。本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// 重置逻辑完全一样,与平台无关 bool allDone = true; for (size_t i = 0; i < bombs.size(); ++i) if (bombs[i].state != 2) { allDone = false; break; } if (allDone) { respawnDelay++; if (respawnDelay > 40) { burnPos = 0.0; particles.clear(); for (size_t i = 0; i < bombs.size(); ++i) { bombs[i].state = 0; bombs[i].explodeTimer = 0; } respawnDelay = 0; flashTimer = 0; } }对照关系:逻辑完全相同,无需任何替换。
📝 逐行看懂这段代码
bool allDone = true;:先假设全炸完了。for (...) if (bombs[i].state != 2) { allDone = false; break; }:只要有一颗还没到 state==2(已爆完),就把allDone改成 false 并立刻跳出循环。break是优化------发现一颗没炸完就不用再看了。if (allDone):全炸完了,进入重置流程。respawnDelay++;:冷却计数器加 1。每帧加 1,所以它计量的是"已经全炸完多少帧了"。if (respawnDelay > 40):等够 40 帧(约 1.3 秒,因为 30FPS 下 40 帧 ≈ 1.33 秒)才真正重置。这个等待让玩家有时间欣赏"全炸完的空旷"。burnPos = 0.0;:引线归零,从头开始烧。particles.clear();:清空所有残留粒子(虽然此时应该都死了,保险起见)。- 遍历所有鞭炮,把
state和explodeTimer都归零,恢复成"未燃"。 respawnDelay = 0;:冷却计数器归零,下一轮重新计。flashTimer = 0;:停止背景闪烁。
💡 背后的原理
为什么要"冷却"再重置,而不是炸完立刻重置?
如果全炸完的当帧立刻重置,玩家根本来不及看到"全炸完"这个状态------画面一闪就回到初始,像没炸过一样。加 40 帧冷却,让"全炸完的空旷"停留一会儿,玩家心理上有个"段落感":砰砰砰......安静 1 秒......又开始烧了。这是节奏控制,让动画有"呼吸"。
allDone 的"短路"检测:为什么要 break?
for (...) if (...) { allDone = false; break; } 发现一颗没炸完就立刻退出循环,不再检查后面的。这叫短路求值------反正已经知道不是全炸完了,再查也没意义,省 CPU。虽然 10 颗无所谓,但这是好习惯,鞭炮上千颗时就明显了。
冷却计时器 respawnDelay 的"累加-触发-归零"三步曲
这是个非常通用的"延迟动作"模式:
- 累加 :每帧
respawnDelay++,数着"已经等了多久"。 - 触发:等到超过阈值(40),执行动作(重置)。
- 归零 :动作做完,
respawnDelay = 0,下一轮从头数。
这个模式可以复用到任何"等一会儿再做某事"的场景:敌人死后等 2 秒再刷新、过关后等 3 秒切关、按钮按下后冷却 1 秒才能再按......都是这个套路。
⚠️ 注意事项
respawnDelay必须在allDone为真时才++,否则没全炸完它也会涨,逻辑错乱。代码把它放在if (allDone)里,正确。- 重置时别忘了
flashTimer = 0,否则上一轮的闪烁会延续到新轮开头。 - 冷却阈值 40 帧约 1.3 秒,想长一点调大、想快一点调小。
particles.clear()别漏,否则上一轮残余粒子会出现在新一轮开头。
10. 🧩 拼合步骤 --- 从零件到成品
🎯 我们要做什么
前面 9 节我们一块块讲了"画板准备""摆鞭炮""引线""加速""点燃""连锁""粒子""闪光音效""重置"。这些零件单独看都懂了,但怎么拼成一整个能跑的程序?这一节我们按"功能叠加"的顺序,把所有零件像搭积木一样一步步拼起来。每一步都是完整可编译运行 的程序,你可以复制到 .cpp 文件里直接跑,亲眼看到每加一块积木后的效果。
第 1 步:搭画板 + 摆鞭炮
这一步把"地基"打好:引入头文件、藏光标、读窗口尺寸、建双缓冲、摆好 10 颗鞭炮。画面是静止的------引线全黄、鞭炮全红,还没开始烧。
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (true) {
// ========== 1. 清空缓冲区 ==========
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = 0x00;
}
// ========== 2. 绘制引线(全黄,还没开始烧) ==========
for (int x = 0; x < winWidth - 1; ++x) {
int idx = FUSE_Y * winWidth + x;
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14; // 亮黄
}
// ========== 3. 绘制鞭炮(全部未燃状态) ==========
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue;
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12; // 红色
}
}
}
// ========== 4. 底部提示文字 ==========
const char* hint = "鞭炮已就位! 即将开始...";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10; // 亮绿
}
}
// ========== 5. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
| 拼合前 | 拼合后 |
|---|---|
| 空程序 | 有头文件、画板、10 颗鞭炮、亮黄引线,但画面静止 |
这一步要加什么?所有"静态准备"------头文件、窗口、数据结构、缓冲区、初始绘制。为什么这么拼?后面所有动态效果都要画在这块"画板"上,画板必须先就位。
第 2 步:引线燃烧 + 按键加速
在第 1 步基础上,给引线加上"燃烧"动画:burnPos 每帧前进一点,引线左边变灰(已燃)、右边保持亮黄(未燃)。再加狂按加速和 ESC 退出。
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
const double KEY_BOOST = 0.35; // 按键加速量
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
double burnPos = 0.0; // 当前引线燃烧到的x坐标
bool quit = false;
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (!quit) {
// ========== 1. 输入处理(狂按加速) ==========
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键
if (_kbhit()) _getch();
}
burnPos += KEY_BOOST; // 任意键加速燃烧
}
if (quit) break;
// 基础燃烧
burnPos += BASE_BURN_SPEED;
// ========== 2. 清空缓冲区 ==========
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = 0x00;
}
// ========== 3. 绘制引线(已燃暗灰 / 未燃亮黄) ==========
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
// 已燃烧部分(暗灰色)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8;
} else if (x < winWidth - 1) {
// 未燃烧部分(亮黄)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14;
}
}
// ========== 4. 绘制鞭炮(全部未燃状态) ==========
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue;
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12; // 红色
}
}
}
// ========== 5. 底部提示文字 ==========
const char* hint = "疯狂敲击键盘 -> 加速燃烧! [ESC]退出";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10; // 亮绿
}
}
// ========== 6. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
| 拼合前 | 拼合后 |
|---|---|
| 画面静止 | 引线会烧、按键会加速、ESC 可退出 |
这一步加了什么?burnPos 浮点数 + 每帧自增 + 按键加速 + 两段式引线绘制。为什么这么拼?燃烧是整个动画的"时间轴",先让时间轴动起来,后面的点燃和爆炸才有触发依据。
第 3 步:点燃 + 连锁引爆
在第 2 步基础上,加入鞭炮的三态管理和连锁引爆逻辑:烧到鞭炮就点燃(状态 0→1),爆炸中的鞭炮会引爆旁边的邻居,像多米诺骨牌一样传开。
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
const double KEY_BOOST = 0.35; // 按键加速量
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
double burnPos = 0.0; // 当前引线燃烧到的x坐标
bool quit = false;
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (!quit) {
// ========== 1. 输入处理(狂按加速) ==========
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键
if (_kbhit()) _getch();
}
burnPos += KEY_BOOST; // 任意键加速燃烧
}
if (quit) break;
// 基础燃烧
burnPos += BASE_BURN_SPEED;
// ========== 2. 点燃鞭炮及连锁反应 ==========
vector<int> toExplode; // 存储本次要引爆的鞭炮索引
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 0 && burnPos >= bombs[i].x) {
toExplode.push_back(i);
}
}
// 连锁引爆:遍历队列,将相邻未燃鞭炮加入
for (size_t idx = 0; idx < toExplode.size(); ++idx) {
int i = toExplode[idx];
if (bombs[i].state != 0) continue; // 可能已处理
// 引爆当前鞭炮
bombs[i].state = 1;
bombs[i].explodeTimer = 12; // 爆炸持续12帧
// 音效
Beep(600 + rand() % 400, 60);
// 检查相邻鞭炮(左右各3个位置内)
for (size_t j = 0; j < bombs.size(); ++j) {
if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) {
// 避免重复加入队列
bool already = false;
for (size_t k = 0; k < toExplode.size(); ++k)
if (toExplode[k] == (int)j) { already = true; break; }
if (!already) toExplode.push_back(j);
}
}
}
// ========== 3. 更新爆炸计时器 ==========
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 1) {
bombs[i].explodeTimer--;
if (bombs[i].explodeTimer <= 0) {
bombs[i].state = 2;
}
}
}
// ========== 4. 清空缓冲区 ==========
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = 0x00;
}
// ========== 5. 绘制引线 ==========
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8;
} else if (x < winWidth - 1) {
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14;
}
}
// ========== 6. 绘制鞭炮(爆炸中不绘制,留空给粒子覆盖) ==========
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue; // 已爆炸不绘
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12; // 红色
}
// state==1 爆炸中不绘制字符
}
}
// ========== 7. 底部提示文字 ==========
const char* hint = "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10; // 亮绿
}
}
// ========== 8. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
| 拼合前 | 拼合后 |
|---|---|
| 只有引线在烧 | 鞭炮会被点燃、连锁引爆、Beep 音效、爆炸后消失 |
这一步加了什么?toExplode 队列式连锁传播 + 状态机(0→1→2)+ 爆炸计时器 + Beep 音效。为什么这么拼?点燃和连锁是整个效果的核心玩法,放在输入和燃烧之后、绘制之前,符合"先更新世界再画出来"的顺序。
第 4 步:加粒子 + 闪光 + 音效
在第 3 步基础上,加入爆炸粒子效果和背景闪光:鞭炮炸开时喷出几十颗彩色粒子向上飞散、受重力下落,同时背景红黄交替闪烁,视觉冲击力拉满。
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 爆炸粒子
struct Particle {
double x, y;
double vx, vy;
int life;
WORD color;
char ch;
};
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
const double KEY_BOOST = 0.35; // 按键加速量
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
double burnPos = 0.0; // 当前引线燃烧到的x坐标
vector<Particle> particles;
particles.reserve(1000);
// 背景闪烁相关
int flashTimer = 0;
bool flashToggle = false;
bool quit = false;
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (!quit) {
// ========== 1. 输入处理(狂按加速) ==========
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键
if (_kbhit()) _getch();
}
burnPos += KEY_BOOST; // 任意键加速燃烧
}
if (quit) break;
// 基础燃烧
burnPos += BASE_BURN_SPEED;
// ========== 2. 点燃鞭炮及连锁反应 ==========
vector<int> toExplode; // 存储本次要引爆的鞭炮索引
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 0 && burnPos >= bombs[i].x) {
toExplode.push_back(i);
}
}
// 连锁引爆:遍历队列,将相邻未燃鞭炮加入
for (size_t idx = 0; idx < toExplode.size(); ++idx) {
int i = toExplode[idx];
if (bombs[i].state != 0) continue; // 可能已处理
// 引爆当前鞭炮
bombs[i].state = 1;
bombs[i].explodeTimer = 12; // 爆炸持续12帧
// 生成粒子
int numParticles = 50 + rand() % 30;
for (int p = 0; p < numParticles; ++p) {
Particle part;
part.x = bombs[i].x;
part.y = BOMB_Y;
double angle = (rand() % 180) * 3.14159 / 180.0; // 向上半球
if (rand() % 2) angle = -angle; // 偶尔向下
double speed = (rand() % 300) / 100.0 + 0.8;
part.vx = cos(angle) * speed;
part.vy = -fabs(sin(angle)) * speed - 0.5; // 主要向上
part.life = rand() % 25 + 15;
// 随机亮色
static int brightColors[] = {9,10,11,12,13,14,15};
part.color = brightColors[rand() % 7];
part.ch = "@#$%&*"[rand() % 6];
particles.push_back(part);
}
// 音效
Beep(600 + rand() % 400, 60);
// 检查相邻鞭炮(左右各3个位置内)
for (size_t j = 0; j < bombs.size(); ++j) {
if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) {
// 避免重复加入队列
bool already = false;
for (size_t k = 0; k < toExplode.size(); ++k)
if (toExplode[k] == (int)j) { already = true; break; }
if (!already) toExplode.push_back(j);
}
}
}
// ========== 3. 更新爆炸计时器 + 背景闪烁 ==========
bool anyExploding = false;
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 1) {
bombs[i].explodeTimer--;
if (bombs[i].explodeTimer <= 0) {
bombs[i].state = 2;
} else {
anyExploding = true;
}
}
}
// 更新背景闪烁计时器
if (anyExploding) {
flashTimer = 8; // 刷新闪烁时间
}
if (flashTimer > 0) {
flashTimer--;
flashToggle = !flashToggle;
} else {
flashToggle = false;
}
// ========== 4. 更新粒子 ==========
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
p.vy += 0.15; // 重力
p.x += p.vx;
p.y += p.vy;
p.life--;
}
// 清除死亡粒子
vector<Particle> alive;
for (size_t i = 0; i < particles.size(); ++i)
if (particles[i].life > 0) alive.push_back(particles[i]);
particles = alive;
// ========== 5. 绘制到缓冲区 ==========
// 确定本帧背景色
WORD bgColor = 0x00; // 黑色背景
if (flashTimer > 0) {
bgColor = flashToggle ? 0x40 : 0x60; // 红/黄背景
}
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = bgColor; // 只设背景色
}
// 绘制引线
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8 | (bgColor & 0xF0);
} else if (x < winWidth - 1) {
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14 | (bgColor & 0xF0);
}
}
// 绘制鞭炮
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue; // 已爆炸不绘
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12 | (bgColor & 0xF0); // 红色
}
// state==1 爆炸中不绘制字符,粒子自己会覆盖
}
}
// 绘制粒子(覆盖底层)
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
int px = (int)(p.x + 0.5);
int py = (int)(p.y + 0.5);
if (px >= 0 && px < winWidth && py >= 0 && py < winHeight) {
int idx = py * winWidth + px;
screenBuf[idx].Char.AsciiChar = p.ch;
screenBuf[idx].Attributes = p.color | (bgColor & 0xF0);
}
}
// 底部提示文字
const char* hint = "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10 | (bgColor & 0xF0); // 亮绿
}
}
// ========== 6. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
| 拼合前 | 拼合后 |
|---|---|
| 爆炸只有消失+音效 | 有粒子飞散、重力下落、背景红黄闪烁、彩色字符 |
这一步加了什么?Particle 结构体 + 爆炸时生成粒子 + 粒子物理(重力)+ 死亡粒子清理 + 背景闪烁。为什么这么拼?粒子和闪光是"视觉效果层",在状态更新完成后统一绘制,确保画出来的就是本帧最终样子。
第 5 步:全炸完重置 + 完整版
在第 4 步基础上,加入"全炸完自动重置"机制:检测所有鞭炮是否都炸完了,冷却一小段时间后自动重置一切------引线从头烧、鞭炮满血复活、粒子清空,无限循环玩不停。
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 爆炸粒子
struct Particle {
double x, y;
double vx, vy;
int life;
WORD color;
char ch;
};
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
const double KEY_BOOST = 0.35; // 按键加速量
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
double burnPos = 0.0; // 当前引线燃烧到的x坐标
vector<Particle> particles;
particles.reserve(1000);
// 背景闪烁相关
int flashTimer = 0;
bool flashToggle = false;
// 用于重新生成鞭炮的冷却计时
int respawnDelay = 0;
bool quit = false;
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (!quit) {
// ========== 1. 输入处理(狂按加速) ==========
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键
if (_kbhit()) _getch();
}
burnPos += KEY_BOOST; // 任意键加速燃烧
}
if (quit) break;
// 基础燃烧
burnPos += BASE_BURN_SPEED;
// ========== 2. 点燃鞭炮及连锁反应 ==========
vector<int> toExplode; // 存储本次要引爆的鞭炮索引
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 0 && burnPos >= bombs[i].x) {
toExplode.push_back(i);
}
}
// 连锁引爆:遍历队列,将相邻未燃鞭炮加入
for (size_t idx = 0; idx < toExplode.size(); ++idx) {
int i = toExplode[idx];
if (bombs[i].state != 0) continue; // 可能已处理
// 引爆当前鞭炮
bombs[i].state = 1;
bombs[i].explodeTimer = 12; // 爆炸持续12帧
// 生成粒子
int numParticles = 50 + rand() % 30;
for (int p = 0; p < numParticles; ++p) {
Particle part;
part.x = bombs[i].x;
part.y = BOMB_Y;
double angle = (rand() % 180) * 3.14159 / 180.0; // 向上半球
if (rand() % 2) angle = -angle; // 偶尔向下
double speed = (rand() % 300) / 100.0 + 0.8;
part.vx = cos(angle) * speed;
part.vy = -fabs(sin(angle)) * speed - 0.5; // 主要向上
part.life = rand() % 25 + 15;
// 随机亮色
static int brightColors[] = {9,10,11,12,13,14,15};
part.color = brightColors[rand() % 7];
part.ch = "@#$%&*"[rand() % 6];
particles.push_back(part);
}
// 音效
Beep(600 + rand() % 400, 60);
// 检查相邻鞭炮(左右各3个位置内)
for (size_t j = 0; j < bombs.size(); ++j) {
if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) {
// 避免重复加入队列
bool already = false;
for (size_t k = 0; k < toExplode.size(); ++k)
if (toExplode[k] == (int)j) { already = true; break; }
if (!already) toExplode.push_back(j);
}
}
}
// ========== 3. 更新爆炸计时器 + 背景闪烁 ==========
bool anyExploding = false;
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 1) {
bombs[i].explodeTimer--;
if (bombs[i].explodeTimer <= 0) {
bombs[i].state = 2;
} else {
anyExploding = true;
}
}
}
// 更新背景闪烁计时器
if (anyExploding) {
flashTimer = 8; // 刷新闪烁时间
}
if (flashTimer > 0) {
flashTimer--;
flashToggle = !flashToggle;
} else {
flashToggle = false;
}
// ========== 4. 更新粒子 ==========
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
p.vy += 0.15; // 重力
p.x += p.vx;
p.y += p.vy;
p.life--;
}
// 清除死亡粒子
vector<Particle> alive;
for (size_t i = 0; i < particles.size(); ++i)
if (particles[i].life > 0) alive.push_back(particles[i]);
particles = alive;
// ========== 5. 全部鞭炮炸完则重置 ==========
bool allDone = true;
for (size_t i = 0; i < bombs.size(); ++i)
if (bombs[i].state != 2) { allDone = false; break; }
if (allDone) {
respawnDelay++;
if (respawnDelay > 40) { // 约1.3秒后刷新
burnPos = 0.0;
particles.clear();
for (size_t i = 0; i < bombs.size(); ++i) {
bombs[i].state = 0;
bombs[i].explodeTimer = 0;
}
respawnDelay = 0;
flashTimer = 0;
}
}
// ========== 6. 绘制到缓冲区 ==========
// 确定本帧背景色
WORD bgColor = 0x00; // 黑色背景
if (flashTimer > 0) {
bgColor = flashToggle ? 0x40 : 0x60; // 红/黄背景
}
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = bgColor; // 只设背景色
}
// 绘制引线
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
// 已燃烧部分(暗灰色)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8 | (bgColor & 0xF0);
} else if (x < winWidth - 1) {
// 未燃烧部分(亮黄)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14 | (bgColor & 0xF0);
}
}
// 绘制鞭炮
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue; // 已爆炸不绘
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12 | (bgColor & 0xF0); // 红色
} else if (bombs[i].state == 1) {
// 爆炸中不绘制字符,粒子自己会覆盖
}
}
}
// 绘制粒子(覆盖底层)
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
int px = (int)(p.x + 0.5);
int py = (int)(p.y + 0.5);
if (px >= 0 && px < winWidth && py >= 0 && py < winHeight) {
int idx = py * winWidth + px;
screenBuf[idx].Char.AsciiChar = p.ch;
screenBuf[idx].Attributes = p.color | (bgColor & 0xF0);
}
}
// 底部提示文字
const char* hint = "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10 | (bgColor & 0xF0); // 亮绿
}
}
// ========== 7. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
| 拼合前 | 拼合后 |
|---|---|
| 炸完就结束了 | 全炸完自动重置、无限循环、完整的"游戏"体验 |
这一步加了什么?allDone 全状态检测 + respawnDelay 冷却计时 + 全部状态重置。为什么放最后?因为重置是"一轮结束"的收尾动作,必须等所有状态都更新完、画完一帧,才能判断"这一轮是不是结束了"。
🐧 Linux / macOS 用户看这里
拼合的五步在 ncurses 版里,只有"画板层"(初始化、输入、渲染、延时、音效)不同,逻辑层(燃烧、点燃、连锁、粒子物理、重置)完全照搬。五步顺序不变。
本节对应的 ncurses 代码片段(编译命令:
g++ firecracker.cpp -o firecracker -lncurses):
cpp// ===== 第1步 初始化 ===== #include <ncurses.h> #include <vector> #include <cstdlib> #include <ctime> #include <cmath> #include <cstring> #include <unistd.h> using namespace std; struct Firecracker { int x, state, explodeTimer; }; int main() { initscr(); cbreak(); noecho(); curs_set(0); srand((unsigned)time(NULL)); int winWidth = COLS, winHeight = LINES; if (winWidth < 50 || winHeight < 15) { endwin(); printf("请拉大终端窗口!\n"); return 1; } start_color(); init_pair(1, COLOR_YELLOW, COLOR_BLACK); // 亮黄引线 init_pair(2, COLOR_RED, COLOR_BLACK); // 红色鞭炮 init_pair(3, COLOR_GREEN, COLOR_BLACK); // 提示文字 init_pair(4, COLOR_WHITE, COLOR_BLACK); // 白色(暗灰替代) // ... 生成 10 个鞭炮 ... // 主循环: erase() + mvaddch() + refresh() + usleep(30000) endwin(); return 0; } // ===== 第2步 燃烧 + 输入 ===== // nodelay(stdscr, TRUE); // 非阻塞输入,替代 _kbhit // int ch = getch(); // 替代 _getch,无输入时返回 ERR // if (ch == 27) quit = true; // if (ch != ERR) burnPos += KEY_BOOST; // burnPos += BASE_BURN_SPEED; // 引线绘制: mvaddch(FUSE_Y, x, '-') | COLOR_PAIR(1) 亮黄 / COLOR_PAIR(4) 暗灰 // ===== 第3步 点燃 + 连锁 ===== // toExplode 队列、abs(x差)<=3、去重逻辑------一行不改 // beep(); // 替代 Beep(freq, dur),ncurses 的蜂鸣 // 爆炸中鞭炮用空格 mvaddch(BOMB_Y, x, ' ') 留空 // ===== 第4步 粒子 + 闪烁 ===== // 粒子 struct 不变;颜色用 init_pair 预定义 7 种亮色 // 背景闪烁: wbkgd(stdscr, COLOR_PAIR(bgPair)) 切换背景色对 // 粒子绘制: mvaddch(py, px, p.ch) | COLOR_PAIR(p.colorPair) // ===== 第5步 重置 + 完整版 ===== // allDone / respawnDelay 逻辑完全不变 // usleep(30000) 替代 Sleep(30) // endwin() 程序退出前恢复终端对照关系:五步顺序不变,第1步(初始化)和第5步(渲染)换了工具,第2~4步逻辑一行不改。
💡 五步拼合的总原则
整个拼合顺序遵循游戏循环的经典五段式:
- 准备(地基、常量、数据结构)
- 输入 + 驱动(按键、自动燃烧)
- 世界演化(点燃、连锁、计时、粒子、重置)
- 渲染(画到缓冲区)
- 呈现 + 节拍(双缓冲输出 + Sleep)
每一帧都重复 2~5,直到 quit。这个骨架可以套用到几乎所有 CMD 实时动画,是本系列最该记住的"模板"。
⚠️ 注意事项
- 五步顺序不能乱:必须先更新状态再画,否则画的是上一帧的状态。
toExplode是每帧新建的局部变量,别提到循环外,否则会跨帧残留导致重复引爆。Sleep(30)别省,否则 CPU 占满、动画快得看不清。
11. 🏆 完整版出炉 --- 完整代码与模块对照表
🎯 我们要做什么
把前面所有零件拼好,下面就是完整的、可直接编译运行的 Firecracker.cpp。它实现:引线从左烧到右、按键加速、烧到鞭炮就炸、连锁传播、粒子向上飞散、背景红黄闪烁、Beep 音效、全炸完自动重置。
💻 完整代码(Windows 版)
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
#include <cstdio>
#include <cmath>
using namespace std;
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
void HideCursor() {
CONSOLE_CURSOR_INFO ci;
GetConsoleCursorInfo(hOut, &ci);
ci.bVisible = false;
SetConsoleCursorInfo(hOut, &ci);
}
// 爆炸粒子
struct Particle {
double x, y;
double vx, vy;
int life;
WORD color;
char ch;
};
// 鞭炮状态:0=未燃, 1=爆炸中, 2=已爆炸
struct Firecracker {
int x;
int state; // 0未燃,1爆炸中,2已爆完
int explodeTimer; // 爆炸中剩余帧数
};
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 控制台窗口信息
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hOut, &csbi);
int winLeft = csbi.srWindow.Left;
int winTop = csbi.srWindow.Top;
int winWidth = csbi.srWindow.Right - winLeft + 1;
int winHeight = csbi.srWindow.Bottom - winTop + 1;
if (winWidth < 50 || winHeight < 15) {
cout << "请拉大控制台窗口!" << endl;
return 1;
}
const int BOMB_Y = winHeight - 3; // 鞭炮所在行
const int FUSE_Y = BOMB_Y - 1; // 引线行
const int MAX_BOMBS = 10;
const double BASE_BURN_SPEED = 0.08; // 基础燃烧速度
const double KEY_BOOST = 0.35; // 按键加速量
// 生成鞭炮位置(均匀分布)
vector<Firecracker> bombs;
bombs.reserve(MAX_BOMBS);
for (int i = 0; i < MAX_BOMBS; ++i) {
Firecracker b;
b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1));
b.state = 0;
b.explodeTimer = 0;
bombs.push_back(b);
}
double burnPos = 0.0; // 当前引线燃烧到的x坐标
vector<Particle> particles;
particles.reserve(1000);
// 背景闪烁相关
int flashTimer = 0;
bool flashToggle = false;
// 用于重新生成鞭炮的冷却计时
int respawnDelay = 0;
bool quit = false;
vector<CHAR_INFO> screenBuf(winWidth * winHeight);
while (!quit) {
// ========== 1. 输入处理(狂按加速) ==========
while (_kbhit()) {
int ch = _getch();
if (ch == 27) { quit = true; break; } // Esc 退出
if (ch == 224) { // 扩展键
if (_kbhit()) _getch();
}
burnPos += KEY_BOOST; // 任意键加速燃烧
}
if (quit) break;
// 基础燃烧
burnPos += BASE_BURN_SPEED;
// ========== 2. 点燃鞭炮及连锁反应 ==========
// 使用队列处理连锁引爆
vector<int> toExplode; // 存储本次要引爆的鞭炮索引
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 0 && burnPos >= bombs[i].x) {
toExplode.push_back(i);
}
}
// 连锁引爆:遍历队列,将相邻未燃鞭炮加入
for (size_t idx = 0; idx < toExplode.size(); ++idx) {
int i = toExplode[idx];
if (bombs[i].state != 0) continue; // 可能已处理
// 引爆当前鞭炮
bombs[i].state = 1;
bombs[i].explodeTimer = 12; // 爆炸持续12帧
// 生成粒子
int numParticles = 50 + rand() % 30;
for (int p = 0; p < numParticles; ++p) {
Particle part;
part.x = bombs[i].x;
part.y = BOMB_Y;
double angle = (rand() % 180) * 3.14159 / 180.0; // 向上半球
if (rand() % 2) angle = -angle; // 偶尔向下
double speed = (rand() % 300) / 100.0 + 0.8;
part.vx = cos(angle) * speed;
part.vy = -fabs(sin(angle)) * speed - 0.5; // 主要向上
part.life = rand() % 25 + 15;
// 随机亮色
static int brightColors[] = {9,10,11,12,13,14,15};
part.color = brightColors[rand() % 7];
part.ch = "@#$%&*"[rand() % 6];
particles.push_back(part);
}
// 音效
Beep(600 + rand() % 400, 60);
// 检查相邻鞭炮(左右各2个位置内)
for (size_t j = 0; j < bombs.size(); ++j) {
if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) {
// 避免重复加入队列
bool already = false;
for (size_t k = 0; k < toExplode.size(); ++k)
if (toExplode[k] == (int)j) { already = true; break; }
if (!already) toExplode.push_back(j);
}
}
}
// ========== 3. 更新爆炸计时器 ==========
bool anyExploding = false;
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 1) {
bombs[i].explodeTimer--;
if (bombs[i].explodeTimer <= 0) {
bombs[i].state = 2;
} else {
anyExploding = true;
}
}
}
// 更新背景闪烁计时器
if (anyExploding) {
flashTimer = 8; // 刷新闪烁时间
}
if (flashTimer > 0) {
flashTimer--;
flashToggle = !flashToggle;
} else {
flashToggle = false;
}
// ========== 4. 更新粒子 ==========
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
p.vy += 0.15; // 重力
p.x += p.vx;
p.y += p.vy;
p.life--;
}
// 清除死亡粒子
vector<Particle> alive;
for (size_t i = 0; i < particles.size(); ++i)
if (particles[i].life > 0) alive.push_back(particles[i]);
particles = alive;
// ========== 5. 全部鞭炮炸完则重置 ==========
bool allDone = true;
for (size_t i = 0; i < bombs.size(); ++i)
if (bombs[i].state != 2) { allDone = false; break; }
if (allDone) {
respawnDelay++;
if (respawnDelay > 40) { // 约1.3秒后刷新
burnPos = 0.0;
particles.clear();
for (size_t i = 0; i < bombs.size(); ++i) {
bombs[i].state = 0;
bombs[i].explodeTimer = 0;
}
respawnDelay = 0;
flashTimer = 0;
}
}
// ========== 6. 绘制到缓冲区 ==========
// 确定本帧背景色
WORD bgColor = 0x00; // 黑色背景
if (flashTimer > 0) {
bgColor = flashToggle ? 0x40 : 0x60; // 红/黄背景
}
for (int i = 0; i < winWidth * winHeight; ++i) {
screenBuf[i].Char.AsciiChar = ' ';
screenBuf[i].Attributes = bgColor; // 只设背景色
}
// 绘制引线
for (int x = 0; x < winWidth; ++x) {
int idx = FUSE_Y * winWidth + x;
if (x <= (int)burnPos) {
// 已燃烧部分(暗灰色)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 8 | (bgColor & 0xF0);
} else if (x < winWidth - 1) {
// 未燃烧部分(亮黄)
screenBuf[idx].Char.AsciiChar = '-';
screenBuf[idx].Attributes = 14 | (bgColor & 0xF0);
}
}
// 绘制鞭炮
for (size_t i = 0; i < bombs.size(); ++i) {
if (bombs[i].state == 2) continue; // 已爆炸不绘
int x = bombs[i].x;
if (x >= 0 && x < winWidth) {
int idx = BOMB_Y * winWidth + x;
if (bombs[i].state == 0) {
screenBuf[idx].Char.AsciiChar = '*';
screenBuf[idx].Attributes = 12 | (bgColor & 0xF0); // 红色
} else if (bombs[i].state == 1) {
// 爆炸中不绘制字符,粒子自己会覆盖
}
}
}
// 绘制粒子(覆盖底层)
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
int px = (int)(p.x + 0.5);
int py = (int)(p.y + 0.5);
if (px >= 0 && px < winWidth && py >= 0 && py < winHeight) {
int idx = py * winWidth + px;
screenBuf[idx].Char.AsciiChar = p.ch;
screenBuf[idx].Attributes = p.color | (bgColor & 0xF0);
}
}
// 底部提示文字
const char* hint = "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出";
int hintY = winHeight - 1;
int hintX = (winWidth - strlen(hint)) / 2;
for (size_t i = 0; i < strlen(hint); ++i) {
int x = hintX + i;
if (x >= 0 && x < winWidth) {
int idx = hintY * winWidth + x;
screenBuf[idx].Char.AsciiChar = hint[i];
screenBuf[idx].Attributes = 10 | (bgColor & 0xF0); // 亮绿
}
}
// ========== 7. 双缓冲输出 ==========
COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
COORD bufCoord = { 0, 0 };
SMALL_RECT writeRegion = {
(SHORT)winLeft, (SHORT)winTop,
(SHORT)(winLeft + winWidth - 1),
(SHORT)(winTop + winHeight - 1)
};
WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);
Sleep(30); // 约30 FPS
}
return 0;
}
🐧 Linux / macOS 用户看这里
Windows 完整版用
<windows.h>+<conio.h>+WriteConsoleOutput双缓冲;Linux/macOS 上对应的是 ncurses 版完整程序,功能完全一致:隐藏光标、检测窗口尺寸、引线燃烧、按键加速、连锁引爆、粒子飞散、背景闪烁、音效、自动重置、双缓冲------一个都不少。核心的"连锁传播队列"逻辑与平台无关,只有"画板层"换了工具。完整等价代码(保存为
firecracker.cpp,编译命令:g++ firecracker.cpp -o firecracker -lncurses):
cpp#include <iostream> #include <ncurses.h> // 替代 <windows.h> + <conio.h> #include <vector> #include <cstdlib> #include <ctime> #include <cstdio> #include <cmath> #include <cstring> // strlen #include <unistd.h> // usleep(替代 Sleep) using namespace std; // ===== 鞭炮结构体(与 Windows 版完全相同)===== struct Firecracker { int x; int state; // 0未燃, 1爆炸中, 2已爆完 int explodeTimer; }; // ===== 粒子结构体(color 字段从 WORD 改为 int)===== struct Particle { double x, y; double vx, vy; int life; int color; // ncurses 颜色常量(COLOR_RED 等) char ch; }; // ===== 颜色对编号:fg(0..7) × bgMode(0黑/1红/2黄) ===== // 编号 = fg * 3 + bgMode + 1,避开保留的 pair 0 int colorPair(int fg, int bgMode) { return fg * 3 + bgMode + 1; } int main() { // ----- ncurses 初始化 ----- initscr(); // 进入 ncurses 模式 cbreak(); // 关闭行缓冲,按键立即可读 noecho(); // 不回显按键 curs_set(0); // 隐藏光标(替代 HideCursor) nodelay(stdscr, TRUE); // getch 非阻塞(替代 _kbhit) keypad(stdscr, TRUE); // 启用功能键(方向键自动转 KEY_*) srand((unsigned)time(NULL)); // ----- 颜色初始化 ----- // Windows 用一个字节存前景/背景;ncurses 用 init_pair 注册颜色对 if (has_colors()) { start_color(); int bgTab[3] = {COLOR_BLACK, COLOR_RED, COLOR_YELLOW}; for (int bg = 0; bg < 3; ++bg) for (int fg = 0; fg < 8; ++fg) init_pair(colorPair(fg, bg), fg, bgTab[bg]); } // ----- 窗口尺寸(替代 GetConsoleScreenBufferInfo)----- int winWidth = getmaxx(stdscr); int winHeight = getmaxy(stdscr); if (winWidth < 50 || winHeight < 15) { endwin(); cout << "请拉大终端窗口!" << endl; return 1; } const int BOMB_Y = winHeight - 3; const int FUSE_Y = BOMB_Y - 1; const int MAX_BOMBS = 10; const double BASE_BURN_SPEED = 0.08; const double KEY_BOOST = 0.35; // ----- 摆鞭炮(均匀分布,逻辑不变)----- vector<Firecracker> bombs; bombs.reserve(MAX_BOMBS); for (int i = 0; i < MAX_BOMBS; ++i) { Firecracker b; b.x = (i + 1) * (winWidth / (MAX_BOMBS + 1)); b.state = 0; b.explodeTimer = 0; bombs.push_back(b); } double burnPos = 0.0; vector<Particle> particles; particles.reserve(1000); int flashTimer = 0; bool flashToggle = false; int respawnDelay = 0; bool quit = false; // 粒子亮色(ncurses 颜色常量,替代 Windows 的 9~15) static int brightColors[] = { COLOR_BLUE, COLOR_GREEN, COLOR_CYAN, COLOR_RED, COLOR_MAGENTA, COLOR_YELLOW, COLOR_WHITE }; // ===== 主循环 ===== while (!quit) { // ========== 1. 输入处理(狂按加速)========== int ch; while ((ch = getch()) != ERR) { // ERR 表示无按键 if (ch == 27) { quit = true; break; } // ESC 退出 burnPos += KEY_BOOST; // 任意键加速 } if (quit) break; burnPos += BASE_BURN_SPEED; // ========== 2. 点燃 + 连锁(逻辑不变)========== vector<int> toExplode; for (size_t i = 0; i < bombs.size(); ++i) if (bombs[i].state == 0 && burnPos >= bombs[i].x) toExplode.push_back(i); for (size_t idx = 0; idx < toExplode.size(); ++idx) { int i = toExplode[idx]; if (bombs[i].state != 0) continue; bombs[i].state = 1; bombs[i].explodeTimer = 12; // 生成粒子 int numParticles = 50 + rand() % 30; for (int p = 0; p < numParticles; ++p) { Particle part; part.x = bombs[i].x; part.y = BOMB_Y; double angle = (rand() % 180) * 3.14159 / 180.0; if (rand() % 2) angle = -angle; double speed = (rand() % 300) / 100.0 + 0.8; part.vx = cos(angle) * speed; part.vy = -fabs(sin(angle)) * speed - 0.5; part.life = rand() % 25 + 15; part.color = brightColors[rand() % 7]; part.ch = "@#$%&*"[rand() % 6]; particles.push_back(part); } beep(); // 音效(替代 Beep) // 传染邻居 for (size_t j = 0; j < bombs.size(); ++j) { if (bombs[j].state == 0 && abs(bombs[j].x - bombs[i].x) <= 3) { bool already = false; for (size_t k = 0; k < toExplode.size(); ++k) if (toExplode[k] == (int)j) { already = true; break; } if (!already) toExplode.push_back(j); } } } // ========== 3. 爆炸计时器 + 闪烁 ========== bool anyExploding = false; for (size_t i = 0; i < bombs.size(); ++i) { if (bombs[i].state == 1) { bombs[i].explodeTimer--; if (bombs[i].explodeTimer <= 0) bombs[i].state = 2; else anyExploding = true; } } if (anyExploding) flashTimer = 8; if (flashTimer > 0) { flashTimer--; flashToggle = !flashToggle; } else { flashToggle = false; } // ========== 4. 粒子物理 + 清理 ========== for (size_t i = 0; i < particles.size(); ++i) { Particle& p = particles[i]; p.vy += 0.15; // 重力 p.x += p.vx; p.y += p.vy; p.life--; } vector<Particle> alive; for (size_t i = 0; i < particles.size(); ++i) if (particles[i].life > 0) alive.push_back(particles[i]); particles = alive; // ========== 5. 全炸完重置 ========== bool allDone = true; for (size_t i = 0; i < bombs.size(); ++i) if (bombs[i].state != 2) { allDone = false; break; } if (allDone) { respawnDelay++; if (respawnDelay > 40) { burnPos = 0.0; particles.clear(); for (size_t i = 0; i < bombs.size(); ++i) { bombs[i].state = 0; bombs[i].explodeTimer = 0; } respawnDelay = 0; flashTimer = 0; } } // ========== 6. 绘制(erase + 逐帧 + refresh 双缓冲)========== int bgMode = 0; // 0黑 1红 2黄 if (flashTimer > 0) bgMode = flashToggle ? 1 : 2; // 设置背景色并清屏(替代 screenBuf 整屏刷背景色) bkgdset(COLOR_PAIR(colorPair(COLOR_BLACK, bgMode))); erase(); // 用当前背景色清空整个屏幕 // 引线(双色) for (int x = 0; x < winWidth; ++x) { if (x <= (int)burnPos) mvaddch(FUSE_Y, x, '-' | COLOR_PAIR(colorPair(COLOR_WHITE, bgMode))); else if (x < winWidth - 1) mvaddch(FUSE_Y, x, '-' | COLOR_PAIR(colorPair(COLOR_YELLOW, bgMode))); } // 鞭炮 for (size_t i = 0; i < bombs.size(); ++i) { if (bombs[i].state == 2) continue; int x = bombs[i].x; if (x >= 0 && x < winWidth && bombs[i].state == 0) mvaddch(BOMB_Y, x, '*' | COLOR_PAIR(colorPair(COLOR_RED, bgMode))); } // 粒子(A_BOLD 让亮色更鲜艳) for (size_t i = 0; i < particles.size(); ++i) { Particle& p = particles[i]; int px = (int)(p.x + 0.5); int py = (int)(p.y + 0.5); if (px >= 0 && px < winWidth && py >= 0 && py < winHeight) mvaddch(py, px, p.ch | COLOR_PAIR(colorPair(p.color, bgMode)) | A_BOLD); } // 提示文字 const char* hint = "疯狂敲击键盘 -> 引燃鞭炮! [ESC]退出"; int hintY = winHeight - 1; int hintX = (winWidth - (int)strlen(hint)) / 2; attron(COLOR_PAIR(colorPair(COLOR_GREEN, bgMode))); mvaddstr(hintY, hintX, hint); attroff(COLOR_PAIR(colorPair(COLOR_GREEN, bgMode))); // ========== 7. 刷新 + 帧率 ========== refresh(); usleep(30000); // 30ms ≈ 30FPS } endwin(); // 退出 ncurses,恢复终端(必须调用!) return 0; }对照关系:
<windows.h>+<conio.h>→<ncurses.h>+<unistd.h>、WriteConsoleOutput→erase()+refresh()、_kbhit/_getch→nodelay+getch、Beep→beep()、Sleep→usleep,连锁/粒子/状态机逻辑全部不变。
📋 模块对照表
下表把完整代码的每个区块对应到前面章节,方便你对照阅读:
| 代码区块(行号区间) | 对应章节 | 作用 |
|---|---|---|
| 头文件 + using | 第1章 | 引入工具箱 |
HideCursor() |
第1章 | 藏光标 |
struct Particle |
第7章 | 粒子数据结构 |
struct Firecracker |
第2章 | 鞭炮数据结构 |
| 窗口检测 | 第1章 | 画板够不够大 |
| 常量定义 | 第2、3、4章 | 位置与速度参数 |
| 生成 bombs | 第2章 | 均匀摆鞭炮 |
burnPos / particles / 计时器初始化 |
第3、7、8、9章 | 状态变量 |
screenBuf 声明 |
第1章 | 双缓冲草稿纸 |
输入 _kbhit 循环 |
第4章 | 狂按加速 + ESC |
burnPos += BASE_BURN_SPEED |
第3章 | 自动燃烧 |
收集 toExplode 名单 |
第5章 | 找被引线点着的 |
| 队列式连锁循环 | 第6章 | 多米诺传播(核心) |
| 生成粒子 + Beep | 第7、8章 | 火星 + 音效 |
| 相邻检测 + 去重 | 第6章 | 传染邻居 |
| 爆炸计时器更新 | 第8章 | 1→2 状态切换 |
| 闪烁计时器 | 第8章 | 背景红黄交替 |
| 粒子物理更新 + 清理 | 第7章 | 重力 + 寿命 |
| allDone + respawnDelay | 第9章 | 全炸完冷却重置 |
| 清屏 + 背景色 | 第8章 | 整屏闪光底色 |
| 绘制引线 | 第3章 | 双色引线 |
| 绘制鞭炮 | 第2章 | 红色 * |
| 绘制粒子 | 第7章 | 火星覆盖 |
| 绘制提示文字 | 第1章 | 底部说明 |
WriteConsoleOutput |
第1章 | 双缓冲贴屏 |
Sleep(30) |
第10章 | 帧率控制 |
💡 背后的原理
为什么完整版几乎和零件一模一样?
因为我们在前面分块时已经按"主循环执行顺序"来讲,零件本身就在正确位置上,拼合时几乎不用调整。这说明好的分块教学就是按程序执行流来切,学完分块自然能拼出整体。如果分块按"概念分类"切(比如所有结构体放一起、所有绘制放一起),拼合时就要大搬家,反而难。
完整版的可调参数清单
| 参数 | 当前值 | 调大会怎样 | 调小会怎样 |
|---|---|---|---|
MAX_BOMBS |
10 | 鞭炮更密、连锁更明显 | 鞭炮更稀 |
BASE_BURN_SPEED |
0.08 | 引线烧得快 | 烧得慢 |
KEY_BOOST |
0.35 | 按键加速更猛 | 加速弱 |
explodeTimer |
12 | 爆炸持续久、闪得久 | 爆得快 |
numParticles |
50~80 | 火星更密 | 火星稀 |
重力 0.15 |
0.15 | 粒子掉得快 | 飘得久 |
respawnDelay 阈值 |
40 | 重置前停顿久 | 重置快 |
Sleep |
30 | 帧率低、动画慢 | 帧率高、动画快 |
⚠️ 注意事项
- 完整版里
bgColor & 0xF0反复出现,是为了让背景闪烁色不被前景字色覆盖。漏了任何一个,那一块就不闪。 static int brightColors[]在连锁循环内部声明,static保证只初始化一次,否则每次进循环都重新构造数组(虽然编译器通常优化掉,但写static更稳)。- 编译时如果报
Beep未定义,确认<windows.h>已 include 且链接了kernel32(g++ 默认链接,一般没问题)。
12. ✨ 架构总结 --- 多层架构与连锁传播模式
🎯 我们要做什么
最后一节我们跳出"逐行看代码",从高处俯瞰整个程序是怎么组织起来的。这一节不讲新代码,只讲"设计思想",帮你把零散的知识串成体系。理解了架构,你以后写自己的动画就能套用这套骨架。
📊 代码量增长表
从第 1 步到完整版,代码量是怎么一步步涨起来的?每一步新增了什么核心概念?下面这张表一目了然:
| 阶段 | 对应章节 | 代码行数 | 新增了什么 | 核心概念 |
|---|---|---|---|---|
| 第 1 步 | 第 1-2 章 | ~120 行 | 双缓冲框架 + 鞭炮结构体 + 静态绘制 | struct · vector · CHAR_INFO · 均匀分布 |
| 第 2 步 | 第 3-4 章 | ~180 行 | 引线燃烧 + 按键加速 | 浮点位置 burnPos · _kbhit 连读 · 双色绘制 |
| 第 3 步 | 第 5-6 章 | ~230 行 | 阈值点燃 + 连锁传播队列 | 三态状态机 · toExplode 队列 · BFS 去重 |
| 第 4 步 | 第 7-8 章 | ~280 行 | 爆炸粒子 + 闪光音效 | 物理模拟 · 重力 · 背景闪烁 · Beep |
| 第 5 步 | 第 9 章 + 完整版 | ~285 行 | 全炸完检测 + 自动重置 + 提示文字 | 冷却计时器 · 闭环系统 · 循环重置 |
从 120 行的"静态画板"到 285 行的"连锁鞭炮系统",每一步只加一个核心功能,但叠加起来就是一个完整的、有节奏感的动画程序。这就是渐进式开发的魅力------每一步都是完整可运行的,你永远知道自己在做什么。
🏗️ 六层架构详解
整个鞭炮程序可以拆解成六层架构,每一层都有明确的职责,就像一家鞭炮厂的流水线:
| 层 | 职责 | 对应代码/数据 | 鞭炮厂比喻 |
|---|---|---|---|
| 输入层 | 监听键盘,加速引线燃烧 | _kbhit · _getch · burnPos += KEY_BOOST |
鼓风机------有人按键就给风,火就烧得快 |
| 数据层(鞭炮状态) | 管理 10 颗鞭炮的位置和三态 | vector<Firecracker> bombs · state 0/1/2 |
鞭炮架------挂着一排待点的鞭炮,每颗有自己的状态 |
| 数据层(粒子) | 管理爆炸产生的所有粒子 | vector<Particle> particles · life · vx/vy |
火星仓库------炸开后飞出的火星,各自乱飞直到熄灭 |
| 传播层(核心) | 连锁引爆队列 + 去重 + 邻居判定 | vector<int> toExplode · 距离阈值 ≤3 |
传话员------一颗炸了就跑去告诉邻居:"该你了!" |
| 渲染层 | 双缓冲绘制 + 背景闪烁 + 文字 | screenBuf · WriteConsoleOutput · flashToggle |
灯光师------控制全场明暗,把所有画面投到银幕上 |
| 音效层 | 爆炸音效 | Beep(600+rand()%400, 60) |
音响师------每次爆炸配一声"啪" |
各层之间的协作关系:
输入层 → 推进引线 → 数据层(鞭炮)
↓
烧到某颗鞭炮(事件)
↓
传播层(队列) ←──┐
↓ │
点燃当前鞭炮 │
↓ │ 邻居入队(循环)
数据层(粒子) 撒粒子 │
↓ │
音效层 Beep 一声 │
↓ │
通知邻居 →───────────┘
↓
渲染层画到屏幕
↓
Sleep(30)
可以看到,传播层是整个系统的核心枢纽------它像"传话游戏"一样,一颗鞭炮把爆炸的消息传给邻居,邻居收到后再传给自己的邻居,消息越传越远,直到所有鞭炮都被点燃。这就是"连锁传播模式"的精髓。
这种分层架构的好处是:每一层只关心自己的事,出了问题知道去哪找。就像鞭炮厂------引线断了找引线工,粒子不对找配方师傅,画面闪了找灯光师,各司其职。
🔄 主循环十步详解
完整版的主循环一共可以拆成十个关键步骤:
① 键盘输入处理(连读 + 加速 + ESC退出)
→ ② 基础燃烧推进(burnPos += BASE_BURN_SPEED)
→ ③ 收集本帧要点燃的鞭炮(引线烧到的 → 加入 toExplode)
→ ④ 连锁引爆处理(遍历队列 + 撒粒子 + Beep + 邻居入队)
→ ⑤ 更新爆炸计时器(state 1→2 切换)
→ ⑥ 更新背景闪烁(flashTimer + flashToggle)
→ ⑦ 更新粒子(重力 + 位置 + 寿命 + 清理死亡)
→ ⑧ 全炸完检测 + 冷却重置
→ ⑨ 绘制所有内容到缓冲区(背景+引线+鞭炮+粒子+提示)
→ ⑩ WriteConsoleOutput 输出 + Sleep(30)
把这十步按功能归类,可以分成四个阶段:
| 阶段 | 步骤 | 做什么 | 比喻 |
|---|---|---|---|
| 输入阶段 | ①-② | 收集用户输入,推进时间 | 鼓风机送风,时间在走 |
| 更新阶段 | ③-⑧ | 更新所有状态:点燃、连锁、计时、粒子、重置 | 鞭炮厂流水线------该炸的炸,该飞的飞,该清的清 |
| 渲染阶段 | ⑨ | 把所有东西画到缓冲区 | 灯光师把画面摆好 |
| 休息阶段 | ⑩ | 输出到屏幕 + 等待下一帧 | 幕布拉开,观众看到一帧完整画面 |
这就是一个标准游戏引擎的主循环结构:输入 → 更新 → 渲染 → 等待。几乎所有游戏、动画程序都是这个套路。你写的鞭炮连锁程序,本质上就是一个微型游戏引擎。
💡 核心设计话题
话题一:多结构体协作 --- Particle + Firecracker 各司其职
本程序有两个 struct:Firecracker(鞭炮)和 Particle(粒子)。它们各管一摊:
Firecracker管"静态布景":位置、状态、爆炸倒计时。数量固定(10 颗),寿命和程序一样长。Particle管"动态特效":位置、速度、寿命、颜色、字符。数量动态(最多上千),寿命短(15~40 帧就死)。
两者通过"引爆事件"耦合:鞭炮 state 0→1 的瞬间,产生一批粒子;粒子独立飞舞,与鞭炮再无关系。这种"长寿命布景对象 + 短寿命特效对象"的分工,是游戏里写"敌人 + 子弹""塔 + 弹幕""角色 + 粒子"的通用模式。把不同生命周期的东西分开管理,代码才不会乱。
话题二:事件驱动模型 --- "发生某事 → 触发某动作"
整个程序的逻辑可以归纳成一条事件链:
引线烧到鞭炮(事件) -> 点燃(state 0->1)(动作)
|
v
鞭炮被点燃(事件) -> 撒粒子 + Beep + 通知邻居(动作)
|
v
邻居被通知(事件) -> 邻居也被点燃(回到上一行, 循环)
|
v
计时器到0(事件) -> 熄灭(state 1->2)(动作)
|
v
全炸完(事件) -> 等冷却 -> 重置(动作)
每一步都是"某条件成立 → 执行某动作 → 可能触发下一个事件"。这种事件驱动 写法的好处是:逻辑分散在各个 if 里,每段只管自己那一摊,容易读懂、容易改。对比"一个大函数从头算到尾"的写法,事件驱动更模块化。
话题三:连锁传播模式 --- 队列式 BFS 的雏形(核心设计)
第 6 章的 toExplode 队列传播,是本篇最核心的设计,也是整个系列里最有技术含量的部分。它本质上是图论里**广度优先搜索(BFS)**的简化版:
- 名单
toExplode= BFS 的"待访问队列"。 - 处理一颗鞭炮 = 访问一个节点。
- 把邻居加进名单 = 把相邻节点入队。
- 去重检查 = 已访问标记。
差别只在于:BFS 通常用 visited[] 数组去重,这里用"扫一遍名单"去重(O(n) 但 n 小)。如果鞭炮上千颗,换成 visited[] 就是标准 BFS。所以你看,一个鞭炮连锁的小程序,骨子里藏着大学数据结构课的 BFS 思想------有趣的 demo 往往是严肃算法的玩具版。
多米诺骨牌比喻 :想象一排多米诺骨牌,你推倒第一张,第一张倒下砸到第二张,第二张倒下砸到第三张......这个"倒下 → 碰倒邻居 → 邻居倒下碰倒邻居的邻居"的过程,和我们的 toExplode 队列完全一样。每一颗被碰倒的骨牌,都会去碰它旁边还站着的骨牌;而已经倒下的(state=2)就不会再被碰了------这就是去重。
这个"队列传播"模式还能用在哪?传染病模拟(病人传染接触者)、消息群发(好友转发给好友)、火焰蔓延(着火点点燃相邻木块)、技能连锁(击杀一个敌人触发溅射伤害到周围敌人)......都是同一套:处理当前节点 → 把符合条件的邻居入队 → 直到队列空。掌握这一个模式,你能写出一大类"连锁反应"程序。
话题四:双缓冲 + 状态机 + 物理模拟 三位一体
回头看,整个程序其实是三种经典技术的合体:
- 双缓冲渲染(第1章):内存画好再贴屏,防闪。
- 有限状态机(第5章):鞭炮 0/1/2 三态切换,驱动行为。
- 物理模拟(第7章):粒子有速度、受重力、走抛物线、有寿命。
这三样是 2D 游戏开发的"三件套",本篇用 285 行把它们全演练了一遍。你以后写贪吃蛇、俄罗斯方块、打砖块、弹幕游戏,都会用到这三件套,只是状态更多、物理更复杂。本篇是这三件套的"最小完整示例"。
⚠️ 注意事项
- 架构理解了不等于会写,建议你亲手敲一遍完整版,改几个参数看效果,再试着加新功能(比如让粒子有拖尾、让鞭炮有不同大小)。
- 别被"事件驱动""BFS""状态机"这些大词吓到,它们本质都是"如果......就......"的朴素逻辑,只是穿了个学术马甲。
- 本架构的局限:单线程、同步 Beep 会卡、去重是 O(n)。做大型游戏时要用多线程、异步音效、哈希去重,但思路一脉相承。
- 传播层是核心,也是最容易出 bug 的地方。如果连锁没反应,先查
toExplode队列是不是空的、距离阈值是不是太小、去重逻辑是不是把新加入的也排除了。
📖 头文件速查
本篇用到的所有头文件,附 cppreference 链接,方便深入查阅:
| 头文件 | 提供的关键工具 | cppreference |
|---|---|---|
<iostream> |
cout、endl(用于提示信息) |
cppreference |
<windows.h> |
HANDLE、GetStdHandle、GetConsoleCursorInfo、SetConsoleCursorInfo、GetConsoleScreenBufferInfo、CONSOLE_CURSOR_INFO、CONSOLE_SCREEN_BUFFER_INFO、CHAR_INFO、COORD、SMALL_RECT、WriteConsoleOutput、Beep、Sleep、WORD、SHORT |
MSDN Win32 文档 |
<vector> |
vector、push_back、reserve、clear、size、operator[] |
cppreference |
<cstdlib> |
rand、srand、abs(整数)、NULL |
cppreference |
<ctime> |
time(给 srand 当种子) |
cppreference |
<conio.h> |
_kbhit、_getch(非标准,Windows/conio 专属) |
MSDN conio |
<cstdio> |
strlen(算提示文字长度) |
cppreference |
<cmath> |
cos、sin、fabs、abs(浮点重载) |
cppreference |
<ncurses.h> |
initscr、endwin、cbreak、noecho、curs_set、getmaxx、getmaxy、mvaddch、mvaddstr、getch、nodelay、keypad、erase、bkgdset、refresh、start_color、init_pair、COLOR_PAIR、has_colors、beep、A_BOLD、ERR(Linux/macOS 跨平台终端库,替代 <windows.h> + <conio.h>) |
ncurses 官方文档 |
<unistd.h> |
usleep(微秒级延时,替代 Sleep) |
POSIX unistd.h |
重点深入研究的几个:
WriteConsoleOutput是双缓冲的核心,MSDN 有详细参数说明,建议读一遍官方文档。vector的reserve/clear/push_back复杂度,cppreference 标注得很清楚,理解了能写出更高性能代码。cos/sin用弧度、fabs取绝对值,是粒子物理的基础,务必熟悉。<ncurses.h>是跨平台终端编程的核心库,本篇各章节末尾的"🐧 Linux / macOS 用户看这里"引用块用它替代了<windows.h>+<conio.h>。建议重点研究init_pair/COLOR_PAIR的颜色对机制(与 Windows 的位运算颜色完全不同)、erase()+refresh()的双缓冲思路,以及nodelay+getch()的非阻塞输入模式。ncurses 官方文档对每个函数都有详尽说明。<unistd.h>的usleep是 Linux/macOS 下控制帧率的常用手段,注意它单位是微秒(1 毫秒 = 1000 微秒),从 Windows 的Sleep(30)迁移时要乘 1000。
🎉 系列完结
两篇回顾
本系列《C++ CMD 互动动画:按键触发爆炸效果》共两篇,到这里就完结了:
- 第 1 章:讲了 CMD 双缓冲基础、字符颜色、按键响应,做了一个"按键触发单点爆炸"的小 demo,学会了一颗鞭炮怎么炸、粒子怎么飞。
- 第 2 章(本篇):把单点爆炸升级成"一排鞭炮 + 引线 + 连锁引爆 + 闪光音效 + 自动重置"的完整系统,学会了一颗怎么传染一片、怎么用状态机管理生命周期、怎么用队列做传播。
从"一颗"到"一排+连锁",难度跨了一级,但也把 CMD 动画的核心技术盘清楚了。
🛤️ 技术演进路线图
两篇文章走下来,你完成了一次完整的"爆炸技术进化之旅"。让我们用路线图的方式回顾一下:
🌱 第 1 章:从 0 到 1 ------ 画出第一个会炸的点
- 起点:空白控制台
- 终点:按键触发,一个点炸开成几十颗粒子
- 跨越:从"静态文字"到"动态爆炸"
- 核心收获:双缓冲、粒子系统、按键交互
- 关键词:诞生
- 比喻:火药的发明------人类第一次掌握了"让东西炸开"的力量
🌿 第 2 章上半段:从 1 到 N ------ 从一颗到一排,加入引线
- 起点:只有单点爆炸
- 终点:一排鞭炮整齐排列,引线从左烧到右
- 跨越:从"随机爆炸"到"有序燃烧"
- 核心收获:结构体数组、浮点运动、阈值检测
- 关键词:排列
- 比喻:鞭炮的发明------把火药装进纸筒,串成一挂,可以有序地炸
🌳 第 2 章中段:从 N 到连锁 ------ 队列传播,一颗传染一片
- 起点:鞭炮只能靠引线一颗一颗烧
- 终点:一颗炸开的冲击波能点着邻居,形成连锁反应
- 跨越:从"线性燃烧"到"网状传播"
- 核心收获:
toExplode队列、BFS 思想、去重逻辑 - 关键词:传染
- 比喻:多米诺骨牌效应------推倒第一张,后面的全倒,气势磅礴
🌋 第 2 章终段:从连锁到闭环 ------ 自动重置,无限循环
- 起点:炸完就结束,画面停在一片灰烬
- 终点:全炸完 → 冷却 → 全部复位 → 再来一轮
- 跨越:从"一次性表演"到"可持续系统"
- 核心收获:冷却计时器、全状态重置、闭环系统
- 关键词:重生
- 比喻:凤凰涅槃------烧完了又从灰烬中重生,生生不息
这四步就像爆炸技术的进化:
- 第 1 章 = 火药(最原始的爆炸,单点)
- 第 2 章上 = 鞭炮(有序排列,可控燃烧)
- 第 2 章中 = 连锁引爆(一颗带一串,气势惊人)
- 第 2 章终 = 循环系统(炸完自动重来,永动机)
从几十行的"让一个点炸开"到 285 行的完整连锁鞭炮系统,你一层一层地往上搭------像拼乐高一样,每块积木本身就是一个能动的成品,拼在一起就成了一个更复杂的成品。
这就是敏捷开发的思路:先做能跑的最小版本,再一步一步加功能。不是先画好架构图再填代码------而是先写代码、看到效果、理解原理,最后再看架构总结。你做过的事情,终于有了正式的叫法。
技术清单
两篇合计你掌握的技术点:
渲染类 :双缓冲 WriteConsoleOutput、CHAR_INFO 一维数组、二维坐标换算(y*width+x)、隐藏光标、窗口尺寸检测、前景背景色位运算(|、& 0xF0)。
数据结构类 :struct 打包多属性对象、vector 动态数组、reserve 预分配、对象池思想(清理死的留活的)、多结构体协作(布景对象 + 特效对象)。
输入类 :_kbhit 非阻塞探测、_getch 无回显读取、while(_kbhit()) 连读、扩展键 224 处理、ESC 退出。
逻辑类 :浮点位置 burnPos 模拟连续运动、阈值检测 >=(不用 ==)、有限状态机 0/1/2 三态、队列式连锁传播(BFS 雏形)、去重检查、冷却计时器(累加-触发-归零)、事件驱动模型。
物理类 :速度-位置积分(x += vx)、重力(vy += g)、抛物线运动、向上半球飞散(-fabs(sin))、粒子生命周期。
表现类 :布尔翻转做闪烁、背景色红黄交替、Beep 随机频率音效、亮色数组随机挑色、字符串字面量当字符池。
🚀 拓展方向:接下来可以玩什么?
本系列讲完了,但 CMD 动画的路还长。基于现在的代码,你可以往很多方向拓展。下面是一些建议,从简单到复杂排列:
🌱 入门级:改参数,看效果
- 调鞭炮数量 :把
MAX_BOMBS从 10 改成 20 或 5,看看画面疏密的变化------越多越壮观,越少越清晰 - 调燃烧速度 :把
BASE_BURN_SPEED从 0.08 改成 0.2(快进模式)或 0.02(慢动作模式),感受不同节奏 - 调爆炸粒子数 :把
50 + rand()%30改成100 + rand()%50,看看满屏火星的效果;改成10 + rand()%10,看看稀疏的小爆炸 - 调重力大小 :把
vy += 0.15改成vy += 0.3(重火星,掉得快)或vy += 0.05(轻火星,飘得远),体会物理参数的影响
🌿 进阶级:加新功能
- 加分数系统:每炸一颗鞭炮加 10 分,连锁炸的有加成(第 N 颗连锁加 N×10 分),在屏幕顶部显示当前得分
- 加倒计时:设置 60 秒倒计时,看你能在时间内让鞭炮重置几轮,挑战自己的手速
- 加多串鞭炮:不止一排,再加一排在上面(不同的 Y 坐标),两串鞭炮同时烧,画面更热闹
- 加引线火花动画 :在
burnPos位置画一个闪烁的小火花(*随机亮黄/亮红交替),让引线燃烧更有动感
🌳 高级:改变系统规则
- 连锁概率机制:不是 100% 传染,而是有一定概率(比如 70%)点着邻居,每次爆炸掷一次骰子。运气不好可能连锁到一半就断了
- 多种鞭炮类型 :给
Firecracker加type字段------有的炸得大(粒子多)、有的炸得响(Beep 频率低)、有的会二次爆炸(state 加一个"蓄力"态)、有的是烟雾弹(只冒烟不闪光) - 风向影响 :加一个全局
wind变量,粒子的vx += wind,风从左吹向右或从右吹向左,爆炸粒子会被吹歪。可以用sin(frameCount*0.02)做周期性变化的风 - 可视化连锁过程:被传染的鞭炮先闪 3 帧再炸(state 加一个"预警"态),或者画一条"传染连线"从爆炸点指向被点着的邻居,帮你看清多米诺是怎么倒的
🌋 专家级:系统级改造
- 关卡系统:设计 5 个关卡,每关鞭炮数量更多、排列更复杂(比如弧形排列、两层排列)、燃烧速度更快,通关后进入下一关
- 无尽模式:鞭炮不是固定 10 颗,而是不断从右边生成、往左边移动,你要不停地按键加速让它们炸掉,没炸掉的到达左边就 Game Over
- 音效系统升级 :用
PlaySound播放真实的 .wav 爆炸音效,或者用多线程异步 Beep 避免阻塞主循环。不同鞭炮类型配不同音效 - 进阶数据结构 :把去重从"扫名单 O(n)"升级成
unordered_set或bool visited[],理解哈希去重的优势。当鞭炮数量达到 1000 颗时,性能差异会非常明显
编程的乐趣就在于:你有一个工具箱,你可以用它搭出任何你想得到的东西。
代码在你手里了。去玩吧。
本系列完结。感谢阅读,欢迎动手敲一遍代码、改改参数,做出你自己的爆炸特效。