[2017BUAA软工]结对项目:数独扩展

结对项目:数独扩展


1. Github项目地址

https://github.com/Slontia/Sudoku2

2. PSP估计表格

3. 关于Information Hiding, Interface Design, Loose Coupling的设计

  首先,在王辰昱同学的提醒下,我们将一开始的代码按照功能分为若干个.cpp文件,每一个.cpp只处理一件事,如create_puzzle.cpp文件负责生成数独,而solve.cpp文件负责解决数独,在一定程度上保证了代码的低耦合性。
  我认为在实际编码中,最能体现这个思想的是dig函数和Rank类。首先来看一下dig函数在代码中的调用情况:

int lower, int upper) {
if (lower > upper || lower < 0 || upper > SIZE * SIZE) {
	return 0;
}
int all_freebox_num = (rand() % (upper - lower + 1)) + lower;
int cleaned_num = dig(sudoku, puzzle, all_freebox_num);

int freebox_num = all_freebox_num - cleaned_num;
if (freebox_num == 0) {
	return all_freebox_num;
}

  这是create_puzzle函数的开头部分,整个程序中只有这个地方用到了这个函数,其功能在于尽可能地清除一个数独中的数字,并保证其单解性。这个函数在清除数字的时候会依靠推理,因此挖起来非常快。这是保证代码速度的一个很重要的部分。但是如果不看dig.cpp文件,我们并不清楚其具体的实现过程,或是用了什么高端的算法,只知道“这是一个挖数非常快的函数”,我认为这是最能体现Information Hiding的部分。
  
  除此之外,GUI项目中还有一个排行榜功能,这个功能位于Rank类中。我们在实现这个类之前,先考虑了一下可能的需要的功能,如清除记录、插入到排行榜、写入文件、读取文件、加密等等。有了这些想法,我们便提供了相应的接口,之后再实现,我认为遵循了Interface Design。下面是rank.h中定义的部分函数:

bool record(int mode, double time, char * name);
// 保存记录(难度,用时,姓名)
bool fetch_rank(int mode, int r, char * name, double & time);
// 获取 mode 难度排名为 r 的姓名和时间, 1-indexed
bool clear();
// 清空排行榜
bool fetch_last_record(char * name, double & time);
// 获得上次录入的姓名和时间
bool encrypt_flush(bool encrypt);
// 加密并保存
void load_db(bool encrypt);
// 从文件中加载
void init_db();
// 初始化数据库
void init_board();
// 初始化 board
bool fetch_free(int mode, int & index);
// 拿走一个空的 entry
bool insert_after(int mode, int tg_id, int new_id);
// 在空间中id为tg_entry的位置后面插入new_entry
bool show(int mode);

4. 计算模块接口的设计与实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。(7')

主要有三个模块:

1. create 模块

  这次作业虽然指定不允许出现等价数独,但我们还是可以通过模板变换快速生成1000000个数独。首先,我们需要保证数独的等价性,我们的做法是不对第一个宫进行任何操作(随机性貌似在-c不做要求?)。我们沿用了刘畅同学在个人项目中使用的3-3-3位置轮换方法成功生成了一个数独终盘。在个人项目中,模板的变化主要体现在R4R6、R7R9的行变换,但其实,对于3-3-3位置轮换方法生成的终盘,部分行的变换也是可行的。
  我们先来想一个问题,在一个3*3的宫中填入1~3三个数字各三次,保证行、列中没有重复的数字,共有几种填法呢?通过穷举,我们可以发现有12种解,如下图:

  

  现在,我们将这个问题向数独上靠拢,在生成的数独上,我们标出图中所示的红、绿、蓝数字:
  

  由上面的结论我们可以得出,通过变换红、绿、蓝共九个数字,可以得到12种不同排列。由于第一个宫我们不能动,只能动R4~R9,因此共可以找到6组类似的9个数字,而这6组的排列又相互独立,因此共可以产生12^6=2985984种排列。

2. solve 模块

  solve模块的实现和个人项目相同,只是做了一下封装。

据之前的思路,我设计了三个类:数独类(Subject_sudoku)、组类(Group)、方格类(Box)。
数独类包含三个组数组,名字分别为rows[9]、columns[9]和blocks[9],分别代表思路中描述的三种组。每个组包括指向9个Box的指针和记录以确定数字的二进制数hasvalue。
在初始化数组之后,首先找到未确定值得Box中可能取值最少的那个,依次对它的值进行猜测。在每次猜测之前,通过拷贝构造将Subject_sudoku备份下来,在新的数独中将该方格的值确定,再继续寻找可能取值最少的Box,对它的值进行猜测,直到所有的Box的值都被确定,或尝试完某个Box的所有可能性(无解)。
基本流程描述如下:

  1. 初始化数独;
  2. 找到可能取值最少的Box;
  3. 依次假定它所有的可能取值;
  4. 重复2、3,直到所有Box都被确定,或尝试完某Box所有可能性。
    对象之间的关系构成网状结构,便于Box和Group之间的信息传递

3. puzzle 模块

  

独到之处:

  1. 选择最有效的随机方式。我们在随机挖空的时候,发现纯粹随机的效果并不好,特别是要求生成55个空的独解数独时,会比较慢。但是如果在各个宫内部进行比较平均的挖空,得出来的空会分布得比较均匀,就容易产生单解数独。

  2. 结合逻辑推导。反向使用行列宫摈除法,可以挖掉当前局面来看具有逻辑必然性的数字(比如说第一行是 1 2 3 4 5 6 7 8 9,那么 1 可以被挖掉,因为 1 的存在是被其他 8 个数决定的),这样,这部分的挖空可以不用交给随机挖空来解决,降低了算法的时间复杂度。

5. UML图显示计算模块部分各个实体之间的关系

6. 计算模块接口部分的性能改进。记录在改进计算模块性能上所花费的时间,描述你改进的思路,并展示一张性能分析图(由VS 2015/2017的性能分析工具自动生成),并展示你程序中消耗最大的函数。(3')

1. puzzle 模块

  1. 测试指令 -n 10000 -r 20~35 -u
      

  2. 测试指令 -n 10000 -r 36~50 -u
      

  3. 测试指令 -n 10000 -r 51~55 -u  
      
        

  可见,FgMap::outside_lock 是最耗时间的函数,编写之前已经考虑到了这个问题,因此采用了位运算等加速方法,实际上很难再优化了。

2. create 模块

  
    

  create 采用的是行列交换的方法生成不等价的数独,因此很快,瓶颈在 io,但这里采用了缓存一次性输出的方式,最大限度利用了 block 读写的功能,因此也无法再优化了。

3. solve 模块

  

7. 看Design by Contract, Code Contract的内容:

http://en.wikipedia.org/wiki/Design_by_contract
http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx
描述这些做法的优缺点, 说明你是如何把它们融入结对作业中的。(5')

优点:

  1. 在开发过程中可能会忘记一个函数本身的含义,而契约设计可以时刻提醒设计者这个函数本身的功能,不至于偏离原本的需求;
  2. 在调用函数时可以了解到需要保证的条件和可能带来的后果,增加了开发的安全性。

缺点:

  1. 契约设计的前置条件和后置条件可能有很多,如果一定严格按照语法规则描述,则阅读起来比较困难;而如果改为用自然语言描述,又会出现描述模糊的情况,这一点在上学期《面向对象课程设计》中有所体会;
  2. 保证契约的正确性会带来更多的开发成本。

  在本次结对作业中,我们通过定义了dig函数和rank类的需求,近似地实现了契约设计。dig函数负责将传入的数独尽可能地挖空,而rank类提供了各种对排行榜操作的接口。预先定义了这些需求再开始实际的代码编写,让后期的代码衔接更加简单。

8. 计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到90%以上,否则单元测试部分视作无效。(6')

覆盖率截图:

单元测试

在编写测试代码之前,首先明确一下单元测试要实现的功能:

  1. 合法性检测
  2. 等价性检测(是更严格的重复性检测)
  3. 单解性检测

对于-c,需要检测1、2;
对于-s,需要检测1;
对于-n,需要检测1、2,如果有-u,还需要检测3。

合法性检测

依然使用二进制数存储法判断合法性,这一点和个人项目完全相同,优点是速度快、代码简洁,代码如下:

for (int i = 0; i < number; i++) {
	sudoku = new string();
	int* sudoku_ptr = result[i];
	for (int j = 0; j < SIZE; j++) {
		for (int k = 0; k < SIZE; k++) {
			int digit;
			digit = sudoku_ptr[GET_POS(j, k)];
			(*sudoku) += digit + '0';
			bit = (1 << (digit - 1));
			row_record[j] |= bit;
			column_record[k] |= bit;
			block_record[(j / 3) * 3 + k / 3] |= bit;
		}
	}

	// judge & initial
	for (int i = 0; i < 9; i++) {
		Assert::AreEqual(511, row_record[i]);
		Assert::AreEqual(511, column_record[i]);
		Assert::AreEqual(511, block_record[i]);
		row_record[i] = 0;
		column_record[i] = 0;
		block_record[i] = 0;
	}
}

等价性检测

沿用了个人项目中的字典树,代码变更如下:

typedef struct node {
		bool isbottom;
		int depth;
		string* sudoku;
		struct node* ptrs[9];
	}Treenode;

	Treenode* create_treenode(int depth, string* sudoku) {
		Treenode* p = (Treenode*)malloc(sizeof(Treenode));
		p->isbottom = true;
		p->depth = depth;
		p->sudoku = sudoku;
		for (int i = 0; i < 9; i++) {
			p->ptrs[i] = NULL;
		}
		return p;
	}

	void add_sudoku_to_tree(int depth, Treenode** p, string* sudoku) {
		if ((*p) == NULL) {
			(*p) = create_treenode(depth, sudoku);
		}
		else {
			if ((*((*p)->sudoku)).length() > 0) {
				if ((*sudoku).compare(*((*p)->sudoku)) == 0) {
					fclose(fout);
				}
				Assert::AreNotEqual(*sudoku, *((*p)->sudoku));
				add_sudoku_to_tree(depth + 1, &((*p)->ptrs[(*((*p)->sudoku))[depth + 1] - '1']), ((*p)->sudoku));
				(*p)->sudoku = new string("");
			}
			add_sudoku_to_tree(depth + 1, &((*p)->ptrs[(*sudoku)[depth + 1] - '1']), sudoku);
		}
	}

其中,这一行

(*p)->sudoku = new string("");

是变动后的代码,之前的代码为

*((*p)->sudoku) = "";

  
这段代码的情境是本身位于这个节点的字符串遇见了新加入的字符串,于是他们需要根据自己接下来的字符,从该节点引申出两个属于它们的新节点,那里是他们的归宿。可以看出之前的代码是有问题的,sudoku是字典树节点中储存的字符串指针,旧代码中为了清空节点,清空的是指针指向的值,导致字典树中很多字符串都被清空了,所幸被测试的代码没有问题,不然后果很严重……新的代码修改的是指针本身,不影响存入的字符串,是正确的处理方式。
  
除此之外,我们沿用了个人项目中的字典树重复性判断,这次新添了检测等价性的功能。我们选取第一个宫,对里面的数字分别和1~9进行映射,之后将整个数独根据映射刷新,再放入字典树中进行判断。映射的代码如下:

char digit_map[SIZE];
for (int i = 0; i < SIZE; i++) {
	digit_map[i] = (*sudoku)[i]; // build map
}
/* change to equivalence */
for (int i = 0; i < SIZE * SIZE; i++) {
    (*sudoku)[i] = digit_map[(*sudoku)[i] - '1']; 
}

单解性测试

其实在生成数独题目的时候已经检查过单解性了,这里引用的是那里的代码:

bool generator_fill_sudoku(Subject_sudoku* sudoku, int &solution_counter) { 
	/* -- succeed(true) or failed(false) */
	Box* box;
	//cout << sudoku->to_string() << endl;
	box = sudoku->get_minpos_box();
	if (box == NULL) {
		solution_counter++;
		if (solution_counter > SOLUTION_MAX) {
			return false;
		}
		return true;
	}
	return generator_guess_value(box, sudoku, solution_counter);
}

   

这个函数是对求解函数的修改,原来是找到解立刻输出答案,现在是找到一个解继续运行,直到找到的解的个数超过SOLUTION_MAX为止。这里SOLUTION_MAX的值为1。

9. 计算模块部分异常处理说明。在博客中详细介绍每种异常的设计目标。每种异常都要选择一个单元测试样例发布在博客中,并指明错误对应的场景。(5')

  InvalidCommandException 错误的指令;

TEST_METHOD(command_exception1) {
    bool test_result = false;
    int argc = 4;
    char* argv[10] = {
        "sudoku.exe",
        "-s",
        "100",
        "-c"
    };
    try {
        read_command(argc, argv);
    }
    catch (InvalidCommandException*) {
        test_result = true;
    }
    Assert::IsTrue(test_result);
}

  CannotOpenFileException 无法打开文件;

TEST_METHOD(cannot_open) {
    bool test_result = false;
    Core core;
    try {
        core.input_file("puz.txt", result_solve);
    }
    catch (CannotOpenFileException* e) {
        test_result = true;
    }
    Assert::IsTrue(test_result);
}

  BadFileException 文件异常或损坏;

TEST_METHOD(incompleted_sudoku) {
    FILE* ftest;
    int erno = fopen_s(&ftest, "puzzle.txt", "w");
    if (ftest == NULL) {
        cout << erno << endl;
        Assert::Fail();
    }
    Core core;
    fputs(
        "4 1 7 2 3 8 6 5 9\n\
        3 2 6 4 9 5 8 1 7\n\
        9 5 8 7 1 6 3 2 4\n\
        6 9 1 8 5 2 7 4 3\n\
        8 4 2 9 7 3 1 6 5\n\
        7 3 5 6 4 1 9 8 2\n\
        1 8 3 5 2 7 4 9 6\n\
        2 7 9 1 6 4 5 3 8\n\
        5 6 4 3 8 9 2 7 b"
        , ftest);
    fclose(ftest);
    bool test_result = false;
    try {
        core.input_file("puzzle.txt", result_solve);
    }
    catch (BadFileException* e) {
        test_result = true;
    }
    Assert::IsTrue(test_result);
}

  InvalidPuzzleException 数独谜题本身不符合规则(并非指全部无解谜题):

int index = 0;
int digit;
QPushButton* btn;
for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
        int digit = puzzle[index++];
        btn = buttons[i][j];
        if (digit == 0) { // free grid
            btn->setText("");
            btn->setEnabled(true);
            btn->setStyleSheet(UNCERTAIN_GRID_STYLE);
            numbers[i][j] = 0;
        }
        else {
            char num[2] = { '0' + digit, '\0' };
            btn->setText(num);
            btn->setEnabled(false);
            btn->setStyleSheet(CERTAIN_GRID_STYLE);
            numbers[i][j] = digit;
        }
    }
}

  以上样例全部测试通过。

10. 界面模块的详细设计过程。在博客中详细介绍界面模块是如何设计的,并写一些必要的代码说明解释实现过程。(5')

  我们主要的GUI界面只有一个,其它的还包括排行榜界面和成绩写入界面。

  GUI的布局使用代码生成,没有使用.ui文件,原因是觉得.ui文件自动生成的代码很臃肿,而自己写的话可以建立数组管理各个组件(大概是我们没有找到正确的方法?)。举例来说的话,下面是生成数独9*9个方格的代码:

void SudokuGUI::create_grids() {
	QSignalMapper* mapper = new QSignalMapper(this);
	for (int i = 0; i < SIZE; i++) {
		for (int j = 0; j < SIZE; j++) {
			numbers[i][j] = 0;
			buttons[i][j] = new QPushButton("", this);
			QPushButton* btn = buttons[i][j];

			btn->setGeometry(
				(j + 1) * BOX_SIZE + (j / 3) * 10 - 20,
				(i + 1) * BOX_SIZE + (i / 3) * 10,
				BOX_SIZE,
				BOX_SIZE
			); // set position
			btn->setEnabled(false);
			btn->setFont(QFont("Times", 18, QFont::Bold)); // set fond
			btn->setStyleSheet(CERTAIN_GRID_STYLE); // set color
			btn->setFont(GRID_FONT);
			QObject::connect(btn, SIGNAL(clicked()), mapper, SLOT(map()));
			mapper->setMapping(btn, GET_GRIDNO(i, j));
		}
	}
	QObject::connect(mapper, SIGNAL(mapped(int)), this, SLOT(record_button(int)));
}

  利用类似的for循环来初始化各个元件,他们的StyleSheet用预定义表示:

#define FUNCTION_FONT QFont("Consolas", 16, QFont::Normal)
#define REMAINING_FONT QFont("Consolas", 14, QFont::Normal)
#define GRID_FONT QFont("Consolas", 18, QFont::Normal)
#define UNCERTAIN_GRID_STYLE "QPushButton:hover{\
	background-color:#AFEEEE;\
}"\
"QPushButton{\
	background-color:#66CCFF;\
}"
#define CERTAIN_GRID_STYLE "QPushButton{\
	color:#1C2460;\
	background-color:#99CCFF;\
}"
#define TIP_GRID_STYLE "QPushButton{\
	color:#1E90FF;\
	background-color:#99CCFF;\
}"
#define WRONG_GRID_STYLE "QPushButton{\
	background-color:#DC143C;\
}"
#define MARK_GRID_STYLE "QPushButton{\
	background-color:#DC143C;\
}"
#define CURRENT_GRID_STYLE "QPushButton{\
	background-color:#FFFF66;\
}"
#define INPUT_BOTTON_STYLE "QPushButton{\
	background-color:#FF69B4;\
}"
#define FUNCTION_BUTTON_STYLE "QPushButton{\
	color:#000000;\
	background-color:#FFFF66;\
}"
#define ENABLE_BUTTON_STYLE "QPushButton{\
	background-color:#DC143C;\
}"
#define DISABLE_INPUT_BUTTON_STYLE "QPushButton{\
	background-color:#FFE4E1;\
}"
#define DISABLE_FUNCTION_STYLE "QPushButton{\
	background-color:#fcf8ab;\
}"
#define WINDOW_SYTLE ""

  下面是一些界面变化部分,首先是新游戏的开始,这里根据Core的generate接口处理数独界面,将未被挖空的数独对应按钮置为Disable:

