学习了那么那么多(其实也就一点点)的 C++ 和 OOP 的思想, 不来点有意思的实战有什么意思呢? 正巧最近在B站冲浪的时候刷到一个宝藏视频合集, 介绍了从零开始的C++游戏开发, 于是跃跃欲试…

Day 1

环境搭建

VSCode + OpenGL 太难配置了, 浪费一个下午, 最终决定用懒人 IDE – VS, VS 赛高!!!

IDE: Visual Studio Community 2022
图形库: EasyX
位图编辑: Picxel Studio

VS

安装时仅需勾选"使用C++的桌面开发"

EasyX

安装最新版本, 打开后为自己将要使用的 VS C++ 安装 EasyX

环境测试

打开 VS, 创建空 C++ 项目命名为 demo, 新建 main.cpp, 键入以下测试代码

#include <graphics.h>
#include <iostream>

int main() {
printf("%ws", GetEasyXVer());
}

按下 CRTL + F5 输出 2023, 即为成功

代码编写

尝试编写一个交互式程序: 一个小球跟着鼠标移动

善用 EasyX 的文档, 毕竟这是为数不多的中文优质文档了

画一个圆

键入以下代码

#include <graphics.h>
#include <iostream>

int main() {
initgraph(1280, 720); // 初始化一个 1280 * 720 的窗口
while (1) { // 游戏循环进行
// 在 (300, 300) 处绘制一个半径为 100 的实心圆
solidcircle(300, 300, 100);
}
}

效果如下

移动逻辑

#include <graphics.h>
#include <iostream>

int main() {
initgraph(1280, 720);
int x = 300, y = 300;
while (1) {
ExMessage msg; // 声明消息类型变量 msg

// 从消息队列中获取 msg
while (peekmessage(&msg)) {
// 更新圆心坐标
x = msg.x;
y = msg.y;
}

cleardevice(); // 清空画布
solidcircle(x, y, 100); // 绘制圆
}
}

EasyX 用消息队列管理消息 Message

在 EasyX 中, 以下行为都被称作 Message

  • 鼠标的移动
  • 鼠标的点击
  • 键盘的按键

当我们触发消息时, EasyX 会将消息存放在消息队列中

peekmessage() 会尝试从消息队列中获取消息

运行后我们发现, 虽然我们实现了圆的移动, 但是画布却在不停闪屏, 这并不是一个友好的结果, 这是因为我们没有应用双缓冲对绘图过程进行优化

加入以下三行代码即可优化绘图过程

#include <graphics.h>
#include <iostream>

int main() {
initgraph(1280, 720);
int x = 300, y = 300;

// 代码 1
BeginBatchDraw();

while (1) {
ExMessage msg;

while (peekmessage(&msg)) {
x = msg.x;
y = msg.y;
}

cleardevice();
solidcircle(x, y, 100);
// 代码 2
FlushBatchDraw();
}

// 代码 3
EndBatchDraw();
}

其他相关

导出为 exe

有时候我们会想要导出我们的游戏为 exe

  1. 在项目界面选择 项目 - 属性, 修改 配置属性 - C/C++ - 代码生成 - 运行库多线程(/MT)
  2. 然后选择 生成 - 生成解决方案 / CTRL + SHIFT + B
  3. 生成的 exe 文件就在 .\x64\Debug\ 下

代码提交

git 新手是这样的

在本地仓库修改完文件后依次运行以下命令提交代码

git add .   # 将工作区的文件添加到暂存区
git commit -m "." # 将某些文件提交到版本库
git push -u origin main # 将本地的 main 分支推送到 origin 主机

VSCode 可以很方便的用 git 提交代码

Day 2

绘图坐标系

和其他 Windows 窗口, 图形库的坐标计算方法一样, EasyX 的坐标计算方法也是以左上角为 (0, 0), 向右向下为正方向, 一个 1280 * 720 的窗口可以描述如下

(0, 0)-------------------------(1280, 0)------(positive x)
| |
| |
| (x, y) |
| |
| |
| |
(0, 720)-----------------------(1280, 720)
|
|
|
(positive y)

渲染缓冲区

产生 Day 1 最后所说的 “不停闪屏” 的问题的原因主要是, EasyX 的画布的绘制不是连续的, 当我们调用 cleardevice() 的时候, 这个逐渐的过程就体现在了宏观上

