Mokepon代码导读
第五站 · 收尾与考试L4约 30 分钟

15逃跑、回主菜单、走到出口

Run away 的两条路、Main menu、Exit、游戏结束:完整流程(考试 Q3 样题)

读完这一章你能
  • 说出“逃跑”的两条路:遇敌框里的 Run Away! 和战斗中菜单的 Run away(Ctrl+R),各自经过哪些类、哪些方法,为什么最后都落到同一个 COverworld.runAway()
  • 解释 Run away 为什么只在战斗进行中能用:canRunAway()、CGameMenu.update 和 showResult 里的 updateMenu()
  • 说出打赢(winEncounter)和逃跑(runAway)在数据上有哪些不同
  • 说出 Main menu、走到出口、输掉、Exit / Quit 各自调用哪个方法,为什么出口框和 Game Over 框是同一个方法造的
  • 画出整个游戏的状态图,每条箭头都说得出方法名
  • 回答样题 Q3:为了加上“逃跑”,从 Project 1 到 Project 2 改了什么;哪些旧设计帮了忙、哪些拖了后腿;现在好不好扩展

前面几章讲的都是“进去”:选好 Mokepon 进地图(第 7 章),踩感叹号遇敌(第 9 章),打一个回合(第 11 章)。这一章讲“出来”:逃跑、打赢回地图、回主菜单、走到出口、输掉、关掉程序。每一种“出来”都只有几行代码,但它们分散在好几个类里。读完这一章,你应该能把整个游戏的所有画面和所有箭头一口气画出来。考试的样题 Q3 问的就是“逃跑”,最后一节专门讲它。

先提醒几个前面讲过的词。接口(interface) IGameNavigator 列出了画面和菜单能请窗口做的 7 件事,CGameWindow 实现它(第 6 章第 2 节)。菜单项(MenuItem)是菜单里能点的一行;快捷键(accelerator)是不打开菜单也能用的组合键(第 6 章第 5 节)。监听者(listener)是登记在 COverworld 上、等它通知的对象,这里就是地图画面(第 8 章第 6 节)。lambda 是先存起来、以后才运行的一小段代码(第 2 章第 8 节)。

截图来自三局真实运行。A 局:Impike,捡到 Poison,第一次遇敌就逃,第二次打赢 Galecrest,最后走到出口。B 局:Shellurk,打到一半用菜单逃跑,然后回主菜单。E 局:Owlet,输掉。敌人是随机挑的,所以每局的敌人都不一样。

1. 先看:往回走和结束的 7 种方式

这一章讲的是“往回走”和“结束”:逃跑、打赢回地图、回主菜单、结束程序。代码里能做这些事的按钮和菜单项一共 7 个。往前走的 Fight! 和 Choose Mokepon 前面讲过(第 9 章、第 6 章第 8 节),这里不算。下面把这 7 个全部找出来:

#你做了什么最后调用 → 画面
1遇敌框的 Run Away!world.runAway(),画面不换
2菜单 Run away / Ctrl+RrunAway():战斗 → 地图
3You Win 框的 Back to it!winBattle():战斗 → 地图
4菜单 Main menu / Ctrl+BreturnToMainMenu():→ 选择界面
5出口框、Game Over 框的 Return to Main MenureturnToMainMenu():→ 选择界面
6同样两个框的 Quitquit():程序结束
7菜单 Exit / Alt+F4quit():程序结束

编号是按“做什么”排的:1、2 逃跑,3 打赢回地图,4、5 回主菜单,6、7 结束程序。所以菜单那张图上是 2、4、7。后面整章说“N 号路”,指的都是这张表的编号。每一节截图上的红色小编号只在那一节里有效,那一节开头会说明。

那段 lambda 写在哪个类:1 号在 COverworldScreen 的 onEncounter 里,2、4、7 号在 CGameMenu 的构造器里,3 号在 CBattleScreen 的 showResult 里,5、6 号在父类 AGameScreen 的 createGameEndDialog 里。看这张表能发现两件事:

  1. 7 个里有 6 个,最后调用的都是 CGameWindow 的方法(表里 2–7 号的 runAway()、winBattle()、returnToMainMenu()、quit())。因为只有窗口能换画面(showScreen 是它的 private 方法),也只有它负责结束程序。可是按钮和菜单并不认识 CGameWindow,它们只认识接口 IGameNavigator,所以代码里写的都是 navigator.xxx() 或 getNavigator().xxx()。
    为什么有两种写法?CGameMenu 的 navigator 是它构造器的参数,lambda 直接用它。画面的 navigator 存在父类 AGameScreen 的 private 字段里(GameScreen.java 第 13 行),子类看不到这个字段,只能调用 protected 方法 getNavigator() 去拿。createGameEndDialog 是写在 AGameScreen 自己里面的,所以它可以直接写 navigator。
  2. 只有 1 号不经过窗口。遇敌框的 Run Away! 不用换画面,地图画面自己找 COverworld 就够了。

还有一个共同点:每个按钮、每个菜单项都是“先登记、后运行”。造它的时候就用 setOnAction(或者 CModalDialog.addButton)存进一个 lambda;你点它的时候,JavaFX 才运行这个 lambda。

另外,点窗口右上角的 × 也会关掉程序:JavaFX 关掉最后一个窗口时,程序就结束了。但这条路不经过 quit(),CGameWindow 里没有任何代码管它(没有 setOnCloseRequest),所以不算在 7 个里。下面一节一节看。

2. 逃跑之一:遇敌框里的 Run Away!

先看最简单的一条路。第 9 章第 8 节已经细讲过,这里拿出来,是为了和第 3 节的菜单逃跑对比。A 局里,Impike 从 (18,17) 往左踩上 (17,17) 的绿色感叹号,遇到 Hexwing,点了 Run Away!。这一节的编号:1 Run Away! 按钮,2 玩家,3 感叹号。前后两张图裁的是同一块:

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
12
  1. 1
    点这个:Run Away!
  2. 2
    点之前:玩家在 (17,17),盖住了感叹号(框后面有一层半透明黑色,所以暗)
点之前:遇敌框开着真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
23
  1. 2
    点之后:玩家回到 (18,17)
  2. 3
    感叹号还在 (17,17)
点之后:框没了,画面还是地图真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
OverworldScreen.onEncounter:造遇敌框的时候,就把 Run Away! 要做的事存好了ui/OverworldScreen.java第 79–94 行
79 @Override
80 public void onEncounter(Encounter encounter) {
81 ModalDialog dialog = new ModalDialog("Encountered a " + encounter.getEnemy().getName());
82 for (String message : encounter.getTerrainMessages()) {
83 dialog.addMessage(message);
84 }
85 dialog.addButton("Fight!", event -> {
86 closeDialog();
87 getNavigator().startBattle();
88 });
891 dialog.addButton("Run Away!", event -> {
90 closeDialog();
912 world.runAway();
92 });
93 showDialog(dialog);
94 }
Overworld.runAway:规则这边只有三行world/Overworld.java第 86–91 行
86 /* Leave the encounter and go back to the tile before it. The marker stays on the map. */
87 public void runAway() {
88 encounter = null;
892 player.moveBack();
90 notifyListeners(listener -> listener.onPlayerMoved());
91 }
Player:一直多记着一个 previousPositionworld/Player.java第 12–14、40–48 行
12 private Position position;
13 /* The tile before the last move, so the player can run back to it. */
14 private Position previousPosition;
⋯ 省略第 15–39 行
40 void moveTo(Position newPosition) {
41 previousPosition = position;
42 position = newPosition;
43 }
44
45 /* Go back to the tile the player stood on before the last move. */
46 void moveBack() {
47 position = previousPosition;
48 }
  1. 第 88 行 encounter = null:这次遇敌作废。字段 encounter 原来指着 Hexwing 那个 CEncounter 对象,现在谁也不指着它了。
  2. 第 89 行 player.moveBack():CPlayer 第 47 行 position = previousPosition。previousPosition 是上一次 moveTo 时第 41 行存下的旧位置 (18,17),所以玩家回到 (18,17)。
  3. 第 90 行通知所有监听者 onPlayerMoved()。地图画面收到后清空状态栏、重画地图,玩家就出现在 (18,17)。

感叹号为什么还在?因为 runAway 根本没碰 objects 列表(这一局地图上还剩的物体)。它只是把“这一次遇敌”忘掉,感叹号本身留着。再踩一次会重新挑一只敌人:A 局第二次踩上去,遇到的是 Galecrest。

画成顺序图(sequence diagram)。读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。

玩家CoverworldScreenCworld : OverworldCplayer : Player点 Run Away!1closeDialog()2runAway()3encounter = nullmoveBack()4onPlayerMoved()5update():重画地图6
1 号路:点 Run Away!。只有地图画面、Overworld 和 Player 三个对象,没有 GameWindow,也没有 GameMenu

看到的:三帧

能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
菜单逃跑的前后:战斗画面直接换回地图画面

B 局开战前,Shellurk 是从 (11,12) 往右踩上 (12,12) 的白色感叹号,然后点了 Fight!。所以逃跑以后退回的是 (11,12)。这张截图本章后面还会用几次。

菜单项:Run away 是怎么造的

这一小节的编号:1 菜单里 Run away 这几个字,2 右边的快捷键。

