
抽卡这件事玩家看到的是屏幕上闪过的光效和立绘开发者看到的其实是一串权重数组、一个计数器和一堆边界条件。我拿它练手是因为它有刚刚好的复杂度不算大但把数据建模、概率逻辑、状态管理、动效编排、本地持久化全串了一遍做完一个能跑通的版本等于把前端最常用的那套东西过了一遍手。这里说的抽卡模拟器本质是一个纯前端的概率演示小工具你配置卡池、设定稀有度和权重、点一下按钮程序按规则从池子里取结果并展示出来。它不涉及任何真实付费、充值或者交易所有数据都只在你自己的浏览器里纯技术练习用途顺带能把概率公示、结果统计做得清清楚楚。这篇文章适合两类人一类是刚学完 HTML/CSS/JS 想做点带劲东西的新手另一类是想找个小题材验证自己代码组织能力的老手。下面我按实际做下来的顺序从需求拆、概率设计、数据建模、界面动效到踩坑排查完整说一遍。1. 项目定位与技术选型1.1 先想清楚这个项目到底要解决什么很多人一上来就写Math.random()写两行就卡住了因为没想清楚要展示什么。我把需求拆成五块写在纸上再动手后面省了大量返工。模块要解决的问题新手最容易漏的点卡池数据有哪些卡、什么稀有度、权重多少权重总和不必等于 100也不必是整数抽取逻辑单抽、十连、按权重随机十连要不要独立计算还是要带保底联动保底机制多少抽必出高稀有度保底计数器存哪儿、什么时候清零结果展示逐个翻牌、稀有度区分动画期间禁止重复点击记录统计历史记录、出货率数据量上来后的存储上限这五块里真正决定体验的是第三块。没有保底的抽卡玩家抽五十次全空会觉得程序坏了有了保底但没有进度提示玩家又会觉得系统在偷偷扣他的运气。所以保底不只是逻辑它还是一个需要在界面上明示的状态——剩余多少抽、保底是否已触发这些都得让用户看得见。我最初版本把保底写成纯内部变量自己测试时都搞不清当前处于什么状态后来加了一个小的进度条才顺过来。另外提醒一句这个项目的定位要摆正它是个概率演示与前端练习不是任何形式的消费场景。界面上不出现任何真实货币符号、不做任何诱导性文案概率公示放在明面上这是我的基本要求也省去很多解释成本。1.2 技术选型原生三件套到底够不够我做第一版的时候纠结了半小时要不要上框架。最后的结论是这个项目用原生 HTML/CSS/JS 完全够甚至更合适。理由很实在。这个项目的状态其实很少当前抽了几次、保底计数、历史列表、动画是否进行中。总共四五个变量用框架的响应式状态管理属于杀鸡用牛刀。而且抽卡动效大量依赖 CSS transition 和requestAnimationFrame的时间控制框架的虚拟 DOM 更新节奏反而容易和 CSS 动画打架——你改一个 class框架重渲染一次节点动画就断了。但原生也有代价主要集中在两处一是 DOM 操作要自己管十连抽生成十个卡片节点反复抽会产生大量节点需要回收二是事件绑定要手动做尤其是十连结果里每个卡片可能还要点击查看详情事件委托就得自己写好。这两块我在第 4 节会具体说。如果你确实想用框架也不是不行选 Vue 这类轻量的更合适重点是动画层千万别让框架插手卡片节点用v-once或者干脆手动操作 DOM 都行。React 的话我建议把动画容器做成非受控组件用 ref 拿节点自己玩别去跟 diff 较劲。1.3 文件结构一个 index.html 也能活但别这么干第一版我图省事所有东西塞进一个 html 文件两天后自己都找不到代码在哪儿。第二版拆成了下面这样规模小但边界清楚gacha-demo/ ├── index.html # 结构骨架 ├── css/ │ ├── base.css # 重置与变量 │ └── card.css # 卡片与动效 ├── js/ │ ├── data.js # 卡牌与卡池配置 │ ├── rng.js # 随机与保底逻辑 │ ├── render.js # DOM 渲染 │ ├── store.js # 本地存储 │ └── main.js # 入口与事件绑定 └── assets/ # 图片资源这么拆的好处是调试时能快速定位概率不对就只看 rng.js画面不对就只看 render.js。而且rng.js完全不碰 DOM可以直接丢到 Node 里跑蒙特卡洛测试这一点在第 2.3 节会用到非常关键。如果你把所有逻辑和 DOM 混在一起测试就得开浏览器手动点效率差十倍。2. 概率模型设计权重、稀有度与保底2.1 权重抽样为什么不建议直接写百分比新手最常见写法是拿 0 到 100 的随机数去比区间比如 0 到 5 是稀有、5 到 30 是精良、30 到 100 是普通。这么写能跑但一旦策划想加一个新稀有度或者临时调整某一档的概率你就得把所有区间边界重算一遍改一次错一次。权重法就干净得多每张卡给一个权重数字抽取时把所有卡权重累加得到总数随机一个[0, total)的数从头累减减到小于等于 0 就命中。// rng.js function pickByWeight(pool) { const total pool.reduce((sum, item) sum item.weight, 0); let r Math.random() * total; for (const item of pool) { r - item.weight; if (r 0) return item; } return pool[pool.length - 1]; // 浮点误差兜底 }这段代码有两点值得说一下。第一权重的绝对值不重要只有比例有意义。普通卡权重 1000、稀有卡权重 10和普通卡权重 100、稀有卡权重 1效果完全一样。所以我习惯把最低稀有度设成 1000 这种整数方便口算比例——比如稀有卡 10 就是千分之一。第二最后那行兜底别省。Math.random()返回的是[0, 1)理论上不会等于 1但累减过程中浮点数会掉精度极端情况下可能一轮减完还是正数返回undefined就会让后面整个渲染流程崩掉。我踩过一次排查了二十分钟才发现是这里。十连抽的情况要注意如果卡池是「不放回」的抽完一张要从池子里移除权重总和会变如果是「放回」的每次独立算就行。绝大多数抽卡演示用放回模型逻辑简单且概率稳定我也建议你用放回。不放回模型在十连里会让第十抽的概率和第一抽不一样虽然差异很小但做概率公示的时候不好解释。2.2 保底机制的三种做法选哪个看你要什么手感保底是最能体现设计思维的地方我试过三种各有适合的场景。第一种是硬保底最直白一个计数器每抽加一抽到高稀有度就清零到第 N 抽强制出。实现就是抽之前判断一下计数是否达到阈值。function drawOnce(state, pool) { const needGuarantee state.pityCount state.pityThreshold - 1; let result; if (needGuarantee) { const highPool pool.filter(c c.rarity 4); result pickByWeight(highPool); } else { result pickByWeight(pool); } state.pityCount result.rarity 4 ? 0 : state.pityCount 1; return result; }硬保底的好处是玩家预期极其明确第 90 抽必出谁都算得清。坏处是前 89 抽的体感是「白给」玩家在最后几抽之前完全没有兴奋点。第二种是软保底也就是概率递增。前面若干抽按基础概率超过某个起点后每抽概率往上加一点越接近硬保底阈值加得越猛。这种做法的体感明显更好因为玩家能感觉到「快出了」而不是纯粹熬次数。function getCurrentRate(baseRate, pityCount, softStart, hardPity) { if (pityCount softStart) return baseRate; const step pityCount - softStart 1; const span hardPity - softStart; const extra (1 - baseRate) * (step / span) ** 2; // 二次曲线后段涨得快 return Math.min(baseRate extra, 1); }这里用二次曲线是我自己调出来的手感。线性递增到 100% 的话最后几抽几乎必然出玩家会觉得「保底前面全是废抽」二次曲线则是前期几乎无感、后期急速拉满抽到最后几抽的紧张感最强。这个公式里的指数和跨度都可以调没有标准答案全看你想要什么节奏。第三种是伪随机分布思路是概率随失败次数累积出一次就重置。它和软保底的区别是每次判定都独立进行只是概率在动态变化。这种做法实现稍复杂适合想要「自然随机感」的场景但对新手来说理解成本高我建议第一版先别碰。我的建议是第一版做硬保底跑通全流程第二版把硬保底换成软保底界面上的进度条改成显示当前实际概率。这个改造过程本身就能让你体会到「同一个功能加一点数学体验差一个档次」。2.3 概率自检跑十万次你的卡池才敢说没问题概率这东西眼见的全是错觉。我第一版自认为概率配得挺好实际跑了一千次高稀有度出了 4 次比设计的 1.5% 高出不少一度怀疑代码写错了后来发现只是样本太小。所以一定得写测试。因为rng.js不碰 DOM可以直接在 Node 里跑// test/probability.js const { pickByWeight, drawOnce } require(../js/rng.js); const { pool } require(../js/data.js); const TIMES 200000; const counter {}; const state { pityCount: 0, pityThreshold: 90 }; for (let i 0; i TIMES; i) { const card drawOnce(state, pool); // 假设这里十连逻辑已剥离 counter[card.rarity] (counter[card.rarity] || 0) 1; } Object.entries(counter).forEach(([rarity, count]) { console.log(稀有度${rarity}: ${count} 次, 实际 ${(count / TIMES * 100).toFixed(3)}%); });跑完把实际概率和你设计的理论概率列成表对比误差在三次抽样标准差以内就算合格。20 万次看起来多实际在本地跑不到半秒非常划算。稀有度设计概率十万次实测二十万次实测判定普通84.0%83.97%84.02%合格精良14.0%14.01%13.96%合格稀有1.5%1.49%1.51%合格传说0.5%0.53%0.51%合格这里要特别注意带保底的卡池整体出货率一定高于单抽基础概率。上面测试里如果带上保底传说档的实测会明显超过 0.5%这不是 bug是设计如此。做概率公示时要把这一点说清楚写「综合出货率」而不是直接抄基础概率否则会被较真的用户抓住。我一般会在界面上同时显示「基础概率」和「含保底综合概率」两个数字实测下来这个细节能省掉大量解释。3. 数据建模与卡池配置3.1 卡牌数据结构的字段取舍卡牌数据看着简单实际设计时容易加太多字段。我的原则是只放「渲染必用」和「逻辑必用」两类其他都算冗余。// data.js const cards [ { id: c001, name: 示例卡牌甲, rarity: 3, weight: 1000, tags: [示例标签], art: assets/c001.svg }, // ... ];id一定要有因为历史记录里你要靠 id 回查不能只存名字名字改了历史记录就断了。rarity用数字而不是字符串「传说」这种中文在排序和比较时很别扭数字可以让rarity 4这种判断直接写出来。weight我放在卡牌上而不是卡池上因为同一张卡在不同卡池里的权重经常不一样这个后面再说。tags是可选的用于筛选和展示我刚做的时候觉得没用后来想加「限定」标识时幸好留着。图片资源这块有个实际问题练手项目不可能画十套立绘所以我用的是纯 CSS 渐变加文字生成的占位卡面。做法是按稀有度给不同颜色的线性渐变中间放大号文字。这么干的好处是资源零依赖页面秒开而且改配色就是改一行变量迭代速度快得多。等逻辑稳了再换成真图成本也不高因为结构没变只是换掉一层背景。3.2 卡池和卡牌分离这一步决定你的项目能不能长大新手常犯的错误是把卡池直接写成卡牌数组一个池子一个数组。第一次迭代感觉挺爽等要做第二个池子的时候就疯了因为大部分卡是共用的复制一遍数据意味着改一次要改两处。正确做法是卡池只存引用和权重覆盖const pools { standard: { id: standard, name: 常驻池, entries: [ { cardId: c001, weight: 800 }, { cardId: c002, weight: 150 }, { cardId: c003, weight: 45 }, { cardId: c004, weight: 5 } ], pityThreshold: 90, softStart: 60 } };抽取时先把entries展开成完整的卡牌对象再去算权重。这个「展开」步骤虽然多一次映射但它让卡池和卡牌彻底解耦新增一张卡不用改卡池结构调某张卡在某个池子里的概率也不用碰卡牌本身。我在第二个池子加进来的时候几乎没动逻辑代码只是多了一组配置这就是解耦带来的收益。还有个细节权重别都用整数。有些卡你想让它的权重特别低用整数就得把其他卡抬得很高看着很别扭。直接用小数完全没问题pickByWeight里本来就是浮点运算1 和 0.001 都是合法的。3.3 本地持久化localStorage 的坑比你想的多历史记录和保底计数得存下来不然刷新一下保底归零用户会直接骂人。localStorage 是最简单的选择但它有四个坑我一路上都踩了。第一个坑是版本号。我把存储结构改成嵌套对象之后老用户浏览器里还留着旧格式的数据读取时解析出一个没有pityCount字段的对象逻辑直接跑飞。解决办法是在存储里塞一个version字段读取时先比对不一致就丢弃重置。练手项目不用做数据迁移直接丢掉最省事。const KEY gacha_state_v2; const VERSION 2; function save(state) { try { localStorage.setItem(KEY, JSON.stringify({ version: VERSION, data: state })); } catch (e) { console.warn(存储失败, e); } } function load() { const raw localStorage.getItem(KEY); if (!raw) return null; try { const parsed JSON.parse(raw); if (parsed.version ! VERSION) return null; return parsed.data; } catch (e) { return null; // 数据损坏直接重建 } }第二个坑是容量。历史记录只存 id 和时间戳一条大概几十字节存几千条没问题。但如果你偷懒把整张卡的对象都塞进去包括图片路径和标签几百条就到兆级了能不卡才怪。我的做法是历史里只存{cardId, ts, poolId}渲染时再去查表。第三个坑是写入频率。十连抽要写十次每次都JSON.stringify整个历史数组数据量大会明显掉帧。我的处理是把写入做成节流十连结束只写一次平时单抽也是延后到动画结束再写。这个改动让动效流畅度提升很明显。第四个坑是异常。隐私模式下 localStorage 可能直接抛异常不做 try-catch 的话整个页面白屏。虽然练手项目不用太较真但两行 try-catch 能省一次莫名其妙的调试很值。4. 界面与动效实现4.1 用一个状态机管住所有交互抽卡界面最容易出的 bug 是「动画没播完又点了一次」结果两批卡片叠在一起或者保底计数被加了两次。我用一个极简的状态机把这类问题堵死。const STATUS { IDLE: idle, ROLLING: rolling, REVEALING: revealing }; let status STATUS.IDLE; function handleDrawClick() { if (status ! STATUS.IDLE) return; // 非空闲一律忽略 status STATUS.ROLLING; setButtonDisabled(true); const results drawMultiple(state, pool, 10); renderCards(results, () { status STATUS.IDLE; setButtonDisabled(false); save(state); }); }关键在于handleDrawClick的第一行非空闲状态直接 return。这一行挡住的不只是重复点击还包括键盘回车触发、按钮快速双击、以及某些浏览器上 touch 和 click 同时触发的情况。我一开始以为disabled属性就够了实测在移动端快速连点还是会漏一两次加了状态判断才彻底干净。状态机还有一个好处是方便加「跳过动画」。用户点了跳过你把status从REVEALING拨回IDLE同时清理掉还在排队的定时器就行逻辑非常清晰。如果没有状态机你得去到处找「哪个动画还没结束」那才叫痛苦。4.2 十连翻牌的节奏定时器堆叠是大忌十连动画最直观的做法是给每张卡设一个延时setTimeout一个一个翻。cards.forEach((card, i) { setTimeout(() card.classList.add(flipped), i * 120); });这段代码能跑但问题很实在如果用户在这期间点了跳过或者页面被切到后台这些定时器还在队列里跑回来之后卡片状态就乱了。而且十个setTimeout的精度不可控在低端机上可能出现几张卡同时翻。更稳的做法是用一个「总时长 帧检测」的循环function revealSequentially(nodes, interval 120) { const start performance.now(); let revealed 0; function frame(now) { const expected Math.min(Math.floor((now - start) / interval) 1, nodes.length); while (revealed expected) { nodes[revealed].classList.add(flipped); revealed; } if (revealed nodes.length) { requestAnimationFrame(frame); } else { onAllRevealed(); } } requestAnimationFrame(frame); }这套写法的核心优势只有一个时间基准统一。所有翻牌节奏都由performance.now()决定不受多个定时器各自漂移的影响。页面切后台时requestAnimationFrame会自动暂停回来之后它会一次性把落下的批次补上而不是像定时器那样全部挤在一起爆掉。翻牌本身用 CSS 就够了别用 JS 改样式。核心是transform: rotateY()加backface-visibility: hidden正面背面各自一张父容器加过渡。.card-inner { transition: transform 0.32s cubic-bezier(0.34, 1.4, 0.64, 1); transform-style: preserve-3d; will-change: transform; } .card-inner.flipped { transform: rotateY(180deg); }那个cubic-bezier里的 1.4 是故意的让翻转末尾有一点回弹手感会比线性好很多。这是纯粹的体感调校我用线性过渡试过翻出来像盖章很生硬加上轻微过冲之后就有「弹出来」的感觉。will-change建议只在动画期间加动画结束移除长期挂着会占用显存。4.3 稀有度视觉分级靠三样东西拉开差距稀有度如果只靠颜色区分高稀有度很难让人眼前一亮。我用的是颜色、动效、动效时长三者叠加。稀有度卡面处理入场方式额外效果普通单色深灰渐变直接翻转无额外动效无精良蓝紫渐变翻转 轻微缩放无稀有金橙渐变翻转 光晕脉冲边框流光传说高饱和金红渐变 描边翻转 缩放 震屏粒子 延迟揭晓关键在于最后一行那个「延迟揭晓」。传说卡不要跟其他卡同一节奏翻出来先让前九张快速过完停 400 毫秒再单独翻最后一张同时加一点白闪。这个空白停顿就是情绪铺垫成本极低但效果拔群。我第一次加上这个延迟的时候自己点的时候都愣了一下那个停顿确实有分量。震屏要慎用。位移超过 6 像素会让一部分人不适而且是纯粹的无障碍风险点。我的做法是幅度控制在 4 像素以内、时长 200 毫秒以内并在设置里留一个「减弱动效」开关打开后所有震动、闪烁、粒子全部关闭只保留颜色区分。这个开关值得做不花什么时间。5. 常见问题与排查技巧5.1 概率相关的问题速查表概率出错是最难查的一类因为结果本身有随机性你看到的异常可能只是运气。我把自己踩过的问题整理成表遇到情况直接对照。现象大概率原因排查方式高稀有度一次都不出权重写成了 0 或者字符串打印池子权重总和检查类型出货率远高于设计值保底逻辑重复触发在保底分支加日志看是否每抽都进抽到的卡不在当前卡池展开 entries 时用了全局卡表对比展开后的 id 集合与配置是否一致十连里出现重复 id 异常多卡池数组被意外共享修改检查pickByWeight是否在改原数组刷新后保底计数错乱存储版本冲突或未节流打印 load 出来的对象看字段是否完整第二条我印象最深。当时为了省事把保底判断写在两个地方一个是抽卡入口一个是单抽函数内部。结果带保底的那一抽又走了一遍内层判断等于连续触发了两次保底高稀有度概率虚高一倍。解决方式很简单——保底判定只能有一个入口其他所有地方都调这个入口。这个原则比具体代码重要概率相关的判断全项目只能有一处实现写两遍必然不一致。还有一个隐蔽问题如果你在十连里做「不放回」同时又带保底那保底过滤后再从池子里取取完要记得把它从剩余池里移除。我漏过一次导致同一张卡在十连里出现两次被自己测出来了。查的时候不用怀疑随机数直接检查是不是「取完没删」。5.2 动效和 DOM 性能上的实操心得分十连抽反复点最容易出现的是内存和节点问题。我最初的做法是每次抽卡先清空容器innerHTML 再重建十个节点。抽个几十次之后明显感觉页面变卡用开发者工具看节点数没涨但内存曲线一直往上走。原因是innerHTML清空时如果节点上绑过事件监听或者有正在跑的动画这些引用不会立刻释放。我改成手动移除并用事件委托之后就好了容器上只绑一个点击监听靠closest(.card)找到具体是哪张卡卡片节点本身不绑任何事件。container.addEventListener(click, (e) { const cardEl e.target.closest(.card); if (!cardEl) return; showCardDetail(cardEl.dataset.cardId); });另一个坑是 CSS 动画和 JS 状态不同步翻牌还没结束就被判定为「已展示」。我一开始没注意跳过动画按钮点下去卡片停在半翻转的状态。后来在跳过逻辑里加了transitionend的兜底监听同时手动给节点加上终态 class两条路都走一遍无论动画有没有真的结束最终态都是对的。这种「不依赖动画事件一定触发」的思路在动效多的项目里能省很多莫名其妙的线上问题。最后一个实操心得很朴素但很有用给动画时间设一个上限。十连整体时长我控制在 2.2 秒以内超过这个时长的动画用户基本都会开始点跳过那还不如一开始就快一点。节奏感不是靠长是靠对比——快九张、慢一张比十张都慢要有效得多。