玩一个小游戏
最速公式游戏
main.cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include "mclog.h"
// 常量类型-数据不可重新赋值
// 定义统一的类型,统一菜单选项名称,防止输入错误
const int _input_quit_ = -1;
const int _input_back_ = -2;
namespace global_name
{
int input_quit = -1;
};
// 替换字符串,用于格式化字符串
// 在 org 字符串内,使用 str_new 替换 str_old 字符
void replace_str_ref(std::string &org, const std::string &str_old, const std::string &str_new)
{
/*
描述运行逻辑:
1.查找 str_old 的位置,如果找到则使用 go 标记位置,复制 go 与 follow 之间的内容
2.复制之后 follow 会尾随 go 的位置并跳过 str_old 字符
3.循环使用 go 寻找下一个 str_old 字符
4.结尾处复制 follow 位置到字符串末端,复制收尾字符
*/
std::string ret;
size_t go = 0;
size_t follow = 0;
while (true)
{
go = org.find(str_old, go);
if (go == std::string::npos)
{
break;
}
ret += std::string(org.begin() + follow, org.begin() + go);
ret += str_new;
go += str_old.size();
follow = go;
}
ret += std::string(org.begin() + follow, org.end());
org = ret;
}
// 打印输出错误提示
void print_input_tips()
{
// 使用 str temp等变量名称时,通常时一个临时变量命名,无含义名称
// \n 为打印时换行,C++允许多行字符串使用 "" 进行文本编写,实际效果为一行字符串
// 例子
// "hello"
// "world"
// 实际打印 "hello world"
std::string str = "你需要输入数字,或者以下选项\n"
"quit: 退出游戏\n"
"back: 返回主菜单\n";
std::cout << str << std::endl;
}
// 打印游戏菜单显示
void print_game_menu()
{
std::string str = "欢迎游玩 \"最速公式\" 口算游戏,你可以选择以下游戏类型\n"
"1.加法公式\n"
"2.乘法公式\n"
"3.指数公式\n";
std::cout << str << std::endl;
}
// 获取用户输入
// 返回值只返回大于零的数和 _input_quit_ _input_back_ 两个负数常量
int get_input()
{
// 循环获取输入,直到符合返回结果
std::string input;
while (true)
{
// 从终端获取输入
std::cin >> input;
// 判断 input 的输入是否为菜单选项
if (input == "quit")
{
return _input_quit_;
}
else if (input == "back")
{
return _input_back_;
}
// cin 出现错误时退出
if (std::cin.bad())
{
break;
}
// 异常块代码,处理 stoi 的异常抛出
try
{
// 将string转为int类型,并返回
return std::stoi(input);
}
catch (...)
{
// stoi 发出异常,说明输入不为数字
// 打印数入提示
print_input_tips();
}
}
// 返回退出类型
return _input_quit_;
}
// 加法游戏,会显示游戏玩法,然后接收玩家输入
// 游戏会通过 vec_rule 预设值进行循环,玩家失败会提前结束循环
// 退出是会附带返回值,表明需要返回的地方
int game_add()
{
// 游戏目标
int total = 100;
// 游戏预设值
std::vector<int> vec_rule = {14, 7, 21, 17, 3};
// 游戏提示
std::string str_rule =
"加法计算游戏,游戏规则如下\n"
"让x接近{0},我会给出a,你需要输入b,超出或者少于x的部分会让total减少\n"
"游戏公式 x = a + b,胜利条件 total > {0}\n"
"你一共有 {1} 次计算,请开始游戏\n";
// 替换描述符号将 "{0}" 替换成 total 的值,结果为 "{0}" = 100
replace_str_ref(str_rule, "{0}", std::to_string(total));
replace_str_ref(str_rule, "{1}", std::to_string(vec_rule.size()));
// 打印提示
std::cout << str_rule << std::endl;
// 有限循环函数,for 会循环 vec_rule 的数量,目前是5次,5次之后会自动退出循环
int total_keep = total;
for (size_t i = 0; i < vec_rule.size(); i++)
{
// 每次输入时提示语
int val_a = vec_rule[i];
std::string str_tips =
"当前 total={0},求公式 {1} = {2} + b\n"
"求公式 {1} = {2} + b\n"
"第{3}次计算,请输入b=\n";
replace_str_ref(str_tips, "{0}", std::to_string(total));
replace_str_ref(str_tips, "{1}", std::to_string(total_keep));
replace_str_ref(str_tips, "{2}", std::to_string(val_a));
replace_str_ref(str_tips, "{3}", std::to_string(i + 1));
std::cout << str_tips << std::endl;
// 获取玩家输入值,如果 val_b 不是负数,则说明游戏继续
int val_b = get_input();
// 如果输入小于0,说明用户输入了 quit back 其中的一个值会提起退出游戏
if (val_b < 0)
{
return val_b;
}
// 正常输入了数字,计算差值公式
int diff = total_keep - (val_a + val_b);
// 通过 diff 差值计算 total 剩余的量,等与0时会直接失败
total -= std::abs(diff);
// 失败时提前退出游戏
if (total <= 0)
{
break;
}
}
// 判断胜利
// if 接收一个 bool 值,结果为 true 进入if分支,否则进入else分支
if (total > 0)
{
std::cout << "游戏胜利 total = " << total << std::endl;
}
else
{
std::cout << "游戏失败 total = " << total << std::endl;
}
// 返回到主菜单
return _input_back_;
}
// 乘法游戏
int game_mul()
{
std::cout << "游戏正在更新中,敬请期待" << std::endl;
return _input_back_;
}
// 指数游戏
int game_exp()
{
std::cout << "游戏正在更新中,敬请期待" << std::endl;
return _input_back_;
}
// 入口函数
int main(int argc, char **argv)
{
// 显示游戏菜单
print_game_menu();
// 是否继续运行标记
bool run = true;
// 无限循环函数,使用 run 判断是否继续循环
// while 需要 bool 类型,为 true 时继续循环,false 时会退出循环
while (run)
{
// 请求玩家输入,是进入游戏还是退出
int input = get_input();
// 判断游戏选择的类型
// switch 函数会根据 input 的值,在分支号 123 中选择对应的分支,如果不存在则进入 default 分支
// 每个分支的作用域内,需要调用 break 退出,否则 switch 会一致按顺序执行所有分支
// 即使分支号和input无关
switch (input)
{
case 1:
{
// 选择了加法游戏,游戏结束时会返回游戏下一步的计划
input = game_add();
break;
}
case 2:
{
input = game_mul();
break;
}
case 3:
{
input = game_exp();
break;
}
default:
// 不是 123 中的数字,不存在选择类型,无事发生
break;
}
// 重新判断 input 输入是否会退出游戏
// switch 函数会根据 input 的值,在分支号 _input_back_ _input_quit_ 中选择对应的分支
// switch 只能接收整数类型和枚举类型
switch (input)
{
case _input_back_:
{
// 返回主菜单,打印游戏菜单
print_game_menu();
break;
}
case _input_quit_:
{
// 退出游戏,结束循环函数,函数会运行到尾部之后退出
run = false;
break;
}
default:
// 不存在输入,提示输入错误
print_input_tips();
break;
}
}
std::cout << "欢迎下次游玩" << std::endl;
return 0;
}
打印结果
欢迎游玩 "最速公式" 口算游戏,你可以选择以下游戏类型
1.加法公式
2.乘法公式
3.指数公式
1
加法计算游戏,游戏规则如下
让x接近100,我会给出a,你需要输入b,超出或者少于x的部分会让total减少
游戏公式 x = a + b,胜利条件 total > 100
你一共有 5 次计算,请开始游戏
当前 total=100,求公式 100 = 14 + b
求公式 100 = 14 + b
第1次计算,请输入b=
80
当前 total=94,求公式 100 = 7 + b
求公式 100 = 7 + b
第2次计算,请输入b=
90
当前 total=91,求公式 100 = 21 + b
求公式 100 = 21 + b
第3次计算,请输入b=
70
当前 total=82,求公式 100 = 17 + b
求公式 100 = 17 + b
第4次计算,请输入b=
88
当前 total=77,求公式 100 = 3 + b
求公式 100 = 3 + b
第5次计算,请输入b=
99
游戏胜利 total = 75
通过上一篇文章,你应该对数据类型和运算符有了一定了解,但离你能自己编写一个像样的程序还有一点小麻烦,今天让我们通过一个小游戏来了解基础编程的最后一块拼图,控制指令
控制指令也叫语句(Statement),在编程的名词中总是让人听起来不明所以,所以我更习惯叫控制指令
它的作用是通过一些有条件的指令控制代码是否执行,通常我们需要编写多种可能发生的事情,每一种事情都需要对应的代码块开处理,而控制指令就是这些代码块的指挥员,它需要指挥控制程序运行那一段代码
本篇文章中有一段很长的 main.cpp 代码,它描述了如何控制一个游戏的运行逻辑,我们需要逐步的解析这份代码,让你明白控制指令的作用
当然,在此之前你最好先提前浏览一下代码,要记住,代码总是需要从 main 函数开始看的,而不是从第一行还是看,这是新手容易犯的错误
请注意我这一份代码全部都编写在 main.cpp 文件内,可以看到 main 函数是最后一个函数,因为C++编译是有顺序的,需要调用的函数必须在调用行代码的前面,所以将 main 函数写在最后可以调用所有的函数
循环代码
// 无限循环
while (run)
{
// 作用域范围
}
// 有限循环
for (size_t i = 0; i < 5; i++)
{
// 作用域范围
}
从 main 函数开始,你可以最先看到的就是 while 循环指令,循环就是不断重复的意思,while 循环就是让在 while 作用域内的代码,不断的循环执行
C++在循环指令中,会经常使用 while for 这两种循环方式,他们的作用是相似的,而且可相互替代,他们是 无限循环 和 有限循环的代表,当循环次数未知时请优先选择 white 指令,当循环次数可预测时优先使用 for 指令
选择合适的指令是 编程规范 的重要一步
当然,这里的无限循环不是指永远不会停下,而是指循环次数是未知的,希望不要误会
从上面代码中可以看出,当循环是5次是,我们选择 for,如果循环次数通过不确定次数的 run 变量控制时,我们优先选择 white 指令
通常循环会被使用 break 或者 return 跳出,从而提前结束作用域,使用 break 为跳出循环,会继续执行函数内代码,使用 return 为跳出函数,直接结束函数
或许你会见过 for(;😉 的来写法来实现无限循环,但请不要使用 for(;😉 这类写法代替 white,这是一种很不好的习惯
无处不在的作用域
// 函数
int fun()
{
// 外层作用域
// 循环
white
{
// 判断
if
{
// 最内层作用域
}
else
{
// 和最内层作用域的同层作用域
}
}
}
你或许已经发现了我总是提到作用域这个词,因为我发现作用域十分重要却总被新手忽视,作用域存在与 函数|循环|判断 等地方,只要出现 { } 花括号的地方,且会存在执行代码的地方就是作用域,作用域会限制代码的执行,一旦执行到最小作用域,作用域内的代码会被全部执行
作用域是可以嵌套的,从外向内一层层的包含,代码只能保证最内部作用域的代码会被全部执行,因为外部作用域可能会被内部作用域的代码提前跳出
作用域还是一个变量范围的问题,内层作用域是可以直接使用外层作用域的变量的,因为内层作用域本身也是外层作用域的内部,但外层不可以使用内层的变量,同层也不行,因为变量是被限制在作用域之内的,所有内层可以使用外层变量,这一点请注意
作用域符号 { } 除了表示作用域外,还被用于表示初始化数据,上面代码中类似 std::vector vec{1}; 的代码就表示初始化数据,而不是作用域,他们很好区分,只需要看有没有执行其他代码行就好了,这是新手需要注意的点
如何进行条件判断
// 条件分支
switch (sum)
{
case 1:
case 2:
}
// 条件判断
if (sum == 1)
{
}
else if(sum == 2)
{
}
当你的代码存在两种可能,成功或者失败时,你需要在不同条件下,执行不同的代码,这时你需要使用 if 或 switch 进行判断,他们会根据不同的条件,只执行多个分支中的某一个分支,让代码执行不同的分支以实现不同的功能
但是请记住,请在所有可能的分支上,都优先使用 if 进行条件判断,if 是完全可以代替 switch 的,但是 switch 却不能代替 if,这一点和 white for 的情况不同
但是你会发现,在上面的最速公式游戏 main.cpp 代码上,我使用了两处的 switch 指令,这是为什么呢,当分支类型使用整数,且类型一致,且可能存在很多分支,且分支内代码简单少量,且代码高度一致时,我才会考虑使用 switch 指令来代替 if,很显然这个代替的条件很苛刻
请记住你不需要刻意判断什么时候需要使用 switch ,你只需要一直使用 if 就可以了,当有一天你发现某一种结构类型的代码使用 switch 比使用 if 更加简单且优雅时,那时候才是使用 switch 的最佳时机
条件判断分析
if (input == "quit")
{
return _input_quit_;
}
else if (input == "back")
{
return _input_back_;
}
在 main.cpp 文件中,使用 if 的场景并不多,但其中有一行代码 if (input == "quit") 它是用于判断字符串是否相等的,if 会让字符串逐个字符进行对比,如果完全相等则返回 true ,进入分支执行 return input_quit; ,否则继续下一条指令 else if (input == "back") 来判断 input 玩家输入是否等于 "back" 字符串,要注意的是,如果都不等于,那两个分支的代码都不会执行,而是直接跳过
来自数字的异常
try
{
return std::stoi(input);
}
catch (...)
{
}
如果你看了 get_input 函数,你会发现将玩家的 input 变量,类型为 string 转成 int 时,被 try catch 代码给包裹了,这是为什么呢
try catch 是处理异常(exception)的意思,这段代码表示,在 try 作用域内抛出的异常会被 catch 给处理,具体如何处理用开发者决定
异常是C++的重要组成部分,异常通常在出现不可解决的错误是抛出,如果异常不处理程序会直接崩溃退出,所以异常是致命的
在 stoi 函数中,如果字符串无法正确的转为数字,它就会抛出异常,如果我们不处理程序就会结束,这意味着玩家在输入数字时输入了字母,程序就直接崩溃退出了,所以我们需要使用异常来包裹代码,获取并处理异常,随后自己决定是否退出程序或者继续执行
异常也可以自己在函数中抛出,用于提示使用者的行为是不合理的,当然你需要标记函数会抛出异常,否则没有处理的任何异常会让程序直接崩溃
抛出异常会打断现有的执行逻辑,会让整个执行链条断开,很容易导致各种内存泄漏或者程序崩溃的问题,在C++中安全的处理异常是复杂的,异常的部分请自行学习
异常在解决深层函数调用的错误返回时有很好的效果,如果函数调用来到了十层,那在任何一层抛出异常,最上层的使用者都可以接收到错误原因
但是C++开发者对异常有两种态度,一种认为不应该在C++中使用异常,另一种认为应该像其他现代语音一样去更多的使用返回错误,我通常习惯于几乎不使用异常,这只是个人习惯问题
接收命令行输入
std::string input;
std::cin >> input;
在编写这个小游戏中,最关键的一步是如果获取玩家的输入内容,否则玩家将毫无参与感
std::cin 类对象就是完成这个任务的,它是 iostream STL头文件的一部分,它可以接收程序在命令行的字符串输入
std::cin >> input; 这个写法很有意思,它表示从命令行获取的字符串将推入到 input 变量中, 然后 input 就会有命令行的内容,>> 用于表示推入的方向,箭头指向哪里,哪里就会获取数据
std::cout << "hello"; 参考这个打印操作,箭头的方向是让我们将 "hello" 字符串推入到命令行终端中,这样一对比或许很好理解一些
请优先使用动态数组
// 动态数组
std::vector<int> vec {1,2,3};
// 固定数组
std::array<int,3> arr {1,2,3};
// 常规数组
int c_arr[3] {1,2,3};
在加法公式游戏中,我需要让玩家计算 x = a + b 的值,我需要给玩家提供 a 的值,在由玩家输入 b ,最后获取 x 的值尽量接近 100 这个数值
在这里我需要提前存储一组由 5 个 a 组成的值,我需要存储数组,在C++中,如果你需要使用数组,那我推荐你使用 std::vector 动态数组,而不是固定数组或者常规数组,虽然它们都可以存储 a 的数据,且在效果上没有任何区别
vector 最大的优势是可以动态的存储任何数量的数据,而 array 和 c_arr 这种类型只能在声明时决定要存储的数量
虽然 vector 在连续大量存储数据时有性能问题,但是我依旧推荐新手优先使用 vector 而不是其他类型的数组
优先使用动态数组是 编程规范 的重要一步
在你决定使用什么类型之前,你需要了解他们的结构和运行原理,你需要自行了解有关数组的知识,然后在决定是否需要替换掉 vector 改用其他类型
当然如何正确使用 vector 也需要你自行学习
请安全的循环
// 正确的写法-保持动态循环条件-自动获取 vec.size
std::vector<int> vec {1,2,3};
for (size_t i = 0; i < vec.size(); i++)
{
}
// 不好的写法-不要直接使用数字3代替vec的数量
std::vector<int> vec {1,2,3};
for (size_t i = 0; i < 3; i++)
{
}
使用 vector 进行循环时,因为它是可预测数量的,使用优先使用 for 循环,但是我经常会看到新手在编写循环时,会直接使用固定的数字去循环,这是非常不好的习惯,即使 vec 的数量确实等于3,你也不能直接使用3进行循环,你需要保持动态循环条件
动态循环条件是指循环总是根据要遍历的目标自动的获取循环次数
保持动态循环条件是 编程规范 的重要一步
一旦你写死了循环条件3,当 vec 的数量在某一次改动中变成2,然而你却忘记更改 for 内的循环次数,使用3次循环遍历长度为2的 vec 程序会直接崩溃,所以请保持动态循环条件
请注意,这个规范在使用任何循环的地方都有效,不仅仅在数组循环中
改变运算顺序
// 等效代码
// int sum = val_a + val_b
// diff = total_keep - sum
int diff = total_keep - (val_a + val_b);
数学中存在括号优先的原则,在编程中也一样,运算符是有优先级的
在符号组合运算中,上面提供了等效代码,它刚好和数据公式一致,不过不要高兴的太早,C++中有很多的操作符,你也会经常看到他们的组合使用,所以你最好了解他们的优先级
你需要去学习操作符的优先级,如果你搞错了优先级,运行结果可能和你推测的结果大相径庭
当然,如果你不知道优先级的时候,可以使用括号将他们包裹起来,这样他们的运行结果会你的想法保持一致
不要写复杂运算代码
// 原始代码
int val_b = get_input();
if (val_b < 0)
{
return val_b;
}
int diff = total_keep - (val_a + val_b);
total -= std::abs(diff);
if (total <= 0)
{
break;
}
// 简化代码
int val_b = get_input();
if (val_b < 0)
{
return val_b ? _input_quit_ : _input_back_;
}
else if ((total -= std::abs(total_keep - (val_a + val_b))) < 0)
{
break;
}
上面的原始代码来源于 game_add 函数,不知道你是否可以看懂原始代码,如果可以看懂原始代码请尝试推理简化代码
实际上它们的功能是完全一致的,它们是等效代码,但是简化代码的阅读复杂度却比原始代码要高很多,让其他开发者去阅读的话也会更加困难,所以请不要编写过于复杂的代码,请保持简单的代码让代码更容易阅读
保持代码可读性是 编程规范 的重要一步
如果你已经在其他教程了解到了指针部分,你会他们编写的代码会更加复杂和难以推理,如果你正在尝试分析这些代码,我推荐你交给AI简化,或者把提供复杂代码的人叫来解释
请不要把时间花在那些没有意义的复杂代码上,因为你需要学习的内容还有很多,那种看不懂的代码是纯粹的垃圾代码,请不要为垃圾代码花费太多时间
原始代码部分在 main.cpp 文件中有注释,如果你连原始代码带注释版本都看不懂的话,别喷我写的代码糟糕,先反思一下自己的基础是否已经足够扎实了在考虑是否来喷我
你应该保持的习惯
尝试提取独立代码
你注意到小游戏中需要玩家输入的地方有几处没有,实际有两处地方,一处在游戏菜单中,一处在加法公式中,但整个 main.cpp 文件中只使用到了一次 std::cin 对象,没错,我将玩家输入的字符串处理提取出来放到了 get_input 函数中,在需要玩家输入的地方只需要调用 get_input 即可获取输入结果
这是一种可重复使用的功能提取,你要时刻关注代码发生重复时是否需要将代码提取到函数,这是在 第二个函数 文章中提到的内容
请统一来源
const int _input_quit_ = -1;
const int _input_back_ = -2;
你是否注意到我们引用一种新类型 const int 常量,使用 const 声明的类型叫常量,常量是标识不可变化,只能在定义时赋值,它是不可改变的,和变量可重复赋值的类型相反
声明为常量表示我希望这个常量名称标识的值我希望它的内容不会被改变
在小游戏中,input_quit input_back 常量用于描述用户输入 quit back 等字符时用 -1 -2 表示,他们是负数,且有具体意义的,和正整数有区别
input_quit 用于 get_input 函数中返回 -1 这个值,为什么我要定义了一个常量名称作为返回值,而不是使用 -1 返回,因为它们是等价的
定义常量名称是有利于调用者,而不是有利于函数本身,使用 get_input 函数的人可以通过返回值是否等于 input_quit 来判断玩家是否退出,对于调用这个 get_input 函数的人的代码编写来说是非常舒适的,它不需要知道 -1 表示是什么,他只需要返回值等于 input_quit 时玩家需要退出,着对于调用函数的人来说他免去了很多时间去确认 -1 这个值的具体效果
让调用者可见是 编程规范 的重要一步
当然返回枚举或者结构体是更好的选择,这部分会在未来提到
有一点主要注意,我有时候会将常量写为变量,这是开发者很难避免的习惯,所以请不要太纠结叫法,造成这种问题可能是因为常量也被称为不可变变量的原因
你应该尝试分类
你注意到 replace_str_ref 这个函数了吗,它用于查找传入字符串中是否存在某个子串,然后用新的子串替换,我在代码中用这个函数来格式化字符串
但是我并不打算讲这个函数的具体功能,它只是随手编写的字符串替换代码,我想告诉你的是,这个函数是可重复使用的,却有着很高频的使用次数,而且在其他代码中也可能会使用,但是现在 replace_str_ref 函数却放在了 main.cpp 文件中,这导致其他文件根本无法引用到这个函数
请你尝试把它移动到头文件中吧,但是要注意前面提到的函数分离,你需要创建声明和定义单独写到.h .cpp 文件中
请注意这个函数的命名规则,存在 ref 标记,这是一个通用的标记约定,表示他会直接改变传入的字符串,而不是返回一个新的字符串回来
全局命名规范
// C++常用命名方式-安全
// 加 g_ 前缀
int g_input_quit = 0;
// 大写加下划线
int INPUT_QUIT = 0;
// 放在命名空间内
namespace global_name
{
int input_quit = 0;
};
// 个人习惯命名方式-不安全
int _input_quit_ = 0;
input_quit 变量名是在函数之外的,它裸露在函数之外,不在 main 函数内,而是编写在于 main.cpp 文件的整个作用域内,这表明它是全局变量
全局变量意味着整个 main.cpp 内的函数都可以隔空使用这个变量,因为全局变量所在的作用域比函数作用域要更外一层
全局变量很容易造成命名污染,全局变量的命名很关键,命名重复会导致编译错误,所以使用全局变量要小心,上述提供了几种标准且安全的全局变量命名方式,我推荐使用将全局变量放入放在命名空间内
我使用的全局变量命名规则是在左右加下划线,不容易重名且容易区分是全局变量,且方便使用,但是这个方式可能会跟操作系统定义的宏重名造成编译错误,请不要模仿,这是我的个人不良习惯
项目路径
https://github.com/HellowAmy/mcpp.git