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

本文详细记录了跟随CodeMonkey的Unity教程制作类似胡闹厨房的游戏KitchenChaos的过程,从项目初始设置、后处理、角色控制器、动画、交互逻辑、柜台与碰撞检测,到游戏UI、音效、按键绑定、手柄输入和细节打磨等方面,全面覆盖游戏开发的各个环节。通过学习和实践,不仅可以掌握Unity游戏开发的基本技能,还能理解如何将新旧Input System结合使用,以及如何设计游戏逻辑和交互体验。
摘要由CSDN通过智能技术生成

本文章是油管上CodeMonkey的一个unity项目KitchenChaos的笔记整理,学习并整理这个项目主要是因为终于看到了一个比较完整地用到了unity的各种功能、风格较为清爽的、代码结构清晰的同时比较新的项目。在学习之后也确实有很大的收获,首先通过该教程第一次走完了一个小游戏的全部流程,之前也零零散散地学过一些unity教程和渲染方面的东西,但是流程一直都不太完整:有些项目的视觉表现实在不太感兴趣,看到了拖沓的动画和过时的画面就没有想学的欲望;有些项目不注重代码的扩展性,导致只能停留在一个小demo的层次;有些项目在游戏逻辑上很复杂,导致学到一半自己已经不太能跟得上。但是本教程在工程上比较讲究,注重代码的整理和解耦,在游戏逻辑上并没有那么复杂,在一些功能的实现上拥抱引擎提供的工具,我个人认为很适合学习
任何教程都有前置知识,视频中在前面也有提及,如果想要学习本项目,还是需要了解unity的基本操作和c#基础,我个人觉得这个教程首先需要有面向对象语言的基础,然后特别地要理解C#中委托和事件的使用,比较浅比较直接的理解可以去看CodeMonkey的C#的事件和委托的讲解,但是如果想要知道委托实际上类似于函数指针、事件模型有各种组成部分和实现形式等更深的知识推荐看刘铁猛老师的C#课程
另外该文章只是一些大概的笔记,具体操作还要去跟原教程去学习,但是和视频不同的是,写成图文的形式可以让别人在学习前能大概浏览一下这个项目都干了什么,并且能够在学习过后很方便地回顾某一步具体干了什么,希望对大家有帮助

1 项目初始设置

该项目使用的unity 2022.2.2f1c1的3d(urp)模板进行创建
500
在Project Setting中Quality面板只保留High Fidelity等级,同时项目Setting目录中也只保留High Fidelity的设置
500
500
下载提供的素材后双击或拖入unity编辑器导入
目前为止的工程文件

2 后处理 Post Processing

一般在开发时在应该在中后期再添加后处理,但在本课中为了一开始就有比较好的视觉效果,所以第一步就先进行了后处理
我们会使用自动创建好的SampleScene作为最终游戏场景,所以将其重命名为GameScene
添加地面、人物和一些游戏物品,调整相机位置,进入Game窗口,在Global Volume上添加override,依次添加Tonemapping、Color Adjustments、Bloom、Vignette,调整后的配置被保存在了Global Volume Profile中,可以点击右方的clone保存文件
500
在Main Camera中可以设置anti-aliasing,同时在Setting/URP-HighFidelity中也可以设置MSAA,这里设置为MSAA 8x
URP-HighFidelity-Renderer中自带了一个Screen Space Ambient Occlusion的render feature,这里保留并稍作调整
Window->Rendering->Lighting可以调出来Lighting面板调整关于灯光的参数,这里保留默认
300
300
目前为止的工程文件

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

3.1 旧的角色控制方法

