11一个回合的完整流程
从点技能按钮到日志更新:playTurn 一步一步怎么走(考试 Q1 最常问)
- 从“点一下 Stun [4]”开始,按顺序说出一个回合经过的每个方法:按钮里的 lambda → CBattleScreen.useSkill → CBattle.playTurn → ASkill.use → … → CBattleScreen.update
- 指着战斗日志里的任何一行,说出它是
Battle.java第几行写进去的 - 说清楚为什么同一个方法
use(user, target, random)既给你用、也给敌人用 - 说出
endTurn的顺序(先算敌人)和finish写下的那几行 - 解释技能按钮为什么会变灰、用道具为什么不给敌人出手的机会
- 知道样题 Q1(一个回合怎样同时处理玩家和敌人的数据)的参考答案里,哪几句英文讲的是这一章
这一章只讲一件事:你在战斗画面点一个技能按钮,到屏幕上的日志、能量条全部更新,中间到底跑了哪些代码。考试的第 1 类题最常问这个。我们用一场真实的战斗(下面叫“A 局”):你的 Impike 对敌人 Galecrest。你先点了 Wait,再点 Stun,然后用道具 Poison,最后用 Lance 打赢。每一张图都是程序真实运行时截下来的。
战斗画面上每一块是谁画的,第 1 章第 5 节已经认过了;CBattle、CFighter 存着哪些数据、谁创建谁,在 第 10 章。这一章用到的几个词先提醒一下:方法(method)是类里有名字的一段代码,对象.方法名(...) 叫调用(call)它;字段(field)是对象自己存东西的变量;返回值是方法做完以后交回给调用者的东西。Java 语法细节在 第 2 章。
1. 先看结果:点一下 Stun,屏幕上变了什么
先不看代码,只看屏幕。下面是 A 局第 2 回合点完 Stun [4] 以后的样子。红框是这一下点击改变(或者没改变)的地方。
123456- 1日志多了 5 行:1 个空行,你的 2 行,敌人的 2 行
- 2你的能量:33 → 29
- 3敌人的能量条短了一截:14 → 9
- 4你点的按钮:Stun [4]
- 5你的 HP 没变:225 / 225(敌人的 Tackle 没打中)
- 6敌人的 HP 条还是满的:Stun 不打血
把点之前(上一回合结束时)和点之后的数字放在一起:
| 哪一块 | 点 Stun 前 → 后 | 谁改的(在本章第几节) |
|---|---|---|
| 日志 | 6 行 → 11 行 | playTurn 的 log.add(第 3 节) |
| 你的能量 | 33 → 29 | use 里的 spendEnergy(4)(第 3、4 节) |
| 敌人的能量 | 14 → 9 | 敌人的 Tackle:spendEnergy(5)(第 3 节) |
| 两边的 HP | 225、93,没变 | Stun 不打血,Tackle 没打中 |
| 看不见的 | Galecrest 多了一个 Stunned,剩 2 回合 | addEffect,回合末倒数(第 5 节) |
敌人的条上没有数字(第 1 章说过原因),表里敌人的数字是运行时记录下来的真实值。
“点之前”的能量为什么是满的 33 / 33?第 1 回合两边都用了 Wait。Wait 的能量消耗是 −2,花 −2 就是回 2 点,可两边的能量本来就是满的:CFighter.spendEnergy 第 90 行 Math.min(getEnergy() - amount, getMaxEnergy()) 不让能量超过开场时的最大值,所以 Impike 还是 33 / 33,Galecrest 还是 14 / 14。后面截图下的灰字写着“用 Wait:能量 +2”,说的是 Wait 本来的作用;能量满的时候看不出来。
这些变化分成 4 段代码完成。整章就按这 4 段往下讲:
- 第 1、2 段(本章第 2 节):按钮怎么知道自己是哪个技能,点了以后怎么走到
useSkill。 - 第 3 段(第 3–5 节):CBattle.playTurn 一行一行跑;技能内部的
use;回合末的endTurn;分出胜负的finish。 - 第 4 段(第 6 节):CBattleScreen.update 怎么把数据画回屏幕,按钮为什么会变灰。
- 第 7 节讲道具(它不走 playTurn),第 8 节把整件事总结成考试答案。
2. 第 1、2 段:从点按钮到 useSkill
按钮在造出来的时候,就记住了自己的技能
技能按钮不是写死的 3 个。CBattleScreen 的构造器调用 createSkillButtons(battle.getPlayer().getSkills()):你的 Mokepon 有几个技能,就造几个按钮。Impike 有 Lance、Stun、Wait 三个技能,所以是三个按钮。截图上的红色数字和下面代码里的红色数字一一对应:

- 1第 183–185 行:一个竖着排的
VBox,放在 (376, 408)。截图里是 y = 433,因为上面多了 25 像素的菜单栏 - 2第 189–190 行:按钮上的字是技能名、一个空格、方括号里的能量消耗,所以是 Lance [4]
- 3第 193 行:每个按钮都登记了一段代码。点 Stun 这个按钮,就运行
useSkill(stun)
181 /* One button per skill, showing its energy cost, e.g. "Bite [1]". */182 private VBox createSkillButtons(List<Skill> skills) {1831 VBox buttonColumn = new VBox(SKILL_GAP);184 buttonColumn.setLayoutX(ACTIONS_X);185 buttonColumn.setLayoutY(SKILLS_Y);186 187 Font font = UiStyle.loadGameFont(SKILL_FONT_SIZE);188 for (Skill skill : skills) {1892 GameButton button = new GameButton(skill.getName() + " [" + skill.getEnergyCost()190 + "]", font, BUTTON_RADIUS);191 button.setPrefSize(SKILL_WIDTH, SKILL_HEIGHT);192 button.addPressEffect();1933 button.setOnAction(event -> useSkill(skill));194 skillButtons.add(button);195 buttonColumn.getChildren().add(button);196 }197 return buttonColumn;198 }一行一行说(第 188–195 行是一个循环,每个技能跑一轮):
- 第 188 行
for (Skill skill : skills):从技能列表里一个一个拿出来,这一轮拿到的叫skill(for-each 循环)。 - 第 189–190 行:new 一个 CGameButton,字是
skill.getName() + " [" + skill.getEnergyCost() + "]"。 - 第 191–192 行:设大小(180 × 48),加上“按下去变亮”的效果:按住鼠标时,按钮的浅灰背景从 50% 不透明变成 75%,看起来更亮(代码注释写的是 Darken,实际是变亮,第 6 章)。
- 第 193 行
button.setOnAction(event -> useSkill(skill));:这是整个回合的起点。下面的小课堂专门讲它。 - 第 194 行:把按钮也放进字段
skillButtons(一个List<GameButton>),以后变灰、按数字键都要用它。 - 第 195 行:把按钮放进竖排的框里,显示在屏幕上。
useSkill:只有两行
117 private void useSkill(Skill skill) {1181 battle.playTurn(skill);1192 update();120 }⋯ 省略第 121–121 行122 private void useItem(Item item) {123 battle.useItem(item);124 update();125 }点了 Stun,运行的就是 useSkill(stun)。它只做两件事:
- 第 118 行
battle.playTurn(skill):把技能交给字段battle里的 CBattle 对象,让它把这一回合打完。伤害、命中、谁先出手,全在 CBattle 里算。 - 第 119 行
update():等 CBattle 改完数据,再把屏幕上每一块都和数据对一遍(第 6 节)。
CBattleScreen 自己不算任何规则,只是把事情交给 CBattle,这叫委托(delegation)。它的类注释原话是 “All the battle rules live in the Battle class; this class only displays them.” 下面的 useItem 结构一模一样,第 7 节再讲。
不用鼠标:按数字键 1–4
按键盘上面那排数字 1–4 也能用技能:CBattleScreen.handleKey 把按下的数字换成按钮的位置(按 2 → skillButtons 里的第 2 个,Stun),再调用 skillButtons.get(1).fire()(用代码按一下按钮),运行的正是第 193 行存进去的同一个 lambda,所以按 2 和用鼠标点 Stun 完全一样。按键怎样从窗口一路传到 handleKey(中间还有父类 AGameScreen 的 handleKeyPress),见 第 6 章第 4 节。
把鼠标这条路画成顺序图(sequence diagram)。读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。
第 2 步的箭头从按钮出发:是按钮在被点时运行了它存着的 lambda,lambda 里写的是 useSkill(skill)。第 3 步把整个回合交给 CBattle,第 5 步 CBattleScreen 调用自己的 update() 重画屏幕。下一节打开第 3 步。
3. 第 3 段:Battle.playTurn 一行一行
现在进入整个回合的中心:CBattle 的 playTurn。先看老师打印稿上 CBattle 长什么样(这种图怎么读,见 第 3 章):

