[特殊字符]️ 【地层堆积】C++ CMD 动画:粒子落下来堆成一座山

🏔️ 【地层堆积】C++ CMD 动画:粒子落下来堆成一座山

C++ CMD 动画编程实战:从零拼出粒子烟花 · 第 3 篇

📌 摘要:本文在第 2 篇键盘交互烟花基础上加入粒子沉积系统——落到底部的粒子不再消失,而是堆积起来形成"地层",越堆越高直到满屏。内容覆盖 vector<int> 高度数组、触底检测、边界保护(钳制)、状态分离设计思想、.clear() 清空容器、绘制顺序控制(后画覆盖先画)等。全文仍采用渐进式教学,每节给出完整可编译程序。适用读者:建议先阅读第 1、2 篇。

📚 目录

  1. 记住每一列堆多高vector<int> 高度数组、下标访问、++ 自增
  2. 让粒子落地后停下来 — 触底检测 p.y >= winHeight - 1、边界保护(钳制)、沉积粒子创建
  3. 两套粒子分开管 — 状态分离设计思想、.clear() 清空容器、.assign() 重置
  4. 画在文字之上 — 绘制顺序控制(后画覆盖先画)
  5. 拼合步骤:从零件到成品 — 四步拼出成品
  6. 完整版出炉TypingSparksKeyzoneStratum.cpp(352 行完整源码)
  7. 架构总结 — 五层架构、数据层拆分为飞行/沉积两个子系统、主循环十二步
  8. 头文件汇总 — 九个头文件及其用途
  9. 下一篇预告 — 雪崩效果

🛠️ 编译环境:和前两篇相同(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);

这行代码看起来简单,但做了三件事:

  1. 创建容器vector<int> 告诉编译器"我要一个装整数的动态数组"
  2. 指定大小:括号里第一个参数 winWidth 是数组的长度——屏幕有多宽,数组就有多少个元素
  3. 初始化为 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 后置

你可能在别的地方见过两种写法:++xx++。它们都是让变量加 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]++

🎯 我们要做什么

上一节我们建好了高度数组——就像给每列装了一把尺子。但尺子光摆在那儿没用,得有东西去"读"它、"写"它才行。

这一节我们要做三件事:

  1. 检测粒子什么时候落地——粒子飞着飞着,怎么知道它已经碰到地面了?
  2. 把落下来的粒子存起来——落地的粒子不能消失,要变成"沉积粒子"留在原地
  3. 保护边界不出错——万一粒子飞出了屏幕左右边界,我们得把它拉回来,不能让程序崩掉

做完这三件事,你就能看到烟花粒子落下来后,像沙子一样在底部堆积起来——这就是"地层"的雏形。


有了高度数组,下一步是:当粒子落到底部时,把它变成沉积粒子,而不是让它消失。

怎么判断粒子"触底"了?第一篇里粒子飞出屏幕底部时直接 p.active = false(消失)。现在改成:如果 p.y >= winHeight - 1(到达最后一行),就触发沉积。winHeight - 1 是因为行号从 0 开始算——屏幕高 25 行,最后一行的行号是 24(即 winHeight - 1)。

触底后做三件事:

  1. 算出落在哪一列:int bx = (int)(p.x + 0.5);——四舍五入取整数列号,第一篇讲过这个技巧
  2. 创建沉积粒子:把速度设为 0(不动了),寿命设为 999999(几乎不死),位置放在该列已经堆了的高度上
  3. 高度数组加 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, &region);

        frames++;
        if (frames > 300) break;
        Sleep(30);
    }
    return 0;
}

Particle& sed = sedimentParticles[i]; 里的 & 是引用——第一篇讲过,直接操作原件不用复印件。sedsediment(沉积)的缩写。

变量名拆解:sed = sediment(沉积),sedimentHeight = sediment + Height(沉积高度),sedimentParticles = sediment + Particles(沉积粒子集合)。

运行效果:运行后你会看到烟花炸开后,粒子纷纷落下,触底的粒子停在那里不动了,越堆越多。

🐧 Linux / macOS 用户看这里

