// ==UserScript== // @name Steam 年度回顾仪表板 // @name:en Steam Replay Dashboard // @namespace https://github.com/SmallRob/steam-userscripts // @version 1.0.1 // @description 在 Steam 年度回顾页打开浮窗仪表板:总时长、每月曲线、游戏排行、和去年的对比,以及成就、连玩、平台。数字来自回顾页自己带的统计。 // @description:en Floating year-in-review dashboard on Steam Replay: hours, monthly curve, top games, last-year comparison, achievements, streak and platforms. Reads the stats already embedded in the page. // @author SmallFork & SmallRob // @match https://store.steampowered.com/replay/* // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%2322d3ee'/%3E%3Cstop offset='1' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cpath d='M16 44V30M28 44V18M40 44V26M50 46H14' stroke='%23fff' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E // @run-at document-idle // @grant none // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; if (window.__SYR_LOCK__) return; window.__SYR_LOCK__ = true; /* SYR_LIB_START */ (function (root, factory) { root.SteamReplayReport = factory(); })(typeof globalThis !== 'undefined' ? globalThis : this, function () { function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; } function trimZeros(text) { if (text.indexOf('.') === -1) return text; return text.replace(/0+$/, '').replace(/\.$/, ''); } function formatHours(seconds) { const h = num(seconds) / 3600; if (!(h > 0)) return '0'; const digits = h >= 10 ? 1 : 2; return trimZeros(h.toFixed(digits)); } function formatDays(seconds) { const d = num(seconds) / 86400; if (!(d > 0)) return '0'; let text = d.toFixed(1); if (text.endsWith('.0')) text = text.slice(0, -2); return text; } function formatInt(v) { return Math.round(num(v)).toLocaleString('en-US'); } function formatPct(n) { const v = Math.round(num(n) * 10) / 10; let text = v.toFixed(1); if (text.endsWith('.0')) text = text.slice(0, -2); return text + '%'; } function formatMinutes(seconds) { const s = num(seconds); if (!(s > 0)) return '0 分钟'; if (s < 90) return Math.max(1, Math.round(s)) + ' 秒'; return Math.round(s / 60) + ' 分钟'; } function utcParts(unix) { if (!(num(unix) > 0)) return null; const d = new Date(num(unix) * 1000); if (isNaN(d.getTime())) return null; return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; } function formatMonthDay(unix) { const p = utcParts(unix); if (!p) return ''; return p.month + '月' + p.day + '日'; } // 回顾页的「玩过」不含试用版和游戏测试:289 = 297 - 5 - 3。 function isPlayed(g) { return !num(g && g.demo) && !num(g && g.playtest); } function releaseBuckets(played, year, cutoffYears) { const cutoff = year - (cutoffYears > 0 ? cutoffYears : 7); const buckets = { released: 0, recent: 0, classic: 0 }; played.forEach((g) => { const p = utcParts(g.rtime_release_date); if (!p) return; if (p.year >= year) buckets.released += 1; else if (p.year >= cutoff) buckets.recent += 1; else buckets.classic += 1; }); return buckets; } function buildMonths(list, totalSeconds) { const by = {}; (list || []).forEach((mo) => { const p = utcParts(mo.rtime_month); if (!p) return; const seconds = num(mo.stats && mo.stats.total_playtime_seconds); const apps = Array.isArray(mo.appid) ? mo.appid : []; let top = null; apps.forEach((a) => { const sec = num(a.stats && a.stats.total_playtime_seconds); if (!top || sec > top.seconds) top = { appid: num(a.appid), seconds: sec }; }); by[p.month] = { month: p.month, seconds: seconds, games: Array.isArray(mo.game_summary) ? mo.game_summary.length : apps.length, topAppid: top ? top.appid : 0, topSeconds: top ? top.seconds : 0, share: totalSeconds ? seconds / totalSeconds : 0, }; }); const out = []; for (let month = 1; month <= 12; month += 1) { out.push(by[month] || { month: month, seconds: 0, games: 0, topAppid: 0, topSeconds: 0, share: 0, }); } return out; } function buildGames(list, totalSeconds) { return (list || []).map((g) => { const seconds = num(g.stats && g.stats.total_playtime_seconds); return { appid: num(g.appid), seconds: seconds, sessions: num(g.stats && g.stats.total_sessions), share: totalSeconds ? seconds / totalSeconds : 0, firstPlayed: num(g.rtime_first_played), }; }).filter((g) => g.appid && g.seconds > 0) .sort((a, b) => b.seconds - a.seconds || a.appid - b.appid); } function buildPlatforms(stats) { const s = stats || {}; function row(id, label, pctKey, sessionKey) { return { id: id, label: label, pct: num(s[pctKey]) / 100, sessions: num(s[sessionKey]), }; } return [ row('windows', 'Windows', 'windows_playtime_percentagex100', 'windows_sessions'), row('linux', 'Linux', 'linux_playtime_percentagex100', 'linux_sessions'), row('mac', 'macOS', 'macos_playtime_percentagex100', 'macos_sessions'), row('deck', 'Steam Deck', 'deck_playtime_percentagex100', 'deck_sessions'), row('controller', '手柄', 'controller_playtime_percentagex100', 'controller_sessions'), row('vr', 'VR', 'vr_playtime_percentagex100', 'vr_sessions'), ]; } function buildPercentiles(n) { const src = n || {}; return [ { key: 'games', label: '游戏数量', pct: num(src.games_played_pct), avg: num(src.games_played_avg), unit: '款' }, { key: 'ach', label: '成就数量', pct: num(src.achievements_pct), avg: num(src.achievements_avg), unit: '个' }, { key: 'streak', label: '连续游玩', pct: num(src.game_streak_pct), avg: num(src.game_streak_avg), unit: '天' }, ]; } function secondHalfShare(months) { let all = 0; let h2 = 0; (months || []).forEach((m) => { all += m.seconds; if (m.month >= 7) h2 += m.seconds; }); return all ? h2 / all : 0; } function buildReport(replay, ctx) { const extra = ctx || {}; const src = replay || {}; const ps = src.playtime_stats || {}; const totalStats = ps.total_stats || {}; const totalSeconds = num(totalStats.total_playtime_seconds); const sessions = num(totalStats.total_sessions); const summary = Array.isArray(ps.game_summary) ? ps.game_summary : []; const played = summary.filter(isPlayed); const year = num(src.year) || num(extra.year); const cutoffYears = num(extra.cutoffYears) || 7; const months = buildMonths(ps.months, totalSeconds); const games = buildGames(ps.games, totalSeconds); const peak = months.reduce((best, m) => (m.seconds > (best ? best.seconds : 0) ? m : best), null); const streak = ps.playtime_streak || {}; const ach = ps.summary_stats || {}; const previous = extra.previous; return { year: year, persona: extra.persona || '', steamid: extra.steamid || '', totalSeconds: totalSeconds, sessions: sessions, avgSessionSeconds: sessions ? totalSeconds / sessions : 0, played: played.length, fresh: played.filter((g) => num(g.new_this_year)).length, demos: summary.filter((g) => num(g.demo)).length, playtests: summary.filter((g) => num(g.playtest)).length, buckets: releaseBuckets(played, year, cutoffYears), cutoffYears: cutoffYears, months: months, games: games, peak: peak && peak.seconds > 0 ? peak : null, halfShare: secondHalfShare(months), streak: { days: num(streak.longest_consecutive_days), start: num(streak.rtime_start), games: Array.isArray(streak.streak_games) ? streak.streak_games.length : 0, }, achievements: { total: num(ach.total_achievements), games: num(ach.total_games_with_achievements), rare: num(ach.total_rare_achievements), }, numbers: { screenshots: num(ps.by_numbers && ps.by_numbers.screenshots_shared), reviews: num(ps.by_numbers && ps.by_numbers.written_reviews), badges: num(ps.by_numbers && ps.by_numbers.badges_earned), friends: num(ps.by_numbers && ps.by_numbers.friends_added), workshopItems: num(ps.by_numbers && ps.by_numbers.workshop_contributions), workshopSubs: num(ps.by_numbers && ps.by_numbers.workshop_subscriptions), forumPosts: num(ps.by_numbers && ps.by_numbers.forum_posts), }, percentiles: buildPercentiles(ps.by_numbers), previous: previous ? { games: num(previous.games_played), achievements: num(previous.unlocked_achievements), streak: num(previous.longest_streak), } : null, platforms: buildPlatforms(totalStats), otherYears: Array.isArray(extra.otherYears) ? extra.otherYears.filter((y) => num(y) > 0) : [], }; } function appLabel(appid, nameOf) { const name = nameOf ? nameOf(appid) : ''; return name || ('App ' + appid); } function narrative(report, nameOf) { if (!report || !(report.totalSeconds > 0)) return ['这一年没有可汇总的游玩时长。']; const lines = []; lines.push( report.year + ' 年一共 ' + formatHours(report.totalSeconds) + ' 小时(约 ' + formatDays(report.totalSeconds) + ' 天),玩了 ' + formatInt(report.played) + ' 款游戏,其中 ' + formatInt(report.fresh) + ' 款是今年第一次打开。' ); if (report.halfShare >= 0.6 && report.peak) { lines.push( '下半年占了全年 ' + formatPct(report.halfShare * 100) + ',' + report.peak.month + '月单月 ' + formatHours(report.peak.seconds) + ' 小时,是全年高峰。' ); } else if (report.peak) { lines.push(report.peak.month + '月是全年高峰,单月 ' + formatHours(report.peak.seconds) + ' 小时。'); } if (report.streak && report.streak.days > 0) { let streakLine = '最长连续游玩 ' + formatInt(report.streak.days) + ' 天'; const from = formatMonthDay(report.streak.start); if (from) streakLine += ',从 ' + from + ' 开始'; if (report.streak.games > 0) streakLine += ',期间碰过 ' + formatInt(report.streak.games) + ' 款'; lines.push(streakLine + '。'); } if (report.games && report.games[0]) { const top = report.games[0]; const top3 = report.games.slice(0, 3).reduce((sum, g) => sum + g.seconds, 0); let line = '时长第一是「' + appLabel(top.appid, nameOf) + '」,占全年 ' + formatPct(top.share * 100); if (report.games.length >= 3 && report.totalSeconds) { line += ';前三名合计 ' + formatPct(top3 / report.totalSeconds * 100); } line += '。'; const best = report.percentiles.reduce((max, row) => Math.max(max, row.pct), 0); if (best >= 50) { const hits = report.percentiles.filter((row) => row.pct === best); if (hits.length === 1) line += hits[0].label + '超过 ' + formatInt(best) + '% 的玩家。'; else line += hits.map((row) => row.label).join('和') + '都超过 ' + formatInt(best) + '% 的玩家。'; } lines.push(line); } return lines; } function summaryText(report, nameOf) { const lines = []; const who = report.persona ? report.persona + ' · ' : ''; lines.push(who + report.year + ' Steam 年度总结'); lines.push(''); narrative(report, nameOf).forEach((line) => lines.push(line)); lines.push(''); lines.push('月份\t时长'); report.months.forEach((m) => { lines.push(m.month + '月\t' + formatHours(m.seconds) + ' 小时'); }); lines.push('合计\t' + formatHours(report.totalSeconds) + ' 小时'); return lines.join('\n'); } return { buildReport: buildReport, narrative: narrative, summaryText: summaryText, formatHours: formatHours, formatDays: formatDays, formatInt: formatInt, formatPct: formatPct, formatMinutes: formatMinutes, formatMonthDay: formatMonthDay, appLabel: appLabel, }; }); /* SYR_LIB_END */ const Report = window.SteamReplayReport; const REDUCED = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; const CSS = ` #syr-root{position:fixed;inset:0;z-index:100000;display:none;align-items:center;justify-content:center;padding:16px;background:rgba(4,8,14,.62);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);box-sizing:border-box;color:#dbe4ee;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif} #syr-root.syr-open{display:flex} #syr-root *{box-sizing:border-box} #syr-panel{position:relative;width:min(1080px,100%);height:min(860px,100%);display:flex;flex-direction:column;background:#0b0f16;border:1px solid rgba(148,163,184,.16);border-radius:18px;box-shadow:0 24px 80px rgba(0,0,0,.55);overflow:hidden;animation:syr-in .45s cubic-bezier(.22,1,.36,1)} #syr-panel::before,#syr-panel::after{content:"";position:absolute;border-radius:50%;filter:blur(80px);pointer-events:none;z-index:0} #syr-panel::before{width:420px;height:420px;left:-160px;top:-180px;background:radial-gradient(circle,rgba(34,211,238,.16),transparent 68%)} #syr-panel::after{width:480px;height:480px;right:-180px;bottom:-220px;background:radial-gradient(circle,rgba(59,130,246,.14),transparent 68%)} @keyframes syr-in{from{transform:translateY(12px)}to{transform:none}} #syr-panel>*{position:relative;z-index:1} .syr-top{display:flex;align-items:center;gap:12px;height:58px;padding:0 16px 0 18px;background:rgba(12,17,26,.88);border-bottom:1px solid rgba(148,163,184,.12);flex-shrink:0} .syr-brand{display:flex;align-items:center;gap:10px;min-width:0} .syr-logo{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#22d3ee,#3b82f6);box-shadow:0 0 18px rgba(34,211,238,.35);flex-shrink:0;color:#fff} .syr-logo svg{width:16px;height:16px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round} .syr-titles{min-width:0} .syr-titles b{display:block;font-size:14px;font-weight:800;color:#fff;letter-spacing:.3px;white-space:nowrap} .syr-titles span{display:block;font-size:11px;color:#8fa1b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px} .syr-years{display:flex;gap:4px;flex:1;min-width:0;overflow-x:auto;scrollbar-width:none} .syr-years::-webkit-scrollbar{display:none} .syr-years a,.syr-years i{flex-shrink:0;border-radius:9px;padding:6px 10px;font-size:12px;font-weight:700;font-style:normal;color:#93a4ba;text-decoration:none;white-space:nowrap} .syr-years a:hover{color:#e2e8f0;background:rgba(148,163,184,.09)} .syr-years .on{color:#fff;background:linear-gradient(135deg,rgba(34,211,238,.22),rgba(59,130,246,.22));box-shadow:inset 0 0 0 1px rgba(34,211,238,.35)} .syr-actions{display:flex;gap:8px;flex-shrink:0} .syr-btn{border:1px solid rgba(148,163,184,.16);background:rgba(148,163,184,.1);color:#e2e8f0;border-radius:9px;padding:7px 12px;font-size:12px;font-weight:700;cursor:pointer} .syr-btn:hover{background:rgba(148,163,184,.18)} .syr-x{width:34px;height:34px;border-radius:9px;border:1px solid rgba(148,163,184,.18);background:rgba(148,163,184,.08);color:#e2e8f0;cursor:pointer;font-size:16px;line-height:1} .syr-x:hover{background:rgba(244,63,94,.22);border-color:rgba(244,63,94,.4);color:#fff} .syr-scroll{flex:1;min-height:0;overflow:auto;padding:14px 16px 18px;scrollbar-width:thin;scrollbar-color:rgba(34,211,238,.3) transparent} .syr-scroll::-webkit-scrollbar{width:8px} .syr-scroll::-webkit-scrollbar-thumb{background:rgba(34,211,238,.28);border-radius:4px} .syr-story{position:relative;overflow:hidden;margin-bottom:12px;padding:14px 16px;border-radius:14px;background:linear-gradient(140deg,rgba(34,211,238,.14),rgba(59,130,246,.06) 46%,rgba(148,163,184,.03));border:1px solid rgba(34,211,238,.28)} .syr-story p{margin:0;font-size:13px;line-height:1.7;color:#d7e4f0} .syr-story p+p{margin-top:4px} .syr-hero{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-bottom:12px} .syr-hero-card{position:relative;overflow:hidden;display:flex;align-items:center;gap:10px;padding:12px 12px;border-radius:14px;background:linear-gradient(160deg,rgba(148,163,184,.08),rgba(148,163,184,.02));border:1px solid rgba(148,163,184,.12)} .syr-hero-card::after{content:"";position:absolute;right:-28px;top:-28px;width:90px;height:90px;border-radius:50%;background:radial-gradient(circle,var(--g1),transparent 70%);opacity:.16;pointer-events:none} .syr-hero-ico{width:36px;height:36px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:linear-gradient(135deg,var(--g1),var(--g2));color:#fff} .syr-hero-ico svg{width:18px;height:18px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round} .syr-hero-card b{display:block;font-size:20px;font-weight:800;color:#fff;line-height:1.05;font-variant-numeric:tabular-nums;white-space:nowrap} .syr-hero-card b small{font-size:11px;font-weight:700;color:#8fa1b8;margin-left:3px} .syr-lbl{display:block;margin-top:3px;font-size:11.5px;font-weight:700;color:#c0cdde} .syr-sub{display:block;margin-top:1px;font-size:10.5px;color:#7d8aa0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .syr-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.82fr);gap:12px;align-items:start} .syr-col{display:flex;flex-direction:column;gap:12px;min-width:0} .syr-card{background:linear-gradient(160deg,rgba(148,163,184,.07),rgba(148,163,184,.02));border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:14px 14px 12px;min-width:0} .syr-hd{display:flex;align-items:baseline;gap:8px;margin-bottom:12px} .syr-hd b{font-size:13.5px;font-weight:800;color:#f1f5f9;white-space:nowrap} .syr-hd span{margin-left:auto;font-size:11px;color:#7d8aa0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .syr-months{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:6px;align-items:end} .syr-m{display:flex;flex-direction:column;align-items:center;gap:5px;min-width:0} .syr-plot{height:148px;width:100%;display:flex;align-items:flex-end} .syr-bar{width:100%;height:0;border-radius:7px 7px 3px 3px;background:linear-gradient(180deg,#67e8f9,#3b82f6);transition:height .9s cubic-bezier(.22,1,.36,1)} .syr-bar.on{min-height:4px} .syr-m.peak .syr-bar{background:linear-gradient(180deg,#fde68a,#f59e0b);box-shadow:0 0 14px rgba(251,191,36,.35)} .syr-m .mo{font-size:10px;color:#7d8aa0} .syr-m.peak .mo,.syr-m.peak .hr{color:#fbbf24;font-weight:800} .syr-m .hr{font-size:10px;color:#cbd5e1;font-variant-numeric:tabular-nums;white-space:nowrap} .syr-m .hr.zero{color:#475569} .syr-peak{margin:10px 0 0;font-size:12px;color:#9fb0c5;line-height:1.6} .syr-peak b{color:#e2e8f0} .syr-grow{display:flex;align-items:center;gap:10px;padding:8px 8px;border-radius:12px;background:rgba(148,163,184,.045);border:1px solid rgba(148,163,184,.08);margin-bottom:8px;text-decoration:none;color:inherit;transition:border-color .18s ease,background .18s ease,transform .18s ease} .syr-grow:last-child{margin-bottom:0} .syr-grow:hover{border-color:rgba(34,211,238,.4);background:rgba(34,211,238,.06);transform:translateY(-1px)} .syr-medal{width:24px;height:24px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0;background:rgba(148,163,184,.13);color:#94a3b8} .syr-medal.m1{background:linear-gradient(135deg,#fbbf24,#d97706);color:#fff;box-shadow:0 0 12px rgba(251,191,36,.4)} .syr-medal.m2{background:linear-gradient(135deg,#cbd5e1,#64748b);color:#fff} .syr-medal.m3{background:linear-gradient(135deg,#d97706,#92400e);color:#fff} .syr-cap{width:92px;height:34px;object-fit:cover;border-radius:5px;background:#16202e;flex-shrink:0} .syr-gmain{flex:1;min-width:0} .syr-gname{display:block;font-size:13px;font-weight:700;color:#f1f5f9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .syr-gmeta{display:block;margin-top:2px;font-size:10.5px;color:#7d8aa0} .syr-gbar{height:4px;border-radius:2px;background:rgba(148,163,184,.12);margin-top:6px;overflow:hidden} .syr-gbar i{display:block;height:100%;width:0;border-radius:2px;background:linear-gradient(90deg,#22d3ee,#3b82f6);transition:width .9s cubic-bezier(.22,1,.36,1)} .syr-ghrs{flex-shrink:0;text-align:right;font-variant-numeric:tabular-nums} .syr-ghrs b{display:block;font-size:14px;font-weight:800;color:#fbbf24} .syr-ghrs small{display:block;font-size:10px;color:#a16207;font-weight:700} .syr-row{display:grid;grid-template-columns:72px minmax(0,1fr) auto;gap:8px;align-items:center;padding:5px 0;font-size:12px} .syr-row .k{color:#a8b6c8;white-space:nowrap} .syr-row .t{height:8px;border-radius:4px;background:rgba(148,163,184,.12);overflow:hidden} .syr-row .t i{display:block;height:100%;width:0;border-radius:4px;background:linear-gradient(90deg,#22d3ee,#3b82f6);transition:width .9s cubic-bezier(.22,1,.36,1)} .syr-row .v{text-align:right;font-weight:800;color:#e2e8f0;font-variant-numeric:tabular-nums;white-space:nowrap} .syr-row .v.up{color:#67e8f9} .syr-row .v.down{color:#fda4af} .syr-note{margin:8px 0 0;font-size:10.5px;color:#64748b;line-height:1.55} .syr-stack{display:flex;height:12px;border-radius:6px;overflow:hidden;background:rgba(148,163,184,.08);gap:2px} .syr-stack i{display:block;height:100%;width:0;transition:width .9s cubic-bezier(.22,1,.36,1)} .syr-stack .a{background:linear-gradient(90deg,#22d3ee,#67e8f9)} .syr-stack .b{background:linear-gradient(90deg,#3b82f6,#60a5fa)} .syr-stack .c{background:linear-gradient(90deg,#475569,#64748b)} .syr-legend{display:flex;flex-wrap:wrap;gap:8px 14px;margin-top:8px;font-size:11px;color:#a8b6c8} .syr-legend b{color:#e2e8f0;font-variant-numeric:tabular-nums} .syr-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px} .syr-chips{display:flex;flex-wrap:wrap;gap:6px} .syr-chip{padding:6px 10px;border-radius:999px;border:1px solid rgba(148,163,184,.14);background:rgba(148,163,184,.06);font-size:12px;color:#cbd5e1} .syr-chip b{color:#fff;font-variant-numeric:tabular-nums} .syr-foot{margin-top:12px;font-size:10.5px;color:#64748b;line-height:1.6} #syr-toast{position:absolute;left:50%;bottom:18px;transform:translateX(-50%) translateY(8px);background:rgba(15,23,32,.94);border:1px solid rgba(34,211,238,.35);color:#e2e8f0;border-radius:999px;padding:8px 14px;font-size:12px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;z-index:2} #syr-toast.on{opacity:1;transform:translateX(-50%) translateY(0)} #syr-fab{position:fixed;right:22px;bottom:22px;z-index:100000;display:inline-flex;align-items:center;gap:8px;border:none;border-radius:999px;padding:11px 16px;cursor:pointer;color:#fff;font-size:13px;font-weight:800;background:linear-gradient(135deg,#22d3ee,#3b82f6);box-shadow:0 8px 24px rgba(34,211,238,.32)} #syr-fab svg{width:16px;height:16px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round} #syr-fab:hover{filter:brightness(1.08)} #syr-fab[hidden]{display:none} .syr-cmp{grid-template-columns:52px minmax(0,1fr)} @media(max-width:980px){.syr-grid,.syr-hero{grid-template-columns:1fr 1fr}.syr-hero{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:640px){.syr-hero{grid-template-columns:1fr 1fr}.syr-top{height:auto;padding:10px 12px;flex-wrap:wrap}.syr-cap{display:none}} @media (prefers-reduced-motion:reduce){#syr-root *,#syr-root *::before,#syr-root *::after{animation:none!important;transition:none!important}} `; const ICONS = { clock: 'M12 8v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18', grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', trophy: 'M8 4h8v5a4 4 0 0 1-8 0zM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M9 20h6M12 13v7', flame: 'M12 21a6 6 0 0 0 4-10c0 2-1 3-2 3 1-4-2-7-6-9 1 4-1 6-2 7a6 6 0 0 0 6 9z', play: 'M8 5v14l11-7z', bars: 'M4 19V10M10 19V5M16 19v-7M21 19H3', }; function svg(name) { return ''; } function esc(v) { return String(v == null ? '' : v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function memory(key, value) { try { if (arguments.length < 2) return sessionStorage.getItem(key); sessionStorage.setItem(key, value); } catch (e) { /* 隐私模式可能禁掉 sessionStorage */ } return null; } function personaFromTitle(title, year) { const text = String(title || ''); const hit = text.match(new RegExp('^(.+?)\\s*的\\s*' + year + '\\s*年')); if (hit) return hit[1].trim(); const loose = text.match(/^(.+?)\s*的\s*\d{4}/); return loose ? loose[1].trim() : ''; } function parseJson(raw) { if (!raw) return null; try { return JSON.parse(raw); } catch (e) { return null; } } function readPage() { const node = document.querySelector('#application_config'); if (!node || !node.dataset) return null; const ds = node.dataset; const key = Object.keys(ds).find((k) => /^yearinreview_\d+_\d{4}$/.test(k)); if (!key) return null; const replay = parseJson(ds[key]); if (!replay || !replay.playtime_stats) return null; const year = replay.year; const path = (location.pathname || '').match(/\/replay\/(\d+)\/(\d{4})/); let cutoffYears = 7; const dist = parseJson(ds['yearinreview_' + year + '_distribution']); if (dist && dist.recent_cutoff_year > 0) cutoffYears = dist.recent_cutoff_year; let otherYears = []; Object.keys(ds).forEach((k) => { if (!/^yearinreview_otheryears_/.test(k)) return; const arr = parseJson(ds[k]); if (Array.isArray(arr)) otherYears = arr; }); let lang = 'schinese'; const cfg = parseJson(ds.config); if (cfg && /^[a-z]{2,16}$/.test(String(cfg.LANGUAGE || ''))) lang = String(cfg.LANGUAGE); return { replay: replay, lang: lang, ctx: { year: year, previous: parseJson(ds['yearinreview_' + year + '_previous_year_summary']), cutoffYears: cutoffYears, otherYears: otherYears, persona: personaFromTitle(document.title, year), steamid: path ? path[1] : '', }, }; } function yearList(report) { const set = {}; (report.otherYears || []).forEach((y) => { set[y] = true; }); if (report.year) set[report.year] = true; return Object.keys(set).map(Number).filter((y) => y >= 2004 && y <= 2100).sort((a, b) => a - b); } function heroCard(g1, g2, icon, valueHtml, label, sub) { return '
' + '' + svg(icon) + '' + valueHtml + '' + '' + esc(label) + '' + esc(sub) + '
'; } function countHtml(n, text, dec) { return '' + (REDUCED ? esc(text) : '0') + ''; } function mount(report, lang) { const names = harvestNames(); const arts = harvestArt(); const nameOf = (id) => names[String(id)] || ''; injectStyle(); const maxMonth = report.months.reduce((max, m) => Math.max(max, m.seconds), 0) || 1; const topSeconds = report.games.length ? report.games[0].seconds : 1; const years = yearList(report); const yearHtml = years.map((y) => { if (y === report.year || !report.steamid) return '' + y + ''; return '' + y + ''; }).join(''); const monthHtml = report.months.map((m) => { const h = maxMonth ? (m.seconds / maxMonth * 100) : 0; const peak = report.peak && report.peak.month === m.month; return '
' + '
' + '' + m.month + '月' + Report.formatHours(m.seconds) + '
'; }).join(''); let peakHtml = ''; if (report.peak) { peakHtml = '

