Mokepon代码导读
第五站 · 收尾与考试L4约 45 分钟

16设计模式和 OOP 原则

模板方法、策略、观察者、依赖倒置、封装、SOLID 在你代码里的具体位置,以及和 Project 1 的对比

读完这一章你能
  • 对每一个模式(模板方法、策略、观察者)说出:中文意思、英文名、在你代码里的哪个类、哪一行、为什么在这里有用、哪里没有完全做到
  • 讲清楚依赖倒置:IGameNavigator 和 IOverworldListener 两个接口怎样把“箭头”倒过来
  • 说出封装的五种做法(private、复制进来、复制出去、只读视图、包内可见),并指出你的代码里还剩下的隐私泄露
  • 用一句话说出 SOLID 五条在你代码里的具体位置
  • 用课程用语 instantiation、delegation、containership(association / aggregation / composition)描述真实的类对
  • 说明 CSkillCatalogue、CItemCatalogue、CUiStyle 为什么不是单例模式
  • 拿 Project 1 和 Project 2 逐条对比:5 个类 vs 72 个类,0 个接口 vs 4 个接口

前面十几章一个类一个类地读过你的代码。这一章换一个方向:按“设计”来整理。考试时卷子上印着你的 UML,题目会问 “Which design pattern did you use? Where? Why?”。这一章就是这些问题的答案库。

每一个模式和原则都按同样的五步讲:

  1. 是什么:一句中文,一句英文。
  2. 在哪里:你代码里的类、方法、行号(代码块)。
  3. 画出来:一张图。
  4. 为什么在这里有用:用 “因为……所以……” 说。
  5. 哪里没有完全做到:老实说。考试问 “What would you improve?” 时就用它。

先提醒几个前面学过的词(细讲在 第 1 章、第 2 章):接口(interface)只写“有哪些方法”,不写怎么做;implements 表示一个类答应把接口里的方法都写出来;抽象类(abstract class)不能 new,只能被继承;重写(override)是子类写一个同名同参数的方法,换掉父类的做法。彩色带圆圈的名字(CBattle)是你项目里的类,点一下能看它的卡片。

1. 先看全图:模式和原则在屏幕的哪里

设计模式听起来很抽象,其实每一个都能在屏幕上找到它“干活”的地方。先看战斗中的一帧(你的 Impike 刚对 Galecrest 用了 Stun):

用 Stun 命中:敌人被眩晕,3 回合内伤害减半1234567
  1. 1
    Stun 按钮:点下去,CBattle 调用 skill.use(...)。use 是 final 的模板方法,先扣能量再生效(第 2 节)
  2. 2
    “Enemy Galecrest used Tackle!”:敌人出哪一招,是 CBattle 交给 IEnemyStrategy 决定的:策略模式(第 3 节)
  3. 3
    整个日志:CBattleScreen 用 battle.getLog() 读出来。拿到的是只读视图,改不了(封装,第 6 节)
  4. 4
    你的 HP 条写着数字:它是 CLabelledStatBar 对象(第 7 节,多态)
  5. 5
    敌人的 HP 条没有数字:同一个字段类型 CStatBar,装的是普通 CStatBar 对象
  6. 6
    三个道具按钮的顺序来自 ItemCatalogue.getBattleOrder():静态目录类(第 9 节)
  7. 7
    Game 菜单:CGameMenu 只认识接口 IGameNavigator,不认识 CGameWindow:依赖倒置(第 5 节)
战斗画面:七个红框,六种设计真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

再看打赢以后回到地图的那一帧:

点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示12333445
  1. 1
    “You beat the enemy Galecrest!”:规则类 COverworld 发出通知 onEncounterWon,画面收到后写这句话:观察者模式(第 4 节)
  2. 2
    玩家:只有 world 包里的 COverworld 能挪动它(moveTo 是包内可见的):封装(第 6 节)
  3. 3
    红、绿、白三种感叹号是同一个类 CMarker。颜色来自每组自己的 Sprite(数据文件里的 .colour 键);挑哪只敌人由字段 picker 里的 IEnemyPicker 决定:策略模式(第 3 节)
  4. 4
    问号箱、感叹号、门:踩上去都是同一句 object.interact(this),各做各的事:多态(第 7 节)
  5. 5
    卷轴的三行道具:来自 inventory.getItems() 交出的副本(第 6 节)
地图画面:同样能找到观察者、策略、封装和多态真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

一张表把全章串起来。最后一列先写本章第几节,再写以前细讲过的章;这一章不再重复细节,只讲“设计”。

模式 / 原则你代码里最主要的位置本章 · 细讲
模板方法
Template Method
ASkill.use、ATargetedSkill.applyEffect、AGameScreen.handleKeyPress§2 · 12、6
策略
Strategy
IEnemyStrategy(CBattle)、IEnemyPicker(CMarker)§3 · 9、11
观察者
Observer
COverworld → IOverworldListener ← COverworldScreen§4 · 8
依赖倒置
Dependency Inversion
IGameNavigator、IOverworldListener§5 · 6
封装
Encapsulation
private 字段、复制、只读视图、包内可见§6 · 10
继承和多态
Inheritance, Polymorphism
AItem、AMapObject、AStatusEffect、AGameScreen、CStatBar§7 · 13、14
SOLID
SRP, OCP, LSP, ISP, DIP
全项目§8
课程用语、静态目录类
instantiation, delegation, containership
CEncounter、CBattleScreen、CBattle、CSkillCatalogue§9 · 10、4
和 Project 1 比5 个类 → 72 个类§10

2. 模板方法(Template Method)

是什么。模板方法:父类里有一个 final 方法,把几个步骤的先后顺序写死;其中某一步不写死,留给子类来写。子类能改步骤的内容,改不了顺序。英文:A final method in the superclass fixes the order of the steps, and subclasses fill in some of the steps.

留给子类的那一步,英文有两种叫法,考试时别混:子类必须写的 abstract 那一步叫抽象步骤(primitive operation),比如 applyEffect、applyHit;父类已经给了一个默认做法、子类想改才改的那一步叫钩子方法(hook method),比如 handleKey(默认什么都不做)。

在哪里:一共三处

模板方法(final,定顺序)留给子类的那一步(谁来写)
ASkill.use(第 30 行):① 付能量 ② 生效applyEffect(abstract):ATargetedSkill、ABoostSkill、CWait 写
ATargetedSkill.applyEffect(第 18 行):① 判断命中 ② 没中就返回 miss ③ 中了才生效applyHit(abstract):AAttackSkill、CPocketSand、CStun 写(CLance 再改写 AAttackSkill 的版本)
AGameScreen.handleKeyPress(第 32 行):① 对话框开着或组合键就不理 ② 否则交给画面handleKey(有一个空的默认版本):COverworldScreen(方向键)、CBattleScreen(数字键 1–4)改写
Skill.use:final 的模板方法,applyEffect 留给子类skill/Skill.java第 27–36 行
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 */
30 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. */
36 protected abstract String applyEffect(Fighter user, Fighter target, Random random);

第 30 行的 final 意思是:没有任何子类能重写 use。所以 10 个技能出手时,一定先执行第 31 行 user.spendEnergy(energyCost),再执行第 32 行 applyEffect(...)。第 36 行的 applyEffect 是抽象方法:只有方法头,没有 { },每家子类自己写。

第 ① 步在屏幕上看得见:点 Stun 之前和之后,你的能量条。

用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
1
  1. 1
    点 Stun 之前:33 / 33
点 Stun 之前(上一回合用了 Wait:33 − (−2) = 35,超过上限,spendEnergy 把它压回 33)真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    之后:29 / 33。第 31 行先扣 4(Stun 的 ENERGY_COST),33 − 4 = 29
点 Stun 之后真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

第 ② 步走进 ATargetedSkill。它又是一个模板方法:它改写了 applyEffect,又把自己写成 final,在里面再留一步 applyHit 给下一层。同一回合的日志里,两种结果都出现了:

TargetedSkill:final 的 applyEffect,再留一步 applyHitskill/TargetedSkill.java第 17–26 行
17 @Override
18 protected final String applyEffect(Fighter user, Fighter target, Random random) {
19 if (!hits(user, target, random)) {
202 return MISS_MESSAGE;
21 }
221 return applyHit(user, target, random);
23 }
24
25 /* What the skill does once it has hit the target. */
26 protected abstract String applyHit(Fighter user, Fighter target, Random random);
Stun 只写 applyHit:打中以后做什么skill/Stun.java第 15–20 行
15 @Override
16 protected String applyHit(Fighter user, Fighter target, Random random) {
17 target.addEffect(new Stunned());
181 return "\t" + target.getName() + " was stunned! Their damage is halved for "
19 + Stunned.TURNS + " turns.";
20 }
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12
  1. 1
    你的 Stun 打中了:hits(...) 为 true,第 22 行调用 CStun.applyHit,返回这句话(第 18 行)
  2. 2
    敌人的 Tackle 没打中:hits(...) 为 false,第 20 行返回 MISS_MESSAGE
同一个 final 方法,两条路:打中(红 1),没中(红 2)。放大了日志的最后四行真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

注意红 2:Tackle 是 AAttackSkill 的子类,Stun 是 ATargetedSkill 的直接子类,可它们“没打中”的那句话完全一样,因为这句话只写在 ATargetedSkill 第 11 行一个地方。

运行时的调用顺序是这样的:CBattle 只调用了一次 use,剩下的步骤由父类一层一层往下交:

CBattleCstun : StunCplayer : FighterCenemy : Fighteruse(player, enemy, random)1Skill 写的 final use:先付能量spendEnergy(4)2applyEffect(...)3TargetedSkill 写的 final applyEffect:先判断命中hits(...) → true4applyHit(...)5Stun 写的 applyHit:打中以后做什么addEffect(new Stunned())6返回那句日志7
点 Stun 以后的调用顺序。stun 是同一个对象,可它身上的三个方法写在三层类里:use 在 Skill,applyEffect 在 TargetedSkill,applyHit 在 Stun。父类写好的 final 方法,在运行时调用了子类写的那一步

想看这段代码一行一行亮起来的动画,见 第 12 章第 8 节。下面这张图把整棵技能树画出来,每个框下面一行写这个类自己写了哪个方法:

抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillfinal use() → applyEffect()抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillfinal applyEffect() → applyHit()抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkill写 applyEffect:加属性技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWait写 applyEffect:说一句话抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkill写 applyHit:算伤害技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSand写 applyHit:致盲技能 Stun:花 4 点能量,没有伤害,但让目标伤害减半 3 回合。CStun写 applyHit:眩晕技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLance改写 applyHit:super + 流血
技能家族里的两层模板方法(实线 + 空心三角 = extends)。框下面一行:这个类自己写的方法;final = 锁死的步骤顺序,→ 后面是留给子类的那一步。Bite、Tackle 只把能量和威力交给 AttackSkill 的构造器,Aim、Bunker、Nimble、Swole 只把一个 Stat 交给 BoostSkill,它们不写任何方法,图里省略
Skill 的 UML 类图
打印稿里的 Skill:斜体名字 = abstract 类,斜体方法 = abstract 方法(applyEffect)。注意 use 前面看不出 final,考试时要你自己说出来。红色方块 = private,绿色圆点 = public,黄色菱形 = protected

第三处:画面处理按键

画面也用了同一个模式。对话框开着时,按方向键玩家不会动。键盘这条路的规矩写在父类 AGameScreen 的 final 方法 handleKeyPress 里:

GameScreen:final 的 handleKeyPress,空的 handleKeyui/GameScreen.java第 28–39、51–53 行
28 /* Template Method: key presses are ignored while a dialog is open, and key combinations
29 * are left for the menu shortcuts (Ctrl+R, Ctrl+B and Alt+F4).
30 * Any other key is passed to the screen.
31 */
32 public final void handleKeyPress(KeyEvent event) {
33 boolean isCombination = event.isControlDown() || event.isAltDown()
34 || event.isMetaDown() || event.isShiftDown();
351 if (isDialogOpen() || isCombination) {
36 return;
37 }
38 handleKey(event);
39 }
⋯ 省略第 40–50 行
51 /* Screens with keyboard controls override this. Other screens ignore the keys. */
52 protected void handleKey(KeyEvent event) {
53 }
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1
  1. 1
    遇敌框开着:isDialogOpen() 是 true,第 35–36 行直接 return,方向键根本到不了 COverworldScreen.handleKey
对话框开着时按键被挡下:挡的代码在父类真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

两个画面改写的那一步:

OverworldScreen 填的那一步(方向键),和它自己的 movePlayerui/OverworldScreen.java第 114–131、173–173 行
114 /* The arrow keys move the player. Other keys are not used here. */
115 @Override
116 protected void handleKey(KeyEvent event) {
117 Direction direction = ARROW_KEYS.get(event.getCode());
118 if (direction != null) {
119 movePlayer(direction);
120 event.consume();
121 }
122 }
123
124 /* The overworld does nothing if the move is not possible.
125 * Nothing can move while a dialog is open.
126 */
127 private void movePlayer(Direction direction) {
128 if (!isDialogOpen()) {
129 world.move(direction);
130 }
131 }
⋯ 省略第 132–172 行
173 button.setOnAction(event -> movePlayer(direction));
BattleScreen 填的那一步:数字键 1–4ui/BattleScreen.java第 107–115 行
107 /* Keys 1 to 4 press the matching skill button. A disabled button ignores the key. */
108 @Override
109 protected void handleKey(KeyEvent event) {
110 int index = SKILL_KEYS.indexOf(event.getCode());
111 if (index >= 0 && index < skillButtons.size()) {
112 skillButtons.get(index).fire();
113 event.consume();
114 }
115 }

CSelectScreen 没有改写 handleKey,用的就是 AGameScreen 第 52–53 行那个空的默认版本:选择界面按什么键都没反应。细节见 第 6 章第 4 节。

为什么在这里有用

3. 策略(Strategy)

是什么。策略模式:把一件“可以有好几种做法”的事写成一个接口,每种做法写成一个实现类;使用它的类用一个接口类型的字段存着其中一个对象,需要时调用接口的方法,不关心背后是哪一种做法。换一个对象,就换了做法,使用者一行不改。英文:The context class holds a strategy object through an interface-typed field and calls it; plugging in a different strategy object changes the behaviour without changing the context.

你的代码里有两个策略接口,各只有一个方法:

EnemyPicker:在感叹号上挑哪只敌人world/EnemyPicker.java第 8–13 行
8/* Strategy: chooses which Mokepon the player meets at a marker.
9 * Random, strong and weak markers only differ in the picker they use.
10 */
11public interface EnemyPicker {
12 Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random);
13}
EnemyStrategy:敌人每回合出哪一招battle/EnemyStrategy.java第 6–12 行
6/* Strategy: decides which skill the enemy uses each turn.
7 * The battle only knows this interface, so a smarter enemy can be added later
8 * without changing the Battle class.
9 */
10public interface EnemyStrategy {
11 Skill chooseSkill(Fighter enemy, Random random);
12}

两个接口的注释第一个词都是 “Strategy”。下面分开看。

EnemyPicker:三种感叹号,一个 Marker 类

地图上的感叹号有三种颜色。它们不是三个类,都是 CMarker。CMapParser 造它们时,每一组交进去两样东西:一个 picker 对象(决定挑哪只敌人),一个 Sprite(图片和颜色):

MapParser:每一组配一个 picker 对象,和一个带颜色的 Spritedata/MapParser.java第 29–35、56–58、63–63 行
29 /* Each marker group has its own way of choosing the enemy. */
30 List<MapObject> objects = new ArrayList<>();
311 objects.addAll(parseMarkers(data, "map.mokeponRandom", new RandomEnemyPicker()));
322 objects.addAll(parseMarkers(data, "map.mokeponStrong",
33 new TerrainEnemyPicker(TerrainEffect.ADVANTAGE)));
343 objects.addAll(parseMarkers(data, "map.mokeponWeak",
35 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)));
⋯ 省略第 36–55 行
56 /* All markers in one group share the same image, colour and picker. */
57 private List<MapObject> parseMarkers(GameData data, String group, EnemyPicker picker) {
58 Sprite sprite = new Sprite(data.getText(group + ".image"), data.getText(group + ".colour"));
⋯ 省略第 59–62 行
63 markers.add(new Marker(position, sprite, picker));
mokepon.game:三组感叹号的位置、图片和颜色mokepon.game第 439–456 行
439map.mokeponRandom.0=3,3
440map.mokeponRandom.1=14,6
441map.mokeponRandom.2=12,12
442map.mokeponRandom.3=4,10
443map.mokeponRandom.image=assets/sprite_exclamation.png
4441map.mokeponRandom.colour=white
445
446map.mokeponStrong.0=5,4
447map.mokeponStrong.1=16,4
448map.mokeponStrong.2=12,16
449map.mokeponStrong.image=assets/sprite_exclamation.png
4502map.mokeponStrong.colour=red
451
452map.mokeponWeak.0=2,12
453map.mokeponWeak.1=6,16
454map.mokeponWeak.2=17,17
455map.mokeponWeak.image=assets/sprite_exclamation.png
4563map.mokeponWeak.colour=lime
向下一格到 (18,17),左边就是绿色感叹号(weak marker)
1233
  1. 1
    白色(map.mokeponRandom,colour=white):picker 是 new RandomEnemyPicker(),36 只里随便挑
  2. 2
    红色(map.mokeponStrong,colour=red):new TerrainEnemyPicker(ADVANTAGE),挑在这块地形上占优势的
  3. 3
    绿色(map.mokeponWeak,colour=lime):new TerrainEnemyPicker(DISADVANTAGE),挑在这块地形上吃亏的。右边 (17,17) 那个也是绿色,再右边的星形是玩家
同一个类 Marker:颜色来自 Sprite,挑法来自 picker真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)

所以:颜色来自 Sprite,是数据;挑哪只敌人来自 picker,是行为。策略模式说的只是后一半。

Marker:字段 picker 的类型是接口;chooseEnemy 把活交给它world/Marker.java第 11–27 行
11public class Marker extends MapObject {
12 private final EnemyPicker picker;
13
14 public Marker(Position position, Sprite sprite, EnemyPicker picker) {
15 super(position, sprite);
16 this.picker = picker;
17 }
18
19 /* Choose the Mokepon to fight on this marker's terrain. */
20 Mokepon chooseEnemy(List<Mokepon> mokepons, Terrain terrain, Random random) {
21 return picker.pick(mokepons, terrain, random);
22 }
23
24 @Override
25 void interact(Overworld world) {
26 world.startEncounter(this);
27 }

CMarker 第 12 行的字段写的是接口 IEnemyPicker,第 21 行 picker.pick(...) 不知道也不关心里面是哪一种 picker。这种“自己不做,交给别的对象做”叫委托(delegation)(第 9 节)。

真实结果:你在 (17,17) 那个绿色感叹号上踩了两次,遇到的敌人不一样:

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
12
  1. 1
    第一次:Hexwing(LIGHTNING 类型)
  2. 2
    两条地形提示:(17,17) 是 FOREST,你的 Impike(FIRE)占优势,敌人吃亏
第一次踩 (17,17)真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
再次踩上感叹号:又遇到一只(敌人可能不同)
12
  1. 1
    第二次:Galecrest(也是 LIGHTNING)
  2. 2
    同样两条:敌人又是一只在 FOREST 上吃亏的
逃跑后再踩一次真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
TerrainEnemyPicker.pick:先筛,再随机world/TerrainEnemyPicker.java第 20–34 行
20 @Override
21 public Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random) {
22 List<Mokepon> matches = new ArrayList<>();
23 for (Mokepon mokepon : mokepons) {
24 if (mokepon.getType().getEffectOn(terrain) == wantedEffect) {
25 matches.add(mokepon);
26 }
27 }
28
29 /* If no Mokepon has the wanted effect here, any Mokepon can appear instead. */
30 if (matches.isEmpty()) {
31 matches.addAll(mokepons);
32 }
33 return matches.get(random.nextInt(matches.size()));
34 }

为什么两次都是 LIGHTNING?先看 (17,17) 是什么地形。CGameMap 用 tiles[y][x] 找格子,行号和列号都从 0 数。y = 17 是数据文件里 map.tiles.17 那一行 OOOOWWGGPPPPGGGGFFFF;x = 17 是这一行从 0 数的第 17 个字母(从 1 数是第 18 个),是 F,FOREST。这个 picker 的 wantedEffect 是 DISADVANTAGE。第 24 行只留下“类型在 FOREST 上是 DISADVANTAGE”的 Mokepon:只有 LIGHTNING 的弱项是 FOREST(LIGHTNING(Terrain.GRASS, Terrain.FOREST))。36 只里有 5 只 LIGHTNING:Hexwing、Voltusk、Skydart、Galecrest、Stormtalon。第 33 行再从这 5 只里随机挑一只,所以两次可能不同,但一定是这 5 只之一。

EnemyStrategy:敌人出哪一招

Battle:字段是接口,默认放一个 RandomEnemyStrategybattle/Battle.java第 18–18、24–27、87–89 行
18 private final EnemyStrategy enemyStrategy;
⋯ 省略第 19–23 行
24 /* Start a battle against an enemy that picks random skills. */
25 public Battle(Fighter player, Fighter enemy, Inventory inventory) {
26 this(player, enemy, inventory, new RandomEnemyStrategy(), new Random());
27 }
⋯ 省略第 28–86 行
871 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);
88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");
89 log.add(enemySkill.use(enemy, player, random));
RandomEnemyStrategy:从付得起的技能里随机挑battle/RandomEnemyStrategy.java第 9–20 行
9public class RandomEnemyStrategy implements EnemyStrategy {
10
11 @Override
12 public Skill chooseSkill(Fighter enemy, Random random) {
13 List<Skill> affordableSkills = new ArrayList<>();
14 for (Skill skill : enemy.getSkills()) {
15 if (enemy.canUse(skill)) {
16 affordableSkills.add(skill);
17 }
18 }
191 return affordableSkills.get(random.nextInt(affordableSkills.size()));
20 }
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    CRandomEnemyStrategy 第 19 行挑出了 Tackle;CBattle 第 87 行拿到它,第 88 行写出这一句
日志里敌人的这一招(放大了日志的最后四行)真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

算一算这一次怎么挑的(地形把数值乘 0.5 的算法见 第 9 章第 7 节):

时刻算式(代码)Galecrest 的能量
战斗开始数据文件 energy=28,在 FOREST 上吃亏 × 0.5:28 × 0.5 = 1414 / 14
上一回合用了 WaitspendEnergy(-2):Math.min(14 - (-2), 14) = min(16, 14),压回上限14
这一回合挑招canUse:14 ≥ 5(Tackle)、14 ≥ 3(Bunker)、14 ≥ 3(Swole)、14 ≥ −2(Wait),4 个都付得起;第 19 行从 4 个里随机挑到 Tackle14
Tackle 出手use 第 31 行先扣:14 − 5 = 9(没打中也扣)9 / 14 ≈ 64%(下图红 1)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    用完 Tackle 以后敌人的能量条(蓝色):9 ÷ 14 ≈ 64%。敌人的条不写数字,上面绿色的是 HP 条,还是满的
Galecrest 的 HP 条和能量条真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle接口:决定敌人这一回合用哪个技能。IEnemyStrategybattleEnemyStrategy 的实现:从能量够用的技能里随机挑一个。CRandomEnemyStrategybattleMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld接口:决定在某个标记上遇到哪只 Mokepon。IEnemyPickerworldEnemyPicker 的实现:从全部 Mokepon 里随便挑一只,不管地形。CRandomEnemyPickerworldEnemyPicker 的实现:挑一只类型在这块地形上有指定效果的 Mokepon。CTerrainEnemyPickerworldenemyStrategypicker
两处策略模式。空心菱形 = 用字段存着(线上是字段名,字段类型是接口);虚线 + 空心三角 = implements。Battle 只认识 EnemyStrategy,Marker 只认识 EnemyPicker

为什么在这里有用

4. 观察者(Observer):一次完整的通知来回

是什么。观察者模式:一个对象(被观察的一方,subject)存着一串“监听者”(listener,也叫 observer)。自己身上发生了什么事,就挨个通知它们。它只认识监听者的接口,不知道监听者是谁、收到后会做什么。英文:The subject keeps a list of listeners typed as an interface and notifies each of them when something happens; it does not know what the listeners are or what they do.

角色你代码里是谁在哪一行
被观察者(subject)COverworld第 23 行字段 listeners
监听者接口IOverworldListener,5 个方法整个文件
具体的监听者COverworldScreen(implements OverworldListener)第 24 行类声明,第 72–112 行五个方法
谁登记CGameWindow.startGame第 61 行 world.addListener(overworldScreen)
谁发通知COverworld 的 move、runAway、winEncounter、startEncounter、collect、reachExit都调用第 125 行 notifyListeners
Overworld 这一边:存、登记、通知world/Overworld.java第 23–23、44–46、122–129 行
23 private final List<OverworldListener> listeners = new ArrayList<>();
⋯ 省略第 24–43 行
44 public void addListener(OverworldListener listener) {
45 listeners.add(listener);
46 }
⋯ 省略第 47–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) {
127 message.accept(listener);
128 }
129 }
一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenuilistenersaddListener(...)overworldScreen
观察者的结构。空心菱形 = 字段;虚线 + 空心三角 = implements;实线箭头 = 调用。Overworld 的箭头只指向接口 OverworldListener,没有一根指向 OverworldScreen

下面用一次真实的来回把它跑一遍:打赢 Galecrest 以后点 Back to it!,回到地图看到 “You beat the enemy Galecrest!”。先看调用顺序(顺序图的读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,绕回自己 = 调用自己的方法,箭头起点旁的小数字是第几步):

玩家CBattleScreenCGameWindowCOverworldScreenCMapViewCworld : Overworld开局时:startGame 第 61 行addListener(overworldScreen)1……走路、遇敌、点 Fight!、打赢……点 Back to it!2winBattle()3winEncounter()4objects.remove(marker)player.addWin()onEncounterWon(Galecrest)5statusBar.setText(...)6update()7mapView.update()8接着 statsScroll.update():Fights 变成 1showScreen(overworldScreen)9
打赢以后的通知来回。第 3 条箭头 winBattle():BattleScreen 是通过字段 navigator(类型是接口 GameNavigator)调用的;第 5 条 onEncounterWon(Galecrest):Overworld 是通过 listeners 列表(类型是接口 OverworldListener)调用的。第 7 条是 OverworldScreen 自己的 private 方法 update(),它再调用 MapView 的 update()

动画里上面的图亮起来的箭头 = 这一步正在发生的调用;左下是这一步运行的代码(太长的行在文字里写全了),右下是屏幕。红框编号:1 Back to it! 按钮,2 状态栏,3 (17,17) 那一格,4 卷轴上的 Fights,5 菜单里的 Run away。

战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigatorui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworldwinBattle()winEncounter()addListener()onEncounterWon()getView()(在 showScreen 里)
一圈经过两个接口:上面一排是“画面 → 窗口”(GameNavigator),下面一排是“规则 → 画面”(OverworldListener)。中间一列是接口
ui/GameWindow.java第 58–62、71–73、101–104 行
58 public void startGame(Mokepon playerChoice) {
59 world = new Overworld(map, mokepons, playerChoice);
60 overworldScreen = new OverworldScreen(world, images, this);
61 world.addListener(overworldScreen);
62 showScreen(overworldScreen);
⋯ 省略第 63–70 行
71 public void winBattle() {
72 world.winEncounter();
73 showScreen(overworldScreen);
⋯ 省略第 74–100 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
ui/BattleScreen.java第 170–175 行
170 private void showResult() {
171 getNavigator().updateMenu();
172 if (battle.hasPlayerWon()) {
173 ModalDialog dialog = new ModalDialog("You Win");
174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());
175 showDialog(dialog);
world/Overworld.java第 93–100、122–129 行
93 /* The battle was won, so remove the marker and count the win. */
94 public void winEncounter() {
95 Mokepon enemy = encounter.getEnemy();
96 objects.remove(encounter.getMarker());
97 player.addWin();
98 encounter = null;
99 notifyListeners(listener -> listener.onEncounterWon(enemy));
100 }
⋯ 省略第 101–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) {
127 message.accept(listener);
128 }
129 }
ui/OverworldScreen.java第 102–106、133–140 行
102 @Override
103 public void onEncounterWon(Mokepon enemy) {
104 statusBar.setText("You beat the enemy " + enemy.getName() + "!");
105 update();
106 }
⋯ 省略第 107–132 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
138 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
3
  1. 3
    开局时 (17,17) 的绿色感叹号:后面要打的就是它
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    Back to it!
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
2
  1. 2
    状态栏:You beat the enemy Galecrest!
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
3
  1. 3
    (17,17):感叹号没了,玩家站在这里
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
4
  1. 4
    Fights: 1
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
地图上打开 Game 菜单:Run away 灰,Main menu 可点
5
  1. 5
    地图画面上 Run away 是灰的(这张是同一个画面在开局时拍的)
真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
ui/GameMenu.java第 46–49 行
46 public void update(GameScreen screen) {
47 runAway.setDisable(!screen.canRunAway());
48 mainMenu.setDisable(!screen.canReturnToMainMenu());
49 }
  1. 登记(开局时)。startGame 第 59 行造规则对象 COverworld,第 60 行造画面,第 61 行 world.addListener(overworldScreen)。addListener 的参数类型是接口 IOverworldListener,COverworldScreen 写了 implements,所以放得进 listeners。这一局里列表一直只有这一个监听者。
  2. 打赢了。Lance 打倒了 Galecrest(伤害怎么算见 第 12 章)。CBattleScreen.update 看到 isFinished(),调用 showResult:第 171 行先 getNavigator().updateMenu(),第 173 行造 You Win 框。第 174 行整句是 dialog.addButton("Back to it!", event -> getNavigator().winBattle()):按钮 Back to it!(红 1)被点时,lambda 里只做 getNavigator().winBattle() 这一件事。到这里还没有任何通知:规则对象 COverworld 还不知道你赢了。要等你点了按钮,才会走到 winEncounter(第 4 步)和 onEncounterWon(第 5 步)。
  3. 经过第一个接口。你点了 Back to it!。getNavigator() 交出父类 AGameScreen 的字段 navigator,它的类型是接口 IGameNavigator,所以 CBattleScreen 不知道背后是谁。真正的对象是 CGameWindow(startBattle 里 new BattleScreen(..., this) 把自己交了进去),所以运行的是 CGameWindow.winBattle。第 72 行先请规则做事:world.winEncounter()。
  4. 规则改数据。winEncounter:第 95 行记下敌人 Galecrest;第 96 行 objects.remove(encounter.getMarker()),把 (17,17) 的感叹号从列表里拿掉。地图开局有 14 个物体(10 个感叹号、3 个问号箱、1 个门);去 (17,17) 的路上已经捡了 (18,16) 的 Poison 箱,剩 13 个;现在剩 12 个。第 97 行 player.addWin(),fightsWon 0 → 1;第 98 行 encounter 清成 null。规则里的数据全改好了,可屏幕一点没变:COverworld 不认识任何画面。
  5. 经过第二个接口。第 99 行整句是 notifyListeners(listener -> listener.onEncounterWon(enemy));。第 126 行遍历 listeners,里面就是第 1 步登记的 COverworldScreen;第 127 行 message.accept(listener) 等于调用它的 onEncounterWon(Galecrest)。图上 COverworld 的箭头只指向接口 IOverworldListener。
  6. 监听者做自己的事。COverworldScreen 第 104 行:"You beat the enemy " + "Galecrest" + "!" 写进状态栏(红 2)。注意:这时窗口中间显示的还是战斗画面(截图是换完画面以后拍的,这句字在这一步就已经写好了)。地图画面对象一直活着(CGameWindow 的字段 overworldScreen 存着它),只是没摆在窗口里,但它照样收到通知、照样更新。
  7. 画面重画。第 105 行调用自己的 private 方法 update()。第 135 行 mapView.update() 只把 world.getObjects() 里剩下的 12 个物体的图放回去,(17,17) 的感叹号没有被放回(红 3,现在那格只有玩家)。第 136 行 statsScroll.update() 把 Fights 写成 getFightsWon() = 1(红 4)。第 137–139 行:moveButtons 是一个 Map(方向 → 按钮),Map.Entry 是其中的一对(键,值);对每一对,按钮能不能用 = world.canMove(那个方向)。
  8. 回到窗口。通知做完,回到 winBattle 第 73 行 showScreen(overworldScreen)。showScreen 是 CGameWindow 自己的 private 方法:第 102 行 currentScreen 换成地图画面,第 103 行 root.setCenter(screen.getView()) 向画面要它的 Pane(图上那根箭头)摆到中间,第 104 行 updateMenu() → CGameMenu.update 第 47 行:COverworldScreen 没改写 canRunAway(),用父类默认的 false,Run away 变灰(红 5)。战斗画面再没人存着,被丢掉。
  9. 总结。一圈:CBattleScreen → 接口 IGameNavigator → CGameWindow → COverworld → 接口 IOverworldListener → COverworldScreen。CBattleScreen 的代码里一次也没写过 “GameWindow”;COverworld 所在的 world 包一行 import ...ui 都没有。两个接口,两次“倒置”(第 5 节)。

五个通知各在什么时候发、画面各做什么,第 8 章第 6 节有一张表;走路一步的完整回放也在那一章。

为什么在这里有用

5. 依赖倒置(Dependency Inversion)

是什么。依赖倒置:高层的代码不要直接依赖低层的具体类;两边都依赖一个抽象(接口)。“倒置”指的是箭头的方向:本来 A 直接指着 B,现在 A 指着接口,B 也指着接口(B implements 它)。英文:High-level code should not depend on concrete low-level classes; both should depend on an abstraction.

你的代码里有两处,上一节的动画两次都经过了。先看“假如没有这两个接口”会是什么样,再看你的代码:

ui 包world 包战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld窗口 new 画面,画面又调用窗口:两个类绕成一圈world 包要 import ui:规则依赖界面winBattle()newworldonEncounterWon()
① 假设没有两个接口(你的代码不是这样):BattleScreen 直接指着 GameWindow,GameWindow 又 new BattleScreen,绕成一圈;Overworld 直接调用 OverworldScreen,箭头从 world 包指进 ui 包,又绕成一圈
ui 包world 包战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigatorui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworldwinBattle()newworldonEncounterWon()
② 你的代码:BattleScreen 只指向接口 GameNavigator,Overworld 只指向接口 OverworldListener(这个接口放在 world 包里)。跨包的箭头全是 ui → world,两个圈都断开了

两处各是谁高谁低:

第一处:IGameNavigator。三个画面(通过父类 AGameScreen 的字段 navigator,第 6 章第 2 节)和菜单 CGameMenu 都要请窗口“换画面、结束程序”,可它们手里拿的是接口,不是 CGameWindow。看菜单的代码和它在屏幕上的样子:

GameMenu 的构造器:参数类型是接口 GameNavigatorui/GameMenu.java第 19–30 行
19 public GameMenu(GameNavigator navigator) {
20 runAway = new MenuItem("Run away");
21 runAway.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
221 runAway.setOnAction(event -> navigator.runAway());
23
24 mainMenu = new MenuItem("Main menu");
25 mainMenu.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN));
262 mainMenu.setOnAction(event -> navigator.returnToMainMenu());
27
28 MenuItem exit = new MenuItem("Exit");
29 exit.setAccelerator(new KeyCodeCombination(KeyCode.F4, KeyCombination.ALT_DOWN));
303 exit.setOnAction(event -> navigator.quit());
战斗中打开 Game 菜单:Run away 可以点了
123
  1. 1
    Run away → 第 22 行 navigator.runAway()
  2. 2
    Main menu → 第 26 行 navigator.returnToMainMenu()
  3. 3
    Exit → 第 30 行 navigator.quit()
战斗中打开的 Game 菜单真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了

CGameWindow 在第 39 行 new GameMenu(this):交进去的是它自己,可 CGameMenu 只把它当一个 IGameNavigator。把整个项目搜一遍,“GameWindow” 这个名字只出现在 GameWindow.java 和造它的 CMain 里。

第二处:IOverworldListener。注意这个接口放在 world 包里,不在 ui 包里。是规则这一边说“我会发这 5 种通知”,界面那一边来实现它。所以依赖的箭头全部从 ui 指向 world,从来没有反方向的(上面图 ②)。

如果没有 GameNavigator

CSelectScreen、CBattleScreen、CGameMenu 的参数要写成 CGameWindow。可 CGameWindow 自己要 new 这些画面,于是“窗口认识画面,画面也认识窗口”,两边绑死(耦合高,循环依赖)。画面还能调用和换画面无关的 show()。

如果没有 OverworldListener

COverworld 要存一个 COverworldScreen 字段,world 包就要 import ...ui.OverworldScreen,规则代码依赖 JavaFX 画面。想测试“走一步”,就得先开一个窗口。

你的代码

两个接口夹在中间。画面和菜单 → IGameNavigator ← CGameWindow;COverworld → IOverworldListener ← COverworldScreen。谁都只认识一个很小的接口。

为什么在这里有用

6. 封装:private、复制、只读视图、包内可见

是什么。封装:一个类的数据只能由它自己改。字段写成 private,外面只能通过它给的方法来读、来请它改。英文:A class keeps its fields private and only lets other classes use them through its methods.

光写 private 还不够。如果一个字段是 List、Map、数组这种“可以改里面内容”的对象,把它的引用交出去(或者直接存下别人交进来的引用),外面的代码就能绕过你的方法去改它。这叫隐私泄露(privacy leak)。你的代码用了五种办法:

办法你代码里的例子
① private 字段 + 只给 getter,不给 setterCMokepon:5 个字段全是 private final,没有一个 setter(Project 1 的 Mokepon 有 9 个 setter,第 10 节)
② 收进来时复制(防御性复制)CMokepon 构造器 new EnumMap<>(stats)、List.copyOf(skills);CGameImages 四个 Map.copyOf;CGameMap 每一行 tiles[row].clone() 和 List.copyOf(objects);COverworld 构造器 List.copyOf(mokepons) 和 new ArrayList<>(map.getObjects())
③ 交出去时复制CInventory.getItems:List.copyOf(counts.keySet())
④ 交出去时只给只读视图CBattle.getLog、COverworld.getObjects:Collections.unmodifiableList(...)
⑤ 方法写成包内可见(什么修饰符都不写)CPlayer 的 moveTo、moveBack、addWin;AMapObject 的 interact;COverworld 的 startEncounter、collect、reachExit;CMarker 的 chooseEnemy
② Mokepon:收进来时复制一份model/Mokepon.java第 18–26 行
18 /* Keep our own copies of the stats and skills, so no other class can change them. */
19 public Mokepon(String name, String imagePath, MokeponType type,
20 Map<Stat, Integer> stats, List<Skill> skills) {
21 this.name = name;
22 this.imagePath = imagePath;
23 this.type = type;
24 this.stats = new EnumMap<>(stats);
25 this.skills = List.copyOf(skills);
26 }
② GameMap:每一行都 cloneworld/GameMap.java第 15–23 行
15 public GameMap(Terrain[][] tiles, Position startPosition, List<MapObject> objects) {
16 /* Copy every row, so no other class can change the tiles (no privacy leak). */
17 this.tiles = new Terrain[tiles.length][];
18 for (int row = 0; row < tiles.length; row++) {
19 this.tiles[row] = tiles[row].clone();
20 }
21 this.startPosition = startPosition;
22 this.objects = List.copyOf(objects);
23 }

为什么要复制?CMokeponParser 第 33 行先造一个 stats Map,再交给 CMokepon 的构造器。如果构造器直接写 this.stats = stats,解析器手里那个 Map 和 Mokepon 里的是同一个对象,解析器以后一改,Mokepon 的数值就跟着变。复制以后,两份互不相干:

MokeponParser 第 33 行局部变量 stats一个 EnumMap(HP、ENERGY 等 6 个数)假设 this.stats = statsMokepon 的字段 statsMokeponParser 第 33 行局部变量 stats原来的 EnumMap新的 EnumMapnew EnumMap<>(stats)你的代码Mokepon 的字段 stats指着也指着同一个指着复制指着
引用和复制。上:假设构造器写 this.stats = stats(你的代码不是这样),两个变量指着同一个 Map,从外面改一个数,Mokepon 也跟着变。下:你的代码第 24 行,Mokepon 指着自己的新 Map,外面怎么改都碰不到它

CGameMap 的二维数组更要小心。二维数组其实是“一个外层数组,里面每一格放着一行(一个 Terrain[])”。如果只写 tiles.clone(),只复制了外层,里面每一行还是同一个数组(浅复制)。所以你的代码第 17 行先 new 一个新的外层数组,第 18–19 行再把每一行 clone() 一份放进去(注释写着 “no privacy leak”):

MapParser 造的 tiles外层数组第 0 行 … 第 19 行20 个 Terrain[]假设只写 tiles.clone()新的外层数组MapParser 造的 tiles外层数组原来的 20 行第 19 行 clone 出的新的 20 行GameMap.tiles第 17 行 new 的外层指着每一行也指着同样的行指着每一行复制指着
二维数组的复制。上:假设只写 tiles.clone(),两个外层数组共用同样的 20 行,MapParser 改一格,GameMap 也变。下:你的代码,外层新建、每一行再复制,两边完全分开

③ 和 ④ 都是“交出去”,区别很重要:

