16、C++项目实战:简易画图板(ASCII艺术)

说实话,做了这么多年C++开发,我始终觉得「即时反馈」是编程学习里最爽的事。你写几行代码,马上能看到图形、动画、甚至一个能交互的小工具——这种成就感,比单纯算个斐波那契数列强太多了。

这一章,我们就来做一个简易画图板。不是那种用鼠标拖拽的GUI,而是基于ASCII艺术的字符画板。说白了,就是用键盘在终端里「画」出图形来。你别小看它,我当年在公司做嵌入式调试工具时,就靠这套思路在只有串口终端的设备上画出了实时波形图。

项目目标

我们要实现一个能在终端里自由绘制、擦除、保存和加载的ASCII画图板。最终效果类似这样:

+------------------+
|                  |
|   @@@@           |
|  @    @          |
| @      @         |
|  @    @          |
|   @@@@           |
|                  |
+------------------+

用户可以用方向键移动光标,用特定按键「落笔」画字符,或者「抬笔」移动。嗯,这里要注意:我们不是真的在画像素,而是在一个二维字符数组里填字符。

核心数据结构

我个人习惯,做这种项目先想清楚数据怎么存。画板本质上就是一个二维字符矩阵:

const int WIDTH = 40;
const int HEIGHT = 20;

char canvas[HEIGHT][WIDTH];

每个格子存一个字符。空格表示空白,其他字符(比如 @#*)就是「颜料」。你想想看,这跟位图(bitmap)的思路其实一模一样,只不过每个像素只有8位(一个char),而且显示方式是用字符拼出来的。

我在项目中遇到过类似的需求:需要在日志文件里输出简单的柱状图。当时就是用这种字符矩阵,配合 printf 一行行打印,效果出奇地好。

光标与状态管理

画图板需要跟踪两个关键状态:

  • 光标位置:当前画笔落在哪个格子
  • 画笔状态:是「落下」还是「抬起」

我们用两个整数记录光标位置:

int cursorX = WIDTH / 2;
int cursorY = HEIGHT / 2;
bool penDown = false;

penDowntrue 时,移动光标就会在路径上留下字符。否则只是移动,不画任何东西。

小技巧:我建议把光标位置初始放在画布中央,这样用户一启动就能直接画,体验更好。

核心操作函数

我们把功能拆成几个清晰的函数。这样做的好处是:每个函数只做一件事,调试起来特别方便。

1. 初始化画布

void initCanvas() {
    for (int y = 0; y < HEIGHT; y++) {
        for (int x = 0; x < WIDTH; x++) {
            canvas[y][x] = ' ';
        }
    }
}

全部填成空格,就是一张白纸。

2. 绘制边框

为了让画板看起来更规整,我们加一个边框。边框用 +-| 字符:

void drawBorder() {
    // 上下边框
    for (int x = 0; x < WIDTH + 2; x++) {
        cout << '-';
    }
    cout << endl;

    // 左右边框 + 画布内容
    for (int y = 0; y < HEIGHT; y++) {
        cout << '|';
        for (int x = 0; x < WIDTH; x++) {
            if (x == cursorX && y == cursorY) {
                cout << '#';  // 光标用特殊字符显示
            } else {
                cout << canvas[y][x];
            }
        }
        cout << '|' << endl;
    }

    // 下边框
    for (int x = 0; x < WIDTH + 2; x++) {
        cout << '-';
    }
    cout << endl;
}

这里有个细节:光标位置用 # 显示,这样用户能清楚知道自己当前在哪儿。我曾经在第一个版本里忘了加光标指示,结果用户完全不知道笔在哪,体验极差。

3. 移动光标与落笔

void moveCursor(int dx, int dy) {
    int newX = cursorX + dx;
    int newY = cursorY + dy;

    // 边界检查
    if (newX < 0 || newX >= WIDTH) return;
    if (newY < 0 || newY >= HEIGHT) return;

    if (penDown) {
        canvas[cursorY][cursorX] = currentChar;  // 落笔时留下字符
    }

    cursorX = newX;
    cursorY = newY;
}

为什么要先画再移动?因为「落笔」的意思是:在离开当前位置时留下痕迹。这跟真实画笔的逻辑是一样的——笔尖经过的地方都会留下颜料。

用户交互循环

画图板的核心是一个事件循环。每次循环:

  1. 清屏
  2. 绘制当前画布
  3. 等待用户按键
  4. 根据按键执行操作
void run() {
    initCanvas();
    char cmd;

    while (true) {
        system("clear");  // Windows 下用 system("cls")
        drawBorder();

        cmd = getch();  // 需要自己实现或使用 conio.h

        switch (cmd) {
            case 'w': moveCursor(0, -1); break;
            case 's': moveCursor(0, 1); break;
            case 'a': moveCursor(-1, 0); break;
            case 'd': moveCursor(1, 0); break;
            case ' ': penDown = !penDown; break;  // 切换画笔状态
            case 'q': return;  // 退出
            case 'c': system("clear"); initCanvas(); break;  // 清空画布
            default: break;
        }
    }
}
注意system("clear") 在跨平台时会有问题。Windows 用 cls,Linux/macOS 用 clear。我曾经在给客户演示时忘了改这个,结果在Windows上跑出了乱码……嗯,从那以后我都是用条件编译来处理。

保存与加载

画完了总得能存下来吧?保存功能就是把字符矩阵写入文件:

void saveCanvas(const string& filename) {
    ofstream file(filename);
    if (!file) {
        cerr << "无法打开文件: " << filename << endl;
        return;
    }

    for (int y = 0; y < HEIGHT; y++) {
        for (int x = 0; x < WIDTH; x++) {
            file << canvas[y][x];
        }
        file << endl;
    }

    file.close();
    cout << "保存成功!" << endl;
}

加载就是反过来,逐行读取字符填回矩阵。注意要处理文件格式错误的情况——比如文件行数不够,或者某行长度不对。

知识体系总览

下面这张图帮你理清整个项目的结构:

简易画图板(ASCII艺术)知识体系 核心数据 字符矩阵 canvas[][] 光标位置 (x, y) 画笔状态 penDown 初始化 initCanvas() 绘制 drawBorder() 移动 moveCursor() 保存/加载 事件循环:清屏 → 绘制 → 等待按键 → 执行

扩展思路

这个基础版本做完之后,你可以加很多有意思的功能:

  • 多种画笔:按数字键1-9切换不同字符,比如 @ 是粗笔,. 是细笔
  • 颜色支持:在支持ANSI转义码的终端里,给不同字符加上颜色
  • 撤销功能:维护一个操作历史栈,每次修改前保存当前画布快照
  • 填充工具:实现一个泛洪填充(flood fill)算法,像油漆桶一样填色

核心收获:这个项目虽然简单,但它涵盖了C++编程中几个非常重要的概念:二维数组操作、状态机设计、事件循环、文件I/O。更重要的是,它让你体会到「用代码创造视觉内容」的乐趣。

我个人觉得,这种小工具特别适合作为C++入门后的第一个「完整项目」。它不依赖任何第三方库,纯标准库就能搞定,而且做完之后真的能拿来玩一玩。你想想看,在终端里用字符画出一只小猫或者一颗爱心,发给朋友看——这种成就感,比刷一百道算法题都来得实在。

好,动手试试吧。从初始化画布开始,一步步把每个函数写出来。遇到问题很正常,调试的时候多用 cout 打印中间状态,很快就能找到问题所在。


公众号:蓝海资料掘金营,微信deep3321