Week 2.5: Implementing a VR Bow and Arrow SystemXRPullInteractable.cs

This week, I expanded my VR project by adding a bow and arrow mechanic inspired by a YouTube tutorial. The system lets the player use VR controllers to pull back a virtual bowstring, visually represented with a line renderer, and shoot arrows that respond to how far the string is drawn.

🎯 How the Bow and Arrow System Works

  • Line Renderer for Bowstring: The bowstring is displayed using a Unity Line Renderer, updating dynamically as the player pulls it back.
  • VR Pull Interaction: The player grabs the string with a VR controller and pulls it backward. The system measures the pull distance to determine arrow force.
  • Arrow Spawning and Shooting: When the string is released, an arrow is spawned at the notch, launched forward with force based on the pull amount, and can stick into surfaces.

Key Scripts and Their Roles

XRPullInteractable.cs

Handles the pulling action on the bowstring, updates the string’s position, and triggers events when the string is pulled or released.

namespace UnityEngine.XR.Interaction.Toolkit.Interactables
{
public class XRPullInteractable : XRBaseInteractable
{
public event Action<float> PullActionReleased;
public event Action<float> PullUpdated;
public event Action PullStarted;
public event Action PullEnded;

[Header("Pull Settings")]
[SerializeField] private Transform _startPoint;
[SerializeField] private Transform _endPoint;
[SerializeField] private GameObject _notchPoint;

public float pullAmount { get; private set; } = 0f;

private LineRenderer _lineRenderer;
private IXRSelectInteractor _pullingInteractor = null;

protected override void Awake()
{
base.Awake();
_lineRenderer = GetComponent<LineRenderer>();
}

public void SetPullInteractor(SelectEnterEventArgs args)
{
_pullingInteractor = args.interactorObject;
PullStarted?.Invoke();
}

public void Release()
{
PullActionReleased?.Invoke(pullAmount);
PullEnded?.Invoke();
_pullingInteractor = null;
pullAmount = 0f;
_notchPoint.transform.localPosition =
new Vector3(_notchPoint.transform.localPosition.x, _notchPoint.transform.localPosition.y, 0f);
UpdateStringAndNotch();
}

public override void ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase updatePhase)
{
base.ProcessInteractable(updatePhase);
if (updatePhase == XRInteractionUpdateOrder.UpdatePhase.Dynamic)
{
if (isSelected && _pullingInteractor != null)
{
Vector3 pullPosition = _pullingInteractor.GetAttachTransform(this).position;
float previousPull = pullAmount;
pullAmount = CalculatePull(pullPosition);

if (previousPull > pullAmount)
{
PullUpdated?.Invoke(pullAmount);
}
UpdateStringAndNotch();
HandleHaptics();
}
}
}

protected override void OnSelectEntered(SelectEnterEventArgs args)
{
base.OnSelectEntered(args);
SetPullInteractor(args);
}

private float CalculatePull(Vector3 pullPosition)
{
Vector3 pullDirection = pullPosition - _startPoint.position;
Vector3 targetDirection = _endPoint.position - _startPoint.position;
float maxLength = targetDirection.magnitude;

targetDirection.Normalize();
float pullValue = Vector3.Dot(pullDirection, targetDirection) / maxLength;
return Mathf.Clamp(pullValue, 0, 1);
}

private void UpdateStringAndNotch()
{
Vector3 linePosition = Vector3.Lerp(_startPoint.localPosition, _endPoint.localPosition, pullAmount);
_notchPoint.transform.localPosition = linePosition;
_lineRenderer.SetPosition(1, linePosition);
}

private void HandleHaptics()
{
if (_pullingInteractor != null && _pullingInteractor is XRBaseInputInteractor controllerInteractor)
{
controllerInteractor.SendHapticImpulse(pullAmount, 0.1f);
}
}
}
}

ArrowSpawner.cs

Spawns an arrow at the notch point when the bow is grabbed and manages the arrow’s lifecycle.

public class ArrowSpawner : MonoBehaviour
{
[SerializeField] private GameObject _arrowPrefab;
[SerializeField] private GameObject _notchPoint;
[SerializeField] private float spawnDelay = 1f;

private XRGrabInteractable _bow;
private XRPullInteractable _pullInteractable;
private bool _arrowNotched = false;
private GameObject _currentArrow = null;

private void Start()
{
_bow = GetComponent<XRGrabInteractable>();
_pullInteractable = GetComponent<XRPullInteractable>();

if (_pullInteractable != null)
{
_pullInteractable.PullActionReleased += NotchEmpty;
}
}

private void OnDestroy()
{
if (_pullInteractable != null)
{
_pullInteractable.PullActionReleased -= NotchEmpty;
}
}

private void Update()
{
if (_bow.isSelected && !_arrowNotched)
{
_arrowNotched = true;
StartCoroutine(DelayedSpawn());
}

if (!_bow.isSelected && _currentArrow != null)
{
Destroy(_currentArrow);
NotchEmpty(1f);
}
}

private void NotchEmpty(float value)
{
_arrowNotched = false;
_currentArrow = null;
}

private IEnumerator DelayedSpawn()
{
yield return new WaitForSeconds(spawnDelay);

_currentArrow = Instantiate(_arrowPrefab, _notchPoint.transform);

ArrowLauncher launcher = _currentArrow.GetComponent<ArrowLauncher>();
if (launcher != null && _pullInteractable != null)
{
launcher.Initialize(_pullInteractable);
}
}
}

ArrowLauncher.cs

Controls the arrow’s flight, applying force based on how far the string was pulled.

