简单TUI游戏设计

              目       录
              一                     Curses库简介与基本开发方法       
              二                     事件驱动编程:编写一个视频游戏       
              三                     弹球游戏的实现与优化       
              四                     简单TUI游戏设计实践总结       

一、Curses库简介与基本开发方法

1.Curses概述

  • curses实际上是一个函数开发包,专门用来进行UNIX下终端环境下的屏幕界面处理以及I/O处理。通过这些函数库,C和C++程序就可以控制终端的视频显示以及输入输出。

  • 使用curses包中的函数,用户可以非常方便的创建和操作窗口,使用菜单以及表单,而且最为重要的一点是使用curses包编写的程序将独立于各种具体的终端,程序具有良好的移植性。

(1)curses开发包内容

库名描述头文件
curses最早的curses包只包含这一部分,主要控制屏幕的输入和输出,光标的操作,窗口的创建和操作等。curses.h
panel类似于窗口堆栈,不同的窗口可以存放于其中,并且可以在其中进行移动。panel.h
menu新增的部分,主要包括创建菜单并且与之交互的函数,主要用来接受用户的选择。menu.h
form包括创建表单以及与之进行交互的函数,主要用来接受用户数据输入。form.h

(2)curses包移植性

curses包的这种终端独立性归根于终端描述数据库terminfo和termcap。

终端描述数据库(Terminal Description Databases)
- terminfo(TERMinal INFOrmation database)
- termcap(TERMinal CAPabilitie database)
  • 这两个数据库里存放了不同终端的操作控制码和转义序列以及其余相关信息。

  • 使用每一个终端:

      - curses将首先在终端描述数据库中查找是否存在该类型的终端描述信息
      - 如果找到,则进行相应的处理。
      - 如果数据库中没有相应信息,则程序无法在该终端上运行,除非用户自己增加新的终端描述。
    

(3)安装与使用

  • 安装

      sudo apt-get install libncurses5-dev 
    
  • 若发现找不到这个包,可以使用命令 sudo apt-get update 更新下包源。

      gcc program.c -o program -lcurses
    
  • 程序中使用curses库函数:引用curses库的头文件curses.h,即:

      #include <curses.h>
    
    • 由于curses使用标准I/O,因此这个库一旦被引用,系统自动的将<stdio.h><unclt.h>一起包含进来,如果对于System V系统,<terminfo.h>也会包含进来,另外还可能包括<termios.h><termio.h><sgtty.h>,具体的由系统本身决定。
  • 编译时加上-lcurses,用来在链接的时候提示链接程序将curses库链接进去。

2.Curses库常量定义

  • 头文件curses.h中定义了四个整型常量,其中两个是大多数的curses函数的返回结果,另外两个是布尔类型的值,一旦包含了curses头文件,就可以在程序中直接使用它们。
整型常量定 义
OKcurses函数运行成功的返回值,系统定义为0
ERR curses函数运行发生错误时候的返回值,系统定义为-1
TRUE布尔值,表示为真,系统定义为1
FALSE布尔值,表示为假,系统定义为0

3.标准屏幕与当前屏幕

(1)初始化

  • 在主函数中设置了信号处理函数之后我们就调用了initscr(),一般情况下在其余的curses函数被调用之前我们就必须首先调用initscr()。

  • initscr()对curses包进行一些初始化的工作,而且在每一个程序里面,这个函数只能调用一次。它的作用主要包括:

      - 通过读取TERM环境变量的值来决定当前使用的终端类型,开启终端模式。
      - 根据终端的具体情况将终端的一些性能参数读进相关变量中,完成对相关数据结构的初始化工作,例如获取LINES和COLS的值。
      - 创建和初始化标准屏幕stdscr和当前屏幕curscr,同时为它们分配必要的存储空间。
      - 通知refresh()函数首次调用的时候能够清除屏幕。
    

(2)标准屏幕、当前屏幕

  • 如果程序中使用了curses库,那么在程序初始化的时候,系统将自动产生两个默认屏幕。

      第一个:标准屏幕,系统定义为stdscr,代表终端的整个屏幕区域。
      第二个:当前屏幕,系统定义为curscr,代表用户能够看到的屏幕显示。
    
  • curses库的屏幕刷新机制:

    • 通常终端的刷新频率都不是很高,因此频繁的刷新可能导致显示速度非常的慢,因此curses库对终端屏幕刷新进行了一些优化处理。
    • 在curses库中我们对标准屏幕的任何更新并不会立即反映到屏幕上,而是必须等我们调用了函数refresh()或者wrefresh()之后,改变的效果才会在屏幕上真正显示出来。
    • 在屏幕刷新的时候curses库系统将标准屏幕与当前屏幕进行比较,然后仅仅刷新物理终端屏幕上的它们之间的不同之处,一旦进行了刷新,当前屏幕将与标准屏幕相同。

如果我们对当前屏幕进行更改而还没有调用刷新函数,那么标准屏幕就仅是一个虚拟屏幕。
  • 一旦当前屏幕进行了改动,标准屏幕将发生变化直到下一次刷新。
  • 这种刷新效率最低的时候就是标准屏幕与当前屏幕完全不同,不过在大部分情况下都能够加快显示。

