【擦除重绘】C++ 控制台动画:弹跳 Logo DVD 屏保效果

系列: 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 hConsole stdscr 标准窗口(默认全屏)
GetStdHandle() initscr() 初始化
SetConsoleCursorInfo() curs_set(0) 隐藏光标
SetConsoleCursorPosition() move(y, x) 移动光标
SetConsoleTextAttribute() attron() / COLOR_PAIR() 设置颜色
GetConsoleScreenBufferInfo() getmaxyx() 获取窗口大小
cout printw() 输出文字
Sleep(ms) napms(ms) 休眠毫秒

📝 逐行看懂这段代码

第 2 行:#include <windows.h>

这是 Windows 系统提供的"工具箱",里面包含了所有控制台操作的函数。就像你画画需要打开画笔工具箱一样,写控制台程序就要包含这个头文件。

第 8 行:HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);

HANDLE 翻译过来是"句柄",你可以把它理解成遥控器 。通过这个遥控器,我们就能指挥控制台做各种事情------移动光标、改颜色、查大小等等。GetStdHandle(STD_OUTPUT_HANDLE) 就是从系统那里拿到"标准输出"的遥控器。

第 11-15 行:hideCursor() 函数

控制台上那个一闪一闪的光标会影响动画效果,所以我们要把它藏起来。流程是:

  1. 先获取当前光标的信息(存在 cursorInfo 结构体里)
  2. bVisible(可见性)改成 false
  3. 再把修改后的信息设置回去

就像你先拿一面镜子看看自己现在什么样子,然后换件衣服,再照照镜子确认换好了。

第 18-21 行:gotoxy(x, y) 函数

这是我们最常用的函数之一。COORD 是 Windows 定义的一个坐标结构体,里面有 XY 两个值。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_INFOCOORDCONSOLE_SCREEN_BUFFER_INFO 这些"奇怪的类型",它们都是结构体。你可以把结构体理解成一个"工具箱",里面装了好几个相关的数据。

比如 COORD 这个工具箱里装了 XY 两个整数,合在一起表示一个坐标点。CONSOLE_CURSOR_INFO 里装了光标的大小、是否可见等信息。

⚠️ 注意事项

  1. 坐标系原点在左上角:x 从左往右数,y 从上往下数,都是从 0 开始。
  2. 颜色编号范围是 0-15:0 是黑色(和背景一样,写了也看不见),7 是默认的灰白色,1-6 和 8-15 是各种彩色。
  3. pair 的第一个是 first,第二个是 second:别搞反了,sz.first 是列数(宽度),sz.second 是行数(高度)。
  4. 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 的比例),所以如果你想画一个正方形,行数应该大约是列数的一半。

⚠️ 注意事项

  1. 所有行长度最好一致:如果行与行长度不一样,图案会歪歪扭扭的。我们的 DVD Logo 每行都是 14 个字符,很整齐。
  2. logo[0] 要确保存在:调用 logo[0].size() 之前,一定要确认 vector 不是空的,否则程序会崩溃。
  3. 位置不要超出窗口 :如果 px 是负数或者超过了窗口宽度,打印出来的东西会被截断或者跑到奇怪的地方。后面我们会用碰撞检测来保证这一点。
  4. 颜色 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 = 向上

dxdy 合在一起叫方向向量 。每次循环,我们都让 x += dxy += 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 行:动画主循环

这是整个程序的核心------游戏循环 / 动画循环。每一帧都做四件事:

  1. 擦除:把上一帧的图案擦掉(用空格覆盖)
  2. 更新:根据速度更新位置
  3. 绘制:在新位置画出来
  4. 等待:暂停一小会儿,控制动画速度