战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
12
  1. 1
    第 20 行:new MenuItem("Run away"),菜单里的这一行字
  2. 2
    第 21 行:快捷键 Ctrl+R。这几个字是 JavaFX 按 setAccelerator 自动写上去的
B 局战斗中打开的 Game 菜单真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
GameMenu 的构造器:Run away 这个菜单项ui/GameMenu.java第 14–22 行
14public class GameMenu {
15 private final MenuBar menuBar;
16 private final MenuItem runAway;
17 private final MenuItem mainMenu;
18
19 public GameMenu(GameNavigator navigator) {
201 runAway = new MenuItem("Run away");
212 runAway.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
22 runAway.setOnAction(event -> navigator.runAway());

navigator 是构造器的参数,类型是接口 IGameNavigator。CGameMenu 不知道、也不需要知道真正收到调用的是谁。它是 CGameWindow 构造器第 39 行 menu = new GameMenu(this); 造的,传进去的 this 就是那个 CGameWindow。构造器早就运行完了,lambda 还能用 navigator,因为 lambda 会记住它用到的变量(变量捕获,第 6 章第 5 节)。

接口和实现:GameNavigator.runAway → GameWindow.runAway

GameNavigator:接口里只有一句ui/GameNavigator.java第 20–21 行
20 /* Cancel the battle and go back to the tile before the encounter. */
21 void runAway();

接口第 21 行 void runAway(); 没有方法体,只说“有这件事”。第 20 行注释写的是要求:“Cancel the battle and go back to the tile before the encounter.” 真正做事的是 CGameWindow。这一小节的编号:1 玩家。

GameWindow.runAway:真正做事的两行ui/GameWindow.java第 76–80 行
76 @Override
77 public void runAway() {
781 world.runAway();
79 showScreen(overworldScreen);
80 }
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
1
  1. 1
    第 78 行的结果:玩家退回 (11,12)
第 79 行 showScreen 以后,窗口里就是这张地图真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
GameNavigator 的 UML 类图
打印稿里的 GameNavigator:圆圈里的 I = 接口;7 个方法都是绿色圆点(public),名字是斜体(只有名字、没有方法体,要由实现它的类来写)。runAway() 和 updateMenu() 都在这里

一步一步:从 Ctrl+R 到地图出现

下面的动画把 2 号路慢放一遍:CGameMenu → CGameWindow → COverworld → CPlayer,最后菜单再问一次地图画面。最上面是顺序图,下面是那一刻运行的代码,再下面是那一刻的屏幕。图里的编号:1 菜单里的 Run away,2 玩家,3 (12,12) 的感叹号,4 还显示在窗口里的战斗画面。

CGameMenuCGameWindowCworld : OverworldCplayer : PlayerCoverworldScreenrunAway()1runAway()2moveBack()3onPlayerMoved()4showScreen(overworldScreen)5update(currentScreen)6canRunAway() → false7
GameMenu 构造器ui/GameMenu.java第 19–22 行
19 public GameMenu(GameNavigator navigator) {
20 runAway = new MenuItem("Run away");
21 runAway.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
22 runAway.setOnAction(event -> navigator.runAway());
GameWindow.runAwayui/GameWindow.java第 76–80 行
76 @Override
77 public void runAway() {
78 world.runAway();
79 showScreen(overworldScreen);
80 }
GameWindow.updateMenu 和 showScreenui/GameWindow.java第 96–105 行
96 @Override
97 public void updateMenu() {
98 menu.update(currentScreen);
99 }
100
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }
Overworld.runAwayworld/Overworld.java第 86–91 行
86 /* Leave the encounter and go back to the tile before it. The marker stays on the map. */
87 public void runAway() {
88 encounter = null;
89 player.moveBack();
90 notifyListeners(listener -> listener.onPlayerMoved());
91 }
Player.moveTo 和 moveBackworld/Player.java第 40–48 行
40 void moveTo(Position newPosition) {
41 previousPosition = position;
42 position = newPosition;
43 }
44
45 /* Go back to the tile the player stood on before the last move. */
46 void moveBack() {
47 position = previousPosition;
48 }
OverworldScreen.onPlayerMoved 和 updateui/OverworldScreen.java第 72–77、133–140 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
⋯ 省略第 78–132 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
138 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
GameMenu.updateui/GameMenu.java第 46–49 行
46 public void update(GameScreen screen) {
47 runAway.setDisable(!screen.canRunAway());
48 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }
GameScreen.canRunAway:父类的默认答案ui/GameScreen.java第 41–44 行
41 /* Whether the "Run away" option can be used. Only the battle screen allows it. */
42 public boolean canRunAway() {
43 return false;
44 }
战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
1
  1. 1
    Run away:亮,能点
真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
4
  1. 4
    还是战斗画面(打开菜单之前拍的,菜单收起来后一模一样)
真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
23
  1. 2
    玩家:(11,12)
  2. 3
    感叹号:还在 (12,12)
真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
地图上打开 Game 菜单:Run away 灰,Main menu 可点
1
  1. 1
    Run away:灰(A 局地图上的菜单;地图画面的答案都一样)
真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
  1. 你在战斗中按 Ctrl+R(或者打开 Game 菜单点 Run away)。这个菜单项是 CGameMenu 构造器第 20 行造的,第 22 行给它存了一个 lambda。现在 JavaFX 运行这个 lambda:navigator.runAway()。
  2. navigator 指着的对象就是 CGameWindow(第 39 行 new GameMenu(this) 交进去的 this)。所以接口方法 runAway() 运行的是 CGameWindow 第 77 行这个版本。菜单收起来了,窗口里显示的还是战斗画面。
  3. 第 78 行 world.runAway()。CGameWindow 自己不管玩家站在哪,它把这件事交给 COverworld 去做,这叫委托(delegation)。字段 world 指着 B 局的 COverworld。
  4. 来到 COverworld.runAway。第 88 行 encounter = null:和 Nightwing 的这次遇敌作废。objects 列表没有被碰,所以 (12,12) 的感叹号还算在地图上。
  5. 第 89 行 player.moveBack()。当初你从 (11,12) 踩上 (12,12) 时,moveTo 第 41 行把 (11,12) 存进了 previousPosition。打仗期间玩家没有走动,它一直是 (11,12)。现在第 47 行把它放回 position。
  6. COverworld 第 90 行通知监听者,地图画面的 onPlayerMoved() 运行:第 75 行清空状态栏,第 76 行 update(),第 135 行 mapView.update() 把玩家画到 (11,12),第 136–139 行更新卷轴和方向按钮。可是这个地图画面还没放进窗口:它在后台改好了,你看到的仍然是战斗画面。
  7. 回到 CGameWindow 第 79 行 showScreen(overworldScreen)。第 102 行 currentScreen = screen:以后的按键交给地图画面。第 103 行 root.setCenter(...):窗口中间换成地图画面的 view。这时你才看到地图(左边是换之前,右边是换之后):玩家在 (11,12),感叹号还在 (12,12)。
  8. 第 104 行 updateMenu() → 第 98 行 menu.update(currentScreen)。画面换了,菜单要重新问一次新画面“你允许什么”。
  9. CGameMenu.update 第 47 行问 screen.canRunAway()。COverworldScreen 没有改写这个方法,所以运行父类 AGameScreen 第 42–44 行,返回 false。!false = true,setDisable(true):Run away 变灰,Ctrl+R 也不起作用了。
  10. 做完了。回头看:BattleScreen.java 和 Battle.java 里的代码一行都没运行(按 Ctrl+R 时,父类 AGameScreen 的 handleKeyPress 在战斗画面身上跑了一下,看到按着 Ctrl 就 return 了),没有人去通知那场战斗“你被取消了”。那场战斗去哪了?看下一小节。

那场战斗去哪了?

CGameWindow 没有存战斗画面的字段。战斗期间指着 CBattleScreen 的只有 currentScreen,而 showScreen(overworldScreen) 第 102 行把它换成了地图画面。于是战斗画面没有人指着了。它的字段 battle 还指着 CBattle,CBattle 的字段还指着两个 CFighter,可是这条链子的头(战斗画面)已经没人能找到,所以从 CGameWindow 出发,顺着字段再也走不到它们。COverworld 的 encounter 也已经是 null,那个 CEncounter 对象同样没人指着。Java 会自动回收这些再也找不到的对象(垃圾回收):

游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowuiworld : Overworldplayer : PlayeroverworldScreen: OverworldScreenBattleScreen 对象Encounter 对象inventory : InventoryBattle 对象Fighter ×2worldplayerinventorycurrentScreenoverworldScreenworldcurrentScreen(已改指地图画面)encounter(已设成 null)battleplayerenemyinventory
逃跑以后谁还指着谁。空心菱形实线 = 字段现在指着它,线上是字段名。灰色虚线箭头 = 刚刚断开的引用:这个字段原来指着它,现在不指了。虚线框:从 GameWindow 顺着字段还走得到,留下。实线、没有 C 圆圈的框:从 GameWindow 已经走不到了,等着被回收。Battle 的 inventory 和 Player 的 inventory 是同一个对象

所以逃跑以后什么都不用“恢复”。战斗里变的属性和效果,全都在那两个 CFighter 里(只有道具数量例外,见下面的提醒);你的 Mokepon 从来没被改过。B 局的日志可以看到 Shellurk 的 Accuracy 一直在涨。图上的 1 是涨 Accuracy 的那几行日志,涨出来的数存在代码第 25 行的 stats 里:

战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
111
  1. 1
    Shellurk 的 Aim:+1、+7、+5。每一条的上面一行都写着 Player's Shellurk used Aim!(第一次的 +3 已经滚到上面去了;Enemy Nightwing 那几行是敌人自己涨的)
逃跑前的日志(只显示最后几行)真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
Fighter:战斗里的数存在它自己的 stats 里battle/Fighter.java第 14–16、21–25、31–38 行
14/* A Mokepon taking part in one battle. Its stats and effects can change during the battle,
15 * but the Mokepon it was made from never changes. When the battle ends this object is
16 * thrown away, so the stats go back to their base values and the effects are cleared.
⋯ 省略第 17–20 行
21 private final Mokepon mokepon;
22 /* The stats at the start of this battle, after the terrain effect. */
23 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
251 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
⋯ 省略第 26–30 行
31 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
32 this.mokepon = mokepon;
33 for (Stat stat : Stat.values()) {
34 int value = stat.limit(terrainEffect.adjust(mokepon.getStat(stat)));
35 startingStats.put(stat, value);
36 stats.put(stat, value);
37 }
38 }

四次 Aim 一共 3 + 1 + 7 + 5 = 16 点。开打时 CFighter 的 Accuracy 是多少?构造器第 34 行先用地形效果调整 Mokepon 的值。Shellurk 是 NORMAL 类型,没有强弱地形,所以它拿到的 terrainEffect 是 NEUTRAL,倍数是 1:Math.round(62 × 1) = 62,不变。62 + 16 = 78。这个 78 存在 CFighter 的 stats(第 25 行)里,它是构造器从 Mokepon 抄过来的一份。下次再打,CEncounter 会重新 new Fighter(playerMokepon, ...),Accuracy 又从 62 开始。类注释第 15–16 行自己就写了这件事:“When the battle ends this object is thrown away, so the stats go back to their base values and the effects are cleared.”

为什么说 Mokepon 不能被改?只说“字段是 final”还不够:final 只保证字段不会换成别的对象。一个 final 的 Map,里面的内容照样能改。真正的保证在 CMokepon 的构造器和 get 方法里:

Mokepon:自己留一份副本,外面只能拿到 intmodel/Mokepon.java第 8–26、40–42 行
8/* A Mokepon as it is written in the data file. Its stats never change.
9 * A battle uses a Fighter made from it, so the Mokepon itself stays the same.
10 */
11public class Mokepon {
12 private final String name;
13 private final String imagePath;
14 private final MokeponType type;
15 private final Map<Stat, Integer> stats;
16 private final List<Skill> skills;
17
18 /* Keep our own copies of the stats and skills, so no other class can change them. */
19 public Mokepon(String name, String imagePath, MokeponType type,
20 Map<Stat, Integer> stats, List<Skill> skills) {
21 this.name = name;
22 this.imagePath = imagePath;
23 this.type = type;
24 this.stats = new EnumMap<>(stats);
25 this.skills = List.copyOf(skills);
26 }
⋯ 省略第 27–39 行
40 public int getStat(Stat stat) {
41 return stats.get(stat);
42 }

4. 为什么只有战斗进行中才能逃跑

打开 Game 菜单看 Run away:在地图上是灰的,在战斗中是亮的。下面两张裁的是同一块:

地图上打开 Game 菜单:Run away 灰,Main menu 可点
1
  1. 1
    地图上:Run away 灰,点不了
A 局,刚进地图真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
1
  1. 1
    战斗中:Run away 亮
B 局,战斗中真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)

菜单自己不知道现在是哪个画面。每次需要的时候,它就问当前画面一个问题:“你允许逃跑吗?”这个问题就是方法 canRunAway()。父类给了默认答案,只有 CBattleScreen 改写了它:

GameScreen:默认答案 falseui/GameScreen.java第 42–44 行
42 public boolean canRunAway() {
43 return false;
44 }
BattleScreen:改写成“战斗还没结束”ui/BattleScreen.java第 102–105 行
102 @Override
103 public boolean canRunAway() {
104 return !battle.isFinished();
105 }
GameMenu.update:把答案反过来交给 setDisableui/GameMenu.java第 43–49 行
43 /* Turn the options on or off to match the screen being shown.
44 * A disabled option also ignores its keyboard shortcut.
45 */
46 public void update(GameScreen screen) {
47 runAway.setDisable(!screen.canRunAway());
48 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }

打完的那一刻:画面没换,菜单也得更新

菜单平时只在 showScreen 的最后一行(updateMenu())去问画面。可是战斗打完的那一刻,画面没有换,还是同一个 CBattleScreen,showScreen 不会运行。所以 CBattleScreen 自己请窗口再问一次。这一小节的编号:1 You Win 框,2 技能按钮。

敌人倒下:敌人卷轴变红,弹出 You Win 对话框
12
  1. 1
    第 173 行造的 You Win 框。它出来之前,第 171 行已经让 Run away 变灰了(菜单没打开,所以图上看不到)
  2. 2
    技能按钮也全灰了:这是第 151 行 updateSkillButtons 做的,它同样看 isFinished()
A 局打赢的那一刻真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
BattleScreen:updateSkillButtons 和 showResult(showResult 第一件事就是 updateMenu())ui/BattleScreen.java第 147–154、167–179 行
147 /* A skill can only be used while the battle is running and there is enough energy. */
148 private void updateSkillButtons() {
149 List<Skill> skills = battle.getPlayer().getSkills();
150 for (int i = 0; i < skillButtons.size(); i++) {
1512 boolean usable = !battle.isFinished() && battle.getPlayer().canUse(skills.get(i));
152 skillButtons.get(i).setUsable(usable);
153 }
154 }
⋯ 省略第 155–166 行
167 /* Show the winning or the losing dialog.
168 * The battle is over, so the menu is updated to turn off "Run away".
169 */
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
1731 ModalDialog dialog = new ModalDialog("You Win");
174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());
175 showDialog(dialog);
176 } else {
177 showDialog(createGameEndDialog("Game Over"));
178 }
179 }

