Mokepon代码导读
第三站 · 界面与地图L2约 35 分钟

09遇敌:地形、类型和挑敌人

Marker 和 EnemyPicker 策略、属性克制、Encounter 怎么造出一场战斗

读完这一章你能
  • 说出白、红、绿三种感叹号在代码里差在哪:都是 CMarker,只差 CMapParser 交给它们的那个 picker 对象
  • 按顺序说出踩上感叹号以后的每一次调用:interact → startEncounter → getTerrain → chooseEnemy → pick → new Encounter → onEncounter
  • 只看 EMokeponType 的 5 个常量,说出任何类型在任何地形上是优势、劣势还是普通
  • 解释遇敌框里每一行提示是怎么拼出来的,为什么有的框一行提示都没有
  • 算出开打时的数值:Impike 的 HP 150 → 225,Galecrest 的 HP 185 → 93(不是 92)
  • 说出 Fight! 和 Run Away! 各自调用了谁,为什么逃跑以后感叹号还在
  • 用一句英语说:EnemyPicker is a Strategy: Marker only knows the interface, and MapParser decides which picker each marker group gets.

第 1 章第 4 节我们踩过 (17,17) 的绿色感叹号,看到了遇敌框。这一章把那一下拆开看:感叹号在代码里是什么,踩上去以后谁调用了谁,敌人是怎么挑出来的,框里的两行字从哪来,点 Fight! 以后战斗里的数值为什么变了,点 Run Away! 以后为什么能退回去。用到的类大多在 world 包(地图上的规则)和 model 包(数据),画对话框的在 ui 包。走一步本身(move、观察者模式)在 第 8 章,这一章接着它往下讲。

先把真实的过程看一遍。下面 4 张是同一局里按顺序截的图,玩家选的是 FIRE 类型的 Impike,刚在 (18,16) 捡到一瓶 Poison。为了看清楚,每张只裁了地图的右下部分(对话框也在这一块里):玩家(白色星形)和 (17,17) 的绿色感叹号在图的右下角。

向下一格到 (18,17),左边就是绿色感叹号(weak marker)踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在再次踩上感叹号:又遇到一只(敌人可能不同)
一次遇敌的经过(真实截图,按顺序播放)

注意第 2 张和第 4 张:同一个感叹号,第一次是 Hexwing,第二次是 Galecrest。敌人不是事先定好的,而是每次踩上去时现挑的。第 4 张以后点了 Fight!,换成战斗画面(第 7 节):地图卷轴上 Impike 的 HP 是 150,到了战斗里变成 225。这两件事都是这一章要讲清楚的。

1. 地图上的感叹号:三组,三种挑法

地图上一共 10 个感叹号,三种颜色。它们的位置和颜色都写在数据文件 mokepon.game 里(第 1 章第 2 节读过这种 名字=值 的写法)。左边是数据文件,右边是刚进地图时的截图,编号一一对应:

mokepon.game:三组感叹号mokepon.game第 439–456 行
4391map.mokeponRandom.0=3,3
440map.mokeponRandom.1=14,6
441map.mokeponRandom.2=12,12
442map.mokeponRandom.3=4,10
443map.mokeponRandom.image=assets/sprite_exclamation.png
444map.mokeponRandom.colour=white
445
4462map.mokeponStrong.0=5,4
447map.mokeponStrong.1=16,4
448map.mokeponStrong.2=12,16
449map.mokeponStrong.image=assets/sprite_exclamation.png
450map.mokeponStrong.colour=red
451
4523map.mokeponWeak.0=2,12
453map.mokeponWeak.1=6,16
454map.mokeponWeak.2=17,17
455map.mokeponWeak.image=assets/sprite_exclamation.png
456map.mokeponWeak.colour=lime

怎么读这几行

同一组的位置写成 组名.0、组名.1……,一行一个格子。3,3 就是第 3 列、第 3 行(格子坐标:先列后行,都从 0 数)。

每组还有一行 .image(三组都是同一张 sprite_exclamation.png)和一行 .colour(white、red、lime)。

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1111222333
  1. 1
    白色 4 个,map.mokeponRandom.0–.3:(3,3)、(14,6)、(12,12)、(4,10)
  2. 2
    红色 3 个,map.mokeponStrong.0–.2:(5,4)、(16,4)、(12,16)
  3. 3
    绿色(lime)3 个,map.mokeponWeak.0–.2:(2,12)、(6,16)、(17,17)
10 个感叹号,按数据文件里的组编号真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

颜色只是给同一张图染色,它本身不决定挑什么敌人。决定挑法的东西在下面这段代码里。

MapParser:每组配一个 picker

程序一启动,CMain 就用 CMapParser 把数据文件里 map. 开头的行变成一个 CGameMap 对象(启动顺序见 第 5 章)。三组感叹号是在它的 parse 方法里造出来的,红色数字和上面的组一样:

MapParser.parse:地图上的物体从这里来data/MapParser.java第 25–40 行
25 public GameMap parse(GameData data) {
26 Terrain[][] tiles = parseTiles(data);
27 Position startPosition = data.getPosition("map.player.startingPos");
28
29 /* Each marker group has its own way of choosing the enemy. */
30 List<MapObject> objects = new ArrayList<>();
311 objects.addAll(parseMarkers(data, "map.mokeponRandom", new RandomEnemyPicker()));
322 objects.addAll(parseMarkers(data, "map.mokeponStrong",
33 new TerrainEnemyPicker(TerrainEffect.ADVANTAGE)));
343 objects.addAll(parseMarkers(data, "map.mokeponWeak",
35 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)));
36 objects.addAll(parseMysteryBoxes(data));
37 objects.add(parseExit(data));
38
39 return new GameMap(tiles, startPosition, objects);
40 }

第 31–35 行调用了三次同一个方法 parseMarkers,每次交过去三样东西:数据、组名、一个“会挑敌人的对象”。三次只差最后一样:

这三个对象代码里统称 picker(挑选者)。红色和绿色用的是同一个类 CTerrainEnemyPicker,只是 new 的时候交给构造器的值不同:一个要 ADVANTAGE,一个要 DISADVANTAGE。这两个值是枚举(enum) ETerrainEffect 的常量(第 4 节)。

parse 里其余几行把这些东西装进一个列表:

再看 parseMarkers 怎么用这个 picker:

MapParser.parseMarkers:一组感叹号data/MapParser.java第 56–66 行
56 /* All markers in one group share the same image, colour and picker. */
57 private List<MapObject> parseMarkers(GameData data, String group, EnemyPicker picker) {
58 Sprite sprite = new Sprite(data.getText(group + ".image"), data.getText(group + ".colour"));
59 List<MapObject> markers = new ArrayList<>();
60 int count = data.countEntries(group + ".", "");
61 for (int index = 0; index < count; index++) {
62 Position position = data.getPosition(group + "." + index);
63 markers.add(new Marker(position, sprite, picker));
64 }
65 return markers;
66 }

关键在第 63 行:同一组的每个 CMarker 拿到的都是同一个 sprite 对象和同一个 picker 对象。picker 只在 parse 的第 31–35 行 new 了一次,作为参数传进来,循环里没有再 new。所以 4 个白色感叹号的字段 picker 都指着同一个 CRandomEnemyPicker 对象(一个对象可以被好几个字段指着,见 引用)。画出来是这样:

把数据文件的地图部分变成 GameMap:地块、起点和地图上的物体。CMapParserdata: RandomEnemyPicker: TerrainEnemyPicker(ADVANTAGE): TerrainEnemyPicker(DISADVANTAGE)白色 : Marker ×4红色 : Marker ×3绿色 : Marker ×3newnewnewpickerpickerpicker
MapParser.parse 造出的对象(冒号前空着 = 对象没有名字,冒号后是它的类)。实线箭头 = MapParser 在这里 new 出它;空心菱形 = Marker 用字段 picker 存着它。MapParser 框里类名下面的小字 data 是它所在的包,不是字段

CMarker 类自己只有一个字段:private final EnemyPicker picker。final 的意思是:这个字段只能在构造器里赋值一次,以后不能再指向别的对象,所以一个感叹号的挑法从它被造出来起就定死了。它的类型写的是接口(interface) IEnemyPicker,不是某个具体的类,所以三种 picker 都能放进去。位置和图片存在父类 AMapObject 里(构造器第一行 super(position, sprite))。三种颜色用的是同一个 Marker 类,代码里没有“白色 Marker 类”“红色 Marker 类”。

Marker 的 UML 类图
老师打印稿里的 Marker:空心三角实线指向 MapObject,表示 extends。名字前的小图标:红色方块 = private,绿色圆点 = public,蓝色三角 = 包内可见(什么修饰符都不写);斜体 = abstract(第 3 章细讲)

每个感叹号脚下是什么地形

红色和绿色的 picker 要看地形挑敌人,所以先查清楚每个感叹号站在什么地形上。地形也在数据文件里:map.tiles.0 到 map.tiles.19 一共 20 行,每行 20 个字母,一个字母一格(R 石头、G 草地、P 小路、W 水、F 森林、B 障碍、O 深海)。左边绿色的行,就是有感叹号的那几行:

map.tiles:一行 20 个字母mokepon.game第 402–421 行
402map.tiles.0=RRRRRRPPRRRRRRRRRRRR
403map.tiles.1=RRRRRRPPRRRRRRRRRRRR
404map.tiles.2=RRRRGGPPRRGGRRPPPPPP
405map.tiles.3=RRRRGGPPRRGGRRPPPPPP
406map.tiles.4=GGGGGGPPPPGGGGPPRRGG
407map.tiles.5=GGGGGGPPPPGGGGPPRRGG
408map.tiles.6=GGWWWWWWPPWWWWPPWWGG
409map.tiles.7=GGWWWWWWPPWWWWPPWWGG
410map.tiles.8=GGWWGGGGPPPPPPPPWWWW
411map.tiles.9=GGWWGGGGPPPPPPPPWWWW
412map.tiles.10=GGWWGGGGPPGGGGBBBBBB
413map.tiles.11=GGWWGGGGPPGGGGBBBBBB
414map.tiles.12=GGWWGGBBPPGGGGBBBBBB
415map.tiles.13=GGWWGGBBPPGGGGBBBBBB
416map.tiles.14=WWWWWWBBPPGGGGBBBBBB
417map.tiles.15=WWWWWWBBPPGGGGBBBBBB
418map.tiles.16=OOOOWWGGPPPPGGGGFFFF
419map.tiles.17=OOOOWWGGPPPPGGGGFFFF
420map.tiles.18=OOOOWWGGGGPPFFFFFFFF
421map.tiles.19=OOOOWWGGGGPPFFFFFFFF
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1122222345
  1. 1
    石头 ROCKS(R):(3,3) 白、(16,4) 红
  2. 2
    草地 GRASS(G):(4,10)、(12,12) 白,(5,4)、(12,16) 红,(6,16) 绿
  3. 3
    小路 PATH(P):(14,6) 白
  4. 4
    水 WATER(W):(2,12) 绿
  5. 5
    森林 FOREST(F):(17,17) 绿
同一张图,这次按感叹号脚下的地形编号真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

查一个格子 (x, y) 的方法:找 map.tiles.y 那一行,从 0 开始数到第 x 个字母。比如 (17,17):第 419 行是 map.tiles.17=OOOOWWGGPPPPGGGGFFFF,第 0–3 个是 O,第 4–5 个是 W……第 16–19 个是 F,所以第 17 个是 F,也就是 FOREST。

程序里查的不是字母,而是地形对象。启动时 CMapParser 第 26 行的 parseTiles 已经把每个字母交给 Terrain.fromSymbol,换成枚举 ETerrain 的一个常量(F → Terrain.FOREST),存进一个二维数组 Terrain[][] tiles(数组的数组):tiles[行][列],第一个下标是 y,第二个是 x。这段代码在 第 5 章第 5 节逐行讲过。踩上去时再用 CGameMap 的 getTerrain 取出来(下一节)。10 个感叹号全部查一遍(最后一列为什么是这些类型,第 4 节和第 6 节解释):

组位置地形踩上去从谁里面挑
白(3,3)R ROCKS36 只随便挑
白(14,6)P PATH36 只随便挑
白(12,12)G GRASS36 只随便挑
白(4,10)G GRASS36 只随便挑
红(5,4)G GRASS在草地占优势的:LIGHTNING 5 只
红(16,4)R ROCKS在石头上占优势的:ICE 6 只
红(12,16)G GRASSLIGHTNING 5 只
绿(2,12)W WATER在水里吃亏的:FIRE 6 只
绿(6,16)G GRASS在草地吃亏的:ICE 6 只
绿(17,17)F FOREST在森林吃亏的:LIGHTNING 5 只

2. 踩上去:规则这一边,从 move 到 Encounter

先看这一步前后的屏幕(两边裁的是地图右下角的同一块):

向下一格到 (18,17),左边就是绿色感叹号(weak marker)
12
  1. 1
    玩家(白色星形)在 (18,17)
  2. 2
    (17,17) 的绿色感叹号,脚下是森林
按 ← 之前真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1
  1. 1
    玩家到了 (17,17),把感叹号盖住了。整块变暗,是对话框的黑色半透明层盖在上面
按 ← 之后(同一块)真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

按一下 ←,屏幕上出了两件事:玩家走到 (17,17),然后弹出一个框。这两件事中间,规则那一边(world 包)已经做完了所有决定:这一格是什么地形、遇到谁、把这次遇敌记下来。这一节只看规则这一边,下一节再看画面那一边怎么把框画出来。

下面的动画是一张顺序图(读法见第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回;第 5 章还讲过一种:虚线 + 实心箭头 = new 一个新对象,创建消息)。正在发生的那一步会变成流动的红色虚线,这只是“现在亮着”的意思;它本来是实线还是虚线,看它没亮的时候。

竖线顶上写的是代码里指着这个对象的名字:map 是 COverworld 的字段(一个 CGameMap),marker 是参数(踩到的那个 CMarker),picker 是 CMarker 的字段,encounter 是新造的 CEncounter。顺序图下面是这一步正在运行的那几行代码(红色底的行)。第一步和最后一步换成真实截图:按 ← 之前,和这一串调用全部跑完以后。

COverworldCmapCmarkerCpickerCencountermove(LEFT):玩家已到 (17,17)interact(this)1startEncounter(this)2getTerrain(position)3FOREST4chooseEnemy(…, FOREST, …)5pick(…, FOREST, …)6Hexwing7Hexwing8new Encounter(...)9notifyListeners(...):通知 onEncounter(下一节)
踩上 (17,17):规则这一边的调用顺序
向下一格到 (18,17),左边就是绿色感叹号(weak marker)
12
  1. 1
    玩家(白色星形)在 (18,17)
  2. 2
    要踩上去的感叹号 (17,17),脚下是森林
第 1 步:按 ← 之前的屏幕真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)
Overworld.move(前半)world/Overworld.java第 65–71 行
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());
Overworld.move(后半)world/Overworld.java第 73–79 行
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 }
Marker:chooseEnemy 和 interactworld/Marker.java第 19–27 行
19 /* Choose the Mokepon to fight on this marker's terrain. */
20 Mokepon chooseEnemy(List<Mokepon> mokepons, Terrain terrain, Random random) {
21 return picker.pick(mokepons, terrain, random);
22 }
23
24 @Override
25 void interact(Overworld world) {
26 world.startEncounter(this);
27 }
Overworld.startEncounterworld/Overworld.java第 102–108 行
102 /* Called by a marker when the player steps onto it. */
103 void startEncounter(Marker marker) {
104 Terrain terrain = map.getTerrain(marker.getPosition());
105 Mokepon enemy = marker.chooseEnemy(mokepons, terrain, random);
106 encounter = new Encounter(marker, player.getMokepon(), enemy, terrain);
107 notifyListeners(listener -> listener.onEncounter(encounter));
108 }
TerrainEnemyPicker.pick(第 6 节逐行讲)world/TerrainEnemyPicker.java第 21–27、33–34 行
21 public Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random) {
22 List<Mokepon> matches = new ArrayList<>();
23 for (Mokepon mokepon : mokepons) {
24 if (mokepon.getType().getEffectOn(terrain) == wantedEffect) {
25 matches.add(mokepon);
26 }
27 }
⋯ 省略第 28–32 行
33 return matches.get(random.nextInt(matches.size()));
34 }
Encounter 的字段和构造器world/Encounter.java第 15–26 行
15public class Encounter {
16 private final Marker marker;
17 private final Mokepon playerMokepon;
18 private final Mokepon enemy;
19 private final Terrain terrain;
20
21 public Encounter(Marker marker, Mokepon playerMokepon, Mokepon enemy, Terrain terrain) {
22 this.marker = marker;
23 this.playerMokepon = playerMokepon;
24 this.enemy = enemy;
25 this.terrain = terrain;
26 }
Overworld:通知监听者world/Overworld.java第 106–108、125–129 行
106 encounter = new Encounter(marker, player.getMokepon(), enemy, terrain);
107 notifyListeners(listener -> listener.onEncounter(encounter));
108 }
⋯ 省略第 109–124 行
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
127 message.accept(listener);
128 }
129 }
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
3
  1. 3
    通知发出去以后屏幕上出现的遇敌框(下一节讲它是怎么造的)
