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 章):
- 类是一段代码,对象是运行时按类造出来的东西。字段(field)是类里面存东西的变量。
- 构造器(constructor)是
new一个对象时自动运行的那段代码。 - CSelectScreen 继承(extends)了抽象类 AGameScreen,也就是说 AGameScreen 是它的父类。
- 彩色带小圆圈的名字(CGameButton)是这个项目自己写的类,UML 上有它的方框。灰底的代码字(
Label、ImageView、ScrollPane、VBox、HBox、GridPane、Circle)是 JavaFX 自带的部件。
1. 先看画面:选择界面由哪些部件组成
下面是选中 Impike 以后的选择界面,切成左右两半放大。除了第 1 块菜单栏,其余每一块都是 CSelectScreen 造出来的。

- 1菜单栏:CGameMenu 造的,不归 CSelectScreen 管
- 2标题 Choose your Mokepon:一个
Label,createTitle造的 - 3名字列表:一个
ScrollPane,createMokeponList造的 - 4列表里的一个名字按钮:CGameButton,36 只 Mokepon 就有 36 个
- 5竖着的滚动条:36 个按钮放不下,
ScrollPane自己加上的

- 6灰色的圆:一个
Circle,createPortraitCircle造的 - 7头像:字段
portraitView(ImageView) - 8名字:字段
nameLabel(Label) - 9类型小图标(火焰):字段
typeIcon(ImageView) - 10卷轴图片:一个
ImageView,构造器里直接造的 - 11数值表的左列:8 个名字
Label(HP: … Type:) - 12数值表的右列:字段
statValues(6 个)、skillsValue、typeValue - 13Choose Mokepon:字段
chooseButton(CGameButton)
注意图例里有的写了“字段 xxx”,有的没写。这不是随便写的。CSelectScreen 只给以后还要改、而且要直接找到的部件开字段:点名字以后,头像、名字、类型图标、数值、Choose 按钮都要变,所以它们存在字段里,select 方法才能找到它们去改。标题、灰色圆、卷轴图片造好以后再也不变,就只放进画面,不存。名字列表也没有字段:列表里的按钮颜色会变(选中的那个变亮),可是 select 是通过参数 button 和字段 selectedButton 找到要变色的那一个(第 4、5 节),不需要一个存着整张列表的字段。
这张图里有三个容易搞错的地方:
- 两个 CGameButton 字段:
chooseButton永远指着 Choose Mokepon 按钮;selectedButton指着现在被选中的那个名字按钮(还没选时是 null)。36 个名字按钮没有一个个存进字段。 - 没有存 Mokepon 列表的字段。36 只 Mokepon 只是传进构造器用一下(第 2 节),字段
selectedMokepon只记着选中的那一只。 - 还有三个字段在父类 AGameScreen 里:
navigator、view(整个画面的Pane)和dialog。它们是 private 的,CSelectScreen 通过getNavigator()、addToView(...)来用。这两个方法是 protected 的:只给子类(和同一个包)用(修饰符见 第 2 章第 3 节,AGameScreen 见 第 6 章第 3 节)。

