一款“Bicho Hunting”多人游戏
练习使用 Windows GDI 和 Winsock。


下载 src_en_string.zip - 17.59 KB
这个 zip 文件包含 4 个文件
Dialogs.cpp
GameEntry.cpp
Hud.cpp
Resource.rc
这些文件打印英文文本而不是中文文本,您可以将它们替换原始 zip (src.zip) 中的文件。
引言
练习使用 Windows GDI 和 Winsock。
背景
几个月前,我决定编写一个具有网络功能的程序。然后我访问了 Code Project 网站,发现了一个名为 Bicho Hunting 的有趣游戏
bichohunting.aspx
我使用了 Dr. Emmett Brown 的 Bicho Hunting 中的两份素材
1. src\bmp\char.bmp
2. src\bmp\title.bmp
我选择了它并对其进行了改进,现在它可以支持玩家之间联机了。
感谢 Dr. Emmett Brown 的出色工作。
这个游戏与他的不同。
关注点
我根本没有定义任何类。如果不用 std::vector,这应该是一个 C 程序。也许我确实使用了一些 C++ 特性,因为这是 C++ 代码,我相信 C++ 特性的使用量很少(如果有的话)。
在这个游戏中,您可以使用箭头键移动屏幕上的图标,使用空格键开火。鼠标按钮用于在全屏和最大化之间切换。
关于本游戏
本游戏的主要思想是产生两条垂直的激光束,让它们击中目标(有时要避免击中目标,例如,您应该始终避免击中骷髅)。
为了增加难度,您的目标被设计成移动对象,但它们的运动轨迹并非随机的,它们只是带有反弹的匀速直线运动。还有另一种移动对象,就是玩家。作为您的对手,它们的运动轨迹可以被认为是随机的,击中它们并避免被它们击中是您应该做的事情。
游戏循环
这个游戏是消息驱动的。这个游戏引擎是一个定时器,它定期向主线程的消息队列发送 GM_UPDATE 消息。我使用 timeBeginPeriod() 来设置定时器,它在另一个线程中。这个线程唯一的任务就是发送 GM_UPDATE 消息,所以不需要担心线程同步。
让我们看看实际的游戏循环。
当游戏首次运行时,它会想从您那里获取一些信息,例如,是主机还是加入。
DShowMainMenu((LPARAM)&g_gci);
我们选择创建一个游戏,其他设置使用默认值。然后创建主窗口。在 WM_CREATE 中,我们调用 MRunMap() 函数。现在,地图级别和地图步数为零,所以我们到达这里
VOID MRunMap() { switch (LOWORD(g_gci.dwGameMode)) { case GMODE_COOP: // ... case GMODE_FAIR: // ... case GMODE_TEAM: // ... case GMODE_DUEL: // ... } }
游戏模式默认为 FAIR,游戏级别为零,我们到达这里
case GMODE_FAIR: switch (g_map.level) { case 1: MFAIRStage1(); break; case 2: MFAIRStage2(); break; case 3: MFAIRStage3(); break; case 4: MFAIRStage4(); break; case 5: MFAIRStage5(); break; default: g_map.level = rand() % 5 + 1; timeKillEvent(g_uTimerID); g_uTimerID = timeSetEvent(g_uDelay, TTL_UPERIOD, SFinalDispatcher, NULL, TIME_PERIODIC | TIME_CALLBACK_FUNCTION); break; }
级别为零,因此匹配“默认”分支,假设级别设置为 1。
在 MFAIRStage1() 中,我们首先停止定时器(如果存在正在运行的)。因为步数为零,所以我们知道是时候设置地图了,例如,设置背景图,生成球(Bichos),放置玩家,并将这些信息同步给所有玩家(客户端)。最后,我们将步数设置为 1,然后启动定时器。40 毫秒后,我们将收到一个 GM_UPDATE 消息,MFAIRStage1() 将被调用,这次步数为 1,我们正在运行主游戏逻辑。
当任务完成后,MFAIRStage1() 将级别和步数设置为零,之后,类似的流程将重复进行。
在 COOP 游戏中,当任务完成后,例如,MCOOPStage1() 会将级别加一并将步数设置为零,在下一次计时时钟,MCOOPStage2() 将被调用,执行步数 0,类似于 MCOOPStage1()。如果任务失败,则将步数设置为零但保持级别不变,下一次计时时钟将重新开始地图。
在静态控件上绘图
要实现这个目标,您必须先让静态控件本身更新其客户区。我从 Charles Petzold 的“Programming Windows”第五版(图 11-3,ABOUT2 程序)中学习了这项技术。在这个游戏中,它是这样实现的
// case WM_PAINT: InvalidateRect(hwndLogo, NULL, TRUE); UpdateWindow(hwndLogo); hDC = GetDC(hwndLogo); TransparentBlt(hDC, 0, 0, 430, 160, hdcLogo, 0, 0, 430, 160, RGB(255,0,255)); ReleaseDC(hwndLogo, hDC); // break;
位置同步
由于这是一个具有网络功能的游戏,我们必须确保每个移动对象对于可能拥有不同屏幕分辨率的所有玩家来说都具有相同的相对位置。解决方案是在一个逻辑矩形中运行游戏,该矩形宽度为 1024 像素,高度为 768 像素。然后使用 StretchBlt() 将其粘贴到玩家的屏幕上。背景图形
大部分情况下,我使用一个名为 MSimpleFade8x768 的函数来生成背景图形。这个函数首先生成一个 8x768 的条带,然后使用 StretchBlt() 将其拉伸到逻辑矩形,代码如下HBITMAP hbmSrc = CreateCompatibleBitmap(hDC, 8, SIZE_LOGICY); SelectObject(hdcSrc, hbmSrc); SelectObject(hdcSrc, GetStockObject(DC_BRUSH)); for (i = 0; i < 768; i += 8) { SetDCBrushColor(hdcSrc, RGB(r, g, b)); PatBlt(hdcSrc, 0, i, 8, 8, PATCOPY); // decreasement R value or G value or B value or two of them or all // till they equal to zero } StretchBlt(hDC, 0, 0, SIZE_LOGICX, SIZE_LOGICY, hdcSrc, 0, 0, 8, SIZE_LOGICY, SRCCOPY);我从 Charles Petzold 的“Programming Windows”第五版(图 16-7,SYSPAL3 程序)中学习了这项技术。
矩形
Rectangle 是 Windows GDI 的一部分。
>>> 目标
将一个较小的矩形放入一个较大的矩形中。
>>> 解决方案
没有什么太多要说的。
VOID MPlacePlayer(PRECT prc, CONST PRECT prcBig) { int x, y, w, h, wBig, hBig; w = prc->right - prc->left; h = prc->bottom - prc->top; wBig = prcBig->right - prcBig->left; hBig = prcBig->bottom - prcBig->top; x = rand() % (wBig - w); y = rand() % (hBig - h); SetRect(prc, x, y, x + w, y + h); OffsetRect(prc, prcBig->left, prcBig->top); }
>>> 目标
绘制一块文本。
>>> 解决方案
首先我们需要知道矩形的区域,这通过 DrawText 配合其 uFormat 参数和 DT_CALCRECT 标志来完成。假设我们已经知道文本块的宽度。
hDC = CreateIC(TEXT("display"), NULL, NULL, NULL); SelectObject(hDC, g_hfntMessage); SetRect(&rect, 0, 0, SIZE_TBWIDTH, 0); DrawText(hDC, psz, -1, &rect, DT_CALCRECT | DT_WORDBREAK); DeleteDC(hDC); ptb->h = (SHORT)(rect.bottom - rect.top);
>>> 目标
将 4:3 的矩形置于另一个矩形的中心。
>>> 解决方案
// assume rcBig.left = rcBig.top = 0 if (rcBig.right * 3 > rcBig.bottom * 4) { // rcBig is a horizontal bar, fit its height dy = rcBig.bottom; dx = dy * 4 / 3; dt = (rcBig.right - dx) / 2; SetRect(&rcSmall, dt, 0, dt + dx, dy); } else { // rcBig is a vertical bar, fit its width dx = rcBig.right; dy = dx * 3 / 4; dt = (rcBig.bottom - dy) / 2; SetRect(&rcSmall, 0, dt, dx, dt + dy); }
>>> 目标
确定目标是否被击中。
>>> 解决方案
我们知道目标实际上是一个矩形,事实上光束也是一个矩形。所以如果两个矩形的交集为空集,目标就没有被击中。这种方法很直接但效率不高,我在这个游戏中使用了这种方法。
BOOL SDamageJudgment(PBEAM pb, PRECT prc) { int x, y; RECT rcAtk, rc; // ... x = pb->x + (SIZE_PLAYERBIG - SIZE_QUAD) / 2; y = pb->y + (SIZE_PLAYERBIG - SIZE_QUAD) / 2; SetRect(&rcAtk, x, 0, x + SIZE_QUAD, SIZE_LOGICY); IntersectRect(&rc, &rcAtk, prc); if (IsRectEmpty(&rc)); else return TRUE; // ... return FALSE; }
>>> 目标
当移动对象到达边缘时反弹。
>>> 解决方案
我们知道在这个游戏中,我们有两种移动对象,一种是 Bicho(球),另一种是玩家。Bicho 必须反弹,而玩家不必。反弹分为两部分:水平和垂直。
BOOL SMoveIt(PRECT prcObj, CONST PRECT prcFrame, PSHORT pdx, PSHORT pdy, BOOL bPlayer) { RECT rect; BOOL bRet; bRet = FALSE; OffsetRect(prcObj, *pdx, *pdy); IntersectRect(&rect, prcObj, prcFrame); if (EqualRect(&rect, prcObj)); // has not reached the edge yet else { // bounce horizontally if (*pdx < 0) { if (prcObj->left < prcFrame->left) // out-of-bounds in left side { if (bPlayer) OffsetRect(prcObj, prcFrame->left - prcObj->left, 0); else { OffsetRect(prcObj, (prcFrame->left - prcObj->left) * 2, 0); *pdx = -*pdx; } bRet = TRUE; } } else { if (prcObj->right > prcFrame->right) // out-of-bounds in right side { if (bPlayer) OffsetRect(prcObj, prcFrame->right - prcObj->right, 0); else { OffsetRect(prcObj, (prcFrame->right - prcObj->right) * 2, 0); *pdx = -*pdx; } bRet = TRUE; } } // bounce vertically if (*pdy < 0) { if (prcObj->top < prcFrame->top) // out-of-bounds in top { if (bPlayer) OffsetRect(prcObj, 0, prcFrame->top - prcObj->top); else { OffsetRect(prcObj, 0, (prcFrame->top - prcObj->top) * 2); *pdy = -*pdy; } } } else { if (prcObj->bottom > prcFrame->bottom) // out-of-bounds in bottom { if (bPlayer) OffsetRect(prcObj, 0, prcFrame->bottom - prcObj->bottom); else { OffsetRect(prcObj, 0, (prcFrame->bottom - prcObj->bottom) * 2); *pdy = -*pdy; } } } } return bRet; }
套接字 (Sockets)
服务器维护六个 TCP 套接字和一个 UDP 套接字,分别在 g_sTcp[6] 和 g_sUdp 中。g_sTcp[0] 是监听套接字,其他五个是通信套接字,它们被放在同一个数组中,但它们的用途不同。
UDP 套接字在这个游戏中不是很有用,因为它无法将 UDP 包发送到子网中的其他客户端。在这个游戏中,我们处理两个 UDP 消息
1. UPH_GREETING,客户端向服务器报告其地址。
2. UPH_MSG,玩家之间的文本消息。
大多数通信通过 TCP 套接字传输,这种套接字是面向流的。由于我们用它来传输各种信息,所以我们必须将消息打包。包总是有一个头部
typedef struct tagTCPPACK { BYTE msgtype; BYTE to; WORD msglen; } TCPPACK, *PTCPPACK;
然后跟着一块数据。读取时,我们必须先从字节流中读取 sizeof(TCPPACK) 字节,然后决定下一步需要读取多少字节。
另一方面,我们使用了 WSAAsyncSelect(..., FD_READ | FD_WRITE),它将在我们的 TCP 缓冲区中有数据可用时生成一个带有 FD_READ 的消息,所以第一次读取将生成另一个 FD_READ,如果 msglen 不为零。然后我们从 TCP 缓冲区读取 msglen 字节,并可能提取所有数据。完成后,我们必须处理另一个没有可读内容的 FD_READ,这不是错误。在游戏中,代码如下
// ... case GM_TCP4: case GM_TCP5: wError = WSAGETSELECTERROR(lParam); wEvent = WSAGETSELECTEVENT(lParam); i = uMsg - GM_TCP0; switch (wEvent) { case FD_READ: if (wError) SRemovePlayer(i); else { // results in another FD_READ nRead = recv(g_sTcp[i], pbuf, sizeof(TCPPACK), 0); if (nRead == SOCKET_ERROR) { // the "another FD_READ" } else { switch (((PTCPPACK)pbuf)->msgtype) { case TPH_DE1: recv(g_sTcp[i], pbuf, sizeof(XCDE1), 0); // ... break; } } } break; } return 0;
发送和接收数据的示例
要发送数据,我们必须将两部分组合在一起:一个头部和一个数据块。最简单的方法是为数据块定义一个结构,这样 msglen 字段就不是必需的了
// typedef struct tagSTATECHANGE // { // BYTE id; // BYTE state; // BYTE life; // BYTE pad; // WORD cx; // WORD cy; // } STATECHANGE, *PSTATECHANGE; // CHAR pbuf[1024]; case GM_STATECHANGE: ((PTCPPACK)pbuf)->msgtype = TPH_STATECHANGE; ((PTCPPACK)pbuf)->to = 0; ((PTCPPACK)pbuf)->msglen = sizeof(STATECHANGE); ((PSTATECHANGE)((PTCPPACK)pbuf + 1))->id = (BYTE)lParam; ((PSTATECHANGE)((PTCPPACK)pbuf + 1))->state = (BYTE)wParam; ((PSTATECHANGE)((PTCPPACK)pbuf + 1))->life = g_vPlayers.at(lParam).life; ((PSTATECHANGE)((PTCPPACK)pbuf + 1))->cx = (WORD)g_vPlayers.at(lParam).rc.left; ((PSTATECHANGE)((PTCPPACK)pbuf + 1))->cy = (WORD)g_vPlayers.at(lParam).rc.top; SPushAll((PTCPPACK)pbuf); return 0;
当它是变量时,msglen 很有用,在这种情况下,我们像下面这样编码
char pbuf[1024]; int i, j, n; #define X ((PBALLCOREINFO)((PTCPPACK)pbuf + 1) + j) j = 0; n = g_vpbBlue.size(); for (i = 0; i < n; ++i) { if (g_vpbBlue.at(i)->state) { X->color = g_vpbBlue.at(i)->dx > 0 ? ICON_BLUE1 : ICON_BLUE2; X->dx = (signed char)g_vpbBlue.at(i)->dx; X->dy = (signed char)g_vpbBlue.at(i)->dy; X->cx = (WORD)g_vpbBlue.at(i)->rc.left; X->cy = (WORD)g_vpbBlue.at(i)->rc.top; } else { X->color = ICON_PWNED1; X->dx = 0; X->dy = 0; X->cx = (WORD)g_vpbBlue.at(i)->rc.left; X->cy = (WORD)g_vpbBlue.at(i)->rc.top; } ++j; } // ... #undef X // we do not know the value of msglen until now ((PTCPPACK)pbuf)->msgtype = TPH_SYNBALL; ((PTCPPACK)pbuf)->to = (BYTE)nIndex; ((PTCPPACK)pbuf)->msglen = (WORD)(j * sizeof(BALLCOREINFO)); if (toall) SPushAll((PTCPPACK)pbuf); else SPush((PTCPPACK)pbuf, nIndex);
现在 msglen 是一个变量。然后我们读取它们。
读取第一个消息很简单,因为我们知道它是一个结构
case TPH_STATECHANGE: nRead = recv(g_sTcp[0], pbuf, ((PTCPPACK)pbuf)->msglen, 0); CPlacePlayer((PSTATECHANGE)pbuf); break;
CPlacePlayer 是一个接受 STATECHANGE 结构指针的函数。
读取第二个消息相对复杂,我们需要使用 msglen 字段。
// nRead = recv(g_sTcp[0], pbuf, sizeof(TCPPACK), 0); case TPH_SYNBALL: if (HeapSize(g_hHeap, 0, g_pbci) < ((PTCPPACK)pbuf)->msglen + sizeof(BALLCOREINFO)) { HeapFree(g_hHeap, 0, g_pbci); g_pbci = (PBALLCOREINFO)HeapAlloc(g_hHeap, 0, ((PTCPPACK)pbuf)->msglen + sizeof(BALLCOREINFO)); } nRead = recv(g_sTcp[0], (PCHAR)g_pbci, ((PTCPPACK)pbuf)->msglen, 0); // msglen = 0 so nRead is SOCKET_ERROR, lasterror = WSAEWOULDBLOCK if (nRead == SOCKET_ERROR) g_pbci->color = ICON_ENDLIST; else { nRead /= sizeof(BALLCOREINFO); (g_pbci + nRead)->color = ICON_ENDLIST; } break;
这次我们将数据块部分复制到另一个字节块(不是 pbuf),头部已经提取,所以在这次读取中我们从数据块的开头开始。
细节
细节都放在源代码里了:D 下载并查看。
==================== HOW TO RUN ==================== this application is designed to running in Windows XP operating system, it may not run in other operating system. if you see this dialog in Windows XP: " This application has failed to start because the application configuration is incorrect " this is because the application requires some dlls in your WinSxS folder. please install "vcredist_x86.exe" to try to fix this issue. ==================== HOW TO PLAY ==================== ===== whats on the first dialog box ===== select a game mode when hosting a game enter ip address when joining a game ------ game mode ------ ------------ | Cooperative | | ip address | | Free for all | ------------ | Team Match | | Duel | ------ ----------------------- | name | ------ ------ | icon | ------ ------ if server specify its port, | port | ------- client need specify the same ------ | color | ------- ------------- ------------- ------------- | Host button | | Join button | | Quit button | ------------- ------------- ------------- ===== when hosting a game ===== you must - click "Host" button you can - select a game mode, default to FAIR - specify a port number, default to 666 - specify your name, default to Bicho - choose an icon, default to Cross - choose a color, default to Red - click "Quit" button, if you want to quit ===== when joining a game ===== you must - fullfill the (hosts, not yours) ip address area - click "Join" button you can - specify port number, default to 666 - specify your name, default to Bicho - choose an icon, default to Cross - choose a color, default to Red - click "Quit" button, if you want to quit ===== whats on the in-game menu dialog box ===== join resume back to main menu back to desktop ===== controls ===== in this game you have 7 keyboard keys and 2 mouse buttons to use. keys: 4 arrow keys, Esc, Enter, Spacebar buttons: left button, right button (zoom in and out) ===== objective ===== This game include four modes: 1. Cooperative 2. Free for all (PVP) 3. Team Match (PVP) 4. Duel (PVP) Press spacebar to fire, the effect is two beams from the center of your icon. Arrow keys to move your icon. In PVP mode, you should try to let your beam hit other players and avoid be hit. Hit moving balls (called Bicho) may enhance your ability (speed etc.). COOP level 1: COOP level 2: Eliminate all Bichos. If hit nothing, minus one life. COOP level 3: COOP level 4: Eliminate all Bichos within one hit. Otherwise, minus one life, map restart. If hit nothing, minus one life. COOP level 5: Eliminate all Bichos within two hits. Otherwise, minus one life, map restart. If hit nothing, minus one life. COOP level 6: Eliminate all three red Bichos within one hit, do not hit others. Otherwise, minus one life, map restart. COOP level 7: Execute the traitor, do not hit others. Otherwise, minus one life, map restart. COOP level 8: Eliminate all Bichos within two hits. Otherwise, minus one life, map restart. If hit nothing, minus one life. COOP level 9: Eliminate all fireballs. COOP level 10: Eliminate all skulls. Your normal attack can freeze those skulls for ever. FAIR: Gain 20 kills. TEAM: Eliminate all your enemies. Only reds and blues are allowed to join. DUEL: Eliminate your opponent. ===== ===== ===== ===== my email: johns@sina.com ===== ===== ===== =====
历史
2010.02.19
添加了一个 zip 文件:src_en_string.zip。它包含四个文件
Dialogs.cpp
GameEntry.cpp
Hud.cpp
Resource.rc
这些文件打印英文文本而不是中文文本,您可以将它们替换原始 zip (src.zip) 中的文件。
Map.cpp 包含很多中文文本,但它们都是提示文本。您可以在 HowTo.txt 中找到这些提示的英文版本。
==========
2010.02.18
vcredist_x86.exe 已被移除。
Bicho.exe 已被移至另一个 .zip 文件 executable.zip
==========
Gadgets.cpp 已从该项目中移除。我在这里提到它是因为它对我很有意义,但对这个游戏没有意义。
倒计时框未实现。它本打算用于决斗游戏。