int index = 0;
int digit;
QPushButton* btn;
for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
        int digit = puzzle[index++];
        btn = buttons[i][j];
        if (digit == 0) { // free grid
            btn->setText("");
            btn->setEnabled(true);
            btn->setStyleSheet(UNCERTAIN_GRID_STYLE);
            numbers[i][j] = 0;
        }
        else {
            char num[2] = { '0' + digit, '\0' };
            btn->setText(num);
            btn->setEnabled(false);
            btn->setStyleSheet(CERTAIN_GRID_STYLE);
            numbers[i][j] = digit;
        }
    }
}

  这次我们实现的功能有四个,除了要求的check和tip外,我们还附加了filter和track功能。filter是在当前格子内切换所有满足填入规则的值,而track是将某种数字标红便于查看。

  check的设计思路是建立三个数组,分别对应行、列、组,并将每一个格子的数字分别存储于这三个数组中,假如某个数组储存的某种数字的数量大于1,说明出现了数字的重复,将重复的数字标红:

int row_digit_counter[SIZE][SIZE] = { 0 };
int column_digit_counter[SIZE][SIZE] = { 0 };
int block_digit_counter[SIZE][SIZE] = { 0 };

bool pass = true;

// store box 
for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
        int value = numbers[i][j];
        if (value != 0) {
            row_digit_counter[i][value - 1]++;
            column_digit_counter[j][value - 1]++;
            block_digit_counter[GET_BLOCKNO(i, j)][value - 1]++;
        }
        else {
            pass = false;
        }
    }
}

// judge & initial
for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
        int value = numbers[i][j];
        if (value != 0 && (
            row_digit_counter[i][value - 1] > 1 ||
            column_digit_counter[j][value - 1] > 1 ||
            block_digit_counter[GET_BLOCKNO(i, j)][value - 1] > 1
            )) {
            buttons[i][j]->setStyleSheet(WRONG_GRID_STYLE);
            pass = false;
        }
        else {
            RESTORE_GRID_STYLE(buttons[i][j]);
        }
    }
}

  tip的实现非常简单,就是将终局数独对应的数字填入就好,这里就不细说了。tracker的实现也很简单,就是找到对应的数字并涂红就好,这里简要说一下filter的实现:

  我采用了二进制存储的方法,将当前选中格子所在行、列、宫中出现的所有数字进行记录,得到所有可取的值。但是由于filter所填入的数字是需要不断轮换的,所以我要从当前填入的数字开始进行for循环,保证下一个出现的数字是在当前填入数字之后的。但是假如没有可以填入的数字,我们就将这个格子清空(填入CLEAN)。

if (curbtn != NULL) {
    GO_THROUGH_BLOCKS(GET_BLOCKNO(this->cur_rowno, this->cur_colno)) {
        int digit = numbers[i][j];
        if (digit != 0 && (i != this->cur_rowno || j != this->cur_colno)){
            binary_recorder |= (bit << (digit - 1));
        }
    }
    for (int i = 0; i < SIZE; i++) {
        int digit;
        digit = numbers[i][this->cur_colno];
        if (digit != 0 && i != this->cur_rowno) {
            binary_recorder |= (bit << (digit - 1));
        }
        digit = numbers[this->cur_rowno][i];
        if (digit != 0 && i != this->cur_colno) {
            binary_recorder |= (bit << (digit - 1));
        }
    }
    int cur_digit = numbers[this->cur_rowno][this->cur_colno];
    for (int digit = cur_digit + 1; digit <= SIZE; digit++) {
        if ((binary_recorder & (bit << (digit - 1))) == 0) {
            set_number(digit);
            return;
        }
    }
    for (int digit = 1; digit <= cur_digit; digit++) {
        if ((binary_recorder & (bit << (digit - 1))) == 0) {
            set_number(digit);
            return;
        }
    }
    set_number(CLEAN);

  计时器采用QLCDNumber元件,利用槽函数,每1ms调用一次timeout_handle函数:

void Timer::timeout_handle() {
	*time = time->addMSecs(TIMEOUT_MILL);
	time_lcd->display(time->toString("hh:mm:ss.zzz"));
}

11. 界面模块与计算模块的对接。详细地描述UI模块的设计与两个模块的对接,并在博客中截图实现的功能。(4')

代码对接

  对接很简单,利用core的生成难度谜题功能和求解功能(用于提示功能),将谜题和解储存在数独中。

FILE* fout;
this->mode = difficulty - 1;

this->unfilled_grid_count = 0;
int puzzle_receiver[1][SIZE*SIZE];
core->generate(1, difficulty, puzzle_receiver);

for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
        int gridno = GET_GRIDNO(i, j);
        this->puzzle[gridno] = puzzle_receiver[0][gridno];
        if (puzzle[gridno] == 0) {
            unfilled_grid_count++;
        }
        
    }
}

