【按键反馈】C++ 实现键盘烟花秀

🎆 【按键反馈】C++ 实现键盘烟花秀

系列:C++ 控制台编程实战


📌 摘要

敲键盘,放烟花------每个按键在对应位置炸开彩色粒子,还弹出一个闪烁的按键方块。背景有自动烟花秀,中间有标题、实时日期和轮换的编程诗句。

通过这个项目,你将学到:

  • 🎯 双缓冲渲染技术 --- screenBuf + WriteConsoleOutput,告别闪烁
  • 🎯 键盘位置映射 --- map + 比例坐标,按哪个键就在哪炸烟花
  • 🎯 闪烁按键方块 --- 5×3 字符方块 + 颜色快速闪烁 + 边界退化
  • 🎯 三层居中文字排版 --- 大标题 + 实时日期 + 轮换诗句
  • 🎯 窗口尺寸自适应 --- 每帧检测,拉大缩小都不怕
  • 🎯 自动烟花背景 --- 随机间隔发射 + 粒子数上限控制

适合人群:已经学过 C++ 基础语法(变量、循环、数组、函数、结构体),想做炫酷小项目的初中生、高中生,以及编程初学者。

代码量 :完整版约 399 行。我们会从几十行的空画布开始,一步步往上加功能,每一步都完整可运行,跟着敲绝对没问题!

新手必读 :如果你是第一次接触控制台编程,强烈建议先看本系列**环境配置指南**,把 Dev-C++ 或 Visual Studio 装好,确保能编译运行。后面每篇的代码都是 Windows 控制台专属的,Linux/macOS 同学需要用 ncurses 改写,每章末尾我都会给对照说明。


TypingSparksKeyzoneFlash

📹 完整版运行视频 --- 键盘烟花秀完整效果演示,按键闪烁方块 + 自动烟花 + 三层文字,Windows 控制台演示。


