Mokepon代码导读
第三站 · 界面与地图L2约 40 分钟

06窗口、界面切换和菜单

GameWindow 怎么换画面、GameScreen 家族、GameNavigator 接口、菜单为什么有时变灰

读完这一章你能
  • 说出 CGameWindow 的 10 个字段:哪些是 final,为什么 world 和 overworldScreen 在选择界面是 null
  • 读懂 showScreen 的三行:换画面 = 换 BorderPane 中间那一块,再让菜单重新问一遍
  • 说出接口 IGameNavigator 的 7 个方法分别由哪个按钮、哪个菜单项调用,并用英语说清楚 Dependency Inversion
  • 说出父类 AGameScreen 给三个画面准备了什么,三个子类各改写了哪个方法
  • 跟着一次按键走完:Scene 上的事件过滤器 → handleKeyPress(模板方法)→ handleKey → movePlayer → Overworld.move → onPlayerMoved
  • 只看代码就能说出 Game 菜单里哪一项什么时候变灰(CGameMenu 的 update 和多态)
  • 跟着动画看一个对话框怎么造出来、盖上去、点了按钮以后怎么拿掉
  • 说出 CModalDialog 为什么能挡住后面的东西,CGameButton 的 0.5、0.75、0.4 分别是什么

第 1 章只看了截图:三个画面 CSelectScreen、COverworldScreen、CBattleScreen,顶上一个 Game 菜单,有时候上面盖一个对话框。这一章打开它们背后的“框架”代码。这几个类自己不画地图,也不画血条。它们管的是:窗口里现在放哪个画面、怎么换、按键交给谁、菜单哪一项能点、对话框怎么盖上去、按钮长什么样。一共 7 个类,都在 ui 包里:

类是什么,管什么
CGameWindow类,implements IGameNavigator。整个窗口:上面放菜单,中间放一个画面。换画面的事全在这里做
IGameNavigator接口(圆圈里是 I)。画面和菜单能请窗口做的 7 件事
AGameScreen抽象类(A)。三个画面的父类:画面区、背景、对话框、按键、菜单的两个问题
CGameMenu类。顶上的 Game 菜单:Run away、Main menu、Exit
CModalDialog类。盖在画面上的对话框(遇敌、You Win、Game Over、出口)
CUiStyle类,里面全是 static。游戏区大小、字体、图片这些大家共用的小工具
CGameButton类,extends Button。游戏里那种半透明灰色的圆角按钮

几个第 1 章讲过的词,这里只提醒一句(语法细节在 第 2 章):字段 = 类里面存东西的变量;构造器 = new 的时候自动运行的那段代码;父类 / 子类 = extends 的两边;抽象类 = 不能直接 new、只当父类用的类;null = 这个字段现在什么都没指着。新出现的词,第一次出现时会写中文和英文。

1. GameWindow:上面一个菜单,下面一个画面

窗口分成两块

先看程序刚打开时的窗口,再看造出它的代码。图上的红色编号和代码行左边的编号是对应的(鼠标放在编号上,两边会一起亮):

游戏刚打开:选择界面,还没选 Mokepon
1
  1. 1
    上面:菜单栏 MenuBar,就是最上面这一整条白色。最左边的 Game 是它里面唯一的菜单(红框从 Game 右边画起,免得挡住这个字)。第 40 行 root.setTop(menu.getMenuBar()) 把 CGameMenu 造的 MenuBar 放在 root 的上边
放大左上角:菜单栏只有 25 像素高真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
游戏刚打开:选择界面,还没选 Mokepon2
  1. 2
    下面这一大块:1280×768 的游戏区。第 42 行 showScreen(selectScreen) 把选择界面的 view 放在 root 的中间
整个窗口:上面一条菜单栏,下面是当前画面真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
GameWindow 的构造器:先放菜单,再放第一个画面ui/GameWindow.java第 30–50 行
30 /* Set up the window and start on the select screen. */
31 public GameWindow(Stage stage, List<Mokepon> mokepons, GameMap map, GameImages images) {
32 this.stage = stage;
33 this.mokepons = mokepons;
34 this.map = map;
35 this.images = images;
36 stage.setTitle("Mokepon");
37 stage.setResizable(false);
38
39 menu = new GameMenu(this);
401 root.setTop(menu.getMenuBar());
41 selectScreen = new SelectScreen(mokepons, images, this);
422 showScreen(selectScreen);
43
44 Scene scene = new Scene(root);
45 /* Use a filter so the screen gets the keys before any button or text box does. */
46 scene.addEventFilter(KeyEvent.KEY_PRESSED,
47 event -> currentScreen.handleKeyPress(event));
48 stage.setScene(scene);
49 stage.sizeToScene();
50 }

窗口最外层的布局是字段 root。它的类型是 BorderPane(BorderPane:JavaFX 的一种布局,分上、下、左、右、中五块,用 setTop、setCenter 往里放东西)。这个项目只用了上和中两块。构造器里和它有关的是这 4 行:

  1. 第 39 行 menu = new GameMenu(this):造菜单。括号里的 this 就是“我自己”,也就是正在造的这个 CGameWindow 对象。为什么要把自己交给菜单,第 2 节讲。
  2. 第 40 行:菜单栏放在上面(标注 1)。以后再也不动它。
  3. 第 41 行 selectScreen = new SelectScreen(mokepons, images, this):造选择界面,存进字段 selectScreen。这里又交了一次 this。
  4. 第 42 行 showScreen(selectScreen):把选择界面放在中间(标注 2)。

顺序很重要:showScreen 最后会调用 updateMenu(),它再调用 menu.update(...)(第 104、98 行),所以 menu 必须先造好。如果把第 39–40 行一起挪到第 42 行后面,程序一启动就会在 menu.update 那里抛出 NullPointerException(对 null 调用方法)。如果只挪第 39 行,错得更早:第 40 行用到了还没赋值的 final 字段 menu,javac 直接报编译错误“variable menu might not have been initialized”,程序根本造不出来。

后面几行(第 44–49 行)造 Scene、给 Scene 装一个事件过滤器、把 Scene 放进窗口。先分清两个词:Stage = 窗口本身(CMain 交来的,存在字段 stage 里);Scene = 窗口里装着的整棵内容,它的根就是 root。按键事件先到 Scene,所以这个事件过滤器(第 4 节细讲)就装在 Scene 上。构造器的其他部分在 第 5 章。最后一行 stage.sizeToScene() 让窗口正好包住里面的内容:菜单栏高 25,画面高 768,宽都是 1280,所以窗口内容是 1280 × 793。每张截图正好就是这么大。

stage : Stage(窗口本身)scene : Scene窗口里的全部内容(构造器里的局部变量)root : BorderPaneMenuBar(GameMenu 造的)view : Pane(当前画面的)setScene(scene)new Scene(root)setTopsetCenter
窗口里一层套一层:Stage 装着 Scene,Scene 装着 root,root 上面放菜单栏,中间放当前画面的 view。换画面 = 只换 root 中间这一块。细线 = 放进去,线上写的是放进去的那句代码(它们不是同一个类的字段)

换画面时,窗口、Scene、root、菜单栏都不换,只换 root 中间那一块。下面的动画轮流放三张真实截图(都只截了上半部分):最上面那条白色的菜单栏一动不动,变的只是下面这一大块。

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)点 Fight! 进入战斗界面,战斗刚开始
同一个菜单栏,三个不同的画面

GameWindow 的 10 个字段

GameWindow 的字段:只有绿色的 3 个不是 finalui/GameWindow.java第 14–28 行
14/* Owns the window and decides which screen it shows.
15 * The menu bar stays at the top, and the current screen fills the space below it.
16 */
17public class GameWindow implements GameNavigator {
18 private final Stage stage;
19 private final List<Mokepon> mokepons;
20 private final GameMap map;
21 private final GameImages images;
22 private final GameMenu menu;
23 private final BorderPane root = new BorderPane();
24 private final SelectScreen selectScreen;
25 private GameScreen currentScreen;
26 /* The game being played and its screen. Both are null on the select screen. */
27 private Overworld world;
28 private OverworldScreen overworldScreen;
字段 : 类型final什么时候有值,用来干什么
stage : Stage是构造器里存下 CMain 交来的窗口。设标题、show() 都用它
mokepons : List<Mokepon>是构造器里存下。读进来的 36 只。交给 CSelectScreen;每开一局,也交给新的 COverworld
map : CGameMap是构造器里存下。每开一局,新的 COverworld 都用这张地图
images : CGameImages是构造器里存下。交给每个画面(类型图标、按钮图片……)
menu : CGameMenu是第 39 行造。顶上的菜单
root : BorderPane是第 23 行声明时就 new 好了。上面菜单栏,中间当前画面
selectScreen : CSelectScreen是第 41 行造。整个程序只造这一次,回主菜单时只是被清空
currentScreen : AGameScreen否每次 showScreen 都改。现在放在中间的那个画面
world : COverworld否startGame 时造,回主菜单时设成 null。这一局的规则
overworldScreen : COverworldScreen否和 world 同时造、同时设成 null。这一局的地图画面

showScreen:换画面只有 3 行

showScreen:整个程序里唯一换画面的地方ui/GameWindow.java第 101–105 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }
  1. 第 102 行 currentScreen = screen:记住新画面。从这一刻起,按键交给它(第 4 节),菜单也去问它(第 5 节)。
  2. 第 103 行 root.setCenter(screen.getView()):每个画面都有一个 view(一个 1280×768 的 Pane,第 3 节),getView() 把它交出来,放进 root 的中间。原来中间那个 view 就被换下来了。
  3. 第 104 行 updateMenu():让菜单问一问新画面:“你允许逃跑吗?你允许回主菜单吗?”(第 5 节)。

两个细节:

为什么 world 和 overworldScreen 有时是 null

两个可以是 null 的字段:startGame 造它们,returnToMainMenu 扔掉它们ui/GameWindow.java第 26–28、56–63、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–55 行
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 }
⋯ 省略第 64–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 }

第 26 行的注释写得很清楚:“The game being played and its screen. Both are null on the select screen.” 意思是:这两个字段存的是“正在玩的这一局”和“这一局的地图画面”,在选择界面时它们都是 null。原因很简单:在选择界面还没有开始玩,没有一局可以存。

时刻currentScreen 指着world 和 overworldScreen
程序刚打开selectScreen都是 null
点了 Choose Mokepon(第 59、60 行)这一局的地图画面都是新造的
正在打仗新 new 的 CBattleScreen还是同一个(地图画面没显示,但留着)
回主菜单(第 85、86 行)selectScreen都设回 null

从这张表能看出三件事:

GameWindow 的 UML 类图
老师打印稿里的 GameWindow:上面的 GameNavigator 用斜体名字和 I 表示接口,虚线加空心三角 = implements(实现)。字段表里能找到全部 10 个字段;showScreen 前面是红色方块,表示 private。名字前的小图标:红色方块 = private,绿色圆点 = public,黄色菱形 = protected,带下划线 = static(第 3 章细讲)

游戏里哪些按钮会“请窗口做事”

先看截图。下面这些按钮和菜单项,点了以后都要窗口做点什么(大多是换画面)。红色编号就是下面接口里方法的编号:

第 7 个方法 updateMenu() 没有按钮可点:战斗一结束,CBattleScreen.showResult 第 171 行自己调用它(第 5 节的动画第 6–7 步)。

这些按钮和菜单项写在不同的类里:Choose Mokepon 在 CSelectScreen.createChooseButton 里造;Fight! 的文字和点击动作(lambda)写在 COverworldScreen.onEncounter 里,Back to it! 的写在 CBattleScreen.showResult 里(这两个按钮对象本身是 CModalDialog.addButton 用 new Button(text) 造的,第 6 节);菜单项在 CGameMenu 里。可它们要做的事(换画面、结束程序)只有 CGameWindow 能做。它们怎么找到窗口?它们手里拿的不是 CGameWindow,而是一个接口(interface):IGameNavigator。

接口里写了什么,窗口里怎么做

上面是接口:只写“有哪 7 件事”,不写怎么做。下面是 CGameWindow 里的同样 7 件事,写了真正怎么做。编号和上面截图的编号一样;鼠标放在一个编号上,两段代码里同号的那一行会一起亮:

GameNavigator:7 个方法,没有方法体ui/GameNavigator.java第 5–33 行
5/* What a screen or the menu can ask the window to do, mostly moving the game
6 * to another screen. They only know this interface, not the window that implements it
7 * (Dependency Inversion).
8 */
9public interface GameNavigator {
10
11 /* Start a new game on the overworld with the chosen Mokepon. */
121 void startGame(Mokepon playerChoice);
13
14 /* Fight the Mokepon of the current encounter. */
152 void startBattle();
16
17 /* Go back to the overworld after winning the battle. */
183 void winBattle();
19
20 /* Cancel the battle and go back to the tile before the encounter. */
214 void runAway();
22
23 /* Go back to the select screen with nothing selected. */
245 void returnToMainMenu();
25
26 /* End the whole program. Used by the Exit menu item and the Quit buttons. */
276 void quit();
28
29 /* Turn the menu options on or off again when the current screen changes what it
30 * allows, for example when a battle ends.
31 */
327 void updateMenu();
33}
GameWindow:同样 7 个方法,写出了怎么做ui/GameWindow.java第 56–99 行
56 /* Every new game starts with a fresh map, an empty inventory and no fights won. */
57 @Override
581 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 }
64
65 @Override
662 public void startBattle() {
67 showScreen(new BattleScreen(world.createBattle(), images, this));
68 }
69
70 @Override
713 public void winBattle() {
72 world.winEncounter();
73 showScreen(overworldScreen);
74 }
75
76 @Override
774 public void runAway() {
78 world.runAway();
79 showScreen(overworldScreen);
80 }
81
82 /* The game being played is thrown away. */
83 @Override
845 public void returnToMainMenu() {
85 world = null;
86 overworldScreen = null;
87 selectScreen.clearSelection();
88 showScreen(selectScreen);
89 }
90
91 @Override
926 public void quit() {
93 Platform.exit();
94 }
95
96 @Override
977 public void updateMenu() {
98 menu.update(currentScreen);
99 }

CGameWindow 的 7 个方法体都很短,最挤的是第 67 行 showScreen(new BattleScreen(world.createBattle(), images, this)):一行里套了三层,要从里往外读:

  1. world.createBattle():先请这一局的 COverworld 用这次遇到的敌人造一场 CBattle(第 9 章)。
  2. new BattleScreen(那一场 Battle, images, this):用它造一个新的战斗画面,又一次把 this(窗口自己)交进去。
  3. showScreen(...):把这个新画面放到 root 中间。

其他几个方法做的事,在第 8 节的表里一个一个对照。

接口里每个方法上面都有一行英文注释,意思是:

  1. startGame(playerChoice):用选好的 Mokepon 开一局新游戏,进地图。
  2. startBattle():和这次遇到的 Mokepon 开打。
  3. winBattle():打赢以后回地图。
  4. runAway():取消这场战斗,回到遇敌之前的那一格。
  5. returnToMainMenu():回选择界面,什么都没选。
  6. quit():结束整个程序。注释说 Exit 菜单项和 Quit 按钮用它。
  7. updateMenu():当前画面允许的事情变了(比如战斗结束),让菜单重新开关一遍。

谁在调用它们?把整个项目搜一遍,就是这张表(行号是调用的那一行):

方法谁调用(你做了什么)
1 startGameCSelectScreen 第 246 行,Choose 按钮的 lambda(你点 Choose Mokepon)
2 startBattleCOverworldScreen 第 87 行,遇敌框 Fight! 的 lambda(你点 Fight!)
3 winBattleCBattleScreen 第 174 行,You Win 框的 lambda(你点 Back to it!)
4 runAwayCGameMenu 第 22 行(菜单 Run away 或 Ctrl+R,只有战斗中能用)
5 returnToMainMenuCGameMenu 第 26 行(菜单 Main menu / Ctrl+B);AGameScreen 第 90 行(Game Over 框、出口框里的 Return to Main Menu)
6 quitCGameMenu 第 30 行(菜单 Exit / Alt+F4);AGameScreen 第 91 行(Quit 按钮)
7 updateMenuCBattleScreen 第 171 行,showResult 里(不用点:战斗一结束就调用)

第 7 个方法 updateMenu 窗口自己也用:showScreen 的最后一行(第 104 行)。第 5、6 个在 AGameScreen 里的那两处是同一个方法 createGameEndDialog(第 3 节),Game Over 框和出口框都是它造的。“lambda”是什么,第 5 节讲菜单时细讲。

为什么画面手里拿的是接口,不是 GameWindow

回到第 1 节:CGameWindow 造画面和菜单时,交出去的是 this。可接收的一方,参数类型写的都是 IGameNavigator:GameMenu(GameNavigator navigator)、SelectScreen(..., GameNavigator navigator)。画面把它存进父类 AGameScreen 的字段 navigator(类型也是 IGameNavigator)。所以整个关系是这样的:

选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigatorui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui画面类和 GameMenu 里没有一处写 GameWindownavigator构造器参数
画面和菜单只指向接口 GameNavigator,GameWindow 也指向它(虚线空心三角 = implements)。空心菱形 = 用字段存着(线上是字段名);虚线箭头 = 只是用到(构造器参数);实线空心三角 = extends。这里只画“画面 → 窗口”这个方向,GameWindow 存着画面的字段见第 1 节

把整个项目搜一遍,GameWindow 这个名字只出现在两个文件里:它自己的 GameWindow.java,和造它的 CMain。三个画面和 CGameMenu 一次都没提到它。它们只认识 IGameNavigator 的 7 个方法。程序运行时,navigator 里装的其实就是那个 CGameWindow 对象;调用 navigator.startGame(...),运行的是 CGameWindow 写的 startGame。所以后面的顺序图里,画面的箭头会指向 CGameWindow 那根线:代码里写的是 getNavigator().startGame(...)(类型是 IGameNavigator),运行时真正被调用的是 CGameWindow 对象的方法。

为什么要多绕这一下?接口的类注释(第 5–8 行)自己说了:“They only know this interface, not the window that implements it (Dependency Inversion).” 这叫依赖倒置(Dependency Inversion):

3. GameScreen:三个画面共同的父类

每个画面都有一个 view,最底下是背景

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)12
  1. 1
    字段 view:一个 Pane,第 20 行设成 1280×768,就是整个游戏区(菜单栏下面的全部)。画面上所有东西都放在它里面
  2. 2
    棕色背景:第 21 行 UiStyle.createBackground(),view 里第一个放进去的东西,所以在最底下。整张 1280×768 都是这张背景图,这里只框出左边一块没被别的东西盖住的地方
地图画面:它的一切都在 GameScreen 给的 view 里真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
GameScreen:3 个字段和构造器ui/GameScreen.java第 8–26 行
8/* The parts shared by every screen: the game area with its background, the dialogs,
9 * the key presses and the menu options. The window shows one screen at a time,
10 * and the screen being shown decides how the keys and the menu behave.
11 */
12public abstract class GameScreen {
13 private final GameNavigator navigator;
141 private final Pane view = new Pane();
15 /* The dialog on top of the screen, or null when there is none. */
16 private ModalDialog dialog;
17
18 protected GameScreen(GameNavigator navigator) {
19 this.navigator = navigator;
20 view.setPrefSize(UiStyle.GAME_WIDTH, UiStyle.GAME_HEIGHT);
212 view.getChildren().add(UiStyle.createBackground());
22 }
23
24 public Pane getView() {
25 return view;
26 }

三个画面类都写着 extends GameScreen。它们每个的构造器第一行都是 super(navigator):先运行父类的这个构造器(第 18–22 行)。它做三件事:

  1. 第 19 行:把交来的 navigator 存进字段(第 2 节那个接口)。
  2. 第 20 行:把 view 设成 1280 × 768(CUiStyle 的两个常量,第 7 节)。view 在第 14 行声明时就 new Pane() 了。
  3. 第 21 行:把背景图片放进 view。它是第一个,所以在最底层;子类后来放进去的东西都画在它上面(绘制顺序:后放的盖住先放的)。三个画面的棕色背景一模一样,就是因为都来自这一行。
字段 : 类型说明
navigator : IGameNavigator(final)请窗口做事用。它是 private,子类要用 getNavigator() 拿
view : Pane(final)整个游戏区。CGameWindow 用 getView() 拿去放在中间
dialog : CModalDialog现在盖在画面上的对话框;没有对话框时是 null(第 15 行注释)

GameScreen 准备好的方法

方法(写法)干什么谁用
getView()(public)交出 viewCGameWindow.showScreen
handleKeyPress(event)(public final)处理按键的固定步骤CGameWindow 的事件过滤器
handleKey(event)(protected,默认空)子类自己填的那一步handleKeyPress
canRunAway()(public,默认 false)菜单问:能逃跑吗?CGameMenu.update
canReturnToMainMenu()(public,默认 true)菜单问:能回主菜单吗?CGameMenu.update
getNavigator()(protected)拿到 navigator三个子类
addToView(...) 两个(protected)把东西放进 view三个子类的构造器
showDialog / closeDialog / isDialogOpen(protected)盖上、拿掉、问有没有对话框COverworldScreen、CBattleScreen
createGameEndDialog(heading)(protected)造一个带两个按钮的结束框Game Over 框、出口框

public 的四个是给“外面”(窗口和菜单)用的,protected 的是只给子类用的工具。下面一组一组看。

模板方法:handleKeyPress 和 handleKey

final 的 handleKeyPress 定好步骤,handleKey 留给子类填ui/GameScreen.java第 28–39、51–53 行
28 /* Template Method: key presses are ignored while a dialog is open, and key combinations
29 * are left for the menu shortcuts (Ctrl+R, Ctrl+B and Alt+F4).
30 * Any other key is passed to the screen.
31 */
32 public final void handleKeyPress(KeyEvent event) {
33 boolean isCombination = event.isControlDown() || event.isAltDown()
34 || event.isMetaDown() || event.isShiftDown();
35 if (isDialogOpen() || isCombination) {
36 return;
37 }
38 handleKey(event);
39 }
⋯ 省略第 40–50 行
51 /* Screens with keyboard controls override this. Other screens ignore the keys. */
52 protected void handleKey(KeyEvent event) {
53 }

按键进来,窗口调用的是 handleKeyPress。它一行一行做:

  1. 第 33–34 行:是不是组合键?event.isControlDown() 问“按着 Ctrl 吗”,后面依次是 Alt、Meta(Mac 上的 Command 键)、Shift。四个用 ||(或)连起来:有一个是 true,isCombination 就是 true。
  2. 第 35–37 行:如果对话框开着,或者是组合键,直接 return(提前返回),后面什么都不做。注释说了原因:组合键留给菜单的快捷键 Ctrl+R、Ctrl+B、Alt+F4。
  3. 第 38 行:都不是,才调用 handleKey(event)。

第 52–53 行的 handleKey 是空的:父类的默认做法就是“什么都不做”。注释(第 51 行)说:有键盘操作的画面改写它,别的画面不管按键。

这就是模板方法(Template Method),注释第 28 行就写着这个名字:

项目里还有一处同样的模式:第 12 章的 ASkill.use 也是 final 的模板方法,先扣能量,再调用子类写的 applyEffect。区别是 applyEffect 是 abstract 的(子类必须写),而这里的 handleKey 有一个空的默认版本(子类想改才改)。

三个子类各改写了什么

抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuicanReturnToMainMenu()→ falsehandleKey()方向键走路canRunAway()handleKey():1–4 键改写改写改写
GameScreen 和它的三个子类:实线空心三角 = extends。下面的便签写着每个子类改写(override)了父类的哪个方法;没写的就用父类的默认版本。OverworldScreen 另外还 implements OverworldListener(第 8 章)
子类改写了(override)没改写,用父类的默认
CSelectScreencanReturnToMainMenu() → falsecanRunAway() → false;handleKey 什么都不做
COverworldScreenhandleKey:方向键走路canRunAway() → false;canReturnToMainMenu() → true
CBattleScreencanRunAway():战斗没结束才 true;handleKey:1–4 键canReturnToMainMenu() → true

handleKeyPress 是 final,三个子类谁都不能改写。

这几个改写的效果,第 4 节(按键)和第 5 节(菜单)会一个一个看它们运行。

addToView:同一个名字,两个版本

addToView 的两个版本ui/GameScreen.java第 59–66 行
59 /* Add nodes in drawing order: later nodes are drawn on top. */
60 protected void addToView(Node... nodes) {
61 view.getChildren().addAll(nodes);
62 }
63
64 protected void addToView(List<Node> nodes) {
65 view.getChildren().addAll(nodes);
66 }

两个方法名字一样,参数不一样,这叫重载(overload)。调用时 Java 看你传的是什么,自动挑一个:

BattleScreen 两个版本都用了:第 92、93 行传 List,第 94–95 行传 6 个ui/BattleScreen.java第 91–95 行
91 /* Add nodes in drawing order: later nodes are drawn on top. */
92 addToView(playerPanel.getNodes());
93 addToView(enemyPanel.getNodes());
94 addToView(skillButtonColumn, itemButtonRow, actionLabel, logArea,
95 playerPanel.getFaintMask(), enemyPanel.getFaintMask());

两个版本里面都是 view.getChildren().addAll(...):按顺序加到 view 的最后面。第 59 行注释:“later nodes are drawn on top”,后加的画在上面。所以 CBattleScreen 第 95 行最后加的两个倒下时的红色遮罩,会盖在卷轴和卷轴上的技能按钮上面。真实截图里就是这样:

玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
12
  1. 1
    红色半透明遮罩:第 95 行最后才加进 view 的 playerPanel.getFaintMask()。平时它是隐藏的,玩家的 Mokepon 倒下时才显示(CFighterPanel 的遮罩,第 14 章)
  2. 2
    技能按钮(这里框的是 Swole、Bunker、Wait)是第 94 行先加进去的,所以被遮罩盖住,字也透着红色。最上面的 Bite 也一样,只是它右边一截又被 Game Over 框盖住了,所以没框它
后加的盖住先加的。这是输掉时的截图,最上面还盖着 Game Over 框和它的深色一层真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

对话框的小工具和 createGameEndDialog

showDialog、closeDialog、isDialogOpen、createGameEndDialogui/GameScreen.java第 68–93 行
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
76 protected void closeDialog() {
77 if (dialog != null) {
78 view.getChildren().remove(dialog.getLayer());
79 dialog = null;
80 }
81 }
82
83 protected boolean isDialogOpen() {
84 return dialog != null;
85 }
86
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);
901 endDialog.addButton("Return to Main Menu", event -> navigator.returnToMainMenu());
912 endDialog.addButton("Quit", event -> navigator.quit());
92 return endDialog;
93 }
  • showDialog(newDialog):先 closeDialog()(同一时间只能有一个框),记进字段 dialog,把对话框的那一层加到 view 的最后(所以盖在所有东西上面),再让它的第一个按钮拿到键盘焦点。
  • closeDialog():如果有框,把它那一层从 view 拿掉,dialog 设回 null。
  • isDialogOpen():就一句 return dialog != null;。handleKeyPress 用的就是它。
  • createGameEndDialog(heading):造一个标题由你定、带两个按钮的框:第 90 行 Return to Main Menu 调用 navigator.returnToMainMenu()(标注 1),第 91 行 Quit 调用 navigator.quit()(标注 2)。它在父类里,所以能直接用 private 字段 navigator。CBattleScreen 输了时用它(“Game Over”),COverworldScreen 走到出口时也用它(“You finished the game! …”)。下面两个框的按钮一模一样,就是因为是同一个方法造的:
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
12
  1. 1
    第 90 行加的 Return to Main Menu
  2. 2
    第 91 行加的 Quit
输了:CBattleScreen.showResult 调用 createGameEndDialog("Game Over")真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
走到出口的门 (6,0):游戏结束对话框
12
  1. 1
    还是第 90 行
  2. 2
    还是第 91 行
走到出口:COverworldScreen.onExitReached 调用同一个方法真实运行截图 · 走到出口的门 (6,0):游戏结束对话框

对话框本身(CModalDialog)长什么样、怎么挡住后面,在第 6 节。

GameScreen 的 UML 类图
老师打印稿里的 GameScreen:斜体名字和 A 表示抽象类。上半是 3 个字段(红色方块 = private),下半是方法:绿色圆点 = public(给窗口和菜单用),黄色菱形 = protected(给子类用)。图上看不出 handleKeyPress 是 final

4. 按一个键:从 Scene 一路到 handleKey

现在跟着一次真的按键走一遍。起点在 CGameWindow 构造器的第 46–47 行:

GameWindow 构造器里的事件过滤器ui/GameWindow.java第 44–49 行
44 Scene scene = new Scene(root);
45 /* Use a filter so the screen gets the keys before any button or text box does. */
46 scene.addEventFilter(KeyEvent.KEY_PRESSED,
47 event -> currentScreen.handleKeyPress(event));
48 stage.setScene(scene);
49 stage.sizeToScene();

地图画面还要知道“哪个键往哪走”。它用一个 static 的 Map 存着这张对照表:

OverworldScreen.ARROW_KEYS:4 个方向键 → 4 个方向ui/OverworldScreen.java第 25–29 行
25 private static final Map<KeyCode, Direction> ARROW_KEYS = Map.of(
26 KeyCode.UP, Direction.UP,
27 KeyCode.DOWN, Direction.DOWN,
28 KeyCode.LEFT, Direction.LEFT,
29 KeyCode.RIGHT, Direction.RIGHT);

下面是你在地图上按一下 → 键时,真正发生的调用顺序。顺序图的读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。动画里还有两点要注意:

图分成两段,每段 5 个箭头。图里 overworldScreen 那根线,就是这一刻 currentScreen 指着的地图画面对象;handleKeyPress 是它从父类 AGameScreen 继承来的方法,所以也画在它身上。

你scene : SceneCoverworldScreenevent : KeyEventCworld : Overworld按 → 键1handleKeyPress(event)2isControlDown() 等3isDialogOpen()4handleKey(event)5
第 1 段(共 2 段):Scene 的事件过滤器 → handleKeyPress 的两关
你scene : SceneCoverworldScreenevent : KeyEventCworld : OverworldgetCode()1movePlayer(RIGHT)2move(RIGHT)3onPlayerMoved()4consume()5
第 2 段(共 2 段):handleKey → 走一格 → 通知画面重画 → consume
GameWindow 构造器:给 Scene 装事件过滤器ui/GameWindow.java第 44–49 行
44 Scene scene = new Scene(root);
45 /* Use a filter so the screen gets the keys before any button or text box does. */
46 scene.addEventFilter(KeyEvent.KEY_PRESSED,
47 event -> currentScreen.handleKeyPress(event));
48 stage.setScene(scene);
49 stage.sizeToScene();
GameScreen.handleKeyPress:两关,再交给 handleKeyui/GameScreen.java第 32–39 行
32 public final void handleKeyPress(KeyEvent event) {
33 boolean isCombination = event.isControlDown() || event.isAltDown()
34 || event.isMetaDown() || event.isShiftDown();
35 if (isDialogOpen() || isCombination) {
36 return;
37 }
38 handleKey(event);
39 }
OverworldScreen.handleKeyui/OverworldScreen.java第 116–121 行
116 protected void handleKey(KeyEvent event) {
117 Direction direction = ARROW_KEYS.get(event.getCode());
118 if (direction != null) {
119 movePlayer(direction);
120 event.consume();
121 }
OverworldScreen.movePlayerui/OverworldScreen.java第 127–131 行
127 private void movePlayer(Direction direction) {
128 if (!isDialogOpen()) {
129 world.move(direction);
130 }
131 }
Overworld.move(前半)world/Overworld.java第 66–71 行
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());
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    按键之前:玩家在 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
按右方向键走一格,现在在 (11,18)
1
  1. 1
    按键之后:玩家在 (11,18),往右挪了一格(32 像素)
真实运行截图 · 按右方向键走一格,现在在 (11,18)
OverworldScreen.onPlayerMovedui/OverworldScreen.java第 72–77 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
回到 handleKeyui/OverworldScreen.java第 118–121 行
118 if (direction != null) {
119 movePlayer(direction);
120 event.consume();
121 }
  1. 你在地图上按了 → 键(下面是按之前:玩家在 (10,18))。JavaFX 造一个 KeyEvent 对象,记着“按的是 RIGHT、没按 Ctrl”这些,先交给 Scene(第 1 个箭头)。Scene 上装着 CGameWindow 的事件过滤器,所以它最先拿到这个按键,比任何按钮都早。
  2. 过滤器是 CGameWindow 构造器第 46–47 行登记的。它的 lambda 只有一句:currentScreen.handleKeyPress(event)(第 2 个箭头)。这一刻 currentScreen 指着地图画面,所以按键交给了它。handleKeyPress 写在父类 AGameScreen 里,地图画面继承了它。
  3. 第一关,第 33–34 行:是不是组合键?问 event 四个问题:isControlDown()(按着 Ctrl 吗)、isAltDown()、isMetaDown()、isShiftDown()。你只按了 →,四个都是 false,所以 isCombination 是 false。
  4. 第二关,第 35 行:有没有对话框开着?字段 dialog 是 null,isDialogOpen() 返回 false。两个条件都不成立,不 return,继续往下。如果这时遇敌框开着,方法就在第 36 行结束了,玩家走不动。
  5. 第 38 行调用 handleKey(event)。这个对象真正的类是 COverworldScreen,它改写了 handleKey,所以运行的是 COverworldScreen 的版本,不是父类那个空的版本。这叫动态绑定(dynamic dispatch):运行时看对象真正是哪个类。第 1 段到这里结束。
  6. 第 2 段。来到 COverworldScreen.handleKey 第 117 行:先问 event.getCode(),得到 KeyCode.RIGHT(“哪个键”)。再拿它去查上面那个 Map ARROW_KEYS(第 25–29 行):RIGHT 对应 Direction.RIGHT。如果按的不是方向键(比如字母 A),get 查不到,得到 null,第 118 行的 if 不成立,什么都不做。
  7. 第 118–119 行:direction 不是 null,调用 movePlayer(RIGHT)(下面就是它的代码)。movePlayer 第 128 行又查了一次对话框。按键这条路刚才已经查过,为什么还要查?因为右下角的四个方向按钮也调用 movePlayer(第 173 行),按钮那条路不经过 handleKeyPress。
  8. 第 129 行 world.move(direction):画面自己不管规则,交给 COverworld。move 第 67 行先问 canMove(RIGHT):右边那格能走;第 70 行 player.moveTo(...):CPlayer 对象里的位置变成 (11,18)。注意:到这里只是对象里的数变了,屏幕上还什么都没变。
  9. COverworld 第 71 行 notifyListeners(listener -> listener.onPlayerMoved()):通知登记过的监听者(startGame 时登记的,本章第 8 节)。于是 onPlayerMoved() 运行(第 4 个箭头):清空状态栏,再 update(),地图照着新位置重画,左下就是结果。这是观察者模式(第 8 章)。
  10. move 后面还会看新格子上有没有东西(这一格没有,第 8 章)。都返回以后,回到 handleKey 第 120 行 event.consume():告诉 JavaFX “这个键我用掉了”。事件到此为止,不会再交给正拿着键盘焦点的按钮去处理。一次按键就走完了。

这一路上,CGameWindow 只管把按键交给 currentScreen,AGameScreen 只管两条规矩,COverworldScreen 只管“方向键 = 走一格”和“收到通知就重画”,COverworld 只管规则。每个类只做自己那一小段。

战斗画面:数字键 1–4

CBattleScreen 也改写了 handleKey,前面几步完全一样(过滤器 → handleKeyPress → 两关),只是最后填的那一步不同。上面是代码;下面左边是这一刻真实的战斗画面(玩家是 Shellurk,能量 0),右边一行一行解释,编号是对应的:

BattleScreen.handleKey:数字键按技能按钮ui/BattleScreen.java第 29–31、107–120 行
29 /* Keys 1 to 4 use the matching skill. */
30 private static final List<KeyCode> SKILL_KEYS =
31 List.of(KeyCode.DIGIT1, KeyCode.DIGIT2, KeyCode.DIGIT3, KeyCode.DIGIT4);
⋯ 省略第 32–106 行
107 /* Keys 1 to 4 press the matching skill button. A disabled button ignores the key. */
108 @Override
109 protected void handleKey(KeyEvent event) {
110 int index = SKILL_KEYS.indexOf(event.getCode());
1114 if (index >= 0 && index < skillButtons.size()) {
1123 skillButtons.get(index).fire();
113 event.consume();
114 }
115 }
116
117 private void useSkill(Skill skill) {
118 battle.playTurn(skill);
119 update();
120 }
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
1按 12按 23按 34按 4
  1. 1
    按 1 → index 0 → Tackle:能量不够,按钮是灰的,第 112 行的 fire() 没有反应
  2. 2
    按 2 → index 1 → Aim:也是灰的,同样没反应
  3. 3
    按 3 → index 2 → Wait:能用,第 112 行 fire() 就等于用鼠标点了它。现在只有按 3 有用
  4. 4
    这里没有第 4 个按钮:Shellurk 只有 3 个技能。按 4 → index 是 3,第 111 行要求 index < skillButtons.size()(3),不成立,什么都不做
能量 0 时:数字键 1、2 按了也没用真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
  • 第 30–31 行 SKILL_KEYS:一个 static 的 List,装着 4 个键 [DIGIT1, DIGIT2, DIGIT3, DIGIT4](DIGIT1 = 键盘上面那一排的 1)。
  • 第 110 行 SKILL_KEYS.indexOf(event.getCode()):找按的键在这个 List 里排第几,从 0 开始数:按 1 → 0,按 2 → 1,按 3 → 2,按 4 → 3。不在里面(别的键)→ -1。
  • 第 111 行 index >= 0 && index < skillButtons.size():&& 是“并且”(逻辑运算符),两个条件都成立才往下:不是 -1,而且这个位置上真有一个按钮(标注 4)。
  • 第 112 行 skillButtons.get(index).fire():拿出那个按钮,在代码里按它一下(标注 1–3);第 113 行 consume(),和地图上一样。

fire() 是 JavaFX 按钮自带的方法:在代码里“按一下”这个按钮,会运行它登记的点击动作。技能按钮的点击动作是 useSkill(skill),所以按数字键和用鼠标点完全一样,走的是同一段代码(第 11 章)。第 107 行的注释也写了:“A disabled button ignores the key.” 不用再写一遍“能量够不够”的判断,按钮变灰就已经挡住了。

CSelectScreen 没有改写 handleKey,用的是父类那个空版本,所以选择界面这个画面自己不处理任何键。Tab、空格这些键也没有人 consume,就照常交给拿着焦点的按钮。

菜单是怎么造出来的

GameMenu 的构造器:三个菜单项、三个快捷键ui/GameMenu.java第 19–37 行
19 public GameMenu(GameNavigator navigator) {
202 runAway = new MenuItem("Run away");
21 runAway.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
22 runAway.setOnAction(event -> navigator.runAway());
23
243 mainMenu = new MenuItem("Main menu");
25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));
26 mainMenu.setOnAction(event -> navigator.returnToMainMenu());
27
284 MenuItem exit = new MenuItem("Exit");
29 exit.setAccelerator(new KeyCodeCombination(KeyCode.F4, KeyCombination.ALT_DOWN));
30 exit.setOnAction(event -> navigator.quit());
31
321 Menu gameMenu = new Menu("Game");
33 gameMenu.getItems().addAll(runAway, mainMenu, new SeparatorMenuItem(), exit);
34
35 menuBar = new MenuBar(gameMenu);
36 menuBar.setPrefWidth(UiStyle.GAME_WIDTH);
37 }
战斗中打开 Game 菜单:Run away 可以点了
1234
  1. 1
    第 32 行 new Menu("Game"):菜单栏上的 Game 这一个菜单
  2. 2
    第 20 行 Run away,第 21 行给它快捷键 Ctrl+R(右边的灰字)
  3. 3
    第 24 行 Main menu,第 25 行快捷键 Ctrl+B
  4. 4
    第 28 行 Exit,第 29 行快捷键 Alt+F4。它上面那条细线是第 33 行的 new SeparatorMenuItem()(分隔线)
战斗中打开的 Game 菜单真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了

一行一行看:

  • new MenuItem("Run away"):一个菜单项(MenuItem)。注意它是菜单项,不是按钮。
  • setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN)):快捷键(accelerator),“R 键 + 按着 Ctrl”。不打开菜单,直接按 Ctrl+R 也能执行这一项。菜单右边的灰字 Ctrl+R 是 JavaFX 自动写上去的。
  • setOnAction(event -> navigator.runAway()):登记“被选中时做什么”(setOnAction)。括号里是一个 lambda,下面细讲。
  • 第 33 行把四样按顺序放进 Game 菜单:Run away、Main menu、分隔线、Exit。第 35 行用它造菜单栏,第 36 行让菜单栏和游戏一样宽(1280)。

哪些是字段、哪些不是?CGameMenu 只有 3 个字段:menuBar、runAway、mainMenu(第 15–17 行,打印的 UML 上也只有这 3 个)。menuBar 要交给窗口(getMenuBar()),runAway 和 mainMenu 以后要变灰变亮(update),所以要存着。exit 和 gameMenu 只是构造器里的局部变量:Exit 永远能点,造好以后再也不用碰它。

GameMenu 的 UML 类图
老师打印稿里的 GameMenu:只有 3 个 private 字段(红色方块)。Exit 菜单项不在字段里,因为它只是构造器里的局部变量。构造器的参数类型是 GameNavigator,不是 GameWindow

三个画面里的菜单

同一个菜单,在三个画面里打开,样子不一样(左、中、右三张):

选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
12
  1. 1
    Run away:灰
  2. 2
    Main menu:灰
左:选择界面真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
地图上打开 Game 菜单:Run away 灰,Main menu 可点
12
  1. 1
    Run away:灰
  2. 2
    Main menu:能点
中:地图真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
战斗中打开 Game 菜单:Run away 可以点了
12
  1. 1
    Run away:能点
  2. 2
    Main menu:能点
右:战斗中真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了

菜单问每个画面两个问题:问题 1 是 canRunAway()(能逃跑吗?),问题 2 是 canReturnToMainMenu()(能回主菜单吗?)。三个画面的答案:

画面Run away(问题 1 的答案)Main menu(问题 2 的答案)
CSelectScreen(上图左)灰(false,父类默认)灰(false,自己改写的)
COverworldScreen(上图中)灰(false,父类默认)能点(true,父类默认)
CBattleScreen 还在打(上图右)能点(true,自己改写的)能点(true,父类默认)
CBattleScreen 打完了(没有菜单打开的截图,见下面动画最后一步)灰(false:同一个改写,答案变了)能点(true,父类默认)

Exit 不在表里:update 根本不碰它,它永远能点。下面的动画一步一步看 update 怎么从代码得出这张表。上面的 update 一直显示;左下换成那一刻真正运行的那个方法;右下是那一刻的画面(前 5 步是打开的菜单)。

GameMenu.update:问两个问题ui/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 }
GameScreen 的默认答案ui/GameScreen.java第 42–44、47–49 行
42 public boolean canRunAway() {
43 return false;
44 }
⋯ 省略第 45–46 行
47 public boolean canReturnToMainMenu() {
48 return true;
49 }
SelectScreen 改写的答案ui/SelectScreen.java第 119–122 行
119 @Override
120 public boolean canReturnToMainMenu() {
121 return false;
122 }
BattleScreen 改写的答案ui/BattleScreen.java第 102–105 行
102 @Override
103 public boolean canRunAway() {
104 return !battle.isFinished();
105 }
BattleScreen:发现打完了 → showResultui/BattleScreen.java第 117–119、128–128、135–137、170–171 行
117 private void useSkill(Skill skill) {
118 battle.playTurn(skill);
119 update();
⋯ 省略第 120–127 行
128 private void update() {
⋯ 省略第 129–134 行
135 if (battle.isFinished() && !isDialogOpen()) {
136 showResult();
137 }
⋯ 省略第 138–169 行
170 private void showResult() {
171 getNavigator().updateMenu();
选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
12
  1. 1
    Run away 灰
  2. 2
    Main menu 灰
真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
地图上打开 Game 菜单:Run away 灰,Main menu 可点
12
  1. 1
    Run away 灰
  2. 2
    Main menu 能点
真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
战斗中打开 Game 菜单:Run away 可以点了
12
  1. 1
    Run away 能点
  2. 2
    Main menu 能点
真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    战斗结束:showResult 先 updateMenu(),再弹出 You Win
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
  1. 程序刚打开:showScreen(selectScreen) 的最后一行 updateMenu() 调用 menu.update(currentScreen)。参数 screen 的类型是 AGameScreen,这一次它指着 CSelectScreen 对象。update 只有两行:每行问画面一个问题,把答案用 ! 反过来,交给 setDisable(禁用:true = 变灰、点不了)。
  2. 第 47 行问 screen.canRunAway()。CSelectScreen 没有改写这个方法,所以运行父类 AGameScreen 的版本,返回 false。!false 是 true,setDisable(true):Run away 变灰。
  3. 第 48 行问 screen.canReturnToMainMenu()。这一个 CSelectScreen 改写了,返回 false。它上面的注释(第 118 行)说:“There is no game to leave yet”,还没有一局可以离开,用不着这一项。!false = true:Main menu 也变灰。
  4. 点了 Choose Mokepon:showScreen(overworldScreen) 又调用一次 update。COverworldScreen 两个方法都没改写,都用父类的:canRunAway() 是 false → Run away 灰;canReturnToMainMenu() 是 true → !true = false → setDisable(false):Main menu 能点了。
  5. 点了 Fight!:showScreen(new BattleScreen(...)),第三次 update。CBattleScreen 改写了 canRunAway():return !battle.isFinished();。刚开打,isFinished() 是 false,所以返回 true,!true = false:Run away 能点了。Main menu 用父类的 true,也能点。
  6. 打赢了。CBattleScreen 怎么知道?你每放一次技能,useSkill 都会在第 119 行调用 update()。update 的第 135 行问:battle.isFinished() 是 true,并且还没有对话框吗?这一次两个都成立,就调用 showResult()。
  7. showResult 的第一句就是第 171 行 getNavigator().updateMenu(),然后才弹出 You Win(右边)。为什么要它?因为这一刻画面没有换,showScreen 不会运行,菜单不会自己去问。第 168 行注释:“The battle is over, so the menu is updated to turn off "Run away".”
  8. updateMenu() → menu.update(currentScreen),currentScreen 还是这个 CBattleScreen。再问 canRunAway():同一个方法,可现在 isFinished() 是 true,返回 false:Run away 变灰,Ctrl+R 也不起作用了。Main menu 还能点。截图里菜单没有打开,看不到灰字;但拍这张截图时程序记下的菜单状态就是 runAway disabled、mainMenu enabled。

这就是又一次多态:update(GameScreen screen) 只认识父类 AGameScreen,从来不问“你是哪个画面”。真正运行哪个版本的 canRunAway(),由 screen 那一刻指着的对象决定。好处是:以后加一个新画面,只要它改写这两个方法(或者用默认答案),CGameMenu 一行都不用改。

update 上面的注释还说了一句:“A disabled option also ignores its keyboard shortcut.” 变灰的菜单项,快捷键也一起失效。所以在选择界面按 Ctrl+R 什么都不会发生,第 1 节说的 null 危险碰不到。在地图上按 Ctrl+R 也没有反应;要是这时 Run away 能用,world.runAway() 会把玩家无缘无故退回上一格(COverworld.runAway 里的 player.moveBack())。

6. 对话框:ModalDialog

一个对话框由哪几块组成

先比一比同一块地图,踩上感叹号之前和之后:

向下一格到 (18,17),左边就是绿色感叹号(weak marker)1
  1. 1
    之前:玩家在 (18,17),左边就是感叹号,下一步往左
之前:没有对话框真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示234
  1. 2
    黑色半透明的一层:字段 layer(一个 StackPane),1280×768,盖住整个画面。和前一张(之前)比,地图、卷轴、方向按钮全都暗了,也都点不到了
  2. 3
    白色的框:构造器里的局部变量 box(一个 VBox),放在 layer 的正中间
  3. 4
    菜单栏没有变暗:layer 只盖住画面的 view(菜单栏下面那一块),菜单还能用
之后:画面没换,只是 OverworldScreen 的 view 最上面多了一层真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

模态对话框(modal dialog)的意思是:它开着的时候,你只能先处理它,后面的东西都用不了。CModalDialog 的类注释(第 15–18 行)说:一个白框,里面有标题、几行字、几个按钮;后面有一层深色盖住整个画面,选一个按钮之前,后面什么都点不了。放大看白框:

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
4567
  1. 4
    标题:第 38 行的局部变量 headingLabel,哥特字体 32 号(HEADING_SIZE)
  2. 5
    字段 messages(VBox):提示一行一行竖着排
  3. 6
    字段 buttons(HBox):按钮横着排,间隔 12(BUTTON_GAP)
  4. 7
    第 42 行的 box:把 4、5、6 竖着装在一起,间隔 20(SPACING)
白框放大:标题、提示、按钮三层真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
ModalDialog:3 个字段和构造器ui/ModalDialog.java第 33–53 行
33 private final StackPane layer = new StackPane();
345 private final VBox messages = new VBox();
356 private final HBox buttons = new HBox(BUTTON_GAP);
36
37 public ModalDialog(String heading) {
384 Label headingLabel = UiStyle.createLabel(heading, UiStyle.loadGameFont(HEADING_SIZE));
39 messages.setAlignment(Pos.CENTER);
40 buttons.setAlignment(Pos.CENTER);
41
427 VBox box = new VBox(SPACING, headingLabel, messages, buttons);
43 box.setAlignment(Pos.CENTER);
44 box.setPadding(PADDING);
45 box.setStyle(BOX_STYLE);
46 /* Keep the box only as big as its contents, instead of filling the screen. */
47 box.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
48
49 /* The layer covers the whole screen and keeps the box in its centre. */
50 layer.setPrefSize(UiStyle.GAME_WIDTH, UiStyle.GAME_HEIGHT);
51 layer.setStyle(SHADE_STYLE);
52 layer.getChildren().add(box);
53 }

构造器(第 37–53 行)一步一步搭出这个框:

  1. 第 38 行:造标题(标注 4)。UiStyle.createLabel 和 UiStyle.loadGameFont 是第 7 节的小工具。
  2. 第 39–40 行:提示和按钮两排都居中。这时两排还是空的,后面用 addMessage、addButton 往里加。
  3. 第 42 行:new VBox(SPACING, headingLabel, messages, buttons):一个竖着排的框(VBox),从上到下放标题、提示、按钮,每层之间隔 20 像素(标注 7)。
  4. 第 43–45 行:内容居中,四周留白(PADDING:上 20、右 20、下 15、左 20),白底黑边(BOX_STYLE)。
  5. 第 47 行:setMaxSize(USE_PREF_SIZE, ...):框只要内容那么大,不要被撑满整个屏幕(第 46 行注释)。
  6. 第 50–52 行:layer 设成 1280 × 768,背景是 SHADE_STYLE(第 20 行:rgba(0, 0, 0, 0.45),45% 不透明的黑色),再把 box 放进去。layer 是一个 StackPane,它默认把里面的东西放在正中间,所以框总是居中的。

验证一下“正中间”:截图里白框在 x = 468.5、宽 343,中心 x = 468.5 + 343 ÷ 2 = 640,正好是 1280 的一半;y = 314.5、高 189,中心 y = 314.5 + 94.5 = 409,减去菜单栏 25 是 384,正好是 768 的一半。

ModalDialog 的另外 4 个方法ui/ModalDialog.java第 55–82 行
55 /* The part to add on top of the screen. */
56 public StackPane getLayer() {
57 return layer;
58 }
59
60 /* Add one line of text under the heading. */
61 public void addMessage(String text) {
62 Label message = new Label(text);
63 message.setTextFill(Color.BLACK);
64 messages.getChildren().add(message);
65 }
66
67 /* Add a button at the bottom, which runs the action when it is chosen. */
68 public void addButton(String text, EventHandler<ActionEvent> action) {
69 Button button = new Button(text);
70 button.setStyle(BUTTON_STYLE);
71 button.setOnAction(action);
72 buttons.getChildren().add(button);
73 }
74
75 /* A modal dialog takes the keyboard focus from the screen behind it,
76 * so the keys go to its first button.
77 */
78 public void focusFirstButton() {
79 if (!buttons.getChildren().isEmpty()) {
80 buttons.getChildren().getFirst().requestFocus();
81 }
82 }

谁造对话框,怎么盖上去

以遇敌框为例。COverworld 发现玩家踩上了感叹号,就通知画面 onEncounter(观察者,第 8 章)。下面的动画从这一刻开始,到你点 Fight!、画面换成战斗为止。这里用的是第二次踩上 (17,17) 的截图:第一次遇到的是 Hexwing,点了 Run Away!;这一次遇到的是 Galecrest(敌人是随机挑的)。图分成三段:造框、盖上、点按钮。

Cworld : OverworldCoverworldScreenCdialog:ModalDialog你CGameWindowonEncounter(encounter)1new ModalDialog(标题)2addMessage(...) ×23addButton(Fight!)4addButton(Run Away!)5
第 1 段(共 3 段):onEncounter 造一个框
Cworld : OverworldCoverworldScreenCdialog:ModalDialog你CGameWindowshowDialog(dialog)1getLayer()2focusFirstButton()3
第 2 段(共 3 段):showDialog 把框盖上去
Cworld : OverworldCoverworldScreenCdialog:ModalDialog你CGameWindow点 Fight!1运行 lambda2closeDialog()3startBattle()4
第 3 段(共 3 段):你点 Fight!,框拿掉,请窗口换画面
OverworldScreen.onEncounter(前半):标题和提示ui/OverworldScreen.java第 79–84 行
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 }
OverworldScreen.onEncounter(后半):两个按钮,然后 showDialogui/OverworldScreen.java第 85–93 行
85 dialog.addButton("Fight!", event -> {
86 closeDialog();
87 getNavigator().startBattle();
88 });
89 dialog.addButton("Run Away!", event -> {
90 closeDialog();
91 world.runAway();
92 });
93 showDialog(dialog);
GameScreen.showDialogui/GameScreen.java第 68–74 行
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 }
GameScreen.closeDialogui/GameScreen.java第 76–81 行
76 protected void closeDialog() {
77 if (dialog != null) {
78 view.getChildren().remove(dialog.getLayer());
79 dialog = null;
80 }
81 }
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
1
  1. 1
    玩家在 (18,17),左边就是感叹号
真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
再次踩上感叹号:又遇到一只(敌人可能不同)
12
  1. 1
    盖上来的框(Galecrest)
  2. 2
    Fight! 拿着键盘焦点
真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    新的 BattleScreen:敌人就是 Galecrest
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
  1. 玩家从 (18,17)(下面)往左走一格,踩上感叹号。COverworld 造好一个 CEncounter(这一次的敌人和地形),通知监听者 onEncounter(encounter)(第 1 个箭头,第 9 章)。下面看地图画面收到通知以后做什么。
  2. 第 81 行:new ModalDialog("Encountered a " + encounter.getEnemy().getName())。标题是一句英文加上敌人的名字,这一次是 Galecrest。构造器把白框、空的提示行、空的按钮行都搭好(上面讲过的第 37–53 行)。
  3. 第 82–84 行是一个 for-each 循环:for (String message : 列表) = 把列表里的每一句依次放进变量 message,每一句运行一次 dialog.addMessage(message)。这一次地形提示有 2 句,所以加了 2 行(也可能是 0 或 1 句,第 9 章)。
  4. 第 85–92 行加两个按钮,各带一个 lambda。两个 lambda 都有两句,所以都用了大括号,而且第一句都是 closeDialog()。Fight! 的第二句是 getNavigator().startBattle()(请窗口换画面);Run Away! 的第二句是 world.runAway()(不找窗口,画面不换,玩家退回上一格,第 15 章)。现在只是登记,都不运行。
  5. 第 93 行 showDialog(dialog),进到父类 AGameScreen 的代码:第 70 行先 closeDialog()(同一时间只能有一个框,这时本来就没有);第 71 行记进字段 dialog;第 72 行 getLayer() 拿到那一层,加到 view 的最后,所以它盖在地图、卷轴、方向按钮的上面。
  6. 第 73 行 dialog.focusFirstButton():第一个按钮 Fight! 拿到键盘焦点。框盖上了,下面就是这一刻:画面变暗,中间一个白框。现在 isDialogOpen() 是 true,方向键都不管用(第 4 节)。程序停下来,等你点。
  7. 你点了 Fight!(或者按空格,因为焦点在 Fight! 上)。按钮运行第 4 步登记的那个 lambda。lambda 的代码是写在 COverworldScreen 里的,所以箭头指回 overworldScreen。
  8. lambda 第一句(第 86 行)closeDialog():第 77 行有框,第 78 行把 layer 从 view 拿掉,第 79 行 dialog 变回 null。
  9. lambda 第二句(第 87 行)getNavigator().startBattle():请窗口换画面。代码里的类型是 IGameNavigator,接到调用的是 CGameWindow 对象,所以箭头指向它。它运行第 67 行:showScreen(new BattleScreen(world.createBattle(), images, this))。
  10. 结果:root 中间换成了一个新的战斗画面,敌人就是框里的 Galecrest。地图画面(连同它已经拿掉框的 view)还留在字段 overworldScreen 里,打完仗再放回来。

它是怎么把后面“挡住”的

游戏里的 4 种对话框

对话框(谁造)标题;提示行按钮 → 调用
遇敌框(COverworldScreen.onEncounter)"Encountered a " + 敌人名;再加 0–2 行地形提示Fight! → closeDialog(),startBattle();Run Away! → closeDialog(),world.runAway()
You Win(CBattleScreen.showResult)"You Win";没有提示行Back to it! → winBattle()
Game Over(CBattleScreen.showResult 调用 createGameEndDialog)"Game Over";没有提示行Return to Main Menu → returnToMainMenu();Quit → quit()
出口框(COverworldScreen.onExitReached 调用 createGameEndDialog)"You finished the game! You won " + fightsWon + " fights!"(第 110–111 行):整句都是标题,这一局 fightsWon = 1;没有提示行同上

四个框是同一个类 CModalDialog 的四个对象,只是标题、提示和按钮不同。画面类不用知道框是怎么排版的,只要会 new、addMessage、addButton、showDialog。框也不知道按钮点了以后要干什么,这件事由画面用 lambda 交给它。

ModalDialog 的 UML 类图
老师打印稿里的 ModalDialog:带下划线的 7 个是 static 常量(样式字符串和尺寸),下面 3 个才是普通字段 layer、messages、buttons。addButton 的第二个参数 EventHandler<ActionEvent> 就是点击时要运行的那段 lambda

7. 统一的样子:UiStyle 和 GameButton

UiStyle:大家共用的尺寸和小工具

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了123456
  1. 1
    背景:UiStyle.createBackground(),只有 AGameScreen 的构造器调用它。整张 1280×768 都是这张图,这里只框出一块空地方让你看清
  2. 2
    标题:createLabel + loadGameFont(34)(哥特字体)+ createWhiteOutline()(白色描边)
  3. 3
    名字:同样的字体(30 号)和白色描边
  4. 4
    火焰图标:createIcon(..., 24),缩到 24 像素、保持比例
  5. 5
    卷轴图片:createImageView(loadImage(SCROLL_FILE), ...)
  6. 6
    数值表的字:左列 createSansSerifFont(粗体),右列 createMonospaceFont(等宽)
选择界面上,这些部件都是用 UiStyle 的小工具造的真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
UiStyle:常量、字体、背景ui/UiStyle.java第 14–31、66–68 行
14/* Shared look of the game: sizes, asset files and helpers used by every screen. */
15public class UiStyle {
16 public static final int GAME_WIDTH = 1280;
17 public static final int GAME_HEIGHT = 768;
18 public static final String SCROLL_FILE = "assets/scroll.png";
19
20 private static final String BACKGROUND_FILE = "assets/bg.png";
21 private static final String FONT_FILE = "assets/GrenzeGotisch-Black.ttf";
22 /* JavaFX logical fonts, so the game looks the same on every computer. */
23 private static final String SANS_SERIF = "SansSerif";
24 private static final String MONOSPACE = "Monospaced";
25 /* The light shines straight down, so the image is lit evenly. */
26 private static final double STRAIGHT_DOWN = 90;
27
28 /* Load the game's gothic font at the given size. */
29 public static Font loadGameFont(int size) {
30 return Font.loadFont("file:" + FONT_FILE, size);
31 }
⋯ 省略第 32–65 行
66 public static ImageView createBackground() {
67 return createImageView(loadImage(BACKGROUND_FILE), 0, 0, GAME_WIDTH, GAME_HEIGHT);
68 }

CUiStyle 里所有的成员都是 static 的(static:属于类本身,不属于某个对象)。用的时候直接写 UiStyle.loadGameFont(30),不用先 new UiStyle()。但不是全都 public:

这种只装常量和工具方法的类叫工具类(utility class)。它的作用是:大家用同一个数字、同一个做法。一个小毛病:CUiStyle 没有写构造器,Java 会自动给它一个 public 的默认构造器,所以 new UiStyle() 其实能编译(只是没有任何意义)。考试问“可以怎么改进”时,可以说加一个 private UiStyle() {},让别人没法 new 它。

UiStyle 的成员做什么谁用(举例)
GAME_WIDTH = 1280,GAME_HEIGHT = 768游戏区大小AGameScreen 的 view、CModalDialog 的 layer、CGameMenu 菜单栏宽、COverworldScreen 让地图居中
createBackground()bg.png 铺满 1280×768只有 AGameScreen 构造器
loadGameFont(size)游戏的哥特字体标题、按钮、对话框标题
createLabel / createCenteredLabel黑字对话框标题、战斗画面的 Actions
loadImage / createImageView / createIcon读图片、放图片背景、卷轴、头像、方向键图标
tint(imageView, colour)给图片染色CMapView:白、红、绿感叹号和洋红色的门
createWhiteOutline()白色描边选择界面的标题和名字

好处:如果游戏区要改成别的大小,只改 GAME_WIDTH、GAME_HEIGHT 两行,画面、对话框、菜单栏一起变,不会有谁漏改。

GameButton:半透明的灰按钮

选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
12
  1. 1
    普通按钮:背景 rgba(211,211,211,0.5),50% 不透明的浅灰(NORMAL)。浅灰只盖住一半,下面的棕色透上来,所以看起来是灰棕色
  2. 2
    被选中的 Nightwing:setHighlighted(true),背景的浅灰从 50% 不透明变成 75%(HIGHLIGHTED)。浅灰盖得更多,在棕色背景上看起来更亮、更实。(代码第 45 行的注释写的是 “Darken”,但截图里其实是变亮)
选择界面左边的名字按钮真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
GameButton:三个透明度,三个方法ui/GameButton.java第 7–23、45–61 行
7/* A button with the game's rounded, see-through grey background. */
8public class GameButton extends Button {
91 private static final double NORMAL = 0.5;
102 private static final double HIGHLIGHTED = 0.75;
11 /* A disabled button is drawn at 40% opacity, so its 50% grey background looks
12 * 20% grey, as the spec asks. Its text and image fade too, as in the sample screenshots.
13 */
14 private static final double DISABLED_OPACITY = 0.4;
15
16 private final int cornerRadius;
17
18 public GameButton(String text, Font font, int cornerRadius) {
19 super(text);
20 this.cornerRadius = cornerRadius;
21 setFont(font);
22 setBackgroundOpacity(NORMAL);
23 }
⋯ 省略第 24–44 行
45 /* Darken the background to show this button is the current choice. */
46 public void setHighlighted(boolean highlighted) {
47 setBackgroundOpacity(highlighted ? HIGHLIGHTED : NORMAL);
48 }
49
50 /* A disabled button fades out as a whole, including its text and image. */
51 public void setUsable(boolean usable) {
52 setDisable(!usable);
53 setBackgroundOpacity(NORMAL);
54 setOpacity(usable ? 1 : DISABLED_OPACITY);
55 }
56
57 private void setBackgroundOpacity(double opacity) {
58 setStyle("-fx-background-color: rgba(211,211,211," + opacity + ");"
59 + "-fx-background-radius: " + cornerRadius + ";"
60 + "-fx-text-fill: black;");
61 }

CGameButton 是 extends Button:它是一个 JavaFX 按钮,只是多了几种样子。背景的样子都是 private 方法 setBackgroundOpacity(第 57–61 行)设的:它拼出一段 JavaFX 样式字符串,背景颜色是 rgba(211,211,211, 透明度)(211,211,211 是浅灰),圆角是 cornerRadius,字是黑的。禁用时整个按钮变淡,则是另一个方法 setOpacity 做的(下面 setUsable)。

游戏刚打开:选择界面,还没选 Mokepon
3
  1. 3
    setUsable(false):点不了,整个按钮只剩 40%
之前:还没选 Mokepon真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
3
  1. 3
    setUsable(true):能点,不透明度回到 1
之后:选了 Impike真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

灰按钮为什么看起来是“20% 的灰”?第 11–13 行注释算给你看了:背景本来是 50% 的灰,整个按钮再乘 0.4,0.5 × 0.4 = 0.2,就是 20%(作业说明要求的样子)。字也一起变淡,和样例截图一样。

能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
4556
  1. 4
    三个道具按钮:数量都是 0,setUsable(false),整个按钮变淡
  2. 5
    Tackle [5] 和 Aim [3]:能量 0 用不起,setUsable(false)
  3. 6
    Wait [-2]:setUsable(true),字是实的
战斗中(这一局玩家是 Shellurk)真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
77
  1. 7
    战斗结束:!battle.isFinished() 是 false,所有道具按钮和技能按钮都 setUsable(false),连 Wait 也灰了
打赢以后(另一局,Impike)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框

谁在调用 setUsable?CSelectScreen(Choose 按钮)、COverworldScreen(四个方向按钮:setUsable(world.canMove(方向)))、CBattleScreen(技能和道具按钮)。三个画面都只说“能用 / 不能用”,至于“不能用长什么样”,只写在 CGameButton 这一个地方。

两个构造器和 addPressEffectui/GameButton.java第 18–43 行
18 public GameButton(String text, Font font, int cornerRadius) {
19 super(text);
20 this.cornerRadius = cornerRadius;
21 setFont(font);
22 setBackgroundOpacity(NORMAL);
23 }
24
25 /* A button that only shows an image, such as a movement arrow. */
26 public GameButton(ImageView icon, int cornerRadius) {
27 this("", Font.getDefault(), cornerRadius);
28 setGraphic(icon);
29 }
30
31 /* Darken the background while the mouse button is held down. */
32 public void addPressEffect() {
33 setOnMousePressed(event -> {
34 if (!isDisabled()) {
35 setBackgroundOpacity(HIGHLIGHTED);
36 }
37 });
38 setOnMouseReleased(event -> {
39 if (!isDisabled()) {
40 setBackgroundOpacity(NORMAL);
41 }
42 });
43 }
GameButton 的 UML 类图
老师打印稿里的 GameButton:空心三角箭头指向 JavaFX 的 Button,意思是 extends。三个带下划线的 double 是 static 常量 0.5 / 0.75 / 0.4;两个同名的构造器就是重载;setBackgroundOpacity 是 private(红色方块)

8. 所有的画面切换,和一次完整的“点 Choose Mokepon”

把前面的东西拼起来。整个游戏里,画面只会这样换:

地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuistartGame()returnToMainMenu()startBattle()winBattle()runAway()returnToMainMenu()
三个画面之间的所有切换:外侧的直箭头是前进,内侧的弯箭头和底下的长箭头是往回走。箭头上写的是 GameNavigator 的方法(都由 GameWindow 实现,最后都调用 showScreen)。图上没画 quit():在哪个画面都能调用,调用后程序直接结束(Platform.exit())
从 → 到(你做了什么)方法CGameWindow 里做的事
选择 → 地图(Choose Mokepon)startGamenew COverworld、new COverworldScreen、addListener、showScreen
地图 → 战斗(遇敌框的 Fight!)startBattle请 world 造一场 CBattle,new 一个 CBattleScreen,showScreen
战斗 → 地图(You Win 的 Back to it!)winBattleworld.winEncounter(),再 showScreen(overworldScreen)
战斗 → 地图(菜单 Run away / Ctrl+R)runAwayworld.runAway(),再 showScreen(overworldScreen)
地图或战斗 → 选择(菜单 Main menu / Ctrl+B,或 Return to Main Menu)returnToMainMenu两个字段设 null,clearSelection(),showScreen(selectScreen)
→ 结束(菜单 Exit / Alt+F4,或 Quit)quitPlatform.exit()(结束程序)

每一种切换做完以后,你看到的真实画面(点的是哪个按钮,见第 2 节开头的截图):

注意:所有“换画面”最后都落到同一个 showScreen。对话框(遇敌、You Win、Game Over、出口)都不是换画面,只是盖在当前画面上;遇敌框里的 Run Away! 也不换画面(上面最后一张)。逃跑、赢了、回主菜单、出口的完整流程在 第 15 章。

最后,把第一种切换从头到尾慢放一遍:你点 Choose Mokepon,到地图出现、菜单变好,中间每一步是哪一行代码。顺序图的第 2 根线是 CGameWindow 对象:selectScreen 手里的 navigator 指着的就是它(代码里写的类型是 IGameNavigator)。

CselectScreenCGameWindowCoverworldScreenCmenu : GameMenuCworld : OverworldstartGame(Impike)1new Overworld(…)2new OverworldScreen3addListener(overworldScreen)4
第 1 段(共 2 段):startGame 造出这一局
CselectScreenCGameWindowCoverworldScreenCmenu : GameMenuCworld : OverworldshowScreen(overworldScreen)1getView()2update(currentScreen)3canRunAway()4canReturnToMainMenu()5
第 2 段(共 2 段):showScreen 换画面,菜单重新问
SelectScreen.createChooseButton(最后 3 行)ui/SelectScreen.java第 244–246 行
244 button.setUsable(false);
245 button.addPressEffect();
246 button.setOnAction(event -> getNavigator().startGame(selectedMokepon));
GameWindow.startGameui/GameWindow.java第 58–63 行
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 }
GameMenu.updateui/GameMenu.java第 46–49 行
46 public void update(GameScreen screen) {
47 runAway.setDisable(!screen.canRunAway());
48 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1
  1. 1
    Choose Mokepon:已经能点(选的是 Impike)
真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    root 中间换成了地图画面的 view
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
地图上打开 Game 菜单:Run away 灰,Main menu 可点
12
  1. 1
    Run away 灰
  2. 2
    Main menu 能点
真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
GameWindow.showScreenui/GameWindow.java第 101–105 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }
GameWindow:showScreen 第 104 行调用上面的 updateMenuui/GameWindow.java第 97–105 行
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 }
  1. 你在选择界面选好 Impike,点了 Choose Mokepon(下面就是那个按钮,选了以后它才亮)。这一下点击引起的所有事情,就是这个动画。
  2. 按钮是 createChooseButton 造的:第 244 行一开始 setUsable(false);第 246 行登记了点它时要做的事,lambda getNavigator().startGame(selectedMokepon)。getNavigator() 交出父类字段 navigator 里的对象,它其实就是 CGameWindow,但 CSelectScreen 只知道它是一个 IGameNavigator。selectedMokepon 是刚才点名字时记下的 Impike。
  3. 来到 CGameWindow.startGame。第 59 行 new Overworld(map, mokepons, playerChoice):造这一局的规则对象,里面有一个新的玩家(站在起点)、一个空背包、地图物体的副本。存进字段 world,它不再是 null。
  4. 第 60 行 new OverworldScreen(world, images, this):造地图画面。它的构造器先 super(navigator)(棕色背景),再造地图、卷轴、状态栏、方向按钮,放进自己的 view(第 8 章)。这时它还没出现在窗口里,你看到的还是选择界面。
  5. 第 61 行 world.addListener(overworldScreen):把地图画面登记成 COverworld 的监听者。以后玩家走路、遇敌、捡到东西,COverworld 就会通知它(第 4 节动画第 9 步就是这样)。是 CGameWindow 登记的,不是画面自己。少了这一行,规则照样运行,画面却永远不会动。
  6. 第 2 段。第 62 行 showScreen(overworldScreen):第 102 行 currentScreen = screen,按键从此交给地图画面;第 103 行 root.setCenter(screen.getView()),中间换成地图画面的 view(左下)。选择界面只是被换下来,没有扔掉。
  7. 第 104 行 updateMenu() → 第 98 行 menu.update(currentScreen):让菜单去问刚换上来的地图画面。
  8. CGameMenu.update 问两个问题。COverworldScreen 都用父类的默认答案:canRunAway() 是 false → setDisable(true);canReturnToMainMenu() 是 true → setDisable(false)。
  9. 结果:打开 Game 菜单就是下面这样,Run away 灰,Main menu 能点。到这里,点一下 Choose Mokepon 引起的事情就全部做完了,程序停下来,等你的下一次按键或点击。

这一章的 7 个类合在一起,就是整个游戏的“骨架”:CGameWindow 一次只放一个画面,所有换画面都走 showScreen;画面和菜单只通过接口 IGameNavigator 请窗口做事;三个画面共用父类 AGameScreen 的背景、对话框、按键规矩和菜单的默认答案;CModalDialog 和 CGameButton 让各处的框和按钮长得一样。下一章开始一个一个看具体的画面,先从 第 7 章 选择界面 SelectScreen 开始。

自测

从选择界面换到地图时,窗口里哪些东西换了,哪些没换?

只换了 root(一个 BorderPane)中间那一块:root.setCenter(overworldScreen.getView())。窗口 stage、Scene、root 本身、顶上的菜单栏都是同一个,从程序开始到结束都不换。currentScreen 字段也改指新画面。英语:Switching screens only replaces the centre of the BorderPane; the stage, the scene and the menu bar stay the same.

在选择界面时,CGameWindow 的 world 是什么?如果这时 Run away 菜单项能点,会发生什么?

world 和 overworldScreen 都是 null(第 26 行注释),因为还没开始一局。runAway() 第一句是 world.runAway(),对 null 调用方法会抛出 NullPointerException。实际上不会发生:CSelectScreen 用的是父类的 canRunAway()(false),CGameMenu.update 把 Run away 设成 disabled,快捷键 Ctrl+R 也一起失效。world and overworldScreen are null on the select screen; startGame creates them and returnToMainMenu sets them back to null.

打完仗回到地图,玩家还站在原来的位置。这个位置存在哪个对象里?为什么 CGameWindow 不用重新 addListener?

位置存在 world(COverworld)的 CPlayer 对象里,不在画面里。监听者登记在 world 的 listeners 列表里。打仗时 CGameWindow 的 world 和 overworldScreen 两个字段都没变,winBattle / runAway 只是 showScreen(overworldScreen) 把同一个画面放回来,所以位置和登记都还在。The player's position lives in the Overworld's Player, and the listener stays registered in the Overworld, so the same OverworldScreen can simply be shown again.

CSelectScreen 的代码里有没有 GameWindow 这个词?那它是怎么让窗口换到地图的?这样设计有什么好处?

没有。它调用 getNavigator().startGame(selectedMokepon),navigator 的类型是接口 IGameNavigator。运行时里面装的是 CGameWindow(它 implements 这个接口),所以运行的是 CGameWindow.startGame。好处:画面碰不到窗口的其他部分(stage、root、showScreen),两边不会互相依赖,换一个实现画面也不用改。The screens only depend on the GameNavigator interface, not on the concrete GameWindow class (Dependency Inversion).

为什么 handleKeyPress 是 final 的,handleKey 却是 protected,而且默认什么都不做?

这是模板方法。handleKeyPress 定好固定步骤:对话框开着、或者按着 Ctrl/Alt/Meta/Shift 时直接 return,否则调用 handleKey。写成 final,子类就不能改写它、跳过这两条规矩。handleKey 是留给子类填的钩子:COverworldScreen 填方向键,CBattleScreen 填 1–4 键;CSelectScreen 不需要按键,就用空的默认版本,不用写任何代码。handleKeyPress is a final template method that ignores keys while a dialog is open or a modifier is held, then calls the protected hook handleKey.

打赢一场仗以后,画面没有换,为什么 Run away 会变灰?是哪一行做的?

放完最后一个技能,useSkill 调用 update(),第 135 行发现战斗结束了、又没有对话框,就调用 showResult()。CBattleScreen.showResult 第 171 行 getNavigator().updateMenu() → CGameWindow.updateMenu → menu.update(currentScreen) → 问 canRunAway()。CBattleScreen 改写的版本是 return !battle.isFinished();,战斗结束后是 false,所以 Run away 被 setDisable(true)。平时菜单只在 showScreen 时更新,这里画面没换,所以要自己叫一次。BattleScreen calls updateMenu() in showResult because the screen does not change when the battle ends.

遇敌框开着的时候,按方向键会怎样?用鼠标点右下角的方向按钮呢?点 Game 菜单呢?

方向键:handleKeyPress 看到 isDialogOpen() 是 true 就 return,玩家不动。方向按钮:点不到,因为对话框的 layer(1280×768 的半透明 StackPane)是 view 里最上面的一层,挡住了所有点击;就算点到了,movePlayer 也会再查一次对话框。Game 菜单:能点,因为菜单栏在 root 的上面,不在画面的 view 里,layer 盖不到它(这时 Main menu 能用,Run away 是灰的)。

第 4 节截图里的 Shellurk 只有 3 个技能(Tackle、Aim、Wait)。按数字键 4 会怎样?能量不够时按 1 呢?

按 4:SKILL_KEYS.indexOf(DIGIT4) 是 3,第 111 行要求 index < skillButtons.size(),3 不小于 3,什么都不做。能量不够时按 1:会执行 skillButtons.get(0).fire(),但这个按钮已经被 setUsable(false) 禁用,禁用的按钮 fire() 没有反应。所以不用另外判断能量。

CGameButton 的 0.5、0.75、0.4 分别是什么?灰按钮为什么看起来是 20% 的灰?

0.5(NORMAL)是普通按钮浅灰背景的不透明度,0.75(HIGHLIGHTED)是被选中或按住时的背景(浅灰盖得更多,在棕色背景上看起来更亮),0.4(DISABLED_OPACITY)是禁用时整个按钮的不透明度。禁用时背景回到 0.5,再整体乘 0.4:0.5 × 0.4 = 0.2,所以是 20% 的灰,字也一起变淡。这些都写在 CGameButton 一个地方,三个画面只调用 setUsable / setHighlighted。

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