这个循环会一直跑下去(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 就像动画的"节拍器",控制着每秒画多少帧。找到合适的节奏,动画才好看。

⚠️ 注意事项

  1. 先擦后画,顺序不能反:如果先画新的再擦旧的,会出现两个图案同时存在的瞬间,效果很奇怪。
  2. 图案会飞出屏幕:这一章我们还没做碰撞检测,Logo 飞出去就不见了,这是正常的。下一章就解决这个问题。
  3. 可能会有轻微闪烁:擦除重绘法天然会有一点闪烁,特别是图案比较大的时候。这是正常现象,后面我们会讨论更高级的"双缓冲"技术。
  4. Sleep 不是精确的:Windows 的 Sleep 最小精度大约是 10-15 毫秒,所以 Sleep(60) 不一定正好 60 毫秒,可能稍微多一点点。对于简单动画来说完全够用了。
  5. 退出按 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()

碰撞检测代码完全照搬:

cpp 复制代码
if (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. 边界从 1 开始还是从 0 开始? 控制台的坐标其实是从 0 开始的,但我们用 < 1 当作左边界,这样左边留了一列的余量,看起来更舒服。你也可以用 < 0,效果差不多。
  2. 右边界是 x + logoW 不是 x :新手最容易犯的错误就是忘了加 logoW,只判断 x > termW。这样的话,Logo 的左边沿碰到右墙才反弹,但右边早就飞出屏幕了。
  3. 速度太快可能穿墙:如果 dx 很大(比如 10),一帧移动 10 个字符,就可能出现"这一帧还在墙左边,下一帧直接穿到墙右边"的情况,碰撞检测就失效了。我们用 dx=1 就不会有这个问题。
  4. 窗口大小不要中途改变 :如果用户在运行时拉大或缩小控制台窗口,termWtermH 不会自动更新,碰撞边界就不对了。进阶版本可以每帧都获取一次窗口大小,但对初学者来说先不管这个。

第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);
// ... 还有亮色版本

然后随机选一个颜色对编号来用:

cpp 复制代码
color = 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 的随机数

记住这个公式,以后生成随机范围的数就靠它了。

⚠️ 注意事项

  1. srand 只调用一次:随机种子初始化一定要放在循环外面,程序开头调用一次就够了。如果在循环里反复调用 srand,因为同一秒内时间不变,种子就一样,生成的随机数也一样。
  2. 颜色 0 不要用 :0 号是黑色,和背景色一样,会让 Logo "消失"。所以我们用 % 15 + 1 跳过 0。
  3. 同时撞两个墙会变两次色:如果 Logo 正好撞到角上(同时撞横墙和竖墙),两个 if 都会执行,颜色会随机两次。这其实不是 bug,反而更有趣------撞角的时候"连换两件衣服"。
  4. 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 版本只需要注意:

  1. 坐标从 0 开始的话,初始位置公式是:

    cpp 复制代码
    int x = rand() % (termW - logoW);
    int y = rand() % (termH - logoH);

    就是把 + 1 去掉就好了。

  2. 随机方向和随机颜色的代码完全一样,照搬就行。

  3. 别忘了 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. 边界检查:防御式编程

我们加的"窗口太小就退出"的检查,体现了一种编程思想叫防御式编程------不要假设一切都会正常,要提前考虑可能出问题的情况,并做好处理。

就像开车上路,你不能假设所有人都遵守交通规则,你要随时准备应对突发情况。写程序也是一样,要想想"如果用户窗口太小怎么办?"、"如果文件打不开怎么办?"、"如果输入的是负数怎么办?"

好的程序不只是"正常情况下能用",而是"出问题了也不会崩,还能告诉用户出了什么问题"。

⚠️ 注意事项

  1. rand() % (termW - logoW) 括号不能少 :如果写成 rand() % termW - logoW,运算顺序就错了(先取模再减),结果就不对了。一定要用括号把 termW - logoW 括起来。
  2. 方向随机不要和位置随机搞混:位置是在屏幕范围内随机,方向只有两种选择(正或负),它们的随机公式不一样。
  3. srand 一定在所有 rand 之前 :初始化种子要放在第一次调用 rand() 之前。我们放在 main() 函数开头,就没问题。
  4. 窗口太小的情况:如果窗口太小,程序直接退出了,用户可能不知道为什么。所以我们打印了一条提示信息,这是友好的做法。

🧩 拼合步骤:从零件到成品

前面 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)

双缓冲的原理:

复制代码
┌──────────────┐        ┌──────────────┐
│  前台缓冲区   │  ←──── │  后台缓冲区   │
│  (显示中的)   │  交换   │  (正在画的)   │
└──────────────┘        └──────────────┘
      ↑                        ↑
   人眼看到的               我们在画的

双缓冲的思路是:

  1. 屏幕上显示的叫"前台缓冲区",人眼看着
  2. 我们不在前台画画,而是在一个隐藏的"后台缓冲区"里画
  3. 等后台缓冲区完全画好了,"唰"的一下把前后台交换
  4. 交换的速度非常快,人眼完全察觉不到

这样做的好处是:人眼永远看到的是完整的画面,不会看到"擦了一半"或者"画了一半"的中间状态,所以完全不会闪烁。

两种方法对比:

对比项 擦除重绘法 双缓冲
复杂度 非常简单 稍复杂
闪烁 有(程度不同) 完全没有
性能 低(直接写屏) 高(后台画完一次交换)
适合场景 简单动画、少量物体 复杂动画、大量物体
初学难度 ⭐⭐⭐
我们的项目 ✅ 用的就是这个 进阶方向

