能帮到你的话,就给个赞吧 😘
文章目录
前言
本项目是跟着up做的最后一个项目,因此,本文将对本项目及之前的项目有印象以及是重点的以及优化全部做总结,类似于重新开始写。因为隔了一段时间发现即便是照抄,当时明白了,但不是自己写的,过了一段时间依旧不明所以。因此,本文将以构造空洞武士场景以及武士零角色为目标,从0开始,梳理之前所有的项目,并对大的概念,重要框架,以及一些细小疑难进行讲解。
本文的模式将类似与客服问答沟通,因为之前与客服交流,发现这种还挺好的。相信即便是0也能开始。
武士零角色以zero代替。
一些与主概念无关的实现放在不重要实现中
0.游戏窗口
#include <graphics.h> //easyx头文件
int main() {
//一个(1280, 720)的游戏窗口
//游戏窗口:渲染所有的游戏内容
initgraph(1280, 720);
return 0;
}
easyx构建窗口非常简单,仅需包含一个头文件和一句话即可。
1.游戏主循环
然而,没有循环的话窗口将会立即退出,所以,在其后加一个死循环防止退出。
而这个死循环也就是游戏的主循环。
#include <graphics.h>
int main() {
initgraph(1280, 720);
while (1) {
}
return 0;
}
FPS
游戏通常还需要控制FPS。FPS就是一秒钟有多少帧。
控制FPS也很简单,只需要计算每一帧的时间,然后与预期时间相减休眠即可。
以144hz为例。
#include <graphics.h>
#include <chrono>
#include <thread>
using namespace std::chrono;
using namespace std::this_thread;
int main() {
initgraph(1280, 720);
const nanoseconds desiredFrameDuration{ 1000000000 / 144 };
while (1) {
//帧开始
const steady_clock::time_point frameStartTime = steady_clock::now();
//帧结束
const nanoseconds frameDuration{ steady_clock::now() - frameStartTime };
const nanoseconds idleDuration = desiredFrameDuration - frameDuration;
if(idleDuration > nanoseconds{0})
sleep_for(idleDuration);
}
return 0;
}
这样,即可实现稳定144hz刷新,同时,如果超时的话便不再延迟。
主循环功能拆解——帧
主循环负责实现游戏的所有功能。而这所有功能,其实就是每一帧。
#include <graphics.h>
int main() {
initgraph(1280, 720);
while (1) {
//每一帧
}
return 0;
}
那么,一帧所做的事情通常分为三类
输入、更新、渲染。
#include <graphics.h>
int main() {
initgraph(1280, 720);
while (1) {
//一帧
//输入
//更新
//渲染
}
return 0;
}
即 一帧 = 输入 + 更新 + 渲染。
输入
在easyx中获取键盘及鼠标的输入仅需两行代码。
ExMessage msg;
//获取输入
if (peekmessage(&msg)) {
//处理输入
zero.processInput(msg);
}
peekmessage即可获取键盘以及鼠标的输入,存入到msg中。
更新
游戏中非静止不变化的对象,都需要提供一个更新的方法,同时呢,也都需要一个Δt参数,来模拟时间流动。
这与游戏的实现有关。游戏实现是离散的,我们固定了1秒钟144个帧,所以,我们需要计算更新与更新之间的时间,来模拟时间的连续。
//更新
static steady_clock::time_point lastUpdateTime = steady_clock::now();
const steady_clock::time_point currentUpdateTime = steady_clock::now();
const duration<float> deltaT{ currentUpdateTime - lastUpdateTime };
//更新前为t0
zero.update(deltaT.count()); //此时 t0 + Δt
//更新后为t1
lastUpdateTime = currentUpdateTime;
渲染
渲染则负责将更新好的数据绘制在窗口中。
zero.render();
在zero渲染前,
完整的主程框架
#include <graphics.h>
#include <chrono>
#include <thread>
using namespace std::chrono;
using namespace std::this_thread;
int main() {
initgraph(1280, 720);
const nanoseconds desiredFrameDuration{ 1000000000 / 144 };
while (1) {
const steady_clock::time_point frameStartTime = steady_clock::now();
//输入
ExMessage msg;
if (peekmessage(&msg))
zero.processInput(msg);
//更新
static steady_clock::time_point lastUpdateTime = steady_clock::now();
const steady_clock::time_point currentUpdateTime = steady_clock::now();
const duration<float> deltaT{ currentUpdateTime - lastUpdateTime };
zero.update(deltaT.count());
lastUpdateTime = currentUpdateTime;
//渲染
zero.render();
const nanoseconds frameDuration{ steady_clock::now() - frameStartTime };
const nanoseconds idleDuration = desiredFrameDuration - frameDuration;
if (idleDuration > nanoseconds{ 0 })
sleep_for(idleDuration);
}
return 0;
}
这样,便搭建好了144刷新的主程框架。
此种实现下zero.update的时间仅是模拟,并不严格对应。
例如玩家在t1帧按下移动键,t2帧松开移动键,那么移动的时间理应为t1与t2之间的帧间隔。
但实现中的时间却为 t0与t1之间的帧间隔。那是因为在按下的那一帧t1就已经开始运动了,但时间却是上一帧t0的间隔,而t2帧松开移动键后就不移动了,所以实际的运动时间是[t0, t1)。不过这并不影响游戏的连续模拟。
easyx渲染问题
拖影——cleardevice
initgraph开启一个窗口,
然而此时运行是有拖影的,是因为easyx窗口每一帧所渲染的画面需要手动清除,不清除的话依旧会留在窗口中。
easyx 清除渲染 也仅需一句话 cleardevice
放在渲染开始时调用即可
//渲染
cleardevice();
画面闪烁——批渲染
画面闪烁的原因是因为
easyx默认的渲染调用方式是一次渲染一次引擎调用,而cleardevice是将窗口涂黑,这样连续起来的话就是 黑 -> 画面 -> 黑 ->画面,就形成了视觉的闪烁。
解决方式也很简单 BeginBatchDraw即可。
BeginBatchDraw开启批渲染,直到调用FlushBatchDraw才进行一次性渲染。这样就减少了画面的闪烁。
#include <graphics.h>
int main() {
//初始化窗口
initgraph(1280, 720);
BeginBatchDraw();
while (1) {
//渲染
cleardevice();
//一次性渲染
FlushBatchDraw();
}
EndBatchDraw();
}
完整主程
#include <graphics.h>
#include <chrono>
#include <thread>
using namespace std::chrono;
using namespace std::this_thread;
int main() {
initgraph(1280, 720);
const nanoseconds desiredFrameDuration{ 1000000000 / 144 };
BeginBatchDraw();
while (1) {
const steady_clock::time_point frameStartTime = steady_clock::now();
//输入
ExMessage msg;
if (peekmessage(&msg))
zero.processInput(msg);
//更新
static steady_clock::time_point lastUpdateTime = steady_clock::now();
const steady_clock::time_point currentUpdateTime = steady_clock::now();
const duration<float> deltaT{ currentUpdateTime - lastUpdateTime };
zero.update(deltaT.count());
lastUpdateTime = currentUpdateTime;
//渲染
cleardevice();
zero.render();
FlushBatchDraw();
//刷新率
const nanoseconds frameDuration{ steady_clock::now() - frameStartTime };
const nanoseconds idleDuration = desiredFrameDuration - frameDuration;
if (idleDuration > nanoseconds{ 0 })
sleep_for(idleDuration);
}
EndBatchDraw();
return 0;
}
看似很多,其实跟要实现的内容没啥关系,都是关于easyx的框架,我们精简一下也就是
#include <graphics.h>
#include <chrono>
#include <thread>
using namespace std::chrono;
using namespace std::this_thread;
int main() {
initgraph(1280, 720);
const nanoseconds desiredFrameDuration{ 1000000000 / 144 };
BeginBatchDraw();
while (1) {
const steady_clock::time_point frameStartTime = steady_clock::now();
//输入
ExMessage msg;
if (peekmessage(&msg)) {
}
//更新
static steady_clock::time_point lastUpdateTime = steady_clock::now();
const steady_clock::time_point currentUpdateTime = steady_clock::now();
const duration<float> deltaT{ currentUpdateTime - lastUpdateTime };
lastUpdateTime = currentUpdateTime;
//渲染
cleardevice();
FlushBatchDraw();
//刷新率
const nanoseconds frameDuration{ steady_clock::now() - frameStartTime };
const nanoseconds idleDuration = desiredFrameDuration - frameDuration;
if (idleDuration > nanoseconds{ 0 })
sleep_for(idleDuration);
}
EndBatchDraw();
return 0;
}
接下来就跟easyx没什么关系了,实现独立的zero。
2.Zero
Zero的状态一共有七个
闲置 /奔跑 /跳起 /下降 /翻滚 /攻击 /死亡
然而这些都不重要,重要的是我们如何在一个游戏中设计一个类。
其实easyx什么都没做,只提供了一个黑屏窗口而已,而我们要做的,就是在这个二维坐标中使用数据去模拟一些物理而已。
注:easyx中的坐标系是右下的,所以我们以右下为正。
定义接口
class Zero {
public:
void processInput(const ExMessage& msg) {
}
void update(float deltaT) {
}
void render() {
}
};
物理模拟
如何实现物理这种宏大的目标呢,先从小事做起。
闲置
仅需一个坐标,即可模拟闲置
private:
Vector2 zeroCoordinate{ 620, 340 };
这样,就已经完成了闲置。无论输入什么,Zero都不会动。
为了观察,使用质点来渲染Zero
void render() {
fillcircle(zeroCoordinate.x, zeroCoordinate.y, 20);
}
左右奔跑
如何完成奔跑呢,仅需一个速度量即可。没错,完成物理的最重要两个变量也就是坐标和速度。其实就是初中物理而已。
当按下左右键,赋予奔跑速度即可。
private:
bool isLeftKeyDown = false;
bool isRightKeyDown = false;
const float speedRun = 300;
Vector2 zeroVelocity = { 0,0 };
这些,便是实现奔跑的所有变量了。
接着,我们仅需在奔跑时赋予奔跑速度即可。
void update(float deltaT) {
//run
if (isRightKeyDown - isLeftKeyDown)
zeroVelocity.x = speedRun;
//坐标更新
zeroCoordinate += zeroVelocity * deltaT;
}
至此,奔跑逻辑便完成了。
运行(需要将processInput填完),发现,一是无论按左还是右都会向右奔跑,二是一开始奔跑就停不下来了。
一是因为zeroVelocity.x = speedRun导致了只有向右的速度,因此,我们还需要根据按键来判断奔跑方向。
我们可以将isRightKeyDown - isLeftKeyDown式子的结果抽象为一个变量,即奔跑方向runDirection。
private:
int runDirection = 0; //runDirection 需要正1负1,所以设为int
//run
runDirection = isRightKeyDown - isLeftKeyDown;
if(runDirection)
zeroVelocity.x = runDirection * speedRun;
运行,这样即可实现左右奔跑。
二是因为我们只写了奔跑进入,没有写奔跑退出。当按键松下时,奔跑退出。
退出判断条件也很简单。
当runDirection == 0时,速度为0即可。
if (runDirection)
zeroVelocity.x = runDirection * speedRun;
else
zeroVelocity.x = 0;
完整代码如下
void update(float deltaT) {
//run
runDirection = isRightKeyDown - isLeftKeyDown;
if (runDirection)
zeroVelocity.x = runDirection * speedRun;
else
zeroVelocity.x = 0;
//坐标更新
zeroCoordinate += zeroVelocity * deltaT;
}
以下则是无关的按键处理,为了方便,在这里也将之后的按键一并给上。
按键处理负责按键按下时为真,按键松开时为假。
private:
bool isJumpKeyDown = false;
bool isRollKeyDown = false;
bool isAttackKeyDown = false;
void processInput(const ExMessage& msg) {
switch (msg.message) {
case WM_KEYDOWN:
switch (msg.vkcode) {
case 0x41://A
isLeftKeyDown = true;
break;
case 0x44://D
isRightKeyDown = true;
break;
case 0x57://W
isJumpKeyDown = true;
break;
case 0x53://S
isRollKeyDown = true;
break;
default:
break;
}
break;
case WM_LBUTTONDOWN://鼠标左键
isAttackKeyDown = true;
break;
case WM_KEYUP:
switch (msg.vkcode) {
case 0x41:
isLeftKeyDown = false;
break;
case 0x44:
isRightKeyDown = false;
break;
case 0x57:
isJumpKeyDown = false;
break;
case 0x53:
isRollKeyDown = false;
break;
default:
break;
}
break;
case WM_LBUTTONUP:
isAttackKeyDown = false;
break;
default:
break;
}
}
跳跃
有了奔跑示例,跳跃也很简单,同样的,仅需在跳跃时赋予跳跃速度即可。
private:
const float speedJump = 780;
//jump
if (isJumpKeyDown)
zeroVelocity.y = -speedJump;
运行,发现,小球一直升天,不会下降,这是因为没有模拟重力。
模拟重力同样仅需一行代码。
private:
const float G = 980 * 2;
zeroVelocity.y += G * deltaT;
运行发现小球直接掉下去,这是因为没有平台检测。我们在坐标更新后 添加 平台检测
private:
const float floor = 340;
//平台检测
if (zeroCoordinate.y >= floor) {
zeroCoordinate.y = floor;
zeroVelocity.y = 0;
}
运行发现小球可以多段跳,这与跳跃的判断条件有关。而解决很简单,只需添加一个条件即可。
if (isJumpKeyDown && isOnFloor())
zeroVelocity.y = -speedJump;
private:
bool isOnFloor() {
return zeroCoordinate.y == floor;
}
完整代码如下
//jump
if (isJumpKeyDown && isOnFloor())
zeroVelocity.y = -speedJump;
zeroVelocity.y += G * deltaT;
//平台检测
if (zeroCoordinate.y >= floor) {
zeroCoordinate.y = floor;
zeroVelocity.y = 0;
}
翻滚
同样的,翻滚仅需在翻滚时赋予翻滚速度即可。
if (isRollKeyDown && isOnFloor())
zeroVelocity.x = speedRoll;
private:
const float speedRoll = 800;
运行,发现,roll只能翻滚一点就停止。这与run的退出有关,因为只要没按下AD,run都会退出,所以下一帧就触发了run退出。我们发现,状态并非独立的,因为有时两种状态都需要操纵同一个变量。
因此,我们还需要修改run的退出条件,即不仅按下AD,还要判断是否在翻滚当中。
if (runDirection)
zeroVelocity.x = runDirection * speedRun;
else if(!isInRoll())
zeroVelocity.x = 0;
private:
bool isInRoll() {
return fabs(zeroVelocity.x) == speedRoll;
}
运行发现,roll不会停止,那是因为我们只写了roll的进入,写roll的退出。因此,可以发现,进入与退出几乎是所有状态的标配,因为没有退出的话,就永远是这个状态了。
而roll的退出条件则与时间有关,当roll滚动0.35秒后,自动停止。
实现计时也很简单,只需一个变量即可。
if (isInRoll())
rollTiming += deltaT;
if (rollTiming > 0.35) {
zeroVelocity.x = 0;
rollTiming = 0;
}
private:
float rollTiming = 0;
运行,即可实现正常翻滚。但是发现无法调整方向。
这与run无法调整方向的原因是一样的,我们还缺少一个方向变量。
而与奔跑方向不一样的是,我们期望按下s就能在当前的方向上翻滚。
因此,还需要一个当前的方向变量。
if (isRollKeyDown && isOnFloor())
zeroVelocity.x = facingDirection == FacingDirection::right ? speedRoll : -speedRoll;
if (runDirection)
facingDirection = runDirection > 0 ? FacingDirection::right : FacingDirection::left;
private:
enum class FacingDirection {
left,
right
};
FacingDirection facingDirection = FacingDirection::right;
运行,发现即可修改方向了。
但是,手感依旧有些不对,一起按ds时,发现翻滚只会向前一小段。这是因为同时按下ds时,同时触发这两个状态,一会run状态。因此,我们还需要修改奔跑的进入条件。
if (runDirection && !isInRoll())
zeroVelocity.x = runDirection * speedRun;
运行,即可正常。
由此可见,状态的耦合性甚至会导致修改状态的进入与退出条件。
可以优化下run代码
if (!isInRoll()) {
if (runDirection)
zeroVelocity.x = runDirection * speedRun;
else
zeroVelocity.x = 0;
}
完整的roll代码
//roll
//进入
if (runDirection)
facingDirection = runDirection > 0 ? FacingDirection::right : FacingDirection::left;
if (isRollKeyDown && isOnFloor())
zeroVelocity.x = facingDirection == FacingDirection::right ? speedRoll : -speedRoll;
//计时
if (isInRoll())
rollTiming += deltaT;
//退出
if (rollTiming > 0.35) {
zeroVelocity.x = 0;
rollTiming = 0;
}
攻击
游戏的攻击并不修改角色的物理,因此单独放出来。
角色的攻击主要与攻击框有关,主要就是攻击时开启攻击框,不攻击时关闭攻击框。
与roll一样,攻击也是计时退出的,Zero攻击后0.3秒自动退出。
由于与roll无异,在这里全部放出代码。
//attack
//进入
if (isAttackKeyDown)
attackBox.setEnabled(true);
//更新
if (isInAttack()) {
attackTiming += deltaT;
updateAttackCoordinate();
}
//退出
if (attackTiming > 0.3) {
attackBox.setEnabled(false);
attackTiming = 0;
}
private:
CollisionBox attackBox{ { 150,150 }, false };
float attackTiming = 0;
private:
void updateAttackCoordinate() {
Vector2 attackCoordinate{ 0,0 };
Vector2 attackSize = attackBox.getSize();
attackCoordinate.y = zeroCoordinate.y - attackSize.y / 2;
attackCoordinate.x = facingDirection == FacingDirection::right ? zeroCoordinate.x + 20 : zeroCoordinate.x - 20 - attackSize.x;
attackBox.setCoordinate(attackCoordinate);
}
攻击箱的实现并不重要,放在不重要实现中。
运行,即可正常。
但还有一个小问题,就是我们不期望翻滚时也能攻击。即状态间也有优先级。
因此,修改攻击的判断条件即可。
//attack
//进入
if (isAttackKeyDown && !isInRoll())
attackBox.setEnabled(true);
运行即可。
完整代码
#include <graphics.h>
#include <chrono>
#include <thread>
using namespace std::chrono;
using namespace std::this_thread;
#include "vector2.h"
#include "collisionBox.h"
class Zero {
enum class FacingDirection {
left,
right
};
const float speedRun = 300;
const float speedJump = 780;
const float G = 980 * 2;
const float floor = 340;
const float speedRoll = 800;
private:
bool isLeftKeyDown = false;
bool isRightKeyDown = false;
bool isJumpKeyDown = false;
bool isRollKeyDown = false;
bool isAttackKeyDown = false;
Vector2 zeroCoordinate{ 620, 340 };
Vector2 zeroVelocity = { 0,0 };
int runDirection = 0;
float rollTiming = 0;
FacingDirection facingDirection = FacingDirection::right;
CollisionBox attackBox{ { 150,150 }, false };
float attackTiming = 0;
private:
bool isOnFloor() {
return zeroCoordinate.y == floor;
}
bool isInRoll() {
return fabs(zeroVelocity.x) == speedRoll;
}
bool isInAttack() {
return attackBox.isEnabled();
}
void updateAttackCoordinate() {
Vector2 attackCoordinate{ 0,0 };
Vector2 attackSize = attackBox.getSize();
attackCoordinate.y = zeroCoordinate.y - attackSize.y / 2;
attackCoordinate.x = facingDirection == FacingDirection::right ? zeroCoordinate.x + 20 : zeroCoordinate.x - 20 - attackSize.x;
attackBox.setCoordinate(attackCoordinate);
}
public:
void processInput(const ExMessage& msg) {
switch (msg.message) {
case WM_KEYDOWN:
switch (msg.vkcode) {
case 0x41:
isLeftKeyDown = true;
break;
case 0x44:
isRightKeyDown = true;
break;
case 0x57:
isJumpKeyDown = true;
break;
case 0x53:
isRollKeyDown = true;
break;
default:
break;
}
break;
case WM_LBUTTONDOWN:
isAttackKeyDown = true;
break;
case WM_KEYUP:
switch (msg.vkcode) {
case 0x41:
isLeftKeyDown = false;
break;
case 0x44:
isRightKeyDown = false;
break;
case 0x57:
isJumpKeyDown = false;
break;
case 0x53:
isRollKeyDown = false;
break;
default:
break;
}
break;
case WM_LBUTTONUP:
isAttackKeyDown = false;
break;
default:
break;
}
}
void update(float deltaT) {
//run
runDirection = isRightKeyDown - isLeftKeyDown;
if (!isInRoll()) {
if (runDirection)
zeroVelocity.x = runDirection * speedRun;
else
zeroVelocity.x = 0;
}
//jump
if (isJumpKeyDown && isOnFloor())
zeroVelocity.y = -speedJump;
zeroVelocity.y += G * deltaT;
//roll
//进入
if (runDirection)
facingDirection = runDirection > 0 ? FacingDirection::right : FacingDirection::left;
if (isRollKeyDown && isOnFloor())
zeroVelocity.x = facingDirection == FacingDirection::right ? speedRoll : -speedRoll;
//计时
if (isInRoll())
rollTiming += deltaT;
//退出
if (rollTiming > 0.35) {
zeroVelocity.x = 0;
rollTiming = 0;
}
//attack
//进入
if (isAttackKeyDown && !isInRoll())
attackBox.setEnabled(true);
//更新
if (isInAttack()) {
attackTiming += deltaT;
updateAttackCoordinate();
}
//退出
if (attackTiming > 0.3) {
attackBox.setEnabled(false);
attackTiming = 0;
}
//坐标更新
zeroCoordinate += zeroVelocity * deltaT;
//平台检测
if (zeroCoordinate.y >= floor) {
zeroCoordinate.y = floor;
zeroVelocity.y = 0;
}
}
void render() {
fillcircle(zeroCoordinate.x, zeroCoordinate.y, 20);
attackBox.render();
}
};
Zero zero;
int main() {
initgraph(1280, 720);
const nanoseconds desiredFrameDuration{ 1000000000 / 60 };
BeginBatchDraw();
while (1) {
const steady_clock::time_point frameStartTime = steady_clock::now();
//输入
ExMessage msg;
while (peekmessage(&msg))
zero.processInput(msg);
//更新
static steady_clock::time_point lastUpdateTime = steady_clock::now();
const steady_clock::time_point currentUpdateTime = steady_clock::now();
const duration<float> deltaT{ currentUpdateTime - lastUpdateTime };
zero.update(deltaT.count());
lastUpdateTime = currentUpdateTime;
//渲染
cleardevice();
zero.render();
FlushBatchDraw();
//刷新率
const nanoseconds frameDuration{ steady_clock::now() - frameStartTime };
const nanoseconds idleDuration = desiredFrameDuration - frameDuration;
if (idleDuration > nanoseconds{ 0 })
sleep_for(idleDuration);
}
EndBatchDraw();
return 0;
}
总结
不难发现,添加状态是件很简单的事,只需要在update中添加状态即可。
状态通常也仅需确定如下五部分
状态
进入条件
进入所做的事
//更新
更新所做的事
退出条件
退出所做的事
且通常,退出条件仅是进入条件的否而已。
但是,状态也有缺点。
其一,状态也并非简单的添加。状态间通常还有耦合以及优先级,因此不能简单地堆砌。
对于那些互相关联的状态,通常还需要再修改状态的进入以及退出条件。
其二,状态都是在update中添加,Zero目前仅有5个状态就稍显复杂,如果是十几个状态还有互相耦合还都在update中,那么代码复杂度可想一般。
那么如何方便的管理状态以及解耦合以及拆分代码呢。
状态机呼之欲出。
值得注意的是,我们已经完成了Zero的所有功能,只不过是使用状态机来优化代码以及耦合而已。
姑且将上述代码方式称为堆砌,堆砌有堆砌的优缺点。状态机有状态机的优缺点。
不过,正如堆砌一样,不手动写一遍是无法体会的,在这里状态机的优缺点也先不表,都等到写完之后的总结再见吧。
3.状态机
我们已经完成了Zero的所有状态,接下来只是使用状态机重写而已。
首先呢,我们需要做一些不重要的前置工作
首先将Zero单独做成文件。
1.添加这两个头文件
#include "stateMachine.h"
#include "zeroStateNodes.h"
stateMachine中只有切换状态比较重要,其他的则在不重要实现中给出实现。
切换状态 需要先退出当前状态,再进入新状态。
void StateMachine::switchTo(const std::string& id){
currentStateNode->quit();
currentStateNode = stateNodes[id];
currentStateNode->enter();
}
zeroStateNodes就是我们要实现的状态节点。
2.添加状态机成员
private:
StateMachine stateMachine;
3.update修改为如下。
void update(float deltaT) {
stateMachine.update(deltaT);
//坐标更新
zeroCoordinate += zeroVelocity * deltaT;
//平台检测
if (zeroCoordinate.y >= floor) {
zeroCoordinate.y = floor;
zeroVelocity.y = 0;
}
}
状态机负责速度即可。
至此,已经完成了Zero改造,剩下的就是写状态节点了。
stateNode.h
在堆砌模式中,我们已经明显感觉状态是有进入,退出和更新的。
现在,将其抽象出来。
#pragma once
class StateNode {
public:
virtual void enter() {
}
virtual void update(float deltaT) {
}
virtual void quit() {
}
};
/*
enter: 进入——代表进入此状态
update:更新——代表状态运行
quit: 退出——代表退出此状态
*/
这样,我们就可以单独声明一个状态变量了,而不需要再添加在update中。上文写了这么多,也只是为了更方便的引出这个抽象。
那么,进入条件和退出条件呢,是的,进入条件和退出条件不由状态节点提供,而是由Zero提供。至于为什么,看代码就知道了。
同时,状态模式还多了一部分,那就是切换。切换代码则在状态节点update中。
zeroStateNodes
接下来,我们就要在zeroStateNodes中实现具体的Zero状态节点了。
为了不影响主要逻辑,讲解时直接写cpp了,h文件在完整代码给出。
cpp所需头文件如下。
#include "zeroStateNodes.h"
#include "zero.h"
extern Zero zero;
同时,为了方便不影响主要逻辑,一些没有给出的Zero的简单函数不再单独给出实现,可在完整代码查看。
同时,新的状态写完运行需要在Zero构造函数中添加状态,以闲置状态为例。为了不影响主要逻辑之后不再赘述,运行前记得添加即可。
Zero(){
stateMachine.registerState("idle", new ZeroIdleState());
stateMachine.setEnter("idle");
}
闲置
每种状态只需要重写接口就行了。
class ZeroIdleState : public StateNode{
public:
void enter() override;
void update(float deltaT) override;
};
那么闲置状态的进入应该怎么写呢,别忘了我们已经实现过所有的核心逻辑了,我们可以回看之前闲置需要干嘛
闲置进入非常简单,设置Zero速度为0即可。
void ZeroIdleState::enter(){
zeroVelocity = { 0, 0 };
}
但是报错,因为Zero需要提供接口,为了避免一句话三个接口,如zeroVelocity.x = runDirection * speedRun;
我们直接将逻辑全部定义在一个函数内。
void ZeroIdleState::enter(){
zero.idleEnter();
}
public:
void idleEnter() {
zeroVelocity = { 0,0 };
}
同时,为了方便,讲解直接在状态节点内写,然后再给出对应的封装函数,替换一下即可。
闲置状态更新呢,就不需要做什么了,因为速度一直为0。
void ZeroIdleState::update(float deltaT){
}
运行,发现小球也是一直处于闲置中。
奔跑
class ZeroRunState : public StateNode{
public:
void enter() override;
void update(float deltaT) override;
};
奔跑状态的进入应该怎么写呢,很简单,只需要将其抄写过来即可。
void ZeroRunState::enter(){
zeroVelocity.x = runDirection * speedRun;
}
void runEnter() {
zeroVelocity.x = runDirection * speedRun;
}
运行(别忘了添加状态),发现不能动,这是因为还没有写闲置到奔跑的切换。
void ZeroIdleState::update(float deltaT){
if (zero.isToRun())
zero.switchTo("run");
}
isToRun怎么写呢,同样的,还是抄写过来即可。我们只是按照状态机的模板优化代码而已,后文同样如此。
bool isToRun() {
return runDirection && !isInRoll();
}
void switchTo(const string& id) {
stateMachine.switchTo(id);
}
发现还是不能动,这是因为忘记写runDirection了。
由于runDirection并非只在ZeroRunState::enter才更新,所以将其放在Zero::enter中
runDirection = isRightKeyDown - isLeftKeyDown;
stateMachine.update(deltaT);
运行,即可。
但是run不会停止,没错,还是没写退出。准确来讲是没写退出条件。但状态机的不需要写退出,状态机只需要写进入即可,就是因为一直写进入退出太麻烦了,所以我们期望能够自动切换。那么现在就是写奔跑到闲置的切换。
void ZeroRunState::update(float deltaT){
if (zero.isToIdle())
zero.switchTo("idle");
}
bool isToIdle() {
return !runDirection;
}
运行,即可实现状态间的切换。所以请注意,既然是写状态机就不要叫进入与退出了,而叫切换。
跳跃
class ZeroJumpState : public StateNode {
public:
void enter() override;
void update(float deltaT) override;
};
跳跃状态的进入同样只需要将其抄写过来即可。
void ZeroRunState::enter(){
zeroVelocity.y = -speedJump;
}
void jumpEnter() {
zeroVelocity.y = -speedJump;
}
运行,发现无法跳跃,这同样是因为没有写其他状态到跳跃的切换。
void ZeroIdleState::update(float deltaT){
if (zero.isToRun())
zero.switchTo("run");
else if(zero.isToJump())
zero.switchTo("jump");
}
void ZeroRunState::update(float deltaT){
if (zero.isToIdle())
zero.switchTo("idle");
else if(zero.isToJump())
zero.switchTo("jump");
}
条件判断直接抄即可。
bool isToJump() {
return isJumpKeyDown && isOnFloor();
}
运行,小球直接起飞。这因为没有写跳跃到其他状态的切换。
void ZeroJumpState::update(float deltaT){
if (zero.isToIdle())
zero.switchTo("idle");
else if(zero.isToRun())
zero.switchTo("run");
}
运行,发现小球只向上跳一段距离即停止。
有了状态节点问题就好分析了,停止是处于闲置状态,那么就是jump节点切换到idle节点出问题了。
即isToIdle有问题。看代码可以想到,就是直接触发isToIdle了。所以还需要补充一个条件。即
bool isToIdle() {
return !runDirection && isOnFloor();
}
运行,小球即可正常只向上跳。
但是,我们发现两个bug
其一是 按下wd后落地按下a依旧向d方向移动。
其二是 jump运行时无法更改方向。
bug一,合理怀疑就是跳跃切换奔跑出错了,查看isToRun,发现其实主要在空中按下ad,那么就会进入run状态。因此,空中其实是有run来运行,接着,空中松开方向键,查看ZeroRunState::update,发现,虽然松开方向键,但依旧一直处于run。因此落地时按a,run接着运行,那为什么不会改变方向呢,是因为ZeroRunState::enter(),速度在进入时就已经确定好方向了,其余不会更改。解决的办法也很简单,更改isToRun即可,使其在空中不会进入run状态。
bool isToRun() {
return runDirection && !isInRoll() && isOnFloor();
}
运行,即可正常。
由此可见,状态机也不能解耦合,添加状态时还会影响其他状态的进入条件。
bug二,则与状态机的特性有关。即,一次只能运行一个状态。所以处于jump状态时,就不会处于run状态。
那么此时就有两种选择,一是将更改速度放进节点内,二是放在外部公共的update中。但由于并非所有节点都需要更改速度,因此将其放在节点内。
void ZeroJumpState::update(float deltaT){
updateXSpeed();
if (zero.isToIdle())
zero.switchTo("idle");
else if(zero.isToRun())
zero.switchTo("run");
}
//update
void jumpUpdate() {
updateXSpeed();
if (isToIdle())
switchTo("idle");
else if (isToRun())
switchTo("run");
}
void updateXSpeed() {
zeroVelocity.x = runDirection * speedRun;
}
运行,即可正常。
翻滚
class ZeroRollState : public StateNode {
public:
void enter() override;
void update(float deltaT) override;
void quit() override;
};
enter同样是抄写即可。
void ZeroRollState::enter(){
velocity.x = facingDirection == FacingDirection::right ? speedRoll : -speedRoll;
rollTiming = 0;
}
void rollEnter() {
zeroVelocity.x = facingDirection == FacingDirection::right ? speedRoll : -speedRoll;
rollTiming = 0;
}
update
void ZeroRollState::update(float deltaT){
zero.rollTiming += deltaT;
if (zero.rollTiming > 0.35) {
if (zero.isToIdle())
zero.switchTo("idle");
else if (zero.isToRun())
zero.switchTo("run");
else if (zero.isToJump())
zero.switchTo("jump");
}
}
void rollUpdate(float deltaT) {
rollTiming += deltaT;
if (rollTiming > 0.35) {
if (isToIdle())
switchTo("idle");
else if (isToRun())
switchTo("run");
else if (isToJump())
switchTo("jump");
}
}
quit
void ZeroRollState::quit(){
zero.rollTiming = 0;
}
void rollQuit() {
rollTiming = 0;
}
运行,发现忘记在其他节点写切换了。
bool isToRoll() {
return isRollKeyDown && isOnFloor();
}
然后在其他节点补上向roll切换的代码即可。在此不再给出
运行,还差改变方向。将更新方向的代码放入Zero的update中
运行,即可正常。
攻击
class ZeroAttackState : public StateNode {
public:
void enter() override;
void update(float deltaT) override;
void quit() override;
};
enter
void ZeroAttackState::enter(){
attackBox.setEnabled(true);
attackTiming = 0;
}
void attackEnter() {
attackBox.setEnabled(true);
attackTiming = 0;
}
update
void ZeroAttackState::update(float deltaT){
attackTiming += deltaT;
updateAttackCoordinate();
if (attackTiming > 0.3) {
attackBox.setEnabled(false);
if (zero.isToIdle())
zero.switchTo("idle");
else if (zero.isToRun())
zero.switchTo("run");
else if (zero.isToJump())
zero.switchTo("jump");
else if (zero.isToRoll())
zero.switchTo("roll");
}
}
void attackUpdate(float deltaT) {
attackTiming += deltaT;
updateAttackCoordinate();
if (attackTiming > 0.3) {
attackBox.setEnabled(false);
if (zero.isToIdle())
zero.switchTo("idle");
else if (zero.isToRun())
zero.switchTo("run");
else if (zero.isToJump())
zero.switchTo("jump");
else if (zero.isToRoll())
zero.switchTo("roll");
}
}
quit
void ZeroAttackState::quit(){
attackBox.setEnabled(false);
attackTiming = 0;
}
void attackQuit() {
attackBox.setEnabled(false);
attackTiming = 0;
}
切换
bool isToAttack() {
return isAttackKeyDown && !isInRoll();
}
再补上其他节点到攻击的切换。
运行,即可攻击。
但是在空中攻击时无法改变方向,有了之前的铺垫,相信你也能顺利找出bug。
在攻击update的代码中添加
updateXSpeed();
运行,即可。
但是还有一点小bug,那就攻击开始时攻击框会在左上角出现一次。
所以还差一次更新攻击框位置
updateAttackCoordinate();
这个就留下等读者来改吧。
至此已完成状态机改造。
同时我们优化下代码,将idle和run的update也封装为对应的update。
代码全部放在完整代码中。
总结
不难发现,状态节点不生产代码,只是代码的搬运工,所有的代码都由Zero提供。
我们知道,堆砌模式的缺点在于方便的状态管理以及拆分代码以及解耦合呢。状态模式是如何做到这一点呢。
状态管理以及拆分代码非常简单。首先就是将状态抽象成一个类,这样就能状态管理。其次,再将代码抽成函数,这样就实现拆分代码。但是,如果仅是这样的话还不足以叫状态机,因为我们最重要的期望的能自动切换状态。那么到底哪一部分是状态模式的核心特色呢,没错,就是切换。
堆砌模式下一个状态定义需要五部分,而状态节点则需要四部分。同时enter与quit不需要主动调用,而是通过状态模式自动调用,因此也就剩两部分,即 进入条件 与 切换。因此我们在编码时也能体会到,需要着重考虑的也就是这两个函数而已。换句话说,只要实现了 进入条件 与 切换,状态就能自动管理了。
那么解耦合呢,正如前文所说,状态机不能解耦合,因为添加状态时还会影响到其他节点的进入条件。但虽不能解耦合,状态模式却能将耦合记录下来。耦合就是两个独立节点之间的边,状态机虽无法让边消除,却可以在切换时记录,我们写切换的if else 不就是记录一条一条的边吗。这也比堆砌好多了。
我们知道堆砌模式的缺点,但堆砌模式为什么有这样的缺点呢。其实就是因为同时运行所有状态。
我们知道状态机的优点,但状态机为什么有此优点呢,其实就是因为一次只能运行一个状态。这就是两种模式的特性,在实际编码时,分析原因,利用特性解决问题即可。
完整代码
通过网盘分享的文件:Zero.zip
链接: https://pan.baidu.com/s/1_syMSh-ns4nuxhORGL8Pcg?pwd=1111 提取码: 1111
不重要实现
vector2.h
#pragma once
#include <cmath>
//自定义的加减乘除二维类
class Vector2{
public:
float x = 0;
float y = 0;
public:
Vector2() = default;
~Vector2() = default;
Vector2(float x, float y)
: x(x), y(y) {
}
Vector2 operator+(const Vector2& vec) const
{
return Vector2(x + vec.x, y + vec.y);
}
void operator+=(const Vector2& vec)
{
x += vec.x, y += vec.y;
}
void operator-=(const Vector2& vec)
{
x -= vec.x, y -= vec.y;
}
Vector2 operator-(const Vector2& vec) const
{
return Vector2(x - vec.x, y - vec.y);
}
float operator*(const Vector2& vec) const
{
return x * vec.x + y * vec.y;
}
Vector2 operator*(float val) const
{
return Vector2(x * val, y * val);
}
void operator*=(float val)
{
x *= val, y *= val;
}
float length()
{
return sqrt(x * x + y * y);
}
Vector2 normalize()
{
float len = length();
if (len == 0)
return Vector2(0, 0);
return Vector2(x / len, y / len);
}
};
collisionBox.h
#pragma once
#include "vector2.h"
class CollisionBox{
private:
Vector2 coordinate;
Vector2 size;
bool enabled = true;
public:
CollisionBox(const Vector2& size, bool enabled) :size(size), enabled(enabled) {
}
bool isEnabled() {
return enabled;
}
void setEnabled(bool flag){
enabled = flag;
}
const Vector2& getSize() {
return size;
}
void setCoordinate(const Vector2& coordinate) {
this->coordinate = coordinate;
}
void render() {
if (enabled) {
//矩形左上角,右下角坐标
rectangle(coordinate.x, coordinate.y, coordinate.x + size.x, coordinate.y + size.y);
}
}
};
stateMachine.h
#pragma once
#include "stateNode.h"
#include <string>
#include <unordered_map>
using std::string;
using std::unordered_map;
class StateMachine{
private:
StateNode* currentStateNode = nullptr;
unordered_map<string, StateNode*> stateNodes;
public:
void registerState(const string& id, StateNode* stateNode);
void setEnter(const string& id);
void update(float deltaT);
public:
void switchTo(const std::string& id);
public:
~StateMachine();
};
stateMachine.cpp
#include "stateMachine.h"
void StateMachine::registerState(const string& id, StateNode* stateNode){
stateNodes[id] = stateNode;
}
void StateMachine::setEnter(const string& id){
currentStateNode = stateNodes[id];
currentStateNode->enter();
}
void StateMachine::update(float deltaT){
currentStateNode->update(deltaT);
}
void StateMachine::switchTo(const std::string& id){
currentStateNode->quit();
currentStateNode = stateNodes[id];
currentStateNode->enter();
}
StateMachine::~StateMachine(){
for (const auto& pair : stateNodes)
delete pair.second;
}