涂鸦跳跃
最开始我选择制作的游戏是俄罗斯方块,不过感觉俄罗斯方块所涉及到的方面还是太少了,并且难以添加一些新的功能与玩法,于是便决定换个游戏制作。
在试玩了网上的一些游戏后,我决定制作一款游戏——涂鸦跳跃 Doodle Jump
,决定尝试面向对象编程,将游戏中的不同元素都分为各个对象进行编写。代码行数接近一千行,代码的可读性仍有待提高。
所使用的IDE是由royqh1979
所编译的Dev C++
编译器版本是GCC 10.3.0
程序代码部分使用了
#include "fstream" //C++文件处理库(用于游戏存档和得分记录)
#include "map" //C++ STL利用红黑树存储键值对(得分记录)
#include "thread" //C++ STL多线程支持(主要用于获取键盘以及鼠标操作)
#include "iostream" //C++标准输入输出库(仅在调试中使用)
#include "Logo.h" //图形库使用的是虞老师的LogoC
编译时加入了-lwinmm
指令以实现播放声音的支持(调用mciSendString
函数)
image
:包含游戏的图片Land
:地板的图片System
:各个界面的图片以及按钮的图片Theme
:包含四个主题不同的背景和小人Welcome
:开始界面相关图片
sounds
:包含游戏中的声音DoodleJump.h
:头文件包含函数的声明DoodleJump.cpp
:对子文件的调用Land.cpp
:包含Land
类,与地块有关Player.cpp
:包含Player
类,与小人有关SaveData.cpp
:包含SaveData
类,存取存档Scene.cpp
:包含Scene
类,场景有关Welcome.cpp
:包含Welcome
类,欢迎界面
#define DOODLE_JUMP_WIDTH 500 //游戏窗口宽度
#define DOODLE_JUMP_HEIGHT 800 //游戏窗口高度
#define LAND_NUM 10 //游戏中所包含的最大地块数量
#define GRAVITY 0.15 //重力加速度
程序使用了较多的枚举类型以提高代码可读性:
enum InterfaceType { //用于描述程序当前所在的界面
I_welcome, I_rule1, I_rule2, I_rank, I_option, I_change_name, I_gamerun, I_gamepause, I_gameretry, I_gameover
};
enum DJTheme { //游戏当前所用的主题名称
Classic, Jungle, Underwater, Winter
};
enum PlayerStatus { //玩家所操控的小人所处的状态
p_left, p_right, fly_left1, fly_right1, fly_left2, fly_right2, rocket_left, rocket_right
};
enum LandType { //LandType:地块的类型
normal, fragile, broken, broken_over, mvland, landfly, landrocket, mvspring1, landspring1, mvspring2, landspring2
};
IMAGE * (*);//将图片存入该指针所指向的地址(按钮,各子界面图)
InterfaceType interfacetype;//当前界面类型
DJtheme themetype;//当前主题类型
string path, theme, name;//路径;主题,玩家姓名
thread p_mouseget;//线程指针
bool bgm, sound_effect;//背景音乐,音效开关
multimap<int, string, sortcmp> scorerank_map;//记录排名的函数
Welcome();//构造函数,负责变量的初始化
void rank_read();//读取排名
void rank_save();//写入排名
void change_name();//改动玩家姓名
void welcome_mouseget();//捕获开始界面的操作,通常在多线程调用
void main_interface();//开始界面循环函数
void draw_welcome();//欢迎界面的绘制
void show_rule();//规则界面
void show_rank(InterfaceType pre);//排行榜界面
void show_option(InterfaceType pre);//设置界面
void show_pauselabel();//游戏暂停界面
void show_gameover();//游戏结束界面
void change_theme();//更改主题函数,调用各类变换主题子函数
void draw_jump();//开始界面跳动小人
void pauseget_mouse();//捕获暂停界面的操作,多线程中调用
void game_run();//游戏运行循环函数
void game_save();//游戏存档
bool game_load();//游戏继续
IMAGE * im_bk, im_basicline;//游戏场景背景图
int direct;//记录接收的按键
Scene();//构造函数,负责初始化
void change_theme(string theme, string path);//更改游戏场景背景图
void draw();//背景绘制
void PlayBGM(string MusicPath);//播放背景音乐
void PlayMusic(string MusicPath);//播放音效
void show();//调用三个draw函数
void updateWithoutInput();//与输入无关的更新
void updateWithKeyInput();//与键入有关的更新
IMAGE * (*);//将图片存入该指针所指向的地址(地面图片)
float land_width, land_height, land_vy;//地板宽高,地板速度
int score;//当前成绩
int broken_y;//记录破碎地板的y坐标
struct LandState {//单个地板的状况
float middle_x;//地板中心x坐标
float top_y;//地板上部y坐标(上部是为了判断碰撞方便)
float vx;//移动地板移动速度
LandType landType;//地板类型
IMAGE *im_land;//地板图片,赋图片地址
} land[LAND_NUM];//共生成_land_num_个地板
Land(string path);//构造函数初始化
void retry_clean();//重新运行游戏时的初始化
void Land_type(int i);//随机生成地板
void draw();//绘制地板
void show_topbar();//绘制顶栏
void updateLandY();//更新地板
IMAGE * (*);//将图片存入该指针所指向的地址(玩家图片)
PlayerStatus playerStatus;//小人当前状态
float x_middle, y_bottom;//小人中心x坐标,底部y坐标(方便碰撞判断)
float vx, vy;//小人水平竖直方向速度
float width, height;//小人的宽高
float rebound_vy;//小人反弹的速度
bool isPlayer_yMax, isPlayer_died;//小人是否到达超过半高,超过半高则小人不动,地面动;判断小人是否死亡
Player(string path, string theme);//构造函数初始化
void retry_clean();//重新运行游戏时的初始化
void change_theme(string theme, string path);//更改主题(小人)
void draw();//绘制小人
void moveLeft();//小人左移显示切换
void moveRight();//小人右移显示切换
void autoJump();//回到初始小人
void autoJump_fly();//小人竹蜻蜓显示切换
void autoJump_rocket();//小人火箭显示切换
void JudgeisPlayer_yMax();//判断小人是否达到半高
void isOnLand();//小人与地面碰撞判断
void updateYcoordinate();//小人的运动更新Y轴坐标
void JudgeisPlayer_died();//判断玩家是否死亡
class IMAGE{
public:
IMAGE(const char * s);
IMAGE(const IMAGE &);
~IMAGE();
int getwidth();
int getheight();
Image * hImage;
private:
int width;
int height;
};
由于LogoC
中IMAGE
类的构造函数仅包含按照路径构造,为了方便修改,以及避免在class
声明时就将IMAGE
类初始化导致异常(IMAGE
类必须在好像在调用setup
函数之后才能够使用),我都采用了IMAGE *
指针类型来储存IMAGE
对象的地址。
路径的写法我采用了C++的string
类型,方便修改,在最后使用c_str
方法转化为C语言风格的字符数组字符串当作路径。
void Scene::PlayBGM(string MusicPath) {
string op = "open " + MusicPath + " alias BGM";
mciSendString(op.c_str(), NULL, 0, NULL);
mciSendString("play BGM repeat", NULL, 0, NULL);
}
void Scene::PlayMusic(string MusicPath) {
string op = "open " + MusicPath + " alias soundeffect";
mciSendString("close soundeffect", NULL, 0, NULL);
mciSendString(op.c_str(), NULL, 0, NULL);
mciSendString("play soundeffect", NULL, 0, NULL);
}
声音的播放我采用的是MCI
(Media Control Interface
,媒体控制接口)发送命令的方式实现的。
在GCC
等非MSVC
编译器中要加入-lwinmm
指令使得编译器识别该函数。
void Scene::updateWithKeyInput() {
if (keymsg()) {
kmsg = getkey();
if (kmsg.flag == KEY_DOWN) {
if (kmsg.key == 'A' || kmsg.key == 'a' || kmsg.key == 37) {
direct = -1;
} else if (kmsg.key == 'D' || kmsg.key == 'd' || kmsg.key == 39) {
direct = 1;
} else if (kmsg.key == 27) {
welcome.interfacetype = I_gamepause;
}
}
if (kmsg.flag == KEY_UP) {
if ((kmsg.key == 'A' || kmsg.key == 'a' || kmsg.key == 37) || (kmsg.key == 'D' || kmsg.key == 'd' || kmsg.key == 39)) {
direct = 0;
}
}
}
if (direct == -1) {
player.moveLeft();
} else if (direct == 1) {
player.moveRight();
}
}
由于键盘的长按有判定时间,大概在几百毫秒左右,但是显示在操作中就会导致小人先动一下然后再持续移动。
为了解决这个问题,我们可以把小人的左移右移设为一个状态,按下左移或者右移时记录下这个状态,然后在松开这些键的时候把状态归零。然后根据当前所处的状态进行操作的更新。
while (interfacetype == I_change_name) {
bool is_change = false;
if (keymsg()) {
kmsg = getkey();
if (kmsg.flag == KEY_DOWN) {
if (isalnum(kmsg.key)) {
if (in.length() <= 10) {
in += kmsg.key;
is_change = true;
}
} else if (kmsg.key == 8) {
if (in.length() > 0) {
in.pop_back();
is_change = true;
}
} else if (kmsg.key == 13 || kmsg.key == 27) {
interfacetype = I_option;
}
}
}
通过接收输入输出存入字符串,再用showtext
函数显示出来,虽然简陋,也不支持中文的输入,不过也算是实现了这个功能。
游戏存档为了防止被修改导致游戏出现异常,使用了二进制来存取数据,对于固定大小的类型直接按所占字节进行存取即可,对于非定长类型例如字符串,可以先存储字符串的长度,再存入字符数据即可,这样读取时就知道需要读取多少字节的数据,实现字符串的二进制读写。
存档部分代码:
void SaveData::game_save() {
ofstream savedata;
size_t StringLength;
savedata.open("savedata.dat", ios::out | ios::binary);
//Welcome部分
savedata.write((char*)&welcome.themetype, sizeof(DJTheme));
savedata.write((char*)&welcome.bgm, sizeof(bool));
savedata.write((char*)&welcome.sound_effect, sizeof(bool));
StringLength = welcome.name.size();
savedata.write((char*)&StringLength, sizeof(size_t));
savedata.write(welcome.name.c_str(), welcome.name.size());
//Land部分
savedata.write((char*)&land.land_width, sizeof(float));
savedata.write((char*)&land.land_height, sizeof(float));
savedata.write((char*)&land.land_vy, sizeof(float));
savedata.write((char*)&land.score, sizeof(int));
savedata.write((char*)&land.broken_y, sizeof(int));
for (int i = 0; i < LAND_NUM; i++)
savedata.write((char*)&land.land[i], sizeof(Land::LandState));
//Player部分
savedata.write((char*)&player.playerStatus, sizeof(PlayerStatus));
savedata.write((char*)&player.x_middle, sizeof(float));
savedata.write((char*)&player.y_bottom, sizeof(float));
savedata.write((char*)&player.vx, sizeof(float));
savedata.write((char*)&player.vy, sizeof(float));
savedata.write((char*)&player.width, sizeof(float));
savedata.write((char*)&player.height, sizeof(float));
savedata.write((char*)&player.rebound_vy, sizeof(float));
savedata.write((char*)&player.isPlayer_yMax, sizeof(bool));
savedata.write((char*)&player.isPlayer_died, sizeof(bool));
savedata.close();
}
读档部分代码:
bool SaveData::game_load() {
ifstream savedata;
size_t StringLength;
savedata.open("savedata.dat", ios::in | ios::binary);
if (savedata.peek() == EOF) {
savedata.close();
return false;
}
//Welcome部分
savedata.read((char*)&welcome.themetype, sizeof(DJTheme));
savedata.read((char*)&welcome.bgm, sizeof(bool));
savedata.read((char*)&welcome.sound_effect, sizeof(bool));
savedata.read((char*)&StringLength, sizeof(size_t));
char* buffer = new char[StringLength + 1];
savedata.read(buffer, StringLength);
buffer[StringLength] = '\0';
welcome.name = buffer;
delete []buffer;
//Land部分
savedata.read((char*)&land.land_width, sizeof(float));
savedata.read((char*)&land.land_height, sizeof(float));
savedata.read((char*)&land.land_vy, sizeof(float));
savedata.read((char*)&land.score, sizeof(int));
savedata.read((char*)&land.broken_y, sizeof(int));
for (int i = 0; i < LAND_NUM; i++) {
savedata.read((char*)&land.land[i], sizeof(Land::LandState));
if (land.land[i].landType == landrocket) {
land.land[i].im_land = land.im_landrocket;
} else if (land.land[i].landType == landfly) {
land.land[i].im_land = land.im_landfly;
} else if (land.land[i].landType == mvspring1) {
land.land[i].im_land = land.im_mvspring1;
} else if (land.land[i].landType == mvland) {
land.land[i].im_land = land.im_move;
} else if (land.land[i].landType == fragile) {
land.land[i].im_land = land.im_break;
} else if (land.land[i].landType == landspring1) {
land.land[i].im_land = land.im_landspring1;
} else if (land.land[i].landType == normal) {
land.land[i].im_land = land.im_normal;
}
}
//Player部分
savedata.read((char*)&player.playerStatus, sizeof(PlayerStatus));
savedata.read((char*)&player.x_middle, sizeof(float));
savedata.read((char*)&player.y_bottom, sizeof(float));
savedata.read((char*)&player.vx, sizeof(float));
savedata.read((char*)&player.vy, sizeof(float));
savedata.read((char*)&player.width, sizeof(float));
savedata.read((char*)&player.height, sizeof(float));
savedata.read((char*)&player.rebound_vy, sizeof(float));
savedata.read((char*)&player.isPlayer_yMax, sizeof(bool));
savedata.read((char*)&player.isPlayer_died, sizeof(bool));
savedata.close();
welcome.change_theme();
return true;
}
void Land::Land_type(int i, int lnor, int lfr, int mv, int lspr, int lfly, int mvspr, int lroc) {
int rd = mt_rand() % 100;
if (rd < lnor) {
land[i].landType = normal;
land[i].im_land = im_normal;
} else if (rd < lfr) {
land[i].landType = fragile;
land[i].im_land = im_break;
} else if (rd < mv) {
land[i].landType = mvland;
land[i].im_land = im_move;
} else if (rd < lspr) {
land[i].landType = landspring1;
land[i].im_land = im_landspring1;
} else if (rd < lfly) {
land[i].landType = landfly;
land[i].im_land = im_landfly;
} else if (rd < mvspr) {
land[i].landType = mvspring1;
land[i].im_land = im_mvspring1;
} else if (rd < lroc) {
land[i].landType = landrocket;
land[i].im_land = im_landrocket;
}
land[i].middle_x = mt_rand() % (DOODLE_JUMP_WIDTH - (int)land_width - DOODLE_JUMP_WIDTH / 2);
}
使用std::mt19937 mt_rand{std::random_device{}()};
初始化随机数种子,再通过mt_rand()
函数生成随机数,按照不同概率生成地板类型。可以将概率写入函数参数表中,这样可以更加方便地按照游戏的进行更改不同的概率,提高游戏难度。
碰撞部分较为愚蠢的采用了手写碰撞距离的判断,不过该游戏所需碰撞的分类并不多,手写距离只是限制了地板以及小人高度的变化。通过多分支结构来判断不同地板的情况。
void Player::isOnLand() {
for (int i = 0; i < LAND_NUM; i++) {
if ((abs(y_bottom - land.land[i].top_y) <= vy) && (vy > 0)) {
if (land.land[i].landType == mvspring1 || land.land[i].landType == landspring1) {
if ((x_middle + 61 <= land.land[i].middle_x) || ((x_middle + 25) >= land.land[i].middle_x + land.land_width)) { // 大约估算图像中人物脚的距离,玩家没有落在地面上
return;
} else if ((x_middle + 61 >= land.land[i].middle_x + 30) && ((x_middle + 25) <= land.land[i].middle_x + 60)) { // 大约估算图像中人物脚的距离,玩家踩在弹簧上
if (welcome.sound_effect)scene.PlayMusic("sounds/spring.wav");
if (land.land[i].landType == mvspring1)
land.land[i].landType = mvspring2;
else {
land.land[i].landType = landspring2;
}
y_bottom = land.land[i].top_y;
vy = -(2.5) * rebound_vy;
} else {
if (welcome.sound_effect)scene.PlayMusic("sounds/jump.wav");
vy = -rebound_vy;
}
autoJump();
} else if (land.land[i].landType == landfly) {
if ((x_middle + 61 <= land.land[i].middle_x) || ((x_middle + 25) >= land.land[i].middle_x + land.land_width)) { // 大约估算图像中人物脚的距离,玩家没有落在地面上
return;
} else if ((x_middle + 61 >= land.land[i].middle_x + 30) && ((x_middle + 25) <= land.land[i].middle_x + 60)) { // 大约估算图像中人物脚的距离,玩家踩在竹蜻蜓上
if (welcome.sound_effect)scene.PlayMusic("sounds/fly.wav");
y_bottom = land.land[i].top_y;
vy = -(4.2) * rebound_vy;
autoJump_fly();
} else {
if (welcome.sound_effect)scene.PlayMusic("sounds/jump.wav");
vy = -rebound_vy;
autoJump();
}
} else if (land.land[i].landType == landrocket) {
if ((x_middle + 61 <= land.land[i].middle_x) || ((x_middle + 25) >= land.land[i].middle_x + land.land_width)) { // 大约估算图像中人物脚的距离,玩家没有落在地面上
return;
} else if ((x_middle + 61 >= land.land[i].middle_x + 30) && ((x_middle + 25) <= land.land[i].middle_x + 60)) { // 大约估算图像中人物脚的距离,玩家踩在竹蜻蜓上
if (welcome.sound_effect)scene.PlayMusic("sounds/rocket.wav");
y_bottom = land.land[i].top_y;
vy = -(5.8) * rebound_vy;
autoJump_rocket();
} else {
if (welcome.sound_effect)scene.PlayMusic("sounds/jump.wav");
vy = -rebound_vy;
autoJump();
}
} else if (land.land[i].landType != broken) { //其他类型地面判断
if ((x_middle + 61 >= land.land[i].middle_x) && ((x_middle + 25) <= land.land[i].middle_x + land.land_width && land.land[i].landType != broken_over)) { // 大约估算图像中人物脚的距离
if (land.land[i].landType != fragile) {
if (welcome.sound_effect)scene.PlayMusic("sounds/jump.wav");
} else {
if (welcome.sound_effect)scene.PlayMusic("sounds/break.wav");
land.land[i].landType = broken;
land.broken_y = land.land[i].top_y;
}
y_bottom = land.land[i].top_y;
vy = - rebound_vy;
autoJump();
}
}
}
}
JudgeisPlayer_died();
}
开始:开始游戏并清空存档
继续:读取存档以开始游戏,若无存档会报错。
规则:按规则按钮会显示规则。按下一页及返回按钮可以退出规则界面。
排行榜:显示排行榜,按返回按键或者点击界面的其他地方可以退出排行榜界面。
设置:点击音乐音效旁的开关可以开关音乐音效;点击主题旁边的四个小人可以更改主题;点击“点我改名”可以改名。点设置界面的其他地方可以退出该界面
游戏界面:按A/D/←/→
来控制小人的运动
在碰到这三个地板上的时物件时会高速运动一段路程
碰到该地板时,地板会破碎并反弹一次
该地板是会左右移动的
游戏目标就是尽可能得到最高的分数
掉出游戏界面即角色死亡
暂停:暂停按键在右上角,也可以通过ESC
按键来呼出暂停界面
设置按键可以调出设置;菜单按键可以回到开始界面,同时也会把当前游戏存档;重玩按键可以重开;按下返回按键退出暂停状态。
死亡后可以按重玩按钮重新游戏;按排行榜看排行榜;点击菜单回到开始界面
出于对性能以及代码可读性的考虑,我决定使用IMAGE
对象来存储图片。但是我在创建IMAGE
对象时经常遇到这种错误:
最后经过单独把IMAGE
类抽出来排查发现IMAGE
类的创建必须在运行setup()
函数之后。
初步估计setup()
函数中应该包含了LogoC
图形库初始化相关代码。
由于LogoC
中没有输入框相关控件的支持,我只能通过getkey()
函数来实现键盘的输入和输出,再使用showtext()
函数显示出来。
游戏中在操作小人运动时会出现小人先动一下然后再持续移动。
这是因为由于键盘的长按有判定时间,大概在几百毫秒左右。为了解决这个问题,我们可以把小人的左移右移设为一个状态,按下左移或者右移时记录下这个状态,然后在松开这些键的时候把状态归零。然后根据当前所处的状态进行操作的更新,就能实现无延迟的小人移动。
在游戏运行过程中,经常会出现鼠标点击却要过一段时间才有反应的情况,是因为等待接受的mouse信号过多导致迟滞。解决方案就是通过多线程将获取鼠标的代码独立在一个线程中运行。
所实现的输入框功能过于简陋,仅能实现英文单词和数字的输入,无法实现例如中文的输入,导致玩家名称仅能是英文名,暂无良好的解决方法。
在背景音乐播放完一遍时,我需要重新播放一遍背景音乐,一般来说,我们可以在MCI
命令中加一段"repeat"
即可,但是在实际操作中,倘若播放的音乐格式是wav
格式,不仅不能实现重复,音乐也会播放失败,我只能将需要循环播放的背景音乐转为mp3
格式,经测试能够正常循环播放,但是还是对于MCI
对wav
格式的支持感到不解。
LogoC
中的textfont()
函数支持的字体选择仅能通过字体在计算机中的序号来选择,导致了在不同电脑中的字体显示不一样。解决方法也是有的,因为我所需要的仅仅只是数字和字母,所以我可以通过把这些字体以位图的方式呈现,再在显示时截取相应片段即可。
但是工作量有亿点点大,最终决定还是放弃了。
项目所有文件的代码总行数超过了1000
行(10+171+466+90+86+111+232=1166),在UI设计中的行数过于庞大(超过半数代码),在实现基础的游戏功能时仅用了200
多行的代码,在项目逐渐庞大之后,不经常写注释的弊端便暴露出来,导致后半段写的代码可能包含一些重复的代码,降低了可读性,也使得代码看上去没有那么美观。
写出来的程序所占用的资源过多,在我自己的电脑(2015款的MacBook Air)会有极为严重的卡顿现象,即使使用多线程也无法改善。而且在学校机房的电脑上,运行游戏时也会有接近20%的CPU占用,暂无良好的解决方案。
- 《LogoC 使用参考》 虞铭财
- 菜鸟教程