Battle 的 logArrayList,会一直变长getLog() 交出的只读视图战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuiInventory 的 counts(LinkedHashMap)getItems() 交出的新 List(副本)地图界面右上的卷轴:玩家的 Mokepon、属性、胜场和道具数量。CPlayerStatsScrollui看的是同一份读复制一份读
上:只读视图(view)。和原列表是同一份数据,原列表变了,通过视图也看得见;但通过视图去改(add、clear)会抛 UnsupportedOperationException。下:副本(copy)。复制那一刻的样子;以后原来的变了,副本不变
④ Battle.getLog:只读视图battle/Battle.java第 20–20、61–64 行
20 private final List<String> log = new ArrayList<>();
⋯ 省略第 21–60 行
61 /* Other classes can read the log but cannot change it. */
62 public List<String> getLog() {
63 return Collections.unmodifiableList(log);
64 }
BattleScreen.showLog:只读,把每一行连起来ui/BattleScreen.java第 140–145 行
140 /* Show all messages and scroll to the latest one. */
141 private void showLog() {
142 logArea.setText(String.join("\n", battle.getLog()));
143 logArea.positionCaret(logArea.getLength());
144 logArea.setScrollTop(Double.MAX_VALUE);
145 }

CBattleScreen 第 142 行 String.join("\n", battle.getLog()):把视图里的每一行用换行连成一整段文字,放进日志区。它每次 update() 都重新读一遍。因为拿到的是视图,读到的总是 CBattle 最新写进去的行。下面两帧是同一场战斗的日志区,中间只用了一个 Poison:

用 Stun 命中:敌人被眩晕,3 回合内伤害减半用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
只读视图会跟着原列表变:两帧之间,BattleScreen 没有再要一次新的列表,只是重新读同一个 getLog()。如果它想 getLog().add(...),会直接抛异常:只有 Battle 自己能往日志里写
③ Inventory.getItems:交出一个副本item/Inventory.java第 21–23 行
21 public List<Item> getItems() {
221 return List.copyOf(counts.keySet());
23 }
PlayerStatsScroll:按副本里的顺序造三行ui/PlayerStatsScroll.java第 62–66 行
621 for (Item item : player.getInventory().getItems()) {
63 Label count = createLabel("", numberFont);
64 itemValues.put(item, count);
65 addRow(grid, row++, item.getName() + ":", count);
66 }
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
1
  1. 1
    三行道具:PlayerStatsScroll 第 62 行对 getItems() 副本里的三个道具对象各造一行;数量用 getCount(item) 读
卷轴里的道具行真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

⑤ 包内可见最能说明问题。看 CPlayer:

⑤ Player:三个会改数据的方法前面什么都没写world/Player.java第 32–52 行
32 public Position getPosition() {
33 return position;
34 }
35
36 public int getFightsWon() {
37 return fightsWon;
38 }
39
40 void moveTo(Position newPosition) {
41 previousPosition = position;
42 position = newPosition;
43 }
44
45 /* Go back to the tile the player stood on before the last move. */
46 void moveBack() {
47 position = previousPosition;
48 }
49
50 void addWin() {
51 fightsWon++;
52 }

getPosition()、getFightsWon() 是 public 的,谁都能读;moveTo、moveBack、addWin 前面没有修饰符,只有 world 包里的类能调用。所以 ui 包里的 COverworldScreen 和 CMapView 能看到玩家在哪,却不能把玩家挪到一格不能走的地方,只能请 world.move(direction),让 COverworld 先检查再挪。AMapObject.interact 也一样:只有 COverworld.move 能触发一个物体,画面没法“假装”踩上了门。

为什么在这里有用

7. 继承和多态:同一句调用,不同的子类各做各的

是什么。继承(inheritance):子类 extends 父类,自动拥有父类的字段和方法,可以加新的,也可以改写。多态(polymorphism):变量写的是父类(或接口)类型,里面装的是某个子类的对象;调用方法时,运行的是那个子类的版本(动态绑定)。英文:The variable is declared with the superclass type; at run time the overriding method of the actual subclass object is called.

技能家族第 2 节已经看过。另外还有四家,每一家都有一个“只认识父类”的调用者:

一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitemHealthTonicCoffee · Poison一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworldMarkerMysteryBox · Exititem.use(...)interact(this)
道具和地图物体。上:只认识父类的调用者(实线箭头 = 调用,线上是那一句调用);中:抽象父类;下:改写了方法的子类(实线 + 空心三角 = extends)
正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle抽象类:所有状态效果的共同部分,主要是还剩几回合。AStatusEffecteffectStunned · BlindedBleeding窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenuiSelectScreenOverworldScreenBattleScreeneffectscanRunAway()
状态效果和画面。Fighter 用字段 effects 存着效果(空心菱形),GameMenu 只认识父类 GameScreen
调用(在哪一行)变量的类型实际对象不同,结果就不同
CBattle.useItem 第 107 行 item.use(player, enemy)AItemCHealthTonic 回满 HP;CCoffee 回满能量;CPoison 让敌人只剩 1 HP
COverworld.move 第 76 行 object.interact(this)AMapObjectCMarker 开始遇敌;CMysteryBox 被捡起;CExit 结束游戏
CFighter 第 134、148、171 行的三个循环AStatusEffectCStunned 伤害 × 0.5;CBlinded 多 50% 落空;CBleeding 回合末掉血
CGameMenu.update 第 47–48 行 screen.canRunAway()、screen.canReturnToMainMenu()AGameScreen只有 CBattleScreen 能逃跑(还没打完时);只有 CSelectScreen 不能回主菜单
CFighterPanel.update 第 77–78 行 hpBar.update(...)CStatBarCLabelledStatBar 还会改上面的数字;普通 CStatBar 只改长度

真实结果:同一行代码,两句不同的日志

Battle.useItem:不管是哪种道具,都是这一句battle/Battle.java第 98–108 行
98 /* Use one item from the inventory. An item always works,
99 * and using it does not give the enemy a turn.
100 */
101 public void useItem(Item item) {
102 if (finished || !inventory.has(item)) {
103 return;
104 }
105 inventory.remove(item);
106 log.add("");
107 log.add(item.use(player, enemy));
108 }
Coffee.useitem/Coffee.java第 11–15 行
11 @Override
12 public String use(Fighter player, Fighter enemy) {
131 player.restoreEnergy();
14 return describeUse(player, "It restored all your energy!");
15 }
HealthTonic.useitem/HealthTonic.java第 11–15 行
11 @Override
12 public String use(Fighter player, Fighter enemy) {
132 player.restoreHp();
14 return describeUse(player, "It healed you to max hp!");
15 }
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
12
  1. 1
    第 107 行,item 装的是 CCoffee:运行 CCoffee.use,能量 1 → 10
  2. 2
    同一行,item 装的是 CHealthTonic:运行 CHealthTonic.use,HP 228 → 280
另一局游戏(选的是 Ironshell,敌人 Frostback):Ironshell 连着用了两个道具,敌人没有行动,因为用道具不占回合。放大了日志的最后三行真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动

两句话的开头 “You used your … on Ironshell.” 一样,因为它们都调用父类 AItem 的 protected 方法 describeUse;后半句由子类自己写。

字段写的是父类,装的是子类:StatBar

FighterPanel:字段是 StatBar,玩家的装 LabelledStatBarui/FighterPanel.java第 30–31、48–59 行
30 private final StatBar hpBar;
31 private final StatBar energyBar;
⋯ 省略第 32–47 行
48 double hpY = top + HP_BAR_OFFSET;
49 double energyY = top + ENERGY_BAR_OFFSET;
50 if (showNumbers) {
511 hpBar = new LabelledStatBar(contentX, hpY, HP_COLOUR,
52 fighter.getHp(), fighter.getMaxHp());
53 energyBar = new LabelledStatBar(contentX, energyY, ENERGY_COLOUR,
54 fighter.getEnergy(), fighter.getMaxEnergy());
55 } else {
562 hpBar = new StatBar(contentX, hpY, HP_COLOUR, fighter.getHp(), fighter.getMaxHp());
57 energyBar = new StatBar(contentX, energyY, ENERGY_COLOUR,
58 fighter.getEnergy(), fighter.getMaxEnergy());
59 }
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    玩家:showNumbers 是 true,new LabelledStatBar(...),条上有 “225 / 225”
你的 Impike真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
点 Fight! 进入战斗界面,战斗刚开始
2
  1. 2
    敌人:new StatBar(...),只有颜色,没有数字