playTurn 一共 28 行(第 69–96 行),可以分成 4 块,代码里的红色数字就是每一块的开头:
66 /* Play one turn: the player acts first, then the enemy if it is still standing.67 * The lingering effects act last.68 */69 public void playTurn(Skill playerSkill) {701 if (finished) {71 return;72 }73 74 if (!player.canUse(playerSkill)) {75 log.add("Not enough energy.");76 return;77 }78 792 log.add("");80 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");81 log.add(playerSkill.use(player, enemy, random));82 if (!enemy.isAlive()) {83 finish(true);84 return;85 }86 873 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");89 log.add(enemySkill.use(enemy, player, random));90 if (!player.isAlive()) {91 finish(false);92 return;93 }94 954 endTurn();96 }- 第 70–77 行,两道检查:战斗打完了吗?能量够吗?
- 第 79–85 行,你出手:写两行日志、用你的技能、看敌人倒没倒。代码里没有比速度:永远是你先出手(第 66 行的注释写着 “the player acts first”),所以你的技能先扣能量、先判断敌人倒没倒。
- 第 87–93 行,敌人出手:请策略挑一个技能、写日志、用它、看你倒没倒。
- 第 95 行,收尾:
endTurn(),算持续伤害、效果倒数。
下面两个动画用 A 局第 2 回合(点 Stun)把这 28 行跑一遍:第一个是你出手(第 69–85 行),第二个是敌人出手和收尾(第 87–96 行)。每个动画从上到下三层:
- 顺序图:6 根竖线一直不变——CBattleScreen、CBattle、你的 CFighter(player)、技能对象、敌人的 CFighter(enemy),最右边是字段
enemyStrategy里装的 CRandomEnemyStrategy 对象。技能对象这一列,前一个动画是你的 Stun,后一个是敌人的 Tackle。正在运行的那一步变成红色、线在流动,看起来像虚线,但它还是原来的箭头:实心三角箭头 = 调用,开口的 V 形箭头 = 返回。已经跑过的步骤是黑色,还没跑到的是淡灰色。 - 代码框:这一段代码,左边有红线的是这一步正在跑的行。
- 两张截图:“点之前”是上一回合结束时的样子(battle-wait),“点之后”是这一回合打完、
update()画完以后的样子(battle-stun)。红框是这一步改了的那一块。记住:动画里每一步改的只是对象里的数据,屏幕要等到最后的update()才变(第二个动画的最后一步)。
截图上的红框编号:1–5 日志里新出现的 5 条,6 你的 HP,7 你的能量,8 敌人的 HP 条,9 敌人的能量条。
69 public void playTurn(Skill playerSkill) {70 if (finished) {71 return;72 }73 74 if (!player.canUse(playerSkill)) {75 log.add("Not enough energy.");76 return;77 }79 log.add("");80 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");81 log.add(playerSkill.use(player, enemy, random));82 if (!enemy.isAlive()) {83 finish(true);84 return;85 }
- 1点之前:1–5 的位置现在还是空的

- 1点之后:1–5 是这一回合写进 log 的 5 条

- 7点之前:你的能量 33 / 33(满的)

- 7点之后:你的能量 29 / 33(花了 4)

- 8点之前:敌人 HP 条 93 / 93(满的)

- 8点之后:敌人 HP 条还是 93 / 93
- 你点了 Stun:CBattleScreen.useSkill 调用
battle.playTurn(skill),参数playerSkill就是 Stun 按钮在第 193 行记住的那个 CStun 对象。先对比下面两张截图:点之前日志到 They wonder… 为止,点之后多了 5 条。这两个动画就是看这 5 条怎样一条一条加进 log 列表。 - 第 70–72 行:字段
finished是 true(仗打完了)就直接return,后面一行都不跑。现在是 false,继续。打完以后按钮全灰、对话框挡住按键,平时走不到这里,这是一道保险。 - 第 74 行问你的 CFighter:
canUse算getEnergy() >= skill.getEnergyCost(),33 ≥ 4,返回 true(“点之前”的框 7)。前面的!是“不”,所以第 75–76 行不跑。能量不够的按钮早就变灰、点不了(第 6 节),所以游戏里看不到Not enough energy.。 - 第 79 行
log.add("")加一个空字符串,显示出来就是一个空行(框 1),隔开上一回合。第 80 行把"Player's "、名字 Impike、" used "、技能名 Stun 和"!"连成框 2。log是 CBattle 的字段(一个List<String>),add加到最后。这一刻它们只是进了 log 列表,屏幕上还没有。 - 第 81 行
playerSkill.use(player, enemy, random):三个参数是“谁用(user)、对谁用(target)、随机数”。ASkill 的 final 方法use第一步是user.spendEnergy(4):user 是你,33 − 4 = 29(两张图的框 7)。先扣能量,后看打没打中。 - 第二步
applyEffect,先算命中率:100²(Impike 的 Accuracy)÷(100² + 40²(Galecrest 的 Dodge))= 10000 ÷ 11600 ≈ 0.86。这次中了:给 target 加一个new Stunned(),再返回一句以制表符开头的话。返回值交给同一行外面的log.add,就是框 3。 - 第 82 行:敌人还活着吗(
getHp() > 0)?Stun 不打血,Galecrest 还是 93(框 8),不进 if。要是这一下打倒了敌人,就finish(true)再return,敌人没机会出手(A 局最后一回合就是这样,第 5 节)。
你出手的部分结束了,log 里多了 3 条。接着是敌人出手和收尾,同一回合、同一个 playTurn 继续往下跑:
87 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");89 log.add(enemySkill.use(enemy, player, random));90 if (!player.isAlive()) {91 finish(false);92 return;93 }90 if (!player.isAlive()) {91 finish(false);92 return;93 }94 95 endTurn();96 }
- 1点之前:1–5 的位置现在还是空的

- 1点之后:1–5 是这一回合写进 log 的 5 条

- 9点之前:敌人能量条 14 / 14(满的)

- 9点之后:敌人能量条 9 / 14(花了 5)

- 6点之前:你的 HP 225 / 225

- 6点之后:你的 HP 还是 225 / 225
- 第 87 行:CBattle 不自己挑敌人的技能,而是问字段
enemyStrategy。里面的 CRandomEnemyStrategy 先挑出能量够用的技能——Galecrest 能量 14(框 9),Tackle [5]、Bunker [3]、Swole [3]、Wait [-2] 都够——再随机抽一个。这次是 Tackle,存进局部变量enemySkill。 - 第 88 行写下
Enemy Galecrest used Tackle!(框 4)。这里没有空行:空行只在每回合开头由第 79 行加一次,所以你的两条和敌人的两条挨在一起。 - 第 89 行
enemySkill.use(enemy, player, random):和第 81 行是同一个方法,只是两个 CFighter 换了位置,这次 user = enemy,target = player。照样先扣 user 的能量:Galecrest 14 − 5 = 9(框 9)。 - 命中率 = 43²(Galecrest 的 Accuracy)÷(43² + 100²(Impike 的 Dodge))= 1849 ÷ 11849 ≈ 0.16。这次没中,ATargetedSkill 返回
"\tBut they missed.",被log.add写成框 5。花掉的 5 点能量不退。 - 第 90 行:你还活着吗?Impike 225 / 225(框 6),活着,不进 if。要是敌人这一下把你打倒了,就
finish(false)再return,你输了。 - 第 95 行:两人都出过手、都还站着,调用
endTurn():先算敌人的、再算你的持续伤害(这回合没人流血,什么也没发生),最后让两边的效果倒数一回合,Galecrest 的 Stunned 从 3 变成 2(第 5 节)。endTurn 这次没往 log 里加东西,所以“点之后”那张里最后一条还是 But they missed. - playTurn 结束,回到 CBattleScreen.useSkill,执行它的第二行
update()。到这一刻为止,屏幕上一个像素都还没变,变化都只发生在对象里;是update()把它们画出来,你才看到“点之后”这 5 条(第 6 节)。
日志里的每一行,是哪一行代码写的
把 battle-stun 的 11 行日志全部对上代码。相同的数字 = 同一行代码写的(同一行代码每回合都跑一次,所以 2–6 号各出现两次):

- 1第 1 行日志:构造器第 37–38 行,new Battle 的时候就写好了
- 2空行:第 79 行
log.add(""),每回合开头一个 - 3Player's Impike used …!:第 80 行
- 4你的技能的结果:第 81 行,内容是技能返回的(Wait 的发呆台词、Stun 的被眩晕)
- 5Enemy Galecrest used …!:第 88 行
- 6敌人技能的结果:第 89 行(Wait 的台词、Tackle 的 But they missed.)
371 log.add("Player chose " + player.getName() + ". Began a fight against enemy "38 + enemy.getName() + "!");⋯ 省略第 39–68 行69 public void playTurn(Skill playerSkill) {70 if (finished) {71 return;72 }73 74 if (!player.canUse(playerSkill)) {75 log.add("Not enough energy.");76 return;77 }78 792 log.add("");803 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");814 log.add(playerSkill.use(player, enemy, random));82 if (!enemy.isAlive()) {83 finish(true);84 return;85 }86 87 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);885 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");896 log.add(enemySkill.use(enemy, player, random));90 if (!player.isAlive()) {91 finish(false);92 return;93 }94 95 endTurn();96 }看出规律了吗?一个双方都站着的回合,正好往 log 列表里加 5 条(5 个 String):空行、你用了什么、结果、敌人用了什么、结果。一条通常就是屏幕上的一行,但有的技能返回的话中间带一个换行符 \n:CPocketSand 返回 "\tPocket sand blinded Bubbore!\n\tTheir targeted skills may miss for 3 turns.",在列表里它还是 1 条,屏幕上却占 2 行(这句话来自另一场战斗:第 5 节的 C 局里,你在第 1 回合对 Bubbore 用了 Pocket Sand,那一回合屏幕上就是 6 行)。结果行前面都缩进一截,那是技能返回的文字以制表符 \t 开头(第 4 节)。如果回合末有人在流血,后面还可能多一条 The bleeding stopped.(第 5 节)。日志里没有任何一行是 CBattleScreen 写的:日志是 CBattle 的数据,CBattleScreen 只负责显示。
敌人的技能是谁挑的:EnemyStrategy
第 87 行把“挑技能”交给了字段 enemyStrategy。它的类型是接口(interface) IEnemyStrategy,接口里只有一个方法 chooseSkill(Fighter enemy, Random random)。真正装在里面的对象是 CRandomEnemyStrategy,它实现(implements)了这个接口:
8/* Picks a random skill out of the ones the enemy has enough energy for. */9public class RandomEnemyStrategy implements EnemyStrategy {10 11 @Override12 public Skill chooseSkill(Fighter enemy, Random random) {13 List<Skill> affordableSkills = new ArrayList<>();14 for (Skill skill : enemy.getSkills()) {15 if (enemy.canUse(skill)) {16 affordableSkills.add(skill);17 }18 }19 return affordableSkills.get(random.nextInt(affordableSkills.size()));20 }21}- 第 9 行
implements EnemyStrategy:答应接口“我会写chooseSkill”。第 11 行的@Override(注解)告诉编译器:下面这个方法就是接口要的那个(override);名字或参数写错了,编译就会报错。 - 第 13 行:先准备一个空列表
affordableSkills(付得起的技能)。List<Skill>= 只装 ASkill 的列表;new ArrayList<>()里那对空的尖括号(代码字体把挨在一起的 < 和 > 画成了一个小菱形)让 Java 照左边自己填上 Skill(泛型,第 2 章)。 - 第 14–18 行:把敌人的技能一个个拿出来,
enemy.canUse(skill)为 true 的才放进列表。用的是和你同一个canUse。 - 第 19 行:
random.nextInt(n)得到 0 到 n−1 之间的一个整数(Random),用它从列表里拿一个。
CRandomEnemyStrategy 对象是谁 new 的?是 CBattle 的构造器。CBattle 有两个构造器,CEncounter.createBattle(第 9 章)用的是三个参数的那个,它第 26 行用 this(...) 把事情转给五个参数的构造器,同时 new 出策略和随机数(构造器链):
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;37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "38 + enemy.getName() + "!");39 }- 第 26 行
this(player, enemy, inventory, new RandomEnemyStrategy(), new Random()):调用本类另一个构造器,多传两个新造的对象(第 2 章)。 - 第 32–36 行:把 5 个参数存进同名的字段。
this.player指字段,等号右边的player指参数(this)。这几个字段都是private final:只有 CBattle 自己能用,而且在构造器里赋值以后不能再换成别的对象。 - 第 37–38 行:写下 log 的第 1 条 “Player chose …”。

