用OpenGL实现3D2048游戏

目录
  1. 程序概况
  2. Glut使用
    1. Glut的安装
    2. Glut基本框架
      1. 主函数main
      2. 渲染函数renderScene
      3. 窗口大小调整changeSize
    3. Glut的目录制作
    4. Glut对键盘的响应
  3. 游戏库编写
    1. 函数介绍
TOC

本游戏使用的是OpenGL的Glut作为图形展示部分,整体程序构架不难,主要是简单入门一门OpenGL。

程序概况

程序由C++语言编写,由图形显示和游戏逻辑两部分构成,其中图形显示使用的是OpenGL的Glut,虽然这个库比较老了,但不需要捆绑主体和编写单独的着色器程序还是很让人快乐的。

本文主要想简单介绍一下Glut的使用和框架,以及编写游戏的逻辑,也算是对工作的小小总结罢。

主程序作为main函数负责图形显示,而游戏部分作为GameSDK库函数存放于另一个文件中,并专门编写了一些查询函数对自己的库中内容进行提取。

在第二版游戏中,考虑到3D的2048使得玩家几乎不可能因为卡死而输掉游戏,而且2048几乎是必定达到的目标,将游戏的评分和目标改为了尽量在2048的方块生成时剩下尽量少的方块,除2048外剩下的数字越小,评分越高。

考虑到3D的2048会对排版布局有很大考验,我添加了一系列辅助工具来使得在二维上显示的三维图案足够清晰,同时,整个游戏采用简约风格,去掉了可能令人看花的光影,使用简单的线条和染色方块构造游戏画面。

Glut使用


Glut的安装

我使用的是Dev C++作为编译器

Glut我参考的是devc++下配置opengl和glut的文章,下载相关文件后,按照提示装入相应文件夹,即可运行。


Glut基本框架

主函数main

Glut运行在主函数main下,但需要对主函数做出一定修改,添加int argc, char **argv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define GLUT_DISABLE_ATEXIT_HACK
#include <windows.h>
#include <math.h>
#include <GL/glut.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowPosition(100,100);
glutInitWindowSize(640*win_scale,360*win_scale);
mainw = glutCreateWindow("3D2048 Game");
initScene();
glutDisplayFunc(renderScene);
glutIdleFunc(renderScene);
glutReshapeFunc(changeSize);

glutMainLoop();
return(0);
}

随后,glutInit初始化一个进程,而glutInitDisplayMode建立了指定的缓冲区, glutInitWindowPosition指定创建的窗口位置,glutInitWindowSize指定窗口大小,最后glutCreateWindow创建了一个名为mainw的标题为“3D2048 Game”的窗口。

glutDisplayFuncglutIdleFunc分别指定在演示和空闲时应该执行的函数,glutReshapeFunc指定了一个函数来指导窗口缩放带来的图像问题,最后用glutMainLoop开始循环。

注意,main函数是int型的,这意味着它有返回值0。

渲染函数renderScene

渲染函数就是mainglutDisplayFunc和glutIdleFunc指定的函数,名字是自己取的,叫做render只是比较符合OpenGL的惯例。

代码如下:

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
void renderScene(void) {
float size;
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
//doki-doki
if (doki){
doki_scale_i = doki_scale_i + doki_speed;
if(doki_scale_i >= 78 || doki_scale_i <= 0)doki_speed = -doki_speed;
doki_scale = 1.0 + doki_s*sin(doki_scale_i/314.15);
}
//self-span
if (self_span) {
anglex += 0.01f;
cameraMove(anglex,angley);
}
if (help_flag || (game.ifgameover() != 2)) {
if (help_flag) help();
else gameOver();
}
else{
//画了 16 个 方块
sred = red;sgreen = green; sblue = blue;
for(int i = -2; i < 2; i++)
for(int j=-2; j < 2; j++) {
if(game.askBoard(i + 2, j + 2) == 0) continue;
glPushMatrix();
glTranslatef(0,(i+0.5)*1.5,(j+0.5) * 1.5);
if (game.askMatch(i + 2, j + 2) && doki)
glScalef(doki_scale,doki_scale,doki_scale);
if (colorStyle){
size = float(game.askBoard(i + 2, j + 2));
size = 1.0 - (1.0/12.0) * log2(size);
sred = (red)?1.0:size; sgreen = (green)?1.0:size; sblue = (blue)?1.0:size;
}
if (sizeStyle){
size = float(game.askBoard(i + 2, j + 2));
size = 1.0 - 1.0/12.0 * (log2(size) - 1);
glScalef(size,size,size);
}
draw_cube(game.askBoard(i + 2, j + 2));
glPopMatrix();
}

strShow("D",0.0,0.0,4.0);
strShow("A",0.0,0.0,-4.0);
strShow("W",0.0,3.0,0.0);
strShow("S",0.0,-3.0,0.0);
strShow(strAnd("score:",int2str(game.score())),0.0,2.0,4.0);
if (show_set){
std::string s;
if (doki) s = "True"; else s = "False";
strShow(strAnd("Doki:",s),0.0,2.0,5.0);
if (lineStyle) s = "True"; else s = "False";
strShow(strAnd("Cube:",s),0.0,1.0,5.0);
if (colorStyle) s = "True"; else s = "False";
strShow(strAnd("Color:",s),0.0,0.0,5.0);
if (sizeStyle) s = "True"; else s = "False";
strShow(strAnd("Size:",s),0.0,-1.0,5.0);
if (num_flag) s = "True"; else s = "False";
strShow(strAnd("Num:",s),0.0,-2.0,5.0);
if (self_span) s = "True"; else s = "False";
strShow(strAnd("Span:",s),0.0,-3.0,5.0);
}
}
glutSwapBuffers();
}

