Re0:从零开始的C++游戏开发
这是蒟蒻观看B站upVoidmatrix的课程从零开始的提瓦特幸存者的个人笔记【自用】
前言:采用适用于小白的easyx图形库。
第一集 追着鼠标的小球
#include <graphics.h>
#include <iostream>
int main(void)
{
// 初始化
initgraph(1280, 720);
int x = 640;
int y = 360;
// 双缓冲绘图
BeginBatchDraw();
// 主循环
while (true)
{
ExMessage msg;
// 读取操作
while (peekmessage(&msg))
{
// 在这里进行消息处理逻辑
if (msg.message == WM_MOUSEMOVE)
{
// 数据处理(由于数据处理逻辑简单,所以嵌套在读取操作中)
x = msg.x;
y = msg.y;
}
}
// 绘制画面
cleardevice();
solidcircle(x, y, 100);
FlushBatchDraw();
}
EndBatchDraw();
return 0;
}
绘图坐标系
easyx中,绘图坐标系相似于二维数组坐标系,即y轴的反转
渲染缓冲区
渲染缓冲区:类似画笔在画布上画画,先绘制的内容就可能被后绘制的内容覆盖掉
cleardevice()
就是使当前填充颜色将画布覆盖,默认填充色为黑色
当我们调用solidcircle()
这类绘图函数时,一个无边框的填充圆被“逐渐地”绘制到这张画布上
当我们不断清屏,不断画圆,逐渐地过程在“宏观上”体现出来了
而当我们调用BeginBatchDraw();
easyx为我们新建一个渲染缓冲区,不同于窗口的渲染缓冲区,它默认是不可见的,随后执行的所有绘制都将在新的画布上进行
而当我们调用FlushBatchDraw();
或EndBatchDraw();
时,easyx会将窗口所显示的缓冲区和新建的缓冲区进行“迅速”交换,这样的交换迅速到我们不会因绘图过程频繁而导致闪烁
游戏框架
*主循环
在上述程序中,我们通过一个while(true)
死循环阻塞程序退出,同时不断执行清屏和绘制的操作,这其实就是游戏框架最核心的部分——主循环
在主循环中,我们不断读取玩家的(鼠标、键盘等)操作,将这些操作翻译成我们的数理逻辑,最后再根据现有的数据将画面内容绘制出来
简而言之,就是读取操作、处理数据、绘制画面这三大要素
初始化();
while(true)
{
读取操作();
处理数据();
绘制画面();
}
释放资源();
如上述代码,我们游戏的渲染部分只依赖于当前的数据,依旧是变量x和y的值,而与如何处理得到这些数据的处理逻辑并未有直接关系。这就是软件工程理论中的”解耦耦合“,或者说这就是"数据驱动",或者说“渲染与逻辑分离”中最朴素的思想。
当然,在主循环开始之前,我们需要把主循环过程中所需要的数据初始化,如:将圆的位置坐标初始化、初始化窗口等。
而在主循环结束后,需要对游戏程序使用的资源进行释放。
第二集 进击的井字棋
三大元素
在代码编写之前,
我们首先根据前面所讲述的游戏框架,思考在井字棋的主循环中三大要素如何设计实现。
初始化();
while(true)
{
读取操作();
处理数据();
绘制画面();
}
释放资源();
-
读取操作:
在本程序中,我们只对鼠标输入进行考虑,所以我们只需对鼠标按键按下的消息进行处理:当鼠标点击在空白的棋盘网格时,便执行落子操作。
-
数据处理:
我们只需要对游戏的胜负条件进行检测即可,游戏结束的条件是同类型三颗棋子连成一条直线或棋盘被填满。
游戏结束时,使用弹窗告诉玩家游戏结果,然后退出主循环。
-
绘制画面
网格棋盘:使用
line()
函数绘制直线将窗口等分为3X3
的网格X棋子
:使用line()
函数绘制连接网格对角线的两条直线O棋子
:使用circle()
函数绘制圆心在网格中心的无填充原型除此之外,我们应会在窗口左上角输出一行文字
当前妻子类型:X
,用以告诉玩家当前被放置的棋子类型。
数据结构
接下来,便是考虑如何组织游戏的数据结构。
-
棋盘:
显而易见,我们可以使用二维数组来表示棋盘。我们将二维数组中每个元素类型设置为
char
类型,再约定'X'字符
表示叉号棋子、'O'字符
表示圆形棋子、'-'字符
默认值表示网格中没有棋子 -
游戏结束条件
2.1 某玩家获胜的情况
我们著需要对
'X'字符
、'O'字符
进行穷举,可能出现的情况一共有8种:分别是横向的三行棋子出现同类型符号、竖向的三行棋子出现同类型符号和两条对角线的棋子出现同类型符号。2.2 两玩家平局的情况
即没有玩家获胜的情况,也就是说数组中的每一个元素均不是
'-'字符
,即可判定玩家平局。
代码编写
到现在,在我们的思路已经十分明晰后,我们着手编写代码。
在代码编写的过程中,我们同样遵循先框架后细化的思路。
我们先把上述思路转变成代码,细节部分先使用注释进行替代,再将每一部分的注释替换为代码。这样可以确保我们在编写代码的过程中不会被突然出现的代码细节打扰。
实现如下:
#include <graphics.h>
#include <iostream>
// 简单粗暴的全局变量并非一个好习惯
char board_data[3][3]
{
{'-','-','-'},
{'-','-','-'},
{'-','-','-'}
};
// 当前落子类型,初始化为'O'
char cur_piece = 'O';
// 检测指定棋子的玩家是否获胜
bool CheckWin(char c)
{
if (board_data[0][0] == c && board_data[0][1] == c && board_data[0][2] == c)
return true;
if (board_data[1][0] == c && board_data[1][1] == c && board_data[1][2] == c)
return true;
if (board_data[2][0] == c && board_data[2][1] == c && board_data[2][2] == c)
return true;
if (board_data[0][0] == c && board_data[1][0] == c && board_data[2][0] == c)
return true;
if (board_data[0][1] == c && board_data[1][1] == c && board_data[2][1] == c)
return true;
if (board_data[0][2] == c && board_data[1][2] == c && board_data[2][2] == c)
return true;
if (board_data[0][0] == c && board_data[1][1] == c && board_data[2][2] == c)
return true;
if (board_data[0][2] == c && board_data[1][1] == c && board_data[2][0] == c)
return true;
return false;
}
// 检测当前是否出现平局
bool CheckDraw()
{
for (size_t i = 0; i < 3; i++)
for (size_t j = 0; j < 3; j++)
if (board_data[i][j] == '-') return false;
return true;
}
// 绘制网格棋盘
void DrawBoard()
{
line(0, 200, 600, 200);
line(0, 400, 600, 400);
line(200, 0, 200, 600);
line(400, 0, 400, 600);
}
// 绘制棋子
void DrawPiece()
{
for (size_t i = 0; i < 3; i++)
{
for (size_t j = 0; j < 3; j++)
{
switch (board_data[i][j])
{
case 'O':
circle(200 * j + 100, 200 * i + 100, 100);
break;
case 'X':
line(200 * j, 200 * i, 200 * (j + 1), 200 * (i + 1));
line(200 * (j + 1), 200 * i, 200 * j, 200 * (i + 1));
break;
case '-':
break;
default:
break;
}
}
}
}
// 绘制左上角文本提示信息
void DrawTipText()
{
static TCHAR str[64];
_stprintf_s(str, "Current Type of Piece: %c", cur_piece);
settextcolor(RGB(225, 175, 45));
outtextxy(0, 0, str);
}
int main(void)
{
// 初始化窗口
initgraph(600, 600);
// 控制主循环是否进行下去
bool running = true;
// 消息处理
ExMessage msg;
// 双缓冲
BeginBatchDraw();
// 主循环
while (running)
{
DWORD start_time = GetTickCount();
// 鼠标消息检测
while (peekmessage(&msg))
{
// 检查鼠标左键按下消息
if (msg.message == WM_LBUTTONDOWN)
{
// 计算点击位置
int x = msg.x;
int y = msg.y;
// 由于每个网格都是200X200
int ind_x = x / 200;
int ind_y = y / 200;
// 尝试落子
if (board_data[ind_y][ind_x] == '-')
{
board_data[ind_y][ind_x] = cur_piece;
// 切换棋子类型
if (cur_piece == 'O') cur_piece = 'X';
else cur_piece = 'O';
}
}
}
// 绘制图像
cleardevice();
DrawBoard();
DrawPiece();
DrawTipText();
FlushBatchDraw();
// X玩家获胜逻辑
if (CheckWin('X'))
{
// 弹窗
MessageBox(GetHWnd(), "X Player WIN!", "Game End", MB_OK);
// 修改主循环控制条件
running = false;
}
// O玩家获胜逻辑
else if(CheckWin('O'))
{
// 弹窗
MessageBox(GetHWnd(), "O Player WIN!", "Game End.", MB_OK);
// 修改主循环控制条件
running = false;
}
// 上述条件都不满足时,对游戏平局检测
else if (CheckDraw())
{
// 弹窗
MessageBox(GetHWnd(), "Ops!It's DRAW.", "Game End.", MB_OK);
// 修改主循环控制条件
running = false;
}
// 依据间隔时间动态分配休眠时间
DWORD end_time = GetTickCount();
DWORD delta_time = end_time - start_time;
// 按每秒60帧刷新页面
if (delta_time < 1000 / 60)
{
Sleep(1000 / 60 - delta_time);
}
}
EndBatchDraw();
return 0;
}
值得注意的是,在程序运行时的程序占用率过高,通过任务管理器也可以发现一个小小的井字棋游戏,CPU占用率甚至已经超过了电脑中的绝大部分软件。这是因为计算机在执行while循环
时速度较快,我们编写的主循环在顷刻间已经执行完了成千上万次,占用了大量的CPU时间片。对于大部分物理刷新率仅有60Hz
的显示设备来说,这无疑是一种性能浪费。所以我们可以使用Sleep();
函数来让程序在执行完一次循环后休眠一小段时间,从而减少计算资源的浪费。
在大多教程中,这里或许会简答粗暴的写一句Sleep(15)
,来让程序在每一次循环结束后强制等待15毫秒。但是,这种设计是不太合适的,随游戏体量的增大,程序每次执行主循环所执行的计算任务可能是不同的,以及涉及到操作系统CPU计算资源的分配,这就导致每次执行主循环所实际消耗的时间可能是不一样的。所以我们需要根据每一帧执行的实际耗时,动态的计算在这之后要休眠多长时间,这是引入一个新的函数GetTickCount()
。我们可以使用它来获得程序自运行开始以来到现在的毫秒数DWORD time = GetTickCount();
。
初始化();
while(true)
{
DWORD start_time = GetTickCount(); // 获取此次循环初始时间
读取操作();
处理数据();
绘制画面();
DWORD end_time = GetTickCount(); // 获取此次循环结束时间
DWORD delta_time = end_time - start_time; // 计算间隔时间
// 依据间隔时间动态分配休眠时间
// 按每秒60帧刷新页面
if (delta_time < 1000 / 60) // 如果间隔时间<每秒60帧,要进行休眠;否则不需要。
{
Sleep(1000 / 60 - delta_time);
}
}
释放资源();