updateMenu() 是 IGameNavigator 的第 7 个方法,它的注释(第 29–31 行)说的正是这件事:“when the current screen changes what it allows, for example when a battle ends.” CGameWindow 实现它只有一行 menu.update(currentScreen)。currentScreen 还是这个 CBattleScreen,但这次 isFinished() 是 true,canRunAway() 返回 false,Run away 变灰。CGameMenu.update 上面的注释还有一句:“A disabled option also ignores its keyboard shortcut.” 变灰以后,按 Ctrl+R 也没用。

时刻谁让菜单去问currentScreencanRunAway()Run away
点 Fight!,战斗画面出现showScreen → updateMenu()新的 CBattleScreen!false = true亮
有人倒下,战斗结束showResult → updateMenu()同一个 CBattleScreen!true = false灰
回到地图(赢了或逃了)showScreen → updateMenu()COverworldScreenfalse(父类默认)灰

如果打完还能逃,会怎样?

CBattleScreen 第 99–101 行的注释给的理由是:“Running away cancels the battle, so it is not possible once the battle is over.” 你的 README 里也写了这条假设:打赢或打输以后 Run away 变灰,“because there is nothing left to run from”。用代码推一推,就知道它挡住了什么:

“什么时候能逃”这条规则只写在 CBattleScreen 的一个方法里;菜单只管照着答案变灰变亮。

5. 打赢以后:Back to it!

A 局里 Impike 用 Lance 打出 83 点伤害,Galecrest 倒下,弹出 You Win 框。点 Back to it! 回到地图。这一节的编号:1 Back to it! 按钮,2 (17,17) 那一格,3 Fights,4 状态栏,5 Poison 的数量。回到地图以后,和逃跑不一样的地方有这几处:

点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示2345
  1. 2
    玩家还站在 (17,17),脚下的绿色感叹号没了(逃跑的话会退回上一格)
  2. 3
    Fights: 1(原来是 0)
  3. 4
    状态栏:You beat the enemy Galecrest!
  4. 5
    Poison: 0:战斗里用掉了,不会回来
点 Back to it! 以后的地图真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
向下一格到 (18,17),左边就是绿色感叹号(weak marker)
35
  1. 3
    Fights: 0
  2. 5
    Poison: 1(刚从问号箱捡的)
打之前:还没踩感叹号真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
35
  1. 3
    Fights: 1
  2. 5
    Poison: 0
打赢以后真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

下面的动画从“敌人倒下”开始慢放。和逃跑一样,CGameWindow 先请 COverworld 改规则,再换画面。注意第 3、4 步之间:程序什么都不做,一直等到你点按钮。顺序图里的 world 就是这一局的 COverworld 对象,player 是 CPlayer 对象。动画里的编号和上面一样:1 Back to it!,2 (17,17) 那一格,3 Fights,4 状态栏;另外 6 是敌人的 HP 条。

