找回密码
 立即注册
查看: 811|回复: 5

Unity制作类胡闹厨房游戏 KitchenChaos 笔记整理(上)

[复制链接]
发表于 2023-4-2 20:34 | 显示全部楼层 |阅读模式


最终效果

本文章是油管上CodeMonkey的一个unity项目KitchenChaos的笔记整理,学习并整理这个项目主要是因为终于看到了一个比较完整地用到了unity的各种功能、风格较为清爽的、代码结构清晰的同时比较新的项目。在学习之后也确实有很大的收获,首先通过该教程第一次走完了一个小游戏的全部流程,之前也零零散散地学过一些unity教程和渲染方面的东西,但是流程一直都不太完整:有些项目的视觉表现实在不太感兴趣,看到了拖沓的动画和过时的画面就没有想学的欲望;有些项目不注重代码的扩展性,导致只能停留在一个小demo的阶段;有些项目在游戏逻辑上很复杂,导致学到一半自己已经不太能跟得上。但是本教程在工程上比较讲究,注重代码的整理和解耦,在游戏逻辑上并没有那么复杂,在一些功能的实现上拥抱引擎提供的工具,CodeMonkey本身逻辑也非常清晰,个人认为很适合学习

任何教程都有前置知识,视频中在前面也有提及,如果想要学习本项目,还是需要了解unity的基本操作和c#基础,我个人觉得这个教程首先需要有面向对象语言的基础,然后特别地要理解C#中委托和事件的使用,比较浅比较直接的理解可以去看CodeMonkey的C#的事件和委托的讲解,我也写了笔记放在了同一个专栏下,但是如果想要知道委托实际上类似于函数指针、事件模型有各种组成部分和实现形式等更深的知识推荐看刘铁猛老师的C#课程

另外该文章只是一些大概的笔记,具体操作还要去跟原教程去学习,但是和视频不同的是,写成图文的形式可以让别人在学习前能大概浏览一下这个项目都干了什么,并且能够在学习过后很方便地回顾某一步具体干了什么,希望对大家有帮助
原教程地址:https://youtu.be/AmGSEH7QcDg
B站搬运教程地址:https://www.bilibili.com/video/BV1gT411Z7bL/?share_source=copy_web&vd_source=10f4d7fd9e763a87da08cd00452bc8a4
该笔记的语雀链接:
下篇:
1 项目初始设置

该项目使用的unity 2022.2.2f1c1的3d(urp)模板进行创建



在Project Setting中Quality面板只保留High Fidelity等级,同时项目Setting目录中也只保留High Fidelity的设置





下载提供的素材后双击或拖入unity编辑器导入引擎

目前为止的工程文件

2 后处理 Post Processing

一般在开发时在应该在中后期再添加后处理,但在本课中为了一开始就有比较好的视觉效果,所以第一步就先进行了后处理
我们会使用自动创建好的SampleScene作为最终游戏场景,所以将其重命名为GameScene
添加地面、人物和一些游戏物品,调整相机位置,进入Game窗口,在Global Volume上添加override,依次添加Tonemapping、Color Adjustments、Bloom、Vignette,调整后的配置被保存在了Global Volume Profile中,可以点击右方的clone保存文件



在Main Camera中可以设置anti-aliasing,同时在Setting/URP-HighFidelity中也可以设置MSAA,这里设置为MSAA 8x
URP-HighFidelity-Renderer中自带了一个Screen Space Ambient Occlusion的render feature,这里保留并稍作调整
Window->Rendering->Lighting可以调出来Lighting面板调整关于灯光的参数,这里保留默认





目前为止的工程文件

3 角色控制器与动画 Character Controller & Animations

3.1 旧的角色控制方法

如果想要组织好一个游戏,应该永远将视觉效果与逻辑分开,在创建角色时,我们不直接创建一个胶囊体然后修改缩放、偏移等属性,三轴不相等的缩放或一些位置的偏移可能会让原本的代码逻辑出现问题,所以这里我们创建一个空物体,之后会在空物体上写逻辑,然后在空物体之下再创建一个胶囊体作为我们的角色,之后会在这个子物体上做视觉的修改



接下来开始代码的编写,创建Scripts文件夹,创建Player.cs
目前Unity中有两种实现角色控制的方法,一个是旧的input manager,一个是新的input system,旧的input manager很易用,适合做原型开发,但是复杂的项目最好用新的input system来做,在这个项目中我们会先用旧的input manager做出原型,然后替换为新的input system

// Player.cs中
public class Player : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 7f;
   
    private void Update()
    {
        Vector2 inputVector = new Vector2(0, 0);

        // getkey会一直返回true,而getkeydown只会在按下的一帧返回true
        if (Input.GetKey(KeyCode.W))
        {
            inputVector.y += 1;
        }
        if (Input.GetKey(KeyCode.S))
        {
            inputVector.y -= 1;
        }
        if (Input.GetKey(KeyCode.A))
        {
            inputVector.x -= 1;
        }
        if (Input.GetKey(KeyCode.D))
        {
            inputVector.x += 1;
        }
        
        inputVector = inputVector.normalized;

        Vector3 moveDir = new Vector3(inputVector.x, 0f, inputVector.y);
        transform.position += moveDir * Time.deltaTime * moveSpeed;
    }
}
将素材文件中的人物模型PlayerVisual放到空物体Player下面,删除之前的胶囊体,这时再操控角色可以正常移动,但是角色的朝向不会变,只需要在上面的脚本中最后加上以下代码即可实现(使用slerp球形插值处理转向角度变化)

float rotateSpeed = 10f;  
transform.forward = Vector3.Slerp(transform.forward, moveDir, Time.deltaTime * rotateSpeed);
目前为止的工程文件

3.2 角色动画

接下来开始添加动画,创建Animations文件夹,创建一个animator,挂载到PlayerVisual上
首先创建Idle.anim,拖入Animator面板,右击Entry->Make Transition指向Idle;打开Animation面板,做出角色的头部上下移动的动画



然后创建Walk.anim,同样拖入Animator面板,右击Idle->Make Transition指向Walk,取消勾选Has Exit Time,Parameters中添加IsWalking参数,同时Conditions中将IsWalking设为True,同样地,右击Walk->Make Transition指向Ilde,取消Has Exit Time,oCnditions中设置IsWalking为False,Walk动画在Idle的基础上添加身体的上下移动,同时加快速度



接下来我们创建PlayerAnimator.cs脚本管理角色的动画,添加到PlayerVisual上;在Player.cs中设置IsWalking的值,当角色移动向量不为0时则为true;在Player.Animator.cs中更新Animator中设置的参数

// Player.cs中
...
public class Player : MonoBehaviour
{
    private bool isWalking;
         ...
    private void Update()
    {
                   ...
        isWalking = (inputVector != Vector2.zero);
                   ...
    }
   
    public bool IsWalking()
    {
        return isWalking;
    }
}
// PlayerAnimator.cs中
using UnityEngine;

public class PlayerAnimator : MonoBehaviour
{
    private const string IS_WALKING = "IsWalking";
   
    [SerializeField] private Player player;
   
    private Animator animator;

    private void Awake()
    {
        animator = GetComponent<Animator>();
    }
    private void Update()
    {
        animator.SetBool(IS_WALKING, player.IsWalking());
    }
}
目前为止的工程文件

3.3 Cinemachine

接着我们使用Cinemachine在角色移动的时候添加一些简单的摄像机动画,首先在Package Manager里安装Cinemachine的包,然后GameObject->Cinemachine->Virtual Camera创建一个摄像机,这样创建一个Virtual Camera会在Main Camera上加上一个CinemachineBrain组件,我们需要在Virtual Camera中去控制相机
在Virtual Camera的Inspector面板中,将Body->Binding Mode设为World Space,将Follow和Look At都设为之前创建的Player,调整Follow Offset,就可以得到一个简单的跟随相机(还可以创建多个Cinemachine,通过设置priority确定相机控制权,如果控制权从一个相机到了另一个相机,Cinemachine还会自动对相机位置进行插值)



目前为止的工程文件

3.4 使用新的input system进行重构

首先将处理玩家输入得到移动向量的部分代码分离
创建GameInput.cs,并在Hierachy创建一个GameInput对象,将脚本挂载到对象上;将原先Player.cs中处理输入的部分拿出来,放到GameInput.cs中,调整完的代码如下

// Player.cs中
using UnityEngine;

public class Player : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 7f;
    [SerializeField] private GameInput gameInput;

    private bool isWalking;
   
    private void Update()
    {
        Vector2 inputVector = gameInput.GetMovementVectorNormalized();
        Vector3 moveDir = new Vector3(inputVector.x, 0f, inputVector.y);
        transform.position += moveDir * Time.deltaTime * moveSpeed;

        isWalking = (inputVector != Vector2.zero);
        float rotateSpeed = 10f;
        transform.forward = Vector3.Slerp(transform.forward, moveDir, Time.deltaTime * rotateSpeed);
    }
   
    public bool IsWalking()
    {
        return isWalking;
    }
}
// GameInput.cs中
using UnityEngine;

public class GameInput : MonoBehaviour
{
    public Vector2 GetMovementVectorNormalized()
    {
        Vector2 inputVector = new Vector2(0, 0);
        
        if (Input.GetKey(KeyCode.W))
        {
            inputVector.y += 1;
        }
        if (Input.GetKey(KeyCode.S))
        {
            inputVector.y -= 1;
        }
        if (Input.GetKey(KeyCode.A))
        {
            inputVector.x -= 1;
        }
        if (Input.GetKey(KeyCode.D))
        {
            inputVector.x += 1;
        }
        
        inputVector = inputVector.normalized;
        return inputVector;
    }
}
接下来去Package Manager中安装Input System,安装完后会提示我们激活input system,我们可以选择no然后在Edit->Project Setting->Player->Other Settings手动激活,我们在下拉菜单中选择Both





在Settings文件夹下右键->Create->Input Actions创建PlayerInputActions.inputactions
双击打开该窗口,创建一个Action Map,创建一个Move Action,将Action Type改为Value,Control Type改为Vector2,删除Move下面的<No Binding>,点击右边的加号选择Add Up\Down\Left\Right Composite



依次修改下方的四个方向绑定的事件,可以选择listen然后按下对应按键



Input System可以通过Add Component添加对应的脚本,但是这里我们选择用代码的方式使用。选中PlayerInputAction.inputactions,在Inspector面板中勾选Generate C# Class,然后Apply



修改GameInput.cs,替换为将原来的方法替换为使用Input System(归一化的操作也可以在.inputactions文件中添加processor)

// GameInput.cs中
using UnityEngine;

public class GameInput : MonoBehaviour
{
    private PlayerInputActions playerInputActions;
   
    private void Awake()
    {
        playerInputActions = new PlayerInputActions();
        playerInputActions.Player.Enable();
    }

    public Vector2 GetMovementVectorNormalized()
    {
        // Vector2 inputVector = new Vector2(0, 0);

        // if (Input.GetKey(KeyCode.W))
        // {
        //     inputVector.y += 1;
        // }
        // if (Input.GetKey(KeyCode.S))
        // {
        //     inputVector.y -= 1;
        // }
        // if (Input.GetKey(KeyCode.A))
        // {
        //     inputVector.x -= 1;
        // }
        // if (Input.GetKey(KeyCode.D))
        // {
        //     inputVector.x += 1;
        // }
        //
        
        Vector2 inputVector = playerInputActions.Player.Move.ReadValue<Vector2>();
        
        inputVector = inputVector.normalized;
        return inputVector;
    }
}
想要添加其他的输入方式,可以在.inputactions文件面板添加新的Action,比如这里添加了一个用方向键控制移动的Action



目前为止的工程文件

3.5 碰撞检测 Collision Detection

我们可以先使用Physics.Raycast()方法做一个简单的碰撞检测
在场景中放置一个Cube,确保这个Cube带有Box Collider组件。在控制角色位置发生变化的脚本Player.cs中,从角色的原点出发,向移动方向发出一条射线,射线长度大于角色的大小时才可以移动

// Player.cs中
...
public class Player : MonoBehaviour
{
    ...
    private void Update()
    {
        ...
        float playerRadius = .7f;
        bool canMove = !Physics.Raycast(transform.position, moveDir, playerRadius);

        if (canMove)
        {
            transform.position += moveDir * Time.deltaTime * moveSpeed;
        }
        ...
    }
    ...
}
这样在对着正方体移动时确实正确处理了碰撞,但是由于我们只从原点发射了一条射线,有且情况还是会发生穿模,所以我们需要使用Physics.CapsuleCast()方法



Physics.CapsuleCast()方法的六个参数分别定义了胶囊体的底部、顶部、半径、射线发射方向、射线最大距离

// Player.cs中
...
float moveDistance = moveSpeed * Time.deltaTime;  
float playerRadius = .7f;
float playerHeight = 2f;
bool canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDir, moveDistance);
...
使用Physics.CapsuleCast()方法后,即是在边缘也能发生碰撞了,但是如果在一个面前有墙的地方同时按住向上移动和向右移动的方向键,角色也不会移动,但通常在游戏中这种情况通常会让角色朝右移动



我们可以多增加一些逻辑来让角色有其他方向的速度向量时仍然移动

// Player.cs中
...
float moveDistance = moveSpeed * Time.deltaTime;  
float playerRadius = .7f;
float playerHeight = 2f;
bool canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDir, moveDistance);