当我们调用了 BeginBatchDraw(), EasyX 便会为我们新建一个画布–渲染缓冲区, 和窗口不一样, 他是默认不可见的, 所有绘图都会在缓冲区上进行, 当我们调用 FlushBatchDraw()EndBatchDraw() 时, EasyX 会交换窗口和缓冲区的画布, 这样绘图过程的频繁闪烁就会被隐藏了, 如果我们用流程图描述就如下

可视的窗口画布
[画布 1]
|
| Call BeginBatchDraw();
|
| 不可视的缓冲区画布
[画布 1]-----------[画布 2]
| |
| | Call Draw();
| |
| |
|---------------[画布 2*] // 逐渐的绘制过程
| |
| | Call FlushBatchDraw();
| | // 交换画布
| |
[画布 2*]----------[画布 1]

游戏基本框架

一个游戏的基本框架可以描述如下

Initialize();
While(True){
ReadOperation();
DataProcessing(); -> Data
DrawPicture(Data);
}
MemoryFree();

其中, 在处理数据和绘制画面两个环节, 我们使用数据来沟通两者, 即处理完毕的数据发送给绘制画面函数, 但是绘制画面函数并不关心数据处理函数的逻辑细节, 只关心发送过来的数据

这就是软件工程理论中的解耦合, 换言之就是 “数据驱动” 或 “渲染与逻辑分离” 中最朴素的思想

井字棋游戏设计

根据我们的设计理念, 可以设计以下程序框架

/* Tic Tac Toe Game Design */

#include <graphics.h>

/* Check whether player with certain color has won */
bool CheckWin(char chr) {

}

/* Check whether it is a draw */
bool CheckDraw() {

}

/* Draw the net of board */
void DrawBoard() {

}

/* Draw piece */
void DrawPiece() {

}

/* Draw current tip text */
void DrawTipText() {

}

int main() {
/* Initialize */
initgraph(600, 600);
bool running = true;
ExMessage msg;
BeginBatchDraw();


while (running) {

/* ReadOperation */
while (peekmessage(&msg)) {
}

/* DataProcessing */

/* DrawPicture */
cleardevice();
DrawBoard();
DrawPiece();
DrawTipText();
FlushBatchDraw();

/* Game Over Check */
if (CheckWin('X')) {
MessageBox(GetHWnd(),
_T("X Player Win"),
_T("Game Over!"),
MB_OK);
running = false;
}
else if (CheckWin('O')) {
MessageBox(GetHWnd(),
_T("O Player Win"),
_T("Game Over!"),
MB_OK);
running = false;
}
else if (CheckDraw()) {
MessageBox(GetHWnd(),
_T("Draw!"),
_T("Game Over!"),
MB_OK);
}
}

EndBatchDraw();
}

Day 3

数据结构

方便起见, 我们暂时用全局变量储存数据

棋盘和当前棋子类型

char board_data[3][3] = {
{'-', '-', '-'},
{'-', '-', '-'},
{'-', '-', '-'}
};

char current_piece = 'O';

渲染逻辑

可以用 line() 函数绘制直线分割棋盘和绘制 X, 用 circle() 函数绘制 O

/* Draw the net of board  */
void DrawBoard() {
line(0, 200, 600, 200);
line(0, 400, 600, 400);
line(200, 0, 200, 600);
line(400, 0, 400, 600);
}

/* Draw piece */
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;
}

}

填补剩下的函数实现, 点击运行, 我们的井字棋游戏就实现了

源代码有点长就不在这里贴上了, 可以在同步仓库中下载

性能优化

运行我们的游戏, 打开任务管理器, 找到终端, 展开就能找到我们的游戏

天哪! 我们一个这么简单的小游戏, 内存占用就高达 27.7MB

实际上, 这是由于我们使用了 while(true) 导致的, 游戏会不断的高速刷新画面, 内存占用当然大了

0076daf2ef3e51498891.png

我们可以通过降低 while(true) 的刷新频率减少资源的消耗

while(running){
DWORD start_time = GetTickCount();

/* Boby Function */

DWORD end_time = GetTickCount();
DWORD delta_time = end_time - start_time;

/* Game is 144Hz */
if(delta_time < 1000 / 144)
Sleep(1000 / 144 - delta_time);
}