玩家CbattleScreenCGameWindowCworldCplayerCoverworldScreenshowResult()1updateMenu()2showResult 结束了,等你点按钮点 Back to it!3winBattle()4winEncounter()5addWin()6onEncounterWon(enemy)7showScreen(overworldScreen)8
BattleScreen.showResultui/BattleScreen.java第 170–179 行
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
173 ModalDialog dialog = new ModalDialog("You Win");
174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());
175 showDialog(dialog);
176 } else {
177 showDialog(createGameEndDialog("Game Over"));
178 }
179 }
GameWindow.winBattle 和 showScreenui/GameWindow.java第 70–74、101–105 行
70 @Override
71 public void winBattle() {
72 world.winEncounter();
73 showScreen(overworldScreen);
74 }
⋯ 省略第 75–100 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }
Overworld.winEncounterworld/Overworld.java第 93–100 行
93 /* The battle was won, so remove the marker and count the win. */
94 public void winEncounter() {
95 Mokepon enemy = encounter.getEnemy();
96 objects.remove(encounter.getMarker());
97 player.addWin();
98 encounter = null;
99 notifyListeners(listener -> listener.onEncounterWon(enemy));
100 }
OverworldScreen.onEncounterWon 和 updateui/OverworldScreen.java第 102–106、133–140 行
102 @Override
103 public void onEncounterWon(Mokepon enemy) {
104 statusBar.setText("You beat the enemy " + enemy.getName() + "!");
105 update();
106 }
⋯ 省略第 107–132 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
138 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
MapView.update:只画还在列表里的物体ui/MapView.java第 50–59 行
50 /* Show only the objects that are still on the map, and move the player's image.
51 * Everything is redrawn from the world, so nothing old is left on the screen.
52 */
53 public void update() {
54 objectLayer.getChildren().clear();
55 for (MapObject object : world.getObjects()) {
56 objectLayer.getChildren().add(objectViews.get(object));
57 }
58 placeOnTile(playerView, world.getPlayer().getPosition());
59 }
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    Back to it!
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
6
  1. 6
    Galecrest 的 HP 条空了(HP 0),卷轴变红
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
24
  1. 2
    (17,17):感叹号没了,玩家还在这一格
  2. 4
    You beat the enemy Galecrest!
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
3
  1. 3
    Fights: 1
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
  1. Lance 让 Galecrest 的 HP 变成 0,CBattle.finish(true) 把 finished 设成 true(第 11 章)。CBattleScreen.update() 第 135–136 行发现“打完了,而且还没有框开着”,就调用 showResult()。
  2. 第 171 行先 getNavigator().updateMenu():菜单再问一次 canRunAway(),这次是 false,Run away 变灰(第 4 节)。
  3. 第 172 行 battle.hasPlayerWon() 是 true,走上半边:第 173 行 new 一个标题为 You Win 的 CModalDialog;第 174 行加一个按钮 Back to it!,存进 lambda event -> getNavigator().winBattle()(这时还不运行);第 175 行 showDialog 把框盖在战斗画面上。showResult 到这里就结束了;之后什么代码都不运行,直到你点按钮。
  4. 你点了 Back to it!,JavaFX 才运行第 174 行存的 lambda:getNavigator().winBattle(),来到 CGameWindow 第 71 行。和 runAway() 一样是两行:第 72 行先改规则,第 73 行再换画面。
  5. COverworld.winEncounter。第 95 行先把敌人存进局部变量 enemy。第 96 行 objects.remove(encounter.getMarker()):把 (17,17) 这个 CMarker 对象从这一局的物体列表里拿掉。下图是结果(地图要等最后一步才放回窗口)。
  6. 第 97 行 player.addWin():CPlayer 第 51 行 fightsWon++(++ 就是“加 1”),0 变 1。第 98 行 encounter = null:这次遇敌结束了。
  7. COverworld 第 99 行通知 onEncounterWon(enemy)。这里用的是第 95 行存下的 enemy,因为第 98 行已经把 encounter 设成了 null,这时再写 encounter.getEnemy() 就会出错。地图画面第 104 行写状态栏:“You beat the enemy ” + 名字 + “!”,第 105 行 update()。
  8. 第 105 行的 update() 里,先是 mapView.update():第 54 行清空物体层,第 55–57 行只为 world.getObjects() 里还在的物体放图片。(17,17) 的感叹号已经不在列表里,就不画了。接着 statsScroll.update() 把 Fights 改成 1。
  9. 最后 CGameWindow 第 73 行 showScreen(overworldScreen):地图回到窗口,感叹号、状态栏、Fights 三处变化一起出现;updateMenu() 让 Run away 保持灰色。带着 You Win 框的那个 CBattleScreen,和逃跑时一样,没有人再指着它了。

把赢和逃放在一起比,考试很爱问:

逃跑:runAway()打赢:winEncounter()
encounter设成 null设成 null
感叹号留在 objects 里,能再踩objects.remove(...),没了
玩家位置moveBack():退回上一格不动:站在感叹号那一格
fightsWon不变addWin():加 1
通知监听者onPlayerMoved()onEncounterWon(enemy)
状态栏清空You beat the enemy …!
谁调用它CGameWindow.runAway(),或者遇敌框的 lambda只有 CGameWindow.winBattle()

注意:拿掉感叹号改的是 COverworld 自己的 objects 列表。它在构造器第 41 行是从地图复制出来的(第 40 行注释:“Copy the objects, so collecting a box or beating a marker never changes the map.”)。所以下一局开始时,CGameMap 里的感叹号一个都不少。

6. 回主菜单,和结束程序

Main menu(Ctrl+B):这一局被扔掉

B 局逃回地图以后,又选了 Game 菜单的 Main menu。回到的选择界面,看起来和程序刚打开时一模一样。这一小节的编号对应下面代码里的行:

选 Main menu:这一局被丢弃,回到选择界面,选择被清空
12345
  1. 1
    名字变回提示语 Select a Mokepon(第 132 行)
  2. 2
    圆圈里没有图片(第 131 行)
  3. 3
    属性卷轴:右边的值全是空的(第 134–138 行)
  4. 4
    Choose Mokepon 是灰的(第 139 行)
  5. 5
    名单里没有哪个按钮是“选中”的深色(第 126–127 行)
B 局选了 Main menu 以后真实运行截图 · 选 Main menu:这一局被丢弃,回到选择界面,选择被清空
SelectScreen.clearSelection:把选择界面恢复原样ui/SelectScreen.java第 124–140 行
124 /* Return the screen to how it looked at the start. */
125 public void clearSelection() {
126 if (selectedButton != null) {
1275 selectedButton.setHighlighted(false);
128 }
129 selectedButton = null;
130 selectedMokepon = null;
1312 portraitView.setImage(null);
1321 nameLabel.setText(PROMPT);
133 showTypeIcon(false);
134 for (Label value : statValues.values()) {
1353 value.setText("");
136 }
137 skillsValue.setText("");
138 typeValue.setText("");
1394 chooseButton.setUsable(false);
140 }

为什么要“清空”?因为选择界面从来不重新造。它是 CGameWindow 构造器第 41 行造的,存在 final 字段 selectScreen 里,整个程序只有这一个。上次选 Shellurk 留下的高亮、图片、属性都还在它身上,所以回来时要一个个擦掉。

是谁调用了 clearSelection?顺着 4 号路走一遍:

玩家CGameMenuCGameWindowCselectScreenMain menu / Ctrl+B1returnToMainMenu()2world = nulloverworldScreen = nullclearSelection()3showScreen(selectScreen)4update(currentScreen)5canReturnToMainMenu() → false6
4 号路:菜单 Main menu(Ctrl+B)。5 号路(框里的 Return to Main Menu)只是起点不同,从第 2 条箭头开始完全一样
GameWindow.returnToMainMenu:把这一局扔掉ui/GameWindow.java第 26–28、82–89 行
26 /* The game being played and its screen. Both are null on the select screen. */
27 private Overworld world;
28 private OverworldScreen overworldScreen;
⋯ 省略第 29–81 行
82 /* The game being played is thrown away. */
83 @Override
84 public void returnToMainMenu() {
85 world = null;
86 overworldScreen = null;
87 selectScreen.clearSelection();
88 showScreen(selectScreen);
89 }
CGameWindow 的字段回主菜单之前之后
world : COverworldB 局的规则对象(Shellurk 在 (11,12))null(第 85 行)
overworldScreen : COverworldScreenB 局的地图画面null(第 86 行)
selectScreen : CSelectScreen程序开始时造的那一个还是那一个,清空了(第 87 行)
currentScreen : AGameScreen地图画面选择界面(第 88 行,经过 showScreen)

第 26 行注释:“The game being played and its screen. Both are null on the select screen.” 两个字段一设成 null,B 局的 COverworld、CPlayer、背包、地图画面就都没人指着了,被垃圾回收。下次点 Choose Mokepon,startGame 再 new 一套全新的(第 59–61 行,包括 addListener)。README 里的假设说的就是这个:“Going back to the main menu throws the current game away.”

三个画面,三种寿命(对象活多久),这是个好考点。下面按时间画出 A 局、B 局里每个画面对象活了多久:

selectScreen : SelectScreen(只有这一个)GameWindow 构造器第 41 行造,一直用到程序结束A 局的 overworldScreenstartGame 造 → returnToMainMenu 扔B 局的 overworldScreenstartGame 造 → returnToMainMenu 扔BattleScreen(打 Galecrest)startBattle 造 → winBattle 扔BattleScreen(打 Nightwing)startBattle 造 → runAway 扔
从左到右是时间:先 A 局,再 B 局。框的长度 = 这个对象活着的时间:只要 GameWindow 的某个字段(包括 currentScreen)还指着它,它就在

CSelectScreen:只有一个

窗口的构造器造一次,final 字段存着,一直重复用。所以回来时要 clearSelection()。

COverworldScreen:一局一个

