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

08地图世界:走一步发生了什么

GameMap、Position、Player、Overworld.move、地图上的物体和观察者模式

读完这一章你能
  • 说出地图这一层的规则类(world 包)和画面类(ui 包)各有哪些,谁用字段存着谁
  • 读懂 tiles[row][column],自己算出 (6,12) 为什么走不过去、(13,0) 为什么不能再往上
  • 讲清楚 CPosition 为什么“不可变”、为什么要改写 equals
  • 一行一行讲 COverworld 的 move:先检查、再移动、再通知、最后让脚下的物体自己反应
  • 说出 CMarker、CMysteryBox、CExit 怎样用同一个 interact 做三件不同的事(多态)
  • 指出观察者模式的三个位置:谁登记(addListener)、谁通知(notifyListeners)、谁收到(COverworldScreen)
  • 从按下 → 键到屏幕变成下一帧,按顺序说出每一个被调用的方法
  • 用英语说:Overworld holds the rules of the map; OverworldScreen only shows them and is told about changes through OverworldListener.

在 第 1 章 里你已经在地图上走过一遍:从起点 (10,18) 往右走 8 格,再往上走 2 格,捡到一个 Poison。那一章只看画面。这一章打开代码,只回答一个问题:按一次方向键,程序里到底发生了什么?

先提醒几个前面学过的词(细讲在 第 1 章、第 2 章):类(class)是一段代码的“模板”,对象(object)是按模板 new 出来的一个具体的东西;字段(field)是对象里存数据的变量;方法(method)是对象能做的事;构造器(constructor)是 new 的时候自动运行的那段代码。彩色带圆圈的名字(比如 COverworld)是项目自己的类,点一下能看它的卡片;灰底的 Label、ImageView、Pane 是 JavaFX 自带的部件。

1. 先看全局:地图画面背后有哪些对象

先看一眼刚进地图时的画面。第 1 章已经说过每一块是谁画的;这一次看每一块背后的数据存在哪个对象里。

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)123456
  1. 1
    地形:400 个格子的图片。它们画的是 CGameMap 里存的二维数组 tiles(第 2 节)
  2. 2
    玩家:画在 CPlayer 的字段 position 那一格,现在是 (10,18)(第 3、4 节)
  3. 3
    地图上的物体:COverworld 的列表 objects 里一共 14 个(10 个感叹号、3 个问号箱、1 扇门),这是问号箱 (18,16)(第 5 节)
  4. 4
    卷轴:CPlayerStatsScroll 读 CPlayer 的 mokepon、fightsWon 和 inventory(第 7 节)
  5. 5
    四个方向按钮:每个能不能点,问的是 COverworld 的 canMove(方向)(第 7 节)
  6. 6
    状态栏:COverworld 发来通知时,COverworldScreen 在这里写一句话(第 6 节)
同一个画面,换个问法:每一块显示的东西,存在哪个对象里真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

这些对象分成两堆,放在两个包(package)里:

world 包:规则(没有 JavaFX)ui 包:画面(JavaFX)数据文件里的地图:每格的地形、玩家起点和开局时的物体。CGameMapworld一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld玩家:他的 Mokepon、位置、上一个位置、背包和胜场。CPlayerworld抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworld接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld画地图:地块、还留在地图上的物体,以及玩家。CMapViewui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui地图界面右上的卷轴:玩家的 Mokepon、属性、胜场和道具数量。CPlayerStatsScrolluimapplayerobjectslistenersworldmapViewstatsScrollworldplayer
地图这一层的主要类。上面 world 包管规则,下面 ui 包管画面。空心菱形 = 用字段存着(菱形在“存着别人”的那一头,线上写字段名);虚线 + 空心三角 = implements(实现接口)。注意:所有的线都是从下往上、或者在同一层里——没有一条从 world 包指向 ui 包

图里有一个“绕回来”的地方,是本章最重要的设计:画面 COverworldScreen 用字段 world 存着 COverworld(画面认识规则);可是 COverworld 并不存着 COverworldScreen,它只存着一串 IOverworldListener(一个接口(interface))。COverworldScreen 实现(implements)了这个接口,所以它能被放进那个列表。规则只认识接口,不认识画面——这就是第 6 节的观察者模式(Observer)。

另外两个画面类也直接存着规则对象:CMapView 也用字段 world 存着同一个 COverworld(它要读物体和玩家的位置),CPlayerStatsScroll 用字段 player 存着 CPlayer(它要读赢了几场、背包里有几个)。方向永远是“画面存着规则”;规则这边一个画面类都不存。

类一句话(在哪一节)
CGameMap、ETerrain地图的每一格是什么地形,哪些地形能走(第 2 节)
CPosition、EDirection一个格子 (x,y);四个方向各让 x、y 变多少(第 3 节)
CPlayer、COverworld玩家站在哪、有什么;走路的规则(第 4 节)
AMapObject、CMarker、CMysteryBox、CExit、CSprite地图上的物体,踩上去各自做什么(第 5 节)
IOverworldListener规则怎么通知画面(第 6 节)
COverworldScreen、CMapView、CPlayerStatsScroll把这些画出来(第 7 节)

第 8 节把它们串起来:按一次 →、踩一次问号箱,一步一步回放。

2. 地图:GameMap 和 Terrain

数据文件里的 20 行字母

地图不是画出来的,是写出来的。数据文件 mokepon.game 第 402–421 行,每行一串 20 个字母,一个字母就是一个格子。map.tiles.0 是最上面一行,map.tiles.19 是最下面一行。

mokepon.game:地图的 20 行mokepon.game第 402–421 行
4021map.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
4142map.tiles.12=GGWWGGBBPPGGGGBBBBBB
415map.tiles.13=GGWWGGBBPPGGGGBBBBBB
416map.tiles.14=WWWWWWBBPPGGGGBBBBBB
417map.tiles.15=WWWWWWBBPPGGGGBBBBBB
4183map.tiles.16=OOOOWWGGPPPPGGGGFFFF
419map.tiles.17=OOOOWWGGPPPPGGGGFFFF
4204map.tiles.18=OOOOWWGGGGPPFFFFFFFF
421map.tiles.19=OOOOWWGGGGPPFFFFFFFF
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    第 402 行 map.tiles.0(最上面一排):R = ROCKS(深灰色碎石);第 6、7 列的两个 P = PATH(小路),门 (6,0) 画在第 6 列这一格上
  2. 2
    第 414 行 map.tiles.12 = GGWWGGBBPPGGGGBBBBBB:从 0 数,第 6 个字母是 B(BARRIER,浅灰色方砖墙),所以 (6,12) 是墙
  3. 3
    第 418–421 行开头的 OOOO:左下角深蓝色的 OCEAN(大海)
  4. 4
    第 420 行 map.tiles.18 的第 10 个字母是 P:玩家起点 (10,18) 是一格小路
文件里的字母和地图上的格子一一对应:第几行 = y,这一行的第几个字母 = x真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

读文件、把字母变成对象,是 CMapParser 的 parseTiles 做的(细节在 第 5 章):每一行造一个 Terrain[],每个字母用 Terrain.fromSymbol(字母) 换成一个地形,最后交给 new GameMap(...)。

Terrain:七种地形,两种不能走