4.终端模式

  • 程序使用initscr()进行初始化之后,程序对终端的模式进行了一些设置。终端模式实际上是一系列的开关属性,它们直接影响着终端如何处理输入以及输出。例如:

      - 当我们敲入字符的时候,系统将根据模式属性设置来判断字符是否需要立即在屏幕上回显。
      - 系统根据模式设置决定读取字符后的处理方法,是立即一一读取还是暂时先存放在字符缓冲区中。
      - 系统判断是否需要将输入Ctrl+D解释为文件结束符。
      - 系统判断如何处理功能键F1、F2或者方向键等等,决定是将它们作为普通的键读取还是作为功能键读取。
    

(1)ECHO模式(回显模式)

  • 用来决定用户的输入是否立即回显。

    • 当ECHO模式设置后,它使得在键盘上输入的每一个字符都在终端屏幕上当前光标处显示出来,在调用某些函数如addch()时字符显示后光标的位置将自动的向后移动一个位置。

    • 在非回显模式下,字符的显示必须由程序本身来完成,否则字符不会显示。

  • 注:非回显模式下按键通常用来控制屏幕的操作而不是用来进行字符输入!

echo()		设置回显模式
noecho()	关闭回显模式
- 成功返回OK,失败返回ERR。
- 默认情况下回显模式是打开的。

(2)CBREAK模式(立即输入模式)

  • 字符输入在一般的情况下必须加回车键才能完成,这时候退格键是起作用的,输入的字符可以通过BackSpace键删除掉。但是这种操作并不适合交互操作。

    • 在CBREAK模式下,除了DELETE或者CTRL等仍然被视为特殊控制字符以外,所有的输入字符都被立即读取出来。
    • 如果没有设置CBREAK模式,从键盘输入的字符都将被存储在缓冲区里面直到输入回车键或者行结束符。
  • 注:中断字符和流控制字符并不受这个模式的影响。

int  cbreak()	打开立即输入模式
int  nocbreak()	关闭立即输入模式
  • 默认情况下CBREAK模式是打开的。
  • 在旧版本的curses中,必须使用crmode()和nocrmode()取代cbreak()和nocbreak()。

(3)NEWLINE模式

NEWLINE模式用来决定当输入时回车键是否被对应为新行产生符。

int nl()
int nonl()
  • 默认情况下,NEWLINE模式是打开的。
  • 如果终端设置成NEWLINE模式,那么在输入字符的时候,按下回车键将会产生新的一行,同时在输出字符的时候换行符将对应成回车键。

(4)功能键模式

  • 通常情况下功能键比如左移方向键‘←’是不会被读取转换的,即使调用wgetch()之类的函数也不能将它们读取出来。
  • 为了读取这些特殊键,必须设置功能键模式。
    • 一旦功能键模式开启,键盘上的一些按键都可以转换为curses.h内部定义的一些特殊键值。
    • 这些键通常以“KEY_”开始。

int  keypad(win,buf)

参数:

WINDOW *win		一个WINDOW类型的指针,它指向需要设置功能键模式的窗口
int buf			buf为TRUE或者FALSE,用来指定模式的开启和关闭。
       功能键定义              控制码              描       述       
KEY_MIN0401Curses中定义的最小的键值
KEY_BREAK0401Break按键
KEY_DOWN0402
KEY_UP0403
KEY_LEFT0404
KEY_RIGHT0405
KEY_HOME0406Home键
KEY_BACKSPACE0407退格键backspace
KEY_F00410功能键F0
KEY_F(n)KEY_F0+n功能键Fn
KEY_DL0510行删除键
KEY_IL0511行插入键
KEY_DC0512字符删除键
KEY_IC0513字符插入键
KEY_NPAGE0522下一页
KEY_PPAGE0523上一页
KEY_END0550end按键
KEY_MAX0777最大的curses键值
  • 完整的资料可以在文件<tinfo.h>找到,通常它的目录为/usr/include/目录下。

(5)RAW模式(原始模式)

  • 与CBREAK模式类似,用户的输入会立即被接受,但是某些中断字符,转义字符以及挂起和流控制字符将不再起作用,取而代之的是产生一个相应的信号。
  • CBREAK模式是覆盖RAW模式的,如果同时设置CBREAK和RAW模式,将仅有RAW模式起作用。

int raw();
int noraw();

(6)延迟模式

延迟模式包括半延迟模式和无延迟模式。
  • 半延迟模式下的所有的用户输入都是立即被接受的,但是如果在一段时间内没有用户输入,则输入函数立即返回ERR。这段时间间隔可以自己指定,单位通常为1/10秒,范围为1-255。
  • 无延迟模式主要用来控制终端的字符输入。一般情况下,终端输入函数比如getch()是阻塞调用,即一直等待直到键盘输入才返回。而无延迟模式可以将这种调用更改为非阻塞调用,即一旦getch()发现键盘没有任何输入它就返回错误ERR。

- int halfdelay(tenth)	设置半延迟模式
- nocbreak()			可以取消终端的半延迟模式
- int nodelay(win,bf)	函数设置终端无延迟模式。

参数:

- int tenth指定半延迟的时间间隔,单位是10毫秒。
- WINDOW *win	指向需要设置无延迟模式的窗口的指针
- bool bf	用来决定开启或者关闭该模式。若bf为TRUE,则设置无延迟模式。

5.字符及字符串操作

字符和字符串操作是应用程序中使用频率最高的,curses库中的一些基本函数允许我们从键盘接受输入,并且将结果输出到指定窗口上或者在指定窗口上读写、删除字符和字符串、定位光标位置或者控制字符色彩等。

(1)字符、字符串输出

addch()函数
int  addch(ch);
int echochar(ch);
chtype ch;

