个人头像 乌鸦之家

【unity3D学习记录】FPS角色交互

创建
笔记
编程笔记

环境:Unity 2023.2.20f1c1

上一期学习记录中,我们完成了FPS游戏的角色的移动、跳跃、镜头旋转的功能。但此时角色和环境唯一的互动方式就是可以踩在某个东西上,这显然非常的无聊。

在这一期学习记录中,我将介绍一下如何实现角色和其它物体的交互。

本期将要实现的场景为:角色与开关(keypad)交互打开大门。

闲话少叙,进入正题。

创建场景

你可以像我一样创建一堆不同的Cube,中间两片作为门,旁边贴一小片作为开关。

图片加载失败

可以把两个门用放在一个空的Object下。

图片加载失败

创建Interactable基类

在游戏里,可以和玩家互动的东西应当有很多(物品/敌人等等),它们的共同点当然就是能与玩家互动,所以可以抽象出它们的共同基类,命名为Interactable

新建一个Interactable.cs脚本,键入以下内容:

/* Interactable.cs */
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class Interactable : MonoBehaviour{
    public string promptMessage;

    public void BaseInteract(){
        Interact();
    }

    protected virtual void Interact(){

    }
}

这些代码实现了一个抽象类,它规定了一个接口BaseInteract,并且预留了一个扩展点,子类可以通过重写Interact来实现不同的行为。
prompMessage则用于存储提示词,提示玩家如何与物体进行交互。

为什么要设计一个BaseInteract里作为公共接口,再在公共接口里调用Interact的形式呢,直接把Interact作为公共借口不行吗,反正子类也还是可以重写Interact

假如真的这么做了,就像下面这样:
```cs
public abstract class Interactable : MonoBehaviour{

public virtual void Interact(){

}

}
那么当有朝一日,老板突然说要在`Interact`执行之前输出日志,于是牛马们彻底傻眼了,每个子类都要加一遍。 但如果是前面的代码写法,那么只需要在父类的`BaseInteract`中,调用`Interact`之前写上输出日志的代码就行了,一次修改,所有子类都生效。cs
public abstract class Interactable : MonoBehaviour{

public void BaseInteract(){
    // 在这里输出日志
    Interact();
}

protected virtual void Interact(){

}

```
这属于设计模式中的“模板方法模式”。

Interactable的子类Keypad

接下来我们实现一下Interactable的子类Keypad类,作为我们前面搭好的场景中的门开关Keypad的脚本。

/* Keypad.cs */
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Keypad : Interactable
{
    protected override void Interact(){
        Debug.Log(promptMessage);
    }
}

目前它相当简单,就是重写了一下父类规定的私有接口。

最后记得把Keypad.cs作为组件添加给场景中的Keypad,然后给组件中的promptMessage(继承于Interactable)先随便添加一段话。

创建PlayerInteract脚本

现在有了能够与玩家交互的物体,那么玩家要怎么探测到它们呢?我们需要创建脚本PlayerInteract.cs,它的功能是探测可交互的物体,并处理交互逻辑。

在FPS游戏里,玩家一般通过屏幕中间的准心来探测物体,因此需要在脚本中引用一下玩家的镜头。

/* PlayerInteract.cs */
private Camera cam;

void Start(){
    // 因为在PlayerLook脚本中已经引用过Camera了,可以直接去PlayerLook中拿。
    cam = GetComponent<PlayerLook>().Cam;
}

但我们怎么知道镜头中心看到的物体是什么呢?

Unity中提供了一个Ray类,顾名思义表示一条射线,还提供了一个Physics.Raycast函数:

bool Physics.Raycast(Ray ray, out RaycastHit hitInfo, float maxDistance, int layerMask);

它能够探测射线在maxDistance的距离内是否hit到了属于layerMask的物体,如果是,就返回True,并把此次的hit记录到hitInfo中。

这里的layerMast是每一个场景中的物体都拥有的属性,Unity主窗口右边的Inspector右上方可以看到一个叫Layer的下拉菜单。

我们可以点击它,可以看到有一些默认的Layer。为了给可交互的物体单独进行区分,我们点击下拉菜单最下方的“Add Layer”,在User Layer 6的位置键入“Interactable”。

然后点击前面搭场景时做好的Keypad,在Inspector里把Layer修改为“Interactable”。

现在我们有了属于“Interactable”的Keypad开关,有了相机,于是可以通过从相机的位置发射出一条射线,检查这条射线是否hit到了“Interactable”的Keypad,如果是,那么就进行交互逻辑的编写。

回到PlayerInteract.cs脚本中,新增以下内容:

[SerializeField]
private float distance = 3f;    // 射线能够探测的最大范围
[SerializeField]
private layerMask mask;

