在 Minecraft Mod 中使用 Coroutine

Coroutine(协程)是编程语言的一种机制,该机制允许开发者编写出和过程式写法相同的代码,并在编译时得到一系列相互调用的子过程,从而使不同的子过程可以在不同的场合执行。

Coroutine 的最常见用途是编写异步代码,而 Minecraft 中恰恰存在大量需要使用异步代码的场合:比方说我们有时需要将某段代码延迟到一个或多个 tick 后执行,有时需要将某段代码延迟到下一次事件触发时执行,等等。

目前很多主流的编程语言都提供了相对官方的 Coroutine 支持,如 C#、Python、JavaScript,等等。虽然 Java 官方至今仍然没有支持 Coroutine,但 JVM 上流行的两大编程语言 Scala 和 Kotlin,已经对 Coroutine 提供了一定程度的支持。本文将分别使用 Scala 和 Kotlin 两门编程语言实现相应的机制:

上图展示的是本文的核心代码,想必对于 Scala 和 Kotlin 有着一定程度了解的读者已经猜到这段代码实现的是什么需求了。上面的代码实现的特性是玩家在攻击怪物的时候给予 100 tick(约五秒)的攻击冷却,并在冷却结束后通知玩家。

本文针对的是 Forge 平台的 1.12.2-14.23.5.2768 版本,不过对于插件等其他平台等,原理是通用的。

概述

回调函数是实现异步逻辑的常见手段,通常异步行为执行完后会调用回调函数从而执行下一步行为:

// scala
def foo(value: Int, next: (Bar) => Unit) = {  
  next(new Bar(value))
}
// kotlin
fun foo(value: Int, next: (Bar) -> Unit) {  
    next(Bar(value))
}

任何普通的方法通常都可以写成回调函数风格,例如下面的代码负责把两个数相加:

// scala
def add(a: Int, b: Int, next: (Int) => Unit) = {  
  next(a + b)
}
// kotlin
fun add(a: Int, b: Int, next: (Int) -> Unit) {  
    next(a + b)
}

我们通常使用 Continuation Passing Style(简称 CPS)描述这种风格,虽然调用 CPS 风格的方法通常写起来会很痛苦(也就是通常所说的回调地狱):

// scala
add(40, 2, { value =>  
  foo(value, { bar =>
    doSomethingElse(bar)
  })
})
// kotlin
add(40, 2, { value ->  
    foo(value, { bar ->
        doSomethingElse(bar)
    })
})

如果我们能直接写成 doSomethingElse(foo(add(40, 2))) 该多好啊。实际上,把上面的回调地狱转换成可读性较强的写法的行为被称为 CPS 变换,它是 Scala 及 Kotlin 等语言实现 Coroutine 的基础。

在 Scala 中,我们可以声明返回值带有 @suspendable 注解的方法,而 Kotlin 则可以使用 suspend 关键字。具体的写法如下:

// scala
def add2(a: Int, b: Int): Int@suspendable = shift { next => next(a + b) }  
// kotlin
suspend fun add2(a: Int, b: Int): Int = suspendCoroutine { next => next(a + b) }  

这样我们就可以在特定场合下使用它们了:

// scala
reset {  
  var i = 1
  while (i < 100) {
    val j = add2(i, 1)
    println(f"$i + 1 = $j")
    i = j
  }
}
// kotlin
launch {  
    var i = 1
    while (i < 100) {
        val j = add2(i, 1)
        println("$i + 1 = $j")
        i = j
    }
}

配置

首先,因为配置环境时使用了 Gradle 新特性,而 Forge 使用的 Gradle 版本太过陈旧,因此读者需要使用 Gradle 4 或更高版本(本文使用的版本是 4.10.3),如果读者使用 Gradle Wrapper 的话,请打开 gradle/wrapper/ 目录并修改 gradle-wrapper.properties 文件(注意最后一行):

distributionBase=GRADLE_USER_HOME  
distributionPath=wrapper/dists  
zipStoreBase=GRADLE_USER_HOME  
zipStorePath=wrapper/dists  
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip  

