在《胜利女神:NIKKE》这类养成游戏中,装备洗练是提升战力的核心环节之一,但洗练结果完全随机,手动计算期望、记录配置、对比方案非常痛苦。尤其是当你需要在不同设备之间迁移存档,或者想针对自己的词条池做个性化模拟时,普通计算器根本不够用。本文会围绕“装备洗练计算器”的完整实现,重点讲解云存档和自定义模拟这两个进阶能力,并给出可复制的前端代码。无论你是游戏工具爱好者,还是想练手前端项目,都能从这套方案里找到可以落地的思路。
1. 背景与核心概念
1.1 装备洗练计算器是什么
装备洗练是养成游戏中常见的随机词条玩法。玩家消耗洗练材料,系统从词条池中随机生成若干条属性,可能是攻击、防御、暴击率等不同词条,也可能出现更高稀有度的特殊词条。由于词条组合千变万化,手动计算“我要洗多少次才能出理想属性”几乎不可能,于是就有了装备洗练计算器。
它本质上是一个概率计算与模拟工具:
- 根据词条池、词条权重、洗练次数,计算目标词条组合出现的理论概率。
- 通过大量模拟,给出接近真实情况的统计结果,比如“模拟一万次,成功次数约为 48 次”。
- 帮助玩家决定优先洗哪个部位、准备多少材料,避免盲目消耗。
1.2 为什么需要云存档和自定义模拟
普通计算器通常有几个痛点:
- 参数写死,不能适配不同玩家的角色等级、词条池、目标词条。
- 配置结果只存在本地,换设备或清理浏览器后就丢失。
- 无法同时记录多个方案的模拟结果,不方便对比。
云存档解决的是“数据可迁移、可恢复”的问题。把用户的参数、历史模拟记录、目标配置保存到 JSON 文件或云端数据库,换设备时导入即可,不用重新设置。
自定义模拟解决的是“个性化计算”的问题。玩家可以自己配置词条池、目标属性、材料成本、洗练次数上限,让计算结果真正符合自己的账号现状,而不是一个通用公式。
2. 方案设计与技术选型
2.1 功能拆解
先明确一个最小可用的装备洗练计算器需要哪些功能:
- 词条池管理:维护词条名称、稀有度、权重。
- 目标词条配置:让用户选择期望出现的词条组合。
- 洗练模拟:根据配置随机生成洗练结果,统计成功率。
- 自定义模拟参数:模拟次数、保底规则、是否允许重复词条等。
- 本地存档:用户配置和模拟记录自动保存到浏览器 localStorage。
- 云存档:通过导出 JSON 文件实现跨设备迁移,或接入后端接口实现真正云同步。
这套设计既照顾了纯前端工具的轻量性,又保留了后续扩展后端的能力。
2.2 技术选型
本文示例采用纯前端技术栈,零依赖、可直接运行:
- 语言:HTML + CSS + JavaScript
- 存储:localStorage(本地)+ JSON 文件导入导出(云存档的轻量替代)
- 运行环境:现代浏览器(Chrome、Edge、Firefox)
- 构建工具:不需要,单 HTML 文件即可
如果你计划接入真正的云端同步,可以在本文基础上增加后端 API 或云数据库(如 Firebase、Supabase),但核心模拟逻辑不需要改变。
2.3 项目结构
为了便于理解,我们先设计文件结构。虽然有多个文件,但最终可以合并成一个 HTML 发布。
equip-simulator/ ├── index.html // 页面结构 ├── style.css // 样式 ├── app.js // 核心逻辑 └── README.md // 说明文档如果只是为了快速体验,可以直接把 CSS 和 JS 写到 index.html 里。本文为了阅读清晰,按单文件示例讲解,所有核心代码都放在一个 HTML 中。
3. 核心模块实现
3.1 洗练概率模型
洗练的本质是一次带权重的随机抽取。
假设某件装备的词条池如下:
| 词条名 | 权重 |
|---|---|
| 攻击力 +3% | 30 |
| 防御力 +3% | 20 |
| 暴击率 +1% | 10 |
| 暴击伤害 +5% | 5 |
| 生命值 +2% | 15 |
| 效果命中 +2% | 10 |
每次洗练会从词条池中随机抽取一个词条,抽取概率由权重占比决定。如果允许重复词条,那么每次抽取都是独立事件;如果不允许重复,则需要移除已抽到的词条。
计算目标词条出现概率的公式是:
目标词条概率 = 目标词条权重之和 / 词条池权重之和多次模拟则直接按概率随机生成结果,统计目标组合出现的次数。
3.2 自定义模拟模块
自定义模拟需要暴露以下参数:
- 模拟次数
- 词条池(用户可增删改)
- 目标词条组合
- 是否允许重复词条
- 每次洗练消耗的材料数
模拟流程如下:
- 根据用户配置构建词条池数组。
- 循环指定次数,每次随机抽取词条。
- 判断抽取结果是否满足目标组合。
- 统计成功次数和成功率。
- 输出模拟结果,并计算平均消耗。
这里要注意,模拟次数越大,结果越接近理论概率,但浏览器计算量也会增大。建议默认模拟 1000 次,用户可自行调整。
3.3 本地存档与云存档方案
本地存档非常适合保存工具类配置。使用 localStorage 的代码很简单:
localStorage.setItem('equipSimConfig', JSON.stringify(config)); const saved = JSON.parse(localStorage.getItem('equipSimConfig'));但 localStorage 有容量限制(通常 5MB 左右),且只保存在当前浏览器。解决跨设备问题,最简单的方式是提供“导出存档”和“导入存档”功能。
导出存档的核心是生成一个 JSON 文件,然后触发浏览器下载:
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'equip-sim-config.json'; a.click(); URL.revokeObjectURL(url);导入存档则通过 file input 读取用户上传的 JSON 文件:
const reader = new FileReader(); reader.onload = function(e) { const config = JSON.parse(e.target.result); applyConfig(config); localStorage.setItem('equipSimConfig', JSON.stringify(config)); }; reader.readAsText(file);这种“导出/导入”方式没有真正上传到服务器,但已经足够让用户把配置存到网盘、邮箱或云盘,实现跨设备迁移。如果后续要接入真正的云同步,只需要将导出接口替换为 POST 请求,将导入接口替换为 GET 请求即可。
4. 完整实战:从零实现一个装备洗练计算器
接下来我们实现一个可运行的单文件版本。为了方便讲解,我将代码分成三个部分:HTML 结构、CSS 样式、JavaScript 逻辑。你可以直接复制到本地 index.html 中运行。
4.1 HTML 页面结构
首先构建页面布局。页面分为三个区域:
- 参数配置区
- 词条池配置区
- 结果展示区
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>装备洗练计算器</title> <style> /* 样式写在后面 */ </style> </head> <body> <div class="container"> <h1>装备洗练计算器</h1> <p class="subtitle">支持自定义模拟、云存档导入导出</p> <div class="card"> <h2>模拟参数</h2> <div class="form-group"> <label for="simCount">模拟次数</label> <input type="number" id="simCount" value="1000" min="100" max="50000"> </div> <div class="form-group"> <label for="allowRepeat">允许重复词条</label> <input type="checkbox" id="allowRepeat" checked> </div> <button id="simulateBtn">开始模拟</button> </div> <div class="card"> <h2>词条池配置</h2> <p>每行一个词条,格式:词条名|权重</p> <textarea id="poolInput" rows="6"> 攻击力+3%|30 防御力+3%|20 暴击率+1%|10 暴击伤害+5%|5 生命值+2%|15 效果命中+2%|10 </textarea> </div> <div class="card"> <h2>目标词条组合</h2> <p>每行一个词条,需要模拟结果同时包含这些词条</p> <textarea id="targetInput" rows="3"> 攻击力+3% 暴击率+1% </textarea> </div> <div class="card"> <h2>结果</h2> <div id="result"> <p>尚未运行模拟</p> </div> </div> <div class="card"> <h2>存档管理</h2> <button id="saveBtn">保存到本地</button> <button id="exportBtn">导出存档</button> <button id="importBtn">导入存档</button> <input type="file" id="importFile" accept=".json" style="display:none"> </div> </div> <script> // JS 逻辑写在后面 </script> </body> </html>4.2 CSS 样式
为了让页面看起来更清爽,加上简单的卡片式布局。这里只保留核心样式,你可以按需美化。
* { box-sizing: border-box; } body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f7fa; margin: 0; padding: 20px; color: #333; } .container { max-width: 720px; margin: 0 auto; } .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 20px; margin-bottom: 16px; } .card h2 { margin-top: 0; font-size: 20px; border-left: 4px solid #4a90d9; padding-left: 10px; } .form-group { margin-bottom: 12px; } label { display: block; margin-bottom: 4px; font-weight: 600; } input[type="number"] { width: 120px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; } input[type="checkbox"] { width: 18px; height: 18px; margin-top: 8px; } textarea { width: 100%; border: 1px solid #ccc; border-radius: 6px; padding: 10px; font-family: monospace; font-size: 14px; } button { background: #4a90d9; color: #fff; border: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; cursor: pointer; margin-right: 8px; margin-top: 8px; } button:hover { background: #357abd; } #result { margin-top: 10px; font-size: 14px; line-height: 1.8; }4.3 JavaScript 核心逻辑
这是整个计算器最重要的一部分。我会按模块拆解代码,然后说明运行逻辑。
// ====== 状态管理 ====== let config = { simCount: 1000, allowRepeat: true, pool: [], target: [] }; // ====== 基础函数 ====== function parsePool(text) { return text.split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .map(line => { const [name, weightStr] = line.split('|'); return { name: name.trim(), weight: parseFloat(weightStr) || 0 }; }) .filter(item => item.name.length > 0 && item.weight > 0); } function parseTarget(text) { return text.split('\n') .map(line => line.trim()) .filter(line => line.length > 0); } // ====== 洗练模拟 ====== function runSimulation() { const pool = config.pool; const target = config.target; const simCount = config.simCount; const allowRepeat = config.allowRepeat; if (pool.length === 0) { alert('词条池不能为空'); return null; } if (target.some(t => !pool.some(p => p.name === t))) { alert('目标词条不在词条池中,请检查配置'); return null; } const cdf = buildCDF(pool); let successCount = 0; const MAX_LOOP = 50000; for (let i = 0; i < Math.min(simCount, MAX_LOOP); i++) { const result = drawOnce(cdf, pool, allowRepeat); const isSuccess = target.every(t => result.includes(t)); if (isSuccess) { successCount++; } } const actualCount = Math.min(simCount, MAX_LOOP); const successRate = (successCount / actualCount * 100).toFixed(2); return { successCount: successCount, actualCount: actualCount, successRate: successRate }; } function buildCDF(pool) { const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0); let cum = 0; return pool.map(item => { cum += item.weight / totalWeight; return cum; }); } function drawOnce(cdf, pool, allowRepeat) { if (allowRepeat) { const r = Math.random(); let idx = 0; for (let i = 0; i < cdf.length; i++) { if (r <= cdf[i]) { idx = i; break; } } return [pool[idx].name]; } else { // 不重复抽取:每次从剩余词条中重新计算概率 const remainingPool = [...pool]; const result = []; while (remainingPool.length > 0) { const totalWeight = remainingPool.reduce((sum, item) => sum + item.weight, 0); const r = Math.random(); let cum = 0; let chosenIndex = 0; for (let i = 0; i < remainingPool.length; i++) { cum += remainingPool[i].weight / totalWeight; if (r <= cum) { chosenIndex = i; break; } } result.push(remainingPool[chosenIndex].name); remainingPool.splice(chosenIndex, 1); } return result; } }这段代码中,buildCDF构建了一个累积分布函数数组,用来按权重随机抽取词条。drawOnce负责执行一次完整洗练:
- 如果允许重复,则每次从完整词条池中抽取一个词条。
- 如果不允许重复,则会从剩余词条池中按权重抽取,直到所有词条被抽完。
注意,在不允许重复的情况下,每次抽取后都要重新计算权重占比,否则概率会不准确。
接下来是界面交互逻辑:
// ====== 界面绑定 ====== const simCountInput = document.getElementById('simCount'); const allowRepeatInput = document.getElementById('allowRepeat'); const poolInput = document.getElementById('poolInput'); const targetInput = document.getElementById('targetInput'); const simulateBtn = document.getElementById('simulateBtn'); const resultDiv = document.getElementById('result'); const saveBtn = document.getElementById('saveBtn'); const exportBtn = document.getElementById('exportBtn'); const importBtn = document.getElementById('importBtn'); const importFile = document.getElementById('importFile'); function readForm() { config.simCount = parseInt(simCountInput.value, 10) || 1000; config.allowRepeat = allowRepeatInput.checked; config.pool = parsePool(poolInput.value); config.target = parseTarget(targetInput.value); } function writeForm() { simCountInput.value = config.simCount; allowRepeatInput.checked = config.allowRepeat; poolInput.value = config.pool.map(p => `${p.name}|${p.weight}`).join('\n'); targetInput.value = config.target.join('\n'); } simulateBtn.addEventListener('click', function() { readForm(); const result = runSimulation(); if (result) { resultDiv.innerHTML = ` <p>模拟次数:${result.actualCount}</p> <p>成功次数:${result.successCount}</p> <p>成功率:${result.successRate}%</p> <p>平均消耗:${config.simCount} 次洗练需要 ${result.actualCount * 1} 个材料(按每次消耗 1 个材料估算)</p> `; // 将结果保存到 localStorage config.lastResult = result; localStorage.setItem('equipSimConfig', JSON.stringify(config)); } }); saveBtn.addEventListener('click', function() { readForm(); localStorage.setItem('equipSimConfig', JSON.stringify(config)); alert('已保存到本地浏览器'); }); exportBtn.addEventListener('click', function() { readForm(); const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'equip-sim-config.json'; a.click(); URL.revokeObjectURL(url); }); importBtn.addEventListener('click', function() { importFile.click(); }); importFile.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(event) { try { const parsed = JSON.parse(event.target.result); if (parsed.pool && parsed.target) { config = parsed; writeForm(); localStorage.setItem('equipSimConfig', JSON.stringify(config)); alert('存档导入成功'); } else { alert('存档格式不正确'); } } catch (err) { alert('解析存档失败:' + err.message); } }; reader.readAsText(file); e.target.value = ''; }); // ====== 初始化 ====== function init() { const saved = localStorage.getItem('equipSimConfig'); if (saved) { try { config = JSON.parse(saved); if (!config.pool || !config.target) { config = { simCount: 1000, allowRepeat: true, pool: [], target: [] }; } } catch (e) { console.warn('本地存档解析失败,使用默认配置'); } } writeForm(); } init();4.4 运行与验证
把以上代码复制到一个.html文件中,用浏览器打开,你会看到如下界面:
- 默认词条池包含 6 个词条。
- 默认目标组合是“攻击力+3%”和“暴击率+1%”。
- 点击“开始模拟”,结果区域会显示模拟次数、成功次数、成功率。
如果你点击“导出存档”,浏览器会下载一个 JSON 文件。把这个文件上传到网盘或发送到另一台设备,再打开计算器点击“导入存档”,选择该文件,配置就会恢复。这就是云存档的轻量实现。
4.5 结果说明
需要注意,模拟结果本身存在随机误差。当模拟次数为 1000 次时,成功率可能在理论概率附近波动,但不会完全一致。这是正常现象。一般来说:
- 模拟次数越多,结果越稳定。
- 如果模拟次数太少,统计结果可能偏离真实概率。
- 在“不允许重复词条”模式下,同一个目标词条只会出现一次,会影响后续概率计算,因此成功率会比“允许重复”模式低。
5. 常见问题与排查思路
5.1 模拟成功率总是 0%
可能原因:
- 目标词条组合里有词条不在词条池中。
- 词条池权重全为 0,导致无法抽取。
- 模拟次数太少,目标词条出现的概率极低,1000 次内没抽中。
排查方法:
- 检查目标词条是否严格匹配词条池中的名称,包括空格、符号。
- 检查词条池每行是否都包含
|分隔符。 - 尝试增加模拟次数到 50000,看成功率是否大于 0。
5.2 导出的存档导入后配置丢失
常见原因:
- 导入时没有等待文件读取完成就关闭页面。
- JSON 文件被篡改,字段缺失。
- 浏览器限制 localStorage 写入。
解决方案:
- 在导入回调中增加错误提示,避免静默失败。
- 导出前用
JSON.stringify(config, null, 2)生成可读格式,方便检查。 - 如果 localStorage 不可用,可以增加 try/catch,并提示用户使用导出功能。
5.3 模拟次数过多导致页面卡顿
当我们设置 50000 次模拟时,浏览器主线程会持续运行,可能卡住页面。优化方向:
- 用
setTimeout或requestAnimationFrame把计算拆成多个帧执行。 - 使用 Web Worker 进行后台计算,避免阻塞 UI。
- 限制最大模拟次数,比如 50000 次,超出时提示用户。
代码中已经用Math.min(simCount, MAX_LOOP)做了最大限制,但更好的做法是使用 Web Worker。下面是一个简化的 worker 使用思路:
const worker = new Worker('sim-worker.js'); worker.onmessage = function(e) { resultDiv.innerHTML = `成功率:${e.data.successRate}%`; }; worker.postMessage({ pool, target, simCount, allowRepeat });Web Worker 内部可以放置与runSimulation相同的逻辑,这样不会阻塞页面。
5.4 对存档安全有疑虑
由于纯前端导出 JSON,存档内容没有加密,任何人都可以查看。如果后续要接入真正的云同步,建议对存档进行加密传输,至少使用 HTTPS,并在服务端对用户身份进行鉴权。
6. 最佳实践与工程建议
6.1 数据模型设计要考虑扩展性
对于游戏工具类项目,数据结构不要写死。建议使用以下模型:
{ "version": 1, "simCount": 1000, "allowRepeat": true, "pool": [ { "name": "攻击力+3%", "weight": 30 } ], "target": ["攻击力+3%", "暴击率+1%"], "lastResult": { "successCount": 12, "actualCount": 1000, "successRate": "1.20" } }保留version字段,后续升级数据结构时可以写迁移逻辑,兼容旧存档。
6.2 云存档方案的分层设计
如果你不想停留在“导出/导入”阶段,可以设计下面三层:
- 第一层:本地 localStorage,保证用户离线可用。
- 第二层:导出 / 导入 JSON,让用户可以手动备份。
- 第三层:远程 API,实现登录后自动云同步。
远程 API 的核心接口很简单:
POST /api/save # 保存存档 GET /api/load # 加载存档请求时携带用户 token,服务端将 JSON 存档存储到数据库。前端只需要在模拟成功后调用POST /api/save,在页面加载时调用GET /api/load即可。
6.3 模拟准确性验证
在编写概率模拟代码时,建议先建立一个“理论概率”对照表。例如:
- 词条池总共 6 个词条,目标词条组合为 2 个词条,且允许重复。
- 单次洗练抽中第一个目标词条的概率 = 30 / 100 = 30%。
- 抽中第二个目标词条的概率 = 10 / 100 = 10%。
- 同时抽中这两个词条(单次判定为同时包含)的理论概率 = 30% × 10% = 3%。
用这个理论值去对比模拟结果,如果差距在 1% 以内,说明模拟逻辑基本可靠。
6.4 安全性注意事项
- 如果接入远程同步,不要在前端写明文数据库密码。
- 用户存档可能包含敏感信息,导出时建议增加密码保护。
- 涉及用户上传 JSON 时,一定要做字段校验,防止 XSS 攻击,不要直接用
innerHTML渲染用户输入。
当前示例代码中用了innerHTML来展示结果,但由于结果是从代码内部生成,风险可控。如果你要把用户输入词条名渲染到页面,请使用textContent或转义 HTML。
7. 总结与后续优化方向
本文从实际痛点出发,完成了一个装备洗练计算器的核心设计,重点实现了自定义模拟和轻量级云存档(导入/导出 JSON)功能。通过这套代码,你可以快速搭建一个单文件工具,也可以继续扩展成完整的全栈应用。
接下来可以做的事情:
- 增加更丰富的词条池编辑功能,比如按装备部位区分词条池。
- 增加保底机制模拟,让计算公式更贴近真实游戏。
- 接入远程同步 API,解决真正的多设备自动同步问题。
- 把计算器打包成 PWA,支持手机端离线使用。
无论你是游戏爱好者还是前端开发者,这个项目都很适合用来练习事件处理、状态管理、文件读写和数组算法。希望这篇文章能给你带来实用的参考。如果你在本地方案基础上接入真实云存档,也欢迎继续分享你的实现经验。