游戏主窗体
最近摸鱼时接触了桌面挂机游戏,游戏画面一般会悬浮置顶放置在状态栏上方区域,不“妨碍”使用的同时能持续游玩,可谓是高效摸鱼利器。与一般开发不同,桌面挂机游戏需要窗体置顶等非游戏引擎内的开发内容。这篇文章应该能在某些方面提供一些帮助。
最开始开发时,我是以开发壁纸游戏的想法在制作,直到效果基本实现了才想到,挂机游戏是悬浮置顶运行而不是运行在桌面底部,于是只好推倒重来。不过好在都是围绕窗体属性开发,也算是丰富了相关知识。后续或许可以写篇壁纸游戏开发的文章,哈哈。那么,在项目正式开始前,先来大概描述一下桌面挂机游戏的显示需求:
需求 1. 窗体只会占据屏幕底部的一部分,会悬浮置顶显示,不会被其他窗体覆盖;
需求 2. 窗体是无边框的,且不会有 Windows 窗体默认的三个按钮;
需求 3. 若设想的游戏窗体背景是透明的,应该保证我们能看到其后面的内容。
现在,让我们逐一实现上述三个需求。请注意,本文只考虑了 Windows 平台的使用需求,Windows 通过 Win32 和 DWM 等头文件向开发者提供了许多系统和窗体相关的开发接口,这些接口直接面向 C++。C#脚本则可以通过 DLLImport 来调用相关函数。:
解决 1:我们可以直接通过 Win32 的 SetWindowPos 函数实现,这个函数可以直接设置窗体的分辨率、置顶或放于底部。
解决 2:可以通过 Win32 的 SetWindowLong 来实现,这个函数可以调整窗体的诸多属性,不止是边框按钮,诸如浏览器的 Alt 菜单栏也可以在这其中添加,具体可参考官方文档。
解决 3:想要让游戏背景透明,我们需要让 Windows 窗体玻璃化,保证其在桌面平台能够有透明效果,这需要调用 DWM 的 DwmExtendFrameIntoClientArea 函数。DWM 提供了很多窗体绘制的功能,还能够实现毛玻璃、添加图标等效果,具体可以参考这篇《使用 DWM 实现 Aero Glass 效果》。同时,要让 Unity 经由 Camera 渲染后的画面背景颜色透明,只需要将 MainCamera 的 ClearFlags 设置为 SolidColor、BackGround 设置为纯黑色,并将 PlayerSetting 中的 Use DXGI Filp Model Swapchain for D3D1 取消勾选就能够实现。这里照抄了这篇《Unity 制作自适应透明背景(PC 端)》。
另外,还需要让 Unity 发布后允许修改分辨率以及后台运行,具体可以按需参考下图修改。
最终效果能够通过以下代码实现。将以下脚本组件添加到场景中,发布、运行就能够实现显示效果了!
using System; using System.Runtime.InteropServices; using UnityEngine; public class MyWindow : MonoBehaviour { //项目名称,请填写为 PlayerSetting 中的 ProductName private string projectName = "DesktopHook"; IntPtr currentIntPtr; private IntPtr programIntPtr = IntPtr.Zero; Resolution[] resolutions;//分辨率 private Rect screenPosition;//最终的屏幕的位置和长宽 void Awake() { #if UNITY_EDITOR print("unity 内运行程序"); return; #endif //获得游戏窗体的句柄,请注意 projectName 应当取值为 PlayerSetting 中的 ProductName currentIntPtr = Win32.FindWindowExA(IntPtr.Zero, IntPtr.Zero, null, projectName); //获取当前屏幕分辩率 resolutions = Screen.resolutions; //游戏窗体宽度,这里设置为与最大分辨率同宽 screenPosition.width = resolutions[resolutions.Length - 1].width; //游戏窗体高度,这里设置为与任务栏一起占用屏幕底部 1/3 的高度 screenPosition.height = Screen.currentResolution.height / 3 - GetTaskBarHeight(); //在 Unity 中设置游戏分辨率 Screen.SetResolution((int)screenPosition.width, (int)screenPosition.height, false); screenPosition.x = 0; screenPosition.y = Screen.currentResolution.height / 3 * 2; //取消窗口自带的边框 Win32.SetWindowLong(currentIntPtr, Win32.GWL_STYLE, Win32.GetWindowLong(IntPtr.Zero, Win32.GWL_STYLE) & ~Win32.WS_BORDER & ~Win32.WS_THICKFRAME & ~Win32.WS_CAPTION); //设置游戏窗体的分辨率、位置、置顶显示 bool result = Win32.SetWindowPos(currentIntPtr, -1, (int)screenPosition.x, (int)screenPosition.y, (int)screenPosition.width, (int)screenPosition.height, Win32.SWP_SHOWWINDOW); ////设置窗体玻璃化 var margins = new Win32.MARGINS() { cxLeftWidth = -1 }; Win32.DwmExtendFrameIntoClientArea(currentIntPtr, ref margins); } /// <summary> /// 获取任务栏高度 /// </summary> /// <returns>任务栏高度</returns> private int GetTaskBarHeight() { int taskbarHeight = 10; IntPtr hWnd = Win32.FindWindow("Shell_TrayWnd", 0); //找到任务栏窗口 Win32.RECT rect = new Win32.RECT(); Win32.GetWindowRect(hWnd, ref rect); //获取任务栏的窗口位置及大小 taskbarHeight = (int)(rect.Bottom - rect.Top); //得到任务栏的高度 return taskbarHeight; } } /// <summary> /// 设置窗体用到的和一些常用 Win32API /// </summary> public static class Win32 { public const int WS_THICKFRAME = 262144; public const int WS_BORDER = 8388608; public const uint SWP_SHOWWINDOW = 0x0040; public const int GWL_STYLE = -16; public const int WS_CAPTION = 0x00C00000; public const int GWL_EXSTYLE = -20; public const int WS_EX_LAYERED = 0x00080000; public const int LWA_COLORKEY = 0x00000001; public const int LWA_ALPHA = 0x00000002; public const int WS_EX_TRANSPARENT = 0x20; [DllImport("Dwmapi.dll")] public static extern uint DwmExtendFrameIntoClientArea(IntPtr hWnd, ref MARGINS margins); [DllImport("user32.dll")] public static extern IntPtr SetWindowLong(IntPtr hwnd, int _nIndex, int dwNewLong); //当前窗口 [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern IntPtr FindWindow(string className, string winName); [DllImport("user32.dll")] public static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam, uint fuFlage, uint timeout, IntPtr result); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc proc, IntPtr lParam); public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); [DllImport("user32.dll")] public static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter, string className, string winName); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow); [DllImport("user32.dll")] public static extern IntPtr SetParent(IntPtr hwnd, IntPtr parentHwnd); //使用查找任务栏 [DllImport("user32.dll")] public static extern IntPtr FindWindow(string strClassName, int nptWindowName); //获取窗口位置以及大小 [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, ref RECT lpRect); [DllImport("user32.dll")] public static extern IntPtr FindWindowExA(IntPtr hWndParent, IntPtr hWndChildAfter, string lpszClass, string lpszWindow); //设置窗口位置,尺寸 [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); //设置无 windows 自带边框 [DllImport("user32.dll")] public static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int _nIndex, int dwNewLong); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; //最左坐标 public int Top; //最上坐标 public int Right; //最右坐标 public int Bottom; //最下坐标 } public struct MARGINS { public int cxLeftWidth; public int cxRightWidth; public int cyTopHeight; public int cyBottomHeight; } }
现在,我们的游戏主窗体已经可以置顶运行,在游戏过程中,会需要有一些设置、菜单等功能页面让玩家浏览,而大多数类似游戏都不会把对应页面直接放在置顶游戏窗体内(可能是窗体太小了,还会置顶占用)。
通过 Unity 等引擎发布的程序,在 Windows 看来是一个单窗体程序,因而,游戏内制作的 UI 窗体都只能在游戏窗体中显示,而不是一个独立的 Windows 窗体。如果我们想要制作一个可以在游戏窗体外随意显示的功能页面(比如设置/商店等页面)就需要另辟蹊径,这就是下一节的内容啦。
窗体通信
首先简述一下实现思路。配置窗体,我选择使用同样采用 C# 语言的 .net framework 窗体来制作,窗体通过 NamedPipe(命名管道)与 Unity 程序交流。
交流过程中,Unity 程序会作为 Server,配置窗体作为 Client。由 Server 打开配置窗体,并等待发送消息。在我们点击 Client 中的各按钮时,向 Server 程序建立连接发送命令。
通信具体使用的是 System 中 NamedPipe 的相关类型,如果你了解其他 C# 语言的窗体应用开发(甚至直接用另一个 Unity 项目),也一样可以参考实现。
配置窗体 Client 实现
框架选择 .net framework4.8,我这里的项目名称为 DesktopHookForms,可以按需修改。
然后,从工具箱中新拖几个 Button 和 Label。如果你从未接触过 .netfreamwork,可以看看这篇《Windows 窗体学这一篇就够了(C#控件讲解)》,非常简单。
效果大概如下图(非常简陋 hh,重在功能实现):
接下来就是逻辑实现了。在窗体启动时,读取启动窗体的 arg 参数,从中获取 NamedPipe 的管道名。随后,当我们点击各个命令按钮,就执行通信函数 SendData:通过 NamedPipeClientStream 与 Unity 窗体建立连接,随后将序列化为 json 的命令数据发送过去。以下是窗体的内容代码:
using System; using System.IO; using System.IO.Pipes; using System.Security.Principal; using System.Windows.Forms; namespace DesktopHookForms { /// <summary> /// 配置窗体 /// </summary> public partial class SysForm : Form { //Program 主函数的参数,在构造窗体时当作参数读取 public string[] args= null; //消息管道名,互相使用同一个管道名就能实现通信了 private string PipeName; public SysForm(string[] args) { InitializeComponent(); //获取 Unity 端启动窗体时传入的参数,并将其读取为管道名 this.args = args; if (args.Length>0&&!string.IsNullOrEmpty(args[0])) { this.PipeName = args[0]; } this.label_title.Text= PipeName; } /// <summary> /// 发送数据至 Unity 端 /// </summary> /// <param name="msg">消息内容</param> private void SendData(PipeMsg msg) { try { string str=Newtonsoft.Json.JsonConvert.SerializeObject(msg); using (NamedPipeClientStream pipeClient = new NamedPipeClientStream("localhost", PipeName, PipeDirection.InOut, PipeOptions.None, TokenImpersonationLevel.None)) { try { pipeClient.Connect(3000); using (StreamWriter sw = new StreamWriter(pipeClient)) { sw.WriteLine(str); sw.Flush(); } } catch (TimeoutException ex) { //超时提示后退出窗体 MessageBox.Show("游戏程序似乎已关闭!"); Application.Exit(); } } } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } //暂停按钮命令 private void button_pause_Click(object sender, EventArgs e) { SendData(new PipeMsg() { Type = PipeMsgType.GamePause, arg1 = "" }); } //开始按钮命令 private void button_start_Click(object sender, EventArgs e) { SendData(new PipeMsg() { Type = PipeMsgType.GameStart, arg1 = "" }); } //结束按钮命令 private void button_end_Click(object sender, EventArgs e) { SendData(new PipeMsg() { Type = PipeMsgType.GameEnd, arg1 = "" }); } //显示消息命令 private void button_showMsg_Click(object sender, EventArgs e) { if (!string.IsNullOrEmpty(textBox_showMsg.Text)) { SendData(new PipeMsg() { Type = PipeMsgType.ShowMsg, arg1 = textBox_showMsg.Text }); } } } /// <summary> /// 消息对象实体 /// </summary> public class PipeMsg { public PipeMsgType Type; public string arg1; } /// <summary> /// 消息类性 /// </summary> public enum PipeMsgType { GameStart = 0, GamePause, GameEnd, ShowMsg } }
我们需要一个参数来构造窗体,记得在 Program.cs 中加上参数:
using System; using System.Windows.Forms; namespace DesktopHookForms { internal static class Program { /// <summary> /// 应用程序的主入口点。 /// </summary> /// <param name="args">应用程序启动时传入的参数</param> [STAThread] static void Main(string[] args) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new SysForm(args)); } } }
编写完成后运行、调试窗体,没问题的话就能在项目根目录 \bin\Debug 路径下找到窗体的可执行文件(exe)。稍后可以将其中的内容放到 Unity 项目的目录附近方便调用。
Unity 窗体 Server 实现
接下来,让我们实现 Unity 端的通信功能,这里我直接在场景 Canvas 上编写了一个脚本,方便控制 Canvas 上的控件来显示命令效果。众所周知,Unity 是一个单线程程序,默认不支持异步编程。为了方便我们异步等待通信消息,我安装了 UniTask 组件。
以下是 Unity 端通信的内容,程序会一直异步执行 WaitData 函数,等待窗体程序建立连接,在收到连接消息后,将其放入 processingMsg 队列,最后在 Update 函数中读取、执行对应的消息。以下是具体的代码:
using Cysharp.Threading.Tasks; using System; using System.Collections.Concurrent; using System.IO; using System.IO.Pipes; using System.Threading; using UnityEngine; public class MyCanvas : MonoBehaviour { //时间文本,用于表示游戏是否暂停 public TMPro.TextMeshProUGUI TimeLabel; //消息文本,用于显示配置窗体发来的消息 public TMPro.TextMeshProUGUI MsgLabel; //管道名,这里随便取了一个,考虑到多开等情况,最好设置一个随机获取的逻辑 private string pipeName = "TestPipe4321"; //游戏是否暂停 bool isGamming = false; //由于 Unity 本身是单线程执行,异步执行的 WaitData 函数无法直接调用 UnityEngine 相关的函数,设置一个队列来存储消息,待主线程调用执行 ConcurrentQueue<PipeMsg> processingMsg = new ConcurrentQueue<PipeMsg>(); void Start() { //修改光标图案,方便区分鼠标是否被游戏窗体占用,记得在 Resources 中添加对应图片 Texture2D img = (Texture2D)Resources.Load("Cursor"); Cursor.SetCursor(img, Vector2.zero, CursorMode.Auto); //异步执行等待数据函数,避免阻塞主线程 WaitData(); } void Update() { //读取消息并根据消息类性,执行具体的命令逻辑 while (processingMsg.Count > 0) { if (processingMsg.TryDequeue(out var msg)) { switch (msg.Type) { case PipeMsgType.GameStart: isGamming = true; break; case PipeMsgType.GamePause: isGamming = false; break; case PipeMsgType.GameEnd: Quit(); break; case PipeMsgType.ShowMsg: MsgLabel.text = msg.arg1; break; } } } //如果游戏没暂停就持续刷新时间 if (isGamming) { TimeLabel.text = DateTime.Now.ToString(); } } //配置窗体进程 System.Diagnostics.Process process; /// <summary> /// 调用发布的 Winform 窗体作为配置窗口 /// </summary> public void OpenConfigForm() { if (process == null || process.HasExited) { process = new System.Diagnostics.Process(); process.StartInfo.FileName = "C:/DesktopHookForms.exe";//注意修改为窗体程序的 exe 名称 //将管道名作为参数传入窗体,防止管道名冲突,后续若窗体内容较复杂也可以自定义消息内容 process.StartInfo.Arguments = pipeName; process.StartInfo.UseShellExecute = true; process.Start(); } } CancellationTokenSource waitDataCancellationToken = new CancellationTokenSource(); /// <summary> /// 异步等待管道消息 /// </summary> /// <returns></returns> private async UniTask WaitData() { Debug.Log("开始等待消息"); while (true) { try { NamedPipeServerStream pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 2, PipeTransmissionMode.Message, PipeOptions.Asynchronous); try { await pipeServer.WaitForConnectionAsync(waitDataCancellationToken.Token); } catch (OperationCanceledException) { //当捕获到操作取消异常则直接跳出循环结束线程 break; } StreamReader sr = new StreamReader(pipeServer); string con = sr.ReadLine(); PipeMsg msg = (PipeMsg)JsonUtility.FromJson(con, typeof(PipeMsg)); sr.Close(); //将消息放入执行队列,由主线程的 Update 函数读取执行 processingMsg.Enqueue(msg); } catch (Exception ex) { Debug.Log("等待消息异常:" + ex.Message); } } Debug.Log("等待消息结束"); } /// <summary> /// 退出程序 /// </summary> public void Quit() { #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif } private void OnApplicationQuit() { //若设置窗体还未关闭,则先关闭设置窗体 if (process != null && !process.HasExited) { process.CloseMainWindow(); } //通过 cancellationToken 取消 WaitData 的等待,若不取消 WaitData 可能会变为阻塞线程一直存在 waitDataCancellationToken.Cancel(); } } /// <summary> /// 消息对象实体 /// </summary> public class PipeMsg { public PipeMsgType Type; public string arg1; } /// <summary> /// 消息类性 /// </summary> public enum PipeMsgType { GameStart = 0, GamePause, GameEnd, ShowMsg }
展示一下最后的实现效果:
本篇文章到此结束,我也摩拳擦掌,准备狠狠地做一些新游戏。如果你对合作开发或交流感兴趣,又或者有什么好的建议,欢迎评论及联系我哦!
QQ:2763686216
暂无关于此文章的评论。