链式反应本地存储工具类
/** * 本地存储工具类 - rotaryChainLocalStorageUtil.js * 功能:封装游戏配置、历史记录的本地存储操作 */ const localStorageUtil = { // ========================== 常量定义(存储键名,避免硬编码)========================== STORAGE_KEYS: { GAME_SETTINGS: 'chainReactionSettings', // 游戏配置(速度、辅助功能等) HISTORY_RECORDS: 'chainReactionHistory', // 游戏历史记录 }, // ========================== 游戏配置操作(速度、辅助功能等)========================== /** * 从本地存储加载游戏配置 * @returns {Object} 游戏配置对象(含默认值) * @property {number} speed - 装置旋转速度(0.5=1倍速,0.25=2倍速) * @property {boolean} flashEnabled - 是否开启闪烁动画 * @property {boolean} assistEnabled - 是否启用辅助预测 * @property {boolean} selectedScore - 辅助优先级(true=分数优先,false=乘数优先) * @property {boolean} assistPredictGlobal - 是否启用全局预测 */ loadGameSettings() { try { const settingsStr = localStorage.getItem(this.STORAGE_KEYS.GAME_SETTINGS); if (!settingsStr) { // 无配置时返回默认值 return { speed: 0.5, flashEnabled: true, assistEnabled: false, selectedScore: true, assistPredictGlobal: false }; } // 解析配置并兼容旧版本(缺失字段用默认值) const parsedSettings = JSON.parse(settingsStr); return { speed: parsedSettings.speed ? parsedSettings.speed : 0.5, flashEnabled: parsedSettings.flashEnabled ? parsedSettings.flashEnabled : true, assistEnabled: parsedSettings.assistEnabled ? parsedSettings.assistEnabled : false, selectedScore: parsedSettings.selectedScore ? parsedSettings.selectedScore : true, assistPredictGlobal: parsedSettings.assistPredictGlobal ? parsedSettings.assistPredictGlobal : false }; } catch (error) { // 解析失败时返回默认值 return { speed: 0.5, flashEnabled: true, assistEnabled: false, selectedScore: true, assistPredictGlobal: false }; } }, /** * 将游戏配置保存到本地存储 * @param {Object} settings - 待保存的配置对象 * @property {number} speed - 装置旋转速度(0.5/0.25) * @property {boolean} flashEnabled - 是否开启闪烁动画 * @property {boolean} assistEnabled - 是否启用辅助预测 * @property {boolean} selectedScore - 辅助优先级(分数/乘数优先) * @property {boolean} assistPredictGlobal - 是否启用全局预测 * @returns {boolean} 保存结果(true=成功,false=失败) */ saveGameSettings(settings) { try { // 过滤无效配置,仅保留允许的字段 const validSettings = { speed: settings.speed === 0.5 || settings.speed === 0.25 ? settings.speed : 0.5, flashEnabled: typeof settings.flashEnabled === 'boolean' ? settings.flashEnabled : true, assistEnabled: typeof settings.assistEnabled === 'boolean' ? settings.assistEnabled : false, selectedScore: typeof settings.selectedScore === 'boolean' ? settings.selectedScore : true, assistPredictGlobal: typeof settings.assistPredictGlobal === 'boolean' ? settings .assistPredictGlobal : false }; // 保存到localStorage localStorage.setItem( this.STORAGE_KEYS.GAME_SETTINGS, JSON.stringify(validSettings) ); return true; } catch (error) { return false; } }, // ========================== 历史记录操作(游戏操作日志)========================== /** * 从本地存储获取所有历史记录 * @returns {Array<Object>} 历史记录数组(含$index索引字段) * @property {number} time - 随机种子 * @property {Array<string>} records - 操作记录(格式:"状态_行_列") * @property {number} score - 游戏分数 * @property {number} date - 时间戳 * @property {number} $index - 原始数组中的索引(用于删除操作) */ getHistoryRecords() { try { const historyStr = localStorage.getItem(this.STORAGE_KEYS.HISTORY_RECORDS); if (!historyStr) return []; // 解析处理后的记录 let historyRecords = [] try { // 优先尝试解密解压(新数据) historyRecords = this._parseDataAfterLoad(historyStr); // 确保是数组,且移除可能残留的$index if (!Array.isArray(historyRecords)) throw new Error('记录格式非数组'); historyRecords = historyRecords.map(item => { const { $index, ...rest } = item; return rest; }); } catch (e) { // 解密失败,解析旧数据(未加密) const oldRecords = JSON.parse(historyStr); if (!Array.isArray(oldRecords)) return []; // 旧数据无$index,直接处理后存储(移除$index步骤) historyRecords = oldRecords.map(item => { const { $index, ...rest } = item; // 兼容可能误加的$index return rest; }); // 加密存储旧数据(转换为新格式) const savedStr = this._processDataBeforeSave(historyRecords); localStorage.setItem(this.STORAGE_KEYS.HISTORY_RECORDS, savedStr); } // 最终返回时添加$index(仅用于前端定位,不存储) return historyRecords.map((record, index) => ({ ...record, $index: index })); } catch (error) { return null; } }, /** * 保存新记录到历史记录(按规则去重:种子前三名、全局前20名) * @param {Object} newRecord - 待保存的新记录 * @property {number} time - 随机种子 * @property {Array<string>} records - 操作记录 * @property {number} score - 游戏分数 * @property {number} date - 时间戳 * @returns {boolean} 保存结果(true=成功,false=失败) */ saveHistoryRecord(newRecord) { try { // 1. 校验新记录格式(至少包含种子和操作记录) if (!newRecord.time || !newRecord.records || !Array.isArray(newRecord.records)) { return false; } // 2. 获取现有历史记录 let historyRecords = this.getHistoryRecords().map(item => { // 移除$index字段(避免存储冗余) const { $index, ...rest } = item; return rest; }); // 3. 检查新记录是否与历史记录前缀匹配(避免重复保存) const { isMatched, updatedHistory } = this._checkRecordMatch(historyRecords, newRecord); historyRecords = isMatched ? updatedHistory : [...historyRecords, newRecord]; // 4. 按种子分组,保留每个种子的前三名(按分数降序) const recordsBySeed = {}; historyRecords.forEach(record => { const seed = record.time; if (!recordsBySeed[seed]) recordsBySeed[seed] = []; recordsBySeed[seed].push(record); }); // 处理每个种子的记录(去重+保留前三) Object.keys(recordsBySeed).forEach(seed => { // 去重:同一种子下相同分数的记录只保留一条 const uniqueRecords = Array.from( new Map(recordsBySeed[seed].map(r => [`${seed}_${r.score}`, r])).values() ); // 按分数降序,保留前三名 recordsBySeed[seed] = uniqueRecords .sort((a, b) => b.score - a.score) .slice(0, 3); }); // 5. 提取所有种子的前三名,形成种子级保留池 const seedLevelRecords = []; Object.values(recordsBySeed).forEach(seedRecords => { seedLevelRecords.push(...seedRecords); }); // 6. 筛选全局前20名(按分数降序,去重) const uniqueAllRecords = Array.from( new Map(historyRecords.map(r => [`${r.time}_${r.score}`, r])).values() ); const top20GlobalRecords = uniqueAllRecords .sort((a, b) => b.score - a.score) .slice(0, 20); // 7. 合并结果(种子前三 + 全局前20),去重后保存 const finalRecordsMap = new Map(); // 先添加种子级记录 seedLevelRecords.forEach(record => { const key = `${record.time}_${record.score}`; if (!finalRecordsMap.has(key)) finalRecordsMap.set(key, record); }); // 再添加全局前20(确保高分不丢失) top20GlobalRecords.forEach(record => { const key = `${record.time}_${record.score}`; if (!finalRecordsMap.has(key)) finalRecordsMap.set(key, record); }); // 转换为数组并按分数降序排序 const finalRecords = Array.from(finalRecordsMap.values()) .sort((a, b) => b.score - a.score); // 处理数据后保存 const savedStr = this._processDataBeforeSave(finalRecords); localStorage.setItem(this.STORAGE_KEYS.HISTORY_RECORDS, savedStr); return true; } catch (error) { return false; } }, /** * 删除指定索引的历史记录 * @param {number} $index - 记录在原始数组中的索引(从getHistoryRecords获取) * @returns {boolean} 删除结果(true=成功,false=失败) */ deleteHistoryRecord($index) { try { // 1. 获取现有记录(不含$index) let historyRecords = this.getHistoryRecords().map(item => { const { $index, ...rest } = item; return rest; }); // 2. 校验索引有效性 if ($index < 0 || $index >= historyRecords.length) { return false; } // 3. 删除指定记录 historyRecords.splice($index, 1); // 4. 保存更新后的记录 localStorage.setItem( this.STORAGE_KEYS.HISTORY_RECORDS, JSON.stringify(historyRecords) ); return true; } catch (error) { return false; } }, /** * 清空所有历史记录 * @returns {boolean} 清空结果(true=成功,false=失败) */ clearAllHistoryRecords() { try { localStorage.removeItem(this.STORAGE_KEYS.HISTORY_RECORDS); return true; } catch (error) { return false; } }, /** * 导出指定key的本地存储数据(按密钥规则处理) * @returns {string} 处理后的密文(当前key不存在时返回空字符串) */ exportStorageData() { // 1. 校验当前key是否存在,不存在直接返回空字符串 if (!window.key) return ''; try { // 3. 获取原始存储数据 const rawData = localStorage.getItem(this.STORAGE_KEYS.HISTORY_RECORDS); if (!rawData) return ''; // 4. 判断数据类型(明文/密文) const isCiphertext = rawData.startsWith('0_') || rawData.startsWith('1_'); let processedData = ''; if (!isCiphertext) { // 明文:先加密,再用unpkg LZMA高压缩 const originalData = JSON.parse(rawData); const encryptedStr = this._processDataBeforeSave(originalData); processedData = this._compressData(encryptedStr); } else { const prefix = rawData.slice(0, 2); if (prefix === '0_') { // 0_密文:默认密钥解密 → 重新加密 → unpkg LZMA压缩 const base64Data = rawData.slice(2); const decodedStr = decodeURIComponent(escape(atob(base64Data))); const decryptedStr = this._xorDecrypt(decodedStr, 123456780); const originalData = JSON.parse(decryptedStr); const encryptedStr = this._processDataBeforeSave(originalData); processedData = this._compressData(encryptedStr); } else if (prefix === '1_') { // 1_密文:直接用unpkg LZMA压缩(无需重新加密,节省性能) processedData = this._compressData(rawData); } } return processedData; } catch (error) { // 任何处理失败(如解析错误)均返回空字符串 return ''; } }, /** * 合并密文到本地历史记录(仅处理前缀1_且可解析的密文) * @param {string} ciphertext - 待合并的密文(需前缀为1_) * @returns {boolean} 合并结果(成功=true,失败=false) */ mergeCiphertextToHistory(ciphertext) { // 1. 前置校验:当前key不存在,直接返回失败 if (!window.key) return false; try { // 尝试解压 let decompressedCiphertext = this._decompressData(ciphertext); if (!decompressedCiphertext) return false; // 解压后的字符串前缀不是1_则返回失败 if (!decompressedCiphertext.startsWith('1_')) return false; // 2. 解析密文(按1_前缀的密钥规则) const base64Data = decompressedCiphertext.slice(2); const decodedStr = decodeURIComponent(escape(atob(base64Data))); const decryptedStr = this._xorDecrypt(decodedStr, window.key); const newRecords = JSON.parse(decryptedStr); // 3. 校验解析结果是否为数组(历史记录需为数组格式) if (!Array.isArray(newRecords) || newRecords.length === 0) return false; // 4. 批量合并:逐条调用saveHistoryRecord的逻辑(复用去重和保留规则) // 先获取现有记录(移除$index) let existingRecords = this.getHistoryRecords().map(item => { const { $index, ...rest } = item; return rest; }); // 遍历新记录,逐条处理(复用前缀匹配、种子分组、全局排序逻辑) newRecords.forEach(newRecord => { // 1. 校验新记录格式(至少包含种子和操作记录) if (!newRecord.time || !newRecord.records || !Array.isArray(newRecord.records)) { return false; } // 3. 检查新记录是否与历史记录前缀匹配(避免重复保存) const { isMatched, updatedHistory } = this._checkRecordMatch(existingRecords, newRecord); existingRecords = isMatched ? updatedHistory : [...existingRecords, newRecord]; // 4. 按种子分组,保留每个种子的前三名(按分数降序) const recordsBySeed = {}; existingRecords.forEach(record => { const seed = record.time; if (!recordsBySeed[seed]) recordsBySeed[seed] = []; recordsBySeed[seed].push(record); }); // 处理每个种子的记录(去重+保留前三) Object.keys(recordsBySeed).forEach(seed => { // 去重:同一种子下相同分数的记录只保留一条 const uniqueRecords = Array.from( new Map(recordsBySeed[seed].map(r => [`${seed}_${r.score}`, r])).values() ); // 按分数降序,保留前三名 recordsBySeed[seed] = uniqueRecords .sort((a, b) => b.score - a.score) .slice(0, 3); }); // 5. 提取所有种子的前三名,形成种子级保留池 const seedLevelRecords = []; Object.values(recordsBySeed).forEach(seedRecords => { seedLevelRecords.push(...seedRecords); }); // 6. 筛选全局前20名(按分数降序,去重) const uniqueAllRecords = Array.from( new Map(existingRecords.map(r => [`${r.time}_${r.score}`, r])).values() ); const top20GlobalRecords = uniqueAllRecords .sort((a, b) => b.score - a.score) .slice(0, 20); // 7. 合并结果(种子前三 + 全局前20),去重后保存 const finalRecordsMap = new Map(); // 先添加种子级记录 seedLevelRecords.forEach(record => { const key = `${record.time}_${record.score}`; if (!finalRecordsMap.has(key)) finalRecordsMap.set(key, record); }); // 再添加全局前20(确保高分不丢失) top20GlobalRecords.forEach(record => { const key = `${record.time}_${record.score}`; if (!finalRecordsMap.has(key)) finalRecordsMap.set(key, record); }); // 转换为数组并按分数降序排序 existingRecords = Array.from(finalRecordsMap.values()) .sort((a, b) => b.score - a.score); }); // 6. 保存合并后的记录(加密存储) const savedStr = this._processDataBeforeSave(existingRecords); localStorage.setItem(this.STORAGE_KEYS.HISTORY_RECORDS, savedStr); return true; } catch (error) { // 任何解析/合并错误均返回失败 return false; } }, // ========================== 内部工具方法(私有,不对外暴露)========================== /** * 检查新记录与历史记录是否前缀匹配(避免重复保存) * @param {Array<Object>} historyRecords - 历史记录数组 * @param {Object} newRecord - 新记录 * @returns {Object} 匹配结果 * @property {boolean} isMatched - 是否匹配 * @property {Array<Object>} updatedHistory - 更新后的历史记录数组 */ _checkRecordMatch(historyRecords, newRecord) { // 只对比同一种子的记录 const sameSeedRecords = historyRecords.filter(item => item.time === newRecord.time); if (sameSeedRecords.length === 0) { return { isMatched: false, updatedHistory: historyRecords }; } // 遍历同一种子的记录,检查操作序列是否前缀匹配 for (let i = 0; i < sameSeedRecords.length; i++) { const historyItem = sameSeedRecords[i]; const historyIndex = historyRecords.indexOf(historyItem); // 检查前缀匹配(一方是另一方的前面部分且完全相同) if (this._checkPrefixMatch(historyItem.records, newRecord.records)) { // 新记录操作数更多时,替换旧记录 if (newRecord.records.length >= historyItem.records.length) { historyRecords[historyIndex] = newRecord; } return { isMatched: true, updatedHistory: historyRecords }; } } return { isMatched: false, updatedHistory: historyRecords }; }, /** * 检查两个操作序列是否为前缀匹配 * @param {Array<string>} arr1 - 历史操作序列 * @param {Array<string>} arr2 - 新操作序列 * @returns {boolean} 是否前缀匹配 */ _checkPrefixMatch(arr1, arr2) { const minLength = Math.min(arr1.length, arr2.length); // 对比最短长度内的所有操作 for (let i = 0; i < minLength; i++) { if (arr1[i] !== arr2[i]) return false; } return true; }, // ========================== 新增:数据压缩/加密/编码工具函数 =========================== /** * 异或加密(效率高,不增加体积) * @param {string} data - 待加密的字符串 * @returns {string} 加密后的字符串(UTF-8编码) */ _xorEncrypt(data, key = 1234567890) { let encrypted = []; for (let i = 0; i < data.length; i++) { // 字符ASCII码与key异或,再转成字符 const charCode = data.charCodeAt(i) ^ key; encrypted.push(String.fromCharCode(charCode)); } return encrypted.join(''); }, /** * 异或解密(对应加密逻辑,密钥需与加密一致) * @param {string} encryptedData - 加密后的字符串 * @returns {string} 解密后的原始字符串 */ _xorDecrypt(encryptedData, key) { // 异或加密和解密逻辑相同,直接复用加密方法 return this._xorEncrypt(encryptedData, key); }, /** * 数据处理统一入口(加密→Base64编码) * @param {Object/Array} data - 待处理的原始数据(如配置对象、记录数组) * @returns {string} 最终处理后的字符串(用于存储) */ _processDataBeforeSave(data) { // 1. 转JSON字符串 const jsonStr = JSON.stringify(data); // 2. 异或加密(简单保护,不增加体积) const encryptedStr = this._xorEncrypt(jsonStr, window.key ? window.key : 123456780); // 3. Base64编码(避免特殊字符,便于存储) return (window.key ? '1_' : '0_') + btoa(unescape(encodeURIComponent(encryptedStr))); }, /** * 数据解析统一入口(Base64解码→解密) * @param {string} savedStr - 存储的字符串 * @returns {Object/Array|null} 解析后的原始数据(失败返回null) */ _parseDataAfterLoad(savedStr) { try { // 1. 提取密钥标识(开头的"1_"或"0_")和真实Base64数据 const prefix = savedStr.slice(0, 2); // 获取前两位字符("1_"或"0_") const base64Data = savedStr.slice(2); // 移除前缀后的Base64数据 // 2. 根据前缀匹配对应的解密密钥 let decryptKey; if (prefix === '1_') { // "1_" 表示加密时用了window.key decryptKey = window.key ? window.key : 123456780; // 兼容window.key意外不存在的情况 } else if (prefix === '0_') { // "0_" 表示加密时用了默认密钥 decryptKey = 123456780; } else { // 前缀不匹配(如旧数据无前缀),直接抛错进入兼容逻辑 throw new Error('无效的密钥标识前缀'); } // 3. 正常执行解码和解密流程 const decodedStr = decodeURIComponent(escape(atob(base64Data))); // Base64解码 const decryptedStr = this._xorDecrypt(decodedStr, decryptKey); // 异或解密(使用匹配的密钥) return JSON.parse(decryptedStr); // JSON解析并返回 } catch (error) { // 解析失败(如前缀异常、Base64错误、解密失败),返回null触发旧数据兼容逻辑 return null; } }, // ========================== 数据压缩/解压工具(基于 unpkg 的 pako 库)========================== /** * pako高压缩(使用deflate算法,level=9为最高级别) * @param {string} rawData - 待压缩的原始字符串(如加密后的JSON字符串) * @returns {string} 压缩后的Base64字符串(避免二进制数据存储/传输问题) */ _compressData(rawData) { try { // 1. 将字符串转换为Uint8Array const uint8Array = new TextEncoder().encode(rawData); // 2. pako压缩:使用deflate算法,level=9(最高压缩级别) const compressedUint8 = pako.deflate(uint8Array, { level: 9 }); // 3. 转换为Base64:解决二进制数据的特殊字符问题,便于文本框显示和复制 return btoa(String.fromCharCode(...compressedUint8)); } catch (error) { return ''; // 压缩失败返回空字符串,上层逻辑会提示用户 } }, /** * pako解压(对应压缩逻辑,仅处理pako压缩的数据) * @param {string} compressedBase64 - 压缩后的Base64字符串(含"COMPRESSED_"前缀的部分) * @returns {string} 解压后的原始字符串(如加密后的密文) */ _decompressData(compressedBase64) { try { // 1. Base64解码:将文本格式的Base64转换为二进制Uint8Array const compressedUint8 = new Uint8Array( atob(compressedBase64) .split('') .map(char => char.charCodeAt(0)) ); // 2. pako解压:使用inflate算法(同步解压,文本数据体积小,无性能压力) const decompressedUint8 = pako.inflate(compressedUint8); // 3. 将Uint8Array转换为字符串 return new TextDecoder().decode(decompressedUint8); } catch (error) { return ''; // 解压失败返回空字符串,上层逻辑会提示用户核对数据 } }, }; // 浏览器环境(挂载到window全局变量) window.localStorageUtil = localStorageUtil;





