用Unity去释放自己的恶意——我们来实现一下《掘地求升》

作者:沈琰
前言
好吧,这个标题起的有些惊悚,但是对于这期要做的游戏来说也并不算无的放矢。
相信大家对前段时间大火的《Getting Over It With Bennett Foddy》这款游戏并不陌生,如果不熟悉这个名字,那么《掘地求升》这个接地气的名字想必有所耳闻。
这款游戏一反“游戏带给人快乐”的这个主旨,而是以伤害别人为目的的。

甚至游戏制作人本人都坦诚的把这句话写在了Steam商店的游戏简介上。

虽然是这么一款玩起来很痛苦的游戏,但是最后还是火了。
这款游戏虽然自己玩起来经常有砸鼠标的冲动,但是当在直播平台上看着别的主播玩却意外的节目效果爆炸,因此该游戏迅速的占领各大直播平台。
最有名的当属一位名叫kimdoe的韩国主播,当他在直播中历经12小时的奋战却一个不慎从半山腰跌入起始点,真的是隔着屏幕都能感觉到那散发出来的满满的绝望。

我们这期就来尝试着用Unity去复制一下这个充满恶意游戏。
1.关于游戏实现方式的猜想
老实说当一开始思考这个游戏如何实现时,我其实是懵逼的。因为按照现实中的物理学来看我们的爬山猛男摆出这么一个奇怪的姿势明显的不合理:

根据杠杆原理,猛男要斜着支撑起自己所要施加的力矩怕是这世界上最强壮的人也做不到。所幸这只是在游戏中模拟,既然真实物理学不太好用,我们就用代码来实现。
要达到这种效果我们可以用代码自己模拟一个绝世猛男,调整猛男手臂的力矩让其拿着的锤子的锤头始终指向鼠标方向。
不过这么一来猛男手臂和手腕关节的扭矩的计算必然很复杂,即便能做到也是很繁琐的做法。
首先我们过滤掉一切表象的东西,从根本的方向来思考这个问题,为此我特意找到了这个游戏的原型SexyHiking:

根据原型的表现形式来看,我们可以把这个问题再简化一下。

如上图,如果我们把锤子运动的路径看做一个圆,猛男身体的重心看做圆心,我们可以发现锤子相对于地面运动方向的向量刚好是这个圆的切线,而重心运动的方向则刚好相反,因此我们可以在锤子触碰到任何碰撞体的时候给身体一个与锤子相反向量的力来模拟这个效果。
2.实现方法
1.场景搭建
说干就干,我们来实际操作一下。
新建一个场景,用简易的3D物体来组装成猛男和锤子,挂上不同的材质用颜色区分一下。

然后分别挂上Rigibody,因为我们想的是让2D平面运行,因此在Rigibody上分别锁了Z轴的移动和X,Y轴的旋转,顺便把锤子上的重力去掉,因为待会要用代码去控制锤子的位置。
2.控制锤头
新建一个脚本挂载在父节点上,分别获取身体和锤子。
我们想要的效果是锤子能一直跟随鼠标移动且锤头一直指向鼠标,思路是在锤头添加一个空的子节点作为锤头的锚点,然后让整个锤子围绕着这个锚点的Z轴旋转让锤柄指向身体的方向。
public GameObject body;
public GameObject hammer;
Rigidbody body_rig;
Rigidbody hammer_rig;
Transform hammer_anchor;
void Start()
{
body_rig = body.GetComponent<Rigidbody>();
hammer_rig = hammer.GetComponent<Rigidbody>();
hammer_anchor = hammer.transform.GetChild(2);
}
//物理相关的操作一般最好放在FixedUpdate里进行,与系统的物理计算保持同步
private void FixedUpdate()
{
HammerControl();
}
void HammerControl()
{
//获取鼠标在屏幕上的坐标并转换为世界坐标
Vector2 MousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
//通过修改锤子身上刚体的速度让锤子移动,向量的起点用锤头的坐标
//注意这里要让锤头的锚点与锤子本身的坐标的Z值相等,为了让旋转轴与世界坐标的Z轴平行,同理鼠标坐标的Z值也直接使用锤子的坐标的Z值
hammer_rig.velocity = (new Vector3(MousePosition.x, MousePosition.y, hammer.transform.position.z) - hammer_anchor.position) * 10;
//获取身体到鼠标的方向
Vector3 direction = (new Vector3(MousePosition.x, MousePosition.y, hammer_anchor.position.z) - new Vector3(body.transform.position.x, body.transform.position.y, hammer_anchor.position.z)).normalized;
//让锤子沿着锤头锚点转向身体的方向
hammer.transform.RotateAround(hammer_anchor.position, Vector3.Cross(hammer_anchor.up, direction), Vector3.Angle(hammer_anchor.up, direction));
}
效果如图:

但是现在问题来了,因为只是简单模拟功能的效果,所以猛男并没有手,但是最终我们是希望锤子活动的范围离猛男有一个最大距离的限制,这样看起来好像是有一只无形的手来操作锤子一样,该如何计算这个范围呢?

由图可见这个问题的思路是,当锤头锚点在最大活动范围里面时是跟着鼠标的坐标走,当处于最大活动范围外的时候是跟着锤头锚点与身体坐标的向量和最大移动范围形成的圆的交点走。这个思路用于代码上就是当鼠标移动到最大距离范围之外时得到当前身体到鼠标位置的方向向量,然后让这个向量的长度等于最大距离的长度。
那么,我们在此基础上修改一下代码。
public float MaxDistance;
float RelativeDistance;
//单独抽出一个函数获取最大距离以外的转换后的鼠标坐标
Vector2 GetConfinedPosition(Vector2 mouseposition)
{
Vector2 Confined_MousePosition;
//去掉body坐标的Z值,避免计算距离时的影响
Vector2 body_position = new Vector2(body.transform.position.x, body.transform.position.y);
//计算当前鼠标位置和身体位置的相对距离
RelativeDistance = Vector2.Distance(mouseposition, body_position);
if (RelativeDistance > MaxDistance)
{
//当相对距离大于自己设置的最大距离时,获取转换以后的目标坐标
//这里的思路需要稍稍转一个弯,一开始是获取的是长度为最大距离,方向为身体到鼠标方向的向量。
//在这个基础上加上身体的当前坐标,其等于是将这个向量的起始点设置为身体的坐标。
//最后向量与坐标点可以直接相互转换,此时转换后的目标坐标就等于这个向量。
Confined_MousePosition = (mouseposition - body_position).normalized * MaxDistance + body_position;
}
else
{
//若相对距离小于最大距离那么鼠标当前坐标就是目标坐标
Confined_MousePosition = mouseposition;
}
return Confined_MousePosition;
}
然后把HammerControl()函数内的MousePosition的赋值修改一下:
//获取鼠标在屏幕上的坐标并转换为世界坐标,若相对距离大于最大距离则获得转换后的坐标
Vector2 MousePosition = GetConfinedPosition(Camera.main.ScreenToWorldPoint(Input.mousePosition));
最后调整MaxDistance到一个合适的值,效果如下:

3.控制身体的逆向运动
现在离实现就差最后一步了,前面思考实现方式的时候说过,锤头的移动方向与理论上身体移动的方向刚好是相反的,并且同为以身体重心为原点,锤子的最大移动距离为半径的圆的切线。那么我们顺着这个思路去做。
首先还有一个先决条件,锤子不能凭空受力,必须是杵在地上或者挂在障碍物上才行。
我们先在锤头上加一个碰撞盒子并在锤子上添加一个新建脚本,用几句简单的代码用来检测锤头是否碰撞到东西。
public class CollisionDetection : MonoBehaviour
{
public bool IsCollision;
private void OnCollisionEnter(Collision collision)
{
IsCollision = true;
}
private void OnCollisionExit(Collision collision)
{
IsCollision = false;
}
}
这里要注意的是锤子是锤头的父物体,所以即便碰撞盒子不在锤子上,作为父物体的锤子依然能检测到子物体上的碰撞信息,反过来由于锤头上并没有刚体组件,所以脚本挂在锤头上是检测不到碰撞信息的。
然后当检测到碰撞时给予身体一个相反的速度值。
void HammerControl()
{
//在给hammer_rig.velocity赋值后添加下面的代码
if(hammer.GetComponent<CollisionDetection>().IsCollision)
{
BodyControl(hammer_rig.velocity);
}
}
void BodyControl(Vector3 velociy)
{
body_rig.velocity = -velociy;
}
最后效果如下:

4.后续优化思路
到这里功能已经基本实现了,但是并没有结束,在手感上依然有很多可以调整的地方。比如原作中快速的杵地可以把自己“甩”飞起来,通过调整障碍物上碰撞盒子的物理材质达到原作中那诡异的摩擦力,添加物理关节模拟手的效果等等。
不过这些调整相当的麻烦与耗时,想必原作者为了能给大家带来痛苦在最后调整参数与设计关卡的时候也是花费了巨大的心力,这究竟是一种怎么样的精神...

限于篇幅在这里就不展开了,大家可以尽情发挥自己的想象力去坑自己的朋友。

结束
在本期文章里我们用相对简单的方法实现了《Getting Over It With Bennett Foddy》里的功能,并没有用到什么复杂的组件,代码量也很少,初学者也能较为容易实现。观看到这里的同学大可自己动手试试。原作也不见得就是用的同样的方法,大家有什么其他的实现方法也欢迎在评论区留言。
工程连接:https://github.com/tank1018702/unity_001/tree/master/BennettFoodyMustDie
最后想系统学习游戏开发的童鞋,欢迎访问 http://levelpp.com/
游戏开发搅基QQ群:869551769
微信公众号:皮皮关