耦合性強的代碼令人頭痛崎苗。一定是有某種自然力量,像重力那樣舀寓,拽著代碼的尾巴胆数,將他們糾纏在一起,難以閱讀互墓,脆弱又混亂必尼。正如你寫的那樣。而且由于一些原因這種情況在游戲開發(fā)中更為常見。除非你自覺的抵制它判莉,否則你的游戲最終會達到臨界值豆挽,進一步塌陷成一個黑洞。(為什么這樣說券盅,是我最近讀了挺多的科幻小說)
我現(xiàn)在不想闡述耦合性的定義帮哈,也不想說服你覺得這是個需要解決的問題。而是锰镀,我會直接告訴你應該怎么辦娘侍。
代碼的好壞與我們使用的工具有很大關系(at risk of repeating myself),在游戲開發(fā)當中泳炉,通常會使用像組件系統(tǒng)這樣的工具來管理代碼的耦合私蕾。而我的目標是希望大家能夠熟練使用另一種在游戲開發(fā)中(我認為)不太為人知曉的工具(至少到目前為止):ReactiveX。
工具
從基礎層面上來看胡桃,可以說 ReactiveX 很像事件處理。好吧磕潮,即便這樣那也像用使用了渦輪增壓來做事件處理翠胰。除了能觸發(fā)和處理事件,我們還可以向處理一等公民(譯注: 例如數(shù)字變量)那樣處理事件隊列自脯。他們甚至有屬于自己的名字:IObservable<T>之景。
并且我們還可以通過很多種方式將他們變形,延時膏潮,過濾锻狗,組合或者為其自定義行為。他們將會擁有比你從前使用的事件處理更為強大的功能焕参。(如果你想看轻纪,ReactiveX 可以提供一個詳盡的介紹)
我能感受到你的懷疑。所有這些響應式功能的天書難道不是為 Web 開發(fā)者和大數(shù)據(jù)大佬準備的嗎叠纷?
我能理解刻帚!因為程序員的初始狀態(tài)都設置在了密蘇里州(不輕信州-譯注:俚語,密蘇里州別名)涩嚣。是騾子是馬拉出來溜溜(譯注:俚語崇众,不會翻)。所以作為示范航厚,我們重寫 Unity3D 標準資源包中的 FirstPersonController
看看顷歌。
目標
在 Unity 中進行原型設計使用標準資源包是非常方便的。資源包提供了簡單的用例幔睬,它的第一人稱控制器打包了一些腳本和游戲對象眯漩,它們?yōu)榈谝蝗朔Q游戲提供了以下特性:
- 使用 WSAD 進行移動
- 使用鼠標進行環(huán)視(上下左右)
- 基礎的碰撞物理學,因此你可以在地上行走也可以倒在地上溪窒。
- 按住 Shift 鍵進行奔跑
- 相機擺動:行走時相機會隨著步伐有輕微的上下浮動
- 按 Space 鍵跳躍
- 播放腳步聲坤塞,跳躍聲和落在地上的聲音
你不需要對 Unity 的 FirstPersonController
代碼很熟悉冯勉。我們不會 100 % 實現(xiàn)它的所有功能。但是我們會盡量接近它摹芙。在此之后灼狰,我相信你會認同 Observable 是更簡潔,更容易理解浮禾,也更容易修改的方式交胚。
那么讓我們開始吧!為了能流暢的進行下去盈电,我假設你已經對 Unity 的基礎比較熟悉了蝴簇。這個系列被分為了三個部分。這一部分我們實現(xiàn)兩個基本的效果: 移動和鼠標視角匆帚。
當我們完成時熬词,效果應該大致如下(譯注:youtube):
<iframe width="786" height="442" src="https://www.youtube.com/embed/0BasblVKr_E?ecver=1" frameborder="0" allowfullscreen></iframe>
開始
在創(chuàng)建完Unity項目之后, 我們從 Asset Store 中導入兩個包:Standard Asset(用于實現(xiàn)一些圖形和聲音效果)和 UniRx。 在GitHub UniRx上提供了一些關于 ReactiveX 框架和一些為 Unity 單獨實現(xiàn)的特性的說明吸重。
下一步我們?yōu)閳鼍疤砑庸庹栈ナ埃粋€ Quad 作為地板, 一個 Cube 作為障礙、一個自帶相機的 GameObject 作為玩家和一個名為 “GameController” 的 GameObject嚎幸,GameController 僅為了方便地保存全局的腳本文件颜矿。大致的結構看起來會是這樣:
我還在 Player 游戲對象上添加了一個 CharacterController 組件。
這是一個可以使用輸入驅動一些簡易的移動和碰撞處理的內置人物組件嫉晶。你可以使用膠囊碰撞機骑疆,運動剛體和一些代碼來實現(xiàn)這個功能。不過我們現(xiàn)在還不至如此麻煩替废。
行走的實現(xiàn)
現(xiàn)在讓我們的玩家開始移動箍铭。比較典型的實現(xiàn)方式會像這樣:
public class ClassicPlayerController : MonoBehaviour {
private void Update () {
// 讀取鍵盤輸入
// 轉換成移動速度
// 將速度賦值給人物
}
}
顯然,這樣已經讓我們的代碼變得緊耦合了舶担。為什么玩家控制器必須要知道如何讀取輸入? 如果我們想要改變輸入的方式坡疼。輸入信號可能來自跳舞毯,VR頭盔衣陶,Twitch命令柄瑰,有感情的AI。我們不可能為這些輸入的每一種都實現(xiàn)一個 PlayerController剪况。即便你知道你的游戲始終只用鍵盤控制輸入教沾,那你也應該從現(xiàn)在起避免造成不必要的耦合。
另一種方案译断,我們將移動輸入設想成一個信號:按下 W 鍵授翻,代表信號說“前進”。我們的控制器不知道信號是怎么做的,只是在接收到信號時實現(xiàn)動作堪唐。信號就是 Observable巡语,例如移動(movement,一個2D向量)淮菠,它就可以表示成 IObservable<Vector2>男公。最后,玩家控制器訂閱(Subscribe)這個 Observable 就會創(chuàng)建一個觀察者(Observer),觀察者每當Observable 處理了一個新的 Vector2 矢量時都會對訂閱者發(fā)出通知合陵。
所以枢赔。我們可以為輸入創(chuàng)建一個單獨的腳本來專注分離:
using UnityEngine;
using UniRx;
using UniRx.Triggers;
public class Inputs : MonoBehaviour {
public IObservable<Vector2> Movement { get; private set; }
private void Awake() {
Movement = this.FixedUpdateAsObservable()
.Select(_ => {
var x = Input.GetAxis("Horizontal");
var y = Input.GetAxis("Vertical");
return new Vector2(x, y).normalized;
});
}
}
首先我們聲明一個 movement 變量作為公開屬性,然后再在 Awake 時對它進行初始化拥知。Observables 的一大好處就是你不能從外部注入變量踏拜。當你創(chuàng)建完一個 Observable 之后,你唯一能做的就只有從訂閱中拿到它的輸出值低剔。這聽起來令人沮喪速梗,但是對于編寫低耦合的代碼卻是一個非常棒的特性。在已經存在的 Observable上變換出新的 Observable 是最常見的創(chuàng)建 Observable 方式襟齿。這里使用“變換”這個詞不太恰當镀琉,因為原來的Observable 仍然還在 - 他們是不可變的 - 我們只能創(chuàng)建一個新的。
我們什么時候需要讀取輸入信號蕊唐?Fixed Update(不是 Update,因為我們知道移動涉及到了物理系統(tǒng))烁设。UniRx 提供了一個可以在每次Fixed Update 時 “tick” 一次的 Observable, 你可以通過調用 this.FixedUpdateAsObservable()
獲取它(ns: UnityRx.Trigger)替梨。返回值是一個 IObservable<Unit>,沒有更詳細的解釋装黑。Unit
告訴我們信號中沒有有效的信息數(shù)據(jù)副瀑。事實上信號得觸發(fā)就代表了這個信號的全部了。在 Observable 上我們調用了一個名為 Select
的方法恋谭。這個方法會在每次輸入之后會返回一個新的 Observable糠睡,輸出的值由我們傳入的函數(shù)決定。此例中疚颊,這個函數(shù)讀取行走的 x y軸數(shù)據(jù)狈孔,然后返回一個歸一化的矢量。所以此時的 Movement 是一個每次 Fixed Update 時根據(jù)鍵盤操作輸出一個矢量的 Observable材义。(譯注:Observable 只有在被訂閱之后才有實際的調用效果均抽,是惰性的)
我們該怎么使用它呢? 在另一個腳本中其掂。我們訂閱這個變量并且將移動設置給角色
public class PlayerController : MonoBehaviour {
// ... fields omitted ...
private void Start() {
inputs.Movement
.Where(v => v != Vector2.zero)
.Subscribe(inputMovement => {
var inputVelocity = inputMovement * walkSpeed;
var playerVelocity =
inputVelocity.x * transform.right +
inputVelocity.y * transform.forward;
var distance = playerVelocity * Time.fixedDeltaTime;
character.Move(distance);
}).AddTo(this);
}
}
首先油挥,需要注意的是,我們在 Awake 階段設置這個變量,然后再 Start 階段訂閱深寥。這是為了避免初始化順序問題攘乒,也是我的一個習慣。
在我們獲取 Movement Observable 實例之后惋鹅,我們有一個簡潔的小優(yōu)化:在哪調用则酝,哪里就可以使用變型轉換的函數(shù):在本例中我們忽略為0的移動矢量。如果用戶沒有按鍵负饲,我們可以更早中斷堤魁。
然后我們?yōu)橛嗛喬峁┮粋€回調函數(shù)(ns:UniRx),這樣每當 Observable 有新的值傳來的時候就會調用這個函數(shù)返十。在這個函數(shù)中我們只是簡單地乘上了玩家的行走速度妥泉,然后將 2D 的輸入轉化到 3D 的坐標系統(tǒng),再乘以時間獲得移動的距離洞坑,然后將這個移動應用到 CharacterController 上盲链。
最后,有一個 UniRx 的細節(jié)迟杂,我們需要在訂閱之后調用 AddTo(this)
刽沾。了解 Observables 和他們的訂閱(Observers)的生命周期是很重要的。只要信號持續(xù)輸入排拷,他們就非常樂意保持進程侧漓。(盡管 Observable 可以完成或拋出錯誤,但是此時卻不在我們控制的范圍之內)為了不使內存泄露监氢,避免浪費進程的性能布蔗。我們需要確保在游戲對象銷毀時清理他們。這就是 AddTo(this)
所要做的事情浪腐∽葑幔基本上當 PlayerController 所在的游戲對象被銷毀。它就會釋放(dispose)訂閱议街。但這并不會釋放底層的 Observable, 也就是 Movement泽谨。Movement 會在 Inputs 游戲對象銷毀時被釋放,因為 Movement 是在 Inputs 對象的 Fixed Update Observable 開始的特漩。
現(xiàn)在你只需要使用 WASD 按鍵就能移動了 吧雹。是不是沒有想象中的那么困難?
環(huán)顧四周
好吧,現(xiàn)在我們有了基本的功能了涂身,可以在此基礎上添加鼠標操控視角的功能吮炕。鼠標輸入則是另一個信號了,不過也是一個 2D 矢量访得。玩家控制器會將這個矢量變換為玩家游戲對象的左右旋轉和玩家內部攝像機的上下旋轉龙亲。我不會一步一步寫代碼了陕凹。我會把最終的代碼貼出來。需要注意的是鳄炉,我們沒有更改之前移動的訂閱杜耙。而是新建了一個關于鼠標信號輸入的一個新的 Observable。現(xiàn)在我們只不過是再重復一遍類似的操作拂盯,但這兩部分在這部分卻是非耦合的佑女。移動的代碼確實沒有什么必要依賴旋轉的代碼(至少在這些簡單示例的需求中)。如果為了更方便管理代碼,你甚至可以把他們放到不同的腳本中谈竿。你可以在GitHub Gist 中找到這一部分的完整代碼团驱。
剩下其他的部分都是實現(xiàn)細節(jié)。收縮上下旋轉角度是因為我們不能把頭仰到脖子后面去空凸。將光標鎖定在屏幕內并將其隱藏嚎花。將輸入行為變?yōu)閱卫?這不是不可變性造成的壞處)。在編輯器中調整輸入軸設置呀洲。記得查看上面的視頻展示出的行為紊选。這些就是第一部分的內容。
希望這篇文章通過這樣一個小例子能夠很好地開啟 Observable 話題的道逗。下一次我們來點復雜的兵罢,處理不同的輸入,添加一些特殊的效果
譯注:以下是完整代碼滓窍,作者貼到了 github 上卖词,沒有寫在原文中
Inputs.cs
using UnityEngine;
using UniRx;
using UniRx.Triggers;
public class Inputs : MonoBehaviour {
public IObservable<Vector2> movement { get; private set; }
public IObservable<Vector2> mouselook { get; private set; }
public void Awake () {
movement = this.FixedUpdateAsObservable()
.Select(_ => {
var x = Input.GetAxis("Horizontal");
var y = Input.GetAxis("Vertical");
return new Vector2(x, y).normalized;
});
mouselook = this.UpdateAsObservable()
.Select(_ => {
var x = Input.GetAxis("Mouse X");
var y = Input.GetAxis("Mouse Y");
return new Vector2(x, y);
});
}
}
PlayerController.cs
using UnityEngine;
using UniRx;
[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour {
float walkSpeed = 5f;
[Range(-90, 0)]
public float minViewAngle = -60;
[Range(0, 90)]
public float maxViewAngle = 60;
Inputs inputs;
CharacterController character;
Camera view;
void Awake () {
character = GetComponent<CharacterController>();
view = GetComponentInChildren<Camera>();
}
// Use this for initialization
void Start () {
inputs = GameObject.Find("GameController").GetComponent<Inputs>();
inputs.movement
.Where(v2 => v2 != Vector2.zero)
.Subscribe(inputMovement => {
var inputVelocity = inputMovement * walkSpeed;
var playerVelocity =
inputVelocity.x * transform.right +
inputVelocity.y * transform.forward;
var distance = playerVelocity * Time.fixedDeltaTime;
character.Move(distance);
}).AddTo(this);
inputs.mouselook
.Where(v2 => v2 != Vector2.zero)
.Subscribe(inputLook => {
var horzLook = inputLook.x * Time.deltaTime * Vector3.up * 100.0f;
transform.localRotation *= Quaternion.Euler(horzLook);
var verLook = inputLook.y * Time.deltaTime * Vector3.left * 100.0f;
var newQ = view.transform.localRotation * Quaternion.Euler(verLook);
view.transform.localRotation = ClampRotationAroundXAxis(
newQ, -maxViewAngle, -minViewAngle
);
});
}
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;
}
}
``