Mokepon代码导读
第二站 · 全局L1约 25 分钟

04项目地图:9 个包、72 个类

每个包管什么、包之间谁依赖谁、规则代码和界面代码为什么分开

读完这一章你能
  • 说出 9 个包的名字、每个包管什么,以及 72 个类各在哪个包
  • 指着截图上的任何一块说出两件事:画它的类(都在 ui 包),和它显示的内容来自哪个包
  • 拿出证据说明:只有 ui 包和 CMain 用了 JavaFX,规则代码不认识画面
  • 画出包之间谁 import 谁,并指出哪里是双向的(比如 battle ↔ skill)
  • 认出 8 个继承家族、4 个接口,和 3 个只有 static 成员的类
  • 跟着点一次 Stun,按顺序说出代码经过了哪几个包
  • 看到画面上一个数字,一步一步找到写它的代码

前三章你玩过了游戏(第 1 章),学了项目里用到的 Java(第 2 章),也会读老师打印的 UML(第 3 章)。这一章不钻进任何一个方法的细节,而是先画一张地图:72 个类分在哪 9 个包(package)里,每个包管什么,包和包之间谁用谁。以后每一章讲一个类,你都能在这张地图上找到它的位置。

1. 先看画面:每一块属于哪个包

先说结论:画面上看得见的东西,全部是 ui 包的类画的。可是它们显示的内容——名字、数字、日志里的句子——几乎都来自别的包。下面三张真实截图,每一条说明都分两行写:画:是谁画的(都在 ui);内容:显示的东西从哪个包来。

选择界面

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了12345567
  1. 1
    Game 菜单
    画:ui CGameMenu
    内容:哪一项能点,它去问当前画面(第 6 章)
  2. 2
    名字列表
    画:ui 每个名字一个 CGameButton
    内容:model CMokepon 的 getName();这 36 只是 data CMokeponParser 从数据文件读出来的
  3. 3
    头像
    画:ui portraitView(一个 ImageView)
    内容:model CMokepon 的 getImagePath() 给的图片路径
  4. 4
    名字 Impike
    画:ui nameLabel(一个 Label)
    内容:model 还是 getName()
  5. 5
    火焰图标和下面的 FIRE
    画:ui CSelectScreen 的 typeIcon 和 typeValue
    内容:model 枚举 EMokeponType 的 FIRE;火焰图片的路径存在 data CGameImages
  6. 6
    卷轴上的数值表
    画:ui 一个个 Label
    内容:行名 HP: 是 model EStat 的 getLabel(),数字是 mokepon.getStat(stat);Skills 一行是 skill 三个技能对象的 getName()
  7. 7
    Choose Mokepon 按钮
    画:ui CGameButton
    点了以后:ui CGameWindow 造出 world COverworld
选择界面:画的全是 ui,内容来自 model、data、skill真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

这一屏没有任何“规则”:点一个名字不会改任何数据,CSelectScreen 只是把 CMokepon 对象里存好的值写进几个 Label(整个选择界面在 第 7 章)。

地图界面

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)1222345678
  1. 1
    地图的格子
    画:ui CMapView
    内容:每格是什么地形,由 world CGameMap 回答;地形的种类是 model 枚举 ETerrain
  2. 2
    感叹号(白、红、绿)
    画:ui CMapView 画图,再用 CUiStyle.tint 染色
    内容:world CMarker 对象;颜色只是它的 CSprite 里存的一个字符串(三种颜色的区别在 第 9 章)
  3. 3
    问号箱
    画:ui CMapView
    内容:world CMysteryBox 对象,里面存着一个 item 道具对象 AItem
  4. 4
    出口门
    画:ui CMapView
    内容:world CExit
  5. 5
    玩家图标
    画:ui CMapView 的 playerView
    内容:放在 world CPlayer 的 getPosition() 那一格,起点 (10,18)
  6. 6
    右上卷轴
    画:ui CPlayerStatsScroll
    内容:model CMokepon 给六项数值,world CPlayer 给 Fights,item CInventory 给道具数量
  7. 7
    四个方向按钮
    画:ui 每个方向一个 CGameButton
    内容:方向是 world 枚举 EDirection 的值;能不能点,问 world COverworld 的 canMove(...)
  8. 8
    状态栏(现在是空的)
    画:ui statusBar(一个 Label)
    内容:捡到道具时 COverworldScreen 往里写字;它怎么知道“捡到了”,看第 4 节
地图界面:画的还是 ui,内容来自 world、model、item真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

战斗界面

用 Stun 命中:敌人被眩晕,3 回合内伤害减半1234567
  1. 1
    你这一方的卷轴
    画:ui CFighterPanel(卷轴图片本身由它里面的 CFighterScroll 画)
    内容:battle 一个 CFighter 对象:战斗中的 Impike
  2. 2
    225 / 225 和 29 / 33
    画:ui 两个 CLabelledStatBar
    内容:battle CFighter 的 getHp() / getMaxHp() 和 getEnergy() / getMaxEnergy()
  3. 3
    敌人的卷轴
    画:ui 另一个 CFighterPanel 对象,条上不写数字
    内容:battle 敌人那个 CFighter
  4. 4
    技能按钮
    画:ui 每个技能一个 CGameButton
    内容:skill 技能对象的 getName() 和 getEnergyCost(),拼成 Stun [4]
  5. 5
    道具按钮
    画:ui 每种道具一个 CGameButton
    内容:item CInventory 给数量,CItemCatalogue 给按钮顺序
  6. 6
    日志框
    画:ui logArea(一个 TextArea)
    内容:battle CBattle 存的 log;“was stunned!” 那句是 skill CStun 写的,句子里的 3 是 effect CStunned 的常量(放大的样子见下面“标 6 放大”)
  7. 7
    Game 菜单
    画:ui CGameMenu
    内容:战斗里 Run away 能点,因为 CBattleScreen 的 canRunAway() 返回 true(菜单打开的样子见下面“标 7 放大”)
战斗界面:画的是 ui;数字和句子来自 battle、skill、effect、item真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

战斗界面最能看出分工。比如技能按钮上的字是怎么来的?看下面这段代码,它在 CBattleScreen 里。代码行左边的红色 1、2,就是截图上的红框 1、2:

BattleScreen.createSkillButtons:一个技能一个按钮ui/BattleScreen.java第 181–198 行
181 /* One button per skill, showing its energy cost, e.g. "Bite [1]". */
182 private VBox createSkillButtons(List<Skill> skills) {
183 VBox buttonColumn = new VBox(SKILL_GAP);
184 buttonColumn.setLayoutX(ACTIONS_X);
185 buttonColumn.setLayoutY(SKILLS_Y);
186
187 Font font = UiStyle.loadGameFont(SKILL_FONT_SIZE);
1882 for (Skill skill : skills) {
1891 GameButton button = new GameButton(skill.getName() + " [" + skill.getEnergyCost()
190 + "]", font, BUTTON_RADIUS);
191 button.setPrefSize(SKILL_WIDTH, SKILL_HEIGHT);
192 button.addPressEffect();
193 button.setOnAction(event -> useSkill(skill));
194 skillButtons.add(button);
195 buttonColumn.getChildren().add(button);
196 }
197 return buttonColumn;
198 }
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
12
  1. 1
    第 189 行造的一个按钮:skill.getName() 是 “Stun”,skill.getEnergyCost() 是 4,拼成 Stun [4]
  2. 2
    第 188 行的循环:Impike 有 3 个技能,所以循环 3 次,有 3 个按钮
三个技能按钮:一个 Skill 对象一个按钮真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半

一行一行看:

2. 包是什么:一个文件夹,加上第一行 package

打开任何一个 .java 文件,第一行都写着它属于哪个包。下面是 CBattle 文件的开头,旁边(手机上在下面)是项目的文件夹:

Battle.java 的开头battle/Battle.java第 1–17 行
11package au.edu.unimelb.swen.oop.mokepon.battle;
2
32import au.edu.unimelb.swen.oop.mokepon.item.Inventory;
4import au.edu.unimelb.swen.oop.mokepon.item.Item;
5import au.edu.unimelb.swen.oop.mokepon.skill.Skill;
63import java.util.ArrayList;
7import java.util.Collections;
8import java.util.List;
9import java.util.Random;
10
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 {
154 private final Fighter player;
16 private final Fighter enemy;
17 private final Inventory inventory;

src/main/java 下面的文件夹(9 个包)

module-info.java
au/edu/unimelb/swen/oop/mokepon/
├─ Main.java  根包,1 个
├─ data/      6 个
├─ model/     5 个
├─ world/     15 个
├─ battle/    4 个
│   ├─ Battle.java
│   ├─ EnemyStrategy.java
│   ├─ Fighter.java
│   └─ RandomEnemyStrategy.java
├─ skill/     15 个
├─ effect/    4 个
├─ item/      6 个
└─ ui/        16 个

一个类一个文件,文件名就是类名。包名的每一段就是一层文件夹。

  1. 第 1 行(标 1) package au.edu.unimelb.swen.oop.mokepon.battle;:这个文件属于 battle 包,所以它必须放在 .../mokepon/battle/ 文件夹里。类的全名是包名加类名:au.edu.unimelb.swen.oop.mokepon.battle.Battle。老师打印的 UML 方框里,类名下面那行小字就是包名。
  2. 第 3–5 行(标 2)是 import:要用别的包里的类,先写出它的全名。之后代码里只写短名,比如第 17 行的 Inventory。这 3 行说明:CBattle 用到了 item 包(CInventory、AItem)和 skill 包(ASkill)。
  3. 第 6–9 行(标 3)也是 import,但 java.util 是 Java 自带的包(ArrayList、List、Random……),不是项目的。String、Math 在 java.lang 包,Java 自动导入,所以哪里都看不到它们的 import。
  4. 第 11–13 行是这个类的说明注释,第 14 行 public class Battle { 开始写类本身,下面是它的字段(第 10 章细讲)。
  5. 第 15 行(标 4)用了 CFighter,却没有 import 它:CFighter 和 CBattle 在同一个包,同一个包的类直接用。

所以,一个文件开头的 import 列表,就是“这个类用到了哪些别的包”的清单。第 5 节的依赖图,就是把 72 个文件的 import 一行一行数出来画的。

3. 九个包、72 个类

先看总数。表里“其中”一列用的是 UML 圆圈里的字母:

包个⁠数其中负责什么
mokepon11 C根包,只有程序入口 CMain:读数据、开窗口
data66 C启动时读 mokepon.game,把文字变成对象;还有存图片路径的 CGameImages
model51 C · 4 E不会变的基本数据:一只 Mokepon、属性、地形、类型
world1511 C · 1 A · 2 I · 1 E地图上的一局游戏:走路、地图上的物体、遇敌
battle43 C · 1 I一场战斗的规则:回合、伤害、日志、输赢
skill1511 C · 4 A10 个技能、4 个抽象父类、技能目录
effect43 C · 1 A流血、致盲、眩晕三种状态效果
item65 C · 1 A三种道具、背包、道具目录
ui1614 C · 1 A · 1 I用 JavaFX 画出所有画面,接收点击和按键
合计7255 C · 8 A · 4 I · 5 E

记住三个数就够应付考试:8 个抽象类、4 个接口、5 个枚举,其余 55 个是普通类。下面每个包一张卡片,列出全部类(鼠标放在彩色小块上能看到一句介绍,点一下进类百科)。顺序是从“读文件”到“画到屏幕”。

mokepon 根包 · 1 个

  • CMain:程序入口。继承 JavaFX 的 Application,读数据、解析、开窗口(第 5 章)

data · 6 个:读数据文件

model · 5 个:基本数据

  • CMokepon:一只 Mokepon:名字、图片、类型、六项属性、技能;造好就不变
  • EStat:六项属性 HP、Energy… 和各自的上下限
  • ETerrain:七种地块,每种一个字母
  • ETerrainEffect:优势 ×1.5、中性 ×1、劣势 ×0.5
  • EMokeponType:五种类型;除了 NORMAL,每种有一个强势地形和一个弱势地形

world · 15 个:地图上的一局

battle · 4 个:一场战斗

skill · 15 个:技能

effect · 4 个:状态效果

  • AStatusEffect:抽象父类,记还剩几回合
  • CBleeding:流血,回合末扣血(Lance 造成)
  • CBlinded:致盲,更容易打空(Pocket Sand 造成)
  • CStunned:眩晕,伤害减半(Stun 造成)

item · 6 个:道具

ui · 16 个:画面

4. 分层:规则和界面分开

把 9 个包按“从数据文件到屏幕”的顺序摆开,就是下面这张分层图。读法:方框是包,箭头是“谁用谁”(主要的方向)。

JavaFX 库javafx.*你:看屏幕、点、按键程序入口:读数据文件、解析成对象,然后打开游戏窗口。CMainmokeponui 包(16 个类)画面:用 JavaFXdata 包(6 个)读文件、存图片路径world · battle · skilleffect · item(44 个)mokepon.game(数据文件)model 包(5 个)Mokepon · Stat · Terrain…看 / 点extends Applicationimport javafx.*2. new GameWindow1. parse(data)读造出 GameMap 等造出 Mokepon调用方法、读数据使用
9 个包的分层:箭头 = 主要的使用方向。右边下面两层(规则、model)和左边的 data 都没有一根箭头指向 ui 或 JavaFX

图里为了清楚省掉了几根箭头:ui 也直接用 model(选择界面读 CMokepon)和 data(各个画面从 CGameImages 拿图片路径)。完整的 import 关系在第 5 节。

证据:只有 ui 和 Main import 了 JavaFX

说“规则不认识界面”要有证据。证据就在 import 里。下面第一段是规则类 CBattle 的全部 import(和第 2 节看到的是同一段),第二段是画面类 CBattleScreen 的:

Battle:没有一行 javafxbattle/Battle.java第 1–14 行
1package au.edu.unimelb.swen.oop.mokepon.battle;
2
3import au.edu.unimelb.swen.oop.mokepon.item.Inventory;
4import au.edu.unimelb.swen.oop.mokepon.item.Item;
5import au.edu.unimelb.swen.oop.mokepon.skill.Skill;
6import java.util.ArrayList;
7import java.util.Collections;
8import java.util.List;
9import java.util.Random;
10
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 {
BattleScreen:11 行 javafxui/BattleScreen.java第 1–28 行
1package au.edu.unimelb.swen.oop.mokepon.ui;
2
31import au.edu.unimelb.swen.oop.mokepon.battle.Battle;
4import au.edu.unimelb.swen.oop.mokepon.data.GameImages;
5import au.edu.unimelb.swen.oop.mokepon.item.Inventory;
6import au.edu.unimelb.swen.oop.mokepon.item.Item;
7import au.edu.unimelb.swen.oop.mokepon.item.ItemCatalogue;
8import au.edu.unimelb.swen.oop.mokepon.skill.Skill;
9import java.util.ArrayList;
10import java.util.LinkedHashMap;
11import java.util.List;
12import java.util.Map;
132import javafx.geometry.Pos;
14import javafx.geometry.Rectangle2D;
15import javafx.scene.control.ContentDisplay;
16import javafx.scene.control.Label;
17import javafx.scene.control.TextArea;
18import javafx.scene.input.KeyCode;
19import javafx.scene.input.KeyEvent;
20import javafx.scene.layout.HBox;
21import javafx.scene.layout.VBox;
22import javafx.scene.text.Font;
23import javafx.scene.text.FontWeight;
24
25/* Shows a battle and turns button clicks and key presses into turns or item uses.
26 * All the battle rules live in the Battle class; this class only displays them.
27 */
28public class BattleScreen extends GameScreen {

CBattle 只 import 了项目的 item、skill 和 Java 自带的 java.util。它的注释(第 11–12 行)自己就说了:It knows nothing about the screen. 而 CBattleScreen 第 3 行(标 1)import 了 CBattle,第 13–23 行(从标 2 开始)全是 javafx;它的注释(第 25–26 行)说:All the battle rules live in the Battle class; this class only displays them.

把 72 个文件全部查一遍(在项目里搜 javafx),出现的文件正好是 16 个:CMain,加上 ui 包里除了 IGameNavigator 以外的 15 个类。IGameNavigator 是个接口,只说“画面可以请窗口做哪些事”,用不到任何 JavaFX 部件。另外,没有任何规则包 import ui,整个项目里 import ui 的只有 CMain(它要 new GameWindow)。

为了分开,代码特意做了什么

有些东西天生和画面有关,比如“感叹号是红色的”。看 world 包的 CSprite 怎么存颜色:

Sprite:颜色只是一个字符串world/Sprite.java第 3–22 行
3/* How a map object looks: its image file, and the colour used to tint the image.
4 * The colour is a JavaFX colour string, for example red or #ff0000.
5 * It is only stored here, so this class does not need any JavaFX code.
6 */
7public class Sprite {
8 /* An empty colour means the image is shown as it is. */
9 private static final String NO_TINT = "";
10
11 private final String imagePath;
121 private final String colour;
13
14 /* A sprite that is not tinted, such as a mystery box. */
15 public Sprite(String imagePath) {
162 this(imagePath, NO_TINT);
17 }
18
19 public Sprite(String imagePath, String colour) {
20 this.imagePath = imagePath;
21 this.colour = colour;
22 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1112
  1. 1
    第 12 行:有颜色的物体,白色感叹号、红色感叹号、品红色的门。colour 字段里存的只是数据文件里的文字 white、red、magenta
  2. 2
    第 16 行:问号箱没有颜色。它用的是只有一个参数的构造器,颜色是空字符串 NO_TINT,按原图显示
地图左上角:三个染了色的物体,一个没染色的问号箱真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

CSprite 的注释第 5 行写明:It is only stored here, so this class does not need any JavaFX code. 它存的是字符串 "red",不是 JavaFX 的 Color。第 9 行的常量 NO_TINT 是空字符串 "",表示“不染色”。

真正把图片染红的,是 ui 包 CMapView 第 79 行调用的 UiStyle.tint(objectView, sprite.getColour()):

ui/MapView.java第 73–82 行
73 /* Markers and the exit are tinted with their colour; mystery boxes are not. */
74 private ImageView createObjectView(MapObject object) {
75 Sprite sprite = object.getSprite();
76 ImageView objectView = createTileView(getImage(sprite.getImagePath()),
77 object.getPosition());
78 if (sprite.hasTint()) {
79 UiStyle.tint(objectView, sprite.getColour());
80 }
81 return objectView;
82 }

第 75 行先拿到物体的 CSprite;第 76–77 行用它的图片路径造一个 ImageView,放到物体所在的格子;第 78–80 行:hasTint() 说有颜色,才调用 CUiStyle.tint 染色,问号箱就跳过。颜色的文字从 world 来,染色这件事在 ui 做。

同样的做法还有两处:

world 包(规则)ui 包(画面)一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenuilistenersimplementsaddListener(...)world.move(...)
观察者(Observer):Overworld 用字段 listeners 存着一串 OverworldListener(空心菱形 = 用字段存着)。OverworldScreen 实现这个接口(虚线空心三角),GameWindow 开局时用 addListener(...) 把画面登记进去。跨包的箭头都从 ui 指向 world,world 包里没有一根箭头指向 ui
OverworldListener:world 包自己的接口world/OverworldListener.java第 1–12、17–18、24–25 行
1package au.edu.unimelb.swen.oop.mokepon.world;
2
3import au.edu.unimelb.swen.oop.mokepon.item.Item;
4import au.edu.unimelb.swen.oop.mokepon.model.Mokepon;
5
6/* Observer: the things that happen in the overworld which a listener (such as a screen)
7 * may want to show. The overworld calls these methods; it does not know what they do.
8 */
9public interface OverworldListener {
10
11 /* The player is now on a different tile. */
12 void onPlayerMoved();
⋯ 省略第 13–16 行
17 /* The player picked up an item from a mystery box. */
18 void onItemCollected(Item item);
⋯ 省略第 19–23 行
24 void onExitReached(int fightsWon);
25}

连起来就是:COverworldScreen 写着 implements OverworldListener,实现了这几个方法;CGameWindow 开局时 world.addListener(overworldScreen),把画面放进 COverworld 的 listeners 列表;捡到箱子时,COverworld 只管调用 listener.onItemCollected(...),不知道对面是个画面。import 的方向是 ui → world,运行时的通知却是 world → ui,靠的就是中间这个接口。这就是观察者模式(Observer),也是依赖倒置(Dependency Inversion)的例子(第 8 章、第 16 章)。第 9 节的动画会一步一步走这条路。

分开有什么好处

  1. 规则可以不开窗口就运行和测试。看 CBattle 的两个构造器:
    battle/Battle.java第 24–36 行
    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 }
    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;
    第 25–27 行是游戏用的那个:只要三样东西,第 26 行用 this(...) 调用下面那个构造器,补上 new RandomEnemyStrategy() 和 new Random()。第 29–31 行那个能自己传入 EnemyStrategy 和 Random(注释:with a chosen enemy strategy and random number generator)。测试时传一个带种子(seed)的 new Random(42):种子一样,随机数的顺序就一样,每次打中没打中都一样,再检查 getLog() 就行。COverworld 也有一个能传 Random 的构造器。这份提交里没有写测试,但规则类已经可以这样测。
  2. 改画面不会碰坏规则。想换按钮的样子、换整个界面,只改 ui;伤害公式、命中率一行都不用动。反过来,改规则也不用动画面的布局。
  3. 每个类只有一个理由要改(单一职责原则 SRP):CBattle 只因为规则改而改,CBattleScreen 只因为显示改而改。

5. 包之间谁依赖谁

把每个文件的 import 数一遍(同一个包的不算,java.* 和 javafx.* 也不算),就得到这张表。“import 了”一列是这个包用到的项目包,“被谁 import”是反过来,谁用到了它:

包import 了被谁 import例子(谁 import 谁)
mokepondata、model、world、ui没有(JavaFX 启动它)CMain → CMokeponParser、CGameMap、CGameWindow
datamodel、skill、item、worldmokepon、uiCMapParser → CMarker、CItemCatalogue;CMokeponParser → CSkillCatalogue
modelskillmokepon、data、world、battle、skill、uiCMokepon → ASkill(它存着技能列表)
worldbattle、item、modelmokepon、data、uiCEncounter → CBattle、CFighter;COverworld → CInventory
battleskill、effect、item、modelworld、skill、effect、item、uiCBattle → ASkill、AItem;CFighter → AStatusEffect
skillbattle、effect、modelbattle、model、data、uiASkill → CFighter;CLance → CBleeding;ABoostSkill → EStat
effectbattlebattle、skillAStatusEffect、CBleeding → CFighter
itembattlebattle、world、data、uiAItem 和三个道具 → CFighter
uibattle、data、item、model、skill、world只有 mokeponCBattleScreen → CBattle;CMapView → COverworld;CGameWindow → CGameImages

先画里面六个包(规则包和 model)之间的 import。读法:虚线箭头 A ⇢ B 表示 A 包里有类 import 了 B 包的类,线上写的是被 import 的类。两个包之间有两根弯线,就是互相 import。

world 包item 包model 包battle 包effect 包skill 包Inventory、Item、ItemCatalogueBattle、FighterMokepon、Terrain…Mokepon、Stat、TerrainEffectInventory、ItemFighterStatusEffectFighterSkillFighterSkillStatBleeding、Blinded、Stunned
六个包之间的 import(虚线箭头 = 依赖 dependency)。battle 分别和 skill、effect、item 互相 import;model 和 skill 也互相 import;skill → effect 只是单向;item 和 skill、effect 之间没有 import

再把外面三个包(mokepon 根包、ui、data)加上。为了不乱,里面那六个包画成一个大框:

程序入口:读数据文件、解析成对象,然后打开游戏窗口。CMainmokeponui 包data 包world · battle · skill · effect · item · model(上一张图里的 6 个包)GameWindowGameImagesGameData、GameDataReader、3 个解析器、GameImagesMokepon、GameMapbattle、item、model、skill、worlditem、model、skill、world
外面三个包 import 了谁:箭头全都从外面指向里面。里面的 6 个包没有一根箭头指回 ui、data 或 Main

从表和两张图里读出四件事:

  1. 没有规则包 import ui。“被谁 import”那一列,ui 只写着 mokepon。这是整个项目最重要的一条线:依赖只从画面指向规则,不会反过来。
  2. data 只被 CMain 和 ui 用。CMain 在启动时调用解析器;ui 只用它的 CGameImages 拿图片路径。游戏开始以后,规则包再也不碰数据文件。
  3. battle 分别和 skill、effect、item 互相 import,中心是 CFighter。这是三对双向的;另外 skill → effect 只是单向(CLance、CPocketSand、CStun 要 new 出效果对象),item 和 skill、effect 之间没有 import。为什么是双向?技能的 use(Fighter user, Fighter target, Random random)、道具的 use(Fighter player, Fighter enemy)、效果的 applyLingeringDamage(Fighter owner) 都要一个 CFighter;反过来 CFighter 要存 AStatusEffect 列表、回答 canUse(Skill skill),CBattle 要用 ASkill 和 AItem。四个包就通过 battle 的 CFighter 绕成一团。这叫双向依赖。
  4. model 也不是最底层。几乎所有包都用 model,可 model 自己也 import 了 skill:CMokepon 存着 List<Skill>。

6. 继承家族总览

72 个类里,很多是“一个父类带几个子类”。一共 8 个家族:ASkill(14 个类);AStatusEffect、AItem、AMapObject、AGameScreen(各带 3 个子类);CStatBar → CLabelledStatBar;JavaFX 的 Button → CGameButton;JavaFX 的 Application → CMain。8 个抽象类全是前 5 个家族里的父类。认出这些家族,打印的 UML 就好读了:家族里的子类往往只有一两个方法,因为大部分代码写在父类里。图里空心三角箭头指向父类(extends),虚线 + 空心三角指向接口(implements)。

技能家族:14 个类,最多 4 层

抽象类:所有技能的共同部分:名字、能量费用和 use()。ASkillskill抽象类:对准对手的技能,必须先通过命中检查才生效。ATargetedSkillskill抽象类:一定成功、给自己某项属性加 1 到 10 点的强化技能。ABoostSkillskill技能 Wait:什么都不做;费用是 -2,所以反而回 2 点能量。CWaitskill技能 Stun:花 4 点能量,没有伤害,但让目标伤害减半 3 回合。CStunskill抽象类:命中后造成伤害的攻击技能,彼此只差威力 power。AAttackSkillskill技能 Pocket Sand:花 4 点能量,没有伤害,但让目标致盲 3 回合。CPocketSandskill技能 Aim:花 3 点能量,提高自己的命中(Accuracy)。CAimskill技能 Bunker:花 3 点能量,提高自己的防御(Defence)。CBunkerskill技能 Nimble:花 3 点能量,提高自己的闪避(Dodge)。CNimbleskill技能 Swole:花 3 点能量,提高自己的力量(Strength)。CSwoleskill技能 Bite:花 1 点能量的普通攻击,威力 1。CBiteskill技能 Tackle:花 5 点能量的重击,威力 2(双倍伤害)。CTackleskill技能 Lance:花 4 点能量,用一半力量攻击,并让目标流血。CLanceskillBattle 只调用Skill 的 use(...)不知道是哪一个技能
skill 包的继承树(左边是父类,空心三角指向父类)。A 是抽象类:Skill、TargetedSkill、AttackSkill、BoostSkill;其余 10 个是能用的技能。最长的一条:Skill → TargetedSkill → AttackSkill → Bite,共 4 层

一句话记三层父类:ASkill 管“先扣能量再生效”;ATargetedSkill 管“先命中检查”;AAttackSkill 管“打中就扣血”。所以 CBite 和 CTackle 只剩一个构造器,只写了名字、能量和威力。这是模板方法(Template Method),第 12 章细讲。

另外三个“抽象父类 + 三个子类”

抽象类:所有状态效果的共同部分,主要是还剩几回合。AStatusEffecteffect流血效果:每回合结束按攻击者一半力量扣血,持续 2 回合。CBleedingeffect致盲效果:目标性技能额外有 50% 机会打空,持续 3 回合。CBlindedeffect眩晕效果:被眩晕的一方造成的伤害减半,持续 3 回合。CStunnedeffect
effect 包:Fighter 对每个效果都调用同样的方法(getDamageMultiplier、getMissChance、applyLingeringDamage、countDown、isOver),子类只改写自己要改的
抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem道具 Health Tonic:把玩家 Mokepon 的 HP 回满。CHealthTonicitem道具 Coffee:把玩家 Mokepon 的能量补满。CCoffeeitem道具 Poison:把敌人的 HP 直接降到 1。CPoisonitem
item 包:Battle.useItem 只调用 item.use(...)
抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld神秘箱子(问号):里面有一个道具,踩上去就捡到。CMysteryBoxworld出口门:踩上去游戏就结束。CExitworld
world 包:Overworld.move 只调用 object.interact(this)

ui 包和 JavaFX 的父类

抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui彩色进度条:显示 HP 或能量还剩多少。CStatBaruiButtonJavaFXApplicationJavaFX带数字的属性条:在条上写出 “225 / 225” 这样的数字。CLabelledStatBarui游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonui程序入口:读数据文件、解析成对象,然后打开游戏窗口。CMainmokepon
上面:三个画面都继承抽象类 GameScreen。下面:StatBar → LabelledStatBar;两个类继承 JavaFX 的类(没有字母圆圈的框,不是项目的类)

AGameScreen 家族和窗口怎么换画面,在 第 6 章。打印稿上也能看到这种“继承外面的类”:CMain 的 UML 图里,父类方框就是 JavaFX 的 Application。

Main 的 UML 类图
打印稿里的 Main:空心三角指向 JavaFX 的 Application(斜体 = 抽象类,下面一行小字 javafx.application 是它的包)。名字前:绿色圆点 = public;main 带下划线 = static

注意 CStatBar 不是抽象类:敌人的血条就是普通的 CStatBar 对象,你的血条是子类 CLabelledStatBar 的对象(多了数字)。CFighterPanel 的字段 hpBar 声明成 CStatBar,两种都能放(第 14 章)。

4 个接口和实现它们的类

一场战斗的规则:双方轮流出招、用道具、记日志、判输赢。CBattlebattleMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld接口:决定敌人这一回合用哪个技能。IEnemyStrategybattle接口:决定在某个标记上遇到哪只 Mokepon。IEnemyPickerworldEnemyStrategy 的实现:从能量够用的技能里随机挑一个。CRandomEnemyStrategybattleEnemyPicker 的实现:从全部 Mokepon 里随便挑一只,不管地形。CRandomEnemyPickerworldEnemyPicker 的实现:挑一只类型在这块地形上有指定效果的 Mokepon。CTerrainEnemyPickerworldenemyStrategypicker
两个策略接口(Strategy):上面是用它的类,空心菱形 = 用字段存着;虚线空心三角 = implements

CBattle 只认识 IEnemyStrategy 接口,CMarker 只认识 IEnemyPicker 接口;换一种挑法只要换一个实现类。数据文件里每一组感叹号有自己的颜色和自己的 picker,三组的区别在 第 9 章。

一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenui接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld接口:界面和菜单可以请窗口做的事,主要是切换界面。IGameNavigatorui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowuilistenersnavigator
另外两个接口:OverworldListener 在 world 包,GameNavigator 在 ui 包

IOverworldListener 第 4 节已经见过(第 8 章细讲)。IGameNavigator 是画面请窗口“换画面、开新游戏”的接口:画面只认识这个接口,真正干活的是 CGameWindow(第 6 章)。

每个家族、每个接口,都有一个“只认识父类型”的地方。先用第一行说清楚这是什么意思:CBattle 的 playTurn 方法,参数写的是 Skill playerSkill,变量的类型是父类 ASkill。你点 Stun 时,传进来的其实是一个 CStun 对象,playerSkill.use(...) 运行的是这个对象自己那一套代码:ASkill 的 use,接着是 ATargetedSkill 的命中检查和 CStun 的 applyHit。点 Lance 时还是同一行代码,运行的却是 Lance 的版本。CBattle 不用知道是哪一个技能,这就是多态(polymorphism)(第 12 章)。考试问多态时,这张表就是证据:

父类 / 接口子类 / 实现只认识父类型的那行代码
ASkill10 个技能CBattle.playTurn:playerSkill.use(player, enemy, random)
AStatusEffect3 个效果CFighter:effect.getDamageMultiplier() 等
AItem3 个道具CBattle.useItem:item.use(player, enemy)
AMapObject3 种物体COverworld.move:object.interact(this)
AGameScreen3 个画面CGameWindow:currentScreen.handleKeyPress(event);CGameMenu.update(screen)
CStatBarCLabelledStatBarCFighterPanel.update:hpBar.update(...)
IEnemyStrategyCRandomEnemyStrategyCBattle:enemyStrategy.chooseSkill(enemy, random)
IEnemyPicker2 个 pickerCMarker:picker.pick(...)
IOverworldListenerCOverworldScreenCOverworld:listener.onItemCollected(...) 等
IGameNavigatorCGameWindowCSelectScreen:getNavigator().startGame(...);CGameMenu:navigator.runAway() 等

7. 只有 static 成员的类,和解析器

有三个类和别的都不一样:它们的字段和方法全是 static 的,程序从来不 new 它们,直接写“类名.方法名”来用。这种类叫静态目录类或工具类。

SkillCatalogue:按名字找技能

SkillCatalogue:10 个技能对象各一个skill/SkillCatalogue.java第 5–24 行
5/* Every skill in the game. The data file only gives skill names, and this class
6 * finds the skill object with that name. Skills never change after they are made,
7 * so all Mokepon can share the same skill objects.
8 * To add a new skill, write a new subclass and add one object of it to this list.
9 */
10public class SkillCatalogue {
11 private static final List<Skill> SKILLS = List.of(
12 new Bite(), new Tackle(), new Lance(), new Stun(), new PocketSand(),
13 new Bunker(), new Aim(), new Swole(), new Nimble(), new Wait());
14
15 /* Find a skill by its exact, case-sensitive name. */
16 public static Skill find(String name) {
17 for (Skill skill : SKILLS) {
18 if (skill.getName().equals(name)) {
19 return skill;
20 }
21 }
22 throw new IllegalArgumentException("Unknown skill: " + name);
23 }
24}

只有 CMokeponParser 调用它:数据文件里写的是 Lance|Stun|Wait 这样的名字,要换成技能对象。所有会 Wait 的 Mokepon 共用同一个 CWait 对象——这没问题,因为技能对象的字段都是 final,造好就不变。

SkillCatalogue 的 UML 类图
打印稿里的 SkillCatalogue:两行都带下划线 = static。红色方块 = private(SKILLS 列表),绿色圆点 = public(find 方法)

ItemCatalogue:同样三个道具,两种顺序

CItemCatalogue(item)里三个道具各一个对象。它有两个列表,屏幕上真的能看出两种顺序:

ItemCatalogue:ITEMS 和 BATTLE_ORDERitem/ItemCatalogue.java第 9–25 行
9public class ItemCatalogue {
10 private static final Item HEALTH_TONIC = new HealthTonic();
11 private static final Item COFFEE = new Coffee();
12 private static final Item POISON = new Poison();
13
14 /* All items, in the order of the overworld scroll. */
151 private static final List<Item> ITEMS = List.of(HEALTH_TONIC, COFFEE, POISON);
16 /* The battle screen shows its item buttons in another order, as in the sample screenshot. */
172 private static final List<Item> BATTLE_ORDER = List.of(HEALTH_TONIC, POISON, COFFEE);
18
19 public static List<Item> getAll() {
20 return ITEMS;
21 }
22
23 public static List<Item> getBattleOrder() {
24 return BATTLE_ORDER;
25 }

另外还有 find("POISON")(第 28–35 行),CMapParser 用它给问号箱放道具。因为每种道具只有一个对象,CInventory 可以直接拿道具对象当 Map 的键。

UiStyle:画面共用的样式

CUiStyle(ui)有 3 个公开常量 GAME_WIDTH(1280)、GAME_HEIGHT(768)、SCROLL_FILE,和 11 个 static 方法,比如 loadImage、loadGameFont、createLabel、tint。所有画面共用,样式只写一处。

解析器:只在启动时用一次

data 包里四个干活的类各读数据文件的一部分:CGameDataReader 打开文件得到 CGameData,CMokeponParser、CMapParser、CImageParser 各有一个 parse(GameData data) 方法。它们不是 static 类,但也没有会变的字段(只有 static 的常量,比如 PREFIX = "mokepon."),CMain 每个 new 一次、调用一次就不再用了。代码行的 1、2、3 对应下面三张图:

Main.start:三个解析器各造一样东西Main.java第 21–40 行
21 @Override
22 public void start(Stage stage) {
23 GameData data;
24 try {
25 data = new GameDataReader().read(getParameters().getRaw());
26 } catch (IOException | IllegalArgumentException e) {
27 /* Stop before opening the game if the data cannot be loaded,
28 * for example "Error loading mokepon.game".
29 */
30 System.out.println("Error loading " + e.getMessage());
31 Platform.exit();
32 return;
33 }
34
351 List<Mokepon> mokepons = new MokeponParser().parse(data);
362 GameMap map = new MapParser().parse(data);
373 GameImages images = new ImageParser().parse(data);
38 GameWindow gameWindow = new GameWindow(stage, mokepons, map, images);
39 gameWindow.show();
40 }

每个解析器只管一部分文件,这也是单一职责:数据文件里 Mokepon 那一段的格式变了,只改 CMokeponParser。启动的全过程在 第 5 章。

8. 跟着一次点击穿过各层

现在把前面几节连起来:在战斗里点一下 Stun [4],代码按什么顺序经过哪些包?用的是真实的一回合:你的 Impike 对敌人 Galecrest 用了 Stun。截图 battle-wait 是点之前(上一回合双方都用了 Wait),battle-stun 是点之后。

怎么看下面这个动画:最上面的图和第 4 节的分层图一样,上面是你和 ui,中间是规则包,最下面是 model。每个方框是一个包,亮起来的包 = 这一步正在运行的代码所在的包,变红的虚线 = 这一步跨包的调用。中间是那一步的代码(正在运行的行标成红底),下面是截图。截图上的编号:1 Stun 按钮,2 你的能量,3 日志里你的两行,4 日志里敌人的两行,5 敌人的能量条。

你:点 Stun [4]亮框 = 正在运行的包红色虚线 = 跨包调用ui 包BattleScreen · GameButtonJavaFXButton · TextAreabattle 包Battle · Fighterskill 包Skill · TargetedSkill · Stuneffect 包Stunned · StatusEffectmodel 包Stat点击运行 lambdasetText(...) 等playTurn(skill)之后 getLog() 等use(...)调 Fighter 的方法Stat.ACCURACYnew Stunned()countDown()
BattleScreen(ui)ui/BattleScreen.java第 117–120、188–194 行
117 private void useSkill(Skill skill) {
118 battle.playTurn(skill);
119 update();
120 }
⋯ 省略第 121–187 行
188 for (Skill skill : skills) {
189 GameButton button = new GameButton(skill.getName() + " [" + skill.getEnergyCost()
190 + "]", font, BUTTON_RADIUS);
191 button.setPrefSize(SKILL_WIDTH, SKILL_HEIGHT);
192 button.addPressEffect();
193 button.setOnAction(event -> useSkill(skill));
194 skillButtons.add(button);
Battle.playTurn 前半(battle)battle/Battle.java第 69–81 行
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
79 log.add("");
80 log.add("Player's " + player.getName() + " used " + playerSkill.getName() + "!");
81 log.add(playerSkill.use(player, enemy, random));
Skill.use(skill)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) {
31 user.spendEnergy(energyCost);
32 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);
TargetedSkill:命中检查(skill)skill/TargetedSkill.java第 18–22、31–35、38–43 行
18 protected final String applyEffect(Fighter user, Fighter target, Random random) {
19 if (!hits(user, target, random)) {
20 return MISS_MESSAGE;
21 }
22 return applyHit(user, target, random);
⋯ 省略第 23–30 行
31 private boolean hits(Fighter user, Fighter target, Random random) {
32 if (random.nextDouble() >= calculateHitChance(user, target)) {
33 return false;
34 }
35 return random.nextDouble() >= user.getMissChance();
⋯ 省略第 36–37 行
38 private double calculateHitChance(Fighter user, Fighter target) {
39 int accuracy = user.getStat(Stat.ACCURACY);
40 int dodge = target.getStat(Stat.DODGE);
41 double accuracySquared = accuracy * accuracy;
42 double dodgeSquared = dodge * dodge;
43 return accuracySquared / (accuracySquared + dodgeSquared);
Stun.applyHit(skill)skill/Stun.java第 16–19 行
16 protected String applyHit(Fighter user, Fighter target, Random random) {
17 target.addEffect(new Stunned());
18 return "\t" + target.getName() + " was stunned! Their damage is halved for "
19 + Stunned.TURNS + " turns.";
Stunned(effect)effect/Stunned.java第 3–10 行
3/* A stunned fighter only does half of its normal damage. */
4public class Stunned extends StatusEffect {
5 public static final int TURNS = 3;
6 private static final double DAMAGE_MULTIPLIER = 0.5;
7
8 public Stunned() {
9 super(TURNS);
10 }
Battle.playTurn 后半(battle)battle/Battle.java第 82–95 行
82 if (!enemy.isAlive()) {
83 finish(true);
84 return;
85 }
86
87 Skill enemySkill = enemyStrategy.chooseSkill(enemy, random);
88 log.add("Enemy " + enemy.getName() + " used " + enemySkill.getName() + "!");
89 log.add(enemySkill.use(enemy, player, random));
90 if (!player.isAlive()) {
91 finish(false);
92 return;
93 }
94
95 endTurn();
Battle.endTurn(battle)battle/Battle.java第 116–131 行
116 private void endTurn() {
117 log.addAll(enemy.applyLingeringDamage());
118 if (!enemy.isAlive()) {
119 finish(true);
120 return;
121 }
122
123 log.addAll(player.applyLingeringDamage());
124 if (!player.isAlive()) {
125 finish(false);
126 return;
127 }
128
129 enemy.countDownEffects();
130 player.countDownEffects();
131 }
BattleScreen.update 和 showLog(ui)ui/BattleScreen.java第 128–133、140–145 行
128 private void update() {
129 playerPanel.update();
130 enemyPanel.update();
131 showLog();
132 updateSkillButtons();
133 updateItemButtons();
⋯ 省略第 134–139 行
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 }
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
1
  1. 1
    Stun [4] 按钮:你点的就是它
点之前(battle-wait)真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
2
  1. 2
    点之前:能量 33 / 33。上一回合的 Wait 会回 2 点能量,可能量本来就是满的,最多只到 33
点之前:你的血和能量真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
34
  1. 3
    你的两行:CBattle 写的 Player's Impike used Stun!,和 CStun 返回的那句
  2. 4
    敌人的两行:CBattle 写的 Enemy Galecrest used Tackle!,和 ATargetedSkill 返回的 But they missed.
点之后(battle-stun):日志的最后四行真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
2
  1. 2
    点之后:能量 29 / 33
点之后:你的能量真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
5
  1. 5
    点之后:敌人的蓝条只剩 9/14 ≈ 64%。敌人的条不写数字
点之后:敌人的血条和能量条真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
  1. 你点 Stun [4](红框 1)。这个按钮是 ui 包的 CGameButton,父类是 JavaFX 的 Button。造按钮时(第 193 行)给它登记了一段 lambda:event -> useSkill(skill),意思是“被点的时候,运行 useSkill(skill)”。发现点击的是 JavaFX,它就运行这段代码;这里的 skill 是造按钮时记住的那个 CStun 对象(skill 包)。
  2. 运行 useSkill,它只有两行。第 118 行 battle.playTurn(skill):从 ui 跨进 battle。CBattleScreen 自己不判断能量够不够、打没打中,这些全是 CBattle 的事。
  3. CBattle.playTurn:第 70 行,战斗没结束。第 74 行 !player.canUse(playerSkill) 问你的 CFighter 能量够不够(! 是“不”):33 ≥ 4,够,所以不进 if。第 79–80 行往 log 加一个空行和 Player's Impike used Stun!。第 81 行 log.add(playerSkill.use(player, enemy, random)):use(...) 会返回一句话(String),外面的 log.add(...) 把这句话存进日志,第 6 步 Stun 返回的那句就是这样进日志的。
  4. 进入 skill 包。use 写在父类 ASkill 里,而且是 final(子类不能改写)。第 31 行 user.spendEnergy(energyCost) 回头调用 battle 的 CFighter:能量 33 − 4 = 29。第 32 行调用 applyEffect,它返回的句子就是 use 返回的句子。注意红框 2 还是 33 / 33:数据已经变了,画面要等第 10 步才更新。
  5. CStun 是对准对手的技能,applyEffect 由中间那层 ATargetedSkill 写好:先命中检查(第 19 行)。calculateHitChance 用 model 包的 Stat.ACCURACY 和 Stat.DODGE 取出两个数:战斗里 Impike 的 Accuracy 是 100,Galecrest 的 Dodge 是 40(为什么不是选择界面上的原始值:Impike 的 74、Galecrest 的 79?看动画下面的说明)。100² ÷ (100² + 40²) = 10000 ÷ 11600 ≈ 0.86;第 32 行的随机小数小于 0.86 就算中。Impike 没被致盲,第 35 行的 getMissChance() 是 0,第二道检查一定过。这次中了,第 22 行调用 applyHit。
  6. CStun.applyHit 第 17 行:new Stunned() 造出一个 effect 包的对象,它的构造器第 9 行 super(TURNS) 把 TURNS = 3 交给父类 AStatusEffect,记下还剩 3 回合。target.addEffect(...) 把它放进 Galecrest 那个 CFighter 的 effects 列表(battle)。第 18–19 行返回日志句子 Galecrest was stunned! Their damage is halved for 3 turns.,句子里的 3 就是 Stunned.TURNS。
  7. 回到 playTurn。第 82 行:敌人还活着,轮到它。第 87 行 enemyStrategy.chooseSkill(enemy, random),CRandomEnemyStrategy(也在 battle)从能量够用的技能里随便挑,这次挑了 Tackle,第 88 行记下 Enemy Galecrest used Tackle!。第 89 行 enemySkill.use(...) 又进 skill:先扣 5 点能量(14 → 9),再命中检查:Galecrest 的 Accuracy 43,Impike 的 Dodge 100,43² ÷ (43² + 100²) = 1849 ÷ 11849 ≈ 0.16。没中,返回 But they missed.。
  8. 两边都还站着,playTurn 第 95 行调用 endTurn()。第 117、123 行先让效果造成持续伤害:CStunned 没改写 applyLingeringDamage,用父类 AStatusEffect 的版本,什么都不做。第 129–130 行 countDownEffects():CFighter 对每个效果调用 countDown(),眩晕 3 → 2 回合。
  9. playTurn 结束,回到 CBattleScreen.useSkill 第 119 行。到现在为止,battle、skill、effect 里没有一行代码碰过屏幕——它们根本没有 import ui 或 javafx。屏幕上还是旧样子。
  10. 第 119 行调用 update():CBattleScreen 回头问 CBattle 现在的样子,再交给 JavaFX 画。playerPanel.update() 把你的能量改成 29 / 33(红框 2);enemyPanel.update() 把敌人的蓝条缩短到 9/14 ≈ 64%(红框 5,敌人的条不写数字);showLog() 把 battle.getLog() 的全部句子用换行连起来,放进 logArea(红框 3、4);第 132–133 行再更新按钮能不能点。规则包从来不去“推”画面,是 ui 自己回头“拉”数据。

整条路是:ui → battle → skill(在这里又用到 model 的 EStat、造出 effect 的 CStunned、回头调用 battle 的 CFighter)→ 回到 battle(敌人回合、endTurn)→ 回到 ui。调用从 ui 出发,最后也是 ui 自己去读结果。每一步的细节在 第 11 章(回合)、第 12 章(技能)、第 13 章(效果)。

9. 看到一个东西,怎么找到它的代码

考试时你手上只有 UML,复习时你有代码。看到画面上一个东西,想知道“哪段代码管它”,按这几步找:

  1. 认画面:选择 / 地图 / 战斗,分别是 CSelectScreen / COverworldScreen / CBattleScreen(都在 ui)。
  2. 在画面类的字段里找它。小部件可能在下一层(地图卷轴是 statsScroll,类型 CPlayerStatsScroll)。本站截图标注的名字,比如 OverworldScreen.statsScroll.itemValues[Poison],就是字段一层一层的路径。
  3. 找把值写上去的方法:通常叫 update,里面有 setText / setProgress。
  4. 看值从哪个对象读来:那个对象的类就是规则或数据所在的包。
  5. 再找谁改了这个值、谁通知了画面:在项目里搜方法名,一层一层往回找。

下面用真实截图走一遍:你在地图上按 ↑,踩上 (18,16) 的问号箱,卷轴里的 Poison 从 0 变成 1。最上面的顺序图(sequence diagram)是真正的调用顺序(从上往下);动画从最下面那条开始,一条一条往上找。截图两张一组:前一张是走上箱子之前(walk-09),后一张是之后(box-collected)。编号:1 卷轴里 Poison 那一行,2 状态栏。

CPlayerStatsScrollCOverworldScreenCOverworldCMysteryBox按 ↑ 以后 move(UP)1interact(this)2collect(this)3player.getInventory().add(...)4onItemCollected(item)5statusBar.setText(...)6statsScroll.update()7
捡到问号箱时真正的调用顺序(从上往下读)。找代码时从最下面往上走
OverworldScreen(ui)ui/OverworldScreen.java第 49–53、96–100、134–136 行
49 private final Overworld world;
50 private final MapView mapView;
51 private final PlayerStatsScroll statsScroll;
52 private final Label statusBar;
53 private final Map<Direction, GameButton> moveButtons = new EnumMap<>(Direction.class);
⋯ 省略第 54–95 行
96 @Override
97 public void onItemCollected(Item item) {
98 statusBar.setText("You picked up a " + item.getName() + "!");
99 update();
100 }
⋯ 省略第 101–133 行
134 private void update() {
135 mapView.update();
136 statsScroll.update();
PlayerStatsScroll(ui)ui/PlayerStatsScroll.java第 34–35、62–66、81–87 行
34 private final Label fightsValue;
35 private final Map<Item, Label> itemValues = new LinkedHashMap<>();
⋯ 省略第 36–61 行
62 for (Item item : player.getInventory().getItems()) {
63 Label count = createLabel("", numberFont);
64 itemValues.put(item, count);
65 addRow(grid, row++, item.getName() + ":", count);
66 }
⋯ 省略第 67–80 行
81 public void update() {
82 fightsValue.setText(String.valueOf(player.getFightsWon()));
83 for (Map.Entry<Item, Label> entry : itemValues.entrySet()) {
84 int count = player.getInventory().getCount(entry.getKey());
85 entry.getValue().setText(String.valueOf(count));
86 }
87 }
Overworld.collect 和 notifyListeners(world)world/Overworld.java第 44–46、111–115、125–129 行
44 public void addListener(OverworldListener listener) {
45 listeners.add(listener);
46 }
⋯ 省略第 47–110 行
111 void collect(MysteryBox box) {
112 objects.remove(box);
113 player.getInventory().add(box.getItem());
114 notifyListeners(listener -> listener.onItemCollected(box.getItem()));
115 }
⋯ 省略第 116–124 行
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
127 message.accept(listener);
128 }
129 }
MysteryBox(world)world/MysteryBox.java第 5–7、18–21 行
5/* A box holding one item. The player picks it up by stepping onto it. */
6public class MysteryBox extends MapObject {
7 private final Item item;
⋯ 省略第 8–17 行
18 @Override
19 void interact(Overworld world) {
20 world.collect(this);
21 }
Overworld.move(world)world/Overworld.java第 65–79 行
65 /* Move the player one tile, then let every object on the new tile react. */
66 public void move(Direction direction) {
67 if (!canMove(direction)) {
68 return;
69 }
70 player.moveTo(player.getPosition().move(direction));
71 notifyListeners(listener -> listener.onPlayerMoved());
72
73 /* Loop over a copy, because a mystery box removes itself from the list. */
74 for (MapObject object : new ArrayList<>(objects)) {
75 if (object.isAt(player.getPosition())) {
76 object.interact(this);
77 }
78 }
79 }
向上走一格,现在在 (18,17)
1
  1. 1
    之前:Poison: 0
之前:站在 (18,17),箱子在上面一格真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
1
  1. 1
    之后:Poison: 1
之后:踩上 (18,16) 的问号箱真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
向上走一格,现在在 (18,17)
2
  1. 2
    之前:状态栏是空的
真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
2
  1. 2
    之后:statusBar 写着 You picked up a Poison!
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
  1. 第 1 步,认画面。这是地图画面,打开 COverworldScreen,先看字段(第 49–53 行):右上卷轴是 statsScroll,类型是 CPlayerStatsScroll;下面的白条是 statusBar,一个 Label。
  2. 第 2 步,往下一层找字段。打开 CPlayerStatsScroll:第 35 行 itemValues 是一个 Map<Item, Label>,每种道具对应一个 Label。构造器第 62–66 行按背包里的道具顺序,一种道具造一行:左边写上 Poison: 这样的名字,右边放这个 Label。
  3. 第 3 步,找写数字的方法。还是这个类的 update():第 84 行 player.getInventory().getCount(entry.getKey()) 问 CPlayer(world)的 CInventory(item)这种道具有几个;第 85 行 setText 把数字写进 Label。所以这个 1 是 CInventory 里存的数。顺序图最下面那条箭头,就是“有人调用了这个 update()”。
  4. 第 4 步,谁调用了 update?在 COverworldScreen 里搜 statsScroll.update():在它自己的 update() 第 136 行。再搜谁调用这个 update():有好几处(第 69、76、99、105 行),和捡道具有关的是 onItemCollected 第 99 行。第 98 行还先在状态栏写了 You picked up a Poison!(红框 2)。
  5. 第 5 步,谁改了数字、谁通知了画面?onItemCollected 是 IOverworldListener 接口的方法,调用它的是 COverworld.collect:第 113 行 add 让背包里的 Poison 0 → 1(之前、之后两个红框 1);第 114 行 notifyListeners(...) 对 listeners 列表里的每个监听者调用 onItemCollected(第 126–127 行)。列表里只有一个监听者:CGameWindow 开局时用 addListener(第 44–46 行)登记的 COverworldScreen。
  6. 第 6 步,谁调用了 collect?搜 collect(:在 CMysteryBox.interact 第 20 行,world.collect(this)。这里的 this 就是这个箱子自己,所以 collect 拿到的 box 正是 (18,16) 的那个箱子,box.getItem() 是它装着的 Poison。
  7. 第 7 步,谁调用了 interact?COverworld.move:第 70 行把玩家移到 (18,16)(第 71 行先通知画面“走了一步”,那时背包里还是 0),第 74–76 行看这一格上有没有物体,有就调用 object.interact(this)。变量 object 的类型是父类 AMapObject,这次真正的对象是 CMysteryBox,所以运行的是箱子的 interact(多态)。而 move 是你按 ↑ 时 COverworldScreen 调用的。从屏幕上的一个数字,一路找到了规则。

常见的东西,按这张表直接去找:

你看到的画它的(ui)内容来自
日志里的一句话CBattleScreen 的 logAreaCBattle 的 log;句子写在 CBattle 和各个技能、道具类里
血条、能量条CFighterPanel → CStatBarCFighter.getHp() / getMaxHp()、getEnergy() / getMaxEnergy()
技能按钮上的字CBattleScreen 的 skillButtonsASkill.getName()、getEnergyCost()
地图格子CMapViewCGameMap.getTerrain() → ETerrain
感叹号的颜色CMapView + CUiStyle.tintCSprite.getColour()
遇敌框里的地形提示(0–2 句,中性的一方不写)COverworldScreen 的 onEncounter + CModalDialogCEncounter.getTerrainMessages()
菜单项变灰(第 6 章)CGameMenu.update当前画面的 canRunAway()、canReturnToMainMenu()
卷轴里的 FightsCPlayerStatsScroll 的 fightsValueCPlayer.getFightsWon()

有了这张地图,下一章 第 5 章从 main 开始,看程序启动时 CMain 怎么一个包一个包地把对象造出来。

自测

72 个类分在几个包?抽象类、接口、枚举各有几个?

9 个包:根包 mokepon(1)、data(6)、model(5)、world(15)、battle(4)、skill(15)、effect(4)、item(6)、ui(16)。8 个抽象类(Skill、TargetedSkill、AttackSkill、BoostSkill、StatusEffect、Item、MapObject、GameScreen),4 个接口(EnemyStrategy、EnemyPicker、OverworldListener、GameNavigator),5 个枚举(Stat、Terrain、TerrainEffect、MokeponType、Direction),其余 55 个是普通类。

The 72 classes are in 9 packages; 8 of them are abstract classes, 4 are interfaces and 5 are enums.

怎么证明 Battle 和 Overworld 不认识画面?

看 import:CBattle 只 import 了 item、skill 和 java.util;COverworld 只 import 了 battle、item、model 和 java.util。整个项目里 import javafx 的只有 CMain 和 ui 包的 15 个类,import ui 包的只有 CMain。CBattle 的注释也写着 “It knows nothing about the screen”。

Only Main and the ui package import JavaFX, and no rule class imports the ui package.

Overworld 没有 import 任何画面类(按设计也不应该),那状态栏里的 “You picked up a Poison!” 是怎么出现的?

COverworld.collect 调用 notifyListeners(listener -> listener.onItemCollected(...))。IOverworldListener 是 world 包自己的接口,COverworldScreen 实现了它,CGameWindow 在 startGame 里用 world.addListener(overworldScreen) 登记。于是 onItemCollected 运行,在状态栏写字。这是观察者模式。注意这是设计上守住的:Java 并不禁止 world 包 import ui 包,是代码选择不这样做。

Overworld only knows the OverworldListener interface, which is in the world package; OverworldScreen implements it and is registered by GameWindow, so the world can notify the screen without depending on it.

Sprite 为什么存颜色字符串 "red",而不是 JavaFX 的 Color?

因为 CSprite 在 world 包,用了 Color 就得 import javafx,规则包就和界面库绑在一起了。它只存数据文件里的文字,真正染色在 ui 包的 CMapView 里,调用 UiStyle.tint(objectView, sprite.getColour())。CGameImages 只存图片路径也是同一个道理。

battle 包和 skill 包,谁依赖谁?这算不算设计问题?

互相依赖:ASkill.use(Fighter user, Fighter target, Random random) 用到 battle 的 CFighter,而 CFighter.canUse(Skill skill)、CBattle.playTurn(Skill playerSkill) 用到 skill 的 ASkill。battle 和 effect、battle 和 item 也是这样互相 import,中心都是 CFighter(skill → effect 只是单向,item 和 skill、effect 之间没有 import)。这是一个可以诚实承认的缺点:这四个包只能一起改、一起拿去用。但它们本来就是同一件事(战斗规则),而且没有一个依赖 ui。

The battle package depends on skill, effect and item, and each of them depends back on battle through Fighter, so they are tightly coupled, but none of them depends on the user interface.

地图卷轴里道具的顺序是 Health Tonic、Coffee、Poison,战斗里的道具按钮却是 Health Tonic、Poison、Coffee。为什么?

两处用的是 CItemCatalogue(item 包)里的两个不同的 static 列表。COverworld 用 getAll()(列表 ITEMS)造背包,地图卷轴按背包的顺序列出;CBattleScreen 用 getBattleOrder()(列表 BATTLE_ORDER)造按钮。两个列表里是同样三个道具对象,只是顺序不同。(CSkillCatalogue、CItemCatalogue 不是单例,见 第 16 章第 9 节。)

ItemCatalogue has only static members: one list of the three shared item objects for the inventory, and another list in the order of the battle buttons.

点一次 Stun [4],代码按顺序经过哪些包?画面什么时候变?

ui(CGameButton 的 lambda → CBattleScreen.useSkill)→ battle(CBattle.playTurn,CFighter.canUse)→ skill(ASkill.use 扣能量,ATargetedSkill 命中检查用到 model 的 EStat,CStun.applyHit)→ effect(new Stunned())→ battle(addEffect、敌人回合、endTurn)→ 回到 ui。画面要等 CBattleScreen 调用 update() 时才变:它回头读 CFighter 的数和 getLog()。

地图卷轴里的 “Fights: 1”,你会去哪个类、哪个方法找写它的代码?

地图画面 COverworldScreen → 字段 statsScroll(CPlayerStatsScroll)→ 字段 fightsValue → update() 里的 fightsValue.setText(String.valueOf(player.getFightsWon())) → 数字存在 CPlayer 的 fightsWon,由 COverworld.winEncounter 里的 player.addWin() 加 1,然后 notifyListeners 调用 onEncounterWon,它再调用 update()。

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