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

10战斗的数据:Mokepon、Fighter、Battle

为什么要有 Fighter、数据归谁管、谁创建谁、谁存着谁

读完这一章你能
  • 说清楚一个问题:战斗里 HP、能量、数值、状态效果都会变,可是选择界面和地图卷轴上的 Mokepon 不能跟着变。Project 2 用两个类解决它:CMokepon(只读)和 CFighter(为这一场造的、会变的那一份)
  • 指着 CMokepon 的代码说出它为什么改不了:private final 字段、构造器里复制、只有 getter
  • 用 EStat 的 limit 和 ETerrainEffect 的 adjust,自己算出 Impike 在战斗里的六个数
  • 说出 CFighter 的每个方法改哪个字段、怎么保证数值不出界,并用截图里的真实数字验算
  • 说出 CBattle 有哪些字段、两个构造器怎么用 this(...) 连起来、getLog() 为什么交出去的是只读列表
  • 画出战斗里“谁存着谁”(containership)和“谁 new 了谁”(instantiation),并用英语说:Battle holds two Fighters; each Fighter holds the Mokepon it was made from.

第 1 章里你看到一件怪事:Impike 的 HP 在选择界面是 150,进了战斗变成 225,打完回到地图又是 150。这一章把背后的三个类讲透:CMokepon、CFighter、CBattle。它们是战斗的数据:存什么、谁存着谁、谁能改。一个回合按什么顺序调用它们,在 第 11 章;技能在 第 12 章;状态效果和道具在 第 13 章;战斗画面怎么画在 第 14 章。

先复习三个第 1 章讲过的词:字段(field)是类里面存东西的变量;构造器(constructor)是 new 一个对象时自动运行的那段代码;对象(object)是按类造出来的一个具体的东西。Java 语法放在 第 2 章,这一章用到新语法时会就地解释。

1. 先看问题:战斗里的数会变,Mokepon 不能变

同一只 Impike,在三个时刻的样子(第 1 章那一局:Impike 在 FOREST 上打 Galecrest):

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12
  1. 1
    HP: 150
  2. 2
    Energy: 22
开打之前:选择界面真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
12
  1. 1
    HP 225 / 225
  2. 2
    能量 25 / 33:Stun 和 Lance 各花了 4 点
刚打赢的那一刻:战斗画面真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
12
  1. 1
    HP: 150
  2. 2
    Energy: 22
打完回到地图:右上角卷轴真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

战斗里 HP 先因为地形变成 225,能量花掉 8 点只剩 25。可是一回到地图,卷轴上写的又是 150 和 22,好像这一仗没打过。这一章要回答的就是:战斗里会变的数存在哪里?为什么打完仗,Mokepon 自己一点没变?

战斗里会变的东西不止 HP 和能量。截图里的几局,每一样都变过:

会变的真实例子(截图里的局)是什么让它变的
HPOwlet:120 → 75 → 30 → 0(被 Lance 打 45,又流血两次,每次 45)攻击技能、流血、道具
能量Shellurk:12 → 9 → 6 → 3 → 0(连用 4 次 Aim,每次 3 点)每个技能的能量消耗、Coffee
其余四个数值Shellurk 的 Accuracy:62 → 65 → 66 → 73 → 78Aim、Swole、Bunker、Nimble
状态效果Galecrest 被眩晕;Bubbore 先被致盲,后来又流血Stun、Pocket Sand、Lance

这一章用的截图来自五局,玩家每一局选的 Mokepon 不一样,敌人是随机挑的:

那为什么不能直接改 CMokepon 对象?因为一只 Mokepon 在整个程序里只有一个对象,很多地方共用它:

如果战斗直接改 CMokepon:打完一仗,选择界面就会显示受伤后的 HP;下一仗开打时就带着伤;要是敌人碰巧是同一只 Mokepon,你打它就等于打你自己。

选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui玩家:他的 Mokepon、位置、上一个位置、背包和胜场。CPlayerworld一次遇敌:哪个标记、玩家的 Mokepon、敌人和脚下的地形。CEncounterworld正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cplayer : Fighterbattle数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CImpike : MokeponmodelselectedMokeponmokeponplayerMokeponmokepon
四个对象的字段指着同一个 Impike 对象(GameWindow 和 Overworld 的 mokepons 列表里也是它)。只有 Fighter 是每场战斗新造的,战斗里会变的数都存在 Fighter 自己的字段里。空心菱形 = 用字段存着,线上的字是字段名

Project 1 的做法:每次开打先复制一份

Project 1 只有一个 Mokepon 类。它既存数据文件里的数,也存战斗里正在变的数,字段都不是 final,还有一排 setter(设置方法):

Project 1 的 Mokepon:会被战斗直接改Project 1 · Mokepon.java第 7–19、46–52、151–156 行
7 private String name;
8 private String image;
9 private int hp;
10 private int energy;
11 private int strength;
12 private int accuracy;
13 private int defence;
14 private int dodge;
15 private List<Skill> skills;
16
17 private int maxHP;
18 private int maxEnergy;
19 private int originDefence;
⋯ 省略第 20–45 行
46 public int getHp() {
47 return hp;
48 }
49
50 public void setHp(int hp) {
51 this.hp = hp;
52 }
⋯ 省略第 53–150 行
151 // Restore HP, energy and defence to their starting values.
152 public void reset() {
153 this.hp = maxHP;
154 this.energy = maxEnergy;
155 this.defence = originDefence;
156 }

战斗直接改这个对象(比如 takeDamage 写 this.hp = this.hp - amount)。为了不弄坏选择界面上的那一只,Project 1 的 GameWindow 每次开打前先复制一份,战斗打的是副本:

Project 1 的 GameWindow:copyMokepon 造副本Project 1 · GameWindow.java第 257–261、530–533 行
257 Mokepon player = copyMokepon(playerTemplate);
258 Mokepon enemy = copyMokepon(enemyTemplate);
259
260 Battle battle = new Battle(player, enemy);
261 battle.getBattleLog().add("Player chose " + player.getName() + ". Began a fight against enemy " + enemy.getName() + "!");
⋯ 省略第 262–529 行
530 // Use copies to keep the original stats unchanged.
531 private Mokepon copyMokepon(Mokepon template) {
532 return new Mokepon(template.getName(), template.getImage(), template.getMaxHP(), template.getMaxEnergy(), template.getStrength(), template.getAccuracy(), template.getDefence(), template.getDodge(), template.getSkills());
533 }

第 261 行和第 532 行太长,右边被截掉了(可以往右拖着看)。第 532 行的意思是:用模板的名字、图片、getMaxHP()、getMaxEnergy()、另外四个数值和技能列表,new 一个新的 Mokepon 交回去。第 257、258 行拿到的 player、enemy 都是这样的副本,战斗改的是副本,选择界面上的模板不动。

这样也能跑,但有几个毛病:

  1. 保护靠“大家都记得复制”。Mokepon 有 public 的 setHp、setEnergy、setDefence……谁拿到它都能改。Mokepon 自己保护不了自己。
  2. 为了“还原”多出三个字段:maxHP、maxEnergy、originDefence,还写了一个 reset()。可是整个 Project 1 里没有任何地方调用 reset(),它是一段用不上的代码。以后再加一个会变的数值,又要再加一个“原来的值”字段。
  3. 第 261 行:GameWindow 从外面往 Battle 的日志里加了第一行。日志不归 Battle 自己管(第 6 节对比)。

Project 2 的做法:拆成两个类

CMokepon:数据文件里的那只

存名字、图片、类型、六个基础数值、技能。程序启动时造好,从来不改,也改不了(下一节看代码)。很多对象共用它,没关系。

Mokepon holds the fixed data from the file.

CFighter:参加这一场的那只

每场战斗为双方各 new 一个。按地形算好这一场的数值,HP、能量、数值、状态效果都在它里面变。打完仗没人再用它,它就被扔掉了。

A Fighter is made for one battle and holds the values that change.

CFighter 的类注释就是这么写的:它是 “A Mokepon taking part in one battle”,“the Mokepon it was made from never changes”,“When the battle ends this object is thrown away, so the stats go back to their base values”。所以 Project 2 不需要 copyMokepon,也不需要 reset():新的一场 = 新的 Fighter,数值自然从头开始。

2. Mokepon:只读的数据

先看选择界面。点一个名字,右边的每一样东西都是 CSelectScreen 的 select 方法问 CMokepon 要来的(这个方法在 第 7 章)。注意它只调用 get...,一次都没有改。代码里的红色数字和下面截图里的红色数字一一对应:

SelectScreen.select:只读 Mokeponui/SelectScreen.java第 152–166 行
152 portraitView.setImage(UiStyle.loadImage(mokepon.getImagePath()));
1531 nameLabel.setText(mokepon.getName());
154
155 /* A normal Mokepon has no type image after its name. */
1562 MokeponType type = mokepon.getType();
157 showTypeIcon(images.hasTypeImage(type));
158 if (images.hasTypeImage(type)) {
159 typeIcon.setImage(UiStyle.loadImage(images.getTypeImage(type)));
160 }
161
162 for (Stat stat : Stat.values()) {
1633 statValues.get(stat).setText(String.valueOf(mokepon.getStat(stat)));
164 }
1654 skillsValue.setText(formatSkills(mokepon));
166 typeValue.setText(type.name());

第 163 行要从里往外读:mokepon.getStat(stat) 拿到一个 int(比如 150);String.valueOf(...) 把这个数变成文字 "150"(String.valueOf),因为 setText 只收文字;statValues 是 CSelectScreen 自己的一个 Map<Stat, Label>,statValues.get(stat) 找到显示这个数值的那个 Label,再 setText 把文字放上去。for 循环走六圈,六个数就都填好了。

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12234
  1. 1
    名字 Impike:mokepon.getName()
  2. 2
    火焰图标和 Type: FIRE:都来自 mokepon.getType()
  3. 3
    六个数值:mokepon.getStat(stat),循环六次,一次要一个数
  4. 4
    技能:formatSkills 里调用 mokepon.getSkills()
选择界面右半边,标号和上面代码的标号对应。头像在这块上面,来自第 152 行的 getImagePath()真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

再看 CMokepon 类本身。整个类只有 47 行:

Mokepon.java(除了 package 和 import 的全部)model/Mokepon.java第 8–47 行
8/* A Mokepon as it is written in the data file. Its stats never change.
9 * A battle uses a Fighter made from it, so the Mokepon itself stays the same.
10 */
11public class Mokepon {
121 private final String name;
13 private final String imagePath;
14 private final MokeponType type;
15 private final Map<Stat, Integer> stats;
16 private final List<Skill> skills;
17
18 /* Keep our own copies of the stats and skills, so no other class can change them. */
19 public Mokepon(String name, String imagePath, MokeponType type,
20 Map<Stat, Integer> stats, List<Skill> skills) {
21 this.name = name;
22 this.imagePath = imagePath;
23 this.type = type;
242 this.stats = new EnumMap<>(stats);
253 this.skills = List.copyOf(skills);
26 }
27
28 public String getName() {
29 return name;
30 }
31
32 public String getImagePath() {
33 return imagePath;
34 }
35
36 public MokeponType getType() {
37 return type;
38 }
39
40 public int getStat(Stat stat) {
414 return stats.get(stat);
42 }
43
44 public List<Skill> getSkills() {
455 return skills;
46 }
47}

它“改不了”靠的是这五处(数字是左边的红色标记):

  1. 第 12–16 行:五个字段都是 private final。private:只有 CMokepon 自己的代码能直接碰这些字段。final:构造器给它赋值以后,这个字段就不能再指向别的东西。再看下面:除了构造器,所有方法都是 get...,一个 setter 都没有。
  2. 第 24 行:this.stats = new EnumMap<>(stats);。不直接存别人交进来的 Map,而是新造一个,把内容抄过来。为什么?CMokeponParser 读文件时先自己造了一个 Map 放六个数,再交给构造器。如果 CMokepon 存的就是那个 Map,手里还拿着它的人以后往里放新数,CMokepon 就被悄悄改了。抄一份,外面那个 Map 怎么变都跟它无关。这叫防御性复制(defensive copy)。
  3. 第 25 行:this.skills = List.copyOf(skills);。也是抄一份,而且 List.copyOf 抄出来的列表是不能改的:谁对它调用 add 或 remove,程序会报错(UnsupportedOperationException)。
  4. 第 41 行:getStat 交出去的是一个 int,就是一个数,不是 Map 本身。Map 从来不交给别人,所以别人没机会往里放东西。
  5. 第 45 行:getSkills 直接交出 skills。这是安全的,因为它是第 3 处那个不能改的列表。列表里的技能对象也不会变(ASkill 的字段也都是 private final,CSkillCatalogue 的注释说 “Skills never change after they are made, so all Mokepon can share the same skill objects”)。
Mokepon 的 UML 类图
老师打印稿里的 Mokepon:五个字段(红色方块 = private),构造器和五个 getter(绿色圆点 = public)。注意 UML 图上看不出 final。考试时判断它是只读的,看的是:没有任何 set... 方法

这种老师打印稿里的 UML 图怎么读(红色方块、绿色圆点、斜体、下划线),在 第 3 章。

3. Stat:六个数值和它们的合法范围

不管是 CMokepon 还是 CFighter,存数值用的 Map 的键都是 EStat。它是一个枚举(enum):一个只有固定几个值的类型。EStat 正好六个值,每个值还带着三样东西:

Stat.java:六个数值、名字和范围model/Stat.java第 3–30 行
3/* The six stats of a Mokepon, and the legal range of each one. */
4public enum Stat {
51 HP("HP", 1, 1000),
6 ENERGY("Energy", 1, 100),
7 STRENGTH("Strength", 1, 100),
8 ACCURACY("Accuracy", 1, 100),
9 DEFENCE("Defence", 1, 100),
10 DODGE("Dodge", 1, 100);
11
12 private final String label;
13 private final int minimum;
14 private final int maximum;
15
162 Stat(String label, int minimum, int maximum) {
17 this.label = label;
18 this.minimum = minimum;
19 this.maximum = maximum;
20 }
21
22 public String getLabel() {
23 return label;
24 }
25
26 /* Move a value into the legal range of this stat. */
27 public int limit(int value) {
283 return Math.max(minimum, Math.min(value, maximum));
29 }
30}
枚举值labelminimummaximum
HPHP11000
ENERGYEnergy1100
STRENGTHStrength1100
ACCURACYAccuracy1100
DEFENCEDefence1100
DODGEDodge1100

label 是给人看的名字。选择界面和地图卷轴上的 “HP:”、“Energy:”、“Strength:” 就是 stat.getLabel() + ":";日志里的 “They raised their accuracy by 3 points!” 是 ABoostSkill 写的 stat.getLabel().toLowerCase()。另外每个枚举值还自带一个 name(),就是代码里的名字("HP"、"ENERGY"……)。CMokeponParser 用 stat.name().toLowerCase() 拼出数据文件里的键,比如 mokepon.0.hp、mokepon.0.energy(枚举自带的 values / valueOf / name)。

limit:把数挪进范围

第 28 行 Math.max(minimum, Math.min(value, maximum)) 要从里往外读:

  1. 先算 Math.min(value, maximum):两个数里取小的。比上限大的数,变成上限。
  2. 再算 Math.max(minimum, 上一步的结果):两个数里取大的。比下限小的数,变成下限。

这种写法叫限制范围(clamp)。几个例子:

交进来的 value先取小:Math.min再取大:Math.max结果
114(Impike 的 Strength 76 × 1.5)min(114, 100) = 100max(1, 100) = 100100
68(Impike 的 Defence 45 × 1.5 = 67.5,四舍五入)min(68, 100) = 68max(1, 68) = 6868(不变)
345(Frostback 的 HP 230 × 1.5)min(345, 1000) = 345max(1, 345) = 345345(HP 上限是 1000)
0(假设的)min(0, 100) = 0max(1, 0) = 11

整个项目里只有两处调用 limit,都在 CFighter 里:构造器算这一场的起始数值(第 4 节),和 raiseStat 给数值加点(第 5 节)。HP 和能量往下掉时不用它:takeDamage 和 spendEnergy 自己用 0 当下限。所以 EStat 说 HP 最少是 1,战斗里 HP 却可以是 0(倒下了)。这两件事不矛盾:1 是“开打时和加点时”的下限,0 是“被打”的下限。

真实例子:两个数值被截到 100

踩上 (3,3) 的白色感叹号
12
  1. 1
    遇到 Frostback(ICE 类型)
  2. 2
    Your enemy will be at an advantage here!:这一格是 ROCKS,ICE 在 ROCKS 上占优势,所有数值 × 1.5
另一局:玩家选 Ironshell,踩上 (3,3) 的白色感叹号真实运行截图 · 踩上 (3,3) 的白色感叹号

Frostback 这一场的数值

Mokepon 里× 1.5limit 后
HP230345345
Energy142121
Strength72108100
Accuracy588787
Defence88132100
Dodge304545

敌人的条上没有数字,这些是运行时记录下来的真实值。Ironshell 是 NORMAL 类型,在哪儿都不加不减。

4. Fighter:为这一场战斗造的 Mokepon

五个字段

Fighter:类注释和字段battle/Fighter.java第 14–26 行
14/* A Mokepon taking part in one battle. Its stats and effects can change during the battle,
15 * but the Mokepon it was made from never changes. When the battle ends this object is
16 * thrown away, so the stats go back to their base values and the effects are cleared.
17 */
18public class Fighter {
191 private static final int DAMAGE_SCALE = 50;
20
212 private final Mokepon mokepon;
22 /* The stats at the start of this battle, after the terrain effect. */
233 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
254 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
265 private final List<StatusEffect> effects = new ArrayList<>();
  1. DAMAGE_SCALE = 50:伤害公式里的一个固定倍数(第 5 节)。static 表示它属于整个类,所有 CFighter 共用一个;final 表示它不能改。这种写法就是常量(constant),名字按习惯全大写。在 UML 图上,static 的成员带下划线。
  2. mokepon:它是从哪只 CMokepon 造出来的。名字、图片、技能都问它要。
  3. startingStats:这一场开始时的六个数(地形已经算进去了)。注释写着 “after the terrain effect”。它只在构造器里放数,以后不再变。HP 和能量拿它当这一场的最大值(getMaxHp、getMaxEnergy)。另外四个数值不是这样:Aim、Swole、Bunker、Nimble 可以把它们加到比开打时还大(Shellurk 的 Accuracy 开打时 62,后来到了 78),它们的上限是 EStat 里写的 100。
  4. stats:现在的六个数。注释写着 “They only change through the methods below”:只有 CFighter 自己的方法能改它。
  5. effects:身上现在有的状态效果(眩晕、致盲、流血),一个 ArrayList,可以加、可以删。

注意第 23、25、26 行字段后面直接写了 = new EnumMap<>(Stat.class) 和 = new ArrayList<>():这是在声明字段的同时就造好对象。每 new 一个 CFighter,这几句都在构造器正文之前先跑一遍,所以每个 CFighter 都有自己的两个空 Map 和一个空列表。EnumMap 要知道键是哪个枚举,所以要交给它 Stat.class:类名后面加 .class,代表的是 EStat 这个类型本身(不是某一个枚举值),意思是“键都是 Stat”。

Fighter 的 UML 类图
老师打印稿里的 Fighter:DAMAGE_SCALE 带下划线(static);四个普通字段都是 private(红色方块);唯一的 private 方法是最后的 getDamageMultiplier。其余方法都是 public(绿色圆点)

构造器:开打那一刻,算出这一场的六个数

构造器要用 ETerrainEffect。它也是枚举,三个值各带一个倍数:

TerrainEffect:三个倍数和 adjustmodel/TerrainEffect.java第 4–7、22–25 行
4public enum TerrainEffect {
5 ADVANTAGE(1.5, "at an advantage"),
6 NEUTRAL(1, ""),
7 DISADVANTAGE(0.5, "at a disadvantage");
⋯ 省略第 8–21 行
22 /* Multiply a stat for one battle and round it to the nearest integer. */
23 public int adjust(int value) {
24 return (int) Math.round(value * multiplier);
25 }

adjust 做两件事:value * multiplier 乘出一个小数,Math.round 四舍五入(.5 往上入);Math.round 给的是 long(更大的整数类型),前面的 (int) 把它强制转换成 int。一只 Mokepon 在这块地形上是 ADVANTAGE、NEUTRAL 还是 DISADVANTAGE,由它的类型决定(EMokeponType 的 getEffectOn,第 9 章)。

下面一步一步走一遍 new Fighter(Impike, ADVANTAGE)。最上面是代码;代码下面一边是两张图:上图是六个数怎么算(亮的那一格是这一步在算的),下图是这个 CFighter 对象的四个字段里现在装着什么(两个 Map 一圈一圈装满);另一边是截图。手机上它们是上下排的。

Fighter:字段和构造器battle/Fighter.java第 23–26、31–38 行
23 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
25 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
26 private final List<StatusEffect> effects = new ArrayList<>();
⋯ 省略第 27–30 行
31 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
32 this.mokepon = mokepon;
33 for (Stat stat : Stat.values()) {
34 int value = stat.limit(terrainEffect.adjust(mokepon.getStat(stat)));
35 startingStats.put(stat, value);
36 stats.put(stat, value);
37 }
38 }
算好的数放进 startingStats 和 stats,各一份mokepon : MokeponImpike:150 22 76 74 45 70terrainEffectADVANTAGE(× 1.5)HP 150 × 1.5 = 225limit(1–1000) → 225ENERGY 22 × 1.5 = 33limit(1–100) → 33STRENGTH 76 × 1.5 = 114limit(1–100) → 100ACCURACY 74 × 1.5 = 111limit(1–100) → 100DEFENCE 45 × 1.5 = 67.5 → 68limit(1–100) → 68DODGE 70 × 1.5 = 105limit(1–100) → 100
第 34 行对每个数做的事:先乘地形倍数、四舍五入,再 limit。亮的格子 = 这一步用到的两样输入,和正在算的那个数
mokepon(还没赋值)startingStats(空 Map)stats(空 Map)effects(空列表)
这个 Fighter 对象的四个字段:两个 Map 和一个列表刚造好,都是空的
mokepon→ Impike 对象startingStats(空 Map)stats(空 Map)effects(空列表)
第 32 行以后:mokepon 指着 Impike 那个对象(没有复制)
mokepon→ Impike 对象startingStatsHP = 225statsHP = 225effects(空列表)
第一圈以后:两个 Map 里各有一个 HP
mokepon→ Impike 对象startingStatsHP = 225ENERGY = 33statsHP = 225ENERGY = 33effects(空列表)
第二圈以后:又放进一个 ENERGY
mokepon→ Impike 对象startingStatsHP = 225ENERGY = 33STRENGTH = 100statsHP = 225ENERGY = 33STRENGTH = 100effects(空列表)
第三圈以后:STRENGTH 放的是截过的 100
mokepon→ Impike 对象startingStatsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100statsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100effects(空列表)
第四圈以后
mokepon→ Impike 对象startingStatsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100DEFENCE = 68statsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100DEFENCE = 68effects(空列表)
第五圈以后
mokepon→ Impike 对象startingStatsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100DEFENCE = 68DODGE = 100statsHP = 225ENERGY = 33STRENGTH = 100ACCURACY = 100DEFENCE = 68DODGE = 100effects(空列表)
六圈走完:两个 Map 里的六个数一模一样。以后战斗里只有 stats 会变
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
123456
  1. 1
    标 1–6 从上到下是六个数,亮的那一行是这一步在算的
Impike 这个 Mokepon 里存的六个数(选择界面)真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Fight! 进入战斗界面,战斗刚开始
12
  1. 1
    getHp() / getMaxHp():225 / 225
  2. 2
    getEnergy() / getMaxEnergy():33 / 33
战斗画面上你的两根条真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
  1. 开打时,CEncounter 的 createBattle 调用 new Fighter(playerMokepon, getPlayerEffect()),构造器从第 31 行开始运行。交进来两样东西:参数 mokepon 是选择界面上那只 Impike(截图里六个数都是它的);参数 terrainEffect 是 ADVANTAGE,因为 FIRE 类型在 FOREST 上占优势,倍数 1.5。
  2. 构造器正文运行之前,第 23、25、26 行字段后面的 new 先执行(new 一个对象时的执行顺序):startingStats 和 stats 是两个空 Map,effects 是空列表(下图亮的三格)。DAMAGE_SCALE 是 static 的,属于整个类,不在这里造。
  3. 第 32 行 this.mokepon = mokepon;:把交进来的 Impike 记在字段 mokepon 里。存的是同一个对象的引用,没有复制。this.mokepon 是字段,等号右边的 mokepon 是参数,名字一样,用 this. 区分。
  4. 第 33 行开始循环:Stat.values() 按顺序给出六个枚举值,第一圈 stat 是 HP。第 34 行从里往外算:mokepon.getStat(HP) 是 150;terrainEffect.adjust(150) = 150 × 1.5 = 225;HP.limit(225):HP 的范围是 1–1000,225 在里面,不变。算出来的 225 先放在局部变量 value 里。
  5. 第 35、36 行把 225 分别放进两个 Map:startingStats 里 HP = 225,stats 里 HP = 225(下图亮的两格)。现在两份一样。以后战斗里只有 stats 会变,startingStats 再也不放新数,所以它一直记着“这一场开始时是多少”。
  6. 第二圈 stat 是 ENERGY:22 × 1.5 = 33,在 1–100 里,两个 Map 里都放 33。
  7. 第三圈 STRENGTH:76 × 1.5 = 114。STRENGTH.limit(114) = Math.max(1, Math.min(114, 100)) = 100。多出来的 14 点被截掉,放进去的是 100。
  8. 第四圈 ACCURACY:74 × 1.5 = 111,也超过 100,截成 100。
  9. 第五圈 DEFENCE:45 × 1.5 = 67.5,Math.round(67.5) 得 68(.5 往上入)。68 在范围里,放 68。
  10. 第六圈 DODGE:70 × 1.5 = 105,截成 100。六圈走完,第 37 行的 } 结束循环。三个数(Strength、Accuracy、Dodge)都被 limit 截到了 100。
  11. 第 38 行,构造器结束,new Fighter(...) 把这个造好的对象交回给 CEncounter。后来 CBattleScreen 造面板时,CFighterPanel 读 getHp()(stats 里的 225)和 getMaxHp()(startingStats 里的 225),所以写着 225 / 225;能量 33 / 33。Impike 那个 CMokepon 对象里还是 150、22、76……一个数都没变。

敌人 Galecrest 走的是同一段代码,只是交进来的是 DISADVANTAGE(LIGHTNING 在 FOREST 上吃亏,× 0.5):HP 185 × 0.5 = 92.5,Math.round 得 93;Strength 73 × 0.5 = 36.5 → 37;Defence 59 × 0.5 = 29.5 → 30。完整的表在 第 1 章第 5 节。两只用的是同一个类、同一个构造器,只是参数不同,造出两个不同的 CFighter 对象。

战斗画面上的东西,从 Fighter 的哪个方法来

两个卷轴都由 CFighterPanel 画,每个面板用字段 fighter 存着自己那一方的 CFighter。面板上的每一样都是问 CFighter 要来的。下面放大的是你的卷轴(左下那一块);右上敌人的卷轴是同一个类画的,只是条上不写数字。红色数字一一对应:

Fighter 的 getterbattle/Fighter.java第 40–70 行
40 public String getName() {
411 return mokepon.getName();
42 }
43
44 public String getImagePath() {
452 return mokepon.getImagePath();
46 }
47
48 public List<Skill> getSkills() {
493 return mokepon.getSkills();
50 }
51
52 public int getStat(Stat stat) {
534 return stats.get(stat);
54 }
55
56 public int getHp() {
574 return getStat(Stat.HP);
58 }
59
60 public int getMaxHp() {
614 return startingStats.get(Stat.HP);
62 }
63
64 public int getEnergy() {
654 return getStat(Stat.ENERGY);
66 }
67
68 public int getMaxEnergy() {
694 return startingStats.get(Stat.ENERGY);
70 }
点 Fight! 进入战斗界面,战斗刚开始
1234
  1. 1
    名字 Impike:getName(),转去问 mokepon.getName()
  2. 2
    头像:getImagePath(),也是转去问 mokepon
  3. 3
    技能按钮:CBattleScreen 用 battle.getPlayer().getSkills() 造的,也是转去问 mokepon
  4. 4
    HP 条和能量条:getHp() / getMaxHp()、getEnergy() / getMaxEnergy(),读的是 Fighter 自己的两个 Map
战斗刚开始时你的卷轴(放大)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

分成两种:

每次点完按钮,CFighterPanel 的 update 都重新问一遍:

FighterPanel.update:只读 Fighterui/FighterPanel.java第 75–80 行
75 /* Make the bars and the red tint match the fighter. */
76 public void update() {
77 hpBar.update(fighter.getHp(), fighter.getMaxHp());
78 energyBar.update(fighter.getEnergy(), fighter.getMaxEnergy());
79 scroll.setFainted(!fighter.isAlive());
80 }

刚开打时 getHp() 和 getMaxHp() 一样,看不出区别。挨过打以后就不一样了。下面是 Ironshell 那一局,被 Frostback 的 Lance 打中、又流了一次血以后:

用道具之前:HP 被打掉了一些,能量也用掉了
12
  1. 1
    228 / 280:228 是 getHp()(stats),280 是 getMaxHp()(startingStats)。绿条长度 = 228 ÷ 280 ≈ 81%
  2. 2
    1 / 10:能量也是“现在 / 开始时”
Ironshell 被打掉 52 点 HP,能量用到只剩 1真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cplayer : Fighterbattlemokepon : MokeponIronshell:HP 280 · EN 10 · ACC 55startingStats : MapHP 280 · EN 10 · ACC 55stats : MapHP 228 · EN 1 · ACC 69effects : List[ Bleeding,还剩 1 回合 ]mokeponstartingStatsstatseffects
这一刻 Ironshell 的 Fighter 对象里存着什么。只列出 HP、能量、Accuracy 三个数:stats 和 effects 是变过的,上面两格没变,放着用来对比。空心菱形 = 用字段存着,线上的字是字段名

Ironshell 是 NORMAL 类型,在 ROCKS 上不加不减,所以 startingStats 和 CMokepon 里的数一样,都是 280。stats 里的数变了:HP 280 − 26(Lance)− 26(流血)= 228;能量 10 − 3 − 3 − 3 = 1(三次 Aim);Accuracy 55 + 7 + 6 + 1 = 69(三次 Aim 加的点)。Lance 打的 26 和三次 Aim 加的 7、6、1 都写在日志里;流血那 26 点日志里没有(CBleeding 不往日志里写伤害,第 5 节最后会看到),要用伤害公式自己算:0.5 × 100 × 50 ÷ 96 = 26.04 → 26(Frostback 现在的 Strength 100,Ironshell 的 Defence 96)。

5. Fighter 的方法:每一个改哪个字段

先看全貌。CFighter 的字段都是 private,别的类只能调用它的方法。下面这张表列出每个方法改哪个字段、项目里谁调用它(都是在源代码里查出来的):

方法改哪个字段谁调用它
getName()、getImagePath()、getSkills()不改,转去问 mokeponCFighterPanel、CBattleScreen、CRandomEnemyStrategy;写日志时还有 CBattle、AItem、AAttackSkill、CStun、CPocketSand
getStat(stat)不改,只读 stats 里的一个数ATargetedSkill(命中率);CFighter 自己(getHp、getEnergy、raiseStat、calculateDamage)
getHp()、getMaxHp()、getEnergy()、getMaxEnergy()、isAlive()不改CFighterPanel;CBattle 用 isAlive 判输赢
canUse(skill)不改,只查能量够不够CBattle.playTurn、CBattleScreen、CRandomEnemyStrategy
spendEnergy(n)stats 的 ENERGYASkill.use
takeDamage(n)stats 的 HPAAttackSkill、CBleeding
raiseStat(stat, n)stats 里的一个数ABoostSkill
restoreHp() / restoreEnergy()stats 的 HP / ENERGYCHealthTonic / CCoffee
reduceHpToOne()stats 的 HPCPoison
calculateDamage(target, power)不改,只算AAttackSkill、CBleeding
addEffect(effect)effects 加一个CLance、CStun、CPocketSand
getMissChance()不改ATargetedSkill
applyLingeringDamage()让效果去改 HPCBattle.endTurn
countDownEffects()effects 删掉结束的CBattle.endTurn

注意画面类(CBattleScreen、CFighterPanel)只调用“查”的方法(getHp、canUse……)。所有“改”的方法都由规则类调用:技能、道具、状态效果、CBattle。而且不管谁来改,都要经过 CFighter 的方法,所以“HP 不能低于 0”“能量不能超过开打时”这些规则只写一次,写在 CFighter 里。这就是封装(encapsulation)。把上面的表画成一张图:

抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkillskill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkillskillLance · StunPocketSand流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingeffect战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterprivate:stats · startingStats · effects战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui道具 Health Tonic:把玩家 Mokepon 的 HP 回满。CHealthTonicitem道具 Coffee:把玩家 Mokepon 的能量补满。CCoffeeitem道具 Poison:把敌人的 HP 直接降到 1。CPoisonitem一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillskillspendEnergytakeDamageraiseStataddEffecttakeDamagerestoreHprestoreEnergyreduceHpToOneapplyLingeringDamagecountDownEffects只读:canUsegetSkills只读:getHp、getMaxHpgetEnergy…、isAlive只读:getMissChancegetStat
谁调用 Fighter 的哪个方法。实线箭头 = 调用一个会改字段的方法;虚线箭头 = 只读(只查数、不改)。所有箭头都指向 Fighter 的方法,没有谁能绕过它直接碰 stats。上排是技能和状态效果,下排是道具和 Battle,左右两边是画面类。AttackSkill 和 Bleeding 算伤害时还会调用只读的 calculateDamage

能量:canUse 查,spendEnergy 扣

Shellurk 那一局连用了 4 次 Aim(每次 3 点能量)。下面是真实截图连播,看能量条和技能按钮:

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):能量每次少 3,按钮一个个变灰

做这两件事的是 CFighter 的两个方法:

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 }

用截图里的真实数字走三遍:

哪一下第 90 行:先取小(Math.min)第 91 行:再取大(Math.max)结果
Shellurk 第 4 次 Aim(消耗 3)min(3 − 3, 12) = min(0, 12) = 0max(0, 0) = 00 / 12
Impike 满能量时用 Wait(消耗 −2)min(33 − (−2), 33) = min(35, 33) = 33max(0, 33) = 33还是 33 / 33
Owlet 最后一次 Wait(能量已经是满的 28)min(28 − (−2), 28) = min(30, 28) = 28max(0, 28) = 2828 / 28(Game Over 截图里就是这个数)

前后两张放在一起,红框编号一样:

能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
1234
  1. 1
    能量 3 / 12
  2. 2
    Tackle [5]:canUse 算 3 ≥ 5,false,变灰
  3. 3
    Aim [3]:3 ≥ 3,true,能点
  4. 4
    Wait [-2]:3 ≥ −2,true,能点
第 4 次 Aim 之前真实运行截图 · 能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
134
  1. 1
    能量 0 / 12
  2. 3
    Aim [3]:0 ≥ 3,false,也灰了
  3. 4
    Wait [-2]:0 ≥ −2,true。能量 0 也永远能用 Wait
第 4 次 Aim 之后真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)

既然用不起的技能按钮是灰的,为什么 spendEnergy 还要管下限 0?因为“按钮变灰”是画面的事,规则不能指望画面。CBattle 的 playTurn 也自己先问一次 canUse,不够就写 Not enough energy.;CRandomEnemyStrategy 替敌人挑技能时,也只从 canUse 为 true 的技能里挑。Math.max(0, ...) 是最后一道保险。

HP:takeDamage 不会低于 0

先说这一局是谁打谁:你选的是 Owlet,敌人碰巧是 Impike,这一格对两只都不加不减,所以 Impike 的 Strength 是原来的 76(不是第 4 节 FOREST 上的 100)。Owlet 用 Bunker 加过一次防御,Defence 是 36 + 6 = 42。

takeDamage 和 isAlivebattle/Fighter.java第 72–74、81–84 行
72 public boolean isAlive() {
732 return getHp() > 0;
74 }
⋯ 省略第 75–80 行
81 /* Lose HP without going below zero. */
82 public void takeDamage(int amount) {
831 stats.put(Stat.HP, Math.max(0, getHp() - amount));
84 }
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
12
  1. 1
    0 / 120:30 − 45 = −15,Math.max(0, −15) = 0
  2. 2
    整个卷轴变红(右边被 Game Over 框挡住的部分没截进来):isAlive() 是 false。CFighterPanel.update 第 79 行 scroll.setFainted(!fighter.isAlive()) 把红色那一层打开
Owlet 那一局的最后:HP 是 0,不是 −15;卷轴变红真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

第 83 行在左边代码框里,右端可能被截掉一点,完整是:stats.put(Stat.HP, Math.max(0, getHp() - amount));。和 spendEnergy 一样,用 Math.max(0, ...) 管下限。Owlet 的 HP 是这样掉的(每次都是 0.5 × 76 × 50 ÷ 42 = 45.24 → 45):

  1. Impike 的 Lance 打中:takeDamage(45),120 − 45 = 75。
  2. 这一回合结束,流血:又 takeDamage(45),75 − 45 = 30。
  3. 下一回合结束,第二次流血:30 − 45 = −15,Math.max(0, −15) = 0。

标 2,第 73 行的 isAlive() 就是“HP 大于 0 吗”。HP 是 0 了,isAlive() 返回 false:CBattle 判你输;CFighterPanel 第 79 行的 ! 是“取反”(逻辑运算符),!false 就是 true,所以 setFainted(true),卷轴变红。日志里能看到三处:

玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
123
  1. 1
    Hit Owlet for 45 damage! They started bleeding!:第 1 步
  2. 2
    The bleeding stopped.:第 3 步那次流血以后才写。两次流血的伤害本身都不写进日志
  3. 3
    HP 到 0,isAlive() 是 false:Player's Owlet fainted! / You lose
Game Over 时的日志框(放大)。最上面的行被滚上去了。这张图单独编号,1–3 和上面代码的标号无关真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

raiseStat:返回“真的加了几点”

BoostSkill.applyEffect:Aim、Swole、Bunker、Nimble 都用它skill/BoostSkill.java第 20–27 行
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) {
231 int boost = random.nextInt(MAX_BOOST) + 1;
242 int gained = user.raiseStat(stat, boost);
253 return "\tThey raised their " + stat.getLabel().toLowerCase() + " by " + gained
26 + " points!";
27 }
Fighter.raiseStatbattle/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);
972 int newValue = stat.limit(oldValue + amount);
98 stats.put(stat, newValue);
993 return newValue - oldValue;
100 }
用 Aim:能量 12 → 9,自己的命中(accuracy)提高 1~10 点
3
  1. 3
    They raised their accuracy by 3 points!:日志里写的是 raiseStat 返回的数(标 3)。标 1 随机出来的数本身不写进日志
Shellurk 第一次 Aim 后的日志(放大)。上面那一行是 Player's Shellurk used Aim!真实运行截图 · 用 Aim:能量 12 → 9,自己的命中(accuracy)提高 1~10 点

跟着 Shellurk 的第一次 Aim 走一遍:

  1. 标 1:ABoostSkill 随机出加几点,boost = 3。
  2. 标 2:CFighter 的 raiseStat(ACCURACY, 3):oldValue = 62,newValue = ACCURACY.limit(62 + 3) = 65,放进 stats。
  3. 标 3:返回 newValue - oldValue = 65 − 62 = 3。ABoostSkill 把这个数拼进日志:“They raised their accuracy by 3 points!”。

为什么不直接把 boost 写进日志?因为数值有上限 100,加不上去的部分不能算。截图里就有一个快到顶的例子:敌人 Nightwing 第一回合用的是 Wait,后面三回合都用 Aim,Accuracy 83 → 91 → 96 → 100(日志写着加了 8、5、4 点,最后一张截图里正好 100)。假设它再用一次 Aim,随机出 7:

newValue = ACCURACY.limit(100 + 7) = 100,返回 100 − 100 = 0

日志就会写 They raised their accuracy by 0 points!。截图里没有出现这一行(那一局接着就逃跑了),但代码就是这么算的:日志说的永远是真的加上去的点数。Project 1 只有 Bunker 一个加点技能,它在 Skill.use 里写了一个 if,专门处理防御超过 100 的情况;Project 2 有四个加点技能,全都走同一个 raiseStat,范围写在 EStat 里,对六个数值一视同仁。

道具用的三个方法:restoreHp、restoreEnergy、reduceHpToOne

三个给道具用的方法battle/Fighter.java第 102–112 行
102 public void restoreHp() {
1031 stats.put(Stat.HP, getMaxHp());
104 }
105
106 public void restoreEnergy() {
1072 stats.put(Stat.ENERGY, getMaxEnergy());
108 }
109
110 public void reduceHpToOne() {
1113 stats.put(Stat.HP, 1);
112 }
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
1
  1. 1
    Health Tonic:restoreHp(),228 → 280 = getMaxHp()
Ironshell 用了 Health Tonic真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
2
  1. 2
    Coffee:restoreEnergy(),1 → 10 = getMaxEnergy()
Ironshell 用了 Coffee真实运行截图 · 用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
3
  1. 3
    Poison:reduceHpToOne(),敌人 Galecrest 93 → 1,绿条只剩一丝
Impike 对 Galecrest 用了 Poison真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合

这三个方法很短,没有任何检查,因为道具“总是有效”(AItem 的注释:“Using an item always works, so there is no hit check”)。它们由三个道具类调用:CHealthTonic 调用 player.restoreHp(),CCoffee 调用 player.restoreEnergy(),CPoison 调用 enemy.reduceHpToOne()(第 13 章)。

要注意“回满”回到哪里:restoreHp() 放的是 getMaxHp(),也就是 startingStats 里这一场开始时的 HP,不是 CMokepon 里的数。Ironshell 不加不减,所以两个都是 280。如果 Impike 在 FOREST 上用 Health Tonic,会回到 225,不是 150。

calculateDamage:伤害公式

calculateDamage 和 getDamageMultiplierbattle/Fighter.java第 114–122、165–174 行
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) {
1714 multiplier = Math.min(multiplier, effect.getDamageMultiplier());
172 }
173 return multiplier;
174 }
用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)
3
  1. 3
    Hit Bubbore for 22 damage!:Nightwing 的 Lance,0.5 × 68 × 50 ÷ 74 = 22.97,(int) 去掉小数得 22,不是四舍五入的 23
Nightwing 打 Bubbore 那一局的日志(放大)。上面那一行是 Player's Nightwing used Lance!真实运行截图 · 用 Lance 命中:造成伤害并让敌人开始流血(Bleeding)

公式是 Project 1 那一个:伤害 = power × 攻方 Strength × 50 ÷ 守方 Defence。this 是出手的一方,target 是挨打的一方:

截图里的真实伤害,全部可以用这个公式算出来(Strength 和 Defence 都是那一刻 stats 里的数):

谁打谁power × Strength × 50 ÷ Defence× 倍数(int)
Impike 的 Lance 打 Galecrest0.5 × 100 × 50 ÷ 30 = 83.33× 183
Frostback 的 Lance 打 Ironshell0.5 × 100 × 50 ÷ 96 = 26.04× 126
Impike 的 Lance 打 Owlet0.5 × 76 × 50 ÷ 42 = 45.24× 145
Nightwing 的 Lance 打 Bubbore0.5 × 68 × 50 ÷ 74 = 22.97× 122
假设:被眩晕的 Galecrest 的 Tackle 打中了 Impike2 × 37 × 50 ÷ 68 = 54.41× 0.5 = 27.2127

最后一行是假设:第 1 章那一局里,Galecrest 被眩晕后用的 Tackle 其实没打中(日志 But they missed.)。要是打中了,没有眩晕是 54,被眩晕只有 27。注意第一行:Impike 打 Galecrest 时,被眩晕的是 Galecrest,Impike 身上没有效果,所以 Impike 的倍数是 1。眩晕只减被眩晕那一方打出去的伤害。流血也算“打出去的伤害”:CBleeding 用的是出 Lance 那一方的 attacker.calculateDamage(...),所以要是出 Lance 的人被眩晕了,对方流的血也只有一半(CBattle.endTurn 的注释写着 “a stun still halves bleeding damage”)。伤害公式和命中率的细节在 第 12 章,流血在 第 13 章。

状态效果:effects 列表

最后四个方法都围着字段 effects 转。列表里放的是效果对象(CStunned、CBlinded、CBleeding)。每个效果对象自己用一个字段 turnsLeft 记着“还剩几回合”,这个字段在它们的父类 AStatusEffect 里。这一章只看每个方法改了什么、谁来调用。一个效果从加上到删掉、每回合怎么倒数,第 13 章第 4 节有一步一步的动画,用的就是下面这一局(Nightwing 对 Bubbore)。

Fighter:四个管 effects 的方法battle/Fighter.java第 124–163 行
124 public void addEffect(StatusEffect effect) {
1251 effects.add(effect);
126 }
127
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) {
1342 missChance = Math.max(missChance, effect.getMissChance());
135 }
136 return missChance;
137 }
138
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() {
143 List<String> messages = new ArrayList<>();
144 for (StatusEffect effect : effects) {
145 if (!isAlive()) {
146 break;
147 }
1483 messages.addAll(effect.applyLingeringDamage(this));
149 }
150 return messages;
151 }
152
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();
1584 effect.countDown();
159 if (effect.isOver()) {
1605 iterator.remove();
161 }
162 }
163 }

这几行里有三样新语法,这里各说一句,细讲在 第 13 章第 4 节:

Bleeding 为什么存着一个 Fighter

四个方法里只有标 3 会让 HP 变,真正扣血的是 CBleeding。它有一个字段 attacker,存的是出 Lance 的那一方的 CFighter,而不是一个算好的伤害数:

Bleeding:存着 attacker,每次当场算effect/Bleeding.java第 13–30 行
13 private final Fighter attacker;
14
15 public Bleeding(Fighter attacker) {
16 super(TURNS);
171 this.attacker = attacker;
18 }
19
20 /* The bleeding damage is not written to the log. Only after the second bleeding
21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.
22 */
23 @Override
24 public List<String> applyLingeringDamage(Fighter owner) {
252 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));
26 if (isLastTurn()) {
273 return List.of("The bleeding stopped.");
28 }
29 return List.of();
30 }
流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
134
  1. 1
    Nightwing 用 Lance 打中 22 点:CLance 调用 new Bleeding(user),第 17 行把 Nightwing 的 CFighter 存进 attacker
  2. 3
    第二次流血以后,第 27 行交回的句子:The bleeding stopped.
  3. 4
    中间那一回合 Bubbore 用了 Bunker,Defence 74 → 82(这一处只在截图里,代码没有标 4)
