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

B站影视 2025-01-20 09:04 2

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

游戏主窗体

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

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

需求 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 效果》(https://blog.csdn.net/ntwilford/article/details/5656633)。同时,要让 Unity 经由 Camera 渲染后的画面背景颜色透明,只需要将 MainCamera 的 ClearFlags 设置为 SolidColor、BackGround 设置为纯黑色,并将 PlayerSetting 中的 Use DXGI Filp Model Swapchain for D3D1 取消勾选就能够实现。这里照抄了这篇《Unity 制作自适应透明背景(PC 端)》(https://blog.csdn.net/weixin_52847003/article/details/120936923)。

另外,还需要让 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, , 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);
}
///
/// 获取任务栏高度
///
/// 任务栏高度
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;
}
}
///
/// 设置窗体用到的和一些常用 Win32API
///
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;

public static extern IntPtr FindWindow(string className, string winName);

public static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam, uint fuFlage, uint timeout, IntPtr result);

public static extern bool EnumWindows(EnumWindowsProc proc, IntPtr lParam);
public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);

public static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter, string className, string winName);

public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);

public static extern IntPtr SetParent(IntPtr hwnd, IntPtr parentHwnd);
//使用查找任务栏

public static extern IntPtr FindWindow(string strClassName, int nptWindowName);
//获取窗口位置以及大小

public static extern bool GetWindowRect(IntPtr hWnd, ref RECT lpRect);

public static extern IntPtr FindWindowExA(IntPtr hWndParent, IntPtr hWndChildAfter, string lpszClass, string lpszWindow);
//设置窗口位置,尺寸

public static extern bool SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
//设置无 windows 自带边框

public static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int _nIndex, int dwNewLong);

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
{
///
/// 配置窗体
///
public partial class SysForm : Form
{
//Program 主函数的参数,在构造窗体时当作参数读取
public string args= ;
//消息管道名,互相使用同一个管道名就能实现通信了
private string PipeName;
public SysForm(string args)
{
InitializeComponent;
//获取 Unity 端启动窗体时传入的参数,并将其读取为管道名
this.args = args;
if (args.Length>0&&!string.IsOrEmpty(args[0]))
{
this.PipeName = args[0];
}
this.label_title.Text= PipeName;
}
///
/// 发送数据至 Unity 端
///
///

消息内容


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.IsOrEmpty(textBox_showMsg.Text))
{
SendData(new PipeMsg
{
Type = PipeMsgType.ShowMsg,
arg1 = textBox_showMsg.Text
});
}
}
}
///
/// 消息对象实体
///
public class PipeMsg
{
public PipeMsgType Type;
public string arg1;
}
///
/// 消息类性
///
public enum PipeMsgType
{
GameStart = 0,
GamePause,
GameEnd,
ShowMsg
}
}

我们需要一个参数来构造窗体,记得在 Program.cs 中加上参数:

using System;
using System.Windows.Forms;

namespace DesktopHookForms
{
internal static class Program
{
///
/// 应用程序的主入口点。
///
///

应用程序启动时传入的参数


[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 processingMsg = new ConcurrentQueue;

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;
///
/// 调用发布的 Winform 窗体作为配置窗口
///
public void OpenConfigForm
{
if (process == || 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;
///
/// 异步等待管道消息
///
///
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("等待消息结束");
}

///
/// 退出程序
///
public void Quit
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit;
#endif
}
private void OnApplicationQuit
{
//若设置窗体还未关闭,则先关闭设置窗体
if (process != && !process.HasExited)
{
process.CloseMainWindow;
}

//通过 cancellationToken 取消 WaitData 的等待,若不取消 WaitData 可能会变为阻塞线程一直存在
waitDataCancellationToken.Cancel;

}
}
///
/// 消息对象实体
///
public class PipeMsg
{
public PipeMsgType Type;
public string arg1;
}
///
/// 消息类性
///
public enum PipeMsgType
{
GameStart = 0,
GamePause,
GameEnd,
ShowMsg
}

展示一下最后的实现效果:

本篇文章到此结束,我也摩拳擦掌,准备狠狠地做一些新游戏。如果你对合作开发或交流感兴趣,又或者有什么好的建议,欢迎评论及联系我哦!

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

来源:indienova

相关推荐