D3D11_Chili_Tutorial(1):关于 Win API 及其封装

本文详细介绍使用WinAPI进行游戏窗口开发的过程,涵盖从创建窗口到处理键盘鼠标输入等关键技术环节。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1:WINAPI篇——进入点WinMain

这些都是 Chili 的习惯设置,我们也跟着学习一下:

创建好项目后,这个 Multi-processor Compilation 我们选 Yes。
在这里插入图片描述
选 Favor fast code (/Ot):
在这里插入图片描述
release配置下添加一个预定义宏 NDEBUG :
在这里插入图片描述
然后在 release 下的运行时库希望链接到 静态库 版本,而不是默认的 dll :
在这里插入图片描述
debug也一样:
在这里插入图片描述
这样在把 exe 文件给别人的时候,他们就不需要下载 visual studio 之类的了!这就很方便。

回到所有配置:

浮点模型改为 fast (因为我们不用太关心精度):
在这里插入图片描述

C++语言标准选最新:
在这里插入图片描述

subsystem 选 Windows:
在这里插入图片描述
从此 entry point 就变成了 WinMain:

#include <Windows.h>

int CALLBACK WinMain(
	HINSTANCE hInstance,
	HINSTANCE hPrevInstance,
	LPSTR	  lpCmdLine,
	int       nCmdShow)
{
	return 0;
}

关于窗口,我们用 API 先注册,然后创建instance,注意,这个窗口并非 C++ 类。
在这里插入图片描述

2:创建窗口

HINSTANCE 是一个指向我们需要的结构的指针,比如加载进内存什么的。

关于WinMain:https://docs.microsoft.com/zh-cn/windows/win32/learnwin32/winmain–the-application-entry-point

在这里插入图片描述
CALLBACK 是函数的修饰符(Modifier),这告诉编译器应该如何调用这个函数,即 stdcall
在这里插入图片描述
WIN32程序必须使用这样的方式,因为 WINAPI 使用 stdcall ,其他使用 WINAPI 创建的函数也要使用stdcall

在这里插入图片描述
Ex版本,即标准版本,也是新版本。

Windows GDI(The Microsoft Windows graphics device interface (GDI)):
在这里插入图片描述

Microsoft Windows图形设备接口 (GDI) 使应用程序能够在视频显示器和打印机上使用图形和格式化文本。
Windows的应用程序不会直接访问图形硬件。 相反,GDI 代表应用程序与设备驱动程序交互。
参考:https://docs.microsoft.com/zh-cn/windows/win32/gdi/windows-gdi

运行,出现了一个窗口,但是并不能做什么(因为还没有处理窗口消息):
在这里插入图片描述

3:消息循环

为了理解消息,首先要理解事件驱动编程(Event Driven Programming)

比如打开一个文本编辑器,作为一个窗口,如果什么也不点击则什么也不会发生,点击后就会改变自己的样式,重新绘制UI;输入文字后,就接收到了键盘消息,然后便把相应的数据存入缓冲中;然后根据缓存数据来更新界面,显示这些文字。当我们不再输入又是什么也不干,等待其他事件。闪烁的光标是因为光标闪烁事件被一个 Timer 控制。

这种方法效率很高,但是游戏中往往不是这样的。因为就算没有输入,游戏也要每秒几十次地更新。比如AI的坐标、子弹的位置。

我们要处理的窗口消息(Window Message)本质上来说就是事件(Event)。
在这里插入图片描述
直接参考 D3D12 龙书插图:
在这里插入图片描述
对应的英文示意图:
请添加图片描述
于是这一节加上窗口事件处理:

// message pump
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0) > 0)
{
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

运行发现窗口此时已经可以拖拽、最小与关闭:
在这里插入图片描述
但是后台进程仍然存在,再次运行会报错(fatal error LNK1168),需要任务管理器手动关进程:
在这里插入图片描述
这是怎么回事呢?是因为我们之前所用的是默认的窗口处理函数wc.lpfnWndProc = DefWindowProc;,它只会关闭窗口,因为不知道一个进程有几个窗口,因此只关窗口不关进程,于是我们需要为主窗口自己写一个窗口过程函数:

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
	switch (msg)
	{
	case WM_CLOSE:
		PostQuitMessage(69);  // 把退出消息送往消息队列,自定义 69 代表关闭应用进程
		break;
	default:
		break;
	}
	return DefWindowProc(hWnd, msg, wParam, lParam);
}

