🏔️ 【地层堆积】C++ CMD 动画:粒子落下来堆成一座山
C++ CMD 动画编程实战:从零拼出粒子烟花 · 第 3 篇
📌 摘要:本文在第 2 篇键盘交互烟花基础上加入粒子沉积系统——落到底部的粒子不再消失,而是堆积起来形成"地层",越堆越高直到满屏。内容覆盖 vector<int> 高度数组、触底检测、边界保护(钳制)、状态分离设计思想、.clear() 清空容器、绘制顺序控制(后画覆盖先画)等。全文仍采用渐进式教学,每节给出完整可编译程序。适用读者:建议先阅读第 1、2 篇。
📚 目录:
- 记住每一列堆多高 —
vector<int>高度数组、下标访问、++自增 - 让粒子落地后停下来 — 触底检测
p.y >= winHeight - 1、边界保护(钳制)、沉积粒子创建 - 两套粒子分开管 — 状态分离设计思想、
.clear()清空容器、.assign()重置 - 画在文字之上 — 绘制顺序控制(后画覆盖先画)
- 拼合步骤:从零件到成品 — 四步拼出成品
- 完整版出炉 —
TypingSparksKeyzoneStratum.cpp(352 行完整源码) - 架构总结 — 五层架构、数据层拆分为飞行/沉积两个子系统、主循环十二步
- 头文件汇总 — 九个头文件及其用途
- 下一篇预告 — 雪崩效果
🛠️ 编译环境:和前两篇相同(Windows 7+,MinGW-w64 或 Visual Studio,编译命令 g++ 04_TypingSparksKeyzoneStratum.cpp -o stratum.exe -static)。无新增平台依赖。
📋 前置条件:已阅读第 1、2 篇或已了解 struct、vector、map、双缓冲、键盘交互等概念。
前两篇的烟花粒子飞出去后,要么寿命耗尽消失,要么飞出屏幕边界消失。无论哪种方式,粒子最终都"没了"。但真正的烟花有残渣——火星落地的灰烬会堆起来。这一篇我们让落到底部的粒子"沉积"下来,像沙子一样越堆越高,直到堆满整个屏幕。
代码从 332 行增长到 352 行。新增了 20 行,但概念上的变化远比行数大——我们引入了第二套粒子系统,以及"状态分离"的设计思想。
每一节的代码都可以直接复制到编译器里运行。不需要先写别的代码——每一节都是完整的程序。
📏 记住每一列堆多高
拼什么:vector<int> 高度数组 + 下标访问
🎯 我们要做什么
想象你在一个箱子里往不同的管子里扔玻璃球。每根管子里的球越堆越高,但你怎么知道每根管子已经堆了多少个呢?总不能每次都把球倒出来数一遍吧。
我们的粒子沉积系统也是一样的道理。屏幕有几十上百列,每一列都可能有粒子落下来堆积。如果不记录每列堆了多高,新粒子落下来时就不知道该落在哪一行——是落在最底下,还是落在已经堆好的粒子上面?
这一节我们就来解决这个问题:用一个高度数组,逐列记录堆积量。就像给每列装了一把"尺子",随时能读出这列堆了多高。
有了这个高度数组,后面才能做到:
- 新粒子落下来时,准确知道该停在哪一行
- 不会出现粒子"穿墙"或"重叠"的奇怪现象
- 沉积效果就像真实的沙子堆积一样自然
怎么知道每一列已经堆了多高?最简单的办法:用一个数组,每个元素对应屏幕上的一列,记录这列堆了多少个粒子。
比如屏幕宽 80 列,就建一个 80 个元素的数组。第 0 列堆了 5 个粒子,height[0] 就是 5。第 40 列堆了 12 个,height[40] 就是 12。
用 vector<int> 来存这个数组。#include <vector> 在第一篇已经讲过——动态数组。cppreference 文档
📐 声明一个高度数组
一行代码就能搞定:
vector<int> sedimentHeight(winWidth, 0);
这行代码看起来简单,但做了三件事:
- 创建容器:
vector<int>告诉编译器"我要一个装整数的动态数组" - 指定大小:括号里第一个参数
winWidth是数组的长度——屏幕有多宽,数组就有多少个元素 - 初始化为 0:第二个参数
0是初始值——所有列一开始都是空的,高度为 0
sedimentHeight 这个名字拆开看:sediment 是"沉积"的意思(就是泥沙沉淀),Height 是"高度"。整个名字就是"沉积高度"——每列沉积了多少。
🔢 怎么用高度数组
vector<int> 的用法和第一篇的 vector<Particle> 一样,只是里面装的是 int 而不是 Particle。
用方括号 [ ] 来访问某一列的高度,就像用储物柜的编号找柜子一样:
sedimentHeight[5]—— 读出第 5 列现在堆了多高sedimentHeight[5]++—— 让第 5 列的高度加 1(有新粒子落下来了)
++ 是"加 1"的简写——和 += 1 效果完全一样,只是写起来更短。cppreference 文档
#include <iostream>
#include <vector>
using namespace std;
int main() {
int winWidth = 10;
// 每一列的沉积高度,初始全为 0
vector<int> sedimentHeight(winWidth, 0);
// 模拟粒子落到第 3 列
int bx = 3;
sedimentHeight[bx]++; // 第 3 列高度 +1
sedimentHeight[bx]++; // 再 +1
// 模拟粒子落到第 7 列
bx = 7;
sedimentHeight[bx]++;
// 打印每列高度
for (int i = 0; i < winWidth; i++) {
cout << "第 " << i << " 列堆了 " << sedimentHeight[i] << " 个" << endl;
}
return 0;
}
📝 逐行看懂这段代码
上面的完整程序虽然不长,但每一行都有它的作用。让我们拆开来看:
- 第 1-3 行:引入头文件。
iostream用于打印输出,vector提供动态数组,using namespace std;让代码更简洁(不用每次写std::)。 - 第 5 行:程序入口
main()函数,所有程序都从这里开始执行。 - 第 6 行:定义屏幕宽度为 10——我们用一个 10 列的小屏幕做演示,方便观察。
- 第 9 行:
vector<int> sedimentHeight(winWidth, 0)创建高度数组。10 个元素,每个初始值都是 0——一开始所有列都是空的。 - 第 12 行:
int bx = 3——模拟一个粒子落在第 3 列。bx是 buffer x 的缩写,意思是"缓冲区的列号"。 - 第 13-14 行:
sedimentHeight[bx]++连续两次——第 3 列的高度增加 2(两个粒子落下来了)。 - 第 17-18 行:又有一个粒子落在第 7 列,高度加 1。
- 第 21-23 行:用
for循环遍历每一列,打印出每列堆了多少个粒子。这是验证高度数组是否工作正常的好办法。
💡 小技巧:写代码的时候,可以像这样用一个小程序先验证核心逻辑对不对,确认没问题了再拼到大程序里。这叫"单元测试"——先测试零件,再组装整机。
变量名拆解:sedimentHeight = sediment(沉积)+ Height(高度),bx = buffer + x(列号,第一篇讲过)。
运行效果:程序输出 10 列的高度——第 3 列堆了 2 个、第 7 列堆了 1 个,其余列都是 0,直观看到高度数组如何逐列记录堆积量。
💡 背后的原理
看完了代码怎么写,我们来挖一挖"为什么这么写"。理解了背后的原理,以后遇到类似问题你也能自己想出解决方案。
为什么用高度数组?——逐列记录 vs 逐粒子记录
你可能会想:要知道每列堆了多高,我直接去数 sedimentParticles 里某一列有多少个粒子不就行了吗?为什么还要单独搞一个 sedimentHeight 数组?
这是一个很好的问题。我们来对比一下两种方案:
| 方案 | 怎么做 | 时间开销 | 适合场景 |
|---|---|---|---|
| 逐粒子数 | 每次需要高度时,遍历所有沉积粒子,数出某一列有多少个 | 每次要数一遍,粒子越多越慢 | 粒子很少的时候 |
| 高度数组 | 专门用一个数组记录每列高度,落一个粒子就 ++ 一下 | 直接读一下数组元素,瞬间得到答案 | 粒子很多、需要频繁查询时 |
打个比方:你开了一家电影院,想知道每场电影卖了多少张票。
- 逐粒子数 = 每次都跑到放映厅里一个一个数人头——人少还好,人多了要数半天
- 高度数组 = 门口放一个计数器,每进去一个人按一下——随时想看随时看,一秒钟的事
我们的粒子系统里可能有成百上千个沉积粒子,如果每次都去数一遍,程序会变得很慢。而高度数组只需要 winWidth 个整数(比如 80 个),占用空间极小,查询速度极快。这就是用空间换时间的设计思想——花一点点内存,换来大大的速度提升。
下标访问是什么?——储物柜编号的比喻
sedimentHeight[5] 这种用方括号加数字的写法,叫做下标访问(也叫索引访问)。
想象一排储物柜,每个柜子上都有一个编号:0、1、2、3……
- 你想拿 5 号柜子里的东西,直接找到编号 5 的柜子打开就行——不用从 0 号开始一个一个找
- 你想往 7 号柜子里放东西,直接找到 7 号柜子放进去
数组的下标就是柜子的编号:
sedimentHeight[0]是第 0 号柜子(第 0 列的高度)sedimentHeight[5]是第 5 号柜子(第 5 列的高度)- 编号从 0 开始,不是从 1 开始——这是 C++ 的规定,几乎所有编程语言都是这样
为什么从 0 开始?其实是历史原因+效率原因:数组在内存里是一排连续的格子,“下标"本质上是"离第一个格子有多远”。第 0 个格子离起点的距离是 0,第 1 个格子离起点的距离是 1……这样计算起来最快。
自增运算符 ++ 的两种写法——前置 vs 后置
你可能在别的地方见过两种写法:++x 和 x++。它们都是让变量加 1,但有一个细微的区别:
x++(后置++):先用 x 的旧值,用完再加 1。就像"先吃饭后付钱"++x(前置++):先加 1,再用加完后的新值。就像"先付钱后吃饭"
举个例子:
int a = 5;
int b = a++; // b = 5(先用旧值),然后 a 变成 6
int c = 5;
int d = ++c; // c 先变成 6,然后 d = 6(用新值)
在我们的高度数组代码里,sedimentHeight[bx]++ 用的是后置++。因为我们只需要让高度加 1,不关心它返回什么值——这时候前置和后置效果完全一样。
💡 小知识:对于简单的
int变量,两种写法效率差不多。但对于复杂的对象(比如迭代器),前置++通常比后置++更快(因为后置需要多做一个"保存旧值"的操作)。所以养成用++x的习惯也挺好。
🐧 Linux / macOS 用户看这里
vector<int>是 C++ STL 标准容器,高度数组的算法逻辑(记录每列堆积量、用下标访问、逐列递增)全平台通用。这一节是纯数据结构,和 Windows API 没有任何关系,代码可以直接在 Linux/macOS 上用g++编译运行。
拧一拧:
- 让第 3 列堆到 10,看看输出
- 加一个循环让所有偶数列都堆 3 个
🛑 让粒子落地后停下来
拼什么:触底检测 p.y >= winHeight - 1 + 边界保护 + sedimentHeight[bx]++
🎯 我们要做什么
上一节我们建好了高度数组——就像给每列装了一把尺子。但尺子光摆在那儿没用,得有东西去"读"它、"写"它才行。
这一节我们要做三件事:
- 检测粒子什么时候落地——粒子飞着飞着,怎么知道它已经碰到地面了?
- 把落下来的粒子存起来——落地的粒子不能消失,要变成"沉积粒子"留在原地
- 保护边界不出错——万一粒子飞出了屏幕左右边界,我们得把它拉回来,不能让程序崩掉
做完这三件事,你就能看到烟花粒子落下来后,像沙子一样在底部堆积起来——这就是"地层"的雏形。
有了高度数组,下一步是:当粒子落到底部时,把它变成沉积粒子,而不是让它消失。
怎么判断粒子"触底"了?第一篇里粒子飞出屏幕底部时直接 p.active = false(消失)。现在改成:如果 p.y >= winHeight - 1(到达最后一行),就触发沉积。winHeight - 1 是因为行号从 0 开始算——屏幕高 25 行,最后一行的行号是 24(即 winHeight - 1)。
触底后做三件事:
- 算出落在哪一列:
int bx = (int)(p.x + 0.5);——四舍五入取整数列号,第一篇讲过这个技巧 - 创建沉积粒子:把速度设为 0(不动了),寿命设为 999999(几乎不死),位置放在该列已经堆了的高度上
- 高度数组加 1:
sedimentHeight[bx]++
沉积粒子的 Y 坐标怎么算?从底部往上堆——第一个粒子在第 winHeight - 1 行(最底行),第二个在第 winHeight - 2 行(上面一行),第三个在第 winHeight - 3 行……公式是:
沉积粒子的 Y = winHeight - 1 - sedimentHeight[bx]
winHeight - 1 是最底行的行号,减去 sedimentHeight[bx](已经堆了几个)就是新粒子应该放在第几行。
但还有一个问题:粒子的水平位置 p.x 可能是负数或超出屏幕宽度(因为粒子飞出了边界后才触底)。所以要做边界保护:
if (bx < 0) bx = 0;
if (bx >= winWidth) bx = winWidth - 1;
这两行是"钳制"——把超出范围的值拉回到合法范围。就像不让球滚出台子。
#include <iostream>
#include <windows.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
using namespace std;
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
struct Particle {
double x, y, vx, vy;
int life;
int color;
char ch;
bool active;
};
int main() {
srand((unsigned)time(NULL));
int width = 80, height = 25;
vector<CHAR_INFO> screen(width * height);
vector<Particle> particles;
// ★ 沉积系统:高度数组 + 沉积粒子列表
vector<int> sedimentHeight(width, 0);
vector<Particle> sedimentParticles;
// 发射一朵烟花
double cx = 40, cy = 5;
for (int i = 0; i < 50; 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;
p.life = 100;
p.color = 14;
p.ch = '*';
p.active = true;
particles.push_back(p);
}
int frames = 0;
while (true) {
// 清空缓冲区
for (int i = 0; i < width * height; i++) {
screen[i].Char.AsciiChar = ' ';
screen[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.y >= height - 1) {
int bx = (int)(p.x + 0.5);
// 边界保护:把超出范围的列号拉回来
if (bx < 0) bx = 0;
if (bx >= width) bx = width - 1;
// 在该列堆一个沉积粒子
if (sedimentHeight[bx] < height) {
Particle sed;
sed.x = (double)bx;
sed.y = (double)(height - 1 - sedimentHeight[bx]);
sed.vx = 0; sed.vy = 0;
sed.life = 999999;
sed.color = p.color;
sed.ch = p.ch;
sed.active = true;
sedimentParticles.push_back(sed);
sedimentHeight[bx]++;
}
p.active = false; // 原粒子消失
continue;
}
if (p.life <= 0 || p.x < 0 || p.x >= width) {
p.active = false;
continue;
}
int bx = (int)(p.x + 0.5);
int by = (int)(p.y + 0.5);
if (bx >= 0 && bx < width && by >= 0 && by < height) {
screen[by * width + bx].Char.AsciiChar = p.ch;
screen[by * width + bx].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;
// ★ 画沉积粒子
for (size_t i = 0; i < sedimentParticles.size(); i++) {
Particle& sed = sedimentParticles[i];
int bx = (int)(sed.x + 0.5);
int by = (int)(sed.y + 0.5);
if (bx >= 0 && bx < width && by >= 0 && by < height) {
screen[by * width + bx].Char.AsciiChar = sed.ch;
screen[by * width + bx].Attributes = sed.color;
}
}
// 输出
COORD bufSize = { (SHORT)width, (SHORT)height };
COORD bufCoord = { 0, 0 };
SMALL_RECT region = { 0, 0, (SHORT)(width-1), (SHORT)(height-1) };
WriteConsoleOutput(h, &screen[0], bufSize, bufCoord, ®ion);
frames++;
if (frames > 300) break;
Sleep(30);
}
return 0;
}
Particle& sed = sedimentParticles[i]; 里的 & 是引用——第一篇讲过,直接操作原件不用复印件。sed 是 sediment(沉积)的缩写。
变量名拆解:sed = sediment(沉积),sedimentHeight = sediment + Height(沉积高度),sedimentParticles = sediment + Particles(沉积粒子集合)。
运行效果:运行后你会看到烟花炸开后,粒子纷纷落下,触底的粒子停在那里不动了,越堆越多。
🐧 Linux / macOS 用户看这里
触底检测、沉积粒子的物理和逻辑都是标准 C++,全平台通用。只有画粒子的方式不同:
Windows 控制台 Linux/macOS (ncurses) 写字符到 screenBuffer数组调用 mvaddch(y, x, '*')WriteConsoleOutputW一次性刷新refresh()刷新屏幕物理更新(
vy += gravity、x += vx、y += vy)和触底判断(y >= 屏幕底部)在两个平台完全一样。
💡 背后的原理
这一节的代码看起来就是几行 if 判断,但背后藏着几个很重要的编程思想。我们来一一拆解。
触底检测的设计——什么时候算"落地"了
你可能觉得触底检测很简单:p.y >= winHeight - 1,不就是判断 y 坐标到了最后一行嘛。但仔细想想,为什么是 >= 而不是 ==?为什么是 winHeight - 1 而不是 winHeight?
这里面有两个设计考虑:
为什么用 >= 而不是 ==?
因为粒子的 y 坐标是小数(double 类型),而且每帧会增加一个速度值 vy。假设当前 y 是 23.7,速度是 0.5,下一帧 y 变成 24.2——直接跳过了 24.0 这个精确值。如果用 == 判断,粒子可能永远不会触发"触底",直接穿过地面飞出屏幕了。
用 >=(大于等于)就安全多了——只要 y 到达或超过底部,就算落地。就像跳高比赛,只要身体任何部分过了横杆就算过,不需要正好停在横杆高度。
为什么是 winHeight - 1 而不是 winHeight?
因为行号从 0 开始数。屏幕高 25 行,行号是 0、1、2……24,最后一行是 24,也就是 winHeight - 1。如果你写 p.y >= winHeight(25),粒子已经完全飞出屏幕了才触发,就晚了一拍。
💡 小经验:在编程里,“边界"问题是最容易出 bug 的地方。差一个数、多一个判断,效果可能完全不同。写代码时多想想"边界情况”——最小的值、最大的值、刚好等于边界的值,这些地方最容易出问题。
边界保护/钳制是什么——为什么不能让粒子跑到屏幕外面
粒子的水平位置 p.x 可能是负数(飞出左边),也可能大于等于 winWidth(飞出右边)。如果这时候直接用 sedimentHeight[bx] 去访问数组,会发生什么?
答案是:程序可能崩溃,或者出现奇怪的错误。
因为 sedimentHeight 数组只有 winWidth 个元素,下标从 0 到 winWidth - 1。你去访问下标为 -1 或 winWidth 的元素,相当于去开一个不存在的储物柜——要么被保安拦住(程序报错退出),要么误开了别人的柜子(修改了不该改的内存数据,造成莫名其妙的 bug)。
所以我们需要边界保护,也叫钳制(clamp):
if (bx < 0) bx = 0; // 小于 0 的,拉回 0
if (bx >= winWidth) bx = winWidth - 1; // 超出最大的,拉回最大值
就像乒乓球台的边界——球打在边缘会弹回来,不会掉出去。边界保护是编程里一个非常常见的"防御性编程"技巧:永远不要相信输入的数据一定是合法的,提前把不合法的值修正过来。
沉积粒子和飞行粒子的区别——状态的概念
你有没有注意到,沉积粒子和飞行粒子用的是同一个 struct Particle?它们的结构完全一样,但行为完全不同:
| 方面 | 飞行粒子 | 沉积粒子 |
|---|---|---|
| 速度 | 有速度,每帧在变 | 速度为 0,不动 |
| 寿命 | 有限,到 0 就消失 | 999999,几乎不死 |
| 位置 | 每帧在移动 | 固定不变 |
| 行为 | 会飞、会消失 | 待在原地不动 |
这就是状态的概念——同一个东西,处于不同的状态时,表现完全不同。就像水:温度低于 0℃ 是固态(冰),0~100℃ 是液态(水),高于 100℃ 是气态(水蒸气)。化学成分都是 H₂O,但状态不同,性质天差地别。
粒子也是一样:飞行状态时到处乱飞,沉积状态时纹丝不动。状态的改变发生在触底的那一刻——p.active = false(飞行粒子消失)+ 新建一个 sed 粒子(沉积粒子出现),这就是一次"状态转换"。
💡 思考一下:为什么我们不直接把飞行粒子的速度改成 0,让它变成沉积粒子,而是要新建一个沉积粒子、再把原粒子删掉?答案在下一节"两套粒子分开管"里揭晓。
拧一拧:
- 把
p.life = 100改成p.life = 200,粒子飞得更久,堆得更多 - 把重力
0.08改成0.3,粒子掉得更快,堆得更集中
🎯 两套粒子分开管
拼什么:vector<Particle> particles vs vector<Particle> sedimentParticles + .clear() 清空 + .assign() 重置
🎯 我们要做什么
上一节留了一个思考题:为什么不直接把飞行粒子的速度改成 0,让它变成沉积粒子?这一节我们就来回答这个问题。
想象一个电影院:
- 有的观众正在进场找座位——走来走去,位置随时在变
- 有的观众已经坐好不动了——安安静静看电影,位置固定
如果把这两类人混在一个名单里管理,工作人员每次找人都得先判断"这个人是在走还是已经坐下了",多麻烦。更好的办法是:用两张名单,一张管"正在进场的人",一张管"已经入座的人",各管各的。
我们的粒子系统也是一样的道理。这一节我们要搞清楚:
- 为什么飞行粒子和沉积粒子要分开存——两个
vector比一个好在哪里 - 窗口变化时怎么重置——窗口大小变了,沉积粒子怎么办
.clear()和.assign()有什么区别——两个看起来都是"清空",但用法不一样
理解了"状态分离"这个设计思想,你以后写复杂程序时就知道怎么把大问题拆成小问题了。
你可能会问:为什么要有两套粒子?为什么不把沉积粒子也放在原来的 particles 里?
因为它们的行为完全不同。飞着的粒子每帧都要更新位置(加重力、加速度),但沉积粒子不动——速度为 0,永远停在那里。如果放在同一个 vector 里,每帧都要判断"这个粒子是飞着的还是沉积的",代码会变得复杂。
把两类粒子分开存放,各管各的,这叫状态分离(Separation of Concerns)。每个 vector 只管一类东西,逻辑清晰。
两套粒子的区别:
| 特性 | 飞行粒子 particles | 沉积粒子 sedimentParticles |
|---|---|---|
| 速度 | 每帧变化(有重力) | 恒为 0(不动) |
| 寿命 | 每帧减 1,到 0 消失 | 999999(几乎不死) |
| 位置 | 每帧移动 | 固定不变 |
| 数量 | 时多时少(发射和消失) | 只增不减(除非窗口变化) |
| 绘制顺序 | 先画 | 后画(覆盖在文字之上) |
窗口大小变化时,两套粒子都要处理:
if (w != winWidth || h != winHeight || left != winLeft || top != winTop) {
winLeft = left;
winTop = top;
winWidth = w;
winHeight = h;
screenBuf.assign(winWidth * winHeight, CHAR_INFO());
sedimentHeight.assign(winWidth, 0); // 高度数组清零
sedimentParticles.clear(); // 沉积粒子全部清除
}
.assign(winWidth, 0) 在第一篇讲过——清空旧内容,填入 winWidth 个 0。cppreference 文档
这里出现了一个新函数 .clear()。clear 是"清空"的意思。sedimentParticles.clear() 把沉积粒子全部删掉,变成空容器。窗口大小变了,之前堆的粒子位置就不对了,干脆全部清掉重新堆。cppreference 文档
飞行粒子 particles 不需要 clear——它们会自然消失(寿命到 0 或飞出屏幕)。
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
using namespace std;
struct Particle {
double x, y, vx, vy;
int life;
int color;
char ch;
bool active;
};
int main() {
// 两套粒子分开管理
vector<Particle> particles; // 飞行粒子:会动、会消失
vector<Particle> sedimentParticles; // 沉积粒子:不动、不消失
// 高度数组
vector<int> sedimentHeight(10, 0);
// 添加一个飞行粒子
Particle p;
p.x = 5; p.y = 0; p.vx = 0; p.vy = 0.5;
p.life = 10; p.color = 14; p.ch = '*'; p.active = true;
particles.push_back(p);
// 添加一个沉积粒子
Particle sed;
sed.x = 5; sed.y = 9; sed.vx = 0; sed.vy = 0;
sed.life = 999999; sed.color = 14; sed.ch = 'o'; sed.active = true;
sedimentParticles.push_back(sed);
sedimentHeight[5]++;
cout << "飞行粒子数: " << particles.size() << endl;
cout << "沉积粒子数: " << sedimentParticles.size() << endl;
cout << "第 5 列高度: " << sedimentHeight[5] << endl;
// 模拟窗口变化:全部重置
sedimentHeight.assign(10, 0); // 高度归零
sedimentParticles.clear(); // 沉积粒子清空
cout << "\n重置后:" << endl;
cout << "沉积粒子数: " << sedimentParticles.size() << endl;
cout << "第 5 列高度: " << sedimentHeight[5] << endl;
return 0;
}
运行效果:程序先打印飞行粒子数 1、沉积粒子数 1、第 5 列高度 1;调用 .assign() 和 .clear() 重置后,沉积粒子数变为 0、第 5 列高度变为 0,演示两套粒子的独立管理与重置。
🐧 Linux / macOS 用户看这里
状态分离是设计思想,全平台通用。
.clear()、.assign()都是vector的标准方法,和操作系统无关。飞行粒子和沉积粒子分开管理的思路,在 ncurses、SDL、Qt 等任何渲染框架下都适用。
💡 背后的原理
这一节讲的"两套粒子分开管",背后是编程中一个非常重要的设计思想。理解了它,你写的代码会更清晰、更好维护。
状态分离的设计思想——为什么不把所有粒子放一个 vector 里
假设我们把所有粒子都放在同一个 vector<Particle> particles 里,会发生什么?
每帧更新的时候,你都得写这样的代码:
for (每个粒子) {
if (粒子是飞行的) {
// 更新物理:加重力、移动、减寿命
} else if (粒子是沉积的) {
// 什么都不做,因为沉积粒子不动
}
}
看起来也能跑,对吧?但问题是:
- 效率低:沉积粒子明明不用动,却每帧都要被检查一遍——就像电影院里工作人员每次都要跑过去问"你坐好了吗?",多浪费时间
- 代码乱:飞行和沉积的逻辑混在一起,时间长了就像一团乱麻——改一个地方可能影响另一个
- 容易出 bug:万一某个
if条件写错了,沉积粒子突然飞起来了,或者飞行粒子突然不动了,排查起来很麻烦
把它们分成两个 vector 之后:
particles—— 只管飞的,每帧更新物理sedimentParticles—— 只管沉积的,不用更新物理(直接画就行)
各管各的,互不干扰。这就是状态分离(Separation of Concerns)的设计思想——把不同状态、不同职责的东西分开管理,每个部分只做一件事。
💡 电影院比喻延伸:飞行粒子就像"正在找座位的观众"——一直在动,位置不固定;沉积粒子就像"已经入座的观众"——坐好了就不动了。用两张名单分开管理,比混在一起清晰得多。
.clear() 和 .assign() 的区别——清空 vs 重置填充
.clear() 和 .assign() 都能让 vector 变"空",但它们做的事情不一样:
| 方法 | 做了什么 | 结果 | 类比 |
|---|---|---|---|
.clear() | 把所有元素删掉,容器变成空的 | size = 0,里面什么都没有 | 把抽屉里的东西全部倒掉,抽屉空了 |
.assign(n, val) | 先清空旧内容,再填入 n 个 val | size = n,每个元素都是 val | 把抽屉清空后,再整齐地放入 n 个同样的东西 |
举个例子:
vector<int> arr(5, 0); // 5个0:[0, 0, 0, 0, 0]
arr.clear(); // 空了:size = 0
// 这时候 arr[0] 是非法的——没有元素了!
arr.assign(3, 7); // 3个7:[7, 7, 7]
// 这时候 arr[0] = 7,arr.size() = 3
在我们的代码里:
sedimentParticles.clear()—— 沉积粒子全部删掉,一个不留。因为窗口大小变了之后,之前堆的粒子位置都不对了,干脆全部清空重新堆。sedimentHeight.assign(winWidth, 0)—— 高度数组不能只是清空,得重新填成正确大小的 0。因为窗口变宽了,新的宽度可能是 100 列,我们需要 100 个 0,而不是一个空数组。
💡 小口诀:只要"清空"用
clear,清空后还要"填满新东西"用assign。
为什么需要两套粒子系统——职责单一原则
两套粒子系统的设计,其实体现了一个更通用的编程原则:单一职责原则(Single Responsibility Principle)。
简单说就是:一个东西只负责一件事。
particles只负责"飞"——物理更新、碰撞检测、寿命管理,都是飞行相关的sedimentParticles只负责"沉积"——存起来、画出来,不涉及运动sedimentHeight只负责"记高度"——每列堆了多高,快速查询
每个部分职责单一,好处是:
- 好理解:看名字就知道这个容器是干嘛的
- 好修改:改飞行粒子的逻辑时,不用担心影响沉积粒子
- 好测试:可以单独测试高度数组对不对、沉积粒子画得对不对
这就像学校里的分工:语文老师教语文,数学老师教数学,体育老师带体育——各有所长,互不干扰。如果一个老师什么都教,既教语文又教数学还带体育,那大概率哪样都教不好。
💡 编程高手的秘密:写程序不是写得越"高级"越好,而是拆得越清楚越好。真正的高手善于把一个复杂的大问题,拆成几个简单的小问题,然后逐个解决。
拧一拧:
- 在重置后不调用
clear(),看看沉积粒子会不会残留(会残留——这就是为什么需要clear) - 试试
sedimentParticles.clear()后再.size(),确认变成 0
🎨 画在文字之上
拼什么:绘制顺序控制——先画文字,后画沉积粒子
🎯 我们要做什么
你有没有画过水彩画?先画背景的天空和远山,再画中间的树木和房子,最后画前景的小花和小草——后画的东西会盖住先画的,这样才有层次感。
我们的屏幕缓冲区也是一样的道理:同一个格子被画两次,后画的会覆盖先画的。所以谁先画、谁后画,决定了谁在上面、谁在下面。
这一节我们要搞清楚:
- 绘制顺序怎么影响画面——后画的为什么能盖住先画的
- 沉积粒子为什么要画在文字上面——灰烬盖住文字才自然
- Z 轴顺序是什么——2D 画面里也有"前后左右"的深度感
理解了绘制顺序,你就能精确控制画面的层次,让效果更真实、更好看。
这一节讲一个看似简单但很重要的细节:绘制顺序。
缓冲区是一个格子一个格子画的。如果同一个格子被画了两次,后画的覆盖先画的。所以绘制顺序决定了谁在上面。
完整版里的绘制顺序是:
① 清空缓冲区(全填空格)
② 画飞行粒子
③ 画标题、日期、诗句
④ 画沉积粒子(最后画,覆盖一切)
为什么沉积粒子最后画?因为沉积粒子代表"实物"——堆积的灰烬应该盖住下面的文字。就像你在纸上写字后撒了一层沙子,沙子盖住了字。
代码里这个顺序体现在 while 循环的代码排列上——先更新和画飞行粒子,再画标题日期诗句,最后才画沉积粒子:
// ① 清空缓冲区
for (int i = 0; i < winWidth * winHeight; ++i) { ... }
// ② 更新并画飞行粒子
for (size_t i = 0; i < particles.size(); ++i) { ... }
// ③ 画标题、日期、诗句
// (标题绘制代码)
// (日期绘制代码)
// (诗句绘制代码)
// ④ 最后画沉积粒子——覆盖在文字之上
for (size_t i = 0; i < sedimentParticles.size(); ++i) {
Particle& sed = sedimentParticles[i];
int bx = (int)(sed.x + 0.5);
int by = (int)(sed.y + 0.5);
if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
int idx = by * winWidth + bx;
screenBuf[idx].Char.AsciiChar = sed.ch;
screenBuf[idx].Attributes = sed.color;
}
}
如果反过来——先画沉积粒子再画文字——文字就会盖住沉积粒子,看起来灰烬"穿透"了文字,不自然。
#include <iostream>
#include <windows.h>
#include <vector>
using namespace std;
int main() {
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
int width = 80, height = 25;
vector<CHAR_INFO> screen(width * height);
// 模拟一行文字
string text = "Hello World!";
// 模拟沉积粒子在第 12 行第 40 列
int sedX = 40, sedY = 12;
char sedCh = 'o';
int sedColor = 14;
while (true) {
// ① 清空
for (int i = 0; i < width * height; i++) {
screen[i].Char.AsciiChar = ' ';
screen[i].Attributes = 7;
}
// ② 先画文字
int startX = (width - (int)text.length()) / 2;
int rowY = height / 2;
for (size_t j = 0; j < text.length(); j++) {
int x = startX + j;
if (x >= 0 && x < width) {
screen[rowY * width + x].Char.AsciiChar = text[j];
screen[rowY * width + x].Attributes = 11;
}
}
// ③ 后画沉积粒子——盖住文字
if (sedX >= 0 && sedX < width && sedY >= 0 && sedY < height) {
screen[sedY * width + sedX].Char.AsciiChar = sedCh;
screen[sedY * width + sedX].Attributes = sedColor;
}
COORD bufSize = { (SHORT)width, (SHORT)height };
COORD bufCoord = { 0, 0 };
SMALL_RECT region = { 0, 0, (SHORT)(width-1), (SHORT)(height-1) };
WriteConsoleOutput(h, &screen[0], bufSize, bufCoord, ®ion);
break; // 只画一帧
}
return 0;
}
运行效果:屏幕中央显示 “Hello World!” 文字,但第 12 行第 40 列的位置被一个黄色的 ‘o’ 覆盖——沉积粒子画在文字之后,盖住了下面的字符。
🐧 Linux / macOS 用户看这里
绘制顺序的思想(后画覆盖先画)全平台通用。ncurses 也是完全一样的模型——先调用
mvaddch画的字符在底层,后调用的会覆盖上去:第 ① 步:mvaddch 画文字 ← 底层 第 ② 步:mvaddch 画飞行粒子 ← 中间层 第 ③ 步:mvaddch 画沉积粒子 ← 顶层(覆盖前两层)和 Windows 控制台缓冲区的"后写覆盖先写"原理完全一致,只是 API 名字不同。
💡 背后的原理
绘制顺序看似只是"谁先画谁后画"的小问题,但背后藏着计算机图形学中一个非常核心的概念——深度层次。我们来深入了解一下。
绘制顺序控制——后画覆盖先画,画画叠色比喻
缓冲区就像一张画纸,每个格子就是一个像素。每次往格子里写字符,就像在纸上涂色——后涂的颜色会盖住先涂的。
比如你先用蓝笔写一个 “A”,再用红笔在同一个位置写一个 “B”,最后你看到的是什么?是红色的 “B”,因为它是后写的,盖住了之前的 “A”。
代码里的绘制顺序就是这个道理:
// 先画文字(底层)
screen[idx].Char.AsciiChar = 'H'; // 写了个 H
screen[idx].Attributes = 11; // 青色
// 后画沉积粒子(顶层)
screen[idx].Char.AsciiChar = 'o'; // 覆盖成 o
screen[idx].Attributes = 14; // 黄色
同一个格子,最后写进去的是什么,显示出来就是什么。先写的被后写的"压"在下面了。
💡 画画叠色比喻:就像用蜡笔画画,先画蓝色的天空,再画白色的云,最后画黑色的鸟。每一层都覆盖在前一层上面,叠在一起就是一幅完整的画。绘制顺序就是"叠色的顺序"。
为什么沉积粒子要画在文字上面——视觉层次设计
你可能会问:为什么不让文字盖住沉积粒子?反过来看起来也挺有意思的啊。
这是一个视觉层次设计的问题。我们来想一下现实世界:
- 灰烬是"实物"——实实在在堆在地上的东西
- 文字是"背景装饰"——就像墙上写的标语
如果灰烬堆到了文字的高度,应该是灰烬盖住文字,还是文字穿透灰烬?
显然是灰烬盖住文字才对——就像你在墙上写了字,然后往墙根堆沙子,沙子越堆越高,慢慢盖住了墙上的字。这符合我们的生活常识,看起来自然。
如果反过来——文字穿透灰烬显示出来——就会给人一种"灰烬是虚的"、"文字是全息投影"的感觉,和我们想要的"地层堆积"效果不符。
所以绘制顺序的设计,本质上是视觉层次的设计:
- 最底层:背景、文字——被其他东西盖住
- 中间层:飞行粒子——在背景前面飞
- 最顶层:沉积粒子——实物堆积,盖住一切
💡 思考一下:如果我们要做一个"弹幕"效果(文字从屏幕上飞过,盖在粒子前面),那绘制顺序应该怎么调整?对,把文字挪到最后画就行!
Z 轴顺序的概念——2D 画面的深度感
虽然我们的屏幕是二维的(只有 X 轴和 Y 轴),但绘制顺序其实引入了第三个维度——Z 轴。
想象一下:
- X 轴:左右方向
- Y 轴:上下方向
- Z 轴:前后方向(从屏幕里指向你)
Z 值越大,东西越"靠前",越后画,越容易盖住别的东西。Z 值越小,东西越"靠后",越先画,越容易被盖住。
我们的绘制顺序对应到 Z 轴就是:
Z 值高(前) ↑ 沉积粒子(最后画,在最前面)
| 飞行粒子(中间)
| 标题文字(靠后)
Z 值低(后) ↓ 清空缓冲区(最底层)
这就是 2D 图形里"模拟深度"的基本方法——通过绘制顺序来决定谁在前、谁在后。在更复杂的游戏引擎里,每个精灵(sprite)都有一个 z-order 属性,引擎会自动按 Z 值排序后绘制,就是这个原理的延伸。
💡 小知识:在 3D 游戏里,这个概念更复杂——需要用深度缓冲(Z-Buffer)来记录每个像素的深度,精确判断哪个面在前面、哪个面在后面。但核心思想和我们的"后画覆盖先画"是一样的。
拧一拧:
- 把第 ② 步和第 ③ 步交换顺序,文字就会盖住沉积粒子
- 改回来,确认沉积粒子盖住文字
🧩 拼合步骤:从零件到成品
拼什么:把前四节的积木焊到第2篇完整版上
前面四节我们分别造好了四块积木——高度数组、触底沉积、两套粒子、绘制顺序。但单独的积木不会自己拼起来。这一节演示怎么把它们一步步焊到第2篇的完整版上。像拼乐高:每拼一块都能编译运行、看到效果,不会等到最后才发现哪里拼错了。
拼合起点:第2篇的完整版(键盘交互 + 自动烟花 + 双缓冲)。第3篇所有新东西都是在这上面往上加的。如果你手头没有第2篇的代码也没关系——下面第一步会给一份基于第2篇结构的完整可运行版本。
先看一下手里有几块积木:
| 序号 | 积木(小模块) | 学到了什么 | 对应章节 |
|---|---|---|---|
| 1 | 高度数组 | vector<int> 记录每列堆积量、下标访问、++ 自增 | 记住每一列堆多高 |
| 2 | 触底沉积 | 触底检测 p.y >= winHeight - 1 + 边界保护 + 沉积粒子存入 sedimentParticles | 让粒子落地后停下来 |
| 3 | 两套粒子分开管 | 飞行粒子 vs 沉积粒子、.clear() / .assign() 重置 | 两套粒子分开管 |
| 4 | 画在文字之上 | 绘制顺序控制(后画覆盖先画) | 画在文字之上 |
下面四步,每步都在上一步基础上加一块积木。
🧭 拼合顺序的设计思路
你可能会问:为什么拼合顺序是"高度数组 → 触底沉积 → 两套粒子 → 绘制顺序"?能不能反过来?比如先搞绘制顺序,再搞高度数组?
答案是:可以反过来,但那样会走很多弯路。好的拼合顺序就像盖房子——先打地基,再搭框架,再砌墙,最后装修。顺序反了不是不行,但会很别扭。
我们的拼合顺序遵循三个原则:
原则一:先数据,后逻辑
先有高度数组(数据),才能有触底沉积(逻辑)。没有高度数组,粒子落地了都不知道该堆在哪。就像先有仓库,才能往里面放东西。
原则二:先功能,后美观
先让粒子能堆起来(功能),再调整谁先画谁后画(美观)。堆积功能是核心,绘制顺序是锦上添花。就像先把蛋糕做出来,再考虑怎么裱花。
原则三:每一步都能运行
每拼一块都能编译运行、看到效果,不会等到最后才发现哪里拼错了。这叫渐进式开发——一步一个脚印,走一步验证一步。
遵循这三个原则,拼合过程就会很顺畅。出了问题也知道是哪一步引入的,容易排查。
📐 拼合第一步:在第2篇基础上加入高度数组
🎯 这一步要加什么?为什么这么拼?
加什么:高度数组 sedimentHeight + 沉积粒子列表 sedimentParticles(只声明,还没用来堆积)
为什么先加这个:
高度数组是整个沉积系统的"地基"——没有它,粒子落地了都不知道该堆在哪一行。就像盖房子先得有地皮和地基,不然砖往哪砌?
这一步我们只做"声明变量 + 简单计数",不做完整的沉积功能。为什么不一步到位?因为渐进式开发讲究步子小一点:先把数据结构搭好,验证它能工作,再往上加逻辑。一步到位容易一步错步步错。
从第2篇完整版出发,先把两块新数据的"地基"埋进去:声明 sedimentHeight(每列堆多高)和 sedimentParticles(存沉积粒子)。这一步粒子触底后仍然消失(和第2篇一样),但在消失前会让高度数组 +1——计数器接好了,只是还没把粒子存下来。同时留好一个画沉积粒子的循环(本步列表是空的,画不出东西),给第二步铺路。
下面是基于第2篇结构的完整可运行代码(简化版:q/w/e/r/t 五个键发射 + 自动烟花 + 高度数组记录):
#include <iostream>
#include <windows.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <conio.h>
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;
};
// 发射一朵烟花(第2篇的代码,原封不动)
void LaunchFirework(vector<Particle>& particles, double cx, double cy, int color = -1) {
if (color == -1) color = rand() % 14 + 1;
char sparkChars[] = {'*', '+', '.', 'o', 'O', '@', 'x', '~'};
int sparkCount = sizeof(sparkChars) / sizeof(sparkChars[0]);
int sparks = rand() % 50 + 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 = color;
p.ch = sparkChars[rand() % sparkCount];
p.active = true;
particles.push_back(p);
}
}
int main() {
HideCursor();
srand((unsigned)time(NULL));
// 获取窗口尺寸(第2篇的双缓冲)
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);
vector<Particle> particles;
particles.reserve(800);
// ★ 新增:高度数组 + 沉积粒子列表(本步只声明,还没用来堆积)
vector<int> sedimentHeight(winWidth, 0);
vector<Particle> sedimentParticles;
int frameCount = 0;
int launchDelay = rand() % 30 + 20;
while (true) {
// ① 键盘交互:按 q/w/e/r/t 在不同位置发射烟花
if (_kbhit()) {
int ch = _getch();
if (ch == 27) break; // ESC 退出
double cx = -1, cy = winHeight * 0.3;
switch (ch) {
case 'q': case 'Q': cx = winWidth * 0.2; break;
case 'w': case 'W': cx = winWidth * 0.4; break;
case 'e': case 'E': cx = winWidth * 0.5; break;
case 'r': case 'R': cx = winWidth * 0.6; break;
case 't': case 'T': cx = winWidth * 0.8; break;
}
if (cx >= 0) {
LaunchFirework(particles, cx, cy, (ch % 15) + 1);
}
}
// ② 清空缓冲区
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.y >= winHeight - 1) {
int bx = (int)(p.x + 0.5);
if (bx < 0) bx = 0;
if (bx >= winWidth) bx = winWidth - 1;
sedimentHeight[bx]++; // 计数器接好了
p.active = false; // 粒子消失——还没转成沉积粒子
continue;
}
if (p.life <= 0 || p.x < 0 || p.x >= winWidth || p.y < -1) {
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;
}
// ⑥ 画沉积粒子(本步列表为空,画不出东西——给第二步留好接口)
for (size_t i = 0; i < sedimentParticles.size(); ++i) {
Particle& sed = sedimentParticles[i];
int bx = (int)(sed.x + 0.5);
int by = (int)(sed.y + 0.5);
if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
int idx = by * winWidth + bx;
screenBuf[idx].Char.AsciiChar = sed.ch;
screenBuf[idx].Attributes = sed.color;
}
}
// ⑦ 输出
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;
}
变量名复习:sedimentHeight = sediment(沉积)+ Height(高度);sedimentParticles = sediment + Particles(沉积粒子集合)。
新增了什么(相对第2篇):
vector<int> sedimentHeight(winWidth, 0)—— 高度数组,每列初始为 0vector<Particle> sedimentParticles—— 沉积粒子列表(本步始终为空)- 触底时
sedimentHeight[bx]++—— 悄悄计数 - 一个画沉积粒子的
for循环(本步画不出东西,因为列表是空的——给第二步留好接口)
运行效果:画面和第2篇几乎一样——按 q/w/e/r/t 发射烟花,自动烟花也在放,粒子飞落、触底后消失。区别藏在内里:触底那一刻高度数组已经在偷偷 +1 了,只是 sedimentParticles 还是空的,所以看不见堆积。地基的钢筋埋好了,还没浇筑。
📊 和上一步比有什么不一样?
第一步是在第2篇基础上加的,所以"上一步"就是第2篇的完整版。我们来对比一下:
| 方面 | 第2篇(上一步) | 第一步(当前) |
|---|---|---|
| 高度数组 | 没有 | 有 sedimentHeight,触底时 ++ |
| 沉积粒子列表 | 没有 | 有 sedimentParticles,但一直是空的 |
| 粒子触底后 | 直接消失 | 还是消失,但消失前高度数组记了一笔 |
| 画沉积粒子 | 没有 | 有循环,但列表为空,画不出东西 |
| 画面效果 | 粒子触底消失 | 看起来一样,但内部已经在计数了 |
看起来变化不大?别急,这一步是"打地基"——表面上看不见,但是后面所有功能的基础。
⚡ 拼合第二步:粒子触底后停下来
🎯 这一步要加什么?为什么这么拼?
加什么:触底沉积逻辑——粒子不再消失,而是转入 sedimentParticles 变成沉积粒子
为什么第二步加这个:
第一步我们已经有了高度数组(地基),但光有地基没用,得往上面堆东西。这一步就是"砌砖"——粒子落地后真的堆起来,而不是悄悄记个数就消失了。
为什么不把"两套粒子分开管"和"绘制顺序"也一起在这一步加了?因为一次只改一个地方。触底沉积是核心功能,两套粒子和绘制顺序是"管理方式"和"视觉效果",属于不同层面的东西。混在一起加容易乱。
只改一个地方:触底的 if 块。第一步里触底只是 sedimentHeight[bx]++ 然后 p.active = false(消失)。现在改成:创建一个沉积粒子(速度归零、寿命 999999、位置放在该列已堆高度上),推进 sedimentParticles,再让原粒子消失。第一步留好的画沉积粒子循环,这下就有东西可画了。
// ……物理更新(p.vy += 0.08 等)不变,省略……
// ★ 触底:粒子停下来,转入沉积粒子列表(第一步是计数后直接消失)
if (p.y >= winHeight - 1) {
int bx = (int)(p.x + 0.5);
if (bx < 0) bx = 0; // 边界保护(钳制)
if (bx >= winWidth) bx = winWidth - 1;
if (sedimentHeight[bx] < winHeight) { // 这列还没堆满
Particle sed;
sed.x = (double)bx;
sed.y = (double)(winHeight - 1 - sedimentHeight[bx]); // 从底往上堆
sed.vx = 0; sed.vy = 0; // 不动了
sed.life = 999999; // 几乎不死
sed.color = p.color;
sed.ch = p.ch;
sed.active = true;
sedimentParticles.push_back(sed); // 存进沉积列表
sedimentHeight[bx]++; // 该列高度 +1
}
p.active = false; // 原粒子消失
continue;
}
// ……寿命/出界检测、画飞行粒子(不变,省略)……
新增了什么:触底时多了"创建沉积粒子 + push 到 sedimentParticles"几行。高度从"只计数"变成"计数 + 堆一个实物"。sed.y = winHeight - 1 - sedimentHeight[bx] 让新粒子从底往上堆——第一个落最底行,第二个落它上面,依此类推。
运行效果:烟花炸开后粒子纷纷落下,触底的粒子停住不动了,越堆越多。屏幕底部慢慢长出一层"灰烬"。
📊 和上一步比有什么不一样?
| 方面 | 第一步(上一步) | 第二步(当前) |
|---|---|---|
| 粒子触底后 | 高度数组 +1,然后粒子消失 | 创建沉积粒子,存入 sedimentParticles,原粒子消失 |
| 沉积粒子列表 | 一直是空的 | 越来越多,粒子堆在底部 |
| 画面效果 | 粒子触底消失(和第2篇一样) | 粒子触底后停下来,越堆越多 |
| 高度数组 | 只计数,不对应实物 | 计数 + 对应真实的沉积粒子 |
| 核心变化 | 数据结构准备好了 | 沉积功能真正跑起来了 |
这一步是变化最大的一步——从"看不见的计数"变成了"看得见的堆积"。地层效果第一次出现了!
🖌️ 拼合第三步:两套粒子分开画
🎯 这一步要加什么?为什么这么拼?
加什么:把"更新物理"和"绘制"拆开 + 引入文字层 + 调整绘制顺序
为什么第三步加这个:
前两步粒子已经能堆起来了,但绘制方式比较"粗糙"——物理更新和绘制挤在同一个循环里,想到哪画到哪。现在功能基本稳定了,该整理一下"绘制顺序"了,让画面层次更清晰。
为什么叫"两套粒子分开画"?因为这一步的核心变化是:飞行粒子的绘制从"物理更新循环里顺手画"变成了"单独的绘制循环"。这样做的好处是——绘制顺序完全由代码排列顺序决定,想让谁在上面,就把谁的绘制代码放后面。
就像整理房间:东西刚买回来时到处乱放(第二步),东西买齐了就该分门别类放好(第三步)。
第二步里飞行粒子的物理更新和绘制挤在同一个循环里,画沉积粒子的循环跟在后面,顺序是"清空 → 画飞行 → 画沉积 → 输出"。现在加入一行文字,并把绘制顺序理清:先清空 → 画文字 → 画沉积粒子 → 画飞行粒子 → 输出。沉积粒子画在文字之后(盖住文字),飞行粒子画在最后(从沉积上方飞过)。
为此要把"更新物理"和"绘制"拆开——物理循环只管算位置,不再顺手画粒子;绘制放到后面按顺序统一画。
// ① 清空缓冲区(不变)
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 块,原封不动)……
// ……寿命/出界检测(不变)……
// ——注意:原先写画粒子的 screenBuf[...] = p.ch 那几行删掉了——
}
// ……剔除失效粒子、自动烟花(不变,省略)……
// ③ 画文字(最底层,新增)
string text = "Particle Stratum";
int startX = (winWidth - (int)text.length()) / 2;
int textY = 0;
for (size_t j = 0; j < text.length(); ++j) {
int x = startX + (int)j;
if (x >= 0 && x < winWidth) {
screenBuf[textY * winWidth + x].Char.AsciiChar = text[j];
screenBuf[textY * winWidth + x].Attributes = 11;
}
}
// ④ 画沉积粒子(盖住文字——后画覆盖先画)
for (size_t i = 0; i < sedimentParticles.size(); ++i) {
Particle& sed = sedimentParticles[i];
int bx = (int)(sed.x + 0.5);
int by = (int)(sed.y + 0.5);
if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
int idx = by * winWidth + bx;
screenBuf[idx].Char.AsciiChar = sed.ch;
screenBuf[idx].Attributes = sed.color;
}
}
// ⑤ 画飞行粒子(最后画,从沉积上方飞过)
for (size_t i = 0; i < particles.size(); ++i) {
Particle& p = particles[i];
if (!p.active) 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;
}
}
// ⑥ 输出(不变,省略)
// ……WriteConsoleOutput……
绘制顺序的设计:
- 文字最先画(底层)——标题、日期、诗句都在这里
- 沉积粒子在文字之后画(盖住文字——符合"画在文字之上"那节的原则)
- 飞行粒子最后画(从沉积上方飞过,因为它是动态的、一闪而过,不该被沉积盖住)
运行效果:屏幕顶部显示一行文字,底部沉积粒子稳定堆积并盖住下方文字,飞行粒子从沉积上方飞过——两层粒子各管各的,互不干扰。
📊 和上一步比有什么不一样?
| 方面 | 第二步(上一步) | 第三步(当前) |
|---|---|---|
| 物理更新和绘制 | 挤在同一个循环里 | 分开了——先更新物理,再统一绘制 |
| 文字层 | 没有 | 新增一行文字(标题) |
| 绘制顺序 | 清空 → 飞行粒子 → 沉积粒子 | 清空 → 文字 → 沉积粒子 → 飞行粒子 |
| 谁在最上面 | 沉积粒子 | 飞行粒子(从沉积上方飞过) |
| 代码结构 | 简单直接,但绘制顺序不灵活 | 结构清晰,绘制顺序完全可控 |
这一步功能上没加新东西(粒子还是那些粒子),但结构上更清晰了。把"更新"和"绘制"拆开,是写复杂程序的重要一步。
🔧 拼合第四步:收尾打磨
🎯 这一步要加什么?为什么这么拼?
加什么:窗口自适应重置 + 颜色多样化 + 标题日期诗句 + 最终绘制顺序调整
为什么最后一步加这些:
这些都是"收尾"的工作——核心功能(高度数组、触底沉积、两套粒子、绘制顺序)都在前几步搞定了,最后一步把细节打磨好,让程序更完整、更好用。
为什么不早加?因为这些都是"锦上添花"的东西:
- 窗口自适应——没有它也能跑,只是窗口变了会崩
- 颜色多样化——没有它也能堆,只是颜色单调
- 标题日期诗句——没有它也能看,只是不够美观
先把核心功能做对,再打磨细节——这就是先跑起来,再优化的开发原则。
还差几块小积木,凑齐就是完整版:
- 窗口自适应:窗口大小变化时,用
sedimentHeight.assign(winWidth, 0)重建高度数组、sedimentParticles.clear()清空沉积粒子、screenBuf.assign(winWidth * winHeight, CHAR_INFO())重建缓冲区(对应"两套粒子分开管"那节的重置逻辑) - 颜色多样化:自动烟花用
rand() % 14 + 1随机颜色,按键发射用(ch % 15) + 1按键映射颜色 - ESC 退出:
if (ch == 27) break;(第一步代码里已经有了) - 标题日期诗句:屏幕顶部循环显示标题框、当前日期、随机诗句——第2篇已有的文字绘制搬过来,放在画沉积粒子之前
- 绘制顺序最终调整:完整版把沉积粒子挪到最后画(覆盖文字和飞行粒子),让灰烬盖住一切——这正是"画在文字之上"那节的最终设计
把这些拼进去,就得到下面的完整版。
📊 和上一步比有什么不一样?
| 方面 | 第三步(上一步) | 第四步(当前,完整版) |
|---|---|---|
| 窗口自适应 | 没有(窗口变了会出问题) | 有——变化时重建高度数组、清空沉积粒子 |
| 文字内容 | 只有一行简单文字 | 标题框 + 日期 + 随机诗句 |
| 颜色 | 比较单一 | 自动烟花随机色、按键发射按键色 |
| 绘制顺序 | 飞行粒子在最上面 | 沉积粒子在最上面(灰烬盖住一切) |
| 完整度 | 核心功能齐全,细节欠缺 | 完整版,可以当成品用 |
第四步之后,程序就从"演示用的半成品"变成了"可以玩的完整版"。所有积木都拼好了,地层堆积效果完整呈现!
🏆 完整版出炉
把沉积系统拼进第二篇的完整版里,就得到了 TypingSparksKeyzoneStratum.cpp,一共 352 行。和第二篇相比只新增了 20 行,但引入了一个全新的子系统。
| 模块 | 零件 | 完整版位置 |
|---|---|---|
| 高度数组 | vector<int> sedimentHeight | 第 166 行声明 · 第 199 行重置 |
| 沉积粒子 | vector<Particle> sedimentParticles | 第 165 行声明 · 第 200 行清空 |
| 触底检测 | p.y >= winHeight - 1 + 边界保护 | 第 220-238 行 |
| 沉积粒子创建 | sed.life = 999999 + sedimentHeight[bx]++ | 第 226-236 行 |
| 绘制顺序 | 文字在前、沉积在后 | 第 329-338 行 |
下面是完整源码,352 行:
#include <iostream>
#include <windows.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <cstdio>
#include <conio.h>
#include <map>
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;
};
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) {
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;
default: return false;
}
return true;
}
return false;
}
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);
// ★ 沉积系统(无限堆积,满屏为止)
vector<Particle> sedimentParticles;
vector<int> sedimentHeight(winWidth, 0);
int frameCount = 0;
int launchDelay = rand() % 30 + 20;
int poemIdx = 0;
int poemChangeFrames = 0;
const int POEM_DURATION = 100;
while (true) {
if (_kbhit()) {
int ch = _getch();
if (ch == 27) break;
double cx, cy;
if (GetFireworkPosition(ch, winWidth, winHeight, cx, cy)) {
int keyColor = (ch % 15) + 1;
LaunchFirework(particles, cx, cy, keyColor);
}
}
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());
sedimentHeight.assign(winWidth, 0);
sedimentParticles.clear();
}
// 清屏
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.y >= winHeight - 1) {
int bx = (int)(p.x + 0.5);
if (bx < 0) bx = 0;
if (bx >= winWidth) bx = winWidth - 1;
if (sedimentHeight[bx] < winHeight) {
Particle sed;
sed.x = (double)bx;
sed.y = (double)(winHeight - 1 - sedimentHeight[bx]);
sed.vx = 0; sed.vy = 0;
sed.life = 999999;
sed.color = p.color;
sed.ch = p.ch;
sed.active = true;
sedimentParticles.push_back(sed);
sedimentHeight[bx]++;
}
p.active = false;
continue;
}
if (p.life <= 0 || p.x < 0 || p.x >= winWidth || p.y < -1) {
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 titleH = bigTitle.size();
int titleY = winHeight / 5;
for (int i = 0; i < titleH; ++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 + titleH + 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 (size_t i = 0; i < sedimentParticles.size(); ++i) {
Particle& sed = sedimentParticles[i];
int bx = (int)(sed.x + 0.5);
int by = (int)(sed.y + 0.5);
if (bx >= 0 && bx < winWidth && by >= 0 && by < winHeight) {
int idx = by * winWidth + bx;
screenBuf[idx].Char.AsciiChar = sed.ch;
screenBuf[idx].Attributes = sed.color;
}
}
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;
}
运行效果:运行后可看到键盘交互烟花——按任意字母/数字键在该键位置炸开一朵烟花,粒子落地后堆积成地层;屏幕顶部循环显示标题、日期和诗句,沉积粒子覆盖在文字之上,越堆越高直到满屏。按 Esc 退出。
🐧 Linux / macOS 用户看这里
下面是一个完整的 ncurses 版沉积烟花程序,涵盖本篇所有核心概念:粒子飞行、触底沉积、高度数组、沉积粒子覆盖在文字之上。
编译运行(需要先安装 ncurses):
# Ubuntu/Debian sudo apt install libncurses5-dev # macOS brew install ncurses g++ stratum_ncurses.cpp -o stratum_ncurses -lncurses ./stratum_ncurses操作说明:按 1~5 在屏幕不同列发射烟花,按 q 退出。
#include <ncurses.h> #include <vector> #include <cmath> #include <cstdlib> #include <ctime> // ---------- 粒子结构 ---------- struct Particle { double x, y; // 位置(用 double 实现平滑运动) double vx, vy; // 速度 int life; // 寿命 int color; // 颜色编号 char ch; // 显示字符 }; // ---------- 全局数据 ---------- std::vector<Particle> flying; // 飞行中的粒子 std::vector<Particle> sediment; // 已沉积的粒子 std::vector<int> sedimentHeight; // 每列沉积高度 int winWidth, winHeight; // ---------- 发射一朵烟花 ---------- void spawnFirework(int col) { int count = 30 + rand() % 20; // 30~50 个粒子 for (int i = 0; i < count; i++) { Particle p; p.x = col; p.y = winHeight / 3; // 从屏幕上方 1/3 处炸开 double angle = (rand() % 360) * 3.14159 / 180.0; double speed = 1.0 + (rand() % 30) / 10.0; // 1.0~3.9 p.vx = cos(angle) * speed; p.vy = sin(angle) * speed * 0.6; // 竖直方向稍弱 p.life = 80 + rand() % 60; p.color = 1 + rand() % 6; // 1~6 号颜色 p.ch = "*o.+x"[rand() % 6]; flying.push_back(p); } } // ---------- 更新物理 ---------- void update() { // 1. 更新飞行粒子 for (size_t i = 0; i < flying.size(); ) { Particle& p = flying[i]; p.vy += 0.08; // 重力 p.x += p.vx; p.y += p.vy; p.life--; int col = (int)round(p.x); int row = (int)round(p.y); // 2. 触底沉积检测 int ground = winHeight - 1 - sedimentHeight[col]; if (row >= ground && p.vy > 0) { // 转入沉积 p.y = ground; // 对齐到地面 p.vx = 0; p.vy = 0; sediment.push_back(p); if (col >= 0 && col < winWidth) { sedimentHeight[col]++; // 该列高度 +1 } flying.erase(flying.begin() + i); continue; } // 寿命耗尽或飞出屏幕 → 删除 if (p.life <= 0 || col < 0 || col >= winWidth || row < 0) { flying.erase(flying.begin() + i); continue; } i++; } } // ---------- 绘制 ---------- void draw() { clear(); // 第 ① 层:顶部文字(最底层) const char* title = "Stratum — 沉积烟花"; attron(COLOR_PAIR(7)); // 白色 mvprintw(0, (winWidth - (int)strlen(title)) / 2, "%s", title); mvprintw(1, 2, "按 1~5 发射烟花 | 按 q 退出"); attroff(COLOR_PAIR(7)); // 第 ② 层:飞行粒子(中间层) for (const auto& p : flying) { int col = (int)round(p.x); int row = (int)round(p.y); if (row >= 0 && row < winHeight && col >= 0 && col < winWidth) { attron(COLOR_PAIR(p.color)); mvaddch(row, col, p.ch); attroff(COLOR_PAIR(p.color)); } } // 第 ③ 层:沉积粒子(顶层,覆盖文字和飞行粒子) for (const auto& p : sediment) { int col = (int)round(p.x); int row = (int)round(p.y); if (row >= 0 && row < winHeight && col >= 0 && col < winWidth) { attron(COLOR_PAIR(p.color)); mvaddch(row, col, p.ch); attroff(COLOR_PAIR(p.color)); } } refresh(); } // ---------- 主函数 ---------- int main() { srand((unsigned)time(nullptr)); // 初始化 ncurses initscr(); cbreak(); noecho(); curs_set(0); nodelay(stdscr, TRUE); // 非阻塞输入 keypad(stdscr, TRUE); if (has_colors()) { start_color(); init_pair(1, COLOR_RED, COLOR_BLACK); init_pair(2, COLOR_GREEN, COLOR_BLACK); init_pair(3, COLOR_YELLOW, COLOR_BLACK); init_pair(4, COLOR_BLUE, COLOR_BLACK); init_pair(5, COLOR_MAGENTA, COLOR_BLACK); init_pair(6, COLOR_CYAN, COLOR_BLACK); init_pair(7, COLOR_WHITE, COLOR_BLACK); } getmaxyx(stdscr, winHeight, winWidth); // 初始化高度数组 sedimentHeight.assign(winWidth, 0); // 主循环 while (true) { int ch = getch(); if (ch == 'q' || ch == 'Q') break; // 按 1~5 在不同位置发射烟花 if (ch >= '1' && ch <= '5') { int pos[] = { winWidth / 6, winWidth / 3, winWidth / 2, winWidth * 2 / 3, winWidth * 5 / 6 }; spawnFirework(pos[ch - '1']); } update(); draw(); napms(30); // 约 33 FPS } endwin(); return 0; }和 Windows 版的对照关系:
概念 Windows 控制台 Linux/macOS (ncurses) 粒子结构 struct Particle完全相同 飞行/沉积分离 两个 vector完全相同 高度数组 vector<int> sedimentHeight完全相同 物理更新 vy += 0.08等完全相同 触底检测 y >= bottom完全相同 绘制顺序 往缓冲区写,后写覆盖先写 调用 mvaddch,后调用覆盖先调用画面刷新 WriteConsoleOutputWrefresh()键盘输入 _kbhit()+_getch()getch()+nodelay颜色 WORD wAttributesinit_pair+COLOR_PAIR可以看到:算法和数据结构完全一致,只是渲染 API 换了一套名字。 这就是"逻辑与渲染分离"的好处——核心思想不受平台限制。
加点料:
- 给沉积粒子加"重力滑坡":如果某一列比相邻列高出 5 以上,把顶部粒子滑到矮的那列
- 给沉积系统加一个"风力侵蚀"效果:每隔 1000 帧,随机清除几个沉积粒子
✨ 架构总结
这一篇在第二篇的五层架构上,把数据层拆成了两个子系统:
| 层 | 职责 | 对应代码 |
|---|---|---|
| 输入层 | 监听键盘 + 映射位置 + 触发烟花 | _kbhit · _getch · keyPositionMap |
| 数据层(飞行) | 飞行粒子的状态 | vector<Particle> particles |
| 数据层(沉积) | 沉积粒子的状态 + 高度记录 | vector<Particle> sedimentParticles · vector<int> sedimentHeight |
| 物理层 | 每帧更新飞行粒子位置和生命 + 触底检测 | vy += 0.08 · 触底沉积逻辑 |
| 渲染层 | 把飞行粒子和沉积粒子都写入缓冲区 | screenBuf · 绘制顺序:先文字后沉积 |
| 展示层 | 缓冲区输出到屏幕 + 文字绘制 | WriteConsoleOutput |
数据层从"一套粒子"变成了"两套粒子",这就是状态分离。飞行粒子是动态的——每帧都在动、在消失;沉积粒子是静态的——不动、不消失。两种完全不同的行为放在同一个容器里会互相干扰,分开后各自清晰。
主循环从十步变成了十二步:
① 检查键盘输入 → ② 获取窗口尺寸 → ③ 清空缓冲区 → ④ 更新飞行粒子(含触底沉积)
→ ⑤ 剔除失效粒子 → ⑥ 自动发射烟花 → ⑦ 获取日期/轮换诗句
→ ⑧ 绘制标题/日期/诗句 → ⑨ 绘制沉积粒子 → ⑩ WriteConsoleOutput 输出
→ ⑪ Sleep(30) → 回到 ①
注意第 ⑨ 步在 ⑧ 之后——沉积粒子画在文字上面。
📖 头文件汇总
和第二篇相比没有新增头文件,所有功能都使用已有的库实现:
| 头文件 | 提供了什么 | 代码里用到的 |
|---|---|---|
<iostream> | 输入输出 | cout、string |
<windows.h> | Windows 控制 API | GetStdHandle、WriteConsoleOutput、Sleep 等 |
<vector> | 动态数组 | vector、push_back、reserve、size、assign、clear |
<cmath> | 数学函数 | cos、sin、M_PI |
<cstdlib> | 通用工具 | rand、srand |
<ctime> | 时间函数 | time、localtime、tm |
<cstdio> | C 标准输入输出 | sprintf |
<conio.h> | 控制台输入 | _kbhit、_getch |
<map> | 映射容器 | map、find |
🔜 下一篇预告
粒子会堆积了,但堆满屏幕后就什么都不发生了。能不能在堆满时触发一次"雪崩"——所有沉积粒子全部复活,向上喷涌成新一轮烟花?下一篇就是这个系列的终章。
🔧 适用边界
| 平台 | 适用性 | 说明 |
|---|---|---|
| Windows 7+ | 完全支持 | 原生 Windows Console API |
| MinGW-w64(g++) | 完全支持 | 推荐编译方式 |
| Visual Studio(MSVC) | 完全支持 | 需包含 <windows.h> 等系统头文件 |
| Linux / macOS | 不支持 | 依赖 Windows Console API,无法直接运行 |
沉积系统本身不引入新平台依赖,但底层仍依赖 Windows Console API。
已知限制:
- 沉积粒子的
life设为999999(近似永久),程序长时间运行时沉积粒子数量会持续增长,可能影响性能 - 窗口尺寸变化时沉积粒子会被清空重建(
sedimentParticles.clear())
关键词:C++ 粒子沉积 vector<int> 状态分离 clear assign 绘制顺序 双缓冲 命令行动画 Windows Console API
▼

33

被折叠的 条评论
为什么被折叠?



