第一次把V01版本的恐龙跳跃跑通时,我发了条朋友圈,配文是“游戏开发入门,其实也没那么难”。现在回头看,那句Flag立得挺打脸的。V01确实能跑,能跳、能撞仙人掌、能Game Over,但代码就是一团麻花:恐龙的位置、速度、跳跃状态全是全局变量,障碍物用一个固定数组硬扛,主函数的while循环里塞了快两百行逻辑。最讽刺的是,我想给恐龙加一个“二段跳”,搜遍整个文件才发现跳跃状态藏在第三个if分支里,改完以后连着戳坏了两次碰撞判定。
所以当V02要求我用面向对象或者结构体重写这版游戏时,我反而有点庆幸。这不只是“让代码变好看”,这是把一个已经能玩的游戏从“一次性脚本”升级成“真正能被后续维护的项目”的关键一步。这篇文章不聊什么高深的设计模式,就把我在EGE19.01上重构恐龙跳跃游戏的真实过程记录下来:先讲为什么V01必须重构,然后分别给出结构体版本和类版本的完整思路与代码,最后聊一聊两版跑完后的取舍判断,以及重构中踩过的那些让人拍桌子的坑。
1. 为什么V01那一版让我下决心重写:面条代码与全局变量的代价
V01刚写完的时候,功能上其实没有太大缺陷。游戏无非就那几件事:恐龙跳跃、障碍物左移、碰撞检测、分数累加、Game Over重开。但问题在于,这些逻辑“散装”得太厉害了。
我举个例子。V01里跳跃状态是用一个int jumpState全局变量控制的,0表示在地面,1表示起跳,2表示下落。听起来很简单对吧?但跳跃状态的修改分散在两个地方:main循环里读取空格键时把它改成1;恐龙更新函数里根据当前y坐标把它改回0或者改成2。而碰撞检测函数又需要读取这个变量来决定要不要缩小碰撞框。然后有一天我想调整跳跃高度,把初速度从-12改成-15,结果发现恐龙落地判定出了问题——它在落地的一瞬间又弹了一小段。为什么呢?因为落地判定里写的是y >= GROUND_Y - 10,这个-10是我当时为了让恐龙看起来更“贴地”随手加的偏移,它和跳跃初速度耦合在了一起。
这就是典型的全局变量造成的“状态污染”。数据没有归属,任何函数都能改;操作也没有边界,改一处不留意就影响到另一处。更头疼的是,V01里障碍物的生成和回收都是通过遍历数组完成的,数组下标一多,代码读起来真的像在解谜。
到了V02,我的核心目标很明确:把恐龙、障碍物、游戏状态这三块数据重新组织起来。数据要么用结构体打包,要么用类封装,让每一个操作都作用在一个明确的对象上,而不是漫山遍野地找全局变量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 重构前先做数据建模:恐龙、仙人掌和全局游戏状态
真正动手写结构体和类之前,我先花了一些时间把游戏里到底有哪些“实体”想清楚。这一步非常重要,直接决定后面代码长什么样。
恐龙需要管理的数据有:位置(x,y)、垂直速度vy、宽高w和h、跳跃状态。注意我这里把y定义为“脚底坐标”,也就是恐龙脚掌贴地的位置。很多EGE新手容易把y理解成“头顶坐标”或者“中心坐标”,导致画图和碰撞检测对不上号。我习惯用脚底坐标,是因为障碍物的y也同样定义为“底部坐标”,这样两个物体碰撞检测时统一从底部往上减高度,逻辑会清晰很多。
仙人掌/障碍物的数据有:位置x和y、宽高w和h、类型type、是否活跃active。类型我定成0和1,0是小仙人掌,1是大仙人掌,后续如果要加翼龙,可以再加2。
全局游戏状态包括:恐龙对象、障碍物数组、当前分数、当前移动速度、障碍物生成计时器、游戏是否结束。
把这些数据列出来以后,结构体版本其实就已经呼之欲出了:用三个结构体分别描述恐龙、障碍物、游戏状态,再用一组函数去操作它们。这也是C语言开发者最熟悉的一套思路。
cpp复制typedef struct {
float x, y; // x是左上角,y是脚底坐标
float vy; // 垂直速度,正数向下
float w, h;
int jumping; // 0=地面 1=跳跃
} Dino;
typedef struct {
float x, y;
float w, h;
int type; // 0=小仙人掌 1=大仙人掌
int active; // 1=在场 0=已回收
} Obstacle;
typedef struct {
Dino dino;
Obstacle obs[6]; // 最多同时存在6个障碍物
int score;
float speed;
int spawnTimer;
int gameOver;
} Game;
相比V01那种“定义十个全局变量然后到处传”的写法,这种结构体嵌套的方式最直接的收益就是:你一眼就能看出某个数据属于哪个实体。而且函数签名也变得清晰了,比如操作恐龙的函数就传Dino*,操作游戏整体的函数就传Game*,不再需要在一个函数里同时修改五六个全局变量。
3. 结构体版本落地:用数据聚合和纯函数驱动游戏循环
确定数据模型后,结构体版本的编码就非常顺理成章了。核心思路是:结构体只管数据,函数来操作数据。每个函数名加前缀标明它服务的对象,例如Dino_开头的函数只操作恐龙,Obs_开头的函数只操作障碍物。
恐龙的操作函数如下:
cpp复制void Dino_Init(Dino* d) {
d->x = DINO_X;
d->y = GROUND_Y;
d->vy = 0;
d->jumping = 0;
d->w = 48;
d->h = 50;
}
void Dino_JumpStart(Dino* d) {
if (!d->jumping) {
d->vy = JUMP_VY;
d->jumping = 1;
}
}
void Dino_Update(Dino* d) {
if (!d->jumping) return;
d->vy += GRAVITY;
d->y += d->vy;
if (d->y >= GROUND_Y) {
d->y = GROUND_Y;
d->vy = 0;
d->jumping = 0;
}
}
void Dino_Draw(const Dino* d) {
int x = (int)d->x;
int y = (int)d->y;
setfillcolor(RGB(66, 66, 66));
// 身体
bar(x, y - 45, x + 40, y);
// 头
bar(x + 24, y - 50, x + 46, y - 34);
// 眼睛
setfillcolor(RGB(255, 255, 255));
bar(x + 30, y - 48, x + 38, y - 42);
}
这种函数的优点是很“直给”:Dino_JumpStart检查是否在跳跃,如果是就不响应,避免了空中二段跳;Dino_Update只处理跳跃时的物理位移,落地后自动复位。
障碍物的操作也类似,无非就是初始化、更新位置、绘制三个函数。比较值得多说两句的是障碍物回收逻辑。因为我用的是固定数组,数组里的元素不会真正消失,只能靠active标记来区分“可用”和“已使用”。生成新障碍物时,遍历数组找一个active == 0的位置;障碍物移出屏幕左侧后,把它的active设回0,这样下次就能重复利用。这种“对象池”思想在游戏开发里很常见,用固定数组实现,省去了动态内存申请的麻烦。
cpp复制void Game_Update(Game* g) {
if (g->gameOver) return;
Dino_Update(&g->dino);
// 生成新障碍物
g->spawnTimer--;
if (g->spawnTimer <= 0) {
for (int i = 0; i < MAX_OBS; i++) {
if (!g->obs[i].active) {
Obs_Init(&g->obs[i], rand() % 2, WIN_W + 20);
break;
}
}
g->spawnTimer = 80 + rand() % 70;
}
// 更新所有在场障碍物并做碰撞检测
for (int i = 0; i < MAX_OBS; i++) {
if (g->obs[i].active) {
Obs_Update(&g->obs[i], g->speed);
if (CheckCollision(&g->dino, &g->obs[i])) {
g->gameOver = 1;
}
}
}
g->score += 2;
if (g->score % 500 == 0) g->speed += 0.5f;
}
这套结构体版本的代码运行起来非常稳定,因为它彻底消除了“改一个全局变量结果另一个功能莫名失效”的问题。每个函数操作的对象都写在参数里,读代码时顺着函数签名走就能理清调用链。对于只学过C语言、还没接触C++的同学来说,这套写法是通向工程化代码的一个很好的过渡。
4. 类版本落地:把行为封装进class,释放继承与多态
结构体版本解决了不少问题,但当我想再新增一种敌人——比如天上飞行的翼龙——时,我发现自己还是要同时修改好几处地方:要给Obstacle加一个flyHeight字段,要改Obs_Init,要改Obs_Update,要改Obs_Draw,还要改碰撞检测。这让我开始认真考虑面向对象版本。
类的核心思路和结构体版本最大的不同是:不再“数据和函数分离”,而是把数据和操作恐龙的行为、数据操作障碍物的行为全部绑定在每个类内部。抽象出一个Sprite基类,把公共属性(x、y、w、h)放在里面,再通过虚函数让子类各自实现更新和绘制。
cpp复制class Sprite {
public:
float x, y, w, h;
Sprite(float px, float py, float pw, float ph)
: x(px), y(py), w(pw), h(ph) {}
virtual void Update(float speed) = 0;
virtual void Draw() = 0;
virtual ~Sprite() {}
};
class Dino : public Sprite {
private:
float vy;
bool jumping;
public:
Dino() : Sprite(DINO_X, GROUND_Y, 48, 48), vy(0), jumping(false) {}
void Jump() {
if (!jumping) {
vy = JUMP_VY;
jumping = true;
}
}
void Update(float) override {
if (!jumping) return;
vy += GRAVITY;
y += vy;
if (y >= GROUND_Y) {
y = GROUND_Y;
vy = 0;
jumping = false;
}
}
void Draw() override {
setfillcolor(RGB(66, 66, 66));
bar((int)x, (int)y - 45, (int)x + 40, (int)y);
bar((int)x + 24, (int)y - 50, (int)x + 46, (int)y - 34);
}
};
Dino类把跳跃状态、垂直速度都封装成了私有成员,外部只能调用Jump()和Update(),根本不可能像V01那样在碰撞检测函数里顺手改掉jumping。这就是封装的意义:数据的安全边界由编译器帮你守住。
Cactus类的实现类似,继承Sprite后,只需要实现Update和Draw两个虚函数。
真正让我体会到面向对象威力的是新增敌人类型的过程。如果我想加一个翼龙,只需要再写一个Pterosaur类继承Sprite,然后在游戏管理类里用基类指针统一操作:
cpp复制class Cactus : public Sprite {
private:
int type;
public:
Cactus(int t, float startX)
: Sprite(startX, GROUND_Y, (t == 0) ? 24 : 36, (t == 0) ? 38 : 58),
type(t) {}
void Update(float speed) override {
x -= speed;
}
void Draw() override {
setfillcolor(RGB(0, 120, 60));
if (type == 0) {
bar((int)x + 8, (int)y - (int)h, (int)x + (int)w - 8, (int)y);
} else {
bar((int)x, (int)y - (int)h, (int)x + (int)w, (int)y);
bar((int)x - 10, (int)y - (int)h + 20, (int)x, (int)y - (int)h + 28);
}
}
};
在Game类里,我用了vector<Cactus*>来存储障碍物,配合多态,以后不管新增多少种敌人,游戏主循环都不需要大改。内存管理方面要特别注意:vector自己不会帮你析构,所以如果新增敌人对象,要么在适当位置delete,要么改用shared_ptr托管。我在V02里用的是手动delete,胜在教学友好,但真实项目中建议用智能指针。
cpp复制class Game {
private:
Dino dino;
vector<Cactus*> cacti;
int score;
float speed;
int spawnTimer;
bool gameOver;
public:
Game() : score(0), speed(6.0f), spawnTimer(60), gameOver(false) {}
~Game() { DeleteAll(); }
void DeleteAll() {
for (auto p : cacti) delete p;
cacti.clear();
}
void HandleInput() {
while (kbhit()) {
char ch = getch();
if (ch == ' ') dino.Jump();
if (ch == 27) exit(0);
}
}
void Update() {
if (gameOver) return;
dino.Update(speed);
spawnTimer--;
if (spawnTimer <= 0) {
cacti.push_back(new Cactus(rand() % 2, 820));
spawnTimer = 80 + rand() % 70;
}
for (int i = (int)cacti.size() - 1; i >= 0; i--) {
cacti[i]->Update(speed);
if (cacti[i]->x + cacti[i]->w < 0) {
delete cacti[i];
cacti.erase(cacti.begin() + i);
}
}
if (Collision()) gameOver = true;
score += 2;
if (score % 500 == 0) speed += 0.5f;
}
};
类版本的代码行数其实和结构体版本差不多,但结构和含义清晰了太多。尤其是新增敌人时,我不需要再回头改“生成逻辑”“更新逻辑”“绘制逻辑”三个地方,只要新增一个类并确保它继承了Sprite、实现了两个虚函数,剩下的交给多态就好。
5. 跳跃物理和碰撞检测:两版通用但最容易写错的核心逻辑
不管结构体版本还是类版本,跳跃物理和碰撞检测是整个游戏最容易踩坑的地方。我单独抽出来说一说。
先说跳跃。V02我用的是一种最常见的简化物理模型:垂直速度每帧累加重力,再叠加到位置上。具体数值是:JUMP_VY = -14.0f,GRAVITY = 0.8f。这里单位为“每帧”。
如果算一下跳跃高度:
v = v0 + at,上升阶段结束时v=0,所以上升时间t = 14 / 0.8 = 17.5帧。
上升最大高度h = v0*t - 0.5*g*t^2 = 14*17.5 - 0.5*0.8*17.5^2 ≈ 122像素。
恐龙从GROUND_Y=420起跳,最高能到420-122=298,而最大的仙人掌高度是58像素,所以这个跳跃高度是很充足的。如果你想让跳跃更“飘”一点,就把GRAVITY调小;想更“重”更“脆”,就调大。这个数值组合改起来非常直观。
再说碰撞判定。我用的就是AABB(轴对齐包围盒)碰撞检测,原理很简单:两个矩形如果水平方向或垂直方向有任意一个方向没有重叠,那就不算碰撞;否则就碰撞。但直接拿恐龙和障碍物的原始尺寸做检测往往会让玩家觉得“明明没碰到就死了”,这是因为图形边缘和实际碰撞盒有偏差。所以我在实现里给两个物体的碰撞框都做了收窄处理:左右各收缩几个像素,顶部收缩,底部留一点余量。
cpp复制bool CheckCollision(const Dino* d, const Obstacle* o) {
if (!o->active) return false;
// 恐龙碰撞盒:左右各收缩6像素,顶部收缩6像素,底部收缩2像素
float l1 = d->x + 6, r1 = d->x + d->w - 6;
float t1 = d->y - d->h + 6, b1 = d->y - 2;