Terrain:每种地形 = 一个字母 + 能不能走model/Terrain.java第 4–23 行
4public enum Terrain {
51 BARRIER('B', false),
62 FOREST('F', true),
73 GRASS('G', true),
84 OCEAN('O', false),
95 PATH('P', true),
106 ROCKS('R', true),
117 WATER('W', true);
12
13 private final char symbol;
14 private final boolean walkable;
15
16 Terrain(char symbol, boolean walkable) {
17 this.symbol = symbol;
18 this.walkable = walkable;
19 }
20
21 public boolean isWalkable() {
22 return walkable;
23 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234567
  1. 1
    BARRIER 'B',false:浅灰色方砖墙,例如 (6,12)–(7,13)
  2. 2
    FOREST 'F',true:深绿色森林,(18,18)–(19,19)
  3. 3
    GRASS 'G',true:草地,(0,4)–(1,5)
  4. 4
    OCEAN 'O',false:深蓝色大海,(0,16)–(3,19)
  5. 5
    PATH 'P',true:棕色小路,(8,4)–(9,5)
  6. 6
    ROCKS 'R',true:深灰色碎石(不是方砖),(0,0)–(1,1)
  7. 7
    WATER 'W',true:浅蓝色的水,(4,6)–(5,7),也能走
只有 BARRIER 和 OCEAN 的 walkable 是 false。两种灰色别弄混:浅灰方砖 = BARRIER(不能走),深灰碎石 = ROCKS(能走)真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

GameMap:把字母表存成二维数组

GameMap:一张整局都不会变的地图world/GameMap.java第 6–44 行
6/* The map from the data file: the terrain of every tile, where the player starts,
7 * and the objects on it when a game begins. It never changes while a game is played.
8 */
9public class GameMap {
10 /* tiles[row][column], so the first index is y and the second index is x. */
111 private final Terrain[][] tiles;
12 private final Position startPosition;
13 private final List<MapObject> objects;
14
15 public GameMap(Terrain[][] tiles, Position startPosition, List<MapObject> objects) {
16 /* Copy every row, so no other class can change the tiles (no privacy leak). */
17 this.tiles = new Terrain[tiles.length][];
18 for (int row = 0; row < tiles.length; row++) {
192 this.tiles[row] = tiles[row].clone();
20 }
21 this.startPosition = startPosition;
223 this.objects = List.copyOf(objects);
23 }
24
25 /* Every row has the same number of tiles, so the first row gives the width. */
26 public int getWidth() {
27 return tiles[0].length;
28 }
29
30 public int getHeight() {
31 return tiles.length;
32 }
33
34 public Position getStartPosition() {
35 return startPosition;
36 }
37
38 public List<MapObject> getObjects() {
39 return objects;
40 }
41
42 public Terrain getTerrain(Position position) {
434 return tiles[position.getY()][position.getX()];
44 }

一行一行看:

用 (6,12) 试一下:getTerrain(new Position(6, 12)) → tiles[12][6] → 第 12 行,再取这一行的第 6 个:

x(列)456789
tiles[12][x]GGBBPP

结果是 BARRIER。如果写反成 tiles[6][12],取到的是地图第 6 行 GGWWWWWWPPWWWWPPWWGG 从 0 数第 12 个字母 W(水),完全是另一个格子。

类注释说 CGameMap “never changes while a game is played”。整个程序只有一个 CGameMap 对象(CGameWindow 的字段 map),每开一局新游戏都用它。它不变,所以上一局捡掉的箱子、打败的感叹号,不会影响下一局——变化的东西都放在每局新造的 COverworld 里(第 4 节)。

isWalkable:能不能站上去,要过两道关

isWalkable 和 isInsideworld/GameMap.java第 46–54 行
46 /* The player can only stand on a walkable tile inside the map. */
47 public boolean isWalkable(Position position) {
482 return isInside(position) && getTerrain(position).isWalkable();
49 }
50
51 private boolean isInside(Position position) {
52 return position.getX() >= 0 && position.getX() < getWidth()
531 && position.getY() >= 0 && position.getY() < getHeight();
54 }
走向出口途中,现在在 (13,0)
1
  1. 1
    玩家在最上面一排 (13,0)。再往上是 (13,−1):y = −1,红 1 position.getY() >= 0 不成立,isInside 返回 false。那里只有棕色的背景框,已经不是地图了
第一道关挡住:出了地图真实运行截图 · 走向出口途中,现在在 (13,0)
站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰
2
  1. 2
    玩家(这一局选的是 Ironshell)在 (5,12)。右边 (6,12) 在地图里面,但地形是 BARRIER:红 2 getTerrain(...).isWalkable() 返回 false
第二道关挡住:地形不能走真实运行截图 · 站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰

isWalkable 只有一行(第 48 行):isInside(position) && getTerrain(position).isWalkable()。两道关都过,才能站上去:

  1. 在地图里面吗?(isInside,第 51–54 行,红 1)0 ≤ x < 20 并且 0 ≤ y < 20。
  2. 这块地形能走吗?(第 48 行 && 右边,红 2)BARRIER 和 OCEAN 不能走,其他五种都能。

3. 格子和方向:Position、Direction

Position:一个格子,永远不变

Position:x 是列,y 是行(省略了 getX、getY)world/Position.java第 5–15、25–48 行
5/* A tile on the map. x is the column and y is the row, and (0, 0) is the top left tile.
6 * A position never changes; moving makes a new position instead.
7 */
8public class Position {
91 private final int x;
10 private final int y;
11
12 public Position(int x, int y) {
13 this.x = x;
14 this.y = y;
15 }
⋯ 省略第 16–24 行
25 /* The position one tile away in the given direction. */
26 public Position move(Direction direction) {
272 return new Position(x + direction.getXChange(), y + direction.getYChange());
28 }
29
30 /* Two positions are equal when they point to the same tile. */
31 @Override
32 public boolean equals(Object other) {
33 if (this == other) {
34 return true;
35 }
36 if (other == null || getClass() != other.getClass()) {
37 return false;
38 }
39 Position position = (Position) other;
403 return x == position.x && y == position.y;
41 }
42
43 /* Equal positions must give the same hash code. */
44 @Override
45 public int hashCode() {
46 return Objects.hash(x, y);
47 }
48}

下面两张小图画的是内存里的对象。变量和字段里存的不是对象本身,而是“指向它的箭头”,叫引用(reference)。左图:玩家走一步以后,previousPosition 指着旧的 (10,18),position 指着 move 新造的 (11,18),旧对象一点没变。右图:箱子的格子和玩家的格子都是 (18,16),但它们是两个不同的对象。

玩家:他的 Mokepon、位置、上一个位置、背包和胜场。Cplayer : Playerworld地图上的一格:x 是列,y 是行,(0,0) 在左上角。C(10,18) : Positionworld地图上的一格:x 是列,y 是行,(0,0) 在左上角。C(11,18) : PositionworldpreviousPositionposition
走一步:旧格子不变,玩家的字段改成指向一个新的 Position
神秘箱子(问号):里面有一个道具,踩上去就捡到。C箱子 : MysteryBoxworld玩家:他的 Mokepon、位置、上一个位置、背包和胜场。Cplayer : Playerworld地图上的一格:x 是列,y 是行,(0,0) 在左上角。C(18,16) : Positionworld地图上的一格:x 是列,y 是行,(0,0) 在左上角。C(18,16) : Positionworldequals → true:x、y 都一样== → false:是两个对象position(读文件时造)position(move 时新造)
两个 (18,16):equals 说相等,== 说不是同一个对象

为什么要做成不可变?因为 CPosition 被到处共享:CPlayer 存着它,AMapObject 存着它,CGameMap 的起点也是它。getPosition() 可以放心地把它交出去——拿到的人改不了它,所以不会有隐私泄露。想“走一步”,只能换一个新的 CPosition(上面左图)。

Direction:四个方向,y 往下变大

Direction:每个方向让 x、y 变多少world/Direction.java第 4–16 行
4public enum Direction {
51 UP(0, -1),
62 DOWN(0, 1),
73 LEFT(-1, 0),
84 RIGHT(1, 0);
9
10 private final int xChange;
11 private final int yChange;
12
13 Direction(int xChange, int yChange) {
14 this.xChange = xChange;
15 this.yChange = yChange;
16 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    UP (0, −1):(10,18) → (10,17),y 少 1,往上走
  2. 2
    DOWN (0, 1):→ (10,19),y 多 1,往下
  3. 3
    LEFT (−1, 0):→ (9,18)
  4. 4
    RIGHT (1, 0):→ (11,18)
中间的星形是玩家 (10,18);四个框是四个方向走一步以后的新格子真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

EDirection 也是枚举,每个方向存两个数:x 变多少(xChange)、y 变多少(yChange)。Position.move 就是 new Position(x + xChange, y + yChange)。注意 UP 是 y − 1:类注释(第 3 行)写着 “y grows downwards, so up takes one away from y”,第 0 行在最上面,往上走行号变小。

格子换成截图上的像素,公式在 第 1 章:截图 x = 320 + 32 × x,截图 y = 65 + 32 × y(320 是地图左右居中的结果,65 = 地图离游戏区顶部 40 + 菜单栏 25,32 是 MapView.TILE_SIZE)。拿几张真实截图里玩家图片 playerView 的位置核对一下:

截图Player.position320 + 32x65 + 32y截图里 playerView 在
overworld-start(10,18)640641(640, 641)
walk-01(11,18)672641(672, 641)
walk-09(18,17)896609(896, 609)
overworld-blocked(5,12)480449(480, 449)
exit-walk-07(13,0)73665(736, 65)

每一行都对得上。规则类(CPosition、CPlayer、COverworld)只认识格子,不知道像素;换算只在 CMapView 的 placeOnTile 里做(第 7 节)。

4. Player 和 Overworld:规则的中心

Player:站在哪、有什么、赢了几场

Player 的 UML 类图
老师打印稿里的 Player。名字前的小图标:红色方块 = private,绿色圆点 = public,蓝色三角 = 什么都没写(包内可见,package-private)。moveTo、moveBack、addWin 前面是蓝色三角

图标的读法在 第 3 章第 4 节。下面是代码:

Player:五个字段,三个“只给 world 包用”的方法(省略了四个 getter)world/Player.java第 9–22、40–53 行
9public class Player {
10 private final Mokepon mokepon;
11 private final Inventory inventory;
12 private Position position;
13 /* The tile before the last move, so the player can run back to it. */
14 private Position previousPosition;
15 private int fightsWon = 0;
16
17 public Player(Mokepon mokepon, Position startPosition, Inventory inventory) {
18 this.mokepon = mokepon;
19 this.inventory = inventory;
20 this.position = startPosition;
21 this.previousPosition = startPosition;
22 }
⋯ 省略第 23–39 行
401 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. */
462 void moveBack() {
47 position = previousPosition;
48 }
49
503 void addWin() {
51 fightsWon++;
52 }
53}

五个字段:mokepon(你选的那只)、inventory(背包)、position(现在的格子)、previousPosition(上一步的格子)、fightsWon(赢了几场,一开始是 0)。前两个是 final,一局里不会换;后三个会变。构造器把 position 和 previousPosition 都设成起点。

会改这些字段的只有三个方法,它们的作用都能在真实截图里看到:

按右方向键走一格,现在在 (11,18)
1
  1. 1
    moveTo((11,18)) 以后:previousPosition = (10,18)(左边一格),position = (11,18)
红 1 moveTo:先记下旧格子,再换新格子真实运行截图 · 按右方向键走一格,现在在 (11,18)
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
2
  1. 2
    踩上 (17,17) 的感叹号后点 Run Away!:moveBack() 把 position 设回 previousPosition (18,17)
红 2 moveBack:退回上一格(第 15 章)真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
3
  1. 3
    打赢一场回来:addWin() 让 fightsWon 从 0 变成 1,卷轴写 Fights: 1
红 3 addWin:多赢一场真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

注意 moveBack 只改 position,不改 previousPosition。所以逃跑以后两个字段都是 (18,17)。addWin 里的 fightsWon++ 是“自己加 1”,等于 fightsWon = fightsWon + 1(自增 increment)。

Overworld:这一局的规则中心

Overworld 的 UML 类图
老师打印稿里的 Overworld:七个 private 字段;public 方法给别的包用;startEncounter、collect、reachExit 是蓝色三角(只给 world 包里的物体用);notifyListeners 是红色方块(只有自己用)
mokepons : List<Mokepon>random : Random一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld一次遇敌:哪个标记、玩家的 Mokepon、敌人和脚下的地形。CEncounterworld数据文件里的地图:每格的地形、玩家起点和开局时的物体。CGameMapworld玩家:他的 Mokepon、位置、上一个位置、背包和胜场。CPlayerworld抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworld接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld背包:记录每种道具各有几个。CInventoryitem地图上的一格:x 是列,y 是行,(0,0) 在左上角。CPositionworld数据文件里的一只 Mokepon:名字、图片、类型、六项属性和技能。CMokeponmodelencountermapplayerobjectslistenersinventorypositionpreviousPositionmokepon
Overworld 和 Player 的字段(线上的字是字段名;空心菱形 = 用字段存着)

七个字段,一个一个说:

构造器:每一局都从头开始

Overworld 的两个构造器world/Overworld.java第 28–42 行
28 /* Start a new game on the map with the chosen Mokepon. */
29 public Overworld(GameMap map, List<Mokepon> mokepons, Mokepon playerChoice) {
301 this(map, mokepons, playerChoice, new Random());
31 }
32
33 /* Start a new game with a chosen random number generator. */
34 public Overworld(GameMap map, List<Mokepon> mokepons, Mokepon playerChoice, Random random) {
35 this.map = map;
36 this.mokepons = List.copyOf(mokepons);
37 this.random = random;
382 Inventory inventory = new Inventory(ItemCatalogue.getAll());
393 this.player = new Player(playerChoice, map.getStartPosition(), inventory);
40 /* Copy the objects, so collecting a box or beating a marker never changes the map. */
414 this.objects = new ArrayList<>(map.getObjects());
42 }

这三种“列表”很像,但不一样,考试问封装时常用:

代码在哪复制了吗能改吗
List.copyOf(objects)CGameMap 构造器复制不能(改就抛异常)
new ArrayList<>(...)COverworld 构造器复制能:collect 要 remove
Collections.unmodifiableList(objects)COverworld.getObjects不复制,只读窗口别人不能改;COverworld 删了东西,窗口里马上看得到
GameMap.objectsList.copyOf 造的:不能改一直是 14 个Overworld.objectsnew ArrayList 造的:能删捡完箱子剩 13 个getObjects() 返回的只读视图不复制,看的就是上面那个列表神秘箱子(问号):里面有一个道具,踩上去就捡到。C(18,16) : MysteryBoxworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。C(17,17) : Markerworld……另外 12 个物体collect 后删掉看着它
三个“列表”,同一批物体。箭头 = 列表里存着这个对象的引用(不是对象的复印件)。虚线 = 捡起箱子以后,Overworld 的列表里这一条被删掉;GameMap 的列表里它还在

getObjects 和 canMove

只读的物体列表;能不能往某个方向走world/Overworld.java第 56–63 行
56 /* Other classes can read the objects but cannot change them. */
57 public List<MapObject> getObjects() {
58 return Collections.unmodifiableList(objects);
59 }
60
61 public boolean canMove(Direction direction) {
62 return map.isWalkable(player.getPosition().move(direction));
63 }

getObjects() 给 CMapView 读“地图上还剩哪些物体”,返回的是只读视图(unmodifiable view):CMapView 能读,删不了。

canMove(direction) 只有一行,从里往外读:player.getPosition() 取现在的格子 → .move(direction) 算出旁边那一格(一个新 CPosition)→ map.isWalkable(...) 问地图能不能站。它只是问一问,不会移动玩家。所以画面可以随便调用它,用来决定按钮灰不灰(第 7 节)。

move:一行一行读

Overworld.move:走一步的全部规则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) {
671 if (!canMove(direction)) {
68 return;
69 }
702 player.moveTo(player.getPosition().move(direction));
713 notifyListeners(listener -> listener.onPlayerMoved());
72
73 /* Loop over a copy, because a mystery box removes itself from the list. */
744 for (MapObject object : new ArrayList<>(objects)) {
75 if (object.isAt(player.getPosition())) {
765 object.interact(this);
77 }
78 }
79 }
  1. 红 1(第 67–69 行)先检查。! 读作“不”,把 true 变成 false、false 变成 true(逻辑运算符)。所以 if (!canMove(direction)) 读作“如果不能往这个方向走”。不能走就 return,后面的代码一行都不执行(提前返回(early return))。不会报错,玩家只是原地不动。
  2. 红 2(第 70 行)再移动。重新算出旁边那一格,交给 player.moveTo(...)。
  3. 红 3(第 71 行)再通知。告诉所有监听者“玩家动了”:notifyListeners(listener -> listener.onPlayerMoved())。括号里的写法叫 lambda,第 6 节讲。画面收到后马上调用 update(),改好画面上的部件(真正画到屏幕上要等这次按键全部处理完,第 8 节)。
  4. 红 4(第 74–75 行)最后看脚下。for (MapObject object : 列表) 是 for-each 循环,读成:“对列表里的每一个物体,把它叫做 object,做一遍大括号里的事”。大括号里问它:你在玩家现在这一格吗(isAt)?
  5. 红 5(第 76 行)让它自己反应。在的话,调用 object.interact(this),把自己(这个 COverworld)交给它。是感叹号就开始遇敌,是箱子就被捡起,是门就结束游戏(第 5 节)。COverworld 自己不用判断它是哪一种物体。

还有一个顺序要记住:先通知 onPlayerMoved,再处理脚下的物体。所以踩到箱子那一步,画面会被更新两次:一次因为“走了一步”,一次因为“捡到了东西”。第 8 节的动画会看到。

5. 地图上的物体:MapObject 一家

先看地图上的三种物体,再看它们被踩到时运行的代码。三段代码各只有一行,编号和右图一样:

Marker:开始遇敌world/Marker.java第 24–27 行
24 @Override
25 void interact(Overworld world) {
261 world.startEncounter(this);
27 }
MysteryBox:被捡起world/MysteryBox.java第 18–21 行
18 @Override
19 void interact(Overworld world) {
202 world.collect(this);
21 }
Exit:到达出口world/Exit.java第 10–13 行
10 @Override
11 void interact(Overworld world) {
123 world.reachExit();
13 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
123
  1. 1
    感叹号 CMarker(这是 (12,16) 的红色感叹号):踩上去 → world.startEncounter(this),开始遇敌
  2. 2
    问号箱 CMysteryBox (18,16):踩上去 → world.collect(this),被捡起
  3. 3
    门 CExit (6,0):踩上去 → world.reachExit(),游戏结束
overworld-start:三种物体各圈一个。14 个物体里有 10 个感叹号、3 个问号箱、1 扇门真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

三段代码都长得一样:@Override void interact(Overworld world),里面调用参数 world 的一个方法。它们是同一个父类的三个子类:

地图物体的外观:图片路径和着色用的颜色字符串。CSpriteworld抽象类:放在地图某一格上、玩家踩上去会有反应的东西。AMapObjectworld地图上的一格:x 是列,y 是行,(0,0) 在左上角。CPositionworldMokepon 标记(感叹号):踩上去遇到一只野生 Mokepon。CMarkerworld神秘箱子(问号):里面有一个道具,踩上去就捡到。CMysteryBoxworld出口门:踩上去游戏就结束。CExitworld接口:决定在某个标记上遇到哪只 Mokepon。IEnemyPickerworld抽象类:一次性道具,有类型名、显示名、图片键和 use()。AItemitem三个子类都改写interact(world)spritepositionpickeritem
MapObject 一家:实线 + 空心三角 = extends(继承);空心菱形 = 用字段存着。三个子类各自多一个或零个字段
MapObject:所有地图物体的抽象父类(省略了两个 getter)world/MapObject.java第 3–13、23–31 行
3/* Something that sits on one tile of the map and reacts when the player steps onto it.
4 * The overworld treats every object the same way and lets each one decide what happens.
5 */
61public abstract class MapObject {
7 private final Position position;
8 private final Sprite sprite;
9
102 protected MapObject(Position position, Sprite sprite) {
11 this.position = position;
12 this.sprite = sprite;
13 }
⋯ 省略第 14–22 行
23 public boolean isAt(Position other) {
243 return position.equals(other);
25 }
26
27 /* Each kind of object reacts in its own way, and asks the world to make the change.
28 * Only the overworld calls this, when the player steps onto the object.
29 */
304 abstract void interact(Overworld world);
31}

三个子类的 interact 都只有一行,各自“回头”调用 COverworld 的一个方法。被回头调用的这三个方法在这里:

Overworld 里被物体回头调用的三个方法(都是包内可见)world/Overworld.java第 102–120 行
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 }
109
110 /* Called by a mystery box when the player steps onto it. A box can only be collected once. */
111 void collect(MysteryBox box) {
112 objects.remove(box);
113 player.getInventory().add(box.getItem());
114 notifyListeners(listener -> listener.onItemCollected(box.getItem()));
115 }
116
117 /* Called by the exit when the player steps onto it. */
118 void reachExit() {
119 notifyListeners(listener -> listener.onExitReached(player.getFightsWon()));
120 }

画成顺序图,就是一来一回:COverworld 调用物体的 interact(this),把自己交出去;物体再拿着这个参数调用 COverworld 的方法。这种“你调用我,我再回头调用你”叫回头调用(callback)。

COverworldC(12,16) : MarkerC(18,16) : MysteryBoxC(6,0) : Exit踩到感叹号interact(this)1startEncounter(this)2踩到问号箱interact(this)3collect(this)4踩到门interact(this)5reachExit()6
回头调用:一来一回。每次只发生其中一组——踩到哪个物体,就只调用那一个的 interact
踩到的interact 里写的COverworld 做的事发出的通知
CMarker(感叹号)world.startEncounter(this)看这一格的地形,用这个感叹号的 picker 挑敌人,new Encounter(...)onEncounter(第 9 章)
CMysteryBox(问号箱)world.collect(this)从 objects 删掉这个箱子,背包里这种道具 +1onItemCollected
CExit(门)world.reachExit()不改任何数据onExitReached(第 15 章)

Sprite:物体长什么样

Sprite:图片路径 + 颜色文字(省略了两个 getter)world/Sprite.java第 7–22、32–34 行
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;
12 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
191 public Sprite(String imagePath, String colour) {
20 this.imagePath = imagePath;
21 this.colour = colour;
22 }
⋯ 省略第 23–31 行
32 public boolean hasTint() {
33 return !colour.isEmpty();
34 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
112
  1. 1
    感叹号:用 2 个参数的构造器造,colour 是这一组的颜色(这个是 red,右边那个绿的是 lime,另一组是 white),hasTint() 是 true,画的时候被染色
  2. 2
    问号箱:用 1 个参数的构造器造,colour 是空字符串 NO_TINT,hasTint() 是 false,按原图画
同一张 sprite_exclamation.png,染成不同颜色;问号箱不染色真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

CSprite 只存两个字符串:图片文件的路径,和颜色的名字(比如 "red")。第 9 行 private static final String NO_TINT = "" 是一个常量:空字符串,意思是“不染色”(static final 第 7 节细讲)。第 16 行:1 个参数的构造器用 this(imagePath, NO_TINT) 交给 2 个参数的构造器,又是一次构造器链。第 33 行 return !colour.isEmpty():颜色字符串“不是空的”就要染色。

CSprite 不认识 JavaFX,不会真的去染色。真正染色的是画面那边的 CMapView:

MapView.createObjectView:有颜色才染色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 }

UiStyle.tint 用 JavaFX 的一盏彩色灯光照这张图:白色的部分变成灯的颜色,黑边还是黑的(染色)。原图的感叹号是白色的,所以 red 组变红、lime 组变绿;white 组也被“染”了,只是染成白色,看起来和原图一样。门染成 magenta(洋红)。把颜色存成文字、在 ui 包里才染色,规则包就不用 import javafx——这是规则与界面分离。

6. 观察者模式:Overworld 怎么告诉画面

问题是这样的:玩家走了一步,COverworld 改了 CPlayer 的 position。屏幕上的星形图片怎么知道要挪?COverworld 在 world 包里,不认识 COverworldScreen。办法是:先定一个接口,写清楚“地图上会发生哪几件事”。

OverworldListener:五件可能发生的事world/OverworldListener.java第 6–25 行
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
14 /* The player stepped onto a marker and met a wild Mokepon. */
15 void onEncounter(Encounter encounter);
16
17 /* The player picked up an item from a mystery box. */
18 void onItemCollected(Item item);
19
20 /* The player won the battle of the last encounter. */
21 void onEncounterWon(Mokepon enemy);
22
23 /* The player stepped onto the exit and finished the game. */
24 void onExitReached(int fightsWon);
25}

接口里只有五个方法的名字,没有代码。注释第一个词就是 “Observer”,后面写着 “The overworld calls these methods; it does not know what they do.”。意思是:谁想知道地图上发生了什么,就实现这个接口;COverworld 只管调用,不管对方拿去做什么。COverworldScreen 的类声明是:

public class OverworldScreen extends GameScreen implements OverworldListener

它继承(extends)AGameScreen,同时实现(implements)IOverworldListener,所以五个方法它都得写(第 72–112 行)。

OverworldListener 的 UML 类图
老师打印稿里的 OverworldListener:圆圈里是 I(interface),名字是斜体,五个方法都是 public 的抽象方法
一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld接口:地图上会发生的五件事(移动、遇敌、捡道具、打赢、到出口)。IOverworldListenerworld游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenuilistenersworldworldoverworldScreen
观察者模式的结构:Overworld 只用字段 listeners 存着接口 OverworldListener;OverworldScreen 实现这个接口;GameWindow 两个都存着,并负责把画面登记给 Overworld(空心菱形 = 字段;虚线 + 空心三角 = implements)

COverworld 这一边只有三段代码:

Overworld:存监听者、登记、通知world/Overworld.java第 23–23、44–46、122–129 行
231 private final List<OverworldListener> listeners = new ArrayList<>();
⋯ 省略第 24–43 行
44 public void addListener(OverworldListener listener) {
452 listeners.add(listener);
46 }
⋯ 省略第 47–121 行
122 /* Send the same message to every registered listener.
123 * The lambda says which listener method to call.
124 */
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
1273 message.accept(listener);
128 }
129 }

这段 lambda 从哪里出发、到哪里结束,用下面这个四步的小动画看一遍:

1. 第 71 行:一段代码当成一个值listener -> listener.onPlayerMoved()2. 第 125 行:存进参数 message类型 Consumer<OverworldListener>3. 第 126–127 行:对每个 listenermessage.accept(listener)4. 真正运行的是OverworldScreen.onPlayerMoved()交给 notifyListeners进入循环accept = 运行 lambda
一段 lambda 的路线:第 71 行 → 参数 message → 循环里的 accept → OverworldScreen.onPlayerMoved
Overworld:move 第 71 行,和 notifyListenersworld/Overworld.java第 71–71、125–128 行
71 notifyListeners(listener -> listener.onPlayerMoved());
⋯ 省略第 72–124 行
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
127 message.accept(listener);
128 }
OverworldScreen.onPlayerMovedui/OverworldScreen.java第 72–77 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
  1. 第 71 行:listener -> listener.onPlayerMoved() 这一段不会马上运行。它被当成一个值,作为参数交给 notifyListeners。箭头左边的 listener 是它自己的参数名,现在还没有值。
  2. 第 125 行:notifyListeners 用参数 message 收下这段 lambda。message 的类型是 Consumer<OverworldListener>,它唯一的抽象方法 accept 的内容,就是这段 lambda。
  3. 第 126 行 for-each:对 listeners 里的每一个,叫它 listener,执行第 127 行 message.accept(listener)。现在列表里只有 1 个:startGame 登记的 COverworldScreen(下一个动画讲它怎么进来的)。
  4. accept(listener) 把这个 listener 放进 lambda 的参数,运行箭头右边:listener.onPlayerMoved()。listener 实际上是 COverworldScreen,所以运行它的第 73–76 行:清空状态栏,update()。COverworld 全程只知道“这是一个 IOverworldListener”。

