C++ CMD 互动动画:按键触发爆炸效果

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(WriteConsoleOutputGetConsoleScreenBufferInfo)。如果你还没配好环境,请先看第 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() = GetStdHandlecurs_set(0) = HideCursor()getmaxx/getmaxy = GetConsoleScreenBufferInfoerase()+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)),把字符和颜色用 | 拼成一个 chtypestruct 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_YFUSE_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):

cpp 复制代码
nodelay(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 == 27break,否则按 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 colorCOLOR_* 常量,绘制用 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(水平方向)也会稍微往上飘,看起来更自然。

如果去掉 fabssin(angle) 在 90~180 度是正的,vy 会变正(向下),粒子就会往地下钻,不科学。所以 fabs 是"把下半球翻到上半球"的关键。

重力:为什么是 vy += 0.15 而不是 y += 0.15?

重力作用在"速度"上,不是直接作用在"位置"上。物理上,重力让物体获得向下的加速度 ,于是每帧速度增加一点,位置才跟着变。如果直接 y += 0.15,那所有粒子匀速下落,没有"先升后落"的弧线,不真实。用 vy += 0.15y += 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):

cpp 复制代码
beep();   // 替代 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 = bgColorbkgdset()+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();:清空所有残留粒子(虽然此时应该都死了,保险起见)。
  • 遍历所有鞭炮,把 stateexplodeTimer 都归零,恢复成"未燃"。
  • respawnDelay = 0;:冷却计数器归零,下一轮重新计。
  • flashTimer = 0;:停止背景闪烁。

💡 背后的原理

为什么要"冷却"再重置,而不是炸完立刻重置?

如果全炸完的当帧立刻重置,玩家根本来不及看到"全炸完"这个状态------画面一闪就回到初始,像没炸过一样。加 40 帧冷却,让"全炸完的空旷"停留一会儿,玩家心理上有个"段落感":砰砰砰......安静 1 秒......又开始烧了。这是节奏控制,让动画有"呼吸"。

allDone 的"短路"检测:为什么要 break?

for (...) if (...) { allDone = false; break; } 发现一颗没炸完就立刻退出循环,不再检查后面的。这叫短路求值------反正已经知道不是全炸完了,再查也没意义,省 CPU。虽然 10 颗无所谓,但这是好习惯,鞭炮上千颗时就明显了。

冷却计时器 respawnDelay 的"累加-触发-归零"三步曲

这是个非常通用的"延迟动作"模式:

  1. 累加 :每帧 respawnDelay++,数着"已经等了多久"。
  2. 触发:等到超过阈值(40),执行动作(重置)。
  3. 归零 :动作做完,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步逻辑一行不改。

💡 五步拼合的总原则

