VR 小游戏 RBK 的设计

最近写了一个 VR 小游戏(不妨命名为 RBK,也就是 Red-Blue-Black 的缩写),游戏的设计大致是这样的:

  • 玩家手持的两个手柄各自代表一个武器,武器可在两种形态之间切换(通过握持键切换):

    • 「剑」形态:挥舞手柄以砍击怪物(以蓝色为代表),点击扳机键以发射子弹(以红色为代表)。灵感来自于 Zach Barth 在 Full Indie 上的分享 Make a VR game in 17 minutes

    • 「抓捕器」形态:按下扳机键以抓取黑色方块,松开扳机键可将物体抛出作为攻击(以黑色为代表)。

  • 游戏中的怪物也分为三种,分别对应三种颜色(灵感源自 Beat Saber):

    • 蓝色怪物:只能被蓝色的剑攻击,死亡后掉落黑色方块。

    • 红色怪物:只能被红色的枪攻击,死亡后掉落黑色方块。

    • 黑色怪物(Boss):只能被黑色方块攻击,死亡后掉落金色方块。

武器的实现

在武器预制中建立四个组件:Edge(剑刃)、Hilt(剑柄)、Gun(枪) 和 Catcher(抓捕器)。当武器处于「剑」形态时隐藏 Catcher,而当武器处于「抓捕器」形态时隐藏 Edge 和 Gun。

将握持键的点击事件与 Switch 动作绑定。当玩家按下握持键时切换武器的形态。

using UnityEngine;
using Valve.VR;

public class WeaponController : MonoBehaviour {
    private enum WeaponState { Sword, Catcher };
    private WeaponState currentState;

    private SteamVR_Action_Boolean switchAction = SteamVR_Input.GetAction<SteamVR_Action_Boolean>("default", "Switch");
    public SteamVR_Input_Sources hand;

    public GameObject edge;
    public GameObject gun;
    public GameObject catcher;

    private void Start() {
        currentState = WeaponState.Sword;
    }

    private void Update() {
        if (switchAction[hand].stateDown) {
            if (currentState == WeaponState.Sword) {
                SwitchToCatcherState();
            }
            else {
                SwitchToSwordState();
            }
        }
    }

    private void SwitchToSwordState() {
        currentState = WeaponState.Sword;
        edge.SetActive(true);
        gun.SetActive(true);
        catcher.SetActive(false);
    }

    private void SwitchToCatcherState() {
        currentState = WeaponState.Catcher;
        edge.SetActive(false);
        gun.SetActive(false);
        catcher.SetActive(true);
    }
}

将蓝色怪物添加到 Blue 层。当剑刃与 Blue 层的物体碰撞时,调用被碰撞物体的 Die 函数。

using UnityEngine;

public class SwordController : MonoBehaviour {
    private void OnCollisionEnter(Collision collision) {
        if (collision.collider.gameObject.layer == LayerMask.NameToLayer("Blue")) {
            collision.collider.gameObject.transform.parent.gameObject.GetComponent<EnemyState>().Die();
        }
    }
}

将红色怪物添加到 Red 层。为 Gun 对象添加一个粒子系统子对象 BulletLauncher,设置 BulletLauncher 发射的粒子只与 Red 层物体碰撞(参考粒子系统在射击游戏中的应用),并在发生粒子碰撞时调用被碰撞物体的 Die 函数。

using UnityEngine;
using System.Collections.Generic;

public class BulletLauncher : MonoBehaviour {
    public ParticleSystem bulletLauncher;
    private readonly List<ParticleCollisionEvent> collisionEvents = new List<ParticleCollisionEvent>();

    private void Start() {
        bulletLauncher = GetComponent<ParticleSystem>();
    }

    private void OnParticleCollision(GameObject other) {
        bulletLauncher.GetCollisionEvents(other, collisionEvents);
        foreach (ParticleCollisionEvent collisionEvent in collisionEvents) {
            collisionEvent.colliderComponent.gameObject.transform.parent.gameObject.GetComponent<EnemyState>().Die();
        }
    }
}

将扳机键的点击事件与 Shoot 动作绑定。当玩家按下扳机键时调用粒子系统的 Emit 函数发射子弹:

using UnityEngine;
using Valve.VR;

public class GunController : MonoBehaviour {
    public ParticleSystem bulletLauncher;
    private readonly SteamVR_Action_Boolean shootAction = SteamVR_Input.GetAction<SteamVR_Action_Boolean>("default", "Shoot");
    public SteamVR_Input_Sources hand;

    private void Start() {
        bulletLauncher = GetComponentInChildren<ParticleSystem>();
    }