谁登记?GameWindow.startGame

列表一开始是空的,总得有人把画面放进去。这个人是 CGameWindow:在选择界面点 Choose Mokepon 时,它的 startGame 按顺序做四件事。下面一步一步看。顺序图的读法见 第 1 章第 3 节:每根竖虚线是一个对象,从上往下是时间,实线箭头 = 调用,绕回自己 = 调用自己的方法;灰色的虚线箭头 = new 一个新对象。正在讲的那一步,箭头会变成红色、流动的虚线——那只是高亮,不管它是不是 new。顺序图下面是这一步正在运行的代码。

CGameWindowCworld : OverworldCOverworldScreennew Overworld(map, …)1new OverworldScreen(world, …)2getMap() · getPlayer() …3addListener(overworldScreen)4showScreen(overworldScreen)5(以后)玩家每走成功一步onPlayerMoved()6
startGame:先造、再登记、再显示;以后才有通知
GameWindow.startGameui/GameWindow.java第 58–63 行
58 public void startGame(Mokepon playerChoice) {
59 world = new Overworld(map, mokepons, playerChoice);
60 overworldScreen = new OverworldScreen(world, images, this);
61 world.addListener(overworldScreen);
62 showScreen(overworldScreen);
63 }
Overworld:3 个参数的构造器world/Overworld.java第 29–31 行
29 public Overworld(GameMap map, List<Mokepon> mokepons, Mokepon playerChoice) {
30 this(map, mokepons, playerChoice, new Random());
31 }
Overworld:4 个参数的构造器world/Overworld.java第 34–41 行
34 public Overworld(GameMap map, List<Mokepon> mokepons, Mokepon playerChoice, Random random) {
35 this.map = map;
36 this.mokepons = List.copyOf(mokepons);
37 this.random = random;
38 Inventory inventory = new Inventory(ItemCatalogue.getAll());
39 this.player = new Player(playerChoice, map.getStartPosition(), inventory);
40 /* Copy the objects, so collecting a box or beating a marker never changes the map. */
41 this.objects = new ArrayList<>(map.getObjects());
OverworldScreen 的构造器ui/OverworldScreen.java第 55–57、60–69 行
55 public OverworldScreen(Overworld world, GameImages images, GameNavigator navigator) {
56 super(navigator);
57 this.world = world;
⋯ 省略第 58–59 行
60 GameMap map = world.getMap();
61 double mapX = (UiStyle.GAME_WIDTH - map.getWidth() * MapView.TILE_SIZE) / 2.0;
62 double mapBottom = MAP_Y + map.getHeight() * MapView.TILE_SIZE;
63 mapView = new MapView(world, images, mapX, MAP_Y);
64 statsScroll = new PlayerStatsScroll(world.getPlayer(), STATS_X, STATS_Y,
65 STATS_WIDTH, STATS_HEIGHT);
66 statusBar = createStatusBar(mapBottom + STATUS_GAP);
67
68 addToView(mapView.getNode(), statsScroll.getNode(), createMovePad(images), statusBar);
69 update();
Overworld.addListenerworld/Overworld.java第 44–46 行
44 public void addListener(OverworldListener listener) {
45 listeners.add(listener);
46 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    玩家在起点 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
Overworld:move 第 71 行,和 notifyListenersworld/Overworld.java第 71–71、125–128 行
71 notifyListeners(listener -> listener.onPlayerMoved());
⋯ 省略第 72–124 行
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
127 message.accept(listener);
128 }
  1. 第 59 行 new Overworld(map, mokepons, playerChoice):造这一局的规则对象。3 个参数的构造器只有一行 this(..., new Random()):补上随机数生成器,交给 4 个参数的构造器(构造器链)。
  2. 4 个参数的构造器:存下 map,复制 36 只 Mokepon 的列表,new Inventory(...)(三种道具都是 0 个),new Player(...)(站在起点 (10,18)),再把 14 个物体复制进自己的 objects。第 23 行的 listeners 这时还是空列表。
  3. CGameWindow 第 60 行 new OverworldScreen(world, images, this),运行画面的构造器:第 57 行先把 world 存进自己的字段;第 60–61 行读 world.getMap(),算出地图放在哪;第 63 行造 CMapView(它读 getObjects() 和玩家位置),第 64 行造 CPlayerStatsScroll(读 getPlayer()),第 66 行造状态栏;第 68 行连同 4 个按钮一起放进画面,第 69 行自己 update() 一次,第一帧就画好了。注意:这时 COverworld 还不认识它。
  4. 第 61 行 world.addListener(overworldScreen):listeners.add(listener)。参数类型是接口 IOverworldListener;COverworldScreen 写了 implements OverworldListener,所以放得进去。现在列表里有 1 个对象。登记观察者的是 CGameWindow,考试常问 “Who registers the observer?”
  5. 第 62 行 showScreen(overworldScreen):窗口中间换成地图画面。你看到的就是这一帧,玩家(红 1)在 (10,18)。
  6. 以后每次 move 成功,第 71 行 notifyListeners(...)。第 126 行的 for 遍历 listeners,里面就是第 4 步登记的那个画面,对它执行 message.accept(listener),也就是 onPlayerMoved()。假如漏写第 61 行:列表是空的,循环一次都不跑。规则里玩家照样在走,屏幕却永远停在 (10,18)。

五个通知,画面各做什么

通知谁发(COverworld 的方法)什么时候COverworldScreen 做什么
onPlayerMoved()move、runAway走成功一步;逃跑退回上一格清空状态栏,update()
onEncounter(encounter)startEncounter(CMarker 调的)踩上感叹号弹出遇敌框(第 9 章)
onItemCollected(item)collect(CMysteryBox 调的)踩上问号箱状态栏写 You picked up a …!,update()
onEncounterWon(enemy)winEncounter(CGameWindow.winBattle 调的)打赢回到地图状态栏写 You beat the enemy …!,update()
onExitReached(fightsWon)reachExit(CExit 调的)踩上门弹出游戏结束框(第 15 章)

下面是代码和它在真实游戏里的结果,编号一一对应(鼠标放在图下的说明上,代码里同号的行会一起亮):

OverworldScreen 实现的五个方法ui/OverworldScreen.java第 72–112 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
78
79 @Override
80 public void onEncounter(Encounter encounter) {
813 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);
94 }
95
96 @Override
97 public void onItemCollected(Item item) {
981 statusBar.setText("You picked up a " + item.getName() + "!");
99 update();
100 }
101
102 @Override
103 public void onEncounterWon(Mokepon enemy) {
1042 statusBar.setText("You beat the enemy " + enemy.getName() + "!");
105 update();
106 }
107
108 @Override
109 public void onExitReached(int fightsWon) {
1104 showDialog(createGameEndDialog("You finished the game! You won " + fightsWon
111 + " fights!"));
112 }

为什么这样设计?COverworld 只认识接口 IOverworldListener,不知道收消息的是一个 JavaFX 画面,也不知道它会写状态栏还是弹框。所以:

7. 画面这一边:OverworldScreen、MapView、PlayerStatsScroll

构造器:每一块放在哪

OverworldScreen:常量和构造器ui/OverworldScreen.java第 30–47、55–70 行
30 private static final int MAP_Y = 40;
31 /* The player stats scroll in the top right. */
32 private static final int STATS_X = 1015;
33 private static final int STATS_Y = 40;
34 private static final int STATS_WIDTH = 215;
35 private static final int STATS_HEIGHT = 292;
36 /* The status bar sits under the map, in the centre. */
37 private static final int STATUS_WIDTH = 420;
38 private static final int STATUS_HEIGHT = 26;
39 private static final int STATUS_GAP = 20;
40 private static final String STATUS_STYLE = "-fx-background-color: white;"
41 + "-fx-border-color: black;";
42 /* The round movement buttons in the bottom right. */
43 private static final int PAD_X = 1054;
44 private static final int PAD_Y = 607;
45 private static final int BUTTON_SIZE = 52;
46 private static final int BUTTON_GAP = 4;
47 private static final int ICON_SIZE = 32;
⋯ 省略第 48–54 行
55 public OverworldScreen(Overworld world, GameImages images, GameNavigator navigator) {
56 super(navigator);
57 this.world = world;
58
59 /* Centre the map across the screen, whatever its size. */
60 GameMap map = world.getMap();
61 double mapX = (UiStyle.GAME_WIDTH - map.getWidth() * MapView.TILE_SIZE) / 2.0;
62 double mapBottom = MAP_Y + map.getHeight() * MapView.TILE_SIZE;
631 mapView = new MapView(world, images, mapX, MAP_Y);
642 statsScroll = new PlayerStatsScroll(world.getPlayer(), STATS_X, STATS_Y,
65 STATS_WIDTH, STATS_HEIGHT);
663 statusBar = createStatusBar(mapBottom + STATUS_GAP);
67
684 addToView(mapView.getNode(), statsScroll.getNode(), createMovePad(images), statusBar);
69 update();
70 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    地图 mapView(CMapView):放在 (mapX, MAP_Y)
  2. 2
    卷轴 statsScroll(CPlayerStatsScroll)
  3. 3
    状态栏 statusBar(一个 Label)
  4. 4
    方向按钮:createMovePad(images) 造的 GridPane;第 68 行 addToView 把四块按顺序放进画面
构造器里造的四块(编号和上面代码的红色圆圈一样)真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

位置全是算出来的,算一遍和截图对一对(游戏区的坐标加上菜单栏 25 就是截图坐标):

部件截图里代码怎么算
1 地图(320, 65)x = (1280 − 20 × 32) / 2.0 = 320.0;y = MAP_Y = 40
2 卷轴(1015, 65)STATS_X = 1015,STATS_Y = 40,215 × 292
3 状态栏(430, 725)x = (1280 − 420) / 2.0 = 430;y = 地图底边 (40 + 20 × 32 = 680) + STATUS_GAP 20 = 700
4 按钮(1054, 632)PAD_X = 1054,PAD_Y = 607

第 61 行除以 2.0 而不是 2:写 2.0 让结果直接是 double(mapX 是 double,CMapView 构造器第 30 行的参数 x 也是 double)。在这个项目里 1280 − 宽 × 32 一定是 32 的倍数,写 2 其实也不会丢小数;用 2.0 是一个好习惯:以后把窗口宽度或格子大小改成别的数,也不会被整数除法(两个 int 相除,小数直接扔掉)截断。第 59 行注释说 “whatever its size”,意思是位置用 map.getWidth() 算出来,没有写死 320:换一张别的大小的地图,它照样居中。

方向按钮:GridPane 里的四格

createMovePad 和 createMoveButtonui/OverworldScreen.java第 152–176 行
152 /* Up sits above down, with left and right on either side of down. */
153 private GridPane createMovePad(GameImages images) {
154 GridPane pad = new GridPane();
155 pad.setHgap(BUTTON_GAP);
156 pad.setVgap(BUTTON_GAP);
157 pad.setLayoutX(PAD_X);
158 pad.setLayoutY(PAD_Y);
1591 pad.add(createMoveButton(Direction.UP, images), 1, 0);
1602 pad.add(createMoveButton(Direction.LEFT, images), 0, 1);
1613 pad.add(createMoveButton(Direction.DOWN, images), 1, 1);
1624 pad.add(createMoveButton(Direction.RIGHT, images), 2, 1);
163 return pad;
164 }
165
166 private GameButton createMoveButton(Direction direction, GameImages images) {
167 ImageView icon = UiStyle.createIcon(UiStyle.loadImage(images.getButtonImage(direction)),
168 ICON_SIZE);
169 GameButton button = new GameButton(icon, BUTTON_SIZE / 2);
170 button.setMinSize(BUTTON_SIZE, BUTTON_SIZE);
171 button.setPrefSize(BUTTON_SIZE, BUTTON_SIZE);
172 button.addPressEffect();
173 button.setOnAction(event -> movePlayer(direction));
174 moveButtons.put(direction, button);
175 return button;
176 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    UP 放在第 1 列、第 0 行:x = 1054 + 52 + 4 = 1110,y = 632
  2. 2
    LEFT:第 0 列、第 1 行 → (1054, 688)
  3. 3
    DOWN:第 1 列、第 1 行 → (1110, 688)
  4. 4
    RIGHT:第 2 列、第 1 行 → (1166, 688)
pad.add(按钮, 列, 行):每格 52 像素,格子之间空 4 像素真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

GridPane(网格布局)的 add(按钮, 列, 行) 先写列、再写行。每个按钮是一个 CGameButton,圆角半径 BUTTON_SIZE / 2 = 26,正好是 52 的一半,所以是圆的。第 173 行 button.setOnAction(event -> movePlayer(direction)):点按钮时调用 movePlayer,和按方向键走的是同一个方法。第 174 行把按钮存进 moveButtons(一个“方向 → 按钮”的 EnumMap),update() 要用它。

按键:handleKey 和 movePlayer

方向键 → 方向 → world.moveui/OverworldScreen.java第 25–29、114–131 行
25 private static final Map<KeyCode, Direction> ARROW_KEYS = Map.of(
26 KeyCode.UP, Direction.UP,
27 KeyCode.DOWN, Direction.DOWN,
28 KeyCode.LEFT, Direction.LEFT,
29 KeyCode.RIGHT, Direction.RIGHT);
⋯ 省略第 30–113 行
114 /* The arrow keys move the player. Other keys are not used here. */
115 @Override
116 protected void handleKey(KeyEvent event) {
117 Direction direction = ARROW_KEYS.get(event.getCode());
118 if (direction != null) {
119 movePlayer(direction);
120 event.consume();
121 }
122 }
123
124 /* The overworld does nothing if the move is not possible.
125 * Nothing can move while a dialog is open.
126 */
127 private void movePlayer(Direction direction) {
128 if (!isDialogOpen()) {
129 world.move(direction);
130 }
131 }

update():按钮什么时候变灰

update:让画面和规则一致ui/OverworldScreen.java第 133–140 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
1381 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰
1
  1. 1
    → 按钮变淡:第 138 行 setUsable(false)。另外三个方向都能走,照常
overworld-blocked 的右下角:结果真实运行截图 · 站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰
站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰
23
  1. 2
    原因:玩家(Ironshell)在 (5,12)
  2. 3
    右边 (6,12) 是 BARRIER:canMove(RIGHT) → isWalkable 返回 false(和第 2 节的图是同一张截图)
同一张截图的地图部分:原因真实运行截图 · 站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰

update() 做三件事:重画地图(mapView.update()),更新卷轴(statsScroll.update()),再把四个按钮挨个问一遍。第 137 行 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) 也是 for-each:entrySet() 是这张表里所有“方向 → 按钮”的配对,每一对叫一个 Map.Entry;entry.getKey() 是方向,entry.getValue() 是按钮。第 138 行 setUsable(world.canMove(方向))。画面自己不懂地形,它只问规则。

这一章里出现了好几张“表”。Map 就是一张“键 → 值”的表:给一个键,查出一个值。EnumMap、HashMap、LinkedHashMap 只是存法不同(主要是遍历时的顺序不同),用法一样(第 2 章第 5 节)。它们里面真正装着什么:

字段类型键 → 值(真实内容)
ARROW_KEYSMap.of 造的,改不了KeyCode.UP 对应 Direction.UP;DOWN、LEFT、RIGHT 也一样,一共 4 对
moveButtonsEnumMap<Direction, GameButton>UP 对应向上的圆按钮,DOWN 对应向下的,LEFT 对应向左的,RIGHT 对应向右的,一共 4 对。EnumMap 按枚举的顺序遍历,所以 update() 依次问 UP、DOWN、LEFT、RIGHT
itemValues(CPlayerStatsScroll)LinkedHashMap<Item, Label>Health Tonic 对应一个写着 "0" 的 Label,Coffee 对应 "0",Poison 对应 "0"(捡到箱子以后变成 "1")。按放进去的顺序遍历
objectViews(CMapView)HashMap<MapObject, ImageView>14 个物体,每个对应自己的那张图片
loadedImages(CMapView)HashMap<String, Image>图片文件路径对应读好的图片(同一个路径只读一次)
GameButton.setUsable:点不动,再整个变淡ui/GameButton.java第 50–55 行
50 /* A disabled button fades out as a whole, including its text and image. */
51 public void setUsable(boolean usable) {
52 setDisable(!usable);
53 setBackgroundOpacity(NORMAL);
54 setOpacity(usable ? 1 : DISABLED_OPACITY);
55 }

setDisable(!usable):! 把 true 变 false,所以能用(true)→ 不禁用(false),不能用 → 禁用,按钮点不动。setOpacity(usable ? 1 : DISABLED_OPACITY) 是三元运算符:能用就完全不透明(1),不能用就 0.4,整个按钮连箭头一起变淡(第 6 章讲 CGameButton)。

下面是真实游戏里走向出口的一串截图。看右下角的按钮:每一帧都是 update() 刚问过四次 canMove 的结果。(这一串不是每一步都截了图,中间有几步省略。)

走向出口途中,现在在 (15,16)走向出口途中,现在在 (13,15)走向出口途中,现在在 (13,12)走向出口途中,现在在 (13,9)走向出口途中,现在在 (13,6)走向出口途中,现在在 (13,3)走向出口途中,现在在 (13,0)走向出口途中,现在在 (10,0)走向出口途中,现在在 (7,0)走到出口的门 (6,0):游戏结束对话框
真实截图连播:按钮灰不灰,完全由 canMove 决定;挡住的原因有两种,墙(BARRIER)和地图边

onPlayerMoved:走一步,旧消息就清掉

onPlayerMovedui/OverworldScreen.java第 72–77 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
1
  1. 1
    刚捡到箱子:You picked up a Poison!
之前(box-collected,玩家在 (18,16))真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
向下一格到 (18,17),左边就是绿色感叹号(weak marker)
1
  1. 1
    往下走一格到 (18,17):setText(""),空了
之后(before-marker)真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)

注释写着 “A successful move clears the old message”。只有走成功才清:撞墙时 move 第 68 行就返回了,不发通知,状态栏的字还在。

MapView:三层图片

MapView:字段和构造器ui/MapView.java第 19–44 行
19 public static final int TILE_SIZE = 32;
20
211 private final Overworld world;
22 private final Pane pane = new Pane();
23 private final Pane objectLayer = new Pane();
24 private final ImageView playerView;
25 /* Each image file is loaded only once, then shared by every tile that uses it. */
26 private final Map<String, Image> loadedImages = new HashMap<>();
27 /* One image view for each object, made once and reused. */
28 private final Map<MapObject, ImageView> objectViews = new HashMap<>();
29
30 public MapView(Overworld world, GameImages images, double x, double y) {
31 this.world = world;
32 pane.setLayoutX(x);
33 pane.setLayoutY(y);
34
35 for (MapObject object : world.getObjects()) {
362 objectViews.put(object, createObjectView(object));
37 }
383 playerView = createTileView(getImage(images.getPlayerImage()),
39 world.getPlayer().getPosition());
40
41 /* The player is added last so it is drawn on top of the objects. */
424 pane.getChildren().addAll(createTileLayer(world.getMap(), images), objectLayer, playerView);
43 update();
44 }

CMapView 的字段:world(这一局的 COverworld,构造器第 31 行存下;update() 每次都从它读 getObjects() 和玩家位置)、pane(整张地图)、objectLayer(物体那一层)、playerView(玩家图片)、两个 HashMap:loadedImages(“图片路径 → 图片”)和 objectViews(“物体 → 它的 ImageView”)。

400 张地形小图是这样造出来的:

MapView.createTileLayer:两层循环,造 400 张地形图片ui/MapView.java第 61–71 行
61 private Pane createTileLayer(GameMap map, GameImages images) {
62 Pane tileLayer = new Pane();
631 for (int row = 0; row < map.getHeight(); row++) {
642 for (int column = 0; column < map.getWidth(); column++) {
653 Position position = new Position(column, row);
66 String imagePath = images.getTileImage(map.getTerrain(position));
67 tileLayer.getChildren().add(createTileView(getImage(imagePath), position));
68 }
69 }
70 return tileLayer;
71 }
第 3 个:playerView玩家的图片(1 张)第 2 个:objectLayer还在 world.getObjects() 里的物体(开局 14 张)第 1 个:createTileLayer 造的 Pane400 张地形图片(没有字段存着它)屏幕上看到的:玩家盖住物体,物体盖住地形再放最后放
MapView 的 pane 里三样东西:从下往上 = 第 42 行 addAll 的顺序;越后放,画得越上面
向下一格到 (18,17),左边就是绿色感叹号(weak marker)
123
  1. 1
    绿色感叹号 (17,17):在第 2 层 objectLayer
  2. 2
    玩家 (18,17):第 3 层 playerView
  3. 3
    (19,16) 这块深绿色的森林格子:第 1 层地形
之前:玩家在感叹号右边一格真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
2
  1. 2
    玩家走到 (17,17):感叹号还在 objects 里,它的图也还在物体层,只是被后放的玩家图片盖住了(整张图变暗,是因为遇敌框的黑色半透明层盖在最上面)
之后:踩上感叹号真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
update、placeOnTile、getImageui/MapView.java第 50–59、90–101 行
50 /* Show only the objects that are still on the map, and move the player's image.
51 * Everything is redrawn from the world, so nothing old is left on the screen.
52 */
53 public void update() {
541 objectLayer.getChildren().clear();
55 for (MapObject object : world.getObjects()) {
56 objectLayer.getChildren().add(objectViews.get(object));
57 }
582 placeOnTile(playerView, world.getPlayer().getPosition());
59 }
⋯ 省略第 60–89 行
90 /* A tile's pixel position is its column or row times the tile size. */
91 private void placeOnTile(ImageView imageView, Position position) {
92 imageView.setLayoutX(position.getX() * TILE_SIZE);
93 imageView.setLayoutY(position.getY() * TILE_SIZE);
94 }
95
96 private Image getImage(String path) {
973 if (!loadedImages.containsKey(path)) {
98 loadedImages.put(path, UiStyle.loadImage(path));
99 }
100 return loadedImages.get(path);
101 }

PlayerStatsScroll:只更新会变的几行

PlayerStatsScroll:写一次的、每次更新的ui/PlayerStatsScroll.java第 47–56、62–65、81–86 行
47 /* The Mokepon's stats never change on the overworld, so they are written once. */
48 Mokepon mokepon = player.getMokepon();
49 int row = 0;
50 addRow(grid, row++, "Mokepon:", createLabel(mokepon.getName(), nameFont));
51 for (Stat stat : Stat.values()) {
52 String value = String.valueOf(mokepon.getStat(stat));
531 addRow(grid, row++, stat.getLabel() + ":", createLabel(value, numberFont));
54 }
55 fightsValue = createLabel("", numberFont);
56 addRow(grid, row++, "Fights:", fightsValue);
⋯ 省略第 57–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–80 行
81 public void update() {
822 fightsValue.setText(String.valueOf(player.getFightsWon()));
83 for (Map.Entry<Item, Label> entry : itemValues.entrySet()) {
84 int count = player.getInventory().getCount(entry.getKey());
853 entry.getValue().setText(String.valueOf(count));
86 }
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
123
  1. 1
    六个数值:构造器里写一次。注释:“The Mokepon's stats never change on the overworld”
  2. 2
    Fights:update() 里每次重新设置 fightsValue 的文字,现在 0
  3. 3
    Poison:itemValues 里 Poison 对应的 Label,每次重新设置文字,现在 1
box-collected:刚捡到 Poison真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了

六个数值不在字段里,update() 也不碰它们:在地图上它们不会变。

8. 回放:按一次 →,再踩一次问号箱

现在把前面所有的类串起来。先看真实画面:从起点往右走 8 格,往上走 2 格,踩到 (18,16) 的问号箱。

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)按右方向键走一格,现在在 (11,18)按右方向键走一格,现在在 (12,18)按右方向键走一格,现在在 (13,18)按右方向键走一格,现在在 (14,18)按右方向键走一格,现在在 (15,18)按右方向键走一格,现在在 (16,18)按右方向键走一格,现在在 (17,18)按右方向键走一格,现在在 (18,18)向上走一格,现在在 (18,17)走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
真实截图连播:每按一次,玩家图片挪 32 像素。下面两个动画把第一步和最后一步拆开看

按一次 →:从按键到下一帧

这是 overworld-start → walk-01 那一步。顺序图下面是这一步正在运行的代码(粉红色底、左边一条红线 = 这一步正在运行的行);屏幕有变化的几步,下面还有截图,红框编号每一步都一样:1 玩家,2 四个方向按钮。实线箭头 = 调用;正在讲的那一步,箭头变成红色、流动的虚线(只是高亮)。最上面的说明框代表 CGameWindow 把按键交过来。最后一步箭头全部亮起,是整个过程的总结。

COverworldScreenCOverworldC(10,18):PositionCmap : GameMapCplayer : PlayerCMapViewGameWindow 交来按键:handleKeyPress(e)move(RIGHT)1canMove(RIGHT)2move(RIGHT)3isWalkable((11,18))4moveTo((11,18))5onPlayerMoved()6update()7update()8statsScroll.update()canMove(d) ×49第 74 行:14 个物体各问一次 isAt((11,18))
按一次 → 键:调用顺序(从上往下是时间)
GameWindow 构造器:在 Scene 上装按键过滤器ui/GameWindow.java第 44–47 行
44 Scene scene = new Scene(root);
45 /* Use a filter so the screen gets the keys before any button or text box does. */
46 scene.addEventFilter(KeyEvent.KEY_PRESSED,
47 event -> currentScreen.handleKeyPress(event));
GameScreen.handleKeyPress(父类,final)ui/GameScreen.java第 32–39 行
32 public final void handleKeyPress(KeyEvent event) {
33 boolean isCombination = event.isControlDown() || event.isAltDown()
34 || event.isMetaDown() || event.isShiftDown();
35 if (isDialogOpen() || isCombination) {
36 return;
37 }
38 handleKey(event);
39 }
OverworldScreen:handleKey 和 movePlayerui/OverworldScreen.java第 116–122、127–131 行
116 protected void handleKey(KeyEvent event) {
117 Direction direction = ARROW_KEYS.get(event.getCode());
118 if (direction != null) {
119 movePlayer(direction);
120 event.consume();
121 }
122 }
⋯ 省略第 123–126 行
127 private void movePlayer(Direction direction) {
128 if (!isDialogOpen()) {
129 world.move(direction);
130 }
131 }
Overworld:canMove 和 move 的前半world/Overworld.java第 61–63、66–71 行
61 public boolean canMove(Direction direction) {
62 return map.isWalkable(player.getPosition().move(direction));
63 }
⋯ 省略第 64–65 行
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());
Position.move:造一个新格子world/Position.java第 25–28 行
25 /* The position one tile away in the given direction. */
26 public Position move(Direction direction) {
27 return new Position(x + direction.getXChange(), y + direction.getYChange());
28 }
GameMap.isWalkable 和 isInsideworld/GameMap.java第 46–54 行
46 /* The player can only stand on a walkable tile inside the map. */
47 public boolean isWalkable(Position position) {
48 return isInside(position) && getTerrain(position).isWalkable();
49 }
50
51 private boolean isInside(Position position) {
52 return position.getX() >= 0 && position.getX() < getWidth()
53 && position.getY() >= 0 && position.getY() < getHeight();
54 }
Player.moveToworld/Player.java第 40–43 行
40 void moveTo(Position newPosition) {
41 previousPosition = position;
42 position = newPosition;
43 }
Overworld:move 第 71 行,和 notifyListenersworld/Overworld.java第 71–71、125–128 行
71 notifyListeners(listener -> listener.onPlayerMoved());
⋯ 省略第 72–124 行
125 private void notifyListeners(Consumer<OverworldListener> message) {
126 for (OverworldListener listener : listeners) {
127 message.accept(listener);
128 }
OverworldScreen.onPlayerMovedui/OverworldScreen.java第 72–77 行
72 @Override
73 public void onPlayerMoved() {
74 /* A successful move clears the old message. */
75 statusBar.setText("");
76 update();
77 }
MapView:update 和 placeOnTileui/MapView.java第 53–59、91–94 行
53 public void update() {
54 objectLayer.getChildren().clear();
55 for (MapObject object : world.getObjects()) {
56 objectLayer.getChildren().add(objectViews.get(object));
57 }
58 placeOnTile(playerView, world.getPlayer().getPosition());
59 }
⋯ 省略第 60–90 行
91 private void placeOnTile(ImageView imageView, Position position) {
92 imageView.setLayoutX(position.getX() * TILE_SIZE);
93 imageView.setLayoutY(position.getY() * TILE_SIZE);
94 }
OverworldScreen.updateui/OverworldScreen.java第 133–140 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
138 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
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 }
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1
  1. 1
    屏幕还是旧的一帧:玩家画在 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
