2022秋程序设计课 Project1

PJ1:小黄和它的罐子

2022秋程序设计课 Project1

p.s. 使用C99语言标准,以 GCC 11.2.0 编译成.exe可执行文件。

引用了Windows下的库,旨在生成Windows控制台(console)程序。

源代码为防止编码混乱,尽量使用英文。仅包含少量中文,须以UTF-8编码打开以正确阅读。

为使此文档简洁,此文档中引用的代码与src文件夹下的源代码不完全一致,以src文件夹下的源代码为准。


功能介绍

  1. 随机生成棋盘

    • 棋盘为$ 10\times 10 $的方格,初始时每个格子有\(50\%\)的概率出现罐子
  2. 打印棋盘

    • 将棋盘打印到标准输出,Windows下即打印到控制台窗口
  3. 处理用户输入

    (大小写不敏感,会被统一转换为小写字母;转换后仍不合法的命令会被提示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键也能实现同样的功能。

      回车键退出此模式。

  1. 实时更新仪表盘

每给出一个指令,立刻在右框显示指令名,并在左框显示该指令生效后的分数

编写代码

整体思路

自顶向下,逐步求精。

​ 下面给出的代码中部分是伪代码,略去细节并改变了缩进逻辑便于审阅。真实代码以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以及请同学和舍友试玩 完美实现!

遇到的困难和解决过程

当时找到的方式:

  1. 单字符读入,在非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;
}
  1. 移动光标

由于我们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语句里也很方便。

posted @ 2022-10-23 15:43  全球通u1  阅读(89)  评论(0编辑  收藏  举报