char unfilled_grid_count_str[3];
sprintf(unfilled_grid_count_str, "%d", unfilled_grid_count);
grid_count->setText(REMAINING_TEXT + unfilled_grid_count_str);

core->solve(puzzle_receiver[0], this->sudoku);

功能实现

主界面

点击Game->New Game可以选择难度,随即开始游戏。操作流程为点击某方格,并点击右侧键盘上的数字进行填入,点击Erase可以消除方格上填入的数字。

功能一:Check

点击Check按钮可以检查填入的正确性,冲突部分会标红,如果全部方格都被填入且正确,游戏结束。

功能二:Tip

点击某一可填入方格,此时可选择Tip,Tip可以告诉你方格的正确答案,但代价是本次成绩将不会被计入排名。

功能三:Track

点击Track后,Track会变红,此时为追踪数字状态,点击右侧数字键盘,可以显示数独中该数字所有出现的位置,便于求解。(此功能只限于Normal和Hard难度)

功能四:Filter

点击某一可填入方格,此时点击Filter可以循环填入该方格的所有可行数值,便于求解。(此功能只限于Hard难度)

功能五:计时

游戏开始时,计时器会开始计时,精确到毫秒,游戏结束后计时器暂停,视成绩决定是否计入排名。

功能六:排名

如果成绩良好,在游戏结束后会弹出窗口供玩家输入姓名,并存储在排行榜中。排行榜位于Game->LeaderBoard中。