4:窗口消息

窗口消息大全(只需要谷歌 list of windows messages):
https://wiki.winehq.org/List_Of_Windows_Messages

chili 写了一个简单的代码来测试这些窗口消息,可以看到即使我们什么也没干也会输出一堆东西:
在这里插入图片描述
我贴过来运行如下:
在这里插入图片描述
这里需要将 OutputDebugString 改为 OutputDebugStringA,否则宽字符的和const char* 会不匹配失败。

节选输出的最后一段:

WM_NCMOUSEMOVE              LP: 0x02440b22   WP: 0x00000012
WM_NCHITTEST                LP: 0x02400b25   WP: 0x00000000
WM_SETCURSOR                LP: 0x02000012   WP: 0x00140e7c
WM_NCMOUSEMOVE              LP: 0x02400b25   WP: 0x00000012
WM_NCMOUSELEAVE             LP: 0x00000000   WP: 0x00000000
WM_NCACTIVATE               LP: 0x00000000   WP: 0x00000000
WM_ACTIVATE                 LP: 0x00000000   WP: 0x00000000
WM_ACTIVATEAPP              LP: 0x0000558c   WP: 0x00000000
WM_KILLFOCUS                LP: 0x00000000   WP: 0x00000000
WM_IME_SETCONTEXT           LP: 0xc000000f   WP: 0x00000000
WM_IME_NOTIFY               LP: 0x00000000   WP: 0x00000001

可以看到学chili这样就非常方便。

而 WPARAM 和 LPARAM 是干嘛的,可以参考:
https://docs.microsoft.com/zh-cn/windows/win32/inputdev/wm-keydown

5:WM_CHAR 和鼠标

什么时候会 WM_CHAR 呢?

我们上一节处理了 WM_KEYDOWN 和 WM_KEYUP,对于 WM_CHAR ,当按下 F1 F2 之类的时候并不会有这个消息,只有当对应字符的时候,比如 F、空格、回车,才会有这个消息。

并且,WM_KEYDOWN 对大小写不敏感(即d和D是一样的),但是 WM_CHAR 大小写敏感。

因此,当你要输入文本消息的时候(比如角色起名),使用 WM_CHAR ;当你只是说要用 D 键代表向右移动,而不管大小写,则用 WM_KEYDOWN 。

而 TranslateMessage 有一个功能就是在适当的条件下把 WM_KEYDOWN 同时转换成 WM_CHAR 。

随后我们添加对 WM_CHAR 的处理(和chili有所区别的是,我们选择用 wstring ,宽字符版,因为是unicode,此时 c_str() 就是返回 const wchar_t* 类型的,用string返回const char* 会报错):

case WM_CHAR:
	{
		static std::wstring title;
		title.push_back((char)wParam);
		SetWindowText(hWnd, title.c_str());
	}
	break;

鼠标左键按下的事件:

case WM_LBUTTONDOWN:
	{
		const POINTS pt = MAKEPOINTS(lParam);
		std::wstringstream oss;
		oss << "(" << pt.x << "," << pt.y << ")";
		SetWindowText(hWnd, oss.str().c_str());
	}
	break;

6:窗口框架

这一节把整个窗口(Win32 API)封装了一下。

我们这里把窗口的注册析构都封装在一个单例类中,
在这里插入图片描述

构造函数比较关键:

// Window Stuff
Window::Window(int width, int height, const wchar_t* name) noexcept
{
	// calculate window size based on desired client region size
	RECT wr;
	wr.left = 100;
	wr.right = width + wr.left;
	wr.top = 100;
	wr.bottom = height + wr.top;
	AdjustWindowRect(&wr, WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU, FALSE);
	// create window & get hWnd
	hWnd = CreateWindow(
		WindowClass::GetName(), name,
		WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU,
		CW_USEDEFAULT, CW_USEDEFAULT, wr.right - wr.left, wr.bottom - wr.top,
		nullptr, nullptr, WindowClass::GetInstance(), this
	);
	// show window
	ShowWindow(hWnd, SW_SHOWDEFAULT);
}

