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

07选择界面 SelectScreen

列表按钮、点一下发生了什么、Choose 按钮怎么开始一局新游戏

读完这一章你能
  • 指着选择界面的每一块,说出是 CSelectScreen 里哪一行代码造的、存在哪个字段里(或者根本没有字段)
  • 用布局常量算出截图上的坐标:为什么头像在 (673, 98)、数值那一列从 x = 720 开始
  • 讲清楚 event -> select(mokepon, button) 这个 lambda:36 个按钮共用一个 select 方法,为什么每个按钮还知道自己是哪一只
  • 一行一行说出点一个名字以后 select 做了什么,为什么 NORMAL 类型没有小图标
  • 说出 Choose Mokepon 为什么一开始点不了、Game 菜单里的 Main menu 为什么是灰的、回到主菜单时谁把选择清空
  • 用英语说:Each name button has its own lambda that captures its Mokepon, and all of them call the same select method.

第 1 章已经带你看过选择界面长什么样。这一章打开 ui/SelectScreen.java(一共 291 行),从上到下把它读完。每一段代码旁边都放着它画出来的真实截图,代码行上的红色数字和截图上的红色数字是一一对应的。

先回忆几个第 1 章讲过的词(Java 语法细节在 第 2 章):

1. 先看画面:选择界面由哪些部件组成

下面是选中 Impike 以后的选择界面,切成左右两半放大。除了第 1 块菜单栏,其余每一块都是 CSelectScreen 造出来的。

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12345
  1. 1
    菜单栏:CGameMenu 造的,不归 CSelectScreen 管
  2. 2
    标题 Choose your Mokepon:一个 Label,createTitle 造的
  3. 3
    名字列表:一个 ScrollPane,createMokeponList 造的
  4. 4
    列表里的一个名字按钮:CGameButton,36 只 Mokepon 就有 36 个
  5. 5
    竖着的滚动条:36 个按钮放不下,ScrollPane 自己加上的
左半边:标题和名字列表真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
678910111213
  1. 6
    灰色的圆:一个 Circle,createPortraitCircle 造的
  2. 7
    头像:字段 portraitView(ImageView)
  3. 8
    名字:字段 nameLabel(Label)
  4. 9
    类型小图标(火焰):字段 typeIcon(ImageView)
  5. 10
    卷轴图片:一个 ImageView,构造器里直接造的
  6. 11
    数值表的左列:8 个名字 Label(HP: … Type:)
  7. 12
    数值表的右列:字段 statValues(6 个)、skillsValue、typeValue
  8. 13
    Choose Mokepon:字段 chooseButton(CGameButton)
右半边:头像、名字、卷轴和 Choose 按钮真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

注意图例里有的写了“字段 xxx”,有的没写。这不是随便写的。CSelectScreen 只给以后还要改、而且要直接找到的部件开字段:点名字以后,头像、名字、类型图标、数值、Choose 按钮都要变,所以它们存在字段里,select 方法才能找到它们去改。标题、灰色圆、卷轴图片造好以后再也不变,就只放进画面,不存。名字列表也没有字段:列表里的按钮颜色会变(选中的那个变亮),可是 select 是通过参数 button 和字段 selectedButton 找到要变色的那一个(第 4、5 节),不需要一个存着整张列表的字段。

选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonui界面要用的图片路径:类型图标、地块、方向按钮、道具、玩家。CGameImagesdataportraitView(7): ImageViewnameLabel(8): LabeltypeIcon(9): ImageView数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodelstatValues(12): Map<Stat, Label>skillsValue(12): LabeltypeValue(12): Label没有字段,只放进画面:2 标题、3 名字列表6 灰色圆、10 卷轴图片11 表格和左列名字imagesselectedMokeponchooseButton(13)selectedButton只放进画面
SelectScreen 的字段,括号里是上面截图的编号。空心菱形 = 用字段存着;空心三角 = extends;细线 = 只放进画面,没有字段

这张图里有三个容易搞错的地方:

SelectScreen 的 UML 类图
老师打印稿里的 SelectScreen。红色方块 = private,绿色圆点 = public;带下划线的是 static。上面一大串大写名字是布局常量(第 3 节),下面 10 个不带下划线的是字段,和上面那张图一样

在 UML 上怎么找这一章的东西

最上面的空心三角指向 AGameScreen:extends。

常量(带下划线,static final):PROMPT 到 CHOOSE_RADIUS,一共 37 个。按名字的开头分成几组,每组管画面上的一块:

名字开头管哪一块
PROMPT没选时的名字 “Select a Mokepon”
TITLE_标题
LIST_、BUTTON_RADIUS名字列表和名字按钮
CIRCLE_、PORTRAIT_SIZE灰色圆和头像
NAME_、TYPE_ICON_名字一行和类型图标
SCROLL_卷轴图片
STATS_、LABEL_WIDTH、VALUE_WIDTH、ROW_HEIGHT、STAT_FONT_SIZE卷轴上的数值表
CHOOSE_Choose Mokepon 按钮

字段(不带下划线):images、portraitView、nameLabel、typeIcon、statValues、skillsValue、typeValue、chooseButton、selectedButton、selectedMokepon。

方法:只有 3 个是 public(绿点):构造器、canReturnToMainMenu()(第 7 节)、clearSelection()(第 8 节)。其余 12 个都是 private(红方块),只给自己用:select、showTypeIcon、formatSkills、addStatRow 和 8 个 create...。

考试问“SelectScreen 有哪些 public 方法、谁调用它们”:构造器和 clearSelection() 由 CGameWindow 调用,canReturnToMainMenu() 由 CGameMenu 调用。

2. 构造器收到的三样东西:数据从哪来

构造器的第一行是 public SelectScreen(List<Mokepon> mokepons, GameImages images, GameNavigator navigator)。括号里是三个参数(parameter),也就是 new 它的人必须交过来的三样东西。

这三样是谁交的?顺着程序启动的顺序看(启动的全过程在 第 5 章)。先说一下顺序图怎么读(第 1 章第 3 节讲过):每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回。

CMainCMokeponParserCImageParserCGameWindowCSelectScreenparse(data)1List<Mokepon>(36 只)2parse(data)3GameImages4new GameWindow(…)5new SelectScreen(…)6构造器把画面造出来
选择界面的数据是怎么一路传进来的
Main.start:读出 Mokepon 和图片路径,交给窗口Main.java第 35–39 行
351 List<Mokepon> mokepons = new MokeponParser().parse(data);
36 GameMap map = new MapParser().parse(data);
372 GameImages images = new ImageParser().parse(data);
38 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
39 gameWindow.show();

第 36 行的 map 是地图,选择界面用不到(第 8 章)。第 35–37 行都用到的 data 是第 25 行从 mokepon.game 读出来的全部数据(第 5 章第 3 节)。

GameWindow:第 17 行实现接口;构造器存起来,再 new SelectScreenui/GameWindow.java第 17–17、31–42 行
173public class GameWindow implements GameNavigator {
⋯ 省略第 18–30 行
31 public GameWindow(Stage stage, List<Mokepon> mokepons, GameMap map, GameImages images) {
32 this.stage = stage;
331 this.mokepons = mokepons;
34 this.map = map;
352 this.images = images;
36 stage.setTitle("Mokepon");
37 stage.setResizable(false);
38
39 menu = new GameMenu(this);
40 root.setTop(menu.getMenuBar());
413 selectScreen = new SelectScreen(mokepons, images, this);
42 showScreen(selectScreen);
  1. 1 号:List<Mokepon>。CMokeponParser 把数据文件 mokepon.game 里 mokepon.0 到 mokepon.35 读成 36 个 CMokepon 对象,按文件里的顺序放进一个列表。CGameWindow 用字段 mokepons 存着它(以后开新局还要用)。
  2. 2 号:CGameImages。CImageParser 读出所有图片路径(类型图标、地形格子、方向按钮、道具、玩家),装进一个 CGameImages 对象。选择界面只用其中的类型图标。
  3. 3 号:this。第 41 行 new SelectScreen(mokepons, images, this) 的第三个值是 this,也就是 CGameWindow 自己。可是参数的类型写的是接口 IGameNavigator。CGameWindow 第 17 行写着 public class GameWindow implements GameNavigator:它实现(implements)了这个接口,所以可以当 IGameNavigator 交过去。CSelectScreen 只知道“我有一个能 startGame 的东西”,不知道它其实是窗口。这叫依赖倒置(Dependency Inversion),第 6 章和 第 16 章细讲。

三样东西进了构造器以后,去向各不相同:

卷轴上的数字就是从 1 号列表里来的。比如 Impike 是列表里的第 15 只(下标 14),数据文件第 155–164 行:

mokepon.game:Impike 这一段mokepon.game第 155–164 行
1551mokepon.14.name=Impike
156mokepon.14.image=assets/Impike.png
1572mokepon.14.hp=150
158mokepon.14.energy=22
159mokepon.14.strength=76
160mokepon.14.accuracy=74
161mokepon.14.defence=45
162mokepon.14.dodge=70
1633mokepon.14.skills=Lance|Stun|Wait
1644mokepon.14.type=FIRE
  • 每一行是“键=值”。键都以 mokepon.14. 开头:14 是下标(从 0 数),所以 Impike 是第 15 只。
  • 第 156 行 image= 是头像图片的路径,select 用它读出头像(第 5 节)。
  • 第 157–162 行六个数,读进来以后存在 CMokepon 对象里,按 EStat 查(第 5 节“六个数”)。
  • 第 163 行技能用竖线 | 隔开,到屏幕上变成逗号(第 5 节“技能那一串字”)。
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1234
  1. 1
    名字 Impike:第 155 行 name=Impike
  2. 2
    六个数:第 157–162 行 hp、energy、strength、accuracy、defence、dodge
  3. 3
    技能:第 163 行,竖线 | 变成了逗号
  4. 4
    类型:第 164 行 type=FIRE
数据文件里写的,原样显示在卷轴上(头像来自第 156 行的图片路径)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

3. 构造器:先造部件,再按顺序放进画面

现在读构造器本身(第 88–116 行)。它做两件事:先造以后要改的部件(存进字段),再放:调用一次 addToView(...),把 8 样东西按顺序放进画面。“造出来”和“放进画面”是两回事:一个 Label 造好了,只要还没放进画面,屏幕上就看不到它。先看前半段,第 88–103 行:

SelectScreen 构造器的前半段:造部件ui/SelectScreen.java第 88–103 行
88 public SelectScreen(List<Mokepon> mokepons, GameImages images, GameNavigator navigator) {
89 super(navigator);
90 this.images = images;
91 DropShadow outline = UiStyle.createWhiteOutline();
92
93 double portraitX = CIRCLE_CENTRE_X - PORTRAIT_SIZE / 2.0;
94 double portraitY = CIRCLE_CENTRE_Y - PORTRAIT_SIZE / 2.0;
95 portraitView = UiStyle.createImageView(null, portraitX, portraitY,
96 PORTRAIT_SIZE, PORTRAIT_SIZE);
97 nameLabel = UiStyle.createLabel(PROMPT, UiStyle.loadGameFont(NAME_FONT_SIZE));
98 nameLabel.setEffect(outline);
99 typeIcon = UiStyle.createIcon(null, TYPE_ICON_SIZE);
100 chooseButton = createChooseButton();
101 skillsValue = createValueLabel();
102 skillsValue.setWrapText(true);
103 typeValue = createValueLabel();

一句一句看:

后半段:一个 addToView,8 样东西,顺序就是谁盖住谁

第 105–114 行只有一句话:调用 addToView(...),括号里 8 个值,用逗号隔开。AGameScreen 的 addToView(Node... nodes) 用的是可变参数:传几个都行。Java 会先从左往右把这 8 个值一个一个算出来(该调用的 create... 方法这时候才运行),全部算完再一起交给 addToView,加到 view 里。后加的画在上面。

下面的动画把整个构造器从头走一遍。代码行上的红色编号 1–9 和截图上的 1–9 一一对应(图例在截图下面)。注意:截图是游戏刚打开时截的,也就是构造器全部跑完以后的样子,所以图上什么都已经有了。动画里亮着的框 = 到这一步为止已经放进 view,或者已经排进 addToView(...) 括号里、等着一起放进去的部分;暗着的框 = 还没轮到。亮起来的先后,就是从下往上画的先后。

ui/SelectScreen.java第 80–80、88–116 行
80 private final Map<Stat, Label> statValues = new EnumMap<>(Stat.class);
⋯ 省略第 81–87 行
88 public SelectScreen(List<Mokepon> mokepons, GameImages images, GameNavigator navigator) {
891 super(navigator);
90 this.images = images;
91 DropShadow outline = UiStyle.createWhiteOutline();
92
93 double portraitX = CIRCLE_CENTRE_X - PORTRAIT_SIZE / 2.0;
94 double portraitY = CIRCLE_CENTRE_Y - PORTRAIT_SIZE / 2.0;
95 portraitView = UiStyle.createImageView(null, portraitX, portraitY,
96 PORTRAIT_SIZE, PORTRAIT_SIZE);
97 nameLabel = UiStyle.createLabel(PROMPT, UiStyle.loadGameFont(NAME_FONT_SIZE));
98 nameLabel.setEffect(outline);
99 typeIcon = UiStyle.createIcon(null, TYPE_ICON_SIZE);
100 chooseButton = createChooseButton();
101 skillsValue = createValueLabel();
102 skillsValue.setWrapText(true);
103 typeValue = createValueLabel();
104
105 addToView(
1062 createPortraitCircle(),
1073 portraitView,
1084 createTitle(outline),
1095 createMokeponList(mokepons),
1106 createNameRow(),
1117 UiStyle.createImageView(UiStyle.loadImage(UiStyle.SCROLL_FILE),
112 SCROLL_X, SCROLL_Y, SCROLL_WIDTH, SCROLL_HEIGHT),
1138 chooseButton,
1149 createStatsGrid());
115 showTypeIcon(false);
116 }
游戏刚打开:选择界面,还没选 Mokepon123456789
  1. 1
    背景(父类放的):整张棕色图铺满游戏区,这里只圈出一块空地
  2. 2
    灰色圆
  3. 3
    头像框(空的)
  4. 4
    标题
  5. 5
    名字列表
  6. 6
    名字一行(HBox)
  7. 7
    卷轴图片
  8. 8
    Choose 按钮
  9. 9
    数值表(GridPane)
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
  1. CGameWindow 执行 new SelectScreen(mokepons, images, this)。第 89 行 super(navigator) 先运行 AGameScreen 的构造器:存好 navigator,view 设成 1280 × 768,放进第一样东西:棕色背景(标注 1)。后面放的都盖在它上面。
  2. 父类构造器一结束,先执行字段声明后面的赋值:第 80 行造出一个空的 EnumMap,存进 statValues。现在里面一个 Label 都没有,画面上也没有多出任何东西。等第 114 行 createStatsGrid()(以后在标注 9 的位置)才往里放。
  3. 第 90 行把参数 images 存进字段,select 以后要用。第 91 行造白色描边 outline,等一下标题和名字共用。这两行在画面上什么都不出现,所以还是只有 1 亮着。
  4. 算出头像框的左上角 (673, 73),造一个空的 ImageView 存进 portraitView。它造好了,但还没放进 view,所以 3 号框还是暗的(它以后出现在 3 号位置)。
  5. 再造名字 nameLabel、类型图标 typeIcon、Choose 按钮 chooseButton、技能和类型的值。全都存进字段,也全都还没放进 view。Choose 按钮这时候已经被设成点不了(它以后在 8 号位置)。
  6. 开始算 addToView 的第 1 个值:createPortraitCircle() 造出灰色圆(圆心 (801, 201),半径 163,半透明的浅灰,黑边 2 像素)。没有字段存它,造好直接排进括号里(2 亮了)。
  7. 第 2 个值:前面造好的 portraitView(3 亮了)。它排在灰色圆后面,所以以后有了图片,头像会盖在灰色圆上面。
  8. 第 3 个值:createTitle(outline) 造标题 Choose your Mokepon,字号 34,放在 (50, 45),截图上是 (50, 70)(4 亮了)。
  9. 第 4 个值:createMokeponList(mokepons)。36 只 Mokepon,一只一个 CGameButton,竖着排进一个能滚动的框(5 亮了,第 4 节细讲)。
  10. 第 5 个值:createNameRow() 造一个 HBox(从左往右排的框),把前面造好的 nameLabel 和 typeIcon 装进去,宽 450,左右居中在圆心下面(6 亮了)。
  11. 第 6 个值:卷轴图片 assets/scroll.png,放在 (604, 441),大小 392 × 285。也没有字段存它(7 亮了)。
  12. 第 7 个值:前面造好的 chooseButton(8 亮了)。
  13. 第 8 个值:createStatsGrid() 造数值表,同时把 6 个值的 Label 放进 statValues(9 亮了)。它排在卷轴图片后面,所以字写在卷轴上面。如果顺序反过来,卷轴会把字盖住。
  14. 8 个值都算好了,addToView 一次把它们加进 view。现在 view 里一共 9 样:背景 + 这 8 样,前面的在下,后面的在上。
  15. 最后 showTypeIcon(false):把类型图标藏起来,而且不占位置。还没选,名字一行(标注 6)只有 “Select a Mokepon”,它正好在正中间。构造器结束,你看到的就是这张图。

布局常量:截图上的坐标都是算出来的

文件开头第 31–74 行是一串 private static final 的常量。它们是游戏区里的坐标和大小(游戏区左上角是 (0, 0))。截图最上面多了 25 像素高的菜单栏,所以截图上的 y = 常量的 y + 25,x 不变。下面先列出常量,再看截图,代码行上的编号和截图上的编号对应。

SelectScreen 的布局常量(只列出和位置、大小有关的)ui/SelectScreen.java第 33–34、37–40、46–49、53–54、59–67、70–72 行
331 private static final int TITLE_X = 50;
34 private static final int TITLE_Y = 45;
⋯ 省略第 35–36 行
372 private static final int LIST_X = 60;
38 private static final int LIST_Y = 108;
39 private static final int LIST_WIDTH = 295;
40 private static final int LIST_HEIGHT = 620;
⋯ 省略第 41–45 行
463 private static final int CIRCLE_CENTRE_X = 801;
47 private static final int CIRCLE_CENTRE_Y = 201;
48 private static final int CIRCLE_RADIUS = 163;
494 private static final int PORTRAIT_SIZE = 256;
⋯ 省略第 50–52 行
535 private static final int NAME_Y = 380;
54 private static final int NAME_WIDTH = 450;
⋯ 省略第 55–58 行
596 private static final int SCROLL_X = 604;
60 private static final int SCROLL_Y = 441;
61 private static final int SCROLL_WIDTH = 392;
62 private static final int SCROLL_HEIGHT = 285;
637 private static final int STATS_X = 646;
64 private static final int STATS_Y = 478;
65 private static final int LABEL_WIDTH = 74;
66 private static final int VALUE_WIDTH = 260;
67 private static final int ROW_HEIGHT = 25;
⋯ 省略第 68–69 行
708 private static final int CHOOSE_X = 1050;
71 private static final int CHOOSE_Y = 504;
72 private static final int CHOOSE_SIZE = 160;
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12345678
  1. 1
    标题的左上角:(TITLE_X, TITLE_Y + 25) = (50, 70)
  2. 2
    列表:(60, 108 + 25 = 133),295 × 620
  3. 3
    红线从圆心画到圆边:圆心 (801, 201 + 25 = 226),长度就是半径 163
  4. 4
    头像 256 × 256,左上角 (673, 98)
  5. 5
    名字一行:宽 450,y = 380 + 25 = 405
  6. 6
    卷轴:(604, 441 + 25 = 466),392 × 285
  7. 7
    数值表的左上角 (646, 478 + 25 = 503);两列宽 74(LABEL_WIDTH)和 260(VALUE_WIDTH),每行高 25(ROW_HEIGHT)
  8. 8
    Choose:(1050, 504 + 25 = 529),160 × 160
常量 + 25(菜单栏)= 截图坐标;编号和上面代码里的编号一样真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

把算式和截图里真实测到的位置放在一起,位置一个都不差:

代码里的算法(编号)截图上应该在截图里真的是
(3、4)头像左上角 = 圆心 − 256 ÷ 2:801 − 128,201 − 128 + 25(673, 98)portraitView 在 (673, 98),256 × 256
(5)名字一行左边 = 801 − 450 ÷ 2 = 576;y = 380 + 25(576, 405)还没选时藏着的 typeIcon 就停在 (576, 405),也就是这个 HBox 的左上角
(7)值那一列的左边 = STATS_X + LABEL_WIDTH = 646 + 74x = 7208 个值的 Label 全都在 x = 720
(7)第 k 行(从 0 数)的上边 = 503 + 25 × k(ROW_HEIGHT = 25)HP 503,Energy 528 … Dodge 6286 个值的 Label(statValues 里的)正好在这些 y 上
(8)CHOOSE_X,CHOOSE_Y + 25(1050, 529)chooseButton 在 (1050, 529),量出来 160 × 161

只有一个数差了 1:Choose 按钮量出来高 161,代码第 241 行写的是 setPrefSize(160, 160)。pref 是 preferred(希望的大小),JavaFX 排版时会自己做最后决定,结果多了 1 像素。看不出来,也不影响位置。

4. 名字列表:createMokeponList 和按钮里的 lambda

左边那一列名字是这样造出来的。左边代码的编号对应右边放大图的编号:

createMokeponList:一只一个按钮ui/SelectScreen.java第 211–234 行
211 /* One button for each Mokepon, in a list that scrolls when it is too long. */
212 private ScrollPane createMokeponList(List<Mokepon> mokepons) {
2131 VBox buttonColumn = new VBox(LIST_GAP);
214 Font font = UiStyle.loadGameFont(LIST_FONT_SIZE);
215 for (Mokepon mokepon : mokepons) {
2162 GameButton button = new GameButton(mokepon.getName(), font, BUTTON_RADIUS);
2173 button.setPrefWidth(LIST_BUTTON_WIDTH);
2184 button.setOnAction(event -> select(mokepon, button));
219 buttonColumn.getChildren().add(button);
220 }
221
222 ScrollPane list = new ScrollPane(buttonColumn);
223 list.setLayoutX(LIST_X);
224 list.setLayoutY(LIST_Y);
225 list.setPrefSize(LIST_WIDTH, LIST_HEIGHT);
226 list.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
2275 list.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
228 /* Make the list see-through, so the background shows behind the buttons. */
2296 list.setStyle("-fx-background: transparent;"
230 + "-fx-background-color: transparent;"
231 + "-fx-focus-color: transparent;"
232 + "-fx-faint-focus-color: transparent;");
233 return list;
234 }
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
123456
  1. 1
    buttonColumn:一个 VBox,把按钮从上往下排
  2. 2
    new GameButton(名字, 字体, 30):按钮上的字就是 mokepon.getName()
  3. 3
    每个按钮宽 270(LIST_BUTTON_WIDTH)
  4. 4
    点 Nightwing 时,运行的就是第 218 行交给它的那段 lambda
  5. 5
    竖滚动条:AS_NEEDED,放不下才出现
  6. 6
    列表是透明的(第 229–232 行),所以按钮之间的缝(第 213 行的 LIST_GAP,10 像素)里透出的是棕色背景
select-normal 放大:列表的前 6 个按钮(Nightwing 被选中,所以更亮、更白)真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

一行一行读:

第 218 行的 lambda:按钮怎么知道自己是哪一只

第 218 行是这一章最重要的一行:

button.setOnAction(event -> select(mokepon, button));

拆开读:

那么问题来了:36 个按钮都存了一段长得一模一样的代码 select(mokepon, button),为什么点 Nightwing 选中的是 Nightwing,不是最后一只 Stormtalon?

因为 for-each 循环每一遍都有一个新的 mokepon 变量,第 216 行每一遍也造一个新的 button 变量。lambda 在造出来的那一刻,就把它用到的这两个变量记住了(叫变量捕获(variable capture))。第 6 遍循环时 mokepon 是 Nightwing 对象、button 是 Nightwing 的按钮,这一遍造出来的 lambda 就永远记着这两个。所以 36 个 lambda 代码一样,记着的东西各不一样。你在 第 6 章讲 CGameMenu 时已经见过一次变量捕获:菜单项的 lambda 记住了构造器参数 navigator。这里是同一件事,只是发生在循环里。点“下一步”看循环一轮一轮地造:

Sealance 按钮: GameButtonNightwing 按钮: GameButtonImpike 按钮: GameButtonlambda 记着mokepon = Sealancebutton = Sealance 按钮lambda 记着mokepon = Nightwingbutton = Nightwing 按钮lambda 记着mokepon = Impikebutton = Impike 按钮选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui互相指着互相指着互相指着select(…)select(…)select(…)
36 个按钮各存着自己的 lambda(图里只画 3 个);每个 lambda 记着自己那一轮的 mokepon 和 button,都调用同一个 select 方法
createMokeponList 里的循环ui/SelectScreen.java第 212–220 行
212 private ScrollPane createMokeponList(List<Mokepon> mokepons) {
213 VBox buttonColumn = new VBox(LIST_GAP);
214 Font font = UiStyle.loadGameFont(LIST_FONT_SIZE);
215 for (Mokepon mokepon : mokepons) {
216 GameButton button = new GameButton(mokepon.getName(), font, BUTTON_RADIUS);
217 button.setPrefWidth(LIST_BUTTON_WIDTH);
218 button.setOnAction(event -> select(mokepon, button));
219 buttonColumn.getChildren().add(button);
220 }
  1. 第 1 轮:局部变量 mokepon = 列表里第 1 只 Sealance。第 216 行 new 一个按钮,上面的字是 Sealance,存进这一轮的局部变量 button(上面左边那个方框)。
  2. 第 218 行造一个 lambda,交给这个按钮存着(setOnAction)。lambda 现在不运行,只是记住这一轮的 mokepon(Sealance)和 button(Sealance 按钮)。按钮存着 lambda,lambda 也记着按钮,所以图上两个方框互相指着。
  3. 第 219 行把按钮加进 VBox。这一轮结束,这两个局部变量就不能再用了;可是 lambda 早已把它们指着的对象(Sealance 对象、Sealance 按钮)记下来了。
  4. 第 6 轮:新的 mokepon = Nightwing,新的 button = Nightwing 按钮。又造一个新的 lambda,记着的是 Nightwing 这一对。第 1 轮那个 lambda 记的还是 Sealance,不会被改掉。
  5. 第 15 轮:Impike,同样又是一个新 lambda。一直转到第 36 轮 Stormtalon,循环结束:36 个按钮,36 个 lambda。到这里,select 一次都还没运行过。
  6. 很久以后,你点了 Nightwing。JavaFX 运行 Nightwing 按钮存着的那个 lambda,它记着 Nightwing 这一对,所以调用的是 select(Nightwing 对象, Nightwing 按钮)。select 里面做什么,见第 5 节。

每个 lambda 除了 mokepon,还记着 button(就是存着它的那个按钮)。为什么也要把按钮传给 select?因为 select 要把这个按钮变亮,还要把它记进 selectedButton,下次点别的名字时好把它变回来。

5. 点一个名字:select(mokepon, button) 一行一行

先看真实的几帧:刚打开游戏 → 打开 Game 菜单看一眼 → 点 Nightwing → 再点 Impike → 点 Choose Mokepon 进入地图。

游戏刚打开:选择界面,还没选 Mokepon选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的选中 NORMAL 类型的 Nightwing:名字后面没有类型图标选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
同一个 SelectScreen 对象,点一次名字就运行一次 select;点 Choose 才换画面

点一个名字时,谁调用了谁?下面的顺序图画的是点 Impike 那一次(之前已经选过 Nightwing)。每根竖线是一个对象:CSelectScreen 自己(最左边)、字段 chooseButton 指着的 Choose 按钮、字段 images 指着的 CGameImages、被选中的 CMokepon 对象(参数 mokepon)、你点的 Impike 按钮(参数 button)、上一次选中的 Nightwing 按钮(字段 selectedButton 原来指着它)。这三个按钮都是 CGameButton。

CSelectScreenCchooseButtonCimagesCmokeponCImpike按钮CNightwing按钮你点了它select(mokepon, button)1setHighlighted(false)2setUsable(true)3setHighlighted(true)4getImagePath(), getName()5getType()6hasTypeImage(FIRE)7true8showTypeIcon(true)9hasTypeImage(FIRE)10true11getTypeImage(FIRE)12getStat(stat) ×613formatSkills(mokepon)146 样东西都换好了
点 Impike 时 select 方法里的调用(它之前选的是 Nightwing)

箭头上的编号和 select 的代码行这样对应(代码在下面):

箭头代码行做什么
1第 218 行的 lambda → 第 143 行你点了 Impike 按钮,它的 lambda 调用 select
2第 144–145 行上一次选中的 Nightwing 按钮变回普通颜色
3、4第 149、150 行Choose 能点了;Impike 按钮变亮
5第 152–153 行问 Impike 要图片路径和名字
6第 156 行问 Impike 的类型:FIRE
7、8、9第 157 行问 images 有没有 FIRE 的图片,得到 true,于是 showTypeIcon(true)
10、11、12第 158–159 行再问一次,还是 true,于是取出图片路径 assets/sprite_fire.png
13第 162–164 行六个数,一个 EStat 问一次
14第 165 行把技能连成一串字(第 166 行的 type.name() 把 FIRE 变成文字,图里没画)

注意 select 只改同一批部件的内容(setText、setImage),画面上没有新加也没有删掉任何东西,CGameWindow 也没被叫来。换画面要等点 Choose。

代码和截图对照

select:记住选择,显示图片、名字、类型和数值ui/SelectScreen.java第 142–167 行
142 /* Remember the choice and show its image, name, type and stats. */
143 private void select(Mokepon mokepon, GameButton button) {
144 if (selectedButton != null) {
145 selectedButton.setHighlighted(false);
146 }
147 selectedButton = button;
148 selectedMokepon = mokepon;
1491 chooseButton.setUsable(true);
150 button.setHighlighted(true);
151
1522 portraitView.setImage(UiStyle.loadImage(mokepon.getImagePath()));
1533 nameLabel.setText(mokepon.getName());
154
155 /* A normal Mokepon has no type image after its name. */
156 MokeponType type = mokepon.getType();
157 showTypeIcon(images.hasTypeImage(type));
158 if (images.hasTypeImage(type)) {
1594 typeIcon.setImage(UiStyle.loadImage(images.getTypeImage(type)));
160 }
161
162 for (Stat stat : Stat.values()) {
1635 statValues.get(stat).setText(String.valueOf(mokepon.getStat(stat)));
164 }
1656 skillsValue.setText(formatSkills(mokepon));
1667 typeValue.setText(type.name());
167 }
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1234567
  1. 1
    Choose 能点了:setUsable(true)
  2. 2
    头像:assets/Impike.png
  3. 3
    名字:getName()
  4. 4
    火焰图标:assets/sprite_fire.png
  5. 5
    六个数:150、22、76、74、45、70
  6. 6
    技能:formatSkills 连成的一串字
  7. 7
    类型:type.name() = FIRE
select(Impike, Impike 按钮) 运行完的样子真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

动画:先点 Nightwing,再点 Impike

下面一步一步走两次 select。编号的意思在每张图里都一样:1 Nightwing 的按钮,2 Choose 按钮,3 头像,4 名字,5 六个数值,6 技能,7 类型,8 名字后面放图标的地方(5–8 在放大的卷轴图里看)。

ui/SelectScreen.java第 142–167 行
142 /* Remember the choice and show its image, name, type and stats. */
143 private void select(Mokepon mokepon, GameButton button) {
144 if (selectedButton != null) {
145 selectedButton.setHighlighted(false);
146 }
147 selectedButton = button;
148 selectedMokepon = mokepon;
149 chooseButton.setUsable(true);
150 button.setHighlighted(true);
151
152 portraitView.setImage(UiStyle.loadImage(mokepon.getImagePath()));
153 nameLabel.setText(mokepon.getName());
154
155 /* A normal Mokepon has no type image after its name. */
156 MokeponType type = mokepon.getType();
157 showTypeIcon(images.hasTypeImage(type));
158 if (images.hasTypeImage(type)) {
159 typeIcon.setImage(UiStyle.loadImage(images.getTypeImage(type)));
160 }
161
162 for (Stat stat : Stat.values()) {
163 statValues.get(stat).setText(String.valueOf(mokepon.getStat(stat)));
164 }
165 skillsValue.setText(formatSkills(mokepon));
166 typeValue.setText(type.name());
167 }
游戏刚打开:选择界面,还没选 Mokepon12345
  1. 1
    Nightwing 按钮(普通颜色)
  2. 2
    Choose:淡的,点不了
  3. 3
    头像:空
  4. 4
    Select a Mokepon
  5. 5
    值:全空
点之前:什么都没选真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标1234
  1. 1
    Nightwing 变亮
  2. 2
    Choose 亮了
  3. 3
    头像
  4. 4
    Nightwing
select(Nightwing, Nightwing 按钮) 运行完真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
48567
  1. 4
    Nightwing
  2. 8
    名字后面没有图标
  3. 5
    155、27、68、83、42、86
  4. 6
    Lance, Aim, Pocket Sand, Wait
  5. 7
    NORMAL
放大:名字一行和卷轴(Nightwing)真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了1234
  1. 1
    Nightwing 变回普通
  2. 2
    Choose 还亮着
  3. 3
    头像
  4. 4
    Impike
select(Impike, Impike 按钮) 运行完真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
48567
  1. 4
    Impike
  2. 8
    火焰图标
  3. 5
    150、22、76、74、45、70
  4. 6
    Lance, Stun, Wait
  5. 7
    FIRE
放大:名字一行和卷轴(Impike)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
  1. 刚打开游戏:selectedButton 和 selectedMokepon 都是 null(声明时没写 = ...,对象类型的字段默认就是 null)。你点 Nightwing,它的按钮运行自己的 lambda:select(Nightwing 对象, Nightwing 按钮),所以参数 mokepon 是 Nightwing,button 是 Nightwing 的按钮。
  2. 第 144 行先问:之前有选中的按钮吗?现在 selectedButton 是 null,条件不成立,第 145 行跳过。这个检查不能省:对 null 调用方法(null.setHighlighted(false))程序会出错(NullPointerException)。
  3. 第 147–148 行把这次的选择记进字段:selectedButton = Nightwing 按钮,selectedMokepon = Nightwing。参数在方法结束后就没了,字段会一直留着:下次点别的名字时第 144 行要用 selectedButton,点 Choose 时要用 selectedMokepon。
  4. 第 149 行 chooseButton.setUsable(true):Choose 按钮能点了,透明度从 0.4 回到 1,不再是淡的(标注 2)。
  5. 第 150 行 button.setHighlighted(true):Nightwing 按钮的浅灰背景从 50% 不透明变成 75%(CGameButton 里的 HIGHLIGHTED = 0.75)。浅灰盖得更实,在棕色背景上看起来更亮、更白(标注 1)。CGameButton 第 45 行的注释写的是 Darken(变暗),可截图里明明是变亮,以截图为准。
  6. 第 152 行:问 Nightwing 要图片路径 assets/Nightwing.png,用 UiStyle.loadImage 读成图片,放进 portraitView(标注 3)。第 153 行:名字换成 Nightwing(标注 4)。
  7. 第 156 行取出类型 NORMAL。第 157 行问 images.hasTypeImage(NORMAL):没有 normal 的图片,答案是 false,于是 showTypeIcon(false):图标看不见、也不占位置。第 158 行再问一次,还是 false,第 159 行跳过。名字后面什么都没有(标注 8)。
  8. 第 162–164 行:对 6 个 EStat 每一个,取出 Nightwing 的数,变成文字,写进对应的 Label:155、27、68、83、42、86(标注 5)。第 165 行写技能 “Lance, Aim, Pocket Sand, Wait”(标注 6)。第 166 行写类型 NORMAL(标注 7)。select 结束。
  9. 现在点 Impike(列表第 15 个,自己玩要往下滚才看得到它;这张截图里列表停在最上面,所以看不到 Impike 的按钮)。又进 select,这次参数是 Impike 和它的按钮。第 144 行 selectedButton 是 Nightwing 按钮,不是 null,第 145 行把它变回普通颜色(标注 1)。所以同一时间只有一个按钮是亮的。
  10. 第 147–148 行:两个字段改成 Impike 按钮和 Impike。Nightwing 不再被记着。第 149 行 Choose 再设一次能点(本来就能点,结果不变,标注 2)。第 150 行把 Impike 按钮变亮(它在列表下面,截图里看不到)。
  11. 头像换成 assets/Impike.png,名字换成 Impike(标注 3、4)。
  12. 类型是 FIRE。hasTypeImage(FIRE) 是 true(数据文件第 397 行有 mokepon.type.fire),所以 showTypeIcon(true),图标出现;第 159 行把它的图片设成 assets/sprite_fire.png(标注 8)。
  13. 数值换成 150、22、76、74、45、70(标注 5),技能 “Lance, Stun, Wait”(标注 6),类型 FIRE(标注 7)。和 Nightwing 用的是同一批 Label,只是字换了。

名字后面的小图标:为什么 NORMAL 没有

选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
123
  1. 1
    NORMAL 的 Nightwing
  2. 2
    名字后面没有图标,也没有空位
  3. 3
    Type: NORMAL
select-normal:typeIcon 藏起来了真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
123
  1. 1
    FIRE 的 Impike
  2. 2
    火焰图标 typeIcon
  3. 3
    Type: FIRE
select-fire:typeIcon 显示出来了真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

决定有没有图标的,是 CGameImages 里有没有这个类型的图片。顺着代码往回找:

mokepon.game:只有 4 种类型有图片mokepon.game第 397–400 行
397mokepon.type.fire=assets/sprite_fire.png
398mokepon.type.ice=assets/sprite_ice.png
399mokepon.type.water=assets/sprite_water.png
400mokepon.type.lightning=assets/sprite_lightning.png
GameImages:有没有、要哪张data/GameImages.java第 30–37 行
30 /* The normal type has no image. */
31 public boolean hasTypeImage(MokeponType type) {
32 return typeImages.containsKey(type);
33 }
34
35 public String getTypeImage(MokeponType type) {
36 return typeImages.get(type);
37 }
ImageParser.readImages 里的循环:没有这个键就不放进 Mapdata/ImageParser.java第 31–36 行
31 for (T value : values) {
32 String key = prefix + value.name().toLowerCase();
33 if (data.has(key)) {
34 images.put(value, data.getText(key));
35 }
36 }
SelectScreen.showTypeIcon:看不见 + 不占位置ui/SelectScreen.java第 169–173 行
169 /* A hidden icon also takes no space, so the name stays in the centre. */
170 private void showTypeIcon(boolean visible) {
171 typeIcon.setVisible(visible);
172 typeIcon.setManaged(visible);
173 }
  1. 数据文件第 397–400 行只写了 fire、ice、water、lightning 四张图,没有 mokepon.type.normal。
  2. 上面 CImageParser 的代码里有个 T:readImages 是一个泛型方法,T 是一个先空着的类型,调用时才定下来(第 2 章第 5 节“泛型方法”)。读类型图标时传进来的是 MokeponType.values(),所以这里 T 就是 EMokeponType,value 依次是 NORMAL、FIRE、ICE……
  3. CImageParser 的 readImages 对 EMokeponType 的 5 个值一个一个拼出键名(mokepon.type. + 小写的名字),第 33 行 if (data.has(key)):有这个键才放进 Map。所以 Map 里只有 4 项,没有 NORMAL。
  4. CGameImages 的 hasTypeImage(type) 就是 typeImages.containsKey(type):Map 里有没有这个键。NORMAL 得到 false。
  5. select 用这个答案调用 showTypeIcon(...),它同时设两样(setVisible / setManaged):setVisible(false) 是“不画出来”;setManaged(false) 是“HBox 排版时当它不存在”。只设第一样的话,名字后面会留一块 24 像素的空位加 12 像素的间隔,名字就不在正中间了。

验证“名字在正中间”:NORMAL 的 Nightwing,名字 nameLabel 在截图里从 x = 736.5 开始,宽 129,中点 = 736.5 + 129 ÷ 2 = 801,正好是圆心 CIRCLE_CENTRE_X。

Impike 有图标时,HBox 居中的是“名字 + 12 像素间隔(TYPE_ICON_GAP)+ 24 像素图标(TYPE_ICON_SIZE)”这一整组,所以名字要往左让出位置。先说一个小细节:截图里量到的名字框,比 Label 本身每边多 2 像素,因为第 91 行的白边(DropShadow,半径 2)往外多画了一圈。Impike 的名字量出来宽 89,Label 本身宽 89 − 4 = 85。

技能那一串字:formatSkills 和 String.join

formatSkills:把技能对象变成一串名字ui/SelectScreen.java第 175–181 行
175 private String formatSkills(Mokepon mokepon) {
1761 List<String> skillNames = new ArrayList<>();
177 for (Skill skill : mokepon.getSkills()) {
1782 skillNames.add(skill.getName());
179 }
1803 return String.join(", ", skillNames);
181 }

数据文件里 Impike 的技能写成 Lance|Stun|Wait(第 163 行)。到屏幕上变成 “Lance, Stun, Wait”,中间经过两次变形:

Lance|Stun|Wait数据文件第 163 行的一行字List<Skill>3 个技能对象(Lance、Stun、Wait)List<String>Lance · Stun · WaitLance, Stun, Wait一个 String,写进 skillsValueMokeponParser 按 | 切开SkillCatalogue.find(名字)formatSkills每个 getName()String.join(…)
技能名字的变形:从文件里的一行字,到卷轴上的一行字

六个数:Stat.values() 和 statValues

Stat:6 个常量,顺序固定;每个带着名字和范围model/Stat.java第 4–24 行
4public enum Stat {
5 HP("HP", 1, 1000),
6 ENERGY("Energy", 1, 100),
7 STRENGTH("Strength", 1, 100),
8 ACCURACY("Accuracy", 1, 100),
9 DEFENCE("Defence", 1, 100),
10 DODGE("Dodge", 1, 100);
11
12 private final String label;
13 private final int minimum;
14 private final int maximum;
15
16 Stat(String label, int minimum, int maximum) {
17 this.label = label;
18 this.minimum = minimum;
19 this.maximum = maximum;
20 }
21
22 public String getLabel() {
23 return label;
24 }
select 里写数值的 3 行ui/SelectScreen.java第 162–164 行
162 for (Stat stat : Stat.values()) {
163 statValues.get(stat).setText(String.valueOf(mokepon.getStat(stat)));
164 }

EStat 是一个枚举(enum):只有 6 个固定的值 HP、ENERGY、STRENGTH、ACCURACY、DEFENCE、DODGE。每个值后面括号里的三样东西,是交给枚举构造器(第 16 行)的:显示用的名字 label、最小值、最大值。比如 ENERGY("Energy", 1, 100):名字 "Energy",范围 1 到 100(枚举见 第 2 章第 6 节)。第 6 节造表格时用的 stat.getLabel(),取的就是第一样,所以卷轴左列写的是 “Energy:” 而不是 “ENERGY”。

Stat.values()(枚举自带的方法)按写的顺序返回这 6 个。每转一圈:

这 6 个 Label 是 createStatsGrid 造表格时放进 statValues 的(下一节),键就是同一个 EStat。所以“哪个数写到哪一行”不会搞错:HP 的数一定写进 HP 那一行。

6. 卷轴上的数值表:createStatsGrid 和 addStatRow

卷轴上的字排得整整齐齐,是因为它们放在一个 GridPane(网格)里:两列、8 行。

createStatsGrid 和 addStatRowui/SelectScreen.java第 250–278 行
250 /* Stat names on the left and values on the right. The skills row is two rows tall,
251 * so a long list of skills can wrap onto a second line before the type row.
252 */
253 private GridPane createStatsGrid() {
254 GridPane grid = new GridPane();
255 grid.setLayoutX(STATS_X);
256 grid.setLayoutY(STATS_Y);
2571 grid.getColumnConstraints().addAll(new ColumnConstraints(LABEL_WIDTH),
2582 new ColumnConstraints(VALUE_WIDTH));
259
260 int row = 0;
261 for (Stat stat : Stat.values()) {
262 Label value = createValueLabel();
263 statValues.put(stat, value);
2643 addStatRow(grid, row++, stat.getLabel() + ":", value, ROW_HEIGHT);
265 }
2664 addStatRow(grid, row++, "Skills:", skillsValue, 2 * ROW_HEIGHT);
2675 addStatRow(grid, row, "Type:", typeValue, ROW_HEIGHT);
268 return grid;
269 }
270
271 private void addStatRow(GridPane grid, int row, String name, Label value, int height) {
272 Font nameFont = UiStyle.createSansSerifFont(FontWeight.BOLD, STAT_FONT_SIZE);
273 grid.add(createRowLabel(name, nameFont), 0, row);
274 grid.add(value, 1, row);
275 RowConstraints constraints = new RowConstraints(height);
276 constraints.setValignment(VPos.TOP);
277 grid.getRowConstraints().add(constraints);
278 }
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
12345
  1. 1
    第 0 列:名字,宽 74(LABEL_WIDTH),x 从 646 到 720
  2. 2
    第 1 列:值,宽 260(VALUE_WIDTH),x 从 720 到 980
  3. 3
    普通的一行:高 25(ROW_HEIGHT),这是 Energy 行(528 到 553,红线是它的下边)
  4. 4
    Skills 行:高 2 × 25 = 50(653 到 703),第二行空着
  5. 5
    Type 行:最后一行,高 25(703 到 728)
select-normal 放大:卷轴上的两列、8 行(编号和上面代码里的编号一样;框线画在格子边上,稍微往外让一点,免得盖住字)真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

createStatsGrid 一行一行读:

addStatRow 负责“加一行”:

8 行的高度和截图上的位置:

行左边的字高截图上 y
0–5HP: Energy: Strength: Accuracy: Defence: Dodge:各 25503、528、553、578、603、628
6Skills:50653(到 703)
7Type:25703(到 728)

为什么 Skills 行要两行高

createStatsGrid 方法上面的注释(第 250–252 行)说得很清楚:技能多了,一行写不下,要能换到第二行,而不是挤到 Type 那一行上。要做到这一点要两样配合:构造器第 102 行 skillsValue.setWrapText(true) 让这个 Label 宽度不够时换行;这里第 266 行让这一行有两行高。VPos.TOP 让只有一行的技能贴着上边,和左边的 “Skills:” 对齐。

现在的数据里,最长的技能是 “Bite, Pocket Sand, Nimble, Wait”(31 个字符,有 6 只都是这一串:Fumubble、Mothique、Hexwing、Staghorn、Cindrake、Skydart)。从截图量,这种等宽字体每个字符约 7.8 像素宽(Nightwing 的 29 个字符宽 226.5),31 个字符约 242 像素,比 260 窄,所以实际上没有谁会换行。截图里 Skills 和 Type 之间总有一行空白,就是那留着备用的第二行(标注 4)。

createValueLabel 和 createRowLabel:每个格子里的 Labelui/SelectScreen.java第 280–290 行
280 private Label createValueLabel() {
281 return createRowLabel("", UiStyle.createMonospaceFont(FontWeight.NORMAL, STAT_FONT_SIZE));
282 }
283
284 /* Every label is at least one row tall, so text in both columns lines up. */
285 private Label createRowLabel(String text, Font font) {
286 Label label = UiStyle.createLabel(text, font);
287 label.setMinHeight(ROW_HEIGHT);
288 label.setAlignment(Pos.CENTER_LEFT);
289 return label;
290 }

7. Choose Mokepon 按钮,和变灰的 Main menu

没选之前不能点

createChooseButton:一造出来就是点不了的ui/SelectScreen.java第 236–248 行
236 /* Disabled until a Mokepon has been selected. */
237 private GameButton createChooseButton() {
2381 GameButton button = new GameButton("Choose\nMokepon",
239 UiStyle.loadGameFont(CHOOSE_FONT_SIZE), CHOOSE_RADIUS);
240 button.setTextAlignment(TextAlignment.CENTER);
2412 button.setPrefSize(CHOOSE_SIZE, CHOOSE_SIZE);
242 button.setLayoutX(CHOOSE_X);
243 button.setLayoutY(CHOOSE_Y);
2443 button.setUsable(false);
245 button.addPressEffect();
246 button.setOnAction(event -> getNavigator().startGame(selectedMokepon));
247 return button;
248 }
游戏刚打开:选择界面,还没选 Mokepon
123
  1. 1
    两行字:"Choose\nMokepon"
  2. 2
    宽 160(第 241 行),左上角放在 (1050, 504),截图上是 (1050, 529)
  3. 3
    还没选:setUsable(false),整个按钮只剩 40% 不透明,点不动
select-initial:刚打开游戏,Choose 是淡的真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon

名字按钮“亮着”、Choose 按钮“淡着”,都不是 CSelectScreen 自己画的:它只调用 CGameButton 的两个方法,“亮成什么样、淡成什么样”写在 CGameButton 里。下面只看这两个方法;0.5、0.75、0.4 三个数和“为什么看起来是 20% 的灰”,在 第 6 章第 7 节已经算过。左边代码的编号对应右边两张图(3 号还是上面那个 Choose 按钮,这次是选了以后):

GameButton 里 SelectScreen 用到的两个方法ui/GameButton.java第 45–55 行
45 /* Darken the background to show this button is the current choice. */
46 public void setHighlighted(boolean highlighted) {
474 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);
543 setOpacity(usable ? 1 : DISABLED_OPACITY);
55 }
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
4
  1. 4
    选中的 Nightwing:setHighlighted(true),背景 0.75,更亮、更白;上下的 Pengloom、Grubeye 是普通的 0.5
select-normal:名字列表的一段真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
3
  1. 3
    选了以后:setUsable(true),整个按钮不透明度回到 1,能点
select-fire:Choose 是正常颜色(和上面那张淡的比一比)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

CSelectScreen 一共在 6 个地方调用这两个方法。它每次只说“要哪种样子”,怎么画出来由 CGameButton 决定:

第 246 行的 lambda 和第 218 行有什么不一样

两个 lambda 长得很像,但用到的东西不同,这个区别很重要:

名字按钮(第 218 行)Choose 按钮(第 246 行)
lambda 里用的mokepon、button:循环里的局部变量selectedMokepon:CSelectScreen 的字段
拿到的是什么时候的值造 lambda 那一刻的值,以后永远不变点按钮那一刻字段里的值
结果每个按钮永远选它自己那一只点的时候选的是谁,就用谁开始游戏

Choose 按钮是在构造器第 100 行造的,那时候 selectedMokepon 还是 null。如果 lambda 记的是“那一刻的值”,它就永远是 null 了。实际上 lambda 里的 selectedMokepon 是 this.selectedMokepon:lambda 记住的是 this(这个画面对象),每次运行时再去读字段,所以读到的是最新的选择。

那会不会在没选时点到,交出一个 null?不会:没选时按钮是 disabled 的,点不动。按钮能不能点,就是代码用来保证 selectedMokepon 不是 null 的办法。

Game 菜单:为什么 Main menu 是灰的

选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
123
  1. 1
    Run away:灰
  2. 2
    Main menu:灰
  3. 3
    Exit:能点
选择界面打开 Game 菜单真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
地图上打开 Game 菜单:Run away 灰,Main menu 可点
123
  1. 1
    Run away:灰
  2. 2
    Main menu:能点
  3. 3
    Exit:能点
地图画面打开 Game 菜单(对比用)真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点

菜单只有一个(CGameMenu),三个画面共用。它怎么知道现在该让哪一项变灰?它去问当前的画面:

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) {
471 runAway.setDisable(!screen.canRunAway());
482 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }
GameScreen 的默认答案ui/GameScreen.java第 41–49 行
41 /* Whether the "Run away" option can be used. Only the battle screen allows it. */
421 public boolean canRunAway() {
43 return false;
44 }
45
46 /* Whether the "Main menu" option can be used. Most screens allow it. */
472 public boolean canReturnToMainMenu() {
48 return true;
49 }
SelectScreen 把第 2 个改写成 falseui/SelectScreen.java第 118–122 行
118 /* There is no game to leave yet, so the main menu option is not needed. */
119 @Override
1202 public boolean canReturnToMainMenu() {
121 return false;
122 }

update 的参数类型是 AGameScreen,它不知道传进来的是哪种画面。传进来的是 CSelectScreen 对象,运行的就是 CSelectScreen 改写过的版本。这就是多态(polymorphism)。谁调用 update?CGameWindow 每次换画面的 showScreen 里都会调用 updateMenu(),它就是 menu.update(currentScreen)。另外还有一处:战斗结束时,CBattleScreen 的 showResult 也会通过 IGameNavigator 调用 updateMenu(),所以没换画面 Run away 也会变灰(第 15 章第 4 节)。对选择界面来说,只有 showScreen 这一处。

当前画面Run away:问 canRunAway()Main menu:问 canReturnToMainMenu()
选择界面父类的 false → 灰自己改写成 false → 灰
地图画面父类的 false → 灰父类的 true → 能点
战斗画面改写过:战斗没结束时是 true → 能点父类的 true → 能点

8. 回到主菜单:clearSelection()

选择界面不止在刚打开时出现。玩完一局(走到出口,或者输了),对话框里有 Return to Main Menu 按钮;地图或战斗中也可以点菜单的 Main menu。这几条路最后都调用同一个方法:CGameWindow 的 returnToMainMenu()。

GameWindow.returnToMainMenu:丢掉这一局,清空选择,换回选择界面ui/GameWindow.java第 82–89 行
82 /* The game being played is thrown away. */
83 @Override
84 public void returnToMainMenu() {
851 world = null;
86 overworldScreen = null;
872 selectScreen.clearSelection();
883 showScreen(selectScreen);
89 }

注意第 87–88 行用的是字段 selectScreen,不是 new SelectScreen(...)。CGameWindow 只在构造器第 41 行造过一个 CSelectScreen,存在 final 字段 selectScreen 里,一直用到程序结束。名字列表永远是那 36 只,没必要重新造 36 个按钮;只要把上次选的东西擦掉就行。这和另外两个画面不一样:COverworldScreen 每开一局 new 一个,CBattleScreen 每打一仗 new 一个。

clearSelection 和 select 正好反过来。左边代码的编号对应右边截图的编号:

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) {
127 selectedButton.setHighlighted(false);
128 }
129 selectedButton = null;
130 selectedMokepon = null;
1311 portraitView.setImage(null);
1322 nameLabel.setText(PROMPT);
133 showTypeIcon(false);
134 for (Label value : statValues.values()) {
1353 value.setText("");
136 }
1374 skillsValue.setText("");
1385 typeValue.setText("");
1396 chooseButton.setUsable(false);
140 }
  • 第 126–128 行:如果有选中的按钮(Impike 的),先把它变回普通颜色。和 select 第 144 行一样要先检查是不是 null。
  • 第 129–130 行:两个字段都设回 null:“现在什么都没选”。
  • 第 131–133 行(标注 1、2):头像清空,名字写回 “Select a Mokepon”,类型图标藏起来。
  • 第 134–136 行(标注 3):statValues.values() 是 Map 里所有的值(6 个 Label),用 for-each 一个个设成空字。
  • 第 137–139 行(标注 4、5、6):技能、类型清空,Choose 按钮关上。
点 Return to Main Menu:回到选择界面,之前的选择被清空
123456
  1. 1
    头像:setImage(null),空了
  2. 2
    名字:又是 PROMPT “Select a Mokepon”
  3. 3
    六个值:setText("")
  4. 4
    技能:空
  5. 5
    类型:空
  6. 6
    Choose:又点不了了
select-after-return:玩完 Impike 那一局、点 Return to Main Menu 以后真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空

为什么是 CGameWindow 来调用它,而不是 CSelectScreen 自己?因为 CSelectScreen 不知道“什么时候回到了主菜单”:它不在画面上的时候什么都不运行。知道这件事的是 CGameWindow,所以 clearSelection 是 public 的,给 CGameWindow 调用。

三条回主菜单的路,结果一模一样:

点 Return to Main Menu:回到选择界面,之前的选择被清空
12
  1. 1
    名字回到 Select a Mokepon
  2. 2
    Choose 点不了
走到出口 → 对话框的 Return to Main Menu(Impike 那一局)真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空
选 Main menu:这一局被丢弃,回到选择界面,选择被清空
12
  1. 1
    名字回到 Select a Mokepon
  2. 2
    Choose 点不了
地图上点菜单的 Main menu(Shellurk 那一局)真实运行截图 · 选 Main menu:这一局被丢弃,回到选择界面,选择被清空
Game Over 后点 Return to Main Menu,回到选择界面
12
  1. 1
    名字回到 Select a Mokepon
  2. 2
    Choose 点不了
输了 → Game Over 框的 Return to Main Menu(Owlet 那一局)真实运行截图 · Game Over 后点 Return to Main Menu,回到选择界面

菜单的 Main menu 走的是 CGameMenu 第 26 行的 lambda event -> navigator.returnToMainMenu();两个结束对话框的按钮走的是 AGameScreen 第 90 行 createGameEndDialog 里的 lambda,也是 navigator.returnToMainMenu()。都通过接口 IGameNavigator,最后到 CGameWindow:

GameMenu 构造器:Main menu 菜单项ui/GameMenu.java第 24–26 行
24 mainMenu = new MenuItem("Main menu");
25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));
26 mainMenu.setOnAction(event -> navigator.returnToMainMenu());
GameScreen.createGameEndDialog:出口框和 Game Over 框ui/GameScreen.java第 88–92 行
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;

