运动模型:结构与行的分解

本 mod 的核心。一个"移动"不是逐格 setBlock,而是先把要动的方块集合切分成若干条平行的行(BlockRow),再把每行压成一个结构(BlockStruct),由结构统一推进进度、统一同步。

代码位置:src/main/scala/mrtjp/relocation/movement.scala(569 行,全 mod 最长的文件)。

三个层次的数据结构

类 职责 关键字段
BlockStruct 一次完整的移动(可能是几十上百格) id、speed、progress、rows: Array[BlockRow]、callback: WeakReference[IMovementCallback]
BlockRow 结构里一条平行的直线 pos: BlockCoord(行首)、moveDir: Int、size: Int
TileMovingRow 每行最前端那格的占位方块实体 prevProg

两个懒加载的坐标集合(lazy val,首次访问时展开):

lazy val allBlocks: Set[BlockCoord] = rows.iterator.flatMap(_.allBlocks).toSet
lazy val preMoveBlocks: Set[BlockCoord] = rows.iterator.flatMap(_.preMoveBlocks).toSet
lazy val postMoveBlocks: Set[BlockCoord] = rows.iterator.flatMap(_.postMoveBlocks).toSet

BlockRow 内部则是:

val allBlocks      = 0 to size map { i => pos.copy.offset(moveDir ^ 1, i) }  // size + 1 格
val preMoveBlocks  = allBlocks drop 1        // 真正被搬走的格
val postMoveBlocks = allBlocks dropRight 1   // 需要 postMove 通知的格

一行占 size + 1 格。最前面那格放 移动占位方块,它自己不搬东西,只负责渲染动画、碰撞箱和推实体;后面 size 格才是真正被推到前面的方块。

tryStartMove:从乱序坐标到有序行

入口是 MovementManager2.tryStartMove(w, blocks, moveDir, speed, c): Boolean,movement.scala:121-164。

第 1 步:上限检查

if (blocks.size > RelocationConfig.moveLimit) return false

moveLimit 默认 2048,见 配置文件。超出直接失败、什么都不做。

第 2 步:按"法线"分桶

val map = new MHashMap[(Int, Int), MSet[Int]] with MMultiMap[(Int, Int), Int]
for (b <- blocks)
  map.addBinding(MathLib.normal(b, moveDir), MathLib.basis(b, moveDir))

MathLib 来自 MrTJPCore(不在本仓库)。normal / basis 是沿 moveDir 建立的局部坐标系:把方块坐标投影到垂直于移动方向的平面坐标上。map 是 MultiMap——(normal, basis) -> Set[Int],即同一列上的所有 basis 位置。

第 3 步:定向排序

val shift = if ((moveDir & 1) == 1) 1 else -1

moveDir 是 ForgeDirection 索引 0~5。& 1 == 1 为真的是下(1)、北(4)——即负 Z / 负 Y 两个方向,其余为正。这些方向的"前方"在 basis 轴上是递减的,所以 shift 用来决定排序方向。

for (normal <- map.keys) {
  val line  = map(normal).toArray
  val sline = if (shift == 1) line.sorted else line.sorted(Ordering[Int].reverse)
  for ((basis, size) <- MathLib.splitLine(sline, shift)) {
    val c = MathLib.rhrAxis(moveDir, normal, basis + shift)
    rowB += new BlockRow(c, moveDir, size)
  }
}
  • sline:按移动方向从前往后排序(先动的排前面)
  • MathLib.splitLine(sline, shift):把排序后的连续 basis 序列切成若干极大连续段,每段产出 (basis, size)
  • MathLib.rhrAxis(moveDir, normal, basis + shift):把局部 (normal, basis) 换算回世界坐标 BlockCoord,即行的首格

于是乱序的 Set[BlockCoord] 被规范化成互不重叠、方向一致的行数组。

第 4 步:通路检查

val rows = rowB.result()
if (rows.exists(row => !MovingTileRegistry.canRunOverBlock(w, row.pos.x, row.pos.y, row.pos.z)))
  return false

只检查每行的首格(pos,也就是占位方块要放的地方)能否通过:

def canRunOverBlock(w: World, x: Int, y: Int, z: Int) = {
  if (w.blockExists(x, y, z))
    w.isAirBlock(x, y, z) || WorldLib.isBlockSoft(w, x, y, z, w.getBlock(x, y, z))
  else false
}

即"目标格必须是空气或软方块(可被替换的)"。注意这是全有或全无——任何一行不通,整次移动直接 return false,不留半成品。

第 5 步:放置占位方块、建结构、同步

for (r <- rows) TileMovingRow.setBlockForRow(w, r)

val struct = new BlockStruct
struct.id = BlockStruct.claimID()
struct.speed = speed
struct.rows = rows
struct.callback = WeakReference(c)
addStructToWorld(w, struct)
sendStruct(w, struct)
true

addStructToWorld 内部调 b.onAdded(w),仅服务端触发回调:

def onAdded(w: World) {
  if (!w.isRemote) callback match {
    case WeakReference(c) => c.setDescriptor(new MoveDesc(this)); c.onMovementStarted()
    case _ =>
  }
}

顺序很关键:占位方块先放好,结构才进世界,描述符包才发出。

结构 ID 分配

object BlockStruct {
  private var maxID = 0
  def claimID() = {
    if (maxID < 32765) maxID += 1  // little less than Short.MaxValue (reserved for terminator)
    else maxID = 0
    maxID
  }
}

ID 上限 32765(源码注释:略小于 Short.MaxValue,后者留给终止符)。到达上限后直接回绕到 0,不做冲突检测——WorldStructs.addStruct 会按 id 相同则拒绝来兜底,但回绕时若 0 号结构仍在移动中,新的 0 号结构会被静默丢弃。

BlockStruct 的 equals / hashCode 只比 id(hashCode() = id),所以结构天然以 ID 为身份。

每 tick 的推进

def push() { progress = math.min(1.0, progress + speed) }
def isFinished = progress >= 1.0d

BlockStruct.speed 默认 1 / 16d。客户端插值用:

def renderPos(s: BlockStruct, partial: Float) =
  new Vector3(BlockCoord.sideOffsets(s.moveDir)).multiply(s.progress + s.speed * partial)

即 progress + speed * partial 做子 tick 平滑。

驱动入口

服务端:TickEvent.ServerTickEvent(END 相)→ MovementManager2.onTick(false) 客户端:TickEvent.ClientTickEvent(END 相)→ MovementManager2.onTick(true)

def onTick(isClient: Boolean) {
  val map = relocationMap(isClient)
  map.foreach { (dim, ws) =>
    if (ws.nonEmpty) {
      ws.pushAll()
      val world = getWorld(dim, isClient)
      if (world != null)
        for (bs <- ws.structs) for (br <- bs.rows) br.pushEntities(world, bs.progress)
    }
  }

  if (!isClient) map.foreach { (dim, ws) =>
    val finished = ws.removeFinished()
    if (finished.nonEmpty) {
      val w = getWorld(dim, false)
      if (w != null) for (s <- finished) { cycleMove(w, s); sendCycle(w, s) }
    }
  }
}

只有服务端执行 cycleMove(真正改方块)并广播完成包;客户端只推进进度与推实体。客户端的 cycleMove 由收到的 key=2 包触发(clientCycleMove),见 网络协议。

完成时的收尾序列

def cycleMove(w: World, struct: BlockStruct) {
  struct.doMove(w)
  struct.postMove(w)
  struct.endMove(w)

  Utils.rescheduleTicks(w, struct.preMoveBlocks, struct.allBlocks, struct.moveDir)

  val changes = MSet[BlockCoord]()
  for (r <- struct.rows) r.cacheChanges(w, changes)
  for (bc <- changes) w.notifyBlockOfNeighborChange(bc.x, bc.y, bc.z, Blocks.air)

  Utils.rerenderBlocks(w, struct.preMoveBlocks)
}

顺序不可换:先搬方块,再通知方块实体,再回调,再修 scheduled tick,最后补邻居通知与重绘。

1. doMove —— 逐行搬移

def doMove(w: World) {
  if (pos.y < 0 || pos.y >= 256) return
  w.removeTileEntity(pos.x, pos.y, pos.z)
  WorldLib.uncheckedSetBlock(w, pos.x, pos.y, pos.z, Blocks.air, 0)  // Remove movement block
  for (b <- preMoveBlocks) MovingTileRegistry.move(w, b.x, b.y, b.z, moveDir)
}

y < 0 || y >= 256 直接跳过整行(超出世界高度)。uncheckedSetBlock 是 MrTJPCore 的直写(跳过光照/更新计算),因为紧接着的 notifyBlockOfNeighborChange 会统一补上。

MovingTileRegistry.move 会查表决定这个方块用哪个搬运器——见 方块搬运器注册表。

2. postMove —— 通知落地

def postMove(w: World) { for (b <- postMoveBlocks) MovingTileRegistry.postMove(w, b.x, b.y, b.z) }

postMoveBlocks = allBlocks dropRight 1,即不含行首那一格(那格本来就是占位方块,已经被清成空气)。用途是让方块实体重新确认自己的新位置(重新绑定区块、重算缓存等)。

3. endMove —— 回调

def endMove(w: World) {
  for (r <- rows) r.endMove(w)
  if (!w.isRemote) callback match {
    case WeakReference(c) => c.onMovementFinished()
    case _                =>
  }
}