注意 CreateWindow 最后传了一个返回 HINSTANCE 的 GetInstance 函数,和一个 this 指针。

我们通常说要多大的窗口(比如 640 * 480),指的是整个 Client 区域,而不包括标题栏和边框:
在这里插入图片描述
所以上面代码的 AdjustWindowRect 就帮助调整窗口的大小。

而窗口消息处理我们是这样的:

LRESULT CALLBACK Window::HandleMsgSetup(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) noexcept
{
	// use create parameter passed in from CreateWindow() to store window class pointer at WinAPI side
	if (msg == WM_NCCREATE)
	{
		// extract ptr to window class from creation data
		const CREATESTRUCTW* const pCreate = reinterpret_cast<CREATESTRUCTW*>(lParam);
		Window* const pWnd = static_cast<Window*>(pCreate->lpCreateParams);
		// set WinAPI-managed user data to store ptr to window class
		SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(pWnd));
		// set message proc to normal (non-setup) handler now that setup is finished
		SetWindowLongPtr(hWnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&Window::HandleMsgThunk));
		// forward message to window class handler
		return pWnd->HandleMsg(hWnd, msg, wParam, lParam);
	}
	// if we get a message before the WM_NCCREATE message, handle with default handler
	return DefWindowProc(hWnd, msg, wParam, lParam);
}

查看 MSDN 我们知道,WM_NCCREATE 在一个窗口创建之初就会发出。

参考:
https://docs.microsoft.com/zh-cn/windows/win32/winmsg/wm-nccreate

重点关注这个链接,可以看到,lParam 为指向 CREATESTRUCTA 结构的指针,而这个结构中的lpCreateParams就是在CreateWindow中我们传递的this指针:
在这里插入图片描述
因此上面代码我们这样转换了:

const CREATESTRUCTW* const pCreate = reinterpret_cast<CREATESTRUCTW*>(lParam);
Window* const pWnd = static_cast<Window*>(pCreate->lpCreateParams);

先转回这个结构,然后回到我们自定义的窗口指针。

CreateWindow 和这里的 CREATESTRUCTA 之间的关系,我们可以参考下面的链接:
https://docs.microsoft.com/zh-cn/windows/win32/api/winuser/nf-winuser-createwindoww

从下图这里我们可以得知有这样的相关性:
在这里插入图片描述
在这里插入图片描述

回到那段代码,其中的 SetWindowLongPtr 方法就允许在 WINAPI 端存储数据,与窗口相对。
在这里插入图片描述
所以这里我们是把窗口实例的指针存储在 WINAPI 端,这在窗口自己和创建它的类之间建立了链接。

// set WinAPI-managed user data to store ptr to window class
SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(pWnd));
// set message proc to normal (non-setup) handler now that setup is finished
SetWindowLongPtr(hWnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&Window::HandleMsgThunk));

参考:https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowlongptra

这里 Chili 命名 Thunk 我猜就是一个转换的意思。

7:自定义图标 / 处理异常

ChiliException.h:

#pragma once
#include <exception>
#include <string>

class ChiliException : public std::exception
{
public:
	ChiliException(int line, const char* file) noexcept;
	const char* what() const noexcept override;
	virtual const char* GetType() const noexcept;
	int GetLine() const noexcept;
	const std::string& GetFile() const noexcept;
	std::string GetOriginString() const noexcept;
private:
	int line;
	std::string file;
protected:
	mutable std::string whatBuffer;
};

line、file:错误出现的行数和错误出现的文件,然后用 what 打印出来。然后 GetType 函数将给出异常类型的名称。

对于 what 函数:

const char* ChiliException::what() const noexcept
{
	std::ostringstream oss;
	oss << GetType() << std::endl
		<< GetOriginString();
	whatBuffer = oss.str();
	return whatBuffer.c_str();
}

我们不直接返回 oss.str().c_str() 是怕what函数结束它就被析构了,这不好;所以用了一个 whatBuffer 去接住。

然后我们在 window 类中加一个异常类:
在这里插入图片描述
这里的 TranslateErrorCode 将把 HRESULT 类型的错误代码转换为字符串。

C++异常体系貌似只支持 char ,不支持Unicode??

好吧,最后我妥协了:
在这里插入图片描述
把这个字符集,从原来的 unicode 改成 not set 了:
在这里插入图片描述
最后代码和 Chili 一模一样了。

