简介:一款基于C语言与EasyX图形库开发的简易植物大战僵尸小游戏,源自C语言程序设计期末作业,代码与说明文档均已打包。适合计算机相关专业在校学生作为课程设计参考,也适合C语言学习者通过源码理解图形界面编程、游戏循环、鼠标响应与碰撞检测等常见知识点。压缩包共98个文件,约16.73MB,内含cpp源文件、Visual Studio工程文件,以及大量gif、png、bmp图片素材和mp3背景音频,可直接编译运行并在此基础上修改扩展。包内同时附带项目文档说明,代码已经过测试并成功运行,可用于期末答辩演示或课程设计提交。目前已有118人学习下载,是一份实用性较高的C语言课程设计范例。
1. 课程设计里的植物大战僵尸:EasyX 能把 C 语言学成什么样
C 语言课程设计做什么题目最划算?植物大战僵尸这个题材,技术门槛不高,展示效果又强,年年都有人交,但真正能编译、能跑、能当场演示的版本真不多。这份用 C 语言 + EasyX 库写的简易植物大战僵尸,就是一份 C 语言程序设计期末作业源码,VS 工程、图片资源、文档说明都收在一个包里。它把窗口初始化、鼠标种植、子弹发射、僵尸刷怪、碰撞判定这些游戏小程序的主干逻辑都串成了一整套代码,对正在找 c++ 课程设计或者 C 语言源代码的人来说,是一个可以直接打开对照学习的参考样例。代码已经跑通,放在期末作业、初期项目演示或者刚学完 C 语言基础的人手里都合适。下面从工程结构到核心玩法,再到底层容易翻车的地方,逐一拆开讲。
2. EasyX 环境与工程骨架:先把 VS 工程拆明白再动手改
2.1 解压后先认文件:这套工程不是单文件项目
大一交上来的 C 语言作业,大概率是一个 .cpp 写到底,所有函数堆在一起。这份不太一样,解压以后是一个完整的 Visual Studio 解决方案:
| 文件 | 作用 |
|---|---|
| MyPvZInC.sln | 解决方案文件,双击直接打开整个工程 |
| MyPvZInC.vcxproj | 项目配置,包含编译器参数、字符集、子系统设置 |
| MyPvZInC.cpp | 入口文件,负责窗口初始化和主循环 |
| common.cpp | 公共数据与素材加载,图片资源在这里统一处理 |
| function.cpp | 游戏功能函数,种植、移动、碰撞、刷怪大多在这里 |
| img | 背景、植物、僵尸的图片素材 |
| .gitattributes / .gitignore | 版本管理辅助文件,对运行没有影响 |
这种拆分方式在课程设计里算是讲究的:入口文件只负责循环框架,common 负责资源,function 负责玩法细节。拿到源码后从 MyPvZInC.cpp 开始读,先看主循环和窗口创建,再进 function.cpp 看具体函数,阅读成本比单文件低很多。如果你打算在这个基础上加新功能,新函数放 function.cpp,新图片素材放 img,结构不会乱。
一个常见的失误是直接在 Visual Studio 里双击 .sln 打开就跑,完全不管编译器版本和配置。EasyX 项目对字符集、子系统、平台位数都有要求,直接编可能蹦出一堆链接错误。先确认三件事:EasyX 已安装;项目配置是 x86 也就是 32 位;字符集设置要和源码保持一致。源码里如果写的是_outtext(L"..."),工程属性就应该用 Unicode 字符集;源码若是普通char*字符串,就改成“使用多字节字符集”。这两样对不上,后面控制台和窗口里的中文都是乱码。
2.2 EasyX 的窗口创建和图像加载是怎么组织的
EasyX 本质是一套 Windows GDI 封装库,程序启动后先initgraph创建绘图窗口,之后所有的drawimage、fillsolidrectangle都作用在这个窗口上。这套工程里最基础的初始化是这样的:
#include <graphics.h> #include <conio.h> int gameWindowWidth = 960; int gameWindowHeight = 540; void initWindow() { // 创建 960x540 的绘图窗口,保留控制台方便看调试日志 initgraph(gameWindowWidth, gameWindowHeight, EX_SHOWCONSOLE); }第一个参数是窗口宽度,第二个是高度。不要一上来就写 1920x1080,植物大战僵尸这类横版地图通常按 960x540 或 800x600 设计,草坪格子数、卡片栏高度、僵尸出生位置都和窗口尺寸绑定,窗口太大会导致素材拉伸变形,太小又放不下整排僵尸。第三个参数EX_SHOWCONSOLE表示保留控制台黑窗,调试的时候可以往里打印变量;给老师演示时改成EX_DEFAULT,控制台就不会弹出来。
窗口创建完,紧接着是加载图片资源。常见的做法是用一个全局IMAGE对象,把背景和角色每一帧都读进去:
#include <graphics.h> IMAGE imgBackground; IMAGE imgZombie[3]; void loadAllImages() { loadimage(&imgBackground, L"./img/background.png"); loadimage(&imgZombie[0], L"./img/zombie_walk1.png"); loadimage(&imgZombie[1], L"./img/zombie_walk2.png"); loadimage(&imgZombie[2], L"./img/zombie_eat.png"); }这里有两个细节值得注意:路径用的是相对路径,代表可执行文件运行目录下的 img 文件夹;字符串带了L前缀,说明工程是 Unicode 字符集。loadimage不是一个不失败的函数,它找不到文件时一般不弹错误框,只是把图片对象留空,画面上一片黑或一片白。我拿到这套工程后第一件事就是检查工作目录:Visual Studio 默认的工作目录是工程文件所在目录,而img文件夹可能没有被复制到Debug或Release输出目录里。把图片路径写死成./img/...,直接双击 exe 时没问题,在 VS 里按 F5 却不一定能找到。后面避坑章会有针对性的解决办法。
2.3 主循环:游戏不是靠延时逻辑撑起来的
如果程序只是initgraph后画一张静态图,那和图片查看器没有区别。游戏要动起来,靠的是“主循环”,每帧做三件事:收集消息,更新逻辑,重新绘制。常见实现长这样:
#include <graphics.h> void update(long long deltaTime); void drawAll(); bool gameRunning = true; int lastClickX = 0; int lastClickY = 0; void gameLoop() { while (gameRunning) { // 用 peekmessage 而不是 getmessage,保证没有消息时画面也持续刷新 ExMessage msg; while (peekmessage(&msg, EX_MOUSE)) { if (msg.message == WM_LBUTTONDOWN) { lastClickX = msg.x; lastClickY = msg.y; } } update(16); // 更新子弹位置、僵尸位置、冷却时间 drawAll(); // 按当前数据重新绘制整帧 Sleep(16); // 约 60 FPS } }逻辑说明:那 16 毫秒的 Sleep 是人为降频,防止 CPU 满载;真正的时间尺度不要依赖循环次数,而要用一个全局累计时间。每次 update 时读取当前毫秒数,减上次时间戳得到 deltaTime,再累加到游戏时钟里。Sleep(16)在大量物体同时刷新时会有波动,但课程设计这个数量级完全够用。
这里一个关键点是peekmessage和getmessage的区别。getmessage在没有消息时会阻塞线程,画面停在最后一帧,鼠标不动游戏就不刷新;peekmessage是非阻塞的,即使玩家没有操作,逻辑和绘图也照常执行。做过 EasyX 的人都知道,鼠标不动画面就卡住,十有八九是这里用了阻塞取消息。
2.4 绘制函数最好集中在一个 drawAll 里
工程里常见的问题是每个功能函数自己画自己,种下一棵植物立刻执行一次绘图,子弹飞一下也立刻刷新,最终相互覆盖。正确做法是集中绘制:逻辑层只改数据,绘图层根据所有数据重新画一整帧。
void drawAll() { BeginBatchDraw(); // 批量绘图,防止闪烁 putimage(0, 0, &imgBackground); // 先画背景 for (int i = 0; i < plantCount; i++) { drawPlant(plants[i]); } for (int i = 0; i < zombieCount; i++) { drawZombie(zombies[i]); } for (int i = 0; i < bulletCount; i++) { drawBullet(bullets[i]); } drawCardBar(); FlushBatchDraw(); // 一次性将内容刷新到窗口 }BeginBatchDraw和FlushBatchDraw这两个函数是 EasyX 专门为防闪烁准备的。先画进内存,再一次性提交到窗口,否则每个对象各自刷新,窗口会不停闪。数据驱动绘制的另一个好处是调试方便:想临时隐藏某类实体,只要注释掉对应绘制循环,不需要动逻辑代码。
工程骨架到这里就清楚了:入口文件负责创建窗口和跑循环,common 负责资源加载,function 实现 update 和 drawAll。下一步看核心玩法,植物、子弹、僵尸三者是怎么在代码里互相作用的。
3. 植物、子弹、僵尸的数据建模:一个结构体数组就够了
3.1 用结构体而不是 class,保持 C 语言风格
如果这是 C++ 课程设计,你大概率会写class Plant、class Zombie。但这份工程是 C 语言作业风格,文件名后缀虽然是 .cpp,实际写作方式还是面向过程:结构体描述实体,全局数组保存所有实体,函数操作数组。植物、子弹、僵尸三段数据大体是这种形态:
#define MAX_PLANTS 128 #define MAX_ZOMBIES 64 #define MAX_BULLETS 256 typedef struct { int x, y; // 所在格子的左上角坐标 int type; // 0=豌豆射手 1=向日葵 2=坚果 int hp; // 当前生命值 int attackTimer; // 攻击间隔计时 } Plant; typedef struct { int x, y; // 当前图片左上角坐标 int width, height; // 图片尺寸,用于碰撞检测 int hp; // 剩余血量 float speed; // 每毫秒移动像素数 int state; // 0=行走 1=啃植物 2=死亡 } Zombie; typedef struct { int x, y; int active; // 0 表示这个槽位空着 int speed; // 子弹每帧移动像素数 } Bullet; Plant plants[MAX_PLANTS]; Zombie zombies[MAX_ZOMBIES]; Bullet bullets[MAX_BULLETS]; int plantCount, zombieCount, bulletCount;参数说明:三个数组都是固定容量。128 个植物槽位,草坪六行九列最多 54 格,留了很大余量;僵尸一屏同时出现十几只就算高压波次,64 够用;子弹上限 256 是为了防止多个豌豆射手同时输出时数组溢出。用数组而不是链表,一个直接好处是遍历代码简单:for 循环从头扫到尾,删除时把最后一个元素搬到被删位置,然后 count--,不会产生内存碎片。缺点是从中间删元素需要搬移数据,但这类小规模游戏完全可以接受。
答辩时老师常问“为什么不用链表”,标准回答是:实体数量有明确上界,数组访问效率高、实现简单;链表适合数量动态变化且频繁插入删除的大规模场景。课程设计用数组是合理取舍。
3.2 鼠标点击种植:坐标换算成格子
玩家点击左边卡片栏,再点草坪,程序要做两件事:判断选中的卡片是否在冷却,判断点击位置是不是可种植的草坪格子。核心是把窗口像素坐标换算成格子坐标:
#define LAWN_START_X 120 #define LAWN_START_Y 90 #define GRID_SIZE 80 int selectedCard = -1; long long cardCooldown[3] = {0}; bool isInLawnArea(int mx, int my) { if (mx < LAWN_START_X || mx >= LAWN_START_X + 9 * GRID_SIZE) return false; if (my < LAWN_START_Y || my >= LAWN_START_Y + 5 * GRID_SIZE) return false; return true; } void tryPlant(int mx, int my) { if (selectedCard < 0) return; if (!isInLawnArea(mx, my)) return; int gridX = (mx - LAWN_START_X) / GRID_SIZE; int gridY = (my - LAWN_START_Y) / GRID_SIZE; for (int i = 0; i < plantCount; i++) { if (plants[i].x / GRID_SIZE == gridX && plants[i].y / GRID_SIZE == gridY) { return; // 格子被占了,不能重复种 } } if (GetTickCount64() < cardCooldown[selectedCard]) return; plants[plantCount].x = LAWN_START_X + gridX * GRID_SIZE; plants[plantCount].y = LAWN_START_Y + gridY * GRID_SIZE; plants[plantCount].type = selectedCard; plants[plantCount].hp = 300; plants[plantCount].attackTimer = 0; plantCount++; cardCooldown[selectedCard] = GetTickCount64() + 5000; selectedCard = -1; }坐标换算用整数除法,gridX 取到 0 到 8 之间的列号;存入坐标时乘回 GRID_SIZE,这样绘制函数直接用植物的 x,y 画图,不需要每次再做除法。检查已占用格子这一步容易漏,漏掉的话多个植物叠在一起,僵尸会同时啃到两颗植物,画面和判定逻辑都乱。这里的冷却写成了固定 5000 毫秒,实际工程里每种植物应该有自己的冷却时间:向日葵快、豌豆射手次之、坚果慢半拍。推荐把植物属性做进一张常量表,type、血量、冷却、攻击间隔、子弹伤害都放在一起,答辩时改参数方便很多。
3.3 子弹移动与碰撞检测:先移动再判定
碰撞检测是横版游戏最容易被新手写歪的地方。最简单的做法是每帧遍历所有子弹,对每颗子弹遍历所有存活僵尸做矩形相交判断。课程设计这个数量级,双重 for 循环完全跑得动:
void updateBullets() { for (int i = 0; i < bulletCount; i++) { bullets[i].x += bullets[i].speed; if (bullets[i].x > gameWindowWidth) // 飞出屏幕就回收 { bullets[i] = bullets[bulletCount - 1]; bulletCount--; i--; continue; } for (int j = 0; j < zombieCount; j++) { if (zombies[j].hp <= 0 || zombies[j].state == 2) continue; int bulletRight = bullets[i].x + 14; int zombieLeft = zombies[j].x; int zombieRight = zombies[j].x + zombies[j].width; if (bulletRight >= zombieLeft && bullets[i].x <= zombieRight) { int bulletTop = bullets[i].y; int bulletBottom = bullets[i].y + 14; int zombieTop = zombies[j].y; int zombieBottom = zombies[j].y + zombies[j].height; if (bulletBottom >= zombieTop && bulletTop <= zombieBottom) { zombies[j].hp -= 20; bullets[i] = bullets[bulletCount - 1]; bulletCount--; i--; break; } } } } }逻辑说明:子弹从左向右移动,speed 是每帧增加的像素数。每秒 600 像素的子弹,60 FPS 下每帧就是 10 像素。检测顺序是先把子弹的 x 和图片尺寸算成一个命中盒,判断和僵尸的 x 区间是否有重叠;x 方向重叠后再检查 y 方向。y 方向这里用的是两个区间是否有交叠,比单纯判断“子弹中心是否落在僵尸身体内”更稳定,否则子弹从僵尸头顶或脚下掠过时容易被误判。
删除元素用“尾部覆盖法”:把最后一个有数据的元素复制到被删除的位置,然后再把计数减一。这个操作比移动后续所有元素快得多,代价是实体顺序会变,遍历时不保证顺序,但对碰撞检测来说顺序无所谓。
3.4 删除实体时别用 memset 清空
新手拿到源码后喜欢把删除操作写成memset(&bullets[i], 0, sizeof(Bullet)),看着像“清空这个格子”。问题在于数组中间会出现空洞,遍历时要反复判断 active 或 hp 是否为 0,计数逻辑也会乱。尾部覆盖法能保证数组前 count 个位置全是有效数据,遍历时可以少写很多无效判断。
还有一个和图片尺寸相关的坑:僵尸图片通常是透明底 PNG,视觉上有留白,直接拿图片宽高做碰撞盒会让“中弹区域”比画面大一整圈。更合理的做法是给每个实体单独维护 hitWidth 和 hitHeight,例如图片 80x80,碰撞盒只取中间 50x60,再配合绘制函数把命中框可视化。这个后面会单独展开。
4. 僵尸刷新与节奏控制:难度不是随机出来的
4.1 刷怪定时器:用游戏时间而不是随机数
僵尸不应该是每帧随机生成,否则会出现“这一波挤在一起,下一波空场半分钟”的断崖体验。常住做法是全局记录游戏累计时间和下一只僵尸的出场时间:
long long gameTime = 0; long long nextZombieTime = 10000; // 第一只僵尸 10 秒后出现 int leftZombies = 20; // 本关剩余僵尸总数 void updateSpawner(long long delta) { gameTime += delta; if (gameTime >= nextZombieTime && leftZombies > 0) { spawnOneZombie(); leftZombies--; // 剩余僵尸越少,下一只来得越快;保底下限 1200 毫秒 int interval = 5000 + (int)(leftZombies * 200); if (interval < 1200) interval = 1200; nextZombieTime = gameTime + interval; } }这个 interval 公式是最简单的难度曲线:后期僵尸数量少,但间隔会压缩到 1.2 秒,制造连续压力。更平滑的做法是把公式改成interval = 8000 - gameTime / 50000,随时间线性缩短。刷怪逻辑要绑定游戏状态,否则胜利界面弹出来之后还有僵尸继续进场,非常出戏。
我拿到这套源码后会先找主循环里有没有维护 gameTime 这个变量。如果整个工程都没有统一时钟,僵尸生成、子弹移动、冷却倒计时各算各的,不同帧率下游戏节奏完全不一样,那就是代码里最需要优先改的部分。
4.2 僵尸属性表:不要在生成函数里写散落魔数
僵尸不会只有一种样子,最少也应该有普通、路障、铁桶三种。参数差异主要在血量、速度和击杀奖励:
| 僵尸类型 | 血量 | 速度 | 击杀奖励 |
|---|---|---|---|
| 普通僵尸 | 200 | 12 px/s | 100 |
| 路障僵尸 | 450 | 12 px/s | 150 |
| 铁桶僵尸 | 700 | 8 px/s | 200 |
这个表是我习惯的数值起点,不是源码必须等于多少。拿到代码后可以直接改常量数组,不用去函数里翻赋值语句:
typedef struct { int hp; float speed; int score; } ZombieTypeInfo; ZombieTypeInfo zombieTypeInfo[3] = { {200, 12, 100}, {450, 12, 150}, {700, 8, 200} }; void spawnOneZombie() { for (int i = 0; i < MAX_ZOMBIES; i++) { if (zombies[i].hp > 0) continue; int type = 0; if (gameTime > 60000) type = 1; if (gameTime > 120000) type = 2; zombies[i].x = gameWindowWidth - 20; zombies[i].y = 90 + (rand() % 5) * 80; zombies[i].type = type; zombies[i].hp = zombieTypeInfo[type].hp; zombies[i].speed = zombieTypeInfo[type].speed; zombies[i].state = 0; break; } }一个容易忽略的问题:随机生成 y 坐标时,两只僵尸可能落在同一行,然后一前一后叠加在同一个格子上,视觉上变成一只,实际攻击判定却是两只。简单解决是生成前检查当前行有没有存活僵尸,如果有就换到相邻行。
4.3 僵尸啃植物和游戏失败判定
僵尸走到植物面前要停下来啃,而不是直接穿过植物。实现思路是预测下一步位置,看能不能够到植物:
void updateZombie(int i, long long delta) { int targetX = zombies[i].x + (int)(zombies[i].speed * delta / 1000); for (int j = 0; j < plantCount; j++) { if (targetX <= plants[j].x + GRID_SIZE && zombies[i].x >= plants[j].x) { zombies[i].state = 1; // 进入啃食状态 zombies[i].x = plants[j].x + GRID_SIZE; break; } } if (zombies[i].state == 0) { zombies[i].x = targetX; } else if (zombies[i].state == 1) { // 每隔固定时间扣植物血量,并且画面要播放啃食动画 if (zombies[i].lastAttackTime + 1000 <= gameTime) { plants[nearestPlantIndex].hp -= 25; zombies[i].lastAttackTime = gameTime; } } }失败判定和这个逻辑是绑定的:僵尸走到 x 小于某个阀值,比如 80 像素,就说明它进入房子了,游戏切换成失败状态。很多课程设计只写了“僵尸吃光植物”才失败,漏了“僵尸进屋也算失败”这条规则,导致玩家挂机几分钟后才发现已经输了。
游戏整体状态建议用一个枚举管理,从菜单、战斗中、胜利、失败到退出,一次性把状态流转理清楚:
enum GameState { STATE_MENU, STATE_PLAYING, STATE_WIN, STATE_LOSE, STATE_QUIT }; GameState currentState = STATE_MENU;主循环每帧只执行当前状态下该做的事:菜单状态只处理开始按钮,战斗状态才更新刷怪和碰撞,胜利或失败状态弹出结算界面并等待鼠标点击重新开始。如果不做状态机,最常见的表现是游戏失败后僵尸还在移动,子弹还在飞行,背景音乐还在继续,整个程序就“失控”了。
5. 避坑与常见问题:EasyX 课程设计最容易翻车的六个片断
5.1 编译报错找不到 graphics.h
现象:一打开工程点编译,控制台提示fatal error C1083: Cannot open include file: 'graphics.h': No such file or directory。
原因:EasyX 没安装,或者安装了但版本和当前 VS 不匹配。EasyX 的安装器会扫描本机 VS 版本,如果 VS 装在非标准路径,或者用的是精简版、绿色版,安装器可能没识别到,头文件自然没有放进 include 目录。
解决:重新运行 EasyX 安装包,选择当前正在用的 VS 版本再装一次。然后单独建一个空项目,写一行#include <graphics.h>测试能否编译。测试通过说明环境没有问题,问题在课程设计工程属性上;测试不通过就重装或者手动检查 include 目录是否被修改过。
5.2 loadimage 失败,背景一片黑但程序不报错
现象:运行后窗口能打开,但只看到黑底或白底,植物、僵尸、背景都没有显示,程序也没有任何报错对话框。
原因:loadimage在文件不存在时不会弹出错误提示,只会留下一个空 IMAGE 对象。最典型的原因是 Visual Studio 调试运行时的当前工作目录和 exe 所在目录不一致。工程默认工作目录是 vcxproj 所在路径,而图片引用写的是./img/...,实际运行去 exe 旁边找 img,结果没找到。
解决:不要依赖于相对路径。用GetModuleFileName取当前 exe 的绝对路径,截掉文件名,再拼接上img文件夹,得到完整图片路径:
TCHAR exePath[MAX_PATH]; GetModuleFileName(NULL, exePath, MAX_PATH); TCHAR dir[MAX_PATH]; wsprintf(dir, L"%s\\img\\background.png", exePath); // 这里还需要去掉文件名部分,只保留目录,再拼接 img这个方法在 VS 里按 F5 和直接双击 exe 运行效果一致,是治本的办法。赶时间的话也可以把 img 文件夹手动复制到 Debug 或 Release 输出目录里,但每次清理解决方案后都要重新复制,比较烦。
5.3 中文显示乱码
现象:outtextxy输出的汉字变成一堆符号,或者 Linux 上正常的中文注释在 VS 里变成乱码。
原因:VS 工程默认使用 Unicode 字符集,而源文件可能是无 BOM 的 UTF-8。字符串常量在编译期的编码转换和运行期_outtext的输出解析不一致,中文必乱。
解决:把源文件统一保存成 UTF-8 with BOM,工程属性里的字符集和代码里字符串前缀对齐。最保险的方案是代码里的文字类内容全部用英文或拼音,路径、卡片名称、游戏标题都用英文,中文只出现在注释里。这样即使字符集配置不小心被改动,也不会影响游戏画面上的文字显示。
5.4 碰撞判定总感觉“打偏了”
现象:子弹明明离僵尸还有一段距离,僵尸却在扣血;反过来子弹穿过僵尸图片,僵尸毫发无伤。
原因:碰撞矩形直接用了整张图片的宽高。PNG 素材往往带透明边缘,或者美术上角色本身没有充满整个画布,导致视觉身体和逻辑碰撞盒不一致。还有种情况是 y 方向判断误写成了“子弹中心点必须落在僵尸矩形内”,坐标偏移几像素就会产生明显误判。
解决:给实体单独维护逻辑碰撞盒,不直接用 image 宽高。图片 80x80,碰撞盒宽度可以只取 40,高度取 60,然后写一个绘制调试框的函数,把碰撞盒画出来,肉眼看偏差再微调。没有可视化之前全靠猜,有了可视化之后三分钟就能调准。
5.5 关闭窗口后进程还在后台
现象:游戏窗口点关闭后消失了,但任务管理器里还能看到 exe,CPU 占用居高不下,只有结束调试才消失。
原因:主循环是while(true)没有退出条件。EasyX 的关闭消息进入消息队列后,程序没有处理WM_CLOSE,循环继续跑。另一个原因是程序执行了return但没有调用closegraph(),绘图窗口的 GDI 资源没有被释放。
解决:定义bool gameRunning = true,主循环条件写while(gameRunning);处理消息段检查msg.message == WM_CLOSE时设置gameRunning = false,循环结束前调用closegraph()。这是我见过课程设计里最常见又最尴尬的问题:演示完代码,结果发现进程杀不掉。
5.6 画面闪烁,僵尸多的时候速度明显变慢
现象:窗口里图片频繁闪烁,或者僵尸数量一多,整个游戏速度明显下降,僵尸少时又恢复。
原因:没有使用批量绘图,每一张图片都立即提交到窗口,GDI 刷新跟不上。另一个根源是固定Sleep(16)没有真实帧间隔补偿,机器卡顿会把帧周期拉长,游戏时间却按循环次数累加,产生“僵尸越多跑得越慢”的雪崩。
解决:绘制函数外层套BeginBatchDraw()和FlushBatchDraw();时间基准用GetTickCount64或timeGetTime计算真实 delta,不要把循环次数当成时间。
6. 把 Demo 调出手感:可视化命中框和参数平衡
6.1 命中框可视化,碰撞微调不靠猜
我拿到任何一份带碰撞的游戏源码,第一步就是加调试绘制。做法是在绘制函数末尾把所有实体的逻辑碰撞盒画出来:
void drawDebugHitbox() { setlinecolor(LIGHTRED); for (int i = 0; i < zombieCount; i++) { rectangle(zombies[i].x + 15, zombies[i].y + 5, zombies[i].x + zombies[i].width - 15, zombies[i].y + zombies[i].height - 5); } for (int i = 0; i < bullets.length; i++) { rectangle(bullets[i].x, bullets[i].y, bullets[i].x + 14, bullets[i].y + 14); } }这是给代码加调试开关的好时机:用一个全局bool debugDraw,通过键盘按键随时开关。演示时关掉,调试时打开,不用反复注释代码。
6.2 把 FPS 和游戏时间打到窗口上
手感问题很难靠眼睛判断,但可以量化。调试期内我会在窗口左上角显示两个数字:当前 FPS 和累计游戏时间。帧率长期低于 50,就是绘制或者逻辑有性能问题;游戏时间走得比真实时间慢,就是 deltaTime 计算有误。实现方法不复杂,统计一秒钟内真实经过了多少帧,再绘制到图形窗口上。
void drawDebugInfo() { char buff[64]; wsprintf(buff, L"FPS:%d Time:%lldms", fps, gameTime); settextcolor(WHITE); outtextxy(10, 10, buff); }这段代码对答辩也有好处。老师看到窗口上有 FPS 和运行时间,说明你知道性能和时间基准是怎么回事,比空口讲解更有说服力。
6.3 手感参数调到哪里算合适
植物大战僵尸的“手感”本质是三组数值的关系:植物攻击间隔、子弹速度、僵尸移动速度。攻击间隔太长,玩家觉得植物在偷懒;子弹速度太慢,玩家要到子弹打中那一刻才觉得输出有了反馈;僵尸速度太快,防线还没摆好就崩。我常用的起步参数是豌豆射手攻击间隔 1.4 秒、子弹每帧 14 像素、普通僵尸速度每秒 12 像素。
| 调整方向 | 现象 | 参数改法 |
|---|---|---|
| 打不死僵尸 | 子弹打在僵尸身上很久才死 | 提高子弹伤害或降低僵尸血量 |
| 植物攻击太慢 | 僵尸走到面前时植物还没打死它 | 缩短攻击间隔 |
| 游戏太紧张 | 第一波僵尸还没种下植物就来了 | 推迟首刷时间,降到 15 秒 |
| 游戏太无聊 | 全程不需要移动鼠标就能赢 | 提高僵尸速度或减少植物冷却 |
从这个角度来看课程设计:代码能跑只是及格线,能把参数调到有基本可玩性,才算真正理解了这套源码。
从那以后,我每次拿到 EasyX 相关的课后作业源码,都会先花十分钟做三件事:确认字符集配置、加一个全局调试开关、把命中框画出来。这三件事做完,后面改功能、调平衡都顺畅很多。希望帮到你。
本文还有配套的精品资源,点击获取