Created
July 20, 2016 23:22
-
-
Save JavadocMD/ec0cb1c7a680f6760a82d3ddd44ba603 to your computer and use it in GitHub Desktop.
Developing a first person controller for Unity3D with UniRx: Part 3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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