' + report.peak.month + '月最高,占全年 ' + Report.formatPct(report.peak.share * 100) + '。'; if (report.peak.topAppid) { peakHtml += '当月主力 ' + esc(Report.appLabel(report.peak.topAppid, nameOf)) + ',' + Report.formatHours(report.peak.topSeconds) + ' 小时。'; } peakHtml += '

'; } const gameHtml = report.games.slice(0, 10).map((g, i) => { const medal = i < 3 ? ' m' + (i + 1) : ''; const when = Report.formatMonthDay(g.firstPlayed); const meta = Report.formatInt(g.sessions) + ' 次' + (when ? ' · ' + when + '首次' : ''); const width = topSeconds ? (g.seconds / topSeconds * 100) : 0; return '' + '' + (i + 1) + '' + '' + '' + esc(Report.appLabel(g.appid, nameOf)) + '' + esc(meta) + '' + '' + '' + Report.formatHours(g.seconds) + '小时 · ' + Report.formatPct(g.share * 100) + ''; }).join(''); const compareHtml = report.previous ? compareBlock(report) : ''; const pctHtml = percentileBlock(report); const platHtml = platformBlock(report); const bucketHtml = bucketBlock(report); const chipHtml = chipBlock(report); const freshSub = '新开 ' + Report.formatInt(report.fresh) + ' 款'; const achSub = '稀有 ' + Report.formatInt(report.achievements.rare); const streakSub = Report.formatMonthDay(report.streak.start) ? (Report.formatMonthDay(report.streak.start) + '起') : '连续游玩'; const root = document.createElement('div'); root.id = 'syr-root'; root.innerHTML = ''; const fab = document.createElement('button'); fab.id = 'syr-fab'; fab.type = 'button'; fab.innerHTML = svg('bars') + '年度报告'; document.body.appendChild(root); document.body.appendChild(fab); paintStory(report, nameOf); bind(root, fab, report, nameOf); const opened = memory('syr-dash') !== '0'; setOpen(root, fab, opened); animate(root); watchCovers(root, arts); fillNames(collectIds(report), names, lang, () => paintStory(report, nameOf)); } function watchCovers(root, arts) { let ticks = 0; const timer = setInterval(() => { ticks += 1; const found = harvestArt(); root.querySelectorAll('img.syr-cap').forEach((img) => { const id = img.getAttribute('data-appid'); const url = found[id]; if (!url) return; if (!arts[id] || artScore(url) >= artScore(arts[id])) arts[id] = url; if (img.src !== url) img.src = url; }); const waiting = Array.from(root.querySelectorAll('img.syr-cap')).some((img) => { const id = img.getAttribute('data-appid'); return !(img.naturalWidth > 0) && !found[id]; }); if (ticks < 12 && waiting) return; clearInterval(timer); root.querySelectorAll('img.syr-cap').forEach((img) => { if (img.naturalWidth > 0 || found[img.getAttribute('data-appid')]) return; img.addEventListener('error', () => img.remove(), { once: true }); img.src = capsuleUrl(img.getAttribute('data-appid')); }); }, 500); } function compareBlock(report) { const prev = report.previous; const rows = [ ['游戏', report.played, prev.games, ' 款'], ['成就', report.achievements.total, prev.achievements, ''], ['连玩', report.streak.days, prev.streak, ' 天'], ]; const body = rows.map((row) => { const delta = row[1] - row[2]; const cls = delta > 0 ? 'up' : delta < 0 ? 'down' : ''; const sign = delta > 0 ? '+' : ''; return '
' + row[0] + '' + '' + Report.formatInt(row[2]) + ' → ' + Report.formatInt(row[1]) + ' ' + sign + Report.formatInt(delta) + row[3] + '
'; }).join(''); return '
和去年比' + (report.year - 1) + '
' + body + '
'; } function percentileBlock(report) { const body = report.percentiles.map((row) => { return '
' + esc(row.label) + '' + '超过 ' + Report.formatInt(row.pct) + '% · 人均 ' + Report.formatInt(row.avg) + ' ' + esc(row.unit) + '
'; }).join(''); return '
超过多少玩家回顾页百分位
' + body + '
'; } function platformBlock(report) { const rows = report.platforms.filter((p) => p.pct > 0 || p.sessions > 0); if (!rows.length) return ''; const body = rows.map((p) => { return '
' + esc(p.label) + '' + '' + Report.formatPct(p.pct) + ' · ' + Report.formatInt(p.sessions) + ' 次
'; }).join(''); return '
在哪玩的
' + body + '