细节一:填入最后一个格子自动触发Check

玩家填入最后一个格子即标志着数独的完成,此时应当立即结束游戏,或者告知玩家哪里填入有问题。

细节二:告知剩余方格数

玩家可以了解到当前的游戏进度。

细节三:功能随难度进行调整,保证游戏性

对于Easy模式,Filter太过强大,以至于无脑Filter就可以结束游戏,成了纯粹比拼手速的游戏。我们认为Easy象征着初学者难度,一些基础的猜数方法要由玩家在此阶段掌握,不能让新玩家过于依赖Filter和Track功能。随着难度的递增,玩家的水平也逐渐递增,此时给予玩家这些功能可以为高端玩家提供便利,这是我们设计的初衷。

细节四:Tip不可随便使用

Tip的存在是把双刃剑——它会让玩家丧失思考的乐趣,破坏游戏性,但同时也能给予玩家帮助,让实在想不出答案的玩家可以睡个安稳觉。为了权衡利弊,我们综合了Rank功能,让使用过Tip的玩家不能将成绩计入Rank,保证了游戏的公平性,同时还鼓励玩家尽可能减少Tip的次数,专注于提高解题能力。

细节五:关闭主窗口,其它窗口随即关闭

如果主窗口都关了,剩下的小窗口存在的意义是什么呢?还要劳烦用户一个一个去关吗?