然后我们需要配置 build.gradle。基于 Scala 的实现需要 scala-continuations 库,考虑到该库 Forge 已经自带,因此不必额外引入该库,仅需额外声明该库对应的 Scala 编译器插件:

// scala
sourceCompatibility = targetCompatibility = '1.7'

configurations {  
    compilerPlugin
}

dependencies {  
    compilerPlugin 'org.scala-lang.plugins:scala-continuations-plugin_2.11.1:1.0.2'
}

compileScala {  
    sourceCompatibility = targetCompatibility = '1.7'
    scalaCompileOptions.additionalParameters = [
        '-Xplugin:' + configurations.compilerPlugin.asPath,
        '-P:continuations:enable'
    ]
}

注意声明插件的同时还需要为编译器添加 -P:continuations:enable 参数。

基于 Kotlin 的实现需要引入 Forge 并未自带的 Kotlin 环境,这通常可以通过依赖一个名为 ShadowFacts' Forgelin 的 Mod 解决:

// kotlin
sourceCompatibility = targetCompatibility = '1.8'

repositories {  
    jcenter()
    maven {
        name = 'shadowfacts'
        url = 'http://maven.shadowfacts.net/'
    }
}

dependencies {  
    deobfCompile 'net.shadowfacts:Forgelin:1.8.3'
}

最后别忘了把 apply plugin: scalaapply plugin: kotlin 添加到配置文件中。后者通常还需要引入 Kotlin 的 Gradle 插件,使用 Kotlin 配置过 Gradle 的应该都比较清楚,这里就不再赘述了。

代码

我们声明一个 Mod 主类,并在其中实现 nextTicknextAttackEvent 两个方法:

// scala
object EventListener {  
  val tickListeners: mutable.Queue[ServerTickEvent => Unit] = mutable.Queue()
  val attackListeners: mutable.Queue[AttackEntityEvent => Unit] = mutable.Queue()

  @SubscribeEvent
  def onTick(event: ServerTickEvent): Unit = {
    if (event.phase == Phase.END) {
      tickListeners.dequeueAll(_ => true).foreach(listener => listener(event))
    }
  }

  @SubscribeEvent
  def onAttack(event: AttackEntityEvent): Unit = {
    if (!event.getTarget.world.isRemote) {
      attackListeners.dequeueAll(_ => true).foreach(listener => listener(event))
    }
  }
}

private def nextTick(): ServerTickEvent@suspendable = shift { continuation =>  
  EventListener.tickListeners.enqueue(continuation)
}

private def nextAttackEvent(): AttackEntityEvent@suspendable = shift { continuation =>  
  EventListener.attackListeners.enqueue(continuation)
}

@Mod.EventHandler
def preInit(e: FMLPreInitializationEvent): Unit = {  
  MinecraftForge.EVENT_BUS.register(EventListener)
}
// kotlin
object EventListener {  
    val tickListeners: Queue<(ServerTickEvent) -> Unit> = ArrayDeque()
    val attackListeners: Queue<(AttackEntityEvent) -> Unit> = ArrayDeque()

    @SubscribeEvent
    fun onTick(event: ServerTickEvent): Unit {
        if (event.phase == Phase.END) {
            tickListeners.toList().also { tickListeners.clear() }.forEach { listener -> listener(event) }
        }
    }

    @SubscribeEvent
    fun onAttack(event: AttackEntityEvent): Unit {
        if (!event.target.world.isRemote) {
            attackListeners.toList().also { attackListeners.clear() }.forEach { listener -> listener(event) }
        }
    }
}

private suspend fun nextTick(): ServerTickEvent = suspendCoroutine { continuation ->  
    EventListener.tickListeners.add { event -> continuation.resume(event) }
}

private suspend fun nextAttackEvent(): AttackEntityEvent = suspendCoroutine { continuation ->  
    EventListener.attackListeners.add { event -> continuation.resume(event) }
}

@Mod.EventHandler
fun preInit(e: FMLPreInitializationEvent): Unit {  
    MinecraftForge.EVENT_BUS.register(EventListener)
}

我们声明了两个事件监听器,并缓存了一串回调函数的列表。nextTicknextAttackEvent 两个方法能够向相应的列表添加回调函数,并在事件触发的时候:

  • 获取所有的回调函数;
  • 清空缓存的回调函数列表;
  • 按顺序依次执行所有回调函数。

注意上面三者必须依次进行,顺序不能有任何变动,读者可以想一想为什么有这样的要求。

最后就是我们的核心代码了:

// scala
@Mod.EventHandler
def init(e: FMLInitializationEvent): Unit = {  
  reset {
    val coolDownPlayers = mutable.HashSet[UUID]()
    while (true) {
      val event = nextAttackEvent()
      val player = event.getEntityPlayer
      val playerUUID = player.getUniqueID
      if (coolDownPlayers.contains(playerUUID)) {
        event.setCanceled(true)
      } else {
        event.getTarget match {
          case _: IMob => reset {
            coolDownPlayers.add(playerUUID)
            var coolDownTickLeft = 100
            while (coolDownTickLeft > 0) {
              coolDownTickLeft -= 1
              nextTick()
            }
            coolDownPlayers.remove(playerUUID)
            val message = "Cool down has expired"
            player.sendMessage(new TextComponentString(message))
          }
          case _ => ()
        }
      }
    }
  }
}
// kotlin
@Mod.EventHandler
fun init(e: FMLInitializationEvent): Unit {  
    GlobalScope.launch {
        val coolDownPlayers = hashSetOf<UUID>()
        while (true) {
            val event = nextAttackEvent()
            val player = event.entityPlayer
            val playerUUID = player.uniqueID
            if (coolDownPlayers.contains(playerUUID)) {
                event.isCanceled = true
            } else {
                when (event.target) {
                    is IMob -> launch {
                        coolDownPlayers.add(playerUUID)
                        var coolDownTickLeft = 100
                        while (coolDownTickLeft > 0) {
                            coolDownTickLeft -= 1
                            nextTick()
                        }
                        coolDownPlayers.remove(playerUUID)
                        val message = "Cool down has expired"
                        player.sendMessage(TextComponentString(message))
                    }
                    else -> Unit
                }
            }
        }
    }
}

一方面,我们可以注意到所有和实现特性相关的代码和对象声明,都放在了同一个方法下,这很好地贯彻了高内聚低耦合的设计原则;另一方面,这样做可以使代码的可读性大大增加,例如跳过 100 tick 的实现声明了一个非常单纯的 while 循环,并重复调用 nextTick 方法 100 次,如果不使用 Coroutine,我难以想象类似的实现能有多复杂。

说明

这里有几点需要说明:

  • 我们是在 Mod 加载的时候调用的这段代码,我们可以注意到,所有代码都是被一个以 reset(Scala)或 launch(Kotlin) 开头的块括了起来,因此即使内部写成了死循环的形式,实际运行的时候也只会在第一次 nextAttackEvent 调用的时候暂停,因此根本不会阻塞加载过程。

  • 我们声明了一个全局性质的 coolDownPlayers,但在代码里是以临时变量的方式表示的。实际上,该对象会在不同的子过程之间辗转,因此虽然它看起来是一个临时变量,但它是会被一直引用着的。

  • 如果游戏世界关闭,代码在执行到 nextAttackEventnextTick 的时候便永远不会继续执行下去,因此不必担心这个看起来像是死循环的代码不会终止的问题。

  • 因为所有的回调函数都是在游戏内事件触发的时候执行的,因此尽管看起来代码本身不同的片段的执行时机并不完全相同,但它们都是在游戏世界的主线程执行的,不必担心任何线程安全的问题。

总结

Coroutine 虽然不是什么新的技术,但是在 Minecraft Mod 中使用 Coroutine,的确能够带来一些相对崭新的写法。这种写法粗看起来可能不那么容易让比较熟悉 Minecraft 的开发者接受(比方说我一开始也不太能接受在代码里写一个循环并在循环里直接等待下一个 tick 这种看起来像是把线程「阻塞」了的写法),但是一旦适应了这样的写法,本人相信开发效率一定能够得到显著的提升。

由于作者本人并不熟悉 Kotlin,因此作者不太能保证本文中的 Kotlin 代码一定完全正确,望请读者谅解。