我做盲盒抽奖系统这套东西,前后折腾了两个多礼拜,从最开始的纯逻辑demo到后面一套完整的“盲盒代码+UI”,踩了不少坑,也沉淀了不少经验。今天把整套方案从头到尾捋一遍,从概率设计、UI框架搭建到Figma素材导入、卡顿优化和真机调试,一次性说清楚。代码和UI思路都是可以直接参考复刻的,适合Unity开发者、独立游戏制作人,还有那些想在自己项目里塞一个抽卡玩法的朋友。别的不说,先看东西。
1. 项目整体设计与方案选型
1.1 这套盲盒系统到底包含什么
先明确一件事:盲盒抽奖系统,核心不止是“抽一下出个结果”这么简单。一套能商用的盲盒玩法,至少要拆成这几块:
- 物品/奖池配置:哪些东西能抽到,每个东西什么品质、什么概率。
- 概率算法:单抽概率、保底逻辑、伪随机分布。
- 抽奖流程:点击、动画播放、结果弹出、奖励入包,每一步都有明确状态。
- UI展示:盲盒展示、开盒动画、结果卡片、抽奖记录、背包入口。
- 数据持久化:抽奖次数、剩余次数、已经获得的物品,断线重连不能丢。
我这套系统重点做了概率逻辑和UI表现两大部分。“盲盒代码”特指抽奖的核心控制层,“UI一套”则包含从主界面入口到开盒动效的完整界面体系。两者之间用事件和解耦的接口传数据,UI层不直接参与概率计算,逻辑层不管按钮长什么样。
1.2 技术选型背后的理由
做这套东西之前,我先问自己几个问题:目标平台是什么?UI复杂度多高?要不要支持批量抽?
答案是Unity引擎、移动端为主、UI动效要求中等偏上。基于这三个前提,我做了如下选型决定:
- UI框架:不引第三方重型插件,自己搭轻量UI框架。原因后面细说,核心是热词里提到的“unity ui框架”选择问题,自己写一套层级管理、窗口栈、遮罩和动画注册系统,其实比盲目引框架更好控制。
- 动画方案:UGUI + Animator为主,个别特效用DOTween,不整Shader那一套。
- 逻辑层:全部C#,用纯类做状态机,不依赖MonoBehaviour生命周期。
- 数据层:本地优先,JSON存档,后续要接服务器也不用推翻重来。
有人可能问,为什么不用现成的UI框架?我试过,确实省事,但盲盒这类玩法UI有个特点:层级切换频繁、模态窗口多、动画和状态强绑定。如果你不深度理解框架内部结构,出问题排查成本比自研还高。自己写一套一百来行的UI管理器,要遮罩给遮罩,要层级给层级,逻辑全捏在自己手里,调试起来非常爽。
1.3 整体架构与模块划分
整个系统我分成了这么几个模块:
BlindBox/ ├── Core/ // 核心逻辑层,不依赖UI │ ├── BoxConfig.cs // 奖池配置(ScriptableObject) │ ├── ProbabilityEngine.cs // 概率算法 │ ├── BlindBoxState.cs // 状态枚举 │ ├── BlindBoxController.cs// 状态机与流程控制 │ └── RewardData.cs // 奖励结果数据结构 ├── UI/ │ ├── UIManager.cs // 窗口栈与层级管理 │ ├── BoxDisplayUI.cs // 盲盒展示面板 │ ├── OpenBoxAnimUI.cs // 开盒动画UI │ └── RewardPopupUI.cs // 奖励弹窗 ├── Data/ │ └── PlayerSaveData.cs // 玩家存档 └── Effects/ ├── IdleRotate.cs // 盲盒待机旋转 └── ParticleAnchor.cs // 特效挂点核心逻辑和UI彻底分离,我用事件通知:开盒按钮点了,UI发一个事件给Controller,Controller走流程,算概率,出结果,再发事件让UI播对应动画。这样UI换皮、逻辑复用、后续加服务器都方便。
2. 盲盒核心逻辑:概率、保底与操控感
2.1 奖池配置与概率模型
奖池是我用ScriptableObject做的配置,不写死在代码里。每个条目包含物品ID、名称、品质、图标、权重、数量范围。核心字段长这样:
[System.Serializable] public class BoxItemConfig { public string itemId; // 唯一ID public string itemName; // 显示名称 public ItemRarity rarity; // 品质枚举:Common / Rare / Epic / Legendary public int weight; // 权重值,越大越容易出 public int minCount; // 单次最少获得数量 public int maxCount; // 单次最多获得数量 public Sprite icon; // 图标 }概率模型我当时第一版用的是最简单的“权重随机”,循环遍历所有条目,把权重累加,最后在总值范围内随机一个数,落在哪个区间就出哪个。实现起来三分钟搞定,但问题来了:玩家永远抽不到保底,脸黑起来连续30抽都是普通物品,体验很差。这里必须引入“保底策略”,不是可选项,是必选项。
2.2 伪随机与保底策略
盲盒抽奖最忌讳的就是纯随机,因为纯随机没有“体验曲线”。我当时写了个ProbabilityEngine,核心做两件事:伪随机分布 + 保底计数。
伪随机我参考了War3的PRD机制思路:物品的基础概率不是直接生效的,而是随着连续未命中而递增。比如传说物品基础概率只有2%,你前几抽没出,概率会逐渐上升到5%、10%,一旦出了就重置。这个策略的好处是——玩家的期望总概率保持稳定,但体验上“越抽越有可能出”,心理感受完全不同。
保底则更直接:设定一个阈值,比如30抽必出史诗或更高品质。用一个保底计数器,如果连续30次都没有抽到史诗及以上,那么第30次强制采用保底奖池。
public RewardData Roll(BoxConfig config) { // 先判断是否触发保底 if (pityCounter >= config.pityThreshold) { pityCounter = 0; return ForceRollFromGuaranteedPool(config); } // 正常权重随机 int totalWeight = config.items.Sum(i => i.weight); int roll = Random.Range(0, totalWeight); int cumulative = 0; BoxItemConfig selected = null; foreach (var item in config.items) { cumulative += item.weight; if (roll < cumulative) { selected = item; break; } } // 命中高品质重置保底,连续未出则累加 if (selected.rarity >= ItemRarity.Epic) pityCounter = 0; else pityCounter++; return BuildReward(selected); }这段代码很短,但有两个细节必须注意:pityCounter要持久化,不能因为切场景就清零;保底判定要写在正常随机之前,避免“这次该保底了,却先按普通概率出了个好东西”这种逻辑冲突。我当时就踩过这个坑,测试时保底出了个普通物品,查了半天发现是判断顺序反了。
2.3 用状态机控制开盒流程
抽奖流程不是一瞬间的事,它有动画、有等待、有前后置条件。我直接用了一个简单的状态机,状态定义如下:
public enum BlindBoxState { Idle, // 待机,可以点 Opening, // 开盒动画中,不可点 Revealing, // 结果揭晓,已锁定 Complete, // 当前轮结束 }状态机的好处是防连点。你想想,如果玩家疯狂点按钮,动画还没播完就触发下一次抽奖,整个UI逻辑会直接乱套。状态机把“可操作”和“不可操作”用状态硬约束住,按钮的interactable属性也在状态切换时同步。我在Controller里写了个方法统一处理状态流转:
private void SwitchState(BlindBoxState newState) { currentState = newState; onStateChanged?.Invoke(newState); }UI层监听这个事件,自己决定按钮置灰还是高亮、播放什么动画。逻辑层完全不持有UI引用,干净得很。实际上做到这一步,核心逻辑已经能独立跑通了——光用Test跑一万次模拟抽奖都行,这个后面讲概率平衡性验证时再展开。
3. UI框架搭建与Figma工作流
3.1 UI框架选型:自己写还是用现成的
回到热词里高频出现的“unity ui框架”。我这次没有用FairyGUI或者UGUI+扩展库,原因前面说过,但这里还是详细聊聊筛选逻辑。
做UI框架之前先看项目定位。盲盒这个玩法,UI复杂度其实是偏中等的:主界面几个按钮、弹窗、动画特效、结果展示。这种场景下一个UIManager加几个Panel脚本完全够用。如果硬上FairyGUI,学习成本、打包流程、资源规范都成倍增加。但是完全裸用UGUI也不是不行,只是弹窗叠多了层级管理会乱,所以我写了一个很轻量的UIManager,大概一百多行,核心功能是:
- 维护一个窗口栈,记录当前打开的UI;
- 提供
Open<T>()和Close<T>()泛型方法; - 自动管理遮罩(普通弹窗半透明黑底,模态弹窗不可穿透);
- 统一处理UI间的事件转发。
public class UIManager : MonoBehaviour { private Stack<UIBase> stack = new Stack<UIBase>(); public T Open<T>() where T : UIBase { // 把新窗口入栈,并隐藏上一个窗口的交互 var go = Resources.Load<GameObject>($"UI/{typeof(T).Name}"); var ui = Instantiate(go).GetComponent<T>(); if (stack.Count > 0) stack.Peek().SetModal(false); stack.Push(ui); return ui; } public void CloseCurrent() { if (stack.Count == 0) return; var top = stack.Pop(); Destroy(top.gameObject); if (stack.Count > 0) stack.Peek().SetModal(true); } }就是这种思路。你去看那些商业项目的UI框架,本质也是这个套路,只是加了解析器、事件绑定、动画队列这些东西。自己写,出了问题你能直接进源码修,不必在别人的封装里翻来翻去。
3.2 Figma导入Unity的几种方式
热词里有“如何将figma里面的ui导入到unity中”,这个问题我之前也研究过。给大家说清楚:Figma导Unity,没有一键完美导入的方案,但分情况有不同路线。
- 如果只是要样式参考:直接导出PNG或者SVG就行,图片拖进Unity当Sprite,或者用Figma自带的切片工具导出指定区域。这是最土但最稳的方法,缺点是纯静态,没法做动态变化。
- 如果是UI组件结构:Figma的auto layout和组件命名,配合社区插件可以生成UGUI结构,但效果说实话比较有限,复杂嵌套的layout转换后经常乱,你还是得手动调。
- 如果追求效率和还原度:我给你一个实操建议,Figma里把UI按层级分层命名,导出时选用“Export frames to PNG”的命名规则——``,然后Unity里用
Resources.Load按名字动态加载。这样至少能保证图标、面板底图这些资源和代码里的引用名一致,UI跑起来不会丢图。
我这次的做法是Figma里做视觉、导出切片、Unity里按需摆组件。做UI动画的时候通常还需要把Figma里的分层信息记住,否则导出来一张整图,UGUI没法子做局部移动效果。
3.3 弹窗框架与UI层级管理
盲盒UI里弹窗特别多:确认购买弹窗、奖励明细弹窗、抽奖记录弹窗、开盒动画弹窗。弹窗之间还会互相叠加,处理不好就是“新窗口被旧窗口遮挡”“点遮罩把底层关了”这类问题。
我用了一套比较实用的层级分配方案。三层Canvas:底层放常驻UI(主界面、顶部状态栏);中层放普通弹窗;顶层放全屏模态层(比如开盒动画这种不能跳过、不能穿透的)。每层Canvas的sortingOrder直接调度:
public static class UIOrder { public const int Base = 0; public const int NormalPopup = 100; public const int TopModal = 200; }这样做的最大好处是,你不需要像以前那样,每次有弹窗就把一个全屏按钮塞进去挡住背后的点击。层级和EventSystem的BlockingMask配合,事件穿透问题一次性解决。盲盒动画播放时,我直接把开盒界面放在TopModal层,玩家点啥都没反应,动画播完自动降层,这样就不存在“动画没播完就乱点”的洞。
4. 关键UI效果实现与交互细节
4.1 数字滚轮效果
热词里提到“unity中实现ui数字滚轮效果”,这个在盲盒抽奖里太常用了,抽奖次数显示、货币变动、倒计时都用得到。我写了一个数字滚轮的通用组件,原理很简单:一个Text网格放多个数字模板,通过RectTransform的anchoredPosition上下平移,加缓动,滚动结束后停在正确数字上。
核心实现思路不复杂:比如数字从5滚到12,先把终点数字按位拆分,每一位用一个纵向滚轮显示0-9,滚动时计算目标偏移量。但有几个坑必须注意:
- 网格数字模板的宽度要一致,否则位宽不对齐,数字会抖动。
- 滚动过程中要禁用文本的RaycastTarget,防止挡点击。
- 滚动结束后要精确校正到整数像素位置,否则会出现“半格数字”的模糊状态。
IEnumerator TweenDigit(int from, int to, float duration) { float elapsed = 0f; float startOffset = from * digitHeight; float endOffset = to * digitHeight; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.SmoothStep(0f, 1f, elapsed / duration); transform.anchoredPosition = new Vector2(0f, Mathf.Lerp(startOffset, endOffset, t)); yield return null; } transform.anchoredPosition = new Vector2(0f, endOffset); }实际用的时候,我做了个扩展:支持一次滚多位、支持缓动曲线自定义,还支持滚动音效回调。盲盒里那个“抽奖次数还剩多少”,每次抽完数字滚动加音效,临场感一下就上来了。
4.2 开盒动画与特效表现
盲盒的开盒动画是整个体验的高潮,我把它做成了分层结构:盲盒震屏、闪光、盒子打开、光柱特效、结果图标飞出。这五个阶段用Animator的AnimationEvent逐帧触发。
为什么要分层?因为动画里要穿插音效和逻辑回调。盒盖打开的那一帧要发“揭晓事件”,如果动画整段在Timeline里做完,逻辑回调只能靠手动对帧,麻烦且不精确。拆成阶段以后,每个阶段独立发事件,逻辑层收到事件后再驱动下一步。
具体实现时,我用Animator做盒盖开合的动画,用DOTween做震屏和光柱的渐隐渐显,结果卡片飞出用doMove加弹性缓出。整个动画期间,开盒界面挂在TopModal层,玩家不能操作。
public void PlayOpenSequence() { animator.SetTrigger("Shake"); // AnimationEvent -> OnShakeDone } public void OnShakeDone() { audioHandler.Play("box_open"); animator.SetTrigger("OpenLid"); } public void OnLidOpened() { controller.NotifyReveal(); sequence = DOTween.Sequence(); sequence.Append(lightBeam.DOFade(1f, 0.3f)); sequence.Append(rewardIcon.DOScale(1f, 0.4f).From(0.2f).SetEase(Ease.OutBack)); sequence.Append(rewardIcon.DOMoveY(..., 0.5f).SetEase(Ease.InOutQuad)); }这套东西调起来很费时间,Unity Editor里一遍一遍地看效果,但我建议大家值得做,因为盲盒的核心乐趣就是“开盒那一瞬间的反馈”,反馈做不好,前面概率设计得再好玩家也感受不到。
4.3 C# Task里更新UI的坑与正确姿势
热词里“c#task中更新ui”是高频问题。我这次确实碰到了,场景是抽奖结果异步保存时,保存过程放在Task.Run里,保存完想更新UI上的“已拥有数量”,结果直接报Trying to access a UnityEngine.Object from a non-main thread。
这里要给新手讲清楚:UGUI的组件基本都不是线程安全的,你不能在后台线程碰任何Unity API,Text.text、Image.color、Canvas.enabled统统不行。正确的姿势是把后台计算的结果用一个变量缓存,然后在主线程的Update或协程里消费。
我的做法是这样的:后台Task只做数据计算和存档序列化,完成后返回一个RewardSaveResult,在主线程的异步等待处接住结果再刷新UI。
private async void SaveAndUpdateUI(RewardData reward) { var result = await Task.Run(() => SaveSystem.SaveReward(reward)); // 这里已经回到主线程 inventoryPanel.RefreshCount(result.newCount); }async void在UI按钮事件里用是常规操作,但要注意捕获异常,不然异常直接冒到Unity主循环就白屏了。我做了一个SafeAsync扩展,内部包try-catch,报错就发日志而不影响整个流程。
5. UI卡顿优化与真机调试
5.1 先定位再优化:打开UI绘制调试
热词里有个“安卓手机调试打开ui绘制”,这个词指的是Android的“Layout Inspector”和“GPU渲染模式分析”,用来查UI卡顿的。Unity里频繁的UI重建、过度绘制都会被工具抓出来,我在调盲盒UI的时候也用了类似的思路,把Unity自带的Profiler窗口打开,重点看两个指标:Canvas.SendWillRenderCanons耗时和Canvas.BuildBatch耗时。
Unity处里UI是在CPU端合批的。如果你一个界面有几十个Image,而且它们交叉叠加、用了不同材质或图集,合批就会崩成一堆小批次,CPU时间飙高,帧率当场掉。定位手段就两步:开Profiler,看UI相关的耗时;关掉一点点组件二分法排查,看帧率回不回来。
5.2 卡顿的常见根源
盲盒UI最容易卡的地方我帮你列全:
- 图集使用不当:UI小图标没有打进图集,几十张小图分开加载上传,批次爆炸。
- RaycastTarget滥用:每个Text和Image都开着RaycastTarget,点击检测要计算的东西多了,高帧率时特别明显。
- 动画驱动导致的脏矩形:用Animator频繁改RectTransform位置和大小,会触发Unity布局重建和脏矩形,这个开销很大。
- 自定义LayoutGroup使用过度:如果盲盒网格用了大量
LayoutGroup,每次增删物品都会重新布局,卡顿的直接元凶。
5.3 Android真机调试经验
做移动端盲盒,UI不优化直接上真机是灾难。我测试用的是小米中端机,开盒动画阶段如果批次太多就掉帧。调完之后帧率稳定了,几个关键做法分享给大家:
第一,把所有UI图标打到一个图集里,用SpriteAtlas配合SpriteAtlasManager管理。盲盒物品图标多,但是一个图集装得下。第二,不需要点击的图片一律关掉RaycastTarget。第三,把数字滚轮这种高频变更的对象单独放一个子Canvas,并把它的Canvas设置为ScreenSpace - Overlay,避免它频繁变化时把整棵Canvas的脏矩形带出来。
真机调试还有个细节,Android上Profiler需要开Development Build,否则看不到UI模块数据。热词里“安卓手机调试打开ui绘制”其实就是这个意思。连接真机以后我习惯先跑一轮开盒动画,专门盯Profiler里有没有红色的峰值。
6. 常见问题与排查技巧实录
6.1 问题速查表
做这套系统期间我整理了一张问题速查表,都是实战里真碰到过的:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 抽奖结果和显示不一致 | 概率计算和UI展示用了两套数据源 | 统一改为单数据源,UI只渲染不计算 |
| 疯狂点按钮出现多个奖励弹窗 | 没有防连点锁 | 状态机加Opening状态,开启时禁用按钮 |
| 保底计数丢失 | 变量存在内存里,没持久化 | 把保底计数写进存档,每次抽完立即刷新 |
| 动画播完但奖励没入包 | UI先播动画,逻辑先入包,顺序反了 | 先入包再播动画,背包刷新通过事件通知 |
| 数字滚轮停不下来 | 缓动中又触发新滚动 | 滚动开始时强制停止旧协程 |
| Figma导出的图在Unity里发虚 | 导出尺寸不对,或者没勾选Generate Mipmap | 按2x或3x导出,UI图保持清晰,不用Mipmap |
6.2 独门避坑技巧
这里说几个文档里基本找不到的经验。
谢天谢地我做了个BoxConfig的热重载工具。ScriptableObject配置改完以后,Editor里一键reload,不用重启游戏就能刷奖池,测概率平衡性的时候效率直接翻倍。你们可以写个[CustomEditor]的脚本,改数字立刻生效。
另一个经验是关于“体验随机性”的。用纯PRD的时候,单次概率看起来合理,但玩家连抽十次可能很欧也可能很非。建议在测试阶段写一个模拟器,跑一万次抽奖,把结果分布打出来。我亲眼看过某个奖池配置我以为是2%出传说,实际跑下来一万次里一个传说都没出的情况——原因是有个低级物品权重写成了2000,把概率稀释完了。不跑模拟器,这种配置问题上真机才会暴露。
还有一个真机相关的:Android底部三键导航栏和全面屏手势区域,有时候会遮挡盲盒界面的“关闭”按钮。Unity默认的Screen.safeArea不处理就会导致按钮点不到。我在UIManager里加了一个对safeArea的适配,UI根节点按可用区域锚定,按钮不超出安全区。
最后关于存档,不要每抽一次就立即File.WriteAllText到磁盘。闪存寿命倒是其次,关键是卡顿。合理做法是抽奖结果先攒在内存里,间隔2秒自动写一次,或者切后台、切场景时触发写入。配合PlayerPrefs做临时缓存,后面再接服务器存档逻辑完全顺畅。
结尾的个人体会
整套“盲盒代码+UI”做完,我最深的一个体会是:盲盒玩法的体验上限,根本不取决于动画多华丽,而在于逻辑和UI的咬合是否严密。状态机把逻辑吃透,UI只做渲染和反馈,这样你在调动画时不会因为逻辑问题分心,在改概率时也不用担心UI报错。
如果你要自己上手做,我建议按照这个顺序来:先纯逻辑写一个没有UI的模拟版,多跑几次概率脚本,确认奖池配置没问题;再搭UI框架,把层级和事件机制跑通;最后再上动画和特效。千万别一上来就把动画做到一半再回头补逻辑,那样会有无穷无尽的牵制。
后续还可以继续扩展的方向其实不少:接入服务器抽奖接口、增加盲盒收集图鉴、增加批量开盒(十连抽)的动画表现、甚至做盲盒的展示场景让玩家能自由旋转查看手办模型。整个系统的地基打稳了,往上叠功能会顺畅很多。希望这篇文章对你有用。