📚 目录

  • 编译环境说明
  • [🏗️ 搭好画板:双缓冲框架](#🏗️ 搭好画板:双缓冲框架)
  • [🎆 基础粒子烟花](#🎆 基础粒子烟花)
  • [⌨️ 键盘位置映射](#⌨️ 键盘位置映射)
  • [💎 闪烁按键方块](#💎 闪烁按键方块)
  • [🌟 自动背景烟花](#🌟 自动背景烟花)
  • [📝 三层居中文字](#📝 三层居中文字)
  • [🔄 窗口尺寸自适应](#🔄 窗口尺寸自适应)
  • [🧩 拼合步骤](#🧩 拼合步骤)
  • [🏆 完整版出炉](#🏆 完整版出炉)
  • [✨ 架构总结](#✨ 架构总结)
  • [🛤️ 学习路径](#🛤️ 学习路径)
  • [📖 头文件速查](#📖 头文件速查)
  • [🔜 拓展方向](#🔜 拓展方向)

🛠️ 编译环境说明

💡 环境配置请看学前篇

本文假设你已经搭好了 C++ 编译环境。如果你还没配置,先去看

👉 【学前准备】C++ CMD 环境配置全攻略

里面有 Windows(Dev-C++ / Visual Studio)、macOS、Linux 三大平台的详细安装步骤。

快速编译

bash 复制代码
# Windows (MinGW / Visual Studio)
g++ TypingSparksKeyzoneFlash.cpp -o fireworks -lwinmm
fireworks.exe

# 按 ESC 退出

🐧 Linux / macOS 用户看这里

本文用的是 Windows Console API,Linux/macOS 上对应的是 ncurses 库。

核心逻辑完全一样,只是渲染 API 不同。安装和基础用法请看上面的环境配置文章,

每一节后面也会附上对应的 ncurses 写法对照。


🏗️ 搭好画板:双缓冲框架

🎯 我们要做什么

一切动画的起点,都是先搭好一块"画布"。这一节我们搞定最基础的框架:

  1. 拿到控制台句柄(画笔)
  2. 隐藏光标(避免闪烁)
  3. 检测窗口尺寸
  4. 建立双缓冲画布(screenBuf
  5. 主循环:清屏 → 更新 → 输出 → 休眠

💻 第一步:写出基础框架

cpp 复制代码
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
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 winWidth  = csbi.srWindow.Right - csbi.srWindow.Left + 1;
    int winHeight = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;

    vector<CHAR_INFO> screenBuf(winWidth * winHeight);

    while (true) {
        // 按 ESC 退出
        if (_kbhit()) {
            int ch = _getch();
            if (ch == 27) break;
        }

        // 清屏
        for (int i = 0; i < winWidth * winHeight; ++i) {
            screenBuf[i].Char.AsciiChar = ' ';
            screenBuf[i].Attributes = 7;
        }

        // ← 后面我们会在这里加各种东西

        // 输出到控制台
        COORD bufSize = { (SHORT)winWidth, (SHORT)winHeight };
        COORD bufCoord = { 0, 0 };
        SMALL_RECT writeRegion = { 0, 0, (SHORT)(winWidth - 1), (SHORT)(winHeight - 1) };
        WriteConsoleOutput(hOut, &screenBuf[0], bufSize, bufCoord, &writeRegion);

        Sleep(30);
    }
    return 0;
}

🐧 Linux / macOS 用户看这里

Windows 用 WriteConsoleOutput + CHAR_INFO 做双缓冲,Linux/macOS 上 ncurses 的对应方式是:

cpp 复制代码
#include <ncurses.h>
#include <unistd.h>
// ...
initscr();
cbreak();
noecho();
curs_set(0);           // 隐藏光标
nodelay(stdscr, 1);    // 非阻塞输入
keypad(stdscr, 1);     // 支持方向键

while (true) {
    erase();           // 清屏
    // ... 绘制 ...
    refresh();         // 输出
    usleep(30000);     // 休眠30毫秒
}
endwin();

对照关系:screenBuf → 直接用 mvaddch 绘制;WriteConsoleOutputrefresh()Sleep(30)usleep(30000)

📝 逐行看懂这段代码

HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);

  • 拿到控制台的"输出句柄",可以理解为拿到了画笔
  • 后面所有画图操作都要用这个 hOut

HideCursor() 函数

  • 控制台默认有一个一闪一闪的光标
  • 动画里光标会干扰视觉,所以一开始就把它藏起来
  • bVisible = false 就是"不可见"的意思

CONSOLE_SCREEN_BUFFER_INFO

  • 这是一个结构体,用来存控制台窗口的信息
  • srWindow 里有窗口的左、上、右、下四个边界
  • 用右边减左边加 1,就是宽度;下边减上边加 1,就是高度

vector<CHAR_INFO> screenBuf(winWidth * winHeight)

  • 这就是我们的"画布"------一个一维数组
  • 每个元素是 CHAR_INFO,包含两个东西:
    • Char.AsciiChar:这个格子里显示什么字符
    • Attributes:这个格子的颜色
  • 为什么是一维数组?因为操作系统要求这样传,用 y * width + x 就能算出索引

💡 背后的原理

为什么叫"双缓冲"

想象你在黑板上画画,一边画观众一边看。画到一半的时候画面是半拉子的,看起来就会闪。

双缓冲的思路是:拿两块黑板------

  • 前缓冲区(前台):观众看到的那块
  • 后缓冲区(后台):你偷偷在上面画的那块

你在后台那块黑板上把整幅画都画好了,然后"啪"一下把两块黑板交换。观众看到的永远是完整的一帧,不会有画到一半的中间状态。

这就是双缓冲为什么能消除闪烁的原因。

为什么是 30 毫秒

Sleep(30) 表示每帧休眠 30 毫秒。

算一下:1000 毫秒 ÷ 30 ≈ 33 帧每秒(FPS)。

这个速度刚刚好------

  • 低于 20 FPS 会明显卡顿
  • 高于 60 FPS 人眼分辨不出差别,反而浪费 CPU
  • 30~40 FPS 是控制台动画的"甜点区"

⚠️ 注意事项

  1. 窗口不要拉大拉小:目前这个版本还不能处理窗口尺寸变化,拉大窗口会导致越界访问(后面第7章会加自适应)
  2. CHAR_INFO 里的字符是 AsciiChar:只支持 ASCII 字符,中文显示不出来(后面会看到日期用的是中文,但那是特殊处理)
  3. 颜色编号 0~15:0 是黑色,7 是默认灰白色,10~15 是亮色

🎆 基础粒子烟花

🎯 我们要做什么

有了画布,接下来加最核心的东西------粒子烟花。这一节我们实现:

  1. 定义粒子结构体
  2. 写一个发射烟花的函数
  3. 让粒子飞起来(重力 + 生命周期)
  4. 把粒子画到画布上
  5. 清理死亡的粒子

💻 粒子结构体和发射函数

cpp 复制代码
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

struct Particle {
    double x, y;       // 位置
    double vx, vy;     // 速度
    int life;          // 剩余生命(帧数)
    int color;         // 颜色
    char ch;           // 显示的字符
    bool active;       // 是否还活着
};

void LaunchFirework(vector<Particle>& particles, double cx, double cy, int baseColor = -1) {
    if (baseColor == -1) baseColor = rand() % 14 + 1;
    char sparkChars[] = {'*', '+', '.', 'o', 'O', '@', 'x', '~'};
    int sparkCount = sizeof(sparkChars) / sizeof(sparkChars[0]);
    int sparks = rand() % 70 + 40;     // 40~109 个粒子
    for (int i = 0; i < sparks; ++i) {
        Particle p;
        p.x = cx; p.y = cy;
        double angle = (rand() % 360) * M_PI / 180.0;
        double speed = (rand() % 150) / 100.0 + 0.5;
        p.vx = cos(angle) * speed;
        p.vy = sin(angle) * speed - 0.5;   // 稍微向上偏
        p.life = rand() % 40 + 20;          // 20~59 帧生命
        p.color = baseColor;
        p.ch = sparkChars[rand() % sparkCount];
        p.active = true;
        particles.push_back(p);
    }
}

🐧 Linux / macOS 用户看这里

ncurses 中颜色用颜色对(color pair)来表示,需要先初始化:

cpp 复制代码
// 初始化颜色(在 initscr() 之后)
start_color();
for (int i = 1; i <= 7; ++i)
    init_pair(i, i, COLOR_BLACK);  // 前景色i,背景色黑

// 绘制时:
attron(COLOR_PAIR(color));
mvaddch(y, x, ch);
attroff(COLOR_PAIR(color));

对照关系:Attributes = colorCOLOR_PAIR(color);ncurses 只有 8 种基础颜色(编号 0~7)

📝 逐行看懂这段代码

struct Particle

  • 粒子有 7 个属性:位置、速度、生命、颜色、字符、是否活跃
  • xydouble 不用 int,因为运动需要精确到小数

LaunchFirework 函数

  • 参数:粒子列表(引用传递,直接修改原列表)、爆炸中心坐标、基础颜色
  • baseColor = -1 是默认参数,不传的话随机选颜色
  • 一次发射 40~109 个粒子(rand() % 70 + 40

角度和速度的计算

  • angle 是 0~360 度的随机角度,转换成弧度(× π ÷ 180)
  • speed 是 0.5~2.0 的随机速度
  • cos(angle) * speed 算水平速度 vx
  • sin(angle) * speed 算垂直速度 vy
  • vy 再减 0.5,让爆炸整体稍微向上喷(更像烟花)

💡 背后的原理

为什么用 cos 和 sin

想象你站在圆心,往四面八方扔小球:

  • cos(角度) 告诉你水平方向扔多远
  • sin(角度) 告诉你垂直方向扔多远
  • 角度从 0 度到 360 度,就能覆盖所有方向

这就像一个飞镖盘------每个粒子往不同方向飞,就形成了爆炸效果。

默认参数的好处

int baseColor = -1 这种写法叫"默认参数"。

意思是:调用的时候你可以传颜色,也可以不传。不传的话就用 -1,函数里再判断是 -1 的话就随机选颜色。

这样一个函数就能有两种用法:

cpp 复制代码
LaunchFirework(particles, 10, 20);        // 随机颜色
LaunchFirework(particles, 10, 20, 12);     // 指定颜色(红色)

就像一个自动售货机------你可以选口味,也可以说"随便来一个"。

💻 在主循环里更新和绘制粒子

cpp 复制代码
// 在 main() 里声明:
vector<Particle> particles;
particles.reserve(800);   // 预留空间,避免频繁扩容

// 在主循环里:

// 更新并绘制粒子
for (size_t i = 0; i < particles.size(); ++i) {
    Particle& p = particles[i];
    if (!p.active) continue;

    p.vy += 0.08;      // 重力
    p.x += p.vx;       // 水平移动
    p.y += p.vy;       // 垂直移动
    p.life--;          // 生命减一

    // 死了或出界了就标记为不活跃
    if (p.life <= 0 || p.x < 0 || p.x >= winWidth ||
        p.y < 0 || p.y >= winHeight) {
        p.active = false;
        continue;
    }

    // 画到画布上
    int bx = (int)(p.x + 0.5);   // 四舍五入
    int by = (int)(p.y + 0.5);
    if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
        int idx = by * winWidth + bx;
        screenBuf[idx].Char.AsciiChar = p.ch;
        screenBuf[idx].Attributes = p.color;
    }
}

// 清理死亡粒子
vector<Particle> alive;
for (size_t i = 0; i < particles.size(); ++i)
    if (particles[i].active) alive.push_back(particles[i]);
particles = alive;

💡 背后的原理

重力为什么是 vy += 0.08

重力是加速度,不是速度。意思是------每过一帧,垂直速度增加一点

就像你从楼上扔一个球:

  • 第 1 帧:速度 0.08,下落 0.08
  • 第 2 帧:速度 0.16,下落 0.16
  • 第 3 帧:速度 0.24,下落 0.24
  • ...

越来越快,这就是自由落体的感觉。

如果写成 y += 0.08,那就是匀速下落,不像真实的物理了。

粒子清理的"搬运法"

清理死亡粒子用的是"搬运法"------

新建一个空列表 alive,把所有还活着的粒子搬到新列表里,然后用新列表替换旧列表。

就像整理抽屉------把有用的东西拿出来放进新抽屉,旧抽屉直接扔掉。简单粗暴但有效。

⚠️ 注意事项

  1. reserve(800) 很重要:预留空间可以避免 vector 频繁扩容搬家,提升性能
  2. Particle& p 用引用 :加 & 是引用,直接修改原粒子;不加就是拷贝,改了没用
  3. 四舍五入(int)(p.x + 0.5) 比直接 (int)p.x 更准确,因为浮点数转整数会截断小数部分

⌨️ 键盘位置映射

🎯 我们要做什么

现在烟花有了,但怎么让"按哪个键,就在键盘对应的位置炸烟花"呢?

这一节我们实现:

  1. 建立一个"按键 → 坐标"的映射表
  2. 按字符键时,查表找到位置,发射烟花
  3. 按方向键时,特殊处理(四个方向对应屏幕四边)

💻 键盘位置映射表

cpp 复制代码
#include <map>
using namespace std;

map<int, pair<double, double>> keyPositionMap;

void InitKeyPositionMap() {
    // 第一行:数字行 `1234567890-=
    string digitRow = "`1234567890-=";
    for (size_t i = 0; i < digitRow.size(); ++i) {
        keyPositionMap[(unsigned char)digitRow[i]] =
            { (double)i / (digitRow.size() - 1), 0.0 };
        // Shift 后的字符也映射到同一个位置
        string shifted = "~!@#$%^&*()_+";
        if (i < shifted.size())
            keyPositionMap[(unsigned char)shifted[i]] =
                { (double)i / (digitRow.size() - 1), 0.0 };
    }

    // 第二行:qwertyuiop[]\
    string row1 = "qwertyuiop[]\\";
    for (size_t i = 0; i < row1.size(); ++i) {
        char c = row1[i];
        keyPositionMap[(unsigned char)c] =
            { (double)i / (row1.size() - 1), 0.25 };
        keyPositionMap[(unsigned char)toupper(c)] =
            { (double)i / (row1.size() - 1), 0.25 };
        // 特殊字符的 Shift 版
        if (c == '[') keyPositionMap['{'] = { (double)i / (row1.size() - 1), 0.25 };
        if (c == ']') keyPositionMap['}'] = { (double)i / (row1.size() - 1), 0.25 };
        if (c == '\\') keyPositionMap['|'] = { (double)i / (row1.size() - 1), 0.25 };
    }

    // 第三行:asdfghjkl;'
    string row2 = "asdfghjkl;'";
    for (size_t i = 0; i < row2.size(); ++i) {
        char c = row2[i];
        keyPositionMap[(unsigned char)c] =
            { (double)i / (row2.size() - 1), 0.5 };
        keyPositionMap[(unsigned char)toupper(c)] =
            { (double)i / (row2.size() - 1), 0.5 };
        if (c == ';') keyPositionMap[':'] = { (double)i / (row2.size() - 1), 0.5 };
        if (c == '\'') keyPositionMap['"'] = { (double)i / (row2.size() - 1), 0.5 };
    }

    // 第四行:zxcvbnm,./
    string row3 = "zxcvbnm,./";
    for (size_t i = 0; i < row3.size(); ++i) {
        char c = row3[i];
        keyPositionMap[(unsigned char)c] =
            { (double)i / (row3.size() - 1), 0.75 };
        keyPositionMap[(unsigned char)toupper(c)] =
            { (double)i / (row3.size() - 1), 0.75 };
        if (c == ',') keyPositionMap['<'] = { (double)i / (row3.size() - 1), 0.75 };
        if (c == '.') keyPositionMap['>'] = { (double)i / (row3.size() - 1), 0.75 };
        if (c == '/') keyPositionMap['?'] = { (double)i / (row3.size() - 1), 0.75 };
    }

    // 空格在最下面中间
    keyPositionMap[' '] = { 0.5, 1.0 };
}

📝 逐行看懂这段代码

map<int, pair<double, double>>

  • map 就像一本字典------给它一个键(key),它告诉你对应的值(value)
  • 这里键是按键的 ASCII 码(int),值是一个 pair,存 x 和 y 的比例坐标
  • pair<double, double> 就是两个 double 绑在一起,.first 取第一个,.second 取第二个

比例坐标

  • 存的不是具体像素,而是 0.0~1.0 的比例
  • x = 0.0 是最左边,x = 1.0 是最右边
  • y = 0.0 是最上边,y = 1.0 是最下边
  • 为什么用比例?因为窗口尺寸会变,用比例的话不管窗口多大都能对应到正确位置

每一行的映射

  • 数字行在 y = 0.0(顶部)
  • qwerty 行在 y = 0.25
  • asdf 行在 y = 0.5
  • zxcv 行在 y = 0.75
  • 空格键在 y = 1.0(底部中间)

Shift 字符也映射

  • 1 和按 ! 是同一个物理键,所以映射到同一个位置
  • 大写字母和小写字母也映射到同一个位置

💻 查表发射烟花

cpp 复制代码
bool GetFireworkPosition(int ch, int winWidth, int winHeight,
                         double& cx, double& cy) {
    map<int, pair<double, double>>::const_iterator it = keyPositionMap.find(ch);
    if (it != keyPositionMap.end()) {
        double baseX = it->second.first * winWidth;
        double baseY = it->second.second * winHeight;
        // 加一点随机偏移,每次不完全一样
        double offsetX = (rand() % 101 - 50) * 0.001 * winWidth;
        double offsetY = (rand() % 101 - 50) * 0.001 * winHeight;
        cx = max(0.0, min((double)winWidth - 1, baseX + offsetX));
        cy = max(0.0, min((double)winHeight - 1, baseY + offsetY));
        return true;
    }
    return false;
}

💡 背后的原理

map 的查找机制

map 内部是用红黑树实现的,查找速度很快------哪怕有 100 个键值对,找一个也只需要比较七八次。

find(ch) 返回一个迭代器:

  • 如果找到了,迭代器指向那个键值对,it != map.end()
  • 如果没找到,迭代器等于 map.end()(一个特殊的"末尾"标记)

这就像查字典------翻到对应页码就读,翻不到就是没这个词。

随机偏移的作用

如果每次按同一个键都在完全相同的位置炸烟花,看起来会很机械。

加一点随机偏移(±5% 的范围),每次位置略有不同,但整体还是在按键对应的区域------就像真实的烟花,每次炸开的位置都差不多但不完全一样。

💻 主循环中的按键处理

cpp 复制代码
if (_kbhit()) {
    int ch = _getch();
    if (ch == 27) break;   // ESC 退出

    double cx = -1, cy = -1;

    // 方向键(224 或 0 是扩展键前缀)
    if (ch == 224 || ch == 0) {
        if (_kbhit()) {
            int code = _getch();
            switch (code) {
                case 72: cx = winWidth * 0.5; cy = winHeight * 0.1; break; // 上
                case 80: cx = winWidth * 0.5; cy = winHeight * 0.9; break; // 下
                case 75: cx = winWidth * 0.1; cy = winHeight * 0.5; break; // 左
                case 77: cx = winWidth * 0.9; cy = winHeight * 0.5; break; // 右
            }
            if (cx > 0) LaunchFirework(particles, cx, cy, (code % 15) + 1);
        }
    }
    // 普通字符键
    else {
        if (GetFireworkPosition(ch, winWidth, winHeight, cx, cy)) {
            int keyColor = (ch % 15) + 1;
            LaunchFirework(particles, cx, cy, keyColor);
        }
    }
}

💡 背后的原理

方向键为什么要读两次

方向键是"扩展键",按下时会发送两个字节:

  • 第一个字节是 224 或 0(前缀标记)
  • 第二个字节才是真正的方向键码(72=上,80=下,75=左,77=右)

所以第一次 _getch() 拿到 224 时,要再读一次才能知道是哪个方向键。

这就像收快递------先收到一个"大件货物"的通知单,然后再去拿真正的包裹。

⚠️ 注意事项

  1. (unsigned char) 强制转换 :有些字符的 ASCII 码可能超过 127,转成 unsigned char 能避免负数问题
  2. toupper() 要包含 <cctype> :不过在很多编译器里 <iostream> 已经间接包含了,但最好显式包含
  3. 方向键的颜色用 code % 15:不同方向键有不同颜色,方便区分

💎 闪烁按键方块

🎯 我们要做什么

按了键,除了炸烟花,还要在按键位置弹出一个闪烁的小方块,方块里显示你按的是什么键。这样按每个键都有明确的反馈。

这一节我们实现:

  1. 定义按键显示的结构体
  2. 画一个 5×3 的字符方块
  3. 让方块颜色快速闪烁
  4. 方块持续一段时间后消失
  5. 边界处退化为单个字符

💻 按键显示结构体

cpp 复制代码
struct KeyPressDisplay {
    char ch;         // 显示的字符
    double x, y;     // 中心坐标
    int framesLeft;  // 还剩多少帧
};
vector<KeyPressDisplay> keyDisplays;

const int KEY_DISPLAY_DURATION = 30;  // 持续约1秒
const int BOX_WIDTH  = 5;             // 方块宽度
const int BOX_HEIGHT = 3;             // 方块高度

按下键的时候,往列表里加一个:

cpp 复制代码
// 在按键处理中,发射烟花的同时加一个按键显示
keyDisplays.push_back({displayChar, cx, cy, KEY_DISPLAY_DURATION});

💻 绘制按键方块

cpp 复制代码
// 在主循环里,粒子绘制之后:

for (vector<KeyPressDisplay>::iterator it = keyDisplays.begin();
     it != keyDisplays.end(); ) {

    int cx = (int)(it->x + 0.5);
    int cy = (int)(it->y + 0.5);
    int startX = cx - BOX_WIDTH / 2;
    int startY = cy - BOX_HEIGHT / 2;

    // 判断能否完整绘制方块
    bool canDrawBox = (startX >= 0 && startX + BOX_WIDTH <= winWidth &&
                       startY >= 0 && startY + BOX_HEIGHT <= winHeight);

    if (canDrawBox) {
        // 颜色快速变化:每2帧切换一次,范围10~15(亮色)
        int flashColor = (frameCount / 2) % 6 + 10;

        // 顶部边框:+---+
        for (int i = 0; i < BOX_WIDTH; ++i) {
            int idx = startY * winWidth + startX + i;
            screenBuf[idx].Char.AsciiChar = (i == 0 || i == BOX_WIDTH - 1) ? '+' : '-';
            screenBuf[idx].Attributes = flashColor;
        }

        // 中间行:| X |
        int midY = startY + 1;
        for (int i = 0; i < BOX_WIDTH; ++i) {
            int idx = midY * winWidth + startX + i;
            if (i == 0 || i == BOX_WIDTH - 1) {
                screenBuf[idx].Char.AsciiChar = '|';
                screenBuf[idx].Attributes = flashColor;
            } else if (i == BOX_WIDTH / 2) {
                screenBuf[idx].Char.AsciiChar = it->ch;
                screenBuf[idx].Attributes = flashColor;
            } else {
                screenBuf[idx].Char.AsciiChar = ' ';
                screenBuf[idx].Attributes = flashColor;
            }
        }

        // 底部边框:+---+
        int bottomY = startY + 2;
        for (int i = 0; i < BOX_WIDTH; ++i) {
            int idx = bottomY * winWidth + startX + i;
            screenBuf[idx].Char.AsciiChar = (i == 0 || i == BOX_WIDTH - 1) ? '+' : '-';
            screenBuf[idx].Attributes = flashColor;
        }
    } else {
        // 无法完整绘制时,退化为单个字符显示
        if (cx >= 0 && cx < winWidth && cy >= 0 && cy < winHeight) {
            int idx = cy * winWidth + cx;
            screenBuf[idx].Char.AsciiChar = it->ch;
            screenBuf[idx].Attributes = 0x0F;
        }
    }

    // 生命周期减一,到期了就删除
    if (--it->framesLeft <= 0)
        it = keyDisplays.erase(it);
    else
        ++it;
}

🐧 Linux / macOS 用户看这里

ncurses 中绘制方块的逻辑一样,只是用 mvaddch 逐格画:

cpp 复制代码
int flashColor = (frameCount / 2) % 6 + 10;
attron(COLOR_PAIR(flashColor));
// 顶部边框
mvaddch(startY, startX, '+');
mvaddch(startY, startX + 1, '-');
mvaddch(startY, startX + 2, '-');
mvaddch(startY, startX + 3, '-');
mvaddch(startY, startX + 4, '+');
// 中间行
mvaddch(startY+1, startX, '|');
mvaddch(startY+1, startX + 2, it->ch);
mvaddch(startY+1, startX + 4, '|');
// 底部边框
mvaddch(startY+2, startX, '+');
// ...
attroff(COLOR_PAIR(flashColor));

📝 逐行看懂这段代码

frameCount / 2 的作用

  • frameCount 是从程序开始到现在经过的帧数
  • 除以 2 之后,每 2 帧才加 1
  • % 6 让颜色在 0~5 之间循环
  • 加 10 让颜色范围变成 10~15(都是亮色)

所以效果是:颜色每 2 帧变一次,在 6 种亮色之间循环切换------这就是"闪烁"的感觉。

迭代器删除的写法

cpp 复制代码
if (--it->framesLeft <= 0)
    it = keyDisplays.erase(it);
else
    ++it;

这是 C++ 中遍历 vector 并删除元素的标准写法。

  • erase(it) 会删除迭代器指向的元素,然后返回指向下一个元素的迭代器
  • 如果不删除,就手动 ++it 往前走
  • 为什么不能用普通 for 循环?因为删除元素后索引会变,容易漏掉或越界

💡 背后的原理

为什么方块是 5×3
  • 宽度 5:+---+ 刚好放下中间一个字符(第3格),两边各有一个空格
  • 高度 3:上边框一行、中间字符一行、下边框一行

这个尺寸刚刚好------不大不小,既能看清字符,又不会太占地方。

边界退化设计

如果按键在屏幕边缘(比如按最左边的 ` 键),方块可能会超出屏幕左边。

这时候怎么办?硬画的话会导致越界访问。

所以代码里先判断 canDrawBox------能完整画就画完整方块,不能就退化为只画一个字符。

这是一种"优雅降级"的设计思想------条件好的时候给最好的效果,条件不够的时候也能正常工作,不会崩溃。

就像手机拍照------光线好的时候用高清模式,光线暗的时候自动降为夜景模式,总能拍出照片。

⚠️ 注意事项

  1. frameCount 要在主循环里声明并递增 :别忘了在 while 外面加 int frameCount = 0;,循环里 frameCount++
  2. 迭代器删除要小心 :一定要用 it = erase(it) 的写法,否则可能崩溃
  3. 方块会盖住粒子:因为方块是在粒子之后绘制的,所以方块会叠在粒子上面。这是合理的------按键反馈应该最醒目

🌟 自动背景烟花

🎯 我们要做什么

只有按键的时候才有烟花,屏幕会有点冷清。我们加一个"自动烟花秀"------即使不按键,背景也时不时有烟花随机绽放。

这一节我们实现:

  1. 随机延迟发射
  2. 粒子数量上限控制(防止太卡)
  3. 位置随机(高度偏上半屏,更像真烟花)

💻 自动烟花代码

cpp 复制代码
// 在 main() 里声明:
int frameCount = 0;
int launchDelay = rand() % 30 + 20;   // 初始延迟 20~49 帧

// 在主循环里,粒子清理之后:

frameCount++;
if (frameCount >= launchDelay && particles.size() < 600) {
    double cx = rand() % winWidth;
    double cy = rand() % (winHeight / 2) + winHeight / 4;
    LaunchFirework(particles, cx, cy);
    frameCount = 0;
    launchDelay = rand() % 25 + 10;   // 下次延迟 10~34 帧
}

📝 逐行看懂这段代码

frameCount >= launchDelay

  • frameCount 记录从上次发射到现在过了多少帧
  • 到了 launchDelay 就发射一发
  • 发射后 frameCount 归零,重新计算下次的延迟

particles.size() < 600

  • 这是一个保护机制------粒子太多会卡
  • 如果已经有 600 个粒子了,就先不发新的了,等粒子少一些再说
  • 就像公交车满员了就不上客了,等下一班

位置的计算

  • x 方向:完全随机,整个宽度范围
  • y 方向:winHeight/4winHeight*3/4,也就是中间半区
  • 为什么偏上?因为真实的烟花是在空中炸开的,然后往下落。偏上一点,粒子下落的过程更有观赏性

💡 背后的原理

随机间隔比固定间隔更自然

如果每隔固定的 30 帧发一发,观众很快就能摸到规律,感觉很机械。

用随机间隔(10~34 帧),有时快有时慢,就像真的烟花秀------有密有疏,节奏感更好。

这也是游戏设计里的常用技巧:随机数让体验更有生命力。

上限控制的重要性

600 这个数字是怎么来的?其实是经验值------

  • 太少了画面冷清
  • 太多了 CPU 跟不上,会掉帧

600 个粒子在大多数电脑上都能流畅运行(30 FPS 以上),所以选了这个值。

实际项目中,你可以根据目标平台的性能来调整这个上限。


📝 三层居中文字

🎯 我们要做什么

光有烟花还不够,我们在中间加三层文字:

  1. 大标题:"Welcome to C++ Programming"(带边框的 ASCII 艺术字)
  2. 实时日期:显示今天的年月日
  3. 轮换诗句:8 句编程相关的诗句,每过一会儿换一句

这一节我们实现:

  1. 初始化标题和诗句
  2. 居中计算公式
  3. 时间获取和格式化
  4. 诗句轮换逻辑

💻 初始化文字数据

cpp 复制代码
#include <string>
#include <cstdio>
#include <ctime>

void initTitle(vector<string>& title) {
    title.push_back("+--------------------------------------+");
    title.push_back("|                                      |");
    title.push_back("|       Welcome to C++ Programming     |");
    title.push_back("|            Let's Start !             |");
    title.push_back("|                                      |");
    title.push_back("+--------------------------------------+");
}

void initPoems(vector<string>& poems) {
    poems.push_back("代码如诗,逻辑生花。");
    poems.push_back("每一行代码,都是思考的脚印。");
    poems.push_back("Bug 是成长的阶梯,不是绊脚石。");
    poems.push_back("编程是今日的魔法,你是魔法师。");
    poems.push_back("屏幕微光,照亮思维的方向。");
    poems.push_back("耐心是调试的最好伙伴。");
    poems.push_back("键盘敲击声,是梦想的节拍。");
    poems.push_back("错误并不可怕,放弃才是。");
}

💻 居中绘制文字

cpp 复制代码
// 在 main() 里声明:
vector<string> bigTitle;
initTitle(bigTitle);
vector<string> poems;
initPoems(poems);
int poemIdx = 0;
int poemChangeFrames = 0;
const int POEM_DURATION = 100;   // 每句诗显示 100 帧

// 在主循环里,自动烟花之后:

// 获取当前时间
time_t now = time(NULL);
struct tm* t = localtime(&now);
char dateStr[50];
sprintf(dateStr, "%04d年%02d月%02d日",
    t->tm_year + 1900, t->tm_mon + 1, t->tm_mday);

// 诗句轮换计时
poemChangeFrames++;
if (poemChangeFrames >= POEM_DURATION) {
    poemIdx = rand() % poems.size();
    poemChangeFrames = 0;
}

// 绘制大标题(在屏幕 1/5 高度处)
int titleY = winHeight / 5;
for (int i = 0; i < (int)bigTitle.size(); ++i) {
    int rowY = titleY + i;
    if (rowY < 0 || rowY >= winHeight) continue;
    string& line = bigTitle[i];
    int startX = (winWidth - (int)line.length()) / 2;   // 居中公式
    for (size_t j = 0; j < line.length(); ++j) {
        int x = startX + j;
        if (x >= 0 && x < winWidth) {
            int idx = rowY * winWidth + x;
            screenBuf[idx].Char.AsciiChar = line[j];
            screenBuf[idx].Attributes = 11;   // 青色
        }
    }
}

// 绘制日期
string dateLine = dateStr;
int dateY = titleY + (int)bigTitle.size() + 1;
if (dateY >= 0 && dateY < winHeight) {
    int startX = (winWidth - (int)dateLine.length()) / 2;
    for (size_t j = 0; j < dateLine.length(); ++j) {
        int x = startX + j;
        if (x >= 0 && x < winWidth) {
            int idx = dateY * winWidth + x;
            screenBuf[idx].Char.AsciiChar = dateLine[j];
            screenBuf[idx].Attributes = 14;   // 黄色
        }
    }
}

// 绘制诗句
string poemLine = poems[poemIdx];
int poemY = dateY + 2;
if (poemY >= 0 && poemY < winHeight) {
    int startX = (winWidth - (int)poemLine.length()) / 2;
    for (size_t j = 0; j < poemLine.length(); ++j) {
        int x = startX + j;
        if (x >= 0 && x < winWidth) {
            int idx = poemY * winWidth + x;
            screenBuf[idx].Char.AsciiChar = poemLine[j];
            screenBuf[idx].Attributes = 10;   // 绿色
        }
    }
}

🐧 Linux / macOS 用户看这里

ncurses 中时间获取和格式化是一样的(都是标准 C 库),绘制文字用 mvprintw 更方便:

cpp 复制代码
attron(COLOR_PAIR(3));  // 青色
mvprintw(titleY + i, startX, "%s", line.c_str());
attroff(COLOR_PAIR(3));

对照关系:逐字符写入 screenBuf → 直接用 mvprintw 输出字符串;颜色属性 11 → 颜色对 3(青色)

📝 逐行看懂这段代码

居中公式:(winWidth - textLength) / 2

  • 总宽度减去文字长度,剩下的空间两边平分
  • 左边一半就是文字的起始 x 坐标
  • 就像贴对联------先量好墙的宽度,减去对联的宽度,除以 2 就是左边距

time_tstruct tm

  • time(NULL) 获取当前时间(从 1970 年 1 月 1 日到现在的秒数)
  • localtime() 把秒数转成年、月、日、时、分、秒的结构体
  • tm_year 是从 1900 年开始算的,所以要加 1900
  • tm_mon 是 0~11 的,所以要加 1

sprintf 格式化字符串

  • 把数字按格式拼成字符串
  • %04d 表示 4 位数字,不够前面补 0(比如 2026、08、27)
  • 结果存在 dateStr 字符数组里

💡 背后的原理

为什么用三层文字

标题 + 日期 + 诗句,三层文字从上到下排列,形成一个"信息区":

层级 作用 颜色
大标题 吸引眼球,告诉用户这是什么 青色(醒目)
日期 实时信息,增加真实感 黄色(温暖)
诗句 文艺气息,增加趣味性 绿色(清新)

颜色从冷到暖,视觉上有层次感。内容从宏观到微观,信息上有层次。

诗句为什么随机换

如果只显示一句诗,看久了就腻了。

每 100 帧(约 3 秒)随机换一句,8 句诗轮换------

  • 用户不会马上看全所有诗句(随机的)
  • 每次看可能都不一样,有新鲜感
  • 编程主题的诗句也能增加学习的氛围感

⚠️ 注意事项

  1. char dateStr[50] 要足够大:装下"2026年08月27日"只需要 17 个字符,但留 50 个更安全
  2. 中文显示依赖控制台编码:Windows 控制台默认是 GBK 编码,源文件也要保存为 GBK 才能正确显示中文。如果是 UTF-8 编码可能会乱码
  3. (int)line.length() 强制转换length() 返回的是 size_t(无符号整数),和 int 运算可能有警告,转成 int 更安全

🔄 窗口尺寸自适应

🎯 我们要做什么

之前的代码有个问题------如果你拉大或缩小控制台窗口,画面可能会错位甚至崩溃。

这一节我们实现:

  1. 每帧检测窗口尺寸
  2. 如果尺寸变了,重新分配画布
  3. 确保新窗口里一切正常显示

💻 窗口尺寸检测

cpp 复制代码
// 在主循环最前面:

CONSOLE_SCREEN_BUFFER_INFO csbiNow;
GetConsoleScreenBufferInfo(hOut, &csbiNow);
int left = csbiNow.srWindow.Left;
int top  = csbiNow.srWindow.Top;
int w    = csbiNow.srWindow.Right - left + 1;
int h    = csbiNow.srWindow.Bottom - top + 1;

if (w != winWidth || h != winHeight || left != winLeft || top != winTop) {
    winLeft   = left;
    winTop    = top;
    winWidth  = w;
    winHeight = h;
    screenBuf.assign(winWidth * winHeight, CHAR_INFO());
}

📝 逐行看懂这段代码

每帧都检测

  • 每帧开始时调用 GetConsoleScreenBufferInfo 拿最新的窗口信息
  • 和上一帧保存的 winWidthwinHeight 比较
  • 如果不一样,说明窗口大小变了

screenBuf.assign()

  • 重新分配画布的大小
  • 新的画布全部填充默认的 CHAR_INFO()(空格 + 灰色)
  • 就像换了一张更大的画纸,重新开始画

💡 背后的原理

为什么要每帧检测

窗口尺寸变化是用户随时可能做的操作------你不知道他什么时候会拉大或缩小窗口。

每帧检测虽然有一点点开销,但 GetConsoleScreenBufferInfo 很快,而且大部分时候尺寸没变,只是比较一下就完事了。

这是一种"防御性编程"的思路------宁可多做一点点检查,也要保证程序在各种情况下都能正常工作。

assign vs resize
  • assign(n, value):把容器变成 n 个元素,每个都是 value(完全重置)
  • resize(n):只改变大小,新增的元素用默认值,旧元素保留

这里用 assign 是因为我们需要一张干净的新画布,旧的内容不要了。

⚠️ 注意事项

  1. 窗口位置也要检测 :不只是宽高,winLeftwinTop(窗口左上角坐标)也要检查。因为用户可能拖动窗口位置,这时候写入区域也要跟着变
  2. WriteConsoleOutputwriteRegion 要用新的坐标:输出到控制台的时候,写入区域的坐标要和当前窗口位置对应
  3. 粒子还在飞:窗口拉大了,粒子还是在原来的位置飞,这是正常的------只是可视范围变大了

🧩 拼合步骤:从零件到成品

前面七节你已经做了七块积木。现在我们把它们一步步拼起来,看看每一步加了什么。

第 1 步:双缓冲框架

能做什么:一张会刷新的空白画布

核心代码

  • HideCursor() --- 隐藏光标
  • screenBuf --- 双缓冲画布
  • while 主循环 --- 清屏→输出→休眠

完整程序(约 70 行)

cpp 复制代码
#include <iostream>
#include <windows.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <conio.h>
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;
    vector<CHAR_INFO> screenBuf(winWidth * winHeight);

    while (true) {
        if (_kbhit()) {
            int ch = _getch();
            if (ch == 27) break;
        }

        for (int i = 0; i < winWidth * winHeight; ++i) {
            screenBuf[i].Char.AsciiChar = ' ';
            screenBuf[i].Attributes = 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);
    }
    return 0;
}

第 2 步:加粒子烟花

和上一步比,新增了什么

新增内容 说明
struct Particle 粒子结构体(位置、速度、生命、颜色、字符、活跃标记)
LaunchFirework() 发射烟花函数(40~109 个粒子全向飞散)
粒子更新与绘制 重力 + 生命周期 + 双缓冲绘制
死亡粒子清理 搬运法筛选活跃粒子

程序规模:约 160 行

第 3 步:加键盘映射 + 按键发射

和上一步比,新增了什么

新增内容 说明
map<int, pair<double, double>> 键盘位置映射表(比例坐标)
InitKeyPositionMap() 初始化四行按键 + 空格的映射
GetFireworkPosition() 查表获取烟花位置(带随机偏移)
按键处理逻辑 字符键查表、方向键特殊处理

程序规模:约 270 行

第 4 步:加闪烁按键方块

和上一步比,新增了什么

新增内容 说明
struct KeyPressDisplay 按键显示结构体(字符、坐标、剩余帧数)
5×3 方块绘制 边框 + 中间字符 + 闪烁颜色
边界退化 边缘处退化为单个字符
迭代器生命周期管理 帧递减 + erase 删除

程序规模:约 330 行

第 5 步:加自动烟花 + 居中文字 + 窗口自适应

和上一步比,新增了什么

新增内容 说明
自动随机烟花 随机间隔发射 + 粒子数上限
三层居中文字 大标题 + 实时日期 + 轮换诗句
窗口尺寸自适应 每帧检测 + 画布重新分配
frameCount 计数 供闪烁和自动烟花使用

程序规模:约 399 行(完整版)


🏆 完整版出炉

完整代码(399 行)

cpp 复制代码
#include <iostream>
#include <windows.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <cstdio>
#include <conio.h>
#include <map>
#include <string>
using namespace std;

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

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;
    int color;
    char ch;
    bool active;
};

struct KeyPressDisplay {
    char ch;
    double x, y;
    int framesLeft;
};
vector<KeyPressDisplay> keyDisplays;

map<int, pair<double, double>> keyPositionMap;

void InitKeyPositionMap() {
    string digitRow = "`1234567890-=";
    for (size_t i = 0; i < digitRow.size(); ++i) {
        keyPositionMap[(unsigned char)digitRow[i]] = { (double)i / (digitRow.size() - 1), 0.0 };
        string shifted = "~!@#$%^&*()_+";
        if (i < shifted.size())
            keyPositionMap[(unsigned char)shifted[i]] = { (double)i / (digitRow.size() - 1), 0.0 };
    }
    string row1 = "qwertyuiop[]\\";
    for (size_t i = 0; i < row1.size(); ++i) {
        char c = row1[i];
        keyPositionMap[(unsigned char)c] = { (double)i / (row1.size() - 1), 0.25 };
        keyPositionMap[(unsigned char)toupper(c)] = { (double)i / (row1.size() - 1), 0.25 };
        if (c == '[') keyPositionMap['{'] = { (double)i / (row1.size() - 1), 0.25 };
        if (c == ']') keyPositionMap['}'] = { (double)i / (row1.size() - 1), 0.25 };
        if (c == '\\') keyPositionMap['|'] = { (double)i / (row1.size() - 1), 0.25 };
    }
    string row2 = "asdfghjkl;'";
    for (size_t i = 0; i < row2.size(); ++i) {
        char c = row2[i];
        keyPositionMap[(unsigned char)c] = { (double)i / (row2.size() - 1), 0.5 };
        keyPositionMap[(unsigned char)toupper(c)] = { (double)i / (row2.size() - 1), 0.5 };
        if (c == ';') keyPositionMap[':'] = { (double)i / (row2.size() - 1), 0.5 };
        if (c == '\'') keyPositionMap['"'] = { (double)i / (row2.size() - 1), 0.5 };
    }
    string row3 = "zxcvbnm,./";
    for (size_t i = 0; i < row3.size(); ++i) {
        char c = row3[i];
        keyPositionMap[(unsigned char)c] = { (double)i / (row3.size() - 1), 0.75 };
        keyPositionMap[(unsigned char)toupper(c)] = { (double)i / (row3.size() - 1), 0.75 };
        if (c == ',') keyPositionMap['<'] = { (double)i / (row3.size() - 1), 0.75 };
        if (c == '.') keyPositionMap['>'] = { (double)i / (row3.size() - 1), 0.75 };
        if (c == '/') keyPositionMap['?'] = { (double)i / (row3.size() - 1), 0.75 };
    }
    keyPositionMap[' '] = { 0.5, 1.0 };
}

void LaunchFirework(vector<Particle>& particles, double cx, double cy, int baseColor = -1) {
    if (baseColor == -1) baseColor = rand() % 14 + 1;
    char sparkChars[] = {'*', '+', '.', 'o', 'O', '@', 'x', '~'};
    int sparkCount = sizeof(sparkChars) / sizeof(sparkChars[0]);
    int sparks = rand() % 70 + 40;
    for (int i = 0; i < sparks; ++i) {
        Particle p;
        p.x = cx; p.y = cy;
        double angle = (rand() % 360) * M_PI / 180.0;
        double speed = (rand() % 150) / 100.0 + 0.5;
        p.vx = cos(angle) * speed;
        p.vy = sin(angle) * speed - 0.5;
        p.life = rand() % 40 + 20;
        p.color = baseColor;
        p.ch = sparkChars[rand() % sparkCount];
        p.active = true;
        particles.push_back(p);
    }
}

bool GetFireworkPosition(int ch, int winWidth, int winHeight, double& cx, double& cy) {
    map<int, pair<double, double>>::const_iterator it = keyPositionMap.find(ch);
    if (it != keyPositionMap.end()) {
        double baseX = it->second.first * winWidth;
        double baseY = it->second.second * winHeight;
        double offsetX = (rand() % 101 - 50) * 0.001 * winWidth;
        double offsetY = (rand() % 101 - 50) * 0.001 * winHeight;
        cx = max(0.0, min((double)winWidth - 1, baseX + offsetX));
        cy = max(0.0, min((double)winHeight - 1, baseY + offsetY));
        return true;
    }
    return false;
}

void initTitle(vector<string>& title) {
    title.push_back("+--------------------------------------+");
    title.push_back("|                                      |");
    title.push_back("|       Welcome to C++ Programming     |");
    title.push_back("|            Let's Start !             |");
    title.push_back("|                                      |");
    title.push_back("+--------------------------------------+");
}

void initPoems(vector<string>& poems) {
    poems.push_back("代码如诗,逻辑生花。");
    poems.push_back("每一行代码,都是思考的脚印。");
    poems.push_back("Bug 是成长的阶梯,不是绊脚石。");
    poems.push_back("编程是今日的魔法,你是魔法师。");
    poems.push_back("屏幕微光,照亮思维的方向。");
    poems.push_back("耐心是调试的最好伙伴。");
    poems.push_back("键盘敲击声,是梦想的节拍。");
    poems.push_back("错误并不可怕,放弃才是。");
}

int main() {
    HideCursor();
    srand((unsigned)time(NULL));
    InitKeyPositionMap();

    vector<string> bigTitle;
    initTitle(bigTitle);
    vector<string> poems;
    initPoems(poems);

    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;

    vector<CHAR_INFO> screenBuf(winWidth * winHeight);
    vector<Particle> particles;
    particles.reserve(800);

    int frameCount = 0;
    int launchDelay = rand() % 30 + 20;
    int poemIdx = 0;
    int poemChangeFrames = 0;
    const int POEM_DURATION = 100;
    const int KEY_DISPLAY_DURATION = 30;

    const int BOX_WIDTH  = 5;
    const int BOX_HEIGHT = 3;

    while (true) {
        // ========== 按键处理 ==========
        if (_kbhit()) {
            int ch = _getch();
            if (ch == 27) break;

            double cx = -1, cy = -1;
            char displayChar = 0;
            int keyColor = 0;

            if (ch == 224 || ch == 0) {
                if (_kbhit()) {
                    int code = _getch();
                    switch (code) {
                        case 72: cx = winWidth * 0.5;  cy = winHeight * 0.1; displayChar = '^'; break;
                        case 80: cx = winWidth * 0.5;  cy = winHeight * 0.9; displayChar = 'v'; break;
                        case 75: cx = winWidth * 0.1;  cy = winHeight * 0.5; displayChar = '<'; break;
                        case 77: cx = winWidth * 0.9;  cy = winHeight * 0.5; displayChar = '>'; break;
                    }
                    if (displayChar) {
                        keyColor = (code % 15) + 1;
                        LaunchFirework(particles, cx, cy, keyColor);
                        keyDisplays.push_back({displayChar, cx, cy, KEY_DISPLAY_DURATION});
                    }
                }
            }
            else {
                if (GetFireworkPosition(ch, winWidth, winHeight, cx, cy)) {
                    keyColor = (ch % 15) + 1;
                    LaunchFirework(particles, cx, cy, keyColor);
                    displayChar = isprint(ch) ? (char)ch : '?';
                    keyDisplays.push_back({displayChar, cx, cy, KEY_DISPLAY_DURATION});
                }
            }
        }

        // ========== 窗口尺寸检测 ==========
        CONSOLE_SCREEN_BUFFER_INFO csbiNow;
        GetConsoleScreenBufferInfo(hOut, &csbiNow);
        int left = csbiNow.srWindow.Left;
        int top  = csbiNow.srWindow.Top;
        int w    = csbiNow.srWindow.Right - left + 1;
        int h    = csbiNow.srWindow.Bottom - top + 1;

        if (w != winWidth || h != winHeight || left != winLeft || top != winTop) {
            winLeft   = left;
            winTop    = top;
            winWidth  = w;
            winHeight = h;
            screenBuf.assign(winWidth * winHeight, CHAR_INFO());
        }

        // ========== 清屏 ==========
        for (int i = 0; i < winWidth * winHeight; ++i) {
            screenBuf[i].Char.AsciiChar = ' ';
            screenBuf[i].Attributes = 7;
        }

        // ========== 更新并绘制粒子 ==========
        for (size_t i = 0; i < particles.size(); ++i) {
            Particle& p = particles[i];
            if (!p.active) continue;
            p.vy += 0.08;
            p.x += p.vx;
            p.y += p.vy;
            p.life--;
            if (p.life <= 0 || p.x < 0 || p.x >= winWidth || p.y < 0 || p.y >= winHeight) {
                p.active = false;
                continue;
            }
            int bx = (int)(p.x + 0.5);
            int by = (int)(p.y + 0.5);
            if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
                int idx = by * winWidth + bx;
                screenBuf[idx].Char.AsciiChar = p.ch;
                screenBuf[idx].Attributes = p.color;
            }
        }

        vector<Particle> alive;
        for (size_t i = 0; i < particles.size(); ++i)
            if (particles[i].active) alive.push_back(particles[i]);
        particles = alive;

        // ========== 自动随机烟花 ==========
        frameCount++;
        if (frameCount >= launchDelay && particles.size() < 600) {
            double cx = rand() % winWidth;
            double cy = rand() % (winHeight / 2) + winHeight / 4;
            LaunchFirework(particles, cx, cy);
            frameCount = 0;
            launchDelay = rand() % 25 + 10;
        }

        // ========== 绘制标题、日期、诗句 ==========
        time_t now = time(NULL);
        struct tm* t = localtime(&now);
        char dateStr[50];
        sprintf(dateStr, "%04d年%02d月%02d日", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday);

        poemChangeFrames++;
        if (poemChangeFrames >= POEM_DURATION) {
            poemIdx = rand() % poems.size();
            poemChangeFrames = 0;
        }

        int titleY = winHeight / 5;
        for (int i = 0; i < (int)bigTitle.size(); ++i) {
            int rowY = titleY + i;
            if (rowY < 0 || rowY >= winHeight) continue;
            string& line = bigTitle[i];
            int startX = (winWidth - (int)line.length()) / 2;
            for (size_t j = 0; j < line.length(); ++j) {
                int x = startX + j;
                if (x >= 0 && x < winWidth) {
                    int idx = rowY * winWidth + x;
                    screenBuf[idx].Char.AsciiChar = line[j];
                    screenBuf[idx].Attributes = 11;
                }
            }
        }

        string dateLine = dateStr;
        int dateY = titleY + (int)bigTitle.size() + 1;
        if (dateY >= 0 && dateY < winHeight) {
            int startX = (winWidth - (int)dateLine.length()) / 2;
            for (size_t j = 0; j < dateLine.length(); ++j) {
                int x = startX + j;
                if (x >= 0 && x < winWidth) {
                    int idx = dateY * winWidth + x;
                    screenBuf[idx].Char.AsciiChar = dateLine[j];
                    screenBuf[idx].Attributes = 14;
                }
            }
        }

        string poemLine = poems[poemIdx];
        int poemY = dateY + 2;
        if (poemY >= 0 && poemY < winHeight) {
            int startX = (winWidth - (int)poemLine.length()) / 2;
            for (size_t j = 0; j < poemLine.length(); ++j) {
                int x = startX + j;
                if (x >= 0 && x < winWidth) {
                    int idx = poemY * winWidth + x;
                    screenBuf[idx].Char.AsciiChar = poemLine[j];
                    screenBuf[idx].Attributes = 10;
                }
            }
        }

        // ========== 绘制按键方块 ==========
        for (vector<KeyPressDisplay>::iterator it = keyDisplays.begin();
             it != keyDisplays.end(); ) {
            int cx = (int)(it->x + 0.5);
            int cy = (int)(it->y + 0.5);
            int startX = cx - BOX_WIDTH / 2;
            int startY = cy - BOX_HEIGHT / 2;

            bool canDrawBox = (startX >= 0 && startX + BOX_WIDTH <= winWidth &&
                               startY >= 0 && startY + BOX_HEIGHT <= winHeight);

            if (canDrawBox) {
                int flashColor = (frameCount / 2) % 6 + 10;

                for (int i = 0; i < BOX_WIDTH; ++i) {
                    int idx = startY * winWidth + startX + i;
                    screenBuf[idx].Char.AsciiChar = (i == 0 || i == BOX_WIDTH - 1) ? '+' : '-';
                    screenBuf[idx].Attributes = flashColor;
                }

                int midY = startY + 1;
                for (int i = 0; i < BOX_WIDTH; ++i) {
                    int idx = midY * winWidth + startX + i;
                    if (i == 0 || i == BOX_WIDTH - 1) {
                        screenBuf[idx].Char.AsciiChar = '|';
                        screenBuf[idx].Attributes = flashColor;
                    } else if (i == BOX_WIDTH / 2) {
                        screenBuf[idx].Char.AsciiChar = it->ch;
                        screenBuf[idx].Attributes = flashColor;
                    } else {
                        screenBuf[idx].Char.AsciiChar = ' ';
                        screenBuf[idx].Attributes = flashColor;
                    }
                }

                int bottomY = startY + 2;
                for (int i = 0; i < BOX_WIDTH; ++i) {
                    int idx = bottomY * winWidth + startX + i;
                    screenBuf[idx].Char.AsciiChar = (i == 0 || i == BOX_WIDTH - 1) ? '+' : '-';
                    screenBuf[idx].Attributes = flashColor;
                }
            } else {
                if (cx >= 0 && cx < winWidth && cy >= 0 && cy < winHeight) {
                    int idx = cy * winWidth + cx;
                    screenBuf[idx].Char.AsciiChar = it->ch;
                    screenBuf[idx].Attributes = 0x0F;
                }
            }

            if (--it->framesLeft <= 0)
                it = keyDisplays.erase(it);
            else
                ++it;
        }

        // ========== 写入控制台 ==========
        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);
    }
    return 0;
}

🐧 Linux / macOS 用户看这里

Linux/macOS 完整 ncurses 版本代码较长,核心逻辑和上面完全一样,只是渲染 API 换成了 ncurses。

需要的话可以参考环境配置文章里的 ncurses 基础,把每一节的 ncurses 片段拼起来就是完整程序了。


✨ 架构总结

整体架构:五层渲染

键盘烟花秀的程序可以分为五层,从下往上叠:

层级 内容 绘制顺序 特点
第 1 层 背景(空白) 最先画 清屏,打底色
第 2 层 自动烟花粒子 第二层 背景装饰,不抢戏
第 3 层 居中文字 第三层 标题/日期/诗句
第 4 层 按键发射的烟花 第四层 用户交互的主要效果
第 5 层 按键方块 最后画 最醒目,按键反馈

越靠上的层越晚画,越容易被看到。这就是"画家算法"------先画远处的,再画近处的。

主循环拆解

每帧的执行顺序:

复制代码
按键检测 → 窗口尺寸检测 → 清屏 → 粒子更新+绘制 → 粒子清理
     → 自动烟花 → 三层文字 → 按键方块 → 输出 → 休眠

一共 10 个步骤,环环相扣。

代码量增长表

阶段 代码量 新增技术
第1步:双缓冲框架 ~70 行 窗口检测、双缓冲、主循环
第2步:粒子烟花 ~160 行 粒子系统、重力、生命周期
第3步:键盘映射 ~270 行 map 映射、方向键、随机偏移
第4步:按键方块 ~330 行 闪烁效果、边界退化、迭代器删除
第5步:完整版 399 行 自动烟花、三层文字、窗口自适应

从 70 行到 399 行,每一步都只加一个功能,最后拼出完整的键盘烟花秀。


🛤️ 学习路径

核心思路:从基础到互动展示

键盘烟花秀是一个"展示型"的程序------既有炫酷的视觉效果,又有丰富的交互反馈。它综合了粒子系统、键盘交互、文字排版、UI 反馈等多种技术。

如果你是初学者

如果你是刚接触控制台编程,建议按这个路线学:

  1. 🌱 【擦除重绘】弹跳 Logo 动画 --- 最简单的控制台动画入门
  2. 🌿 【打字游戏】字母雨打字练习 --- 学习游戏循环和双缓冲
  3. 🌳 本篇:键盘烟花秀 --- 把粒子 + 键盘 + 文字排版组合起来

如果你想深入特效

喜欢粒子烟花这类视觉效果,可以挑战:

👉 粒子烟花系列(4 篇,从基础到雪崩)

从简单的粒子爆炸,到键盘控制烟花,再到粒子沉积堆积,最后到雪崩效应。同样是双缓冲 + 粒子系统,但物理模拟更复杂,视觉效果更震撼。

💡 学习建议

  1. 先跑起来再说:把完整代码复制运行,感受效果,再回头看原理
  2. 改参数找感觉:试试改粒子数量、重力大小、方块尺寸,观察变化
  3. 自己加功能:比如加一个"连按模式",按得越快烟花越多

📖 头文件速查

头文件 用到的东西 简介 文档
<iostream> cout, cin 标准输入输出 cppreference
<windows.h> HANDLE, GetStdHandle, WriteConsoleOutput Windows 控制台 API Microsoft Docs
<vector> vector<T>, push_back, erase 动态数组容器 cppreference
<map> map<K,V>, find, insert 关联容器(字典) cppreference
<cmath> cos, sin, fabs, M_PI 数学函数 cppreference
<cstdlib> rand(), srand() 随机数 + 通用工具 cppreference
<ctime> time(), localtime(), struct tm 时间函数 cppreference
<cstdio> sprintf() C 风格格式化输出 cppreference
<conio.h> _kbhit(), _getch() 控制台键盘输入(Windows 专用) 非标准,Windows 特有
<string> string, length(), c_str() 字符串类 cppreference
<ncurses.h> initscr, mvaddch, refresh Linux/macOS 终端库(替代 windows.h) GNU ncurses
<unistd.h> usleep() Unix 休眠函数(替代 Sleep) POSIX 标准

🔜 拓展方向

学完了本篇,你可以试试这些进阶挑战:

🌱 初级(改改参数)

  1. 改方块尺寸:把 5×3 改成 7×5,看看效果
  2. 改闪烁速度 :把 frameCount / 2 改成 frameCount / 4,闪得慢一点
  3. 改诗句内容:把 8 句诗换成你自己喜欢的句子
  4. 改自动烟花频率 :把 launchDelay 的范围调小,烟花更密集

🌿 中级(加加功能)

  1. 加连击计数:连续按键时显示连击数(Combo)
  2. 加得分系统:按键越多得分越高,显示在角落
  3. 加音效 :用 Beep() 在发射烟花时发出声音
  4. 改标题样式:把大标题换成更大的 ASCII 艺术字

🌳 高级(改改架构)

  1. 粒子爆炸冲击波:按键中心的粒子速度更快,形成冲击波效果
  2. 按键颜色主题:不同区域的按键有不同的颜色主题(冷色/暖色)
  3. 文字阴影效果:文字下面加一层深色偏移,模拟阴影
  4. 多种烟花形状:心形、星形、圆形,不同的键触发不同形状

🏆 大师级(完整项目)

  1. 打字练习模式:屏幕上方出字母,用户按对应键消除,算正确率
  2. 烟花记录回放:录制按键序列,然后回放整个烟花秀
  3. 多人模式:两个键盘区域(左手/右手)比赛谁按得快
  4. 音乐可视化:读取音频音量,音量越大烟花越密

(全文完)

系列:C++ 控制台编程实战

上一篇:【二维数组】C++ 实现控制台画板工具

下一篇:更多内容持续更新中

相关推荐
间歇性努力持续性发呆的野生快乐选手16 分钟前
二分模板(整型,浮点型)
数据结构·c++·算法
艾莉丝努力练剑25 分钟前
【AI大模型接入SDK】DeepSeek API 基础概述
c++·人工智能·学习·ai·面试·deepseek
AI 编程助手GPT25 分钟前
Bun 1.4 正式发布:从 Zig 改写为 Rust,内置浏览器、图片处理和并行测试
开发语言·人工智能·后端·ai·chatgpt
知识分享小能手26 分钟前
概理论与数理统计学习教程,从入门到精通,马尔可夫链(25)
学习·机器学习·概率论
paopaokaka_luck31 分钟前
基于springboot3+vue3的飞行器模型实验室预约管理系统(协同过滤算法、Echarts图形化分析)
开发语言·推荐算法
夏炳辉.35 分钟前
零基础Linux PostgreSQL(psql)从安装到集群高可用全套学习大纲
linux·学习·postgresql
lingchen190635 分钟前
用MATLAB r2010b和r2016b求解微分方程组
开发语言·算法·matlab
ZiLing35 分钟前
2026 ROS 2 Lyrical C++ 入门(一):rclcpp、QoS、colcon 与 CMake 踩坑指南
c++
ttwosix41 分钟前
JavaEE初阶 多线程 3
java·开发语言