类蜘蛛侠+刺客信条暗杀动作系统开发日志

新版输入系统------斜向移动变快问题解决

生成对应的input管理脚本

Day 01------角色移动基类

CharacterMovementControlBase

csharp 复制代码
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<CharacterController>();
            _animator = GetComponent<Animator>();
        }

        protected virtual void Start()
        {
            _fallOutDeltaTime = _fallOutTime;
        }

        private void Update()
        {
            SetCharacterGravity();
            UpdateCharacterGravity();
        }

        /// <summary>
        /// 地面检测方法
        /// </summary>
        /// <returns>返回角色是否在地面的布尔值</returns>
        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
            );
        }

        /// <summary>
        /// 根据是否在地面设置对应的角色重力逻辑
        /// </summary>
        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;
                    // 重力公式累积垂直速度
                }
            }
        }

        /// <summary>
        /// 更新角色垂直方向移动(应用重力效果)
        /// </summary>
        private void UpdateCharacterGravity()
        {
            //这里只处理 y 轴重力
            // x/z 由其他移动逻辑控制
            Vector3 _characterVerticalDirection = new Vector3(0, _characterVerticalVelocity, 0);

            // 通过 CharacterController 应用y轴移动
            _controller.Move(_characterVerticalDirection * Time.deltaTime);
        }

        /// <summary>
        /// 斜坡方向重置:检测角色是否在坡上移动,防止下坡速度过快导致异常
        /// </summary>
        /// <param name="moveDirection">原始移动方向</param>
        /// <returns>适配斜坡后的移动方向</returns>
        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

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Spiderman.Movement
{
    public class PlayerMovementControl : CharacterMovementControlBase
    {

    }
}

Day02 带碰撞体相机脚本

GameInputManager

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameInputManager : MonoBehaviour
{
    private GameInputAction _gameInputAction;

    public Vector2 Movement => _gameInputAction.Player.Movement.ReadValue<Vector2>();
    public Vector2 CameraLook => _gameInputAction.Player.CameraLook.ReadValue<Vector2>();


    private void Awake()
    {
        _gameInputAction ??= new GameInputAction(); //是空的,则创建新的实例
    }

    private void OnEnable()
    {
        _gameInputAction.Enable();
    }
    private void OnDisable()
    {
        _gameInputAction.Disable();
    }
}

TP_CameraControl

csharp 复制代码
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<GameInputManager>();
        //隐藏光标
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }

    private void Update()
    {
        // 检测到按下ESC键或鼠标左键点击窗口,则切换相机输入状态
        HandleCameraInputToggle();

        // 只有在相机输入启用时才处理输入
        if (_cameraInputEnabled)
        {
            // 实时处理相机输入
            CameraInput();
        }
    }


    private void LateUpdate()
    {
        // 更新相机旋转
        UpdateCameraRotation();
        // 更新相机位置
        UpdateCameraPosition();
    }

    /// <summary>
    /// 处理相机输入,获取并处理上下查看等输入,限制垂直角度范围
    /// </summary>
    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
        );

    }

    /// <summary>
    /// 更新相机旋转
    /// </summary>
    private void UpdateCameraRotation()
    {
        var targetRotation = new Vector3(_input.x, _input.y, 0);
        _cameraRotation = Vector3.SmoothDamp(
            _cameraRotation,
            targetRotation,
            ref smoothDampVelocity,
            _smoothSpeed
        );

        //更新相机欧拉角
        transform.eulerAngles = _cameraRotation;

    }

    /// <summary>
    /// 更新相机位置
    /// </summary>
    private void UpdateCameraPosition()
    {
        var newPos = _lookTarget.position 
            + Vector3.back * _cameraDistance 
            + Vector3.up * _cameraHeight;
        // 平滑位置移动
        transform.position = Vector3.Lerp(
            transform.position,
            newPos,
            _DistancemoothTime
        );
    }

    /// <summary>
    /// 处理相机输入状态切换
    /// </summary>
    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继承于单例模式

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using GGG.Tool.Singleton;

public class GameInputManager : Singleton<GameInputManager>
{
    private GameInputAction _gameInputAction;

    public Vector2 Movement => _gameInputAction.Player.Movement.ReadValue<Vector2>();
    public Vector2 CameraLook => _gameInputAction.Player.CameraLook.ReadValue<Vector2>();


    private void Awake()
    {
        base.Awake();
        _gameInputAction ??= new GameInputAction(); //是空的,则创建新的实例
    }

    private void OnEnable()
    {
        _gameInputAction.Enable();
    }
    private void OnDisable()
    {
        _gameInputAction.Disable();
    }
}

TP_CameraControl

csharp 复制代码
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();
    }

    /// <summary>
    /// 处理相机输入,获取并处理上下查看等输入,限制垂直角度范围
    /// </summary>
    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
        );

    }

    /// <summary>
    /// 更新相机旋转
    /// </summary>
    private void UpdateCameraRotation()
    {
        var targetRotation = new Vector3(_input.x, _input.y, 0);
        _cameraRotation = Vector3.SmoothDamp(
            _cameraRotation,
            targetRotation,
            ref smoothDampVelocity,
            _smoothSpeed
        );

        //更新相机欧拉角
        transform.eulerAngles = _cameraRotation;

    }

    /// <summary>
    /// 更新相机位置
    /// </summary>
    private void UpdateCameraPosition()
    {
        var newPos = _lookTarget.position 
            + Vector3.back * _cameraDistance 
            + Vector3.up * _cameraHeight;
        // 平滑位置移动
        transform.position = Vector3.Lerp(
            transform.position,
            newPos,
            DevelopmentToos.UnTetheredLerp(_distanceSmoothTime)
        );
    }

    /// <summary>
    /// 处理相机输入状态切换
    /// </summary>
    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

csharp 复制代码
        protected Vector3 _moveDirection; // 角色移动方向
csharp 复制代码
        /// <summary>
        /// 脚本控制animator的根运动
        /// </summary>
        protected virtual void OnAnimatorMove()
        {
            _animator.ApplyBuiltinRootMotion();
            UpdateCharacterMoveDirection(_animator.deltaPosition);
        }
csharp 复制代码
        /// <summary>
        /// 更新角色水平移动方向------绕y轴旋转
        /// </summary>
        protected void UpdateCharacterMoveDirection(Vector3 direction)
        {
            _moveDirection = SlopResetDirection(direction);
            _controller.Move(_moveDirection * Time.deltaTime);
        }

GameInputManager

