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) 的绿色感叹号在图的右下角。




注意第 2 张和第 4 张:同一个感叹号,第一次是 Hexwing,第二次是 Galecrest。敌人不是事先定好的,而是每次踩上去时现挑的。第 4 张以后点了 Fight!,换成战斗画面(第 7 节):地图卷轴上 Impike 的 HP 是 150,到了战斗里变成 225。这两件事都是这一章要讲清楚的。
1. 地图上的感叹号:三组,三种挑法
地图上一共 10 个感叹号,三种颜色。它们的位置和颜色都写在数据文件 mokepon.game 里(第 1 章第 2 节读过这种 名字=值 的写法)。左边是数据文件,右边是刚进地图时的截图,编号一一对应:
4391map.mokeponRandom.0=3,3440map.mokeponRandom.1=14,6441map.mokeponRandom.2=12,12442map.mokeponRandom.3=4,10443map.mokeponRandom.image=assets/sprite_exclamation.png444map.mokeponRandom.colour=white445 4462map.mokeponStrong.0=5,4447map.mokeponStrong.1=16,4448map.mokeponStrong.2=12,16449map.mokeponStrong.image=assets/sprite_exclamation.png450map.mokeponStrong.colour=red451 4523map.mokeponWeak.0=2,12453map.mokeponWeak.1=6,16454map.mokeponWeak.2=17,17455map.mokeponWeak.image=assets/sprite_exclamation.png456map.mokeponWeak.colour=lime怎么读这几行
同一组的位置写成 组名.0、组名.1……,一行一个格子。3,3 就是第 3 列、第 3 行(格子坐标:先列后行,都从 0 数)。
每组还有一行 .image(三组都是同一张 sprite_exclamation.png)和一行 .colour(white、red、lime)。

- 1白色 4 个,
map.mokeponRandom.0–.3:(3,3)、(14,6)、(12,12)、(4,10) - 2红色 3 个,
map.mokeponStrong.0–.2:(5,4)、(16,4)、(12,16) - 3绿色(lime)3 个,
map.mokeponWeak.0–.2:(2,12)、(6,16)、(17,17)
颜色只是给同一张图染色,它本身不决定挑什么敌人。决定挑法的东西在下面这段代码里。
MapParser:每组配一个 picker
程序一启动,CMain 就用 CMapParser 把数据文件里 map. 开头的行变成一个 CGameMap 对象(启动顺序见 第 5 章)。三组感叹号是在它的 parse 方法里造出来的,红色数字和上面的组一样:
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,每次交过去三样东西:数据、组名、一个“会挑敌人的对象”。三次只差最后一样:
- 标注 1,白色组
map.mokeponRandom:new RandomEnemyPicker(),36 只里随便挑。 - 标注 2,红色组
map.mokeponStrong:new TerrainEnemyPicker(TerrainEffect.ADVANTAGE),挑在这块地形上占优势的(强敌)。 - 标注 3,绿色组
map.mokeponWeak:new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE),挑在这块地形上吃亏的(弱敌)。
这三个对象代码里统称 picker(挑选者)。红色和绿色用的是同一个类 CTerrainEnemyPicker,只是 new 的时候交给构造器的值不同:一个要 ADVANTAGE,一个要 DISADVANTAGE。这两个值是枚举(enum) ETerrainEffect 的常量(第 4 节)。
parse 里其余几行把这些东西装进一个列表:
- 第 30 行:先 new 一个空的列表
objects(类型List<MapObject>,List),地图上所有的物体最后都放进它。 - 第 31–35 行:
parseMarkers交回的不是一个感叹号,而是一个 List(这一组的几个 CMarker)。objects.addAll(...)把这个列表里的每一个都加进objects。三次以后,objects里有 4 + 3 + 3 = 10 个 CMarker。 - 第 36 行同样用
addAll加进 3 个问号箱。第 37 行只有一扇门,parseExit交回的是一个 CExit 对象,不是列表,所以用add(加一个)。 - 第 39 行用地形
tiles(第 26 行读出来的)、起点和objects造出 CGameMap(第 8 章第 2 节)。所以刚开局时地图上一共 14 个物体。
再看 parseMarkers 怎么用这个 picker:
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 }- 第 57 行:方法的样子。
private,只有 CMapParser 自己用。返回类型List<MapObject>:交回这一组造好的感叹号。第三个参数EnemyPicker picker的类型是接口 IEnemyPicker,不是某个具体的类,所以三种 picker 都能传进来(第 6 节的关键)。 - 第 58 行:为这一组造一个 CSprite(图片路径 + 颜色)。
group + ".image"拼成名字map.mokeponRandom.image,getText读出它的值assets/sprite_exclamation.png;颜色同理是white。 - 第 59 行:new 一个空的列表
markers,用来装这一组的感叹号。 - 第 60 行:数这一组有几个。CGameData 的
countEntries(prefix, suffix)从 0 开始,一个一个试“prefix + 数字 + suffix”这个名字在不在数据文件里,遇到第一个不在的就停。这里 prefix 是"map.mokeponRandom.",suffix 是空字符串""(什么都不加),所以试的就是map.mokeponRandom.0、.1、.2、.3、.4:.4不存在,所以白色组是 4 个。.image和.colour不是数字,不会被数进去。 - 第 61 行:计数的 for 循环。
int index = 0从 0 开始;每一轮之前先检查index < count;做完一轮index++加 1。所以index依次是 0、1、2、3,一共跑 4 轮。 - 第 62 行:
group + "." + index拼成名字map.mokeponRandom.0(字符串和数字用+连起来,数字自动变成文字,字符串拼接)。getPosition读出它的值3,3,按逗号切开,造成new Position(3, 3)。 - 第 63 行:
new Marker(position, sprite, picker),再markers.add(...)放进列表。 - 第 65 行:
return markers,交回给parse第 31 行,在那里被addAll进objects。
关键在第 63 行:同一组的每个 CMarker 拿到的都是同一个 sprite 对象和同一个 picker 对象。picker 只在 parse 的第 31–35 行 new 了一次,作为参数传进来,循环里没有再 new。所以 4 个白色感叹号的字段 picker 都指着同一个 CRandomEnemyPicker 对象(一个对象可以被好几个字段指着,见 引用)。画出来是这样:
CMarker 类自己只有一个字段:private final EnemyPicker picker。final 的意思是:这个字段只能在构造器里赋值一次,以后不能再指向别的对象,所以一个感叹号的挑法从它被造出来起就定死了。它的类型写的是接口(interface) IEnemyPicker,不是某个具体的类,所以三种 picker 都能放进去。位置和图片存在父类 AMapObject 里(构造器第一行 super(position, sprite))。三种颜色用的是同一个 Marker 类,代码里没有“白色 Marker 类”“红色 Marker 类”。