Q: 在sleep的时候时间队列还会接收我的操作信息吗,如果是的话,那我这一帧的操作(理论上,操作速度快和运算慢的时候)不就可能延时到下一帧运算和渲染了吗?所以我在想能不能把操作检测运算和渲染分成两个线程,操作检测放到“中断”里(当年课设虚拟机里面用过,不知道windows支不支持,会不会比while好用一些)

A: 非常好的问题,Sleep时确实会接收操作信息,游戏的主循环的本质就是用离散的Tick模拟连续的过程,所以当下的操作是否在数帧之后处理这个在数据层面不应该有影响。不用多线程的原因有如下几个:

  • 首先,在现有的程序框架下,消息处理单独使用线程不会有明显的性能提升,反而会因为引入了资源竞争导致开发难度和debug难度增加,甚至可能因此而降低运行效率;
  • 其次,从设计思想角度考虑,事件队列设计的初衷之一就是为了让多线程的数据能够方便地在单线程Tick中进行同步,如异步加载资源也可以使用消息队列来实现,单独将消息处理开辟线程的话会与这种设计背道而驰;
  • 再者,在如SDL2等库内部,拉取消息的处理要求限定在了主线程内执行,EasyX并未开放源码,文档中也没有提及,无法确定底层是否也有类似设计,强制使用多线程处理事件可能会导致未知的问题。

Day 4

根据学习安排,Day 4 及之后我们的任务是制作一款 “提瓦特幸存者”,但是由于我缺少必要的文件素材导致所有的图片和音效都要我自己制作,综合考量之后我觉得简化一下素材的使用,制作一款以初音未来为主角的割草游戏

图片加载

由于矢量图(也就是 Day 1-3 我们实现图像的方法)的资源消耗太大了,在现代游戏开发中,我们一般使用位图作为贴图

加载并渲染图片

EasyX 为我们提供了 loadimage 函数用来从文件中读取图像

void loadimage(
IMAGE* pDstImg, // 保存图像的指针
LPCTSTB pImgFile, // 图片文件名
int nWidth = 0, // 拉伸宽度
int nHeight = 0, // 拉伸高度
bool bResize false, // 是否调整 IMAGE 的大小以适应图片
);

例如

IMAGE img;
loadimage(&img, _T("test.jpg"));

我认为这里使用智能指针会更优:

auto img = std::make_unique<IMAGE>();
IMAGE p_img;
loadimage(&p_img, _T("image/background.bmp"));
img.reset(&p_img);

渲染图片

void putimage(
int dstX; // 绘制图片的 x 坐标
int dstY; // 绘制图片的 y 坐标
IMAGE *pSrcImg; // 绘制图片的对象指针
DWORD dwRop = SRCCOPY // 三元光栅操作码
);
putimage(100, 200, pImg.get());

下面的程序就可以加载一张大小合适的背景图

/* Miku Running */

#include <graphics.h>


int main() {
initgraph(1280, 720);

bool running = true;
ExMessage msg;
IMAGE img_background;
loadimage(&img_background, _T("image/background.bmp"));

BeginBatchDraw();

while (running) {
while (peekmessage(&msg))
{

}

cleardevice();

putimage(0, 0, &img_background);
FlushBatchDraw();
}

EndBatchDraw();
}

让画面动起来

接下来我们考虑加载 Miku 的角色图, 由于我们选用的 Miku 的角色图是 png 格式的图片(包含透明度信息), EasyX 自带的图片加载无法胜任, 所以我们需要封装一个自己的, 可以加载 png 图片的函数

下面的函数实现了带有透明度的混叠一张图像 img

#pragma comment(lib, "MSIMG32.LIB")

inline void putimage_alpha(int x, int y, IMAGE* img) {
int w = img->getwidth(), h = img->getheight();

AlphaBlend(GetImageHDC(NULL), x, y, w, h,
GetImageHDC(img), 0, 0, w, h,
{ AC_SRC_OVER, 0, 255, AC_SRC_ALPHA });
}

同样的办法, 声明一个 img* 类型的数组, 并通过计数器定时切换现实的图片, 就可以实现图片的动效了

上述做法一般被称为"序列帧动画", 与之相对的还有"关键帧动画", 但是由于涉及到更加复杂的图形学技术, 在这里暂时不予讨论

角色移动

我们通过键盘上的 UP, DOWN, LEFT, RIGHT 四个键控制角色的移动