csharp 复制代码
    public bool Run => _gameInputAction.Player.Run.triggered;

PlayerMovementControl

csharp 复制代码
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();
        }

        /// <summary>
        /// 角色旋转控制
        /// </summary>
        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
                                        );
            }
        }

        /// <summary>
        /// 更新动画
        /// </summary>
        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

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using GGG.Tool;
using GGG.Tool.Singleton;

public class GameEventManager : SingletonNonMono<GameEventManager>
{
    // 事件接口
    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<T> : IEventHelp
    {
        // 存储事件委托
        private event Action<T> _action;

        // 构造函数,初始化时绑定初始事件逻辑
        public EventHelp(Action<T> action)
        {
            // 首次实例化时赋值,仅执行这一次初始绑定
            _action = action;
        }

        // 增加事件注册的方法,将新的事件逻辑追加到委托中
        public void AddCall(Action<T> action)
        {
            _action += action;
        }

        // 调用事件的方法,若有绑定逻辑则执行
        public void Call(T value)
        {
            _action?.Invoke(value);
        }

        // 移除事件的方法,将指定事件逻辑从委托中移除
        public void Remove(Action<T> action)
        {
            _action -= action;
        }
    }

    private class EventHelp<T1, T2> : IEventHelp
    {
        // 存储事件委托
        private event Action<T1, T2> _action;

        // 构造函数,初始化时绑定初始事件逻辑
        public EventHelp(Action<T1, T2> action)
        {
            // 首次实例化时赋值,仅执行这一次初始绑定
            _action = action;
        }

        // 增加事件注册的方法,将新的事件逻辑追加到委托中
        public void AddCall(Action<T1, T2> action)
        {
            _action += action;
        }

        // 调用事件的方法,若有绑定逻辑则执行
        public void Call(T1 value1, T2 value2)
        {
            _action?.Invoke(value1, value2);
        }

        // 移除事件的方法,将指定事件逻辑从委托中移除
        public void Remove(Action<T1, T2> action)
        {
            _action -= action;
        }
    }

    /// <summary>
    /// 事件中心,用于管理事件注册、调用
    /// </summary>
    private Dictionary<string, IEventHelp> _eventCenter = new Dictionary<string, IEventHelp>();

    /// <summary>
    /// 添加事件监听
    /// </summary>
    /// <param name="eventName">事件名称</param>
    /// <param name="action">回调函数</param>
    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<T>(string eventName, Action<T> action)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T>)?.AddCall(action);
        }
        else
        {
            // 如果事件中心不存在叫这个名字的事件,new一个然后添加
            _eventCenter.Add(eventName, new EventHelp<T>(action));
        }
    }
    public void AddEventListening<T1, T2>(string eventName, Action<T1, T2> action)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T1, T2>)?.AddCall(action);
        }
        else
        {
            // 如果事件中心不存在叫这个名字的事件,new一个然后添加
            _eventCenter.Add(eventName, new EventHelp<T1, T2>(action));
        }
    }

    /// <summary>
    /// 调用事件
    /// </summary>
    /// <param name="eventName">事件名称</param>
    public void CallEvent(string eventName)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp)?.Call();
        }
        else
        {
            LogEventNotFound(eventName, "调用");
        }
    }

    public void CallEvent<T>(string eventName, T value)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T>)?.Call(value);
        }
        else
        {
            LogEventNotFound(eventName, "调用");
        }
    }

    public void CallEvent<T1, T2>(string eventName, T1 value, T2 value1)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T1, T2>)?.Call(value, value1);
        }
        else
        {
            LogEventNotFound(eventName, "调用");
        }
    }


    /// <summary>
    /// 移除事件监听
    /// </summary>
    /// <param name="eventName">事件名称</param>
    /// <param name="action">要移除的事件回调</param>
    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<T>(string eventName, Action<T> action)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T>)?.Remove(action);
        }
        else
        {
            LogEventNotFound(eventName, "移除");
        }
    }
    public void RemoveEvent<T1, T2>(string eventName, Action<T1, T2> action)
    {
        if (_eventCenter.TryGetValue(eventName, out var eventHelp))
        {
            (eventHelp as EventHelp<T1, T2>)?.Remove(action);
        }
        else
        {
            LogEventNotFound(eventName, "移除");
        }
    }

    /// <summary>
    /// 事件未找到时的统一日志输出
    /// </summary>
    /// <param name="eventName">事件名称</param>
    /// <param name="operation">操作类型(移除、调用)</param>
    private void LogEventNotFound(string eventName, string operation)
    {
        DevelopmentTools.WTF($"当前未找到{eventName}的事件,无法{operation}");
    }

}

Day05 AnimationStringToHash

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 动画参数哈希值管理类,用于统一存储Animator参数的哈希值,避免重复计算
/// </summary>
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

csharp 复制代码
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 计时器状态枚举,描述计时器不同工作阶段
/// </summary>
public enum TimerState
{
    NOTWORKERE, // 没有工作(初始或重置后状态)
    WORKERING,  // 工作中(计时进行时)
    DONE        // 工作完成(计时结束)
}

/// <summary>
/// 游戏计时器类,用于管理计时逻辑,支持启动计时、更新计时、获取状态、重置等功能
/// </summary>
public class GameTimer
{
    // 计时时长(剩余计时时间)
    private float _startTime;
    // 计时结束后要执行的任务(Action 委托)
    private Action _task;
    // 是否停止当前计时器标记
    private bool _isStopTimer;
    // 当前计时器的状态
    private TimerState _timerState;

    /// <summary>
    /// 构造函数,初始化时重置计时器
    /// </summary>
    public GameTimer()
    {
        ResetTimer();
    }

    /// <summary>
    /// 1. 开始计时
    /// </summary>
    /// <param name="time">要计时的时长</param>
    /// <param name="task">计时结束后要执行的任务(Action 委托)</param>
    public void StartTimer(float time, Action task)
    {
        _startTime = time;
        _task = task;
        _isStopTimer = false;
        _timerState = TimerState.WORKERING;
    }

    /// <summary>
    /// 2. 更新计时器(通常在 MonoBehaviour 的 Update 里调用,驱动计时逻辑)
    /// </summary>
    public void UpdateTimer()
    {
        // 如果标记为停止,直接返回,不执行计时更新
        if (_isStopTimer)
            return;

        // 递减计时时间
        _startTime -= Time.deltaTime;
        // 计时时间小于 0,说明计时结束
        if (_startTime < 0)
        {
            // 安全调用任务(如果任务不为 null 才执行)
            _task?.Invoke();
            // 更新状态为已完成
            _timerState = TimerState.DONE;
            // 标记为停止,后续不再继续计时更新
            _isStopTimer = true;
        }
    }

