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

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] 以后的样子。红框是这一下点击改变(或者没改变)的地方。

用 Stun 命中:敌人被眩晕,3 回合内伤害减半123456
  1. 1
    日志多了 5 行:1 个空行,你的 2 行,敌人的 2 行
  2. 2
    你的能量:33 → 29
  3. 3
    敌人的能量条短了一截:14 → 9
  4. 4
    你点的按钮:Stun [4]
  5. 5
    你的 HP 没变:225 / 225(敌人的 Tackle 没打中)
  6. 6
    敌人的 HP 条还是满的:Stun 不打血
点完 Stun [4]:日志、两个人的能量都变了,HP 都没变真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

把点之前(上一回合结束时)和点之后的数字放在一起:

哪一块点 Stun 前 → 后谁改的(在本章第几节)
日志6 行 → 11 行playTurn 的 log.add(第 3 节)
你的能量33 → 29use 里的 spendEnergy(4)(第 3、4 节)
敌人的能量14 → 9敌人的 Tackle:spendEnergy(5)(第 3 节)
两边的 HP225、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 段往下讲:

玩家游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle屏幕:两根条、日志按钮、对话框两个 Fighter 的数值和效果日志 log、finished1. 点击(按 1–4 时由 handleKey 调 fire())2. useSkill(skill)3. playTurn(skill)改数据4. update()照着数据画
一个回合分 4 段:① 你点按钮(或按数字键),按钮运行它存着的 lambda;② lambda 调用 BattleScreen.useSkill;③ useSkill 调用 Battle.playTurn,在 Battle 里改数据;④ useSkill 再调用 update(),照着数据重画屏幕。实线箭头 = 调用,箭头上是被调用的方法

2. 第 1、2 段:从点按钮到 useSkill

按钮在造出来的时候,就记住了自己的技能

技能按钮不是写死的 3 个。CBattleScreen 的构造器调用 createSkillButtons(battle.getPlayer().getSkills()):你的 Mokepon 有几个技能,就造几个按钮。Impike 有 Lance、Stun、Wait 三个技能,所以是三个按钮。截图上的红色数字和下面代码里的红色数字一一对应:

点 Fight! 进入战斗界面,战斗刚开始
123
  1. 1
    第 183–185 行:一个竖着排的 VBox,放在 (376, 408)。截图里是 y = 433,因为上面多了 25 像素的菜单栏
  2. 2
    第 189–190 行:按钮上的字是技能名、一个空格、方括号里的能量消耗,所以是 Lance [4]
  3. 3
    第 193 行:每个按钮都登记了一段代码。点 Stun 这个按钮,就运行 useSkill(stun)
战斗刚开始:Actions 下面三个 GameButton真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
BattleScreen.createSkillButtons:一个技能一个按钮(编号和上图相同)ui/BattleScreen.java第 181–198 行
181 /* One button per skill, showing its energy cost, e.g. "Bite [1]". */
182 private VBox createSkillButtons(List<Skill> skills) {
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 行是一个循环,每个技能跑一轮):

useSkill:只有两行

BattleScreen.useSkill 和 useItemui/BattleScreen.java第 117–120、122–125 行
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)。它只做两件事:

  1. 第 118 行 battle.playTurn(skill):把技能交给字段 battle 里的 CBattle 对象,让它把这一回合打完。伤害、命中、谁先出手,全在 CBattle 里算。
  2. 第 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 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回,绕回自己 = 调用自己的方法。

玩家CskillButtons[1]CBattleScreenCBattle鼠标点 Stun [4]1useSkill(stun)2playTurn(stun)3(返回)4update()5
鼠标点 Stun [4]:从按钮到 playTurn,再回来 update()。skillButtons[1] 是第 2 个技能按钮(Stun)

第 2 步的箭头从按钮出发:是按钮在被点时运行了它存着的 lambda,lambda 里写的是 useSkill(skill)。第 3 步把整个回合交给 CBattle,第 5 步 CBattleScreen 调用自己的 update() 重画屏幕。下一节打开第 3 步。

