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);
}
}