Mokepon代码导读
第二站 · 全局L1约 35 分钟

05程序是怎么跑起来的

从 main 到第一个画面:读数据文件、解析成对象、打开窗口,然后等你点

读完这一章你能
  • 按顺序说出:从 gradlew run 到看见选择界面,运行了哪些类的哪些方法
  • 看懂 mokepon.game 的写法,并指出 Impike 卷轴上每一个数字来自数据文件的哪一行
  • 说出 CMain.start 里的 try / catch 接得住哪些错、接不住哪些错;不带 data= 参数运行时会打印什么
  • 说出三个解析器各造出什么:CMokeponParser → 36 个 CMokepon;CMapParser → CGameMap(20×20 格 + 14 个地图物体);CImageParser → CGameImages(19 个图片路径)
  • 说出 CGameWindow 构造器按什么顺序造出菜单、选择界面和 Scene,为什么 start() 结束了程序还没结束
  • 用一句英语说清楚:After start() returns, the program is event-driven: our code only runs when JavaFX calls an event handler (a lambda) that we registered — the first ones during start-up, more later when new screens are created.

上一章把 72 个类按包分好了。这一章看它们第一次动起来的那一刻:从 Java 程序开始运行,到选择界面出现,只要很短的时间。这段时间里,程序读了一个 472 行的文本文件,把它变成几百个对象,再搭出窗口。搭完以后,程序就停下来等你。

这一章会出现很多代码,但都是按运行的先后顺序排的。你可以一直问自己一个问题:“现在执行到 CMain 的第几行了?”

1. 先看终点:启动完成时屏幕上的每一块从哪来

程序跑起来以后,你看到的第一个画面是这样的。这时你还什么都没点。图上每一块,都是启动那一瞬间由某个类造出来的:

游戏刚打开:选择界面,还没选 Mokepon12345678910
  1. 1
    菜单栏:CGameMenu 造的 MenuBar,CGameWindow 用 root.setTop(...) 把它放在最上面(第 7 节)
  2. 2
    棕色背景:这个框里什么都没有,框住的就是背景图本身。图片 assets/bg.png 铺满整个游戏区,是父类 AGameScreen 的构造器第一个放进画面的
  3. 3
    标题 Choose your Mokepon:CSelectScreen 的 createTitle 造的一个 Label
  4. 4
    名字按钮:CMokeponParser 从数据文件读出 36 只 Mokepon,CSelectScreen 一只造一个按钮。最上面的 Sealance 是数据文件里的第 0 只(第 4 节)
  5. 5
    滚动条:36 个按钮放不下,装按钮的 ScrollPane 自己加了滚动条。现在只看得到前 12 个左右
  6. 6
    灰色圆:createPortraitCircle 造的 Circle。头像以后放在它上面,现在还是空的
  7. 7
    一句提示 Select a Mokepon:CSelectScreen 里的常量 PROMPT
  8. 8
    卷轴:图片 assets/scroll.png(常量 UiStyle.SCROLL_FILE),数值表就画在它上面
  9. 9
    HP: …… Dodge::每个 EStat 常量自己带的 label("HP"、"Energy"……,用 getLabel() 取),后面加一个冒号,顺序就是 EStat 里写的顺序。下面的 Skills: 和 Type: 两行是 CSelectScreen 自己写的字
  10. 10
    Choose Mokepon:灰的。CSelectScreen 一造好这个按钮就调用 setUsable(false)
启动刚完成:窗口里每一块都已经造好,程序正在等你点真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon

截图宽 1280、高 793:上面 25 像素是菜单栏,下面 1280×768 是游戏区。窗口标题栏上写着 Mokepon,但标题栏不在截图里。

从你按下运行,到这个画面出现,按顺序发生了 6 件事:

① gradlew run带上参数 data=mokepon.game(第 2 节)② main → launch→ start(stage)(第 2 节)③ 读文件GameDataReader(第 3 节)④ 把文字变成对象三个 Parser(第 4–6 节)⑤ new GameWindow菜单 + 选择界面(第 7 节)⑥ show()然后停下来等你点(第 8 节)
启动的 6 步,从上往下:①–⑤ 一口气做完,然后停在 ⑥ 等你操作。方框里写了它在这一章的第几节

总指挥是 CMain(项目根包里唯一的类)。它一共只有 20 多行。下面代码里的红色数字就是上图的圆圈编号:第 ② 步是 main 和 start 的开头,第 ③ 步是第 25 行,第 ④ 步是第 35–37 行,第 ⑤ ⑥ 步是第 38、39 行。第 ① 步不在 Java 代码里,在 build.gradle.kts 里。

Main.java:整个启动过程就在这里Main.java第 18–45 行
18/* Starts the game: load the data, then open the window. */
19public class Main extends Application {
20
21 @Override
222 public void start(Stage stage) {
23 GameData data;
24 try {
253 data = new GameDataReader().read(getParameters().getRaw());
26 } catch (IOException | IllegalArgumentException e) {
27 /* Stop before opening the game if the data cannot be loaded,
28 * for example "Error loading mokepon.game".
29 */
30 System.out.println("Error loading " + e.getMessage());
31 Platform.exit();
32 return;
33 }
34
354 List<Mokepon> mokepons = new MokeponParser().parse(data);
364 GameMap map = new MapParser().parse(data);
374 GameImages images = new ImageParser().parse(data);
385 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
396 gameWindow.show();
40 }
41
42 public static void main(String[] args) {
432 launch(args);
44 }
45}

先记住这张图和这 20 多行。下面每一节讲其中一步。最后第 8 节用一个大动画,把整条路从头到尾再走一遍。

2. 启动:从 gradlew run 到 Main.start

build.gradle.kts:告诉 Gradle 怎么运行

Java 程序不会自己跑起来,要有人启动它。你的项目用 Gradle:在项目文件夹里运行 gradlew run(或者点 IntelliJ 右边 Gradle 面板里的 run),Gradle 读 build.gradle.kts,照着里面写的编译、启动。跟启动有关的是这几行:

build.gradle.kts:和启动有关的部分build.gradle.kts第 20–36 行
20javafx {
21 version = "25"
221 modules = listOf("javafx.controls")
23}
24
25application {
262 mainModule.set("au.edu.unimelb.swen.oop.mokepon")
273 mainClass.set("au.edu.unimelb.swen.oop.mokepon.Main")
28
29 applicationDefaultJvmArgs = listOf(
304 "--enable-native-access=javafx.graphics"
31 )
32}
33
34tasks.named<JavaExec>("run") {
355 args("data=mokepon.game")
36}
  1. 第 22 行 modules = listOf("javafx.controls"):要用 JavaFX 的 controls 模块。按钮、文字、菜单这些窗口部件都在里面。
  2. 第 26 行 mainModule:程序所在的模块叫 au.edu.unimelb.swen.oop.mokepon,它在 module-info.java 里声明(下面马上看)。
  3. 第 27 行 mainClass:从哪个类开始。写的是 CMain,Java 就去找 CMain 的 main 方法。
  4. 第 30 行 --enable-native-access=javafx.graphics:交给 Java 虚拟机的一个开关,允许 javafx.graphics 模块加载它自带的本地代码(.dll 文件,画窗口要用)。不加的话,开头会先打印 4 行 WARNING,程序照样运行。
  5. 第 35 行 args("data=mokepon.game"):运行时带上一个命令行参数,内容就是这串文字 data=mokepon.game。它会一路传到 CGameDataReader(第 3 节有一张图画出它走过的路)。

mokepon.game 前面没有写在哪个文件夹,这叫相对路径:Java 从程序运行时所在的文件夹开始找。gradlew run 在项目文件夹里启动程序,所以能找到项目根目录下的 mokepon.game。数据文件里的图片路径 assets/Impike.png 也是这样找到的。

module-info.java:这个程序是一个模块

module-info.java:模块名,加上两句src/main/java/module-info.java第 1–5 行
1module au.edu.unimelb.swen.oop.mokepon {
21 requires javafx.controls;
3
42 exports au.edu.unimelb.swen.oop.mokepon;
5}
  1. 第 2 行 requires javafx.controls:这个模块要用 JavaFX 的 controls 模块(它会顺带把画图用的 javafx.graphics 也带进来)。
  2. 第 4 行 exports au.edu.unimelb.swen.oop.mokepon:把根包开放给模块外面。CMain 就在根包里。JavaFX 在它自己的模块里,它要造一个 CMain 对象、调用 start,就必须看得见 CMain。其余 8 个包没有写 exports,模块外面看不到它们。

module-info 的写法和 requires / exports 的意思,第 2 章第 9 节讲过。

main → launch → start:JavaFX 接手

Java 找到 CMain 以后,从 main 方法开始执行。先看老师打印稿里的 CMain:

Main 的 UML 类图
打印稿里的 Main:空心三角箭头指向 Application,意思是 Main extends Application。Application 是抽象类(A,名字是斜体),它的 start(Stage) 也是斜体:抽象方法,子类必须写。main(String[]) 带下划线:static。名字前的绿色圆点 = public(红色方块 = private,黄色菱形 = protected)

名字前面这些小图标怎么读,见 第 3 章第 5 节;斜体和下划线见 第 3 章第 6 节。