3. 第 3 段:Battle.playTurn 一行一行

现在进入整个回合的中心:CBattle 的 playTurn。先看老师打印稿上 CBattle 长什么样(这种图怎么读,见 第 3 章):

Battle 的 UML 类图
老师打印稿里的 Battle:上面一格是字段,下面一格是方法。红色方块 = private,绿色圆点 = public。注意 player 和 enemy 两个字段的类型都是 Fighter,enemyStrategy 的类型是接口 EnemyStrategy;endTurn() 和 finish(boolean) 是 private,只在 Battle 自己里面调用

playTurn 一共 28 行(第 69–96 行),可以分成 4 块,代码里的红色数字就是每一块的开头:

Battle.playTurn:4 块battle/Battle.java第 66–96 行
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 }
  1. 第 70–77 行,两道检查:战斗打完了吗?能量够吗?
  2. 第 79–85 行,你出手:写两行日志、用你的技能、看敌人倒没倒。代码里没有比速度:永远是你先出手(第 66 行的注释写着 “the player acts first”),所以你的技能先扣能量、先判断敌人倒没倒。
  3. 第 87–93 行,敌人出手:请策略挑一个技能、写日志、用它、看你倒没倒。
  4. 第 95 行,收尾:endTurn(),算持续伤害、效果倒数。

下面两个动画用 A 局第 2 回合(点 Stun)把这 28 行跑一遍:第一个是你出手(第 69–85 行),第二个是敌人出手和收尾(第 87–96 行)。每个动画从上到下三层:

截图上的红框编号:1–5 日志里新出现的 5 条,6 你的 HP,7 你的能量,8 敌人的 HP 条,9 敌人的能量条。

CBattleScreenCBattleCplayer:FighterCstun:StunCenemy:FighterCenemyStrategyplayTurn(stun)1finished 是 false2canUse(stun)3true(33 ≥ 4)4
CBattleScreenCBattleCplayer:FighterCstun:StunCenemy:FighterCenemyStrategylog.add 两次:空行、…used Stun!1use(player, enemy, random)2spendEnergy(4)3命中 → addEffect(new Stunned())4日志文字 → log.add5isAlive():true(HP 93)6
playTurn 第 1 块(共 4 块):两道检查battle/Battle.java第 69–77 行
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 }
playTurn 第 2 块(共 4 块):你出手battle/Battle.java第 79–85 行
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 }
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
1
  1. 1
    点之前:1–5 的位置现在还是空的
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12345
  1. 1
    点之后:1–5 是这一回合写进 log 的 5 条
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
7
  1. 7
    点之前:你的能量 33 / 33(满的)
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
7
  1. 7
    点之后:你的能量 29 / 33(花了 4)
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
8
  1. 8
    点之前:敌人 HP 条 93 / 93(满的)
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
8
  1. 8
    点之后:敌人 HP 条还是 93 / 93
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
  1. 你点了 Stun:CBattleScreen.useSkill 调用 battle.playTurn(skill),参数 playerSkill 就是 Stun 按钮在第 193 行记住的那个 CStun 对象。先对比下面两张截图:点之前日志到 They wonder… 为止,点之后多了 5 条。这两个动画就是看这 5 条怎样一条一条加进 log 列表。
  2. 第 70–72 行:字段 finished 是 true(仗打完了)就直接 return,后面一行都不跑。现在是 false,继续。打完以后按钮全灰、对话框挡住按键,平时走不到这里,这是一道保险。
  3. 第 74 行问你的 CFighter:canUse 算 getEnergy() >= skill.getEnergyCost(),33 ≥ 4,返回 true(“点之前”的框 7)。前面的 ! 是“不”,所以第 75–76 行不跑。能量不够的按钮早就变灰、点不了(第 6 节),所以游戏里看不到 Not enough energy.。
  4. 第 79 行 log.add("") 加一个空字符串,显示出来就是一个空行(框 1),隔开上一回合。第 80 行把 "Player's "、名字 Impike、" used "、技能名 Stun 和 "!" 连成框 2。log 是 CBattle 的字段(一个 List<String>),add 加到最后。这一刻它们只是进了 log 列表,屏幕上还没有。
  5. 第 81 行 playerSkill.use(player, enemy, random):三个参数是“谁用(user)、对谁用(target)、随机数”。ASkill 的 final 方法 use 第一步是 user.spendEnergy(4):user 是你,33 − 4 = 29(两张图的框 7)。先扣能量,后看打没打中。
  6. 第二步 applyEffect,先算命中率:100²(Impike 的 Accuracy)÷(100² + 40²(Galecrest 的 Dodge))= 10000 ÷ 11600 ≈ 0.86。这次中了:给 target 加一个 new Stunned(),再返回一句以制表符开头的话。返回值交给同一行外面的 log.add,就是框 3。
  7. 第 82 行:敌人还活着吗(getHp() > 0)?Stun 不打血,Galecrest 还是 93(框 8),不进 if。要是这一下打倒了敌人,就 finish(true) 再 return,敌人没机会出手(A 局最后一回合就是这样,第 5 节)。

你出手的部分结束了,log 里多了 3 条。接着是敌人出手和收尾,同一回合、同一个 playTurn 继续往下跑:

CBattleScreenCBattleCplayer:FighterCtackle:TackleCenemy:FighterCenemyStrategychooseSkill(enemy, random)1tackle2log.add(…used Tackle!)3use(enemy, player, random)4spendEnergy(5)5没打中:返回 But they missed.6
CBattleScreenCBattleCplayer:FighterCtackle:TackleCenemy:FighterCenemyStrategyisAlive():true(HP 225)1endTurn()2endTurn 里:先算敌人、再算你的持续伤害,最后两边倒数一回合(第 5 节)(返回)3update()4
playTurn 第 3 块(共 4 块):敌人出手battle/Battle.java第 87–93 行
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 }
playTurn 第 4 块(共 4 块):收尾,回到画面battle/Battle.java第 90–96 行
90 if (!player.isAlive()) {
91 finish(false);
92 return;
93 }
94
95 endTurn();
96 }
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
1
  1. 1
    点之前:1–5 的位置现在还是空的
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12345
  1. 1
    点之后:1–5 是这一回合写进 log 的 5 条
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
9
  1. 9
    点之前:敌人能量条 14 / 14(满的)
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
9
  1. 9
    点之后:敌人能量条 9 / 14(花了 5)
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
6
  1. 6
    点之前:你的 HP 225 / 225
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
6
  1. 6
    点之后:你的 HP 还是 225 / 225
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
  1. 第 87 行:CBattle 不自己挑敌人的技能,而是问字段 enemyStrategy。里面的 CRandomEnemyStrategy 先挑出能量够用的技能——Galecrest 能量 14(框 9),Tackle [5]、Bunker [3]、Swole [3]、Wait [-2] 都够——再随机抽一个。这次是 Tackle,存进局部变量 enemySkill。
  2. 第 88 行写下 Enemy Galecrest used Tackle!(框 4)。这里没有空行:空行只在每回合开头由第 79 行加一次,所以你的两条和敌人的两条挨在一起。
  3. 第 89 行 enemySkill.use(enemy, player, random):和第 81 行是同一个方法,只是两个 CFighter 换了位置,这次 user = enemy,target = player。照样先扣 user 的能量:Galecrest 14 − 5 = 9(框 9)。
  4. 命中率 = 43²(Galecrest 的 Accuracy)÷(43² + 100²(Impike 的 Dodge))= 1849 ÷ 11849 ≈ 0.16。这次没中,ATargetedSkill 返回 "\tBut they missed.",被 log.add 写成框 5。花掉的 5 点能量不退。
  5. 第 90 行:你还活着吗?Impike 225 / 225(框 6),活着,不进 if。要是敌人这一下把你打倒了,就 finish(false) 再 return,你输了。
  6. 第 95 行:两人都出过手、都还站着,调用 endTurn():先算敌人的、再算你的持续伤害(这回合没人流血,什么也没发生),最后让两边的效果倒数一回合,Galecrest 的 Stunned 从 3 变成 2(第 5 节)。endTurn 这次没往 log 里加东西,所以“点之后”那张里最后一条还是 But they missed.
  7. playTurn 结束,回到 CBattleScreen.useSkill,执行它的第二行 update()。到这一刻为止,屏幕上一个像素都还没变,变化都只发生在对象里;是 update() 把它们画出来,你才看到“点之后”这 5 条(第 6 节)。

日志里的每一行,是哪一行代码写的

把 battle-stun 的 11 行日志全部对上代码。相同的数字 = 同一行代码写的(同一行代码每回合都跑一次,所以 2–6 号各出现两次):

用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12233445566
  1. 1
    第 1 行日志:构造器第 37–38 行,new Battle 的时候就写好了
  2. 2
    空行:第 79 行 log.add(""),每回合开头一个
  3. 3
    Player's Impike used …!:第 80 行
  4. 4
    你的技能的结果:第 81 行,内容是技能返回的(Wait 的发呆台词、Stun 的被眩晕)
  5. 5
    Enemy Galecrest used …!:第 88 行
  6. 6
    敌人技能的结果:第 89 行(Wait 的台词、Tackle 的 But they missed.)
battle-stun 的日志(放大):第 1 回合是 Wait 对 Wait,第 2 回合是 Stun 对 Tackle。编号一左一右错开,放在日志左边的空白里真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
Battle:会写日志的 6 行(编号和上图相同)battle/Battle.java第 37–38、69–96 行
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)了这个接口:

RandomEnemyStrategy.chooseSkill:能用的里面随机挑一个battle/RandomEnemyStrategy.java第 8–21 行
8/* Picks a random skill out of the ones the enemy has enough energy for. */
9public class RandomEnemyStrategy implements EnemyStrategy {
10
11 @Override
12 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}

CRandomEnemyStrategy 对象是谁 new 的?是 CBattle 的构造器。CBattle 有两个构造器,CEncounter.createBattle(第 9 章)用的是三个参数的那个,它第 26 行用 this(...) 把事情转给五个参数的构造器,同时 new 出策略和随机数(构造器链):

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;
37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
39 }
RandomEnemyStrategy 的 UML 类图
老师打印稿里的 RandomEnemyStrategy:虚线加空心三角指向 EnemyStrategy,意思是 implements(实现接口)。EnemyStrategy 的名字是斜体、圆圈里是 I,表示接口(第 3 章)

为什么不直接在 CBattle 里写“随机挑一个”?因为 CBattle 只认识接口 IEnemyStrategy。以后想要一个更聪明的敌人(比如“能打就打、打不了再 Wait”),只要新写一个实现类,在构造器里传进去,CBattle 一行都不用改。这就是策略模式(Strategy pattern),接口的注释原话也这么说。五个参数的构造器还可以传一个固定种子的 Random,那样每次运行结果都一样,方便测试——不过这只是设计上留的口子:这个项目里没有测试代码(src 下只有 main),现在唯一调用它的就是第 26 行。设计模式的总结在 第 16 章。

4. 技能内部:skill.use 只看轮廓

第 81 行和第 89 行都调用了 use。它写在抽象类 ASkill 里:

Skill.use:先扣能量,再执行效果skill/Skill.java第 27–36 行
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);
skill.use(user, target, random)Skill 里写好的 final 方法① user.spendEnergy(energyCost)打没打中都先扣② applyEffect(...)每种技能自己写抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillStun、Tackle…抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkillAim、Swole…技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWait什么都不做先再各自改写
Skill.use 的两步:① 一定先扣能量;② 交给 applyEffect。② 下面的三个类各自改写(@Override)了 applyEffect,运行时跑的是技能对象真实类型的那一份;连线只表示“这一步由它们写”,不是调用。细节在第 12 章

同一个方法,两个人用:参数换个位置

CBattle 里调用了两次 use:第 81 行是 playerSkill.use(player, enemy, random),第 89 行是 enemySkill.use(enemy, player, random)。Java 按位置把实参交给参数:第一个给 user,第二个给 target。

第 81 行(你出手)第 89 行(敌人出手)
user(用技能的)player:Impikeenemy:Galecrest
target(被用的)enemy:Galecrestplayer:Impike
spendEnergy 扣谁Impike:33 → 29Galecrest:14 → 9
效果落在谁身上Galecrest 被眩晕Impike(这次没打中)

这就是本章最重要的设计点:你和敌人是同一个类 CFighter 的两个对象,技能代码只认“user”和“target”,不认“你”和“敌人”。所以一套技能代码同时处理两边的数据,不用写两份。每个 CFighter 对象都有自己的 stats(HP、能量……)和 effects,user.spendEnergy(4) 只改 user 那一个对象里的数字。

日志那一行是技能自己拼出来的。比如 CStun(上面是 battle-stun 日志的第 8、9 行,下面是拼出第 9 行的代码):

用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12
  1. 1
    第 18 行开头的 "\t":制表符(转义字符),让这一行往里缩一截
  2. 2
    第 19 行的 Stunned.TURNS:常量 3,拼成 “3 turns.”
battle-stun 日志第 8–9 行(放大)真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
Stun.applyHit:加效果,返回一句话(编号和上图相同)skill/Stun.java第 15–20 行
15 @Override
16 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 步:

① 敌人的持续伤害第 117 行② 敌人还活着?第 118 行finish(true)你赢,return③ 你的持续伤害第 123 行④ 你还活着?第 124 行finish(false)你输,return⑤ 两边倒数一回合第 129–130 行活着倒下倒下活着
endTurn 的顺序:先算敌人;谁倒下就 finish 并 return,后面的步骤不再运行。①–⑤ 和下面代码里的红色编号相同

一个短例子:C 局,你的 Nightwing 对 Bubbore。第 2 回合你的 Lance 让 Bubbore 开始流血;第 3 回合你用 Wait、它用 Bunker,两人都站着,进入 endTurn。截图是这一回合打完以后的日志:

Battle.endTurn(红色编号 = 上图的 ①–⑤)battle/Battle.java第 116–131 行
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 }
流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.
1
  1. 1
    第 117 行(①)写进日志的:The bleeding stopped.
C 局第 3 回合的日志(放大,右边裁掉了一点)。最后一行是 endTurn 加的,上面的空行和 4 行是 playTurn 写的。这一回合是流血的第 2 个回合真实运行截图 · 流血第二次扣血之后(从开打算是第 3 回合结束),日志最后一行写 The bleeding stopped.

为什么要先算敌人、为什么倒数放在最后、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)。

两张日志放在一起看,下面是写出它们的代码(相同编号 = 同一行代码写的):

敌人倒下:敌人卷轴变红,弹出 You Win 对话框
123
  1. 1
    第 81 行:Lance 的结果。第 82 行发现 HP 是 0,于是 finish(true),第 84 行 return:敌人没有出手
  2. 2
    第 137–138 行:两个空行
  3. 3
    第 140–141 行:Enemy's Galecrest fainted! 和 You win
赢(A 局,battle-win):日志最后 6 行(放大)。对话框弹出以后整个画面变暗,所以字是灰的真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
425
  1. 4
    第 123 行(Bleeding 返回的):The bleeding stopped.
  2. 2
    又是第 137–138 行的两个空行
  3. 5
    第 143–144 行:Player's Owlet fainted! 和 You lose
