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

12技能家族:继承和模板方法

Skill 抽象类、命中率和伤害公式、10 个技能各自做了什么

读完这一章你能
  • 画出技能家族的继承树:ASkill 下面是 ATargetedSkill、ABoostSkill、CWait,再往下是 10 个具体技能
  • 说清楚 Skill.use 为什么是模板方法(Template Method):先付能量,再生效,顺序谁也改不了,所以打不中也照样扣能量
  • 用截图里真实的数值算出命中率和伤害,比如 Nightwing 的 Lance 为什么打 Bubbore 正好是 22 点
  • 说出每个具体技能类做了什么:大多数只是把名字、能量、威力或属性交给 super(...),另外 4 个多写了一个方法
  • 解释为什么全游戏每种技能只有一个对象(CSkillCatalogue),大家共用它为什么不会出错
  • 用英语回答 How would you add a new skill?,并和 Project 1 的 if/else 写法比一比

时间不够就先看:第 3 节(模板方法 use)、第 4 节(命中判定 hits)、第 8 节的第一个动画、第 9 节(怎么加一个新技能)和最后的自测。

上一章(第 11 章)走完了一个回合:CBattle 的 playTurn 走到第 81 行,执行 playerSkill.use(player, enemy, random),再把它返回的一行字加进日志。这一行后面到底发生了什么,就是这一章的内容。

这一章会一直用到几个第 1、2 章讲过的词(详细的讲解在 第 2 章第 7 节),先提醒一下:父类(superclass)和子类(subclass)——子类用 extends 继承父类,父类写好的字段和方法子类自动都有;抽象类(abstract class)——不能 new,只能当父类用;改写(override,有的书叫“重写”)——子类写一个名字、参数都一样的方法,换掉父类的做法。别的新词,用到的地方再解释。

1. 先看屏幕:技能在哪里出现

先看一张真实的战斗截图。你选了 Nightwing,敌人是 Bubbore。第一回合你用了 Pocket Sand,第二回合点了 Lance [4]。这一张是第二回合结束时的样子。屏幕上和技能有关的有 4 块:

用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)1234
  1. 1
    4 个技能按钮 BattleScreen.skillButtons(每个是一个 CGameButton):Nightwing 有 4 个技能,就有 4 个按钮
  2. 2
    这一回合新加的 4 行日志。Player's Nightwing used Lance! 是 CBattle.playTurn 第 80 行写的;Hit Bubbore for 22 damage! They started bleeding! 是技能对象的 use(...) 返回的,第 81 行把它加进日志。后两行是敌人 Bubbore 用了 Wait:敌人出手走的也是同一个 use(第 88–89 行)
  3. 3
    Bubbore 的 HP 条:195 → 151(第 5 节一步一步算)
  4. 4
    你的能量 19 / 27:Lance 花了 4 点,用之前是 23
一次 Lance:按钮、日志里的新几行、HP 条和能量条都和技能有关真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)

这 4 块是三个地方做的。按钮是画面 CBattleScreen 造的;日志里 “Player's Nightwing used Lance!” 这一行是 CBattle 写的;但是技能到底做了什么——打中没有、扣多少血、加了什么效果、日志里那句结果——全是技能对象自己的代码算出来的。CBattle 只负责叫它干活,根本不知道它是哪一个技能。这一章就是把技能对象打开来看。

按钮上的字从哪来

CBattleScreen 造按钮的时候,问每个技能对象要两样东西:名字和能量消耗。代码和截图用同一套编号:

BattleScreen.createSkillButtons:一个技能一个按钮ui/BattleScreen.java第 181–198 行
181 /* One button per skill, showing its energy cost, e.g. "Bite [1]". */
182 private VBox createSkillButtons(List<Skill> skills) {
183 VBox buttonColumn = new VBox(SKILL_GAP);
184 buttonColumn.setLayoutX(ACTIONS_X);
185 buttonColumn.setLayoutY(SKILLS_Y);
186
187 Font font = UiStyle.loadGameFont(SKILL_FONT_SIZE);
1881 for (Skill skill : skills) {
1892 GameButton button = new GameButton(skill.getName() + " [" + skill.getEnergyCost()
190 + "]", font, BUTTON_RADIUS);
191 button.setPrefSize(SKILL_WIDTH, SKILL_HEIGHT);
192 button.addPressEffect();
1933 button.setOnAction(event -> useSkill(skill));
194 skillButtons.add(button);
195 buttonColumn.getChildren().add(button);
196 }
197 return buttonColumn;
198 }
Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
123
  1. 1
    循环 4 次:Nightwing 的技能列表里有 4 个技能
  2. 2
    按钮上的字 = 名字 + “ [” + 能量 + “]”:Pocket Sand [4]
  3. 3
    每个按钮记住自己的技能对象:点 Wait [-2] 就是用 Wait 那个对象
Nightwing 的 4 个技能按钮真实运行截图 · Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait

点下去以后:谁调用谁

下面是点 Lance [4] 之后的调用顺序。读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。

玩家CBattleScreenCBattleCskill : Lance点 Lance [4]1useSkill(skill)2playTurn(skill)3use(...)4技能自己去改两个 Fighter:扣能量、扣 HP、加效果(后面几节)返回一行 String5log.add(...)6(返回)7update()8
点 Lance [4]:Battle 只调用 use,具体做什么由技能对象决定

最关键的是第 4 步 use(...),也就是 CBattle 的第 81 行:log.add(playerSkill.use(player, enemy, random))。

2. 技能家谱:一棵四层的继承树

skill 包里有 15 个类。除了 CSkillCatalogue(第 7 节讲),另外 14 个连成一棵继承树。这张图是横着画的:最左边是根 ASkill,越往右越具体(14 个框竖着排放不下)。老师打印的 UML 是父类在上、子类在下,箭头的意思完全一样:

瞄准对手:要先判断命中给自己加点:一定成功抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkill技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWait抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkill技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSand技能 Stun:花 4 点能量,没有伤害,但让目标伤害减半 3 回合。CStun技能 Aim:花 3 点能量,提高自己的命中(Accuracy)。CAim技能 Bunker:花 3 点能量,提高自己的防御(Defence)。CBunker技能 Nimble:花 3 点能量,提高自己的闪避(Dodge)。CNimble技能 Swole:花 3 点能量,提高自己的力量(Strength)。CSwole技能 Bite:花 1 点能量的普通攻击,威力 1。CBite技能 Tackle:花 5 点能量的重击,威力 2(双倍伤害)。CTackle技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLance
技能家族的继承树:空心三角箭头 = extends,从子类指向父类;圆圈 A = 抽象类,C = 具体类;浅色底是同一家

怎么读这张图:

为什么要分这么多层?因为同一家的技能有相同的地方,相同的代码只写一次。每往下一层,只写这一层和别人不一样的部分:

类父类这一层加了什么留给子类写的
ASkill(根)名字、能量消耗,和固定顺序的 use()applyEffect
ATargetedSkillSkill命中判定 hits(),打不中的那句话applyHit
AAttackSkillTargetedSkill威力 power,打中就算伤害(没有了)
ABoostSkillSkill加哪个属性 stat,随机加 1–10 点(没有了)
CBite、CTackleAttackSkill只有两个数字:能量、威力—
CLanceAttackSkill两个数字,加上“打中后流血”—
CPocketSand、CStunTargetedSkill打中后加致盲 / 眩晕—
CAim、CBunker、CNimble、CSwoleBoostSkill只选一个属性 EStat—
CWaitSkill负的能量消耗,随机一句话—

越往下要写的越少。10 个具体类里,6 个只有一个构造器(Bite、Tackle、Aim、Bunker、Nimble、Swole),另外 4 个也只多一个方法(Lance、Pocket Sand、Stun 多一个 applyHit,Wait 多一个 applyEffect)。最长的 CLance 和 CWait 也才 23 行。

老师打印的 UML 上是什么样

UML 图的完整读法在 第 3 章(第 2 节讲类名的斜体和圆圈字母,第 5–7 节讲名字前的小图标、下划线和箭头),这里只提醒图上的几个记号:

Skill 的 UML 类图
Skill:名字是斜体、圆圈是 A = 抽象类。斜体的 applyEffect 是抽象方法;use 不是斜体,它有代码。名字前的小图标:红色方块 = private,绿色圆点 = public,黄色菱形 = protected
Lance 的 UML 类图
Lance:空心三角箭头指向父类 AttackSkill。带下划线的 ENERGY_COST、HALF_STRENGTH 是 static 常量。图上只列它自己写的 applyHit,继承来的 getName、use 不再画一遍(UMLDoclet 也没画它的无参构造器 Lance())

3. Skill:每个技能都一样的部分(模板方法 use)

先看根 ASkill。整个文件只有 37 行:

Skill.java:整个文件skill/Skill.java第 1–37 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import java.util.Random;
5
6/* Every skill has a name and an energy cost, but each kind of skill has its own effect.
7 * To add a new skill, write a new subclass instead of changing the existing ones.
8 */
9public abstract class Skill {
101 private final String name;
11 private final int energyCost;
12
13 /* A negative energy cost means the skill restores energy. */
142 protected Skill(String name, int energyCost) {
15 this.name = name;
16 this.energyCost = energyCost;
17 }
18
19 public String getName() {
20 return name;
21 }
22
23 public int getEnergyCost() {
24 return energyCost;
25 }
26
27 /* Template Method: pay the energy cost first (even if an attack then misses),
28 * then apply the effect. Returns the message for the battle log.
29 */
303 public final String use(Fighter user, Fighter target, Random random) {
31 user.spendEnergy(energyCost);
32 return applyEffect(user, target, random);
33 }
34
35 /* Each subclass decides what the skill actually does. */
364 protected abstract String applyEffect(Fighter user, Fighter target, Random random);
37}
  1. 标注 1,第 10–11 行:两个字段。name 是名字(比如 "Lance"),energyCost 是能量消耗(Lance 是 4)。它们是 private:只有 ASkill 自己能直接用,子类和别的类要通过 getName()、getEnergyCost()(第 19–25 行)来读。它们也是 final:构造器里赋值一次,以后再也不能改。
  2. 标注 2,第 14 行:构造器是 protected 的。它是留给子类用的:子类在自己的构造器里写 super(name, energyCost),就会运行它(第 5 节画了这条链)。第 13 行的注释说:能量消耗是负数,就表示这个技能会回能量(Wait 是 −2)。
  3. 标注 3,第 30–33 行:use,所有技能都用它出手。只有两行。第 31 行先让用技能的人付能量:user.spendEnergy(energyCost)。第 32 行再执行技能的效果 applyEffect(...),并把效果返回的那句话原样交给 CBattle。方法前面写着 final:任何子类都不能改写 use。第三个参数 Random random 是 CBattle 传进来的:CBattle 第 19 行只有一个 random 字段,整场战斗的随机数都从它来,所以技能自己不 new Random。(CBattle 第 30–31 行的构造器还能接收外面给的 Random,比如固定种子,结果就能重复;游戏里实际走的是第 25–26 行,自己 new Random()。)
  4. 标注 4,第 36 行:applyEffect 是抽象方法(abstract method)。只有方法头,后面直接是分号,没有 { }。ASkill 只规定“每个技能都有一个效果”,效果是什么,交给子类写。

模板方法:顺序写死在父类,内容交给子类

把 use 画出来是这样:

① user.spendEnergy(energyCost)先付能量:10 个技能都一样② applyEffect(user, target, random)再生效:Skill 里只有方法头抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkill技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWait然后先判断命中,打中再 applyHit给自己加属性随机说一句话
Skill.use(final)的两步:上面一排的顺序写死在 Skill 里;第 ② 步的代码由三家子类各自写(细线上写的是那一家的做法)

这就是模板方法模式(Template Method):父类用一个 final 方法把步骤的顺序定死(这里是 use:① 付能量 → ② 生效),其中一步留给子类写(这里是 applyEffect)。applyEffect 是子类必须写的抽象步骤,英文叫 primitive operation(abstract step);父类已经给了默认做法、子类想改才改的那种步骤,英文更常叫钩子方法(hook method),比如 AStatusEffect 的 getMissChance() 默认返回 0,只有 CBlinded 改写(第 13 章)。三家子类各写各的 applyEffect:

为什么这样设计?

真实结果:打不中,能量也照扣

第 31 行先付能量,第 32 行才进 applyEffect;而“打没打中”是在 applyEffect 里面才判断的。所以能量在知道结果之前就已经付了。下面是 Owlet 对 Impike 那一仗,看敌人 Impike 出手。编号 1 = 第 31 行付能量,编号 2 = 第 32 行的效果:

Skill.useskill/Skill.java第 27–33 行
27 /* Template Method: pay the energy cost first (even if an attack then misses),
28 * then apply the effect. Returns the message for the battle log.
29 */
30 public final String use(Fighter user, Fighter target, Random random) {
311 user.spendEnergy(energyCost);
322 return applyEffect(user, target, random);
33 }
Owlet 的战斗开始:技能 Bite、Swole、Bunker、Wait
1
  1. 1
    Impike 的能量条(蓝色)满的:22 / 22
开打真实运行截图 · Owlet 的战斗开始:技能 Bite、Swole、Bunker、Wait
用 Swole:提高自己的力量(strength)1~10 点
1
  1. 1
    Stun 没中,第 31 行还是扣了 4:18 / 22
第一回合后(灰字说的是你的 Owlet 这回合用了 Swole;这里看的是敌人 Impike)真实运行截图 · 用 Swole:提高自己的力量(strength)1~10 点
用 Bunker:提高自己的防御(defence)1~10 点
1
  1. 1
    Lance 也没中,又扣 4:14 / 22
第二回合后(灰字说的是你的 Owlet 用了 Bunker)真实运行截图 · 用 Bunker:提高自己的防御(defence)1~10 点
用 Swole:提高自己的力量(strength)1~10 点
2
  1. 2
    Enemy Impike used Stun! But they missed.:第 32 行进了 applyEffect,没打中,返回 But they missed.
第一回合的日志(放大)。上面两行是你的 Owlet 用 Swole,下面两行是敌人 Impike 的 Stun真实运行截图 · 用 Swole:提高自己的力量(strength)1~10 点

敌人的条上不写数字,但能量可以按代码算出来。Impike 开打时能量 22(这一仗地形不加不减,就是数据文件里的值)。它用 Stun(能量 4):第 31 行先扣,22 − 4 = 18;然后在 applyEffect 里没打中,返回 But they missed.。18 ÷ 22 ≈ 82%,就是蓝条剩下的长度。下一回合它用 Lance(也是 4),又没打中,又扣 4:18 − 4 = 14,蓝条剩 64%。

这一仗 Impike 一共出手攻击 5 次(Stun、Lance、Stun、Lance、Lance),只有最后一次打中;最后一回合它还用了一次 Wait,回 2 点。最后它的能量是 22 − 5 × 4 + 2 = 4。每一次没打中,那 4 点都白花了。

4. TargetedSkill:先判断打没打中

Bite、Tackle、Lance、Pocket Sand、Stun 这 5 个技能是冲着对手去的。它们共同的父类 ATargetedSkill 写好了命中判定,5 个技能共用这一份:

TargetedSkill.java:整个文件skill/TargetedSkill.java第 1–45 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import au.edu.unimelb.swen.oop.mokepon.model.Stat;
5import java.util.Random;
6
7/* A skill aimed at the other fighter. It only works if it passes the hit check,
8 * so every targeted skill shares the same hit check and miss message.
9 */
10public abstract class TargetedSkill extends Skill {
111 private static final String MISS_MESSAGE = "\tBut they missed.";
12
13 protected TargetedSkill(String name, int energyCost) {
14 super(name, energyCost);
15 }
16
17 @Override
182 protected final String applyEffect(Fighter user, Fighter target, Random random) {
19 if (!hits(user, target, random)) {
20 return MISS_MESSAGE;
21 }
223 return applyHit(user, target, random);
23 }
24
25 /* What the skill does once it has hit the target. */
264 protected abstract String applyHit(Fighter user, Fighter target, Random random);
27
28 /* The normal hit check uses the user's accuracy and the target's dodge.
29 * A blinded user can still miss after passing it.
30 */
31 private boolean hits(Fighter user, Fighter target, Random random) {
325 if (random.nextDouble() >= calculateHitChance(user, target)) {
33 return false;
34 }
356 return random.nextDouble() >= user.getMissChance();
36 }
37
38 private double calculateHitChance(Fighter user, Fighter target) {
39 int accuracy = user.getStat(Stat.ACCURACY);
40 int dodge = target.getStat(Stat.DODGE);
41 double accuracySquared = accuracy * accuracy;
42 double dodgeSquared = dodge * dodge;
437 return accuracySquared / (accuracySquared + dodgeSquared);
44 }
45}
  1. 标注 1,第 11 行:打不中时的那句话 "\tBut they missed."。private static final 是常量:整个程序只有一份,永远不变。开头的 \t 是制表符(转义字符),日志里结果那一行往里缩,就是它造成的。
  2. 标注 2,第 18 行:applyEffect 在这里有了代码。第 17 行的 @Override 是注解,告诉编译器“我在改写父类的方法”。这个方法也是 final:下面的子类(AttackSkill、Lance、Pocket Sand、Stun……)都不能再改它,谁也跳不过命中判定。第 19–20 行:!hits(...) 读作“没打中”(! 是“不”),那就直接返回第 11 行那句话,后面什么都不做。
  3. 标注 3,第 22 行:打中了,才调用 applyHit(...),把它返回的话交回去。
  4. 标注 4,第 26 行:applyHit 又是一个抽象方法。打中以后做什么,交给子类写。
  5. 标注 5,第 32 行:第一次随机判断。random.nextDouble() 给一个 0(含)到 1(不含)之间的随机小数(Random)。如果它大于等于命中率(代码里是 > 加 = 两个字符),就返回 false:没打中。所以通过这一次判断的机会正好等于命中率。
  6. 标注 6,第 35 行:第二次随机判断。再取一个随机小数,要大于等于 user.getMissChance() 才算打中。没被致盲时 getMissChance() 是 0,任何小数都 ≥ 0,这一次一定通过;被致盲时是 0.5,只有一半机会通过。注意是 user(用技能的那个人)的落空几率。
  7. 标注 7,第 38–44 行:命中率。用技能的人的 Accuracy 的平方,除以(Accuracy 的平方 + 对手 Dodge 的平方)。accuracy * accuracy 是两个 int 相乘(最大 100 × 100 = 10000),存进 double 变量;所以第 43 行是小数除法,结果在 0 到 1 之间。

两次随机判断

第一次:random.nextDouble()小于命中率吗?命中率 = A² ÷ (A² + D²)A = 用技能的人的 AccuracyD = 对手的 Dodge第二次:random.nextDouble()大于等于 user.getMissChance() 吗?没打中:return MISS_MESSAGE日志:But they missed.打中:return applyHit(...)打中后做什么,子类写calculateHitChance 算出来是(机会 = 命中率)否否(致盲时一半)是(没致盲时一定是)
hits 的两次判断:两次都通过才算打中;任何一次没通过,日志就写 But they missed.

为什么用平方?平方会把差距拉大。Accuracy 83 对 Dodge 55,直接比是 83 ÷ (83 + 55) ≈ 0.60;用平方是 6889 ÷ (6889 + 3025) ≈ 0.69。谁的数值高,谁更占便宜。Project 1 的 Skill.use 第 52 行用的也是这个公式(第 9 节有那段代码)。

用真实截图里的数字算一算

下面用的都是那一仗战斗中的数值(地形可能改过它们,见 第 9 章),不是选择界面上的原始数值:

