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,执行游戏逻辑。