MCFrames 卡扣注册表(StickRegistry)

框架如何"抓住"相邻方块并把整条结构解析出来。判定是 IFrame 双向配对 + latchMap 配对表两套规则串起来,最后由一次 BFS 遍历收敛成完整方块集合。

代码位置:src/main/scala/mrtjp/mcframes/registry.scala(92 行,MCFrames 侧)与 src/main/scala/mrtjp/mcframes/handler/MCFramesAPI_Impl.scala 的 StickResolver_Impl(49 行)。

两张表

object StickRegistry {
  var latchMap = Map[(Block, Int), Set[(Block, Int)]]().withDefaultValue(Set())
  var interactionList = Seq[IFrameInteraction]()
}
表 类型 作用
latchMap Map[(Block, Int), Set[(Block, Int)]] 卡扣对:(方块, meta) → 会被一起带走的 (方块, meta) 集合;withDefaultValue(Set()) 查不到返回空集而非抛错
interactionList Seq[IFrameInteraction] 外部 mod 注册的"伪框架"处理器,让不实现 IFrame 的方块也能参与连接

interactionList 由 MCFramesAPI.registerFrameInteraction 追加(见 API 接口)。

resolveStick:单步判定

def resolveStick(w: World, pos: BlockCoord, side: Int): Boolean = {
  def getFrame(pos: BlockCoord): IFrame = {
    val b = getBlock(w, pos)
    if (b.isInstanceOf[IFrame]) return b.asInstanceOf[IFrame]
    val te = getTileEntity(w, pos, classOf[IFrame])
    if (te != null) return te
    interactionList.find(_.canInteract(w, pos.x, pos.y, pos.z)).orNull
  }

  val f1 = getFrame(pos)
  if (f1 != null && f1.stickOut(w, pos.x, pos.y, pos.z, side)) {
    val p2 = pos.copy.offset(side)
    val f2 = getFrame(p2)
    return f2 == null || f2.stickIn(w, p2.x, p2.y, p2.z, side ^ 1)
  }

  if (latchSet(w, pos.x, pos.y, pos.z, side)) return true
  false
}

getFrame 的三级回退(顺序即优先级):

  1. 该格的方块实现了 IFrame(框架方块 走这条)
  2. 该格的方块实体实现了 IFrame(马达方块 走这条)
  3. 遍历 interactionList,第一个 canInteract 为真的(让外部 mod 无需改自己的类)

判定流程:

步骤 条件 结果
1 getFrame(pos) != null 且 stickOut(pos, side) 进入第 2 步
2 对面 p2 = pos.offset(side) 的 getFrame(p2) f2 == null(对面根本不是框架)→ 返回 true;否则返回 f2.stickIn(p2, side ^ 1)
3 第 1 步不成立 退到 latchSet(w, pos, side)

⚠️ 第 2 步有个重要行为:f2 == null 时直接判 true。也就是说——只要这一格是框架且该面 stickOut 为真,对面即使是完全无关的方块也会被判定为"黏住"。这正是框架的用法:它无条件抓住正对面的方块(框架方块 的 stickOut 六面全 true)。

两处 side ^ 1:传入对面时把方向取反,因为"我这边朝 side 伸出"对应"对面朝 side ^ 1 被抓住"。

IFrame 的 javadoc 明确要求 stickOut / stickIn 必须在客户端与服务端结果一致——StickResolver_Impl 两侧都会跑。

latchSet:卡扣对

def latchSet(w: World, x: Int, y: Int, z: Int, side: Int) = {
  val pos = new BlockCoord(x, y, z).offset(side)
  val b1 = getBlockMetaPair(w, x, y, z)
  val b2 = getBlockMetaPair(w, pos.x, pos.y, pos.z)

  val set = latchMap.getOrElse(b1, latchMap((b1._1, -1)))
  set.contains(b2) || set.contains((b2._1, -1))
}

回退链:先查 (b1, 精确meta),没有则查 (b1._1, -1)(该方块所有 meta)。命中后 b2 同样两级匹配(精确 meta → 任意 meta)。

方向性:只单向生效

def addLatchSet(b1: (Block, Int), b2: (Block, Int)) {
  latchMap += b1 -> (latchMap(b1) + b2)
}

不做对称化。配置注释原文说明得很清楚:

'block1 -> block2' means that if block1 is moved, any block2 connected to it will also move. However, moving block2 does not move block1. To do that, you must also register block2 -> block1.

所以三条默认值 minecraft:bed -> minecraft:bed 等自映射看起来多余、实则必要——自映射才让床/门的两半互相带动(单向上行不够)。详见 配置文件。

卡扣的键语法

val rKeyVal    = raw"([\w:]+)\s*->\s*(.+)".r
val rName      = raw"(.+)".r
val rNameMetaM = raw"(.+)m(\d+)".r
val rMod       = raw"mod:(\w+)".r

比 relocation 侧(MovingTileRegistry)宽松:value 用 (.+) 贪婪匹配,允许含空格;key 用 ([\w:]+) 限定为单词字符与冒号。

形式 含义
<modID>:<blockname> 该方块所有 meta(fixName 补 minecraft: 前缀)
<modID>:<blockname>m<meta> 该方块该 meta

parseKV / parseBlockMeta / fixName 的实现与 方块搬运器注册表 里的同名方法逐字相同(各自独立定义,非共享)。

⚠️ rMod = raw"mod:(\w+)" 在本文件中定义了但无任何使用点——parseBlockMeta 只匹配 rNameMetaM 与 rName,没有 case rMod 分支。所以 mod:SomeMod -> something 会被 rName 捕获成方块名 "mod:SomeMod"(含 : 故 fixName 不加前缀)→ Block.getBlockFromName 返回 null。配置注释里也确实没有列出 mod: 键。

StickResolver_Impl:结构解析

getStructure 返回整条结构的方块坐标集合,BlockRow 的行分解与搬运由 运动模型 接手。

override def getStructure(w: World, x: Int, y: Int, z: Int, ex: BlockPos*): JSet[BlockPos] = {
  world = w
  start = new BlockCoord(x, y, z)
  excl  = ex.map(b => new BlockCoord(b.x, b.y, b.z)).toSet
  val result = iterate(Queue(start))
  world = null; start = null; excl = null
  result.map(b => new BlockPos(b.x, b.y, b.z))
}

ex: BlockPos* 是排除项——马达方块 传入自己,避免"马达被自己推动"。javadoc 原文:“All coordinates to not include in the structure. generally, one of these is the motor block that moved the structure.”

BFS(工作表算法)

@tailrec
private def iterate(open: Seq[BlockCoord], closed: Set[BlockCoord] = Set.empty): Set[BlockCoord] = open match {
  case Seq() => closed
  case Seq(next, rest @ _*) =>
    WorldLib.getBlock(world, next) match {
      case block: Block =>
        val toCheck = Vector.newBuilder[BlockCoord]
        for (s <- 0 until 6) {
          if (StickRegistry.resolveStick(world, next, s)) {
            val to = next.copy.offset(s)
            if (!excl(to) && !closed(to) && !open.contains(to))
              if (!world.isAirBlock(to.x, to.y, to.z) && !RelocationAPI.instance.isMoving(world, to.x, to.y, to.z)
                  /** && MovingTileRegistry.canMove(world, to.x, to.y, to.z)* */ )
                toCheck += to
          }
        }
        iterate(rest ++ toCheck.result(), closed + next)
      case _ => iterate(rest, closed + next)
    }
}

四个过滤条件(全部满足才纳入):

条件 作用
!excl(to) 不在排除集(马达自身)
!closed(to) && !open.contains(to) 去重,兼作已访问标记
!world.isAirBlock(to) 不把空气拉进结构
!RelocationAPI.instance.isMoving(world, to...) 不把正在移动中的方块拉进来(避免嵌套移动)

⚠️ 被注释掉的那行是搬运器可动性检查,源码注释写着:

/** && MovingTileRegistry.canMove(world, to.x, to.y, to.z)* */
// Dont ignore non-movables, have them halt movement.

即刻意不检查"这块能不能搬"——设计意图是让搬不动的方块留在结构里从而中止整次移动,而不是提前把它从结构中剔除。(不过实际效果取决于 运动模型 那一侧的判定,两者并非完全等价。)

静态可变状态(非线程安全)

object StickResolver_Impl extends StickResolver {
  private var world: World = null
  private var start: BlockCoord = null
  private var excl: Set[BlockCoord] = null
}

world / start / excl 是 object 上的全局可变字段,getStructure 执行期间被赋值、结束后清空。后果:

  • 不可重入——若某方块的 IFrame 实现或 canInteract 在 resolveStick 过程中回调 getStructure,内层调用会覆写外层的 world/excl,外层遍历随即错乱
  • 非线程安全——两个维度同时解析结构会互相污染
  • 正常路径下有 world = null 的收尾,但若 iterate 抛异常,收尾的三行不会执行,字段会残留脏值

源码现状,未加锁也未用局部变量重构。

性能特征

iterate(rest ++ toCheck.result(), closed + next) 每轮都做一次 Seq 拼接(++ 在 Vector 上是 O(n) 拷贝)与 Set 拷贝;!open.contains(to) 还是线性扫描开放表。结构规模为 N 时整体是 O(N²) 量级。@tailrec 保证它编译成尾递归循环而非真正递归,不会栈溢出。

与搬运器的正交性

卡扣解析只回答"哪些方块要一起动",完全不涉及"怎么搬"。前者是本页的 StickRegistry / StickResolver_Impl,后者是 方块搬运器注册表 的 MovingTileRegistry / ITileMover。两者在 运动模型 的 tryStartMove 处汇合:MCFrames 先用 getStructure 收集坐标,再交给 Relocator.execute() → tryStartMove 切分成行。

相关条目