2022秋程序设计课 Project1
PJ1:小黄和它的罐子
2022秋程序设计课 Project1
p.s. 使用C99语言标准,以 GCC 11.2.0 编译成.exe可执行文件。
引用了Windows下的库,旨在生成Windows控制台(console)程序。
源代码为防止编码混乱,尽量使用英文。仅包含少量中文,须以UTF-8编码打开以正确阅读。
为使此文档简洁,此文档中引用的代码与src文件夹下的源代码不完全一致,以src文件夹下的源代码为准。
功能介绍
-
随机生成棋盘
- 棋盘为$ 10\times 10 $的方格,初始时每个格子有\(50\%\)的概率出现罐子
-
打印棋盘
- 将棋盘打印到标准输出,Windows下即打印到控制台窗口
-
处理用户输入
(大小写不敏感,会被统一转换为小写字母;转换后仍不合法的命令会被提示invalid)
- 预置命令:
- exit或者Ctrl+Z——退出游戏,结算分数
- 单个字符0到6:
1. 随机移动,即随机出现以下(No.2 ~ No.7)行为中的任何一种
2. 向上移动,如果小黄上方的格子为墙,则小黄位置不变,视为一次撞墙
3. 向下移动,如果小黄下方的格子为墙,则小黄位置不变,视为一次撞墙
4. 向左移动,如果小黄左边的格子为墙,则小黄位置不变,视为一次撞墙
5. 向右移动,如果小黄右边的格子为墙,则小黄位置不变,视为一次撞墙
6. 不移动
7. 捡罐子 ,如果小黄此时的位置上有罐子,则罐子被捡起,即此格子变为空
- 额外命令:
-
help——提供帮助文档(如下)
-
wasd——切换到快速模式
该模式下无需输入回车,直接以WASD键控制上下左右移动,E控制捡罐子,Q代表随机移动。
I J K L O U键也能实现同样的功能。
回车键退出此模式。
-
- 实时更新仪表盘
每给出一个指令,立刻在右框显示指令名,并在左框显示该指令生效后的分数
编写代码
整体思路
自顶向下,逐步求精。
下面给出的代码中部分是伪代码,略去细节并改变了缩进逻辑便于审阅。真实代码以src下的源文件为准。
main.c
主函数,即上面的“顶”,负责大体上实现该程序的逻辑。
int main(int argc, char **argv){
initialize(); // 伪函数,在init.c
char cmd[512]; // 存储用户输入的指令
while (scanf("%s", cmd) != EOF && !strcmp(strlwr(cmd), "exit")){
if (!strcmp(cmd, "help")){
help(); // 显示帮助文档
continue;
}
if (!isdigit(*cmd) || *cmd > '6'){
invalid(); // 提示非法输入
continue;
}
move(*cmd ^ '0'); // 确定了*cmd是个数字,将其从ASCII变为数码
}
puts("Final Score:");
puts("The game has ended! Now you can check your final score.");
system("pause");
return 0;
}
其中用到help(), move()等具体实现的函数可以放在 main.c 之外。
main.h
声明要用到的全部自定义类型、全局变量、在所有源文件中用到的重要函数(为了性能直接编写为宏)
#ifndef PJ1_MAIN_H
#define PJ1_MAIN_H // 防止重复编译头文件导致报错
#include <stdlib.h>
#include <string.h>
#include <windows.h> // 在main()中出现的所有库函数所在头文件
typedef struct{unsigned int x:4;unsigned int y:4;} Position;
// 小黄的位置类型,由于很小,可以整个记录在1字节(8bit)内
const int width=3; // 横向间隔是width-1,C99推荐用const int或者enum代替#define
int score/*=0*/,cnt; // 全局变量声明时即被赋值0
char map[11][11]; // 存储地图
Position pos; // 小黄位置类型的实例
/*inline void MoveCursorTo(SHORT nRows, SHORT nCols){
COORD crdLocation = {nRows, nCols};
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), crdLocation);
}*/
/**
* 此函数命名风格与其它函数明显不同,参照Windows API的命名规范
* 实现“向指定位置移动光标”的功能
* 为使各源文件调用此函数不产生混乱,同时减少call-func的开销,已将此函数改写为macro
*/
#define MoveCursorTo(a,b)
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE),\
(COORD){(short)(a),(short)(b)})
int main(int, char *[]);
#endif // PJ1_MAIN_H
MoveCursorTo()将在多个源文件中反复用到,用于定位光标,以准确更改指定位置的字符。
init.c
实现地图的初始化,以及向控制台输出地图和更改控制台上已经绘制的地图
#include "init.h" //内含头文件引用和函数声明
void init(){ // 打印地图以及小黄的位置
// 上面的墙
for (int j = 0; j <= width * 11; ++j)putchar(j % width ? ' ' : '#');
putchar('\n');
// 主体
for (int i = 1, j; i <= 10; ++i){ // 行号
putchar('#'); // 左墙
for (j = 1; j < width * 11; ++j) // 列号
if (j % width == 0) putchar(map[j / width][i]);
else putchar(' '); //补充空格
puts("#"); // 右墙
}
for (int j = 0; j <= width * 11; ++j)putchar(j % width ? ' ' : '#');
putchar(10);// 下面的墙
for (int j = 0; j <= width * 11; ++j)putchar('_');puts("\n");// 分割线
MoveCursorTo(pos.x * width, pos.y);putchar('!');//打印小黄所在位置
}
inline void create_map(){// 短小的函数用 "inline" 定义为内联,比使用#define要好。
srand(time(0));
//无需全部初始化为空格,所有用到的格子都会获得有意义的值//memset(map,' ',sizeof map);
for (int i = 1, j; i <= 10; ++i)
for (j = 1; j <= 10; ++j)
map[i][j] = (rand() & 1 ? (++cnt, '@') : ' ');//顺便统计罐子的个数
// 注意这种写法是错的,(1,1)也可能有罐子: //map[1][1] = '!';
}
还有如下函数,其功能简单,不赘述。
inline void initialize_prompts(){...} // 打印仪表盘
void help(){...} // 显示帮助文档
move.c
根据用户的输入,移动小黄或改变棋盘。
#include "move.h" // 里面引用了<stdbool.h>,以便使用bool true false关键字。
bool move(int cmd){ // 返回真,代表全部罐子都被捡起;否则返回假。
if (cmd == 0) cmd = rand() % 6 + 1;
switch (cmd){ // 各case为枚举类型,定义在"move.h"中。
case up,...,right:
puts("up,...,right"); // 回显,下同。
if (撞墙) score -= 5;
else 移动小黄;
break;
case pick:
puts(" pick ");
if (map[pos.x][pos.y] == ' ') score -= 2;
else{
map[pos.x][pos.y] = ' '; // 捡罐子
score += 10;
if (!--cnt) return true; // 表示罐子捡完了
}
break;
default /*do nothing*/:
puts(" skip ");
break;
}
更新分数;
return false;
}
后面编写了一个fast_move(),复用此move函数,避免重复编写。详见源文件。
结果
经过一系列debug以及请同学和舍友试玩 完美实现!
遇到的困难和解决过程
当时找到的方式:
- 单字符读入,在非windows平台有如下实现:
#include <termios.h>
char getch(void){
struct termios oldt, newt;
char a;
tcgetattr(STDIN_FILENO, &oldt);
newt = oldt;
newt.c_lflag &= ~(ICANON | ECHO);
tcsetattr(STDIN_FILENO, TCSANOW, &newt);
a = getchar();
tcsetattr(STDIN_FILENO, TCSANOW, &oldt);
return a;
}
- 移动光标
由于我们PJ1中的棋盘比较小,可以记录当前光标的坐标,然后用若干'\b'来移动到目标光标位置;或者把每行用一个字符数组记下来,改变目标位置的字符后,'用\r'移动到行首再重新写这一行并覆盖掉原来的字符。
以上做法可能导致效率低下,但对于 \(10\times 10\) 的棋盘貌似也够用。
得到了如下回复:
于是去查阅Windows帮助文档,学会了使用如下MoveCursorTo()函数,将光标移动到(nRows, nCols)。
void MoveCursorTo(SHORT nRows, SHORT nCols){
COORD crdLocation = (COORD){nRows, nCols};
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), crdLocation);
}
另外
做了一些debug。bug主要源于控制台的\(x,y\)坐标应该分别对应循环变量\(j,i\),但不慎搞反了。
参考资料
重要参考:
使用控制台 - Windows Console | Microsoft Learn
尤其是这个函数:
SetConsoleCursorPosition 函数 - Windows Console | Microsoft Learn
改进建议
-
从PJ1开始就可以加入选做的GUI,黑底白字的控制台不是很友好。
即使改为其他颜色(如把代表小黄的"!"改成黄色)也没有太大提升。
-
可以给不使用GUI的同学提供一些帮助文档,帮助大家更好地使用这个黑底白字的控制台,
毕竟它是大部分可执行文件都会用到的UI。
-
"0123456"这样的命令不够直观,可以借鉴电子游戏常用的"WASD"移动和"Q, E, Space"交互,
这些键同样是单字符,用在switch语句里也很方便。