Files
chouka/第06课-完整抽卡游戏.md
T
2026-05-16 12:05:04 +08:00

509 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第 6 课:完整抽卡游戏
> **本课目标**:把前 5 课全部整合成一个真正的小游戏——**金币系统**、**累计统计**、**重置**、**退出**。最后你还会把重复代码**封装成函数**,体会"代码越改越短"的快乐。
>
> **预计时间**:50~70 分钟(最长的一课)
>
> **学完之后**:你拥有一个完整、可玩的小项目。
---
## 0. 上课前先理理
### 最终长这样
```
┌────────── 抽卡大冒险 ──────────┐
│ │
│ [单抽] [十连抽] │
│ (10金币) (100金币) │
│ │
│ 金币:500 │
│ │
│ ┌── 抽卡结果 ──────────────┐ │
│ │ │ │
│ │ ★ 稀有卡 ★ │ │
│ │ 冰霜之狼 │ │
│ │ │ │
│ └──────────────────────────┘ │
│ │
│ 累计:普通 12 稀有 5 超 2 │
│ │
│ [ 重置 ] [ 退出 ] │
│ │
└─────────────────────────────────┘
```
### 游戏规则
| 操作 | 消耗金币 |
|------|---------|
| 单抽 | 10 |
| 十连抽 | 100 |
| 重置 | 0(金币回到 500,统计清零) |
| 退出 | 0(关闭窗口) |
初始金币 500,金币不够时不能抽卡,提示玩家。
### 这一课的"软"目标
之前 5 课都是"加一个功能"。这一课除了加功能,还要练**"减代码"**
- 把两个回调里都有的 "判断 r → 选卡名 → 累计统计" 抽成一个函数 `get_card()`
- 把 "更新金币显示" 抽成 `update_coins_display()`
- 把 "更新统计显示" 抽成 `update_stats_display()`
体会"重复就是 bug 的温床——同一段代码写两份,迟早改其中一份忘了另一份"。
### 这一课认识 2 个新朋友
| 名字 | 干什么 |
|---------------|---------------------------------------------|
| **引用参数** `int &x` | 函数可以**修改**外面传进来的变量 |
| `return;` 提前结束 | 条件不满足时让函数立刻退出(金币不够就 return) |
---
## 1. 起步:先把窗口和 window 全局化
打开 `lesson_gui_6.cpp`。骨架是第 5 课结束的样子。
> 💡 这一课要让 "退出按钮" 能调用 `window->hide()`,但 `window` 之前一直是 `main` 里的局部变量。所以第 1 步:**把 `window` 也升级成全局**。
### ✏️ 改 3 处
**(1) 在全局控件那一段加**(替换对应 TODO):
```cpp
Fl_Window *window;
```
**(2) 把 `main` 里那行**
```cpp
Fl_Window *window = new Fl_Window(420, 520, "抽卡世界 - 第5课");
```
**改成**
```cpp
window = new Fl_Window(440, 560, "抽卡大冒险");
```
注意:①去掉了 `Fl_Window *`(全局已经声明)②窗口加大了③标题改了。
### ▶️ 编译运行
```bash
cmake -B build -DLESSON=6
cmake --build build && ./build/gacha6
```
### ✅ 检查点 1
- [ ] 窗口标题变 "抽卡大冒险"
- [ ] 窗口比之前更大(440×560
- [ ] 抽卡按钮还能用
---
## 2. 步骤 2:加金币系统 ⭐核心⭐
### 🤔 先想一下
- 金币是"全程一直存在"的数据,应该用什么变量?(答:全局 `int`
- 不够金币时怎么阻止抽卡继续执行?(答:`return;` 提前退出)
### ✏️ 改 5 处
**(1) 在全局区加状态变量**(替换对应 TODO):
```cpp
int coins = 500;
```
**(2) 在全局控件区加**(替换对应 TODO):
```cpp
Fl_Box *coins_box;
```
**(3) 在所有回调函数之前加辅助函数**(替换对应 TODO):
```cpp
void update_coins_display() {
char buf[50];
std::sprintf(buf, "金币:%d", coins);
coins_box->label(buf);
if (coins < 100) {
coins_box->labelcolor(FL_RED);
} else {
coins_box->labelcolor(fl_rgb_color(0, 100, 0)); // 深绿
}
}
```
**(4) 在 `main` 里、按钮和 `result_box` 之间加金币显示框**(替换对应 TODO):
```cpp
coins_box = new Fl_Box(30, 75, 380, 30, "");
coins_box->labelsize(20);
coins_box->labelfont(FL_BOLD);
update_coins_display();
```
同时把 `result_box` 的 Y 坐标改一下(从 80 改到 115),别和金币框重叠:
```cpp
result_box = new Fl_Box(20, 115, 400, 270, "点击按钮开始抽卡!");
```
(同时把 `result_box` 宽 380、高 350 也改成 400、270,给底部按钮留位置)
**(5) 在两个回调函数最前面加金币检查**:
`draw_one_callback` 开头:
```cpp
if (coins < 10) {
result_box->label("金币不够!单抽需要 10 金币。\n\"重置\"恢复金币。");
result_box->labelcolor(FL_RED);
result_box->labelsize(16);
return;
}
coins -= 10;
update_coins_display();
```
`draw_ten_callback` 开头:
```cpp
if (coins < 100) {
result_box->label("金币不够!十连抽需要 100 金币。\n\"重置\"恢复金币。");
result_box->labelcolor(FL_RED);
result_box->labelsize(16);
return;
}
coins -= 100;
update_coins_display();
```
### ▶️ 编译运行
### ✅ 检查点 2
- [ ] 窗口顶部出现绿色 "金币:500"
- [ ] 每点单抽 → 金币减 10
- [ ] 每点十连抽 → 金币减 100
- [ ] 金币低于 100 → 数字变红
- [ ] 金币不够时点按钮 → 提示信息出现,金币不再扣
---
## 3. 步骤 3:累计统计
底部的 `stats_box` 之前显示"单次"统计(这十连里的)。改成显示"累计"统计——全部回合加起来。
### ✏️ 改 4 处
**(1) 全局区加 3 个变量**
```cpp
int total_normal = 0;
int total_rare = 0;
int total_super = 0;
```
**(2) 加辅助函数**
```cpp
void update_stats_display() {
char buf[100];
std::sprintf(buf, "累计:普通 %d | 稀有 %d | 超稀有 %d",
total_normal, total_rare, total_super);
stats_box->label(buf);
}
```
**(3) 在两个回调里都累加 `total_*` 并在结尾调一次 `update_stats_display()`**
例如在 `draw_one_callback``if/else` 三个分支里分别加:
- 普通分支末尾:`total_normal++;`
- 稀有分支末尾:`total_rare++;`
- 超稀有分支末尾:`total_super++;`
然后函数末尾加:`update_stats_display();`
`draw_ten_callback` 里循环每个分支也加同样的 `total_*++;`,循环结束后**删掉原来那行覆盖 `stats_box``sprintf` 代码**,改成调 `update_stats_display();`
**(4) 在 `main` 里、`stats_box` 创建之后加**
```cpp
update_stats_display();
```
> 顺便把 stats_box 的位置从 (20, 445, 380, 40) 调整成 (20, 400, 400, 30),给底部按钮留位置。
### ▶️ 编译运行
### ✅ 检查点 3
- [ ] 底部出现 "累计:普通 0 | 稀有 0 | 超稀有 0"
- [ ] 每抽一次 → 对应稀有度计数 +1
- [ ] 十连一次 → 三个计数一共 +10
- [ ] 数字一直在涨,不会被覆盖
---
## 4. 步骤 4:用 `get_card()` 消灭重复 ⭐核心⭐
仔细看你现在的代码——单抽和十连抽里都在写"随机数 → 判断稀有度 → 取卡名 → `total_*++;`"。**这同一段代码出现了两次**。
### 🤔 先想一下
- 怎么把这段重复代码抽成一个函数?
- 但是这段代码同时要返回"卡名"和"稀有度等级"——一个函数怎么返回两个东西?
### 💡 新工具:**引用参数** `int &x`
```cpp
std::string get_card(int &rare_level) {
int r = std::rand() % 100;
if (r < 70) {
rare_level = 0; // 通过引用,外面那个变量也被改了
total_normal++;
return normal_cards[std::rand() % 5];
} else if (r < 95) {
rare_level = 1;
total_rare++;
return rare_cards[std::rand() % 5];
} else {
rare_level = 2;
total_super++;
return super_cards[std::rand() % 3];
}
}
```
调用方:
```cpp
int level = 0;
std::string card = get_card(level); // level 会被函数改成 0/1/2
```
### ✏️ 改 3 处
**(1) 在所有回调之前加 `get_card` 函数**(替换对应 TODO,代码见上)。
**(2) 把 `draw_one_callback``coins -= 10; update_coins_display();` 之后**的所有内容**整体替换成**:
```cpp
int level = 0;
std::string card = get_card(level);
std::string result;
if (level == 0) {
result = "☆ 普通卡 ☆\n " + card;
result_box->labelcolor(fl_rgb_color(128, 128, 128));
} else if (level == 1) {
result = "★ 稀有卡 ★\n " + card;
result_box->labelcolor(FL_BLUE);
} else {
result = "★★★ 超稀有卡!! ★★★\n " + card;
result_box->labelcolor(FL_RED);
}
result_box->labelsize(24);
result_box->label(result.c_str());
update_stats_display();
```
**(3) 把 `draw_ten_callback``coins -= 100; update_coins_display();` 之后**的所有内容**整体替换成**:
```cpp
std::string all = "";
int ten_normal = 0, ten_rare = 0, ten_super = 0;
for (int i = 1; i <= 10; i++) {
int level = 0;
std::string card = get_card(level);
all += "第" + std::to_string(i) + "抽:";
if (level == 0) {
all += "普通 " + card;
ten_normal++;
} else if (level == 1) {
all += "★稀有 " + card;
ten_rare++;
} else {
all += "★★★超稀有 " + card;
ten_super++;
}
all += "\n";
}
// 在 result_box 末尾加一行本次小结
char summary[80];
std::sprintf(summary, "\n--- 本次:普通%d 稀有%d 超稀有%d ---",
ten_normal, ten_rare, ten_super);
all += summary;
result_box->labelsize(14);
result_box->label(all.c_str());
result_box->labelcolor(FL_BLACK);
update_stats_display();
```
### ▶️ 编译运行
### ✅ 检查点 4
- [ ] 功能和上一步一样
- [ ] 但是 `get_card()` 函数让两个回调都简短多了
- [ ] 累计统计仍然正常累加(因为 `total_*++``get_card` 里)
---
## 5. 步骤 5:重置按钮
### ✏️ 改 2 处
**(1) 在所有回调之后加**(替换对应 TODO):
```cpp
void reset_callback(Fl_Widget *w, void *data) {
coins = 500;
total_normal = 0;
total_rare = 0;
total_super = 0;
update_coins_display();
update_stats_display();
result_box->labelsize(20);
result_box->label("游戏已重置!\n金币回到 500,统计清零。\n开始抽卡吧!");
result_box->labelcolor(FL_BLACK);
}
```
**(2) 在 `main``stats_box` 之后加**(替换对应 TODO):
```cpp
Fl_Button *btn_reset = new Fl_Button(100, 450, 100, 40, "重置");
btn_reset->callback(reset_callback);
```
### ▶️ 编译运行
### ✅ 检查点 5
- [ ] 底部多出 "重置" 按钮
- [ ] 把金币抽光 → 点重置 → 金币 500、统计全 0、结果区显示重置提示
---
## 6. 步骤 6:退出按钮
### ✏️ 改 2 处
**(1) 在所有回调之后加**(替换对应 TODO):
```cpp
void quit_callback(Fl_Widget *w, void *data) {
window->hide(); // 这里能用 window,是因为步骤 1 我们把它升级成全局了
}
```
**(2) 在 `main``btn_reset` 之后加**(替换对应 TODO):
```cpp
Fl_Button *btn_quit = new Fl_Button(240, 450, 100, 40, "退出");
btn_quit->callback(quit_callback);
```
### ▶️ 编译运行
### ✅ 检查点 6
- [ ] 底部多出 "退出" 按钮
- [ ] 点 "退出" → 窗口关闭,程序退出
- [ ] 点 "重置" 还能用
🎉🎉🎉 **第 6 课通关 = 整个 GUI 教程通关!**
---
## 7. 代码架构总结
回顾你现在的代码结构:
```
全局区域
├── 卡片数据(3 个数组)
├── 游戏状态(coins、total_*
├── 全局控件指针(window、result_box、stats_box、coins_box
辅助函数
├── update_coins_display() ← 显示金币
├── update_stats_display() ← 显示累计
├── get_card(int &level) ← 抽一张卡(被两个回调复用!)
回调函数
├── draw_one_callback() ← 单抽
├── draw_ten_callback() ← 十连
├── reset_callback()
└── quit_callback()
main()
├── 设种子
├── 创建窗口、所有控件
├── 绑定回调
└── Fl::run()
```
注意 `get_card()` —— 它就是旧课程"函数复用"知识的真正应用。两个回调都调用它,不重复任何逻辑。
---
## 8. 玩一玩
1. 改初始金币 `coins = 1000`,体会游戏节奏。
2. 单抽改成 20 金币,十连抽改 200 金币(涨价)。
3. 把卡片名字换成你自己的主题(三国、星座、美食……)。
4. **思考题**:为什么 `coins` 要写成全局变量而不是 `main` 里?
---
## 9. 课后挑战(选做 / 拓展)
挑你最感兴趣的:
- **A. 幸运计数器**:再加一个 `int unlucky_streak = 0;`,每出一次超稀有清零,否则 `+1`。超过 30 时在 `stats_box` 里提示 "保底在望!"。
- **B. 图鉴**:加一个按钮,点了显示三组数组里所有卡名。
- **C. 颜色主题**:给整个窗口换一套配色,按钮、背景、字色都改。
- **D. 抽卡历史**:用 `std::vector<std::string>`(如果你想接触)保存每次抽到的卡,加一个按钮显示历史。
---
## 10. 卡住了?常见小问题
| 现象 | 多半是 |
|------|-------|
| 编译报 `window` 未声明 | 步骤 1 没把 `window` 升级为全局,或 main 里又写了 `Fl_Window *` |
| 退出按钮没反应 | 没把 `window` 升全局,或 `quit_callback` 里写错变量名 |
| 金币显示不更新 | 改了 `coins` 后没调 `update_coins_display()` |
| 累计统计不更新 | 回调里漏调 `update_stats_display()`,或没在 `get_card``total_*++` |
| `get_card` 报错"不能修改 const" | 函数签名忘了写 `int &rare_level`(少了 `&` |
| 控件位置重叠 | y 坐标算错;先在纸上画一张布局图 |
---
## 11. 偷看老师答案
```bash
cmake -B build -DLESSON=6 -DUSE_SOLUTION=ON
cmake --build build && ./build/gacha6
```
---
## 🎉 整个教程总结
你完成了从命令行到 GUI 的跨越。回看 6 课:
| 课 | 学到了什么 |
|---|---|
| 1 | 怎么让屏幕上出现一个窗口 |
| 2 | 让按钮触发代码(回调) |
| 3 | 在回调里用随机和判断 |
| 4 | 用循环 + 字符串拼接处理多行结果 |
| 5 | 用数组管理内容 + 给界面分区 |
| 6 | 综合所有知识 + 用函数消灭重复 |
从第 1 课 `std::cout << "欢迎来到抽卡世界!"` 到一个完整可玩的游戏,你只用了 6 课!
> 📝 学完记得在 `学习进度与状态-GUI.md` 里把第 6 课打勾。**这一栏打完,整张表就全绿啦。**