每个感叹号脚下是什么地形
红色和绿色的 picker 要看地形挑敌人,所以先查清楚每个感叹号站在什么地形上。地形也在数据文件里:map.tiles.0 到 map.tiles.19 一共 20 行,每行 20 个字母,一个字母一格(R 石头、G 草地、P 小路、W 水、F 森林、B 障碍、O 深海)。左边绿色的行,就是有感叹号的那几行:
402map.tiles.0=RRRRRRPPRRRRRRRRRRRR403map.tiles.1=RRRRRRPPRRRRRRRRRRRR404map.tiles.2=RRRRGGPPRRGGRRPPPPPP405map.tiles.3=RRRRGGPPRRGGRRPPPPPP406map.tiles.4=GGGGGGPPPPGGGGPPRRGG407map.tiles.5=GGGGGGPPPPGGGGPPRRGG408map.tiles.6=GGWWWWWWPPWWWWPPWWGG409map.tiles.7=GGWWWWWWPPWWWWPPWWGG410map.tiles.8=GGWWGGGGPPPPPPPPWWWW411map.tiles.9=GGWWGGGGPPPPPPPPWWWW412map.tiles.10=GGWWGGGGPPGGGGBBBBBB413map.tiles.11=GGWWGGGGPPGGGGBBBBBB414map.tiles.12=GGWWGGBBPPGGGGBBBBBB415map.tiles.13=GGWWGGBBPPGGGGBBBBBB416map.tiles.14=WWWWWWBBPPGGGGBBBBBB417map.tiles.15=WWWWWWBBPPGGGGBBBBBB418map.tiles.16=OOOOWWGGPPPPGGGGFFFF419map.tiles.17=OOOOWWGGPPPPGGGGFFFF420map.tiles.18=OOOOWWGGGGPPFFFFFFFF421map.tiles.19=OOOOWWGGGGPPFFFFFFFF
- 1石头 ROCKS(R):(3,3) 白、(16,4) 红
- 2草地 GRASS(G):(4,10)、(12,12) 白,(5,4)、(12,16) 红,(6,16) 绿
- 3小路 PATH(P):(14,6) 白
- 4水 WATER(W):(2,12) 绿
- 5森林 FOREST(F):(17,17) 绿
查一个格子 (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 ROCKS | 36 只随便挑 |
| 白 | (14,6) | P PATH | 36 只随便挑 |
| 白 | (12,12) | G GRASS | 36 只随便挑 |
| 白 | (4,10) | G GRASS | 36 只随便挑 |
| 红 | (5,4) | G GRASS | 在草地占优势的:LIGHTNING 5 只 |
| 红 | (16,4) | R ROCKS | 在石头上占优势的:ICE 6 只 |
| 红 | (12,16) | G GRASS | LIGHTNING 5 只 |
| 绿 | (2,12) | W WATER | 在水里吃亏的:FIRE 6 只 |
| 绿 | (6,16) | G GRASS | 在草地吃亏的:ICE 6 只 |
| 绿 | (17,17) | F FOREST | 在森林吃亏的:LIGHTNING 5 只 |
2. 踩上去:规则这一边,从 move 到 Encounter
先看这一步前后的屏幕(两边裁的是地图右下角的同一块):

- 1玩家(白色星形)在 (18,17)
- 2(17,17) 的绿色感叹号,脚下是森林

- 1玩家到了 (17,17),把感叹号盖住了。整块变暗,是对话框的黑色半透明层盖在上面
按一下 ←,屏幕上出了两件事:玩家走到 (17,17),然后弹出一个框。这两件事中间,规则那一边(world 包)已经做完了所有决定:这一格是什么地形、遇到谁、把这次遇敌记下来。这一节只看规则这一边,下一节再看画面那一边怎么把框画出来。
下面的动画是一张顺序图(读法见第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,虚线箭头 = 返回;第 5 章还讲过一种:虚线 + 实心箭头 = new 一个新对象,创建消息)。正在发生的那一步会变成流动的红色虚线,这只是“现在亮着”的意思;它本来是实线还是虚线,看它没亮的时候。
竖线顶上写的是代码里指着这个对象的名字:map 是 COverworld 的字段(一个 CGameMap),marker 是参数(踩到的那个 CMarker),picker 是 CMarker 的字段,encounter 是新造的 CEncounter。顺序图下面是这一步正在运行的那几行代码(红色底的行)。第一步和最后一步换成真实截图:按 ← 之前,和这一串调用全部跑完以后。

- 1玩家(白色星形)在 (18,17)
- 2要踩上去的感叹号 (17,17),脚下是森林
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());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 }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 @Override25 void interact(Overworld world) {26 world.startEncounter(this);27 }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 }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 }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 }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 }
- 3通知发出去以后屏幕上出现的遇敌框(下一节讲它是怎么造的)
- 先看按 ← 之前的屏幕(上面的截图):玩家(标注 1)站在 (18,17),左边一格就是 (17,17) 的绿色感叹号(标注 2)。你按 ←,COverworldScreen 调用
world.move(Direction.LEFT)。下一步开始看 COverworld 的代码。 - 第 67 行先问能不能走:(17,17) 是森林,能走。第 70 行
player.moveTo(...)把玩家的位置改成 (17,17),第 71 行先通知一次onPlayerMoved(),画面改好玩家的位置(第 8 章;真正画到屏幕上,要等这一整串调用都跑完)。到这里还没有“遇敌”的事,顺序图里只亮着最上面那个说明框。 - 第 74 行把地图上剩下的 13 个物体(10 个感叹号、2 个问号箱、1 扇门)挨个拿出来。循环的是一份复制出来的列表
new ArrayList<>(objects):第 73 行注释说,问号箱被捡走时会把自己从objects里删掉,边循环边删同一个列表会出错(遍历时修改集合,第 8 章第 4 节)。第 75 行问每个物体isAt(玩家的新位置),只有 (17,17) 的感叹号回答 true,于是第 76 行调用它的interact(this)(箭头 1),把这个 COverworld 自己交给它。 - CMarker 的
interact(第 24–27 行)自己不改任何东西,只调用world.startEncounter(this)(箭头 2):请 COverworld 开始一次遇敌,this就是这个感叹号自己。AMapObject 的注释定了这个规矩:每种物体 “asks the world to make the change”。问号箱也一样,它调用的是world.collect(this)。 - 回到 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。 - 第 105 行请感叹号挑敌人:
marker.chooseEnemy(mokepons, terrain, random)(箭头 5),交过去三样:字段mokepons(数据文件里全部 36 只 Mokepon,也包括你自己选的 Impike)、FOREST、这一局的随机数对象random。 - CMarker 的
chooseEnemy只有一行(第 21 行):原样转交给自己的字段picker(箭头 6),这叫委托。CMarker 不知道 picker 是哪个类,只知道它是一个 IEnemyPicker。(17,17) 属于绿色组,所以这个 picker 就是第 1 节里那个new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)。 - CTerrainEnemyPicker 的
pick从 36 只里留下“在 FOREST 上吃亏”的,也就是 5 只 LIGHTNING 类型(第 23–26 行),再随机挑一只(第 33 行)。这次挑到 Hexwing,交回给感叹号(箭头 7)。每一行怎么做,第 6 节有单独的动画。 chooseEnemy把 Hexwing 原样交回 COverworld(箭头 8),第 105 行把它存进局部变量enemy。Hexwing 不是复制出来的新对象,就是mokepons列表里本来那个 CMokepon 对象。36 只里也有你自己选的那只,所以白色感叹号有可能挑到同一个 Impike 对象。这没关系:开打时 CEncounter 给双方各 new 一个 CFighter,CMokepon 本身从来不被改(第 7 节)。- 第 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。 - 第 107 行通知所有登记过的监听者:
notifyListeners(listener -> listener.onEncounter(encounter)),意思是“对每一个监听者调用一次onEncounter,把这个 encounter 交给它”。这一局只有一个监听者,就是 COverworldScreen。括号里的 lambda、Consumer 和第 125–129 行的循环,第 8 章第 6 节逐行讲过。 - 规则这一边到这里就做完了,
move跑完,屏幕才重新画出来:玩家站在 (17,17),上面盖着遇敌框(上面的截图)。这个框是 COverworldScreen 收到onEncounter以后造的,下一节接着看。
第 106 行造出的 CEncounter(“这一次遇敌”)存着什么、被谁存着:
这个 CEncounter 一直留在 COverworld 的字段 encounter 里,等你在框里做选择:点 Fight! 时用它造战斗(第 7 节),点 Run Away! 时把字段设回 null(第 8 节)。它还记着 marker,是为了打赢以后知道该把哪个感叹号从地图上拿掉(winEncounter,第 15 章)。
3. 画面这一边:遇敌框是怎么造出来的
上一节最后一步通知了监听者。COverworldScreen 是唯一的监听者:CGameWindow 开始一局时用 world.addListener(overworldScreen) 登记过它(第 8 章)。于是轮到 COverworldScreen 的 onEncounter。先看它调用了谁(读法同上):
这 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 玩家。
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);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);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 }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 }
- 1标题
- 2两行地形提示
- 3Fight!
- 4Run Away!
5黑色层6- 5黑色半透明层
layer,铺满整个游戏区 - 6玩家在 (17,17),被盖在下面
- (上面顺序图的箭头 1)COverworld 调用
listener.onEncounter(encounter)。它手里的 listener 类型是接口 IOverworldListener,它不知道对方是一个画面;真正运行的是 COverworldScreen 改写(@Override)的onEncounter(第 80 行)。上面两张截图是框最后的样子,这一步它还一点都没有。 - (箭头 2–4,都在第 81 行)先拼标题
"Encountered a " + encounter.getEnemy().getName():getEnemy()交回 Hexwing 这个 CMokepon,getName()交回它的名字,+把两段文字连成 “Encountered a Hexwing”。然后new ModalDialog(标题)(造一个新对象),存进局部变量dialog。 - 钻进 CModalDialog 的构造器看看(这里换成了它的代码)。第 38 行造标题
Label,字号是常量HEADING_SIZE(第 28 行定义,值是 32)。第 42 行造白框box:一个竖排的VBox,从上往下放三样:标题、放提示的messages、放按钮的buttons(都是 CModalDialog 的字段,现在还都是空的)。第 47 行让白框只和里面的东西一样大,不铺满屏幕。第 50–52 行:黑色半透明层layer铺满整个游戏区,白框放在它正中间。 - (箭头 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 节讲。 - (箭头 7)第 82–84 行是 for-each 循环,读作“对列表里的每一个 message”。列表里有几句,就调用几次
dialog.addMessage(message)。这次两句,调用两次;列表是空的,循环就一次也不跑。 - 再钻进
addMessage(第 61–65 行):第 62 行 new 一个Label(一行字),第 63 行设成黑色,第 64 行放进字段messages这个VBox。VBox 从上往下排,所以两句提示一句一行(标注 2)。构造器第 47 行说过,白框只和里面的东西一样大,所以多一行字,白框就高一点(第 5 节)。 - (箭头 8)第 85–88 行加第一个按钮 Fight!。第二个参数
event -> { closeDialog(); getNavigator().startBattle(); }是一个 lambda:大括号里的两行现在不运行,只是交给按钮存起来,等你点了才运行(第 7 节)。 addButton里面(第 68–73 行):第 69 行 new 一个Button,第 70 行设样式(灰色圆角),第 71 行button.setOnAction(action)把那段 lambda 存进按钮,第 72 行放进字段buttons这个HBox。HBox 从左往右排,先加的在左边。- (箭头 9)第 89–92 行加第二个按钮 Run Away!,同样存起一段 lambda:
closeDialog(); world.runAway();(第 8 节)。它是第二个放进buttons的,所以排在 Fight! 右边(标注 4)。 - (箭头 10)第 93 行
showDialog(dialog),这是父类 AGameScreen 的方法(这里换成了它的代码;protected = 子类能调用)。第 70 行先关掉旧框(这时没有)。第 71 行把新框记在字段dialog里。第 72 行view.getChildren().add(dialog.getLayer()):view是 AGameScreen 里的一个Pane,整个画面都在它里面;黑色层加到它的子节点列表末尾。JavaFX 里排得越靠后,画得越靠上(绘制顺序),所以黑色层盖住了地图和按钮。第 73 行让第一个按钮 Fight! 拿到键盘焦点。 - 框出来了。黑色层(标注 5)盖住整个游戏区,后面的地图和方向按钮都点不到。字段
dialog不再是 null,所以isDialogOpen()(第 83–85 行)返回 true:handleKeyPress和movePlayer都直接不理方向键。onEncounter返回,第 2 节那个move也跑完了。程序停下来,等你点一个按钮。
把两节合起来看分工:COverworld 只说“遇敌了,这是 encounter”,它不知道框长什么样,连对方是不是画面都不知道;COverworldScreen 只管把框画出来,它不决定遇到谁。规则和画面分开(模型和界面分离),中间只隔着接口 IOverworldListener(观察者模式)。遇敌框也不是一个新画面:CGameWindow 的 currentScreen 还是这个 COverworldScreen,框只是盖在它上面的一层。
4. 类型和地形:MokeponType 和 TerrainEffect

- 1“at an advantage”:你的 Impike 在这块地上占优势
- 2“at a disadvantage”:敌人 Hexwing 在这块地上吃亏
遇敌框说你的 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:每种类型一个强项、一个弱项
3/* This shows Mokepon Type. Each type except normal is strong on one terrain4 * 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 }每个常量括号里的两个值
| 类型 | 强项地形 | 弱项地形 | 几只 | |
|---|---|---|---|---|
| 1 | NORMAL | null | null | 14 |
| 2 | FIRE | FOREST | WATER | 6 |
| 3 | ICE | ROCKS | GRASS | 6 |
| 4 | WATER | WATER | ROCKS | 5 |
| 5 | LIGHTNING | GRASS | FOREST | 5 |
强项 = 字段 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),从上往下只问两个问题:
- 第 24 行:这块地形是我的强项吗?是 → 返回
ADVANTAGE,方法马上结束(提前 return)。 - 第 27 行:是我的弱项吗?是 → 返回
DISADVANTAGE。 - 都不是 → 第 30 行返回
NEUTRAL。
拿这一章的三只试一下:
FIRE.getEffectOn(FOREST):FOREST == FOREST(强项),第 24 行成立 → ADVANTAGE。这是 Impike。LIGHTNING.getEffectOn(FOREST):FOREST == GRASS?不是。FOREST == FOREST(弱项)?是 → DISADVANTAGE。这是 Hexwing 和 Galecrest。NORMAL.getEffectOn(FOREST):FOREST == null?不是。FOREST == null?不是 → NEUTRAL。地图上的格子永远有地形,不会是 null,所以 NORMAL 在哪儿都是 NEUTRAL。

把上面“每个常量括号里的两个值”那张表反过来,按地形排一遍(5 种能走的地形;BARRIER 和 OCEAN 不能走,上面不会有感叹号)。每一块地上,谁占优势、谁吃亏,其余类型都是 NEUTRAL:
| 地形 | ADVANTAGE(优势) | DISADVANTAGE(吃亏) |
|---|---|---|
| FOREST 森林 | FIRE | LIGHTNING |
| GRASS 草地 | LIGHTNING | ICE |
| PATH 小路 | 没有 | 没有 |
| ROCKS 石头 | ICE | WATER |
| WATER 水 | WATER | FIRE |
NORMAL 不在表里,因为它在哪儿都是 NEUTRAL。注意 PATH 那一行是两个“没有”:没有哪种类型把小路当强项或弱项。所以站在 (14,6) 小路上的白色感叹号,不管遇到谁,框里都不会有提示。第 1 节表格最后一列“从谁里面挑”,就是查的这张表:红色感叹号要 ADVANTAGE 那一列,绿色要 DISADVANTAGE 那一列。
TerrainEffect:乘几倍
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 }三个常量各存两样东西
| 常量 | 倍数 | 说明 | |
|---|---|---|---|
| 1 | ADVANTAGE | 1.5 | at an advantage |
| 2 | NEUTRAL | 1 | (空的) |
| 3 | DISADVANTAGE | 0.5 | at a disadvantage |
说明 = 字段 description,拼进遇敌框的提示(第 5 节)。倍数 = 字段 multiplier,开打时乘到每个数值上(第 7 节)。
第 23–25 行的 adjust(value) 就一行:(int) Math.round(value * multiplier),把一个数值乘上倍数,再四舍五入成整数。几个这一章真的会用到的例子:
| 调用 | 乘出来 | round 以后 |
|---|---|---|
ADVANTAGE.adjust(150) | 150 × 1.5 = 225.0 | 225 |
ADVANTAGE.adjust(45) | 45 × 1.5 = 67.5 | 68 |
DISADVANTAGE.adjust(185) | 185 × 0.5 = 92.5 | 93 |
NEUTRAL.adjust(240) | 240 × 1 = 240.0 | 240 |

5. 框里的提示:getTerrainMessages
现在能解释框里的字了。提示是 CEncounter 的 getTerrainMessages 拼的。代码里的红色数字 1、2,对应下面截图里框中的两行:
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 }
- 1第 47 行加的:你的 Impike(FIRE)在 FOREST 上 → ADVANTAGE
- 2第 48 行加的:敌人 Hexwing(LIGHTNING)在 FOREST 上 → DISADVANTAGE
- 第 36–42 行:两个 private 小方法。
getPlayerEffect()是playerMokepon.getType().getEffectOn(terrain):先拿你的 Mokepon 的类型(FIRE),再问它在这块地形(FOREST)上的效果,就是第 4 节的getEffectOn。getEnemyEffect()一样,换成敌人。 - 第 46 行:new 一个空的列表
messages。 - 第 47、48 行:调用两次
addTerrainMessage,先你、后敌人,顺序是写死的。交过去的是同一个列表对象,addTerrainMessage往这个列表里加,getTerrainMessages最后把它交回去。 - 第 60 行:效果是 NEUTRAL,就什么都不加。所以 NEUTRAL 的 description 是空的也没关系,它从来不会被用上。
- 第 61 行:否则拼一句:
who + " will be " + effect.getDescription() + " here!"。比如第 47 行这次拼的是 “Your mokepon” + “ will be ” + “at an advantage” + “ here!”。代码里写的是小写的 mokepon,所以框里也是小写。
四个真实的遇敌框
用这条规则,把所有截到的遇敌框都解释一遍。截图来自三局不同的游戏。为了好称呼,给这三局起个名字,后面第 11–17 章也一直这样叫:A 局 = 这一章一直在看的 Impike 这一局;B 局 = 玩家选了 Shellurk,踩的是 (12,12) 的白色感叹号;D 局 = 玩家选了 Ironshell,踩的是 (3,3) 的白色感叹号(C 局、E 局这一章用不上)。A 局有两个框,因为同一个感叹号踩了两次。下面按提示从多到少排:

- 1敌人 Hexwing(LIGHTNING)
- 2两行:你优势、敌人劣势

- 1敌人 Galecrest(LIGHTNING)
- 2还是这两行

- 1敌人 Frostback(ICE)
- 2只有一行:敌人优势

- 1敌人 Nightwing(NORMAL)
- 2放提示的
messages还在,但里面一个 Label 都没有,高度是 0
| 哪一局,地形 | 你 | 敌人 | 提示 |
|---|---|---|---|
| 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 行 |
- D 局为什么只有一行:你的 Ironshell 是 NORMAL,第 47 行什么都不加;Frostback 是 ICE,ROCKS 是 ICE 的强项,第 48 行加了 “Your enemy will be at an advantage here!”。
- D 局还说明一件重要的事:(3,3) 是白色感叹号,它的 picker 根本不看地形,挑到 ICE 只是碰巧。但 CEncounter 不管感叹号是什么颜色,都会按地形算双方的效果。picker 只决定“遇到谁”,地形效果对每一场遇敌都算。
- B 局为什么一行都没有:双方都是 NORMAL,两次
addTerrainMessage都什么都没加,列表是空的,onEncounter的 for-each 循环一次都不跑。 - 框的大小也跟着变:CModalDialog 让白框只和里面的东西一样大(
setMaxSize(USE_PREF_SIZE, …))。真实截图里白框的高度是:A 局 189、D 局 174、B 局 159 像素,每少一行提示就矮 15 像素。

6. 挑敌人:两种 picker 和策略模式
回到截图:A 局的两个框是同一个绿色感叹号,挑到两只不同的敌人,但都是 LIGHTNING;白色感叹号挑到的什么类型都有(D 局是 ICE,B 局是 NORMAL)。差别就在 picker。先看它们共同的接口和最简单的那个:
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}8/* Picks any Mokepon from the list. The terrain does not matter. */9public class RandomEnemyPicker implements EnemyPicker {10 11 @Override12 public Mokepon pick(List<Mokepon> mokepons, Terrain terrain, Random random) {13 return mokepons.get(random.nextInt(mokepons.size()));14 }15}- IEnemyPicker 是接口:只写了一个方法
pick的样子(交进来 Mokepon 列表、地形、随机数,交出去一只 CMokepon),没有大括号、没有内容。注释第一个词就是 “Strategy”。 - CRandomEnemyPicker implements 这个接口,必须写出
pick的内容(@Override)。它只有一行:random.nextInt(mokepons.size())得到 0 到 35 之间的一个整数(Random),再用get取出列表里那一只。参数terrain根本没用上,注释说 “The terrain does not matter”。它还是得写这个参数,因为接口规定了pick的样子,所有 picker 都一样。 - 列表的顺序就是数据文件的顺序(
mokepon.0Sealance 到mokepon.35Stormtalon)。D 局的 Frostback 是mokepon.11,B 局的 Nightwing 是mokepon.5,所以那两次nextInt(36)分别得到了 11 和 5(下标从 0 开始数)。
TerrainEnemyPicker 的每一行
红色和绿色感叹号用的 CTerrainEnemyPicker 复杂一点。还是用 (17,17) 这次:绿色组,wantedEffect 是 DISADVANTAGE,地形是 FOREST。下面动画里的图按类型把 36 只分成 5 堆,方便看(亮起来的框就是这一步在看的那一堆);代码的循环其实是按数据文件的顺序一只一只看的,结果一样。

- 1第一次 pick:Hexwing(
matches里的第 0 只)

- 1第二次 pick:Galecrest(第 3 只)
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 }- 这个对象是 CMapParser 第 34–35 行
new TerrainEnemyPicker(TerrainEffect.DISADVANTAGE)造的。它的构造器(第 16–18 行)把 DISADVANTAGE 存进第 14 行的 final 字段wantedEffect:“我要在这块地形上吃亏的”。红色组的那个对象存的是 ADVANTAGE,别的代码完全一样。 - 踩上 (17,17) 时,
pick(mokepons, FOREST, random)被调用。第 22 行先 new 一个空的 ArrayListmatches,用来装“合格的”。new ArrayList<>()里的<>让 Java 照着左边的List<Mokepon>自己填上类型(泛型)。 - 第 23 行 for-each 循环,一只一只看。第一只 Sealance 是 WATER:
WATER.getEffectOn(FOREST),FOREST 不是强项 WATER,也不是弱项 ROCKS,返回 NEUTRAL。第 24 行:NEUTRAL==DISADVANTAGE?不等,不加。5 只 WATER 都这样。 - 14 只 NORMAL(Puffume、Nightwing、Shellurk……):强项、弱项都是 null,返回 NEUTRAL,不加。
- 6 只 ICE(Pengloom、Frostback……):强项 ROCKS、弱项 GRASS,FOREST 都不是,返回 NEUTRAL,不加。
- 6 只 FIRE(Impike、Mammagma……):FOREST 正是 FIRE 的强项,返回 ADVANTAGE。可这个 picker 要的是 DISADVANTAGE,不加。如果是一个站在森林里的红色感叹号,要的就正是这 6 只。
- 5 只 LIGHTNING:FOREST 是它们的弱项,返回 DISADVANTAGE,和
wantedEffect相等,第 25 行matches.add(mokepon)。按数据文件的顺序,matches里是 Hexwing、Voltusk、Skydart、Galecrest、Stormtalon。 - 循环结束。第 30 行:
matches是空的吗?有 5 只,不空,跳过第 31 行。只有一只合格的都没有时,第 31 行才会把 36 只全部加进去,变成“随便挑”(第 29 行注释)。 - 第 33 行:
matches.size()是 5,random.nextInt(5)得到 0–4 中的一个整数,matches.get(...)取出那一只(下标从 0 数)。第一次踩得到 0,就是 Hexwing;Run Away! 以后再踩,pick重新跑一遍,这次得到 3,就是 Galecrest。 - 这就是那两次的真实遇敌框(上面)。同一个绿色感叹号、同一个 picker 对象,
pick跑了两次:matches两次都是同样的 5 只 LIGHTNING,只有第 33 行nextInt给的数不一样。所以敌人会变,但一定是 LIGHTNING。
第 30–32 行的“退路”在这份数据文件里其实用不上:查第 1 节的表,3 个红色和 3 个绿色感叹号站在 GRASS、ROCKS、WATER、FOREST 上,每一块都有合格的类型。如果把一个红色感叹号放到小路上,PATH 对谁都是 NEUTRAL,matches 就是空的,这时它才会从 36 只里随便挑,而不是出错(nextInt(0) 会抛异常)。
这就是策略模式
把这几个类拼在一起看:
这叫策略模式(Strategy)。在你的代码里它由四块组成:
- 一个接口 IEnemyPicker:只规定“挑敌人”这件事的样子,就是方法
pick。 - 几个实现类 CRandomEnemyPicker、CTerrainEnemyPicker:各写一种挑法。
- 使用者只认接口:CMarker 的字段
picker类型是 IEnemyPicker,chooseEnemy只写picker.pick(...)。真正跑哪个类的pick,要看字段里放的是哪个对象(动态绑定),CMarker 自己并不知道(面向接口编程)。 - 谁放哪个:CMapParser 在造感叹号时决定(第 1 节第 31–35 行)。
为什么这样设计:
- 三种感叹号只有“挑法”不一样,其他(位置、图片、踩上去开始遇敌)全一样。把会变的那一块单独拿出来做成接口,CMarker 和 COverworld 里就没有
if (红色) … else if (绿色) …这种判断。 - 想加第四种感叹号(比如只挑 NORMAL 的),只要写一个新类 implements IEnemyPicker,在 CMapParser 里加一行
parseMarkers(...),再在数据文件里加一组。CMarker、COverworld、CEncounter 一行都不用改(开闭原则)。 - 也可以给 CMarker 写三个子类(RandomMarker、StrongMarker、WeakMarker),每个子类改写一次
chooseEnemy。但那样要多写三个几乎一样的类,而且 StrongMarker 和 WeakMarker 的代码只差一个值。现在的做法是:挑法是一个单独的对象,用字段picker“装”进 CMarker。强和弱共用同一个 CTerrainEnemyPicker 类,只差构造器参数wantedEffect;给哪一组哪一个 picker,由 CMapParser 在程序运行时决定。这叫组合优于继承(composition over inheritance)。


