系统性学习C语言-第七讲-数组和函数实践:扫雷游戏

经过前六讲内容的学习,我们已经初步掌握了一些 C语言 的基础知识,俗话说实践出真知,光是纸上谈兵我们的技艺终究不会有大的进步,现在我们就对所学知识进行实践,在实践中更进一步地掌握

1. 扫雷游戏分析和设计

我们想要通过代码来模仿实现扫雷游戏,那么我们就要对它的游戏机制,功能进行分析,将其拆分,分析每个模块如何实现

当然,以我们当前的知识储备,完美实现一个完善的扫雷游戏显然是不太可能的,所以我们要在适当程度上对功能进行一定精简

1.1 扫雷游戏的功能说明

  1. 使用控制台实现经典的扫雷游戏

  2. 游戏可以通过菜单实现继续玩或者退出游戏

  3. 扫雷的棋盘是 9 * 9 的格子

  4. 默认随机布置10个雷

  5. 可以排查雷

  6. 如果位置不是雷,就显示周围有几个雷

  7. 如果位置是雷,就炸死游戏结束

  8. 把除 10 个雷之外的所有非雷都找出来,排雷成功,游戏结束

游戏的界面:

在这里插入图片描述

1.2 游戏的分析和设计

1.2.1 数据结构的分析

扫雷的过程中,布置的雷和排查出的雷的信息都需要存储,所以我们需要⼀定的数据结构来存储这些信息。

因为我们需要在 9 * 9 的棋盘上布置雷的信息和排查雷,我们首先想到的就是创建⼀个 9 * 9 的数组来存放信息。

在这里插入图片描述
那如果这个位置布置雷,我们就存放 1 ,没有布置雷就存放 0 。

在这里插入图片描述
假设我们排查 ( 2 , 5 ) 这个坐标时,我们访问周围的⼀圈 8 个黄色位置,统计周围雷的个数是 1 。

假设我们排查( 8 , 6 )这个坐标时,我们访问周围的⼀圈 8 个黄色位置,统计周围雷的个数时,最下面的三个坐标就会越界,

为了防止越界,我们在设计的时候,给数组扩大⼀圈,雷还是布置在中间的 9 * 9 的坐标上,周围⼀圈不去布置雷就行,

这样就解决了越界的问题。所以我们将存放数据的数组创建成 11 * 11 是比较合适。

在这里插入图片描述
再继续分析,我们在棋盘上布置了雷,棋盘上雷的信息(1)和非雷的信息(0),假设我们排查了某⼀个位置后,这个坐标处不是雷,

这个坐标的周围有1个雷,那我们需要将排查出的雷的数量信息记录存储,并打印出来,作为排雷的重要参考信息的。

那这个雷的个数信息存放在哪里呢?如果存放在布置雷的数组中,这样雷的信息和雷的个数信息就可能或产生混淆和打印上的困难。

这⾥我们肯定有办法解决,比如:雷和非雷的信息不要使用数字,使用某些字符就行,这样就避免冲突了,

但是这样做棋盘上有雷和非雷的信息,还有排查出的雷的个数信息,就比较混杂,不够方便。这⾥我们采用另外⼀种方案,

我们专门给⼀个棋盘(对应⼀个数组mine)存放布置好的雷的信息,再给另外⼀个棋盘(对应另外⼀个数组show)存放排查出的雷的信

息。这样就互不干扰了,把雷布置到 mine 数组,在 mine 数组中排查雷,排查出的数据存放在 show 数组,

并且打印 show 数组的信息给后期排查参考。同时为了保持神秘,show 数组开始时初始化为字符 ‘*’,为了保持两个数组的类型⼀致,

可以使用同⼀套函数处理,mine数组最开始也初始化为字符 ’ 0 ’ ,布置雷改成 ’ 1 ’ 。如下如:

在这里插入图片描述
对应的数组应该是:

char mine[11][11] = {0};//⽤来存放布置好的雷的信息 
char show[11][11] = {0};//⽤来存放排查出的雷的个数信息

1.2.2 文件结构设计

之前学习了多文件的形式对函数的声明和定义,这里我们实践⼀下,我们设计三个文件:

test.c //⽂件中写游戏的测试逻辑  
game.c //⽂件中写游戏中函数的实现等 
game.h //⽂件中写游戏需要的数据类型和函数声明等

2. 扫雷游戏的代码实现

2.1 头文件代码分析与实现

我们先对 game.h 头文件中的代码内容进行分析

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
# define eazy_count 10
# define ROW 9
# define COL 9
# define COLS COL + 2
# define ROWS ROW + 2

void InitBoard(char board[ROWS][COLS], int row, int col, char set);  //初始化棋盘

void SetBoard(char board[ROWS][COLS], int row, int col);  //布置雷

void PrintBoard(char board[ROWS][COLS], int row, int col);  //展示棋盘

void FindBoard(char show[ROWS][COLS], char mine[ROWS][COLS], int row, int col);   //查找雷

2.1.1 头文件包含与常量定义

首先我们尽量将 .c 文件中所需要的头文件包含进来,这样就可以仅包含一个 game.h 头文件就能实现全部的功能

接下来我们需要用 define 定义一些常量,给这些常量换个名字,更加方便理解,相比直接在代码中使用常量,可读性更高

同时以后要进行修改时,我们只需修改定义的常量即可,十分便捷

我们现在可暂且将在此处 define 定义常量的作用为起别名,在别处使用这些名字,系统在编译时会自动将其替换为常量

在一些情况下,我们展示的数组,通常是 9 行 9 列的,所以我们将 ROWCOL 定义为常量 9,但是出于实际的需要

我们在定义数组是仍需定义为 11 行 11 列,11 这个数我们也经常会使用,且具有一定的意义,我们也如定义ROWCOL

一样,将其定义为 ROWSCOLS,不过我们并不将其直接定义为 11 ,而是通过ROWCOL各自 + 2,

这样我们在修改ROWCOL 的同时, ROWSCOLS 就已经顺便修改了,无需我们再进行修改

2.1.2 头文件函数模块拆分与声明

这里我们将我们要实现的代码部分拆分为四部分,分析一下他们需要的变量

  1. InitBoard 初始化数组,用于将数组初始化为我们想要的数据:需要传递字符型数组参数char board[ROWS][COLS],要初始化的行数与列数int row, int col,以及初始化为什么符号char set

  2. SetBoard布置地雷的数组,用于在 mine 数组中布置 ‘1’ 的炸弹:需要传递字符型数组参数char board[ROWS][COLS],要布置炸弹限制的最大行数与列数int row, int col,关于具体地雷的坐标,我们会用专门的随机数生成函数来生成,所以不用传参。

  3. PrintBoard打印数组,用于打印我们的数组,给玩家展示相关信息:需要传递字符型数组参数char board[ROWS][COLS],要展示的行数与列数int row, int col

  4. FindBoard查找数组,查找数组中的地雷:需要传递 showmine 两个字符符型数组参数,方便在查找后根据各种情况,调整数组数据,char show[ROWS][COLS], char mine[ROWS][COLS],需要传递最大的查找范围,行与列 int row, int col,这里我们将输入行与列的代码,在查找数组内部实现,所以我们无需传递要查找的坐标

2.2 game.c 源文件代码分析与实现

对头文件的代码分析完毕后,我们开始对函数的定义进行分析,以及代码实现

2.2.1 InitBoard 初始化数组函数

1 . 如何使用循环来实现数组的初始化部分

要对数组进行初始化,我们就要生成所有的下标,对一维数组进行初始化,我们使用一重循环即可产生所有的下标

二重循环具有行、列双下标进行定位,所以我们要使用二重循环进行初始化,对于初始化数组来言,循环最好的选择为 for

限制循环的条件是,在一重与二重 for 循环中定义的变量分别小于作为参数传递进来的 rowcol

真正在函数中运用 InitBoard 初始化数组时,我们传递的限制变量实际为 ROWSCOLS ,这是因为初始化数组必须完全,彻底

并不是像展示数组函数 PrintBoard 一样只是用部分的数组,所以读者不必对限制循环变量部分产生疑惑。

在我们产生坐标的同时,我们要对数组的内容进行初始化,将我们传递的字符 set 赋予坐标

2 . 函数返回类型的思考

这个函数并不需要我们返回任何数据,我们初始化数组的目的已经在函数的主体部分实现,返回数据的类型定义成 void 即可

此时,函数定义部分的代码逻辑我们基本完成,现在我们就按照对应思路,来实现代码。

注意:

在用到 for 循环时,我们通常需要定义变量,若这个变量还需要在循环中进行使用,要根据其使用的含义,为其定义一个有意义的名称

void InitBoard(char board[ROWS][COLS], int row, int col, char set)  //初始化棋盘
{
	for (int current_row = 0; current_row < row; current_row++)
	{
		for (int current_column = 0; current_column < col; current_column++)
		{
			board[current_row][current_column] = set;
		}
	}
}

3 . 变量命名所要注意的事项

这里我们将在 for 循环内定义的变量分别命名成 current_rowcurrent_column,代表着当时数组的行与列,由于还会进行自增,

变化,所以在前面加上了 current ,这样代码的可读性就会增强。

以上就是我们 InitBoard 初始化数组函数定义部分的实现。

2.2.2 SetBoard布置地雷数组函数

1 . 如何随机生成地雷的数组下标

SetBoard布置地雷数组中,我们需要对 mine 数组进行地雷的布置,数量为 10 个。

我们先思考如何产生地雷的坐标,首先排除我们一个一个输入的可能,这样的运行过程过于繁琐 ,我们使用 rand来生成随机数

对于尚未学习 rand 函数的读者可以进行搜索学习,简述而言这是一个可以生成随机数的函数,但要使用它还需要进行处理,后续我们会在主函数中遇见

2 . 对于随机生成的数组下标各种情况的思考

但生成的随机数很有可能超出我们的数组范围,我们需要将雷的行和列限制在 1 ~ 9,所以我们在计算是要使用取模,

我们将限制的最大行数与列数传递到了参数 rowcol 中,所以我们对于随机出来的行数与列数分别对 rowcol 取模即可,

但是这样我们就的炸弹行数和列数就可能取不到 0 或者 9,所以我们要在取模的基础上 + 1 。

在我们处理完设置的行数与列数超出范围的情况外,我们还需要处理一种情况,虽然有极小的概率,但是在设置雷的过程中,

我们可能产生重复的坐标,此时我们就要对这个位置是否被设置为雷作出判断,然后再进行设置。

3 . 根据已知的条件对循环进行选择

对于重复坐标的情况,运用 for 循环显然无法简单的处理,无论是否是重复坐标,只要循环结束,我们再 for 循环中设置的变量都会

产生自增行为,这是我们就可以使用 while 循环,只用非重复的坐标点此时我们才对限制变量进行自增。

基于我们上面的代码逻辑,我们可以进行函数定义的实现。

void SetBoard(char board[ROWS][COLS], int row, int col)  //布置雷
{
	int boom_count = 0;
	while (boom_count < eazy_count)
	{
		int boom_row = rand() % ROW + 1;
		int boom_col = rand() % ROW + 1;
		if (board[boom_row][boom_col] != '1')
		{
			board[boom_row][boom_col] = '1';
			boom_count++;
		}
	}
}

4 . 变量命名所要注意的事项

我们将限制变量定义为 boom_count ,限制变量每自增代表我们安放的地雷增加一个,有助于代码的可读性

2.2.3 PrintBoard打印数组函数

1 . 如何使用双重循环输出仅需展示的部分数组

首先我们要打印出数组,需要生成数组的部分下标,对于我们创建的 11 行 11 列数组,实际上我们只展示其中的 1 ~ 9 行 1 ~ 9 列,

所以我们要用上二重循环来生成对应的坐标,对于二重循环产生数组下标的部分,我们使用 for 循环的效果最佳。、

但同时我们要对限制循环的变量做出一定的调整,达到展示部分数组的目的。

因为我们第 0 行,第 0 列,第 10 行 ,第 10 列并不是我们打印的对象,我们将限制变量初始化为 1 ,

这样我们就能跳过第 0 行,第 0 列的打印。对于第 10 行 ,第 10 列,因为我们是部分打印数组,所以我们在函数部分传入的参数为

ROWCOL ,所以我们并不用担心第 10 行 ,第 10 列被打印到数组中,因为当限制条件为 < ROW , < COL 时我们最多也打印到

第 8 行,第 8 列,所以我们现在要担心的问题转化为了如何打印 第 9 行 ,第 9 列,我们只需将限制条件出 + 1 即可,

即变为 < ROW +1 , < COL + 1,这样我们便可打印到第 9 行,第 9 列。

2 . 如何实现在界面添加数组坐标参照

为了便于玩家更好的输入需要排查的数组坐标,我们最好打印出对应的坐标行数与列数,就像这样。

在这里插入图片描述

这样玩家就能快速找到自己像查找的对应坐标。

对于第一行对应的坐标,我们可以单独使用一层循环来实现,对于第一列的坐标,我们则可以在数组的第一层循环中生成,

3 . 对于数组内容的打印,什么条件下我们要进行换行操作

对于数组数据的打印,我们还有一个注意的点,即换行符的打印。

这里我们选择用 if 在特定条件时,打印数组数据后,直接紧接换行符的打印,现在我们就要分析一下判断条件为何时,

我们需要进行换行符的打印,当我们的限制条件为 < COL + 1 时,我们的限制变量 == COL 时,

我们打印的数组就来到了这一行最后一列的打印,这是我们就要打印换行符,所以我们分析出了打印换行的条件为限制变量 == COL

基于以上的函数实现逻辑,我们可以对函数进行代码层面的实现。

void PrintBoard(char board[ROWS][COLS], int row, int col)  //展示棋盘
{
	printf("--------扫雷游戏--------\n");

	int col_mark = 0;
	for (col_mark = 0; col_mark < col + 1; col_mark++)
	{
		printf("%d ",col_mark);
	}
	printf("\n");
	
	int row_mark = 0;
	
	for (int current_row = 1; current_row < row + 1; current_row++)
	{
		row_mark++;
		printf("%d ", row_mark);
		for (int current_col = 1; current_col < col + 1; current_col++)
		{
			
			if (current_col == col )
				printf("%c\n", board[current_row][current_col]);
			else
				printf("%c ", board[current_row][current_col]);
		}
	}
}

4 . 变量命名所要注意的事项

这里我们将第一行下标参考定义为 row_mark ,第一列下标参考为 col_mark,都是为了可读性做考量,for 循环中的限制变量同理

2.2.4 FindBoard查找数组函数

1 . 对于输入坐标的实现

这里定义两个变量,分别储存输入的需要排查的行数、列数,用 scanf 库函数实现输入即可。

2 . 是否使用循环,使用何种循环,循环进入的条件是什么?

排查雷,一次并不能排完全部的雷,所以需要使用循环,此处我们使用 while 循环,原因是在排查雷时,我们会遇到多种情况,

不是每一种情况限制变量都要自增, 使用 for 循环会使我们的代码复杂,需要对每次循环自增的限制变量进行处理。

由于我们对扫雷的功能进行了精简,我们单次排雷并不能像游戏中那样,如果有一大片的无雷区全部展开,

所以我们可能会将所有的无雷区都排查到,仅剩有雷区,我们定义一个限制变量,当这个变量小于无雷区最大可以取到的数时,

则进入循环,也就是他等于所有无雷区坐标个数的数时,循环停止,代表剩下的坐标全部都是雷

3 . 被查找处坐标的几种可能,以及对应操作

一 :坐标合法,不超出数组范围:

( 1 )此处坐标已经被排查过:此时我们需要告诉玩家,此坐标已经被排查过,需要重新排查,并不对限制变量进行任何更改,

此次查找相当于一次无效操作,然后进入新一轮循环重新输入。

( 2 )此处坐标不为雷:此时我们需要定义一个数,来统计此坐标九宫格内雷的数量,并将值放入 show 数组的相同坐标处,

并打印 show 数组,然后限制变量自增,此次操作有效,成功找到一处无雷坐标,再重新进入循环。

( 3 )此处坐标恰好是雷:告诉玩家被炸死了,展示 mine 数组,告诉玩家雷坐标的安放位置,然后跳出循环。

二:坐标不合法,超出范围:

( 1 )此处坐标不合法:超出数组对应的范围,我们对限制变量不进行操作,这是一次无效的查找,我们需要重新进入循环输入。

前三次可以归为坐标合法的情况,第四种情况是坐标不合法的情况,此处我们在循环中使用 if - else 来实现情况的分类

4 . 坐标不为雷时,返回九宫格内是雷个数的函数如何实现

首先判断一下函数需要传入的参数有哪些,函数需要一个 mine 数组,被选择的行坐标、与列坐标,

我们需要对坐标的九宫格进行判断,所以我们就需要生成对应的坐标,这里我们不使用循环,直接使用作为参数传递进的行、列

进行 + 1 或者 - 1,由于字符数组在内存中以 ASCII 码表的形式存在,所以字符 ’ 1 ’ + ’ 1 ’ 的结果不为 ’ 2 '。

在将八个坐标的数据全部相加后,我们要减去 8 * ’ 0 ’ ,得出一个整形代表着雷的个数,所以函数的返回类型为整数。

在使用返回的结果时,直接 + ’ 0 ',或者直接加字符数组的内容也是可以的。

int GetMineCount(char mine[ROWS][COLS], int row, int col)
{
	return (mine[row - 1][col] + mine[row - 1][col - 1] + mine[row][col - 1] + mine[row + 1][col - 1] + mine[row + 1][col] +

		mine[row + 1][col + 1] + mine[row][col + 1] + mine[row - 1][col + 1] - 8 * '0');
}

5 . 循环结束的几种情况,以及对应处理

跳出循环的情况有两种:

( 1 )正常跳出循环:那么此时说明我们的条件已经不满足进入循环的条件,说明我们此时已经排查了所有无雷处,

此时我们应该告诉玩家排雷成功,并打印 mine 数组,展示布置雷的具体坐标,然后退出函数。

( 2 )被炸死跳出循环:这是我们只需退出函数即可,并不用进行任何操作

基于以上的函数实现逻辑,我们可以对函数进行代码层面的实现。

void FindBoard(char show[ROWS][COLS], char mine[ROWS][COLS], int row, int col)   //查找雷
{
	int row_chosen = 0;
	int col_chosen = 0;
	int win = 0;
	
	while (win < row * col - eazy_count)
	{
		printf("输入你想查询的坐标\n");
		scanf("%d%d", &row_chosen, &col_chosen);
		if (row_chosen > 0 && col_chosen > 0 && row_chosen <= row && col_chosen <= col)
		{
			if (show[row_chosen][col_chosen] != '*')
			{
				printf("此位置已被查找,请重新选择位置\n");
			}
			
			if (mine[row_chosen][col_chosen] == '0')
			{
				int num_boom_nearby = 0;

				num_boom_nearby = GetMineCount(mine, row_chosen, col_chosen);
				show[row_chosen][col_chosen] = num_boom_nearby + '0';
				PrintBoard(show, ROW, COL);
				win++;
			}
			
			if (mine[row_chosen][col_chosen] == '1')
			{
				printf("很遗憾,你被炸死了\n");
				PrintBoard(mine, ROW, COL);
				break;
			}	
		}
		else
		{
			printf("坐标不合法,请重新输入");
		}
	}

	if (win == row * col - eazy_count)
	{
		printf("恭喜你,排雷成功");
		PrintBoard(mine, row, col);
		return;
	}
	return;
}

6. 变量命名所要注意的事项

我们将所选则的行与列命名为 row_chosencol_chosen ,增加代码的可读性。

到此 game.c 文件内函数定义就已经实现完全。

2.3 测试文件代码分析与实现

我们将测试文件中需要实现的代码分成三部分:

  1. menu 菜单生成函数:生成一个游戏的开始菜单,用数字对应不同的选项,比如“开始游戏”、“退出游戏”。

  2. game 游玩逻辑函数:完整实现一遍游玩的逻辑,从初始化数组开始,到查找数组结束,实现完整的游玩逻辑。

  3. main 主函数:在主函数中,运用函数,实现完整的游玩体验。

2.3.1 menu 菜单生成函数

menu 菜单生成函数的任务很明确,生成一个菜单对应游玩选项即可,我们使用 printf 来操作即可。

对于此函数来言,我们无需返回任何数据,返回类型为 void

void menu()
{
	printf("***********************\n");
	printf("  ***** 1. play *****\n");
	printf("  ***** 0. exit *****\n");
	printf("***********************\n");
}

2.3.2 game 游玩逻辑函数

在此函数中我们要完整地实现一遍游玩逻辑。

首先我们要创建出二维数组 show , mine ,它们均为 11 行 11 列的二维数组。

在创建处数组后我们要对数组的内容进行初始化,此时我们使用 InitBoard 初始化数组函数,对其进行初始化。

初始化函数过后,我们使用PrintBoard打印数组函数将show 数组打印出,便于玩家对数组下标有选择参照。

之后我们要使用 SetBoard布置地雷数组函数对 mine 数组进行地雷的布置。

最后我们使用 FindBoard 查找数组函数,进行查找步骤,正式开始游戏。

在此函数中我们仍无需返回任何数据,所以函数的返回类型为 void

我们将逻辑梳理清楚后,代码层面的实现就会变得清晰、简单。

void game()
{
	char show[ROWS][COLS];
	char mine[ROWS][COLS];

	InitBoard(show, ROWS, COLS, '*');
	InitBoard(mine, ROWS, COLS, '0');

	PrintBoard(show, ROW, COL);

	SetBoard(mine, ROW, COL);

	FindBoard(show, mine, ROW, COL);
}

2.3.3 main 主函数

1 . 如何实现游戏的重复游玩,若要使用循环,使用哪种循环,循环结束的条件是什么

要实现游戏的重复游玩,我们就要使用循环,我们希望每次循环都会出现菜单给玩家进行选择,所以我们要讲 menu 菜单生成函数

融入到循环中,也就意味着这个循环至少执行一次,不存在一次不执行的情况,所以我们使用 do-while 循环来实现。

同事为了获取到玩家看到, menu 菜单生成函数上输入决定游玩还是退出的数字,我们还要定义一个变量,用来接受数据。

当玩家输入 0 时,意味着我们需要结束循环,退出游戏,所以循环进入的条件为定义的变量不为 0 时,0 正好在判断中表示假,

所以在循环进入条件处,我们只需要放入定义的变量名即可。

2 . 在循环中会出现哪些情况,这些情况我们应该进行哪些操作

在循环中,玩家输入的变量会出现三种情况:

  1. 玩家想要进行游戏,输入了 1 :这时我们调用 game 游玩逻辑函数即可,开始游戏。

  2. 玩家不想要进行游戏,输入了 0 :这是我们就要跳出循环,经循环判断后结束程序。

  3. 玩家输入了除了 1 或 0 ,之外没有意义的值,:这是就要提示玩家输入数据有误重新输入。

基于以上多种情况,而且我们判断的依据为常量,常量的不同值意味着我们要进行不同的操作,所以我们可以使用 switch-case 语句

来实现我们的条件判断。

将逻辑梳理清晰后,我就可以实现代码:

int main()
{
	int input = 0;
	srand((unsigned int)time(NULL));
	do
	{
		menu();
		printf("请选择:>");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			game();
			break;
		case 0:
			printf("退出游戏\n");
			break;
		default:
			printf("选择错误,重新选择\n");
			break;
		}
	} while (input);
	return 0;
}

在主函数的实现中,srand((unsigned int)time(NULL)); 这段代码的存在是为了随机数的生成不连续,规律性更小,是配合我们在SetBoard布置地雷数组函数中生成随机数函数使用的,这部分读者们可自行查阅资料,在此不做讲述。

到此我们测试函数的代码部分就已经完备实现。

接下来我们只需将对应代码放入对应文件内,整个扫雷的程序代码就完成了。

在这个程序中,我们对数组和函数的实践加强了我们对概念的理解,对编程逻辑的思考。

希望读者们可以再复现一次代码,加强理解,纸上得来终觉浅,绝知此事要躬行。

到底第七讲文章结束,这篇文章算是我第一次这么耗费精力去细细撰写,可能在某个我不注意的地方就出现错误了,望读者们批评指正,同时如对文章有更好的意见与建议,一定要告知作者,读者的反馈对于我十分重要,希望读者们继续勤勉励学,精益求精,
我们下篇文章再见👋。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值