1. 程式人生 > >Unity 離線建造系統

Unity 離線建造系統

很多遊戲,特別是養成類手遊,都會有自己獨特的建造系統,一個建造裝置的狀態迴圈或者說生命週期一般是這樣的:

1.準備建造,設定各項資源的投入等

2.等待一段倒計時,正在建造中

3.建造結束,選擇是否收取資源

 

大體上,可以將建造盒子分為以下三種狀態,每一個狀態的邏輯和顯示的頁面不同:

1 public enum BuildBoxState
2 {
3     Start,
4     Doing,
5     Complete
6 }
 1     private void ShiftState(BuildBoxState state)
 2     {
 3         switch (state)
 4         {
 5             case BuildBoxState.Start:
 6                 Start.SetActive(true);
 7                 Doing.SetActive(false);
 8                 Complete.SetActive(false);
 9 
10                 ResetResCount();
11                 break;
12             case BuildBoxState.Doing:
13                 Start.SetActive(false);
14                 Doing.SetActive(true);
15                 Complete.SetActive(false);
16 
17                 StartCoroutine(ShowBuildTime());
18                 break;
19             case BuildBoxState.Complete:
20                 Start.SetActive(false);
21                 Doing.SetActive(false);
22                 Complete.SetActive(true);
23 
24                 break;
25         }
26         CurState = state;
27     }

這裡值得思考的並非是狀態的切換或者基礎的按鈕偵聽,檢視資源更新等。

如何在離線一段時間後重新獲取目前對應建造盒子所處的狀態才是重點;並且如果處於建造中狀態的話,還應該能正確的顯示剩餘時間的倒計時。

一個非常常見的想法是,在建造開始時記錄一份開始建造的時間資料給伺服器或存在本地離線資料中,當下一次再登入時讀取當前系統的時間,並通過總共需要的建造時長來計算剩餘時間。

但假如總共需要的建造時長與當時投入的資源型別和量都有關係,這時就需要至少額外記載一類資料來進行計算。那麼,有沒有方法僅通過一個數據得到剩餘時長呢?

答案是,不記錄開始建造的時刻,改為記錄擬定建造完成的時刻。

如此一來,每次離線登入後,只需要幹兩件事既可以判斷出所有狀態檢視:

1.是否存在該建造盒子ID對應的擬定建造完成時刻的資料,如果不存在,一定是處於準備狀態,即Start狀態。

2.如果存在,對比當前系統時刻與擬定建造完成時刻的資料大小,大於等於則處於完成狀態,小於則依然在建造中,並按秒顯示差值更新。

 

記錄的時刻如下:

 1     public string BuildCompleteTime
 2     {
 3         get
 4         {
 5             if (PlayerPrefs.HasKey(ID.ToString()))
 6                 return PlayerPrefs.GetString(ID.ToString());
 7             return S_Null;
 8         }
 9         set
10         {
11             PlayerPrefs.SetString(ID.ToString(), value);
12             PlayerPrefs.Save();
13         }
14     }

 

每次開始時,只需要判斷這個資料是否存在:

 1     protected override void InitState()
 2     {
 3         View = HudView as BuildBoxView;
 4         if (BuildCompleteTime == S_Null)
 5         {
 6             ShiftState(BuildBoxState.Start);
 7         }
 8         else
 9         {
10             ShiftState(BuildBoxState.Doing);
11         }
12     }

通過建造中的時刻關係自動判斷是否完成:

 1     IEnumerator ShowBuildTime()
 2     {
 3         var ct = GetCompleteTime();
 4         if (CheckBuildCompleted(ct))
 5         {
 6             ShiftState(BuildBoxState.Complete);
 7             yield break;
 8         }
 9         else
10         {
11             for (; ; )
12             {
13                 View.SetTime(CalNeedTime(ct));
14                 yield return new WaitForSeconds(1);
15             }
16         }
17     }

當建造完成點選收取資源時,切換為準備狀態的同時,自動清空擬定建造完成時刻的資料記錄:

1     private void OnClickGet()
2     {
3         Canvas.SendEvent(new GetItemEvent());
4         ClearCompleteTime();
5         ShiftState(BuildBoxState.Start);
6     }

這裡有一個問題是,為什麼不在建造完成時就清理資料呢,因為有一種情況是,建造完成後,玩家還沒來得及點選收取,就直接進入了離線狀態,如果此時再次登入時資料已經清空,那他將做了一場無用功。

說不定直接垃圾遊戲毀我青春敗我前程了,為了避免這種情況發生,我們只有確保玩家真正收取到資源的那一刻才能清理資料。

 

到此,整個建造的基礎邏輯已經梳理完畢。如果要實現快速建造的話,也只不過是將擬定的完成時間直接設定為此刻即可。如果之前記錄的是開始建造的時刻,此時又會進行更多額外計算。

 

