链接
原项目(github)链接:Matthew-J-Spencer Ultimate-2D-Controller
Itch上的官方配套展示:tarodev.itch.io/ultimate-2d-controller
unitypackage加载后的文件结构
Tarodev 2D Controller/
├── _Scripts/ # 存放脚本代码的文件夹
│ ├── PlayerAnimator.cs # 控制玩家动画状态的脚本
│ ├── PlayerController.cs # 控制玩家移动和物理行为的主脚本
│ └── ScriptableStats.cs # 使用 ScriptableObject 存储玩家的参数(如速度、跳跃力等)
├── Animation/ # 存放动画资源的文件夹
│ ├── Idle.anim # 玩家站立时的动画
│ ├── Jump.anim # 玩家跳跃时的动画
│ ├── Land.anim # 玩家落地时的动画
│ └── Visual.controller # 动画状态机控制器,用于切换动画状态
├── Audio/ # 存放音效文件的文件夹
│ ├── Splat 1.wav # 撞击或落地时的音效(1)
│ ├── Splat 2.wav # 撞击或落地时的音效(2)
│ └── Splat 3.wav # 撞击或落地时的音效(3)
├── Demo/ # 示例场景及演示资源
│ ├── Scene.unity # 示例场景文件,展示控制器的使用
│ └── Square.png # 示例中的方块贴图或辅助图像
├── Materials/ # 材质与物理材质
│ ├── Dust.mat # 用于尘土粒子的材质
│ ├── Player.physicsMaterial2D # 玩家用的2D物理材质(如摩擦力、弹性等)
│ └── Trail.mat # 跟踪或拖尾效果使用的材质
├── Prefabs/ # 预制体文件夹
│ └── Player Controller.prefab # 玩家控制器的预设,组合了动画、脚本、碰撞器等
├── Sprites/ # 2D图像资源
│ ├── Circle.png # 圆形精灵,可用于角色或特效
│ └── Player.png # 玩家角色的主要图像
└── Stat Presets/ # ScriptableObject 参数预设
└── Player Controller.asset # 玩家控制器使用的参数配置文件(ScriptableStats 的实例)
示例对象
代码拆解
ScriptableStats.cs:
(做了部分汉化注释)
using UnityEngine;
namespace TarodevController
{
[CreateAssetMenu]
public class ScriptableStats : ScriptableObject
{
[Header("LAYERS")]
// 图层
[Tooltip("Set this to the layer your player is on")]
// 将此设置为玩家所在的图层
public LayerMask PlayerLayer;
[Header("INPUT")]
// 输入
[Tooltip("Makes all Input snap to an integer. Prevents gamepads from walking slowly. Recommended value is true to ensure gamepad/keyboard parity.")]
// 将所有输入四舍五入为整数。防止手柄缓慢移动。建议开启以确保手柄和键盘输入效果一致。
public bool SnapInput = true;
[Tooltip("Minimum input required before you mount a ladder or climb a ledge. Avoids unwanted climbing using controllers")]
// 攀爬梯子或翻越台阶前需要的最小垂直输入,避免手柄漂移导致的意外攀爬
[Range(0.01f, 0.99f)]
public float VerticalDeadZoneThreshold = 0.3f;
[Tooltip("Minimum input required before a left or right is recognized. Avoids drifting with sticky controllers")]
// 识别左右移动前需要的最小水平输入,避免手柄漂移
[Range(0.01f, 0.99f)]
public float HorizontalDeadZoneThreshold = 0.1f;
[Header("MOVEMENT")]
// 移动
[Tooltip("The top horizontal movement speed")]
// 最高水平移动速度
public float MaxSpeed = 14;
[Tooltip("The player's capacity to gain horizontal speed")]
// 玩家水平速度增长的能力
public float Acceleration = 120;
[Tooltip("The pace at which the player comes to a stop")]
// 玩家停止输入时地面上的减速速度
public float GroundDeceleration = 60;
[Tooltip("Deceleration in air only after stopping input mid-air")]
// 空中停止输入后仅在空中生效的减速速度
public float AirDeceleration = 30;
[Tooltip("A constant downward force applied while grounded. Helps on slopes")]
// 落地时向下的恒定作用力,帮助角色在斜坡上稳定
[Range(0f, -10f)]
public float GroundingForce = -1.5f;
[Tooltip("The detection distance for grounding and roof detection")]
// 检测地面和头顶碰撞的射线长度
[Range(0f, 0.5f)]
public float GrounderDistance = 0.05f;
[Header("JUMP")]
// 跳跃
[Tooltip("The immediate velocity applied when jumping")]
// 跳跃时立即施加的初始向上速度
public float JumpPower = 36;
[Tooltip("The maximum vertical movement speed")]
// 允许的最大自由落体速度
public float MaxFallSpeed = 40;
[Tooltip("The player's capacity to gain fall speed. a.k.a. In Air Gravity")]
// 空中重力加速度程度
public float FallAcceleration = 110;
[Tooltip("The gravity multiplier added when jump is released early")]
// 提前释放跳跃键时施加的额外重力倍数
public float JumpEndEarlyGravityModifier = 3;
[Tooltip("The time before coyote jump becomes unusable. Coyote jump allows jump to execute even after leaving a ledge")]
// “科约特时间”持续时长,在离开平台后仍可短暂跳跃
public float CoyoteTime = .15f;
[Tooltip("The amount of time we buffer a jump. This allows jump input before actually hitting the ground")]
// 可缓存的跳跃输入时长,允许在即将着地前按下跳跃键
public float JumpBuffer = .2f;
}
}
PlayerController.cs:
(加了中文注释)
主要功能:
可控制的跳跃:例如提前释放跳跃键,通过在玩家释放跳跃的时向角色添加额外向下的力来实现。(代码默认设置为4倍的重力)
跳跃缓冲:可以让你在真正落地之前排队进行下一次跳跃。(通过比较当前时间刻 和 跳跃按下的时间刻+最大缓冲时间 来获取是否处在有跳跃缓冲的状态)
Coyote Time(土狼时间):允许玩家在离开平台后仍能执行跳跃(时间仅为几毫秒),(通过检测角色何时离开地面并且是否在coyote time阈值内按下跳跃键来实现的)。
(PS:Coyote Time的名字源于经典动画《乐一通》(Looney Tunes)中角色冲出平台后仍能在空中奔跑的夸张效果。)
using System;
using UnityEngine;
namespace TarodevController
{
/// <summary>
/// Tarodev 提供的 2D 玩家控制器
/// </summary>
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]//挂载Rigidbody2D和Collider2D组件
public class PlayerController : MonoBehaviour, IPlayerController
{
[SerializeField] private ScriptableStats _stats; // 配置的行为参数
private Rigidbody2D _rb; // 刚体组件引用
private CapsuleCollider2D _col; // 碰撞器组件引用
private FrameInput _frameInput; // 当前帧输入
private Vector2 _frameVelocity; // 计算后帧速度
private bool _cachedQueryStartInColliders; // 缓存 Physics2D.queriesStartInColliders 原值
#region Interface
public Vector2 FrameInput => _frameInput.Move; // 对外暴露的移动输入
public event Action<bool, float> GroundedChanged; // 地面状态改变事件,参数:是否着地,上一次离地时的垂直速度
public event Action Jumped; // 跳跃事件
#endregion
private float _time; // 游戏运行时间计数
private void Awake()
{
// 缓存所有组件引用
_rb = GetComponent<Rigidbody2D>();
_col = GetComponent<CapsuleCollider2D>();
// 缓存物理查询设置
_cachedQueryStartInColliders = Physics2D.queriesStartInColliders;
}
private void Update()
{
_time += Time.deltaTime;//累计时间
GatherInput();//采集玩家输入
}
/// <summary>
/// 收集玩家输入,并处理跳跃缓冲与死区
/// </summary>
private void GatherInput()
{
_frameInput = new FrameInput
{
JumpDown = Input.GetButtonDown("Jump") || Input.GetKeyDown(KeyCode.C), // 跳跃按下
JumpHeld = Input.GetButton("Jump") || Input.GetKey(KeyCode.C), // 跳跃持续
Move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"))// 水平与垂直移动输入
};
// 输入死区处理(可选)让
if (_stats.SnapInput)
{
_frameInput.Move.x = Mathf.Abs(_frameInput.Move.x) < _stats.HorizontalDeadZoneThreshold//小于一定值则归零,防止因为误触而移动
? 0 : Mathf.Sign(_frameInput.Move.x);//Mathf.Sign(x)返回x的正负号 这里用来规范到整数
_frameInput.Move.y = Mathf.Abs(_frameInput.Move.y) < _stats.VerticalDeadZoneThreshold
? 0 : Mathf.Sign(_frameInput.Move.y);
}
// 跳跃输入缓冲
if (_frameInput.JumpDown)
{
_jumpToConsume = true; //是否有输入待处理
_timeJumpWasPressed = _time; //记录跳跃按下时刻
}
}
private void FixedUpdate()
{
// 物理更新:检测碰撞、处理跳跃、方向、重力,最后应用速度
CheckCollisions();//碰撞检测
HandleJump();//跳跃处理
HandleDirection();//水平移动处理
HandleGravity();//重力处理
ApplyMovement();//应用速度
}
#region Collisions
private float _frameLeftGrounded = float.MinValue; // 离地时间戳
private bool _grounded; // 是否着地
/// <summary>
/// 检测地面与天花板碰撞,并触发相应事件
/// </summary>
private void CheckCollisions()
{
// 禁止从内部开始查询碰撞,以避免自碰撞
Physics2D.queriesStartInColliders = false;
// 向下检测地面,向上检测天花板
/*
Physics2D.CapsuleCast(
_col.bounds.center, // 胶囊中心点(角色中心)
_col.size, // 胶囊尺寸(和碰撞体一样大)
_col.direction, // 胶囊朝向(垂直 or 水平)
0, // 胶囊体旋转角度(这里不旋转)
Vector2.down, // 投射方向 ↓(检测“地面”)
_stats.GrounderDistance, // 投射距离(短短的一点点)
~_stats.PlayerLayer // 探测哪些层级(除了玩家自己)
);
*/
// 地面
bool groundHit = Physics2D.CapsuleCast(
_col.bounds.center, _col.size, _col.direction,
0, Vector2.down, _stats.GrounderDistance, ~_stats.PlayerLayer
);
//天花板
bool ceilingHit = Physics2D.CapsuleCast(
_col.bounds.center, _col.size, _col.direction,
0, Vector2.up, _stats.GrounderDistance, ~_stats.PlayerLayer
);
// 撞到天花板时,限制向上速度
if (ceilingHit)
_frameVelocity.y = Mathf.Min(0, _frameVelocity.y);
// 处理落地事件
if (!_grounded && groundHit)//如果之前状态是不在地面,并且下方检测到地面
{
_grounded = true;//修改状态为在在地面
_coyoteUsable = true;//土狼跳跃(离开地面后仍然能起跳)//可用
_bufferedJumpUsable = true;//跳跃缓冲可用
_endedJumpEarly = false;//提前松开跳跃
GroundedChanged?.Invoke(true, Mathf.Abs(_frameVelocity.y));//触发落地事件广播(用于动画控制),传递落地时候的速度
}
// 离地事件
else if (_grounded && !groundHit)//如果之前状态是在地面,并且下方没有检测到地面
{
_grounded = false;//不再着地
_frameLeftGrounded = _time;//记录离地时间戳
GroundedChanged?.Invoke(false, 0);//触发离地事件(用于动画控制),传递上一次离地时的速度
}
// 恢复原查询设置
Physics2D.queriesStartInColliders = _cachedQueryStartInColliders;
}
#endregion
#region Jumping
private bool _jumpToConsume; // 是否有跳跃输入待处理
private bool _bufferedJumpUsable; // 缓冲跳跃是否可用
private bool _endedJumpEarly; // 是否提前松开跳跃
private bool _coyoteUsable; // 是否可使用 coyote jump
private float _timeJumpWasPressed; // 跳跃按下时刻
// 跳跃缓冲有效
//(使用属性,每次访问都是重新计算)
private bool HasBufferedJump => _bufferedJumpUsable && _time < _timeJumpWasPressed + _stats.JumpBuffer;//是在落地的一瞬间_bufferedJumpUsable为true,才计算这个状态。
// Coyote 时间内可跳
private bool CanUseCoyote => _coyoteUsable && !_grounded && _time < _frameLeftGrounded + _stats.CoyoteTime;
/// <summary>
/// 处理跳跃输入、缓冲与 coyote jump
/// </summary>
private void HandleJump()
{
// 检测跳跃提前结束,用于调整重力
if (!_endedJumpEarly && !_grounded && !_frameInput.JumpHeld && _rb.velocity.y > 0)//没有标记为提前结束,并且不在地面上,并且跳跃键没有一直按住,并且速度向上。
_endedJumpEarly = true;
// 无待处理跳跃且缓冲已过期,直接返回
if (!_jumpToConsume && !HasBufferedJump)//如果没有待处理的跳跃则返回
return;
if (_grounded || CanUseCoyote)//如果可以有跳跃需处理 在地面上或者满足土狼跳(刚离开地面没多久)的条件则执行跳跃
ExecuteJump();
_jumpToConsume = false;//清空跳跃请求状态
}
/// <summary>
/// 执行跳跃逻辑,重置相关状态
/// </summary>
private void ExecuteJump()
{
// 重置相关状态
_endedJumpEarly = false;//将提前结束标记清空
_timeJumpWasPressed = 0;//将跳跃按下时刻清空
_bufferedJumpUsable = false;//缓冲跳跃状态为不可用
_coyoteUsable = false;//土狼跳状态为不可用
_frameVelocity.y = _stats.JumpPower;//设置跳跃速度
//跳跃事件广播(用于角色动画代码)
Jumped?.Invoke();
}
#endregion
#region Horizontal
/// <summary>
/// 处理水平移动和减速
/// </summary>
private void HandleDirection()
{
if (_frameInput.Move.x == 0)//停止水平输入后减速
{
// 无输入时进行减速处理
float decel = _grounded ? _stats.GroundDeceleration : _stats.AirDeceleration;//判断是否在地面,选择对应减速度。
_frameVelocity.x = Mathf.MoveTowards(_frameVelocity.x, 0, decel * Time.fixedDeltaTime);//减速
//MoveTowards(current, target, maxDelta):将current向target靠拢,但不超过maxDelta的步长。
}
else//有水平输入时加速
{
// 有输入时加速至最大速度
_frameVelocity.x = Mathf.MoveTowards(//加速
_frameVelocity.x,
_frameInput.Move.x * _stats.MaxSpeed,//方向×最大速度大小
_stats.Acceleration * Time.fixedDeltaTime//加速度
);
}
}
#endregion
#region Gravity
/// <summary>
/// 处理重力效果,包括着地时的下压力和空中重力
/// </summary>
private void HandleGravity()
{
if (_grounded && _frameVelocity.y <= 0f)//如果在地面且y轴速度为0或负 则施加额外力,辅助贴地
{
// 着地时施加额外下压力,帮助角色贴地
_frameVelocity.y = _stats.GroundingForce;
}
else//处理在空中的速度
{
// 空中下落重力
float gravity = _stats.FallAcceleration;
// 提前松开跳跃时增加重力加速度
if (_endedJumpEarly && _frameVelocity.y > 0)
gravity *= _stats.JumpEndEarlyGravityModifier;
_frameVelocity.y = Mathf.MoveTowards(//下落速度计算,不超过最大下落速度
_frameVelocity.y,
-_stats.MaxFallSpeed,//最大下落速度
gravity * Time.fixedDeltaTime
);
}
}
#endregion
/// <summary>
/// 将计算后的速度应用到刚体
/// </summary>
private void ApplyMovement() => _rb.velocity = _frameVelocity;
#if UNITY_EDITOR
private void OnValidate()
{
// 在编辑器中提醒绑定 Stats
if (_stats == null)
Debug.LogWarning("Please assign a ScriptableStats asset to the Player Controller's Stats slot", this);
}
#endif
}
// 玩家输入结构体
public struct FrameInput
{
public bool JumpDown; // 跳跃按下
public bool JumpHeld; // 跳跃持续
public Vector2 Move; // 水平与垂直移动输入
}
// 玩家控制接口
public interface IPlayerController
{
event Action<bool, float> GroundedChanged; // 地面状态事件
event Action Jumped; // 跳跃事件
Vector2 FrameInput { get; } // 当前移动输入(只读属性)
}
}
PlayerAnimanitor.cs
(加了中文注释)
using TarodevController;
using UnityEngine;
namespace TarodevController
{
/// <summary>
/// 非常基础的角色动画示例,演示如何根据角色状态驱动动画、翻转、倾斜及粒子效果。
/// </summary>
public class PlayerAnimator : MonoBehaviour
{
[Header("References")]
[SerializeField]
private Animator _anim; // 引用 Animator 组件,用于控制动画状态机
[SerializeField] private SpriteRenderer _sprite; // 精灵渲染器,用于左右翻转
[Header("Settings")]
[SerializeField, Range(1f, 3f)]
private float _maxIdleSpeed = 2; // 空闲时动画速度的最大倍率
[SerializeField] private float _maxTilt = 5; // 最大倾斜角度(度数)
[SerializeField] private float _tiltSpeed = 20; // 倾斜插值速度
[Header("Particles")]
[SerializeField] private ParticleSystem _jumpParticles; // 跳跃时的粒子特效
[SerializeField] private ParticleSystem _launchParticles; // 起跳发射效果
[SerializeField] private ParticleSystem _moveParticles; // 移动持续粒子特效
[SerializeField] private ParticleSystem _landParticles; // 落地粒子特效
[Header("Audio Clips")]
[SerializeField]
private AudioClip[] _footsteps; // 脚步音频列表,用于随机播放脚步声
private AudioSource _source; // 音频源组件
private IPlayerController _player; // 角色控制接口
private bool _grounded; // 当前是否在地面
private ParticleSystem.MinMaxGradient _currentGradient; // 粒子颜色渐变
private void Awake()
{
// 缓存组件引用
_source = GetComponent<AudioSource>();// 音频源组件
_player = GetComponentInParent<IPlayerController>();// 角色控制接口
}
private void OnEnable()
{
// 订阅跳跃与落地事件
_player.Jumped += OnJumped;//跳跃事件
_player.GroundedChanged += OnGroundedChanged;//着陆状态改变
// 启动移动粒子
_moveParticles.Play();
}
private void OnDisable()
{
// 取消订阅,防止内存泄漏
_player.Jumped -= OnJumped;
_player.GroundedChanged -= OnGroundedChanged;
// 停止移动粒子
_moveParticles.Stop();
}
private void Update()
{
if (_player == null) return;
// 检测地面下方颜色,用于粒子染色
DetectGroundColor();
// 根据水平输入翻转精灵
HandleSpriteFlip();
// 根据输入强度调节空闲动画速度及移动粒子大小
HandleIdleSpeed();
// 根据地面状态和平移输入倾斜角色
HandleCharacterTilt();
}
/// <summary>
/// 根据输入方向翻转精灵
/// </summary>
private void HandleSpriteFlip()
{
if (_player.FrameInput.x != 0)
_sprite.flipX = _player.FrameInput.x < 0;
}
/// <summary>
/// 调整空闲动画速度和移动粒子缩放
/// </summary>
private void HandleIdleSpeed()
{
float inputStrength = Mathf.Abs(_player.FrameInput.x);//(开启SnapInput时为1)
// 空闲时速率在 1 到 _maxIdleSpeed 之间根据inputStrength插值
_anim.SetFloat(IdleSpeedKey, Mathf.Lerp(1, _maxIdleSpeed, inputStrength));
// 移动粒子根据输入强度缩放
_moveParticles.transform.localScale = Vector3.MoveTowards(
_moveParticles.transform.localScale,
Vector3.one * inputStrength,
2 * Time.deltaTime
);
}
/// <summary>
/// 角色倾斜效果,跑动时身体向前倾
/// </summary>
private void HandleCharacterTilt()
{
// 如果在地面,根据输入角度生成旋转,否则回正
Quaternion target = _grounded
? Quaternion.Euler(0, 0, _maxTilt * _player.FrameInput.x)
: Quaternion.identity;
// 平滑插值当前 transform.up(决定了当前动画对象的朝向)
_anim.transform.up = Vector3.RotateTowards(
_anim.transform.up,
target * Vector2.up,
_tiltSpeed * Time.deltaTime,
0f
);
}
/// <summary>
/// 跳跃时触发动画与粒子效果
/// </summary>
private void OnJumped()//跳跃事件
{
// 播放跳跃动画
_anim.SetTrigger(JumpKey);
_anim.ResetTrigger(GroundedKey);
if (_grounded) // 仅在非土狼跳时播放粒子
{
//设置粒子颜色
SetColor(_jumpParticles);
SetColor(_launchParticles);
//播放粒子
_jumpParticles.Play();
_launchParticles.Play();
}
}
/// <summary>
/// 落地状态改变时触发动画、声音、粒子
/// </summary>
private void OnGroundedChanged(bool grounded, float impact)
{
//获取当前是否在地面
_grounded = grounded;
if (grounded)
{
DetectGroundColor(); // 更新粒子颜色
SetColor(_landParticles); // 应用颜色梯度
_anim.SetTrigger(GroundedKey); // 播放落地动画
// 随机脚步声
_source.PlayOneShot(_footsteps[Random.Range(0, _footsteps.Length)]);
_moveParticles.Play(); // 恢复移动粒子
// 根据冲击强度缩放落地粒子
// Mathf.InverseLerp 反向插值 返回的是一个 0到1之间的比例值,表示 value 在范围 [a, b] 中的位置。
float scale = Mathf.InverseLerp(0, 40, impact);//落地的时候会impact传过来y轴速度,不在地面的时候传过来0
_landParticles.transform.localScale = Vector3.one * scale;//缩放粒子
_landParticles.Play(); // 播放落地粒子
}
else
{
// 离地时停止移动粒子
_moveParticles.Stop();
}
}
/// <summary>
/// 向下射线检测地面颜色,用于粒子染色
/// </summary>
private void DetectGroundColor()
{
var hit = Physics2D.Raycast(transform.position, Vector3.down, 2);//射线检测,以当前位置为起点,向下检测2的距离
if (!hit || hit.collider.isTrigger) return;//如果没有碰到东西或者碰到的东西是触发器,则不处理
// 如果碰撞物带有 SpriteRenderer,则获取其颜色
if (hit.transform.TryGetComponent(out SpriteRenderer r))
{
Color color = r.color;
// 生成渐变色用于粒子
_currentGradient = new ParticleSystem.MinMaxGradient(color * 0.9f, color * 1.2f);//在原颜色的基础上有一定的亮度变化
SetColor(_moveParticles);
}
}
/// <summary>
/// 设置粒子系统的起始颜色
/// </summary>
private void SetColor(ParticleSystem ps)
{
var main = ps.main;//获取粒子系统的主模块
main.startColor = _currentGradient;//设置粒子颜色
}
// Animator 参数的哈希值,优化性能
//StringToHash 方法将 Animator 参数名(字符串)转换成一个整数哈希值。
private static readonly int GroundedKey = Animator.StringToHash("Grounded");
private static readonly int IdleSpeedKey = Animator.StringToHash("IdleSpeed");
private static readonly int JumpKey = Animator.StringToHash("Jump");
}
}
有话要说
花了几个小时把这个角色控制器的代码读了一遍,受益匪浅。他介绍的原视频中还提到了定点修改和边缘检测的功能,但我并没有在源码中找到。但通过可控的跳跃、跳跃缓冲、土狼时间、以及会变色的粒子效果和Q弹的角色动画已经得到了非常不错的手感和体验。感谢大佬对代码的开源。
这篇笔记没有把代码拆细,如果带来了观感上的不适请在评论区留言,我也会继续完善这篇笔记的。