news 2026/10/19 16:42:43

UE4 2D游戏开发中的核心数学:坐标系、移动跳跃与相机计算

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
UE4 2D游戏开发中的核心数学:坐标系、移动跳跃与相机计算

当年刚开始用UE4做2D游戏时,我一度觉得这东西根本不需要什么计算——精灵摆上去,蓝图拖几个变量,角色能跑能跳就完事了。直到做跳跃手感时被狠狠地上了一课:跳多高、飞多快、空中能不能转向,背后全是一堆数值在互相制约,随便调一个参数,整个手感就崩了。这篇是"用UE4制作2D游戏文档"系列的第六篇,专门聊2D游戏开发里跑不掉的那部分计算:坐标系换算、移动与跳跃公式、碰撞判定、点击拾取的坐标链路、相机跟随和镜头震动的数学,以及我在调试过程中踩过的坑。适合已经搭好Paper2D基础工程、正准备把角色动作和玩法逻辑做实的朋友。

1. 坐标系迁移:把UE4的3D空间降维成2D游戏平面

1.1 先想清楚游戏平面放在哪个轴系上

这是整个"计算篇"的地基,也是我在带项目时最强调的事。

UE4的本质是3D引擎,我们做2D游戏只是把它限制在一个平面内使用。但这个"平面"放哪里、怎么约定方向,会直接影响所有后续代码:移动向量、跳跃的轴、旋转、相机位置、碰撞体方向,所有计算都是围绕这个平面展开的。

常见选法有两种。第一种是俯视角项目,把游戏平面放在Z=0的XY平面上。屏幕X轴对应世界X轴,屏幕Y轴对应世界Y轴,跳跃轴是Z轴。相机的Forward基本对着-Z。这种方案最贴近UE4默认的世界方向习惯,很多俯视角模板就是这样的。

第二种是横版项目,把游戏平面放在XZ平面上,Y=0,屏幕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的旋转方向,不会告诉你"现在精灵朝左还是朝右"。

所以我的习惯是单独维护一个面向状态变量FacingDirection(1或-1),所有需要朝前方向的逻辑(攻击方向、击退方向、镜头前瞻量)都用这个变量去乘投影轴向量,而不是依赖Actor旋转。这个细节花不了几分钟,但能让后续所有"方向相关"的计算少很多莫名其妙的Bug。

还有一个跟动画相关的计算:在Flipbook里播放左右动画时,如果Asset本身是"朝右"绘制的,向左走时可以直接翻转Sprite,但左右行走的动画帧如果不对称(比如手上武器在右边),直接翻转会导致手的位置突然穿帮。这时候更好的做法是准备两套动画资源,而不是在计算上偷懒。这个虽然不算严格意义的数学问题,但它是"计算方案选型"的一部分——因为在确定翻转方案前,你先要算清楚角色的武器偏移量需要不需要跟着变。

2. 手感不是玄学:移动与跳跃背后的数值关系

2.1 移动的加速度、最大速度与阻尼

2D角色的移动手感,说白了就是三个数:达到最大速度要多快、停下来要多快、在空中的反应速度是多少。

我常用的参数组是:

参数说明我的起点值(像素/秒)
MaxWalkSpeed走路最大速度500
GroundAcceleration地面加速度1800
BrakingDeceleration地面刹车减速度2800
AirAccelerationRatio空中加速度倍率0.35
Friction地面摩擦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像素高,重力取G=1500像素/秒²,那么上面算出来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平面游戏里,因为游戏平面已知(比如Z=0),可以直接用数学求交:

t = -origin.Z / direction.Z 交点 = origin + direction * t

如果direction.Z很接近0,说明射线几乎平行于平面,这种情况在你的相机垂直向下看时不会发生,但相机角度一旦倾斜,就必须加个保护判断。蓝图里直接用LineTraceByChannel做射线检测更省事,因为引擎帮你把求交做完了,但我还是建议理解背后的公式,否则遇到相机改动时的坐标偏移问题,你会完全不知道从何查起。

4.2 相机缩放与屏幕分辨率带来的陷阱