bool is_move[4] = {false};	// {up, down, left, right}
int PLAYER_SPEED = 20;
...
while (peekmessage(&msg))
{
if (msg.message == WM_KEYDOWN) {
switch (msg.vkcode) {
case VK_UP:
is_move[0] = true;
break;
case VK_DOWN:
is_move[1] = true;
break;
case VK_LEFT:
is_move[2] = true;
break;
case VK_RIGHT:
is_move[3] = true;
break;
}
}
if (msg.message == WM_KEYUP) {
switch (msg.vkcode) {
case VK_UP:
is_move[0] = false;
break;
case VK_DOWN:
is_move[1] = false;
break;
case VK_LEFT:
is_move[2] = false;
break;
case VK_RIGHT:
is_move[3] = false;
break;
}
}
}

if (++current % FPS == 0) {
idx_current_anim++;

if (is_move[0]) player_position.y -= PLAYER_SPEED;
if (is_move[1]) player_position.y += PLAYER_SPEED;
if (is_move[2]) player_position.x -= PLAYER_SPEED;
if (is_move[3]) player_position.x += PLAYER_SPEED;
}

Day 5

从 Day 5 开始, 我们将应用 OO 的思想升级我们之前的代码, 对动画, 玩家, 敌人的行为逻辑进行封装

动画类实现

为了减少代码的冗余, 我们定义动画类 Animation

class Animation {
public:
Animation() {

}
~Animation() {

}
private:

};

完整的 Animation 类的构造和析构如下

class Animation {
public:
Animation(LPCTSTR path, int num, int interval) {
TCHAR path_file[256];
for (size_t i = 0; i < num; ++i) {
_stprintf_s(path_file, path, i + 1);
IMAGE* frame = new IMAGE;
loadimage(frame, path_file);
frame_list.push_back(frame);
}
}
~Animation() {
for (size_t i = 0; i < frame_list.size(); ++i)
delete frame_list[i];
}
private:
std::vector<IMAGE*> frame_list;
};

定义 Play 函数循环播放动画图片

class Animation{
public:
void Play(int x, int y, int delta);
private:
int timer = 0; // Animation timer
int index_frame = 0; // Animation frame index
int interval_ms = 0;
};

void Animation::Play(int x, int y, int delta) {
timer += delta;
if (timer >= interval_ms) {
index_frame = (index_frame + 1) % (frame_list.size());
timer = 0;
}

putimage_alpha(x, y, frame_list[index_frame]);
}

角色移动优化

玩家阴影

为了使角色更加突出, 我们定义 DrawPlayer 函数绘制玩家阴影

void DrawPlayer(int delta) {
int pos_shadow_x = player_position.x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);
int pos_shadow_y = player_position.y;
putimage_alpha(pos_shadow_x, pos_shadow_y, &img_shadow);

anim_player.Play(player_position.x, player_position.y, delta);
}

斜角移动

按照我们之前的移动逻辑, 玩家在斜角移动的时候速度会是普通移动时的 2\sqrt{2} 倍, 我们可以引入标准向量来修复这个手感差的问题

int dir_x = is_move[3] - is_move[2];
int dir_y = is_move[1] - is_move[0];

double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);

if (++current % FPS == 0) {
idx_current_anim++;

if (len_dir != 0) {
double normalized_x = dir_x / len_dir;
double normalized_y = dir_y / len_dir;

player_position.x += (int)(PLAYER_SPEED * normalized_x);
player_position.y += (int)(PLAYER_SPEED * normalized_y);
}
}

位置较准

定义 AccuratePlayerPosition 函数校准玩家的位置

const int CANVAS_HEIGHT = 720;
const int CANVAS_WIDTH = 1280;

const int PLAYER_HEIGHT = 50;
const int PLAYER_WIDTH = 50;

inline void AccuratePlayerPosition(POINT& position) {
if (position.x < 0)
position.x = 0;
if (position.y < 0)
position.y = 0;
if (position.x > CANVAS_WIDTH - PLAYER_WIDTH)
position.x = CANVAS_WIDTH - PLAYER_WIDTH;
if (position.y > CANVAS_HEIGHT - PLAYER_HEIGHT)
position.y = CANVAS_HEIGHT - PLAYER_HEIGHT;
}

玩家类和敌人类

接下来, 我们考虑应用 OO 的思想封装玩家类和敌人类

我们可以有以下的继承图

GameObeject
^
|
|
|
Character
^
|-------^
| |
| |
Player Enemy

在目前的开发阶段, 我们无需考虑 Character 和 GameObject, 因为我们的项目太简单了