对于我们这个 DVD 弹跳 Logo(只有一个物体、图案也不大),擦除重绘法完全够用,闪烁也不明显。但如果你要做"几百个粒子乱飞"或者"复杂的场景动画",双缓冲就是必须的了。

📌 预告: 在本系列的粒子烟花那一篇,我们会学习双缓冲技术,做出完全不闪烁的粒子爆炸效果。继续往下看"学习路径"了解更多。


🛤️ 学习路径

核心思路:从简单动画到复杂游戏

这篇讲的是擦除重绘法------最朴素的动画实现方式。学会了这个,你已经掌握了控制台动画的基本原理。

下一步学什么

🌱 同系列下一篇:字母雨打字游戏

学完了简单动画,可以挑战一个更完整的打字小游戏

👉 【打字游戏】C++ 实现控制台字母雨打字游戏

在那里你会学到:

  • 双缓冲渲染 --- 比擦除重绘更高级的动画技术,画面不闪烁
  • 游戏循环 --- 输入 → 更新 → 渲染 的经典游戏框架
  • 难度递增 --- 让游戏越玩越有挑战性
  • 堆积机制 --- 字母落下来堆成山的效果
🌿 再下一篇:控制台画板

如果你更喜欢工具类的方向,可以去玩:

👉 【二维数组】C++ 实现控制台画板工具

在那里你会学到:

  • 二维数组 --- 存下整张画布的数据
  • 文件读写 --- 把你的作品保存到电脑里
  • MVC 思想 --- 数据、视图、控制分开的设计模式
🌳 挑战进阶:粒子烟花系列

如果你想做更炫酷的动画效果,推荐挑战:

👉 粒子烟花系列(4 篇,从基础到雪崩)

从简单的粒子爆炸,到键盘控制烟花,再到粒子沉积堆积,最后到雪崩效应。用的就是双缓冲技术,一步一步做出完整的物理模拟系统。

💡 学习建议

  1. 按顺序来:擦除重绘(本篇)→ 双缓冲(字母雨 / 粒子烟花)→ 更复杂的系统
  2. 动手改代码:每学完一篇,试着改改参数、加加功能,比只读不写印象深得多
  3. 自己想创意:学到的技术可以组合出无数玩法,大胆去试!

📖 头文件速查

这个项目我们一共用了 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):

💡 小技巧: 遇到不会用的函数,直接去 cppreference 查。这是 C++ 程序员的"字典",比百度靠谱多了。建议养成查官方文档的习惯。


🔜 拓展方向

学完了基础版,你可能会想:"还能加什么功能?" 这里给你准备了 4 个级别、共 16 个拓展方向,从简单到困难,任你挑(zhěng)战(huó)。

🌱 入门级(4 个)------ 改改参数,练练手

适合刚学完基础版的同学,主要是熟悉代码结构,改改常量看看效果。

  1. 改颜色 --- 把随机颜色改成你喜欢的几种颜色之间切换(比如只在红、黄、蓝之间变)

    • 提示:不用 rand() % 15 + 1,而是弄一个颜色数组,从数组里随机选
  2. 改速度 --- 让 Logo 飞得更快或更慢,试试不同的 Sleep 时间

    • 提示:改 Sleep(60) 的数值,或者把 dx, dy 改成 2、3 看看效果
  3. 改 Logo 文字 --- 把 "DVD" 改成你的名字,或者 "HELLO",做一个专属屏保

    • 提示:修改 logo.push_back() 里的字符串就行
  4. 改 Logo 形状 --- 换一个完全不同的图案,比如爱心、笑脸、小房子

    • 提示:自己设计一个字符画,注意每行长度要一致

🌿 进阶级(4 个)------ 加新功能,动动脑

需要加一些逻辑,但难度不算大,适合想挑战一下的同学。

  1. 加碰撞计数 --- 在屏幕角落显示"已经撞了多少次墙"

    • 提示:加一个计数器变量,每次撞墙就 +1,每帧把数字画在固定位置
  2. 加音效 --- 撞墙的时候"嘀"一声,更有那味儿

    • 提示:Windows 可以用 Beep(freq, duration) 函数,Linux 可以用 printf("\a") 响铃
  3. 加多个 Logo --- 屏幕上同时有 3 个 DVD Logo 在弹,颜色都不一样

    • 提示:用数组存多个 x, y, dx, dy, color,循环处理每一个
    • 进阶挑战:Logo 之间会不会碰撞?(这个就比较难了)
  4. 加 Logo 变大 --- 每撞一次墙,Logo 就变大一点(或者变小一点)

    • 提示:图案可以缩放吗?字符画缩放比较麻烦,可以用多套不同尺寸的图案切换

