ConsoleUI4

· · 个人记录

0. 前言

作为一名 Oier,在闲暇时刻,不免会打开 C++,写一个简易的、基于控制台的项目,然后传到网上,供大家学习、参考,甚至游玩。但很多时候,不少人会被黑白的控制台界面劝退,或者是被简陋的 cin,scanf 交互搞得体验感全无。

如何把一个单纯的控制台项目玩出花样——即用简单的字符,展示着较为美观的界面,并有着容易上手的交互操作,这便是本文的主题。

前排提示:本文提到的所有函数均在 <windows.h><conio.h> 中。使用 #include 即可。

1. 美化界面

1.1. 给字符加上颜色

对于一个没有图画,只有字符的程序而言,字符的颜色以及色彩的搭配直接决定了整个程序界面的美观程度。

在 C++ 里,如何使得在控制台中输出的字符拥有颜色呢?其实很简单。

SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE),__color__);

注意:在本文展示的代码中,像 __color__ 这样用下划线括起来的部分,表示你需要用合适的代码块来填充它。

__color__:表示输出在控制台上的颜色。这是一个两位的 16 进制数,第一位表示背景色,第二位表示前景色(即字符颜色)。其中:

0   黑色
1   深蓝色
2   深绿色
3   深青色
4   深红色
5   深紫色
6   深黄色
7   浅灰色
8   灰色
9   蓝色
a   绿色
b   青色
c   红色
d   紫色
e   黄色
f   白色

比如 __color__=0x0c 时,表示在此之后输出的字符均会变成红色,同时该字符的背景色为黑色。

1.2. 清空屏幕 & “按任意键继续” & 等待

清空屏幕自然是 system("cls")。注意:如果你在之前修改过字体的背景色(就是第一位 16 进制数),必须先修改回来再清屏。

按任意键继续虽然有 system("pause") 的手段,但我个人更喜欢 _getch()_getch() 是一个中断型的函数,如果你一直不按键盘,则程序会一直等待。当你按下键盘的时候,该函数会返回你按下的是哪一个键。

比如:char c=_getch(),就是一个典型的交互案例(更多的交互内容会在第二节阐述)。如果只是单纯地把它当做一个 pause 来用,只需要单独使用 _getch() 即可。

类似的函数还有一个 kbhit(),它是一个即时的函数,不会使你的程序进入等待状态。但它只会返回 truefalse,表示你是否按下了键盘上的任意键。

while (true){
    //do something
    if (kbhit()){
        char c=_getch();
        //do something
        break;
    }
}

这也是一个比较经典的应用,在等待你按键的同时,程序还能进行一些运算和操作。

当然有一个更 NB 的函数,会在第三节讲到,这里先埋个伏笔。

等待自然是 Sleep(__time__),其中 __time__ 是一个整数,表示你需要等待的时间(毫秒记)。

1.3. 杂七杂八的设置

void showCursor(){
    HANDLE fd=GetStdHandle(STD_OUTPUT_HANDLE);
    CONSOLE_CURSOR_INFO cinfo;
    cinfo.bVisible=1;
    cinfo.dwSize=1;
    SetConsoleCursorInfo(fd,&cinfo);
}
void hideCursor(){
    HANDLE fd=GetStdHandle(STD_OUTPUT_HANDLE);
    CONSOLE_CURSOR_INFO cinfo;
    cinfo.bVisible=0;
    cinfo.dwSize=1;
    SetConsoleCursorInfo(fd,&cinfo);
}
void setWindowSize(){
    system("title __name__");
    char cmd[30];
    sprintf(cmd,"mode con cols=%d lines=%d",__width__,__height__);
    system(cmd);
}

__name__:你要显示的标题。

__width__,__height__:宽度、高度。

1.4. 光标操作

void gotoxy(int x,int y){
    COORD pos;
    pos.X=y-1;
    pos.Y=x-1;
    SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE),pos);
}

