简介:一份基于C++开发的原神抽卡模拟器工程,面向原神玩家与C++学习者。该程序复刻游戏内祈愿逻辑,通过随机数生成、类与计数器实现常驻池、角色池概率分配及保底机制,并用Qt/SFML等方式提供简易交互界面。资源包共38个文件,约4.04MB,核心源码包括2个cpp、2个hpp和1个h文件,另有VS工程文件sln/vcxproj、编译中间文件obj/tlog及可执行程序exe,附带README与多处txt说明,便于运行与二次开发。目前已有1576人学习下载。爱好者可基于源码拆解抽卡概率算法、保底计数与随机种子实现,也可直接运行体验模拟抽卡流程;开发者还能参考其项目组织方式、GUI集成思路和日志记录模块,快速掌握C++游戏算法与工程协作的基础范式。 很多人第一次接触到“原神抽卡模拟器.zip”这个压缩包,是在群文件或网盘里——文件名看起来平平无奇,下载解压后里面却藏着一个能直接玩的抽卡小工具。双击index.html,浏览器弹出页面,点十连、金光闪过,五星角色和武器轮番登场,体验和游戏里的抽卡流程几乎一模一样。不夸张地说,这个zip里装的是一个完整的、离线可运行的抽卡模拟系统。
你可能会好奇,这东西到底是怎么做出来的?为什么一个小小的zip包就能跑起来?抽卡概率是怎么模拟的?保底机制又是怎么算的?如果你也想动手写一个,或者想在这个基础上改造成自己喜欢的卡池、加一点“玄学统计”功能,这篇文章就是从一个实际开发者的视角,把整套实现思路和踩过的坑完整拆开讲清楚。无论是前端新手拿来练手,还是老手想快速做一个能分发给朋友的小工具,都值得往下看一看。
1. 抽卡模拟器zip正在流行:它解决的是什么需求
1.1 三种典型使用场景
先说动机。抽卡模拟器这种东西之所以一直有人做、一直有人下载,背后其实是三类很真实的需求。
第一类是“手痒替代品”。版本更新前攒着原石不能乱抽,或者某张卡池刚开、还没想好要不要真金白银地下池子,这时候打开模拟器先抽几十发过过瘾,心理上能起到很大的“脱敏”作用。很多人从池子上线第一天抽到关池,自己在模拟器里已经抽了几百抽,等到真抽的时候反而不容易上头。
第二类是“概率验证”。这是最容易让一个模拟器火起来的场景。官方只给出综合概率和保底数,但“第几抽出金”的真实分布长什么样?软保底到底是从第几抽开始触发?把模拟器挂机跑一万抽、十万抽,统计出金分布和平均出货抽数,既能验证模拟器自己做得对不对,也能让玩家直观感受到所谓的“1.6%综合概率”意味着什么。
第三类是“前端练手”。从技术角度看,这个项目麻雀虽小,五脏俱全:概率算法、状态管理、DOM渲染、本地存储、跨设备存档,甚至打包分发,全都能覆盖到。对于一个想系统做完一个完整项目的前端学习者来说,这是一个比待办事项清单和天气应用有意思得多的选题。
1.2 为什么是zip而不是在线网站
这就要说到标题里的那个.zip了。明明做一个在线网页版,发个链接别人就能打开,为什么还要打包成压缩包分发?
核心原因有三个。一是零成本部署。个人做的模拟器属于兴趣项目,不会有太多预算去买服务器和域名,而zip包只要解压后双击就能运行,不用管后端、不用管备案,也不怕哪天服务挂了链接失效。二是分发灵活。QQ群、网盘、U盘、甚至邮件附件,都能塞一个十几兆的zip包过去,对方下载解压就能玩,不需要联网等待加载,尤其适合校园网、弱网环境下“秒开”的需求。三是数据隐私。在线版要把抽卡记录存在别人服务器上,本地版则完全由玩家自己掌握,某种意义上更能给人一种“这数据真的是我自己抽出来的”可信感。
顺便纠正一个常见误区:这个模拟器不是“安卓模拟器”那种需要安装的软件,也不需要装雷电模拟器、MUMU之类的环境。它本质上就是一个静态网页,只是被zip压缩后改了个看起来很有年代感的后缀。你解压它、打开它,全程连网都不需要。
2. 五星概率与保底算法:从官方规则到可复现代码
2.1 先理清原神的抽卡规则
要把模拟器做像,第一步是精确理解真实的抽卡规则。我把核心机制先整理成一张表,后面所有代码都围绕这张表展开。
| 规则项 | 数值 | 说明 |
|---|---|---|
| 五星基础概率 | 0.6% | 每次单抽触发五星的初始概率 |
| 五星综合概率 | 1.6% | 算上保底机制后的长期平均概率 |
| 五星硬保底 | 90抽 | 连续89抽未出五星,第90抽必出五星 |
| 软保底区间 | 约74抽后 | 从第75抽开始,出金概率显著上升 |
| 五星UP判定 | 50% | 出金时一半概率为当期UP,歪了后下一个金必为UP |
| 四星基础概率 | 5.1% | 每次单抽触发四星或以上的初始概率 |
| 四星保底 | 10抽 | 连续9抽未出四星或以上,第10抽必出 |
这里面最容易被忽略的是“软保底”。硬保底90抽大家都能理解,但如果没有软保底,90抽内的出金分布会非常平缓,玩家大量集中在80~90抽出金,这和真实体感完全不符。实测中大部分金都集中在75抽到85抽区间,前74抽的金基本属于“欧皇时刻”。所以一个合格的模拟器,必须把软保底做进去,否则抽卡体验就失真了。
2.2 核心概率代码实现
官方不会公布软保底的具体递增曲线,目前社区普遍接受的做法是:从第75抽开始,将五星概率从基础0.6%线性提升,到第90抽达到100%。也就是说在75到90这16抽区间内,每抽增加约(1 - 0.006) / 16 ≈ 6.21%的出金概率。
用代码实现就是这个样子:
function getFiveStarRate(pity) { // pity: 距离上次出金已经抽了多少抽 if (pity >= 89) return 1; // 第90抽硬保底 if (pity < 74) return 0.006; // 基础概率 // 第75抽开始软保底,线性递增到第90抽的100% const t = (pity - 74) / 16; return 0.006 + t * (1 - 0.006); } function rollOnce(state) { const r = Math.random(); const fiveStarRate = getFiveStarRate(state.pity5); if (r < fiveStarRate) { state.pity5 = 0; state.total5++; // 判断是UP还是歪 if (state.pity5Guaranteed || Math.random() < 0.5) { state.pity5Guaranteed = false; return { rarity: 5, up: true }; } else { state.pity5Guaranteed = true; // 歪了,下一个五星必UP return { rarity: 5, up: false }; } } // 未出五星时的四星判定,略,逻辑类似 }注意几个细节。state.pity5记录的是“距离上次出五星已经抽了几抽”,每次没出金就pity5++,出了金就归零。pity5Guaranteed是大小保底标记:一旦歪了立即置为true,下一次出金时强制走UP分支,并把它复位为false。这个标记在模拟器里一定不能丢,它是玩家口中的“大保底”。
还有一点:游戏中五星和四星判定并不是简单的“先判定五星,再判定四星”。真实机制里,如果一次判定触发了五星,四星的保底计数并不会清零,而且五星会优先占用当前抽卡结果。模拟器为了简化体验,可以采用“若未出五星,再判定四星”的写法,这样和真实规则在大样本下几乎无差异,但逻辑会简单很多。
2.3 十连流程与保底状态更新
手动一次一次点单抽还行,但“十连”是标配功能。十连最好不要循环调用10次单抽函数就完事,而是把十次结果一次性算完,最后再统一渲染、统一落库。原因后面“踩坑”部分会详细讲,这里先记住结论:抽卡计算和UI更新要分离。
function rollTen(state) { const results = []; for (let i = 0; i < 10; i++) { results.push(rollOnce(state)); } saveState(state); // 一次性写入缓存 renderResults(results); // 统一渲染动画 }这样做还有一个隐藏好处:玩家点十连后立刻关掉页面,状态也不会停留在“抽了一半”的中间态。每次点击抽卡都是一个完整事务,要么十抽全部生效,要么一抽都不生效。
3. 抽卡记录与存档:localStorage、导出JSON与跨设备迁移
3.1 存档数据结构设计
一个抽卡模拟器如果刷新之后抽卡记录全没了,那它连及格线都达不到。所以存档是必须的。我用的数据结构大致长这样:
const saveData = { schemaVersion: 1, // 存档结构版本号,方便以后迁移 uid: 'sim-2025-07-xxxxxx', pity5: 0, // 五星水位 pity5Guaranteed: false, // 是否处于大保底 pity4: 0, // 四星水位 pity4Guaranteed: false, // 四星大保底 totalPulls: 0, // 总抽数 fiveStarCount: 0, fourStarCount: 0, history: [] // 完整的抽卡历史记录,每一条包含角色/武器名、星级、是否UP、时间戳 };schemaVersion是我强烈建议保留的字段。你永远不知道三个月后自己会往存档里加什么新字段,有了版本号,读旧存档时就能做兼容迁移,而不是直接解析失败导致玩家数据全丢。
3.2 事务性写入与异常降级
localStorage 是一个同步API,性能足够应付抽卡模拟器的写入频率。但有几个坑必须处理。
第一个坑是“隐私模式下的异常”。Safari的无痕浏览、某些浏览器的严格模式,以及部分从zip包直接打开页面的场景,直接调用localStorage.setItem可能直接抛SecurityError异常。如果不做处理,玩家一点抽卡整个页面就白屏。我的做法是把所有存档操作封装成一个函数,内部用try...catch包住,一旦写入失败就降级为“内存存档”,同时在界面上显示一行提示“当前浏览器不支持持久化存档,本次运行记录不会被保存”。
const storage = { get(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (e) { return null; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { this.memoryStore = value; } // 降级 } };第二个坑是“连抽时状态错乱”。十连过程中如果把saveState放在每次单抽之后,一旦中途有异常,就可能只写入了前几抽的数据。所以我在前面特意强调:先算完十连结果,再统一保存。这个习惯能避免绝大多数存档不一致的问题。
3.3 导出/导入与跨设备迁移
zip分发的天然弱点是“换一台电脑就跑掉了”。为了解决这个问题,我加了导出和导入功能。导出就是把saveData序列化成一段JSON字符串,下载成.json文件;导入则是在新设备上选择该文件并解析覆盖当前存档。
这个功能对模拟器来说不是锦上添花,而是刚需。想想这个场景:你从网盘下了一个模拟器zip,抽了半个月,有感情了,某天换了台电脑,如果你不能导出旧存档,一切从零开始,那大概率就直接弃坑了。有了导入导出,哪怕把zip发给十个朋友,他们各自的抽卡记录也都能独立保存和迁移。
实现上没有技术难点,核心是用URL.createObjectURL生成下载链接,以及用FileReader读取文件。
function exportSave() { const blob = new Blob([JSON.stringify(saveData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'genshin-sim-save.json'; a.click(); URL.revokeObjectURL(url); }4. 从本地页面到zip分发包:文件组织、加密与版本更新
4.1 目录结构与打包规范
项目工程结构我建议做成这个样子,简单清晰,不要引入构建工具:
genshin-sim/ ├── index.html # 入口页面 ├── css/ │ └── style.css ├── js/ │ ├── data.js # 卡池数据(角色、武器、概率配置) │ ├── gacha.js # 抽卡核心逻辑 │ ├── storage.js # 存档读写 │ └── ui.js # 界面渲染与交互 └── assets/ └── images/ # 星级特效、角色立绘等素材打包前有两条铁律:一是所有文件名必须用英文,不要在zip里出现中文文件名。Windows系统自带解压器中文字符经常乱码,一乱码路径就找不到资源,玩家体验直接归零。二是不要混入任何构建缓存、.git目录、node_modules这类垃圾文件,压缩包尽量控制在几MB以内,毕竟大家的下载速度和U盘空间都有限。
4.2 加密压缩与存档位置
有些作者不希望玩家随便改卡池配置,会在压缩时给zip加密码。这个做法可以理解,但要说清楚:前端代码本来就是完全可见的,玩家按F12打开开发者工具,任何配置都一览无余,加密zip顶多算个防君子不防小人的措施。它的真实价值是防止不懂技术的玩家解压后乱改文件导致程序报错,并不是真正意义上的“加密保护”。
如果真的要用密码压缩,建议选择AES256加密方式。老旧的ZipCrypto方式在网盘在线解压、系统自带解压工具下的兼容性都比较差。还有一点容易被忽略:如果你打算后续继续发布小版本更新,压缩包密码要保持稳定,否则老玩家更新时需要重新询问密码,很影响维护节奏。
至于存档放哪里,很多第一次做zip分发工具的人都会踩同一个坑:把存档文件放在zip解压后的目录里。这个方案在存档跟随zip包走的情况下看似合理,实际上隐患很大——玩家把zip解压到A目录,运行了几次之后换了B目录,存档就“丢了”;更别说有人解压两份包当成两个副本玩。正确的做法是让存档走浏览器localStorage,它跟域名/本地路径绑定,不依赖解压目录位置,而且天然支持读取、修改和迁移。
4.3 版本更新时的兼容策略
模拟器也是会长出新功能的。今天加个角色池,明天加个统计图表,后天改成支持武器池定轨。每次更新都会带来存档结构的变化,如果没有版本兼容策略,老玩家的存档很可能在打开新版zip时直接白屏或重置。
我的做法是:读取存档时先检查schemaVersion,把它和目标版本号做对比,再执行对应的迁移函数。
function migrate(data) { if (data.schemaVersion === 1) { data.weaponPool = { pity: 0, epitomizedPath: 0 }; data.schemaVersion = 2; } if (data.schemaVersion < 3) { // 新的字段初始化 data.schemaVersion = 3; } return data; }这样老玩家的水位、历史记录、统计信息全都能平滑迁移到新版模拟器里,不会有“更新一次从零开始”的挫败感。
5. 实测踩坑清单:file协议、连抽渲染与概率校验
5.1 file协议下的模块加载问题
这是整个开发过程中最隐蔽的一个坑。很多人在写代码时理所当然地用ES Module,也就是<script type="module">的方式来组织JavaScript代码,本地用Live Server跑得好好的,一旦打包成zip发给别人,对方双击index.html,页面却一片空白,控制台报一串跨域错误。
原因在于:浏览器对file://协议下的ES Module解析有严格的CORS限制,很多浏览器直接不允许以file://页面加载本地模块文件。解决方案很简单:不用模块语法,改用普通script标签按依赖顺序加载。先加载data.js,再加载gacha.js、storage.js,最后加载ui.js。虽然丧失了模块化的整洁性,但这个项目体量小,全局变量完全够用。
<script src="js/data.js"></script> <script src="js/gacha.js"></script> <script src="js/storage.js"></script> <script src="js/ui.js"></script>记住这个原则:凡是打算双击html打开的本地工具项目,一律别用type="module",也别默认依赖任何构建产物的绝对路径。
5.2 连抽渲染性能
第一次做完十连功能时,我遇到一个很典型的问题:动画特别卡。点击十连后,页面逐条插入新卡片,浏览器在短时间内频繁触发布局重排,特别是在低配电脑或老旧浏览器上,十张卡插完要好几秒。
解决方案是“算渲染分离”。先把十抽的结果全部计算出来,再通过一次DocumentFragment或字符串拼接一次性更新到页面上,最后用CSS动画统一做进入效果。实测改善非常明显,从原来的卡顿变得丝滑。
抽卡计算(纯逻辑,毫秒级) → 保存存档(一次写入) → 构建卡片列表 → 一次性渲染 → 播放动画另外提一句动画的粒度。我见过有些人把金光特效做得过度华丽,连抽时特变频闪,看着头晕。轻量级工具不要在本机动画上投入太多,一个简单的渐变过渡足够表达“出金了”的爽感。
5.3 一万抽自检概率
写概率算法最怕的是“觉得自己写对了,实际偏差很大”。我的习惯是内置一个自检函数,直接告诉模拟器自动抽取一万次,然后输出结果:
总抽数:10000 五星总数:162 四星总数:515 平均出货抽数:61.73 五星综合概率:1.62%把统计结果和官方综合概率1.6%对比,只要在误差范围内,就说明软保底曲线调得比较合理。如果综合概率严重偏低或偏高,那大概率是软保底递增公式写错了,或者出金后的水位重置逻辑有bug。
还有一个容易忽略的细节:Math.random()是伪随机数生成器,跑上万次统计没问题,但如果想更严谨地做一些分布分析,可以用crypto.getRandomValues()生成更高质量的随机数。对于抽卡模拟器本身,Math.random()已经足够;但如果你的统计结果要发到论坛上作为依据,被人质疑随机性就尴尬了,这时候换成加密安全随机数更稳妥。
这个模拟器做下来,我自己最有体会的一点是:技术本身都不难,难的是把所有体验细节串起来。从单抽到十连,从概率判定到软保底曲线,从本地存档到跨设备迁移,每一环单独拆开都没什么含量,但组合成一个让别人愿意下载、愿意传阅、愿意玩上几周的zip小工具,靠的就是这些细活。如果你也和当时的我一样,正在找一个“能完整做完、学得到东西、还可以拿去分享”的前端项目,直接从“原神抽卡模拟器”这个方向下手,错不了。
本文还有配套的精品资源,点击获取