惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

aimingoo的专栏
aimingoo的专栏
S
Securelist
博客园 - Franky
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
IT之家
IT之家
GbyAI
GbyAI
Microsoft Azure Blog
Microsoft Azure Blog
The Cloudflare Blog
云风的 BLOG
云风的 BLOG
N
News and Events Feed by Topic
AI
AI
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Schneier on Security
Schneier on Security
Attack and Defense Labs
Attack and Defense Labs
Vercel News
Vercel News
腾讯CDC
Google DeepMind News
Google DeepMind News
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
M
MIT News - Artificial intelligence
WordPress大学
WordPress大学
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
N
Netflix TechBlog - Medium
量子位
S
Schneier on Security
Hacker News: Ask HN
Hacker News: Ask HN
Cyberwarzone
Cyberwarzone
S
Security Affairs
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
N
News and Events Feed by Topic
T
Tenable Blog
PCI Perspectives
PCI Perspectives
MyScale Blog
MyScale Blog
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cyber Attacks, Cyber Crime and Cyber Security
W
WeLiveSecurity
N
News | PayPal Newsroom
P
Proofpoint News Feed
O
OpenAI News
C
CERT Recently Published Vulnerability Notes
B
Blog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
V
Visual Studio Blog
MongoDB | Blog
MongoDB | Blog
大猫的无限游戏
大猫的无限游戏
A
Arctic Wolf
Y
Y Combinator Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Spread Privacy
Spread Privacy

机核

