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):

- 1HP: 150
- 2Energy: 22

- 1HP 225 / 225
- 2能量 25 / 33:Stun 和 Lance 各花了 4 点

- 1HP: 150
- 2Energy: 22
战斗里 HP 先因为地形变成 225,能量花掉 8 点只剩 25。可是一回到地图,卷轴上写的又是 150 和 22,好像这一仗没打过。这一章要回答的就是:战斗里会变的数存在哪里?为什么打完仗,Mokepon 自己一点没变?
战斗里会变的东西不止 HP 和能量。截图里的几局,每一样都变过:
| 会变的 | 真实例子(截图里的局) | 是什么让它变的 |
|---|---|---|
| HP | Owlet:120 → 75 → 30 → 0(被 Lance 打 45,又流血两次,每次 45) | 攻击技能、流血、道具 |
| 能量 | Shellurk:12 → 9 → 6 → 3 → 0(连用 4 次 Aim,每次 3 点) | 每个技能的能量消耗、Coffee |
| 其余四个数值 | Shellurk 的 Accuracy:62 → 65 → 66 → 73 → 78 | Aim、Swole、Bunker、Nimble |
| 状态效果 | Galecrest 被眩晕;Bubbore 先被致盲,后来又流血 | Stun、Pocket Sand、Lance |
这一章用的截图来自五局,玩家每一局选的 Mokepon 不一样,敌人是随机挑的:
- 第 1 章那一局:你选 Impike(FIRE),敌人 Galecrest,地形 FOREST(Impike × 1.5,Galecrest × 0.5)。
- 你选 Shellurk,敌人 Nightwing;你选 Nightwing,敌人 Bubbore;你选 Ironshell,敌人 Frostback(ROCKS,Frostback × 1.5)。
- 你选 Owlet,敌人碰巧挑中了 Impike。这一格对两只都不加不减(NEUTRAL),所以这一局里 Impike 的数就是 CMokepon 里原来的 150 HP、Strength 76,不是 FOREST 上的 225、100。同一只 Impike 在两局里数不一样,正好说明:变的是每场新造的 CFighter,CMokepon 一直没变。
那为什么不能直接改 CMokepon 对象?因为一只 Mokepon 在整个程序里只有一个对象,很多地方共用它:
- 程序启动时,CMokeponParser 读数据文件,new 了 36 个 CMokepon,以后再也不 new(第 5 章第 4 节)。CGameWindow 用字段
mokepons存着这个列表,选择界面显示的就是它们。 - 你点了 Impike,CSelectScreen 用字段
selectedMokepon记住它;开始一局时,CPlayer 用字段mokepon存着同一个对象;遇敌时,CEncounter 的字段playerMokepon也是它(CPlayer 和 CEncounter 在 第 8 章)。 - 敌人也是从这同一份 36 只里挑的。白色感叹号(CRandomEnemyPicker)从全部 36 只里随便挑,连你自己选的那只也可能被挑中。
如果战斗直接改 CMokepon:打完一仗,选择界面就会显示受伤后的 HP;下一仗开打时就带着伤;要是敌人碰巧是同一只 Mokepon,你打它就等于打你自己。
Project 1 的做法:每次开打先复制一份
Project 1 只有一个 Mokepon 类。它既存数据文件里的数,也存战斗里正在变的数,字段都不是 final,还有一排 setter(设置方法):
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 每次开打前先复制一份,战斗打的是副本:
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 都是这样的副本,战斗改的是副本,选择界面上的模板不动。
这样也能跑,但有几个毛病:
- 保护靠“大家都记得复制”。
Mokepon有 public 的setHp、setEnergy、setDefence……谁拿到它都能改。Mokepon自己保护不了自己。 - 为了“还原”多出三个字段:
maxHP、maxEnergy、originDefence,还写了一个reset()。可是整个 Project 1 里没有任何地方调用reset(),它是一段用不上的代码。以后再加一个会变的数值,又要再加一个“原来的值”字段。 - 第 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...,一次都没有改。代码里的红色数字和下面截图里的红色数字一一对应:
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 循环走六圈,六个数就都填好了。

- 1名字 Impike:
mokepon.getName() - 2火焰图标和 Type: FIRE:都来自
mokepon.getType() - 3六个数值:
mokepon.getStat(stat),循环六次,一次要一个数 - 4技能:
formatSkills里调用mokepon.getSkills()
再看 CMokepon 类本身。整个类只有 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}它“改不了”靠的是这五处(数字是左边的红色标记):
- 第 12–16 行:五个字段都是
private final。private:只有 CMokepon 自己的代码能直接碰这些字段。final:构造器给它赋值以后,这个字段就不能再指向别的东西。再看下面:除了构造器,所有方法都是get...,一个 setter 都没有。 - 第 24 行:
this.stats = new EnumMap<>(stats);。不直接存别人交进来的 Map,而是新造一个,把内容抄过来。为什么?CMokeponParser 读文件时先自己造了一个 Map 放六个数,再交给构造器。如果 CMokepon 存的就是那个 Map,手里还拿着它的人以后往里放新数,CMokepon 就被悄悄改了。抄一份,外面那个 Map 怎么变都跟它无关。这叫防御性复制(defensive copy)。 - 第 25 行:
this.skills = List.copyOf(skills);。也是抄一份,而且List.copyOf抄出来的列表是不能改的:谁对它调用add或remove,程序会报错(UnsupportedOperationException)。 - 第 41 行:
getStat交出去的是一个int,就是一个数,不是 Map 本身。Map 从来不交给别人,所以别人没机会往里放东西。 - 第 45 行:
getSkills直接交出skills。这是安全的,因为它是第 3 处那个不能改的列表。列表里的技能对象也不会变(ASkill 的字段也都是private final,CSkillCatalogue 的注释说 “Skills never change after they are made, so all Mokepon can share the same skill objects”)。

这种老师打印稿里的 UML 图怎么读(红色方块、绿色圆点、斜体、下划线),在 第 3 章。
3. Stat:六个数值和它们的合法范围
不管是 CMokepon 还是 CFighter,存数值用的 Map 的键都是 EStat。它是一个枚举(enum):一个只有固定几个值的类型。EStat 正好六个值,每个值还带着三样东西:
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}- 标 1,第 5–10 行:六个枚举值。
HP("HP", 1, 1000)的意思是:造HP这个值时,把"HP"、1、1000交给第 16 行的构造器。 - 标 2,第 16–20 行:枚举的构造器,把三样东西存进
label、minimum、maximum。枚举的构造器不能自己 new,Java 在第一次用到 EStat 时自动把六个值造好。 - 标 3,第 28 行:
limit把一个数挪进合法范围(下面细讲)。
| 枚举值 | label | minimum | maximum |
|---|---|---|---|
HP | HP | 1 | 1000 |
ENERGY | Energy | 1 | 100 |
STRENGTH | Strength | 1 | 100 |
ACCURACY | Accuracy | 1 | 100 |
DEFENCE | Defence | 1 | 100 |
DODGE | Dodge | 1 | 100 |
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)) 要从里往外读:
- 先算
Math.min(value, maximum):两个数里取小的。比上限大的数,变成上限。 - 再算
Math.max(minimum, 上一步的结果):两个数里取大的。比下限小的数,变成下限。
这种写法叫限制范围(clamp)。几个例子:
| 交进来的 value | 先取小:Math.min | 再取大:Math.max | 结果 |
|---|---|---|---|
| 114(Impike 的 Strength 76 × 1.5) | min(114, 100) = 100 | max(1, 100) = 100 | 100 |
| 68(Impike 的 Defence 45 × 1.5 = 67.5,四舍五入) | min(68, 100) = 68 | max(1, 68) = 68 | 68(不变) |
| 345(Frostback 的 HP 230 × 1.5) | min(345, 1000) = 345 | max(1, 345) = 345 | 345(HP 上限是 1000) |
| 0(假设的) | min(0, 100) = 0 | max(1, 0) = 1 | 1 |
整个项目里只有两处调用 limit,都在 CFighter 里:构造器算这一场的起始数值(第 4 节),和 raiseStat 给数值加点(第 5 节)。HP 和能量往下掉时不用它:takeDamage 和 spendEnergy 自己用 0 当下限。所以 EStat 说 HP 最少是 1,战斗里 HP 却可以是 0(倒下了)。这两件事不矛盾:1 是“开打时和加点时”的下限,0 是“被打”的下限。
真实例子:两个数值被截到 100

- 1遇到 Frostback(ICE 类型)
- 2Your enemy will be at an advantage here!:这一格是 ROCKS,ICE 在 ROCKS 上占优势,所有数值 × 1.5
Frostback 这一场的数值
| Mokepon 里 | × 1.5 | limit 后 | |
|---|---|---|---|
| HP | 230 | 345 | 345 |
| Energy | 14 | 21 | 21 |
| Strength | 72 | 108 | 100 |
| Accuracy | 58 | 87 | 87 |
| Defence | 88 | 132 | 100 |
| Dodge | 30 | 45 | 45 |
敌人的条上没有数字,这些是运行时记录下来的真实值。Ironshell 是 NORMAL 类型,在哪儿都不加不减。
4. Fighter:为这一场战斗造的 Mokepon
五个字段
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 is16 * 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<>();DAMAGE_SCALE = 50:伤害公式里的一个固定倍数(第 5 节)。static 表示它属于整个类,所有 CFighter 共用一个;final表示它不能改。这种写法就是常量(constant),名字按习惯全大写。在 UML 图上,static 的成员带下划线。mokepon:它是从哪只 CMokepon 造出来的。名字、图片、技能都问它要。startingStats:这一场开始时的六个数(地形已经算进去了)。注释写着 “after the terrain effect”。它只在构造器里放数,以后不再变。HP 和能量拿它当这一场的最大值(getMaxHp、getMaxEnergy)。另外四个数值不是这样:Aim、Swole、Bunker、Nimble 可以把它们加到比开打时还大(Shellurk 的 Accuracy 开打时 62,后来到了 78),它们的上限是 EStat 里写的 100。stats:现在的六个数。注释写着 “They only change through the methods below”:只有 CFighter 自己的方法能改它。effects:身上现在有的状态效果(眩晕、致盲、流血),一个ArrayList,可以加、可以删。
注意第 23、25、26 行字段后面直接写了 = new EnumMap<>(Stat.class) 和 = new ArrayList<>():这是在声明字段的同时就造好对象。每 new 一个 CFighter,这几句都在构造器正文之前先跑一遍,所以每个 CFighter 都有自己的两个空 Map 和一个空列表。EnumMap 要知道键是哪个枚举,所以要交给它 Stat.class:类名后面加 .class,代表的是 EStat 这个类型本身(不是某一个枚举值),意思是“键都是 Stat”。