为什么不直接在 CBattle 里写“随机挑一个”?因为 CBattle 只认识接口 IEnemyStrategy。以后想要一个更聪明的敌人(比如“能打就打、打不了再 Wait”),只要新写一个实现类,在构造器里传进去,CBattle 一行都不用改。这就是策略模式(Strategy pattern),接口的注释原话也这么说。五个参数的构造器还可以传一个固定种子的 Random,那样每次运行结果都一样,方便测试——不过这只是设计上留的口子:这个项目里没有测试代码(src 下只有 main),现在唯一调用它的就是第 26 行。设计模式的总结在 第 16 章。
4. 技能内部:skill.use 只看轮廓
第 81 行和第 89 行都调用了 use。它写在抽象类 ASkill 里:
27 /* Template Method: pay the energy cost first (even if an attack then misses),28 * then apply the effect. Returns the message for the battle log.29 */30 public final String use(Fighter user, Fighter target, Random random) {311 user.spendEnergy(energyCost);322 return applyEffect(user, target, random);33 }34 35 /* Each subclass decides what the skill actually does. */36 protected abstract String applyEffect(Fighter user, Fighter target, Random random);- 第 30 行的
final:子类不能改写use。所以不管哪个技能,顺序永远是“先扣能量,再执行效果”。这是模板方法(Template Method):父类定顺序,子类只填applyEffect这一步。 - 第 36 行的
abstract:ASkill 自己不写applyEffect的内容(抽象方法),每个子类必须写。运行时调用的是对象真实类型的那个版本(动态绑定)。 - ATargetedSkill(打对方的技能:Stun、Tackle、Lance、Bite、Pocket Sand)的
applyEffect先做命中判定(命中率),没中就返回"\tBut they missed.",中了才调用子类的applyHit。ABoostSkill(Aim、Swole、Bunker、Nimble)一定成功,给自己加点数。CWait 什么都不做,返回一句随机的发呆台词;它回能量靠的是负数的能量消耗 −2。 - 不管哪种,
applyEffect最后都返回一个 String,这就是日志里缩进的那一行。
同一个方法,两个人用:参数换个位置
CBattle 里调用了两次 use:第 81 行是 playerSkill.use(player, enemy, random),第 89 行是 enemySkill.use(enemy, player, random)。Java 按位置把实参交给参数:第一个给 user,第二个给 target。
| 第 81 行(你出手) | 第 89 行(敌人出手) | |
|---|---|---|
user(用技能的) | player:Impike | enemy:Galecrest |
target(被用的) | enemy:Galecrest | player:Impike |
spendEnergy 扣谁 | Impike:33 → 29 | Galecrest:14 → 9 |
| 效果落在谁身上 | Galecrest 被眩晕 | Impike(这次没打中) |
这就是本章最重要的设计点:你和敌人是同一个类 CFighter 的两个对象,技能代码只认“user”和“target”,不认“你”和“敌人”。所以一套技能代码同时处理两边的数据,不用写两份。每个 CFighter 对象都有自己的 stats(HP、能量……)和 effects,user.spendEnergy(4) 只改 user 那一个对象里的数字。
日志那一行是技能自己拼出来的。比如 CStun(上面是 battle-stun 日志的第 8、9 行,下面是拼出第 9 行的代码):

