跳到主要内容

6-3 从构想到思绪

确定所用事件

开始冲锋

由于 Bukkit 肯定没有提供像是冲锋开始这样的事件,所以我们必须寻找一个功能相近的事件,然后想办法利用它判断我们关注的情况是否发生。触发冲锋的动作是玩家按下右键,尽管可能有其它限制条件(例如必须是在疾跑中),但触发冲锋的来源是按下右键,或者说,当玩家按下右键时,有可能会触发冲锋。既然是有可能,我们就需要监听这个事件。

玩家点按右键PlayerInteractEvent 的一种特殊情况,这个事件描述玩家以任何方式尝试与世界交互,例如破坏方块,放置方块,攻击实体等。基本上来说,只要点按了左右键,PlayerInteractEvent 就会被触发。所以,我们可以先监听这个事件,然后再判断玩家到底点击的是不是右键。

你可能会觉得对着空气按右键是不是不会被捕捉到?,但实际上是可以的 —— 与空气交互怎么就不是一种交互了呢(笑)?

结束冲锋

在我们的设计里,结束冲锋可能有三种原因:

  • 玩家停止疾跑。
  • 玩家撞上实体。
  • 玩家移动的距离达到上限。

这三者都可以通过 PlayerMoveEvent 来进行检测,PlayerMoveEvent 会在玩家每次移动时触发,我们可以在这里检查玩家是否在疾跑,是否距离某个实体很近(相当于撞上),是否已经移动了指定的距离等。

存储冲锋状态

将数据与玩家关联

接下来我们就要思考如何存储每个玩家的冲锋状态,即玩家当前是否在冲锋,已经冲锋了多远等等。在 Player 对象中存储数据的最简单方法是什么?PDC,当然,但 PDC 不是今天的主角,因为 PDC 只能存储一些简单类型的数据,而且由于 PDC 的存储机制(命名空间 ID、持久存储等),PDC 的读写速度也比较慢。

我们可以使用映射表(Map) 来存储每个玩家的冲锋状态。映射表和 YAML 中的表非常相似,同样是将给定的键与值关联起来的数据结构,在 Kotlin 中用接口 MapMutableMap 表示。你可以向 MapMutableMap 中提供一个对应关系,然后它就会记住这个关系,稍后只需要提供同样的键,就能取回设置的值:

val mm = mutableMapOf<Int, Int>()   // mutableMapOf 创建一个 MutableMap 对象,键类型是 Int,值类型也是 Int
mm[1] = 2       // 设置 1 对应 2
mm[8] = 9999    // 设置 8 对应 9999

println(mm[1])  // 2
println(mm[8])  // 9999
可变与不变

valvar 类似,映射表也有只读和可读可写两种,Map 是只读的,而 MutableMap 是可写的。

[] 用于访问映射表中给定键所对应的值,这个运算符非常强大 —— 它是可读可写的。直接使用 mm[1]查找1 对应的值,而向 mm[1] 赋值就是设置 1 所对应的值。

映射表的对应关系是单向的,只能通过键查找值,而不能反过来,也就是说,当你设置 1 对应 2 后,通过 1 可以查找到 2,但不能通过 2 来查找 1

如果将玩家作为键,那么就可以通过玩家查找到存储的数据,也就相当于是向玩家添加额外的数据了!所以,我们只要设计一个键是玩家,值是冲锋状态数据的映射表就好了。我们会在稍后的代码中介绍如何做到这一点。

存储的数据结构

那么,要保存的冲锋状态数据是什么呢?前面已经提到过,主要包含玩家是否在冲锋以及已经冲锋了多远。你可能会想,我们可以建立一个对象来存储这些数据,但实际上不需要这么麻烦。

还记得我们将用 BossBar 向玩家呈现一个进度条吗?每个玩家的冲锋状态都不同,因此所有正在发动冲锋的玩家,都拥有自己的 BossBar,所以,我们肯定要用映射表来存储玩家和 BossBar 之间的关联关系。我们能否借用这个 BossBar,来存储玩家的冲锋状态呢?

答案是肯定的。BossBar 存储着一个进度值,把它与最远冲刺距离相乘,就能得到剩余的冲锋距离。至于玩家是否在冲锋,只要看看玩家是否有关联的 BossBar 就好了。也就是说,我们完全不需要创建新的对象保存数据,而仅需要利用已有的,玩家与 BossBar 的关联关系,顺便保存冲锋数据,就 OK 了!

备注

像这种只把数据放在一个地方的设计方法,叫做单一数据源(Single Data Source) 模式。玩家已经冲锋的距离,唯一地存储在 BossBar 的进度中,任何时候如果我们想得知这一数据,我们只需要查询对应的 BossBar 就好了。

如果单独创建对象来保存冲锋数据,我们就不得不将 BossBar 的进度随时与这个数据对象保持同步,因为在这里,有冲锋数据对象和 BossBar 进度两个数据源,而同步多个数据源是一件很麻烦的事情。

当你发现你的程序中有多个对象的状态需要时刻保持一致时,请想想能否用单一数据源模式来简化工作。

加速与减速

要让玩家的速度增加和减少,最简单的方法就是对玩家施加状态效果

/**
 * 向玩家添加指定的状态效果,返回值指示这次添加是否成功。
 * 如果已经存在更强或时间更长的同名效果,则添加失败。
 */
fun addPotionEffect(effect: PotionEffect): Boolean

/**
 * 以 `type` 指定的状态效果类型,`duration` 指定的持续时间,`amplifier` 指定的等级,构造一个状态效果对象。
 * 状态效果的持续时间单位为刻。
 * 状态效果的等级为游戏内显示的等级 - 1,例如速度 III 的状态效果等级为 2。
 * `type` 必须从 `PotionEffectType` 枚举类中取值。
 */
class PotionEffect(type: PotionEffectType, duration: Int, amplifier: Int)

要让玩家加速,我们可以使用速度效果(PotionEffectType.SPEED),而减速则可以使用缓慢效果(PotionEffectType.SLOWNESS)。在冲锋开始时,我们赋予玩家一个持续时间无限的速度效果,而冲锋结束时则赋予一个持续一段时间的缓慢效果。效果的时长和强度在配置文件中可以修改。

冲锋结束后,玩家将在一定时间内无法冲锋,我们刚好可以使用这个缓慢效果来判断冲锋当前是否在冷却,Bukkit 会自动计算状态效果的时间,这非常方便,而且也与玩家的体验一致,因为在玩家无法冲锋的时候,他们会得到视觉上的反馈,避免出现明明一切都好好的怎么就是没法冲锋这种令人困惑的情况。

Bukkit 提供了一些其它的方法来设置玩家速度,但是它们操作起来比较麻烦,不如状态效果来得简单,而且使用状态效果也更贴合原版游戏的设计 —— 这对插件来说是很重要的!

冲锋击杀

在 Bukkit 中,判断两个实体的碰撞其实并没有直接的事件,因为本质上,实体的碰撞就是它们之间的距离短于它们的碰撞箱大小之和。不过,要读取实体的碰撞箱数据很麻烦,并且如果把实体的受击区域做得非常小,玩家在高速移动的时候想要瞄准就很困难。

有鉴于此,我们可以采取更简单的办法来判断碰撞,即当玩家冲锋时,寻找玩家附近小范围的实体,并选择其中的第一个作为冲锋的目标。只要范围设计得合适,看上去就和直接碰撞差不多,而且还能节省不少计算资源。

Bukkit 提供了非常方便的方法来寻找附近的实体:

/**
 * 获取当前实体附近的实体。
 * 三个参数分别指定在三个坐标轴上的最远搜寻距离。
 */
fun getNearbyEntities(x: Double, y: Double, z: Double): List<Entity>

当碰撞到实体后,我们可以像以前那样,通过设置 health 属性将实体击杀。不过,这种击杀d 观感不是很好,因为玩家没有做出任何攻击动作,实体就受到了伤害,这与游戏内的体验不符。也许最好在对实体造巨额伤害前,让玩家先攻击一下实体:

/**
 * 操作当前实体对 `target` 进行一次近战攻击。
 */
fun attack(target: Entity): Unit

这样,当玩家冲锋撞上某个实体(比如僵尸)时,就会做出攻击动作,同时目标的生命值将归零,看上去就像是冲刺击杀一样。好吧,这听上去确实是有点糊弄人的感觉,不过由于插件的能力是如此有限,因此很多时候没办法完美地模拟想要的情况,像这种替代方式,已经是近乎完美的方案了。大家在做完这个插件后,可以试试其它的方法,例如在攻击前给玩家一个短暂的力量效果等等。


把上面这些与我们的设计方案合并在一起,用我们熟悉的当……发生时,就做……的形式来写就是:

  • 当玩家进行交互时:
    • 检查是否满足冲锋条件(按下右键、没有缓慢效果、疾跑中)。
    • 赋予玩家速度效果。
    • 创建并在映射表中登记玩家的 BossBar
  • 当玩家移动时:
    • 判断是否在冲锋(映射表中有对应的 BossBar)。
    • 检查冲锋是否终止(撞上实体、不在疾跑、距离耗尽)。
    • 如果撞上实体,则对实体造成伤害。
    • 根据移动的距离,减少玩家的剩余冲锋距离。
    • 如果冲锋结束,赋予玩家缓慢效果,并删除对应的 BossBar

大致就是这样的原理,在下一节中我们将把这些转换成代码。