diff --git a/.gitignore b/.gitignore index 1cc9a90..c96749f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,11 @@ app-data downloads logs python-embedded -configs \ No newline at end of file +configs + +# Map Mask smoke/debug artifacts and Electron profiles +.map-mask-smoke-user-data*/ +map-mask-smoke-artifacts/ +map-mask-viewport-artifacts/ +*.log +*.png diff --git a/src/lib/electron-router-dom.ts b/src/lib/electron-router-dom.ts index 5e7fda2..4ade48c 100644 --- a/src/lib/electron-router-dom.ts +++ b/src/lib/electron-router-dom.ts @@ -4,6 +4,6 @@ export const { Router, registerRoute, settings } = createElectronRouter({ port: 4927, types: { - ids: ['main', 'about', 'overlay', 'video-overlay', 'splash'], + ids: ['main', 'about', 'overlay', 'video-overlay', 'map-mask-overlay', 'splash'], }, }) diff --git a/src/main/index.ts b/src/main/index.ts index 1e98f62..d31e261 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { BrowserWindow, app, ipcMain } from 'electron' import { execFile } from 'node:child_process' +import { join } from 'node:path' import { promisify } from 'node:util' import { makeAppWithSingleInstanceLock } from 'lib/electron-app/factories/app/instance' @@ -19,12 +20,17 @@ import { registerConversationBridge } from './services/conversation-bridge' import { MainWindow } from './windows/main' import { OverlayWindow, persistOverlayState } from './windows/overlay' import { SplashWindow } from './windows/splash' +import { persistMapMaskOverlayState } from './windows/map-mask-overlay' import { persistVideoOverlayState, unregisterVideoOverlayShortcuts } from './windows/video-overlay' import log from 'electron-log/main.js' const execFileAsync = promisify(execFile) const AUTO_START_TASK_NAME = 'Whimbox Auto Start' +if (process.env.WHIMBOX_MAP_MASK_SMOKE === '1') { + app.setPath('userData', join(process.cwd(), '.map-mask-smoke-user-data')) +} + if (process.platform === 'win32') { app.commandLine.appendSwitch('no-sandbox') app.commandLine.appendSwitch('disable-gpu-sandbox') @@ -267,6 +273,11 @@ makeAppWithSingleInstanceLock(async () => { registerAppLogger() registerLauncherIpc(window) registerAppUpdater(window) + if (process.env.WHIMBOX_MAP_MASK_SMOKE === '1') { + void import('./services/map-mask-smoke') + .then(({ runMapMaskSmoke }) => runMapMaskSmoke({ waitForRpcConnected })) + .catch((error) => log.error('[map-mask-smoke] failed', error)) + } try { await startAuthServer(window) } catch (error) { @@ -289,6 +300,7 @@ makeAppWithSingleInstanceLock(async () => { app.on('before-quit', () => { persistOverlayState() persistVideoOverlayState() + persistMapMaskOverlayState() unregisterVideoOverlayShortcuts() primaryWindow = null destroyTray() diff --git a/src/main/services/game-window-tracker.ts b/src/main/services/game-window-tracker.ts new file mode 100644 index 0000000..67e2cbc --- /dev/null +++ b/src/main/services/game-window-tracker.ts @@ -0,0 +1,712 @@ +import { execFile } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' + +import log from 'electron-log/main.js' + +const execFileAsync = promisify(execFile) + +export type GameWindowTrackerMode = 'debug' | 'real-window' + +export type GameWindowRect = { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number +} + +export type GameWindowBounds = { + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: GameWindowTrackerMode + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: GameWindowRect | null + clientRect: GameWindowRect | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string +} + +type DebugBounds = { + x: number + y: number + width: number + height: number +} + +type TrackerConfig = { + mode?: string + windowTitleKeywords?: unknown + processNames?: unknown + debugBounds?: Partial +} + +type NativeWindowLookupResult = { + found?: boolean + matchedBy?: 'title' | 'process' + title?: string + handle?: string + processName?: string + pid?: number + x?: number + y?: number + width?: number + height?: number + appliedBoundsSource?: 'client-area' | 'window-rect' + clientAreaAvailable?: boolean + clientX?: number + clientY?: number + clientWidth?: number + clientHeight?: number + windowRect?: GameWindowRect + clientRect?: GameWindowRect | null + dpiScale?: number + scaleFactor?: number + isMinimized?: boolean +} + +const DEFAULT_DEBUG_WIDTH = 1920 +const DEFAULT_DEBUG_HEIGHT = 1080 +const DEFAULT_TITLE_KEYWORDS = [ + 'Infinity Nikki', + '无限暖暖', + 'InfinityNikki', + 'Papergames', + 'Infold', +] +const DEFAULT_PROCESS_NAMES = [ + 'X6Game-Win64-Shipping.exe', + 'X6Game.exe', +] +const LOOKUP_TIMEOUT_MS = 5000 +const DEFAULT_TRACKER_INTERVAL_MS = 1000 + +class GameWindowTracker { + private readonly debugBounds: DebugBounds + private readonly titleKeywords: string[] + private readonly processNames: string[] + private readonly trackerMode: GameWindowTrackerMode + private readonly trackerIntervalMs: number + private readonly windowLookupScript: string + private currentBounds: GameWindowBounds + private refreshPromise: Promise | null = null + + constructor() { + const config = loadTrackerConfig() + this.debugBounds = resolveDebugBounds(config) + this.titleKeywords = resolveTitleKeywords(config) + this.processNames = resolveProcessNames(config) + this.trackerMode = resolveTrackerMode(config) + this.trackerIntervalMs = resolveTrackerInterval() + this.windowLookupScript = buildWindowLookupScript( + this.titleKeywords, + this.processNames, + ) + this.currentBounds = this.buildDebugBounds( + this.trackerMode === 'debug' + ? 'debug tracker mode' + : 'waiting for first window lookup', + ) + } + + getBounds(): GameWindowBounds { + return this.currentBounds + } + + getMode(): GameWindowTrackerMode { + return this.trackerMode + } + + getIntervalMs(): number { + return this.trackerIntervalMs + } + + async refresh(): Promise { + if (this.refreshPromise) return this.refreshPromise + this.refreshPromise = this.refreshNow().finally(() => { + this.refreshPromise = null + }) + return this.refreshPromise + } + + private async refreshNow(): Promise { + const started = performance.now() + if (this.trackerMode === 'debug' || process.platform !== 'win32') { + return this.commitBounds(this.buildDebugBounds( + this.trackerMode === 'debug' + ? 'debug tracker mode' + : 'real window tracking is Windows-only', + ), started) + } + + try { + const found = await findGameWindow(this.windowLookupScript) + if (found?.found && isUsableWindowBounds(found)) { + return this.commitBounds({ + x: Math.round(found.x), + y: Math.round(found.y), + width: Math.round(found.width), + height: Math.round(found.height), + source: 'game-window', + appliedBoundsSource: found.appliedBoundsSource ?? 'window-rect', + trackerMode: this.trackerMode, + isGameWindowFound: true, + isMinimized: Boolean(found.isMinimized), + clientAreaAvailable: Boolean(found.clientAreaAvailable), + clientX: asFiniteNumber(found.clientX), + clientY: asFiniteNumber(found.clientY), + clientWidth: asFiniteNumber(found.clientWidth), + clientHeight: asFiniteNumber(found.clientHeight), + windowRect: found.windowRect ?? null, + clientRect: found.clientRect ?? null, + dpiScale: asFiniteNumber(found.dpiScale), + scaleFactor: asFiniteNumber(found.scaleFactor ?? found.dpiScale), + matchedBy: found.matchedBy ?? 'fallback', + processName: found.processName ?? null, + pid: asFiniteNumber(found.pid), + foundWindowTitle: found.title ?? null, + foundWindowHandle: found.handle ?? null, + titleKeywords: this.titleKeywords, + processNames: this.processNames, + lastUpdateTime: new Date().toISOString(), + trackerIntervalMs: this.trackerIntervalMs, + lastUpdateDurationMs: 0, + lastBoundsChanged: false, + }, started) + } + + return this.commitBounds( + this.buildDebugBounds('game window not found'), + started, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn(`[game-window-tracker] lookup failed: ${message}`) + return this.commitBounds( + this.buildDebugBounds(`lookup failed: ${message}`), + started, + ) + } + } + + private commitBounds( + next: GameWindowBounds, + started: number, + ): GameWindowBounds { + const previous = this.currentBounds + const changed = ( + previous.x !== next.x || + previous.y !== next.y || + previous.width !== next.width || + previous.height !== next.height || + previous.source !== next.source || + previous.isMinimized !== next.isMinimized + ) + this.currentBounds = { + ...next, + trackerIntervalMs: this.trackerIntervalMs, + lastUpdateDurationMs: Math.max(0, performance.now() - started), + lastBoundsChanged: changed, + } + return this.currentBounds + } + + private buildDebugBounds(message: string): GameWindowBounds { + return { + x: this.debugBounds.x, + y: this.debugBounds.y, + width: this.debugBounds.width, + height: this.debugBounds.height, + source: 'debug-fixed', + appliedBoundsSource: 'debug-fixed', + trackerMode: this.trackerMode, + isGameWindowFound: false, + isMinimized: false, + clientAreaAvailable: true, + clientX: this.debugBounds.x, + clientY: this.debugBounds.y, + clientWidth: this.debugBounds.width, + clientHeight: this.debugBounds.height, + windowRect: rectFromBounds(this.debugBounds), + clientRect: rectFromBounds(this.debugBounds), + dpiScale: 1, + scaleFactor: 1, + matchedBy: 'fallback', + processName: null, + pid: null, + foundWindowTitle: null, + foundWindowHandle: null, + titleKeywords: this.titleKeywords, + processNames: this.processNames, + lastUpdateTime: new Date().toISOString(), + trackerIntervalMs: this.trackerIntervalMs, + lastUpdateDurationMs: 0, + lastBoundsChanged: false, + message, + } + } +} + +function resolveTrackerMode(config: TrackerConfig): GameWindowTrackerMode { + if (process.env.WHIMBOX_MAP_MASK_SMOKE === '1') return 'debug' + + const rawMode = ( + process.env.WHIMBOX_MAP_MASK_TRACKER_MODE ?? + config.mode ?? + 'real' + ).toString().trim().toLowerCase() + + if (rawMode === 'debug' || rawMode === 'fixed') return 'debug' + return 'real-window' +} + +function resolveTrackerInterval(): number { + const value = Number(process.env.WHIMBOX_MAP_MASK_TRACKER_INTERVAL_MS) + if (!Number.isFinite(value)) return DEFAULT_TRACKER_INTERVAL_MS + return Math.max(250, Math.round(value)) +} + +function resolveTitleKeywords(config: TrackerConfig): string[] { + const fromEnv = splitKeywords(process.env.WHIMBOX_MAP_MASK_WINDOW_TITLE_KEYWORDS) + if (fromEnv.length > 0) return fromEnv + + const rawConfigKeywords = config.windowTitleKeywords + if (Array.isArray(rawConfigKeywords)) { + const keywords = rawConfigKeywords + .map((item) => String(item).trim()) + .filter(Boolean) + if (keywords.length > 0) return keywords + } + if (typeof rawConfigKeywords === 'string') { + const keywords = splitKeywords(rawConfigKeywords) + if (keywords.length > 0) return keywords + } + + return DEFAULT_TITLE_KEYWORDS +} + +function resolveProcessNames(config: TrackerConfig): string[] { + const fromEnv = normalizeProcessNames(splitKeywords(process.env.WHIMBOX_MAP_MASK_PROCESS_NAMES)) + if (fromEnv.length > 0) return fromEnv + + const rawConfigProcessNames = config.processNames + if (Array.isArray(rawConfigProcessNames)) { + const names = normalizeProcessNames(rawConfigProcessNames.map((item) => String(item))) + if (names.length > 0) return names + } + if (typeof rawConfigProcessNames === 'string') { + const names = normalizeProcessNames(splitKeywords(rawConfigProcessNames)) + if (names.length > 0) return names + } + + return DEFAULT_PROCESS_NAMES +} + +function normalizeProcessNames(values: string[]): string[] { + const seen = new Set() + const normalized: string[] = [] + for (const value of values) { + const trimmed = value.trim() + if (!trimmed) continue + const withExtension = trimmed.toLowerCase().endsWith('.exe') + ? trimmed + : `${trimmed}.exe` + const key = withExtension.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + normalized.push(withExtension) + } + return normalized +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function rectFromBounds(bounds: DebugBounds): GameWindowRect { + return { + left: bounds.x, + top: bounds.y, + right: bounds.x + bounds.width, + bottom: bounds.y + bounds.height, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + } +} + +function resolveDebugBounds(config: TrackerConfig): DebugBounds { + const fromEnv = parseDebugBounds(process.env.WHIMBOX_MAP_MASK_DEBUG_BOUNDS) + if (fromEnv) return fromEnv + + const fromConfig = config.debugBounds + if ( + fromConfig && + Number.isFinite(fromConfig.width) && + Number.isFinite(fromConfig.height) + ) { + return { + x: Number.isFinite(fromConfig.x) ? Number(fromConfig.x) : 0, + y: Number.isFinite(fromConfig.y) ? Number(fromConfig.y) : 0, + width: Math.max(1, Number(fromConfig.width)), + height: Math.max(1, Number(fromConfig.height)), + } + } + + return { x: 0, y: 0, width: DEFAULT_DEBUG_WIDTH, height: DEFAULT_DEBUG_HEIGHT } +} + +function parseDebugBounds(value: string | undefined): DebugBounds | null { + if (!value) return null + const parts = value + .split(/[,\s]+/) + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)) + if (parts.length !== 4) return null + const [x, y, width, height] = parts + return { + x, + y, + width: Math.max(1, width), + height: Math.max(1, height), + } +} + +function splitKeywords(value: string | undefined): string[] { + if (!value) return [] + return value + .split(/[;,|\n]/) + .map((item) => item.trim()) + .filter(Boolean) +} + +function loadTrackerConfig(): TrackerConfig { + const configPath = process.env.WHIMBOX_MAP_MASK_TRACKER_CONFIG + ? resolve(process.env.WHIMBOX_MAP_MASK_TRACKER_CONFIG) + : join(process.cwd(), 'map-mask-window-tracker.json') + + if (!existsSync(configPath)) return {} + + try { + return JSON.parse(readFileSync(configPath, 'utf-8')) as TrackerConfig + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn(`[game-window-tracker] failed to read ${configPath}: ${message}`) + return {} + } +} + +function isUsableWindowBounds( + value: NativeWindowLookupResult, +): value is Required> & + NativeWindowLookupResult { + return ( + typeof value.x === 'number' && + typeof value.y === 'number' && + typeof value.width === 'number' && + typeof value.height === 'number' && + value.width > 0 && + value.height > 0 + ) +} + +async function findGameWindow( + script: string, +): Promise { + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], + { + windowsHide: true, + timeout: LOOKUP_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + }, + ) + const text = stdout.trim() + if (!text) return null + return JSON.parse(text) as NativeWindowLookupResult +} + +function buildWindowLookupScript(titleKeywords: string[], processNames: string[]) { + const keywordsJson = JSON.stringify(titleKeywords).replace(/'/g, "''") + const processNamesJson = JSON.stringify(processNames).replace(/'/g, "''") + return ` +$ErrorActionPreference = 'Stop' +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public class WhimboxMapMaskWindow { + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + + [DllImport("user32.dll")] + public static extern bool GetClientRect(IntPtr hWnd, out RECT rect); + + [DllImport("user32.dll")] + public static extern bool ClientToScreen(IntPtr hWnd, ref POINT point); + + [DllImport("user32.dll")] + public static extern uint GetDpiForWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetShellWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + public struct POINT { + public int X; + public int Y; + } +} +"@ + +$keywords = ConvertFrom-Json -InputObject '${keywordsJson}' +if ($null -eq $keywords) { + $keywords = @() +} elseif ($keywords -isnot [System.Array]) { + $keywords = @($keywords) +} +$processNames = ConvertFrom-Json -InputObject '${processNamesJson}' +if ($null -eq $processNames) { + $processNames = @() +} elseif ($processNames -isnot [System.Array]) { + $processNames = @($processNames) +} +$shellWindow = [WhimboxMapMaskWindow]::GetShellWindow() +$candidates = New-Object System.Collections.Generic.List[object] + +function Normalize-ProcessName([string]$name) { + if ([string]::IsNullOrWhiteSpace($name)) { return $null } + $trimmed = $name.Trim() + if ($trimmed.EndsWith('.exe', [System.StringComparison]::OrdinalIgnoreCase)) { + return $trimmed + } + return "$($trimmed).exe" +} + +function Test-TitleMatch([string]$title) { + $safeTitle = if ($null -eq $title) { '' } else { $title } + foreach ($keyword in $keywords) { + $text = [string]$keyword + if ([string]::IsNullOrWhiteSpace($text)) { continue } + if ($safeTitle.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + return $true + } + } + return $false +} + +function Test-ProcessMatch([string]$processName) { + $normalized = Normalize-ProcessName $processName + if ([string]::IsNullOrWhiteSpace($normalized)) { return $false } + foreach ($expected in $processNames) { + $expectedName = Normalize-ProcessName ([string]$expected) + if ([string]::IsNullOrWhiteSpace($expectedName)) { continue } + if ([string]::Equals($normalized, $expectedName, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} + +$processNameByPid = @{} +Get-Process | ForEach-Object { + try { + $processNameByPid[[int]$_.Id] = Normalize-ProcessName $_.ProcessName + } catch {} +} + +[WhimboxMapMaskWindow]::EnumWindows({ + param([IntPtr]$hWnd, [IntPtr]$lParam) + if ($hWnd -eq $shellWindow) { return $true } + if (-not [WhimboxMapMaskWindow]::IsWindowVisible($hWnd)) { return $true } + + $length = [WhimboxMapMaskWindow]::GetWindowTextLength($hWnd) + $title = '' + if ($length -gt 0) { + $builder = New-Object System.Text.StringBuilder ($length + 1) + [void][WhimboxMapMaskWindow]::GetWindowText($hWnd, $builder, $builder.Capacity) + $title = $builder.ToString() + } + + $pidValue = [uint32]0 + [void][WhimboxMapMaskWindow]::GetWindowThreadProcessId($hWnd, [ref]$pidValue) + $processName = $null + if ($pidValue -gt 0) { + $processName = $processNameByPid[[int]$pidValue] + } + + $titleMatched = Test-TitleMatch $title + $processMatched = Test-ProcessMatch $processName + if (-not $titleMatched -and -not $processMatched) { + return $true + } + + $rect = New-Object WhimboxMapMaskWindow+RECT + if (-not [WhimboxMapMaskWindow]::GetWindowRect($hWnd, [ref]$rect)) { + return $true + } + + $windowWidth = $rect.Right - $rect.Left + $windowHeight = $rect.Bottom - $rect.Top + if ($windowWidth -le 0 -or $windowHeight -le 0) { + return $true + } + + $windowRect = [pscustomobject]@{ + left = $rect.Left + top = $rect.Top + right = $rect.Right + bottom = $rect.Bottom + x = $rect.Left + y = $rect.Top + width = $windowWidth + height = $windowHeight + } + + $client = New-Object WhimboxMapMaskWindow+RECT + $clientPoint = New-Object WhimboxMapMaskWindow+POINT + $clientPoint.X = 0 + $clientPoint.Y = 0 + $clientOk = [WhimboxMapMaskWindow]::GetClientRect($hWnd, [ref]$client) + $clientScreenOk = [WhimboxMapMaskWindow]::ClientToScreen($hWnd, [ref]$clientPoint) + $clientWidth = $client.Right - $client.Left + $clientHeight = $client.Bottom - $client.Top + $clientAreaAvailable = $clientOk -and $clientScreenOk -and $clientWidth -gt 0 -and $clientHeight -gt 0 + + if ($clientAreaAvailable) { + $appliedX = $clientPoint.X + $appliedY = $clientPoint.Y + $appliedWidth = $clientWidth + $appliedHeight = $clientHeight + $appliedSource = 'client-area' + $clientRect = [pscustomobject]@{ + left = $clientPoint.X + top = $clientPoint.Y + right = $clientPoint.X + $clientWidth + bottom = $clientPoint.Y + $clientHeight + x = $clientPoint.X + y = $clientPoint.Y + width = $clientWidth + height = $clientHeight + } + } else { + $appliedX = $rect.Left + $appliedY = $rect.Top + $appliedWidth = $windowWidth + $appliedHeight = $windowHeight + $appliedSource = 'window-rect' + $clientRect = $null + } + + $dpiScale = 1.0 + try { + $dpi = [WhimboxMapMaskWindow]::GetDpiForWindow($hWnd) + if ($dpi -gt 0) { + $dpiScale = [math]::Round(([double]$dpi / 96.0), 4) + } + } catch { + $dpiScale = 1.0 + } + + $candidates.Add([pscustomobject]@{ + found = $true + matchedBy = if ($titleMatched) { 'title' } else { 'process' } + titleMatched = $titleMatched + processMatched = $processMatched + title = $title + handle = $hWnd.ToInt64().ToString() + processName = $processName + pid = [int]$pidValue + x = $appliedX + y = $appliedY + width = $appliedWidth + height = $appliedHeight + appliedBoundsSource = $appliedSource + clientAreaAvailable = $clientAreaAvailable + clientX = if ($clientAreaAvailable) { $clientPoint.X } else { $null } + clientY = if ($clientAreaAvailable) { $clientPoint.Y } else { $null } + clientWidth = if ($clientAreaAvailable) { $clientWidth } else { $null } + clientHeight = if ($clientAreaAvailable) { $clientHeight } else { $null } + windowRect = $windowRect + clientRect = $clientRect + dpiScale = $dpiScale + scaleFactor = $dpiScale + isMinimized = [WhimboxMapMaskWindow]::IsIconic($hWnd) + }) | Out-Null + return $true +}, [IntPtr]::Zero) | Out-Null + +$result = $candidates | Where-Object { $_.titleMatched } | Select-Object -First 1 +if ($null -eq $result) { + $result = $candidates | Where-Object { $_.processMatched } | Select-Object -First 1 + if ($null -ne $result) { + $result.matchedBy = 'process' + } +} + +if ($null -eq $result) { + [pscustomobject]@{ found = $false } | ConvertTo-Json -Compress +} else { + $result | ConvertTo-Json -Compress +} +` +} + +export const gameWindowTracker = new GameWindowTracker() diff --git a/src/main/services/map-mask-smoke.ts b/src/main/services/map-mask-smoke.ts new file mode 100644 index 0000000..4b18cd1 --- /dev/null +++ b/src/main/services/map-mask-smoke.ts @@ -0,0 +1,257 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { app, type BrowserWindow } from 'electron' +import log from 'electron-log/main.js' + +import { + MapMaskOverlayWindow, + getMapMaskOverlayDebugState, + setMapMaskOverlayIgnoreMouseEvents, +} from '../windows/map-mask-overlay' + +type SmokeOptions = { + waitForRpcConnected: (timeoutMs: number) => Promise +} + +type RendererDebugState = { + enabled: boolean + visibleCount: number + selectedLabelIds: string[] + labels: string[] + hoverPointId: string | null + selectedPointId: string | null + detailPointId: string | null + hasValidViewport: boolean + isBigMapOpen: boolean + viewportSource: string + detectionSource: string +} + +type CanvasPixel = { + x: number + y: number + rgba: number[] +} + +const SMOKE_DIR = join(process.cwd(), 'map-mask-smoke-artifacts') + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function smokeLog(message: string) { + log.info(`[map-mask-smoke] ${message}`) +} + +async function waitForRendererState( + win: BrowserWindow, + predicate: (state: RendererDebugState | null) => boolean, + timeoutMs = 10_000, +) { + const started = Date.now() + while (Date.now() - started < timeoutMs) { + const state = await readRendererState(win) + if (predicate(state)) return state + await wait(200) + } + return null +} + +async function readRendererState(win: BrowserWindow): Promise { + return await win.webContents.executeJavaScript( + `(() => { + const root = document.querySelector('[data-testid="map-mask-overlay"]'); + if (!root) return null; + const split = (value) => value ? value.split(',').filter(Boolean) : []; + return { + enabled: root.dataset.enabled === 'true', + visibleCount: Number(root.dataset.visibleCount || 0), + selectedLabelIds: split(root.dataset.selectedLabelIds || ''), + labels: split(root.dataset.labelIds || ''), + hoverPointId: root.dataset.hoverPointId || null, + selectedPointId: root.dataset.selectedPointId || null, + detailPointId: root.dataset.detailPointId || null, + hasValidViewport: root.dataset.hasValidViewport === 'true', + isBigMapOpen: root.dataset.isBigmapOpen === 'true', + viewportSource: root.dataset.viewportSource || '', + detectionSource: root.dataset.detectionSource || '', + }; + })()`, + true, + ) as RendererDebugState | null +} + +async function capture(win: BrowserWindow, name: string) { + mkdirSync(SMOKE_DIR, { recursive: true }) + const image = await win.capturePage() + const file = join(SMOKE_DIR, name) + writeFileSync(file, image.toPNG()) + return file +} + +async function readCanvasPixels(win: BrowserWindow): Promise { + return await win.webContents.executeJavaScript( + `(() => { + const canvas = document.querySelector('canvas'); + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return []; + return [ + { x: 235, y: 289, rgba: Array.from(ctx.getImageData(235, 289, 1, 1).data) }, + { x: 500, y: 500, rgba: Array.from(ctx.getImageData(500, 500, 1, 1).data) }, + ]; + })()`, + true, + ) as CanvasPixel[] +} + +async function clickLabel(win: BrowserWindow, labelId: string) { + await win.webContents.executeJavaScript( + `(() => { + const label = document.querySelector('[data-map-mask-label-id="${labelId}"]'); + if (!label) return false; + label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + return true; + })()`, + true, + ) +} + +async function clickBigMapMode(win: BrowserWindow, mode: 'auto' | 'force-open' | 'force-closed') { + await win.webContents.executeJavaScript( + `(() => { + const button = document.querySelector('[data-map-mask-bigmap-mode="${mode}"]'); + if (!button) return false; + button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + return true; + })()`, + true, + ) +} + +async function clickCanvasPoint(win: BrowserWindow, x: number, y: number) { + await win.webContents.executeJavaScript( + `(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return false; + const options = { bubbles: true, cancelable: true, clientX: ${x}, clientY: ${y} }; + canvas.dispatchEvent(new PointerEvent('pointermove', options)); + canvas.dispatchEvent(new MouseEvent('click', options)); + return true; + })()`, + true, + ) +} + +export async function runMapMaskSmoke(options: SmokeOptions) { + const report: Record = { + startedAt: new Date().toISOString(), + screenshots: [], + } + + try { + mkdirSync(SMOKE_DIR, { recursive: true }) + smokeLog('waiting for RPC') + report.rpcConnected = await options.waitForRpcConnected(10_000) + smokeLog(`RPC connected=${String(report.rpcConnected)}`) + + const win = await MapMaskOverlayWindow() + setMapMaskOverlayIgnoreMouseEvents(true, { forward: true }) + win.setAlwaysOnTop(true, 'normal') + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + win.showInactive() + report.initialOverlayWindow = getMapMaskOverlayDebugState() + smokeLog('overlay shown') + + const initial = await waitForRendererState( + win, + (state) => Boolean(state?.hasValidViewport && state.visibleCount >= 2), + ) + report.initialRendererState = initial + smokeLog(`initial state=${JSON.stringify(initial)}`) + await wait(500) + report.initialCanvasPixels = await readCanvasPixels(win) + ;(report.screenshots as string[]).push(await capture(win, '01-initial-overlay.png')) + smokeLog('initial capture saved') + + await clickBigMapMode(win, 'force-closed') + smokeLog('bigmap force-closed clicked') + const afterBigMapClosed = await waitForRendererState( + win, + (state) => Boolean(state && !state.isBigMapOpen && state.visibleCount === 0), + ) + report.afterBigMapClosedRendererState = afterBigMapClosed + smokeLog(`after bigmap closed state=${JSON.stringify(afterBigMapClosed)}`) + ;(report.screenshots as string[]).push(await capture(win, '02-bigmap-force-closed.png')) + + await clickBigMapMode(win, 'force-open') + smokeLog('bigmap force-open clicked') + const afterBigMapOpen = await waitForRendererState( + win, + (state) => Boolean(state?.isBigMapOpen && state.visibleCount >= 2), + ) + report.afterBigMapOpenRendererState = afterBigMapOpen + smokeLog(`after bigmap open state=${JSON.stringify(afterBigMapOpen)}`) + ;(report.screenshots as string[]).push(await capture(win, '03-bigmap-force-open.png')) + + await clickLabel(win, 'material') + smokeLog('material label clicked') + const afterToggle = await waitForRendererState( + win, + (state) => + Boolean( + state && + afterBigMapOpen && + state.visibleCount < afterBigMapOpen.visibleCount && + !state.selectedLabelIds.includes('material'), + ), + ) + report.afterToggleRendererState = afterToggle + smokeLog(`after toggle state=${JSON.stringify(afterToggle)}`) + report.afterToggleCanvasPixels = await readCanvasPixels(win) + ;(report.screenshots as string[]).push(await capture(win, '04-after-category-toggle.png')) + smokeLog('toggle capture saved') + + await clickCanvasPoint(win, 500, 500) + smokeLog('canvas point clicked') + const afterPopup = await waitForRendererState( + win, + (state) => Boolean(state?.detailPointId), + ) + report.afterPopupRendererState = afterPopup + smokeLog(`after popup state=${JSON.stringify(afterPopup)}`) + ;(report.screenshots as string[]).push(await capture(win, '05-point-detail-popup.png')) + smokeLog('popup capture saved') + + await clickLabel(win, 'material') + await waitForRendererState( + win, + (state) => Boolean(state?.selectedLabelIds.includes('material')), + 5_000, + ) + + report.overlayWindow = getMapMaskOverlayDebugState() + report.passed = Boolean( + report.rpcConnected && + initial && + initial.visibleCount >= 2 && + afterBigMapClosed?.visibleCount === 0 && + afterBigMapOpen && + afterBigMapOpen.visibleCount >= 2 && + afterToggle && + !afterToggle.selectedLabelIds.includes('material') && + afterPopup?.detailPointId, + ) + } catch (error) { + report.passed = false + report.error = error instanceof Error ? error.stack ?? error.message : String(error) + } + + const reportPath = join(SMOKE_DIR, 'report.json') + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf-8') + log.info(`[map-mask-smoke] report written to ${reportPath}`) + if (process.env.WHIMBOX_MAP_MASK_SMOKE_EXIT === '1') { + setTimeout(() => app.quit(), 500) + } + return report +} diff --git a/src/main/windows/map-mask-overlay.ts b/src/main/windows/map-mask-overlay.ts new file mode 100644 index 0000000..cb70368 --- /dev/null +++ b/src/main/windows/map-mask-overlay.ts @@ -0,0 +1,273 @@ +import { join } from 'node:path' + +import { BrowserWindow, ipcMain } from 'electron' +import log from 'electron-log/main.js' + +import { createWindow } from 'lib/electron-app/factories/windows/create' +import { gameWindowTracker, type GameWindowBounds } from '../services/game-window-tracker' + +let mapMaskOverlayWindowRef: BrowserWindow | null = null +let creatingMapMaskOverlayWindowPromise: Promise | null = null +let followGameWindowTimer: ReturnType | null = null +let mapMaskOverlayIgnoringMouseEvents = true +let mapMaskOverlayVisibleRequested = false +let hiddenBecauseGameMinimized = false +let allowMapMaskOverlayClose = false + +function isCalibrationOverlayEnabledByEnv() { + const value = process.env.WHIMBOX_MAP_MASK_DEBUG_OVERLAY ?? process.env.WHIMBOX_MAP_MASK_CALIBRATION_OVERLAY + if (!value) return false + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) +} + +function isViewportDebugEnabledByEnv() { + const value = process.env.WHIMBOX_MAP_MASK_DEBUG_VIEWPORT + if (!value) return false + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) +} + +function getTrackedBounds(): GameWindowBounds { + return gameWindowTracker.getBounds() +} + +async function refreshTrackedBounds(): Promise { + return gameWindowTracker.refresh() +} + +async function applyTrackedBounds(win: BrowserWindow) { + const bounds = await refreshTrackedBounds() + + if (bounds.isGameWindowFound && bounds.isMinimized) { + hiddenBecauseGameMinimized = true + if (win.isVisible()) { + win.hide() + } + return bounds + } + + const wasHiddenBecauseGameMinimized = hiddenBecauseGameMinimized + hiddenBecauseGameMinimized = false + const nextBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + } + const currentBounds = win.getBounds() + if ( + currentBounds.x !== nextBounds.x || + currentBounds.y !== nextBounds.y || + currentBounds.width !== nextBounds.width || + currentBounds.height !== nextBounds.height + ) { + win.setBounds(nextBounds) + } + + if (wasHiddenBecauseGameMinimized && mapMaskOverlayVisibleRequested && !win.isVisible()) { + win.showInactive() + } + + return bounds +} + +async function bringMapMaskOverlayToFront(win: BrowserWindow) { + await applyTrackedBounds(win) + win.setAlwaysOnTop(true, 'normal') + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + if (!hiddenBecauseGameMinimized) { + win.showInactive() + } +} + +function startFollowGameWindow() { + if (followGameWindowTimer) return + followGameWindowTimer = setInterval(() => { + const win = mapMaskOverlayWindowRef + if (!win || win.isDestroyed()) return + void applyTrackedBounds(win).catch((error) => { + log.warn(`[map-mask-overlay] failed to follow game window: ${error instanceof Error ? error.message : String(error)}`) + }) + }, gameWindowTracker.getIntervalMs()) +} + +function stopFollowGameWindow() { + if (!followGameWindowTimer) return + clearInterval(followGameWindowTimer) + followGameWindowTimer = null +} + +export function getMapMaskOverlayWindow() { + return mapMaskOverlayWindowRef +} + +export function setMapMaskOverlayIgnoreMouseEvents( + ignore: boolean, + options?: { forward?: boolean }, +) { + const win = mapMaskOverlayWindowRef + if (!win || win.isDestroyed()) return + mapMaskOverlayIgnoringMouseEvents = ignore + win.setIgnoreMouseEvents(ignore, options) +} + +export function getMapMaskOverlayDebugState() { + const win = mapMaskOverlayWindowRef + return { + exists: Boolean(win && !win.isDestroyed()), + visible: Boolean(win && !win.isDestroyed() && win.isVisible()), + alwaysOnTop: Boolean(win && !win.isDestroyed() && win.isAlwaysOnTop()), + ignoreMouseEvents: mapMaskOverlayIgnoringMouseEvents, + transparentConfigured: true, + bounds: win && !win.isDestroyed() ? win.getBounds() : null, + hiddenBecauseGameMinimized, + visibleRequested: mapMaskOverlayVisibleRequested, + tracker: gameWindowTracker.getBounds(), + } +} + +async function ensureMapMaskOverlayWindow() { + if (mapMaskOverlayWindowRef && !mapMaskOverlayWindowRef.isDestroyed()) { + return mapMaskOverlayWindowRef + } + if (creatingMapMaskOverlayWindowPromise) { + return creatingMapMaskOverlayWindowPromise + } + creatingMapMaskOverlayWindowPromise = createMapMaskOverlayWindow().finally(() => { + creatingMapMaskOverlayWindowPromise = null + }) + return creatingMapMaskOverlayWindowPromise +} + +function registerMapMaskOverlayIpc() { + ipcMain.handle('map-mask-overlay:show', async () => { + const win = await ensureMapMaskOverlayWindow() + mapMaskOverlayVisibleRequested = true + setMapMaskOverlayIgnoreMouseEvents(true, { forward: true }) + await bringMapMaskOverlayToFront(win) + startFollowGameWindow() + return true + }) + + ipcMain.handle('map-mask-overlay:hide', () => { + const win = mapMaskOverlayWindowRef + if (!win || win.isDestroyed()) return false + mapMaskOverlayVisibleRequested = false + hiddenBecauseGameMinimized = false + win.hide() + stopFollowGameWindow() + return true + }) + + ipcMain.handle('map-mask-overlay:get-bounds', async () => { + const tracked = getTrackedBounds() + const win = mapMaskOverlayWindowRef + return { + ...tracked, + overlay: win && !win.isDestroyed() ? win.getBounds() : null, + } + }) + + ipcMain.handle('map-mask-overlay:sync-to-game-window', async () => { + const win = mapMaskOverlayWindowRef + if (!win || win.isDestroyed()) return refreshTrackedBounds() + const tracked = await applyTrackedBounds(win) + return { + ...tracked, + overlay: win.getBounds(), + } + }) + + ipcMain.handle('map-mask-overlay:get-debug-options', () => ({ + calibrationOverlayEnabled: isCalibrationOverlayEnabledByEnv(), + viewportDebugEnabled: isViewportDebugEnabledByEnv(), + })) + + ipcMain.handle( + 'map-mask-overlay:set-ignore-mouse-events', + (_event, ignore: boolean, options?: { forward?: boolean }) => { + setMapMaskOverlayIgnoreMouseEvents(ignore, options) + }, + ) +} + +registerMapMaskOverlayIpc() + +async function createMapMaskOverlayWindow() { + const bounds = getTrackedBounds() + const window = createWindow({ + id: 'map-mask-overlay', + title: 'Whimbox Map Mask', + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + minWidth: 640, + minHeight: 360, + show: false, + frame: false, + transparent: true, + resizable: false, + movable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + backgroundColor: '#00000000', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + backgroundThrottling: false, + }, + }) + + mapMaskOverlayWindowRef = window + mapMaskOverlayVisibleRequested = false + hiddenBecauseGameMinimized = false + allowMapMaskOverlayClose = false + window.setIgnoreMouseEvents(true, { forward: true }) + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + + window.on('show', () => { + if (!hiddenBecauseGameMinimized) { + mapMaskOverlayVisibleRequested = true + } + startFollowGameWindow() + }) + + window.on('hide', () => { + if (!mapMaskOverlayVisibleRequested || !hiddenBecauseGameMinimized) { + stopFollowGameWindow() + } + }) + + window.on('close', (event) => { + if (allowMapMaskOverlayClose) return + event.preventDefault() + window.hide() + }) + + window.on('closed', () => { + stopFollowGameWindow() + mapMaskOverlayWindowRef = null + }) + + await new Promise((resolve) => { + window.webContents.once('did-finish-load', () => resolve()) + }) + + log.info(`[map-mask-overlay] created with tracker mode=${gameWindowTracker.getMode()}`) + return window +} + +export async function MapMaskOverlayWindow() { + return ensureMapMaskOverlayWindow() +} + +export function persistMapMaskOverlayState() { + allowMapMaskOverlayClose = true + mapMaskOverlayVisibleRequested = false + stopFollowGameWindow() + const win = mapMaskOverlayWindowRef + if (win && !win.isDestroyed()) { + win.destroy() + } + mapMaskOverlayWindowRef = null +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ecb631..f011804 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -179,6 +179,121 @@ const API = { } }, }, + mapMaskOverlay: { + show: () => ipcRenderer.invoke('map-mask-overlay:show'), + hide: () => ipcRenderer.invoke('map-mask-overlay:hide'), + getBounds: () => + ipcRenderer.invoke('map-mask-overlay:get-bounds') as Promise<{ + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: 'debug' | 'real-window' + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + clientRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string + overlay?: { x: number; y: number; width: number; height: number } | null + }>, + syncToGameWindow: () => + ipcRenderer.invoke('map-mask-overlay:sync-to-game-window') as Promise<{ + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: 'debug' | 'real-window' + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + clientRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string + overlay?: { x: number; y: number; width: number; height: number } | null + }>, + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => + ipcRenderer.invoke('map-mask-overlay:set-ignore-mouse-events', ignore, options), + getDebugOptions: () => + ipcRenderer.invoke('map-mask-overlay:get-debug-options') as Promise<{ + calibrationOverlayEnabled: boolean + viewportDebugEnabled: boolean + }>, + }, conversation: { getState: () => ipcRenderer.invoke('conversation:get-state') as Promise<{ diff --git a/src/renderer/components/map-mask/map-mask-canvas.tsx b/src/renderer/components/map-mask/map-mask-canvas.tsx new file mode 100644 index 0000000..52d66f8 --- /dev/null +++ b/src/renderer/components/map-mask/map-mask-canvas.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { + MapMaskLabel, + MapMaskViewport, + VisibleMapMaskPoint, +} from 'renderer/types/map-mask' + +type CanvasPoint = { + point: VisibleMapMaskPoint + x: number + y: number +} + +type CanvasPointerEvent = + | React.PointerEvent + | React.MouseEvent + +type MapMaskCanvasProps = { + points: VisibleMapMaskPoint[] + labels: MapMaskLabel[] + viewport: MapMaskViewport | null + enabled: boolean + selectedPointId: string | null + onPointHover: ( + point: VisibleMapMaskPoint | null, + position: { x: number; y: number } | null, + ) => void + onPointClick: ( + point: VisibleMapMaskPoint, + position: { x: number; y: number }, + ) => void +} + +const markerColors = [ + '#ff6b8a', + '#39c5bb', + '#f5b84b', + '#8b7cf6', + '#5bc0eb', + '#7bd88f', +] + +function hashColor(value: string) { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0 + } + return markerColors[hash % markerColors.length] +} + +function markerGlyph(label?: MapMaskLabel) { + const source = label?.name || label?.id || '?' + return source.trim().slice(0, 1).toUpperCase() || '?' +} + +function toCanvasPoint( + point: VisibleMapMaskPoint, +): CanvasPoint { + return { point, x: point.screen_x, y: point.screen_y } +} + +function findHit( + points: CanvasPoint[], + x: number, + y: number, +): CanvasPoint | null { + let best: CanvasPoint | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const item of points) { + const dx = item.x - x + const dy = item.y - y + const distance = Math.sqrt(dx * dx + dy * dy) + if (distance <= 18 && distance < bestDistance) { + best = item + bestDistance = distance + } + } + return best +} + +export function MapMaskCanvas({ + points, + labels, + enabled, + selectedPointId, + onPointHover, + onPointClick, +}: MapMaskCanvasProps) { + const canvasRef = useRef(null) + const [size, setSize] = useState({ width: 1, height: 1 }) + + const labelById = useMemo(() => { + return new Map(labels.map((label) => [label.id, label])) + }, [labels]) + + const canvasPoints = useMemo(() => { + return points.map((point) => toCanvasPoint(point)) + }, [points]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const updateSize = () => { + const rect = canvas.getBoundingClientRect() + setSize({ + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }) + } + + updateSize() + const observer = new ResizeObserver(updateSize) + observer.observe(canvas) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ratio = Math.max(1, window.devicePixelRatio || 1) + canvas.width = Math.round(size.width * ratio) + canvas.height = Math.round(size.height * ratio) + + const context = canvas.getContext('2d') + if (!context) return + + context.setTransform(ratio, 0, 0, ratio, 0, 0) + context.clearRect(0, 0, size.width, size.height) + + if (!enabled) return + + for (const item of canvasPoints) { + const label = labelById.get(item.point.label_id) + const color = hashColor(item.point.label_id) + const selected = selectedPointId === item.point.id + + context.save() + context.shadowColor = 'rgba(15, 23, 42, 0.45)' + context.shadowBlur = selected ? 16 : 10 + context.beginPath() + context.arc(item.x, item.y, selected ? 12 : 9, 0, Math.PI * 2) + context.fillStyle = color + context.fill() + context.lineWidth = selected ? 3 : 2 + context.strokeStyle = 'rgba(255, 255, 255, 0.94)' + context.stroke() + + context.shadowBlur = 0 + context.font = '700 10px system-ui, -apple-system, BlinkMacSystemFont, sans-serif' + context.textAlign = 'center' + context.textBaseline = 'middle' + context.fillStyle = '#ffffff' + context.fillText(markerGlyph(label), item.x, item.y + 0.5) + context.restore() + } + }, [canvasPoints, enabled, labelById, selectedPointId, size.height, size.width]) + + const resolvePointer = useCallback( + (event: CanvasPointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + const hit = findHit(canvasPoints, x, y) + return { + hit, + position: hit + ? { + x: rect.left + hit.x, + y: rect.top + hit.y, + } + : null, + } + }, + [canvasPoints], + ) + + return ( + { + const { hit, position } = resolvePointer(event) + onPointHover(hit?.point ?? null, position) + }} + onPointerLeave={() => onPointHover(null, null)} + onClick={(event) => { + const { hit, position } = resolvePointer(event) + if (!hit || !position) return + event.preventDefault() + event.stopPropagation() + onPointClick(hit.point, position) + }} + /> + ) +} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index e5b39f1..8b0132b 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -67,6 +67,114 @@ declare global { onPlaybackCommand: (callback: (command: 'toggle_play' | 'seek_forward' | 'seek_backward') => void) => () => void onFocusInput: (callback: () => void) => () => void } + mapMaskOverlay?: { + show: () => Promise + hide: () => Promise + getBounds: () => Promise<{ + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: 'debug' | 'real-window' + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + clientRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string + overlay?: { x: number; y: number; width: number; height: number } | null + }> + syncToGameWindow: () => Promise<{ + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: 'debug' | 'real-window' + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + clientRect: { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number + } | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string + overlay?: { x: number; y: number; width: number; height: number } | null + }> + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => Promise + getDebugOptions: () => Promise<{ calibrationOverlayEnabled: boolean; viewportDebugEnabled: boolean }> + } conversation: { getState: () => Promise<{ messages: Array<{ diff --git a/src/renderer/pages/map-mask-page.tsx b/src/renderer/pages/map-mask-page.tsx new file mode 100644 index 0000000..8ccba7c --- /dev/null +++ b/src/renderer/pages/map-mask-page.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState } from 'react' +import { MapPinned, PlayCircle, RefreshCw, Square } from 'lucide-react' +import { toast } from 'sonner' + +import { ScrollCenterLayout } from 'renderer/components/scroll-center-layout' +import { SettingsPageLayout } from 'renderer/components/settings-page-layout' +import { Button } from 'renderer/components/ui/button' +import type { + GameWindowBounds, + MapMaskLabelsResponse, + MapMaskState, +} from 'renderer/types/map-mask' + +export function MapMaskPage() { + const [state, setState] = useState(null) + const [labelCount, setLabelCount] = useState(0) + const [bounds, setBounds] = useState(null) + const [loading, setLoading] = useState(false) + + const refresh = useCallback(async () => { + setLoading(true) + try { + const [nextState, labels, nextBounds] = await Promise.all([ + window.App.rpc.request('map_mask.get_state') as Promise, + window.App.rpc.request('map_mask.get_labels') as Promise, + window.App.mapMaskOverlay?.getBounds(), + ]) + setState(nextState) + setLabelCount(labels.labels.length) + setBounds(nextBounds ?? null) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Map mask RPC failed') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const handleOpen = async () => { + try { + await window.App.mapMaskOverlay?.show() + await refresh() + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Open map mask failed') + } + } + + const handleClose = async () => { + await window.App.mapMaskOverlay?.hide() + } + + return ( + + + + + + } + > +
+
+
+
+ + Backend state +
+ +
+
+ + + + + + + + + + + + + + +
+
+ +
+
+ + Window tracker +
+
+ + + +
+
+
+
+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/src/renderer/routes.tsx b/src/renderer/routes.tsx index ed88fe6..60e4024 100644 --- a/src/renderer/routes.tsx +++ b/src/renderer/routes.tsx @@ -3,6 +3,7 @@ import { Route } from 'react-router-dom' import { Router } from 'lib/electron-router-dom' import { MainScreen } from './screens/main' +import { MapMaskOverlayScreen } from './screens/map-mask-overlay' import { OverlayScreen } from './screens/overlay' import { StartingScreen } from './screens/starting' import { VideoOverlayScreen } from './screens/video-overlay' @@ -14,6 +15,7 @@ export function AppRoutes() { main: } path="/" />, overlay: } path="/" />, 'video-overlay': } path="/" />, + 'map-mask-overlay': } path="/" />, splash: } path="/" />, }} /> diff --git a/src/renderer/screens/main.tsx b/src/renderer/screens/main.tsx index cd56902..63c67cb 100644 --- a/src/renderer/screens/main.tsx +++ b/src/renderer/screens/main.tsx @@ -47,6 +47,7 @@ import { AutoMacroPage } from '../pages/auto-macro-page' import { AutoMusicPage } from '../pages/auto-music-page' import { ScriptSubscribePage } from '../pages/script-subscribe-page' import { VideoOverlayPage } from '../pages/video-overlay-page' +import { MapMaskPage } from '../pages/map-mask-page' import { IpcRpcClient } from 'renderer/lib/ipc-rpc' import { apiClient } from 'renderer/lib/api-client' import { type ConversationSendPayload, useHomeConversation } from 'renderer/hooks/use-home-conversation' @@ -99,6 +100,7 @@ const navItems: NavItem[] = [ ], }, { id: 'script-subscribe', label: '订阅脚本', icon: Rss }, + { id: 'map-mask', label: 'Map Mask', icon: Map }, { id: 'video-overlay', label: '视频小窗', icon: Tv }, ] @@ -494,6 +496,8 @@ export function MainScreen() { return case 'video-overlay': return + case 'map-mask': + return case 'auto-navigate': return case 'auto-macro': @@ -559,6 +563,14 @@ export function MainScreen() {
+ + + + + + + + + + +
+ + +
+ {labels.map((label) => ( + + ))} +
+ +
+ {([ + ['auto', 'Auto Detect'], + ['force-open', 'Force Open'], + ['force-closed', 'Force Closed'], + ] as const).map(([mode, label]) => ( + + ))} +
+ +
+

+ bigmap: {visibleResult.state.is_bigmap_open ? 'open' : 'closed'} / rendered:{' '} + {renderedPoints.length} +

+

+ data: {visibleResult.state.data_source} / labels:{' '} + {visibleResult.state.labels_source} / points: {visibleResult.state.points_source} +

+

+ local points: draft {localPointDrafts.length} / shown {localRenderedPoints.length} + {localPointsVisible ? '' : ' / hidden'} +

+

+ placement: {placementMode ? 'active' : 'off'} / pointer:{' '} + {mapPointerInside ? 'map' : 'outside'} +

+

+ selected draft: {selectedDraftPoint?.id ?? 'none'} +

+

+ last map hover:{' '} + {lastMapHoverCoordinate + ? `${lastMapHoverCoordinate.screen_x},${lastMapHoverCoordinate.screen_y}` + : 'none'} +

+

+ hover image:{' '} + {lastMapHoverCoordinate + ? `${lastMapHoverCoordinate.image_x.toFixed(1)},${lastMapHoverCoordinate.image_y.toFixed(1)}` + : 'n/a'} +

+

+ points path: {visibleResult.state.local_points_path || 'sample fallback'} +

+

+ raw/stable:{' '} + {visibleResult.state.raw_is_bigmap_open ? 'open' : 'closed'} /{' '} + {visibleResult.state.stable_is_bigmap_open ? 'open' : 'closed'} +

+

+ stable frames: {visibleResult.state.consecutive_open_count}/ + {visibleResult.state.stable_open_frames} open,{' '} + {visibleResult.state.consecutive_closed_count}/ + {visibleResult.state.stable_closed_frames} closed +

+

source: {bounds?.source ?? 'pending'} / {bounds?.appliedBoundsSource ?? 'pending'}

+

+ tracker: {bounds?.trackerMode ?? 'pending'} + {bounds?.isGameWindowFound ? ' / found' : ' / fallback'} + {bounds?.isMinimized ? ' / minimized' : ''} +

+

+ matchedBy: {bounds?.matchedBy ?? 'pending'} +

+

+ process: {bounds?.processName ?? 'n/a'} / pid: {bounds?.pid ?? 'n/a'} +

+

+ hwnd: {bounds?.foundWindowHandle ?? 'n/a'} +

+

+ windowTitle: {bounds?.foundWindowTitle ?? 'not found'} +

+

window rect: {formatRect(bounds?.windowRect ?? null)}

+

client rect: {formatRect(bounds?.clientRect ?? null)}

+

overlay: {formatOverlayRect(bounds)}

+

dpi: {formatScale(bounds?.dpiScale)} / scale: {formatScale(bounds?.scaleFactor)}

+

applied: {describeBounds(bounds)}

+

+ updated: {formatUpdateTime(bounds?.lastUpdateTime)} +

+

+ tracker poll: {bounds?.trackerIntervalMs ?? 'n/a'}ms / duration:{' '} + {bounds ? bounds.lastUpdateDurationMs.toFixed(1) : 'n/a'}ms / changed:{' '} + {bounds ? String(bounds.lastBoundsChanged) : 'n/a'} +

+

+ viewport:{' '} + {visibleResult.state.has_valid_viewport + ? activeViewport?.map_name ?? 'valid' + : 'fallback unavailable'} + {' / '} + {visibleResult.state.viewport_source} +

+

+ viewport mode: {visibleResult.state.viewport_mode} + {visibleResult.state.viewport_fallback_used ? ' / fallback' : ''} +

+

+ viewport center:{' '} + {visibleResult.state.viewport_center_x !== null && visibleResult.state.viewport_center_y !== null + ? `${visibleResult.state.viewport_center_x.toFixed(1)},${visibleResult.state.viewport_center_y.toFixed(1)}` + : 'n/a'} +

+

+ raw center:{' '} + {visibleResult.state.raw_center_x !== null && visibleResult.state.raw_center_y !== null + ? `${visibleResult.state.raw_center_x.toFixed(1)},${visibleResult.state.raw_center_y.toFixed(1)}` + : 'n/a'} +

+

+ accepted center:{' '} + {visibleResult.state.accepted_center_x !== null && + visibleResult.state.accepted_center_y !== null + ? `${visibleResult.state.accepted_center_x.toFixed(1)},${visibleResult.state.accepted_center_y.toFixed(1)}` + : 'n/a'} +

+

+ corrected center:{' '} + {formatCoord(visibleResult.state.corrected_center_x)}, + {formatCoord(visibleResult.state.corrected_center_y)} +

+

+ center correction: {visibleResult.state.center_correction_source || 'disabled'} + {visibleResult.state.center_correction_enabled ? ' / enabled' : ' / disabled'} + {visibleResult.state.center_correction_enabled ? ' / experimental' : ''} +

+

+ map scale: {formatScale(visibleResult.state.map_scale)} / source:{' '} + {visibleResult.state.map_scale_source || 'n/a'} / span:{' '} + {visibleResult.state.viewport_span_source} +

+ {visibleResult.state.assumes_max_bigmap_zoom ? ( +

+ Assumes max bigmap zoom / map_scale={formatScale(visibleResult.state.map_scale)} +

+ ) : null} +

+ pending center:{' '} + {visibleResult.state.pending_center_x !== null && + visibleResult.state.pending_center_y !== null + ? `${visibleResult.state.pending_center_x.toFixed(1)},${visibleResult.state.pending_center_y.toFixed(1)}` + : 'n/a'} + {' / '} + {visibleResult.state.pending_confirm_count} +

+

+ center jump:{' '} + {visibleResult.state.center_jump_distance !== null + ? visibleResult.state.center_jump_distance.toFixed(1) + : 'n/a'} + {' / age: '} + {visibleResult.state.last_good_center_age_ms !== null + ? `${visibleResult.state.last_good_center_age_ms.toFixed(0)}ms` + : 'n/a'} +

+ {visibleResult.state.center_accept_reason ? ( +

+ center accepted: {visibleResult.state.center_accept_reason} +

+ ) : null} + {visibleResult.state.center_rejected_reason ? ( +

+ center rejected: {visibleResult.state.center_rejected_reason} +

+ ) : null} +

+ smoothing: {visibleResult.state.smoothing_mode} + {' / '} + {visibleResult.state.smoothing_applied ? 'applied' : 'not applied'} + {visibleResult.state.smoothing_distance !== null + ? ` / ${visibleResult.state.smoothing_distance.toFixed(1)}` + : ''} +

+ {visibleResult.state.snap_reason ? ( +

+ center snap: {visibleResult.state.snap_reason} +

+ ) : null} +

+ tracking: {visibleResult.state.tracking_mode} + {' / reacquire: '} + {visibleResult.state.reacquire_pending_count} +

+

+ tracking center: {formatCoord(visibleResult.state.tracking_center_x)}, + {formatCoord(visibleResult.state.tracking_center_y)} +

+

+ global check: {formatCoord(visibleResult.state.global_check_center_x)}, + {formatCoord(visibleResult.state.global_check_center_y)} / delta:{' '} + {formatCoord(visibleResult.state.global_check_delta)} / confidence:{' '} + {formatConfidence(visibleResult.state.global_check_confidence)} +

+

+ tracking suspect: {visibleResult.state.tracking_suspect ? 'yes' : 'no'} / reset:{' '} + {visibleResult.state.tracking_reset_reason || 'none'} +

+

+ global checked: {formatUpdateTime(visibleResult.state.last_global_check_time)} +

+

+ motion:{' '} + {visibleResult.state.motion_diff !== null + ? visibleResult.state.motion_diff.toFixed(2) + : 'n/a'} + {visibleResult.state.motion_unstable ? ' / unstable' : ' / stable'} + {' / '} + {visibleResult.state.motion_stable_count} +

+

+ candidate distance:{' '} + {visibleResult.state.candidate_distance_to_last_good !== null + ? visibleResult.state.candidate_distance_to_last_good.toFixed(1) + : 'n/a'} +

+

+ match: {visibleResult.state.selected_match_source} + {' / local: '} + {formatConfidence(visibleResult.state.local_match_confidence)} + {' / global: '} + {formatConfidence(visibleResult.state.global_match_confidence)} +

+

+ viewport confidence: {formatConfidence(visibleResult.state.viewport_detection_confidence)} + {visibleResult.state.viewport_stale ? ' / stale' : ''} +

+

+ viewport updated: {formatUpdateTime(visibleResult.state.last_viewport_update_time)} +

+ {visibleResult.state.viewport_fallback_reason ? ( +

+ viewport fallback: {visibleResult.state.viewport_fallback_reason} +

+ ) : null} +

+ calibration path: {visibleResult.state.viewport_calibration_path || 'not loaded'} +

+

+ calibration fallback: {visibleResult.state.viewport_fallback_used ? 'yes' : 'no'} +

+

map area (overlay): {formatViewportRect(overlayViewport)}

+

map image: {formatImageRect(overlayViewport)}

+

+ nearest point: {visibleResult.state.nearest_loaded_point_name || 'none'} / image{' '} + {formatCoord(visibleResult.state.nearest_loaded_point_image_x)}, + {formatCoord(visibleResult.state.nearest_loaded_point_image_y)} +

+

+ nearest delta image:{' '} + {formatCoord(visibleResult.state.nearest_loaded_point_delta_image_x)}, + {formatCoord(visibleResult.state.nearest_loaded_point_delta_image_y)} / screen{' '} + {formatCoord(visibleResult.state.nearest_loaded_point_delta_screen_x)}, + {formatCoord(visibleResult.state.nearest_loaded_point_delta_screen_y)} +

+

+ zoom: {formatScale(overlayViewport?.scale)} / map: {overlayViewport?.map_name ?? 'n/a'} +

+

+ backend points: {samplePointsVisible ? 'shown' : 'hidden'} / backend rendered:{' '} + {backendRenderedPoints.length} +

+

+ mouse screen:{' '} + {mouseCalibrationInfo + ? `${mouseCalibrationInfo.screenX},${mouseCalibrationInfo.screenY}` + : 'n/a'} +

+

+ mouse area:{' '} + {mouseCalibrationInfo + ? `${mouseCalibrationInfo.areaX.toFixed(1)},${mouseCalibrationInfo.areaY.toFixed(1)}` + : 'n/a'} + {mouseCalibrationInfo?.insideArea ? ' / inside' : mouseCalibrationInfo ? ' / outside' : ''} +

+

+ mouse image:{' '} + {mouseCalibrationInfo + ? `${mouseCalibrationInfo.imageX.toFixed(1)},${mouseCalibrationInfo.imageY.toFixed(1)}` + : 'n/a'} +

+ {copyMessage ?

{copyMessage}

: null} + {visibleResult.state.viewport_calibration_error ? ( +

+ viewport error: {visibleResult.state.viewport_calibration_error} +

+ ) : null} + {visibleResult.state.viewport_detection_error ? ( +

+ viewport detect: {visibleResult.state.viewport_detection_error} +

+ ) : null} + {visibleResult.state.local_points_error ? ( +

+ points error: {visibleResult.state.local_points_error} +

+ ) : null} +

+ detection: {visibleResult.state.detection_source} /{' '} + {formatConfidence(visibleResult.state.detection_confidence)} +

+

+ detect time: {formatMs(visibleResult.state.detection_duration_ms)} / interval:{' '} + {visibleResult.state.detection_interval_ms}ms +

+

+ detected: {formatUpdateTime(visibleResult.state.last_detection_time)} +

+

+ last ok: {formatUpdateTime(visibleResult.state.last_successful_detection_time)} +

+ {visibleResult.state.detection_error ? ( +

detect error: {visibleResult.state.detection_error}

+ ) : null} + {error ?

{error}

: null} +
+ + + {calibrationPanelVisible ? ( + void handleCopyCalibrationJson()} + onCopyLandmarkCenter={() => void handleCopyLandmarkCenter()} + onCopyMouse={() => void handleCopyMouseCoordinates()} + onCopyLocalPoints={() => void handleCopyLocalPointsJson()} + onAddLocalPoint={handleAddLocalPoint} + onTogglePlacementMode={handleTogglePlacementMode} + onContinuousPlacementChange={setContinuousPlacement} + onUpdateLocalPoint={handleUpdateLocalPoint} + onDeleteLocalPoint={handleDeleteLocalPoint} + onDeleteSelectedDraftPoint={handleDeleteSelectedDraftPoint} + onClearLocalPoints={handleClearLocalPoints} + onToggleLocalPoints={() => setLocalPointsVisible((value) => !value)} + onLocalPointLabelChange={setLocalPointLabelId} + onChange={handleCalibrationFieldChange} + onReset={() => setCalibrationDraft(null)} + onClose={() => setCalibrationPanelVisible(false)} + /> + ) : null} + + {calibrationVisible ? : null} + {viewportDebugVisible ? ( + + ) : null} + + {placementMode ? ( + + ) : null} + + {hoverPoint && hoverPosition ? ( +
+

{hoverPoint.point.name}

+

{hoverPoint.point.label_id}

+
+ ) : null} + + {detailPoint && detailPosition ? ( +
+
+
+

{detailPoint.name}

+

+ {detailPoint.label_id} / {detailPoint.provider} +

+
+ +
+

+ {typeof detailPoint.detail?.description === 'string' + ? detailPoint.detail.description + : 'No description'} +

+
+ image x: {formatCoord(detailPoint.image_x)} + image y: {formatCoord(detailPoint.image_y)} + {hasScreenCoords(detailPoint) ? ( + <> + screen x: {formatCoord(detailPoint.screen_x)} + screen y: {formatCoord(detailPoint.screen_y)} + + ) : null} +
+
+ ) : null} + + ) +} + +function isInside(element: HTMLElement | null, x: number, y: number) { + if (!element) return false + const rect = element.getBoundingClientRect() + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom +} + +function isEditableKeyboardTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false + const tagName = target.tagName.toLowerCase() + return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select' +} + +const calibrationFields: Array<{ + field: CalibrationField + label: string + step: number +}> = [ + { field: 'screen_left', label: 'area left', step: 1 }, + { field: 'screen_top', label: 'area top', step: 1 }, + { field: 'screen_width', label: 'area width', step: 1 }, + { field: 'screen_height', label: 'area height', step: 1 }, + { field: 'image_left', label: 'image left', step: 0.1 }, + { field: 'image_top', label: 'image top', step: 0.1 }, + { field: 'image_width', label: 'image width', step: 0.1 }, + { field: 'image_height', label: 'image height', step: 0.1 }, + { field: 'scale', label: 'zoom', step: 0.01 }, +] + +function CalibrationAdjustmentPanel({ + panelRef, + viewport, + calibrationJson, + mouseInfo, + lastMapHoverCoordinate, + labels, + localPointDrafts, + localPointsVisible, + localPointLabelId, + selectedDraftPoint, + placementMode, + continuousPlacement, + mapPointerInside, + landmarkCenter, + landmarkCenterDisabledReason, + copyMessage, + onCopyCalibration, + onCopyLandmarkCenter, + onCopyMouse, + onCopyLocalPoints, + onAddLocalPoint, + onTogglePlacementMode, + onContinuousPlacementChange, + onUpdateLocalPoint, + onDeleteLocalPoint, + onDeleteSelectedDraftPoint, + onClearLocalPoints, + onToggleLocalPoints, + onLocalPointLabelChange, + onChange, + onReset, + onClose, +}: { + panelRef: RefObject + viewport: MapMaskViewport | null + calibrationJson: CalibrationJson | null + mouseInfo: MouseCalibrationInfo | null + lastMapHoverCoordinate: LastMapHoverCoordinate | null + labels: MapMaskLabel[] + localPointDrafts: MapMaskPoint[] + localPointsVisible: boolean + localPointLabelId: string + selectedDraftPoint: MapMaskPoint | null + placementMode: boolean + continuousPlacement: boolean + mapPointerInside: boolean + landmarkCenter: LandmarkCenterJson | null + landmarkCenterDisabledReason: string + copyMessage: string + onCopyCalibration: () => void + onCopyLandmarkCenter: () => void + onCopyMouse: () => void + onCopyLocalPoints: () => void + onAddLocalPoint: () => void + onTogglePlacementMode: () => void + onContinuousPlacementChange: (value: boolean) => void + onUpdateLocalPoint: (pointId: string, patch: Partial) => void + onDeleteLocalPoint: (pointId: string) => void + onDeleteSelectedDraftPoint: () => void + onClearLocalPoints: () => void + onToggleLocalPoints: () => void + onLocalPointLabelChange: (labelId: string) => void + onChange: (field: CalibrationField, value: string) => void + onReset: () => void + onClose: () => void +}) { + return ( +
+
+
+

