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。打完三个回合以后,屏幕是这样的:
12345- 1三个道具按钮(
BattleScreen.itemButtons):上面是图片,下面是背包里还剩几个。Health 1、Poison 0(灰的,点不了)、Coffee 1 - 2Frostback 的 Pocket Sand 打中了:Ironshell 身上多了一个 CBlinded(这是第 1 回合加的;到拍这张图的时候,它已经倒数完、被删掉了,见第 4 节末尾)
- 3Frostback 的 Lance 打中了:26 点伤害,Ironshell 身上又多了一个 CBleeding
- 4HP 228 / 280:比 280 − 26 = 254 又少了 26(第 5 节讲为什么)
- 5能量 1 / 10:只剩 Wait 能点,所以第 8 节要用 Coffee
日志的字太小,把标注 2、3 那一块放大:

- 2第 1 回合:Pocket Sand 打中,写了两行(Pocket sand blinded Ironshell! 和 Their targeted skills may miss for 3 turns.)。第 2 回合的 Pocket Sand 没打中(But they missed.),没有效果
- 3第 3 回合:Hit Ironshell for 26 damage! They started bleeding!
注意一件事:屏幕上没有任何图标告诉你“谁身上有什么效果”。CBattleScreen 根本不画效果。你只能从两个地方看出来:日志里的句子(标注 2、3),和回合结束时变了的数字(标注 4)。日志里和效果有关的句子,一共只有这四种:

- 1Pocket Sand 加上 Blinded 时写的两行

- 1Stun 加上 Stunned 时写的

- 1Lance 打中时写的这一行:前半句 Hit Bubbore for 22 damage! 是父类 AAttackSkill 写的,后半句 They started bleeding! 是 Lance 加上 Bleeding 时接上去的

- 1Bleeding 最后一次扣血时写的
前三句是技能对象写的,就在效果被加上的那一刻(第 12 章)。第四句是效果对象自己写的,在回合结束时。“还剩几回合”屏幕上哪里都没有,这一章我们照着代码一回合一回合把它数出来。
这一章的 10 个类分成两组:
| 包 | 类 | 一句话 | 节 |
|---|---|---|---|
| effect | AStatusEffect | 所有效果的抽象父类:还剩几回合、三个默认做法 | 2 |
| CBleeding、CBlinded、CStunned | 流血、致盲、眩晕 | 2–5 | |
| item | AItem | 所有道具的抽象父类 | 6 |
| CHealthTonic、CCoffee、CPoison | 回满 HP、回满能量、敌人 HP 变 1 | 6 | |
| CInventory | 背包:每种道具有几个 | 7 | |
| CItemCatalogue | 全游戏共用的三个道具对象 | 7 |
用到它们的类在别的包里,这一章都会碰到:
- battle:CFighter(存着效果,回合末叫它们)、CBattle(用道具、回合末结算)
- skill:CLance、CPocketSand、CStun(打中时 new 出效果)、ATargetedSkill(命中判定时问
getMissChance()) - ui:CBattleScreen(道具按钮)、CPlayerStatsScroll(地图卷轴上的数量)
- world:COverworld(建背包、捡箱子)、CMysteryBox(问号箱)、CPlayer(存着背包)、CEncounter(把背包交给战斗)
- data:CMapParser(读箱子里装什么)、CImageParser、CGameImages(道具图片)
2. StatusEffect:父类给默认做法,子类只改一处
先看整个家族,以及谁存着它们:
这张图这样读:CFighter 有一个字段 effects,里面放的是 AStatusEffect;真正放进去的,是三个子类的对象。CBleeding 自己又存着一个 CFighter:打它的那个人。最下面一排是第 12 章的三个技能,打中时各 new 一个效果。

整个父类只有这么多:
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,第 10 行
private int turnsLeft:还剩几回合。它是 private:连子类也不能直接改,只能通过下面的countDown()减 1。它没写 final:final 的字段只能赋值一次,而turnsLeft--每回合都要给它换一个新的数(第 3 节还会看到一个写了 final、里面的东西却在变的字段)。 - 标注 2,第 12–14 行:构造器是 protected 的,只给子类的
super(...)用。每个子类把自己的回合数交上来,第 13 行this.turnsLeft = turns存进字段:this.turnsLeft是这个对象自己的字段,右边的turns是构造器的参数(this)。 - 标注 3,第 17 行
getDamageMultiplier():这个效果让主人打出去的伤害乘几。默认 1,也就是不变。 - 标注 4,第 22 行
getMissChance():主人的 TargetedSkill 额外多几成落空。默认 0。 - 标注 5,第 29 行
applyLingeringDamage(Fighter owner):回合结束时伤害主人,返回要写进日志的句子。默认什么都不做,返回空列表List.of()。 - 标注 6,第 34–36 行
countDown():过完一个回合,turnsLeft--(减 1)。 - 标注 7,第 38–40 行
isOver():turnsLeft <= 0就结束了。<=是“小于或等于”。代码里打的是 < 和 = 两个字符;这个网站的代码字体会把它们连起来画成一个像 ≤ 的样子,Java 里并没有 ≤ 这个符号。 - 标注 8,第 43–45 行
isLastTurn():turnsLeft == 1就是最后一回合。==是两个等号,意思是“比较是不是相等”(字体同样把它画得连在一起);一个=是赋值,第 13 行用的就是它。注释说 “before it is counted”:要在这一回合倒数之前问。它也是 protected,只有 CBleeding 用它。
“主人”(owner)就是身上带着这个效果的那个 CFighter。三个默认值的意思都是“我什么都不改”。所以一个子类只要改写它真正要改的那一个方法,另外两个照用父类的。
三个子类:每个只改写一个方法
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 @Override154 public double getMissChance() {16 return MISS_CHANCE;17 }18}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 @Override134 public double getDamageMultiplier() {14 return DAMAGE_MULTIPLIER;15 }16}两个文件长得一模一样,编号也一一对应:
- 标注 1:
TURNS = 3,持续 3 回合。它是public static final,三个词各管一件事:static = 属于 CBlinded 这个类本身,整个程序只有一份,别的类写Blinded.TURNS就能用,不用先 new 一个 Blinded;final = 赋值以后不能再改,所以它是一个常量(名字全大写是常量的习惯写法);public = 别的包也能读。CPocketSand 和 CStun 拼日志时要用它(“may miss for 3 turns”“halved for 3 turns”),它们在 skill 包里,所以得是 public。 - 标注 2:真正要改的那个数,都是 0.5。它是
private static final:同样是常量,但只有这个类自己用,别人只能通过方法拿到它。 - 标注 3:构造器只有一句
super(TURNS):把 3 交给父类的构造器,存进turnsLeft(super)。 - 标注 4:CBlinded 只改写
getMissChance(),返回 0.5;CStunned 只改写getDamageMultiplier(),返回 0.5。另外两个方法都用父类的默认值。
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 bleeding21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.22 */23 @Override24 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}- 标注 1,第 10 行:流血只持续 2 回合。这里是 private:Lance 的日志 “They started bleeding!” 里没写回合数,别的类用不到它。
- 标注 2,第 11 行:
HALF_STRENGTH = 0.5:流血用攻击者一半的力量算。 - 标注 3,第 13、17 行:
private final Fighter attacker:打它的那个人,构造器收进来存好。第 17 行this.attacker = attacker:左边的this.attacker是这个 Bleeding 对象自己的字段,右边的attacker是构造器第 15 行的参数,两个名字一样,靠this.分开。三个子类里,只有它自己又多声明了一个不是 static 的字段(每个 Bleeding 对象各存各的攻击者)。 - 标注 4,第 16 行:
super(TURNS),turnsLeft从 2 开始。 - 标注 5,第 25 行:它只改写
applyLingeringDamage。这里有两个人:owner是流血的人(参数,每次由 CFighter 把自己传进来),attacker是打它的人(字段)。这一行先请攻击者算伤害attacker.calculateDamage(owner, HALF_STRENGTH),再让主人扣血owner.takeDamage(...)。 - 标注 6,第 26–27 行:如果这是最后一回合(
isLastTurn()),就返回一句"The bleeding stopped."。 - 标注 7,第 29 行:不是最后一回合,返回空列表:流血扣的血不写进日志。第 20–22 行的注释说的就是这件事。
为什么存一个人,而不是存一个伤害数?因为伤害是每次当场算的。标注 5 那一行请攻击者调用 CFighter 的 calculateDamage,它就是技能打人用的那个伤害公式(第 12 章第 5 节):
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 and116 * 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_SCALE120 / target.getStat(Stat.DEFENCE);1213 return (int) (damage * getDamageMultiplier());122 }- 标注 1,第 19 行:
DAMAGE_SCALE = 50。这一章算伤害时出现的“× 50”,就是它。 - 标注 2,第 119–120 行:
power× 自己的 STRENGTH × 50 ÷ 对方的 DEFENCE(代码里乘号写*,除号写/)。“自己”是调用这个方法的 Fighter,也就是攻击者;target是挨打的人。流血时 power 是HALF_STRENGTH,也就是 0.5。 - 标注 3,第 121 行:再乘
getDamageMultiplier()(攻击者被眩晕时是 0.5,否则是 1,第 3 节),最后(int)把小数部分直接去掉(不是四舍五入,强制类型转换)。
所以流血每次都用那一刻攻击者的力量、主人的防御,还有攻击者身上有没有眩晕。C 局的例子:0.5 × 68(Nightwing 的 Strength)× 50 ÷ 74(Bubbore 的 Defence)= 22.97,(int) → 22。第 4 节会看到:Bubbore 中途用 Bunker 把防御从 74 加到 82,第二次流血就从 22 变成了 20。
| 伤害乘几 | 多几成落空 | 回合末做什么 | 回合数 | |
|---|---|---|---|---|
| AStatusEffect(默认) | 1 | 0 | 空列表 | 子类交上来 |
| CBlinded | 1 | 0.5 | 空列表 | 3 |
| CStunned | 0.5 | 0 | 空列表 | 3 |
| CBleeding | 1 | 0 | 扣血;最后一回合返回一句话 | 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 的):
16 /* Deal the normal attack damage first, then start the bleeding. */17 @Override18 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 }- 标注 1,第 19 行
super.applyHit(user, target, random):先跑父类 AAttackSkill 的 applyHit,也就是普通的打人:用calculateDamage算伤害、让 target 扣血,返回 “Hit Bubbore for 22 damage!” 这样的句子(super)。 - 标注 2,第 20 行:再
target.addEffect(new Bleeding(user)):造一个新的 Bleeding,把出手的 user 交给它当 attacker,加到对手身上。 - 标注 3,第 21 行:在父类的句子后面接上 “ They started bleeding!”,所以日志里是一行。
没打中就没有效果:hits 返回 false 时,applyHit 根本不运行。每打中一次就 new 一个新对象,因为每个效果都有自己要倒数的 turnsLeft。放到哪里?三行都是 target.addEffect(...),而 addEffect 只有一行:
261 private final List<StatusEffect> effects = new ArrayList<>();⋯ 省略第 27–123 行124 public void addEffect(StatusEffect effect) {1252 effects.add(effect);126 }- 标注 1,第 26 行:每个 CFighter 开打时都带着一个新的空列表
effects。声明类型是List<StatusEffect>,所以三种子类的对象都能放进去(声明类型是父类)。 - 标注 2,第 125 行:
effects.add(effect),加到列表最后面。它不检查身上有没有同一种效果:同一种效果再打中一次,列表里就有两个。 - 战斗结束,这个 CFighter 被丢掉,效果也跟着没了。下一场战斗,新的 Fighter 又是一个空列表(第 10 章)。
身上有好几个效果:只算最强的那个
效果加上以后,谁去问它“乘几、落空几成”?CFighter 里的两个方法:
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” 就是这个意思。
getMissChance()是 public:ATargetedSkill.hits 第 35 行random.nextDouble() >= user.getMissChance()(>=是“大于或等于”,字体同样把它连成了一个符号)要用它,那是另一个包里的类(第 12 章第 4 节)。getDamageMultiplier()是 private:只有 CFighter 自己的calculateDamage第 121 行用它:(int) (damage * getDamageMultiplier())。- 起点 0 和 1 不是随便选的:身上没有效果时,循环一次都不跑,结果就是“不额外落空、伤害不变”。
4. 一个效果的一生:每回合结束倒数一次
效果什么时候减 1?只有一个地方:CBattle.endTurn 的最后两行(第 129–130 行),两边各调用一次 countDownEffects()。endTurn 只在两个人都出完手、都还站着的时候才运行(第 11 章第 5 节)。
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,第 155 行:向
effects要一个迭代器(Iterator)。它从列表开头一个一个往后走。 - 标注 2,第 156 行:
while (iterator.hasNext()):后面还有就继续(while 循环)。 - 标注 3,第 157 行:
iterator.next()取出下一个效果。 - 标注 4,第 158 行:
effect.countDown():turnsLeft减 1。 - 标注 5、6,第 159–160 行:减完到 0 了(
isOver()),就iterator.remove():把刚才 next() 取出的那一个从列表里删掉。
Nightwing 对 Bubbore:三个回合,一格一格看
下面用 C 局的四张真实截图,看 Bubbore 身上的两个效果从被造出来到被删掉。最上面的表是时间线:一列是一个回合,亮的格子是这一步在变的东西(每一格写的是那一回合里发生的变化),这一格所在的行名、列名也会一起亮,告诉你它是哪个效果、哪个回合。中间是这一步正在运行的代码。最下面是两块放大的截图:左边是日志(框 1 是这一步相关的句子),右边是 Bubbore 的 HP 条(框 2)。敌人的 HP 条上不写数字,下面写的 195、151 这些数是游戏里的值。turnsLeft 屏幕上也看不到,表里的数是照代码数出来的。
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 }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 }15 @Override16 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 }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 }16 /* Deal the normal attack damage first, then start the bleeding. */17 @Override18 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 }23 @Override24 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 }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 }
- 1开打时日志只有这一句

- 1Pocket sand blinded Bubbore! 两行
- 3Enemy Bubbore used Swole!

- 1Hit Bubbore for 22 damage! …:22 只写了一次

- 1The bleeding stopped.

- 2HP 195 / 195(满的)

- 2还是 195 / 195

- 2151 / 195:绿条剩 77%

- 2131 / 195:绿条剩 67%
- 开打。CEncounter 刚 new 出 Bubbore 的 CFighter,第 26 行的
effects是一个空列表。日志只有一句(框 1),HP 195 / 195(框 2)。 - 第 1 回合,你点 Pocket Sand,命中判定过了。CPocketSand.applyHit 第 17 行
target.addEffect(new Blinded()):造一个 Blinded,加进 Bubbore 的effects。第 18–19 行写下框 1 那两行,句子里的 3 就是Blinded.TURNS。 - “new Blinded()” 跑的是 Blinded 的构造器:第 11 行
super(TURNS),TURNS 是 3,于是父类 AStatusEffect 的构造器把turnsLeft设成 3。现在 Bubbore 的 effects = [Blinded(3)]。 - Bubbore 出手用 Swole(框 3)。Swole 是 ABoostSkill,不过命中判定,致盲管不着它(第 12 章第 6 节)。两人都站着,进入
endTurn。CBattle.endTurn 第 117 行调用 Bubbore 的applyLingeringDamage(),就是上面 CFighter 第 142 行开始的这个方法:循环里只有 Blinded,它没改写applyLingeringDamage,跑的是父类的默认版本,返回空列表,什么都没发生。 - CBattle.endTurn 第 129 行
enemy.countDownEffects(),跑的是上面这个方法:Blinded 3 → 2,isOver()是 false,留下。HP 还是 195(框 2)。第 1 回合结束。 - 第 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。) - 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)。 - 第 129 行倒数:迭代器先拿到 Blinded,2 → 1;再拿到 Bleeding,2 → 1。两个都没到 0,都留下。第 2 回合结束。
- 第 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)。 - 第 129 行倒数:Blinded 1 → 0,
isOver()是 true,iterator.remove()删掉;Bleeding 1 → 0,也删掉。Bubbore 的effects又空了。从第 4 回合起,它的getMissChance()又是 0。 - 回头数一数: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 的代码看出来的。
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 damage1122 * 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 damage114 * 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,是每个回合末叫一遍所有效果的那个循环:
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,第 143 行:先造一个新的空列表
messages,用来收集要写进日志的句子。 - 标注 2,第 144 行:for-each 把
effects里的效果按加上的先后一个一个拿出来。 - 标注 3,第 145–147 行:主人已经倒下了(
!isAlive(),!读作“不是”),就break:马上跳出整个循环(break),后面的效果不再扣血。这和第 118 行是同样的小心。 - 标注 4,第 148 行:
effect.applyLingeringDamage(this):this就是这个 CFighter 自己,交给效果当owner(主人)。效果返回的句子列表,用addAll全部接到messages后面;返回空列表就什么都不加。 - 标注 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:
- 现在的顺序:扣血时 Stunned 还在,0.5(HALF_STRENGTH)× 100(Frostback 的 Strength)× 50(DAMAGE_SCALE)÷ 96(Ironshell 的 Defence)= 26.04,再 × 0.5 = 13.02,
(int)→ 13。扣完才倒数,Stunned 这时才被删掉。 - 假如先倒数:Stunned 先被删掉,流血按 26.04 × 1 算 → 26。
这就是注释那句 “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:
201 /* The bleeding damage is not written to the log. Only after the second bleeding21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.22 */23 @Override24 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 }
- 1日志里只有 Lance 本身打的 26

- 2280 − 26(Lance)− 26(流血)= 228
第二个 26 是 CBleeding 第 25 行扣的(标注 2)。它发生在回合末,第 29 行返回空列表,日志里一个字都没有(标注 1)。这一回合末完整的调用顺序如下。顺序图的读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回。动画里亮起来的箭头会变成红色、流动的虚线,那只表示“这一步正在发生”,不是返回;它原来是实线还是虚线,看前后几步里它变灰时的样子。图下面是这一步正在运行的代码。
116 private void endTurn() {117 log.addAll(enemy.applyLingeringDamage());118 if (!enemy.isAlive()) {119 finish(true);120 return;121 }122 123 log.addAll(player.applyLingeringDamage());124 if (!player.isAlive()) {125 finish(false);126 return;127 }128 129 enemy.countDownEffects();130 player.countDownEffects();131 }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 }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 }23 @Override24 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 }118 public int calculateDamage(Fighter target, double power) {119 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE120 / target.getStat(Stat.DEFENCE);121 return (int) (damage * getDamageMultiplier());122 }⋯ 省略第 123–167 行168 private double getDamageMultiplier() {169 double multiplier = 1;170 for (StatusEffect effect : effects) {171 multiplier = Math.min(multiplier, effect.getDamageMultiplier());172 }173 return multiplier;174 }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 }- 两人都站着,进入
endTurn。第 117 行enemy.applyLingeringDamage():Frostback 的effects是空的,CFighter 里那个 for-each 一次都不跑,返回空列表,log.addAll什么都没加。第 118 行:Frostback 还活着,继续。 - 第 123 行
player.applyLingeringDamage():Ironshell 身上有两个效果,按加上的先后排着:[Blinded(第 1 回合加的,剩 1),Bleeding(这一回合刚加的,剩 2)]。 - 进入 Ironshell 的 CFighter.applyLingeringDamage。for-each 先拿到 Blinded。第 145 行:Ironshell 还活着,不 break。第 148 行调用
effect.applyLingeringDamage(this),this就是 Ironshell 自己。 - Blinded 没改写这个方法,所以跑的是父类 AStatusEffect 第 29–31 行的默认版本:什么都不做,返回空列表。回到 Fighter 第 148 行,
addAll什么都没加。 - for-each 再拿到第二个:Bleeding,它改写了这个方法。第 25 行要先问
attacker:attacker 是 Frostback,因为 Frostback 用 Lance 时,CLance 第 20 行把 user(Frostback)传进了new Bleeding(user)。于是请 Frostback 算:calculateDamage(Ironshell, 0.5)。 - 在 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。 - 回到 Bleeding 第 25 行:
owner.takeDamage(26),Ironshell 254 → 228。接着第 26 行问isLastTurn():turnsLeft是 2,不是;第 29 行返回空列表。所以日志里没有这个 26。 - 回到 CBattle。第 124 行:Ironshell 还活着,不 return。第 129 行 Frostback 没有效果可倒数;第 130 行轮到 Ironshell 的
countDownEffects()。 - 迭代器先拿到 Blinded:1 → 0,
isOver()是 true,iterator.remove()删掉。再拿到 Bleeding:2 → 1,留下。所以第 1 节截图那一刻,Ironshell 身上只有 [Bleeding(剩 1)]。 - 接下来你用了 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:

- 1“The bleeding stopped.” 是上面 Bleeding 第 27 行返回的
- 2然后才是 finish(false) 写的 fainted 和 You lose
回看本节前面那段 CBleeding 代码(第 20–30 行):第 25 行先扣血,第 26 行才问是不是最后一回合;它不管主人还活不活着,句子照样返回。回到 CBattle,第 124 行发现你倒下了:finish(false),return。第 129–130 行这次没运行,所以这个 Bleeding 到最后也没被删掉(还剩 1)。这不要紧:战斗结束了,两个 CFighter 对象都会被丢掉。
规则 4:不跑 endTurn,就不倒数
倒数只写在 endTurn 里。有两种情况走不到它:用道具(useItem 根本不调用它),和这一回合有人被技能打倒(playTurn 第 84、92 行提前 return)。A 局这四张截图,两种情况都有:




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

- 1第 2 回合:你的 Stun,然后是敌人的 Tackle。敌人出手只会出现在 playTurn 里

- 2用 Poison:只多了一个空行和这一行。后面没有 Enemy Galecrest used …,也没有回合末的结算
所以“一回合”在代码里的准确意思是:一次 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 和三个子类
再看道具。战斗画面上,道具按钮在你的卷轴上方;每个按钮上面是图片,下面是背包里的数量。图片路径来自数据文件:
462map.items.image=assets/sprite_question.png463map.items.0.type=HEALTH464map.items.0.location=3,12465map.items.1.type=POISON466map.items.1.location=18,16467map.items.2.type=COFFEE468map.items.2.location=2,2469 4703items.coffee=assets/sprite_coffee.png4711items.heal=assets/sprite_heal.png4722items.poison=assets/sprite_poison.png
- 1Health Tonic:图片是 items.heal,数量 1
- 2Poison:items.poison,数量 0,所以是灰的
- 3Coffee:items.coffee,数量 1
数据文件里和道具有关的有两段。第 462 行是所有问号箱共用的那张问号图片(CMapParser 第 70 行读它);第 463–468 行是地图上的三个问号箱,每个箱子两行:一行 type(HEALTH、POISON、COFFEE),一行 location(位置)。第 470–472 行是战斗按钮上的三张图片。注意同一种道具在这两段里名字不一样:箱子写 HEALTH,图片写 heal,屏幕上又显示 Health Tonic。这三个名字都存在 AItem 对象里。

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 example38 * "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,第 8 行
typeName:数据文件里箱子用的名字,比如HEALTH。CItemCatalogue.find 靠它查(第 7 节)。 - 标注 2,第 10 行
name:给玩家看的名字,比如Health Tonic。地图卷轴、状态栏、战斗日志都用它。 - 标注 3,第 12 行
imageName:图片键的最后一段,比如heal。CImageParser 第 44 行拼成"items." + item.getImageName(),读出assets/sprite_heal.png。 - 标注 4,第 14–18 行:protected 构造器,三个子类用
super(...)把三个名字交上来。三个字段都是 private final:造好以后谁也改不了。 - 标注 5,第 35 行:
use(Fighter player, Fighter enemy)是抽象方法,没有方法体,每个子类都必须写。两个战斗者都交给道具,道具自己挑用哪一个。注释说 “Using an item always works, so there is no hit check”:没有命中判定。 - 标注 6,第 40–42 行:
describeUse(target, result)拼出日志里那一句:"You used your " + name + " on " + target.getName() + ". " + result。三种道具的句子开头都一样,所以写在父类,子类只交后半句。
| typeName(数据文件) | name(屏幕上) | imageName(图片键) |
|---|---|---|
| HEALTH | Health Tonic | heal |
| COFFEE | Coffee | coffee |
| POISON | Poison | poison |
三行依次是 CHealthTonic、CCoffee、CPoison 构造器里交给 super(...) 的三个名字。
三个子类:一个构造器,一个 use
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 @Override12 public String use(Fighter player, Fighter enemy) {132 player.restoreHp();143 return describeUse(player, "It healed you to max hp!");15 }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 @Override12 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”。
player.restoreHp():stats.put(Stat.HP, getMaxHp())。最大值是这一场开打时的 HP(地形调整以后的,第 10 章)。player.restoreEnergy():能量回到开打时的值。
CPoison 是唯一一个对敌人用的:
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 @Override12 public String use(Fighter player, Fighter enemy) {131 enemy.reduceHpToOne();142 return describeUse(enemy, "It devastated them!");15 }16}
- 1用之前:Galecrest HP 93 / 93,绿条是满的(敌人的血条上不写数字,93 / 93 是游戏里的值,见 第 14 章第 5 节)

