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 }
完整建造脚本:
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 }
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 }
这里用到的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 }
效果: