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