如何在游戏里当好一个反派——用Unity简单复刻《勇者别嚣张》(下)

作者:沈琰
本篇难度:★★☆☆☆
前言
接着上期开始下一阶段的内容。
上期传送门:如何在游戏里当好一个反派——用Unity简单复刻《勇者别嚣张》(上)
地图搭建完毕后就应该是游戏的核心玩法:魔物和勇者的行为逻辑实现了。
(上期评论说美少女别嚣张的,你们赔我的童年记忆啊,我现在已经没办法直视这个游戏了......)

功能分析
魔物行为逻辑
这个看似休闲的小游戏某种意义上来说相当硬核,怎么说?
原版游戏里的魔物生态规则看似简单,实则复杂而严谨,小时候因为没有汉化,玩得糊里糊涂,基本是很难撑到5关以后,到后来看了攻略才明白。
简单来说魔物之间除了简单的移动攻击行动外还存在着一条生态链,高级魔物吃低级魔物除了补充生命值外还能促进繁殖,魔物的生命值即使没有遭受攻击也会随着时间慢慢减少。
生成魔物的种类又与砖块吸收的养分和魔份有关系,处于魔物生态链底端的史莱姆和鬼火负责养分和魔份的供给。所以想要能抵御越来越强的勇者进攻,较为稳定的生态链是一个金字塔形状:

至于其他更复杂的养殖催生魔物进化等等就不展开说了,都快变成游戏攻略了。
总之就是游戏的魔物行为逻辑没有看起来那么简单,加上缺少动画素材,所以复刻时只选择了一些重点功能(主要是因为懒)。归纳一下就是:
1.移动
2.养分运送
3.攻击
4.食物链(大怪物吃小怪物)
5.随时间减少的生命力
勇者行为逻辑
相较来说勇者的行为逻辑就简单多了,就是找魔王->干掉路上遇到的魔物->找到魔王->捆回家这么个过程。关键就在这个找魔王上,有经验的同学大概能想到这里可能会用到寻路算法,但具体用哪一种以及怎么用?
基于观察原版游戏勇者的寻找魔王得出的规律:
1.勇者会走到岔路里。
2.如果有多个勇者,在分岔路口会主动分路寻找。
3.如果进入死路,会沿着原路返回到上一个拥有其他未探寻岔路的节点继续寻找。
其实到这结论已经呼之欲出了,这种一条道走到黑的寻路方法与之最接近的是深度优先搜索算法(Depth-First-Search),接下来所要做的就是把寻路的过程通过勇者的行动显示在游戏界面中。
角色移动逻辑
先写移动逻辑是因为这是魔物与勇者通用的方法,在上期文章实现鼠标点击逻辑时卖了一个关子:为什么选择了一个坐标系转换自定义二维数组的方法?因为移动逻辑同样也以此作为基础。

因为游戏里场景内的物体都是遵循TileMap里网格大小的正方形,所以把世界坐标转换为我们自定义的二维数组坐标后,移动相关的逻辑就变成一个类似控制台小游戏的移动逻辑了。
假设现在要从游戏地图里的蓝点沿着最短曼哈顿距离移动到红点,如果没有这个自定义的二维坐标,计算起来会非常麻烦。而现在就简单了,设蓝点为(0,0),右和上分别为X,Y的正方向,那么移动到红点的路径则可以表示为:(0,0)->(0,-1)->(0,-2)->(0,-3)->(1,-3)->(2,-3),再用之前写好的转换函数得到场景内的实际坐标,两点之间用差值计算移动过程。

实现代码:
protected IEnumerator Move(Vector2 dir)
{
Vector2 correctionPos = Pos.Pos2Vector2(Pos.Float2IntPos(transform.position));
Vector2 endPos = correctionPos + (dir * 0.18f);
if (!isMoving)
yield return SmoothMovement(endPos);
}
protected IEnumerator MoveTo(Vector2 pos)
{
if (!isMoving)
yield return SmoothMovement(pos);
}
IEnumerator SmoothMovement(Vector2 endPos)
{
isMoving = true;
float Distance = Vector2.Distance(new Vector2(transform.position.x, transform.position.y), endPos);
while (Distance > float.Epsilon)
{
Vector2 newPos = Vector2.MoveTowards(new Vector2(transform.position.x, transform.position.y), endPos, MoveSpeed * Time.deltaTime);
transform.position = newPos;
Distance = Vector2.Distance(new Vector2(transform.position.x, transform.position.y), endPos);
}
isMoving = false;
yield return null;
}
以上是纯移动逻辑,在之后加上动画状态机运行效果如下:

魔物状态机
在上面的分析中已经把魔物所需要的的功能都罗列出来了,其中移动已经实现了。现在要做的就是把剩下的功能实现并用状态机组装成魔物的AI。
状态机大家应该不陌生了,不熟悉的同学可以先看看专栏里关于AI状态机的教程。
首先把魔物的动画状态机做出来:

很简单的四方向动画,在待机->移动->攻击之间切换,然后思考一下魔物状态机的流程:

大概流程就是这样,这一步大家自己实现时不必完全一样。
表现到代码里其实很简单,首先是单方向的障碍检测函数,根据layer返回检测结果:
Vector2 Direction2Vector2(Direction dir)
{
Vector2 direction = Vector2.zero;
switch (dir)
{
case Direction.Up:
direction = Vector2.up;
break;
case Direction.Down:
direction = Vector2.down;
break;
case Direction.Left:
direction = Vector2.left;
break;
case Direction.Right:
direction = Vector2.right;
break;
}
return direction;
}
protected bool ObstacleCheck(Direction dir, LayerMask layer)
{
RaycastHit2D[] hit;
hit = Physics2D.RaycastAll(transform.position, Direction2Vector2(dir), 0.18f, layer);
return hit.Length > 0;
}
然后是各个行为函数:
protected IEnumerator CheckAndAttackEnemyAround()
{
Collider2D[] arounds = Physics2D.OverlapCircleAll(transform.position, 0.09f, Enemylayer);
if(arounds.Length>0)
{
targetDir = GetDirection(transform.position, arounds[0].transform.position);
yield return Attack(targetDir);
}
}
protected IEnumerator Attack(Direction dir)
{
RaycastHit2D[] hit;
while (ObstacleCheck(dir, Enemylayer, out hit))
{
_animator.SetBool("MoveState", false);
_animator.SetInteger("Dir", (int)dir);
yield return null;
_animator.SetTrigger("Attack");
for (int i = 0; i < hit.Length; i++)
{
Character c = hit[i].transform.GetComponent<Character>();
if (c)
{
c.OnBeHit(damage);
}
}
yield return AttackInterval;
}
}
IEnumerator Idle()
{
float time = idleTime;
while(time>float.Epsilon)
{
time -= Time.deltaTime;
CheckAndAttackEnemyAround();
yield return null;
}
}
protected virtual IEnumerator TransportNutrients()
{
RaycastHit2D[] hit;
if (ObstacleCheck(CurDir, WallLayer, out hit))
{
_animator.SetTrigger("Attack");
Block script = hit[0].transform.GetComponent<Block>();
if (script)
{
script.ChangeNutrient(nutrient);
}
}
yield return IdleTime;
}
然后把流程逻辑写在一整个协程函数中:
protected virtual IEnumerator Action()
{
while (true)
{
RandomDir();
if (Random.Range(0, 4) > 2)
{
yield return Idle();
}
else
{
yield return null;
if (ObstacleCheck(CurDir, TempLayer))
{
yield return Behaviour();
continue;
}
yield return Move(CurDir);
}
}
}
应该有人注意到了所有行为逻辑的代码都是协程,这里为什么要用到协程?
因为不管是移动过程也好,动画播放也好都要涉及到时间,也就是每个单独的逻辑函数可能执行时间都不一样,全部用协程实现会让状态转移的时候更加方便。
所以整个AI本质上是一个协程状态机,然后把魔物的预制体填入砖块生成中,运行试一试效果:

勇者寻路逻辑
与魔物不同,勇者的AI其实就是一个寻路的过程,在移动过程中如果遇到了魔物就会暂时中断并与魔物展开战斗,所以攻击的行为是写在移动过程里的。剩下要做的事就是把这个寻找的过程表现出来。
之前分析的时候说最接近这个表现形式的寻路算法是DFS,为什么这么说呢?把每个格子看做一个节点,当前格子能去往下一个格子的路线看做分支,那么整个地图可以看做是一张无向图:

再看看《算法导论》中关于DFS的说明:
深度优先搜索算法所使用的策略就像其名字所隐含的:只要可能,就在图中尽量“深入”。深度优先搜索总是对最近才发现的结点v的出发边进行探索,直到该结点的所有出发边都被发现为止。
一旦节点v的所有出发边都被发现,搜索则"回溯"到v的前驱结点(v是经过该节点才被发现的),来搜索该前驱结点的出发边。该过程一直持续到从源节点可以达到的所有结点都被发现为止。
如果还存在尚未发现的结点,深度优先搜索将从这些未被发现的结点中任选一个作为新的源节点,并重复同样的搜索过程。该算法重复整个过程,直到图中的所有结点都被发现为止。
简单点来说就是在每个结点探寻到新的路,都以那条最后找到的路为最优先级走到黑,一旦无路可走再返回到之前还有未探寻路径的结点继续走到黑,这恰好与分析中原版游戏中的勇者寻路规律一模一样。
把游戏里的地图转换成结点图来看如下:

假如现在起点是A,终点是B,在结点1的位置进入向下的岔路,则会一直走到C结点。然后原路返回至1,再探寻其他的路直到找到B为止。
把寻路算法用步骤用代码表示出来如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Hero : Character
{
Dictionary<Pos, bool> map;
Pos target;
bool IsFindTarget;
IEnumerator HeroSearchRoad(Pos next)
{
//把当前世界坐标转换为二维数组坐标
Pos cur = Pos.Float2IntPos(transform.position);
//如果找到目标,跳出递归
if (next.Equals(target))
{
IsFindTarget = true;
yield return new WaitForSeconds(2f);
yield break;
}
//移动到下一个目标点
yield return MoveTo(Pos.Pos2Vector2(next));
//字典中标记下一个坐标点为已探寻,防止回旋绕路
map[next] = false;
//获取当前位置能移动到的其他节点坐标
List<Pos> curNode = GetCurrentNode();
//根据与目标距离对节点目标进行排序(可选)
if (curNode.Count > 1)
{
curNode.Sort((a, b) => Pos.GetManhattanDistance(a, target).CompareTo(Pos.GetManhattanDistance(b, target)));
}
//沿着所有开辟出的新节点寻路
for (int i = 0; i < curNode.Count; i++)
{
//找到目标就不继续其他节点的探索了
if (IsFindTarget)
{
break;
}
yield return HeroSearchRoad(curNode[i]);
}
//所有能走的节点都走完了,只能回头
//回溯
yield return MoveTo(Pos.Pos2Vector2(cur));
}
List<Pos> GetCurrentNode()
{
List<Pos> list = new List<Pos>();
List<Pos> resut = new List<Pos>();
Pos cur = Pos.Float2IntPos(transform.position);
if (!ObstacleCheck(Direction.Up, WallLayer))
{
list.Add(new Pos(cur.x, cur.y + 1));
}
if (!ObstacleCheck(Direction.Down, WallLayer))
{
list.Add(new Pos(cur.x, cur.y - 1));
}
if (!ObstacleCheck(Direction.Right, WallLayer))
{
list.Add(new Pos(cur.x + 1, cur.y));
}
if (!ObstacleCheck(Direction.Left, WallLayer))
{
list.Add(new Pos(cur.x - 1, cur.y));
}
for (int i = 0; i < list.Count; i++)
{
if (!map.ContainsKey(list[i]))
{
resut.Add(list[i]);
map.Add(list[i], true);
}
else
{
if (map[list[i]] == true)
{
resut.Add(list[i]);
}
}
}
return resut;
}
}
代码里除了原本的逻辑,额外多加了一步:在每次探寻下一个结点时优先选择相对目标点最近的那一个,所以最后勇者的寻路算法就变成了有距离指导的DFS。
弄个稍微复杂点的地图,运行的效果如下:

关于寻路算法更详细的教程可以参考专栏里的另一篇文章。
给猫看的游戏AI实战(四)眼见为实——让AI的思考过程可视化
结尾
至此游戏虽不完整,但主体逻辑已经还原的七七八八了。限于篇幅,还有一些参数调整和提升表现力的工作就由大家自己去尝试,或者更改逻辑,定制出专属自己的规则。


通过这两期的简单复刻,可以发现这样简单的小游戏其实蕴含了相当多的细节在里面,虽然整体算不上太难,但积累起来就很可观了。
我们只是把最基本的核心逻辑还原就费了不少功夫,更别说原版游戏还有更多游戏性上的细节工作。现在的游戏画面日趋精良,但像这样设计师的心思都花在玩法上的游戏反而少了。
扯得有些远了,但愿能有更多像这样能带给玩家最本质的乐趣的游戏吧,感谢观看至此。
本期工程地址:https://github.com/tank1018702/unity-005
最后想系统学习游戏开发的童鞋,欢迎访问 http://levelpp.com/
游戏开发搅基QQ群:869551769
微信公众号:皮皮关