1. 项目介绍
本项目名称叫做《朋克逃亡—7702》,是一款由我设计开发的开源跑酷小游戏,风格来源业界3A大作《赛博朋克—2077》,玩法类似于经典游戏《jetpack疯狂喷气机》,游戏全程需要操控角色来躲避障碍物,由于项目规模不大,功能架构也不复杂,后期读者还可以自行优化代码来添加想要实现的功能,游戏实机演示和资源素材连接如下:
源代码:源代码.zip - 蓝奏云 图片包:图素材.zip - 蓝奏云 音乐包:音乐素材.zip - 蓝奏云
2. 开发日志
本项目开发历程并不算太长,所以作者在实现项目的过程中写好了日志文件,不过这里还是单独拿出做个简单介绍。首先本项目采用easyX图形库实现对所有图片素材的渲染,前期准备素材阶段下载了各种素材网站的免费开源图片包和音乐包,然后作者使用专业工具处理,一部分素材可以正常应用在项目里,还有一些没有处理干净的素材留给大家进一步优化。开发过程总共可分为10个阶段:1. 创建移动中的英雄人物和背景图片;2. 创建人物的跳跃功能;3. 创建人物的猛冲(闪现);功能4. 实现按键功能;5. 创建障碍物功能;6. 实现碰撞监测功能;7. 实现碰撞反馈和死亡提示功能;8. 添加血条和初始化界面;9. 实现计分和渲染成绩功能;10.实现胜利判断功能;这些不同的阶段里都完成了哪些工作,我会在后续的段落中一一为大家就介绍。
3. 准备工作
看完了开发日志以后,就开始跟随作者进入项目实现吧。首先easyX图形库和素材包不用再强调了吧,自行下载即可 ,然后为了提升代码的可阅读性,我们依旧采用头文件导入外部变量的方式实现一个伪封装。在源文件目录里创建一个outlook.cpp文件,用来声明并初始化所有用得到的外部变量,outlook.cpp代码如下:
#include <graphics.h>
#include <vector>
#include "outlook.h"
using namespace std;
/*-------图片---------*/
IMAGE START1; // 启动界面图片
IMAGE START2;
IMAGE DEATH1; // 阵亡界面图片
IMAGE DEATH2;
IMAGE WIN1; // 胜利界面图片
IMAGE WIN2;
IMAGE heroImg[6]; // 英雄图片
IMAGE backImg[3]; // 背景图片
IMAGE SUN;
IMAGE ground; // 地面图片
IMAGE jumpImg[6];// 跳跃图片
IMAGE dashImg[6]; // 闪现图片
IMAGE bleedImg; // 受伤反馈图片
IMAGE numberBlack[10]; // 黑色数字图片
IMAGE numberGold[10]; // 金色数字图片
vector<vector<IMAGE>> trapImg; //陷阱池图片
// 背景信息
int backX[3];
int backY[3];
int groundX;
int backSpeed[3];
int groundSpeed;
// 英雄信息
int heroX;
int heroY;
int heroIndex;
short heroHP;
short heroMark;
// 跳跃信息
bool isJump; // 跳跃开关
int jumpLim; // 跳跃高度
int jumpSpeed; // 跳跃速度
int jumpX;
int jumpY;
// 闪现信息
short isDash;
int dashX;
int dashY;
int dashIndex;
// 陷阱池信息
trapPool trPool[POOL_LIM];
有了 outlook.cpp以后,我们还需要在头文件的目录下继续创建一个outlook.h头文件,这样其他文件里的代码才能够识别出来outlook.cpp里的全局变量,outlook.h的代码如下:
#pragma once
#include <graphics.h>
#include <vector>
using namespace std;
/*---------------------宏定义--------------------*/
#define SCR_WIDTH 548 // 屏幕宽度
#define SCR_HEIGHT 253 // 屏幕高度
#define HERO_FOOT 228 // 英雄站位
#define HERO_TALL 65 // 英雄身高
#define MARK_LIM 38 // 得分上限
#define KEY_JUMP 'k' // 跳跃键
#define KEY_DASH 'l' // 闪现键
#define POOL_LIM 5 // 陷阱池上限
/*--------------------陷阱定义--------------------*/
typedef enum {
STONE,
FENCE,
TANK,
ALL
}trapType;
typedef struct trap {
trapType type; // 陷阱类型(石头,铁门,坦克)
int imgIndex; // 当前显示的图片序号
int x, y; // 障碍物的X坐标和Y坐标
int speed; // 速度
int damage; // 伤害值
bool exist; // 存在标记
bool hited; // 碰撞标记
bool passed; // 通过标记
}trapPool;
/*---------------------图片----------------------*/
extern IMAGE START1;
extern IMAGE START2;
extern IMAGE DEATH1;
extern IMAGE DEATH2;
extern IMAGE WIN1;
extern IMAGE WIN2;
extern IMAGE heroImg[6];
extern IMAGE backImg[3];
extern IMAGE SUN;
extern IMAGE ground;
extern IMAGE jumpImg[6];
extern IMAGE dashImg[6];
extern IMAGE bleedImg;
extern IMAGE numberBlack[10]; // 黑色数字图片
extern IMAGE numberGold[10]; // 金色数字图片
extern vector<vector<IMAGE>> trapImg;
// 背景信息
extern int backX[3];
extern int backY[3];
extern int groundX;
extern int backSpeed[3];
extern int groundSpeed;
// 英雄信息
extern int heroX;
extern int heroY;
extern int heroIndex;
extern short heroHP;
extern short heroMark;
// 跳跃信息
extern bool isJump;
extern int jumpLim;
extern int jumpSpeed;
extern int jumpX;
extern int jumpY;
// 闪现信息
extern short isDash;
extern int dashX;
extern int dashY;
extern int dashIndex;
// 陷阱池信息
extern trapPool trPool[POOL_LIM];
有了这两个文件之后,后面需要查看或修改宏定义和全局变量的时候,就可以直接在这里面操作,当然还需要按照同样的办法再导入两个作者已经优化好的第三方开源工具文件tools.cpp和tools.h,这两个文件的代码在文首的链接里就有,可以下载并直接导入,主要作用是修复easyX图形库的一些功能缺陷,实现原理这里不再赘述,最后记得关闭编译器的“SDL语法检查”功能,并开启“多字节字符集”功能,防止产生一些没必要的报错。
4. 创建移动中的英雄人物和背景图片
准备工作完成以后,我们开始实现项目,首先创建主函数文件main.cpp, 这是驱动整个项目运行的关键,所以务必要保证其较低的耦合性,代码如下:
#include <iostream>
#include <graphics.h>
#include <conio.h>
#include "move.h"
#include "outlook.h"
using namespace std;
int main() {
showStart();
init();
/游戏主体/
while (1) {
// 按键介入
if (_kbhit()) {
OFswitch();
}
BeginBatchDraw();
// 渲染背景
showBack();
// 渲染陷阱
showTrap();
// 渲染角色附带渲染得分
OFstates();
// 渲染HP
showHP();
EndBatchDraw();
// 下一帧计算
clutDraw();
Sleep(30);
}
return 0;
}
创建完成后发现有非常多的报错提示,不用担心,按照刚才的方法创建好move.h和move.cpp文件即可解决,move.h的代码如下:
#pragma once
// 初始化
void init();
// 渲染启动界面
void showStart();
// 渲染背景
void showBack();
// 渲染角色
void showHero();
// 渲染跳跃
void showJump();
// 渲染闪现
void showDash();
// 渲染陷阱
void showTrap();
// 渲染HP
void showHP();
// 渲染阵亡
void showOver();
// 渲染得分
void showMark();
// 渲染成绩
void showCong(int x, int y);
// 渲染胜利
void showWin();
// 下一帧计算
void clutDraw();
// 转换闪现跳跃开关
void OFswitch();
// 转换英雄运动状态
void OFstates();
// 构造陷阱
void buildTrap();
// 碰撞监测
void crashCheck();
// 得分监测
void markCheck();
move.cpp的代码如下:
#include <graphics.h>
#include <iostream>
#include <conio.h>
#include "move.h"
#include "tools.h"
#include "outlook.h"
using namespace std;
然后我们的所有功能都在move.cpp里实现就可以了。实现人物移动功能和背景渲染功能需要先初始化特定的素材,然后再创建两个接口并实现,初始化接口init()、人物移动接口showHero()、背景渲染接口showBack()的代码如下:
/*---------------初始化---------------*/
void init() {
// 绘制背景
loadimage(&SUN, "res/backgrounds/SUN.png",
SCR_WIDTH, SCR_HEIGHT);
putimage(0, -25, &SUN);
//加载背景图片
char name[36];
loadimage(&backImg[0], "res/backgrounds/BK1.png",
SCR_WIDTH * 2, SCR_HEIGHT - 25);
loadimage(&backImg[1], "res/backgrounds/BK2.png",
SCR_WIDTH * 2, SCR_HEIGHT - 25);
loadimage(&backImg[2], "res/backgrounds/BK3.png",
SCR_WIDTH * 2, SCR_HEIGHT - 25);
loadimage(&ground, "res/backgrounds/ground.jpg",
SCR_WIDTH * 2, 28);
loadimage(&DEATH1, "res/inits/death1.png",
SCR_WIDTH, SCR_HEIGHT);
loadimage(&DEATH2, "res/inits/death2.png",
SCR_WIDTH, SCR_HEIGHT);
loadimage(&WIN1, "res/inits/win1.png",
SCR_WIDTH, SCR_HEIGHT);
loadimage(&WIN2, "res/inits/win2.png",
SCR_WIDTH, SCR_HEIGHT);
// 加载数字图片
for (int i = 0; i < 10; i++) {
sprintf(name, "res/numbers/black/%d.png", i);
loadimage(&numberBlack[i], name, 30, 30);
sprintf(name, "res/numbers/gold/%d.png", i);
loadimage(&numberGold[i], name, 30, 30);
}
// 加载英雄图片
for (int i = 0; i < 6; i++) {
sprintf(name, "res/heros/blue%d.png", i + 1);
loadimage(&heroImg[i], name,
HERO_TALL, HERO_TALL);
}
loadimage(&bleedImg, "res/hurts/bleed.png",
HERO_TALL, HERO_TALL);
// 加载跳跃图片
for (int i = 0; i < 6; i++) {
IMAGE temper;
sprintf(name, "res/jumps/blue%d.png", i + 1);
loadimage(&temper, name, 0, 0);
int heSize = temper.getheight() * HERO_TALL /
48;
loadimage(&jumpImg[i], name,
HERO_TALL, heSize);
}
// 加载闪现图片
for (int i = 0; i < 6; i++) {
IMAGE temper;
sprintf(name, "res/dash_down/blue%d.png", i + 1);
loadimage(&temper, name, 0, 0);
int heSize = temper.getheight() * HERO_TALL /
48;
loadimage(&dashImg[i], name,
HERO_TALL, heSize);
}
// 加载石头图片
vector<IMAGE> arry1;
IMAGE tempStone;
loadimage(&tempStone, "res/traps/stone.png",
27, 16);
arry1.push_back(tempStone);
trapImg.push_back(arry1);
// 加载铁门图片
vector<IMAGE> arry2;
IMAGE tempFence;
loadimage(&tempFence, "res/traps/fence.png",
25, 200);
arry2.push_back(tempFence);
trapImg.push_back(arry2);
// 加载坦克图片
vector<IMAGE> arry3;
IMAGE tempTank;
for (int i = 0; i < 4; i++) {
sprintf(name, "res/traps/tank%d.png", i + 1);
loadimage(&tempTank, name, 25, 25);
arry3.push_back(tempTank);
}
trapImg.push_back(arry3);
/内部隔离带
// 加载背景信息
for (int i = 0; i < 3; i++) {
backX[i] = 0;
backY[i] = 0;
}
groundX = 0;
backSpeed[0] = 1;
backSpeed[1] = 2;
backSpeed[2] = 4;
putimagePNG2(groundX, SCR_HEIGHT - 25, &ground);
// 加载英雄信息
heroIndex = 0;
heroX = SCR_WIDTH / 3;
heroY = SCR_HEIGHT - heroImg[0].getheight() - 25;
heroHP = 100;
heroMark = 0;
// 加载跳跃信息
isJump = false;
jumpX = SCR_WIDTH / 3;
jumpY = SCR_HEIGHT - heroImg[0].getheight() - 25;
jumpLim = jumpY - 35;
jumpSpeed = 4;
// 加载闪现信息
isDash = false;
dashIndex = 0;
dashX = SCR_WIDTH / 3;
dashY = SCR_HEIGHT - dashImg[0].getheight() - 25;
// 加载陷阱信息
for (int i = 0; i < POOL_LIM; i++) {
trPool[i].exist = false;
trPool[i].passed = false;
}
// 加载音效信息
preLoadSound("res/music/crashSound.wav");
preLoadSound("res/music/crySound.wav");
preLoadSound("res/music/jumpSound.wav");
preLoadSound("res/music/winSound.mp3");
mciSendString("play res/music/backSound.mp3 repeat",
0, 0, 0);
}
/*---------------渲染背景---------------*/
void showBack() {
// 打印当前背景帧
putimage(0, -25, &SUN);
putimagePNG2(backX[0], backY[0], &backImg[0]);
putimagePNG2(backX[1], backY[1], &backImg[1]);
putimagePNG2(backX[2], backY[2], &backImg[2]);
putimagePNG2(groundX, SCR_HEIGHT - 25, &ground);
}
/*---------------渲染英雄---------------*/
void showHero() {
// 打印当前英雄帧
putimagePNG2(heroX, heroY, &heroImg[heroIndex]);
}
其中初始化接口的原理就是先通过loadimage函数将图片加载到定义好的全局变量里,然后再为其他全局变量信息赋初始值 ,没有什么特别难的语法,showBack和showHero这两个函数则是直接调用tools.cpp里优化过的putimagePNG2函数在参数坐标指定的位置打印出这一帧的画面,不过要注意的是,这两个show函数里并没有添加FlushBatchDraw()进行强制渲染,后面有读者如果想在本项目的基础上进行优化的话需要注意show函数的实时性问题。
5. 创建人物的跳跃功能
做完上述的工作之后,我们的英雄和背景还动不起来,不过请先不要着急,因为这一部分会为读者慢慢讲,那么先讲如何创建人物的跳跃功能,人物的跳跃功能无非是玩家通过按键激活控制罢了,但在编程阶段我们还没有实现接受玩家按键的功能接口,而且也没必要提前实现,为了应对上述问题,我们可以使用开关变量来顶替键盘激活,这么做的好处是可以和后面的键盘控制接口无缝衔接。首先是打印跳跃的帧图,showJump函数,代码如下:
/*---------------渲染跳跃---------------*/
void showJump() {
// 打印当前跳跃信息
putimagePNG2(jumpX, jumpY, &jumpImg[heroIndex]);
}
有了打印本次帧图的函数后,我们就需要考虑,如何计算下一帧的图片布局呢,所以我们这时候引入计算下一帧图片的接口cultDraw(),其代码如下:
/*---------------下帧计算---------------*/
void clutDraw() {
// 计算偏离背景
for (int i = 0; i < 3; i++) {
backX[i] = backX[i] - backSpeed[i];
if (backX[i] < -SCR_WIDTH) {
backX[i] = 0;
}
}
groundX = groundX - backSpeed[2];
if (groundX < -SCR_WIDTH) {
groundX = 0;
}
// 计算英雄帧
heroIndex = (heroIndex + 1) % 6;
// 计算跳跃帧
if (jumpY > jumpLim && isJump == true) {
jumpY = jumpY - jumpSpeed;
}
else if(jumpY < heroY){
isJump = false;
jumpY = jumpY >= heroY ?
heroY : jumpY + jumpSpeed;
}
// 计算闪现帧
if (isDash > 0) {
static short dashFrame = 0;
static short delay[6] = { 2, 2, 2, 5 ,4, 4};
dashFrame++;
if (dashFrame >= delay[dashIndex]) {
dashFrame = 0;
int cmp = isDash - dashIndex;
if (cmp > 0) {
dashIndex = dashIndex + 1;
dashY = HERO_FOOT -
dashImg[dashIndex].getheight();
}
else {
isDash = 0;
dashIndex = 0;
dashY = SCR_HEIGHT -
dashImg[0].getheight() - 25;
}
}
}
// 计算陷阱帧(1) 构建陷阱
static int frameTrap = 0;
static int frameTrap_lim = 45;
frameTrap++;
if (frameTrap > frameTrap_lim) {
frameTrap = 0;
frameTrap_lim = 47 + rand() % 57;
buildTrap();
}
// 计算陷阱帧(2) 计算坐标
for (int i = 0; i < POOL_LIM; i++) {
if (trPool[i].exist) {
trPool[i].x -= (trPool[i].speed +
backSpeed[2]);
if (trPool[i].x < -trapImg[trPool[i].type]
[0].getwidth() * 2) {
trPool[i].exist = false;
}
int arryLen = trapImg[trPool[i].type].size();
trPool[i].imgIndex = (trPool[i].imgIndex +
1) % arryLen;
}
}
// 计算碰撞和健康值帧
crashCheck();
// 计算得分帧
markCheck();
}
我们可以看到多了许多其他地图元素的帧图计算,这些计算原理我们在后面讲,本节内容先讲如何实现背景移动以及奔跑跳跃的计算原理,首先是背景帧的计算,背景帧其实就是利用背景图片是窗口距离两倍的条件,在每次上一帧帧结束渲染后,将不同背景元素的X坐标减去对应的速度偏移量,就是下一帧的背景画面,一旦话背景画面到达极限时,重新在初始位置渲染就能实现无缝衔接。同理奔跑帧直接不断更换索引,原地踏步即可实现背景的移动和人物自身的奔跑效果,跳跃帧需要判断跳跃开关是否处于激活状态,如果是激活状态那么令Y坐标减去对应的速度偏移量,一旦到达跳跃高度上限,那么立即关闭跳跃开关,然后如果此时还在空中,则需要不断加上速度偏移量来平稳落地。
6. 实现人物的猛冲(闪现、下蹲)功能
实现奔跑和跳跃甚至帧计算功能以后,我们的人物确实能够丝滑地跑起来,可奇怪的事来了,该怎么让他跳跃起来呢,其实很简单,需要我们使用状态切换函数进行判断然后分别调用对应的show函数。那么这一节我们会讲述如何实现人物地猛冲(下蹲)功能和状态切换函数,首先是正常的打印下蹲的show函数,相信大多数读者已经摸清楚本项目开发的套路了,showDash函数代码如下:
/*---------------渲染闪现---------------*/
void showDash() {
// 打印当前闪现信息
putimagePNG2(dashX, dashY, &dashImg[dashIndex]);
}
有了show函数打印当前帧图的能力,还应计算下一帧图片,计算代码已经在上一节的cultDraw函数中给出过了, 这里就不再赘述,原理同跳跃帧计算一样,先判断下蹲的开关是否处于激活状态,如果处于激活状态,那么就再利用静态变量控制每一帧的持续时间,以便于完成更加持久的下蹲过程,如果满足上述全部条件,那么只需要更换对应的索引下标和重新计算Y坐标即可,至于计算方法就是利用英雄的脚部坐标减去蹲下后的身高,没有什么太难理解的地方。然后就是最关心的状体切换函数,我们有了开关、有了所有的运动状态,就需要有函数来对人物应该渲染什么动作有一个基本的判断,这里需要实现OFstates()函数,其代码如下:
void OFstates() {
showMark(); // 渲染分数
if (isJump == true || isDash != 0 ||
jumpY < heroY) {
if (isJump == true || jumpY < heroY) {
showJump();
return;
}
else {
showDash();
return;
}
}
showHero();
}
就是根据开关对当前运动状态的一个基本判断,而showMark函数是渲染得分的接口,我们之后再讲解。
7. 实现按键功能
为了能够正常进行游戏和便于调试,每次手动更改开关标记的办法需要革新,所以这一节介绍如何实现按键功能,判断键盘触发的函数是_kbhit(),需要包含头文件<conio.h>才能正常使用,判断按键的语句只有一两行,不需要再额外封装成一个函数,直接再main函数里写就行,只有当_kbhit被触发时,我们才需要进行详细的判断,此时需要继续在move.cpp文件里实现一个函数接口OFswitch,用来判断激活哪一个开关,OFswitch()的代码如下:
/*---------------转换闪现跳跃开关---------------*/
void OFswitch() {
if (isJump == false && isDash == 0 && jumpY == heroY) {
char kb_key = _getch();
if (kb_key == KEY_JUMP) {
isJump = true;
mciSendString("play res/music/jumpSound.wav", 0, 0, 0);
}
else if (kb_key == KEY_DASH) {
isDash = 5;
}
}
}
有了OFswitch函数后。我们就可以无缝衔接地利用键盘进行跳跃和下蹲了。
8. 创建陷阱(障碍物)功能
到此整个游戏项目已经可以正常控制游玩了,但是还需要添加一些陷阱才能够使得游戏变得更加有意思。按照正常地思路,我们还是先实现showTrap函数用来渲染当前陷阱帧图。代码如下:
/*---------------渲染陷阱---------------*/
void showTrap() {
// 打印当前陷阱信息
for (int i = 0; i < POOL_LIM; i++) {
if (trPool[i].exist) {
int x = trPool[i].x;
int y = trPool[i].y;
IMAGE temp = trapImg[trPool[i].type]
[trPool[i].imgIndex];
putimagePNG2(x, y, SCR_WIDTH,
&temp);
}
}
}
这里地POOL_LIM是模拟了池子的思想,相当于所有的陷阱全部存放在了一个池子中,当池子中存在陷阱时,才允许showTrap函数对陷阱进行渲染。接下来就是陷阱的创建过程,我们封装一个函数,然后再cultDraw函数中进行调用即可,那么创建陷阱函数 buildTrap()的代码如下:
/*---------------构造陷阱---------------*/
void buildTrap() {
// 监测陷阱池开张
int i = 0;
for (i = 0; i < POOL_LIM; i++) {
if (trPool[i].exist == false) {
break;
}
}
if (i >= POOL_LIM) return;
// 构造陷阱基本数据
trPool[i].exist = true;
trPool[i].hited = false;
trPool[i].imgIndex = 0;
trPool[i].type = (trapType)(rand() % 3);
trPool[i].x = SCR_WIDTH;
trPool[i].y = HERO_FOOT -
trapImg[trPool[i].type][0].getheight();
trPool[i].passed = false;
// 构造陷阱特殊数据
if (trPool[i].type == STONE) {
trPool[i].speed = 0;
trPool[i].damage = 20;
}
else if (trPool[i].type == FENCE) {
trPool[i].speed = 0;
trPool[i].damage = 40;
trPool[i].y = 0;
}
else if (trPool[i].type == TANK) {
trPool[i].speed = 2;
trPool[i].damage = 30;
}
}
具体实现原理就是判断当前的陷阱池中是否有空位,如果有空位那么就随机初始化一个陷阱的类型,以及所有的详细信息,包括生成位置的坐标和存在标记等。实现完buildTrap函数以后,就需要再cultDraw函数中调用,为了防止生成的陷阱数量太多,我们可以仿照下蹲延时的方法,利用静态变量实现帧等待,具体代码已经在上文的cultDraw函数代码里给出了,这里不再赘述。
9. 实现碰撞监测功能
项目进行到这里,游戏的基本游玩基本没有什么问题了,但为了能够将游戏做到更加的完善,还需要额外添加碰撞监测功能。碰撞监测最好采用单独封装的方式,封装好了以后,可以直接在main函数中调用,也可也直接在cultDraw函数中调用,这里我们为了增加程序的内聚性,采取第二种方式。首先我们先封装实现碰撞监测函数,crshCheck(),其代码如下:
/*---------------碰撞监测---------------*/
void crashCheck() {
for (int i = 0; i < POOL_LIM; i++) {
if (trPool[i].exist && trPool[i].hited == false) {
// 定义判定坐标变量
int a1x, a1y, a2x, a2y; // 英雄判定坐标
int b1x, b1y, b2x, b2y; // 陷阱判定坐标
int off_x = HERO_TALL / 2 + 15;
int off_y = HERO_TALL / 3 + 15;
bool bleed_ground = true;
// 陷阱坐标赋值
b1x = trPool[i].x;
b1y = trPool[i].y;
b2x = trPool[i].x +
trapImg[trPool[i].type]
[trPool[i].imgIndex].getwidth() -5;
b2y = trPool[i].y +
trapImg[trPool[i].type]
[trPool[i].imgIndex].getheight();
// 英雄坐标赋值
if (isJump || jumpY < heroY) { // 跳跃状态
bleed_ground = false;
a1x = jumpX;
a1y = jumpY;
a2x = jumpX + jumpImg[heroIndex].getwidth() - off_x;
a2y = jumpY + jumpImg[heroIndex].getheight() - 12;
}
else if(isDash > 0){ // 闪现状态
a1x = dashX;
a1y = dashY;
a2x = dashX + dashImg[dashIndex].getwidth() - off_x;
a2y = HERO_FOOT;
}
else { // 奔跑状态
a1x = heroX;
a1y = heroY + off_y;
a2x = heroX + heroImg[heroIndex].getwidth() - off_x;
a2y = heroY + heroImg[heroIndex].getheight();
}
// 判断碰撞
if (rectIntersect(a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y)) {
heroHP -= trPool[i].damage;
if (bleed_ground) {
putimagePNG2(heroX, heroY, &bleedImg);
isDash = 0;
dashIndex = 0;
dashY = SCR_HEIGHT -
dashImg[0].getheight() - 25;
}
else {
putimagePNG2(jumpX, jumpY, &bleedImg);
}
if (heroHP <= 0) {
showOver();
}
// 在控制台输出血量信息,以便调试程序bug
// cout << "HP: " << heroHP << endl;
playSound("res/music/crashSound.wav");
trPool[i].hited = true;
}
}
}
}
crashCheck函数里大部分工作是用来精确分析不同运动状态下的碰撞监测坐标,比如下蹲状态下的坐标只需要对左上角的Y坐标进行精确分析即可,得到这些精确分析的坐标之后呢,我们调用tools.cpp里的rectIntersect函数进行判断,然后根据判断结果来计算角色因受伤而减少的HP值,以及一些相应的效果反馈。
10. 实现碰撞反馈和死亡提示功能
在上文中我们已经实现了碰撞监测的功能,但是具体的碰撞反馈我们并没有实现,为了更加良好的游戏体验,还需要实现碰撞反馈和死亡提示功能。碰撞反馈的代码已经在上文的碰撞监测crashCheck函数里给出过了,并没有单独进行封装,原理就是在碰撞到陷阱的一瞬间渲染一张受伤的帧图,非常的简单。我们在计算完角色受伤的HP值后,一旦发现角色的PH值已经小于等于0,说明角色已经死亡了,这时候就需要立刻终止游戏。这里采用一个showOver函数进行封装调用,其代码如下:
/*---------------渲染阵亡---------------*/
void showOver() {
mciSendString("close res/music/backSound.mp3",
0, 0, 0);
mciSendString("play res/music/crySound.wav",
0, 0, 0);
Sleep(1500);
// 打印当前阵亡信息
mciSendString("play res/music/death.mp3 repeat", 0, 0, 0);
while (1) {
putimage(0, 0, &DEATH1);
showCong(305, 85);
Sleep(300);
putimage(0, 0, &DEATH2);
showCong(305, 85);
Sleep(300);
if (_kbhit()) {
break;
}
}
mciSendString("close res/music/death.mp3",
0, 0, 0);
showStart();
init();
}
可以看到里面有许多对音乐的操作指令,不过不要担心,所有的音乐资源作者都已经放在开头的项目介绍里了,读者如果有需要,自行下载即可。
11. 添加血条和初始化(启动)界面
顾名思义,本节要介绍的两个函数就是添加血条showHP()和初始化界面showStart(),其实两个函数的基本原理都是渲染出对应的内容,showHP和showStart函数的代码如下:
/*---------------渲染健康值---------------*/
void showHP() {
// 打印当前HP信息
drawBloodBar(10, 10, 200, 10, 2, BLUE,
DARKGRAY, RED, heroHP / 100.0);
}
void showStart() {
// 起始画面
loadimage(&START1, "res/inits/initBack1.png",
SCR_WIDTH, SCR_HEIGHT);
loadimage(&START2, "res/inits/initBack2.png",
SCR_WIDTH, SCR_HEIGHT);
initgraph(SCR_WIDTH, SCR_HEIGHT);
// 启动界面
mciSendString("play res/music/start.mp3 repeat",
0, 0, 0);
while (1) {
putimage(0, 0, &START2);
Sleep(500);
putimage(0, 0, &START1);
Sleep(500);
if (_kbhit()) break;
}
mciSendString("close res/music/start.mp3 ", 0, 0, 0);
}
要注意showHP里采用的是tools.cpp里的drawBloodBar函数,其具体的使用方法和参数要求可以在tools.h文件里查找。实现上述功能后,游戏的启动界面如下:
12. 实现计分和渲染成绩功能
在游戏过程中不仅要实时关注血量情况,更要有得分情况。所以本节介绍计分showMark和渲染成绩showCong函数以及计算得分函数markCheck的实现,这两个函数在功能上其实是一致的,无非是前者使用白色数字,不需要参数定位,后者使用金色数字,可以使用参数定位增加灵活性。其函数代码如下:
/*---------------渲染得分---------------*/
void showMark() {
char str[8];
sprintf(str, "%d", heroMark);
int x = 479;
int y = 10;
for (int i = 0; str[i]; i++) {
int nuIndex = str[i] - '0';
putimagePNG2(x, y, &numberBlack[nuIndex]);
x += numberBlack[nuIndex].getwidth() + 2;
}
}
/*---------------渲染成绩---------------*/
void showCong(int x, int y) {
char str[8];
sprintf(str, "%d", heroMark);
for (int i = 0; str[i]; i++) {
int index = str[i] - '0';
putimagePNG2(x, y, &numberGold[index]);
x += numberGold[index].getwidth() + 1;
}
}
可以看到两个函数基本上能够用重载来实现,不过这里为了区分方便,还是设计成了两个不同的函数,其原理就是简单的字符串拆分赋值,需要注意的是showCong函数已经在上文的showOver接口里调用过了,不需要在单独返回重新添加调用。而markCheck函数也已经在cultDraw函数中完成了调用,所以只需要在move.cpp里实现一下就能解决各种各样的报错提示,markCheck的代码如下:
/*---------------分数监测---------------*/
void markCheck() {
for (int i = 0; i < POOL_LIM; i++) {
if (trPool[i].exist &&
trPool[i].passed == false &&
trPool[i].hited == false &&
trPool[i].x + trapImg[trPool[i].type][0].getwidth() < heroX) {
heroMark++;
trPool[i].passed = true;
// 调试语句
cout << "得分:" << heroMark << endl;
}
}
if (heroMark >= MARK_LIM) {
showMark(); // 令showMark()强行渲染一帧
showWin();
}
}
13. 实现胜利判断功能
胜利的判断条件已经在markCheck函数里给出了,即“heorMark >= MARK_LIM”。所以我们的最终任务就是实现胜利面的渲染函数showWin(),其代码如下:
/*---------------渲染胜利---------------*/
void showWin() {
mciSendString("close res/music/backSound.mp3",
0, 0, 0);
mciSendString("play res/music/winSound.mp3 repeat",
0, 0, 0);
Sleep(1500);
// 展示获胜界面信息
while (1) {
putimage(0, 0, &WIN1);
Sleep(300);
putimage(0, 0, &WIN2);
showCong(418, 165);
Sleep(300);
}
}
可以看到showWin的代码基本和showOver一致,但showWin不需要额外添加复活功能。
14. 总结&测试
生成解决方案和调试的过程中如果出现下列提示,属于正常现象,只要程序能够正常运行即可,无需过于担心。
本项目到此彻底竣工,以上工作请在Visual Studio编译器中进行,关闭SDL检查和开启多字节字符集的方法可以直接在CSDN中搜索查找,更多精彩文章,请关注博主“程序员Akgry”,作者将持续为您更新。