Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/components/pages/ToolsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -644,6 +646,15 @@ export function ToolsPage() {
onChange={(v) => { setHideRightNavText(v); store.set('hideRightNavText', v) }}
/>
</SettingCard>
<SettingCard
title="窗口异常修复"
description="自动修复客户端最小化恢复或子窗口(Wegame 对局助手)尺寸异常的问题。"
>
<SonaSwitch
checked={fixLcuWindow}
onChange={(v) => { setFixLcuWindow(v); store.set('fixLcuWindow', v) }}
/>
</SettingCard>
<SettingCard
title="窗口特效"
description="为客户端窗口添加毛玻璃等视觉效果。Win10 拖动窗口时可能卡顿。但实际测试下来好像没啥效果?"
Expand Down
158 changes: 119 additions & 39 deletions src/lib/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { updateCustomProfileBg } from '@/lib/features/profile-background'
import { updateCustomBanner } from '@/lib/features/custom-banner'
import { updateGameAnalysisPopup } from '@/lib/features/game-analysis-popup'
import { updateAutoReturnToLobby } from '@/lib/features/auto-return-to-lobby'
import { updateFixLcuWindow } from '@/lib/features/fix-lcu-window'
import { updateOpggBuildRecommendation } from '@/lib/features/opgg-build-recommendation'
import { preloadChampSelectTierBadgeData, updateChampSelectTierBadge } from '@/lib/features/champselect-tier-badge'
import { setAvailabilityHijackEnabled, setHideTFTEnabled, setHideRightNavTextEnabled } from '@/lib/injections'
Expand Down Expand Up @@ -572,60 +573,130 @@ export function getRating(winRate: number, kda: number): string {
return '☠️ 演员已就位'
}

async function analyzeTeammates() {
try {
const { stats } = await fetchTeamStats()

logger.info('┌─── 队友战绩分析 ───')
/** 将 timer.phase 或自定义阶段映射为中文 */
function getPhaseLabel(phase: string): string {
switch (phase) {
case 'PLANNING': return '预选英雄阶段'
case 'BAN_PICK': return '禁用/选择阶段'
case 'FINALIZATION': return '锁定英雄阶段'
case 'trade_swap': return '英雄换楼阶段'
default: return '数据刷新'
}
}

const chatLines: string[] = ['Sona助手 ♫ 队友卡池一览(本模式战绩):\n']
const SEPARATOR = '━━━'

for (const s of stats) {
const floor = `${s.floor}楼`
if (s.winRate == null) {
logger.info('│ %s — %s#%s — 无近期战绩或查询失败', floor, s.gameName, s.tagLine)
chatLines.push(`${floor}: 🆕 萌新上线 (无战绩)`)
continue
}
/** 统一的队友战绩消息构造与发送 */
async function sendTeamStatsMessage(stats: TeammateStats[], phaseLabel: string) {
const header = `${SEPARATOR} 队友卡池一览(${phaseLabel})${SEPARATOR}`
const chatLines: string[] = [header, '']

const winRate = s.winRate.toFixed(1)
const kdaStr = s.kdaNum >= 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<string, TeammateStats>()

/** 根据 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) {
Expand All @@ -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')
}
}
Expand Down Expand Up @@ -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')) {
Expand Down
67 changes: 67 additions & 0 deletions src/lib/features/fix-lcu-window.ts
Original file line number Diff line number Diff line change
@@ -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')
}
}
8 changes: 6 additions & 2 deletions src/lib/features/global-particle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = () => {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export interface SonaConfig {
autoReturnToLobby: boolean
/** 自动返回模式: queue=自动排队, lobby=仅返回房间 */
autoReturnMode: string
/** 修复客户端窗口异常(最小化恢复或子窗口尺寸异常时自动校正) */
fixLcuWindow: boolean
}


Expand Down Expand Up @@ -192,6 +194,7 @@ const DEFAULT_CONFIG: SonaConfig = {
gameAnalysisPopup: false,
autoReturnToLobby: false,
autoReturnMode: 'queue',
fixLcuWindow: false,
}


Expand Down