此时我强行抛一个异常就会像这样:
在这里插入图片描述
在这里插入图片描述

然后在这个 resource file 这里选择 add -> resource:
在这里插入图片描述
载入图标就好啦:
在这里插入图片描述
对于这个 ICON:
在这里插入图片描述
我们就可以这样用了(注意上面是 define IDI_ICON1,我们也可以换成别的名字用):
在这里插入图片描述
然后就有这个 ICON 了:
在这里插入图片描述

8:键盘输入

这里我们可以认为客户端就是游戏逻辑,服务端就是 window system:
在这里插入图片描述
我们希望游戏逻辑在把消息传入窗口程序前,可以获取键盘按键的信息。比如某个时间点上,有哪个按键被按下了。并且可以处理各种按键按下,释放事件。我们将其作为一个独立的键盘类和键盘对象,嵌入到我们的窗口程序中,而不是直接写进消息处理模块中,导致代码庞杂而难以阅读。将其模块化是一个很好的处理方式。

键盘对象要面对两边,一边是窗口程序的接口,另一边是客户端。

面对窗口的接口要处理窗口事件,并且更新键盘对象的状态。每个事件都要对应一个函数,比如键盘按下,键盘释放。客户端这边有个函数,当你按下按键时会传递一个参数,比如 VK_ENTER ,如果当前正在按 Enter 键,那么返回真。ReadChar,将从封装在键盘类的字符数组中读取字符。然后键盘类有两种主要数据,一种是当前键盘被按下的状态,然后是事件队列,包含键盘事件,比如键盘按下/释放和 WM_CHAR。

下面来看代码:

这里我们将键盘作为 window 的一个子系统,所以将window设置为它的友元类:
在这里插入图片描述
对于键盘类的方法,光看作用域就知道是面向客户端还是window了(public显然是暴露出去给客户端用的,private由于window是友元类,所以是内部以及window用的):
在这里插入图片描述

写好键盘类后,我们加入window类中:
在这里插入图片描述
然后我们就可以加入键盘的处理了:
在这里插入图片描述
对于连续按键,比如人物一直按着D跑动,查看 MSDN :
在这里插入图片描述
于是我们这样:

/*********** KEYBOARD MESSAGES ***********/
case WM_KEYDOWN:
	// syskey commands need to be handled to track ALT key (VK_MENU) and F10
case WM_SYSKEYDOWN:
	if (!(lParam & 0x40000000) || kbd.AutorepeatIsEnabled()) // filter autorepeat
	{
		kbd.OnKeyPressed(static_cast<unsigned char>(wParam));
	}
	break;

通过检测它的第30位(注意上图30bit位置的英文释义)。

注意这里我们即处理了 WM_KEYDOWN 又处理了 WM_SYSKEYDOWN ,是因为像 ALT 这样的按键就属于系统按键,因此要加上 WM_SYSKEYDOWN 。

此时测试就一切正常了:

代码:

if (wnd.kbd.KeyIsPressed(VK_MENU))
{
	MessageBox(nullptr, "Something Happon!", "The alt key was pressed", MB_OK | MB_ICONEXCLAMATION);
}

在这里插入图片描述

效果:
在这里插入图片描述

9:鼠标输入

这一节添加一个鼠标类,本质和键盘类差不多:
在这里插入图片描述
然后同样在window类中加入一个Mouse成员,并且处理鼠标事件:

public:
	Keyboard kbd;
	Mouse mouse;

在这里插入图片描述
为了便于测试,我们给 Window 类加入一个 SetTitle 方法:

void Window::SetTitle(const std::string& title)
{
	if (SetWindowText(hWnd, title.c_str()) == 0)
	{
		throw CHWND_LAST_EXCEPT();
	}
}

这里我们修复了一个 bug :

旧版本:

if (FAILED(AdjustWindowRect(&wr, WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU, FALSE)))
{
	throw CHWND_LAST_EXCEPT();
}

新版本:

if (AdjustWindowRect(&wr, WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU, FALSE) == 0)
{
	throw CHWND_LAST_EXCEPT();
}

我们不使用 FAILED 宏,它是用于检查 HRESULT 的,但并不是所有的窗口函数都返回 HRESULT ,有些仅仅是返回整数。

