一、将贴图信息整合到类中
承接上文,在完成地图绘制之后,现在的地图还仅仅只是一块图片而已。我们要将其与地图元素类 MapElement
绑定起来。这里我的想法是,在窗口的显示部分,要有一套完整的贴图用于画面表现。同时也有一套完整、抽象出来的游戏系统,比如发生战斗时,后台要计算攻击伤害,执行一系列函数;而窗口则要显示战斗动画。因此应该画面显示绑定到游戏逻辑中。
(当然,只是自己探索过程中粗浅的理解,或许其实看起来很好笑很不严谨呢?读者随便看看就好)
沿着这个思路,我们对上一章的地图绘制进行修改。注意到在第一章对各个类的定义中,MapElement
类总是要在地面上显示的,因此对这个类指定一个 wchar_t[]
字符串,保存其图片资源文件名.
不过想到地图资源并不会包含特别多不同的材质,所以尝试把所有材质资源命名改为数字。
这样,MapElement 类中存一个数字就可以表示是哪个文件了,不用再另外开字符数组。
MapElement 类:
#pragma once
#include <Windows.h>
#include <iostream>
class MapElement
{
protected:
unsigned int priority = 0; // 绘制优先级
unsigned int px = 0; // 在地图上的x坐标
unsigned int py = 0; // 在地图上的y坐标
unsigned int id = 0; // 图片资源的编号
const bool walkable = 1; // 能否在其上行走. 对于确定的类型,这一值是确定的.
const bool interactable = 1; // 走入是否会发生交互
public:
VOID SetPx(unsigned int x) { px = x; }
VOID SetPy(unsigned int y) { py = y; }
VOID SetID(unsigned int d) { id = d; }
unsigned int GetPx() { return px; }
unsigned int GetPy() { return py; }
unsigned int getID() { return id; }
};
现在尝试在源文件中创建 MapElement 对象,并用该函数绘制出地图。
首先,开一个地图元素的全局数组。
MapElement gameMap[12][12]; // 游戏地图
写一个创建地图的函数 Map_Create()
.
// 前向声明
VOID Map_Create(); // 创建地图
/*...
...*/
// 函数实现
VOID Map_Create()
{
unsigned int mapIndex[12][12] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0,
1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0,
1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0,
0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 0,
1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1
};
for (int i = 0; i < 12; ++i) {
for (int j = 0; j < 12; ++j) {
gameMap[i][j].SetPx(i);
gameMap[i][j].SetPy(j);
gameMap[i][j].SetID(mapIndex[j][i]);
}
}
}
修改 Map_Paint()
:
VOID Map_Paint(HWND hwnd)
{
hdc = GetDC(hwnd);
mdc = CreateCompatibleDC(hdc);
mMapDC = CreateCompatibleDC(hdc);
hMap = CreateCompatibleBitmap(hdc, CELL_SIZE * 12, CELL_SIZE * 12);
SelectObject(mMapDC, hMap);
HBITMAP hTexture[2]; // 材质句柄
// STEP 0: 地图样式
Map_Create();
wchar_t filename[20];
// STEP 1: 加载材质
for (int i = 0; i < 2; ++i) {
wsprintf(filename, L"res/%d.bmp", i);
hTexture[i] = (HBITMAP)LoadImage(NULL, filename, IMAGE_BITMAP, CELL_SIZE, CELL_SIZE, LR_LOADFROMFILE);
}
// STEP 2: 绘制好地图
for (int i = 0; i < 12; ++i) { // 行
for (int j = 0; j < 12; ++j) { // 列
SelectObject(mdc, hTexture[gameMap[i][j].getID()]);
BitBlt(mMapDC, gameMap[i][j].GetPx() * CELL_SIZE, gameMap[i][j].GetPy() * CELL_SIZE, CELL_SIZE, CELL_SIZE, mdc, 0, 0, SRCCOPY);
}
}
// STEP 3: 将地图贴到窗口里
SelectObject(mMapDC, hMap);
BitBlt(hdc, 10, 10, CELL_SIZE * 12, CELL_SIZE * 12, mMapDC, 0, 0, SRCCOPY);
ReleaseDC(hwnd, hdc);
}
其实只是把原来的一些属性封装到了 MapElement
类中,其余并无改变。
二、干员的创建
到这一步,我们终于要在地图上绘制我们的角色了!
为了让窗口能够响应键盘上的消息,我们对窗口过程函数 WndProc
进行修改,在其 switch 语句中加一个 WM_KEYDOWN
的 case,先尝试添加一个输入 Esc 就关闭程序的条件:
switch (message)
{
case WM_KEYDOWN:
switch (wParam)
{
case VK_ESCAPE:
DestroyWindow(hWnd);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
...
调试开始后,按 ESC 窗口就关闭了。消息响应正常。现在我们再将方向按键加进来:
首先添加全局变量
MCharacter gamePlayer; // 控制角色
别忘记这里的 x 和 y 表示的是行和列。也就是说坐标轴的原点在左上角,x 是纵坐标且向下增加,y 是横坐标且向右增加。
还要注意坐标的范围是 [ 0 , 11 ] [0,11] [0,11],而非 [ 1 , 12 ] [1,12] [1,12].
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
DWORD chara_x = gamePlayer.GetPx(), chara_y = gamePlayer.GetPy();
switch (message)
{
case WM_KEYDOWN:
switch (wParam)
{
case VK_ESCAPE:
DestroyWindow(hWnd);
PostQuitMessage(0);
break;
case VK_UP:
if (chara_y > 0)
gamePlayer.SetPy(chara_y - 1);
break;
case VK_DOWN:
if (chara_y < 11)
gamePlayer.SetPy(chara_y + 1);
break;
case VK_LEFT:
if (chara_x > 0)
gamePlayer.SetPx(chara_x - 1);
break;
case VK_RIGHT:
if (chara_x < 11)
gamePlayer.SetPx(chara_x + 1);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
...
写好移动之后,还需要将其绘制出来。
但注意,这里人物是绘制在地面之上的,其肯定不会占满整个正方单元格,所以图片是部分透明的。而位图的透明绘制,自然需要遮罩法了。所以我们得制作一个遮罩贴图:
斯卡蒂的遮罩像素贴图
(当然也是随便做做用于测试的,不是最终方案哈哈)
现在添加一个角色绘制的函数声明:
VOID Chara_Paint(HWND hwnd, MapElement me); // 角色绘制
为了其还能画一些 NPC 和敌人等其他使用透明贴图的地图元素,在参数中加了一个 MapElement
类. 其实现:
VOID Chara_Paint(HWND hwnd, MapElement me)
{
hdc = GetDC(hwnd);
mdc = CreateCompatibleDC(hdc);
wchar_t filename[20];
wsprintf(filename, L"res/%d.bmp", me.getID()); // 贴图编号转为文件名
// 加载贴图
hChara = (HBITMAP)LoadImage(NULL, filename, IMAGE_BITMAP, CELL_SIZE * 2, CELL_SIZE, LR_LOADFROMFILE);
SelectObject(mdc, hChara);
// 透明遮罩法
BitBlt(hdc, me.GetPx() * CELL_SIZE, me.GetPy() * CELL_SIZE, CELL_SIZE, CELL_SIZE, mdc, CELL_SIZE, 0, SRCAND);
BitBlt(hdc, me.GetPx() * CELL_SIZE, me.GetPy() * CELL_SIZE, CELL_SIZE, CELL_SIZE, mdc, 0, 0, SRCPAINT);
ReleaseDC(hwnd, hdc);
}
注意斯卡蒂的图片编号为 2,我们还需要将全局变量 gamePlayer 的编号指定为 2. 发现现在少了个游戏初始化的函数,加入之。
VOID Game_Init(); // 游戏初始化
...
VOID Game_Init()
{
// 现在函数中只有这一句话,未来会有更多需要初始化的操作.
gamePlayer.SetID(2);
}
将这个函数加入到 WinMain()
插入代码的地方去。
现在执行,发现正确生成了一张带角色的地图:
可是斯卡蒂好像动不起来……按方向键没有反应。
看代码发现:
在 WM_PAINT 处框架已经预先给出了一个 hdc,然而我没注意到,也加了一个全局变量的 hdc,这里就导致了重名,可能会有一些错误……
所以我将 WM_PAINT 中这句预置去掉尝试了一下,发现可以正常移动角色!但是屏幕会出现严重的闪烁,肯定也是不行的。所以没有再改动。
上网搜索才知道,原来只有在:
当第一次创建一个窗口时,当改变窗口的大小时,当把窗口从另一个窗口背后移出时,当最大化或最小化窗口
时,系统才会发送一个 WM_PAINT 命令。也就是说平常是不会长期重绘的。我尝试了一下,果然在移动之后,只有我拖拽一下屏幕,人物才被重新绘制到正确的位置。
那我们如何修改绘图方式以达到在必要的时候重绘,并不会使屏幕闪烁,且资源消耗不过大的目的呢?
重新捡起在《WINDOWS游戏编程之从零开始》学过的——游戏循环。
加入两个全局时间变量 tNow 和 tPre:
DWORD tPre = 0, tNow = 0; // 时间
把原来的一系列 Paint 函数全部整合到 Game_Paint()
中去:
VOID Game_Paint(HWND hwnd)
{
BackGround_Paint(hwnd);
Map_Paint(hwnd);
Chara_Paint(hwnd, gamePlayer);
}
这里还得对 Map_Paint()
作修改:之前 Map_Paint()
中调用了 Map_Create()
. 但实际上Map_Create()
调用一次就可以了,不用每次循环都 Create一次。所以我把对 Map_Create()
的调用移动到 Game_Init()
中。
修改原来的消息循环:
while (msg.message != WM_QUIT)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg); // 获取消息
DispatchMessage(&msg); // 分配消息并响应
}
else
{
tNow = GetTickCount64();
if (tNow - tPre >= 100)
Game_Paint(hWnd);
}
}
因为框架把窗口句柄封装到 InitInstance
中去了,为了调用 Game_Paint(hWnd)
,需要将其暴露出来,所以把 InitInstance()
这个函数去掉,其函数体直接放到 WinMain
中来,效果是一样的。(当然,记得去掉最后一句 return TRUE
捏)
因为已经有游戏循环中的 Game_Paint
了,这个时候我们完全可以把 WM_PAINT
弃之不顾,删除掉窗口过程函数中所有绘制函数。
在 Game_Paint()
函数中加上一句 tPre = GetTickCount64()
,获取绘制时的时间。
VOID Game_Paint(HWND hwnd)
{
BackGround_Paint(hwnd);
Map_Paint(hwnd);
Chara_Paint(hwnd, gamePlayer);
tPre = GetTickCount64();
}
接着再调试运行,发现画面不闪烁了,斯卡蒂也能正常走路了!
总结
感觉最后实现的地图仍然有一点点闪烁情况。或许可以通过增大绘制间隔或者再增加缓冲区来消除……但缓冲区再增加开销可就大了吧。不过基本上没有影响了。(再测试了一下,好像只有录制的时候闪烁会很明显,自己测试肉眼观察的时候几乎没有闪烁)
总之我的斯卡蒂动起来啦……