Main.java:main 只有一行,start 由 JavaFX 来调用Main.java第 19–22、42–44 行
19public class Main extends Application {
20
21 @Override
223 public void start(Stage stage) {
⋯ 省略第 23–41 行
421 public static void main(String[] args) {
432 launch(args);
44 }
  1. 第 42 行 public static void main(String[] args):Java 程序的入口(main 方法)。args 是一个字符串数组,里面就是 Gradle 带上的那一个参数:"data=mokepon.game"。它是 static 的,因为刚开始时还没有任何对象,入口必须不用对象就能调用。
  2. 第 43 行 launch(args):launch 是父类 Application(JavaFX 的类,见 Application 类)的静态方法。CMain 继承了它,所以可以直接写 launch(...)。launch 按固定顺序做三件事:启动 JavaFX 的界面线程;造一个 CMain 对象(CMain 没写构造器,用的是默认构造器);造出第一个窗口,一个 Stage(还没显示)。然后它在界面线程上调用这个对象的 start(stage)。
  3. 第 22 行 public void start(Stage stage):真正干活的地方。stage 就是 JavaFX 刚造的那个窗口,后面会交给 CGameWindow。

下面这张顺序图(sequence diagram)画的就是这几步。读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。这一章还多一种:虚线 + 实心箭头 = 造一个新对象(new,创建消息)。带底色的小框是说明。

你:gradlew runCMainJavaFXstage : Stagemain(args)1launch(args)2new Main()造窗口(还没显示)3start(stage)4读数据、解析、造 GameWindow(第 3–7 节)start() 返回5JavaFX 接着运行,等你操作(第 8 节)
main → launch → start:真正调用 start 的是 JavaFX,不是你的代码

第 4 个箭头最重要:start 不是你的代码调用的,是 JavaFX 调用的。第 5 节最后会看到一段真实的报错,里面就印着这一点:Main.start 是从 JavaFX 内部被叫来的。

3. 读文件:GameDataReader 和 GameData

先看 mokepon.game 长什么样

数据文件的开头,是第一只 Mokepon(Sealance):

mokepon.game 第 1–11 行:第 0 只 Mokeponmokepon.game第 1–11 行
1mokepon.0.name=Sealance
2mokepon.0.image=assets/Sealance.png
3mokepon.0.hp=180
4mokepon.0.energy=10
5mokepon.0.strength=55
6mokepon.0.accuracy=70
7mokepon.0.defence=75
8mokepon.0.dodge=45
9mokepon.0.skills=Bite|Wait
10mokepon.0.type=WATER
11

写法只有一种:一行一个 键=值。这种文件叫属性文件。mokepon.0.hp=180:键是 mokepon.0.hp(第 0 只 Mokepon 的 HP),值是 180。整个文件 472 行,其中 426 行是键值,46 行是空行。空行只是为了好看,读的时候会被跳过。

要注意:值全是文字(字符串)。180 在这里只是三个字符,10,18 也只是五个字符。把它们变成整数、变成坐标,是后面代码的事。

426 行按键的开头分成几组,每组由一个解析器负责:

键的开头(几行)谁来读 → 变成什么
mokepon.N.(N 是 0–35)
360 行
CMokeponParser → 36 个 CMokepon 对象
map.tiles.N(N 是 0–19)
20 行
CMapParser → 地形 Terrain[][]
起点、三组感叹号、出口、问号箱
27 行
CMapParser → 起点和 14 个地图物体
类型、地形、方向按钮、玩家、道具的图片
19 行
CImageParser → 19 个图片路径

360 + 20 + 27 + 19 = 426,一行不多一行不少。

GameDataReader.read:把文件读进来

CMain 第 25 行是 data = new GameDataReader().read(getParameters().getRaw());。getParameters() 是从父类 Application 继承来的方法,它的 getRaw() 取回 launch 收到的参数,得到一个 List<String>,里面只有一个元素 "data=mokepon.game"。先看这串文字从 build.gradle.kts 一路走到打开文件,经过了哪些地方:

build.gradle.kts第 35 行“data=mokepon.game”main(String[] args)Main 第 42 行args[0] 就是它launch(args)Main 第 43 行JavaFX 记下参数getRaw()Main 第 25 行List,1 个元素read(arguments)第 18 行是 data= 开头吗?是substring(5)第 24 行“mokepon.game”Path.of(...)第 24 行一个文件路径对象newInputStream第 26 行打开文件
参数 data=mokepon.game 走过的路:从左上角开始跟着箭头走(第一行往右,第二行往左,第三行往右,第四行往左)。每个方框写的是代码在哪一行,和这时候手里的值

后 4 步(从 read(arguments) 开始)都在 CGameDataReader.read 里面。read 的代码是这样的:

GameDataReader.read:参数 → 文件 → Properties → GameDatadata/GameDataReader.java第 10–36 行
10/* Reads the game data file named on the command line. */
11public class GameDataReader {
12 private static final String ARGUMENT_PREFIX = "data=";
13
14 /* The first argument must look like data=relativeFilePath.
15 * If the file is missing, the exception message is its path, for example mokepon.game.
16 */
17 public GameData read(List<String> arguments) throws IOException {
181 if (arguments.isEmpty() || !arguments.getFirst().startsWith(ARGUMENT_PREFIX)) {
19 throw new IllegalArgumentException(
20 "data file: missing argument in the form data=relativeFilePath");
21 }
22
23 String argument = arguments.getFirst();
242 Path dataFile = Path.of(argument.substring(ARGUMENT_PREFIX.length()));
253 Properties properties = new Properties();
264 try (InputStream input = Files.newInputStream(dataFile)) {
27 properties.load(input);
28 }
29
30 /* Remove spaces around values before the numbers are converted. */
315 for (String key : properties.stringPropertyNames()) {
32 properties.setProperty(key, properties.getProperty(key).trim());
33 }
346 return new GameData(properties);
35 }
36}
  1. 第 18 行:检查参数。列表是空的(没带参数),或者(||)第一个参数不是以 data= 开头(! 表示“不是”:startsWith 得到 true,前面加 ! 就变成 false),就执行第 19–20 行:throw 一个 IllegalArgumentException(参数不合法的异常),read 立刻停下。getFirst() 就是取第 0 个元素。ARGUMENT_PREFIX 是第 12 行的常量 "data="。
  2. 第 24 行:"data=" 有 5 个字符,substring(5) 去掉前 5 个,剩下 "mokepon.game"。Path.of(...) 把这串文字变成一个文件路径对象。
  3. 第 25 行:造一个空的 Properties。它是 Java 自带的一张“键 → 值”表,专门读这种文件。
  4. 第 26–27 行:Files.newInputStream 打开文件,得到一个 InputStream(输入流:可以从头到尾一点一点往外读的文件内容)。properties.load(input) 一行一行读:空行跳过,每个 键=值 放进表里,一共 426 对。文件不存在时,第 26 行就抛出 IOException。
  5. 第 31–32 行:stringPropertyNames() 拿到全部 426 个键,for-each 循环一个一个处理:取出值,trim() 去掉前后的空格,再放回去。Properties 会自己去掉等号后面紧跟的空格,但不管值末尾的空格。"150 " 多一个空格,以后变整数时就会出错。你的 mokepon.game 里其实没有这样的空格,这两行是预防。
  6. 第 34 行:把这张表包进一个新的 CGameData 对象,交回给 CMain。

读不到文件会怎样:真实运行结果

回到 CMain。第 25 行外面包着一个 try:

Main.start:只有读文件这一句被 try 包住Main.java第 22–40 行
22 public void start(Stage stage) {
23 GameData data;
24 try {
251 data = new GameDataReader().read(getParameters().getRaw());
262 } catch (IOException | IllegalArgumentException e) {
27 /* Stop before opening the game if the data cannot be loaded,
28 * for example "Error loading mokepon.game".
29 */
303 System.out.println("Error loading " + e.getMessage());
314 Platform.exit();
325 return;
33 }
34
35 List<Mokepon> mokepons = new MokeponParser().parse(data);
36 GameMap map = new MapParser().parse(data);
37 GameImages images = new ImageParser().parse(data);
38 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
39 gameWindow.show();
40 }
  1. 第 25 行(标注 1):调用 read。正常就把结果存进局部变量 data,跳过 catch,接着执行第 35 行。
  2. 第 26 行(标注 2):catch (IOException | IllegalArgumentException e),用竖线一次接住两种异常(multi-catch)。read 抛出的两种异常,都会跳到这里。
  3. 第 30 行(标注 3):打印 "Error loading " + e.getMessage()。getMessage() 是异常里带的那句话:缺参数时是 read 第 20 行写的 data file: missing argument …;文件找不到时,Java 给的话就是文件路径 mokepon.gam(CGameDataReader 第 15 行的注释写的就是这件事)。所以才会印出下面那两种样子。
  4. 第 31 行(标注 4):Platform.exit()(结束 JavaFX 程序)。
  5. 第 32 行(标注 5):return,start 到此为止。后面造窗口的第 35–39 行一行都不执行,所以窗口不出现。第 27–29 行的注释说的就是这个目的:数据读不进来,就在开窗口之前停下。

画成两条路,就是这样(方框里的标注号和上面代码里的红色数字一样):

第 25 行(标注 1)read(...)第 35–37 行三个解析器第 38–39 行new GameWindowshow()窗口出现第 26 行(标注 2)catch 接住第 30 行(标注 3)打印 Error loading …第 31 行(标注 4)Platform.exit()第 32 行(标注 5)return:窗口不出现正常抛出异常
从第 25 行分成两条路。左边一列是正常的路:读成功,跳过 catch,从第 35 行接着做。右边一列是出错的路:第 25 行抛出异常,直接跳进 catch,打印、退出、return,第 35–39 行一行都不执行

GameData:所有解析器都通过它读值

read 交回的不是光秃秃的 Properties,而是包了一层的 CGameData。它只有一个字段,外加 5 个小方法:

GameData:一个字段 + 五个小工具data/GameData.java第 6–44 行
6/* The values loaded from the data file, with helpers that turn them into
7 * numbers and positions. All parsers read the file through this class.
8 */
9public class GameData {
10 private final Properties properties;
11
12 public GameData(Properties properties) {
13 this.properties = properties;
14 }
15
16 public boolean has(String key) {
171 return properties.containsKey(key);
18 }
19
20 public String getText(String key) {
212 return properties.getProperty(key);
22 }
23
24 public int getNumber(String key) {
253 return Integer.parseInt(getText(key));
26 }
27
28 /* A position is written as x,y, for example 10,18. */
29 public Position getPosition(String key) {
304 String[] parts = getText(key).split(",");
31 return new Position(Integer.parseInt(parts[0].trim()), Integer.parseInt(parts[1].trim()));
32 }
33
34 /* Count numbered entries such as mokepon.0.name, mokepon.1.name and so on.
35 * Numbers start at 0 and have no gaps, so stop at the first missing one.
36 */
37 public int countEntries(String prefix, String suffix) {
38 int count = 0;
395 while (has(prefix + count + suffix)) {
40 count++;
41 }
42 return count;
43 }
44}

用你数据文件里真实的键试一试:

  1. has("mokepon.35.name") → true;has("mokepon.36.name") → false(文件里没有第 36 只)。
  2. getText("mokepon.14.name") → 文字 "Impike"。键不存在时得到 null。
  3. getNumber("mokepon.14.hp") → 先拿到文字 "150",Integer.parseInt 把它变成整数 150。
  4. getPosition("map.player.startingPos") → 文字 "10,18",split(",") 按逗号切成 "10" 和 "18",再变成整数,new Position(10, 18):玩家的起点。
  5. countEntries("mokepon.", ".name") → 36。它用一个 while 循环从 0 开始数:mokepon.0.name 有吗?有,count 变 1;mokepon.1.name 有吗?……一直到 mokepon.36.name 没有,停下,返回 36。同样,countEntries("map.tiles.", "") → 20,countEntries("map.mokeponRandom.", "") → 4。
mokepon.game(文字)mokepon.14.name=Impikemokepon.14.hp=150…startingPos=10,18properties : Properties“mokepon.14.hp” → “150”426 对,值都是 String数据文件里的全部键值对,外加转成数字和坐标的小工具。Cdata : GameDatadata“Impike”(String)150(int 整数)Position(10,18)(一个对象)loadpropertiesgetTextgetNumbergetPosition
从文字到值:load 把文件读成一张 Properties 表,表里的值还全是文字;GameData 用字段 properties 存着这张表(空心菱形 = 字段);解析器调用 getText / getNumber / getPosition,才拿到真正的 String、int 和 Position
GameData 的 UML 类图
打印稿里的 GameData:一个 private 字段 properties(红色方块),一个构造器和五个 public 方法(绿色圆点)

为什么多包这一层?三个解析器都要“拿文字、变整数、变坐标、数有几个”。这些小动作写在 CGameData 里一次,三个解析器都用它,Integer.parseInt 和 split(",") 就不用在三个地方各写一遍。类注释原话是 “All parsers read the file through this class.” 另外,字段 properties 是 private 的,解析器只能读,碰不到 Properties 本身。

4. MokeponParser:36 只 Mokepon 是这样造出来的

现在 CMain 手里有了 data,执行到第 35–37 行:三个解析器各读文件的一部分,各造出一样东西:

mokepon.game472 行文字(426 个 键=值)数据文件里的全部键值对,外加转成数字和坐标的小工具。Cdata : GameDatadata把数据文件里的 Mokepon 条目变成 Mokepon 对象列表。CMokeponParserdata把数据文件的地图部分变成 GameMap:地块、起点和地图上的物体。CMapParserdata从数据文件读出所有图片路径,组装成一个 GameImages。CImageParserdatamokepons : List<Mokepon>36 个 Mokepon 对象map : GameMaptiles 20×20 + 14 个 MapObjectimages : GameImages19 个图片路径GameDataReader.read读读读parse() 返回parse() 返回parse() 返回
从文字到内存里的对象:GameDataReader 把文件读成 data;三个解析器都从 data 读(虚线箭头 ..> = 用到,data 是参数传进来的),各自交回一样东西(最下面的虚线箭头 = parse() 的返回值)

先看第一个。你在选择界面点 Impike 时,卷轴上的每一个数、名字、技能和类型,都来自数据文件第 155–164 行。代码里的红色数字和截图上的红色数字一一对应:

数据文件里的 10 行 = 屏幕上的 10 处

mokepon.game 第 155–164 行:mokepon.14 就是 Impikemokepon.game第 155–164 行
1551mokepon.14.name=Impike
1562mokepon.14.image=assets/Impike.png
1573mokepon.14.hp=150
1584mokepon.14.energy=22
1595mokepon.14.strength=76
1606mokepon.14.accuracy=74
1617mokepon.14.defence=45
1628mokepon.14.dodge=70
1639mokepon.14.skills=Lance|Stun|Wait
16410mokepon.14.type=FIRE
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
2
  1. 2
    第 156 行 image=assets/Impike.png → 头像。只存了路径,等你点名字的时候,才按这个路径去读图片
头像(灰色圆里)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
134567891010
  1. 1
    第 155 行 → 名字 Impike
  2. 3
    第 157 行 hp → 150
  3. 4
    第 158 行 energy → 22
  4. 5
    第 159 行 strength → 76
  5. 6
    第 160 行 accuracy → 74
  6. 7
    第 161 行 defence → 45
  7. 8
    第 162 行 dodge → 70
  8. 9
    第 163 行 Lance|Stun|Wait → 三个技能(竖线变成了逗号)
  9. 10
    第 164 行 type=FIRE → 火焰图标,和最下面一行 Type: FIRE(图标的事在第 6 节)
名字一行和卷轴上的数值表真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

数字一个不差。卷轴上的字是 CSelectScreen 画的(第 7 章),但它画的内容来自一个 CMokepon 对象,而这个对象是 CMokeponParser 在启动时,用这 10 行造出来的。左边的行名 HP:、Energy: 不是数据文件里的字,是 EStat 自己带的 label。

代码一步一步:parseMokepon 造出 Impike

CMokeponParser 有三个方法:parse 先用 countEntries 数出 36,然后 for 循环 36 次,每次调用一次 parseMokepon 造一只;parseMokepon 用 parseSkills 读技能。下面跟着循环走到第 14 次(Impike)。点“下一步”:最上面的代码框里亮的是正在执行的代码,数据框里亮的是它读的那一行数据,对象图是内存里慢慢成形的那个 CMokepon 对象(亮框 = 这一步刚放进去的东西)。

data/MokeponParser.java第 18–49 行
18 public List<Mokepon> parse(GameData data) {
19 List<Mokepon> mokepons = new ArrayList<>();
20 int count = data.countEntries(PREFIX, ".name");
21 for (int index = 0; index < count; index++) {
22 mokepons.add(parseMokepon(data, PREFIX + index + "."));
23 }
24 return mokepons;
25 }
26
27 private Mokepon parseMokepon(GameData data, String prefix) {
28 String name = data.getText(prefix + "name");
29 String imagePath = data.getText(prefix + "image");
30 MokeponType type = MokeponType.valueOf(data.getText(prefix + "type"));
31
32 /* Each stat key is the stat name in lower case, for example mokepon.0.hp. */
33 Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
34 for (Stat stat : Stat.values()) {
35 stats.put(stat, data.getNumber(prefix + stat.name().toLowerCase()));
36 }
37
38 List<Skill> skills = parseSkills(data.getText(prefix + "skills"));
39 return new Mokepon(name, imagePath, type, stats, skills);
40 }
41
42 /* Skill names are separated by "|", for example Bite|Pocket Sand|Nimble|Wait. */
43 private List<Skill> parseSkills(String skillNames) {
44 List<Skill> skills = new ArrayList<>();
45 for (String skillName : skillNames.split("\\|")) {
46 skills.add(SkillCatalogue.find(skillName.trim()));
47 }
48 return skills;
49 }
mokepon.game第 155–164 行
155mokepon.14.name=Impike
156mokepon.14.image=assets/Impike.png
157mokepon.14.hp=150
158mokepon.14.energy=22
159mokepon.14.strength=76
160mokepon.14.accuracy=74
161mokepon.14.defence=45
162mokepon.14.dodge=70
163mokepon.14.skills=Lance|Stun|Wait
164mokepon.14.type=FIRE
Impike(String)assets/Impike.png(String)FIRE(MokeponType)数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。Cimpike : MokeponmodelHP 150 · ENERGY 22 …(EnumMap,6 个)mokepon.game第 155–164 行Lance · Stun · Wait(List,3 个 Skill)nameimagePathtypestatsskillsparseMokepon
内存里的 Impike 对象(空心菱形 = 字段,线上写字段名,方框里是存进去的值)
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1234
  1. 1
    字段 name → 名字一行 Impike
  2. 2
    字段 stats → 6 个数:150、22、76、74、45、70
  3. 3
    字段 skills → 3 个技能对象的名字,用逗号连起来
  4. 4
    字段 type → FIRE(还有名字后面的火焰图标)
真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
  1. 第 20 行先数出 count = 36。第 21 行的 for 循环让 index 从 0 走到 35。现在 index = 14:第 22 行把 PREFIX + index + "." 拼成 "mokepon.14.",交给 parseMokepon。数据框里亮的 10 行,键都以这个前缀开头。以后每个键都是“前缀 + 一个词”。
  2. 第 28 行:data.getText("mokepon.14." + "name"),也就是键 mokepon.14.name,得到 "Impike",存进局部变量 name。
  3. 第 29 行:键 mokepon.14.image,得到路径 "assets/Impike.png"。注意这里只记路径,不读图片。
  4. 第 30 行:键 mokepon.14.type 得到文字 "FIRE"。MokeponType.valueOf("FIRE") 按名字找到枚举 EMokeponType 里的常量 FIRE。文字变成了一个真正的类型,以后可以用它算地形克制(第 9 章)。写错成 FIRo 的话,valueOf 会抛异常。
  5. 第 33 行造一个空的 EnumMap:Stat.class(类字面量)告诉它键是哪一种枚举,所以键只能是 EStat。第 34 行的循环把 EStat 的 6 个常量按顺序走一遍:HP 的 name() 是 "HP",toLowerCase() 变成 "hp",拼成键 mokepon.14.hp,getNumber 得到 150,放进表里。接着 energy 22、strength 76、accuracy 74、defence 45、dodge 70。一个循环读完 6 行数据。
  6. 第 38 行:拿到文字 "Lance|Stun|Wait",交给第 43 行的 parseSkills。第 44 行先造一个空列表。第 45 行 split("\\|") 按竖线切成 3 段文字:"Lance"、"Stun"、"Wait",for-each 循环一段一段处理。
  7. 第 46 行:每段先 trim(),再交给 SkillCatalogue.find,换回一个技能对象,放进列表。3 段文字变成 3 个 ASkill 对象。第 48 行交回这个列表,存进局部变量 skills。
  8. 第 39 行:new Mokepon(name, imagePath, type, stats, skills)。五样东西交给 CMokepon 的构造器,存进它的 5 个字段,这个对象就造好了。构造器还把 stats 和 skills 各复制一份(new EnumMap<>(stats)、List.copyOf(skills)),别的类以后改不了它们(防御性复制,第 16 章)。
  9. 回到第 22 行:mokepons.add(...) 把 Impike 放进列表,排第 15 个(下标 14)。循环继续造第 15 到 35 只,最后第 24 行交回 36 个 CMokepon。等你在选择界面点 Impike,CSelectScreen 就从这个对象里取出这些值,写在卷轴上:就是这张换上来的真实截图,4 个框对应对象的 4 个字段。

SkillCatalogue:技能名换成技能对象

SkillCatalogue:10 个技能对象,按名字查skill/SkillCatalogue.java第 5–24 行
5/* Every skill in the game. The data file only gives skill names, and this class
6 * finds the skill object with that name. Skills never change after they are made,
7 * so all Mokepon can share the same skill objects.
8 * To add a new skill, write a new subclass and add one object of it to this list.
9 */
10public class SkillCatalogue {
111 private static final List<Skill> SKILLS = List.of(
12 new Bite(), new Tackle(), new Lance(), new Stun(), new PocketSand(),
13 new Bunker(), new Aim(), new Swole(), new Nimble(), new Wait());
14
15 /* Find a skill by its exact, case-sensitive name. */
162 public static Skill find(String name) {
17 for (Skill skill : SKILLS) {
18 if (skill.getName().equals(name)) {
19 return skill;
20 }
21 }
223 throw new IllegalArgumentException("Unknown skill: " + name);
23 }
24}
  1. 第 11–13 行:一个 static final 列表,装着 10 个技能对象,一个类一个。List.of(...) 用这 10 个对象造一个不能再改的列表(List.of)。它是 static 的:整个程序只有这一份,在第一次用到 CSkillCatalogue 时造好(也就是解析第 0 只 Sealance 的 Bite 的时候)。
  2. 第 16 行:find 也是 static 的,所以写 SkillCatalogue.find("Lance") 就能用,不用先 new。它挨个比名字,equals 相同就把那个对象交回去。
  3. 第 22 行:找不到就抛 IllegalArgumentException("Unknown skill: ...")。比较时大小写都要一样:数据里写成 lance 就找不到。

36 只 Mokepon 一共写了 128 个技能名(2 只各 2 个、12 只各 3 个、22 只各 4 个),find 被调用 128 次。但技能对象始终只有 10 个:每只 Mokepon 都有 Wait,36 只拿到的是同一个 CWait 对象。可以共用,是因为技能对象造好以后不会再变(类注释:“Skills never change after they are made, so all Mokepon can share the same skill objects.”)。这种“静态列表 + 按名字查”的类叫静态目录类,下一节的 CItemCatalogue 也是这样。

5. MapParser:地图、起点和 14 个地图物体

CMain 第 36 行:GameMap map = new MapParser().parse(data);。parse 按顺序做四件事:第 26 行读地形,第 27 行读起点,第 30–37 行造 14 个地图物体,放进同一个列表 objects,第 39 行把三样东西包进一个 CGameMap。代码里的红色数字和下面地图截图上的红色数字一一对应:

MapParser.parse:每一行造出地图上的哪些东西data/MapParser.java第 25–40 行
25 public GameMap parse(GameData data) {
261 Terrain[][] tiles = parseTiles(data);
272 Position startPosition = data.getPosition("map.player.startingPos");
28
29 /* Each marker group has its own way of choosing the enemy. */
30 List<MapObject> objects = new ArrayList<>();
313 objects.addAll(parseMarkers(data, "map.mokeponRandom", new RandomEnemyPicker()));
324 objects.addAll(parseMarkers(data, "map.mokeponStrong",
33 new TerrainEnemyPicker(TerrainEffect.ADVANTAGE)));
345 objects.addAll(parseMarkers(data, "map.mokeponWeak",
35 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)));
366 objects.addAll(parseMysteryBoxes(data));
377 objects.add(parseExit(data));
38
39 return new GameMap(tiles, startPosition, objects);
40 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1233334445556667
  1. 1
    整张地图的 20×20 个格子:第 26 行 parseTiles(下一小节)
  2. 2
    起点 (10,18):第 27 行,getPosition("map.player.startingPos")
  3. 3
    白色 4 个:第 31 行,map.mokeponRandom 组,配一个 CRandomEnemyPicker
  4. 4
    红色 3 个:第 32–33 行,map.mokeponStrong 组,配 TerrainEnemyPicker(ADVANTAGE)
  5. 5
    绿色 3 个(数据里写的颜色是 lime):第 34–35 行,map.mokeponWeak 组,配 TerrainEnemyPicker(DISADVANTAGE)
  6. 6
    问号箱 3 个:第 36 行
  7. 7
    门:第 37 行
刚进地图时的样子:地形、起点和 14 个物体,都是启动时就造好、存在 GameMap 里的真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

每行 20 个字母 = 地图上的一行 20 格

先看第 26 行(标注 1)的地形。数据文件里地图是 20 行字母,每个字母是一格:

mokepon.game 第 402–421 行:map.tiles.0 是最上面一行mokepon.game第 402–421 行
4021map.tiles.0=RRRRRRPPRRRRRRRRRRRR
403map.tiles.1=RRRRRRPPRRRRRRRRRRRR
404map.tiles.2=RRRRGGPPRRGGRRPPPPPP
405map.tiles.3=RRRRGGPPRRGGRRPPPPPP
406map.tiles.4=GGGGGGPPPPGGGGPPRRGG
407map.tiles.5=GGGGGGPPPPGGGGPPRRGG
408map.tiles.6=GGWWWWWWPPWWWWPPWWGG
409map.tiles.7=GGWWWWWWPPWWWWPPWWGG
410map.tiles.8=GGWWGGGGPPPPPPPPWWWW
411map.tiles.9=GGWWGGGGPPPPPPPPWWWW
412map.tiles.10=GGWWGGGGPPGGGGBBBBBB
413map.tiles.11=GGWWGGGGPPGGGGBBBBBB
4142map.tiles.12=GGWWGGBBPPGGGGBBBBBB
415map.tiles.13=GGWWGGBBPPGGGGBBBBBB
416map.tiles.14=WWWWWWBBPPGGGGBBBBBB
417map.tiles.15=WWWWWWBBPPGGGGBBBBBB
4183map.tiles.16=OOOOWWGGPPPPGGGGFFFF
419map.tiles.17=OOOOWWGGPPPPGGGGFFFF
4204map.tiles.18=OOOOWWGGGGPPFFFFFFFF
421map.tiles.19=OOOOWWGGGGPPFFFFFFFF

7 个字母对应枚举 ETerrain 的 7 个常量。每个常量带两样东西:字母和能不能走:

字母Terrain截图里的样子能走吗
BBARRIER灰色石砖墙不能
FFOREST深绿色森林能
GGRASS绿色草地能
OOCEAN深蓝色的海不能
PPATH棕色小路能
RROCKS灰色岩石能
WWATER蓝色水面能(水能走,海不能)
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    第 0 行(第 402 行数据)RRRRRRPPRRRRRRRRRRRR:灰色的岩石 R,第 6、7 列是小路 P,门就在 (6,0) 的小路上
  2. 2
    第 12 行(第 414 行数据)最后 6 个 B:BARRIER,一块块石砖墙,不能走
  3. 3
    第 16 行(第 418 行数据)开头 4 个 O:OCEAN,深蓝色的海,不能走
  4. 4
    第 18 行(第 420 行数据)OOOOWWGGGGPPFFFFFFFF:海、水、草、小路、森林。玩家站在第 10 列的 P 上
数据里的字母,一个一个变成了地图上的格子真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

读这 20 行的是 parseTiles,每个字母交给 ETerrain.fromSymbol 换成地形:

MapParser.parseTiles:两层循环data/MapParser.java第 42–54 行
42 /* Each row is a string with one letter per tile, and map.tiles.0 is the top row. */
43 private Terrain[][] parseTiles(GameData data) {
441 int rowCount = data.countEntries(TILES_PREFIX, "");
452 Terrain[][] tiles = new Terrain[rowCount][];
46 for (int row = 0; row < rowCount; row++) {
473 String letters = data.getText(TILES_PREFIX + row);
484 tiles[row] = new Terrain[letters.length()];
49 for (int column = 0; column < letters.length(); column++) {
505 tiles[row][column] = Terrain.fromSymbol(letters.charAt(column));
51 }
52 }
53 return tiles;
54 }
Terrain:7 个常量、两个字段、构造器和 fromSymbolmodel/Terrain.java第 4–19、25–33 行
4public enum Terrain {
56 BARRIER('B', false),
6 FOREST('F', true),
7 GRASS('G', true),
8 OCEAN('O', false),
9 PATH('P', true),
10 ROCKS('R', true),
11 WATER('W', true);
12
137 private final char symbol;
14 private final boolean walkable;
15
168 Terrain(char symbol, boolean walkable) {
17 this.symbol = symbol;
18 this.walkable = walkable;
19 }
⋯ 省略第 20–24 行
25 /* Find the terrain for one letter of a map.tiles row. */
26 public static Terrain fromSymbol(char symbol) {
27 for (Terrain terrain : values()) {
289 if (terrain.symbol == symbol) {
29 return terrain;
30 }
31 }
3210 throw new IllegalArgumentException("Unknown tile: " + symbol);
33 }
  1. 第 44 行(MapParser):countEntries("map.tiles.", "") 数出 20 行。
  2. 第 45 行:new Terrain[20][]:一个能放 20 行的二维数组,每一行先空着。
  3. 第 47 行:取出第 row 行的 20 个字母,比如第 0 行的 "RRRRRRPPRRRRRRRRRRRR"。
  4. 第 48 行:这一行有几个字母,就造一个多长的数组(20)。
  5. 第 50 行:letters.charAt(column) 取第 column 个字母,交给 Terrain.fromSymbol 换成地形,放进 tiles[row][column]。注意第一个下标是行(y),第二个是列(x)。
  6. 第 5 行(Terrain):BARRIER('B', false):造常量 BARRIER,括号里的两个值交给第 16 行的构造器。另外 6 个常量同样写法。
  7. 第 13–14 行:每个常量都有的两个字段:symbol(它的字母)和 walkable(能不能走)。
  8. 第 16–18 行:枚举的构造器。BARRIER('B', false) 运行它时,'B' 存进 symbol,false 存进 walkable。
  9. 第 28 行:fromSymbol 把 7 个常量挨个看,常量的 symbol 和交进来的字母相同,就交回这个常量。
  10. 第 32 行:7 个都不是(比如数据里写了个 X),抛 IllegalArgumentException("Unknown tile: X")。

三组感叹号、问号箱和出口

三组感叹号用的是同一个方法 parseMarkers,只是交给它的组名和 picker 不一样。看它怎么用数据:

MapParser.parseMarkers:一组感叹号data/MapParser.java第 56–66 行
56 /* All markers in one group share the same image, colour and picker. */
57 private List<MapObject> parseMarkers(GameData data, String group, EnemyPicker picker) {
581 Sprite sprite = new Sprite(data.getText(group + ".image"), data.getText(group + ".colour"));
59 List<MapObject> markers = new ArrayList<>();
602 int count = data.countEntries(group + ".", "");
61 for (int index = 0; index < count; index++) {
623 Position position = data.getPosition(group + "." + index);
634 markers.add(new Marker(position, sprite, picker));
64 }
65 return markers;
66 }
  1. 第 58 行:读 map.mokeponRandom.image 和 .colour,造一个 CSprite(图片 sprite_exclamation.png,颜色 white)。
  2. 第 60 行:countEntries("map.mokeponRandom.", "") 数出 4 个(.0 到 .3;.image、.colour 不是数字,不算)。
  3. 第 62 行:读第 index 个的位置,比如 map.mokeponRandom.0=3,3 → (3,3)。
  4. 第 63 行:new Marker(position, sprite, picker)。4 个 CMarker 拿到的是同一个 sprite 和同一个 picker:这两个对象都是在循环外面造的。
地图物体的外观:图片路径和着色用的颜色字符串。C1 个 Spritewhite · sprite_exclamation.pngMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。C(3,3) : MarkerworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。C(14,6) : MarkerworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。C(12,12) : MarkerworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。C(4,10) : MarkerworldEnemyPicker 的实现:从全部 Mokepon 里随便挑一只,不管地形。C1 个 RandomEnemyPickerworldspritespritespritespritepickerpickerpickerpicker
白色这一组:4 个 Marker 对象共用 1 个 Sprite 对象和 1 个 RandomEnemyPicker 对象。空心菱形 = 用字段存着:sprite 是父类 MapObject 的 private 字段(每个 Marker 对象里都有,但 Marker 的代码只能用 getSprite() 读),picker 是 Marker 自己的字段

红色组和绿色组也一样,各有自己的 1 个 CSprite 和 1 个 picker。三组感叹号的样子只差颜色,挑敌人的方法只差 picker(第 9 章讲 picker 怎么挑)。问号箱和门也类似:

它们读的数据就是这两段。三组感叹号那一段里的标注 1、3,和上面 parseMarkers 代码里的 1、3 是同一件事:

mokepon.game:三组感叹号mokepon.game第 439–456 行
4393map.mokeponRandom.0=3,3
440map.mokeponRandom.1=14,6
441map.mokeponRandom.2=12,12
442map.mokeponRandom.3=4,10
4431map.mokeponRandom.image=assets/sprite_exclamation.png
4441map.mokeponRandom.colour=white
445
4463map.mokeponStrong.0=5,4
447map.mokeponStrong.1=16,4
448map.mokeponStrong.2=12,16
4491map.mokeponStrong.image=assets/sprite_exclamation.png
4501map.mokeponStrong.colour=red
451
4523map.mokeponWeak.0=2,12
453map.mokeponWeak.1=6,16
454map.mokeponWeak.2=17,17
4551map.mokeponWeak.image=assets/sprite_exclamation.png
4561map.mokeponWeak.colour=lime
mokepon.game 第 458–468 行:门和 3 个问号箱mokepon.game第 458–468 行
458map.exit.image=assets/sprite_door.png
459map.exit.colour=magenta
460map.exit.location=6,0
461
462map.items.image=assets/sprite_question.png
463map.items.0.type=HEALTH
464map.items.0.location=3,12
465map.items.1.type=POISON
466map.items.1.location=18,16
467map.items.2.type=COFFEE
468map.items.2.location=2,2

第 39 行把三样东西装进 CGameMap:

数据文件里的地图:每格的地形、玩家起点和开局时的物体。CGameMapworldtiles : Terrain[][]20 行 × 20 列startPosition : Position(10,18)objects : List<MapObject>14 个Mokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld神秘箱子(问号):里面有一个道具,踩上去就捡到。CMysteryBoxworld出口门:踩上去游戏就结束。CExitworldtilesstartPositionobjects10 个3 个1 个
GameMap 对象里存着什么(空心菱形 = 字段,线上写字段名)。14 个物体按加入的顺序排:4 白、3 红、3 绿、3 个问号箱、1 个门

CGameMap 的构造器也做了复制:每一行 tiles[row].clone(),物体列表 List.copyOf(objects)。类注释说这张地图 “never changes while a game is played”。整个程序只有这一个 CGameMap,启动时造好,存在 CGameWindow 的字段 map 里。每开一局,GameWindow.startGame 都 new 一个新的 COverworld,它把这份 CGameMap 的 14 个物体复制进自己的列表(COverworld.java 第 40–41 行:“Copy the objects, so collecting a box or beating a marker never changes the map.”)。玩游戏时捡走箱子、打败感叹号,只从 COverworld 自己的列表里删(第 8 章)。地图对象始终是启动时那一个,没动过,所以新一局里 14 个物体又全在。

MapParser 的 UML 类图
打印稿里的 MapParser:两个下划线的 static 常量(前缀),一个 public parse,四个 private 小方法(红色方块)

解析出错会怎样:一段真实的异常堆栈

第 3 节说过:try 只包住读文件的第 25 行,解析出的错没人接。现在你已经见过 CMapParser.parse 和 CGameData.getPosition,可以看一个真实例子了:把 build.gradle.kts 当成数据文件交给程序(参数写成 data=build.gradle.kts)。

JavaFXCMainCMapParserCGameDatastart(stage)1第 25 行读文件 OK;第 35 行得到空列表parse(data)2getPosition(…)3第 30 行:getText 返回 null对 null 调用 split → 出错NullPointerException 没人接住,传回 JavaFX:打印 Exception in Application start method
data=build.gradle.kts 时的调用链:异常从 GameData 第 30 行一路往外传,经过的方法里都没有 catch

堆栈要从下往上读:最下面是最早的调用,最上面是真正出错的地方。每一行都能在上面的顺序图里找到:

  1. WinApplication._runLoop:JavaFX 的事件循环(第 8 节),图里的 JavaFX 生命线。是它调用了 Main.start(第 1 个箭头):又一次证明 start 是 JavaFX 调用的。
  2. Main.start(Main.java:36):CMain 第 36 行调用 parse(第 2 个箭头)。
  3. MapParser.parse(MapParser.java:27):parse 第 27 行调用 getPosition("map.player.startingPos")(第 3 个箭头)。
  4. GameData.getPosition(GameData.java:30):第 30 行 getText(key).split(",")。getText 返回 null,对 null 调用方法,就是 NullPointerException(图里的说明框)。

这些行号和这一页代码框里的行号完全对得上。异常一路往外传,getPosition、parse、start 里都没有 catch,最后传回 JavaFX。JavaFX 打印 Exception in Application start method,程序结束,窗口也没出现。

6. ImageParser:只记图片的路径

CMain 第 37 行:GameImages images = new ImageParser().parse(data);。前两个解析器造的是游戏里的“东西”。这一个只收集图片文件的路径:Mokepon 和地图物体的图片路径已经存在它们自己身上,剩下的类型图标、地形格子、方向按钮、玩家、道具的图片,由它收集起来。

ImageParser:同一个方法 readImages 用了三次data/ImageParser.java第 14–47 行
14 public GameImages parse(GameData data) {
15 Map<MokeponType, String> typeImages =
161 readImages(data, "mokepon.type.", MokeponType.values());
172 Map<Terrain, String> tileImages = readImages(data, "map.tiles.images.", Terrain.values());
18 Map<Direction, String> buttonImages =
193 readImages(data, "map.buttons.", Direction.values());
204 return new GameImages(typeImages, tileImages, buttonImages, readItemImages(data),
215 data.getText("map.player.image"));
22 }
23
24 /* Generic method: read one image for each value of an enum. The key ends with the
25 * value's name in lower case, for example map.tiles.images.grass for GRASS.
26 * A value without a key is left out, which is how the normal type has no image.
27 */
28 private <T extends Enum<T>> Map<T, String> readImages(GameData data, String prefix,
29 T[] values) {
30 Map<T, String> images = new HashMap<>();
31 for (T value : values) {
326 String key = prefix + value.name().toLowerCase();
337 if (data.has(key)) {
34 images.put(value, data.getText(key));
35 }
36 }
37 return images;
38 }
39
40 /* Each item knows the end of its image key, for example heal for items.heal. */
41 private Map<Item, String> readItemImages(GameData data) {
42 Map<Item, String> images = new HashMap<>();
43 for (Item item : ItemCatalogue.getAll()) {
44 images.put(item, data.getText("items." + item.getImageName()));
45 }
46 return images;
47 }
  1. 第 15–16 行:类型图标。前缀 "mokepon.type.",交给它 EMokeponType 的 5 个常量。
  2. 第 17 行:地形格子。前缀 "map.tiles.images.",ETerrain 的 7 个常量。
  3. 第 18–19 行:方向按钮。前缀 "map.buttons.",EDirection 的 4 个常量。
  4. 第 20 行:道具图片,交给下面的 readItemImages。
  5. 第 21 行:玩家图片,键 map.player.image。五样一起交给 CGameImages 的构造器。
  6. 第 32 行:键 = 前缀 + 常量名的小写。FIRE → mokepon.type.fire,GRASS → map.tiles.images.grass,UP → map.buttons.up。
  7. 第 33 行:只有文件里有这个键,才放进 Map。

NORMAL 为什么没有图标

mokepon.game:只有 4 种类型有图片mokepon.game第 397–400 行
3971mokepon.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
readImages 的循环data/ImageParser.java第 31–36 行
31 for (T value : values) {
32 String key = prefix + value.name().toLowerCase();
332 if (data.has(key)) {
341 images.put(value, data.getText(key));
35 }
36 }
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
1
  1. 1
    FIRE:键 mokepon.type.fire 在第 397 行,路径放进了 Map,所以 Impike 名字后面有火焰图标
Impike(FIRE)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
2
  1. 2
    NORMAL:文件里没有 mokepon.type.normal 这一行,第 33 行的 if 不成立,NORMAL 没放进 Map,所以 Nightwing 后面什么都没有
Nightwing(NORMAL)真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

第 26 行的注释原话是:“A value without a key is left out, which is how the normal type has no image.” 以后 CSelectScreen 问 images.hasTypeImage(NORMAL),得到 false,就把图标藏起来(第 7 章)。数据文件少一行,屏幕上就少一个图标,代码里没有任何“如果是 NORMAL”的判断。

玩家、方向按钮和道具的图片

mokepon.game:玩家和 4 个方向按钮mokepon.game第 431–431、434–437 行
4311map.player.image=assets/sprite_light.png
⋯ 省略第 432–433 行
4342map.buttons.up=assets/sprite_up.png
4353map.buttons.down=assets/sprite_down.png
4364map.buttons.left=assets/sprite_left.png
4375map.buttons.right=assets/sprite_right.png
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
123456
  1. 1
    玩家:sprite_light.png,一颗白色的星
  2. 2
    ↑:map.buttons.up
  3. 3
    ↓:map.buttons.down
  4. 4
    ←:map.buttons.left
  5. 5
    →:map.buttons.right
  6. 6
    (不是图片)白色长条:地图下面的提示栏 OverworldScreen.statusBar 的右半段,现在还是空的(第 8 章)
地图画面右下角的 4 个按钮和地图上的玩家真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
mokepon.game:3 种道具的图片mokepon.game第 470–472 行
4703items.coffee=assets/sprite_coffee.png
4711items.heal=assets/sprite_heal.png
4722items.poison=assets/sprite_poison.png
点 Fight! 进入战斗界面,战斗刚开始
123
  1. 1
    Health Tonic:items.heal,绿色十字
  2. 2
    Poison:items.poison,绿色泡泡
  3. 3
    Coffee:items.coffee,杯子
战斗画面左上角的 3 个道具按钮真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
readItemImages:键 = items. + 道具自己记着的名字data/ImageParser.java第 40–47 行
40 /* Each item knows the end of its image key, for example heal for items.heal. */
41 private Map<Item, String> readItemImages(GameData data) {
42 Map<Item, String> images = new HashMap<>();
43 for (Item item : ItemCatalogue.getAll()) {
44 images.put(item, data.getText("items." + item.getImageName()));
45 }
46 return images;
47 }

道具不是枚举,所以没用 readImages,而是单独写了 readItemImages:从 ItemCatalogue.getAll() 取出 3 个道具对象,每个道具自己记着键的结尾(CHealthTonic 记着 "heal"),拼成 items.heal。

五类加起来:类型 4 + 地形 7 + 按钮 4 + 道具 3 + 玩家 1 = 19 个路径。CGameImages 的构造器用 Map.copyOf 把四个 Map 各复制一份存起来。它只存文字路径,不读图片:真正把图片读进来的是界面类,它们用 UiStyle.loadImage(path)。CGameImages 的类注释原话:“Only the file paths are stored; the screens load the images.”

三个解析器放在一起看:为什么要拆开

你的 Project 1 里,读文件和解析都写在 CMain 自己身上。两段 start 上下对比,红色数字相同的是同一件事:1 读文件,2 把文字变成对象,3 造窗口:

Project 1 的 Main.start:读文件、解析 Mokepon 都是 Main 自己的方法Project 1 · Main.java第 15–32 行
15 @Override
16 public void start(Stage stage) throws IOException {
17
18 Properties gameData;
19 try {
201 gameData = loadPropertiesFromArgument();
21 } catch (IOException | IllegalArgumentException e) {
22 // Stop before opening the game if the data cannot be loaded.
23 System.out.println("Error loading: " + e.getMessage());
24 javafx.application.Platform.exit();
25 return;
26 }
27
282 List<Mokepon> mokepons = generateMokepon(gameData);
29
303 GameWindow gameWindow = new GameWindow(mokepons);
31 gameWindow.show();
32 }
Project 2 的 Main.start:每件事交给一个专门的类Main.java第 22–40 行
22 public void start(Stage stage) {
23 GameData data;
24 try {
251 data = new GameDataReader().read(getParameters().getRaw());
26 } catch (IOException | IllegalArgumentException e) {
27 /* Stop before opening the game if the data cannot be loaded,
28 * for example "Error loading mokepon.game".
29 */
30 System.out.println("Error loading " + e.getMessage());
31 Platform.exit();
32 return;
33 }
34
352 List<Mokepon> mokepons = new MokeponParser().parse(data);
362 GameMap map = new MapParser().parse(data);
372 GameImages images = new ImageParser().parse(data);
383 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
39 gameWindow.show();
40 }
  1. 读文件:Project 1 第 20 行调用 CMain 自己的 private 方法 loadPropertiesFromArgument(同一个文件第 35–56 行),检查参数、打开文件、去空格全写在 CMain 里。Project 2 第 25 行交给 CGameDataReader,得到一个 CGameData。
  2. 解析:Project 1 第 28 行调用 CMain 自己的 static 方法 generateMokepon(第 64 行起),只造 Mokepon。Project 2 第 35–37 行交给三个解析器,各管一块。
  3. 造窗口:两边都是 new GameWindow(...),Project 2 多交了窗口、地图和图片路径。

Project 2 拆成了 CGameDataReader、CGameData 和三个解析器,CMain 只剩下按顺序调用它们。好处有三条:

7. GameWindow:造窗口,放上第一个画面

三样东西都有了。CMain 第 38 行:new GameWindow(stage, mokepons, map, images),把 JavaFX 给的窗口和三样数据一起交过去。构造器做的事,就是把窗口一层一层搭起来。动画最上面的图是搭好以后窗口里一层套一层的样子;下面是 CGameWindow 的代码框(字段、构造器、show、updateMenu、showScreen),旁边是每一步的说明卡片或真实截图。点“下一步”,亮的框和线就是这一步刚放好的东西:

stage : Stage窗口scene : Scene窗口里的全部内容root : BorderPane分上、中两块menuBar : MenuBar上:GameMenu 造的view : Pane中:选择界面setScene根节点setTopsetCenter
窗口里装着什么(细线 = 放进去;冒号后面是类型)。stage 是 JavaFX 给的,其余都是 GameWindow 构造器里放进去的
ui/GameWindow.java第 18–54、96–105 行
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;
29
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);
40 root.setTop(menu.getMenuBar());
41 selectScreen = new SelectScreen(mokepons, images, this);
42 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 }
51
52 public void show() {
53 stage.show();
54 }
⋯ 省略第 55–95 行
96 @Override
97 public void updateMenu() {
98 menu.update(currentScreen);
99 }
100
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }

第 23 行:root 最先造好

private final BorderPane root = new BorderPane(); 是写在字段声明里的。new GameWindow(...) 一开始,它就先执行,比构造器里第 32 行还早。现在 root 是一个空的 BorderPane。

这时窗口还没显示,屏幕上什么都没有。

第 32–35 行:先把参数存起来

this.stage = stage; 这四行把交进来的东西存进同名字段(this.stage 是字段,右边的 stage 是参数):

  • stage:JavaFX 给的窗口
  • mokepons:36 只
  • map:那张 CGameMap
  • images:19 个路径

以后点 Choose Mokepon 开新游戏时,startGame 还要用 map 和 mokepons。

第 36–37 行:设置窗口

setTitle("Mokepon"):标题栏上的字(标题栏不在截图里)。

setResizable(false):不能拖动边框改大小,所以游戏区永远是 1280×768。

第 39 行:new GameMenu(this)

CGameMenu 的构造器造出 Game 菜单:三个菜单项 Run away、Main menu、Exit,各带一个快捷键,各登记一个 setOnAction 的 lambda(点了以后才运行,现在不运行)。

交进去的 this 就是这个 CGameWindow。CGameMenu 只把它当成 IGameNavigator 接口来用(动画后面的代码)。

游戏刚打开:选择界面,还没选 Mokepon
1
  1. 1
    菜单栏:root.setTop(menu.getMenuBar()) 把它放进 root 的上面一块
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon

第 41 行:new SelectScreen(mokepons, images, this)

选择界面的构造器把画面上的东西全造好:灰色圆、头像框、标题、36 个名字按钮、名字一行、卷轴、Choose 按钮、数值表(第 7 章)。每个名字按钮登记一个 lambda event -> select(mokepon, button)。

这些东西都放在它的 view 里。view 是父类 AGameScreen 的 private 字段,用 getView() 拿到。字段 selectScreen 记住这个画面。

第 101–103 行:showScreen 的前两句

currentScreen = screen;:字段 currentScreen(类型是父类 AGameScreen)记住“现在显示的是哪个画面”。以后按键过滤器也靠它找到当前画面。

root.setCenter(screen.getView());:把选择界面的 view 放进 root 的中间。

GameMenu.update(GameMenu.java 第 46–49 行)

runAway.setDisable(!screen.canRunAway());
mainMenu.setDisable(!screen.canReturnToMainMenu());

! = 取反(true 变 false)。setDisable(true) = 变灰,不能点,快捷键也不管用。

选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
12
  1. 1
    Run away:灰
  2. 2
    Main menu:灰
真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的

第 44–47 行:Scene 和按键过滤器

new Scene(root):造一个 Scene,root 是它的根节点。scene 只是构造器里的局部变量,不是字段。

addEventFilter(KeyEvent.KEY_PRESSED, event -> currentScreen.handleKeyPress(event)):登记一个按键过滤器。现在只是登记,不运行。以后每按一次键运行一次。它每次都去读字段 currentScreen,所以以后换了画面,按键自动交给新画面(第 6 章第 4 节)。

第 48–49 行:放进窗口,定大小

stage.setScene(scene):窗口里装上这个 Scene。

stage.sizeToScene():按 Scene 想要的大小定窗口大小。窗口里面的内容区是 1280×793(宽:菜单栏和画面都是 1280;高:菜单栏 25 + 画面 768),标题栏和边框另算。所以每张截图都是 1280×793。

游戏刚打开:选择界面,还没选 Mokepon1
  1. 1
    root 的中间:CSelectScreen 的 view,菜单栏下面整块(1280×768)。框上面那一条就是 root 上面的菜单栏
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
  1. 还没执行构造器的第一行,第 23 行的字段声明先执行:root 先有了一个空的 BorderPane(亮框)。
  2. 第 32–35 行把 4 个参数存进 4 个字段。stage 就是 JavaFX 造好的那个窗口(亮框),现在 CGameWindow 用字段记着它。这 4 个字段都是 final,存一次就不再换。
  3. 第 36–37 行设置窗口:标题 Mokepon,不能改大小。还是在给 stage(亮框)做设置,窗口仍然没显示。
  4. 第 39 行 new GameMenu(this):造好菜单栏 menuBar(亮框)和里面的三个菜单项,存进字段 menu。
  5. 第 40 行 root.setTop(menu.getMenuBar()):菜单栏放进 root 的上面(亮线 setTop)。以后不管换到哪个画面,它都在这里不动。
  6. 第 41 行 new SelectScreen(mokepons, images, this):选择界面造好了。它的 view(亮框)里已经有 36 个名字按钮,但还没放进 root。
  7. 第 42 行 showScreen(selectScreen),跳到第 101 行。第 102 行记住当前画面;第 103 行 root.setCenter(screen.getView()) 把选择界面的 view 放进 root 的中间(亮线 setCenter)。
  8. 第 104 行 updateMenu(),跳到第 96–99 行:menu.update(currentScreen)。CGameMenu.update 问画面两个问题:能逃跑吗?能回主菜单吗?选择界面两个都答 false,!false 是 true,于是 setDisable(true):Run away 和 Main menu 变灰(截图是以后打开菜单时看到的样子)。
  9. 第 44 行造 Scene,root 是它的根节点(亮线)。第 46–47 行登记按键过滤器:只登记,不运行。
  10. 第 48 行 stage.setScene(scene):Scene 装进窗口(亮线 setScene)。第 49 行按内容定大小:内容区 1280×793。到这里一层一层全搭好了,可是窗口还没出现。
  11. 构造器结束,回到 CMain 第 39 行 gameWindow.show(),跳到第 52 行:stage.show()。窗口这才出现在屏幕上,就是这张真实截图:框里是选择界面的 view,框上面是菜单栏。

第 4 步里的 CGameMenu 构造器,和第 8 步里的 update 方法,是这样写的。三个菜单项和截图里的三行一一对应:

GameMenu:构造器(第 19–37 行)和 update(第 46–49 行)ui/GameMenu.java第 19–37、43–49 行
19 public GameMenu(GameNavigator navigator) {
201 runAway = new MenuItem("Run away");
21 runAway.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
22 runAway.setOnAction(event -> navigator.runAway());
23
242 mainMenu = new MenuItem("Main menu");
25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));
26 mainMenu.setOnAction(event -> navigator.returnToMainMenu());
27
283 MenuItem exit = new MenuItem("Exit");
29 exit.setAccelerator(new KeyCodeCombination(KeyCode.F4, KeyCombination.ALT_DOWN));
30 exit.setOnAction(event -> navigator.quit());
31
32 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 }
⋯ 省略第 38–42 行
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 }
选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
123
  1. 1
    Run away,快捷键 Ctrl+R。点了运行 navigator.runAway()。现在是灰的:第 47 行
  2. 2
    Main menu,Ctrl+B,运行 navigator.returnToMainMenu()。现在是灰的:第 48 行
  3. 3
    Exit,Alt+F4,运行 navigator.quit()。update 不管它,所以它永远能点
选择界面上打开 Game 菜单:前两项是灰的真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
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 }

第 33 行把三个菜单项放进 Game 菜单,中间的 new SeparatorMenuItem() 就是 Main menu 和 Exit 之间那条分隔线。第 35 行用这个菜单造出 menuBar,第 36 行把它的宽度设成 1280。

update 的参数类型是父类 AGameScreen。它调用 screen.canRunAway() 时,真正运行哪一个版本,要看 screen 实际是什么对象:CSelectScreen 没改写 canRunAway,用的是父类的 return false;它改写了 canReturnToMainMenu,返回 false。所以两项都变灰。这就是多态(polymorphism),第 6 章第 5 节细讲。

构造器的参数类型写的是 IGameNavigator(一个接口),不是 CGameWindow。CGameWindow implements 了这个接口,所以第 39 行能把 this 交进去。CGameMenu 和 CSelectScreen 都只知道“有一个能 runAway、能 startGame 的东西”,不知道它就是 CGameWindow。这叫依赖倒置,第 6 章第 2 节细讲。

小结:启动时谁 new 了谁

考试常问“谁创建了谁”(instantiation)。把这一章出现过的 new 画在一张图上:

程序入口:读数据文件、解析成对象,然后打开游戏窗口。CMainmokepon读取命令行参数 data=... 指定的数据文件,生成 GameData。CGameDataReaderdata数据文件里的全部键值对,外加转成数字和坐标的小工具。CGameDatadata把数据文件里的 Mokepon 条目变成 Mokepon 对象列表。CMokeponParserdata数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokepon ×36model把数据文件的地图部分变成 GameMap:地块、起点和地图上的物体。CMapParserdata数据文件里的地图:每格的地形、玩家起点和开局时的物体。CGameMapworldMarker ×10MysteryBox ×3、Exit ×1Sprite ×5、picker ×3从数据文件读出所有图片路径,组装成一个 GameImages。CImageParserdata界面要用的图片路径:类型图标、地块、方向按钮、道具、玩家。CGameImagesdata游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui«create»
启动时谁 new 了谁。虚线箭头 ..> 是依赖,这里每一条都是 «create»:箭头尾巴的类在代码里 new 了箭头指着的类。Main 对象自己是 JavaFX 造的

8. start() 结束以后:程序在等你

一行代码都不运行

CMain 第 39 行 show() 返回,第 40 行 start 结束,把控制交回给 JavaFX。从这一刻起,你的代码一行都不运行:

这种程序叫事件驱动的程序。你的代码只在一种时候运行:JavaFX 调用了一段事先登记好的处理代码(事件处理器,在你的项目里都是 lambda)。现在(选择界面)这 43 段都是启动时登记的;以后造地图画面、战斗画面时,它们的构造器还会再登记新的:地图画面的 4 个方向按钮(第 8 章),战斗画面的技能和道具按钮(第 14 章),对话框的按钮。

JavaFX 界面线程里的循环(WinApplication._runLoop)等一件事(点击、按键……)找到它的处理器(事先登记好的 lambda)运行这段 lambda(你的代码只在这里运行)重新画窗口start() 返回以后,程序就停在这个圈里GameMenu:3 段第 22、26、30 行名字按钮:36 段SelectScreen 第 218 行Choose 按钮:1 + 2 段SelectScreen 第 246 行GameButton 第 33、38 行按键过滤器:1 段GameWindow 第 46 行
事件循环:JavaFX 界面线程一直在左边这个圈里转。“找到处理器”时,它从右边这 43 段里挑一段运行(方框里写的是登记它的那一行代码)
在哪里登记几段什么时候运行
CGameMenu 构造器(第 22、26、30 行)3(Run away、Main menu、Exit)点菜单项,或按 Ctrl+R、Ctrl+B、Alt+F4
CSelectScreen.createMokeponList(第 218 行,循环 36 次)36(一个名字一段)点名字按钮
CSelectScreen.createChooseButton(第 246 行)和 CGameButton.addPressEffect(第 33、38 行)1 + 2(点击 + 按下 / 松开鼠标变色)点 Choose Mokepon
CGameWindow 构造器(第 46 行)1(按键过滤器)任何时候按一个键

比如你点 Nightwing:

SelectScreen.createMokeponList:启动时,每只 Mokepon 登记一段 lambdaui/SelectScreen.java第 211–220 行
211 /* One button for each Mokepon, in a list that scrolls when it is too long. */
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) {
2161 GameButton button = new GameButton(mokepon.getName(), font, BUTTON_RADIUS);
217 button.setPrefWidth(LIST_BUTTON_WIDTH);
2182 button.setOnAction(event -> select(mokepon, button));
219 buttonColumn.getChildren().add(button);
220 }

第 216 行给每只 Mokepon 造一个按钮(标注 1)。第 218 行(标注 2)的 event -> select(mokepon, button) 在启动时只是交给了按钮(setOnAction),没有运行。等你真的点了 Nightwing,JavaFX 才运行它。

你JavaFXCSelectScreenC按钮 : GameButton鼠标点一下1点到了这个按钮2select(mokepon, button)3Nightwing 按钮上,第 218 行登记的 lambda换头像、名字、数值4处理完5重画窗口,接着等
点 Nightwing:JavaFX 调用启动时登记好的 lambda

下面是真实截图连播。每一帧之间,都是你做了一个动作,JavaFX 运行了一段登记好的代码:

游戏刚打开:选择界面,还没选 Mokepon选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的选中 NORMAL 类型的 Nightwing:名字后面没有类型图标选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
五帧真实截图。没有一帧是“自己变的”,每一次变化前面都有一次点击

最重要的一次点击,前后放在一起比一比。每一组里,第一张是点之前,第二张是点之后,裁的是同一块地方,同一个数字指同一样东西:

游戏刚打开:选择界面,还没选 Mokepon
123
  1. 1
    Nightwing 按钮:普通的灰色底
  2. 2
    头像:空的
  3. 3
    Select a Mokepon
点之前(select-initial):名单、头像、名字真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
123
  1. 1
    Nightwing 按钮:灰色底更浓了(不透明度 0.5 → 0.75),在棕色背景上看起来更亮。是 GameButton.setHighlighted(true) 做的,代码注释管这叫 Darken
  2. 2
    头像:Nightwing
  3. 3
    名字 Nightwing(没有类型图标,第 6 节)
点之后(select-normal):名单、头像、名字真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
游戏刚打开:选择界面,还没选 Mokepon
45
  1. 4
    数值:全空(6 个数值 Label 都是空字符串)
  2. 5
    Choose:灰
点之前:卷轴和 Choose 按钮真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
45
  1. 4
    数值:155、27、68、83、42、86,技能 Lance, Aim, Pocket Sand, Wait,类型 NORMAL
  2. 5
    Choose:亮了,能点
点之后:卷轴和 Choose 按钮真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

中间只运行了一次 select(...)。这 5 处变化,全是这一次调用做的。select 一行一行在 第 7 章第 5 节。

全程回放:从 gradlew run 到选择界面

最后把整个启动连起来走一遍。上面是顺序图(分四段,会自己换),下面是 CMain 的代码框(亮的是正在执行的那一行),旁边是这一步之后内存里多了什么,最后两步换成真实截图。和前面一样,亮着的箭头是红色流动的虚线,那只是“正在发生”。

你:gradlew runCMainJavaFXCGameDataReaderCGameDatamain(args)1launch(args)2start(stage)3read(getRaw())4检查 data=,Properties.load 读 426 个键值new GameData(…)5data6
第一段:启动和读文件
CMainCMokeponParserCSkillCatalogueCMapParserCImageParserparse(data)1find(技能名) ×128236 个 Mokepon3parse(data)4map : GameMap5parse(data)6images : GameImages7
第二段:三个解析器把文字变成对象(都从 data 读值)
CMainCGameWindowCGameMenuCSelectScreennew GameWindow(…)1new GameMenu(this)2new SelectScreen(…)3showScreen(selectScreen)4update(…)5new Scene(root)、登记按键过滤器、setScene、sizeToScene
第三段:GameWindow 的构造器搭窗口
JavaFXCMainCGameWindowstage : Stageshow()1show()2start() 返回3等事件(第 8 节的那个圈)
第四段:显示窗口,start 返回,然后等
Main.java第 22–44 行
22 public void start(Stage stage) {
23 GameData data;
24 try {
25 data = new GameDataReader().read(getParameters().getRaw());
26 } catch (IOException | IllegalArgumentException e) {
27 /* Stop before opening the game if the data cannot be loaded,
28 * for example "Error loading mokepon.game".
29 */
30 System.out.println("Error loading " + e.getMessage());
31 Platform.exit();
32 return;
33 }
34
35 List<Mokepon> mokepons = new MokeponParser().parse(data);
36 GameMap map = new MapParser().parse(data);
37 GameImages images = new ImageParser().parse(data);
38 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
39 gameWindow.show();
40 }
41
42 public static void main(String[] args) {
43 launch(args);
44 }

现在有什么

args:一个字符串数组,只有一个元素 "data=mokepon.game"(来自 build.gradle.kts 第 35 行)。

还没有任何对象。

现在有什么

JavaFX 启动了界面线程,造了一个 CMain 对象,还造了一个窗口 stage(没显示)。

main 停在第 43 行,一直等到程序结束。

现在有什么

JavaFX 在界面线程上调用 start(stage)。从这里开始,执行的是你写的代码。

现在有什么

参数检查通过,"mokepon.game" 打开了。一个 Properties 表,装着 426 对“键 = 值”,值全是文字。

如果这一步出错:打印 Error loading ...,Platform.exit(),窗口不出现。

现在有什么

data : GameData,里面包着那张表。局部变量 data 记着它。

现在有什么

mokepons:36 个 CMokepon,第 0 个 Sealance …… 第 14 个 Impike …… 第 35 个 Stormtalon。

另外:CSkillCatalogue 里的 10 个技能对象,被 36 只共用。

现在有什么

map : GameMap:20×20 个地形,起点 (10,18),14 个地图物体(10 个 CMarker、3 个 CMysteryBox、1 个 CExit)。

现在有什么

images : GameImages:19 个图片路径(类型 4、地形 7、按钮 4、道具 3、玩家 1)。只是文字,图片一张都还没读。

现在有什么

CGameWindow 存好 4 个参数,造了 CGameMenu(3 个菜单项,3 段 lambda)和 CSelectScreen(36 个名字按钮),菜单栏放在 root 上面。

选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
12
  1. 1
    Run away:灰
  2. 2
    Main menu:灰
真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的

现在有什么

一个 Scene,root 是它的根节点;按键过滤器(一段 lambda)登记在 Scene 上,现在不运行。窗口里装上了这个 Scene,内容区 1280×793。

构造器结束。窗口还没出现。

游戏刚打开:选择界面,还没选 Mokepon1
  1. 1
    CSelectScreen 的 view:菜单栏下面整块(1280×768)。框上面那一条是 CGameMenu 的菜单栏
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
  1. 你运行 gradlew run。Gradle 启动 Java,从 CMain 的 main 开始,带上参数 data=mokepon.game。
  2. main 唯一的一行:launch(args)。JavaFX 接手:启动界面线程,new 一个 CMain 对象,造出窗口 stage。
  3. JavaFX 调用 start(stage)。注意箭头的方向:是 JavaFX 调用你的代码。
  4. 第 25 行:new GameDataReader().read(getParameters().getRaw())。检查参数,去掉 data=,打开文件,Properties.load 读进 426 对键值,再去掉值末尾的空格。
  5. read 把表包进 new GameData(properties),交回给 CMain,存进局部变量 data。读数据文件这一步就完了,后面再也不碰 mokepon.game(图片和字体以后由界面类自己读)。
  6. 第 35 行:CMokeponParser 数出 36,循环 36 次 parseMokepon。每个技能名都交给 SkillCatalogue.find 换成对象,一共 128 次。交回一个装着 36 个 CMokepon 的列表。
  7. 第 36 行:CMapParser 读 20 行字母变成地形,读起点,再造 4 白 3 红 3 绿 10 个感叹号、3 个问号箱、1 个门,装进 CGameMap。
  8. 第 37 行:CImageParser 用同一个泛型方法 readImages 读三种枚举的图片路径,再读道具和玩家的,装进 CGameImages。没有 mokepon.type.normal,所以 NORMAL 没有图标。
  9. 第 38 行:new GameWindow(stage, mokepons, map, images)。构造器里 new GameMenu(this)、new SelectScreen(..., this),两个都拿到 this,当成 IGameNavigator 用。
  10. 还在第 38 行(构造器里面):showScreen(selectScreen)(绕回自己的箭头)用 root.setCenter 把选择界面放进 root 中间,再让菜单调用 update(selectScreen):Run away 和 Main menu 变灰(截图是以后打开菜单时的样子)。
  11. 构造器最后几行(CGameWindow 第 44–49 行):造 Scene、登记按键过滤器(只登记,不运行)、装进窗口、按内容定大小。构造器结束,回到 CMain,局部变量 gameWindow 记住这个对象。
  12. 第 39 行:gameWindow.show() → stage.show()。窗口出现了,就是这张真实截图,也就是这一章第 1 节的那张图。
  13. 第 40 行:start 结束,返回给 JavaFX(虚线箭头)。程序没有结束:main 还停在 launch 里,JavaFX 在等事件。你点一下、按一下,它才运行一段事先登记好的 lambda(现在这 43 段都是启动时登记的,以后新画面还会登记新的)。这就是事件驱动。

现在你可以对着第 1 节那张图,把每一块的来历从头说一遍了。下一章 第 6 章 接着讲 CGameWindow:点了按钮以后,它怎么换画面、菜单为什么时灰时亮。

自测

在 IntelliJ 里直接运行 CMain,没有填任何参数,会打印什么?参数写成 data=mokepon.gam(少一个字母)呢?

没有参数:getRaw() 是空列表,CGameDataReader.read 第 18 行的检查不通过,抛出 IllegalArgumentException。CMain.start 第 26 行的 catch 接住它,打印 Error loading data file: missing argument in the form data=relativeFilePath,调用 Platform.exit(),然后 return。窗口不出现。(如果这个运行配置里也没有 --enable-native-access,前面还会先有 4 行 WARNING。)

data=mokepon.gam:打印 Error loading mokepon.gam。第 26 行 Files.newInputStream 找不到文件,抛出一种 IOException,它的 getMessage() 就是文件路径本身,所以后面只有文件名。

English: The argument check in GameDataReader.read throws an IllegalArgumentException (or opening a missing file throws an IOException); Main.start catches both, prints "Error loading" plus the message, and exits before the window is created.

程序怎么知道有 36 只 Mokepon?如果把 mokepon.20 那 10 行删掉,会怎样?

countEntries("mokepon.", ".name") 从 0 开始一个一个问:mokepon.0.name 有吗、mokepon.1.name 有吗……问到 mokepon.36.name 没有,停下,返回 36。删掉 mokepon.20 以后,它问到 mokepon.20.name 就停了,只读进第 0–19 只,一共 20 只,而且不报错:第 21–35 只就这样悄悄没了。

English: GameData.countEntries counts mokepon.0.name, mokepon.1.name, … until the first missing number, so it finds 36; a gap stops the count without any error.

有人把 Impike 那一行改成 mokepon.14.skills=lance|Stun|Wait(l 小写)。会打印 Error loading ... 吗?

不会。SkillCatalogue.find 比名字时大小写都要一样,找不到 lance,抛出 IllegalArgumentException("Unknown skill: lance")。这是在 CMain 第 35 行(CMokeponParser.parse)里抛的,在 try 外面,没有 catch 接住。JavaFX 打印 Exception in Application start method 和一段异常堆栈,程序结束,窗口不出现。

English: SkillCatalogue.find throws an IllegalArgumentException while Main.start is parsing, outside the try block, so it is not caught and JavaFX prints a stack trace.

地图上 4 个白色感叹号,在内存里是几个 CMarker 对象、几个 CSprite 对象、几个 picker 对象?

4 个 CMarker,但只有 1 个 CSprite、1 个 CRandomEnemyPicker:parseMarkers 在循环外面造 sprite,picker 在 parse 第 31 行造好一次交进来,循环里的 4 次 new Marker(...) 都用这同一个。整张地图 14 个物体,一共只有 5 个 CSprite(三组感叹号各 1、问号箱 1、门 1)和 3 个 picker。

English: The four white markers share one Sprite object and one RandomEnemyPicker object, both created once outside the loop, so parseMarkers makes four Marker objects but only one sprite and one picker.

选择界面上,为什么 Impike 名字后面有图标,Nightwing 没有?代码里有没有写“如果是 NORMAL 就不显示”?

没有这样的判断。CImageParser.readImages 对每个类型拼出键,比如 mokepon.type.fire、mokepon.type.normal,只有 data.has(key) 为真才放进 Map。数据文件只有 fire、ice、water、lightning 四行,所以 NORMAL 不在 Map 里。CSelectScreen 问 images.hasTypeImage(type),Nightwing(NORMAL)得到 false,图标就藏起来了。

English: ImageParser only stores a path when the key exists; the data file has no mokepon.type.normal, so hasTypeImage(NORMAL) is false and SelectScreen hides the icon.

要给 Mokepon 加第 7 种数值(比如 Speed),要改哪些地方?CMokeponParser 要改吗?

在枚举 EStat 里加一个常量,比如 SPEED("Speed", 1, 100)(label 和范围);数据文件里给 36 只各加一行 mokepon.N.speed=…。CMokeponParser 不用改:第 34–35 行 for (Stat stat : Stat.values()) 自动多走一次,拼出 mokepon.N.speed。选择界面的数值表也按 Stat.values() 画,自动多一行。(战斗规则要不要用它,另说。)

English: Add a SPEED constant to the Stat enum and a mokepon.N.speed line for every Mokepon; MokeponParser builds the keys by looping over Stat.values(), so it does not need to change.

start() 执行完以后,程序为什么没有结束?这时候谁在“运行”?

main 里的 launch(args) 还没返回,它要等程序结束才返回。JavaFX 的界面线程在自己的事件循环里等事件。你的代码这时一行都不运行,直到你点击或按键,JavaFX 才调用一段事先登记好的 lambda(现在是启动时登记的 43 段:36 个名字按钮、Choose 按钮、3 个菜单项、按键过滤器……),运行完又回去等。以后造地图画面、战斗画面时,还会登记新的处理器。

English: After start() returns, launch() does not return until the application exits; the JavaFX Application Thread waits for events, and our code only runs inside the event handlers we registered — the first ones during start-up, more when new screens are created.

new GameMenu(this) 写在哪个类里?this 是什么?为什么 CGameMenu 构造器的参数类型是 IGameNavigator,不是 CGameWindow?

写在 CGameWindow 的构造器里(第 39 行),所以是 CGameWindow 创建了 CGameMenu。this 是正在被造的那个 CGameWindow 对象。CGameWindow implements IGameNavigator,所以它可以当成 IGameNavigator 交进去。CGameMenu 只需要“能 runAway、能 returnToMainMenu、能 quit 的东西”,只依赖这个接口,不依赖具体的窗口类。

English: The GameWindow constructor instantiates GameMenu and passes itself as a GameNavigator, so GameMenu and the screens depend on the interface, not on GameWindow (Dependency Inversion).

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