新版输入系统——斜向移动变快问题解决
生成对应的input管理脚本
Day 01——角色移动基类
CharacterMovementControlBase- using UnityEngine;namespace Spiderman.Movement{ [RequireComponent(typeof(CharacterController))] public abstract class CharacterMovementControlBase : MonoBehaviour { // 角色控制器组件,用于处理角色移动相关的物理交互 private CharacterController _controller; // 动画组件,用于控制角色动画播放 private Animator _animator; // 地面检测相关变量 protected bool _characterIsOnGround; [Header("地面检测相关变量")] [SerializeField]protected float _groundDetectionPositionOffset; // 地面检测位置偏移量 [SerializeField]protected float _detectionRang; // 地面检测范围 [SerializeField]protected LayerMask _whatIsGround; // 地面层掩码 // 重力相关变量 protected readonly float CharacterGravity = -9.8f; protected float _characterVerticalVelocity; // 角色垂直方向速度 protected float _fallOutDeltaTime; // 下落 delta 时间,用于计算重力作用的时间积累 protected float _fallOutTime = 0.15f; // 下落等待时间,控制跌落动画播放时机 protected readonly float _characterVerticalMaxVelocity = 54f; // 角色最大垂直速度,低于这个值应用重力 protected Vector3 _characterVerticalDirection; // 角色Y轴移动方向,通过charactercontroller.move来实现y轴移动 // 初始化函数,在对象实例化后、Start 之前调用,获取必要组件 protected virtual void Awake() { _controller = GetComponent(); _animator = GetComponent(); } protected virtual void Start() { _fallOutDeltaTime = _fallOutTime; } private void Update() { SetCharacterGravity(); UpdateCharacterGravity(); } /// /// 地面检测方法 /// /// 返回角色是否在地面的布尔值 private bool GroundDetection() { // 构建检测位置:基于角色当前位置,调整 Y 轴偏移(用于地面检测的位置修正) Vector3 detectionPosition = new Vector3( transform.position.x, transform.position.y - _groundDetectionPositionOffset, transform.position.z ); // 球形检测:检查在指定位置、指定半径范围内,与 _whatIsGround 层的碰撞体是否存在相交 // 参数分别为:检测中心、检测半径、地面层掩码、忽略触发器交互 return Physics.CheckSphere( detectionPosition, _detectionRang, _whatIsGround, QueryTriggerInteraction.Ignore ); } /// /// 根据是否在地面设置对应的角色重力逻辑 /// private void SetCharacterGravity() { // 检测角色是否在地面 _characterIsOnGround = GroundDetection(); if (_characterIsOnGround) { //1.在地面 // 1.1 重置下落等待时间 _fallOutDeltaTime = _fallOutTime; // 1.2 重置垂直速度(防止落地后持续累积速度) if (_characterVerticalVelocity < 0) { _characterVerticalVelocity = -2f; } } else { //2.不在地面 if (_fallOutDeltaTime > 0) { // 2.1 处理楼梯/小落差:等待 0.15 秒后再应用重力 _fallOutDeltaTime -= Time.deltaTime; } else { // 2.2 倒计时结束还没有落地?那说明不是小落差,要开始应用重力 } if (_characterVerticalVelocity < _characterVerticalMaxVelocity) { _characterVerticalVelocity += CharacterGravity * Time.deltaTime; // 重力公式累积垂直速度 } } } /// /// 更新角色垂直方向移动(应用重力效果) /// private void UpdateCharacterGravity() { //这里只处理 y 轴重力 // x/z 由其他移动逻辑控制 Vector3 _characterVerticalDirection = new Vector3(0, _characterVerticalVelocity, 0); // 通过 CharacterController 应用y轴移动 _controller.Move(_characterVerticalDirection * Time.deltaTime); } /// /// 斜坡方向重置:检测角色是否在坡上移动,防止下坡速度过快导致异常 /// /// 原始移动方向 /// 适配斜坡后的移动方向 private Vector3 SlopResetDirection(Vector3 moveDirection) { // 射线检测参数配置 Vector3 rayOrigin = transform.position + transform.up * 0.5f; // 射线起点 Vector3 rayDirection = Vector3.down; // 射线方向 float maxDistance = _controller.height * 0.85f; // 射线最大距离 LayerMask targetLayer = _whatIsGround; // 检测的目标地面层 QueryTriggerInteraction triggerInteraction = QueryTriggerInteraction.Ignore; // 忽略触发器 // 执行向下的射线检测 if (Physics.Raycast(rayOrigin, rayDirection, out RaycastHit hit, maxDistance, targetLayer, triggerInteraction)) { // 点积判断:检测地面法线是否与角色上方向垂直(点积接近0表示垂直,非0则说明有坡度) if (Vector3.Dot(transform.up, hit.normal) != 0) { // 将移动方向投影到斜坡平面 moveDirection = Vector3.ProjectOnPlane(moveDirection, hit.normal); } } return moveDirection; } private void OnDrawGizmos() { // 设置gizmos颜色为红色,使其更容易看到 Gizmos.color = Color.red; Vector3 detectionPosition = new Vector3( transform.position.x, transform.position.y - _groundDetectionPositionOffset, transform.position.z ); Gizmos.DrawWireSphere(detectionPosition, _detectionRang); } }}
复制代码 PlayerMovementControl- using System.Collections;using System.Collections.Generic;using UnityEngine;namespace Spiderman.Movement{ public class PlayerMovementControl : CharacterMovementControlBase { }}
复制代码 Day02 带碰撞体相机脚本
GameInputManager- using System.Collections;using System.Collections.Generic;using UnityEngine;public class GameInputManager : MonoBehaviour{ private GameInputAction _gameInputAction; public Vector2 Movement => _gameInputAction.Player.Movement.ReadValue(); public Vector2 CameraLook => _gameInputAction.Player.CameraLook.ReadValue(); private void Awake() { _gameInputAction ??= new GameInputAction(); //是空的,则创建新的实例 } private void OnEnable() { _gameInputAction.Enable(); } private void OnDisable() { _gameInputAction.Disable(); }}
复制代码


TP_CameraControl- using System.Collections;using System.Collections.Generic;using UnityEngine;public class TP_CameraControl : MonoBehaviour{ private GameInputManager _gameInputManager; [Header("相机参数配置")] [SerializeField] private Transform _lookTarget; //相机跟随目标 [SerializeField] private float _controlSpeed; //相机移动速度 [SerializeField] private Vector2 _cameraVerticalMaxAngle; //相机上下旋转角度限制 [SerializeField] private Vector2 _cameraHorizontalMaxAngle; //相机左右旋转角度限制 [SerializeField] private float _smoothSpeed; //平滑速度 [SerializeField] private float _cameraDistance; //相机到跟随目标的距离 [SerializeField] private float _cameraHeight; //相机高度 [SerializeField] private float _DistancemoothTime; //位置跟随平滑时间 private Vector3 smoothDampVelocity = Vector3.zero; //旋转阻尼 private Vector2 _input; // 输入值 private Vector3 _cameraRotation; // 相机旋转方向 private bool _cameraInputEnabled = true; // 相机输入是否启用 private void Awake() { // 获取游戏输入管理组件 _gameInputManager = GetComponent(); //隐藏光标 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; } private void Update() { // 检测到按下ESC键或鼠标左键点击窗口,则切换相机输入状态 HandleCameraInputToggle(); // 只有在相机输入启用时才处理输入 if (_cameraInputEnabled) { // 实时处理相机输入 CameraInput(); } } private void LateUpdate() { // 更新相机旋转 UpdateCameraRotation(); // 更新相机位置 UpdateCameraPosition(); } /// /// 处理相机输入,获取并处理上下查看等输入,限制垂直角度范围 /// private void CameraInput() { // 获取相机xy轴输入 _input.y += _gameInputManager.CameraLook.x * _controlSpeed; _input.x -= _gameInputManager.CameraLook.y * _controlSpeed; // 限制相机垂直方向角度范围,垂直方向是绕 x 轴旋转,所以平滑的是x轴输入 _input.x = Mathf.Clamp( _input.x, _cameraVerticalMaxAngle.x, _cameraVerticalMaxAngle.y ); // 限制相机水平方向角度范围,水平方向是绕 y 轴旋转,所以限制的是y轴输入 _input.y = Mathf.Clamp( _input.y, _cameraHorizontalMaxAngle.x, _cameraHorizontalMaxAngle.y ); } /// /// 更新相机旋转 /// private void UpdateCameraRotation() { var targetRotation = new Vector3(_input.x, _input.y, 0); _cameraRotation = Vector3.SmoothDamp( _cameraRotation, targetRotation, ref smoothDampVelocity, _smoothSpeed ); //更新相机欧拉角 transform.eulerAngles = _cameraRotation; } /// /// 更新相机位置 /// private void UpdateCameraPosition() { var newPos = _lookTarget.position + Vector3.back * _cameraDistance + Vector3.up * _cameraHeight; // 平滑位置移动 transform.position = Vector3.Lerp( transform.position, newPos, _DistancemoothTime ); } /// /// 处理相机输入状态切换 /// private void HandleCameraInputToggle() { // 检测ESC键切换相机输入状态 if (Input.GetKeyDown(KeyCode.Escape)) { _cameraInputEnabled = false; // 显示光标并解锁 Cursor.lockState = CursorLockMode.None; Cursor.visible = true; } // 检测鼠标左键点击窗口来恢复相机控制 if (Input.GetMouseButtonDown(0) && !_cameraInputEnabled) { _cameraInputEnabled = true; // 隐藏光标并锁定 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; } }}
复制代码 加入摄像机碰撞逻辑
GameInputManager继承于单例模式- using System.Collections;using System.Collections.Generic;using UnityEngine;using GGG.Tool.Singleton;public class GameInputManager : Singleton{ private GameInputAction _gameInputAction; public Vector2 Movement => _gameInputAction.Player.Movement.ReadValue(); public Vector2 CameraLook => _gameInputAction.Player.CameraLook.ReadValue(); private void Awake() { base.Awake(); _gameInputAction ??= new GameInputAction(); //是空的,则创建新的实例 } private void OnEnable() { _gameInputAction.Enable(); } private void OnDisable() { _gameInputAction.Disable(); }}
复制代码 TP_CameraControl- using System.Collections;using System.Collections.Generic;using UnityEngine;using GGG.Tool;public class TP_CameraControl : MonoBehaviour{ [Header("相机参数配置")] [SerializeField] private Transform _lookTarget; //相机跟随目标 [SerializeField] private float _controlSpeed; //相机移动速度 [SerializeField] private Vector2 _cameraVerticalMaxAngle; //相机上下旋转角度限制 [SerializeField] private Vector2 _cameraHorizontalMaxAngle; //相机左右旋转角度限制 [SerializeField] private float _smoothSpeed; //平滑速度 [SerializeField] private float _cameraDistance; //相机到跟随目标的距离 [SerializeField] private float _cameraHeight; //相机高度 [SerializeField] private float _distanceSmoothTime; //位置跟随平滑时间 private Vector3 smoothDampVelocity = Vector3.zero; //旋转阻尼 private Vector2 _input; // 输入值 private Vector3 _cameraRotation; // 相机旋转方向 private bool _cameraInputEnabled = true; // 相机输入是否启用 private void Awake() { //隐藏光标 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; } private void Update() { // 检测到按下ESC键或鼠标左键点击窗口,则切换相机输入状态 HandleCameraInputToggle(); // 只有在相机输入启用时才处理输入 if (_cameraInputEnabled) { // 实时处理相机输入 CameraInput(); } } private void LateUpdate() { // 更新相机旋转 UpdateCameraRotation(); // 更新相机位置 UpdateCameraPosition(); } /// /// 处理相机输入,获取并处理上下查看等输入,限制垂直角度范围 /// private void CameraInput() { // 获取相机xy轴输入 _input.y += GameInputManager.MainInstance.CameraLook.x * _controlSpeed; _input.x -= GameInputManager.MainInstance.CameraLook.y * _controlSpeed; // 限制相机垂直方向角度范围,垂直方向是绕 x 轴旋转,所以平滑的是x轴输入 _input.x = Mathf.Clamp( _input.x, _cameraVerticalMaxAngle.x, _cameraVerticalMaxAngle.y ); // 限制相机水平方向角度范围,水平方向是绕 y 轴旋转,所以限制的是y轴输入 _input.y = Mathf.Clamp( _input.y, _cameraHorizontalMaxAngle.x, _cameraHorizontalMaxAngle.y ); } /// /// 更新相机旋转 /// private void UpdateCameraRotation() { var targetRotation = new Vector3(_input.x, _input.y, 0); _cameraRotation = Vector3.SmoothDamp( _cameraRotation, targetRotation, ref smoothDampVelocity, _smoothSpeed ); //更新相机欧拉角 transform.eulerAngles = _cameraRotation; } /// /// 更新相机位置 /// private void UpdateCameraPosition() { var newPos = _lookTarget.position + Vector3.back * _cameraDistance + Vector3.up * _cameraHeight; // 平滑位置移动 transform.position = Vector3.Lerp( transform.position, newPos, DevelopmentToos.UnTetheredLerp(_distanceSmoothTime) ); } /// /// 处理相机输入状态切换 /// private void HandleCameraInputToggle() { // 检测ESC键切换相机输入状态 if (Input.GetKeyDown(KeyCode.Escape)) { _cameraInputEnabled = false; // 显示光标并解锁 Cursor.lockState = CursorLockMode.None; Cursor.visible = true; } // 检测鼠标左键点击窗口来恢复相机控制 if (Input.GetMouseButtonDown(0) && !_cameraInputEnabled) { _cameraInputEnabled = true; // 隐藏光标并锁定 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; } }}
复制代码 Day03 Movement
动画部分
脚本
CharacterMovementControlBase- protected Vector3 _moveDirection; // 角色移动方向
复制代码- /// /// 脚本控制animator的根运动 /// protected virtual void OnAnimatorMove() { _animator.ApplyBuiltinRootMotion(); UpdateCharacterMoveDirection(_animator.deltaPosition); }
复制代码- /// /// 更新角色水平移动方向——绕y轴旋转 /// protected void UpdateCharacterMoveDirection(Vector3 direction) { _moveDirection = SlopResetDirection(direction); _controller.Move(_moveDirection * Time.deltaTime); }
复制代码 GameInputManager- public bool Run => _gameInputAction.Player.Run.triggered;
复制代码 PlayerMovementControl- using GGG.Tool;using System.Collections;using System.Collections.Generic;using UnityEngine;namespace Spiderman.Movement{ public class PlayerMovementControl : CharacterMovementControlBase { [SerializeField] float moveSpeed = 1.5f; // 角色旋转角度(绕 Y 轴) private float _rotationAngle; // 旋转角速度 private float _angleVelocity = 0; // 旋转平滑时间 [SerializeField] private float _rotationSmoothTime; private Transform _mainCamera; protected override void Awake() { base.Awake(); _mainCamera = Camera.main.transform; } private void LateUpdate() { UpdateAnimation(); CharacterRotationControl(); } /// /// 角色旋转控制 /// private void CharacterRotationControl() { // 不在地面时直接返回,不处理旋转 if (!_characterIsOnGround) return; // 处理输入存在时的旋转角度计算 if (_animator.GetBool("HasInput")) { _rotationAngle = Mathf.Atan2( GameInputManager.MainInstance.Movement.x, GameInputManager.MainInstance.Movement.y ) * Mathf.Rad2Deg + _mainCamera.eulerAngles.y; // 计算角色的旋转角度(弧度转角度) } // 满足HasInput==true且处于“Motion”动画标签时,平滑更新角色旋转 if (_animator.GetBool("HasInput") && _animator.AnimationAtTag("Motion")) { transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle( transform.eulerAngles.y, _rotationAngle, ref _angleVelocity, _rotationSmoothTime ); } } /// /// 更新动画 /// private void UpdateAnimation() { if (!_characterIsOnGround) return; _animator.SetBool("HasInput", GameInputManager.MainInstance.Movement != Vector2.zero); if (_animator.GetBool("HasInput")) { if (GameInputManager.MainInstance.Run) { //按下奔跑键 _animator.SetBool("Run",true); } //有输入 // Run被开启,那就Movement设置为2,否则设置为输入的两个轴的平方 var targetSpeed = _animator.GetBool("Run") ? 2f :GameInputManager.MainInstance.Movement.sqrMagnitude; _animator.SetFloat( "Movement", targetSpeed / _animator.humanScale * moveSpeed, 0.25f, Time.deltaTime ); } else { //无输入 _animator.SetFloat("Movement", 0f, 0.25f, Time.deltaTime); if (_animator.GetFloat("Movement") < 0.2f) { _animator.SetBool("Run", false); } } } }}
复制代码 Day04 事件管理器
GameEventManager- using System.Collections;using System.Collections.Generic;using UnityEngine;using System;using GGG.Tool;using GGG.Tool.Singleton;public class GameEventManager : SingletonNonMono{ // 事件接口 private interface IEventHelp { } // 事件类,实现 IEventHelp 接口,用于管理事件注册、调用等逻辑 private class EventHelp : IEventHelp { // 存储事件委托 private event Action _action; // 构造函数,初始化时绑定初始事件逻辑 public EventHelp(Action action) { // 首次实例化时赋值,仅执行这一次初始绑定 _action = action; } // 增加事件注册的方法,将新的事件逻辑追加到委托中 public void AddCall(Action action) { _action += action; } // 调用事件的方法,若有绑定逻辑则执行 public void Call() { _action?.Invoke(); } // 移除事件的方法,将指定事件逻辑从委托中移除 public void Remove(Action action) { _action -= action; } } private class EventHelp : IEventHelp { // 存储事件委托 private event Action _action; // 构造函数,初始化时绑定初始事件逻辑 public EventHelp(Action action) { // 首次实例化时赋值,仅执行这一次初始绑定 _action = action; } // 增加事件注册的方法,将新的事件逻辑追加到委托中 public void AddCall(Action action) { _action += action; } // 调用事件的方法,若有绑定逻辑则执行 public void Call(T value) { _action?.Invoke(value); } // 移除事件的方法,将指定事件逻辑从委托中移除 public void Remove(Action action) { _action -= action; } } private class EventHelp : IEventHelp { // 存储事件委托 private event Action _action; // 构造函数,初始化时绑定初始事件逻辑 public EventHelp(Action action) { // 首次实例化时赋值,仅执行这一次初始绑定 _action = action; } // 增加事件注册的方法,将新的事件逻辑追加到委托中 public void AddCall(Action action) { _action += action; } // 调用事件的方法,若有绑定逻辑则执行 public void Call(T1 value1, T2 value2) { _action?.Invoke(value1, value2); } // 移除事件的方法,将指定事件逻辑从委托中移除 public void Remove(Action action) { _action -= action; } } /// /// 事件中心,用于管理事件注册、调用 /// private Dictionary _eventCenter = new Dictionary(); /// /// 添加事件监听 /// /// 事件名称 /// 回调函数 public void AddEventListening(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.AddCall(action); } else { // 如果事件中心不存在叫这个名字的事件,new一个然后添加 _eventCenter.Add(eventName, new EventHelp(action)); } } public void AddEventListening(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.AddCall(action); } else { // 如果事件中心不存在叫这个名字的事件,new一个然后添加 _eventCenter.Add(eventName, new EventHelp(action)); } } public void AddEventListening(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.AddCall(action); } else { // 如果事件中心不存在叫这个名字的事件,new一个然后添加 _eventCenter.Add(eventName, new EventHelp(action)); } } /// /// 调用事件 /// /// 事件名称 public void CallEvent(string eventName) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Call(); } else { LogEventNotFound(eventName, "调用"); } } public void CallEvent(string eventName, T value) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Call(value); } else { LogEventNotFound(eventName, "调用"); } } public void CallEvent(string eventName, T1 value, T2 value1) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Call(value, value1); } else { LogEventNotFound(eventName, "调用"); } } /// /// 移除事件监听 /// /// 事件名称 /// 要移除的事件回调 public void RemoveEvent(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Remove(action); } else { LogEventNotFound(eventName, "移除"); } } public void RemoveEvent(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Remove(action); } else { LogEventNotFound(eventName, "移除"); } } public void RemoveEvent(string eventName, Action action) { if (_eventCenter.TryGetValue(eventName, out var eventHelp)) { (eventHelp as EventHelp)?.Remove(action); } else { LogEventNotFound(eventName, "移除"); } } /// /// 事件未找到时的统一日志输出 /// /// 事件名称 /// 操作类型(移除、调用) private void LogEventNotFound(string eventName, string operation) { DevelopmentTools.WTF($"当前未找到{eventName}的事件,无法{operation}"); }}
复制代码 Day05 AnimationStringToHash
- using System.Collections;using System.Collections.Generic;using UnityEngine;/// /// 动画参数哈希值管理类,用于统一存储Animator参数的哈希值,避免重复计算/// public class AnimationID{ // 角色移动相关动画参数哈希 public static readonly int MovementID = Animator.StringToHash("Movement"); public static readonly int LockID = Animator.StringToHash("Lock"); public static readonly int HorizontalID = Animator.StringToHash("Horizontal"); public static readonly int VerticalID = Animator.StringToHash("Vertical"); public static readonly int HasInputID = Animator.StringToHash("HasInput"); public static readonly int RunID = Animator.StringToHash("Run");}
复制代码 Day06 GameTimer
- using System;using System.Collections;using System.Collections.Generic;using UnityEngine;/// /// 计时器状态枚举,描述计时器不同工作阶段/// public enum TimerState{ NOTWORKERE, // 没有工作(初始或重置后状态) WORKERING, // 工作中(计时进行时) DONE // 工作完成(计时结束)}/// /// 游戏计时器类,用于管理计时逻辑,支持启动计时、更新计时、获取状态、重置等功能/// public class GameTimer{ // 计时时长(剩余计时时间) private float _startTime; // 计时结束后要执行的任务(Action 委托) private Action _task; // 是否停止当前计时器标记 private bool _isStopTimer; // 当前计时器的状态 private TimerState _timerState; /// /// 构造函数,初始化时重置计时器 /// public GameTimer() { ResetTimer(); } /// /// 1. 开始计时 /// /// 要计时的时长 /// 计时结束后要执行的任务(Action 委托) public void StartTimer(float time, Action task) { _startTime = time; _task = task; _isStopTimer = false; _timerState = TimerState.WORKERING; } /// /// 2. 更新计时器(通常在 MonoBehaviour 的 Update 里调用,驱动计时逻辑) /// public void UpdateTimer() { // 如果标记为停止,直接返回,不执行计时更新 if (_isStopTimer) return; // 递减计时时间 _startTime -= Time.deltaTime; // 计时时间小于 0,说明计时结束 if (_startTime < 0) { // 安全调用任务(如果任务不为 null 才执行) _task?.Invoke(); // 更新状态为已完成 _timerState = TimerState.DONE; // 标记为停止,后续不再继续计时更新 _isStopTimer = true; } } /// /// 3. 获取当前计时器的状态 /// /// 返回 TimerState 枚举值,代表当前计时器状态 public TimerState GetTimerState() => _timerState; /// /// 4. 重置计时器,恢复到初始状态 /// public void ResetTimer() { _startTime = 0f; _task = null; _isStopTimer = true; _timerState = TimerState.NOTWORKERE; }}
复制代码 TimerManager- using System;using System.Collections;using System.Collections.Generic;using GGG.Tool;using GGG.Tool.Singleton;using UnityEngine;using UnityEngine.UIElements;/// /// 计时器管理器,采用单例模式,负责管理空闲计时器队列和工作中计时器列表,/// 实现计时器的初始化、分配、回收及更新逻辑/// public class TimerManager : Singleton{ #region 私有字段 // 初始最大计时器数量,在 Inspector 中配置 [SerializeField] private int _initMaxTimerCount; // 空闲计时器队列,存储可用的 GameTimer private Queue _notWorkingTimer = new Queue(); // 工作中计时器列表,存储正在计时的 GameTimer private List _workingTimer = new List(); #endregion #region 生命周期与初始化 protected override void Awake() { base.Awake(); InitTimerManager(); } /// /// 初始化计时器管理器,创建初始数量的空闲计时器 /// private void InitTimerManager() { for (int i = 0; i < _initMaxTimerCount; i++) { CreateTimerInternal(); } } /// /// 内部创建计时器并加入空闲队列的方法 /// private void CreateTimerInternal() { var timer = new GameTimer(); _notWorkingTimer.Enqueue(timer); } #endregion #region 计时器分配与回收 /// /// 尝试获取一个计时器,用于执行定时任务 /// /// 计时时长 /// 计时结束后执行的任务 public void TryGetOneTimer(float time, Action task) { // 若空闲队列为空,额外创建一个计时器 if (_notWorkingTimer.Count == 0) { CreateTimerInternal(); } var timer = _notWorkingTimer.Dequeue(); timer.StartTimer(time, task); _workingTimer.Add(timer); } /// /// 回收计时器(可在 GameTimer 完成任务时调用,这里逻辑已内联在更新里,也可扩展外部调用) /// 注:当前通过 UpdateWorkingTimer 自动回收,此方法可留作扩展 /// /// 要回收的计时器 private void RecycleTimer(GameTimer timer) { timer.ResetTimer(); _notWorkingTimer.Enqueue(timer); _workingTimer.Remove(timer); } #endregion #region 计时器更新逻辑 private void Update() { UpdateWorkingTimer(); } /// /// 更新工作中计时器的状态,处理计时推进和完成后的回收 /// private void UpdateWorkingTimer() { // 遍历副本,避免列表修改时迭代出错 for (int i = _workingTimer.Count - 1; i >= 0; i--) { var timer = _workingTimer[i]; timer.UpdateTimer(); if (timer.GetTimerState() == TimerState.DONE) { RecycleTimer(timer); } } } #endregion}
复制代码 Day07 脚部拖尾特效的控制——奔跑时启用
- using UnityEngine;using System.Collections;public class ObjectVisibilityController : MonoBehaviour{ // 在 Inspector 中手动拖入需要控制的子物体 public GameObject targetChild; public Animator playerAnimator; // 存储当前目标状态,用于判断是否需要执行状态切换 private bool _currentTargetState; // 标记是否正在等待延迟,避免重复启动协程 private bool _isWaiting = false; private void Update() { // 获取动画状态的当前值 bool desiredState = playerAnimator.GetBool(AnimationID.RunID); // 如果状态发生变化且不在等待状态,则启动延迟协程 if (desiredState != _currentTargetState && !_isWaiting) { StartCoroutine(ChangeStateAfterDelay(desiredState, 0.5f)); } } // 延迟改变状态的协程 private IEnumerator ChangeStateAfterDelay(bool newState, float delay) { _isWaiting = true; // 标记为正在等待 yield return new WaitForSeconds(delay); // 等待指定秒数 // 应用新状态 targetChild.SetActive(newState); _currentTargetState = newState; _isWaiting = false; // 重置等待标记 }}
复制代码
Day08 IKController——头部IK跟随相机(平滑控制)
IKController- using System.Collections;using System.Collections.Generic;using UnityEngine;public class IKController : MonoBehaviour{ public Animator _animator; //IK控制点 //四肢关节点 public Transform ik_LHand; public Transform ik_RHand; public Transform ik_LFoot; public Transform ik_RFoot; //头部控制点,可以根据主相机的位置,让玩家能够从侧视角下看到头部偏转。 public Transform Head_IKPoint; [Header("IK 权重控制")] [SerializeField] private float ikBlendSpeed = 5f; // IK权重变化速度 [SerializeField] private float headTurnSpeed = 5f; // 头部转向速度 [SerializeField] private float maxHeadAngle = 60f; // 头部最大转向角度 // IK相关私有变量 private float _currentHeadIKWeight = 0f; // 当前头部IK权重 private Vector3 _currentLookTarget; // 缓存"当前正在看的点" private bool _hasInitializedLookTarget = false; // 是否已初始化看向目标 private void OnAnimatorIK(int layerIndex) { // 四肢IK控制 if (ik_LHand != null) IKControl(AvatarIKGoal.LeftHand, ik_LHand); if (ik_RHand != null) IKControl(AvatarIKGoal.RightHand, ik_RHand); if (ik_LFoot != null) IKControl(AvatarIKGoal.LeftFoot, ik_LFoot); if (ik_RFoot != null) IKControl(AvatarIKGoal.RightFoot, ik_RFoot); // 头部IK控制 - 使用平滑权重过渡 HandleHeadIK(); } /// /// 处理头部IK控制 - 解决生硬切换问题 /// private void HandleHeadIK() { if (Head_IKPoint == null) return; // 判断是否应该启用头部IK bool shouldUseHeadIK = _animator.GetFloat(AnimationID.MovementID) < 0.1f; // 计算目标权重 float targetWeight = shouldUseHeadIK ? 1f : 0f; // 平滑过渡权重 - 这是解决生硬切换的关键 _currentHeadIKWeight = Mathf.Lerp(_currentHeadIKWeight, targetWeight, ikBlendSpeed * Time.deltaTime); // 如果权重大于0,执行头部IK控制 if (_currentHeadIKWeight > 0.01f) { IKHeadControl(Head_IKPoint, headTurnSpeed, maxHeadAngle); } // 使用平滑权重而不是固定的1f _animator.SetLookAtWeight(_currentHeadIKWeight); // 如果已初始化目标位置,设置看向位置 if (_hasInitializedLookTarget) { _animator.SetLookAtPosition(_currentLookTarget); } } /// /// 头部 IK 控制(平滑转向 + 角度限制) /// /// 要看的对象 /// 插值速度 /// 最大允许夹角(度数) private void IKHeadControl(Transform target, float turnSpeed = 5f, float maxAngle = 60f) { // 初始化看向目标 - 防止第一次启用时的突然跳转 if (!_hasInitializedLookTarget) { _currentLookTarget = transform.position + transform.forward * 5f; _hasInitializedLookTarget = true; } // 1. 计算最终想要看的点 Vector3 rawTargetPos; Vector3 directionToCamera = target.position - transform.position; bool isCameraInFront = Vector3.Dot(transform.forward, directionToCamera.normalized) > 0; if (isCameraInFront) { // 相机在前面,看向相机 rawTargetPos = target.position; } else { // 相机在背后,看向相机视线向前延伸的点 rawTargetPos = target.position + target.forward * 10f; } // 2. 计算与正前方向的夹角 Vector3 dirToRawTarget = (rawTargetPos - transform.position).normalized; float angle = Vector3.Angle(transform.forward, dirToRawTarget); // 3. 如果角度在范围内,才允许平滑转向 if (angle float.Epsilon || Mathf.Abs(x - z) > float.Epsilon || Mathf.Abs(y - z) > float.Epsilon) { Debug.LogWarning("The xyz scales of the Spider are not equal. Please make sure they are. The scale of the spider is defaulted to be the Y scale and a lot of values depend on this scale."); } rb = GetComponent(); //Initialize the two Sphere Casts downRayRadius = downRaySize * getColliderRadius(); float forwardRayRadius = forwardRaySize * getColliderRadius(); downRay = new SphereCast(transform.position, -transform.up, downRayLength * getColliderLength(), downRayRadius, transform, transform); forwardRay = new SphereCast(transform.position, transform.forward, forwardRayLength * getColliderLength(), forwardRayRadius, transform, transform); //Initialize the bodyupLocal as the spiders transform.up parented to the body. Initialize the breathePivot as the body position parented to the spider bodyY = body.transform.InverseTransformDirection(transform.up); bodyZ = body.transform.InverseTransformDirection(transform.forward); bodyCentroid = body.transform.position + getScale() * bodyOffsetHeight * transform.up; bodyDefaultCentroid = transform.InverseTransformPoint(bodyCentroid); } void FixedUpdate() { //** Ground Check **// grdInfo = GroundCheck(); //** Rotation to normal **// float normalAdjustSpeed = (grdInfo.rayType == RayType.ForwardRay) ? forwardNormalAdjustSpeed : groundNormalAdjustSpeed; Vector3 slerpNormal = Vector3.Slerp(transform.up, grdInfo.groundNormal, 0.02f * normalAdjustSpeed); Quaternion goalrotation = getLookRotation(Vector3.ProjectOnPlane(transform.right, slerpNormal), slerpNormal); // Save last Normal for access lastNormal = transform.up; //Apply the rotation to the spider if (Quaternion.Angle(transform.rotation,goalrotation)>Mathf.Epsilon) transform.rotation = goalrotation; // Dont apply gravity if close enough to ground if (grdInfo.distanceToGround > getGravityOffDistance()) { rb.AddForce(-grdInfo.groundNormal * gravityMultiplier * 0.0981f * getScale()); //Important using the groundnormal and not the lerping normal here! } } void Update() { //** Debug **// if (showDebug) drawDebug(); Vector3 Y = body.TransformDirection(bodyY); //Doesnt work the way i want it too! On sphere i go underground. I jiggle around when i go down my centroid moves down to.(Depends on errortolerance of IKSolver) if (legCentroidAdjustment) bodyCentroid = Vector3.Lerp(bodyCentroid, getLegsCentroid(), Time.deltaTime * legCentroidSpeed); else bodyCentroid = getDefaultCentroid(); body.transform.position = bodyCentroid; if (legNormalAdjustment) { Vector3 newNormal = GetLegsPlaneNormal(); //Use Global X for pitch Vector3 X = transform.right; float angleX = Vector3.SignedAngle(Vector3.ProjectOnPlane(Y, X), Vector3.ProjectOnPlane(newNormal, X), X); angleX = Mathf.LerpAngle(0, angleX, Time.deltaTime * legNormalSpeed); body.transform.rotation = Quaternion.AngleAxis(angleX, X) * body.transform.rotation; //Use Local Z for roll. With the above global X for pitch, this avoids any kind of yaw happening. Vector3 Z = body.TransformDirection(bodyZ); float angleZ = Vector3.SignedAngle(Y, Vector3.ProjectOnPlane(newNormal, Z), Z); angleZ = Mathf.LerpAngle(0, angleZ, Time.deltaTime * legNormalSpeed); body.transform.rotation = Quaternion.AngleAxis(angleZ, Z) * body.transform.rotation; } if (breathing) { float t = (Time.time * 2 * Mathf.PI / breathePeriod) % (2 * Mathf.PI); float amplitude = breatheMagnitude * getColliderRadius(); Vector3 direction = body.TransformDirection(bodyY); body.transform.position = bodyCentroid + amplitude * (Mathf.Sin(t) + 1f) * direction; } // Update the moving status if (transform.hasChanged) { isMoving = true; transform.hasChanged = false; } else isMoving = false; } //** Movement methods**// private void move(Vector3 direction, float speed) { // TODO: Make sure direction is on the XZ plane of spider! For this maybe refactor the logic from input from spidercontroller to this function. //Only allow direction vector to have a length of 1 or lower float magnitude = direction.magnitude; if (magnitude > 1) { direction = direction.normalized; magnitude = 1f; } // Scale the magnitude and Clamp to not move more than down ray radius (Makes sure the ground is not lost due to moving too fast) if (direction != Vector3.zero) { float directionDamp = Mathf.Pow(Mathf.Clamp(Vector3.Dot(direction / magnitude, transform.forward), 0, 1), 2); float distance = 0.0004f * speed * magnitude * directionDamp * getScale(); distance = Mathf.Clamp(distance, 0, 0.99f * downRayRadius); direction = distance * (direction / magnitude); } //Slerp from old to new velocity using the acceleration currentVelocity = Vector3.Slerp(currentVelocity, direction, 1f - walkDrag); //Apply the resulting velocity transform.position += currentVelocity; } public void turn(Vector3 goalForward) { //Make sure goalForward is orthogonal to transform up goalForward = Vector3.ProjectOnPlane(goalForward, transform.up).normalized; if (goalForward == Vector3.zero || Vector3.Angle(goalForward, transform.forward) < Mathf.Epsilon) { return; } goalForward = Vector3.ProjectOnPlane(goalForward, transform.up); transform.rotation = Quaternion.RotateTowards(transform.rotation, Quaternion.LookRotation(goalForward, transform.up), turnSpeed); } //** Movement methods for public access**// // It is advised to call these on a fixed update basis. public void walk(Vector3 direction) { if (direction.magnitude < Mathf.Epsilon) return; move(direction, walkSpeed); } public void run(Vector3 direction) { if (direction.magnitude < Mathf.Epsilon) return; move(direction, runSpeed); } //** Ground Check Method **// private groundInfo GroundCheck() { if (groundCheckOn) { if (forwardRay.castRay(out hitInfo, walkableLayer)) { return new groundInfo(true, hitInfo.normal.normalized, Vector3.Distance(transform.TransformPoint(capsuleCollider.center), hitInfo.point) - getColliderRadius(), RayType.ForwardRay); } if (downRay.castRay(out hitInfo, walkableLayer)) { return new groundInfo(true, hitInfo.normal.normalized, Vector3.Distance(transform.TransformPoint(capsuleCollider.center), hitInfo.point) - getColliderRadius(), RayType.DownRay); } } return new groundInfo(false, Vector3.up, float.PositiveInfinity, RayType.None); } //** Helper methods**// /* * Returns the rotation with specified right and up direction * May have to make more error catches here. Whatif not orthogonal? */ private Quaternion getLookRotation(Vector3 right, Vector3 up) { if (up == Vector3.zero || right == Vector3.zero) return Quaternion.identity; // If vectors are parallel return identity float angle = Vector3.Angle(right, up); if (angle == 0 || angle == 180) return Quaternion.identity; Vector3 forward = Vector3.Cross(right, up); return Quaternion.LookRotation(forward, up); } //** Torso adjust methods for more realistic movement **// // Calculate the centroid (center of gravity) given by all end effector positions of the legs private Vector3 getLegsCentroid() { if (legs == null || legs.Length == 0) { Debug.LogError("Cant calculate leg centroid, legs not assigned."); return body.transform.position; } Vector3 defaultCentroid = getDefaultCentroid(); // Calculate the centroid of legs position Vector3 newCentroid = Vector3.zero; float k = 0; for (int i = 0; i < legs.Length; i++) { newCentroid += legs[i].getEndEffector().position; k++; } newCentroid = newCentroid / k; // Offset the calculated centroid Vector3 offset = Vector3.Project(defaultCentroid - getColliderBottomPoint(), transform.up); newCentroid += offset; // Calculate the normal and tangential translation needed Vector3 normalPart = Vector3.Project(newCentroid - defaultCentroid, transform.up); Vector3 tangentPart = Vector3.ProjectOnPlane(newCentroid - defaultCentroid, transform.up); return defaultCentroid + Vector3.Lerp(Vector3.zero, normalPart, legCentroidNormalWeight) + Vector3.Lerp(Vector3.zero, tangentPart, legCentroidTangentWeight); } // Calculate the normal of the plane defined by leg positions, so we know how to rotate the body private Vector3 GetLegsPlaneNormal() { if (legs == null) { Debug.LogError("Cant calculate normal, legs not assigned."); return transform.up; } if (legNormalWeight 2f) return false; // 3. 在敌人前方/在敌人背后但角度差超过 60° 时不允许(和敌人同向的时候,是很小的锐角) float angle = Vector3.Angle(transform.forward, _currentEnemy.forward); if (angle > 60f) return false; // 4. 正在播放暗杀动画时不允许(避免重复触发) if (_animator.AnimationAtTag("Assassin")) return false; return true; } /// /// 处理角色暗杀输入的响应逻辑 /// private void CharacterAssassinInput() { // 不满足暗杀条件时直接返回 if (!CanAssassin()) return; // 检测到 "取出武器/触发暗杀" 输入时执行逻辑 if (GameInputManager.Instance.FinishAttack) { // 1. 随机选取暗杀连招索引 _currentComboIndex = Random.Range( 0, _assassinCombo.TryGetComboMaxCount() ); // 2. 播放对应的暗杀动画 string animationState = _assassinCombo.TryGetOneComboAction(_currentComboIndex); _animator.Play(animationState, 0, 0f); // 3. 获取暗杀动画的命中名称,用于事件传递 string hitName = _assassinCombo.TryGetOneHitName(_currentComboIndex, 0); // 4. 调用事件中心,触发敌人的处决/暗杀事件 GameEventManager.MainInstance.CallEvent( "触发处决", hitName, transform, _currentEnemy ); // 5. 重置连招状态,防止索引越界 ResetComboInfo(); } } #endregion
复制代码 修改之前的正面处决条件,加上角度限制- /// /// 是否允许执行处决攻击 /// private bool CanSpecialAttack() { // 处于 "Finish" 标签动画时,不允许 if (_animator.AnimationAtTag("Finish")) return false; // 没有当前敌人时,不允许 if (_currentEnemy == null) return false; // 当前连招总数小于2时,不允许 if(_currentComboCount < 2) return false; // 在敌人后方或侧面时,不允许(和敌人同向的时候,是很小的锐角,慢慢放大这个角就是侧面,所以小于某个超过90度的角即可,这里取120度) float angle = Vector3.Angle(transform.forward, _currentEnemy.forward); if (angle < 120f) return false; return true; }
复制代码 Bug修复——让处决的Combo索引单独控制
- // 处决执行状态变量 private int _finishComboIndex; // 处决连招动作索引
复制代码- /// /// 处理角色暗杀输入的响应逻辑 /// private void CharacterAssassinInput() { // 不满足暗杀条件时直接返回 if (!CanAssassin()) return; // 检测到 "取出武器/触发暗杀" 输入时执行逻辑 if (GameInputManager.Instance.FinishAttack) { // 1. 随机选取暗杀连招索引 _finishComboIndex = Random.Range( 0, _assassinCombo.TryGetComboMaxCount() ); // 2. 播放对应的暗杀动画 string animationState = _assassinCombo.TryGetOneComboAction(_finishComboIndex); _animator.Play(animationState, 0, 0f); // 3. 获取暗杀动画的命中名称,用于事件传递 string hitName = _assassinCombo.TryGetOneHitName(_finishComboIndex, 0); // 4. 调用事件中心,触发敌人的处决/暗杀事件 GameEventManager.MainInstance.CallEvent( "触发处决", hitName, transform, _currentEnemy ); // 5. 重置连招状态,防止索引越界 ResetComboInfo(); } }
复制代码 region 触发伤害(普通攻击和处决攻击)
- private void TriggerDamage()
复制代码- else { // 同一处决动画期间会触发多次伤害 //处决攻击 // 从处决数据中获取连招伤害相关参数 float damageValue = _finishCombo.TryGetComboDamage(_finishComboIndex); // 调用触发处决伤害事件 GameEventManager.MainInstance.CallEvent("触发处决伤害", damageValue,_currentEnemy); Debug.Log("触发处决伤害"); } #endregion
复制代码- #region 位置同步 /// /// 处决期间玩家位置同步 /// private void MatchPosition() { if (_currentEnemy == null) return; if (!_animator) return; if (_animator.AnimationAtTag("Finish"))//当前在处决动画 { transform.rotation = Quaternion.LookRotation(-_currentEnemy.forward); // 面对敌人 RunningMatch(_finishCombo,_finishComboIndex); } else if (_animator.AnimationAtTag("Assassin"))//当前在普通攻击动画 { transform.rotation = Quaternion.LookRotation(_currentEnemy.forward); // 背对敌人 RunningMatch(_assassinCombo, _finishComboIndex); } } private void RunningMatch(CharacterComboSO combo,int comboIndex, float startTime = 0f, float endTime = 0.01f) { if (!_animator.isMatchingTarget && !_animator.IsInTransition(0))//当前不在匹配,同时不处于过渡状态 { _animator.MatchTarget( _currentEnemy.position + (-transform.forward * combo.TryGetComboPositionOffset(comboIndex)), Quaternion.identity, AvatarTarget.Body, new MatchTargetWeightMask(Vector3.one, 0f), startTime, endTime ); } } #endregion
复制代码- /// /// 重置连招状态(索引、冷却时间) /// private void ResetComboInfo() { _currentComboIndex = 0; _maxColdTime = 0f; _hitIndex = 0; _finishComboIndex = 0; }
复制代码- /// /// 处理角色处决攻击的输入响应逻辑 /// private void CharacterFinishAttackInput() { // 不满足处决攻击条件时,直接返回 if (!CanSpecialAttack()) return; // 检测到处决输入时,执行处决流程 if (GameInputManager.Instance.FinishAttack) { // 1. 随机选取处决连招索引 _finishComboIndex = Random.Range(0, _finishCombo.TryGetComboMaxCount()); // 2. 播放对应的处决动画 string finishAnim = _finishCombo.TryGetOneComboAction(_finishComboIndex); _animator.Play(finishAnim); // 3. 调用事件中心,触发敌人的处决事件 string hitName = _finishCombo.TryGetOneHitName(_finishComboIndex, 0); GameEventManager.MainInstance.CallEvent( "触发处决", hitName, transform, _currentEnemy ); // 4. 调用定时器事件:更新连招状态信息,防止索引越界 TimerManager.Instance.TryGetOneTimer( _finishCombo.TryGetColdTime(_finishComboIndex), //这里原先写的是固定的0.5f UpdateComboInfo); // 5. 重置连招状态,防止索引越界 ResetComboInfo(); } }
复制代码 敌人AI
这个还没看明白,时间不太够,后面做
Foot IK
方案:实际走的是斜面(不渲染),footik识别的是阶梯本身(渲染)
优点:相机平滑,角色走路平滑,只对脚部进行ik
随机待机动画系统
- using UnityEngine;[RequireComponent(typeof(Animator))]public class RandomIdleAnimation : MonoBehaviour{ [SerializeField] private int IdleNum = 9; private Animator animator; private float idleTimeCounter = 0f; private bool isInIdleState = false; private const float idleThreshold = 5f; private const string idleStateName = "Idle"; private const string blendTreeParameter = "IdleType"; void Start() { animator = GetComponent(); } void Update() { // 检查当前是否处于Idle状态 bool isCurrentStateIdle = IsInIdleState(); if (isCurrentStateIdle) { idleTimeCounter += Time.deltaTime; isInIdleState = true; if (idleTimeCounter >= idleThreshold) { RandomizeIdleAnimation(); idleTimeCounter = 0f; } } else { // 离开Idle状态时重置 idleTimeCounter = 0f; isInIdleState = false; } } // 检查是否处于Idle状态 private bool IsInIdleState() { if (animator.layerCount == 0) return false; AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0); // 使用IsName方法检查状态名称,这比哈希值比较更可靠 return stateInfo.IsName(idleStateName) && !animator.IsInTransition(0); } private void RandomizeIdleAnimation() { int randomInt = Random.Range(0, IdleNum); animator.SetFloat(blendTreeParameter, randomInt); }}
复制代码 演示效果(倍速处理)

开启物理碰撞交互
- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ColliderInteraction : MonoBehaviour{ private void OnControllerColliderHit(ControllerColliderHit hit) { if(hit.transform.TryGetComponent(out Rigidbody rigidbody)) { // 只要接触到的碰撞体是rigidbody,就给它施加一个向前的力 rigidbody.AddForce(transform.forward * 20f,ForceMode.Force); } }}
复制代码
加上手部扶墙的程序动画刚好完美推门(不过要记得的删去非法角度的判断)
注意,开启物理碰撞之后,之前的扶墙物体需要把Kinematic打开,让他固定住不受外力,不然墙倒了
不过推门还是尽量做单独的animator动画比较好,用扶墙的程序动画看着还行,但还是用固定的动画不容易出戏
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |