Mokepon代码导读
第四站 · 战斗L3约 40 分钟

13状态效果和道具

流血、致盲、眩晕怎么持续几回合;三种道具、背包和道具目录

读完这一章你能
  • 说出 AStatusEffect 给了哪三个默认做法,以及 CBleeding、CBlinded、CStunned 各自只改写了哪一个
  • 讲清楚一个效果从哪来(Lance、Pocket Sand、Stun 打中时 new 出来)、存在哪(CFighter 的 effects 列表)、身上有好几个效果时怎么算
  • 照着真实截图一回合一回合数出 turnsLeft:流血为什么正好扣两次血,为什么只在第二次写 The bleeding stopped.
  • 解释 endTurn 为什么“先扣血、再倒数”,countDownEffects 为什么要用 Iterator 删除
  • 说出三种道具做什么、CInventory 怎么记数量、CItemCatalogue 为什么有两种顺序,以及为什么可以直接拿道具对象当 Map 的键
  • 从“点道具按钮”和“走上问号箱”开始,把调用一步一步说出来,并用英语回答相关的设计题

前两章讲了一个回合(第 11 章)和技能(第 12 章)。里面有两样东西一直说“第 13 章再讲”:技能打中以后留在对手身上的状态效果,和战斗里点的道具。这一章就讲这两样。它们在两个包里:effect(4 个类)和 item(6 个类)。

先提醒几个第 1、2 章讲过的词:抽象类(abstract class)不能 new,只能当父类用;子类用 extends 继承父类,自动拥有父类的字段和方法;改写(override)是子类写一个名字、参数都一样的方法,换掉父类的做法(有的书叫“重写”,是同一件事);字段(field)是对象自己存着的数据。新的词,用到的地方再解释。

这一章的截图来自四场真实的战斗,和第 11、12 章用的是同几场,字母也一样(B 局这一章用不到):A 局 你的 Impike 对 Galecrest,C 局 你的 Nightwing 对 Bubbore,D 局 你的 Ironshell 对 Frostback,E 局 你的 Owlet 对 Impike。敌人是随机挑的,所以每一局的对手都不一样。

1. 先看屏幕:效果和道具在哪里看得到

先看一场真实的战斗:你选了 Ironshell,敌人是 Frostback。打完三个回合以后,屏幕是这样的:

用道具之前:HP 被打掉了一些,能量也用掉了12345
  1. 1
    三个道具按钮(BattleScreen.itemButtons):上面是图片,下面是背包里还剩几个。Health 1、Poison 0(灰的,点不了)、Coffee 1
  2. 2
    Frostback 的 Pocket Sand 打中了:Ironshell 身上多了一个 CBlinded(这是第 1 回合加的;到拍这张图的时候,它已经倒数完、被删掉了,见第 4 节末尾)
  3. 3
    Frostback 的 Lance 打中了:26 点伤害,Ironshell 身上又多了一个 CBleeding
  4. 4
    HP 228 / 280:比 280 − 26 = 254 又少了 26(第 5 节讲为什么)
  5. 5
    能量 1 / 10:只剩 Wait 能点,所以第 8 节要用 Coffee
Ironshell 对 Frostback,第 3 回合结束时真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了

日志的字太小,把标注 2、3 那一块放大:

用道具之前:HP 被打掉了一些,能量也用掉了
23
  1. 2
    第 1 回合:Pocket Sand 打中,写了两行(Pocket sand blinded Ironshell! 和 Their targeted skills may miss for 3 turns.)。第 2 回合的 Pocket Sand 没打中(But they missed.),没有效果
  2. 3
    第 3 回合:Hit Ironshell for 26 damage! They started bleeding!
上图日志区放大(battle-before-items)真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了

注意一件事:屏幕上没有任何图标告诉你“谁身上有什么效果”。CBattleScreen 根本不画效果。你只能从两个地方看出来:日志里的句子(标注 2、3),和回合结束时变了的数字(标注 4)。日志里和效果有关的句子,一共只有这四种:

前三句是技能对象写的,就在效果被加上的那一刻(第 12 章)。第四句是效果对象自己写的,在回合结束时。“还剩几回合”屏幕上哪里都没有,这一章我们照着代码一回合一回合把它数出来。

这一章的 10 个类分成两组:

包类一句话节
effectAStatusEffect所有效果的抽象父类:还剩几回合、三个默认做法2
CBleeding、CBlinded、CStunned流血、致盲、眩晕2–5
itemAItem所有道具的抽象父类6
CHealthTonic、CCoffee、CPoison回满 HP、回满能量、敌人 HP 变 16
CInventory背包:每种道具有几个7
CItemCatalogue全游戏共用的三个道具对象7

用到它们的类在别的包里,这一章都会碰到:

2. StatusEffect:父类给默认做法,子类只改一处

先看整个家族,以及谁存着它们:

正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle抽象类:所有状态效果的共同部分,主要是还剩几回合。AStatusEffecteffect流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingeffect致盲效果:目标性技能额外有 50% 机会打空,持续 3 回合。CBlindedeffect眩晕效果:被眩晕的一方造成的伤害减半,持续 3 回合。CStunnedeffect技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLanceskill技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSandskill技能 Stun:花 4 点能量,没有伤害,但让目标伤害减半 3 回合。CStunskilleffectsattackernew(第 20 行)new(第 17 行)new(第 17 行)
状态效果一家。空心三角 = extends;空心菱形 = 用字段存着(菱形在“存的人”那头,线上是字段名);实线箭头 = 调用 new,线上是哪一行

这张图这样读:CFighter 有一个字段 effects,里面放的是 AStatusEffect;真正放进去的,是三个子类的对象。CBleeding 自己又存着一个 CFighter:打它的那个人。最下面一排是第 12 章的三个技能,打中时各 new 一个效果。

StatusEffect 的 UML 类图
老师打印稿里的 StatusEffect:斜体名字和 A = 抽象类。名字前的小图标:红色方块 = private,绿色圆点 = public,黄色菱形 = protected(第 3 章细讲)。注意它没有一个斜体方法:一个抽象方法都没有

整个父类只有这么多:

StatusEffect.javaeffect/StatusEffect.java第 6–46 行
6/* A temporary effect that stays on a fighter for a number of turns.
7 * Each kind of effect only overrides the parts it changes.
8 */
9public abstract class StatusEffect {
101 private int turnsLeft;
11
122 protected StatusEffect(int turns) {
13 this.turnsLeft = turns;
14 }
15
16 /* The owner's damage is multiplied by this. Most effects do not change damage. */
173 public double getDamageMultiplier() {
18 return 1;
19 }
20
21 /* The extra chance that the owner's targeted skills miss. Most effects add none. */
224 public double getMissChance() {
23 return 0;
24 }
25
26 /* Lingering damage effects override this to hurt the owner at the end of a turn.
27 * Other effects do nothing. Returns the messages for the battle log.
28 */
295 public List<String> applyLingeringDamage(Fighter owner) {
30 return List.of();
31 }
32
33 /* Count one finished turn. */
346 public void countDown() {
35 turnsLeft--;
36 }
37
387 public boolean isOver() {
39 return turnsLeft <= 0;
40 }
41
42 /* Whether this is the effect's last turn, before it is counted. */
438 protected boolean isLastTurn() {
44 return turnsLeft == 1;
45 }
46}
  1. 标注 1,第 10 行 private int turnsLeft:还剩几回合。它是 private:连子类也不能直接改,只能通过下面的 countDown() 减 1。它没写 final:final 的字段只能赋值一次,而 turnsLeft-- 每回合都要给它换一个新的数(第 3 节还会看到一个写了 final、里面的东西却在变的字段)。
  2. 标注 2,第 12–14 行:构造器是 protected 的,只给子类的 super(...) 用。每个子类把自己的回合数交上来,第 13 行 this.turnsLeft = turns 存进字段:this.turnsLeft 是这个对象自己的字段,右边的 turns 是构造器的参数(this)。
  3. 标注 3,第 17 行 getDamageMultiplier():这个效果让主人打出去的伤害乘几。默认 1,也就是不变。
  4. 标注 4,第 22 行 getMissChance():主人的 TargetedSkill 额外多几成落空。默认 0。
  5. 标注 5,第 29 行 applyLingeringDamage(Fighter owner):回合结束时伤害主人,返回要写进日志的句子。默认什么都不做,返回空列表 List.of()。
  6. 标注 6,第 34–36 行 countDown():过完一个回合,turnsLeft--(减 1)。
  7. 标注 7,第 38–40 行 isOver():turnsLeft <= 0 就结束了。<= 是“小于或等于”。代码里打的是 < 和 = 两个字符;这个网站的代码字体会把它们连起来画成一个像 ≤ 的样子,Java 里并没有 ≤ 这个符号。
  8. 标注 8,第 43–45 行 isLastTurn():turnsLeft == 1 就是最后一回合。== 是两个等号,意思是“比较是不是相等”(字体同样把它画得连在一起);一个 = 是赋值,第 13 行用的就是它。注释说 “before it is counted”:要在这一回合倒数之前问。它也是 protected,只有 CBleeding 用它。

“主人”(owner)就是身上带着这个效果的那个 CFighter。三个默认值的意思都是“我什么都不改”。所以一个子类只要改写它真正要改的那一个方法,另外两个照用父类的。

三个子类:每个只改写一个方法

Blinded.javaeffect/Blinded.java第 3–18 行
3/* A blinded fighter's targeted skills have an extra 50% chance to miss,
4 * even after they pass the normal hit check.
5 */
6public class Blinded extends StatusEffect {
71 public static final int TURNS = 3;
82 private static final double MISS_CHANCE = 0.5;
9
10 public Blinded() {
113 super(TURNS);
12 }
13
14 @Override
154 public double getMissChance() {
16 return MISS_CHANCE;
17 }
18}
Stunned.javaeffect/Stunned.java第 3–16 行
3/* A stunned fighter only does half of its normal damage. */
4public class Stunned extends StatusEffect {
51 public static final int TURNS = 3;
62 private static final double DAMAGE_MULTIPLIER = 0.5;
7
8 public Stunned() {
93 super(TURNS);
10 }
11
12 @Override
134 public double getDamageMultiplier() {
14 return DAMAGE_MULTIPLIER;
15 }
16}

两个文件长得一模一样,编号也一一对应:

Bleeding.java:整个文件effect/Bleeding.java第 1–31 行
1package au.edu.unimelb.swen.oop.mokepon.effect;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import java.util.List;
5
6/* A lingering damage effect from Lance. At the end of each turn the owner bleeds,
7 * taking damage worked out with half of the attacker's strength.
8 */
9public class Bleeding extends StatusEffect {
101 private static final int TURNS = 2;
112 private static final double HALF_STRENGTH = 0.5;
12
133 private final Fighter attacker;
14
15 public Bleeding(Fighter attacker) {
164 super(TURNS);
173 this.attacker = attacker;
18 }
19
20 /* The bleeding damage is not written to the log. Only after the second bleeding
21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.
22 */
23 @Override
24 public List<String> applyLingeringDamage(Fighter owner) {
255 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));
266 if (isLastTurn()) {
276 return List.of("The bleeding stopped.");
28 }
297 return List.of();
30 }
31}

为什么存一个人,而不是存一个伤害数?因为伤害是每次当场算的。标注 5 那一行请攻击者调用 CFighter 的 calculateDamage,它就是技能打人用的那个伤害公式(第 12 章第 5 节):

Fighter:DAMAGE_SCALE 和 calculateDamagebattle/Fighter.java第 19–19、114–122 行
191 private static final int DAMAGE_SCALE = 50;
⋯ 省略第 20–113 行
114 /* Work out the damage this fighter does to the target with the Project 1 formula.
115 * The power multiplies the user's strength: Tackle has power 2, while Lance and
116 * bleeding use half strength (power 0.5). The damage is truncated to an integer.
117 */
118 public int calculateDamage(Fighter target, double power) {
1192 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE
120 / target.getStat(Stat.DEFENCE);
1213 return (int) (damage * getDamageMultiplier());
122 }

所以流血每次都用那一刻攻击者的力量、主人的防御,还有攻击者身上有没有眩晕。C 局的例子:0.5 × 68(Nightwing 的 Strength)× 50 ÷ 74(Bubbore 的 Defence)= 22.97,(int) → 22。第 4 节会看到:Bubbore 中途用 Bunker 把防御从 74 加到 82,第二次流血就从 22 变成了 20。

伤害乘几多几成落空回合末做什么回合数
AStatusEffect(默认)10空列表子类交上来
CBlinded10.5空列表3
CStunned0.50空列表3
CBleeding10扣血;最后一回合返回一句话2

三列依次是 getDamageMultiplier()、getMissChance()、applyLingeringDamage(owner) 的结果。粗体是子类自己改写的,其余都是从父类继承来的默认值。

3. 效果从哪来、存在哪、几个一起怎么算

谁 new 的:三个技能,在打中的时候

三个效果都在技能的 applyHit 里造出来,也就是“打中以后”(第 12 章第 5 节),而且都加给对手(target):

技能哪一行new 了什么
CLance第 20 行new Bleeding(user):attacker 是出手的人
CPocketSand第 17 行new Blinded()
CStun第 17 行new Stunned()

拿最长的 CLance 看一下(另外两个只有 new 和写日志两步,代码在 第 12 章第 5 节,第 4 节的动画里也会出现 CPocketSand 的):

Lance.applyHit:先打,再加流血skill/Lance.java第 16–22 行
16 /* Deal the normal attack damage first, then start the bleeding. */
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
191 String hitMessage = super.applyHit(user, target, random);
202 target.addEffect(new Bleeding(user));
213 return hitMessage + " They started bleeding!";
22 }

没打中就没有效果:hits 返回 false 时,applyHit 根本不运行。每打中一次就 new 一个新对象,因为每个效果都有自己要倒数的 turnsLeft。放到哪里?三行都是 target.addEffect(...),而 addEffect 只有一行:

Fighter:effects 字段和 addEffectbattle/Fighter.java第 26–26、124–126 行
261 private final List<StatusEffect> effects = new ArrayList<>();
⋯ 省略第 27–123 行
124 public void addEffect(StatusEffect effect) {
1252 effects.add(effect);
126 }

身上有好几个效果:只算最强的那个

效果加上以后,谁去问它“乘几、落空几成”?CFighter 里的两个方法:

Fighter:把几个效果合成一个数battle/Fighter.java第 128–137、165–174 行
128 /* The extra chance that this fighter's targeted skills miss, for example when blinded.
129 * If there are several effects, only the strongest one counts.
130 */
131 public double getMissChance() {
1321 double missChance = 0;
133 for (StatusEffect effect : effects) {
1342 missChance = Math.max(missChance, effect.getMissChance());
135 }
136 return missChance;
137 }
⋯ 省略第 138–164 行
165 /* Effects such as stun can lower this fighter's damage.
166 * If there are several effects, only the strongest one counts.
167 */
168 private double getDamageMultiplier() {
1693 double multiplier = 1;
170 for (StatusEffect effect : effects) {
1714 multiplier = Math.min(multiplier, effect.getDamageMultiplier());
172 }
173 return multiplier;
174 }

第 133 行 for (StatusEffect effect : effects) 是 for-each 循环,读作:“把 effects 里的东西从头到尾一个一个拿出来,每次叫它 effect,做一遍大括号里的事”(第 2 章第 4 节)。列表里有两个效果,大括号里就跑两遍。Math.max(a, b) 返回两个数里大的那个,Math.min 返回小的那个。

用 Bubbore 第 2 回合算一算

Nightwing 的 Lance 打中以后,Bubbore 的 effects 是 [Blinded, Bleeding]。

12 落空几率:从 0 开始,挨个取 Math.max:max(0, 0.5) = 0.5(Blinded),max(0.5, 0) = 0.5(Bleeding 用默认的 0)。结果 0.5。

34 伤害倍率:从 1 开始,挨个取 Math.min:min(1, 1) = 1,min(1, 1) = 1。结果 1,Bubbore 的伤害不减。

如果身上有两个 Stunned:min(1, 0.5, 0.5) = 0.5,不是 0.25。注释里的 “only the strongest one counts” 就是这个意思。

4. 一个效果的一生:每回合结束倒数一次

效果什么时候减 1?只有一个地方:CBattle.endTurn 的最后两行(第 129–130 行),两边各调用一次 countDownEffects()。endTurn 只在两个人都出完手、都还站着的时候才运行(第 11 章第 5 节)。

Fighter.countDownEffects:倒数,删掉结束的battle/Fighter.java第 153–163 行
153 /* Count one finished turn for every effect, and remove the effects that are over. */
154 public void countDownEffects() {
1551 Iterator<StatusEffect> iterator = effects.iterator();
1562 while (iterator.hasNext()) {
1573 StatusEffect effect = iterator.next();
1584 effect.countDown();
1595 if (effect.isOver()) {
1606 iterator.remove();
161 }
162 }
163 }
  1. 标注 1,第 155 行:向 effects 要一个迭代器(Iterator)。它从列表开头一个一个往后走。
  2. 标注 2,第 156 行:while (iterator.hasNext()):后面还有就继续(while 循环)。
  3. 标注 3,第 157 行:iterator.next() 取出下一个效果。
  4. 标注 4,第 158 行:effect.countDown():turnsLeft 减 1。
  5. 标注 5、6,第 159–160 行:减完到 0 了(isOver()),就 iterator.remove():把刚才 next() 取出的那一个从列表里删掉。

Nightwing 对 Bubbore:三个回合,一格一格看

下面用 C 局的四张真实截图,看 Bubbore 身上的两个效果从被造出来到被删掉。最上面的表是时间线:一列是一个回合,亮的格子是这一步在变的东西(每一格写的是那一回合里发生的变化),这一格所在的行名、列名也会一起亮,告诉你它是哪个效果、哪个回合。中间是这一步正在运行的代码。最下面是两块放大的截图:左边是日志(框 1 是这一步相关的句子),右边是 Bubbore 的 HP 条(框 2)。敌人的 HP 条上不写数字,下面写的 195、151 这些数是游戏里的值。turnsLeft 屏幕上也看不到,表里的数是照代码数出来的。

回合 →第 1 回合Pocket Sand第 2 回合Lance第 3 回合WaitBlindedturnsLeftnew:3回合末 3 → 2回合末 2 → 1回合末 1 → 0删掉BleedingturnsLeft(还没有)new:2流血 22,2 → 1流血 20,写一句1 → 0,删掉Bubbore HP195195 → 173 → 151151 → 131
C 局时间线:Bubbore 身上的两个效果,和它的 HP
Fighter:effects 字段和 applyLingeringDamagebattle/Fighter.java第 26–26、142–151 行
26 private final List<StatusEffect> effects = new ArrayList<>();
⋯ 省略第 27–141 行
142 public List<String> applyLingeringDamage() {
143 List<String> messages = new ArrayList<>();
144 for (StatusEffect effect : effects) {
145 if (!isAlive()) {
146 break;
147 }
148 messages.addAll(effect.applyLingeringDamage(this));
149 }
150 return messages;
151 }
Fighter.countDownEffects(Battle.endTurn 第 129 行调用它)battle/Fighter.java第 153–163 行
153 /* Count one finished turn for every effect, and remove the effects that are over. */
154 public void countDownEffects() {
155 Iterator<StatusEffect> iterator = effects.iterator();
156 while (iterator.hasNext()) {
157 StatusEffect effect = iterator.next();
158 effect.countDown();
159 if (effect.isOver()) {
160 iterator.remove();
161 }
162 }
163 }
PocketSand.applyHitskill/PocketSand.java第 15–20 行
15 @Override
16 protected String applyHit(Fighter user, Fighter target, Random random) {
17 target.addEffect(new Blinded());
18 return "\tPocket sand blinded " + target.getName() + "!\n"
19 + "\tTheir targeted skills may miss for " + Blinded.TURNS + " turns.";
20 }
Blinded 的构造器effect/Blinded.java第 6–12 行
6public class Blinded extends StatusEffect {
7 public static final int TURNS = 3;
8 private static final double MISS_CHANCE = 0.5;
9
10 public Blinded() {
11 super(TURNS);
12 }
Lance.applyHitskill/Lance.java第 16–22 行
16 /* Deal the normal attack damage first, then start the bleeding. */
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
19 String hitMessage = super.applyHit(user, target, random);
20 target.addEffect(new Bleeding(user));
21 return hitMessage + " They started bleeding!";
22 }
Bleeding.applyLingeringDamageeffect/Bleeding.java第 23–30 行
23 @Override
24 public List<String> applyLingeringDamage(Fighter owner) {
25 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));
26 if (isLastTurn()) {
27 return List.of("The bleeding stopped.");
28 }
29 return List.of();
30 }
StatusEffect:倒数的三个方法effect/StatusEffect.java第 33–45 行
33 /* Count one finished turn. */
34 public void countDown() {
35 turnsLeft--;
36 }
37
38 public boolean isOver() {
39 return turnsLeft <= 0;
40 }
41
42 /* Whether this is the effect's last turn, before it is counted. */
43 protected boolean isLastTurn() {
44 return turnsLeft == 1;
45 }
Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
1
  1. 1
    开打时日志只有这一句
开打时的日志(battle-start-c)真实运行截图 · Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
13
  1. 1
    Pocket sand blinded Bubbore! 两行
  2. 3
    Enemy Bubbore used Swole!
第 1 回合结束时的日志(battle-blind)真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
1
  1. 1
    Hit Bubbore for 22 damage! …:22 只写了一次
第 2 回合结束时的日志(battle-bleed)真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
1
  1. 1
    The bleeding stopped.
第 3 回合结束时的日志(battle-bleed-stop;对流血来说是它的第 2 回合)真实运行截图 · 流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
2
  1. 2
    HP 195 / 195(满的)
Bubbore 的 HP 条真实运行截图 · Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
2
  1. 2
    还是 195 / 195
Bubbore 的 HP 条真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
2
  1. 2
    151 / 195:绿条剩 77%
Bubbore 的 HP 条真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
2
  1. 2
    131 / 195:绿条剩 67%
Bubbore 的 HP 条真实运行截图 · 流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
  1. 开打。CEncounter 刚 new 出 Bubbore 的 CFighter,第 26 行的 effects 是一个空列表。日志只有一句(框 1),HP 195 / 195(框 2)。
  2. 第 1 回合,你点 Pocket Sand,命中判定过了。CPocketSand.applyHit 第 17 行 target.addEffect(new Blinded()):造一个 Blinded,加进 Bubbore 的 effects。第 18–19 行写下框 1 那两行,句子里的 3 就是 Blinded.TURNS。
  3. “new Blinded()” 跑的是 Blinded 的构造器:第 11 行 super(TURNS),TURNS 是 3,于是父类 AStatusEffect 的构造器把 turnsLeft 设成 3。现在 Bubbore 的 effects = [Blinded(3)]。
  4. Bubbore 出手用 Swole(框 3)。Swole 是 ABoostSkill,不过命中判定,致盲管不着它(第 12 章第 6 节)。两人都站着,进入 endTurn。CBattle.endTurn 第 117 行调用 Bubbore 的 applyLingeringDamage(),就是上面 CFighter 第 142 行开始的这个方法:循环里只有 Blinded,它没改写 applyLingeringDamage,跑的是父类的默认版本,返回空列表,什么都没发生。
  5. CBattle.endTurn 第 129 行 enemy.countDownEffects(),跑的是上面这个方法:Blinded 3 → 2,isOver() 是 false,留下。HP 还是 195(框 2)。第 1 回合结束。
  6. 第 2 回合,你点 Lance,打中。第 19 行 super.applyHit 跑父类 AAttackSkill 的打人代码:0.5 × 68(Nightwing 的 Strength)× 50(DAMAGE_SCALE)÷ 74(Bubbore 的 Defence)= 22.97 → 22,HP 195 → 173,返回 “Hit Bubbore for 22 damage!”(框 1)。然后第 20 行 new Bleeding(user):user 是你的 Nightwing,存进 attacker;super(TURNS) 让 turnsLeft = 2。现在 effects = [Blinded(2),Bleeding(2)],新的排在后面。(截图是这一回合结束时拍的,已经包含了下一步的流血,所以 HP 条是 151。)
  7. Bubbore 用 Wait。endTurn 第 117 行:CFighter 依次问两个效果。Blinded 给空列表。Bleeding 第 25 行:attacker(Nightwing).calculateDamage(owner = Bubbore, 0.5) = 0.5 × 68 × 50 ÷ 74 = 22.97 → 22,HP 173 → 151(框 2,77%)。turnsLeft 是 2,不是最后一回合,第 29 行返回空列表:所以日志里 22 只出现了一次(框 1)。
  8. 第 129 行倒数:迭代器先拿到 Blinded,2 → 1;再拿到 Bleeding,2 → 1。两个都没到 0,都留下。第 2 回合结束。
  9. 第 3 回合:你用 Wait,Bubbore 用 Bunker,防御 74 → 82。endTurn:Blinded 还是空列表。Bleeding 再当场算一次:0.5 × 68 × 50 ÷ 82 = 20.73 → 20,HP 151 → 131(框 2)。这次 turnsLeft == 1,isLastTurn() 是 true,第 27 行返回 "The bleeding stopped.",CBattle 用 log.addAll 把它加到日志最后(框 1)。
  10. 第 129 行倒数:Blinded 1 → 0,isOver() 是 true,iterator.remove() 删掉;Bleeding 1 → 0,也删掉。Bubbore 的 effects 又空了。从第 4 回合起,它的 getMissChance() 又是 0。
  11. 回头数一数:Blinded 被倒数了 3 次(第 1、2、3 回合末),正好是 TURNS = 3;Bleeding 扣了 2 次血(第 2、3 回合末),正好是 TURNS = 2。isLastTurn() 判断的是 turnsLeft == 1 而不是 0,因为它在倒数之前被问到(注释:“before it is counted”)。

“3 回合”到底是哪 3 回合

效果被加上的那一回合也算一回合,因为那一回合结束时就倒数了一次。所以流血在被打中的那一回合末扣第一次,下一回合末扣第二次;致盲、眩晕从加上的那一回合起,连着三个回合末各倒数一次。

但是主人在有效期里能出手几次,还要看是谁先出手。每个回合都是你先、敌人后(第 11 章第 3 节):

真实的一局哪回合加的有效期里主人出手几次
C:你的 Pocket Sand 让 Bubbore 致盲第 1 回合3 次(第 1–3 回合)。它用的 Swole、Wait、Bunker 都不是 ATargetedSkill,致盲白挨了
D:Frostback 的 Pocket Sand 让你的 Ironshell 致盲第 1 回合2 次(第 2–3 回合),第 1 回合你已经先出过手了。两次都用 Aim,也没受影响
A:你的 Stun 让 Galecrest 眩晕第 2 回合1 次(第 2 回合的 Tackle,没打中),第 3 回合你先出手就赢了

照代码数,D 局的 Blinded 在第 1、2、3 回合末各倒数一次,第 3 回合末被删掉。所以第 1 节那张截图的时刻,Ironshell 身上只剩 Bleeding。

5. 回合末的四条规则(Battle.endTurn)

endTurn 的大致顺序第 11 章第 5 节讲过。这里只看和效果有关的四条规则。规则 1、2 写在 endTurn 上面那段注释里(标注 1–3 把注释的句子和做这件事的代码行对上);规则 3 写在 CBleeding 的注释里;规则 4 没有注释,是从 useItem 和 playTurn 的代码看出来的。

Battle.endTurn:注释和代码battle/Battle.java第 110–131 行
110 /* Lingering damage is applied after both fighters have had their turn.
1111 * The enemy's damage goes first. If it makes the enemy faint, the player's damage
1122 * is skipped, so both Mokepon can never faint in the same turn.
1133 * Only then is the turn counted, so a stun still halves bleeding damage
114 * at the end of its last turn.
115 */
116 private void endTurn() {
1171 log.addAll(enemy.applyLingeringDamage());
1182 if (!enemy.isAlive()) {
119 finish(true);
120 return;
121 }
122
1233 log.addAll(player.applyLingeringDamage());
124 if (!player.isAlive()) {
125 finish(false);
126 return;
127 }
128
1293 enemy.countDownEffects();
1303 player.countDownEffects();
131 }

规则 1:敌人先算;敌人倒下,你的就不算了(标注 1、2)

第 117 行先算敌人身上的持续伤害。第 118 行马上检查:敌人倒下了,就 finish(true) 然后 return,第 123 行你的持续伤害根本不跑。所以注释说两个 Mokepon 永远不会在同一回合一起倒下(这几局截图里没有发生这种情况)。

第 117、123 行调用的 CFighter.applyLingeringDamage,是每个回合末叫一遍所有效果的那个循环:

Fighter.applyLingeringDamage:把每个效果叫一遍battle/Fighter.java第 139–151 行
139 /* Let every lingering damage effect hurt this fighter at the end of a turn.
140 * Stop early if the fighter faints. Returns the messages for the battle log.
141 */
142 public List<String> applyLingeringDamage() {
1431 List<String> messages = new ArrayList<>();
1442 for (StatusEffect effect : effects) {
1453 if (!isAlive()) {
1463 break;
147 }
1484 messages.addAll(effect.applyLingeringDamage(this));
149 }
1505 return messages;
151 }
  1. 标注 1,第 143 行:先造一个新的空列表 messages,用来收集要写进日志的句子。
  2. 标注 2,第 144 行:for-each 把 effects 里的效果按加上的先后一个一个拿出来。
  3. 标注 3,第 145–147 行:主人已经倒下了(!isAlive(),! 读作“不是”),就 break:马上跳出整个循环(break),后面的效果不再扣血。这和第 118 行是同样的小心。
  4. 标注 4,第 148 行:effect.applyLingeringDamage(this):this 就是这个 CFighter 自己,交给效果当 owner(主人)。效果返回的句子列表,用 addAll 全部接到 messages 后面;返回空列表就什么都不加。
  5. 标注 5,第 150 行:把收集到的句子交回给 CBattle,它再 log.addAll(...)。

规则 2:先扣血,再倒数(标注 3)

两边的持续伤害都算完(第 117、123 行),才轮到第 129–130 行倒数。顺序反过来会怎样?拿 C 局的 Bleeding 试一下:

现在:先扣血,再倒数假如:先倒数,再扣血
第 2 回合末扣 22(turnsLeft 是 2,不写日志)→ 倒数到 1倒数到 1 → 扣 22,isLastTurn 已经是 true,马上写 The bleeding stopped.
第 3 回合末扣 20,写 The bleeding stopped. → 倒数到 0,删掉倒数到 0,删掉 → 已经不在列表里了,不扣血
一共扣两次只扣一次,而且第一次就说“停了”

注释里说的眩晕,也是同一个道理。流血用的是攻击者的 calculateDamage,所以看的是攻击者身上有没有 CStunned(第 3 节)。下面是一个按代码推出来的例子(这几局截图里没有发生):D 局里 Frostback 让 Ironshell 流血,每次 26 点。假设这时 Frostback 身上有一个只剩最后 1 回合的 Stunned:

这就是注释那句 “a stun still halves bleeding damage at the end of its last turn”:眩晕在它的最后一回合里,一直到回合末结算完都还算数。

Ironshell 那一回合:日志写 26,HP 却少了 52

回到第 1 节那张图。第 3 回合,Frostback 的 Lance 日志只写了 “Hit Ironshell for 26 damage!”,HP 却从 280 掉到了 228:

Bleeding:流血的伤害不写日志effect/Bleeding.java第 20–30 行
201 /* The bleeding damage is not written to the log. Only after the second bleeding
21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.
22 */
23 @Override
24 public List<String> applyLingeringDamage(Fighter owner) {
252 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));
26 if (isLastTurn()) {
27 return List.of("The bleeding stopped.");
28 }
291 return List.of();
30 }
用道具之前:HP 被打掉了一些,能量也用掉了
1
  1. 1
    日志里只有 Lance 本身打的 26
D 局第 3 回合的日志(放大)真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
用道具之前:HP 被打掉了一些,能量也用掉了
2
  1. 2
    280 − 26(Lance)− 26(流血)= 228
同一时刻 Ironshell 的 HP真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了

第二个 26 是 CBleeding 第 25 行扣的(标注 2)。它发生在回合末,第 29 行返回空列表,日志里一个字都没有(标注 1)。这一回合末完整的调用顺序如下。顺序图的读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回。动画里亮起来的箭头会变成红色、流动的虚线,那只表示“这一步正在发生”,不是返回;它原来是实线还是虚线,看前后几步里它变灰时的样子。图下面是这一步正在运行的代码。

CBattleCplayer : FighterCenemy : FighterCblind : BlindedCbleed : BleedingapplyLingeringDamage()1applyLingeringDamage()2applyLingeringDamage(this)3applyLingeringDamage(this)4calculateDamage(owner, 0.5)5266takeDamage(26)7countDownEffects()8countDownEffects()9countDown()10iterator.remove():删 Blinded11countDown()12Blinded 1 → 0,被删掉;Bleeding 2 → 1,留下
D 局第 3 回合末:Battle.endTurn 里谁调用了谁(player 是你的 Ironshell,enemy 是 Frostback)
Battle.endTurn(前半)battle/Battle.java第 116–121 行
116 private void endTurn() {
117 log.addAll(enemy.applyLingeringDamage());
118 if (!enemy.isAlive()) {
119 finish(true);
120 return;
121 }
Battle.endTurn(后半)battle/Battle.java第 122–131 行
122
123 log.addAll(player.applyLingeringDamage());
124 if (!player.isAlive()) {
125 finish(false);
126 return;
127 }
128
129 enemy.countDownEffects();
130 player.countDownEffects();
131 }
Fighter.applyLingeringDamage(Ironshell 这个对象在运行它)battle/Fighter.java第 142–151 行
142 public List<String> applyLingeringDamage() {
143 List<String> messages = new ArrayList<>();
144 for (StatusEffect effect : effects) {
145 if (!isAlive()) {
146 break;
147 }
148 messages.addAll(effect.applyLingeringDamage(this));
149 }
150 return messages;
151 }
StatusEffect:applyLingeringDamage 的默认版本(Blinded 用的就是它)effect/StatusEffect.java第 26–31 行
26 /* Lingering damage effects override this to hurt the owner at the end of a turn.
27 * Other effects do nothing. Returns the messages for the battle log.
28 */
29 public List<String> applyLingeringDamage(Fighter owner) {
30 return List.of();
31 }
Bleeding.applyLingeringDamageeffect/Bleeding.java第 23–30 行
23 @Override
24 public List<String> applyLingeringDamage(Fighter owner) {
25 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));
26 if (isLastTurn()) {
27 return List.of("The bleeding stopped.");
28 }
29 return List.of();
30 }
Fighter:calculateDamage 和 getDamageMultiplier(Frostback 这个对象在运行它)battle/Fighter.java第 118–122、168–174 行
118 public int calculateDamage(Fighter target, double power) {
119 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE
120 / target.getStat(Stat.DEFENCE);
121 return (int) (damage * getDamageMultiplier());
122 }
⋯ 省略第 123–167 行
168 private double getDamageMultiplier() {
169 double multiplier = 1;
170 for (StatusEffect effect : effects) {
171 multiplier = Math.min(multiplier, effect.getDamageMultiplier());
172 }
173 return multiplier;
174 }
Fighter.countDownEffects(Ironshell 这个对象在运行它)battle/Fighter.java第 154–163 行
154 public void countDownEffects() {
155 Iterator<StatusEffect> iterator = effects.iterator();
156 while (iterator.hasNext()) {
157 StatusEffect effect = iterator.next();
158 effect.countDown();
159 if (effect.isOver()) {
160 iterator.remove();
161 }
162 }
163 }
  1. 两人都站着,进入 endTurn。第 117 行 enemy.applyLingeringDamage():Frostback 的 effects 是空的,CFighter 里那个 for-each 一次都不跑,返回空列表,log.addAll 什么都没加。第 118 行:Frostback 还活着,继续。
  2. 第 123 行 player.applyLingeringDamage():Ironshell 身上有两个效果,按加上的先后排着:[Blinded(第 1 回合加的,剩 1),Bleeding(这一回合刚加的,剩 2)]。
  3. 进入 Ironshell 的 CFighter.applyLingeringDamage。for-each 先拿到 Blinded。第 145 行:Ironshell 还活着,不 break。第 148 行调用 effect.applyLingeringDamage(this),this 就是 Ironshell 自己。
  4. Blinded 没改写这个方法,所以跑的是父类 AStatusEffect 第 29–31 行的默认版本:什么都不做,返回空列表。回到 Fighter 第 148 行,addAll 什么都没加。
  5. for-each 再拿到第二个:Bleeding,它改写了这个方法。第 25 行要先问 attacker:attacker 是 Frostback,因为 Frostback 用 Lance 时,CLance 第 20 行把 user(Frostback)传进了 new Bleeding(user)。于是请 Frostback 算:calculateDamage(Ironshell, 0.5)。
  6. 在 Frostback 的 CFighter 里算:第 119–120 行 0.5 × 100(Frostback 战斗里的 Strength)× 50(DAMAGE_SCALE)÷ 96(Ironshell 的 Defence)= 26.04;第 121 行乘 Frostback 自己的 getDamageMultiplier():它身上没有效果,循环不跑,是 1;(int) → 26。和 Lance 本身打的一样多,因为 Lance 的威力也是 0.5。
  7. 回到 Bleeding 第 25 行:owner.takeDamage(26),Ironshell 254 → 228。接着第 26 行问 isLastTurn():turnsLeft 是 2,不是;第 29 行返回空列表。所以日志里没有这个 26。
  8. 回到 CBattle。第 124 行:Ironshell 还活着,不 return。第 129 行 Frostback 没有效果可倒数;第 130 行轮到 Ironshell 的 countDownEffects()。
  9. 迭代器先拿到 Blinded:1 → 0,isOver() 是 true,iterator.remove() 删掉。再拿到 Bleeding:2 → 1,留下。所以第 1 节截图那一刻,Ironshell 身上只有 [Bleeding(剩 1)]。
  10. 接下来你用了 Coffee 和 Health Tonic(第 8 节)。用道具不跑 endTurn,这个 Bleeding 一直停在 1。照代码推下去:下一次你出手、回合结束时,它会再扣 26,写 The bleeding stopped.,然后被删掉(截图里没有拍这一回合)。

规则 3:最后一下流血,就算把人打倒了,也写那句话

CBleeding 第 20–22 行的注释完整是:“The bleeding damage is not written to the log. Only after the second bleeding damage does the log say that the bleeding stopped, even if this damage ends the battle.” 意思是:流血的伤害不写进日志;只有第二次流血以后,日志才说流血停了,就算这一下把战斗打完了也照样说。E 局就是这样。你的 Owlet 被 Impike 的 Lance 打中:0.5 × 76(Impike 的 Strength)× 50 ÷ 42(Owlet 用过 Bunker 以后的 Defence)= 45.24 → 45,HP 120 → 75;同一回合末第一次流血,同样的算式又是 45,75 → 30。下一回合两边都用 Wait,回合末第二次流血 45,30 − 45 被 takeDamage 里的 Math.max(0, …) 挡在 0:

玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
12
  1. 1
    “The bleeding stopped.” 是上面 Bleeding 第 27 行返回的
  2. 2
    然后才是 finish(false) 写的 fainted 和 You lose
E 局最后几行日志(放大)真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

回看本节前面那段 CBleeding 代码(第 20–30 行):第 25 行先扣血,第 26 行才问是不是最后一回合;它不管主人还活不活着,句子照样返回。回到 CBattle,第 124 行发现你倒下了:finish(false),return。第 129–130 行这次没运行,所以这个 Bleeding 到最后也没被删掉(还剩 1)。这不要紧:战斗结束了,两个 CFighter 对象都会被丢掉。

规则 4:不跑 endTurn,就不倒数

倒数只写在 endTurn 里。有两种情况走不到它:用道具(useItem 根本不调用它),和这一回合有人被技能打倒(playTurn 第 84、92 行提前 return)。A 局这四张截图,两种情况都有:

用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)用 Stun 命中:敌人被眩晕,3 回合内伤害减半用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合敌人倒下:敌人卷轴变红,弹出 You Win 对话框
A 局四帧真实截图(放大到 Galecrest 的血条和日志)。每一帧的文字说的是 Galecrest 身上的效果;turnsLeft 是照代码数出来的,屏幕上看不到

把用 Poison 前后的日志放在一起看(同一块放大):

用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    第 2 回合:你的 Stun,然后是敌人的 Tackle。敌人出手只会出现在 playTurn 里
第 2 回合结束(battle-stun)真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
2
  1. 2
    用 Poison:只多了一个空行和这一行。后面没有 Enemy Galecrest used …,也没有回合末的结算
用 Poison 以后(battle-poison)真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合

所以“一回合”在代码里的准确意思是:一次 playTurn 一直跑到 endTurn。用道具不算回合;把对手打倒的那一下,也走不到回合末。如果考试问 When are the effects counted down?,可以答:Only at the end of a full turn, in Battle.endTurn, after the lingering damage. Using an item, or knocking a fighter out with a skill, does not count the effects down.

6. 道具:Item 和三个子类

再看道具。战斗画面上,道具按钮在你的卷轴上方;每个按钮上面是图片,下面是背包里的数量。图片路径来自数据文件:

mokepon.game:和道具有关的两段mokepon.game第 462–472 行
462map.items.image=assets/sprite_question.png
463map.items.0.type=HEALTH
464map.items.0.location=3,12
465map.items.1.type=POISON
466map.items.1.location=18,16
467map.items.2.type=COFFEE
468map.items.2.location=2,2
469
4703items.coffee=assets/sprite_coffee.png
4711items.heal=assets/sprite_heal.png
4722items.poison=assets/sprite_poison.png
Ironshell 的战斗开始:上方道具按钮 Health 1、Poison 0、Coffee 1
123
  1. 1
    Health Tonic:图片是 items.heal,数量 1
  2. 2
    Poison:items.poison,数量 0,所以是灰的
  3. 3
    Coffee:items.coffee,数量 1
D 局开打时的三个道具按钮(放大)真实运行截图 · Ironshell 的战斗开始:上方道具按钮 Health 1、Poison 0、Coffee 1

数据文件里和道具有关的有两段。第 462 行是所有问号箱共用的那张问号图片(CMapParser 第 70 行读它);第 463–468 行是地图上的三个问号箱,每个箱子两行:一行 type(HEALTH、POISON、COFFEE),一行 location(位置)。第 470–472 行是战斗按钮上的三张图片。注意同一种道具在这两段里名字不一样:箱子写 HEALTH,图片写 heal,屏幕上又显示 Health Tonic。这三个名字都存在 AItem 对象里。

背包:记录每种道具各有几个。CInventoryitem全部道具的目录:每种道具只造一个对象,全游戏共用。CItemCatalogueitem神秘箱子(问号):里面有一个道具,踩上去就捡到。CMysteryBoxworld抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle道具 Health Tonic:把玩家 Mokepon 的 HP 回满。CHealthTonicitem道具 Coffee:把玩家 Mokepon 的能量补满。CCoffeeitem道具 Poison:把敌人的 HP 直接降到 1。CPoisonitemcounts(键)ITEMS 等5 个 static 字段itemuse(player, enemy)
道具一家,和谁存着道具。空心三角 = extends;空心菱形 = 用字段存着(线上是字段名);虚线箭头 = 只在方法参数里用到(依赖)
Item 的 UML 类图
老师打印稿里的 Item:斜体的 use 是抽象方法;构造器和 describeUse 前面是黄色菱形(protected)
Item.java:整个文件item/Item.java第 1–43 行
1package au.edu.unimelb.swen.oop.mokepon.item;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4
5/* A single use item from a mystery box. Each kind of item has its own effect in a battle. */
6public abstract class Item {
7 /* How the data file names this item in a mystery box, for example HEALTH. */
81 private final String typeName;
9 /* The name shown to the player, for example Health Tonic. */
102 private final String name;
11 /* The end of the data file key for its battle image, for example heal for items.heal. */
123 private final String imageName;
13
144 protected Item(String typeName, String name, String imageName) {
15 this.typeName = typeName;
16 this.name = name;
17 this.imageName = imageName;
18 }
19
20 public String getTypeName() {
21 return typeName;
22 }
23
24 public String getName() {
25 return name;
26 }
27
28 public String getImageName() {
29 return imageName;
30 }
31
32 /* Use the item in a battle and return the message for the battle log.
33 * Using an item always works, so there is no hit check.
34 */
355 public abstract String use(Fighter player, Fighter enemy);
36
37 /* Every item message starts the same way, for example
38 * "You used your Coffee on Fumubble. It restored all your energy!".
39 */
406 protected String describeUse(Fighter target, String result) {
41 return "You used your " + name + " on " + target.getName() + ". " + result;
42 }
43}
  1. 标注 1,第 8 行 typeName:数据文件里箱子用的名字,比如 HEALTH。CItemCatalogue.find 靠它查(第 7 节)。
  2. 标注 2,第 10 行 name:给玩家看的名字,比如 Health Tonic。地图卷轴、状态栏、战斗日志都用它。
  3. 标注 3,第 12 行 imageName:图片键的最后一段,比如 heal。CImageParser 第 44 行拼成 "items." + item.getImageName(),读出 assets/sprite_heal.png。
  4. 标注 4,第 14–18 行:protected 构造器,三个子类用 super(...) 把三个名字交上来。三个字段都是 private final:造好以后谁也改不了。
  5. 标注 5,第 35 行:use(Fighter player, Fighter enemy) 是抽象方法,没有方法体,每个子类都必须写。两个战斗者都交给道具,道具自己挑用哪一个。注释说 “Using an item always works, so there is no hit check”:没有命中判定。
  6. 标注 6,第 40–42 行:describeUse(target, result) 拼出日志里那一句:"You used your " + name + " on " + target.getName() + ". " + result。三种道具的句子开头都一样,所以写在父类,子类只交后半句。
typeName(数据文件)name(屏幕上)imageName(图片键)
HEALTHHealth Tonicheal
COFFEECoffeecoffee
POISONPoisonpoison

三行依次是 CHealthTonic、CCoffee、CPoison 构造器里交给 super(...) 的三个名字。

三个子类:一个构造器,一个 use

HealthTonic.javaitem/HealthTonic.java第 5–16 行
5/* Heals the player's Mokepon back to its maximum HP. */
6public class HealthTonic extends Item {
7 public HealthTonic() {
81 super("HEALTH", "Health Tonic", "heal");
9 }
10
11 @Override
12 public String use(Fighter player, Fighter enemy) {
132 player.restoreHp();
143 return describeUse(player, "It healed you to max hp!");
15 }
16}
Coffee.javaitem/Coffee.java第 5–16 行
5/* Gives the player's Mokepon all of its energy back. */
6public class Coffee extends Item {
7 public Coffee() {
81 super("COFFEE", "Coffee", "coffee");
9 }
10
11 @Override
12 public String use(Fighter player, Fighter enemy) {
132 player.restoreEnergy();
143 return describeUse(player, "It restored all your energy!");
15 }
16}

两个文件结构一样:标注 1 把三个名字交给父类;标注 2 调用 CFighter 的一个方法;标注 3 用 describeUse(player, ...) 拼日志,target 是你自己,所以句子写 “on Ironshell”。

CPoison 是唯一一个对敌人用的:

Poison.javaitem/Poison.java第 5–16 行
5/* Leaves the enemy Mokepon with only 1 HP. */
6public class Poison extends Item {
7 public Poison() {
8 super("POISON", "Poison", "poison");
9 }
10
11 @Override
12 public String use(Fighter player, Fighter enemy) {
131 enemy.reduceHpToOne();
142 return describeUse(enemy, "It devastated them!");
15 }
16}
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    用之前:Galecrest HP 93 / 93,绿条是满的(敌人的血条上不写数字,93 / 93 是游戏里的值,见 第 14 章第 5 节)
用 Poison 之前(battle-stun)真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
1
  1. 1
    用之后:HP 1 / 93,只剩最左边一小条绿色
用 Poison 之后(battle-poison)真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
2
  1. 2
    describeUse(enemy, …):所以写的是 “on Galecrest”
日志最后一行真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合

标注 1,第 13 行 enemy.reduceHpToOne():stats.put(Stat.HP, 1),直接设成 1,不是减多少。所以 Poison 永远打不倒人,useItem 也就不需要检查谁倒下了。1 ÷ 93 只有 1%,血条却还留着一小条:CStatBar 让大于 0 的值至少显示 3%(MIN_VISIBLE_RATIO,第 14 章)。标注 2,第 14 行传的是 enemy,所以句子里是敌人的名字。

