From 2f445be837687544e1ca80658e6c3e65eb66bb09 Mon Sep 17 00:00:00 2001
From: uye <99072975+ABA2396@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:40:32 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8E=AB=E5=A5=88=E5=8F=96=E8=89=B2=20?=
=?UTF-8?q?(#17242)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: 移动关卡配置字段到 FightTask
UseAlternateStage / HideUnavailableStage / CustomStageCode 已迁移至
FightTask.cs,从 GUI 全局配置中移除冗余定义
* feat: 莫奈取色 — 配置与本地化
新增 BackgroundMonetEnabled / BackgroundMonetMode / BackgroundMonetCustomColor
配置字段,以及 MonetModeType 枚举(Auto / Custom)。
添加 zh-cn / zh-tw / en-us / ja-jp / ko-kr 五语本地化字符串
* feat: 莫奈取色 — 调色板生成与应用逻辑
- MonetPaletteHelper: 基于 HSL 线性计算生成主题色板,主色 / 文字 /
背景角色各自有独立的饱和度与明度策略;明度随背景图不透明度连续
自适应,保证前景与有效背景之间始终有足够对比度
- ColorExtractorHelper: 从背景图 BitmapSource 提取主色
- ThemeHelper: ApplyMonetPalette / RevertMonetPalette / ReapplyMonet,
计算放后台线程、资源写入在 UI 线程;修正 AccentColor 覆盖顺序、
主题切换时清除遮蔽资源以恢复正确主题色
* feat: 莫奈取色 — 设置界面与 ViewModel
- BackgroundSettings.xaml: 新增莫奈取色开关、取色模式选择、自定义颜色
色块预览与 ColorPicker 按钮
- BackgroundSettingsUserControlModel: UpdateMonet / ScheduleMonetUpdate
防抖逻辑,不透明度滑块拖动时后台计算 + UI 线程写入
- SettingsViewModel: 主题初始化完成后恢复莫奈调色板
* chore: review
* chore: 调整间距
---
src/MaaWpfGui/Configuration/Global/GUI.cs | 29 +-
src/MaaWpfGui/Helper/ColorExtractorHelper.cs | 192 +++++++++++++
src/MaaWpfGui/Helper/MonetPaletteHelper.cs | 266 ++++++++++++++++++
src/MaaWpfGui/Helper/ThemeHelper.cs | 186 ++++++++++++
src/MaaWpfGui/Res/Localizations/en-us.xaml | 6 +
src/MaaWpfGui/Res/Localizations/ja-jp.xaml | 6 +
src/MaaWpfGui/Res/Localizations/ko-kr.xaml | 6 +
src/MaaWpfGui/Res/Localizations/zh-cn.xaml | 6 +
src/MaaWpfGui/Res/Localizations/zh-tw.xaml | 6 +
.../ViewModels/UI/SettingsViewModel.cs | 3 +
.../BackgroundSettingsUserControlModel.cs | 255 ++++++++++++++++-
.../Settings/BackgroundSettings.xaml | 72 ++++-
12 files changed, 1012 insertions(+), 21 deletions(-)
create mode 100644 src/MaaWpfGui/Helper/ColorExtractorHelper.cs
create mode 100644 src/MaaWpfGui/Helper/MonetPaletteHelper.cs
diff --git a/src/MaaWpfGui/Configuration/Global/GUI.cs b/src/MaaWpfGui/Configuration/Global/GUI.cs
index a759cec610..05b876ea1c 100644
--- a/src/MaaWpfGui/Configuration/Global/GUI.cs
+++ b/src/MaaWpfGui/Configuration/Global/GUI.cs
@@ -43,12 +43,6 @@ public class GUI : INotifyPropertyChanged
public bool SaveWindowPlacement { get; set; } = true;
- public bool UseAlternateStage { get; set; } = false;
-
- public bool HideUnavailableStage { get; set; } = true;
-
- public bool CustomStageCode { get; set; } = false;
-
public InverseClearType InverseClearMode { get; set; } = InverseClearType.Clear;
public string WindowTitlePrefix { get; set; } = string.Empty;
@@ -85,6 +79,13 @@ public class GUI : INotifyPropertyChanged
public bool ExpanderAboutUs { get; set; } = true;
+ // ===== 背景设置(莫奈取色) =====
+ public bool BackgroundMonetEnabled { get; set; } = false;
+
+ public MonetModeType BackgroundMonetMode { get; set; } = MonetModeType.Auto;
+
+ public string BackgroundMonetCustomColor { get; set; } = "#326CF3";
+
[UsedImplicitly]
public void OnPropertyChanged(string propertyName, object before, object after)
{
@@ -129,4 +130,20 @@ public class GUI : INotifyPropertyChanged
///
ClearInverse,
}
+
+ ///
+ /// 莫奈取色的模式。
+ ///
+ public enum MonetModeType
+ {
+ ///
+ /// 从背景图自动提取主色。
+ ///
+ Auto = 0,
+
+ ///
+ /// 用户手动选择颜色。
+ ///
+ Custom,
+ }
}
diff --git a/src/MaaWpfGui/Helper/ColorExtractorHelper.cs b/src/MaaWpfGui/Helper/ColorExtractorHelper.cs
new file mode 100644
index 0000000000..e36e0965b3
--- /dev/null
+++ b/src/MaaWpfGui/Helper/ColorExtractorHelper.cs
@@ -0,0 +1,192 @@
+//
+// Part of the MaaWpfGui project, maintained by the MaaAssistantArknights team (Maa Team)
+// Copyright (C) 2021-2025 MaaAssistantArknights Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License v3.0 only as published by
+// the Free Software Foundation, either version 3 of the License, or
+// any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace MaaWpfGui.Helper;
+
+///
+/// 从图片中提取主色(dominant color)的工具类。
+/// 采用降采样 + 颜色量化桶统计的方式,性能优于完整的 k-means。
+///
+public static class ColorExtractorHelper
+{
+ ///
+ /// 量化桶步长,将 RGB 空间按每 为一格分桶。
+ ///
+ private const int BucketSize = 16;
+
+ ///
+ /// 降采样后的最大边长(像素),用于加速取色。
+ ///
+ private const int SampleSize = 32;
+
+ ///
+ /// 从 BitmapImage 中提取出现频率最高的颜色。
+ ///
+ /// 源图片。必须已调用过EndInit并完成解码。
+ /// 提取到的主色;若图片无效则返回白色。
+ public static Color ExtractDominantColor(BitmapSource image)
+ {
+ if (image == null)
+ {
+ return Colors.White;
+ }
+
+ // 降采样到 ~32×32 以加速
+ var scaledWidth = Math.Min(SampleSize, image.PixelWidth);
+ var scaledHeight = Math.Min(SampleSize, image.PixelHeight);
+
+ var converted = new FormatConvertedBitmap(image, PixelFormats.Bgra32, null, 0);
+ var resized = new TransformedBitmap(converted, new ScaleTransform(
+ (double)scaledWidth / image.PixelWidth,
+ (double)scaledHeight / image.PixelHeight));
+
+ var pixels = new byte[scaledWidth * scaledHeight * 4];
+ resized.CopyPixels(pixels, scaledWidth * 4, 0);
+
+ // 量化到桶,统计频率
+ var buckets = new Dictionary();
+
+ for (var i = 0; i < pixels.Length; i += 4)
+ {
+ var a = pixels[i + 3];
+ if (a < 128)
+ {
+ // 跳过近透明像素
+ continue;
+ }
+
+ var r = pixels[i + 2]; // BGRA
+ var g = pixels[i + 1];
+ var b = pixels[i];
+
+ var bucketR = r / BucketSize;
+ var bucketG = g / BucketSize;
+ var bucketB = b / BucketSize;
+ var key = (((bucketR * 256L) + bucketG) * 256L) + bucketB;
+
+ if (buckets.TryGetValue(key, out var entry))
+ {
+ buckets[key] = (entry.Count + 1, entry.R + r, entry.G + g, entry.B + b);
+ }
+ else
+ {
+ buckets[key] = (1, r, g, b);
+ }
+ }
+
+ if (buckets.Count == 0)
+ {
+ return Colors.White;
+ }
+
+ // 对每个桶计算「频率 × 鲜艳度」的综合得分,优先选高占比且鲜艳的颜色
+ Color bestColor = Colors.White;
+ var bestScore = -1.0;
+
+ foreach (var (_, value) in buckets)
+ {
+ long count = value.Count;
+ var avgR = (byte)(value.R / count);
+ var avgG = (byte)(value.G / count);
+ var avgB = (byte)(value.B / count);
+ var avgColor = Color.FromRgb(avgR, avgG, avgB);
+
+ var (_, sat, _) = RgbToHsl(avgColor);
+
+ // 频率占比 × 饱和度²,饱和度平方确保鲜艳颜色优先
+ var frequencyRatio = (double)count / (scaledWidth * scaledHeight);
+ var score = frequencyRatio * sat * sat;
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestColor = avgColor;
+ }
+ }
+
+ return bestColor;
+ }
+
+ ///
+ /// 异步版本的取色方法,将计算放到后台线程。
+ ///
+ /// 源图片。
+ /// 提取到的主色。
+ public static Task ExtractDominantColorAsync(BitmapSource image)
+ {
+ if (image == null)
+ {
+ return Task.FromResult(Colors.White);
+ }
+
+ // BitmapImage 必须 Freeze 后才能跨线程访问像素
+ if (!image.IsFrozen)
+ {
+ image.Freeze();
+ }
+
+ return Task.Run(() => ExtractDominantColor(image));
+ }
+
+ ///
+ /// RGB → HSL 转换。
+ ///
+ private static (double H, double S, double L) RgbToHsl(Color color)
+ {
+ var r = color.R / 255.0;
+ var g = color.G / 255.0;
+ var b = color.B / 255.0;
+
+ var max = Math.Max(r, Math.Max(g, b));
+ var min = Math.Min(r, Math.Min(g, b));
+ var delta = max - min;
+
+ var l = (max + min) / 2.0;
+
+ if (delta == 0)
+ {
+ return (0, 0, l);
+ }
+
+ var s = delta / (1 - Math.Abs((2 * l) - 1));
+
+ double h;
+ if (max == r)
+ {
+ h = ((g - b) / delta) % 6;
+ }
+ else if (max == g)
+ {
+ h = ((b - r) / delta) + 2;
+ }
+ else
+ {
+ h = ((r - g) / delta) + 4;
+ }
+
+ h *= 60;
+ if (h < 0)
+ {
+ h += 360;
+ }
+
+ return (h, s, l);
+ }
+}
diff --git a/src/MaaWpfGui/Helper/MonetPaletteHelper.cs b/src/MaaWpfGui/Helper/MonetPaletteHelper.cs
new file mode 100644
index 0000000000..0f5bae09b1
--- /dev/null
+++ b/src/MaaWpfGui/Helper/MonetPaletteHelper.cs
@@ -0,0 +1,266 @@
+//
+// Part of the MaaWpfGui project, maintained by the MaaAssistantArknights team (Maa Team)
+// Copyright (C) 2021-2025 MaaAssistantArknights Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License v3.0 only as published by
+// the Free Software Foundation, either version 3 of the License, or
+// any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY
+//
+
+using System;
+using System.Collections.Generic;
+using System.Windows.Media;
+
+namespace MaaWpfGui.Helper;
+
+///
+/// 根据提取到的主色,通过 HSL 线性计算生成 Material You 风格的主题色板。
+/// 所有颜色均由 (色相, 饱和度, 明度) 直接计算,无离散档位量化,保证连续平滑。
+/// 文字与背景之间保持足够的明度距离以保证可读性。
+///
+public static class MonetPaletteHelper
+{
+ ///
+ /// 需要生成的主题资源 key 列表。
+ ///
+ public static readonly string[] PaletteKeys =
+ [
+ "PrimaryBrush",
+ "DarkPrimaryBrush",
+ "LightPrimaryBrush",
+ "TitleBrush",
+ "RegionBrush",
+ "RegionBrushOpacity10",
+ "RegionBrushOpacity25",
+ "RegionBrushOpacity50",
+ "RegionBrushOpacity75",
+ "MouseOverRegionBrush",
+ "MouseOverRegionBrushOpacity10",
+ "MouseOverRegionBrushOpacity25",
+ "MouseOverRegionBrushOpacity50",
+ "MouseOverRegionBrushOpacity75",
+
+ // 亮度依赖的文字色:随明暗模式翻转,需跟随 region 明度适配
+ "PrimaryTextBrush",
+ "ThirdlyTextBrush",
+ "TraceLogBrush",
+ "MessageLogBrush",
+ ];
+
+ // 原 Dark.xaml / Light.xaml 的 Alpha 值,保持不变
+ private const byte Alpha10 = 0x19; // 10%
+ private const byte Alpha25 = 0x40; // 25%
+ private const byte Alpha50 = 0x7F; // 50%
+ private const byte Alpha75 = 0xBF; // 75%
+
+ // ── 各角色的饱和度常量 ──
+
+ ///
+ /// 主色(Primary)饱和度下限。原始主色饱和度不足此值时使用此值。
+ ///
+ private const double PrimaryMinSaturation = 0.55;
+
+ ///
+ /// 背景体系(Neutral Tint)饱和度。
+ ///
+ private const double BackgroundSaturation = 0.16;
+
+ ///
+ /// 文字色饱和度,极低饱和度接近中性色。
+ ///
+ private const double TextSaturation = 0.06;
+
+ // ── 对比度常量 ──
+
+ ///
+ /// 文字与有效背景之间要求的最小明度差。
+ /// 原版深色主题 PrimaryText(L=0.90) 与 Region(L=0.11) 差约 0.79。
+ ///
+ private const double TextContrastDistance = 0.70;
+
+ ///
+ /// 主色(PrimaryBrush)与有效背景之间要求的最小明度差。
+ /// 原版 Primary(L=0.57) 与 Region(L=0.11) 差约 0.46。
+ ///
+ private const double PrimaryContrastDistance = 0.45;
+
+ // ── 固定明度常量(不随透明度变化的角色) ──
+ private const double RegionDarkL = 0.10;
+ private const double RegionLightL = 0.90;
+ private const double MouseOverDarkL = 0.20;
+ private const double MouseOverLightL = 0.70;
+
+ ///
+ /// RegionBrushOpacity25 中底层(混合底)的占比(75%)。
+ /// Opacity25 覆盖层为 25% RegionBrush + 75% 混合底,
+ /// 此常量即混合底在 effectiveRegionL 公式中的权重。
+ ///
+ private const double Alpha25Factor = 0.75;
+
+ ///
+ /// 背景图对有效背景明度的影响因子。
+ /// 背景图虽然透过半透明层影响视觉,但文字的可读性主要取决于 RegionBrush 系列底色,
+ /// 因此只取背景图影响的 50%,避免文字色被背景图过度拉偏。
+ ///
+ private const double BackgroundInfluence = 0.5;
+
+ ///
+ /// 根据基础主色生成调色板。
+ ///
+ /// 提取或用户选定的主色。
+ /// 当前是否为深色模式。
+ /// 背景图不透明度 (0~100),影响文字色的明度选取。
+ /// 资源 key → 颜色的映射。
+ public static Dictionary Generate(Color baseColor, bool isDark, int backgroundOpacity = 50)
+ {
+ var (hue, sat, _) = RgbToHsl(baseColor);
+
+ // 主色饱和度:保留原色鲜艳度,但设下限避免过低饱和
+ var primarySat = Math.Max(sat, PrimaryMinSaturation);
+
+ // 有效背景明度:文字实际位于 RegionBrushOpacity25 覆盖层之上。
+ // 背景图透过半透明层影响视觉,但对可读性的影响有限,只取部分影响。
+ var regionL = isDark ? RegionDarkL : RegionLightL;
+ var baseL = RgbToHsl(baseColor).L;
+ var alpha = backgroundOpacity / 100.0;
+ var blendedL = (regionL * (1 - (alpha * BackgroundInfluence))) + (baseL * (alpha * BackgroundInfluence));
+
+ // Opacity25 覆盖层:25% region + 75% 混合底
+ var effectiveRegionL = (blendedL * Alpha25Factor) + (regionL * (1 - Alpha25Factor));
+
+ // ── 固定明度角色:背景体系 ──
+ var region = HslToRgb(hue, BackgroundSaturation, isDark ? RegionDarkL : RegionLightL);
+ var mouseOver = HslToRgb(hue, BackgroundSaturation, isDark ? MouseOverDarkL : MouseOverLightL);
+
+ // ── 自适应明度角色:主色系(随有效背景明度连续变化) ──
+ var primaryTargetL = isDark
+ ? Math.Min(effectiveRegionL + PrimaryContrastDistance, 0.90)
+ : Math.Max(effectiveRegionL - PrimaryContrastDistance, 0.10);
+ var primary = HslToRgb(hue, primarySat, primaryTargetL);
+
+ var darkPrimary = isDark
+ ? HslToRgb(hue, primarySat, Math.Max(primaryTargetL - 0.10, 0.05))
+ : primary;
+ var lightPrimary = HslToRgb(hue, primarySat, isDark ? 0.10 : 0.70);
+
+ // ── 自适应明度角色:文字系 ──
+ var textTargetL = isDark
+ ? Math.Min(effectiveRegionL + TextContrastDistance, 0.90)
+ : Math.Max(effectiveRegionL - TextContrastDistance, 0.05);
+ var traceTargetL = isDark
+ ? Math.Min(effectiveRegionL + (TextContrastDistance * 0.7), 0.90)
+ : Math.Max(effectiveRegionL - (TextContrastDistance * 0.7), 0.05);
+
+ var primaryText = HslToRgb(hue, TextSaturation, textTargetL);
+ var traceLog = HslToRgb(hue, TextSaturation, traceTargetL);
+
+ var palette = new Dictionary
+ {
+ ["PrimaryBrush"] = primary,
+ ["DarkPrimaryBrush"] = darkPrimary,
+ ["LightPrimaryBrush"] = lightPrimary,
+ ["TitleBrush"] = primary,
+ ["RegionBrush"] = region,
+ ["MouseOverRegionBrush"] = mouseOver,
+ ["PrimaryTextBrush"] = primaryText,
+ ["ThirdlyTextBrush"] = Color.FromArgb(0x7F, primaryText.R, primaryText.G, primaryText.B),
+ ["TraceLogBrush"] = traceLog,
+ ["MessageLogBrush"] = primaryText,
+ };
+
+ // Opacity 系列
+ palette["RegionBrushOpacity10"] = Color.FromArgb(Alpha10, region.R, region.G, region.B);
+ palette["RegionBrushOpacity25"] = Color.FromArgb(Alpha25, region.R, region.G, region.B);
+ palette["RegionBrushOpacity50"] = Color.FromArgb(Alpha50, region.R, region.G, region.B);
+ palette["RegionBrushOpacity75"] = Color.FromArgb(Alpha75, region.R, region.G, region.B);
+
+ palette["MouseOverRegionBrushOpacity10"] = Color.FromArgb(Alpha10, mouseOver.R, mouseOver.G, mouseOver.B);
+ palette["MouseOverRegionBrushOpacity25"] = Color.FromArgb(Alpha25, mouseOver.R, mouseOver.G, mouseOver.B);
+ palette["MouseOverRegionBrushOpacity50"] = Color.FromArgb(Alpha50, mouseOver.R, mouseOver.G, mouseOver.B);
+ palette["MouseOverRegionBrushOpacity75"] = Color.FromArgb(Alpha75, mouseOver.R, mouseOver.G, mouseOver.B);
+
+ return palette;
+ }
+
+ ///
+ /// RGB → HSL 转换。
+ ///
+ private static (double H, double S, double L) RgbToHsl(Color color)
+ {
+ var r = color.R / 255.0;
+ var g = color.G / 255.0;
+ var b = color.B / 255.0;
+
+ var max = Math.Max(r, Math.Max(g, b));
+ var min = Math.Min(r, Math.Min(g, b));
+ var delta = max - min;
+
+ var l = (max + min) / 2.0;
+
+ if (delta == 0)
+ {
+ return (0, 0, l);
+ }
+
+ var s = delta / (1 - Math.Abs((2 * l) - 1));
+
+ double h;
+ if (max == r)
+ {
+ h = ((g - b) / delta) % 6;
+ }
+ else if (max == g)
+ {
+ h = ((b - r) / delta) + 2;
+ }
+ else
+ {
+ h = ((r - g) / delta) + 4;
+ }
+
+ h *= 60;
+ if (h < 0)
+ {
+ h += 360;
+ }
+
+ return (h, s, l);
+ }
+
+ ///
+ /// HSL → RGB 转换。
+ ///
+ private static Color HslToRgb(double h, double s, double l)
+ {
+ if (s == 0)
+ {
+ var gray = (byte)(l * 255);
+ return Color.FromRgb(gray, gray, gray);
+ }
+
+ var c = (1 - Math.Abs((2 * l) - 1)) * s;
+ var hp = h / 60.0;
+ var x = c * (1 - Math.Abs((hp % 2) - 1));
+
+ double r1, g1, b1;
+ switch ((int)hp)
+ {
+ case 0: r1 = c; g1 = x; b1 = 0; break;
+ case 1: r1 = x; g1 = c; b1 = 0; break;
+ case 2: r1 = 0; g1 = c; b1 = x; break;
+ case 3: r1 = 0; g1 = x; b1 = c; break;
+ case 4: r1 = x; g1 = 0; b1 = c; break;
+ default: r1 = c; g1 = 0; b1 = x; break;
+ }
+
+ var m = l - (c / 2.0);
+ return Color.FromRgb(
+ (byte)((r1 + m) * 255),
+ (byte)((g1 + m) * 255),
+ (byte)((b1 + m) * 255));
+ }
+}
diff --git a/src/MaaWpfGui/Helper/ThemeHelper.cs b/src/MaaWpfGui/Helper/ThemeHelper.cs
index 4f124532d4..2c3ac8d36a 100644
--- a/src/MaaWpfGui/Helper/ThemeHelper.cs
+++ b/src/MaaWpfGui/Helper/ThemeHelper.cs
@@ -11,6 +11,9 @@
// but WITHOUT ANY WARRANTY
//
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using HandyControl.Themes;
@@ -36,6 +39,9 @@ public static class ThemeHelper
}
Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor;
+
+ // 系统主题切换后,莫奈调色板需要按新的明暗模式重新生成
+ ReapplyMonet();
}
public static void SwitchToLightMode()
@@ -44,6 +50,7 @@ public static class ThemeHelper
ThemeManager.Current.UsingWindowsAppTheme = false;
ThemeManager.Current.ApplicationTheme = ApplicationTheme.Light;
Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor;
+ ReapplyMonet();
}
public static void SwitchToDarkMode()
@@ -52,6 +59,7 @@ public static class ThemeHelper
ThemeManager.Current.UsingWindowsAppTheme = false;
ThemeManager.Current.ApplicationTheme = ApplicationTheme.Dark;
Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor;
+ ReapplyMonet();
}
public static void SwitchToSyncWithOsMode()
@@ -67,10 +75,188 @@ public static class ThemeHelper
}
Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor;
+ ReapplyMonet();
}
#endregion Swith Theme
+ #region Monet(莫奈取色)
+
+ ///
+ /// 缓存的莫奈调色板的生成参数,用于明暗切换后重新生成。
+ /// 为 null 表示莫奈取色未启用。
+ ///
+ private static Color? _monetBaseColor;
+
+ ///
+ /// 缓存的背景图不透明度,用于 ReapplyMonet 时重新生成文字色。
+ ///
+ private static int _monetBackgroundOpacity = 50;
+
+ ///
+ /// 保存被莫奈覆盖前的原始 brush,用于关闭莫奈时恢复。
+ ///
+ private static readonly Dictionary _monetOriginalBrushes = [];
+
+ ///
+ /// 莫奈取色是否已启用(即是否已调用过 ApplyMonetPalette 且未 Revert)。
+ ///
+ public static bool IsMonetActive => _monetBaseColor != null;
+
+ ///
+ /// 获取当前是否处于深色模式。
+ ///
+ /// 深色模式返回 true。
+ public static bool IsDarkMode()
+ {
+ return ThemeManager.Current.ApplicationTheme == ApplicationTheme.Dark;
+ }
+
+ ///
+ /// 应用莫奈调色板,覆盖主题资源中的主色系和背景色系。
+ /// 首次调用时会保存原始值以供回退。
+ ///
+ /// 提取或用户选定的主色。
+ /// 背景图不透明度 (0~100),影响文字色的明度选取。
+ public static void ApplyMonetPalette(Color baseColor, int backgroundOpacity = 50)
+ {
+ var isDark = IsDarkMode();
+ _monetBaseColor = baseColor;
+ _monetBackgroundOpacity = backgroundOpacity;
+
+ // 调色板计算可在任意线程完成,无需占用 UI 线程
+ var palette = MonetPaletteHelper.Generate(baseColor, isDark, backgroundOpacity);
+
+ // 只有实际写入 WPF 资源才需要在 UI 线程执行
+ Application.Current?.Dispatcher.Invoke(() =>
+ {
+ ApplyPaletteToResources(palette, baseColor);
+ });
+ }
+
+ ///
+ /// 异步应用莫奈调色板:计算在后台线程,资源写入在 UI 线程。
+ /// 适用于滑块拖动等高频调用场景,避免阻塞 UI。
+ ///
+ /// 提取或用户选定的主色。
+ /// 背景图不透明度 (0~100),影响文字色的明度选取。
+ /// 取消令牌,用于防抖时取消过期的调色板计算。
+ /// 表示异步操作的任务。
+ public static async Task ApplyMonetPaletteAsync(Color baseColor, int backgroundOpacity = 50, CancellationToken cancellationToken = default)
+ {
+ var isDark = IsDarkMode();
+ _monetBaseColor = baseColor;
+ _monetBackgroundOpacity = backgroundOpacity;
+
+ // 在后台线程计算调色板
+ var palette = await Task.Run(() => MonetPaletteHelper.Generate(baseColor, isDark, backgroundOpacity), cancellationToken)
+ .ConfigureAwait(true);
+
+ // 在 UI 线程写入资源
+ Application.Current?.Dispatcher.Invoke(() =>
+ {
+ ApplyPaletteToResources(palette, baseColor);
+ });
+ }
+
+ ///
+ /// 将已计算好的调色板写入 WPF 资源(必须在 UI 线程调用)。
+ ///
+ private static void ApplyPaletteToResources(Dictionary palette, Color baseColor)
+ {
+ // 首次应用时保存原始值
+ SaveOriginalsIfNeeded(MonetPaletteHelper.PaletteKeys);
+
+ // 先设置 AccentColor:HandyControl 的 ThemeManager 会根据它重新生成
+ // PrimaryBrush / DarkPrimaryBrush / TitleBrush 等内部资源,
+ // 因此必须在 AccentColor 之后再覆盖为莫奈调色板的值,否则会被覆盖回去
+ ThemeManager.Current.AccentColor = new SolidColorBrush(baseColor);
+
+ foreach (var (key, color) in palette)
+ {
+ Application.Current.Resources[key] = new SolidColorBrush(color);
+ }
+ }
+
+ ///
+ /// 回退莫奈调色板,恢复所有被覆盖的 brush 到原始值。
+ ///
+ public static void RevertMonetPalette()
+ {
+ if (_monetBaseColor == null)
+ {
+ return;
+ }
+
+ _monetBaseColor = null;
+
+ // 只有资源写入需要在 UI 线程
+ Application.Current?.Dispatcher.Invoke(() =>
+ {
+ // 先恢复 AccentColor 到系统值(同样会重写 HandyControl 内部 brush)
+ if (!WineRuntimeInformation.IsRunningUnderWine)
+ {
+ ThemeManager.Current.AccentColor = ThemeManager.Current.GetAccentColorFromSystem();
+ }
+
+ // 在 AccentColor 之后再恢复原始 brush,避免被 HandyControl 覆盖
+ foreach (var (key, brush) in _monetOriginalBrushes)
+ {
+ Application.Current.Resources[key] = brush;
+ }
+
+ _monetOriginalBrushes.Clear();
+
+ Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor;
+ });
+ }
+
+ ///
+ /// 当莫奈取色启用时,使用缓存的 baseColor 重新生成调色板。
+ /// 用于明暗模式切换后同步更新。
+ ///
+ public static void ReapplyMonet()
+ {
+ if (_monetBaseColor is Color color)
+ {
+ // 明暗模式切换后,HandyControl 会通过 ThemeResources 切换底层主题字典,
+ // 但莫奈之前直接写入 Application.Current.Resources 的值会遮蔽新主题的值。
+ // 需要先移除这些直接覆盖项,让底层新主题值透出,再重新保存和生成。
+ Application.Current?.Dispatcher.Invoke(() =>
+ {
+ foreach (var key in MonetPaletteHelper.PaletteKeys)
+ {
+ Application.Current.Resources.Remove(key);
+ }
+ });
+
+ _monetOriginalBrushes.Clear();
+ ApplyMonetPalette(color, _monetBackgroundOpacity);
+ }
+ }
+
+ ///
+ /// 首次覆盖前保存原始 brush 值。
+ ///
+ private static void SaveOriginalsIfNeeded(IEnumerable keys)
+ {
+ if (_monetOriginalBrushes.Count > 0)
+ {
+ return;
+ }
+
+ foreach (var key in keys)
+ {
+ if (Application.Current?.Resources[key] is SolidColorBrush brush)
+ {
+ // 冻结副本,避免引用被修改
+ _monetOriginalBrushes[key] = brush.Clone();
+ }
+ }
+ }
+
+ #endregion Monet(莫奈取色)
+
#region Check UiLogColor
// In official version, using ResourceHelper.GetSkin
diff --git a/src/MaaWpfGui/Res/Localizations/en-us.xaml b/src/MaaWpfGui/Res/Localizations/en-us.xaml
index e98cd72e81..7a67d239fc 100644
--- a/src/MaaWpfGui/Res/Localizations/en-us.xaml
+++ b/src/MaaWpfGui/Res/Localizations/en-us.xaml
@@ -23,6 +23,12 @@
Fill
Uniform (Fit)
Uniform to Fill (Cover)
+ Monet Color
+ Color Mode
+ Auto Extract
+ Custom Color
+ Select Color
+ Extract dominant color from background image to generate theme palette
UI Theme
HotKeys
Update
diff --git a/src/MaaWpfGui/Res/Localizations/ja-jp.xaml b/src/MaaWpfGui/Res/Localizations/ja-jp.xaml
index b62adca29c..01d64a3576 100644
--- a/src/MaaWpfGui/Res/Localizations/ja-jp.xaml
+++ b/src/MaaWpfGui/Res/Localizations/ja-jp.xaml
@@ -23,6 +23,12 @@
引き伸ばし(フィル)
アスペクト比保持(フィット)
アスペクト比保持(トリミング)
+ モネ採色
+ 採色モード
+ 自動採色
+ カスタムカラー
+ 色を選択
+ 背景画像からメインカラーを自動抽出してテーマ配色を生成します
UI主題
ホットキー設定
アップデート設定
diff --git a/src/MaaWpfGui/Res/Localizations/ko-kr.xaml b/src/MaaWpfGui/Res/Localizations/ko-kr.xaml
index 43b18022d6..d39ebbfb13 100644
--- a/src/MaaWpfGui/Res/Localizations/ko-kr.xaml
+++ b/src/MaaWpfGui/Res/Localizations/ko-kr.xaml
@@ -24,6 +24,12 @@
늘려 채움
비율 유지 (맞춤)
비율 유지 (자르기)
+ 모네 색상
+ 색상 모드
+ 자동 추출
+ 사용자 지정 색상
+ 색상 선택
+ 배경 이미지에서 주요 색상을 추출하여 테마 색상을 생성합니다
단축키 설정
업데이트
도전과제
diff --git a/src/MaaWpfGui/Res/Localizations/zh-cn.xaml b/src/MaaWpfGui/Res/Localizations/zh-cn.xaml
index 6ae5e41b64..2123bc752f 100644
--- a/src/MaaWpfGui/Res/Localizations/zh-cn.xaml
+++ b/src/MaaWpfGui/Res/Localizations/zh-cn.xaml
@@ -23,6 +23,12 @@
拉伸填充
等比适应
等比填充(裁剪)
+ 莫奈取色
+ 取色模式
+ 自动取色
+ 自定义颜色
+ 选择颜色
+ 将从背景图自动提取主色生成主题配色
界面主题
热键设置
更新设置
diff --git a/src/MaaWpfGui/Res/Localizations/zh-tw.xaml b/src/MaaWpfGui/Res/Localizations/zh-tw.xaml
index ae557fcbf7..9979378d29 100644
--- a/src/MaaWpfGui/Res/Localizations/zh-tw.xaml
+++ b/src/MaaWpfGui/Res/Localizations/zh-tw.xaml
@@ -23,6 +23,12 @@
非等比例填滿
等比例縮放
等比例填滿(裁剪)
+ 莫奈取色
+ 取色模式
+ 自動取色
+ 自訂顏色
+ 選擇顏色
+ 將從背景圖自動提取主色產生主題配色
介面主題
熱鍵設定
更新設定
diff --git a/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs b/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs
index 4747af313e..c1f4e52e44 100644
--- a/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs
+++ b/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs
@@ -359,6 +359,9 @@ public class SettingsViewModel : Screen
GuiSettings.LanguageList = languageList;
GuiSettings.SwitchDarkMode();
+
+ // 主题初始化完成后,若莫奈取色已开启,恢复调色板(必须在主题切换之后执行)
+ BackgroundSettings.UpdateMonet();
}
private void InitConnectConfig()
diff --git a/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs b/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs
index 880c81411e..69d4537a48 100644
--- a/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs
+++ b/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs
@@ -13,27 +13,53 @@
#nullable enable
using System;
-using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
+using HandyControl.Controls;
+using HandyControl.Tools;
+using MaaWpfGui.Configuration.Factory;
using MaaWpfGui.Constants;
using MaaWpfGui.Helper;
+using MaaWpfGui.Utilities;
using MaaWpfGui.Utilities.ValueType;
using Microsoft.Win32;
+using Serilog;
using Stylet;
+using MonetModeType = MaaWpfGui.Configuration.Global.GUI.MonetModeType;
namespace MaaWpfGui.ViewModels.UserControl.Settings;
public class BackgroundSettingsUserControlModel : PropertyChangedBase
{
+ private static readonly ILogger _logger = Log.ForContext();
+
+ ///
+ /// 莫奈取色更新的防抖延迟(毫秒)。
+ ///
+ private const int MonetDebounceMs = 150;
+
static BackgroundSettingsUserControlModel()
{
Instance = new();
}
+ private BackgroundSettingsUserControlModel()
+ {
+ PropertyDependsOnUtility.InitializePropertyDependencies(this);
+ LocalizationHelper.LanguageChanged += RefreshLocalization;
+ }
+
+ public void RefreshLocalization()
+ {
+ BackgroundImageStretchModeList.RefreshLocalization();
+ BackgroundMonetModeList.RefreshLocalization();
+ }
+
public static BackgroundSettingsUserControlModel Instance { get; }
private static string _backgroundImagePath = ConfigurationHelper.GetGlobalValue(ConfigurationKeys.BackgroundImagePath, "background/background.png");
@@ -46,6 +72,9 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase
ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundImagePath, value);
BackgroundImage = RefreshBackgroundImage(value);
+ // 背景图变更后,若莫奈自动取色开启,重新提取主色
+ UpdateMonet();
+
AchievementTrackerHelper.Instance.Unlock(AchievementIds.CustomizationMaster);
}
}
@@ -83,13 +112,11 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase
}
}
- public static List BackgroundImageStretchModeList { get; } =
- [
- new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeNone"), Value = Stretch.None.ToString() },
- new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeFill"), Value = Stretch.Fill.ToString() },
- new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeUniform"), Value = Stretch.Uniform.ToString() },
- new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeUniformToFill"), Value = Stretch.UniformToFill.ToString() },
- ];
+ public LocalizedObservableList BackgroundImageStretchModeList { get; } = new(
+ (Stretch.None, "BackgroundImageStretchModeNone"),
+ (Stretch.Fill, "BackgroundImageStretchModeFill"),
+ (Stretch.Uniform, "BackgroundImageStretchModeUniform"),
+ (Stretch.UniformToFill, "BackgroundImageStretchModeUniformToFill"));
private static BitmapImage? RefreshBackgroundImage(string imagePath)
{
@@ -124,6 +151,13 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase
get => _backgroundOpacity;
set {
SetAndNotify(ref _backgroundOpacity, value);
+
+ // 透明度变化后,若莫奈取色开启,重新生成调色板(文字色需根据新的有效背景明度选取)
+ // 滑块拖动会高频触发,使用防抖避免阻塞 UI
+ if (BackgroundMonetEnabled)
+ {
+ ScheduleMonetUpdate();
+ }
}
}
@@ -147,4 +181,209 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase
ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundOpacity, BackgroundOpacity.ToString(CultureInfo.InvariantCulture));
ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundBlurEffectRadius, BackgroundBlurEffectRadius.ToString(CultureInfo.InvariantCulture));
}
+
+ #region 莫奈取色(Monet Color)
+
+ ///
+ /// 莫奈取色是否启用。
+ ///
+ public bool BackgroundMonetEnabled
+ {
+ get => ConfigFactory.Root.GUI.BackgroundMonetEnabled;
+ set {
+ ConfigFactory.Root.GUI.BackgroundMonetEnabled = value;
+ NotifyOfPropertyChange();
+ UpdateMonet();
+ }
+ }
+
+ ///
+ /// 莫奈取色模式(Auto / Custom)。
+ ///
+ public MonetModeType BackgroundMonetMode
+ {
+ get => ConfigFactory.Root.GUI.BackgroundMonetMode;
+ set {
+ ConfigFactory.Root.GUI.BackgroundMonetMode = value;
+ NotifyOfPropertyChange();
+ UpdateMonet();
+ }
+ }
+
+ [PropertyDependsOn(nameof(BackgroundMonetMode))]
+ public bool IsBackgroundMonetAuto => BackgroundMonetMode == MonetModeType.Auto;
+
+ [PropertyDependsOn(nameof(BackgroundMonetMode))]
+ public bool IsBackgroundMonetCustom => BackgroundMonetMode == MonetModeType.Custom;
+
+ ///
+ /// 莫奈取色模式下拉列表。
+ ///
+ public LocalizedObservableList BackgroundMonetModeList { get; } = new(
+ (MonetModeType.Auto, "BackgroundMonetAuto"),
+ (MonetModeType.Custom, "BackgroundMonetCustom"));
+
+ ///
+ /// 用户自定义颜色(HEX 字符串)。
+ ///
+ public string BackgroundMonetCustomColor
+ {
+ get => ConfigFactory.Root.GUI.BackgroundMonetCustomColor;
+ set {
+ ConfigFactory.Root.GUI.BackgroundMonetCustomColor = value;
+ NotifyOfPropertyChange();
+ NotifyOfPropertyChange(nameof(CurrentMonetColor));
+ }
+ }
+
+ ///
+ /// 当前生效的莫奈主色,用于色块预览。
+ ///
+ public Color CurrentMonetColor
+ {
+ get {
+ if (!BackgroundMonetEnabled)
+ {
+ // 关闭时显示默认主色
+ return ThemeHelper.String2Color("#326CF3");
+ }
+
+ return IsBackgroundMonetCustom
+ ? ThemeHelper.String2Color(BackgroundMonetCustomColor)
+ : _lastExtractedColor ?? ThemeHelper.String2Color("#326CF3");
+ }
+ }
+
+ ///
+ /// 上次自动提取到的颜色(缓存)。
+ ///
+ private Color? _lastExtractedColor;
+
+ ///
+ /// 打开 HandyControl ColorPicker 弹窗让用户选择自定义颜色。
+ ///
+ public void SelectMonetColor()
+ {
+ var picker = SingleOpenHelper.CreateControl();
+
+ // 初始化 ColorPicker 为当前颜色
+ var current = ThemeHelper.String2Color(BackgroundMonetCustomColor);
+ picker.SelectedBrush = new SolidColorBrush(current);
+
+ var dialog = Dialog.Show(picker, nameof(Views.UI.RootView));
+
+ picker.Confirmed += (_, e) => {
+ var color = e.Info;
+ BackgroundMonetCustomColor = ThemeHelper.Color2HexString(color);
+ UpdateMonet();
+ dialog.Close();
+ };
+
+ picker.Canceled += (_, _) => dialog.Close();
+ }
+
+ ///
+ /// 防抖取消令牌源,避免滑块拖动时高频重新计算调色板。
+ ///
+ private CancellationTokenSource? _monetUpdateCts;
+
+ ///
+ /// 调度一次莫奈取色更新(防抖 150ms)。
+ /// 滑块拖动等高频场景调用此方法,而非直接 。
+ ///
+ private void ScheduleMonetUpdate()
+ {
+ _monetUpdateCts?.Cancel();
+ _monetUpdateCts = new CancellationTokenSource();
+ _ = UpdateMonetAsync(_monetUpdateCts.Token);
+ }
+
+ ///
+ /// 根据当前配置执行莫奈取色逻辑。
+ /// 自动 / 自定义模式都会将计算放到后台线程,仅资源写入在 UI 线程。
+ ///
+ public void UpdateMonet()
+ {
+ _monetUpdateCts?.Cancel();
+ _ = UpdateMonetAsync(CancellationToken.None);
+ }
+
+ ///
+ /// 异步执行莫奈取色,支持取消(用于防抖)。
+ /// 所有异常均在方法内部捕获并记录,避免 fire-and-forget 调用产生未观察异常。
+ ///
+ /// 取消令牌。
+ private async Task UpdateMonetAsync(CancellationToken cancellationToken)
+ {
+ // 防抖延迟:等待 150ms,期间若被取消则直接返回
+ try
+ {
+ await Task.Delay(MonetDebounceMs, cancellationToken).ConfigureAwait(true);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ try
+ {
+ if (!BackgroundMonetEnabled)
+ {
+ ThemeHelper.RevertMonetPalette();
+ NotifyOfPropertyChange(nameof(CurrentMonetColor));
+ return;
+ }
+
+ if (BackgroundMonetMode == MonetModeType.Custom)
+ {
+ // 自定义模式:直接用用户选定的颜色,计算放后台线程
+ var color = ThemeHelper.String2Color(BackgroundMonetCustomColor);
+ await ThemeHelper.ApplyMonetPaletteAsync(color, BackgroundOpacity, cancellationToken);
+ NotifyOfPropertyChange(nameof(CurrentMonetColor));
+ }
+ else
+ {
+ // 自动模式:从背景图提取主色(本身就是异步的)
+ await UpdateMonetAutoAsync(cancellationToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // 正常的防抖取消,静默忽略
+ }
+ catch (Exception ex)
+ {
+ // 兜底:fire-and-forget 调用中的异常不能逃逸,记录后忽略
+ _logger.Error(ex, "Failed to update Monet palette.");
+ }
+ }
+
+ ///
+ /// 异步执行自动取色:提取主色 → 应用调色板。
+ /// 颜色提取和调色板计算在后台线程,资源写入在 UI 线程。
+ ///
+ /// 取消令牌。
+ private async Task UpdateMonetAutoAsync(CancellationToken cancellationToken)
+ {
+ var image = BackgroundImage;
+ if (image == null)
+ {
+ // 没有背景图时,取色无意义,先回退
+ ThemeHelper.RevertMonetPalette();
+ NotifyOfPropertyChange(nameof(CurrentMonetColor));
+ return;
+ }
+
+ var rawColor = await ColorExtractorHelper.ExtractDominantColorAsync(image).ConfigureAwait(true);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // 直接使用提取到的原始主色生成调色板
+ _lastExtractedColor = rawColor;
+
+ await ThemeHelper.ApplyMonetPaletteAsync(rawColor, BackgroundOpacity, cancellationToken);
+
+ NotifyOfPropertyChange(nameof(CurrentMonetColor));
+ }
+
+ #endregion 莫奈取色(Monet Color)
}
diff --git a/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml b/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml
index bf80aa68b8..330e041e37 100644
--- a/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml
+++ b/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml
@@ -12,12 +12,12 @@
xmlns:settings_vms="clr-namespace:MaaWpfGui.ViewModels.UserControl.Settings"
d:Background="White"
d:DataContext="{d:DesignInstance {x:Type settings_vms:BackgroundSettingsUserControlModel}}"
- d:DesignHeight="300"
+ d:DesignHeight="350"
d:DesignWidth="550"
s:View.ActionTarget="{Binding}"
mc:Ignorable="d">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+