    /// <summary>
    /// 3. 获取当前计时器的状态
    /// </summary>
    /// <returns>返回 TimerState 枚举值,代表当前计时器状态</returns>
    public TimerState GetTimerState() => _timerState;

    /// <summary>
    /// 4. 重置计时器,恢复到初始状态
    /// </summary>
    public void ResetTimer()
    {
        _startTime = 0f;
        _task = null;
        _isStopTimer = true;
        _timerState = TimerState.NOTWORKERE;
    }
}

TimerManager

csharp 复制代码
using System;
using System.Collections;
using System.Collections.Generic;
using GGG.Tool;
using GGG.Tool.Singleton;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// 计时器管理器,采用单例模式,负责管理空闲计时器队列和工作中计时器列表,
/// 实现计时器的初始化、分配、回收及更新逻辑
/// </summary>
public class TimerManager : Singleton<TimerManager>
{
    #region 私有字段
    // 初始最大计时器数量,在 Inspector 中配置
    [SerializeField] private int _initMaxTimerCount;

    // 空闲计时器队列,存储可用的 GameTimer
    private Queue<GameTimer> _notWorkingTimer = new Queue<GameTimer>();
    // 工作中计时器列表,存储正在计时的 GameTimer
    private List<GameTimer> _workingTimer = new List<GameTimer>();
    #endregion

    #region 生命周期与初始化
    protected override void Awake()
    {
        base.Awake();
        InitTimerManager();
    }

    /// <summary>
    /// 初始化计时器管理器,创建初始数量的空闲计时器
    /// </summary>
    private void InitTimerManager()
    {
        for (int i = 0; i < _initMaxTimerCount; i++)
        {
            CreateTimerInternal();
        }
    }

    /// <summary>
    /// 内部创建计时器并加入空闲队列的方法
    /// </summary>
    private void CreateTimerInternal()
    {
        var timer = new GameTimer();
        _notWorkingTimer.Enqueue(timer);
    }
    #endregion

    #region 计时器分配与回收
    /// <summary>
    /// 尝试获取一个计时器,用于执行定时任务
    /// </summary>
    /// <param name="time">计时时长</param>
    /// <param name="task">计时结束后执行的任务</param>
    public void TryGetOneTimer(float time, Action task)
    {
        // 若空闲队列为空,额外创建一个计时器
        if (_notWorkingTimer.Count == 0)
        {
            CreateTimerInternal();
        }

        var timer = _notWorkingTimer.Dequeue();
        timer.StartTimer(time, task);
        _workingTimer.Add(timer);
    }

    /// <summary>
    /// 回收计时器(可在 GameTimer 完成任务时调用,这里逻辑已内联在更新里,也可扩展外部调用)
    /// 注:当前通过 UpdateWorkingTimer 自动回收,此方法可留作扩展
    /// </summary>
    /// <param name="timer">要回收的计时器</param>
    private void RecycleTimer(GameTimer timer)
    {
        timer.ResetTimer();
        _notWorkingTimer.Enqueue(timer);
        _workingTimer.Remove(timer);
    }
    #endregion

    #region 计时器更新逻辑
    private void Update()
    {
        UpdateWorkingTimer();
    }

    /// <summary>
    /// 更新工作中计时器的状态,处理计时推进和完成后的回收
    /// </summary>
    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 脚部拖尾特效的控制------奔跑时启用

csharp 复制代码
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

csharp 复制代码
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;
  
    private void OnAnimatorIK(int layerIndex)
    {
        //四肢
        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);

        //头部
        if (Head_IKPoint != null)
            IKHeadControl(Head_IKPoint);
    }


    /// <summary>
    /// 头部 IK 控制(平滑转向 + 角度限制)
    /// </summary>
    /// <param name="target">要看的对象</param>
    /// <param name="turnSpeed">插值速度</param>
    /// <param name="maxAngle">最大允许夹角(度数)</param>
    private void IKHeadControl(Transform target,
                               float turnSpeed = 3f,
                               float maxAngle = 60f)
    {
        // 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 <= maxAngle)
        {
            _currentLookTarget = Vector3.Lerp(_currentLookTarget, rawTargetPos,
                                              turnSpeed * Time.deltaTime);
        }
        // 否则保持上一帧的 _currentLookTarget 不变(即不更新)

        // 4. 设置 Animator
        _animator.SetLookAtWeight(1f);
        _animator.SetLookAtPosition(_currentLookTarget);

        // 5. Debug
        Debug.DrawLine(transform.position, _currentLookTarget, Color.red);
        Debug.DrawRay(target.position, target.forward * 10f, Color.blue);
    }

    // 缓存"当前正在看的点"
    private Vector3 _currentLookTarget;

    /// <summary>
    /// 四肢IK控制
    /// </summary>
    /// <param name="ControlPosition"></param>
    /// <param name="target"></param>
    public void IKControl(AvatarIKGoal ControlPosition, Transform target)
    {
        _animator.SetIKPositionWeight(ControlPosition, 1);
        _animator.SetIKPosition(ControlPosition, target.position);
        _animator.SetIKRotationWeight(ControlPosition, 1);
        _animator.SetIKRotation(ControlPosition, target.rotation);
    }
}

Day09 角色切换------Spiderman To Spider

蜘蛛控制脚本------Rigging Animation

Spider

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Raycasting;

/*
 * This class represents the actual spider. It is responsible for "glueing" it to the surfaces around it. This is accomplished by
 * creating a fake gravitational force in the direction of the surface normal it is standing on. The surface normal is determined
 * by spherical raycasting downwards, as well as forwards for wall-climbing.
 * 
 * The torso of the spider will move and rotate depending on the height of the referenced legs to mimic "spinal movement".
 * 
 * The spider does not move on its own. Therefore a controller should call the provided functions walk() and turn() for
 * the desired control.
 */

[DefaultExecutionOrder(0)] // Any controller of this spider should have default execution -1
public class Spider : MonoBehaviour {

    private Rigidbody rb;

    [Header("Debug")]
    public bool showDebug;

    [Header("Movement")]
    [Range(1, 10)]
    public float walkSpeed;
    [Range(1, 10)]
    public float runSpeed;
    [Range(1, 5)]
    public float turnSpeed;
    [Range(0.001f, 1)]
    public float walkDrag;

