眼瞅着2026年春节就快到跟前了,周围同事群里已经开始讨论抢票、年货、给亲戚家小孩准备礼物这些事。作为一个常年跟C++打交道的程序员,我总想用自己的方式送点不一样的祝福。代码能做什么?写个控制台版的字符烟花,再顺手做一个Windows图形版,让屏幕上的烟花炸开成“新年快乐”的样子,发给朋友或者自己跑一跑,比群发的祝福文案有温度多了。
这个项目听起来小,但实际动手会发现里面塞满了C++里非常核心的东西:结构体设计、STL容器、随机数引擎、指针与数组的关系、帧循环、以及一个最简单的粒子系统。把一个“春节烟花祝福”拆开来看,本质上就是写一个粒子系统模拟器,只不过输出目标从游戏引擎里的GPU变成了控制台字符流或者GDI绘制。这篇就把我整个做项目的过程、踩过的坑、关键的代码片段和排查思路完整记录下来,想复现的朋友可以直接照着抄。
1. 项目整体设计与思路拆解
1.1 为什么选C++来做烟花
网上想实现烟花效果,方案其实非常多:Python的pygame、网页的Canvas、甚至是Excel动画。但我个人还是喜欢用C++做这种小项目,原因很实在。
第一,性能足够顶。烟花粒子动辄几百上千个,每个粒子每一帧都要更新坐标、速度、生命周期,控制台版用std::vector遍历更新,Win32版配合GDI双缓冲,哪怕在老机器上跑起来也是丝般顺滑。C++在这一类“大量小型对象高频更新”的场景下几乎不需要额外优化,直接写就能跑得很稳。
第二,标准库够用。整个项目其实不需要任何第三方库,Windows版调用的是系统自带的Win32 API,控制台版只依赖<iostream>、<vector>、<random>、<thread>这些标准头文件。这意味着在纯C++环境下就能完成,对新手来说没有环境上的负担。
第三,适合学习硬核知识点。热搜词列表里那些高频问题——c++ 指针、c++ 结构体链表、c++ 多维数组、vscode配置c/c++环境——在这个小项目里几乎都能碰到。画布用二维字符数组,数组名退化成指针传参;粒子用结构体数组管理;多线程发射烟花时自然引入std::thread;随机数生成可以对比rand()和<random>库的差异。这些不是生硬的学习,而是为了解决实际问题顺手就学会了。
有人说这种项目用Python十行代码就搞定了,没必要上C++。我不否认Python的简洁,但C++这条路能让你真正理解底层发生了什么——比如二维数组在内存中是怎么排布的,为什么传参时会退化成指针,为什么每次更新画面要清理缓冲区。做完这个项目,这些概念不需要死记硬背,全变成肌肉记忆了。
1.2 烟花效果的本质是粒子系统
很多人第一次听到“粒子系统”觉得很高大上,其实拆穿了就四个要素:位置、速度、生命周期、外观。拿烟花的视觉过程来看,从发射到消失可以拆成三个阶段。
第一个阶段是上升。一枚烟花弹从屏幕底部以较高的初速度向上飞,此时它的速度方向几乎竖直向上,重力让它在上升过程中缓慢减速。这个阶段对应粒子系统里的“发射器”概念,每一段时间就产生一个新的粒子,并给它一个初始位置和初速度。
第二个阶段是爆炸。当烟花弹到达预定高度后,它会生成大量子粒子。这些子粒子的初始位置相同,但速度方向呈圆形均匀分布,速度大小也不完全一样。这是整个系统中最核心的逻辑:如何生成“炸开”的效果。
第三个阶段是飘散与消失。爆炸产生的粒子会受到重力影响,轨迹呈现抛物线形态,同时粒子有一个生命周期,比如1.5秒后就会从画面上消失。为了让效果更真实,可以让粒子在生命后期逐渐变暗,或者在画面中逐渐缩短移动轨迹。
把这三个阶段抽象出来,落到C++代码里就是一个Particle结构体的状态机,或者更简单粗暴一点,在更新逻辑里用if判断粒子当前处于哪个阶段,然后执行不同的位移逻辑。这是我自己常用的做法,因为烟花粒子的状态转换是单向且确定的,不需要引入复杂的状态模式。
1.3 两条技术路线:控制台版与图形版
我在实际动手时做了两个版本,原因是两个版本的技术侧重点完全不同。
控制台版只依赖Windows控制台API,输出的是彩色字符,画面由字符矩阵构成。这个版本技术含量集中在二维数组操作、光标定位、字符颜色控制上,同时也比较好调试,因为每一步输出都能看得清清楚楚。它非常适合新手学习,代码量控制在两百行左右,一晚上就能搞定。
图形版则基于Win32窗口和GDI绘图,画面达到了像素级,效果远好于字符版。这个版本的难点在于窗口消息循环的理解、双缓冲绘图防止闪烁、以及在GDI中高效地绘制圆形粒子。它的代码量更大,但作为送给朋友的“礼物”,视觉效果足以让人眼前一亮。
建议第一次做的人先做控制台版。原因很简单:控制台版的代码逻辑与图形版完全一致,只是最终输出方式不同。把控制台版搞明白了,图形版的核心逻辑就只是“换一个画笔”的问题。如果上来就直接写图形版,一遇到窗口消息、设备上下文、刷新机制这些概念,很容易被绕晕,反而不利于理解项目本质。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 随机数引擎与分布:别再用rand()
烟花效果里大量使用随机数,比如爆炸时粒子的方向角度、速度大小、颜色选择,都需要“随机”。很多初学者会习惯性用rand(),但这个函数在C++里其实已经是“过时”的方案了。
rand()的问题在于:它是一个线性同余生成器,产生的随机数质量一般,而且它是全局状态,在多线程环境下会互相干扰。<random>标准库提供了更好的引擎和分布类。我在项目里使用的是std::mt19937,它基于梅森旋转算法,周期长达2的19937次方减1,质量远高于rand。
具体写法如下:
cpp复制#include <random>
std::random_device rd; // 用于获取真随机种子(通常来自硬件)
std::mt19937 gen(rd()); // 用种子初始化梅森旋转引擎
std::uniform_real_distribution<float> angleDist(0.0f, 2.0f * 3.14159265f);
std::uniform_real_distribution<float> speedDist(50.0f, 150.0f);
// 使用时:
float angle = angleDist(gen);
float speed = speedDist(gen);
float vx = std::cos(angle) * speed;
float vy = std::sin(angle) * speed;
这里有个小坑需要注意:std::random_device在某些环境下可能退化为伪随机数生成器,导致每次运行结果一样。如果发现烟花爆炸方向每次都完全相同,可以把种子改成std::chrono::steady_clock::now().time_since_epoch().count(),以当前时间作为随机种子。虽然这不是最完美的方案,但对这个项目来说绰绰有余。
2.2 坐标系统与字符画布:二维数组与指针的纠缠
控制台版画面的核心数据结构是一块二维字符画布。我习惯把它定义成全局的二维数组:
cpp复制const int COLS = 80; // 列数,对应控制台宽度
const int ROWS = 30; // 行数,对应控制台高度
char screen[ROWS][COLS];
这里就牵扯到热搜词里那个高频问题——“c++ 多维数组 指针”。当你需要把画布传给一个函数时,数组名会退化为指向其第一个元素的指针。不同维度退化的结果不一样,比如screen作为参数传递时类型会变成char (*)[COLS],这是一个指向长度为COLS的字符数组的指针。
更好的方案是用vector来管理画布:
cpp复制std::vector<std::vector<char>> screen(ROWS, std::vector<char>(COLS, ' '));
这样传参就非常简单了,直接用const std::vector<std::vector<char>>&或者std::vector<std::vector<char>>&传引用即可,不用纠结指针语法。缺点是比裸数组稍慢一点点,但在这个项目里根本不是性能瓶颈。我给的建议是:如果是学习指针与数组的关系,用裸数组走一遍;如果追求开发效率和可读性,用vector更舒服。
坐标系统的设计还有一个很容易踩的坑:数学中的坐标系Y轴朝上,而屏幕坐标系的Y轴朝下(第一行是顶部)。这意味着粒子在更新时,如果物理公式里Y轴速度是正值表示向上,那么映射到屏幕上要取反,或者定义初始速度时直接让Y轴速度取负值。我在这个项目里采用的约定是:y表示屏幕坐标中的行号,0表示顶部;重力加速度为正值,每帧让粒子的速度vy += gravity,这样粒子就会向下坠落。
2.3 数据结构设计:结构体、数组与vector的选择
粒子结构体我在项目中是这样写的:
cpp复制struct Particle {
float x; // 横坐标,浮点数方便做物理运算
float y; // 纵坐标
float vx; // X轴速度
float vy; // Y轴速度
int life; // 生命周期(剩余帧数)
int color; // 颜色编码
bool active; // 是否存活
};
为什么用struct而不是class?因为粒子只是一个纯数据集合,不需要封装行为,也没有私有成员。用struct语义上更直白,访问成员直接就用p.x这种形式,看着舒服,写起来也顺手。
管理粒子的容器我推荐直接使用std::vector<Particle>。很多人一说到粒子集合就会想到链表,因为“粒子会动态创建和销毁”。但仔细想想,这个场景里粒子的增删是连续内存上的简单操作,用vector完全没有问题。
使用vector<Particle>时有两个优化点值得注意:
第一,提前用reserve预留容量。一个烟花爆炸会生成几十到几百个粒子,程序运行时粒子总数可能达到上千,如果不reserve,vector会频繁扩容导致性能下降。我一般在初始化时particles.reserve(2048),一次性预留足够空间。
第二,删除粒子时不要用erase,因为在循环中erase会导致迭代器失效,而且会频繁搬移内存。更高效的做法是“标志位+交换删除”:
cpp复制// 收集存活粒子到临时数组
std::vector<Particle> alive;
alive.reserve(particles.size());
for (auto& p : particles) {
if (p.active) alive.push_back(p);
}
particles.swap(alive);
这个方案在粒子数量上千时依然飞快,代码逻辑也非常直观。
2.4 帧循环与时间步长:为什么画面会“动”
整个程序的核心是一个帧循环。控制台版用Sleep控制帧率,图形版用定时器驱动。无论哪种,核心逻辑都是三步:处理输入(本项目不需要)、更新粒子状态、绘制画面。
帧率与时间步长的关系是很容易被忽略的点。如果在帧循环里不加控制地执行,程序会以极快的速度刷新,导致烟花一闪而过根本看不清。控制台版我用的方案是每帧Sleep(50),也就是每秒约20帧。这个帧率对字符画来说是足够的,人眼看起来是平滑动画。
但有个细节必须说明:Sleep的时间粒度受操作系统调度影响,实际上每帧之间的间隔并不是严格相等的。如果粒子运动速度很高,帧率波动会造成烟花卡顿感。进阶方案是计算两帧之间的真实时间差(比如用std::chrono),然后让粒子的位移量乘上这个时间差作为“时间步长”。这样无论帧率怎么波动,粒子的运动速度都保持一致。我的控制台版没做这层处理,因为效果已经够用;图形版因为帧率更加稳定,也不存在这个问题。所以新手完全可以直接用固定帧率。
3. 实操过程与核心代码实现
3.1 控制台版:从空屏幕到第一束烟花
控制台版的第一步是初始化窗口和控制台属性。为了让彩色字符正常显示,我使用Windows API中的SetConsoleTextAttribute函数。这个函数接收一个控制台句柄和颜色值,然后后续的std::cout输出就会用该颜色。
控制台的光标定位也是一个关键操作。默认情况下std::cout输出会换行,整个画面会不断向下滚动。正确的做法是每次重绘前把光标定位到窗口左上角,然后重新输出整个画布内容。
定位光标用SetConsoleCursorPosition:
cpp复制#include <windows.h>
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void gotoxy(int x, int y) {
COORD coord;
coord.X = x;
coord.Y = y;
SetConsoleCursorPosition(hConsole, coord);
}
还需要隐藏控制台光标,否则会有一个闪烁的小横条一直在画面上干扰视觉效果:
cpp复制void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = FALSE;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
这两步是控制台动画的标配,不写的话画面会非常难受。光标隐藏后,每次重绘其实就是“跳到左上角,把整个画布重新打印一遍”。这里用gotoxy(0, 0)而不是system("cls")清屏,原因是后者会清空整屏而且速度很慢,视觉上会有明显的闪烁,前者直接覆盖输出,速度极快。
接下来是整个系统的核心逻辑。我设计了一个launchFirework()函数,它做的事情是:随机选择一列作为发射位置,在底部生成一个上升粒子。这个粒子的初速度向上,比如vy = -150,同时在底部一直有粒子生成,实现“连续发射”的效果。
爆炸逻辑在粒子更新的过程中判断:如果一个上升粒子的y坐标小于等于目标高度,或者它的vy(竖直速度)从负变正(说明已经到达顶点开始下落),就触发爆炸。触发爆炸时,把当前粒子标记为active = false,然后生成一圈子粒子。这里注意爆炸点坐标要用浮点数保存,避免取整误差导致爆炸位置偏斜。
3.2 完整可运行的控制台版核心代码
下面的代码是我整理出的一个相对完整的控制台版本核心逻辑。去掉了部分花哨功能,保留了主体框架,直接复制到VS或者MinGW环境下编译运行就能看到效果。
cpp复制#include <iostream>
#include <vector>
#include <random>
#include <thread>
#include <chrono>
#include <cmath>
#include <windows.h>
const int COLS = 80;
const int ROWS = 30;
const float GRAVITY = 1.6f;
const int MAX_LIFE = 100;
struct Particle {
float x, y;
float vx, vy;
int life;
int color;
bool active;
};
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
std::vector<Particle> particles;
std::mt19937 gen(static_cast<unsigned int>(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318f);
std::uniform_real_distribution<float> speedDist(30.0f, 100.0f);
std::uniform_int_distribution<int> launchColDist(5, COLS - 6);
std::uniform_int_distribution<int> colorDist(1, 15);
void gotoxy(int x, int y) {
COORD coord;
coord.X = static_cast<SHORT>(x);
coord.Y = static_cast<SHORT>(y);
SetConsoleCursorPosition(hConsole, coord);
}
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = FALSE;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void launchRocket() {
Particle p;
p.x = static_cast<float>(launchColDist(gen));
p.y = static_cast<float>(ROWS - 1);
p.vx = (std::uniform_real_distribution<float>(-5.0f, 5.0f))(gen);
p.vy = -static_cast<float>(std::uniform_real_distribution<float>(80.0f, 140.0f)(gen));
p.life = MAX_LIFE;
p.color = colorDist(gen);
p.active = true;
particles.push_back(p);
}
void explode(float x, float y, int color) {
int count = static_cast<int>(std::uniform_int_distribution<int>(30, 60)(gen));
for (int i = 0; i < count; ++i) {
float angle = angleDist(gen);
float speed = speedDist(gen);
Particle p;
p.x = x;
p.y = y;
p.vx = std::cos(angle) * speed;
p.vy = std::sin(angle) * speed;
p.life = static_cast<int>(std::uniform_int_distribution<int>(30, 70)(gen));
p.color = color;
p.active = true;
particles.push_back(p);
}
}
void update() {
for (auto& p : particles) {
if (!p.active) continue;
p.x += p.vx * 0.02f;
p.y += p.vy * 0.02f;
p.vy += GRAVITY * 0.02f;
p.life--;
// 上升粒子到达顶点附近时爆炸
if (p.vy > 0.0f && p.life > 50) {
p.active = false;
explode(p.x, p.y, p.color);
continue;
}
if (p.life <= 0 || p.y >= ROWS - 1 || p.x < 0 || p.x >= COLS) {
p.active = false;
}
}
}
void render() {
char screen[ROWS][COLS];
for (int i = 0; i < ROWS; ++i) {
for (int j = 0; j < COLS; ++j) {
screen[i][j] = ' ';
}
}
for (const auto& p : particles) {
if (!p.active) continue;
int px = static_cast<int>(p.x);
int py = static_cast<int>(p.y);
if (px >= 0 && px < COLS && py >= 0 && py < ROWS) {
screen[py][px] = '*';
}
}
gotoxy(0, 0);
for (int i = 0; i < ROWS; ++i) {
for (int j = 0; j < COLS; ++j) {
SetConsoleTextAttribute(hConsole, 7);
if (screen[i][j] == '*') {
SetConsoleTextAttribute(hConsole, particles[0].color); // 简化:取第一个粒子颜色
}
std::cout << screen[i][j];
}
std::cout << '\n';
}
}
int main() {
hideCursor();
// 隐藏滚动条
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
SMALL_RECT rect = { 0, 0, static_cast<SHORT>(COLS - 1), static_cast<SHORT>(ROWS - 1) };
SetConsoleWindowInfo(hConsole, TRUE, &rect);
while (true) {
// 随机发射新烟花
static int frameCount = 0;
if (frameCount % 20 == 0) {
launchRocket();
}
frameCount++;
// 也随机生成一些直接爆炸的烟花
if (std::uniform_int_distribution<int>(0, 100)(gen) < 10) {
explode(
static_cast<float>(std::uniform_int_distribution<int>(10, COLS - 10)(gen)),
static_cast<float>(std::uniform_int_distribution<int>(5, ROWS / 2)(gen)),
colorDist(gen)
);
}
update();
render();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return 0;
}
这份代码里我用了很“暴力”的写法,比如在render函数里直接取particles[0].color来统一颜色,这会导致画面里所有粒子的颜色都跟着第一个粒子变。真正完整版应该为每个字符保存对应的颜色值,但那样画布要变成二维的struct Cell { char ch; int color; }。我故意在这里简化,目的是先让程序跑起来,后续再逐步完善,这也是我自己写代码的习惯——先跑通流程,再雕琢细节。
3.3 进阶:Win32图形版的关键思路
控制台版跑通之后,图形版的核心代码反倒没什么神秘的了。因为粒子系统的逻辑完全复用,唯一变化的是绘图部分。
Win32图形版的大体框架是:WinMain注册窗口类,创建窗口,设置一个定时器(比如每16毫秒触发一次,对应约60帧),然后在WM_TIMER消息处理中执行updateScene()和drawScene()。
GDI绘图最关键的双缓冲技术必须使用。如果不使用双缓冲,直接在窗口的WM_PAINT中绘图,会看到严重的闪烁。双缓冲原理非常简单:先在内存中创建一块与窗口客户区等大的位图,把所有粒子画在位图上,然后一次性把整块位图复制到屏幕上。
cpp复制void drawScene(HWND hwnd, HDC hdc) {
RECT client;
GetClientRect(hwnd, &client);
// 内存DC
static HDC memDC = nullptr;
static HBITMAP memBmp = nullptr;
static HBITMAP oldBmp = nullptr;
if (memDC == nullptr) {
memDC = CreateCompatibleDC(hdc);
memBmp = CreateCompatibleBitmap(hdc, client.right, client.bottom);
oldBmp = (HBITMAP)SelectObject(memDC, memBmp);
}
// 清空背景
HBRUSH bgBrush = CreateSolidBrush(RGB(0, 0, 0));
FillRect(memDC, &client, bgBrush);
DeleteObject(bgBrush);
// 绘制粒子
for (const auto& p : particles) {
if (!p.active) continue;
int px = static_cast<int>(p.x);
int py = static_cast<int>(p.y);
HBRUSH brush = CreateSolidBrush(p.color);
HBRUSH oldBrush = (HBRUSH)SelectObject(memDC, brush);
Ellipse(memDC, px - 3, py - 3, px + 3, py + 3);
SelectObject(memDC, oldBrush);
DeleteObject(brush);
}
// 一次性拷贝到屏幕
BitBlt(hdc, 0, 0, client.right, client.bottom, memDC, 0, 0, SRCCOPY);
}
这个版本中粒子的坐标可以直接映射到窗口像素坐标,不需要再经过字符画布的转换。为了让视觉效果更好,我调整了粒子尺寸、爆炸粒子的数量、重力参数,并且把粒子颜色设计成类似真实烟花“红色、金色、白色、紫色”的集合,而不是纯随机颜色。
3.4 编译与运行全流程
写完代码后,编译这一步也是很多初学者卡住的地方。先讲MinGW的命令行编译方式,然后在讲VS和VS Code怎么配置。
控制台版用MinGW编译:
bash复制g++ firework.cpp -o firework.exe -std=c++11 -O2 -static
-static会把C++标准库静态链接到exe里,这样发给别人运行时不需要额外安装运行库。Win32图形版也一样,但需要确保包含了-lgdi32库:
bash复制g++ firework_win.cpp -o firework_win.exe -std=c++11 -O2 -static -lgdi32
用Visual Studio就简单了,新建一个“Windows桌面应用程序”或控制台应用,把代码粘进去,直接F5运行。VS会自动处理库依赖。
VS Code配置C++环境时,最常遇到的问题就是tasks.json和launch.json不知道怎么写。其实编译任务里完全可以不用launch.json,只需要配好tasks.json,用Ctrl+Shift+B编译,然后在终端里手动运行exe。这个方法比较简单:
json复制{
"version": "2.0.0",
"tasks": [
{
"label": "build firework",
"type": "shell",
"command": "g++",
"args": [
"-g", "firework.cpp", "-o", "firework.exe",
"-std=c++11", "-static"
],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
如果你用VS Code搭配微软的C/C++扩展,运行到断点时如果卡住,基本可以断定是launch.json中miDebuggerPath路径没配置好。这种情况建议直接命令行运行,没必要非得折腾调试器——这种小项目靠printf打日志就够用了。
4. 常见问题与排查技巧实录
4.1 画面闪烁严重
控制台版最容易遇到的问题就是画面闪烁。如果你用system("cls")清屏,100%会闪,因为你每次清屏到重新输出之间,显示器有一段时间处于空白状态。正确的做法是用光标定位到左上角,然后覆盖输出。由于新内容会覆盖旧内容,理论上不会出现空白期。
Win32图形版闪烁大概率是没做双缓冲。直接用hdc在WM_PAINT里画,屏幕会疯狂闪。解决办法就是CreateCompatibleDC + CreateCompatibleBitmap + BitBlt三件套。
4.2 中文乱码与字符集问题
控制台输出中文“新年快乐”出现乱码,很多时候不是编码问题,而是编译器的执行字符集没有设置好。Windows下控制台默认使用GBK编码,而VS Code和现代编辑器默认保存为UTF-8。解决方案有两种:
一是在代码开头使用system("chcp 65001")把控制台代码页切换为UTF-8,但这种方式在部分Windows版本上依然存在问题。
二是把源文件编码改成GBK,或者使用#pragma execution_character_set("utf-8")指令。还有一种非常取巧的建议:既然这个项目的定位是“图形视觉效果”,中文输出放在图形版窗口里,用GDI的TextOut绘制,就完全绕开控制台字符集问题了。绘制时设置字体为“Microsoft YaHei”,大小调到48像素,输出“新年快乐”四个字非常美观。
4.3 粒子数量太多导致掉帧
刚开始做的时候,粒子vector没有reserve,而且粒子爆炸后没有及时回收,几秒之后粒子数量就飙升到几千,画面明显变卡。后来做了两部分优化:一是初始化时reserve(4096),避免扩容;二是每帧结束时把所有active == false的粒子统一清理掉。这两步做完之后,画面重新回到满帧流畅状态。
如果还觉得卡,可能跟终端输出有关。控制台每帧打印80×30个字符,约2400个字符,输出速度其实非常快。但如果你把控制台窗口拉得很大,或者每帧都调用SetConsoleTextAttribute多次,也会拖慢速度。一个优化技巧是让控制台窗口保持标准大小,并且减少颜色切换次数,尽量把相同颜色的粒子放在一起输出。
4.4 编译环境常见报错
error: 'chrono' has not been declared,说明你忘记#include <chrono>。std::thread相关的报错,看是不是忘了#include <thread>。SetConsoleCursorPosition未定义,检查是否#include <windows.h>。
还有一次我在MinGW下编译Win32版本时报错undefined reference to __imp_...,这是链接库缺失,检查有没有加-lgdi32。
C++编译器版本过低也会导致问题。std::mt19937、std::chrono在C++11就引入了,所以至少需要-std=c++11。如果编译器太老,比如Visual C++ 6.0,那我建议还是装一个MinGW-w64或者新版Visual Studio Community,这份代码会优雅很多。
4.5 控制台大小与窗口闪烁
控制台版运行时,如果控制台窗口大小和程序里COLS、ROWS不一致,画面会错位或显示不全。可以在程序启动时通过system("mode con cols=80 lines=30")设置窗口大小,但这个方法在Windows 10/11上有时不生效,因为新版终端改用了文本缓冲区方式。
更稳定的做法是用我上面代码里的SetConsoleWindowInfo和SetConsoleScreenBufferSize来设置缓冲区大小和窗口大小。设置的顺序有讲究,一定要先设置缓冲区大小(因为窗口不能大于缓冲区),再设置窗口大小。
5. 一些想说的话
烟花程序做完了,我把它保存成了一个小组件,以后每年春节都可以改改参数,加个新功能,发给朋友图个乐。这个项目让我重新意识到,C++的乐趣其实来自这种“把抽象概念变成真实效果”的过程。粒子系统、随机数引擎、双缓冲绘图,这些概念放在教科书上干巴巴的,但放到一个烟花动画里,瞬间就变得可感可触。
如果你是初学者,我强烈建议你从控制台版动手,一行行敲,敲完再回头想想哪些地方还可以改进。比如把颜色改成粒子专属颜色,把爆炸形状从圆形改成心形,或者加上声音效果。让程序跑起来的那一刻,你可能会有一种说不上来的满足感。这大概就是编程的浪漫吧。
