那么先说结论——重构了一次,我还是失败了,失败于拓展调整个性化的设计,不过我还是实现了按键监测然后显示的功能。只不过是说我对于WPF软件等的封装和软窗口的功能还是不怎么熟悉。
引言
在许多游戏玩家中,高难度操作(高APM)复现始终是技术提升的核心,而在各类剪辑、特效、建模软件教学视频中,“快捷键教学”也逐渐成为主流。为此,我希望实现一个全局按键监听器,能以视觉化方式实时展示当前操作,辅助观众更好地理解、复现操作步骤。
按键检测读取,注意不是按键精灵,前者多半是为了教学中的复现,自证,亦或是实现某种真实在进行的直播动作直播效果。而后者我的理解是一种按键宏,也就是传统意义上的外挂。我们观看游戏直播时,很多主播会使用到,教学视频方面,我观察到的更多是belender软件的教学和使用。毕竟学会更多的快捷键,就可以大大提高生产效率。但是我的目光聚焦在这个按键映射软件本身,于是我进行了开发,与大G老师深入交流。
🛠️ 项目目标
1.实现全局键盘与鼠标监听。
2.监听操作后在屏幕左下角浮现按键组合。
3.拓展配置:控制显示位置、字体缩放、最大数量等个性化设置。(失败啦!)
开发(C#)(第一次)
通过第三方库 Gma.System.MouseKeyHook
监听全局按键,然后把每次捕获到的按键以文字的形式展示在屏幕右下角,用 WPF
搭配 StackPanel + Border + TextBlock
动态生成显示框。
直接用按键转换包,按下那个按键就可以自动从码值转换成按键对应的文本。
Title="KeyCaster"
Height="450"
Width="800"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
最后用窗体大小和生成位置调整最后我们需要显示提示的位置,这个窗体透明就可以。
然后奉上按键映射监测的核心代码:
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using Gma.System.MouseKeyHook;
namespace WpfApp1
{
public partial class MainWindow : Window
{
#region 字段定义
private IKeyboardMouseEvents _globalHook;
private readonly List<string> _keyList = new();
private DateTime _lastInputTime = DateTime.MinValue;
private Border _activeBlock = null;
private DispatcherTimer _groupTimer;
private double _scaleFactor = 1.0;
private Thickness _screenOffset = new Thickness(0, 0, 20, 20);
#endregion
#region 构造函数与初始化
public MainWindow()
{
InitializeComponent();
Console.WriteLine("MainWindow 构造函数执行");
StartGlobalHook();
var settings = SettingsManager.Load();
_scaleFactor = settings.Scale;
_screenOffset = new Thickness(settings.OffsetX, settings.OffsetY, 20, 20);
}
private void StartGlobalHook()
{
Console.WriteLine("尝试注册键鼠监听");
_globalHook = Hook.GlobalEvents();
_globalHook.KeyDown += OnInputEvent;
_globalHook.MouseDown += OnInputEvent;
Console.WriteLine("全局钩子已启动");
}
#endregion
#region 输入处理
private void OnInputEvent(object sender, EventArgs e)
{
Console.WriteLine($"捕获事件:{e}");
var now = DateTime.Now;
string inputStr = e switch
{
System.Windows.Forms.KeyEventArgs keyEvent => keyEvent.KeyCode.ToString(),
System.Windows.Forms.MouseEventArgs mouseEvent => mouseEvent.Button switch
{
System.Windows.Forms.MouseButtons.Left => "MouseLeft",
System.Windows.Forms.MouseButtons.Right => "MouseRight",
System.Windows.Forms.MouseButtons.Middle => "MouseMiddle",
_ => mouseEvent.Button.ToString()
},
_ => string.Empty
};
if (string.IsNullOrEmpty(inputStr))
return;
double interval = (now - _lastInputTime).TotalSeconds;
_lastInputTime = now;
if (interval <= 0.5 && _activeBlock != null)
{
_keyList.Add(inputStr);
string formatted = FormatKeyList(_keyList);
UpdateActiveDisplay(formatted);
ResetGroupTimer();
}
else
{
if (_activeBlock != null)
{
StartFadeOut(_activeBlock);
_activeBlock = null;
}
_keyList.Clear();
_keyList.Add(inputStr);
string formatted = FormatKeyList(_keyList);
ShowNewDisplay(formatted);
ResetGroupTimer();
}
}
private string FormatKeyList(List<string> keys)
{
var sb = new StringBuilder();
for (int i = 0; i < keys.Count; i++)
{
string current = keys[i];
bool currentIsLetter = current.Length == 1 && current[0] >= 'A' && current[0] <= 'Z';
if (i > 0)
{
string previous = keys[i - 1];
bool previousIsLetter = previous.Length == 1 && previous[0] >= 'A' && previous[0] <= 'Z';
if (!(previousIsLetter && currentIsLetter))
{
sb.Append(" + ");
}
}
sb.Append(current);
}
return sb.ToString();
}
#endregion
#region UI 显示与更新
private void ShowNewDisplay(string text)
{
_activeBlock = new Border
{
Background = System.Windows.Media.Brushes.Black,
Opacity = 0.8,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10),
Margin = _screenOffset,
LayoutTransform = new ScaleTransform(_scaleFactor, _scaleFactor),
Child = new TextBlock
{
Text = text,
Foreground = System.Windows.Media.Brushes.White,
FontSize = 20
}
};
KeyDisplayPanel.Children.Add(_activeBlock);
}
private void UpdateActiveDisplay(string text)
{
if (_activeBlock != null)
{
((TextBlock)_activeBlock.Child).Text = text;
}
}
private void ResetGroupTimer()
{
_groupTimer?.Stop();
_groupTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(0.5)
};
_groupTimer.Tick += (s, e) =>
{
_groupTimer.Stop();
if (_activeBlock != null)
{
StartFadeOut(_activeBlock);
_activeBlock = null;
}
};
_groupTimer.Start();
}
#endregion
#region 动画淡出
private void StartFadeOut(Border border)
{
var animation = new DoubleAnimation
{
From = border.Opacity,
To = 0.0,
Duration = TimeSpan.FromSeconds(3),
FillBehavior = FillBehavior.HoldEnd
};
animation.Completed += (s, e) =>
{
KeyDisplayPanel.Children.Remove(border);
};
border.BeginAnimation(UIElement.OpacityProperty, animation);
}
private void OpenSettings()
{
var settingsWindow = new SettingsWindow();
settingsWindow.ShowDialog();
var settings = SettingsManager.Load();
_scaleFactor = settings.Scale;
_screenOffset = new Thickness(settings.OffsetX, settings.OffsetY, 20, 20);
}
#endregion
#region 清理资源
protected override void OnClosed(EventArgs e)
{
_globalHook.KeyDown -= OnInputEvent;
_globalHook.MouseDown -= OnInputEvent;
_globalHook.Dispose();
base.OnClosed(e);
}
#endregion
}
}
使用到的字段:监听器对象、按键队列、动画定时器、缩放与位移。
加入组合键监听,使用‘+’连接
private string FormatKeyList(List<string> keys)
{
var sb = new StringBuilder();
for (int i = 0; i < keys.Count; i++)
{
string current = keys[i];
bool currentIsLetter = current.Length == 1 && current[0] >= 'A' && current[0] <= 'Z';
if (i > 0)
{
string previous = keys[i - 1];
bool previousIsLetter = previous.Length == 1 && previous[0] >= 'A' && previous[0] <= 'Z';
if (!(previousIsLetter && currentIsLetter))
{
sb.Append(" + ");
}
}
sb.Append(current);
}
return sb.ToString();
}
加上鼠标监听,和键盘的要放在一起,毕竟光有按键的同时,有些操作仍然需要鼠标的参与
private void OnInputEvent(object sender, EventArgs e)
{
string inputStr = e switch
{
System.Windows.Forms.KeyEventArgs keyEvent => keyEvent.KeyCode.ToString(),
System.Windows.Forms.MouseEventArgs mouseEvent => mouseEvent.Button switch
{
System.Windows.Forms.MouseButtons.Left => "MouseLeft",
System.Windows.Forms.MouseButtons.Right => "MouseRight",
System.Windows.Forms.MouseButtons.Middle => "MouseMiddle",
_ => mouseEvent.Button.ToString()
},
_ => string.Empty
};
if (string.IsNullOrEmpty(inputStr))
return;
// 以下省略……
}
最后加入最大的框体数限制,获取连续输入的最大时间间隔(我这里用了0.5s),同时保证单纯输入A~Z的字母的时候不需要使用+连接,设置每个框体的淡出时间防止遮挡视野。。。
private void ResetGroupTimer()
{
_groupTimer?.Stop();
_groupTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(0.5)
};
_groupTimer.Tick += (s, e) =>
{
_groupTimer.Stop();
if (_activeBlock != null)
{
StartFadeOut(_activeBlock);
_activeBlock = null;
}
};
_groupTimer.Start();
}
private void StartFadeOut(Border border)
{
var animation = new DoubleAnimation
{
From = border.Opacity,
To = 0.0,
Duration = TimeSpan.FromSeconds(3),
FillBehavior = FillBehavior.HoldEnd
};
animation.Completed += (s, e) =>
{
KeyDisplayPanel.Children.Remove(border);
};
border.BeginAnimation(UIElement.OpacityProperty, animation);
}
我们得到了。。。电脑右下角的按键提示!
大概就是这个效果
重构(第二次)
本来想着加功能,做一个设置调试的,但是“钩子”就是不触发!!!可以说是我菜,但是我想象中的逻辑没有跑通,设置对于另外一个窗口毫无影响,虽然只是一些改变量的事情。。。实际上这是一个多窗口多事件的软件,因此我就卡在这里了。相信我一定有机会进行下一次重构,这样我就可以打包并且发放出来啦!
总结
我尝试通过 WPF 实现托盘图标与隐藏窗口的控制、通过设置窗口更改显示行为,但遇到了一些实际上的限制,比如窗口状态变更后监听可能失效、MainWindow 的引用生命周期等问题。虽然“个性化拓展”功能暂时搁浅,但这也让我意识到未来如何规划组件化开发更加合理。
期待我下一次重构他的时候,可能会是下周,也可能会是明年,不过,我都记着呢!
——By;Oldmeat