    [Header("Grounding")]
    public CapsuleCollider capsuleCollider;
    [Range(1, 10)]
    public float gravityMultiplier;
    [Range(1, 10)]
    public float groundNormalAdjustSpeed;
    [Range(1, 10)]
    public float forwardNormalAdjustSpeed;
    public LayerMask walkableLayer;
    [Range(0, 1)]
    public float gravityOffDistance;

    [Header("IK Legs")]
    public Transform body;
    public IKChain[] legs;

    [Header("Body Offset Height")]
    public float bodyOffsetHeight;

    [Header("Leg Centroid")]
    public bool legCentroidAdjustment;
    [Range(0, 100)]
    public float legCentroidSpeed;
    [Range(0, 1)]
    public float legCentroidNormalWeight;
    [Range(0, 1)]
    public float legCentroidTangentWeight;

    [Header("Leg Normal")]
    public bool legNormalAdjustment;
    [Range(0, 100)]
    public float legNormalSpeed;
    [Range(0, 1)]
    public float legNormalWeight;

    private Vector3 bodyY;
    private Vector3 bodyZ;

    [Header("Breathing")]
    public bool breathing;
    [Range(0.01f, 20)]
    public float breathePeriod;
    [Range(0, 1)]
    public float breatheMagnitude;

    [Header("Ray Adjustments")]
    [Range(0.0f, 1.0f)]
    public float forwardRayLength;
    [Range(0.0f, 1.0f)]
    public float downRayLength;
    [Range(0.1f, 1.0f)]
    public float forwardRaySize = 0.66f;
    [Range(0.1f, 1.0f)]
    public float downRaySize = 0.9f;
    private float downRayRadius;

    private Vector3 currentVelocity;
    private bool isMoving = true;
    private bool groundCheckOn = true;

    private Vector3 lastNormal;
    private Vector3 bodyDefaultCentroid;
    private Vector3 bodyCentroid;

    private SphereCast downRay, forwardRay;
    private RaycastHit hitInfo;

    private enum RayType { None, ForwardRay, DownRay };
    private struct groundInfo {
        public bool isGrounded;
        public Vector3 groundNormal;
        public float distanceToGround;
        public RayType rayType;

        public groundInfo(bool isGrd, Vector3 normal, float dist, RayType m_rayType) {
            isGrounded = isGrd;
            groundNormal = normal;
            distanceToGround = dist;
            rayType = m_rayType;
        }
    }

    private groundInfo grdInfo;

    private void Awake() {

        //Make sure the scale is uniform, since otherwise lossy scale will not be accurate.
        float x = transform.localScale.x; float y = transform.localScale.y; float z = transform.localScale.z;
        if (Mathf.Abs(x - y) > 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<Rigidbody>();

        //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 <= 0f) return transform.up;

        Vector3 newNormal = transform.up;
        Vector3 toEnd;
        Vector3 currentTangent;

        for (int i = 0; i < legs.Length; i++) {
            //normal += legWeight * legs[i].getTarget().normal;
            toEnd = legs[i].getEndEffector().position - transform.position;
            currentTangent = Vector3.ProjectOnPlane(toEnd, transform.up);

            if (currentTangent == Vector3.zero) continue; // Actually here we would have a 90degree rotation but there is no choice of a tangent.

            newNormal = Quaternion.Lerp(Quaternion.identity, Quaternion.FromToRotation(currentTangent, toEnd), legNormalWeight) * newNormal;
        }
        return newNormal;
    }


    //** Getters **//
    public float getScale() {
        return transform.lossyScale.y;
    }

    public bool getIsMoving() {
        return isMoving;
    }

    public Vector3 getCurrentVelocityPerSecond() {
        return currentVelocity / Time.fixedDeltaTime;
    }

    public Vector3 getCurrentVelocityPerFixedFrame() {
        return currentVelocity;
    }
    public Vector3 getGroundNormal() {
        return grdInfo.groundNormal;
    }

    public Vector3 getLastNormal() {
        return lastNormal;
    }

    public float getColliderRadius() {
        return getScale() * capsuleCollider.radius;
    }

    public float getNonScaledColliderRadius() {
        return capsuleCollider.radius;
    }

    public float getColliderLength() {
        return getScale() * capsuleCollider.height;
    }

    public Vector3 getColliderCenter() {
        return transform.TransformPoint(capsuleCollider.center);
    }

    public Vector3 getColliderBottomPoint() {
        return transform.TransformPoint(capsuleCollider.center - capsuleCollider.radius * new Vector3(0, 1, 0));
    }

    public Vector3 getDefaultCentroid() {
        return transform.TransformPoint(bodyDefaultCentroid);
    }

    public float getGravityOffDistance() {
        return gravityOffDistance * getColliderRadius();
    }

    //** Setters **//
    public void setGroundcheck(bool b) {
        groundCheckOn = b;
    }

    //** Debug Methods **//
    private void drawDebug() {
        //Draw the two Sphere Rays
        downRay.draw(Color.green);
        forwardRay.draw(Color.blue);

        //Draw the Gravity off distance
        Vector3 borderpoint = getColliderBottomPoint();
        Debug.DrawLine(borderpoint, borderpoint + getGravityOffDistance() * -transform.up, Color.magenta);

        //Draw the current transform.up and the bodys current Y orientation
        Debug.DrawLine(transform.position, transform.position + 2f * getColliderRadius() * transform.up, new Color(1, 0.5f, 0, 1));
        Debug.DrawLine(transform.position, transform.position + 2f * getColliderRadius() * body.TransformDirection(bodyY), Color.blue);

        //Draw the Centroids 
        DebugShapes.DrawPoint(getDefaultCentroid(), Color.magenta, 0.1f);
        DebugShapes.DrawPoint(getLegsCentroid(), Color.red, 0.1f);
        DebugShapes.DrawPoint(getColliderBottomPoint(), Color.cyan, 0.1f);
    }

#if UNITY_EDITOR
    void OnDrawGizmosSelected() {

        if (!showDebug) return;
        if (UnityEditor.EditorApplication.isPlaying) return;
        if (!UnityEditor.Selection.Contains(transform.gameObject)) return;

        Awake();
        drawDebug();
    }
#endif

}

SpiderController

csharp 复制代码
using UnityEngine;
using System.Collections;
using Raycasting;

/*
 * This class needs a reference to the Spider class and calls the walk and turn functions depending on player input.
 * So in essence, this class translates player input to spider movement. The input direction is relative to a camera and so a 
 * reference to one is needed.
 */

[DefaultExecutionOrder(-1)] // Make sure the players input movement is applied before the spider itself will do a ground check and possibly add gravity
public class SpiderController : MonoBehaviour {

