類似War3的幀同步RTS遊戲開發總結

最近從零開始,實現了一個基於幀同步的RTS類型遊戲的Demo,這裡主要是列出實現這樣一個Demo需要實現的各個模塊,作為一個總結,以備日後回顧。

同步方案最終定了幀同步的方案,主要是基於幾點考慮:

1. 操作頻繁,單位的數據變化多,狀態同步的數據量太大

2. 開發人手少,幀同步開發效率高

3. 伺服器壓力小

4. 錄像功能好實現,只需要記錄操作,客戶端就可以重現整個戰鬥過程

地圖管理和移動邏輯

基於格子的地圖以及A*的尋路

地圖和A*沒什麼說的,就是傳統的演算法,有個地方需要特殊處理的就是飛行單位是可以穿越阻擋的,這樣尋路模塊需要提供兩個介面,一個是阻擋的,一個是無視阻擋的。

基於指令系統的移動邏輯

移動的表現層有個需要特殊處理的地方。就是尋路是基於格子,那麼坐標都是(12,13),(50, 60)這樣的整數,但是場景里的角色對象是3d世界下的Vector3坐標。用戶點擊的是場景里的任意坐標,並且單位的移動是可以被新的操作打斷的,那麼一次移動是可以理論上從任意的世界坐標點,移動到任意的世界坐標點的。對尋路來說,怎麼平滑的處理。

我這邊想到的辦法是,尋路模塊返回的是格子中心的點的世界坐標,然後起始點和結束點的坐標就用做最後表現層的點,然後把所在的格子原本的路點坐標丟棄。

網路幀同步

戰鬥開始流程

做幀同步,就要加入房間的概念,然後需要修改戰鬥的開始流程

伺服器轉發操作協議

主要的工作在於手寫序列化,反序列化

