1. 从零拆解一个2D横版冒险游戏该从哪里下手
做2D游戏最怕的一件事,就是一上来就打开引擎、新建场景、拖素材,然后卡在“下一步该干嘛”上。我见过太多人兴致勃勃地开了一个项目,三天后文件夹里躺着十几个没关联的PNG和一堆报错。所以这次拿“Ruby's Adventure”这个经典2D案例来聊,我不打算按部就班地念教程,而是把整个项目拆成几个真正需要做决策的节点,告诉你每个节点为什么这么选、不这么选会怎样。
Ruby's Adventure本质上是一个俯视角2D动作冒险游戏的原型:一个叫Ruby的角色在地图里移动,可以拾取道具、与场景交互、被敌人伤害并扣血,最终完成某个目标。听起来简单,但它几乎覆盖了2D游戏开发的所有基础系统——角色控制、动画状态机、碰撞检测、物理交互、UI血量显示、敌人AI、场景管理。这也是为什么它被很多教程选作入门案例:麻雀虽小,五脏俱全。
这篇文章适合谁看?如果你已经装好了引擎但不知道一个完整项目该怎么组织,或者你做过一些零散的Demo但从来没把角色移动、动画、碰撞、伤害反馈串成一条线,那这篇内容就是给你写的。我会用从业者的视角,把每个系统的设计意图、参数取值、踩坑点讲清楚,代码和配置都会给到可以直接抄的程度。
先说一个核心判断:2D游戏开发里,90%的bug都出在“坐标系”和“层级关系”上。角色不动、动画不播、碰撞不触发,十有八九不是代码写错了,而是你把它放错了位置或者设错了层级。这个认知会贯穿整篇文章,你先把这句话记住。
2. 项目结构设计与引擎选型的取舍逻辑
2.1 为什么这个案例适合用组件化引擎来做
Ruby's Adventure这种项目,最忌讳用“一个脚本管所有”的方式去写。角色的移动、动画、血量、碰撞响应,如果全塞在一个类里,到第三天你自己都看不懂。所以引擎选型上,优先考虑组件化架构的方案——每个功能是一个独立组件,挂在角色对象上,组件之间通过引用通信。
我实测下来,这类2D原型用Unity的2D模板或者Godot的2D场景系统都很顺手。Unity的优势是资源商店和教程生态成熟,Godot的优势是场景树结构直观、轻量。不管你用哪个,核心思路是一样的:把“Ruby”这个角色拆成若干个职责单一的组件。
具体拆法是这样的:
- 移动组件:只负责读取输入、计算位移、调用物理系统
- 动画组件:只负责根据当前状态切换动画片段
- 生命值组件:只负责存储当前血量、处理扣血和回血逻辑
- 碰撞响应组件:只负责检测碰到了什么、触发对应事件
这样拆的好处是,当你想调整移动手感时,你只需要动移动组件,不会误伤动画逻辑。当你想加一个“受伤闪红”的效果时,你只需要在生命值组件里发一个事件,动画组件去监听就行。
注意:组件之间的通信尽量用事件或信号机制,不要互相直接调用方法。直接调用会让依赖关系变成一张蜘蛛网,后期改一个地方崩三个地方。
2.2 目录结构怎么组织才不会被自己坑
很多人项目做到一半,找一张图片要找五分钟。这不是小事,资源管理混乱会直接拖慢你的迭代速度。我习惯的目录结构是这样的:
Assets/ Art/ Characters/ Ruby/ Sprites/ Animations/ Environment/ UI/ Scripts/ Player/ Enemies/ Systems/ UI/ Prefabs/ Player/ Enemies/ Items/ Scenes/ Audio/ BGM/ SFX/这个结构的关键在于:按功能分而不是按文件类型分。把所有脚本扔一个文件夹里,看起来整齐,实际上你找一个跟玩家相关的脚本要翻遍整个目录。按Player、Enemies、Systems这样分,你脑子里想“我要改玩家跳跃”,手就能直接点到Player文件夹。
还有一个细节:Prefab(预制体)一定要单独管理。Ruby这个角色本身应该是一个Prefab,敌人是Prefab,可拾取的道具也是Prefab。这样你在场景里摆放的时候,改一个Prefab就能同步所有实例,不用一个个改。
2.3 场景划分的粒度控制
Ruby's Adventure这种体量的游戏,场景不需要切得太碎。我的建议是:
- 主菜单场景:一个,负责开始游戏、退出
- 游戏主场景:一个,包含整个关卡地图、角色、敌人、UI
- 测试场景:一个,专门用来调试角色移动和碰撞,不放任何多余东西
为什么测试场景很重要?因为当你在主场景里调角色手感时,场景里可能有几十个物件在跑,帧率波动、日志刷屏,你根本判断不了是角色逻辑有问题还是场景太卡。单独开一个干净的空场景,只放一个角色和一块地板,调到手感满意了再放回主场景。
我踩过的一个坑:曾经在一个堆满装饰物的大场景里调跳跃高度,调了半天觉得重力参数不对,最后发现是场景里有个隐藏的碰撞体卡住了角色。从那以后,调核心手感一律在空场景里做。
3. 角色移动的手感调校与物理参数计算
3.1 输入读取与位移计算的基本框架
角色移动看起来简单,但“手感”这个词背后是一堆参数的博弈。先看最基本的位移逻辑:
// 伪代码示意,适用于组件化引擎 void Update() { Vector2 input = new Vector2( Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical") ); Vector2 direction = input.normalized; Vector2 displacement = direction * moveSpeed * Time.deltaTime; transform.position += (Vector3)displacement; }这段代码能跑,但手感大概率很“飘”。问题出在几个地方:没有加速度、没有摩擦力、没有输入缓冲。真实游戏里,角色从静止到全速不应该是一瞬间的事,从全速到停止也不应该是瞬间归零。
我的做法是引入一个速度变量,用插值来平滑:
Vector2 currentVelocity = Vector2.zero; float acceleration = 60f; // 加速度 float deceleration = 80f; // 减速度 void Update() { Vector2 targetVelocity = input.normalized * moveSpeed; float rate = (targetVelocity.magnitude > 0.01f) ? acceleration : deceleration; currentVelocity = Vector2.MoveTowards( currentVelocity, targetVelocity, rate * Time.deltaTime ); transform.position += (Vector3)(currentVelocity * Time.deltaTime); }这里的acceleration和deceleration就是手感的灵魂。加速快、减速慢,角色会显得“滑”;加速慢、减速快,角色会显得“重”。我一般从acceleration = 60、deceleration = 80开始调,这个组合下角色响应及时但不生硬。
3.2 移动速度到底设多少才合适
这是被问得最多的问题之一。答案是:取决于你的角色尺寸和摄像机视野。没有一个万能数值,但有一个计算方法。
假设你的角色精灵是32x32像素,摄像机正交尺寸(Orthographic Size)设为5,屏幕高度就是10个世界单位。如果角色每秒移动5个世界单位,那么它横穿整个屏幕高度需要2秒。这个速度对于俯视角冒险游戏来说偏快,适合动作类;如果是解谜探索类,3左右更舒服。
我的经验值参考:
| 游戏类型 | 角色尺寸 | 摄像机Size | 建议移速 |
|---|---|---|---|
| 快节奏动作 | 32px | 5 | 5-6 |
| 标准冒险 | 32px | 5 | 3.5-4.5 |
| 探索解谜 | 48px | 6 | 2.5-3.5 |
| 潜行类 | 32px | 4 | 2-3 |
注意这个表里的移速单位是“世界单位/秒”。如果你用的是像素完美方案,还需要考虑Pixels Per Unit(PPU)的设置。PPU设为32时,一个32px的精灵正好占1个世界单位。
提示:调移速时把摄像机跟随打开,用真实视角去感受。在Scene视图里看着合适的速度,放到Game视图里往往会偏慢,因为Scene视图可以自由缩放,你的参照系是错的。
3.3 摄像机跟随的平滑处理
摄像机跟随如果硬跟,角色一动画面就跟着抖,玩久了头晕。必须加平滑:
public Transform target; public float smoothTime = 0.15f; private Vector3 velocity = Vector3.zero; void LateUpdate() { Vector3 targetPos = new Vector3( target.position.x, target.position.y, transform.position.z ); transform.position = Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime ); }SmoothDamp比Lerp好的地方在于它不依赖帧率,而且有速度记忆,跟随更自然。smoothTime设0.1到0.2之间比较合适,太小了跟得紧但仍有抖动,太大了角色会跑到画面边缘。
还有一个进阶技巧:摄像机前瞻(Look-ahead)。根据角色移动方向,让摄像机目标点稍微往前偏移一点,这样玩家能看到更多前方的内容。实现方式是在目标位置上加一个偏移量:
Vector3 lookAhead = currentVelocity.normalized * lookAheadDistance; targetPos += lookAhead;lookAheadDistance设0.5到1.0之间,根据移速调整。移速越快,前瞻距离可以适当加大。
4. 动画状态机的搭建与切换逻辑
4.1 状态划分:到底需要几个动画状态
Ruby这个角色,最少需要这几个状态:待机(Idle)、行走(Walk)、受伤(Hurt)、死亡(Death)。如果游戏里有攻击动作,再加一个攻击状态。不要一上来就搞十几个状态,状态越多,切换逻辑越容易出bug。
状态之间的切换规则要明确:
- Idle → Walk:当输入方向不为零时
- Walk → Idle:当输入方向为零且速度接近零时
- Any → Hurt:当收到伤害事件时
- Hurt → Idle/Walk:当受伤动画播放完毕且未死亡时
- Any → Death:当血量归零时
这里的关键是优先级。Hurt和Death的优先级高于Idle和Walk,也就是说,当角色正在行走时受到伤害,应该立即打断行走动画播放受伤动画,而不是等行走动画播完。
4.2 用混合树处理八方向移动动画
俯视角游戏的角色通常有四个或八个方向的行走动画。如果为每个方向单独建一个状态,状态机会变得很臃肿。更好的做法是用混合树(Blend Tree)。
以四方向为例,混合树的两个参数是MoveX和MoveY,取值范围都是-1到1。四个动画片段分别放在对应的位置上:
- 上(0, 1)
- 下(0, -1)
- 左(-1, 0)
- 右(1, 0)
引擎会自动根据参数值在四个动画之间做插值混合。这样你只需要一个“Walk”状态,就能覆盖所有方向的移动动画。
但这里有个坑:混合树在斜向移动时会产生“平均脸”效果。比如同时按右和下,角色会播放一个右下方向的混合动画,但如果你只有四个方向的素材,这个混合结果可能看起来很怪。解决办法是要么做八方向素材,要么在代码里把输入方向吸附到最近的四个方向之一:
// 将输入方向吸附到四方向 Vector2 snapped; if (Mathf.Abs(input.x) > Mathf.Abs(input.y)) { snapped = new Vector2(Mathf.Sign(input.x), 0); } else { snapped = new Vector2(0, Mathf.Sign(input.y)); }这样角色永远只播放四个基本方向的动画,不会出现奇怪的混合结果。
4.3 动画事件的时机控制
动画事件(Animation Event)是在动画片段特定帧上触发回调的机制。Ruby这个案例里,至少有两个地方需要用到:
第一个是受伤动画结束的时机。受伤动画播完后,角色应该恢复到可控制状态。你可以在受伤动画的最后一帧打一个事件,回调里把状态切回Idle。
第二个是脚步声的触发。行走动画的每一帧脚落地时,触发一次脚步声效。这样声音和画面是同步的,比用计时器循环播放自然得多。
注意:动画事件在动画过渡(Transition)期间可能会丢失或重复触发。如果你的状态机有复杂的过渡条件,建议在代码里用计时器兜底,不要完全依赖动画事件。
我踩过的一个坑:受伤动画的事件回调里直接切回了Idle,但如果玩家在受伤期间一直按着移动键,切回Idle后角色会顿一下才开始走。正确的做法是在回调里检查当前输入,如果有输入就直接切到Walk。
5. 碰撞检测与伤害反馈的完整链路
5.1 碰撞体选型:Box、Circle还是Polygon
2D游戏里常用的碰撞体有三种:BoxCollider2D、CircleCollider2D、PolygonCollider2D。选哪种取决于你的精度需求和性能预算。
- BoxCollider2D:性能最好,适合方形物体,比如地板、墙壁、箱子
- CircleCollider2D:性能次之,适合圆形物体,比如角色、子弹、金币
- PolygonCollider2D:性能最差,但精度最高,适合不规则形状
Ruby这个角色,我建议用CircleCollider2D。原因是俯视角游戏里角色通常是一个近似圆形的轮廓,用圆形碰撞体在斜向移动时不会出现“卡角”现象。如果用Box,角色在贴着墙壁斜向移动时,方形的角会卡进墙里,导致移动受阻。
敌人的碰撞体可以用Box或Circle,看敌人形状。可拾取道具用Circle,因为道具通常比较小,圆形碰撞体在旋转时不需要重新计算。
5.2 触发器与碰撞器的区别使用
这是新手最容易混淆的地方。简单说:
- Collider(碰撞器):会产生物理反弹,两个碰撞器碰在一起会互相推开
- Trigger(触发器):不会产生物理反弹,只检测“进入了范围”,然后触发事件
Ruby's Adventure里,哪些用Collider,哪些用Trigger?
| 对象 | 组件类型 | 原因 |
|---|---|---|
| 角色 vs 墙壁 | Collider | 需要阻挡移动 |
| 角色 vs 敌人 | Trigger | 碰到敌人不应该被弹开,而是扣血 |
| 角色 vs 道具 | Trigger | 碰到道具应该拾取,不是被弹开 |
| 敌人 vs 墙壁 | Collider | 敌人也需要被墙壁阻挡 |
| 攻击判定 | Trigger | 攻击范围检测,不产生物理效果 |
这个表建议你直接截图保存,每次新建物件时对照着设。
5.3 伤害反馈的完整事件链
当角色碰到敌人时,从碰撞发生到玩家看到反馈,中间经历了一条完整的事件链。这条链上任何一环断了,玩家就会觉得“打中了没反应”。
完整链路是这样的:
- 碰撞检测:角色的Trigger进入敌人的Trigger范围
- 伤害计算:读取敌人的攻击力,减去角色的防御力(如果有),得到实际伤害值
- 血量扣减:调用生命值组件的
TakeDamage方法 - 无敌帧启动:给角色一个短暂的无敌时间,防止连续扣血
- 视觉反馈:角色精灵闪烁、屏幕震动、伤害数字弹出
- 音效反馈:播放受伤音效
- 动画切换:播放受伤动画
- 死亡判定:如果血量归零,切换到死亡状态
这里面无敌帧是最容易被忽略但最重要的。没有无敌帧,角色碰到敌人时会在几帧内被连续扣血,瞬间暴毙。实现方式很简单:
float invincibleDuration = 1.0f; float invincibleTimer = 0f; public void TakeDamage(int damage) { if (invincibleTimer > 0) return; currentHealth -= damage; invincibleTimer = invincibleDuration; // 触发闪烁、音效、动画等 } void Update() { if (invincibleTimer > 0) { invincibleTimer -= Time.deltaTime; } }invincibleDuration设0.8到1.5秒之间比较合适。太短了玩家还是会连续掉血,太长了游戏变得太简单。
闪烁效果的实现,我习惯用协程或者计时器控制精灵的透明度:
IEnumerator BlinkEffect() { SpriteRenderer sr = GetComponent<SpriteRenderer>(); for (int i = 0; i < 5; i++) { sr.enabled = false; yield return new WaitForSeconds(0.1f); sr.enabled = true; yield return new WaitForSeconds(0.1f); } }注意闪烁期间不要禁用碰撞体,否则角色会掉出地图。只禁用渲染器就够了。
6. 敌人AI的行为设计与状态管理
6.1 最简单的巡逻与追击逻辑
Ruby's Adventure里的敌人不需要太复杂的AI。一个基础的敌人行为循环是:巡逻 → 发现玩家 → 追击 → 攻击 → 丢失目标 → 回到巡逻。
巡逻的实现方式有两种:路径点巡逻和随机游走。路径点巡逻更可控,适合关卡设计;随机游走更自然,适合开放区域。我建议用路径点巡逻,因为你可以精确控制敌人的活动范围,不会出现敌人跑到关卡外面去的情况。
public Transform[] waypoints; private int currentWaypointIndex = 0; public float patrolSpeed = 2f; void Patrol() { Transform target = waypoints[currentWaypointIndex]; transform.position = Vector2.MoveTowards( transform.position, target.position, patrolSpeed * Time.deltaTime ); if (Vector2.Distance(transform.position, target.position) < 0.1f) { currentWaypointIndex = (currentWaypointIndex + 1) % waypoints.Length; } }追击逻辑的核心是检测范围。给敌人一个圆形触发器,当玩家进入范围时,切换到追击状态:
public float detectionRadius = 5f; public float chaseSpeed = 3.5f; void Update() { float distanceToPlayer = Vector2.Distance( transform.position, player.position ); if (distanceToPlayer < detectionRadius) { state = EnemyState.Chase; } else { state = EnemyState.Patrol; } }detectionRadius设5左右比较合理,大约是摄像机视野的一半。这样玩家能看到敌人的时候,敌人也差不多能看到玩家。
6.2 状态切换的边界条件处理
敌人AI最容易出bug的地方是状态切换的边界。比如:
- 玩家在检测范围边缘反复横跳,敌人会在巡逻和追击之间疯狂切换
- 敌人追击到一半,玩家跑出了范围,敌人应该回到巡逻还是继续追一段?
- 敌人回到巡逻状态时,应该回到哪个路径点?
解决第一个问题的方法是加迟滞(Hysteresis):进入追击的范围和退出追击的范围不一样。比如进入范围是5,退出范围是6。这样玩家在5到6之间移动时,敌人不会反复切换状态。
public float enterRadius = 5f; public float exitRadius = 6f; if (state == EnemyState.Patrol && distance < enterRadius) { state = EnemyState.Chase; } else if (state == EnemyState.Chase && distance > exitRadius) { state = EnemyState.Patrol; }解决第二个问题的方法是加一个丢失目标计时器。玩家跑出范围后,敌人继续追2秒,如果2秒内没重新发现玩家,才回到巡逻。
解决第三个问题的方法是记录最近的路径点。敌人回到巡逻时,从离当前位置最近的路径点开始,而不是从头开始。
6.3 敌人之间的碰撞避免
如果场景里有多个敌人,它们追击玩家时可能会挤在一起,看起来像一坨。解决办法是给敌人之间加一个排斥力:
void AvoidOtherEnemies() { Collider2D[] nearby = Physics2D.OverlapCircleAll( transform.position, avoidRadius ); Vector2 separation = Vector2.zero; foreach (var col in nearby) { if (col.gameObject == gameObject) continue; if (!col.CompareTag("Enemy")) continue; Vector2 away = transform.position - col.transform.position; separation += away.normalized / away.magnitude; } transform.position += (Vector3)(separation * avoidForce * Time.deltaTime); }avoidRadius设1.5左右,avoidForce设2到3。这个力不需要太大,只要能让敌人之间保持一点距离就行。
7. UI血量显示与游戏状态管理
7.1 血量UI的三种呈现方式
血量显示看起来是个小功能,但呈现方式直接影响玩家的信息获取效率。常见的三种方式:
第一种是心形图标。每个心代表一点血,扣血时心变暗或消失。优点是直观,缺点是血量多了会占屏幕空间。适合血量少(3到5点)的游戏。
第二种是血条。一条横条,按比例缩短。优点是节省空间,适合血量多的游戏。缺点是不够精确,玩家很难一眼看出具体剩多少血。
第三种是数字。直接显示“75/100”。优点是精确,缺点是缺乏视觉冲击力。
Ruby's Adventure我建议用心形图标,因为这种冒险游戏的节奏偏慢,血量不会太多,心形图标更符合游戏的气质。
实现方式是用一个Horizontal Layout Group排列心形图片,扣血时把对应索引的图片颜色调暗:
public Image[] hearts; public void UpdateHealthUI(int currentHealth) { for (int i = 0; i < hearts.Length; i++) { if (i < currentHealth) { hearts[i].color = Color.white; } else { hearts[i].color = new Color(1, 1, 1, 0.3f); } } }7.2 游戏状态机的设计
一个完整的游戏至少需要这几个状态:游戏中(Playing)、暂停(Paused)、游戏结束(GameOver)、胜利(Victory)。
状态管理的关键是时间缩放(Time Scale)。暂停时把Time.timeScale设为0,所有基于时间的逻辑都会停止。但UI动画通常需要继续播放,所以UI动画要用Time.unscaledDeltaTime。
public void PauseGame() { Time.timeScale = 0; pauseMenu.SetActive(true); } public void ResumeGame() { Time.timeScale = 1; pauseMenu.SetActive(false); }注意:
Time.timeScale = 0时,FixedUpdate不会执行,物理系统会完全停止。如果你的游戏有需要继续运行的物理逻辑,要用Time.fixedDeltaTime手动推进,或者把Time.timeScale设为一个很小的值(如0.01)而不是0。
7.3 场景切换与数据持久化
从主菜单进入游戏场景,或者从游戏场景回到主菜单,需要用到场景加载。但场景加载会销毁当前场景的所有对象,包括你想保留的数据(比如玩家血量、分数)。
解决办法是用不销毁对象(DontDestroyOnLoad):
void Awake() { DontDestroyOnLoad(gameObject); }把需要跨场景保留的数据放在一个单独的GameManager对象上,给它加上DontDestroyOnLoad。这样切换场景时,这个对象不会被销毁,数据得以保留。
但这里有个坑:如果从主菜单多次进入游戏场景,GameManager会重复创建。解决办法是在Awake里检查是否已经存在实例:
private static GameManager instance; void Awake() { if (instance != null && instance != this) { Destroy(gameObject); return; } instance = this; DontDestroyOnLoad(gameObject); }这是单例模式的标准写法,建议直接背下来,几乎所有项目都会用到。
8. 性能优化与发布前的检查清单
8.1 2D游戏最常见的性能瓶颈
2D游戏一般不会遇到太严重的性能问题,但有几个地方如果不注意,帧率会莫名其妙掉下去:
第一个是Overdraw。半透明物体层层叠加,GPU要反复绘制同一片像素。UI、粒子效果、半透明精灵都是Overdraw大户。优化方法是减少不必要的半透明层叠,粒子效果不要铺满全屏。
第二个是Draw Call。每个不同的材质、不同的图集都会产生一次Draw Call。优化方法是把同一类精灵打包到同一个图集(Sprite Atlas)里。Ruby的所有动画帧应该在一个图集里,敌人的在另一个图集里。
第三个是物理检测频率。Physics2D的检测频率在Project Settings里可以调。默认值对大多数2D游戏够用,但如果你的场景里有几百个碰撞体,可以适当降低检测频率。
8.2 发布前的自查清单
在打包发布之前,我习惯过一遍这个清单:
| 检查项 | 检查内容 | 常见问题 |
|---|---|---|
| 分辨率适配 | 在不同分辨率下UI是否错位 | Canvas Scaler没设对 |
| 输入兼容 | 键盘和手柄是否都能操作 | 只测了键盘 |
| 边界情况 | 角色走到地图边缘会怎样 | 掉出地图或卡住 |
| 死亡流程 | 血量归零后是否正常结束 | 角色还能动或UI没更新 |
| 暂停功能 | 暂停后所有逻辑是否停止 | 音效还在播或动画还在跑 |
| 存档读取 | 退出重进后数据是否正确 | 数据丢失或重复 |
| 性能表现 | 最低配置下帧率是否稳定 | 粒子太多或Draw Call过高 |
这个清单里的每一项我都至少踩过一次坑。特别是边界情况,角色走到地图边缘如果没有碰撞体挡住,会直接掉出地图,然后你就看着角色在虚空中无限下落。
8.3 从原型到可玩版本的最后一公里
原型跑通之后,到真正“能玩”还有一段距离。这段距离里最重要的是反馈的丰富度。原型阶段可能只有最基本的移动和碰撞,但可玩版本需要:
- 音效:走路声、拾取声、受伤声、攻击声
- 粒子效果:拾取时的闪光、受伤时的血花、移动时的尘土
- 屏幕效果:受伤时的红屏闪烁、拾取时的短暂定格
- UI动画:血量变化时的缓动、菜单弹出时的缩放
这些东西单个看起来都不起眼,但加起来就是“手感”和“品质感”的来源。我的建议是先把核心玩法跑通,然后花同等的时间去打磨反馈。一个反馈丰富的简单游戏,比一个反馈贫乏的复杂游戏好玩得多。
最后分享一个我在实际项目中的体会:不要等到所有系统都做完才开始测试。每做完一个系统,就把它放到真实场景里跑一遍。角色移动做完,就操控角色走一圈;碰撞做完,就故意去撞墙撞敌人;UI做完,就故意扣血看显示对不对。问题发现得越早,修复成本越低。等到所有系统都堆在一起再测,一个bug可能牵扯到五六个系统的交互,排查起来非常痛苦。