Mokepon代码导读
第一站 · 起步L0约 25 分钟

03读懂老师打印的 UML

UMLDoclet 图里的 C/A/I/E、+ - # ~、斜体、下划线、箭头分别是什么意思

读完这一章你能
  • 拿到卷子上任何一张类图,马上说出它是普通类、抽象类、接口还是枚举(看圆圈里的 C / A / I / E 和名字是不是斜体)
  • 把图上的一行 calculateDamage(Fighter, double): int 翻回 Java 代码,也知道哪些东西图上根本没写(参数名、final、初始值、没有参数的构造器)
  • 认出名字前面的四种小图标,说出“谁能调用这个方法”
  • 看懂下划线(static)、斜体(abstract)、实线三角(extends)、虚线三角(implements)
  • 从字段的类型推出“谁存着谁”,并且知道图上没有的东西(谁创建谁、谁调用谁)只能靠记代码
  • 用一句英语说:Battle has two Fighter fields, player and enemy, so a Battle contains two Fighters.

考试时,卷子上会印你交上去的这份代码的 UML 类图。它是一个叫 UMLDoclet(2.3.2 版)的工具直接从代码画出来的,和你手里的 Mokepon-Project2-UML.pdf 一模一样:8 页横版 A4,按 9 个包分组,每组上面写着包名(比如 au.edu.unimelb.swen.oop.mokepon.battle)。一个类一张图,72 个类就是 72 张图。如果这个类继承了父类、或者实现了接口,图的上方还会多画一个父类 / 接口的方框,用箭头连起来,这个方框里只写名字和它的抽象方法。除此之外,类和类之间一条线都不画:谁存着谁、谁调用谁、谁创建谁,图上都没有,只能从字段的类型去推,或者靠你记得代码。

这一章把图上的每一种记号都拿出来讲:一边放卷子上的原图,一边放生成它的那几行代码。UML 图是一张图片,不能在上面画红框,所以红色数字画在代码这一边;需要的时候,原图旁边再放一张一样高的示意图,把“红 1、红 2……”写在原图对应的那几行旁边。先复习几个 第 1 章 讲过的词:类(class)、对象(object)、字段(field)(类里用来存东西的变量)、方法(method)、构造器(constructor)(new 一个对象时自动运行的那段代码)。Java 写法忘了就翻 第 2 章,下面用到具体写法时会直接链接到那一节。

1. 一张图分三格:先看 Battle

先拿 CBattle 来看,它管一场战斗的规则。先看代码:下面挑出了 Battle.java 里图上每一行的出处(方法里面的内容省略了)。图被横线分成三格(compartment)。

