Unity 2D物理游戏开发实战:从核心玩法到跨平台发布 1. 项目概述从“平衡大师”看Unity小游戏的完整闭环最近在整理过往项目时翻出了几年前做的一个小玩意儿——“平衡大师”。这名字听起来挺唬人其实就是一个物理模拟类的休闲小游戏核心玩法就是控制一个平台让上方不断堆积的、形状各异的方块保持平衡坚持得越久分数越高。这类游戏在移动端和网页端都很常见像《Happy Glass》、《堆栈球》的某些变体核心乐趣就在于物理反馈的即时性和“差一点就倒”的紧张感。我之所以把它翻出来重新梳理是因为它麻雀虽小五脏俱全几乎涵盖了Unity 2D游戏开发从零到一、再到打包发布的核心流程非常适合想从“Hello World”迈向“完整项目”的开发者作为练手参考。这个项目最初是为了验证一些Unity物理引擎和输入控制的特性而做的原型后来觉得手感不错就逐步完善了UI、音效、关卡和计分系统。它不追求复杂的画面和庞大的系统而是聚焦于“核心玩法循环”的打磨。对于新手来说通过复现这样一个项目你能清晰地理解一个游戏是如何从无到有被构建起来的场景搭建、脚本编写、物理参数调试、UI交互、资源管理、最终打包。而对于有一定经验的开发者或许能从我的某些实现细节和踩过的坑里找到一些优化思路或避坑指南。接下来我会把这个项目的完整实现思路、关键代码、以及那些教程里不会写的“实战心得”毫无保留地分享出来。2. 核心玩法设计与物理系统解析2.1 玩法规则与游戏循环设计“平衡大师”的规则极其简单但正是这种简单让游戏循环变得清晰而富有粘性。游戏开始时屏幕底部有一个可左右移动的基座平台。屏幕上方会随机生成不同形状方形、长条形、L形等和质量的方块它们受重力作用下落。玩家的唯一操作就是通过鼠标PC端或触摸滑动移动端来控制基座的左右移动用基座去接住下落的方块并将它们尽可能稳定地堆叠起来。游戏的核心循环如下生成阶段游戏按照一定时间间隔或条件从预设的形状库中随机选取一个方块预制体在屏幕顶部随机水平位置生成。操作阶段方块自由落体玩家实时控制基座移动试图让方块落在理想的位置。判定阶段方块与基座或其他已堆叠方块发生碰撞。系统需要实时计算整个堆叠体系的重心和稳定性。循环与成长成功堆叠后分数增加。随着时间或分数增长方块的生成速度可能加快形状可能更复杂质量分布可能更不均匀从而提升难度。失败条件如果任何方块从基座上完全滑落即与基座和所有其他方块失去碰撞接触并坠落出屏幕底部则游戏结束。这个循环的关键在于“物理模拟”的真实性和“操作反馈”的即时性。玩家需要预判方块下落的轨迹和碰撞后的弹跳微操基座进行精细调整。这种“易于上手难于精通”的特质正是休闲小游戏的魅力所在。2.2 Unity 2D物理引擎关键组件与参数实现上述玩法的基石是Unity的2D物理引擎。我们需要用到几个核心组件Rigidbody 2D刚体这是赋予游戏对象物理属性的核心。无论是基座还是下落方块都必须挂载此组件。它负责模拟质量、重力、速度、旋转等物理状态。关键参数Body Type对于基座我们通常设置为Kinematic运动学。这意味着它不受物理力的直接影响如重力、碰撞力其运动完全由脚本控制适合作为玩家直接操控的平台。对于下落方块则设置为Dynamic动态使其完全受物理引擎模拟。Mass质量方块的质量。我们可以为不同方块设置不同的质量质量越大惯性越大越难被推动倒塌时的影响也越大。这是增加游戏策略性的一个点。Linear Drag线性阻尼/Angular Drag角阻尼模拟空气阻力影响物体移动和旋转的衰减速度。适当增加阻尼可以让堆叠更稳定减少过度弹跳。Gravity Scale重力缩放控制该物体所受重力的大小。保持为1即可但如果你想创造“低重力”关卡可以调小此值。Collider 2D碰撞体定义物体的物理形状用于碰撞检测。我们使用BoxCollider 2D盒型碰撞体来匹配方块的矩形形状。关键设置Is Trigger通常不勾选。我们需要的真实的物理碰撞而非触发事件。Material物理材质这是影响“手感”的灵魂参数我们需要创建一个或引用一个Physics Material 2D。Physics Material 2D物理材质它定义了碰撞表面的摩擦力和弹性反弹系数。参数详解Friction摩擦力决定了物体在接触面上移动的难易程度。这是本游戏最重要的参数之一。摩擦力太低方块堆上去就像在冰面上极易滑动倒塌摩擦力太高则堆叠过于稳定缺乏挑战和动态感。我经过大量测试发现将摩擦力的Friction设置在0.4到0.6之间Friction Combine摩擦力合并方式设置为Average平均能取得较好的平衡。Bounciness弹性碰撞后的反弹程度。对于堆叠游戏弹性不宜过高否则方块会蹦蹦跳跳难以控制。我通常将其设置为0.1到0.3Bounce Combine设置为Average模拟有轻微弹性的木质或塑料质感。实操心得物理参数的调试没有银弹必须反复在游戏中测试。我的方法是建立一个“测试场景”用脚本批量生成不同参数的方块进行堆叠快速观察效果。记住微小的参数调整比如摩擦力从0.5改为0.55都可能带来手感上的显著变化。2.3 输入控制方案跨平台适配玩家控制基座移动。我们需要实现一套兼容PC鼠标/键盘和移动端触摸的输入方案。方案选择对于此类需要平滑、连续控制的游戏我推荐使用“位置映射”而非“离散按键”的方式。PC端鼠标将鼠标在屏幕上的水平位置Input.mousePosition.x映射到游戏世界中基座可移动的范围内。移动端触摸获取首个触摸点的水平位移Input.GetTouch(0).deltaPosition.x将其转换为一个速度或位移量叠加到基座当前位置上。代码实现核心思路 我们创建一个PlatformController脚本挂在基座对象上。using UnityEngine; public class PlatformController : MonoBehaviour { public float moveSpeed 10f; // 移动速度用于触摸控制 public float boundaryLeft -2.5f; // 左边界 public float boundaryRight 2.5f; // 右边界 private Camera mainCamera; private float targetX; // 目标X位置用于鼠标平滑移动 void Start() { mainCamera Camera.main; // 初始化目标位置为当前位置 targetX transform.position.x; } void Update() { HandleInput(); ApplyMovement(); } void HandleInput() { #if UNITY_EDITOR || UNITY_STANDALONE // PC/编辑器环境 // 鼠标控制将屏幕鼠标位置转换为世界坐标 Vector3 mouseWorldPos mainCamera.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, 0, mainCamera.nearClipPlane)); // 直接将目标X设置为鼠标的世界X坐标并钳制在边界内 targetX Mathf.Clamp(mouseWorldPos.x, boundaryLeft, boundaryRight); #elif UNITY_IOS || UNITY_ANDROID // 移动端环境 if (Input.touchCount 0) { Touch touch Input.GetTouch(0); if (touch.phase TouchPhase.Moved) { // 触摸控制根据触摸位移计算移动量 // deltaPosition 是上一帧到当前帧的像素位移需要转换为世界单位 float moveAmount touch.deltaPosition.x * moveSpeed * Time.deltaTime; float newX transform.position.x moveAmount; targetX Mathf.Clamp(newX, boundaryLeft, boundaryRight); } } #endif } void ApplyMovement() { // 为了运动平滑使用 Lerp 插值移动。对于 Kinematic 刚体直接修改 position。 Vector3 newPosition transform.position; newPosition.x Mathf.Lerp(newPosition.x, targetX, Time.deltaTime * 10f); // 10是平滑系数可调 transform.position newPosition; } }注意事项边界限制必须使用Mathf.Clamp将基座的位置限制在游戏区域如boundaryLeft和boundaryRight内防止玩家把平台移出屏幕。平滑移动直接瞬间将位置设置为目标位置如鼠标位置会显得生硬。使用Mathf.Lerp进行线性插值可以让移动带有平滑的跟随效果手感更佳。平滑系数需要根据游戏节奏调整。平台编译指令使用#if、#elif、#endif预处理指令来区分不同平台的输入代码确保打包到不同平台时只有对应的代码被编译。3. 核心模块实现与源码详解3.1 方块生成器与形状管理方块不能只靠一个Cube多样的形状是游戏趣味性的来源。我们需要一个BlockSpawner脚本来管理方块的生成。设计思路预制体池在Unity编辑器中预先制作好几种不同形状的方块组合预制体Prefab。例如1x1的小方块、2x1的长条、2x2的大方块、L形块等。每个预制体都已配置好正确的碰撞体和刚体。生成逻辑在固定时间间隔如每隔2秒或当场上方块稳定后从预制体池中随机选择一个在屏幕上方的一个随机水平位置X坐标实例化。难度调节可以通过协程Coroutine控制生成间隔随着游戏进行间隔逐渐缩短。也可以引入权重系统让后期更易出现不规则、难平衡的形状。关键代码BlockSpawner.cs 精简版using System.Collections; using System.Collections.Generic; using UnityEngine; public class BlockSpawner : MonoBehaviour { public GameObject[] blockPrefabs; // 在Inspector中拖入各种方块预制体 public Transform spawnPoint; // 一个位于屏幕上方的空物体作为生成参考点 public float spawnInterval 2f; public float spawnRangeX 3f; // 生成位置的X轴随机范围 private bool isSpawning true; private Coroutine spawningCoroutine; void Start() { StartSpawning(); } public void StartSpawning() { if (spawningCoroutine ! null) StopCoroutine(spawningCoroutine); spawningCoroutine StartCoroutine(SpawnBlocksRoutine()); } public void StopSpawning() { isSpawning false; if (spawningCoroutine ! null) StopCoroutine(spawningCoroutine); } IEnumerator SpawnBlocksRoutine() { // 初始等待让玩家做好准备 yield return new WaitForSeconds(1f); while (isSpawning) { SpawnSingleBlock(); yield return new WaitForSeconds(spawnInterval); // 可选随着时间减少生成间隔增加难度 // spawnInterval Mathf.Max(0.5f, spawnInterval - 0.05f); } } void SpawnSingleBlock() { if (blockPrefabs.Length 0) { Debug.LogError(没有分配方块预制体); return; } // 1. 随机选择预制体 int randomIndex Random.Range(0, blockPrefabs.Length); GameObject blockToSpawn blockPrefabs[randomIndex]; // 2. 计算随机生成位置 float randomX Random.Range(-spawnRangeX, spawnRangeX); Vector3 spawnPosition new Vector3(spawnPoint.position.x randomX, spawnPoint.position.y, spawnPoint.position.z); // 3. 实例化方块 GameObject newBlock Instantiate(blockToSpawn, spawnPosition, Quaternion.identity); // 4. 可选为方块添加一个初始的微小随机旋转增加不确定性 Rigidbody2D rb newBlock.GetComponentRigidbody2D(); if (rb ! null) { float randomTorque Random.Range(-5f, 5f); // 很小的扭矩 rb.AddTorque(randomTorque, ForceMode2D.Impulse); } } }3.2 游戏状态管理与计分系统我们需要一个全局的GameManager来管理游戏状态开始、进行中、结束、分数、以及协调其他模块如生成器、UI控制器。核心职责状态管理定义枚举GameState { Waiting, Playing, GameOver }并控制状态切换。分数计算每当一个方块成功稳定在基座或堆叠体上例如其速度低于某个阈值并持续一小段时间就增加分数。更复杂的规则可以基于堆叠高度、方块大小给予加分。失败判定监听所有动态方块的OnTriggerEnter2D或OnBecameInvisible事件配合一个位于屏幕底部的“死亡区域”碰撞体当有方块坠落时触发游戏结束。UI更新持有对UI文本分数、状态提示的引用并在分数变化或状态改变时更新它们。关键代码GameManager.cs 部分using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class GameManager : MonoBehaviour { public static GameManager Instance; // 单例模式便于访问 public enum GameState { Waiting, Playing, GameOver } public GameState CurrentState { get; private set; } public Text scoreText; public Text gameOverText; public Button restartButton; private int currentScore 0; private ListRigidbody2D activeBlocks new ListRigidbody2D(); public float gameOverYThreshold -6f; // 低于此Y坐标视为坠落 void Awake() { if (Instance null) Instance this; else Destroy(gameObject); } void Start() { SetState(GameState.Waiting); restartButton.onClick.AddListener(RestartGame); restartButton.gameObject.SetActive(false); gameOverText.gameObject.SetActive(false); } void Update() { if (CurrentState GameState.Playing) { CheckForFallenBlocks(); } } public void SetState(GameState newState) { CurrentState newState; switch (newState) { case GameState.Waiting: Time.timeScale 1f; break; case GameState.Playing: currentScore 0; UpdateScoreUI(); FindObjectOfTypeBlockSpawner()?.StartSpawning(); restartButton.gameObject.SetActive(false); gameOverText.gameObject.SetActive(false); break; case GameState.GameOver: Time.timeScale 0f; // 暂停物理模拟游戏定格 gameOverText.text $游戏结束\n最终分数: {currentScore}; gameOverText.gameObject.SetActive(true); restartButton.gameObject.SetActive(true); FindObjectOfTypeBlockSpawner()?.StopSpawning(); break; } } public void AddScore(int points) { if (CurrentState ! GameState.Playing) return; currentScore points; UpdateScoreUI(); } void UpdateScoreUI() { if (scoreText ! null) scoreText.text $分数: {currentScore}; } void CheckForFallenBlocks() { // 这是一个简单的检查实际可能需要更精确的碰撞检测 // 例如检查方块是否与“基座”或任何其他方块完全脱离接触 // 这里简化为检查Y坐标 for (int i activeBlocks.Count - 1; i 0; i--) { if (activeBlocks[i] null) { activeBlocks.RemoveAt(i); continue; } if (activeBlocks[i].position.y gameOverYThreshold) { // 有方块坠落游戏结束 SetState(GameState.GameOver); break; } } } // 方块生成时调用此方法注册 public void RegisterBlock(Rigidbody2D blockRb) { if (!activeBlocks.Contains(blockRb)) activeBlocks.Add(blockRb); } // 方块被销毁时调用此方法注销例如在清理时 public void UnregisterBlock(Rigidbody2D blockRb) { activeBlocks.Remove(blockRb); } public void RestartGame() { // 清理所有现有方块 foreach (var block in activeBlocks.ToArray()) // 使用ToArray避免遍历时修改集合 { if (block ! null) Destroy(block.gameObject); } activeBlocks.Clear(); // 重置状态 SetState(GameState.Playing); } }3.3 稳定性判定与分数奖励逻辑如何判定一个方块“稳定”并给予加分这是一个影响游戏体验的关键细节。我们不能在方块触碰到基座或其它方块的瞬间就加分因为那时它可能还在弹跳或滑动。实现方案 为每个动态方块Block添加一个脚本用于监测自身的稳定状态。using UnityEngine; public class Block : MonoBehaviour { private Rigidbody2D rb; private bool isStable false; private float stableTimer 0f; public float requiredStableTime 0.5f; // 需要保持稳定多久才算数 public int baseScore 10; // 稳定后的基础分数 void Start() { rb GetComponentRigidbody2D(); GameManager.Instance?.RegisterBlock(rb); } void Update() { if (isStable) return; // 已经稳定并加分过了不再检测 // 判断稳定的条件速度非常小几乎静止 if (rb.velocity.magnitude 0.1f Mathf.Abs(rb.angularVelocity) 1f) { stableTimer Time.deltaTime; if (stableTimer requiredStableTime) { Stabilize(); } } else { // 如果还在动重置计时器 stableTimer 0f; } } void Stabilize() { isStable true; // 加分可以根据方块大小、堆叠位置等计算额外分数 GameManager.Instance?.AddScore(baseScore); // 可选改变方块颜色或材质提示玩家它已稳定 // GetComponentSpriteRenderer().color Color.gray; } void OnDestroy() { GameManager.Instance?.UnregisterBlock(rb); } }注意事项参数调优velocity.magnitude和angularVelocity的阈值0.1f和1f需要根据你设定的物理重力、摩擦力等参数进行微调。性能考虑每个动态方块每帧都在进行Update检测。如果后期方块数量非常多上百个可以考虑优化例如只对最近下落的几个方块进行检测或者使用更高效的稳定判定方法如检查是否处于“睡眠”状态但Unity 2D刚体的自动睡眠有时不太可靠。4. 项目构建、优化与发布实战4.1 场景搭建与资源组织一个清晰的项目结构能极大提升开发效率。我的建议如下Assets/ ├── _Scripts/ # 所有C#脚本 │ ├── Managers/ # GameManager, UIManager, AudioManager等 │ ├── Gameplay/ # PlatformController, Block, BlockSpawner等 │ └── Utilities/ # 通用工具类、扩展方法 ├── Prefabs/ # 预制体 │ ├── Blocks/ # 各种方块预制体 │ ├── UI/ # 按钮、面板等UI预制体 │ └── VFX/ # 粒子效果预制体如落地灰尘 ├── Scenes/ # 游戏场景 │ └── Main.unity # 主游戏场景 ├── Art/ # 美术资源 │ ├── Sprites/ # 精灵图片 │ ├── Materials/ # 材质球包括Physics Material 2D │ └── UI/ # UI用到的图集、字体等 ├── Audio/ # 音效和背景音乐 └── Settings/ # 项目设置文件如InputManager, TagManager备份场景搭建步骤创建2D场景设置合适的正交摄像机Orthographic Camera大小确保游戏区域在目标分辨率下显示完整。创建背景、基座Platform、方块生成点SpawnPoint、死亡区域一个带有BoxCollider2D和Rigidbody2D设置为Kinematic且IsTriggertrue的空物体放在屏幕底部以下。搭建UI Canvas包含分数文本、开始按钮、游戏结束提示和重新开始按钮。将编写好的脚本拖拽到对应的游戏对象上并在Inspector面板中配置好公共变量引用如预制体数组、UI文本等。4.2 性能优化与调试技巧即使是一个小游戏性能优化也是好习惯。对象池Object Pooling当前版本中方块不断生成游戏结束后销毁。如果频繁开始/结束游戏会产生GC垃圾回收压力。对于方块这类频繁创建销毁的对象实现一个简单的对象池是很好的优化。思路游戏开始时预先实例化一定数量的方块如20个并禁用它们。需要生成时从池中取出一个可用的启用它方块坠落销毁时不是真的Destroy而是禁用它并放回池中。物理引擎优化固定时间步长Fixed Timestep在Edit - Project Settings - Time中Fixed Timestep默认是0.02s50Hz。对于手机游戏如果物理计算不复杂可以尝试稍微调大如0.04s能减少CPU开销但会降低物理模拟的平滑度需要测试手感。碰撞层Layer Collision Matrix在Edit - Project Settings - Physics 2D中可以设置哪些层Layer之间会发生碰撞。确保只有必要的物体之间才进行碰撞检测。例如所有方块可以放在“Block”层基座放在“Platform”层然后只启用Block与Platform、Block与Block之间的碰撞。调试技巧绘制调试图形在脚本中使用Debug.DrawLine或Debug.DrawRay可以可视化一些关键信息如基座移动边界、生成范围等非常方便。使用Time.timeScale在Update中监听按键如按1键Time.timeScale 0.1f可以慢放游戏仔细观察物理碰撞和运动细节对于调试手感至关重要。4.3 打包发布针对WebGL与移动端WebGL发布构建设置File - Build Settings将场景加入选择WebGL平台点击Switch Platform。播放器设置点击Player Settings。Company Name Product Name填写你的信息。Resolution and PresentationDefault Canvas Width/Height设置为你期望的初始分辨率。WebGL Template可以选择Minimal以减少包体。发布与上传点击Build选择一个输出文件夹。构建完成后你会得到一个包含.html、.js和.data等文件的文件夹。你可以将这个文件夹整个上传到任何静态网站托管服务如GitHub Pages, Netlify上分享。踩坑记录Unity WebGL初始化很久这是WebGL开发者常遇到的问题。主要原因和解决方案资源过大检查Assets/StreamingAssets和Resources文件夹避免在里面存放未压缩的大文件如图片、音频。尽量使用AssetBundle或Addressables进行动态加载。内存设置在Player Settings - Publishing Settings中Compression Format改为Brotli比Gzip压缩率更高。WebGL Memory Size根据你的游戏需要适当调大如256MB但不宜过大。首包优化使用Unity的Managed Stripping Level在Player Settings - Other Settings设置为High或Full可以移除未使用的代码减小构建尺寸。但要注意如果使用了反射可能需要添加link.xml文件来防止必要代码被误删。移动端Android/iOS发布安装对应模块在Unity Hub中为编辑器安装Android Build Support或iOS Build Support。构建设置切换平台到Android或iOS。玩家设置Android需要设置Bundle Identifier如com.YourCompany.BalanceMaster并配置Keystore用于签名。iOS需要Apple开发者账号配置Bundle Identifier和Provisioning Profile。输入适配确保你的输入控制代码如前面的PlatformController已经通过编译指令#if正确处理了触摸输入。UI适配使用Canvas的Canvas Scaler组件设置UI Scale Mode为Scale With Screen Size并指定一个参考分辨率如1920x1080以确保UI在不同屏幕尺寸上都能正确缩放。5. 常见问题排查与进阶优化方向5.1 开发与运行中的典型问题方块堆叠时过度抖动或滑落原因物理材质摩擦力设置过低刚体的Collision Detection模式为Discrete离散检测可能导致快速小物体穿透。解决调高Physics Material 2D的Friction值0.5-0.7。尝试将方块的Rigidbody 2D的Collision Detection模式改为Continuous连续检测但这会消耗更多性能。对于此游戏Discrete通常足够。检查所有碰撞体的形状是否精确匹配精灵图像避免空隙导致接触不稳。游戏在手机上运行卡顿原因Draw Call过高物理计算负担重GC频繁。解决合批Batching确保方块的精灵使用同一张图集Sprite Atlas减少Draw Call。控制方块数量设置一个上限当场上方块超过一定数量如30个时停止生成或开始移除最底部的方块。使用对象池如前所述减少Instantiate和Destroy的调用。性能分析使用Unity ProfilerWindow - Analysis - Profiler连接真机运行查看CPU和GPU的耗时瓶颈。触摸控制不跟手或延迟大原因在Update中直接使用touch.deltaPosition可能受帧率影响平滑移动的Lerp系数不合适。解决确保触摸控制代码在Update而非FixedUpdate中。调整平滑移动的系数。系数越大跟随越快但可能产生“跳变”系数越小延迟感越强。需要在真机上反复测试找到最佳值。考虑使用Input.GetTouch(0).position直接映射位置而非位移增量但需要处理从屏幕坐标到世界坐标的转换。WebGL构建后画面显示不全或错位原因Canvas Scaler设置不当摄像机Size与Canvas分辨率不匹配。解决检查UI Canvas的Canvas Scaler设置。确保游戏主摄像机的Size属性设置正确。对于正交摄像机Size等于屏幕高度的一半世界单位。例如如果你希望游戏区域高度为10个单位则摄像机Size应设为5。在Player Settings - Resolution and Presentation中取消勾选Fullscreen Mode并设置Default Canvas Width/Height。5.2 项目扩展与进阶思路当你完成了基础版本后可以尝试以下方向进行扩展让游戏更具深度和可玩性引入关卡与目标设计不同关卡每个关卡有特定的目标形状需要堆叠出来如金字塔、高塔。增加特殊功能方块如“粘性方块”碰到后粘住不动、“炸弹方块”定时爆炸炸飞周围的方块、“冰方块”摩擦力极低。增强视觉与听觉反馈粒子效果方块落地时播放灰尘粒子稳定时播放一个微光特效。屏幕震动当大质量方块坠落或堆叠体倒塌时触发轻微的摄像机震动Camera.main.transform.ShakePosition。音效为方块碰撞、稳定得分、游戏结束等事件添加对应的音效。使用AudioSource.PlayOneShot播放短音效。数据持久化与社交功能使用PlayerPrefs存储最高分。集成Unity的UnityEngine.SocialAPI实现GameCenter或Google Play Games的排行榜和成就系统。** monetization变现**在UI中加入激励视频广告的按钮观看后可以获得一次“复活”机会或特殊道具。使用Unity Ads或AdMob插件相对容易集成。这个“平衡大师”项目虽然体量不大但它像一颗种子包含了游戏开发中最核心的循环创意、实现、调试、优化、发布。希望这份详细的拆解和源码思路能帮助你更顺畅地走完自己的第一个完整Unity小游戏项目。记住最重要的不是复现我的每一行代码而是理解每个决策背后的“为什么”并动手去调整参数、修改逻辑感受它带来的变化。这才是从项目中学到真东西的唯一途径。