Viewport Calibration

+

+ {calibrationJson ? `${calibrationJson.map_name} / ${calibrationJson.screen_width}x${calibrationJson.screen_height}` : 'viewport unavailable'} +

+
+ +
+ +
+ {calibrationFields.map(({ field, label, step }) => ( + + ))} +
+ +
+

map area: {calibrationJson ? `${calibrationJson.map_area_width}x${calibrationJson.map_area_height} at ${calibrationJson.map_area_left},${calibrationJson.map_area_top}` : 'n/a'}

+

map image: {calibrationJson ? `${calibrationJson.map_image_width}x${calibrationJson.map_image_height} at ${calibrationJson.map_image_left},${calibrationJson.map_image_top}` : 'n/a'}

+

+ mouse screen: {mouseInfo ? `${mouseInfo.screenX},${mouseInfo.screenY}` : 'n/a'} +

+

+ mouse area: {mouseInfo ? `${mouseInfo.areaX.toFixed(1)},${mouseInfo.areaY.toFixed(1)}` : 'n/a'} +

+

+ mouse image: {mouseInfo ? `${mouseInfo.imageX.toFixed(1)},${mouseInfo.imageY.toFixed(1)}` : 'n/a'} +

+ {copyMessage ?

{copyMessage}

: null} +
+ +
+
+
+