输(E 局,battle-lose):日志最后 6 行(放大),同样被对话框压暗了真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
两个调用的地方和 finish 本身(编号和上面两张图相同)battle/Battle.java第 81–85、123–127、133–146 行
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 做三件事:

  1. 第 135 行 this.finished = true:以后 isFinished() 返回 true。第 6 节的 update() 就靠它把按钮变灰、弹出结果框;playTurn 第 70 行也靠它挡住后面的点击。
  2. 第 136 行 this.playerWon = playerWon:参数和字段同名,this.playerWon 指字段,右边的 playerWon 指参数。以后 hasPlayerWon() 用它决定弹 You Win 还是 Game Over。
  3. 第 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 你的能量。

BattleScreen.update:每次都全部对一遍ui/BattleScreen.java第 128–138 行
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 }
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合13
  1. 1
    敌人 HP 1 / 93:只剩一丝
  2. 3
    日志 13 条,最后一条是 Poison 那句
点 Lance 之前(battle-poison)真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
7
  1. 7
    你的能量 25 / 33(原来 29,Lance 花了 4);上面的 HP 225 / 225 没变
update() 以后(battle-win;对话框让整个画面变暗了)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
21
  1. 2
    整个卷轴盖上了红色遮罩(原来是米黄色的)
  2. 1
    敌人 HP 条:0 / 93,空了
update() 以后:敌人卷轴的下半部分(battle-win)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
33
  1. 3
    最上面一行已经是第 8 条(Player's Impike used Stun!),右边的滚动条在最底下
update() 以后的日志框:20 条只放得下 13 行(battle-win)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
4
  1. 4
    三个技能按钮全灰
update() 以后(battle-win)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
5
  1. 5
    三个道具按钮全灰
update() 以后(battle-win)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
6
  1. 6
    You Win 对话框,只有一个按钮 Back to it!
最后弹出的对话框,后面的画面都变暗了(battle-win)真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
用 Stun 命中:敌人被眩晕,3 回合内伤害减半34
  1. 3
    日志:这一回合多了 5 条
  2. 4
    技能按钮:能量够,都亮着
对比:普通回合以后(battle-stun)真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
第 129、130 行调用的 FighterPanel.updateui/FighterPanel.java第 76–80 行
76 public void update() {
77 hpBar.update(fighter.getHp(), fighter.getMaxHp());
78 energyBar.update(fighter.getEnergy(), fighter.getMaxEnergy());
79 scroll.setFainted(!fighter.isAlive());
80 }
第 131 行调用的 showLogui/BattleScreen.java第 141–145 行
141 private void showLog() {
142 logArea.setText(String.join("\n", battle.getLog()));
143 logArea.positionCaret(logArea.getLength());
144 logArea.setScrollTop(Double.MAX_VALUE);
145 }
第 132 行调用的 updateSkillButtonsui/BattleScreen.java第 148–154 行
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 }
第 133 行调用的 updateItemButtonsui/BattleScreen.java第 157–165 行
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 }
第 136 行调用的 showResultui/BattleScreen.java第 170–179 行
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 }
  1. 在截图这个样子时点 Lance [4]。playTurn 跑完那一刻,Galecrest 的 HP 已经是 0,finished 是 true,log 有 20 条,但屏幕还是这个样子,因为还没人去改画面。useSkill 的第二行调用 update()(第 128 行),下面几步就是它的各个部分。
  2. 第 129 行 playerPanel.update()(下面的代码框):每个 CFighterPanel 在 new 的时候就存了自己那个 CFighter。第 77–78 行读出 getHp()、getEnergy() 和它们的最大值,交给两根条:你的能量 29 → 25 / 33(框 7),HP 225 / 225 没变。第 79 行:你还活着,不盖红色遮罩。
  3. 第 130 行 enemyPanel.update():同一个方法、同样三行,只是这个面板存的是敌人的 CFighter。敌人的 HP 是 0,HP 条变空(框 1);第 79 行 isAlive() 是 false,setFainted(!false) 就是 setFainted(true),整个卷轴盖上红色遮罩(框 2)。
  4. 第 131 行 showLog():第 142 行用 String.join("\n", battle.getLog()) 把整个 log 列表用换行连成一段文字,整段放进 logArea(不是只追加新的几条)。第 143–144 行把光标放到最后、滚到最底下,所以你总能看到最新的几行。20 条日志框里只放得下 13 行,前 7 条被滚上去了(框 3)。
  5. 第 132 行 updateSkillButtons():第 151 行给每个技能按钮算一个 usable = 战斗没结束 && 能量够用。现在战斗结束了,!battle.isFinished() 是 false,&& 右边就不看了,三个都是 false;第 152 行 setUsable(false):按钮禁用,整个变淡(框 4)。
  6. 第 133 行 updateItemButtons():第 162 行给每个道具按钮写上背包里还剩几个(getCount),第 163 行再定能不能点:战斗没结束、并且背包里有(has),才亮着。现在战斗结束了,三个都灰(框 5;数量本来也都是 0)。
  7. 第 135 行:战斗结束了,而且现在没有对话框开着,就调用 showResult()。第 171 行先让菜单重新检查一遍:canRunAway() 现在返回 false,Game 菜单里的 Run away 变灰。hasPlayerWon() 是 true,就造一个 You Win 对话框,只有一个按钮 Back to it!,点它会调用 CGameWindow 的 winBattle()(框 6)。输了则走第 177 行,弹 Game Over。对话框盖住整个画面,所以前几步的截图都是暗的。
  8. 对比一个普通回合(点 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):

BattleScreen.updateSkillButtonsui/BattleScreen.java第 147–154 行
147 /* A skill can only be used while the battle is running and there is enough energy. */
148 private void updateSkillButtons() {
149 List<Skill> skills = battle.getPlayer().getSkills();
150 for (int i = 0; i < skillButtons.size(); i++) {
1511 boolean usable = !battle.isFinished() && battle.getPlayer().canUse(skills.get(i));
1522 skillButtons.get(i).setUsable(usable);
153 }
154 }
能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
123
  1. 1
    能量 3 / 12:第 151 行的 canUse 就拿它和每个技能的消耗比
  2. 2
    Tackle [5]:3 ≥ 5 不成立,usable 是 false,第 152 行 setUsable(false),变灰
  3. 3
    Aim [3]:3 ≥ 3 成立,还亮着
B 局:能量剩 3真实运行截图 · 能量只剩 3:Tackle 要 5 点,按钮变灰;Aim 要 3 点,还能用
GameButton.setUsable:禁用加变淡ui/GameButton.java第 50–55 行
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 }
能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
1445
  1. 1
    能量 0 / 12
  2. 4
    Tackle 和 Aim 都灰了:第 52 行 setDisable(true),鼠标点不动,fire() 也不起作用
  3. 5
    Wait [-2]:0 ≥ −2 成立,还能用。第 54 行把它的不透明度设成 1(灰的是 0.4)
B 局:能量用光了,只剩 Wait真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)

所以从界面上永远不会把一个付不起的技能交给 playTurn:鼠标点不动灰按钮,数字键的 fire() 对灰按钮也没用。playTurn 里那道检查是给“万一有别的代码直接调用 playTurn”准备的保险。usable ? 1 : DISABLED_OPACITY 是三元运算符:usable 为 true 取 1,否则取 0.4。为什么灰按钮看起来是 20% 灰,在 第 14 章。

结果对话框:showResult

BattleScreen.showResult(编号和下面两张图相同)ui/BattleScreen.java第 167–179 行
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 }
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    第 174 行:Back to it! → getNavigator().winBattle(),回到地图
赢了:You Win真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
2
  1. 2
    第 177 行:父类 AGameScreen 的 createGameEndDialog("Game Over"),两个按钮 Return to Main Menu 和 Quit
输了:Game Over真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

对话框是一个 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 短得多:

Battle.useItem:没有敌人,没有 endTurnbattle/Battle.java第 98–108 行
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 }
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
12
  1. 1
    第 106 行:空行
  2. 2
    第 107 行:item.use(player, enemy) 返回的一句话
A 局用 Poison 以后的日志(放大,右边被裁掉了):后面没有 Enemy … used真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
3
  1. 3
    第 105 行 inventory.remove(item):Poison 1 → 0,update 以后按钮变灰
道具按钮真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合

画成顺序图,和第 3 节的 playTurn 一比就清楚了:

CBattleScreenCBattleCinventoryCpoison:PoisonCenemy:FighteruseItem(poison)1has(poison):true2remove(poison)3log.add(空行)4use(player, enemy)5reduceHpToOne()6日志文字 → log.add7(返回)8update()9
点 Poison:只有这几步。没有 chooseSkill(敌人不出手),也没有 endTurn(不扣流血、不倒数)

连用两个道具也一样。D 局里你的 Ironshell 先用 Coffee、再用 Health Tonic,中间敌人一次都没动(编号 1、2 和上面意思相同):

用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
1122
  1. 1
    两个空行:每用一次道具,第 106 行加一个
  2. 2
    Coffee 和 Health Tonic 各一行:第 107 行,中间没有敌人的行动
D 局:日志最后 6 行(放大)真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
点技能:playTurn点道具:useItem
开头的检查打完了吗?能量够吗?打完了吗?背包里有吗?
花能量花(Skill.use)不花
会不会失败打对方的技能可能没打中一定成功
敌人出手出手不出手
endTurn(持续伤害、倒数)有没有
往 log 里加几条空行 + 4 条(回合末可能再多)空行 + 1 条

三种道具各自做什么、背包 CInventory 怎么数,在 第 13 章。

8. 总结:控制流、数据流,和 Q1 怎么答

考试说“处理一个回合”,可以从两个角度回答:控制流(control flow)是“谁按什么顺序调用谁”;数据流(data flow)是“数据存在哪、谁改它、谁读它”。两张图分开看:

游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattle接口:决定敌人这一回合用哪个技能。IEnemyStrategybattle抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill正在战斗的 Mokepon:带着会变的 HP、能量、属性和状态效果。CFighterbattleuseSkill(skill)playTurn(skill)chooseSkill(...)use(...) ×2canUse、isAliveapplyLingeringDamagecountDownEffectsspendEnergytakeDamage 等update()getHp() 等
控制流:实线箭头 = 调用,箭头上是方法。界面类在左边,Battle 在中间指挥。Battle 自己还调用自己的 endTurn() 和 finish()
抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill抽象类:所有状态效果的共同部分,主要是还剩几回合。AStatusEffecteffect一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattleFighter:stats 和 effectsInventory.countsBattle.logfinished、playerWon战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。CFighterPanelui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui改改改改改读读读
数据流:实线 = 改数据,虚线 = 读出来显示。HP、能量和效果在 Fighter 里,日志和 finished 在 Battle 里,界面类只读不改

读第二张图:

最后,把第 3 节两个动画里的 4 块拼成一张完整的顺序图。考试要你画“一个回合”时,画这张就够了:

CBattleScreenCBattleCplayer:FighterAskill:SkillCenemy:FighterIenemyStrategyplayTurn(skill)1canUse(skill)2use(player, enemy, random)3spendEnergy(cost)4takeDamage 等5日志文字6isAlive()7chooseSkill(enemy, random)8enemySkill9use(enemy, player, random)10spendEnergy(cost)11takeDamage 等12日志文字13isAlive()14endTurn()15(返回)16update()17
一个回合的完整调用顺序(双方都没倒下)。skill 这一列先是你选的技能对象,后是敌人的技能对象,两次调用的都是 Skill 的同一个 use

样题 Q1:本章讲的是答案里的哪几句

样题问的是:你的设计怎样在同一个回合操作里,处理和玩家、敌人两边数值有关的数据。完整的参考答案一共 10 句,在 第 17 章第 2 节,考前背那一份就够了。其中 6 句讲的就是这一章的内容,下面把它们和本章的节对上(英文和第 17 章一字不差,不用另背一套):

下一章 第 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.

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