游戏性能旗舰最强之选,一加 Ace 6 至尊版国补到手价2999元起 6元钱自己更换电动车刹车线 《生化危机9:安魂曲》编剧Haris Orkin专访 摸金游戏?音乐游戏!暗区新赛季这把能弹的琴有何来历? 好评国产武侠SRPG《息风谷战略》免费DLC现已推出 | 机核 GCORES 碎片 《生化危机:安魂曲》将于5月实装首个免费更新内容 | 机核 GCORES 新锐东方游戏,谱写世界新章! 沉浸式恋爱视觉小说游戏《心象演算》免费试玩版现已正式上线 | 机核 GCORES 互动影游《代号三国:龙起》上线!穿越三国与曹操并肩、与佳人同行、与权谋博弈! 《老头收集梦想生活》,游戏酒桌会6,录音笔VOL.689 | 机核 GCORES 破界·共生——《白日梦:无限世界》五大核心游戏特点解析 价格已到史低,锐龙5 9600X/锐龙7 9700X正适合抄底 时间循环之旅即刻启程!《归环》一周目测试今日开启 热门在线韩游变魂游,洛奇衍生作能否打破“花瓶”魔咒? LG UltraGear evo 全新高端显示器系列:当“5K”遇见“AI”,不止强大,更懂热爱 反套路三国互动影游《代号三国:龙起》今日上线! 愿望单登记人数突破10万!备受瞩目的“女儿养成游戏” 《梦幻魔法公主》今日于Steam平台上线!限时八折优惠中 《黑神话:悟空》全球音乐会2026巡演将于4月29日12时开票 | 机核 GCORES “Snowguelike”生存肉鸽挖矿新作《蛙穿雪境》公布发售日期,5月7日正式上线 | 机核 GCORES 重塑移动办公、AI创作新境!全新华硕灵耀Air系列、ProArt 骁龙版震撼首发,创芯未来 均分88:《Saros》媒体评分汇总 | 机核 GCORES 上海烛龙公布合作遗迹探险游戏《吉时已到》首支预告片 | 机核 GCORES 首个独立游戏《萝薇日记》已上线Steam! 烛龙新IP《吉时已到》首曝,打造国内首款中式合作遗迹探险游戏 喜加一:《暗黑破坏神Ⅳ》国服现已开启限时免费领取本体活动 | 机核 GCORES 新版《生化危机》电影定于9月18日上映,官方网站现已上线 | 机核 GCORES 《冲就完事模拟器2》“星球大战”联动DLC正式宣布 | 机核 GCORES SteamController将于5月4日发售,售价99美元 | 机核 GCORES 事已至此,内存这么用也算省钱了,“2+1”非对称双通道应用实测 505游戏母公司现已收购《明末:渊虚之羽》IP | 机核 GCORES 基石 手动杂谈12|格斗游戏也能讲好故事 | 机核 GCORES I Love You Mr Snowball 我的向日葵小姐 会比GTA6先发售吗?最硬核的生存游戏《DAYZ》要推新DLC了 周记02:在2026遇到新怪谈 什么硬件,能让游戏Loading界面快速消失? | 机核 GCORES GadioSpec《百年风云世界杯》免费试听集 | 机核 GCORES 百年风云世界杯Vol.1丨足球崛起 | 机核 GCORES 100年前,一群人提前替我们经历了AI恐惧 | 机核 GCORES 《呼啸山庄》2026 ——一辆当代艺术的大卡车冲撞了我的大脑 手游《天穗之咲稻姬:日之香巡灵传》宣布将于7月27日停服 | 机核 GCORES 集结梦之队,征战世界杯,《最佳球会ONLINE》上线Steam 山水绝景随心拼 休闲建造游戏《千里山河录》Steam商店页公开 巫师帽、法袍、魔杖,为什么它们是影视、游戏里的法师必备三件套? 《无鞘信使》-第一章 复古风自动战斗肉鸽《终结之终结》Steam商店页面现已上线 电脑里有一款不破不立的MMO,录音笔VOL.688 | 机核 GCORES 《生化危机》30周年纪念周边发售,这次是真的“保护伞” 可靠耐用+AI全能,惠普战66 2025锐龙版深度体验 《时之书:无尽终章》关卡“大航海时代”全球首次公开 经典名作《乌龙派出所》改编经营模拟游戏《乌龙派出所~阿两的商店街物语~》正式宣布支持简体中文 明日开冲,解锁反套路三国互动影游《代号三国:龙起》即将上线 独立游戏《这是我的宝藏!》已发售~ 降低难度不是唯一解,无压力死亡也是好体验 Netflix官宣新片《普通人》:讲述韩国现代史上的权力风暴 《生化危机:安魂曲》全球销量现已突破700万份 《绝地鸭卫》PC版5月15日正式发售 亡妻回忆录?女性向情感叙事游戏《S-mail》现已正式发售 全新酷黑风格,酷睿Ultra 200S PLUS的高性价比搭档!七彩虹BATTLE-AX B860M-PLUS S WIFI7 V20 超级黑刃主板测评 胖狗 索尼发布了港台地区PS5产品价格调整公告,将于5月1日起实施 二次元怪猎+性感美女!《碧蓝幻想:无尽黄昏》开启Beta公测 这款怪谈类型中式恐怖游戏居然更新了?! 《百日战纪 -最终防卫学园-》改编漫画将于今年冬季开启连载 《如被附身,请致电我们》:匈牙利黑色幽默恐怖小说 《竹屿山房杂部卷五》(译文) 大树 超越引擎 摄影分享丨四月 碎片杂记vol.72 当老式FPS与老式动画技术碰撞出新时代的火花—《神探杰克鼠》 想做独游,如何避免首个项目就褒姒? 在线多人动作游戏《OCTOPinbs》将于5月12日上线Steam! 《酒鬼女神的酒诡》确定将于2026年登陆Steam 《CRYMELIGHT》将于11月5日(周四)正式发售!4月25日开始预购! 钢铁国度MKI部落Evolution,蛮兵部分 【钢铁国度】部落-Primal MK I,蛮兵部分 这是一个高中生用ai跑出来的作品,我自称他为物理神话 钢铁国度MKI部落Metamorphosis,蛮兵部分上 钢铁国度MKI部落Metamorphosis,蛮兵部分下 INDIE Live Expo于4月25日举办:首发9款新作,超200款独立游戏亮相 暗黑卡牌策略新作《魔忌:穷鼠啮狸》发布全新中文试玩版和发售预告片,4月30日正式上线在即 战锤40K长篇小说:变节者・苦难主宰(三)(全书完) 战锤40K长篇小说:变节者・苦难主宰(二) 战锤40K长篇小说:变节者・苦难主宰(一) 英语语言学习丨短语专题1:短语的特点 唯一的EVE战争 【少前同人】【M200】战术人形会梦见音乐会吗 【昏迷3】即将发售,前作主角悉数到场!“恶灵”宋老师化身可操控角色 业内人士:游戏公司“十有八九”使用生成式AI,包括卡普空 欢庆一周年:《光与影:33号远征队》全新纪念艺术图、超值折扣与免费更新同步上线 才刚刚开始呢 【抽奖】《星际卡车司机》推出免费大型更新,四折平史低折扣进行中 四人合作FPS游戏《佣兵猎手》抢先体验重大更新 1 现已上线 Raw Fury新作《深馅地牢 Deep Dish Dungeon》将于今秋加入 XGP 塔防幸存者游戏《魔怪来袭》推出首个 DLC 《饿狼传说:群狼之城》1周年纪念!新DLC“沃尔夫冈·克劳萨”今日参战 猫狗相伴 欢乐闯关 双人合作平台跳跃游戏《猫狗同行》上线Steam商店页 《同行:月球逃脱》(Together: Moon Escape)上线将于明日上线Steam
韩版《传奇 2》源码分析与 Unity 重制(五)地图对象的行为处理
Sou1gh0st · 2023-05-14 · via 机核

专题介绍

该专题将会分析 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(); }

角色移动的处理

下面我们以角色移动为例分析行为系统的工作过程,在网络游戏中,角色移动是一个较为复杂的逻辑,其核心逻辑包括:

  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 与用户绑定,地图在不断地被变换到观察空间。

下一步

到这里我们已经分析清楚了地图对象的行为系统和行为处理流程,接下来我们将深入到对象的攻击、技能等交互逻辑中进行分析。