- 如果函数执行成功,将返回OK常量,否则返回ERR。
  • addch()函数是curses中的核心输出函数。将给定的字符输出到标准屏幕上的当前位置。

  • addch()函数输出后并不执行屏幕刷新,因此为了能够显示输出结果,我们就必须调用refresh()函数。

  • echochar()函数可以在完成字符输出的同时完成刷新,在使用非控制字符的时候非常方便。

  • 参数是一个chtype类型的字符,curses中将chtype声明为无符号长整型,它的低位可以包含字符本身的信息,这部分与char字符类型相似,它的高位部分存储了字符的额外的属性信息,比如字符是否高亮度显示,是否反显以及什么色彩等等。

  • 函数在当前光标处输出一个字符,同时光标将向右移动一个位置。如果移动后光标将超出了当前屏幕的范围,光标将自动换行到下一行的开始位置。

  • 除了常用的字符参数以外,addch()函数还可以使用的C语言中的转义字符:

转义字符描              述
       /n       换行,删除当前到行尾的所有字符,并将字符指针向下移动一行。若设newline标志,addch将删除光标后的所有字符并将字符指针置于下行开始处。
       /r       回车,将字符指针置于当前行的开始处。
       /t       制表符,将字符指针移动到下一个制表符处。
  • 由addch()衍生出来的函数还有waddch()、mvaddch()、mvwaddch()
特殊的行图形字符
  • 通常用来绘图或者制表。curses中的字符实际上就是由它们中的部分组成。对于这些特殊的符号,curses中定义了一些常量与之对应,这些常量都以ACS_开始,通常称之为ACS_常量。
addstr()函数
int   addstr(str);
char *str;
  • addch()函数只能在当前位置增加一个字符,如果需要增加字符串,那就需要使用addstr()函数。

  • 字符串的首字符将在当前光标处输出,它的参数是一个字符指针。

  • 如果字符串的长度超出了屏幕的大小,字符串将被截取掉。

  • curses中与addstr()类似的函数还包括waddstr()、mvwaddstr()、mvaddstr()。

printw()函数
int  printw(fmt [,arg…])
char *fmt;
  • printw()函数在屏幕上格式化输出一个或者多个值,这些值包括字符串、字符、十进制数、八进制数、以及二进制数。

  • 主要用来在标准屏幕上格式化输出。它与C语言中的printf()类似,也是变参函数,而且用法几乎完全相同。

  • 参数:

      - fmt  是一个字符串指针,用来表示打印的格式,比如对于字符串格式为%s,字符为%c,十进制整数为%d,八进制整数为%o等。
      - arg  是需要打印的值,如果给出的arg不止一个,每一个都必须用逗号隔开,它们必须与fmt的格式相适应。
      - 如果fmt为%s格式,则相应的arg参数必须为一个字符串指针,
      - 如果fmt为%d格式,则相应的arg参数必须为整数。
    
  • 类似的函数有mvprintw(),mvwprintw(),wprintw()。

(2)字符、字符串输入

getch()函数
  • getch()函数可以从终端键盘读入一个字符,并且返回字符的整数值。这个函数通常用来从键盘接受输入。

  • 输入模式决定了在程序接受字符之前内核进行处理的过程。如果终端被设置成ECHO模式,getch()接受的字符将立即在屏幕上显示,否则屏幕将保持不变化直到刷新后才显示出来

  • 通常情况下,内核会缓存输入文本,若不需要输入缓存,就必须设置CBRREAK或者raw模式。

     -  在raw模式下,内核并不进行缓存处理。
     -  在CBREAK模式下,除了^S、^Q、^C、^Y 等控制字符以外,其余的字符都原封不动的发送到系统中被处理。
     -  如果终端没有设置成RAW或者NOECHO模式,getch()将自动的将终端设置成CBREAK模式。
    
  • 对于普通的字符,getch() 将返回与字符本身相同的整数值。但如果想获取功能键和方向键等我们则必须设置功能键模式。一旦进行设置,getch()将返回curses.h中定义的与这些功能键对应的整数。

getstr()函数
int  getstr(str)
char* str
  • 从终端键盘接受字符串,并且显示在指定的位置上。

  • str是一个字符指针指向字符串变量或者存储字符串的位置。从键盘输入时候字符串必须以‘/n’结束,当字符串被存储的时候,‘/n’被空字符所代替。

  • 与getch()相同,如果终端模式被设置为ECHO,getstr()将终端屏幕上立即更新显示字符。如果终端模式没有设置为RAW或者NOECHO模式,函数将自动将终端设置为CBREAK模式,并在读入字符串以后自动恢复到以前的模式。

scanw()函数
int scanw(fmt [,argptr…])
char * fmt;
  • 格式化输入数据,并把它们拷贝到指定的位置,这个值可以是字符串、字符、十进制、八进制或者二进制数。
  • scanw()与scanf()函数类似,是一个变参函数。

(3)字符插入和删除

int  insch(ch)
chtype ch;
int delch();
  • insch()函数可以用来在当前位置上插入一个字符。

  • 字符插入以后光标将自动的向右移动一个位置,如果最右边的字符超出了终端屏幕的范围,它将被截取掉。

  • delch()删除当前光标处字符的函数。

  • 从当前位置删除一个字符,并且将删除字符右边的所有字符向左移动一个位置。当前行最右边的字符由空格代替。

(4)行插入和删除