接下來,關於時間的坑這裡也略提一二吧,一開始我以為記錄時刻只需要記錄時分秒即可,因為最多的建造時長也不超過10小時一般,遊戲要保證玩家每天登陸,不可能動用海量的時間去建造一個資源。

如若如此,策劃很可能會馬上被抓出來祭天,並被玩家評論區冰冷的口水淹沒。

但後來寫著寫著就發現了一個問題,那就是好多天沒登入的玩家怎麼辦,只記錄時分秒根本沒辦法判斷時間的早晚,後來想一會還是把日期也記錄下來吧。

1 public struct TimeData
2 {
3     public int DayOfYear;
4     public int Hour;
5     public int Minute;
6     public int Second;
7 }

要是你問,那一年以上沒登入怎麼辦,那隻能說,你建造的資源已經被時光的齒輪碾碎了(允悲...)。後來突然想起來如果是某一年的最後一天呢...emm,還是老實寫全吧:

 1 public struct TimeData
 2 {
 3     public int Year;
 4     public int DayOfYear;
 5     public int Hour;
 6     public int Minute;
 7     public int Second;
 8 
 9     public TimeData(int year,int dayOfYear,int hour,int minute,int second)
10     {
11         Year = year;
12         DayOfYear = dayOfYear;
13         Hour = hour;
14         Minute = minute;
15         Second = second;
16     }
17 }

 

完整時間資料管理指令碼:

  1 using System;
  2 
  3 public class TimeDataManager : Singleton<TimeDataManager>
  4 {
  5     const char S_Time = ':';
  6     public int GetYearDayCount(int year)
  7     {
  8         return year % 4 == 0 ? 366 : 365;
  9     }
 10 
 11     public string TimeToString(TimeData d)
 12     {
 13         return d.Year.ToString() + S_Time + d.DayOfYear.ToString() + S_Time + d.Hour.ToString() + S_Time + d.Minute.ToString() + S_Time + d.Second.ToString();
 14     }
 15 
 16     public TimeData StringToTime(string str)
 17     {
 18         var d = new TimeData();
 19         var s = str.Split(S_Time);
 20         d.Year = int.Parse(s[0]);
 21         d.DayOfYear = int.Parse(s[1]);
 22         d.Hour = int.Parse(s[2]);
 23         d.Minute = int.Parse(s[3]);
 24         d.Second = int.Parse(s[4]);
 25         return d;
 26     }
 27 
 28     public TimeData GetNowTime()
 29     {
 30         var d = new TimeData();
 31         var t = DateTime.Now;
 32         d.Year = t.Year;
 33         d.DayOfYear = t.DayOfYear;
 34         d.Hour = t.Hour;
 35         d.Minute = t.Minute;
 36         d.Second = t.Second;
 37         return d;
 38     }
 39 
 40     public bool CheckTimeBeforeNow(TimeData d)
 41     {
 42         var now = GetNowTime();
 43         if (now.Year < d.Year) { return false; }
 44         else if (now.Year > d.Year) { return true; }
 45         else if (now.DayOfYear < d.DayOfYear) { return false; }
 46         else if (now.DayOfYear > d.DayOfYear) { return true; }
 47         else if (now.Hour < d.Hour) { return false; }
 48         else if (now.Hour > d.Hour) { return true; }
 49         else if (now.Minute < d.Minute) { return false; }
 50         else if (now.Minute > d.Minute) { return true; }
 51         else if (now.Second < d.Second) { return false; }
 52         return true;
 53     }
 54 
 55     public TimeData Add(TimeData moment,TimeData time)
 56     {
 57         var y = moment.Year + time.Year;
 58         var d = moment.DayOfYear + time.DayOfYear;
 59         var h = moment.Hour + time.Hour;
 60         var m = moment.Minute + time.Minute;
 61         var s = moment.Second + time.Second;
 62 
 63         if (s > 59)
 64         {
 65             s -= 60;
 66             m++;
 67         }
 68 
 69         if (m > 59)
 70         {
 71             m -= 60;
 72             h++;
 73         }
 74 
 75         if (h > 23)
 76         {
 77             h -= 24;
 78             d++;
 79         }
 80 
 81         var ydc = GetYearDayCount(moment.Year);
 82         if (d > ydc)
 83         {
 84             d -= ydc;
 85             y++;
 86         }
 87 
 88         return new TimeData(y, d, h, m, s);
 89     }
 90 
 91     public TimeData Sub(TimeData afterTime,TimeData beforeTime)
 92     {
 93         var d = new TimeData();
 94         d.Second = afterTime.Second - beforeTime.Second;
 95         d.Minute = afterTime.Minute - beforeTime.Minute;
 96         d.Hour = afterTime.Hour - beforeTime.Hour;
 97         d.DayOfYear = afterTime.DayOfYear - beforeTime.DayOfYear;
 98         d.Year = afterTime.Year - beforeTime.Year;
 99 
100         if (d.Second < 0)
101         {
102             d.Second += 60;
103             d.Minute--;
104         }
105 
106         if (d.Minute < 0)
107         {
108             d.Minute += 60;
109             d.Hour--;
110         }
111 
112         if (d.Hour < 0)
113         {
114             d.Hour += 24;
115             d.DayOfYear--;
116         }
117 
118         var ydc = GetYearDayCount(beforeTime.Year);
119         if (d.DayOfYear < 0)
120         {
121             d.DayOfYear += ydc;
122             d.Year--;
123         }
124 
125         return d;
126     }
127 }
View Code