Windows、Linux、macOS 按游玩时长划分。手柄、Steam Deck 和 VR 另外统计,可以和系统同时发生。

'; } function bucketBlock(report) { const b = report.buckets; const sum = b.released + b.recent + b.classic; if (!sum) return ''; function w(n) { return (n / sum * 100).toFixed(1) + '%'; } return '
发售年份不含试用和测试
' + '
' + '
本年发售 ' + Report.formatInt(b.released) + '' + '近 ' + report.cutoffYears + ' 年 ' + Report.formatInt(b.recent) + '' + '更早 ' + Report.formatInt(b.classic) + '
'; } function chipBlock(report) { const n = report.numbers; const items = [ ['截图', n.screenshots], ['评测', n.reviews], ['徽章', n.badges], ['新好友', n.friends], ['工坊投稿', n.workshopItems], ['工坊订阅', n.workshopSubs], ['论坛', n.forumPosts], ].filter((item) => item[1] > 0); if (!items.length) return ''; return '
这一年还留下
' + items.map((item) => '' + esc(item[0]) + ' ' + Report.formatInt(item[1]) + '').join('') + '
'; } function paintStory(report, nameOf) { const host = document.getElementById('syr-story'); if (!host) return; host.textContent = ''; Report.narrative(report, nameOf).forEach((line) => { const p = document.createElement('p'); p.textContent = line; host.appendChild(p); }); document.querySelectorAll('.syr-fill[data-appid]').forEach((node) => { const name = nameOf(node.getAttribute('data-appid')); if (name) node.textContent = name; }); } function capsuleUrl(appid) { return 'https://cdn.akamai.steamstatic.com/steam/apps/' + appid + '/capsule_231x87.jpg'; } function artScore(src) { if (/capsule_231x87/.test(src)) return 100; if (/capsule_/.test(src)) return 80; if (/header/.test(src)) return 70; if (/library_600x900|library_capsule/.test(src)) return 60; if (/library_hero/.test(src)) return 50; if (/community_assets/.test(src)) return 30; if (/page_bg/.test(src)) return 5; return 20; } function considerArt(map, src) { const hit = String(src || '').match(/\/apps\/(\d+)\//); if (!hit) return; const id = hit[1]; if (!map[id] || artScore(src) > artScore(map[id])) map[id] = src; } function harvestArt() { const map = {}; document.querySelectorAll('img').forEach((img) => { if (img.closest && img.closest('#syr-root')) return; considerArt(map, img.currentSrc || img.src); }); document.querySelectorAll('[style*="/apps/"]').forEach((el) => { const hit = String(el.getAttribute('style') || '').match(/url\(["']?([^"')]+)/); if (hit) considerArt(map, hit[1]); }); return map; } function harvestNames() { const map = {}; document.querySelectorAll('a[href*="/app/"]').forEach((a) => { const hit = String(a.getAttribute('href') || '').match(/\/app\/(\d+)\/([^/?#]+)/); if (!hit || map[hit[1]]) return; try { const name = decodeURIComponent(hit[2]).replace(/_/g, ' ').trim(); if (name) map[hit[1]] = name; } catch (e) { /* 坏链接跳过 */ } }); return map; } function collectIds(report) { const ids = []; report.games.slice(0, 10).forEach((g) => ids.push(g.appid)); report.months.forEach((m) => { if (m.topAppid) ids.push(m.topAppid); }); return ids.filter((id, i) => id && ids.indexOf(id) === i); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchName(appid, lang) { const url = '/api/appdetails?appids=' + appid + '&l=' + encodeURIComponent(lang) + '&filters=basic'; for (let attempt = 0; attempt < 2; attempt += 1) { try { const res = await fetch(url, { credentials: 'include' }); if (res.status === 429) { await sleep(700); continue; } const json = await res.json(); const row = json && json[String(appid)]; if (row && row.success && row.data && row.data.name) return String(row.data.name); return ''; } catch (e) { await sleep(400); } } return ''; } async function fillNames(ids, names, lang, onUpdate) { const missing = ids.filter((id) => !names[String(id)]); if (!missing.length) return; let cursor = 0; async function run() { while (cursor < missing.length) { const id = missing[cursor]; cursor += 1; const name = await fetchName(id, lang); if (!name) continue; names[String(id)] = name; onUpdate(); } } const workers = []; const n = Math.min(4, missing.length); for (let i = 0; i < n; i += 1) workers.push(run()); await Promise.all(workers); } function injectStyle() { if (document.getElementById('syr-style')) return; const style = document.createElement('style'); style.id = 'syr-style'; style.textContent = CSS; document.documentElement.appendChild(style); } let prevOverflow = ''; function setOpen(root, fab, open) { root.classList.toggle('syr-open', open); fab.hidden = open; if (open) { prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; memory('syr-dash', '1'); } else { document.body.style.overflow = prevOverflow; memory('syr-dash', '0'); } } function animate(root) { const bars = root.querySelectorAll('[data-h],[data-bar]'); const apply = () => { bars.forEach((node) => { if (node.hasAttribute('data-h')) node.style.height = node.getAttribute('data-h'); if (node.hasAttribute('data-bar')) node.style.width = node.getAttribute('data-bar'); }); root.querySelectorAll('[data-count]').forEach(countUp); }; if (REDUCED) apply(); else requestAnimationFrame(apply); } function countUp(node) { const target = Number(node.getAttribute('data-count')); const dec = Number(node.getAttribute('data-dec') || 0); const finalText = node.getAttribute('data-text') || ''; if (REDUCED || !(target > 0)) { node.textContent = finalText; return; } const t0 = performance.now(); const step = (now) => { const p = Math.min(1, (now - t0) / 900); const eased = 1 - Math.pow(1 - p, 3); const value = target * eased; node.textContent = dec ? value.toFixed(dec) : Math.round(value).toLocaleString('en-US'); if (p < 1) requestAnimationFrame(step); else node.textContent = finalText; }; requestAnimationFrame(step); } function bind(root, fab, report, nameOf) { const panel = root.querySelector('#syr-panel'); root.addEventListener('click', (e) => { if (e.target === root) setOpen(root, fab, false); }); panel.addEventListener('click', (e) => e.stopPropagation()); root.querySelector('#syr-close').addEventListener('click', () => setOpen(root, fab, false)); fab.addEventListener('click', () => setOpen(root, fab, true)); root.querySelector('#syr-copy').addEventListener('click', () => { copyText(Report.summaryText(report, nameOf)); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && root.classList.contains('syr-open')) setOpen(root, fab, false); }); } function copyText(text) { const done = () => toast('已复制年度总结'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done)); return; } fallbackCopy(text, done); } function fallbackCopy(text, done) { const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.cssText = 'position:fixed;left:-999px'; document.body.appendChild(area); area.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('复制失败'); } area.remove(); } function toast(message) { const node = document.getElementById('syr-toast'); if (!node) return; node.textContent = message; node.classList.add('on'); setTimeout(() => node.classList.remove('on'), 1600); } function boot(tries) { try { const page = readPage(); if (!page) { if (tries < 40) setTimeout(() => boot(tries + 1), 250); else window.__SYR_LOCK__ = false; return; } mount(Report.buildReport(page.replay, page.ctx), page.lang); } catch (e) { window.__SYR_LOCK__ = false; console.error('[syr]', e); } } boot(0); })();