Nightwing 对 Bubbore 那一局的日志(放大)。标号和上面代码的标号对应。两次流血掉的血,日志里都不写。这是从开打数的第 3 回合,也是流血的第 2 个回合真实运行截图 · 流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.

两次流血都在第 25 行(标 2)当场算伤害,用的是那一刻双方 stats 里的数:

哪一次流血0.5 × Nightwing 的 Strength × 50 ÷ Bubbore 的 DefenceBubbore 的 HP
第 1 次:Lance 那一回合结束0.5 × 68 × 50 ÷ 74 = 22.97 → 22173 → 151
第 2 次:下一回合结束(这一回合里 Bubbore 用 Bunker 把 Defence 从 74 加到 82)0.5 × 68 × 50 ÷ 82 = 20.73 → 20151 → 131

(173 是 195 先被 Lance 打掉 22 以后的数。敌人的 HP 条上不写数字,这些是游戏运行时记下来的值。)

要是 Lance 打中时就把 22 算好存起来,第二次也会扣 22,Bunker 加的 8 点防御就白加了。CBleeding 每次都调用 attacker.calculateDamage(owner, 0.5) 重新算,所以它得一直拿着打它的那个 CFighter。前面说过“出 Lance 的人被眩晕了,对方流的血也只有一半”,原因也在这一行:calculateDamage 是对 attacker 调用的,用的是 attacker 身上的伤害倍数。第 26–29 行:不是最后一次(isLastTurn() 是 false),就交回空列表 List.of(),日志里什么都不写;最后一次才交回 “The bleeding stopped.”(标 3)。

正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cenemy : FighterBubbore正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。Cplayer : FighterNightwing致盲效果:目标性技能额外有 50% 机会打空,持续 3 回合。CBlindedturnsLeft = 2流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingturnsLeft = 2effects[0]effects[1]attacker
Lance 打中以后的对象:挨打的 Bubbore 的 Fighter 用 effects 存着两个效果;Bleeding 用 attacker 存着出手的 Nightwing 的 Fighter。空心菱形 = 用字段存着,线上的字是字段名

这张图里的 attacker 线,就是第 7 节类图上那根从 CBleeding 指回 CFighter 的线。Pocket Sand 是上一回合加的,CBlinded 开始是 3,回合结束数掉了 1,所以图上是 2;CBleeding 刚加上,从 2 开始。

6. Battle:一场战斗的总管

CBattle 管一场战斗的规则:谁先出手、什么时候算流血、什么时候结束。它的类注释说:“It knows nothing about the screen; it only updates the fighters, the items and the log.”(它不知道屏幕的存在,只更新双方、道具和日志。)先看它存着什么:

Battle:类注释和字段battle/Battle.java第 11–22 行
11/* The rules of one fight between the player's Mokepon and the enemy's Mokepon.
12 * It knows nothing about the screen; it only updates the fighters, the items and the log.
13 */
14public class Battle {
15 private final Fighter player;
16 private final Fighter enemy;
17 private final Inventory inventory;
18 private final EnemyStrategy enemyStrategy;
19 private final Random random;
20 private final List<String> log = new ArrayList<>();
21 private boolean finished = false;
22 private boolean playerWon = false;
字段类型存的是什么会变吗
player、enemyCFighter你和敌人的 CFighterfinal:一直是这两个对象。对象里面的数在变
inventoryCInventory背包。和 CPlayer 的背包是同一个对象,所以战斗里用掉 Poison,回地图卷轴上也是 0final;里面的数量会变
enemyStrategyIEnemyStrategy(接口)替敌人挑技能的对象,现在装的是 CRandomEnemyStrategyfinal
randomRandom随机数:敌人挑哪个技能(playTurn 第 87 行把它交给 enemyStrategy.chooseSkill)、打没打中、加几点、Wait 说哪句话final
logList<String>战斗日志,一行一个字符串final;里面一直在加行
finished、playerWonboolean打完了吗?你赢了吗?不是 final:finish 里改成 true
Battle 的 UML 类图
老师打印稿里的 Battle:八个字段,两个构造器(参数个数不同),最后两个方法 endTurn 和 finish 是 private 的(红色方块),只在 Battle 里面用

两个构造器,用 this(...) 连起来

Battle 的两个构造器battle/Battle.java第 24–39 行
24 /* Start a battle against an enemy that picks random skills. */
25 public Battle(Fighter player, Fighter enemy, Inventory inventory) {
261 this(player, enemy, inventory, new RandomEnemyStrategy(), new Random());
27 }
28
29 /* Start a battle with a chosen enemy strategy and random number generator. */
30 public Battle(Fighter player, Fighter enemy, Inventory inventory,
31 EnemyStrategy enemyStrategy, Random random) {
32 this.player = player;
33 this.enemy = enemy;
34 this.inventory = inventory;
35 this.enemyStrategy = enemyStrategy;
36 this.random = random;
372 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
39 }
点 Fight! 进入战斗界面,战斗刚开始
2
  1. 2
    日志第一行:第 37–38 行写的(和上面代码的标 2 是同一处)。Battle 一造好,这一行就在了
战斗刚开始的日志框(放大)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

CEncounter 只调用三个参数的那个(new Battle(player, enemyFighter, inventory))。整个项目里没有别的地方直接调用五个参数的版本。那为什么还要它?IEnemyStrategy 的注释说:“The battle only knows this interface, so a smarter enemy can be added later without changing the Battle class.” 有了五个参数的构造器,以后可以交进来一个更聪明的敌人策略,或者一个固定种子的 Random(每次结果一样,方便测试),CBattle 的代码一行都不用改。字段 enemyStrategy 的类型写的是接口 IEnemyStrategy,不是 CRandomEnemyStrategy,这叫面向接口编程,就是策略模式(Strategy)(第 16 章)。

getter,和只读的 getLog()

Battle 的 getterbattle/Battle.java第 41–64 行
41 public Fighter getPlayer() {
42 return player;
43 }
44
45 public Fighter getEnemy() {
46 return enemy;
47 }
48
49 public Inventory getInventory() {
50 return inventory;
51 }
52
53 public boolean isFinished() {
54 return finished;
55 }
56
57 public boolean hasPlayerWon() {
58 return playerWon;
59 }
60
61 /* Other classes can read the log but cannot change it. */
62 public List<String> getLog() {
631 return Collections.unmodifiableList(log);
64 }

前面五个 getter 很简单,交出字段里的东西。标 1 的 getLog() 不一样:它交出去的不是 log 本身,而是 Collections.unmodifiableList(log),一个只能看、不能改的“窗口”(只读视图)。谁要是对它调用 add,程序报错 UnsupportedOperationException。CBattleScreen 只是读它,把每一行用换行连起来,放进日志框:

BattleScreen.showLog:只读日志ui/BattleScreen.java第 140–145 行
140 /* Show all messages and scroll to the latest one. */
141 private void showLog() {
142 logArea.setText(String.join("\n", battle.getLog()));
143 logArea.positionCaret(logArea.getLength());
144 logArea.setScrollTop(Double.MAX_VALUE);
145 }

为什么这样做?日志必须按规则一行一行写:每回合前一个空行、谁用了什么、结果……只有 CBattle 知道这些规则(构造器写第一行,playTurn、useItem、endTurn、finish 四个方法往里加)。如果谁都能往里加,日志就可能被画面类弄乱。Project 1 的 getBattleLog() 交出的是真正的列表,GameWindow 就真的往里加了一行。

Collections.unmodifiableList(log):不抄List.copyOf(skills):抄一份一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuilog : ArrayList第 1 行、第 2 行……(Battle 一直往里加)getLog() 交出的只读视图(调用 add 就报错)MokeponParser 交进来的skills 列表数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodelList.copyOf 造的新列表(内容一样,不能改)loggetLog() 拿到读的就是它把内容抄过去skills
左:只读视图,不抄,BattleScreen 读到的就是 Battle 的那一个 log;右:List.copyOf,抄出一个新的、不能改的列表。实线箭头 = 拿到 / 读的是它;空心菱形 = 用字段存着

isFinished 和 hasPlayerWon

Battle.finish:只有这里改 finished 和 playerWonbattle/Battle.java第 133–146 行
133 /* Stop the battle and record who fainted and the result. */
134 private void finish(boolean playerWon) {
1351 this.finished = true;
1362 this.playerWon = playerWon;
137 log.add("");
138 log.add("");
139 if (playerWon) {
140 log.add("Enemy's " + enemy.getName() + " fainted!");
141 log.add("You win");
142 } else {
143 log.add("Player's " + player.getName() + " fainted!");
144 log.add("You lose");
145 }
146 }
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
23
  1. 2
    You Win:hasPlayerWon() 是 true(标 2 那一行放进去的是 true)
  2. 3
    赢了只有一个按钮 Back to it!
Galecrest 倒下:finish(true)。红色 2 对应上面代码的标 2(playerWon);3 只在截图里,弹哪种框是 BattleScreen 的事(第 14 章)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
23
  1. 2
    Game Over:hasPlayerWon() 是 false
  2. 3
    输了是两个按钮:Return to Main Menu、Quit
Owlet 倒下:finish(false)。编号的意思和左图一样真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

finished 和 playerWon 只在 private 的 finish 里改(标 1、2)。别的类只能用 isFinished() 和 hasPlayerWon() 读:

7. 谁存着谁、谁 new 了谁

考试第一类题常问:“哪些类存着这些数据?它们之间是什么关系?”下面把这一章的类画出来,分成两张图。先看包含关系(containership):A 有一个字段,类型是 B,就画一根从 A 到 B 的线,菱形在 A 那头,线上写字段名。

战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodelbattleplayerPanelenemyPanelfighterplayerenemymokeponskills
第一张:主线,画面 → 规则 → 数据。空心菱形 = 用字段存着(菱形在“存的人”那头,线上的字是字段名)
背包:记录每种道具各有几个。CInventoryitem一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle接口:决定敌人这一回合用哪个技能。IEnemyStrategybattle抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattleEnemyStrategy 的实现:从能量够用的技能里随机挑一个。CRandomEnemyStrategybattle抽象类:所有状态效果的共同部分,主要是还剩几回合。AStatusEffecteffect流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingeffectinventorycounts 的键enemyStrategyplayerenemyeffectsattacker
第二张:Battle 和 Fighter 的其他字段。空心菱形 = 用字段存着;空心三角实线 = extends(继承);空心三角虚线 = implements(实现接口)

从上往下读这两张图:

还要注意线的方向:除了 CBleeding → CFighter(attacker)这一根,线都是从上往下:画面 → 规则 → 数据。那一根往回指,是因为流血每次都要用出手那一方现在的 Strength 重新算伤害(第 5 节最后的说明),所以在类图上 CFighter(effects)和 CBleeding(attacker)互相指着。落到对象上,指的是两个不同的 CFighter:挨打的 Bubbore 的 CFighter 存着 CBleeding,CBleeding 存着出手的 Nightwing 的 CFighter(第 5 节最后那张对象图)。其余的:CBattle 没有字段指向 CBattleScreen,CFighter 没有字段指向 CBattle,CMokepon 不知道有 CFighter。所以规则类完全不知道屏幕长什么样(规则与界面分离),CMokepon 也可以被任何一场战斗拿去用。

谁 new 了谁(instantiation)

“存着”和“造出来”是两回事。比如 CBattle 存着 CFighter,可 CFighter 不是 CBattle new 的,是 CEncounter new 好了交给它的。下面这张图按“什么时候造”分成几个灰框,箭头从“调用 new 的类”指向“被造出来的类”:

程序启动,读数据文件时(只一次)也在读数据文件时每开始一局(点 Choose Mokepon)每次踩上感叹号每一场战斗(点 Fight! 时)回合中(技能打中时)把数据文件里的 Mokepon 条目变成 Mokepon 对象列表。CMokeponParserdata数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodel全部 10 个技能的目录:按名字找到对应的技能对象。CSkillCatalogueskill抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill全部道具的目录:每种道具只造一个对象,全游戏共用。CItemCatalogueitem抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld背包:记录每种道具各有几个。CInventoryitem一次遇敌:哪个标记、玩家的 Mokepon、敌人和脚下的地形。CEncounterworld正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattle一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattleEnemyStrategy 的实现:从能量够用的技能里随机挑一个。CRandomEnemyStrategybattle游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLanceskill流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingeffect技能 Stun:花 4 点能量,没有伤害,但让目标伤害减半 3 回合。CStunskill眩晕效果:被眩晕的一方造成的伤害减半,持续 3 回合。CStunnedeffect技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSandskill致盲效果:目标性技能额外有 50% 机会打空,持续 3 回合。CBlindedeffectnew ×36new ×10new ×3newnewnew ×2newnewnewnew ×2newnewnew
谁调用 new 造出谁(实线箭头 = 调用 new,线上写造几个)。每个灰框是一个时间点:箭头指向的类在这个时间点被造出来;箭头起点的类可能早就有了(比如 Overworld、GameWindow)
造出来的对象谁 new 的(哪一行)什么时候造好以后谁存着
36 个 CMokeponCMokeponParser 第 39 行程序启动,读数据文件CGameWindow 的 mokepons 列表
10 个技能对象CSkillCatalogue 第 12–13 行(一个 static 列表)第一次用到 CSkillCatalogue:读数据文件时CSkillCatalogue;每个 CMokepon 的 skills 里放的就是这一批对象
3 个道具对象CItemCatalogue 第 10–12 行(static 常量)第一次用到 CItemCatalogue:读数据文件时CItemCatalogue;CInventory 的 counts 用它们当键
CInventoryCOverworld 构造器第 38 行每开始一局(点 Choose Mokepon)CPlayer 的 inventory;开打时同一个对象交给 CBattle
CEncounterCOverworld.startEncounter 第 106 行每次踩上感叹号(遇敌框弹出来之前)COverworld 的 encounter
2 个 CFighterCEncounter.createBattle 第 54、55 行每场战斗(点 Fight!)CBattle 的 player、enemy;两个 CFighterPanel
CBattleCEncounter.createBattle 第 56 行同上CBattleScreen 的 battle
CRandomEnemyStrategy 和 RandomCBattle 第 26 行同上(CBattle 构造器里)CBattle 的 enemyStrategy、random
CBattleScreenCGameWindow.startBattle 第 67 行同上CGameWindow 的 currentScreen
2 个 CFighterPanelCBattleScreen 构造器第 78、81 行同上CBattleScreen 的 playerPanel、enemyPanel
CBleeding / CStunned / CBlindedCLance 第 20 行 / CStun 第 17 行 / CPocketSand 第 17 行回合中,技能打中时挨打一方 CFighter 的 effects

看最后一列和第三列就明白“活多久”(对象活多久)。按图上的灰框说:

CMokepon 和背包打完仗还在,因为别的对象(CGameWindow、CPlayer)还存着它们。

点 Fight! 以后:一步一步看对象怎么造出来

这一段用顺序图(sequence diagram),读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间。箭头有三种:

注意:图上每根竖线都从最上面画起,但 Fighter、battle、BattleScreen、FighterPanel 其实是在指向它们的那根 new 箭头那一刻才出生的,在那之前它们还不存在。

动画从你点 Fight! 开始,到战斗画面出现为止。顺序图下面是这一步运行的代码,再下面是两张截图:一张是开始时的遇敌框(前 9 步屏幕上一直是它,这时只是在造对象,画面还没换),另一张是最后出现的战斗画面。每一步亮的红色编号就是这一步在说的东西。

CGameWindowCBattleScreenCFighterPanelCOverworldCencounterCbattleCFighter ×2你点了 Fight!:startBattle()createBattle()1createBattle(…)2new Fighter(…) ×23new Battle(…)4this(p, e, inv, …)5new RandomEnemyStrategy()new Random()battle6battle7new BattleScreen8new FighterPanel9
点 Fight! 以后,谁 new 了谁
GameWindow.startBattleui/GameWindow.java第 65–68 行
65 @Override
66 public void startBattle() {
67 showScreen(new BattleScreen(world.createBattle(), images, this));
68 }
Overworld.createBattleworld/Overworld.java第 81–84 行
81 /* Make the battle for the current encounter. */
82 public Battle createBattle() {
83 return encounter.createBattle(player.getInventory());
84 }
Encounter.createBattleworld/Encounter.java第 52–57 行
52 /* Start a battle where the terrain changes the stats of both fighters. */
53 public Battle createBattle(Inventory inventory) {
54 Fighter player = new Fighter(playerMokepon, getPlayerEffect());
55 Fighter enemyFighter = new Fighter(enemy, getEnemyEffect());
56 return new Battle(player, enemyFighter, inventory);
57 }
Battle 的三个参数的构造器battle/Battle.java第 24–27 行
24 /* Start a battle against an enemy that picks random skills. */
25 public Battle(Fighter player, Fighter enemy, Inventory inventory) {
26 this(player, enemy, inventory, new RandomEnemyStrategy(), new Random());
27 }
Battle 的五个参数的构造器battle/Battle.java第 29–39 行
29 /* Start a battle with a chosen enemy strategy and random number generator. */
30 public Battle(Fighter player, Fighter enemy, Inventory inventory,
31 EnemyStrategy enemyStrategy, Random random) {
32 this.player = player;
33 this.enemy = enemy;
34 this.inventory = inventory;
35 this.enemyStrategy = enemyStrategy;
36 this.random = random;
37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
39 }
BattleScreen 构造器(省略了造按钮的几行)ui/BattleScreen.java第 73–83、96–97 行
73 public BattleScreen(Battle battle, GameImages images, GameNavigator navigator) {
74 super(navigator);
75 this.battle = battle;
76
77 /* Only the player's panel shows numbers on its bars. */
78 playerPanel = new FighterPanel(battle.getPlayer(),
79 new Rectangle2D(PLAYER_X, PLAYER_Y, PLAYER_WIDTH, PLAYER_HEIGHT),
80 PLAYER_CONTENT_X, true);
81 enemyPanel = new FighterPanel(battle.getEnemy(),
82 new Rectangle2D(ENEMY_X, ENEMY_Y, ENEMY_WIDTH, ENEMY_HEIGHT),
83 ENEMY_CONTENT_X, false);
⋯ 省略第 84–95 行
96 update();
97 }
再次踩上感叹号:又遇到一只(敌人可能不同)
1
  1. 1
    Fight!:调用 getNavigator().startBattle(),也就是 CGameWindow 的 startBattle()
开始:遇敌框(Impike 遇到 Galecrest)。第 1–9 步屏幕上一直是它真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
点 Fight! 进入战斗界面,战斗刚开始234
  1. 2
    playerPanel:拿着 battle.getPlayer()
  2. 3
    enemyPanel:拿着 battle.getEnemy()
  3. 4
    日志第一行:CBattle 构造器写的
结束(第 10 步):新的 BattleScreen 放进窗口。编号接着遇敌框的 1真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
  1. 你在遇敌框里点 Fight!(截图标 1),按钮调用 CGameWindow 的 startBattle()(通过 IGameNavigator 接口,第 6 章)。第 67 行要先算出括号里的 world.createBattle(),拿到 battle,才能 new CBattleScreen。
  2. 箭头 1:COverworld 的 createBattle() 自己不 new 任何东西。它把玩家的背包交给这一次遇敌:encounter.createBattle(player.getInventory())。所以战斗里用的背包,就是地图卷轴上显示的那一个。
  3. 箭头 2 就是上一步第 83 行那一句:CEncounter 的 createBattle 开始运行,参数是背包。箭头 3:它 new 两个 CFighter。第 54 行:你的 Impike 和 getPlayerEffect()(ADVANTAGE);第 55 行:敌人 Galecrest 和 getEnemyEffect()(DISADVANTAGE)。两个构造器里各自算好这一场的数值(第 4 节的动画)。
  4. 箭头 4:第 56 行 new Battle(player, enemyFighter, inventory),用的是三个参数的构造器。CEncounter 自己不留着这两个 CFighter(它们只是局部变量),全部交给 CBattle。
  5. 箭头 5:三个参数的构造器只有第 26 行:this(...) 去调用五个参数的构造器,顺手 new 一个 CRandomEnemyStrategy 和一个 Random 交过去。
  6. 五个参数的构造器把五样东西存进字段,再往日志里写第一行 “Player chose Impike. Began a fight against enemy Galecrest!”(第 10 步的截图里能看到它)。到这里,CBattle 已经完整了,屏幕上还是遇敌框。
  7. 箭头 6、7(虚线、空心箭头头 = 返回):造好的 battle 一路返回,先回到 COverworld,再回到 CGameWindow 的第 67 行。COverworld 和 CEncounter 都没有用字段存 battle。
  8. 箭头 8:new BattleScreen(battle, images, this)。第 74 行 super(navigator) 先调用父类 AGameScreen 的构造器(super,第 6 章);第 75 行把 battle 存进 final 字段 battle。这个画面从此只显示这一场仗,所以每场战斗都要 new 一个新的 CBattleScreen。
  9. 箭头 9:第 78、81 行 new 两个 CFighterPanel,交给它们 battle.getPlayer() 和 battle.getEnemy()(截图标 2、3 是它们最后画出来的样子;这时画面还没放进窗口)。第 79 行的 new Rectangle2D(...) 只是告诉面板卷轴放在哪一块(Rectangle2D);最后的 true / false 决定条上写不写数字(第 77 行注释:只有你的面板写数字)。于是每个 CFighter 同时被 CBattle 和一个面板存着。
  10. 构造器最后第 96 行 update():面板读 CFighter 的 HP 和能量,showLog 读 battle.getLog(),第 6 步写的那一行出现在日志框里(截图标 4)。构造器结束,回到 CGameWindow 第 67 行,showScreen 把这个新画面放进窗口,战斗画面出现。

8. 数据归谁管:一张总表

把这一章合起来:每一种战斗数据存在哪个字段、谁能改、谁只读。

数据存在哪谁能改谁只读
六个基础数值、名字、技能CMokepon 的字段没有人(只读)CSelectScreen、CPlayerStatsScroll、CFighter
这一场开始时的数值(HP、能量的最大值)CFighter.startingStats只有构造器放一次getMaxHp → 条、restoreHp
现在的 HP、能量、数值CFighter.stats只有 CFighter 的方法;调用它们的是技能、道具、效果CFighterPanel、CBattleScreen
身上的状态效果CFighter.effectsaddEffect(技能)、countDownEffects(CBattle)getMissChance、getDamageMultiplier
道具数量CInventory.countsCOverworld.collect 加、CBattle.useItem 减CBattleScreen、CPlayerStatsScroll
战斗日志CBattle.log只有 CBattleCBattleScreen(拿到的是只读视图)
打完了吗、谁赢了CBattle.finished、playerWon只有 CBattle.finishCBattleScreen

这样分有三个好处,考试时就说这三个“why”:

  1. 每个类只管一件事(单一职责):CMokepon = 文件里的数据;CFighter = 一方在这一场里的状态;CBattle = 一场仗的规则和日志。
  2. 检查只写一次(封装):字段都是 private,改数值必须走 CFighter 的方法,所以“HP 不低于 0”“能量不超过开打时”“数值不超过 100”只在 CFighter 和 EStat 里写一次。Project 1 的 Mokepon 有 setter,谁都能直接改。
  3. 不用还原:每场新 new CFighter,原来的 CMokepon 从来没被改过,所以 Project 1 的 copyMokepon 和 reset() 都不需要了。

下一章 第 11 章把这些对象动起来:从点一个技能按钮开始,playTurn 按什么顺序调用这一章讲的方法。

自测

Project 2 为什么要有 CFighter?直接改 CMokepon 的数不行吗?

不行。一只 Mokepon 在程序里只有一个对象,CGameWindow、CSelectScreen、CPlayer、CEncounter 都存着它,敌人也是从同一份 36 只里挑的。战斗直接改它,选择界面和下一场都会带着伤,敌人和你是同一只时还会互相影响。所以 CMokepon 只读,每场战斗为双方各 new 一个 CFighter,会变的数都存在 CFighter 里,打完就扔掉。

English: Mokepon is shared and never changes; each battle makes a new Fighter for each side to hold the values that change, so no copy or reset is needed.

Ironshell 的 HP 条写着 228 / 280。这两个数分别是哪个方法、哪个字段给的?

228 是 getHp(),读 stats 里的 HP(现在的数);280 是 getMaxHp(),读 startingStats 里的 HP(这一场开始时的数)。CFighterPanel 的 update 每次都调用这两个方法。280 − 26(Lance)− 26(流血)= 228。

Impike 在 FOREST 上打仗,Strength 为什么是 100,不是 76 × 1.5 = 114?是哪一行做的?

CFighter 构造器第 34 行:stat.limit(terrainEffect.adjust(mokepon.getStat(stat)))。adjust 算出 114,EStat 的 limit 用 Math.max(1, Math.min(114, 100)) 把它截到 STRENGTH 的上限 100。Accuracy(111)和 Dodge(105)也被截到 100。

Impike 在 FOREST 上的战斗里用 Health Tonic,HP 回到多少?为什么不是 150?

回到 225。restoreHp() 放的是 getMaxHp(),也就是 startingStats 里这一场开始时的 HP(150 × 1.5 = 225),不是 CMokepon 里的 150。

CBattle 的字段 player 和你那一边 CFighterPanel 的字段 fighter,存的是同一个 CFighter 吗?CBattle 这边扣了 HP,面板怎么知道?

是同一个对象。CBattleScreen 构造器第 78 行 new FighterPanel(battle.getPlayer(), ...) 把 CBattle 手里那个 CFighter 的引用交给面板,没有复制。技能调用 takeDamage,改的是这个对象里的 stats。回合打完,CBattleScreen 的 update 调用 playerPanel.update(),面板第 77 行重新问 fighter.getHp(),就读到了新数。没有谁去“通知”面板,它每次都重新读一遍。

English: Battle and the FighterPanel hold references to the same Fighter object, so after Battle changes it, the panel reads the new values when update() runs.

CBattle 的两个构造器是怎么连起来的?CEncounter 调用的是哪一个?

CEncounter 调用三个参数的 Battle(player, enemy, inventory)。它只有一行 this(player, enemy, inventory, new RandomEnemyStrategy(), new Random()),把默认的敌人策略和随机数补上,交给五个参数的构造器去存字段、写日志第一行。五个参数的版本让以后换一个敌人策略或固定的 Random 时,不用改 CBattle。

English: The three-argument constructor chains to the five-argument one with this(...), passing a new RandomEnemyStrategy and a new Random.

getLog() 为什么返回 Collections.unmodifiableList(log)?和 Project 1 有什么不同?

日志的写法是规则的一部分,只能由 CBattle 写(构造器写第一行,playTurn、useItem、endTurn、finish 往里加)。交出去一个只读视图,CBattleScreen 能读、能看到新加的行,但一调用 add 就报错。Project 1 的 getBattleLog() 交出真正的列表,GameWindow 就从外面往里加了第一行。

English: getLog returns an unmodifiable view, so other classes can read the log but only Battle can add to it.

从 CBattleScreen 开始,说出战斗里的 containership(谁用字段存着谁)。

CBattleScreen 存着 CBattle(battle)和两个 CFighterPanel;CBattle 存着两个 CFighter(player、enemy)、CInventory、IEnemyStrategy、Random 和日志;每个 CFighterPanel 存着一个 CFighter;CFighter 存着 CMokepon 和一个 AStatusEffect 列表;CMokepon 存着 ASkill 列表;CInventory 存着 AItem;CBleeding 存着打它的 CFighter。

English: BattleScreen holds a Battle and two FighterPanels. Battle holds two Fighters, the Inventory, an EnemyStrategy, a Random and the log. Each Fighter holds its Mokepon and a list of StatusEffects; the Mokepon holds its list of Skills.

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