Landmark Center

+

+ {landmarkCenter + ? `accepted ${landmarkCenter.accepted_center_x.toFixed(1)},${landmarkCenter.accepted_center_y.toFixed(1)} / nearest ${landmarkCenter.nearest_loaded_point.name} ${landmarkCenter.expected_png_x.toFixed(1)},${landmarkCenter.expected_png_y.toFixed(1)} / confidence ${landmarkCenter.confidence.toFixed(2)}` + : landmarkCenterDisabledReason} +

+
+ +
+
+ +
+
+
+

Local Points

+

+ {localPointDrafts.length} draft points / {localPointsVisible ? 'shown' : 'hidden'} +

+
+
+ + +
+
+ +
+

+ placement: {placementMode ? (mapPointerInside ? '点击地图添加点位,Esc 取消' : '请把鼠标移到地图区域') : 'off'} +

+ +

+ last map screen:{' '} + {lastMapHoverCoordinate + ? `${lastMapHoverCoordinate.screen_x},${lastMapHoverCoordinate.screen_y}` + : '请先把鼠标移到地图区域'} +

+

+ last map area:{' '} + {lastMapHoverCoordinate + ? `${lastMapHoverCoordinate.area_x.toFixed(1)},${lastMapHoverCoordinate.area_y.toFixed(1)}` + : 'n/a'} +

