[Unity] ⑤第一人称视角控制器——耐力值系统、门的交互(学习笔记)

Posted by Rezzo on Wednesday, February 23, 2022

[Unity] ④第一人称视角控制器——脚步声、血量系统(学习笔记)

耐力值系统

需 求 功 能
耐力值系统 开/关 bool useStamina
耐力条 float maxStamina(最大耐力值)float currentStamina(当前耐力值)
每秒消耗耐力 float staminaMultiplier = 5f
开始回复耐力前等待时间 float timeBeforeStaminaRengeStarts = 1.5f
单次回复耐力值 float staminaValueIncrement = 2f
单次回复耐力后等待时间 float staminaTimeIncrement = 0.1f
回复耐力协程 Coroutine regeneratingStamina
在耐力值改变时 UI Text同步改变 public static Action OnStaminaChange

冲刺 -> 消耗耐力值 -> 停止冲刺 -> (等待 timeBeforeStaminaRengeStarts 秒 -> 每 staminaTimeIncrement 秒回复 staminaValueIncrement 点耐力)

()表示回复耐力的协程做的事。

(回复耐力)时 -> 冲刺 -> 停止协程 -> 停止冲刺或耐力值归零 -> (回复耐力)

和血量系统做法几乎一模一样。

[Header("Stamina Paramenters")]
    // 最大耐力
    [SerializeField] private float maxStamina = 100f;
    // 每秒消耗多少耐力
    [SerializeField] private float staminaMultiplier = 5f;
    // 耐力回复前,等待时间
    [SerializeField] private float timeBeforeStaminaRengeStarts = 1.5f;
    // 每次单位耐力回复量
    [SerializeField] private float staminaValueIncrement = 2f;
    // 单位耐力回复间隔时间
    [SerializeField] private float staminaTimeIncrement = 0.1f;
    // 当前耐力
    private float currentStamina;
    private Coroutine regeneratingStamina;
    // 耐力变化时,调用
    public static Action<float> OnStaminaChange;
void Update()
    {
        if (CanMove)
        {
        //...
            if (useStamina)
                HandleStamina();
            ApplyFinalMovement();
        }
    }
private void HandleStamina()
    {
        // 冲刺状态,且有移动输入,处理耐力
        if (IsSprinting && moveInput != Vector2.zero) {}
        
        // 耐力值不满,且没有冲刺,且没有耐力回复协程在进行
        if (currentStamina < maxStamina && !IsSprinting && regeneratingStamina == null)
            // 开启协程
            regeneratingStamina = StartCoroutine(RegenerateStamina());
    }

一、冲刺时,先检查是否有耐力回复协程

            // 如果耐力回复协程开启,中断
            if (regeneratingStamina != null)
            {
                StopCoroutine(regeneratingStamina);
                regeneratingStamina = null;
            }

二、如果没有,那就正常消耗耐力值,并将改变的耐力值作为参数传给UI

            // 每秒消耗耐力
            currentStamina -= staminaMultiplier * Time.deltaTime;
            if (currentStamina < 0f)
                currentStamina = 0f;
            // 之后在UI的脚本里添加委托
            OnStaminaChange?.Invoke(currentStamina);

三、耐力值用完的时候,关闭冲刺功能

            // 耐力值归零,禁止使用冲刺
            if (currentStamina <= 0f)
                canSprint = false;

完整:

   private void HandleStamina()
    {
        // 冲刺状态,且有移动输入,处理耐力
        if (IsSprinting && moveInput != Vector2.zero)
        {
            // 如果耐力回复协程开启,中断
            if (regeneratingStamina != null)
            {
                StopCoroutine(regeneratingStamina);
                regeneratingStamina = null;
            }

            currentStamina -= staminaMultiplier * Time.deltaTime;
            if (currentStamina < 0f)
                currentStamina = 0f;
            OnStaminaChange?.Invoke(currentStamina);

            // 耐力值归零,禁止使用冲刺
            if (currentStamina <= 0f)
                canSprint = false;
        }
        // 耐力值不满,且没有冲刺,且耐力回复未开启
        if (currentStamina < maxStamina && !IsSprinting && regeneratingStamina == null)
            regeneratingStamina = StartCoroutine(RegenerateStamina());
    }

协程:

private IEnumerator RegenerateStamina()
    {
        yield return new WaitForSeconds(timeBeforeStaminaRengeStarts);

        WaitForSeconds timeToWait = new WaitForSeconds(staminaTimeIncrement);
        while (currentStamina < maxStamina)
        {
            // 大于0,可以使用冲刺
            if (currentStamina > 0f)
                canSprint = true;

            currentStamina += staminaValueIncrement;

            if (currentStamina > maxStamina)
                currentStamina = maxStamina;
            // 改变的耐力值
            OnStaminaChange?.Invoke(currentStamina);
            yield return timeToWait;
        }
        // 耐力回复完毕,引用置空
        regeneratingStamina = null;
    }

门的交互

门的交互,主要是动画机(Animator)的相关问题。

image.png

image.png

可以在商店里找门的资源,也可以自己在Blender之类的软件里做出来导入。

有个注意的点:

Pivot Point就是物体Transform.position所在的点,在这个模式下,箭头表示的是“物体的原点在世界坐标系中的坐标”,可以在模型制作软件中自己设定。

而Center模式下,箭头表示的是“包围物体的最小包围盒(AABB)的中心点”,是unity算出来的模型中心位置。 image.png

image.png

Center模式:

image.png

Pivot模式:

image.png

门开关的动画,要在Pivot模式下制作。

动画机动画制作