x 表示行数,y 表示列数。gotoxy(1,10) 表示将光标移动至第一行的第十列。

pair <int,int> getxy(){
    HANDLE hStdout;
    CONSOLE_SCREEN_BUFFER_INFO pBuffer; 
    hStdout=GetStdHandle(STD_OUTPUT_HANDLE); 
    GetConsoleScreenBufferInfo(hStdout,&pBuffer); 
    return make_pair(pBuffer.dwCursorPosition.Y+1,pBuffer.dwCursorPosition.X+1);
}

返回 pairfirst 是行数,second 是列数。如果你的光标在第一行第十列,则会返回 {1,10}

上述两个函数可以优化程序更新界面的操作,如果只需要修改一小部分格子上的字符,只需要将光标移动到指定的地方输出更新后的字符即可。同理,我们也可以通过上述两个函数实现清行操作,请读者自行思考。

2. 键盘交互

键盘交互本质上其实就是 _getch()kbhit() 的灵活应用。这里主要给出几个比较优雅的键盘交互模板。

2.1. 更方便的输出

为了让模板更加优雅,我采用了自定义的 tellraw 输出格式,用一些简单的特殊字符来表示特殊操作,比如 &a 表示把输出的前景色改成绿色等。具体的,下面是我自己的一个 tellraw 模板。

方便起见,我们把一些函数封装一下:

void setColor(int colorID){
    SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE),colorID);
}
void clrscr(){
    setColor(0x0f);system("cls");
}
int charToHex(char c){
    if (c>='0' && c<='9') return c-'0';
    else return c-'a'+10;
}

然后就是我们的 tellraw 了:

void tellraw(string s,...){
    va_list ap;va_start(ap,s);
    int len=s.size();setColor(0x0f);
    int foreColor=15,backColor=0,crlf=getxy().second;
    bool change=false;
    for (int i=0;i<len;++i){
        if (s[i]=='%'){
            if (change) setColor(foreColor+backColor*16),change=false;
            if (s[i+1]=='s') cout<<va_arg(ap,char*);
            else if (s[i+1]=='d') cout<<va_arg(ap,int);
            i++;
        }else if (s[i]=='&'){
            foreColor=charToHex(s[i+1]);
            change=true;i++;
        }else if (s[i]=='#'){
            backColor=charToHex(s[i+1]);
            change=true;i++;
        }else if (s[i]=='/'){
            if (s[i+1]=='/') putchar('/');
            else if (s[i+1]=='#') putchar('#');
            else if (s[i+1]=='%') putchar('%');
            else if (s[i+1]=='&') putchar('&');
            else if (s[i+1]=='n'){
                pair <int,int> tmp=getxy();
                gotoxy(tmp.first,crlf-1);
            }
            else if (s[i+1]=='c') clrscr(); 
            i++;
        }else{
            if (change) setColor(foreColor+backColor*16),change=false;
            putchar(s[i]);
        }
    }
    setColor(0x0f);va_end(ap);
}
# + 0..9,a..f   修改背景色
& + 0..9,a..f   修改前景色
% + d,s     和 printf 类似地输出数字/字符串
/ + #,&,%   输出特殊字符
/ + c       清屏
/ + n       换行(换行后光标的列数将与第一个字符的列数相同)
tellraw("/c&8%s &fget &c%d &fpoint in the contest on &e%s %d&f, &e%d&f./n","zombie462",0,"August",7,2020);

2.2. 选择框