这套坐标换算有一个隐蔽坑:屏幕坐标在不同相机设置下对应不同的世界坐标。改变OrthoWidth、修改视口尺寸、或者移动相机后,之前缓存的计算全部失灵。

我的建议是:每次需要点击拾取时,实时从PlayerController取GetHitResultUnderCursor,不要自己缓存"鼠标位置对应的世界点"。这是最稳的做法,因为它自动处理了相机参数和分辨率变化。

但如果是移动端多分辨率适配,还要关注UIScale和DPI。触摸事件给出的坐标是设备物理像素,要转换成与设计分辨率匹配的坐标系,才能不再根据屏幕宽高缩放。常见错误就是把屏幕坐标直接除以窗口尺寸做归一化,结果在不同比例的设备上点击位置总是偏的。

4.3 一个完整的最小拾取流程

我可以整理出一个我在2D项目里最常用的最小流程:

  1. 在拥有输入控制的PlayerController里检测鼠标点击(或触摸开始)。
  2. 调用GetHitResultUnderCursor(必要时指定碰撞通道,只对地形和可交互Actor响应)。
  3. 如果命中,取Hit Location,得到世界坐标下的目标点。
  4. 用第3章的移动逻辑让角色走向目标点。
  5. 如果命中到一个可交互对象,把目标点换成对象的位置,并触发交互。

这个流程看起来简单,但第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) Z=0平面上的两个点,相机的视口半宽可以这样估算:

半宽 = 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游戏的计算并不难,难的是让所有计算在同一个约定体系里协调工作,而这恰恰是"计算篇"最想传递的东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 16:40:48

LLM Agent驱动的Git原生代码审查CLI工具

1. 项目概述:这不是又一个代码审查工具,而是一次开发工作流的底层重定义“open-code-review”这个名称乍看平平无奇,甚至有点像某个被遗忘在 GitHub 某个角落的冷门仓库名。但如果你最近刷过技术社区、看过几篇关于 LLM Agent 的实操笔记&…

作者头像 李华
网站建设 2026/10/19 16:40:11

斑马打印机驱动安装与标签对位详解

简介:这套斑马打印机驱动资源包面向零售、物流、医疗、制造等行业的条码标签打印场景,提供Zebra GX430t、ZM400、ZD620等多型号驱动文件,可解决打印机与操作系统间的通信、驱动安装及打印配置问题。包体共230个文件,约2.43MB&…

作者头像 李华
网站建设 2026/10/19 16:39:55

用代码自动生成电影预告片:从场景检测到音频卡点的工程实践

我最初接触“用代码生成电影预告片”这个想法,是在一次项目交付周期被压缩到几乎不合理的极限时。当你的手头堆着几十段零碎的素材,需要在几个小时内剪出一版节奏够快、情绪能立起来的预告片,传统剪辑软件里的逐帧精修操作,就会变…

作者头像 李华
网站建设 2026/10/19 16:38:03

Unity多层血条完整实现:从数据模型到特效与性能优化

做血条这件事,看着简单,真正放进战斗系统里才发现坑不少。多层血条更是这样——Boss三阶段要换血条、玩家有护盾要同时显示护盾和生命两层、动作游戏还喜欢做一条延迟虚血条来表现受伤“回不去”的感觉。我最早只是投机取巧把三根Image叠在一起&#xff…

作者头像 李华
网站建设 2026/10/19 16:37:12

ShardingSphere-JDBC事务与分片路由深度解析

简介:本资源是一个基于SpringBoot、ShardingJDBC与MyBatis的轻量级分库分表实战项目,面向Java后端中级开发者及分布式数据库学习者,聚焦高并发场景下单库性能瓶颈的工程化解决方案。压缩包共15个文件(61KB)&#xff0c…

作者头像 李华
网站建设 2026/10/19 16:35:57

本地生活商家入驻好呗生活平台:从价值解析到避坑实操

在本地生活服务这条赛道上摸爬滚打这些年,我亲眼见证了不少商家从纯线下等客到线上平台获客的转变过程。最近被问得特别多的问题是"好呗生活平台到底值不值得入驻",尤其是那些已经在点评、外卖、团购上吃过甜头或踩过坑的老板,他们…

作者头像 李华