封装可以保证我们不会因为太笨而把数据弄得到处都是

玩家类

class Player {
public:
Player() {

}
~Player() {

}
private:
};

这一步可能会非常耗时, 取决于你史里淘金的能力

首先把到处都是的常量和构造函数收进来

class Player {
public:
Player() {
loadimage(&img_shadow, _T("image/player_shadow.png"));
Animation* anim = new Animation(_T("image/miku_0%llu.png"), 1, 45);
}
~Player() {
delete anim;
}
private:
int PLAYER_SPEED = 12;
const int PLAYER_HEIGHT = 50;
const int PLAYER_WIDTH = 50;
const int SHADOW_WIDTH = 50;

IMAGE img_shadow;
POINT player_position = { 500, 500 };
Animation* anim;
bool is_move[4] = { false }; // {up, down, left, right}
};

定义并实现成员函数

class Player {
public:
void ProcessEvent(const ExMessage& msg); // Process player's operation messages
void Move();
void Draw(int delta);
private:
inline void AccuratePlayerPosition(POINT& position);
};

void Player::ProcessEvent(const ExMessage& msg) {
if (msg.message == WM_KEYDOWN) {
switch (msg.vkcode) {
case VK_UP:
is_move[0] = true;
break;
case VK_DOWN:
is_move[1] = true;
break;
case VK_LEFT:
is_move[2] = true;
break;
case VK_RIGHT:
is_move[3] = true;
break;
}
}
if (msg.message == WM_KEYUP) {
switch (msg.vkcode) {
case VK_UP:
is_move[0] = false;
break;
case VK_DOWN:
is_move[1] = false;
break;
case VK_LEFT:
is_move[2] = false;
break;
case VK_RIGHT:
is_move[3] = false;
break;
}
}
}

void Player::Move() {
int dir_x = is_move[3] - is_move[2];
int dir_y = is_move[1] - is_move[0];

double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);

if (len_dir != 0) {
double normalized_x = dir_x / len_dir;
double normalized_y = dir_y / len_dir;

player_position.x += (int)(PLAYER_SPEED * normalized_x);
player_position.y += (int)(PLAYER_SPEED * normalized_y);
}
AccuratePlayerPosition(player_position);
}

inline void Player::AccuratePlayerPosition(POINT& position) {
if (position.x < 0)
position.x = 0;
if (position.y < 0)
position.y = 0;
if (position.x > CANVAS_WIDTH - PLAYER_WIDTH)
position.x = CANVAS_WIDTH - PLAYER_WIDTH;
if (position.y > CANVAS_HEIGHT - PLAYER_HEIGHT)
position.y = CANVAS_HEIGHT - PLAYER_HEIGHT;
}

void Player::Draw(int delta) {
int pos_shadow_x = player_position.x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);
int pos_shadow_y = player_position.y;
putimage_alpha(pos_shadow_x, pos_shadow_y, &img_shadow);

anim->Play(player_position.x, player_position.y, delta);
}

子弹类

class Bullet {
public:
Bullet() = delete;
~Bullet() = delete;

POINT position = { 0, 0 };
void Draw() const;
private:
const int RADIUS = 10;
};

void Bullet::Draw() const {
setlinecolor(RGB(255, 155, 50));
setlinecolor(RGB(200, 75, 10));
fillcircle(position.x, position.y, RADIUS);
}

敌人类

仿照玩家类定义敌人类

class Enemy {
public:
Enemy();
~Enemy();
private:
const int PLAYER_SPEED = 8;
const int PLAYER_HEIGHT = 36;
const int PLAYER_WIDTH = 35;
const int SHADOW_WIDTH = 50;

IMAGE img_shadow;
POINT player_position = { 0, 0 };
Animation* anim;
bool is_move[4] = { false }; // {up, down, left, right}
bool facing_left = false;
inline void AccuratePlayerPosition(POINT& position);
};
Enemy::Enemy() {
// Enemy spawn edge
enum class SpawnEdge {
Up = 0,
Down,
Left,
Right
};

// Put Enemy
SpawnEdge edge = (SpawnEdge)(rand() % 4);
switch (edge){
case SpawnEdge::Up:
position.x = rand() % CANVAS_WIDTH;
position.y = -PLAYER_HEIGHT;
break;
case SpawnEdge::Down:
position.x = rand() % CANVAS_WIDTH;
position.y = PLAYER_HEIGHT;
break;
case SpawnEdge::Left:
position.x = -PLAYER_WIDTH;
position.y = rand() % CANVAS_HEIGHT;
break;
case SpawnEdge::Right:
position.x = PLAYER_WIDTH;
position.y = rand() % CANVAS_HEIGHT;
break;
default:
break;
}
}