和战斗里的 EnemyStrategy 比一比
战斗里也有一个一模一样的结构:敌人每回合用哪个技能,由 IEnemyStrategy 决定(第 11 章)。两个接口放在一起看:
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}6/* Strategy: decides which skill the enemy uses each turn.7 * The battle only knows this interface, so a smarter enemy can be added later8 * without changing the Battle class.9 */10public interface EnemyStrategy {11 Skill chooseSkill(Fighter enemy, Random random);12}IEnemyPicker(world 包)
- 决定:踩上感叹号时遇到哪只
- 方法:
pick(…),交回一只 CMokepon - 谁存着:CMarker 的字段
picker - 实现类:CRandomEnemyPicker、CTerrainEnemyPicker
- 谁 new 实现类:CMapParser,每组一个
IEnemyStrategy(battle 包)
- 决定:敌人这回合用哪个技能
- 方法:
chooseSkill(…),交回一个 ASkill - 谁存着:CBattle 的字段
enemyStrategy - 实现类:CRandomEnemyStrategy
- 谁 new 实现类:CBattle 的三参数构造器
它们都是“把一件可以换着做的事交给一个接口”,都要交进来一个 Random,也都只有一个方法。不同的是:IEnemyPicker 已经有两种实现,而且同一局里三种 picker 同时在用;IEnemyStrategy 目前只有一种实现,注释说是为了以后加“更聪明的敌人”时不用改 CBattle。
7. 点 Fight!:Encounter 造出一场战斗
回到 A 局(Impike 这一局):第二次踩上去遇到 Galecrest,这次点 Fight!,屏幕换成战斗画面:

- 1点 Fight!
23
- 4日志第一行:CBattle 的构造器写的(下面动画第 9 步)
从点按钮到战斗画面出来,调用顺序是这样的。动画上面是顺序图,下面是这一步正在运行的代码,最后一步换成真实截图:
85 dialog.addButton("Fight!", event -> {86 closeDialog();87 getNavigator().startBattle();88 });65 @Override66 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 }81 /* Make the battle for the current encounter. */82 public Battle createBattle() {83 return encounter.createBattle(player.getInventory());84 }52 /* Start a battle where the terrain changes the stats of both fighters. */53 public Battle createBattle(Inventory inventory) {54 Fighter player = new Fighter(playerMokepon, getPlayerEffect());55 Fighter enemyFighter = new Fighter(enemy, getEnemyEffect());56 return new Battle(player, enemyFighter, inventory);57 }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 }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!,第 3 节存进按钮的那段 lambda 现在才运行。第一句
closeDialog()(父类 AGameScreen 的方法)把黑色层从画面上拿掉,字段dialog设回 null。先关框,是因为打完仗回来时,用的还是这个 COverworldScreen 对象,框不能还盖着。 - 第二句
getNavigator().startBattle()。getNavigator()交回父类 AGameScreen 里存着的 IGameNavigator(一个接口),CGameWindow 实现了它,所以运行的是 CGameWindow 的startBattle。画面自己不能换画面,只能请窗口来换(第 6 章)。 - CGameWindow 第 67 行
showScreen(new BattleScreen(world.createBattle(), images, this))。Java 先算最里面的括号:先调用world.createBattle(),拿到结果才能 new CBattleScreen,最后才showScreen。另外两个参数:images是 CGameWindow 的字段,一个 CGameImages 对象,存着画面要用的图片路径(类型图标、地形、方向按钮、道具、玩家);this就是这个 CGameWindow 自己,CBattleScreen 把它当成自己的 navigator(第 6 章第 2 节)。 - COverworld 的
createBattle只有一行:encounter.createBattle(player.getInventory())。encounter是第二次踩上去时新造的那个(敌人是 Galecrest);第一次那个在 Run Away! 时已经扔掉了。背包交给战斗,因为战斗里能用道具(Impike 背包里有 1 瓶 Poison)。 - CEncounter 第 54 行:
new Fighter(playerMokepon, getPlayerEffect()),getPlayerEffect()就是FIRE.getEffectOn(FOREST)= ADVANTAGE。第 55 行给敌人:LIGHTNING.getEffectOn(FOREST)= DISADVANTAGE。真正 new 出两个 CFighter 的是 CEncounter,不是 CGameWindow,也不是 CBattle。 - 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 个数值在动画下面算。 - 回到 CEncounter 第 56 行:
new Battle(player, enemyFighter, inventory)(箭头 5:虚线 + 实心箭头,造一个新对象)。交过去的是刚造好的两个 CFighter 和背包。注意这里的player是第 54 行的局部变量,类型是 CFighter,不是 CPlayer。 - CBattle 有两个构造器(重载)。被调用的是三个参数的这个(第 25–27 行),它自己不存任何字段,只有一行
this(...)。this(...) 写在构造器第一行,意思是“去运行这个类的另一个构造器”:它把收到的三样,再加上新 new 的 CRandomEnemyStrategy 和Random,一共 5 个值,交给第 30–31 行那个五参数的构造器。 - 五参数的构造器(从第 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”)。 - 造好的 CBattle 一层一层交回去:CEncounter 交给 COverworld,COverworld 交给 CGameWindow。COverworld 自己不存这个 battle,它只留着
encounter,等打赢了再处理感叹号。 - 回到 CGameWindow 第 67 行:用这个 battle new 一个新的 CBattleScreen,再
showScreen(第 101–105 行):currentScreen换成它,窗口中间换成它的画面,菜单重新检查一遍(现在 Run away 能点了)。 - 结果就是本节开头那张战斗截图(上面裁了它的下半部分)。HP 条上写着 225 / 225,能量 33 / 33:CFighterPanel 显示的是 CFighter 的值,不是 CMokepon 的(地图卷轴上还是 150 和 22)。日志区第一行,就是第 9 步 CBattle 构造器写的那一句。
数值是怎么变的
先对比同一局里地图卷轴和战斗画面上的数字。左边是 Run Away! 以后的地图卷轴,右边是开打时你的两根条(敌人的条上没有数字,第 14 章):

- 1HP: 150(CMokepon 本来的值)
- 2Energy: 22

- 1HP 225 / 225 = 150 × 1.5
- 2能量 33 / 33 = 22 × 1.5
数字是第 34 行算出来的。225 / 225 左边的 225 是“现在的 HP”,右边是“最大 HP”,分别来自两张表:
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 }
- 3现在的 HP:第 57 行
getHp()读stats - 4最大 HP:第 61 行
getMaxHp()读startingStats
两张表都是 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 里的方法:
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.5 | Galecrest ×0.5 |
|---|---|---|
| HP | 150 × 1.5 = 225.0 → 225 | 185 × 0.5 = 92.5 → 93 |
| Energy | 22 × 1.5 = 33.0 → 33 | 28 × 0.5 = 14.0 → 14 |
| Strength | 76 × 1.5 = 114.0 → 114 → 上限 100 | 73 × 0.5 = 36.5 → 37 |
| Accuracy | 74 × 1.5 = 111.0 → 111 → 上限 100 | 86 × 0.5 = 43.0 → 43 |
| Defence | 45 × 1.5 = 67.5 → 68 | 59 × 0.5 = 29.5 → 30 |
| Dodge | 70 × 1.5 = 105.0 → 105 → 上限 100 | 79 × 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!。前后两张图裁的是同一块:

- 1点之前:玩家在 (17,17),盖住了感叹号

- 1点之后:玩家回到 (18,17)
- 2感叹号还在 (17,17)
点 Run Away! 运行的是第 3 节存进去的第二段 lambda。整个过程只有三个对象:
按运行的顺序看代码。先是按钮里的那段 lambda:
89 dialog.addButton("Run Away!", event -> {90 closeDialog();91 world.runAway();92 });COverworldScreen 第 90 行先关框,第 91 行调用 world.runAway()。注意它直接调用字段 world,没有经过 getNavigator():逃跑不用换画面,地图画面自己就能处理。Fight! 要换成一个新的 CBattleScreen,只有 CGameWindow 能做,所以才要通过 navigator。
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 }- COverworld 第 88 行
encounter = null:这次遇敌作废,没有字段再指着那个 CEncounter 对象了(Hexwing 那一次就这样没了)。 - COverworld 第 89 行
player.moveBack():玩家退回上一格(下面细看)。 - COverworld 第 90 行通知所有监听者
onPlayerMoved(),于是又回到画面那一边(下面的代码)。 - 感叹号为什么还在:
runAway根本没碰objects列表。整个 COverworld 里,只有winEncounter(COverworld 第 96 行objects.remove(encounter.getMarker()))会把感叹号拿掉,而它只在打赢以后才被调用。所以感叹号可以再踩,再踩就重新挑一次敌人(这就是 A 局第 2 次的 Galecrest)。
72 @Override73 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 怎么知道“上一格”在哪?它一直多记着一个位置:
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 }这一局里两个字段的值
| 时刻 | position | previousPosition |
|---|---|---|
| 按 ← 之前 | (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).