拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity保龄球游戏开发:物理模拟、C#记分与手感调优全解析

简介这是一份完整的Unity体育游戏项目源码主题为真实3D保龄球体验面向Unity开发者和游戏编程学习者也适合希望快速上线休闲游戏的团队。项目覆盖场景搭建、物理碰撞、界面交互与关卡设计并已集成广告横幅和插页式广告支持安卓与苹果系统可离线运行适配平板设备。压缩包大小约373.63MB共2001个文件主体为C#脚本、PNG图片、材质、动画及FBX模型并包含DOTween等配套插件目录结构清晰便于按模块查阅。目前已有99人学习下载。通过阅读源码可掌握广告集成流程、一键游戏逻辑、极简界面构建及保龄球运动模拟等实战技巧所有图形与动画资源齐全便于直接替换素材或调整玩法参数。1. 真正的保龄球体验难在哪物理可信、规则可信、手感可信在 Unity 里做保龄球第一个让人劝退的瞬间往往是球刚出手球瓶就飞上了天。真正的保龄球体验不是贴图做得像而是三层可信物理可信——球速、球道油感、球瓶重量感让击倒动作符合直觉规则可信——十帧记分里 strike 的分数必须等到后两球落地才结算手感可信——力量、角度、旋转有连续控制而不是一按按钮球就飞出去。这套逻辑用 C# 写进项目源码适合商场体感设备、模拟训练和 WebGL 小游戏。下面按物理搭建、投球控制、记分和发布优化四步展开代码都按可直接改参数的方式给。2. 球道与球瓶的物理参数为什么你的球瓶总是飞出去2.1 最小物理场景怎么搭Rigidbody 与 Collider 的分配保龄球场景里真正参与物理的物体只有三类球道、球、球瓶。球道和边沟用 Static Collider球和球瓶用 Rigidbody。一个常见误区是把球瓶做成由 Cylinder、Sphere 拼接的复杂组合体每个子物体都挂 Rigidbody这会让物理解算器在碰撞瞬间计算出多组冲量球瓶反而容易爆飞。我一般这样搭球一个 Sphere Collider 加 RigidbodyCollision Detection 设为 Continuous 或 Continuous Dynamic。球瓶Mesh Collider 勾选 Convex加 Rigidbody不勾 Is Kinematic。球道Box Collider作为 Static 物体。边沟两个 Box Collider或者用球道本身的 Box Collider 侧面压出的凹陷区域。项目结构建议按 C# 源码管理习惯分目录后续切换输入方式或者替换记分逻辑都不动物理层。Assets/Scripts/ Input/BowlingInputController.cs Physics/PinResetter.cs Physics/BallPhysicsController.cs Scoring/BowlingScoreKeeper.cs UI/ScoreBoardUpdater.cs球瓶的 Mesh Collider 必须勾 Convex这是新手最容易忽略的。Unity 的 Mesh Collider 在非 Convex 状态下不能与另一个 Convex Collider 做正确碰撞球会直接穿过瓶身。勾选 Convex 后碰撞体由凸包近似对保龄球瓶这种有瓶颈弧度的形状已经够用代价是颈部会有轻微“多肉”但不影响倒瓶表现。2.1.1 为什么不建议用多个 Capsule Collider 拼球瓶有人为了还原球瓶曲线把瓶身拆成三截 Capsule瓶底、瓶肚、瓶颈各一个再一起放在同一个 Rigidbody 下。这种做法在静止时没问题一旦球高速撞上来球瓶会绕多个接触点同时计算摩擦和反弹表现出明显的“颤抖”和“横移”。保持单一 Collider让物理引擎用一套接触面去算是最稳的做法。如果一定要优化形态用 Blender 把球瓶模型处理成封闭、无重叠面的单网格导出后让 Unity 自动生成凸包。2.2 一组可直接抄的 Physic Material 参数表保龄球体验里最核心的物理参数是摩擦和弹力。真实球道有油层球在油道上是滑动的触底区才开始滚动球瓶则要站得住倒下时又不该像乒乓球一样弹跳。我在项目里直接用这组 Physic Material新建三个 Physic Material 分别给球道、球瓶和球物体Dynamic FrictionStatic FrictionBouncinessFriction CombineBounce Combine球道油道0.050.050.05MinimumMinimum球道两侧护轨0.60.70.3AverageMaximum球瓶0.80.90.2MaximumMinimum球0.30.40.1AverageMinimum球道摩擦设成接近 0.05是因为真实球道的油层让球在到达球瓶区前不会突然减速。如果这个值设大了球会在中途明显顿一下玩家会觉得“球被粘住了”。球瓶的 Static Friction 要高否则球瓶受球碰之前自己就会在轻微震动下滑动。Bounciness 是整个项目里最需要压住的值球瓶倒地和球瓶互撞时如果 Bounciness 超过 0.3就会出现连环弹跳视觉效果立刻失真。2.3 让球瓶不乱飞的两条经验重心与接触底面积球瓶乱飞不是因为物理引擎“太强”而是因为默认碰撞体的接触底面积太小。球以 20 m/s 撞击球瓶时碰撞穿透修正会在几个物理帧内把球瓶推开底面积越小单位面积冲量越大球瓶就像被弹射一样飞出去。常见的修法有两个。第一把球瓶底部的 Collider 半径放大。以真实球瓶约 38 cm 高度为参考底部半径放大 2 到 3 cm 就够不用改成圆柱墩子。视觉模型不动只是碰撞体底部宽一圈。放大后球的撞击力被分散到更大的接触面上球瓶会倾倒而不是弹出。第二别把球瓶质量设成默认的 1真实球瓶质量约 1.3 到 1.6 kg我一般设 1.4。质量太低时球的动量传递效率过高球瓶会被“打穿”质量太高又会出现球被瓶弹回来的反物理表现。不建议在 OnCollisionEnter 里直接去限制球瓶速度比如检测到速度大于某值就强行降低。这么做确实能止住乱飞但球瓶会像是撞到了一堵看不见的墙倒瓶动作会突然“定住”。物理表现要回到参数层解决不要用脚本兜底。2.4 用 Fixed Timestep 0.01 稳住高速球Unity 默认的 Fixed Timestep 是 0.02 秒也就是物理系统每 20 毫秒计算一步。20 m/s 的球每步前进 0.4 米球瓶本身直径不到 0.15 米高速下很容易出现“这一步还在瓶前下一步已经在瓶后”的穿透。保龄球这种小场景里物体数量很少把步长减半对性能影响可接受效果非常明显。void Awake() { // 默认0.02s高速球容易穿瓶0.01s后每步位移减半 Time.fixedDeltaTime 0.01f; // 锁到60帧配合Rigidbody插值让倒瓶画面更顺 Application.targetFrameRate 60; QualitySettings.vSyncCount 0; }注意 Time.fixedDeltaTime 是全局参数会影响场景里所有 Rigidbody。如果项目里还有其他需要精确物理的玩法建议把 0.01 作为启动参数写进一个 GameSettings 脚本方便不同模式切换。调到 0.01 之后Rigidbody 的 Interpolate 选项要从 None 改成 Interpolate否则物理步长与渲染帧率不一致时球瓶会出现肉眼可见的抖动。3. 投球控件的 C# 实现滑动条、出手与摄像机跟随3.1 投球操作的三种实现与选择投球操作有三种常见做法鼠标拖拽后松手、滑动条控制、VR 手柄挥动。鼠标拖拽最接近桌面端手感玩家按住球向后拉再前推Unity 通过 Input 系统记录拖拽向量但这类交互在触屏和 WebGL 上很难做准。滑动条上限明确力量和角度都是归一化数值适合跨平台发布。VR 手柄挥动需要额外处理挥动速度与球出手速度的换算一般留给体感设备单独开发。从工程角度我会优先做滑动条。它不需要依赖相机射线和平面判定逻辑简单且玩家能明确感知力量和角度的变化范围。保龄球体验里的“真实感”不一定来自最高自由度的操作而是来自“我能复现上一次出手”的可预测性滑动条天然具备这个属性。3.2 一套带参数的投球 Controller 代码下面的脚本把三个 Slider 分别映射到球速、水平角度和侧旋。项目源码里只要把 Ball 的 Rigidbody 拖进 Inspector再把 Slider 引用挂好调用 ThrowBall 就能完成一次出手。using UnityEngine; using UnityEngine.UI; public class BowlingInputController : MonoBehaviour { [Header(UI 控件)] [SerializeField] private Slider powerSlider; [SerializeField] private Slider angleSlider; [SerializeField] private Slider spinSlider; [SerializeField] private Button throwButton; [Header(球与参数)] [SerializeField] private Rigidbody ballRb; [SerializeField] private float minSpeed 6f; [SerializeField] private float maxSpeed 20f; [SerializeField] private float maxAngle 12f; [SerializeField] private float maxSpin 8f; private void OnEnable() { throwButton.onClick.AddListener(ThrowBall); } private void OnDisable() { throwButton.onClick.RemoveListener(ThrowBall); } public void ThrowBall() { if (ballRb null) return; // 重置旧的速度与旋转避免上一次的运动残留 ballRb.velocity Vector3.zero; ballRb.angularVelocity Vector3.zero; float speed Mathf.Lerp(minSpeed, maxSpeed, powerSlider.normalizedValue); float angle Mathf.Lerp(-maxAngle, maxAngle, angleSlider.normalizedValue); float spin Mathf.Lerp(-maxSpin, maxSpin, spinSlider.normalizedValue); // 以世界Z轴为正向绕Y轴旋转出水平角度 Vector3 forward Quaternion.Euler(0f, angle, 0f) * Vector3.forward; ballRb.velocity forward * speed; // Impulse模式下侧旋值直接作用于角速度不依赖质量 ballRb.AddTorque(Vector3.up * spin, ForceMode.Impulse); } }Slider.normalizedValue 返回 0 到 1 的归一化值用 Mathf.Lerp 映射到实际物理量比直接读 Slider.value 更稳。因为 Slider 的 minValue 和 maxValue 可能被调整归一化值始终表示当前滑块在轨道中的相对位置。AddTorque 用 ForceMode.Impulse 是因为我们需要的是“瞬间获得侧旋速度”而不是持续施加力矩如果用 ForceMode.Force球的旋转会慢慢增加出手瞬间的侧旋表现会很“肉”。3.3 为什么出手角度要映射成 -12° 到 12°保龄球球道约 1.15 米宽球瓶区在球道末端球从出手到到达球瓶区约需 1 秒出头具体取决于速度。假设球以 18 m/s 出手球道有效长度按 18 米估算飞行时间约 1 秒。若允许球从最左侧切入到最右侧角落横向偏移最多 1.15 米左右考虑到球本身直径和球瓶区宽度可用横向偏移约 1 米。根据水平位移公式 tan(angle) ≈ 1 / 18可得最大角度约 3.2°。这个值看起来很小但实际球道有油层的滑动球在接近球瓶区时会产生横向滚动角度乘到投影距离后会有放大。实际工程里我不会只给 3°因为玩家能感知的“偏转感”需要比纯几何值更大一些。取 12° 是为了兼顾视觉反馈和可玩性让微调更容易被察觉。如果球道长度被改短这组参数要等比缩小。团队联调时最快的方法是在球到达球瓶区时拉一条 Debug.DrawRay对比实际落点与按角度计算的理论落点再决定是调 maxAngle 还是调摩擦力。3.4 摄像机跟随用 LateUpdate球在出手后滚动很快摄像机如果直接在 Update 里跟随球的位置会因为物理结算发生在 FixedUpdate 而出现拉扯感。正确做法是把跟随逻辑放到 LateUpdate保证相机位置在每帧所有更新完成之后才计算。using UnityEngine; public class BowlingCameraFollow : MonoBehaviour { [SerializeField] private Transform ball; [SerializeField] private float followSpeed 6f; private Vector3 initialOffset; private void Start() { // 记录相机与球的初始相对位置而不是动态算偏移 initialOffset transform.position - ball.position; } private void LateUpdate() { if (ball null) return; Vector3 targetPosition ball.position initialOffset; transform.position Vector3.Lerp( transform.position, targetPosition, followSpeed * Time.deltaTime ); } }initialOffset 必须在 Start 里记录不能在 LateUpdate 里用 transform.position 减去 ball.position否则每一帧目标位置都在变相机永远追不上。followSpeed 设为 6 左右时相机既有跟随感又不会太“硬”。如果想在球出手前保持固定机位可以在脚本里加一个 bool 标志位出手瞬间再启用跟随。4. C# 手写 10 帧保龄球记分Strike、Spare 与第十帧的边界4.1 记分规则里最容易错的三个点保龄球十帧记分在项目源码里最常见的坑有三个。第一Strike 的分数不能在当前帧立即结算。第 1 帧打出全中当前帧显示为空必须等第 2 帧的两球分数出来才能填 10 第 2 帧两球击倒数。第二连续两个 Strike 时第一个 Strike 的追加分是第二个 Strike 的 10 分加上第三个 Strike 第一球的分数。第三第十帧的投球次数不是固定 2 次如果第十帧打出 Strike 或 Spare会获得第三次投球机会这一帧总击倒数可以超过 10。4.2 用全局球序替代帧内数组的 ScoreKeeper很多初级实现会用一个int[10, 2]二维数组按帧存分数遇到追加分时再去数组里找“后面的球”。这种做法在连续 Strike 场景里很容易越界。我改用一个全局球序 List 记录每一次投球再用一个帧索引去扫描逻辑更接近规则本身。using System.Collections.Generic; public class BowlingScoreKeeper { private readonly Listint ballLog new Listint(); public void Reset() { ballLog.Clear(); } // pins 为本次投球实际击倒数返回当前局是否已结束 public void RecordRoll(int pins) { ballLog.Add(pins); } public bool IsGameOver() { return CalculateFrame(9) ! -1 PredictEnd(); } private bool PredictEnd() { int startIndex TotalRollsBeforeFrame(9); int rollsInTenth ballLog.Count - startIndex; if (rollsInTenth 1) { return ballLog[startIndex] ! 10; } if (rollsInTenth 2) { int first ballLog[startIndex]; int second ballLog[startIndex 1]; return first ! 10 first second ! 10; } return rollsInTenth 3; } public int GetFinalScore() { int score 0; int ballIndex 0; for (int frame 0; frame 10 ballIndex ballLog.Count; frame) { if (ballLog[ballIndex] 10) { // Strike10 后续两次击倒 score 10; if (ballIndex 1 ballLog.Count) score ballLog[ballIndex 1]; if (ballIndex 2 ballLog.Count) score ballLog[ballIndex 2]; ballIndex; // 一帧只用一次投球 } else { int first ballLog[ballIndex]; int second ballIndex 1 ballLog.Count ? ballLog[ballIndex 1] : 0; if (first second 10) { // Spare10 下一次击倒 score 10; if (ballIndex 2 ballLog.Count) score ballLog[ballIndex 2]; } else { score first second; } ballIndex 2; // 普通帧用两次投球 } } return score; } private int TotalRollsBeforeFrame(int targetFrame) { int ballIndex 0; for (int frame 0; frame targetFrame ballIndex ballLog.Count; frame) { if (ballLog[ballIndex] 10) { ballIndex; } else { ballIndex 2; } } return ballIndex; } }GetFinalScore 从第 0 帧重算总得分不缓存中间结果。这样好处是调用方任何时候调用都能拿到当前已完成帧的准确值。如果某一帧是 Strike 但后续追加球还没打此时该帧的追加分会缺一两个数但循环里针对越界已经做了边界判断不会抛异常。IsGameOver 的判定基于第十帧的投球组合第十帧前两球全中算作需要第三投Spare 同理。这个逻辑在 RecordRoll 之后调用即可。4.3 逐帧分数的延迟结算与“未结算帧”显示计分板 UI 需要逐帧显示但 strike 未结算帧不能填数字。实现上不要在 GetFinalScore 上做拆分单独写一个方法按帧推进并标记哪些帧已结算。public int GetDisplayScoreForFrame(int frameIndex) { int score 0; int ballIndex 0; for (int frame 0; frame frameIndex ballIndex ballLog.Count; frame) { if (frame frameIndex) { // 如果当前帧没有足够的后续球完成结算返回 -1 if (ballLog[ballIndex] 10) { if (ballIndex 2 ballLog.Count) return -1; } else if (ballIndex 1 ballLog.Count) { int first ballLog[ballIndex]; int second ballLog[ballIndex 1]; if (first second 10 ballIndex 2 ballLog.Count) return -1; } else { return -1; } } if (ballLog[ballIndex] 10) { score 10; if (ballIndex 1 ballLog.Count) score ballLog[ballIndex 1]; if (ballIndex 2 ballLog.Count) score ballLog[ballIndex 2]; ballIndex; } else { int first ballLog[ballIndex]; int second ballIndex 1 ballLog.Count ? ballLog[ballIndex 1] : 0; if (first second 10) { score 10; if (ballIndex 2 ballLog.Count) score ballLog[ballIndex 2]; } else { score first second; } ballIndex 2; } } return score; }调用方把返回值等于 -1 的帧显示为空格其他显示为数字。第十帧扫入三个球后GetFinalScore 会自然计完不需要单独写分支。4.4 犯规与球瓶复位的状态处理犯规规则在代码里只需要做一件事该次投球记 0但投球次数照算。如果第 1 帧第一次投球犯规就算实际撞倒了 8 瓶ballLog 里也只记 0球瓶区被撞倒的瓶要不要复位以美术表现为准。建议把犯规逻辑放在调用 RecordRoll 之前把 pins 强制改为 0。注意第十帧犯规会吃掉一次额外机会这个行为由记分类自动处理不需要额外判断。5. 从“能玩”到“真球感”WebGL 发布配置与 3 个手感技巧5.1 WebGL 下物理与存档的兼容性WebGL 构建与桌面端的最大差异是单线程和内存限制。物理步长调到 0.01 后每帧物理计算量翻倍在桌面端不是问题但发布到 WebGL 前要在 Player Settings 里开启 Incremental GC避免每次 GC 造成 100ms 级卡顿。保龄球场景的物体数量少0.01 步长通常能保住 60 帧如果目标机性能不足优先把球的 Continuous Detection 降到 Continuous Speculative而不是把步长调回 0.02。WebGL 下玩家存档会写入 IndexedDB 支撑的 IDBFS 文件系统隐私模式或存储被清空时会发生写入失败。项目源码里不要在主线程同步等待写盘把高分记录先放内存再异步尝试持久化失败时只弹一条非阻塞提示。5.2 力量槽回弹与全屏点击区的两个 UI 技巧力量滑动条在玩家松手后自动回落到起始位置这个动作要在协程里用时间插值完成而不是每帧硬设到一个固定值。回弹速度过快会让玩家觉得生硬过慢会影响下一球准备节奏推荐 0.3 秒回到零位。投球按钮的点击范围也是真实感的一部分。移动端上手指会遮挡按钮常见做法是在按钮外围放一张全透明的 Image并把它的 Raycast Target 勾上透明区域也能接收点击变相放大了点击范围。这个技巧对倒计时、重开按钮同样适用。5.3 手感验证的几个硬指标验证不要只靠眼睛看列一组可量化的指标每次调完参数照着过一遍。检查项通过标准备注高速球穿透30 m/s 出手不穿瓶未过则调步长或碰撞检测模式球瓶乱飞球瓶在 2 秒内完全静止超时说明 Bounciness 过高全中率同一角度投 10 次至少 8 次全倒不稳定就检查球瓶质心高度计分正确连续输 10、10、10前两帧显示正确用白盒测试灌入固定球序WebGL 帧率中端机型保持 55 FPS 以上不达标则关闭阴影或降粒子验证时把 Time.timeScale 调到 0.5观察球瓶被击中瞬间的碰撞法线朝向比站在屏幕前看回放更容易发现问题。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门