该专题将会分析 LOMCN 基于韩版传奇 2,使用 .NET 重写的传奇源码(服务端 + 客户端),分析数据交互、状态管理和客户端渲染等技术,此外笔者还会分享将客户端部分移植到 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();
}
下面我们以角色移动为例分析行为系统的工作过程,在网络游戏中,角色移动是一个较为复杂的逻辑,其核心逻辑包括:
角色在地图上的移动需要与帧动画保持同步,需要将实际的位移按照动画帧进行插值;
需要限制客户端的移动频率,避免服务端和客户端的位置产生极大偏差,这一频率还应当跟随网络延迟进行变化;
当客户端与服务端的角色位置不同步时,需要将客户端位置与服务单进行强制同步。
通过上文对行为系统的分析我们知道,当用户通过鼠标触发移动时,会将一个 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;
}
当服务端接收到 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,下面我们分别进行解释:
Movement 在 Walking Action 完成之前将一直保持移动前的位置,即 CurrentLocation 回退到移动前的位置,在 Walking Action 完成后才会被更新为 CurrentLocation;
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 与用户绑定,地图在不断地被变换到观察空间。
到这里我们已经分析清楚了地图对象的行为系统和行为处理流程,接下来我们将深入到对象的攻击、技能等交互逻辑中进行分析。
评论区
共 条评论热门最新