注意到我们在这里使用引用来传递参数, 目的是避免不必要的拷贝构造
同时限定为 const 防止意外的修改了参数

class Enemy{
public:
bool CheckBulletCollision(const Bullet& bullet);
bool CheckPlayerCollision(const Player& player);
}

注意到我们在这里使用引用来传递参数, 目的是避免不必要的拷贝构造
同时限定为 const 防止意外的修改了参数

class Enemy{
public:
void Move(const Player& player);
}
void Enemy::Move(const Player& player) {
const POINT& player_position = player.GetPosition();
int dir_x = player_position.x - position.x;
int dir_y = player_position.y - position.y;

double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);

if (len_dir != 0) {
double normalized_x = dir_x / len_dir;
double normalized_y = dir_y / len_dir;

position.x += (int)(PLAYER_SPEED * normalized_x);
position.y += (int)(PLAYER_SPEED * normalized_y);
}

}
class Enemy{
public:
void Draw(int delta);
}
void Enemy::Draw(int delta){

int pos_shadow_x = position.x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);
int pos_shadow_y = position.y;
putimage_alpha(pos_shadow_x, pos_shadow_y, &img_shadow);

anim->Play(position.x, position.y, delta);
}

实例化

将玩家对象实例化

int main() {
initgraph(CANVAS_WIDTH, CANVAS_HEIGHT);

bool running = true;
Player player;
ExMessage msg;
IMAGE img_background;

loadimage(&img_background, _T("image/background.png"));

BeginBatchDraw();

while (running) {
DWORD start_time = GetTickCount();

while (peekmessage(&msg))
{
player.ProcessEvent(msg);
}

player.Move();

cleardevice();

putimage(0, 0, &img_background);
player.Draw(1000 / FPS);

DWORD end_time = GetTickCount();
DWORD delta_time = end_time - start_time;

if (delta_time < 1000 / FPS) {
Sleep(1000 / FPS - delta_time);
}


FlushBatchDraw();
}

EndBatchDraw();
}

定义敌人生成函数

using vec_ptr_enemy = std::vector<Enemy*>;

void TryGenerateEnemy(vec_ptr_enemy& enemy_list);

int main(){

vec_ptr_enemy enemy_list;

while (running) {

player.Move();
TryGenerateEnemy(enemy_list);
for (auto enemy : enemy_list) {
enemy->Move(player);
}
}

}

void TryGenerateEnemy(vec_ptr_enemy& enemy_list) {
const int INTERVAL = 100;
static int counter = 0;
if ((++counter % INTERVAL) == 0)
enemy_list.push_back(new Enemy());
}

Day 6

子弹碰撞逻辑

敌人与子弹的碰撞

bool Enemy::CheckBulletCollision(const Bullet& bullet) {
bool is_overlap_x =
bullet.position.x >= position.x
&& bullet.position.x <= position.x + PLAYER_WIDTH;
bool is_overlap_y =
bullet.position.y >= position.y
&& bullet.position.y <= position.y + PLAYER_HEIGHT;
return is_overlap_x && is_overlap_y;
}

玩家与敌人的碰撞逻辑

在一般的游戏中, 玩家和敌人的碰撞箱一般小于其渲染范围, 但是我们的游戏可以稍微简化一下, 把敌人抽象成一个点

bool Enemy::CheckPlayerCollision(const Player& player) {
POINT check_position = {
position.x + PLAYER_WIDTH / 2,
position.y + PLAYER_HEIGHT / 2
};
POINT position = player.GetPosition();
bool is_overlap_x =
check_position.x >= position.x
&& check_position.x <= position.x + PLAYER_WIDTH;
bool is_overlap_y =
check_position.y >= position.y
&& check_position.y <= position.y + PLAYER_HEIGHT;

return is_overlap_x && is_overlap_y;
}

子弹渲染