在 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 节讲过):每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回。
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 节)。
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 号:
List<Mokepon>。CMokeponParser 把数据文件mokepon.game里mokepon.0到mokepon.35读成 36 个 CMokepon 对象,按文件里的顺序放进一个列表。CGameWindow 用字段mokepons存着它(以后开新局还要用)。 - 2 号:CGameImages。CImageParser 读出所有图片路径(类型图标、地形格子、方向按钮、道具、玩家),装进一个 CGameImages 对象。选择界面只用其中的类型图标。
- 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 章细讲。
三样东西进了构造器以后,去向各不相同:
mokepons:交给createMokeponList,一只造一个按钮(第 4 节)。没有存进字段:构造器结束后,CSelectScreen 不再需要整个列表。images:第 90 行this.images = images;存进字段images,因为以后每次点名字,select都要问它有没有类型图标。navigator:第 89 行super(navigator)交给父类,存进父类 AGameScreen 的字段navigator。Choose 按钮用getNavigator()把它取出来(第 7 节)。
卷轴上的数字就是从 1 号列表里来的。比如 Impike 是列表里的第 15 只(下标 14),数据文件第 155–164 行:
1551mokepon.14.name=Impike156mokepon.14.image=assets/Impike.png1572mokepon.14.hp=150158mokepon.14.energy=22159mokepon.14.strength=76160mokepon.14.accuracy=74161mokepon.14.defence=45162mokepon.14.dodge=701633mokepon.14.skills=Lance|Stun|Wait1644mokepon.14.type=FIRE
- 1名字 Impike:第 155 行
name=Impike - 2六个数:第 157–162 行 hp、energy、strength、accuracy、defence、dodge
- 3技能:第 163 行,竖线 | 变成了逗号
- 4类型:第 164 行
type=FIRE
3. 构造器:先造部件,再按顺序放进画面
现在读构造器本身(第 88–116 行)。它做两件事:先造以后要改的部件(存进字段),再放:调用一次 addToView(...),把 8 样东西按顺序放进画面。“造出来”和“放进画面”是两回事:一个 Label 造好了,只要还没放进画面,屏幕上就看不到它。先看前半段,第 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();一句一句看:
- 先认一个名字 CUiStyle:第 91、95、97、99 行都写着
UiStyle.xxx(...)。CUiStyle 是这个项目自己写的工具类(utility class),里面的方法全是 static 的,所以直接写“类名.方法名(...)”就能调用,不用先 new 一个对象。它负责造Label、ImageView、字体、白边,让每个画面长得一样(第 6 章第 7 节)。 - 第 89 行
super(navigator):先运行父类 AGameScreen 的构造器。它把navigator存进自己的字段,把画面view(一个Pane)设成 1280 × 768,然后第一个放进去的是棕色背景图。所以背景在最底下。这份代码把super(...)写在第一行:父类先把navigator和view准备好,子类才开始造自己的部件。(老版本的 Java 规定super(...)必须是构造器的第一句。这个项目用 Java 25 编译,Java 25 允许在它前面写几句不碰this的代码(灵活的构造器正文),但这里没有这样用。) - 第 90 行:存
images。this.images是字段,右边的images是参数,名字一样,用 this 区分。 - 第 91 行:造一个白色描边效果
outline(DropShadow)。标题和名字都用它,黑字外面一圈白边,在棕色背景上看得清。 - 第 93–96 行:算头像放在哪。头像是 256 × 256 的正方形,要放在灰色圆的正中间,所以左上角 = 圆心 − 一半大小:x = 801 − 256 ÷ 2.0 = 673,y = 201 − 128 = 73(
double和2.0见下面的小课堂)。UiStyle.createImageView(null, ...)造一个空的ImageView:位置、大小都定好了,图片是 null,什么都不显示。 - 第 97–98 行:名字
nameLabel,一开始写着常量PROMPT的内容 “Select a Mokepon”,字号 30,加上白边。 - 第 99 行:类型小图标
typeIcon,24 × 24,图片也是 null。 - 第 100 行:Choose 按钮,交给
createChooseButton()去造(第 7 节)。 - 第 101–103 行:技能和类型两个值的
Label,现在是空字。技能那个允许自动换行(第 6 节)。
后半段:一个 addToView,8 样东西,顺序就是谁盖住谁
第 105–114 行只有一句话:调用 addToView(...),括号里 8 个值,用逗号隔开。AGameScreen 的 addToView(Node... nodes) 用的是可变参数:传几个都行。Java 会先从左往右把这 8 个值一个一个算出来(该调用的 create... 方法这时候才运行),全部算完再一起交给 addToView,加到 view 里。后加的画在上面。
下面的动画把整个构造器从头走一遍。代码行上的红色编号 1–9 和截图上的 1–9 一一对应(图例在截图下面)。注意:截图是游戏刚打开时截的,也就是构造器全部跑完以后的样子,所以图上什么都已经有了。动画里亮着的框 = 到这一步为止已经放进 view,或者已经排进 addToView(...) 括号里、等着一起放进去的部分;暗着的框 = 还没轮到。亮起来的先后,就是从下往上画的先后。
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 }
123456789- 1背景(父类放的):整张棕色图铺满游戏区,这里只圈出一块空地
- 2灰色圆
- 3头像框(空的)
- 4标题
- 5名字列表
- 6名字一行(HBox)
- 7卷轴图片
- 8Choose 按钮
- 9数值表(GridPane)
- CGameWindow 执行
new SelectScreen(mokepons, images, this)。第 89 行super(navigator)先运行 AGameScreen 的构造器:存好navigator,view设成 1280 × 768,放进第一样东西:棕色背景(标注 1)。后面放的都盖在它上面。 - 父类构造器一结束,先执行字段声明后面的赋值:第 80 行造出一个空的
EnumMap,存进statValues。现在里面一个Label都没有,画面上也没有多出任何东西。等第 114 行createStatsGrid()(以后在标注 9 的位置)才往里放。 - 第 90 行把参数
images存进字段,select以后要用。第 91 行造白色描边outline,等一下标题和名字共用。这两行在画面上什么都不出现,所以还是只有 1 亮着。 - 算出头像框的左上角 (673, 73),造一个空的
ImageView存进portraitView。它造好了,但还没放进view,所以 3 号框还是暗的(它以后出现在 3 号位置)。 - 再造名字
nameLabel、类型图标typeIcon、Choose 按钮chooseButton、技能和类型的值。全都存进字段,也全都还没放进view。Choose 按钮这时候已经被设成点不了(它以后在 8 号位置)。 - 开始算
addToView的第 1 个值:createPortraitCircle()造出灰色圆(圆心 (801, 201),半径 163,半透明的浅灰,黑边 2 像素)。没有字段存它,造好直接排进括号里(2 亮了)。 - 第 2 个值:前面造好的
portraitView(3 亮了)。它排在灰色圆后面,所以以后有了图片,头像会盖在灰色圆上面。 - 第 3 个值:
createTitle(outline)造标题 Choose your Mokepon,字号 34,放在 (50, 45),截图上是 (50, 70)(4 亮了)。 - 第 4 个值:
createMokeponList(mokepons)。36 只 Mokepon,一只一个 CGameButton,竖着排进一个能滚动的框(5 亮了,第 4 节细讲)。 - 第 5 个值:
createNameRow()造一个HBox(从左往右排的框),把前面造好的nameLabel和typeIcon装进去,宽 450,左右居中在圆心下面(6 亮了)。 - 第 6 个值:卷轴图片
assets/scroll.png,放在 (604, 441),大小 392 × 285。也没有字段存它(7 亮了)。 - 第 7 个值:前面造好的
chooseButton(8 亮了)。 - 第 8 个值:
createStatsGrid()造数值表,同时把 6 个值的Label放进statValues(9 亮了)。它排在卷轴图片后面,所以字写在卷轴上面。如果顺序反过来,卷轴会把字盖住。 - 8 个值都算好了,
addToView一次把它们加进view。现在view里一共 9 样:背景 + 这 8 样,前面的在下,后面的在上。 - 最后
showTypeIcon(false):把类型图标藏起来,而且不占位置。还没选,名字一行(标注 6)只有 “Select a Mokepon”,它正好在正中间。构造器结束,你看到的就是这张图。
布局常量:截图上的坐标都是算出来的
文件开头第 31–74 行是一串 private static final 的常量。它们是游戏区里的坐标和大小(游戏区左上角是 (0, 0))。截图最上面多了 25 像素高的菜单栏,所以截图上的 y = 常量的 y + 25,x 不变。下面先列出常量,再看截图,代码行上的编号和截图上的编号对应。
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;
- 1标题的左上角:(
TITLE_X,TITLE_Y+ 25) = (50, 70) - 2列表:(60, 108 + 25 = 133),295 × 620
- 3红线从圆心画到圆边:圆心 (801, 201 + 25 = 226),长度就是半径 163
- 4头像 256 × 256,左上角 (673, 98)
- 5名字一行:宽 450,y = 380 + 25 = 405
- 6卷轴:(604, 441 + 25 = 466),392 × 285
- 7数值表的左上角 (646, 478 + 25 = 503);两列宽 74(
LABEL_WIDTH)和 260(VALUE_WIDTH),每行高 25(ROW_HEIGHT) - 8Choose:(1050, 504 + 25 = 529),160 × 160
把算式和截图里真实测到的位置放在一起,位置一个都不差:
| 代码里的算法(编号) | 截图上应该在 | 截图里真的是 |
|---|---|---|
| (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 + 74 | x = 720 | 8 个值的 Label 全都在 x = 720 |
(7)第 k 行(从 0 数)的上边 = 503 + 25 × k(ROW_HEIGHT = 25) | HP 503,Energy 528 … Dodge 628 | 6 个值的 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
左边那一列名字是这样造出来的。左边代码的编号对应右边放大图的编号:
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 }
- 1
buttonColumn:一个VBox,把按钮从上往下排 - 2
new GameButton(名字, 字体, 30):按钮上的字就是mokepon.getName() - 3每个按钮宽 270(
LIST_BUTTON_WIDTH) - 4点 Nightwing 时,运行的就是第 218 行交给它的那段 lambda
- 5竖滚动条:
AS_NEEDED,放不下才出现 - 6列表是透明的(第 229–232 行),所以按钮之间的缝(第 213 行的
LIST_GAP,10 像素)里透出的是棕色背景
一行一行读:
- 第 213 行(标注 1):
new VBox(LIST_GAP)。VBox 把放进去的东西从上往下排成一列,括号里的 10 是相邻两个之间的间距(标注 6 那条缝)。 - 第 214 行:字体只加载一次,36 个按钮共用。
- 第 215 行:for-each 循环:对列表里的每一只 Mokepon,执行一遍大括号里的 4 行。列表有 36 只,所以执行 36 遍,顺序就是数据文件里的顺序(Sealance 第一,Stormtalon 最后)。
- 第 216 行(标注 2):造一个 CGameButton,字是这只的名字,圆角半径 30(
BUTTON_RADIUS)。按钮高约 44 像素,所以两头是圆的。 - 第 217 行(标注 3):宽 270。
- 第 218 行(标注 4):告诉按钮“被点的时候做什么”(下面细讲)。
- 第 219 行:把按钮加进
VBox。 - 第 222–227 行:把整个
VBox装进一个 ScrollPane(能滚动的框),放在 (60, 108),大小 295 × 620。横滚动条NEVER(永远不出现),竖滚动条AS_NEEDED(需要时才出现,标注 5)。 - 第 229–232 行(标注 6):用 setStyle 把列表的底色、列表里面那一层的底色、拿到键盘焦点时的蓝色边框,全都设成透明(transparent)。所以你看到的是按钮直接浮在棕色背景上,没有白底,也没有蓝框。
第 218 行的 lambda:按钮怎么知道自己是哪一只
第 218 行是这一章最重要的一行:
button.setOnAction(event -> select(mokepon, button));
拆开读:
button.setOnAction(...):setOnAction 是 JavaFX 按钮的方法,意思是“交给你一段代码,以后你每次被点,就运行它”。event -> select(mokepon, button):交过去的那段代码,写成 lambda 表达式。箭头左边event是点击时 JavaFX 传进来的事件对象(这里没用到它);箭头右边是被点时要做的事:调用select(mokepon, button)。- 关键:第 218 行只是把这段代码交给按钮存着,现在并不运行
select。等你真的点了按钮,JavaFX 才运行它。
那么问题来了: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。这里是同一件事,只是发生在循环里。点“下一步”看循环一轮一轮地造:
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 轮:局部变量
mokepon= 列表里第 1 只 Sealance。第 216 行 new 一个按钮,上面的字是 Sealance,存进这一轮的局部变量button(上面左边那个方框)。 - 第 218 行造一个 lambda,交给这个按钮存着(
setOnAction)。lambda 现在不运行,只是记住这一轮的mokepon(Sealance)和button(Sealance 按钮)。按钮存着 lambda,lambda 也记着按钮,所以图上两个方框互相指着。 - 第 219 行把按钮加进
VBox。这一轮结束,这两个局部变量就不能再用了;可是 lambda 早已把它们指着的对象(Sealance 对象、Sealance 按钮)记下来了。 - 第 6 轮:新的
mokepon= Nightwing,新的button= Nightwing 按钮。又造一个新的 lambda,记着的是 Nightwing 这一对。第 1 轮那个 lambda 记的还是 Sealance,不会被改掉。 - 第 15 轮:Impike,同样又是一个新 lambda。一直转到第 36 轮 Stormtalon,循环结束:36 个按钮,36 个 lambda。到这里,
select一次都还没运行过。 - 很久以后,你点了 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 进入地图。





点一个名字时,谁调用了谁?下面的顺序图画的是点 Impike 那一次(之前已经选过 Nightwing)。每根竖线是一个对象:CSelectScreen 自己(最左边)、字段 chooseButton 指着的 Choose 按钮、字段 images 指着的 CGameImages、被选中的 CMokepon 对象(参数 mokepon)、你点的 Impike 按钮(参数 button)、上一次选中的 Nightwing 按钮(字段 selectedButton 原来指着它)。这三个按钮都是 CGameButton。
箭头上的编号和 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。
代码和截图对照
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 }
- 1Choose 能点了:
setUsable(true) - 2头像:
assets/Impike.png - 3名字:
getName() - 4火焰图标:
assets/sprite_fire.png - 5六个数:150、22、76、74、45、70
- 6技能:
formatSkills连成的一串字 - 7类型:
type.name()= FIRE
动画:先点 Nightwing,再点 Impike
下面一步一步走两次 select。编号的意思在每张图里都一样:1 Nightwing 的按钮,2 Choose 按钮,3 头像,4 名字,5 六个数值,6 技能,7 类型,8 名字后面放图标的地方(5–8 在放大的卷轴图里看)。
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 }
12345- 1Nightwing 按钮(普通颜色)
- 2Choose:淡的,点不了
- 3头像:空
- 4Select a Mokepon
- 5值:全空
1234- 1Nightwing 变亮
- 2Choose 亮了
- 3头像
- 4Nightwing

