介绍
在这个项目中,你将学习如何使用 shell 脚本创建一个简单的打字游戏。游戏会在屏幕上显示随机字符,你的目标是在它们消失之前输入这些字符。游戏提供了不同难度级别的模式。你可以选择练习输入数字、字母、两者混合,甚至是你自己的自定义单词。
👀 预览

🎯 任务
在这个项目中,你将学习:
- 如何创建一个项目文件并在代码编辑器中打开它
- 如何使用特殊字符和颜色显示欢迎界面
- 如何实现一个模式选择菜单来选择难度级别
- 如何实现一个打字类别选择菜单来选择要练习的字符类型
- 如何创建用于绘制边框和填充打字界面背景颜色的函数
- 如何为打字游戏生成随机字母和数字
- 如何实现打字功能,包括处理用户输入和计算准确率
- 如何创建一个优雅的退出函数来处理特殊信号
🏆 成果
完成这个项目后,你将能够:
- 展示 shell 脚本的基础知识
- 在终端输出中使用特殊字符和颜色
- 在 shell 脚本中读取用户输入
- 在 shell 脚本中实现菜单和用户界面
- 在 shell 脚本中处理特殊信号
创建项目文件
首先,请创建一个名为 shell_typing_game.sh 的新文件,并在你喜欢的代码编辑器中打开它。
cd ~/project
touch shell_typing_game.sh
显示欢迎界面
#!/bin/bash
function dis_welcome() {
declare -r str='
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
1000000010000000101111101000000111100111000000100000001000001111
0100000101000001001000001000001000001000100001010000010100001000
0010001000100010001111101000001000001000100010001000100010001111
0001010000010100001000001000001000001000100100000101000001001000
0000100000001000001111101111100111100111001000000010000000101111
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000001111000000100000000001000000010000001111100000000000
0000000000010000000001010000000010100000101000001000000000000000
0000000000010011000011111000000100010001000100001111100000000000
0000000000010001000100000100001000001010000010001000000000000000
0000000000001111001000000010010000000100000001001111100000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000'
declare -i j=0
declare -i row=3 ## 打印起始行。
line_char_count=65 ## 定义换行位置,每行64个字符加换行符,共65个字符。
echo -ne "\033[37;40m\033[5;3H" ## 设置颜色和光标起始位置。
for ((i = 0; i < ${#str}; i++)); do
## 换行。
if [ "$((i % line_char_count))" == "0" ] && [ "$i"!= "0" ]; then
row=$row+1
echo -ne "\033["$row";3H"
fi
## 确定前景和背景字符。
if [ "${str:$i:1}" == "0" ]; then
echo -ne "\033[37;40m "
elif [ "${str:$i:1}" == "1" ]; then
echo -ne "\033[31;40m$"
fi
done
}
dis_welcome
开头的长字符串010101...是要显示的欢迎游戏界面。直接看可能不太明白是什么。所以你可以把这个字符串复制到 gedit 编辑器,然后按快捷键Ctrl+F查找。输入1时,你马上就会明白。我们重点看echo命令。我们需要使用echo命令是因为要在屏幕上显示(这里指的是 Xfce 终端。Linux 下默认标准输入、输出和标准错误输出都连接到终端)。为了实现一些特殊功能,比如在指定位置打印、设置显示字符颜色,以及打印整个游戏的背景,我们需要使用相应命令的一些特殊参数,如下代码所示:
## 设置颜色和光标起始位置。
echo -ne "\033[37;40m\033[5;3H"
根据注释,我们大致能明白它的功能。但具体是什么意思呢?首先,-n参数表示在当前行打印,输出后不换行。然后设置输出字符颜色和光标位置。其中,\033[前景色;背景色 m,后面是\033[行;列 H。不过要让echo识别这些特殊转义序列,我们需要-e参数,否则echo会原样输出这些字符。
前景色:30 - 39,背景色:40 - 49。
| 值 | 前景颜色 | 值 | 背景颜色范围:40 - 49 |
|---|---|---|---|
| 30 | 设置黑色前景 | 40 | 设置黑色背景 |
| 31 | 设置红色前景 | 41 | 设置红色背景 |
| 32 | 设置绿色前景 | 42 | 设置绿色背景 |
| 33 | 设置棕色前景 | 43 | 设置棕色背景 |
| 34 | 设置蓝色前景 | 44 | 设置蓝色背景 |
| 35 | 设置紫色前景 | 45 | 设置紫色背景 |
| 36 | 设置青色前景 | 46 | 设置青色背景 |
| 37 | 设置白色前景 | 47 | 设置白色背景 |
| 38 | 在默认前景色上设置下划线 | 49 | 设置默认黑色背景 |
| 39 | 关闭默认前景色下划线 |
没想到echo还能这么用吧!你是不是感觉对它有了新的认识?好了,现在我们在终端测试这段代码。
cd ~/project
bash shell_typing_game.sh

显示模式选择菜单
## 声明变量'time'来表示打字超时时间,不同难度级别对应不同的超时值
declare -i time
function modechoose() {
## 从三种不同模式中选择
echo -e "\033[8;30H1) 简单模式"
echo -e "\033[9;30H2) 普通模式"
echo -e "\033[10;30H3) 困难模式"
echo -ne "\033[22;2H请输入你的选择: "
read mode
case $mode in
"1")
time=10
dismenu ## 调用菜单选择函数
;;
"2")
time=5
dismenu
;;
"3")
time=3
dismenu
;;
*)
echo -e "\033[22;2H你的选择不正确,请重试"
sleep 1
;;
esac
}
在定义这个函数之前,我们首先使用declare -i命令声明了一个整数变量'time'。然后,在下面的case语句中,我们根据用户选择的难度为'time'变量设置了不同的值。难度越高,值越小。这个变量实际上在后续的打字处理函数中使用。你可能还记得,在 shell 脚本中,在函数内部或外部声明或定义的变量被视为全局变量,作用域贯穿整个脚本文件,除非在函数内部使用'local'关键字声明变量。至于菜单的显示效果,我们仍然使用echo命令,然后使用read命令将用户的输入读取到变量'mode'中。

显示打字类别选择菜单
function display_menu() {
while [ 1 ]; do
draw_border
echo -e "\033[8;30H1) 练习输入数字"
echo -e "\033[9;30H2) 练习输入字母"
echo -e "\033[10;30H3) 练习输入字母和数字"
echo -e "\033[11;30H4) 练习输入单词"
echo -e "\033[12;30H5) 退出"
echo -ne "\033[22;2H请输入你的选择: "
read choice
case $choice in
"1")
draw_border
## 接下来两个是函数参数,第一个参数表示打字类型,第二个是移动字符的函数
main digit
echo -e "\033[39;49m"
;;
"2")
draw_border
main char
echo -e "\033[39;49m"
;;
"3")
draw_border
main mix
echo -e "\033[39;49m"
;;
"4")
draw_border
echo -ne "\033[22;2H"
read -p "你想使用哪个文件进行打字练习: " file
if [! -f "$file" ]; then
display_menu
else
exec 4< $file ## 创建一个文件管道
main word
echo -e "\033[39;49m"
fi
;;
"5" | "q" | "Q")
draw_border
echo -e "\033[10;25H你现在将退出这个游戏"
echo -e "\033[39;49m"
sleep 1
clear
exit 1
;;
*)
draw_border
echo -e "\033[22;2H你的选择错误,请重试"
sleep 1
;;
esac
done
}
在这个函数中,我们先来解释一下case分支中调用的两个函数。首先,draw_border函数用于绘制打字界面的边框,稍后会展示。然后,调用了main函数。这个main函数不像 Java 和 C 等语言中的main函数有什么特殊作用,它只是简单地叫main,用于表明它在整个程序中起主要作用。你可能注意到每个main后面都有一个参数,没错,这就是传递给函数的参数。在 shell 中,参数不是像许多其他语言那样紧跟在函数名后面写在括号里。还需要注意的是,在case语句的第四个分支,即这几行:
read -p "你想使用哪个文件进行打字练习: " file
if [! -f "$file" ]; then
display_menu
else
exec 4< $file ## 创建一个文件管道
main word
echo -e "\033[39;49m"
fi
根据菜单提示,我们知道这个分支是用于练习输入单词的。这个程序允许用户使用自定义的单词文件(有一定格式要求的文本文件,每行一个单词,你可以在网上下载一个单词列表文件并用awk命令提取)进行练习。所以首先我们需要读入一个用户输入的文件名来选择单词文件。然后我们使用exec命令创建一个指向该文件的管道,即将文件的输出重定向到文件描述符 4(fd)。由于exec是 bash 中的一个内置命令,你用man命令是看不到exec的文档的。使用exec进行 I/O 重定向通常与 fd 有关,shell 通常有 10 个 fd,范围从 0 到 9。常用的 fd 是 3 个,分别是 0(标准输入 stdin)、1(标准输出 stdout)和 2(标准错误输出 stderr)。现在先了解它的意思就行。

为打字界面绘制边框
function draw_border() {
declare -i width
declare -i high
width=79 ## 终端默认宽度 - 1
high=23 ## 终端默认高度 - 1
clear
## 设置显示颜色为黑底白字
echo -e "\033[37;40m"
## 设置背景颜色
for ((i = 1; i <= $width; i = i + 1)); do
for ((j = 1; j <= $high; j = j + 1)); do
## 设置显示位置
echo -e "\033["$j";"$i"H "
done
done
## 绘制背景边框
echo -e "\033[1;1H+\033["$high";1H+\033[1;"$width"H+\033["$high";"$width"H+"
for ((i = 2; i <= $width - 1; i = i + 1)); do
echo -e "\033[1;"$i"H-"
echo -e "\033["$high";"$i"H-"
done
for ((i = 2; i <= $high - 1; i = i + 1)); do
echo -e "\033["$i";1H|"
echo -e "\033["$i";"$width"H|\n"
done
}
- 在
draw_border()函数中,声明了两个整数变量width和high,分别表示终端窗口的宽度和高度。 - 命令
echo -e "\033[37;40m"用于设置显示颜色。使用嵌套的for循环遍历终端的每一行和每一列来填充背景。 - 在内层循环中,使用 ANSI 转义码将光标定位到终端内所需的坐标位置。例如,
echo -e "\033["$j";"$i"H"将光标位置设置为第 j 行和第 i 列。 - 在填充完终端背景后,使用特定字符绘制装饰性边框。使用的字符包括
+、-和|,这些字符常用于绘制边框。这些字符的位置由它们的行和列值决定,并使用 ANSI 转义码进行打印。例如,echo -e "\033[1;1H+"将+字符放置在终端的左上角。
总之,draw_border()函数清除终端,将背景颜色设置为黑色,用空格填充终端以创建背景。最后,通过在特定位置放置字符(如角落的+、线条的-和|)绘制出美观的边框。

填充打字界面的背景颜色
## 清除整个字符落点区域
function clear_all_area() {
local i j
## 填充打字区域
for ((i = 5; i <= 21; i++)); do
for ((j = 3; j <= 77; j = j + 1)); do
## 设置显示位置
echo -e "\033[44m\033["$i";"$j"H "
done
done
echo -e "\033[37;40m"
}
## 功能:清除特定列的字符
## 输入:要清除的列号
## 返回:无
function clear_line() {
local i
## 填充打字区域
for ((i = 5; i <= 21; i++)); do
for ((j = $1; j <= $1 + 9; j = j + 1)); do
echo -e "\033[44m\033["$i";"$j"H "
done
done
echo -e "\033[37;40m"
}
clear_all_area()函数:
- 它用于清除整个字符落点区域。其功能是用背景颜色(黑色)填充字符落点区域中的字符,以清除终端窗口中的字符。
- 它使用嵌套循环遍历
i和j的所有组合,其中i表示行,j表示列。 - 在内层循环中,它使用 ANSI 转义码将光标位置设置到指定的行和列,然后使用
echo -e "\033[44m\033["$i";"$j"H "在该位置打印一个空格字符,但背景颜色设置为黑色(代码 44)。 - 循环结束后,函数使用
echo -e "\033[37;40m"将文本颜色和背景颜色恢复为默认值(白色文本,黑色背景)。
clear_line()函数:
- 此函数用于清除字符落点区域中的特定列。通常用于在用户输入正确字符后清除该列中的字符路径。
- 该函数接受一个参数
$1,表示要清除的列号。 - 它使用一个循环遍历字符落点区域中的行和列,并用背景颜色(黑色)填充该列的字符路径。
- 与
clear_all_area()类似,该函数使用 ANSI 转义码将光标位置设置到指定的行和列,然后使用echo -e "\033[44m\033["$i";"$j"H "在该位置打印一个空格字符,但背景颜色设置为黑色。 - 最后,函数使用
echo -e "\033[37;40m"将文本颜色和背景颜色恢复为默认值。
生成随机字母和数字
## 功能:沿着下落路径移动字符。
## 输入:参数1:字符的当前行(与字符离开的时间长度相关)。
## 参数2:字符的当前列。
## 参数3:(未使用的参数)。
## 返回:无
function move() {
local locate_row lastloca
locate_row=$(($1 + 5))
## 显示要输入的字符。
echo -e "\033[30;44m\033["$locate_row";"$2"H$3\033[37;40m"
if [ "$1" -gt "0" ]; then
lastloca=$(($locate_row - 1))
## 清除上一个位置。
echo -e "\033[30;44m\033["$lastloca";"$2"H \033[37;40m"
fi
}
function putarray() {
local chars
case $1 in
digit)
chars='0123456789'
for ((i = 0; i < 10; i++)); do
array[$i]=${chars:$i:1}
done
;;
char)
chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
for ((i = 0; i < 52; i++)); do
array[$i]=${chars:$i:1}
done
;;
mix)
chars='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
for ((i = 0; i < 62; i++)); do
array[$i]=${chars:$i:1}
done
;;
*) ;;
esac
}
## 功能:生成相应类型的随机字符以转换为随机字符。
## 输入:要生成的字符类型。
## 全局变量:random_char, array[]
## 返回:无
function get_random_char() {
local typenum
declare -i typenum=0
case $1 in
digit)
typenum=$(($RANDOM % 10))
;;
char)
typenum=$(($RANDOM % 52))
;;
mix)
typenum=$(($RANDOM % 62))
;;
*) ;;
esac
random_char=${array[$typenum]}
}
由于 shell 的字符串变量不直接支持索引,我们需要将所有字母和数字放入一个带索引的数组中。这就是putarray函数的目的。下面的get_random_char函数用于生成随机字母和数字。它使用系统的随机数环境变量RANDOM来获取一个随机索引,然后从数组中读取相应的字符。
打字功能的实现
在完成所有准备工作之后,我们终于可以开始实现打字功能了。让我们来看一下下面的代码:
function main() {
declare -i gamestarttime=0
declare -i gamedonetime=0
declare -i starttime
declare -i deadtime
declare -i curtime
declare -i donetime
declare -i numright=0
declare -i numtotal=0
declare -i accuracy=0
## 将相应的字符存储到数组中,$1 表示用户选择的字符类型
putarray $1
## 初始化游戏开始时间
gamestarttime=$(date +%s)
while [ 1 ]; do
echo -e "\033[2;2H 请在屏幕上的字母消失之前输入它!"
echo -e "\033[3;2H 游戏时间: "
curtime=$(date +%s)
gamedonetime=$curtime-$gamestarttime
echo -e "\033[31;40m\033[3;15H$gamedonetime s\033[37;40m"
echo -e "\033[3;60H 总数: \033[31;26m$numtotal\033[37;40m"
echo -e "\033[3;30H 准确率: \033[31;40m$accuracy %\033[37;40m"
echo -ne "\033[22;2H 你的输入: "
clear_all_area
## 循环10次,检查一列字符是否超时或被击中
for ((line = 20; line <= 60; line = line + 10)); do
## 检查该列字符是否被击中
if [ "${ifchar[$line]}" == "" ] || [ "${donetime[$line]}" -gt "$time" ]; then
## 清除该列的显示
clear_line $line
## 生成一个随机字符
if [ "$1" == "word" ]; then
read -u 4 word
if [ "$word" == "" ]; then ## 文件读取结束
exec 4< $file
fi
putchar[$line]=$word
else
get_random_char $1
putchar[$line]=$random_char
fi
numtotal=$numtotal+1
## 设置标志为1
ifchar[$line]=1
## 重置定时器
starttime[$line]=$(date +%s)
curtime[$line]=${starttime[$line]}
donetime[$line]=$time
## 将列位置重置为0
column[$line]=0
if [ "$1" == "word" ]; then
move 0 $line ${putchar[$line]}
fi
else
## 如果未超时或未被击中,则更新定时器和当前位置
curtime[$line]=$(date +%s)
donetime[$line]=${curtime[$line]}-${starttime[$line]}
move ${donetime[$line]} $line ${putchar[$line]}
fi
done
if [ "$1"!= "word" ]; then
echo -ne "\033[22;14H" ## 清除输入行字符
## 检查用户输入的字符,并作为一个一秒的定时器
if read -n 1 -t 0.5 tmp; then
## 输入成功,循环检查输入是否与某一列匹配
for ((line = 20; line <= 60; line = line + 10)); do
if [ "$tmp" == "${putchar[$line]}" ]; then
## 清除该列的显示
clear_line $line
## 如果匹配,清除标志
ifchar[$line]=""
echo -e "\007\033[32;40m\033[4;62H 正确!\033[37;40m"
numright=$numright+1
## 退出循环
break
else
## 否则,一直显示错误直到超时
echo -e "\033[31;40m\033[4;62H错误,请重试!\033[37;40m"
fi
done
fi
else
echo -ne "\033[22;14H" ## 清除输入行字符
## 检查用户输入的字符,并作为一个定时器
if read tmp; then
## 输入成功,循环检查输入是否与某一列匹配
for ((line = 20; line <= 60; line = line + 10)); do
if [ "$tmp" == "${putchar[$line]}" ]; then
## 清除该列的显示
clear_line $line
## 如果匹配,清除标志
ifchar[$line]=""
echo -e "\007\033[32;40m\033[4;62H 正确!\033[37;40m"
numright=$numright+1
## 退出循环
break
else
## 否则,一直显示错误直到超时
echo -e "\033[31;40m\033[4;62H错误,请重试!\033[37;40m"
fi
done
fi
fi
trap " doexit " 2 ## 捕获特殊信号
## 计算准确率
accuracy=$numright*100/$numtotal
done
}
现在让我们分别解释一下输入单个字符和单词的方法,因为它们略有不同。
对于输入单个字符,我们希望同时出现五个字符,它们将以设定的时间间隔持续下落,直到到达一定高度(由开始时选择的超时期限决定)时消失。如果在消失之前未被击中(用户未输入相应的正确字符),则该列将出现一个新字符。因此,首先我们循环初始化一个字符序列并将其存储在数组中。数组的索引表示终端的列号,这样做的优点是可以独立管理每列字符,无论它是否超时或被击中,以及应该放置在哪里。缺点是我们将创建一个相对较大的数组,但不会大到无法处理。对于 shell 来说这没关系,因为它不会根据数组索引的大小来分配内存。第一个 for 循环负责此操作,并且它还在每个主循环结束时检查某一列是否为空,然后在该列中出现一个新字符。
接下来是一个很长的 if...else 语句。你还记得调用 main 函数时传递的参数吗?最外层用于区分是输入单个字符还是单词。让我们先看输入单个字符的情况,其中包含关键的一行:
if read -n 1 -t 0.5 tmp
read 命令的 -n 参数指定要读取的字符长度。这里指定长度为 1,这意味着用户输入一个字符后输入立即结束,无需按下回车键。-t 参数指定输入超时时间。如果用户在超时期间未输入或输入未完成,读取将立即结束。因为 read 命令在读取用户输入时对于同一个 shell 是同步操作,字符下落的实现依赖于此超时(间接实现下落延迟)。这里设置为 0.5s,因为大多数用户可以在 0.5s 内完成一个字符的输入。读取用户输入后,它使用一个 for 循环比较与每列对应的每个字符数组,如果有匹配项,则调用 clear_line 函数清除当前列中的字符,并将标志变量 ifchar[$line] 设置为 0,表示已清除。
输入单词的流程与输入字符基本相同,只是因为我们无法估计用户输入一个长度不确定的单词所需的时间,所以我们不能为 read 命令设置输入超时时间。没有这个超时,输入单词的最终实现可能不会像输入字符时那样自动下落,而是在我们输入一个单词并按下回车键后,单词才会下落一行。当然,你可以考虑使用其他方法来实现与输入字符相同或更好的效果。
此外,为每列获取新单词的处理略有不同,因为我们是从文件中读取,而不是生成随机单词。你还记得我们之前创建的文件描述符 4 吗?它指向一个单词文件。我们在这里使用了这个文件描述符:
read -u 4 word
if [ "$word" == "" ]; then ## 文件读取结束
exec 4< $file
fi
putchar[$line]=$word
这里我们仍然使用 read 命令,并使用 -u 参数指定从哪个文件描述符逐行读取文件到变量中。后续的空检查语句是为了在文件到达末尾时重新创建指向该文件的文件描述符,以便 read 可以再次从文件开头读取。
你是否觉得有点复杂?别担心,还没完呢。接下来,注意 main 函数的倒数第二行:
trap " doexit " 2 ## 捕获特殊信号
trap 命令用于在 shell 中捕获特殊信号,例如 Ctrl+C、Ctrl+D、Ctrl+Z、ESC 等。原本这些特殊信号由 shell 本身处理。因为我们希望游戏在退出时更优雅地退出,所以我们可以拦截第二个特殊信号,即 Ctrl+C,来实现一些定制处理。例如,在这里,捕获到第二个信号后,它会调用下面的 doexit 函数来优雅地退出程序:
function doexit() {
draw_border
echo -e "\033[10;30H此游戏将退出....."
echo -e "\033[0m"
sleep 2
clear
exit 1
}
主要代码流程
由于每个功能模块的代码都已就绪,为了使程序运行,我们仍然需要一个主代码流程来调用这些函数。
draw_border
dis_welcome
echo -ne "\033[3;30H开始游戏。Y/N : "
read yourchoice
if [ "$yourchoice" == "Y" ] || [ "$yourchoice" == "y" ]; then
draw_border
modechoose
else
clear
exit 1
fi
exit 1
这段代码为一个字符下落游戏创建了一个入口点。它首先显示一个欢迎屏幕和一个开始游戏的提示,然后等待用户输入是否开始游戏。如果用户选择开始游戏,将提示他们选择游戏的难度模式。如果用户选择退出或输入无效内容,游戏将不会启动。
运行与测试
接下来,我们可以运行我们的 shell 打字游戏:
cd ~/project
bash shell_typing_game.sh

总结
你刚刚创建了一个在 shell 中构建简单打字游戏的项目。你可以按照这些步骤来创建自己的打字游戏项目,并选择不同的游戏模式和难度级别。这个项目提供了 shell 脚本编程和基于终端的游戏开发的实践经验。如果你对课程中使用的一些命令不熟悉,比如 echo 的各种输出、exec 的 I/O 重定向以及捕获特殊信号的 trap,你可以多练习以熟悉它们的用法。



