前言
字符画(ASCII art)是练习坐标变换、循环控制和输出格式的好题材:它不依赖图形库,不需要窗口系统,所有效果都由字符在二维网格中的位置决定。本文做一朵「玫瑰花」——用极坐标下的玫瑰线(rose curve)生成五片花瓣,再加上花茎和叶片,最终输出到终端。
需要先说明两点。第一,终端里的「像素」不是正方形:一个字符单元通常高约为宽的两倍,如果直接在 63×32 的网格里画一个标准圆,看起来会是一个竖长的椭圆。解决办法是让横向缩放系数约为纵向的两倍。第二,字符画没有唯一正确答案,本文给出一套可调参数的生成方式,读者可以改花瓣数、半径、字符集得到自己的版本。文中的代码按 C++17 书写,本机没有编译环境,不声称任何执行效果;所有几何结论都按公式推演。
一、坐标模型:把数学坐标映射到字符网格
终端网格的坐标原点在左上角,x 向右增大,y 向下增大。数学上的极坐标习惯是 y 向上,因此做变换时要么把 y 取反,要么在atan2里传入-ny。本文统一用「屏幕坐标」,即ny = (y - cy) / ry,此时角度 0 指向右方,角度 π/2 指向下方。对五瓣玫瑰线来说,旋转对称,方向不影响形状,只影响花瓣的起始朝向,所以不必额外取反。
映射关系如下:
nx = (x - cx) / rx // 归一化到 [-1, 1] 附近
ny = (y - cy) / ry
r = sqrt(nx^2 + ny^2)
θ = atan2(ny, nx)其中cx、cy是花心所在的列与行,rx、ry是横向和纵向的缩放半径。由于字符高宽比约为 2:1,取rx ≈ 2 * ry能让花瓣在视觉上接近圆形而不是被压扁。
| 参数 | 含义 | 本文取值 |
|---|
kWidth/kHeight | 画布列数 / 行数 | 63 / 32 |
cx/cy | 花心列 / 行 | 31.0 / 13.0 |
rx/ry | 横向 / 纵向缩放 | 21.0 / 11.0 |
| 花瓣数 | 玫瑰线的 k | 5 |
| 字符集 | 轮廓 / 内层 / 花心 | #/@/* |
二、花瓣的数学表达
极坐标玫瑰线的方程是r = a·cos(kθ)。当 k 为奇数时,曲线有 k 片花瓣;k 为偶数时花瓣数翻倍。本文取 k = 5,得到五片花瓣。为了让花瓣的边缘清晰,判断条件写成r <= |cos(5θ)|:满足的点落在花瓣内部,不满足的留空。
只有「在不在花瓣内」还不够,纯色填充会显得扁平。可以用「到边缘的距离」depth = |cos(5θ)| - r做分层:
depth < 0.06:贴近边缘,用#勾轮廓;0.06 <= depth < 0.28:过渡区,用@;- 其余:内部,用
*。
这样一片花瓣就有了从外到内的层次,而不需要任何灰度支持。
花茎是一条竖线,从花心下方一直延伸到画布底部;叶片用斜线和对角线字符拼出。由于五瓣玫瑰在正下方(θ = 90°)恰好是花瓣之间的凹陷,花茎接在花心正下方不会与花瓣重叠,视觉上比较自然。
三、完整代码
下面是一个单文件实现,包含画布构造、花瓣绘制、花茎与叶片绘制、输出裁剪。
// 适用于 C++17
#include <cmath>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
namespace {
constexpr double kPi = 3.14159265358979323846; // 不使用非标准的 M_PI
constexpr int kWidth = 63;
constexpr int kHeight = 32;
// 把花瓣画到画布上
void drawFlower(std::vector<std::string>& canvas) {
const double cx = 31.0; // 花心列
const double cy = 13.0; // 花心行
const double rx = 21.0; // 横向缩放,约为纵向的两倍
const double ry = 11.0; // 纵向缩放
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth; ++x) {
const double nx = (x - cx) / rx;
const double ny = (y - cy) / ry;
const double r = std::sqrt(nx * nx + ny * ny);
const double theta = std::atan2(ny, nx);
const double petal = std::fabs(std::cos(5.0 * theta));
if (r > petal) {
continue; // 不在花瓣内
}
const double depth = petal - r;
char ch = '*';
if (depth < 0.06) {
ch = '#';
} else if (depth < 0.28) {
ch = '@';
}
canvas[static_cast<std::size_t>(y)]
[static_cast<std::size_t>(x)] = ch;
}
}
}
// 花茎与两片叶子
void drawStem(std::vector<std::string>& canvas) {
const int stemX = kWidth / 2; // 31
for (int y = 16; y < kHeight; ++y) {
canvas[static_cast<std::size_t>(y)]
[static_cast<std::size_t>(stemX)] = '|';
}
// 左侧叶片:向左下方伸展
for (int i = 1; i <= 5; ++i) {
const int x = stemX - i;
const int y = 22 + i / 2;
if (x >= 0 && y < kHeight) {
canvas[static_cast<std::size_t>(y)]
[static_cast<std::size_t>(x)] = (i == 5) ? '/' : '-';
}
}
// 右侧叶片:向右下方伸展
for (int i = 1; i <= 5; ++i) {
const int x = stemX + i;
const int y = 26 + i / 2;
if (x < kWidth && y < kHeight) {
canvas[static_cast<std::size_t>(y)]
[static_cast<std::size_t>(x)] = (i == 5) ? '\\' : '-';
}
}
}
} // namespace
int main() {
std::vector<std::string> canvas(
static_cast<std::size_t>(kHeight),
std::string(static_cast<std::size_t>(kWidth), ' '));
drawFlower(canvas);
drawStem(canvas);
for (const std::string& line : canvas) {
const std::size_t end = line.find_last_not_of(' ');
if (end == std::string::npos) {
std::cout << '\n';
} else {
std::cout << line.substr(0, end + 1) << '\n';
}
}
return 0;
}几点说明。
kPi定义了却没有被用到,这是有意的「常量占位」,方便后续添加螺旋线或圆弧时使用;如果开启-Wall -Wextra,编译器可能对未使用的命名空间作用域常量给出警告(不同编译器行为不同)。想保持零警告,可以删掉它,或改成[[maybe_unused]] constexpr double kPi = ...(C++17 属性)。
所有对画布的写入都用了static_cast<std::size_t>。operator[]接受size_type,传int会隐式转换,值为负时会变成一个极大的无符号数,导致越界写——虽然在本文的取值下不会发生,但养成显式转换的习惯更安全。对索引做边界检查比事后调试便宜得多。
find_last_not_of(' ')用来裁掉行尾空格。如果不裁剪,某些终端在选中文本时会带出大量空白,视觉上花朵会「偏左」。裁剪后每行长度不同,但终端按列对齐,不影响图形。
四、编译与参数调整
在具备工具链的机器上,编译命令通常是:
g++ -std=c++17 -O2 -Wall -Wextra rose.cpp -o roseWindows 上用 MSVC 则是cl /std:c++17 /EHsc rose.cpp,或用 Visual Studio 建一个控制台项目。本文不声称任何编译结果,命令仅表达所需的语言标准与警告级别。
调参的方向很直接:
| 想要的效果 | 改什么 |
|---|
| 花瓣更多 / 更少 | 把5.0改成其它整数 k(奇数得 k 瓣) |
| 花朵更大 | 增大rx、ry,同时增大画布 |
| 花瓣更细长 | 减小rx与ry的比值,或改指数 |
| 轮廓更粗 | 增大depth的第一个阈值 |
| 换字符风格 | 替换#、@、*三个字符 |
如果想让画面「开放」而不是「实心」,可以把填充条件改成只画环带,例如petal - 0.12 < r && r <= petal,这样每个花瓣只剩一条轮廓线。还可以叠加一条正弦扰动的内圈,做出花心的螺旋感:在 r 较小时用theta决定字符,而不是用depth。
再进一步是加颜色。ANSI 转义序列在多数 Unix 终端和 Windows 10 之后的控制台上可用(Windows 需要先启用虚拟终端处理,或使用支持 ANSI 的终端):
// 适用于 C++17(平台相关:依赖 ANSI 转义序列)
#include <string>
std::string paint(char ch) {
switch (ch) {
case '#': return "\033[31m#\033[0m"; // 红
case '@': return "\033[35m@\033[0m"; // 品红
case '*': return "\033[33m*\033[0m"; // 黄
case '|': return "\033[32m|\033[0m"; // 绿
default: return std::string(1, ch);
}
}注意每个带颜色的字符都包了\033[0m复位,否则颜色会「泄漏」到后续输出。这段代码是平台相关的:把输出重定向到文件时,转义序列会原样写入,打开文件会看到乱码。更稳妥的做法是先判断isatty/_isatty(平台相关头文件),只在交互式终端里着色。
常见坑点
- ❌ 直接用
M_PI,MSVC 下不加_USE_MATH_DEFINES就找不到。
✅ 自己定义constexpr double kPi = 3.14159265358979323846;,可移植且不依赖宏。
- ❌ 横纵用同一个缩放系数,画出来的花被压扁成竖长条。
✅ 终端字符高约为宽的两倍,令rx ≈ 2 * ry。
- ❌ 用
(x - cx) / rx但x、cx、rx全是int,除法截断成 0。
✅ 至少让一个操作数是浮点类型,或显式static_cast<double>。
- ❌ 画布索引不检查,
x为负时隐式转成size_t造成越界写。
✅ 写入前判断x >= 0 && x < kWidth && y >= 0 && y < kHeight,或用at()。
- ❌ 以为屏幕坐标的 y 轴向上,
atan2(dy, dx)传错符号,花瓣朝向整体翻转。
✅ 明确 y 向下增大;需要数学坐标时用-ny。
- ❌ 输出每行都补齐到固定宽度,行尾大量空格影响选中和对比。
✅ 用find_last_not_of(' ')裁掉行尾,或输出时逐字符判断。
- ❌ 用
system("pause")或system("cls")做暂停与清屏,换平台就失效。
✅ 交互需求用标准输入读取,清屏用 ANSI 序列并注明平台限制。
- ❌ 在源码里直接写中文制表符或全角字符当画布,列宽对不齐。
✅ 画布只用单字节 ASCII 字符;需要宽字符时单独处理wchar_t与终端编码。
总结
| 环节 | 关键技术 | 易错点 |
|---|
| 坐标映射 | 归一化 + 屏幕 y 轴向下 | 整型除法、y 方向 |
| 花瓣生成 | 极坐标 `r <= | cos(kθ) |
| 层次表现 | 用depth分档选字符 | 阈值过密导致全是一种字符 |
| 花茎叶片 | 逐点写入并做边界检查 | 负索引转无符号 |
| 输出 | 裁剪行尾空格 | 全角字符破坏对齐 |
| 着色 | ANSI 转义并复位 | 平台差异、重定向污染 |
字符画项目的价值在于它把「数学公式 → 坐标 → 字符」这条链路完整走了一遍:极坐标负责形状,归一化负责尺度,字符集负责明暗,边界检查负责安全。把参数抽成常量之后,同一套代码可以生成不同瓣数、不同大小的花,这也是它适合作为入门实战题材的原因。