道具和技能比一比:

ASkill.useAItem.use
写法final 模板方法:先付能量,再 applyEffect抽象方法:每个子类整个自己写
参数user、target、randomplayer、enemy(不用随机数)
会不会失败TargetedSkill 可能打不中一定成功
算不算回合算:接着敌人出手、endTurn(除非有人先倒下)不算
对象有几个每种一个(SkillCatalogue)每种一个(ItemCatalogue)

道具不碰效果:三个 use 里没有一行和 effects 有关。D 局里你用了 Health Tonic,HP 回满了,身上的 Bleeding 还在(第 5 节)。

7. 背包 Inventory 和道具目录 ItemCatalogue

Inventory:道具 → 数量

Inventory.javaitem/Inventory.java第 7–43 行
7/* The items the player has picked up, and how many of each kind.
8 * There is only one object for each kind of item, so that object is used as the key.
9 */
10public class Inventory {
11 /* A LinkedHashMap keeps the items in the order they were added. */
121 private final Map<Item, Integer> counts = new LinkedHashMap<>();
13
14 /* Start with none of each item. */
15 public Inventory(List<Item> items) {
16 for (Item item : items) {
172 counts.put(item, 0);
18 }
19 }
20
21 public List<Item> getItems() {
22 return List.copyOf(counts.keySet());
23 }
24
25 public int getCount(Item item) {
263 return counts.getOrDefault(item, 0);
27 }
28
29 public boolean has(Item item) {
30 return getCount(item) > 0;
31 }
32
33 public void add(Item item) {
344 counts.put(item, getCount(item) + 1);
35 }
36
37 /* Take away one of the item, if there is one left. */
38 public void remove(Item item) {
395 if (has(item)) {
40 counts.put(item, getCount(item) - 1);
41 }
42 }
43}

标注 1、2、4 在屏幕上都看得到,就在地图右上角的卷轴里:

捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个
124
  1. 1
    卷轴下半部分:按 counts 的顺序列出三种道具
  2. 2
    Poison 0:构造器放进去的 0,没人 add 过
  3. 4
    Coffee 1:刚捡的,add 把 0 改成了 1
D 局:捡完两个箱子以后,地图右上角的卷轴(放大)真实运行截图 · 捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个
  1. 标注 1,第 12 行:Map<Item, Integer> counts:键是道具对象,值是数量(Map)。值写的是 Integer 不是 int:Integer 是 int 的对象版,Map 和 List 里只能放对象,Java 会自动在 int 和 Integer 之间转换(自动装箱)。实际对象是 LinkedHashMap,它记得放进去的先后顺序,卷轴就按这个顺序显示。这个字段也是 final,和 CFighter 的 effects 一样:它永远指着同一个 Map,put 改的是 Map 里面的值。
  2. 标注 2,第 15–19 行:构造器收一个道具列表,每种先放一个 0。是谁传的列表?COverworld 构造器第 38 行:new Inventory(ItemCatalogue.getAll())。所以一开局三种道具都有一行,都是 0。
  3. 标注 3,第 25–27 行 getCount:counts.getOrDefault(item, 0)(getOrDefault):没有这个键时返回 0,不会返回 null。has(第 29–31 行)就是 getCount(item) > 0。
  4. 标注 4,第 33–35 行 add:counts.put(item, getCount(item) + 1)。对已经有的键再 put,会把旧的值换掉,位置不变。
  5. 标注 5,第 38–42 行 remove:先 has,有才减 1。所以数量永远不会变成负数。

还有第 21–23 行 getItems():List.copyOf(counts.keySet())。keySet() 是 Map 里所有的键(keySet);List.copyOf 再复制成一个不能改的列表交出去(防御性复制)。拿到这个列表的 CPlayerStatsScroll 只能读,改不了背包。想改数量,只能走 add 和 remove:这就是封装。

为什么可以直接拿道具对象当键

AItem 没有改写 equals 和 hashCode。这时 Map 认键的规则是:必须是同一个对象(equals 和 hashCode);内容一样的两个对象,也算两个不同的键。那为什么不出错?因为全游戏每种道具只有一个对象,大家拿的都是它:

ItemCatalogueCOFFEE 和两个列表Inventory.counts的键MysteryBox (2,2)的 itemBattleScreen.itemButtons 的键PlayerStatsScroll.itemValues 的键GameImages.itemImages 的键COFFEE 这一个对象new Coffee() 只跑过一次
对象图:六个地方存着同一个 Coffee 对象(空心菱形 = 用字段存着,框里写的是哪个字段;BattleScreen 只在战斗中存在)

六个地方都拿同一个对象当键或字段,所以 inventory.getCount(item)、itemButtons 里的查找都能对上。它们都是从目录拿到这个对象的:COverworld 用 ItemCatalogue.getAll() 建背包;CMapParser 用 ItemCatalogue.find("COFFEE") 给箱子;CBattleScreen 用 ItemCatalogue.getBattleOrder() 造按钮;CImageParser 也用 getAll()。没有一个地方自己写 new Coffee()。

道具对象为什么可以全游戏共用?它只有三个 final 的名字,没有会变的数据;“有几个”记在 CInventory 里,不在道具身上。这和第 2 节的效果正好相反:效果有自己会变的 turnsLeft,所以每次打中都 new 一个。

ItemCatalogue:三个对象,两种顺序,一个 find

ItemCatalogue.javaitem/ItemCatalogue.java第 5–36 行
5/* Every kind of item in the game.
6 * There is only one object for each kind of item, and the whole game shares it.
7 * To add a new item, write a new subclass and add one object of it to both lists.
8 */
9public class ItemCatalogue {
101 private static final Item HEALTH_TONIC = new HealthTonic();
111 private static final Item COFFEE = new Coffee();
121 private static final Item POISON = new Poison();
13
14 /* All items, in the order of the overworld scroll. */
152 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. */
173 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;
25 }
26
27 /* Find an item by the type name used in the data file, for example POISON. */
284 public static Item find(String typeName) {
29 for (Item item : ITEMS) {
30 if (item.getTypeName().equals(typeName)) {
31 return item;
32 }
33 }
345 throw new IllegalArgumentException("Unknown item: " + typeName);
35 }
36}

标注 2 和 3 的两种顺序,在两个画面上都看得到:

捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个
2
  1. 2
    ITEMS 的顺序:Health Tonic、Coffee、Poison
地图卷轴(box-coffee)真实运行截图 · 捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个
Ironshell 的战斗开始:上方道具按钮 Health 1、Poison 0、Coffee 1
3
  1. 3
    BATTLE_ORDER 的顺序:Health、Poison、Coffee
战斗按钮(battle-start-d)真实运行截图 · Ironshell 的战斗开始:上方道具按钮 Health 1、Poison 0、Coffee 1
  1. 标注 1,第 10–12 行:三个 private static final 常量,各 new 一次。static 表示它们属于类本身,整个程序只有一份;这个类第一次被用到时就造好。
  2. 标注 2,第 15 行 ITEMS:地图卷轴的顺序。getAll() 交出它 → COverworld 第 38 行拿它建 CInventory → LinkedHashMap 记住这个顺序 → CPlayerStatsScroll 第 62 行 for (Item item : player.getInventory().getItems()) 一行一行加到卷轴上。
  3. 标注 3,第 17 行 BATTLE_ORDER:战斗按钮的顺序,注释说是照着作业给的样图排的。getBattleOrder() 交出它 → CBattleScreen 第 211 行 for (Item item : ItemCatalogue.getBattleOrder()) 一个一个造按钮。
  4. 标注 4,第 28–33 行 find(typeName):在 ITEMS 里挨个用 equals 比 getTypeName() 的文字,找到就返回那个对象。注意这里比的是两个 String:String 改写了 equals,比的是文字内容,"POISON" 和 "POISON" 就相等。而 AItem 没有改写 equals,所以两个 Item 之间的 equals 只认同一个对象(上一小节)。比较字符串要用 equals,不要用 ==:== 比的是“是不是同一个对象”(引用相等)。
  5. 标注 5,第 34 行:数据文件里写了不认识的名字,就 throw new IllegalArgumentException("Unknown item: " + typeName)(throw)。

两个列表都是 List.of(...) 造的,不能修改,所以 getAll() 直接把它交出去也安全,不用复制。这个类只有静态成员,是一个静态目录类;它不是单例模式(Singleton),因为它根本不 new 它自己。

find 是谁用的?读数据文件的时候:

MapParser.parseMysteryBoxesdata/MapParser.java第 68–79 行
68 /* All mystery boxes share one image, and each box holds one item. */
69 private List<MapObject> parseMysteryBoxes(GameData data) {
701 Sprite sprite = new Sprite(data.getText(ITEMS_PREFIX + "image"));
71 List<MapObject> boxes = new ArrayList<>();
722 int count = data.countEntries(ITEMS_PREFIX, ".type");
73 for (int index = 0; index < count; index++) {
743 String prefix = ITEMS_PREFIX + index + ".";
754 Item item = ItemCatalogue.find(data.getText(prefix + "type"));
765 boxes.add(new MysteryBox(data.getPosition(prefix + "location"), sprite, item));
77 }
78 return boxes;
79 }
mokepon.game:它读的那几行(编号和上面的代码对应)mokepon.game第 462–468 行
4621map.items.image=assets/sprite_question.png
4634map.items.0.type=HEALTH
4645map.items.0.location=3,12
4654map.items.1.type=POISON
4665map.items.1.location=18,16
4674map.items.2.type=COFFEE
4685map.items.2.location=2,2
第 463 行map.items.0.type=HEALTH第 464 行map.items.0.location=3,12第 75 行getText(prefix + "type") → "HEALTH"find("HEALTH") → HEALTH_TONIC第 76 行getPosition(prefix + "location")→ (3,12)new MysteryBox((3,12), sprite, HEALTH_TONIC)item位置
index = 0 那一圈:两行数据怎么变成一个箱子(index = 1、2 两圈照样读第 465–468 行)。实线箭头 = 这一行的文字被这一行代码读走

同一个背包:从地图带进战斗,再带回来

还有一件事:战斗里用的背包不是复制品。

一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld玩家:他的 Mokepon、位置、上一个位置、背包和胜场。CPlayerworld一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle背包:记录每种道具各有几个。CInventoryitem不是复制品:战斗里用掉一个,地图卷轴也少一个playernew(第 38 行)inventoryinventory
只有一个 Inventory 对象:Overworld new 了它,Player 和 Battle 的字段 inventory 都指着它(空心菱形 = 用字段存着;实线箭头 = 调用 new)

COverworld.createBattle 第 83 行把 player.getInventory() 交给 CEncounter.createBattle,它第 56 行 new Battle(player, enemyFighter, inventory) 把同一个对象存进 CBattle 的字段。所以在 A 局里:

走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
1
  1. 1
    Poison: 1
① 地图上捡到 Poison(box-collected)真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
1
  1. 1
    战斗里用掉:1 → 0
② 战斗里用掉(battle-poison)真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
1
  1. 1
    回到地图:Poison: 0
③ 打赢回到地图(overworld-after-win)真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

③ 里的 0 不是谁去“同步”过来的:CPlayerStatsScroll.update 每次都去问同一个背包 getCount,问到的就是战斗里减过的数。

8. 动起来:点道具按钮,走上问号箱

先看结果:D 局连用两个道具

Ironshell 的战斗开始:上方道具按钮 Health 1、Poison 0、Coffee 1用道具之前:HP 被打掉了一些,能量也用掉了用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
D 局四帧真实截图(放大到道具按钮、你的卷轴和日志;日志的字下面的动画里会再放大)

下面分两段一步一步跑(以 Coffee 为例)。第一段从点按钮跑到写日志:上面是顺序图,亮的箭头是这一步;下面是正在运行的代码。这一段只改了数据,屏幕还没变。

你CBattleScreenCBattleCinv : InventoryCCOFFEE : CoffeeCplayer : Fighter点 Coffee 按钮1useItem(item)2useItem(item)3has(item)4remove(item)5use(player, enemy)6restoreEnergy()7You used your Coffee …8log.add(...)9(返回)10update()11getCount(item)12
点 Coffee 按钮:从头到尾只有你这一方的事,没有敌人
BattleScreen.createItemButtons(循环)ui/BattleScreen.java第 211–219 行
211 for (Item item : ItemCatalogue.getBattleOrder()) {
212 GameButton button = new GameButton("", font, ITEM_WIDTH / 2);
213 button.setGraphic(UiStyle.createIcon(UiStyle.loadImage(images.getItemImage(item)),
214 ITEM_ICON_SIZE));
215 button.setContentDisplay(ContentDisplay.TOP);
216 button.setPrefSize(ITEM_WIDTH, ITEM_HEIGHT);
217 button.addPressEffect();
218 button.setOnAction(event -> useItem(item));
219 itemButtons.put(item, button);
BattleScreen.useItemui/BattleScreen.java第 122–125 行
122 private void useItem(Item item) {
123 battle.useItem(item);
124 update();
125 }
Battle.useItembattle/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 }
Inventory:has 和 removeitem/Inventory.java第 29–31、38–42 行
29 public boolean has(Item item) {
30 return getCount(item) > 0;
31 }
⋯ 省略第 32–37 行
38 public void remove(Item item) {
39 if (has(item)) {
40 counts.put(item, getCount(item) - 1);
41 }
42 }
Coffee.useitem/Coffee.java第 11–15 行
11 @Override
12 public String use(Fighter player, Fighter enemy) {
13 player.restoreEnergy();
14 return describeUse(player, "It restored all your energy!");
15 }
Fighter:回满battle/Fighter.java第 102–104、106–108 行
102 public void restoreHp() {
103 stats.put(Stat.HP, getMaxHp());
104 }
⋯ 省略第 105–105 行
106 public void restoreEnergy() {
107 stats.put(Stat.ENERGY, getMaxEnergy());
108 }
Item.describeUseitem/Item.java第 37–42 行
37 /* Every item message starts the same way, for example
38 * "You used your Coffee on Fumubble. It restored all your energy!".
39 */
40 protected String describeUse(Fighter target, String result) {
41 return "You used your " + name + " on " + target.getName() + ". " + result;
42 }
  1. 每个道具按钮是在 createItemButtons 的这个循环里造的。第 218 行给它装了一段 lambda:event -> useItem(item),意思是“按钮被点的时候,调用 useItem(item)”(箭头在代码里是 - 和 > 两个字符,这个网站的代码字体把它们连着画成了一个箭头)。item 是这一圈循环的变量,所以 Coffee 按钮一直记着 COFFEE 这个对象(技能按钮也是这样,见 第 11 章第 2 节)。你点了 Coffee。
  2. CBattleScreen.useItem 只有两行:先交给 battle.useItem(item),再 update()。规则全在 CBattle 里,画面只负责显示。
  3. CBattle.useItem 第 102 行:if (finished || !inventory.has(item))。|| 读作“或者”:左右两边只要有一个是 true,就 return,什么都不做。! 读作“不是”:!inventory.has(item) 就是“背包里没有”(逻辑运算符)。现在战斗没结束,finished 是 false;has(COFFEE) → getCount 是 1,1 > 0 → true,所以 !has 是 false。两边都是 false,不 return,接着往下走。
  4. 第 105 行 inventory.remove(item):remove 自己又 has 一次(第 39 行),有,就 put(item, 1 − 1),Coffee 变成 0。
  5. 第 106 行加一个空行。第 107 行 item.use(player, enemy):item 的声明类型是 AItem,实际对象是 CCoffee,所以跑 Coffee 的 use(动态绑定)。CBattle 不知道、也不用知道这是哪一种道具。
  6. CCoffee.use 第 13 行:player.restoreEnergy()。enemy 这个参数它没用。
  7. CFighter.restoreEnergy:stats.put(Stat.ENERGY, getMaxEnergy())。最大能量是开打时的 10,所以 1 → 10。数据变了,但画面还没刷新。
  8. Coffee 第 14 行调用父类的 describeUse(player, "It restored all your energy!"),第 41 行拼出 “You used your Coffee on Ironshell. It restored all your energy!”,一路返回到 CBattle 第 107 行,log.add 加进日志。useItem 到这里就结束了:没有 chooseSkill,没有 endTurn。
  9. 回到 CBattleScreen.useItem 第 124 行:update()。它把屏幕和 CBattle 对一遍,其中会对每个道具按钮问一次背包 getCount。这一段看下一个动画。

第二段:update() 把屏幕刷新,然后再用一次 Health Tonic。下面是三块放大的真实截图:1 道具按钮,2 你的能量,3 你的 HP,4 日志最后几行。

BattleScreen.updateui/BattleScreen.java第 127–134 行
127 /* Make everything on the screen match the battle, and show the result once it is over. */
128 private void update() {
129 playerPanel.update();
130 enemyPanel.update();
131 showLog();
132 updateSkillButtons();
133 updateItemButtons();
134
BattleScreen.updateItemButtonsui/BattleScreen.java第 156–165 行
156 /* Each item button shows how many are left, and is disabled when there are none. */
157 private void updateItemButtons() {
158 Inventory inventory = battle.getInventory();
159 for (Map.Entry<Item, GameButton> entry : itemButtons.entrySet()) {
160 Item item = entry.getKey();
161 GameButton button = entry.getValue();
162 button.setText(String.valueOf(inventory.getCount(item)));
163 button.setUsable(!battle.isFinished() && inventory.has(item));
164 }
165 }
HealthTonic.useitem/HealthTonic.java第 11–15 行
11 @Override
12 public String use(Fighter player, Fighter enemy) {
13 player.restoreHp();
14 return describeUse(player, "It healed you to max hp!");
15 }
Battle.useItembattle/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 }
用道具之前:HP 被打掉了一些,能量也用掉了
1
  1. 1
    Coffee:1
真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
1
  1. 1
    Coffee:0,灰了
真实运行截图 · 用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
1
  1. 1
    Health:0,灰了
真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
用道具之前:HP 被打掉了一些,能量也用掉了
23
  1. 2
    能量 1
  2. 3
    HP 228
真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
2
  1. 2
    能量 10 / 10
真实运行截图 · 用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
3
  1. 3
    HP 280 / 280
真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
用道具之前:HP 被打掉了一些,能量也用掉了
4
  1. 4
    最后一行还是 Frostback 的 Lance 打的
真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
4
  1. 4
    一个空行,然后是 You used your Coffee on Ironshell. It restored all your energy!
真实运行截图 · 用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
4
  1. 4
    又一个空行和 You used your Health Tonic on Ironshell. It healed you to max hp!
真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
  1. 进 update() 之前,屏幕还是用道具之前的样子:Coffee 还写着 1(框 1),能量还是 1(框 2),日志最后一行还是 Lance 那句(框 4)。可是 CBattle 里的数据已经变了:背包里 Coffee 是 0,能量是 10,日志多了两行。
  2. update() 第 129 行刷新你的面板:能量条变成 10 / 10(框 2)。第 131 行 showLog 把整个日志重新写一遍,新的空行和那一句出现了(框 4)。第 133 行调用 updateItemButtons。
  3. updateItemButtons 第 159 行把 itemButtons 这个 Map 一对一对拿出来:entrySet() 交出所有的“键值对”,每一对叫 entry(Map.Entry),第 160 行 getKey() 是道具,第 161 行 getValue() 是它的按钮。轮到 Coffee 时,getCount 是 0:第 162 行 String.valueOf(0) 把数字 0 变成文字 "0" 写到按钮上;第 163 行 !battle.isFinished() && inventory.has(item),&& 是“并且”,两边都要是 true,现在 has 是 false,所以 setUsable(false),按钮变灰(框 1)。
  4. 再点 Health Tonic:走的是一模一样的路线,只是这次 item 是 HEALTH_TONIC,CBattle 第 107 行跑的是 CHealthTonic.use:player.restoreHp(),HP 228 → 280(开打时的最大值,框 3);日志写 “It healed you to max hp!”(框 4);update() 以后 Health 1 → 0,变灰(框 1)。
  5. 两次都没有敌人的行动,也没有回合末结算:Frostback 一次都没出手,Ironshell 身上的 Bleeding 还剩 1(第 5 节)。这就是 useItem 注释说的 “using it does not give the enemy a turn”。现在三个按钮都是 0:灰按钮点不了;就算有别的代码直接调用 useItem,第 102 行的 has 也会挡住。

走上问号箱:捡到道具

背包里的道具是从地图上捡来的。D 局里,Ironshell 从 (4,12) 往左走一格,踩上 (3,12) 的问号箱:

COverworldScreenCworld : OverworldCbox : MysteryBoxCinv : InventoryCPlayerStatsScrollmove(LEFT)1走到 (3,12),先通知 onPlayerMoved()interact(this)2collect(this)3objects.remove(box)4add(item)5onItemCollected(item)6statusBar.setText(...)7update()8getCount(item)9
走上问号箱:箱子请世界去改,世界改完通知画面
MysteryBox.javaworld/MysteryBox.java第 5–22 行
5/* A box holding one item. The player picks it up by stepping onto it. */
6public class MysteryBox extends MapObject {
71 private final Item item;
8
9 public MysteryBox(Position position, Sprite sprite, Item item) {
10 super(position, sprite);
11 this.item = item;
12 }
13
14 public Item getItem() {
15 return item;
16 }
17
18 @Override
19 void interact(Overworld world) {
202 world.collect(this);
21 }
22}
Overworld.collectworld/Overworld.java第 110–115 行
110 /* Called by a mystery box when the player steps onto it. A box can only be collected once. */
111 void collect(MysteryBox box) {
1123 objects.remove(box);
1134 player.getInventory().add(box.getItem());
1145 notifyListeners(listener -> listener.onItemCollected(box.getItem()));
115 }
捡到 Health Tonic(地图左下 (3,12) 的问号箱)
34566
  1. 3
    你站在 (3,12):这里原来的问号没了
  2. 4
    卷轴:Health Tonic: 1
  3. 5
    状态栏:You picked up a Health Tonic!
  4. 6
    还没捡的两个箱子:(2,2) 里是 Coffee,(18,16) 里是 Poison
D 局:Ironshell 从 (4,12) 往左走一格,捡到 Health Tonic(编号和上面的代码对应)真实运行截图 · 捡到 Health Tonic(地图左下 (3,12) 的问号箱)

接着 Ironshell 走到 (2,2),又捡到 Coffee。两次走的是完全一样的代码,区别只在箱子里装的是哪个对象:

捡到 Health Tonic(地图左下 (3,12) 的问号箱)
4
  1. 4
    Coffee: 0
捡到 Health Tonic 以后(box-health)真实运行截图 · 捡到 Health Tonic(地图左下 (3,12) 的问号箱)
捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个
4
  1. 4
    Coffee: 1:同一个 add,换了一个 item
再捡到 Coffee 以后(box-coffee)真实运行截图 · 捡到 Coffee(地图左上 (2,2) 的问号箱),卷轴里 Health Tonic 和 Coffee 各 1 个

谁负责什么

要做的事谁做为什么这样分
记每种有几个CInventory封装:数量只能通过 add / remove 改
一个道具具体做什么每个 AItem 子类的 use多态:CBattle 只写一句 item.use(...)
能不能用、不给敌人回合CBattle.useItem规则放在规则类里,和画面分开
显示数量、变灰战斗里 CBattleScreen;地图上 CPlayerStatsScroll画面只读数据,不改
捡道具CMysteryBox.interact → COverworld.collect物体决定怎么反应,世界负责改,再通知画面
每种道具只有一个对象CItemCatalogue所以对象本身可以当 Map 的键

下一章(第 14 章)讲这一章里一直出现的战斗画面是怎么画出来的:按钮、血条、红色的倒下遮罩和输赢对话框。

自测

CBlinded 和 CStunned 都没有写 applyLingeringDamage。回合末 CFighter 对它们调用这个方法时,会发生什么?

跑的是父类 AStatusEffect 第 29–31 行的默认版本:什么都不做,返回空列表 List.of(),不扣血、不写日志。所以 CFighter 可以对所有效果一视同仁地调用它,不用判断是哪一种。

English: Blinded and Stunned inherit the default applyLingeringDamage() from StatusEffect, which does nothing and returns an empty list, so Fighter can call it on every effect.

C 局第 2 回合,Nightwing 的 Lance 日志写的是 22 点,Bubbore 的 HP 却从 195 变成 151。另外那 22 是哪一行代码扣的?为什么日志里没有?

是 CBleeding 第 25 行 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH)) 扣的,它在 CBattle.endTurn 第 117 行被调用。那时 turnsLeft 是 2,isLastTurn() 是 false,第 29 行返回空列表,所以日志里什么都没加。到第 3 回合末第二次流血时,才返回 “The bleeding stopped.”。

English: The second 22 is taken by Bleeding.applyLingeringDamage at the end of the turn, called from Battle.endTurn. It is not the last turn yet, so it returns an empty list and nothing is written to the log.

CBleeding 为什么存一个 Fighter attacker,而不是在 Lance 打中时算好伤害、存一个 int?

因为伤害每次都要当场算:用那一刻攻击者的力量、主人的防御和攻击者的伤害倍率。C 局里 Bubbore 第 3 回合用 Bunker 把防御从 74 加到 82,第二次流血就从 22 变成 20;如果攻击者被眩晕,流血也会减半。存一个 int 就做不到这些。

English: Bleeding keeps a reference to the attacking Fighter, so the damage is recalculated every turn with the current strength, defence and stun multiplier.

countDownEffects 为什么用 Iterator 和 while,而不用 for-each 加 effects.remove(effect)?

一边用 for-each 走列表、一边删它的元素,Java 通常会抛出 ConcurrentModificationException。Iterator 的 remove() 专门用来删除刚刚 next() 取出的那个元素,删完还能继续往下走。C 局第 3 回合末,Blinded 和 Bleeding 就是这样在同一次循环里先后被删掉的。

English: countDownEffects uses an Iterator so it can safely remove finished effects while it is looping over the list.

D 局里你先用 Coffee 再用 Health Tonic。这期间 Ironshell 身上 Bleeding 的 turnsLeft 变了吗?Frostback 出手了吗?用代码说明。

都没有。CBattle.useItem(第 101–108 行)只做三件事:inventory.remove(item)、加一个空行、log.add(item.use(player, enemy))。它不调用 enemyStrategy.chooseSkill,也不调用 endTurn,所以敌人不出手,效果也不倒数,Bleeding 一直停在 1。日志里两句道具的话之间也没有任何 “Enemy Frostback used …”。

English: Using an item does not give the enemy a turn and does not count the effects down, because Battle.useItem never calls the enemy strategy or endTurn.

AItem 没有改写 equals 和 hashCode,CInventory 却拿道具对象当 Map 的键。为什么不出错?什么情况下会出错?

没改写时,只有同一个对象才算同一个键。全游戏每种道具只有 CItemCatalogue 里的那一个对象,背包、按钮、卷轴、图片表、箱子拿到的都是它,所以总能查到。如果某处自己 new Coffee() 再拿去 add 或 getCount,它会被当成一个新的键,数量就对不上了(用目录里的 COFFEE 去问,还是 0)。

English: ItemCatalogue keeps exactly one shared object for each kind of item, so the object itself can be used as the map key even though Item does not override equals and hashCode.

为什么地图卷轴的顺序是 Health Tonic、Coffee、Poison,战斗按钮却是 Health、Poison、Coffee?分别是哪一行决定的?

CItemCatalogue 有两个列表。第 15 行 ITEMS 是 Health Tonic、Coffee、Poison:COverworld 第 38 行用它建背包,LinkedHashMap 记住这个顺序,CPlayerStatsScroll 第 62 行按 getItems() 的顺序加行。第 17 行 BATTLE_ORDER 是 Health Tonic、Poison、Coffee:CBattleScreen 第 211 行按它造按钮。

English: ItemCatalogue has two lists: ITEMS gives the order of the overworld scroll through the Inventory, and BATTLE_ORDER gives the order of the item buttons in the battle screen.

老师问:How would you add a new item, for example one that gives back half of the energy? 先用中文说,再说英文。

写一个 AItem 的新子类:构造器用 super(typeName, name, imageName) 交三个名字;改写 use,比如 player.spendEnergy(-player.getMaxEnergy() / 2)(负的消耗就是回能量,spendEnergy 会把能量限制在最大值以内),再 return describeUse(player, "...")。然后改 CItemCatalogue(这是唯一要动的旧类):像第 10–12 行那样加一个常量,再把它加进 ITEMS 和 BATTLE_ORDER 两个列表(第 7 行的注释就是这么说的);数据文件里加一行 items.<imageName> 图片,想放进箱子就在 map.items.N.type 写它的 typeName。CBattle、CBattleScreen、CInventory、CPlayerStatsScroll 都不用改,因为它们只是遍历列表、调用 item.use(...)。

English: I would write one new subclass of Item that overrides use(), add one object of it to both lists in ItemCatalogue, and add its items.<imageName> line to the data file. Apart from those two lists in ItemCatalogue, no existing class has to change: Battle, BattleScreen, Inventory and PlayerStatsScroll only work with the abstract Item and the catalogue lists (Open/Closed Principle).

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