那一仗谁打谁AD命中率结果
Nightwing 对 BubboreNightwing → Bubbore83556889 ÷ 9914 ≈ 0.69Pocket Sand、Lance 都中
Impike 对 GalecrestImpike → Galecrest1004010000 ÷ 11600 ≈ 0.86Stun、Lance 都中
Impike 对 GalecrestGalecrest → Impike431001849 ÷ 11849 ≈ 0.16Tackle 没中
Owlet 对 ImpikeImpike → Owlet74895476 ÷ 13397 ≈ 0.415 次只中 1 次
Ironshell 对 FrostbackFrostback → Ironshell87207569 ÷ 7969 ≈ 0.953 次中 2 次

最后一行最值得看:Frostback 的命中率有 95%,它的第二次 Pocket Sand 还是落空了。随机就是随机,95% 不等于一定。反过来,Galecrest 只有 16%,它的 Tackle 落空一点也不奇怪。

Owlet 那一仗的两张日志,正好一张是没打中、一张是打中(编号 1 = 第 20 行,编号 2 = 第 22 行):

TargetedSkill.applyEffectskill/TargetedSkill.java第 17–23 行
17 @Override
18 protected final String applyEffect(Fighter user, Fighter target, Random random) {
19 if (!hits(user, target, random)) {
201 return MISS_MESSAGE;
21 }
222 return applyHit(user, target, random);
23 }
用 Bunker:提高自己的防御(defence)1~10 点
11
  1. 1
    第一回合的 Stun、第二回合的 Lance:都没中,走第 20 行
前两回合(灰字说的是你的 Owlet 用了 Bunker;这里看的是敌人 Impike)真实运行截图 · 用 Bunker:提高自己的防御(defence)1~10 点
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
2
  1. 2
    第 5 次攻击:Lance 打中了,走第 22 行,applyHit 返回 Hit Owlet for 45 damage! ...
后面:Impike 的 Lance 终于打中(这一仗最后 Owlet 输了,画面是灰的)真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

标注 1 框住的两次(第一回合的 Stun、第二回合的 Lance)都没通过判断,走的是第 20 行,日志写 But they missed.。Impike 的命中率只有 0.41,大约 5 次中 2 次;这一仗运气差一点,前 4 次全没中,第 5 次 Lance 才中(标注 2),走的是第 22 行。

被致盲:第二次判断才起作用

Pocket Sand 打中以后,会给对手加一个 CBlinded 效果(第 5 节)。接下来的 3 回合里,对手自己出手的时候,它就是 user,user.getMissChance() 就是 0.5:

Fighter.getMissChance:身上效果里最大的落空几率battle/Fighter.java第 128–137 行
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() {
132 double missChance = 0;
133 for (StatusEffect effect : effects) {
134 missChance = Math.max(missChance, effect.getMissChance());
135 }
136 return missChance;
137 }

一个 CFighter 身上可以同时有好几个效果。getMissChance 用 for-each 把每个效果的落空几率拿出来,Math.max 只留最大的那个(第 134 行)。身上什么效果都没有时,就是一开始的 0。父类 AStatusEffect 默认返回 0,只有 CBlinded 改写成 0.5(第 13 章)。

下面这一仗里,Frostback 把 Ironshell 致盲了。日志最近的几行:

用道具之前:HP 被打掉了一些,能量也用掉了
12345
  1. 1
    Frostback 的 Pocket Sand 打中:Ironshell 被致盲 3 回合
  2. 2
    Ironshell 被致盲以后用 Aim:照样成功,accuracy +6
  3. 3
    Frostback 第二次 Pocket Sand:没中(它有 95%,还是落空了)
  4. 4
    Ironshell 再用 Aim:又成功,+1
  5. 5
    Frostback 的 Lance 打中:26 点伤害(第 5 节的伤害表里有它)
Ironshell 对 Frostback:被致盲的一方用 Aim,一次都没失败真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了

这张图说明两件事:

如果被致盲的人用了 TargetedSkill 呢?按代码算一个:Nightwing 那一仗,Bubbore 第一回合就被 Pocket Sand 致盲了。它的 4 个技能里只有 Tackle 是 TargetedSkill。Tackle 打 Nightwing 的命中率是 73² ÷ (73² + 86²) = 5329 ÷ 12725 ≈ 0.42;第二次判断只有一半能通过,合起来 0.42 × 0.5 ≈ 0.21。不过那几回合 Bubbore 用的是 Swole、Wait、Bunker,都不是 TargetedSkill,这次致盲白挨了。

5. TargetedSkill 下面的五个具体技能:三种攻击、两种干扰

ATargetedSkill 下面有两种孩子:AAttackSkill 一家(打中了扣血:Bite、Tackle、Lance),和直接继承它的 CPocketSand、CStun(打中了不扣血,只给对手加一个状态效果)。

AttackSkill:打中了就算伤害

AttackSkill.java:整个文件skill/AttackSkill.java第 1–23 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import java.util.Random;
5
6/* A targeted skill that deals damage when it hits.
7 * Attacks differ in their power, which multiplies the user's strength.
8 */
9public abstract class AttackSkill extends TargetedSkill {
101 private final double power;
11
122 protected AttackSkill(String name, int energyCost, double power) {
13 super(name, energyCost);
14 this.power = power;
15 }
16
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
193 int damage = user.calculateDamage(target, power);
204 target.takeDamage(damage);
215 return "\tHit " + target.getName() + " for " + damage + " damage!";
22 }
23}
  1. 标注 1,第 10 行:攻击多了一个字段 power(威力),private final double。Bite 是 1,Tackle 是 2,Lance 是 0.5。
  2. 标注 2,第 12–15 行:构造器收三个值。前两个用 super(name, energyCost) 交给父类,第三个自己存进 this.power。
  3. 标注 3,第 19 行:伤害不是技能自己算的,而是请用技能的人算:user.calculateDamage(target, power)。因为要用到 user 的力量、target 的防御,还要看 user 身上有没有被眩晕——这些数据都在 CFighter 里。
  4. 标注 4,第 20 行:target.takeDamage(damage):对手扣血。
  5. 标注 5,第 21 行:拼出日志里那句话,比如 "\tHit Bubbore for 22 damage!"。

这里的 applyHit 没有写 final,所以子类还能改写它(Lance 就改写了)。AAttackSkill 已经一个抽象方法都没有了,但它仍然写着 abstract:不让别人直接 new 一个“没名字、没数字”的攻击,只能 new Bite、Tackle、Lance。

伤害公式:Fighter.calculateDamage

下面是 CFighter 里和伤害有关的几段:第 19 行的常量、第 118–121 行的公式、第 168–174 行的倍率。编号和下面的算式卡片一样:

Fighter:DAMAGE_SCALE、calculateDamage 和 getDamageMultiplierbattle/Fighter.java第 19–19、114–122、165–174 行
19 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) {
1191 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE
1202 / target.getStat(Stat.DEFENCE);
1213 return (int) (damage * getDamageMultiplier());
122 }
⋯ 省略第 123–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() {
169 double multiplier = 1;
170 for (StatusEffect effect : effects) {
171 multiplier = Math.min(multiplier, effect.getDamageMultiplier());
172 }
173 return multiplier;
174 }

Nightwing 的 Lance 打 Bubbore:一步一步算

1 第 119 行:0.5(power:Lance 交给 AttackSkill 的常量 HALF_STRENGTH)× 68(出手的 Nightwing 的 Strength)× 50(第 19 行的常量 DAMAGE_SCALE)= 1700

2 第 120 行:÷ 74(挨打的 Bubbore 的 Defence)= 22.97…

3 第 121 行:× 1(getDamageMultiplier():Nightwing 身上没有眩晕,第 169 行的 1 没被改小)= 22.97…;(int) 把小数直接去掉(不是四舍五入)→ 22

公式里来自两只 Mokepon 的只有两个数:出手的 Nightwing 的力量 68(这一仗地形不加不减,就是数据文件里的值),和挨打的 Bubbore 的防御 74(一直没变)。Bubbore 自己的力量这时碰巧也是 68(第一回合 Swole 把它从 66 加到 68),但它和这一下伤害无关。

第 119–120 行先用 double 算出带小数的伤害,第 121 行乘上倍率,再用 (int) 强制转换成整数。来对一下截图:

用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
45
  1. 4
    日志写的就是 22
  2. 5
    Bubbore 的 HP 条(绿色)剩 77%:151 / 195
Nightwing 那一仗第二回合结束:右上是 Bubbore 的 HP 条,下面是日志(放大)真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)

日志的 22 和算出来的一样(标注 4)。血条为什么是 151 不是 195 − 22 = 173?Lance 打中以后还给 Bubbore 加了流血(下面讲 Lance)。这个回合最后,流血用同一个公式又扣了一次:attacker.calculateDamage(owner, 0.5),攻击者还是 Nightwing,防御还是 74,又是 22。所以 195 − 22 − 22 = 151,151 ÷ 195 ≈ 77%,就是标注 5 绿条的长度。流血扣的血不写进日志(第 13 章)。

截图里一共出现过 4 次 Lance 打中,每一次都能这样算对(power 都是 0.5):

那一仗谁打谁力量防御0.5 × 力量 × 50 ÷ 防御日志
Nightwing 对 BubboreNightwing → Bubbore68741700 ÷ 74 = 22.9722
Impike 对 GalecrestImpike → Galecrest100302500 ÷ 30 = 83.3383
Owlet 对 ImpikeImpike → Owlet76421900 ÷ 42 = 45.2445
Ironshell 对 FrostbackFrostback → Ironshell100962500 ÷ 96 = 26.0426

几个数值的来历:Impike 在森林里是 76 × 1.5 = 114,超过上限被压成 100;Galecrest 的防御 59 × 0.5 = 29.5,四舍五入成 30;Frostback 72 × 1.5 = 108,也压成 100(地形的规则见 第 9 章)。Owlet 的防御本来是 36,它用 Bunker 加了 6 点,变成 42。要是没用 Bunker,这一下就是 1900 ÷ 36 = 52.78 → 52,Bunker 每一下替它挡了 7 点。

Bite 和 Tackle:只有两个数字不一样

