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 工具速查。

🧰 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。

⏱ 预计 10 分钟🧩 前置:看完 1 工具速查📄 改 DrawGame👁 眼睛验

🎯 这一步学会

  • 用一句 DrawSprite(SpriteId::X, 方框) 画贴图
  • 看懂「门面」:细节被藏进 assets.h,你只管调用

📋 你要做到

在 DrawGame 里找到标了 ★TODO① 的两行:现在用 DrawRectangleRec 画蓝方块(玩家)和黑方块(子弹)。把它们换成 DrawSprite(SpriteId::Player, player.rect) 和 DrawSprite(SpriteId::Bullet, bullets[i].rect)。

🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
  1. 玩家那行:DrawRectangleRec(player.rect, BLUE); → DrawSprite(SpriteId::Player, player.rect);
  2. 子弹那行(在 for 里):把 DrawRectangleRec(bullets[i].rect, BLACK); 换成 DrawSprite(SpriteId::Bullet, bullets[i].rect);
  3. 方框(rect)不用改——贴图会自动铺满这个方框。
💡 看到品红色方块?那是贴图没加载成功时的「占位色」。检查 SpriteId 名字有没有拼错、assets/ 里图在不在。

🧪 怎么验证

make run       # 玩家变成贴图小人、子弹变成能量弹就对了

这一步自动测试测不到,靠眼睛看。

?未连接
源码检查后眼睛验:make run 里玩家和子弹都是贴图(不再是方块)→ 提交:git commit

2填全配置表 📊

这一步是全课的核心。在 ConfigOf 里,照「普通怪」的范例,补上快速 / 重甲 / 精英三行数据。

⏱ 预计 25 分钟🧩 前置:① 完成📄 改 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。

🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
  1. 在 switch 里,Grunt 那行下面补三个 case,每个都要有自己的 return。
  2. 照 { 贴图, 速度, 满血, 分数, 大小 } 的顺序填五个值。
  3. 速度:快速 > 1.8 > 重甲;血量:精英 > 重甲 > 1;分数:精英最大。
  4. 大小可略有差别(重甲/精英大一点更像),但不强制。
⚠️ 常见坑某个 case 忘了写 return,或四种怪的 sprite 填重了(复制粘贴没改)。自动测试有「四种贴图各不相同」专抓这个。

🧪 怎么验证

make test      # 看 [2] Config 组:比快慢、比血量、比分数、贴图各不同
?未连接
通关条件:[2] 组全绿(全 PASS)→ 提交:git commit

3刷怪:记住种类 + 满血开场 🐣

在 SpawnEnemies 里,让每只刚冒出来的怪「记住自己是哪种」,并按配置表设成满血。

⏱ 预计 12 分钟🧩 前置:② 完成📄 改 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 commit

4速度按种类查表 🏃

在 UpdateEnemies 里,把写死的速度换成「查配置表」——快速怪就该真的快。

⏱ 预计 8 分钟🧩 前置:③ 完成📄 改 UpdateEnemies🧪 可自动测

🎯 这一步学会

  • 用「它是哪种」查出「它该多快」
  • 体会数据驱动:追人逻辑一个字没改,速度却各不相同

📋 你要做到

找到 ★TODO④ 那行 float sp = 1.5f;(所有怪都用同一个写死的速度)。改成按这只怪的种类查表:

float sp = ConfigOf(enemies[i].kind).speed;   // 速度 = 查它那一行的 speed

下面追人的四个 if 一个字都不用动——这正是数据驱动的好处:逻辑不变,行为随表而变。

🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
  1. enemies[i].kind 就是这只怪的种类(③ 里存好的)。
  2. ConfigOf(那个种类).speed 就是它该用的速度。
  3. 把结果存进 sp,下面的移动照旧用 sp。

🧪 怎么验证

make test      # [4] Chase 组:快速一帧走得比重甲多、步长正好等于表里的速度
make run       # 手动:橙色快速怪明显更快,蓝灰重甲慢吞吞
?未连接
通关条件:[4] 组全绿 + 试玩看得出快慢 → 提交:git commit

