之前写过一个用C/C++做贪吃蛇的blog,很多同学都对此颇有兴趣,而我知道他们感兴趣的原因是,他们遇到了好多问题也问了我好多问题。为什么他们会遇到这么多麻烦?问题出在哪呢?这些凑成了写这篇blog的初衷。
“给出的代码看不懂!”,这是问我最多的问题,没错,能渴求谁在开始学着写自己游戏的时候就会用DirectX的代码?哎!我只学了一些基本语法,会自己定义’贪吃蛇‘这种类型,我猜这种类型会有’生长身体’,‘拐弯’等等的函数,其它什么COM、API的我一概不知,但是我想写自己的游戏,想把我偶然的一个游戏点子付诸实践,难到成不了编程大师之前便写不出游戏了吗(才怪)?
来吧,下面让我们试着打破这个悖论。
为了摆脱DOS(也叫控制台),我们需要一个窗口,那种Windows窗口,那需要写Win32的代码吗?不能,这样违反了第地二条,因为我没怎么接触过Windows程序设计(也叫VC++)。
C/C++是出了名的‘小气’,标准库的头文件屈指可数,要不用python吧?pygame似乎可以让我很快写出一个像样的游戏;用C#也不错?Unity的脚本语言就是它,拖拖点点,一个像样的游戏就出来了。不能,这不仅违反第二条,甚至不满足第三条。
Unreal(也叫虚幻引擎)用了C++,cocos2d-x也支持C++(更棒的是支持Lua),OGRE也是用C++写的吧,为什么不用呢?不行,这些库学习成本偏高,需且配置过程可能就违反了第三条。
怎么办?有一点现实不得不接受,我们的确需要引入一个库(Third party library),因为我们无法靠纯语言去做出一个像样点的游戏,因为语言本身只是一些语法规范的定义,就算调用个printf也是用了标准库(Standard library)中的一个功能(函数),只不过它的官方制定并认可的,而非第三方。
前面,我们根据自己列出的技能和条件,排除了很多库或引擎,DirectX和OpenGL(更偏底层的一些多媒体接口)就更不合适了,那就找一个轻量,新手友好,能快速上手而且还能做出像样游戏的库吧,等会开发游戏原型时我们使用Easy2D游戏库,选择它的原因是,它是符合【2。】中所有条件的库之一:
对于编程而言,初学者最需要的不是技能,而是成就感。
如果你喜欢用 C/C++ 编写自己的小游戏,那么 Easy2D 将是个不错的选择,它大大简化了游戏制作过程,可以帮助你快速开发 Windows 上的 2D 小游戏。
它的特点和它的名字一样,Everything is Easy!
正如文章题目所要表达的一样,这篇blog要传达的重点不是贪吃蛇怎样开发(当然等会会做一个它的原型),更多的是从想法,怎样结合自己的条件,最终用计算机做出能运行自己想法的程序。来道练习:)
练习:我学了C/C++,怎样才能做出一个GUI(图形界面接口)软件,比如画图(也叫MS-paint)?
这个概念是游戏库(easy2d)需要考虑的实现技术。简单说,游戏里的每个东西(如:场景、蛇(头、身节)、食物、墙)都是以节点(Node)这种数据结构存在的。这里面有一点容易被忽略,场景也是一个节点,它对应你当前在窗口中看到的所有东西,就像电影里的一幕,所以也称之为场景。
从数据结构的角度来解构最终完成的游戏,就像下面这样:
游戏中你所看到的每一个玩家、道具、UI…都能在上面这棵树中找到对应的节点。对应到贪吃蛇这个游戏原型大概是这样:
我们通过编码完成一棵这种渲染树之后,游戏库中会有一个专门的渲染器,可以简单认为是一块专门管绘制的代码,它会遍历这棵树的每个结点并将它们一一绘制到屏幕上。这也告诉了我们另一个重要的事情,如果运行的时候在屏幕上没有找到自己的精灵,首先就要检查我有没有将自己的精灵加入到这棵树中?
上面提到了场景,我们也接触过堆栈,这两者结合起来用可以形成一个方便的数据结构–场景栈。
游戏库中,一般都会维护一个指向场景栈栈顶的指针,并且,哪个场景在栈顶,当前屏幕上就显示哪个场景。考虑一下,如果我们首先来到游戏的开始界面(开始场景),紧接着,我们点击开始游戏,会切换到下一界面(场景),比如说第一个关卡,这个时候我们需要将对应第一关的场景(由我们编码构造而成)压栈,如果我们在中途要返回到游戏界面,这时我们只需要执行一次出栈操作。
所以要编写游戏中可能会有下面类似的代码(伪码):
scene_manager::push(start_scene);
if (start_btn.compressed())
{
scene_manager::push(level_1_scene);
...
if (0 == player.get_health())
{
scene_manager::clear();
scene_manager::push(game_over_scene);
}
}
试着问自己一个问题,屏幕上各种精灵是怎动起来的?
大家估计也看过那种演示,用好多页纸,相邻纸页上画的东西只做一点点微小的改变,然后快速动,动画就出现了。游戏中的动画也不例外,这里的纸张变成了显存,纸上的铅笔画变成了显存中每比特(bit)的数据,而这些数据从哪里来呢,这就是我们要间接完成的工作。
所以典型的游戏框架都要有一个无穷循环,每进入一次循环,就渲染出了屏幕上的一帧。当然了,这样每次进入循环的时间是随机的,这受网速、机器当前性能等各种因素的影响,这会造成渲染每帧的间隔不一样,也就直接导致动画的不平滑。所以一般会引入定时器将渲染频率控制在帧/秒(也叫fps ~= 60)。fps为60表示1秒钟会刷新60次屏幕,如果你在每次循环中给精灵的横坐标增加1,那它就产生每秒在屏幕上移动60个像素的效果。
练习:游戏和电影的区别是什么?(因为需要交互,所以前者是实时渲染)
由于本次我们使用easy2d,所以看不到无穷循环的实际代码,这一块逻辑对我们用这个库的人来说是透明的。
简单看一下这个原型,游戏原型的开发在于对一些游戏点子和机制的验证,着眼于逻辑,所以我们用自己画的一些简单几何形状来代替精灵。游戏中的精灵有蛇(头、身)、墙块(绿色、红色)和一个HUD来展示一些当前游戏运行的状态信息。
考虑一个问题,如果现在要给蛇加一段身体,这段身体的位置应该怎确定?如果只是得到当前的最后一段身体的位置(x_tail, y_tail),再假设我们用的每个身体段的大小为22 x 22像素,我们还是不能确定新加入身体段的位置坐标(比如应该是(x_tail+22, y_tail),还是(x_tail, y_tail+22)),比如:
很明显,在加入一个新的身体段时需要把尾巴的运动方向考虑进去。根据两点可以确定一条直线的定理,这里我们也可以用末尾两段身体来确定出尾巴运动的方向,末尾的两段身体有如下四种相对位置关系:
根据四个不同的横纵坐标关系式,可以确定尾巴的运动方向,接下来只要在尾巴运动的反方向追加新的身体段就可以完成贪吃蛇身体的生长。等等,你也许会想到一种特殊情况,在只有一个蛇头时候,并没有两个点可以供我们确定方向,其实这种情况下也没必要,因为蛇头运动的方向也就是整个蛇的运动方向,这个时候只需要在蛇(头)运动的反方向追加新的身体段就Ok了。实际游戏原型中可以看到类似下面的代码:
/
void GreedySnake::private_grown_snake_only_head(const Point &np, Sprite *ns)
{
if (ADUp == ad)
{
ns->setPos(np.x, np.y + 22);
}
else if (ADDown == ad)
{
ns->setPos(np.x, np.y - 22);
}
...
}
/
void GreedySnake::private_grown_snake_normal(const Point &np,
const Point &nprev, Sprite *ns)
{
if ((np.x == nprev.x) && ((nprev.y - np.y) < 0))
{
ns->setPos(np.x, np.y + 22);
}
else if ((np.x == nprev.x) && ((nprev.y - np.y) > 0))
{
ns->setPos(np.x, np.y - 22);
}
...
}
/
void GreedySnake::gs_grown_snake()
{
...
if (bodies.size() > 1)
{
...
private_grown_snake_normal(np, nprev, ns);
}
else // 只有一个蛇头的情况
{
private_grown_snake_only_head(np, ns);
}
...
}
实现蛇的移动有一个直接的想法:逐个将前一段蛇身的位置覆盖掉后一个蛇身的位置。没错,在原型中我们也是采取了这种算法,但是也有一种特殊情况需要处理,如果把整条蛇看成一个链表的话,蛇头是没有前继节点的,那么它的位置就需要根据当前蛇的运动方向计算而来。
所以在游戏原型中会有类似下面的代码:
...
// 更新每段蛇身(除了蛇头)的位置
for (int i = bodies.size() - 1; i > 0; --i)
{
bodies[i]->setPos(bodies[i - 1]->getPos());
}
// 更新蛇头的位置
(0 == x_or_y) ?
head->movePosX(distance) : head->movePosY(distance);
...
使用随机数工具类可以很方便的产生食物位位置,这些工具类你所采用的游戏库一般都会附带。当然这里需要考虑与其它精灵的位置重叠的情况,需要取它们的差集。另外食物类(Food Class)如果设计得当的话可以适应非常灵活的游戏机制,比如,不同食物有不同的分数、效果和显示方式等。
碰撞检测是游戏开发中必不可少的技术。此次原型设计中,有两处用到了碰撞检测:蛇与食物的碰撞(吃与被吃)和蛇与墙体的碰撞。这里采用了比较简单的碰撞手段----使用碰撞盒,也就是一个包裹精灵的矩形区域,在每次循环里进行矩形的交集测试,如果有交集则认定为两个矩形所包裹的精灵发生了碰撞。
所以在实现中会有类似的代码,其中intersets是Rect(由easy2d提供的一种矩形数据结构)的一个成员函数,用来判断当前矩形与目的矩形是否有交集。
bool fe_check_collision()
{
Rect box_curr_food = m_curr_food->getBoundingBox();
Rect box_snake_head = m_head->getBoundingBox();
return box_curr_food.intersects(box_snake_head);
}
蛇与墙的碰撞与上述类似,所以当游戏运行的时候相当于在每个会检测碰撞的精灵周围都有一个包围盒。
每种信息更新时,HUD类都会收到通知,每次循环时,HUD类的相应函数只需要将信息参数取出并格式化到屏幕上就Ok了。
可以去easy2d的官网或者访问下面的链接来下载easy2d游戏库:https://pan.baidu.com/s/1L9GLOc3Ew0sZQOIhM4XGUw 提取码:3bhl 。
环境:
visual studio 2013(更新版本的应该都可以)
win10
easy2d(上面网盘里的版本)
当前时间:2020-12-20
右键‘解决方案’->属性,看到如下界面:
编辑“包含目录”和“库目录”两个项目,修改成你解压easy2d文件夹的路径。比如,我easy2d解压后的文件布局是这样的:
那么这两个地方就分别填:
#include
#include
#include
#pragma comment(lib, "libeasy2d.lib")
using namespace std;
using namespace easy2d;
int main()
{
if (Game::init()) // 初始化游戏
{
auto scene = gcnew Scene; // 创建一个场景
SceneManager::enter(scene); // 进入该场景
auto text = gcnew Text(L"Hello Easy2D!"); // 创建一个文本
text->setAnchor(0.5, 0.5); // 设置文本中心点
text->setPos(Window::getSize() / 2); // 文本位置居中
scene->addChild(text); // 将这个文本添加到场景中
Game::start(); // 开始游戏
}
Game::destroy();
}
7.最后别忘了还可以查看官方文档。
8.遇到问题也可以私信我,杏感学长,在线解答。