在Windows平台实现桌面挂机游戏的开发基础(Unity):游戏主窗体与窗体通信

作者:mioku
2025-01-18
7 5 0

游戏主窗体

最近摸鱼时接触了桌面挂机游戏,游戏画面一般会悬浮置顶放置在状态栏上方区域,不“妨碍”使用的同时能持续游玩,可谓是高效摸鱼利器。与一般开发不同,桌面挂机游戏需要窗体置顶等非游戏引擎内的开发内容。这篇文章应该能在某些方面提供一些帮助。

最开始开发时,我是以开发壁纸游戏的想法在制作,直到效果基本实现了才想到,挂机游戏是悬浮置顶运行而不是运行在桌面底部,于是只好推倒重来。不过好在都是围绕窗体属性开发,也算是丰富了相关知识。后续或许可以写篇壁纸游戏开发的文章,哈哈。那么,在项目正式开始前,先来大概描述一下桌面挂机游戏的显示需求:

需求 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 发布后允许修改分辨率以及后台运行,具体可以按需参考下图修改。

Camera设置

PlayerSetting设置

最终效果能够通过以下代码实现。将以下脚本组件添加到场景中,发布、运行就能够实现显示效果了!

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

本文为用户投稿,不代表 indienova 观点。

近期点赞的会员

 分享这篇文章

mioku 

加把劲骑士! 

您可能还会对这些文章感兴趣

参与此文章的讨论

暂无关于此文章的评论。

您需要登录或者注册后才能发表评论

登录/注册