构造器:开打那一刻,算出这一场的六个数
构造器要用 ETerrainEffect。它也是枚举,三个值各带一个倍数:
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 一圈一圈装满);另一边是截图。手机上它们是上下排的。
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 }
- 1标 1–6 从上到下是六个数,亮的那一行是这一步在算的

- 1getHp() / getMaxHp():225 / 225
- 2getEnergy() / getMaxEnergy():33 / 33
- 开打时,CEncounter 的
createBattle调用new Fighter(playerMokepon, getPlayerEffect()),构造器从第 31 行开始运行。交进来两样东西:参数mokepon是选择界面上那只 Impike(截图里六个数都是它的);参数terrainEffect是ADVANTAGE,因为 FIRE 类型在 FOREST 上占优势,倍数 1.5。 - 构造器正文运行之前,第 23、25、26 行字段后面的
new先执行(new 一个对象时的执行顺序):startingStats和stats是两个空 Map,effects是空列表(下图亮的三格)。DAMAGE_SCALE是 static 的,属于整个类,不在这里造。 - 第 32 行
this.mokepon = mokepon;:把交进来的 Impike 记在字段mokepon里。存的是同一个对象的引用,没有复制。this.mokepon是字段,等号右边的mokepon是参数,名字一样,用this.区分。 - 第 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里。 - 第 35、36 行把 225 分别放进两个 Map:
startingStats里 HP = 225,stats里 HP = 225(下图亮的两格)。现在两份一样。以后战斗里只有stats会变,startingStats再也不放新数,所以它一直记着“这一场开始时是多少”。 - 第二圈
stat是ENERGY:22 × 1.5 = 33,在 1–100 里,两个 Map 里都放 33。 - 第三圈
STRENGTH:76 × 1.5 = 114。STRENGTH.limit(114)=Math.max(1, Math.min(114, 100))= 100。多出来的 14 点被截掉,放进去的是 100。 - 第四圈
ACCURACY:74 × 1.5 = 111,也超过 100,截成 100。 - 第五圈
DEFENCE:45 × 1.5 = 67.5,Math.round(67.5)得 68(.5 往上入)。68 在范围里,放 68。 - 第六圈
DODGE:70 × 1.5 = 105,截成 100。六圈走完,第 37 行的}结束循环。三个数(Strength、Accuracy、Dodge)都被 limit 截到了 100。 - 第 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 要来的。下面放大的是你的卷轴(左下那一块);右上敌人的卷轴是同一个类画的,只是条上不写数字。红色数字一一对应:
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 }
- 1名字 Impike:
getName(),转去问mokepon.getName() - 2头像:
getImagePath(),也是转去问 mokepon - 3技能按钮:CBattleScreen 用
battle.getPlayer().getSkills()造的,也是转去问 mokepon - 4HP 条和能量条:
getHp()/getMaxHp()、getEnergy()/getMaxEnergy(),读的是 Fighter 自己的两个 Map
分成两种:
- 标 1、2、3:战斗里不会变的东西(名字、图片、技能)。CFighter 自己不存,一行代码转去问
mokepon。这种“自己不做、交给别的对象做”的写法叫委托(delegation)。 - 标 4:会变的东西。第 52–54 行的
getStat(stat)从stats这个 Map 里取出这个数值现在是多少。getHp()就是getStat(Stat.HP),getEnergy()就是getStat(Stat.ENERGY):两步,先调用getStat,getStat再去读stats。getMaxHp()和getMaxEnergy()不走getStat,直接读startingStats。 getStat是 public 的,别的类也用它:技能算命中率时,ATargetedSkill 调用user.getStat(Stat.ACCURACY)和target.getStat(Stat.DODGE);第 5 节的calculateDamage用它读 Strength 和 Defence。它只读不改。
每次点完按钮,CFighterPanel 的 update 都重新问一遍:
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 打中、又流了一次血以后:

- 1228 / 280:228 是
getHp()(stats),280 是getMaxHp()(startingStats)。绿条长度 = 228 ÷ 280 ≈ 81% - 21 / 10:能量也是“现在 / 开始时”
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() | 不改,转去问 mokepon | CFighterPanel、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 的 ENERGY | ASkill.use |
takeDamage(n) | stats 的 HP | AAttackSkill、CBleeding |
raiseStat(stat, n) | stats 里的一个数 | ABoostSkill |
restoreHp() / restoreEnergy() | stats 的 HP / ENERGY | CHealthTonic / CCoffee |
reduceHpToOne() | stats 的 HP | CPoison |
calculateDamage(target, power) | 不改,只算 | AAttackSkill、CBleeding |
addEffect(effect) | effects 加一个 | CLance、CStun、CPocketSand |
getMissChance() | 不改 | ATargetedSkill |
applyLingeringDamage() | 让效果去改 HP | CBattle.endTurn |
countDownEffects() | effects 删掉结束的 | CBattle.endTurn |
注意画面类(CBattleScreen、CFighterPanel)只调用“查”的方法(getHp、canUse……)。所有“改”的方法都由规则类调用:技能、道具、状态效果、CBattle。而且不管谁来改,都要经过 CFighter 的方法,所以“HP 不能低于 0”“能量不能超过开打时”这些规则只写一次,写在 CFighter 里。这就是封装(encapsulation)。把上面的表画成一张图:
能量:canUse 查,spendEnergy 扣
Shellurk 那一局连用了 4 次 Aim(每次 3 点能量)。下面是真实截图连播,看能量条和技能按钮:
![Shellurk 的战斗开始:技能 Tackle [5]、Aim [3]、Wait [-2],能量 12](shots/battle-start-b.jpg)



做这两件事的是 CFighter 的两个方法:
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 }- 标 1,第 78 行
canUse:能量 ≥ 技能的消耗,就能用。CBattleScreen 每次更新都对每个技能问一遍,用不起的按钮就变灰。 - 标 2,第 90 行:先算
getEnergy() - amount,再和getMaxEnergy()(开打时的能量)比,取小的。这一步管上限:Wait 的消耗是 −2,减一个负数等于加 2,但最多加到开打时的数。 - 标 3,第 91 行:再和 0 比,取大的。这一步管下限:能量不会是负数。
用截图里的真实数字走三遍:
| 哪一下 | 第 90 行:先取小(Math.min) | 第 91 行:再取大(Math.max) | 结果 |
|---|---|---|---|
| Shellurk 第 4 次 Aim(消耗 3) | min(3 − 3, 12) = min(0, 12) = 0 | max(0, 0) = 0 | 0 / 12 |
| Impike 满能量时用 Wait(消耗 −2) | min(33 − (−2), 33) = min(35, 33) = 33 | max(0, 33) = 33 | 还是 33 / 33 |
| Owlet 最后一次 Wait(能量已经是满的 28) | min(28 − (−2), 28) = min(30, 28) = 28 | max(0, 28) = 28 | 28 / 28(Game Over 截图里就是这个数) |
前后两张放在一起,红框编号一样:

- 1能量 3 / 12
- 2Tackle [5]:
canUse算 3 ≥ 5,false,变灰 - 3Aim [3]:3 ≥ 3,true,能点
- 4Wait [-2]:3 ≥ −2,true,能点

- 1能量 0 / 12
- 3Aim [3]:0 ≥ 3,false,也灰了
- 4Wait [-2]:0 ≥ −2,true。能量 0 也永远能用 Wait
既然用不起的技能按钮是灰的,为什么 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。
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 }
- 10 / 120:30 − 45 = −15,
Math.max(0, −15)= 0 - 2整个卷轴变红(右边被 Game Over 框挡住的部分没截进来):
isAlive()是 false。CFighterPanel.update 第 79 行scroll.setFainted(!fighter.isAlive())把红色那一层打开
第 83 行在左边代码框里,右端可能被截掉一点,完整是:stats.put(Stat.HP, Math.max(0, getHp() - amount));。和 spendEnergy 一样,用 Math.max(0, ...) 管下限。Owlet 的 HP 是这样掉的(每次都是 0.5 × 76 × 50 ÷ 42 = 45.24 → 45):
- Impike 的 Lance 打中:
takeDamage(45),120 − 45 = 75。 - 这一回合结束,流血:又
takeDamage(45),75 − 45 = 30。 - 下一回合结束,第二次流血:30 − 45 = −15,
Math.max(0, −15)= 0。
标 2,第 73 行的 isAlive() 就是“HP 大于 0 吗”。HP 是 0 了,isAlive() 返回 false:CBattle 判你输;CFighterPanel 第 79 行的 ! 是“取反”(逻辑运算符),!false 就是 true,所以 setFainted(true),卷轴变红。日志里能看到三处:

- 1Hit Owlet for 45 damage! They started bleeding!:第 1 步
- 2The bleeding stopped.:第 3 步那次流血以后才写。两次流血的伤害本身都不写进日志
- 3HP 到 0,isAlive() 是 false:Player's Owlet fainted! / You lose
raiseStat:返回“真的加了几点”
20 /* The stat cannot go above its limit, so the log shows the points actually gained. */21 @Override22 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 " + gained26 + " points!";27 }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 }
- 3They raised their accuracy by 3 points!:日志里写的是
raiseStat返回的数(标 3)。标 1 随机出来的数本身不写进日志
跟着 Shellurk 的第一次 Aim 走一遍:
- 标 1:ABoostSkill 随机出加几点,
boost= 3。 - 标 2:CFighter 的
raiseStat(ACCURACY, 3):oldValue= 62,newValue=ACCURACY.limit(62 + 3)= 65,放进stats。 - 标 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
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 }
- 1Health Tonic:
restoreHp(),228 → 280 =getMaxHp()

- 2Coffee:
restoreEnergy(),1 → 10 =getMaxEnergy()

- 3Poison:
reduceHpToOne(),敌人 Galecrest 93 → 1,绿条只剩一丝
这三个方法很短,没有任何检查,因为道具“总是有效”(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:伤害公式
114 /* Work out the damage this fighter does to the target with the Project 1 formula.115 * The power multiplies the user's strength: Tackle has power 2, while Lance and116 * bleeding use half strength (power 0.5). The damage is truncated to an integer.117 */118 public int calculateDamage(Fighter target, double power) {1191 double damage = power * getStat(Stat.STRENGTH) * DAMAGE_SCALE1202 / 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 }
- 3Hit Bubbore for 22 damage!:Nightwing 的 Lance,0.5 × 68 × 50 ÷ 74 = 22.97,
(int)去掉小数得 22,不是四舍五入的 23
公式是 Project 1 那一个:伤害 = power × 攻方 Strength × 50 ÷ 守方 Defence。this 是出手的一方,target 是挨打的一方:
- 标 1:
power是技能给的倍数:Bite 1,Tackle 2,Lance 0.5,流血也是 0.5。乘上自己现在的 Strength 和常量DAMAGE_SCALE(50)。 - 标 2:除以对方现在的 Defence。算出来是
double(带小数)。 - 标 3:乘上
getDamageMultiplier(),再用(int)把小数直接去掉(不是四舍五入)。注意乘法在(int)括号里面:先乘,再去小数。 - 标 4:
getDamageMultiplier()是 private 的,只有 CFighter 自己用。它从 1 开始,挨个看身上的效果,取最小的那个倍数。没有效果就是 1;被眩晕(CStunned)是 0.5。被眩晕两次也还是 0.5,不会变成 0.25:只算最强的那一个。
截图里的真实伤害,全部可以用这个公式算出来(Strength 和 Defence 都是那一刻 stats 里的数):
| 谁打谁 | power × Strength × 50 ÷ Defence | × 倍数 | (int) |
|---|---|---|---|
| Impike 的 Lance 打 Galecrest | 0.5 × 100 × 50 ÷ 30 = 83.33 | × 1 | 83 |
| Frostback 的 Lance 打 Ironshell | 0.5 × 100 × 50 ÷ 96 = 26.04 | × 1 | 26 |
| Impike 的 Lance 打 Owlet | 0.5 × 76 × 50 ÷ 42 = 45.24 | × 1 | 45 |
| Nightwing 的 Lance 打 Bubbore | 0.5 × 68 × 50 ÷ 74 = 22.97 | × 1 | 22 |
| 假设:被眩晕的 Galecrest 的 Tackle 打中了 Impike | 2 × 37 × 50 ÷ 68 = 54.41 | × 0.5 = 27.21 | 27 |
最后一行是假设:第 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)。
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 }- 标 1,
addEffect(第 125 行):把效果对象加到effects的最后面。它不查身上是不是已经有同一种。谁调用:CLance、CStun、CPocketSand,在打中的时候。 - 标 2,
getMissChance(第 134 行):不改。把列表走一遍,取最大的落空率:没有效果是 0,被致盲(CBlinded)是 0.5。谁调用:ATargetedSkill,在判断打没打中的时候。 - 标 3,
applyLingeringDamage(第 148 行):自己不碰 HP,而是让每个效果“发作”一次。CBleeding 在这里扣血,另外两种什么都不做。效果交回来的句子收进messages,再交给 CBattle 写进日志。谁调用:CBattle.endTurn,在回合结束时。 - 标 4、5,
countDownEffects(第 158、160 行):每个效果的turnsLeft减 1;减到 0 的,从effects里删掉。谁调用:也是 CBattle.endTurn,紧接着applyLingeringDamage。
这几行里有三样新语法,这里各说一句,细讲在 第 13 章第 4 节:
- 第 145–147 行
if (!isAlive()) { break; }:!isAlive()读作“已经不是活着的了”;break立刻跳出整个 for 循环(break)。人已经倒下,后面的效果就不再发作。 - 第 155–157 行:
effects.iterator()拿到一个迭代器(Iterator),它从列表开头一个一个往后走。while (iterator.hasNext())是“后面还有就接着转”(while 循环),iterator.next()拿出下一个效果。 - 第 160 行
iterator.remove():删掉刚才next()拿出来的那一个。一边走列表一边删,要用迭代器自己的remove();写成 for-each 再调用effects.remove(...),Java 通常会报错(ConcurrentModificationException)。
Bleeding 为什么存着一个 Fighter
四个方法里只有标 3 会让 HP 变,真正扣血的是 CBleeding。它有一个字段 attacker,存的是出 Lance 的那一方的 CFighter,而不是一个算好的伤害数:
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 bleeding21 * damage does the log say that the bleeding stopped, even if this damage ends the battle.22 */23 @Override24 public List<String> applyLingeringDamage(Fighter owner) {252 owner.takeDamage(attacker.calculateDamage(owner, HALF_STRENGTH));26 if (isLastTurn()) {273 return List.of("The bleeding stopped.");28 }29 return List.of();30 }
- 1
- 3第二次流血以后,第 27 行交回的句子:The bleeding stopped.
- 4中间那一回合 Bubbore 用了 Bunker,Defence 74 → 82(这一处只在截图里,代码没有标 4)
两次流血都在第 25 行(标 2)当场算伤害,用的是那一刻双方 stats 里的数:
| 哪一次流血 | 0.5 × Nightwing 的 Strength × 50 ÷ Bubbore 的 Defence | Bubbore 的 HP |
|---|---|---|
| 第 1 次:Lance 那一回合结束 | 0.5 × 68 × 50 ÷ 74 = 22.97 → 22 | 173 → 151 |
| 第 2 次:下一回合结束(这一回合里 Bubbore 用 Bunker 把 Defence 从 74 加到 82) | 0.5 × 68 × 50 ÷ 82 = 20.73 → 20 | 151 → 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)。
这张图里的 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.”(它不知道屏幕的存在,只更新双方、道具和日志。)先看它存着什么:
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、enemy | CFighter | 你和敌人的 CFighter | final:一直是这两个对象。对象里面的数在变 |
inventory | CInventory | 背包。和 CPlayer 的背包是同一个对象,所以战斗里用掉 Poison,回地图卷轴上也是 0 | final;里面的数量会变 |
enemyStrategy | IEnemyStrategy(接口) | 替敌人挑技能的对象,现在装的是 CRandomEnemyStrategy | final |
random | Random | 随机数:敌人挑哪个技能(playTurn 第 87 行把它交给 enemyStrategy.chooseSkill)、打没打中、加几点、Wait 说哪句话 | final |
log | List<String> | 战斗日志,一行一个字符串 | final;里面一直在加行 |
finished、playerWon | boolean | 打完了吗?你赢了吗? | 不是 final:finish 里改成 true |