前面的几个ifelse都只是给程序添加的一点小功能,重要的是先glClear清空缓存区,然后对于每一个小方块,在glPushMatrix()glPopMatrix()之间(这样不会改变世界坐标),用glTranslatefglScalef移动和放缩小方块,并调用draw_cube函数来绘制小方块。

下面的strShow是自己写的函数,主要功能是显示文字在指定位置。而strAnd则负责连接两个字符串,int2str负责将数字转换为字符串。

最后,通过glutSwapBuffers交换缓存区和显示区,通过两个缓存的交替,实现图形的移动。

对于draw_cube(),函数代码如下:

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
void draw_cube(int num){  
//8个顶点
GLfloat x=1.0f,y=1.0f,z=1.0f;
static const GLfloat vetexs[][3] = {
0.0,0.0,0.0,
x,0.0,0.0,
0.0,y,0.0,
x,y,0.0,
0.0,0.0,z,
x,0.0,z,
0.0,y,z,
x,y,z
};
glColor3f(sred, sgreen, sblue);
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);
if (lineStyle)
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
else
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
if (num != 3 && num_flag)
strShow(int2str(num),0.0,0.0,0.0);
glTranslatef(-x/2,-y/2,-z/2); //平移至中心
//画立方体
for(int i = 0;i<6;i++){
glBegin(GL_QUADS);
glVertex3f(vetexs[quads[i][0]][0],vetexs[quads[i][0]][1],vetexs[quads[i][0]][2]);
glVertex3f(vetexs[quads[i][1]][0],vetexs[quads[i][1]][1],vetexs[quads[i][1]][2]);
glVertex3f(vetexs[quads[i][2]][0],vetexs[quads[i][2]][1],vetexs[quads[i][2]][2]);
glVertex3f(vetexs[quads[i][3]][0],vetexs[quads[i][3]][1],vetexs[quads[i][3]][2]);
glEnd();
}
}

当然,这里使用的是一种比较繁琐的方式,实际上可以直接调用Glut函数库中的内容实现立方体的绘制,通过调整glPolygonMode的模式,可以控制立方体由线构成还是由着色的面构成。

对于strShow(),其代码如下:

1
2
3
4
5
6
void strShow(std::string str,float x,float y,float z) {
int n = str.size();
glRasterPos3f(x, y, z);
for (int i = 0; i < n; i++)
glutBitmapCharacter(GLUT_BITMAP_TIMES_ROMAN_24, str[i]);
}

其中glRasterPos3f用于控制字体所在的位置,glutBitmapCharacter表示显示文字,但只按char格式显示,因此需要自行添加框架,而GLUT_BITMAP_TIMES_ROMAN_24是指定的字体和大小。

这种方式指定的文字和贴图不同,会一直面向摄像头,其添加方式也比贴图要简单,但其字号和字体选择极为有限,不支持大号的显示。

窗口大小调整changeSize

renderScene类似,这里的命名也不是强求的,这个函数主要用于保证图形显示不会随着窗口大小的改变而失真。

其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void changeSize(int w, int h)
{
// 防止被 0 除.
if(h == 0) h = 1;
ratio = 1.0f * w / h;
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
//设置视口为整个窗口大小
glViewport(0, 0, w, h);
//设置可视空间
gluPerspective(45,ratio,1,1000);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(-camera_lenth, 0.0f, 0.0f,
0.0f,0.0f,0.0f,
0.0f,1.0f,0.0f);
}

这一块属于必有的过程,一般不需要修改,需要注意的是最后的gluLookAt函数是用于控制摄像头方向的。


Glut的目录制作

Glut自带小目录制作函数,简单美观,对于一个小游戏的菜单来说,是一个不错的选择。

在主函数main下,设定目录以及子目录

代码部分如下:

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
int subMenu1,subMenu2,Menu;
subMenu1 = glutCreateMenu(ColorSubMenu);
glutAddMenuEntry("Red", 1);
glutAddMenuEntry("Blue", 2);
glutAddMenuEntry("Green", 3);
glutAddMenuEntry("White", 4);
glutAttachMenu(GLUT_RIGHT_BUTTON);

subMenu2 = glutCreateMenu(StyleSubMenu);
glutAddMenuEntry("Doki-Doki", 1);
glutAddMenuEntry("Cube Style", 2);
glutAddMenuEntry("Self Span", 3);
glutAddMenuEntry("Color Style", 4);
glutAddMenuEntry("Size Style", 5);
glutAddMenuEntry("Show Number", 6);
glutAddMenuEntry("Show Settings", 7);
glutAttachMenu(GLUT_RIGHT_BUTTON);