startGame 造,returnToMainMenu 扔。中间每次打完、逃完都放回同一个,所以 runAway 和 winBattle 能直接 showScreen(overworldScreen)。

CBattleScreen:一场一个

startBattle 第 67 行每次 new 一个,窗口不存它。它的字段 battle 是 final,一个战斗画面只能显示一场战斗。

为什么选择界面上 Main menu 是灰的?上面顺序图的最后一条箭头:CSelectScreen 改写了 canReturnToMainMenu():

SelectScreen:还没有一局可以离开ui/SelectScreen.java第 118–122 行
118 /* There is no game to leave yet, so the main menu option is not needed. */
119 @Override
120 public boolean canReturnToMainMenu() {
121 return false;
122 }

注释说:“There is no game to leave yet, so the main menu option is not needed.” 老实说,就算在这里调用 returnToMainMenu() 也不会出错(两个字段本来就是 null,clearSelection() 只会把你刚选的擦掉),只是没有意义,所以干脆变灰。COverworldScreen 和 CBattleScreen 用父类的默认答案 true,所以在地图上、战斗中、甚至框开着的时候,都能用 Main menu。

Exit(Alt+F4)和 Quit:结束程序

这一小节的编号和第 1 节相同:4 Main menu,7 Exit。

战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
47
  1. 4
    第 24–26 行:Main menu,Ctrl+B,调用 navigator.returnToMainMenu()
  2. 7
    第 28–30 行:Exit,Alt+F4,调用 navigator.quit()
B 局战斗中打开的 Game 菜单真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
GameMenu 构造器:Main menu 和 Exitui/GameMenu.java第 24–30 行
244 mainMenu = new MenuItem("Main menu");
25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));
26 mainMenu.setOnAction(event -> navigator.returnToMainMenu());
27
287 MenuItem exit = new MenuItem("Exit");
29 exit.setAccelerator(new KeyCodeCombination(KeyCode.F4, KeyCombination.ALT_DOWN));
30 exit.setOnAction(event -> navigator.quit());
GameWindow.quit:一行ui/GameWindow.java第 91–94 行
91 @Override
92 public void quit() {
93 Platform.exit();
94 }

Platform.exit() 让 JavaFX 关掉窗口、结束整个程序(Platform.exit)。IGameNavigator 第 26 行注释说了谁会调用它:“Used by the Exit menu item and the Quit buttons.” 两个细节:

7. 走到出口,和输掉:同一个“游戏结束”框

看到的:一路走到门

A 局打赢以后,Impike 一路往上、往左走,最后踩上 (6,0) 的紫色门。下面是途中的真实画面(每两帧之间走了好几步)。白色的星形就是玩家,最上面一行那扇紫色的门就是出口 (6,0):

走向出口途中,现在在 (15,16)走向出口途中,现在在 (13,15)走向出口途中,现在在 (13,12)走向出口途中,现在在 (13,9)走向出口途中,现在在 (13,6)走向出口途中,现在在 (13,3)走向出口途中,现在在 (13,0)走向出口途中,现在在 (10,0)走向出口途中,现在在 (7,0)走到出口的门 (6,0):游戏结束对话框
A 局:打赢 Galecrest 以后走到出口

最后一步前后,裁同一块看。这一节的编号:1 门,2 玩家,3 框的标题;两个按钮用 5 Return to Main Menu、6 Quit,和第 1 节的 5 号路、6 号路是同一个编号(这一节没有 4)。

走向出口途中,现在在 (7,0)
12
  1. 1
    门:一个 CExit 对象,在 (6,0)
  2. 2
    玩家在 (7,0)
按 ← 之前真实运行截图 · 走向出口途中,现在在 (7,0)
走到出口的门 (6,0):游戏结束对话框
2
  1. 2
    玩家站在门上 (6,0)(框后面的黑色层让它变暗)
按 ← 之后真实运行截图 · 走到出口的门 (6,0):游戏结束对话框

一步一步:从按 ← 到结束框

怎么从按键走到 COverworld.move,第 8 章讲过。下面从 move 开始:COverworld 让格子上的物体自己决定做什么,CExit 再请 COverworld 通知画面。这几步都在同一次按键里做完,中间屏幕不会刷新,所以你看到的是上面左图直接变成右图加上结束框。

图里有一种新箭头:倒数第二条是虚线,但箭头头是实心的,指着一条新的生命线。它的意思是“new 出来一个对象”(创建消息 create message),不是返回。返回的虚线是空心箭头头,而且指回调用的人。

CoverworldScreenCworld : OverworldCplayer : PlayerCexit : ExitCendDialogmove(LEFT)1moveTo((6,0))2isAt((6,0)) → true3interact(this)4reachExit()5onExitReached(1)6createGameEndDialog(heading)7new ModalDialog(heading)8showDialog(endDialog)9
Overworld.canMove 和 move 的前半world/Overworld.java第 61–71 行
61 public boolean canMove(Direction direction) {
62 return map.isWalkable(player.getPosition().move(direction));
63 }
64
65 /* Move the player one tile, then let every object on the new tile react. */
66 public void move(Direction direction) {
67 if (!canMove(direction)) {
68 return;
69 }
70 player.moveTo(player.getPosition().move(direction));
71 notifyListeners(listener -> listener.onPlayerMoved());
Overworld.move 的后半world/Overworld.java第 73–79 行
73 /* Loop over a copy, because a mystery box removes itself from the list. */
74 for (MapObject object : new ArrayList<>(objects)) {
75 if (object.isAt(player.getPosition())) {
76 object.interact(this);
77 }
78 }
79 }
Exit.java:门(Exit extends MapObject)world/Exit.java第 3–4、10–13 行
3/* The exit door. Stepping onto it finishes the game. */
4public class Exit extends MapObject {
⋯ 省略第 5–9 行
10 @Override
11 void interact(Overworld world) {
12 world.reachExit();
13 }
Overworld.reachExitworld/Overworld.java第 117–120 行
117 /* Called by the exit when the player steps onto it. */
118 void reachExit() {
119 notifyListeners(listener -> listener.onExitReached(player.getFightsWon()));
120 }
OverworldScreen.onExitReachedui/OverworldScreen.java第 108–112 行
108 @Override
109 public void onExitReached(int fightsWon) {
110 showDialog(createGameEndDialog("You finished the game! You won " + fightsWon
111 + " fights!"));
112 }
GameScreen.createGameEndDialogui/GameScreen.java第 87–93 行
87 /* A dialog for the end of a game, with buttons to go back to the menu or to quit. */
88 protected ModalDialog createGameEndDialog(String heading) {
89 ModalDialog endDialog = new ModalDialog(heading);
90 endDialog.addButton("Return to Main Menu", event -> navigator.returnToMainMenu());
91 endDialog.addButton("Quit", event -> navigator.quit());
92 return endDialog;
93 }
GameScreen.showDialog 和 isDialogOpenui/GameScreen.java第 68–74、83–85 行
68 /* Show a dialog on top of everything else on the screen. */
69 protected void showDialog(ModalDialog newDialog) {
70 closeDialog();
71 dialog = newDialog;
72 view.getChildren().add(dialog.getLayer());
73 dialog.focusFirstButton();
74 }
⋯ 省略第 75–82 行
83 protected boolean isDialogOpen() {
84 return dialog != null;
85 }
走向出口途中,现在在 (7,0)
12
  1. 1
    门 (6,0)
  2. 2
    玩家 (7,0)
真实运行截图 · 走向出口途中,现在在 (7,0)
走到出口的门 (6,0):游戏结束对话框
356
  1. 3
    标题:You finished the game! You won 1 fights!
  2. 5
    Return to Main Menu
  3. 6
    Quit
真实运行截图 · 走到出口的门 (6,0):游戏结束对话框
  1. 玩家在 (7,0) 按 ←,地图画面调用 world.move(LEFT)。第 67 行先问 canMove:第 62 行 player.getPosition().move(direction) 算出左边那一格 (6,0)(move 返回一个新的 CPosition 对象,原来的不变),再问地图这一格能不能走。能走,所以不 return。
  2. 第 70 行 player.moveTo(...):previousPosition 变成 (7,0),position 变成 (6,0)。第 71 行先通知 onPlayerMoved()(图上省掉了这一条箭头):地图画面把玩家画到门上。这时还不知道门上有什么。(屏幕要等这一次按键全部处理完才刷新,所以右边的图还是 (7,0)。)
  3. 第 74–75 行:把物体列表复制一份(new ArrayList<>(objects),空着的尖括号让 Java 自己填类型,菱形语法),一个一个问 isAt(player.getPosition()):“你在 (6,0) 吗?”只有 Exit@6,0 回答 true。复制一份,是因为问号箱被捡走时会把自己从列表里删掉(第 73 行注释)。
  4. 第 76 行 object.interact(this)。this 就是正在运行 move 的这个 COverworld 对象,把它交给门,门才能回头调用它的方法。变量 object 的类型是抽象类 AMapObject,真正运行哪个 interact,看它指着的是哪种物体(多态)。
  5. 这次指着的是 CExit,运行它改写的版本:只有一行第 12 行 world.reachExit(),world 就是刚才交进来的 this。interact 和 reachExit 前面都没写 public,是包内可见:只有 world 包里的类能调用。
  6. 第 119 行通知所有监听者 onExitReached(player.getFightsWon())。A 局赢过一场,所以传进去的是 1。COverworld 不知道画面会怎么显示,它只负责报告。
  7. 地图画面的 onExitReached(1):标题是 "You finished the game! You won " + fightsWon + " fights!"。代码没有区分单数复数,所以屏幕上是 “You won 1 fights!”。
  8. createGameEndDialog 是父类 AGameScreen 的 protected 方法,地图画面在自己身上调用它:第 89 行 new 一个 CModalDialog(图上的虚线实心箭头),第 90 行加 Return to Main Menu(lambda 里是 navigator.returnToMainMenu()),第 91 行加 Quit(navigator.quit()),第 92 行交回这个框。
  9. 第 69–73 行 showDialog:把框的黑色层加到画面最上面,让第一个按钮拿到键盘焦点。从现在起 isDialogOpen() 是 true:按键被 handleKeyPress 挡掉,方向按钮也被 movePlayer 挡掉。门还留在地图上,游戏就停在这里,等你点 5 或 6,或者用菜单。

输掉:同一个方法造的框

E 局里 Owlet 的 HP 变成 0,弹出的是 Game Over 框。它和出口框长得一样,因为是同一个方法造的。编号和上面一样:3 标题,5 Return to Main Menu,6 Quit。

BattleScreen.showResult:下半边是输了ui/BattleScreen.java第 167–179 行
167 /* Show the winning or the losing dialog.
168 * The battle is over, so the menu is updated to turn off "Run away".
169 */
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
173 ModalDialog dialog = new ModalDialog("You Win");
174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());
175 showDialog(dialog);
176 } else {
1773 showDialog(createGameEndDialog("Game Over"));
178 }
179 }
GameScreen.createGameEndDialog:两个画面共用ui/GameScreen.java第 87–93 行
87 /* A dialog for the end of a game, with buttons to go back to the menu or to quit. */
88 protected ModalDialog createGameEndDialog(String heading) {
893 ModalDialog endDialog = new ModalDialog(heading);
905 endDialog.addButton("Return to Main Menu", event -> navigator.returnToMainMenu());
916 endDialog.addButton("Quit", event -> navigator.quit());
92 return endDialog;
93 }
GameScreen 的 UML 类图
打印稿里的 GameScreen:类名是斜体(抽象类,A)。黄色菱形 = protected:createGameEndDialog、showDialog、closeDialog、getNavigator 都是给子类用的;绿色圆点 = public:canRunAway()、canReturnToMainMenu() 是给 GameMenu 问的;红色方块 = private 字段

