韓版《傳奇 2》源碼分析與 Unity 重製(五)地圖對象的行為處理


3樓貓 發佈時間:2023-05-14 18:36:41 作者:Sou1gh0st Language

專題介紹

該專題將會分析 LOMCN 基於韓版傳奇 2,使用 .NET 重寫的傳奇源碼(服務端 + 客戶端),分析數據交互、狀態管理和客戶端渲染等技術,此外筆者還會分享將客戶端部分移植到 Unity 和服務端用現代編程語言重寫的全過程。

系列文章

韓版傳奇 2 源碼分析與 Unity 重製(一)服務端 TCP 狀態管理
韓版傳奇 2 源碼分析與 Unity 重製(二)客戶端啟動與交互流程
韓版傳奇 2 源碼分析與 Unity 重製(三)客戶端渲染管線
韓版傳奇 2 源碼分析與 Unity 重製(四)服務端地圖對象管理

概覽

在這一篇文章中,我們將分析角色如何接收用戶輸入,產生和處理行為 (Action),並以角色的移動為例分析處理和繪製邏輯。

行為系統

對於每個地圖對象 MapObject,都會有一個行為隊列 ActionFeed,以及代表當前行為的 CurrentAction 變量:
// MapObject.cs public abstract class MapObject { public List<QueuedAction> ActionFeed = new List<QueuedAction>(); public QueuedAction NextAction { get { return ActionFeed.Count > 0 ? ActionFeed[0] : null; } } ​ public MirAction CurrentAction; }
注意這裡的 NextAction 是一個計算屬性,用於取出 ActionFeed 中的下一個行為。

行為的產生

地圖對象的行為來自於用戶輸入或者 AI 行為邏輯,在這裡我們需要對用戶控制的角色和其他對象做一個區分,其他角色的行為序列是通過服務端同步到客戶端的,而用戶輸入是由輸入設備直接產生的,需要加以限制,因此在 UserObject 上有一個 QueuedAction 屬性用於記錄下一個待執行的行為,同時這也限制了遊戲中的角色無法一邊移動一邊施法:
// UserObject.cs public class UserObject : PlayerObject { public QueuedAction QueuedAction; }
以角色為例,當用戶通過鼠標右鍵進行移動時,將會產生一個 Walking Action 設置到 QueuedAction:
// GameScene.cs private void CheckInput() { // ... if ((CanWalk(direction)) && (CheckDoorOpen(Functions.PointMove(User.CurrentLocation, direction, 1)))) { User.QueuedAction = new QueuedAction { Action = MirAction.Walking, Direction = direction, Location = Functions.PointMove(User.CurrentLocation, direction, 1) }; return; } // ... }

行為的處理

