Unity Tutorial - Adventure Game

本文是Unity冒险游戏教程的概述,涵盖了玩家控制、物品管理、交互系统和游戏状态管理。教程强调了Unity中关键概念的运用,如EventSystem、Animator、ScriptableObject以及委托、事件和多态。在交互系统部分,介绍了条件、反应、交互对象的设计,并通过自定义Inspector简化工作流程。游戏状态管理部分,探讨了如何在场景切换时保持游戏数据。
摘要由CSDN通过智能技术生成

Unity Tutorial - Adventure Game

标签(空格分隔): unity unity_tutorial


  • 写在前面:个人觉得,这个教程是官方教程里面的一个分水岭,教程中涉及了实际 unity 项目开发中几个特别重要的方面:AnimationScriptableObjectEditor Scripting ,以及 C# 中的委托、事件、多态、 Lambda 表达式都会涉及到,所以难度和之前几个会有比较大的一个跨度,不过这是好事,如果教程都是差不多难度的,那也没意思。这篇总结会比较长,同时也尽可能多地深入一下各个概念,尝试用自己的话说出来,那才是自己的知识。

01 - Player Control

和往常一样,万丈高楼从地起,我们从 player 的移动构建起,这个游戏和以往的游戏风格不一样,不需要打怪,是一个自己在 scene 中寻找线索,找 NPC 提供信息道具,达成一定条件即可过关的游戏。总结一下我们需要在 player 移动的时候做到以及兼顾到什么

  • 1.通过点击 scene 中的位置,使 player 向该点移动

  • 2.如果点击的是可交互的 NPC 或者物件,需要 player 走到 interactionLocation ,同时交互的时候 player 不可接受输入

  • 3.player 移动的过程中,animation 要求平滑过渡

要做到上面的事情,我们需要用到下面的方法途径

Build a click to move animated character using:
•EventSystem
•NavMesh
•Animator
•prefabs

这里主要讲 EventSystem 和 Animator 。

  • EventSystem
    想要和整个场景交互,我们需要使用 EventSystem 。使用他有三个要素:

    1. send events
    2. receive events
    3. Manage events

    首先我们使用 Physics Raycaster component 作为一个光线投射器,将它附着在 camera 中,每当有事件触发激活它的时候,它会从 cramer 发射出一条射线,来击中沿途的第一个属于指定图层的 3D Object ,如果该物体实现了事件接口,那么还可以向该物体传递信息。为此,我们需要为整个 SecurityRoom 添加一个事件触发器(Event Trigger)并指定事件类型为 Pointer Click 。这时将目光放到事件触发时该做什么,当鼠标点击屏幕时,Pointer Click 事件被触发,同时传入 BaseEventData 到回调方法中,通过将 BaseEventData 转型为 PointerEventData 可以访问由刚刚设置好的全局里面唯一一个 Raycaster 投射出的射线击中的目标信息 PointerEventData.pointerCurrentRaycast 。此时,再通过 NavMesh.SamplePosition 采样 NavMesh 中一定范围内最近的点来作为 AI 的目的点,让 NavMeshAgent 自动寻路。详情参考脚本。

  • Animator
    再次提到 Animator 状态机,这次比 Surival Shooter 中的要复杂一些,这里主要有三类状态: Idle, Walking, xTake ,xTake 是指各种高度尝试捡东西的状态。在三种类型之间的状态切换并不复杂,复杂在于 Walking 状态中的 WalkingBlendTreeBlendTree 这里我们第一次遇到,和一个状态与另一个完全不同状态之间的 Transition 不同,BlendTree 要求需要混合的几种状态之间必须在某些性质和运动时机上相似,比如本例中的,停顿、行走和奔跑就是可以对齐的,因为它们都有一个共性就是脚会着地。按照教程把三个动画添加到 BlendTree 之后,取消 Automate Thresholds ,并点击 Compute Thresholds 选择 speed ,这意味着,unity 会根据你所选择的动画片段的 root motions 来计算各个动画的 speed 进而决定在混合树中的阈值。值得注意的是,这里还需要把 IdleWalking 两个状态 Tag 为 Locomotion 用作后面判断交互是否完成。

EventSystem 和 Animator 先说到这里,更多深入的东西我们留到日后专题研究,下面来分析 player 移动的脚本。关于整个 PlayerMovement.cs 脚本逻辑,首先需要明确几件事:

  • NavMeshAgent 的速度是由 Animator 动画人物的速度来决定,这个动画任务的速度是由 Animator.deltaPosition 计算出来的,它的意思是 获取上一帧根骨骼动画的增量位置 ,除上 Time.deltaTime 即可得到动画的速度。

  • 整个移动过程分为两个部分,一个部分是进入 NavMeshAgent.StoppingDistance 之前,该部分直接由 NavMeshAgent 来负责,另一部分是进入 NavMeshAgent.StoppingDistance 之后,该部分是我们自己来实现,而这部分里面又细分成进入真正停止的的距离之前的 Slowing,以及停止 Stopping 两部分。

移动过程图解

  • 为获取目标点 DestinationPosition 的位置信息需要使用 Event Trigger 来触发,为此需要在 Hierarchy 中的 SecurityRoom 中添加事件触发器 Pointer Click ,然后再为该事件触发器添加相应的函数。之后的交互事件同样是使用 Event Trigger 实现。

  • 在交互事件的触发的过程中,我们不希望接受任何别的输入,于是乎,这里需要用到 Coroutine 来处理 handleInput 使得只有当 animator 处于标记为 Locomotion 的状态(Idle 以及 Walking),才能够接受新的输入。

明确完脚本的基本逻辑之后,来仔细探索脚本中的实现

public class PlayerMovement : MonoBehaviour {

    public Animator animator;
    public NavMeshAgent agent;
    public float inputHoldDelay = 0.5f;         
    public float turnSpeedThreshold = 0.5f;
    public float speedDampTime = 0.1f;
    public float slowingSpeed = 0.175f;
    public float turnSmoothing = 15f;

    private Interactable currentInteractable;
    private WaitForSeconds inputHoldWait;                  
    private Vector3 destinationPosition;                   // 目标点
    private bool handleInput = true;                       // 是否允许输入


    private const float stopDistanceProportion = 0.1f;     // 停止的距离比例
    private const float navMeshSampleDistance = 4f;        // 采样点范围


    private readonly int hashSpeedPara = Animator.StringToHash("Speed"); 
    private readonly int hashLocomotionTag = Animator.StringToHash("Locomotion");

    private void Start()
    {
        // 我们将使用脚本控制 player 的旋转,所以应该禁止 AI 自动转向 
        agent.updateRotation = false;

        inputHoldWait = new WaitForSeconds (inputHoldDelay);

        // 设置开始的终点为当前位置
        destinationPosition = transform.position;
    }


    // 用于修改根运动处理动画移动的回调,该回调将在每帧调用
    private void OnAnimatiorMove()
    {
        // 使用 Animator 里的速度来决定 AI 实际的速度
        agent.velocity = animator.deltaPosition / Time.deltaTime;
    }


    private void Update()
    {
        // 如果路径仍在计算且没有算好,直接跳过该帧
        if (agent.pathPending) 
        {
            return;
        }

        // 储存起 player 想要到达的速度
        float speed = agent.desiredVelocity.magnitude;

        // 当 AI 离目标处小于需要停止的距离的十分之一时,停止
        if (agent.remainingDistance <= agent.stoppingDistance * stopDistanceProportion)
        {
            Stopping (out speed);
        } 

        // 当 AI 走进应该需要停下来的范围时,减速
        else if (agent.remainingDistance <= agent.stoppingDistance) 
        {
            Slowing (out speed, agent.remainingDistance);
        }

        // 当 AI 的速度大于起始速度的阈值时,行动
        else if (speed > turnSpeedThreshold) 
        {
            Moving ();
        }

        // 以 speedDampTime 的速率来更新 animator 的速度
        animator.SetFloat (hashSpeedPara, speed, speedDampTime, Time.deltaTime);

//      if (transform.position == destinationPosition)
//          agent.Stop();
//      else {
   
//          Moving ();
//          animator.SetFloat (hashSpeedPara, agent.desiredVelocity.magnitude, speedDampTime, Time.deltaTime);
//      }
    }


    // 仅当 player 走进确切需要停下来的范围调用
    private void Stopping(out float speed)
    {
        agent.Stop ();
        transform.position = destinationPosition;
        speed = 0f;

        if (currentInteractable) 
        {
            // 让 player 正对着交互画面
            transform.rotation = currentInteractable.interactionLocation.rotation;

            // 执行交互并保证交互内容只执行一次
            currentInteractable.Interact ();
            currentInteractable = null;

            // 等待交互完成,防止交互时,player 进行移动
            StartCoroutine (WaitForInteraction ());
        }
    }


    // 当 player 走进 AI 中定义需要停下来的范围时调用
    private void Slowing(out float speed, float distanceToDestination)
    {
        // 这里不需要劳烦 AI 帮我们走到最终的位置,我们自己来
        agent.Stop ();

        // 计算目前位置到终点的距离与应该停止的距离之间的比值
        float proportionalDistance = 1f - distanceToDestination / agent.stoppingDistance;

        // 按照上面计算出的比值进行插值减速
        speed = Mathf.Lerp (slowingSpeed, 0f, proportionalDistance);

        // 如果存在可交互的人或物,则面向他
        Quaternion targetRotation = currentInteractable ? currentInteractable.interactionLocation.rotation : transform.rotation;

         // Interpolate the player's rotation between itself and the target rotation based on how close to the destination the player is.
        transform.rotation = Quaternion.Lerp (transform.rotation, targetRotation, proportionalDistance);

        // Move the player towards the destination by an amount based on the slowing speed. 
        transform.position = Vector3.MoveTowards (transform.position, destinationPosition, slowingSpeed * Time.deltaTime);
    }


    // 位移实际上是 NavMeshAgent 帮我们完成,这里只是更新 Player 的 roatation
    private void Moving()
    {
        Quaternion targetRotation = Quaternion.LookRotation (agent.desiredVelocity);

        // Interpolate the player's rotation towards the target rotation.
        transform.rotation = Quaternion.Lerp (transform.rotation, targetRotation, turnSmoothing * Time.deltaTime);
    }


    // 登记了点击 SecurityRoom 的方法
    public void OnGroundClick(BaseEventData data)
    {
        // 如果当前正在交互,则直接返回
        if (!handleInput) 
        {
            return;
        }

        currentInteractable = null;

        // 强制转型为我们需要的指针事件数据类型
        PointerEventData pData = (PointerEventData)data;

        // 在 NavMesh 中采样在 NavMesh 所有区域中是否存在点在 navMeshSmpleDistance 的范围内,是返回 true ,反之返回 false
        NavMeshHit hit;
        if (NavMesh.SamplePosition (pData.pointerCurrentRaycast.worldPosition, out hit, navMeshSampleDistance, NavMesh.AllAreas))
            destinationPosition = hit.position;
        else
            destinationPosition = pData.pointerCurrentRaycast.worldPosition;
        agent.SetDestination (destinationPosition);
        agent.Resume ();
    }


    // 登记了点击可交互事件的方法
    public void OnInteractableClick(Interactable interactable)
    {
        // 如果当前正在交互,则直接返回
        if(!handleInput)
        {
            return;
        }

        // 储存可交互事件的 Interactable 变量
        currentInteractable = interactable;
        destinationPosition = currentInteractable.interactionLocation.position;

        agent.SetDestination (destinationPosition);
        agent.Resume ();
    }


    // 控制 handleInput 变量,保证交互时不受其他输入影响
    private IEnumerator WaitForInteraction()
    {
        // 进入交互的时候,在交互完成之前不接受任何输入
        handleInput = false;

        // 延迟一小会
        yield return inputHoldWait;

        // 仅当 animator 恢复到 Locomotion 标记的状态中(Idle 以及 Walking),才重新接受输入
        while (animator.GetCurrentAnimatorStateInfo (0).tagHash != hashLocomotionTag) 
        {
            yield return null;
        }

        // Now input can be accepted again.
        handleInput = true;
    }
}

脚本中注释比较详细,下面对几个点再总结一下:

  • 注意到在 Update() 方法中,使用了三个函数 Stopping()Slowing()Moving(),我在学习这里的时候一度觉得疑惑,为什么要弄这么复杂,直接像 Survival Shooter 一样调用 NavMeshAgent 的 setDestination() 方法,让 AI 全权负责不就好了吗,然而实践是检验真理的唯一标准,在 update() 方法中底部注释的代码解除注释,上面的代码全注释掉,会发现 AI 的确可以带着我们到达想要的位置,然而在到达那个位置之后,player 却会在诡异地旋转。那么原因到底是什么呢,不得而知,猜想是当 agent 到达目标点之后计算 rotation 的问题。既然知道这三个函数不是吹毛求疵,那么来认真看一下里面到底干了什么

    • Stopping() : 置 speed = 0 让 player 彻底停下来并且直接暴力地使用 transform.position = destinationPosition 将 player 移到最终位置,检查当前位置是否存在交互信息,相应进行处理
    • Slowing() : 禁用 NavMeshAgent ,简单的使用 Vector3.MoveTowards() 进行移动,根据距离终点的距离与开始减速的距离之间的比例更新 speed ,存在可交互人或物则一并向其旋转
    • Moving() : 让 player 的正面始终朝着 player 前进的方向
    • 小结:注意到上面用了三次 Lerp() 函数,它的是在现有值到目标值之间进行插值。之所以要使用它是因为函数是在 Update() 里面调用的,我们不希望在一帧里面就到那个目标值,所以要每一帧都进行插值达到缓缓移动或者旋转的效果。
  • Update() 里面还有一个 speed 变量,我们正是使用它来更新 animator 中的 Speed 参数,进而改变 Walking 中的 BlendTree 的动画效果。注意这里的 animator.SetFloat() 用到的一个参数是 hashSpeedPara ,而它其实就是 animator 中 Speed 的一个 ID ,通过 Animator.StringToHash() 方法实现转换,至于为什么要使用 ID 而不是本身的字符串是因为 ID 可以优化参数的存取器。

  • StartCoroutine(WaitForInteraction()) 方法使我再一次加深了对 Coroutine 的理解,它最大的作用其实是让一个方法,不必急着在一帧里面执行完,使用 yield return 可以非常方便的跳转到 return 的代码继续执行,然后下一帧回来再继续执行刚刚 yield return 下面的代码。同时不必担心,上一帧中方法里所有的信息都会保存起来,在这一帧继续执行。


