这次我们来看一个游戏开发日志里的常见玩法功能——“船”新功能。简单说就是把一艘船放进湖面场景里让玩家可以控制它划水前进并在满足条件时自动达成“湖上划水”成就。这个功能看起来只是“放一艘船 弹一个提示”但真正落到 Unity 项目里会牵扯到浮力模拟、刚体控制、输入响应、动画切换、成就系统、日志埋点等一系列问题。如果前期不做模块拆分后面再加钓鱼、竞速、双人同屏时会非常痛苦。这篇文章会围绕这一小段开发日志展开讲清楚怎么把一个可漂浮、可控制、可记录日志的“湖上划水”功能串起来。阅读完以后你能拿到一套完整的开发思路湖面场景搭建、船体浮力实现、划船控制、成就检测、开发日志管理、测试验证和问题排查。内容以 Unity 3D 为主代码用 C#工程结构尽量保持通用方便你直接套进自己的项目。# 本文以 Unity 3D 为例 # 版本建议使用 Unity 2021.3 LTS 或 Unity 2022.3 LTS # 渲染管线使用 Built-in 或 URP 均可1. 核心功能速览能力项说明项目类型Unity 3D 游戏开发日志 / 玩法功能实现核心功能湖面场景、船体漂浮、划船控制、成就“湖上划水”引擎版本推荐 Unity 2021.3 LTS 或更高版本开发语言C#目标平台Windows、macOS、Android、iOS以实际打包配置为准渲染管线Built-in / URP 均可硬件门槛普通开发机即可是否流畅取决于水面 Shader 复杂度和场景面数输入方式键盘 / 触屏虚拟摇杆 / 手柄根据目标平台选成就系统条件检测 本地存档 UI 弹窗日志系统Debug 日志、文件日志、真机日志、性能日志是否支持 API这里指游戏内的事件接口成就检测、日志上报可封装为模块批量任务适合批量冒烟测试场景例如跑完多个测试用例并输出日志适合场景新手入湖玩法、休闲游戏、开放世界湖泊交互、开发日志记录模板从开发日志角度看这个功能最值得记录的并不是“船模型好看”而是三个技术点浮力采样、成就条件检测、日志管理。只要这三个模块拆得干净后续扩展成“竞速赛船”“湖上钓鱼”“多人划船”都会方便很多。2. 需求分析与功能拆解2.1 “船”新功能到底是什么标题里的“船”新功能其实是“全新功能”的谐音。放在游戏开发场景里它通常代表一个新的交互玩法加入。这里我们要做的目标是玩家进入湖面区域后可以看到一艘船。玩家上船后通过方向键或虚拟摇杆控制船前进、转向。船体在湖面上要有自然的起伏不能像方块一样硬邦邦漂着。玩家累计划船达到一定时间或距离后触发成就“湖上划水”。整个开发过程要能通过日志追踪状态方便排查问题。2.2 功能模块拆分建议先拆成以下几个模块后面写起来才有条理GameDevLog/ ├── Scripts/ │ ├── Boat/ │ │ ├── BoatController.cs // 船体输入与移动 │ │ ├── BuoyancyObject.cs // 浮力采样点 │ │ ├── WaterFloat.cs // 浮力总控 │ │ └── OarAnimation.cs // 划桨动画控制 │ ├── Achievement/ │ │ ├── AchievementData.cs // 成就数据结构 │ │ ├── AchievementManager.cs // 成就管理器 │ │ └── AchievementNotifier.cs // 成就弹窗 │ ├── Logging/ │ │ ├── GameLogger.cs // 日志封装 │ │ └── PerformanceLogger.cs // 性能日志 │ └── Camera/ │ └── FollowCamera.cs // 相机跟随模块拆分的核心原则是船体逻辑不依赖成就逻辑成就逻辑不直接读写 UI日志系统可以随时开关。这样后续换模型、换 UI、接后端统计都不会牵连一片代码。2.3 开发日志的关注点既然是“游戏开发日志”日志不只是记录“今天做了什么”更要在代码里埋点。常用的埋点包括玩家进入湖面区域。玩家上船。浮力初始化完成。划船输入触发。成就条件达到。成就已解锁并写入存档。异常情况例如船体翻覆、浮力采样点失效。这些埋点会贯穿后面每一个功能模块。3. 环境准备与 Unity 项目配置3.1 基础环境在开始搭建之前先把环境准备好。下面是通用参考具体版本以你本机实际安装为准。操作系统Windows 10/11 或 macOS Unity Hub任意较新版本 Unity 版本Unity 2021.3 LTS / 2022.3 LTS 代码编辑器Visual Studio 2022 或 Rider 图形 APIWindows 使用 DX11Android 使用 Vulkan / GLES3如果目标平台包含 Android需要在 Unity Hub 里提前安装 Android Build Support 和 OpenJDK、SDK。如果目标平台只有 PC可以先不装移动端模块减少安装体积。3.2 创建项目打开 Unity Hub点击“新建项目”选择“3D 核心”模板项目名称可以叫BoatLogDemo。如果团队已经有一个游戏工程建议在当前工程里新建一个独立场景避免污染主场景。# 项目名称示例 BoatLogDemo # 主场景名称 MainLakeScene.unity创建完成后在 Project 窗口建立以下目录Assets/ ├── Art/ │ ├── Models/ │ ├── Materials/ │ └── Textures/ ├── Prefabs/ ├── Scenes/ ├── Scripts/ └── Logs/Logs目录专门用来存放运行时生成的文件日志。即使不上传到版本管理也要在.gitignore里加一条Logs/避免日志文件污染仓库。3.3 资源准备船体模型可以从 Asset Store 找免费的低多边形小船也可以用 Unity 自带的基础几何体临时拼一个。重点不是模型精细度而是碰撞体和浮力采样点布局。水面资源同理。可以先用 Unity 自带的 Plane 加一个透明水面材质也可以在 URP 下用 Shader Graph 做简单的正弦波水面。第一次调试尽量简单不要让水面 Shader 成为排查浮力问题的干扰项。4. 湖面场景搭建与船体导入4.1 创建湖面新建一个场景删除默认的 Directional Light 之外的临时物体。先创建湖面GameObject 3D Object Plane 改名LakeWater 缩放Scale (20, 1, 20) 位置Position (0, 0, 0) 碰撞体移除自带的 Mesh Collider后面浮力用脚本计算如果使用 URP 的 Stylized Water 类资源要确保水面 Shader 支持你当前管线。否则渲染出来是粉红色。4.2 导入船体模型将船体模型拖入场景调整到水面之上。初始位置可以放在(0, 0.5f, 0)让船底略高于水面方便落水时观察到下沉和上浮的过程。在船体根节点上添加以下组件Rigidbody Mass200 Drag1 Angular Drag2 Use Gravity勾选 InterpolateInterpolate Box Collider Center根据船体调整 Size根据船体调整这里不建议用 Mesh Collider因为复杂的碰撞面会增加浮力采样点的计算干扰。先用 Box Collider 把船体包住浮力逻辑稳定后再考虑更精细的碰撞。4.3 相机跟随脚本相机跟随在调试划船功能时非常重要。如果相机不跟随船一划出视野就看不清效果。写一个简单的第三人称跟随脚本。using UnityEngine; public class FollowCamera : MonoBehaviour { public Transform target; public Vector3 offset new Vector3(0f, 3f, -6f); public float smoothSpeed 5f; void LateUpdate() { if (target null) return; Vector3 desiredPosition target.position target.TransformDirection(offset); transform.position Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime); transform.LookAt(target); } }把FollowCamera挂到 Main Camera 上再把船的 Transform 拖到target字段。启动游戏后相机就会跟随船体移动。这个脚本属于调试辅助后续可以替换为 Cinemachine 或更好的相机系统。5. 船体浮力与划船控制实现浮力是“湖上划水”的核心。如果船不能稳定浮在水面上后面所有接口、成就、日志都是空谈。5.1 浮力原理Unity 中模拟浮力最简单的方式是在船体上设置多个浮力采样点。每个采样点检测自身与水面水平面的位置关系采样点在水面以下则产生向上的浮力。采样点在水面以上不产生浮力或只产生很小的阻力。采样点入水深度越深浮力越大但不超过排水体积对应的上限。使用多个采样点而不是在船体中心只加一个力是因为单点浮力容易让船体倾斜后无法回正。常见做法是在船头、船尾、左舷、右舷各放一个采样点四点共同作用让船体自然稳定。5.2 浮力采样点脚本下面这个脚本挂在船体的四个子物体上每个子物体代表一个采样点。using UnityEngine; public class BuoyancyObject : MonoBehaviour { public float waterLevel 0f; public float waterDensity 1f; public float objectVolume 1f; public float dragCoefficient 1f; private Rigidbody rb; private float submergedHeight 0f; void Start() { rb GetComponentInParentRigidbody(); } void FixedUpdate() { if (rb null) return; float currentWaterLevel waterLevel; float pointHeight transform.position.y; if (pointHeight currentWaterLevel) { submergedHeight currentWaterLevel - pointHeight; float buoyancyForce waterDensity * Physics.gravity.magnitude * objectVolume * submergedHeight; rb.AddForceAtPosition(Vector3.up * buoyancyForce, transform.position, ForceMode.Force); float dragForce dragCoefficient * rb.velocity.magnitude; rb.AddForceAtPosition(-rb.velocity * dragForce, transform.position, ForceMode.Force); } } }waterLevel可以手动填成湖面世界坐标的 Y 值。湖面平面在 Y0 时这里也填 0。如果水面用 Shader 做了波浪就需要在脚本里从水面 Mesh 采样高度或者传一个波浪高度函数。第一次开发建议先固定waterLevel稳定后再接真实水面高度。5.3 船体总控脚本浮力采样点只负责“产生力”船体总控负责把输入转换成推力和转向力矩。using UnityEngine; public class BoatController : MonoBehaviour { public float moveSpeed 8f; public float turnSpeed 30f; public float acceleration 2f; public Animator oarAnimator; private Rigidbody rb; private float currentSpeed 0f; void Start() { rb GetComponentRigidbody(); } void FixedUpdate() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); if (oarAnimator ! null) { oarAnimator.SetBool(IsRowing, Mathf.Abs(vertical) 0.1f || Mathf.Abs(horizontal) 0.1f); } currentSpeed Mathf.Lerp(currentSpeed, vertical * moveSpeed, acceleration * Time.fixedDeltaTime); rb.AddRelativeForce(Vector3.forward * currentSpeed, ForceMode.Acceleration); rb.AddRelativeTorque(Vector3.up * horizontal * turnSpeed * Time.fixedDeltaTime, ForceMode.Acceleration); } }这里利用Input.GetAxis同时兼容键盘和虚拟轴的输入。如果把Horizontal和Vertical替换成Input.GetAxis(Horizontal)的虚拟摇杆映射PC 和移动端都可以共用一套控制代码。5.4 划桨动画切换划桨动画属于表现层。在船上放置一个空物体挂上OarAnimation脚本。这个脚本可以检测船是否在移动以此控制动画播放。using UnityEngine; public class OarAnimation : MonoBehaviour { public Animator oarAnimator; public Rigidbody boatRigidbody; void Update() { if (oarAnimator null || boatRigidbody null) return; float speed boatRigidbody.velocity.magnitude; oarAnimator.SetFloat(Speed, speed); } }动画状态机的过渡条件设置为Speed大于 0.1 时进入划桨循环否则回到待机。这里的关键点是动画只负责表现不反过来驱动物理否则会出现“船不动但是桨在划”的感官不一致问题。6. 成就“湖上划水”实现成就系统其实是一个状态机条件未达成 - 条件达成 - 已解锁 - 存档记录。开发时不要把成就逻辑直接写在BoatController里而是通过事件或轮询检查来解耦。6.1 成就数据结构先定义一个通用的成就数据类。using System; [Serializable] public class AchievementData { public string id; public string name; public string description; public float targetValue; public float currentValue; public bool isUnlocked; }“湖上划水”这个成就targetValue可以设成累计划船时间比如 60 秒。玩家只要累计划船 60 秒就达成成就。数据可以存储在PlayerPrefs或 JSON 文件中。6.2 成就管理器using System.Collections.Generic; using UnityEngine; public class AchievementManager : MonoBehaviour { public static AchievementManager Instance { get; private set; } public ListAchievementData achievements new ListAchievementData(); void Awake() { if (Instance null) { Instance this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } } public void AddProgress(string achievementId, float delta) { AchievementData data achievements.Find(a a.id achievementId); if (data null || data.isUnlocked) return; data.currentValue delta; GameLogger.Log($[Achievement] {data.id} progress: {data.currentValue}/{data.targetValue}); if (data.currentValue data.targetValue) { data.currentValue data.targetValue; UnlockAchievement(data); } } private void UnlockAchievement(AchievementData data) { data.isUnlocked true; GameLogger.Log($[Achievement] Unlocked: {data.name}); AchievementNotifier.Instance?.ShowAchievement(data); SaveAchievements(); } public void SaveAchievements() { // 这里可以序列化成 JSON 写入本地文件也可以用 PlayerPrefs } public void LoadAchievements() { // 读取存档 } }AddProgress做成按 id 增加进度这样后续增加“航行距离 500 米”“翻转 5 次”等成就时只需新增数据条目不需要修改管理器逻辑。6.3 划船计时检测器如何判断玩家正在“湖上划水”可以在BoatController里暴露一个isRowing属性当垂直或水平输入超阈值时置为 true。然后写一个划水计时器。using UnityEngine; public class RowingAchievementTracker : MonoBehaviour { public string achievementId lake_rowing; public float checkInterval 0.5f; private float timer 0f; void Update() { if (BoatInput.IsRowing) { timer Time.deltaTime; if (timer checkInterval) { AchievementManager.Instance?.AddProgress(achievementId, timer); timer 0f; } } else { timer 0f; } } }为了避免每帧都调用AddProgress这里用了一个 0.5 秒的累计窗口。实际项目中如果成就较多可以做一个统一的成就心跳检测器统一收集事件源并更新进度避免每帧遍历所有成就。6.4 成就弹窗 UI成就解锁后弹出提示。可以用一个简单的 Canvas 文本 动画实现。using UnityEngine; using TMPro; public class AchievementNotifier : MonoBehaviour { public static AchievementNotifier Instance { get; private set; } public GameObject panel; public TextMeshProUGUI titleText; public TextMeshProUGUI descText; void Awake() { Instance this; } public void ShowAchievement(AchievementData data) { panel.SetActive(true); titleText.text data.name; descText.text data.description; // 这里使用 Invoke 或协程延迟隐藏 CancelInvoke(nameof(HidePanel)); Invoke(nameof(HidePanel), 3f); } private void HidePanel() { panel.SetActive(false); } }如果项目没有导入 TextMeshPro可以先创建 UGUI 的 Text 来替代避免工程编译报错。7. 游戏开发日志与调试信息管理标题里的“日志”不只是开发过程的记录也代表游戏运行时的日志系统。做好日志管理能在真机调试时快速定位问题也能在批量测试时收集多台设备的状态。7.1 Debug 日志的分类建议统一使用自定义日志类而不是直接在代码里写Debug.Log。原因有两个上线时可以通过日志级别过滤。可以同时输出到 Unity Console 和本地文件。using System; using System.IO; using UnityEngine; public static class GameLogger { public enum LogLevel { Debug, Info, Warning, Error } private static string logFilePath Path.Combine(Application.persistentDataPath, game_log.txt); public static void Log(string message) { Debug.Log(message); WriteFile($[{DateTime.Now:HH:mm:ss}] [INFO] {message}); } public static void LogWarning(string message) { Debug.LogWarning(message); WriteFile($[{DateTime.Now:HH:mm:ss}] [WARN] {message}); } public static void LogError(string message) { Debug.LogError(message); WriteFile($[{DateTime.Now:HH:mm:ss}] [ERROR] {message}); } private static void WriteFile(string text) { try { File.AppendAllText(logFilePath, text Environment.NewLine); } catch { // 文件写入失败不影响游戏主逻辑 } } }这里把日志文件写到Application.persistentDataPath不同平台的路径都不同。真机测试时可以通过 adb 查看文件内容或者用 Unity 自带的 Logger 插件。7.2 日志轮转与大小控制真机上长时间跑日志文件会越来越大。建议在WriteFile中判断文件大小超过 1MB 就重命名备份后重新创建新文件。private static void RotateLogIfNeeded() { if (File.Exists(logFilePath)) { FileInfo fileInfo new FileInfo(logFilePath); if (fileInfo.Length 1024 * 1024) { File.Move(logFilePath, logFilePath .1); } } }日志轮转是移动端游戏常见需求。一个文件撑到几百 MB 后再排查打开文件本身就很浪费时间。7.3 性能日志在游戏开发中除了业务日志性能日志也很重要。可以写一个简单的帧率采样器定期把平均帧率写入日志。using UnityEngine; public class PerformanceLogger : MonoBehaviour { public float sampleInterval 5f; private float timer 0f; private int frameCount 0; private float elapsedTime 0f; void Update() { frameCount; elapsedTime Time.unscaledDeltaTime; timer Time.unscaledDeltaTime; if (timer sampleInterval) { float fps frameCount / elapsedTime; GameLogger.Log($[Performance] Average FPS: {fps:F1}); timer 0f; frameCount 0; elapsedTime 0f; } } }性能日志的价值在于对比。比如船体浮力采样点从 4 个增加到 8 个帧率变化了多少水面 Shader 从 Built-in 换成 URP 后变化了多少有了日志就能用数据判断优化方向。8. 功能测试与效果验证功能开发完成后要有一套可重复执行的测试流程。这里把测试用例列成表格方便你在自己的工程里核对。测试项输入操作预期结果判断成功标准浮力初始化点击 Play 进入场景船体落水后缓慢上浮最终稳定在水面附近船体不沉底、不弹飞3 秒后姿态基本稳定前进控制按住 W / 向上虚拟键船体向前移动速度缓慢提升移动方向正确有加速过程不瞬移转向控制按住 A / D船体绕 Y 轴旋转转向响应符合输入方向旋转不穿模划桨动画按住前进键划桨动画循环播放动画速度与船速匹配停止输入后动画停止成就累计连续划船 60 秒弹窗提示“湖上划水”成就只弹出一次重启后仍为已解锁日志输出观察 Console 和本地日志文件能看到进度和成就解锁日志文件日志包含关键状态无空行或无意义信息性能稳定开启 PerformanceLogger平均帧率变化幅度小样例周期内 FPS 无明显断崖测试顺序建议先测浮力再测控制然后测成就最后测日志。浮力不稳定时不要急着测成就否则会出现船都翻了你还在等着弹成就排查时很难分清是逻辑问题还是物理问题。真机测试时可以在水面 Shader 上增加一个调试开关把水面高度直接显示出来。这样能确认是浮力采样点算错了还是水面渲染高度和物理高度不一致。9. 常见问题与排查方法问题现象可能原因排查方式解决方案船体直接沉入水底浮力采样点坐标错误或浮力过小打开 Scene 视图选中采样点查看其 Y 坐标确保采样点在水面高度以下合理增大objectVolume船体剧烈抖动浮力计算在 FixedUpdate 中力变化过大观察 Rigidbody velocity 是否存在尖刺降低waterDensity增加 Rigidbody 的 Drag 和 Angular Drag船体无法转向转向力矩过小或输入轴未映射打印 Horizontal 输入值增大turnSpeed或在 Project Settings 中确认输入轴划船无反应Collider 被水面阻挡或动画状态机卡住查看船体是否能移动再观察 Animator 状态检查湖面碰撞体是否误设为 Collider调整动画过渡条件成就一直不触发计数器未累加或存档逻辑问题在AddProgress中打印每次累加值确认RowingAchievementTracker是否挂到激活对象检查时间累计条件成就重复弹出已解锁状态未持久化在UnlockAchievement后打印isUnlocked确保isUnlocked在存档读取时被恢复真机日志看不到Application.persistentDataPath 路径不对用真机日志工具查看路径在日志中打印完整路径通过设备文件管理器访问日志文件过大没有做日志轮转查看文件大小增加日志轮转逻辑按大小拆分如果遇到浮力与水面高度不一致的问题优先检查水面世界坐标的 Y 值。很多水面 Shader 渲染出来的波浪高度是视觉上的和物理计算用的waterLevel完全不相关。因此建议把物理用水面高度做成一个独立变量方便统一修改。10. 资源占用与性能观察这类玩法功能的性能瓶颈主要集中在三块水面 Shader、浮力采样点数量、刚体物理计算。10.1 水面 Shader水面 Shader 是湖面场景中 GPU 负载最大的部分。简单的正弦波 Shader 在移动端可以跑得很流畅但如果是多层法线贴图、反射探针、实时光照移动端帧率会明显下降。观察方法在 Game 视图打开 Stats 面板查看 SetPass Calls 和 Batches 数量。如果水面占用的 DrawCall 过高考虑使用 Texture 扰动或简化反射或者在远处使用低精度 LOD。10.2 浮力采样点数量浮力采样点不是越多越真实。每个采样点都在FixedUpdate中执行物理计算采样点数量翻倍CPU 压力也会翻倍。通常 4 到 6 个采样点就能模拟出比较自然的船体姿态。小型船用 4 点即可大型船可以增加到 8 点但不建议无限增加。10.3 物理组件控制船体上的 Rigidbody、多个子物体的 Collider、浮力采样点都会参与物理计算。如果湖面上有多艘船物理开销会线性增加。建议用对象池管理船只实例只在玩家进入可视范围时激活。11. 发布与后续扩展11.1 发布设置发布 PC 版本时在 Build Settings 里选择目标平台确保场景包含MainLakeScene。发布 Android 版本时注意以下设置Graphics APIVulkan 优先 Multithreaded Rendering勾选 Minimum API LevelAndroid 7.0 或更高 Target ArchitecturesARM64如果包体较大建议启用 AssetBundle 或 Addressables 做资源管理避免首次加载卡顿。11.2 后续扩展方向这个功能模块的扩展空间很大多人划船需要将船的位置、朝向、划桨动画状态同步到网络浮力采样点不需要同步只需要同步刚体的运动状态。天气系统发生波浪时waterLevel变成动态高度浮力脚本需要接入波浪采样函数。交互玩法在船上增加钓鱼、对话、寻宝等交互点。成就系统升级接入后端存档将成就解锁记录同步到账号系统。日志上报把GameLogger接上后端日志平台可以按设备 ID 查询日志快速定位线上问题。从工程角度说最值得保留的是一个通用成就管理器和一套统一日志接口后续加其他功能时只需要往里面丢新条目而不需要每次改多个脚本。12. 总结与下一步这个“湖上划水”成就适合作为新项目早期玩法验证的入门功能。它覆盖了场景搭建、物理模拟、输入控制、动画表现、成就状态机、日志管理几个基础模块难度不高但很考验模块拆分能力。建议第一次实现时先跑通最小闭环船能浮起来、能划动、能解锁成就、日志能记录。完成这个闭环后再考虑加水面波浪、船只类型、多人同步等进阶内容。如果你的项目也需要类似的湖泊交互玩法可以直接按这篇开发日志的路子走。先把浮力采样点做对再把成就检测和日志管理独立出来后面的扩展就不会让代码越来越乱。建议收藏备用下一次做水面交互功能时可以直接复用这套结构。