整个拼合顺序遵循游戏循环的经典五段式:

  1. 准备(地基、常量、数据结构)
  2. 输入 + 驱动(按键、自动燃烧)
  3. 世界演化(点燃、连锁、计时、粒子、重置)
  4. 渲染(画到缓冲区)
  5. 呈现 + 节拍(双缓冲输出 + 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>WriteConsoleOutputerase()+refresh()_kbhit/_getchnodelay+getchBeepbeep()Sleepusleep,连锁/粒子/状态机逻辑全部不变。

📋 模块对照表

下表把完整代码的每个区块对应到前面章节,方便你对照阅读:

代码区块(行号区间) 对应章节 作用
头文件 + 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 各司其职

本程序有两个 structFirecracker(鞭炮)和 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. 双缓冲渲染(第1章):内存画好再贴屏,防闪。
  2. 有限状态机(第5章):鞭炮 0/1/2 三态切换,驱动行为。
  3. 物理模拟(第7章):粒子有速度、受重力、走抛物线、有寿命。

这三样是 2D 游戏开发的"三件套",本篇用 285 行把它们全演练了一遍。你以后写贪吃蛇、俄罗斯方块、打砖块、弹幕游戏,都会用到这三件套,只是状态更多、物理更复杂。本篇是这三件套的"最小完整示例"。

⚠️ 注意事项

  • 架构理解了不等于会写,建议你亲手敲一遍完整版,改几个参数看效果,再试着加新功能(比如让粒子有拖尾、让鞭炮有不同大小)。
  • 别被"事件驱动""BFS""状态机"这些大词吓到,它们本质都是"如果......就......"的朴素逻辑,只是穿了个学术马甲。
  • 本架构的局限:单线程、同步 Beep 会卡、去重是 O(n)。做大型游戏时要用多线程、异步音效、哈希去重,但思路一脉相承。
  • 传播层是核心,也是最容易出 bug 的地方。如果连锁没反应,先查 toExplode 队列是不是空的、距离阈值是不是太小、去重逻辑是不是把新加入的也排除了。

📖 头文件速查

本篇用到的所有头文件,附 cppreference 链接,方便深入查阅:

头文件 提供的关键工具 cppreference
<iostream> coutendl(用于提示信息) cppreference
<windows.h> HANDLEGetStdHandleGetConsoleCursorInfoSetConsoleCursorInfoGetConsoleScreenBufferInfoCONSOLE_CURSOR_INFOCONSOLE_SCREEN_BUFFER_INFOCHAR_INFOCOORDSMALL_RECTWriteConsoleOutputBeepSleepWORDSHORT MSDN Win32 文档
<vector> vectorpush_backreserveclearsizeoperator[] cppreference
<cstdlib> randsrandabs(整数)、NULL cppreference
<ctime> time(给 srand 当种子) cppreference
<conio.h> _kbhit_getch(非标准,Windows/conio 专属) MSDN conio
<cstdio> strlen(算提示文字长度) cppreference
<cmath> cossinfabsabs(浮点重载) cppreference
<ncurses.h> initscrendwincbreaknoechocurs_setgetmaxxgetmaxymvaddchmvaddstrgetchnodelaykeypaderasebkgdsetrefreshstart_colorinit_pairCOLOR_PAIRhas_colorsbeepA_BOLDERR(Linux/macOS 跨平台终端库,替代 <windows.h> + <conio.h> ncurses 官方文档
<unistd.h> usleep(微秒级延时,替代 Sleep POSIX unistd.h

重点深入研究的几个:

  • WriteConsoleOutput 是双缓冲的核心,MSDN 有详细参数说明,建议读一遍官方文档。
  • vectorreserve / 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 行的完整连锁鞭炮系统,你一层一层地往上搭------像拼乐高一样,每块积木本身就是一个能动的成品,拼在一起就成了一个更复杂的成品。

这就是敏捷开发的思路:先做能跑的最小版本,再一步一步加功能。不是先画好架构图再填代码------而是先写代码、看到效果、理解原理,最后再看架构总结。你做过的事情,终于有了正式的叫法。

技术清单

两篇合计你掌握的技术点:

渲染类 :双缓冲 WriteConsoleOutputCHAR_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%)点着邻居,每次爆炸掷一次骰子。运气不好可能连锁到一半就断了
  • 多种鞭炮类型 :给 Firecrackertype 字段------有的炸得大(粒子多)、有的炸得响(Beep 频率低)、有的会二次爆炸(state 加一个"蓄力"态)、有的是烟雾弹(只冒烟不闪光)
  • 风向影响 :加一个全局 wind 变量,粒子的 vx += wind,风从左吹向右或从右吹向左,爆炸粒子会被吹歪。可以用 sin(frameCount*0.02) 做周期性变化的风
  • 可视化连锁过程:被传染的鞭炮先闪 3 帧再炸(state 加一个"预警"态),或者画一条"传染连线"从爆炸点指向被点着的邻居,帮你看清多米诺是怎么倒的

🌋 专家级:系统级改造

  • 关卡系统:设计 5 个关卡,每关鞭炮数量更多、排列更复杂(比如弧形排列、两层排列)、燃烧速度更快,通关后进入下一关
  • 无尽模式:鞭炮不是固定 10 颗,而是不断从右边生成、往左边移动,你要不停地按键加速让它们炸掉,没炸掉的到达左边就 Game Over
  • 音效系统升级 :用 PlaySound 播放真实的 .wav 爆炸音效,或者用多线程异步 Beep 避免阻塞主循环。不同鞭炮类型配不同音效
  • 进阶数据结构 :把去重从"扫名单 O(n)"升级成 unordered_setbool visited[],理解哈希去重的优势。当鞭炮数量达到 1000 颗时,性能差异会非常明显

编程的乐趣就在于:你有一个工具箱,你可以用它搭出任何你想得到的东西。

代码在你手里了。去玩吧。


本系列完结。感谢阅读,欢迎动手敲一遍代码、改改参数,做出你自己的爆炸特效。

相关推荐
水饺编程18 分钟前
第5章,[Win32 章节] :Polygon 函数和多边形填充模式
c语言·c++·windows·visual studio
wuminyu19 分钟前
Java FFM处理网络读写事件源码剖析
java·linux·c语言·jvm·c++
互联网中的一颗神经元20 分钟前
10. Large 对象分配:直接路径
开发语言·golang
爱学习的小郑30 分钟前
IC验证学习
学习
田径他爸来了31 分钟前
嵌入式学习第32天(epoll)
学习
心情无变化1 小时前
deepseek harness安装教程
程序人生·ai
a7665989001 小时前
MBTI能预测考试成绩吗?性格偏好不能替代学习条件分析
学习·生活
一木 之林1 小时前
C/C++ 面向对象编程(OOP)(进阶)
java·c语言·c++
mayaairi2 小时前
JS BOM完全指南(二):事件轮询、三大尺寸家族与浏览器对象详解
开发语言·javascript·ecmascript