运动模型:结构与行的分解
本 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() 自愈成空气。