Instantly share code, notes, and snippets.

Embed
What would you like to do?
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