解析的开始,先附上源码:https://github.com/Matthew-J-Spencer/Ultimate-2D-Controller
额外付费部分:https://www.patreon.com/tarodev
b站转载:【Unity】好手感从何而来?一款免费的2D角色控制器,附源码哔哩哔哩bilibili
前言
开篇之前我想先聊一聊
这篇文章用于讲述类笔记
在写它之前我对控制器原理其实一无所知
是在学习的过程中当成学习笔记写得的
所以说,如果真有缘有人能对我这篇文章感兴趣
理论上哪怕你是真小白,对unity一窍不通
也是能看懂的
因为只要是我不明白的地方我都会对其解释
哪怕是最基础对象操作或者属性性质等知识
学习效率非常低,而且全靠自学,唯一有帮助的就是Chatgpt辅助调查
前前后后也解析了近半个月时间
希望读者能轻喷
注!!!!这个Unity角色控制器是自定义的物理组件制作的(不存在刚体和碰撞体)
为了玩家手感它甚至抛弃了Unity自带的物理系统逻辑方式!
所以除了该作者本身定义出的交互方式,无法使用Unity自带组件的几乎任何交换方式!
所以这篇文章的目的,就是记录我重构这个控制器的过程,使他在可能降低手感的前提下,提高对Unity物理系统的适应性(人家好不容易弄好的你给还原回去了是吧)
当然,做这个的主要是出于 学习目的!
代码总览(Update()函数)
private void Update() {
if(!_active) return;
// Calculate velocity
Velocity = (transform.position - _lastPosition) / Time.deltaTime;
_lastPosition = transform.position;
GatherInput();
RunCollisionChecks();
CalculateWalk(); // Horizontal movement
CalculateJumpApex(); // Affects fall speed, so calculate before gravity
CalculateGravity(); // Vertical movement
CalculateJump(); // Possibly overrides vertical
MoveCharacter(); // Actually perform the axis movement
}
以下是原作者@Tarodev对该组件的教学描述
首先是把所有对应模块一块块区分开来,化整为零,逐个击破,最后也方便形成框架
提前跳跃释放模块
这个主要用来控制跳跃高度,比如提前释放跳跃键,这是作者当成特色介绍的模块
这里先介绍一个2D游戏常用跳跃技巧:郊狼跳(coyote)
玩家在玩游戏的时候,会经常遇到很多和人脑直觉相对的东西
例如fps游戏中,使用狙击枪打击一个超远距离的敌人有时候并不是一个很明智的选择,因为超低的命中率和巨大的枪声很容易暴露自己的位置、浪费资源,最后往往造成形势的逆转。
但作为一款电子游戏,狙击枪的存在就是为了给予狙击枪玩家一种 超远距离击杀敌人的快感,所以提高玩家玩狙击枪的命中率,将玩家”伺候“得好好的成了众多fps游戏程序员的一门必修课
玩过《APEX传奇》中的传奇“万蒂奇”肯定会有更深的体会
图片来源bilibili@是千智哥啊
在《APEX传奇》中狙击枪在打击远距离敌人时,子弹会随飞行时间增大碰撞体积,这样就能专门针对“远距离打击”的情况来增加命中率而且看起来不“bug”,这对“万蒂奇”的大招技能会体现得更明显一点,这就是一个游戏面对玩家更加“宽容”的一个典型的例子
回到2D跳跃游戏里,“郊狼跳”的具体形式就是当系统检测到玩家脱离平台下落时,会设置一个短计时器让玩家在这个短时间内也能做出跳跃动作,这样就能有效避免玩家认为还没走出平台,结果系统检测已经走出平台开始下落,然后无法做出跳跃动作的情况
同样也是一个“宽容”的很好方式,为玩家增添手感
参考知乎@斯锅特
如下为源代码中全部的跳跃模块(其中涉及到一些重力控制模块,后面会解释)
#region Jump
[Header("JUMPING")] [SerializeField] private float _jumpHeight = 30;
[SerializeField] private float _jumpApexThreshold = 10f;
[SerializeField] private float _coyoteTimeThreshold = 0.1f;
[SerializeField] private float _jumpBuffer = 0.1f;
[SerializeField] private float _jumpEndEarlyGravityModifier = 3;
private bool _coyoteUsable;
private bool _endedJumpEarly = true;
private float _apexPoint; // Becomes 1 at the apex of a jump
private float _lastJumpPressed;
private bool CanUseCoyote => _coyoteUsable && !_colDown && _timeLeftGrounded + _coyoteTimeThreshold > Time.time;
private bool HasBufferedJump => _colDown && _lastJumpPressed + _jumpBuffer > Time.time;
private void CalculateJumpApex() {
if (!_colDown) {
// Gets stronger the closer to the top of the jump
_apexPoint = Mathf.InverseLerp(_jumpApexThreshold, 0, Mathf.Abs(Velocity.y));
_fallSpeed = Mathf.Lerp(_minFallSpeed, _maxFallSpeed, _apexPoint);
}
else {
_apexPoint = 0;
}
}
private void CalculateJump() {
// Jump if: grounded or within coyote threshold || sufficient jump buffer
if (Input.JumpDown && CanUseCoyote || HasBufferedJump) {
_currentVerticalSpeed = _jumpHeight;
_endedJumpEarly = false;
_coyoteUsable = false;
_timeLeftGrounded = float.MinValue;
JumpingThisFrame = true;
}
else {
JumpingThisFrame = false;
}
// End the jump early if button released
if (!_colDown && Input.JumpUp && !_endedJumpEarly && Velocity.y > 0) {
// _currentVerticalSpeed = 0;
_endedJumpEarly = true;
}
if (_colUp) {
if (_currentVerticalSpeed > 0) _currentVerticalSpeed = 0;
}
}
#endregion
首先直接看方法CalculateJumpApex()
与CalculateJump()
可从之前的定义中得知
_colDown
是一个布尔值,意思为“对象下界是否与平台产生碰撞”,这个变量还涉及一层又一层的检测函数的和变量改动,不过单这个模块可以先理解到这里。Mathf.Lerp
是一个脚本API,输入三个float值a、b、t(t为0~1),当t为0是返回a,当t为1时返回b,当t为0.5时返回中间点,常用于创造出更平滑的动画效果等。通常情况下,我们把设置的当前速度为a,设置最大速度为b,然后设置用加速度与上一帧时间相乘计算出来的插值因子t来返回物体当前速度,来形成一种带最大速度的平滑加速的效果,如果你能细细品味我这一段话,会发现这个函数会趋向于这样
Vn为新速度,t为时间变量,Vm为固定的最大速度,V为当前速度
这个公式会随帧率不断计算,由于V不断趋向于Vm,导致时间变量造成的影响越来越小,所以越接近Vm变化越慢,这是几乎所有与速度相关的插件都会用到的方式,与直接用加速度比起来的优势:
- 公式是逐帧计算的,物体不会因为帧率的改变导致做出与游戏逻辑不符的变化
- 在没达到最大需求时变化会非常快,快达到时变化会非常小,能达到平滑的效果
在这个脚本中,
Mathf.Lerp
的使用其实没有我说的这么麻烦,他单纯地规定了最大下落速度与最小下落速度,然后通过顶点值来计算当前速度,有趣的是_apexPoint
值。这个值是通过物体垂直的速度绝对值来计算的,当速度接近
_jumpApexThreshold
时,_apexPoint
的值会接近于0,说明远远未到达顶点,到达顶点时,则应该为0//这个地方一直有点不太明白
当然,顶点值会限制在0~1之间以方便作为t值使用。
Mathf.InverseLerp()
与前者相反,目的是计算出value在a与b之间所成比例的参数,具体算法为(value-a)/(b-a)。
_jumpApexThreshold
是一个跳跃顶点函值,用于确定物体在到达顶点之前需要跳多高。下落速度也一样,使用
Lerp
保证越接近最高点重力越强,以上可知_apexPoint
是一个比例,规定在0~1之间,也就是说作刚体y轴速度配合来计算出者很擅长利用这些Unity中的复杂数学API,用数学函数实现了对象在跳跃过程中在最高点那一瞬间的平滑丝滑感,令人不得不佩服。
注意这个
_apexPoint
,之后其他模块中也会涉及
以上规定了对象在跳跃最高点的物理逻辑
Time.time
的运用个人理解为保证游戏是在启动是开始判断,属于一个很好的编码习惯。HasBufferedJump
作者判断为跳转缓冲区,即开始涉及跳转缓冲和郊狼时间了。
注意这个,后面也会涉及另一个模块
- 当触发发生郊狼或者缓冲时刻时时触发以下动作:1、垂直速度
_currentVerticalSpeed
变为设置的跳跃高度,目的是方便缓冲模块设定。2、_endedJumpEarly
赋为假,是一个“提早停止跳跃的标识符”3、_coyoteUsable
赋为假,是一个郊狼时间标识符4、_timeLeftGrounded
是啥?//5、JumpingThisFrame
赋为真,是一个下落动作相关的标识符。6、普通落地时,直接触发动画。 - 提早取消跳跃键逻辑解读:
!_colDown&&Input.JumpUp&&!_endedJumpEarly&&Velocity.y>0
如果触发这个条件,则将_endedJumpEarly
关键词赋为真,其实字面意思理解就好 - 还有一句如果触顶则停止,这个不用解读了
- 由此可见,具体的动作触发大多通过布尔值关键字的改变来运作,这是一个很好的编写习惯,分开的判断与实现更方便代码的维护与更新
- 小tips:float.MinValue指浮点值最小的值,即为最负的值,常用于在不初始化变量的情况下让变量能够与某类型比对的情况
“顶点修改器”
这个一个增强2D游戏角色的一个很好的方式,角色在进行高跳时,系统会给予角色一个很大的助推来保证玩家能很好地控制落点,这是一个夸张的效果:
这个模块参杂在上面的跳跃模块和走路模块中(以下是走路模块):
#region Walk
[Header("WALKING")] [SerializeField] private float _acceleration = 90;
[SerializeField] private float _moveClamp = 13;
[SerializeField] private float _deAcceleration = 60f;
[SerializeField] private float _apexBonus = 2;
private void CalculateWalk() {
if (Input.X != 0) {
// Set horizontal move speed
_currentHorizontalSpeed += Input.X * _acceleration * Time.deltaTime;
// clamped by max frame movement
_currentHorizontalSpeed = Mathf.Clamp(_currentHorizontalSpeed, -_moveClamp, _moveClamp);
// Apply bonus at the apex of a jump
var apexBonus = Mathf.Sign(Input.X) * _apexBonus * _apexPoint;
_currentHorizontalSpeed += apexBonus * Time.deltaTime;
}
else {
// No input. Let's slow the character down
_currentHorizontalSpeed = Mathf.MoveTowards(_currentHorizontalSpeed, 0, _deAcceleration * Time.deltaTime);
}
if (_currentHorizontalSpeed > 0 && _colRight || _currentHorizontalSpeed < 0 && _colLeft) {
// Don't walk through walls
_currentHorizontalSpeed = 0;
}
}
#endregion
其中核心关键词为_apexBonus
,_apexPoint
,apexBonus
,_currentHorizontalSpeed
_apexBonus
为设置的“修改力度”变量,设置越大,则显示更加夸张_apexPoint
前文中提到过随跳跃高度变化,映射最大高度的值被限制在一定范围内- 最终生成的
apexBonus
与Time.deltaTime
相乘得出最终速度_currentHorizontalSpeed
在计算最终速度时乘以一个
Time.deltaTime
是经常的做法,这个函数返回上一帧的时间间隔,以秒为单位,所以游戏时间逻辑会严格按照游戏帧数运行而不是现实时间,正常情况下当成一个常值即可
跳跃缓冲
private bool HasBufferedJump => _colDown && _lastJumpPressed + _jumpBuffer > Time.time;
在跳跃模块中,这个式子被定义为HasBufferedJump
,同时在土狼时间中出现,与土狼时间相或,并执行同样的结果
在此之前,我们先查看一下用于检测输入的代码块
#region Gather Input
private void GatherInput() {
Input = new FrameInput {
JumpDown = UnityEngine.Input.GetButtonDown("Jump"),
JumpUp = UnityEngine.Input.GetButtonUp("Jump"),
X = UnityEngine.Input.GetAxisRaw("Horizontal")
};
if (Input.JumpDown) {
_lastJumpPressed = Time.time;
}
}
#endregion
UnityEngine.Input.GetButtonDown("Jump")
:检测 Jump 虚拟按钮是否在当前帧被按下。UnityEngine.Input.GetButtonUp("Jump")
:检测 Jump 虚拟按钮是否在当前帧被释放。UnityEngine.Input.GetAxisRaw("Horizontal")
:获取水平方向上的输入状态。这个方法返回一个浮点数,表示水平方向上输入设备的状态。如果玩家按下左箭头或 A 键,返回的值为 -1;如果玩家按下右箭头或 D 键,返回的值为 1;如果玩家没有按下任何键,返回的值为 0。
在JumpDown按下时,_lastJumpPressed
记录按下时的游戏时间,也就是“记住了”按下时这个动作
因为Input这个类中,所有的检测都是只能检测“按下的瞬间”,在下一个Update函数运行时全部还原,所以要通过一个另外的变量来记录
回到HasBufferedJump
- 变量一:
_coldowm
说明是否在地面上,毕竟只能在地面上才能跳跃 - 变量二:
Time.time
减去_lastJumpPressed
,如果结果小于缓冲时间_jumpBuffer
,则说明现在的时间还处于缓冲时间内(只有Time.time
为变量) - 总结:跳跃缓冲会使角色还在天上的时候如果按下跳跃键,则将跳跃并入“队列”中,当角色落地时自动立刻再次完成一次跳跃,深入地讲,就是玩家在每次按下“跳跃键”时,都会被
_lastJumpPressed
所记录,如果角色在地面上正常跳跃,则“跳跃时间”加任何一个正数“缓冲时间”肯定大于“当前时间“,因为在地面上的当前时间就是跳跃时间,那么按照这个算法角色完成正常跳跃;如果角色在天上,角色是没办法进行跳跃的,则算法会不断地在缓冲时间内不停地触发“跳跃”动作,直到角色落地正常跳跃
土狼跳模块
private bool CanUseCoyote => _coyoteUsable && !_colDown && _timeLeftGrounded + _coyoteTimeThreshold > Time.time;
源码中的模块表现形式和前者及其相似
由于已经分析过跳跃缓冲时间的代码,所以这里很容易得出CanUseCoyote
为真的条件为_coyoteUsable
(土狼时间可用),!_colDown
(角色不在地面上),当前时间还处于土狼时间函值内
_timeLeftGrounded
字段用于记录角色离地时间,源代码如下:
LandingThisFrame = false;
var groundedCheck = RunDetection(_raysDown);
if (_colDown && !groundedCheck) _timeLeftGrounded = Time.time; // Only trigger when first leaving
else if (!_colDown && groundedCheck) {
_coyoteUsable = true; // Only trigger when first touching
LandingThisFrame = true;
}
_colDown = groundedCheck;
对于向下的射线,如果上一帧玩家与地面接触且当前不再接触地面,则将_timeLeftGrounded
设置为当前时间;如果上一帧玩家未接触地面且当前接触地面,则将_coyoteUsable
设置为true
可以说
_colDown
用于储存上一帧角色是否接触地面,而_raysDown
用于检测当前是否接触地面
_colDown
与groundedCheck
的相互交织应用实现了测量“跳出一瞬间”与“接触一瞬间”这两种瞬时情况- 之后,用
_coyoteUsable
与LandingThisFrame
这两个标记来记录情况变化
当然,从这里可看出,若再附加一个“二段跳”功能
跳跃速度控制
按原作者的话说,角色下落时有一个最大速度不仅增加了角色的可控性,也能更加方便设计师设计一些下落相关的关卡
边缘检测
这是这个控制器中与移动相关的代码块
[Header("MOVE")] [SerializeField, Tooltip("Raising this value increases collision accuracy at the cost of performance.")]
private int _freeColliderIterations = 10;
// We cast our bounds before moving to avoid future collisions
private void MoveCharacter() {
var pos = transform.position + _characterBounds.center;
RawMovement = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed); // Used externally
var move = RawMovement * Time.deltaTime;
var furthestPoint = pos + move;
// check furthest movement. If nothing hit, move and don't do extra checks
var hit = Physics2D.OverlapBox(furthestPoint, _characterBounds.size, 0, _groundLayer);
if (!hit) {
transform.position += move;
return;
}
// otherwise increment away from current pos; see what closest position we can move to
var positionToMoveTo = transform.position;
for (int i = 1; i < _freeColliderIterations; i++) {
// increment to check all but furthestPoint - we did that already
var t = (float)i / _freeColliderIterations;
var posToTry = Vector2.Lerp(pos, furthestPoint, t);
if (Physics2D.OverlapBox(posToTry, _characterBounds.size, 0, _groundLayer)) {
transform.position = positionToMoveTo;
// We've landed on a corner or hit our head on a ledge. Nudge the player gently
if (i == 1) {
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
var dir = transform.position - hit.transform.position;
transform.position += dir.normalized * move.magnitude;
}
return;
}
positionToMoveTo = posToTry;
}
}
#endregion
先易后难,首先是初始化阶段
var pos = transform.position + _characterBounds.center
这一段看起来是将两个坐标相加,初看看不出什么意义,但我们应该提前知道_charactweBounds.center
是一个局部坐标,是相对于对象坐标的一个相对坐标,所以说这两个坐标相加后的初始坐标pos就变成了包围盒中心所在的坐标(由于计算碰撞肯定用包围盒)
最终计算出来坐标并没有立即更新给对象,而是赋予给了furthestPoint
,然后再将这个坐标参数结合包围盒数据,在目标地点“模拟”出一个“未来”包围盒,再使用Physics2D.OverlapBox
函数检测是否碰撞,至此,初始化完成
Physics2D.OverlapBox
详解:四个参数依次为:检测中心,检测物大小,检测物角度,勘探图层。最后返回勘探区域内的所有碰撞体大小size用坐标表示,给出长宽深
然后就是两个大结构:
if(!hit){
//正常更新坐标并返回大函数
}
//发现预料碰撞,则从pos递增位置,更新到碰撞那一瞬间
var positionToMoveTo = transform.position;
for(int i=1;i<_freeColliderIterations;i++){
//在碰撞路程内一次次分段,在每个段中寻找可能路线
}
这里重点放在对碰撞路径的探索中,也就是分段for循环中
在这个模块开头其实有要求设置一个数据_freeColliderIterations
,这个其实就是决定了这个for循环的次数,而这个for循环的本质是将pos原位置与“未来位置”中间的路段进行分段,然后在一个个分段中寻找,这个参数就是分的段数
posToTry
就是此段到达的位置,这个由前文提到 过的Lerp
函数得出,在每次循环最后赋值给positionToMoveTo
当中
如果分段递增过程中某一段有碰撞,即进入新的条件事件中,首先
transform.position = positionToMoveTo
更新目标位置(这个positionToMoveTo是前一段的位置)
ok,从这里之前的所有代码,其实都是符合unity自带碰撞的物理逻辑,即“遇到碰撞体然后停下”,但是很明显我们是不需要这种逻辑的,我们在更改游戏包容性的过程中,需要舍弃这个逻辑然后自行进行更改
先查看上下文,发现实际对象改变位置只有这一个地方,而且若没有满足条件,则直接return返回
所以在发生条件路径之前,程序的结果都是将对象移动到该移动的地方,直到最后碰撞前一瞬间,而且这最后一段移动是不按帧逻辑来的,也就是说
红色数字为帧数,第二帧与第三帧之间发生碰撞,则程序在第二帧瞬间将位置更新到碰撞位置(若分段发生碰撞)
更新位置后必return,也就是说下一次判断发生在下一帧更新,这个时候重新进入该函数,此时i必为1(不设为1则会发生对象未到碰撞地点就触发”边缘检测“动作)
- 由主函数目录中知,move函数最后计算,在重新开始新的一帧时水平速度与垂直速度都会重新开始计算,这一句
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
其实是作用于”对象触顶“的情况,若触顶,按照碰撞逻辑停止,然后return之后先运行重力函数,垂直速度必为负,在这个代码块中重置垂直速度来避免重力对边缘检测的影响(而且若边缘检测发生在平台爬升的阶段,不发生触顶,则不会触发这个条件,也就不会重置
var dir = transform.position - hit.transform.position;transform.position += dir.normalized * move.magnitude;
使得对象被轻轻推开,就像
看见那个弯弯折折没
这个原理其实就把所有碰撞体在发生边缘检测时都默认为了圆形(试想一下物体在一堆球中一直前进)
随后return,进入下一帧计算
结语
这个角色控制器只是@Tarodev对角色控制器的第一版,不按照unity本身自带碰撞要求实现的一系列功能,很显然这对游戏开发者来说是很不方便的,但是无论适不适配碰撞,这些改变手感的原理都是一致的,所以这个模板有很大的学习价值(很难受的是受限于精力与能力,这个拆解尚存在太多错误/可能/与疑问未能及时解决)
在最终完成这篇文章之前,我一直在使用@Tarodev的第二版控制器,这个控制器适配了unity碰撞与刚体,而且加了很多新的功能(冲刺等),如果有时间,我会对第二版进行更完善的拆解