按右方向键走一格,现在在 (11,18)
1
  1. 1
    新的一帧 walk-01:玩家画在 (11,18)
真实运行截图 · 按右方向键走一格,现在在 (11,18)
按右方向键走一格,现在在 (11,18)
2
  1. 2
    四个按钮都能点
真实运行截图 · 按右方向键走一格,现在在 (11,18)
  1. 你按了 →。JavaFX 造出一个 KeyEvent(键盘事件)。CGameWindow 构造器第 46–47 行在 Scene 上装了一个事件过滤器(event filter),按键先到它这里。过滤器是一段 lambda:event -> currentScreen.handleKeyPress(event),不管是哪个键,直接交给现在的画面——COverworldScreen。顺序图最上面的说明框就是这一步。截图:玩家(红 1)还在 (10,18)。
  2. 父类 AGameScreen 的 final 方法 handleKeyPress 先查两件事。第 33–34 行用 ||(“或者”)连起四个检查:Ctrl、Alt、Meta、Shift 任何一个按着,就算组合键。第 35 行:有对话框开着,或者是组合键,就 return。都不是,走到第 38 行 handleKey(event)。COverworldScreen 改写了 handleKey,所以运行的是它的版本(模板方法)。
  3. 第 117 行 ARROW_KEYS.get(KeyCode.RIGHT) 查到 Direction.RIGHT;第 118 行它 != null(不是空的),所以第 119 行调用 movePlayer(RIGHT)。movePlayer 第 128 行再确认一次 !isDialogOpen()(没有对话框开着),第 129 行 world.move(RIGHT):把事情交给规则(顺序图第 1 个箭头)。
  4. COverworld.move 第 67 行 if (!canMove(direction)):要先算出 canMove(RIGHT),再用 ! 把结果反过来。所以先运行同一个对象的 canMove(第 2 个箭头,绕回自己 = 调用自己的方法)。第 62 行从里往外:player.getPosition() 取到玩家现在的格子,就是 (10,18) 那个 CPosition 对象。
  5. 第 3 个箭头:对 (10,18) 调用 .move(RIGHT)。注意这是 CPosition 的 move,和第 1 个箭头 COverworld 的 move 同名,但属于另一个类。CPosition 第 27 行 return new Position(x + 1, y + 0):造一个新的 CPosition (11,18) 还回去。原来的 (10,18) 没有变(第 3 节左边那张图)。
  6. map.isWalkable((11,18)):第一道关 isInside:0 ≤ 11 < 20,0 ≤ 18 < 20,在地图里。第二道关 getTerrain:tiles[18][11],地图第 18 行 OOOOWWGGGGPPFFFFFFFF 从 0 数第 11 个字母是 P,PATH 能走。返回 true。回到第 67 行:canMove 是 true,!true 是 false,if 不进去,第 68 行的 return 不执行。
  7. move 第 70 行再算一次 (11,18),交给 player.moveTo:先 previousPosition = position(记下 (10,18)),再 position = newPosition((11,18))。现在规则里的玩家已经在 (11,18),但屏幕还没变:截图还是旧样子(红 1)。
  8. move 第 71 行 notifyListeners(listener -> listener.onPlayerMoved())。第 126–127 行遍历 listeners:里面只有一个,就是 startGame 登记的 COverworldScreen。对它执行 lambda,也就是调用它的 onPlayerMoved()(第 6 节的小动画)。
  9. COverworldScreen.onPlayerMoved:第 75 行 statusBar.setText("") 清空状态栏(本来就是空的,看不出变化),第 76 行调用自己的 update()。截图换成 walk-01:下面几步做完,屏幕就是这个样子。
  10. update() 第 135 行 mapView.update():清空物体层,把 world.getObjects() 里的 14 个物体的图片放回去(一个都没少),再 placeOnTile(playerView, (11,18)):layoutX = 11 × 32 = 352,layoutY = 18 × 32 = 576。加上地图的 (320, 65),截图里是 (672, 641)(红 1)。接着第 136 行 statsScroll.update()(图上的说明框)把 Fights 和道具数量重新写一遍,这一步数字都没变。
  11. 第 137–139 行:四个按钮(红 2)各问一次 world.canMove(方向)。从 (11,18) 出发:上 (11,17) 是 P,下 (11,19) 是 P,左 (10,18) 是 P,右 (12,18) 是 F,四个都是 true,四个按钮都 setUsable(true)。
  12. 通知做完,回到 move 第 74 行:复制一份 objects,for-each 挨个问 isAt((11,18))(图上最下面的说明框)。14 个物体没有一个在 (11,18),第 76 行一次都没执行,循环结束,move 结束。
  13. 总结:一路返回到 handleKey,第 120 行 event.consume()。这一次按键处理完了,JavaFX 才把新的样子画到屏幕上:就是截图 walk-01。从上往下再看一遍顺序图:画面翻译按键 → 规则检查、移动、通知 → 画面按规则里的新数据重画。

一句话总结这一步:画面把按键翻译成方向 → 规则检查、移动、通知 → 画面按规则里的新数据重画。画面从来没有自己去改玩家的位置。

踩到问号箱:walk-09 → box-collected

最后一步按 ↑,从 (18,17) 走到 (18,16)。前半段和上面完全一样(检查、moveTo、onPlayerMoved、update);不一样的是 move 最后那个循环这次找到了东西。红框编号:1 问号箱,2 玩家,3 状态栏,4 卷轴里的 Poison,5 ↑ 按钮。第 1 步只亮最上面的说明框(前半段,上一个动画已经拆开看过),最后一步全部亮起(总结)。颜色的意思和上一个动画一样。

COverworldC箱子 : MysteryBoxC背包 : InventoryCOverworldScreenCMapViewCPlayerStatsScroll前半段和按 → 一样:canMove → moveTo → onPlayerMoved → update()isAt((18,16))1interact(this)2collect(this)3objects.remove(box)4add(Poison)5onItemCollected(Poison)6update()7update()8update()9canMove(d) ×410
踩上问号箱:move 最后的循环找到了 MysteryBox
Overworld.move 的前半world/Overworld.java第 66–71 行
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 }
MysteryBox.interactworld/MysteryBox.java第 18–21 行
18 @Override
19 void interact(Overworld world) {
20 world.collect(this);
21 }
Overworld.collectworld/Overworld.java第 110–115 行
110 /* Called by a mystery box when the player steps onto it. A box can only be collected once. */
111 void collect(MysteryBox box) {
112 objects.remove(box);
113 player.getInventory().add(box.getItem());
114 notifyListeners(listener -> listener.onItemCollected(box.getItem()));
115 }
Inventory.additem/Inventory.java第 33–35 行
33 public void add(Item item) {
34 counts.put(item, getCount(item) + 1);
35 }
OverworldScreen.onItemCollectedui/OverworldScreen.java第 96–100 行
96 @Override
97 public void onItemCollected(Item item) {
98 statusBar.setText("You picked up a " + item.getName() + "!");
99 update();
100 }
MapView.updateui/MapView.java第 53–59 行
53 public void update() {
54 objectLayer.getChildren().clear();
55 for (MapObject object : world.getObjects()) {
56 objectLayer.getChildren().add(objectViews.get(object));
57 }
58 placeOnTile(playerView, world.getPlayer().getPosition());
59 }
PlayerStatsScroll.updateui/PlayerStatsScroll.java第 81–87 行
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 }
OverworldScreen.updateui/OverworldScreen.java第 133–140 行
133 /* Make the map, the scroll and the buttons match the world. */
134 private void update() {
135 mapView.update();
136 statsScroll.update();
137 for (Map.Entry<Direction, GameButton> entry : moveButtons.entrySet()) {
138 entry.getValue().setUsable(world.canMove(entry.getKey()));
139 }
140 }
向上走一格,现在在 (18,17)
12
  1. 1
    箱子 (18,16)
  2. 2
    按 ↑ 之前的一帧:玩家画在 (18,17)。按键处理完以前屏幕不重画,所以前几步一直是这一帧
真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
2
  1. 2
    玩家 (18,16),箱子的图没了
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
3
  1. 3
    You picked up a Poison!
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
4
  1. 4
    Poison: 1
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
5
  1. 5
    ↑ 按钮变淡
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
  1. 现在在 (18,17)(红 2),按 ↑。canMove(UP):(18,16) 是地图第 16 行 OOOOWWGGPPPPGGGGFFFF 从 0 数第 18 个字母 F(森林),能走 → moveTo((18,16)) → onPlayerMoved() → 画面 update() 一次。这时箱子(红 1)还在 objects 里,所以箱子的图还会被放回去,玩家图片盖在它上面。这个中间样子你看不到:JavaFX 要等整个按键处理完才画,所以截图还是按键之前的样子。
  2. 第 74 行复制出一份 14 个物体的列表,for-each 挨个问 isAt((18,16))。前 11 个都不是;第 12 个是 (18,16) 的 CMysteryBox:它的 CPosition 和玩家的新 CPosition 是两个不同的对象(第 3 节右边那张图),但 x、y 都相同,equals 返回 true。截图还是旧的一帧:屏幕上玩家还画在 (18,17),可规则里 player.position 已经是 (18,16)。
  3. 第 76 行 object.interact(this)。变量 object 的类型是 AMapObject,实际对象是 CMysteryBox,所以运行 CMysteryBox 改写的这个 interact(动态绑定)。参数 this 是这个 COverworld,在 CMysteryBox 里叫 world。
  4. CMysteryBox.interact 只有一行:world.collect(this),把自己交回给 COverworld(回头调用)。箱子自己不改任何数据。
  5. collect 第 112 行 objects.remove(box):objects 从 14 个变成 13 个。循环用的是第 74 行的副本,还是 14 个,不受影响,不会抛异常。截图还是旧的:箱子的图还在屏幕上。
  6. collect 第 113 行 player.getInventory().add(box.getItem()):这个箱子装的是 Poison(数据文件第 465–466 行)。CInventory.add:counts.put(Poison, getCount(Poison) + 1),0 + 1 = 1。
  7. collect 第 114 行通知 onItemCollected(Poison)。COverworldScreen 第 98 行:"You picked up a " + "Poison" + "!" 写进状态栏(红 3)。
  8. 第 99 行 update() → 第 135 行 mapView.update():清空物体层,只放回 getObjects() 里剩下的 13 个物体的图。箱子不在列表里,它的图就没有被放回去:箱子从屏幕上消失。玩家图片(红 2)在 (18,16),截图 (896, 577)。
  9. 同一个 update() 的第 136 行 statsScroll.update():遍历 itemValues,Poison 那一行的 Label 写成 getCount(Poison) = 1(红 4)。
  10. 同一个 update() 的最后:四个按钮各问一次 world.canMove(方向)。canMove(UP):(18,15) 是地图第 15 行 WWWWWWBBPPGGGGBBBBBB 从 0 数第 18 个字母 B,BARRIER,false → ↑ 按钮(红 5)setUsable(false),点不动、变淡。下 (18,17)、左 (17,16)、右 (19,16) 都是 F,照常。
  11. 总结:回到第 74 行的循环,副本里剩下的两个物体,(2,2) 的箱子和 (6,0) 的门,都不在 (18,16)。循环结束,move 结束。JavaFX 画出最后的样子:截图 box-collected。这一次按键,画面一共 update() 了两次(第 1 步一次,第 8–10 步一次)。

踩到感叹号和门:下一站

踩到 CMarker 和 CExit 时,走的是同一条路:move → 循环 → isAt → interact(this),只是子类回头调用的方法不同(第 5 节的顺序图)。

自测

tiles[12][6] 是地图上的哪一格?为什么 CGameMap.getTerrain 写成 tiles[position.getY()][position.getX()]?

是 (6,12):第 12 行、第 6 列,地形 BARRIER。tiles 是“数组的数组”,外层是行,tiles[row] 是一整行,再取 [column]。行号就是 y,列号就是 x,所以先 y 后 x。写反成 tiles[6][12] 会取到 (12,6),那是一格水。

English: tiles is indexed tiles[row][column], so the first index is y and the second is x.

站在 (18,16) 时,↑ 按钮为什么是灰的?按顺序说出算出这个结果的方法。

COverworldScreen.update 对 UP 调用 world.canMove(UP) → COverworld.canMove:player.getPosition().move(UP) 得到新的 (18,15) → CGameMap.isWalkable:isInside 为 true,getTerrain 取 tiles[15][18] = BARRIER,isWalkable() 为 false → 结果 false → CGameButton.setUsable(false):setDisable(true),透明度 0.4。

English: OverworldScreen.update asks world.canMove for each direction; the tile above is a BARRIER, so the UP button is disabled.

CPosition 为什么要改写 equals?如果不改写,捡箱子会怎样?

AMapObject.isAt 用 position.equals(other) 判断玩家是不是踩在物体上。玩家每走一步,move 都造一个新的 CPosition 对象,和箱子的 CPosition 不是同一个对象。不改写的话,equals 只比较“是不是同一个对象”,永远是 false,箱子、感叹号、门都踩不到。改写以后只比较 x 和 y。改写 equals 必须一起改写 hashCode。

English: Position is immutable and every move makes a new object, so equals must compare x and y; otherwise isAt would never be true.

COverworld.move 为什么遍历 new ArrayList<>(objects),而不是 objects?

因为踩到问号箱时,interact → collect 会在循环还没结束时 objects.remove(box)。一边用 for-each 遍历 ArrayList 一边删除,通常会抛出 ConcurrentModificationException。遍历副本、删除原列表,就没有这个问题。

English: It loops over a copy because a MysteryBox removes itself from objects during the loop.

Player.moveTo 为什么不写 public?UML 上它前面是什么符号?

它是包内可见(package-private),UML 上是蓝色三角(文字 UML 写成 ~)。只有 world 包里的类能调用,实际上只有 COverworld.move 调用它。画面(ui 包)只能读位置,不能直接把玩家挪走,所以每一步都必须经过 COverworld 的规则检查。

English: moveTo is package-private, so only Overworld can move the player, after checking canMove.

观察者模式里,谁登记监听者?在哪一行?漏掉这一行会怎样?

CGameWindow.startGame 第 61 行 world.addListener(overworldScreen)。它先 new COverworld,再 new COverworldScreen,然后登记,最后 showScreen。漏掉的话 listeners 是空的,notifyListeners 的循环一次都不执行:规则里玩家在走、能捡东西,但屏幕一直停在第一帧,也不会弹出遇敌框。

English: GameWindow.startGame registers the OverworldScreen with world.addListener; Overworld then calls the OverworldListener methods in notifyListeners.

捡到箱子以后,屏幕上的箱子是怎么消失的?有没有哪行代码“删掉箱子的图片”?

没有专门删图片的代码。COverworld.collect 只把箱子从 objects 里删掉。画面收到 onItemCollected 后调用 update() → CMapView.update 先清空物体层,再只放回 world.getObjects() 里还在的物体的图。箱子不在列表里,它的图就没被放回去。

English: MapView.update rebuilds the object layer from world.getObjects(), so a collected box simply is not drawn again.

CMarker、CMysteryBox、CExit 被踩到时做的事完全不同,可 move 里只有一句 object.interact(this)。这叫什么?有什么好处?

多态(动态绑定)。interact 是 AMapObject 的抽象方法,三个子类各自改写:CMarker 调 startEncounter(this),CMysteryBox 调 collect(this),CExit 调 reachExit()。运行时看对象的真实类型决定运行哪一个。好处:move 不用写 if 判断物体种类;加一种新物体只要写一个新子类,move 不用改(开闭原则)。

English: MapObject is abstract; each subclass overrides interact and calls back a different Overworld method, so Overworld.move treats every object the same way.

下一章:踩到感叹号以后发生了什么,三种颜色的感叹号怎样用不同的策略挑敌人——第 9 章 遇敌:地形、类型和挑敌人。

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