這是三部曲的第二篇盗温。使用 ReactiveX 實(shí)現(xiàn) Unity 標(biāo)準(zhǔn)資源包中的第一人稱控制器,是這個系列文章的主要內(nèi)容婶恼。
我們上一篇已經(jīng)完成了行走和鼠標(biāo)控制視野功能「坑牵現(xiàn)在我們來添加奔跑功能和攝像機(jī)震動功能痕钢。這篇文章里我們將會開始看到一些我們在第一部分所做工作的一些回報(bào)。以下是我們這次將要完成的效果:
<iframe width="786" height="442" src="https://www.youtube.com/embed/G_N8l9Sd-aI" frameborder="0" allowfullscreen></iframe>
My little runaway
(譯注:題目好像是歌名)
"按住 Shift 跑動" 在第一人稱游戲中幾乎是標(biāo)準(zhǔn)實(shí)踐鱼冀,所以我們要支持這個功能报破。即便使用 Observable 也有很多種實(shí)現(xiàn)方式。不過我覺得這是一個介紹響應(yīng)式屬性(Reactive Properties)的絕佳機(jī)會千绪。這個特性是 UniRx 中獨(dú)有的充易,它不在 ReactiveX 的標(biāo)準(zhǔn)中。
響應(yīng)式屬性讓我們二者兼得:技能擁有屬性的靈活性荸型,也可以擁有 Observable 的功能盹靴。你不僅可以像設(shè)置和獲取一個正常屬性值那樣來操作響應(yīng)式屬性,而且你還可以通過訂閱這個屬性來得到它變化的通知。將奔跑的信號輸入轉(zhuǎn)換成響應(yīng)式屬性的原因是稿静,我不想對用戶按下和釋放按鍵做出反應(yīng)梭冠,我僅僅想要知道在計(jì)算移動時,按鍵處于按下狀態(tài)改备。下面給 Inputs 腳本添加一些代碼(我省略了之前的代碼)
public ReadOnlyReactiveProperty<bool> Run { get; private set; }
// ...
private void Awake() {
// ...
Run = this.UpdateAsObservable()
.Select(_ => Input.GetButton("Fire3"))
.ToReadOnlyReactiveProperty();
}
首先控漠,我聲明了一個 ReadOnlyReactiveProperty
屬性。如果我使用普通的 ReactiveProperty 屬性悬钳,那么任何代碼都可以改變它的值盐捷。為了能讓你的代碼解耦,更好的方式是默勾,在任何可能的情況下限制寫的權(quán)限碉渡。并且無論什么情況下,我們都不必主動設(shè)置 Run 的值母剥,因?yàn)槲覀兺耆梢灾圃炝硪粋€ Observable 來生產(chǎn)新值爆价。正如我們在 Awake 中所做的:每次 Update 時得到 “Fire3” 按鈕的狀態(tài),將它轉(zhuǎn)化為屬性值媳搪。(“Fire3” 是 Unity 項(xiàng)目默認(rèn)定義的輸入軸, 可以方便地匹配到 Shift 鍵)
使用這個輸入也很簡單铭段。在 PlayerController 添加一個 runSpeed 屬性。現(xiàn)在我們在計(jì)算移動的時候秦爆,就可以查詢 Run 的值來決定使用哪種速度了序愚。
// In PlayerController.Start()
inputs.Movement
.Where(v => v != Vector2.zero)
.Subscribe(inputMovement => {
var inputVelocity = inputMovement * (inputs.Run.Value ? runSpeed : walkSpeed);
// ... etc.
這可能是實(shí)現(xiàn)這個功能最簡單的方式了,但它是否足夠好呢等限? 此處使用 Observable (或者任何其他的異步代碼) 有些許微妙之處:因?yàn)槌莾蓚€輸入信號直接依賴于彼此爸吮,否則我們基本上無法保證它們的執(zhí)行順序。換句話說:我不清楚當(dāng)我訪問 Run 值的時候望门,它是否已經(jīng)更新完畢了形娇。所以在使用這段代碼的時候,我們心中最好有預(yù)期筹误。
站在移動信號的角度看桐早, Run 值可能會落后一幀。現(xiàn)在有方法可以確保正確的執(zhí)行順序(我們將在第三部分看到他們)厨剪,但這會是你的代碼變得復(fù)雜化哄酝。你要想清楚這樣做是否值得。跑慢一幀有沒有關(guān)系祷膳?可能沒問題陶衅,也可能有。這當(dāng)然就是你的工作啦直晨,去把它找出來搀军。但現(xiàn)在膨俐,我們會繼續(xù)使用這個簡單的方法。
在下面的情況下罩句,你可能對這種執(zhí)行效果感覺足夠了:首先你知道 Update 是在 FixedUpdate 之前處理的吟策,并且我們代碼的調(diào)用都是像這樣直截了當(dāng)?shù)摹5悴荒苤竿@點(diǎn)的止。所以最好是圍繞這個問題進(jìn)行設(shè)計(jì)檩坚。
Bob and un-weave
實(shí)現(xiàn)攝像機(jī)擺動將會帶來更多的代碼混合,也因此我們可以看到 Observables 展現(xiàn)給我們的低耦合代碼了诅福。我們會對攝像機(jī)進(jìn)行輕微的彈動來模擬行走匾委,所以我們需要知道移動時每一幀間隔的空間距離。在標(biāo)準(zhǔn)資源中氓润,這個效果是通過讓播放器的控制器直接更新負(fù)責(zé)相機(jī)擺動效果的類來實(shí)現(xiàn)的赂乐。自然地,這會將播放器控制器代碼和相機(jī)擺動的代碼耦合在一起咖气,而這正是我們要避免的“ご耄現(xiàn)在,我們要使用 Observable 生成這兩個類之間的接口崩溪。(好吧浅役,它實(shí)際上是一個抽象類,但是很容易使用 Unity 的 inspector 做兼容處理)
public abstract class PlayerSignals : MonoBehaviour {
public abstract float StrideLength { get; }
public abstract IObservable<Vector3> Walked { get; }
}
我們的 PlayerController
將會繼承并實(shí)現(xiàn)這個抽象類(譯注:原作者在代碼的第一版時使用的是接口伶唯,后又轉(zhuǎn)而使用了抽象類觉既,原文此處行文為接口),因此控制相機(jī)擺動的腳本則不需要直接依賴 PlayerController 乳幸。StrideLength
是一個簡單的配置項(xiàng)瞪讼。那我們怎么實(shí)現(xiàn) Walked Observable 呢? Unity 的 CharacterController 組件實(shí)際上會為我們計(jì)算這個值(這是考慮到墻壁碰撞后移動的實(shí)際距離),我們要做的就是導(dǎo)入這個值粹断。改寫移動的代碼符欠。
inputs.Movement
.Where(v => v != Vector2.zero)
.Subscribe(inputMovement => {
// ...
var distance = playerVelocity * Time.fixedDeltaTime;
character.Move(distance);
var distanceActuallyWalked = character.velocity * Time.fixedDeltaTime;
}).AddTo(this);
我們想要將 distanceActuallyWalked
放到一個 Observable 中。但是我說過不能從外部向 Observable 中注入值瓶埋,對吧希柿?
其中一個選擇是介紹一個新概念:Subject。在 ReactiveX 中悬赏,Subject 結(jié)合了 Observer 和 Observable 狡汉, 但你不能將它理解成是一個 “可讀寫的 Observable”。 那它就與響應(yīng)式屬性沒有什么區(qū)別了闽颇。Subject 沒有像響應(yīng)式屬性那樣定義一個“當(dāng)前值”。 你只能通過訂閱 Subject 來得到它改變后傳給你的通知寄锐,但你不能拿到它的當(dāng)前值兵多。所以你可以將它理解成閹割版的響應(yīng)式屬性尖啡。而且你在任何情況下都應(yīng)該選擇能使代碼正常運(yùn)行而功能添加又最少的選項(xiàng)。在實(shí)踐中需要根據(jù)如何使用信號來進(jìn)行決策剩膘。
那我們在 PlayerController 腳本中添加一個 Subject<Vector3> 字段衅斩,因?yàn)檫@是屬于我們自己的信號(譯注:“我們”指 PlayerController, 相對于 Inputs而言)怠褐,那就需要在 Awake 中初始化它畏梆。
walked = new Subject<Vector3>().AddTo(this);
為了能在我們的游戲?qū)ο笾屑s束這個信號的生命周期,添加了 AddTo(this)
奈懒。
現(xiàn)在我們用下面這行代替 distanceActuallyWalked
:
walked.OnNext(character.velocity * Time.fixedDeltaTime);
OnNext 方法會為信號提供一個新的值奠涌。任何訂閱這個信號的人都會得到攜帶這個新值得通知。
我們到現(xiàn)在還沒接觸到相機(jī)擺動的那一部分磷杏,但是我覺得我們已經(jīng)做的相當(dāng)不錯了溜畅。我們的腳本不僅僅只是“消耗”信號,而且還能產(chǎn)生新的信號极祸。這使你可以集成不基于 Observable 的系統(tǒng)慈格,比如 Unity 的物理系統(tǒng)和場景圖。
這里最后需要記著點(diǎn)遥金。到目前為止浴捆,我都十分謹(jǐn)慎地限制著代碼的讀寫權(quán)限。如果我公開了 Subject 的權(quán)限稿械,那么潛在地汤功,任何人都可能在此處修改信號的值并破壞我們的代碼(可能是在上線前一天的夜里3點(diǎn)鐘)。不過不用擔(dān)心:因?yàn)?Subject 是一個 Observable溜哮,我們可以像下面這樣通過定義來簡單的限制一下我們變量的可見性滔金。
private Subject<Vector3> walked;
public override IObservable<Vector3> Walked {
get { return walked; }
}
我們可以看見 Subject,其他人只能看見 IObservable茂嗓。干凈漂亮餐茵!
Okey,接著就是實(shí)際的相機(jī)擺動腳本了述吸!這個腳本需要設(shè)置在相機(jī)的游戲?qū)ο笊戏拮澹刂扑倪\(yùn)動。我們要訂閱 Walked 信號并累計(jì)玩家移動的距離蝌矛,然后在對步長取模道批。我們用 Unity AnimationCurve 將這個值轉(zhuǎn)化為能正確調(diào)整相機(jī)位置的正弦曲線。(譯注:代碼中的注釋就不翻譯了入撒,最下面有完整的代碼和注釋)
public class CameraBob : MonoBehaviour {
// IPlayerSignals reference configured in the Unity Inspector, since we can
// reasonably expect these game objects to be in the same hierarchy
public PlayerSignals player;
public float walkBobMagnitude = 0.05f;
public float runBobMagnitude = 0.10f;
public AnimationCurve bob;
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 = InputsV2.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);
}
}
注意隆豹,distance
變量是需要在訂閱內(nèi)部使用的 狀態(tài)
。在這我用使用了閉包來實(shí)現(xiàn)茅逮。之前我們是使用類變量來實(shí)現(xiàn)的璃赡。(例如:CharacterController 實(shí)例)
為了提升真實(shí)感判哥,我們還可以根據(jù)玩家的跑動狀態(tài)來決定相機(jī)擺動的幅度大小。歸根結(jié)底碉考,這個例子是為了說明如何清晰整潔地重用一個信號:當(dāng)我們處理 Run 的時候真的無需考慮相機(jī)如何擺動塌计。
我們完成了第2部分!我們添加了跑動和相機(jī)特效锌仅,而并沒有將我們的代碼混雜。當(dāng)需求變化時墙贱,我們可以輕易地將相機(jī)擺動的效果移除热芹,因?yàn)樗鼪]有和任何代碼耦合在一起。接下來的第三部分嫩痰,我們將會添加跳躍和一些其他的效果剿吻。
你可以在 GitHub Gist 上找到完整的代碼.
譯注:以下是完整代碼,作者貼到了 github 上串纺,沒有寫在原文中
Inputs.cs
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using System;
public class Inputs : MonoBehaviour {
// 單例
public static Inputs instance;
public IObservable<Vector2> movement { get; private set; }
public IObservable<Vector2> mouselook { get; private set; }
public ReadOnlyReactiveProperty<bool> run { get; private set; }
public void Awake () {
instance = this;
// 隱藏鼠標(biāo)指針丽旅,將其鎖定在游戲窗口內(nèi)
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
// 移動輸入 tick 基于 fixedUpdate
movement = this.FixedUpdateAsObservable()
.Select(_ => {
var x = Input.GetAxis("Horizontal");
var y = Input.GetAxis("Vertical");
return new Vector2(x, y).normalized;
});
// 鼠標(biāo)視野 tick 基于 Update
mouselook = this.UpdateAsObservable()
.Select(_ => {
var x = Input.GetAxis("Mouse X");
var y = Input.GetAxis("Mouse Y");
return new Vector2(x, y);
});
// 按下時奔跑
run = this.UpdateAsObservable()
.Select(_ => Input.GetButton("Fire3"))
.ToReadOnlyReactiveProperty();
}
}
PlayerSignals.cs
using UnityEngine;
using UniRx;
public abstract class PlayerSignals: MonoBehaviour {
public abstract float strideLength { get; }
public abstract IObservable<Vector3> walked { get; }
}
PlayerController.cs
using UnityEngine;
using UniRx;
[RequireComponent(typeof(CharacterController))]
public class PlayerController : PlayerSignals {
float walkSpeed = 5f;
float runSpeed = 10f;
float _strideLength = 2.5f;
[Range(-90, 0)]
public float minViewAngle = -60; // 玩家最低能看多少角度
[Range(0, 90)]
public float maxViewAngle = 60; // 玩家最高能看多少角度
// 實(shí)現(xiàn) PlayerSignal
public override float strideLength {
get { return _strideLength; }
}
private Subject<Vector3> _walked; // 我們自己看是 Subject
public override IObservable<Vector3> walked {
get { return _walked; } // 其他人看是 IObservable
}
CharacterController character;
Camera view;
void Awake () {
character = GetComponent<CharacterController>();
view = GetComponentInChildren<Camera>();
_walked = new Subject<Vector3>().AddTo(this);
}
void Start () {
var inputs = Inputs.instance;
// 處理 wsad 的行走和奔跑效果
inputs.movement
.Where(v2 => v2 != Vector2.zero) // 如果移動量為0則忽略
.Subscribe(inputMovement => {
// 計(jì)算速度 (方向 * 速率)
var inputVelocity = inputMovement * (inputs.run.Value ? runSpeed : walkSpeed);
// 將 2D 的速度 轉(zhuǎn)化為 3D 玩家的坐標(biāo)
var playerVelocity =
inputVelocity.x * transform.right + // x (+/-) 對應(yīng)右/左
inputVelocity.y * transform.forward; // y (+/-) 對應(yīng)前/后
// 使用移動量
var distance = playerVelocity * Time.fixedDeltaTime;
character.Move(distance);
// 移動產(chǎn)生信號
_walked.OnNext(character.velocity * Time.fixedDeltaTime);
}).AddTo(this);
// 處理鼠標(biāo)輸入
inputs.mouselook
.Where(v2 => v2 != Vector2.zero) // 如果鼠標(biāo)沒動則忽略
.Subscribe(inputLook => {
// 將 2D 鼠標(biāo)輸入轉(zhuǎn)化為歐拉角的轉(zhuǎn)動量
// inputLook.x 使角色繞縱軸旋轉(zhuǎn)(+ 代表右轉(zhuǎn))
var horzLook = inputLook.x * Time.deltaTime * Vector3.up * 100.0f;
transform.localRotation *= Quaternion.Euler(horzLook);
// inputLook.y 使相機(jī)繞橫軸旋轉(zhuǎn) (+ 代表向上轉(zhuǎn))
var verLook = inputLook.y * Time.deltaTime * Vector3.left * 100.0f;
var newQ = view.transform.localRotation * Quaternion.Euler(verLook);
// 我們必須在這里翻轉(zhuǎn)最小/最大視角的標(biāo)志和位置,
// 因?yàn)榇颂帞?shù)學(xué)計(jì)算和角度相矛盾(+/- 對應(yīng)下/上)
view.transform.localRotation = ClampRotationAroundXAxis(
newQ, -maxViewAngle, -minViewAngle
);
});
}
// 直接從標(biāo)準(zhǔn)資源中的 MouseLook 腳本中拿出來的(這真的是一個標(biāo)準(zhǔn)函數(shù)...)
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;
}
}
CameraBob.cs
using UnityEngine;
using UniRx;
[RequireComponent(typeof(Camera))]
public class CameraBob: MonoBehaviour {
public PlayerSignals player;
float walkBobMagnitude = 0.05f;
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)
);
Camera view;
Vector3 initialPosition;
void Awake () {
view = GetComponent<Camera>();
initialPosition = view.transform.localPosition;
// 譯注: 作者在 Inspector 界面進(jìn)行配置,為了更好理解
// 將獲取腳本的代碼寫在了這纺棺。但這樣使得代碼變的耦合有利有弊
player = transform.parent.GetComponent<PlayerSignals>();
}
void Start () {
var distance = 0f;
player.walked.Subscribe(w => {
// 累計(jì)行走的距離(步幅的模長)
distance += w.magnitude;
distance %= player.strideLength;
// 用 distance 設(shè)置相機(jī)的震動曲線
var magnitude = Inputs.instance.run.Value ? runBobMagnitude : walkBobMagnitude;
var deltaPos = magnitude * bob.Evaluate(distance / player.strideLength) * Vector3.up;
// 調(diào)整相機(jī)位置
view.transform.localPosition = initialPosition + deltaPos;
}).AddTo(this);
}
}