
当年刚开始用UE4做2D游戏时我一度觉得这东西根本不需要什么计算——精灵摆上去蓝图拖几个变量角色能跑能跳就完事了。直到做跳跃手感时被狠狠地上了一课跳多高、飞多快、空中能不能转向背后全是一堆数值在互相制约随便调一个参数整个手感就崩了。这篇是用UE4制作2D游戏文档系列的第六篇专门聊2D游戏开发里跑不掉的那部分计算坐标系换算、移动与跳跃公式、碰撞判定、点击拾取的坐标链路、相机跟随和镜头震动的数学以及我在调试过程中踩过的坑。适合已经搭好Paper2D基础工程、正准备把角色动作和玩法逻辑做实的朋友。1. 坐标系迁移把UE4的3D空间降维成2D游戏平面1.1 先想清楚游戏平面放在哪个轴系上这是整个计算篇的地基也是我在带项目时最强调的事。UE4的本质是3D引擎我们做2D游戏只是把它限制在一个平面内使用。但这个平面放哪里、怎么约定方向会直接影响所有后续代码移动向量、跳跃的轴、旋转、相机位置、碰撞体方向所有计算都是围绕这个平面展开的。常见选法有两种。第一种是俯视角项目把游戏平面放在Z0的XY平面上。屏幕X轴对应世界X轴屏幕Y轴对应世界Y轴跳跃轴是Z轴。相机的Forward基本对着-Z。这种方案最贴近UE4默认的世界方向习惯很多俯视角模板就是这样的。第二种是横版项目把游戏平面放在XZ平面上Y0屏幕X对应世界X屏幕Y对应世界Z跳跃轴就是世界Z。好处是跳跃方向天然和重力轴一致物理世界里重力的效果可以直接用坏处是相机要绕X轴转90度去看这个平面且碰撞体的默认方向也要跟着调整。我见过的项目里这两种都有人用谈不上谁对谁错但一定要在一开始就把约定写进团队文档里。因为混用带来的后果非常隐蔽某个向量0,0,1在一个平面里是竖直向上在另一个平面里是面向屏幕外两个人的计算结果完全对不上。1.2 向量降维与朝向翻转时的特殊处理在2D游戏计算里我通常把Vector拆成X和Y分量来思考Z只有跳跃时才参与。例如计算两点距离我不会直接Length(FullVector)而是明确取出相对位置取水平分量的Size。这样代码意图更清楚也方便后面做不同类型的地形判断。朝向翻转是另一个容易踩坑的地方。2D角色向左向右走通常不是旋转Actor而是把SpriteComponent的相对Scale设为(-1,1,1)。这个做法很轻量也不容易让Flipbook动画在转向时出现奇怪的旋转插值。但问题是Actor的ForwardVector不会因为Scale翻转而变化GetActorForwardVector永远还是Actor的旋转方向不会告诉你现在精灵朝左还是朝右。所以我的习惯是单独维护一个面向状态变量FacingDirection1或-1所有需要朝前方向的逻辑攻击方向、击退方向、镜头前瞻量都用这个变量去乘投影轴向量而不是依赖Actor旋转。这个细节花不了几分钟但能让后续所有方向相关的计算少很多莫名其妙的Bug。还有一个跟动画相关的计算在Flipbook里播放左右动画时如果Asset本身是朝右绘制的向左走时可以直接翻转Sprite但左右行走的动画帧如果不对称比如手上武器在右边直接翻转会导致手的位置突然穿帮。这时候更好的做法是准备两套动画资源而不是在计算上偷懒。这个虽然不算严格意义的数学问题但它是计算方案选型的一部分——因为在确定翻转方案前你先要算清楚角色的武器偏移量需要不需要跟着变。2. 手感不是玄学移动与跳跃背后的数值关系2.1 移动的加速度、最大速度与阻尼2D角色的移动手感说白了就是三个数达到最大速度要多快、停下来要多快、在空中的反应速度是多少。我常用的参数组是参数说明我的起点值像素/秒MaxWalkSpeed走路最大速度500GroundAcceleration地面加速度1800BrakingDeceleration地面刹车减速度2800AirAccelerationRatio空中加速度倍率0.35Friction地面摩擦0.8这组参数的意思很简单从静止到满速约需要0.28秒500/1800松开摇杆后约0.18秒停下500/2800。玩家按方向键时角色会感觉跟手但又不是瞬间瞬移。很多新人把GroundAcceleration和MaxSpeed当成一个速度上限来控制比如在Tick里给Speed加固定值到了上限就不加了。这种做法的问题在于角色永远以恒定速度移动没有惯性2D动作游戏里玩家会觉得角色像纸片一样飘。正确的做法是让加速度始终存在速度 clamp(速度 加速度 * DeltaTime, 0, MaxSpeed)松开摇杆后速度按BrakingDeceleration衰减到0。要注意的是加速度和速度不是同一个量。调试时最常犯的错就是把加速度当速度用结果角色在地面上每秒疯跑几万像素直接穿墙。2.2 跳跃高度与重力参数的互相制约跳跃手感的核心公式就两个起跳初速度v0 sqrt(2 * G * H)到达最高点时间t sqrt(2H / G)G是重力加速度H是目标跳跃高度。如果你希望角色跳180像素高重力取G1500像素/秒²那么上面算出来v0约等于734像素/秒上升阶段耗时约0.49秒加上自由落体回落完整跳跃周期约0.98秒。这意味着调跳跃手感时先定跳多高和滞空多久两个体验指标再反推初速度和重力这才是正确的顺序。反过来先随便定一个重力然后一点点试通常会陷入跳太高了调重力、重力调了又太飘的循环。可变高度跳跃的计算还会再复杂一点跳跃键松开后对垂直速度乘以一个衰减系数比如0.4同时设置一个最小跳跃高度分支短按时只走最小高度长按时走完整高度。这些值都需要在键按下和抬起两个事件里分别处理不能只在跳跃开始时算一次。2.3 DeltaTime帧率波动下的计算救星这是计算篇里最容易出错、也最影响体验的一个点。在UE4的Tick里写移动逻辑时几乎所有物理计算都要乘上DeltaTime。原因是游戏在不同设备上的帧率不一样60帧每秒和30帧每秒下如果每帧固定加上同样的速度增量后者的移动速度会慢整整一倍。正确写法是速度增量 加速度 * DeltaTime位移增量 当前速度 * DeltaTime蓝图里对应的节点就是Get World Delta Seconds。我的习惯是每帧开头先取一次DeltaTime存为局部变量后面所有计算统一用这个值避免在十几个节点里反复获取这个节点既不高效也不容易保持一致。有一个反直觉的坑如果你用的是引擎自带的CharacterMovement组件的AddMovementInput就不需要手动乘DeltaTime因为组件内部已经处理了。只有当你自己维护Velocity变量时才必须乘。难点就在于这两套逻辑混用时极容易一处乘了一处没乘表现出来就是角色在地面上的移动是变速的、或者说跟物理表现对不上。这个我在第6章会有具体的排错方法。3. 碰撞判定与伤害计算不只OnOverlap那么简单3.1 Hitbox和Hurtbox的几何管理我在系列前面几篇里提过2D动作游戏里的打到没打到本质上是一堆Box组件之间的Overlap检测。有一个专门的Box作为角色的受击框Hurtbox挂在角色的Root或者Capsule上攻击时用一个或多个武器碰撞体Hitbox去和对手的受击框做Overlap。管理这些Box的关键是激活时机攻击框不应该在整个攻击动画播放期间都保持开启否则角色在动画前摇阶段就能打到人体验会非常糟糕。正确的做法是在动画的特定帧通过Notify触发开启攻击框事件然后在下一次Tick或者动画Notify结束时关闭它。用蓝图的话就是在AnimNotify里给碰撞组件设Collision Enabled动画结束后再设Disabled。这里有一些计算攻击框的偏移量怎么定。如果你的角色武器在右手攻击框的中心应该放在(30, 40, 0)这样的位置这些数值通常是资源尺寸/角色体型推出来的要反复实测。用DrawDebugBox把每个攻击框画出来是必须的我几乎每调一次技能都要开着调试画一遍确认攻击框的可见范围和动画帧的表现一致。3.2 伤害结算与暴击计算伤害计算在2D游戏里常被简化但它依然值得好好设计。我项目中使用的结算顺序是基础伤害 攻击力 * 技能倍率 加成伤害 基础伤害 * (1 增伤总和) 减免后伤害 加成伤害 * (1 - 目标减伤) 最终伤害 max(1, round(减免后伤害 * 随机浮动))暴击按照概率在第一步之后额外结算暴击时最终伤害 最终伤害 * 暴击倍率。不要小看这个执行顺序先乘后减、先加后乘同样参数最后体验完全不同。比如角色攻击力100、技能倍率2、敌方减伤30%如果先减后乘伤害是140先乘后减伤害是140这里数值恰好一样但如果再加入固定减伤差异就明显了。我的建议是统一放在一个蓝图函数库里做单次结算不要散落在各个蓝图里。计算频率也要注意伤害结算只在命中事件里做一次不要在Tick里算。除非你要做持续扣血的光环这类效果否则每帧计算伤害是纯浪费而且容易产生重复扣血Bug。3.3 击退方向与受击位移击退计算的常见做法是取攻击者打击点和被击者位置的向量投影到游戏平面去掉垂直轴归一化后乘击退速度。例如横版游戏中角色A从左侧攻击右侧的B打击点在B左侧那么击退方向大概率是(1,0,0)的水平方向。如果你的攻击是按朝向计算的就简单了击退方向 FacingDirection * 击退强度这样永远是朝攻击者的正前方推手感更一致。受击位移要有衰减。否则B会被击退到天涯海角。我的做法是用一个受击速度变量命中时设为初始值Tick里让它朝0指数衰减受击速度 * (1 - 衰减系数 * DeltaTime)位移 受击速度 * DeltaTime。衰减系数我一般取8到12数值越大击退感越脆越小越飘需要结合动画时长来调。4. 点击拾取与坐标换算从屏幕像素到游戏世界4.1 点击位置如何变成2D平面上的点做俯视角2D游戏时玩家点击屏幕你要让角色走到那个点。这一连串计算的源头就是坐标换算。UE4提供的标准节点是Deproject Screen to World它的输入是屏幕坐标输出是一个Ray起点WorldOrigin和射线方向WorldDirection。在3D工程里这一步之后通常要做一次LineTrace找碰撞点在2D平面游戏里因为游戏平面已知比如Z0可以直接用数学求交t -origin.Z / direction.Z 交点 origin direction * t如果direction.Z很接近0说明射线几乎平行于平面这种情况在你的相机垂直向下看时不会发生但相机角度一旦倾斜就必须加个保护判断。蓝图里直接用LineTraceByChannel做射线检测更省事因为引擎帮你把求交做完了但我还是建议理解背后的公式否则遇到相机改动时的坐标偏移问题你会完全不知道从何查起。4.2 相机缩放与屏幕分辨率带来的陷阱这套坐标换算有一个隐蔽坑屏幕坐标在不同相机设置下对应不同的世界坐标。改变OrthoWidth、修改视口尺寸、或者移动相机后之前缓存的计算全部失灵。我的建议是每次需要点击拾取时实时从PlayerController取GetHitResultUnderCursor不要自己缓存鼠标位置对应的世界点。这是最稳的做法因为它自动处理了相机参数和分辨率变化。但如果是移动端多分辨率适配还要关注UIScale和DPI。触摸事件给出的坐标是设备物理像素要转换成与设计分辨率匹配的坐标系才能不再根据屏幕宽高缩放。常见错误就是把屏幕坐标直接除以窗口尺寸做归一化结果在不同比例的设备上点击位置总是偏的。4.3 一个完整的最小拾取流程我可以整理出一个我在2D项目里最常用的最小流程在拥有输入控制的PlayerController里检测鼠标点击或触摸开始。调用GetHitResultUnderCursor必要时指定碰撞通道只对地形和可交互Actor响应。如果命中取Hit Location得到世界坐标下的目标点。用第3章的移动逻辑让角色走向目标点。如果命中到一个可交互对象把目标点换成对象的位置并触发交互。这个流程看起来简单但第2步的碰撞通道设置特别容易踩坑如果可交互对象的碰撞只对Pawn响应你点击它时LineTrace就穿过了角色会走向它背后的地板。解决办法是给可交互对象单独设一个Object Type比如Interactable在LineTrace的Object Type数组里把它加进去。5. 2D相机跟随的数学让镜头成为活的5.1 Lerp阻尼与帧率无关的插值2D游戏的相机跟随不能直接用Camera位置 玩家位置那样镜头会非常僵硬。业界通用的做法是每帧向目标位置插值。蓝图里最常用的节点是Interp To也就是数学上的Lerp传入当前值、目标值、插值速度、DeltaTime。关键是插值速度的单位是每秒向目标靠近多少如果漏传了DeltaTime节点帧率越高相机动得越快在60帧和144帧显示器上会产生完全不同的手感。如果不习惯Interp To用公式也能实现同样的效果alpha 1 - pow(1 - speed, DeltaTime) 当前值 当前值 (目标值 - 当前值) * alpha这个公式的好处是alpha永远在0到1之间永远也不会真的等于目标但视觉上会在0.3到0.5秒内完成稳定跟随。调参时InterpSpeed取10到15是比较跟手又有点缓冲的感觉具体看你想要镜头粘一点还是漂一点。5.2 前瞻量、速度自适应与关卡边界约束玩家面向哪边镜头就往那边多看一点——这是2D动作游戏表现力的重要一环。前瞻量的计算可以简单也可以高级。简单版镜头目标 玩家位置 玩家朝向的方向向量 * LookAheadDistance常量。 高级版LookAheadDistance根据玩家当前速度做自适应。归一化速度 当前速度大小 / 最大速度然后LookAheadDistance Lerp(最小前沿量, 最大前沿量, 归一化速度)这样角色站定不动时镜头居中冲刺时镜头自然往前推看着比固定偏移更有呼吸感。边界约束也属于计算你需要让相机不要移出关卡范围。设关卡的世界范围是(LevelMinX, LevelMaxX) Z0平面上的两个点相机的视口半宽可以这样估算半宽 OrthoWidth / 2 半高 半宽 / 屏幕宽高比那么相机中心的可活动范围就是 clamp(cameraX, LevelMinX 半宽, LevelMaxX - 半宽) clamp(cameraY, LevelMinY 半高, LevelMaxY - 半高)这一步计算常出问题的地方是宽度和高度的方向搞反。我在项目里就遇到过镜头在Y轴方向上能移出边界原因就是半高算成半宽导致死边差了一截。5.3 屏幕震动的实现与衰减公式屏幕震动是我最喜欢的2D表现技巧因为使用简单、效果明显而且背后是一个典型的衰减正弦波shakeOffset 初始强度 * 衰减系数 * sin(频率 * 时间 * 2PI)衰减系数随时间递减做法有两种线性衰减1 - time/duration和指数衰减exp(-rate * time)。我倾向用指数衰减因为前一小段地震感强烈后面迅速归于平静视觉上更自然衰减率取3左右效果不错。具体实施时把shakeOffset加在相机Actor的WorldLocation上而不是加在玩家的目标位置上。很多新手直接改相机相对位置结果镜头的Lerp插值又被这个偏移带偏了镜头一直在抖。还要注意时间要用累计值Tick里这样处理accumulateTime DeltaTime duration剩余 max(0, duration - accumulateTime) 强度 初始强度 * exp(-rate * accumulateTime) * (剩余时间 0 ? 1 : 0)震动的频率一般取30到50Hz看起来比较脆低于20Hz会像低速晃动而不是震动。6. 调试计算问题从看起来怪到定位到具体数字6.1 常见症状与直接原因对照表这是我在实际项目里反复踩过的坑整理成一个办案参考表方便大家遇到问题时对号入座。症状最可能的计算原因排查思路角色忽快忽慢DeltaTime漏乘或重复乘检查Tick里的位移增量是否统一跳跃高度时高时低起跳时变量没复位上一次的垂直速度残留在跳跃入口先清零垂直速度点击拾取点永远偏移相机缩放或分辨率改变后坐标没重新计算不要缓存世界点用Deproject现算攻击偶尔没命中攻击框激活时机与动画帧不匹配DrawDebugBox观察攻击框存在区间镜头在边界抖动Lerp插值目标被边界clamp后又回到了外头先clamp目标再插值角色冲出去很远击退速度没有衰减或加速度当成了速度检查受击速度是否每帧更新我的经验是遇到这类问题不要急着在蓝图里翻来翻去先在纸上把期望值写出来。比如期望角色每秒500像素先在特定位置放置Actor打印它每秒走过的距离确认是否真的是计算原因还是组件配置原因。6.2 DrawDebug把不可见的数值画出来计算调试最直观的手段就是可视化。UE4的Draw Debug Box、Draw Debug Line、Draw Debug Sphere是我在项目里使用频率最高的几个调试节点。画攻击框在攻击框激活的帧里用DrawDebugBox把它打印出来你就能看到实际能打到人的区域和肉眼看到的动画是否重叠。这个检查只需要几行蓝图能省掉成堆的为什么打不到人排查。画移动向量把角色当前位置、上一帧位置、预测下一帧位置三个点用不同颜色的线画出来可以看到速度大小、方向以及插值是否平滑。我见过抖动问题最终就是通过这个方法发现的一目了然比看数值快得多。6.3 蓝图计算里的隐形陷阱类型、截断与重复计算几个特别容易忽略的小问题集中说一下。第一整数除法。蓝图里的整数除法会直接截断小数1/2 0不是0.5。如果要做百分比计算必须让你的类型是Float。新手经常在伤害计算里用整数做除法结果暴击倍率永远取不出小数。第二变量的精度表现。Vector的每个分量默认是双精度存储但蓝图里显示成小数有时会误导人。当你发现数值看起来对但实际不对时可以在合适的位置用Print String把未截断的小数打出来。第三重复计算。同一个坐标换算、同一个距离计算在Tick里被反复执行对2D游戏来说可能不至于卡但修起来很麻烦而且很容易写错。我习惯把常用计算统一封装成蓝图函数库Blueprint Function Library尤其是坐标换算、跳跃初速度计算、朝向翻转这些基础逻辑。直接在函数库里新建几个继承自Object的纯蓝图函数输入输出全用纯函数这样在任何一行蓝图里都可以直接拖出来用不占执行线逻辑也清晰。全项目约定了用同一套跳跃参数和坐标换算逻辑之后之后每个人写新功能计算部分基本都不用重新造轮子只需要调参数。我自己的小习惯是每一个公式函数里加注释写上这个函数算的是什么、单位是什么、受哪些全局参数影响。几个月后再回来维护你会发现这几个注释能省下大把回忆时间。说到底2D游戏的计算并不难难的是让所有计算在同一个约定体系里协调工作而这恰恰是计算篇最想传递的东西。