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 的三级回退(顺序即优先级):
- 该格的方块实现了
IFrame(框架方块 走这条) - 该格的方块实体实现了
IFrame(马达方块 走这条) - 遍历
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 切分成行。