8. 全图:整个游戏的状态图

现在把所有画面和所有箭头画在一张图上。这种图叫状态图(state diagram):每个框是游戏可能处在的一个“状态”(正在显示哪个画面、有没有框开着),每条箭头是让游戏换状态的事,箭头上第一行是你做了什么,第二行是运行的方法。它和类图不一样:类图画“谁存着谁”,状态图画“现在在哪、下一步能去哪”。

“程序结束”画了两个框,只是为了线不交叉,它们是同一个状态(UML 里的结束状态(final state)本来就可以画好几个)。动画的第 1 步是全图。后面按真实的三局走一遍:先走完 A 局,再看 B 局的菜单逃跑、E 局的输掉。图下面先是那一步运行的代码,再下面是那一步之后的真实屏幕。为了不乱,图上没画“随时都能走”的两条路:菜单 Main menu(Ctrl+B,地图和战斗中都能用)→ returnToMainMenu();菜单 Exit(Alt+F4)→ quit()。它们都在图后面的总表里。

程序结束出口框地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenuiYou Win 框选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui遇敌框战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui程序结束Game Over 框Choose MokeponstartGame()踩到感叹号onEncounter()Run Away!world.runAway()Fight!startBattle()菜单 Run awayrunAway()敌人倒下showResult()Back to it!winBattle()你倒下showResult()踩到门onExitReached()Return to Main MenureturnToMainMenu()Return to Main MenureturnToMainMenu()Quitquit()Quitquit()
整个游戏的状态图:带 C 圆圈的框 = 画面,没有圆圈的框 = 盖在画面上的对话框,或者程序结束
GameWindow 构造器ui/GameWindow.java第 39–42 行
39 menu = new GameMenu(this);
40 root.setTop(menu.getMenuBar());
41 selectScreen = new SelectScreen(mokepons, images, this);
42 showScreen(selectScreen);
GameWindow.startGameui/GameWindow.java第 56–63 行
56 /* Every new game starts with a fresh map, an empty inventory and no fights won. */
57 @Override
58 public void startGame(Mokepon playerChoice) {
59 world = new Overworld(map, mokepons, playerChoice);
60 overworldScreen = new OverworldScreen(world, images, this);
61 world.addListener(overworldScreen);
62 showScreen(overworldScreen);
63 }
GameWindow.startBattleui/GameWindow.java第 65–68 行
65 @Override
66 public void startBattle() {
67 showScreen(new BattleScreen(world.createBattle(), images, this));
68 }
GameWindow.winBattleui/GameWindow.java第 70–74 行
70 @Override
71 public void winBattle() {
72 world.winEncounter();
73 showScreen(overworldScreen);
74 }
GameWindow.runAwayui/GameWindow.java第 76–80 行
76 @Override
77 public void runAway() {
78 world.runAway();
79 showScreen(overworldScreen);
80 }
GameWindow.returnToMainMenuui/GameWindow.java第 82–89 行
82 /* The game being played is thrown away. */
83 @Override
84 public void returnToMainMenu() {
85 world = null;
86 overworldScreen = null;
87 selectScreen.clearSelection();
88 showScreen(selectScreen);
89 }
GameWindow.quitui/GameWindow.java第 91–94 行
91 @Override
92 public void quit() {
93 Platform.exit();
94 }
OverworldScreen.onEncounter(头尾)ui/OverworldScreen.java第 80–81、93–94 行
80 public void onEncounter(Encounter encounter) {
81 ModalDialog dialog = new ModalDialog("Encountered a " + encounter.getEnemy().getName());
⋯ 省略第 82–92 行
93 showDialog(dialog);
94 }
OverworldScreen.onEncounter:Run Away! 按钮ui/OverworldScreen.java第 89–92 行
89 dialog.addButton("Run Away!", event -> {
90 closeDialog();
91 world.runAway();
92 });
OverworldScreen.onExitReachedui/OverworldScreen.java第 108–112 行
108 @Override
109 public void onExitReached(int fightsWon) {
110 showDialog(createGameEndDialog("You finished the game! You won " + fightsWon
111 + " fights!"));
112 }
BattleScreen.showResult:赢了ui/BattleScreen.java第 170–175 行
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
173 ModalDialog dialog = new ModalDialog("You Win");
174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());
175 showDialog(dialog);
BattleScreen.showResult:输了ui/BattleScreen.java第 170–172、176–179 行
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
⋯ 省略第 173–175 行
176 } else {
177 showDialog(createGameEndDialog("Game Over"));
178 }
179 }
GameScreen.createGameEndDialogui/GameScreen.java第 88–91 行
88 protected ModalDialog createGameEndDialog(String heading) {
89 ModalDialog endDialog = new ModalDialog(heading);
90 endDialog.addButton("Return to Main Menu", event -> navigator.returnToMainMenu());
91 endDialog.addButton("Quit", event -> navigator.quit());
游戏刚打开:选择界面,还没选 Mokepon
1
  1. 1
    程序打开:选择界面,还没选
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    起点 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1
  1. 1
    遇敌框:Hexwing
真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
1
  1. 1
    退回 (18,17)
真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
再次踩上感叹号:又遇到一只(敌人可能不同)
1
  1. 1
    又踩一次:Galecrest
真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    新的战斗画面,日志只有第 1 行
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    You Win 框
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
1
  1. 1
    You beat the enemy Galecrest!
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
走到出口的门 (6,0):游戏结束对话框
1
  1. 1
    出口框
真实运行截图 · 走到出口的门 (6,0):游戏结束对话框
点 Return to Main Menu:回到选择界面,之前的选择被清空
1
  1. 1
    回到选择界面,已清空:Choose Mokepon 又是灰的
真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
1
  1. 1
    B 局:退回 (11,12)
真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
1
  1. 1
    E 局:Game Over 框
真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
Game Over 后点 Return to Main Menu,回到选择界面
1
  1. 1
    又回到选择界面,也清空了
真实运行截图 · Game Over 后点 Return to Main Menu,回到选择界面
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
1
  1. 1
    点 Quit:程序结束
真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
  1. 全图。三个带 C 圆圈的框是三个画面;没有圆圈的框,四个是盖在画面上的对话框,两个是“程序结束”。程序一打开,CGameWindow 构造器第 41–42 行造好选择界面并显示它,游戏就停在左边中间的 CSelectScreen 这一格。下一步开始走 A 局。
  2. 选好 Impike,点 Choose Mokepon → startGame():new 一个 COverworld 和一个 COverworldScreen,登记监听者,showScreen。(第 6 章第 8 节)
  3. 踩上 (17,17) 的感叹号:COverworld 通知 onEncounter(encounter),地图画面造遇敌框并 showDialog。画面没换,只是多了一个框。(第 9 章)
  4. 1 号路:点 Run Away! → closeDialog() + world.runAway()。框关掉,玩家退回 (18,17)。(第 2 节)
  5. 再往左踩一次同一个感叹号:又是 onEncounter,这次挑到的是 Galecrest。
  6. 点 Fight!:lambda 先 closeDialog(),再 getNavigator().startBattle()。CGameWindow 第 67 行:world.createBattle() 交出一场新的 CBattle(真正 new 两个 CFighter 和 CBattle 的是 CEncounter.createBattle,第 9 章),再 new 一个 CBattleScreen 放进窗口。
  7. 几个回合以后 Galecrest 倒下:showResult() 先 updateMenu(),再盖上 You Win 框。画面还是 CBattleScreen。(第 4、5 节)
  8. 3 号路:点 Back to it! → winBattle():world.winEncounter() 拿掉感叹号、Fights 加 1,然后放回同一个地图画面。(第 5 节)
  9. 走到门 (6,0):CExit.interact → reachExit() → onExitReached(1),地图画面盖上出口框。(第 7 节)
  10. 5 号路:点 Return to Main Menu → returnToMainMenu():world 和 overworldScreen 设成 null,选择界面清空后放回来。A 局到此结束。(第 6 节)
  11. 换到 B 局:战斗中用菜单 Run away(2 号路)→ runAway():world.runAway(),再 showScreen(overworldScreen)。Shellurk 退回 (11,12)。(第 3 节)
  12. 换到 E 局:Owlet 倒下,showResult() 走下半边:showDialog(createGameEndDialog("Game Over"))。(第 7 节)
  13. 在 Game Over 框点 Return to Main Menu:运行的是 createGameEndDialog 第 90 行存进去的 lambda,和出口框是同一行代码,一样的 returnToMainMenu()。
  14. 如果在 Game Over 框或出口框点的是 Quit(6 号路),或者任何时候用菜单 Exit(7 号路):quit() → Platform.exit(),程序结束。这是图上唯一一个出不来的状态。

状态图上为了不乱,省掉了“随时都能走”的几条路。下面这张表把所有的换状态都列出来:

从你做了什么运行的方法(所在的类)到
(程序启动)—CGameWindow 构造器 → showScreen(selectScreen)选择界面
选择界面Choose MokeponstartGame(CGameWindow)地图
地图踩到感叹号startEncounter(COverworld)→ onEncounter(COverworldScreen)遇敌框
遇敌框Run Away!runAway(COverworld)地图
遇敌框Fight!startBattle(CGameWindow)战斗
战斗菜单 Run away / Ctrl+RrunAway(CGameWindow → COverworld)地图
战斗敌人倒下showResult(CBattleScreen)You Win 框
战斗你倒下showResult → createGameEndDialogGame Over 框
You Win 框Back to it!winBattle(CGameWindow)→ winEncounter(COverworld)地图
地图踩到门reachExit(COverworld)→ onExitReached(COverworldScreen)出口框
出口框、Game Over 框Return to Main MenureturnToMainMenu(CGameWindow)选择界面
地图、战斗、任何框开着时菜单 Main menu / Ctrl+BreturnToMainMenu(CGameWindow)选择界面
出口框、Game Over 框Quitquit(CGameWindow)程序结束
任何地方菜单 Exit / Alt+F4quit(CGameWindow)程序结束

还有两条“走不了”的路,也要记住:遇敌框、出口框开着时,方向键和方向按钮都没用(isDialogOpen());You Win 框开着时,Run away 已经变灰(第 4 节)。

9. 样题 Q3:为了加上“逃跑”,从 Project 1 到 Project 2 改了什么

样题 Q3 是“扩展”类的题,围绕 run away:为了支持它,从 Project 1 到 Project 2 改了哪些类、哪些方法;Project 1 的设计哪里帮了忙,哪里碍事;现在的设计以后好不好扩展。下面用你两份代码一条一条分析。英文的完整参考答案在 第 17 章第 4 节。

Project 1:没有“逃跑”这回事

Project 1 的 GameWindow:菜单是两个方法造出来的(只摘了前几行)Project 1 · GameWindow.java第 24–24、551–557、570–581 行
24public class GameWindow extends Stage {
⋯ 省略第 25–550 行
551 // Create the menu with reset and restart disabled before battle.
552 private MenuBar getSelectMenu() {
553 MenuItem reset = new MenuItem("Reset");
554 reset.setDisable(true);
555
556 MenuItem restart = new MenuItem("Restart Battle");
557 restart.setDisable(true);
⋯ 省略第 558–569 行
570 // Create the battle menu with reset, restart and exit actions.
571 private MenuBar getBattleMenu(List<Mokepon> mokeponList, Mokepon playerTemplate, Mokepon enemyTemplate) {
572 MenuItem reset = new MenuItem("Reset");
573 reset.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
574 reset.setOnAction(event -> resetSelectScreen());
575
576 MenuItem restart = new MenuItem("Restart Battle");
577 restart.setAccelerator(new KeyCodeCombination(KeyCode.R));
578 restart.setOnAction(event -> battleScreen(mokeponList, playerTemplate, enemyTemplate));
579
580 MenuItem exit = new MenuItem("Exit");
581 exit.setOnAction(event -> Platform.exit());
Project 1:两个菜单各在哪里用Project 1 · GameWindow.java第 133–134、431–431、548–548 行
133 selectScene = new Scene(new VBox(getSelectMenu(), selectPane));
134 setScene(selectScene);
⋯ 省略第 135–430 行
431 setScene(new Scene(new VBox(getBattleMenu(mokeponList, playerTemplate, enemyTemplate), selectPane)));
⋯ 省略第 432–547 行
548 setScene(selectScene);
Project 1:Mokepon 会被改,所以开打前先复制两份Project 1 · GameWindow.java第 256–260、530–533 行
256
257 Mokepon player = copyMokepon(playerTemplate);
258 Mokepon enemy = copyMokepon(enemyTemplate);
259
260 Battle battle = new Battle(player, enemy);
⋯ 省略第 261–529 行
530 // Use copies to keep the original stats unchanged.
531 private Mokepon copyMokepon(Mokepon template) {
532 return new Mokepon(template.getName(), template.getImage(), template.getMaxHP(), template.getMaxEnergy(), template.getStrength(), template.getAccuracy(), template.getDefence(), template.getDodge(), template.getSkills());
533 }
Project 1 的 Mokepon:自己存着会变的 hp、energy,还有 setterProject 1 · Mokepon.java第 7–10、50–52、132–138、151–156 行
7 private String name;
8 private String image;
9 private int hp;
10 private int energy;
⋯ 省略第 11–49 行
50 public void setHp(int hp) {
51 this.hp = hp;
52 }
⋯ 省略第 53–131 行
132 // Apply damage and keep HP at zero or above.
133 public void takeDamage(int amount) {
134 this.hp = this.hp - amount;
135 if (this.hp <= 0) {
136 this.hp = 0;
137 }
138 }
⋯ 省略第 139–150 行
151 // Restore HP, energy and defence to their starting values.
152 public void reset() {
153 this.hp = maxHP;
154 this.energy = maxEnergy;
155 this.defence = originDefence;
156 }

Project 1 的 Mokepon 第 9–10 行自己存着会变的 hp、energy(不是 final),有 setHp(第 50 行)、takeDamage(第 133 行)、reset()(第 152 行)这些会改它的方法。所以 battleScreen 第 257–258 行先用 copyMokepon 复制两份,再 new Battle(player, enemy)。

Project 1 的 Battle:没有一行 JavaFX,已经有 finished 和 isFinished()Project 1 · Battle.java第 1–11、47–53 行
1package au.edu.unimelb.swen.oop.mokepon;
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.Random;
6
7public class Battle {
8 private Mokepon player;
9 private Mokepon enemy;
10 private List<String> battleLog;
11 private boolean finished;
⋯ 省略第 12–46 行
47 public boolean isFinished() {
48 return finished;
49 }
50
51 public void setFinished(boolean finished) {
52 this.finished = finished;
53 }

Project 1 的 Battle 只 import 了 java.util(第 3–5 行),没有任何 JavaFX。第 11 行已经有字段 finished,第 47–49 行已经有 isFinished()。这两点在下面“帮了忙”里会用到。

Project 2:为逃跑出力的每一块

类新加的东西它在逃跑里做什么
CGameMenu(新类)一个菜单对象,整个程序只造一次;Run away 菜单项 + Ctrl+R;update(screen)2 号路的起点:菜单项只调用 navigator.runAway();变灰变亮去问当前画面
IGameNavigator(新接口)runAway()、updateMenu()菜单和画面只认识这个接口,不认识 CGameWindow
CGameWindow(重新写的)runAway();字段 overworldScreen 一直留着先 world.runAway(),再放回原来的地图画面
AGameScreen(新抽象类)canRunAway(),默认 false每个画面回答“能不能逃”
CBattleScreen(新类)改写 canRunAway();showResult 里 updateMenu()只有战斗中能逃,打完马上变灰
COverworldScreen(新类)遇敌框的 Run Away! 按钮1 号路:直接调用 world.runAway()
COverworld(新类)runAway()逃跑的规则只写在这一个地方,两条路共用
CPlayer(新类)previousPosition,moveTo 存旧位置,moveBack()知道该退回哪一格
CFighter(新类)为一场战斗造的副本扔掉战斗就行,Mokepon 从来没被改,不用恢复
窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigatorui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle玩家:他的 Mokepon、位置、上一个位置、背包和胜场。CPlayerworld2 号路navigator.runAway()menu.update(currentScreen)问 canRunAway()改写 canRunAway()getNavigator().updateMenu()isFinished()player, enemy1 号路world.runAway()2 号路world.runAway()player.moveBack()
Project 2 里和逃跑有关的类。实线箭头 = 调用(线上是方法),空心三角实线 = extends,空心三角虚线 = implements,空心菱形 = 用字段存着。两条路(1 号从 OverworldScreen,2 号从 GameWindow)的箭头都指向 Overworld 的同一个 runAway()。GameMenu 只认识 GameNavigator 和 GameScreen 这两个“上层”类型

哪些旧设计帮了忙,哪些拖了后腿

帮了忙

  • Battle 早就和界面分开了。Project 1 的 Battle.java 没有一行 JavaFX,而且已经有字段 finished 和 isFinished()(上面的代码)。所以 Project 2 的 canRunAway() 只要一行 !battle.isFinished()。
  • 菜单项和快捷键的写法现成。Project 1 已经用 MenuItem + setAccelerator(Reset 的 Ctrl+R,第 573 行),Project 2 照着写,只是 Ctrl+R 换成了 Run away。
  • “战斗用副本”的想法已经有了。Project 1 开打前 copyMokepon;Project 2 把这个想法变成一个专门的类 CFighter,扔掉战斗就等于扔掉副本。

拖了后腿

  • 菜单写死在窗口里。菜单的 lambda 直接调用 GameWindow 的 private 方法;加一项要改两个造菜单的方法;菜单也没法问“现在的画面允许什么”,只能造的时候写死灰不灰。
  • 一个类管所有画面。GameWindow extends Stage,没有“画面对象”可以回答 canRunAway(),也没有地方把地图画面存起来、逃完再放回去。
  • Mokepon 会被改。有 setter、takeDamage、reset()(上面的代码)。要是直接拿原对象打,逃跑时就得把数值改回去;只能靠复制。
  • 没有地图和位置。“回到上一格”要用的 CPlayer、CPosition、COverworld 全得从头写。

现在好不好扩展

容易的

  • 新画面要不要允许逃跑:改写 canRunAway() 就行,不写就是默认 false。菜单变灰变亮这部分,CGameMenu 一行不用改(开闭原则)。(“逃跑以后去哪”就不一样了,见右边第一条。)
  • 再加一个能触发逃跑的地方(比如战斗画面上放一个按钮):调用 getNavigator().runAway() 就行。
  • 逃跑的规则只在 COverworld.runAway 一个地方,两条路共用,改一处两条路一起变。

有限制的

  • “逃跑以后做什么”只有一种:CGameWindow.runAway 不看当前画面是谁,永远 world.runAway()(退一格)再 showScreen(overworldScreen)。如果新画面说 canRunAway() = true,但它不是在遇敌之后出现的,选 Run away 照样会把玩家往回推一格、送回地图。想“逃”到别处,就得改 CGameWindow.runAway。
  • 只记一个 previousPosition:只能退一步。要“退回进这片草地之前”或者多步撤销,得改 CPlayer(比如存一个位置列表)。
  • CBattle 不知道有“逃跑”这回事:CGameWindow 直接把战斗扔掉。如果新规则是“逃跑可能失败”(比如按 Dodge 算概率)、“逃跑也给敌人一个回合”、“逃跑写进日志”,就要给 CBattle 加方法(比如 tryRunAway()),再由 CBattleScreen 按结果决定要不要调用 getNavigator().runAway()。
  • 每加一个菜单选项,就要在 AGameScreen 加一个 canXxx(),在 IGameNavigator 和 CGameWindow 各加一个方法。接口越来越大,而 CGameMenu 只用其中 3 个(接口隔离的讨论见 第 17 章第 3 节)。
  • world 可能是 null:CGameWindow.runAway() 完全靠菜单变灰来保护。以后别的代码要是在选择界面调用它,就会 NullPointerException。
  • 背包是同一个对象:战斗里用掉的道具,逃跑后不退回。如果规则改成“逃跑就当没打过”,CBattle 得记下用了什么。

答题时的顺序可以是:① 说清现在的两条路(类、方法、谁调用谁);② 说 Project 1 缺了什么、哪些设计帮了忙;③ 说扩展性,至少举一个容易的、一个有限制的,每一点都带 “because”。下面的英文句子可以直接用。

自测

两种逃跑各经过哪些对象?哪一种不经过 CGameWindow,为什么?

遇敌框的 Run Away!:COverworldScreen 里的 lambda → closeDialog() → world.runAway()(COverworld)→ player.moveBack() → onPlayerMoved()。它不经过 CGameWindow,因为画面不用换,地图画面自己有字段 world。菜单 Run away:CGameMenu 的 lambda → navigator.runAway() → CGameWindow.runAway() → world.runAway()(同一个方法)→ showScreen(overworldScreen) → updateMenu()。要换画面,只有窗口能做。

English: Both paths end in Overworld.runAway; only the menu path goes through GameWindow, because only the window can change the screen.

如果把 CBattleScreen.showResult 里的 getNavigator().updateMenu() 删掉,会出什么问题?

打完以后画面没有换,showScreen 不会运行,菜单就不会再问 canRunAway(),Run away 会一直亮着。在 You Win 框开着时按 Ctrl+R,会运行 world.runAway():退回上一格、感叹号留着、Fights 不加,这场胜利白打;在 Game Over 框时按 Ctrl+R,等于输了也不算,可以接着玩。

English: The screen does not change when a battle ends, so showResult must call updateMenu(); otherwise Run away would stay enabled and the player could cancel a battle that is already won or lost.

逃跑以后感叹号为什么还在?打赢以后为什么没了?两种情况玩家各站在哪?

runAway 只 encounter = null 和 player.moveBack(),不碰 objects,所以感叹号还在,玩家退回上一格(A 局 (18,17),B 局 (11,12))。winEncounter 第 96 行 objects.remove(encounter.getMarker()) 把它拿掉,还 addWin(),玩家不动,站在感叹号那一格 (17,17)。MapView.update 只画列表里还在的物体,所以地图上就看不到了。

B 局 Shellurk 在战斗里 Accuracy 涨到了 78。逃回地图以后再打一场,Accuracy 从多少开始?为什么不需要写任何“恢复”的代码?

从 62 开始(Shellurk 是 NORMAL 类型,地形效果永远是 NEUTRAL,倍数 1,不会改它)。78 存在那场战斗的 CFighter 的 stats 里;逃跑以后那个 CFighter 没人指着了。CMokepon 从来没被改过:字段是 private final,构造器把 stats 和 skills 复制了一份(防御性复制),外面只能通过 getStat 拿到一个 int,也没有 setter。下一场 CEncounter.createBattle 会重新 new Fighter(playerMokepon, ...),从 Mokepon 的原始值抄起。

English: Battle changes are stored in Fighter objects created for each battle, and the Mokepon is immutable, so nothing needs to be reset.

选了 Main menu 以后,CGameWindow 的哪些字段变了?为什么 CSelectScreen 要 clearSelection(),而 COverworldScreen 不需要“清空”?

world 和 overworldScreen 设成 null,currentScreen 换成选择界面,selectScreen 不变。选择界面整个程序只有一个(构造器里造、final 字段),上次的选择还留在它身上,所以要擦掉。地图画面不会被重复用:下一局 startGame 会 new 一个全新的 COverworldScreen,旧的直接被扔掉。

出口框和 Game Over 框为什么长得一样?如果只想给出口框加一个 Play again 按钮(用同一只 Mokepon 再开一局),改哪里?

两个都是父类 AGameScreen 的 createGameEndDialog(heading) 造的,只有标题不同。如果把按钮加在 createGameEndDialog 里,Game Over 框也会多出这个按钮。只想改出口框,就在 COverworldScreen.onExitReached 里先拿到框,再 addButton("Play again", event -> getNavigator().startGame(world.getPlayer().getMokepon())),然后 showDialog。IGameNavigator 已经有 startGame(Mokepon),接口不用加方法。

Q3 风格:新规则说“逃跑有一半的机会失败,失败了敌人要打你一下”。要改哪些类?

现在 CBattle 根本不知道“逃跑”,菜单的 lambda 一调用 navigator.runAway(),CGameWindow 就直接把战斗扔掉了,中间没有任何地方能说“失败”。所以要改三处:① CBattle 加一个方法(比如 tryRunAway()):用自己的 random 决定成不成功,失败就让敌人出一招(像 playTurn 的后半段)、写日志,返回结果;② 菜单不能再直接跳到窗口,要先让战斗画面试一试,例如 AGameScreen 加一个方法 tryRunAway(),CBattleScreen 改写它:先调用 battle.tryRunAway(),成功才 getNavigator().runAway(),失败就 update() 重画;③ CGameMenu 的 lambda 改成调用当前画面的这个方法(菜单现在不存当前画面,要么让 CGameWindow 转交,要么在 IGameNavigator 加方法)。COverworld.runAway 和 CPlayer 不用改。

English: Battle would need a new method, for example tryRunAway(), because the current design simply discards the battle and Battle has no idea that the player ran away.

下一章 第 16 章把这几章里出现过的设计(观察者、模板方法、钩子方法、依赖倒置……)放在一起比较;第 17 章第 4 节给出样题 Q3 的完整英文答案。

读完这一章,确认每个问题都能自己答出来,再点右边的按钮。