- 4Nightwing
- 8名字后面没有图标
- 5155、27、68、83、42、86
- 6Lance, Aim, Pocket Sand, Wait
- 7NORMAL
1234- 1Nightwing 变回普通
- 2Choose 还亮着
- 3头像
- 4Impike

- 4Impike
- 8火焰图标
- 5150、22、76、74、45、70
- 6Lance, Stun, Wait
- 7FIRE
- 刚打开游戏:
selectedButton和selectedMokepon都是 null(声明时没写= ...,对象类型的字段默认就是 null)。你点 Nightwing,它的按钮运行自己的 lambda:select(Nightwing 对象, Nightwing 按钮),所以参数mokepon是 Nightwing,button是 Nightwing 的按钮。 - 第 144 行先问:之前有选中的按钮吗?现在
selectedButton是 null,条件不成立,第 145 行跳过。这个检查不能省:对 null 调用方法(null.setHighlighted(false))程序会出错(NullPointerException)。 - 第 147–148 行把这次的选择记进字段:
selectedButton= Nightwing 按钮,selectedMokepon= Nightwing。参数在方法结束后就没了,字段会一直留着:下次点别的名字时第 144 行要用selectedButton,点 Choose 时要用selectedMokepon。 - 第 149 行
chooseButton.setUsable(true):Choose 按钮能点了,透明度从 0.4 回到 1,不再是淡的(标注 2)。 - 第 150 行
button.setHighlighted(true):Nightwing 按钮的浅灰背景从 50% 不透明变成 75%(CGameButton 里的HIGHLIGHTED = 0.75)。浅灰盖得更实,在棕色背景上看起来更亮、更白(标注 1)。CGameButton 第 45 行的注释写的是 Darken(变暗),可截图里明明是变亮,以截图为准。 - 第 152 行:问 Nightwing 要图片路径
assets/Nightwing.png,用UiStyle.loadImage读成图片,放进portraitView(标注 3)。第 153 行:名字换成 Nightwing(标注 4)。 - 第 156 行取出类型 NORMAL。第 157 行问
images.hasTypeImage(NORMAL):没有 normal 的图片,答案是 false,于是showTypeIcon(false):图标看不见、也不占位置。第 158 行再问一次,还是 false,第 159 行跳过。名字后面什么都没有(标注 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结束。 - 现在点 Impike(列表第 15 个,自己玩要往下滚才看得到它;这张截图里列表停在最上面,所以看不到 Impike 的按钮)。又进
select,这次参数是 Impike 和它的按钮。第 144 行selectedButton是 Nightwing 按钮,不是 null,第 145 行把它变回普通颜色(标注 1)。所以同一时间只有一个按钮是亮的。 - 第 147–148 行:两个字段改成 Impike 按钮和 Impike。Nightwing 不再被记着。第 149 行 Choose 再设一次能点(本来就能点,结果不变,标注 2)。第 150 行把 Impike 按钮变亮(它在列表下面,截图里看不到)。
- 头像换成
assets/Impike.png,名字换成 Impike(标注 3、4)。 - 类型是 FIRE。
hasTypeImage(FIRE)是 true(数据文件第 397 行有mokepon.type.fire),所以showTypeIcon(true),图标出现;第 159 行把它的图片设成assets/sprite_fire.png(标注 8)。 - 数值换成 150、22、76、74、45、70(标注 5),技能 “Lance, Stun, Wait”(标注 6),类型 FIRE(标注 7)。和 Nightwing 用的是同一批
Label,只是字换了。
名字后面的小图标:为什么 NORMAL 没有

- 1NORMAL 的 Nightwing
- 2名字后面没有图标,也没有空位
- 3Type: NORMAL

- 1FIRE 的 Impike
- 2火焰图标
typeIcon - 3Type: FIRE
决定有没有图标的,是 CGameImages 里有没有这个类型的图片。顺着代码往回找:
397mokepon.type.fire=assets/sprite_fire.png398mokepon.type.ice=assets/sprite_ice.png399mokepon.type.water=assets/sprite_water.png400mokepon.type.lightning=assets/sprite_lightning.png30 /* 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 }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 }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 }- 数据文件第 397–400 行只写了 fire、ice、water、lightning 四张图,没有
mokepon.type.normal。 - 上面 CImageParser 的代码里有个
T:readImages是一个泛型方法,T是一个先空着的类型,调用时才定下来(第 2 章第 5 节“泛型方法”)。读类型图标时传进来的是MokeponType.values(),所以这里T就是 EMokeponType,value依次是 NORMAL、FIRE、ICE…… - CImageParser 的
readImages对 EMokeponType 的 5 个值一个一个拼出键名(mokepon.type.+ 小写的名字),第 33 行if (data.has(key)):有这个键才放进 Map。所以 Map 里只有 4 项,没有 NORMAL。 - CGameImages 的
hasTypeImage(type)就是typeImages.containsKey(type):Map 里有没有这个键。NORMAL 得到 false。 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。
- 如果没有图标、名字单独居中:从 801 − 85 ÷ 2 = 758.5 开始。
- 有图标,一整组宽 85 + 12 + 24 = 121:从 801 − 121 ÷ 2 = 740.5 开始。截图里量到 738.5,正好是 740.5 − 2(白边)。
- 名字往左挪了 758.5 − 740.5 = 18 = (12 + 24) ÷ 2:让出间隔和图标的一半。
- 图标从 740.5 + 85 + 12 = 837.5 开始,和截图里
typeIcon的 x = 837.5 一样。
技能那一串字:formatSkills 和 String.join
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”,中间经过两次变形:
- 第 176 行(标注 1):造一个空的 ArrayList,用来放名字(
List<String>:一个装字符串的列表)。代码里new ArrayList后面的 ◇ 又是小于号加大于号两个字符,空着表示“和左边一样,装 String”(见第 3 节的 Java 小课堂)。 - 第 177–179 行(标注 2):CMokepon 存的不是名字,是 ASkill 对象:读文件时 CMokeponParser 已经把
Lance|Stun|Wait按竖线切开,再用SkillCatalogue.find(名字)一个个找到对应的技能对象(技能对象从哪来见 第 12 章第 7 节)。对每个技能对象调用getName(),把名字加进列表。 - 第 180 行(标注 3):String.join(", ", skillNames) 用逗号加一个空格把列表里的名字连成一个字符串。只放在名字之间,最后一个后面没有逗号。
六个数:Stat.values() 和 statValues
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 }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 个。每转一圈:
mokepon.getStat(stat):取出这只 Mokepon 这一项的数,比如 Impike 的 HP 是 150(一个 int)。String.valueOf(...):把 150 变成文字 "150"(String.valueOf),因为setText只收文字。statValues.get(stat):从 Map 里取出管 HP 那一行的Label,setText("150")。
这 6 个 Label 是 createStatsGrid 造表格时放进 statValues 的(下一节),键就是同一个 EStat。所以“哪个数写到哪一行”不会搞错:HP 的数一定写进 HP 那一行。
6. 卷轴上的数值表:createStatsGrid 和 addStatRow
卷轴上的字排得整整齐齐,是因为它们放在一个 GridPane(网格)里:两列、8 行。
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 }
- 1第 0 列:名字,宽 74(
LABEL_WIDTH),x 从 646 到 720 - 2第 1 列:值,宽 260(
VALUE_WIDTH),x 从 720 到 980 - 3普通的一行:高 25(
ROW_HEIGHT),这是 Energy 行(528 到 553,红线是它的下边) - 4Skills 行:高 2 × 25 = 50(653 到 703),第二行空着
- 5Type 行:最后一行,高 25(703 到 728)
createStatsGrid 一行一行读:
- 第 254–256 行:造一个空的
GridPane,放在 (646, 478),截图上是 (646, 503)。 - 第 257–258 行(标注 1、2):规定两列的宽度:第 0 列 74,第 1 列 260(ColumnConstraints)。所以值那一列从 646 + 74 = 720 开始。
- 第 260 行:
int row = 0;记着“下一行是第几行”。 - 第 261–265 行(标注 3):对 6 个 EStat 各做一次:造一个空的值
Label,放进statValues(键是这个 Stat),再调用addStatRow加一行。名字是stat.getLabel() + ":",比如"Energy" + ":"="Energy:"。 - 第 266 行(标注 4):加 Skills 行,值是构造器里造好的
skillsValue,高度是2 * ROW_HEIGHT= 50。 - 第 267 行(标注 5):加 Type 行,值是
typeValue,高 25。这里写row不写row++:最后一行,后面不用再加了。
addStatRow 负责“加一行”:
- 第 272 行:名字用粗体的 SansSerif 字体,13 号。
- 第 273 行:
grid.add(名字Label, 0, row):放进第 0 列、第row行。注意add的参数顺序是先列后行。 - 第 274 行:值的
Label放进第 1 列、同一行。 - 第 275–277 行:给这一行一个
RowConstraints:高height,里面的东西靠上(VPos.TOP)。GridPane按加进去的顺序对应:第 0 个规定管第 0 行,第 1 个管第 1 行……所以每加一行,就加一个规定。
8 行的高度和截图上的位置:
| 行 | 左边的字 | 高 | 截图上 y |
|---|---|---|---|
| 0–5 | HP: Energy: Strength: Accuracy: Defence: Dodge: | 各 25 | 503、528、553、578、603、628 |
| 6 | Skills: | 50 | 653(到 703) |
| 7 | Type: | 25 | 703(到 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)。
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 }createValueLabel():一个空字的Label,用 Monospaced(等宽)字体 13 号。等宽字体每个字一样宽,数字上下对得齐。截图里右边的值看起来像打字机的字,就是这个原因。createRowLabel(text, font):名字和值都由它造。第 287 行setMinHeight(ROW_HEIGHT):每个Label至少 25 高,所以左右两列的字一样高、在同一条线上(第 284 行的注释)。第 288 行CENTER_LEFT:字靠左、上下居中。
7. Choose Mokepon 按钮,和变灰的 Main menu
没选之前不能点
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 }
- 1两行字:
"Choose\nMokepon" - 2宽 160(第 241 行),左上角放在 (1050, 504),截图上是 (1050, 529)
- 3还没选:
setUsable(false),整个按钮只剩 40% 不透明,点不动
- 第 238 行(标注 1):字是
"Choose\nMokepon"。\n是转义字符,表示换行,所以按钮上是两行。字号 24,圆角 20。 - 第 240 行:两行字居中对齐。
- 第 241–243 行(标注 2):160 × 160,放在 (1050, 504)。
- 第 244 行(标注 3):
setUsable(false):一造出来就点不了。只有select的第 149 行会把它打开,clearSelection(第 8 节)会把它再关上。 - 第 245 行:
addPressEffect():鼠标按住时背景变成 0.75(浅灰更实,看起来更亮),松开变回 0.5。名字按钮没有调用它,它们用的是“选中就一直亮着”(setHighlighted)。 - 第 246 行:被点时运行
getNavigator().startGame(selectedMokepon)(下面细讲)。
名字按钮“亮着”、Choose 按钮“淡着”,都不是 CSelectScreen 自己画的:它只调用 CGameButton 的两个方法,“亮成什么样、淡成什么样”写在 CGameButton 里。下面只看这两个方法;0.5、0.75、0.4 三个数和“为什么看起来是 20% 的灰”,在 第 6 章第 7 节已经算过。左边代码的编号对应右边两张图(3 号还是上面那个 Choose 按钮,这次是选了以后):
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 }
- 4选中的 Nightwing:
setHighlighted(true),背景 0.75,更亮、更白;上下的 Pengloom、Grubeye 是普通的 0.5

- 3选了以后:
setUsable(true),整个按钮不透明度回到 1,能点
- 第 46–48 行
setHighlighted(标注 4):只改背景浅灰的不透明度:true 用 0.75,false 用 0.5。highlighted ? HIGHLIGHTED : NORMAL是三元运算符条件 ? A : B:条件是 true 就取 A,否则取 B。浅灰盖得越实,在棕色背景上越亮。(第 45 行注释写的是 Darken,但截图里是变亮,以截图为准。) - 第 51–55 行
setUsable(标注 3):第 52 行setDisable(!usable),!是“取反”:usable是 false 时,!usable就是 true,于是setDisable(true),按钮点不动(禁用)。第 53 行把背景设回普通的 0.5。第 54 行把整个按钮(背景、字一起)的不透明度设成 1 或 0.4,所以没选时 Choose 的字也是淡的。
CSelectScreen 一共在 6 个地方调用这两个方法。它每次只说“要哪种样子”,怎么画出来由 CGameButton 决定:
- 第 244 行(
createChooseButton):button.setUsable(false):Choose 一造出来就是淡的、点不了。这个局部变量button会被 return 出去,存进字段chooseButton。 - 第 145 行(
select):selectedButton.setHighlighted(false):上一次选中的名字变回普通。 - 第 149 行(
select):chooseButton.setUsable(true):Choose 能点了。 - 第 150 行(
select):button.setHighlighted(true):点的这个名字变亮。 - 第 127 行(
clearSelection):selectedButton.setHighlighted(false):回主菜单时,选中的名字变回普通(第 8 节)。 - 第 139 行(
clearSelection):chooseButton.setUsable(false):Choose 又点不了了(第 8 节)。
第 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 是灰的

- 1Run away:灰
- 2Main menu:灰
- 3Exit:能点

- 1Run away:灰
- 2Main menu:能点
- 3Exit:能点
菜单只有一个(CGameMenu),三个画面共用。它怎么知道现在该让哪一项变灰?它去问当前的画面:
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 }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 }118 /* There is no game to leave yet, so the main menu option is not needed. */119 @Override1202 public boolean canReturnToMainMenu() {121 return false;122 }- 父类 AGameScreen 给两个问题都写了默认答案:
canRunAway()返回 false,canReturnToMainMenu()返回 true。 - CSelectScreen 用
@Override改写(override)了第二个,返回 false。注释说:还没有一局游戏,没有东西可以丢掉,回主菜单没意义(本来就在主菜单)。第一个它没改,用的是父类的 false。 - CGameMenu 的
update(GameScreen screen):mainMenu.setDisable(!screen.canReturnToMainMenu())。!是“取反”:画面说 false(不能),!false= true,于是setDisable(true),菜单项变灰。变灰的菜单项连快捷键 Ctrl+B 也不响应。
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()。
82 /* The game being played is thrown away. */83 @Override84 public void returnToMainMenu() {851 world = null;86 overworldScreen = null;872 selectScreen.clearSelection();883 showScreen(selectScreen);89 }- 第 85–86 行(标注 1):
world和overworldScreen设成 null。这一局的地图世界和地图画面没人再指着了,就被丢掉了。下次点 Choose 会 new 新的。 - 第 87 行(标注 2):
selectScreen.clearSelection():叫选择界面把上次的选择清空。 - 第 88 行(标注 3):把选择界面放回窗口中间。
注意第 87–88 行用的是字段 selectScreen,不是 new SelectScreen(...)。CGameWindow 只在构造器第 41 行造过一个 CSelectScreen,存在 final 字段 selectScreen 里,一直用到程序结束。名字列表永远是那 36 只,没必要重新造 36 个按钮;只要把上次选的东西擦掉就行。这和另外两个画面不一样:COverworldScreen 每开一局 new 一个,CBattleScreen 每打一仗 new 一个。
clearSelection 和 select 正好反过来。左边代码的编号对应右边截图的编号:
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 按钮关上。

- 1头像:
setImage(null),空了 - 2名字:又是
PROMPT“Select a Mokepon” - 3六个值:
setText("") - 4技能:空
- 5类型:空
- 6Choose:又点不了了
为什么是 CGameWindow 来调用它,而不是 CSelectScreen 自己?因为 CSelectScreen 不知道“什么时候回到了主菜单”:它不在画面上的时候什么都不运行。知道这件事的是 CGameWindow,所以 clearSelection 是 public 的,给 CGameWindow 调用。
三条回主菜单的路,结果一模一样:

- 1名字回到 Select a Mokepon
- 2Choose 点不了

- 1名字回到 Select a Mokepon
- 2Choose 点不了

- 1名字回到 Select a Mokepon
- 2Choose 点不了
菜单的 Main menu 走的是 CGameMenu 第 26 行的 lambda event -> navigator.returnToMainMenu();两个结束对话框的按钮走的是 AGameScreen 第 90 行 createGameEndDialog 里的 lambda,也是 navigator.returnToMainMenu()。都通过接口 IGameNavigator,最后到 CGameWindow:
24 mainMenu = new MenuItem("Main menu");25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));26 mainMenu.setOnAction(event -> navigator.returnToMainMenu());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 的代码,下面的顺序图画的是“选择界面 → 地图 → 回到选择界面”,只画了和选择界面有关的调用(地图上走路、打仗都省略了)。

