這篇文章是我經過 2D Roguelike tutorial 項目在學習 Unity 的使用時留下的記錄. git
官方教程中提供的視頻來自 YouTube, 國內有搬運工已經把整套視頻搬到 Bilibili 了.github
學習成果: 學習成果.mp4算法
本文內容的更新將第一時間在 Github 項目主頁 發佈. 更多內容見 我的主頁編程
經過試玩, 我發現了這個小遊戲的一些基礎的遊戲規則:json
WASD
和 ↑↓←→
進行移動.根目錄 ┣ Assets (資產目錄, 存放源代碼, 素材的地方, 應該被 VCS 管理) ┃ ┣ _Complete-Game (完整示例) ┃ ┃ ┣ Animation (動畫示例) ┃ ┃ ┣ Prefabs (預製件示例) ┃ ┃ ┣ Scenes (場景示例) ┃ ┃ ┣ Scripts (腳本示例) ┃ ┃ ┣ _Complete-Game.unity (完整示例的主場景) ┃ ┃ ┗ (**.meta (各個文件/文件夾的元數據文件, 由 Unity3D 編譯產生, 用來記錄 Inspector 中的各類數據)) ┃ ┣ Audio (音頻素材) ┃ ┣ Fonts (字體素材) ┃ ┣ (Plugins (插件目錄)) ┃ ┣ Sprites (貼圖目錄) ┃ ┣ TutorialInfo (教程目錄) ┃ ┗ Readme.asset (使用說明對應的資產) ┣ Library (庫目錄, 存放 Unity3D 編譯期產物的地方, 應該被 VCS 忽略) ┣ Logs (編譯期日誌文件夾, ) ┣ obj (C# 腳本編譯產物的地方) ┣ Packages (依賴目錄? 應該被 VCS 管理) ┃ ┗ manifest.json (清單文件, 記錄了這個項目的第三方依賴等) ┣ ProjectSettings (工程配置, 應該被 VCS 管理) ┃ ┣ *.asset (各類資源管理器對應的元數據) ┃ ┗ ProjectVersion.txt (目前只記錄了 Unity3D 編輯器的版本號) ┣ Temp (用途不明的臨時文件夾, 應該被 VCS 忽略) ┣ *.csproj (Visual studio 編譯項目時生產的文件, 應該被 VCS 忽略) ┗ *.sln (Visual studio 的項目管理文件, 應該被 VCS 管理)
第一次接觸 Unity 工程, 在作版本控制的時候搜了一些文檔, 主要提到了要將 Edit->ProjectSettings->Editor
中的 Version control mode
改成 Visible Meta Files
以及將Asset serialization mode
改成 Force Text
. c#
仔細調試了一下後, 發現 Visible Meta Files
可以使 Unity 在導入工程的時候生成各類 .meta
後綴的文件. 而 Force Text
則控制着 *.meta
文件裏的內容格式, 將各類元數據序列化爲可讀性較好的 Unity YAML
格式.app
後來測試發現這些 *.meta
文件記錄着 Inspector
面板中的數據, 是 Unity 的各類組件之間的依賴關係的持久化文件.less
_Complete-Game 目錄下包含整個教程完整的示例, 是我學習這個項目的主戰場.dom
該文件中記錄了 TutorialInfo/Scripts/Readme.cs 類對應的元數據. 進一步探索發現這個資產的 Inspector
界面以及頂部工具欄裏的 Tutorial
菜單都是經過
TutorialInfo/Scripts/Editor/ReadmeEditor.cs 控制的.編程語言
處處改一改, 發現 Unity 插件開發至關的敏捷, 不像 IDEA 的插件, 每次測試插件效果都要重啓一個 IDEA 實例. 這個應該也跟編程語言有關係吧.
主場景. 仔細觀察了一下發如今 Camera 上掛載着一個 Loader 腳本.
腳本內容很簡單, 經過 Inspector
中設置的屬性實例化 GameManager 和 SoundManager 對象.
音效管理器. 因爲對象在 Unity 環境中託管, 所以在生命週期函數 Awake
中經過判斷靜態引用的方式實現了單利設計. 利用 AudioSource
類播放本地音頻文件, 爲了不聽覺疲勞, 使用了隨機數調整音效的高低音.
private void Awake() { //Check if there is already an instance of SoundManager if (Instance == null) //if not, set it to this. Instance = this; //If instance already exists: else if (Instance != this) //Destroy this, this enforces our singleton pattern so there can only be one instance of SoundManager. Destroy(gameObject); //Set SoundManager to DontDestroyOnLoad so that it won't be destroyed when reloading our scene. DontDestroyOnLoad(gameObject); ... }
遊戲管理器. 在 Awake
中定義了 Enemy 集合, 並經過 GetComponent<>()
獲取到了被託管的 BoardManager 對象完成成員變量的初始化.
//Get a component reference to the attached BoardManager script _boardScript = GetComponent<BoardManager>();
加下來調用 InitGame
方法控制 UI 層的遮罩以及關卡提示的顯示與隱藏, 並調用了 BoardManager
的 SetupScene(int)
初始化了每一級關卡的動態場景.
在生命週期 Update
中, 若是不須要等待玩家移動, 其餘敵人移動或者過場的話, 則嘗試開啓協程移動每個 Enemy
對象. 所有 Enemy
移動完成後, 容許 Player 移動.
關卡管理器. 沒有采用單例設計, 也沒有實現生命週期方法.
對外僅暴露部分紅員屬性用於依賴注入, 以及 SetupScene(int)
方法用於實例化每一級關卡的外牆, 地板, 內牆 (Wall), 食物, 蘇打, 敵人以及出口.
具體的實例化方式則是調用 Instantiate(...)
方法, 將指定的預設物實例化在場景中.
//Instantiate the GameObject instance using the prefab chosen for toInstantiate at the Vector3 //corresponding to current grid position in loop, cast it to GameObject. Set the parent of our newly //instantiated object instance to boardHolder, this is just organizational to avoid cluttering hierarchy. Instantiate(toInstantiate, new Vector3(x, y, 0f), Quaternion.identity, _boardHolder);
內牆. 對外暴露 DamageWall(int)
方法, 從實現的功能上來看應該是屬於回調類型的方法 (更貼切一點的命名應該是 OnDamage(int)
吧 23333).
內牆被攻擊時經過調用 SpriteRenderer
實現了運行時將換貼圖替換爲受損狀態的貼圖, hp 低於 0 時將 gameObject 設爲失活從而將其從場景中隱藏.
//Set spriteRenderer to the damaged wall sprite. _spriteRenderer.sprite = dmgSprite; ... if (_hp > 0) return; //If hit points are less than or equal to zero, disable the gameObject. gameObject.SetActive(false);
敵人. 繼承自 MovingObject 類.
在 Start
生命週期中將自身註冊到 GameManager
(我以爲這個註冊的時機應該由 BoardManager
管理), 並引用玩家的 Transform
對象, 以便在 Update
時向玩家移動.
尋路的算法比較簡陋, 簡單的判斷了一下自身與玩家的水平方向的差距和豎直方向的差距, 而後嘗試移動, 沒有考慮到撞牆, 繞路等狀況.
//MoveEnemy is called by the GameManger each turn to tell each Enemy to //try to move towards the player. public void MoveEnemy() { //Declare variables for X and Y axis move directions, these range //from -1 to 1. These values allow us to choose between the cardinal //directions: up, down, left and right. var xDir = 0; var yDir = 0; //If the difference in positions is approximately zero (Epsilon) do the following: if (Mathf.Abs(_target.position.x - transform.position.x) < float.Epsilon) //If the y coordinate of the target's (player) position is greater //than the y coordinate of this enemy's position set y direction 1 //(to move up). If not, set it to -1 (to move down). yDir = _target.position.y > transform.position.y ? 1 : -1; //If the difference in positions is not approximately zero (Epsilon) do the following: else //Check if target x position is greater than enemy's x position, //if so set x direction to 1 (move right), if not set to -1 (move left). xDir = _target.position.x > transform.position.x ? 1 : -1; //Call the AttemptMove function and pass in the generic parameter Player, //because Enemy is moving and expecting to potentially encounter a Player AttemptMove(xDir, yDir); }
當遇到玩家致使不能移動時, 則攻擊玩家, 觸發攻擊動畫同時播放攻擊音效.
//OnCantMove is called if Enemy attempts to move into a space occupied //by a Player, it overrides the OnCantMove function of MovingObject //and takes a generic parameter T which we use to pass in the component //we expect to encounter, in this case Player protected override void OnCantMove <T> (T component) { //Declare hitPlayer and set it to equal the encountered component. Player hitPlayer = component as Player; //Call the LoseFood function of hitPlayer passing it playerDamage, //the amount of foodpoints to be subtracted. hitPlayer.LoseFood (playerDamage); //Set the attack trigger of animator to trigger Enemy attack animation. animator.SetTrigger ("enemyAttack"); //Call the RandomizeSfx function of SoundManager passing in //the two audio clips to choose randomly between. SoundManager.instance.RandomizeSfx (attackSound1, attackSound2); }
可移動的對象. 做爲敵人和玩家的父類, 爲子類提供了移動方向的碰撞檢測和平滑移動能力.
在嘗試移動時, 調用 Move(...)
方法進行碰撞檢測, 遇到障礙時調用回調方法 OnCannotMove<>()
, 不然開啓協程進行平滑移動.
//Move returns true if it is able to move and false if not. //Move takes parameters for x direction, y direction and a RaycastHit2D to check collision. protected bool Move(int xDir, int yDir, out RaycastHit2D hit) { ... //Cast a line from start point to end point checking collision on blockingLayer. hit = Physics2D.Linecast (start, end, blockingLayer); ... if(hit.transform == null) { //If nothing was hit, start SmoothMovement co-routine passing in //the Vector2 end as destination StartCoroutine(SmoothMovement(end)); //Return true to say that Move was successful return true; } //If something was hit, return false, Move was unsuccesful. return false; }
Move(...)
方法同時輸出了 2 個結果, 一個 bool
類型的返回值, 標誌着可否朝指定方向移動, 以及一個 RaycastHit2D
類型的碰撞結果. 我我的感受布爾類型的返回值有點多餘, 經過判斷碰撞結果是否爲 null 便可.
//The virtual keyword means AttemptMove can be overridden by inheriting classes //using the override keyword. AttemptMove takes a generic parameter T //to specify the type of component we expect our unit to interact with if blocked //(Player for Enemies, Wall for Player). protected virtual void AttemptMove <T> (int xDir, int yDir) where T : Component { //Hit will store whatever our linecast hits when Move is called. RaycastHit2D hit; //Set canMove to true if Move was successful, false if failed. bool canMove = Move (xDir, yDir, out hit); //Check if nothing was hit by linecast if(hit.transform == null) //If nothing was hit, return and don't execute further code. return; //Get a component reference to the component of type T attached to the object that was hit T hitComponent = hit.transform.GetComponent <T> (); //If canMove is false and hitComponent is not equal to null, meaning //MovingObject is blocked and has hit something it can interact with. if(!canMove && hitComponent != null) //Call the OnCantMove function and pass it hitComponent as a parameter. OnCantMove (hitComponent); }
AttemptMove(int,int)
判斷到能移動時沒有執行動做, 這裏其實能夠把 Move()
方法中開協程進行平滑移動的代碼移動到這裏來的.
玩家.
玩家類使用宏對運行平臺進行了隔離, 移動端判斷滑動方向, 桌面端則是判斷 WASD
和 ↑↓←→
進行移動. 不能移動時能夠攻擊內牆.
private static void HandleInput(out int horizontal, out int vertical) { //Check if we are running either in the Unity editor or in a standalone build. #if UNITY_STANDALONE || UNITY_WEBPLAYER //Get input from the input manager, round it to an integer //and store in horizontal to set x axis move direction horizontal = (int) Input.GetAxisRaw("Horizontal"); //Get input from the input manager, round it to an integer //and store in vertical to set y axis move direction vertical = (int) Input.GetAxisRaw("Vertical"); //Check if moving horizontally, if so set vertical to zero. if (horizontal != 0) vertical = 0; //Check if we are running on iOS, Android, Windows Phone 8 or Unity iPhone #elif UNITY_IOS || UNITY_ANDROID || UNITY_WP8 || UNITY_IPHONE ... #endif //End of mobile platform dependent compilation section started above with #elif }
玩家實現了 OnTriggerEnter2D(Collider2D)
方法, 從而監聽到了 2d 碰撞箱的碰撞事件, 對遇到食物和蘇打分別進行了不一樣程度的生命恢復, 遇到出口時則觸發場景的重載, 進入下一關卡.
//OnTriggerEnter2D is sent when another object enters a trigger collider attached to this object (2D physics only). private void OnTriggerEnter2D (Collider2D other) { //Check if the tag of the trigger collided with is Exit. if(other.tag == "Exit") { //Invoke the Restart function to start the next level with a delay of restartLevelDelay (default 1 second). Invoke ("Restart", restartLevelDelay); //Disable the player object since level is over. enabled = false; } //Check if the tag of the trigger collided with is Food. else if(other.tag == "Food") { //Add pointsPerFood to the players current food total. food += pointsPerFood; //Update foodText to represent current total and notify player that they gained points foodText.text = "+" + pointsPerFood + " Food: " + food; //Call the RandomizeSfx function of SoundManager and pass in //two eating sounds to choose between to play the eating sound effect. SoundManager.instance.RandomizeSfx (eatSound1, eatSound2); //Disable the food object the player collided with. other.gameObject.SetActive (false); } //Check if the tag of the trigger collided with is Soda. else if(other.tag == "Soda") { ... } }
當玩家被敵人攻擊時, 回調 OnLossFood(int)
方法, 觸發被攻擊動畫, 移除生命值, 並判斷遊戲是否結束.
至此, Unity 官方教程 2d-roguelike-tutorial 分析完畢, 接下來就是處處改東西, 看看會不會崩了 :).
想要調整地圖尺寸很簡單, 在 BoardManager
中修改 _columns
和 _rows
成員變量就好了.
private void BoardSetup(){ _columns = Random.Range(6, 10); _rows = Random.Range(6, 10); ... }
地牢裏的水果和飲料老是那麼的新鮮, 看起來很詭異, 我決定加入一些爛蘋果和腐敗的飲料混在其中.
private void OnTriggerEnter2D(Collider2D other) { switch (other.tag) { case "Food": // simulate bad apple var randomFoodPoint = Random.Range(-pointsPerFood, pointsPerFood); _food += randomFoodPoint; ... } }
精英怪都是會拆牆的.
爲了讓玩家和敵人可以攻擊不一樣類型的對象, 須要調用 AttemptMove<>(int,int)
方法, 並傳入不一樣的泛型:
... base.AttemptMove<Wall>(xDir, yDir); base.AttemptMove<Enemy>(xDir, yDir); ...
但這樣寫看起來很不簡潔, 並且對象會移動屢次. 所以須要把泛型從 AttemptMove<>(int,int)
和 OnCannotMove<T>(T) where T : MonoBehavior
中移除.
protected override void OnCantMove(MonoBehavior component) { switch (component) { case Wall wall: wall.DamageWall(Damage); break; } ... }
鏡頭默認位於 (x=3.5,y=3.5,z=-10) 位置, 在地圖尺寸隨機的狀況下, 有時候會看不全. 要使鏡頭跟隨玩家的話, 須要在 Hierarchy
面板中將 MainCamera
移動到 Player
對象內部, 並將 MainCamera
的 position 調整爲 (x=0,y=0,z=-10), 確保玩家位於鏡頭中央.
戰爭迷霧的缺失, 使得遊戲的探索性下降爲 0, 玩家只須要在開始行動前規劃好路徑便可.
要將戰爭迷霧這一特性加入遊戲, 須要改造 BoardManager
和 GameManager
:
Fog
預設物以及貼圖, 並調整 Sorting layer
爲 Units
, 確保戰爭迷霧與玩家同層.List<GameObject>
到 BoardManager
, 用於存放戰爭迷霧實例.BoardManager
添加方法 public void ClearFogAround(Vector3)
, 經過控制 GameObject
的 active
屬性實現迷霧的顯示隱藏, 並延伸到移除指定位置近處的迷霧效果, 恢復遠處的迷霧效果.BoardManager
單例化, 以便可以跨組件調用.MovingObject
添加移動結束回調, 以便玩家移動成功時能觸發戰爭迷霧的動態效果.