Unity 1-9 Unity2D游戲開發 Roguelike拾荒者
任務1:游戲介紹

Food:相當於血量:每走一步下降1,吃東西可以回復(果子10葯水20),被怪物攻擊會減少
中間的障礙物可以打破,人走兩步僵屍走一步;走到Exit進入下一關
最外圈的過道會保證是空的,其他的隨機生成(--確保主角能夠到達出口)
任務2:創建工程、素材
pan.baidu.com/s/1kTYS8ez
Unity5.2.1
2D Project -- Roguelike
導入Assets.unitypackage
Sprites->所有切好的圖片 -- 主角/地形/出口/圍牆/僵屍等
任務3:創建游戲主角
Sprites中找到6個主角Scavengers的sprite圖片,拖入Hierachy中
會自動在游戲物體Scavengers_SpritesSheet_0中創建一個動畫Animator,並將Animation
和AnimationController保存在Sprites文件夾中,重命名控制器為Player/ 動畫為PlayerIdle
並創建文件夾分類Assets->Animations->Animation/AnimatorController
此時,運行游戲,會自動播放Idle動畫
雙擊AnimatorController打開動畫編輯器,修改速度為0.5
創建攻擊動畫:
將兩個攻擊Sprites拖入Player物體,會自動在Player中創建一個新的動畫PlayerAttack
創建受攻擊動畫:相似 -- PlayerUnderAttack
創建主角的Prefab
任務4:創建敵人
與創建主角相似
創建完Enemy1,創建Enemy1的Prefab
因為Enemy2的動畫控制器AnimatorController與Enemy1相同
在Project視窗中右鍵Create->Animator Override Controller
將Enemy1的Animator賦值給新建的Animator -- 表示兩個Enemy共用一個狀態機
將Hierarchy中的Enemy1->GameObject->Break prefab instance,重命名為Enemy2
將Enemy2 Animator賦值給Enemy2
創建Enemy2的兩個動畫,將動畫拖入Enemy2 Animator賦值即可

相當於:Enemy1和Enemy2共用了Enemy1的狀態機(Animator),但是使用了不同動畫
創建Enemy2的Prefab
任務5:創建地板/ 圍牆/ 食物
創建地板:
把8種地板和出口分別拖入,並重命名Floor1~8/ Exit
把8中障礙物拖入,重命名為Wall1~8
做成Prefab
創建圍牆:三種圍牆,重命名為OutWall1~3並做成Prefab