int insertln();
int deleteln();
  • deleteln()函数删除当前行,并且将当前行下的所有的行向上移动一行,最后一行则用空格代替。
  • deleteln()函数没有任何的参数,如果函数执行正确,返回OK,否则返回ERR。

6.字符属性

(1)常见属性列表

A_NORMAL:标准的显示模式
A_BLINK:闪烁属性
A_BOLD:加粗属性
A_DIM: 半透明属性
A_REVERSE:反显属性
A_STANDOUT:高亮度显示属性
A_UNDERLINE:加下划线
A_ALTCHARSET:可代替字符集
COLOR_PAIR(n):字符的背景和前景属性

(2)设置和取消字符属性

int  attron(attrs)
int  attrset(attrs)
int attroff(attrs)
- chtype attrs;
  • 前面列出的那些属性可以单独的设置和关闭,某个属性的设置和关闭对其余的属性不会产生任何影响。

  • attron()设置attrs参数指定的属性,它的设置不影响任何现在已经存在的属性,但是一旦它设置,之后输出的所有的文本字符都将受该属性的影响。

  • attrset()的设置将影响到当前的所有的属性,因为它是用attrs指定的参数属性代替当前的所有属性,如果某个属性在设置之前是打开的,在attrs中却没有设置,那么设置后这个属性将被关闭。

  • attroff()关闭某个已经存在的属性。

  • attrset(0)的特殊用法关闭所有的属性。

7.光标操作

(1)光标的概念

Curses库中光标分为物理光标和逻辑光标。
- 物理光标是最常用的光标,每个窗体只有一个物理光标。
- 逻辑光标属于curses窗体,每个窗体可能有多个逻辑光标。
  • 在窗体(包括标准屏幕stdscr)中进行输入输出的时候,逻辑光标会不断的定位于窗体中要进行操作的区域。因此通过移动逻辑光标,可以在任何时候在窗体的任何部分进行输入输出。

int move(y,x);
int y,x;
  • move()函数可以将逻辑光标移动到给定的位置。

  • 这个位置是相对与屏幕的左上角而言,左上角的坐标为(0,0)。

  • 参数y是移动后位置的所在的行数,x为新位置所在的的列数。如果移动的目标位置超出了屏幕的范围,则会导致错误。

  • 屏幕的行宽和列宽在curses库中定义为(LINES-1,COLS-1)。

  • 需要注意的是行和列都是从0开始计数。我们进行的大部分操作在操作之前都需要移动光标到一定的位置,如果这样的话我们需要分两步进行:移动光标,然后进行相关操作。为了更方便,一些函数将移动光标与显示字符结合起来执行。这种函数的格式一般如下:

      mvfunc(y , x, [arg,…])
    
      - func一般为操作函数的名字,比如addch,addstr等等。
      - y为操作进行时候光标所在的行数,通常也是移动之后的新的光标位置。
      - x为操作进行时候光标所在的列数。
    
  • 例如:需要将光标移动到(10,5)处然后输出字符‘X’,那么我们就可以使用move()函数与addch()函数结合形成的mvaddch()函数来实现。可以写为:mvaddch(10,5,‘X’);

(2)清除屏幕

清除整个屏幕
  • 方法一:先用空白字符填充屏幕所有区域,然后再调用refresh()函数刷新屏幕。
  • 方法二:是用固定的终端控制字符清除屏幕。
  • 第一种方法比较慢,因为它需要重写当前屏幕。第二种能迅速清除整个屏幕内容。

- 清除整个屏幕
	int clear();
	int erase();
- 清除指定窗口
	wclear();
	werase();
  • 这四个函数在标准屏幕上使用空格来代替当前字母从而达到清屏的效果。

  • clear()清除屏幕上的所有的字符并且将光标移动到屏幕的原点(0,0),继而自动调用clearok()函数,这使得屏幕在下次调用refresh()刷新的时候能够完全被清除。因此clear()函数能够清除物理屏幕上的那些无法识别的东西,这样下次输出将是基于一个完全“干净”的屏幕进行的。

  • erase()函数同样可以用来清除屏幕,但是它不会自动调用clearok()函数,因此与clear()相比,它并不是一种最彻底的清除方式。

部分屏幕清除
int  clrtoeol()
int  clrtobot()
  • 用空格代替当前的需要清除部分的现有字符。

  • clrtobot()清除从当前光标位置到屏幕底端的所有内容。

  • clrtoeol()清除屏幕上从当前光标位置到该行末尾的所有字符。

  • 必须调用refresh()以后才开始生效。

  • 不管clrtobot()还是clrtoeol()都会改变当前光标的位置。

以上只是我编写TUI游戏中用到的curses中的最简单、最基础的内容,我的代码中对于颜色、窗体都没有设计编写,实现的非常简单...

二、事件驱动编程:编写一个视频游戏

1.视频游戏和操作系统

(1)视频游戏怎么做

  • 创建游戏中物体的影像,并使它们移动。

    • 每个程序创建的东西都有自己的属性:移动速度、方向、动力和其它属性。
    • 物体之间相互联系,作用。
  • 游戏要与用户互动,响应用户输入。

    • 游戏玩家通过按钮和鼠标,在任何时刻都有可能生成输入,程序必须在短时间内做出响应。
    • 这些输入会影响创建的物体的属性,改变移动速度、方向等。

(2)视频游戏如何做

空间
  • 游戏必须在计算机屏幕的特点位置画影像。
  • 程序如何控制视频显示?