在「Project」 -> 右键 -> 「Create」 -> 「Animator Controller」:

image.png 并将其拖动到物体的「Inspector」。

image.png

然后在「Animation」工作区,Create 一个 Animation Clip,选择储存位置。 image.png

image.png

新建的「Animation Clip」命名为 “OpenIn” ,表示从门内侧开启的动画。

image.png 开启录制, 点击Add event,在第0帧,添加一个event。 image.png

在第60帧,也就是动画最后一帧,设置为门完全开启的状态:

image.png

image.png

image.png

又一个要注意的点:

image.png

image.png

假如,我录制了一段移动的动画。

并且我把这个动画,复用在其他物体上,比如下面那个方块。当我播放下面那个方块的动画时,会发生什么?

image.png

没错,瞬移到上面的位置,播放了一段完全相同的动画。

image.png

所以,需要创建一个父物体,将门作为他的子物体,然后录制本地坐标的动画,如果录制的时候,是相对于世界坐标,当一个动画应用到多个物体上的时候,就会出问题。


image.png

录制五种状态的动画:

动画的循环关掉,开关门动画不需要循环。

image.png

从内部面朝门时的开关动画。

image.png image.png

从外部面朝门时的开关动画。

image.png image.png

默认关闭状态的动画,什么都不用录,状态是正常关闭状态就行。

image.png

门的交互代码

变量 含义
bool isOpen 门的开关状态
Animator animator 门的动画机
public class Door : Interactable
{
    private bool isOpen = false;
    private bool canBeInteractedWith = true;
    private Animator animator;

    public override void OnFocus()
    {
        animator = GetComponent<Animator>();
    }

    public override void OnInteract()
    {
        if (canBeInteractedWith)
        {
            isOpen = !isOpen;

            // 门面朝方向的世界坐标
            Vector3 doorTransformDirection = transform.TransformDirection(Vector3.down);
            // 玩家面朝方向
            Vector3 playerTransformDirection = 
            PlayerController.instance.transform.TransformDirection(Vector3.forward);
            
            // 向量点积,cosθ 为正,方向基本相同在(0, 90),反之相反
            float dot = Vector3.Dot(doorTransformDirection, playerTransformDirection);

            // 动画设置
            animator.SetFloat("dot", dot);
            animator.SetBool("isOpen", isOpen);

        }
    }
}

动画机中添加两个变量:

image.png

dot: 是两个向量的点集,值为正,则两个向量方向基本相同,在(0, 90)度间,为0,向量垂直,为负,向量方向反向,在(90, 180)。

doorTransformDirection 就是将门本地正方向,转换为世界坐标。playerTransformDirection 将玩家的正方向,转换为世界坐标,计算在世界坐标中的方向是否一致。

在「Player Controller」 脚本中,将自己的实例创建一个公共静态变量。 image.png

动画状态机

从默认关闭状态到开启:

image.png

Reset设置,取消勾选 退出时间,添加两个转换条件:

当门被玩家变为 isOpen = True 开启状态,且当 dot > 0(玩家方向和门方向基本一致),判断为玩家在门内,所以向外开,播放「OpenIn」,从内向外打开的动画。

image.png

而从内打开的状态,回到对应的关闭状态,条件只需要是 false 就够了,不用判断方向,因为已经打开了,关闭动画唯一确定。

image.png image.png

从关闭动画,到默认关闭状态,不需要设置,默认即可。

image.png image.png

另一边,从外开关门的动画同理,只是dot的判断改为 dot < 0。

自动关门

通过添加一个协程,可以完成自动关门的操作。

玩家与门交互后,开启一个协程,每隔一段时间,检查玩家和门的距离。

Vector3.Distance(transform.position,
            PlayerController.instance.transform.position) > 3f
public class Door : Interactable
{
    private bool isOpen = false;
    // 动画过程中,禁止交互
    private bool canBeInteractedWith = true;
    private Animator animator;
    private WaitForSeconds detectionInterval = new WaitForSeconds(3);

    public override void OnFocus()
    {
        animator = GetComponent<Animator>();
    }

    public override void OnInteract()
    {
        if (canBeInteractedWith)
        {
            isOpen = !isOpen;
            Vector3 doorTransformDirection = transform.TransformDirection(Vector3.down);
            Vector3 playerTransformDirection = PlayerController.instance.transform.TransformDirection(Vector3.forward);
            float dot = Vector3.Dot(doorTransformDirection, playerTransformDirection);
            animator.SetFloat("dot", dot);
            animator.SetBool("isOpen", isOpen);
        //
            StartCoroutine(AutoClose());
        //
        }
    }
    private IEnumerator AutoClose()
    {
        // Debug.Log("开启协程");
        while (isOpen)
        {
            yield return detectionInterval;

            if(Vector3.Distance(transform.position,
            PlayerController.instance.transform.position) > 3f)
            {
                isOpen = false;
                animator.SetFloat("dot", 0f);
                animator.SetBool("isOpen", isOpen);
            }
        }
        // Debug.Log("退出协程");
    }

    public override void OnLoseFocus()
    {

    }
    private void Animator_LockInteraction()
    {
        // Debug.Log("锁定交互");
        canBeInteractedWith = false;
    }
    private void Animator_UnlockInteraction()
    {
        // Debug.Log("开启交互");
        canBeInteractedWith = true;
    }
}

记得前面添加的事件吗?

image.png image.png image.png

通过播放动画时,执行事件,来禁止玩家在动画过程中再次与门交互。

又因为协程的循环条件是(isOpen = true),所以,在玩家主动关门后,协程就会直接运行完毕。玩家反复开关门不会运行超过2个以上的协程。