Mokepon代码导读
第一站 · 起步L0约 20 分钟

01先玩一局:用真实截图认识游戏

不看代码,先把游戏从头玩到尾,认出每个画面、每个按钮是哪个类画出来的

读完这一章你能
  • 说出游戏的三个画面分别是哪个类画的:CSelectScreen、COverworldScreen、CBattleScreen
  • 指着截图上的任何一块(血条、按钮、日志框、感叹号、卷轴……),说出是哪个类、哪个字段负责它
  • 算出地图上的格子 (10,18) 为什么在截图的 (640, 641)
  • 说出是哪些按钮和菜单项让画面切换:按钮 Choose Mokepon、Fight!、Back to it!、Return to Main Menu,Game 菜单里的菜单项 Run away、Main menu
  • 说出 CGameWindow 用哪几个字段记着画面,为什么没有一个 BattleScreen 类型的字段
  • 用一句英语说清楚:GameWindow shows one GameScreen at a time.

这一章先不读 Java 代码。我们把游戏真的从头玩到尾:选一只 Mokepon,在地图上走,捡一个问号箱,碰到敌人,打一仗,最后走到出口。每一张图都是程序真实运行时截下来的。图上的红框和红色数字告诉你:这一块是哪个类画的,存在哪个字段里。以后的章节打开代码时,你已经知道每个类在屏幕上是哪一块了。

先认识几个词。后面每一章都会用到,这里只说最少的意思(Java 语法本身放在 第 2 章):

1. 打开游戏:选择界面 SelectScreen

运行程序以后,CMain 先读数据文件 mokepon.game,把里面写的 36 只 Mokepon、地图和图片路径变成对象,然后造出窗口 CGameWindow。CGameWindow 放进窗口的第一个画面就是选择界面 CSelectScreen。这段启动过程在 第 5 章。

第一眼看到的画面

游戏刚打开:选择界面,还没选 Mokepon12345678
  1. 1
    菜单栏 MenuBar:CGameMenu 造的,CGameWindow 把它放在窗口最上面。换画面时它一直在
  2. 2
    标题:一个 Label,CSelectScreen 的 createTitle 造的
  3. 3
    名字列表:一个能上下滚动的 ScrollPane,里面一列 36 个按钮。现在只看得到前 11 个多一点。它造好就放进画面,没有存进字段
  4. 4
    其中一个名字按钮:CGameButton(项目自己写的按钮类),一只 Mokepon 一个
  5. 5
    灰色的圆 Circle(也没有存进字段)。中间放头像的 SelectScreen.portraitView(ImageView)现在是空的
  6. 6
    名字一行 SelectScreen.nameLabel:还没选,所以写着 Select a Mokepon
  7. 7
    卷轴上的数值表:左边一列是名字(HP: Energy: …),右边一列是值,现在全是空的
  8. 8
    Choose Mokepon 按钮 SelectScreen.chooseButton(也是 CGameButton),现在是灰的
游戏刚打开:选择界面上的 8 块,除了菜单栏,都是 SelectScreen 造的真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon

这张图上的东西,除了第 1 块菜单栏,全都是 CSelectScreen 自己造出来的。造的时间是 new SelectScreen(...) 的那一刻,也就是运行它的构造器(constructor)的时候。构造器就是 new 一个对象时自动运行的那段代码。它先把以后还要改的部件造好,存进字段(头像框 portraitView、名字 nameLabel、类型图标 typeIcon、Choose 按钮 chooseButton、技能和类型的值);然后调用 addToView(...),按下面的顺序把东西放进画面:灰色圆、头像框、标题、名字列表、名字那一行、卷轴图片、Choose 按钮、数值表。后放的画在上面,所以头像会盖在灰色圆上。灰色圆、标题、名字列表、卷轴图片以后都不用再改,所以造好就直接放进画面,没有字段存着它们。

最底下那层棕色背景放得更早。CSelectScreen 构造器的第一行是 super(navigator):先运行父类的构造器。三个画面类 CSelectScreen、COverworldScreen、CBattleScreen 都继承(extends)同一个父类(superclass) AGameScreen:父类里写好的字段和方法,三个子类自动都有。AGameScreen 的构造器第一个把棕色背景放进画面,所以三个画面的背景一样。AGameScreen 是抽象类(abstract class)(圆圈里是 A),它自己不能被 new,只能当父类用。

菜单栏不属于任何一个画面。它是 CGameMenu 造的,CGameWindow 把它放在窗口顶上。不管换到哪个画面,它都在。

点一个名字:右边的内容出现了

选中 NORMAL 类型的 Nightwing:名字后面没有类型图标12345
  1. 1
    被点的 Nightwing 按钮变亮了:setHighlighted(true) 让浅灰背景更实(不透明度 0.5 → 0.75),在棕色背景上看起来更亮。字段 selectedButton 记住它
  2. 2
    头像 portraitView:换成 Nightwing 的图片
  3. 3
    名字 nameLabel:Nightwing。后面没有图标,因为它是 NORMAL 类型
  4. 4
    右边一列的值:六个数值 statValues(155、27、68、83、42、86,一个数一个 Label)、技能 skillsValue、类型 typeValue
  5. 5
    Choose Mokepon 亮了,现在能点
点了 Nightwing:同一个画面,SelectScreen 把右边的内容全换了真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标

点名字按钮,会运行 CSelectScreen 里的 select 方法。它做的事,就是你在图上看到的变化:

  1. 把上一个高亮的按钮变回普通,把 Nightwing 按钮变亮(标注 1)。
  2. 把这只 Mokepon 记在字段 selectedMokepon 里,等一下 Choose 按钮要用。
  3. 让 Choose 按钮变回能点(标注 5)。
  4. 换头像、换名字(标注 2、3)。
  5. 只有带类型图片的 Mokepon 才显示类型图标(下一小节)。
  6. 把六个数值、技能和类型写进卷轴(标注 4)。

卷轴上的数字是从哪来的?就是数据文件 mokepon.game 第 56–65 行 Nightwing 那一段:

数据文件里写的卷轴上显示的
mokepon.5.hp=155
…
mokepon.5.dodge=86
HP 155、Energy 27、Strength 68、Accuracy 83、Defence 42、Dodge 86
mokepon.5.skills=
Lance|Aim|Pocket Sand|Wait
Lance, Aim, Pocket Sand, Wait(竖线变成了逗号,是 formatSkills 用 ", " 把技能名连起来的)
mokepon.5.type=NORMALNORMAL

名字后面的小火苗:类型图标

选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
12
  1. 1
    NORMAL 的 Nightwing:名字后面什么都没有
  2. 2
    Type: NORMAL
NORMAL 类型:没有图标真实运行截图 · 选中 NORMAL 类型的 Nightwing:名字后面没有类型图标
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12
  1. 1
    FIRE 的 Impike:名字后面多了火焰图标 typeIcon(ImageView)
  2. 2
    Type: FIRE
FIRE 类型:有图标真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了

为什么一个有图标、一个没有?数据文件第 397–400 行只给了四种类型的图片:fire、ice、water、lightning,没有 normal。CSelectScreen 先问 images.hasTypeImage(type):有图片就显示 typeIcon,没有就把它藏起来。藏起来的图标也不占位置,所以 NORMAL 的名字还是居中的。

把这一节看到的东西整理一下。CSelectScreen 用字段存着以后还要改的部件(下图下面一排)。名字列表和灰色圆造好就放进了画面,没有字段指着它们(上面两边)。36 个名字按钮也没有一个个存进字段,只有被点的那一个记在 selectedButton 里,下次点别的名字时好把它变回普通颜色。

名字列表 ScrollPane里面 36 个 GameButton选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui灰色圆 CircleportraitView: ImageViewnameLabel : LabeltypeIcon : ImageViewstatValues(6 个 Label)skillsValue · typeValuechooseButton: GameButtonselectedButton : GameButtonselectedMokepon : Mokepon只放进画面只放进画面
SelectScreen 的字段:空心菱形 = 用字段存着它;细线连着的两样只放进了画面,没有字段存着它们(冒号后面是字段的类型)

2. 进入地图:OverworldScreen

选好 Impike,点 Choose Mokepon。这个按钮做的事,是请窗口开始一局新游戏:调用 startGame 方法,把选好的 Mokepon 交过去。不过画面里没有一个直接指向 CGameWindow 的字段。它手里拿的是一个接口(interface) IGameNavigator,存在父类 AGameScreen 的字段 navigator 里。CGameWindow 实现(implements)了这个接口,所以最后真正运行的是 CGameWindow 的 startGame。在打印的 UML 上,要找的是 AGameScreen 的字段 navigator: GameNavigator(为什么这样绕一下,第 6 章讲)。startGame 按顺序做四件事:

  1. new Overworld(...):造这一局的“地图世界”,里面有玩家、地图上剩下的物体和走路的规则。
  2. new OverworldScreen(...):造负责把地图画出来的画面。
  3. world.addListener(overworldScreen):把这个画面登记给 COverworld。以后地图上出了事(玩家走了一步、捡到东西……),COverworld 就会通知它(第 3 节会看到)。
  4. showScreen(overworldScreen):把窗口中间换成这个新画面。

地图画面上的每一块

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)12345678910
  1. 1
    整张地图:CMapView 的 pane,20×20 个格子,每格 32 像素,一共 640×640
  2. 2
    玩家:MapView.playerView(一张白色星形图片),站在起点 (10,18)
  3. 3
    白色感叹号:一个 CMarker(random 组)
  4. 4
    红色感叹号:也是 CMarker(strong 组)
  5. 5
    绿色感叹号:也是 CMarker(weak 组)
  6. 6
    问号:一个 CMysteryBox(问号箱),里面装着一个道具
  7. 7
    洋红色的门:CExit,出口
  8. 8
    右上角的卷轴:CPlayerStatsScroll(字段 statsScroll)
  9. 9
    白色状态栏 statusBar(一个 Label),现在没有字
  10. 10
    四个方向按钮 moveButtons:4 个圆形 CGameButton,和方向键一样能走路
刚进地图:地图、卷轴、状态栏、方向按钮,都由 OverworldScreen 放进画面真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

记住一件事:地图画面上的东西都只是“画”出来的,它们不管规则。能不能往前走、踩到什么会发生什么,是 world 包里的 COverworld、CMarker、CMysteryBox、CExit 决定的。COverworldScreen 的类注释原话是:它 “only shows what happens; the rules for moving live in the Overworld class”。

地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld画地图:地块、还留在地图上的物体,以及玩家。CMapViewui地图界面右上的卷轴:玩家的 Mokepon、属性、胜场和道具数量。CPlayerStatsScrolluistatusBar : Label游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonuiplayerView(玩家)objectViews(地图物体)Marker · MysteryBox · Exit(world 包里的规则类)worldmapViewstatsScrollstatusBarmoveButtons ×4一个物体一张图
OverworldScreen 的字段,各自画哪一块;字段 world 指着管规则的 Overworld(空心菱形 = 用字段存着)

图里的 CMapView 还画了最底下那层地形格子(草地、小路、石头、水……),每个格子一张 32×32 的小图。玩家图片是最后加的,所以它总在物体上面。COverworldScreen 自己不判断能不能走:玩家要走时,它调用 world.move(...),把事情交给字段 world 指着的 COverworld。

三种颜色的感叹号、问号箱和门

地图上每个物体放在哪、是什么颜色,都写在数据文件里。这是本章唯一一段“代码”:数据文件 mokepon.game 的几行。先看懂它的写法:

代码里的红色数字和截图上的红色数字一一对应。

mokepon.game:起点、三组感叹号、出口和问号箱mokepon.game第 432–432、439–468 行
4321map.player.startingPos=10,18
⋯ 省略第 433–438 行
439map.mokeponRandom.0=3,3
440map.mokeponRandom.1=14,6
441map.mokeponRandom.2=12,12
442map.mokeponRandom.3=4,10
443map.mokeponRandom.image=assets/sprite_exclamation.png
4442map.mokeponRandom.colour=white
445
446map.mokeponStrong.0=5,4
447map.mokeponStrong.1=16,4
448map.mokeponStrong.2=12,16
449map.mokeponStrong.image=assets/sprite_exclamation.png
4503map.mokeponStrong.colour=red
451
452map.mokeponWeak.0=2,12
453map.mokeponWeak.1=6,16
454map.mokeponWeak.2=17,17
455map.mokeponWeak.image=assets/sprite_exclamation.png
4564map.mokeponWeak.colour=lime
457
458map.exit.image=assets/sprite_door.png
4595map.exit.colour=magenta
460map.exit.location=6,0
461
4626map.items.image=assets/sprite_question.png
463map.items.0.type=HEALTH
464map.items.0.location=3,12
465map.items.1.type=POISON
466map.items.1.location=18,16
467map.items.2.type=COFFEE
468map.items.2.location=2,2
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
122223334445666
  1. 1
    起点 (10,18):第 432 行 startingPos=10,18
  2. 2
    白色 4 个:第 439–442 行的 (3,3)、(14,6)、(12,12)、(4,10),第 444 行 colour=white
  3. 3
    红色 3 个:(5,4)、(16,4)、(12,16),第 450 行 colour=red
  4. 4
    绿色 3 个:(2,12)、(6,16)、(17,17),第 456 行 colour=lime
  5. 5
    门:第 459 行 colour=magenta,第 460 行位置 (6,0)
  6. 6
    问号箱 3 个:第 462 行的图片,第 463–468 行是 (3,12) HEALTH、(18,16) POISON、(2,2) COFFEE
同一张图片 sprite_exclamation.png,被染成三种颜色真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

三组感叹号用的是同一张图片(第 443、449、455 行都是 sprite_exclamation.png),只是 CMapView 按 colour 给它们染了不同的颜色。门也染了色(洋红色)。问号箱不染色。颜色不同,挑敌人的方法也不同:

你看到的(数据文件里的组)是哪个类踩上去会怎样
白色感叹号 ×4
map.mokeponRandom
CMarker + CRandomEnemyPicker36 只里随便挑一只当敌人
红色感叹号 ×3
map.mokeponStrong
CMarker + CTerrainEnemyPicker(ADVANTAGE)挑一只在这块地形上占优势的敌人(强敌)
绿色感叹号 ×3
map.mokeponWeak
CMarker + CTerrainEnemyPicker(DISADVANTAGE)挑一只在这块地形上吃亏的敌人(弱敌)
问号 ×3
map.items
CMysteryBox捡到一个道具:(3,12) HEALTH、(18,16) POISON、(2,2) COFFEE
门 ×1
map.exit
CExit游戏结束

挑敌人的细节在 第 9 章。这里只要记住:颜色只是样子,真正决定挑哪只敌人的是 Marker 里存着的那个 picker。

格子坐标怎么变成截图上的位置

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
123(10,18)4(18,16)5(19,19)
  1. 1
    左上角第一格 (0,0):截图上的 (320, 65)
  2. 2
    门 (6,0):第 6 列、第 0 行
  3. 3
    玩家起点:第 10 列、第 18 行
  4. 4
    问号箱:第 18 列、第 16 行
  5. 5
    右下角最后一格 (19,19)
(列, 行):列从左往右数,行从上往下数,都从 0 开始真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

位置写成 (列, 行),比如 (10,18) 就是“第 10 列、第 18 行”。注意行号是从上往下数的,所以 18 很靠下。换成截图上的像素,只要一个公式:

截图 x = 320 + 列 × 32  截图 y = 65 + 行 × 32

格子x = 320 + 列 × 32y = 65 + 行 × 32截图里它的左上角
(0,0) 左上角320 + 0 = 32065 + 0 = 65(320, 65)
(6,0) 门320 + 192 = 51265 + 0 = 65(512, 65)
(10,18) 起点320 + 320 = 64065 + 576 = 641(640, 641)
(18,16) 问号箱320 + 576 = 89665 + 512 = 577(896, 577)

320 和 65 是怎么来的?

每个格子在截图上的位置,只在 CMapView 的 placeOnTile 里算。COverworldScreen 只用 TILE_SIZE 算整张地图放在哪(左右居中),以及地图下面的状态栏放多低。规则类(CPosition、CPlayer、COverworld)只认识格子,不知道像素。

右上角的卷轴 PlayerStatsScroll

点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
1234
  1. 1
    Mokepon: Impike(你选的那只)
  2. 2
    六个数值:150、22、76、74、45、70,和选择界面一模一样
  3. 3
    Fights: 0——赢了几场,字段 fightsValue
  4. 4
    Inventory(背包):三种道具各有几个,字段 itemValues,现在都是 0
卷轴放大:上半是 Mokepon,下半是背包真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)

CPlayerStatsScroll 里有一句注释:“The Mokepon's stats never change on the overworld, so they are written once.” 所以六个数值只写一次;会变的只有 Fights 和三个道具数量,它们在 update() 里重新写。记住这里的 HP 是 150,后面打仗时你会看到 225。

3. 走路,捡到一个问号箱

按方向键(或者点右下角的方向按钮),玩家一次走一格。下面是真实的一串截图:从起点往右走 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 像素,最后往上两格踩到问号箱。最后一帧下面的状态栏出现了一行字;右上角卷轴里的数量变化在下面的对比图里

最后一步前后,放在一起比一比:

向上走一格,现在在 (18,17)
123
  1. 1
    问号箱还在 (18,16)
  2. 2
    玩家在 (18,17)
  3. 3
    ↑ 按钮能点
之前(walk-09)真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
13
  1. 1
    问号箱不见了,玩家站在 (18,16) 这一格
  2. 3
    ↑ 按钮变灰:再往上 (18,15) 是 BARRIER(墙),走不过去
之后(box-collected)真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
向上走一格,现在在 (18,17)
4
  1. 4
    状态栏 statusBar:空的
之前:地图下面的白色状态栏真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
4
  1. 4
    状态栏:You picked up a Poison!(COverworldScreen 的 onItemCollected 写上去的)
之后:多了一行字真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
向上走一格,现在在 (18,17)
5
  1. 5
    卷轴里 Poison: 0
之前:背包里没有 Poison真实运行截图 · 向上走一格,现在在 (18,17)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
5
  1. 5
    Poison: 1
之后:卷轴马上更新真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了

这一步背后发生了什么?用大白话说:

  1. 按键先到 CGameWindow:它在窗口上装了一个“按键过滤器”,任何按键都先交给当前画面的 handleKeyPress。当前画面是 COverworldScreen,它把“往上”交给 COverworld 的 move。
  2. COverworld 检查能不能走,能走就让 CPlayer 对象换到新格子(player.moveTo(...)),然后通知画面“玩家动了”。
  3. 新格子上有一个 CMysteryBox。COverworld 让它自己决定怎么办(方法 interact)。问号箱的做法是:请 COverworld 把自己从地图上拿掉,把道具 Poison 放进背包。
  4. COverworld 再通知画面“捡到了 Poison”。画面在状态栏写 You picked up a Poison!,然后重画地图(箱子没了)和卷轴(Poison 变 1),并重新算四个方向按钮能不能点(↑ 灰了)。

下面这张图把同一件事画成顺序图(sequence diagram)。这是网站里第一张顺序图,先学怎么看:

CGameWindowCOverworldScreenCOverworldCplayer : PlayerCbox : MysteryBox你按了 ↑ 键handleKeyPress(event)1move(UP)2moveTo(新格子 18,16)3onPlayerMoved()4interact(world)5collect(box)6onItemCollected(item)7update():重画地图和卷轴8
踩到问号箱的那一步:谁调用谁、谁通知谁

第 4 和第 7 个箭头是 COverworld 在通知画面(第 7 个交过去的 item 就是箱子里的 Poison)。它为什么知道要通知 COverworldScreen?因为第 2 节 startGame 的第 3 步 world.addListener(overworldScreen) 已经把这个画面登记给了 COverworld。这种“先登记,出事时收到通知”的对象叫监听者(listener),这种设计叫观察者模式(Observer),第 8 章细讲。

4. 踩到感叹号:遇敌对话框

从 (18,16) 往下走一格到 (18,17),再往左一格,就踩上了 (17,17) 的绿色感叹号。画面上弹出一个框:

踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示123
  1. 1
    半透明的黑色一层 layer:盖住整个画面,后面的东西都点不到
  2. 2
    白色的框:CModalDialog(模态对话框)的构造器里造的一个 VBox。它只是构造器里的局部变量 box,不是字段。整个 CModalDialog 对象 = 黑色的 layer + 里面这个框。下面放大看
  3. 3
    玩家站在 (17,17) 的绿色感叹号上(感叹号被玩家图片盖住了)
遇敌对话框:画面没有换,只是 OverworldScreen 上面盖了一个 ModalDialog真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
4567
  1. 4
    标题:Encountered a Hexwing(遇到了一只 Hexwing)
  2. 5
    两条地形提示 messages:你占优势,敌人吃亏
  3. 6
    Fight! 按钮:开打
  4. 7
    Run Away! 按钮:不打,退回去
对话框放大:一个标题、几行提示、两个按钮真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示

这个框是 COverworldScreen 收到 COverworld 的通知 onEncounter 以后造的:标题写 “Encountered a ” 加敌人名字,每条地形提示加一行,再加两个按钮,最后调用父类的 showDialog 把它盖到画面上。框开着的时候,按方向键也没用:三个画面共同的父类 AGameScreen 收到按键时,先看有没有对话框开着,开着就什么都不做(这种“父类先统一检查、再交给各画面自己处理”的写法叫模板方法(Template Method),第 6 章细讲)。

两条提示是怎么算出来的

向下一格到 (18,17),左边就是绿色感叹号(weak marker)
1123
  1. 1
    FOREST(深绿色):数据文件第 418–421 行里的字母 F(第 16、17 行的第 16–19 列,第 18、19 行的第 12–19 列)
  2. 2
    绿色感叹号 (17,17) 就在这片森林里
  3. 3
    玩家在 (18,17),下一步往左就踩上去
踩上去之前(before-marker):感叹号脚下的地形决定提示真实运行截图 · 向下一格到 (18,17),左边就是绿色感叹号(weak marker)

(17,17) 这一格的地形是 FOREST(森林,地图右下角那片深绿色)。每种类型都有一块“强项地形”和一块“弱项地形”(EMokeponType):

谁类型FOREST 对它来说框里的提示战斗里的数值
你:ImpikeFIRE强项地形 → ADVANTAGEYour mokepon will be at an advantage here!×1.5
敌人:HexwingLIGHTNING弱项地形 → DISADVANTAGEYour enemy will be at a disadvantage here!×0.5

如果是 NORMAL 类型,或者地形不是它的强项也不是弱项,就是 NEUTRAL(不变),这时不写提示。所以有些遇敌框里一条提示都没有。绿色感叹号专挑“在这里吃亏”的敌人:在 FOREST 上吃亏的是 LIGHTNING 类型,数据文件里一共 5 只(Hexwing、Voltusk、Skydart、Galecrest、Stormtalon),每次从里面随机挑一只。

先点 Run Away!,再踩一次

在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
12
  1. 1
    点了 Run Away!:玩家退回上一格 (18,17)
  2. 2
    绿色感叹号还在 (17,17)
Run Away! 之后真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
再次踩上感叹号:又遇到一只(敌人可能不同)
34
  1. 3
    再踩一次:这次是 Galecrest,换了一只
  2. 4
    提示一样:Galecrest 也是 LIGHTNING
再踩一次感叹号真实运行截图 · 再次踩上感叹号:又遇到一只(敌人可能不同)

点 Run Away! 只是关掉框,再让 COverworld 把玩家退回上一格(CPlayer 一直记着“上一格”在哪)。感叹号不会消失,所以可以再踩。每次踩都会重新随机挑敌人,所以第二次变成了 Galecrest。这一次我们点 Fight!。

5. 战斗画面 BattleScreen

点 Fight! 以后,CGameWindow 的 startBattle 做了两件事:请 COverworld 按这次遇敌造一场新的 CBattle(战斗规则),再 new 一个新的 CBattleScreen(战斗画面)放到窗口中间。真正 new 出 CBattle 的是 CEncounter(“这一次遇敌”)的 createBattle:它按地形给双方各造一个 CFighter,再用这两个 CFighter 和背包造 CBattle。每打一场,都是一个新的 CBattleScreen。

从踩上感叹号到战斗画面出来,整条调用链是这样的(读法和第 3 节的顺序图一样):

COverworldScreenCGameWindowCOverworldCmarker : MarkerCencounterinteract(world)1startEncounter(this)2chooseEnemy(...)3new Encounter(...)4onEncounter(encounter)5showDialog(遇敌框)6你点了 Fight!startBattle()7createBattle()8createBattle(inventory)9new Fighter ×2new Battle(...)battle10battle11new BattleScreen(battle, ...)12showScreen(...)13
踩上感叹号 → 遇敌框 → 点 Fight! → 战斗画面

第 1 步发生在玩家走上 (17,17) 那一刻(和第 3 节问号箱的 interact 是同一个方法名,感叹号的做法不同)。第 3 步 CMarker 用它存着的 picker 挑敌人(第 9 章)。最右边一列是第 4 步造出来、存在 COverworld 的字段 encounter 里的那个 CEncounter 对象。第 7 步和第 2 节一样,是通过接口 IGameNavigator 调到 CGameWindow 的。虚线箭头是“返回”:把造好的 battle 交回给调用的人。第 9 步造 CFighter 和 CBattle 的细节在 第 10 章。

战斗画面上有什么

点 Fight! 进入战斗界面,战斗刚开始123456
  1. 1
    左下的大卷轴:你的 CFighterPanel,字段 playerPanel。里面有名字、头像、HP 条、能量条
  2. 2
    右上的卷轴:敌人的 CFighterPanel,字段 enemyPanel
  3. 3
    三个道具按钮 itemButtons:Health Tonic、Poison、Coffee,下面的数字是还剩几个
  4. 4
    Actions 和技能按钮 skillButtons:Impike 有几个技能就有几个按钮
  5. 5
    日志框 logArea(一个 TextArea):一行一行写战斗经过
  6. 6
    还是同一个菜单栏。战斗中 Game 菜单里的 Run away 能点了(第 7 节)
战斗刚开始:两个 FighterPanel、两排按钮、一个日志框真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
游戏统一样式的按钮:圆角、半透明的灰色背景。CGameButtonui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuilogArea : TextArea战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。playerPanel : FighterPanelui战斗者身后的卷轴图片,和倒下时盖上去的红色遮罩。CFighterScrollui战斗中一方的显示块:卷轴、头像、名字、HP 条和能量条。enemyPanel : FighterPanelui彩色进度条:显示 HP 或能量还剩多少。CStatBarui带数字的属性条:在条上写出 “225 / 225” 这样的数字。CLabelledStatBaruiskillButtonsitemButtonslogAreaplayerPanelenemyPanelscrollscrollhpBar / energyBarhpBar / energyBarextends
BattleScreen 的部件:两个 FighterPanel 对象(虚线框),每个都有一张卷轴和两根条。空心菱形 = 用字段存着;空心三角 = extends(继承)

两个卷轴是同一个类 CFighterPanel 造出来的两个对象:一个给你,一个给敌人。CFighterScroll 是卷轴那张图,外加一层平时看不见的红色遮罩(打输的那一方会变红,下面会看到)。注意图里两根条的字段指向的是 CStatBar,不是 CLabelledStatBar——下一小节说为什么。

你的条有数字,敌人的条没有

点 Fight! 进入战斗界面,战斗刚开始
12
  1. 1
    HP 条:绿色的条 + 写在上面的 225 / 225(字段 numbers,一个 Label)
  2. 2
    能量条:蓝色,33 / 33
你的:LabelledStatBar(有数字)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
点 Fight! 进入战斗界面,战斗刚开始
34
  1. 3
    敌人的 HP 条:只有条,没有数字
  2. 4
    敌人的能量条:也没有数字
敌人的:StatBar(没数字)真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

条本身是 JavaFX 的进度条 ProgressBar,由 CStatBar 管。CLabelledStatBar 是 CStatBar 的子类(subclass):条的功能全部继承下来,再多放一个写数字的 Label。CFighterPanel 造条的时候看一个开关 showNumbers:给你的面板传 true,造 CLabelledStatBar;给敌人的传 false,造普通的 CStatBar。所以敌人的 HP 你只能看条的长短,猜不到准确数字。

为什么上面的图里 CFighterPanel 只连着 CStatBar?因为它的两个字段 hpBar、energyBar 写的类型都是 CStatBar,你的面板只是往里面装了子类 CLabelledStatBar 的对象。子类的对象可以放进父类类型的字段,这叫多态(polymorphism),第 14 章细讲。

LabelledStatBar 的 UML 类图
老师打印稿里的 LabelledStatBar:空心三角箭头指向 StatBar,意思是 extends(继承)。名字前的小图标:红色方块 = private,绿色圆点 = public,黄色菱形 = protected;带下划线的是 static(第 3 章细讲)

Impike 的 HP 为什么变成了 225

选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12
  1. 1
    HP: 150
  2. 2
    Energy: 22
选择界面:Mokepon 本来的数值真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Fight! 进入战斗界面,战斗刚开始
12
  1. 1
    HP 225 / 225
  2. 2
    Energy 33 / 33
战斗里:同一只 Impike,数值变大了真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

选择界面和地图卷轴上 Impike 的 HP 都是 150,战斗里却是 225 / 225。这不是 bug,是地形加成:开打时,每一方的六个数值都按地形乘一个倍数,四舍五入,再限制在合法范围里(HP 1–1000,其他 1–100)。这件事由 CFighter(“参加这一场战斗的 Mokepon”)在它的构造器里做,ETerrainEffect 提供倍数 1.5 / 1 / 0.5。真实数字:

数值Impike(FIRE):原来 × 1.5 → 战斗里Galecrest(LIGHTNING):原来 × 0.5 → 战斗里
HP150 × 1.5 = 225 → 225185 × 0.5 = 92.5 → 93(四舍五入)
Energy22 × 1.5 = 33 → 3328 × 0.5 = 14 → 14
Strength76 × 1.5 = 114 → 100(最多 100)73 × 0.5 = 36.5 → 37
Accuracy74 × 1.5 = 111 → 100(最多 100)86 × 0.5 = 43 → 43
Defence45 × 1.5 = 67.5 → 6859 × 0.5 = 29.5 → 30
Dodge70 × 1.5 = 105 → 100(最多 100)79 × 0.5 = 39.5 → 40

Galecrest 的数字屏幕上看不到(敌人的条没数字),表里是运行时记录下来的真实值。CMokepon 对象本身一直没变,变的是为这一场新造的 CFighter 对象。第 10 章专门讲为什么要有 CFighter。

按钮上的 [4] 和数字

点 Fight! 进入战斗界面,战斗刚开始
123
  1. 1
    Health Tonic:0 个,所以是灰的
  2. 2
    Poison:1 个(就是地图上捡的那个),能点
  3. 3
    Coffee:0 个,灰的
道具按钮 itemButtons:图片在上,剩几个在下真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
点 Fight! 进入战斗界面,战斗刚开始
456
  1. 4
    Lance [4]:方括号里是这个技能要花的能量
  2. 5
    Stun [4]
  3. 6
    Wait [-2]:负数的意思是“回 2 点能量”
技能按钮 skillButtons:名字 [能量]真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始

技能按钮上的字是 CBattleScreen 拼出来的:技能名 + “ [” + 能量消耗 + “]”。道具按钮的图片在上、数字在下,数字就是背包里剩几个;一个都没有就变灰。也可以按数字键 1–4 代替点技能按钮。

打一仗:一步一步看

这一仗我们点了四下:Wait、Stun、道具 Poison、Lance。点“下一步”或者“自动播放”,看每一下以后屏幕上变了什么。每一步左边是整张截图,右边是放大的日志框(框住的是这一步新多出来的几行)。红框编号在 5 步里意思相同:1 日志,2 你的能量,3 敌人 HP,4 敌人能量,5 Poison 按钮,6 红色遮罩,7 对话框。

点 Fight! 进入战斗界面,战斗刚开始123
  1. 2
    你的能量 33 / 33
  2. 3
    敌人 HP 满的(93 / 93)
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)12
  1. 2
    还是 33 / 33:已经是满的,Wait 回的 2 点加不上去
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半124
  1. 2
    29 / 33
  2. 4
    敌人能量 14 → 9(条短了)
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合135
  1. 3
    敌人 HP 只剩一丝:1 / 93
  2. 5
    Poison:1 → 0,变灰
真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
敌人倒下:敌人卷轴变红,弹出 You Win 对话框12367
  1. 2
    25 / 33
  2. 3
    敌人 HP 0
  3. 6
    敌人的卷轴变红
  4. 7
    You Win 对话框
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    日志(放大):只有第 1 行
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
1
  1. 1
    日志(放大):空一行,再多 Wait 的 4 行
真实运行截图 · 用 Wait:日志里是一句随机的发呆台词,然后敌人行动(能量本来就是满的 33 / 33,所以没有变化)
用 Stun 命中:敌人被眩晕,3 回合内伤害减半
1
  1. 1
    日志(放大):空一行,再多 Stun 和 Tackle 的 4 行
真实运行截图 · 用 Stun 命中:敌人被眩晕,3 回合内伤害减半
用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
1
  1. 1
    日志(放大):空一行,再多 1 行 Poison
真实运行截图 · 用道具 Poison:敌人 HP 直接变成 1,道具数量 1 → 0,不占回合
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
1
  1. 1
    日志(放大):Lance 的 2 行、空两行、fainted 和 You win。最上面几行被滚上去了
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
  1. 战斗开始。日志里只有一行:Player chose Impike. Began a fight against enemy Galecrest!。这一行是 CBattle 的构造器一造出来就写进日志的;CBattleScreen 只是把 CBattle 的日志显示在 logArea 里。
  2. 点 Wait [-2]。日志先空一行,再多 4 行:Player's Impike used Wait!、They think about what they will have for dinner.、Enemy Galecrest used Wait!、They wonder if this is a good strategy.。你先出手,敌人再出手,这就是一个回合。能量还是 33 / 33:Wait 的能量消耗写成 -2,意思是回 2 点;但能量最多只能回到开打时的 33,本来就是满的,所以没变(第 10 章)。
  3. 点 Stun [4]。日志先空一行,再多 4 行:Player's Impike used Stun!、Galecrest was stunned! Their damage is halved for 3 turns.、Enemy Galecrest used Tackle!、But they missed.。你的能量 33 − 4 = 29;敌人用 Tackle 花了 5 点,14 − 5 = 9,所以它的蓝条变短了。Tackle 没打中,为什么还扣能量?因为技能一用就先扣能量,打没打中是后面才算的(第 12 章)。
  4. 点道具 Poison。日志先空一行,再多 1 行:You used your Poison on Galecrest. It devastated them!。Poison 把敌人的 HP 直接变成 1,所以红框 3 里的绿条只剩最左边一小截(CStatBar 规定:只要 HP 大于 0,条至少显示 3%,免得看起来像已经倒下)。注意没有 “Enemy Galecrest used …” 这一行:用道具不给敌人出手的机会。能量也没花,还是 29。
  5. 点 Lance [4]。日志先空一行,再多:Player's Impike used Lance!、Hit Galecrest for 83 damage! They started bleeding!,空两行,Enemy's Galecrest fainted!、You win。83 是用双方战斗里的数值算出来的(怎么算,见 第 12 章);第 3 步的眩晕只让 Galecrest 打人减半,不影响你的 Lance。敌人只剩 1 点 HP,被打 83 点就到 0 了(HP 最低是 0,不会变成负数)。敌人倒下了,没机会出手。你的能量 29 − 4 = 25。

看第 5 步放大的日志:最上面一行是 Player's Impike used Stun!。日志比框长的时候,CBattleScreen 每次更新都会自动滚到最底下(它的 showLog 方法),所以最前面 7 行——开头那一行、一个空行、Wait 的 4 行、又一个空行——被滚到上面去了;从 Stun 开始的几行还看得见。

赢了:红色遮罩和 You Win

敌人倒下:敌人卷轴变红,弹出 You Win 对话框123456
  1. 1
    敌人的卷轴变红:CFighterScroll 的 faintMask(红色、35% 不透明的长方形)现在显示出来了
  2. 2
    HP 条空了:0 / 93
  3. 3
    You Win 对话框:又是一个 CModalDialog
  4. 4
    唯一的按钮 Back to it!:回地图
  5. 5
    三个技能按钮全灰了:战斗已经结束(battle.isFinished() 是 true)
  6. 6
    道具按钮也全灰了
战斗结束:谁倒下,谁的卷轴就变红真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框

每次点按钮以后,CBattleScreen 都会把整个画面和 CBattle 对一遍(它的 update):两根条、红色遮罩、日志、按钮能不能点。发现战斗结束了,就弹出结果框:你赢了是 “You Win”,输了是 “Game Over”(第 7 节有输的截图)。同时它让菜单重新检查一次,于是 Game 菜单里的 Run away 也变灰了:仗都打完了,不能再逃。

6. 回到地图,走到出口

点 Back to it!,CGameWindow 的 winBattle 先告诉 COverworld “这场赢了”(world.winEncounter()),再把原来那个 COverworldScreen 放回窗口中间(showScreen(overworldScreen))。注意不是 new 一个新的:地图上的状态(捡过的箱子、走到哪了)全都还在。

点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
12
  1. 1
    玩家还站在 (17,17)。绿色感叹号没了:赢了以后 COverworld 把这个 CMarker 从地图上拿掉
  2. 2
    状态栏:You beat the enemy Galecrest!
地图:少了一个感叹号真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
345
  1. 3
    HP 还是 150,不是 225:卷轴写的是 Mokepon 本来的数值
  2. 4
    Fights: 1
  3. 5
    Poison: 0:战斗里用掉了。战斗和地图用的是同一个背包
卷轴:多了一场胜利真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示

为什么 HP 又是 150?战斗里那个 225 存在为这一场造的 CFighter 对象里。战斗结束,这个 CFighter 就没人要了。CMokepon 自己从头到尾没有被改过,所以卷轴上还是 150。

接下来往出口走。出口是地图最上面一行的那扇门 (6,0)。下面这串截图每隔几步截一张:

点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示走向出口途中,现在在 (15,16)走向出口途中,现在在 (13,15)走向出口途中,现在在 (13,12)走向出口途中,现在在 (13,9)走向出口途中,现在在 (13,6)走向出口途中,现在在 (13,3)走向出口途中,现在在 (13,0)走向出口途中,现在在 (10,0)走向出口途中,现在在 (7,0)走到出口的门 (6,0):游戏结束对话框
真实截图连播:从 (17,17) 走到出口 (6,0)
走到出口的门 (6,0):游戏结束对话框
123
  1. 1
    标题:赢了几场就写几(这里是 1)。英文 “1 fights” 是程序原样拼出来的
  2. 2
    Return to Main Menu:回到选择界面
  3. 3
    Quit:关掉整个程序
出口对话框放大(玩家此时站在门 (6,0) 上,见上面连播的最后一帧)真实运行截图 · 走到出口的门 (6,0):游戏结束对话框

门是一个 CExit 对象。玩家踩上去,它请 COverworld 通知画面“到出口了”,并带上赢了几场。COverworldScreen 就弹出这个框。这个框和打输时的 Game Over 框是同一个方法造的(AGameScreen 的 createGameEndDialog):标题不同,按钮都是 Return to Main Menu 和 Quit。

点 Return to Main Menu:回到选择界面,之前的选择被清空1234
  1. 1
    名字一行又变回 Select a Mokepon
  2. 2
    数值全空了
  3. 3
    Choose Mokepon 又灰了
  4. 4
    没有哪个名字按钮是高亮的
点 Return to Main Menu:回到选择界面,和刚打开时一样真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空

点 Return to Main Menu,CGameWindow 把这一局整个丢掉(存着这一局的两个字段 world 和 overworldScreen 都设成 null),让 CSelectScreen 清空选择(它的 clearSelection),再把 CSelectScreen 放回中间。选择界面这个对象从程序开始就一直是同一个,只是被清空了。下次点 Choose,又会 new 一个全新的 COverworld:地图上的箱子和感叹号全部回来,背包清零。

7. 其他你会遇到的画面

上面那一局没有碰到的情况,这里用另外几局的真实截图补上。

同一个 Game 菜单,在三个画面里不一样

选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
123
  1. 1
    Run away:灰
  2. 2
    Main menu:灰
  3. 3
    Exit:能点
选择界面真实运行截图 · 选择界面打开 Game 菜单:Run away 和 Main menu 都是灰的
地图上打开 Game 菜单:Run away 灰,Main menu 可点
123
  1. 1
    Run away:灰
  2. 2
    Main menu:能点
  3. 3
    Exit:能点
地图真实运行截图 · 地图上打开 Game 菜单:Run away 灰,Main menu 可点
战斗中打开 Game 菜单:Run away 可以点了
123
  1. 1
    Run away:能点
  2. 2
    Main menu:能点
  3. 3
    Exit:能点
战斗中真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点了

菜单只有一个(CGameMenu),三个画面共用。每次换画面,CGameWindow 都让菜单问一问当前画面:“你允许逃跑吗?你允许回主菜单吗?”然后把不允许的选项变灰。变灰的选项连快捷键(Ctrl+R、Ctrl+B)也不起作用。

画面Run awayMain menu为什么
CSelectScreen灰灰还没开始玩,没有一局可以丢。CSelectScreen 改写(override)了 canReturnToMainMenu(),返回 false
COverworldScreen灰能点两个都用父类 AGameScreen 的默认答案:不能逃跑,能回主菜单
CBattleScreen
还在打
能点能点CBattleScreen 改写了 canRunAway():战斗没结束就能逃
CBattleScreen
已经打完
灰能点战斗结束了,canRunAway() 变成 false(上面 You Win 那张图)

Exit 不归画面管,永远能点。

CGameMenu 只认识父类 AGameScreen,每次都问同样的两个问题;当前画面是哪个子类,就由那个子类自己的方法回答(没改写的,用父类的默认答案)。这也是多态(polymorphism),第 6 章细讲。

GameScreen 的 UML 类图
老师打印稿里的 GameScreen:斜体名字和 A 表示抽象类;canRunAway() 和 canReturnToMainMenu() 就是菜单要问的两个问题。名字前:红色方块 = private,绿色圆点 = public,黄色菱形 = protected(第 3 章细讲)

能量不够:技能按钮变灰

能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)
1234
  1. 1
    Tackle [5]:要 5 点能量,只有 0 点,灰
  2. 2
    Aim [3]:要 3 点,也灰
  3. 3
    Wait [-2]:不花能量(反而回 2 点),还能点
  4. 4
    能量 0 / 12
另一局(玩家选了 Shellurk):连用 4 次 Aim,12 点能量花光了真实运行截图 · 能量 0:Tackle 和 Aim 都灰了,只剩 Wait(能量 -2,也就是回 2 点)

每次更新,CBattleScreen 都一个一个问:“这个技能现在用得起吗?”(CFighter 的 canUse:能量够不够)。用不起的按钮就变灰。所以能量花光的时候,你只能按 Wait 回能量。

战斗中从菜单逃跑

战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
1
  1. 1
    战斗中打开 Game 菜单,点 Run away(或按 Ctrl+R)
同一局 Shellurk,还在战斗中真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
23
  1. 2
    回到地图:玩家退回踩感叹号之前的那一格,就在感叹号 (12,12) 左边,也就是 (11,12)
  2. 3
    白色感叹号还在 (12,12)
逃跑以后真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在

菜单的 Run away 和遇敌框里的 Run Away! 结果一样:退回上一格,感叹号留着,这场不算赢也不算输。区别是时间点:Run Away! 是在开打之前,画面一直是 COverworldScreen;菜单的 Run away 是在开打之后,CGameWindow 还要把画面从 CBattleScreen 换回 COverworldScreen。完整流程在 第 15 章。

用道具:Coffee 和 Health Tonic

用道具之前:HP 被打掉了一些,能量也用掉了
1234
  1. 1
    Health Tonic:1 个
  2. 2
    Coffee:1 个
  3. 3
    HP 228 / 280:被 Lance 打掉 26,回合结束又流血掉 26(流血伤害不写进日志)
  4. 4
    能量 1 / 10:Tackle [5] 和 Aim [3] 都灰了
用道具之前(这一局玩家是 Ironshell)真实运行截图 · 用道具之前:HP 被打掉了一些,能量也用掉了
用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
234
  1. 2
    Coffee:1 → 0,变灰
  2. 3
    HP 不变,228 / 280
  3. 4
    能量回满 10 / 10,Tackle 和 Aim 又能点了
点了 Coffee真实运行截图 · 用 Coffee:能量回满,Coffee 数量 1 → 0,敌人没有行动
用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动
13
  1. 1
    Health Tonic:1 → 0,变灰
  2. 3
    HP 回满 280 / 280
再点 Health Tonic真实运行截图 · 用 Health Tonic:HP 回满,数量 1 → 0,敌人没有行动

这两下的日志

You used your Coffee on Ironshell. It restored all your energy!

You used your Health Tonic on Ironshell. It healed you to max hp!

和 Poison 一样,两行后面都没有敌人出手的那一行:用道具不算一个回合。所以这几下之间,敌人 Frostback 一直没动:程序记录的状态里,它的 HP 一直是 345 / 345,能量一直是 9 / 21(图里裁掉了敌人那边)。

三种道具按钮各自的数量来自同一个背包 CInventory,就是地图卷轴下半部分显示的那个。道具在 第 13 章。

输了:Game Over

玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框123
  1. 1
    这次是你这边变红:玩家的 Owlet 倒下了。红色遮罩盖住的是整个左下卷轴,不只是这个头像
  2. 2
    HP 0 / 120
  3. 3
    Game Over 对话框(下面放大)
另一局:玩家选 Owlet,敌人是 Impike(这一格地形对两边都不加不减)真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
45
  1. 4
    Return to Main Menu:回到选择界面
  2. 5
    Quit:关掉程序
Game Over 框放大:和出口框的两个按钮一模一样真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框

这一局是怎么输的?日志里写着:Owlet 先用了 Swole(力量 +5)、Bunker(防御 +6),然后一直按 Wait。Impike 的 Stun 和 Lance 前几次都没打中,第 5 回合 Lance 打中了:Hit Owlet for 45 damage! They started bleeding!。这一回合结束时和下一回合结束时,流血又各掉 45,而且流血的伤害不写进日志,只在第二次流完写一句 The bleeding stopped.(流血在 第 13 章)。所以 HP 是 120 → 75(Lance)→ 30(第一次流血)→ 0(第二次流血)。

红色遮罩用的是同一个规则:谁的 HP 到 0,谁的 CFighterScroll 就变红。两个按钮和出口框一模一样,因为是同一个方法 createGameEndDialog 造的。日志最后两行是 Player's Owlet fainted! 和 You lose。

前面是墙:方向按钮变灰

站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰
123
  1. 1
    玩家(这局是 Ironshell)站在 (5,12)
  2. 2
    右边一格 (6,12) 是 BARRIER(灰色石墙),不能走
  3. 3
    → 按钮变灰
每走一步,四个方向按钮都重新检查一次真实运行截图 · 站在 (5,12):右边 (6,12) 是障碍物 BARRIER,所以向右的按钮变灰

每次画面更新,COverworldScreen 都对四个方向各问一次 COverworld:“往这边能走吗?”能走就亮,不能走就灰。BARRIER 和 OCEAN(左下角深蓝色那块)是不能走的地形,地图外面也不能走。

8. 总结:画面、类和章节

把这一章看到的东西列成一张表。考试时看到 UML 上的一个类名,就能想起它在屏幕上是哪一块。

你在屏幕上看到的负责它的类(字段)细讲
整个窗口;决定中间放哪个画面CGameWindow5、6
顶上的 Game 菜单CGameMenu(GameWindow.menu)6
菜单里哪些变灰AGameScreen 的 canRunAway() / canReturnToMainMenu()6
每个画面的棕色背景、对话框、按键AGameScreen(三个画面的父类)6
选择界面CSelectScreen7
左边一列名字按钮CSelectScreen 的 ScrollPane + CGameButton7
头像、名字、类型图标SelectScreen.portraitView、nameLabel、typeIcon7
选择界面的数值卷轴SelectScreen.statValues、skillsValue、typeValue7
Choose Mokepon 按钮SelectScreen.chooseButton(CGameButton)7
地图画面COverworldScreen8
地形格子CMapView(画)+ CGameMap(数据)8
玩家图标MapView.playerView + CPlayer(位置)8
白 / 红 / 绿感叹号CMarker + CRandomEnemyPicker / CTerrainEnemyPicker8、9
问号箱CMysteryBox8、13
出口的门CExit8、15
右上角卷轴CPlayerStatsScroll(OverworldScreen.statsScroll)8
白色状态栏OverworldScreen.statusBar(Label)8
四个方向按钮OverworldScreen.moveButtons(CGameButton)8
遇敌框和地形提示CModalDialog + CEncounter9
战斗画面CBattleScreen14
两个大卷轴CFighterPanel(playerPanel / enemyPanel)+ CFighterScroll14
HP 条、能量条FighterPanel.hpBar / energyBar,字段类型都是 CStatBar;你的面板里装的是子类 CLabelledStatBar(有数字),敌人的是普通 CStatBar14
战斗里的数值(225 这种)CFighter(不是 CMokepon)10
技能按钮 [能量]BattleScreen.skillButtons + ASkill11、12
道具按钮和数量BattleScreen.itemButtons + CInventory13
日志框BattleScreen.logArea(TextArea),内容来自 CBattle 的日志11
倒下时的红色遮罩FighterScroll.faintMask14
You Win / Game Over / 出口框都是 CModalDialog。Game Over 和出口框由 GameScreen.createGameEndDialog 造(两个按钮);You Win 是 CBattleScreen 的 showResult 直接 new 的(只有 Back to it!)14、15

一次只显示一个画面

整个游戏只有一个窗口 CGameWindow。窗口顶上永远是菜单栏,中间一次只放一个画面:CSelectScreen、COverworldScreen 或 CBattleScreen。三个都是 AGameScreen 的子类。先看 CGameWindow 用哪几个字段记着它们(打印的 UML 上 CGameWindow 的字段表里都能找到;另外几个字段 stage、mokepons、map、images、root 和画面无关,这里不画):

窗口顶部的 Game 菜单:Run away、Main menu、Exit 三项。CGameMenuui游戏窗口:拥有 Stage,决定现在显示哪个界面。CGameWindowui一局地图上的游戏:玩家、还剩下的物体和移动规则。COverworldworld选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenui抽象类:三个界面的共同部分:背景、对话框、按键、菜单开关。AGameScreenuimenuworldselectScreenoverworldScreencurrentScreen
GameWindow 的字段:空心菱形 = 用字段存着(线上写的是字段名);空心三角 = extends。没有一根线从 GameWindow 直接连到 BattleScreen

换画面只靠一个私有方法 showScreen:记下新画面(currentScreen = screen)、把它放到窗口中间、让菜单重新问一遍。按键也是交给 currentScreen.handleKeyPress(...),CGameWindow 不用知道现在是哪个画面。

下面把整个旅程重放一遍。动画最上面的图里,亮框就是那一刻 currentScreen 指着的画面;会换画面的那一步,让画面切换的那根箭头也会亮(没有箭头亮的步骤,就是画面没换,只是画面自己在更新或弹了个框)。图的下面,左边是那一刻的完整截图,右边是放大的关键部分。

GameWindow.currentScreen(类型 GameScreen)指着下面亮起来的那个画面选择界面:从列表里挑一只 Mokepon,并查看它的属性。CSelectScreenui地图界面:地图、右上属性卷轴、方向按钮和下方状态栏。COverworldScreenui战斗界面:显示双方 Mokepon、技能按钮、道具按钮和日志。CBattleScreenuiMain menu 或Return to Main MenuChoose MokeponFight!Back to it!Run away(菜单)Main menu 或Return to Main Menu
画面怎么切换:亮框 = GameWindow 的 currentScreen 现在指着的画面;箭头上写的是让画面切换的按钮或菜单项
游戏刚打开:选择界面,还没选 Mokepon
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
点 Fight! 进入战斗界面,战斗刚开始
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
走到出口的门 (6,0):游戏结束对话框
真实运行截图 · 走到出口的门 (6,0):游戏结束对话框
点 Return to Main Menu:回到选择界面,之前的选择被清空
真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空
战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
Game Over 后点 Return to Main Menu,回到选择界面
真实运行截图 · Game Over 后点 Return to Main Menu,回到选择界面
游戏刚打开:选择界面,还没选 Mokepon
1
  1. 1
    Choose Mokepon:灰的,还不能点
真实运行截图 · 游戏刚打开:选择界面,还没选 Mokepon
选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
12
  1. 1
    Impike 和火焰图标
  2. 2
    Choose Mokepon 亮了
真实运行截图 · 选中 FIRE 类型的 Impike:名字后面有火焰图标,Choose 按钮亮了
点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
12
  1. 1
    起点 (10,18)
  2. 2
    状态栏是空的
真实运行截图 · 点 Choose Mokepon 后进入地图,玩家站在起点 (10,18)
走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
1
  1. 1
    Poison: 1
真实运行截图 · 走到问号箱 (18,16):捡到 Poison,状态栏和卷轴里的数量都变了
踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
12
  1. 1
    Fight!
  2. 2
    Run Away!(这次先点它)
真实运行截图 · 踩上 (17,17) 的感叹号:弹出遇敌对话框,有两条地形提示
在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
12
  1. 1
    退回 (18,17)
  2. 2
    感叹号还在 (17,17)
真实运行截图 · 在对话框里点 Run Away!:回到上一格 (18,17),感叹号还在
点 Fight! 进入战斗界面,战斗刚开始
1
  1. 1
    刚捡的 Poison:1 个
真实运行截图 · 点 Fight! 进入战斗界面,战斗刚开始
敌人倒下:敌人卷轴变红,弹出 You Win 对话框
123
  1. 1
    Back to it!
  2. 2
    敌人 HP 0
  3. 3
    敌人的卷轴变红
真实运行截图 · 敌人倒下:敌人卷轴变红,弹出 You Win 对话框
点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
12
  1. 1
    Fights: 1
  2. 2
    Poison: 0(用掉了)
真实运行截图 · 点 Back to it! 回到地图:感叹号消失,Fights 变成 1,状态栏提示
走到出口的门 (6,0):游戏结束对话框
1
  1. 1
    Return to Main Menu
真实运行截图 · 走到出口的门 (6,0):游戏结束对话框
点 Return to Main Menu:回到选择界面,之前的选择被清空
12
  1. 1
    Select a Mokepon
  2. 2
    又灰了
真实运行截图 · 点 Return to Main Menu:回到选择界面,之前的选择被清空
战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
1
  1. 1
    Run away(Ctrl+R)
真实运行截图 · 战斗中打开 Game 菜单:Run away 可以点(快捷键 Ctrl+R)
选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
12
  1. 1
    退回 (11,12)
  2. 2
    感叹号还在 (12,12)
真实运行截图 · 选 Run away:战斗取消,回到踩感叹号之前的那一格 (11,12),感叹号还在
玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
1
  1. 1
    Return to Main Menu
真实运行截图 · 玩家的 Mokepon 倒下:玩家卷轴变红,弹出 Game Over 对话框
Game Over 后点 Return to Main Menu,回到选择界面
1
  1. 1
    回到 Select a Mokepon
真实运行截图 · Game Over 后点 Return to Main Menu,回到选择界面
  1. 程序启动:CGameWindow 的构造器造好菜单和 CSelectScreen(存进字段 selectScreen),再调用 showScreen(selectScreen):字段 currentScreen(类型是 AGameScreen)现在指着 CSelectScreen,所以它亮了。下面每一步,亮框的那个就是当时 currentScreen 指着的画面。还没选,Choose Mokepon 是灰的。
  2. 点 Impike。画面没换,还是 CSelectScreen,只是它把右边的内容换了,Choose Mokepon 亮了。
  3. 点 Choose Mokepon:CGameWindow new 一个 COverworld 和一个 COverworldScreen,把画面登记给 COverworld(addListener),再换到地图。玩家在起点 (10,18)。
  4. 走路、捡到 Poison:画面没换。COverworldScreen 收到 COverworld 的通知,自己重画地图、卷轴和状态栏。
  5. 踩到 (17,17) 的感叹号:弹出的是一个 CModalDialog,盖在 COverworldScreen 上面。对话框不是画面,所以箭头不亮。
  6. 先点框里的 Run Away!:框关掉,玩家退回 (18,17)。还是 COverworldScreen。
  7. 再踩一次,这次点 Fight!:CGameWindow 请 COverworld 造一场 CBattle(真正 new 出两个 CFighter 和 CBattle 的是 CEncounter 的 createBattle),再 new 一个新的 CBattleScreen,currentScreen 换成它。
  8. Wait、Stun、Poison、Lance,打赢了。You Win 框盖在 CBattleScreen 上,画面还没换。
  9. 点 Back to it!:CGameWindow 告诉 COverworld 赢了,然后把原来那个 COverworldScreen 放回来。Fights 变成 1,Poison 用掉了。
  10. 走到门 (6,0):COverworldScreen 弹出游戏结束框。又是对话框,画面没换。
  11. 点 Return to Main Menu:CGameWindow 丢掉这一局,清空选择,换回 CSelectScreen。地图上点菜单的 Main menu,走的也是这根箭头。
  12. 另一局(玩家选了 Shellurk),正在 CBattleScreen 里。打开 Game 菜单:战斗中 Run away 能点。
  13. 点 Run away:CGameWindow 让 COverworld 把玩家退回 (11,12),再换回 COverworldScreen。这场战斗直接作废,感叹号还在。,也就是 (11,12)
  14. 又一局(玩家选了 Owlet),在战斗里输了:你的卷轴变红,Game Over 框盖在 CBattleScreen 上。
  15. 点 Return to Main Menu:从战斗画面直接回到 CSelectScreen。战斗中点菜单的 Main menu 也走这根箭头。另外,任何时候点 Quit 或菜单的 Exit,整个程序就关掉了。

看完这个动画,你应该能自己说出:只有 6 种操作会换画面(4 个按钮 Choose Mokepon、Fight!、Back to it!、Return to Main Menu,和 Game 菜单里的 2 个菜单项 Run away、Main menu);遇敌框、You Win、Game Over、出口框都只是盖在当前画面上的对话框。每个画面只管“显示”,真正的规则在 COverworld 和 CBattle 里。下一章先补 Java 语法:第 2 章 Java 最小知识包。

自测

截图标注里写着 BattleScreen.playerPanel.hpBar.bar,这串名字怎么读?它在屏幕上是哪一块?

从左往右读“里面的”:CBattleScreen 对象的字段 playerPanel(一个 CFighterPanel,你的卷轴),它的字段 hpBar(类型写的是 CStatBar,你的面板里装的是 CLabelledStatBar 对象),再里面的字段 bar(JavaFX 的 ProgressBar)。在屏幕上就是左下卷轴里那根绿色的 HP 条。

English: It is the ProgressBar inside the LabelledStatBar that the player's FighterPanel uses as its HP bar.

地图上的格子 (18,16) 在截图的哪个像素位置?320 和 65 是怎么来的?

x = 320 + 18 × 32 = 896,y = 65 + 16 × 32 = 577,和截图里问号箱的位置 (896, 577) 一样。320 = (1280 − 20 × 32) ÷ 2,让地图左右居中;65 = 地图离游戏区顶部 40 像素(MAP_Y)+ 菜单栏 25 像素。32 是 MapView.TILE_SIZE。

为什么敌人的血条上没有数字,你的有?

CFighterPanel 造条的时候看 showNumbers:CBattleScreen 给你的面板传 true,造的是 CLabelledStatBar(CStatBar 的子类,多一个写数字的 Label);给敌人的传 false,造的是普通 CStatBar。

English: Only the player's panel uses LabelledStatBar, a subclass of StatBar that also writes the numbers on the bar.

战斗里 Impike 的 HP 是 225 / 225,回到地图卷轴上又写 150。哪个是对的?为什么会不一样?

两个都对。150 是 CMokepon 本来的数值,卷轴 CPlayerStatsScroll 显示的就是它。开打时为这一场新造了一个 CFighter,按地形 FOREST 把 FIRE 类型的 Impike 所有数值乘 1.5:150 × 1.5 = 225。战斗结束,这个 CFighter 被丢掉,CMokepon 从来没被改过。

English: The battle stats live in a Fighter object made for that one battle; the Mokepon itself never changes.

为什么选择界面的 Game 菜单里 Main menu 是灰的,地图上却能点?Run away 又是什么时候能点?

菜单只有一个 CGameMenu,每次换画面都会问当前画面。AGameScreen 的默认答案是“不能逃跑、能回主菜单”。CSelectScreen 改写了 canReturnToMainMenu() 返回 false(还没有一局可丢);CBattleScreen 改写了 canRunAway(),战斗没结束时返回 true。所以 Run away 只有在战斗进行中能点。

English: Each GameScreen decides which menu options it allows, and GameMenu.update() asks the current screen.

踩到感叹号弹出遇敌框,算不算换画面?一共有哪几种操作会换画面?

不算。遇敌框是一个 CModalDialog,盖在 COverworldScreen 上面,CGameWindow 的 currentScreen 没变。会换画面的只有:Choose Mokepon(选择 → 地图)、Fight!(地图 → 战斗)、Back to it! 和 Run away(战斗 → 地图)、Main menu 和 Return to Main Menu(回到选择界面)。

English: GameWindow shows one GameScreen at a time; dialogs are drawn on top of the current screen.

CGameWindow 有没有一个 BattleScreen 类型的字段?那它怎么记住正在显示的战斗画面?

没有。CGameWindow 跟画面有关的字段是 selectScreen : SelectScreen、overworldScreen : OverworldScreen 和 currentScreen : GameScreen。战斗画面只放在 currentScreen 里:这个字段的类型是父类 AGameScreen,所以子类 CBattleScreen 的对象也能放进去(多态)。每打一场都 new 一个新的 CBattleScreen,因为它的字段 battle 是 final 的,一个画面只对应一场仗。

English: GameWindow has no BattleScreen field; the battle screen is only held in currentScreen, which is declared as GameScreen, and a new BattleScreen is made for every battle.

遇敌框里的 Run Away! 和战斗中菜单里的 Run away,结果有什么一样、什么不一样?

一样的是:都退回踩感叹号之前的那一格,感叹号留在地图上。不一样的是时间点:Run Away! 在开打之前,画面一直是 COverworldScreen,只是关掉对话框;菜单的 Run away 在战斗中,CGameWindow 还要把画面从 CBattleScreen 换回原来的 COverworldScreen。

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