这三条路(还有逃跑、打赢以后回地图)的完整流程见 第 15 章。

动画:一局的来回

把这一章的东西连起来,看选择界面在一局游戏里经历了什么。动画上面是截图和 CGameWindow 的代码,下面的顺序图画的是“选择界面 → 地图 → 回到选择界面”,只画了和选择界面有关的调用(地图上走路、打仗都省略了)。

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1
  1. 1
    点 Choose Mokepon(选的是 Impike)
真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    新的一局:Impike 站在起点 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
地图上打开 Game 菜单:Run away 灰,Main menu 可点
1
  1. 1
    地图画面:Main menu 能点
真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
走到出口的门 (6,0):游戏结束对话框
1
  1. 1
    点 Return to Main Menu
真实运行截图 · 走到出口的门 (6,0):游戏结束对话框
点 Return to Main Menu:回到选择界面,之前的选择被清空
1
  1. 1
    又是 Select a Mokepon,数值也空了
真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空
选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
1
  1. 1
    选择界面:Main menu 灰
真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
ui/GameWindow.java第 58–62、101–104 行
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–100 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
ui/GameWindow.java第 84–88、101–104 行
84 public void returnToMainMenu() {
85 world = null;
86 overworldScreen = null;
87 selectScreen.clearSelection();
88 showScreen(selectScreen);
⋯ 省略第 89–100 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
CSelectScreenCGameWindowCOverworldScreenCGameMenustartGame(selectedMokepon)1new OverworldScreen(…)2showScreen(overworldScreen)3returnToMainMenu()4clearSelection()5update(currentScreen)6canReturnToMainMenu()7false8
  1. 选了 Impike,点 Choose Mokepon。按钮的 lambda(CSelectScreen 第 246 行)读出字段 selectedMokepon,通过 getNavigator() 交给 CGameWindow 的 startGame(箭头 1)。CSelectScreen 只认识接口 IGameNavigator。
  2. startGame 用 Impike new 一个 COverworld 和一个 COverworldScreen(箭头 2),再把画面登记成监听者。选择界面没有被丢掉:它还在字段 selectScreen 里,Impike 的选择也原样留着,只是不显示了。
  3. 第 62 行 showScreen(overworldScreen)(箭头 3):换上地图画面,再 updateMenu()。COverworldScreen 没改写 canReturnToMainMenu(),用父类的 true,所以地图上 Main menu 能点。
  4. 走到出口 (6,0),弹出结束对话框。点 Return to Main Menu:这个按钮的 lambda 写在 AGameScreen 的 createGameEndDialog 里,调用 navigator.returnToMainMenu()(箭头 4)。
  5. world 和 overworldScreen 设成 null:这一局(地图世界、地图画面、背包、赢了几场)全部丢掉。
  6. clearSelection()(箭头 5)把同一个选择界面擦干净。第 88 行 showScreen(selectScreen) 把它的 view 放回窗口中间:还是游戏刚打开时那一个 Pane,36 个按钮也还是原来那些。
  7. showScreen 最后调用 menu.update(currentScreen)(箭头 6)。CGameMenu 问选择界面 canReturnToMainMenu()(箭头 7),它改写过,答 false(箭头 8),Main menu 又变灰(截图是刚打开游戏时截的,状态一样)。

选择界面的代码就这些。下一章离开选择界面,进入地图:第 8 章 地图世界:走一步发生了什么。

自测

36 个名字按钮只连着一个 select 方法。点 Nightwing 时,select 怎么知道要选 Nightwing?

每个按钮在 createMokeponList 的循环里得到自己的 lambda:event -> select(mokepon, button)。for-each 每一遍都有新的局部变量 mokepon 和 button,lambda 造出来时就把这两个记住了(变量捕获)。Nightwing 那个按钮的 lambda 记着 Nightwing 对象和 Nightwing 按钮,点它就运行 select(Nightwing, Nightwing 按钮)。

English: Each button's lambda captures the mokepon and button of its own loop round, so clicking it calls select with that Mokepon.

先点 Nightwing 再点 Impike,为什么 Nightwing 的按钮会自己变回普通颜色?是哪一行做的?

select 第 144–146 行:如果字段 selectedButton 不是 null(上一次选的是 Nightwing 按钮),就对它调用 setHighlighted(false)。然后第 147 行把 selectedButton 换成 Impike 的按钮,第 150 行把 Impike 的按钮变亮。所以同一时间只有一个按钮是亮的。

为什么选 Nightwing 时名字后面没有图标,而且名字还在正中间?

Nightwing 是 NORMAL 类型。数据文件只给了 fire、ice、water、lightning 四种类型的图片,CImageParser 没有给 NORMAL 放图片,所以 images.hasTypeImage(NORMAL) 是 false。select 调用 showTypeIcon(false):setVisible(false) 不画它,setManaged(false) 让 HBox 不给它留位置,所以名字一行里只有名字,居中在 x = 801。

English: The type icon is shown only if GameImages has an image for the type; NORMAL has none, so the icon is hidden and takes no space.

Choose 按钮的 lambda 用的是字段 selectedMokepon,名字按钮的 lambda 用的是局部变量 mokepon。为什么 Choose 不能也像名字按钮那样“记住一只”?

Choose 按钮在构造器里就造好了,那时候还没选,selectedMokepon 是 null。它要用的是点击那一刻选中的那只。lambda 里的 selectedMokepon 其实是 this.selectedMokepon,每次运行时才去读字段,所以拿到的是最新的选择。而没选时按钮是 disabled 的,所以交出去的永远不是 null。

卷轴上 Skills 和 Type 之间为什么空着一行?现在的数据里有哪只的技能会写到那一行吗?

createStatsGrid 第 266 行给 Skills 行的高度是 2 * ROW_HEIGHT = 50,skillsValue 又设了 setWrapText(true):技能太长时可以换到第二行,不会压到 Type 上。现在最长的是 “Bite, Pocket Sand, Nimble, Wait”(31 个字符,约 242 像素),比值那一列的 260 像素窄,所以没有谁真的换行,第二行一直空着。

选择界面打开 Game 菜单,为什么 Main menu 是灰的?是谁去问的、什么时候问的?

CSelectScreen 改写了 canReturnToMainMenu(),返回 false(还没有一局可以丢)。CGameWindow 每次 showScreen 都会调用 updateMenu(),也就是 menu.update(currentScreen);CGameMenu 执行 mainMenu.setDisable(!screen.canReturnToMainMenu())。参数类型是 AGameScreen,实际运行的是 CSelectScreen 的版本(多态)。

English: SelectScreen overrides canReturnToMainMenu() to return false, and GameMenu.update() asks the current screen, so the Main menu item is disabled.

玩完一局回到选择界面,之前选的 Impike 不见了。这是一个新造的 CSelectScreen 吗?

不是。CGameWindow 只在构造器里 new 过一个 CSelectScreen,存在 final 字段 selectScreen 里。returnToMainMenu() 先把 world、overworldScreen 设成 null,再调用 selectScreen.clearSelection() 把选择擦掉,最后 showScreen(selectScreen) 把同一个画面放回来。

English: GameWindow keeps one SelectScreen in a final field; returnToMainMenu() clears its selection and shows the same screen again.

CSelectScreen 有没有一个字段存着 36 只 Mokepon?画 UML 时它和 CMokepon 之间有几条“用字段存着”的关系?

没有存列表的字段。mokepons 只是构造器的参数,用来造按钮;每只 Mokepon 由它按钮里的 lambda 记着,列表本身由 CGameWindow 的字段 mokepons 存着。CSelectScreen 和 CMokepon 之间只有一个字段 selectedMokepon : Mokepon(最多一只,没选时是 null)。

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