diff --git a/src/components/pages/ToolsPage.tsx b/src/components/pages/ToolsPage.tsx index 8dd3ddc..f0b819c 100644 --- a/src/components/pages/ToolsPage.tsx +++ b/src/components/pages/ToolsPage.tsx @@ -108,6 +108,7 @@ export function ToolsPage() { const [benchNoCooldown, setBenchNoCooldown] = useState(store.get('benchNoCooldown')) const [hideTFT, setHideTFT] = useState(store.get('hideTFT')) const [hideRightNavText, setHideRightNavText] = useState(store.get('hideRightNavText')) + const [fixLcuWindow, setFixLcuWindow] = useState(store.get('fixLcuWindow')) const [windowEffect, setWindowEffect] = useState(store.get('windowEffect')) const [champSelectAssist, setChampSelectAssist] = useState(store.get('champSelectAssist')) const [opggBuildRecommendation, setOpggBuildRecommendation] = useState(store.get('opggBuildRecommendation')) @@ -163,6 +164,7 @@ export function ToolsPage() { store.onChange('unlockChromas', setUnlockChromas), store.onChange('benchNoCooldown', setBenchNoCooldown), store.onChange('hideTFT', setHideTFT), + store.onChange('fixLcuWindow', setFixLcuWindow), store.onChange('windowEffect', setWindowEffect), store.onChange('champSelectAssist', setChampSelectAssist), store.onChange('opggBuildRecommendation', setOpggBuildRecommendation), @@ -644,6 +646,15 @@ export function ToolsPage() { onChange={(v) => { setHideRightNavText(v); store.set('hideRightNavText', v) }} /> + + { setFixLcuWindow(v); store.set('fixLcuWindow', v) }} + /> + = 99 ? 'Perfect' : s.kdaNum.toFixed(2) - const rating = getRating(s.winRate, s.kdaNum) + for (const s of stats) { + const floor = `${s.floor}楼` + if (s.winRate == null) { + chatLines.push(`${floor}: 🆕 萌新上线 (无战绩)`) + continue + } + const winRate = s.winRate.toFixed(1) + const kdaStr = s.kdaNum >= 99 ? 'Perfect' : s.kdaNum.toFixed(2) + const rating = getRating(s.winRate, s.kdaNum) + chatLines.push(`${floor}: ${rating} | 胜率${winRate}% | KDA ${kdaStr}`) + } - logger.info( - '│ %s — %s#%s — 近%d场 胜率: %s%% (%d胜%d负) | KDA: %s (%.1f/%.1f/%.1f) | %s', - floor, s.gameName, s.tagLine, - s.total, winRate, s.wins, s.total - s.wins, - kdaStr, s.avgK, s.avgD, s.avgA, rating, - ) + chatLines.push('') + chatLines.push('Sona助手 ♫') - chatLines.push(`${floor}: ${rating} | 胜率${winRate}% | KDA ${kdaStr}`) + const msg = chatLines.join('\n') + const msgType = store.get('analyzeTeamPowerMsgType') || 'celebration' + for (let attempt = 0; attempt < 10; attempt++) { + try { + await lcu.sendChampSelectMessage(msg, msgType) + logger.info('队友分析已发送到聊天框 ✓ (%s)', phaseLabel) + break + } catch { + if (attempt < 9) { + await sleep(1000) + } else { + logger.warn('聊天发送失败,聊天室始终未就绪') + } } + } +} - logger.info('└────────────────────') +async function analyzeTeammates() { + try { + const session = await lcu.getChampSelectSession() + const { stats } = await fetchTeamStats() - // 等待聊天室就绪后发送 - const msg = chatLines.join('\n') - const msgType = store.get('analyzeTeamPowerMsgType') || 'celebration' - for (let attempt = 0; attempt < 10; attempt++) { - try { - await lcu.sendChampSelectMessage(msg, msgType) - logger.info('队友分析已发送到聊天框 ✓') - break - } catch { - if (attempt < 9) { - await sleep(1000) - } else { - logger.warn('聊天发送失败,聊天室始终未就绪') - } - } + // 缓存 stats 供换楼后重建消息(避免重复请求 SGP) + analyzeTeamPowerStatsByPuuid.clear() + for (const s of stats) { + if (s.puuid) analyzeTeamPowerStatsByPuuid.set(s.puuid, s) + if (s.summonerId) analyzeTeamPowerStatsByPuuid.set(`sid:${s.summonerId}`, s) } + + const phaseLabel = getPhaseLabel(session.timer?.phase ?? '') + await sendTeamStatsMessage(stats, phaseLabel) } catch (err) { logger.error('队友战绩分析失败:', err) } } let analyzeTeamPowerUnsub: (() => void) | null = null +let analyzeTeamPowerUpdateUnsub: (() => void) | null = null +/** 上次发送聊天消息时的 trades 快照,换楼会导致 trades 变化从而触发重发 */ +let lastAnalyzedTradesSnapshot = '' +/** puuid → TeammateStats 缓存,换楼后直接从缓存重建消息,避免重复请求 SGP */ +const analyzeTeamPowerStatsByPuuid = new Map() + +/** 根据 myTeam 顺序 + 缓存的 stats 构造聊天消息并发送 */ +async function resendAnalyzeMessageFromCache() { + try { + const session = await lcu.getChampSelectSession() + if (!session?.myTeam) return + + const stats: TeammateStats[] = [] + for (let i = 0; i < session.myTeam.length; i++) { + const player = session.myTeam[i] + const stat = (player.puuid ? analyzeTeamPowerStatsByPuuid.get(player.puuid) : undefined) + ?? (player.summonerId ? analyzeTeamPowerStatsByPuuid.get(`sid:${player.summonerId}`) : undefined) + + stats.push(stat ?? { + floor: i + 1, + summonerId: player.summonerId, + puuid: player.puuid, + gameName: player.gameName, + tagLine: player.tagLine, + winRate: null, + wins: 0, + total: 0, + avgK: 0, + avgD: 0, + avgA: 0, + kdaNum: 0, + }) + } + + await sendTeamStatsMessage(stats, getPhaseLabel('trade_swap')) + } catch (err) { + logger.error('队友战绩分析失败:', err) + } +} + +/** 监听 CHAMP_SELECT Update,通过 trades 变化检测换楼 */ +function onAnalyzeTeamPowerSwap(event: LCUEventMessage) { + if (event.eventType !== 'Update') return + + const session = event.data as ChampSelectSession + if (!session) return + + const tradesSnapshot = JSON.stringify(session.trades ?? []) + if (tradesSnapshot === lastAnalyzedTradesSnapshot) return + + logger.info('[AnalyzeTeamPower] 检测到换楼,重新发送队友战绩分析') + lastAnalyzedTradesSnapshot = tradesSnapshot + resendAnalyzeMessageFromCache() +} function updateAnalyzeTeamPower(enabled: boolean) { if (enabled && !analyzeTeamPowerUnsub) { @@ -635,10 +706,17 @@ function updateAnalyzeTeamPower(enabled: boolean) { analyzeTeammates() } }) + analyzeTeamPowerUpdateUnsub = lcu.observe(LcuEventUri.CHAMP_SELECT, onAnalyzeTeamPowerSwap) logger.info('Analyze team power enabled ✓') } else if (!enabled && analyzeTeamPowerUnsub) { analyzeTeamPowerUnsub() analyzeTeamPowerUnsub = null + if (analyzeTeamPowerUpdateUnsub) { + analyzeTeamPowerUpdateUnsub() + analyzeTeamPowerUpdateUnsub = null + } + lastAnalyzedTradesSnapshot = '' + analyzeTeamPowerStatsByPuuid.clear() logger.info('Analyze team power disabled') } } @@ -772,6 +850,8 @@ export function initFeatures() { updateAutoReturnToLobby(store.get('autoReturnToLobby')) store.onChange('autoReturnToLobby', updateAutoReturnToLobby) + updateFixLcuWindow(store.get('fixLcuWindow')) + store.onChange('fixLcuWindow', updateFixLcuWindow) store.onChange('autoReturnMode', () => { // 模式变化时,如果功能已启用,重新注册以应用新模式 if (store.get('autoReturnToLobby')) { diff --git a/src/lib/features/fix-lcu-window.ts b/src/lib/features/fix-lcu-window.ts new file mode 100644 index 0000000..ad38959 --- /dev/null +++ b/src/lib/features/fix-lcu-window.ts @@ -0,0 +1,67 @@ +import { logger } from '@/index' + +// ==================== 客户端窗口异常修复 ==================== + +let fixLcuWindowCleanup: (() => void) | null = null + +/** LCU 客户端标准尺寸范围 */ +const MIN_WIDTH = 1024 +const MIN_HEIGHT = 576 +const MAX_WIDTH = 1920 +const MAX_HEIGHT = 1200 + +function clampWindowSize() { + const body = document.body + if (!body) return + + const iw = window.innerWidth + const ih = window.innerHeight + const needsClamp = + iw > MAX_WIDTH || + ih > MAX_HEIGHT || + (iw > 0 && iw < MIN_WIDTH) || + (ih > 0 && ih < MIN_HEIGHT) + + if (needsClamp) { + body.style.maxWidth = `${MAX_WIDTH}px` + body.style.maxHeight = `${MAX_HEIGHT}px` + body.style.minWidth = `${MIN_WIDTH}px` + body.style.minHeight = `${MIN_HEIGHT}px` + body.style.overflow = 'auto' + } else { + body.style.maxWidth = '' + body.style.maxHeight = '' + body.style.minWidth = '' + body.style.minHeight = '' + body.style.overflow = '' + } +} + +export function updateFixLcuWindow(enabled: boolean) { + if (enabled && !fixLcuWindowCleanup) { + let debounceTimer: number + const handler = () => { + window.clearTimeout(debounceTimer) + debounceTimer = window.setTimeout(clampWindowSize, 100) + } + window.addEventListener('resize', handler) + clampWindowSize() + fixLcuWindowCleanup = () => { + window.removeEventListener('resize', handler) + window.clearTimeout(debounceTimer) + const b = document.body + if (b) { + b.style.maxWidth = '' + b.style.maxHeight = '' + b.style.minWidth = '' + b.style.minHeight = '' + b.style.overflow = '' + } + } + logger.info('Fix LCU window enabled ✓') + } else if (!enabled && fixLcuWindowCleanup) { + fixLcuWindowCleanup() + fixLcuWindowCleanup = null + logger.info('Fix LCU window disabled') + } +} diff --git a/src/lib/features/global-particle.ts b/src/lib/features/global-particle.ts index efcd79f..330a3f0 100644 --- a/src/lib/features/global-particle.ts +++ b/src/lib/features/global-particle.ts @@ -19,6 +19,9 @@ function getGlobalParticleHost(): HTMLElement | null { * 必须等待客户端主视图宿主就绪后才挂载,避免首启时被 loading/iframe 层遮挡 */ function tryInjectGlobalParticle(): boolean { + // 只在 LCU 主窗口挂载粒子特效,跳过子窗口(如 Wegame 对局助手、天赋选择弹窗) + if (window.innerWidth < 800 || window.innerHeight < 550) return true + const host = getGlobalParticleHost() if (!host) return false @@ -49,8 +52,9 @@ function tryInjectGlobalParticle(): boolean { }> = [] const resize = () => { - canvas.width = window.innerWidth - canvas.height = window.innerHeight + // 限制 canvas 最大尺寸,防止 CEF 子窗口(如 Wegame 对局助手)被异常撑大 + canvas.width = Math.min(window.innerWidth, 1920) + canvas.height = Math.min(window.innerHeight, 1200) } const initParticles = () => { diff --git a/src/lib/store.ts b/src/lib/store.ts index e1552c8..881ae3d 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -138,6 +138,8 @@ export interface SonaConfig { autoReturnToLobby: boolean /** 自动返回模式: queue=自动排队, lobby=仅返回房间 */ autoReturnMode: string + /** 修复客户端窗口异常(最小化恢复或子窗口尺寸异常时自动校正) */ + fixLcuWindow: boolean } @@ -192,6 +194,7 @@ const DEFAULT_CONFIG: SonaConfig = { gameAnalysisPopup: false, autoReturnToLobby: false, autoReturnMode: 'queue', + fixLcuWindow: false, }