- 1用之后:HP 1 / 93,只剩最左边一小条绿色

- 2describeUse(enemy, …):所以写的是 “on Galecrest”
标注 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.use | AItem.use | |
|---|---|---|
| 写法 | final 模板方法:先付能量,再 applyEffect | 抽象方法:每个子类整个自己写 |
| 参数 | user、target、random | player、enemy(不用随机数) |
| 会不会失败 | TargetedSkill 可能打不中 | 一定成功 |
| 算不算回合 | 算:接着敌人出手、endTurn(除非有人先倒下) | 不算 |
| 对象有几个 | 每种一个(SkillCatalogue) | 每种一个(ItemCatalogue) |
道具不碰效果:三个 use 里没有一行和 effects 有关。D 局里你用了 Health Tonic,HP 回满了,身上的 Bleeding 还在(第 5 节)。
7. 背包 Inventory 和道具目录 ItemCatalogue
Inventory:道具 → 数量
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 在屏幕上都看得到,就在地图右上角的卷轴里:

- 1卷轴下半部分:按 counts 的顺序列出三种道具
- 2Poison 0:构造器放进去的 0,没人 add 过
- 4Coffee 1:刚捡的,add 把 0 改成了 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,第 15–19 行:构造器收一个道具列表,每种先放一个 0。是谁传的列表?COverworld 构造器第 38 行:
new Inventory(ItemCatalogue.getAll())。所以一开局三种道具都有一行,都是 0。 - 标注 3,第 25–27 行
getCount:counts.getOrDefault(item, 0)(getOrDefault):没有这个键时返回 0,不会返回 null。has(第 29–31 行)就是getCount(item) > 0。 - 标注 4,第 33–35 行
add:counts.put(item, getCount(item) + 1)。对已经有的键再 put,会把旧的值换掉,位置不变。 - 标注 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);内容一样的两个对象,也算两个不同的键。那为什么不出错?因为全游戏每种道具只有一个对象,大家拿的都是它:
六个地方都拿同一个对象当键或字段,所以 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
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 的两种顺序,在两个画面上都看得到:

- 2ITEMS 的顺序:Health Tonic、Coffee、Poison

- 3BATTLE_ORDER 的顺序:Health、Poison、Coffee
- 标注 1,第 10–12 行:三个
private static final常量,各 new 一次。static 表示它们属于类本身,整个程序只有一份;这个类第一次被用到时就造好。 - 标注 2,第 15 行
ITEMS:地图卷轴的顺序。getAll()交出它 → COverworld 第 38 行拿它建 CInventory → LinkedHashMap 记住这个顺序 → CPlayerStatsScroll 第 62 行for (Item item : player.getInventory().getItems())一行一行加到卷轴上。 - 标注 3,第 17 行
BATTLE_ORDER:战斗按钮的顺序,注释说是照着作业给的样图排的。getBattleOrder()交出它 → CBattleScreen 第 211 行for (Item item : ItemCatalogue.getBattleOrder())一个一个造按钮。 - 标注 4,第 28–33 行
find(typeName):在 ITEMS 里挨个用equals比getTypeName()的文字,找到就返回那个对象。注意这里比的是两个 String:String 改写了equals,比的是文字内容,"POISON" 和 "POISON" 就相等。而 AItem 没有改写 equals,所以两个 Item 之间的 equals 只认同一个对象(上一小节)。比较字符串要用 equals,不要用==:==比的是“是不是同一个对象”(引用相等)。 - 标注 5,第 34 行:数据文件里写了不认识的名字,就
throw new IllegalArgumentException("Unknown item: " + typeName)(throw)。
两个列表都是 List.of(...) 造的,不能修改,所以 getAll() 直接把它交出去也安全,不用复制。这个类只有静态成员,是一个静态目录类;它不是单例模式(Singleton),因为它根本不 new 它自己。
find 是谁用的?读数据文件的时候:
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 }4621map.items.image=assets/sprite_question.png4634map.items.0.type=HEALTH4645map.items.0.location=3,124654map.items.1.type=POISON4665map.items.1.location=18,164674map.items.2.type=COFFEE4685map.items.2.location=2,2- 标注 1,第 70 行:
ITEMS_PREFIX是"map.items."(第 23 行的常量),加上"image"就是数据文件第 462 行的键,读出问号图片的路径。只 new 一个 CSprite,三个箱子共用它。getText、countEntries是 CGameData 的方法(第 5 章第 3 节)。 - 标注 2,第 72 行:
countEntries("map.items.", ".type")从 0 开始数:有没有map.items.0.type?有。map.items.1.type?有……一直数到第一个没有的号。这里是 0、1、2(第 463、465、467 行),一共 3 个箱子。 - 标注 3,第 73–74 行:普通的 for 循环(for 循环),
index依次是 0、1、2。每一圈先拼出这一个箱子的前缀:"map.items." + 0 + "."就是"map.items.0."。 - 标注 4,第 75 行:
prefix + "type"就是键map.items.0.type,读出HEALTH,交给find,换成目录里的那个对象。 - 标注 5,第 76 行:
prefix + "location"读出位置 (3,12),new 一个 CMysteryBox,里面装的就是 find 找到的那个对象。
同一个背包:从地图带进战斗,再带回来
还有一件事:战斗里用的背包不是复制品。
COverworld.createBattle 第 83 行把 player.getInventory() 交给 CEncounter.createBattle,它第 56 行 new Battle(player, enemyFighter, inventory) 把同一个对象存进 CBattle 的字段。所以在 A 局里:

- 1Poison: 1

- 1战斗里用掉:1 → 0

- 1回到地图:Poison: 0
③ 里的 0 不是谁去“同步”过来的:CPlayerStatsScroll.update 每次都去问同一个背包 getCount,问到的就是战斗里减过的数。
8. 动起来:点道具按钮,走上问号箱
先看结果:D 局连用两个道具




