摘要: 在 Windows 中使用 C 语言制作控制台小游戏可用的控制台双缓冲技术,此技术可以防止画面刷新时屏幕闪烁的现象,使画面过渡较为流畅。
参考样例文件见 https://github.com/Asura336/BufferCanvas
在使用 C 语言制作控制台小游戏时,为了在画面更新的时候刷新显示内容,一个容易想到的方法是在每一帧刷新画面,Windows 下可以使用控制台命令刷新画面,形如:
// 分配一块内存当作二维数组,存储屏幕显示的信息
char** new_screen(int screen_width, int screen_height)
{
char** screen = (char**)calloc(screen_height, sizeof(char*));
for (int i = 0; i < screen_width; i++)
screen[i] = (char*)calloc(screen_width, sizeof(char));
return screen;
}
// ...
int main(void)
{
int screen_width = 80, screen_height = 80;
char** screen = new_screen(screen_width, screen_height);
// 你在这之前做了点什么,比如初始化工作
while(true)
{
// 你在这之前又做了点什么,比如为 screen 填进了每一帧的内容
system("cls");
}
// ...
}
在上述例子中使用 cls 命令清屏,而这将造成严重的屏幕闪烁。通过移动光标位置局部擦除写入的方式可以一部分改善这个问题,但如果需要重绘的部分过多,这个办法依然不好用。
使用双缓冲方法虽然不会一劳永逸地解决闪屏,但多半能一劳很久逸。所谓双缓冲即是为控制台显示增加一块缓冲区域,每一帧的显示在缓冲区域进行,写入完成后直接放到在活动的控制台显示区域。现代计算机处理数据的速度已经很快,相对而言慢的部分在输出文字到显示器这一步,所以在每一帧将字符放置到缓冲区域再一起显示完全来得及。一开始为了找到合适的方法我在网络上寻找了很久但只有一些只言片语留在一些技术博客里,在这里我会放上较为完善的版本。
双缓冲的基础是两块控制台显示,表现为两个控制台显示句柄。由于标准输出流对应的控制台显示可以便利地调用 putchar() 等函数打印字符,所以将原来的显示区域也就是标准输出流对应的控制台显示作为缓冲区比较合适。
/*
双缓冲核心,两个控制台显示句柄
_std 对应标准输出流,用作缓冲区
_buffer 对应活动的控制台显示
*/
struct hud
{
HANDLE _std;
HANDLE _buffer;
};
typedef struct hud HUD;
HUD handle; // 一个全局的结构体
/*
获取标准输出流句柄
创建新的控制台输出句柄
设置新的控制台输出句柄为在活动的控制台输出句柄
在 main() 里先调用这个函数,控制台显示将切换为 _buffer 中显示的内容
*/
void init()
{
// handle._std 为当前标准输出流对应的控制台显示
handle._std = GetStdHandle(STD_OUTPUT_HANDLE);
// 新建一个控制台显示
handle._buffer = CreateConsoleScreenBuffer(
GENERIC_READ|
GENERIC_WRITE,
FILE_SHARE_READ|
FILE_SHARE_WRITE,
NULL,
CONSOLE_TEXTMODE_BUFFER,
NULL);
// 将 handle._buffer 置为在活动的控制台显示
SetConsoleActiveScreenBuffer(handle._buffer);
// 设置光标为不可见
CONSOLE_CURSOR_INFO cc_info;
cc_info.dwSize = 1;
cc_info.bVisible = 0;
SetConsoleCursorInfo(handle._buffer, &cc_info);
SetConsoleCursorInfo(handle._std, &cc_info);
return;
}
/*
Mono 类结构和方法
虽然 C 语言语法没有类的概念,但依然可以制作一个类似类的
*/
struct mono
{
char** surface; // 存储图案内容
int width, height; // surface 的宽高信息
int position_x, position_y; // surface 代表的矩形的左上角点坐标
};
typedef struct mono* Mono;
/*
基础构造函数,传入图标宽高信息。
*/
Mono new_mono(int width, int height)
{
Mono self = (Mono)calloc(1, sizeof(struct mono));
self->width = width;
self->height = height;
self->position_x = 0;
self->position_y = 0;
self->surface = (char**)calloc(self->height, sizeof(char*));
for (int i = 0; i < self->height; i++)
{
self->surface[i] = (char*)calloc(self->width, sizeof(char));
}
return self;
}
/*析构函数,按动态申请资源的相反顺序释放内存*/
void del_mono(Mono self)
{
for (int i = 0; i < self->height; i++)
{
free(self->surface[i]);
self->surface[i] = NULL;
}
free(self->surface);
self->surface = NULL;
free(self);
self = NULL;
return;
}
/*
向标准输出流传入 self 内的信息
*/
void _show(Mono self)
{
for (int i = 0; i < self->height; i++)
{
for (int j = 0; j < self->width; j++)
{
putchar(self->surface[i][j]);
}
putchar('\n');
}
return;
}
/*
传入 Surface 屏幕,对应标准输出流和在活动的控制台显示
self 与屏幕显示之间以缓冲区作为媒介
将 self 的内容显示到控制台
*/
void show(Mono self, Mono buffer)
{
_show(self);
COORD coord = {0, 0};
DWORD bytes = 0;
// 这个函数定位 handle._std 中的光标位置
SetConsoleCursorPosition(handle._std, coord);
for (int i = 0; i < self->height; i++)
{
// 它的作用是向 buffer 中保存的缓冲区域写入显示内容
// buffer 的内容关联标准输出流
ReadConsoleOutputCharacterA(handle._std, buffer->surface[i], buffer->width, coord, &bytes);
coord.Y++;
}
COORD output_coord = {0, 0};
DWORD output_bytes = 0;
SetConsoleCursorPosition(handle._buffer, output_coord);
for (int i = 0; i < self->height; i++)
{
// 将缓冲区中的内容贴到 handle._buffer 中,一次贴一整行
WriteConsoleOutputCharacterA(handle._buffer, buffer->surface[i], buffer->width, output_coord, &output_bytes);
output_coord.Y++;
}
return;
}
/*将 Surface 对象的内容置为特定字符*/
void clean_mono(Mono self, char c)
{
for (int i = 0; i < self->rect->height; i++)
{
memset(self->surface[i], c, self->rect->width);
}
return;
}
在主函数中,建立上例中的 Mono 对象作为显示区域 (由于 Mono 类的特性,它也可以用来盛放如可动的游戏角色或者静止元素的图标,你可以将一个 Mono 对象叠放在另一个 Mono 对象上,并控制其中的位置属性),并启动双缓冲。
int main(void)
{
int screen_width = 80, screen_height = 80;
Mono screen = new_mono(screen_width, screen_height);
Mono screen_buffer = new_mono(screen_width, screen_height);
init(); // 由于之前已经声明了全局的结构体对象,这里可以直接调用 init()
// ...
while (true)
{
// 为每一帧写入显示内容,要写入的内容仅传入 screen->surface 中就可以
// ...完成所有的控制反馈和写入帧画面
show(screen, screen_buffer);
clean_mono(screen, ' ');
}
// 做一些收尾工作如释放内存,如果设计的程序会走到这里的话
return 0;
}
已知的问题:
有时候控制台的显示会出现多余的空行,右键控制台标题栏设置控制台属性中的布局,取消“调整大小时对输出的文本换行”的勾选即可。