12. 描述结对的过程,提供非摆拍的两人在讨论的结对照片。(1')

我们属于是两个落单的人,于是就结对了……结对的时候使用的是我的个人项目代码,GUI部分由我编写,-c生成算法也主要由我想出来的,而核心代码中生成数独谜题部分的算法则是由王辰昱提出的,实际运行效率非常高。在本次结对编程过程中,我学到了很多东西,包括编程能力和沟通能力,收益匪浅。

13. 结对编程的优点和缺点

结对编程

优点:

  1. 减少代码编写过程中出现问题的几率
  2. 在出现问题的时候,两个人的思路更加广阔,更利于解决问题
  3. 在代码设计过程中,两个人经过讨论可以使代码设计更加完美
  4. 两个人取长补短,结对过程也是相互学习的过程

缺点:

  1. 对空闲时间要求较大,两个人的空闲时间必须有很多交集

队友

优点:

  1. 代码能力超强,封装性很好,代码风格很赞
  2. 学习能力很强,可以通过学习掌握一些算法,提高代码运行效率
  3. 用户体验部分理解比我要深刻,GUI的优化部分帮了不少忙
  4. 肯下工夫,愿意花费时间对代码进行雕琢(很肝)

缺点:

  1. 感觉有些缺乏计划性,导致最后时间很紧张……

自己

优点:

  1. debug能力感觉还可以,小技巧有很多,de掉了一些比较玄的bug
  2. 肯下工夫,愿意花费时间对代码进行雕琢(也很肝)
  3. 感觉沟通能力较差,很多时候不能表达出自己的观点

缺点:

  1. 代码风格不好,比较粗心大意,造成很多bug

14. 在你实现完程序之后,在附录提供的PSP表格记录下你在程序的各个模块上实际花费的时间。(0.5')

附加题4

交换小组:

王子铭 15231058
索一奇 15061180

合并情况

  合并的时候出现了一些问题,主要原因是我们两组的core接口不同,前者传入的是一个二维指针,而我们传入的是一个二维数组(助教博客中写的是int[][] result,这是java中的用法,根据不同人理解不同,我认为无论传入指针还是数组都是正确的),导致最终通过修改代码才能进行合并。
  要想使用交换小组的dll新建游戏,必须要调用set_play(bool)函数,这个函数十分简单:

void Core::set_play(bool a) {
	play = a;
}

  其目的是为了调整生成puzzle的策略,如果play是false,说明是命令行模式下运行,使用回溯法保证了数独生成的不等价性;如果play是true,说明是在GUI中运行,更加侧重随机性。这种做法的优点在于生成数独更加灵活,但同时为dll的交换造成了一定影响,因为set_play并不是约定的接口,导致只有通过修改代码才能确保数独游戏的正确生成。 因此我的个人观点是,core中多余的函数会影响dll的灵活性,尽量不去添加没有约定好的接口是比较好的设计方式。
  除此之外的一个小缺陷是任意打开两次游戏的第N局游戏都是相同的,我认为可以通过srand(time(0))根据系统时间改变随机数种子,从而解决问题~

合并后的游戏界面

  我normal模式下挖45个空,对方是挖47个空:

Bug修复

经交换小组提示,目前Exception类已提供dll接口,调用者可以自行调用所有的异常类。

附加题5

正在收集反馈,部分反馈结果如下:

某舍友A君
亮点在于:细节做得非常好,比如使用tip之后不算成绩、难度的递增会有伴随功能的出现、排行榜的可以自行清除;check、filter和track的功能做得很新颖,也很实用,比如check可以随时点击随时检查,track和filter能够使得游戏过程的真实性更强。
不足在于:未开放的按钮可以不显示;说明性的文字过少,影响用户上手时的体验。

某舍友B君
优点:增加了filter功能,提升了游戏体验,数独填写完成后自动触发check功能,不必手动点击check按钮。排行榜信息丰富,用户体验良好。
缺少必要的用户提示。直接进入游戏页面感觉很突兀。按钮太方方正正了。

某好友C君
感觉很方便啊,感觉可以改进一下,不用点数字,键盘输入数字更爽。

某舍友D君
优点:

  1. check功能标注了同行或同列相同数字。
  2. 增加filter功能,可提示当前数字框接受数字。
  3. 有track功能,可以显示当前数独游戏中某数字所在位置。
  4. help功能是一个亮点,包含各个按钮使用规则,显得逼格很高的样子。

缺点:

  1. 被提示之后不能消除,不利于小伙伴之间的互动。
  2. 使用界面不友善。建议可以将按钮变换形状增添界面趣味性

某基友E君

这个成功提示显示不全啊,话说这也太简单了,hard8分多不用试数就做出来了= =
(不好意思,是你太强了)

相关改进

B君说得很有道理,于是我们添加了help功能,在About->Help可以调出帮助:

解释了相关的功能用法,同时该内容也在README中写入了。
E君提出的问题暂时无法解决,这和计算机自定义的文本大小有关,经测试,该同学的文本大小大约为1.5x,而在1.0x和1.25x上显示正常。

感想

我是个强迫症,觉得界面可以不精美,但用起来一定要舒服,因此在颜色搭配、布局等方面花了一点时间,包括使用七段数码管提供毫秒级的计时功能,营造紧张感;所有窗口的一键关闭;创造新纪录后立刻调出排行榜等等……感觉至少对用户习惯有了一点思考,这是我结对编程主要的收获之一,当然这部分也离不开同伴的帮助和支持,后面关于tip的配色问题都是他进行改良的hhh,总体来讲最终效果还说得过去。其实我一开始是想向“扫雷”看齐的,系统自带的扫雷没有特别花哨的配色,界面十分简洁朴素,但是用起来也很舒服,我感觉能做到这种程度就很不错了。
但是内部的代码实现其实在个人复审的时候感觉还是挺糟糕的,至少应该建几个QPushButton的子类代表不同类型的按钮,主要是一开始的界面还是在个人阶段一晚上搞出来的,没有考虑太多,导致GUI内部元件的封装性特别差。这一部分以后还是要注意的。

posted @ 2017-10-15 05:00  森高Slontia  阅读(457)  评论(3编辑  收藏  举报