编写推箱子游戏程序(第七步)——绘制游戏局面1
本文目标
本文讲解如何绘制游戏局面。游戏局面的示例如图 1,图 2 所示。这两幅图中,红旗代
表箱子的目的地。在任一关卡,玩家把全部箱子推到各个标有红旗的单元格上,就过了这一
关。
图 1 游戏局面示例 1
图 2 游戏局面示例 2
实现绘制游戏局面这一任务,要解决两个子问题:
1. 如何在程序中存储游戏局面?
2. 如何读取游戏局面的存储数据,显示到手机屏幕上?
第 1 个问题是本文的关键。我们知道,不仅每一关的游戏局面不一样,而且由于搬运工
或箱子的移动,游戏局面是不断变化的。因此,我们要用一个“变量”来存储游戏局面。
通过本文,你将学习到:
1. 用数据类存储游戏局面的方法。
2. 静态数据成员的用法。
1本文遵循 Apache License 2.0 协议。你可以修改和再发布本文档,但须保留原著者和采用 Apache License 2.0
协议。
实现思路和步骤
实现思路
解决第一个子问题,即“如何在程序中存储游戏局面”,的思路是,采用矩阵来存储游
戏局面,用字符来表示单元格的内容(例如,字符 B (Box) 表示箱子;字符 W (Wall) 表示墙
体)。矩阵的元素与游戏局面上的单元格一一对应,如下一节的图 3 所示。矩阵的元素是字
符型的。
解决第二个子问题的思路是,依次读取矩阵的元素,根据元素的字符值在相应的单元格
内绘制图像。例如,如果矩阵元素值为’B’,则绘制箱子;如果矩阵元素值为’W’,则绘制墙
体。
我们约定,游戏局面固定为 12 行 12 列。这一约定是为了减少次要的细节,使我们聚焦
于核心内容。
实现步骤
我们要区分游戏开局和游戏局面这两个概念。
开局。推箱子游戏的每一关,都有一个开局。这是玩家第一次玩(或者重头玩)这
一关,最开始看到的游戏局面,也就是这一关的初始局面——搬运工和箱子都没有
移动过的局面。各个关卡的开局数据始终要存在。这是说,推箱子游戏程序运行期
间,开局数据要存在;程序下一次、下下次运行,开局数据还是要存在。这是因为,
即使以前玩家一个玩过关卡,在以后玩家都有可能重头玩这一关。还有一点,开局
是不会变化的。
局面。玩家在玩一个关卡期间,这个玩家在游戏界面所看到状态叫做局面。一个关
卡的开局是这一关的初始局面。搬运工或箱子移动后,都将导致状态变化,形成新
的局面。可见,局面是不断变化的。
下文中,首先详细讲解存储游戏局面的方法。在代码实现上,我们将讲解:
1. 如何存储游戏关卡的开局?
2. 如何从关卡号得到该关卡的开局?
3. 如何存储和绘制游戏关卡的局面?
存储游戏局面的方法
采用矩阵来存储游戏局面,矩阵的元素与游戏局面上的单元格一一对应,如图 3 所示。
图 3a 是矩阵,图 3b 是该矩阵表示的游戏局面。
W
F
W W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
B
M
W
W W
W
图 3a 存储游戏局面的矩阵(12x12)
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
W
图 3b 矩阵对应的游戏局面
图 3 中,矩阵元素[0, 0]对应的是游戏局面第 1 行第 1 列的单元格。矩阵元素[1,2]对应的
是游戏局面第 2 行第 3 列的单元格。矩阵元素[11,11]对应的是游戏局面第 12 行第 12 列的单
元格。
单元格的内容可以是墙体、箱子、红旗、搬运工和空白(即没有墙体、箱子、红旗和搬
运工的情形)。我们在程序中用大写字符来表示它们:
墙体用’W’ (Wall),
红旗用’F’ (Flag),
搬运工用’M’ (Man),
空白用’ ‘(空格)。
这样,我们就知道图 3a 中的字符代表什么了,也知道图 3a 所示的矩阵正好表示了图 3b 这
个游戏局面。
事实上,单元格的内容还会出现以下情形:
搬运工踩在红旗上。用大写字符 R 表示。
箱子压在红旗上。用大写字符 X 表示。
存储和使用游戏关卡的开局
存储游戏关卡的开局
推箱子游戏的每一关都有一个开局。
代码实现上,我们用 GameLevels 类来实现存储开局数据的功能。GameLevels 类首先定
义了若干常量,如表 1 所示。注意,表 1 所示的代码仅仅是代码片段,需要和表 2、表 3、
表 4、表 6 合并在一起才构成 GameLevels 类的完整代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
表 1 GameLevels 类实现存储开局数据的功能
com.yescorp.moveboxgame.GameLevels.java(部分)
public class GameLevels {
public static final int DEFAULT_ROW_NUM= 12;
public static final int DEFAULT_COLUMN_NUM= 12;
//游戏区单元格放了什么
public static final char NOTHING= ' ';
public static final char BOX= 'B';
public static final char FLAG= 'F';
public static final char MAN= 'M';
public static final char WALL= 'W';
public static final char MAN_FLAG= 'R';
public static final char BOX_FLAG= 'X';
……
//其他代码
//该单元格啥也没有
//该单元格放的是箱子
//红旗,表示箱子的目的地
//搬运工
//墙
//搬运工+ 红旗
//箱子+ 红旗
};
对表 1,说明如下:
1. 第 2,3 行是定义了两个常量,对应于游戏局面固定为 12 行 12 列的约定。
2. 第 5~11 行定义了表示单元格内容的字符常量。这样做是为了增加可读性。
开局是游戏局面的初始状态。上面已经解释,应该采用字符矩阵来存储开局。代码实现
中,我们采用字符串数组来存储它,如表 2 所示。
表 2 存储第一关开局的代码片段
com.yescorp.moveboxgame.GameLevels.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static final String [] LEVEL_1 = {
"WWWWWWWWWWWW",
"W
"W
"W
"W
"W
"W
"W
"W
"W
"W
FW",
W",
W",
WWWW
W",
B
M
W",
W",
W",
W",
W",
W",
"WWWWWWWWWWWW"
};
对表 2 的代码,说明如下:
第 1 行,static 修饰词使得数组 LEVEL_1 成为类级变量。这样,在程序运行期间,
LEVEL_1 只 有 一 份 拷 贝 — — 发 挥 着 全 局 变 量 的 作 用 。 去 掉 static 的 话 , 每 个
GameLevels 类的实例都会有一份 LEVEL_1 的拷贝——这是没有必要的。
第 1 行,final 修饰词使得 LEVEL_1 的值始终保持不变。
第 1 行,String[]表明 LEVEL_1 是一个字符数组。前面约定游戏局面是 12 行 12 列,
那么意味着数组元素个数是 12,每个元素是字符串,字符串的长度是 12。
第 2~13 行表示的是图 3a 矩阵,也就是图 3b 的局面。
表 2 是第一关的开局数据。如何存储第二关呢?答案是定义另一个字符串数组常量,如
表 3 所示。依次类推,我们可以存储第三关,第四关,以及更多关卡的开局。
表 3 存储第二关的开局数据的代码片段
com.yescorp.moveboxgame.GameLevels.java
public static final String [] LEVEL_2 = {
"
"
" WWWWWWW
" W FFB W
" W W B W
" W W W W
" W BMW W
" WFB
W
" WFWWWWW
" WWW
",
",
",
",
",
",
",
",
",
",
1
2
3
4
5
6
7
8
9
10
11
"
"
12
13
14
};
",
"
如表 2,表 3 所示,我们在程序中用硬编码的方式来存储开局。这一做法使得要改动开
局,就需要修改代码,需要重新编译程序。事实上,我们完全可以采用不用重新编译程序的
方法:用文件来存储开局,程序中读文件来加载开局。同学们可以自行尝试用文件存储开局
的方法。
据关卡号获取游戏关卡的开局
什么时候会使用开局数据?答案是,玩家选择关卡,进入游戏界面时,将使用所选关卡
的开局。本系列文章《编写推箱子游戏程序(三)》中讲到,玩家选择关卡后,将把所选的
关卡号传给显示游戏界面的活动(下称之为打游戏活动)。启动打游戏活动之际,要根据传
进来的关卡号得到开局数据,构成游戏关卡的初始状态。
问题来了,怎么根据传进来的关卡号得到开局数据呢?答案是,我们要有从关卡号映射
到开局的代码。也就是说,我们要有这样的代码:传入关卡号 1,返回 LEVEL_1 这个矩阵;
传入关卡 2,返回 LEVEL_2 这个矩阵。完成这一映射功能的代码如表 4 所示。
表 4
GameLevel.java 中,从关卡号映射到开局的代码片段
com.yescorp.moveboxgame.GameLevels.java
1
2
3
4
5
6
7
8
9
10
11
12
13
public static ArrayList OriginalLevels= new ArrayList<>();
//loadGameLevels()的作用是加载关卡列表
public static void loadGameLevels(){
if (OriginalLevels.isEmpty()) {
//存储多个开局的列表
OriginalLevels.add(LEVEL_1);
OriginalLevels.add(LEVEL_2);
//把第 1 关开局添加到开局列表中
//把第 2 关开局添加到开局列表中
}
}
//getLevel()是根据关卡号 level 得到该关卡的开局(用 String[]实现的矩阵)
public static String [] getLevel(int level){
//level 参数是关卡号
loadGameLevels();
returnOriginalLevels.get(level - 1);
//加载关卡列表
//返回关卡号 level 对应的开局。level 从 1 开始编号,列表下标从 0 开始。
}
对表 4,说明如下:
1. 要从关卡号映射到开局,需要构建一个存储开局的列表(与数组类似)。表 4 中,用
ArrayList来实现这个列表,如第 1 行所示。OriginalLevels 是列表的名字。
2. 第 3~8 行的 loadGameLevels()方法的作用是往列表内填充每个关卡的开局。必须先填充
第 1 关的开局,接着第 2 关的开局,接着第 3 关(如有的话),….,顺序不能乱——这
样才保证列表的下标对应关卡号。第 4 行的 if 语句的作用是:即使 loadGameLevels()被
调用多次,也不会重复填充开局。
3. 第 5 行,OriginalLevels 是开局列表,add(LEVEL_1)是把第 1 关的开局添加到开局列表中。
4. 第 6 行,OriginalLevels 是开局列表,add(LEVEL_2)是把第 2 关的开局添加到开局列表中。
5. 根据第 3,4 点,你应该能知道如何添加第 3, 4, …的开局到开局列表中。
6. 第 10~13 行的 getLevel()方法的作用是根据输入的关卡号 level,得到该关卡的开局数据。
关卡号从 1 开始编号,列表下标从 0 开始编号。因此,第 12 行有 level-1 的写法。
7. 第 11 行,调用 loadGameLevels()来加载关卡列表。第 4 行保证:如果以前没有加载过,
那么就加载之;如果以前加载过,那么就不会再次加载。
8. 第 12 行,第 level 关对应的列表下标是 level-1。OriginalLevels.get(level-1)是获取第 level
关的开局。开局是用 String[]实现的二维矩阵。
9. 第 5 行的 add()和第 12 行的 get()方法是 ArrayList 类的成员方法。查阅 Android SDK 参考
文档可知它们的详细用法。
修改选择关卡功能的代码
本系列文章《编写推箱子游戏程序(三)》描述了实现选择关卡功能的代码。这部分代
码(位于 GameLevelActivity.java)有两处值得修改,见表 5。
1. 删除第 3 行。第 3 行设定的 4 个关卡是“编造”的——压根儿没有跟关卡的开局关联起
来。
2. 用第 12 行替换第 11 行。第 12 行调用了 GameLevels 类的 getLevelList()方法。getLevelList()
方法的作用是获取关卡名列表,代码如表 6 所示。
表 5 修改选择关卡功能的代码
com.yescorp.moveboxgame.GameLevelActivity.java
public class GameLevelActivity extends AppCompatActivity {
String[]levelList=newString[]{"第1关","第2关","第3关","第4关"};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_game_level);
GridView gv_levels = (GridView) findViewById(R.id.gv_levels);
ArrayAdapterarrayAdapter=newArrayAdapter(this,R.layout.gv_levels_item_textview,levelList);
ArrayAdapter arrayAdapter = new ArrayAdapter(this, R.layout.gv_levels_item_textview, GameLevels.getLevelList());
gv_levels.setAdapter(arrayAdapter);
gv_levels.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
Intent intent = new Intent(GameLevelActivity.this, GameActivity.class);
intent.putExtra(GameActivity.KEY_SELECTED_LEVEL, i + 1);
startActivity(intent);
}
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
}
表 6 是 GameLevels 类的 getLevelList()方法的代码,说明如下:
1. 这一方法将返回一个字符串列表,类型是 List,如第 1 行所示。返回值用作创建
ArrayAdapter 时要传入的第 3 个参数(见表 5 第 12 行)。
2. 第 2 行调用 loadGameLevels()。如果以前没有把关卡开局填充到开局数组中,这一步将
完成填充;如果以前填充过,那么这一步将什么也不做(见表 4 的第 3~8 行)。
3. 第 3 行,定义 levelList 来存储关卡名列表。方法执行到最后,将返回这个关卡名列表。
4. 第 4 行,得到关卡数目,用 levelNum 存储。OriginalLevels 是存储关卡开局的列表,
OriginalLevels.size()是返回这个列表的元素个数。
5. 第 5~7 行,作用是生成包含“第 1 关”, “第 2 关”,…这样的关卡名的列表——关卡名
列表。
6. 第 6 行,作用是把关卡名添加到关卡名列表中。假如 i=1,那么就是把“第 1 关”这个
名称加入到关卡名列表中;假如 i=2,那么就是把“第 2 关”这个名称加入到关卡名列
表中。
7. 第 9 行,返回关卡名列表。
表 6 GameLevels 类的 getLevelList()方法
com.yescorp.moveboxgame.GameLevels.java(部分)
1
2
3
4
5
6
7
8
9
10
public static List getLevelList(){
//返关卡名列表(用 List实现这个列表)
//加载关卡列表
loadGameLevels();
List levelList = new ArrayList<>();
int levelNum = OriginalLevels.size();
for (int i = 1; i <= levelNum; i++){
//创建关卡名列表对象 levelList,并分配存储空间
//得到关卡的数目
//对每一关 i (i=1, 2, …),
levelList.add(new String("第" + i + "关"));
//把关卡名(如第 1 关,第 2 关)加入到关卡名列表
}
return levelList;
//返回关卡名列表
}
注意,表1、表2、表3、表4、表6 合并在一起才构成GameLevels 类的完整代码。
到这一步,我们做到了:
1. 选择关卡界面显示的关卡名列表中,每一关都关联着一个游戏开局。
2. 玩家选择关卡后,将获得该关卡的开局数据。
下一步,我们将显示关卡的开局。
存储和绘制游戏局面
存储游戏局面
玩家在游戏界面上指挥搬运工走动后,游戏局面将发生变化。如何存储不断变化的游戏