BlockRow.endMove 是空方法,仅为对称而存在。

4. Utils.rescheduleTicks —— 修 scheduled tick

这是最容易被忽略、但对红石/随机刻机器最关键的一步。WorldServer 的三个私有字段被反射取出:

反射字段名 SRG 名 类型
pendingTickListEntriesHashSet field_73064_N JSet[NextTickListEntry]
pendingTickListEntriesTreeSet field_73065_O JTreeSet[NextTickListEntry]
pendingTickListEntriesThisTick field_94579_S JAList[NextTickListEntry]
val isOptifine = world.getClass.getName == "WorldServerOF"
...
world.getPendingBlockUpdates(ch, !isOptifine) match {
  case null  => HashSet[NextTickListEntry]()
  case tList => tList.toSet
}

⚠️ OptiFine 特判:WorldServerOF 的签名不同(第二个参数含义相反),所以传 !isOptifine。装有 OptiFine 时还会先把 tick 从三个容器里全部移除再重建(tree.remove / hash.remove / list.remove),普通 Forge 则直接改坐标。

偏移逻辑只对真正被搬走的格子生效:

for (tick <- scheduledTicks) {
  val bc = new BlockCoord(tick.xCoord, tick.yCoord, tick.zCoord)
  if (blocks(bc)) { bc.offset(dir); tick.xCoord = bc.x; tick.yCoord = bc.y; tick.zCoord = bc.z }
}
for (tick <- scheduledTicks) if (!hash.contains(tick)) { hash.add(tick); tree.add(tick) }

第二个 blocks 参数是 allBlocks(用于确定要扫哪些区块),if (blocks(bc)) 判的才是 preMoveBlocks。没有这一步,被推动的机器会停在旧坐标上等待 tick,永远不触发。

5. 邻居更新范围

def cacheChanges(w: World, changes: MSet[BlockCoord]) {
  for (i <- 0 to size) {
    val c = pos.copy.offset(moveDir ^ 1, i)
    changes += c
    for (s <- 0 until 6; s1 <- 0 until 6 if s1 != (s ^ 1))
      changes += c.copy.offset(s).offset(s1)
  }
}

每格产生 1 + 6 × 5 = 31 个坐标(排除与 s 相对的那一面 s ^ 1)。changes 是个 mutable.Set——大量格子时坐标会互相覆盖,实际写入远少于 31 × 总格数。

世界索引:维度 → 结构集合

val serverRelocations = new WorldStructsMap
val clientRelocations = new WorldStructsMap
def relocationMap(isClient: Boolean) = if (isClient) clientRelocations else serverRelocations
def getWorldStructs(w: World) = relocationMap(w.isRemote).getOrCreate(w.provider.dimensionId)

服务端与客户端各持一份完全独立的结构表,靠 网络协议 保持一致。WorldStructsMap 是手写的动态扩容数组(初始容量 4,翻倍),getOrCreate 整个方法体包在 synchronized 里;foreach 先在锁内把 (dims, structs) 拷成定长快照(Arrays.copyOf)再在锁外遍历,避免持锁调用回调。

WorldStructs 同样是手写数组 + 全部方法 synchronized。它实现的关键操作:

  • contains(x,y,z) —— 线性扫所有结构的 BlockStruct.contains → 逐行 BlockRow.contains。这是每 tick 每方块渲染都要走的路径,复杂度 O(结构数 × 行数)
  • addStruct(b) —— id 相同则静默拒绝(去重)
  • removeFinished() —— 一次遍历同时完成"收集完成的"与"原地压缩保留未完成的",尾部填 null 防泄漏
  • removeStruct(s) —— 按引用移除(不是按 id)
  • structs —— 返回 Arrays.copyOf(structsArr, numStructs) 的防御性拷贝

BlockRow.contains 用局部坐标做区间判定:

def contains(x: Int, y: Int, z: Int) = {
  if (normal(x, y, z, moveDir) == normal(pos, moveDir)) {
    val b1 = basis(pos, moveDir)
    val b2 = b1 + size * shift(moveDir ^ 1)
    val b  = basis(x, y, z, moveDir)
    b >= math.min(b1, b2) && b <= math.max(b1, b2)
  } else false
}

shift(moveDir ^ 1) 决定了 basis 随 moveDir 正反而增还是减,故用 min/max 归一。

世界卸载

def onWorldUnload(w: World) { getWorldStructs(w).clear() }

由 WorldEvent.Unload 触发,清空该维度的所有结构。⚠️ 只 clear() 数组、不 removeStruct 之外的处理——正在移动的结构被静默丢弃,占位方块则依靠 移动占位方块 自身的 update() 自愈成空气。

相关条目