资讯详情

Unity3D横版过关游戏开发:从角色控制器到对象池的完整实践

📅 2026/9/19 9:47:05 | 华诺云谱 👁 阅读
Unity3D横版过关游戏开发:从角色控制器到对象池的完整实践
简介一份基于Unity3D的横版过关游戏设计与实现毕业设计论文资源适合游戏开发初学者、独立开发者及高校相关专业学生参考。资源围绕横版跑酷玩法系统梳理了Unity引擎介绍、玩家与功能需求分析、游戏结构设计、关卡系统、跑酷机制、交互反馈及测试优化等核心章节可作为课程设计、毕业设计或同类游戏开发的完整参考模板。资源为单个docx文档整体大小1.87MB包含中英文摘要、目录和章节正文结构清晰便于直接阅读与编辑二次整理。目前已有239人学习适合需要借鉴游戏设计框架、论文写作结构或Unity项目逻辑的读者。通过这份资源既可理解从需求到实现优化的完整流程也能从中提取关卡设计与交互反馈的具体思路为自身项目提供可落地的设计模式与实现要点。1. 横版过关游戏在Unity3D里做什么先把四条线拆开横版过关在 Unity3D 游戏开发里是性价比最高的练手题材控制器、物理、摄像机、关卡事件四条线同时跑每一截都不算难但把它们粘在一起时总在莫名其妙的地方翻车。比如角色跑着跑着被台阶边缘弹飞摄像机在跳跃时把下方空场景拍进来攻击帧用 OnTriggerEnter 检测却等敌人死了才跳伤害。做这个标题要解决的问题不单是“让角色能左右走”而是把输入响应、刚体物理、动画事件和关卡布局整理成一套可复用的横向卷轴骨架。就算只是做一个简单小游戏项目这套骨架也值得按模块拆。这篇文章按我平时搭横版项目的顺序讲先把输入层和角色控制器固定住再安排碰撞层级与战斗判定然后处理摄像机跟随和关卡物件合组最后用对象池、存档和打包设置收尾。适合正在做 2D 平台跳跃、横版动作或者要学习 Unity3D 物理系统的开发者。读完能直接照抄核心脚本也能知道每个参数在什么情况下会捣乱。2. 输入体系与角色控制器先解决“手感”再谈关卡2.1 为什么在Unity3D里要把输入从逻辑里剥出来很多初版横版项目直接在 Update 里写Input.GetAxis(Horizontal)角色能动但后续问题来得很快UI 弹窗时方向键还在操控角色攻击连段里多按一下 A 就会重复触发跳跃换成手柄布局要改四处代码。剥离输入层不是过度设计而是给“手感”留一个调参的入口。Unity3D 官方的新 Input System 包能很好地解决这一堆问题。Package Manager 里搜索 Input System 安装后右键 Create Input Actions 生成.inputactions资产用编辑器把动作画出来。确定横版需要的动作最简只有三个移动、跳跃、攻击。我把动作表这样配动作名类型绑定项默认用途MoveValue / Vector2WASD、方向键、左摇杆横向移动与冲刺方向JumpButton空格、A键、手柄南键跳跃与二段跳AttackButtonJ键、X键、手柄北键触发近战攻击配置后勾选 Generate C# ClassUnity3D 会生成强类型访问类把Move.ReadValueVector2()、Jump.performed Callback接到自己代码里。这样 UI 抢输入时可以用inputActions.UI.Enable()切动作表不会再出现弹窗里按空格角色跳起来的低级错误。这是后续所有手感调优的前提输入事件是单次触发而不是每一帧的状态扫描。2.2 用Rigidbody2D的velocity写横版移动核心物理组件选择上我用 Dynamic Rigidbody2D 配 BoxCollider2D不用 Kinematic。原因很简单横版里需要与地面、敌人、机关互相碰撞Dynamic 类型会给到受击击退、被弹飞这类的响应。角色控制代码我一般这样起步// 挂到玩家角色上需要 Rigidbody2D 和 BoxCollider2D 组件 using UnityEngine; using UnityEngine.InputSystem; public class PlayerController : MonoBehaviour { [SerializeField] private float moveSpeed 7f; [SerializeField] private float jumpForce 11f; private PlayerInputActions inputActions; private Rigidbody2D rb; private float moveInput; private bool jumpRequested; private bool isGrounded; private void Awake() { // 构造函数里不要做依赖查找Awake阶段把引用备齐 inputActions new PlayerInputActions(); rb GetComponentRigidbody2D(); } private void OnEnable() { inputActions.Gameplay.Jump.performed OnJumpPerformed; inputActions.Enable(); } private void OnDisable() { inputActions.Gameplay.Jump.performed - OnJumpPerformed; inputActions.Disable(); } private void OnJumpPerformed(InputAction.CallbackContext context) { // performed只会在按下瞬间触发一次避免Update里连读按键导致跳两下 jumpRequested true; } private void Update() { // 移动值在Update读存到字段里等FixedUpdate消费 Vector2 move inputActions.Gameplay.Move.ReadValueVector2(); moveInput Mathf.Clamp(move.x, -1f, 1f); } private void FixedUpdate() { // 物理模拟在FixedUpdate里执行直接写velocity比AddForce更容易控制峰值速度 rb.velocity new Vector2(moveInput * moveSpeed, rb.velocity.y); // 跳跃是“二段式”的按键登记一次落地后消耗掉 if (jumpRequested isGrounded) { rb.velocity new Vector2(rb.velocity.x, jumpForce); jumpRequested false; } } private void OnCollisionStay2D(Collision2D collision) { // 简单接地判断后面会换成脚底检测这段写法对单向平台无效 isGrounded true; } private void OnCollisionExit2D(Collision2D collision) { isGrounded false; } }这段代码有两个关键点。第一输入值用字段中转不让 Input System 回调与物理循环纠缠第二jumpRequested在按键按下时置位落地位为 true 时消费。好处是玩家在空中提前按了跳落地会自动起跳俗称跳跃缓冲。这套逻辑已经是商业横版项目里的标配手感之一。只在当前地面判断上有缺陷角色站上单向平台OneWay Platform时OnCollisionExit2D可能误报正确的做法是给脚底加一个Physics2D.OverlapCircle检测点只和 Ground 层碰撞。2.3 可变跳跃高度与双段跳的参数整定如果玩家每次按跳跃键都是同一个抛物线玩五分钟就会觉得硬。横版过关的手感秘密在“可变跳跃高度”按键按住跳得高轻点一下则跳得低而且松开瞬间要快速切断上升力。实现方式是基于jumpCutScale在按键取消时削减垂直速度// 在输入配置里还要监听Jump的canceled事件 private void OnJumpCancelled(InputAction.CallbackContext context) { // 只有当角色仍在上升期时做削减下落中按“砍跳”没有意义 if (rb ! null rb.velocity.y 0f) { rb.velocity new Vector2(rb.velocity.x, rb.velocity.y * jumpCutScale); } } // 动态重力上升和下落用不同重力倍率手感更利落 private void FixedUpdate() { // 比固定gravityScale更自然跳跃上升时轻些下坠时重些 float g rb.velocity.y 0f ? riseGravityScale : fallGravityScale; rb.gravityScale g; }我常用的横版关卡初版参数表跑出来的跳跃手感是“轻点小跳能踩平台长按能越上大台阶”。这组数值是经验值每个项目要按角色像素大小重调参数名建议初值调整方向说明moveSpeed6 ~ 8数值越大跑得越快超过10后空中转向会显得滑jumpForce10 ~ 12决定起跳初速度与riseGravityScale共同决定跳跃高度riseGravityScale2.5 ~ 3.0调大会降低跳跃弧线顶点跳跃变“矮”fallGravityScale4.5 ~ 5.0调大让角色快速落地操作回应更快但空战很难受jumpCutScale0.45 ~ 0.6越小键松后越急速坠落越大则接近“死跳”双段跳就是在 OnJumpPerformed 里加一个跳数计数jumpCount maxAirJumps时允许再次起跳落地时重置jumpCount 0。一个有价值的细节二段跳的初速度通常低于一段跳想做得更高级二段跳采用类似 Rise 的重力梯度跳跃中段的“滞空感”会相对更强。2.4 常见误用直接在transform上移动会踩什么坑新手最爱把横版移动写成transform.position Vector3.right * speed * Time.deltaTime跑起来看着没事可一旦角色需要和地面、箱子、敌人互动就会发生碰撞穿透或卡进墙体。Rigidbody2D 的物理材质有PhysicsMaterial2D通过 friction 和 bounciness 控制斜坡与墙壁上的表现。直接改 transform 会绕过物理系统的连续碰撞检测尤其在高帧率下角色可能直接穿过薄墙。第二个常见误用是在 Update 里给rb.velocity每帧赋新值。物理循环的默认频率约 50Hz渲染帧率通常是 60 或更高二者节奏不匹配会造成抖动的视觉表现。解决办法就是前面代码里的方案Update 里读输入FixedUpdate 里写刚体。记住这一点横版项目的“手感”工作就从玄学变成参数调校。3. 碰撞分层的战斗系统敌人AI、攻击框与受击反馈3.1 碰撞矩阵设计先定Layer再写代码横版过关要靠碰撞分层把敌我交互约束在固定路径上。我一般先把视觉、物理和逻辑层分开规划项目初期就进入 Project Settings Physics 2D Layer Collision Matrix 把矩阵设好。角色和敌人之间不要互相推挤玩家子弹与敌人之间只检测触发器地面与敌人要碰撞但不得穿透。一个实际可用的碰撞矩阵配置如下层名碰撞类型与谁碰撞说明PlayerDynamic Rigidbody2D BoxCollider2DGround、Enemy受击检测用 TriggerGroundStatic / TilemapCollider2DPlayer、Enemy、Boss所有实体都踩在上面EnemyDynamic Rigidbody2D BoxCollider2DGround、PlayerAttack敌人之间不互相碰撞PlayerAttackTrigger ColliderEnemy、可破坏物挂在武器子物体下EnemyAttackTrigger ColliderPlayer敌人攻击框只触发一次Layer Collision Matrix 里要勾掉 Player 与 EnemyAttack 之外的其他无关碰撞。这样做的好处是敌人碰撞体不会推挤玩家角色可以贴着敌人走位玩家的子弹也不会被地面挡下来。Visual Studio 的 Inspector 里给预制体根物体指定层的代码也很少用到直接在 Prefab 的 Layer 下拉框里选择并确认应用到子物体就好。3.2 横版敌人巡逻AI的有限状态机落地写法横版敌人最稳的 AI 是有限状态机状态不用多Patrol巡逻、Chase追击、Attack攻击。重点是切换条件要简单可控。我一般用 OverlapCircle 做玩家探测比频繁的OnTriggerStay2D便宜得多而且探测半径可以调。// 挂到敌人根物体Rigidbody2D设Dynamic并Freeze Rotation public enum EnemyState { Patrol, Chase, Attack } public class PatrolEnemy : MonoBehaviour { [SerializeField] private float patrolSpeed 2f; [SerializeField] private float chaseSpeed 3.5f; [SerializeField] private float detectRadius 1.2f; [SerializeField] private LayerMask playerLayer; private EnemyState state EnemyState.Patrol; private Rigidbody2D rb; private Vector2 direction Vector2.right; private void Awake() { rb GetComponentRigidbody2D(); } private void Update() { // 探测圆心放在敌人面向的前方不必转头就能看到面前玩家 Vector2 detectCenter (Vector2)transform.position direction * detectRadius; Collider2D hit Physics2D.OverlapCircle(detectCenter, detectRadius, playerLayer); if (hit ! null) { state EnemyState.Chase; } else if (state ! EnemyState.Patrol) { // 丢失目标后不原地发呆回到索敌前状态 state EnemyState.Patrol; } } private void FixedUpdate() { switch (state) { case EnemyState.Patrol: // 巡逻匀速直线撞墙后在OnCollisionEnter2D里反向 rb.velocity new Vector2(direction.x * patrolSpeed, rb.velocity.y); break; case EnemyState.Chase: // 追击只改X轴速度保留角色Y轴物理状态 float moveX Mathf.Sign(PlayerPosition().x - transform.position.x); rb.velocity new Vector2(moveX * chaseSpeed, rb.velocity.y); break; } } private void OnCollisionEnter2D(Collision2D collision) { // 撞墙或碰到同层敌人时转向简单横版敌人够用了 direction * -1f; } private Vector2 PlayerPosition() { // 推荐在玩家身上放一个静态访问点避免反复FindObjectOfType return GameObject.FindWithTag(Player).transform.position; } }这个实现里 Patrol 和 Chase 的区别只有速度系数但状态机本身被撑住了想加攻击状态就在switch里加一个 case攻击完成后回到 Chase 或 Patrol。攻击状态切换建议放在Update的检测半径逻辑里当Vector2.Distance(transform.position, playerPos) attackRange时设为Attack改成触发动画事件而不是物理检测避免持续碰撞导致伤害多次判定。3.3 受击反馈闪白、击退与无敌帧横版游戏里玩家被敌人碰到后如果没有受击反馈会感觉像在撞棉花。伤害系统我统一抽象成一个接口玩家和普通敌人都实现它。这样做后续加 Boss、加陷阱都直接用同一套调用// 受击接口所有可受伤对象实现TakeDamage public interface IDamageable { void TakeDamage(int amount, Vector2 knockDir); }玩家侧接伤害时的无敌帧计算仍然靠时间戳不使用Physics2D.IgnoreLayerCollision全局开关因为开关层碰撞会有副作用比如玩家穿过地面后忘记恢复。无敌帧的代码放在 Update 里计时到了时间自然恢复可受击public class PlayerHealth : MonoBehaviour, IDamageable { [SerializeField] private SpriteRenderer spriteRenderer; private float invincibleTimer; private const float INVINCIBLE_TIME 0.5f; public void TakeDamage(int amount, Vector2 knockDir) { // 无敌帧期间直接吞掉伤害不做闪烁也不做击退 if (invincibleTimer 0f) return; invincibleTimer INVINCIBLE_TIME; rb.velocity new Vector2(knockDir.x * 4f, 3f); StartCoroutine(FlashWhite()); } private void Update() { // 无敌帧是严格的时间判断Time.timeScale暂停时也会继续 if (invincibleTimer 0f) { invincibleTimer - Time.unscaledDeltaTime; } } private IEnumerator FlashWhite() { spriteRenderer.color Color.white; yield return new WaitForSeconds(0.08f); spriteRenderer.color Color.white * 0.8f; } }FlashWhite 协程里用两个颜色来回切换会更像闪烁也可用spriteRenderer.material做闪白但那样要复制材质不吃Pelican此开销。击退方向注意要标准化后再乘系数否则斜向碰撞时击退力度会忽大忽小。3.4 攻击帧事件把伤害判定绑定到动画而不是Update横版动作游戏里最不可靠的做法是在 Update 里持续检测攻击区域那样要么伤害 60 帧打满要么攻击判定在攻击动画还没出现时就触发。我把攻击框做在角色的子物体上勾选 Is Trigger并让动画器在特定帧调用事件OnAttackHit// 挂在攻击框子物体上攻击框本身是Trigger public class AttackBox : MonoBehaviour { [SerializeField] private int attackDamage 1; [SerializeField] private Vector2 attackSize new Vector2(0.8f, 0.6f); [SerializeField] private Transform attackPoint; [SerializeField] private LayerMask targetLayer; // 动画事件调用攻击帧显示时触发一次伤害 public void OnAttackHit() { Collider2D[] hits Physics2D.OverlapBoxAll( attackPoint.position, attackSize, 0f, targetLayer); foreach (Collider2D hit in hits) { IDamageable target hit.GetComponentIDamageable(); if (target ! null) { Vector2 dir (hit.transform.position - transform.position).normalized; target.TakeDamage(attackDamage, dir); } } } }OverlapBoxAll的优点是攻击判定只执行一次不会因为 AttackBox 持续停留在敌人身上造成多次伤害。动画事件要把调用点放在真正出拳的那一帧普通攻击一般是动画前 20% 位置。失败时常见表现是伤害出了但攻击动画没播这时去 Animator 窗口里看事件的时间点会出现一个可拖拽的小白点把它落到正确的帧上即可。4. 摄像机跟随与关卡物件合组让横版视角稳下来4.1 SmoothDamp平滑跟随的核心参数横版游戏里镜头晃动会让玩家立刻晕而默认的固定镜头又缺少节奏感。摄像机跟随先用 SmoothDamp 而不是 LerpSmoothDamp 自带速度和加速感知镜头不会出现“永远差一步”的滞后感。核心脚本如下// 挂在主摄像机上目标在Start里找到并赋值 public class CameraFollow : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private float smoothTime 0.18f; [SerializeField] private float aheadDistance 1.5f; [SerializeField] private float minX, maxX, minY, maxY; private Vector3 velocity Vector3.zero; private void LateUpdate() { // LateUpdate在所有Update之后执行此时角色已到达本帧最终位置 float targetX target.position.x target.localScale.x * aheadDistance; float clampedX Mathf.Clamp(targetX, minX, maxX); float clampedY Mathf.Clamp(target.position.y, minY, maxY); Vector3 desired new Vector3(clampedX, clampedY, transform.position.z); // SmoothDamp与Lerp不同它会记录移动速度镜头停止时没有残余抖动 transform.position Vector3.SmoothDamp( transform.position, desired, ref velocity, smoothTime); } }关键参数smoothTime控制镜头到目标点所需时间数值越大跟随越“懒”。我一般设 0.15 到 0.25 之间超过 0.3 观众会感觉镜头和角色脱节。aheadDistance有点讲究它是让镜头看向角色面朝方向的偏移量用target.localScale.x做正负号最省事但前提是翻转角色用 scale 而不是transform.rotation。如果角色用翻转 rotation 的方式需要把符号改成读取localScale.x或rotation.y来修正。4.2 关卡边界的Clamp参数表与标尺物体限制摄像机不出场景需要知道四个边界值。手填数值容易在改关卡尺寸后失效我习惯在场景里放两个空物体LeftBound和RightBound在 Inspector 里用 Cube 图标做视觉标尺然后在 Start 把它们的 x 赋值给摄像机// 在CameraFollow里增加编辑器辅助读取标尺物体的值用于Clamp private void Awake() { if (target null) { target GameObject.FindWithTag(Player).transform; } // 关卡标尺物体在Inspector里手动指定避免重复命名问题 if (leftBound ! null) minX leftBound.position.x; if (rightBound ! null) maxX rightBound.position.x; }下面是横版关卡常见关卡的边界参数经验值可在原地作为初始方案参数建议初值说明minX / maxX-8 ~ 8代表一屏宽约 16 个世界单位配合 1920x1080 像素投影比例minY / maxY-4.5 ~ 4.52D 物理世界里高度边界比宽度小一半留出上下跳出空间smoothTime0.15 ~ 0.25越小镜头越硬越大越拖尾aheadDistance1.0 ~ 2.0按敌人密度调整越密集越小否则战斗中镜头边缘漏出空地一个常见的坑摄像机跟随 Waypoint 时如果两个边界值写反了镜头会像抽风一样在边缘来回跳。遇到这种问题把 Mathf.Clamp 的首个参数传Mathf.Min(minX, maxX)、末参传Mathf.Max(minX, maxX)逻辑上就永远不会出错了。4.3 合组与预制体把场景里的零散物件管起来Unity3D 项目做到关卡中期层级面板里会出现几十个散乱的平台、跟斗和炮台。把所有同类物件拖到一个空物体下是最直接的“合组”但更严谨的做法是让合组变成程序可感知的结构。我按Level_01 / Enemies、Level_01 / Platforms、Level_01 / TriggerZones建空物体把预制体实例放进去命名规则统一成类型_序号比如Platform_03、Enemy_01。合组时一个容易忽略的细节是合组只是编辑期整理运行时如果开发者用Instantiate生成敌人它们默认会生成到根层级。要挂到组里需要做一次SetParent操作// 生成敌人时把实例放到容器下方便运行时统一管理例如全部停用或销毁 GameObject enemy Instantiate(enemyPrefab, spawnPos, Quaternion.identity); enemy.transform.SetParent(levelContainer.transform, true);worldPositionStays参数传 true否则生成的敌人位置会被容器坐标系重置常常飞到十万八千里。合组的另一层用法是关卡结束播放结算动画时遍历容器内敌人引用把攻击胶囊体禁用或者把动画切到死亡状态不需要全局找对象。把 Dropdown 列表里的物件按类型合好组后续换武器、换怪物的改动只动容器子物体就行不会碰任何代码。5. 对象池、存档与打包横版项目收尾的三个实用技巧横版游戏里敌人子弹和死亡特效会频繁实例化每帧 Instantiate 和 Destroy 带来大量 GC Alloc帧率表在低端机上能掉到 30 以下。对象池是解决这个问题的标准方案一个简单的队列池就够用// 通用对象池预先生成prewarmCount个实例放入队列 public class SimplePool : MonoBehaviour { [SerializeField] private GameObject prefab; [SerializeField] private int prewarmCount 30; private QueueGameObject pool new QueueGameObject(); private void Awake() { for (int i 0; i prewarmCount; i) { GameObject go Instantiate(prefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Get(Vector2 pos) { GameObject go pool.Count 0 ? pool.Dequeue() : Instantiate(prefab, transform); go.transform.position pos; go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }Release 不要挪到回调外去执行否则同一帧多次 Release 会把同一个对象入队两次。子弹碰到敌人后调用pool.Release(gameObject)让对象返回到队列而不是销毁横版战斗里大量炮台的发射压力就能稳住。存档方面Demo 阶段用 PlayerPrefs 存currentLevel和playerScore即可存档更完整时再用 JSON 序列化类写文件。这里有个横版常见细节存进度时一定要把敌人的击杀状态一并记录否则玩家重进关卡会发现“打死过的怪又活了”体验很断裂。用Dictionarystring, bool按敌人 ID 存历史击杀状态配合对象池的复用就很自然。打包时关注三件事导入设置里要确保 0 号场景就是主菜单或游戏入口Build Settings 勾选 Development Build 会住下调试余量但需要关闭打包自检Scripting Backend 选择 IL2CPP 会带来首次构建变慢的开销但运行时性能和包体积都更好。最重要的是在 Player Settings Other Settings 里把 Active Input Handling 选成 Input System Package (New)否则新输入系统不会生效按键全无响应。改完重启编辑器再进游戏输入就会走通你写的整套回调链。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。