一个用户 界面 ( UI )定义了计算机程序和用户之间的交互。在我们的游戏中,到目前为止,我们的交互仅限于控制玩家飞船的键盘界面。当我们编写粒子系统配置应用时,我们使用 HTML 定义了一个更健壮的用户界面,允许我们输入值来配置我们的粒子系统。从那个用户界面,我们的代码必须间接地与 WebAssembly 代码交互。如果你想利用 HTML 来定义你的用户界面,你可以在游戏中继续使用这种技术,但是它有一些缺点。首先,我们可能想要覆盖游戏内容的用户界面元素。对于这种效果,遍历 DOM 的效率不是很高。如果用户界面元素在游戏引擎中呈现,我们的用户界面和游戏中的对象之间的交互也更容易。此外,您可能正在开发 C/C++ 代码,以用于平台和网络发布。如果是这种情况,您可能不希望 HTML 在您的用户界面中扮演太多的角色。
在本章中,我们将在游戏中实现一些用户界面功能。我们将需要实现一个Button
类,这是最简单和最常见的 UI 元素之一。我们还需要实现一个单独的屏幕和游戏状态,这样我们就可以有一个开始和结束的游戏屏幕。
You will need to include several images and audio files in your build to make this project work. Make sure that you include the /Chapter14/sprites/
and /Chapter14/audio/
folders from this project's GitHub repository. If you haven't downloaded the GitHub project yet, you can get it online here: https://github.com/PacktPublishing/Hands-On-Game-Development.
在本章中,我们将涵盖以下主题:
- 用户界面要求
- 获取鼠标输入
- 创建按钮
- 开始游戏屏幕
- 屏幕上的游戏
当实现我们的用户界面时,我们需要做的第一件事是决定一些需求。我们的用户界面到底需要什么?第一部分是决定我们的游戏需要什么游戏屏幕。这通常是你在游戏设计过程的早期所做的事情,但是因为我正在写一本关于 WebAssembly 的书,所以我把这一步留到了后面的章节。决定你的游戏需要什么样的屏幕通常需要一个故事板和一个过程,通过这个过程,你要么通过对话(如果不止一个人在玩游戏),要么通过用户与你的网页以及网页上的游戏互动的方式来思考:
Figure 14.1: Storyboard example for our user interface
你不必画一个故事板,但是我发现它在思考我需要一个游戏的用户界面时很有用。当你需要将这些信息传递给另一个团队成员或艺术家时,这就更有用了。当思考我们在这个游戏中需要什么来制作前面的故事板时,我提出了以下需求列表:
- 打开屏幕
- 说明
- 工作按钮
- 游戏画面
- 乐谱文本
- 屏幕上的游戏
- 你赢得了信息
- 你失去了信息
- 再次播放按钮
出于几个原因,我们的游戏需要一个开放屏幕。首先,我们不希望用户一加载网页游戏就开始。用户可能会加载网页,但在网页完全加载后不会立即开始播放,原因有很多。如果他们的连接速度很慢,他们可能会在游戏加载时离开电脑,可能不会注意到第二次加载。如果他们通过点击链接到达这个页面,他们可能还没有准备好在游戏加载的瞬间开始玩。一般来说,让玩家在投入游戏之前必须做一些事情来确认他们已经准备好了,这也是一个很好的做法。开屏还应该包括一些基本玩法的说明。街机游戏有很长的历史,把简单的指令放在柜子上,告诉玩家玩游戏必须做什么。众所周知,游戏《乒乓》附带了印刷在柜子上的“高分避免漏球”的说明。不幸的是,我们没有一个街机柜来打印我们的说明,所以使用游戏开始屏幕是下一个最好的事情。我们还需要一个按钮,让用户点击后就可以开始玩游戏,如下所示:
Figure 14.2: Opening screen image
播放屏幕是我们一直拥有的屏幕。这是玩家移动飞船的屏幕,试图摧毁敌人的飞船。我们可能不需要改变这个屏幕的工作方式,但是我们需要根据游戏状态在这个屏幕上添加过渡。当玩家点击一个按钮时,游戏将需要从开始屏幕过渡到我们的播放屏幕。如果任何一艘船被摧毁,玩家还需要从屏幕上转移到游戏画面上。如下所示:
Figure 14.3: The original screen is now the play screen
如果其中一艘宇宙飞船被摧毁,游戏就结束了。如果玩家的船被摧毁,那么玩家就输了。如果敌舰被摧毁,那么玩家赢得游戏。游戏结束画面让我们知道游戏结束,并告诉我们玩家是赢了还是输了。它还需要提供一个按钮,允许我们的玩家再次玩游戏,如果他们愿意。屏幕上的游戏如下所示:
Figure 14.4: Game over screen
在我们实现一个按钮之前,我们需要学习如何在 SDL 使用鼠标输入。我们用来获得键盘输入的代码在我们的main.cpp
文件中。在input
功能中,您会发现对SDL_PollEvent
的调用,后面是一些不同的开关语句。第一个开关语句检查SDL_KEYDOWN
的event.type
。第二个开关检查event.key.keysym.sym
看我们按了哪个键:
if( SDL_PollEvent( &event ) ){
switch( event.type ){
case SDL_KEYDOWN:
switch( event.key.keysym.sym ){
case SDLK_LEFT:
left_key_down = true;
break;
case SDLK_RIGHT:
right_key_down = true;
break;
case SDLK_UP:
up_key_down = true;
break;
case SDLK_DOWN:
down_key_down = true;
break;
case SDLK_f:
f_key_down = true;
break;
case SDLK_SPACE:
space_key_down = true;
break;
default:
break;
}
break;
当我们寻找鼠标输入时,我们需要使用相同的SDL_PollEvent
函数来检索我们的鼠标事件。我们关注的三个鼠标事件分别是SDL_MOUSEMOTION
、SDL_MOUSEBUTTONDOWN
和SDL_MOUSEBUTTONUP
。一旦我们知道了我们正在处理的鼠标事件的种类,我们就可以使用SDL_GetMouseState
在事件发生时找到我们鼠标的x
和y
坐标:
if(SDL_PollEvent( &event ) )
{
switch (event.type)
{
case SDL_MOUSEMOTION:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
printf(”mouse move x=%d y=%d\n”, x_val, y_val);
}
case SDL_MOUSEBUTTONDOWN:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
printf(”mouse down x=%d y=%d\n”, x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
case SDL_MOUSEBUTTONUP:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
printf(”mouse up x=%d y=%d\n”, x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
现在我们可以接收鼠标输入,让我们创建一个简单的用户界面按钮。
现在我们知道了如何使用 SDL 在 WebAssembly 中捕获鼠标输入,我们可以使用这些知识来创建一个可以被鼠标点击的按钮。我们需要做的第一件事是在game.hpp
文件中创建一个UIButton
类定义。我们的按钮将有一个以上的雪碧纹理相关联。按钮通常有一个悬停状态和一个点击状态,因此如果用户将鼠标光标悬停在按钮上,或者点击了按钮,我们将希望显示另一个版本的精灵:
Figure 14.5: Button states
为了捕捉这些事件,我们需要函数来检测鼠标是点击了我们的按钮还是悬停在按钮上。下面是我们的类定义:
class UIButton {
public:
bool m_Hover;
bool m_Click;
bool m_Active;
void (*m_Callback)();
SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
SDL_Texture *m_SpriteTexture;
SDL_Texture *m_ClickTexture;
SDL_Texture *m_HoverTexture;
UIButton( int x, int y,
char* file_name, char* hover_file_name, char* click_file_name,
void (*callback)() );
void MouseClick(int x, int y);
void MouseUp(int x, int y);
void MouseMove( int x, int y );
void KeyDown( SDL_Keycode key );
void RenderUI();
};
前三个属性是按钮状态属性,告诉我们的渲染函数绘制什么精灵,或者如果按钮不活动,不绘制任何东西。如果是true
,则m_Hover
属性将导致我们的渲染器绘制m_HoverTexture
。如果是true
,则m_Click
属性将导致我们的渲染器绘制m_ClickTexture
。最后,m_Active
,如果设置为false
,将导致我们的渲染器不绘制任何东西。
下面一行是指向我们回调的函数指针:
void (*m_Callback)();
这个函数指针是在我们的构造函数中设置的,当有人点击按钮时,我们就调用这个函数。在函数指针之后,我们有了目标矩形,在构造函数运行之后,它将有按钮图像文件的位置、宽度和高度:
SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
然后,我们有三个纹理。这些纹理用于在画布上绘制图像,并在渲染过程中根据我们之前讨论的状态标志进行选择:
SDL_Texture *m_SpriteTexture;
SDL_Texture *m_ClickTexture;
SDL_Texture *m_HoverTexture;
接下来,我们有构造函数。该功能接受我们按钮的x
和y
屏幕坐标。之后,有三个字符串,这是我们将用来加载我们的纹理的三个 PNG 文件的位置。最后一个参数是回调函数的指针:
UIButton( int x, int y,
char* file_name, char* hover_file_name, char* click_file_name,
void (*callback)() );
然后,根据鼠标的当前状态,我们调用SDL_PollEvent
后需要调用三个函数:
void MouseClick(int x, int y);
void MouseUp(int x, int y);
void MouseMove( int x, int y );
KeyDown
功能如果按下一个键会取一个键码,如果键码和我们的热键匹配,我们想用它来代替用鼠标点击按钮:
void KeyDown( SDL_Keycode key );
RenderUI
功能类似于我们为其他对象创建的Render
功能。RenderUI
和Render
的区别在于Render
功能在将精灵渲染到屏幕上时会考虑相机位置。RenderUI
功能将始终在画布空间中渲染:
void RenderUI();
在下一节中,我们将创建用户界面状态信息来跟踪当前屏幕。
在我们开始给游戏添加新屏幕之前,我们需要创建一些屏幕状态。我们将从main.cpp
文件中对这些状态进行大部分管理。不同的屏幕状态将需要不同的输入,将运行不同的逻辑,以及不同的渲染功能。我们将在代码的最高级别管理所有这些,作为我们的游戏循环调用的函数。我们将从game.hpp
文件中定义一个可能状态的列表作为枚举:
enum SCREEN_STATE {
START_SCREEN = 0,
PLAY_SCREEN = 1,
PLAY_TRANSITION = 2,
GAME_OVER_SCREEN = 3,
YOU_WIN_SCREEN = 4
};
您可能会注意到,尽管只有三个不同的屏幕,但我们总共有五种不同的屏幕状态。START_SCREEN
和PLAY_SCREEN
分别是开始画面和播放画面。PLAY_TRANSITION
状态在START_SCREEN
和PLAY_SCREEN
之间切换屏幕,在游戏中逐渐消失,而不是突然切换。我们将在屏幕上使用两种不同的游戏状态。这些状态是GAME_OVER_SCREEN
和YOU_WIN_SCREEN
。这两种状态的唯一区别是游戏结束时显示的信息。
我们需要对game.hpp
文件进行一些额外的更改。除了我们的UIButton
类,我们还需要添加一个UISprite
类定义文件。UISprite
只是一个在画布空间中绘制的普通图像。除了作为用户界面元素呈现的精灵之外,它没有任何功能。定义如下:
class UISprite {
public:
bool m_Active;
SDL_Texture *m_SpriteTexture;
SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
UISprite( int x, int y, char* file_name );
void RenderUI();
};
像按钮一样,它有一个由m_Active
属性表示的活动状态。如果该值为假,精灵将不会呈现。它还有一个精灵纹理和一个目标属性,告诉渲染器绘制什么和在哪里绘制:
SDL_Texture *m_SpriteTexture;
SDL_Rect m_dest = {.x = 0, .y = 0, .w = 128, .h = 32 };
它有一个简单的构造器,接受我们将在画布上渲染精灵的x
和y
坐标,以及我们将从中加载精灵的虚拟文件系统中的图像文件名:
UISprite( int x, int y, char* file_name );
最后,它有一个名为RenderUI
的渲染函数,可以将精灵渲染到画布上:
void RenderUI();
RenderManager
类将需要一个新的属性和一个新的函数。在我们游戏的早期版本中,我们有一种可以渲染的背景,那就是我们的滚动星域。当我们渲染我们的开始屏幕时,我想使用一个新的自定义背景,其中包括一些如何玩游戏的说明。
以下是新版本的RenderManager
类定义:
class RenderManager {
public:
const int c_BackgroundWidth = 800;
const int c_BackgroundHeight = 600;
SDL_Texture *m_BackgroundTexture;
SDL_Rect m_BackgroundDest = {.x = 0, .y = 0, .w =
c_BackgroundWidth, .h = c_BackgroundHeight };
SDL_Texture *m_StartBackgroundTexture;
RenderManager();
void RenderBackground();
void RenderStartBackground(int alpha = 255);
void Render( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest,
float rad_rotation = 0.0,
int alpha = 255, int red = 255, int green = 255,
int blue = 255 );
void RenderUI( SDL_Texture *tex, SDL_Rect *src, SDL_Rect *dest,
float rad_rotation = 0.0,
int alpha = 255, int red = 255, int green = 255,
int blue = 255 );
};
我们添加了一个新的SDL_Texture
,我们将使用它来渲染开始屏幕中的背景图像:
SDL_Texture *m_StartBackgroundTexture;
除了新属性之外,我们还添加了一个新功能,以便在启动屏幕激活时渲染该图像:
void RenderStartBackground(int alpha = 255);
传递到该功能的α值将用于在PLAY_TRANSITION
屏幕状态期间淡出开始屏幕。当玩家点击“播放”按钮时,过渡状态将开始,并持续大约一秒钟。
我们需要添加三个新的extern
变量定义,它们将引用我们在main.cpp
文件中声明的变量。其中两个变量是指向UISprite
对象的指针,其中一个变量是指向UIButton
的指针。以下是三个extern
的定义:
extern UISprite *you_win_sprite;
extern UISprite *game_over_sprite;
extern UIButton* play_btn;
我们在屏幕上方的游戏中使用这两个UISprite
指针。第一个,you_win_sprite
,是玩家赢得游戏后会显示的精灵。第二个精灵game_over_sprite
,是玩家输了会显示的精灵。最后一个变量play_btn
是将显示在开始屏幕上的播放按钮。
我们在游戏循环中管理新的屏幕状态。正因为如此,我们将对main.cpp
文件进行大部分修改。我们需要将input
功能分成三个新功能,每个游戏屏幕一个。我们需要将render
功能分解为start_render
和play_render
功能。我们不需要end_render
功能,因为当显示结束屏幕时,我们将继续使用play_render
功能。
我们还需要一个功能来显示开始屏幕和播放屏幕之间的转换。在游戏循环内部,我们需要根据当前屏幕添加逻辑来执行不同的循环逻辑。
我们需要对main.cpp
文件进行的第一个更改是添加新的全局变量。我们的用户界面精灵和按钮需要新的全局变量。我们将需要一个新的全局变量来表示当前的屏幕状态,状态之间的转换时间,以及一个标志来告诉我们玩家是否赢得了游戏。以下是我们在main.cpp
文件中需要的新的全局变量:
UIButton* play_btn;
UIButton* play_again_btn;
UISprite *you_win_sprite;
UISprite *game_over_sprite;
SCREEN_STATE current_screen = START_SCREEN;
int transition_time = 0;
bool you_win = false;
前两个变量是UIButton
对象指针。第一个是play_btn
,是用户点击开始玩游戏的开始画面按钮。第二个是play_again_btn
,这是游戏结束画面上的一个按钮,玩家可以点击重新开始游戏。在 UIButtons 之后,我们有两个UISprite
对象:
UISprite *you_win_sprite;
UISprite *game_over_sprite;
这些是显示在最终游戏屏幕上的精灵。显示哪个精灵取决于玩家是否摧毁了敌舰,反之亦然。在那些精灵之后,我们有一个SCREEN_STATE
变量,用来跟踪当前的屏幕状态:
SCREEN_STATE current_screen = START_SCREEN;
transition_time
变量用于记录开始屏幕和播放屏幕之间过渡状态的剩余时间。you_win
标志在游戏结束时设置,用于记录谁赢得了游戏。
我们游戏的前一个版本有一个单一的input
功能,使用SDL_PollEvent
来轮询按键。在这个版本中,我们希望三种屏幕状态都有一个输入功能。我们首先要做的就是将原来的input
功能play_input
重新命名。这将不再是通用输入功能,它将只执行播放屏幕的输入功能。现在我们已经重命名了我们原来的输入函数,让我们为我们的开始屏幕定义输入函数,并将其称为start_input
:
void start_input() {
if(SDL_PollEvent( &event ) )
{
switch (event.type)
{
case SDL_MOUSEMOTION:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseMove(x_val, y_val);
}
case SDL_MOUSEBUTTONDOWN:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseClick(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
case SDL_MOUSEBUTTONUP:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseUp(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
case SDL_KEYDOWN:
{
play_btn->KeyDown( event.key.keysym.sym );
}
}
}
}
像我们的play_input
功能一样,start_input
功能将会调用SDL_PollEvent
。除了检查SDL_KEYDOWN
以确定某个键是否被按下,我们还将检查三个鼠标事件:SDL_MOUSEMOTION
、SDL_MOUSEBUTTONDOWN
和SDL_MOUSEBUTTONUP
。当检查这些鼠标事件时,我们将根据检索到的SDL_GetMouseState
值调用play_btn
函数。鼠标事件将触发以下代码:
case SDL_MOUSEMOTION:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseMove(x_val, y_val);
}
如果event.type
是SDL_MOUSEMOTION
,我们创建x_val
和y_val
整数变量,并使用对SDL_GetMouseState
的调用来检索鼠标光标的x
和y
坐标。然后我们称之为play_btn->MouseMove(x_val, y_val)
。这将鼠标 x 和 y 坐标传递给播放按钮,播放按钮使用这些值来确定按钮是否处于悬停状态。如果event.type
是SDL_MOUSEBUTTONDOWN
,我们会做类似的事情:
case SDL_MOUSEBUTTONDOWN:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseClick(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
如果按下鼠标按钮,我们看一下event.button.button
的内部,看看被点击的按钮是否是鼠标左键。如果是,我们用x_val
、y_val
结合SDL_GetMouseState
找到鼠标光标位置。我们用这些价值观来称呼play_btn->MouseClick(x_val, y_val)
。MouseClick
功能将确定按钮点击是否落在按钮内,如果是,它将调用按钮的回调功能。
事件为SDL_MOUSEBUTTONUP
时执行的代码与SDL_MOUSEBUTTONDOWN
非常相似,不同的是它调用的是play_btn->MouseUp
而不是play_btn->MouseClick
:
case SDL_MOUSEBUTTONUP:
{
switch (event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_btn->MouseUp(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
除了鼠标事件,我们还将键盘事件传递给我们的按钮。这样做是为了让我们可以创建一个热键来触发回调:
case SDL_KEYDOWN:
{
play_btn->KeyDown( event.key.keysym.sym );
}
在start_input
功能之后,我们将定义end_input
功能。end_input
功能与start_input
功能非常相似。唯一显著的区别是play_btn
对象被play_again_btn
对象替换,这将有不同的回调和与之关联的 SDL 纹理:
void end_input() {
if(SDL_PollEvent( &event ) )
{
switch(event.type)
{
case SDL_MOUSEMOTION:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_again_btn->MouseMove(x_val, y_val);
}
case SDL_MOUSEBUTTONDOWN:
{
switch(event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_again_btn->MouseClick(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
case SDL_MOUSEBUTTONUP:
{
switch(event.button.button)
{
case SDL_BUTTON_LEFT:
{
int x_val = 0;
int y_val = 0;
SDL_GetMouseState( &x_val, &y_val );
play_again_btn->MouseUp(x_val, y_val);
break;
}
default:
{
break;
}
}
break;
}
case SDL_KEYDOWN:
{
printf("SDL_KEYDOWN\n");
play_again_btn->KeyDown( event.key.keysym.sym );
}
}
}
}
在我们游戏的早期版本中,我们只有一个渲染功能。现在,我们必须有一个渲染功能,我们的开始屏幕和我们的播放屏幕。现有的渲染器将成为我们新的播放屏幕渲染器,所以我们必须重命名render
功能play_render
。我们还需要为我们的启动屏幕添加一个名为start_render
的渲染功能。该功能将渲染我们的新背景和play_btn
。以下是start_render
的代码:
void start_render() {
render_manager->RenderStartBackground();
play_btn->RenderUI();
}
需要对collisions()
功能进行一些小的修改。当一艘玩家船或一艘敌人船被摧毁时,我们需要将当前屏幕改为游戏屏幕。根据哪艘船被摧毁,我们要么需要把它换成胜利画面,要么换成失败画面。这是我们碰撞功能的新版本:
void collisions() {
Asteroid* asteroid;
std::vector<Asteroid*>::iterator ita;
if( player->m_CurrentFrame == 0 && player->CompoundHitTest( star ) ) {
player->m_CurrentFrame = 1;
player->m_NextFrameTime = ms_per_frame;
player->m_Explode->Run();
current_screen = GAME_OVER_SCREEN;
large_explosion_snd->Play();
}
if( enemy->m_CurrentFrame == 0 && enemy->CompoundHitTest( star ) ) {
enemy->m_CurrentFrame = 1;
enemy->m_NextFrameTime = ms_per_frame;
current_screen = YOU_WIN_SCREEN;
enemy->m_Explode->Run();
large_explosion_snd->Play();
}
Projectile* projectile;
std::vector<Projectile*>::iterator it;
for(it=projectile_pool->m_ProjectileList.begin();
it!=projectile_pool->m_ProjectileList.end();it++){
projectile = *it;
if( projectile->m_CurrentFrame == 0 && projectile->m_Active ) {
for( ita = asteroid_list.begin(); ita!=asteroid_list.end();
ita++ ) {
asteroid = *ita;
if( asteroid->m_Active ) {
if( asteroid->HitTest( projectile ) ) {
asteroid->ElasticCollision( projectile );
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
small_explosion_snd->Play();
}
}
}
if( projectile->HitTest( star ) ){
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
small_explosion_snd->Play();
}
else if( player->m_CurrentFrame == 0 &&
( projectile->HitTest( player ) || player->CompoundHitTest(
projectile ) ) ) {
if( player->m_Shield->m_Active == false ) {
player->m_CurrentFrame = 1;
player->m_NextFrameTime = ms_per_frame;
current_screen = GAME_OVER_SCREEN;
player->m_Explode->Run();
large_explosion_snd->Play();
}
else {
hit_snd->Play();
player->ElasticCollision( projectile );
}
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
}
else if( enemy->m_CurrentFrame == 0 &&
( projectile->HitTest( enemy ) || enemy->CompoundHitTest(
projectile ) ) ) {
if( enemy->m_Shield->m_Active == false ) {
enemy->m_CurrentFrame = 1;
enemy->m_NextFrameTime = ms_per_frame;
current_screen = YOU_WIN_SCREEN;
enemy->m_Explode->Run();
large_explosion_snd->Play();
enemy->m_Shield->m_ttl -= 1000;
}
else {
enemy->ElasticCollision( projectile );
hit_snd->Play();
}
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
}
}
}
for( ita = asteroid_list.begin(); ita != asteroid_list.end(); ita++ ) {
asteroid = *ita;
if( asteroid->m_Active ) {
if( asteroid->HitTest( star ) ) {
asteroid->Explode();
small_explosion_snd->Play();
}
}
else { continue; }
if( player->m_CurrentFrame == 0 && asteroid->m_Active &&
( asteroid->HitTest( player ) || player->CompoundHitTest(
asteroid ) ) ) {
if( player->m_Shield->m_Active == false ) {
player->m_CurrentFrame = 1;
player->m_NextFrameTime = ms_per_frame;
player->m_Explode->Run();
current_screen = GAME_OVER_SCREEN;
large_explosion_snd->Play();
}
else {
player->ElasticCollision( asteroid );
small_explosion_snd->Play();
}
}
if( enemy->m_CurrentFrame == 0 && asteroid->m_Active &&
( asteroid->HitTest( enemy ) || enemy->CompoundHitTest( asteroid
) ) ) {
if( enemy->m_Shield->m_Active == false ) {
enemy->m_CurrentFrame = 1;
enemy->m_NextFrameTime = ms_per_frame;
enemy->m_Explode->Run();
current_screen = YOU_WIN_SCREEN;
large_explosion_snd->Play();
}
else {
enemy->ElasticCollision( asteroid );
small_explosion_snd->Play();
}
}
}
Asteroid* asteroid_1;
Asteroid* asteroid_2;
std::vector<Asteroid*>::iterator ita_1;
std::vector<Asteroid*>::iterator ita_2;
for( ita_1 = asteroid_list.begin(); ita_1 != asteroid_list.end();
ita_1++ ) {
asteroid_1 = *ita_1;
if( !asteroid_1->m_Active ) { continue; }
for( ita_2 = ita_1+1; ita_2 != asteroid_list.end(); ita_2++ ) {
asteroid_2 = *ita_2;
if( !asteroid_2->m_Active ) { continue; }
if(asteroid_1->HitTest(asteroid_2)) {
asteroid_1->ElasticCollision( asteroid_2 ); }
}
}
}
你会注意到玩家被消灭的每一行,都有一个player->m_Explode->Run()
的召唤。我们现在通过调用current_screen = GAME_OVER_SCREEN
将屏幕设置为玩家丢失的屏幕。另一种方法是在Ship
类中添加一个函数,该函数运行爆炸动画并设置游戏屏幕,但我选择通过在main
函数中进行更改来修改更少的文件。如果我们使用这个项目不仅仅是为了演示,我可能会用另一种方式。
我们对碰撞所做的其他改变是相似的。每当通过运行enemy->m_Explode->Run()
功能消灭了一个敌人,我们就用一行将当前屏幕设置为“你赢了”屏幕,如下所示:
current_screen = YOU_WIN_SCREEN;
从开始屏幕到游戏的突然转变可能会有点不和谐。为了使过渡更加平滑,我们将创建一个名为draw_play_transition
的过渡功能,它将使用 alpha 渐变将我们的屏幕从开始屏幕过渡到游戏屏幕。这个函数是这样的:
void draw_play_transition() {
transition_time -= diff_time;
if( transition_time <= 0 ) {
current_screen = PLAY_SCREEN;
return;
}
render_manager->RenderStartBackground(transition_time/4);
}
该函数使用我们之前创建的transition_time
全局变量,并减去自上一帧以来的时间(以毫秒为单位)。当绘制开始屏幕背景时,它使用该值除以 4 作为 alpha 值,以便在过渡到游戏时淡出。当过渡时间降至 0 以下时,我们将当前屏幕设置为播放屏幕。过渡开始时,我们将transition_time
设置为 1020 毫秒,比一秒多一点。将该值除以 4 得到一个从 255(完全不透明度)过渡到 0(完全透明度)的值。
需要修改game_loop
功能,为每个屏幕执行不同的逻辑。以下是游戏循环的新版本:
void game_loop() {
current_time = SDL_GetTicks();
diff_time = current_time - last_time;
delta_time = diff_time / 1000.0;
last_time = current_time;
if( current_screen == START_SCREEN ) {
start_input();
start_render();
}
else if( current_screen == PLAY_SCREEN || current_screen ==
PLAY_TRANSITION ) {
play_input();
move();
collisions();
play_render();
if( current_screen == PLAY_TRANSITION ) {
draw_play_transition();
}
}
else if( current_screen == YOU_WIN_SCREEN || current_screen ==
GAME_OVER_SCREEN ) {
end_input();
move();
collisions();
play_render();
play_again_btn->RenderUI();
if( current_screen == YOU_WIN_SCREEN ) {
you_win_sprite->RenderUI();
}
else {
game_over_sprite->RenderUI();
}
}
}
我们有新的分支逻辑,基于当前屏幕进行分支。如果当前屏幕是开始屏幕,则运行第一个if
块。它运行start_input
和start_render
功能:
if( current_screen == START_SCREEN ) {
start_input();
start_render();
}
除了这段代码末尾的PLAY_TRANSITION
周围的if
块外,播放屏幕和播放过渡与原始游戏循环具有相同的逻辑。这通过调用我们前面定义的draw_play_transition()
函数来绘制游戏过渡:
else if( current_screen == PLAY_SCREEN || current_screen == PLAY_TRANSITION ) {
play_input();
move();
collisions();
play_render();
if( current_screen == PLAY_TRANSITION ) {
draw_play_transition();
}
}
这个函数的最后一段代码是为屏幕上的游戏准备的。如果当前屏幕为YOU_WIN_SCREEN
,将渲染you_win_sprite
;如果当前屏幕为GAME_OVER_SCREEN
,将渲染game_over_sprite
;
else if( current_screen == YOU_WIN_SCREEN || current_screen ==
GAME_OVER_SCREEN ) {
end_input();
move();
collisions();
play_render();
play_again_btn->RenderUI();
if( current_screen == YOU_WIN_SCREEN ) {
you_win_sprite->RenderUI();
}
else {
game_over_sprite->RenderUI();
}
}
在我们对游戏循环进行更改后,我们需要为按钮添加一些回调函数。这些功能中的第一个是play_click
功能。这是当玩家点击开始屏幕上的播放按钮时运行的回调。该功能将当前屏幕设置为播放过渡,并将过渡时间设置为 1,020 毫秒:
void play_click() {
current_screen = PLAY_TRANSITION;
transition_time = 1020;
}
之后,我们将定义play_again_click
回调。当玩家点击游戏结束屏幕上的再次播放按钮时,该功能运行。因为这是一个网络游戏,我们将使用一个小技巧来简化这个逻辑。在为几乎任何其他平台编写的游戏中,您需要创建一些重新初始化逻辑,这些逻辑必须返回到您的游戏中,并重置所有内容的状态。我们将通过简单地使用 JavaScript 重新加载网页来欺骗:
void play_again_click() {
EM_ASM(
location.reload();
);
}
这种欺骗并不适用于所有游戏。重新加载一些游戏会导致不可接受的延迟。对于一些游戏来说,可能有太多的状态信息需要我们保存。然而,对于这个游戏来说,重新加载页面是一个快速简单的完成任务的方法。
我们在应用中使用main
函数来执行所有的游戏初始化。这是我们需要添加一些代码来初始化我们将在我们的游戏屏幕上使用的精灵和我们的新按钮的地方。
在下面的代码片段中,我们有了新的 sprite 初始化行:
game_over_sprite = new UISprite( 400, 300, (char*)"/sprites/GameOver.png" );
game_over_sprite->m_Active = true;
you_win_sprite = new UISprite( 400, 300, (char*)"/sprites/YouWin.png" );
you_win_sprite->m_Active = true;
您可以看到我们正在将game_over_sprite
坐标和you_win_sprite
坐标设置为400, 300
。这将把这些精灵放在屏幕的中央。我们将这两个精灵都设置为活动的,因为它们无论如何都只会在最终游戏屏幕上呈现。在后面的代码中,我们将调用UIButton
对象的构造函数:
play_btn = new UIButton(400, 500,
(char*)"/sprites/play_button.png",
(char*)"/sprites/play_button_hover.png",
(char*)"/sprites/play_button_click.png",
play_click );
play_again_btn = new UIButton(400, 500,
(char*)"/sprites/play_again_button.png",
(char*)"/sprites/play_again_button_hover.png",
(char*)"/sprites/play_again_button_click.png",
play_again_click );
这将这两个按钮放在400, 500
上,以 x 轴为中心,但靠近 y 轴上的游戏屏幕底部。回调被设置为play_click
和play_again_click
,这是我们之前定义的。以下是整个main
功能的外观:
int main() {
SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO );
int return_val = SDL_CreateWindowAndRenderer( CANVAS_WIDTH,
CANVAS_HEIGHT, 0, &window, &renderer );
if( return_val != 0 ) {
printf("Error creating renderer %d: %s\n", return_val,
IMG_GetError() );
return 0;
}
SDL_SetRenderDrawColor( renderer, 0, 0, 0, 255 );
SDL_RenderClear( renderer );
game_over_sprite = new UISprite( 400, 300,
(char*)"/sprites/GameOver.png" );
game_over_sprite->m_Active = true;
you_win_sprite = new UISprite( 400, 300,
(char*)"/sprites/YouWin.png" );
you_win_sprite->m_Active = true;
last_frame_time = last_time = SDL_GetTicks();
player = new PlayerShip();
enemy = new EnemyShip();
star = new Star();
camera = new Camera(CANVAS_WIDTH, CANVAS_HEIGHT);
render_manager = new RenderManager();
locator = new Locator();
enemy_laser_snd = new Audio(ENEMY_LASER, false);
player_laser_snd = new Audio(PLAYER_LASER, false);
small_explosion_snd = new Audio(SMALL_EXPLOSION, true);
large_explosion_snd = new Audio(LARGE_EXPLOSION, true);
hit_snd = new Audio(HIT, false);
device_id = SDL_OpenAudioDevice(NULL, 0, &(enemy_laser_snd->spec),
NULL, 0);
if (device_id == 0) {
printf("Failed to open audio: %s\n", SDL_GetError());
}
SDL_PauseAudioDevice(device_id, 0);
int asteroid_x = 0;
int asteroid_y = 0;
int angle = 0;
// SCREEN 1
for( int i_y = 0; i_y < 8; i_y++ ) {
asteroid_y += 100;
asteroid_y += rand() % 400;
asteroid_x = 0;
for( int i_x = 0; i_x < 12; i_x++ ) {
asteroid_x += 66;
asteroid_x += rand() % 400;
int y_save = asteroid_y;
asteroid_y += rand() % 400 - 200;
angle = rand() % 359;
asteroid_list.push_back(
new Asteroid( asteroid_x, asteroid_y,
get_random_float(0.5, 1.0),
DEG_TO_RAD(angle) ) );
asteroid_y = y_save;
}
}
projectile_pool = new ProjectilePool();
play_btn = new UIButton(400, 500,
(char*)"/sprites/play_button.png",
(char*)"/sprites/play_button_hover.png",
(char*)"/sprites/play_button_click.png",
play_click );
play_again_btn = new UIButton(400, 500,
(char*)"/sprites/play_again_button.png",
(char*)"/sprites/play_again_button_hover.png",
(char*)"/sprites/play_again_button_click.png",
play_again_click );
emscripten_set_main_loop(game_loop, 0, 0);
return 1;
}
在下一节中,我们将在我们的ui_button.cpp
文件中定义函数。
UIButton
对象有几个必须定义的功能。我们已经创建了一个新的ui_button.cpp
文件来保存所有这些新功能。我们需要定义一个构造函数,以及MouseMove
、MouseClick
、MouseUp
、KeyDown
和RenderUI
。
首先,我们将包括我们的game.hpp
文件:
#include "game.hpp"
现在,我们将定义我们的构造函数:
UIButton::UIButton( int x, int y, char* file_name, char* hover_file_name, char* click_file_name, void (*callback)() ) {
m_Callback = callback;
m_dest.x = x;
m_dest.y = y;
SDL_Surface *temp_surface = IMG_Load( file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button surface\n");
}
m_SpriteTexture = SDL_CreateTextureFromSurface( renderer,
temp_surface );
if( !m_SpriteTexture ) {
return;
}
SDL_QueryTexture( m_SpriteTexture,
NULL, NULL,
&m_dest.w, &m_dest.h );
SDL_FreeSurface( temp_surface );
temp_surface = IMG_Load( click_file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button click surface\n");
}
m_ClickTexture = SDL_CreateTextureFromSurface( renderer,
temp_surface );
if( !m_ClickTexture ) {
return;
}
SDL_FreeSurface( temp_surface );
temp_surface = IMG_Load( hover_file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button hover surface\n");
}
m_HoverTexture = SDL_CreateTextureFromSurface( renderer,
temp_surface );
if( !m_HoverTexture ) {
return;
}
SDL_FreeSurface( temp_surface );
m_dest.x -= m_dest.w / 2;
m_dest.y -= m_dest.h / 2;
m_Hover = false;
m_Click = false;
m_Active = true;
}
构造函数从设置传入参数的回调函数开始:
m_Callback = callback;
然后,它根据我们传入的参数设置m_dest
矩形的x
和y
坐标:
m_dest.x = x;
m_dest.y = y;
之后,它将三个不同的图像文件加载到按钮的三个不同纹理中,即按钮的悬停状态和按钮的单击状态:
SDL_Surface *temp_surface = IMG_Load( file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button surface\n");
}
m_SpriteTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );
if( !m_SpriteTexture ) {
return;
}
SDL_QueryTexture( m_SpriteTexture,
NULL, NULL,
&m_dest.w, &m_dest.h );
SDL_FreeSurface( temp_surface );
temp_surface = IMG_Load( click_file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button click surface\n");
}
m_ClickTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );
if( !m_ClickTexture ) {
return;
}
SDL_FreeSurface( temp_surface );
temp_surface = IMG_Load( hover_file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button hover surface\n");
}
m_HoverTexture = SDL_CreateTextureFromSurface( renderer, temp_surface );
if( !m_HoverTexture ) {
return;
}
SDL_FreeSurface( temp_surface );
前面的代码看起来应该很熟悉,因为加载一个图像文件到一个SDL_Texture
对象中是我们已经做了很多的事情。之后,我们使用前面查询的宽度和高度值来居中目标矩形:
m_dest.x -= m_dest.w / 2;
m_dest.y -= m_dest.h / 2;
然后,我们设置悬停、点击和活动状态标志:
m_Hover = false;
m_Click = false;
m_Active = true;
我们需要一个函数来确定鼠标光标是否已经移动到我们的按钮上。我们从输入函数中调用MouseMove
函数,然后传入当前鼠标光标x
和y
坐标。我们对照我们的m_dest
矩形检查这些坐标,看它们是否重叠。如果是这样,我们将悬停旗设置为true
。如果没有,我们将悬停标志设置为false
:
void UIButton::MouseMove(int x, int y) {
if( x >= m_dest.x && x <= m_dest.x + m_dest.w &&
y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
m_Hover = true;
}
else {
m_Hover = false;
}
}
MouseClick
功能与MouseMove
功能非常相似。当用户按下鼠标左键时,我们的输入函数也会调用它。鼠标光标的x
和y
坐标被传入,该功能使用m_dest
矩形查看鼠标光标在点击按钮时是否在按钮上方。如果是,我们将点击标志设置为true
。如果没有,我们将点击标志设置为false
:
void UIButton::MouseClick(int x, int y) {
if( x >= m_dest.x && x <= m_dest.x + m_dest.w &&
y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
m_Click = true;
}
else {
m_Click = false;
}
}
当鼠标左键被释放时,我们调用这个函数。无论鼠标光标坐标是什么,我们都要将点击标志设置为false
。如果释放按钮时鼠标在按钮上,并且按钮被点击,我们需要调用回调函数:
void UIButton::MouseUp(int x, int y) {
if( m_Click == true &&
x >= m_dest.x && x <= m_dest.x + m_dest.w &&
y >= m_dest.y && y <= m_dest.y + m_dest.h ) {
if( m_Callback != NULL ) {
m_Callback();
}
}
m_Click = false;
}
我本可以让按键功能更灵活一点。最好将热键设置为在对象中设置的值。支持屏幕上不止一个按钮。事实上,如果有人点击进入键,屏幕上的所有按钮都会被点击。这对于我们的游戏来说不是问题,因为我们不会在一个屏幕上有一个以上的按钮,但是如果你想提高热键功能,这应该不会太难。就功能而言,它会将正在检查的键硬编码到SDLK_RETURN
。以下是我们拥有的功能版本:
void UIButton::KeyDown( SDL_Keycode key ) {
if( key == SDLK_RETURN) {
if( m_Callback != NULL ) {
m_Callback();
}
}
}
RenderUI
功能检查按钮中的各种状态标志,并根据这些值呈现正确的子画面。如果m_Active
标志为false
,则该功能不渲染任何内容。以下是功能:
void UIButton::RenderUI() {
if( m_Active == false ) {
return;
}
if( m_Click == true ) {
render_manager->RenderUI( m_ClickTexture, NULL, &m_dest, 0.0,
0xff, 0xff, 0xff, 0xff );
}
else if( m_Hover == true ) {
render_manager->RenderUI( m_HoverTexture, NULL, &m_dest, 0.0,
0xff, 0xff, 0xff, 0xff );
}
else {
render_manager->RenderUI( m_SpriteTexture, NULL, &m_dest, 0.0,
0xff, 0xff, 0xff, 0xff );
}
}
在下一节中,我们将在我们的ui_sprite.cpp
文件中定义函数。
UISprite
类相当简单。它只有两个功能:构造函数和呈现函数。与我们项目中的其他所有 CPP 文件一样,我们必须做的第一件事是包含game.hpp
文件:
#include "game.hpp"
构造函数非常熟悉。它将m_dest
矩形的x
和y
值设置为传递给构造函数的值。它使用我们作为参数传入的file_name
变量从虚拟文件系统加载纹理。最后,它使用使用SDL_QueryTexture
函数检索的宽度和高度值使m_dest
矩形居中。下面是构造函数的代码:
UISprite::UISprite( int x, int y, char* file_name ) {
m_dest.x = x;
m_dest.y = y;
SDL_Surface *temp_surface = IMG_Load( file_name );
if( !temp_surface ) {
printf("failed to load image: %s\n", IMG_GetError() );
return;
}
else {
printf("success creating ui button surface\n");
}
m_SpriteTexture = SDL_CreateTextureFromSurface( renderer,
temp_surface );
if( !m_SpriteTexture ) {
return;
}
SDL_QueryTexture( m_SpriteTexture,
NULL, NULL,
&m_dest.w, &m_dest.h );
SDL_FreeSurface( temp_surface );
m_dest.x -= m_dest.w / 2;
m_dest.y -= m_dest.h / 2;
}
我们雪碧的RenderUI
功能也很简单。它检查精灵是否是活动的,如果是,调用渲染管理器的RenderUI
功能。下面是代码:
void UISprite::RenderUI() {
if( m_Active == false ) {
return;
}
render_manager->RenderUI( m_SpriteTexture, NULL, &m_dest, 0.0,
0xff, 0xff, 0xff, 0xff );
}
现在我们已经给我们的游戏添加了一个用户界面,让我们编译它,从我们的 web 服务器或 emrun 提供它,并在 web 浏览器中打开它。下面是我们编译ui.html
文件所需的em++
命令:
em++ asteroid.cpp audio.cpp camera.cpp collider.cpp emitter.cpp enemy_ship.cpp finite_state_machine.cpp locator.cpp main.cpp particle.cpp player_ship.cpp projectile_pool.cpp projectile.cpp range.cpp render_manager.cpp shield.cpp ship.cpp star.cpp ui_button.cpp ui_sprite.cpp vector.cpp -o ui.html --preload-file audio --preload-file sprites -std=c++ 17 -s USE_WEBGL2=1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"] -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=["png"]
新版本将打开我们的开始屏幕。如果你想玩游戏,你现在需要点击播放按钮。下面是截图:
Figure 14.6: Opening screen
你会注意到开屏有如何玩游戏的说明。在面向动作的网络游戏中有一个打开的屏幕通常是好的,因为当页面加载时,玩家并不总是准备好玩。并不是所有的网络游戏都需要打开屏幕。我的网站,classicsolitaire.com,一个都没有。这是因为接龙是一个基于回合的游戏,玩家不会马上投入到游戏中。您的游戏的用户界面需求可能与我们为这本书编写的游戏不同。所以,画一个故事板,花时间收集需求。你会很高兴你做到了。
在本章中,我们花了一些时间收集用户界面的需求。我们创建了一个故事板来帮助我们思考我们的游戏需要什么屏幕,以及它们可能是什么样子。我们讨论了我们的开屏布局,以及我们为什么需要它。然后,我们将原本是我们整个游戏的屏幕变成了游戏屏幕。然后,我们讨论了游戏在屏幕上的布局以及需要哪些用户界面元素,并学习了如何使用 SDL 来检索鼠标输入。我们还创建了一个按钮类作为用户界面的一部分,以及一个屏幕状态的枚举,并讨论了这些状态之间的转换。然后,我们添加了一个 sprite 用户界面对象,然后修改我们的渲染管理器,以允许我们渲染我们的开始屏幕的背景图像。最后,我们对代码进行了修改,以支持多个游戏屏幕。
在下一章中,我们将学习如何编写新的着色器,并使用网络组件的 OpenGL 应用编程接口来实现它们。