void Update(){
    // 以相机为原点,朝着相机正方向发射的射线
    Ray ray = new Ray(cam.transform.position, cam.transform.forward);

    RaycastHit hitInfo; // 存放hit信息
    if ( Physics.Raycast(ray, out hitInfo, distance, mask) ){
        // 获取被射线hit到的物体
        Interactable interactable = hitInfo.collider.GetComponent<Interactable>();
        if ( interactable != null ){
            // 如果确实hit到了一个Interactable,那么就调用它的交互函数
            interactable.BaseInteract();
        }
    }
}

测试
现在可以运行游戏,走到Keypad面前看着它,应当会在控制台输出Keypad的promptMessage

创建UI界面

很显然,上面的结果并没有特别好的游戏体验,作为一个FPS游戏,它居然没有靶子!而我们在与Keypad交互时,完全是用人脑来感知画面中心的。

而且交互的信息现在还只能在控制台打印,而我们需要的是在游戏画面中显示出提示词,否则真的把游戏打包好了,玩家又看不到控制台。

所以这时候需要请出我们的UI界面。

在Unity主窗口右边的Hierarchy点击鼠标右键,点击“UI”,接着点击“Text-TextMeshPro”。如果是第一次用它,可能会提示需要安装,安装就行了。

创建完TextMeshPro之后,在右边的Inspector中可能出现一个红色的感叹号,下面有一个按钮“Replace with InputSystemUIInputiModule”,点击就行了。(大概的意思是说我们使用了新的Input System,这个TextMeshPro却是为旧的InputSystem设计的,需要替换掉某些东西)

!!todo:这里还有一个很无聊的步骤,等后面再补充。

可以看到“TextMeshPro”是位于一个叫“Canvas”的物体中的,说明在创建“TextMeshPro”,首先需要创建一个画布。

我们把这个“TextMeshPro”重命名为“promptText”。并把文本的位置移动到你想要的位置。

接着创建一个准心,我们可以右键点击“Canvas”,接着先后点击UI和Image。将它重命名为“Crosshair”。然后在右边的Inspector中把它的x、y、z都调整为0(将它置于屏幕正中心),把宽、高调整到自己的满意的大小,接着在下面的“Source Image”把图片换成一个圆形。这样一个简易的准心就做好了。

创建UI脚本

PlayerInteract.cs中,玩家能够探测到Interactable,并进行交互,所以应该能够在PlayerInteract.cs中操作到UI界面的“promptText”,这样才能提示玩家如何与物体进行交互。

不过为了理清脚本责任及让代码清晰,我们要创建一个单独的PlayerUI.cs脚本来管理UI界面。

/* PlayerUI.cs */
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;    // 别忘了导入这个,否则引用不了“promptText”

public class PlayerUI : MonoBehaviour
{

    // 引用UI界面里的“promptText”
    [SerializeField]
    private TextMeshProUGUI promptText;

    public void UpdateText(string promptMessage){
        promptText.text = promptMessage;
    }
}

别忘了把这个脚本拖动到Player里,并且把“promptText”拖到此脚本的promptText中。

这样一来,PlayerInteract.cs中只需要引用一下这个PlayerUI,每次探测到可交互的物体时,就让PlayerUI调用一下UpdateText就行了。

private PlayerUI playerUI;
// ...

void Start(){
    playerUI = GetComponent<PlayerUI>();
    // ...
}

void Update(){
    playerUI.UpdateText(string.Empty);  // 将UI的promptMessage初始化为空

    Ray ray = new Ray(cam.transform.position, cam.transform.forward);

    RaycastHit hitInfo;
    if ( Physics.Raycast(ray, out hitInfo, distance, mask) ){
        Interactable interactable = hitInfo.collider.GetComponent<Interactable>();
        if ( interactable != null ){
            // 当玩家探测到Interactable时,UI界面显示提示信息
            playerUI.UpdateText(interactable.promptMessage);
        }
    }
}

交互Action

玩家探测到了Interactable是否代表他一定要交互呢?No,所以需要再创建一个交互的按键。

在“PlayerInput”中添加新的Action——Interact,按键设置为E。

是的,涉及到输入,又得请出InputManager老祖了,不过这次是在PlayerInteract.cs中请它。

private InputManager inputManager;
void Start(){
    // ...

    inputManager = GetComponent<InputManager>();
}
void Update(){
    playerUI.UpdateText(string.Empty);

    Ray ray = new Ray(cam.transform.position, cam.transform.forward);

    RaycastHit hitInfo;
    if ( Physics.Raycast(ray, out hitInfo, distance, mask) ){
        Interactable interactable = hitInfo.collider.GetComponent<Interactable>();
        if ( interactable != null ){
            playerUI.UpdateText(interactable.promptMessage);

            // 当玩家按下E键——玩家主动进行交互,则触发交互行为
            if (inputManager.onFoot.Interact.triggered ){
                interactable.BaseInteract();
            }
        }
    }
}

这时可能会报错,因为inputManager.onFoot是个私有的属性,我们需要回到InputManager.cs中把它改为public的属性。



0

评论