韓版《傳奇2》源碼分析與 Unity 重製(四)服務端地圖對象管理


3樓貓 發佈時間:2023-04-15 22:08:59 作者:Sou1gh0st Language

專題介紹

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

系列文章

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

概覽

在這一篇文章中,我們將開始研究傳奇服務端的地圖邏輯,根據前面的分析我們知道,傳奇採用了 Tilemap 的方式來實現地圖,接下來就讓我們看一下其中的細節。

地圖數據加載

在傳統的 Tilemap 渲染中,地圖資源會以地圖元數據 + 瓦片貼圖的形式存在,傳奇作為一款網絡遊戲,地圖元數據存儲在服務端,瓦片貼圖存儲在客戶端。下面讓我們來看一下地圖元數據的加載及數據結構吧。
就像我們在第一篇文章中講的,加載地圖的邏輯也是在 MainEnvir 的 StartEnvir 階段完成的,首先在 LoadDB 階段從 Server.MirDB 加載地圖列表,然後通過地圖列表分別加載對應的地圖文件:
// Envir.cs public bool LoadDB() { // ... var count = reader.ReadInt32(); MapInfoList.Clear(); for (var i = 0; i < count; i++) MapInfoList.Add(new MapInfo(reader)); // ... } ​ private void StartEnvir() { // ... LoadDB(); // ... for (var i = 0; i < MapInfoList.Count; i++) MapInfoList[i].CreateMap(); // ... }
在第一階段 LoadDB 中,加載的是地圖索引以及動態配置,例如刷怪點和傳送點,其中的 FileName 指向的是第二階段加載的地圖元數據:
// MapInfo.cs public MapInfo(BinaryReader reader) { Index = reader.ReadInt32(); FileName = reader.ReadString(); Title = reader.ReadString(); MiniMap = reader.ReadUInt16(); Light = (LightSetting) reader.ReadByte(); ​ BigMap = reader.ReadUInt16(); ​ int count = reader.ReadInt32(); for (int i = 0; i < count; i++) SafeZones.Add(new SafeZoneInfo(reader) { Info = this }); ​ count = reader.ReadInt32(); for (int i = 0; i < count; i++) Respawns.Add(new RespawnInfo(reader, Envir.LoadVersion, Envir.LoadCustomVersion)); ​ count = reader.ReadInt32(); for (int i = 0; i < count; i++) Movements.Add(new MovementInfo(reader)); // mesh link ​ NoTeleport = reader.ReadBoolean(); NoReconnect = reader.ReadBoolean(); NoReconnectMap = reader.ReadString(); ​ NoRandom = reader.ReadBoolean(); NoEscape = reader.ReadBoolean(); NoRecall = reader.ReadBoolean(); NoDrug = reader.ReadBoolean(); NoPosition = reader.ReadBoolean(); NoThrowItem = reader.ReadBoolean(); NoDropPlayer = reader.ReadBoolean(); NoDropMonster = reader.ReadBoolean(); NoNames = reader.ReadBoolean(); Fight = reader.ReadBoolean(); Fire = reader.ReadBoolean(); FireDamage = reader.ReadInt32(); Lightning = reader.ReadBoolean(); LightningDamage = reader.ReadInt32(); MapDarkLight = reader.ReadByte(); count = reader.ReadInt32(); for (int i = 0; i < count; i++) MineZones.Add(new MineZone(reader)); MineIndex = reader.ReadByte(); NoMount = reader.ReadBoolean(); NeedBridle = reader.ReadBoolean(); NoFight = reader.ReadBoolean(); Music = reader.ReadUInt16(); ​ if (Envir.LoadVersion < 78) return; NoTownTeleport = reader.ReadBoolean(); if (Envir.LoadVersion < 79) return; NoReincarnation = reader.ReadBoolean(); }
在第二階段 CreateMap 中,加載的是瓦片地圖的元數據,這部分數據應該是美術和策劃在地圖編輯器中完成地圖製作後導出的靜態資源文件,後期無需再進行修改:
// MapInfo.cs public void CreateMap() { // ... Map map = new Map(this); if (!map.Load()) return; Envir.MapList.Add(map); // ... } ​ // Map.cs public bool Load() { // ... string fileName = Path.Combine(Settings.MapPath, Info.FileName + ".map"); if (File.Exists(fileName)) { byte[] fileBytes = File.ReadAllBytes(fileName); switch(FindType(fileBytes)) { case 0: LoadMapCellsv0(fileBytes); break; case 1: LoadMapCellsv1(fileBytes); break; // ... } } }
運行時的 Tilemap Cell 創建是在上述代碼中的 LoadMapCellsX 中完成的,這裡我們以 LoadMapCellsv0 為例,可以看到這裡首先根據地圖尺寸創建了 Cells,然後根據每個瓦片的屬性為 Cell 填充屬性:
private void LoadMapCellsv0(byte[] fileBytes) { int offSet = 0; Width = BitConverter.ToInt16(fileBytes, offSet); offSet += 2; Height = BitConverter.ToInt16(fileBytes, offSet); Cells = new Cell[Width, Height]; DoorIndex = new Door[Width, Height]; ​ offSet = 52; ​ for (int x = 0; x < Width; x++) for (int y = 0; y < Height; y++) {//total 12 if ((BitConverter.ToInt16(fileBytes, offSet) & 0x8000) != 0) Cells[x, y] = Cell.HighWall; //Can Fire Over. ​ offSet += 2; if ((BitConverter.ToInt16(fileBytes, offSet) & 0x8000) != 0) Cells[x, y] = Cell.LowWall; //Can't Fire Over. ​ offSet += 2; ​ if ((BitConverter.ToInt16(fileBytes, offSet) & 0x8000) != 0) Cells[x, y] = Cell.HighWall; //No Floor Tile. ​ if (Cells[x, y] == null) Cells[x, y] = new Cell { Attribute = CellAttribute.Walk }; ​ offSet += 4; ​ if (fileBytes[offSet] > 0) DoorIndex[x, y] = AddDoor(fileBytes[offSet], new Point(x, y)); ​ offSet += 3; ​ byte light = fileBytes[offSet++]; ​ if (light >= 100 && light <= 119) Cells[x, y].FishingAttribute = (sbyte)(light - 100); } }

地圖對象管理

通過上述分析我們瞭解了瓦片地圖的加載過程,其中包含動態部分和靜態部分,靜態部分包含了地圖尺寸和瓦片類型,動態部分上面只涵蓋了安全區、刷怪點、傳送點和道具限制等這些基礎屬性,除此之外,地圖上的玩家, 怪物和 NPC 等對象才是地圖的核心數據。
讓我們先以 NPC 為例,NPC 的加載是在地圖加載的第二階段,在完成了 LoadMapCellsv0 後會根據一階段 MapInfo 中的 NPC 配置將 NPC 添加到特定的 Cell 中:
// Map.cs public bool Load() { // ... LoadMapCellsX(); // ... for (int i = 0; i < Info.NPCs.Count; i++) { NPCInfo info = Info.NPCs[i]; if (!ValidPoint(info.Location)) continue; AddObject(new NPCObject(info) { CurrentMap = this }); } }
這裡的 ValidPoint 指的是該座標點是否可通行,在後續處理人物行走等邏輯時也會用到,它的判斷依據是瓦片屬性是否等於 Walk:
// Map.cs public class Cell { // ... public bool Valid { get { return Attribute == CellAttribute.Walk; } } // ... } ​ public Cell GetCell(Point location) { return Cells[location.X, location.Y]; } ​ public bool ValidPoint(Point location) { return location.X >= 0 && location.X < Width && location.Y >= 0 && location.Y < Height && GetCell(location).Valid; }
上述代碼在檢查目標點合法後,會通過 AddObject 方法將 NPC 對象添加到地圖中,傳奇的瓦片地圖上的每一個對象都需要繼承 MapObject 來維護對象在地圖上的狀態:
// NPCObject.cs (Server) public sealed class NPCObject : MapObject { public override ObjectType Race { get { return ObjectType.Merchant; } } // ... } ​ // Map.cs public void AddObject(MapObject ob) { if (ob.Race == ObjectType.Player) { Players.Add((PlayerObject)ob); InactiveTime = Envir.Time; } if (ob.Race == ObjectType.Merchant) NPCs.Add((NPCObject)ob); ​ GetCell(ob.CurrentLocation).Add(ob); }
通過上述代碼可知 Map 在處理 AddObject 時會單獨維護 Player 與 NPC 的列表,隨後獲取到當前座標點的 Cell 完成對象的添加邏輯,Cell 本身的邏輯很輕,只維護對象列表而不處理任何邏輯:
// Map.cs public class Cell { public static Cell LowWall { get { return new Cell { Attribute = CellAttribute.LowWall }; } } public static Cell HighWall { get { return new Cell { Attribute = CellAttribute.HighWall }; } } ​ public bool Valid { get { return Attribute == CellAttribute.Walk; } } ​ public List<MapObject> Objects; public CellAttribute Attribute; public sbyte FishingAttribute = -1; ​ public void Add(MapObject mapObject) { if (Objects == null) Objects = new List<MapObject>(); ​ Objects.Add(mapObject); } public void Remove(MapObject mapObject) { Objects.Remove(mapObject); if (Objects.Count == 0) Objects = null; } }
那麼地圖上會有哪些對象呢?我們通過查找所有繼承 MapObject 的類可以得到:
  1. DecoObject - Decoration, 裝飾物
  2. ItemObject - 物品
  3. MonsterObject - 怪物
  4. NPCObject - NPC
  5. PlayerObject - 玩家
  6. SpellObject - 技能

地圖邏輯處理

在服務端啟動時,已加載的運行時地圖數據包含如下信息:
  1. 加載地圖的安全區,刷怪點,傳送點,道具技能的使用限制等配置;
  2. 根據配置創建 Tilemap Cells,填充屬性
  3. 根據配置創建刷怪點對象 MapRespawn
  4. 根據配置添加 NPC 對象到指定的 Cell
  5. 根據配置創建安全區,安全區是通過無限循環的技能對象 SpellObject 表示的
  6. 根據配置創建挖礦點
顯然此時在遊戲中還缺少怪物和玩家對象,下面我們分別來看他們的創建時機。

怪物刷新

怪物的刷新數據包括從 DB 中讀取的怪物刷新配置 RespawnInfo 以及基於它創建的刷怪點對象 MapRespawn,下面我們來看一下他們的核心屬性:
// RespawnInfo.cs public class RespawnInfo { protected static Envir Envir { get { return Envir.Main; } } public int MonsterIndex; public Point Location; public ushort Count, Spread, Delay, RandomDelay; public byte Direction; public string RoutePath = string.Empty; public int RespawnIndex; public bool SaveRespawnTime = false; public ushort RespawnTicks; //leave 0 if not using this system! // ... }
每個 Info 中只存儲了一個 MonsterIndex,因此不同的怪物刷新需要獨立的 RespawnInfo,RoutePath 為刷怪點的座標配置文件路徑,文件中通過逗號+換行分隔的字符串表示一系列座標,每行一個刷怪點座標。
這裡的 Delay 和 RespawnTicks 分別代表了兩種怪物刷新系統,當沒有配置 RespawnTicks 的時候 Delay 生效,否則 RespawnTicks 生效,這個後面我們會更加詳細的進行分析。
上述刷怪點配置對象會在地圖加載 Cell 完成後作為入參創建地圖上的實際刷怪點控制對象 MapRespawn,它會完成從 RoutePath 讀取座標、綁定地圖、綁定怪物等邏輯:
// Map.cs public class MapRespawn { protected static Envir Envir { get { return Envir.Main; } } public RespawnInfo Info; public MonsterInfo Monster; public Map Map; public int Count; public long RespawnTime; public ulong NextSpawnTick; public byte ErrorCount = 0; public List<RouteInfo> Route; // ... }
這裡的 Count, RespawnTime, NextSpawnTick 和 ErrorCount 將作為該刷怪點的運行時狀態用於控制刷怪邏輯。
根據之前的文章不難推斷,刷怪這類地圖邏輯肯定是在服務端 WorkLoop 的 Process 階段處理的:
// Envir.cs - WorkLoop for (var i = 0; i < MapList.Count; i++) MapList[i].Process(); // Map.cs public void Process() { ProcessRespawns(); // ... } private void ProcessRespawns() { bool Success = true; for (int i = 0; i < Respawns.Count; i++) { MapRespawn respawn = Respawns[i]; if ((respawn.Info.RespawnTicks != 0) && (Envir.RespawnTick.CurrentTickcounter < respawn.NextSpawnTick)) continue; if ((respawn.Info.RespawnTicks == 0) && (Envir.Time < respawn.RespawnTime)) continue; if (respawn.Count < (respawn.Info.Count * Envir.SpawnMultiplier)) { int count = (respawn.Info.Count * Envir.SpawnMultiplier) - respawn.Count; for (int c = 0; c < count; c++) Success = respawn.Spawn(); } if (Success) { respawn.ErrorCount = 0; long delay = Math.Max(1, respawn.Info.Delay - respawn.Info.RandomDelay + Envir.Random.Next(respawn.Info.RandomDelay * 2)); respawn.RespawnTime = Envir.Time + (delay * Settings.Minute); if (respawn.Info.RespawnTicks != 0) { respawn.NextSpawnTick = Envir.RespawnTick.CurrentTickcounter + (ulong)respawn.Info.RespawnTicks; if (respawn.NextSpawnTick > long.MaxValue)//since nextspawntick is ulong this simple thing allows an easy way of preventing the counter from overflowing respawn.NextSpawnTick -= long.MaxValue; } } else { respawn.RespawnTime = Envir.Time + 1 * Settings.Minute; // each time it fails to spawn, give it a 1 minute cooldown if (respawn.ErrorCount < 5) respawn.ErrorCount++; else { if (respawn.ErrorCount == 5) { respawn.ErrorCount++; Logger.GetLogger(LogType.Spawn).Info($"Failed to spawn: " + $"mapindex: {respawn.Map.Info.Index}, " + $"mob info: index: {respawn.Info.MonsterIndex}, " + $"spawncoords ({respawn.Info.Location.X}:{respawn.Info.Location.Y}), " + $"range {respawn.Info.Spread}"); } } } } }
這裡的刷怪處理邏輯分為兩個子系統:
  1. 如果 RespawnInfo.RespawnTicks = 0,使用以分鐘為單位延遲的刷怪系統,即根據 RespawnInfo.Delay 疊加當前時間 Envir.Time 計算下一次刷怪時間,並不斷重複;
  2. 如果 RespawnInfo.RespawnTicks ≠ 0,使用一個獨立的 RespawnTimer 進行 tick,並基於此控制刷怪間隔。
注意這裡的 Envir.Time 是通過一個 StopWatch 不斷在 WorkLoop 開始時更新的毫秒時間戳:
// Envir.cs - WorkLoop try { while (Running) { Time = Stopwatch.ElapsedMilliseconds; // ... } // ... }
在服務端啟動過程中,MapRespawn.RespawnTime 和 MapRespawn.NextSpawnTick 都是 0(對於高版本的 DB,服務端也可以選擇在 Shutdown 時將這兩個數據序列化,並在下次啟動時恢復),因此每個刷怪點都會觸發一次刷新邏輯,即調用到 MapRespawn.Spawn。
刷怪的邏輯非常簡單直接,主要包括以下幾個步驟:
  1. 根據 MapRespawn 綁定的 MonterInfo,調用工廠方法 MonsterObject.GetMonster 創建 MonsterObject;
  2. 調用 MonsterObject 的 Spawn 方法,在隨機的點位嘗試創建 1 只怪物,最多重試 10 次;
  3. 將新創建的怪物添加到地圖中,並刷新怪物狀態。
// Map.cs - MapRespawn public bool Spawn() { MonsterObject ob = MonsterObject.GetMonster(Monster); if (ob == null) return true; return ob.Spawn(this); } // MonsterObject.cs public static MonsterObject GetMonster(MonsterInfo info) { if (info == null) return null; switch (info.AI) { case 1: case 2: return new Deer(info); case 3: return new Tree(info); // ... } } public bool Spawn(MapRespawn respawn) { Respawn = respawn; if (Respawn.Map == null) return false; for (int i = 0; i < 10; i++) { CurrentLocation = new Point(Respawn.Info.Location.X + Envir.Random.Next(-Respawn.Info.Spread, Respawn.Info.Spread + 1), Respawn.Info.Location.Y + Envir.Random.Next(-Respawn.Info.Spread, Respawn.Info.Spread + 1)); if (!respawn.Map.ValidPoint(CurrentLocation)) continue; respawn.Map.AddObject(this); CurrentMap = respawn.Map; if (Respawn.Route.Count > 0) Route.AddRange(Respawn.Route); RefreshAll(); SetHP(MaxHP); Spawned(); Respawn.Count++; respawn.Map.MonsterCount++; Envir.MonsterCount++; return true; } return false; }
這裡再補充一點,上述基於 Timer 和 Tick 的刷怪點有哪些不同呢?通過分析代碼我們可以看到基於 Tick 的模式在特定配置下,隨著玩家數量的增加可以動態縮短 Tick 間隔從而提升刷怪速度:
// RespawnTimer.cs public void GetTickSpeed() { if (LastUsercount == Envir.PlayerCount) return; LastUsercount = Envir.PlayerCount; double bonus = 1.0; foreach (RespawnTickOption Option in Respawn) { if (Option.UserCount <= LastUsercount) bonus = Math.Min(bonus, Option.DelayLoss); } CurrentDelay = (long)Math.Round((BaseSpawnRate * 60000) * bonus); } ​ public void Process() { //by always rechecking tickspeed we reduce the chance of having respawns get silly on situations where usercount goes up or down fast (like say after a server reboot) GetTickSpeed(); ​ if (Envir.Time >= (LastTick + CurrentDelay)) { CurrentTickcounter++; if (CurrentTickcounter == long.MaxValue) //by using long instead of ulong here you basicaly have a huge safe zone on the respawn ticks of mobs { CurrentTickcounter = 0; } LastTick = Envir.Time; } }

玩家上線與下線

在第二篇文章中我們講到服務端在與客戶端完成鑑權、角色選擇後會在收到 C.StartGame 的數據包後創建 PlayerObject,並調用 PlayerObject.StartGame,在這個過程中,角色會被添加到特定地圖的特定位置:
// PlayerObject.cs public void StartGame() { Map temp = Envir.GetMap(CurrentMapIndex); ​ if (temp != null && temp.Info.NoReconnect) { Map temp1 = Envir.GetMapByNameAndInstance(temp.Info.NoReconnectMap); if (temp1 != null) { temp = temp1; CurrentLocation = GetRandomPoint(40, 0, temp); } } ​ if (temp == null || !temp.ValidPoint(CurrentLocation)) { temp = Envir.GetMap(BindMapIndex); ​ if (temp == null || !temp.ValidPoint(BindLocation)) { SetBind(); temp = Envir.GetMap(BindMapIndex); ​ if (temp == null || !temp.ValidPoint(BindLocation)) { StartGameFailed(); return; } } CurrentMapIndex = BindMapIndex; CurrentLocation = BindLocation; } temp.AddObject(this); CurrentMap = temp; Envir.Players.Add(this); // ... }
相應地,在玩家斷開連接後會調用 StopGame 從地圖上移除:
// PlayerObject.cs public void StopGame(byte reason) { // ... Envir.Players.Remove(this); CurrentMap.RemoveObject(this); Despawn(); // ... }

下一步

到這裡我們已經分析完了地圖的加載和基礎的地圖對象維護邏輯,但其中還有很多細節沒有深入,例如人物和怪物的移動,技能的釋放,物品的掉落等,這些內容會在接下來的文章中一一分析。

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