Unity3D横版过关游戏开发:从角色控制器到对象池的完整实践
2026/9/19 9:46:58 网站建设 项目流程

简介:一份基于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# Class,Unity3D 会生成强类型访问类,把Move.ReadValue<Vector2>()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 = GetComponent<Rigidbody2D>(); } 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.ReadValue<Vector2>(); 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受击检测用 Trigger
GroundStatic / 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 = GetComponent<Rigidbody2D>(); } 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.GetComponent<IDamageable>(); if (target != null) { Vector2 dir = (hit.transform.position - transform.position).normalized; target.TakeDamage(attackDamage, dir); } } } }

OverlapBoxAll的优点是攻击判定只执行一次,不会因为 AttackBox 持续停留在敌人身上造成多次伤害。动画事件要把调用点放在真正出拳的那一帧,普通攻击一般是动画前 20% 位置。失败时常见表现是伤害出了但攻击动画没播,这时去 Animator 窗口里看事件的时间点,会出现一个可拖拽的小白点,把它落到正确的帧上即可。

4. 摄像机跟随与关卡物件合组:让横版视角稳下来

4.1 SmoothDamp平滑跟随的核心参数

横版游戏里镜头晃动会让玩家立刻晕,而默认的固定镜头又缺少节奏感。摄像机跟随先用 SmoothDamp 而不是 Lerp,SmoothDamp 自带速度和加速感知,镜头不会出现“永远差一步”的滞后感。核心脚本如下:

// 挂在主摄像机上,目标在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.xrotation.y来修正。

4.2 关卡边界的Clamp参数表与标尺物体

限制摄像机不出场景需要知道四个边界值。手填数值容易在改关卡尺寸后失效,我习惯在场景里放两个空物体LeftBoundRightBound,在 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 / EnemiesLevel_01 / PlatformsLevel_01 / TriggerZones建空物体,把预制体实例放进去,命名规则统一成类型_序号,比如Platform_03Enemy_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 Queue<GameObject> pool = new Queue<GameObject>(); 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 存currentLevelplayerScore即可;存档更完整时再用 JSON 序列化类写文件。这里有个横版常见细节:存进度时一定要把敌人的击杀状态一并记录,否则玩家重进关卡会发现“打死过的怪又活了”,体验很断裂。用Dictionary<string, bool>按敌人 ID 存历史击杀状态,配合对象池的复用就很自然。

打包时关注三件事:导入设置里要确保 0 号场景就是主菜单或游戏入口;Build Settings 勾选 Development Build 会住下调试余量,但需要关闭打包自检;Scripting Backend 选择 IL2CPP 会带来首次构建变慢的开销,但运行时性能和包体积都更好。最重要的是在 Player Settings > Other Settings 里把 Active Input Handling 选成 Input System Package (New),否则新输入系统不会生效,按键全无响应。改完重启编辑器再进游戏,输入就会走通你写的整套回调链。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询