下面分两段一步一步跑(以 Coffee 为例)。第一段从点按钮跑到写日志:上面是顺序图,亮的箭头是这一步;下面是正在运行的代码。这一段只改了数据,屏幕还没变。
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);122 private void useItem(Item item) {123 battle.useItem(item);124 update();125 }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 }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 }11 @Override12 public String use(Fighter player, Fighter enemy) {13 player.restoreEnergy();14 return describeUse(player, "It restored all your energy!");15 }102 public void restoreHp() {103 stats.put(Stat.HP, getMaxHp());104 }⋯ 省略第 105–105 行106 public void restoreEnergy() {107 stats.put(Stat.ENERGY, getMaxEnergy());108 }37 /* Every item message starts the same way, for example38 * "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 }- 每个道具按钮是在
createItemButtons的这个循环里造的。第 218 行给它装了一段 lambda:event -> useItem(item),意思是“按钮被点的时候,调用useItem(item)”(箭头在代码里是 - 和 > 两个字符,这个网站的代码字体把它们连着画成了一个箭头)。item是这一圈循环的变量,所以 Coffee 按钮一直记着 COFFEE 这个对象(技能按钮也是这样,见 第 11 章第 2 节)。你点了 Coffee。 - CBattleScreen.useItem 只有两行:先交给
battle.useItem(item),再update()。规则全在 CBattle 里,画面只负责显示。 - 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,接着往下走。 - 第 105 行
inventory.remove(item):remove自己又has一次(第 39 行),有,就put(item, 1 − 1),Coffee 变成 0。 - 第 106 行加一个空行。第 107 行
item.use(player, enemy):item的声明类型是 AItem,实际对象是 CCoffee,所以跑 Coffee 的use(动态绑定)。CBattle 不知道、也不用知道这是哪一种道具。 - CCoffee.use 第 13 行:
player.restoreEnergy()。enemy这个参数它没用。 - CFighter.restoreEnergy:
stats.put(Stat.ENERGY, getMaxEnergy())。最大能量是开打时的 10,所以 1 → 10。数据变了,但画面还没刷新。 - 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。 - 回到 CBattleScreen.useItem 第 124 行:
update()。它把屏幕和 CBattle 对一遍,其中会对每个道具按钮问一次背包getCount。这一段看下一个动画。
第二段:update() 把屏幕刷新,然后再用一次 Health Tonic。下面是三块放大的真实截图:1 道具按钮,2 你的能量,3 你的 HP,4 日志最后几行。
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 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 }11 @Override12 public String use(Fighter player, Fighter enemy) {13 player.restoreHp();14 return describeUse(player, "It healed you to max hp!");15 }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 }
- 1Coffee:1

- 1Coffee:0,灰了

- 1Health:0,灰了

- 2能量 1
- 3HP 228

- 2能量 10 / 10

- 3HP 280 / 280

- 4最后一行还是 Frostback 的 Lance 打的

- 4一个空行,然后是 You used your Coffee on Ironshell. It restored all your energy!

- 4又一个空行和 You used your Health Tonic on Ironshell. It healed you to max hp!
- 进
update()之前,屏幕还是用道具之前的样子:Coffee 还写着 1(框 1),能量还是 1(框 2),日志最后一行还是 Lance 那句(框 4)。可是 CBattle 里的数据已经变了:背包里 Coffee 是 0,能量是 10,日志多了两行。 update()第 129 行刷新你的面板:能量条变成 10 / 10(框 2)。第 131 行showLog把整个日志重新写一遍,新的空行和那一句出现了(框 4)。第 133 行调用updateItemButtons。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)。- 再点 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)。 - 两次都没有敌人的行动,也没有回合末结算: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) 的问号箱:
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 @Override19 void interact(Overworld world) {202 world.collect(this);21 }22}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 }- 标注 1:CMysteryBox 只多一个字段
item:CMapParser 造它时交进来的那个目录对象。 - 标注 2:它改写
interact,只有一句:请世界collect(this)。箱子自己不改背包,改动都交给 COverworld(AMapObject 的注释:“asks the world to make the change”)。interact和collect前面都没写 public 或 private:只有 world 包里的类能调用(包内可见)。 - 标注 3,第 112 行:把箱子从
objects里删掉,所以一个箱子只能捡一次,地图上也不再画这个问号。COverworld.move 第 74 行循环的是new ArrayList<>(objects)(一份复制品),就是因为箱子会在循环中途把自己删掉(第 8 章)。 - 标注 4,第 113 行:
player.getInventory().add(box.getItem()):背包里这一种加 1。 - 标注 5,第 114 行:通知所有监听者(观察者模式,第 8 章第 6 节)。括号里的
listener -> listener.onItemCollected(box.getItem())是一段交给notifyListeners的小代码(lambda),意思是“对每一个监听者,调用它的onItemCollected,把捡到的道具交给它”。notifyListeners(第 125–129 行)就用 for-each 走一遍所有监听者,对每一个运行这段代码。COverworldScreen 就是其中一个监听者:它第 97–100 行收到以后,状态栏写 “You picked up a … !”,再update(),卷轴重新问一遍数量。

- 3你站在 (3,12):这里原来的问号没了
- 4卷轴:Health Tonic: 1
- 5状态栏:You picked up a Health Tonic!
- 6还没捡的两个箱子:(2,2) 里是 Coffee,(18,16) 里是 Poison
接着 Ironshell 走到 (2,2),又捡到 Coffee。两次走的是完全一样的代码,区别只在箱子里装的是哪个对象:

- 4Coffee: 0

- 4Coffee: 1:同一个 add,换了一个 item
谁负责什么
| 要做的事 | 谁做 | 为什么这样分 |
|---|---|---|
| 记每种有几个 | 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).