完整建造指令碼:

  1 using System.Collections;
  2 using UnityEngine;
  3 
  4 public enum BuildBoxState
  5 {
  6     Start,
  7     Doing,
  8     Complete
  9 }
 10 
 11 public class BuildBoxCtrl : HudBase
 12 {
 13     private BuildBoxView View;
 14     public BuildBoxState CurState { get; set; }
 15 
 16     public GameObject Start;
 17     public GameObject Doing;
 18     public GameObject Complete;
 19 
 20     private const int ResDef = 160;
 21     private const int ResMax = 800;
 22     private const int ResMin = 100;
 23     private const int ResCha = 10;
 24     private int CurResCount;
 25 
 26     private const string S_Null = "";
 27 
 28     public int ID;
 29 
 30     public string BuildCompleteTime
 31     {
 32         get
 33         {
 34             if (PlayerPrefs.HasKey(ID.ToString()))
 35                 return PlayerPrefs.GetString(ID.ToString());
 36             return S_Null;
 37         }
 38         set
 39         {
 40             PlayerPrefs.SetString(ID.ToString(), value);
 41             PlayerPrefs.Save();
 42         }
 43     }
 44 
 45     protected override void InitState()
 46     {
 47         View = HudView as BuildBoxView;
 48         if (BuildCompleteTime == S_Null)
 49         {
 50             ShiftState(BuildBoxState.Start);
 51         }
 52         else
 53         {
 54             ShiftState(BuildBoxState.Doing);
 55         }
 56     }
 57 
 58     protected override void AddListeners()
 59     {
 60         View.AddRes.onClick.AddListener(() => SetResCount(ResCha));
 61         View.CutRes.onClick.AddListener(() => SetResCount(-ResCha));
 62         View.Build.onClick.AddListener(OnClickBuild);
 63         View.Get.onClick.AddListener(OnClickGet);
 64         View.Speed.onClick.AddListener(() => Canvas.SendEvent(new ShowConfirmWindowEvent(ID)));
 65         Canvas.AddListener<ConfirmCompleteEvent>(ConfirmCompleteHandler);
 66     }
 67 
 68     protected override void RemoveListeners()
 69     {
 70         View.AddRes.onClick.RemoveAllListeners();
 71         View.CutRes.onClick.RemoveAllListeners();
 72         View.Build.onClick.RemoveAllListeners();
 73         View.Get.onClick.RemoveAllListeners();
 74         View.Speed.onClick.RemoveAllListeners();
 75         Canvas.RemoveListener<ConfirmCompleteEvent>(ConfirmCompleteHandler);
 76     }
 77 
 78     private void ConfirmCompleteHandler(ConfirmCompleteEvent e)
 79     {
 80         if (e.bYes && e.ID == ID)
 81         {
 82             SetCompleteTimeAtNow();
 83             ShiftState(BuildBoxState.Complete);
 84         }
 85     }
 86 
 87     private void OnClickBuild()
 88     {
 89         var pd = GameData.Instance.PlayerData;
 90         if (pd.ResourcePoint < CurResCount)
 91             return;
 92 
 93         pd.ResourcePoint -= CurResCount;
 94         Canvas.SendEvent(new UpdateUpBoxEvent());
 95 
 96         SetCompleteTime();
 97         ShiftState(BuildBoxState.Doing);
 98     }
 99 
100     private void OnClickGet()
101     {
102         Canvas.SendEvent(new GetItemEvent());
103         ClearCompleteTime();
104         ShiftState(BuildBoxState.Start);
105     }
106 
107     private void SetCompleteTime()
108     {
109         var nt = GetNowTime();
110         var bt = CalBuildTime(CurResCount);
111         var ct = TimeDataManager.Instance.Add(nt, bt);
112         SetCompleteTime(ct);     
113     }
114 
115     private void SetCompleteTime(TimeData d)
116     {
117         BuildCompleteTime = TimeDataManager.Instance.TimeToString(d);
118     }
119 
120     private void SetCompleteTimeAtNow()
121     {
122         var nt = GetNowTime();
123         SetCompleteTime(nt);
124     }
125 
126     private TimeData GetCompleteTime()
127     {
128         return TimeDataManager.Instance.StringToTime(BuildCompleteTime);
129     }
130 
131     private TimeData GetNowTime()
132     {
133         return TimeDataManager.Instance.GetNowTime();
134     }
135 
136     private TimeData CalBuildTime(int res)
137     {
138         var d = new TimeData();
139         d.Hour = res / 100;
140         d.Minute = res % 100;
141         if (d.Minute > 59)
142         {
143             d.Second = d.Minute - 59;
144             d.Minute = 59;
145         }
146         return d;
147     }
148 
149     private void SetResCount(int change)
150     {
151         CurResCount += change;
152         if (CurResCount > ResMax)
153             CurResCount = ResMax;
154         if (CurResCount < ResMin)
155             CurResCount = ResMin;
156 
157         View.SetRes(CurResCount);
158     }
159 
160     private void ResetResCount()
161     {
162         CurResCount = ResDef;
163         View.SetRes(CurResCount);
164     }
165 
166     private void ShiftState(BuildBoxState state)
167     {
168         switch (state)
169         {
170             case BuildBoxState.Start:
171                 Start.SetActive(true);
172                 Doing.SetActive(false);
173                 Complete.SetActive(false);
174 
175                 ResetResCount();
176                 break;
177             case BuildBoxState.Doing:
178                 Start.SetActive(false);
179                 Doing.SetActive(true);
180                 Complete.SetActive(false);
181 
182                 StartCoroutine(ShowBuildTime());
183                 break;
184             case BuildBoxState.Complete:
185                 Start.SetActive(false);
186                 Doing.SetActive(false);
187                 Complete.SetActive(true);
188 
189                 break;
190         }
191         CurState = state;
192     }
193 
194     private void ClearCompleteTime()
195     {
196         if (PlayerPrefs.HasKey(ID.ToString()))
197             PlayerPrefs.DeleteKey(ID.ToString());
198     }
199 
200     IEnumerator ShowBuildTime()
201     {
202         var ct = GetCompleteTime();
203         if (CheckBuildCompleted(ct))
204         {
205             ShiftState(BuildBoxState.Complete);
206             yield break;
207         }
208         else
209         {
210             for (; ; )
211             {
212                 View.SetTime(CalNeedTime(ct));
213                 yield return new WaitForSeconds(1);
214             }
215         }
216     }
217 
218     private TimeData CalNeedTime(TimeData com)
219     {      
220         var now = GetNowTime();
221         return TimeDataManager.Instance.Sub(com, now);
222     }
223 
224     private bool CheckBuildCompleted(TimeData com)
225     {
226         return TimeDataManager.Instance.CheckTimeBeforeNow(com);
227     }
228 }
View Code
 1 using UnityEngine.UI;
 2 using TMPro;
 3 
 4 public class BuildBoxView : HudView
 5 {
 6     public TextMeshProUGUI ResCount;
 7     public TextMeshProUGUI Time;
 8 
 9     public Button AddRes;
10     public Button CutRes;
11     public Button Build;
12     public Button Get;
13     public Button Speed;
14 
15     public void SetRes(int v)
16     {
17         ResCount.text = v.ToString();
18     }
19 
20     public void SetTime(TimeData data)
21     {
22         Time.text = data.Hour + ":" + data.Minute + ":" + data.Second;
23     }
24 }
View Code

這裡用到的UI基礎類可詳見之前寫過的隨筆:

https://www.cnblogs.com/koshio0219/p/12808063.html

單例模式:

https://www.cnblogs.com/koshio0219/p/11203631.html

 

補充:

通用確認彈窗:

 1 using TMPro;
 2 using UnityEngine.Events;
 3 using UnityEngine.UI;
 4 
 5 [System.Serializable]
 6 public class WindowBtClickdEvent : UnityEvent<bool> { }
 7 
 8 public class WindowView : HudBase
 9 {
10     public Button Yes;
11     public Button No;
12     public TextMeshProUGUI Content;
13 
14     public string Text;
15 
16     public WindowBtClickdEvent OnClick;
17 
18     protected override void InitState()
19     {
20         Content.text = Text;
21     }
22 
23     protected override void AddListeners()
24     {
25         Yes.onClick.AddListener(()=> OnClick.Invoke(true));
26         No.onClick.AddListener(() => OnClick.Invoke(false));
27     }
28 
29     protected override void RemoveListeners()
30     {
31         Yes.onClick.RemoveAllListeners();
32         No.onClick.RemoveAllListeners();
33     }
34 }
View Code

 

效果:

&n