- 1第 18 行开头的
"\t":制表符(转义字符),让这一行往里缩一截 - 2第 19 行的
Stunned.TURNS:常量 3,拼成 “3 turns.”
15 @Override16 protected String applyHit(Fighter user, Fighter target, Random random) {17 target.addEffect(new Stunned());181 return "\t" + target.getName() + " was stunned! Their damage is halved for "192 + Stunned.TURNS + " turns.";20 }第 17 行 target.addEffect(new Stunned()) 在屏幕上看不见:它只是往 Galecrest 的 effects 列表里加了一个对象。第 18 行 target.getName() 得到 “Galecrest”,用 + 把几段字连成一句。如果刚才 Galecrest 的 Tackle 打中了,伤害会是 2(Tackle 的 POWER)× 37(Galecrest 战斗里的 Strength)× 50(Fighter.DAMAGE_SCALE)÷ 68(Impike 战斗里的 Defence)= 54.41,再乘 0.5(Galecrest 刚被眩晕,伤害减半)= 27.2,(int) 去掉小数得 27。十个技能各自的效果、命中和伤害公式在 第 12 章。
5. 回合收尾:endTurn 和 finish
endTurn:先算敌人,再算你,最后一起倒数
只有两个人都站着,playTurn 才会走到第 95 行 endTurn()。它处理“回合结束时才发生”的事:流血这种持续伤害,以及所有状态效果的倒数。这一章只记住它的顺序,一共 5 步:
一个短例子:C 局,你的 Nightwing 对 Bubbore。第 2 回合你的 Lance 让 Bubbore 开始流血;第 3 回合你用 Wait、它用 Bunker,两人都站着,进入 endTurn。截图是这一回合打完以后的日志:
116 private void endTurn() {1171 log.addAll(enemy.applyLingeringDamage());1182 if (!enemy.isAlive()) {119 finish(true);120 return;121 }122 1233 log.addAll(player.applyLingeringDamage());1244 if (!player.isAlive()) {125 finish(false);126 return;127 }128 1295 enemy.countDownEffects();130 player.countDownEffects();131 }
- 1第 117 行(①)写进日志的:The bleeding stopped.
- ① 第 117 行
enemy.applyLingeringDamage():Bubbore 的 CFighter 把自己身上的效果挨个问一遍。Bleeding 扣了 20 点血(HP 151 → 131),扣血本身不写日志;这是流血的最后一回合,所以它返回一句The bleeding stopped.,log.addAll把它加到日志最后(框 1)。 - ② 第 118 行:131 > 0,Bubbore 活着,不进 if。
- ③④ 第 123–124 行:你的 Nightwing 身上没有效果,什么也没扣、什么也没加;HP 155 / 155,活着。
- ⑤ 第 129–130 行:两边的效果各倒数一回合,减到 0 的就删掉。Bubbore 身上的 Bleeding 和 Blinded 都到了 0,一起被删掉。
为什么要先算敌人、为什么倒数放在最后、20 点是怎么算的、流血把你打倒时会怎样(E 局),都在 第 13 章第 5 节“回合末的四条规则”,那里按时间一步一步画了出来。
finish:记下结果,写下最后几行
谁倒下,就调用 finish。它在 CBattle 里一共有 4 个调用的地方:
| 第几行 | 什么时候 | 真实例子 |
|---|---|---|
83 finish(true) | 你的技能把敌人打倒 | A 局最后一回合,Lance 打出 83 点 |
91 finish(false) | 敌人的技能把你打倒 | 这几局里没有发生 |
119 finish(true) | 回合末敌人流血倒下 | 这几局里没有发生 |
125 finish(false) | 回合末你流血倒下 | E 局,Owlet |
先看赢的那次。A 局最后一回合:你点 Lance,伤害 = 0.5(Lance 只用一半力量 HALF_STRENGTH)× 100(Impike 战斗里的 Strength)× 50(DAMAGE_SCALE)÷ 30(Galecrest 战斗里的 Defence)= 83.33,(int) 去掉小数得 83。被眩晕的是 Galecrest,Impike 自己的伤害不减半。Galecrest 只剩 1 HP(上一步用了 Poison),1 − 83 被 Math.max(0, …) 挡在 0,第 83 行调用 finish(true)。
输的那次是另一场(E 局):你的 Owlet 对 Impike(敌人是随机挑的,这次 Impike 站在了对面)。Impike 的 Lance 让 Owlet 流血,最后一回合两边都用 Wait,进入 endTurn:第 123 行的流血把 Owlet 扣到 0,第 124 行发现你倒下,第 125 行调用 finish(false),然后 return(这一回合一步一步的过程见 第 13 章第 5 节的规则 3)。
两张日志放在一起看,下面是写出它们的代码(相同编号 = 同一行代码写的):

- 1第 81 行:Lance 的结果。第 82 行发现 HP 是 0,于是
finish(true),第 84 行return:敌人没有出手 - 2第 137–138 行:两个空行
- 3第 140–141 行:
Enemy's Galecrest fainted!和You win

- 4第 123 行(Bleeding 返回的):The bleeding stopped.
- 2又是第 137–138 行的两个空行
- 5第 143–144 行:
Player's Owlet fainted!和You lose
811 log.add(playerSkill.use(player, enemy, random));82 if (!enemy.isAlive()) {83 finish(true);84 return;85 }⋯ 省略第 86–122 行1234 log.addAll(player.applyLingeringDamage());124 if (!player.isAlive()) {125 finish(false);126 return;127 }⋯ 省略第 128–132 行133 /* Stop the battle and record who fainted and the result. */134 private void finish(boolean playerWon) {135 this.finished = true;136 this.playerWon = playerWon;1372 log.add("");1382 log.add("");139 if (playerWon) {1403 log.add("Enemy's " + enemy.getName() + " fainted!");1413 log.add("You win");142 } else {1435 log.add("Player's " + player.getName() + " fainted!");1445 log.add("You lose");145 }146 }finish 做三件事:
- 第 135 行
this.finished = true:以后isFinished()返回 true。第 6 节的update()就靠它把按钮变灰、弹出结果框;playTurn 第 70 行也靠它挡住后面的点击。 - 第 136 行
this.playerWon = playerWon:参数和字段同名,this.playerWon 指字段,右边的 playerWon 指参数。以后hasPlayerWon()用它决定弹 You Win 还是 Game Over。 - 第 137–145 行:两个空行,然后两行结果。赢输只差在写谁 fainted、写 You win 还是 You lose。
6. 第 4 段:回到 BattleScreen.update()
playTurn 返回以后,useSkill 的第二行调用 update()。它不管“这回合变了什么”,而是每次都把屏幕上所有会变的东西和 CBattle 的数据对一遍。下面用 A 局最后一回合(点 Lance,赢了)走一遍。左上的代码框一直是 update() 本身;它下面的代码框换成这一步调用的那个方法;旁边(手机上是下面)的截图是这一步改了的那一块。红框编号:1 敌人 HP 条,2 红色遮罩,3 日志,4 技能按钮,5 道具按钮,6 结果对话框,7 你的能量。
128 private void update() {129 playerPanel.update();130 enemyPanel.update();131 showLog();132 updateSkillButtons();133 updateItemButtons();134 135 if (battle.isFinished() && !isDialogOpen()) {136 showResult();137 }138 }
13- 1敌人 HP 1 / 93:只剩一丝
- 3日志 13 条,最后一条是 Poison 那句

- 7你的能量 25 / 33(原来 29,Lance 花了 4);上面的 HP 225 / 225 没变

- 2整个卷轴盖上了红色遮罩(原来是米黄色的)
- 1敌人 HP 条:0 / 93,空了

- 3最上面一行已经是第 8 条(Player's Impike used Stun!),右边的滚动条在最底下

- 4三个技能按钮全灰

- 5三个道具按钮全灰

- 6You Win 对话框,只有一个按钮 Back to it!
34- 3日志:这一回合多了 5 条
- 4技能按钮:能量够,都亮着
76 public void update() {77 hpBar.update(fighter.getHp(), fighter.getMaxHp());78 energyBar.update(fighter.getEnergy(), fighter.getMaxEnergy());79 scroll.setFainted(!fighter.isAlive());80 }141 private void showLog() {142 logArea.setText(String.join("\n", battle.getLog()));143 logArea.positionCaret(logArea.getLength());144 logArea.setScrollTop(Double.MAX_VALUE);145 }148 private void updateSkillButtons() {149 List<Skill> skills = battle.getPlayer().getSkills();150 for (int i = 0; i < skillButtons.size(); i++) {151 boolean usable = !battle.isFinished() && battle.getPlayer().canUse(skills.get(i));152 skillButtons.get(i).setUsable(usable);153 }154 }157 private void updateItemButtons() {158 Inventory inventory = battle.getInventory();159 for (Map.Entry<Item, GameButton> entry : itemButtons.entrySet()) {160 Item item = entry.getKey();161 GameButton button = entry.getValue();162 button.setText(String.valueOf(inventory.getCount(item)));163 button.setUsable(!battle.isFinished() && inventory.has(item));164 }165 }170 private void showResult() {171 getNavigator().updateMenu();172 if (battle.hasPlayerWon()) {173 ModalDialog dialog = new ModalDialog("You Win");174 dialog.addButton("Back to it!", event -> getNavigator().winBattle());175 showDialog(dialog);176 } else {177 showDialog(createGameEndDialog("Game Over"));178 }179 }- 在截图这个样子时点 Lance [4]。playTurn 跑完那一刻,Galecrest 的 HP 已经是 0,
finished是 true,log 有 20 条,但屏幕还是这个样子,因为还没人去改画面。useSkill的第二行调用update()(第 128 行),下面几步就是它的各个部分。 - 第 129 行
playerPanel.update()(下面的代码框):每个 CFighterPanel 在 new 的时候就存了自己那个 CFighter。第 77–78 行读出getHp()、getEnergy()和它们的最大值,交给两根条:你的能量 29 → 25 / 33(框 7),HP 225 / 225 没变。第 79 行:你还活着,不盖红色遮罩。 - 第 130 行
enemyPanel.update():同一个方法、同样三行,只是这个面板存的是敌人的 CFighter。敌人的 HP 是 0,HP 条变空(框 1);第 79 行isAlive()是 false,setFainted(!false)就是setFainted(true),整个卷轴盖上红色遮罩(框 2)。 - 第 131 行
showLog():第 142 行用String.join("\n", battle.getLog())把整个 log 列表用换行连成一段文字,整段放进logArea(不是只追加新的几条)。第 143–144 行把光标放到最后、滚到最底下,所以你总能看到最新的几行。20 条日志框里只放得下 13 行,前 7 条被滚上去了(框 3)。 - 第 132 行
updateSkillButtons():第 151 行给每个技能按钮算一个usable= 战斗没结束&&能量够用。现在战斗结束了,!battle.isFinished()是 false,&&右边就不看了,三个都是 false;第 152 行setUsable(false):按钮禁用,整个变淡(框 4)。 - 第 133 行
updateItemButtons():第 162 行给每个道具按钮写上背包里还剩几个(getCount),第 163 行再定能不能点:战斗没结束、并且背包里有(has),才亮着。现在战斗结束了,三个都灰(框 5;数量本来也都是 0)。 - 第 135 行:战斗结束了,而且现在没有对话框开着,就调用
showResult()。第 171 行先让菜单重新检查一遍:canRunAway()现在返回 false,Game 菜单里的 Run away 变灰。hasPlayerWon()是 true,就造一个 You Win 对话框,只有一个按钮 Back to it!,点它会调用 CGameWindow 的winBattle()(框 6)。输了则走第 177 行,弹 Game Over。对话框盖住整个画面,所以前几步的截图都是暗的。 - 对比一个普通回合(点 Stun 以后):同样的
update()整个跑一遍,只是isFinished()是 false。按钮按能量决定亮不亮(三个都够,都亮着),也不弹对话框。每个回合的画面都是这样“全部对一遍”画出来的,所以 CBattleScreen 不用记住上回合是什么样子。它的构造器最后一行(第 96 行)也调用了update(),开战时的第一个画面也是这样画出来的。
CFighterPanel 的两根条,字段类型是 CStatBar;你的面板里装的是子类 CLabelledStatBar,它改写的 update 还会改条上的数字(第 1 章)。条的长短、红色遮罩和对话框的样子在 第 14 章。
技能按钮为什么会变灰
第 3 节说过,playTurn 第 74–77 行的 Not enough energy. 平时看不到。原因就在 updateSkillButtons:每次 update,能量不够的技能按钮都会被禁用。看 B 局(你的 Shellurk 一直用 Aim,能量 12 → 9 → 6 → 3 → 0):
147 /* A skill can only be used while the battle is running and there is enough energy. */148 private void updateSkillButtons() {149 List<Skill> skills = battle.getPlayer().getSkills();150 for (int i = 0; i < skillButtons.size(); i++) {1511 boolean usable = !battle.isFinished() && battle.getPlayer().canUse(skills.get(i));1522 skillButtons.get(i).setUsable(usable);153 }154 }
- 1能量 3 / 12:第 151 行的
canUse就拿它和每个技能的消耗比 - 2Tackle [5]:3 ≥ 5 不成立,
usable是 false,第 152 行setUsable(false),变灰 - 3Aim [3]:3 ≥ 3 成立,还亮着
50 /* A disabled button fades out as a whole, including its text and image. */51 public void setUsable(boolean usable) {524 setDisable(!usable);53 setBackgroundOpacity(NORMAL);545 setOpacity(usable ? 1 : DISABLED_OPACITY);55 }
- 1能量 0 / 12
- 4Tackle 和 Aim 都灰了:第 52 行
setDisable(true),鼠标点不动,fire()也不起作用 - 5Wait [-2]:0 ≥ −2 成立,还能用。第 54 行把它的不透明度设成 1(灰的是 0.4)
所以从界面上永远不会把一个付不起的技能交给 playTurn:鼠标点不动灰按钮,数字键的 fire() 对灰按钮也没用。playTurn 里那道检查是给“万一有别的代码直接调用 playTurn”准备的保险。usable ? 1 : DISABLED_OPACITY 是三元运算符:usable 为 true 取 1,否则取 0.4。为什么灰按钮看起来是 20% 灰,在 第 14 章。
结果对话框:showResult
167 /* Show the winning or the losing dialog.168 * The battle is over, so the menu is updated to turn off "Run away".169 */170 private void showResult() {171 getNavigator().updateMenu();172 if (battle.hasPlayerWon()) {173 ModalDialog dialog = new ModalDialog("You Win");1741 dialog.addButton("Back to it!", event -> getNavigator().winBattle());175 showDialog(dialog);176 } else {1772 showDialog(createGameEndDialog("Game Over"));178 }179 }
- 1第 174 行:Back to it! →
getNavigator().winBattle(),回到地图

- 2第 177 行:父类 AGameScreen 的
createGameEndDialog("Game Over"),两个按钮 Return to Main Menu 和 Quit
对话框是一个 CModalDialog(模态对话框),盖住整个战斗画面,后面的东西都点不到。它开着的时候按数字键也没用:所有按键先经过父类 AGameScreen 的 handleKeyPress,它发现有对话框开着就直接 return(第 6 章)。第 135 行的 !isDialogOpen() 保证结果框只弹一次。点了 Back to it! 以后发生什么,在 第 15 章。
7. 道具:useItem 不给敌人回合
点道具按钮走的是另一条路。按钮的 lambda 是 event -> useItem(item)(第 218 行),CBattleScreen.useItem 同样只有两行:battle.useItem(item),然后 update()(第 2 节看过)。CBattle 里的 useItem 比 playTurn 短得多:
98 /* Use one item from the inventory. An item always works,99 * and using it does not give the enemy a turn.100 */101 public void useItem(Item item) {102 if (finished || !inventory.has(item)) {103 return;104 }1053 inventory.remove(item);1061 log.add("");1072 log.add(item.use(player, enemy));108 }
- 1第 106 行:空行
- 2第 107 行:
item.use(player, enemy)返回的一句话

- 3第 105 行
inventory.remove(item):Poison 1 → 0,update 以后按钮变灰
- 第 102 行:战斗结束了、或者背包里没有这个道具,就直接 return。
- 第 105 行:背包里减一个。第 106 行:空一行。第 107 行:调用道具自己的
use,把返回的话写进日志。Poison 的use调用enemy.reduceHpToOne(),Galecrest 的 HP 93 → 1。 - 然后就结束了:没有
chooseSkill,敌人不出手;没有endTurn,流血不扣血,效果也不倒数。Galecrest 的 Stunned 用 Poison 前后都剩 2 回合。 - 道具不会打倒任何人(Poison 只把 HP 变成 1,另外两种只帮你),所以这里不需要
finish。
画成顺序图,和第 3 节的 playTurn 一比就清楚了:
连用两个道具也一样。D 局里你的 Ironshell 先用 Coffee、再用 Health Tonic,中间敌人一次都没动(编号 1、2 和上面意思相同):

- 1两个空行:每用一次道具,第 106 行加一个
- 2Coffee 和 Health Tonic 各一行:第 107 行,中间没有敌人的行动
| 点技能:playTurn | 点道具:useItem | |
|---|---|---|
| 开头的检查 | 打完了吗?能量够吗? | 打完了吗?背包里有吗? |
| 花能量 | 花(Skill.use) | 不花 |
| 会不会失败 | 打对方的技能可能没打中 | 一定成功 |
| 敌人出手 | 出手 | 不出手 |
| endTurn(持续伤害、倒数) | 有 | 没有 |
| 往 log 里加几条 | 空行 + 4 条(回合末可能再多) | 空行 + 1 条 |
三种道具各自做什么、背包 CInventory 怎么数,在 第 13 章。
8. 总结:控制流、数据流,和 Q1 怎么答
考试说“处理一个回合”,可以从两个角度回答:控制流(control flow)是“谁按什么顺序调用谁”;数据流(data flow)是“数据存在哪、谁改它、谁读它”。两张图分开看:
读第二张图:
- 你和敌人的 HP、能量、效果都存在各自的 CFighter 对象里,只能通过 CFighter 的方法改(
spendEnergy、takeDamage、addEffect、restoreHp……)。改它们的是技能(ASkill)、流血(AStatusEffect 的子类 CBleeding)和道具(AItem)。 - 日志、
finished、playerWon是 CBattle 自己的字段,只有 CBattle 改。背包的数量由 CBattle.useItem 调用inventory.remove改。 - 界面一侧(CBattleScreen、CFighterPanel)只通过 getter 读:
getLog()、isFinished()、getHp()、getCount()……。CBattle 的类注释原话:“It knows nothing about the screen”。规则包里没有一行 JavaFX 代码(规则与界面分离)。
最后,把第 3 节两个动画里的 4 块拼成一张完整的顺序图。考试要你画“一个回合”时,画这张就够了:
样题 Q1:本章讲的是答案里的哪几句
样题问的是:你的设计怎样在同一个回合操作里,处理和玩家、敌人两边数值有关的数据。完整的参考答案一共 10 句,在 第 17 章第 2 节,考前背那一份就够了。其中 6 句讲的就是这一章的内容,下面把它们和本章的节对上(英文和第 17 章一字不差,不用另背一套):
- 第 1 句(位置):回合在 CBattle.playTurn(Skill) 里处理,由 CBattleScreen 的技能按钮启动(本章第 2、3 节)。
- 第 4 句(delegation):按钮的 lambda 调用
useSkill(skill),它把整个回合交给battle.playTurn(skill)(本章第 2 节)。 - 第 5 句(两边共用):同一个
Skill.use(user, target, random):你出手传(player, enemy),敌人出手传(enemy, player)(本章第 3、4 节)。 - 第 7 句(敌人的技能):
enemyStrategy.chooseSkill(enemy, random);字段类型是接口 IEnemyStrategy,默认装的是 CRandomEnemyStrategy(本章第 3 节)。 - 第 8 句(收尾):每次出手后
isAlive(),倒下就finish();否则endTurn():先算敌人的持续伤害,再算你的,最后两边倒数(本章第 3、5 节)。 - 第 9 句(显示):
update()通过 getter 读出新数据,两个 CFighterPanel 重画(本章第 6 节)。
下一章 第 12 章打开 use 里面的盒子:十个技能、命中率和伤害公式。
自测
你用鼠标点 Stun [4],到 playTurn 开始运行,中间经过哪几个方法?按数字键 2 呢?
鼠标:按钮运行它在 createSkillButtons 第 193 行登记的 lambda event -> useSkill(skill) → CBattleScreen.useSkill(stun) → battle.playTurn(stun)。数字键 2:CBattleScreen.handleKey 调用 skillButtons.get(1).fire(),用代码按一下 Stun 按钮 → 同一个 lambda → useSkill → playTurn(按键怎样传到 handleKey,见 第 6 章)。
English: The button's action lambda calls useSkill(skill), which calls battle.playTurn(skill); a number key reaches the same lambda because BattleScreen.handleKey calls fire() on the matching button.
battle-stun 的日志里,“Enemy Galecrest used Tackle!” 是哪一行代码写的?它上面为什么没有空行?
CBattle.playTurn 第 88 行:log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!")。空行只在每回合开头由第 79 行加一次,所以同一回合里你的两条和敌人的两条挨在一起。
use 在 playTurn 里被调用了两次,参数顺序却不一样。为什么这样写?有什么好处?
第 81 行 playerSkill.use(player, enemy, random),第 89 行 enemySkill.use(enemy, player, random)。use 只认“user(用技能的)”和“target(被用的)”,不认“你”和“敌人”。你和敌人都是 CFighter 对象,换一下参数位置,同一套技能代码就能给两边用,不用写两份;以后加新技能,敌人也自动能用。
English: Both fighters are Fighter objects, so Battle calls the same Skill.use method for both and only swaps the user and target arguments.
Not enough energy. 这句什么时候会出现?为什么在游戏里看不到?
playTurn 第 74–76 行:player.canUse(playerSkill) 是 false 时写这句并 return(这一回合不算,敌人也不出手)。但每次 update() 都会跑 updateSkillButtons:能量不够的技能,按钮被 setUsable(false) 禁用;禁用的按钮鼠标点不动,fire() 也没用。所以界面不会把付不起的技能交给 playTurn,这道检查只是保险。另外每只 Mokepon 都有 Wait(消耗 −2),永远能用。
回合末,你和敌人都在流血、都只剩一点 HP,谁先倒下?两个会不会一起倒下?
endTurn 先算敌人(第 117 行),如果敌人倒下就 finish(true) 然后 return,你的流血不再计算。所以敌人先倒下,你赢;两个人永远不会在同一回合一起倒下。
English: endTurn applies the enemy's lingering damage first and stops if the enemy faints, so both Mokepon can never faint in the same turn.
用了 Poison 以后,为什么日志里没有敌人的行动?Galecrest 身上的 Stunned 倒数了吗?
道具走的是 CBattle.useItem,不是 playTurn。它只做 inventory.remove、加空行、写道具的那句话,然后就结束了:不调用 chooseSkill,敌人不出手;也不调用 endTurn,所以流血不扣血、效果不倒数,Stunned 用 Poison 前后都剩 2 回合。
playTurn 刚运行完的那一刻,屏幕变了没有?是谁让屏幕变的?
没变。playTurn 只改了对象里的数据(CFighter 的数值、CBattle 的日志和 finished)。CBattleScreen.useSkill 的第二行 update() 才把两根条、红色遮罩、日志、按钮全部按数据重新设置一遍,结束时还弹出结果框。
English: Battle only changes the model; BattleScreen.update() then reads the new state and redraws the bars, the log and the buttons.
敌人的技能是谁挑的?那个对象是谁 new 出来的?
playTurn 第 87 行调用字段 enemyStrategy 的 chooseSkill(enemy, random)。字段类型是接口 IEnemyStrategy,里面装的是 CRandomEnemyStrategy:在能量够用的技能里随机挑一个。它是 CBattle 三个参数的构造器第 26 行 this(..., new RandomEnemyStrategy(), new Random()) new 出来的;这个构造器由 CEncounter.createBattle 调用。
English: Battle depends only on the EnemyStrategy interface (Strategy pattern); its three-argument constructor creates a RandomEnemyStrategy.