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

推荐订阅源

Recent Announcements
Recent Announcements
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
Scott Helme
Scott Helme
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
L
LINUX DO - 最新话题
S
Security @ Cisco Blogs
Webroot Blog
Webroot Blog
S
Security Affairs
H
Hacker News: Front Page
TaoSecurity Blog
TaoSecurity Blog
W
WeLiveSecurity
G
GRAHAM CLULEY
T
Tenable Blog
Schneier on Security
Schneier on Security
S
Securelist
Cyberwarzone
Cyberwarzone
P
Privacy International News Feed
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Schneier on Security
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recent Commits to openclaw:main
Recent Commits to openclaw:main
O
OpenAI News
N
News and Events Feed by Topic
AWS News Blog
AWS News Blog
C
Cisco Blogs
T
Threat Research - Cisco Blogs
S
Secure Thoughts
大猫的无限游戏
大猫的无限游戏
C
Check Point Blog
The GitHub Blog
The GitHub Blog
G
Google Developers Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
美团技术团队
Martin Fowler
Martin Fowler
Microsoft Security Blog
Microsoft Security Blog
L
LangChain Blog
Apple Machine Learning Research
Apple Machine Learning Research
爱范儿
爱范儿
D
DataBreaches.Net
博客园_首页
MyScale Blog
MyScale Blog
博客园 - 叶小钗
博客园 - 三生石上(FineUI控件)
P
Proofpoint News Feed
J
Java Code Geeks
SecWiki News
SecWiki News
P
Palo Alto Networks Blog
Know Your Adversary
Know Your Adversary
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org

机核

游戏性能旗舰最强之选,一加 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 与用户绑定,地图在不断地被变换到观察空间。

下一步

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