控制台字符画可谓是程序员永远的浪漫~
前不久有朋友问我:如何实现控制台的飞机大战游戏,想来想去,控制台游戏我玩过,飞机大战我也写过,逻辑也并不复杂,但是控制台飞机大战还真没有做过,控制台没有方便的图形API,如何实现简单高效地绘图恐怕是最大的问题;
转念一想,类比图形API,控制台下每一个字符都可以被视作一个单独的像素,图片便是字符串数组,而二维的字符数组就可以充当渲染缓冲区,调用printf
进行输出的操作就可以当成是将渲染缓冲区的数据拷贝至显示器的操作,这样来看,完全可以从头设计一套简单的控制台图形API来方便将游戏数据显示到屏幕上;
实现细节
首先考虑的是“屏幕”,即渲染缓冲区的设计,在这里我们称之为“舞台”:
1 2 3 4 5
| struct MW_Sprite { int width, height; char** content; };
|
开发者可以通过自定义“舞台”的宽高来初始化画面大小,只有存在于舞台之上的内容(字符)才会被渲染(输出)到控制台上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| MW_Stage* MW_CreateStage(int width, int height) { if (!width || !height) return NULL;
MW_Stage* pStage = (MW_Stage*)malloc(sizeof(MW_Stage)); if (!pStage) return NULL;
pStage->width = width, pStage->height = height; pStage->content = (char**)malloc(sizeof(char*) * height); if (!pStage->content) { free(pStage); return NULL; } memset(pStage->content, '\0', sizeof(char*) * height);
for (int i = 0; i < height; ++i) { pStage->content[i] = (char*)malloc(sizeof(char) * (width + 1));
if (!pStage->content[i]) { for (int j = 0; j < i; ++j) free(pStage->content[i]); free(pStage->content); free(pStage); return NULL; }
memset(pStage->content[i], '\0', sizeof(char) * (width + 1)); }
return pStage; }
|
类比传统图形API中使用某种颜色清空绘图缓冲区的功能,我们也提供了使用指定字符清空(填充)舞台的功能:
1 2 3 4 5 6 7 8
| void MW_ClearStage(MW_Stage* stage, char c) { if (!stage) return;
for (int i = 0; i < stage->width; i++) for (int j = 0; j < stage->height; j++) stage->content[j][i] = c; }
|
同时,舞台结构的内存释放操作也是需要的:
1 2 3 4 5 6 7 8 9 10
| void MW_DestroyStage(MW_Stage* stage) { if (!stage) return;
for (int i = 0; i < stage->height; ++i) free(stage->content[i]);
free(stage->content); free(stage); }
|
舞台的设计完整了,接下来就是对“图片”的设计了,我们遵循传统的游戏贴图的命名,称之为“精灵”,精灵贴图的设计和舞台的设计在数据层面很相像,但是考虑到后面可能会对精灵贴图或者舞台的设计进行修改,所以额外定义了精灵贴图结构:
1 2 3 4 5
| struct MW_Sprite { int width, height; char** content; };
|
并且提供了从二维字符数组创建精灵贴图的工厂方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| MW_Sprite* MW_CreateSprite(const char** content, int width, int height) { if (!width || !height) return NULL;
MW_Sprite* pSprite = (MW_Sprite*)malloc(sizeof(MW_Sprite)); if (!pSprite) return NULL;
pSprite->width = width, pSprite->height = height; pSprite->content = (char**)malloc(sizeof(char*) * height); if (!pSprite->content) { free(pSprite); return NULL; } memset(pSprite->content, '\0', sizeof(char*) * height);
for (int i = 0; i < height; ++i) { pSprite->content[i] = (char*)malloc(sizeof(char) * (width + 1));
if (!pSprite->content[i]) { for (int j = 0; j < i; ++j) free(pSprite->content[i]); free(pSprite->content); free(pSprite); return NULL; }
memset(pSprite->content[i], '\0', sizeof(char) * (width + 1)); memcpy(pSprite->content[i], content[i], sizeof(char) * width); }
return pSprite; }
|
以及,将精灵贴图拷贝到舞台上的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void MW_CopySprite(MW_Stage* stage, const MW_Sprite* sprite, const MW_Rect* src, const MW_Point* dst) { if (!stage || !sprite || !dst || dst->x > stage->width - 1 || dst->y > stage->height - 1) return; int src_x = src ? max(src->x, 0) : 0; int src_y = src ? max(src->y, 0) : 0; int src_w = src ? min(src->w, sprite->width) : sprite->width; int src_h = src ? min(src->h, sprite->height) : sprite->height;
if (dst->x + src_w < 0 || dst->y + src_h < 0) return;
for (int i = 0; i < min(src_h, stage->height - dst->y); i++) memcpy(stage->content[max(0, dst->y + i)] + dst->x, sprite->content[src_x + i], sizeof(char) * min(src_w, stage->width - dst->x)); }
|
如上述代码所示,MW_CopySprite
函数会根据舞台大小自动适配,来防止内存越界,同时,支持开发者通过const MW_Rect* src
矩形对贴图进行裁剪后显示,由于缩放的操作在控制台上的实现较为复杂,所以对于拷贝的目的地const MW_Point* dst
只使用点来描述;
以及,贴图依然是手动释放内存的:
1 2 3 4 5 6 7 8 9 10
| void MW_DestroySprite(MW_Sprite* sprite) { if (!sprite) return;
for (int i = 0; i < sprite->height; ++i) free(sprite->content[i]);
free(sprite->content); free(sprite); }
|
最后一步,就是将舞台缓冲区中在字符显示到控制台上了,有了先前的设计和实现作为铺垫,所谓的“渲染”这一步就变得十分显而易见了:
1 2 3 4 5 6 7
| void MW_RenderStage(const MW_Stage* stage) { if (!stage) return;
for (int i = 0; i < stage->height; i++) printf("%s\n", stage->content[i]); }
|
以上部分使用标准C进行实现,并且将声明和定义都放到了单头文件中,参照知名开源项目stb,开发者可以使用MAGICWORDS_IMPLEMENTATION
宏来启用上述函数的实现;
作为一个游戏库,有了简单的渲染封装,当然也还要考虑玩家的交互操作,考虑到非阻塞输入与平台相关,所以在Windows平台下,还特别提供了获取玩家按键输入的API:
1 2 3 4 5 6 7 8 9 10 11 12
| #ifdef _WIN32 int MW_GetInput(int* input) { if (_kbhit()) { *input = _getch(); return 1; }
return 0; } #endif
|
到此为止,控制台游戏开发所需的基本功能都被粗糙地实现完成了~
代码实战
下面展示的是一个简单游戏场景的实现,玩家可以通过WASD
控制代表飞机的角色在场景中移动;由于使用了Sleep
函数进行了延时,所以被迫引入了Windows.h
头文件,在一般情况下,是不需要手动添加平台相关依赖的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| #define MAGICWORDS_IMPLEMENTATION #include "MagicWords.h"
#include <Windows.h>
int main(void) { int input;
MW_Stage* stage = MW_CreateStage(22, 15);
char* data_map[15] = { "||==================||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "|| ||", "||==================||", };
char* data_player[2] = { " ^ ", "<| |>" };
MW_Sprite* sprite_player = MW_CreateSprite(data_player, 5, 2); MW_Sprite* sprite_map = MW_CreateSprite(data_map, 22, 15);
MW_Point point_player = {2, 2}; MW_Point point_map = {0, 0};
while (1) { system("cls"); MW_ClearStage(stage, ' ');
while (MW_GetInput(&input)) { switch (input) { case 'w': point_player.y--; break; case 'a': point_player.x--; break; case 's': point_player.y++; break; case 'd': point_player.x++; break; default: break; } }
MW_CopySprite(stage, sprite_map, NULL, &point_map); MW_CopySprite(stage, sprite_player, NULL, &point_player);
MW_RenderStage(stage);
Sleep(100); }
return 0; }
|

其他问题
对于控制台游戏而言,还有一个问题没有解决,虽然我们模仿了通用图形API设计了双缓冲,但是由于在MW_RenderStage
时,依然是通过printf
逐行打印了缓冲区内容,所以受限于控制台,还是没能解决在逐“像素”渲染时闪屏的问题;
这个问题解决起来其实也并不复杂,在各平台上,基本都提供了对控制台本身的缓冲区操作API,也就是说,打印输出字符时,可以先输出到控制台的后缓冲区中,然后在打印操作结束后与前缓冲区进行交换,实现平稳“渲染”;
在Windows下的CreateConsoleScreenBuffer
和WriteConsoleOutputCharacter
等函数可以实现此操作,考虑到现有的代码实现并未在舍弃跨平台之路上走太远,以及在适当延时后偶尔出现的闪屏也并非完全不能忍受,所以在本开发库的封装设计中,并未添加与平台深入绑定的控制台多缓冲区功能。
到GitHub上赏颗星⭐吧!