/* ============================================================================= * SGLV PTW · Tab: 成就分析 (Achievements) * ----------------------------------------------------------------------------- * Phase 3 / T3.2新增能力。借鉴 steam-game-hub-2.0 `achievement-analytics.ts:106-390`。 * * 三个分析维度: * ① 稀有度分档 —— 按全球达成率分4 档(>=50% 常见 / >=20% 少见 / >=5% 稀有 / <5% 传说) * ② 成就时间线 —— 按解锁年份/月份聚合,看清成就节奏 * ③ 获取速度 —— 已解锁成就的解锁耗时分布(相对首个/最末解锁点) * * ⚠️ 口径纪律(重要): * `percent` 是**全球达成率**(越高越常见),不是稀有度。 * 分档必须**反向**解读:percent 越低越稀有。 * 分档阈值与主脚本 SGIS 的成就列表保持一致(50/20/5), * 避免同一游戏在两个入口显示不同稀有度。 * * 数据源(均经 PTW_HOST 桥接,Tab 不直连 SGLV_API): * getPlayerAchievements(appId) → { error?, achievements[], total, unlocked, gameName } * achievements[i] = { name, apiname, achieved, unlocktime } * getGlobalAchievements(appId)→ [{ name, percent }] 已按 percent 降序 * * 限流纪律:两个取数函数均**无内部限流**,必须经 sglvSmartPool 约束并发。 * ========================================================================== */ (function (root) { 'use strict'; // ============================================================================= // 样式(内联,随库走) // ----------------------------------------------------------------------------- // 命名空间 sglv-ptw-ach-*;配色沿用主脚本成就列表口径(50/20/5 分档), // 保证同一游戏在 SGIS 侧栏与 PTW 浮窗中稀有度读数一致。 // ========================================================================== const CSS = ` .sglv-ptw-ach{display:flex;flex-direction:column;gap:14px;} .sglv-ptw-ach-picker{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--sglv-border,rgba(102,192,244,.2));border-radius:10px;background:var(--sglv-panel-2,rgba(255,255,255,.02));} .sglv-ptw-ach-picker-label{font-size:12px;color:var(--sglv-text-dim,#94a3b8);flex-shrink:0;} .sglv-ptw-ach-select{flex:1;min-width:0;padding:7px 10px;border-radius:8px;border:1px solid var(--sglv-border,rgba(102,192,244,.25));background:var(--sglv-panel,#1a2733);color:var(--sglv-text,#f8fafc);font-size:13px;outline:none;} .sglv-ptw-ach-select:focus{border-color:#66c0f4;} .sglv-ptw-ach-body{display:flex;flex-direction:column;gap:14px;} /* 状态占位(加载中 / 无 Key / 失败) */ .sglv-ptw-ach-state{padding:36px 20px;text-align:center;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-state-icon{font-size:32px;margin-bottom:10px;} .sglv-ptw-ach-state-msg{font-size:14px;font-weight:600;color:#e8a33d;} .sglv-ptw-ach-state-sub{margin-top:8px;font-size:12px;opacity:.8;line-height:1.7;} /* HERO 摘要 */ .sglv-ptw-ach-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;} .sglv-ptw-ach-hero-item{padding:14px 16px;border:1px solid var(--sglv-border,rgba(102,192,244,.16));border-radius:12px;background:var(--sglv-panel-2,rgba(255,255,255,.025));text-align:center;} .sglv-ptw-ach-hero-val{font-size:26px;font-weight:700;color:var(--sglv-text,#f8fafc);line-height:1.2;} .sglv-ptw-ach-hero-total{font-size:14px;font-weight:400;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-hero-lbl{margin-top:4px;font-size:12px;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-hero-bar{margin:10px 0 4px;height:5px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;} .sglv-ptw-ach-hero-bar i{display:block;height:100%;border-radius:3px;} .sglv-ptw-ach-hero-sub{font-size:11px;color:var(--sglv-text-dim,#94a3b8);} /* 面板 */ .sglv-ptw-ach-panel{padding:14px 16px;border:1px solid var(--sglv-border,rgba(102,192,244,.16));border-radius:12px;background:var(--sglv-panel-2,rgba(255,255,255,.02));} .sglv-ptw-ach-panel-title{display:flex;align-items:center;gap:7px;margin-bottom:12px;font-size:13px;font-weight:600;color:var(--sglv-text,#e8eef4);} .sglv-ptw-ach-count{margin-left:auto;padding:1px 8px;border-radius:10px;background:rgba(102,192,244,.14);color:#66c0f4;font-size:11px;} .sglv-ptw-ach-note{padding:12px;border:1px dashed rgba(232,163,61,.4);border-radius:8px;color:#e8a33d;font-size:12px;text-align:center;} /* 稀有度分布条 */ .sglv-ptw-ach-tier-body{display:flex;flex-direction:column;gap:9px;} .sglv-ptw-ach-tier{display:grid;grid-template-columns:52px 1fr 58px;align-items:center;gap:10px;} .sglv-ptw-ach-tier-label{font-size:12px;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-tier-track{height:7px;border-radius:4px;background:rgba(255,255,255,.07);overflow:hidden;} .sglv-ptw-ach-tier-track i{display:block;height:100%;border-radius:4px;transition:width .35s ease;} .sglv-ptw-ach-tier-val{font-size:12px;text-align:right;color:var(--sglv-text-dim,#94a3b8);font-variant-numeric:tabular-nums;} /* 时间线 */ .sglv-ptw-ach-chart{margin:2px 0 10px;overflow-x:auto;} .sglv-ptw-ach-tl-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:6px;} .sglv-ptw-ach-tl-row{display:flex;align-items:center;justify-content:space-between;padding:5px 9px;border-radius:7px;background:rgba(255,255,255,.03);font-size:12px;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-tl-row b{color:#66c0f4;} /* 明细列表 */ .sglv-ptw-ach-list{display:flex;flex-direction:column;gap:2px;max-height:420px;overflow-y:auto;} .sglv-ptw-ach-item{display:grid;grid-template-columns:8px 1fr auto;align-items:center;gap:10px;padding:8px 8px;border-radius:8px;transition:background .15s;} .sglv-ptw-ach-item:hover{background:rgba(102,192,244,.07);} .sglv-ptw-ach-item.locked{opacity:.62;} .sglv-ptw-ach-dot{width:8px;height:8px;border-radius:50%;} .sglv-ptw-ach-info{min-width:0;} .sglv-ptw-ach-name{font-size:13px;color:var(--sglv-text,#e8eef4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .sglv-ptw-ach-meta{margin-top:2px;font-size:11px;color:var(--sglv-text-dim,#94a3b8);} .sglv-ptw-ach-tier-tag{padding:1px 7px;border:1px solid;border-radius:9px;font-size:10px;white-space:nowrap;} @media (max-width:820px){.sglv-ptw-ach-tier{grid-template-columns:48px 1fr 52px;}.sglv-ptw-ach-hero-val{font-size:22px;}} `; let cssInjected = false; function injectCSS() { if (cssInjected) return; cssInjected = true; try { const st = document.createElement('style'); st.id = 'sglv-ptw-ach-style'; st.textContent = CSS; document.head.appendChild(st); } catch (e) { /* 样式失败不阻断功能 */ } } // ---- 稀有度分档(percent = 全球达成率,越低越稀有)---- const RARITY_TIERS = [ { key: 'common', min: 50, color: '#22c55e', label: { zh: '常见', en: 'Common' } }, { key: 'uncommon', min: 20, color: '#84cc16', label: { zh: '少见', en: 'Uncommon' } }, { key: 'rare', min: 5, color: '#fbbf24', label: { zh: '稀有', en: 'Rare' } }, { key: 'legendary', min: 0, color: '#ef4444', label: { zh: '传说', en: 'Legendary' } }, ]; function rarityOf(percent) { const p = Number(percent) || 0; return RARITY_TIERS.find(t => p >= t.min) || RARITY_TIERS[RARITY_TIERS.length - 1]; } /** 稀有度积分:越稀有得分越高(0-100),用于「成就分」汇总 */ function rarityScore(percent) { const p = Math.max(0, Math.min(100, Number(percent) || 0)); // percent=0 → 100 分;percent=100 → 0 分 return Math.round((100 - p) * 10) / 10; } function fmtDate(ts) { if (!ts) return ''; try { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } catch (e) { return ''; } } function fmtDay(ts) { if (!ts) return ''; try { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } catch (e) { return ''; } } // ========================================================================== function mount(container, ctx) { const H = ctx.host; const T = H.T; const ICONS = H.ICONS; const isZh = H.isZh; const escHtml = H.escHtml; const Charts = H.SGLVCharts; injectCSS(); const root_ = document.createElement('div'); root_.className = 'sglv-ptw-ach'; container.appendChild(root_); // ---- 游戏选择器 ---- const games = (ctx.getGames() || []).slice() .sort((a, b) => (b.playtime || 0) - (a.playtime || 0)); if (!games.length) { root_.innerHTML = H.emptyHtml(T.ptNoData); return; } const picker = document.createElement('div'); picker.className = 'sglv-ptw-ach-picker'; picker.innerHTML = `
${escHtml(isZh ? '选择游戏' : 'Select a game')}
`; root_.appendChild(picker); const sel = picker.querySelector('#sglv-ptw-ach-game'); sel.innerHTML = games.map(g => `` ).join(''); const cur = ctx.view && ctx.view.achAppId; if (cur && games.some(g => String(g.appid) === String(cur))) sel.value = String(cur); const body = document.createElement('div'); body.className = 'sglv-ptw-ach-body'; root_.appendChild(body); function setLoading(msg) { body.innerHTML = `
${escHtml(msg || (isZh ? '加载中…' : 'Loading…'))}
`; } function setState(icon, msg, sub) { body.innerHTML = `
${icon || ''}
${escHtml(msg)}
${sub ? `
${escHtml(sub)}
` : ''}
`; } /** 无 API Key 时的引导(与 SGIS 口径一致:引导去 dev/apikey 配置) */ function renderNoKey() { setState(ICONS.trophy || '🏆', isZh ? '需要 Steam Web API Key' : 'Steam Web API Key required', isZh ? '请在设置中配置后重试' : 'Configure it in settings and retry'); } // ---- 主渲染 ---- async function load(appId) { setLoading(); let player, global; try { player = await H.getPlayerAchievements(appId); } catch (e) { setState(ICONS.trophy || '🏆', isZh ? '成就数据获取失败' : 'Failed to load achievements', String(e && e.message || e)); return; } if (player && player.error === 'NO_KEY') { renderNoKey(); return; } if (!player || (player.error && player.error !== 'API_FAIL')) { setState(ICONS.trophy || '🏆', isZh ? '成就数据获取失败' : 'Failed to load achievements', player && player.error ? String(player.error) : ''); return; } if (!player.total) { setState(ICONS.trophy || '🏆', isZh ? '该游戏暂无成就' : 'No achievements for this game'); return; } // 全球达成率:软依赖,失败不阻断(仅缺稀有度维度) try { global = await H.getGlobalAchievements(appId); } catch (e) { global = null; } const gMap = {}; (global || []).forEach(g => { gMap[g.name] = Number(g.percent) || 0; }); render(player, gMap, global !== null); } function render(player, gMap, hasGlobal) { const list = player.achievements || []; const total = player.total || list.length; const unlockedList = list.filter(a => a.achieved && a.unlocktime); const unlocked = player.unlocked || unlockedList.length; // ---- 合并 + 分档 ---- const merged = list.map(a => { const p = gMap[a.name]; return { name: a.name, achieved: !!a.achieved, unlocktime: a.unlocktime || 0, percent: (p === undefined || p === null) ? null : p, tier: (p === undefined || p === null) ? null : rarityOf(p), score: (p === undefined || p === null) ? null : rarityScore(p), }; }); const pct = total > 0 ? (unlocked / total * 100) : 0; const scoreSum = merged.reduce((s, a) => s + (a.score || 0), 0); const maxScore = merged.length * 100; // ---- 分档统计 ---- const tierStats = RARITY_TIERS.map(t => { const inTier = merged.filter(a => a.tier && a.tier.key === t.key); const got = inTier.filter(a => a.achieved).length; return { ...t, total: inTier.length, unlocked: got, rate: inTier.length ? (got / inTier.length * 100) : 0 }; }); // ---- 时间线(按年-月聚合)---- const tlMap = new Map(); unlockedList.forEach(a => { const k = fmtDate(a.unlocktime); if (!k) return; tlMap.set(k, (tlMap.get(k) || 0) + 1); }); const timeline = [...tlMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); body.innerHTML = ''; // ---- HERO 摘要 ---- const hero = document.createElement('div'); hero.className = 'sglv-ptw-ach-hero'; hero.innerHTML = `
${unlocked}/${total}
${escHtml(isZh ? '已解锁' : 'Unlocked')}
${pct.toFixed(1)}%
${hasGlobal ? `
${scoreSum.toFixed(0)}
${escHtml(isZh ? '成就分' : 'Score')}
${maxScore > 0 ? `满分 ${maxScore}` : ''}
` : ''}
${timeline.length}
${escHtml(isZh ? '活跃月份' : 'Active months')}
${escHtml(isZh ? '有解锁记录的月份数' : 'months with unlocks')}
`; body.appendChild(hero); // ---- 稀有度分布 ---- const tierPanel = document.createElement('div'); tierPanel.className = 'sglv-ptw-ach-panel'; tierPanel.innerHTML = `
${ICONS.trophy || '🏆'} ${escHtml(isZh ? '稀有度分布' : 'Rarity distribution')}
`; const tierBody = document.createElement('div'); tierBody.className = 'sglv-ptw-ach-tier-body'; if (!hasGlobal) { tierBody.innerHTML = `
${escHtml(isZh ? '全球达成率不可用,稀有度维度跳过' : 'Global achievement rates unavailable')}
`; } else { const maxN = Math.max(1, ...tierStats.map(t => t.total)); tierBody.innerHTML = tierStats.map(t => `
${escHtml(isZh ? t.label.zh : t.label.en)}
${t.unlocked}/${t.total}
`).join(''); } tierPanel.appendChild(tierBody); body.appendChild(tierPanel); // ---- 时间线图 ---- if (timeline.length > 0) { const tlPanel = document.createElement('div'); tlPanel.className = 'sglv-ptw-ach-panel'; tlPanel.innerHTML = `
${ICONS.trend || '📈'} ${escHtml(isZh ? '解锁时间线' : 'Unlock timeline')}
`; tlPanel.innerHTML += `
${Charts.lineChart([{ data: timeline.map(x => x[1]) }], { width: 640, height: 180, pL: 34, pR: 20, pT: 18, pB: 26, smooth: false, colors: [Charts.accent()], area: { fill: Charts.accent(), opacity: 0.28 }, gridCount: 3, yFormatter: v => String(Math.round(v)), xLabels: timeline.map(x => x[0]), xLabelEvery: Math.max(1, Math.ceil(timeline.length / 8)), xLabelLast: true, dots: { r: 3, stroke: '#0f172a', strokeWidth: 1, title: (v, i) => `${timeline[i][0]}: ${v}`, }, })}
`; // 逐月明细 tlPanel.innerHTML += `
${timeline.slice().reverse().slice(0, 12).map(([m, n]) => `
${escHtml(m)}${n}
`).join('')}
`; body.appendChild(tlPanel); } // ---- 成就明细(按稀有度降序:最稀有的排前面)---- const listPanel = document.createElement('div'); listPanel.className = 'sglv-ptw-ach-panel'; const sorted = merged.slice().sort((a, b) => { // 未解锁优先展示(提示可玩),同状态内按稀有度 if (a.achieved !== b.achieved) return a.achieved ? 1 : -1; return (a.percent == null ? 999 : a.percent) - (b.percent == null ? 999 : b.percent); }); listPanel.innerHTML = `
${ICONS.list || '📋'} ${escHtml(isZh ? '成就明细' : 'All achievements')} ${merged.length}
${sorted.map(a => { const color = a.tier ? a.tier.color : '#64748b'; return `
${escHtml(a.name)}
${a.achieved ? escHtml(fmtDay(a.unlocktime)) : escHtml(isZh ? '未解锁' : 'Locked')}${a.percent != null ? ` · ${isZh ? '全球' : 'global'} ${a.percent.toFixed(1)}%` : ''}
${a.percent != null ? `${escHtml(isZh ? a.tier.label.zh : a.tier.label.en)}` : ''}
`; }).join('')}
`; body.appendChild(listPanel); } sel.addEventListener('change', () => { const v = sel.value; ctx.setView({ achAppId: v }); load(v); }); load(sel.value); } // ---- 注册 ---- if (!root.SGLVPTW) { console.warn('[PTW-TAB-ACH] 框架未就绪,无法注册'); } else { root.SGLVPTW.registerTab({ key: 'achievements', label: '成就', order: 35, requires: [ 'getOwnedGames', 'getPlayerAchievements', 'T', 'ICONS', 'isZh', 'escHtml', 'emptyHtml', 'SGLVCharts', ], softRequires: ['getGlobalAchievements'], mount, }); console.log('[PTW-TAB-ACH] 已注册'); } })(typeof window !== 'undefined' ? window : globalThis);