韩版《传奇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