Unity2D 人物基础框架(不依赖Collder2D组件)
事先声明
此框架由Tarodev编写,仅做记录
演示效果
![](https://blog-static.cnblogs.com/files/blogs/686520/U2dCharacterFrameByTarodev.gif?t=1649300832)
脚本模块
PlayerExtra.cs脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TarodevController {
/// <summary>
/// 帧输入情况
/// </summary>
public struct FrameInput {
public float X,Y;
public bool JumpDown;
public bool JumpUp;
}
/// <summary>
/// 玩家控制必要的基础接口
/// </summary>
public interface IPlayerController {
public Vector3 Velocity { get; }
public FrameInput Input { get; }
public bool JumpingThisFrame { get; }
public bool LandingThisFrame { get; }
public Vector3 RawMovement { get; }
public bool Grounded { get; }
}
public interface IExtendedPlayerController : IPlayerController {
public bool DoubleJumpingThisFrame { get; set; }
public bool Dashing { get; set; }
}
/// <summary>
/// 射线检测范围结构体
/// </summary>
public struct RayRange {
/// <summary>
/// 这里的话,最好把传来的float类型更改为vec因为值类型在调用过来时会重新copy一份,而引用类型copy的是引用,改为引用能节省内存占用
/// </summary>
/// <param name="x1"></param>
/// <param name="y1"></param>
/// <param name="x2"></param>
/// <param name="y2"></param>
/// <param name="dir"></param>
public RayRange(float x1, float y1, float x2, float y2, Vector2 dir) {
Start = new Vector2(x1, y1);
End = new Vector2(x2, y2);
Dir = dir;
}
/// <summary>
/// 只读,只在new时通过构造函数赋值,同时也是为了防止当声明为属性时直接赋值的一种躲避
/// </summary>
public readonly Vector2 Start, End, Dir;
}
}
PlayerAnimator.cs
using UnityEngine;
using Random = UnityEngine.Random;
namespace TarodevController {
/// <summary>
/// This is a pretty filthy script. I was just arbitrarily adding to it as I went.
/// You won't find any programming prowess here.
/// This is a supplementary script to help with effects and animation. Basically a juice factory.
/// </summary>
public class PlayerAnimator : MonoBehaviour {
[SerializeField] private Animator _anim;
[SerializeField] private AudioSource _source;
[SerializeField] private LayerMask _groundMask;
[SerializeField] private ParticleSystem _jumpParticles, _launchParticles;
[SerializeField] private ParticleSystem _moveParticles, _landParticles;
[SerializeField] private AudioClip[] _footsteps;
[SerializeField] private float _maxTilt = .1f;
[SerializeField] private float _tiltSpeed = 1;
[SerializeField, Range(1f, 3f)] private float _maxIdleSpeed = 2;
[SerializeField] private float _maxParticleFallSpeed = -40;
private IPlayerController _player;
private bool _playerGrounded;
private ParticleSystem.MinMaxGradient _currentGradient;
private Vector2 _movement;
void Awake() => _player = GetComponentInParent<IPlayerController>();
void Update() {
if (_player == null) return;
// Flip the sprite
if (_player.Input.X != 0) transform.localScale = new Vector3(_player.Input.X > 0 ? 1 : -1, 1, 1);
// Lean while running
var targetRotVector = new Vector3(0, 0, Mathf.Lerp(-_maxTilt, _maxTilt, Mathf.InverseLerp(-1, 1, _player.Input.X)));
_anim.transform.rotation = Quaternion.RotateTowards(_anim.transform.rotation, Quaternion.Euler(targetRotVector), _tiltSpeed * Time.deltaTime);
// Speed up idle while running
_anim.SetFloat(IdleSpeedKey, Mathf.Lerp(1, _maxIdleSpeed, Mathf.Abs(_player.Input.X)));
// Splat
if (_player.LandingThisFrame) {
_anim.SetTrigger(GroundedKey);
_source.PlayOneShot(_footsteps[Random.Range(0, _footsteps.Length)]);
}
// Jump effects
if (_player.JumpingThisFrame) {
_anim.SetTrigger(JumpKey);
_anim.ResetTrigger(GroundedKey);
// Only play particles when grounded (avoid coyote)
if (_player.Grounded) {
SetColor(_jumpParticles);
SetColor(_launchParticles);
_jumpParticles.Play();
}
}
// Play landing effects and begin ground movement effects
if (!_playerGrounded && _player.Grounded) {
_playerGrounded = true;
_moveParticles.Play();
_landParticles.transform.localScale = Vector3.one * Mathf.InverseLerp(0, _maxParticleFallSpeed, _movement.y);
SetColor(_landParticles);
_landParticles.Play();
}
else if (_playerGrounded && !_player.Grounded) {
_playerGrounded = false;
_moveParticles.Stop();
}
// Detect ground color
var groundHit = Physics2D.Raycast(transform.position, Vector3.down, 2, _groundMask);
if (groundHit && groundHit.transform.TryGetComponent(out SpriteRenderer r)) {
_currentGradient = new ParticleSystem.MinMaxGradient(r.color * 0.9f, r.color * 1.2f);
SetColor(_moveParticles);
}
_movement = _player.RawMovement; // Previous frame movement is more valuable
}
private void OnDisable() {
_moveParticles.Stop();
}
private void OnEnable() {
_moveParticles.Play();
}
void SetColor(ParticleSystem ps) {
var main = ps.main;
main.startColor = _currentGradient;
}
#region Animation Keys
private static readonly int GroundedKey = Animator.StringToHash("Grounded");
private static readonly int IdleSpeedKey = Animator.StringToHash("IdleSpeed");
private static readonly int JumpKey = Animator.StringToHash("Jump");
#endregion
}
}
PlayerController.cs
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace TarodevController {
/// <summary>
/// Hey!
/// Tarodev here. I built this controller as there was a severe lack of quality & free 2D controllers out there.
/// Right now it only contains movement and jumping, but it should be pretty easy to expand... I may even do it myself
/// if there's enough interest. You can play and compete for best times here: https://tarodev.itch.io/
/// If you hve any questions or would like to brag about your score, come to discord: https://discord.gg/GqeHHnhHpz
/// </summary>
public class PlayerController : MonoBehaviour, IPlayerController {
// Public for external hooks
public Vector3 Velocity { get; private set; }
public FrameInput Input { get; private set; }
/// <summary>
/// 在当前帧跳跃中
/// </summary>
public bool JumpingThisFrame { get; private set; }
/// <summary>
/// 在当前帧着陆中
/// </summary>
public bool LandingThisFrame { get; private set; }
public Vector3 RawMovement { get; private set; }
public bool Grounded => _colDown;
private Vector3 _lastPosition;
private float _currentHorizontalSpeed, _currentVerticalSpeed;
// This is horrible, but for some reason colliders are not fully established when update starts...
private bool _active;
void Awake() => Invoke(nameof(Activate), 0.5f);
void Activate() => _active = true;
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
}
#region Gather Input
/// <summary>
/// 输入集合控制
/// </summary>
private void GatherInput() {
//获取玩家的输入
Input = new FrameInput {
JumpDown = UnityEngine.Input.GetButtonDown("Jump"),
JumpUp = UnityEngine.Input.GetButtonUp("Jump"),
X = UnityEngine.Input.GetAxisRaw("Horizontal")
};
//判断如果按下Jump按钮则记录一下最晚按下的时间
if (Input.JumpDown) {
_lastJumpPressed = Time.time;
}
}
#endregion
/// <summary>
/// 对碰撞部分的脚本逻辑
/// </summary>
#region Collision
[Header("COLLISION")] [SerializeField] private Bounds _characterBounds;
[SerializeField] private LayerMask _groundLayer;
[SerializeField] private int _detectorCount = 3;
[SerializeField] private float _detectionRayLength = 0.1f;
[SerializeField] [Range(0.1f, 0.3f)] private float _rayBuffer = 0.1f; // Prevents side detectors hitting the ground
private RayRange _raysUp, _raysRight, _raysDown, _raysLeft;
private bool _colUp, _colRight, _colDown, _colLeft;
private float _timeLeftGrounded;
/// <summary>
/// 运行碰撞检测
/// </summary>
// We use these raycast checks for pre-collision information
private void RunCollisionChecks() {
// 生成射线范围
// Generate ray ranges.
CalculateRayRanged();
// 地面检测
// Ground
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;
// The rest
_colUp = RunDetection(_raysUp);
_colLeft = RunDetection(_raysLeft);
_colRight = RunDetection(_raysRight);
//本地函数
bool RunDetection(RayRange range) {
//使用linq语法糖获取任意由GetEnumerator()返回来的位置,当获取到值即为碰撞返回true否则false
return EvaluateRayPositions(range).Any(point => Physics2D.Raycast(point, range.Dir, _detectionRayLength, _groundLayer));
}
}
/// <summary>
/// 计算射线范围,依靠Bounds外包围盒
/// </summary>
private void CalculateRayRanged() {
//实时获取最新的包围盒
// This is crying out for some kind of refactor.
var b = new Bounds(transform.position, _characterBounds.size);
_raysDown = new RayRange(b.min.x + _rayBuffer, b.min.y, b.max.x - _rayBuffer, b.min.y, Vector2.down);
_raysUp = new RayRange(b.min.x + _rayBuffer, b.max.y, b.max.x - _rayBuffer, b.max.y, Vector2.up);
_raysLeft = new RayRange(b.min.x, b.min.y + _rayBuffer, b.min.x, b.max.y - _rayBuffer, Vector2.left);
_raysRight = new RayRange(b.max.x, b.min.y + _rayBuffer, b.max.x, b.max.y - _rayBuffer, Vector2.right);
}
/// <summary>
/// 返回一条射线上的多个侦察点
/// </summary>
/// <param name="range"></param>
/// <returns></returns>
private IEnumerable<Vector2> EvaluateRayPositions(RayRange range) {
for (var i = 0; i < _detectorCount; i++) {
var t = (float)i / (_detectorCount - 1);
yield return Vector2.Lerp(range.Start, range.End, t);
}
}
/// <summary>
/// 实时绘制出碰撞盒边框(只能在编辑器未构建模式下使用)
/// </summary>
private void OnDrawGizmos() {
// Bounds
Gizmos.color = Color.yellow;
Gizmos.DrawWireCube(transform.position + _characterBounds.center, _characterBounds.size);
//当状态为非播放模式时;
// Rays
if (!Application.isPlaying) {
CalculateRayRanged();
Gizmos.color = Color.blue;
foreach (var range in new List<RayRange> { _raysUp, _raysRight, _raysDown, _raysLeft }) {
foreach (var point in EvaluateRayPositions(range)) {
Gizmos.DrawRay(point, range.Dir * _detectionRayLength);
}
}
}
//当状态为播放模式
if (!Application.isPlaying) return;
//通过当前速度乘以每帧之间时间得到未来下一帧的动向,加上当前位置
// Draw the future position. Handy for visualizing gravity
Gizmos.color = Color.red;
var move = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed) * Time.deltaTime;
Gizmos.DrawWireCube(transform.position + move, _characterBounds.size);
}
#endregion
#region Walk
/// <summary>
/// 加速参数
/// </summary>
[Header("WALKING")] [SerializeField] private float _acceleration = 90;
/// <summary>
/// 移动速度最高限制
/// </summary>
[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
#region Gravity
[Header("GRAVITY")] [SerializeField] private float _fallClamp = -40f;
[SerializeField] private float _minFallSpeed = 80f;
[SerializeField] private float _maxFallSpeed = 120f;
private float _fallSpeed;
//计算重力
private void CalculateGravity() {
//如果碰到下面的地面
if (_colDown) {
//判断当前垂直速度小于0的话就立马改为0
// Move out of the ground
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
}
else {
//当没有碰撞到底部执行以下代码
// Add downward force while ascending if we ended the jump early
var fallSpeed = _endedJumpEarly && _currentVerticalSpeed > 0 ? _fallSpeed * _jumpEndEarlyGravityModifier : _fallSpeed;
// Fall
_currentVerticalSpeed -= fallSpeed * Time.deltaTime;
// Clamp
if (_currentVerticalSpeed < _fallClamp) _currentVerticalSpeed = _fallClamp;
}
}
#endregion
#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;
/// <summary>
/// 快速结束跳跃
/// </summary>
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));
//Debug.Log(_apexPoint);
_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;
}
// 当玩家一直按着跳跃键且y轴速度大于0时突然松开结束容易跳跃,即下落加快
// 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
#region Move
[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;
RawMovement = new Vector3(_currentHorizontalSpeed, _currentVerticalSpeed); // Used externally
var move = RawMovement * Time.deltaTime;
var furthestPoint = pos + move;
Debug.Log($"_currentSpeed:{RawMovement} furthestPoint:{furthestPoint}; _moveX:{move.x}");
//使用此方式判断,但是如果人物碰撞盒中心发生变化而对于此判断函数来说并没有效果它是先判断人物下一步位置为中心点计算
// 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;
}
//再次细化玩家当前位置到下一步之间的插值,如果overlapbox返回不为true则说明
//细化后的那一等分并没有接触到玩家碰撞盒之后村上它的位置并且在下次不能进的时候把它的位置赋值给玩家
// 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) {
//判断如果当前垂直方向速度小于0的话把速度改为0
if (_currentVerticalSpeed < 0) _currentVerticalSpeed = 0;
//--------------注意以下两个不同代码块展示的效果是一样的-------------------
var dir = transform.position - hit.transform.position;
//向量归一化即代表方向,然后乘上速度的长度就等于下一步要去的位置
transform.position += dir.normalized * move.magnitude;
//--------------------------------------------------------------------
//transform.position += move;
//--------------------------------------------------------------------
}
return;
}
positionToMoveTo = posToTry;
}
}
#endregion
}
}