两个构造器,用 this(...) 连起来
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 }
- 2日志第一行:第 37–38 行写的(和上面代码的标 2 是同一处)。Battle 一造好,这一行就在了
- 标 1,第 26 行:三个参数的构造器只有一行:
this(player, enemy, inventory, new RandomEnemyStrategy(), new Random());。这里的this(...)不是字段,而是“去调用本类的另一个构造器”,这叫构造器链(constructor chaining)。它把自己收到的三样,加上两样默认的(随机挑技能的敌人、一个新的随机数生成器),交给五个参数的那个。this(...)写在构造器的第一行,这份代码就是这样写的。老版本 Java 规定它必须是第一句;这个项目用 Java 25 编译(build.gradle.kts里languageVersion是 25),Java 25 起前面可以先放几句不碰this的语句(灵活的构造器正文),但这里用不上。 - 第 32–38 行:五个参数的构造器才真正干活:把五样东西存进字段。
- 标 2,第 37–38 行:写日志第一行
Player chose Impike. Began a fight against enemy Galecrest!。
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()
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 只是读它,把每一行用换行连起来,放进日志框:
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 就真的往里加了一行。
isFinished 和 hasPlayerWon
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 }
- 2You Win:
hasPlayerWon()是 true(标 2 那一行放进去的是 true) - 3赢了只有一个按钮 Back to it!