02 - Inventory

Build a UI and Item Management System for Player Inventory
•UI System
•Editor Scripting

本节构造的物品清单 UI 是 Persistent Scene 中的,因为我们不希望从 SecurityRoom 切换到 Market 场景中这个清单就没了,或者又得在 Market 场景中重新做一个清单,这里的方法是创造一个始终存在的 Persistent Scene 它由始至终都是在渲染的。所有像物品清单这样的 UI 都可以储存在这个 scene 中,这样就可以可将一些 UI 独立到一个 scene 了。同时,这个单独存在的 scene 还可以帮助我们将一些场景的数据状态保存下来,比如说从 Market 中捡了一个硬币,然后走到 SecurityRoom 中,接着又走回 Market ,此时重新加载的 Market 场景理论上会重置,再次刷新出一个硬币,这个时候就可以利用到始终在加载的 Persistent Scene 来保存一些字段的数据,使硬币不再刷新,这个在后面还会讲到。

SceneStrut1

SceneStrut2

本节说是构造这个游戏场景里的道具清单,实际上,大部分的力气是花在教我们如何定制属于自己的 Inspector ,来简化我们的工作环境。

  • UI System
    做 Inventory 的 UI 不难,首先为 PersistentCanvas 添加一个子 GameObject 命名为 Inventory ,需要注意的是,Inventory 必须在 FadeImage 的上面,之前也提到过,UI 在 Hierarchy 中的顺序将决定 UI 在场景中的渲染顺序,Hierarchy 中排在越后的,就越在后面渲染,也就是在我们看到的场景最前面,而 FadeImage 将要用来覆盖整个 scene 来作为后面 scene 和 scene 之间的过渡,因此它必须在 Inventory 后面。随后为 Inventory 添加四个 GameObject 名为 ItemSlot ,每一个 ItemSlot 里面有一个 BackgroundImage 和 ItemImage 分别代表物品槽框和物品图片。这时,只需在 Inventor 中添加一个 VerticalLayoutGroup ,再调节一个各个组件的 Rect Transform ,Inventory 的样子就出来了。
    Inventory

    饺子皮做好了,来做饺子馅。首先得使用一个贴图类来显示在清单上的物件,这个其实可以直接使用 Sprite 来表示,但是为了使 Item 类不失拓展性,这里使用 Item 类将他进行封装

    // This simple script represents Items that can be picked
    // up in the game.  The inventory system is done using
    // this script instead of just sprites to ensure that items
    // are extensible.
    [CreateAssetMenu]
    public class Item : ScriptableObject
    {
    public Sprite sprite;
    }

    这里涉及到重要概念 ScriptableObject ,简单的说,ScriptableObject 可以将一些不需要依附 GameObject 的数据抽象成 asset,这意味着,它可以像纹理,贴图,模型等 asset 一样,只有一份实例,一旦改变它,所有它的实例都会改变(可参考乐乐姐博文)。而物品清单正正就是想要这样的效果,想象我们有一个物品清单,里面存放着各种物品以及它们的状态,现在我们有两个商店从清单中调货出去卖,如果每一商店都拥有一份清单实例,那么它们各自修改清单的时候,彼此的数据是不同步的,有可能在一个店里面已经调货卖光了,而在另一个店的清单实例中仍有该货品,造成清单上有货,但是却调不出的现象。解决它的方法就是将清单作为一个 ScriptableObject 来储存,全局只有一个物品清单,各个商店都只能访问修改同一份清单,这也是设计模式中常见的 单例模式

    • 值得注意的是:在 Item 类的上面有一个 CreateAssetMenu 的特性,该特性的作用是使 Item 可以自动地罗列在子菜单下。

    理解了为什么要 Item 继承 ScriptableObject 之后,来构建 Inventory 类。Inventory 应该暴露两个方法供外界调用,一个负责捡,一个负责使用,那么直接来看脚本实现

    public class Inventory : MonoBehaviour
    {
    public Image[] itemImages = new Ima
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值