int chosenbox(string s){
    int len=s.size(),i=0;
    pair <int,int> tmp=getxy();
    string t="";
    while (s[i]!='@' && i<len) t=t+s[i],i++;
    tellraw(t);
    int j=i;
    t="";
    int item=0;
    for (int i=j+1;i<len;++i){
        if (s[i]=='^'){ 
            j=i;
            break;
        }else if (s[i]=='@'){
            item++;
            gotoxy(tmp.first+item,tmp.second);
            tellraw("    &b%d. &f"+t+"/n",item);
            t="";
        }else{
            t=t+s[i];
        }
    }
    t="";
    for (int i=j+1;i<len;++i){
        t=t+s[i];
    }
    gotoxy(tmp.first+item+1,tmp.second);
    tellraw(t);
    gotoxy(tmp.first+1,tmp.second);
    tellraw("&f-->");
    char c=_getch();
    int chosen=1;
    while (c!=' '){
        if (c=='W' || c=='w'){
            if (chosen>1){
                gotoxy(tmp.first+chosen,tmp.second);
                chosen--;
                printf("   ");
                gotoxy(tmp.first+chosen,tmp.second);
                tellraw("&f-->");
            }
        }else if (c=='S' || c=='s'){
            if (chosen<item){
                gotoxy(tmp.first+chosen,tmp.second);
                chosen++;
                printf("   ");
                gotoxy(tmp.first+chosen,tmp.second);
                tellraw("&f-->");
            }           
        }else if (c>='1' && c<='9' && c-48<=item){
            gotoxy(tmp.first+chosen,tmp.second);
            chosen=c-48;
            printf("   ");
            gotoxy(tmp.first+chosen,tmp.second);
            tellraw("&f-->");           
        }else if (c=='A' || c=='a'){
            gotoxy(tmp.first+chosen,tmp.second);
            chosen=1;
            printf("   ");
            gotoxy(tmp.first+chosen,tmp.second);
            tellraw("&f-->");           
        }else if (c=='D' || c=='d'){
            gotoxy(tmp.first+chosen,tmp.second);
            chosen=item;
            printf("   ");
            gotoxy(tmp.first+chosen,tmp.second);
            tellraw("&f-->");           
        }
        c=_getch();
    }
    gotoxy(tmp.first+item+2,tmp.second);
    return chosen;
}

传入的字符串由三部分组成:

  1. 标题部分;表示选择框的标题。以字符 @ 结束。
  2. 选项部分;表示具体的选项。每个选项以 @ 结束。最后以 ^ 结束。
  3. 提示部分;表示选项下方的文字。

函数的返回值为你选择的选项标号。

chosenbox("&fPlease Choose:@&aFirst@&bSecond@&cThird@&fHello^");

使用 a,d 直接跳到第一个/最后一个选项。

使用 w,s 移动到上一个/下一个选项。

使用数字键直接跳转到指定选项。

3. 鼠标交互

鼠标交互的核心代码是这一句:

#define press(VK_NONAME) ((GetAsyncKeyState(VK_NONAME)&0x8000)?1:0)

press(VK_LBUTTON) 为真表示按下了左键,press(VK_RBUTTON) 同理。另外,press(__char__) 也能够判断是否按下键盘上的某一个按键。和 kbhit() 一样,它不会中断程序。此外,像 press('A') && press('B') 的形式可以判断你是否同时按下 A,B 两个按键。

当然,只获取鼠标是否按下的信息是不够的,我们还要获得鼠标的位置。

void getpos(POINT &pt){
    HWND hwnd=GetForegroundWindow();
    GetCursorPos(&pt);
    ScreenToClient(hwnd,&pt);
    pt.y=pt.y/16+1;
    pt.x=pt.x/8+1;
}

使用方式:

POINT pt;getpos(pt);

pt.x 为鼠标所在的行号,pt.y 为鼠标所在的列号。

使用这种方式,我们可以维护按钮,即:

while (true){
    //do something
    POINT pt;
    getpos(pt);
    if (pt.x>=button.x && pt.x<=button.x+button.width-1 && pt.y>=button.y && button.y+button.height-1){
        //do something
        if (press(VK_LBUTTON)){
            //do something
            break;
        }
    }
}

其中 button 是一个自定义的按钮类。

当然,鼠标交互还有相当多的技巧和手法,这里由于篇幅原因,不加赘述,相信你一定能够发现新大陆。

4. 后记

ConsoleUI5 已经发布。在您完全掌握本文档的内容后可以前去 这里 看看。