如果想要组织好一个游戏,应该永远将视觉效果与逻辑分开,在创建角色时,我们不直接创建一个胶囊体然后修改缩放、偏移等属性,三轴不相等的缩放或一些位置的偏移可能会让原本的代码逻辑出现问题,所以这里我们创建一个空物体,之后会在空物体上写逻辑,然后在空物体之下再创建一个胶囊体作为我们的角色,之后会在这个子物体上做视觉的修改
300
接下来开始代码的编写,创建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面板,做出角色的头部上下移动的动画
200
然后创建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的基础上添加身体的上下移动,同时加快速度
200
接下来我们创建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还会自动对相机位置进行插值)
300
目前为止的工程文件

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
200
500
在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
500
依次修改下方的四个方向绑定的事件,可以选择listen然后按下对应按键
500
Input System可以通过Add Component添加对应的脚本,但是这里我们选择用代码的方式使用。选中PlayerInputAction.inputactions,在Inspector面板中勾选Generate C# Class,然后Apply
500
修改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
500
目前为止的工程文件

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()方法
300
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()方法后,即是在边缘也能发生碰撞了,但是如果在一个面前有墙的地方同时按住向上移动和向右移动的方向键,角色也不会移动,但通常在游戏中这种情况通常会让角色朝右移动
300
我们可以多增加一些逻辑来让角色有其他方向的速度向量时仍然移动

// 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;
}
...

进行一些调整之后,我们就可以正常移动了
300
目前为止的工程文件
在原视频4:24:00处作者进行了一些改进,现在如果我们垂直向墙体走动,由于当不能向moveDir方向移动时我们限制住了moveDir,所以垂直向墙体走动的时候moveDir为(0, 0, 0),而我们的动画是当前角色朝向往moveDir方向插值的,所以我们的角色在垂直向墙体走动的时候不会向墙体转向,效果如下
300
要解决这个问题,需要改动以下语句(注意这里moveDir.x != 0moveDir.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
		 {
			  ...
		 }
	}
}

改动之后,就能够在垂直朝墙体走动时正常转向了
300

4 创建空的柜台 Clear Counter

4.1 添加柜台

首先在场景中创建一个空物体,命名为ClearCounter,在_Assets/PrefabsVisuals/CountersVisuals下找到ClearCounter_Visual拖动到ClearCounter下,在ClearCounter上添加一个Box Collider组件,调整到合适大小,现在角色就可以和柜台发生碰撞了
300
我们需要把设置好碰撞体积的柜台变为一个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("-");
	  }        
 }
 ...

300
接下来开始给柜台添加脚本,在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
300

 // 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键
500
在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文档
500

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

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")
300

个人解释:使用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的显示时就能得到一个选中效果的柜台
500
像这样两个网格重叠时,可能会形成闪烁的效果,为了避免这种情况,我们可以将选中效果的柜台稍微放大一些,例如这里将所有轴的缩放改为1.01
200

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)信息显示出我们当前选中的柜台
300
接下来我们处理选中的视觉效果,这里我们有两种思路可以选择。一种是让当前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);
    }
}

现在,我们就实现了物品的选中效果
300
目前为止的工程文件

7 放置物品与Scriptable Objects

7.1 按E在柜台上生成物品

我们先来实现按E在柜台上生成番茄的效果
首先要创建番茄的prefab,在场景中创建一个空物体命名为Tomato,在_Assets/PrefabVisuals/KitchenObjects下找到Tomato_Visual,拖动到空物体下,将该带着番茄模型的空物体再拖回Prefabs中创建prefab(为了之后分类方便,这里视频中在Prefab文件夹下分为了Counters文件夹和KitchenObjects文件夹,番茄放到了KitchenObjects中),创建完prefab后,删除场景中的番茄
300
为了定位番茄生成的位置,我们需要在ClearCounter.prefab中创建一个空物体,重命名为CounterTopPoint,移动到柜台的正上方
300
接下来在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到其中一个柜台的脚本下
300
运行游戏,按E即可放置番茄与奶酪
300

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填好
500
在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,开始游戏,得到和之前相同的效果
300
我们需要让柜台知道在其上方放置的物品是什么,但是由于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());
    }
}

现在运行游戏,就能在控制台看到放置物品时的信息了
300
目前为止的工程文件

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中番茄确实从当前柜子跑到了另一个柜子上,并且位置也发生了改变
500
然而这样操作时我们的两个ClearCounter物体中的KitchenObject变量并没有改变,第一个柜子的KitchenObject依然是Tomato,第二个柜子的KitchenObject依然是None(private的变量可以通过右上角三个点打开debug模式查看)
500
我们有两个途径可以解决这个问题,第一种是在ClearCounter.cs中设置kitchenObject的父级后让新的

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值