LEVEL 02 / 会分种类的敌人
让敌人分出「四种脾气」
老师已经把游戏骨架、四种怪的长相都备好了。你来补完 6 处 ★TODO:先把玩家和子弹换成贴图,再用一张「配置表」让普通 / 快速 / 重甲 / 精英各有各的速度、血量和分数。想改强弱,只改表,逻辑一行不动——这就是今天的大招。
👋 0 · 开始这里
上一课你把半成品改成了能玩的游戏。这一课,敌人不再是清一色的红方块——它们会分成四种「脾气」:普通、快速、重甲、精英,各有各的速度、血量和分数,还都换上真正的贴图。
🎯 今天你要做什么
填 6 处 ★TODO(代码里都标好了序号),把游戏升级成「怪物图鉴」版。核心新本领只有两个:类型(enum) 和 配置表(数据驱动)。顺带练到 struct、switch,以及贴图绘制 DrawSprite。git 继续用上一课学会的 commit 保存每一步。
🗺️ 六步路线图
| TODO | 补完后游戏会 |
|---|---|
| ① 玩家/子弹贴图 | 蓝方块变成贴图小人,子弹变成能量弹 |
| ② 填全配置表 | 四种怪各有速度/血量/分数/长相(一张表说了算) |
| ③ 刷怪记种类+满血 | 刷出来的怪记住「自己是哪种」并满血开场 |
| ④ 速度按种类 | 快的真快、重甲真慢(速度查表得来) |
| ⑤ 敌人各自贴图 | 四种怪长相各不相同 |
| ⑥ 命中扣血+按种给分 | 重甲要连打几下才倒,杀不同的怪加不同的分 |
🗑 其中 3 处要「先删掉」再写
上面六步里,②③④ 是在原来的行上补东西、改数值;另外 3 处不一样 —— 老师故意留了「临时写法」,让游戏在没做完的时候也能跑起来。你要先整行删掉,再写新的。删代码容易手抖,先把这 5 行认清楚(代码里都标了 ★TODO,照序号找):
// ① 在 DrawGame 里 —— 删掉这两行方块: DrawRectangleRec(player.rect, BLUE); DrawRectangleRec(bullets[i].rect, BLACK); // ⑤ 也在 DrawGame 里 —— 删掉这行红方块: DrawRectangleRec(enemies[i].rect, RED); // ⑥ 在 HandleHits 里 —— 删掉这两行「临时」码: enemies[j].active = false; // 临时:一下就死 score += 10; // 临时:不管什么怪都只给 10 分
删掉之后换成什么,每一步里都写好了:① 换成贴图 · ⑤ 按种类换贴图 · ⑥ 换成扣血。
make 不会报错 —— 但会出怪事:⑤ 那行删了不补,下面画 HP / 分数的语句就被 if (enemies[i].active) 吃掉了,场上没怪时,分数那行字整个消失。每删一行就立刻补上一行,别停在「删完了」的中间状态。★TODO,顺着 ①→⑥ 做最顺(后面几步都要用到 ② 那张表)。📥 获取这一课的代码
第一次配置(fork、upstream)只做一次,见 课程主页。之后开第 2 课就两条命令:
git fetch upstream # 取老师最新的课 git switch -c my-lesson-2 --no-track upstream/lesson-2 # 从第2课起点开一条自己的分支
🧰 1 · 工具速查
操作说明。命令都在 MSYS2 UCRT64 终端、工程根目录 first-frame/ 下运行。做题时随时回来查。
四条 make 命令
| 命令 | 作用 |
|---|---|
make | 编译,生成 build/game.exe |
make run | 编译并打开游戏窗口(手动试玩,看贴图和四种怪) |
make test | 运行自动评测,不开窗口,只报告哪些通过、哪些未通过 |
make clean | 删除编译产物,需要从头再来时用 |
📦 贴图怎么画:一句 DrawSprite
老师已经把所有贴图放进 assets/,并写好了一个「门面」assets.h。你不用碰读图、缩放这些细节,只要一句:
DrawSprite(SpriteId::Player, player.rect); // 在 player.rect 这个方框里,画玩家贴图
SpriteId 是「贴图名字表」:Player / Bullet / Grunt / Runner / Heavy / Elite / Floor。想画哪张,就把名字填进去。配置表里也存了每种怪该用哪张贴图。
看懂 make test 的红和绿
它是个自动评分器(autograder),按可测的 TODO 分组打分:
[2] Config table -- four enemy kinds' stats [PASS] Runner faster than Grunt (speed) [FAIL] Heavy hp > 1 (takes several hits) (test_todos.cpp:73) -- group passed 5 / 6 --
- 🟢
[PASS]= 正确;🔴[FAIL]= 有误,读那行英文说明。 - 有的行还会给 (expected X, got Y)(期望 X、实际 Y),直接点出差在哪。
- 行尾
(test_todos.cpp:73)是检查所在行号。
[PASS]/[FAIL] + 组名 [2] [3] [4] [6]),这样在任何终端里都绝不乱码。组号 [2]…[6] 就对应下面 TODO 的 ②…⑥。make run 跑起来,四种怪长相不同、玩家子弹是贴图,就算过。提交三步(commit)
git status # ① 查看改了哪些文件 git add src/main.cpp # ② 放进「待提交」区 git commit # ③ 正式提交(照弹出的模板填标题)
1玩家 / 子弹换贴图 🎨
在 DrawGame 里,把画玩家和子弹的「方块」换成「贴图」。热身第一步,先熟悉 DrawSprite。
🎯 这一步学会
- 用一句
DrawSprite(SpriteId::X, 方框)画贴图 - 看懂「门面」:细节被藏进
assets.h,你只管调用
📋 你要做到
在 DrawGame 里找到标了 ★TODO① 的两行:现在用 DrawRectangleRec 画蓝方块(玩家)和黑方块(子弹)。把它们换成 DrawSprite(SpriteId::Player, player.rect) 和 DrawSprite(SpriteId::Bullet, bullets[i].rect)。
🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
- 玩家那行:
DrawRectangleRec(player.rect, BLUE);→DrawSprite(SpriteId::Player, player.rect); - 子弹那行(在
for里):把DrawRectangleRec(bullets[i].rect, BLACK);换成DrawSprite(SpriteId::Bullet, bullets[i].rect); - 方框(
rect)不用改——贴图会自动铺满这个方框。
SpriteId 名字有没有拼错、assets/ 里图在不在。🧪 怎么验证
make run # 玩家变成贴图小人、子弹变成能量弹就对了
这一步自动测试测不到,靠眼睛看。
源码检查后眼睛验:
make run 里玩家和子弹都是贴图(不再是方块)→ 提交:git commit2填全配置表 📊
这一步是全课的核心。在 ConfigOf 里,照「普通怪」的范例,补上快速 / 重甲 / 精英三行数据。
🎯 这一步学会
enum class:给每种怪起个名字(类型)struct+switch:一种怪对应「一行数据」- 数据驱动:强弱写在表里,不写死在逻辑里
📖 概念:类型 + 配置表
enum class EnemyKind 给四种怪各起个名字(Grunt / Runner / Heavy / Elite)。EnemyConfig 是「一行数据」,装着一种怪的全部数值:{ 贴图, 速度, 满血, 分数, 大小 }。ConfigOf(kind) 就是查表——给一个种类,返回它那一行。以后想让重甲更肉?只改表里那个血量,别处一个字都不用动。这就叫「数据驱动」。
📋 你要做到
范例已给:case EnemyKind::Grunt: return { SpriteId::Grunt, 1.8f, 1, 10, 40 };。照它补三行,让四种怪满足这些关系(具体数字你自己定):
| 种类 | 性格(要满足的关系) |
|---|---|
| 快速 Runner | 比普通快、很脆(1 血)、分数略高一点 |
| 重甲 Heavy | 比普通慢、肉(血量 > 1,建议 3)、分数更高 |
| 精英 Elite | 血量最高(建议 6)、够猛、分数最高(压轴) |
每种怪的贴图别填错、别填重:Runner 用 SpriteId::Runner,Heavy 用 SpriteId::Heavy,Elite 用 SpriteId::Elite。
🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
- 在
switch里,Grunt 那行下面补三个case,每个都要有自己的return。 - 照
{ 贴图, 速度, 满血, 分数, 大小 }的顺序填五个值。 - 速度:快速 > 1.8 > 重甲;血量:精英 > 重甲 > 1;分数:精英最大。
- 大小可略有差别(重甲/精英大一点更像),但不强制。
case 忘了写 return,或四种怪的 sprite 填重了(复制粘贴没改)。自动测试有「四种贴图各不相同」专抓这个。🧪 怎么验证
make test # 看 [2] Config 组:比快慢、比血量、比分数、贴图各不同
通关条件:
[2] 组全绿(全 PASS)→ 提交:git commit3刷怪:记住种类 + 满血开场 🐣
在 SpawnEnemies 里,让每只刚冒出来的怪「记住自己是哪种」,并按配置表设成满血。
🎯 这一步学会
- 给对象存「它是哪种」(把
kind记进去) - 用查表结果初始化:满血 =
cfg.maxHp
📖 为什么要记种类
取种类、查配置这两句老师已经写好(EnemyKind k = NextKind(); 和 EnemyConfig cfg = ConfigOf(k);)。但下面两行是「占位」——不管刷出什么,都写死成普通怪、1 血。要让后面 ④⑤⑥ 都能「按种类办事」,就得先在这里把种类和满血真正存进这只怪身上。
📋 你要做到
找到 ★TODO③ 的两行占位码,改成:
enemies[i].kind = k; // 把这次的种类记下来(原来写死 Grunt) enemies[i].hp = cfg.maxHp; // 满血开场(原来写死 1)
kind 不记 → 所有怪都被当普通怪,④ 速度、⑤ 贴图、⑥ 分数全乱。hp 不设满 → 重甲精英开场就 1 血,一枪就死。🧪 怎么验证
make test # [3] Spawn 组:会轮换出多种、每只 hp == 该种 maxHp
通关条件:
[3] 组全绿 → 提交:git commit4速度按种类查表 🏃
在 UpdateEnemies 里,把写死的速度换成「查配置表」——快速怪就该真的快。
🎯 这一步学会
- 用「它是哪种」查出「它该多快」
- 体会数据驱动:追人逻辑一个字没改,速度却各不相同
📋 你要做到
找到 ★TODO④ 那行 float sp = 1.5f;(所有怪都用同一个写死的速度)。改成按这只怪的种类查表:
float sp = ConfigOf(enemies[i].kind).speed; // 速度 = 查它那一行的 speed
下面追人的四个 if 一个字都不用动——这正是数据驱动的好处:逻辑不变,行为随表而变。
🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
enemies[i].kind就是这只怪的种类(③ 里存好的)。ConfigOf(那个种类).speed就是它该用的速度。- 把结果存进
sp,下面的移动照旧用sp。
🧪 怎么验证
make test # [4] Chase 组:快速一帧走得比重甲多、步长正好等于表里的速度 make run # 手动:橙色快速怪明显更快,蓝灰重甲慢吞吞
通关条件:
[4] 组全绿 + 试玩看得出快慢 → 提交:git commit5敌人换上各自的贴图 👾
在 DrawGame 里,把画敌人的红方块换成「它那种」的贴图——四种怪就长得不一样了。
🎯 这一步学会
- 把 ① 的
DrawSprite和 ② 的查表合起来用 - 贴图也能「按种类查表」得到
📋 你要做到
找到 ★TODO⑤ 那行(在敌人的 for 里)DrawRectangleRec(enemies[i].rect, RED);。改成按种类查出贴图再画:
DrawSprite(ConfigOf(enemies[i].kind).sprite, enemies[i].rect);
「查出这只怪的种类 → 查表拿到它的贴图 → 画在它的方框里」,一句话串起来。
🧪 怎么验证
make run # 四种怪长相各不相同就对了(普通红、快速橙、重甲蓝灰、精英戴皇冠)
这一步自动测试测不到,靠眼睛看。
源码检查后眼睛验:
make run 里四种怪长相各不同 → 提交:git commit6命中:扣血 + 按种类给分 💥
压轴。在 HandleHits 里,把「一枪一个、都给 10 分」改成「扣血才死、按种类给分」。重甲要连打几下才倒。
🎯 这一步学会
- 用血量(
hp)做「打几下才死」 - 死亡时按种类查表给分(把前面所有本领串起来)
📖 概念:先扣血,血空才死
现在的写法是「碰到就 active=false、score+=10」——一枪一个、不分种类。改成:每中一发先 hp-=1;只有 hp<=0 时这只怪才真的死,死的那一刻按它的种类查表加分。这样重甲(3 血)要打 3 下,精英(6 血)要打 6 下,普通(1 血)还是一下。
📋 你要做到
找到 ★TODO⑥ 的两行临时码(enemies[j].active=false; score+=10;),换成扣血逻辑:
enemies[j].hp -= 1; // 先扣 1 血 if (enemies[j].hp <= 0) { // 血空了才死 enemies[j].active = false; score += ConfigOf(enemies[j].kind).score; // 按「它是哪种」给分 }
子弹那句 bullets[i].active = false;(打中就消失)保持不动。
🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
- 先把临时的
enemies[j].active=false; score+=10;两行删掉。 - 写
enemies[j].hp -= 1;。 - 用
if (enemies[j].hp <= 0)包住「死亡 + 加分」两件事。 - 加分用
ConfigOf(enemies[j].kind).score,别写死 10。
hp 没设满),重甲开场就 1 血,连打几下的效果出不来。② 加分还写死 +10,没查表 → 分数不对。🧪 怎么验证
make test # [6] Hit 组:重甲连打 3 下才倒、按种类给分、幽灵/没打中不加分 make run # 手动:打重甲要好几下,杀精英分数跳得多
通关条件:
[6] 组全绿 + 试玩重甲要连打 → 提交:git commit★挑战项目:会绕墙追人的怪(BFS 最短路)🧠
前面 6 步是必做地板。这一关没有标准答案、也不自动评分——它把你在算法里学的 BFS 搬进游戏:让怪不再直线撞墙,而是绕开墙、走最短路抓你。make run 跑起来能看见怪绕过障碍,就算成功。
🧭 一句话思路(先在脑子里有画面)
把整张地图看成一道网格题:BFS 从玩家那格出发,给每一格标一个数字=离玩家几步;每只怪只干一件事——看四周哪格数字最小,就往那格挪一点点。算一次 BFS,全场的怪一起用。真正难的不是 BFS,是「格子 ↔ 像素」来回换。
🎯 你要做出什么
在场地上放几堵墙,让某一种怪(建议先拿 EnemyKind::Elite)用 BFS 单源最短路朝玩家逼近:一格一格绕过墙,而不是穿墙或卡在墙上。
📖 概念:网格 + 两条换算公式
你们的地板本来就是 TILE = 40 的网格,正好当 BFS 的格子:整块场地是 SCREEN_W/TILE × SCREEN_H/TILE = 32 × 18 格(数组一律 [行 y][列 x],别写反)。把某些格标成墙,其余能走。BFS 从玩家所在格出发一圈圈往外扩,天然算出每一格到玩家的最短步数——这就是「无权图最短路只能用 BFS、不能用 DFS」的原因。
整道题其实只要记住两条换算,别的都顺下来了:
- 像素 → 哪一格:
格 = (int)(像素 / TILE) - 哪一格 → 那格中心像素:
中心 = 格 * TILE + TILE/2(怪要"走向"的就是这个中心点)
📋 四步阶梯(每步跑一次,看见东西再往下走)
- 先让墙看得见:开一个
bool wall[18][32],手动摆一个 L 形,再在画面上把墙格画出来(可以用一张墙贴图)。跑一次:屏幕中间出现一堵 L 墙——还没 BFS,先把地图备好,第一个看得见的成果。 - BFS 灌“距离表”:从玩家格出发,用
queue+ 一张int dist[18][32],算每格离玩家几步(墙不入队;表先全设成一个“很大=到不了”的值,这步不做后面会乱)。
怎么确认写对了?把每格数字临时画在地板上用肉眼看:离玩家越远数字越大、墙那格空着、绕过墙数字接着涨——对了再往下。(这一招把看不见的表变成看得见的东西,是这道题最有用的调试法。) - 让一种怪查表走(先拿
Elite):它看四个邻格里dist最小的那格,朝那格中心平滑挪——用上面的公式一点点逼近,不是瞬间跳格(瞬移会一跳一跳很难看)。跑一次:精英绕墙、普通直线怪撞墙,一对比就懂了。 - 打磨两个坑:四邻都“到不了”(被墙围死)就原地不动,别乱冲;每帧只算一次 BFS、全场怪共用同一张表(别每只怪各算一遍,会卡)。
🧠 讲究在哪(GESP 想深一层)
- 为什么 BFS 不是 DFS:无权图里 BFS 第一次到达某格就是最短,DFS 不保证。
- 为什么从玩家单源出发而不是每只怪各跑一次:一次 BFS 是 O(格子数)=O(32×18),几十只怪就省几十倍——这叫「共享计算」;那张
dist表其实就是一张 flow field。 - 复杂度取舍:每帧全图 BFS vs 缓存复用,正是真实游戏里的性能权衡。
💡 火花(选做,越挑越硬)
- 哪些种类会寻路:给
EnemyConfig加一个bool smart,用配置表决定——又回到本课的「表驱动」。 - A*:把 BFS 换成
priority_queue按dist + 曼哈顿距离排序,更快找到路。 - 让墙随关卡变化,或让玩家能放墙「骗」怪绕远。
🧪 怎么算成了
make run,把墙摆成 L 形:寻路怪会绕过去、普通直线怪会卡在墙上。两种一对比,你就亲眼看见 BFS 的价值了。做完给爸妈演示一下!🛟 卡住了看这里
出现红色(未通过)很正常。程序员大多数时间都在「写一点 → 测试 → 发现问题 → 修改 → 再测试」。这就是 Debug,和写代码一样重要。
🔧 测试变红:按这个顺序查
- 哪一条失败?读那行英文说明和
(expected X, got Y)。 - 是哪一组?失败属于 ②③④⑥ 哪组,就只改那一步。
- 是不是漏做了前置?②→③→④/⑥ 是有依赖的(种类、满血、查表)。
- 改完
make test再看一遍。
📋 常见症状对照表
| 症状 | 多半是 |
|---|---|
make 报一堆红字 | 漏了分号或括号没配对,某个 case 忘了 return —— 看第一条错误的行号 |
| 出现品红色方块 | 贴图没加载:SpriteId 名字拼错,或 assets/ 里缺图 |
| 四种怪长得一模一样 | ⑤ 还没改成 DrawSprite,或 ② 配置表 sprite 填重了 |
| 四种怪速度一样 | ④ 还是写死 1.5f,没查表;或 ③ 没记 kind |
| 重甲一枪就死 | ③ 没设 cfg.maxHp(开场只有 1 血),或 ⑥ 没改扣血 |
| 分数不管什么怪都 +10 | ⑥ 加分没用 ConfigOf(kind).score,还写死 10 |
| HP / 分数那行字时有时无 | ⑤ 的红方块只删了、还没补 DrawSprite —— 画字的语句被 if (enemies[i].active) 吃掉了,没怪时就不画(见 0 · 三处要删的) |
git commit 说 nothing to commit | 没 git add,或文件没真改动 —— 先 git status |
make run 亲眼看一遍。★通关验收
六步都做完了?从干净状态重头跑一遍:
make clean && make && make test && make run
✅ 逐条对照
✅ 玩家、子弹是贴图 ✅ 四种怪长相各不相同 ✅ 快速怪明显更快 ✅ 重甲慢吞吞、要连打几下才倒 ✅ 精英最肉、给分最多 ✅ 杀不同的怪加不同的分
判定:make test 里 ②③④⑥ 全绿 + 眼睛看 ①⑤ 对。两个都满足,通关 🏆。
🧠 今天你真正练到的
enum class(给东西分类型)· struct(打包一组数据)· 配置表 / 查表(数据驱动:强弱写在表里)· switch · 贴图绘制 DrawSprite。还有一个能受用很久的思路:想改平衡,只改一张表,逻辑一行不动——后面几课的道具、掉落都会复用它。
git commit;想备份到自己的 GitHub,运行 git push -u origin my-lesson-2(推送到你的复刻,不影响老师的库)。也可以回课程地图看看后面的冒险。恭喜通关!你用一张配置表,让敌人有了四种脾气 👏