🎬 【擦除重绘】C++ 实现控制台弹跳 Logo 动画
系列: C++ 控制台编程实战
难度: ⭐⭐ 入门级
代码量: 123 行
预计阅读时间: 25 分钟
前置知识: 变量、循环、函数、vector
📌 摘要
你还记得小时候 DVD 机的屏保吗?一个彩色的 DVD Logo 在电视屏幕上弹来弹去,撞到墙就变色,全家人都盯着看它什么时候能刚好撞到角上------那种又期待又紧张的感觉,至今难忘。
今天,我们就要用 C++ 控制台 复刻这个经典效果!只用 123 行代码,你就能做出一个会四处弹跳、撞墙变色、每次打开位置都不一样的 DVD Logo 屏保。
读完这篇文章,你将学会:
- 🎨 用
vector<string>存字符画,像印章一样盖到控制台上 - 🏃 用"擦除重绘法"让图案动起来------这是动画最基础的原理
- 🧱 边界碰撞检测------让 Logo 撞墙就反弹
- 🌈 撞墙随机变色------16 种颜色随机切换
- 🎲 随机出生点和随机方向------每次运行都不一样
- 🏗️ 控制台工具函数封装:光标定位、隐藏光标、设置颜色、获取窗口大小
适合人群: 刚学完 C++ 基础语法(变量、循环、函数、数组/vector),想做一个"看起来很酷"的小项目练练手的同学。如果你连 dev-c++ 或 g++ 都还没装,请先去看第 0 篇环境配置全攻略。
代码量说明: 完整版 123 行,我们会从 20 行的空控制台开始,一步步加到 123 行。每一步的代码都是完整可运行的,你可以边看边敲,每一步都能看到效果。
BounceLogo
📹 完整版运行视频 --- DVD 弹跳 Logo 完整演示,包含四处弹跳、撞墙变色、随机出生点全流程,Windows 控制台演示。
📚 目录
- 编译环境说明
- [🏗️ 搭好控制台框架](#🏗️ 搭好控制台框架)
- [✏️ 画一个字符画](#✏️ 画一个字符画)
- [🏃 让它动起来](#🏃 让它动起来)
- [🧱 碰到墙就反弹](#🧱 碰到墙就反弹)
- [🌈 撞墙就变色](#🌈 撞墙就变色)
- [🔄 随机出生点](#🔄 随机出生点)
- [🧩 拼合步骤](#🧩 拼合步骤)
- [🏆 完整版出炉](#🏆 完整版出炉)
- [✨ 架构总结](#✨ 架构总结)
- [🛤️ 学习路径](#🛤️ 学习路径)
- [📖 头文件速查](#📖 头文件速查)
- [🔜 拓展方向](#🔜 拓展方向)
🛠️ 编译环境说明
Windows 用户(推荐):
本文所有代码默认在 Windows 控制台中运行,使用 g++ 编译器(MinGW 环境)。
快速编译命令:
bash
g++ CD.cpp -o CD.exe
CD.exe
详细配置指引: 如果你是第一次接触 C++ 控制台编程,不知道怎么装编译器、怎么调字体大小、怎么设置控制台属性,请去看**第 0 篇《C++ 控制台环境配置完整指南》**,里面有图文教程,照着做 5 分钟搞定。
💡 小提示: 运行后想退出程序,按
Ctrl + C即可。
第1章 🏗️ 搭好控制台框架
🎯 我们要做什么
在开始画画和做动画之前,我们得先把"画布"准备好。控制台就像一张画布,但默认的画布有个一闪一闪的光标在那里捣乱,而且我们还需要能自由地把画笔移动到任意位置、切换画笔颜色、知道画布有多大。
这一章我们的目标是:
- ✅ 拿到控制台的"操作权限"(句柄)
- ✅ 隐藏那个烦人的闪烁光标
- ✅ 写一个
gotoxy(x, y)函数,让光标想去哪就去哪 - ✅ 写一个
setColor()函数,切换文字颜色 - ✅ 写一个
getConsoleSize()函数,知道控制台窗口有多少行多少列 - ✅ 验证这些工具都能正常工作
💻 代码
cpp
#include <iostream>
#include <windows.h> // 控制台操作需要的头文件
#include <utility> // pair
using namespace std;
// 获取控制台句柄(相当于拿到画笔的控制权)
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
// 隐藏光标
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false; // 设为不可见
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
// 设置光标位置到 (x, y)
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
// 设置文字颜色 (0-15)
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
// 获取控制台窗口大小(列数,行数)
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
int main() {
hideCursor();
// 测试:在第5行第10列用红色打印 Hello
gotoxy(10, 5);
setColor(12); // 12 = 红色
cout << "Hello, 控制台!" << endl;
// 测试:获取窗口大小并显示
pair<int, int> sz = getConsoleSize();
gotoxy(10, 7);
setColor(7); // 7 = 默认白色
cout << "窗口大小:" << sz.first << " 列 x " << sz.second << " 行" << endl;
// 移到最下面再结束,免得输出被挤掉
gotoxy(0, sz.second - 1);
return 0;
}
🐧 Linux / macOS 用户看这里
Linux 和 macOS 没有
windows.h,控制台操作要用 ncurses 库。编译时需要加-lncurses。
cpp#include <iostream> #include <ncurses.h> #include <utility> using namespace std; void hideCursor() { curs_set(0); // 0 = 隐藏光标 } void gotoxy(int x, int y) { move(y, x); // ncurses 是先 y 后 x,注意顺序! } void setColor(int fg) { // ncurses 的颜色编号和 Windows 不同 // 需要先用 start_color() 初始化,然后用 COLOR_PAIR // 简单起见,这里用 attron 设粗体等属性 attron(COLOR_PAIR(fg)); } pair<int, int> getConsoleSize() { int rows, cols; getmaxyx(stdscr, rows, cols); // 注意是先 rows 后 cols return make_pair(cols, rows); } int main() { initscr(); // 初始化 ncurses start_color(); // 启用颜色 init_pair(1, COLOR_RED, COLOR_BLACK); // 初始化颜色对 hideCursor(); gotoxy(10, 5); printw("Hello, 控制台!"); pair<int, int> sz = getConsoleSize(); gotoxy(10, 7); printw("窗口大小:%d 列 x %d 行", sz.first, sz.second); getch(); // 按任意键退出 endwin(); // 结束 ncurses return 0; }API 对照表:
Windows API ncurses 对应 说明 HANDLE hConsolestdscr标准窗口(默认全屏) GetStdHandle()initscr()初始化 SetConsoleCursorInfo()curs_set(0)隐藏光标 SetConsoleCursorPosition()move(y, x)移动光标 SetConsoleTextAttribute()attron()/COLOR_PAIR()设置颜色 GetConsoleScreenBufferInfo()getmaxyx()获取窗口大小 coutprintw()输出文字 Sleep(ms)napms(ms)休眠毫秒
📝 逐行看懂这段代码
第 2 行:#include <windows.h>
这是 Windows 系统提供的"工具箱",里面包含了所有控制台操作的函数。就像你画画需要打开画笔工具箱一样,写控制台程序就要包含这个头文件。
第 8 行:HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
HANDLE 翻译过来是"句柄",你可以把它理解成遥控器 。通过这个遥控器,我们就能指挥控制台做各种事情------移动光标、改颜色、查大小等等。GetStdHandle(STD_OUTPUT_HANDLE) 就是从系统那里拿到"标准输出"的遥控器。
第 11-15 行:hideCursor() 函数
控制台上那个一闪一闪的光标会影响动画效果,所以我们要把它藏起来。流程是:
- 先获取当前光标的信息(存在
cursorInfo结构体里) - 把
bVisible(可见性)改成false - 再把修改后的信息设置回去
就像你先拿一面镜子看看自己现在什么样子,然后换件衣服,再照照镜子确认换好了。
第 18-21 行:gotoxy(x, y) 函数
这是我们最常用的函数之一。COORD 是 Windows 定义的一个坐标结构体,里面有 X 和 Y 两个值。SetConsoleCursorPosition 就是让光标跳到指定位置。
⚠️ 注意:控制台的坐标系是 x 向右增加,y 向下增加,原点 (0,0) 在左上角。这和数学里的坐标系不太一样,y 轴是反过来的!
第 24-26 行:setColor(fg) 函数
SetConsoleTextAttribute 可以同时设置前景色(文字颜色)和背景色。这里我们只传一个参数,默认背景色是黑色。颜色编号是 0 到 15,共 16 种。
第 29-36 行:getConsoleSize() 函数
这个函数返回控制台窗口的宽度(列数)和高度(行数)。它用了 pair<int, int> 这个"打包工具"------把两个整数绑在一起返回,第一个是列数,第二个是行数。
💡 背后的原理
1. 控制台是画布,句柄是画笔
想象控制台是一张放在你面前的画纸。默认情况下,你只能从左上角开始,一行一行往下写,就像用打字机打字一样。但有了"句柄"这个画笔之后,你就可以把笔抬起来,移到画纸的任意位置再落下,想在哪画就在哪画。
gotoxy(x, y) 就是抬笔移动 ,cout 就是落笔绘画。
2. 为什么叫"句柄"(Handle)?
Handle 这个词的本意是"把手、手柄"。为什么编程里要叫句柄呢?因为你不是直接操作控制台的------你是通过一个"把手"来间接操作它。就像你开门不是直接推门板,而是握住门把手来开门。句柄就是系统给你的那个"门把手"。
3. 结构体(struct)是什么?
代码里出现了 CONSOLE_CURSOR_INFO、COORD、CONSOLE_SCREEN_BUFFER_INFO 这些"奇怪的类型",它们都是结构体。你可以把结构体理解成一个"工具箱",里面装了好几个相关的数据。
比如 COORD 这个工具箱里装了 X 和 Y 两个整数,合在一起表示一个坐标点。CONSOLE_CURSOR_INFO 里装了光标的大小、是否可见等信息。
⚠️ 注意事项
- 坐标系原点在左上角:x 从左往右数,y 从上往下数,都是从 0 开始。
- 颜色编号范围是 0-15:0 是黑色(和背景一样,写了也看不见),7 是默认的灰白色,1-6 和 8-15 是各种彩色。
pair的第一个是first,第二个是second:别搞反了,sz.first是列数(宽度),sz.second是行数(高度)。- Linux/macOS 用户注意 ncurses 坐标顺序 :ncurses 的
move(y, x)是先写行再写列,和 Windows 的gotoxy(x, y)正好相反!
第2章 ✏️ 画一个字符画
🎯 我们要做什么
光有空白画布可不行,我们得在上面画点东西。这一章的目标是做出我们的主角------DVD Logo 字符画,并把它打印到控制台上。
我们要实现:
- ✅ 用
vector<string>存储一个多行的字符画图案 - ✅ 写一个循环,逐行把图案打印到指定位置
- ✅ 能控制图案的位置和颜色
- ✅ 计算图案的宽度和高度
💻 代码
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
// 在指定位置用指定颜色绘制字符画
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
int main() {
hideCursor();
// DVD 字符画:用 vector 存,每一行是一个 string
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size(); // 图案宽度 = 第一行的长度
int logoH = logo.size(); // 图案高度 = 行数
// 在控制台中心画一个绿色的 DVD Logo
pair<int, int> sz = getConsoleSize();
int centerX = (sz.first - logoW) / 2;
int centerY = (sz.second - logoH) / 2;
drawLogo(logo, centerX, centerY, 10); // 10 = 绿色
// 移到最下面
gotoxy(0, sz.second - 1);
setColor(7);
return 0;
}
🐧 Linux / macOS 用户看这里
cpp// ncurses 版本的 drawLogo void drawLogo(vector<string>& logo, int px, int py, int colorPair) { attron(COLOR_PAIR(colorPair)); for (int i = 0; i < logo.size(); ++i) { move(py + i, px); // 注意 ncurses 是先 y 后 x printw("%s", logo[i].c_str()); } attroff(COLOR_PAIR(colorPair)); }关键差异:
- ncurses 用
move(y, x)定位,顺序和 Windows 相反- 输出用
printw(),string 需要.c_str()转成 C 风格字符串- 颜色用
attron(COLOR_PAIR(n))开启,用完记得attroff()关闭logo.size()和logo[0].size()的用法完全一样
📝 逐行看懂这段代码
第 39-42 行:drawLogo() 函数
这是我们的"印章函数"。传入三个参数:
logo:字符画内容(一个 vector,每一行是一个字符串)px, py:要画的位置(左上角坐标)color:用什么颜色画
函数内部用一个 for 循环,一行一行地打印。第 i 行就画在 py + i 的位置上,x 坐标都是 px(因为每一行都是左对齐的)。
第 48-55 行:定义 DVD 字符画
我们用 vector<string> 来存字符画。为什么用 vector 而不是普通数组呢?因为 vector 可以方便地用 push_back 一行一行加,而且能自动知道有多少行(size() 函数)。
这个 DVD 图案一共 7 行,每一行 14 个字符。你可以自己数一下:+------------+ 是 2 个加号加 12 个横杠,总共 14 个字符。
第 57-58 行:计算图案尺寸
cpp
int logoW = logo[0].size(); // 宽度 = 第一行的字符数
int logoH = logo.size(); // 高度 = 行数
宽度是第一行字符串的长度(假设所有行一样长,我们的图案确实是这样的),高度就是 vector 的元素个数。
第 62-63 行:计算中心位置
cpp
int centerX = (sz.first - logoW) / 2;
int centerY = (sz.second - logoH) / 2;
要让图案居中,就用"窗口宽度减去图案宽度,再除以2"。这个公式很常用,记住它!
💡 背后的原理
1. 字符画就像印章
你可以把 vector<string> 理解成一个橡皮印章 。每一行字符串就是印章上的一排凸起。当你调用 drawLogo() 的时候,就像把印章按在纸上------"啪"的一下,整个图案就印上去了。
和真正的印章一样,你只需要关心:
- 印章上刻了什么图案(vector 里的内容)
- 按在什么位置(px, py)
- 用什么颜色的印泥(color)
2. 为什么用 vector 而不是数组?
如果用普通数组,你得提前知道有多少行,而且每行的长度也得固定。用 vector 就灵活多了:
- 想加一行就
push_back - 想知道有几行就
size() - 想拿某一行就
logo[i]
vector 就像一个可以自动扩容的书架,书放多了它自己会变大,不用你操心。
3. 字符画的"像素"是字符
在普通的屏幕上,最小的画面单位是"像素"(pixel)。但在控制台里,最小的单位是一个字符位置。所以我们的"像素"就是一个个字符------可以是字母、数字、符号,甚至是空格。
一个字符的高度比宽度大一些(大概是 2:1 的比例),所以如果你想画一个正方形,行数应该大约是列数的一半。
⚠️ 注意事项
- 所有行长度最好一致:如果行与行长度不一样,图案会歪歪扭扭的。我们的 DVD Logo 每行都是 14 个字符,很整齐。
logo[0]要确保存在:调用logo[0].size()之前,一定要确认 vector 不是空的,否则程序会崩溃。- 位置不要超出窗口 :如果
px是负数或者超过了窗口宽度,打印出来的东西会被截断或者跑到奇怪的地方。后面我们会用碰撞检测来保证这一点。 - 颜色 0 是黑色:如果你把颜色设成 0,字就和黑色背景融为一体了,什么都看不见。所以通常颜色从 1 开始用。
第3章 🏃 让它动起来
🎯 我们要做什么
静止的图案没意思,我们要让它动起来!这一章我们学习动画最基础的原理------擦除重绘法。
目标清单:
- ✅ 用变量
x, y记录图案当前的位置 - ✅ 用变量
dx, dy表示移动方向和速度 - ✅ 掌握"擦除重绘"三步法:擦除上一帧 → 移动坐标 → 画新位置
- ✅ 用
Sleep()控制动画速度(帧率) - ✅ 做出一个能斜着飞的 DVD Logo(飞出屏幕不管,后面再处理)
💻 代码
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
// 用空格擦除指定位置的图案
void eraseLogo(int px, int py, int w, int h) {
setColor(7); // 用默认颜色(其实空格什么颜色都一样)
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' '); // 打印 w 个空格
}
}
int main() {
hideCursor();
// DVD 字符画
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
// 初始位置
int x = 5;
int y = 5;
// 方向向量:dx 是水平速度,dy 是垂直速度
int dx = 1; // 正的向右,负的向左
int dy = 1; // 正的向下,负的向上
int color = 10; // 绿色
// 先画第一帧
drawLogo(logo, x, y, color);
// 动画主循环
while (true) {
// 第一步:擦除上一帧的图案
eraseLogo(x, y, logoW, logoH);
// 第二步:更新位置(移动)
x += dx;
y += dy;
// 第三步:在新位置画图案
drawLogo(logo, x, y, color);
// 第四步:休息一会儿,控制帧率
Sleep(60); // 暂停 60 毫秒,大约每秒 16 帧
}
return 0;
}
🐧 Linux / macOS 用户看这里
cpp// ncurses 版本的擦除函数 void eraseLogo(int px, int py, int w, int h) { for (int i = 0; i < h; ++i) { move(py + i, px); for (int j = 0; j < w; ++j) { addch(' '); } } } // 主循环中的 Sleep 换成 napms napms(60); // ncurses 的毫秒级休眠 // 别忘了刷新!ncurses 是缓冲的,画完要刷新 refresh();关键差异:
- Windows 用
Sleep(ms),ncurses 用napms(ms)- ncurses 的输出是"先在后台画,画完一起显示",所以每次画完要调用
refresh()才会显示出来- 这个特性其实很有用------它天然就是"双缓冲"的,不会闪烁
string(w, ' ')这个 C++ string 的构造函数在 ncurses 里也能用,只是打印方式不同
📝 逐行看懂这段代码
第 44-49 行:eraseLogo() 擦除函数
擦除的原理很简单------在原来的位置打印一堆空格,把之前的图案覆盖掉。就像你用橡皮擦掉铅笔画一样。
string(w, ' ') 是 C++ string 的一个好用的构造函数:创建一个由 w 个空格组成的字符串。相当于 " " 重复 w 次。
第 71-72 行:方向向量 dx, dy
cpp
int dx = 1; // 水平方向:1 = 向右,-1 = 向左
int dy = 1; // 垂直方向:1 = 向下,-1 = 向上
dx 和 dy 合在一起叫方向向量 。每次循环,我们都让 x += dx、y += dy,这样图案就会沿着 dx, dy 指向的方向移动。
- dx=1, dy=1:向右下飞
- dx=1, dy=-1:向右上飞
- dx=-1, dy=1:向左下飞
- dx=-1, dy=-1:向左上飞
速度的大小由 dx, dy 的绝对值决定。绝对值越大,速度越快。我们这里用 1,是最慢的速度(每帧移动 1 个字符)。
第 78-91 行:动画主循环
这是整个程序的核心------游戏循环 / 动画循环。每一帧都做四件事:
- 擦除:把上一帧的图案擦掉(用空格覆盖)
- 更新:根据速度更新位置
- 绘制:在新位置画出来
- 等待:暂停一小会儿,控制动画速度
这个循环会一直跑下去(while(true) 是死循环),直到用户按 Ctrl+C 退出。
第 89 行:Sleep(60)
Sleep 的单位是毫秒,1000 毫秒 = 1 秒。Sleep(60) 就是每一帧暂停 60 毫秒。
那帧率是多少呢?1 秒 = 1000 毫秒,1000 / 60 ≈ 16.7,所以大约是每秒 16 帧。
- Sleep(100) → 10 fps,很慢,像幻灯片
- Sleep(60) → ~16 fps,还可以,有点复古感
- Sleep(33) → ~30 fps,比较流畅
- Sleep(16) → ~60 fps,非常流畅
但控制台动画不用追求太高帧率,因为控制台本身刷新就不算特别快,太快了反而可能闪。
💡 背后的原理
1. 动画的本质:快速翻页的手翻书
你小时候玩过手翻书吗?就是那种每页画着稍微不一样的小人,快速翻动的时候小人就动起来了。
控制台动画也是一样的道理:
- 每一帧,我们把整个画面重新画一遍
- 两帧之间的变化很小(只移动了一点点)
- 每秒画 15-30 次,人眼就会觉得"它在动"
这就是视觉暂留效应------人眼看到的画面会在大脑里保留一小段时间,如果下一张画面来得够快,我们就会觉得是连续的。
2. 擦除重绘法:最简单的动画方案
我们用的方法叫擦除重绘法(也叫"清屏重绘法"的简化版)。它的思路非常朴素:
旧位置画着图案 → 把旧位置擦成空白 → 图案挪到新位置 → 在新位置画上
就像你在纸上画小人,画完一帧之后,用橡皮把小人擦掉,然后在旁边再画一个稍微挪了一点的小人。擦得越快、画得越快,动画就越流畅。
擦除重绘法的优点是简单易懂,适合初学者。但它也有缺点------如果擦和画之间的时间差太长,人眼会看到闪烁(画面闪一下)。就像你擦小人的时候,纸上有一瞬间是空白的。
3. 为什么要有 Sleep?
如果不加 Sleep,程序会以最快的速度跑------可能每秒几百帧。这样有两个问题:
- 动画太快了,看不清(图案嗖的一下就飞出去了)
- 浪费电脑性能(明明不需要那么快,却在拼命跑)
Sleep 就像动画的"节拍器",控制着每秒画多少帧。找到合适的节奏,动画才好看。
⚠️ 注意事项
- 先擦后画,顺序不能反:如果先画新的再擦旧的,会出现两个图案同时存在的瞬间,效果很奇怪。
- 图案会飞出屏幕:这一章我们还没做碰撞检测,Logo 飞出去就不见了,这是正常的。下一章就解决这个问题。
- 可能会有轻微闪烁:擦除重绘法天然会有一点闪烁,特别是图案比较大的时候。这是正常现象,后面我们会讨论更高级的"双缓冲"技术。
- Sleep 不是精确的:Windows 的 Sleep 最小精度大约是 10-15 毫秒,所以 Sleep(60) 不一定正好 60 毫秒,可能稍微多一点点。对于简单动画来说完全够用了。
- 退出按 Ctrl+C :因为是
while(true)死循环,程序不会自己结束,想退出就按Ctrl + C。
第4章 🧱 碰到墙就反弹
🎯 我们要做什么
上一章我们的 DVD Logo 会飞了,但飞着飞着就飞出屏幕不见了------这可不行!DVD 屏保的精髓就在于它会撞墙反弹,永远在屏幕里弹来弹去。
这一章我们要加上边界碰撞检测,让 Logo 撞到墙壁就弹回来。
目标清单:
- ✅ 检测左右边界:碰到左墙或右墙时,水平方向反向
- ✅ 检测上下边界:碰到天花板或地板时,垂直方向反向
- ✅ 理解
dx = -dx取反的物理意义 - ✅ 确保 Logo 不会卡在墙里或者飞出边界
💻 代码
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int termW = sz.first; // 终端宽度
int termH = sz.second; // 终端高度
// 初始位置
int x = 5;
int y = 5;
int dx = 1;
int dy = 1;
int color = 10;
drawLogo(logo, x, y, color);
while (true) {
// 1. 擦除上一帧
eraseLogo(x, y, logoW, logoH);
// 2. 碰撞检测:碰墙就反弹
// 左边界:下一步 x+dx < 1,说明要撞左墙了
// 右边界:下一步 x+logoW+dx > termW,说明右边要出界了
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx; // 水平方向取反
}
// 上边界:下一步 y+dy < 1
// 下边界:下一步 y+logoH+dy > termH
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy; // 垂直方向取反
}
// 3. 更新位置
x += dx;
y += dy;
// 4. 绘制新位置
drawLogo(logo, x, y, color);
// 5. 帧率控制
Sleep(60);
}
return 0;
}
🐧 Linux / macOS 用户看这里
碰撞检测的逻辑和 Windows 完全一样,因为碰撞检测是纯数学计算,和操作系统无关。
需要改的只有:
Sleep(60)→napms(60)- 擦除和绘制函数用 ncurses 版本
- 画完记得
refresh()碰撞检测代码完全照搬:
cppif (x + dx < 0 || x + logoW + dx > termW) { // ncurses 从 0 开始 dx = -dx; } if (y + dy < 0 || y + logoH + dy > termH) { dy = -dy; }⚠️ 注意 ncurses 的坐标从 0 开始 ,所以边界判断用
< 0而不是< 1。Windows 控制台其实也是从 0 开始的,但我们习惯把 1 当边界(给左边留点余量),两种方式都可以。
📝 逐行看懂这段代码
第 73-74 行:获取窗口尺寸
cpp
int termW = sz.first; // 终端宽度(列数)
int termH = sz.second; // 终端高度(行数)
碰撞检测需要知道"墙在哪里",所以我们要先获取控制台窗口的大小。termW 是终端宽度,termH 是终端高度。
第 89-93 行:水平碰撞检测
cpp
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx;
}
这行代码的意思是:"如果下一步会撞到左墙,或者下一步右边会出右墙,就把水平方向反过来。"
我们来拆解一下:
x + dx < 1:左边沿的下一个位置小于 1 → 要撞左墙了x + logoW + dx > termW:右边沿的下一个位置超过了终端宽度 → 要撞右墙了dx = -dx:把 dx 取反,方向就反过来了(向右变向左,向左变向右)
注意我们检测的是**"下一步"**的位置(x + dx),而不是当前位置。这样可以在碰撞发生之前就反向,避免 Logo 卡在墙里。
第 95-98 行:垂直碰撞检测
cpp
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy;
}
和水平方向完全一样的逻辑,只是换成了 y 坐标和图案高度。
y + dy < 1:上边要撞天花板了y + logoH + dy > termH:下边要撞地板了dy = -dy:垂直方向反向(向下变向上,向上变向下)
为什么用 x + dx 而不是 x?
如果我们用 if (x < 1) 来判断,意思是"如果已经撞墙了就反弹"。但这时候 Logo 可能已经有一部分卡在墙外面了,体验不好。
用 x + dx 的意思是"先预判下一步会不会撞墙,如果会就先反弹再走"。这样 Logo 永远不会出界,永远在屏幕内。
💡 背后的原理
1. 反弹 = 速度取反
为什么 dx = -dx 就能实现反弹?我们来想一个乒乓球的例子:
- 乒乓球向右飞(dx = 3),撞到右边的墙
- 墙会给球一个向左的弹力
- 球就变成向左飞(dx = -3)
在物理里,这叫弹性碰撞 ------碰撞后速度大小不变,方向相反。dx = -dx 就是最朴素的弹性碰撞模拟。
同理,dy = -dy 就是垂直方向的弹性碰撞。
2. 为什么是矩形碰撞盒?
你可能注意到了,我们检测碰撞的时候,把 Logo 当成了一个长方形。不管 Logo 画的是什么(圆形、三角形、或者 DVD 图案),我们只用它的外接矩形来判断碰撞。
这在游戏编程里叫碰撞盒(Collision Box)或者包围盒(Bounding Box)。用矩形来检测碰撞非常简单、计算速度快,对于大多数 2D 游戏来说完全够用。
我们的 DVD Logo 本身就是个矩形的字符画,所以用矩形碰撞盒简直是天作之合。
3. 预判式碰撞 vs 事后修正
我们的代码用的是"预判式碰撞"------先算下一步会不会撞,会的话就先反弹。
还有另一种思路叫"事后修正"------先移动,移动完了检查是不是出界了,如果出界了就拉回来并反向。
两种方法各有优缺点:
- 预判式:永远不会出界,逻辑清晰,但只适合匀速直线运动
- 事后修正:适合更复杂的运动(比如有加速度),但可能会有一帧的画面出界
对于我们这个简单的弹跳 Logo,预判式是最好的选择。
⚠️ 注意事项
- 边界从 1 开始还是从 0 开始? 控制台的坐标其实是从 0 开始的,但我们用
< 1当作左边界,这样左边留了一列的余量,看起来更舒服。你也可以用< 0,效果差不多。 - 右边界是
x + logoW不是x:新手最容易犯的错误就是忘了加logoW,只判断x > termW。这样的话,Logo 的左边沿碰到右墙才反弹,但右边早就飞出屏幕了。 - 速度太快可能穿墙:如果 dx 很大(比如 10),一帧移动 10 个字符,就可能出现"这一帧还在墙左边,下一帧直接穿到墙右边"的情况,碰撞检测就失效了。我们用 dx=1 就不会有这个问题。
- 窗口大小不要中途改变 :如果用户在运行时拉大或缩小控制台窗口,
termW和termH不会自动更新,碰撞边界就不对了。进阶版本可以每帧都获取一次窗口大小,但对初学者来说先不管这个。
第5章 🌈 撞墙就变色
🎯 我们要做什么
经典的 DVD 屏保有个很有趣的特性------每次撞墙都会换一种颜色!这个小细节让单调的弹跳变得有趣起来,大家都喜欢盯着看它撞角的时候会变成什么颜色。
这一章我们就来加上"撞墙变色"的效果。
目标清单:
- ✅ 认识控制台的 16 种颜色编号
- ✅ 用
rand()生成随机颜色 - ✅ 每次撞墙时切换颜色
- ✅ 颜色变量
color的使用
💻 代码
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
#include <cstdlib> // rand, srand
#include <ctime> // time
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
srand((unsigned)time(NULL)); // 初始化随机种子
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int termW = sz.first;
int termH = sz.second;
int x = 5;
int y = 5;
int dx = 1;
int dy = 1;
int color = 10; // 初始颜色
drawLogo(logo, x, y, color);
while (true) {
eraseLogo(x, y, logoW, logoH);
// 碰撞检测 + 变色
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx;
color = rand() % 15 + 1; // 随机换一个颜色(1~15)
}
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy;
color = rand() % 15 + 1; // 随机换一个颜色(1~15)
}
x += dx;
y += dy;
drawLogo(logo, x, y, color);
Sleep(60);
}
return 0;
}
🐧 Linux / macOS 用户看这里
ncurses 的颜色系统和 Windows 不一样,它需要先定义"颜色对"(前景色+背景色的组合)。
如果你想要 16 种颜色随机切换,需要先初始化 15 个颜色对:
cpp// 在 initscr() 和 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); // ... 还有亮色版本然后随机选一个颜色对编号来用:
cppcolor = rand() % 7 + 1; // 随机选 1~7 号颜色对 attron(COLOR_PAIR(color));颜色随机的逻辑完全一样,只是颜色编号的含义不同而已。
📝 逐行看懂这段代码
第 7-8 行:新增头文件
cpp
#include <cstdlib> // rand, srand
#include <ctime> // time
cstdlib 里有随机数函数 rand() 和随机种子函数 srand()。ctime 里有 time() 函数,用来获取当前时间作为随机种子。
第 55 行:初始化随机种子
cpp
srand((unsigned)time(NULL));
这句话要放在程序开头,只执行一次。它的作用是"给随机数发生器上发条"------用当前时间作为起始值,这样每次运行程序随机数序列都不一样。
如果不写这句话,每次运行 rand() 生成的随机数都是一样的,颜色顺序就固定了,那就不叫"随机"了。
第 86-87 行:撞墙变色
cpp
dx = -dx;
color = rand() % 15 + 1; // 随机换一个颜色
在反弹的同时,换一个新颜色。rand() % 15 + 1 生成一个 1 到 15 之间的随机整数。
为什么是 1 到 15 而不是 0 到 15?因为 0 是黑色,和背景色一样,写出来就看不见了,所以我们从 1 开始。
颜色编号速查表:
| 编号 | 颜色 | 编号 | 颜色 |
|---|---|---|---|
| 0 | 黑色(=背景色,看不见) | 8 | 深灰色 |
| 1 | 深蓝色 | 9 | 亮蓝色 |
| 2 | 深绿色 | 10 | 亮绿色 |
| 3 | 深青色 | 11 | 亮青色 |
| 4 | 深红色 | 12 | 亮红色 |
| 5 | 深紫色 | 13 | 亮紫色 |
| 6 | 深黄色(土黄) | 14 | 亮黄色 |
| 7 | 灰白色(默认) | 15 | 亮白色 |
1-7 是暗色,8-15 是对应的亮色。总共 16 种颜色。
💡 背后的原理
1. 变色龙效应:撞墙就换衣服
你可以把颜色变量想象成 Logo 的"衣服"。平时它穿着一件衣服到处跑,每次撞到墙,它就立刻脱下来换一件新的------而且是从衣柜里闭着眼睛摸一件(随机选)。
衣柜里有 15 件不同颜色的衣服(1~15 号),每次撞墙都可能换一件不同的,也有可能运气不好摸到了同一件(概率 1/15)。
2. 计算机的"随机"其实是伪随机
你可能会好奇:计算机是怎么生成随机数的?难道它也会"扔骰子"吗?
其实,计算机里的随机数都是伪随机 的------它不是真的随机,而是用一个很复杂的数学公式算出来的。只要给它一个"起始值"(叫种子),它就能算出一长串看起来毫无规律的数字。
如果种子一样,算出来的随机数序列就完全一样。所以我们用当前时间(time(NULL))作为种子------因为时间一直在变,每次运行的种子都不一样,随机数序列也就不一样了。
这就像洗牌:你用不同的方式洗牌(不同的种子),牌的顺序就不一样。但只要洗牌方式相同,牌的顺序就完全一样。
3. rand() % N + M 这个公式
你会经常看到 rand() % 15 + 1 这种写法,它是生成随机数的经典公式:
rand() % 范围大小 + 起始值
rand() % 15→ 生成 0~14 的随机数(15 种可能)rand() % 15 + 1→ 生成 1~15 的随机数rand() % 100→ 生成 0~99 的随机数rand() % 100 + 1→ 生成 1~100 的随机数rand() % 50 + 20→ 生成 20~69 的随机数
记住这个公式,以后生成随机范围的数就靠它了。
⚠️ 注意事项
- srand 只调用一次:随机种子初始化一定要放在循环外面,程序开头调用一次就够了。如果在循环里反复调用 srand,因为同一秒内时间不变,种子就一样,生成的随机数也一样。
- 颜色 0 不要用 :0 号是黑色,和背景色一样,会让 Logo "消失"。所以我们用
% 15 + 1跳过 0。 - 同时撞两个墙会变两次色:如果 Logo 正好撞到角上(同时撞横墙和竖墙),两个 if 都会执行,颜色会随机两次。这其实不是 bug,反而更有趣------撞角的时候"连换两件衣服"。
- rand() 的范围 :
rand()返回的是 0 到RAND_MAX之间的整数,RAND_MAX 至少是 32767。对于我们这种小范围随机完全够用了。
第6章 🔄 随机出生点
🎯 我们要做什么
现在我们的 DVD Logo 每次都从 (5, 5) 出发,方向固定是右下,颜色固定是绿色------太 predictable 了!真正的 DVD 屏保每次开始的位置和方向都是随机的。
这是最后一块拼图:让 Logo 的初始位置、初始方向、初始颜色全部随机。
目标清单:
- ✅ 初始位置随机:在屏幕范围内任意位置出生
- ✅ 初始方向随机:四个斜向方向随机选一个
- ✅ 初始颜色随机
- ✅ 复习
rand() % N + M公式的灵活运用
💻 代码
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
#include <cstdlib>
#include <ctime>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
srand((unsigned)time(NULL));
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int termW = sz.first;
int termH = sz.second;
// 安全检查:窗口太小就退出
if (termW < logoW || termH < logoH) {
cout << "请将控制台窗口拉大一些!" << endl;
return 1;
}
// 🎲 随机初始位置
int x = rand() % (termW - logoW) + 1;
int y = rand() % (termH - logoH) + 1;
// 🎲 随机初始方向(向右/向左,向下/向上)
int dx = (rand() % 2 == 0) ? 1 : -1;
int dy = (rand() % 2 == 0) ? 1 : -1;
// 🎲 随机初始颜色
int color = rand() % 15 + 1;
drawLogo(logo, x, y, color);
while (true) {
eraseLogo(x, y, logoW, logoH);
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx;
color = rand() % 15 + 1;
}
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy;
color = rand() % 15 + 1;
}
x += dx;
y += dy;
drawLogo(logo, x, y, color);
Sleep(60);
}
return 0;
}
🐧 Linux / macOS 用户看这里
随机出生点的逻辑完全是数学计算,和操作系统无关。ncurses 版本只需要注意:
坐标从 0 开始的话,初始位置公式是:
cppint x = rand() % (termW - logoW); int y = rand() % (termH - logoH);就是把
+ 1去掉就好了。随机方向和随机颜色的代码完全一样,照搬就行。
别忘了
srand((unsigned)time(NULL))初始化随机种子。
📝 逐行看懂这段代码
第 67-70 行:窗口大小安全检查
cpp
if (termW < logoW || termH < logoH) {
cout << "请将控制台窗口拉大一些!" << endl;
return 1;
}
如果控制台窗口比 Logo 还小,那 Logo 根本放不下,更别说弹跳了。所以我们加一个安全检查,窗口太小就提示用户并退出。
return 1 表示程序异常退出(正常退出是 return 0)。
第 73-74 行:随机初始位置
cpp
int x = rand() % (termW - logoW) + 1;
int y = rand() % (termH - logoH) + 1;
termW - logoW 是 Logo 能放的最右边的位置(再往右右边就出界了)。所以 x 的范围就是 1 到 termW - logoW。
用我们的公式验证一下:
- 范围大小 =
termW - logoW - 起始值 = 1
- 所以是
rand() % (termW - logoW) + 1
完美对应!
第 77-78 行:随机初始方向
cpp
int dx = (rand() % 2 == 0) ? 1 : -1;
int dy = (rand() % 2 == 0) ? 1 : -1;
这里用了一个新东西:三元运算符 ? :。它的意思是:
条件 ? 如果条件成立就取这个值 : 否则就取那个值
所以 (rand() % 2 == 0) ? 1 : -1 的意思是:
- 随机生成 0 或 1
- 如果是 0,dx 就等于 1(向右)
- 如果是 1,dx 就等于 -1(向左)
这样 dx 和 dy 各有 50% 的概率取 1 或 -1,合起来就有 4 种可能的方向:
- dx=1, dy=1 → 右下
- dx=1, dy=-1 → 右上
- dx=-1, dy=1 → 左下
- dx=-1, dy=-1 → 左上
每种方向的概率都是 25%。
第 81 行:随机初始颜色
cpp
int color = rand() % 15 + 1;
这个我们上一章已经见过了,1~15 号颜色随机选一个。
💡 背后的原理
1. 开盲盒:每次运行都是新的惊喜
加上随机出生点之后,每次运行程序就像开一个盲盒------你不知道 Logo 会从哪里冒出来,不知道它朝哪个方向飞,也不知道它是什么颜色。这种"不确定性"让简单的屏保变得有趣多了。
这就是随机性的魅力------同样的代码,每次运行都有不一样的体验。
2. 三元运算符:if-else 的简写
? : 这个三元运算符其实就是 if-else 的简写形式。下面两段代码是等价的:
cpp
// 用 if-else
int dx;
if (rand() % 2 == 0) {
dx = 1;
} else {
dx = -1;
}
// 用三元运算符
int dx = (rand() % 2 == 0) ? 1 : -1;
三元运算符更简洁,但只适合"二选一"的简单情况。如果逻辑比较复杂,还是用 if-else 更清楚。
3. 边界检查:防御式编程
我们加的"窗口太小就退出"的检查,体现了一种编程思想叫防御式编程------不要假设一切都会正常,要提前考虑可能出问题的情况,并做好处理。
就像开车上路,你不能假设所有人都遵守交通规则,你要随时准备应对突发情况。写程序也是一样,要想想"如果用户窗口太小怎么办?"、"如果文件打不开怎么办?"、"如果输入的是负数怎么办?"
好的程序不只是"正常情况下能用",而是"出问题了也不会崩,还能告诉用户出了什么问题"。
⚠️ 注意事项
rand() % (termW - logoW)括号不能少 :如果写成rand() % termW - logoW,运算顺序就错了(先取模再减),结果就不对了。一定要用括号把termW - logoW括起来。- 方向随机不要和位置随机搞混:位置是在屏幕范围内随机,方向只有两种选择(正或负),它们的随机公式不一样。
- srand 一定在所有 rand 之前 :初始化种子要放在第一次调用
rand()之前。我们放在main()函数开头,就没问题。 - 窗口太小的情况:如果窗口太小,程序直接退出了,用户可能不知道为什么。所以我们打印了一条提示信息,这是友好的做法。
🧩 拼合步骤:从零件到成品
前面 6 章我们是一个功能一个功能地学,每章都是在上一章的基础上加东西。现在我们换一个视角------从"零"开始,分 4 步把完整程序拼出来,看看每一步到底加了什么、代码量涨了多少。
这种"增量式开发"的思路非常重要:先做一个能跑的最小版本,然后一点点加功能,每一步都能看到效果,每一步都不会出大错。
第1步:空控制台 + 静止字符画
和第 0 步(空文件)相比,新增:
| 新增功能 | 关键代码 |
|---|---|
| 控制台框架 | hideCursor(), gotoxy(), setColor(), getConsoleSize() |
| DVD 字符画 | vector<string> logo + 7 行 push_back |
| 绘制函数 | drawLogo() 逐行打印 |
| 居中显示 | (sz.first - logoW) / 2 计算中心位置 |
完整 Windows 代码:
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
int main() {
hideCursor();
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int centerX = (sz.first - logoW) / 2;
int centerY = (sz.second - logoH) / 2;
drawLogo(logo, centerX, centerY, 10);
gotoxy(0, sz.second - 1);
setColor(7);
return 0;
}
代码量: 约 60 行
效果: 控制台中心显示一个绿色的静止 DVD Logo。
🐧 Linux/macOS 对照: 把 Windows API 换成 ncurses 对应的函数(
initscr(),curs_set(0),move(y,x),printw(),getmaxyx()),编译加-lncurses,结尾加getch()+endwin()。
第2步:加移动 + 擦除重绘
和第 1 步相比,新增:
| 新增功能 | 关键代码 |
|---|---|
| 位置变量 | int x, y |
| 方向速度 | int dx, dy |
| 擦除函数 | eraseLogo() 用空格覆盖 |
| 动画主循环 | while(true) + 擦除→更新→绘制→等待 |
| 帧率控制 | Sleep(60) |
完整 Windows 代码:
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
int x = 5;
int y = 5;
int dx = 1;
int dy = 1;
int color = 10;
drawLogo(logo, x, y, color);
while (true) {
eraseLogo(x, y, logoW, logoH);
x += dx;
y += dy;
drawLogo(logo, x, y, color);
Sleep(60);
}
return 0;
}
代码量: 约 75 行(比上一步多了约 15 行)
效果: DVD Logo 从左上角出发,向右下方匀速飞行,飞出屏幕后消失。
🐧 Linux/macOS 对照:
Sleep(60)换成napms(60),擦除函数用 ncurses 版本,每帧结束加refresh()。
第3步:加碰撞反弹
和第 2 步相比,新增:
| 新增功能 | 关键代码 |
|---|---|
| 获取窗口尺寸 | termW = sz.first, termH = sz.second |
| 左右边界检测 | `if (x + dx < 1 |
| 上下边界检测 | `if (y + dy < 1 |
| 反弹(速度取反) | dx = -dx, dy = -dy |
完整 Windows 代码:
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int termW = sz.first;
int termH = sz.second;
int x = 5;
int y = 5;
int dx = 1;
int dy = 1;
int color = 10;
drawLogo(logo, x, y, color);
while (true) {
eraseLogo(x, y, logoW, logoH);
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx;
}
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy;
}
x += dx;
y += dy;
drawLogo(logo, x, y, color);
Sleep(60);
}
return 0;
}
代码量: 约 88 行(比上一步多了约 13 行)
效果: DVD Logo 在控制台内四处弹跳,永远不会飞出屏幕,但颜色一直是绿色。
🐧 Linux/macOS 对照: 碰撞检测逻辑完全一样,注意坐标边界是 0 还是 1 即可。
第4步:加变色 + 随机出生(完整版)
和第 3 步相比,新增:
| 新增功能 | 关键代码 |
|---|---|
| 随机数头文件 | #include <cstdlib>, #include <ctime> |
| 随机种子 | srand((unsigned)time(NULL)) |
| 撞墙变色 | color = rand() % 15 + 1 |
| 随机初始位置 | rand() % (termW - logoW) + 1 |
| 随机初始方向 | (rand() % 2 == 0) ? 1 : -1 |
| 随机初始颜色 | rand() % 15 + 1 |
| 窗口大小检查 | if (termW < logoW) 提示并退出 |
完整 Windows 代码(就是完整版):
cpp
#include <iostream>
#include <windows.h>
#include <vector>
#include <string>
#include <utility>
#include <cstdlib>
#include <ctime>
using namespace std;
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
void hideCursor() {
CONSOLE_CURSOR_INFO cursorInfo;
GetConsoleCursorInfo(hConsole, &cursorInfo);
cursorInfo.bVisible = false;
SetConsoleCursorInfo(hConsole, &cursorInfo);
}
void gotoxy(int x, int y) {
COORD pos = { (short)x, (short)y };
SetConsoleCursorPosition(hConsole, pos);
}
void setColor(int fg) {
SetConsoleTextAttribute(hConsole, fg);
}
pair<int, int> getConsoleSize() {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(hConsole, &csbi);
int cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
int rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
return make_pair(cols, rows);
}
void drawLogo(vector<string>& logo, int px, int py, int color) {
setColor(color);
for (int i = 0; i < logo.size(); ++i) {
gotoxy(px, py + i);
cout << logo[i];
}
}
void eraseLogo(int px, int py, int w, int h) {
setColor(7);
for (int i = 0; i < h; ++i) {
gotoxy(px, py + i);
cout << string(w, ' ');
}
}
int main() {
hideCursor();
srand((unsigned)time(NULL));
vector<string> logo;
logo.push_back("+------------+");
logo.push_back("| DVD |");
logo.push_back("| ##==## |");
logo.push_back("| ###### |");
logo.push_back("| -####- |");
logo.push_back("| VIDEO |");
logo.push_back("+------------+");
int logoW = logo[0].size();
int logoH = logo.size();
pair<int, int> sz = getConsoleSize();
int termW = sz.first;
int termH = sz.second;
if (termW < logoW || termH < logoH) {
cout << "请将控制台窗口拉大一些!" << endl;
return 1;
}
int x = rand() % (termW - logoW) + 1;
int y = rand() % (termH - logoH) + 1;
int dx = (rand() % 2 == 0) ? 1 : -1;
int dy = (rand() % 2 == 0) ? 1 : -1;
int color = rand() % 15 + 1;
drawLogo(logo, x, y, color);
while (true) {
eraseLogo(x, y, logoW, logoH);
if (x + dx < 1 || x + logoW + dx > termW) {
dx = -dx;
color = rand() % 15 + 1;
}
if (y + dy < 1 || y + logoH + dy > termH) {
dy = -dy;
color = rand() % 15 + 1;
}
x += dx;
y += dy;
drawLogo(logo, x, y, color);
Sleep(60);
}
return 0;
}
代码量: 约 105 行(比上一步多了约 17 行)
效果: 完整的 DVD 弹跳 Logo 屏保------随机位置出生、随机方向飞行、撞墙反弹、撞墙变色,每次运行都不一样。
🐧 Linux/macOS 对照: 随机逻辑完全一样,颜色系统需要用 ncurses 的
init_pair()+COLOR_PAIR()方式。
🏆 完整版出炉
恭喜你!经过 4 步增量式开发,我们完成了完整的 DVD 弹跳 Logo 屏保。完整版一共 123 行(含空行和注释),核心逻辑不到 30 行。
模块对照表
| 模块 | 函数/变量 | 行数 | 作用 |
|---|---|---|---|
| 控制台工具层 | hConsole |
1 | 控制台句柄 |
hideCursor() |
5 | 隐藏光标 | |
gotoxy() |
3 | 光标定位 | |
setColor() |
2 | 设置颜色 | |
getConsoleSize() |
7 | 获取窗口大小 | |
| 渲染层 | drawLogo() |
5 | 绘制字符画 |
eraseLogo() |
5 | 擦除字符画 | |
| 数据层 | logo (vector) |
7 | DVD 图案数据 |
logoW, logoH |
2 | 图案尺寸 | |
termW, termH |
2 | 窗口尺寸 | |
| 状态层 | x, y |
2 | 当前位置 |
dx, dy |
2 | 速度方向 | |
color |
1 | 当前颜色 | |
| 初始化 | srand() |
1 | 随机种子 |
| 随机出生 | 3 | 位置/方向/颜色随机 | |
| 主循环 | while(true) |
~15 | 擦除→碰撞→更新→绘制→等待 |
| 总计 | ~123 |
可以看到,虽然总共有 123 行,但大部分都是"基础设施"(控制台工具函数),真正的游戏逻辑(主循环里的内容)其实只有十几行。这就是好的代码结构------把底层工具封装好,上层逻辑就非常简洁。
✨ 架构总结
做完了项目,我们停下来复盘一下。这个 123 行的小程序,里面藏着很多游戏/动画程序的通用架构思想。把这些想明白了,以后做更复杂的项目(比如打砖块、贪吃蛇、甚至 3D 游戏)都是同样的套路。
代码量增长表
| 步骤 | 功能 | 代码量 | 新增 | 增长率 |
|---|---|---|---|---|
| 第1步 | 空控制台 + 静止字符画 | ~60 行 | 60 | - |
| 第2步 | 加移动 + 擦除重绘 | ~75 行 | +15 | +25% |
| 第3步 | 加碰撞反弹 | ~88 行 | +13 | +17% |
| 第4步 | 加变色 + 随机出生 | ~105 行 | +17 | +19% |
| 完整版 | 全部功能 | 123 行 | - | - |
你会发现一个有趣的规律:基础设施占了大头,功能叠加的成本越来越低。前 60 行都是在搭架子(控制台工具函数、字符画数据结构),后面每加一个大功能,只需要加十几行。
这就像建房子:打地基、搭框架要花很多时间,但地基打好了,往上加楼层就快了。编程也是一样------好的架构让后续功能"加起来很便宜"。
三层架构详解
我们的程序虽然小,但已经具备了经典游戏的三层架构:
┌─────────────────────────────────┐
│ 逻辑层 (Logic) │ ← 碰撞检测、速度更新、变色逻辑
│ x += dx | dx = -dx │
├─────────────────────────────────┤
│ 渲染层 (Render) │ ← 画东西、擦东西
│ drawLogo() | eraseLogo() │
├─────────────────────────────────┤
│ 平台层 (Platform) │ ← 和操作系统打交道
│ gotoxy() | setColor() ... │
└─────────────────────────────────┘
1. 平台层(最底层)------ 和操作系统对话
- 代表函数:
hideCursor(),gotoxy(),setColor(),getConsoleSize(),Sleep() - 职责:封装 Windows 控制台 API,给上层提供简单好用的工具
- 比喻:就像画家的"工具箱"------里面有画笔、橡皮、调色盘、尺子
- 特点:这一层是"和操作系统绑定的",换了平台(比如从 Windows 换到 Linux),这一层就要重写,但上面两层完全不用动
2. 渲染层(中间层)------ 把数据变成画面
- 代表函数:
drawLogo(),eraseLogo() - 职责:根据当前的状态数据(位置、颜色、图案),把画面画出来
- 比喻:就像画家的"手"------脑子里想好了画什么、画在哪、用什么颜色,手就负责执行
- 特点:渲染层只关心"怎么画",不关心"为什么要画在这"。它不知道 Logo 为什么在这个位置,它只接到指令:"在 (x, y) 用 color 画 logo"
3. 逻辑层(最上层)------ 游戏的大脑
- 代表代码:主循环里的碰撞检测、坐标更新、变色逻辑
- 职责:决定游戏下一帧应该是什么样子------检测碰撞、更新位置、改变颜色
- 比喻:就像画家的"大脑"------思考物体该怎么运动、什么时候该转弯、什么时候该变色
- 特点:逻辑层是纯数学计算,和平台完全无关。碰撞检测的代码在 Windows、Linux、手机上都是一样的
三层架构的好处是什么?
想象一下,如果我们想把这个程序移植到手机上:
- 平台层:全部重写(手机没有
windows.h) - 渲染层:小改(手机上不是画字符而是画图片)
- 逻辑层:一行都不用改!碰撞检测还是
dx = -dx,随机数还是rand() % 15 + 1
这就是分层设计的威力------把"会变的"和"不会变的"分开,变化来了只需要改会变的那部分。
主循环四步详解
我们的主循环 while(true) 里做了四件事,这四步是所有游戏/动画主循环的经典模板:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 1.擦除 │ → │ 2.逻辑 │ → │ 3.绘制 │ → │ 4.等待 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
↑ │
└──────────────────────────────────────────────┘
第 1 步:擦除(Erase)
- 做什么:把上一帧的图案擦掉
- 代码:
eraseLogo(x, y, logoW, logoH) - 归类:渲染层
- 比喻:翻页动画里"翻走旧的一页"
第 2 步:逻辑(Update / Logic)
- 做什么:碰撞检测、速度取反、变色、更新坐标
- 代码:两个
if碰撞检测 +x += dx; y += dy; - 归类:逻辑层
- 比喻:"算一算下一帧应该是什么样子"
第 3 步:绘制(Draw / Render)
- 做什么:在新位置画出新的一帧
- 代码:
drawLogo(logo, x, y, color) - 归类:渲染层
- 比喻:翻页动画里"画上新的一页"
第 4 步:等待(Wait / Sleep)
- 做什么:暂停一段时间,控制帧率
- 代码:
Sleep(60) - 归类:平台层
- 比喻:"节拍器",保证动画速度稳定
这四步一轮就是一帧,每秒循环 16 次左右,人眼就看到流畅的动画了。
💡 思考: 为什么是"擦除→逻辑→绘制"的顺序?能不能改成"逻辑→擦除→绘制"?
答案是可以的,而且很多游戏就是这么写的。逻辑放在最前面(先算好下一帧的状态),然后擦旧的、画新的。两种顺序差别不大,我们的写法是因为要擦除的是"当前位置"(x, y 还是旧的),所以先擦再更新。
核心设计话题:擦除重绘法 vs 双缓冲
我们这个项目用的是擦除重绘法 ------直接在屏幕上擦了画、画了擦。这是最简单的动画方案,但它有个缺点:闪烁。
为什么会闪烁?因为在"擦完了但还没画新的"那一瞬间,屏幕上有一块是空白的。如果帧率不够高或者图案很大,人眼就能察觉到这个空白,感觉画面在"闪"。
有没有更好的方法?有,那就是双缓冲(Double Buffering)。
双缓冲的原理:
┌──────────────┐ ┌──────────────┐
│ 前台缓冲区 │ ←──── │ 后台缓冲区 │
│ (显示中的) │ 交换 │ (正在画的) │
└──────────────┘ └──────────────┘
↑ ↑
人眼看到的 我们在画的
双缓冲的思路是:
- 屏幕上显示的叫"前台缓冲区",人眼看着
- 我们不在前台画画,而是在一个隐藏的"后台缓冲区"里画
- 等后台缓冲区完全画好了,"唰"的一下把前后台交换
- 交换的速度非常快,人眼完全察觉不到
这样做的好处是:人眼永远看到的是完整的画面,不会看到"擦了一半"或者"画了一半"的中间状态,所以完全不会闪烁。
两种方法对比:
| 对比项 | 擦除重绘法 | 双缓冲 |
|---|---|---|
| 复杂度 | 非常简单 | 稍复杂 |
| 闪烁 | 有(程度不同) | 完全没有 |
| 性能 | 低(直接写屏) | 高(后台画完一次交换) |
| 适合场景 | 简单动画、少量物体 | 复杂动画、大量物体 |
| 初学难度 | ⭐ | ⭐⭐⭐ |
| 我们的项目 | ✅ 用的就是这个 | 进阶方向 |
对于我们这个 DVD 弹跳 Logo(只有一个物体、图案也不大),擦除重绘法完全够用,闪烁也不明显。但如果你要做"几百个粒子乱飞"或者"复杂的场景动画",双缓冲就是必须的了。
📌 预告: 在本系列的粒子烟花那一篇,我们会学习双缓冲技术,做出完全不闪烁的粒子爆炸效果。继续往下看"学习路径"了解更多。
🛤️ 学习路径
核心思路:从简单动画到复杂游戏
这篇讲的是擦除重绘法------最朴素的动画实现方式。学会了这个,你已经掌握了控制台动画的基本原理。
下一步学什么
🌱 同系列下一篇:字母雨打字游戏
学完了简单动画,可以挑战一个更完整的打字小游戏:
👉 【打字游戏】C++ 实现控制台字母雨打字游戏
在那里你会学到:
- 双缓冲渲染 --- 比擦除重绘更高级的动画技术,画面不闪烁
- 游戏循环 --- 输入 → 更新 → 渲染 的经典游戏框架
- 难度递增 --- 让游戏越玩越有挑战性
- 堆积机制 --- 字母落下来堆成山的效果
🌿 再下一篇:控制台画板
如果你更喜欢工具类的方向,可以去玩:
👉 【二维数组】C++ 实现控制台画板工具
在那里你会学到:
- 二维数组 --- 存下整张画布的数据
- 文件读写 --- 把你的作品保存到电脑里
- MVC 思想 --- 数据、视图、控制分开的设计模式
🌳 挑战进阶:粒子烟花系列
如果你想做更炫酷的动画效果,推荐挑战:
👉 粒子烟花系列(4 篇,从基础到雪崩)
从简单的粒子爆炸,到键盘控制烟花,再到粒子沉积堆积,最后到雪崩效应。用的就是双缓冲技术,一步一步做出完整的物理模拟系统。
💡 学习建议
- 按顺序来:擦除重绘(本篇)→ 双缓冲(字母雨 / 粒子烟花)→ 更复杂的系统
- 动手改代码:每学完一篇,试着改改参数、加加功能,比只读不写印象深得多
- 自己想创意:学到的技术可以组合出无数玩法,大胆去试!
📖 头文件速查
这个项目我们一共用了 7 个头文件。下面是每个头文件的简介和参考链接,方便你查阅。
| 头文件 | 我们用到的功能 | 简介 |
|---|---|---|
iostream |
cout |
标准输入输出流,C++ 最基础的头文件 |
windows.h |
HANDLE, GetStdHandle(), SetConsoleCursorInfo(), SetConsoleCursorPosition(), SetConsoleTextAttribute(), Sleep(), CONSOLE_CURSOR_INFO, COORD |
Windows 系统 API,包含控制台操作、窗口管理等 |
vector |
vector<string> |
动态数组容器,可以自动扩容 |
string |
string, string(w, ' ') |
字符串类,比 C 风格的 char 数组好用 |
utility |
pair<int, int>, make_pair() |
pair 工具,用来把两个值打包在一起 |
cstdlib |
rand(), srand() |
C 标准库的随机数函数等 |
ctime |
time() |
时间函数,用来获取当前时间(作随机种子) |
详细文档链接(cppreference):
-
<iostream>--- https://en.cppreference.com/w/cpp/header/iostream- C++ 标准输入输出库,提供
cin、cout、cerr等流对象
- C++ 标准输入输出库,提供
-
<windows.h>--- Windows 专用,无 cppreference- Windows 操作系统的核心头文件,包含了几千个 API 函数
- 完整的 Windows API 文档在微软官网:https://learn.microsoft.com/en-us/windows/console/console-functions
-
<vector>--- https://en.cppreference.com/w/cpp/header/vector- 标准库中的动态数组容器,支持自动扩容、随机访问、尾部插入删除
- 是 C++ 最常用的容器之一
-
<string>--- https://en.cppreference.com/w/cpp/header/string- 标准字符串类,封装了字符串的存储、拼接、查找、替换等操作
-
<utility>--- https://en.cppreference.com/w/cpp/header/utility- 提供
pair、swap、move等基础工具 - 其中
pair是最简单的"两个值的打包器"
- 提供
-
<cstdlib>--- https://en.cppreference.com/w/cpp/header/cstdlib- C 标准库的 C++ 版本,包含通用工具函数
- 除了
rand/srand,还有malloc/free、exit、atoi等
-
<ctime>--- https://en.cppreference.com/w/cpp/header/ctime- C 时间库的 C++ 版本
- 提供时间获取、时间格式化、计时等功能
💡 小技巧: 遇到不会用的函数,直接去 cppreference 查。这是 C++ 程序员的"字典",比百度靠谱多了。建议养成查官方文档的习惯。
🔜 拓展方向
学完了基础版,你可能会想:"还能加什么功能?" 这里给你准备了 4 个级别、共 16 个拓展方向,从简单到困难,任你挑(zhěng)战(huó)。
🌱 入门级(4 个)------ 改改参数,练练手
适合刚学完基础版的同学,主要是熟悉代码结构,改改常量看看效果。
-
改颜色 --- 把随机颜色改成你喜欢的几种颜色之间切换(比如只在红、黄、蓝之间变)
- 提示:不用
rand() % 15 + 1,而是弄一个颜色数组,从数组里随机选
- 提示:不用
-
改速度 --- 让 Logo 飞得更快或更慢,试试不同的 Sleep 时间
- 提示:改
Sleep(60)的数值,或者把 dx, dy 改成 2、3 看看效果
- 提示:改
-
改 Logo 文字 --- 把 "DVD" 改成你的名字,或者 "HELLO",做一个专属屏保
- 提示:修改
logo.push_back()里的字符串就行
- 提示:修改
-
改 Logo 形状 --- 换一个完全不同的图案,比如爱心、笑脸、小房子
- 提示:自己设计一个字符画,注意每行长度要一致
🌿 进阶级(4 个)------ 加新功能,动动脑
需要加一些逻辑,但难度不算大,适合想挑战一下的同学。
-
加碰撞计数 --- 在屏幕角落显示"已经撞了多少次墙"
- 提示:加一个计数器变量,每次撞墙就 +1,每帧把数字画在固定位置
-
加音效 --- 撞墙的时候"嘀"一声,更有那味儿
- 提示:Windows 可以用
Beep(freq, duration)函数,Linux 可以用printf("\a")响铃
- 提示:Windows 可以用
-
加多个 Logo --- 屏幕上同时有 3 个 DVD Logo 在弹,颜色都不一样
- 提示:用数组存多个 x, y, dx, dy, color,循环处理每一个
- 进阶挑战:Logo 之间会不会碰撞?(这个就比较难了)
-
加 Logo 变大 --- 每撞一次墙,Logo 就变大一点(或者变小一点)
- 提示:图案可以缩放吗?字符画缩放比较麻烦,可以用多套不同尺寸的图案切换
🌳 高级(4 个)------ 需要点算法,真东西
这些功能需要一些算法思维,适合学有余力的同学。
-
Logo 之间碰撞 --- 多个 Logo 不仅撞墙,互相撞到也会反弹
- 提示:两个矩形碰撞检测(AABB 碰撞),判断两个矩形是否重叠
- 难点:碰撞后速度怎么分?是交换速度还是各自取反?
-
重力下落弹跳 --- 给 Logo 加重力,往下掉的时候越来越快,撞到地板弹起来,弹一次比一次低
- 提示:加一个重力加速度
g,每帧dy += g,碰地板时dy = -dy * 0.8(能量损失)
- 提示:加一个重力加速度
-
鼠标控制 --- 用鼠标拖动 Logo,或者用鼠标控制挡板,把 Logo 挡回去(像打砖块)
- 提示:Windows 控制台可以用
ReadConsoleInput读取鼠标事件 - 难度较高,需要学习控制台输入事件处理
- 提示:Windows 控制台可以用
-
Logo 拖尾效果 --- Logo 飞过后留下淡淡的拖尾,像彗星一样
- 提示:不是完全擦除,而是把上一帧的颜色调淡一点再画
- 或者:记录最近 N 个位置,越旧的位置颜色越暗
🌋 专家级(4 个)------ 真·硬核挑战
这些是给想"玩大的"同学准备的,做完你就是控制台动画大神了。
-
3D 透视弹跳 --- 做一个伪 3D 效果,Logo 不仅弹,还会有"远近"变化(近大远小)
- 提示:加一个 z 坐标,用透视公式
scale = focal / z计算缩放比例 - 难点:字符画怎么缩放?可以用多套尺寸 + 插值
- 提示:加一个 z 坐标,用透视公式
-
物理引擎 --- 实现一个简单的 2D 物理引擎,支持多个物体、重力、碰撞、摩擦、弹性
- 提示:每个物体有位置、速度、质量,每帧做力的累加、积分、碰撞检测、碰撞响应
- 这是游戏物理引擎的简化版
-
关卡系统 --- 做成一个完整的打砖块游戏,有多关卡、不同砖块布局、生命值
- 提示:用二维数组存砖块地图,不同关卡加载不同的地图
- 可以加道具系统(加挡板、加球、减速等)
-
屏保模式 --- 做成真正的 Windows 屏保程序(.scr 文件),可以在屏幕保护设置里选择
- 提示:Windows 屏保本质上就是个 exe,改扩展名为 .scr 就行
- 需要处理全屏、鼠标移动退出、密码保护等屏保特性
🎯 写在最后:
DVD 弹跳 Logo 这个项目看起来简单,但它涵盖了控制台动画的核心思想------擦除重绘、游戏循环、碰撞检测、随机数。这些概念是所有动画和游戏的基础。
不要因为它"简单"就小看它。把简单的东西做扎实了,复杂的东西自然就会了。就像学画画先练画直线、画圆圈一样,基本功练好了,画什么都像那么回事。
继续加油,我们下一篇见!
(全文完)
系列:C++ 控制台编程实战
上一篇:这是本系列第一篇
下一篇:【打字游戏】C++ 实现控制台字母雨打字游戏
🏷️ 标签: C++ 控制台编程 / 控制台动画 / 擦除重绘 / 碰撞检测 / 随机数 / DVD屏保 / 入门教程