每一個地圖對象 MapObject 在幀開始時都會被調用到 Process 方法處理遊戲邏輯,遊戲角色 UserObject 繼承了 PlayerObject,它重寫了 PlayerObject 的 ProcessFrames 方法來處理用戶輸入產生的 QueuedAction:
// UserObject.cs public override void ProcessFrames() { bool clear = CMain.Time >= NextMotion; ​ base.ProcessFrames(); ​ if (clear) QueuedAction = null; if ((CurrentAction == MirAction.Standing || CurrentAction == MirAction.MountStanding || CurrentAction == MirAction.Stance || CurrentAction == MirAction.Stance2 || CurrentAction == MirAction.DashFail) && (QueuedAction != null || NextAction != null)) SetAction(); }
這裡的 base.ProcessFrames 用於處理動畫幀的更新,我們先重點來看 SetAction 的執行邏輯,當目前的行為是 Idle 類狀態(站立和野蠻衝撞失敗等),且存在 QueuedAction 或 ActionFeed 不為空時則執行 SetAction 執行角色的下一個行為:
// UserObject.cs public override void SetAction() { if (QueuedAction != null ) { if ((ActionFeed.Count == 0) || (ActionFeed.Count == 1 && NextAction.Action == MirAction.Stance)) { ActionFeed.Clear(); ActionFeed.Add(QueuedAction); QueuedAction = null; } } ​ base.SetAction(); }
由上面的代碼可見 UserObject 並不直接處理 Action,而是隻處理從用戶輸入產生 QueuedAction 到 QueuedAction 加入到行為隊列這一過程,實際的行為處理邏輯在其父類 PlayerObject 中處理:
// PlayerObject.cs public virtual void SetAction() { // ... if (ActionFeed.Count == 0) { CurrentAction = MirAction.Standing; // ... } else { // ... QueuedAction action = ActionFeed[0]; ActionFeed.RemoveAt(0); CurrentAction = action.Action; // ... } // ... }
根據上面的代碼可知,當行為隊列為空時,會設置當前行為 CurrentAction 為 Standing,否則從隊列中取出第一個行為設置到當前。
總的來說,用戶的當前行為取決於 CurrentAction,當行為隊列為空時,CurrentAction 會被設置為 Idle 類狀態,否則會從隊列中依次取出行為執行,那麼哪些行為會引起 ActionFeed 被消費(即調用 SetAction)呢?除了上述提到的 UserObject 的 QueuedAction 不為空,另一個比較直觀的就是當前動作執行完成,即幀動畫播放完畢:
// PlayerObject.cs public virtual void ProcessFrames() { // ... if (UpdateFrame(false) >= Frame.Count) { FrameIndex = Frame.Count - 1; SetAction(); } }
此外,當服務端推送對象的狀態與本地狀態不同步時,也會觸發 SetAction 來強制更新客戶端狀態,例如服務端推送角色位置時如果發現狀態不一致會清空 QueuedAction 和 ActionFeed 並強制觸發一次 SetAction:
private void UserLocation(S.UserLocation p) { // ... if (User.CurrentLocation == p.Location && User.Direction == p.Direction) { return; } // ... User.QueuedAction = null; ​ for (int i = User.ActionFeed.Count - 1; i >= 0; i--) { if (User.ActionFeed[i].Action == MirAction.Pushed) continue; User.ActionFeed.RemoveAt(i); } ​ User.SetAction(); }

角色移動的處理

下面我們以角色移動為例分析行為系統的工作過程,在網絡遊戲中,角色移動是一個較為複雜的邏輯,其核心邏輯包括:
  1. 角色在地圖上的移動需要與幀動畫保持同步,需要將實際的位移按照動畫幀進行插值;
  2. 需要限制客戶端的移動頻率,避免服務端和客戶端的位置產生極大偏差,這一頻率還應當跟隨網絡延遲進行變化;
  3. 當客戶端與服務端的角色位置不同步時,需要將客戶端位置與服務單進行強制同步。

本地處理 Walking Action

通過上文對行為系統的分析我們知道,當用戶通過鼠標觸發移動時,會將一個 Walking Action 插入到 ActionFeed 並調用 SetAction 方法,在這裡我們會取出 Action 直接更新角色的位置和方向:
// PlayerObject.cs public virtual void SetAction() { // 限制角色行為頻率 if (User == this && CMain.Time < MapControl.NextAction)// && CanSetAction) { //NextMagic = null; return; } // ... QueuedAction action = ActionFeed[0]; ActionFeed.RemoveAt(0); CurrentAction = action.Action; CurrentLocation = action.Location; Direction = action.Direction; // ... // 取出 Walking 的幀動畫信息 Frame Frames.TryGetValue(CurrentAction, out Frame); // ... // 發送 Walking Packet 到服務端 Network.Enqueue(new C.Walk { Direction = Direction }); // 在服務端沒有回覆 ACK 的情況下,每 2500ms 只能執行一次 Walking MapControl.NextAction = CMain.Time + 2500; }

服務端 ACK

當服務端接收到 C.Walk 並完成相應處理後,會發送 S.UserLocation 到客戶端,客戶端在收到該數據包後會將 MapControl.NextAction 置為 0 使得客戶端可以立即執行下一個行為:
// GameScene.cs private void UserLocation(S.UserLocation p) { MapControl.NextAction = 0; if (User.CurrentLocation == p.Location && User.Direction == p.Direction) { return; } // ... }

客戶端行走動畫

對於移動邏輯本身而言,Walking 只是沿著瓦片地圖的 X 或者 Y 方向行走步長 Step 個 Cell,但對於客戶端渲染而言,行走是一個幀動畫,我們需要將行走過程連貫的渲染出來,否則角色的移動就像是在地圖上進行瞬移。
在上文本地處理 Walking Action 的過程中,我們已經完成了 CurrentAction 和 Frame 的設置,接下來的邏輯位於 PlayerObject 的 Process 中,在這裡我們會根據步長計算出位移,再根據 Frame 將位移分解到每一幀來產生與行走動畫同步的位移:
// GameScene.cs public override void Process() { // ... // 每 100ms 處理一次移動的 Frame 刷新,即以 10FPS 執行移動動畫 if (CMain.Time >= MoveTime) { MoveTime += 100; //Move Speed CanMove = true; MapControl.AnimationCount++; MapControl.TextureValid = false; } else CanMove = false; // ... } // PlayerObject.cs public override void Process() { // ... ProcessFrames(); // ... // 處理角色位移的插值 } public virtual void ProcessFrames() { // ... // 與上述邏輯對應,來控制移動幀率 if (!GameScene.CanMove) return; // ... // 處理 FrameIndex 更新 if (UpdateFrame(false) >= Frame.Count) { FrameIndex = Frame.Count - 1; SetAction(); } else { if (this == User) { if (FrameIndex == 1 || FrameIndex == 4) PlayStepSound(); } //NextMotion += FrameInterval; } // ... }
在上述代碼中,我們首先在 PlayerObject 的 Process 中處理 Frame Update,隨後處理角色的位移插值,這部分邏輯位於 Process 調用完 ProcessFrames 之後的部分:
// PlayerObject.cs public override void Process() { // ... ProcessFrames(); // ... ​ switch (CurrentAction) { case MirAction.Walking: case MirAction.Running: // ... // 計算移動步長 var i = 0; if (CurrentAction == MirAction.MountRunning) i = 3; else if (CurrentAction == MirAction.Running) i = (Sprint && !Sneaking ? 3 : 2); else i = 1; ​ if (CurrentAction == MirAction.Jump) i = -JumpDistance; if (CurrentAction == MirAction.DashAttack) i = JumpDistance; // 由目標位置 CurrentLocation, Direction 和步長 i 反算移動前的位置 Movement = Functions.PointMove(CurrentLocation, Direction, CurrentAction == MirAction.Pushed ? 0 : -i); ​ int count = Frame.Count; int index = FrameIndex; if (CurrentAction == MirAction.DashR || CurrentAction == MirAction.DashL) { count = 3; index %= 3; } // 根據 Frame 信息計算當前 FrameIndex 對應的位移 switch (Direction) { case MirDirection.Up: OffSetMove = new Point(0, (int)((MapControl.CellHeight * i / (float)(count)) * (index + 1))); break; case MirDirection.UpRight: OffSetMove = new Point((int)((-MapControl.CellWidth * i / (float)(count)) * (index + 1)), (int)((MapControl.CellHeight * i / (float)(count)) * (index + 1))); break; // ... } // 向上取最接近的偶數 OffSetMove = new Point(OffSetMove.X % 2 + OffSetMove.X, OffSetMove.Y % 2 + OffSetMove.Y); break; default: // 動作完成後,Movement 更新到目標位置 CurrentLocation OffSetMove = Point.Empty; Movement = CurrentLocation; break; } ​ // 繪製角色,注意繪製用戶和其他玩家的區別 DrawY = Movement.Y > CurrentLocation.Y ? Movement.Y : CurrentLocation.Y; DrawLocation = new Point((Movement.X - User.Movement.X + MapControl.OffSetX) * MapControl.CellWidth, (Movement.Y - User.Movement.Y + MapControl.OffSetY) * MapControl.CellHeight); DrawLocation.Offset(GlobalDisplayLocationOffset); // 對於其他玩家繪製,如果用戶在移動,需要在位置差的基礎上疊加移位移插值 if (this != User) { DrawLocation.Offset(User.OffSetMove); DrawLocation.Offset(-OffSetMove.X, -OffSetMove.Y); } ​ if (BodyLibrary != null && update) { FinalDrawLocation = DrawLocation.Add(BodyLibrary.GetOffSet(DrawFrame)); DisplayRectangle = new Rectangle(DrawLocation, BodyLibrary.GetTrueSize(DrawFrame)); } // ... }
上述邏輯比較複雜,其中的核心變量是 Movement 和 OffSetMove,下面我們分別進行解釋:
  1. Movement 在 Walking Action 完成之前將一直保持移動前的位置,即 CurrentLocation 回退到移動前的位置,在 Walking Action 完成後才會被更新為 CurrentLocation;
  2. OffSetMove 代表角色位移的反向向量,它是通過位移基於 Frame 插值而來的。
這裡的 Movement 用於在屏幕上正確的繪製玩家,當 PlayerObject 代表當前玩家時,即 this == User,Movement 與 User.Movement 是相等的,DrawLocation 始終位於屏幕的中心區域,而當 PlayerObject 代表其他玩家時,我們需要計算出其他玩家相對於用戶的距離,即通過 Movement - UserMovement,此外別忘了 Movement 只代表移動前的位置,如果用戶在移動中,還需要將這個距離疊加上 -OffSetMove 來進行插值。
而 OffSetMove 一方面用於繪製其他玩家,另一方面則用於繪製地圖,傳奇的地圖渲染邏輯是我不動世界在動,即角色移動的本質是地圖產生反向位移,讓我們再回到地圖繪製邏輯來看看 OffSetMove 對地圖繪製的影響:
// GameScene.cs { // ... int index; int drawY, drawX; ​ for (int y = User.Movement.Y - ViewRangeY; y <= User.Movement.Y + ViewRangeY; y++) { if (y <= 0 || y % 2 == 1) continue; if (y >= Height) break; ​ drawY = (y - User.Movement.Y + OffSetY) * CellHeight + User.OffSetMove.Y; //Moving OffSet ​ for (int x = User.Movement.X - ViewRangeX; x <= User.Movement.X + ViewRangeX; x++) { if (x <= 0 || x % 2 == 1) continue; if (x >= Width) break; drawX = (x - User.Movement.X + OffSetX) * CellWidth - OffSetX + User.OffSetMove.X; //Moving OffSet if ((M2CellInfo[x, y].BackImage == 0) || (M2CellInfo[x, y].BackIndex == -1)) continue; index = (M2CellInfo[x, y].BackImage & 0x1FFFFFFF) - 1; Libraries.MapLibs[M2CellInfo[x, y].BackIndex].Draw(index, drawX, drawY); } } // ... }
可見當用戶移動時,地圖會沿著相反的方向移動從而產生用戶在地圖上移動的視覺效果,當然我們也可以理解為遊戲的 Main Camera 與用戶綁定,地圖在不斷地被變換到觀察空間。

下一步

到這裡我們已經分析清楚了地圖對象的行為系統和行為處理流程,接下來我們將深入到對象的攻擊、技能等交互邏輯中進行分析。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com