public class ArrowLauncher : MonoBehaviour
{
[Header("Launch Settings")]
[SerializeField] private float _speed = 10f;

[Header("Visual Effects")]
[SerializeField] private GameObject _trailSystem;

private Rigidbody _rigidbody;
private bool _inAir = false;
private XRPullInteractable _pullInteractable;

private void Awake()
{
InitializeComponents();
SetPhysics(false);
}

private void InitializeComponents()
{
_rigidbody = GetComponent<Rigidbody>();
if (_rigidbody == null)
{
Debug.LogError($"Rigidbody component not found on Arrow {gameObject.name}");
}
}

public void Initialize(XRPullInteractable pullInteractable)
{
_pullInteractable = pullInteractable;
_pullInteractable.PullActionReleased += Release;
}

private void OnDestroy()
{
if (_pullInteractable != null)
{
_pullInteractable.PullActionReleased -= Release;
}
}

private void Release(float value)
{
if (_pullInteractable != null)
{
_pullInteractable.PullActionReleased -= Release;
}

gameObject.transform.parent = null;
_inAir = true;
SetPhysics(true);

Vector3 force = transform.forward * value * _speed;
_rigidbody.AddForce(force, ForceMode.Impulse);
StartCoroutine(RotateWithVelocity());

_trailSystem.SetActive(true);
}

private IEnumerator RotateWithVelocity()
{
yield return new WaitForFixedUpdate();
while (_inAir)
{
if (_rigidbody != null && _rigidbody.linearVelocity.sqrMagnitude > 0.01f)
{
transform.rotation = Quaternion.LookRotation(_rigidbody.linearVelocity, transform.up);
}
yield return null;
}
}

public void StopFlight()
{
_inAir = false;
SetPhysics(false);
_trailSystem.SetActive(false);
}

private void SetPhysics(bool usePhysics)
{
if (_rigidbody != null)
{
_rigidbody.useGravity = usePhysics;
_rigidbody.isKinematic = !usePhysics;
}
}
}

ArrowImpactHandler.cs

Handles what happens when the arrow hits a surface—either sticking into it or triggering an effect.

public class ArrowImpactHandler : MonoBehaviour
{
[Header("Impact Settings")]
[SerializeField] private bool _explodeOnImpact = false;
[SerializeField] private float _stickDuration = 3f;
[SerializeField] private float _minEmbedDepth = 0.05f;
[SerializeField] private float _maxEmbedDepth = 0.15f;
[SerializeField] private LayerMask _ignoreLayers;
[SerializeField] private Transform _tip;

[Header("Visual Effects")]
[SerializeField] private GameObject _impactGameObject;
[SerializeField] private MeshRenderer _arrowMeshRenderer;

private ArrowLauncher _arrowLauncher;
private Rigidbody _rigidbody;
private bool _hasHit = false;

private void Awake()
{
_arrowLauncher = GetComponent<ArrowLauncher>();
_rigidbody = GetComponent<Rigidbody>();
}

private void OnCollisionEnter(Collision collision)
{
if (_hasHit || ((1 << collision.gameObject.layer) & _ignoreLayers) != 0)
{
return;
}

_hasHit = true;
_arrowLauncher.StopFlight();

if (_explodeOnImpact)
{
HandleExplosion();
}
else
{
HandleStick(collision);
}
}

private void HandleExplosion()
{
Debug.Log("Explosion Called");
if (_arrowMeshRenderer != null)
{
_arrowMeshRenderer.enabled = false;
}

if (_impactGameObject != null)
{
Instantiate(_impactGameObject, transform.position, Quaternion.identity);
}

Destroy(gameObject);
}

private void HandleStick(Collision collision)
{
Vector3 arrowDirection = transform.forward;
Vector3 arrowUp = transform.up;
ContactPoint contact = collision.GetContact(0);

float randomDepth = Random.Range(_minEmbedDepth, _maxEmbedDepth);
Quaternion finalRotation = Quaternion.LookRotation(arrowDirection, arrowUp);
Vector3 centerOffSet = _tip.localPosition;
Vector3 finalPosition = contact.point - (finalRotation * centerOffSet) + contact.normal * -randomDepth;

transform.SetPositionAndRotation(finalPosition, finalRotation);

CreateJabPoint(collision, randomDepth);

transform.SetParent(collision.transform, true);
StartCoroutine(DespawnAfterDelay());
}

public ConfigurableJoint CreateJabPoint(Collision collision, float randomDepth)
{
var joint = gameObject.AddComponent<ConfigurableJoint>();
joint.connectedBody = collision.rigidbody;
joint.xMotion = ConfigurableJointMotion.Limited;
joint.yMotion = ConfigurableJointMotion.Locked;
joint.zMotion = ConfigurableJointMotion.Locked;

var limit = joint.linearLimit;
limit.limit = randomDepth;
joint.linearLimit = limit;

return joint;
}

private IEnumerator DespawnAfterDelay()
{
yield return new WaitForSeconds(_stickDuration);
Destroy(gameObject);
}
}

What I Learned

  • VR Interactions: Using Unity’s XR Interaction Toolkit made it much easier to handle VR controller input and object manipulation.
  • Visual Feedback: The line renderer and haptic feedback made the bow feel more realistic.
  • Physics-Based Shooting: Calculating force based on pull distance gave the arrows a natural, variable trajectory.
  • Tutorials as Learning Tools: Adapting code from tutorials helped me understand event-driven programming and Unity’s component system.

Reference

This bow and arrow system was adapted from a YouTube tutorial by Valem.

Next Steps:
I plan to add damage to enemies from arrows, improve arrow pickup and quiver mechanics, and add sound/particle effects for a more immersive experience.

: Valem, “VR Bow and Arrow in Unity – XR Interaction Toolkit Tutorial”, YouTube.

Leave a comment

Design a site like this with WordPress.com
Get started