Bite.java:整个文件skill/Bite.java第 1–11 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3/* A cheap attack with normal damage. */
4public class Bite extends AttackSkill {
51 private static final int ENERGY_COST = 1;
62 private static final double POWER = 1;
7
8 public Bite() {
93 super("Bite", ENERGY_COST, POWER);
10 }
11}
Tackle.java:整个文件skill/Tackle.java第 1–11 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3/* An expensive attack with double damage. */
4public class Tackle extends AttackSkill {
51 private static final int ENERGY_COST = 5;
62 private static final double POWER = 2;
7
8 public Tackle() {
93 super("Tackle", ENERGY_COST, POWER);
10 }
11}

两个文件一模一样,只有标注 1、2 的数字不一样:

ENERGY_COST、POWER 是 private static final 常量:给数字起个名字,比直接写 super("Bite", 1, 1) 好懂。标注 3 的 super(...) 把名字和两个数字交给父类。new Bite() 的时候,四个构造器一层一层往上调用。下图和 UML 一样,父类画在上面:

Skill(name, energyCost)TargetedSkill(name, energyCost)AttackSkill(name, energyCost, power)Bite()4. 最先存好:this.name = Bite,this.energyCost = 1没有字段,直接回去5. 回来以后:power = 16. 回到 Bite():没字段要存1. super("Bite", 1, 1)2. super("Bite", 1)3. super("Bite", 1)
new Bite() 时的构造器链:实线 1、2、3 = 每一层构造器第一行的 super(...),一层层往上调用;虚线 = 那一层做完,回到下面一层。所以最上面的 Skill 最先存好字段(第 4 步),回到 AttackSkill 才存 power(第 5 步),最后回到 Bite()(第 6 步)

做完以后,这个 Bite 对象身上有三个值:name = "Bite"、energyCost = 1(存在 ASkill 那一层)、power = 1(存在 AAttackSkill 那一层)。方法也是一层层继承来的:use 来自 Skill,applyEffect 来自 TargetedSkill,applyHit 来自 AttackSkill。Bite 自己一个方法都没写。

能量够不够:按钮为什么会变灰

能量消耗还决定按钮能不能点。每次画面刷新,CBattleScreen 的 updateSkillButtons 都会把每个技能按钮问一遍,问的就是 CFighter 的 canUse。编号在代码和截图上一样:

Fighter.canUse:能量够不够battle/Fighter.java第 76–79 行
76 /* Check whether there is enough energy to use the skill. */
77 public boolean canUse(Skill skill) {
781 return getEnergy() >= skill.getEnergyCost();
79 }
BattleScreen.updateSkillButtons:每个技能按钮问一遍ui/BattleScreen.java第 147–154 行
147 /* A skill can only be used while the battle is running and there is enough energy. */
148 private void updateSkillButtons() {
149 List<Skill> skills = battle.getPlayer().getSkills();
150 for (int i = 0; i < skillButtons.size(); i++) {
1512 boolean usable = !battle.isFinished() && battle.getPlayer().canUse(skills.get(i));
1523 skillButtons.get(i).setUsable(usable);
153 }
154 }
能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
123
  1. 1
    第 78 行的 getEnergy():Shellurk 只剩 3 点
  2. 2
    i = 1 是 Aim [3]:3 ≥ 3 成立,第 151 行算出 usable = true,还能点
  3. 3
    i = 0 是 Tackle [5]:3 ≥ 5 不成立,usable = false;第 152 行 setUsable(false) 让它变灰、点不了
Shellurk 连用三次 Aim 以后:12 − 3 × 3 = 3真实运行截图 · 能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用

Lance:先当普通攻击,再加流血

Lance.java:整个文件skill/Lance.java第 1–23 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import au.edu.unimelb.swen.oop.mokepon.effect.Bleeding;
5import java.util.Random;
6
7/* An attack that only uses half of the user's strength, but makes the target bleed. */
8public class Lance extends AttackSkill {
9 private static final int ENERGY_COST = 4;
10 private static final double HALF_STRENGTH = 0.5;
11
12 public Lance() {
131 super("Lance", ENERGY_COST, HALF_STRENGTH);
14 }
15
16 /* Deal the normal attack damage first, then start the bleeding. */
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
192 String hitMessage = super.applyHit(user, target, random);
203 target.addEffect(new Bleeding(user));
214 return hitMessage + " They started bleeding!";
22 }
23}
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
2334
  1. 2
    Hit Owlet for 45 damage!:super.applyHit 返回的部分
  2. 3
    流血两回合以后:The bleeding stopped.,Owlet 倒下
  3. 4
    They started bleeding!:Lance 自己接上的半句
Owlet 对 Impike:Impike 的 Lance 打中一次,流血把 Owlet 打倒了(输了以后画面变灰)真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
  1. 标注 1,第 13 行:Lance 的威力是 HALF_STRENGTH = 0.5,只用一半力气。能量 4。
  2. 标注 2,第 19 行:super.applyHit(user, target, random):先运行父类 AAttackSkill 的 applyHit,也就是普通攻击那一套:算伤害、扣血、拼出 “Hit Owlet for 45 damage!”,存进变量 hitMessage。
  3. 标注 3,第 20 行:target.addEffect(new Bleeding(user)):新造一个 CBleeding 对象,放进对手的效果列表。传进去的 user 让流血记住是谁打的:以后每回合结束,流血都用攻击者的力量来算(这就是上面 151 的来历)。
  4. 标注 4,第 21 行:在父类那句话后面接上 “ They started bleeding!”,一起返回。

截图里是 Impike 打 Owlet:0.5 × 76 × 50 ÷ 42 = 45.24 → 45。Owlet 的 HP 从 120 变成 75;这个回合结束,流血再扣 45,剩 30;下一回合结束,流血又扣 45,30 − 45 不会变成负数(takeDamage 里有 Math.max(0, …)),就是 0。日志写 “The bleeding stopped.” 和 “Player's Owlet fainted!”(标注 3 的两行)。

Pocket Sand 和 Stun:打中了只加效果

这两个直接继承 ATargetedSkill,不是 AAttackSkill:所以它们没有威力,也不扣血;但它们照样要过命中判定。

PocketSand.java:整个文件skill/PocketSand.java第 1–21 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import au.edu.unimelb.swen.oop.mokepon.effect.Blinded;
5import java.util.Random;
6
7/* Deals no damage, but blinds the target so its targeted skills may miss. */
8public class PocketSand extends TargetedSkill {
9 private static final int ENERGY_COST = 4;
10
11 public PocketSand() {
12 super("Pocket Sand", ENERGY_COST);
13 }
14
15 @Override
16 protected String applyHit(Fighter user, Fighter target, Random random) {
171 target.addEffect(new Blinded());
182 return "\tPocket sand blinded " + target.getName() + "!\n"
19 + "\tTheir targeted skills may miss for " + Blinded.TURNS + " turns.";
20 }
21}
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
2
  1. 2
    第 18–19 行拼出的两行:Pocket sand blinded Bubbore! 和 Their targeted skills may miss for 3 turns.,3 来自 Blinded.TURNS
Nightwing 的 Pocket Sand 打中了 Bubbore(第一回合的日志,放大)真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
Stun.java:整个文件skill/Stun.java第 1–21 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import au.edu.unimelb.swen.oop.mokepon.effect.Stunned;
5import java.util.Random;
6
7/* Deals no damage, but halves the target's damage for a few turns. */
8public class Stun extends TargetedSkill {
9 private static final int ENERGY_COST = 4;
10
11 public Stun() {
12 super("Stun", ENERGY_COST);
13 }
14
15 @Override
16 protected String applyHit(Fighter user, Fighter target, Random random) {
171 target.addEffect(new Stunned());
182 return "\t" + target.getName() + " was stunned! Their damage is halved for "
19 + Stunned.TURNS + " turns.";
20 }
21}
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
23
  1. 2
    Galecrest was stunned! Their damage is halved for 3 turns.:第 18–19 行拼的一句
  2. 3
    顺便:下面两行是 Galecrest 的 Tackle,没打中(它的命中率只有 0.16,第 4 节)
Impike 对 Galecrest 第二回合:Impike 的 Stun 打中了 Galecrest真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

CStun 和 Pocket Sand 的结构完全一样,只是换成 new Stunned()(标注 1)和另一句话(标注 2)。这句话没有 \n,所以只占一行。被眩晕的一方 3 回合内打出去的伤害减半,靠的就是上面 getDamageMultiplier() 里的 0.5。Pocket Sand 和 Stun 的能量都是 4。

6. BoostSkill 和 Wait:不用瞄准对手的技能

BoostSkill:一定成功,给自己加点

BoostSkill.java:整个文件skill/BoostSkill.java第 1–28 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import au.edu.unimelb.swen.oop.mokepon.model.Stat;
5import java.util.Random;
6
7/* A skill that always works and raises one of the user's own stats by 1 to 10 points.
8 * The boost skills only differ in which stat they raise.
9 */
10public abstract class BoostSkill extends Skill {
111 private static final int MAX_BOOST = 10;
12
132 private final Stat stat;
14
15 protected BoostSkill(String name, int energyCost, Stat stat) {
16 super(name, energyCost);
17 this.stat = stat;
18 }
19
20 /* The stat cannot go above its limit, so the log shows the points actually gained. */
21 @Override
22 protected String applyEffect(Fighter user, Fighter target, Random random) {
233 int boost = random.nextInt(MAX_BOOST) + 1;
244 int gained = user.raiseStat(stat, boost);
255 return "\tThey raised their " + stat.getLabel().toLowerCase() + " by " + gained
26 + " points!";
27 }
28}
  1. 标注 1,第 11 行:MAX_BOOST = 10,一次最多加 10 点。
  2. 标注 2,第 13 行:stat:这个技能要加哪一项属性。类型是枚举 EStat(HP、ENERGY、STRENGTH、ACCURACY、DEFENCE、DODGE 之一;枚举见 第 2 章第 6 节)。它由子类在构造器里传上来(第 15–18 行)。
  3. 标注 3,第 23 行:random.nextInt(MAX_BOOST) 得到 0 到 9 之间的整数,再 + 1,就是 1 到 10。
  4. 标注 4,第 24 行:user.raiseStat(stat, boost):给用技能的人自己加。它返回实际加上去的点数(见下面的代码)。
  5. 标注 5,第 25–26 行:拼出 “They raised their accuracy by 3 points!”。stat.getLabel() 是 "Accuracy",toLowerCase() 变成小写 "accuracy"。

