🎆 【按键反馈】C++ 实现键盘烟花秀
系列:C++ 控制台编程实战
📌 摘要
敲键盘,放烟花------每个按键在对应位置炸开彩色粒子,还弹出一个闪烁的按键方块。背景有自动烟花秀,中间有标题、实时日期和轮换的编程诗句。
通过这个项目,你将学到:
- 🎯 双缓冲渲染技术 ---
screenBuf+WriteConsoleOutput,告别闪烁 - 🎯 键盘位置映射 ---
map+ 比例坐标,按哪个键就在哪炸烟花 - 🎯 闪烁按键方块 --- 5×3 字符方块 + 颜色快速闪烁 + 边界退化
- 🎯 三层居中文字排版 --- 大标题 + 实时日期 + 轮换诗句
- 🎯 窗口尺寸自适应 --- 每帧检测,拉大缩小都不怕
- 🎯 自动烟花背景 --- 随机间隔发射 + 粒子数上限控制
适合人群:已经学过 C++ 基础语法(变量、循环、数组、函数、结构体),想做炫酷小项目的初中生、高中生,以及编程初学者。
代码量 :完整版约 399 行。我们会从几十行的空画布开始,一步步往上加功能,每一步都完整可运行,跟着敲绝对没问题!
⚡ 新手必读 :如果你是第一次接触控制台编程,强烈建议先看本系列**环境配置指南**,把 Dev-C++ 或 Visual Studio 装好,确保能编译运行。后面每篇的代码都是 Windows 控制台专属的,Linux/macOS 同学需要用 ncurses 改写,每章末尾我都会给对照说明。
TypingSparksKeyzoneFlash
📹 完整版运行视频 --- 键盘烟花秀完整效果演示,按键闪烁方块 + 自动烟花 + 三层文字,Windows 控制台演示。
📚 目录
- 编译环境说明
- [🏗️ 搭好画板:双缓冲框架](#🏗️ 搭好画板:双缓冲框架)
- [🎆 基础粒子烟花](#🎆 基础粒子烟花)
- [⌨️ 键盘位置映射](#⌨️ 键盘位置映射)
- [💎 闪烁按键方块](#💎 闪烁按键方块)
- [🌟 自动背景烟花](#🌟 自动背景烟花)
- [📝 三层居中文字](#📝 三层居中文字)
- [🔄 窗口尺寸自适应](#🔄 窗口尺寸自适应)
- [🧩 拼合步骤](#🧩 拼合步骤)
- [🏆 完整版出炉](#🏆 完整版出炉)
- [✨ 架构总结](#✨ 架构总结)
- [🛤️ 学习路径](#🛤️ 学习路径)
- [📖 头文件速查](#📖 头文件速查)
- [🔜 拓展方向](#🔜 拓展方向)
🛠️ 编译环境说明
💡 环境配置请看学前篇
本文假设你已经搭好了 C++ 编译环境。如果你还没配置,先去看
里面有 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 写法对照。
🏗️ 搭好画板:双缓冲框架
🎯 我们要做什么
一切动画的起点,都是先搭好一块"画布"。这一节我们搞定最基础的框架:
- 拿到控制台句柄(画笔)
- 隐藏光标(避免闪烁)
- 检测窗口尺寸
- 建立双缓冲画布(
screenBuf) - 主循环:清屏 → 更新 → 输出 → 休眠
💻 第一步:写出基础框架
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绘制;WriteConsoleOutput→refresh();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 是控制台动画的"甜点区"
⚠️ 注意事项
- 窗口不要拉大拉小:目前这个版本还不能处理窗口尺寸变化,拉大窗口会导致越界访问(后面第7章会加自适应)
CHAR_INFO里的字符是AsciiChar:只支持 ASCII 字符,中文显示不出来(后面会看到日期用的是中文,但那是特殊处理)- 颜色编号 0~15:0 是黑色,7 是默认灰白色,10~15 是亮色
🎆 基础粒子烟花
🎯 我们要做什么
有了画布,接下来加最核心的东西------粒子烟花。这一节我们实现:
- 定义粒子结构体
- 写一个发射烟花的函数
- 让粒子飞起来(重力 + 生命周期)
- 把粒子画到画布上
- 清理死亡的粒子
💻 粒子结构体和发射函数
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 = color→COLOR_PAIR(color);ncurses 只有 8 种基础颜色(编号 0~7)
📝 逐行看懂这段代码
struct Particle
- 粒子有 7 个属性:位置、速度、生命、颜色、字符、是否活跃
x和y用double不用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,把所有还活着的粒子搬到新列表里,然后用新列表替换旧列表。
就像整理抽屉------把有用的东西拿出来放进新抽屉,旧抽屉直接扔掉。简单粗暴但有效。
⚠️ 注意事项
reserve(800)很重要:预留空间可以避免 vector 频繁扩容搬家,提升性能Particle& p用引用 :加&是引用,直接修改原粒子;不加就是拷贝,改了没用- 四舍五入 :
(int)(p.x + 0.5)比直接(int)p.x更准确,因为浮点数转整数会截断小数部分
⌨️ 键盘位置映射
🎯 我们要做什么
现在烟花有了,但怎么让"按哪个键,就在键盘对应的位置炸烟花"呢?
这一节我们实现:
- 建立一个"按键 → 坐标"的映射表
- 按字符键时,查表找到位置,发射烟花
- 按方向键时,特殊处理(四个方向对应屏幕四边)
💻 键盘位置映射表
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 时,要再读一次才能知道是哪个方向键。
这就像收快递------先收到一个"大件货物"的通知单,然后再去拿真正的包裹。
⚠️ 注意事项
(unsigned char)强制转换 :有些字符的 ASCII 码可能超过 127,转成unsigned char能避免负数问题toupper()要包含<cctype>:不过在很多编译器里<iostream>已经间接包含了,但最好显式包含- 方向键的颜色用 code % 15:不同方向键有不同颜色,方便区分
💎 闪烁按键方块
🎯 我们要做什么
按了键,除了炸烟花,还要在按键位置弹出一个闪烁的小方块,方块里显示你按的是什么键。这样按每个键都有明确的反馈。
这一节我们实现:
- 定义按键显示的结构体
- 画一个 5×3 的字符方块
- 让方块颜色快速闪烁
- 方块持续一段时间后消失
- 边界处退化为单个字符
💻 按键显示结构体
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逐格画:
cppint 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------能完整画就画完整方块,不能就退化为只画一个字符。
这是一种"优雅降级"的设计思想------条件好的时候给最好的效果,条件不够的时候也能正常工作,不会崩溃。
就像手机拍照------光线好的时候用高清模式,光线暗的时候自动降为夜景模式,总能拍出照片。
⚠️ 注意事项
frameCount要在主循环里声明并递增 :别忘了在while外面加int frameCount = 0;,循环里frameCount++- 迭代器删除要小心 :一定要用
it = erase(it)的写法,否则可能崩溃 - 方块会盖住粒子:因为方块是在粒子之后绘制的,所以方块会叠在粒子上面。这是合理的------按键反馈应该最醒目
🌟 自动背景烟花
🎯 我们要做什么
只有按键的时候才有烟花,屏幕会有点冷清。我们加一个"自动烟花秀"------即使不按键,背景也时不时有烟花随机绽放。
这一节我们实现:
- 随机延迟发射
- 粒子数量上限控制(防止太卡)
- 位置随机(高度偏上半屏,更像真烟花)
💻 自动烟花代码
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/4到winHeight*3/4,也就是中间半区 - 为什么偏上?因为真实的烟花是在空中炸开的,然后往下落。偏上一点,粒子下落的过程更有观赏性
💡 背后的原理
随机间隔比固定间隔更自然
如果每隔固定的 30 帧发一发,观众很快就能摸到规律,感觉很机械。
用随机间隔(10~34 帧),有时快有时慢,就像真的烟花秀------有密有疏,节奏感更好。
这也是游戏设计里的常用技巧:随机数让体验更有生命力。
上限控制的重要性
600 这个数字是怎么来的?其实是经验值------
- 太少了画面冷清
- 太多了 CPU 跟不上,会掉帧
600 个粒子在大多数电脑上都能流畅运行(30 FPS 以上),所以选了这个值。
实际项目中,你可以根据目标平台的性能来调整这个上限。
📝 三层居中文字
🎯 我们要做什么
光有烟花还不够,我们在中间加三层文字:
- 大标题:"Welcome to C++ Programming"(带边框的 ASCII 艺术字)
- 实时日期:显示今天的年月日
- 轮换诗句:8 句编程相关的诗句,每过一会儿换一句
这一节我们实现:
- 初始化标题和诗句
- 居中计算公式
- 时间获取和格式化
- 诗句轮换逻辑
💻 初始化文字数据
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更方便:
cppattron(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_t 和 struct tm
time(NULL)获取当前时间(从 1970 年 1 月 1 日到现在的秒数)localtime()把秒数转成年、月、日、时、分、秒的结构体tm_year是从 1900 年开始算的,所以要加 1900tm_mon是 0~11 的,所以要加 1
sprintf 格式化字符串
- 把数字按格式拼成字符串
%04d表示 4 位数字,不够前面补 0(比如 2026、08、27)- 结果存在
dateStr字符数组里
💡 背后的原理
为什么用三层文字
标题 + 日期 + 诗句,三层文字从上到下排列,形成一个"信息区":
| 层级 | 作用 | 颜色 |
|---|---|---|
| 大标题 | 吸引眼球,告诉用户这是什么 | 青色(醒目) |
| 日期 | 实时信息,增加真实感 | 黄色(温暖) |
| 诗句 | 文艺气息,增加趣味性 | 绿色(清新) |
颜色从冷到暖,视觉上有层次感。内容从宏观到微观,信息上有层次。
诗句为什么随机换
如果只显示一句诗,看久了就腻了。
每 100 帧(约 3 秒)随机换一句,8 句诗轮换------
- 用户不会马上看全所有诗句(随机的)
- 每次看可能都不一样,有新鲜感
- 编程主题的诗句也能增加学习的氛围感
⚠️ 注意事项
char dateStr[50]要足够大:装下"2026年08月27日"只需要 17 个字符,但留 50 个更安全- 中文显示依赖控制台编码:Windows 控制台默认是 GBK 编码,源文件也要保存为 GBK 才能正确显示中文。如果是 UTF-8 编码可能会乱码
(int)line.length()强制转换 :length()返回的是size_t(无符号整数),和int运算可能有警告,转成int更安全
🔄 窗口尺寸自适应
🎯 我们要做什么
之前的代码有个问题------如果你拉大或缩小控制台窗口,画面可能会错位甚至崩溃。
这一节我们实现:
- 每帧检测窗口尺寸
- 如果尺寸变了,重新分配画布
- 确保新窗口里一切正常显示
💻 窗口尺寸检测
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拿最新的窗口信息 - 和上一帧保存的
winWidth、winHeight比较 - 如果不一样,说明窗口大小变了
screenBuf.assign()
- 重新分配画布的大小
- 新的画布全部填充默认的
CHAR_INFO()(空格 + 灰色) - 就像换了一张更大的画纸,重新开始画
💡 背后的原理
为什么要每帧检测
窗口尺寸变化是用户随时可能做的操作------你不知道他什么时候会拉大或缩小窗口。
每帧检测虽然有一点点开销,但 GetConsoleScreenBufferInfo 很快,而且大部分时候尺寸没变,只是比较一下就完事了。
这是一种"防御性编程"的思路------宁可多做一点点检查,也要保证程序在各种情况下都能正常工作。
assign vs resize
assign(n, value):把容器变成 n 个元素,每个都是 value(完全重置)resize(n):只改变大小,新增的元素用默认值,旧元素保留
这里用 assign 是因为我们需要一张干净的新画布,旧的内容不要了。
⚠️ 注意事项
- 窗口位置也要检测 :不只是宽高,
winLeft和winTop(窗口左上角坐标)也要检查。因为用户可能拖动窗口位置,这时候写入区域也要跟着变 WriteConsoleOutput的writeRegion要用新的坐标:输出到控制台的时候,写入区域的坐标要和当前窗口位置对应- 粒子还在飞:窗口拉大了,粒子还是在原来的位置飞,这是正常的------只是可视范围变大了
🧩 拼合步骤:从零件到成品
前面七节你已经做了七块积木。现在我们把它们一步步拼起来,看看每一步加了什么。
第 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 反馈等多种技术。
如果你是初学者
如果你是刚接触控制台编程,建议按这个路线学:
- 🌱 【擦除重绘】弹跳 Logo 动画 --- 最简单的控制台动画入门
- 🌿 【打字游戏】字母雨打字练习 --- 学习游戏循环和双缓冲
- 🌳 本篇:键盘烟花秀 --- 把粒子 + 键盘 + 文字排版组合起来
如果你想深入特效
喜欢粒子烟花这类视觉效果,可以挑战:
👉 粒子烟花系列(4 篇,从基础到雪崩)
从简单的粒子爆炸,到键盘控制烟花,再到粒子沉积堆积,最后到雪崩效应。同样是双缓冲 + 粒子系统,但物理模拟更复杂,视觉效果更震撼。
💡 学习建议
- 先跑起来再说:把完整代码复制运行,感受效果,再回头看原理
- 改参数找感觉:试试改粒子数量、重力大小、方块尺寸,观察变化
- 自己加功能:比如加一个"连按模式",按得越快烟花越多
📖 头文件速查
| 头文件 | 用到的东西 | 简介 | 文档 |
|---|---|---|---|
<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 标准 |
🔜 拓展方向
学完了本篇,你可以试试这些进阶挑战:
🌱 初级(改改参数)
- 改方块尺寸:把 5×3 改成 7×5,看看效果
- 改闪烁速度 :把
frameCount / 2改成frameCount / 4,闪得慢一点 - 改诗句内容:把 8 句诗换成你自己喜欢的句子
- 改自动烟花频率 :把
launchDelay的范围调小,烟花更密集
🌿 中级(加加功能)
- 加连击计数:连续按键时显示连击数(Combo)
- 加得分系统:按键越多得分越高,显示在角落
- 加音效 :用
Beep()在发射烟花时发出声音 - 改标题样式:把大标题换成更大的 ASCII 艺术字
🌳 高级(改改架构)
- 粒子爆炸冲击波:按键中心的粒子速度更快,形成冲击波效果
- 按键颜色主题:不同区域的按键有不同的颜色主题(冷色/暖色)
- 文字阴影效果:文字下面加一层深色偏移,模拟阴影
- 多种烟花形状:心形、星形、圆形,不同的键触发不同形状
🏆 大师级(完整项目)
- 打字练习模式:屏幕上方出字母,用户按对应键消除,算正确率
- 烟花记录回放:录制按键序列,然后回放整个烟花秀
- 多人模式:两个键盘区域(左手/右手)比赛谁按得快
- 音乐可视化:读取音频音量,音量越大烟花越密
(全文完)
系列:C++ 控制台编程实战
上一篇:【二维数组】C++ 实现控制台画板工具
下一篇:更多内容持续更新中