創建食物:拖入Soda和Food,做成Prefab
任務6:生成地圖
創建空物體GameManager
創建MapManager.cs
// 地圖左下角設置為(0, 0)
public GameObject[] outWallArray/ floorArray;
// 創建根物體
private Transform mapHolder;
mapHolder = new GameObject("Map").transform;
// 初始化圍牆
for x for y(...) {
GameObject cell;
if (x==0 || y==0 || x==cols-1 || y==rows-1) {
int index = Random.Range(0, outWallArray.Length);
cell = GameObject.Instantiate
(outWallArray[index], ...(x, y, 0), Quaternion.identity) as GameObject;
// as GameObject -- .Instantiate()生成的為Object類型,強制轉化為GameObject
// 在(x,y,0)處生成一個旋轉為0的圍牆
} else ...
cell.transform.SetParent(mapHolder);
}
// 初始化地板
else {
int index = Random.Range(0, floorArray.Length);
cell = GameObject.Instantiate
(floorArray[index], ...(x,y,0), Quaternion.identity) as GameObject;
}
此時生成的地圖並不位於相機正中心
-- 地圖長10寬10 -- (每一格長寬為1,從(0,0)開始,即畫面從(-0.5, -0.5開始))
--> Camera位置:(4.5, 4.5, 0),顏色改為黑色
private void InitMap() { for (int x = 0; x < cols; x++) { for (int y = 0; y < cols; y++) { GameObject cell; if (x == 0 || x == cols - 1 || y == 0 || y == rows - 1) { int index = Random.Range(0, outWallArray.Length); cell = GameObject.Instantiate (outWallArray[index], new Vector3(x, y, 0), Quaternion.identity); } else { int index = Random.Range(0, floorArray.Length); cell = GameObject.Instantiate (floorArray[index], new Vector3(x, y, 0), Quaternion.identity); } cell.transform.SetParent(mapHolder); } } }
任務7:控制障礙物的生成
在InitMap()中
private List<Vector2> positionList = new List<Vector2>(); // 用於取得中間部分的格子位置
positionList.Clear();
for(int x/y = 0 + 2; x/y < cols/rows - 2; x/y++) { // 圍牆一列,空道一列
positionList.Add(new Vector2(x, y));
}
// 從上面的positionList中隨機取得格子放入障礙物/食物/敵人
// 創建障礙物
public GameObject[] wallArray;
public int min/maxCountWall = 2/8;
int wallCount = Random.Range(minCountWall, maxCountWall + 1);
for(int i = 0~wallCount) {
// 隨機取得格子位置
int positionIndex = Random.Range(0, positionList.Count); // 隨機取得index
Vector2 pos = positionList[positionIndex]; // 得到位置信息
positionList.RemoveAt[positionIndex]; // 移除該位置 -- 保證一個格子只能有一個東西
// 隨機取得障礙物
int wallIndex = Random.Range(0, wallArray.Length);
GameObject cell = GameObject(wallArray[...], pos, ...) as GameObject;
cell.transform.SetParent(mapHolder);
}
避免地板將障礙物覆蓋,設置Layer:Background/ Items/ Roles
positionList = new List<Vector2>(); positionList.Clear(); for(int x = 0+2; x < cols - 2; x++) { for(int y = 0 + 2; y < rows - 2; y++) { positionList.Add(new Vector2(x, y)); } } int wallCount = Random.Range(minCountWall, maxCountWall + 1); for(int i = 0; i < wallCount; i++) { int positionIndex = Random.Range(0, positionList.Count); Vector2 pos = positionList[positionIndex]; positionList.RemoveAt(positionIndex); int wallIndex = Random.Range(0, wallArray.Length); GameObject cell = GameObject.Instantiate (wallArray[wallIndex], pos, Quaternion.identity) as GameObject; cell.transform.SetParent(mapHolder); }
任務8&9:敵人和食物的隨機生成 & 代碼優化
食物和敵人的數量與關卡有關 -- 數量成正比
創建GameManager.cs -- 控制游戲關卡
public int level = 1;
在MapManager.cs中
// 獲取GameManager
private GameManager gameManager;
gameManager = GetComponent<GameManager>();
創建食物 -- 數量2~level*2
int foodCount = Random.Range(2, gameManager.level * 2 + 1);
// 取得隨機位置 -- 重復代碼寫成Vector2 RandomPosition()
Vector2 pos = RandomPosition();
// 隨機取得物體 -- 重復代碼寫成GameObject RandomPrefab(GameObject[] prefabs);
GameObject foodPrefab= Instantiate(RandomPrefab(foodArray)) as GameObject;
foodPrefab.transform.setParent(mapHolder);
// get a random available position private Vector2 RandomPosition() { int positionIndex = Random.Range(0, positionList.Count); Vector2 pos = positionList[positionIndex]; positionList.RemoveAt(positionIndex); return pos; } // get a random gameobject private GameObject RandomPrefab(GameObject[] prefabs) { int index = Random.Range(0, prefabs.Length); return prefabs[index]; }
創建敵人 -- 數量為level / 2
int enemyCount = gameManager.level/2;
for(0~enemyCount) {
Vector2 pos = ...;
GameObject enemyPrefab = GameObject.Instantiate(...) as GameObject;
enemyPrefab.transform.setParent(mapHolder);
}
創建出口 -- 位置固定在右上方
GameObject exit =
(Instantiate(exitPrefab, new Vector2(cols-2, rows-2), Quaternion.identity) as GameObject;
exti.transform.SetParent(mapHolder);
-- 代碼優化
上面的創建代碼是可重用的 -- 寫成method
private void InstantiateItems(int count, GameObject[] itemArray) { for(int i = 0; i < count; i++) { GameObject item = GameObject.Instantiate(RandomPrefab (itemArray), RandomPosition(), Quaternion.identity) as GameObject; item.transform.SetParent(mapHolder); } }
任務10:完善主角和敵人的動畫狀態機
Player的動畫:PlayerIdle/ PlayerUnderAttack/ PlayerAttack
PlayerIdle<-->PlayerUnderAttack
PlayerIdle<-->PlayerAttack
在Animator中添加觸發器Trigger分別稱為Damage和Attack
Idle->Attack/UnderAttack:
將Has Exit Time取消勾選 -- 在Idle動畫的任何時候都可以隨時切換到另一個動畫
Transition Duration = 0 -- 因為這里的動畫是幀動畫,因此可以進行瞬時切換成其他動畫
Conditions 切換方式:添加Attack/Damage Trigger

拖動左邊的箭頭可以手動播放動畫;拖動右邊的兩個箭頭可以手動控制何時切換
Attack/UnderAttack->Idle:
勾選Has Exit Time即可 -- 播放完Attack/UnderAttack后自動切換到Idle動畫
Transition Duration = 0 -- 瞬時間切換
Exit Time = 1 -- 退出時間(多久進行切換)
Enemy的動畫:EnemyIdle/ EnemyAttack
Idle->Attack:
添加觸發器Attack
Has Exit Time uncheck
Transition Duration = 0
Attack->Idle:
Has Exit Time check
Exit Time = 1
Transistion Duration = 0
檢測動畫:
運行游戲;選中Player/ Enemy;在Animator視窗中查看狀態機
點擊Trigger右邊的小圓點,即為觸發該Trigger
任務11:控制主角的運動
為Player添加剛體,用於控制移動,勾選Is Kinematic
為Player添加BoxCollider2D,用於檢測碰撞(大小不能設置為(1,1),0.9就好)
為Player添加Player.cs來控制移動
private int posx/posy = 1; // 當前位置
private int Vector2 targetPos = new Vector2(1,1); // 目標位置
float h/v = Input.GetAxisRaw("Horizontal"/"Vertical"); // GetAxisRaw()的返回值為-1/0/1
targetPos += new Vector2(h,v); // 按鍵后,目標位置發生改變
private Rigidbody2D rigidbody = GetComponent<Rigidbody2D>(); // 得到剛體,用於移動
rigidbody.MovePosition(Vector2.Lerp(transform.position, targetPos, smoothing*Time.deltaTime));
// MovePosition(目標位置):向目標位置移動
// Lerp(起點,終點,速度);這里設smoothing=1
發現此時按下按鈕會移動很長的距離 -- Update中不停調用targetPos += Vector2();
需要設置一個休息時間:
public float restTime = 0.5f;
public float restTimer = 0.5f; // 計時器
在Update()中
restTimer += Time.deltaTime; // 每次增加時間間隔
if(restTimer <= restTime) { // 還在休息間隔中
return; // 不進行其他操作
}
當有按鍵按下時:
if(h!=0 || v!=0) {
targetPos += ...; // 更新目標位置
rigidbody.MovePosition(...); // 移動
restTimer = 0; // 重置計時器
}
按下按鍵時,只能移動一點點距離
將rigidbody.MovePosition()代碼移出if條件
因為每一幀都需要調用而不是只有按鍵的時候調用
發現可以一次同時在水平和豎直移動 -- 和游戲規則有悖
if(h!=0) v=0; // 優先控制豎直方向
void Update () { rigidbody.MovePosition(Vector2.Lerp(transform.position, targetPos, smooth * Time.deltaTime)); if (restTimer < restTime) { restTimer += Time.deltaTime; return; } float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); if (h != 0) { v = 0; } if (h != 0 || v != 0) { // 有按鍵輸入時 targetPos += new Vector2(h, v); restTimer = 0; // 計時器歸零 } }
任務12:控制主角對牆體的攻擊
給outWall添加BoxCollider2D,scale=0.9;添加tag = OutWall
給wall添加BoxCollider2D,scale=0.9;添加tag = Wall
在每次按鍵的時候,進行碰撞檢測:
RaycastHit2D hit = Physics2D.Linecast(targetPos, targetPos + new Vector2(h, v));
// Physics2D.Linecast(起點,終點) 從起點向終點發射射線做檢測,返回RaycastHit2D
if(hit.transform == null) { // 沒有碰撞,可以行走
} else { // 有碰撞
switch(hit.collider.tag) {
case "OutWall": // 不做處理
case "Wall": // 進行攻擊
// 向牆發送攻擊的通知
// 在Wall里添加Wall.cs來處理行為 Wall.TakeDamage();
hit.collider.SendMessage("TakeDamage");
public int hp = 2; public Sprite damageSprite; // 受損后的牆體圖片 public void TakeDamage() { hp -= 1; GetComponent<SpriteRenderer>().sprite = damageSprite; if(hp<=0) Destroy(this.gameObject); }
禁用自身的collider,因為可能射線會碰到自身的collider:
private BoxCollider2D collider = GetComponent<...>();
collider.enabled = false;
... // 射線檢測
collider.enabled = true; // 檢測后再啟用
無論是移動還是攻擊,都需要休息 -- 只要按下了按鍵,restTimer = 0;
攻擊時,播放PlayerAttack動畫
Player.cs
private Animator animator = GetComponent<Animator>();
animator.SetTrigger("Attack"); // 觸發觸發器
rigidbody.MovePosition( Vector2.Lerp(transform.position, targetPos, smooth * Time.deltaTime)); if (restTimer < restTime) { restTimer += Time.deltaTime; return; } float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); if (h != 0) {v = 0;} if (h != 0 || v != 0) { // 有按鍵輸入時 collider.enabled = false; // 禁用自身collider // 碰撞檢測 RaycastHit2D hit = Physics2D.Linecast(targetPos, targetPos + new Vector2(h, v)); if (hit.transform == null) { // 沒有檢測到碰撞 targetPos += new Vector2(h, v); } else { // 檢測到碰撞 switch (hit.collider.tag) { case "OutWall": break; case "Wall": hit.collider.SendMessage("TakeDamage"); animator.SetTrigger("Attack"); break; } } restTimer = 0; // 計時器歸零 collider.enabled = true; // 檢測完碰撞后 開啟自身collider }
任務13:控制主角吃食物
對食物進行碰撞檢測:
對食物添加BoxCollider2D;設置為Is Trigger,scale=0.9,tag=food
存儲當前食物 GameManager.public int food = 100;
將GameManager設置為單例模式
private static GameManager _instance; public static GameManager Instance { get{ return _instance; } } private void Awake() { _instance = this; }
增加/減少食物的method:public void Add/ReduceFood(int count)
food +=/-= count;
case "Food":
// 用if判斷hit.collider.name是Food(Clone)還是Soda(Clone)
GameManager.Instance.AddFood(10 | 20);
// 同時,需要移動Player位置 && 銷毀食物
targetPos += new Vector2(h, v);
Destroy(hit.collider.gameObject);
case "Food": targetPos += new Vector2(h, v); Destroy(hit.collider.gameObject); if (hit.collider.name == "Food(Clone)") { GameManager.Instance.AddFood(10); } else if(hit.collider.name == "Soda(Clone)") { GameManager.Instance.AddFood(20); } break;
任務14&15:控制敵人的移動
當Player移動兩步時,敵人移動一步
給Enemy添加BoxCollider2D進行碰撞檢測,size=0.9,
給Enemy添加Rigidbody2D進行移動,勾選Is Kinematic:很重要
如果沒有勾選,會導致后面Enemy被Player推走
給Enemy添加tag=Enemy
case "Enemy": // 說明所走的路徑不通,判斷失誤,浪費一步
break;
給Enemy添加Enemy.cs
Enemy是被動移動 -- 提供移動方法,供其他對象調用
public void Move() {
// 判斷Player所在位置的方向
// Transform player = GameObject.FindGameObjectWithTag(...)
Vector2 offset = player.position - transform.position;
if (offset.magnitude < 1.9f) { // 距離小於一格
// 攻擊
} else { // 追 -- 哪個軸偏移大,就往哪里追
if (Mathf.Abs(offset.x) > Mathf.Abs(offset.y)) { // x軸移動
if(offset.x<0) x=-1; else x=1;
} else { // y軸移動
if(offset.y<0) y=-1; else y=1;
}
targetPos += new Vector(x, y);
}
在GameManager.cs中統一管理游戲進程:
public List<Enemy> enemyList = new List...;
在Enemy.cs中:
GameManager.Instance.enemyList.Add(this);
在GameManager.cs中控制Enemy是否行走:
bool sleepStep = true; // 是否為休息狀態
OnPlayerMove() {
if(sleepStep) sleepStep = false;
else { foreach enemy in enemyList { enemy.Move();
sleepStep = true; }
}
在Player.cs中
有按鍵按下的時候調用OnPlayerMove()
GameManager.Instance.OnPlayerMove();
此時,Enemy會隨着主角移動而移動,但是Enmey沒有碰撞檢測
設置目標位置targetPos之前先做檢測
// 更新位置之前,做碰撞檢測 collider.enabled = false; // 否則會碰撞到自己的collider RaycastHit2D hit = Physics2D.Linecast(targetPos, targetPos+new Vector2(x,y)); collider.enabled = true; if (hit.collider == null) { // 沒有碰撞 targetPos += new Vector2(x, y); } else { // 有碰撞 switch (hit.collider.tag) { case "Wall": case "OutWall": // 不可能出現外牆的情況 break; case "Food": Destroy(hit.collider.gameObject); targetPos += new Vector2(x, y); break; } }
任務16:控制敵人的攻擊
private Animator animator = GetComponent<...>();
animator.SetTrigger("Attack");
player.SendMessage("TakeDamage", lossFood); // 不同敵人的傷害不同
public void TakeDamage(int lossFood) {
GameManager.Instance.ReduceFood(lossFood);
animator.SetTrigger("UnderAttack");
}
任務17:控制游戲食物數量UI的顯示
在Player每走一步就消耗一定量食物:
GameManager.Instance.ReduceFood(1);
在屏幕上顯示食物:
右鍵創建UI->Text,重命名FoodText
字體劇中,字號25,字體為Font->PressStart2P-Regular,內容:Food: 100
放置在屏幕下方,Anchor Presets設置為bottom-center
在GameManager.cs中控制UI的更新
private Text foodText;
在void Awake()中 加入 InitGame();
void InitGame() {
foodText = GameObject.Find("FoodText").GetComponent...;
UpdateFoodText(0);
}
private void UpdateFoodText(int foodChange) { if (foodChange == 0) { foodText.text = "Food: " + food; } else if (foodChange > 0) { foodText.text = "+" + foodChange + " Food: " + food; } else { foodText.text = foodChange + " Food: " + food; } }
在ReduceFood()/ AddFood(){} 中加上 UpdateFoodText(count/-count);
任務18:控制游戲的失敗
游戲失敗:Food <= 0;
Player每走完一步:檢測food的數量
-- 我的優化:在GameManager中的ReduceFood()中檢測 -- 沒有必要每一步都檢測
if(food<=0){
GameObject.FindGameObjectWithTag("Player").SetActive(false);
// 顯示游戲失敗 -- UI->Text,居中,白色,字號,字體,GameOver等等
gameOverText.enabled = true;
}
任務19&20:游戲關卡的勝利判斷 && 下一關卡的加載
判斷Player是否到達了Exit的位置(8,8)
每次Player移動,判斷是否到達終點
Player每次移動,都會調用GameManager中的OnPlayerMove()
在OnPlayerMove()中加入
// 需要得到player的targetPos,與destinationPos比較
將targetPos改為[HideInInspector]public targetPos;
Player player = GameObject.Find...Tag(...).GetComponent...;
// 還需得到Exit的位置
MapManager mapManager = GetComponent...;
if(player.targetPos.x == mapManager.cols-2 && ...y==rows-2) {
private bool missionCompleted = true;
// 加載下一關卡
Application.LoadLevel(Application.loadedLevel); // 重新加載本關卡 -- 已棄用
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
// 需要記住某些值 -- 使用系統函數OnLevelWasLoaded()
}
運行 -- 發現可以重置關卡了,但是所有數據會重置
GameManager實例不能銷毀 -- 否則數據會重置
在GameManager.cs的Awake()中:
DontDestroyOnLoad(gameObject);
運行 -- 發現原來的GameManager沒有銷毀,但是新建了另一個GameManager
不將GameManager放在場景中
-- 將GameManager做成Prefab
-- 刪除場景中的GameManager
在MainCamera中添加GameLoader.cs
// 實例化GameManager public GameObject gameManager; void Awake() { if(GameManager.Instance == null) { GameObject.Instantiate(gameManager); } }
任務21:控制天數UI的顯示
當前天數的顯示
UI->Image->DayImage; alt+上下左右居中;顏色設置為黑色
在DayImage中創建一個Text叫DayText;居中偏上;字號32;字體自定義;顏色白色
什么時候顯示呢? -- 初始化UI的時候需要顯示
GameManage.InitGame()中:
private Image dayImage = GameObject.Find("DayImage").GetComponent<Image>();
private Text dayText ...;
dayText.text = "Day " + level;
顯示完天數,需要隱藏
void HideDayImage() { dayImage.gameObject.SetActive(false); }
在InitGame()最后調用 Invoke("HideDayImage", 1); 即可 -- 過1s后調用
任務22:添加音效
GameManager.cs