ABoostSkill 直接继承 ASkill,它的 applyEffect 里没有 hits:一定成功,被致盲也没关系(第 4 节 Ironshell 的例子)。参数 target 一次都没用到:加点只加自己。

加点真正发生在 CFighter 的 raiseStat 里。编号 1 = 第 97 行压上限,编号 2 = 第 99 行返回实际加的点数:

Fighter.raiseStat:加点,不超过上限battle/Fighter.java第 94–100 行
94 /* Raise a stat without going above its limit, and return the points actually gained. */
95 public int raiseStat(Stat stat, int amount) {
96 int oldValue = getStat(stat);
971 int newValue = stat.limit(oldValue + amount);
98 stats.put(stat, newValue);
992 return newValue - oldValue;
100 }
用 Aim:能量 12 → 9,自己的命中(accuracy)提高 1~10 点
2
  1. 2
    Shellurk 用 Aim:accuracy 62 → 65,日志写 3
真实运行截图 · 用 Aim:能量 12 → 9,自己的命中(accuracy)提高 1~10 点
用 Swole:提高自己的力量(strength)1~10 点
2
  1. 2
    Owlet 用 Swole:strength 44 → 49,日志写 5
真实运行截图 · 用 Swole:提高自己的力量(strength)1~10 点
用 Bunker:提高自己的防御(defence)1~10 点
2
  1. 2
    Owlet 用 Bunker:defence 36 → 42,日志写 6
真实运行截图 · 用 Bunker:提高自己的防御(defence)1~10 点
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
221
  1. 2
    敌人 Nightwing 第 2、3 回合用 Aim:83 → 91 → 96,随机出几就加几,日志写 8、5
  2. 1
    第 4 回合:96 + boost 到了 100 或更多,第 97 行 limit 把它压在 100;第 99 行返回 100 − 96 = 4,日志写 4
Shellurk 对 Nightwing:敌人 Nightwing 连用三次 Aim真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)

第 97 行用 EStat 的 limit 把新值压在合法范围里。EStat 的每一项都存着自己的最小值和最大值,limit(value) 就是 Math.max(最小值, Math.min(value, 最大值))(限制范围):Strength、Accuracy、Defence、Dodge 都是 1 到 100。第 99 行返回 newValue - oldValue:真正加上去的点数,不是随机出来的 boost。平时两者一样;快到上限时就不一样了。

真实例子就是上面 Nightwing 连用三次 Aim 的那张日志(Shellurk 对 Nightwing 那一仗):敌人 Nightwing 连用 Aim,83 → 91(+8)→ 96(+5),第三次日志写 “by 4 points”,变成 100(标注 1)。96 离上限只差 4:随机出 4 到 10 里的任何一个数,结果都是 100,日志都只能写 4。所以 ABoostSkill 第 25 行写的是 gained,不是 boost(第 20 行的注释就是这么说的)。再用一次 Aim,日志会写 “by 0 points”。

截图里一共有 14 次加点,按代码对一遍(加的点数和能量都对得上):

那一仗谁用技能属性变化(括号里是日志写的点数)能量
Shellurk 对 NightwingShellurkAim × 4accuracy 62 → 65 → 66 → 73 → 78(+3、+1、+7、+5)12 → 9 → 6 → 3 → 0
Shellurk 对 NightwingNightwing(敌人)Aim × 3accuracy 83 → 91 → 96 → 100(+8、+5、+4)27 → 24 → 21 → 18
Ironshell 对 FrostbackIronshellAim × 3accuracy 55 → 62 → 68 → 69(+7、+6、+1)10 → 7 → 4 → 1
Owlet 对 ImpikeOwletSwolestrength 44 → 49(+5)28 → 25
Owlet 对 ImpikeOwletBunkerdefence 36 → 42(+6)25 → 22
Nightwing 对 BubboreBubbore(敌人)Swolestrength 66 → 68(+2)24 → 21
Nightwing 对 BubboreBubbore(敌人)Bunkerdefence 74 → 82(+8)23 → 20

Nightwing 那一行:它第一回合用的是 Wait,开打时就是满的 27,所以还是 27;后面三次 Aim 各扣 3。属性值在战斗画面上不显示(屏幕上只有 HP 条和能量条),它们存在 CFighter 的 stats 里。战斗结束,这个 CFighter 被丢掉,加的点也就没了(第 10 章)。

Aim、Bunker、Nimble、Swole:只选一个属性

Aim.java:整个文件skill/Aim.java第 1–12 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.model.Stat;
4
5/* Raises the user's own accuracy. */
6public class Aim extends BoostSkill {
71 private static final int ENERGY_COST = 3;
8
9 public Aim() {
102 super("Aim", ENERGY_COST, Stat.ACCURACY);
11 }
12}
Swole.java:整个文件skill/Swole.java第 1–12 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.model.Stat;
4
5/* Raises the user's own strength. */
6public class Swole extends BoostSkill {
71 private static final int ENERGY_COST = 3;
8
9 public Swole() {
102 super("Swole", ENERGY_COST, Stat.STRENGTH);
11 }
12}

四个加点技能的代码一模一样,只有标注 2 传给 super 的那个 EStat 不同;能量(标注 1)都是 3:

类传给 super 的 Stat能量截图里有没有用过
CAimStat.ACCURACY3有(Shellurk、Nightwing、Ironshell)
CBunkerStat.DEFENCE3有(Owlet、Bubbore)
CNimbleStat.DODGE3没有
CSwoleStat.STRENGTH3有(Owlet、Bubbore)

下面是 Shellurk 一直用 Aim 的真实画面。每一帧看三个地方:左下蓝条上的能量数字、中间 Actions 下面的按钮、右边日志的最后一行:

Shellurk 的战斗开始:技能 Tackle [5]、Aim [3]、Wait [-2],能量 12用 Aim:能量 12 → 9,自己的命中(accuracy)提高 1~10 点能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
Shellurk 对 Nightwing:连用 4 次 Aim,能量 12 → 0

Wait:负的能量消耗

Wait.java:整个文件skill/Wait.java第 1–23 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import au.edu.unimelb.swen.oop.mokepon.battle.Fighter;
4import java.util.List;
5import java.util.Random;
6
7/* Does nothing except restore energy (through its negative cost). */
8public class Wait extends Skill {
91 private static final int ENERGY_COST = -2;
102 private static final List<String> MESSAGES = List.of(
11 "\tThey get a little more bored.",
12 "\tThey wonder if this is a good strategy.",
13 "\tThey think about what they will have for dinner.");
14
15 public Wait() {
16 super("Wait", ENERGY_COST);
17 }
18
19 @Override
20 protected String applyEffect(Fighter user, Fighter target, Random random) {
213 return MESSAGES.get(random.nextInt(MESSAGES.size()));
22 }
23}
  1. 标注 1,第 9 行:能量消耗是 −2。Wait 回能量,靠的不是自己的代码,而是父类 use 第 31 行那句 user.spendEnergy(energyCost):付 −2 点,就是多 2 点。
  2. 标注 2,第 10–13 行:三句台词,用 List.of 做成一个不能修改的列表。
  3. 标注 3,第 21 行:random.nextInt(MESSAGES.size()):size 是 3,得到 0、1、2 里的一个,取出那一句。user、target 都没用到。

三句台词在截图里都出现过:“They think about what they will have for dinner.” 和 “They wonder if this is a good strategy.”(Impike 对 Galecrest 第一回合,两边都用了 Wait),还有 “They get a little more bored.”(Owlet 输掉那一仗快结束时)。Wait 直接继承 ASkill:它不瞄准对手,也不加属性,放在哪一家都不合适。

“付 −2 点”具体怎么算,要看 CFighter 的两个方法(编号和截图一样):

Fighter.canUse 和 spendEnergybattle/Fighter.java第 76–79、86–92 行
76 /* Check whether there is enough energy to use the skill. */
77 public boolean canUse(Skill skill) {
781 return getEnergy() >= skill.getEnergyCost();
79 }
⋯ 省略第 80–85 行
86 /* Pay an energy cost. A negative cost restores energy instead.
87 * Energy always stays between zero and the starting energy.
88 */
89 public void spendEnergy(int amount) {
902 int energy = Math.min(getEnergy() - amount, getMaxEnergy());
913 stats.put(Stat.ENERGY, Math.max(0, energy));
92 }
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
13
  1. 1
    第 78 行:0 ≥ 5?0 ≥ 3?都不成立,Tackle、Aim 灰了;0 ≥ −2 成立,Wait 能点。能量最小是 0(第 91 行),0 ≥ −2 永远成立,所以只要战斗没结束(上面 updateSkillButtons 第 151 行),Wait 永远能点
  2. 3
    0 / 12:上一次 Aim 是 3 − 3 = 0;第 91 行保证不会小于 0
Shellurk 的能量用光了真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
2
  1. 2
    Impike 用了 Wait,还是 33 / 33:本来就满了
Impike 满能量时用 Wait真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)

按代码算三个真实情况(amount 是 −2):

7. SkillCatalogue:每个技能只有一个对象

数据文件里只写了技能的名字,比如 Nightwing 那一段:

mokepon.game 里的 Nightwingmokepon.game第 56–65 行
56mokepon.5.name=Nightwing
57mokepon.5.image=assets/Nightwing.png
58mokepon.5.hp=155
59mokepon.5.energy=27
60mokepon.5.strength=68
61mokepon.5.accuracy=83
62mokepon.5.defence=42
63mokepon.5.dodge=86
641mokepon.5.skills=Lance|Aim|Pocket Sand|Wait
65mokepon.5.type=NORMAL
选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
1
  1. 1
    选择界面上的 Skills:Lance, Aim, Pocket Sand, Wait