时间
  • 影像在屏幕上移动,以一个特定的时间间隔改变位置。
  • 程序如何获知时间并且在特定的时间触发事情发生呢?
中断
  • 程序在屏幕上平滑的移动物体,用户可以在任意时刻产生输入。
  • 程序是如何响应这些中断的?
并发——同时做几件事
  • 游戏在保证几个影像移动的同时还要响应中断。
  • 程序如何实现同时做这么多事而不被弄得晕头转向呢?

(3)类比操作系统面临类似问题

  • 操作系统同样要面临上面4个类似的问题:

      - 内核将程序载入内存空间并维护每个程序在内存中所处的位置。
      - 在内核的调度下,程序以时间片间隔的方式运行,同时,内核在特定的时间运行特定的任务。
      - 内核必须在很短的时间内响应用户和外设在任何时刻的输入。
      - 同时,做几件事需要一些技巧。内核要保证数据的有序和规整的。
    
屏幕管理、时间、信号、共享资源

(4)弹球游戏

  • 三个主要元素:墙、球和挡板

  • 游戏的概要描述:

      - 球以一定的速度和方向移动
      - 球碰到墙壁或挡板会被弹回
      - 用户按按钮来控制挡板移动
    

2.时间与时钟

(1)sleep

  • 需求:游戏中,需要把影像在特定的时间置于特定的位置。用curses把影像置于特定的位置,然后在程序中添加时间响应。
  • 方法:使用系统函数sleep。
简单的例子1
#include	<stdio.h>
#include	<curses.h>

void main()
{
	int	i;
	initscr();
	   clear();
	   for(i=0; i<LINES; i++ ){
		move( i, i+i );
		if ( i%2 == 1 ) 
			standout();
		addstr("Hello, world");
		if ( i%2 == 1 ) 
			standend();
		sleep(1);
		refresh();	//比较不同,然后刷新屏幕
	   }
	endwin();
}
  • Hello,world这串字符在屏幕上自上而下逐行显示,每秒增加一行,反色和正常交替出现。

简单的例子2
  • 如何让上一个例子创造移动的假象?

      #include	<stdio.h>
      #include	<curses.h>
      
      void main()
      {
      	int	i;
      
      	initscr();
      	   clear();
      	   for(i=0; i<LINES; i++ ){
      		move( i, i+i );
      		if ( i%2 == 1 ) 
      			standout();
      		addstr("Hello, world");
      		if ( i%2 == 1 ) 
      			standend();
      		refresh();
      		sleep(1);
      		move(i,i+i);	//将光标移动到上一条字符串的开头
      		addstr("             ");	//用空串覆盖原有字符串
      	   }
      	endwin();
      }
    
  • 方法:先输出一个字符串,然后sleep一秒,然后在原来的地方写空字符串覆盖掉原有字符串,在下一行输出新的字符串。

sleep如何工作的:使用Unix中的Alarms
  • sleep函数由3个步骤组成:

      - 为SIGALRM设置一个处理函数
      - 调用alarm(num_seconds)
      - 调用pause
    
存在的问题
  • 一秒钟的时间太长,需要更精准的计时器
  • 需要增加用户输入

(2)间隔计时器

- 有更高的精度:可以精确到微秒。
	- usleep()将当前进程挂起n微秒或者直到有一个不能被忽略的信号到达。
- 每个进程都有3个独立的计时器
- 每个计时器有两个设置:初始间隔和重复间隔设置
- 支持alarm和sleep
三种计时器

- 真实(ITIMER_REAL):进程运行的全部时间
- 虚拟(ITIMER_VIRTUAL):进程在用户态的时间
- 实用(ITIMER_PROF):进程在用户态加内核态的时间
两种间隔
  • 包含初始间隔和重复间隔,每个间隔由秒数和微秒数组成。

      - 初始间隔	it_value
      - 重复间隔	it_interval
    
  • it_value设为0,关掉两个时钟。

  • it_interval设为0,不再重复这一特征。

取得或设置间隔计时器
#include<sys/time.h>

int set_ticker(int n_msecs)  
{  
    struct itimerval new_timeset;  
    long n_sec, n_usecs;  

    n_sec = n_msecs / 1000;  
    n_usecs = (n_msecs % 1000) * 1000L;  

    new_timeset.it_interval.tv_sec = n_sec;  //设置初始间隔  
    new_timeset.it_interval.tv_usec = n_usecs;  

    new_timeset.it_value.tv_sec = n_sec;   //设置重复间隔  
    new_timeset.it_value.tv_usec = n_usecs; 

    return setitimer(ITIMER_REAL, &new_timeset, NULL);     
}

- getitimer(int which, struct itimerval *val)  
- setitimer(int which, const struct itimerval* newval, struct itimerval *oldval);  
- which指定哪种计时器:ITMER_REAL, ITIMER_VIRTUAL, ITIMER_PROF。
struct itimerval

struct itimerval {
    struct timeval it_interval;
    struct timeval it_value;
};
struct timeval {
    time_t tv_sec;
    suseconds_t tv_usec;
};
小结
  • 内核只有一个系统时钟脉冲,当收到一个时钟脉冲,内核将会使所有的计时器减一个时钟单位。即内核在每个时钟脉冲时,遍历所有的间隔计时器,使用这样的手段来管理每个进程的真实计时器。
  • 其他两个计时器,在程序运行的特定时刻进行计时。
  • 通过这样的机制,来实现每个进程三个计时器的管理。
  • 可以通过setitimer系统调用,得到了更高的精度控制计时器,同时能够以固定的时间间隔发送信号

3.信号处理

(这一部分在《深入理解计算机系统》第八章中已经学习过,就不再赘述)

三、弹球游戏的实现与优化

1.基本弹球功能实现

(1)头文件

#include <curses.h>
#include <sys/time.h>
#include <signal.h>
#include <stdlib.h>

(2)宏定义

#define LEFT 0		//当前屏幕的最左边
#define TOP 0		//当前屏幕的最上边
#define RIGHT COLS-1	//球所能到达的当前屏幕最大水平范围
#define BOTTOM LINES-1	//球所能到达的当前屏幕最大垂直范围
#define WIDE RIGHT-LEFT+1	//宽度
#define BOARD_LENGTH 10		//挡板长度

(3)全局变量

char BALL = 'O';	//球的形状
char BLANK = ' ';	//覆盖球走过的轨迹

int hdir;	//控制球水平运动的变量
int vdir;	//控制球垂直运动的变量
int pos_X;	//球的横坐标
int pos_Y;	//球的纵坐标

int left_board;		//挡板左端点
int right_board;	//挡板右端点
int is_lose=0;		//标志:小球是否落在挡板上

int delay=100;	//设置速度
int ndelay;		//速度倍数

(4)主函数

int main()
{

	//初始化curses
	initscr();
	crmode();	//中断模式
	noecho();	//关闭回显
	control();
	endwin();	//结束 curses
	return 0;
}

(5)初始化小球和挡板

void init()
{
	int i,j;
	clear();

	//初始球
	pos_X =20;		//球初始的横坐标
	pos_Y = BOTTOM-1;	//球初始的纵坐标

	//初始化球的运动方向,朝右上方运动
	hdir=1;
	vdir=-1;

	//初始挡板
	left_board=20;
	right_board=left_board+BOARD_LENGTH;
	//显示挡板
	for(i=left_board;i<=right_board;i++)
	{
		move(BOTTOM,i);
		addch('=');
	}

	//初始刷新时间
	ndelay=2;
	signal(SIGALRM,moveBall);
	set_ticker(delay*ndelay);

	keypad(stdscr,TRUE);	//打开 keypad 键盘响应
	attroff(A_BLINK);	//关闭 A_BLINK 属性
	
	is_lose=0;
	move(pos_Y,pos_X);
	addch(BALL);
	move(LINES-1, COLS-1);
	refresh();
	usleep(100000);
	move(LINES-1,COLS-1);
	refresh();
}
  • 特别注意:屏幕的坐标原点在左上角,向上运动是负数。

(6)小球运动

void moveBall()
{
	if(is_lose) return;
	signal(SIGALRM,moveBall);
	move(pos_Y,pos_X);
	addch(BLANK);
	pos_X += hdir;
	pos_Y += vdir;

	//改变方向
	if(pos_X >= RIGHT)	//当球横坐标大于右边边缘时,球反弹朝左运动
		hdir = -1;
	if(pos_X <= LEFT)	//当球横坐标大于左边边缘时,球反弹朝右运动
		hdir = 1;
	if(pos_Y <= TOP)	//当球纵坐标大于顶部边缘时,球反弹朝下运动
		vdir = 1;

	//当球在底部的时候进行额外的处理
	if(pos_Y >= BOTTOM-1)
	{
		if(pos_X>=left_board&&pos_X<=right_board)	//球在挡板处
			vdir=-1;
		else	//球不在挡板处
		{
			is_lose=1;
			move(pos_Y,pos_X);
			addch(BALL);
			move(LINES-1, COLS-1);
			refresh();
			usleep(delay*ndelay*1000);
			move(pos_Y,pos_X);
			addch(BLANK);
			pos_X += hdir;
			pos_Y += vdir;
			move(pos_Y,pos_X);
			addch(BALL);
			move(LINES-1, COLS-1);
			refresh();
		}
	}
	//不改变球的方向
	move(pos_Y,pos_X);
	addch(BALL);
	move(LINES-1, COLS-1);
	refresh();
	
}

(7)键盘控制事件响应

	cmd=getch();
	if(cmd=='q') break;//按“Q”键退出游戏
	//挡板左移
	else if(cmd==KEY_LEFT)
	{
		if(left_board>0)
		{
			move(BOTTOM,right_board);
			addch(' ');
			right_board--;
			left_board--;
			move(BOTTOM,left_board);
			addch('=');
			move(BOTTOM,RIGHT);
			refresh();
		}
	}	
	//挡板右移
	else if(cmd==KEY_RIGHT)
	{
		if(right_board<RIGHT)
		{
			move(BOTTOM,left_board);
			addch(' ');
			right_board++;
			left_board++;
			move(BOTTOM,right_board);
			addch('=');
			move(BOTTOM,RIGHT);
			refresh();
		}
	}

(8)输掉球后的处理

int flag=1;
char choice;
move(6,10);
addstr("Game over! Do you want try again?(y/n):");
refresh();
choice=getch();
while(flag)
{
	if(choice=='y'||choice=='Y'||choice=='n'||choice=='N')
	 	flag=0;
	else  choice=getch();
}
if(choice=='y'||choice=='Y')
{
	init();
	continue;
}
else
{
	if(choice=='n'||choice=='N')
		break;
}

(8)计时器用的就是之前提到的间隔计数器:int set_ticker(int n_msecs)

2.优化与改进

增加欢迎界面