最后一步:这一串调用跑完以后的屏幕真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
  1. 先看按 ← 之前的屏幕(上面的截图):玩家(标注 1)站在 (18,17),左边一格就是 (17,17) 的绿色感叹号(标注 2)。你按 ←,COverworldScreen 调用 world.move(Direction.LEFT)。下一步开始看 COverworld 的代码。
  2. 第 67 行先问能不能走:(17,17) 是森林,能走。第 70 行 player.moveTo(...) 把玩家的位置改成 (17,17),第 71 行先通知一次 onPlayerMoved(),画面改好玩家的位置(第 8 章;真正画到屏幕上,要等这一整串调用都跑完)。到这里还没有“遇敌”的事,顺序图里只亮着最上面那个说明框。
  3. 第 74 行把地图上剩下的 13 个物体(10 个感叹号、2 个问号箱、1 扇门)挨个拿出来。循环的是一份复制出来的列表 new ArrayList<>(objects):第 73 行注释说,问号箱被捡走时会把自己从 objects 里删掉,边循环边删同一个列表会出错(遍历时修改集合,第 8 章第 4 节)。第 75 行问每个物体 isAt(玩家的新位置),只有 (17,17) 的感叹号回答 true,于是第 76 行调用它的 interact(this)(箭头 1),把这个 COverworld 自己交给它。
  4. CMarker 的 interact(第 24–27 行)自己不改任何东西,只调用 world.startEncounter(this)(箭头 2):请 COverworld 开始一次遇敌,this 就是这个感叹号自己。AMapObject 的注释定了这个规矩:每种物体 “asks the world to make the change”。问号箱也一样,它调用的是 world.collect(this)。
  5. 回到 COverworld,第 104 行查地形:map.getTerrain(marker.getPosition())。marker.getPosition() 交回感叹号的位置,一个 CPosition 对象 (17,17)(顺序图里写成 position)。CGameMap 的 getTerrain 只有一句 return tiles[position.getY()][position.getX()];:二维数组先行后列,取到 tiles[17][17],也就是启动时从字母 F 换来的 Terrain.FOREST(箭头 4 交回)。它存进局部变量 terrain。
  6. 第 105 行请感叹号挑敌人:marker.chooseEnemy(mokepons, terrain, random)(箭头 5),交过去三样:字段 mokepons(数据文件里全部 36 只 Mokepon,也包括你自己选的 Impike)、FOREST、这一局的随机数对象 random。
  7. CMarker 的 chooseEnemy 只有一行(第 21 行):原样转交给自己的字段 picker(箭头 6),这叫委托。CMarker 不知道 picker 是哪个类,只知道它是一个 IEnemyPicker。(17,17) 属于绿色组,所以这个 picker 就是第 1 节里那个 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)。
  8. CTerrainEnemyPicker 的 pick 从 36 只里留下“在 FOREST 上吃亏”的,也就是 5 只 LIGHTNING 类型(第 23–26 行),再随机挑一只(第 33 行)。这次挑到 Hexwing,交回给感叹号(箭头 7)。每一行怎么做,第 6 节有单独的动画。
  9. chooseEnemy 把 Hexwing 原样交回 COverworld(箭头 8),第 105 行把它存进局部变量 enemy。Hexwing 不是复制出来的新对象,就是 mokepons 列表里本来那个 CMokepon 对象。36 只里也有你自己选的那只,所以白色感叹号有可能挑到同一个 Impike 对象。这没关系:开打时 CEncounter 给双方各 new 一个 CFighter,CMokepon 本身从来不被改(第 7 节)。
  10. 第 106 行 new Encounter(marker, player.getMokepon(), enemy, terrain)(箭头 9:虚线 + 实心箭头,造一个新对象)。“哪个感叹号、你的 Mokepon、敌人、地形”四样交给 CEncounter 的构造器(第 21–26 行),存进它的 4 个 final 字段(第 16–19 行)。第 22 行 this.marker = marker;:左边是新对象的字段,右边是构造器的参数(下面的 Java 小课堂)。新对象再存进 COverworld 的字段 encounter,这个字段的注释说:没有遇敌时它是 null。
  11. 第 107 行通知所有登记过的监听者:notifyListeners(listener -> listener.onEncounter(encounter)),意思是“对每一个监听者调用一次 onEncounter,把这个 encounter 交给它”。这一局只有一个监听者,就是 COverworldScreen。括号里的 lambda、Consumer 和第 125–129 行的循环,第 8 章第 6 节逐行讲过。
  12. 规则这一边到这里就做完了,move 跑完,屏幕才重新画出来:玩家站在 (17,17),上面盖着遇敌框(上面的截图)。这个框是 COverworldScreen 收到 onEncounter 以后造的,下一节接着看。

第 106 行造出的 CEncounter(“这一次遇敌”)存着什么、被谁存着:

一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld一次遇敌:哪个标记、玩家的 Mokepon、敌人和脚下的地形。CEncounterworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworldImpike : MokeponHexwing : MokeponFOREST : TerrainencountermarkerplayerMokeponenemyterrain
遇敌那一刻的对象:空心菱形 = 用字段存着(线上写的是字段名)。Encounter 的 4 个字段都是 final,造好以后不会再变

这个 CEncounter 一直留在 COverworld 的字段 encounter 里,等你在框里做选择:点 Fight! 时用它造战斗(第 7 节),点 Run Away! 时把字段设回 null(第 8 节)。它还记着 marker,是为了打赢以后知道该把哪个感叹号从地图上拿掉(winEncounter,第 15 章)。

3. 画面这一边:遇敌框是怎么造出来的

上一节最后一步通知了监听者。COverworldScreen 是唯一的监听者:CGameWindow 开始一局时用 world.addListener(overworldScreen) 登记过它(第 8 章)。于是轮到 COverworldScreen 的 onEncounter。先看它调用了谁(读法同上):

COverworldCOverworldScreenCencounterCdialogonEncounter(encounter)1getEnemy()2Hexwing3new ModalDialog(标题)4getTerrainMessages()5两句提示(List)6addMessage(…) ×27addButton(Fight!, …)8addButton(Run Away!, …)9showDialog(dialog)10
收到 onEncounter 以后:OverworldScreen 造对话框

这 10 个箭头全在 COverworldScreen 的一个方法 onEncounter 里。先把箭头和代码行对上号,下面动画里每一步也会再说一次:

顺序图onEncounter 里的代码
箭头 1第 80 行:方法被调用
箭头 2–4第 81 行:getEnemy()、拿到 Hexwing、new ModalDialog(...)
箭头 5–6第 82 行:getTerrainMessages() 和它交回的列表
箭头 7第 83 行:addMessage,列表里几句就调用几次
箭头 8、9第 85–88 行、第 89–92 行:两次 addButton
箭头 10第 93 行:showDialog(dialog)

下面的动画一行一行走这个方法,中间还会钻进 CModalDialog 和 AGameScreen 里看一看:上面是代码,下面左边是放大的框,右边是整个画面。两张截图都是框最后的样子;每一步亮起的红框,就是这一步造出来的那一块。编号:1 标题,2 地形提示,3 Fight!,4 Run Away!,5 黑色半透明层,6 玩家。

OverworldScreen.onEncounterui/OverworldScreen.java第 80–93 行
80 public void onEncounter(Encounter encounter) {
81 ModalDialog dialog = new ModalDialog("Encountered a " + encounter.getEnemy().getName());
82 for (String message : encounter.getTerrainMessages()) {
83 dialog.addMessage(message);
84 }
85 dialog.addButton("Fight!", event -> {
86 closeDialog();
87 getNavigator().startBattle();
88 });
89 dialog.addButton("Run Away!", event -> {
90 closeDialog();
91 world.runAway();
92 });
93 showDialog(dialog);
ModalDialog 的构造器ui/ModalDialog.java第 37–47、50–52 行
37 public ModalDialog(String heading) {
38 Label headingLabel = UiStyle.createLabel(heading, UiStyle.loadGameFont(HEADING_SIZE));
39 messages.setAlignment(Pos.CENTER);
40 buttons.setAlignment(Pos.CENTER);
41
42 VBox box = new VBox(SPACING, headingLabel, messages, buttons);
43 box.setAlignment(Pos.CENTER);
44 box.setPadding(PADDING);
45 box.setStyle(BOX_STYLE);
46 /* Keep the box only as big as its contents, instead of filling the screen. */
47 box.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
⋯ 省略第 48–49 行
50 layer.setPrefSize(UiStyle.GAME_WIDTH, UiStyle.GAME_HEIGHT);
51 layer.setStyle(SHADE_STYLE);
52 layer.getChildren().add(box);
ModalDialog:addMessage 和 addButtonui/ModalDialog.java第 60–73 行
60 /* Add one line of text under the heading. */
61 public void addMessage(String text) {
62 Label message = new Label(text);
63 message.setTextFill(Color.BLACK);
64 messages.getChildren().add(message);
65 }
66
67 /* Add a button at the bottom, which runs the action when it is chosen. */
68 public void addButton(String text, EventHandler<ActionEvent> action) {
69 Button button = new Button(text);
70 button.setStyle(BUTTON_STYLE);
71 button.setOnAction(action);
72 buttons.getChildren().add(button);
73 }
GameScreen:showDialog 和 isDialogOpenui/GameScreen.java第 68–74、83–85 行
68 /* Show a dialog on top of everything else on the screen. */
69 protected void showDialog(ModalDialog newDialog) {
70 closeDialog();
71 dialog = newDialog;
72 view.getChildren().add(dialog.getLayer());
73 dialog.focusFirstButton();
74 }
⋯ 省略第 75–82 行
83 protected boolean isDialogOpen() {
84 return dialog != null;
85 }
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1234
  1. 1
    标题
  2. 2
    两行地形提示
  3. 3
    Fight!
  4. 4
    Run Away!
真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示5黑色层6
  1. 5
    黑色半透明层 layer,铺满整个游戏区
  2. 6
    玩家在 (17,17),被盖在下面
真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
  1. (上面顺序图的箭头 1)COverworld 调用 listener.onEncounter(encounter)。它手里的 listener 类型是接口 IOverworldListener,它不知道对方是一个画面;真正运行的是 COverworldScreen 改写(@Override)的 onEncounter(第 80 行)。上面两张截图是框最后的样子,这一步它还一点都没有。
  2. (箭头 2–4,都在第 81 行)先拼标题 "Encountered a " + encounter.getEnemy().getName():getEnemy() 交回 Hexwing 这个 CMokepon,getName() 交回它的名字,+ 把两段文字连成 “Encountered a Hexwing”。然后 new ModalDialog(标题)(造一个新对象),存进局部变量 dialog。
  3. 钻进 CModalDialog 的构造器看看(这里换成了它的代码)。第 38 行造标题 Label,字号是常量 HEADING_SIZE(第 28 行定义,值是 32)。第 42 行造白框 box:一个竖排的 VBox,从上往下放三样:标题、放提示的 messages、放按钮的 buttons(都是 CModalDialog 的字段,现在还都是空的)。第 47 行让白框只和里面的东西一样大,不铺满屏幕。第 50–52 行:黑色半透明层 layer 铺满整个游戏区,白框放在它正中间。
  4. (箭头 5–6)回到 onEncounter。第 82 行向 encounter 要地形提示,getTerrainMessages() 交回一个 List<String>(一串字符串)。这次里面是两句:“Your mokepon will be at an advantage here!” 和 “Your enemy will be at a disadvantage here!”。每句怎么拼、什么时候少一句,第 5 节讲。
  5. (箭头 7)第 82–84 行是 for-each 循环,读作“对列表里的每一个 message”。列表里有几句,就调用几次 dialog.addMessage(message)。这次两句,调用两次;列表是空的,循环就一次也不跑。
  6. 再钻进 addMessage(第 61–65 行):第 62 行 new 一个 Label(一行字),第 63 行设成黑色,第 64 行放进字段 messages 这个 VBox。VBox 从上往下排,所以两句提示一句一行(标注 2)。构造器第 47 行说过,白框只和里面的东西一样大,所以多一行字,白框就高一点(第 5 节)。
  7. (箭头 8)第 85–88 行加第一个按钮 Fight!。第二个参数 event -> { closeDialog(); getNavigator().startBattle(); } 是一个 lambda:大括号里的两行现在不运行,只是交给按钮存起来,等你点了才运行(第 7 节)。
  8. addButton 里面(第 68–73 行):第 69 行 new 一个 Button,第 70 行设样式(灰色圆角),第 71 行 button.setOnAction(action) 把那段 lambda 存进按钮,第 72 行放进字段 buttons 这个 HBox。HBox 从左往右排,先加的在左边。
  9. (箭头 9)第 89–92 行加第二个按钮 Run Away!,同样存起一段 lambda:closeDialog(); world.runAway();(第 8 节)。它是第二个放进 buttons 的,所以排在 Fight! 右边(标注 4)。
  10. (箭头 10)第 93 行 showDialog(dialog),这是父类 AGameScreen 的方法(这里换成了它的代码;protected = 子类能调用)。第 70 行先关掉旧框(这时没有)。第 71 行把新框记在字段 dialog 里。第 72 行 view.getChildren().add(dialog.getLayer()):view 是 AGameScreen 里的一个 Pane,整个画面都在它里面;黑色层加到它的子节点列表末尾。JavaFX 里排得越靠后,画得越靠上(绘制顺序),所以黑色层盖住了地图和按钮。第 73 行让第一个按钮 Fight! 拿到键盘焦点。
  11. 框出来了。黑色层(标注 5)盖住整个游戏区,后面的地图和方向按钮都点不到。字段 dialog 不再是 null,所以 isDialogOpen()(第 83–85 行)返回 true:handleKeyPress 和 movePlayer 都直接不理方向键。onEncounter 返回,第 2 节那个 move 也跑完了。程序停下来,等你点一个按钮。

把两节合起来看分工:COverworld 只说“遇敌了,这是 encounter”,它不知道框长什么样,连对方是不是画面都不知道;COverworldScreen 只管把框画出来,它不决定遇到谁。规则和画面分开(模型和界面分离),中间只隔着接口 IOverworldListener(观察者模式)。遇敌框也不是一个新画面:CGameWindow 的 currentScreen 还是这个 COverworldScreen,框只是盖在它上面的一层。

4. 类型和地形:MokeponType 和 TerrainEffect

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
12
  1. 1
    “at an advantage”:你的 Impike 在这块地上占优势
  2. 2
    “at a disadvantage”:敌人 Hexwing 在这块地上吃亏
遇敌框里的两句提示(放大)。“优势”“吃亏”是谁定的?这一节讲真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

遇敌框说你的 Impike “at an advantage”,敌人 Hexwing “at a disadvantage”。这是谁说了算?是 model 包里的两个枚举:EMokeponType(Mokepon 的类型)和 ETerrainEffect(地形效果)。每只 Mokepon 在数据文件里有一行 type,比如 mokepon.14.type=FIRE(Impike)、mokepon.15.type=LIGHTNING(Hexwing)。CMokeponParser 用 MokeponType.valueOf(...) 把这个词变成 EMokeponType 的常量,存进 CMokepon 的字段 type。

MokeponType:每种类型一个强项、一个弱项

MokeponType:5 种类型model/MokeponType.java第 3–31 行
3/* This shows Mokepon Type. Each type except normal is strong on one terrain
4 * and weak on another one.
5 */
6public enum MokeponType {
7 /* A normal Mokepon has no strong or weak terrain, so both are null. */
81 NORMAL(null, null),
92 FIRE(Terrain.FOREST, Terrain.WATER),
103 ICE(Terrain.ROCKS, Terrain.GRASS),
114 WATER(Terrain.WATER, Terrain.ROCKS),
125 LIGHTNING(Terrain.GRASS, Terrain.FOREST);
13
14 private final Terrain strongTerrain;
15 private final Terrain weakTerrain;
16
17 MokeponType(Terrain strongTerrain, Terrain weakTerrain) {
18 this.strongTerrain = strongTerrain;
19 this.weakTerrain = weakTerrain;
20 }
21
22 /* Find whether this type is at an advantage, a disadvantage or neither on the terrain. */
23 public TerrainEffect getEffectOn(Terrain terrain) {
24 if (terrain == strongTerrain) {
25 return TerrainEffect.ADVANTAGE;
26 }
27 if (terrain == weakTerrain) {
28 return TerrainEffect.DISADVANTAGE;
29 }
30 return TerrainEffect.NEUTRAL;
31 }

每个常量括号里的两个值

类型强项地形弱项地形几只
1NORMALnullnull14
2FIREFORESTWATER6
3ICEROCKSGRASS6
4WATERWATERROCKS5
5LIGHTNINGGRASSFOREST5

强项 = 字段 strongTerrain,弱项 = 字段 weakTerrain。“几只” = 数据文件 36 只 Mokepon 里这种类型的个数,加起来正好 36。

第 8–12 行是 5 个枚举常量。每个常量后面的括号,就是交给第 17–20 行构造器的两个值。比如 FIRE(Terrain.FOREST, Terrain.WATER) 的意思是:造一个叫 FIRE 的对象,它的 strongTerrain 是 FOREST,weakTerrain 是 WATER。这 5 个对象 Java 只造一次,整个程序里就这 5 个,谁也不能再 new。

WATER 有点绕:它既是类型名(MokeponType.WATER),又是地形名(Terrain.WATER),这是两个不同枚举里的两个不同常量。WATER 类型在 WATER 地形上占优势,在 ROCKS 上吃亏。NORMAL 的两个值都是 null(什么都没指着),第 7 行注释说 “A normal Mokepon has no strong or weak terrain”。

判断用第 22–31 行的 getEffectOn(terrain),从上往下只问两个问题:

  1. 第 24 行:这块地形是我的强项吗?是 → 返回 ADVANTAGE,方法马上结束(提前 return)。
  2. 第 27 行:是我的弱项吗?是 → 返回 DISADVANTAGE。
  3. 都不是 → 第 30 行返回 NEUTRAL。

拿这一章的三只试一下:

MokeponType 的 UML 类图
老师打印稿里的 MokeponType:E = enum。带下划线的 5 个是常量(static),两个字段和构造器是红色方块(private),getEffectOn 是绿色圆点(public)

把上面“每个常量括号里的两个值”那张表反过来,按地形排一遍(5 种能走的地形;BARRIER 和 OCEAN 不能走,上面不会有感叹号)。每一块地上,谁占优势、谁吃亏,其余类型都是 NEUTRAL:

地形ADVANTAGE(优势)DISADVANTAGE(吃亏)
FOREST 森林FIRELIGHTNING
GRASS 草地LIGHTNINGICE
PATH 小路没有没有
ROCKS 石头ICEWATER
WATER 水WATERFIRE

NORMAL 不在表里,因为它在哪儿都是 NEUTRAL。注意 PATH 那一行是两个“没有”:没有哪种类型把小路当强项或弱项。所以站在 (14,6) 小路上的白色感叹号,不管遇到谁,框里都不会有提示。第 1 节表格最后一列“从谁里面挑”,就是查的这张表:红色感叹号要 ADVANTAGE 那一列,绿色要 DISADVANTAGE 那一列。

TerrainEffect:乘几倍

TerrainEffect:倍数和说明model/TerrainEffect.java第 3–25 行
3/* How the terrain of a battle changes the stats of a Mokepon. */
4public enum TerrainEffect {
51 ADVANTAGE(1.5, "at an advantage"),
62 NEUTRAL(1, ""),
73 DISADVANTAGE(0.5, "at a disadvantage");
8
9 private final double multiplier;
10 private final String description;
11
12 TerrainEffect(double multiplier, String description) {
13 this.multiplier = multiplier;
14 this.description = description;
15 }
16
17 /* Used in messages such as "Your enemy will be at an advantage here!". */
18 public String getDescription() {
19 return description;
20 }
21
22 /* Multiply a stat for one battle and round it to the nearest integer. */
23 public int adjust(int value) {
24 return (int) Math.round(value * multiplier);
25 }

三个常量各存两样东西

常量倍数说明
1ADVANTAGE1.5at an advantage
2NEUTRAL1(空的)
3DISADVANTAGE0.5at a disadvantage

说明 = 字段 description,拼进遇敌框的提示(第 5 节)。倍数 = 字段 multiplier,开打时乘到每个数值上(第 7 节)。

第 23–25 行的 adjust(value) 就一行:(int) Math.round(value * multiplier),把一个数值乘上倍数,再四舍五入成整数。几个这一章真的会用到的例子:

调用乘出来round 以后
ADVANTAGE.adjust(150)150 × 1.5 = 225.0225
ADVANTAGE.adjust(45)45 × 1.5 = 67.568
DISADVANTAGE.adjust(185)185 × 0.5 = 92.593
NEUTRAL.adjust(240)240 × 1 = 240.0240
TerrainEffect 的 UML 类图
老师打印稿里的 TerrainEffect:3 个常量带下划线(static);multiplier 和 description 是 private 字段;adjust(int): int 就是开打时用的那个方法

5. 框里的提示:getTerrainMessages

现在能解释框里的字了。提示是 CEncounter 的 getTerrainMessages 拼的。代码里的红色数字 1、2,对应下面截图里框中的两行:

Encounter:每一边最多一句world/Encounter.java第 36–50、59–63 行
36 private TerrainEffect getPlayerEffect() {
37 return playerMokepon.getType().getEffectOn(terrain);
38 }
39
40 private TerrainEffect getEnemyEffect() {
41 return enemy.getType().getEffectOn(terrain);
42 }
43
44 /* Messages for the encounter dialog. A neutral Mokepon gets no message. */
45 public List<String> getTerrainMessages() {
46 List<String> messages = new ArrayList<>();
471 addTerrainMessage(messages, "Your mokepon", getPlayerEffect());
482 addTerrainMessage(messages, "Your enemy", getEnemyEffect());
49 return messages;
50 }
⋯ 省略第 51–58 行
59 private void addTerrainMessage(List<String> messages, String who, TerrainEffect effect) {
60 if (effect != TerrainEffect.NEUTRAL) {
61 messages.add(who + " will be " + effect.getDescription() + " here!");
62 }
63 }
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
12
  1. 1
    第 47 行加的:你的 Impike(FIRE)在 FOREST 上 → ADVANTAGE
  2. 2
    第 48 行加的:敌人 Hexwing(LIGHTNING)在 FOREST 上 → DISADVANTAGE
两行提示,正好是代码里两次 addTerrainMessage 的结果真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

四个真实的遇敌框

用这条规则,把所有截到的遇敌框都解释一遍。截图来自三局不同的游戏。为了好称呼,给这三局起个名字,后面第 11–17 章也一直这样叫:A 局 = 这一章一直在看的 Impike 这一局;B 局 = 玩家选了 Shellurk,踩的是 (12,12) 的白色感叹号;D 局 = 玩家选了 Ironshell,踩的是 (3,3) 的白色感叹号(C 局、E 局这一章用不上)。A 局有两个框,因为同一个感叹号踩了两次。下面按提示从多到少排:

哪一局,地形你敌人提示
A 局 第 1 次
FOREST
Impike FIRE → 优Hexwing LIGHTNING → 劣2 行
A 局 第 2 次
FOREST
Impike FIRE → 优Galecrest LIGHTNING → 劣2 行
D 局
ROCKS
Ironshell NORMAL → 普通Frostback ICE → 优1 行
B 局
GRASS
Shellurk NORMAL → 普通Nightwing NORMAL → 普通0 行
Encounter 的 UML 类图
老师打印稿里的 Encounter:4 个 private 字段就是第 2 节那张“遇敌那一刻的对象”图里从 Encounter 出去的 4 根线;getPlayerEffect、getEnemyEffect、addTerrainMessage 是红色方块(private,只在类里面用),getTerrainMessages 和 createBattle 是绿色圆点(public,画面和 Overworld 要调用)

6. 挑敌人:两种 picker 和策略模式

回到截图:A 局的两个框是同一个绿色感叹号,挑到两只不同的敌人,但都是 LIGHTNING;白色感叹号挑到的什么类型都有(D 局是 ICE,B 局是 NORMAL)。差别就在 picker。先看它们共同的接口和最简单的那个:

EnemyPicker:接口world/EnemyPicker.java第 8–13 行
8/* Strategy: chooses which Mokepon the player meets at a marker.
9 * Random, strong and weak markers only differ in the picker they use.
10 */
11public interface EnemyPicker {
12 Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random);
13}
RandomEnemyPicker:白色感叹号world/RandomEnemyPicker.java第 8–15 行
8/* Picks any Mokepon from the list. The terrain does not matter. */
9public class RandomEnemyPicker implements EnemyPicker {
10
11 @Override
12 public Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random) {
13 return mokepons.get(random.nextInt(mokepons.size()));
14 }
15}

TerrainEnemyPicker 的每一行

红色和绿色感叹号用的 CTerrainEnemyPicker 复杂一点。还是用 (17,17) 这次:绿色组,wantedEffect 是 DISADVANTAGE,地形是 FOREST。下面动画里的图按类型把 36 只分成 5 堆,方便看(亮起来的框就是这一步在看的那一堆);代码的循环其实是按数据文件的顺序一只一只看的,结果一样。

wantedEffect = DISADVANTAGEterrain = FORESTWATER ×5NEUTRAL:不要NORMAL ×14NEUTRAL:不要ICE ×6NEUTRAL:不要FIRE ×6ADVANTAGE:不要LIGHTNING ×5DISADVANTAGE:要matches:5 只 LIGHTNINGnextInt(5) 选一只add
(17,17) 这一次:36 只按类型分成 5 堆。每堆第二行是 getEffectOn(FOREST) 的结果,只有“要”的那一堆进 matches
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1
  1. 1
    第一次 pick:Hexwing(matches 里的第 0 只)
第一次踩 (17,17)真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
再次踩上感叹号:又遇到一只(敌人可能不同)
1
  1. 1
    第二次 pick:Galecrest(第 3 只)
Run Away! 以后再踩一次真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
TerrainEnemyPicker:字段、构造器和 pickworld/TerrainEnemyPicker.java第 14–18、21–34 行
14 private final TerrainEffect wantedEffect;
15
16 public TerrainEnemyPicker(TerrainEffect wantedEffect) {
17 this.wantedEffect = wantedEffect;
18 }
⋯ 省略第 19–20 行
21 public Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random) {
22 List<Mokepon> matches = new ArrayList<>();
23 for (Mokepon mokepon : mokepons) {
24 if (mokepon.getType().getEffectOn(terrain) == wantedEffect) {
25 matches.add(mokepon);
26 }
27 }
28
29 /* If no Mokepon has the wanted effect here, any Mokepon can appear instead. */
30 if (matches.isEmpty()) {
31 matches.addAll(mokepons);
32 }
33 return matches.get(random.nextInt(matches.size()));
34 }
  1. 这个对象是 CMapParser 第 34–35 行 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE) 造的。它的构造器(第 16–18 行)把 DISADVANTAGE 存进第 14 行的 final 字段 wantedEffect:“我要在这块地形上吃亏的”。红色组的那个对象存的是 ADVANTAGE,别的代码完全一样。
  2. 踩上 (17,17) 时,pick(mokepons, FOREST, random) 被调用。第 22 行先 new 一个空的 ArrayList matches,用来装“合格的”。new ArrayList<>() 里的 <> 让 Java 照着左边的 List<Mokepon> 自己填上类型(泛型)。
  3. 第 23 行 for-each 循环,一只一只看。第一只 Sealance 是 WATER:WATER.getEffectOn(FOREST),FOREST 不是强项 WATER,也不是弱项 ROCKS,返回 NEUTRAL。第 24 行:NEUTRAL == DISADVANTAGE?不等,不加。5 只 WATER 都这样。
  4. 14 只 NORMAL(Puffume、Nightwing、Shellurk……):强项、弱项都是 null,返回 NEUTRAL,不加。
  5. 6 只 ICE(Pengloom、Frostback……):强项 ROCKS、弱项 GRASS,FOREST 都不是,返回 NEUTRAL,不加。
  6. 6 只 FIRE(Impike、Mammagma……):FOREST 正是 FIRE 的强项,返回 ADVANTAGE。可这个 picker 要的是 DISADVANTAGE,不加。如果是一个站在森林里的红色感叹号,要的就正是这 6 只。
  7. 5 只 LIGHTNING:FOREST 是它们的弱项,返回 DISADVANTAGE,和 wantedEffect 相等,第 25 行 matches.add(mokepon)。按数据文件的顺序,matches 里是 Hexwing、Voltusk、Skydart、Galecrest、Stormtalon。
  8. 循环结束。第 30 行:matches 是空的吗?有 5 只,不空,跳过第 31 行。只有一只合格的都没有时,第 31 行才会把 36 只全部加进去,变成“随便挑”(第 29 行注释)。
  9. 第 33 行:matches.size() 是 5,random.nextInt(5) 得到 0–4 中的一个整数,matches.get(...) 取出那一只(下标从 0 数)。第一次踩得到 0,就是 Hexwing;Run Away! 以后再踩,pick 重新跑一遍,这次得到 3,就是 Galecrest。
  10. 这就是那两次的真实遇敌框(上面)。同一个绿色感叹号、同一个 picker 对象,pick 跑了两次:matches 两次都是同样的 5 只 LIGHTNING,只有第 33 行 nextInt 给的数不一样。所以敌人会变,但一定是 LIGHTNING。

第 30–32 行的“退路”在这份数据文件里其实用不上:查第 1 节的表,3 个红色和 3 个绿色感叹号站在 GRASS、ROCKS、WATER、FOREST 上,每一块都有合格的类型。如果把一个红色感叹号放到小路上,PATH 对谁都是 NEUTRAL,matches 就是空的,这时它才会从 36 只里随便挑,而不是出错(nextInt(0) 会抛异常)。

这就是策略模式

把这几个类拼在一起看:

抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworld枚举:地形对属性的影响:优势 ×1.5、中性 ×1、劣势 ×0.5。ETerrainEffectmodelMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld接口:决定在某个标记上遇到哪只 Mokepon。IEnemyPickerworldEnemyPicker 的实现:从全部 Mokepon 里随便挑一只,不管地形。CRandomEnemyPickerworldEnemyPicker 的实现:挑一只类型在这块地形上有指定效果的 Mokepon。CTerrainEnemyPickerworldpickerwantedEffect
挑敌人的类:空心三角 + 实线 = extends;空心三角 + 虚线 = implements;空心菱形 = 用字段存着(线上写字段名)

这叫策略模式(Strategy)。在你的代码里它由四块组成:

为什么这样设计:

EnemyPicker 的 UML 类图
EnemyPicker:I = interface,方法名是斜体(abstract,只有样子没有内容)
TerrainEnemyPicker 的 UML 类图
TerrainEnemyPicker:虚线加空心三角指向 EnemyPicker,就是 implements。字段 wantedEffect 是红色方块(private)

和战斗里的 EnemyStrategy 比一比

战斗里也有一个一模一样的结构:敌人每回合用哪个技能,由 IEnemyStrategy 决定(第 11 章)。两个接口放在一起看:

world 包:挑哪只敌人world/EnemyPicker.java第 8–13 行
8/* Strategy: chooses which Mokepon the player meets at a marker.
9 * Random, strong and weak markers only differ in the picker they use.
10 */
11public interface EnemyPicker {
12 Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random);
13}
battle 包:敌人用哪个技能battle/EnemyStrategy.java第 6–12 行
6/* Strategy: decides which skill the enemy uses each turn.
7 * The battle only knows this interface, so a smarter enemy can be added later
8 * without changing the Battle class.
9 */
10public interface EnemyStrategy {
11 Skill chooseSkill(Fighter enemy, Random random);
12}

IEnemyPicker(world 包)

IEnemyStrategy(battle 包)

  • 决定:敌人这回合用哪个技能
  • 方法:chooseSkill(…),交回一个 ASkill
  • 谁存着:CBattle 的字段 enemyStrategy
  • 实现类:CRandomEnemyStrategy
  • 谁 new 实现类:CBattle 的三参数构造器

它们都是“把一件可以换着做的事交给一个接口”,都要交进来一个 Random,也都只有一个方法。不同的是:IEnemyPicker 已经有两种实现,而且同一局里三种 picker 同时在用;IEnemyStrategy 目前只有一种实现,注释说是为了以后加“更聪明的敌人”时不用改 CBattle。

7. 点 Fight!:Encounter 造出一场战斗

回到 A 局(Impike 这一局):第二次踩上去遇到 Galecrest,这次点 Fight!,屏幕换成战斗画面:

再次踩上感叹号:又遇到一只(敌人可能不同)
1
  1. 1
    点 Fight!
点之前:遇敌框,敌人是 Galecrest真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)
点 Fight! 进入战斗界面,战斗刚开始23
  1. 2
    你的 Impike:一个新的 CFighter,HP 225 / 225
  2. 3
    敌人 Galecrest:另一个新的 CFighter
点之后:一个新的 BattleScreen真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
点 Fight! 进入战斗界面,战斗刚开始
4
  1. 4
    日志第一行:CBattle 的构造器写的(下面动画第 9 步)
同一张战斗截图,放大右下角日志区的第一行真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

从点按钮到战斗画面出来,调用顺序是这样的。动画上面是顺序图,下面是这一步正在运行的代码,最后一步换成真实截图:

COverworldScreenCGameWindowCOverworldCencounterCbattlecloseDialog()1startBattle()2createBattle()3createBattle(…)4new Fighter ×2new Battle(…)5battle6battle7showScreen(new BattleScreen(…))8
点 Fight!:从按钮到战斗画面
OverworldScreen:Fight! 按钮的 lambdaui/OverworldScreen.java第 85–88 行
85 dialog.addButton("Fight!", event -> {
86 closeDialog();
87 getNavigator().startBattle();
88 });
GameWindow:startBattle 和 showScreenui/GameWindow.java第 65–68、101–105 行
65 @Override
66 public void startBattle() {
67 showScreen(new BattleScreen(world.createBattle(), images, this));
68 }
⋯ 省略第 69–100 行
101 private void showScreen(GameScreen screen) {
102 currentScreen = screen;
103 root.setCenter(screen.getView());
104 updateMenu();
105 }
Overworld.createBattleworld/Overworld.java第 81–84 行
81 /* Make the battle for the current encounter. */
82 public Battle createBattle() {
83 return encounter.createBattle(player.getInventory());
84 }
Encounter.createBattleworld/Encounter.java第 52–57 行
52 /* Start a battle where the terrain changes the stats of both fighters. */
53 public Battle createBattle(Inventory inventory) {
54 Fighter player = new Fighter(playerMokepon, getPlayerEffect());
55 Fighter enemyFighter = new Fighter(enemy, getEnemyEffect());
56 return new Battle(player, enemyFighter, inventory);
57 }
Fighter 的构造器battle/Fighter.java第 28–38 行
28 /* Prepare a Mokepon for a battle on a terrain that gives it the given effect.
29 * Every stat is multiplied, rounded and kept inside its legal range.
30 */
31 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
32 this.mokepon = mokepon;
33 for (Stat stat : Stat.values()) {
34 int value = stat.limit(terrainEffect.adjust(mokepon.getStat(stat)));
35 startingStats.put(stat, value);
36 stats.put(stat, value);
37 }
38 }
Battle 的两个构造器battle/Battle.java第 25–38 行
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;
37 log.add("Player chose " + player.getName() + ". Began a fight against enemy "
38 + enemy.getName() + "!");
点 Fight! 进入战斗界面,战斗刚开始
123
  1. 1
    HP 225 / 225:CFighter 的值,150 × 1.5
  2. 2
    能量 33 / 33:22 × 1.5
  3. 3
    日志第一行:CBattle 构造器第 37–38 行写的
最后一步:开打时的战斗画面(裁了下半部分)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
  1. 你点 Fight!,第 3 节存进按钮的那段 lambda 现在才运行。第一句 closeDialog()(父类 AGameScreen 的方法)把黑色层从画面上拿掉,字段 dialog 设回 null。先关框,是因为打完仗回来时,用的还是这个 COverworldScreen 对象,框不能还盖着。
  2. 第二句 getNavigator().startBattle()。getNavigator() 交回父类 AGameScreen 里存着的 IGameNavigator(一个接口),CGameWindow 实现了它,所以运行的是 CGameWindow 的 startBattle。画面自己不能换画面,只能请窗口来换(第 6 章)。
  3. CGameWindow 第 67 行 showScreen(new BattleScreen(world.createBattle(), images, this))。Java 先算最里面的括号:先调用 world.createBattle(),拿到结果才能 new CBattleScreen,最后才 showScreen。另外两个参数:images 是 CGameWindow 的字段,一个 CGameImages 对象,存着画面要用的图片路径(类型图标、地形、方向按钮、道具、玩家);this 就是这个 CGameWindow 自己,CBattleScreen 把它当成自己的 navigator(第 6 章第 2 节)。
  4. COverworld 的 createBattle 只有一行:encounter.createBattle(player.getInventory())。encounter 是第二次踩上去时新造的那个(敌人是 Galecrest);第一次那个在 Run Away! 时已经扔掉了。背包交给战斗,因为战斗里能用道具(Impike 背包里有 1 瓶 Poison)。
  5. CEncounter 第 54 行:new Fighter(playerMokepon, getPlayerEffect()),getPlayerEffect() 就是 FIRE.getEffectOn(FOREST) = ADVANTAGE。第 55 行给敌人:LIGHTNING.getEffectOn(FOREST) = DISADVANTAGE。真正 new 出两个 CFighter 的是 CEncounter,不是 CGameWindow,也不是 CBattle。
  6. CFighter 的构造器对 6 个数值挨个做同一件事:Stat.values() 交回 EStat 的 6 个常量,第 33 行的 for-each 一个一个看。第 34 行拿 CMokepon 本来的值,terrainEffect.adjust(...) 乘倍数再四舍五入,stat.limit(...) 压回合法范围;第 35、36 行同时存进 startingStats(开打时的值)和 stats(现在的值)。Impike 的 HP:150 × 1.5 = 225。全部 12 个数值在动画下面算。
  7. 回到 CEncounter 第 56 行:new Battle(player, enemyFighter, inventory)(箭头 5:虚线 + 实心箭头,造一个新对象)。交过去的是刚造好的两个 CFighter 和背包。注意这里的 player 是第 54 行的局部变量,类型是 CFighter,不是 CPlayer。
  8. CBattle 有两个构造器(重载)。被调用的是三个参数的这个(第 25–27 行),它自己不存任何字段,只有一行 this(...)。this(...) 写在构造器第一行,意思是“去运行这个类的另一个构造器”:它把收到的三样,再加上新 new 的 CRandomEnemyStrategy 和 Random,一共 5 个值,交给第 30–31 行那个五参数的构造器。
  9. 五参数的构造器(从第 30 行开始)才真正存东西:第 32–36 行用 this.x = x 存进 5 个 final 字段;第 37–38 行写下日志第一行 “Player chose Impike. Began a fight against enemy Galecrest!”。整个项目里只有 CEncounter 第 56 行 new 了 CBattle,用的是三参数的版本;五参数的版本只被它通过 this(...) 调用。五参数的版本留着给“想换一种敌人策略、或者交进一个指定的 Random”的时候用(第 29 行注释:“a chosen enemy strategy and random number generator”)。
  10. 造好的 CBattle 一层一层交回去:CEncounter 交给 COverworld,COverworld 交给 CGameWindow。COverworld 自己不存这个 battle,它只留着 encounter,等打赢了再处理感叹号。
  11. 回到 CGameWindow 第 67 行:用这个 battle new 一个新的 CBattleScreen,再 showScreen(第 101–105 行):currentScreen 换成它,窗口中间换成它的画面,菜单重新检查一遍(现在 Run away 能点了)。
  12. 结果就是本节开头那张战斗截图(上面裁了它的下半部分)。HP 条上写着 225 / 225,能量 33 / 33:CFighterPanel 显示的是 CFighter 的值,不是 CMokepon 的(地图卷轴上还是 150 和 22)。日志区第一行,就是第 9 步 CBattle 构造器写的那一句。

数值是怎么变的

先对比同一局里地图卷轴和战斗画面上的数字。左边是 Run Away! 以后的地图卷轴,右边是开打时你的两根条(敌人的条上没有数字,第 14 章):

在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
12
  1. 1
    HP: 150(CMokepon 本来的值)
  2. 2
    Energy: 22
地图卷轴:一直是 Mokepon 的数据真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
点 Fight! 进入战斗界面,战斗刚开始
12
  1. 1
    HP 225 / 225 = 150 × 1.5
  2. 2
    能量 33 / 33 = 22 × 1.5
战斗里:Fighter 的数据真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

数字是第 34 行算出来的。225 / 225 左边的 225 是“现在的 HP”,右边是“最大 HP”,分别来自两张表:

Fighter:两张数值表battle/Fighter.java第 21–38、52–62 行
21 private final Mokepon mokepon;
22 /* The stats at the start of this battle, after the terrain effect. */
23 private final Map<Stat, Integer> startingStats = new EnumMap<>(Stat.class);
24 /* The stats right now. They only change through the methods below. */
25 private final Map<Stat, Integer> stats = new EnumMap<>(Stat.class);
26 private final List<StatusEffect> effects = new ArrayList<>();
27
28 /* Prepare a Mokepon for a battle on a terrain that gives it the given effect.
29 * Every stat is multiplied, rounded and kept inside its legal range.
30 */
31 public Fighter(Mokepon mokepon, TerrainEffect terrainEffect) {
32 this.mokepon = mokepon;
33 for (Stat stat : Stat.values()) {
34 int value = stat.limit(terrainEffect.adjust(mokepon.getStat(stat)));
35 startingStats.put(stat, value);
36 stats.put(stat, value);
37 }
38 }
⋯ 省略第 39–51 行
52 public int getStat(Stat stat) {
53 return stats.get(stat);
54 }
55
56 public int getHp() {
573 return getStat(Stat.HP);
58 }
59
60 public int getMaxHp() {
614 return startingStats.get(Stat.HP);
62 }
点 Fight! 进入战斗界面,战斗刚开始
34
  1. 3
    现在的 HP:第 57 行 getHp() 读 stats
  2. 4
    最大 HP:第 61 行 getMaxHp() 读 startingStats
开打时两张表的值一样,所以两边都是 225真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

两张表都是 EnumMap<Stat, Integer>(EnumMap:键是枚举 EStat 的 6 个常量,值是整数)。第 23、25 行 new EnumMap<>(Stat.class) 里的 Stat.class 告诉 EnumMap:键是 EStat 这个枚举。put(键, 值) 存进去(第 35、36 行),get(键) 取出来(第 53、61 行)。第 26 行的 effects 是这场战斗里身上的状态效果,开打时是空的(第 13 章)。打起来以后 stats 会变(掉血、花能量),startingStats 不变,所以条能显示“剩多少 / 一共多少”。第 34 行的 stat.limit(...) 是 EStat 里的方法:

Stat:每个数值的合法范围model/Stat.java第 3–10、26–29 行
3/* The six stats of a Mokepon, and the legal range of each one. */
4public enum Stat {
5 HP("HP", 1, 1000),
6 ENERGY("Energy", 1, 100),
7 STRENGTH("Strength", 1, 100),
8 ACCURACY("Accuracy", 1, 100),
9 DEFENCE("Defence", 1, 100),
10 DODGE("Dodge", 1, 100);
⋯ 省略第 11–25 行
26 /* Move a value into the legal range of this stat. */
27 public int limit(int value) {
28 return Math.max(minimum, Math.min(value, maximum));
29 }

HP 只能在 1–1000 之间,其他 5 个只能在 1–100 之间。Math.max(minimum, Math.min(value, maximum)) 就是“太大就变成上限,太小就变成下限”(限制范围)。现在把 Impike 和 Galecrest 的 12 个数值全部算一遍:Impike 是 FIRE,在 FOREST 上 ADVANTAGE,乘 1.5;Galecrest 是 LIGHTNING,DISADVANTAGE,乘 0.5。本来的值来自数据文件第 155–162 行和第 375–382 行:

数值Impike ×1.5Galecrest ×0.5
HP150 × 1.5 = 225.0 → 225185 × 0.5 = 92.5 → 93
Energy22 × 1.5 = 33.0 → 3328 × 0.5 = 14.0 → 14
Strength76 × 1.5 = 114.0 → 114 → 上限 10073 × 0.5 = 36.5 → 37
Accuracy74 × 1.5 = 111.0 → 111 → 上限 10086 × 0.5 = 43.0 → 43
Defence45 × 1.5 = 67.5 → 6859 × 0.5 = 29.5 → 30
Dodge70 × 1.5 = 105.0 → 105 → 上限 10079 × 0.5 = 39.5 → 40

每格的顺序就是第 34 行从里往外:mokepon.getStat(stat) 取本来的值 → adjust 乘倍数并四舍五入 → limit 压回范围。这些正是程序真实运行时两个 CFighter 里的值;屏幕上能直接看到的只有你的 HP 225 和能量 33。第 1 章算 Lance 的伤害时用的 Strength 100 和 Defence 30,就是这张表里的数。

为什么不直接改 CMokepon 的数值?因为 CMokepon 是数据文件里的那一只,下一场还要用,选择界面和地图卷轴也在显示它;第 2 节还说过,敌人有可能就是你自己那个 CMokepon 对象。CFighter 的类注释说:它只属于这一场战斗,打完就扔掉,所以数值自动“回到原样”。CMokepon 和 CFighter 的关系是 第 10 章的主题。

8. 点 Run Away!:退回上一格

第一次遇到 Hexwing 时,我们点的是 Run Away!。前后两张图裁的是同一块:

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
1
  1. 1
    点之前:玩家在 (17,17),盖住了感叹号
框还开着(黑色层盖着地图)真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
12
  1. 1
    点之后:玩家回到 (18,17)
  2. 2
    感叹号还在 (17,17)
点了 Run Away!真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在

点 Run Away! 运行的是第 3 节存进去的第二段 lambda。整个过程只有三个对象:

COverworldScreenCOverworldCplayercloseDialog()1runAway()2encounter = nullmoveBack()3onPlayerMoved()4update():重画地图5
点 Run Away!:画面没换,只是玩家退了一格

按运行的顺序看代码。先是按钮里的那段 lambda:

OverworldScreen:Run Away! 按钮的 lambda(onEncounter 里)ui/OverworldScreen.java第 89–92 行
89 dialog.addButton("Run Away!", event -> {
90 closeDialog();
91 world.runAway();
92 });

COverworldScreen 第 90 行先关框,第 91 行调用 world.runAway()。注意它直接调用字段 world,没有经过 getNavigator():逃跑不用换画面,地图画面自己就能处理。Fight! 要换成一个新的 CBattleScreen,只有 CGameWindow 能做,所以才要通过 navigator。

Overworld:runAway 和 winEncounterworld/Overworld.java第 86–100 行
86 /* Leave the encounter and go back to the tile before it. The marker stays on the map. */
87 public void runAway() {
88 encounter = null;
89 player.moveBack();
90 notifyListeners(listener -> listener.onPlayerMoved());
91 }
92
93 /* The battle was won, so remove the marker and count the win. */
94 public void winEncounter() {
95 Mokepon enemy = encounter.getEnemy();
96 objects.remove(encounter.getMarker());
97 player.addWin();
98 encounter = null;
99 notifyListeners(listener -> listener.onEncounterWon(enemy));
100 }
OverworldScreen.onPlayerMoved:最后回到画面ui/OverworldScreen.java第 72–77 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }

COverworldScreen 的 onPlayerMoved:第 75 行清空状态栏,第 76 行调用 update() 重画地图和按钮,于是玩家出现在 (18,17)。这和平时走一步收到的是同一个通知(第 8 章),画面并不知道这一步是“逃跑”。

CPlayer 怎么知道“上一格”在哪?它一直多记着一个位置:

Player:position 和 previousPositionworld/Player.java第 12–14、40–48 行
12 private Position position;
13 /* The tile before the last move, so the player can run back to it. */
14 private Position previousPosition;
⋯ 省略第 15–39 行
40 void moveTo(Position newPosition) {
41 previousPosition = position;
42 position = newPosition;
43 }
44
45 /* Go back to the tile the player stood on before the last move. */
46 void moveBack() {
47 position = previousPosition;
48 }

这一局里两个字段的值

时刻positionpreviousPosition
按 ← 之前(18,17)(18,16)
moveTo 以后(17,17)(18,17)
moveBack 以后(18,17)(18,17)

(18,16) 是捡 Poison 的那一格,玩家是从那里往下走到 (18,17) 的。

moveTo 每走一步,先把旧位置存进 previousPosition(第 41 行),再换成新位置。moveBack 只做一件事:position = previousPosition(第 47 行)。所以退回的永远是“踩上感叹号之前的那一格”。moveTo 和 moveBack 前面没有 public,都是包内可见的:只有 world 包里的类能调用,实际调用它们的只有 COverworld(第 70、89 行),ui 包里的画面不能直接挪动玩家。

这一章的整条线就走完了:数据文件和 CMapParser 把感叹号和 picker 配好;踩上去,COverworld 查地形、请 picker 挑敌人、造 CEncounter、通知画面;COverworldScreen 造遇敌框;点 Fight!,CEncounter 按地形造出两个 CFighter 和一个 CBattle;点 Run Away!,玩家退一格,感叹号留着。接下来的 第 10 章从这个刚造好的 CBattle 开始讲战斗。

自测

白色、红色、绿色感叹号在代码里有什么不一样?是三个不同的类吗?

不是。三种都是同一个类 CMarker。不一样的只有两样:CSprite 的颜色(只管样子),和字段 picker 里放的对象。CMapParser 的 parse 第 31–35 行给白色组 new RandomEnemyPicker(),红色组 new TerrainEnemyPicker(TerrainEffect.ADVANTAGE),绿色组 new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE),每组只 new 一个,组里的感叹号共用它。

English: All markers are Marker objects; the groups only differ in the EnemyPicker that MapParser passes to them.

从玩家踩上感叹号,到 CEncounter 被造出来,按顺序有哪些调用?CEncounter 是谁 new 的?

Overworld.move 发现玩家所在格子上有物体 → marker.interact(this) → world.startEncounter(this) → map.getTerrain(...) 查地形 → marker.chooseEnemy(...) → picker.pick(...) 挑出敌人 → COverworld 自己 new Encounter(marker, player.getMokepon(), enemy, terrain),存进字段 encounter → notifyListeners 调用 onEncounter(encounter)。

English: Overworld.startEncounter looks up the terrain, asks the marker to choose an enemy, creates the Encounter and notifies its listeners with onEncounter.

D 局里 Ironshell 踩白色感叹号 (3,3) 遇到 Frostback,框里为什么只有一行?那一行是什么?

(3,3) 的地形是 ROCKS。Ironshell 是 NORMAL,getEffectOn 永远返回 NEUTRAL,addTerrainMessage 不加提示。Frostback 是 ICE,ROCKS 是 ICE 的强项,返回 ADVANTAGE,于是加一句 “Your enemy will be at an advantage here!”。虽然是白色感叹号(随便挑),地形效果照样算:战斗里 Frostback 的 HP 是 230 × 1.5 = 345。

English: The picker only chooses the enemy; Encounter always applies the terrain effect to both Mokepon.

战斗里 Galecrest 的 HP 为什么是 93,不是 92?它的 Strength 呢?

Galecrest 是 LIGHTNING,FOREST 是它的弱项,DISADVANTAGE 的倍数是 0.5。TerrainEffect.adjust 写的是 (int) Math.round(value * multiplier):185 × 0.5 = 92.5,Math.round 遇到 .5 往大的方向进,得 93。如果只写 (int),小数会被直接去掉,就是 92。Strength:73 × 0.5 = 36.5 → 37。

English: TerrainEffect.adjust uses Math.round, so 185 × 0.5 = 92.5 is rounded up to 93 instead of being cut to 92.

绿色感叹号 (17,17) 第一次挑到 Hexwing,第二次挑到 Galecrest。为什么会不同?为什么两次都是 LIGHTNING?

TerrainEnemyPicker.pick 每次被调用都重新挑。它先把“在 FOREST 上的效果正好是 DISADVANTAGE”的 Mokepon 放进 matches:只有 LIGHTNING 类型的 5 只(FOREST 是 LIGHTNING 的弱项)。然后 random.nextInt(5) 随机取一只,第一次是 0(Hexwing),第二次是 3(Galecrest)。

English: TerrainEnemyPicker.pick runs again on every step onto the marker; it keeps only the LIGHTNING Mokepon, which are weak on FOREST, and picks one of them at random.

点了 Run Away! 以后,为什么还能再踩同一个感叹号?玩家是怎么回到 (18,17) 的?

Overworld.runAway() 只做三件事:encounter = null、player.moveBack()、通知 onPlayerMoved()。它不碰 objects 列表,只有打赢后的 winEncounter 才会 objects.remove(...) 把感叹号拿掉。CPlayer 每次 moveTo 都把旧位置存进 previousPosition,moveBack 就是 position = previousPosition,回到 (18,17)。

English: Run Away clears the encounter and moves the player back to the previous tile; the marker stays because only winEncounter removes it.

Fight! 的按钮代码通过 getNavigator().startBattle(),Run Away! 却直接调用 world.runAway()。为什么不一样?两个 CFighter 和 CBattle 是谁 new 的?

Fight! 要换成一个新的 CBattleScreen,换画面只有窗口能做,所以 COverworldScreen 通过接口 IGameNavigator 请 CGameWindow 来做。Run Away! 留在同一个画面,只是规则那边退一格,所以直接找自己的字段 world。CGameWindow.startBattle 调用 world.createBattle(),COverworld 再调用 encounter.createBattle(inventory),真正 new Fighter ×2 和 new Battle 的是 CEncounter。

English: Encounter.createBattle creates one Fighter for each side with its own TerrainEffect, and then the Battle; GameWindow only asks for it and shows a new BattleScreen.

想加一种蓝色感叹号,只挑 NORMAL 类型的敌人。要改哪些地方?哪些类不用改?这说明了什么设计?

写一个新类(比如叫 NormalEnemyPicker)implements IEnemyPicker,在它的 pick 里只留下 NORMAL 的;在 CMapParser.parse 里加一行 parseMarkers(data, "map.mokeponNormal", new NormalEnemyPicker());数据文件里加这一组的位置、图片和颜色。CMarker、COverworld、CEncounter、COverworldScreen 都不用改,因为 CMarker 只认接口 IEnemyPicker。这是策略模式,也符合开闭原则。

English: EnemyPicker is a Strategy: a new way of choosing enemies is a new class that implements the interface, and Marker does not change (Open/Closed Principle).

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