    public Spider spider;

    [Header("Camera")]
    public SmoothCamera smoothCam;

    void FixedUpdate() {
        //** Movement **//
        Vector3 input = getInput();

        if (Input.GetKey(KeyCode.LeftShift)) spider.run(input);
        else spider.walk(input);

        Quaternion tempCamTargetRotation = smoothCam.getCamTargetRotation();
        Vector3 tempCamTargetPosition = smoothCam.getCamTargetPosition();
        spider.turn(input);
        smoothCam.setTargetRotation(tempCamTargetRotation);
        smoothCam.setTargetPosition(tempCamTargetPosition);
    }

    void Update() {
        //Hold down Space to deactivate ground checking. The spider will fall while space is hold.
        spider.setGroundcheck(!Input.GetKey(KeyCode.Space));
    }

    private Vector3 getInput() {
        Vector3 up = spider.transform.up;
        Vector3 right = spider.transform.right;
        Vector3 input = Vector3.ProjectOnPlane(smoothCam.getCameraTarget().forward, up).normalized * Input.GetAxis("Vertical") + (Vector3.ProjectOnPlane(smoothCam.getCameraTarget().right, up).normalized * Input.GetAxis("Horizontal"));
        Quaternion fromTo = Quaternion.AngleAxis(Vector3.SignedAngle(up, spider.getGroundNormal(), right), right);
        input = fromTo * input;
        float magnitude = input.magnitude;
        return (magnitude <= 1) ? input : input /= magnitude;
    }
}

IKStepManager

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/*
 * This class holds references to each IKStepper of the legs and manages the stepping of them.
 * So instead of each leg managing its stepping on its own, this class acts as the brain and decides when each leg should step.
 * It uses the step checking function in the IKStepper to determine if a step is wanted for a leg, and then handles it by calling
 * the step function in the IKStepper when the time is right to step.
 */

[DefaultExecutionOrder(+1)] // Make sure all the stepping logic is called after the IK was solved in each IKChain
public class IKStepManager : MonoBehaviour {
    public bool printDebugLogs;

    public Spider spider;

    public enum StepMode { AlternatingTetrapodGait, QueueWait, QueueNoWait }
    /*
     * Note the following about the stepping modes:
     * 
     * Alternating Tetrapod Gait:   This mode is inspired by a real life spider walk.
     *                              The legs are assigned one of two groups, A or B.
     *                              Then a timer switches between these groups on the timeinterval "stepTime".
     *                              Every group only has a specific frame at which stepping is allowed in each interval
     *                              With this, legs in the same group will always step at the same time if they need to step,
     *                              and will never step while the other group is.
     *                              If dynamic step time is selected, the average of each legs dyanamic step time is used.
     *                              This mode does not use the asynchronicity specified in each legs, since the asyncronicty is already given
     *                              by the groups.
     *                
     * Queue Wait:  This mode stores the legs that want to step in a queue and performs the stepping in the order of the queue.
     *              This mode will always prioritize the next leg in the queue and will wait until it is able to step.
     *              This however can and will inhibit the other legs from stepping if the waiting period is too long.
     *              Unlike the above mode, this mode uses the asyncronicity defined in each leg to determine whether a leg is 
     *              allowed to step or not. Each leg will be inhibited to step as long as these async legs are stepping.
     *  
     * Queue No Wait:   This mode is analog to the above with the exception of not waiting for each next leg in the queue.
     *                  The legs will still be iterated through in queue order but if a leg is not able to step,
     *                  we still continue iterating and perform steps for the following legs if they are able to.
     *                  So to be more specific, this is not a queue in the usual sense. It is a list of legs that need stepping,
     *                  which will be iterated through in order and if the k-th leg is allowed to step, it will step
     *                  and the k-th element of this list will be removed.
     */

    [Header("Step Mode")]
    public StepMode stepMode;

    //Order is important here as this is the order stepCheck is performed, giving the first elements more priority in case of a same frame step desire
    [Header("Legs for Queue Modes")]
    public List<IKStepper> ikSteppers;
    private List<IKStepper> stepQueue;
    private Dictionary<int, bool> waitingForStep;

    [Header("Legs for Gait Mode")]
    public List<IKStepper> gaitGroupA;
    public List<IKStepper> gaitGroupB;
    private List<IKStepper> currentGaitGroup;
    private float nextSwitchTime;

    [Header("Steptime")]
    public bool dynamicStepTime = true;
    public float stepTimePerVelocity;
    [Range(0, 1.0f)]
    public float maxStepTime;

    public enum GaitStepForcing { NoForcing, ForceIfOneLegSteps, ForceAlways }
    [Header("Debug")]
    public GaitStepForcing gaitStepForcing;

    private void Awake() {

        /* Queue Mode Initialization */

        stepQueue = new List<IKStepper>();

        // Remove all inactive IKSteppers
        int k = 0;
        foreach (var ikStepper in ikSteppers.ToArray()) {
            if (!ikStepper.allowedTargetManipulationAccess()) ikSteppers.RemoveAt(k);
            else k++;
        }

        // Initialize the hash map for step waiting with false
        waitingForStep = new Dictionary<int, bool>();
        foreach (var ikStepper in ikSteppers) {
            waitingForStep.Add(ikStepper.GetInstanceID(), false);
        }

        /* Alternating Tetrapod Gait Initialization */

        // Remove all inactive IKSteppers from the Groups
        k = 0;
        foreach (var ikStepper in gaitGroupA.ToArray()) {
            if (!ikStepper.allowedTargetManipulationAccess()) gaitGroupA.RemoveAt(k);
            else k++;
        }
        k = 0;
        foreach (var ikStepper in gaitGroupB.ToArray()) {
            if (!ikStepper.allowedTargetManipulationAccess()) gaitGroupB.RemoveAt(k);
            else k++;
        }

        // Start with Group A and set switch time to step time
        currentGaitGroup = gaitGroupA;
        nextSwitchTime = maxStepTime;
    }

    private void LateUpdate() {
        if (stepMode == StepMode.AlternatingTetrapodGait) AlternatingTetrapodGait();
        else QueueStepMode();
    }