    private void Update() {
        if (shootAction[hand].stateDown) {
            bulletLauncher.Emit(1);
        }
    }
}

「抓捕器」的实现比较麻烦,找了一轮 VR 物体拾取教程之后,我采用了 VR with Andrew 频道提供的方法(见 Vive Pickup and Drop Object)。简单来说就是在「抓捕器」上添加一个 FixedJoint 组件:拾取物体时将物体与 FixedJoint 绑定;扔掉物体时解绑物体与 FixedJoint,并将手柄的速度和角度赋予物体。将扳机键的点击事件与 Catch 动作绑定,当「抓捕器」与黑色方块相碰并且按下扳机键时抓起方块,松开扳机键时扔掉方块。

using UnityEngine;
using Valve.VR;
using System.Collections.Generic;

public class CatcherController : MonoBehaviour {
    public SteamVR_Behaviour_Pose pose;
    private readonly SteamVR_Action_Boolean catchAction = SteamVR_Input.GetAction<SteamVR_Action_Boolean>("default", "Catch");
    public SteamVR_Input_Sources hand;

    private FixedJoint fixedJoint;
    private Interactable currentInteractable;
    public List<Interactable> contactInteractables = new List<Interactable>();

    private void Awake() {
        fixedJoint = GetComponent<FixedJoint>();
    }

    private void Update() {
        if (catchAction[hand].stateDown) {
            PickUp();
        }
        if (catchAction[hand].stateUp) {
            Drop();
        }
    }

    private void OnTriggerEnter(Collider other) {
        if (other.gameObject.CompareTag("BlackBlock")) {
            contactInteractables.Add(other.gameObject.GetComponent<Interactable>());
        }
    }

    private void OnTriggerExit(Collider other) {
        if (other.gameObject.CompareTag("BlackBlock")) {
            contactInteractables.Remove(other.gameObject.GetComponent<Interactable>());
        }
    }

    private void PickUp() {
        currentInteractable = GetNearestInteractable();
        if (!currentInteractable) {
            return;
        }

        if (currentInteractable.catcherController) {
            currentInteractable.catcherController.Drop();
        }

        currentInteractable.transform.position = transform.position;

        Rigidbody targetBody = currentInteractable.GetComponent<Rigidbody>();
        fixedJoint.connectedBody = targetBody;

        currentInteractable.catcherController = this;
    }

    private void Drop() {
        if (!currentInteractable) {
            return;
        }

        Rigidbody targetBody = currentInteractable.GetComponent<Rigidbody>();
        targetBody.velocity = pose.GetVelocity();
        targetBody.angularVelocity = pose.GetAngularVelocity();

        fixedJoint.connectedBody = null;

        currentInteractable.catcherController = null;
        currentInteractable = null;
    }

    private Interactable GetNearestInteractable() {
        Interactable nearest = null;
        float minDistance = float.MaxValue;

        foreach (Interactable interactable in contactInteractables) {
            float distance = (interactable.transform.position - transform.position).sqrMagnitude;

            if (distance < minDistance) {
                minDistance = distance;
                nearest = interactable;
            }
        }

        return nearest;
    }
}

为黑色方块添加 BlackBlock 标签以及 Interactable 脚本。

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class Interactable : MonoBehaviour {
    [HideInInspector]
    public CatcherController catcherController;
}

打开 SteamVR 提供的 Player 预制,在玩家的左右手中添加武器预制。

怪物的实现

每个怪物预制都由包含一个 MovingBlock,为其添加 Nav Mesh Agent 用于移动,并将 Box Collider 设置为与地面接触。

为 MovingBlock 添加一个移动脚本:当玩家进入怪物的识别范围,调用 SetDestination 函数让怪物走向玩家,并调用 FaceTarget 函数让怪物面向玩家。

using UnityEngine;
using UnityEngine.AI;

public class EnemyMovement : MonoBehaviour {
    private readonly float lookRadius = 20f;
    private Transform target;
    private NavMeshAgent agent;

    private void Start() {
        target = CameraManager.instance.vrCamera.transform;
        agent = GetComponent<NavMeshAgent>();
    }

    private void Update() {
        float distance = Vector3.Distance(target.position, transform.position);

        if (distance <= lookRadius) {
            agent.SetDestination(target.position);

            if (distance <= agent.stoppingDistance) {
                FaceTarget();
            }
        }
    }

    private void FaceTarget() {
        Vector3 direction = (target.position - transform.position).normalized;
        Quaternion lookRotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z));
        transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, 5 * Time.deltaTime);
    }

    private void OnDrawGizmos() {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, lookRadius);
    }
}

玩家的位置由 Player 预制的摄像机确定。