Menu = glutCreateMenu(MainMenu);
glutAddMenuEntry("Restart Game", 1);
glutAddMenuEntry("Help and Related", 2);
glutAddSubMenu("Color", subMenu1);
glutAddSubMenu("Setting", subMenu2);
glutAttachMenu(GLUT_RIGHT_BUTTON);

子目录被命名为subMenu,通过glutCreateMenu新建目录,对其命名并把它捆绑到一个自定义的函数上,利用glutAddMenuEntry添加目录选项,并排序(和绑定的函数中对应)。通过glutAttachMenu(GLUT_RIGHT_BUTTON)设定目录打开方式是单击右键(一般都是这种方式)。

在主目录中,通过glutAddSubMenu添加子目录,并把子目录名字自己绑定。

随后,我们需要编写相应的自定义函数来说明当点击了目录的条目后会发生什么。

代码部分如下:

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
void MainMenu(GLint MainOption)
{
switch (MainOption)
{
case 1:
restart();
break;
case 2:
help_flag = (help_flag)?0:1;
break;
}

glutPostRedisplay();
}

void ColorSubMenu(GLint colorOption)
{
switch (colorOption)
{
case 1:
red = 1.0f; green = 0.0f; blue = 0.0f;
break;
case 2:
red = 0.0f; green = 0.0f; blue = 1.0f;
break;
case 3:
red = 0.0f; green = 1.0f; blue = 0.0f;
break;
case 4:
red = 1.0f; green = 1.0f; blue = 1.0f;
break;
}

glutPostRedisplay();
}

void StyleSubMenu(GLint styleOption)
{
switch (styleOption)
{
case 1:
doki = (doki)?0:1;
break;
case 2:
lineStyle = (lineStyle)?0:1;
break;
case 3:
self_span = (self_span)?0:1;
break;
case 4:
colorStyle = (colorStyle)?0:1;
break;
case 5:
sizeStyle = (sizeStyle)?0:1;
break;
case 6:
num_flag = (num_flag)?0:1;
break;
case 7:
show_set = (show_set)?0:1;
break;
}

glutPostRedisplay();
}

可以看到,函数中的框架基本固定,case和之前在main中声明的条目相对应,而这种方式制作的目录很适合对一些参数进行修改,根据这个原理,在游戏中我们实现了对主题色,样式和辅助功能的修改。


Glut对键盘的响应

游戏通过键盘控制游戏进程,因此,与键盘的交互也必不可少。Glut的键盘操作要求在main函数注册监控,然后通过自定义的函数控制具体的改变。

主函数中的代码如下:

1
2
glutSpecialFunc(inputSpecialKey);
glutKeyboardFunc(inputNormalKey);

其中inputSpecialKeyinputNormalKey分别是对特殊字符(如F1小键盘)和常用字符(如字母数字)的自定义函数,利用glutSpecialFuncglutKeyboardFunc将它们和主函数绑定。

而两个函数的具体内容如下:

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
void inputSpecialKey(int key, int x, int y) {
switch (key) {
case GLUT_KEY_LEFT :
anglex -= 0.01f;
cameraMove(anglex,angley);break;
case GLUT_KEY_RIGHT :
anglex += 0.01f;
cameraMove(anglex,angley);break;
case GLUT_KEY_UP :
angley -= 0.01f;
cameraMove(anglex,angley);break;
case GLUT_KEY_DOWN :
angley += 0.01f;
cameraMove(anglex,angley);break;
}
}

void inputNormalKey(unsigned char key, int x, int y) {
switch (key) {
case 'a' :
game.getmove('a');break;
case 'w' :
game.getmove('s');break;
case 's' :
game.getmove('w');break;
case 'd' :
game.getmove('d');break;
case 'r' :
cameraReset();break;
case 27 :
exit(0);
}
}

inputSpecialKeyGLUT_KEY_LEFTGLUT_KEY_RIGHT等为小键盘的输入,inputNormalKey通过char或者ASCII码指定字符,二者都有固定的格式,利用 switch 对内容进行判断是一种常见的选择。

游戏库编写

游戏逻辑的编写被分成两个版本,一个是伪3D版本的2048,一个是真·3D2048,后者拥有4X4X4的大小,支持前后左右上下的移动,功能上基本覆盖前者,因此这里主要针对后者进行介绍。


函数介绍

游戏库中有内部函数:用于初始化棋盘并产生4个随机数的setchessboard,在测试时负责打印棋盘的printchessboard,用于重新开始和第一次开始的initial,对匹配信息棋盘的更新updateMatch,以及六个不同方向的函数:updownleftrightbackfront

对于外部交互,有函数:查询分数(当前棋盘所有数之和)score,查询棋盘某一个位置的数字(C不允许输出整个数组)askBoard,查询匹配信息棋盘某一个位置的数字askMatch,对棋盘进行一步操作getmove,判断游戏是否结束ifgameover,返回上一步的棋盘(悔棋)regret

具体代码没什么特点,就是按部就班地玩数组就好了,具体的代码会放在我的GitHub里。

DAR
SON