+

+ last map image:{' '} + {lastMapHoverCoordinate + ? `${lastMapHoverCoordinate.image_x.toFixed(1)},${lastMapHoverCoordinate.image_y.toFixed(1)}` + : 'n/a'} +

+

+ viewport timestamp: {lastMapHoverCoordinate?.viewport_timestamp ?? 'n/a'} +

+

+ selected draft: {selectedDraftPoint?.name ?? 'none'} +

+
+ +
+ + +
+ +

+ Add Here 将添加到最后悬停的地图位置 + {lastMapHoverCoordinate + ? ` / image ${lastMapHoverCoordinate.image_x.toFixed(1)},${lastMapHoverCoordinate.image_y.toFixed(1)}` + : ' / 请先把鼠标移到地图区域'} +

+ +
+ + + +
+ +
+ {localPointDrafts.length === 0 ? ( +

+ Move over the map and add the current image coordinate. +

+ ) : ( + localPointDrafts.map((point) => ( +
+
+ onUpdateLocalPoint(point.id, { name: event.currentTarget.value })} + className="h-7 rounded-md border-white/12 bg-white/7 px-2 text-xs text-white" + /> + +
+ +

+ image {point.image_x.toFixed(1)},{point.image_y.toFixed(1)} / {point.map_name} +

+
+ )) + )} +
+
+ +
+ + + + +
+
+ ) +} + +function CalibrationOverlay() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} + +function PlacementModeOverlay({ + viewport, + coordinate, + continuous, +}: { + viewport: MapMaskViewport | null + coordinate: LastMapHoverCoordinate | null + continuous: boolean +}) { + const message = coordinate + ? `点击地图添加点位,Esc 取消${continuous ? ',连续添加开启' : ''}` + : '请把鼠标移到地图区域' + const labelLeft = viewport + ? clamp(viewport.screen_left + viewport.screen_width / 2 - 150, 12, window.innerWidth - 312) + : 12 + const labelTop = viewport ? clamp(viewport.screen_top + 16, 12, window.innerHeight - 60) : 12 + + return ( +
+
+

{message}

+ {coordinate ? ( +

+ image {coordinate.image_x.toFixed(1)},{coordinate.image_y.toFixed(1)} / A or Enter 添加 +

+ ) : null} +
+ {coordinate ? ( +
+
+
+
+ ) : null} +
+ ) +} + +function ViewportCalibrationOverlay({ + viewport, + mousePosition, + mouseInfo, +}: { + viewport: MapMaskViewport | null + mousePosition: { x: number; y: number } | null + mouseInfo: MouseCalibrationInfo | null +}) { + if (!viewport) { + return ( +
+ viewport unavailable +
+ ) + } + + const style = { + left: viewport.screen_left, + top: viewport.screen_top, + width: viewport.screen_width, + height: viewport.screen_height, + } + const labelLeft = clamp(viewport.screen_left + 8, 8, window.innerWidth - 240) + const labelTop = clamp(viewport.screen_top + 8, 8, window.innerHeight - 80) + + return ( +
+
+
+
+
+
+
+
+
+
+
+

+ area {viewport.screen_width}x{viewport.screen_height} at {viewport.screen_left}, + {viewport.screen_top} +

+

+ image {viewport.image_left.toFixed(1)},{viewport.image_top.toFixed(1)} / zoom{' '} + {viewport.scale.toFixed(2)} +

+

+ mouse {mousePosition ? `${mousePosition.x},${mousePosition.y}` : 'n/a'} +

+

+ area {mouseInfo ? `${mouseInfo.areaX.toFixed(1)},${mouseInfo.areaY.toFixed(1)}` : 'n/a'} + {mouseInfo?.insideArea ? ' in' : mouseInfo ? ' out' : ''} +

+

+ image {mouseInfo ? `${mouseInfo.imageX.toFixed(1)},${mouseInfo.imageY.toFixed(1)}` : 'n/a'} +

+
+
+ ) +} diff --git a/src/renderer/types/map-mask.ts b/src/renderer/types/map-mask.ts new file mode 100644 index 0000000..fad044e --- /dev/null +++ b/src/renderer/types/map-mask.ts @@ -0,0 +1,208 @@ +export type MapMaskLabel = { + id: string + name: string + parent_id: string | null + icon: string | null + provider: string + default_enabled: boolean +} + +export type MapMaskPointDetail = { + description?: string + images?: string[] + [key: string]: unknown +} + +export type MapMaskPoint = { + id: string + label_id: string + name: string + map_name: string + image_x: number + image_y: number + game_x: number | null + game_y: number | null + icon: string | null + provider: string + detail: MapMaskPointDetail +} + +export type MapMaskViewport = { + map_name: string + image_left: number + image_top: number + image_width: number + image_height: number + screen_left: number + screen_top: number + screen_width: number + screen_height: number + scale: number + rotation: number +} + +export type MapMaskState = { + enabled: boolean + is_map_open?: boolean + is_bigmap_open: boolean + provider: string + fallback_provider: string + data_source: 'sample' | 'local' | 'fallback' | string + labels_source: 'sample' | 'local' | 'fallback' | string + points_source: 'sample' | 'local' | 'fallback' | string + local_labels_path: string + local_points_path: string + local_labels_error: string + local_points_error: string + selected_label_ids: string[] + has_valid_viewport: boolean + viewport: MapMaskViewport | null + viewport_mode: MapMaskViewportMode + viewport_source: string + viewport_fallback_used: boolean + viewport_fallback_reason: string + viewport_detection_confidence: number + viewport_detection_error: string + viewport_center_x: number | null + viewport_center_y: number | null + raw_center_x: number | null + raw_center_y: number | null + accepted_center_x: number | null + accepted_center_y: number | null + corrected_center_x: number | null + corrected_center_y: number | null + center_correction_enabled: boolean + center_correction_scale_x: number + center_correction_scale_y: number + center_correction_offset_x: number + center_correction_offset_y: number + center_correction_source: string + nearest_loaded_point_id: string + nearest_loaded_point_name: string + nearest_loaded_point_image_x: number | null + nearest_loaded_point_image_y: number | null + nearest_loaded_point_distance: number | null + nearest_loaded_point_delta_image_x: number | null + nearest_loaded_point_delta_image_y: number | null + nearest_loaded_point_delta_screen_x: number | null + nearest_loaded_point_delta_screen_y: number | null + pending_center_x: number | null + pending_center_y: number | null + center_jump_distance: number | null + center_accept_reason: string + center_rejected_reason: string + pending_confirm_count: number + last_good_center_age_ms: number | null + smoothing_mode: 'off' | 'jitter-only' | 'all' | string + smoothing_applied: boolean + smoothing_distance: number | null + snap_reason: string + tracking_mode: 'idle' | 'tracking' | 'reacquire' | string + motion_diff: number | null + motion_unstable: boolean + motion_stable_count: number + candidate_distance_to_last_good: number | null + local_match_confidence: number | null + global_match_confidence: number | null + selected_match_source: string + reacquire_pending_count: number + tracking_center_x: number | null + tracking_center_y: number | null + global_check_center_x: number | null + global_check_center_y: number | null + global_check_delta: number | null + global_check_confidence: number | null + tracking_suspect: boolean + tracking_reset_reason: string + last_global_check_time: string + last_viewport_update_time: string + viewport_stale: boolean + viewport_calibration_path: string + viewport_calibration_error: string + viewport_screen_width: number | null + viewport_screen_height: number | null + map_scale: number | null + map_scale_source: string + viewport_span_source: string + assumes_max_bigmap_zoom: boolean + detection_mode: MapMaskBigMapDetectionMode + detection_source: string + detection_confidence: number + raw_is_bigmap_open: boolean + stable_is_bigmap_open: boolean + consecutive_open_count: number + consecutive_closed_count: number + detection_error: string + last_detection_time: string + last_successful_detection_time: string + detection_duration_ms: number + detection_interval_ms: number + stable_open_frames: number + stable_closed_frames: number + debug?: boolean + message?: string +} + +export type MapMaskBigMapDetectionMode = 'auto' | 'force-open' | 'force-closed' +export type MapMaskViewportMode = 'sample' | 'manual-calibration' | 'auto-placeholder' | 'hybrid-auto-center' + +export type VisibleMapMaskPoint = MapMaskPoint & { + screen_x: number + screen_y: number + is_visible: boolean +} + +export type MapMaskLabelsResponse = { + labels: MapMaskLabel[] + selected_label_ids: string[] +} + +export type MapMaskVisiblePointsResponse = { + points: VisibleMapMaskPoint[] + state: MapMaskState +} + +export type GameWindowBounds = { + x: number + y: number + width: number + height: number + source: 'debug-fixed' | 'game-window' + appliedBoundsSource: 'debug-fixed' | 'client-area' | 'window-rect' + trackerMode: 'debug' | 'real-window' + isGameWindowFound: boolean + isMinimized: boolean + clientAreaAvailable: boolean + clientX: number | null + clientY: number | null + clientWidth: number | null + clientHeight: number | null + windowRect: GameWindowRect | null + clientRect: GameWindowRect | null + dpiScale: number | null + scaleFactor: number | null + matchedBy: 'title' | 'process' | 'fallback' + processName: string | null + pid: number | null + foundWindowTitle: string | null + foundWindowHandle: string | null + titleKeywords: string[] + processNames: string[] + lastUpdateTime: string + trackerIntervalMs: number + lastUpdateDurationMs: number + lastBoundsChanged: boolean + message?: string + overlay?: { x: number; y: number; width: number; height: number } | null +} + +export type GameWindowRect = { + left: number + top: number + right: number + bottom: number + x: number + y: number + width: number + height: number +}