Developing a first person controller for Unity3D with UniRx: Part 3
using UnityEngine; | |
using UniRx; | |
using UniRx.Triggers; | |
namespace Assets.Scripts.v3 { | |
public class InputsV3 : MonoBehaviour { | |
// Singleton. | |
public static InputsV3 Instance { get; private set; } | |
public IObservable<Vector2> Movement { get; private set; } | |
public IObservable<Unit> Jump { get; private set; } | |
public IObservable<MoveInputs> MoveInputs { get; private set; } | |
public IObservable<Vector2> Mouselook { get; private set; } | |
public ReadOnlyReactiveProperty<bool> Run { get; private set; } | |
private void Awake() { | |
Instance = this; | |
// Hide the mouse cursor and lock it in the game window. | |
Cursor.lockState = CursorLockMode.Locked; | |
Cursor.visible = false; | |
// Movement inputs tick on FixedUpdate | |
Movement = this.FixedUpdateAsObservable() | |
.Select(_ => { | |
var x = Input.GetAxis("Horizontal"); | |
var y = Input.GetAxis("Vertical"); | |
return new Vector2(x, y).normalized; | |
}); | |
// Jump: sample during Update... | |
Jump = this.UpdateAsObservable() | |
.Where(_ => Input.GetButtonDown("Jump")); | |
// ... But latch it until FixedUpdate. | |
var jumpLatch = CustomObservables.Latch(this.FixedUpdateAsObservable(), Jump, false); | |
// Now zip jump and movement together so we can handle them at the same time. | |
// Zip only works here because both Movement and jumpLatch will emit at the same | |
// frequency: during FixedUpdate. | |
MoveInputs = Movement.Zip(jumpLatch, (m, j) => new MoveInputs(m, j)); | |
// Run while held. | |
Run = this.UpdateAsObservable() | |
.Select(_ => Input.GetButton("Fire3")) | |
.ToReadOnlyReactiveProperty(); | |
// Mouse look ticks on Update | |
Mouselook = this.UpdateAsObservable() | |
.Select(_ => { | |
var x = Input.GetAxis("Mouse X"); | |
var y = Input.GetAxis("Mouse Y"); | |
return new Vector2(x, y); | |
}); | |
} | |
} | |
public struct MoveInputs { | |
public readonly Vector2 movement; | |
public readonly bool jump; | |
public MoveInputs(Vector2 movement, bool jump) { | |
this.movement = movement; | |
this.jump = jump; | |
} | |
} | |
public abstract class PlayerSignalsV3 : MonoBehaviour { | |
public abstract float StrideLength { get; } | |
public abstract IObservable<Vector3> Walked { get; } | |
public abstract IObservable<Unit> Landed { get; } | |
public abstract IObservable<Unit> Jumped { get; } | |
public abstract IObservable<Unit> Stepped { get; } | |
} | |
[RequireComponent(typeof(CharacterController))] | |
public class PlayerControllerV3 : PlayerSignalsV3 { | |
public float walkSpeed = 5f; | |
public float runSpeed = 10f; | |
public float jumpSpeed = 2f; | |
public float strideLength = 2.5f; | |
public float stickToGround = 5f; | |
[Range(-90, 0)] | |
public float minViewAngle = -60f; // How much can the user look down (in degrees) | |
[Range(0, 90)] | |
public float maxViewAngle = 60f; // How much can the user look up (in degrees) | |
// Implement IPlayerSignalsV3 | |
public override float StrideLength { | |
get { return strideLength; } | |
} | |
private Subject<Vector3> walked; | |
public override IObservable<Vector3> Walked { | |
get { return walked; } | |
} | |
private Subject<Unit> landed; | |
public override IObservable<Unit> Landed { | |
get { return landed; } | |
} | |
private Subject<Unit> jumped; | |
public override IObservable<Unit> Jumped { | |
get { return jumped; } | |
} | |
private Subject<Unit> stepped; | |
public override IObservable<Unit> Stepped { | |
get { return stepped; } | |
} | |
private CharacterController character; | |
private Camera view; | |
private void Awake() { | |
character = GetComponent<CharacterController>(); | |
view = GetComponentInChildren<Camera>(); | |
walked = new Subject<Vector3>().AddTo(this); | |
jumped = new Subject<Unit>().AddTo(this); | |
landed = new Subject<Unit>().AddTo(this); | |
stepped = new Subject<Unit>().AddTo(this); | |
} | |
private void Start() { | |
// This sticks the character to the ground immediately so our first frame counts as "grounded". | |
// Otherwise we get a spurious landing at program start. | |
character.Move(-stickToGround * transform.up); | |
var inputs = InputsV3.Instance; | |
// Handle movement input (WASD-style) with run (Shift). | |
inputs.MoveInputs | |
.Subscribe(i => { | |
// Note: CharacterController is a stateful object. But as long as I only modify it from this | |
// function, I can be reasonably sure things will work as expected. | |
var wasGrounded = character.isGrounded; | |
// Vertical movements (jumping and gravity) are the player's y-axis. | |
var verticalVelocity = 0f; | |
if (i.jump && wasGrounded) { | |
// We're on the ground and want to jump. | |
verticalVelocity = jumpSpeed; | |
jumped.OnNext(Unit.Default); | |
} else if (!wasGrounded) { | |
// We're in the air: apply gravity. | |
verticalVelocity = character.velocity.y + (Physics.gravity.y * Time.fixedDeltaTime); | |
} else { | |
// We're otherwise on the ground: push us down a little. | |
// (Required for character.isGrounded to work.) | |
verticalVelocity = -Mathf.Abs(stickToGround); | |
} | |
// Horizontal movements are the player's x- and z-axes. | |
var horizontalVelocity = i.movement * (inputs.Run.Value ? runSpeed : walkSpeed); //Calculate velocity (direction * speed). | |
// Combine horizontal and vertical into player coordinate space. | |
var playerVelocity = transform.TransformVector(new Vector3( | |
horizontalVelocity.x, // input x (+/-) corresponds to strafe right/left (player x-axis) | |
verticalVelocity, | |
horizontalVelocity.y)); // input y (+/-) corresponds to forward/back (player z-axis) | |
// Apply movement. | |
var distance = playerVelocity * Time.fixedDeltaTime; | |
character.Move(distance); | |
// "Output" signals. | |
if (wasGrounded && character.isGrounded) { | |
// Both started and ended this frame on the ground. | |
walked.OnNext(character.velocity * Time.fixedDeltaTime); | |
} | |
if (!wasGrounded && character.isGrounded) { | |
// Didn't start on the ground, but ended up there. | |
landed.OnNext(Unit.Default); | |
} | |
}).AddTo(this); | |
// Track distance walked to emit step events. | |
var stepDistance = 0f; | |
Walked.Subscribe(w => { | |
stepDistance += w.magnitude; | |
if (stepDistance > strideLength) | |
stepped.OnNext(Unit.Default); | |
stepDistance %= strideLength; | |
}).AddTo(this); | |
// Handle mouse input (free mouse look). | |
inputs.Mouselook | |
.Where(v => v != Vector2.zero) // We can ignore this if mouse look is zero. | |
.Subscribe(inputLook => { | |
// Translate 2D mouse input into euler angle rotations. | |
// inputLook.x rotates the character around the vertical axis (with + being right) | |
var horzLook = inputLook.x * Vector3.up; | |
transform.localRotation *= Quaternion.Euler(horzLook * Time.deltaTime); | |
// inputLook.y rotates the camera around the horizontal axis (with + being up) | |
var vertLook = inputLook.y * Time.deltaTime * Vector3.left; | |
var newQ = view.transform.localRotation * Quaternion.Euler(vertLook); | |
// We have to flip the signs and positions of min/max view angle here because the math | |
// uses the contradictory interpretation of our angles (+/- is down/up). | |
view.transform.localRotation = ClampRotationAroundXAxis(newQ, -maxViewAngle, -minViewAngle); | |
}).AddTo(this); | |
} | |
// Ripped straight out of the Standard Assets MouseLook script. (This should really be a standard function...) | |
private static Quaternion ClampRotationAroundXAxis(Quaternion q, float minAngle, float maxAngle) { | |
q.x /= q.w; | |
q.y /= q.w; | |
q.z /= q.w; | |
q.w = 1.0f; | |
float angleX = 2.0f * Mathf.Rad2Deg * Mathf.Atan(q.x); | |
angleX = Mathf.Clamp(angleX, minAngle, maxAngle); | |
q.x = Mathf.Tan(0.5f * Mathf.Deg2Rad * angleX); | |
return q; | |
} | |
} | |
[RequireComponent(typeof(PlayerControllerV3), typeof(AudioSource))] | |
public class PlayerAudio : MonoBehaviour { | |
public AudioClip[] footsteps; | |
public AudioClip jump; | |
public AudioClip land; | |
private PlayerSignalsV3 player; | |
private AudioSource audioSource; | |
private void Awake() { | |
player = GetComponent<PlayerControllerV3>(); | |
audioSource = GetComponent<AudioSource>(); | |
} | |
private void Start() { | |
player.Stepped | |
.SelectRandom(footsteps) | |
.Subscribe(clip => audioSource.PlayOneShot(clip)) | |
.AddTo(this); | |
player.Jumped | |
.Subscribe(_ => audioSource.PlayOneShot(jump)) | |
.AddTo(this); | |
player.Landed | |
.Subscribe(_ => audioSource.PlayOneShot(land)) | |
.AddTo(this); | |
} | |
} | |
public static class CustomObservables { | |
public static IObservable<bool> Latch(IObservable<Unit> tick, IObservable<Unit> latchTrue, bool initialValue) { | |
// Create a custom Observable, whose behavior is determined by our calls to the provided 'observable' | |
return Observable.Create<bool>(observer => { | |
// Our state value. | |
var value = initialValue; | |
// Create an inner subscription to latch: | |
// Whenever latch fires, store true. | |
var latchSub = latchTrue.Subscribe(_ => value = true); | |
// Create an inner subscription to tick: | |
var tickSub = tick.Subscribe( | |
// Whenever tick fires, send the current value and reset state. | |
_ => { | |
observer.OnNext(value); | |
value = false; | |
}, | |
observer.OnError, // pass through tick's errors (if any) | |
observer.OnCompleted); // complete when tick completes | |
// If we're disposed, dispose inner subscriptions too. | |
return Disposable.Create(() => { | |
latchSub.Dispose(); | |
tickSub.Dispose(); | |
}); | |
}); | |
} | |
public static IObservable<T> SelectRandom<T>(this IObservable<Unit> eventObs, T[] items) { | |
// Edge-cases: | |
var n = items.Length; | |
if (n == 0) { | |
// No items! | |
return Observable.Empty<T>(); | |
} else if (n == 1) { | |
// Only one item! | |
return eventObs.Select(_ => items[0]); | |
} | |
var myItems = (T[]) items.Clone(); | |
return Observable.Create<T>(observer => { | |
var sub = eventObs.Subscribe(_ => { | |
// Select any item after the first. | |
var i = Random.Range(1, n); | |
var value = myItems[i]; | |
// Swap with value at index 0 to avoid selecting an item twice in a row. | |
var temp = myItems[0]; | |
myItems[0] = value; | |
myItems[i] = temp; | |
// Finally emit the selected value. | |
observer.OnNext(value); | |
}, | |
observer.OnError, | |
observer.OnCompleted); | |
return Disposable.Create(() => sub.Dispose()); | |
}); | |
} | |
} | |
// There's nothing new in CameraBob since Part 2 | |
[RequireComponent(typeof(Camera))] | |
public class CameraBobV3 : MonoBehaviour { | |
// PlayerSignals reference configured in the Unity Inspector, since we can | |
// reasonably expect these game objects to be in the same hierarchy | |
public PlayerSignalsV3 player; | |
public float walkBobMagnitude = 0.05f; | |
public float runBobMagnitude = 0.10f; | |
public AnimationCurve bob = new AnimationCurve( | |
new Keyframe(0.00f, 0f), | |
new Keyframe(0.25f, 1f), | |
new Keyframe(0.50f, 0f), | |
new Keyframe(0.75f, -1f), | |
new Keyframe(1.00f, 0f)); | |
private Camera view; | |
private Vector3 initialPosition; | |
private void Awake() { | |
view = GetComponent<Camera>(); | |
initialPosition = view.transform.localPosition; | |
} | |
private void Start() { | |
var distance = 0f; | |
player.Walked.Subscribe(w => { | |
// Accumulate distance walked (modulo stride length). | |
distance += w.magnitude; | |
distance %= player.StrideLength; | |
// Use distance to evaluate the bob curve. | |
var magnitude = InputsV3.Instance.Run.Value ? runBobMagnitude : walkBobMagnitude; | |
var deltaPos = magnitude * bob.Evaluate(distance / player.StrideLength) * Vector3.up; | |
// Adjust camera position. | |
view.transform.localPosition = initialPosition + deltaPos; | |
}).AddTo(this); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment