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. 先看终点:启动完成时屏幕上的每一块从哪来
程序跑起来以后,你看到的第一个画面是这样的。这时你还什么都没点。图上每一块,都是启动那一瞬间由某个类造出来的:
12345678910- 1
- 2棕色背景:这个框里什么都没有,框住的就是背景图本身。图片
assets/bg.png铺满整个游戏区,是父类 AGameScreen 的构造器第一个放进画面的 - 3
- 4名字按钮:CMokeponParser 从数据文件读出 36 只 Mokepon,CSelectScreen 一只造一个按钮。最上面的 Sealance 是数据文件里的第 0 只(第 4 节)
- 5滚动条:36 个按钮放不下,装按钮的
ScrollPane自己加了滚动条。现在只看得到前 12 个左右 - 6灰色圆:
createPortraitCircle造的Circle。头像以后放在它上面,现在还是空的 - 7一句提示 Select a Mokepon:CSelectScreen 里的常量
PROMPT - 8卷轴:图片
assets/scroll.png(常量UiStyle.SCROLL_FILE),数值表就画在它上面 - 9HP: …… Dodge::每个 EStat 常量自己带的 label(
"HP"、"Energy"……,用getLabel()取),后面加一个冒号,顺序就是 EStat 里写的顺序。下面的 Skills: 和 Type: 两行是 CSelectScreen 自己写的字 - 10Choose Mokepon:灰的。CSelectScreen 一造好这个按钮就调用
setUsable(false)
截图宽 1280、高 793:上面 25 像素是菜单栏,下面 1280×768 是游戏区。窗口标题栏上写着 Mokepon,但标题栏不在截图里。
从你按下运行,到这个画面出现,按顺序发生了 6 件事:
总指挥是 CMain(项目根包里唯一的类)。它一共只有 20 多行。下面代码里的红色数字就是上图的圆圈编号:第 ② 步是 main 和 start 的开头,第 ③ 步是第 25 行,第 ④ 步是第 35–37 行,第 ⑤ ⑥ 步是第 38、39 行。第 ① 步不在 Java 代码里,在 build.gradle.kts 里。
18/* Starts the game: load the data, then open the window. */19public class Main extends Application {20 21 @Override222 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,照着里面写的编译、启动。跟启动有关的是这几行:
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}- 第 22 行
modules = listOf("javafx.controls"):要用 JavaFX 的 controls 模块。按钮、文字、菜单这些窗口部件都在里面。 - 第 26 行
mainModule:程序所在的模块叫au.edu.unimelb.swen.oop.mokepon,它在module-info.java里声明(下面马上看)。 - 第 27 行
mainClass:从哪个类开始。写的是 CMain,Java 就去找 CMain 的main方法。 - 第 30 行
--enable-native-access=javafx.graphics:交给 Java 虚拟机的一个开关,允许javafx.graphics模块加载它自带的本地代码(.dll 文件,画窗口要用)。不加的话,开头会先打印 4 行 WARNING,程序照样运行。 - 第 35 行
args("data=mokepon.game"):运行时带上一个命令行参数,内容就是这串文字data=mokepon.game。它会一路传到 CGameDataReader(第 3 节有一张图画出它走过的路)。
mokepon.game 前面没有写在哪个文件夹,这叫相对路径:Java 从程序运行时所在的文件夹开始找。gradlew run 在项目文件夹里启动程序,所以能找到项目根目录下的 mokepon.game。数据文件里的图片路径 assets/Impike.png 也是这样找到的。
module-info.java:这个程序是一个模块
1module au.edu.unimelb.swen.oop.mokepon {21 requires javafx.controls;3 42 exports au.edu.unimelb.swen.oop.mokepon;5}- 第 2 行
requires javafx.controls:这个模块要用 JavaFX 的 controls 模块(它会顺带把画图用的javafx.graphics也带进来)。 - 第 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:

名字前面这些小图标怎么读,见 第 3 章第 5 节;斜体和下划线见 第 3 章第 6 节。
19public class Main extends Application {20 21 @Override223 public void start(Stage stage) {⋯ 省略第 23–41 行421 public static void main(String[] args) {432 launch(args);44 }- 第 42 行
public static void main(String[] args):Java 程序的入口(main 方法)。args是一个字符串数组,里面就是 Gradle 带上的那一个参数:"data=mokepon.game"。它是 static 的,因为刚开始时还没有任何对象,入口必须不用对象就能调用。 - 第 43 行
launch(args):launch是父类Application(JavaFX 的类,见 Application 类)的静态方法。CMain 继承了它,所以可以直接写launch(...)。launch按固定顺序做三件事:启动 JavaFX 的界面线程;造一个 CMain 对象(CMain 没写构造器,用的是默认构造器);造出第一个窗口,一个Stage(还没显示)。然后它在界面线程上调用这个对象的start(stage)。 - 第 22 行
public void start(Stage stage):真正干活的地方。stage就是 JavaFX 刚造的那个窗口,后面会交给 CGameWindow。
下面这张顺序图(sequence diagram)画的就是这几步。读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。这一章还多一种:虚线 + 实心箭头 = 造一个新对象(new,创建消息)。带底色的小框是说明。
第 4 个箭头最重要:start 不是你的代码调用的,是 JavaFX 调用的。第 5 节最后会看到一段真实的报错,里面就印着这一点:Main.start 是从 JavaFX 内部被叫来的。
3. 读文件:GameDataReader 和 GameData
先看 mokepon.game 长什么样
数据文件的开头,是第一只 Mokepon(Sealance):
1mokepon.0.name=Sealance2mokepon.0.image=assets/Sealance.png3mokepon.0.hp=1804mokepon.0.energy=105mokepon.0.strength=556mokepon.0.accuracy=707mokepon.0.defence=758mokepon.0.dodge=459mokepon.0.skills=Bite|Wait10mokepon.0.type=WATER11 写法只有一种:一行一个 键=值。这种文件叫属性文件。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 一路走到打开文件,经过了哪些地方:
后 4 步(从 read(arguments) 开始)都在 CGameDataReader.read 里面。read 的代码是这样的:
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}- 第 18 行:检查参数。列表是空的(没带参数),或者(
||)第一个参数不是以data=开头(!表示“不是”:startsWith得到 true,前面加!就变成 false),就执行第 19–20 行:throw 一个IllegalArgumentException(参数不合法的异常),read立刻停下。getFirst()就是取第 0 个元素。ARGUMENT_PREFIX是第 12 行的常量"data="。 - 第 24 行:
"data="有 5 个字符,substring(5)去掉前 5 个,剩下"mokepon.game"。Path.of(...)把这串文字变成一个文件路径对象。 - 第 25 行:造一个空的
Properties。它是 Java 自带的一张“键 → 值”表,专门读这种文件。 - 第 26–27 行:
Files.newInputStream打开文件,得到一个InputStream(输入流:可以从头到尾一点一点往外读的文件内容)。properties.load(input)一行一行读:空行跳过,每个键=值放进表里,一共 426 对。文件不存在时,第 26 行就抛出IOException。 - 第 31–32 行:
stringPropertyNames()拿到全部 426 个键,for-each 循环一个一个处理:取出值,trim()去掉前后的空格,再放回去。Properties会自己去掉等号后面紧跟的空格,但不管值末尾的空格。"150 "多一个空格,以后变整数时就会出错。你的mokepon.game里其实没有这样的空格,这两行是预防。 - 第 34 行:把这张表包进一个新的 CGameData 对象,交回给 CMain。
读不到文件会怎样:真实运行结果
回到 CMain。第 25 行外面包着一个 try:
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 }- 第 25 行(标注 1):调用
read。正常就把结果存进局部变量data,跳过 catch,接着执行第 35 行。 - 第 26 行(标注 2):
catch (IOException | IllegalArgumentException e),用竖线一次接住两种异常(multi-catch)。read抛出的两种异常,都会跳到这里。 - 第 30 行(标注 3):打印
"Error loading " + e.getMessage()。getMessage()是异常里带的那句话:缺参数时是read第 20 行写的data file: missing argument …;文件找不到时,Java 给的话就是文件路径mokepon.gam(CGameDataReader 第 15 行的注释写的就是这件事)。所以才会印出下面那两种样子。 - 第 31 行(标注 4):
Platform.exit()(结束 JavaFX 程序)。 - 第 32 行(标注 5):
return,start到此为止。后面造窗口的第 35–39 行一行都不执行,所以窗口不出现。第 27–29 行的注释说的就是这个目的:数据读不进来,就在开窗口之前停下。
画成两条路,就是这样(方框里的标注号和上面代码里的红色数字一样):
GameData:所有解析器都通过它读值
read 交回的不是光秃秃的 Properties,而是包了一层的 CGameData。它只有一个字段,外加 5 个小方法:
6/* The values loaded from the data file, with helpers that turn them into7 * 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}用你数据文件里真实的键试一试:
has("mokepon.35.name")→true;has("mokepon.36.name")→false(文件里没有第 36 只)。getText("mokepon.14.name")→ 文字"Impike"。键不存在时得到 null。getNumber("mokepon.14.hp")→ 先拿到文字"150",Integer.parseInt把它变成整数 150。getPosition("map.player.startingPos")→ 文字"10,18",split(",")按逗号切成"10"和"18",再变成整数,new Position(10, 18):玩家的起点。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。

为什么多包这一层?三个解析器都要“拿文字、变整数、变坐标、数有几个”。这些小动作写在 CGameData 里一次,三个解析器都用它,Integer.parseInt 和 split(",") 就不用在三个地方各写一遍。类注释原话是 “All parsers read the file through this class.” 另外,字段 properties 是 private 的,解析器只能读,碰不到 Properties 本身。
4. MokeponParser:36 只 Mokepon 是这样造出来的
现在 CMain 手里有了 data,执行到第 35–37 行:三个解析器各读文件的一部分,各造出一样东西:
先看第一个。你在选择界面点 Impike 时,卷轴上的每一个数、名字、技能和类型,都来自数据文件第 155–164 行。代码里的红色数字和截图上的红色数字一一对应:
数据文件里的 10 行 = 屏幕上的 10 处
1551mokepon.14.name=Impike1562mokepon.14.image=assets/Impike.png1573mokepon.14.hp=1501584mokepon.14.energy=221595mokepon.14.strength=761606mokepon.14.accuracy=741617mokepon.14.defence=451628mokepon.14.dodge=701639mokepon.14.skills=Lance|Stun|Wait16410mokepon.14.type=FIRE
- 2第 156 行
image=assets/Impike.png→ 头像。只存了路径,等你点名字的时候,才按这个路径去读图片

- 1第 155 行 → 名字 Impike
- 3第 157 行 hp → 150
- 4第 158 行 energy → 22
- 5第 159 行 strength → 76
- 6第 160 行 accuracy → 74
- 7第 161 行 defence → 45
- 8第 162 行 dodge → 70
- 9第 163 行
Lance|Stun|Wait→ 三个技能(竖线变成了逗号) - 10第 164 行
type=FIRE→ 火焰图标,和最下面一行 Type: FIRE(图标的事在第 6 节)
数字一个不差。卷轴上的字是 CSelectScreen 画的(第 7 章),但它画的内容来自一个 CMokepon 对象,而这个对象是 CMokeponParser 在启动时,用这 10 行造出来的。左边的行名 HP:、Energy: 不是数据文件里的字,是 EStat 自己带的 label。
代码一步一步:parseMokepon 造出 Impike
CMokeponParser 有三个方法:parse 先用 countEntries 数出 36,然后 for 循环 36 次,每次调用一次 parseMokepon 造一只;parseMokepon 用 parseSkills 读技能。下面跟着循环走到第 14 次(Impike)。点“下一步”:最上面的代码框里亮的是正在执行的代码,数据框里亮的是它读的那一行数据,对象图是内存里慢慢成形的那个 CMokepon 对象(亮框 = 这一步刚放进去的东西)。
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 }155mokepon.14.name=Impike156mokepon.14.image=assets/Impike.png157mokepon.14.hp=150158mokepon.14.energy=22159mokepon.14.strength=76160mokepon.14.accuracy=74161mokepon.14.defence=45162mokepon.14.dodge=70163mokepon.14.skills=Lance|Stun|Wait164mokepon.14.type=FIRE
- 1字段
name→ 名字一行 Impike - 2字段
stats→ 6 个数:150、22、76、74、45、70 - 3字段
skills→ 3 个技能对象的名字,用逗号连起来 - 4字段
type→ FIRE(还有名字后面的火焰图标)
- 第 20 行先数出
count = 36。第 21 行的for循环让index从 0 走到 35。现在index = 14:第 22 行把PREFIX + index + "."拼成"mokepon.14.",交给parseMokepon。数据框里亮的 10 行,键都以这个前缀开头。以后每个键都是“前缀 + 一个词”。 - 第 28 行:
data.getText("mokepon.14." + "name"),也就是键mokepon.14.name,得到"Impike",存进局部变量name。 - 第 29 行:键
mokepon.14.image,得到路径"assets/Impike.png"。注意这里只记路径,不读图片。 - 第 30 行:键
mokepon.14.type得到文字"FIRE"。MokeponType.valueOf("FIRE")按名字找到枚举 EMokeponType 里的常量FIRE。文字变成了一个真正的类型,以后可以用它算地形克制(第 9 章)。写错成FIRo的话,valueOf会抛异常。 - 第 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 行数据。 - 第 38 行:拿到文字
"Lance|Stun|Wait",交给第 43 行的parseSkills。第 44 行先造一个空列表。第 45 行split("\\|")按竖线切成 3 段文字:"Lance"、"Stun"、"Wait",for-each 循环一段一段处理。 - 第 46 行:每段先
trim(),再交给SkillCatalogue.find,换回一个技能对象,放进列表。3 段文字变成 3 个 ASkill 对象。第 48 行交回这个列表,存进局部变量skills。 - 第 39 行:
new Mokepon(name, imagePath, type, stats, skills)。五样东西交给 CMokepon 的构造器,存进它的 5 个字段,这个对象就造好了。构造器还把 stats 和 skills 各复制一份(new EnumMap<>(stats)、List.copyOf(skills)),别的类以后改不了它们(防御性复制,第 16 章)。 - 回到第 22 行:
mokepons.add(...)把 Impike 放进列表,排第 15 个(下标 14)。循环继续造第 15 到 35 只,最后第 24 行交回 36 个 CMokepon。等你在选择界面点 Impike,CSelectScreen 就从这个对象里取出这些值,写在卷轴上:就是这张换上来的真实截图,4 个框对应对象的 4 个字段。
SkillCatalogue:技能名换成技能对象
5/* Every skill in the game. The data file only gives skill names, and this class6 * 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}- 第 11–13 行:一个 static final 列表,装着 10 个技能对象,一个类一个。
List.of(...)用这 10 个对象造一个不能再改的列表(List.of)。它是 static 的:整个程序只有这一份,在第一次用到 CSkillCatalogue 时造好(也就是解析第 0 只 Sealance 的 Bite 的时候)。 - 第 16 行:
find也是 static 的,所以写SkillCatalogue.find("Lance")就能用,不用先 new。它挨个比名字,equals相同就把那个对象交回去。 - 第 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。代码里的红色数字和下面地图截图上的红色数字一一对应:
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 }
- 1整张地图的 20×20 个格子:第 26 行
parseTiles(下一小节) - 2起点 (10,18):第 27 行,
getPosition("map.player.startingPos") - 3白色 4 个:第 31 行,
map.mokeponRandom组,配一个 CRandomEnemyPicker - 4红色 3 个:第 32–33 行,
map.mokeponStrong组,配TerrainEnemyPicker(ADVANTAGE) - 5绿色 3 个(数据里写的颜色是 lime):第 34–35 行,
map.mokeponWeak组,配TerrainEnemyPicker(DISADVANTAGE) - 6问号箱 3 个:第 36 行
- 7门:第 37 行
每行 20 个字母 = 地图上的一行 20 格
先看第 26 行(标注 1)的地形。数据文件里地图是 20 行字母,每个字母是一格:
4021map.tiles.0=RRRRRRPPRRRRRRRRRRRR403map.tiles.1=RRRRRRPPRRRRRRRRRRRR404map.tiles.2=RRRRGGPPRRGGRRPPPPPP405map.tiles.3=RRRRGGPPRRGGRRPPPPPP406map.tiles.4=GGGGGGPPPPGGGGPPRRGG407map.tiles.5=GGGGGGPPPPGGGGPPRRGG408map.tiles.6=GGWWWWWWPPWWWWPPWWGG409map.tiles.7=GGWWWWWWPPWWWWPPWWGG410map.tiles.8=GGWWGGGGPPPPPPPPWWWW411map.tiles.9=GGWWGGGGPPPPPPPPWWWW412map.tiles.10=GGWWGGGGPPGGGGBBBBBB413map.tiles.11=GGWWGGGGPPGGGGBBBBBB4142map.tiles.12=GGWWGGBBPPGGGGBBBBBB415map.tiles.13=GGWWGGBBPPGGGGBBBBBB416map.tiles.14=WWWWWWBBPPGGGGBBBBBB417map.tiles.15=WWWWWWBBPPGGGGBBBBBB4183map.tiles.16=OOOOWWGGPPPPGGGGFFFF419map.tiles.17=OOOOWWGGPPPPGGGGFFFF4204map.tiles.18=OOOOWWGGGGPPFFFFFFFF421map.tiles.19=OOOOWWGGGGPPFFFFFFFF7 个字母对应枚举 ETerrain 的 7 个常量。每个常量带两样东西:字母和能不能走:
| 字母 | Terrain | 截图里的样子 | 能走吗 |
|---|---|---|---|
| B | BARRIER | 灰色石砖墙 | 不能 |
| F | FOREST | 深绿色森林 | 能 |
| G | GRASS | 绿色草地 | 能 |
| O | OCEAN | 深蓝色的海 | 不能 |
| P | PATH | 棕色小路 | 能 |
| R | ROCKS | 灰色岩石 | 能 |
| W | WATER | 蓝色水面 | 能(水能走,海不能) |

- 1第 0 行(第 402 行数据)
RRRRRRPPRRRRRRRRRRRR:灰色的岩石 R,第 6、7 列是小路 P,门就在 (6,0) 的小路上 - 2第 12 行(第 414 行数据)最后 6 个
B:BARRIER,一块块石砖墙,不能走 - 3第 16 行(第 418 行数据)开头 4 个
O:OCEAN,深蓝色的海,不能走 - 4第 18 行(第 420 行数据)
OOOOWWGGGGPPFFFFFFFF:海、水、草、小路、森林。玩家站在第 10 列的 P 上
读这 20 行的是 parseTiles,每个字母交给 ETerrain.fromSymbol 换成地形:
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 }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 }- 第 44 行(MapParser):
countEntries("map.tiles.", "")数出 20 行。 - 第 45 行:
new Terrain[20][]:一个能放 20 行的二维数组,每一行先空着。 - 第 47 行:取出第
row行的 20 个字母,比如第 0 行的"RRRRRRPPRRRRRRRRRRRR"。 - 第 48 行:这一行有几个字母,就造一个多长的数组(20)。
- 第 50 行:
letters.charAt(column)取第column个字母,交给Terrain.fromSymbol换成地形,放进tiles[row][column]。注意第一个下标是行(y),第二个是列(x)。 - 第 5 行(Terrain):
BARRIER('B', false):造常量 BARRIER,括号里的两个值交给第 16 行的构造器。另外 6 个常量同样写法。 - 第 13–14 行:每个常量都有的两个字段:
symbol(它的字母)和walkable(能不能走)。 - 第 16–18 行:枚举的构造器。
BARRIER('B', false)运行它时,'B'存进symbol,false存进walkable。 - 第 28 行:
fromSymbol把 7 个常量挨个看,常量的symbol和交进来的字母相同,就交回这个常量。 - 第 32 行:7 个都不是(比如数据里写了个
X),抛IllegalArgumentException("Unknown tile: X")。
三组感叹号、问号箱和出口
三组感叹号用的是同一个方法 parseMarkers,只是交给它的组名和 picker 不一样。看它怎么用数据:
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 }- 第 58 行:读
map.mokeponRandom.image和.colour,造一个 CSprite(图片sprite_exclamation.png,颜色white)。 - 第 60 行:
countEntries("map.mokeponRandom.", "")数出 4 个(.0 到 .3;.image、.colour不是数字,不算)。 - 第 62 行:读第
index个的位置,比如map.mokeponRandom.0=3,3→ (3,3)。 - 第 63 行:
new Marker(position, sprite, picker)。4 个 CMarker 拿到的是同一个sprite和同一个picker:这两个对象都是在循环外面造的。
红色组和绿色组也一样,各有自己的 1 个 CSprite 和 1 个 picker。三组感叹号的样子只差颜色,挑敌人的方法只差 picker(第 9 章讲 picker 怎么挑)。问号箱和门也类似:
- 问号箱(第 36 行 →
parseMysteryBoxes):3 个箱子共用 1 个不染色的 CSprite(sprite_question.png)。每个箱子用ItemCatalogue.find("HEALTH")这样按名字取一个道具对象:(3,12) HEALTH、(18,16) POISON、(2,2) COFFEE。和技能一样,道具对象全游戏只有一份。 - 门(第 37 行 →
parseExit):一个 CExit,在 (6,0),图片sprite_door.png染成magenta(洋红色)。
它们读的数据就是这两段。三组感叹号那一段里的标注 1、3,和上面 parseMarkers 代码里的 1、3 是同一件事:
4393map.mokeponRandom.0=3,3440map.mokeponRandom.1=14,6441map.mokeponRandom.2=12,12442map.mokeponRandom.3=4,104431map.mokeponRandom.image=assets/sprite_exclamation.png4441map.mokeponRandom.colour=white445 4463map.mokeponStrong.0=5,4447map.mokeponStrong.1=16,4448map.mokeponStrong.2=12,164491map.mokeponStrong.image=assets/sprite_exclamation.png4501map.mokeponStrong.colour=red451 4523map.mokeponWeak.0=2,12453map.mokeponWeak.1=6,16454map.mokeponWeak.2=17,174551map.mokeponWeak.image=assets/sprite_exclamation.png4561map.mokeponWeak.colour=lime458map.exit.image=assets/sprite_door.png459map.exit.colour=magenta460map.exit.location=6,0461 462map.items.image=assets/sprite_question.png463map.items.0.type=HEALTH464map.items.0.location=3,12465map.items.1.type=POISON466map.items.1.location=18,16467map.items.2.type=COFFEE468map.items.2.location=2,2第 39 行把三样东西装进 CGameMap:
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 个物体又全在。

解析出错会怎样:一段真实的异常堆栈
第 3 节说过:try 只包住读文件的第 25 行,解析出的错没人接。现在你已经见过 CMapParser.parse 和 CGameData.getPosition,可以看一个真实例子了:把 build.gradle.kts 当成数据文件交给程序(参数写成 data=build.gradle.kts)。
- 文件能打开,
read没问题,不会打印Error loading ...。 - 里面没有 Mokepon 的键,
countEntries数出 0,CMokeponParser 安静地返回一个空列表。 - 到了 CMapParser.parse:第 26 行也数出 0 行地形;第 27 行要
map.player.startingPos,文件里没有这个键。
堆栈要从下往上读:最下面是最早的调用,最上面是真正出错的地方。每一行都能在上面的顺序图里找到:
WinApplication._runLoop:JavaFX 的事件循环(第 8 节),图里的 JavaFX 生命线。是它调用了Main.start(第 1 个箭头):又一次证明 start 是 JavaFX 调用的。Main.start(Main.java:36):CMain 第 36 行调用parse(第 2 个箭头)。MapParser.parse(MapParser.java:27):parse第 27 行调用getPosition("map.player.startingPos")(第 3 个箭头)。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 和地图物体的图片路径已经存在它们自己身上,剩下的类型图标、地形格子、方向按钮、玩家、道具的图片,由它收集起来。
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 the25 * 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 }- 第 15–16 行:类型图标。前缀
"mokepon.type.",交给它 EMokeponType 的 5 个常量。 - 第 17 行:地形格子。前缀
"map.tiles.images.",ETerrain 的 7 个常量。 - 第 18–19 行:方向按钮。前缀
"map.buttons.",EDirection 的 4 个常量。 - 第 20 行:道具图片,交给下面的
readItemImages。 - 第 21 行:玩家图片,键
map.player.image。五样一起交给 CGameImages 的构造器。 - 第 32 行:键 = 前缀 + 常量名的小写。
FIRE→mokepon.type.fire,GRASS→map.tiles.images.grass,UP→map.buttons.up。 - 第 33 行:只有文件里有这个键,才放进 Map。
NORMAL 为什么没有图标
3971mokepon.type.fire=assets/sprite_fire.png398mokepon.type.ice=assets/sprite_ice.png399mokepon.type.water=assets/sprite_water.png400mokepon.type.lightning=assets/sprite_lightning.png31 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 }
- 1FIRE:键
mokepon.type.fire在第 397 行,路径放进了 Map,所以 Impike 名字后面有火焰图标

- 2NORMAL:文件里没有
mokepon.type.normal这一行,第 33 行的if不成立,NORMAL 没放进 Map,所以 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”的判断。
玩家、方向按钮和道具的图片
4311map.player.image=assets/sprite_light.png⋯ 省略第 432–433 行4342map.buttons.up=assets/sprite_up.png4353map.buttons.down=assets/sprite_down.png4364map.buttons.left=assets/sprite_left.png4375map.buttons.right=assets/sprite_right.png
- 1玩家:
sprite_light.png,一颗白色的星 - 2↑:
map.buttons.up - 3↓:
map.buttons.down - 4←:
map.buttons.left - 5→:
map.buttons.right - 6(不是图片)白色长条:地图下面的提示栏
OverworldScreen.statusBar的右半段,现在还是空的(第 8 章)
4703items.coffee=assets/sprite_coffee.png4711items.heal=assets/sprite_heal.png4722items.poison=assets/sprite_poison.png
- 1Health Tonic:
items.heal,绿色十字 - 2Poison:
items.poison,绿色泡泡 - 3Coffee:
items.coffee,杯子
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 造窗口:
15 @Override16 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 }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 }- 读文件:Project 1 第 20 行调用 CMain 自己的 private 方法
loadPropertiesFromArgument(同一个文件第 35–56 行),检查参数、打开文件、去空格全写在 CMain 里。Project 2 第 25 行交给 CGameDataReader,得到一个 CGameData。 - 解析:Project 1 第 28 行调用 CMain 自己的 static 方法
generateMokepon(第 64 行起),只造 Mokepon。Project 2 第 35–37 行交给三个解析器,各管一块。 - 造窗口:两边都是
new GameWindow(...),Project 2 多交了窗口、地图和图片路径。
Project 2 拆成了 CGameDataReader、CGameData 和三个解析器,CMain 只剩下按顺序调用它们。好处有三条:
- 一个类只管一件事(单一职责原则):数据文件里地图的写法变了,只改 CMapParser。Mokepon 多了一种数值:只要在 EStat 里加一个常量(带 label 和范围),再在数据文件里给 36 只各加一行
mokepon.N.xxx=…;CMokeponParser 第 34–35 行按Stat.values()循环拼键,一行都不用改。选择界面的数值表(CSelectScreen 第 261–265 行)也是按Stat.values()画的,自动多一行。(战斗规则要不要用这个新数值,是另一件事。) - 规则类不知道文件长什么样:CMokepon、CGameMap、CMarker 都只收现成的值,不认识
mokepon.14.hp这种键。 - data 包不用 JavaFX:六个类一个 JavaFX 的 import 都没有(图片也只存路径),没有窗口也能测试(第 4 章)。
7. GameWindow:造窗口,放上第一个画面
三样东西都有了。CMain 第 38 行:new GameWindow(stage, mokepons, map, images),把 JavaFX 给的窗口和三样数据一起交过去。构造器做的事,就是把窗口一层一层搭起来。动画最上面的图是搭好以后窗口里一层套一层的样子;下面是 CGameWindow 的代码框(字段、构造器、show、updateMenu、showScreen),旁边是每一步的说明卡片或真实截图。点“下一步”,亮的框和线就是这一步刚放好的东西:
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 @Override97 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:那张 CGameMapimages: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 接口来用(动画后面的代码)。

- 1菜单栏:
root.setTop(menu.getMenuBar())把它放进 root 的上面一块
第 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) = 变灰,不能点,快捷键也不管用。

- 1Run away:灰
- 2Main 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。
1- 1root 的中间:CSelectScreen 的
view,菜单栏下面整块(1280×768)。框上面那一条就是 root 上面的菜单栏
- 还没执行构造器的第一行,第 23 行的字段声明先执行:
root先有了一个空的BorderPane(亮框)。 - 第 32–35 行把 4 个参数存进 4 个字段。
stage就是 JavaFX 造好的那个窗口(亮框),现在 CGameWindow 用字段记着它。这 4 个字段都是 final,存一次就不再换。 - 第 36–37 行设置窗口:标题 Mokepon,不能改大小。还是在给
stage(亮框)做设置,窗口仍然没显示。 - 第 39 行
new GameMenu(this):造好菜单栏menuBar(亮框)和里面的三个菜单项,存进字段menu。 - 第 40 行
root.setTop(menu.getMenuBar()):菜单栏放进 root 的上面(亮线 setTop)。以后不管换到哪个画面,它都在这里不动。 - 第 41 行
new SelectScreen(mokepons, images, this):选择界面造好了。它的view(亮框)里已经有 36 个名字按钮,但还没放进 root。 - 第 42 行
showScreen(selectScreen),跳到第 101 行。第 102 行记住当前画面;第 103 行root.setCenter(screen.getView())把选择界面的view放进 root 的中间(亮线 setCenter)。 - 第 104 行
updateMenu(),跳到第 96–99 行:menu.update(currentScreen)。CGameMenu.update 问画面两个问题:能逃跑吗?能回主菜单吗?选择界面两个都答 false,!false是 true,于是setDisable(true):Run away 和 Main menu 变灰(截图是以后打开菜单时看到的样子)。 - 第 44 行造 Scene,root 是它的根节点(亮线)。第 46–47 行登记按键过滤器:只登记,不运行。
- 第 48 行
stage.setScene(scene):Scene 装进窗口(亮线 setScene)。第 49 行按内容定大小:内容区 1280×793。到这里一层一层全搭好了,可是窗口还没出现。 - 构造器结束,回到 CMain 第 39 行
gameWindow.show(),跳到第 52 行:stage.show()。窗口这才出现在屏幕上,就是这张真实截图:框里是选择界面的 view,框上面是菜单栏。
第 4 步里的 CGameMenu 构造器,和第 8 步里的 update 方法,是这样写的。三个菜单项和截图里的三行一一对应:
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 }
- 1Run away,快捷键 Ctrl+R。点了运行
navigator.runAway()。现在是灰的:第 47 行 - 2Main menu,Ctrl+B,运行
navigator.returnToMainMenu()。现在是灰的:第 48 行 - 3Exit,Alt+F4,运行
navigator.quit()。update不管它,所以它永远能点
42 public boolean canRunAway() {43 return false;44 }⋯ 省略第 45–46 行47 public boolean canReturnToMainMenu() {48 return true;49 }119 @Override120 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 画在一张图上:
- CMain.start 第 25、35–38 行:
new GameDataReader()、三个new ...Parser()、new GameWindow(...)。 - CGameDataReader.read 第 34 行:
new GameData(properties)。 - CMokeponParser.parseMokepon 第 39 行:
new Mokepon(...),36 次。 - CMapParser:
new GameMap(第 39 行)、3 个 picker(第 31–35 行)、new Sprite、new Marker、new MysteryBox、new Exit(第 58–83 行)。 - CImageParser.parse 第 20 行:
new GameImages(...)。 - CGameWindow 构造器第 39、41 行:
new GameMenu(this)、new SelectScreen(mokepons, images, this)(还有第 44 行的new Scene(root),那是 JavaFX 的类)。
8. start() 结束以后:程序在等你
一行代码都不运行
CMain 第 39 行 show() 返回,第 40 行 start 结束,把控制交回给 JavaFX。从这一刻起,你的代码一行都不运行:
main还停在第 43 行的launch(args)里,等程序结束。- JavaFX 的界面线程进入一个循环(事件循环):等一件事(鼠标点击、按键……)→ 找到这件事对应的处理代码 → 运行它 → 重新画窗口 → 接着等。第 5 节那段异常堆栈里的
WinApplication._runLoop,就是这个循环(Windows 上叫这个名字)。
这种程序叫事件驱动的程序。你的代码只在一种时候运行:JavaFX 调用了一段事先登记好的处理代码(事件处理器,在你的项目里都是 lambda)。现在(选择界面)这 43 段都是启动时登记的;以后造地图画面、战斗画面时,它们的构造器还会再登记新的:地图画面的 4 个方向按钮(第 8 章),战斗画面的技能和道具按钮(第 14 章),对话框的按钮。
| 在哪里登记 | 几段 | 什么时候运行 |
|---|---|---|
| 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:
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 才运行它。
下面是真实截图连播。每一帧之间,都是你做了一个动作,JavaFX 运行了一段登记好的代码:





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

- 1Nightwing 按钮:普通的灰色底
- 2头像:空的
- 3Select a Mokepon

- 1Nightwing 按钮:灰色底更浓了(不透明度 0.5 → 0.75),在棕色背景上看起来更亮。是
GameButton.setHighlighted(true)做的,代码注释管这叫 Darken - 2头像:Nightwing
- 3名字 Nightwing(没有类型图标,第 6 节)

- 4数值:全空(6 个数值 Label 都是空字符串)
- 5Choose:灰

- 4数值:155、27、68、83、42、86,技能 Lance, Aim, Pocket Sand, Wait,类型 NORMAL
- 5Choose:亮了,能点
中间只运行了一次 select(...)。这 5 处变化,全是这一次调用做的。select 一行一行在 第 7 章第 5 节。
全程回放:从 gradlew run 到选择界面
最后把整个启动连起来走一遍。上面是顺序图(分四段,会自己换),下面是 CMain 的代码框(亮的是正在执行的那一行),旁边是这一步之后内存里多了什么,最后两步换成真实截图。和前面一样,亮着的箭头是红色流动的虚线,那只是“正在发生”。
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 在界面线程上调用 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 上面。

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