这里我们不使用 FAILED 宏,而是直接判断 AdjustWindowRect 是不是等于0。

接着我们测试一下鼠标类是否成功:

winmain.cpp中加入这些代码:

#include <sstream>

...

// test code
while (!wnd.mouse.IsEmpty())
{
	const auto e = wnd.mouse.Read();
	switch (e.GetType())
	{
	case Mouse::Event::Type::Leave:
		wnd.SetTitle("Gone!");
		break;
	case Mouse::Event::Type::Move:
	{
		std::ostringstream oss;
		oss << "Mouse moved to (" << e.GetPosX() << "," << e.GetPosY() << ")";
		wnd.SetTitle(oss.str());
	}
	break;
	}
}

加入的位置如下图所示:
在这里插入图片描述
可以看到很成功:
在这里插入图片描述
对于Mouse类中的 Leave 和 Enter:
在这里插入图片描述
这在鼠标拖拽的时候很有用,比如这个游戏:
在这里插入图片描述
鼠标选中卡牌并且向外拖的时候,即使鼠标一直向外移动,但是它仍然要响应鼠标事件(比如此时我还可以上下移动)。

对于鼠标类中的这个成员int wheelDeltaCarry = 0;,查看MSDN文档:
在这里插入图片描述
我们知道要记录下滚轮的总的滚动值(因为有的精度高的滚轮可能要滚两下才能触发某些下限阈值,见文档,比如有的阈值是120,精度高的鼠标滚一下可能是60 [?] )

10:应用层

这一节将学习创建应用层逻辑。

一个游戏窗口的基本框架:
在这里插入图片描述
它将会有多个窗口,游戏逻辑也与一些接口,比如鼠标、键盘相关,当然也与游戏内的实体相关。
在这里插入图片描述
在这里插入图片描述
同时新加一个计时器:
在这里插入图片描述
Mark的返回值为从上次调用Mark起到现在所经历的时间。Peek 也是同样的作用,但是不会重设Mark计时器。

然后在 App.h 中就添加这个计时器,在cpp中使用。

这里在App.cpp中的Go函数循环中还有一个问题,就是只有当我们接收到消息的时候,我们才会去做一些事情,但是我们的游戏逻辑循环肯定是要一直循环的啊!(之前就讲过,比如子弹位置要更新之类的)

所以这里不能用 GetMessage ,而要用 PeekMessage:

旧代码:
在这里插入图片描述
新代码:

窗口类中添加:
在这里插入图片描述
static 是因为要处理所有的窗口。于是这里返回的是一个 int 或者是一个空的 optianal ,这对于调用ProcessMessages 的地方很好。所以在 ProcessMessages 和之前的消息处理函数基本一样。

PeekMessage 的功能就是从消息队列中复制一个消息出来,看看这个消息是什么,但不会把这个消息从消息队列中删掉(查阅文档,其实貌似是指定了 PM_NOREMOVE 才这样)。如果没有消息的话,它会立即返回。
在这里插入图片描述

std::optional<int> Window::ProcessMessages()
{
	MSG msg;
	// while queue has messages, remove and dispatch them (but do not block on empty queue)
	while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
	{
		// check for quit because peekmessage does not signal this via return val
		if (msg.message == WM_QUIT)
		{
			// return optional wrapping int (arg to PostQuitMessage is in wparam) signals quit
			return (int)msg.wParam;
		}

		// TranslateMessage will post auxilliary WM_CHAR messages from key msgs
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}

	// return empty optional when not quitting app
	return {};
}

所有的 GetMessage 的返回值将直接告诉你它是不是 QUIT 消息,而 PeekMessage 不会,所以我们要手动检查。

App.cpp中就简单多了:

int App::Go()
{
	while (true)
	{
		// process all messages pending, but to not block for new messages
		if (const auto ecode = Window::ProcessMessages())
		{
			// if return optional has value, means we're quitting so return exit code
			return *ecode;
		}
		DoFrame();
	}
}

ecode 类型是 optional,optional 会重载 BOOL 类型,如果 optional 不为空将返回值真。在我们这里,那这就是 WM_QUIT 消息,那我们返回 ecode 退出;否则调用 DoFrame,执行游戏逻辑。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值