选择界面上 Nightwing 的数值和技能真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

读数据的 CMokeponParser 把这一串名字切开,再一个一个去 CSkillCatalogue 里找对象:

MokeponParser.parseSkillsdata/MokeponParser.java第 42–49 行
42 /* Skill names are separated by "|", for example Bite|Pocket Sand|Nimble|Wait. */
43 private List<Skill> parseSkills(String skillNames) {
44 List<Skill> skills = new ArrayList<>();
451 for (String skillName : skillNames.split("\\|")) {
462 skills.add(SkillCatalogue.find(skillName.trim()));
47 }
48 return skills;
49 }
SkillCatalogue.java:整个文件skill/SkillCatalogue.java第 1–24 行
1package au.edu.unimelb.swen.oop.mokepon.skill;
2
3import java.util.List;
4
5/* Every skill in the game. The data file only gives skill names, and this class
6 * finds the skill object with that name. Skills never change after they are made,
7 * so all Mokepon can share the same skill objects.
8 * To add a new skill, write a new subclass and add one object of it to this list.
9 */
10public class SkillCatalogue {
111 private static final List<Skill> SKILLS = List.of(
12 new Bite(), new Tackle(), new Lance(), new Stun(), new PocketSand(),
13 new Bunker(), new Aim(), new Swole(), new Nimble(), new Wait());
14
15 /* Find a skill by its exact, case-sensitive name. */
162 public static Skill find(String name) {
17 for (Skill skill : SKILLS) {
183 if (skill.getName().equals(name)) {
19 return skill;
20 }
21 }
224 throw new IllegalArgumentException("Unknown skill: " + name);
23 }
24}
  1. 标注 1,第 11–13 行:private static final List<Skill> SKILLS = List.of(new Bite(), …, new Wait())。static:这个列表属于类本身,整个程序只有一份;程序第一次用到 CSkillCatalogue 时造好,10 个 new 只执行这一次。List.of 造出来的列表不能再加、不能再删。
  2. 标注 2,第 16 行:public static Skill find(String name):返回类型是 ASkill,不管找到的是 Lance 还是 Wait。
  3. 标注 3,第 18 行:用 equals 比较名字(字符串要用 equals,不能用两个等号,见 == 和 equals)。大小写要完全一样:写成 "lance" 就找不到。找到就直接返回列表里那个对象,不是复制一个新的。
  4. 标注 4,第 22 行:10 个都不是,就 throw new IllegalArgumentException("Unknown skill: " + name):抛出异常,读数据就停在这里,告诉你哪个名字写错了。

大家共用同一个对象

结果是:全游戏每种技能只有一个对象。36 只 Mokepon 的技能里都有 Wait,它们的 skills 列表里放的全是同一个 Wait 对象。画两只出来看:

Shellurk : Mokepon下排都是 SKILLS 里的对象(一共 10 个,这里画 4 个)Nightwing : Mokepon技能 Tackle:花 5 点能量的重击,威力 2(双倍伤害)。C: Tackle技能 Aim:花 3 点能量,提高自己的命中(Accuracy)。C: Aim技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。C: Wait技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。C: Lanceskills
对象图:Shellurk 和 Nightwing 的 skills 列表指着同一个 Aim 对象、同一个 Wait 对象(空心菱形 = Mokepon 的字段 skills 存着它们)

两只 Mokepon 用的是同一个 Aim 对象,为什么不会互相影响?看 Shellurk 对 Nightwing 那一仗:两边都用了 Aim。编号 1 = 第 81 行,编号 2 = 第 89 行:

Battle.playTurn 的中间一段battle/Battle.java第 79–89 行
79 log.add("");
80 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");
811 log.add(playerSkill.use(player, enemy, random));
82 if (!enemy.isAlive()) {
83 finish(true);
84 return;
85 }
86
87 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);
88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");
892 log.add(enemySkill.use(enemy, player, random));
能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
12
  1. 1
    Shellurk 用 Aim:user 是 Shellurk,加的是 Shellurk 的 accuracy(+1)
  2. 2
    Nightwing 用同一个 Aim 对象:user 是 Nightwing,加的是 Nightwing 的(+8)
第二回合:两边先后用了同一个 Aim 对象(日志放大)真实运行截图 · 能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用

第 81 行把 player 当 user 传进去,第 89 行把 enemy 当 user 传进去。Aim 对象自己什么都不记:它的 applyEffect 只改“这一次传进来的 user”。所有会变的东西(能量、HP、属性、效果)都在两个 CFighter 里。

所以共用是安全的。原因写在 CSkillCatalogue 第 6–7 行的注释里:Skills never change after they are made。具体来说:

反过来,前面说过效果对象每次都新造:CBleeding 要记还剩几回合,这个数会变,两个对手不能共用一份。

8. 动起来看:模板方法一步一步跑

一次 Lance,从头到尾

把前面几节连起来,看第 1 节那一下 Lance 到底跑了哪些代码。怎么看这个动画:

同一个 Lance 对象(SkillCatalogue 里那一个)抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkill抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkill技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLance一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattle正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cplayer : Fighter正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cenemy : Fighter流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingapplyEffect(...)applyHit(...)super.applyHituse(...)返回 StringspendEnergy(4)calculateDamage(...)takeDamage(22)addEffect(...)effects
Battle.playTurn(一段)battle/Battle.java第 74–81 行
74 if (!player.canUse(playerSkill)) {
75 log.add("Not enough energy.");
76 return;
77 }
78
79 log.add("");
80 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");
81 log.add(playerSkill.use(player, enemy, random));
Battle.playTurn(后半段)battle/Battle.java第 87–89、95–95 行
87 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);
88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");
89 log.add(enemySkill.use(enemy, player, random));
⋯ 省略第 90–94 行
95 endTurn();
Skill.javaskill/Skill.java第 27–33 行
27 /* Template Method: pay the energy cost first (even if an attack then misses),
28 * then apply the effect. Returns the message for the battle log.
29 */
30 public final String use(Fighter user, Fighter target, Random random) {
31 user.spendEnergy(energyCost);
32 return applyEffect(user, target, random);
33 }
TargetedSkill.javaskill/TargetedSkill.java第 17–23 行
17 @Override
18 protected final String applyEffect(Fighter user, Fighter target, Random random) {
19 if (!hits(user, target, random)) {
20 return MISS_MESSAGE;
21 }
22 return applyHit(user, target, random);
23 }
TargetedSkill.java:hitsskill/TargetedSkill.java第 31–36 行
31 private boolean hits(Fighter user, Fighter target, Random random) {
32 if (random.nextDouble() >= calculateHitChance(user, target)) {
33 return false;
34 }
35 return random.nextDouble() >= user.getMissChance();
36 }
TargetedSkill.java:calculateHitChanceskill/TargetedSkill.java第 38–44 行
38 private double calculateHitChance(Fighter user, Fighter target) {
39 int accuracy = user.getStat(Stat.ACCURACY);
40 int dodge = target.getStat(Stat.DODGE);
41 double accuracySquared = accuracy * accuracy;
42 double dodgeSquared = dodge * dodge;
43 return accuracySquared / (accuracySquared + dodgeSquared);
44 }
Lance.javaskill/Lance.java第 17–22 行
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 }
AttackSkill.javaskill/AttackSkill.java第 10–10、17–22 行
10 private final double power;
⋯ 省略第 11–16 行
17 @Override
18 protected String applyHit(Fighter user, Fighter target, Random random) {
19 int damage = user.calculateDamage(target, power);
20 target.takeDamage(damage);
21 return "\tHit " + target.getName() + " for " + damage + " damage!";
22 }
Fighter.java:calculateDamagebattle/Fighter.java第 19–19、118–122 行
19 private static final int DAMAGE_SCALE = 50;
⋯ 省略第 20–117 行
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 }
Fighter.java:takeDamagebattle/Fighter.java第 81–84 行
81 /* Lose HP without going below zero. */
82 public void takeDamage(int amount) {
83 stats.put(Stat.HP, Math.max(0, getHp() - amount));
84 }
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
1
  1. 1
    Nightwing 的能量(左下蓝条):23 / 27
真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
2
  1. 2
    Bubbore 的 HP 条(右上绿条):195 / 195,满的
真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
1
  1. 1
    Nightwing 的能量(左下蓝条):19 / 27
真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
2
  1. 2
    Bubbore 的 HP 条(右上绿条):151 / 195
真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
3
  1. 3
    点 Lance [4] 之前:日志里只有第一回合(Pocket Sand 和 Swole),下面是空的
真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
3
  1. 3
    回合做完、画面刷新以后:新的 4 行字