- 1点 Choose Mokepon(选的是 Impike)

- 1新的一局:Impike 站在起点 (10,18)

- 1地图画面:Main menu 能点

- 1点 Return to Main Menu

- 1又是 Select a Mokepon,数值也空了

- 1选择界面:Main menu 灰
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();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();- 选了 Impike,点 Choose Mokepon。按钮的 lambda(CSelectScreen 第 246 行)读出字段
selectedMokepon,通过getNavigator()交给 CGameWindow 的startGame(箭头 1)。CSelectScreen 只认识接口 IGameNavigator。 startGame用 Impike new 一个 COverworld 和一个 COverworldScreen(箭头 2),再把画面登记成监听者。选择界面没有被丢掉:它还在字段selectScreen里,Impike 的选择也原样留着,只是不显示了。- 第 62 行
showScreen(overworldScreen)(箭头 3):换上地图画面,再updateMenu()。COverworldScreen 没改写canReturnToMainMenu(),用父类的 true,所以地图上 Main menu 能点。 - 走到出口 (6,0),弹出结束对话框。点 Return to Main Menu:这个按钮的 lambda 写在 AGameScreen 的
createGameEndDialog里,调用navigator.returnToMainMenu()(箭头 4)。 world和overworldScreen设成 null:这一局(地图世界、地图画面、背包、赢了几场)全部丢掉。clearSelection()(箭头 5)把同一个选择界面擦干净。第 88 行showScreen(selectScreen)把它的view放回窗口中间:还是游戏刚打开时那一个Pane,36 个按钮也还是原来那些。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)。