    private void QueueStepMode() {

        /* Perform the step checks for all legs not already waiting to step.
         * If a step is needed, enqueue them.
         */
        foreach (var ikStepper in ikSteppers) {

            // Check if Leg isnt already waiting for step.
            if (waitingForStep[ikStepper.GetInstanceID()] == true) continue;

            //Now perform check if a step is needed and if so enqueue the element
            if (ikStepper.stepCheck()) {
                stepQueue.Add(ikStepper);
                waitingForStep[ikStepper.GetInstanceID()] = true;
                if (printDebugLogs) Debug.Log(ikStepper.name + " is enqueued to step at queue position " + stepQueue.Count);
            }
        }

        if (printDebugLogs) printQueue();

        /* Iterate through the step queue in order and check if legs are eligible to step.
         * If legs are able to step, let them step.
         * If not, we have two cases:   If the current mode selected is the QueueWait mode, then stop the iteration.
         *                              If the current mode selected is the QueueNoWait mode, simply continue with the iteration.
         */
        int k = 0;
        foreach (var ikStepper in stepQueue.ToArray()) {
            if (ikStepper.allowedToStep()) {
                ikStepper.getIKChain().unpauseSolving();
                ikStepper.step(calculateStepTime(ikStepper));
                // Remove the stepping leg from the list:
                waitingForStep[ikStepper.GetInstanceID()] = false;
                stepQueue.RemoveAt(k);
                if (printDebugLogs) Debug.Log(ikStepper.name + " was allowed to step and is thus removed.");
            }
            else {
                if (printDebugLogs) Debug.Log(ikStepper.name + " is not allowed to step.");

                // Stop iteration here if Queue Wait mode is selected
                if (stepMode == StepMode.QueueWait) {
                    if (printDebugLogs) Debug.Log("Wait selected, thus stepping ends for this frame.");
                    break;
                }
                k++; // Increment k by one here since i did not remove the current element from the list.
            }
        }

        /* Iterate through all the legs that are still in queue, and therefore werent allowed to step.
         * For them pause the IK solving while they are waiting.
         */
        foreach (var ikStepper in stepQueue) {
            ikStepper.getIKChain().pauseSolving();
        }
    }

    private void AlternatingTetrapodGait() {

        // If the next switch time isnt reached yet, do nothing.
        if (Time.time < nextSwitchTime) return;


        /* Since switch time is reached, switch groups and set new switch time.
         * Note that in the case of dynamic step time, it would not make sense to have each leg assigned its own step time
         * since i want the stepping to be completed at the same time in order to switch to next group again.
         * Thus, i simply calculate the average step time of the current group and use it for all legs.
         * TODO: Add a random offset to the steptime of each leg to imitate nature more closely and use the max value as the next switch time
         */
        currentGaitGroup = (currentGaitGroup == gaitGroupA) ? gaitGroupB : gaitGroupA;
        float stepTime = calculateAverageStepTime(currentGaitGroup);
        nextSwitchTime = Time.time + stepTime;

        if (printDebugLogs) {
            string text = ((currentGaitGroup == gaitGroupA) ? "Group: A" : "Group B") + " StepTime: " + stepTime;
            Debug.Log(text);
        }

        /* Now perform the stepping for the current gait group.
         * A leg in the gait group will only step if a step is needed.
         * However, for debug purposes depending on which force mode is selected the other legs can be forced to step anyway.
         */
        if (gaitStepForcing == GaitStepForcing.ForceAlways) {
            foreach (var ikStepper in currentGaitGroup) ikStepper.step(stepTime);
        }
        else if (gaitStepForcing == GaitStepForcing.ForceIfOneLegSteps) {
            bool b = false;
            foreach (var ikStepper in currentGaitGroup) {
                b = b || ikStepper.stepCheck();
                if (b == true) break;
            }
            if (b == true) foreach (var ikStepper in currentGaitGroup) ikStepper.step(stepTime);
        }
        else {
            foreach (var ikStepper in currentGaitGroup) {
                if (ikStepper.stepCheck()) ikStepper.step(stepTime);
            }
        }
    }

    private float calculateStepTime(IKStepper ikStepper) {
        if (dynamicStepTime) {
            float k = stepTimePerVelocity * spider.getScale(); // At velocity=1, this is the steptime
            float velocityMagnitude = ikStepper.getIKChain().getEndeffectorVelocityPerSecond().magnitude;
            return (velocityMagnitude == 0) ? maxStepTime : Mathf.Clamp(k / velocityMagnitude, 0, maxStepTime);
        }
        else return maxStepTime;
    }

    private float calculateAverageStepTime(List<IKStepper> ikSteppers) {
        if (dynamicStepTime) {
            float stepTime = 0;
            foreach (var ikStepper in ikSteppers) {
                stepTime += calculateStepTime(ikStepper);
            }
            return stepTime / ikSteppers.Count;
        }
        else return maxStepTime;
    }

    private void printQueue() {
        if (stepQueue == null) return;
        string queueText = "[";
        if (stepQueue.Count != 0) {
            foreach (var ikStepper in stepQueue) {
                queueText += ikStepper.name + ", ";
            }
            queueText = queueText.Substring(0, queueText.Length - 2);
        }
        queueText += "]";
        Debug.Log("Queue: " + queueText);
    }
}

切换角色

CharacterSwitcher

csharp 复制代码
using UnityEngine;
using System.Collections;

public class CharacterSwitcher : MonoBehaviour
{
    [Header("角色设置")]
    public GameObject character1;
    public GameObject character2;

    [Header("切换按键")]
    public KeyCode switchKey = KeyCode.Tab;

    [Header("当前状态")]
    public bool isCharacter1Active = true;

    [Header("角色2专用相机")]
    public Camera camera2;

    [Header("切换延迟")]
    public float switchDelay = 0.5f;   // 等待时间

    private bool isSwitching = false;    // 正在等待切换

    private void Start()
    {
        if (character1 == null || character2 == null)
        {
            Debug.LogError("请在Inspector中指定两个角色的GameObject!");
            return;
        }

        character1.SetActive(isCharacter1Active);
        character2.SetActive(!isCharacter1Active);

        if (camera2 != null)
            camera2.gameObject.SetActive(!isCharacter1Active);
    }

    private void Update()
    {
        if (Input.GetKeyDown(switchKey) && !isSwitching)
            SwitchCharacter();
    }

    /* 供外部脚本调用的接口同样延迟 */
    public void SwitchCharacter()
    {
        if (character1 == null || character2 == null || isSwitching)
            return;

        isSwitching = true;

        /* 立即冻结当前角色,防止继续移动 */
        FreezeMovement(GetActiveCharacter());

        /* 延迟真正切换 */
        StartCoroutine(DelayedSwitch());
    }

    public void SwitchToSpecificCharacter(bool switchToCharacter1)
    {
        if (isCharacter1Active == switchToCharacter1 || isSwitching)
            return;

        isSwitching = true;
        FreezeMovement(GetActiveCharacter());
        StartCoroutine(DelayedSwitch(switchToCharacter1));
    }

    /* 0.5 秒后真正切换 */
    private IEnumerator DelayedSwitch(bool? targetState = null)
    {
        yield return new WaitForSeconds(switchDelay);

        bool nextState = targetState ?? !isCharacter1Active;

        isCharacter1Active = nextState;
        character1.SetActive(isCharacter1Active);
        character2.SetActive(!isCharacter1Active);

        if (camera2 != null)
            camera2.gameObject.SetActive(!isCharacter1Active);

        Debug.Log($"切换到: {(isCharacter1Active ? "角色1" : "角色2")}");

        isSwitching = false;
    }

    /* 简单冻结:把 Rigidbody 设为 Kinematic,关闭 CharacterController */
    private void FreezeMovement(GameObject go)
    {
        if (go.TryGetComponent(out Rigidbody rb))
        {
            rb.velocity = Vector3.zero;
            rb.angularVelocity = Vector3.zero;
            rb.isKinematic = true;
        }

        if (go.TryGetComponent(out CharacterController cc))
            cc.enabled = false;
    }

    public GameObject GetActiveCharacter()
    {
        return isCharacter1Active ? character1 : character2;
    }
}

Day10 对象池管理------音频管理

对象池管理

GamePoolManager

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using GGG.Tool.Singleton;
using GGG.Tool;

public class GamePoolManager : Singleton<GamePoolManager>
{
    // 1. 缓存配置项类
    [System.Serializable]
    private class PoolItem
    {
        public string ItemName;      // 对象名称,用于标识
        public GameObject Item;      // 要缓存的游戏对象
        public int InitMaxCount;     // 初始最大缓存数量
    }

    // 2. 缓存配置列表
    [SerializeField]
    private List<PoolItem> _configPoolItem = new List<PoolItem>();

    private Dictionary<string, Queue<GameObject>> _poolCenter = new Dictionary<string, Queue<GameObject>>();
    //对象池父对象
    private GameObject _poolItemParent;

    private void Start()
    {
        _poolItemParent = new GameObject("PoolItemParent");
        //放到GamePoolManager的子级,统一管理
        _poolItemParent.transform.SetParent(this.transform);
        InitPool();
    }

    private void InitPool()
    {
        // 1. 我们判断外部配置是不是空的。
        if (_configPoolItem.Count == 0)
            return;

        for (var i = 0; i < _configPoolItem.Count; i++)
        {
            for (int j = 0; j < _configPoolItem[i].InitMaxCount; j++)
            {
                var item = Instantiate(_configPoolItem[i].Item);
                // 将对象设置为不可见
                item.SetActive(false);
                // 设置为PoolItemParent的子物体
                item.transform.SetParent(_poolItemParent.transform);
                // 判断池子中有没有存在这个对象的
                if (!_poolCenter.ContainsKey(_configPoolItem[i].ItemName))
                {
                    // 如果当前对象池中没有对应名称的池子,那么我们需要创建一个
                    _poolCenter.Add(
                        _configPoolItem[i].ItemName,
                        new Queue<GameObject>()
                    );
                    _poolCenter[_configPoolItem[i].ItemName].Enqueue(item);
                }
                else
                {
                    _poolCenter[_configPoolItem[i].ItemName].Enqueue(item);
                }
            }
        }
        Debug.Log(_poolCenter.Count);
        Debug.Log(_poolCenter["ATKSound"].Count);
    }

    /// <summary>
    /// 从对象池中尝试获取指定名称的对象,并设置其位置和旋转信息
    /// </summary>
    /// <param name="name">要获取的对象池名称(用于标识特定类型的对象)</param>
    /// <param name="position">对象激活后的世界坐标位置</param>
    /// <param name="rotation">对象激活后的世界空间旋转角度</param>
    public void TryGetPoolItem(string name, Vector3 position, Quaternion rotation)
    {
        // 检查对象池容器中是否存在指定名称的对象池
        if (_poolCenter.ContainsKey(name))
        {
            // 从对应名称的对象池队列中取出队首的对象(出队操作)
            var item = _poolCenter[name].Dequeue();

            // 设置对象的位置信息
            item.transform.position = position;

            // 设置对象的旋转信息
            item.transform.rotation = rotation;

            // 激活对象
            item.SetActive(true);

            // 将使用后的对象重新放回队列尾部(实现对象复用,避免频繁创建销毁)
            _poolCenter[name].Enqueue(item);
        }
        else
        {
            // 当请求的对象池不存在时
            Debug.Log(message: $"当前请求的对象池{name}不存在");
        }
    }

    /// <summary>
    /// 从对象池中尝试获取指定名称的对象(重载方法,不指定位置和旋转)
    /// </summary>
    /// <param name="name">要获取的对象池名称</param>
    /// <returns>获取到的游戏对象,若对象池不存在则返回null</returns>
    public GameObject TryGetPoolItem(string name)
    {
        // 检查对象池容器中是否存在指定名称的对象池
        if (_poolCenter.ContainsKey(name))
        {
            // 从对应名称的对象池队列中取出队首的对象
            var item = _poolCenter[name].Dequeue();

            // 激活对象
            item.SetActive(true);

            // 将使用后的对象重新放回队列尾部
            _poolCenter[name].Enqueue(item);

            return item;
        }

        // 当请求的对象池不存在时
        Debug.Log(message: $"当前请求的对象池{name}不存在");
        return null;
    }
}

新建一个音频预制体,作为对象池的物品

对象池物品基类

PoolItemBase

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


/// <summary>
/// 对象池物品接口
/// </summary>
public interface IPoolItem
{
    void Spawn();   // 当对象从对象池取出、激活时执行的逻辑,比如初始化状态、显示特效等
    void Recycle(); // 当对象回收到对象池时执行的逻辑,比如重置状态、隐藏对象等
}

/// <summary>
/// 对象池物品基类,继承自MonoBehaviour并实现IPoolItem接口
/// 作为具体对象池物品(如子弹、道具等)的抽象父类,封装通用逻辑
/// </summary>
public abstract class PoolItemBase : MonoBehaviour, IPoolItem
{

    private void OnEnable()
    {
        Spawn();
    }

    private void OnDisable()
    {
        Recycle();
    }

    public virtual void Spawn()
    {

    }

    public virtual void Recycle()
    {

    }
}

对象池中的物品------音频

PoolItemSound

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 声音类型枚举
/// </summary>
public enum SoundType
{
    ATK,    // 攻击
    HIT,    // 受击
    BLOCK,  // 格挡
    FOOT    // 脚步
}

/// <summary>
/// 声音对象池物品类
/// 用于管理音效播放对象的激活、回收,复用AudioSource
/// </summary>
public class PoolItemSound : PoolItemBase
{
    // 音频源
    private AudioSource _audioSource;
    [SerializeField] SoundType _soundType;

    private void Awake()
    {
        _audioSource = GetComponent<AudioSource>();
    }

    /// <summary>
    /// 音效对象从对象池取出时的逻辑
    /// </summary>
    public override void Spawn()
    {
        //PlaySound(_soundType); 
    }


    private void PlaySound(SoundType _soundType)
    {

    }


}

音频ScriptableObject

AssetsSoundSO

csharp 复制代码
using System.Collections.Generic;
using UnityEngine;

namespace Spiderman.Assets
{
    // 自定义创建Asset的菜单,方便在Unity编辑器右键创建该资源
    [CreateAssetMenu(fileName = "Sound", menuName = "CreateActions/Assets/Sound", order = 0)]
    public class AssetsSoundSO : ScriptableObject
    {
        // 序列化的内部类,用于配置声音类型和对应的音频片段数组
        [System.Serializable]
        private class SoundConfig
        {
            public SoundType SoundType;     // 声音类型,需有对应的枚举定义(代码里未展示,需确保存在)
            public AudioClip[] AudioClips;  // 该类型声音对应的音频片段数组
        }

        // 声音配置列表,可在Inspector中配置不同类型声音及其音频片段
        [SerializeField]
        private List<SoundConfig> _configSound = new List<SoundConfig>();
    }
}

然后在AssetsSoundSO中加入函数:根据声音类型获取对应的音频片段

csharp 复制代码
        /// <summary>
        /// 根据声音类型获取对应的音频片段
        /// </summary>
        /// <param name="_soundType"></param>
        /// <returns></returns>
        public AudioClip GetAudioClip(SoundType _soundType)
        {
            if(_configSound == null || _configSound.Count == 0)
                return null;

            switch (_soundType)
            {
                //随机返回对应类型的音频片段
                case SoundType.ATK:
                    return _configSound[0].AudioClips[Random.Range(0, _configSound[0].AudioClips.Length)];
                case SoundType.HIT:
                    return _configSound[1].AudioClips[Random.Range(0, _configSound[1].AudioClips.Length)];
                case SoundType.BLOCK:
                    return _configSound[2].AudioClips[Random.Range(0, _configSound[2].AudioClips.Length)];
                case SoundType.FOOT:
                    return _configSound[3].AudioClips[Random.Range(0, _configSound[3].AudioClips.Length)];
            }

            return null;
        }

音频预制体的播放逻辑

在PoolItemSound中加入音效播放及其回收逻辑

csharp 复制代码
using Spiderman.Assets;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 声音类型枚举
/// </summary>
public enum SoundType
{
    ATK,    // 攻击
    HIT,    // 受击
    BLOCK,  // 格挡
    FOOT    // 脚步
}

/// <summary>
/// 声音对象池物品类
/// 用于管理音效播放对象的激活、回收,复用AudioSource
/// </summary>
public class PoolItemSound : PoolItemBase
{
    // 音频源
    private AudioSource _audioSource;
    [SerializeField] SoundType _soundType;
    [SerializeField] AssetsSoundSO _soundAssets;

    private void Awake()
    {
        _audioSource = GetComponent<AudioSource>();
    }

    /// <summary>
    /// 音效对象从对象池取出
    /// </summary>
    public override void Spawn()
    {
        //被激活的时候播放音效
        PlaySound(); 
    }

    /// <summary>
    /// 播放音效
    /// </summary>
    private void PlaySound()
    {
        _audioSource.clip = _soundAssets.GetAudioClip(_soundType);
        _audioSource.Play();
        // 回收音效对象
        StartRecycle();
    }

    /// <summary>
    /// 音效对象回收
    /// </summary>
    private void StartRecycle()
    {
        // 延迟0.3秒后停止播放
        TimerManager.MainInstance.TryGetOneTimer(0.3f, DisableSelf);
    }

    /// <summary>
    /// 定时任务:停止播放
    /// </summary>
    private void DisableSelf()
    {
        _audioSource.Stop();
        gameObject.SetActive(false);
    }


}

然后在声音预制体中拖入该脚本PoolItemSound

注意勾选Assets和对应的Type

Day11 Animation Event动画事件

音频播放与Animation联动起来

脚本AnimationEvent挂在角色身上

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Spiderman.Event
{
    public class AnimationEvent : MonoBehaviour
    {
        private void PlaySound(string _soundName)
        {
            //选取对象池中的音效对象
            GamePoolManager.MainInstance.TryGetPoolItem(_soundName,transform.position,Quaternion.identity);
        }
    }

}

在关键帧加入事件调用PlaySound函数:

目前进度:

Day12 踩在不同材质地面上的脚步声音

相关推荐
Buling_09 小时前
游戏中的设计模式——第一篇 设计模式简介
游戏·设计模式
lingran__15 小时前
C语言制作扫雷游戏(拓展版赋源码)
c语言·算法·游戏
D1555408805815 小时前
电竞护航小程序成品搭建三角洲行动护航小程序开发俱乐部点单小程序成品游戏派单小程序定制
游戏·小程序
nightunderblackcat1 天前
新手向:Python制作贪吃蛇游戏(Pygame)
python·游戏·pygame
王家视频教程图书馆1 天前
2025年最新 unityHub游戏引擎开发2d手机游戏和桌面游戏教程
游戏·unity·游戏引擎
点金石游戏出海1 天前
每周资讯 | 中国游戏市场将在2025年突破500亿美元;《恋与深空》收入突破50亿元
游戏·网易游戏·海外市场·恋与深空·手游市场
一点都不方女士2 天前
《无畏契约》游戏报错“缺少DirectX”?5种解决方案(附DirectX修复工具)
windows·游戏·microsoft·动态链接库·directx·运行库
wanhengidc2 天前
云手机可以息屏挂手游吗?
运维·网络·安全·游戏·智能手机
wanhengidc2 天前
云手机的空间会占用本地内存吗
科技·游戏·智能手机
wanhengidc2 天前
网页版的云手机都有哪些优势?
运维·网络·安全·游戏·智能手机