真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
  1. 你点了 Lance [4]。CBattleScreen 的 useSkill 调用 battle.playTurn(skill)。CBattle 第 79–80 行先写一个空行和 “Player's Nightwing used Lance!”,第 81 行调用 playerSkill.use(player, enemy, random)。playerSkill 指着 CSkillCatalogue 里那个 Lance 对象。截图是点之前的样子(标注 3:日志还停在第一回合)。
  2. Lance 类里没有 use,AAttackSkill、ATargetedSkill 里也没有:use 只写在 ASkill 里(Skill 第 30 行),而且是 final,谁也不能改写。所以跑的是 ASkill 的 use。
  3. Skill 第 31 行 user.spendEnergy(energyCost):energyCost 是 Lance 构造器一层层传上来的 4。Nightwing 的能量 23 → 19。这时还不知道打不打得中,能量已经付了。画面还没刷新,所以截图里(标注 1)仍是 23 / 27。
  4. Skill 第 32 行 return applyEffect(user, target, random)。applyEffect 在 ASkill 里只有方法头。Java 从对象真正的类 Lance 开始往上找有代码的版本:Lance 没写,AttackSkill 没写,TargetedSkill 写了。所以红色虚线指向 ATargetedSkill。这叫动态绑定。
  5. 现在跑的是 TargetedSkill 第 18 行的 applyEffect。第 19 行第一件事就是 hits(user, target, random):判断打没打中。
  6. hits 先调用 calculateHitChance(TargetedSkill 第 38–44 行):读 user(Nightwing)的 Accuracy 83、target(Bubbore)的 Dodge 55。83 × 83 = 6889,55 × 55 = 3025,6889 ÷ (6889 + 3025) ≈ 0.69。
  7. 第一次判断(TargetedSkill 第 32 行):随机出来的小数小于 0.69,所以第 33 行的 return false 没有执行。第二次判断(第 35 行):Nightwing 没被致盲,getMissChance() 是 0,任何小数都 ≥ 0,结果是 true。hits 返回 true。
  8. 打中了,走到 TargetedSkill 第 22 行 return applyHit(user, target, random)。applyHit 在 TargetedSkill 里也只有方法头;对象是 Lance,Lance 自己写了 applyHit,所以红色虚线跳过 AttackSkill,直接指向 CLance。
  9. 现在跑的是 Lance 第 18 行的 applyHit。第 19 行第一句就是 super.applyHit(user, target, random):先跑父类 AAttackSkill 的 applyHit,也就是普通攻击那一套。
  10. AttackSkill 第 19 行请 user 算伤害:user.calculateDamage(target, power)。power(第 10 行的字段)是 Lance 构造器交上来的 0.5。
  11. 在 Nightwing 的 CFighter 里算(Fighter 第 118–121 行):0.5 × 68(Nightwing 自己的 Strength)× 50(第 19 行的 DAMAGE_SCALE)÷ 74(target 也就是 Bubbore 的 Defence)= 22.97;Nightwing 没被眩晕,倍率 1;(int) 去掉小数 → 22。
  12. 回到 AttackSkill 第 20 行 target.takeDamage(22),跑的是 Fighter 第 82–84 行:Bubbore 的 HP 195 − 22 = 173。Math.max(0, …) 保证 HP 不会小于 0。截图里的 HP 条(标注 2)还是满的:画面还没刷新。
  13. AttackSkill 第 21 行返回 "\tHit Bubbore for 22 damage!"。回到 Lance 第 19 行,这句话存进变量 hitMessage。
  14. Lance 第 20 行 target.addEffect(new Bleeding(user)):新造一个 CBleeding,记住攻击者是 Nightwing,放进 Bubbore 的 effects 列表。它要等回合结束才扣血。
  15. Lance 第 21 行返回 hitMessage + " They started bleeding!"。这句话一路原样交回去:TargetedSkill 第 22 行 → Skill 第 32 行 → Battle 第 81 行 log.add(...)(红色虚线:返回 String)。Lance 的出手到这里结束。
  16. 回合剩下的部分(第 11 章):Battle 第 87–89 行让 Bubbore 出手,它用 Wait(同一个 use,能量 +2);第 95 行 endTurn(),Bleeding 再扣 22:173 → 151。然后 CBattleScreen 调用 update(),画面才刷新:能量 19 / 27(标注 1),HP 条 151 / 195(标注 2),日志多了 4 行字(标注 3,前面还有 1 个空行)。

这 16 步里,Lance 自己的代码只跑了 3 行(第 19–21 行)。其余的:付能量是 ASkill 的,命中判定是 ATargetedSkill 的,算伤害、扣血是 AAttackSkill 和 CFighter 的。这就是继承加模板方法的好处:每个类只写自己那一点,合起来就是完整的一招。

一整场:家谱里哪些类在干活

再看 Nightwing 对 Bubbore 的前三个回合。上面是家谱(只画这一仗用到的类),亮的框和箭头 = 这一句日志是哪几层类的代码造出来的;中间是这一步说到的那段代码;下面是那一刻的日志(放大),亮的红框是这一步说的那几行。

不是技能(第 13 章)抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkill技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWait抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkill技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSand技能 Aim:花 3 点能量,提高自己的命中(Accuracy)。CAim技能 Swole:花 3 点能量,提高自己的力量(Strength)。CSwole技能 Bunker:花 3 点能量,提高自己的防御(Defence)。CBunker技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLance流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleeding
这一仗用到的技能类(没用到的 Bite、Tackle、Stun、Nimble 没画);亮框 = 这一步跑了谁的代码
SkillCatalogue.javaskill/SkillCatalogue.java第 10–16 行
10public class SkillCatalogue {
11 private static final List<Skill> SKILLS = List.of(
12 new Bite(), new Tackle(), new Lance(), new Stun(), new PocketSand(),
13 new Bunker(), new Aim(), new Swole(), new Nimble(), new Wait());
14
15 /* Find a skill by its exact, case-sensitive name. */
16 public static Skill find(String name) {
PocketSand.javaskill/PocketSand.java第 14–20 行
14
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 }
BoostSkill.javaskill/BoostSkill.java第 21–27 行
21 @Override
22 protected String applyEffect(Fighter user, Fighter target, Random random) {
23 int boost = random.nextInt(MAX_BOOST) + 1;
24 int gained = user.raiseStat(stat, boost);
25 return "\tThey raised their " + stat.getLabel().toLowerCase() + " by " + gained
26 + " points!";
27 }
Lance.javaskill/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 }
Wait.javaskill/Wait.java第 16–22 行
16 super("Wait", ENERGY_COST);
17 }
18
19 @Override
20 protected String applyEffect(Fighter user, Fighter target, Random random) {
21 return MESSAGES.get(random.nextInt(MESSAGES.size()));
22 }
Bleeding.javaeffect/Bleeding.java第 24–30 行
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 }
Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
1
  1. 1
    只有开头一行
战斗开始真实运行截图 · Nightwing 的战斗开始:技能 Lance、Aim、Pocket Sand、Wait
用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
12
  1. 1
    Nightwing 的 Pocket Sand
  2. 2
    Bubbore 的 Swole
第一回合真实运行截图 · 用 Pocket Sand 命中:敌人被致盲,3 回合内它的攻击可能多 50% 落空
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
12
  1. 1
    Nightwing 的 Lance
  2. 2
    Bubbore 的 Wait
第二回合真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
123
  1. 1
    Nightwing 的 Wait
  2. 2
    Bubbore 的 Bunker
  3. 3
    流血结束
第三回合,也是流血的第 2 个回合真实运行截图 · 流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
  1. 战斗开始。Nightwing 的 4 个技能分在三家:CLance(AttackSkill 一家)、CPocketSand(TargetedSkill 一家)、CAim(BoostSkill 一家)、CWait(直接继承 Skill)。亮的就是这 4 个类,它们的对象都是 CSkillCatalogue 第 11–13 行造的那唯一的一个。
  2. Nightwing 用 Pocket Sand。ASkill.use 先扣能量 27 → 23;ATargetedSkill.applyEffect 判定命中(0.69,通过了);再跑 CPocketSand.applyHit:第 17 行给 Bubbore 新造一个 Blinded,第 18–19 行返回两行字。
  3. Bubbore 用 Swole。同一个 ASkill.use 先扣能量 24 → 21;然后是 ABoostSkill.applyEffect:第 23 行随机出一个 1–10 的数,第 24 行 raiseStat 把力量 66 → 68,第 25 行写实际加的 2 点。Bubbore 刚被致盲,可 Swole 根本不判命中,照样成功。
  4. Nightwing 用 Lance:四层都亮了(上一个动画一步一步看过)。能量 23 → 19,伤害 22;第 20 行再给 Bubbore 加一个 CBleeding(右边亮的那个框)。
  5. Bubbore 用 Wait:ASkill.use 的 spendEnergy(-2) 让能量 21 → 23;CWait.applyEffect 第 21 行只是随机挑一句话。回合结束时 CBleeding 扣了 22(日志里没有这一行),HP 173 → 151。
  6. 第三回合 Nightwing 用 Wait:19 → 21。它用的和刚才 Bubbore 用的是同一个 Wait 对象,跑的也是同一行第 21 行。
  7. Bubbore 用 Bunker:和 Swole 跑的是 ABoostSkill 里同一段代码,只是 stat 换成了 DEFENCE。防御 74 → 82(+8),能量 23 → 20。
  8. “The bleeding stopped.” 不是技能写的:回合结束时,CBattle 让 Bubbore 身上的 CBleeding 再扣一次血(Bleeding 第 25 行,第 13 章);这是它的最后一回合,所以第 26–27 行返回这句话。防御刚升到 82,这次只扣 0.5 × 68 × 50 ÷ 82 = 20.73 → 20,HP 151 → 131。Bunker 马上就起作用了。

10 个技能一张表

最后一列可以点,直接跳到这一章里那张真实截图:

技能(父类)能⁠量做什么真实日志截图
CBite(AttackSkill)1威力 1 的攻击截图里没人用过;打中会写 Hit … for N damage!无(截图里没人用过)
CTackle(AttackSkill)5威力 2 的攻击Galecrest 用过一次:But they missed.Stun 那张的标注 3
CLance(AttackSkill)4威力 0.5,打中后流血 2 回合Hit Bubbore for 22 damage! They started bleeding!第 1 节大图、Owlet 那张
CPocketSand(TargetedSkill)4对手致盲 3 回合Pocket sand blinded Bubbore! Their targeted skills may miss for 3 turns.第 5 节
CStun(TargetedSkill)4对手伤害减半 3 回合Galecrest was stunned! Their damage is halved for 3 turns.第 5 节
CAim(BoostSkill)3自己 Accuracy +1~10They raised their accuracy by 3 points!第 6 节、Nightwing 那张
CBunker(BoostSkill)3自己 Defence +1~10They raised their defence by 6 points!第 6 节
CNimble(BoostSkill)3自己 Dodge +1~10截图里没人用过;会写 They raised their dodge by N points!无(截图里没人用过)
CSwole(BoostSkill)3自己 Strength +1~10They raised their strength by 5 points!第 6 节
CWait(Skill)−2最多回 2 点能量(不超过开打时的值),随机一句话They think about what they will have for dinner.第 6 节

