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)。
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) {
- 红 1 → 第一格,类头:第 14 行
public class Battle变成图最上面的一格:浅绿色圆圈里一个 C,旁边写Battle,下面一行小字是它所在的包au.edu.unimelb.swen.oop.mokepon.battle。 - 红 2 → 第二格,字段:第 15–22 行的 8 个字段,变成第二格的 8 行。
private final Fighter player;写成player: Fighter:名字在前,冒号,类型在后。 - 红 3 → 第三格开头,构造器:第 25 行和第 30–31 行是两个构造器,图上是
Battle(Fighter, Fighter, Inventory)和Battle(Fighter, Fighter, Inventory, EnemyStrategy, Random)。名字和类名一样,后面没有返回类型。 - 红 4 → 第三格,方法:第 69 行
public void playTurn(Skill playerSkill)在图上是playTurn(Skill): void。第三格一共 2 个构造器、10 个方法,顺序和代码里一样。
一张打印图里可能出现的东西就这么多。下面这张示意图把它们放在一起,并写上本章哪一节讲哪一样:
图上的字段,屏幕上在哪
CBattle 在 battle 包里。它的类注释写着它“knows nothing about the screen”:它自己一个像素都不画。屏幕上的东西是 CBattleScreen 画的,而 CBattleScreen 是通过图上第三格的 4 个 public 方法,把 CBattle 字段里的东西读出来的。代码这边的红色数字和截图上的红框编号一样:
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 }
1234- 1
- 2右上敌人的卷轴:字段
enemy,用battle.getEnemy()拿到 - 3三个道具按钮上的 0、1、0:字段
inventory里每样东西有几个,用battle.getInventory()拿到 - 4日志框:字段
log里的每一行,用battle.getLog()拿到
另外 4 个字段:enemyStrategy、random 只在规则里用(挑敌人的技能、掷随机数),屏幕上看不到。finished、playerWon 屏幕上也没有直接画出来,但 CBattleScreen 会通过 isFinished()、hasPlayerWon() 读它们:打完了就把按钮变灰,再决定弹出 You Win 还是 Game Over(第 14 章)。记住这个读法:第二格告诉你这个类“存着什么”,第三格告诉你别的类“能让它做什么、能从它读什么”。
2. 类头:圆圈字母、斜体和包名
每张图最上面一格是类头。看三样东西:圆圈里的字母、名字是不是斜体、名字下面那行包名。下面是你代码里的四种类头,每张图下面是代码里的那一行声明。
I:接口 IEnemyStrategy

10public interface EnemyStrategy {| 圆圈 | 颜色 | 是什么 | 代码里写 | 名字 | 能直接 new 吗 | 你的项目里 |
|---|---|---|---|---|---|---|
| C | 浅绿 | 普通类 | class | 正体 | 能 | 55 个 |
| A | 浅蓝 | 抽象类 | abstract class | 斜体 | 不能,只能 new 它的子类 | 8 个 |
| I | 浅紫 | 接口 | interface | 斜体 | 不能,只能 new 实现它的类 | 4 个 |
| E | 橙红 | 枚举 | enum | 正体 | 不能,常量就是现成的几个对象 | 5 个 |
55 + 8 + 4 + 5 = 72。不是 C 的只有 17 个,最好全记住:
- 8 个抽象类(A):AGameScreen、AMapObject、AItem、AStatusEffect、ASkill、ATargetedSkill、AAttackSkill、ABoostSkill
- 4 个接口(I):IGameNavigator、IOverworldListener、IEnemyStrategy、IEnemyPicker
- 5 个枚举(E):EStat、ETerrain、ETerrainEffect、EMokeponType、EDirection
3. 成员行怎么读
类头下面的每一行是一个成员:字段、构造器或方法。UMLDoclet 把 Java 的写法换成 UML 的写法。拿 CFighter 来看:它是一只 Mokepon 在一场战斗里的样子(第 10 章细讲)。先看代码里挑出来的 6 行(红 1 到红 6),再看整张图和旁边的对照。
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) {
- 红 1,第 19 行
private static final int DAMAGE_SCALE = 50;→ 图上字段格第一行DAMAGE_SCALE: int(带下划线,第 6 节讲)。= 50和final都没画。 - 红 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,第 31 行构造器
public Fighter(Mokepon mokepon, TerrainEffect terrainEffect)→Fighter(Mokepon, TerrainEffect)。参数名 mokepon、terrainEffect 不画,只画类型。构造器没有返回类型。 - 红 4,第 52 行
public int getStat(Stat stat)→getStat(Stat): int。返回类型从最前面挪到了最后,前面加一个冒号。 - 红 5,第 82 行
public void takeDamage(int amount)→takeDamage(int): void。不返回东西也要写void。 - 红 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 |
final | CFighter 的 5 个字段全是 final,图上看不出 |
| 初始值 | DAMAGE_SCALE 是 50 |
| 方法里面写了什么 | playTurn 先让谁出手(第 11 章) |
| 唯一的、没有参数的 public 构造器 | public Bite() |
equals 和 hashCode | CPosition 写了这两个方法 |
表里倒数第二行最容易让人误会。看 CBite:
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}
代码第 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 能调用它们,把结果交给血条,写成“当前 / 最大”:
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 }
- 1225 / 225:
getHp()读stats里的 HP,getMaxHp()读startingStats里的 HP - 229 / 33:
getEnergy()和getMaxEnergy()。上一回合用了 Wait(能量最多 33,加不上去),这一回合用 Stun 花了 4 点:33 − 4 = 29
这里能看出两个字段的分工:stats 是“现在的”数值,会被扣;startingStats 是“开打时的”数值,不会变,用来当最大值。光看图上 startingStats: Map<Stat, Integer> 和 stats: Map<Stat, Integer> 两行,你只能猜;看第 22、24 行的注释才确定。(地图上卷轴写的 HP 是 150,这里却是 225,原因在下一节最后。)
4. 枚举的图:常量写在最上面
枚举(E)的图和普通类长得差不多,只是字段格最上面多了几行常量。拿 EStat 和 ETerrain 两张图对照 Stat.java:


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:第 5–10 行的 6 个常量 HP、ENERGY、STRENGTH、ACCURACY、DEFENCE、DODGE,画在字段格最上面,前面是绿色空心圆,带下划线,没有类型。意思是:每个常量都是 public、static 的,类型就是 EStat 自己。括号里的
("HP", 1, 1000)不画。 - 红 2:第 12–14 行三个 private 字段
label、minimum、maximum。每个常量自己存一份,比如 HP 存着 "HP"、1、1000。 - 红 3:第 16 行的构造器
Stat(String, int, int)。代码里没写 private,图上却是红色实心方块:枚举的构造器 Java 自动当成 private(Java 自动加上的修饰符),所以别的类不能new Stat(...),世界上只有这 6 个 Stat。 - 红 4:第 27 行
limit(int): int,把一个数压回这个属性的合法范围(第 28 行:比最小值小就变成最小值,比最大值大就变成最大值)。CFighter 的构造器用它(Fighter.java第 34 行),这一节最后会看到它真的起作用。 - ETerrain 的图一样:7 个常量 BARRIER……WATER 在最上面,然后是两个 private 字段
symbol: char、walkable: boolean,红色实心方块的构造器Terrain(char, boolean)。最后一行fromSymbol(char): Terrain是绿色实心圆加下划线:一个 public static 方法(第 6 节),CMapParser 读地图文字时用它把一个字母变成一个 ETerrain。
每个枚举还自带一个 values() 方法(枚举自带的方法),按顺序给出所有常量,图上不画。地图右上角卷轴里的 6 行就是这样来的:
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 }
- 1HP 到 Dodge 这 6 行:
Stat.values()的 6 个常量,顺序和Stat.java第 5–10 行一样。左边的字是stat.getLabel()加一个冒号,右边是第 52 行的mokepon.getStat(stat)
再看 EDirection(第 2 节的 E 卡片)。它的 4 个常量各存两个数:UP(0, -1)、DOWN(0, 1)、LEFT(-1, 0)、RIGHT(1, 0),就是图上的 xChange: int 和 yChange: int。CPosition 的 move(Direction) 用它们算出下一格。下面是真实的走路截图(白色星形就是你的小人),每按一次键,就是用一个 EDirection 常量走一格:




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 }
- 1按 ↑ 以后:第 27 行
move(Direction.UP)算出 (18 + 0, 18 + (−1)) = (18,17),白色星形的小人画到这一格(正上方就是问号箱) - 2↑ 按钮:点它和按 ↑ 键一样,都用
Direction.UP
第 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:三种图标在同一张图里
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() {
- 红 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 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。
- 玩家点 → 按钮(按 → 键也一样)。
- COverworldScreen 调用自己的 private 方法
movePlayer(RIGHT)。 - 它只能调用 COverworld 的 public 方法
move(RIGHT)。 - COverworld 和 CPlayer 都在 world 包,所以能调用包内可见的
moveTo(...),把位置改成 (11,18)。 - COverworld 通过 IOverworldListener 接口回头调用界面的
onPlayerMoved(),界面重画(第 7 节最后)。这一条是 COverworld 主动调用界面,不是返回,所以是实线。
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());- 第 66 行(红 1)
public void move:ui 包的 COverworldScreen 能调用的入口。 - 第 67–69 行:
canMove说走不过去(比如前面是海),就直接return,什么都不改。 - 第 70 行(红 2):
player.getPosition().move(direction)先用Position.move算出新格子(第 4 节),再交给包内可见的moveTo。 - 第 71 行:通知所有监听者“玩家动了”。
listener -> listener.onPlayerMoved()是一个 lambda(第 2 章第 8 节):一小段“对拿到的监听者调用onPlayerMoved()”的代码,交给notifyListeners对每个监听者执行一次。观察者模式在 第 8 章第 6 节。

- 1→ 按钮:COverworldScreen 只能调用 public 的
world.move(Direction.RIGHT)(按 → 键也走到这里) - 2
如果 COverworldScreen 写 world.getPlayer().moveTo(...),编译就不通过,因为 moveTo 不是 public。这样“能不能走、走完要做什么(踩到箱子、遇敌)”的规则全在 COverworld 里,界面绕不过去。
protected:留给子类用的
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) {
- 红 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):

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
下划线:属于类本身
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 }
带下划线的成员是 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,原因看屏幕就明白了:

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 }
11280 × 76823背景 bg.png- 1整个游戏区 1280 × 768(底边这条线就是 1280 宽):AGameScreen 的构造器用
UiStyle.GAME_WIDTH和UiStyle.GAME_HEIGHT定画面大小(GameScreen.java第 20 行),CGameMenu 也用GAME_WIDTH定菜单栏的宽。别的类要用,所以是 public - 2卷轴图片
assets/scroll.png:路径存在 public 的SCROLL_FILE里,CPlayerStatsScroll、CFighterScroll、CSelectScreen 三个类都要用 - 3棕色背景
assets/bg.png(地图外面的棕色都是它):路径存在 private 的BACKGROUND_FILE里,只有 CUiStyle 自己的createBackground()(第 67 行)用它,别的类调用这个方法就行
斜体:抽象的,只有方法头
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);
- 红 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)。前两张图是这样的:


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

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 @Override20 protected String applyEffect(Fighter user, Fighter target, Random random) {21 return MESSAGES.get(random.nextInt(MESSAGES.size()));22 }
- 1你的 Wait:返回了第 13 行那句 “They think about what they will have for dinner.”
- 2敌人的 Wait:返回了第 12 行那句 “They wonder if this is a good strategy.”
第 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(继承)

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

4public class Bite extends AttackSkill {9public class RandomEnemyStrategy implements EnemyStrategy {父类的方框里只写名字和父类的抽象方法(斜体),也就是这个类必须有实现的那几个;父类的字段、构造器、普通方法一概不写。所以 CBite 图上的 AAttackSkill 方框是空的(AAttackSkill 已经没有抽象方法了),CRandomEnemyStrategy 图上的 IEnemyStrategy 方框里有 chooseSkill(Fighter, Random): Skill。
父类也可以不是你写的。CMain 的图上方是 JavaFX 的 Application(包名 javafx.application):

191public class Main extends Application {⋯ 省略第 20–20 行21 @Override222 public void start(Stage stage) {⋯ 省略第 23–41 行423 public static void main(String[] args) {43 launch(args);44 }- 红 1:
public class Main extends Application→ 实线三角指向Application。它的圆圈是 A:Application是抽象类。 - 红 2:
Application方框里斜体的start(Stage): void是它的抽象方法,CMain 在第 22 行写出来了,所以 CMain 自己的方框里也有一行正体的start(Stage): void。 - 红 3:
public static void main(String[] args)→main(String[]): void,带下划线,因为是 static。String[]是字符串数组:运行程序时命令行上给的几个参数。
同时 extends 和 implements:OverworldScreen
241public class OverworldScreen extends GameScreen implements OverworldListener {⋯ 省略第 25–71 行72 @Override732 public void onPlayerMoved() {⋯ 省略第 74–78 行79 @Override802 public void onEncounter(Encounter encounter) {⋯ 省略第 81–95 行96 @Override972 public void onItemCollected(Item item) {⋯ 省略第 98–101 行102 @Override1032 public void onEncounterWon(Mokepon enemy) {⋯ 省略第 104–107 行108 @Override1092 public void onExitReached(int fightsWon) {⋯ 省略第 110–114 行115 @Override1163 protected void handleKey(KeyEvent event) {
- 红 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 常量(按钮大小、位置等)。

CGameWindow 的图也一样:虚线三角指向接口 IGameNavigator,接口方框里 7 行斜体方法;CGameWindow 自己的方框里,7 个同名方法全是正体:它全部写了。
谁调用这 7 个方法?图上看不出。答案是各个画面:它们只存着一个 GameNavigator(AGameScreen 的字段 navigator),比如遇敌框的 Fight! 按钮调用 getNavigator().startBattle()。画面不知道窗口是 CGameWindow,只知道这个接口(第 6 章第 2 节)。
只往上画一层
每张图只画直接的父类。想知道 CBite 到底能做什么,要把 4 张图拼起来:
CBite 自己只有两个常量。打出伤害的代码在 AAttackSkill,判断打没打中在 ATargetedSkill,扣能量在 ASkill。考试时如果被问“Bite 被用的时候发生了什么”,要顺着这条链往上读(第 12 章)。
实现了接口的方法,什么时候被调用
虚线三角只说“COverworldScreen 写了这 5 个方法”,没说谁调用它们。看捡到问号箱的那一刻。先看规则这一边,COverworld:
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 }- 第 111 行
collect(MysteryBox box):问号箱的interact调用它(CMysteryBox 第 20 行world.collect(this))。它前面没写修饰符,是包内可见的。 - 第 112–113 行:把箱子从地图上拿走,把道具放进背包。
- 第 114 行(红 1):让每个监听者执行
onItemCollected(box.getItem())。 - 第 125 行:参数
message的类型Consumer<OverworldListener>是 Java 自带的一个接口,意思是“一段拿到一个 IOverworldListener 以后要执行的代码”。第 114 行传进来的那个 lambda 就是这样一段代码。 - 第 126–128 行(红 2):
notifyListeners用 for-each 走一遍listeners列表,第 127 行message.accept(listener)把当前这个监听者交给那段 lambda 执行,也就是调用它的onItemCollected(...)。
- COverworld.move 发现小人站在箱子上,调用
object.interact(this)(第 76 行)。 - 箱子回头调用
world.collect(this)。 collect调用notifyListeners(第 114 行)。- 列表里的监听者是 COverworldScreen,于是它的
onItemCollected(item)被调用。 - 它在状态栏写上一句话(第 98 行)。
- 它调用自己的
update()(第 99 行)。 update()里statsScroll.update()(第 136 行)重新写卷轴里的数字。
96 @Override97 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();
- 1状态栏:第 98 行写上 “You picked up a Poison!”
- 3小人站在 (18,16):问号箱已经被
collect第 112 行拿走了,这一格只剩小人

- 2卷轴里 Poison 变成 1:第 99 行
update()里的statsScroll.update()(第 136 行)重新写了数字
COverworld 的图上有一个字段 listeners: List<OverworldListener>,类型是接口,不是 COverworldScreen:COverworld 根本不知道界面长什么样。把 COverworldScreen 放进这个列表的是 CGameWindow.startGame 里的 world.addListener(overworldScreen)(CGameWindow 第 61 行)。这就是观察者模式(Observer),第 8 章第 6 节细讲。这些“谁调用谁”,图上全都没有。
8. 图上没有画的东西:从字段类型推出“谁存着谁”
考试的 Q1 常问 interaction:为了完成一件事,类和类怎么配合。要说清三件事:谁存着谁(containership)、谁创建谁(instantiation)、谁调用谁。打印的图上一条关系线都没有,但第一件事可以从字段格推出来:
- 字段的类型是项目里的类 → 这个类的对象存着一个那个类的对象(has-a)。
- 类型是
List<X>→ 存着好几个 X(可以是 0 个)。类型是Map<K, V>→ 存着好几对“K → V”;K 或 V 是项目里的类,就和它有关系。比如 CInventory 的counts: Map<Item, Integer>:键是 AItem,所以 CInventory 和 AItem 有关系。 - 类型是接口或抽象类 → 真正放进去的是它的某个子类的对象,要去代码里找 new。
- 类型是 int、boolean、String,或者 Java 自带的类(
Random、List<String>)→ 卷子上没有它们的图。答 containership 时只说项目里的类。
用这四条读 CBattle 的字段格。点“下一步”,关系图会一条一条亮起来(亮的框和红色的线就是这一步推出来的):


- 1Poison 按钮上的 1:就是字段
inventory这个背包里 Poison 的个数

- 1日志第一行:构造器第 37–38 行
log.add(...)加进去的
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() + "!");- 从类头开始:CBattle,C,普通类。原图只看第二格的 8 行,它们就是下面代码的第 15–22 行。关系图是我们要推出来的,现在只有 CBattle 亮着。一行一行看。
- 前两行
player: Fighter、enemy: Fighter:类型是项目里的类 → CBattle 存着两个 CFighter,一个是你的,一个是敌人的。关系图上画一条空心菱形线,标上两个字段名。 - 第三行
inventory: Inventory→ 存着一个 CInventory(背包),截图上 Poison 按钮的 1 就是它里面的数。注意第 34 行this.inventory = inventory:背包是从构造器参数传进来的,不是 CBattle new 的。它是 COverworld 开局时 new 的那一个,CPlayer 也存着它,所以战斗里用掉的道具,回到地图也少了。 - 第四行
enemyStrategy: EnemyStrategy:类型是接口(I)→ 图上看不出真正的对象是什么。去代码里找 new:第 26 行。this(...)写在构造器第一行,意思是“调用同一个类的另一个构造器”(构造器链,第 2 章第 2 节):三个参数的构造器自己什么都不存,补上new RandomEnemyStrategy()和new Random(),交给五个参数的构造器去存。所以关系图上再加一条虚线三角:CRandomEnemyStrategy implements IEnemyStrategy。 - 第五行
random: Random:Random是 Java 自带的随机数类,卷子上没有它的图,所以关系图上只画一条细线,答 containership 时不用提它。它也是第 26 行 new 的。技能打没打中、敌人用哪个技能,都靠它。 - 第六行
log: List<String>→ 存着很多行字,String 也是 Java 自带的,所以也是细线。第 37–38 行的构造器一开始就加了第一行 “Player chose ... Began a fight against enemy ...!”,就是截图里日志框的第一行。 - 最后两行
finished: boolean、playerWon: boolean:只是两个是 / 否的值(打完了没有、你赢了没有),不是对象,不画线。 - 全部推完。考试可以这样写:Battle has two Fighter fields, player and enemy, and also holds the Inventory and an EnemyStrategy (a RandomEnemyStrategy by default). 但是这张图还回答不了:是谁 new 了这个 Battle 和两个 Fighter?答案在下面。
谁创建谁:图上完全看不出来
点 Fight! 以后,这几个对象是这样造出来的(虚线箭头 = 返回,绕回自己的箭头 = 调用自己的方法):
- 玩家在遇敌框里点 Fight!,COverworldScreen 调用
getNavigator().startBattle()(OverworldScreen.java第 87 行)。 - CGameWindow.startBattle 先调用
world.createBattle()。 - COverworld 把背包交给
encounter.createBattle(player.getInventory())。 - CEncounter 在第 54–56 行 new 两个 CFighter 和一个 CBattle,把 CBattle 一路返回。
- 回到 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)。放进去的对象可以是它的子类,或者实现了它的类(多态)。你的代码里最常被问到的四个:
| 类 | 字段 | 图上的类型 | 真正的对象 |
|---|---|---|---|
| CBattle | enemyStrategy | IEnemyStrategy | CRandomEnemyStrategy(第 26 行) |
| CGameWindow | currentScreen | AGameScreen | 三个画面中正在显示的那个 |
| CFighterPanel | hpBar | CStatBar | 你的面板里是 CLabelledStatBar |
| CFighter | stats | Map | EnumMap(第 25 行) |
图上看得出
有哪些类、每个类存着什么(字段格)、能做什么(方法格)、谁能调用(小图标)、谁继承谁、谁实现哪个接口(箭头)。
9. 考场上读一张图:一步一步
考试时间紧,读一张图要有固定的顺序。下面用 CFighter 走一遍:一边是卷子上的原图,一边是照着它画的示意图(每一块和原图同样高度的那几行对应,每一步亮起来的那一块,就是原图上同一个位置),下面是代码。

1package au.edu.unimelb.swen.oop.mokepon.battle;⋯ 省略第 2–17 行18public class Fighter {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<>();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 }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() {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) {118 public int calculateDamage(Fighter target, double power) {119 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE120 / 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 步,圆圈和名字:圆圈是 C,名字是正体 → 普通类,能 new。英语:Fighter is a concrete class.
- 第 2 步,包名和箭头:名字下面的包是 battle(代码第 1 行),说明它是战斗规则的一部分,不是界面。原图上方没有别的方框、没有箭头 → CFighter 不继承项目里的类,也不实现接口。
- 第 3 步,带下划线的字段:
DAMAGE_SCALE: int,红色空心方块 + 下划线 = private static。所有 CFighter 共用这一个值。值是 50,图上看不到,要记得伤害公式里乘的就是它(第 12 章)。 - 第 4 步,普通字段 = 它存着什么:一只 CMokepon(
mokepon);两个Map<Stat, Integer>(开打时的数值和现在的数值,各 6 项);一个List<StatusEffect>(身上的 0 个或多个效果,比如流血)。全是红色空心方块:private。 - 第 5 步,构造器 = 造它要给什么:
Fighter(Mokepon, TerrainEffect),要一只 Mokepon 和一个地形效果。第 32 行this.mokepon = mokepon:左边this.mokepon是字段,右边是参数,把参数存进字段。第 34 行从里往外读:取本来的数值 → 按地形乘 →limit压回范围(第 4 节的 225 就是这样来的),再存进两个 Map。谁来造?图上没写,记住是 CEncounter.createBattle,一场仗造两个。 - 第 6 步,get 方法 = 别人能读什么:名字、图片、技能、数值、HP、能量……全是绿色实心圆 public。屏幕上的 “225 / 225” 就是 CFighterPanel 调用
getHp()和getMaxHp()得来的(第 3 节)。 - 第 7 步,其余 public 方法 = 别人能让它做什么:
takeDamage(int)扣血、spendEnergy(int)花能量、addEffect(StatusEffect)加效果、calculateDamage(Fighter, double): int算伤害……数值只能通过这些方法改(封装)。比如 AAttackSkill 打中时调用target.takeDamage(damage)。 - 第 8 步,private 方法 = 只给自己用:
getDamageMultiplier()是红色实心方块,只有 CFighter 自己的calculateDamage调用它(第 121 行),用来算眩晕把伤害减半。第 121 行的(int)是强制类型转换:把 double 的小数部分直接砍掉,变成 int。 - 第 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 条的清单,考试时每张图都照着走一遍:
- 圆圈字母:C / A / I / E?名字斜体吗?(斜体 = 不能 new;E 是正体,也不能 new)
- 包名:属于程序的哪一块?上面有没有父类 / 接口的方框和箭头?实线还是虚线?
- 带下划线的成员:哪些是 static?
- 字段的类型:它存着哪些对象?一个还是一串(List / Map)?
- 构造器的参数:造它要先有什么?
- public 方法:别的类能读什么、能让它做什么?
- protected / 包内可见 / private:哪些只给子类、同一个包、自己用?
- 斜体的方法:哪些是抽象的,要子类来实现?
练习 1:TargetedSkill
先看卷子上的图,再看它在游戏里留下的日志,然后答下面两道题(点题目看答案):


- 1Stun 打中了:日志里这一句是 CStun 写的
applyHit返回的 - 2敌人的 Tackle 没打中:“But they missed.”
看图: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


- 1Poison 按钮写着 1,可以点
- 2Health Tonic 和 Coffee 写着 0,是灰的
从图上看,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


- 1标题里的 Hexwing:
getEnemy(): Mokepon给的 - 2两行地形提示:
getTerrainMessages(): List<String>给的 - 3点 Fight! 以后才会用到
createBattle(Inventory): Battle
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

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 }
- 1Run away:灰的(字段
runAway,第 47 行把它设成灰) - 2Main menu:能点(字段
mainMenu) - 3Exit:能点(第 28 行的局部变量
exit)

- 1Run away:在战斗里能点了
- 2Main menu:能点
菜单里有三个菜单项(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


- 1出口:染成品红色(数据文件里 colour 是 magenta)
- 2问号箱:没有染色,原图是白色的
- 3白色感叹号:colour 是 white
- 4红色感叹号:colour 是 red
图上有 Sprite(String) 和 Sprite(String, String) 两个构造器。这叫什么?截图上哪些物体用的是哪一个?
名字一样、参数不同的两个构造器叫重载(overload)。只给图片路径的 Sprite(String) 用来做问号箱(CMapParser 里 new Sprite(data.getText(...))),它把颜色设成 NO_TINT(空字符串)。感叹号和出口用两个参数的版本,第二个参数是颜色 white、red、lime 或 magenta。
hasTint(): boolean 是给谁用的?为什么 CSprite 只存颜色的名字,不自己染色?
自测
卷子上一张图的圆圈里是 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.
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 章 项目地图。