🌳 高级(4 个)------ 需要点算法,真东西

这些功能需要一些算法思维,适合学有余力的同学。

  1. Logo 之间碰撞 --- 多个 Logo 不仅撞墙,互相撞到也会反弹

    • 提示:两个矩形碰撞检测(AABB 碰撞),判断两个矩形是否重叠
    • 难点:碰撞后速度怎么分?是交换速度还是各自取反?
  2. 重力下落弹跳 --- 给 Logo 加重力,往下掉的时候越来越快,撞到地板弹起来,弹一次比一次低

    • 提示:加一个重力加速度 g,每帧 dy += g,碰地板时 dy = -dy * 0.8(能量损失)
  3. 鼠标控制 --- 用鼠标拖动 Logo,或者用鼠标控制挡板,把 Logo 挡回去(像打砖块)

    • 提示:Windows 控制台可以用 ReadConsoleInput 读取鼠标事件
    • 难度较高,需要学习控制台输入事件处理
  4. Logo 拖尾效果 --- Logo 飞过后留下淡淡的拖尾,像彗星一样

    • 提示:不是完全擦除,而是把上一帧的颜色调淡一点再画
    • 或者:记录最近 N 个位置,越旧的位置颜色越暗

🌋 专家级(4 个)------ 真·硬核挑战

这些是给想"玩大的"同学准备的,做完你就是控制台动画大神了。

  1. 3D 透视弹跳 --- 做一个伪 3D 效果,Logo 不仅弹,还会有"远近"变化(近大远小)

    • 提示:加一个 z 坐标,用透视公式 scale = focal / z 计算缩放比例
    • 难点:字符画怎么缩放?可以用多套尺寸 + 插值
  2. 物理引擎 --- 实现一个简单的 2D 物理引擎,支持多个物体、重力、碰撞、摩擦、弹性

    • 提示:每个物体有位置、速度、质量,每帧做力的累加、积分、碰撞检测、碰撞响应
    • 这是游戏物理引擎的简化版
  3. 关卡系统 --- 做成一个完整的打砖块游戏,有多关卡、不同砖块布局、生命值

    • 提示:用二维数组存砖块地图,不同关卡加载不同的地图
    • 可以加道具系统(加挡板、加球、减速等)
  4. 屏保模式 --- 做成真正的 Windows 屏保程序(.scr 文件),可以在屏幕保护设置里选择

    • 提示:Windows 屏保本质上就是个 exe,改扩展名为 .scr 就行
    • 需要处理全屏、鼠标移动退出、密码保护等屏保特性

🎯 写在最后:

DVD 弹跳 Logo 这个项目看起来简单,但它涵盖了控制台动画的核心思想------擦除重绘、游戏循环、碰撞检测、随机数。这些概念是所有动画和游戏的基础。

不要因为它"简单"就小看它。把简单的东西做扎实了,复杂的东西自然就会了。就像学画画先练画直线、画圆圈一样,基本功练好了,画什么都像那么回事。

继续加油,我们下一篇见!


(全文完)

系列:C++ 控制台编程实战

上一篇:这是本系列第一篇

下一篇:【打字游戏】C++ 实现控制台字母雨打字游戏

🏷️ 标签: C++ 控制台编程 / 控制台动画 / 擦除重绘 / 碰撞检测 / 随机数 / DVD屏保 / 入门教程

相关推荐
sakiko_2 小时前
Swift学习笔记42-SwiftUI的属性包装器(讲解+面试)
笔记·学习·ios·swiftui·swift
kyle~2 小时前
C++_STL---迭代器失效
开发语言·c++
Brilliantwxx3 小时前
【C语言】 初入嵌入式C语言复习(基础+进阶面试题)
c语言·开发语言
熊野君3 小时前
附录与 Codex 实操手册
开发语言·人工智能·产品经理
wuminyu3 小时前
深入剖析 Panama Off-heap 的性能损耗与开销
java·linux·c语言·jvm·c++
存在morning3 小时前
【Paimon 学习笔记 三】工作流程:Paimon 的批写、流写、批读与流读
笔记·学习
rhythm-ring4 小时前
宏定义续行符 \ 的使用与踩坑
c语言·c++
XZ-0700014 小时前
week2-1-可视化
开发语言·python
Java后端的Ai之路4 小时前
14、Python - 责任链模式
服务器·开发语言·人工智能·python·责任链模式