9. 加一个新技能:开闭原则,和 Project 1 比一比

ASkill 第 7 行的注释写着:To add a new skill, write a new subclass instead of changing the existing ones. CSkillCatalogue 第 8 行也说:写一个新子类,再往列表里加一个对象。先看 Project 1 是怎么写技能的。它只有一个 Skill 类,先看造对象的方法:

Project 1 的 Skill.generateSkill(第 31–43 行)Project 1 · Skill.java第 31–43 行
31 // Create a supported skill with its fixed energy cost.
32 public static Skill generateSkill(String name) {
331 if (name.equals("Bite")) {
34 return new Skill("Bite", 1);
35 } else if (name.equals("Tackle")) {
36 return new Skill("Tackle", 5);
37 } else if (name.equals("Bunker")) {
38 return new Skill("Bunker", 3);
39 } else if (name.equals("Wait")) {
40 return new Skill("Wait", -2);
41 }
42 return null;
43 }

标注 1,第 33–41 行:generateSkill 用一串 if / else if 按名字造对象,只认识 4 个技能:Bite、Tackle、Bunker、Wait。名字不认识就返回 null(第 42 行)。再看出手的方法:

Project 1 的 Skill.use(一部分)Project 1 · Skill.java第 47–71、79–82 行
47 public String use(Mokepon attacker, Mokepon target, Random random) {
48 // Pay the energy cost even if the attack misses.
49 attacker.spendEnergy(energyCost);
50
51 // Use accuracy and dodge to calculate the hit chance.
522 double hitProbability = Math.pow(attacker.getAccuracy(), 2) / (Math.pow(attacker.getAccuracy(), 2) + Math.pow(target.getDodge(), 2));
53
54 // Round damage down with integer division.
55 int biteDamage = attacker.getStrength() * 50 / target.getDefence();
56 int tackleDamage = 2 * attacker.getStrength() * 50 / target.getDefence();
57
583 if (name.equals("Bite")) {
59 if (random.nextDouble() < hitProbability) {
60 target.takeDamage(biteDamage);
61 return "\tHit " + target.getName() + " for " + biteDamage + " damage!";
62 }
63 return "\tBut they missed.";
64
654 } else if (name.equals("Tackle")) {
66 if (random.nextDouble() < hitProbability) {
67 target.takeDamage(tackleDamage);
68 return "\tHit " + target.getName() + " for " + tackleDamage + " damage!";
69 }
70 return "\tBut they missed.";
715 } else if (name.equals("Bunker")) {
⋯ 省略第 72–78 行
79 attacker.setDefence(attacker.getDefence() + defence);
80 return "\tThey raised their defence by " + defence + " points!";
816 } else {
82 if (attacker.getEnergy() > attacker.getMaxEnergy()) {

两种写法放在一起画出来:

Project 1Project 2(你交的)Skill(只有这一个类)generateSkill:4 个 if 分支use:4 个分支抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkill技能 Bite:花 1 点能量的普通攻击,威力 1。CBite技能 Tackle:花 5 点能量的重击,威力 2(双倍伤害)。CTackle技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLanceHeadbutt(假设)只新写这一个类全部 10 个技能的目录:按名字找到对应的技能对象。CSkillCatalogueSKILLS 里加new Headbutt()
左:Project 1 只有一个 Skill 类,加技能要改它的两串 if/else。右:Project 2 加一个 Headbutt(假设的例子)只要新写一个类(虚线框),再往 SkillCatalogue 的 SKILLS 列表里加一个对象

假如要加 Lance(Project 2 才有的技能),两种写法要动的地方完全不一样:

Project 1Project 2(你交的)
新⁠加没有新类,只能改旧的一个新类 CLance(23 行)
要⁠改generateSkill 多一个 else if;use 多一个分支,流血也写进去CSkillCatalogue 第 12 行多一个 new Lance()
不⁠用⁠碰(每个技能都在同一个方法里,改一处可能影响别的)ASkill、ATargetedSkill、AAttackSkill、CBattle、CFighter、CBattleScreen、CRandomEnemyStrategy
写⁠错⁠名⁠字返回 null,用的时候才出错find 马上抛出 Unknown skill

这就是开闭原则(Open/Closed Principle):对扩展开放(可以加新的子类),对修改关闭(已经能用的类不用改)。能做到,靠的是两件事:

比如要加一个威力 1.5、能量 3 的攻击,只要写这样一个类(这是假设的例子,你交的代码里没有它):

// 假设的例子:不在你的项目里
public class Headbutt extends AttackSkill {
    private static final int ENERGY_COST = 3;
    private static final double POWER = 1.5;

    public Headbutt() {
        super("Headbutt", ENERGY_COST, POWER);
    }
}

然后在 CSkillCatalogue 的 List.of(...) 里加上 new Headbutt(),数据文件里某只 Mokepon 的 skills 写上 Headbutt。按钮、日志、敌人随机选技能,全都自动能用。

这也说明了另一个原则:任何 ASkill 的子类都能放在需要 ASkill 的地方,程序照样正常(里氏替换原则)。在真实代码里就是:CBattle.playTurn(Skill playerSkill) 收到 Lance、Wait,或者将来的 Headbutt,都一样跑 playerSkill.use(...),CBattle 从来不检查它是哪一个子类。模板方法、开闭原则和这些原则在整个项目里的位置,第 16 章会再统一整理。下一章 第 13 章 讲这一章多次提到的 CBleeding、CBlinded、CStunned 怎么持续几回合,以及道具。

自测

Skill.use 为什么写成 final?如果 Lance 能改写 use,会有什么风险?

use 是模板方法:它定死了“先 user.spendEnergy(energyCost),再 applyEffect(...)”这个顺序。final 让任何子类都改不了这个顺序,所以每个技能都一定先付能量。如果 Lance 能改写 use,它可能忘了扣能量,或者改成“打中才扣”,规则就不一样了。

English: use() is a final Template Method, so every skill pays its energy cost first and then applies its effect; no subclass can change that order.

Owlet 那一仗,Impike 的 Stun 没打中,能量却从 22 变成了 18。指出是哪一行代码造成的。

ASkill 第 31 行 user.spendEnergy(energyCost) 在第 32 行 applyEffect 之前执行;命中判定在 ATargetedSkill.applyEffect 里面(第 19 行 hits)。所以先付了 4 点,再发现没中,返回 But they missed.。

English: The energy is paid in Skill.use before applyEffect runs the hit check, so it is paid even if the attack misses.

Nightwing(Accuracy 83,Strength 68)用 Lance 打 Bubbore(Dodge 55,Defence 74)。命中率是多少?打中扣多少?

命中率 = 83² ÷ (83² + 55²) = 6889 ÷ 9914 ≈ 0.69(Nightwing 没被致盲,第二次判断一定通过)。伤害 = 0.5(Lance 的 power)× 68 × 50(DAMAGE_SCALE)÷ 74 = 22.97,倍率 1,(int) 去掉小数 = 22。回合结束流血再扣 22,所以 HP 195 → 151。

CLance.applyHit 第一句为什么是 super.applyHit(...)?把它删掉会怎样?

super.applyHit 运行父类 AAttackSkill 的版本:算伤害、takeDamage、返回 “Hit … for N damage!”。Lance 只在后面多加流血。如果删掉这一句,第 21 行用到的变量 hitMessage 就不存在了,编译都通不过;就算改成不用它,Lance 也只会让对手流血,不会扣那一下的伤害。

English: Lance overrides applyHit(), calls super.applyHit() to deal the normal attack damage, and then adds a Bleeding effect to the target.

Bubbore 被 Pocket Sand 致盲以后用 Bunker,会不会因为致盲而失败?为什么?

不会。致盲只在 ATargetedSkill.hits 的第二次判断起作用(user.getMissChance())。CBunker 属于 ABoostSkill,它的 applyEffect 根本不调用 hits,一定成功。截图里 Bubbore 被致盲后用 Bunker 加了 8 点防御,Ironshell 被致盲后用 Aim 也两次都成功。

Shellurk 和 Nightwing 用的是同一个 Aim 对象。为什么 Shellurk 用 Aim 不会把 Nightwing 的 Accuracy 也加上去?

Aim 对象自己不存任何会变的数据:它只有 final 的名字、能量和 stat。加点加的是这一次传进来的 user:CBattle 第 81 行传 player,第 89 行传 enemy。会变的数值都在各自的 CFighter 里。所以 CSkillCatalogue 可以让所有 Mokepon 共用同一个对象。

English: Skill objects are immutable and keep no battle state, so SkillCatalogue can share one object per skill; all changing state lives in the Fighter passed to use().

Wait 的能量消耗是 −2。为什么 Impike 满能量时用 Wait,能量还是 33?为什么能量是 0 时 Wait 按钮还能点?

spendEnergy(-2) 算 33 − (−2) = 35,再 Math.min(35, getMaxEnergy()) = 33:能量最多回到开打时的值。canUse 比较的是 能量 ≥ energyCost,0 ≥ −2 永远成立(能量不会小于 0),而 CBattleScreen.updateSkillButtons 第 151 行只在战斗结束时才另外把按钮变灰,所以只要战斗还没结束,Wait 就永远能点。

老师问:How would you add a new skill to this program? 用中文说一遍,再说英文。

写一个新的子类:攻击就继承 AAttackSkill,只用 super(...) 传名字、能量、威力;加点就继承 ABoostSkill,传一个 EStat;效果完全不同就继承 ATargetedSkill 写 applyHit,或者继承 ASkill 写 applyEffect。然后在 CSkillCatalogue 的列表里加一个对象。CBattle、CFighter、CBattleScreen 都不用改。Project 1 要在 generateSkill 和 use 的 if/else 里各加一个分支。

English: I would write one new subclass of AttackSkill, BoostSkill, TargetedSkill or Skill, and add one object of it to SkillCatalogue. No existing class has to change, because Battle only depends on the abstract Skill (Open/Closed Principle).

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