原神抽卡模拟器zip:概率算法、保底机制与前端实现全拆解 简介一份基于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 typemodule的方式来组织JavaScript代码本地用Live Server跑得好好的一旦打包成zip发给别人对方双击index.html页面却一片空白控制台报一串跨域错误。原因在于浏览器对file://协议下的ES Module解析有严格的CORS限制很多浏览器直接不允许以file://页面加载本地模块文件。解决方案很简单不用模块语法改用普通script标签按依赖顺序加载。先加载data.js再加载gacha.js、storage.js最后加载ui.js。虽然丧失了模块化的整洁性但这个项目体量小全局变量完全够用。script srcjs/data.js/script script srcjs/gacha.js/script script srcjs/storage.js/script script srcjs/ui.js/script记住这个原则凡是打算双击html打开的本地工具项目一律别用typemodule也别默认依赖任何构建产物的绝对路径。5.2 连抽渲染性能第一次做完十连功能时我遇到一个很典型的问题动画特别卡。点击十连后页面逐条插入新卡片浏览器在短时间内频繁触发布局重排特别是在低配电脑或老旧浏览器上十张卡插完要好几秒。解决方案是“算渲染分离”。先把十抽的结果全部计算出来再通过一次DocumentFragment或字符串拼接一次性更新到页面上最后用CSS动画统一做进入效果。实测改善非常明显从原来的卡顿变得丝滑。抽卡计算纯逻辑毫秒级 → 保存存档一次写入 → 构建卡片列表 → 一次性渲染 → 播放动画另外提一句动画的粒度。我见过有些人把金光特效做得过度华丽连抽时特变频闪看着头晕。轻量级工具不要在本机动画上投入太多一个简单的渐变过渡足够表达“出金了”的爽感。5.3 一万抽自检概率写概率算法最怕的是“觉得自己写对了实际偏差很大”。我的习惯是内置一个自检函数直接告诉模拟器自动抽取一万次然后输出结果总抽数10000 五星总数162 四星总数515 平均出货抽数61.73 五星综合概率1.62%把统计结果和官方综合概率1.6%对比只要在误差范围内就说明软保底曲线调得比较合理。如果综合概率严重偏低或偏高那大概率是软保底递增公式写错了或者出金后的水位重置逻辑有bug。还有一个容易忽略的细节Math.random()是伪随机数生成器跑上万次统计没问题但如果想更严谨地做一些分布分析可以用crypto.getRandomValues()生成更高质量的随机数。对于抽卡模拟器本身Math.random()已经足够但如果你的统计结果要发到论坛上作为依据被人质疑随机性就尴尬了这时候换成加密安全随机数更稳妥。这个模拟器做下来我自己最有体会的一点是技术本身都不难难的是把所有体验细节串起来。从单抽到十连从概率判定到软保底曲线从本地存档到跨设备迁移每一环单独拆开都没什么含量但组合成一个让别人愿意下载、愿意传阅、愿意玩上几周的zip小工具靠的就是这些细活。如果你也和当时的我一样正在找一个“能完整做完、学得到东西、还可以拿去分享”的前端项目直接从“原神抽卡模拟器”这个方向下手错不了。本文还有配套的精品资源点击获取