触底检测、沉积粒子的物理和逻辑都是标准 C++,全平台通用。只有画粒子的方式不同:

Windows 控制台Linux/macOS (ncurses)
写字符到 screenBuffer 数组调用 mvaddch(y, x, '*')
WriteConsoleOutputW 一次性刷新refresh() 刷新屏幕

物理更新(vy += gravityx += vxy += 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,让它变成沉积粒子?这一节我们就来回答这个问题。

想象一个电影院:

  • 有的观众正在进场找座位——走来走去,位置随时在变
  • 有的观众已经坐好不动了——安安静静看电影,位置固定

如果把这两类人混在一个名单里管理,工作人员每次找人都得先判断"这个人是在走还是已经坐下了",多麻烦。更好的办法是:用两张名单,一张管"正在进场的人",一张管"已经入座的人",各管各的。

我们的粒子系统也是一样的道理。这一节我们要搞清楚:

  1. 为什么飞行粒子和沉积粒子要分开存——两个 vector 比一个好在哪里
  2. 窗口变化时怎么重置——窗口大小变了,沉积粒子怎么办
  3. .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 (粒子是沉积的) {
        // 什么都不做,因为沉积粒子不动
    }
}

看起来也能跑,对吧?但问题是:

  1. 效率低:沉积粒子明明不用动,却每帧都要被检查一遍——就像电影院里工作人员每次都要跑过去问"你坐好了吗?",多浪费时间
  2. 代码乱:飞行和沉积的逻辑混在一起,时间长了就像一团乱麻——改一个地方可能影响另一个
  3. 容易出 bug:万一某个 if 条件写错了,沉积粒子突然飞起来了,或者飞行粒子突然不动了,排查起来很麻烦

把它们分成两个 vector 之后:

  • particles —— 只管飞的,每帧更新物理
  • sedimentParticles —— 只管沉积的,不用更新物理(直接画就行)

各管各的,互不干扰。这就是状态分离(Separation of Concerns)的设计思想——把不同状态、不同职责的东西分开管理,每个部分只做一件事。

💡 电影院比喻延伸:飞行粒子就像"正在找座位的观众"——一直在动,位置不固定;沉积粒子就像"已经入座的观众"——坐好了就不动了。用两张名单分开管理,比混在一起清晰得多。

.clear() 和 .assign() 的区别——清空 vs 重置填充

.clear().assign() 都能让 vector 变"空",但它们做的事情不一样:

方法做了什么结果类比
.clear()把所有元素删掉,容器变成空的size = 0,里面什么都没有把抽屉里的东西全部倒掉,抽屉空了
.assign(n, val)先清空旧内容,再填入 n 个 valsize = 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

🎨 画在文字之上

拼什么:绘制顺序控制——先画文字,后画沉积粒子

🎯 我们要做什么

你有没有画过水彩画?先画背景的天空和远山,再画中间的树木和房子,最后画前景的小花和小草——后画的东西会盖住先画的,这样才有层次感。

我们的屏幕缓冲区也是一样的道理:同一个格子被画两次,后画的会覆盖先画的。所以谁先画、谁后画,决定了谁在上面、谁在下面。

这一节我们要搞清楚:

  1. 绘制顺序怎么影响画面——后画的为什么能盖住先画的
  2. 沉积粒子为什么要画在文字上面——灰烬盖住文字才自然
  3. 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, &region);

        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) —— 高度数组,每列初始为 0
  • vector<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>输入输出coutstring
<windows.h>Windows 控制 APIGetStdHandleWriteConsoleOutputSleep
<vector>动态数组vectorpush_backreservesizeassignclear
<cmath>数学函数cossinM_PI
<cstdlib>通用工具randsrand
<ctime>时间函数timelocaltimetm
<cstdio>C 标准输入输出sprintf
<conio.h>控制台输入_kbhit_getch
<map>映射容器mapfind

🔜 下一篇预告

粒子会堆积了,但堆满屏幕后就什么都不发生了。能不能在堆满时触发一次"雪崩"——所有沉积粒子全部复活,向上喷涌成新一轮烟花?下一篇就是这个系列的终章。


🔧 适用边界

平台适用性说明
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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值