某天,当你一不小心发现已经够随心所欲的驾驭3D摄像机之时,任何类型的3D游戏都将成为囊中玩物,过往如烟。
回忆逝去的童年让我极度惦记的SLG策略战棋游戏,或许对于大多数玩家来说,它费时费力不被讨好;然而深邃的内涵和无限可能的战略战术始终占据着我内心很大一片天地。于是,在本系列前5节2D SLG知识原理的基础上,萌发了移植一款基于平面的3D SLG Demo计划。
首先,什么是基于平面的3D SLG游戏?大伙不妨先看看以下几款该类型经典游戏巨作截图 - 《英雄无敌6》、《文明5》和《三国志11》:
无论地形单元格为四边形或六边形,其整体地貌都不存在高低起伏(No HeightMap);用游戏开发者的话说便是:三维空间中,一条轴用做旋转,另外两条轴形成类似2D中的Canvs平面承载对象。这样的设计更像是一盘3D化棋局,地形好比棋盘盘面,角色仿若棋子,附带一个环绕棋盘的360°轨道摄像机,无论视野还是战术方略都能得到淋漓尽致的体现。
当然,除此之外,层次感更分明,基于HeightMap地形的立面3D SLG游戏亦备受日系游戏青睐,不乏大作,比如《火焰纹章 晓之女神》、《皇家骑士团:命运之轮》和《三国志战记2》等,该类型游戏通常需要辅以更加复杂而强大的地形编辑器,这些内容并不属于本节范畴,后续章节中若有时间再做补充:
OK,做足了SLG游戏设计方面的知识准备,接下来我们要做的头等大事便是打开第4节的源码,神马差集运算、四叉树算法、蜂窝拓扑算法、A*算法等等统统一并拿来,将其中的Point改成Vector3(即原先的Point(X,Y)更换成Vector3(X,0,Y)),嘿嘿,原来编码也是可以这么浮云的。举个例吧,其中的DirectionScan方法在移植前后的对比:
2D游戏中所有我们看得到的图形都是通过Image图片的形式予以呈现,而到了3D游戏中,这条路已经行不通了。比如我们要绘制3D四边形或3D蜂窝状地形单元格,此时就得自己编写基于三角面合成的3D面控件:
Shape3D
///
<summary>
///
3D图形(面)基类
///
</summary>
public
abstract
class Shape3D : Object3D {
protected Camera3D camera;
protected Texture2D texture;
protected BasicEffect effect;
protected
short[] indices;
protected VertexPositionTexture[] vertices;
public Shape3D(ContentManager content, GraphicsDevice device, Camera3D camera)
:
base(content, device) {
this.camera = camera;
effect =
new BasicEffect(device) { TextureEnabled =
true };
}
string _TextureName;
///
<summary>
///
获取或设置纹理资产名称
///
</summary>
public
string TextureName {
get {
return _TextureName; }
set {
_TextureName = value;
texture = content.Load<Texture2D>(value);
effect.Texture = texture;
}
}
public
override
void Draw(GameTimerEventArgs e, ModelBatch modelBatch) {
effect.GraphicsDevice.BlendState = BlendState.AlphaBlend;
//
设置透明覆盖(透明显示纹理alpha透明部分)
effect.World = World;
effect.View = camera.View;
effect.Projection = camera.Projection;
effect.CurrentTechnique.Passes[
0].Apply();
device.DrawUserIndexedPrimitives(PrimitiveType.TriangleList, vertices,
0, vertices.Length, indices,
0, indices.Length /
3);
}
}
Rect3D
///
<summary>
///
3D矩形(面)控件
///
</summary>
public
sealed
class Rect3D : Shape3D {
public Rect3D(ContentManager content, GraphicsDevice device, Camera3D camera, Vector2 radius)
:
base(content, device, camera) {
this.Radius = radius;
}
Vector2 _Radius;
///
<summary>
///
获取或设置宽高半径
///
</summary>
public Vector2 Radius {
get {
return _Radius; }
set {
_Radius = value;
indices =
new
short[
6] {
//
2个三角形4个顶点(0,1,2,3)组成1个四边形
0,
2,
1,
0,
3,
2
};
vertices =
new VertexPositionTexture[
4];
vertices[
0].Position =
new Vector3(-value.X,
0, value.Y);
vertices[
0].TextureCoordinate =
new Vector2(
0,
1);
vertices[
1].Position =
new Vector3(value.X,
0, value.Y);
vertices[
1].TextureCoordinate =
new Vector2(
1,
1);
vertices[
2].Position =
new Vector3(value.X,
0, -value.Y);
vertices[
2].TextureCoordinate =
new Vector2(
1,
0);
vertices[
3].Position =
new Vector3(-value.X,
0, -value.Y);
vertices[
3].TextureCoordinate =
new Vector2(
0,
0);
}
}
}
Hex3D
///
<summary>
///
3D六边形(面)控件
///
</summary>
public
sealed
class Hex3D : Shape3D {
public Hex3D(ContentManager content, GraphicsDevice device, Camera3D camera,
int radius)
:
base(content, device , camera) {
this.Radius = radius;
}
int _Radius;
///
<summary>
///
获取或设置半径
///
</summary>
public
int Radius {
get {
return _Radius; }
set {
_Radius = value;
indices =
new
short[
18] {
//
6个三角形18个顶点组成1个四边形
0,
1,
2,
0,
2,
3,
0,
3,
4,
0,
4,
5,
0,
5,
6,
0,
6,
1
};
vertices =
new VertexPositionTexture[
6];
//
注意,纹理一定要边缘匹配,否则会导致残影
float sqrt3 = (
float)Math.Sqrt(
3);
vertices[
0].Position =
new Vector3(-value,
0,
0);
vertices[
0].TextureCoordinate =
new Vector2(
0,
0.5f);
vertices[
1].Position =
new Vector3(-value /
2,
0, -sqrt3 * value /
2);
vertices[
1].TextureCoordinate =
new Vector2(
0.25f, (
2 - sqrt3) /
4);
vertices[
2].Position =
new Vector3(value /
2,
0, -sqrt3 * value /
2);
vertices[
2].TextureCoordinate =
new Vector2(
0.75f, (
2 - sqrt3) /
4);
vertices[
3].Position =
new Vector3(value,
0,
0);
vertices[
3].TextureCoordinate =
new Vector2(
1,
0.5f);
vertices[
4].Position =
new Vector3(value /
2,
0, sqrt3 * value /
2);
vertices[
4].TextureCoordinate =
new Vector2(
0.75f, (
2 + sqrt3) /
4);
vertices[
5].Position =
new Vector3(-value /
2,
0, sqrt3 * value /
2);
vertices[
5].TextureCoordinate =
new Vector2(
0.25f, (
2 + sqrt3) /
4);
}
}
}
其中四边形只需2个三角形即可,而六边形则可由6个完全一样的正三角形组合而成。
接下来再赋予这些单元格以纹理,配上之前移植过来的所有算法,很酷的3D地形即刻呈现在我们面前(额外提醒一下,在Draw时必须设置纹理的BlendState为Alpha 混合(basicEffect.GraphicsDevice.BlendState = BlendState.AlphaBlend;),否则这些纹理的透明部分将会被可恶的黑色所覆盖):
如此漂亮的地砖Tile,也得有能够与之相匹配的3D场景才算协调。话说3D场景与2D场景真是截然不同,3D场景大多基于模型,比如刚从网上下载的一个宫殿场景,还附赠了一个天空盒呢(顺带鄙视下该天空盒,即非半球又非四方,嗯,很有偷懒嫌疑):
将整个模型从3DMAX中导出成.X或.FBX文件后,我们便可在游戏中直接载入,很酷吧,天圆地方,魔兽出没皇宫中:
慢着,你刚才说啥来着?魔兽?
拜托呀,大哥。对埃及神话中那个狗头人身的死神不清楚就算了,如今,《魔兽世界》中如此伟大的“阿努比萨斯”活生生的矗立在你面前,汝等依旧能保持如此之淡定,小弟不胜佩服。
其实,此次Demo制作也印证了一个事实:《魔兽世界》中的模型大多还是以中低品质模型为主,奇迹的诞生并非与模型复杂度成正比。而目前市面上绝大多数的XNA骨骼动画模型素材管道最多仅支持72块骨骼解析,若想展示次世代模型还得找到更加强悍的素材管道才行(或者哪位大神帮忙写个?哈哈):
至此,3D SLG游戏场景全部布置完毕,接下来是操作部分。
2D游戏基于Canvas平面画布,鼠标点击的地方可谓所见即所得; 3D游戏则大为不同,无论它的显示载体是PC的显示屏还是Windows Phone的触摸屏,基于二维平面的点击/触碰操作要完成三维空间的精确拾取,仿佛是件不可能的事。
然而前人的智慧告诉我们,一条射线便可轻松搞定这一切,这就是传说中的
“3D射线拾取法”:
通过屏幕点击位置垂直于屏幕3D空间向内发射射线,利用射线的穿透效果拾取一切3D对象。
其实射线拾取法也可以通俗的理解为碰撞检测,用开发者的话说便是Ray是否与模型的BoundingBox、BoundingSphere、BoundingFrustum或某个Plane等对象存在交点:
在3D世界里,通常为了高性能检测模型之间的碰撞,会用到Box(立方体)、Sphere(球体)或者Frustum(锥体)包裹住模型,包裹物之间的交错关系即视为模型之间的碰撞关系。其实很多2D游戏也效仿了类似的做法来处理各类碰撞检测。
非常幸运的是,Engine Nine除了为我们提供强大的骨骼动画解析外,还拓展了Model里的Intersects方法,用于检测基于模型BoundingSphere的Pick操作,精确度还蛮高的,再配合上一些相关算法,最终便完成了3D SLG游戏中的角色模型拾取和单元格命中操作:
命中角色和拾取单元格处理
///
<summary>
屏幕上按下
</summary>
void inputHandler_Press(
object sender, TouchEventArgs e) {
switch (Global.TouchHandler) {
case TouchHandlers.SelectLeader:
#region 命中角色处理
Ray ray = device.Viewport.GetPickRay((
int)e.Point.Position.X, (
int)e.Point.Position.Y, camera.View, camera.Projection);
scene.RoleList.ForEach(X => {
float? distance = X.IsRayPass(ray);
if (distance.HasValue) { SetLeader(X); }
});
#endregion
break;
case TouchHandlers.MoveLeader:
#region 命中单元格处理
Vector3? target = Get3DPickPosition(e.Point.Position);
bool hitPath =
false;
if (target.HasValue) {
for (
int i =
0; i < scene.PathRangeList.Count; i++) {
Vector3 destination = scene.PathRangeList[i].Coordinate;
if (!hitPath && Terrain.GetCoordinateFromPosition(
new Vector3(target.Value.X + Terrain.TileRadius, (
int)target.Value.Y, target.Value.Z + Terrain.TileRadius)) == destination) {
scene.PathRangeList[i].TextureName =
string.Format(
"
Texture/{0}
", Global.TileDirectionNum == TileDirectionNums.Six ?
"
Hex1
" :
"
Box1
");
leader.MoveTo(destination, terrain.DynamicMatrix);
hitPath =
true;
}
else {
scene.PathRangeList[i].TextureName =
string.Format(
"
Texture/{0}
", Global.TileDirectionNum == TileDirectionNums.Six ?
"
Hex0
" :
"
Box0
");
}
}
tb0.Text =
string.Format(
"
触碰位置 {0} 命中场景位置 ({1},{2},{3})
", e.Point.Position, (
int)target.Value.X, (
int)target.Value.Y, (
int)target.Value.Z);
}
#endregion
break;
}
}
角色移动处理是3D SLG游戏制作的最后环节。设计方面通常有两种方案:第一种是由起点向终点沿寻路路径移动,这种基于A*算法的移动在我之前的教程都讲烂掉了不再赘述;而另外的则是像《英雄无敌3》那样直接做由起点向终点的直线移动(题外话,制作完Demo后才发现,基于六方格的地形真不适合沿路径移动,非常别扭)。后者实现方法也很简单,按照第七节开头所述原理,分割出X和Z分向量速度即可:
角色两种移动模式算法
List<Vector3> movePath =
new List<Vector3>();
float xMoveSpeed, zMoveSpeed;
///
<summary>
///
A*寻路向目的地移动
///
</summary>
public
void MoveTo(Vector3 coordinate,
byte[,] matrix) {
if (movePath.Count ==
0) {
//
movePath.Clear();
PathFinderFast pathFinderFast =
new PathFinderFast(matrix) {
TileDirectionNum = Global.TileDirectionNum,
HeuristicEstimate =
2,
SearchLimit =
200,
};
List<PathFinderNode> path = pathFinderFast.FindPath(
new Point() { X = (
int)Coordinate.X, Y = (
int)Coordinate.Z },
new Point() { X = (
int)coordinate.X, Y = (
int)coordinate.Z }
);
if (path ==
null || path.Count <
1) {
//
路径不存在
return;
}
else {
switch (Global.MoveMode) {
case MoveModes.Line:
movePath.Add(
new Vector3((
float)path[
0].X,
0, (
float)path[
0].Y));
Vector3 v = Terrain.GetPositionFromCoordinate(movePath[
0]);
float distance = (
float)Math.Sqrt(Math.Pow((v.X - Position.X),
2) + Math.Pow((v.Z - Position.Z),
2));
float countMove = distance / MoveSpeed;
xMoveSpeed = (Math.Abs(v.X - Position.X) / countMove) * (v.X < Position.X ? -
1 :
1);
zMoveSpeed = Math.Abs(v.Z - Position.Z) / countMove * (v.Z < Position.Z ? -
1 :
1);
RotationY = MathHelper.ToRadians(
90 - MathHelper.ToDegrees((
float)Math.Atan2(v.Z - Position.Z, v.X - Position.X)));
//
纠正角度
break;
case MoveModes.Path:
for (
int i = path.Count -
1; i >=
0; i--) {
movePath.Add(
new Vector3((
float)path[i].X,
0, (
float)path[i].Y));
}
break;
}
}
Run();
}
}
嘿嘿,收工。
啥?
人太少不给力?
那么我们刷300个《魔兽世界》里的小怪出来开心开心吧,顺便也检测下本节的各种3D算法是否正确:
本节Demo源码下载地址:(WP)SLXnaGame3
Silverlight版本下载地址:(SL)SLXnaGame3
在线演示地址:Cangod.com
手记小结:《魔兽世界》运行于Windows Phone 和 Silverlight之上,想想都让人口水直流;因为我们对游戏的执着与狂热,使得这个梦想变得不再遥不可及。 3D游戏开发今非昔比,日新月异的技术进步让它变得并非难如炼狱;长期的2D游戏积累和虔诚的设计感悟,从2D向3D转型一日千里。磨练过的勇士将创新出更多属于中国自己的游戏奇迹,你手中的键盘鼠标便是最锋利的战具!
Silverlight三国类万人国战页游 - 《国策》《战龙在野》全面开启,诚邀大家参与体验~
参考推荐:Nowpaper和Williams关于Windows Phone的游戏开发博客。