设置游戏难易程度:更改小球速度和挡板长度

增加计分功能

附:最终版源代码

/*
	Bounceball Game
	Version 1.3
		- 新增welcome界面
		- 可设置游戏难易程度(小球速度,挡板长度)
		- 记分
*/
#include <curses.h>
#include <sys/time.h>
#include <signal.h>
#include <stdlib.h>

#define LEFT 0		//当前屏幕的最左边
#define TOP 0		//当前屏幕的最上边
#define RIGHT COLS-1	//球所能到达的当前屏幕最大水平范围
#define BOTTOM LINES-1	//球所能到达的当前屏幕最大垂直范围
#define WIDE RIGHT-LEFT+1	//宽度

char BALL = 'O';	//球的形状
char BLANK = ' ';	//覆盖球走过的轨迹

int hdir;	//控制球水平运动的变量
int vdir;	//控制球垂直运动的变量
int pos_X;	//球的横坐标
int pos_Y;	//球的纵坐标

int left_board;
int right_board;
int BOARD_LENGTH;	

int is_lose=0;
int score=0;

int delay;
int ndelay;
void init();
void moveBall();
void control();
int set_ticker(int n_msecs);
void start();
void help();
void information();
int welcome();

int main()
{

	//初始化curses
	initscr();
	crmode();	//中断模式
	noecho();	//关闭回显

	welcome();

	endwin();	//结束 curses
	return 0;
}

int welcome()
{
	move(6,20);
	addstr("Hello! Welcome the the Bounceball Game!");
	move(8,23);
	addstr("1.Start the Game");
	move(9,23);
	addstr("2.Help");
	move(10,23);
	addstr("3.About me");
	move(11,23);
	addstr("4.Quit");

	int flag = 1;
	char choice;	
	move(13,23);
	addstr("Please choose your choices : ");
	refresh();
	choice=getch();
	while(flag)
	{
		if(choice=='1'||choice=='2'||choice=='3'||choice=='4')
		{
			flag = 0;
			switch(choice)
			{
				case '1':
					start();
					break;
				case '2':
					help();
					welcome();
					break;
				case '3':
					information();
					welcome();
					break;
				case '4':
					break;
			}
		}
		else
			choice=getch();
	}
	return 0;
}

void start()
{
	clear();
	move(6,20);		
	addstr("Game Level:");
	move(8,23);
	addstr("1.Easy");
	move(9,23);
	addstr("2.Normal");
	move(10,23);
	addstr("3.Hard");

	score=0;
	int flag = 1;
	char choice;	
	move(13,23);
	addstr("Please choose the level : ");
	refresh();
	choice=getch();
	while(flag)
	{
		if(choice=='1'||choice=='2'||choice=='3')
		{
			flag = 0;
			switch(choice)
			{
				case '1':
					delay=100;
					BOARD_LENGTH=10;
					break;
				case '2':
					delay=60;
					BOARD_LENGTH=8;
					break;
				case '3':
					BOARD_LENGTH=5;
					delay=40;
					break;
			}
		}
		else
			choice=getch();
	}

	clear();
	move(8,20);		
	addstr("Are you ready?");
	refresh();
	control();
}

void control()
{
	init();
	int cmd;
	while (1)
	{	
		if(!is_lose)
		{
			cmd=getch();
			if(cmd==27) break;//退出
			//挡板左移
			else 
			{
				if(cmd==KEY_LEFT)
				{
					if(left_board>0)
					{
						move(BOTTOM,right_board);
						addch(' ');
						right_board--;
						left_board--;
						move(BOTTOM,left_board);
						addch('=');
						move(BOTTOM,RIGHT);
						refresh();
					}
				}
				//挡板右移
				else 
				{
					if(cmd==KEY_RIGHT)
					{
						if(right_board<RIGHT)
						{
							move(BOTTOM,left_board);
							addch(' ');
							right_board++;
							left_board++;
							move(BOTTOM,right_board);
							addch('=');
							move(BOTTOM,RIGHT);
							refresh();
						}
					}
				}
			}	
		}
		else
		{
			//输掉球后的处理
			int flag=1;
			char choice;
			move(6,20);
			addstr("Game over!");
			move(8,20);
			addstr("Your score : ");
			printw("%d",score);
			move(10,20);
			addstr("Do you want to try again?(y/n):");
			refresh();
			choice=getch();

			while(flag)
			{
				if(choice=='y'||choice=='Y'||choice=='n'||choice=='N')
				 	flag=0;
				else  choice=getch();
			}
			if(choice=='y'||choice=='Y')
			{
				score=0;
				init();
				continue;
			}
			else
			{
				if(choice=='n'||choice=='N')
				{
					clear();
					refresh();
					welcome();
					break;
				}
			}	
		}
	}
}

void init()
{
	int i,j;
	clear();

	//初始球
	pos_X =20;		//球初始的横坐标
	pos_Y = BOTTOM-1;	//球初始的纵坐标

	//初始化球的运动方向,朝右上方运动	
	hdir=1;
	vdir=-1;

	//初始挡板
	left_board=20;
	right_board=left_board+BOARD_LENGTH;

	//显示挡板
	for(i=left_board;i<=right_board;i++)
	{
		move(BOTTOM,i);
		addch('=');
	}

	//初始刷新时间
	ndelay=2;
	signal(SIGALRM,moveBall);
	set_ticker(delay*ndelay);

	keypad(stdscr,TRUE);	//打开 keypad 键盘响应
	attroff(A_BLINK);	//关闭 A_BLINK 属性

	is_lose=0;
	move(pos_Y,pos_X);
	addch(BALL);
	move(LINES-1, COLS-1);
	refresh();
	usleep(100000);
	move(LINES-1,COLS-1);
	refresh();
}

void moveBall()
{
	if(is_lose) return;
	signal(SIGALRM,moveBall);
	move(pos_Y,pos_X);
	addch(BLANK);
	pos_X += hdir;
	pos_Y += vdir;

	//改变方向
	if(pos_X >= RIGHT)
		hdir = -1;
	if(pos_X <= LEFT)
		hdir = 1;
	if(pos_Y <= TOP)
		vdir = 1;

	//当球在底部的时候进行额外的处理
	if(pos_Y >= BOTTOM-1){
		if(pos_X>=left_board&&pos_X<=right_board)
		{
			vdir=-1;
			score++;
		}
		else
		{
				is_lose=1;
				move(pos_Y,pos_X);
				addch(BALL);
				move(LINES-1, COLS-1);
				refresh();
				usleep(delay*ndelay*1000);
				move(pos_Y,pos_X);
				addch(BLANK);
				pos_X += hdir;
				pos_Y += vdir;
				move(pos_Y,pos_X);
				addch(BALL);
				move(LINES-1, COLS-1);
				refresh();
		}
	}
	//不改变球的方向
	move(pos_Y,pos_X);
	addch(BALL);
	move(LINES-1, COLS-1);
	refresh();
	
}

int set_ticker(int n_msecs)
{
	struct itimerval new_timeset;	
	long n_sec,n_usecs;
	n_sec=n_msecs/1000;
	n_usecs=(n_msecs%1000)*1000L;
	new_timeset.it_interval.tv_sec=n_sec;
	new_timeset.it_interval.tv_usec=n_usecs;
	new_timeset.it_value.tv_sec=n_sec;
	new_timeset.it_value.tv_usec=n_usecs;
	return setitimer(ITIMER_REAL,&new_timeset,NULL);
}


void help()
{
	clear();
	move(6,20);
	addstr("Help Information");
	move(8,23);
	addstr(" <- : Control the baffle left shift");
	move(9,23);
	addstr(" -> : Control the baffle right shift");
	move(10,23);
	addstr(" q : Exit the game ");
	move(12,40);
	addstr("Press any key to exit...");
	refresh();
	int ch=getch();
	clear();
	refresh();
}

void information()
{
	clear();
	move(6,20);
	addstr("About the Game");
	move(8,23);
	addstr("written by 20135317_Han");
	move(9,23);
	addstr("Version 1.3");
	move(11,40);
	addstr("Press any key to exit...");
	refresh();
	int ch=getch();
	clear();
	refresh();
}

四、简单TUI游戏设计实践总结

1.关于curses库

  • 这个弹球游戏的设计实践实现的非常简单,老师给的参考资料中的《Unix/Linux下的Curses库开发指南》对Curses库的讲解非常全面,而且有代码的例子供读者参考,易于理解。
  • 我的代码中用到的主要是这本书前两章的内容,没有对颜色、窗口、鼠标、面板、菜单、表单的设置,但是看了书中的介绍,让我感到curses库的真的是很强大,尤其是它良好的可移植性和简单的操作函数应用让编写变得容易了许多。

2.在编程任务中学习

  • 另一本参考资料《Unix-Linux编程实践教程》给我提供了很好的在编程任务中学习的例子。这本书的组织模式就是通过读者的编写来学习相关内容。比如通过编写who命令学习用户、文件和联机帮助,通过编写ls命令学习目录与文件属性......这个视频游戏也是通过编写一个弹球游戏来学习curses库、时间和时钟编程、信号处理的内容。
  • 这学期有一次实践中我自己尝试编写了who命令,对这种在编程任务中学习的方法感受很深。这样的学习方式会花费了不少时间,因为编写的过程中不可避免的会遇到的很多问题,但是通过查找资料和自己的认真分析研究去解决之后,对问题的理解会更深入,也很有成就感。

3.其它

  • 这次实践编写不是很难,但是这篇博客由于各种原因拖了很长时间才完成。我自己感觉写完就像从头又学习了一遍,倒是对一些之前理解模糊的概念更清楚了。
  • 越学越觉得自己不会的好东西太多了,老师给的这两本参考资料,尤其是《Unix/Linux编程实践教程》,感觉对学习教材《深入理解计算机系统》是非常好的辅助。可能是因为有些东西是从不同角度的讲解,相辅相成,理解得更加深入。

参考资料

参考资料1:Unix/Linux编程实践教程(Understanding UNIX/LINUX Programming)
参考资料2:Unix/Linux下的Curses库开发指南——第一章 Curses库开发简介
参考资料3:Unix/Linux下的Curses库开发指南——第二章 curses库I/O处理
参考资料4:Linux下curses函数库安装运行
参考资料5:linux中curses使用
参考资料6:curses编程函数1(三类输出函数)
参考资料7:Linux的sleep()和usleep()的使用
参考资料8:《Unix-Linux编程实践教程》读书笔记(七)
参考资料9:第七章 事件驱动编程(curses库,计时器,信号,异步I/O)
参考资料10:Linux进程的计时器和间隔计时器
参考资料11:时钟编程: alarm和setitimer
参考资料12:信息安全系统设计基础第十周学习总结——第八章 异常控制流