敌人 Galecrest真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
LabelledStatBar 改写两个方法,都先调用 superui/LabelledStatBar.java第 11–11、27–39 行
11public class LabelledStatBar extends StatBar {
⋯ 省略第 12–26 行
27 @Override
28 public void update(int current, int max) {
29 super.update(current, max);
30 numbers.setText(current + SEPARATOR + max);
31 }
32
33 /* The numbers go after the bar so they are drawn on top of it. */
34 @Override
35 public List<Node> getNodes() {
36 List<Node> nodes = new ArrayList<>(super.getNodes());
37 nodes.add(numbers);
38 return nodes;
39 }

打印的 UML 只会写 hpBar: StatBar,看不出玩家那边装的是子类。考试时要说:hpBar is declared as StatBar; the player's panel stores a LabelledStatBar in it, so update() also changes the numbers.

为什么在这里有用

8. SOLID 五条,一条一条找

SOLID 是五条设计原则的首字母。每一条都给出:一句话、你代码里做到的地方、没完全做到的地方。

S:单一职责(Single Responsibility)

一个类只负责一件事,只有一种理由需要改它。英文:A class should have only one reason to change.

mokepon.game:三段,各由一个解析器读mokepon.game第 1–10、402–403、439–444、470–472 行
11mokepon.0.name=Sealance
2mokepon.0.image=assets/Sealance.png
3mokepon.0.hp=180
4mokepon.0.energy=10
5mokepon.0.strength=55
6mokepon.0.accuracy=70
7mokepon.0.defence=75
8mokepon.0.dodge=45
9mokepon.0.skills=Bite|Wait
10mokepon.0.type=WATER
⋯ 省略第 11–401 行
4022map.tiles.0=RRRRRRPPRRRRRRRRRRRR
403map.tiles.1=RRRRRRPPRRRRRRRRRRRR
⋯ 省略第 404–438 行
439map.mokeponRandom.0=3,3
440map.mokeponRandom.1=14,6
441map.mokeponRandom.2=12,12
442map.mokeponRandom.3=4,10
443map.mokeponRandom.image=assets/sprite_exclamation.png
444map.mokeponRandom.colour=white
⋯ 省略第 445–469 行
4703items.coffee=assets/sprite_coffee.png
471items.heal=assets/sprite_heal.png
472items.poison=assets/sprite_poison.png

红 1 是 CMokeponParser 读的(mokepon.N.xxx),红 2 是 CMapParser 读的(map.tiles.N、感叹号、问号箱、出口),红 3 是 CImageParser 读的(类型、地形、按钮、道具的图片)。数据文件里地图那一段格式变了,只改 CMapParser。

没完全做到:CGameWindow 既管窗口和换画面,又管“这一局”的生命周期(字段 world、startGame 里造规则对象);CBattleScreen 235 行、CSelectScreen 291 行,排版、造按钮、更新都在一个类里;CFighter 既存数值,又算伤害公式,又管效果列表。

O:开闭(Open/Closed)

对扩展开放,对修改关闭:加新功能时写新代码(新的子类),不改已经能用的旧代码。英文:Classes should be open for extension but closed for modification.

一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattle不用改:只认识 Skill抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillskill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkillskill新技能类(假设,还不存在)只写 applyHit全部 10 个技能的目录:按名字找到对应的技能对象。CSkillCatalogue要改:列表里 +1 个对象use(...)new
加一个新技能会碰到哪些类。米色框是假设的新类(现在不存在):它只要 extends TargetedSkill、写一个 applyHit。旧代码里只有 SkillCatalogue 要多 new 一个对象;Battle 只认识 Skill,一行不改
想加要写的新东西还要改的旧代码完全不用碰
新技能一个 AAttackSkill / ABoostSkill / ATargetedSkill 子类CSkillCatalogue 的列表加一个对象CBattle、CFighter、CBattleScreen、CRandomEnemyStrategy
新道具一个 AItem 子类CItemCatalogue 的两个列表;数据文件的图片键CBattle、CInventory、CBattleScreen、CPlayerStatsScroll
新状态效果一个 AStatusEffect 子类造它的那个技能CFighter、CBattle
新地图物体一个 AMapObject 子类CMapParser 加一个读它的方法;可能还要 COverworld 加一个包内方法COverworld.move、CMapView
新 Mokepon什么代码都不用写只改数据文件全部 Java 代码

没完全做到:目录类和解析器认识每一个子类,所以“加一个”总要改一两行旧代码。完全不改旧代码,需要按名字自动找类之类更复杂的办法,这个项目没有必要。加一个技能的完整对比在 第 12 章第 9 节。

L:里氏替换(Liskov Substitution)

子类对象放在任何需要父类对象的地方,程序照样正常。英文:Any subclass object can be used wherever its superclass is expected, without surprising the caller.

要留心的地方:CWait 的能量消耗是 −2。这对“消耗”来说有点出人意料,但 ASkill 第 13 行的注释写明了 “A negative energy cost means the skill restores energy”,canUse 和 spendEnergy 也都处理了负数,所以没有破坏替换:

Fighter:canUse 和 spendEnergy 对负的消耗也成立battle/Fighter.java第 77–79、89–92 行
77 public boolean canUse(Skill skill) {
78 return getEnergy() >= skill.getEnergyCost();
79 }
⋯ 省略第 80–88 行
89 public void spendEnergy(int amount) {
90 int energy = Math.min(getEnergy() - amount, getMaxEnergy());
91 stats.put(Stat.ENERGY, Math.max(0, energy));
92 }
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    Wait 按钮写着 [-2]。第 78 行:能量 ≥ −2 永远成立,所以只要战斗没结束,这个按钮永远能点。第 90 行:消耗 −2 就是能量 + 2,再用 Math.min 压在上限以内(Impike 满能量 33 时用 Wait,还是 33)
负的消耗在屏幕上的样子真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

真正违反 LSP 的写法,是子类改写方法后抛出 “不支持” 异常,或者偷偷不做父类答应的事,你的代码里没有。

I:接口隔离(Interface Segregation)

不要逼一个类依赖它用不到的方法;接口要小而专一。英文:Clients should not be forced to depend on methods they do not use.

做到的:IEnemyStrategy 和 IEnemyPicker 各只有 1 个方法;IOverworldListener 有 5 个,它唯一的实现者五个都用。没完全做到的是 IGameNavigator:

选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigator7 个方法窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenuistartGame()startBattle()winBattle()updateMenu()runAway()returnToMainMenu()quit()returnToMainMenu()quit()7 个全写
谁用了 GameNavigator 的哪几个方法(线上写的是它真正调用的方法)。GameNavigator 一共 7 个:startGame、startBattle、winBattle、runAway、returnToMainMenu、quit、updateMenu。每个使用者只用 1–3 个,却能看见全部 7 个。GameScreen 那根线来自它的 createGameEndDialog(出口框、Game Over 框的两个按钮)
谁拿着 GameNavigator7 个方法里真正调用的
CSelectScreenstartGame(1 个)
COverworldScreenstartBattle(1 个)
CBattleScreenwinBattle、updateMenu(2 个)
AGameScreen.createGameEndDialog(出口框、Game Over 框用它)returnToMainMenu、quit(2 个)
CGameMenurunAway、returnToMainMenu、quit(3 个)

可以改进的写法:拆成两个小接口,比如菜单只要一个有 runAway、returnToMainMenu、quit 的接口,CGameWindow 两个都实现。现在的写法简单、只有一个实现者,代价是每个使用者都能看到它不需要的方法。

D:依赖倒置(Dependency Inversion)

高层和低层都依赖抽象:就是第 5 节的 IGameNavigator 和 IOverworldListener,还有第 3 节 CBattle 的字段类型 IEnemyStrategy、CMarker 的 IEnemyPicker。没完全做到的地方见第 5 节最后。

9. 课程用语:实例化、委托、包含关系;静态目录类

设计题常要求用课程里的词描述类之间的关系。每个词配一对你代码里的真实类:

战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle背包:记录每种道具各有几个。CInventoryitem一次遇敌:哪个标记、玩家的 Mokepon、敌人和脚下的地形。CEncounterworld数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodelbattleplayerPanelenemyPanelfighterplayerenemyinventorymokeponnew ×2new
一场战斗周围的关系,按课程用语分开画。实线箭头 + 字段名 = 关联(长期认识对方);空心菱形 = 聚合(整体—部分,部分是别人造的、可以共用);实心菱形 = 组合(自己造、只属于自己);虚线箭头 = 用 new 造出来(实例化)。BattleScreen 到 Battle 那根线同时也是委托:它把回合交给 battle.playTurn(skill)
用语意思你代码里的例子英文说法
实例化
instantiation
谁用 new 造出谁CEncounter.createBattle 第 54–56 行造两个 CFighter 和一个 CBattle;CGameWindow.startBattle 造 CBattleScreen;CLance.applyHit 每次打中 new Bleeding(user)Encounter instantiates the two Fighters and the Battle.
委托
delegation
自己不做,调用别的对象去做CBattleScreen.useSkill → battle.playTurn(skill);CMarker.chooseEnemy → picker.pick(...);CFighter.getName → mokepon.getName();CGameWindow.updateMenu → menu.update(currentScreen)BattleScreen delegates each turn to Battle.playTurn().
包含关系
containership
一个类在字段里存着另一个类的对象(has-a)下面三种都是Battle contains two Fighters, an Inventory and an EnemyStrategy.
· 关联 association长期认识对方,能随时调用CBattleScreen 的 battle;CFighterPanel 的 fighter;COverworldScreen 的 world;AGameScreen 的 navigator;CBleeding 的 attacker;CGameWindow 的 stageOverworldScreen is associated with Overworld through its field world.
· 聚合 aggregation整体—部分,但部分是别人造的、可以共用、比整体活得久CBattle 的 inventory(COverworld 造的,CPlayer 也拿着,打完还在);CFighter 的 mokepon;CMokepon 的 skills(技能对象大家共用)Battle aggregates the Inventory: it is shared with Player and outlives the battle.
· 组合 composition部分由整体自己造、只属于它,整体没了它也没了CBattleScreen 在构造器里造的两个 CFighterPanel;CFighter 自己造的 stats、effects;CBattle 自己造的 log;CGameWindow 的 menu;CStatBar 的 bar(自己 new 的 ProgressBar)BattleScreen is composed of two FighterPanels that it creates and nobody else holds.

静态目录类:不是单例

CSkillCatalogue、CItemCatalogue、CUiStyle 的字段和方法全是 static 的,程序从不 new 它们,直接写“类名.方法名”用(静态目录类,第 4 章第 7 节)。CItemCatalogue 里两个列表的顺序,在两个画面上都看得见:

ItemCatalogue:三个道具对象,两种顺序item/ItemCatalogue.java第 9–24 行
9public class ItemCatalogue {
10 private static final Item HEALTH_TONIC = new HealthTonic();
11 private static final Item COFFEE = new Coffee();
12 private static final Item POISON = new Poison();
13
14 /* All items, in the order of the overworld scroll. */
151 private static final List<Item> ITEMS = List.of(HEALTH_TONIC, COFFEE, POISON);
16 /* The battle screen shows its item buttons in another order, as in the sample screenshot. */
172 private static final List<Item> BATTLE_ORDER = List.of(HEALTH_TONIC, POISON, COFFEE);
18
19 public static List<Item> getAll() {
20 return ITEMS;
21 }
22
23 public static List<Item> getBattleOrder() {
24 return BATTLE_ORDER;
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
1
  1. 1
    第 15 行 ITEMS 的顺序:Health Tonic、Coffee、Poison(CPlayerStatsScroll 通过 CInventory 读到)
地图卷轴真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
点 Fight! 进入战斗界面,战斗刚开始
2
  1. 2
    第 17 行 BATTLE_ORDER 的顺序:Health Tonic(十字)、Poison(绿色药丸)、Coffee(杯子)
战斗里的道具按钮真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

单例模式(Singleton)是“这个类只能造一个对象”:构造器写成 private,再用一个静态方法交出那唯一的对象。你的目录类不是这样:

好处

  • 每种道具只有一个对象,所以 CInventory 能直接拿道具对象当 Map 的键。
  • 技能对象不变(字段都是 final),36 只 Mokepon 共用 10 个对象,省事又安全。
  • 查找写在一处:名字拼错马上抛 “Unknown skill”,不像 Project 1 返回 null。
  • CUiStyle 把尺寸、字体、图片读取集中在一处,所有画面看起来一样。

坏处

  • 全局的:谁都能调用,测试时换不掉(static 方法不能用接口替换,违反一点 DIP)。
  • 目录要认识每一个子类,加新子类就要改它(OCP 不完美)。
  • CItemCatalogue 的两个列表要手动保持一致,漏加一个就会少一个按钮。
  • 没有 private 构造器,挡不住无意义的 new。

10. Project 1 和 Project 2:逐条对比

Project 1 只有 5 个类,全在一个包里。先看它们的关系:

Main读文件 + generateMokeponGameWindowextends Stage · 591 行BattlechooseEnemySkill()Mokepon9 个 setter + reset()Skillif / else 按名字newnewcopyMokepon()playerenemyskills
Project 1 的 5 个类(都在同一个包里,没有接口、没有抽象类、没有枚举)。实线箭头 = 调用 / new;空心菱形 = 字段
Project 1Project 2(你交的)
规模5 个类,1 个包,1058 行72 个类(55 个普通类、8 个抽象类、4 个接口、5 个枚举),9 个包,3730 行
接口0 个4 个:IEnemyStrategy、IEnemyPicker、IOverworldListener、IGameNavigator
技能一个 Skill 类,generateSkill 和 use 里两串 if / else 按名字分支,只认识 4 个技能14 个类的继承树(4 个抽象 + 10 个具体),两层模板方法,CSkillCatalogue 按名字找对象
Mokepon 的数值一个 Mokepon 类:9 个 public setter;开打前 copyMokepon 复制一份,战斗里改副本;写了 reset() 却没有地方调用只读的 CMokepon + 每场战斗新造的 CFighter,打完直接丢掉
敌人出招Battle.chooseEnemySkill() 一个方法接口 IEnemyStrategy + CRandomEnemyStrategy(方法体几乎一模一样,只是搬进了一个能替换的类)
窗口和画面GameWindow extends Stage,591 行,所有画面都在它的方法里拼出来;两套菜单由 getSelectMenu、getBattleMenu 分开造CGameWindow 106 行,主要管换画面(还管这一局的 world:startGame、returnToMainMenu);三个 AGameScreen 子类各画一个画面;一个 CGameMenu,靠 canRunAway() 多态开关
读数据Main 里读文件,static generateMokepon 一口气造 MokeponCGameDataReader + CGameData + 三个解析器,各管一段
封装几乎每个字段都有 setter;getBattleLog() 直接交出列表,GameWindow 从外面往战斗日志里加第一行没有 setter;日志只读;CBattle 在自己的构造器里写第一行

下面六组代码对照:大多数是左边 Project 1、右边 Project 2(窄屏上是上下排);“敌人出招”那一组的行太长,改成上下排,上面是 Project 1。

技能:if / else 按名字 vs 继承树

Project 1:use 里按名字分支Project 1 · Skill.java第 45–70 行
45 // Apply the skill effect and return its battle log message.
46 // Attack skills must hit before they can deal damage.
47 public String use(Mokepon attacker, Mokepon target, Random random) {
48 // Pay the energy cost even if the attack misses.
49 attacker.spendEnergy(energyCost);
50
51 // Use accuracy and dodge to calculate the hit chance.
52 double hitProbability = Math.pow(attacker.getAccuracy(), 2) / (Math.pow(attacker.getAccuracy(), 2) + Math.pow(target.getDodge(), 2));
53
54 // Round damage down with integer division.
55 int biteDamage = attacker.getStrength() * 50 / target.getDefence();
56 int tackleDamage = 2 * attacker.getStrength() * 50 / target.getDefence();
57
58 if (name.equals("Bite")) {
59 if (random.nextDouble() < hitProbability) {
60 target.takeDamage(biteDamage);
61 return "\tHit " + target.getName() + " for " + biteDamage + " damage!";
62 }
63 return "\tBut they missed.";
64
65 } else if (name.equals("Tackle")) {
66 if (random.nextDouble() < hitProbability) {
67 target.takeDamage(tackleDamage);
68 return "\tHit " + target.getName() + " for " + tackleDamage + " damage!";
69 }
70 return "\tBut they missed.";
Project 2:攻击只写一次,Bite、Tackle 只给数字skill/AttackSkill.java第 9–22 行
9public abstract class AttackSkill extends TargetedSkill {
10 private final double power;
11
12 protected AttackSkill(String name, int energyCost, double power) {
13 super(name, energyCost);
14 this.power = power;
15 }
16
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
19 int damage = user.calculateDamage(target, power);
20 target.takeDamage(damage);
21 return "\tHit " + target.getName() + " for " + damage + " damage!";
22 }

Project 1 的 Bite 分支和 Tackle 分支几乎一样(第 58–64 行、第 65–70 行),只有伤害乘 1 还是乘 2。Project 2 把相同的部分写在 AAttackSkill 里,CBite、CTackle 各 11 行,只传 POWER 和 ENERGY_COST。加一个新技能的对比表在 第 12 章第 9 节。

数值:setter + 复制 vs 只读 Mokepon + Fighter

Project 1:谁都能改防御;reset() 没人调用Project 1 · Mokepon.java第 82–89、151–156 行
82 // Update defence without going above 100.
83 public void setDefence(int defence) {
84 if (defence > 100) {
85 this.defence = 100;
86 } else {
87 this.defence = defence;
88 }
89 }
⋯ 省略第 90–150 行
151 // Restore HP, energy and defence to their starting values.
152 public void reset() {
153 this.hp = maxHP;
154 this.energy = maxEnergy;
155 this.defence = originDefence;
156 }
Project 2:全是 private final,构造器里复制model/Mokepon.java第 8–26 行
8/* A Mokepon as it is written in the data file. Its stats never change.
9 * A battle uses a Fighter made from it, so the Mokepon itself stays the same.
10 */
11public class Mokepon {
12 private final String name;
13 private final String imagePath;
14 private final MokeponType type;
15 private final Map<Stat, Integer> stats;
16 private final List<Skill> skills;
17
18 /* Keep our own copies of the stats and skills, so no other class can change them. */
19 public Mokepon(String name, String imagePath, MokeponType type,
20 Map<Stat, Integer> stats, List<Skill> skills) {
21 this.name = name;
22 this.imagePath = imagePath;
23 this.type = type;
24 this.stats = new EnumMap<>(stats);
25 this.skills = List.copyOf(skills);
26 }

Project 1 靠 GameWindow.copyMokepon(第 531–533 行)在开打前复制一份,战斗里 Bunker 调用的 setDefence 改的是副本。可是 Mokepon 有 9 个 public setter,任何类都能改原来那一份,要靠“大家都记得先复制”才安全;reset() 写好了,整个项目却没有一处调用它。Project 2 让 CMokepon 根本改不了,每场战斗新造一个 CFighter 来装会变的数,问题就没了(第 10 章)。

敌人出招:一个方法 vs 一个策略类

Project 1:Battle 自己的方法Project 1 · Battle.java第 63–75 行
63 // Randomly choose a skill the enemy can afford.
64 public Skill chooseEnemySkill() {
65 List<Skill> availableSkills = new ArrayList<>();
66
67 for (Skill s : enemy.getSkills()) {
68 if (enemy.canUse(s)) {
69 availableSkills.add(s);
70 }
71 }
72
73 int index = random.nextInt(availableSkills.size());
74 return availableSkills.get(index);
75 }
Project 2:同样的代码,搬进实现接口的类battle/RandomEnemyStrategy.java第 8–21 行
8/* Picks a random skill out of the ones the enemy has enough energy for. */
9public class RandomEnemyStrategy implements EnemyStrategy {
10
11 @Override
12 public Skill chooseSkill(Fighter enemy, Random random) {
13 List<Skill> affordableSkills = new ArrayList<>();
14 for (Skill skill : enemy.getSkills()) {
15 if (enemy.canUse(skill)) {
16 affordableSkills.add(skill);
17 }
18 }
19 return affordableSkills.get(random.nextInt(affordableSkills.size()));
20 }
21}

两边的循环几乎一字不差。区别不在“怎么挑”,而在“放在哪”:Project 1 想换一种挑法,只能改 Battle;Project 2 写一个新的 implements EnemyStrategy 的类,再让 CEncounter.createBattle 用 5 个参数的构造器把它传进去,CBattle 一行不改(现在还没有人这样传,见第 3 节)。

窗口和菜单:extends Stage + 两套菜单 vs 实现接口 + 一个 GameMenu

Project 1:GameWindow 就是一个 Stage;两个方法各造一套菜单Project 1 · GameWindow.java第 24–24、551–589 行
24public class GameWindow extends Stage {
⋯ 省略第 25–550 行
551 // Create the menu with reset and restart disabled before battle.
552 private MenuBar getSelectMenu() {
553 MenuItem reset = new MenuItem("Reset");
554 reset.setDisable(true);
555
556 MenuItem restart = new MenuItem("Restart Battle");
557 restart.setDisable(true);
558
559 MenuItem exit = new MenuItem("Exit");
560 exit.setOnAction(event -> Platform.exit());
561
562 Menu gameMenu = new Menu("Game");
563 gameMenu.getItems().addAll(reset, restart, exit);
564
565 MenuBar menuBar = new MenuBar(gameMenu);
566 menuBar.setPrefWidth(1280);
567 return menuBar;
568 }
569
570 // Create the battle menu with reset, restart and exit actions.
571 private MenuBar getBattleMenu(List<Mokepon> mokeponList, Mokepon playerTemplate, Mokepon enemyTemplate) {
572 MenuItem reset = new MenuItem("Reset");
573 reset.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN));
574 reset.setOnAction(event -> resetSelectScreen());
575
576 MenuItem restart = new MenuItem("Restart Battle");
577 restart.setAccelerator(new KeyCodeCombination(KeyCode.R));
578 restart.setOnAction(event -> battleScreen(mokeponList, playerTemplate, enemyTemplate));
579
580 MenuItem exit = new MenuItem("Exit");
581 exit.setOnAction(event -> Platform.exit());
582
583 Menu gameMenu = new Menu("Game");
584 gameMenu.getItems().addAll(reset, restart, exit);
585
586 MenuBar menuBar = new MenuBar(gameMenu);
587 menuBar.setPrefWidth(1280);
588 return menuBar;
589 }
Project 2:实现接口,用字段存着 Stage 和一个 GameMenuui/GameWindow.java第 14–25 行
14/* Owns the window and decides which screen it shows.
15 * The menu bar stays at the top, and the current screen fills the space below it.
16 */
17public class GameWindow implements GameNavigator {
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;
只有一个菜单:问当前画面能做什么ui/GameMenu.java第 43–49 行
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 }

Project 1 的 GameWindow extends Stage:它“就是”窗口,继承了 Stage 的全部方法;选择界面、战斗界面的每一个按钮和标签也都在这个类里造,光 battleScreen 一个方法就从第 232 行写到第 435 行。菜单靠两个方法各造一套:选择界面那套在第 554、557 行写死 setDisable(true),战斗那套每开一场战斗再造一次。Project 2 的 CGameWindow 不继承 Stage,而是用字段 stage 存着它:has-a 代替 is-a,这就是“组合优于继承”的思路。不过按第 9 节的分法,这个字段本身是关联,不是组合,因为 Stage 是 JavaFX 造好、经 CMain.start 第 38 行交进构造器的(真正的组合例子是 CStatBar 自己 new 的 ProgressBar)。菜单只造一次,换画面时 CGameMenu.update 问 screen.canRunAway(),由每个画面自己回答(多态)。一个什么都管的上帝类,就是这样被拆开的。

读数据:一个 static 方法 vs 每个类管一段

Project 1:Main 里一个 static 方法全包Project 1 · Main.java第 63–96 行
63 // Build the Mokepon list from the numbered data entries.
64 public static List<Mokepon> generateMokepon(Properties gameData) {
65 List<Mokepon> mokepons = new ArrayList<>();
66 int index = 0;
67
68 // Read entries in order until the next name is missing.
69 while (gameData.containsKey("mokepon." + index + ".name")) {
70 String prefix = "mokepon." + index + ".";
71
72 String name = gameData.getProperty(prefix + "name");
73 String image = gameData.getProperty(prefix + "image");
74 int hp = Integer.parseInt(gameData.getProperty(prefix + "hp"));
75 int energy = Integer.parseInt(gameData.getProperty(prefix + "energy"));
76 int strength = Integer.parseInt(gameData.getProperty(prefix + "strength"));
77 int accuracy = Integer.parseInt(gameData.getProperty(prefix + "accuracy"));
78 int defence = Integer.parseInt(gameData.getProperty(prefix + "defence"));
79 int dodge = Integer.parseInt(gameData.getProperty(prefix + "dodge"));
80
81 // Convert the skill names into Skill objects.
82 List<String> skills = List.of(gameData.getProperty(prefix + "skills").split("\\|"));
83
84 List<Skill> skillList = new ArrayList<>();
85 for (String s : skills) {
86 skillList.add(Skill.generateSkill(s));
87 }
88
89 Mokepon m = new Mokepon(name, image, hp, energy, strength, accuracy, defence, dodge, skillList);
90
91 mokepons.add(m);
92 index++;
93 }
94
95 return mokepons;
96 }
Project 2:MokeponParser 只读 Mokepon 那一段data/MokeponParser.java第 17–49 行
17 /* Read mokepon.0, mokepon.1, ... in order until the next name is missing. */
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 }

两边做的事差不多。Project 1 读文件和造 Mokepon 都在 Main 里,六个数值一行一个 Integer.parseInt;技能名不认识时 generateSkill 返回 null,悄悄放进列表。Project 2 读文件交给 CGameDataReader,数字转换交给 CGameData.getNumber;第 34–36 行遍历 Stat.values(),以后多一种属性这里不用改;技能交给 SkillCatalogue.find,不认识就抛异常。地图和图片由 CMapParser、CImageParser 各管一段(Project 1 没有地图)。

战斗日志:交出列表 vs 只读视图

Project 1:窗口从外面往日志里加一行Project 1 · GameWindow.java第 257–261 行
257 Mokepon player = copyMokepon(playerTemplate);
258 Mokepon enemy = copyMokepon(enemyTemplate);
259
260 Battle battle = new Battle(player, enemy);
261 battle.getBattleLog().add("Player chose " + player.getName() + ". Began a fight against enemy " + enemy.getName() + "!");
Project 2:第一行由 Battle 自己写,外面只能读battle/Battle.java第 30–39、61–64 行
30 public Battle(Fighter player, Fighter enemy, Inventory inventory,
31 EnemyStrategy enemyStrategy, Random random) {
32 this.player = player;
33 this.enemy = enemy;
34 this.inventory = inventory;
35 this.enemyStrategy = enemyStrategy;
36 this.random = random;
37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
39 }
⋯ 省略第 40–60 行
61 /* Other classes can read the log but cannot change it. */
62 public List<String> getLog() {
63 return Collections.unmodifiableList(log);
64 }

Project 1 的 getBattleLog() 第 40 行直接 return battleLog;,还有 setBattleLog 能整个换掉。GameWindow 第 261 行就利用了这一点:从外面往 Battle 的列表里加东西。这是一个典型的隐私泄露。Project 2 的 CBattle 在构造器第 37–38 行自己写这一行,getLog() 只给只读视图。

菜单项也不一样:Project 1 是 Reset、Restart Battle、Exit;Project 2 是 Run away、Main menu、Exit(第 5 节的截图)。

考试怎样组织这些内容来答题,见下一章 第 17 章。

自测

你的代码里哪里用了模板方法?为什么 Skill.use 要写 final?

三处:ASkill.use(先 spendEnergy,再 applyEffect)、ATargetedSkill.applyEffect(先判断命中,中了才 applyHit)、AGameScreen.handleKeyPress(对话框开着或组合键就不理,否则 handleKey)。final 让子类改不了顺序:每个技能都一定先付能量,没打中也一样(Galecrest 的 Tackle 没中,能量 14 → 9)。applyEffect、applyHit 是子类必须写的抽象步骤(primitive operation),handleKey 有空的默认版本,是 hook method。

English: Skill.use() is a final Template Method: it always calls spendEnergy() first and then the abstract applyEffect(), so no subclass can change the order.

三种颜色的感叹号为什么不是三个子类?这用的是什么模式?颜色是从哪来的?

策略模式。它们都是 CMarker。挑哪只敌人由字段 picker(类型是接口 IEnemyPicker)里装的对象决定:白色 CRandomEnemyPicker,红色 new TerrainEnemyPicker(ADVANTAGE),绿色 new TerrainEnemyPicker(DISADVANTAGE),由 CMapParser 第 31–35 行配好。chooseEnemy 只是委托给 picker.pick(...)。颜色不是 picker 给的:每组有自己的 Sprite,颜色读自数据文件的 .colour 键(white、red、lime),CMapView 按它上色。这也是“组合优于继承”。

English: All markers are the same Marker class; each group gets its own Sprite (the colour) and its own EnemyPicker strategy (how the enemy is chosen), so no subclasses are needed.

观察者模式里,谁登记监听者?谁发通知?打赢以后状态栏的字是怎么来的?

CGameWindow.startGame 第 61 行 world.addListener(overworldScreen) 登记。COverworld 通过 notifyListeners 发通知。打赢后:Back to it! → navigator.winBattle() → CGameWindow.winBattle → world.winEncounter()(拿掉感叹号、加一场胜利)→ notifyListeners(listener -> listener.onEncounterWon(enemy)) → COverworldScreen.onEncounterWon 写 “You beat the enemy Galecrest!” 并 update() → 回到 winBattle,showScreen(overworldScreen)。

English: GameWindow registers OverworldScreen with world.addListener(); after winEncounter() the Overworld notifies all listeners with onEncounterWon(), and the screen updates its status bar.

“依赖倒置”在你的代码里“倒置”了什么?举两个例子。这样做的好处是什么?

本来会是“画面直接认识窗口”“规则直接认识画面”。现在中间各夹了一个接口:画面和 CGameMenu 只认识 IGameNavigator,CGameWindow implements 它;COverworld 只认识 IOverworldListener(放在 world 包里),COverworldScreen implements 它。好处:没有循环依赖(GameWindow new 画面,画面却不认识 GameWindow);world 包不 import ui,规则可以不开窗口单独测试;换掉 GameWindow 时画面类不用改。注意别说“接口让画面碰不到 stage”:stage 本来就是 private 的。

English: The screens and the menu depend on the GameNavigator interface, which GameWindow implements; Overworld depends on OverworldListener, which OverworldScreen implements, so the world package never depends on the ui package and there is no dependency cycle.

getLog() 和 getItems() 都防止了隐私泄露,它们有什么不同?你的代码里还剩下哪种泄露?

Battle.getLog() 返回只读视图:和原列表是同一份数据,CBattle 以后写的新行也看得见(用 Poison 前后日志从 11 行变 13 行),但改它会抛异常。Inventory.getItems() 返回 List.copyOf 的副本:复制那一刻的样子,和原来的不再相连。还剩下的:Battle.getPlayer() 交出真的 CFighter,它的 takeDamage、restoreHp 等都是 public;getInventory() 交出能改的 CInventory;CGameWindow 构造器直接存下解析器造的 Mokepon 列表,没有复制。

English: getLog() returns an unmodifiable view of the live list, while getItems() returns a copy; Battle.getPlayer() still exposes a mutable Fighter with public mutators, and GameWindow stores the parser's list without copying it.

CFighterPanel 的 hpBar 在 UML 里写的是 StatBar,为什么玩家的条上有数字?

多态。字段声明的类型是 CStatBar;构造器里 showNumbers 为 true(玩家),装进去的是子类 CLabelledStatBar;敌人装的是普通 CStatBar。hpBar.update(...) 运行的是实际对象的版本:CLabelledStatBar 先 super.update 改长度,再改数字。

English: hpBar is declared as StatBar; the player's panel stores a LabelledStatBar in it, and its overriding update() also changes the numbers.

IGameNavigator 符合接口隔离原则吗?

不完全符合。它有 7 个方法,可 CSelectScreen 只用 startGame,COverworldScreen 只用 startBattle,CGameMenu 只用 runAway、returnToMainMenu、quit。可以拆成几个小接口,由 CGameWindow 一起实现。反过来 IEnemyStrategy、IEnemyPicker 各只有 1 个方法,是做得好的例子。

English: GameNavigator is wider than its clients need; GameMenu only uses three of its seven methods, so it could be split into smaller interfaces.

CSkillCatalogue 是单例吗?说一个好处和一个坏处。

不是。单例是“类只造一个自己的对象”(private 构造器 + getInstance);CSkillCatalogue 根本不造自己的对象,只用一个 static 列表存 10 个技能对象,用 static 的 find 查。好处:每个技能只有一个不可变对象,所有 Mokepon 共用,名字拼错马上抛异常。坏处:全局的 static 调用,测试时换不掉;加新技能要改这个列表;没写 private 构造器,还是能被 new。

English: SkillCatalogue is a static catalogue, not a Singleton: it is never instantiated; it keeps one shared, immutable object per skill in a static list.

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