TetroGL:Win32 平台 C++ OpenGL 游戏教程 - 第三部分






4.98/5 (33投票s)
了解如何绘制文本和处理游戏状态。
前言
本系列文章专注于使用 C++ 和 OpenGL 为 Windows 平台开发 2D 游戏。本系列的最终目标是提供一个类似于经典俄罗斯方块的游戏。我们不仅会关注 OpenGL,还会以完全面向对象的方法讨论游戏编程中常用的设计。为了最大限度地从本系列中受益,您应该已经熟悉 C++ 语言。文章底部有一个留言板,如果您有疑问、意见或建议,可以使用它。
本系列分为三篇文章
目录
引言
这是本系列的最后一篇文章。我们已经学习了如何创建主窗口并在其上显示图像和动画。现在我们将把这些知识应用到具体的示例中。在此之前,我们将首先学习如何使用 OpenGL 绘制文本以及如何管理游戏的各种状态(主菜单、游戏状态、高分等)。
绘制文本
对于许多游戏来说,仅显示从文件加载的图像是不够的:有时您可能想显示一些在设计游戏时未知的文本。例如,高分榜中使用的玩家姓名、当前玩家分数等。您可以通过使用显示列表来用 OpenGL 绘制文本。显示列表是一组 OpenGL 命令,它们被一起存储,以后可以随时执行。假设您有一个非常复杂的对象,其中包含大量 OpenGL 调用(例如,一个由许多纹理组成的对象),并且您希望经常绘制它。与其每次都重复相同的 OpenGL 调用,不如将这些调用执行一次并将它们存储在显示列表中。然后,您可以随时调用您的显示列表来显示对象,而不是调用所有 OpenGL 函数。当调用列表时,列表中的所有命令将按照其发出的顺序执行。使用显示列表的主要优势是优化:OpenGL 命令已经过评估,并且可能以更适合您的图形卡的格式存储。例如,旋转变换需要大量的计算,因为必须根据该命令生成旋转矩阵。如果您使用显示列表,最终的旋转矩阵将被保存,从而避免了每次都进行复杂的重新计算。
那么,这些显示列表在绘制文本方面有什么用呢?嗯,当您创建字体(具有特定的字体、高度和粗细)时,所有以后需要重用的字符都可以绘制到显示列表中(每个字符一个列表)。然后,您可以通过调用您想绘制的字符的不同显示列表来轻松地绘制文本。让我们看看用于绘制文本的CGameFont
类的头文件。
// Utility class used to draw text on the screen using a
// specific font.
class CGameFont
{
public:
// Default constructor
CGameFont();
// Default destructor
~CGameFont();
// Create the font with a specific height and weight.
void CreateFont(const std::string& strTypeface ,
int iFontHeight,
int iFontWeight);
// Draw text on the screen at the specified location with
// the specified colour.
void DrawText(const std::string& strText, int XPos,
int YPos, GLfloat fRed, GLfloat fGreen,
GLfloat fBlue);
// Returns the size of the text. The top and right fields
// of the returned rectangle are set to 0.
TRectanglei GetTextSize(const std::string& strText);
static void SetDeviceContext(HDC hDevContext)
{ m_hDeviceContext = hDevContext; }
private:
// The device context used to create the font.
static HDC m_hDeviceContext;
// The index of the base of the lists.
GLuint m_uiListBase;
// The win32 font
HFONT m_hFont;
};
CreateFont
函数用于创建指定字体(例如,“Arial”、“Times New Roman”...)、特定高度和粗细(粗细指定字体的厚度)的字体。成功创建字体后,您可以调用DrawText
在屏幕上指定的XPos
和YPos
位置,使用指定的 RGB 颜色(fRed
、fGreen
和fBlue
)绘制strText
中的文本。DrawText
函数不得在CreateFont
之前调用,否则将抛出异常。为了能够创建字体,应该提供一个设备上下文。这可以通过静态函数(SetDeviceContext
)一次完成:您可以在程序开始时通过调用CGameFont::SetDeviceContext(hDC)
一次调用该函数。GetTextSize
函数是一个实用函数,可以在绘制文本到屏幕上之前检索文本的大小,以便正确放置它。该类还包含显示列表的基础索引:可以已经创建了多个显示列表,这些列表由 ID 标识。当您为字体生成多个连续的显示列表时,第一个列表的 ID 保存在m_uiListBase
成员中。
现在让我们看看CreateFont
的实现。
void CGameFont::CreateFont(const std::string& strTypeface,
int iFontHeight,
int iFontWeight)
{
if (!m_hDeviceContext)
{
string strError = "Impossible to create the font: ";
strError += strTypeface;
throw CException(strError);
return;
}
// Ask openGL to generate a contiguous set of 255 display lists.
m_uiListBase = glGenLists(255);
if (m_uiListBase == 0)
{
string strError = "Impossible to create the font: ";
strError += strTypeface;
throw CException(strError);
return;
}
// Create the Windows font
m_hFont = ::CreateFont(-iFontHeight,
0,
0,
0,
iFontWeight,
FALSE,
FALSE,
FALSE,
ANSI_CHARSET,
OUT_TT_PRECIS,
CLIP_DEFAULT_PRECIS,
ANTIALIASED_QUALITY,
FF_DONTCARE|DEFAULT_PITCH,
strTypeface.c_str());
if (m_hFont == NULL)
{
m_uiListBase = 0;
string strError = "Impossible to create the font: ";
strError += strTypeface;
throw CException(strError);
return;
}
// Select the newly create font into the device context (and save the previous
// one).
HFONT hOldFont = (HFONT)SelectObject(m_hDeviceContext, m_hFont);
// Generate the font display list (for the 255 characters) starting
// at display list m_uiListBase.
wglUseFontBitmaps(m_hDeviceContext, 0, 255, m_uiListBase);
// Set the original font back in the device context
SelectObject(m_hDeviceContext, hOldFont);
}
我们首先要验证是否提供了设备上下文。如果没有,我们就无法创建字体,因此会抛出异常。然后,我们要求 OpenGL 生成一组连续的 255 个显示列表,用于 255 个字符。该函数返回第一个显示列表的 ID,该 ID 保存在m_uiListBase
成员中。如果函数返回,则意味着 OpenGL 无法分配 255 个显示列表,因此我们会抛出异常。然后,我们通过调用CreateFont
(这是一个 Windows 函数)来创建字体。我不会列出该函数的所有参数,但我们感兴趣的参数是字体高度(第一个)、字体粗细(第五个)和字体类型(最后一个)。如果您感兴趣,可以查看 MSDN 文档,在此。请注意,我们将字体高度提供为负数。这是为了让 Windows 尝试使用字符高度而不是单元格高度来查找匹配的字体。如果字体创建成功,则返回字体,否则返回 NULL(在这种情况下,我们将抛出异常)。然后,我们将此字体选为设备上下文中的活动字体,并存储旧字体,以便在完成后将其设置回。然后,我们调用wglUseFontBitmaps
,它将根据设备上下文中的所选字体为每个字符生成显示列表。第二个参数是我们希望为其生成显示列表的第一个字符的索引,第三个参数是(从该字符开始)我们希望生成显示列表的字符数。在我们的例子中,我们希望为所有 255 个字符生成显示列表。如果您查看 ASCII 表,您会发现并非所有字符都可以使用:第一个可用字符从 32(空格)开始,最后一个是 127(删除字符)。因此,我们可以将显示列表减少到 96 个而不是 255 个,但在这里没有这样做是为了保持简单。一旦所有显示列表都已生成,我们将旧字体重新选回设备上下文。
字体创建后,我们就可以通过调用DrawText
在屏幕上绘制文本了。
void CGameFont::DrawText(const std::string& strText,
int XPos, int YPos,
GLfloat fRed,
GLfloat fGreen,
GLfloat fBlue)
{
if (m_uiListBase == 0)
{
throw CException("Impossible to diplay the text.");
return;
}
// Disable 2D texturing
glDisable(GL_TEXTURE_2D);
// Specify the current color
glColor3f(fRed, fGreen, fBlue);
// Specify the position of the text
glRasterPos2i(XPos, YPos);
// Push the list base value
glPushAttrib (GL_LIST_BIT);
// Set a new list base value.
glListBase(m_uiListBase);
// Call the lists to draw the text.
glCallLists((GLsizei)strText.size(), GL_UNSIGNED_BYTE,
(GLubyte *)strText.c_str());
glPopAttrib ();
// Reenable 2D texturing
glEnable(GL_TEXTURE_2D);
}
我们首先要验证我们是否有一个有效的列表基础(它是在创建字体时生成的)。如果不是,我们将抛出异常。之后,我们禁用 2D 纹理,因为它会干扰文本,并且文本颜色会受到最后一个应用的纹理的影响。然后,我们通过设置当前颜色来指定文本颜色,然后通过调用glRasterPos2i
来设置文本位置,它设置当前光栅位置(用于绘制像素和位图的位置)。然后,我们推入“列表位”,以保存 OpenGL 中的当前列表基础。这样做是为了避免干扰可能已保存列表基础的其他显示列表。然后,我们通过调用glListBase
来设置此列表基础值,这告诉 OpenGL m_uiListBase
是显示列表的新基础。假设我们在生成字体时,第一个可用的显示列表的 ID 是 500。glListBase
命令指定 500 是显示列表的新基础,因此如果您调用glCallLists
,则会向我们提供给glCallLists
的 ID 添加 500 的偏移量。您将在下一行代码中看到为什么这样做。最后,我们通过调用glCallLists
来绘制文本:第一个参数是要执行的显示列表的数量,我们需要为要绘制的每个字母执行一个显示列表(因此显示列表的数量是字符串中字符的数量)。第二个参数是传递给第三个参数的值的类型。我们传递单字节字符,因此类型是GL_UNSIGNED_BYTE
。第三个参数是我们想要调用的列表的 ID。假设第一个字符是“A”,其 ASCII 码为 65,那么我们将调用 ID 为 565 的列表(因为前一个示例的偏移量),它对应于字母“A”的列表 ID。我们对字符串中的每个字符都这样做。每次调用显示列表都会修改当前光栅位置,并将其移到已绘制字符的右侧。这就是为什么字符不会堆叠在一起的原因。然后,我们通过调用glPopAttrib
将列表基础重置为其先前的值,并重新启用 2D 纹理。
显示列表在不再需要时也应该被销毁。这在类的析构函数中完成。
CGameFont::~CGameFont()
{
if (m_uiListBase)
glDeleteLists(m_uiListBase,255);
DeleteObject(m_hFont);
}
如果字体已正确初始化(m_uiListBase
不为 0),我们将删除从索引m_uiListBase
开始的 255 个列表,这些列表是为该字体生成的。我们还将删除 Win32 字体。
因此,使用这个小类可以轻松显示文本。
// Done once, at the start of the program.
CGameFont::SetDeviceContext(hDC);
...
...
CGameFont newFont;
newFont.CreateFont("Arial", 30, FW_BOLD);
newFont.DrawText("Test",300,150,1.0,1.0,1.0);
处理游戏状态
几乎在所有游戏中,您都会遇到不同的“状态”:通常您有一个主菜单(允许用户开始新游戏、设置一些选项、查看高分)、主游戏状态、高分状态等。如果所有内容都必须在一个类中管理,那么您的代码很快就会变得混乱:更新和绘制函数会变成一个巨大的 switch 语句,您必须处理所有可能的状态,所有变量都混在一起,这使得代码难以维护,等等。幸运的是,有一个简单的设计模式可以优雅地解决这个问题:状态模式。原理很简单:游戏的每个状态都有自己的独立类,这些类都继承自一个通用的“状态”类。所以,在我们的例子中,我们有一个菜单类、一个游戏状态类、一个高分类,等等。状态管理器类会跟踪游戏的当前状态,并将所有调用重定向到活动状态(绘制、更新、按键按下等)。当您需要切换到另一个状态时,只需通知状态管理器新状态即可。您可以在网上找到很多关于此模式的好文章,因此我在此不做过多详细介绍。如果您想了解有关此设计模式的更多详细信息,请查看参考文献中的第一个链接。
在源代码中,您会找到一个CStateManager
类,它看起来像这样。
// Manages the different states of the game.
class CStateManager
{
public:
// Default constructor
CStateManager();
// Default destructor
~CStateManager();
// Switches to another active state.
void ChangeState(CGameState* pNewState)
{
if (m_pActiveState)
m_pActiveState->LeaveState();
m_pActiveState = pNewState;
m_pActiveState->EnterState();
}
// Returns the current active state.
CGameState* GetActiveState() { return m_pActiveState; }
// 'Events' function, they are simply redirected to
// the active state.
void OnKeyDown(WPARAM wKey);
void OnKeyUp(WPARAM wKey);
void Update(DWORD dwCurrentTime);
void Draw();
private:
// Active State of the game (intro, play, ...)
CGameState* m_pActiveState;
};
此类管理游戏的当前状态,并将所有“事件”调用重定向到它:如果您查看OnKeyDown
、OnKeyUp
、Update
和Draw
的实现,您会发现它们只是调用m_pActiveState
实例上的相同函数。切换到另一个状态时,状态管理器会调用当前状态的LeaveState
和新状态的EnterState
。状态可以实现这些函数,以便在状态变为活动或非活动时进行特殊初始化或清理。
CGameState
也非常简单。
// Base class for the different states
// of the game.
class CGameState
{
public:
// Constructor
CGameState(CStateManager* pManager);
// Destructor
virtual ~CGameState();
// The different 'events' functions. Child classes can
// implement the ones in which they are interested in.
virtual void OnKeyDown(WPARAM ) { }
virtual void OnKeyUp(WPARAM ) { }
virtual void OnChar(WPARAM ) { }
virtual void Update(DWORD ) { }
virtual void Draw() { }
// Functions called when the state is entered or exited
// (transition from/to another state).
virtual void EnterState() { }
virtual void ExitState() { }
protected:
// Helper function to switch to a new active state.
void ChangeState(CGameState* pNewState);
// The state manager.
CStateManager* m_pStateManager;
};
管理游戏状态的各种类都继承自此类。然后,这些子类可以实现它们感兴趣的“事件”函数。ChangeState
函数只是一个辅助函数:它只是调用CStateManager
的ChangeState
。
游戏示例
现在我们已经涵盖了创建游戏所需的所有内容。本节解释了代码的重要部分,但并未在此处详细介绍所有内容:文章中的代码太多,无法逐行解释。但是,源代码注释相当齐全,请随时深入查看。
游戏分为三个状态:菜单状态、游戏状态和高分状态。如前所述,这些状态中的每一个都在其自己的类中处理,这些类继承自CGameState
类。这些类中的每一个都实现为单例。
这款游戏中有一个小小的附加功能,在其典型的前辈中没有:连击乘数。每次完成一行(或多行)后,玩家都有一定时间完成另一行,以使新完成的行(或行)的分数加倍。每次在连击时间用完之前完成一行,乘数都会增加。如果时间用完,当前乘数会减一,然后开始一个新的计时器。当然,乘数越高,时间减少得越快。
菜单状态
此状态显示主菜单,包含以下选项:新游戏、继续游戏(如果当前有活动游戏)、高分和退出游戏。头文件是。
// Specialization of the CGameState class for
// the menu state. This displays a menu in which
// the player can start a new game, continue an
// existing game, see the high-scores or exit the game.
class CMenuState : public CGameState
{
public:
~CMenuState();
void OnKeyDown(WPARAM wKey);
void Draw();
void EnterState();
static CMenuState* GetInstance(CStateManager* pManager);
protected:
CMenuState(CStateManager* pManager);
private:
// The player went up or down in
// the menu
void SelectionUp();
void SelectionDown();
// The player validated the current selection
void SelectionChosen();
CGameFont* m_pFont;
// Index of the current selected menu item
int m_iCurrentSelection;
// A pointer to the current active game (if any).
CPlayState* m_pCurrentGame;
// The background and title images
TImagePtr m_pBackgroundImg;
TImagePtr m_pTitleImg;
// The images of the menu items (normal and
// selected).
TImagePtr m_pItemBckgndNormal;
TImagePtr m_pItemBckgndSelected;
// The text controls of the different entries.
CTextControl* m_pNewGameText;
CTextControl* m_pResumeGameText;
CTextControl* m_pScoresText;
CTextControl* m_pExitText;
};
按下向上、向下或回车键时,会调用SelectionUp
、SelectionDown
或SelectionChosen
函数。向上和向下选择函数仅更改m_iCurrentSelection
索引,而SelectionChosen
函数根据所选的菜单项切换到另一个状态或退出游戏。
CTextControl
是一个简单的实用类,它在一个矩形区域内以特定的对齐方式(左、中、右)显示文本。
游戏状态
此状态最复杂,因为它负责处理所有游戏逻辑。游戏状态将大部分逻辑委托给CBlocksMatrix
类,该类负责管理游戏区域。有 7 种不同的形状(也称为四格骨牌),它们根据形状命名:I、O、Z、S、T、L 和 J。每个四格骨牌都有一个特定的类来处理它。这是因为没有通用的方法来处理所有不同的四格骨牌。例如,旋转并不总是以相同的方式进行:I 四格骨牌(直线)只有两种不同的位置(垂直和水平),您不能简单地围绕一个点旋转所有单元格。因此,为此,必须单独处理每个四格骨牌。它们都继承自CTetrad
类,该类看起来像这样。
// Base class for all shapes (tetrad)
class CTetrad
{
public:
// Construct a new tetrad. The image of the block used to draw
// the tetrad is loaded depending of the tetrad color.
CTetrad(CBlocksMatrix* pParent, EBlockColor blockColor)
: m_pParentMatrix(pParent), m_iXPos(4), m_iYPos(0),
m_Orientation(Rotation0), m_pBlockImg(NULL), m_BlockColor(blockColor)
{
switch (blockColor)
{
case bcCyan:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(0,BLOCK_HEIGHT,0,BLOCK_WIDTH));
break;
case bcBlue:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(0,BLOCK_HEIGHT,BLOCK_WIDTH,2*BLOCK_WIDTH));
break;
case bcOrange:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(0,BLOCK_HEIGHT,2*BLOCK_WIDTH,3*BLOCK_WIDTH));
break;
case bcYellow:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(0,BLOCK_HEIGHT,3*BLOCK_WIDTH,4*BLOCK_WIDTH));
break;
case bcGreen:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(0,BLOCK_HEIGHT,4*BLOCK_WIDTH,5*BLOCK_WIDTH));
break;
case bcPurple:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(BLOCK_HEIGHT,2*BLOCK_HEIGHT,0,BLOCK_WIDTH));
break;
case bcRed:
m_pBlockImg = CImage::CreateImage("Block.PNG",
TRectanglei(BLOCK_HEIGHT,2*BLOCK_HEIGHT,BLOCK_WIDTH,2*BLOCK_WIDTH));
break;
}
}
virtual ~CTetrad() { }
// Tries to rotate the tetrad. If it can't be rotated,
// the function returns false.
virtual bool Rotate() = 0;
// Tries to move the tetrad to the left. If it can't be
// moved, the function returns false.
virtual bool MoveLeft() = 0;
// Tries to move the tetrad to the right. If it can't be
// moved, the function returns false.
virtual bool MoveRight() = 0;
// Tries to move the tetrad down. If it can't be
// moved, the function returns false.
virtual bool MoveDown() = 0;
// Ask the tetrad to fill the cells in the matrix.
// This function is called when the tetrad is positioned.
virtual void FillMatrix() = 0;
// Checks if the tetrad is at a valid position (do not
// overlap with a filled cell in the matrix). This is
// called when the tetrad is created to check for game over.
virtual bool IsValid() = 0;
// Draw the tetrad at its position in the matrix.
virtual void Draw() = 0;
// Draw the tetrad somewhere on the screen (used to
// display the next shape). The tetrad is centered
// in the rectangle.
virtual void DrawOnScreen(const TRectanglei& rect) = 0;
protected:
// The play area in which the tetrad is used
CBlocksMatrix* m_pParentMatrix;
// The position in the play area (in
// blocks).
int m_iXPos;
int m_iYPos;
enum EOrientation
{
Rotation0,
Rotation90,
Rotation180,
Rotation270,
};
// Orientation of the tetrad
EOrientation m_Orientation;
// The block image use to draw the tetrad.
TImagePtr m_pBlockImg;
// The block color.
EBlockColor m_BlockColor;
};
子类实现这些虚拟方法。它们与CBlocksMatrix
类交互,以检查某些单元格是否为空。这里是 Z 四格骨牌的旋转函数的示例。
bool CTetrad_Z::Rotate()
{
bool bSuccess = false;
switch (m_Orientation)
{
case Rotation0:
case Rotation180:
if (m_pParentMatrix->IsCellFree(m_iXPos,m_iYPos-1) &&
m_pParentMatrix->IsCellFree(m_iXPos-1,m_iYPos+1) )
{
m_Orientation = Rotation90;
bSuccess = true;
}
break;
case Rotation90:
case Rotation270:
if (m_pParentMatrix->IsCellFree(m_iXPos,m_iYPos+1) &&
m_pParentMatrix->IsCellFree(m_iXPos+1,m_iYPos+1))
{
m_Orientation = Rotation0;
bSuccess = true;
}
break;
}
return bSuccess;
}
根据四格骨牌的当前旋转,它会检查旋转后将占用的单元格是否为空。如果它们为空,则更新m_Orientation
成员并返回 true。所有四格骨牌的其他移动或旋转函数类似,因此我不会在此处列出所有代码。Draw
函数也不是很难。
void CTetrad_Z::Draw()
{
int screenX=0, screenY=0;
switch (m_Orientation)
{
case Rotation0:
case Rotation180:
m_pParentMatrix->GetScreenPosFromCell(m_iXPos-1,m_iYPos,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos ,m_iYPos,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos ,m_iYPos+1,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos+1,m_iYPos+1,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
break;
case Rotation90:
case Rotation270:
m_pParentMatrix->GetScreenPosFromCell(m_iXPos ,m_iYPos-1,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos ,m_iYPos ,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos-1,m_iYPos ,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
m_pParentMatrix->GetScreenPosFromCell(m_iXPos-1,m_iYPos+1,screenX,screenY);
m_pBlockImg->BlitImage(screenX,screenY);
break;
}
}
单元格的屏幕位置可以从CBlocksMatrix
类中检索(记住m_iXPos
和m_iYPos
成员是矩阵中的位置,而不是屏幕位置)。
CBlocksMatrix
负责处理与检查填充行和移除它们相关的所有逻辑。让我们首先看一下类的头文件,稍后我们将查看一些函数的实现。
// Class managing the playing area (where the shapes are
// falling). It handles all the logic related to lines.
class CBlocksMatrix
{
public:
// Constructor and destructor
CBlocksMatrix(CMatrixEventsListener* pListener, int xPos, int yPos);
~CBlocksMatrix();
// Draw and update the matrix
void Draw();
void Update(DWORD dwCurrentTime);
// Reset the matrix to its initial state
void Reset();
// Move the current shape
void ShapeLeft();
void ShapeRight();
void ShapeDown();
void ShapeRotate();
// Check if the specified cell is free or not.
bool IsCellFree(int XPos, int YPos);
// Fill the specified cell with a specific block color
void FillCell(int XPos, int YPos, EBlockColor BlockColor);
// Transform a cell coordinates into screen coordinates.
void GetScreenPosFromCell(int cellXPos, int cellYPos,
int& screenXPos, int& screenYPos);
// Returns the next shape
CTetrad* GetNextShape() const { return m_pNextShape; }
// Sets/Gets the time between two update of the current
// shape (determines the speed at which it falls).
void SetTetradUpdate(int iNewUpdate) { m_iTetradUpdate = iNewUpdate; }
int GetTetradUpdate() const { return m_iTetradUpdate; }
private:
// Check if there are lines completed in the
// matrix. This returns true if at least one
// line is complete
bool CheckMatrix();
// Check if the specified line is currently being
// removed
bool IsLineRemoved(int iRow);
// Remove the lines that are complete from the
// matrix and adjust the remaining blocks.
void RemoveLines();
// Tries to create a new shape. If this is not
// possible (e.g. matrix full), m_bGameOver is
// set to true.
void NewShape();
// The screen coordinates of the top-left
// corner.
int m_iXPos;
int m_iYPos;
// The matrix of blocks which are already filled
int m_pBlocksMatrix[MATRIX_WIDTH][MATRIX_HEIGHT];
// The images of the 7 different blocks
TImagePtr m_pBlockImg[7];
// The tetrad factory
CTetradFactory m_TetradFactory;
// Current shape that the player manipulates
CTetrad* m_pTetrad;
// Next shape
CTetrad* m_pNextShape;
// The last move down of the current shape
DWORD m_dwLastShapeDown;
// Flag indicating that one or more
// lines are being removed (blinking)
bool m_bRemovingLine;
// The number of times the line being removed
// has already blinked.
int m_iLineBlinkCount;
// Specify if the the line being removed is currently
// visible or not (for blinking)
bool m_bLineBlinkOn;
// Vector containing the line numbers of the
// lines being removed.
std::vector<int> m_vecLinesRemoved;
// The event listener
CMatrixEventsListener* m_pListener;
// Time (in msec) before the tetrad is moved down
// one step.
int m_iTetradUpdate;
// Flag indicating a game over.
bool m_bGameOver;
};
ShapeLeft
、ShapeRight
、ShapeRotate
和ShapeDown
函数只是重定向到当前四格骨牌(m_pTetrad
)。ShapeDown
函数做得更多一些,因为如果一个四格骨牌无法向下移动,则需要进行一些特殊检查。
void CBlocksMatrix::ShapeDown()
{
if (m_pTetrad && !m_pTetrad->MoveDown())
{
// If the current shape can't move down,
// we ask it to fill the matrix.
m_pTetrad->FillMatrix();
// Then delete the current shape
delete m_pTetrad;
m_pTetrad = NULL;
// We then check if no lines have been completed
// and create the next shape. The m_bGameOver flag
// can be set in this NewShape function.
if (!CheckMatrix())
NewShape();
}
// Set the last update (down) of the shape to
// the current time.
m_dwLastShapeDown = GetCurrentTime();
}
如果形状无法向下移动(MoveDown
函数返回 false),我们首先要求它填充它所在位置的矩阵单元格,然后删除它。然后,我们检查矩阵中是否至少有一行已满:CheckMatrix
函数在至少一行已满时返回 true,并且如果那样的话,它会将已填充行的数字推送到m_vecLinesRemoved
向量中,并将m_bRemovingLine
设置为 true。如果没有完成任何行,我们尝试通过调用NewShape
来创建一个新形状。如果无法创建形状(因为矩阵已满),则会将m_bGameOver
标志设置为 true。
Update
函数仅检查当前形状是否应向下移动。
void CBlocksMatrix::Update(DWORD dwCurrentTime)
{
if (!m_bGameOver)
{
// Check if the current shape should be moved down
if (dwCurrentTime > m_dwLastShapeDown+m_iTetradUpdate)
ShapeDown();
}
}
m_iTetradUpdate
变量指定当前形状两次向下移动之间的最大时间。这会随着关卡而减少(关卡越高,形状下降得越快)。不要忘记m_dwLastShapeDown
变量在ShapeDown
函数中设置为当前时间(因此,如果手动移动形状,也会设置它)。
最后,Draw
函数负责在屏幕上绘制当前状态。
void CBlocksMatrix::Draw()
{
int iBlockX=0, iBlockY=0;
// If some lines are currently being removed,
// We shouldn't draw them all.
if (m_bRemovingLine)
{
for (int j=0; j<MATRIX_HEIGHT;j++)
{
// Don't draw the line if it is being removed and blinking off
if (IsLineRemoved(j) && !m_bLineBlinkOn)
continue;
// Else draw the line
for (int i=0; i<MATRIX_WIDTH;i++)
{
if (m_pBlocksMatrix[i][j])
{
int color = m_pBlocksMatrix[i][j]-1;
GetScreenPosFromCell(i, j, iBlockX, iBlockY);
m_pBlockImg[color]->BlitImage(iBlockX, iBlockY);
}
}
}
// Switch the blinking
if (m_bLineBlinkOn)
m_bLineBlinkOn = false;
else
m_bLineBlinkOn = true;
m_iLineBlinkCount++;
// If blink count equals 10, we stop blinking and remove
// the lines.
if (m_iLineBlinkCount == 10)
{
RemoveLines();
m_bRemovingLine = false;
m_bLineBlinkOn = false;
m_iLineBlinkCount = 0;
NewShape();
}
}
else
{
// Draw filled blocks
for (int j=0; j<MATRIX_HEIGHT;j)
{
for (int i=0; i<MATRIX_WIDTH;i++)
{
if (m_pBlocksMatrix[i][j])
{
int color = m_pBlocksMatrix[i][j]-1;
GetScreenPosFromCell(i, j, iBlockX, iBlockY);
m_pBlockImg[color]->BlitImage(iBlockX, iBlockY);
}
}
}
// Finally, draw the current shape
if (!m_bGameOver)
m_pTetrad->Draw();
}
}
函数的第一部分(如果m_bRemovingLine
为 true)仅在删除行时执行(已满的行在删除之前会闪烁)。请记住,为了显示“动画”,必须以某种方式保存状态,以便在下一帧显示。这就是为什么我们必须记住行当前是否可见(m_bLineBlinkOn
)以及它们已经闪烁了多少次(m_iLineBlinkCount
)的原因。IsLineRemoved
函数在传递的行正在被删除时返回 true。当闪烁完成后,将调用RemoveLines
函数,该函数将从矩阵中删除行并清理所有内容(已移除行上方的块将向下移动)。函数的第二部分在其余时间执行(当没有正在删除的行时)。它只需绘制所有已填充的块和当前形状。
您可能也看到了CMatrixEventsListener
类。实际上,这只是一个接口,应该由另一个类实现,以便收到有关块矩阵中发生的某些事件的通知(开始删除行、行已删除、游戏结束)。CPlayState
类实现了此接口(并且其地址在构造它时传递给CBlocksMatrix
)。此技术用于减少这些类之间的耦合:CBlocksMatrix
类变得独立于使用它的类,并且应该收到事件通知。CPlayState
看起来像这样。
class CPlayState : public CGameState,
public CMatrixEventsListener
{
public:
~CPlayState();
// Implementation of specific events
void OnKeyDown(WPARAM wKey);
void Update(DWORD dwCurrentTime);
void Draw();
// Implementation of the CMatrixEventsListener class
void OnStartRemoveLines();
void OnLinesRemoved(int iLinesCount);
void OnMatrixFull();
void Reset();
bool IsGameOver() { return m_bGameOver; }
// Returns the single instance
static CPlayState* GetInstance(CStateManager* pManager);
protected:
CPlayState(CStateManager* pManager);
private:
// The blocks matrix class
CBlocksMatrix* m_pMatrix;
// The font used to draw text
CGameFont* m_pFont;
// The control in charge of the decreasing
// time for the combo score.
CComboControl* m_pComboControl;
// The text controls to display the current
// information.
CTextControl* m_pScoreControl;
CTextControl* m_pLevelControl;
CTextControl* m_pLinesControl;
// The current number of lines completed
int m_iTotalLines;
// The current level
int m_iCurrentLevel;
// The current score
int m_iCurrentScore;
bool m_bGameOver;
// The background image
TImagePtr m_pBackgroundImg;
};
此类的主要作用是协调不同的元素:游戏矩阵和连击控制,并管理分数和当前已完成的行。该类的实现相当简单,因此我在此不作描述。CComboControl
类处理连击控制:当一行完成时,此控件显示一个递减的时间条。如果在时间结束前完成另一行,则添加到已完成行(或行)的分数中会应用乘数。乘数越高,时间减少得越快。
游戏结束后,将在全屏幕上显示一个半透明的黑色矩形,上面有一些文本。这是通过混合实现的:在CMainWindow::InitGL
函数中添加了混合支持。
// Specifies the blending function
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// Enable blending
glEnable(GL_BLEND);
glEnable(GL_BLEND)
只是启用了混合,但您还必须指定一个混合函数。混合函数告诉 OpenGL 如何将传入的像素与帧缓冲区中的像素混合。这通过glBlendFunc
完成:第一个参数指定应用于源像素(传入像素)的 RGB 分量的因子,第二个参数指定应用于目标像素(已在帧缓冲区中的像素)的因子。最终像素将是每个分量的结果值的总和。我们用于创建半透明黑色屏幕的代码是。
if (m_bGameOver)
{
// In game over, we draw a semi-transparent black screen on top
// of the background. This is possible because blending has
// been enabled.
glColor4f(0.0,0.0,0.0,0.5);
// Disable 2D texturing because we want to draw a non
// textured rectangle over the screen.
glDisable(GL_TEXTURE_2D);
glBegin(GL_QUADS);
glVertex3i(0,0,0);
glVertex3i(0,600,0);
glVertex3i(800,600,0);
glVertex3i(800,0,0);
glEnd();
glEnable(GL_TEXTURE_2D);
m_pFont->DrawText("GAME OVER",340,200);
m_pFont->DrawText("Press Enter to continue",285,300);
}
这意味着我们首先选择一个具有 0.5 alpha 通道的黑色,然后我们在整个屏幕上绘制我们的矩形。绘制矩形时我们必须禁用纹理,因为我们想要一个非纹理的黑色矩形。
高分状态
此状态负责显示从先前游戏中保存的高分。信息保存在文件中以实现持久性(“HighScores.txt”)。文件未受保护,因此任何人都可以编辑此文件并更改高分。这当然不太好,但描述保护数据的方法超出了本文的范围。像往常一样,在详细介绍之前,我将首先展示类声明。
// Specialization of the CGameState class for
// the high scores state. This displays the high
// scores (player name+score). When a new high
// score is available after a game, it lets the
// player enters his name.
class CHighScoreState : public CGameState
{
public:
~CHighScoreState();
// Sets a new score: if this score should be
// part of the high scores, the user will need
// to enter his name.
void SetNewHighScore(ULONG ulNewHighScore)
{ m_ulNewHighScore = ulNewHighScore; }
// Implementation of specific events
void OnKeyDown(WPARAM wKey);
void OnChar(WPARAM wChar);
void Draw();
void EnterState();
static CHighScoreState* GetInstance(CStateManager* pManager);
protected:
CHighScoreState(CStateManager* pManager);
private:
// Saves the current high scores
void SaveScores();
// Adds a new score in the high-score table and
// insert it at the correct location.
void AddNewScore(const std::string& strName, ULONG ulScore);
// High-score data: score and player name.
struct HighScoreData
{
std::string strPlayer;
ULONG ulScore;
// We have to sort in decreasing order, so the <
// operator returns the opposite.
bool operator< (const HighScoreData& other)
{
if (this->ulScore > other.ulScore)
return true;
return false;
}
};
// The new high-score, if any.
ULONG m_ulNewHighScore;
// Mode in which the user has to enter his name.
bool m_bEnterName;
// Char array containing the name currently being entered.
char m_pCurrentName[26];
// The index of the next char to be entered.
int m_iNameIndex;
CGameFont* m_pFont;
typedef std::vector<HighScoreData> THighScoreTable;
// The high-score table.
THighScoreTable m_vecHighScores;
// The background and title images.
TImagePtr m_pBackgroundImg;
TImagePtr m_pTitleImg;
// The image of the entries background
TImagePtr m_pEntriesBckgndImg;
// The 'Enter name' image and the background.
TImagePtr m_pEnterNameImg;
TImagePtr m_pEnterNameBackImg;
};
此类覆盖了EnterState
函数,该函数用于从文件中读取高分并检查是否应将新的高分添加到表中。
void CHighScoreState::EnterState()
{
// Clear the high-score table
m_vecHighScores.clear();
ifstream inputFile("HighScores.txt");
if (inputFile.fail())
{
if (m_ulNewHighScore)
m_bEnterName = true;
return;
}
// Read all entries from the file
while (!inputFile.eof())
{
HighScoreData newScore;
inputFile >> newScore.strPlayer >> newScore.ulScore;
m_vecHighScores.push_back(newScore);
}
// Sort the table
sort(m_vecHighScores.begin(), m_vecHighScores.end());
// Check if we have a new high-score that should be
// added in the table. If yes, m_bEnterName is set
// to true.
ULONG lastScore = 0;
if (m_vecHighScores.size())
lastScore = m_vecHighScores[m_vecHighScores.size()-1].ulScore;
if (m_ulNewHighScore && m_ulNewHighScore>lastScore)
m_bEnterName = true;
}
读取文件时,我们使用std::sort
函数对高分进行排序。为此,我们应该为我们的结构提供一个operator<
。std::sort
函数按升序排序元素,这就是为什么我们的运算符返回与预期相反的值(以便所有元素都按相反的顺序排序)。当用户插入字符时,会调用OnChar
函数。如果m_bEnterName
标志为 true,字符将添加到m_pCurrentName
数组中,直到用户按下回车键。在这种情况下,将调用AddNewScore
函数。
void CHighScoreState::AddNewScore(const std::string& strName, ULONG ulScore)
{
// Create a new high-score and push it into the table
HighScoreData newData;
newData.strPlayer = strName;
newData.ulScore = ulScore;
m_vecHighScores.push_back(newData);
// Sort the table
sort(m_vecHighScores.begin(), m_vecHighScores.end());
// If too much elements, remove the last one.
while (m_vecHighScores.size() > 10)
m_vecHighScores.pop_back();
SaveScores();
}
最后调用SaveScores
将新的高分保存到文件中。
void CHighScoreState::SaveScores()
{
// Create the file
ofstream outputFile("HighScores.txt");
if (outputFile.fail())
return;
// Write all the entries in the file.
THighScoreTable::iterator iter = m_vecHighScores.begin();
for (iter; iter != m_vecHighScores.end(); iter++)
{
outputFile << iter->strPlayer << " " << iter->ulScore;
}
}
在正常模式下(未输入名称时),用户可以通过按 Enter 或 Esc 键退出高分状态并返回主菜单。
结论
这是本系列的最后一篇文章,我们在其中学习了如何在屏幕上绘制文本以及如何管理游戏的各种状态。我们在本教程中学习到的所有内容都用于一个经典的俄罗斯方块游戏示例。
当然,这个例子相当简单,因为它没有声音、没有高级用户界面,也没有网络访问。在更高级的游戏中,您可能希望做类似的事情。查看参考文献,我提供了一些支持这些功能的库的链接。
希望您喜欢本系列。不要犹豫通过文章底部的留言板或给它评分来发表您的看法。谢谢。
链接
[1] 状态模式:一篇关于状态模式设计模式的好文章。[2] SFML 库:简单快速多媒体库。一个免费的多媒体 C++ API,为您提供图形、输入、音频等的低级和高级访问。
[3] FMOD 库:一个音乐和声音效果库,对于非商业发行版是免费的。
[4] RakNet:一个跨平台 C++ 游戏网络引擎。
[5] CEGUI:一个免费库,为图形 API 提供窗口和控件。
[6] dafont:一个提供一些漂亮免费字体的网站(游戏中使用的“01 digitall”字体是从那里下载的)。
致谢
我想感谢 Daniel Metien 和 Andrew Vos 在图形方面所做的非常出色的工作。没有他们的工作,游戏就不会那么有趣了 :)。也感谢 Jeff(又名 El Corazon)在 OpenGL 方面的耐心和建议。