5敌人换上各自的贴图 👾

在 DrawGame 里,把画敌人的红方块换成「它那种」的贴图——四种怪就长得不一样了。

⏱ 预计 10 分钟🧩 前置:②③ 完成📄 改 DrawGame👁 眼睛验

🎯 这一步学会

  • 把 ① 的 DrawSprite 和 ② 的查表合起来用
  • 贴图也能「按种类查表」得到

📋 你要做到

找到 ★TODO⑤ 那行(在敌人的 for 里)DrawRectangleRec(enemies[i].rect, RED);。改成按种类查出贴图再画:

DrawSprite(ConfigOf(enemies[i].kind).sprite, enemies[i].rect);

「查出这只怪的种类 → 查表拿到它的贴图 → 画在它的方框里」,一句话串起来。

⚠️ 四种怪长得一样?多半是这步还没改(还在画红方块),或 ② 配置表里 sprite 填重了。回 ② 检查四种贴图是否各不相同。

🧪 怎么验证

make run       # 四种怪长相各不相同就对了(普通红、快速橙、重甲蓝灰、精英戴皇冠)

这一步自动测试测不到,靠眼睛看。

?未连接
源码检查后眼睛验:make run 里四种怪长相各不同 → 提交:git commit

6命中:扣血 + 按种类给分 💥

压轴。在 HandleHits 里,把「一枪一个、都给 10 分」改成「扣血才死、按种类给分」。重甲要连打几下才倒。

⏱ 预计 18 分钟🧩 前置:②③ 完成📄 改 HandleHits🧪 可自动测

🎯 这一步学会

  • 用血量(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;(打中就消失)保持不动。

🪜 卡住了再看:分步提示先自己试,真卡住再点开▸
  1. 先把临时的 enemies[j].active=false; score+=10; 两行删掉。
  2. 写 enemies[j].hp -= 1;。
  3. 用 if (enemies[j].hp <= 0) 包住「死亡 + 加分」两件事。
  4. 加分用 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(怪要"走向"的就是这个中心点)

📋 四步阶梯(每步跑一次,看见东西再往下走)

  1. 先让墙看得见:开一个 bool wall[18][32],手动摆一个 L 形,再在画面上把墙格画出来(可以用一张墙贴图)。跑一次:屏幕中间出现一堵 L 墙——还没 BFS,先把地图备好,第一个看得见的成果。
  2. BFS 灌“距离表”:从玩家格出发,用 queue + 一张 int dist[18][32],算每格离玩家几步(墙不入队;表先全设成一个“很大=到不了”的值,这步不做后面会乱)。
    怎么确认写对了?把每格数字临时画在地板上用肉眼看:离玩家越远数字越大、墙那格空着、绕过墙数字接着涨——对了再往下。(这一招把看不见的表变成看得见的东西,是这道题最有用的调试法。)
  3. 让一种怪查表走(先拿 Elite):它看四个邻格里 dist 最小的那格,朝那格中心平滑挪——用上面的公式一点点逼近,不是瞬间跳格(瞬移会一跳一跳很难看)。跑一次:精英绕墙、普通直线怪撞墙,一对比就懂了。
  4. 打磨两个坑:四邻都“到不了”(被墙围死)就原地不动,别乱冲;每帧只算一次 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,和写代码一样重要。

🔧 测试变红:按这个顺序查

  1. 哪一条失败?读那行英文说明和 (expected X, got Y)。
  2. 是哪一组?失败属于 ②③④⑥ 哪组,就只改那一步。
  3. 是不是漏做了前置?②→③→④/⑥ 是有依赖的(种类、满血、查表)。
  4. 改完 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(推送到你的复刻,不影响老师的库)。也可以回课程地图看看后面的冒险。

恭喜通关!你用一张配置表,让敌人有了四种脾气 👏

↑ 课程目录