public class Command : IProtoSerializern{ntpublic intttteam_id { set; get; }ntpublic intttcommand_type { set; get; }nnt// 下面的欄位都是可選的項,根據上面的操作類型來定義nt// 這個是move的欄位ntpublic intttcast_id { set; get; }ntpublic intttx { set; get; }ntpublic int tty { set; get; }nt// 放置將軍欄位ntpublic inttthero_index { set; get; }nt// 追擊目標(用上面的cast id)ntpublic inttttarget_id { set; get; }nntpublic inttLength()nt{nttreturn 20;nt}nntpublic void Serialize(byte[] buffer, ref int offset)nt{nttSerializeHelper.WriteInt(buffer, team_id, ref offset);nttSerializeHelper.WriteInt(buffer, command_type, ref offset);nnttswitch(command_type)ntt{nttcase BL.TickCommandType.Move:nttt{nttttSerializeHelper.WriteInt(buffer, cast_id, ref offset);nttttSerializeHelper.WriteInt(buffer, x, ref offset);nttttSerializeHelper.WriteInt(buffer, y, ref offset);nttt}ntttbreak;nttcase BL.TickCommandType.PUT_HERO:nttt{ntttt// 暫時都填充滿20個位元組nttttSerializeHelper.WriteInt(buffer, hero_index, ref offset);nttttSerializeHelper.WriteInt(buffer, hero_index, ref offset);nttttSerializeHelper.WriteInt(buffer, hero_index, ref offset);nttt}ntttbreak;nttcase BL.TickCommandType.PURSUE_TARGET:nttt{ntttt// 暫時都填充滿20個位元組nttttSerializeHelper.WriteInt(buffer, cast_id, ref offset);nttttSerializeHelper.WriteInt(buffer, target_id, ref offset);nttttSerializeHelper.WriteInt(buffer, target_id, ref offset);nttt}ntttbreak;ntt};nnt}nntpublic static Command Deserialize(byte[] data, ref int offset)nt{nttCommand obj = new Command();nnttobj.team_id = SerializeHelper.ReadInt(data, ref offset);nttobj.command_type = SerializeHelper.ReadInt(data, ref offset);nnttswitch(obj.command_type)ntt{nttcase BL.TickCommandType.Move:nttt{nttttobj.cast_id = SerializeHelper.ReadInt(data, ref offset);nttttobj.x = SerializeHelper.ReadInt(data, ref offset);nttttobj.y = SerializeHelper.ReadInt(data, ref offset);nttt}ntttbreak;nttcase BL.TickCommandType.PUT_HERO:nttt{nttttobj.hero_index = SerializeHelper.ReadInt(data, ref offset);nttttSerializeHelper.ReadInt(data, ref offset);nttttSerializeHelper.ReadInt(data, ref offset);nttt}ntttbreak;nttcase BL.TickCommandType.PURSUE_TARGET:nttt{nttttobj.cast_id = SerializeHelper.ReadInt(data, ref offset);nttttobj.target_id = SerializeHelper.ReadInt(data, ref offset);nttttSerializeHelper.ReadInt(data, ref offset);nttt}ntttbreak;ntt};nnttreturn obj;nt}n}n

客戶端邏輯層和表現層分開

邏輯層

單位,角色單位,建築單位

建築單位和角色單位的屬性設計,參考的是魔獸的編輯器,然後根據策劃的需求做了一定的修改。

時間軸控制器

時間軸控制器的主要作用就是控制邏輯幀的播放,暫停,回放,停止等等,可以看作是驅動整個邏輯層運作的核心模塊。

在這個Demo中是按照每秒16幀來驅動的,也就是伺服器每隔1/16秒往客戶端發一個同步幀,幀的內容就是這一幀之內所有玩家的操作。

客戶端本地則按照實際流逝的時間,計算實際應該經過的邏輯幀,然後再看當前邏輯層應該往前運行多少幀。

public class BLTimelineController n{n ...ntpublic void Tick(float dt)nt{nttif(!is_start)ntt{ntttreturn;ntt}nntt// 切入後台的時候dt 不是真實的dt,所以要用下面的自己計算的nttfloat delta_time = (Time.realtimeSinceStartup - time_pre_frame);ntttotal_time_elapsed += delta_time;nntttime_pre_frame = Time.realtimeSinceStartup;nnttnow_local_logic_frame = (int)(total_time_elapsed / SECOND_PER_FRAME);nnttint need_move_frame = now_local_logic_frame - current_logic_frame;nnttint real_move_frame = 0;nttif(frame_received >= need_move_frame)ntt{ntttreal_move_frame = need_move_frame;ntt}nttelsentt{ntttreal_move_frame = frame_received;ntt}nn//Debug.Log("frame_received " + frame_received + " current_logic_frame " + current_logic_frame + " now_local_logic_frame " + now_local_logic_frame + " real_move_frame " + real_move_frame);nnttif( real_move_frame > 0 )ntt{nttttime_elapsed_from_pre_frame = 0;ntt}nttelsentt{nttttime_elapsed_from_pre_frame += delta_time;ntt}nttDoMainLogic(real_move_frame);nnttframe_received -= real_move_frame;nnt}n

視野系統

視野系統是為每個戰場的玩家,保存了一個可見行的列表,然後每隔一定的時間去更新整個列表,更新的辦法就是對所有單位遍歷,看單位到單位的距離是否小於視野。

上面的視野信息是給邏輯層使用的,做所有玩家的戰鬥邏輯。然後還有一個自己能看到的單位的列表是給表現層使用的,用於做戰爭迷霧的時候使用。

var unit_list1 = BLUnitManager.Instance().GetAllUnitList();nvar unit_list2 = BLUnitManager.Instance().GetAllUnitList();nnvar enumerator1 = unit_list1.GetEnumerator();nnwhile(enumerator1.MoveNext())n{ntvar enumerator2 = unit_list2.GetEnumerator();ntBLUnitBase unit1 = enumerator1.Current.Value;nntwhile(enumerator2.MoveNext())nt{nttBLUnitBase unit2 = enumerator2.Current.Value;nnttif(unit1.team_id != unit2.team_id nttt&& unit1.IsAlive() && unit2.IsAlive() nttt&& unit1.IsCanSeeUnitCheckOnlyMyself(unit2))ntt{ntttif(!vision_enemy_units[unit1.team_id].Contains(unit2))nttt{nttttvision_enemy_units[unit1.team_id].Add(unit2);nntttt// 更新能看見的單位,給渲染層用nttttif(unit1.team_id == my_team_id)ntttt{ntttttcan_see_unit_id.Add(unit2.unit_id);ntttt}nttt}ntt}nt}nnt// 更新能看見的單位,給渲染層用ntif(unit1.team_id == my_team_id)nt{nttcan_see_unit_id.Add(unit1.unit_id);nt}n}n

攻擊和追擊的AI

目前就是簡單的if,else實現,後面如果AI複雜了,考慮重構行為樹的方式。

攻擊AI

virtual public void AttackImplementTick()n{nt// 移動攻擊屬性的檢查ntif(my_unit.unit_type == UnitType.Hero)nt{nttBLUnitHero hero_unit = my_unit as BLUnitHero;nnttif( !hero_unit.is_move_attack && hero_unit.IsMoveState())ntt{ntttreturn;ntt}nt}ntif(target_unit == null)nt{ntttarget_unit = AttackAI.FindCanAttackTarget(my_unit);nnttif(target_unit != null)ntt{ntttDoAttack(target_unit);ntt}nt}ntelsent{nttif(target_unit.IsAlive())ntt{ntttfloat distance_sqr;nntttif(my_unit.IsCanAttack(target_unit, out distance_sqr))nttt{nttttDoAttack(target_unit);nttt}ntttelsenttt{ntttt// 打不到,清空目標ntttttarget_unit = null;nttt}ntt}nttelsentt{nttttarget_unit = null;ntt}nt}tnn}n

追擊AI

public class PursueTargetComponent n{nt// 追擊的目標ntpublic BLUnitBase pursue_target;ntpublic BLUnitHero my_unit;nntprivate float pursue_cool_down = 0;nntpublic delegate void PursueEndCallBack(BLUnitBase target);nntpublic PursueEndCallBack end_callback = null;nntpublic void Tick(float delta_time)nt{nttif(pursue_target != null)ntt{ntttif(pursue_target.IsAlive())nttt{nttttfloat distance_sqr;nnttttif(my_unit.IsCanAttack(pursue_target, out distance_sqr))ntttt{ntttttmy_unit.Idle();nntttttif(end_callback != null)nttttt{nttttttend_callback.Invoke(pursue_target);nttttt}ntttt}nttttelsentttt{ntttttif(my_unit.IsCanSeeUnit(pursue_target))nttttt{nttttttDoPursueTarget(delta_time);nttttt}ntttttelsenttttt{nttttttmy_unit.SetPursueTarget(null);nttttt}ntttt}nttt}ntttelsenttt{nttttmy_unit.SetPursueTarget(null);nttt}ntt}nt}nntprivate void DoPursueTarget(float delta_time)nt{nttif(pursue_cool_down <= 0)ntt{ntttint grid_x;ntttint grid_y;nntttif(BattleField.battle_field.WorldPositon2Grid(pursue_target.position, out grid_x, out grid_y))nttt{nttttmy_unit.Move(grid_x, grid_y);nttt}nntttpursue_cool_down = my_unit._pursue_rate;ntt}nttelsentt{ntttpursue_cool_down -= delta_time;ntt}nt}n}tn

有彈道攻擊和沒有彈道的攻擊

攻擊效果方面採用了組件的形式,有彈道的單位就初始化一個有彈道攻擊的組件在單位身上,沒有彈道的就初始化一個沒有彈道的組件,後面彈道的不同形式都可以通過組件的方式做擴展。

// 有彈道npublic class AttackBulletComponent : AttackComponentBase n// 實際攻擊邏輯npublic override void DoAttack(BLUnitBase attack_target_unit)n{ntif(attack_cool_down <= 0)nt{nttmy_unit.DoAttack(attack_target_unit);nnnttBulletComponent bullet = BLBulletManager.Instance().CreateBullet();nttbullet.bullet_speed = my_unit.bullet_speed;nttbullet.Start(my_unit.position, attack_target_unit.position, HitCallBack);nntt// 換算成攻擊間隔是多少幀nttattack_cool_down = (int)(my_unit.attack_speed / BLTimelineController.MS_PER_FRAME);nt}n}nn// 沒有彈道npublic class AttackNoBulletComponent : AttackComponentBasen...npublic override void DoAttack(BLUnitBase attack_target_unit)n{ntif(attack_target_unit != target_unit)nt{ntttarget_unit = attack_target_unit;tnt}nntif(attack_cool_down <= 0)nt{nttmy_unit.DoAttack(attack_target_unit);nnttBLUnitHero.AttackCaculate(my_unit, attack_target_unit, attack_target_unit.position);nntt// 換算成攻擊間隔是多少幀nttattack_cool_down = (int)(my_unit.attack_speed / BLTimelineController.MS_PER_FRAME);nt}n}n

表現層

單位移動的表現插值

邏輯層的單位位置在移動的時候是基於1/16秒的離散的點,那麼表現層肯定需要平滑的處理,這裡使用的方法是,記錄邏輯幀這一幀和前一幀的本地時間戳,然後根據距離前一幀的經過的時間,對前一幀和這一幀的位置做插值。

public float GetCurrentLerpPercent()n{ntfloat time_span = (current_logic_frame_time_stamp - pre_logic_frame_time_stamp);ntif(time_span != 0)nt{nttreturn time_elapsed_from_pre_frame / time_span;nt}nttreturn 0;n}nn// 位置插值計算nfloat lerp = BL.BLTimelineController.Instance().GetCurrentLerpPercent();nnVector3 now_position = Vector3.Lerp(pre_position, next_position, lerp);n

基於事件系統的觸發動畫播放

表現層角色的動畫播放,都是基於事件系統,主要的事件如下:

public void Init()n{nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_START_MOVE, OnStartMove);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_END_MOVE, OnEndMove);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_PLAY_ATTACK, OnPlayAttack);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_PLAY_HIT, OnPlayHit);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_PLAY_DEAD, OnPlayDead);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_TEAM_ID_CHANGE, OnTeamIDChange);nEventManager.Instance().RegisterEvent(EventConfig.EVENT_L2R_PLAY_REINIT, OnPlayReinit);n}n

戰爭迷霧

這裡使用了一個第三方插件,FogOfWar,

使用的時候主要就幾個設置的介面。

1. 設置當前主視角的玩家的team

2. 設置每個單位自己的team id。

3. 設置每個單位的視野半徑

FoW.FogOfWar.current.team = team_id; n public class FogOfWarUnit : MonoBehaviourn {n public int team = 0;n public float radius = 5.0f;n ...n } n

後續還可以做的事情

1. 戰鬥協議用udp

2. 部分浮點數的應用,考慮新的方式

3. 尋路改導航網格

4. 單位碰撞加入RVO演算法

5. 地形對視野的阻擋

6. 預表現層


推薦閱讀:

《解放地球》偏重陣地戰的小型即時戰略遊戲
是要操作,還是要策略?從8bit系列想起的
2000年,這款國產遊戲出現在了大洋彼岸E3大展上
《英雄連》十周年,製作組講述開發秘辛
《紅警4》《魔獸4》為何難產?也許與RTS的衰落有關

TAG:即时战略游戏RTS | 游戏开发 | Unity游戏引擎 |