if (!canMove)
{
        // 当不能向moveDir方向移动时
       
        // 尝试沿x轴移动
        Vector3 moveDirX = new Vector3(moveDir.x, 0f, 0f).normalized; // 归一化让速度和直接左右移动相同
        canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirX, moveDistance);

        if (canMove)
        {
                 // 可以沿x轴移动
                 moveDir = moveDirX;
        } else
        {
                 // 不能向x轴方向移动,尝试向z轴方向移动
                 Vector3 moveDirZ = new Vector3(0f, 0f, moveDir.z).normalized; // 归一化让速度和直接左右移动相同
                 canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirZ, moveDistance);

                 if (canMove)
                 {
                          // 可以向z轴方向移动
                          moveDir = moveDirZ;
                 } else
                 {
                          // 不能朝任何方向移动
                 }
        }
}

if (canMove)
{
        transform.position += moveDir * Time.deltaTime * moveSpeed;
}
...
进行一些调整之后,我们就可以正常移动了



目前为止的工程文件

在原视频4:24:00处作者进行了一些改进,现在如果我们垂直向墙体走动,由于当不能向moveDir方向移动时我们限制住了moveDir,所以垂直向墙体走动的时候moveDir为(0, 0, 0),而我们的动画是当前角色朝向往moveDir方向插值的,所以我们的角色在垂直向墙体走动的时候不会向墙体转向,效果如下



要解决这个问题,需要改动以下语句(注意这里moveDir.x != 0和moveDir.z != 0的加入也给更后面的手柄输入带来了一定问题,在最后的时候作者改为了(moveDir.x < -0.5f || moveDir.x > 0.5f)和`(moveDir.z < -0.5f || moveDir.z > 0.5f))

// Player.cs中
...
if (!canMove)
{
        ...
        // canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirX, moveDistance);
        canMove = moveDir.x != 0 && !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirX, moveDistance);
        ...
        if (canMove)
        {
                 ...
        } else
        {
                 ...
                 // canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirZ, moveDistance);
                 canMove = moveDir.z != 0 && !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirZ, moveDistance);
                 ...
                 if (canMove)
                 {
                          ...
                 } else
                 {
                          ...
                 }
        }
}
改动之后,就能够在垂直朝墙体走动时正常转向了



4 创建空的柜台 Clear Counter

4.1 添加柜台

首先在场景中创建一个空物体,命名为ClearCounter,在_Assets/PrefabsVisuals/CountersVisuals下找到ClearCounter_Visual拖动到ClearCounter下,在ClearCounter上添加一个Box Collider组件,调整到合适大小,现在角色就可以和柜台发生碰撞了



我们需要把设置好碰撞体积的柜台变为一个prefab,这样每次只要使用这个prefab就可以了,新建文件夹,命名为Prefabs,然后将需要制作prefab的物体从Hierachy窗口拖入文件夹

4.2 利用Raycast处理角色与柜台的交互

然后我们在Player.cs中开始写角色与柜台交互的代码,首先先将原代码进行整理,将处理移动的代码放入到一个函数中去

// Player.cs中
public class Player : MonoBehaviour
{
    ...
    private void Update()
    {
        HandleMovement();
        HandleInteractions()
    }
   
    public bool IsWalking()
    {
        return isWalking;
    }

    private void HandleInteractions()
    {
        
    }

    private void HandleMovement()
    {
        // 之前Update中的代码全放在这里
    }
    ...
}
Physics.Raycast()有一个构造函数可以填入一个参数用来返回被射线击中位置的属性,这里用raycastHit.transform来返回被击中的物体信息,我们可以在这里测试一下,当在可交互距离内击中则返回物体名称,未击中则返回"-",使用lastInteractDir保存上一次操作时移动的方向,防止当移动速度为0时moveDir为(0, 0, 0)而无法确定是否可以与柜台交互

// Player.cs中
...
private Vector3 lastInteractDir;
...
private void HandleInteractions()
{
          Vector2 inputVector = gameInput.GetMovementVectorNormalized();
          
          Vector3 moveDir = new Vector3(inputVector.x, 0f, inputVector.y);
                  
          if (moveDir != Vector3.zero)
          {
                        lastInteractDir = moveDir;
          }
          
          float interactDistance = 2f;
          if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit, interactDistance))
          {
                        Debug.Log(raycastHit.transform);
          } else
          {
                        Debug.Log("-");
          }        
}
...



接下来开始给柜台添加脚本,在Scripts文件夹新建ClearCounter.cs,将脚本添加到ClearCounter.prefab上

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour
{
    public void Interact()
    {
        Debug.Log("Interact");
    }
}
为了处理角色与柜台间的交互,我们同样需要在Player.cs中添加代码

// Player.cs中
...
private void HandleInteractions()
{
          Vector2 inputVector = gameInput.GetMovementVectorNormalized();
          
          Vector3 moveDir = new Vector3(inputVector.x, 0f, inputVector.y);
          
          if (moveDir != Vector3.zero)
          {
                        lastInteractDir = moveDir;
          }
          
          float interactDistance = 2f;
          if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit,interactDistance))
          {
                        if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
                        {
                                 // 线击中的物体拥有ClearCounter.cs脚本
                                 clearCounter.Interact();
                        }
                       
                        // 使用TryGetComponent()方法和使用下面的代码相同,会检测到是否有ClearCounter.cs
                        // ClearCounter clearCounter = raycastHit.transform.GetComponent<ClearCounter>();
                        // if (clearCounter != null)
                        // {
                        //     // Has clearCounter.cs
                        // }
          } else
          {
                        Debug.Log("-");
          }
}
...
4.3 Layermask

现在我们的代码还有一个问题,如果有其他东西挡在了角色与柜台之间,射线就不会打到柜台,所以我们可以将ClearCounter.prefab单独设置在一个Layer中(要手动添加一个Layer),然后在Player.cs使用Physics.Raycast的其中的一个构造函数,最后一个参数可以传入一个layermask



// Player.cs中
...
[SerializeField] private LayerMask countersLayerMask;
...
private void HandleInteractions()
{
          ...
          if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit,interactDistance, countersLayerMask))
          {
                        if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
                        {
                                 // Has ClearCounter.cs
                                 clearCounter.Interact();
                        }
          } else
          {
                        Debug.Log("-");
          }
}
...
目前为止的工程文件

5 处理Interact输入的C#事件 Interact Action C# Events

5.1 添加Interact Action

打开PlayerInputActions.inputactions,添加一个Action,命名为Interact,绑定E键



在GameInput.cs中,我们使用委托为这个Interact Action添加一个调用的函数Interact_performed(),我们先来测试一下,使用Debug.log(obj)在控制台输出一下调用的函数本身

// GameInput.cs中
using UnityEngine;

public class GameInput : MonoBehaviour
{
    private PlayerInputActions playerInputActions;
   
    private void Awake()
    {
        playerInputActions = new PlayerInputActions();
        playerInputActions.Player.Enable();
        
        playerInputActions.Player.Interact.performed += Interact_performed;
    }
   
    private void Interact_performed(UnityEngine.InputSystem.InputAction.CallbackContext obj)
    {
        Debug.Log(obj);
    }

    public Vector2 GetMovementVectorNormalized()
    {
        Vector2 inputVector = playerInputActions.Player.Move.ReadValue<Vector2>();
        
        inputVector = inputVector.normalized;
        return inputVector;
    }
}
Unity Input System文档



启动游戏,按下E键,可以看到控制台输出了我们的按下对应按键的相关信息



5.2 使用EventHandler委托将交互逻辑写在Player.cs

接下来在Interact Action调用的函数Interact_performed()中添加EventHandler委托,在Player.cs中为该委托添加具体的交互行为

// GameInput.cs中
using System;
using UnityEngine;

public class GameInput : MonoBehaviour
{
    public event EventHandler OnInteractAction; // 新添加
   
    private PlayerInputActions playerInputActions;
   
    private void Awake()
    {
        playerInputActions = new PlayerInputActions();
        playerInputActions.Player.Enable();
        
        playerInputActions.Player.Interact.performed += Interact_performed;
    }
   
    private void Interact_performed(UnityEngine.InputSystem.InputAction.CallbackContext obj)
    {
        OnInteractAction?.Invoke(this, EventArgs.Empty); // 新添加
        // 这里使用了"?"运算符,与下面的代码相同
        // if (OnInteractAction != null)
        // {
        //     OnInteractAction(this, EventArgs.Empty);
        // }
    }

    public Vector2 GetMovementVectorNormalized()
    {
        Vector2 inputVector = playerInputActions.Player.Move.ReadValue<Vector2>();
        
        inputVector = inputVector.normalized;
        return inputVector;
    }
}
这里可以在GameInput_OnInteractAction()中先放上之前HandleInteractions()的代码测试一下

// Player.cs中
...
private void Start()
{
          gameInput.OnInteractAction += GameInput_OnInteractAction;
}

private void GameInput_OnInteractAction(object sender, EventArgs e)
{
          //将原先HandleInteractions()中的内容暂时放到了这里,并且暂时不再调用HandleInteractions()中的clearCounter.Interact()
          Vector2 inputVector = gameInput.GetMovementVectorNormalized();
          
          Vector3 moveDir = new Vector3(inputVector.x, 0f, inputVector.y);
          
          if (moveDir != Vector3.zero)
          {
                        lastInteractDir = moveDir;
          }
          
          float interactDistance = 2f;
          if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit,interactDistance, countersLayerMask))
          {
                        if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
                        {
                                 // Has ClearCounter.cs
                                 clearCounter.Interact();
                        }
          }
}
...
这时运行游戏按E进行交互,可以得到显示Interact(我们先前在clearCounter.Interact()中定义了输出"Interact")



个人解释:使用playerInputActions.Player.Interact.performed += Interact_performed;是为该控制器触发的委托添加了一个会调用的函数,在这个函数中,我们写具体的按下按键发生的事情,这里我们想把具体的交互行为写在Player.cs中,所以我们在这个函数中去触发一个叫OnInteractAction的Eventhandler委托,这个委托添加的会调用的函数写在了Player.cs中,使用gameInput.OnInteractAction += GameInput_OnInteractAction;添加了GameInput_OnInteractAction()这个具体处理输入逻辑函数
关于委托和事件的讲解可以看刘铁猛老师的C#课程和CodeMonkey讲C#的相关课程

目前为止的工程文件

6 选中柜台的视觉效果与单例模式 Select Counter Visual Singleton Pattern

6.1 添加带有选中效果的模型

我们可以将柜台视觉效果的改变写在Player.cs中最后clearCounter.Interact()中,然而这样在写角色逻辑的代码中处理柜台的视觉效果,会使视觉效果与交互逻辑的代码耦合,所以这里我们要使用其他的方法去改变柜台的视觉效果
在ClearCounter.perfab中,复制ClearCounter_Visual并重命名为Selected,选择下面的模型,将Material换为CounterSelected,在Scripts文件夹中新建SelectCounterVisual.cs并将该脚本添加到Selected上,这样当我们在打开Selected的显示时就能得到一个选中效果的柜台



像这样两个网格重叠时,可能会形成闪烁的效果,为了避免这种情况,我们可以将选中效果的柜台稍微放大一些,例如这里将所有轴的缩放改为1.01



6.2 为柜台增加选中的效果

我们在Player.cs中增加一个selectedCounter变量,用于获取当前被选中的柜台,在HandleInteractions()中随着投射出的光线击中的物体改变而改变当前selectedCounter为哪一个柜台,然后在GameInput_OnInteractAction()中调用selectedCounter.Interact()实现交互
// Player.cs中
...
public class Player : MonoBehaviour
{
    ...
    private ClearCounter selectedCounter;

    private void Start()
    {
        gameInput.OnInteractAction += GameInput_OnInteractAction;
    }
   
    private void GameInput_OnInteractAction(object sender, EventArgs e)
    {
        if (selectedCounter != null)
        {
            selectedCounter.Interact();
        }
    }

    private void Update()
    {
        HandleMovement();
        HandleInteractions();
    }
    ...
    private void HandleInteractions()
    {
        ...
        
        if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit,interactDistance, countersLayerMask))
        {
            if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
            {
                // 射线击中的物体拥有ClearCounter.cs脚本
                // 如果当前交互的柜台不是上一次选中的柜台,就把选中的clearCounter设置为当前的柜台
                if (clearCounter != selectedCounter)
                {
                    selectedCounter = clearCounter;
                }
            } else
            {
                // 射线击中的物体没有ClearCounter.cs脚本
                selectedCounter = null;
            }
        } else
        {
            // 没有射线碰撞到任何东西
            selectedCounter = null;
        }
        
        Debug.Log(selectedCounter);
    }
    ...
}
现在运行游戏,我们就可以在靠近柜台时看到Debug.Log(selectedCounter)信息显示出我们当前选中的柜台



接下来我们处理选中的视觉效果,这里我们有两种思路可以选择。一种是让当前selectedCounter发送选中的事件,在处理视觉表现的脚本中订阅该事件写选中时视觉效果的变化,也就是在ClearCounter.cs中发送事件,在SeletedCounterVisual.cs中订阅该事件;另一种思路是让当前操作的角色发送事件,在处理视觉表现的脚本中订阅该事件写选中时视觉效果的变化。
第一种方式的好处是SeletedCounterVisual.cs只会订阅当前选中柜台的事件,并且如果我们需要选中时添加其他效果也可以很方便地添加,坏处是控制视觉效果的代码又经过了专门处理逻辑的ClearCounter.cs的脚本中,会造成代码的耦合
第二种方式的好处是它更加方便,控制视觉效果的代码不会经过处理逻辑的脚本,坏处是可能会存在性能问题,因为所有的柜台都在订阅由角色发送的事件,但是我们的项目体量比较小,这样的方法并不会带来性能瓶颈,所以我们选择使用这一种方法。并且由于我们要完成的游戏只有一个角色,使用这一种方法还可以使用单例模式
在Player.cs中,添加一个EventHandler委托,并使用EventArgs传递选中的柜台信息,在Update()中触发事件,这里由于要使用到多次OnSelectedCounterChanged,所以这里把它单独放在了一个函数中

// Player.cs中
...
public class Player : MonoBehaviour
{
    ...
    public event EventHandler<OnSelectedCounterChangedEventArgs> OnSelectedCounterChanged;
    public class OnSelectedCounterChangedEventArgs : EventArgs
    {
        public ClearCounter selectedCounter;
    }
    ...
    private void Update()
    {
        ...
        HandleInteractions();
    }
    ...
    private void HandleInteractions()
    {
        ...
        if (Physics.Raycast(transform.position, lastInteractDir, out RaycastHit raycastHit,interactDistance, countersLayerMask))
        {
            if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter))
            {
                // 射线击中的物体拥有ClearCounter.cs脚本
                // 如果当前交互的柜台不是上一次选中的柜台,就把选中的clearCounter设置为当前的柜台
                if (clearCounter != selectedCounter)
                {
                    SetSelectedCounter(clearCounter);
                }
            } else
            {
                // 射线击中的物体没有ClearCounter.cs脚本
                SetSelectedCounter(null);
            }
        } else
        {
            // 没有射线碰撞到任何东西
            SetSelectedCounter(null);
        }
    }
    ...
    private void SetSelectedCounter(ClearCounter selectedCounter)
    {
        this.selectedCounter = selectedCounter;
        
        OnSelectedCounterChanged?.Invoke(this, new OnSelectedCounterChangedEventArgs
        {
            selectedCounter = selectedCounter
        });
    }
}
我们需要在SelectCounterVisual.cs中订阅该事件,由于我们只有一个Player,所以我们可以使用单例模式
依然是在Player.cs中,我们定义一个public的、static的Player的实例,并且设置为只读(public是为了其他类能够访问到,static是让该实例与类无关,让程序中只有始终只有一个Player实例),我们必须确保游戏中只有一个Player,所以我们需要在Awake()中进行检查

// Player.cs中
...
public class Player : MonoBehaviour
{
    public static Player Instance { get; private set; }
    ...
    private void Awake()
    {
        if (Instance != null)
        {
            Debug.LogError("There is more than one Player instance");
        }
        Instance = this;
    }
    ...
}
...
在SelectCounterVisual.cs中,订阅这个实例上的事件控制选中效果模型的显示与隐藏,由于我们在Player.cs中使用了Awake()设置了单例模式中的实例,而Awake()会在Start()之前执行,所以程序可以正常运行,如果我们这里也使用Awake(),则可能会导致单例设置前该脚本就已经执行(如果都使用Awake(),还可以在Edit->Project Settings->Script Execution Order中规定脚本的执行顺序)

// SelectCounterVisual.cs中
using UnityEngine;

public class SelectCounterVisual : MonoBehaviour
{
    [SerializeField] private ClearCounter clearCounter;
    [SerializeField] private GameObject visualGameObject;
   
    private void Start()
    {
        Player.Instance.OnSelectedCounterChanged += Player_OnSelectedCounterChanged;
    }
   
    private void Player_OnSelectedCounterChanged(object sender, Player.OnSelectedCounterChangedEventArgs e)
    {
        if (e.selectedCounter == clearCounter)
        {
            show();
        }
        else
        {
            hide();
        }
    }

    private void show()
    {
        visualGameObject.SetActive(true);
    }
   
    private void hide()
    {
        visualGameObject.SetActive(false);
    }
}
现在,我们就实现了物品的选中效果



目前为止的工程文件

7 放置物品与Scriptable Objects

7.1 按E在柜台上生成物品

我们先来实现按E在柜台上生成番茄的效果
首先要创建番茄的prefab,在场景中创建一个空物体命名为Tomato,在_Assets/PrefabVisuals/KitchenObjects下找到Tomato_Visual,拖动到空物体下,将该带着番茄模型的空物体再拖回Prefabs中创建prefab(为了之后分类方便,这里视频中在Prefab文件夹下分为了Counters文件夹和KitchenObjects文件夹,番茄放到了KitchenObjects中),创建完prefab后,删除场景中的番茄



为了定位番茄生成的位置,我们需要在ClearCounter.prefab中创建一个空物体,重命名为CounterTopPoint,移动到柜台的正上方



接下来在ClearCounter.cs中,接收tomatoPrefab与counterTopPoint,并在Interact()中实例化一个番茄

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour
{
    [SerializeField] private Transform tomatoPrefab;
    [SerializeField] private Transform counterTopPoint;

    public void Interact()
    {
        Debug.Log("Interact");
        Transform tomatoTransform = Instantiate(tomatoPrefab, counterTopPoint);
        tomatoTransform.localPosition = Vector3.zero;
    }
}
现在运行游戏,当柜子高亮,按下E即可放置番茄,现在我们测试一下其他物体,复制Tomato.prefab,重命名为Cheese.prefab,将其中的Tomato_Visual换为CheeseBlock_Visual,拖动Cheese.prefab到其中一个柜台的脚本下



运行游戏,按E即可放置番茄与奶酪



7.2 Scriptable Objects

Scriptable Object可以很方便地定义一个类的多种不同实例,如多种武器、多种装备、多种食物等等
首先在Scripts文件夹新建KitchenObjectSO.cs,注意这里不是继承自MonoBehaviour,而是ScriptableObject,要创建Scriptable Object,还需要在类前面加上Unity为我们准备的[CreateAssetMenu()],这样我们就可以在Unity编辑器中使用这个脚本创建对象

// KitchenObjectSO.cs中
using UnityEngine;

[CreateAssetMenu()]
public class KitchenObjectSO : ScriptableObject
{
    public Transform prefab;
    public Sprite sprite;
    public string objectName;
}
创建文件夹ScriptableObjects/KitchenObjectSO(截图里多加了个s,后面我改了),右键create,点击最上方的Kitchen Object SO新建文件,重命名为Tomato.asset,并在Inspector面板中将prefab、Sprite、Object Name填好



在ClearCounter.cs中,修改对应部分,使其通过kitchenObjectSO对象调用对应prefab

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour
{
    // [SerializeField]private Transform tomatoPrefab;
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
    [SerializeField] private Transform counterTopPoint;

    public void Interact()
    {
        // Transform tomatoTransform = Instantiate(tomatoPrefab, counterTopPoint);
        Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab, counterTopPoint);
        // tomatoTransform.localPosition = Vector3.zero;
        kitchenObjectTransform.localPosition = Vector3.zero;
    }
}
在场景中挂载了ClearCounter.cs脚本的ClearCounter上,将Kitchen Object SO设置为Tomato,开始游戏,可以正常交互,同时可以通过Script Object创建另一个CheeseBlock.asset,将另一个柜台的Kitchen Object SO设置为CheeseBlock,开始游戏,得到和之前相同的效果



我们需要让柜台知道在其上方放置的物品是什么,但是由于Script Object不是继承自Monobehaviour的类,所以不能作为一个组件添加到物体上,所以我们需要在Scripts文件夹下创建一个KitchenObject.cs,将它添加到我们已经创建的两个prefab上并将对应的Tomato.asset和CheeseBlock.asset拖动到脚本上

// KitchenObject.cs中
using UnityEngine;

public class KitchenObject : MonoBehaviour
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
   
    public KitchenObjectSO GetKitchenObjectSO()
    {
        return kitchenObjectSO;
    }
}
然后在ClearCounter.cs中获取放置物品信息

// ClearCounter.cs中
...
public class ClearCounter : MonoBehaviour
{
    ...
    public void Interact()
    {
        ...
        Debug.Log(kitchenObjectTransform.GetComponent<KitchenObject>().GetKitchenObjectSO());
    }
}
现在运行游戏,就能在控制台看到放置物品时的信息了



目前为止的工程文件

7.3 Kitchen Object Parent

现在我们只能在特定的柜子上放特定的物品,而最后游戏应该是物品可以放置到其他柜子上的,所以我们需要一直改变Kitchen Object的父级,这意味着我们需要让Kitchen Object知道自己在哪个Counter上,Counter也能知道哪个Kitchen Object在放置在了自己身上,以便接下来移动Kitchen Object到其他位置
在KitchenObject.cs中,用变量clearCounter保存当前物品放置在的柜子,增加可以设置和获取当前放置在的柜子的方法

// KitchenObject.cs中
using UnityEngine;

public class KitchenObject : MonoBehaviour
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
   
    private ClearCounter clearCounter;
   
    public KitchenObjectSO GetKitchenObjectSO()
    {
        return kitchenObjectSO;
    }
   
    public void SetClearCounter(ClearCounter clearCounter)
    {
        this.clearCounter = clearCounter;
    }
   
    public ClearCounter GetClearCounter()
    {
        return clearCounter;
    }
}
在ClearCounter.cs中,用变量kitchenObject来保存当前柜子上放置的物品,并用if else避免柜子上放置多个物体

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
    [SerializeField] private Transform counterTopPoint;
   
    private KitchenObject kitchenObject;

    public void Interact()
    {
        if (kitchenObject == null)
        {
            Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab, counterTopPoint);
            kitchenObjectTransform.localPosition = Vector3.zero;
            
            kitchenObject = kitchenObjectTransform.GetComponent<KitchenObject>();
            kitchenObject = SetClearCounter(this);
        } else  
        {  
            Debug.Log(kitchenObject.GetClearCounter());  
        }
    }
}
接下来我们先来测试一下如何改变物体的父级,最终我们要让物品可以成为成为任意柜子的子级,也可以成为角色的子级,我们先来实现按T键物品就可以从一个柜子的子级变为另一个柜子的子级,并且位置从一个柜子移动到另一个柜子上的逻辑
在ClearCounter.cs中,增加secondClearCounter参数获取另一个柜子,增加叫testing的布尔值方便测试,在Update()中检测如果按下了T键则将当前柜子上的物品的父级设为另一个柜子,为了将位置也移动过去,我们新增了一个GetKitchenObjectFollowTransform()方法用来获取柜子上放置物品的位置,并在KitchenObject.cs中的SetClearCounter()中加上设置位置的代码

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
    [SerializeField] private Transform counterTopPoint;
    [SerializeField] private ClearCounter secondClearCounter;
    [SerializeField] private bool testing;

    private KitchenObject kitchenObject;

    private void Update()
    {
        if (testing && Input.GetKeyDown(KeyCode.T))
        {
            if (kitchenObject != null)
            {
                kitchenObject.SetClearCounter(secondClearCounter);
            }
        }
    }

    public void Interact()
    {
        if (kitchenObject == null)
        {
            Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab, counterTopPoint);
            kitchenObjectTransform.localPosition = Vector3.zero;
            
            kitchenObject = kitchenObjectTransform.GetComponent<KitchenObject>();
            kitchenObject.SetClearCounter(this);
        } else
        {
            Debug.Log(kitchenObject.GetClearCounter());
        }
    }
   
    public Transform GetKitchenObjectFollowTransform()
    {
        return counterTopPoint;
    }
}
// KitchenObject.cs中
...
public class KitchenObject : MonoBehaviour
{
    ...
    public KitchenObjectSO GetKitchenObjectSO(){...}
   
    public void SetClearCounter(ClearCounter clearCounter)
    {
        this.clearCounter = clearCounter;
        transform.parent = clearCounter.GetKitchenObjectFollowTransform();
        transform.localPosition = Vector3.zero;
    }
   
    public ClearCounter GetClearCounter(){...}
}
这时调整好相应参数,运行游戏,按下T键时发现在Hierachy中番茄确实从当前柜子跑到了另一个柜子上,并且位置也发生了改变



然而这样操作时我们的两个ClearCounter物体中的KitchenObject变量并没有改变,第一个柜子的KitchenObject依然是Tomato,第二个柜子的KitchenObject依然是None(private的变量可以通过右上角三个点打开debug模式查看)



我们有两个途径可以解决这个问题,第一种是在ClearCounter.cs中设置kitchenObject的父级后让新的父级去更新一下对应变量,第二种是在KitchenObject.cs中当该物体被设置到新的父级上时自己去更新父级,这里教程中用了第二种,作者认为让物体去更新父级的代码写在物体对应的脚本中更合理一些
为了在KitchenObject.cs中更新父级,还需要在ClearCounter.cs中增加获取、设置、清空当前柜子下物体和判断当前柜子上是否有物体的方法,然后在KitchenObject.cs的SetClearCounter()中更新父级,当我们把更新父级的逻辑写在KitchenObject.cs的SetClearCounter()后,原来KitchenObject.cs的Interact()中的逻辑就可以不用了,直接SetKitchenObjectParent()即可

// ClearCounter.cs中
public class ClearCounter : MonoBehaviour
{
    ...
    public void Interact()
    {
        if (kitchenObject == null)
        {
            Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab, counterTopPoint);
            // kitchenObjectTransform.localPosition = Vector3.zero;
            // kitchenObject = kitchenObjectTransform.GetComponent<KitchenObject>();
            // kitchenObject.SetKitchenObjectParent(this);
            kitchenObjectTransform.GetComponent<KitchenObject>().SetKitchenObjectParent(this);
        } else
        {
            Debug.Log(kitchenObject.GetClearCounter());
        }
    }
    ...
    public void SetKitchenObject(KitchenObject kitchenObject)
    {
        this.kitchenObject = kitchenObject;
    }

    public KitchenObject GetKitchenObject()
    {
        return kitchenObject;
    }
   
    public void ClearKitchenObject()
    {
        kitchenObject = null;
    }
   
    public bool HasKitchenObject()
    {
        return kitchenObject != null;
    }
}
// KitchenObject.cs中
...
public class KitchenObject : MonoBehaviour
{
    ...
    public void SetClearCounter(ClearCounter clearCounter)
    {
        if (this.clearCounter != null) // 这里的this.clearCounter指的是之前的clearCounter
        {
            this.clearCounter.ClearKitchenObject();
        }
        this.clearCounter = clearCounter;

        if (clearCounter.HasKitchenObject()) // 这里clearCounter指的是现在的、作为传入的clearCounter
        {  
            Debug.LogError("ClearCounter already has a KitchenObject!");  
        }
        clearCounter.SetKitchenObject(this);
        
        transform.parent = clearCounter.GetKitchenObjectFollowTransform();
        transform.localPosition = Vector3.zero;
    }
    ...
}
再次运行游戏,可以看到按下T键之后变量可以被正确更改了



目前为止的工程文件

8 角色拿取物品与C#接口 Player Pick Up & C# Interfaces

我们已经实现了物品从一个柜子上转移到另一个柜子上,同时切换了物品的父级,我们同样也可以将物品从一个柜子上转移到角色身上,同时切换物品的父级为角色,然而,我们现有的逻辑都是针对ClearCounter类写的,因此我们可以把这部分逻辑抽象成一个接口,让Player类与ClearCounter都继承自这个接口,这部分基本上是提取接口、修改变量名的过程,所以笔记就简单写了
我们可以抽象出来接口IkitchenObjectParent,让所有可以成为物品父级的对象都继承自这个接口,这个接口中要实现的方法就和之前ClearCounter.cs中的获取、设置、删除物品等方法相同,在Scripts文件夹新建c#脚本,重命名为IkitchenObjectParent.cs

// IkitchenObjectParent.cs中
using UnityEngine;

public interface IKitchenObjectParent
{
    public Transform GetKitchenObjectFollowTransform();

    public void SetKitchenObject(KitchenObject kitchenObject);

    public KitchenObject GetKitchenObject();

    public void ClearKitchenObject();

    public bool HasKitchenObject();
}
在ClearCounter.cs中,我们要继承接口实现方法,并且要实现让Inertct()方法实现“当按下交互键时,如果柜子上有物品,就将物品转移到角色上”的逻辑,所以需要传入Player类的实例作为参数,在else部分调用SetKitchenObjectParent(player)实现转移的逻辑,以下是实现了对应逻辑并且根据接口进行改名的脚本

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : MonoBehaviour, IKitchenObjectParent
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
    [SerializeField] private Transform counterTopPoint;


    private KitchenObject kitchenObject;


    public void Interact(Player player)
    {
        if (kitchenObject == null)
        {
            Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab, counterTopPoint);
            kitchenObjectTransform.GetComponent<KitchenObject>().SetKitchenObjectParent(this);
        } else
        {
            // 将物品转到角色下,成为角色的子级
            kitchenObject.SetKitchenObjectParent(player);
        }
    }
   
    public Transform GetKitchenObjectFollowTransform()
    {
        return counterTopPoint;
    }
   
    public void SetKitchenObject(KitchenObject kitchenObject)
    {
        this.kitchenObject = kitchenObject;
    }

    public KitchenObject GetKitchenObject()
    {
        return kitchenObject;
    }
   
    public void ClearKitchenObject()
    {
        kitchenObject = null;
    }
   
    public bool HasKitchenObject()
    {
        return kitchenObject != null;
    }
}
在Player.cs中,同样需要继承接口实现方法,并且由于调用了Interact(),经过上面的修改之后Interact()需要传入参数,所以调用的时候要传入this

// Player.cs
...
public class Player : MonoBehaviour, IKitchenObjectParent
{
    ...
    private void GameInput_OnInteractAction(object sender, EventArgs e)
    {
        if (selectedCounter != null)
        {
            selectedCounter.Interact(this);
        }
    }
    ...   
    public Transform GetKitchenObjectFollowTransform()
    {
        return kitchenObjectHoldPoint;
    }
   
    public void SetKitchenObject(KitchenObject kitchenObject)
    {
        this.kitchenObject = kitchenObject;
    }

    public KitchenObject GetKitchenObject()
    {
        return kitchenObject;
    }
   
    public void ClearKitchenObject()
    {
        kitchenObject = null;
    }
   
    public bool HasKitchenObject()
    {
        return kitchenObject != null;
    }
}
在KitchenObject.cs中,需要将ClearCounter相关的全都改为kitchenObjectParent相关的名称

// KitchenObject.cs
using UnityEngine;

public class KitchenObject : MonoBehaviour
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;
   
    private IKitchenObjectParent kitchenObjectParent;
   
    public KitchenObjectSO GetKitchenObjectSO()
    {
        return kitchenObjectSO;
    }
   
    public void SetKitchenObjectParent(IKitchenObjectParent kitchenObjectParent)
    {
        if (this.kitchenObjectParent != null)
        {
            this.kitchenObjectParent.ClearKitchenObject();
        }
        this.kitchenObjectParent = kitchenObjectParent;

        if (kitchenObjectParent.HasKitchenObject())
        {
            Debug.LogError("IKitchenObjectParent already has a KitchenObject!");
        }
        kitchenObjectParent.SetKitchenObject(this);
        
        transform.parent = kitchenObjectParent.GetKitchenObjectFollowTransform();
        transform.localPosition = Vector3.zero;
    }
   
    public IKitchenObjectParent GetKitchenObjectParent()
    {
        return kitchenObjectParent;
    }
}
目前为止的工程文件

9 创建容器柜台 Container Counter

9.1 柜台预制件变体 Prefab Viriant

容器柜台和空柜台都是柜台,因此我们可以直接复制ClearCounter.prefab然后更改一些东西,但是unity为我们提供了一个叫prefab variant的东西,我们这里就创建一个基本柜台的prefab,然后使用prefab variant来创建不同的柜台
复制ClearCounter.prefab,重命名为_BaseCounter.prefab,作为基类,该基类除了要保留Box Collider组件和空物体CounterTopPoint,其余全部删除



在Project窗口中右键_BaseCounter.prefab->Create->Prefab Variant基于这个基类创建一个prefab,将之前ClearCounter.prefab中的内容复制到这个新创建的prefab上,并把脚本都设置好,然后删除之前的ClearCounter.prefab,将新的prefab命名为ClearCounter.prefab



再次右键_BaseCounter.prefab->Create->Prefab Variant基于这个基类创建ContainerCounter.prefab,将ContainerCounter_Visual拖到ContainerCounter下面作为它的子级,复制一份ContainerCounter_Visual重命名为Selected,将Selected下的模型的材质都改为CounterSelected并默认隐藏,将Selected缩放均改为1.01,添加SelectedCounterVisual.cs组件



9.2 继承自柜台基类创建ClearCounter与ContainerCounter

接下来我们在Scripts文件夹下创建ContainerCounter.cs,我们可以让这个脚本中的内容和ClearCounter.cs中的内容一致来进行测试,原教程中先尝试了这么做,但是在写完之后容器柜台并不能正常与角色交互,这时因为在Player.cs中我们进行Raycast的时候检测的就是射线是否与ClearCounter类的实例碰撞,我们当然可以让它也检测射线与ContainerCounter类的实例是否碰撞,但是ContainerCounter与ClearCounter的行为很类似,我们可以写一个基类BaseCounter,将所有柜台都需要实现的逻辑放在基类中,然后其他柜台类都继承自这个类,我们在检测射线与BaseCounter类的实例是否碰撞时,所有柜台就都能被检测到了,通过继承,我们也能够更好地组织代码
在Scripts文件夹下新建BaseCounter.cs,在这个基类中,我们可以写一个虚方法public virtual void Interact(Player player),所有继承自该基类的类都需要使用override重写这个方法,并且由于所有柜台类都有counterTopPoint,都实现了IKitchenObjectParent接口,我们也把这两部分都放到这个基类中

// BaseCounter.cs中
using UnityEngine;

public class BaseCounter : MonoBehaviour, IKitchenObjectParent
{
    [SerializeField] private Transform counterTopPoint;
   
    private KitchenObject kitchenObject;
   
    public virtual void Interact(Player player)
    {
        Debug.Log("BaseCounter.Interact()");
    }

    public Transform GetKitchenObjectFollowTransform()
    {
        return counterTopPoint;
    }
   
    public void SetKitchenObject(KitchenObject kitchenObject)
    {
        this.kitchenObject = kitchenObject;
    }

    public KitchenObject GetKitchenObject()
    {
        return kitchenObject;
    }
   
    public void ClearKitchenObject()
    {
        kitchenObject = null;
    }
   
    public bool HasKitchenObject()
    {
        return kitchenObject != null;
    }
}
在ClearCounter.cs中,改为角色放置和拾取物品的逻辑

// ClearCounter.cs中
using UnityEngine;

public class ClearCounter : BaseCounter
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;

    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                player.GetKitchenObject().SetKitchenObjectParent(this);
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
            } else
            {
                // 角色没有物品,拾取物品
                GetKitchenObject().SetKitchenObjectParent(player);
            }
        }
    }
}
在ContainerCounter.cs中,只需要实现按下交互按键时该容器柜子对应的物品出现在角色手上的逻辑

// ContainerCounter.cs中
using UnityEngine;

public class ContainerCounter : BaseCounter
{
    [SerializeField] private KitchenObjectSO kitchenObjectSO;

    if (!player.HasKitchenObject())
         {
                 // 角色没有物品,拾取物品
                 Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab);
                 kitchenObjectTransform.GetComponent<KitchenObject>().SetKitchenObjectParent(player);
         }
}
在SelectCounterVisual.cs中,我们之前使用的是ClearCounter类的实例,这样ContainerCounter是无法被正确选中的,现在要换成BaseCounter,同时我们的ContainerCounter模型有好几部分组成,因此在设置选中效果的模型时,我们需要传入一个数组,遍历这个数组将其中所有的对象设为显示或隐藏(注释部分是修改之前的代码)

// SelectCounterVisual.cs中
using UnityEngine;

public class SelectCounterVisual : MonoBehaviour
{
    // [SerializeField] private ClearCounter clearCounter;
    [SerializeField] private BaseCounter baseCounter;
    // [SerializeField] private GameObject visualGameObject;
    [SerializeField] private GameObject[] visualGameObjectArray;
   
    private void Start()
    {
        Player.Instance.OnSelectedCounterChanged += Player_OnSelectedCounterChanged;
    }
   
    private void Player_OnSelectedCounterChanged(object sender, Player.OnSelectedCounterChangedEventArgs e)
    {
        // if (e.selectedCounter == clearCounter)
        if (e.selectedCounter == baseCounter)
        {
            show();
        }
        else
        {
            hide();
        }
    }

    private void show()
    {
        // visualGameObject.SetActive(true);
        foreach (GameObject visualGameObject in visualGameObjectArray)
        {
            visualGameObject.SetActive(true);
        }
    }
    private void hide()
    {
        // visualGameObject.SetActive(false);
        foreach (GameObject visualGameObject in visualGameObjectArray)
        {
            visualGameObject.SetActive(false);
        }
    }
}
代码之外,在ContainerCounter.prefab上挂载上对应的脚本



由于我们改变了SelectCounterVisual.cs这个脚本,原先的ClearCounter上的相应组件也要重新设置一下



在场景中放置两个ContainerCounter,其中一个将上面的Sprite改为番茄,将这个柜台的CountainerCounter组件中的SkitchenObjectSO也改为Tomato,让我们能够从中拿取番茄



现在运行游戏,我们就可以从对应的柜台中获取对应的物品了



9.3 容器柜台的动画

我们的ContainerCounter素材实际上已经包含了一个打开柜子的动画,直接双击ContainerCounter_Visual上Animator组件的Controller部分,可以看到Animator面板中已经设置好的状态机,这些动画通过OpenClose这个参数来控制开关



在Scripts文件夹新建ContainerCounterVisual.cs,在ContainerCounter.cs中触发事件,在ContainerCounterVisual.cs中订阅事件

// ContainerCounter.cs中
using System;
using UnityEngine;

public class ContainerCounter : BaseCounter
{
    public event EventHandler OnPlayerGrabbedObject;
   
    [SerializeField] private KitchenObjectSO kitchenObjectSO;

    public override void Interact(Player player)
    {
        if (!player.HasKitchenObject())
        {
            // 角色没有物品,拾取物品
            Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab);
            kitchenObjectTransform.GetComponent<KitchenObject>().SetKitchenObjectParent(player);
            OnPlayerGrabbedObject?.Invoke(this, EventArgs.Empty);
        }
    }
}
// ContainerCounterVisual.cs中
using System;
using UnityEngine;

public class ContainerCounterVisual : MonoBehaviour
{
    private const string OPEN_CLOSE = "OpenClose";
   
    [SerializeField] private ContainerCounter containerCounter;
   
    private Animator animator;

    private void Awake()
    {
        animator = GetComponent<Animator>();
    }

    private void Start()
    {
        containerCounter.OnPlayerGrabbedObject += ContainerCounter_OnPlayerGrabbedObject;
    }
   
    private void ContainerCounter_OnPlayerGrabbedObject(object sender, EventArgs e)
    {
        animator.SetTrigger(OPEN_CLOSE);
    }
}
再次运行游戏,就可以看到对应的动画了



9.4 容器柜台预制件变体

之前我们是通过复制容器柜台然后更改上面的Sprite创建新的容器柜台的,为了方便起见我们也应该以容器柜台为基类去创建Prefab Viriant
通过Script Object给其他食材物品创建对象(Bread、Cabbage、MeatPattyUncooked),复制现有的食材的Prefab,替换组件中的KitchenObjectSO项,然后将模型更改为KitchenObjectsVisuals中对应的模型,如Bread.prefab和Bread.asset更改后如下



所有食材物品创建好后应该如下所示



然后右键ContainerCounter.prefab->Create->Prefab Viriant给对应食材创建对应容器柜台的Prefab Viriant,更改每个Prefab Viriant组件中的KitchenObjectSO项与Sprite





将各种柜台摆放在场景中,运行游戏,我们就能够从各个容器柜台中拿取对应的食材了


目前为止的工程文件)

10 创建切菜台 Cutting Counter

10.1 继承自柜台基类创建 CuttingCounter

右键BaseCounter.prefab->Create->Prefab Variant创建一个新的prefab,重命名为CuttingCounter.prefab,从Assets/PrefabsVisuals/CounterVisuals找到CuttingCounter_Visual,拖动到CuttingCounter.prefab下,复制一个重命名为Selected,像之前一样给Selected加上SelectedCounterVisual.cs组件,将其中的模型材质换为CounterSelected,将Selected的缩放改为1.01



在Scripts创建CuttingCounter.cs,同样要继承自BaseCounter基类,暂时先放上与ClearCounter.cs相同的交互逻辑

// CuttingCounter.cs中
using UnityEngine;

public class CuttingCounter : BaseCounter
{
    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                player.GetKitchenObject().SetKitchenObjectParent(this);
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
            } else
            {
                // 角色没有物品,拾取物品
                GetKitchenObject().SetKitchenObjectParent(player);
            }
        }
    }
}
在CuttingCounter上添加CuttingCounter.cs组件,将CuttingCounter拖动到Selected的组件中的BaseCounter项上,开始游戏,就可以看到实现了和ClearCounter相同的交互效果

10.2 CutKitchenObject与简单的切菜交互

要实现切菜交互的效果,我们需要删除放在切菜台上的食材,替换为切片的相应的食材的模型,然后播放切菜的循环动画,这里我们先来实现按下相应按键番茄替换为番茄切片的简单效果,为此我们需要创建相应的ScriptableObject与prefab
先来做一个番茄的切片素材的ScriptableObject与prefab,在ScriptableObjects/KitchenObjectsSO下右键->Create->Kitchen Object SO,重命名为TomatoSlices



在Setting/PlayInputActions.inputactions中,添加一个Action,绑定F按键



要实现按下F键物体替换为物体切片的效果,先要实现按下F键删除原物体的效果,可以按照写上一个Interact的Action差不多的方式去完成。我们在BaseCounter.cs这个基类中写了一个InteractAlternate()的接口,在CuttingCounter.cs中实现了该接口。在GameInput.cs中添加按下F键将会触发的函数,这里这个函数中我们触发一个EventHandler委托,在Player.cs中在该委托上添加会调用的函数GameInput_OnInteractAlternateAction(),在函数中使用selectedCounter.InteractAlternate(this)调用具体方法。

// BaseCounter.cs中
...
public class BaseCounter : MonoBehaviour, IKitchenObjectParent
{
    ...
    public virtual void InteractAlternate(Player player)
    {
        Debug.LogError("BaseCounter.InteractAlternate()"); // 不是所有的柜台都有这个交互,所以不应该输出Error信息,一开始作者是输出的,但是在后面改了
    }
    ...
}
// CuttingCounter.cs中
...
public class CuttingCounter : BaseCounter
{
    [SerializeField] private KitchenObjectSO cutKitchenObjectSO;
   
    public override void Interact(Player player)
    {
        ...
    }
   
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject())
        {
            // 柜子上有物品,开始切菜
            GetKitchenObject().DetroySelf();
        }
    }
}
// GameInput.cs中
public class GameInput : MonoBehaviour
{
    ...
    public event EventHandler OnInteractAlternateAction;
    ...
    private void Awake()
    {
        ...
        playerInputActions.Player.InteractAlternate.performed += InteractAlternate_performed;
    }
    ...
    private void InteractAlternate_performed(UnityEngine.InputSystem.InputAction.CallbackContext obj)
    {
        OnInteractAlternateAction?.Invoke(this, EventArgs.Empty);
    }
    ...
}
// Player.cs中
...
public class Player : MonoBehaviour, IKitchenObjectParent
{
    ...

    private void Start()
    {
        ...
        gameInput.OnInteractAlternateAction += GameInput_OnInteractAlternateAction;
    }
   
    ...
   
    private void GameInput_OnInteractAlternateAction(object sender, EventArgs e)
    {
        if (selectedCounter != null)
        {
            selectedCounter.InteractAlternate(this);
        }
    }
    ...
}
接下来我们还要实现对应的物品切片模型出现的效果,我们在KitchenObject.cs中写一个SpawnKitchenObject()方法,然后在CuttingCounter.cs的InteractAlternate()方法中调用

// KitchenObject.cs中
...
public class KitchenObject : MonoBehaviour
{
    ...
    public static KitchenObject SpawnKitchenObject(KitchenObjectSO kitchenObjectSO, IKitchenObjectParent kitchenObjectParent)
    {
        Transform kitchenObjectTransform = Instantiate(kitchenObjectSO.prefab);
        KitchenObject kitchenObject = kitchenObjectTransform.GetComponent<KitchenObject>();
        kitchenObject.SetKitchenObjectParent(kitchenObjectParent);
        return kitchenObject;
    }
}
// CuttingCounter.cs中
...
public class CuttingCounter : BaseCounter
{
    [SerializeField] private KitchenObjectSO cutKitchenObjectSO;
   
    public override void Interact(Player player)
    {
        ...
    }
   
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject())
        {
            // 柜子上有物品,开始切菜
            GetKitchenObject().DetroySelf();
            KitchenObject.SpawnKitchenObject(cutKitchenObjectSO, this);
        }
    }
}
至于要出现的切片模型我们先来往场景拖一个CuttingCounter.prefab然后规定死测试一下,就用上面做好的番茄切片



目前为止的工程文件

10.3 CuttingRecipeSO

为了使我们的代码更易扩展,我们可以给把这些表示物品对象到对应物体切片对象的信息用ScriptableObject储存
首先先把几个物品切片的ScriptbaleObject创建好,在KitchenObjectSO下右键->Create->Kitchen Object SO创建CabbageSlices.asset、CheeseSlices.asset,并在Prefab/KitchenObjects中创建相应的Prefab填好信息



我们还需要知道正常物品与切片物品的对应关系,在Scripts文件夹下新建CuttingRecipeSO.cs,其中我们需要两个ScriptableObject,一个表示正常的物品对象,一个表示该物品对应的物品切片对象

// CuttingRecipeSO.cs
using UnityEngine;

[CreateAssetMenu()]
public class CuttingRecipeSO : ScriptableObject
{
    public KitchenObjectSO input;
    public KitchenObjectSO output;
}
在ScriptableObjects文件夹下新建CuttingRecipeSO,在该文件夹下右键->Create->Cutting Recipe SO,创建Tomato-TomatoSilices.asset、Cabbage-CabbageSlices.asset、CheeseBlock-CheeseSlices.asset并设置好相应的变量



在CuttingCounter.cs中,我们使用一个数组来保存CuttingRecipeSO对象的实例,然后写一个GetOutputForInput()方法用foreach来获取对应输入物体的切片版本

// CuttingCounter.cs中
...
public class CuttingCounter : BaseCounter
{
    // [SerializeField] private KitchenObjectSO cutKitchenObjectSO;
    [SerializeField] private CuttingRecipeSO[] cuttingRecipeSOArray;
    ...
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject())
        {
            // 柜子上有物品,开始切菜
            KitchenObjectSO outputKitchenObjectSO = GetOutputForInput(GetKitchenObject().GetKitchenObjectSO());
            GetKitchenObject().DetroySelf();
            KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this);
        }
    }

    private KitchenObjectSO GetOutputForInput(KitchenObjectSO inputKitchenObjectSO)
    {
        foreach (CuttingRecipeSO cuttingRecipeSO in cuttingRecipeSOArray)
        {
            if (cuttingRecipeSO.input == inputKitchenObjectSO)
            {
                return cuttingRecipeSO.output;
            }
        }
        return null;
    }
}
设置CuttingCounter组件对应变量



运行游戏,可以看到当按下F键时,我们可以将对应物品切片了



然而,并不是所有的物品都可以被切片,如果遇到不能被切片的物品或者已经被切过的切片我们按下F键就会报错,我们在CuttingCounter.cs中添加一个HasRecipeInput()方法来检查对应物品是否有切片形式,在Interact()和InteractAlternate()中角色放置物品时进行检查

// CuttingCounter.cs
using UnityEngine;

public class CuttingCounter : BaseCounter
{
    [SerializeField] private CuttingRecipeSO[] cuttingRecipeSOArray;
   
    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被切片
                    player.GetKitchenObject().SetKitchenObjectParent(this);
                }
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            ...
        }
    }
   
    public override void InteractAlternate(Player player)
    {
        // if (HasKitchenObject())
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO()))
        {
            // 柜子上有物品,开始切菜
            ...
        }
    }
   
    private bool HasRecipeWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        foreach (CuttingRecipeSO cuttingRecipeSO in cuttingRecipeSOArray)
        {
            if (cuttingRecipeSO.input == inputKitchenObjectSO)
            {
                return true;
            }
        }
        return false;
    }
   
    ...
}
目前为止,我们就不能在切菜台上放置不能切的物品,并且不能切已经切过的物品了
目前为止的工程文件

10.4 使用Canvas绘制切菜进度条

现在我们的菜一下就切好了,我们希望不同的食材需要按不同次F键才能被切成片,在CuttingRecipeSO.cs中,增加一个变量cuttingProgressMax保存这种食材应该切的次数

// CuttingRecipeSO.cs中
using UnityEngine;

[CreateAssetMenu()]
public class CuttingRecipeSO : ScriptableObject
{
    public KitchenObjectSO input;
    public KitchenObjectSO output;
    public int cuttingProgressMax;
}
在CuttingCounter.cs中,我们用cuttingProgress保存切菜的按键数量,该数量在每次在Interact()方法被调用时重置为0,在每次调用InteractAlternate()方法时+1,由于我们需要频繁获取每样物品的对应的ScriptObject,这里将GetCuttingRecipeSOWithInput()这个方法抽象了出来

// CuttingCounter.cs中
using UnityEngine;

public class CuttingCounter : BaseCounter
{
    [SerializeField] private CuttingRecipeSO[] cuttingRecipeSOArray;
   
    private int cuttingProgress;
   
    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被切片
                    ...
                    cuttingProgress = 0;
                    ...
                }
            } else
            {
                ...
            }
        } else
        {
            ...
        }
    }
   
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO()))
        {
            // 柜子上有物品,开始切菜
            cuttingProgress++;
            
            if (cuttingProgress >= GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO()).cuttingProgressMax)
            {
                KitchenObjectSO cutKitchenObjectSO = GetOutputForInput(GetKitchenObject().GetKitchenObjectSO());
                GetKitchenObject().DetroySelf();
                KitchenObject.SpawnKitchenObject(cutKitchenObjectSO, this);
            }
        }
    }
   
    private bool HasRecipeWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(inputKitchenObjectSO);
        return cuttingRecipeSO != null;
    }
   
    private KitchenObjectSO GetOutputForInput(KitchenObjectSO inputKitchenObjectSO)
    {
        CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(inputKitchenObjectSO);
        if (cuttingRecipeSO != null)
        {
            return cuttingRecipeSO.output;
        } else
        {
            return null;
        }
    }
   
    private CuttingRecipeSO GetCuttingRecipeSOWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        foreach (CuttingRecipeSO cuttingRecipeSO in cuttingRecipeSOArray)
        {
            if (cuttingRecipeSO.input == inputKitchenObjectSO)
            {
                return cuttingRecipeSO;
            }
        }
        return null;
    }
}
在编辑器中,为三个CuttingRecipeSO的CuttingProgressMax赋予不同的值,比如这里将番茄与奶酪设置为5,卷心菜设置为3



加上一点控制台输出,现在启动游戏就能看到不同食材需要切不同次数了



接下来来使用World Canvas将切菜的进度用进度条绘制出来
进入CuttingCounter.prefab的编辑界面,Hierachy窗口右键->UI->Canvas创建一个Canvas对象,重命名为ProgressBarUI,将Canvas组件下的Render Mode改为World Space,然后调整位置到柜台上方并将长宽设置为0用于定位;在ProgressBarUI下新建Cavans,重命名为Bar,在Bar中调整大小、设置Image组件中的Color、Source Image、Fill Method,滑动调整Fill Amount的值即可看到场景中进度条的变化;复制Bar,重命名为Background,放在Bar上方以确保渲染顺序正确,添加Outline组件并设置Effect Color的Alpha值为0,调整Effect Distance到合适值,将Image组件中的Color改为深灰色。



新建完Canvas,我们需要一个写一个脚本来根据切菜的进度来控制进度条。在Scripts文件夹下新建ProgressBarUI.cs,挂载到Prefab中的ProgressBarUI上,在CuttingCounter.cs中添加EventHandler委托,在每一次切菜时触发事件并传递归一化后的进度值,在ProgressBarUI.cs中写用这个进度值控制fillAmount值的逻辑与进度条隐藏显示的逻辑

// CuttingCounter.cs中
...
public class CuttingCounter : BaseCounter
{
    public event EventHandler<OnProgressChangedEventArgs> OnProgressChanged;
    public class OnProgressChangedEventArgs : EventArgs
    {
        public float progressNormalized;
    }
    ...
    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被切片
                    ...
                    
                    CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
                    OnProgressChanged?.Invoke(this, new OnProgressChangedEventArgs
                    {
                        progressNormalized = (float)cuttingProgress / cuttingRecipeSO.cuttingProgressMax
                    });
                }
            } else
            {
                ...
            }
        } else
        {
            ...
        }
    }
   
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO()))
        {
            // 柜子上有物品,开始切菜
            ...
            OnProgressChanged?.Invoke(this, new OnProgressChangedEventArgs
            {
                CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
                progressNormalized = (float)cuttingProgress / cuttingRecipeSO.cuttingProgressMax
            });
            ...
        }
    }
    ...
}
// ProgressBarUI.cs中
using UnityEngine;
using UnityEngine.UI;

public class ProgressBarUI : MonoBehaviour
{
    [SerializeField] private CuttingCounter cuttingCounter;
    [SerializeField] private Image barImage;

    private void Start()
    {
        cuttingCounter.OnProgressChanged += CuttingCounter_OnProgressChanged;
        
        barImage.fillAmount = 0;
        Hide();
    }
   
    private void CuttingCounter_OnProgressChanged(object sender, CuttingCounter.OnProgressChangedEventArgs e)
    {
        barImage.fillAmount = e.progressNormalized;
        
        if (e.progressNormalized == 0f || e.progressNormalized == 1)
        {
            Hide();
        } else
        {
            Show();
        }
    }

    private void Show()
    {
        gameObject.SetActive(true);
    }
   
    private void Hide()
    {
        gameObject.SetActive(false);
    }
}
目前为止运行游戏就可以看到带有进度条的切菜台了



10.5 切菜台的动画

目前为止运行游戏我们已经能够在将物品放到切菜台上按下F键时看到进度条了,另外素材中还有一个切菜的动画,其中CuttingCouterCut为刀切菜的动画,CuttingCounterIdle为静止状态



打开Animations/CuttingCounter.controller,在Animator面板可以看到,动画由AnyState通过一个Trigger进入CuttingCounterCut状态,然后执行完该动画后就会再次回到Idle状态



在Scripts文件夹新建CuttingCounterVisual.cs,在CuttingCounter.cs中添加EventHandler委托,该委托在开始切菜时触发,委托执行的函数CuttingCounter_OnCut()写在CuttingCounterVisual.cs中,该函数将触发控制动画的Trigger

// CuttingCounter.cs
...
public class CuttingCounter : BaseCounter
{
    ...
    public event EventHandler OnCut;
    ...   
    public override void InteractAlternate(Player player)
    {
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO()))
        {
            // 柜子上有物品,开始切菜
            ...
            OnCut?.Invoke(this, EventArgs.Empty);
            ...
        }
    }
    ...
}
// CuttingCounterVisual.cs中
using System;
using UnityEngine;

public class CuttingCounterVisual : MonoBehaviour
{
    private const string CUT = "Cut";

    [SerializeField] private CuttingCounter cuttingCounter;
   
    private Animator animator;

    private void Awake()
    {
        animator = GetComponent<Animator>();
    }

    private void Start()
    {
        cuttingCounter.OnCut += CuttingCounter_OnCut;
    }

    private void CuttingCounter_OnCut(object sender, EventArgs e)
    {
        animator.SetTrigger(CUT);
    }
}
运行游戏,就可以看到带有动画和进度条的切菜台了



目前为止的工程文件

10.6 切菜进度条朝向摄像机

目前我们的切菜台的进度条还有一个问题,如果我们180度旋转该柜台,我们的UI也会跟着旋转,我们会从背面看到进度条,进度条会呈现从右向左的状态



为了解决这个问题我们可以创建一个脚本,让进度条始终面朝摄像机。在Scripts文件夹新建LookAtCamera.cs,添加到CuttingCounter.prefab的ProgressBarUI上,其中LateUpdate()会在所有Update()调用后被调用

// LookAtCamera.cs中
using UnityEngine;

public class LookAtCamera : MonoBehaviour
{
    private void LateUpdate()
    {
        transform.LookAt(Camera.main.transform);
    }
}
运行游戏,发现进度条确实朝向了摄像机,但是有两个问题,一个是我们的Canvas其实默认是背面朝摄像机的,使用LookAt函数会导致进度条变为从右向左的样式,另一个问题是,由于它朝向了摄像机,所以在画面中并不是完全水平的,而是会随着摄像机的左右移动而左右倾斜



上面的效果具体要哪种是个人偏好问题,我们可以使用枚举类型和switch语句设置一些选项供我们在编辑器中选择,其中进度条左右翻转的问题通过看向完全相反的位置修复,左右倾斜的问题通过看向摄像机朝向方向修复

// LookAtCamera.cs中
using UnityEngine;

public class LookAtCamera : MonoBehaviour
{
    private enum Mode
    {
        LookAt,
        LookAtInverted,
        CameraForward,
        CameraForwardInverted
    }

    [SerializeField] private Mode mode;
   
    private void LateUpdate()
    {
        switch (mode)
        {
            case Mode.LookAt:
                transform.LookAt(Camera.main.transform);
                break;
            case Mode.LookAtInverted:
                Vector3 dirFromCamera = transform.position - Camera.main.transform.position;
                transform.LookAt(transform.position + dirFromCamera);
                break;
            case Mode.CameraForward:
                transform.forward = Camera.main.transform.forward;
                break;
            case Mode.CameraForwardInverted:
                transform.forward = -Camera.main.transform.forward;
                break;
        }
    }
}
这里选择CameraForward模式来得到从左到右的、水平的进度条



目前为止的工程文件

11 创建垃圾箱 Trash Counter

垃圾箱的逻辑很简单,只需要按下交互按键时销毁角色手上的物品即可
右键BaseCounter.prefab->Create->Prefab Variant创建一个新的prefab,重命名为TrashCounter.prefab,从Assets/PrefabsVisuals/CounterVisuals找到TrashCounter_Visual,拖动到TrashCounter.prefab下,复制一个重命名为Selected,像之前一样给Selected加上SelectedCounterVisual.cs组件,将其中的模型材质换为CounterSelected,将Selected的缩放改为1.01



在Scripts创建TrashCounter.cs,同样要继承自BaseCounter基类,当按下交互键时检查如果角色手上有物品则销毁

// CuttingCounter.cs中
public class TrashCounter : BaseCounter
{
    public override void Interact(Player player)
    {
        if (player.HasKitchenObject())
        {
            player.GetKitchenObject().DetroySelf();
        }
    }
}
顺便可以把Scripts文件夹下的文件整理一下,创建Counters文件夹和ScriptableObjets文件夹,将相应的文件分类放好



在TrashCounter上添加TrashCounter.cs组件,将TrashCounter拖动到Selected的组件中的BaseCounter项上,开始游戏,就可以看到实现了垃圾箱的效果



目前为止的工程文件

12 创建炉灶台 Stove Counter

12.1 继承自柜台基类创建StoveCounter与简单的计时器

与上面创建其他Counter相同,从_BaseCounter.prefab创建PrefabVariant、复制一个Selected,改缩放,将CounterTopPoint移动到炉灶台上锅的中心处,在Scripts文件夹下新建StoveCounter.cs继承自BaseCounter,挂载好对应脚本



我们需要将要经过炉灶台的生的食材变成熟的,这个转换关系也可以用Scriptable Object来存储。在Scripts文件夹下新建FryingRecipeSO.cs

// FryingRecipeSO.cs
using UnityEngine;

[CreateAssetMenu()]
public class FryingRecipeSO : ScriptableObject
{
    public KitchenObjectSO input;
    public KitchenObjectSO output;
    public float fryingTimerMax;
}
在Scriptbale Object文件夹下新建FryingObjectSO文件夹,在这个文件夹下右键->Create->Frying Recipe SO,新建MeatPattyUncooked-MeatPattyCooked.asset,将MeatPattyCooked和MeatPattyBurned的Prefab和KitchenObjectSO都创建好,将所有要设置的参数都设置好



在StoveCounter.cs中,我们仿照CuttingCouter.cs写出Interact()、HasRecipeWithInput()、GetOutputForInput()、GetFryingRecipeSOWithInput()几个函数,然后声明一个fryingTimer,在Update()中随时间增加,一旦到达了设定的值就会销毁原物品生成被煎好的版本(这里计时器也可以使用协程写,但是作者更喜欢直接声明变量)

// StoveCounter.cs中
using UnityEngine;

public class StoveCounter : BaseCounter
{
    [SerializeField] private FryingRecipeSO[] fryingRecipeSOArray;
   
    private float fryingTimer;
    private FryingRecipeSO fryingRecipeSO;

    private void Update()
    {
        if (HasKitchenObject())
        {
            fryingTimer += Time.deltaTime;
            if (fryingTimer > fryingRecipeSO.fryingTimerMax)
            {
                // 煎好了
                fryingTimer = 0f;
                Debug.Log("Fried!");
                GetKitchenObject().DetroySelf();
                KitchenObject.SpawnKitchenObject(fryingRecipeSO.output, this);
            }
            Debug.Log(fryingTimer);
        }
    }

    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被煎
                    player.GetKitchenObject().SetKitchenObjectParent(this);
                    fryingRecipeSO = GetFryingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
                }
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
            } else
            {
                // 角色没有物品,拾取物品
                GetKitchenObject().SetKitchenObjectParent(player);
            }
        }
    }
   
    private bool HasRecipeWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        FryingRecipeSO fryingRecipeSO = GetFryingRecipeSOWithInput(inputKitchenObjectSO);
        return fryingRecipeSO != null;
    }
   
    private KitchenObjectSO GetOutputForInput(KitchenObjectSO inputKitchenObjectSO)
    {
        FryingRecipeSO fryingRecipeSO = GetFryingRecipeSOWithInput(inputKitchenObjectSO);
        if (fryingRecipeSO != null)
        {
            return fryingRecipeSO.output;
        } else
        {
            return null;
        }
    }
   
    private FryingRecipeSO GetFryingRecipeSOWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        foreach (FryingRecipeSO fryingRecipeSO in fryingRecipeSOArray)
        {
            if (fryingRecipeSO.input == inputKitchenObjectSO)
            {
                return fryingRecipeSO;
            }
        }
        return null;
    }
}
这样运行游戏还有一些bug,比如我们在计时器到了之后便会重新开始计时,等到第二次计时器到了后会生成第二个被煎好的食材,不过我们已经能看到被煎熟的效果,下面会通过一个状态机来实现正确的效果



12.2 使用状态机控制食物状态

为了实现煎糊的效果,我们还需要创建一个ScriptableObject用来保存煎熟的和煎糊的食材间的对应关系,在Scripts/ScriptableObjects中,新建BurningRecipeSO.cs

// BurningRecipeSO.cs中
using UnityEngine;

[CreateAssetMenu()]
public class BurningRecipeSO : ScriptableObject
{
    public KitchenObjectSO input;
    public KitchenObjectSO output;
    public float burningTimerMax;
}
在ScriptableObjects下新建BurningRecipeSO文件夹,在该文件夹下新建MeatPattyCooked-MeatPattyBurned.asset,设置对应变量



接下来我们要用状态机来实现食物在一开始被放到炉灶台上时间到了会被煎熟,等再过了一段时间后会被煎糊的效果,使用一个枚举类型的变量State保存各个状态,在Start()中初始化状态为Idle,在Update()中用一个switch语句写下各个状态的行为,在Interact()中写下当角色把食材放到炉灶台上重置计时器与设置状态为Frying、当角色拿取物品设置状态为Idle的逻辑

// StoveCounter.cs中
...
public class StoveCounter : BaseCounter
{
    public enum State
    {
        Idle,
        Frying,
        Fried,
        Burned
    }
   
    [SerializeField] private FryingRecipeSO[] fryingRecipeSOArray;
    [SerializeField] private BurningRecipeSO[] burningRecipeSOArray;
   
    private State state;
    private float fryingTimer;
    private FryingRecipeSO fryingRecipeSO;
    private float burningTimer;
    private BurningRecipeSO burningRecipeSO;

    private void Start()
    {
        state = State.Idle;
    }
   
    private void Update()
    {
        if (HasKitchenObject())
        {
            switch (state)
            {
                case State.Idle:
                    break;
                case State.Frying:
                    fryingTimer += Time.deltaTime;
                    if (fryingTimer > fryingRecipeSO.fryingTimerMax)
                    {
                        // 煎好了
                        fryingTimer = 0f;
                        GetKitchenObject().DetroySelf();
                        KitchenObject.SpawnKitchenObject(fryingRecipeSO.output, this);
                    
                        Debug.Log("Object fried!");
                        state = State.Fried;
                        burningTimer = 0f;
                        burningRecipeSO = GetBurningRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
                    }
                    break;
                case State.Fried:
                    burningTimer += Time.deltaTime;
                    if (burningTimer > burningRecipeSO.burningTimerMax)
                    {
                        // 煎好了
                        fryingTimer = 0f;
                        GetKitchenObject().DetroySelf();
                        KitchenObject.SpawnKitchenObject(burningRecipeSO.output, this);
                    
                        Debug.Log("Object burned!");
                        state = State.Burned;
                    }
                    break;
                case State.Burned:
                    break;
            }
        Debug.Log(state);
        }
    }

    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被煎
                    ...
                    state = State.Frying;
                    fryingTimer = 0f;
                }
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
            } else
            {
                // 角色没有物品,拾取物品
                ...
                state = State.Idle;
            }
        }
    }
    ...
    private BurningRecipeSO GetBurningRecipeSOWithInput(KitchenObjectSO inputKitchenObjectSO)
    {
        foreach (BurningRecipeSO burningRecipeSO in burningRecipeSOArray)
        {
            if (burningRecipeSO.input == inputKitchenObjectSO)
            {
                return burningRecipeSO;
            }
        }
        return null;
    }
}
挂载好相应的组件并设置变量



运行游戏,就能看到将肉饼放到炉灶台上由生变熟变糊的过程



12.3 简单的特效

在所给的StoveCounter_Visual素材中有两个简单的效果,一个是SizzlingParticles像火花的粒子效果,一个是StoveOnVisual红色的自发光面片,这两个加上之后就有一种烹饪的感觉,我们希望在Frying和Fried状态出现这个效果



粒子效果主要调整了Gravity Source、Size over Lifetime的曲线让粒子发射出来逐渐受力落下变小



在Scripts/Counters文件夹新建StoveCounterVisual.cs,在StoveCounter.cs中添加EventHandler委托,在每一处状态改变的代码后触发该委托并传递当前状态作为参数,在StoveCounterVisual.cs中写在Frying和Fried状态才会出现上面两个视觉效果的逻辑

// StoveCounter.cs中
...
state = State.Fried;
OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
{
        state = state
});
...
state = State.Burned;
OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
{
        state = state
});
...
state = State.Frying;
OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
{
        state = state
});
...
state = State.Idle;
OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
{
        state = state
});
...
// StoveCounterVisual.cs中
using UnityEngine;

public class StoveCounterVisual : MonoBehaviour
{
    [SerializeField] private StoveCounter stoveCounter;
    [SerializeField] private GameObject stoveGameObject;
    [SerializeField] private GameObject particlesGameObject;
   
    private void Start()
    {
        stoveCounter.OnStateChanged += StoveCounter_OnStateChanged;
    }
   
    private void StoveCounter_OnStateChanged(object sender, StoveCounter.OnStateChangedEventArgs e)
    {
        bool showVisual = e.state == StoveCounter.State.Frying || e.state == StoveCounter.State.Fried;
        stoveGameObject.SetActive(showVisual);
        particlesGameObject.SetActive(showVisual);
    }
}
将StoveCounterVisual.cs挂载到StoveCounter_Visual上,设置好变量,运行游戏,即可看到煎肉的特效



12.4 使用接口重构进度条组件用在炉灶台上

我们在切菜台上使用的进度条组件ProgressBarUI.cs只接受CuttingCounter类的实例,但是我们想要实现一个更通用的进度条组件,之后其他需要表示进度的物品都可以用上,所以我们需要修改一下ProgressBarUI.cs,使它接受一个实现了IHasProgress接口的类的实例
将CuttingCounter.prefab下的ProgressBarUI对象拖动到到Prefabs文件夹,在Scripts文件夹新建IHasProgress.cs,在这个脚本中我们定义一个接口,这个接口拥有一个可以传递已经归一化了的进度的EventHandler委托

// IHasProgress.cs中
using System;

public interface IHasProgress
{
    public event EventHandler<OnProgressChangedEventArgs> OnProgressChanged;
    public class OnProgressChangedEventArgs : EventArgs
    {
        public float progressNormalized;
    }
}
在ProgressBarUI.cs中,我们将原来使用CuttingCounter类的实例的方法与其定义的委托的地方替换为实现了IHasProgress接口的类的实例的方法与其定义的委托,但是Unity并不支持我们在编辑器面板显示Interface,所以我们先声明了一个GameObject对象,在Start()中再确定实现了该接口的对象

// ProgressBarUI.cs中
...
public class ProgressBarUI : MonoBehaviour
{
    // [SerializeField] private CuttingCounter cuttingCounter;
    [SerializeField] private GameObject hasProgressGameObject;
    ...
    private IHasProgress hasProgress;

    private void Start()
    {
        hasProgress = hasProgressGameObject.GetComponent<IHasProgress>();
        if (hasProgress == null)
        {
            Debug.LogError("Game Object " + hasProgressGameObject + " does not have a component that implements IHasProgress!");
        }
        
        // cuttingCounter.OnProgressChanged += CuttingCounter_OnProgressChanged;
        hasProgress.OnProgressChanged += HasProgress_OnProgressChanged;
        ...
    }
   
    // private void CuttingCounter_OnProgressChanged(object sender, CuttingCounter.OnProgressChangedEventArgs e)
    private void HasProgress_OnProgressChanged(object sender, IHasProgress.OnProgressChangedEventArgs e)
    {
        ...
    }
    ...
}
我们先来修复CuttingCounter.cs,我们需要让该类继承自IHasProgress接口,然后改动所有使用OnProgressChanged这个委托的地方,注释处是改动前

// CuttingCounter.cs中
...
public class CuttingCounter : BaseCounter, IHasProgress
{
// public event EventHandler<OnProgressChangedEventArgs> OnProgressChanged;
// public class OnProgressChangedEventArgs : EventArgs
// {
//     public float progressNormalized;
// }
        public event EventHandler<IHasProgress.OnProgressChangedEventArgs> OnProgressChanged;
        ...
        // OnProgressChanged?.Invoke(this, new OnProgressChangedEventArgs
        OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
        {
                ...
        });
        ...
        // OnProgressChanged?.Invoke(this, new OnProgressChangedEventArgs
        OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
        {
                 ...
        });
}
...
运行游戏,可以正常切菜



然后来处理在StoveCounter上的进度条显示,首先进到StoveCounter,将Prefab文件夹下的ProgressBarUI.prefab拖动到StoveCounter下,设置好组件中相应变量,在StoveCounter.cs中,在Interact()中角色将物品放至炉灶台上时、Frying状态中、Fried状态中触发OnProgressChanged委托设置归一化进度值,在计时器跑完Fried变为Burned状态时和角色中途拿起物品时同样触发OnProgressChanged委托,将归一化进度值设置为0

// StoveCounter.cs中
...
public class StoveCounter : BaseCounter, IHasProgress
{
    public event EventHandler<IHasProgress.OnProgressChangedEventArgs> OnProgressChanged;
    ...
    private void Update()
    {
        switch (state)
        {
            if (HasKitchenObject())
            {
                case State.Idle:
                    break;
                case State.Frying:
                    ...
                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
                    {
                        progressNormalized = fryingTimer / fryingRecipeSO.fryingTimerMax
                    });
               
                    if (fryingTimer > fryingRecipeSO.fryingTimerMax)
                    {
                        // 煎好了
                        ...
                    }
                    break;
                case State.Fried:
                    ...
                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
                    {
                        progressNormalized = burningTimer / burningRecipeSO.burningTimerMax
                    });
                    
                    if (burningTimer > burningRecipeSO.burningTimerMax)
                    {
                        // 煎好了
                        ...
                        OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
                        {
                            progressNormalized = 0f
                        });
                    }
                    break;
                case State.Burned:
                    break;
            }
        }
    }

    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO()))
                {
                    // 角色拿着的物品可以被煎
                    ...
                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
                    {
                        progressNormalized = fryingTimer / fryingRecipeSO.fryingTimerMax
                    });
                }
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
            } else
            {
                // 角色没有物品,拾取物品
                ...
                OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                    progressNormalized = 0f
                });
            }
        }
    }
}
运行游戏,可以看到炉灶台的进度条正常运行



目前为止的工程文件

13 创建盘子存放台 Plates Counter

13.1 继承自柜台基类创建PlatesCounter与自动生成盘子

与上面创建其他Counter相同,从_BaseCounter.prefab右键->Create->PrefabVariant、复制一个Selected,改缩放,在Scripts文件夹下新建PlatesCounter.cs继承自BaseCounter,挂载好对应脚本
在ScriptableObjects/KitchenObjectSO下右键->Create->KitchenObjectSO新建Plate.asset,在Prefabs/KitchenObjects文件夹下复制一个prefab改为Plate.prefab并放进去对应模型,将各个中的组件的相应变量填好
我们希望盘子存放台可以每隔几秒生成一个盘子,当存放台上的盘子到达一定数量时不再生成盘子,然而,我们的_BaseCounter在设计之初并没有考虑过一个Counter上放置多个KitchenObject实例的情况,所以,我们并不会真的去生成KitchenObject实例,而是生成盘子的模型KitchenObject_Visual,当角色想要取盘子时我们再调用SpawnKitchenObject()方法去生成KitchenObject实例交给角色
在PlatesCounter.cs中,规定生成盘子所需的时间、最大盘子数,在计时器到时时触发EventHandler委托,在PlatesCounterVisual.cs中我们用一个List保存生成的KitchenObject_Visual,并实现堆在柜子上的效果

// PlatesCounter.cs
using UnityEngine;
using System;

public class PlatesCounter : BaseCounter
{
    public event EventHandler OnPlateSpawned;

    private float spawnPlateTimer;
    private float spawnPlateTimerMax = 4f;
    private int platesSpawnedAmount;
    private int platesSpawnedAmountMax = 4;

    private void Update()
    {
        spawnPlateTimer += Time.deltaTime;
        if (spawnPlateTimer > spawnPlateTimerMax)
        {
            spawnPlateTimer = 0f;
            if (platesSpawnedAmount < platesSpawnedAmountMax)
            {
                platesSpawnedAmount++;
               
                OnPlateSpawned?.Invoke(this, EventArgs.Empty);
            }
        }
    }
}
// PlatesCounterVisual.cs
using System;
using System.Collections.Generic;
using UnityEngine;

public class PlatesCounterVisual : MonoBehaviour
{
    [SerializeField] private PlatesCounter platesCounter;
    [SerializeField] private Transform counterTopPoint;
    [SerializeField] private Transform plateVisualPrefab;

    private List<GameObject> plateVisualGameObjectsList;

    private void Awake()
    {
        plateVisualGameObjectsList = new List<GameObject>();
    }
   
    private void Start()
    {
        platesCounter.OnPlateSpawned += PlatesCounter_OnPlateSpawned;
    }
   
    private void PlatesCounter_OnPlateSpawned(object sender, EventArgs e)
    {
        Transform plateVisualTransform = Instantiate(plateVisualPrefab, counterTopPoint);
        
        float plateOffsetY = 0.1f;
        plateVisualTransform.localPosition = new Vector3(0f, plateOffsetY * plateVisualGameObjectsList.Count, 0f);
        
        plateVisualGameObjectsList.Add(plateVisualTransform.gameObject);
    }
}
运行游戏,我们就能看到每隔我们设置的4秒,就会出现一个盘子,且最多能达到4个盘子



接下来让角色可以拿起来盘子,我们需要在PlatesCounter.cs中实现一下Interact()接口,当角色拿起盘子符合条件时将盘子Spawn给角色,同时触发EventHandler委托,该委托调用的函数写在PlateCounterVisual.cs中,该函数移除掉存放Plater_Visual的List的最后一个对象

// PlatesCounter.cs
...
public class PlatesCounter : BaseCounter
{
    ...
    public event EventHandler OnPlateRemoved;
    ...
    [SerializeField] private KitchenObjectSO plateKitchenObjectSO;
    ...
    public override void Interact(Player player)
    {
        if (!player.HasKitchenObject())
        {
            // 角色手上没有东西
            if (platesSpawnedAmount > 0)
            {
                // 盘子存放台上有盘子
                platesSpawnedAmount--;
               
                KitchenObject.SpawnKitchenObject(plateKitchenObjectSO, player);
               
                OnPlateRemoved?.Invoke(this, EventArgs.Empty);
            }
        }
    }
}
// PlateCounterVisual.cs中
...
public class PlatesCounterVisual : MonoBehaviour
{
    ...
    private void Start()
    {
        ...
        platesCounter.OnPlateRemoved += PlatesCounter_OnPlateRemoved;
    }
    ...
    private void PlatesCounter_OnPlateRemoved(object sender, EventArgs e)
    {
        if (plateVisualGameObjectsList.Count > 0)
        {
            GameObject plateGameObject = plateVisualGameObjectsList[plateVisualGameObjectsList.Count - 1];
            plateVisualGameObjectsList.Remove(plateGameObject);
            Destroy(plateGameObject);
        }
    }
}
运行游戏,我们就可以拿起盘子了



目前为止的工程文件

13.2 使用盘子装物品

首先我们先在ClearCounter上实现装物品的效果,在之前ClearCounter.cs的Interact()中,当柜子上有物品而角色也有物品时,我们没有写任何逻辑,这里我们可以在角色拿的物品是盘子的时候将桌子上的物品装到盘子里,为了保存盘子里装的东西已经方便实现对应方法,我们在Script文件夹新建PlateKitchenObject.cs,这个类继承自KitchenObject,使用一个List保存装的东西

// PlateKitchenObject.cs中
using System.Collections.Generic;

public class PlateKitchenObject : KitchenObject
{
    private List<KitchenObjectSO> kitchenObjectSOList;

    private void Awake()
    {
        kitchenObjectSOList = new List<KitchenObjectSO>();
    }

    public void AddIngredient(KitchenObjectSO kitchenObjectSO)
    {
        kitchenObjectSOList.Add(kitchenObjectSO);
    }
}
在Plate.prefab上添加PlateKitchenObject组件,移除KitchenObject组件



在ClearCounter.cs中写上在角色拿的物品是盘子的时候将桌子上的物品添加到List中的逻辑

// ClearCounter.cs中
...
public class ClearCounter : BaseCounter
{
    ...
    public override void Interact(Player player)
    {
        if (!HasKitchenObject())
        {
            // 柜子上没有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品,放置物品
                ...
            } else
            {
                // 角色没有物品
            }
        } else
        {
            // 柜子上有物品
            if (player.HasKitchenObject())
            {
                // 角色有物品
                if (player.GetKitchenObject() is PlateKitchenObject)
                {
                    // 角色拿的是盘子
                    PlateKitchenObject plateKitchenObject = player.GetKitchenObject() as PlateKitchenObject;
                    plateKitchenObject.AddIngredient(GetKitchenObject().GetKitchenObjectSO());
                    GetKitchenObject().DetroySelf();
                }
            } else
            {
                // 角色没有物品,拾取物品
                ...
            }
        }
    }
}
运行游戏,将物品放到空的柜台上,在Inspector面板右上角三个点切换为Debug模式,就能在角色进行交互时将物品添加到我们想要加入的List中了



在最终游戏中,我们的盘子中并不能装一切东西,并且盘子中也不会装两种相同种类的物品,我们通过一个List存放可以被装在盘子的物品,每次装盘时检查该物品是否在List中,并且判断是否盘子中已经有了相同种类的物品

// PlateKitchenObject.cs中
...
public class PlateKitchenObject : KitchenObject
{
    [SerializeField] private List<KitchenObjectSO> validKitchenObjectSOList;
   
    private List<KitchenObjectSO> kitchenObjectSOList;
    ...
    public bool TryAddIngredient(KitchenObjectSO kitchenObjectSO)
    {
        if (!validKitchenObjectSOList.Contains(kitchenObjectSO))
        {
            // 盘子中不能放这种物品
            return false;
        }
        if (kitchenObjectSOList.Contains(kitchenObjectSO))
        {
            // 盘子中已经有了这种物品
            return false;
        } else
        {
            kitchenObjectSOList.Add(kitchenObjectSO);
            return true;
        }
    }
}
在Plate.prefab上设置可以被装盘的物品列表



为了以后检查一个KitchenObject是否为盘子更加方便,我们在KitchenObject.cs中写一个TryGetPlate()判断是否为盘子,如果是则传回PlateKitchenObject类的版本

// KitchenObject.cs中
...
public class KitchenObject : MonoBehaviour
{
    ...
    public bool TryGetPlate(out PlateKitchenObject plateKitchenObject)
    {
        if (this is PlateKitchenObject)
        {
            plateKitchenObject = this as PlateKitchenObject;
            return true;
        } else
        {
            plateKitchenObject = null;
            return false;
        }
    }
    ...
}
在ClearCounter.cs中替换相关的实现,在CuttingCounter.cs、StoveCounter.cs中相同位置添加上用盘子装物品的相关代码,由于StoveCounter.cs中有状态设置的代码,所以在写了用盘子装上物品的逻辑后还要设置相关状态

// ClearCounter.cs与CuttingCounter.cs中的相同位置
...
// 柜子上有物品
if (player.HasKitchenObject())
{
         // 角色有物品
         // if (player.GetKitchenObject() is PlateKitchenObject)
         if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject))
         {
                  // 角色拿的是盘子
                  // PlateKitchenObject plateKitchenObject = player.GetKitchenObject() as PlateKitchenObject;
                  if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO()))
                  {
                                GetKitchenObject().DetroySelf();
                  }
         }
}
...
// StoveCounter.cs中
...
// 柜子上有物品
if (player.HasKitchenObject())
{
         // 角色有物品
         if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject))
         {
                  // 角色拿的是盘子
                  if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO()))
                  {
                                GetKitchenObject().DetroySelf();
                               
                                state = State.Idle;
         
                                OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
                                {
                                         state = state
                                });
         
                                OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                                         progressNormalized = 0f
                                });
                  }
         }
}
...
启动游戏,在Inspector面板打开Debug模式,就能看到被装到盘子的物品的List了


接下来我们要实现相反的效果,我们希望角色拿着相应的食材来装到盘子了,由于只有在空的柜子上才能放盘子,我们只用在ClearCounter.cs中实现相关逻辑
// ClearCounter.cs中
...
// 柜子上有物品
if (player.HasKitchenObject())
{
         // 角色有物品
         if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject))
         {
                  ...
         } else {
                  // 角色拿的不是盘子,而是别的东西
                  if (GetKitchenObject().TryGetPlate(out plateKitchenObject))
                  {
                                if (plateKitchenObject.TryAddIngredient(player.GetKitchenObject().GetKitchenObjectSO()) )
                                {
                                         player.GetKitchenObject().DetroySelf();
                                }
                  }
         }
}
...
运行游戏,可以将物品向空柜台上的盘子里装东西



目前为止的工程文件

13.3 装盘效果

我们要实现的装盘效果用了比较简单的方式,可以看到在_Assets/PrefabsVisuals下有一个PlateCompleteVisual.prefab的汉堡模型,由六个不同的部分组成,要做成不同搭配的菜只需要让对应物品显示,其余物品隐藏即可



在上一个小节我们已经可以将放进盘子的物品保存到一个List了,只要根据List中有哪些物品去显示和隐藏物品即可,我们可以通过tranform.Find()通过名字来找到对应的物品,但是直接使用string并不是一个很好的方式,因此我们需要建立KitchenObjectSO类的实例与GameObject类的实例之间的对应关系,要找到这个对应关系,我们可以定义一个包含这两个变量的struct,用一个List保存所有的struct的实例,也就是所有的对应关系
在Scripts文件夹新建PlateCompleteVisual.cs,添加到PlateCompleteVisual.prefab上,在PlateKitchenObject.cs中添加一个EventHandler委托,在将物品放置到盘子中时触发该委托并传递,委托调用的函数写在PlateCompleteVisual.cs中,遍历盘子中物体的List,显示和隐藏对应物品

// PlateKitchenObject.cs中
...
public class PlateKitchenObject : KitchenObject
{
    public event EventHandler<OnIngredientAddedEventArgs> OnIngredientAdded;
    public class OnIngredientAddedEventArgs : EventArgs
    {
        public KitchenObjectSO kitchenObjectSO;
    }
    ...
    public bool TryAddIngredient(KitchenObjectSO kitchenObjectSO)
    {
        if (!validKitchenObjectSOList.Contains(kitchenObjectSO))
        {
            // 盘子中不能放这种物品
            return false;
        }
        if (kitchenObjectSOList.Contains(kitchenObjectSO))
        {
            // 盘子中已经有了这种物品
            return false;
        } else
        {
            kitchenObjectSOList.Add(kitchenObjectSO);
            
            OnIngredientAdded?.Invoke(this, new OnIngredientAddedEventArgs
            {
                kitchenObjectSO = kitchenObjectSO
            });
            
            return true;
        }
    }
}
在unity序列化class或一个struct的实例需要在这个struct或类前加上[Serializable]

// PlateCompleteVisual.cs中
using System;
using System.Collections.Generic;
using UnityEngine;

public class PlateCompleteVisual : MonoBehaviour
{
    [Serializable]
    public struct KitchenObjectSO_GameObject
    {
        public KitchenObjectSO kitchenObjectSO;
        public GameObject gameObject;   
    }

    [SerializeField] private PlateKitchenObject plateKitchenObject;
    [SerializeField] private List<KitchenObjectSO_GameObject> kitchenObjectSOGameObjectList;

    private void Start()
    {
        plateKitchenObject.OnIngredientAdded += PlateKitchenObject_OnIngredientAdded;
        
        foreach (KitchenObjectSO_GameObject kitchenObjectSOGameObject in kitchenObjectSOGameObjectList)
        {
            kitchenObjectSOGameObject.gameObject.SetActive(false);
        }
    }
   
    private void PlateKitchenObject_OnIngredientAdded(object sender, PlateKitchenObject.OnIngredientAddedEventArgs e)
    {
        foreach (KitchenObjectSO_GameObject kitchenObjectSOGameObject in kitchenObjectSOGameObjectList)
        {
            if (kitchenObjectSOGameObject.kitchenObjectSO == e.kitchenObjectSO)
            {
                kitchenObjectSOGameObject.gameObject.SetActive(true);
            }
        }
    }
}
将PlateCompleteVisual.prefab添加到Plate.prefab下,设置好相应的变量



现在运行游戏,就能够正确装盘了



目前为止的工程文件

13.4 显示盘子中物品的UI

接下来我们做一个显示一个盘子中都放了哪些物品的UI
首先在Package Manager中下载2D Sprite



在Plate.prefab中右键->UI->Canvas新建一个Canvas重命名为PlateIconsUI,调整位置大小添加一个GridLayoutGroup组件;在PlateIconsUI下新建空物体重命名为Icon Template,在这个Icon Template下右键->UI->Image,重命名为Background,Source Image设置为下载的包里的Circle素材(记得点开Source Image右上角显示隐藏项);复制一份重命名为Icon,Source Image放一个Bread;我们可以多复制几个Icon Template看看效果



我们可以给不同物品的UI都做成prefab,但是作者认为这样做会有很多prefab,文件会很乱,所以我们接下来写的脚本就挂载到PlateIconsUI对象上然后复制和删除下面的IconTemplate就行了
我们要给PlateIconsUI添加一个新的脚本PlateIconsUI.cs,在这个脚本中写生成IconTemplate的方法并在物品放上去时调用,我们希望不同的物品放到盘子时会有不同的Icon,为此我们可以用比较"脏"的方式,生成IconTemplate后Find("Icon")找到Icon对象然后改变Image组件的Sprite属性,但是代码里最好不要用string来找一个对象,为此我们又在IconTemplate上添加了一个新的脚本PlateIconsSingleUI.cs用来获取Icon对象更改Sprite属性

// PlateIconsSingleUI.cs中
using UnityEngine;
using UnityEngine.UI;

public class PlateIconsSingleUI : MonoBehaviour
{
    [SerializeField] private Image image;
   
    public void SetKitchenObjectSO(KitchenObjectSO kitchenObjectSO)
    {
        image.sprite = kitchenObjectSO.sprite;
    }
}
在上面的教程中,我们已经在PlateKitchenObjectSO.cs中添加过了一个叫做OnIngredientAdded的EventHandler委托,该委托在物品放入盘子时触发,并且当前放入盘中的物品的ScriptableObject存在了一个List中,我们需要在PlateKitchenObjectSO.cs添加一个方法获取这个List,然后在PlateIconsUI.cs中为这个委托添加调用的函数和一些刷新UI的逻辑即可

// PlateKitchenObjectSO.cs中
...
public class PlateKitchenObject : KitchenObject
{
    ...
    public List<KitchenObjectSO> GetKitchenObjectSOList()
    {
        return kitchenObjectSOList;
    }
}
// PlateIconsUI.cs中
using UnityEngine;

public class PlateIconsUI : MonoBehaviour
{
    [SerializeField] private PlateKitchenObject plateKitchenObject;
    [SerializeField] private Transform iconTemplate;

    private void Awake()
    {
        iconTemplate.gameObject.SetActive(false);
    }

    private void Start()
    {
        plateKitchenObject.OnIngredientAdded += PlateKitchenObject_OnIngredientAdded;
    }

    private void PlateKitchenObject_OnIngredientAdded(object sender, PlateKitchenObject.OnIngredientAddedEventArgs e)
    {
        UpdateVisual();
    }
   
    private void UpdateVisual()
    {
        foreach (Transform child in transform)
        {
            if (child == iconTemplate) continue;
            Destroy(child.gameObject);
        }
        
        foreach (KitchenObjectSO kitchenObjectSO in plateKitchenObject.GetKitchenObjectSOList())
        {
            Transform iconTransform = Instantiate(iconTemplate, transform);
            iconTransform.gameObject.SetActive(true);
            iconTransform.GetComponent<PlateIconsSingleUI>().SetKitchenObjectSO(kitchenObjectSO);
        }
    }
}
这个UI也用到了Canvas,所以同样也有根据视角不同会看到相反方向的UI的情况,不过还好我们在做进度条UI时已经写了一个LookAtCamera.cs组件解决这个问题,只需要把这个组件放到PlateIconsUI上即可



运行游戏,即可看到盘子的UI正常运行



目前为止的工程文件
下篇:

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-4-2 20:40 | 显示全部楼层
强啊,这是把整个视频写下来了[爱]
发表于 2023-4-2 20:44 | 显示全部楼层
666[赞同][赞同][赞同]
发表于 2023-4-2 20:48 | 显示全部楼层
有点狠[赞同]
发表于 2023-4-2 20:55 | 显示全部楼层
牛,你就是我的超人[赞同][赞同][赞][赞]
发表于 2023-4-2 20:58 | 显示全部楼层
newbee
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-16 21:34 , Processed in 0.114749 second(s), 28 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表