using UnityEngine;

public class CameraManager : MonoBehaviour {
    #region Singleton
    public static CameraManager instance;

    private void Awake() {
        instance = this;
    }
    #endregion

    public GameObject vrCamera;
}

每个怪物都有一个 EnemyState 脚本组件,当 Die 函数被调用时切换游戏对象(从 initBlock 到 endBlock)。

using UnityEngine;

public class EnemyState : MonoBehaviour {
    public GameObject initBlock;
    public GameObject endBlock;

    private void Start() {
        initBlock.SetActive(true);
        endBlock.SetActive(false);
    }

    public void Die() {
        endBlock.transform.position = initBlock.transform.position;
        initBlock.SetActive(false);
        endBlock.SetActive(true);
    }
}

蓝色怪物和红色怪物由 MovingBlock 和 BlackBlock 组成。initBlock 设置为 MovingBlock,endBlock 设置为 BlackBlock,实现死亡时掉落黑色方块。

为 BlackBlock 预制添加如下脚本。当 BlackBlock 与 Black 层(也就是黑色怪物所在层)的物体碰撞,则调用被碰撞物体的 Die 函数。

using UnityEngine;

public class BlackBlock : MonoBehaviour {
    private void OnCollisionEnter(Collision collision) {
        if (collision.collider.gameObject.layer == LayerMask.NameToLayer("Black")) {
            collision.collider.gameObject.transform.parent.gameObject.GetComponent<EnemyState>().Die();
        }
    }
}

黑色怪物在 Black 层中,由 MovingBlock 和 GoldenBlock 组成。initBlock 设置为 MovingBlock,endBlock 设置为 GoldenBlock,实现死亡时掉落金色方块。

玩家的移动

最「沉浸式」的移动方式当然是在现实中跑动,如果场地大小有限,可通过手柄进行移动:

  • 方式一:通过传输点移动,在地面上添加 Teleporting Area 用于定点,点击手柄的触控板进行跳跃。

  • 方式二:通过手柄的触控板决定移动方向。下面是一个简单的实现(参考了教程 Basic Touchpad Locomotion for SteamVR 2.0),将 movePressAction 与触控板的点击事件绑定,moveValueAction 与触控板的触摸位置绑定。实际的移动效果有点像在空中飘,少了现实中走路的那种一上一下的感觉(要实现这种效果可能要在行走过程中微调摄像机的位置)。
using UnityEngine;
using Valve.VR;

public class PlayerMovement : MonoBehaviour {
    private readonly float sensitivity = 0.1f;
    private readonly float maxSpeed = 1.0f;

    public SteamVR_Action_Boolean movePressAction;
    public SteamVR_Action_Vector2 moveValueAction;

    private float speed;

    private CharacterController characterController;
    private Transform vrCamera;
    private Transform head;

    private void Awake() {
        characterController = GetComponent<CharacterController>();
    }

    private void Start() {
        vrCamera = SteamVR_Render.Top().origin;
        head = SteamVR_Render.Top().origin;
    }

    private void Update() {
        HandleHead();
        HandleHeight();
        CalculateMovement();
    }

    private void HandleHead() {
        Vector3 oldPosition = vrCamera.position;
        Quaternion oldRotation = vrCamera.rotation;

        transform.eulerAngles = new Vector3(0.0f, head.rotation.eulerAngles.y, 0.0f);

        vrCamera.position = oldPosition;
        vrCamera.rotation = oldRotation;
    }

    private void HandleHeight() {
        float headHeight = Mathf.Clamp(head.localPosition.y, 1, 2);
        characterController.height = headHeight;

        Vector3 newCenter = Vector3.zero;
        newCenter.y = characterController.height / 2;
        newCenter.y += characterController.skinWidth;

        newCenter.x = head.localPosition.x;
        newCenter.z = head.localPosition.z;

        newCenter = Quaternion.Euler(0, -transform.eulerAngles.y, 0) * newCenter;

        characterController.center = newCenter;
    }

    private void CalculateMovement() {
        Vector3 orientationEuler = new Vector3(0, transform.eulerAngles.y, 0);
        Quaternion orientation = Quaternion.Euler(orientationEuler);
        Vector3 movement = Vector3.zero;

        if (movePressAction.GetStateUp(SteamVR_Input_Sources.Any)) {
            speed = 0;
        }

        if (movePressAction.state) {
            speed += moveValueAction.axis.y * sensitivity;
            speed = Mathf.Clamp(speed, -maxSpeed, maxSpeed);
            movement += orientation * (speed * Vector3.forward);
        }

        characterController.Move(movement);
    }
}

Updated: