【更新】我的新博客:www.ryuzhihao.cc,当然这个csdn博客也会更新
本文在新博客中的链接:点击打开链接
时间:2017年7月28日-7月30日
耗时:2天
此前一直在玩OpenGL,做的都是三维世界的东西,已经好久没有写过2D类的小游戏了。上学期,玩了《charles》之后,就一直想要写一款类似的射击类小游戏,只是前段时间有很多跟图形学有关的程序还要填坑,而且还要忙着参加夏令营。到现在终于闲了下来,就开始着手做一下这款小游戏《Hori Miona》吧。(在家一周也没见体重长回去多少)
这篇博文,可能更多的涉及到“游戏开发架构”。这俩天对于我自己来讲,也算是是摸索出了比较不错的架构。不管是3D还是2D游戏,render和modeling虽然不同,但是架构应该是个可以通用的模式。
在正文开始之前,还是惯例上一下我的实现结果。
射击游戏《Hori Miona》
一、下载链接:
http://download.csdn.net/detail/mahabharata_/9916762
二、游戏截图:
本游戏的部分设计细节
一、类图
程序的可视化界面为GameWidget类,并进行游戏循环,利用计时器Timer更新游戏状态。玩家Player类,用于记录玩家的状态信息;同时,玩家的所有操作由状态控制,每次游戏循环中,根据玩家当前所处的行为状态(攻击、移动?),更新玩家的信息。在Player类中,还有一个攻击管理器AttackManager,用于对玩家的攻击进行处理,包括:攻击的渲染、攻击模式的转化、攻击的碰撞检测等等。此外,在GameWidget中,还有一个成员EnemyManager,用于对游戏中的AI单位进行控制,根据玩家的当前位置,更新敌人的状态。
如下是简化的类图(在进行大致设计时采用):
二、基于四叉树的场景管理。
在游戏中的敌人的数目,这里采取了基于四叉树的场景管理的方法。虽然前面的类图在设计阶段是list<Enemy>来代替的。但是为了提高检索效率还是必须要引入四叉树进行场景对象的管理。
四叉树并不仅仅针对二维的游戏场景上,这里所指的二维是因为四叉树本身仅仅考虑了两个维度。在三维的游戏场景中,我们也可以使用四叉树,因为在四叉树应用最广泛的室外地形场景中,z轴往往只代表地形高度信息,或者说它的信息相比起xy轴而言,并没有特别重要。这是我们在场景管理中可以适当忽略的部分。
四叉树有很多变种,先谈一个简单的情况,就是假设所有物体是一个点,这样比较容易理解。在后面可以利用AABB包围盒进行碰撞检测。
把每个点放到正方形空间里,若该正方形内含有超过1个点(2个或2个以上的点),就把该正方形分割,直至每个小正方形(叶节点)仅含有一个点,就可以得出以下的分割结果:
这个做法是可以调整的,也就是说当空间分割到一定大小就不能继续分割(如最多只能对一个结点分割4层,因为层数太多也会影响检索效率)。关于四叉树节点的数据结构可按如下进行定义:
struct treeNode {
bool isLeaf; //是否是叶节点
// 四个子树
treeNode* upLeft;
treeNode* upRight;
treeNode* downLeft;
treeNode* downRight;
int objectNum; //包含对象个数
List<Object> objects; //包含的对象
AABB box; //所在的AABB包围盒
}
了解了最基本四叉树后,可把问题从质点扩充到占有一定体积的物体。虽然我们可以每次比较场景物体和正方形的相交,但是为了提高性能,一般使用物体的包围盒进行碰撞检测:
这里推荐我的另外一篇文章《对Obj模型的AABB包围盒加载器》:http://blog.csdn.net/mahabharata_/article/details/72593925
三、状态图在游戏中的应用:
相比起一般的软件开发,游戏开发对状态图的依赖应该是比较强的了。因为游戏中通常会涉及很多复杂的状态转换过程。比如:实现飞机移动的状态转换。这里也简单拿这个例子来进行一下说明:在程序中,飞机的非战斗状态主要包括以下几种行为(用枚举类型表示):
class Player
{
private:
// 飞机运动状态
enum STATE
{
_STOP, // 停止
_RUN, // 直行
_LEFT, // 静止左转
_RIGHT, // 静止右转
_RUN_LEFT, // 运动左转
_RUN_RIGHT // 运动右转
};
short m_curState; // 飞机当前运行状态
public:
void setCurrentState(short state); // 设置飞机的当前状态
short getCurrentState(); // 获取飞机的当前状态
void updateStates(); // 更新
void render(QPainter* painter);
};
这是玩家类Player的简化代码,预设了6种非战斗的状态。为了能理顺完整的状态转换,因此设计了如下的状态图(为了方便绘制,没有体现左转的情况,但是可以对称得到):
在有了上面的状态图的情况下,便可以非常轻松地写出相应的状态转换代码:
void GameWidget::keyPressEvent(QKeyEvent *event)
{
if(event->isAutoRepeat())
return;
switch(event->key())
{
case Qt::Key_W: // 前进
if(m_player.getCurrentState()==Player::_STOP)
m_player.setCurrentState(Player::_RUN);
if(m_player.getCurrentState()==Player::_LEFT)
m_player.setCurrentState(Player::_RUN_LEFT);
if(m_player.getCurrentState()==Player::_RIGHT)
m_player.setCurrentState(Player::_RUN_RIGHT);
break;
case Qt::Key_A: // 左
bA = true;
if(m_player.getCurrentState()==Player::_STOP)
m_player.setCurrentState(Player::_LEFT);
if(m_player.getCurrentState()==Player::_RIGHT)
m_player.setCurrentState(Player::_LEFT);
if(m_player.getCurrentState()==Player::_RUN || m_player.getCurrentState()==Player::_RUN_RIGHT)
m_player.setCurrentState(Player::_RUN_LEFT);
break;
case Qt::Key_D: // 右
bD = true;
if(m_player.getCurrentState()==Player::_STOP)
m_player.setCurrentState(Player::_RIGHT);
if(m_player.getCurrentState()==Player::_LEFT)
m_player.setCurrentState(Player::_RIGHT);
if(m_player.getCurrentState()==Player::_RUN || m_player.getCurrentState()==Player::_RUN_LEFT)
m_player.setCurrentState(Player::_RUN_RIGHT);
break;
case Qt::Key_J: //攻击
m_attackManager.setAttacked(true);
break;
case Qt::Key_Space: // 切换攻击模式
m_attackManager.changeAttackMode();
ui->lblAttackMode->setText(m_attackManager.getAttackMode());
break;
case Qt::Key_R:
if(m_timer.isActive())
m_timer.stop();
else
m_timer.start();
break;
case Qt::Key_Escape:
m_timer.stop();
emit sig_closeGameWidget();
break;
}
}
void GameWidget::keyReleaseEvent(QKeyEvent *event)
{
if(event->isAutoRepeat())
return;
switch(event->key())
{
case Qt::Key_W:
if(m_player.getCurrentState()==Player::_RUN)
m_player.setCurrentState(Player::_STOP);
if(m_player.getCurrentState()==Player::_RUN_LEFT)
m_player.setCurrentState(Player::_LEFT);
if(m_player.getCurrentState()==Player::_RUN_RIGHT)
m_player.setCurrentState(Player::_RIGHT);
break;
case Qt::Key_A:
bA = false;
if(m_player.getCurrentState() == Player::_RUN_LEFT && !bD)
m_player.setCurrentState(Player::_RUN);
if(m_player.getCurrentState() == Player::_LEFT && !bD)
m_player.setCurrentState(Player::_STOP);
if(m_player.getCurrentState() == Player::_RUN_LEFT && bD)
m_player.setCurrentState(Player::_RUN_RIGHT);
if(m_player.getCurrentState() == Player::_LEFT && bD)
m_player.setCurrentState(Player::_RIGHT);
break;
case Qt::Key_D:
bD = false;
if(m_player.getCurrentState() == Player::_RUN_RIGHT && !bA)
m_player.setCurrentState(Player::_RUN);
if(m_player.getCurrentState() == Player::_RIGHT && !bA)
m_player.setCurrentState(Player::_STOP);
if(m_player.getCurrentState() == Player::_RUN_RIGHT && bA)
m_player.setCurrentState(Player::_RUN_LEFT);
if(m_player.getCurrentState() == Player::_RIGHT && bA)
m_player.setCurrentState(Player::_LEFT);
break;
case Qt::Key_J:
m_attackManager.setAttacked(false);
break;
}
}
四、火焰碰撞的矩阵计算:
在2D游戏的开发中,其实会发现和3D游戏有很多相似之处。用到的主要数学方法有:
变换矩阵、 插值、图像遮罩等等。
这里介绍其中一个处理方法:火焰跟随飞机旋转的效果——借助变换矩阵去实现。
这个和OpenGL中三维变换矩阵的计算类似,只是相应的转换成了2维,但是为了方便计算,我们还是要使用齐次坐标。火焰虽然在视觉上看到的是不规则且不断变化的图形,但是在游戏中,我是用的是一个凸多边形作为一个检测区域,如下图:
已知玩家的坐标pos(Vec3),和倾斜角度dir(float)。那么我们可以首先确定一个原始的菱形(顶点坐标pi已知,为齐次坐标x,y,w),再计算相应的三维变换矩阵matrix(3*3),将顶点坐标pi乘以三维变换矩阵matrix便可求出火焰的范围。
计算的数学过程如下:
(1) 已知火焰顶点pi(x,y,w),和变换角度dir,
(2) 计算pi到火焰中心位置的平移矩阵mat1;
计算绕火焰中心位置旋转dir角度的旋转矩阵mat2;
计算从火焰中心到原位置pi的平移助阵Mat3;
(3) 得到三维变换矩阵matrix = mat3* mat2*mat1;
(4) 由点pi* matrix即可得出变换后的坐标pi'。
可以用如下代码得到:
// 将 pos从玩家中心移动到火焰中心
pos.setX(pos.x()+sin(3.14*dir/180.0)*90);
pos.setY(pos.y()-cos(3.14*dir/180.0)*90);
QPolygonF polygon; // 未变换的多边形
polygon<<QPointF(pos.x(),pos.y()-50)
<<QPointF(pos.x()-35,pos.y()+35)
<<QPointF(pos.x(),pos.y()+75)
<<QPointF(pos.x()+35,pos.y()+35);
QMatrix matrix; // 计算旋转矩阵matrix
matrix.translate(pos.x(),pos.y()); // 平移到火焰中心
matrix.rotate(dir); // 旋转
matrix.translate(-pos.x(),-pos.y()); // 平移回原位置
polygon = polygon*matrix; // 多边形的坐标
结语:
整体来说,这个项目的代码量要在2天时间内完成,还算是比较多的。想要在博客里面完全阐述所有的设计细节,真的是不太可能,但是欢迎交流。本程序是个娱乐工作,所以代码在整理完成后,会抽时间全部上传。