void UpdateBullets(vec_bullet& bullet_list, const Player& player) {

const double RADIAL_SPEED = 0.0045;
const double TANGENT_SPEED = 0.0055;

double radian_interval = 2 * PI / bullet_list.size();
POINT player_position = player.GetPosition();
double radius = 100 + 25 * (sin(GetTickCount() * RADIAL_SPEED));

for (size_t i = 0; i < bullet_list.size(); ++i) {
double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i;
bullet_list[i].position.x =
player_position.x +
player.GetFrame().first / 2 +
(int)(radius * sin(radian));
bullet_list[i].position.y =
player_position.y +
player.GetFrame().second / 2 +
(int)(radius * cos(radian));
}
}

敌人消失逻辑

void Enemy::Hurt() {
alive = false;
}

bool Enemy::CheckAlive() {
return alive;
}
for (auto enemy : enemy_list) {
for (const Bullet& bullet : bullet_list) {
if (enemy->CheckBulletCollision(bullet)) {
enemy->Hurt();
}
}
}

for (size_t i = 0; i < enemy_list.size(); ++i) {
auto enemy = enemy_list[i];
if (enemy->CheckAlive() == false) {
std::swap(enemy_list[i], enemy_list.back());
enemy_list.pop_back();
delete enemy;
}
}

计分板

void DrawPlayerScore(int score) {
static TCHAR content[64];
_stprintf_s(content, _T("当前玩家得分:%d"), score);

setbkmode(TRANSPARENT);
settextcolor(RGB(0, 0, 0));
outtextxy(10, 10, content);
}

音乐和音效

为了让我们的游戏更 man!, 我们应该使用音乐和音效, 用 MCI 可以播放音频文件

注意, 假如你使用的音频文件来自网易云音乐等平台, 很可能出现播放失败的情况, 原因
大致意思如下:
Ok,我发现了问题的源头。我使用了这个小型的Delhpi MP3 Player Tutorial(你可以在这里下载这个项目)去测试了你的MP3文件并且我(也)得到了和你的MP3一样的错误。在一些测试之后我发现了其他MP3文件在这个Tutorial应用上播放良好。你的MP3则在Windows Media Player 和 其他多媒体播放器上播放良好。
是的,重新编码这个文件解决了问题,但这并不是一个真的“问题”。这个问题来自于MP3的元数据(这个ID3 tags)而并非声音文件本身的编码问题。
我使用Mp3tag操作这个文件,之一除了这个tags,在此之后所有一切工作良好,没有EMCIDeviceError。
看起来是TMediaPlayer在遇到一些格式的元数据时会崩溃。在我搜索时,我也看到了TMediaPlayer的有关嵌入JPEG封面的MP3文件的bug报告。
后面的不在翻译了。到这里其实很明白了。网易云使用的是嵌入JPEG封面的mp3格式,而我后来使用QQ音乐下载的文件则不是这种格式的。而这一点,假如你是win10系统,在删除文件时候,就可以看出来。

我的解决办法是: 先用格式工厂压缩成 .wav 清除封面, 再用格式工厂修改为 .mp3(很麻烦但有效)

#pragma comment(lib, "Winmm.lib")
int main(){
mciSendString(_T("open \"sound/bgm.mp3\" alias bgm"), NULL, 0, NULL);
mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL);
}

对于受击音效, 我没有实现, 其实只需要删去 repeat 就可以了

Day 7

Day 7 本来是渲染优化和 GUI 设计, 我将其延后, 并添加一些有意思的新机制

增益水果

定义抽象增益水果类 BuffFruit, 吃下后为 Miku 提供增益效果

class BuffFruit {
public:
BuffFruit() {};
~BuffFruit() {};

POINT GetPosition();
bool CheckAlive();
void Hurt();
void Draw();
void CheckPlayerCollision(Player& player);
virtual void Buff(Player& player) = 0;

private:
POINT position = {0, 0};
bool alive = true;
IMAGE* img = nullptr;
};

CheckPlayerCollision 函数是判定自己是否被玩家吃下的函数, 也是用来调用虚函数 Buff 的接口

void BuffFruit::CheckPlayerCollision(Player& player) {
POINT check_position = {
position.x + img->getwidth() / 2,
position.y + img->getheight() / 2
};
POINT position = player.GetPosition();
bool is_overlap_x =
check_position.x >= position.x
&& check_position.x <= position.x + img->getwidth();
bool is_overlap_y =
check_position.y >= position.y
&& check_position.y <= position.y + img->getheight();

if (is_overlap_x && is_overlap_y) {
this->Hurt();
this->Buff(player);
}
else {
return;
}
}