Battle.java:图上每一行的出处battle/Battle.java第 14–22、25–25、30–31、41–41、45–45、49–49、53–53、57–57、62–62、69–69、101–101、116–116、134–134 行
141public class Battle {
152 private final Fighter player;
16 private final Fighter enemy;
17 private final Inventory inventory;
18 private final EnemyStrategy enemyStrategy;
19 private final Random random;
20 private final List<String> log = new ArrayList<>();
21 private boolean finished = false;
22 private boolean playerWon = false;
⋯ 省略第 23–24 行
253 public Battle(Fighter player, Fighter enemy, Inventory inventory) {
⋯ 省略第 26–29 行
303 public Battle(Fighter player, Fighter enemy, Inventory inventory,
31 EnemyStrategy enemyStrategy, Random random) {
⋯ 省略第 32–40 行
41 public Fighter getPlayer() {
⋯ 省略第 42–44 行
45 public Fighter getEnemy() {
⋯ 省略第 46–48 行
49 public Inventory getInventory() {
⋯ 省略第 50–52 行
53 public boolean isFinished() {
⋯ 省略第 54–56 行
57 public boolean hasPlayerWon() {
⋯ 省略第 58–61 行
62 public List<String> getLog() {
⋯ 省略第 63–68 行
694 public void playTurn(Skill playerSkill) {
⋯ 省略第 70–100 行
101 public void useItem(Item item) {
⋯ 省略第 102–115 行
116 private void endTurn() {
⋯ 省略第 117–133 行
134 private void finish(boolean playerWon) {
Battle 的 UML 类图
卷子上印的 Battle(原图)。每行最前面的小图标:红色方块 = private,绿色圆 = public(第 5 节)
红 1:类头(C、Battle、包名)红 2:字段格,8 行player: Fighterenemy: Fighter……playerWon: boolean红 3:两个构造器 Battle(…)getPlayer() … getLog()6 个 public 方法红 4:playTurn(Skill): voiduseItem(Item)endTurn()、finish(boolean)
示意图:每一块和原图同样高度的那几行对应,写着代码里的红色数字
  1. 红 1 → 第一格,类头:第 14 行 public class Battle 变成图最上面的一格:浅绿色圆圈里一个 C,旁边写 Battle,下面一行小字是它所在的包 au.edu.unimelb.swen.oop.mokepon.battle。
  2. 红 2 → 第二格,字段:第 15–22 行的 8 个字段,变成第二格的 8 行。private final Fighter player; 写成 player: Fighter:名字在前,冒号,类型在后。
  3. 红 3 → 第三格开头,构造器:第 25 行和第 30–31 行是两个构造器,图上是 Battle(Fighter, Fighter, Inventory) 和 Battle(Fighter, Fighter, Inventory, EnemyStrategy, Random)。名字和类名一样,后面没有返回类型。
  4. 红 4 → 第三格,方法:第 69 行 public void playTurn(Skill playerSkill) 在图上是 playTurn(Skill): void。第三格一共 2 个构造器、10 个方法,顺序和代码里一样。

一张打印图里可能出现的东西就这么多。下面这张示意图把它们放在一起,并写上本章哪一节讲哪一样:

父类 / 接口的方框(有继承时才有)① 类头:圆圈·类名·包名② 字段:名字: 类型③ 方法:名字(类型): 返回类型第 7 节:箭头第 2 节:圆圈和斜体第 3–5 节:读法、枚举、图标第 3、5、6 节:图标、下划线、斜体实线或虚线 + 空心三角
一张 UMLDoclet 图的组成(示意图,不是原图)。空心三角箭头只连到父类或接口;除此之外图上没有别的线

图上的字段,屏幕上在哪

CBattle 在 battle 包里。它的类注释写着它“knows nothing about the screen”:它自己一个像素都不画。屏幕上的东西是 CBattleScreen 画的,而 CBattleScreen 是通过图上第三格的 4 个 public 方法,把 CBattle 字段里的东西读出来的。代码这边的红色数字和截图上的红框编号一样:

字段和读它们的 get 方法battle/Battle.java第 15–22、41–51、62–64 行
151 private final Fighter player;
162 private final Fighter enemy;
173 private final Inventory inventory;
18 private final EnemyStrategy enemyStrategy;
19 private final Random random;
204 private final List<String> log = new ArrayList<>();
21 private boolean finished = false;
22 private boolean playerWon = false;
⋯ 省略第 23–40 行
411 public Fighter getPlayer() {
42 return player;
43 }
44
452 public Fighter getEnemy() {
46 return enemy;
47 }
48
493 public Inventory getInventory() {
50 return inventory;
51 }
⋯ 省略第 52–61 行
624 public List<String> getLog() {
63 return Collections.unmodifiableList(log);
64 }
用 Stun 命中:敌人被眩晕,3 回合内伤害减半1234
  1. 1
    左下你的卷轴:画的是字段 player 这个 CFighter。CBattleScreen 用 battle.getPlayer() 拿到它
  2. 2
    右上敌人的卷轴:字段 enemy,用 battle.getEnemy() 拿到
  3. 3
    三个道具按钮上的 0、1、0:字段 inventory 里每样东西有几个,用 battle.getInventory() 拿到
  4. 4
    日志框:字段 log 里的每一行,用 battle.getLog() 拿到
Battle 的 4 个字段,经过 4 个 get 方法,变成了屏幕上的 4 块真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

另外 4 个字段:enemyStrategy、random 只在规则里用(挑敌人的技能、掷随机数),屏幕上看不到。finished、playerWon 屏幕上也没有直接画出来,但 CBattleScreen 会通过 isFinished()、hasPlayerWon() 读它们:打完了就把按钮变灰,再决定弹出 You Win 还是 Game Over(第 14 章)。记住这个读法:第二格告诉你这个类“存着什么”,第三格告诉你别的类“能让它做什么、能从它读什么”。

每张图最上面一格是类头。看三样东西:圆圈里的字母、名字是不是斜体、名字下面那行包名。下面是你代码里的四种类头,每张图下面是代码里的那一行声明。

C:普通类 CPosition

Position 的 UML 类图
浅绿色圆圈 C,名字是正体
world/Position.java第 8–8 行
8public class Position {

A:抽象类 ASkill

Skill 的 UML 类图
浅蓝色圆圈 A,名字和包名都是斜体
skill/Skill.java第 9–9 行
9public abstract class Skill {

I:接口 IEnemyStrategy

EnemyStrategy 的 UML 类图
浅紫色圆圈 I,名字和包名都是斜体,方法也是斜体
battle/EnemyStrategy.java第 10–10 行
10public interface EnemyStrategy {

E:枚举 EDirection

Direction 的 UML 类图
橙红色圆圈 E,名字是正体
world/Direction.java第 4–4 行
4public enum Direction {
圆圈颜色是什么代码里写名字能直接 new 吗你的项目里
C浅绿普通类class正体能55 个
A浅蓝抽象类abstract class斜体不能,只能 new 它的子类8 个
I浅紫接口interface斜体不能,只能 new 实现它的类4 个
E橙红枚举enum正体不能,常量就是现成的几个对象5 个

55 + 8 + 4 + 5 = 72。不是 C 的只有 17 个,最好全记住:

3. 成员行怎么读

类头下面的每一行是一个成员:字段、构造器或方法。UMLDoclet 把 Java 的写法换成 UML 的写法。拿 CFighter 来看:它是一只 Mokepon 在一场战斗里的样子(第 10 章细讲)。先看代码里挑出来的 6 行(红 1 到红 6),再看整张图和旁边的对照。

Fighter.java:挑出来的几行battle/Fighter.java第 18–26、31–31、52–52、82–82、95–95、118–118 行
18public class Fighter {
191 private static final int DAMAGE_SCALE = 50;
20
21 private final Mokepon mokepon;
22 /* The stats at the start of this battle, after the terrain effect. */
23 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
252 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
26 private final List<StatusEffect> effects = new ArrayList<>();
⋯ 省略第 27–30 行
313 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
⋯ 省略第 32–51 行
524 public int getStat(Stat stat) {
⋯ 省略第 53–81 行
825 public void takeDamage(int amount) {
⋯ 省略第 83–94 行
95 public int raiseStat(Stat stat, int amount) {
⋯ 省略第 96–117 行
1186 public int calculateDamage(Fighter target, double power) {
Fighter 的 UML 类图
卷子上印的 Fighter:5 个字段,1 个构造器,22 个方法
  1. 红 1,第 19 行 private static final int DAMAGE_SCALE = 50; → 图上字段格第一行 DAMAGE_SCALE: int(带下划线,第 6 节讲)。= 50 和 final 都没画。
  2. 红 2,第 25 行 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class); → stats: Map<Stat, Integer>。只写等号左边声明的类型 Map,右边真正 new 出来的 EnumMap 不画。尖括号是泛型(generics):这个 Map 用 EStat 当键,用整数当值(第 2 章第 5 节)。
  3. 红 3,第 31 行构造器 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) → Fighter(Mokepon, TerrainEffect)。参数名 mokepon、terrainEffect 不画,只画类型。构造器没有返回类型。
  4. 红 4,第 52 行 public int getStat(Stat stat) → getStat(Stat): int。返回类型从最前面挪到了最后,前面加一个冒号。
  5. 红 5,第 82 行 public void takeDamage(int amount) → takeDamage(int): void。不返回东西也要写 void。
  6. 红 6,第 118 行 public int calculateDamage(Fighter target, double power) → calculateDamage(Fighter, double): int。两个参数就是括号里两个类型,用逗号隔开。
Java 代码里UML 图上
字段类型 名字名字: 类型
方法返回类型 名字(类型 参数名)名字(类型): 返回类型
构造器类名(类型 参数名)类名(类型),没有返回类型

考试时反过来读:看到 raiseStat(Stat, int): int,就读成“raiseStat 方法,要两个参数,一个 EStat、一个 int,返回一个 int”。看到 getSkills(): List<Skill>,就读成“不要参数,返回一个装着 ASkill 的列表”。

图上没写出来的东西

图是“简写”。下面这些东西代码里有,图上没有:

图上没有的例子
参数名takeDamage(int) 里的 int 在代码里叫 amount
finalCFighter 的 5 个字段全是 final,图上看不出
初始值DAMAGE_SCALE 是 50
方法里面写了什么playTurn 先让谁出手(第 11 章)
唯一的、没有参数的 public 构造器public Bite()
equals 和 hashCodeCPosition 写了这两个方法

表里倒数第二行最容易让人误会。看 CBite:

Bite.java:整个文件skill/Bite.java第 1–11 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3/* A cheap attack with normal damage. */
4public class Bite extends AttackSkill {
5 private static final int ENERGY_COST = 1;
6 private static final double POWER = 1;
7
81 public Bite() {
9 super("Bite", ENERGY_COST, POWER);
10 }
11}
Bite 的 UML 类图
卷子上的 Bite:只有两个字段,看不到构造器

代码第 8–10 行(红 1)明明有一个构造器 public Bite(),图上却没有。UMLDoclet 把“只有一个、又没有参数的 public 构造器”当成 Java 自动给的默认构造器(default constructor),不画出来。你的 10 个技能子类(CBite、CTackle、CLance、CStun……)、3 个道具(CHealthTonic、CCoffee、CPoison)、CBlinded 和 CStunned 都是这样。CPosition 的 equals 和 hashCode(两个对象算不算相等)也被省掉了。所以:图上没有,不等于代码里没有。

屏幕上的数字来自图上的哪几行

CFighter 图上有 getHp(): int、getMaxHp(): int、getEnergy(): int、getMaxEnergy(): int 四行。它们是 public 的,所以 ui 包里的 CFighterPanel 能调用它们,把结果交给血条,写成“当前 / 最大”:

Fighter 的 4 个 get 方法battle/Fighter.java第 56–58、60–62、64–66、68–70 行
561 public int getHp() {
57 return getStat(Stat.HP);
58 }
⋯ 省略第 59–59 行
601 public int getMaxHp() {
61 return startingStats.get(Stat.HP);
62 }
⋯ 省略第 63–63 行
642 public int getEnergy() {
65 return getStat(Stat.ENERGY);
66 }
⋯ 省略第 67–67 行
682 public int getMaxEnergy() {
69 return startingStats.get(Stat.ENERGY);
70 }
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12
  1. 1
    225 / 225:getHp() 读 stats 里的 HP,getMaxHp() 读 startingStats 里的 HP
  2. 2
    29 / 33:getEnergy() 和 getMaxEnergy()。上一回合用了 Wait(能量最多 33,加不上去),这一回合用 Stun 花了 4 点:33 − 4 = 29
你的血条和能量条(放大):数字就是 4 个 get 方法返回的值真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

这里能看出两个字段的分工:stats 是“现在的”数值,会被扣;startingStats 是“开打时的”数值,不会变,用来当最大值。光看图上 startingStats: Map<Stat, Integer> 和 stats: Map<Stat, Integer> 两行,你只能猜;看第 22、24 行的注释才确定。(地图上卷轴写的 HP 是 150,这里却是 225,原因在下一节最后。)

4. 枚举的图:常量写在最上面

枚举(E)的图和普通类长得差不多,只是字段格最上面多了几行常量。拿 EStat 和 ETerrain 两张图对照 Stat.java:

Stat 的 UML 类图
卷子上的 Stat:6 个常量在字段格最上面
Terrain 的 UML 类图
卷子上的 Terrain:7 个常量,最后一行 fromSymbol 带下划线
Stat.java(第 28 行 limit 的内容省略)model/Stat.java第 4–27 行
4public enum Stat {
51 HP("HP", 1, 1000),
6 ENERGY("Energy", 1, 100),
7 STRENGTH("Strength", 1, 100),
8 ACCURACY("Accuracy", 1, 100),
9 DEFENCE("Defence", 1, 100),
10 DODGE("Dodge", 1, 100);
11
122 private final String label;
13 private final int minimum;
14 private final int maximum;
15
163 Stat(String label, int minimum, int maximum) {
17 this.label = label;
18 this.minimum = minimum;
19 this.maximum = maximum;
20 }
21
22 public String getLabel() {
23 return label;
24 }
25
26 /* Move a value into the legal range of this stat. */
274 public int limit(int value) {
  1. 红 1:第 5–10 行的 6 个常量 HP、ENERGY、STRENGTH、ACCURACY、DEFENCE、DODGE,画在字段格最上面,前面是绿色空心圆,带下划线,没有类型。意思是:每个常量都是 public、static 的,类型就是 EStat 自己。括号里的 ("HP", 1, 1000) 不画。
  2. 红 2:第 12–14 行三个 private 字段 label、minimum、maximum。每个常量自己存一份,比如 HP 存着 "HP"、1、1000。
  3. 红 3:第 16 行的构造器 Stat(String, int, int)。代码里没写 private,图上却是红色实心方块:枚举的构造器 Java 自动当成 private(Java 自动加上的修饰符),所以别的类不能 new Stat(...),世界上只有这 6 个 Stat。
  4. 红 4:第 27 行 limit(int): int,把一个数压回这个属性的合法范围(第 28 行:比最小值小就变成最小值,比最大值大就变成最大值)。CFighter 的构造器用它(Fighter.java 第 34 行),这一节最后会看到它真的起作用。
  5. ETerrain 的图一样:7 个常量 BARRIER……WATER 在最上面,然后是两个 private 字段 symbol: char、walkable: boolean,红色实心方块的构造器 Terrain(char, boolean)。最后一行 fromSymbol(char): Terrain 是绿色实心圆加下划线:一个 public static 方法(第 6 节),CMapParser 读地图文字时用它把一个字母变成一个 ETerrain。

每个枚举还自带一个 values() 方法(枚举自带的方法),按顺序给出所有常量,图上不画。地图右上角卷轴里的 6 行就是这样来的:

PlayerStatsScroll:每个 Stat 一行ui/PlayerStatsScroll.java第 48–54 行
48 Mokepon mokepon = player.getMokepon();
49 int row = 0;
50 addRow(grid, row++, "Mokepon:", createLabel(mokepon.getName(), nameFont));
511 for (Stat stat : Stat.values()) {
52 String value = String.valueOf(mokepon.getStat(stat));
531 addRow(grid, row++, stat.getLabel() + ":", createLabel(value, numberFont));
54 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    HP 到 Dodge 这 6 行:Stat.values() 的 6 个常量,顺序和 Stat.java 第 5–10 行一样。左边的字是 stat.getLabel() 加一个冒号,右边是第 52 行的 mokepon.getStat(stat)
地图上的卷轴(放大):Impike 本来的 6 个数值真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

再看 EDirection(第 2 节的 E 卡片)。它的 4 个常量各存两个数:UP(0, -1)、DOWN(0, 1)、LEFT(-1, 0)、RIGHT(1, 0),就是图上的 xChange: int 和 yChange: int。CPosition 的 move(Direction) 用它们算出下一格。下面是真实的走路截图(白色星形就是你的小人),每按一次键,就是用一个 EDirection 常量走一格:

按右方向键走一格,现在在 (17,18)按右方向键走一格,现在在 (18,18)向上走一格,现在在 (18,17)走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
地图右下角(放大):4 次移动,用了 RIGHT、RIGHT、UP、UP 四次 Direction 常量
Position.move:用 Direction 的两个数算新格子world/Position.java第 25–28 行
25 /* The position one tile away in the given direction. */
26 public Position move(Direction direction) {
271 return new Position(x + direction.getXChange(), y + direction.getYChange());
28 }
向上走一格,现在在 (18,17)
12
  1. 1
    按 ↑ 以后:第 27 行 move(Direction.UP) 算出 (18 + 0, 18 + (−1)) = (18,17),白色星形的小人画到这一格(正上方就是问号箱)
  2. 2
    ↑ 按钮:点它和按 ↑ 键一样,都用 Direction.UP
上面动画的第 3 帧(放大):(18,18) 往上走一格到 (18,17)真实运行截图 · 向上走一格,现在在 (18,17)

第 27 行 new Position(...):CPosition 从来不改自己的 x 和 y(两个字段都是 final),走一步就造一个新的 CPosition 对象。

5. 名字前面的小图标:谁能用这个成员

每一行最前面都有一个彩色小图标,说的是“哪些类能用这个成员”,这叫可见性(visibility)。教科书上的 UML 用 + - # ~ 四个符号;UMLDoclet 画成四种小图标。同一种颜色还分两种:空心的是字段,实心的是构造器和方法。下面的图标是照原图放大画的(原图里它们只有 6 像素宽):

Java 写法(教科书符号)UMLDoclet 小图标
左:字段(空心)
右:方法(实心)
谁能用
public(+) 绿色圆任何类
protected(#) 黄色菱形子类,和同一个包里的类
什么都不写(~) 蓝色三角只有同一个包里的类
private(-) 红色方块只有这个类自己

你的项目里没有包内可见的字段,所以空心蓝三角不会出现;空心黄菱形也只有 CStatBar 的两个常量(本节最后)。

翻一遍 72 张图会发现:所有不是 static 的字段,全是红色空心方块(private)。不是 private 的字段,除了枚举常量(第 4 节),只有 8 个 static 常量:6 个 public(CUiStyle 的 GAME_WIDTH、GAME_HEIGHT、SCROLL_FILE,CMapView 的 TILE_SIZE,CBlinded 和 CStunned 的 TURNS)和 2 个 protected(CStatBar 的 WIDTH、HEIGHT)。别的类想读或改数据,只能调用这个类自己提供的方法,比如 get 方法、takeDamage、spendEnergy。这就是封装(encapsulation),考试很爱问。

Player:三种图标在同一张图里

Player.java:字段和每个方法的第一行world/Player.java第 9–17、24–24、28–28、32–32、36–36、40–40、46–46、50–50 行
9public class Player {
101 private final Mokepon mokepon;
11 private final Inventory inventory;
12 private Position position;
13 /* The tile before the last move, so the player can run back to it. */
14 private Position previousPosition;
15 private int fightsWon = 0;
16
172 public Player(Mokepon mokepon, Position startPosition, Inventory inventory) {
⋯ 省略第 18–23 行
243 public Mokepon getMokepon() {
⋯ 省略第 25–27 行
28 public Inventory getInventory() {
⋯ 省略第 29–31 行
32 public Position getPosition() {
⋯ 省略第 33–35 行
36 public int getFightsWon() {
⋯ 省略第 37–39 行
404 void moveTo(Position newPosition) {
⋯ 省略第 41–45 行
46 void moveBack() {
⋯ 省略第 47–49 行
50 void addWin() {
Player 的 UML 类图
卷子上的 Player(放大 1.4 倍):红色空心方块、绿色实心圆、蓝色实心三角
  • 红 1:第 10–15 行 5 个字段,图上全是红色空心方块:private,只有 CPlayer 自己的代码能直接读写。
  • 红 2、红 3:构造器(第 17 行)和 4 个 get 方法(第 24 行起)是绿色实心圆:public,哪个类都能调用。
  • 红 4:第 40 行 void moveTo(Position newPosition) 前面什么修饰符都没写,第 46 行 moveBack、第 50 行 addWin 也一样。图上是最后三行的蓝色实心三角:包内可见(package-private),只有 world 包里的类能调用。

为什么要这样?看看你点 → 按钮走一步时发生了什么。下面是一张顺序图(sequence diagram),读法在 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。

玩家COverworldScreenCOverworldCplayer : Player点 → 按钮1movePlayer(RIGHT)2move(RIGHT)3moveTo(新格子 11,18)4onPlayerMoved()5moveTo 是包内可见(蓝色三角)Overworld 和 Player 都在 world 包:能调用OverworldScreen 在 ui 包:不能直接调用 moveTo第 5 条:Overworld 通过接口回头通知界面
点一下 → 按钮:界面只能请 Overworld 走,Overworld 再去改 Player
  1. 玩家点 → 按钮(按 → 键也一样)。
  2. COverworldScreen 调用自己的 private 方法 movePlayer(RIGHT)。
  3. 它只能调用 COverworld 的 public 方法 move(RIGHT)。
  4. COverworld 和 CPlayer 都在 world 包,所以能调用包内可见的 moveTo(...),把位置改成 (11,18)。
  5. COverworld 通过 IOverworldListener 接口回头调用界面的 onPlayerMoved(),界面重画(第 7 节最后)。这一条是 COverworld 主动调用界面,不是返回,所以是实线。
Overworld.move:public 的入口,里面调用包内可见的 moveToworld/Overworld.java第 65–71 行
65 /* Move the player one tile, then let every object on the new tile react. */
661 public void move(Direction direction) {
67 if (!canMove(direction)) {
68 return;
69 }
702 player.moveTo(player.getPosition().move(direction));
71 notifyListeners(listener -> listener.onPlayerMoved());
按右方向键走一格,现在在 (11,18)
12
  1. 1
    → 按钮:COverworldScreen 只能调用 public 的 world.move(Direction.RIGHT)(按 → 键也走到这里)
  2. 2
    小人到了 (11,18):move 里的 player.moveTo(...) 改了 CPlayer 的位置
从 (10,18) 往右走了一格真实运行截图 · 按右方向键走一格,现在在 (11,18)

如果 COverworldScreen 写 world.getPlayer().moveTo(...),编译就不通过,因为 moveTo 不是 public。这样“能不能走、走完要做什么(踩到箱子、遇敌)”的规则全在 COverworld 里,界面绕不过去。

protected:留给子类用的

GameScreen.java:每个成员的第一行ui/GameScreen.java第 12–18、24–24、32–32、42–42、47–47、52–52、55–55、60–60、64–64、69–69、76–76、83–83、88–88 行
12public abstract class GameScreen {
13 private final GameNavigator navigator;
14 private final Pane view = new Pane();
15 /* The dialog on top of the screen, or null when there is none. */
16 private ModalDialog dialog;
17
181 protected GameScreen(GameNavigator navigator) {
⋯ 省略第 19–23 行
24 public Pane getView() {
⋯ 省略第 25–31 行
322 public final void handleKeyPress(KeyEvent event) {
⋯ 省略第 33–41 行
42 public boolean canRunAway() {
⋯ 省略第 43–46 行
47 public boolean canReturnToMainMenu() {
⋯ 省略第 48–51 行
523 protected void handleKey(KeyEvent event) {
⋯ 省略第 53–54 行
55 protected GameNavigator getNavigator() {
⋯ 省略第 56–59 行
604 protected void addToView(Node... nodes) {
⋯ 省略第 61–63 行
64 protected void addToView(List<Node> nodes) {
⋯ 省略第 65–68 行
69 protected void showDialog(ModalDialog newDialog) {
⋯ 省略第 70–75 行
76 protected void closeDialog() {
⋯ 省略第 77–82 行
83 protected boolean isDialogOpen() {
⋯ 省略第 84–87 行
88 protected ModalDialog createGameEndDialog(String heading) {
GameScreen 的 UML 类图
卷子上的 GameScreen:黄色实心菱形是 protected
  • 红 1:第 18 行构造器是 protected(黄色实心菱形)。只有子类能用它,写法是子类构造器第一行的 super(navigator),比如 COverworldScreen 第 56 行。
  • 红 2:第 32 行 handleKeyPress 是 public,因为 CGameWindow 要在按键时调用它(CGameWindow 第 47 行)。代码里它还有 final,图上看不出来。
  • 红 3:第 52 行 handleKey 是 protected:子类 COverworldScreen 和 CBattleScreen 改写(override)它,别的类不该调用它。
  • 红 4:第 60 行 addToView(Node... nodes) 在图上是 addToView(Node...): void。三个点叫可变参数(varargs):调用时可以传任意多个 Node,方法里把它们当成一个数组。下一行(第 64 行)还有一个 addToView(List<Node>):名字一样、参数类型不同,叫重载(overload)。图上也是两行 addToView。
  • 3 个字段 navigator、view、dialog 还是 private:连子类也不能直接碰,只能用 protected 的 getNavigator()、addToView(...)、showDialog(...)。

protected 的字段全项目只有两个,在 CStatBar 里。图上是黄色空心菱形,加下划线(static):

StatBar 的 UML 类图
卷子上的 StatBar:WIDTH 和 HEIGHT 是黄色空心菱形加下划线
子类 LabelledStatBar 直接用 WIDTH、HEIGHTui/LabelledStatBar.java第 11–11、17–25 行
111public class LabelledStatBar extends StatBar {
⋯ 省略第 12–16 行
17 public LabelledStatBar(double x, double y, String colour, int current, int max) {
18 super(x, y, colour, current, max);
19 numbers = UiStyle.createLabel(current + SEPARATOR + max,
20 UiStyle.createMonospaceFont(FontWeight.BOLD, FONT_SIZE));
21 numbers.setLayoutX(x);
22 numbers.setLayoutY(y);
232 numbers.setPrefSize(WIDTH, HEIGHT);
24 numbers.setAlignment(Pos.CENTER);
25 }

CLabelledStatBar 是 CStatBar 的子类(第 11 行,红 1),所以第 23 行(红 2)能直接写 WIDTH、HEIGHT,让数字标签和条一样大(256 × 22)。

6. 下划线 = static,斜体 = abstract

下划线:属于类本身

SkillCatalogue.javaskill/SkillCatalogue.java第 10–23 行
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 }
22 throw new IllegalArgumentException("Unknown skill: " + name);
23 }
SkillCatalogue 的 UML 类图
卷子上的 SkillCatalogue(放大 1.3 倍):两行都带下划线

带下划线的成员是 static 的:它属于类本身,不属于哪一个对象。

  • 红 1:SKILLS: List<Skill>(红色空心方块 + 下划线 = private static 字段)。整个程序只有这一个列表,里面是 10 个技能对象,所有 Mokepon 共用。
  • 红 2:find(String): Skill(绿色实心圆 + 下划线 = public static 方法)。用的时候写“类名.方法名”,不用先 new:CMokeponParser 第 46 行就是 SkillCatalogue.find(skillName.trim())。整个程序从来没有 new 过一个 CSkillCatalogue 对象。

代码一行一行看:第 11–13 行 List.of(...) 一次造好 10 个技能对象,放进一个不能再改的列表。第 17–21 行用 for-each 一个一个比名字,skill.getName().equals(name) 是“这个技能的名字和要找的名字一样吗”,一样就 return 这个技能。第 22 行:10 个都比完还没找到,说明数据文件里写了一个不存在的技能名,就 throw 一个异常,程序报错停下。

CUiStyle 的图上每一行都带下划线:它是一个只放常量和 static 方法的工具类(utility class)。同样是 static 常量,有的是 public,有的是 private,原因看屏幕就明白了:

UiStyle 的 UML 类图
卷子上的 UiStyle(放大 1.3 倍):所有成员都带下划线(static);前 3 个字段是绿色空心圆(public),后 5 个是红色空心方块(private)
UiStyle:public 和 private 的 static 常量ui/UiStyle.java第 15–21、66–68 行
15public class UiStyle {
161 public static final int GAME_WIDTH = 1280;
171 public static final int GAME_HEIGHT = 768;
182 public static final String SCROLL_FILE = "assets/scroll.png";
19
203 private static final String BACKGROUND_FILE = "assets/bg.png";
21 private static final String FONT_FILE = "assets/GrenzeGotisch-Black.ttf";
⋯ 省略第 22–65 行
66 public static ImageView createBackground() {
673 return createImageView(loadImage(BACKGROUND_FILE), 0, 0, GAME_WIDTH, GAME_HEIGHT);
68 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)11280 × 76823背景 bg.png
  1. 1
    整个游戏区 1280 × 768(底边这条线就是 1280 宽):AGameScreen 的构造器用 UiStyle.GAME_WIDTH 和 UiStyle.GAME_HEIGHT 定画面大小(GameScreen.java 第 20 行),CGameMenu 也用 GAME_WIDTH 定菜单栏的宽。别的类要用,所以是 public
  2. 2
    卷轴图片 assets/scroll.png:路径存在 public 的 SCROLL_FILE 里,CPlayerStatsScroll、CFighterScroll、CSelectScreen 三个类都要用
  3. 3
    棕色背景 assets/bg.png(地图外面的棕色都是它):路径存在 private 的 BACKGROUND_FILE 里,只有 CUiStyle 自己的 createBackground()(第 67 行)用它,别的类调用这个方法就行
地图画面:三样东西的大小或图片,都来自 UiStyle 的 static 常量真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

斜体:抽象的,只有方法头

Skill.java:use 和 applyEffectskill/Skill.java第 9–11、14–14、27–36 行
9public abstract class Skill {
10 private final String name;
11 private final int energyCost;
⋯ 省略第 12–13 行
14 protected Skill(String name, int energyCost) {
⋯ 省略第 15–26 行
27 /* Template Method: pay the energy cost first (even if an attack then misses),
28 * then apply the effect. Returns the message for the battle log.
29 */
302 public final String use(Fighter user, Fighter target, Random random) {
31 user.spendEnergy(energyCost);
32 return applyEffect(user, target, random);
33 }
34
35 /* Each subclass decides what the skill actually does. */
361 protected abstract String applyEffect(Fighter user, Fighter target, Random random);
Skill 的 UML 类图
卷子上的 Skill:只有最后一行 applyEffect 是斜体
  • 红 1:第 36 行 protected abstract String applyEffect(...);,图上是斜体(黄色实心菱形 = protected)。这是抽象方法(abstract method):只有方法头,用分号结束,没有内容。每个不是抽象的子类都必须有它的实现:可以自己写(CWait),也可以从中间的父类继承(CBite 用的是 ATargetedSkill 写好的 applyEffect)。整个 skill 包里,写了 applyEffect 的只有 ATargetedSkill、ABoostSkill 和 CWait 这 3 个类;10 个技能里除了 CWait,另外 9 个都是从 ATargetedSkill 或 ABoostSkill 继承来的。
  • 红 2:第 30 行 public final String use(...),图上是正体:它有内容,先扣能量(第 31 行),再调用 applyEffect(第 32 行)。代码里的 final(子类不能改写它)图上看不出来。这个“父类定好顺序、子类填一步”的写法叫模板方法(Template Method),见 第 12 章 和 第 16 章。

斜体还出现在:AItem 的 use(Fighter, Fighter): String、AMapObject 的 interact(Overworld): void(斜体加蓝色实心三角)、所有接口里的所有方法(Java 自动把它们当成 abstract)。前两张图是这样的:

MapObject 的 UML 类图
MapObject(放大 1.2 倍):最后一行 interact(Overworld): void 是斜体 + 蓝色实心三角 = 包内可见的抽象方法(MapObject.java 第 30 行 abstract void interact(Overworld world);)
Item 的 UML 类图
Item(放大 1.2 倍):use(Fighter, Fighter): String 是斜体 = 抽象方法(第 35 行);构造器和 describeUse 是黄色实心菱形 = protected

那子类写出来的样子呢?看 CWait:

Wait 的 UML 类图
卷子上的 Wait:上面 Skill 的方框里 applyEffect 是斜体,下面 Wait 自己的方框里是正体
Wait.java:三句话和写出来的 applyEffectskill/Wait.java第 9–13、19–22 行
9 private static final int ENERGY_COST = -2;
10 private static final List<String> MESSAGES = List.of(
11 "\tThey get a little more bored.",
122 "\tThey wonder if this is a good strategy.",
131 "\tThey think about what they will have for dinner.");
⋯ 省略第 14–18 行
19 @Override
20 protected String applyEffect(Fighter user, Fighter target, Random random) {
21 return MESSAGES.get(random.nextInt(MESSAGES.size()));
22 }
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
12
  1. 1
    你的 Wait:返回了第 13 行那句 “They think about what they will have for dinner.”
  2. 2
    敌人的 Wait:返回了第 12 行那句 “They wonder if this is a good strategy.”
用 Wait 那一回合的战斗日志(放大):两句发呆的话,都是 Wait.applyEffect 返回的真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)

第 10–13 行的 MESSAGES 是一个装着三句话的 List.of(...)。第 21 行 random.nextInt(MESSAGES.size()) 在 0、1、2 里随机挑一个下标,MESSAGES.get(...) 取出那一句返回。你和敌人各挑了一次,挑到的不一样,所以日志里两句不同。每句话最前面的 \t 是一个制表符(tab),所以截图里这两句比上面的 “used Wait!” 往右缩进了一截。第 19 行的 @Override 说明它写的是父类的方法(第 7 节有说明)。

读图的诀窍:同一个方法,在父类方框里是斜体,在自己的方框里是正体,就说明这个类把父类的抽象方法写出来了。

7. 箭头:extends 和 implements

每张图里只可能有一种线:从这个类连到它直接的父类或接口。线的头是一个空心三角,指向父类(或接口)。线是实线还是虚线,意思不一样:

实线 + 空心三角 = extends(继承)

Bite 的 UML 类图
Bite extends AttackSkill

虚线 + 空心三角 = implements(实现接口)

RandomEnemyStrategy 的 UML 类图
RandomEnemyStrategy implements EnemyStrategy
实线:Bite.java 的类头skill/Bite.java第 4–4 行
4public class Bite extends AttackSkill {
虚线:RandomEnemyStrategy.java 的类头battle/RandomEnemyStrategy.java第 9–9 行
9public class RandomEnemyStrategy implements EnemyStrategy {

父类的方框里只写名字和父类的抽象方法(斜体),也就是这个类必须有实现的那几个;父类的字段、构造器、普通方法一概不写。所以 CBite 图上的 AAttackSkill 方框是空的(AAttackSkill 已经没有抽象方法了),CRandomEnemyStrategy 图上的 IEnemyStrategy 方框里有 chooseSkill(Fighter, Random): Skill。

父类也可以不是你写的。CMain 的图上方是 JavaFX 的 Application(包名 javafx.application):

Main 的 UML 类图
卷子上的 Main:父类是 JavaFX 的 Application
Main.java:类头、start、mainMain.java第 19–19、21–22、42–44 行
191public class Main extends Application {
⋯ 省略第 20–20 行
21 @Override
222 public void start(Stage stage) {
⋯ 省略第 23–41 行
423 public static void main(String[] args) {
43 launch(args);
44 }

同时 extends 和 implements:OverworldScreen

OverworldScreen.java:类头和 6 个 @Overrideui/OverworldScreen.java第 24–24、72–73、79–80、96–97、102–103、108–109、115–116 行
241public class OverworldScreen extends GameScreen implements OverworldListener {
⋯ 省略第 25–71 行
72 @Override
732 public void onPlayerMoved() {
⋯ 省略第 74–78 行
79 @Override
802 public void onEncounter(Encounter encounter) {
⋯ 省略第 81–95 行
96 @Override
972 public void onItemCollected(Item item) {
⋯ 省略第 98–101 行
102 @Override
1032 public void onEncounterWon(Mokepon enemy) {
⋯ 省略第 104–107 行
108 @Override
1092 public void onExitReached(int fightsWon) {
⋯ 省略第 110–114 行
115 @Override
1163 protected void handleKey(KeyEvent event) {
OverworldScreen 的 UML 类图
卷子上的 OverworldScreen:左上实线连 GameScreen,右上虚线连 OverworldListener
  • 红 1:第 24 行 extends GameScreen implements OverworldListener。图上左上一条实线三角指向 AGameScreen,右上一条虚线三角指向 IOverworldListener。Java 里一个类只能 extends 一个父类,但可以 implements 好几个接口。
  • 红 2:5 个 @Override 方法正好是 IOverworldListener 方框里那 5 行斜体。实现一个接口,就是答应把它的方法全写出来。
  • 红 3:handleKey 也是 @Override,改写的是 AGameScreen 的方法。可是 AGameScreen 方框里什么都没有:它的 handleKey 有方法体(虽然是空的),不是抽象方法,所以不画。

图下面的 private 方法(红色实心方块)movePlayer、update……只给 COverworldScreen 自己用,前面那一串带下划线的红色空心方块是它的 private static 常量(按钮大小、位置等)。

GameWindow 的 UML 类图
卷子上的 GameWindow:虚线 + 空心三角指向接口 GameNavigator,接口方框里 7 行斜体

CGameWindow 的图也一样:虚线三角指向接口 IGameNavigator,接口方框里 7 行斜体方法;CGameWindow 自己的方框里,7 个同名方法全是正体:它全部写了。

谁调用这 7 个方法?图上看不出。答案是各个画面:它们只存着一个 GameNavigator(AGameScreen 的字段 navigator),比如遇敌框的 Fight! 按钮调用 getNavigator().startBattle()。画面不知道窗口是 CGameWindow,只知道这个接口(第 6 章第 2 节)。

只往上画一层

每张图只画直接的父类。想知道 CBite 到底能做什么,要把 4 张图拼起来:

抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillskill抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkillskill技能 Bite:花 1 点能量的普通攻击,威力 1。CBiteskilluse():先扣能量,再调 applyEffect()applyEffect():抽象的,没写内容写了 applyEffect():先判断打没打中applyHit():抽象的,没写内容写了 applyHit():按 power 造成伤害只给出名字 Bite、能量 1、power 1画在 TargetedSkill 那张图上画在 AttackSkill 那张图上画在 Bite 那张图上
Bite 的祖先:要从 4 张打印图里拼出来。空心三角 = extends;细线连着的是这一层写了什么

CBite 自己只有两个常量。打出伤害的代码在 AAttackSkill,判断打没打中在 ATargetedSkill,扣能量在 ASkill。考试时如果被问“Bite 被用的时候发生了什么”,要顺着这条链往上读(第 12 章)。

实现了接口的方法,什么时候被调用

虚线三角只说“COverworldScreen 写了这 5 个方法”,没说谁调用它们。看捡到问号箱的那一刻。先看规则这一边,COverworld:

Overworld.java:collect 和 notifyListenersworld/Overworld.java第 110–115、122–129 行
110 /* Called by a mystery box when the player steps onto it. A box can only be collected once. */
111 void collect(MysteryBox box) {
112 objects.remove(box);
113 player.getInventory().add(box.getItem());
1141 notifyListeners(listener -> listener.onItemCollected(box.getItem()));
115 }
⋯ 省略第 116–121 行
122 /* Send the same message to every registered listener.
123 * The lambda says which listener method to call.
124 */
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
1272 message.accept(listener);
128 }
129 }
Cbox : MysteryBoxCOverworldCOverworldScreenCstatsScrollinteract(this)1collect(this)2notifyListeners(…)3onItemCollected(item)4statusBar.setText(…)5update()6statsScroll.update()7
捡到问号箱:Overworld 只知道 OverworldListener 接口,真正被通知的是 OverworldScreen
  1. COverworld.move 发现小人站在箱子上,调用 object.interact(this)(第 76 行)。
  2. 箱子回头调用 world.collect(this)。
  3. collect 调用 notifyListeners(第 114 行)。
  4. 列表里的监听者是 COverworldScreen,于是它的 onItemCollected(item) 被调用。
  5. 它在状态栏写上一句话(第 98 行)。
  6. 它调用自己的 update()(第 99 行)。
  7. update() 里 statsScroll.update()(第 136 行)重新写卷轴里的数字。
界面这一边:OverworldScreen 写的 onItemCollectedui/OverworldScreen.java第 96–100、133–136 行
96 @Override
97 public void onItemCollected(Item item) {
981 statusBar.setText("You picked up a " + item.getName() + "!");
99 update();
100 }
⋯ 省略第 101–132 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
1362 statsScroll.update();

COverworld 的图上有一个字段 listeners: List<OverworldListener>,类型是接口,不是 COverworldScreen:COverworld 根本不知道界面长什么样。把 COverworldScreen 放进这个列表的是 CGameWindow.startGame 里的 world.addListener(overworldScreen)(CGameWindow 第 61 行)。这就是观察者模式(Observer),第 8 章第 6 节细讲。这些“谁调用谁”,图上全都没有。

8. 图上没有画的东西:从字段类型推出“谁存着谁”

考试的 Q1 常问 interaction:为了完成一件事,类和类怎么配合。要说清三件事:谁存着谁(containership)、谁创建谁(instantiation)、谁调用谁。打印的图上一条关系线都没有,但第一件事可以从字段格推出来:

  1. 字段的类型是项目里的类 → 这个类的对象存着一个那个类的对象(has-a)。
  2. 类型是 List<X> → 存着好几个 X(可以是 0 个)。类型是 Map<K, V> → 存着好几对“K → V”;K 或 V 是项目里的类,就和它有关系。比如 CInventory 的 counts: Map<Item, Integer>:键是 AItem,所以 CInventory 和 AItem 有关系。
  3. 类型是接口或抽象类 → 真正放进去的是它的某个子类的对象,要去代码里找 new。
  4. 类型是 int、boolean、String,或者 Java 自带的类(Random、List<String>)→ 卷子上没有它们的图。答 containership 时只说项目里的类。

用这四条读 CBattle 的字段格。点“下一步”,关系图会一条一条亮起来(亮的框和红色的线就是这一步推出来的):

Battle 的 UML 类图
卷子上的 Battle(第 1 节那张):这次只看第二格的 8 个字段
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    Poison 按钮上的 1:就是字段 inventory 这个背包里 Poison 的个数
战斗刚开始的道具按钮(放大)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
List<String>:很多行字一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattleRandom(Java 自带的类)正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle背包:记录每种道具各有几个。CInventoryitem接口:决定敌人这一回合用哪个技能。IEnemyStrategybattleEnemyStrategy 的实现:从能量够用的技能里随机挑一个。CRandomEnemyStrategybattleplayer, enemyinventoryenemyStrategyrandomlog
从 Battle 的字段类型推出来的关系(卷子上本来没有这些线)。空心菱形 = 字段里存着项目里的类的对象;细线 = Java 自带的类型;虚线空心三角 = implements
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    日志第一行:构造器第 37–38 行 log.add(...) 加进去的
战斗刚开始的日志框(放大):里面只有一行真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
Battle.java:字段格的 8 行和构造器里的几行battle/Battle.java第 14–22、26–26、34–34、37–38 行
14public class Battle {
15 private final Fighter player;
16 private final Fighter enemy;
17 private final Inventory inventory;
18 private final EnemyStrategy enemyStrategy;
19 private final Random random;
20 private final List<String> log = new ArrayList<>();
21 private boolean finished = false;
22 private boolean playerWon = false;
⋯ 省略第 23–25 行
26 this(player, enemy, inventory, new RandomEnemyStrategy(), new Random());
⋯ 省略第 27–33 行
34 this.inventory = inventory;
⋯ 省略第 35–36 行
37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
  1. 从类头开始:CBattle,C,普通类。原图只看第二格的 8 行,它们就是下面代码的第 15–22 行。关系图是我们要推出来的,现在只有 CBattle 亮着。一行一行看。
  2. 前两行 player: Fighter、enemy: Fighter:类型是项目里的类 → CBattle 存着两个 CFighter,一个是你的,一个是敌人的。关系图上画一条空心菱形线,标上两个字段名。
  3. 第三行 inventory: Inventory → 存着一个 CInventory(背包),截图上 Poison 按钮的 1 就是它里面的数。注意第 34 行 this.inventory = inventory:背包是从构造器参数传进来的,不是 CBattle new 的。它是 COverworld 开局时 new 的那一个,CPlayer 也存着它,所以战斗里用掉的道具,回到地图也少了。
  4. 第四行 enemyStrategy: EnemyStrategy:类型是接口(I)→ 图上看不出真正的对象是什么。去代码里找 new:第 26 行。this(...) 写在构造器第一行,意思是“调用同一个类的另一个构造器”(构造器链,第 2 章第 2 节):三个参数的构造器自己什么都不存,补上 new RandomEnemyStrategy() 和 new Random(),交给五个参数的构造器去存。所以关系图上再加一条虚线三角:CRandomEnemyStrategy implements IEnemyStrategy。
  5. 第五行 random: Random:Random 是 Java 自带的随机数类,卷子上没有它的图,所以关系图上只画一条细线,答 containership 时不用提它。它也是第 26 行 new 的。技能打没打中、敌人用哪个技能,都靠它。
  6. 第六行 log: List<String> → 存着很多行字,String 也是 Java 自带的,所以也是细线。第 37–38 行的构造器一开始就加了第一行 “Player chose ... Began a fight against enemy ...!”,就是截图里日志框的第一行。
  7. 最后两行 finished: boolean、playerWon: boolean:只是两个是 / 否的值(打完了没有、你赢了没有),不是对象,不画线。
  8. 全部推完。考试可以这样写:Battle has two Fighter fields, player and enemy, and also holds the Inventory and an EnemyStrategy (a RandomEnemyStrategy by default). 但是这张图还回答不了:是谁 new 了这个 Battle 和两个 Fighter?答案在下面。

谁创建谁:图上完全看不出来

点 Fight! 以后,这几个对象是这样造出来的(虚线箭头 = 返回,绕回自己的箭头 = 调用自己的方法):

COverworldScreenCGameWindowCOverworldCEncounter玩家点 Fight!startBattle()1createBattle()2createBattle(背包)3new Fighter(...) 两次new Battle(...)battle4battle5new BattleScreen(battle, …)6showScreen(…)7
点 Fight!:Battle 和两个 Fighter 是 Encounter 造的,BattleScreen 是 GameWindow 造的
  1. 玩家在遇敌框里点 Fight!,COverworldScreen 调用 getNavigator().startBattle()(OverworldScreen.java 第 87 行)。
  2. CGameWindow.startBattle 先调用 world.createBattle()。
  3. COverworld 把背包交给 encounter.createBattle(player.getInventory())。
  4. CEncounter 在第 54–56 行 new 两个 CFighter 和一个 CBattle,把 CBattle 一路返回。
  5. 回到 CGameWindow:它 new BattleScreen(...),马上交给 showScreen 显示(GameWindow.java 第 67 行)。

从图上你只能看到 CEncounter 有一行 createBattle(Inventory): Battle:它返回一个 CBattle。但它里面 new 了两个 CFighter 和一个 CBattle,这个只有代码里有。同样,CGameWindow 的字段格里根本没有 CBattleScreen:new BattleScreen(...) 写在 startBattle() 里面,造好就交给 showScreen,只存在 currentScreen 里(第 1 章、第 6 章)。

声明的类型,和真正放进去的对象

UML 只画字段的声明类型(declared type)。放进去的对象可以是它的子类,或者实现了它的类(多态)。你的代码里最常被问到的四个:

类字段图上的类型真正的对象
CBattleenemyStrategyIEnemyStrategyCRandomEnemyStrategy(第 26 行)
CGameWindowcurrentScreenAGameScreen三个画面中正在显示的那个
CFighterPanelhpBarCStatBar你的面板里是 CLabelledStatBar
CFighterstatsMapEnumMap(第 25 行)

图上看得出

有哪些类、每个类存着什么(字段格)、能做什么(方法格)、谁能调用(小图标)、谁继承谁、谁实现哪个接口(箭头)。

图上看不出

谁 new 了谁、谁调用谁、方法按什么顺序做事、字段里真正是哪个子类、哪些是 final。这些要从代码里记:创建在 第 5 章、第 9 章,一个回合的调用在 第 11 章。

9. 考场上读一张图:一步一步

考试时间紧,读一张图要有固定的顺序。下面用 CFighter 走一遍:一边是卷子上的原图,一边是照着它画的示意图(每一块和原图同样高度的那几行对应,每一步亮起来的那一块,就是原图上同一个位置),下面是代码。

Fighter 的 UML 类图
卷子上的 Fighter(原图)
正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterau.edu.unimelb.swen.oop.mokepon.battleDAMAGE_SCALE: int(带下划线)mokepon: MokeponstartingStats: Map<Stat, Integer>stats: Map<Stat, Integer>effects: List<StatusEffect>Fighter(Mokepon, TerrainEffect)getName() … getMaxEnergy()前 8 个 get 方法:别人能读什么isAlive() … countDownEffects()后 13 个 public 方法:判断、扣血、花能量、加效果、算伤害……getDamageMultiplier()(红色)
示意图:字段格分 2 块、方法格分 4 块,高度和原图一样
Fighter.java:包和类头battle/Fighter.java第 1–1、18–18 行
1package au.edu.unimelb.swen.oop.mokepon.battle;
⋯ 省略第 2–17 行
18public class Fighter {
Fighter.java:字段battle/Fighter.java第 18–26 行
18public class Fighter {
19 private static final int DAMAGE_SCALE = 50;
20
21 private final Mokepon mokepon;
22 /* The stats at the start of this battle, after the terrain effect. */
23 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
25 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
26 private final List<StatusEffect> effects = new ArrayList<>();
Fighter.java:构造器battle/Fighter.java第 28–38 行
28 /* Prepare a Mokepon for a battle on a terrain that gives it the given effect.
29 * Every stat is multiplied, rounded and kept inside its legal range.
30 */
31 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
32 this.mokepon = mokepon;
33 for (Stat stat : Stat.values()) {
34 int value = stat.limit(terrainEffect.adjust(mokepon.getStat(stat)));
35 startingStats.put(stat, value);
36 stats.put(stat, value);
37 }
38 }
Fighter.java:8 个 get 方法的第一行battle/Fighter.java第 40–40、44–44、48–48、52–52、56–56、60–60、64–64、68–68 行
40 public String getName() {
⋯ 省略第 41–43 行
44 public String getImagePath() {
⋯ 省略第 45–47 行
48 public List<Skill> getSkills() {
⋯ 省略第 49–51 行
52 public int getStat(Stat stat) {
⋯ 省略第 53–55 行
56 public int getHp() {
⋯ 省略第 57–59 行
60 public int getMaxHp() {
⋯ 省略第 61–63 行
64 public int getEnergy() {
⋯ 省略第 65–67 行
68 public int getMaxEnergy() {
Fighter.java:其余 public 方法里的 6 个battle/Fighter.java第 77–77、82–82、89–89、95–95、118–118、124–124 行
77 public boolean canUse(Skill skill) {
⋯ 省略第 78–81 行
82 public void takeDamage(int amount) {
⋯ 省略第 83–88 行
89 public void spendEnergy(int amount) {
⋯ 省略第 90–94 行
95 public int raiseStat(Stat stat, int amount) {
⋯ 省略第 96–117 行
118 public int calculateDamage(Fighter target, double power) {
⋯ 省略第 119–123 行
124 public void addEffect(StatusEffect effect) {
Fighter.java:private 方法和调用它的地方battle/Fighter.java第 118–122、168–174 行
118 public int calculateDamage(Fighter target, double power) {
119 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE
120 / target.getStat(Stat.DEFENCE);
121 return (int) (damage * getDamageMultiplier());
122 }
⋯ 省略第 123–167 行
168 private double getDamageMultiplier() {
169 double multiplier = 1;
170 for (StatusEffect effect : effects) {
171 multiplier = Math.min(multiplier, effect.getDamageMultiplier());
172 }
173 return multiplier;
174 }
  1. 第 1 步,圆圈和名字:圆圈是 C,名字是正体 → 普通类,能 new。英语:Fighter is a concrete class.
  2. 第 2 步,包名和箭头:名字下面的包是 battle(代码第 1 行),说明它是战斗规则的一部分,不是界面。原图上方没有别的方框、没有箭头 → CFighter 不继承项目里的类,也不实现接口。
  3. 第 3 步,带下划线的字段:DAMAGE_SCALE: int,红色空心方块 + 下划线 = private static。所有 CFighter 共用这一个值。值是 50,图上看不到,要记得伤害公式里乘的就是它(第 12 章)。
  4. 第 4 步,普通字段 = 它存着什么:一只 CMokepon(mokepon);两个 Map<Stat, Integer>(开打时的数值和现在的数值,各 6 项);一个 List<StatusEffect>(身上的 0 个或多个效果,比如流血)。全是红色空心方块:private。
  5. 第 5 步,构造器 = 造它要给什么:Fighter(Mokepon, TerrainEffect),要一只 Mokepon 和一个地形效果。第 32 行 this.mokepon = mokepon:左边 this.mokepon 是字段,右边是参数,把参数存进字段。第 34 行从里往外读:取本来的数值 → 按地形乘 → limit 压回范围(第 4 节的 225 就是这样来的),再存进两个 Map。谁来造?图上没写,记住是 CEncounter.createBattle,一场仗造两个。
  6. 第 6 步,get 方法 = 别人能读什么:名字、图片、技能、数值、HP、能量……全是绿色实心圆 public。屏幕上的 “225 / 225” 就是 CFighterPanel 调用 getHp() 和 getMaxHp() 得来的(第 3 节)。
  7. 第 7 步,其余 public 方法 = 别人能让它做什么:takeDamage(int) 扣血、spendEnergy(int) 花能量、addEffect(StatusEffect) 加效果、calculateDamage(Fighter, double): int 算伤害……数值只能通过这些方法改(封装)。比如 AAttackSkill 打中时调用 target.takeDamage(damage)。
  8. 第 8 步,private 方法 = 只给自己用:getDamageMultiplier() 是红色实心方块,只有 CFighter 自己的 calculateDamage 调用它(第 121 行),用来算眩晕把伤害减半。第 121 行的 (int) 是强制类型转换:把 double 的小数部分直接砍掉,变成 int。
  9. 第 9 步,写成一句话:Fighter is a concrete class in the battle package. It holds a Mokepon, its starting and current stats and a list of StatusEffects; all fields are private, and other classes change it only through public methods such as takeDamage and spendEnergy.

把上面的步骤记成一张 8 条的清单,考试时每张图都照着走一遍:

  1. 圆圈字母:C / A / I / E?名字斜体吗?(斜体 = 不能 new;E 是正体,也不能 new)
  2. 包名:属于程序的哪一块?上面有没有父类 / 接口的方框和箭头?实线还是虚线?
  3. 带下划线的成员:哪些是 static?
  4. 字段的类型:它存着哪些对象?一个还是一串(List / Map)?
  5. 构造器的参数:造它要先有什么?
  6. public 方法:别的类能读什么、能让它做什么?
  7. protected / 包内可见 / private:哪些只给子类、同一个包、自己用?
  8. 斜体的方法:哪些是抽象的,要子类来实现?

练习 1:TargetedSkill

先看卷子上的图,再看它在游戏里留下的日志,然后答下面两道题(点题目看答案):

TargetedSkill 的 UML 类图
卷子上的 TargetedSkill(放大 1.2 倍):上面是它的父类 Skill 的方框
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12
  1. 1
    Stun 打中了:日志里这一句是 CStun 写的 applyHit 返回的
  2. 2
    敌人的 Tackle 没打中:“But they missed.”
用 Stun 那一回合的战斗日志(放大):你的 Stun 打中了,敌人的 Tackle 没打中真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
看图:ATargetedSkill 能不能 new?哪一行是斜体,说明什么?

不能。圆圈是 A,名字是斜体:抽象类。它自己方框里斜体的是 applyHit(Fighter, Fighter, Random): String:抽象方法。不是抽象类的子类 CStun、CPocketSand 必须写出来;子类 AAttackSkill 自己也是抽象类,Java 不强制它写,但它写了一个(CBite、CTackle 直接用;CLance 在它的基础上再加流血)。上方 ASkill 方框里的 applyEffect 是斜体,而 ATargetedSkill 自己方框里的 applyEffect 是正体:它把 ASkill 的抽象方法写出来了。

English: TargetedSkill is an abstract class; it implements applyEffect from Skill and leaves applyHit abstract for its subclasses.

日志里的 “But they missed.” 是图上哪个成员写的?CStun 能不能改掉这个命中判断?

是 MISS_MESSAGE: String(红色空心方块 + 下划线:private static 常量)。applyEffect 先调用 hits(...),没打中就返回 MISS_MESSAGE,打中了才调用 applyHit(代码第 18–23 行)。Tackle 的父类的父类就是 ATargetedSkill,所以它没打中时也是这句。子类改不了这个判断,有两个原因:applyEffect 是 final(第 18 行,图上看不出),子类不能改写;hits 和 calculateHitChance 是红色实心方块(private),子类连调用都不能。所以 ATargetedSkill 下面的 5 个技能(CStun、CPocketSand 直接继承它;CBite、CTackle、CLance 经过 AAttackSkill 继承它)命中判断都一样。CWait 和 CAim、CBunker、CNimble、CSwole 不经过这个判断,永远不会 miss。

练习 2:Inventory

Inventory 的 UML 类图
卷子上的 Inventory
点 Fight! 进入战斗界面,战斗刚开始
122
  1. 1
    Poison 按钮写着 1,可以点
  2. 2
    Health Tonic 和 Coffee 写着 0,是灰的
战斗画面的三个道具按钮(放大)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
从图上看,CInventory 用什么记住“每样道具有几个”?截图上的 1 和 0 是哪个方法给的?

只有一个字段 counts: Map<Item, Integer>:键是道具(AItem),值是个数。截图上的数字是 getCount(Item): int 返回的;按钮能不能点看 has(Item): boolean(个数大于 0 才是 true)。调用它们的是 CBattleScreen 的 updateItemButtons,这一点图上没有。

English: Inventory keeps a Map from each Item to how many the player has.

图上 add(Item) 和 remove(Item) 都是 public。在游戏里分别是谁调用的?

图上看不出,要记代码:捡到问号箱时,COverworld 的 collect 调用 player.getInventory().add(...);战斗里用道具时,CBattle 的 useItem 先用 has 检查,再调用 remove。两边用的是同一个 CInventory 对象。

练习 3:Encounter

Encounter 的 UML 类图
卷子上的 Encounter
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
123
  1. 1
    标题里的 Hexwing:getEnemy(): Mokepon 给的
  2. 2
    两行地形提示:getTerrainMessages(): List<String> 给的
  3. 3
    点 Fight! 以后才会用到 createBattle(Inventory): Battle
踩到 (17,17) 的感叹号:遇敌对话框(放大)。敌人是随机挑的:这一次是 Hexwing;逃跑以后再踩一次,遇到的是 Galecrest,本章的战斗截图打的就是它真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
CEncounter 存着几只 CMokepon?getPlayerEffect() 谁能调用?

两只:字段 playerMokepon: Mokepon 和 enemy: Mokepon,另外还存着 marker: Marker 和 terrain: Terrain。getPlayerEffect() 和 getEnemyEffect() 是红色实心方块(private),只有 CEncounter 自己用:getTerrainMessages 用它们写提示,createBattle 用它们造 CFighter。

只看 createBattle(Inventory): Battle 这一行,你能知道什么,不能知道什么?

能知道:它是 public 的,要一个 CInventory,返回一个 CBattle。不能知道:它里面 new 了两个 CFighter(按地形调整数值)和一个 CBattle,调用它的是 COverworld 的 createBattle()。考试问“谁创建了 Battle”,答案是 CEncounter,这一点只能靠记代码。

English: Encounter.createBattle creates the two Fighters and the Battle.

练习 4:GameMenu

GameMenu 的 UML 类图
卷子上的 GameMenu(放大 1.2 倍):三个字段,一个构造器,两个方法
GameMenu.java:三个字段、Exit 那一行和 updateui/GameMenu.java第 15–17、28–28、46–49 行
15 private final MenuBar menuBar;
161 private final MenuItem runAway;
172 private final MenuItem mainMenu;
⋯ 省略第 18–27 行
283 MenuItem exit = new MenuItem("Exit");
⋯ 省略第 29–45 行
46 public void update(GameScreen screen) {
471 runAway.setDisable(!screen.canRunAway());
482 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }
地图上打开 Game 菜单:Run away 灰,Main menu 可点
123
  1. 1
    Run away:灰的(字段 runAway,第 47 行把它设成灰)
  2. 2
    Main menu:能点(字段 mainMenu)
  3. 3
    Exit:能点(第 28 行的局部变量 exit)
在地图上打开 Game 菜单真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
战斗中打开 Game 菜单:Run away 可以点了
12
  1. 1
    Run away:在战斗里能点了
  2. 2
    Main menu:能点
在战斗里打开 Game 菜单真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了
菜单里有三个菜单项(MenuItem),为什么图上只有 runAway 和 mainMenu 两个 MenuItem 字段,没有 exit?(第三个字段 menuBar 是整条菜单栏)

因为 Exit 永远能点,造好以后不用再改它,所以构造器里只用了一个局部变量 MenuItem exit(第 28 行):构造器跑完,这个变量就没了。Run away 和 Main menu 换画面时要变灰或变亮(update 第 47–48 行),所以要存进字段,以后还能找到它们。

update(GameScreen): void 的参数类型是 AGameScreen。这说明了什么?

AGameScreen 是抽象类,所以传进来的一定是它的某个子类的对象:CSelectScreen、COverworldScreen 或 CBattleScreen。update 只调用 screen.canRunAway() 和 screen.canReturnToMainMenu(),每个画面自己回答(多态)。只有 CBattleScreen 在战斗没打完时回答“能逃跑”,所以战斗里那张截图上 Run away 能点。

English: GameMenu.update asks the current GameScreen whether each option is allowed (polymorphism).

练习 5:Sprite

Sprite 的 UML 类图
卷子上的 Sprite:两个构造器
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    出口:染成品红色(数据文件里 colour 是 magenta)
  2. 2
    问号箱:没有染色,原图是白色的
  3. 3
    白色感叹号:colour 是 white
  4. 4
    红色感叹号:colour 是 red
地图左上角(放大):同一个 Sprite 类,染色和不染色真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
图上有 Sprite(String) 和 Sprite(String, String) 两个构造器。这叫什么?截图上哪些物体用的是哪一个?

名字一样、参数不同的两个构造器叫重载(overload)。只给图片路径的 Sprite(String) 用来做问号箱(CMapParser 里 new Sprite(data.getText(...))),它把颜色设成 NO_TINT(空字符串)。感叹号和出口用两个参数的版本,第二个参数是颜色 white、red、lime 或 magenta。

hasTint(): boolean 是给谁用的?为什么 CSprite 只存颜色的名字,不自己染色?

CMapView 画每个物体时调用 sprite.hasTint(),是 true 才用 UiStyle.tint(...) 染色(MapView.java 第 78–79 行)。CSprite 在 world 包,类注释说它“does not need any JavaFX code”:规则那一边只存数据,画图全交给 ui 包。图上的线索是:CSprite 的字段只有 String,没有任何 JavaFX 类型。

自测

卷子上一张图的圆圈里是 I,名字是斜体。这是什么?能不能 new?代码里用什么关键字跟它连起来?

接口(interface)。不能 new,只能 new 实现了它的类。别的类用 implements 实现它,在那个类的图上是一条虚线加空心三角,指向这个接口。比如 CGameWindow implements IGameNavigator。

English: GameNavigator is an interface; GameWindow implements it, shown by a dashed line with a hollow triangle.

图上一行是红色实心方块 calculateHitChance(Fighter, Fighter): double。把它翻回 Java 的方法头。

private double calculateHitChance(Fighter user, Fighter target)。红色实心方块 = private 方法;冒号后面是返回类型 double,放到最前面;参数名图上没有,代码里是 user 和 target(ATargetedSkill 第 38 行)。

图上 CWait 没有构造器,代码里有吗?CPosition 图上为什么没有 equals?

有,public Wait()。UMLDoclet 不画“唯一的、没有参数的 public 构造器”。CPosition 写了 equals 和 hashCode,UMLDoclet 也不画这两个。所以图上没有的,代码里可能有。

实线三角和虚线三角有什么不同?COverworldScreen 的图上各是哪一条?

实线 + 空心三角 = extends(继承一个类),虚线 + 空心三角 = implements(实现一个接口),三角都指向父类或接口。COverworldScreen 的图上,实线指向 AGameScreen,虚线指向 IOverworldListener。父类方框里只写抽象方法,所以 AGameScreen 的方框是空的,IOverworldListener 的方框里有 5 个斜体方法。

English: OverworldScreen extends GameScreen and implements OverworldListener.

怎么从 UML 看出 CBattle 包含 CFighter?UML 能告诉你是谁 new 了这两个 CFighter 吗?

看字段格:player: Fighter 和 enemy: Fighter,类型是 CFighter,所以一个 CBattle 存着两个 CFighter。但谁 new 了它们,图上没有:是 CEncounter.createBattle 造了两个 CFighter,再 new 了 CBattle 把它们传进去。

English: The containership comes from the field types; the creation is not in the diagram, it happens in Encounter.createBattle.

CSkillCatalogue 的图上,find(String): Skill 前面是绿色实心圆,下面还有一条下划线。这两样各说明什么?别的类怎么调用它?

绿色实心圆 = public 方法,哪个类都能调用。下划线 = static:这个方法属于类本身,不属于哪一个对象,所以调用时直接写“类名.方法名”,不用先 new:CMokeponParser 第 46 行就是 SkillCatalogue.find(skillName.trim())。同一张图上的 SKILLS: List<Skill> 也带下划线:整个程序只有这一个技能列表。

English: find is a public static method, so MokeponParser calls it on the class, SkillCatalogue.find(name), without creating a SkillCatalogue object.

图上看不出 final。你的代码里哪三个 final 方法和设计模式有关?

ASkill 的 use(先扣能量,再调用 applyEffect)、ATargetedSkill 的 applyEffect(先判断打没打中,再调用 applyHit)、AGameScreen 的 handleKeyPress(对话框开着、或者按着 Ctrl / Alt / Shift 这类组合键时不管,组合键留给菜单快捷键;否则调用 handleKey)。三个都是模板方法:final 让子类不能改掉这个顺序,子类只能写被调用的那一步。

下一章把 72 个类按 9 个包摆在一起,看包和包之间谁依赖谁:第 4 章 项目地图。

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