YAOTU INSIGHTS

Unity船类玩法开发:从浮力模拟到划船手感实现

Unity船类玩法开发:从浮力模拟到划船手感实现
在游戏开发里真正让人上瘾的往往不是大场面而是一个玩法从“能跑”变成“好玩”的过程。我最近在做一个偏休闲的开放水域场景玩家可以在湖面自由探索最初只是简单地把角色放在水面上用方向键控制位移结果手感像在冰面上溜冰完全没有“船”的感觉。后来我把船体移动重写了一遍加入了浮力模拟、划水节奏和转向阻力才终于找到了“在湖上划船”那种慢悠悠但可控的体验也顺手把【湖上划水】这个成就做了出来。这篇文章就用这次开发过程完整拆解一个船类玩法在 Unity 里是怎么从零做出来的。先说结论船类玩法的核心难点不是“让物体动起来”而是让物体像船一样动。水面浮力、桨力输入和转向阻尼这三层逻辑缺一不可。如果你只给船体挂一个 Rigidbody 然后用 AddForce 推着走得到的只会是“水上的盒子”不是船。下面我会从核心原理、场景搭建、代码实现到参数调优完整演示如何在 Unity 中实现一个可划行、可转向、可触发成就的湖上小船代码采用通用思路Unity 版本不必过于纠结重点在于理解拆解过程。1. 这篇文章真正要解决的问题很多刚接触 Unity 的开发者会把“小船”理解成“会动的木板”然后在 Update 里写一堆位移逻辑。这种写法有三个直观问题第一运动不真实。船在水面上的运动不是瞬时的它有惯性、有阻尼、有水流扰动。用位置插值做出来的船看起来像是在轨道上滑行而不是在水中起伏。第二代码难以扩展。如果你把操作逻辑堆在 Update 里后面要加成就系统、音效反馈、动画状态就得不断地往里塞 if代码会越来越乱。第三手感和玩法无法区分。不同船的操控感应该不同比如小木船轻快、货船笨重如果没有统一的物理参数和状态机后面做“换船”功能会非常痛苦。从开发流程上看这篇文章要解决的其实是两个层面的问题物理层让船体在湖面上有合理的漂浮、前进、转向表现而不是简单的刚体拖动。玩法层让划船这个动作有节奏感能触发划水动画、桨音效并且能被成就系统识别。所以我在这篇文章里会把所有逻辑拆成三块船体浮力与姿态模拟、划船输入与桨力计算、成就与状态反馈。这样做的好处是你以后做任何水上载具快艇、竹筏、甚至水上摩托都能复用这套框架只需要改参数和输入映射。先说一下适用读者如果你已经能熟练创建 Unity 工程会用 Rigidbody 和 Collider但对“如何做出有手感的操控”还比较模糊这篇文章非常适合你。如果你是想做完整的水面渲染或真实流体模拟这篇文章不会深入因为湖面视觉是用 Unity 自带水面 Shader 解决的物理层只要保证浮力和阻力正确即可。2. 船体移动的核心概念与原理做船类玩法前先要想清楚一个问题船为什么能浮在水面上又为什么能“往前走”这里涉及三个物理概念它们直接决定船的手感。2.1 浮力不是“一个向上的力”而是“重心的稳定”很多人以为给船一个向上的力就行实际上船在水中的稳定来自浮心与重心的相对位置。简单说船入水越深排开的水越多浮力越大当浮力等于重力时船就静止。如果船体倾斜浮心偏移会产生扶正力矩船会慢慢回正。Unity 里做浮力的常见思路是用多个浮力采样点而不是给整个船体一个统一浮力。这样船头、船尾、左右两侧各自感受到不同的入水深度就能模拟出真实的摇摆和起伏。采样的点越多船的物理越稳定但性能也会增加。一般小船 4 个采样点就够用。2.2 推力是“给水的反作用力”不是“给船的直接力”真实划船时桨向后推水水给桨一个向前的反作用力。在游戏里我们把桨力简化成施加在船体上的一个向前的力。但关键在于这个力不应该持续存在而是应该有节奏地“脉冲式”加入否则船会一直加速到很飘。比如说划一下桨船会加速然后因为水阻尼慢慢减速再划一下又加速。这个节奏感是“船”和“盒子”的重要区别。如果你把力写成了每帧都按那船就会像喷射艇一样冲出去完全没有划船的味道。2.3 转向是有阻力的不是“按方向键就掉头”船在水面上转向时船体侧面会排开大量的水因此会产生明显的阻力。这意味着如果你按左键船不会立刻转向而是会先有一个角速度然后因为水阻尼慢慢停下来。只有当你持续按住左键船头才会开始稳定地转过去。所以转向逻辑不能直接修改 localRotation而是应该施加一个角力矩同时加入角阻尼。这个阻尼值调的越大船就越“笨重”适合大船调的越小船就越灵活适合小舟。2.4 为什么要用状态机管理划船状态划船玩法看起来很单调但实际上包含待机、划左桨、划右桨、自由滑行、撞岸停止好几个状态。如果不用状态机直接在输入回调里写动画和音效很容易出现“连续按三下出三遍声音”的混乱情况。我这次用的是 Unity 的 Animator 结合一个轻量级状态脚本把“能否划桨”“是否在划桨”“桨的冷却时间”管理起来。每一桨都有最小间隔避免玩家高频率点击导致手感失控。3. 环境准备与场景搭建在写代码之前先把 Unity 工程和场景准备到位。版本方面不需要特别纠结我用的是 Unity 2021 系列核心逻辑用 C# 实现不依赖任何不需要的插件。如果你用的是 Unity 2020 或 2022也能直接运行。3.1 创建工程与场景打开 Unity Hub新建一个 3D 工程命名为 BoatDemo。创建后在 Project 窗口里新建如下目录结构Assets/ Scenes/ Scripts/ Prefabs/ Models/接着在 Scenes 下新建场景 Main并保存。3.2 搭建湖面场景湖面我用了 Unity 自带的水面 Shader具体做法是在 Hierarchy 窗口右键选择 3D Object - Plane命名为 Lake。将 Plane 的 Scale 调整为 (20, 1, 20)保证湖面足够大。给 Plane 添加一个材质球Shader 选择 Standard并设置一个偏蓝绿的水面颜色。为了有波光效果可以微调金属度和光滑度让它带一点反射感。这里不需要做真实的水面网格动画因为物理浮力采样在代码里做视觉上只需要一个能看过去的湖面即可。如果你追求更好的水面效果后续可以换 URP 下的水面 Shader但不在本次讨论范围。3.3 搭建船体船体模型方面我没有用外部 FBX直接用 Unity 自带的基础几何体拼了一个简易小木船具体结构如下Boat (空物体挂刚体和脚本) Hull (CubeScale 1.2, 0.3, 2.4) LeftSideWall (CubeScale 0.05, 0.3, 2.2) RightSideWall (CubeScale 0.05, 0.3, 2.2) Seat (CubeScale 1.0, 0.1, 0.5) PaddleLeft (CubeScale 0.08, 0.08, 1.2) PaddleRight (CubeScale 0.08, 0.08, 1.2)子物体用 Cube 拼装虽然简陋但对于做玩法原型足够后面可以直接替换成美术模型。关键在于船体父物体的中心最好在吃水线附近不要把中心放在船底否则浮力计算容易出现“船永远在往下翻滚”的问题。3.4 添加物理组件选中 Boat 根物体添加以下组件RigidbodyBox Collider或者 Mesh ColliderRigidbody 参数建议如下Mass: 50 Drag: 1.2 Angular Drag: 2.5 Use Gravity: true Interpolate: Interpolate Collision Detection: ContinuousMass 设为 50 是为了让船有一定的重量感如果太小任何力都会让它飞出去。Drag 和 Angular Drag 是模拟水阻的关键参数后面调手感时会经常修改。Collider 的大小要尽量贴合船体可以稍微比船体大一点避免穿帮。4. 核心流程拆解整个船体玩法从输入到反馈可以拆成六个步骤每一步对应一块逻辑4.1 输入采集玩家通过方向键或 WASD 控制船的方向。我在这里使用的是 Unity 的旧版 Input ManagerGetAxis而不是新 Input System原因是代码简单适合原型阶段。如果你项目里已经用了新 Input System改一下输入映射即可核心逻辑不变。输入采集的目标是输出两个值水平转向输入-1 到 1和垂直划桨输入-1 到 1。4.2 浮力采样与姿态修正在 FixedUpdate 中对船体的 4 个浮力点分别做碰撞检测检测点是否在水中然后施加向上的浮力。同时根据船体的倾斜角度施加恢复力矩。这一步是“像船”的关键如果省略船会显得非常生硬。4.3 划桨力计算当玩家按下前进键时船体不直接获得持续力而是进入一个“划桨冷却”流程。每 0.6 秒可以生成一次向前的脉冲力力的大小和船体当前朝向一致。左右转向的输入同时决定哪一侧的桨在动。比如按住右键就只给船体一个顺时针的角力矩同时触发右侧桨的动画。4.4 阻尼修正Rigidbody 的 Drag 只是线性阻尼实际上船速越快水阻力越大。我会额外写一个速度钳制逻辑保证船速在一个合理范围内。4.5 状态判定与成就触发船体移动后系统检测玩家是否完成了“从岸边出发在湖面上移动超过一定距离”的条件。满足后就触发成就【湖上划水】。4.6 动画与音效回调每一次有效的划桨动作都会通知 Animator 和音频管理器播放对应的划水动画和音效。这个用事件或直接调用都可以关键是不要和物理逻辑强耦合。5. 完整代码实现下面直接给出核心代码。为了便于阅读我把代码分成了几个文件放在 Scripts 目录下。5.1 船体控制器BoatController.cs这个脚本负责整个船体的运动逻辑包括浮力采样、输入读取、划桨力和转向力。using UnityEngine; public class BoatController : MonoBehaviour { [Header(浮力设置)] public Transform[] floatPoints; // 浮力采样点 public float waterHeight 0.0f; // 水面高度 public float floatStrength 20f; // 浮力系数 public float waterDrag 1.2f; // 水中阻力 [Header(划桨设置)] public float paddleForce 18f; // 一次划桨的力 public float paddleCooldown 0.6f; // 两桨之间的最小间隔 public float turnTorque 12f; // 转向力矩 public float angularDragForce 2.5f; // 角阻尼 [Header(速度限制)] public float maxForwardSpeed 8f; public float maxAngularSpeed 1.5f; private Rigidbody rb; private float paddleTimer 0f; void Start() { rb GetComponentRigidbody(); if (floatPoints.Length 0) { // 如果没有指定采样点自动使用四个角 floatPoints new Transform[4]; Vector3 size GetComponentCollider().bounds.size; floatPoints[0] CreateFloatPoint(new Vector3(-size.x * 0.4f, 0, -size.z * 0.4f)); floatPoints[1] CreateFloatPoint(new Vector3(size.x * 0.4f, 0, -size.z * 0.4f)); floatPoints[2] CreateFloatPoint(new Vector3(-size.x * 0.4f, 0, size.z * 0.4f)); floatPoints[3] CreateFloatPoint(new Vector3(size.x * 0.4f, 0, size.z * 0.4f)); } } Transform CreateFloatPoint(Vector3 localPos) { GameObject go new GameObject(FloatPoint); go.transform.SetParent(transform); go.transform.localPosition localPos; return go.transform; } void FixedUpdate() { ApplyBuoyancy(); ApplyWaterDrag(); HandleInput(); ClampSpeed(); paddleTimer - Time.fixedDeltaTime; } void ApplyBuoyancy() { foreach (Transform point in floatPoints) { float difference point.position.y - waterHeight; if (difference 0) { // 采样点在水下施加向上的浮力 Vector3 force -Physics.gravity * (difference * floatStrength); rb.AddForceAtPosition(force, point.position, ForceMode.Force); } } } void ApplyWaterDrag() { rb.linearDamping waterDrag; rb.angularDamping angularDragForce; } void HandleInput() { float vertical Input.GetAxis(Vertical); float horizontal Input.GetAxis(Horizontal); if (Mathf.Abs(horizontal) 0.1f) { // 转向施加绕 Y 轴的力 Vector3 torque Vector3.up * horizontal * turnTorque; rb.AddTorque(torque, ForceMode.Force); } if (vertical 0.1f paddleTimer 0f) { // 前进触发一次划桨力 Vector3 force transform.forward * paddleForce; rb.AddForce(force, ForceMode.Impulse); paddleTimer paddleCooldown; // 通知动画和成就系统 if (OnPaddle ! null) { OnPaddle.Invoke(); } } } void ClampSpeed() { if (rb.linearVelocity.magnitude maxForwardSpeed) { rb.linearVelocity rb.linearVelocity.normalized * maxForwardSpeed; } if (rb.angularVelocity.magnitude maxAngularSpeed) { rb.angularVelocity rb.angularVelocity.normalized * maxAngularSpeed; } } public delegate void PaddleAction(); public event PaddleAction OnPaddle; }这段代码的关键点有几个。首先浮力采样用了 AddForceAtPosition而不是直接在重心施加力。这样当船头没入水中时船头受到的浮力更大会自然向上抬起来从而实现船体随水面摇晃的效果。其次划桨力用了 ForceMode.Impulse而不是 Force这样每一桨都是一个瞬间的冲量船会先加速再因为阻尼慢慢减速符合划船的真实节奏。最后事件 OnPaddle 用来解耦动画和成就系统。船体控制脚本只负责物理不关心动画怎么播、成就怎么判。5.2 划船动画与成就检测PaddleAnimator.cs这个脚本负责监听船体事件触发左侧或右侧桨的旋转动画同时记录累计划桨距离判断成就条件。using UnityEngine; public class PaddleAnimator : MonoBehaviour { public Transform paddleLeft; public Transform paddleRight; public float paddleAngle 40f; public float paddleSpeed 6f; public float achievementDistance 30f; // 需要划行的距离 private BoatController boat; private Vector3 lastPosition; private float totalDistance 0f; private bool achievementUnlocked false; void Start() { boat GetComponentBoatController(); if (boat ! null) { boat.OnPaddle OnPaddleHandler; } lastPosition transform.position; } void OnDestroy() { if (boat ! null) { boat.OnPaddle - OnPaddleHandler; } } void Update() { // 累计移动距离 float delta Vector3.Distance(transform.position, lastPosition); totalDistance delta; lastPosition transform.position; // 判断成就 if (!achievementUnlocked totalDistance achievementDistance) { achievementUnlocked true; Debug.Log([成就解锁] 湖上划水); } } void OnPaddleHandler() { // 随机选择左桨或右桨模拟划船节奏 bool useLeft Random.value 0.5f; StartCoroutine(PaddleAnimation(useLeft ? paddleLeft : paddleRight)); } System.Collections.IEnumerator PaddleAnimation(Transform paddle) { float t 0f; while (t 1f) { t Time.deltaTime * paddleSpeed; rotation.z Mathf.Lerp(0, paddleAngle, Mathf.Sin(t * Mathf.PI)); yield return null; } } }这段代码其实是一个很简化的版本真正项目中你可能会换成 Animator 参数来控制但核心思路一样通过事件触发动画通过位移累计触发成就。在实际项目中成就触发通常会调用一个全局的成就管理器这里用 Debug.Log 代替方便验证逻辑。5.3 岸边碰撞提示与场景回收ShoreDetector.cs船在湖面上划行玩家可能会把船开到岸边。这时如果没有碰撞提示船可能会卡在岸上或穿模。这里加一个简单的岸边检测脚本。using UnityEngine; public class ShoreDetector : MonoBehaviour { public float speedThreshold 1f; public LayerMask shoreLayer; private void OnCollisionEnter(Collision collision) { if ((shoreLayer.value (1 collision.gameObject.layer)) ! 0) { Debug.Log(船体接触岸边速度 collision.relativeVelocity.magnitude); } } }将这个脚本挂到船体上并把湖边的地形或障碍物放到指定的 Layer 中就可以在船撞岸时收到日志或触发反馈。6. 运行结果与效果验证代码写完接下来是实际运行和调参。6.1 挂载脚本与运行场景回到 Unity 编辑器选中 Boat 根物体在 Inspector 中添加 BoatController 脚本。将湖面的 Plane 的位置改为 (0, -0.1, 0)这样水面高度 waterHeight 可以设置为 -0.1让船体初始时稍微没入水中。如果没有手动挂载浮力点BoatController 会自动在 Start 时创建四个浮力点。这个逻辑我在代码里加了兜底减少了配置工作量。点击 Play 按钮运行场景。6.2 预期效果按下 W 或上方向键船体应该向前移动同时有一点上下浮动的感觉。按住 A 或 D船头应该开始转向。由于设置了 maxAngularSpeed转向不会过快而是在一个可控范围内。查看 Console 窗口如果划桨事件被触发会看到 Debug 输出的“划桨”日志如果有。累计移动距离超过 30 后会看到“ [成就解锁] 湖上划水”的日志。6.3 如何判断船的手感是否合格一个关键的判断标准松手后船是否还有滑行感。如果你松开按键船马上停下来说明 waterDrag 太大可以减小 Drag 到 0.5 试试。如果松开后船一直滑很远说明 Drag 太小可以增加到 1.5 左右。另一个判断标准转向是否有延迟。按 A/D 后船不应该立刻出现明显偏转而是先有一个角加速度然后船头慢慢转过去。如果感觉太肉增大 turnTorque如果太灵敏增大 angularDragForce。6.4 常见失败现象如果按下前进键船直接冲向高空很可能是浮力点没有正确设置或者浮力计算出来的力方向不对。检查一下 floatPoints 的 Y 坐标是否在船体底部或吃水线附近。如果船在水面上持续左右旋转但按下转向键没反应检查转向的 RemoveTorque 是否被某个脚本覆盖或者 maxAngularSpeed 设置过小。7. 常见问题与排查思路这里我把实际开发中最容易踩的几个坑列成表格方便快速排查。问题现象可能原因排查方式解决方案船体直接沉入水底浮力系数太小或浮力点在水面以上的位置检查 floatPoints 位置确认点在船体下半部增大 floatStrength 到 30 以上或把采样点往下移船体剧烈震荡乱跳浮力点位置不对称或 FixedUpdate 里力过大检查浮力点分布确认左右前后对称降低 floatStrength增加采样点密度按前进键没有反应划桨冷却时间太长或输入轴没有设置查看 paddleTimer 是否降到 0 以下调小 paddleCooldown检查 Input 设置转向过度无法稳定直线turnTorque 太大或角阻尼太小观察角速度峰值减小 turnTorque增大 angularDragForce船体像冰面滑动没有浮起来缺少浮力代码或 Rigidbody 被设置为 Kinematic检查 BoatController 是否启用启用浮力脚本确保 Rigidbody 非 Kinematic到达岸边后穿模Collider 太小或没有 Collider检查船体和岸边的碰撞体增加 Box Collider调整大小这些坑几乎都是新手做物理载具时一定会遇见的。核心思路是先确保浮力正常再调移动最后调手感。顺序不能乱。8. 最佳实践与工程建议如果只是想让小船动起来上面的代码已经够用。但如果你想把它做成一个真正能放进游戏里的玩法模块下面这些工程建议会帮你少走弯路。8.1 配置分离参数不要写死在代码里把 floatStrength、paddleForce、maxForwardSpeed 这些参数提出来放到 ScriptableObject 里作为“船体配置”这样不同船型可以挂不同的配置资源而不需要复制多个脚本代码。比如你后续要加“快艇”和“竹筏”只需要做两个不同参数的配置资产在生成船体时赋值即可。8.2 浮力采样点要复制到子物体上不要挂在根物体上如果你在代码里用 GetComponent 获取 Collider 边界然后动态生成采样点性能其实相当不错。但如果你的船体模型本身有多个零件建议在 Prefab 编辑时手动放置 4 个 FloatPoint 子物体保证它们的位置精确地位于船头、船尾、左舷、右舷。把采样点变成子物体后船体旋转时采样点也会跟着旋转这是正确的因为浮力计算需要世界坐标。8.3 用事件而不是硬编码来触发动画和音效BoatController 的 OnPaddle 事件就是一个典型例子。物理脚本不应该知道外面有没有动画也不知道有没有音效管理器。通过事件转发后续加侧倾动画、水花粒子、划桨音效都不会改动物理脚本。8.4 设置合理的物理更新频率船类物理对 FixedUpdate 的依赖很高。如果项目里物理步长被调整过船的稳定性会受到影响。默认 0.02 秒的 Fixed Timestep 一般是够用的不需要为船类玩法单独加速物理更新。8.5 性能不要为每个浮力采样点分配 Collider这是容易踩的性能陷阱。有些人会用多个 Sphere Collider 挂到浮力点上来检测入水深度但 Collider 的物理交互开销很大。推荐做法是在代码中直接用采样点的世界坐标与水面高度比较如本文的 waterHeight 方式。这是一个纯数学判断性能开销几乎为零。如果水面是不平整的比如有浪则需要获取水面网格在采样点处的高度这是另一个复杂度层级可在完成基础玩法后再优化。8.6 成就要做条件记录而不是一次性判断举例【湖上划水】的判定条件如果是“移动距离超过 30”你可以在 Update 里累计 distance。但如果未来要加“逆风划行”“连续划桨 10 次”就需要一个专门的数据记录模块。别把成就逻辑散落在各个控制器中建议独立成 AchievementManager。8.7 操作反馈要有多层好的划船手感不仅来自物理也来自反馈层。当玩家划桨时至少要同时出现桨的旋转动画水花粒子效果划水音效轻微的船体侧倾(这可以由物理模拟自然产生)这些反馈叠加起来玩家才会觉得“我真的在划船”而不是“我按了一个键船走了”。9. 总结与后续学习方向这次实现一个“船”新功能虽然表面上只是给场景加了一个可以移动的物体但真正的工作量集中在理解“船和普通物体的移动有什么不同”。船在水面上同时受到浮力、水阻、推力和重力四股力量互相平衡才产生了既有重量感又有灵活性的操控体验。回顾整个实现过程核心收获有三个浮力不是只有一个向上的力而是分布在不同位置的多种力的叠加划船不是持续加速而是周期性脉冲加速中间靠水阻减速手感调优的目标不是“让物体听话”而是“让物体的反馈符合玩家对物理的直觉”。如果你想继续深入建议下一步从这几个方向挑选一个视觉升级替换水面 Shader加上波浪顶点动画让浮力采样点跟随波浪高度计算。玩法扩展加入体力条每次划桨消耗体力体力耗尽后只能漂浮等待恢复这会改变整体节奏。物理精度接入真实流体模拟方案或更高质量的 buoyancy 算法让船体在大浪中翻滚。换船系统把船体参数做成配置支持轻舟、帆船、快艇之间的切换。这些都是从“能跑”到“好玩”的必经之路。顺手把这个项目的完整逻辑保存好后面扩展到“湖上划水”这个成就时只需要改一下成就触发条件——比如从移动距离改成“不撞岸持续划行 30 秒”就能得到完全不同的挑战目标。游戏玩法的乐趣往往就藏在这些参数差异和条件组合里。建议你把基础版的代码整体收藏备用下一次遇到“水面载具”类型的需求可以直接拿这套框架来改省掉重新踩坑的时间。