- 2Game Over:
hasPlayerWon()是 false - 3输了是两个按钮:Return to Main Menu、Quit
finished 和 playerWon 只在 private 的 finish 里改(标 1、2)。别的类只能用 isFinished() 和 hasPlayerWon() 读:
- CBattle 自己:
playTurn和useItem第一件事就是查finished,打完了就什么都不做。 - CBattleScreen:打完了,技能按钮和道具按钮全变灰;
canRunAway()返回!battle.isFinished(),菜单里的 Run away 变灰;再看hasPlayerWon(),弹 You Win 还是 Game Over(上面两张图,第 14 章)。
7. 谁存着谁、谁 new 了谁
考试第一类题常问:“哪些类存着这些数据?它们之间是什么关系?”下面把这一章的类画出来,分成两张图。先看包含关系(containership):A 有一个字段,类型是 B,就画一根从 A 到 B 的线,菱形在 A 那头,线上写字段名。
从上往下读这两张图:
- CBattleScreen 存着一个 CBattle(字段
battle)和两个 CFighterPanel(playerPanel、enemyPanel)。 - CBattle 存着两个 CFighter(
player、enemy),一个 CInventory,一个 IEnemyStrategy(还有random和log,类型是 Java 自带的Random和List<String>,图上不画)。 - 每个 CFighterPanel 存着一个 CFighter。所以每个 CFighter 至少被两个对象存着:CBattle 和一个 CFighterPanel(被 Lance 打中的一方身上的 CBleeding 还存着出手的那个 CFighter,见下面最后一条)。它们指着的是同一个 CFighter 对象,所以 CBattle 改了 HP,面板下次
update就能看到。 - 每个 CFighter 存着它的 CMokepon(
mokepon)和一个效果列表(effects);CMokepon 存着一个技能列表(skills);CInventory 用一个 Map 的键存着道具(counts,值是数量)。 - CBleeding 存着打它的那个 CFighter(
attacker)。
还要注意线的方向:除了 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 的类”指向“被造出来的类”:
| 造出来的对象 | 谁 new 的(哪一行) | 什么时候 | 造好以后谁存着 |
|---|---|---|---|
| 36 个 CMokepon | CMokeponParser 第 39 行 | 程序启动,读数据文件 | CGameWindow 的 mokepons 列表 |
| 10 个技能对象 | CSkillCatalogue 第 12–13 行(一个 static 列表) | 第一次用到 CSkillCatalogue:读数据文件时 | CSkillCatalogue;每个 CMokepon 的 skills 里放的就是这一批对象 |
| 3 个道具对象 | CItemCatalogue 第 10–12 行(static 常量) | 第一次用到 CItemCatalogue:读数据文件时 | CItemCatalogue;CInventory 的 counts 用它们当键 |
| CInventory | COverworld 构造器第 38 行 | 每开始一局(点 Choose Mokepon) | CPlayer 的 inventory;开打时同一个对象交给 CBattle |
| CEncounter | COverworld.startEncounter 第 106 行 | 每次踩上感叹号(遇敌框弹出来之前) | COverworld 的 encounter |
| 2 个 CFighter | CEncounter.createBattle 第 54、55 行 | 每场战斗(点 Fight!) | CBattle 的 player、enemy;两个 CFighterPanel |
| CBattle | CEncounter.createBattle 第 56 行 | 同上 | CBattleScreen 的 battle |
CRandomEnemyStrategy 和 Random | CBattle 第 26 行 | 同上(CBattle 构造器里) | CBattle 的 enemyStrategy、random |
| CBattleScreen | CGameWindow.startBattle 第 67 行 | 同上 | CGameWindow 的 currentScreen |
| 2 个 CFighterPanel | CBattleScreen 构造器第 78、81 行 | 同上 | CBattleScreen 的 playerPanel、enemyPanel |
| CBleeding / CStunned / CBlinded | CLance 第 20 行 / CStun 第 17 行 / CPocketSand 第 17 行 | 回合中,技能打中时 | 挨打一方 CFighter 的 effects |
看最后一列和第三列就明白“活多久”(对象活多久)。按图上的灰框说:
- “程序启动”和“也在读数据文件时”两框里造出来的 CMokepon、技能、道具,一直活到关掉程序。
- “每开始一局”造的 CInventory 活一局(COverworld 和 CPlayer 也是每局新造的,第 8 章)。
- “每次踩上感叹号”造的 CEncounter 由 COverworld 的字段
encounter存着,活到你逃跑或打赢(COverworld 第 88、98 行把encounter设成null),或者这一局结束。 - “每一场战斗”框里被造出来的 CFighter、CBattle、CRandomEnemyStrategy、CBattleScreen、CFighterPanel 只活一场。打完仗 CGameWindow 换回地图画面,再没有字段指着这个 CBattleScreen,它和它存着的 CBattle、两个 CFighter 就都没人要了,Java 会自动回收(垃圾回收)。
- 状态效果最多活到它的回合数用完(被
iterator.remove()拿掉),或者这一场打完。
CMokepon 和背包打完仗还在,因为别的对象(CGameWindow、CPlayer)还存着它们。
点 Fight! 以后:一步一步看对象怎么造出来
这一段用顺序图(sequence diagram),读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间。箭头有三种:
- 实线、实心箭头头 = 调用一个方法(箭头 1、2)。绕回自己的那根(箭头 5)= 调用自己的另一个构造器或方法。
- 虚线、实心箭头头 =
new一个对象,指向被造出来的那个对象(箭头 3、4、8、9)。这是 UML 的习惯画法(创建消息),不是返回。 - 虚线、空心箭头头(只有两道线) = 返回(箭头 6、7)。
注意:图上每根竖线都从最上面画起,但 Fighter、battle、BattleScreen、FighterPanel 其实是在指向它们的那根 new 箭头那一刻才出生的,在那之前它们还不存在。
动画从你点 Fight! 开始,到战斗画面出现为止。顺序图下面是这一步运行的代码,再下面是两张截图:一张是开始时的遇敌框(前 9 步屏幕上一直是它,这时只是在造对象,画面还没换),另一张是最后出现的战斗画面。每一步亮的红色编号就是这一步在说的东西。
65 @Override66 public void startBattle() {67 showScreen(new BattleScreen(world.createBattle(), images, this));68 }81 /* Make the battle for the current encounter. */82 public Battle createBattle() {83 return encounter.createBattle(player.getInventory());84 }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 }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 }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 }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
234- 2playerPanel:拿着
battle.getPlayer() - 3enemyPanel:拿着
battle.getEnemy() - 4日志第一行:CBattle 构造器写的
- 你在遇敌框里点 Fight!(截图标 1),按钮调用 CGameWindow 的
startBattle()(通过 IGameNavigator 接口,第 6 章)。第 67 行要先算出括号里的world.createBattle(),拿到 battle,才能 new CBattleScreen。 - 箭头 1:COverworld 的
createBattle()自己不 new 任何东西。它把玩家的背包交给这一次遇敌:encounter.createBattle(player.getInventory())。所以战斗里用的背包,就是地图卷轴上显示的那一个。 - 箭头 2 就是上一步第 83 行那一句:CEncounter 的
createBattle开始运行,参数是背包。箭头 3:它 new 两个 CFighter。第 54 行:你的 Impike 和getPlayerEffect()(ADVANTAGE);第 55 行:敌人 Galecrest 和getEnemyEffect()(DISADVANTAGE)。两个构造器里各自算好这一场的数值(第 4 节的动画)。 - 箭头 4:第 56 行
new Battle(player, enemyFighter, inventory),用的是三个参数的构造器。CEncounter 自己不留着这两个 CFighter(它们只是局部变量),全部交给 CBattle。 - 箭头 5:三个参数的构造器只有第 26 行:
this(...)去调用五个参数的构造器,顺手 new 一个 CRandomEnemyStrategy 和一个Random交过去。 - 五个参数的构造器把五样东西存进字段,再往日志里写第一行 “Player chose Impike. Began a fight against enemy Galecrest!”(第 10 步的截图里能看到它)。到这里,CBattle 已经完整了,屏幕上还是遇敌框。
- 箭头 6、7(虚线、空心箭头头 = 返回):造好的 battle 一路返回,先回到 COverworld,再回到 CGameWindow 的第 67 行。COverworld 和 CEncounter 都没有用字段存 battle。
- 箭头 8:
new BattleScreen(battle, images, this)。第 74 行super(navigator)先调用父类 AGameScreen 的构造器(super,第 6 章);第 75 行把 battle 存进 final 字段battle。这个画面从此只显示这一场仗,所以每场战斗都要 new 一个新的 CBattleScreen。 - 箭头 9:第 78、81 行 new 两个 CFighterPanel,交给它们
battle.getPlayer()和battle.getEnemy()(截图标 2、3 是它们最后画出来的样子;这时画面还没放进窗口)。第 79 行的new Rectangle2D(...)只是告诉面板卷轴放在哪一块(Rectangle2D);最后的true/false决定条上写不写数字(第 77 行注释:只有你的面板写数字)。于是每个 CFighter 同时被 CBattle 和一个面板存着。 - 构造器最后第 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.effects | addEffect(技能)、countDownEffects(CBattle) | getMissChance、getDamageMultiplier |
| 道具数量 | CInventory.counts | COverworld.collect 加、CBattle.useItem 减 | CBattleScreen、CPlayerStatsScroll |
| 战斗日志 | CBattle.log | 只有 CBattle | CBattleScreen(拿到的是只读视图) |
| 打完了吗、谁赢了 | CBattle.finished、playerWon | 只有 CBattle.finish | CBattleScreen |
这样分有三个好处,考试时就说这三个“why”:
- 每个类只管一件事(单一职责):CMokepon = 文件里的数据;CFighter = 一方在这一场里的状态;CBattle = 一场仗的规则和日志。
- 检查只写一次(封装):字段都是 private,改数值必须走 CFighter 的方法,所以“HP 不低于 0”“能量不超过开打时”“数值不超过 100”只在 CFighter 和 EStat 里写一次。Project 1 的
Mokepon有 setter,谁都能直接改。 - 不用还原:每场新 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?是哪一行做的?
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.