活塞系统全剖析#

活塞是 Minecraft 中最复杂的方块之一:它不仅涉及两阶段的方块状态切换,还包括推动计算、粘液块粘连、实体推动、方块事件动画。本文从零解析活塞从"接收红石信号"到"推动完成"的完整路径。

一、组件总览#

活塞系统由 4 个方块类 + 1 个 BE 类 + 2 个数据类 + 1 个算法类 组成:

角色 关键属性
PistonBlock 活塞本体(普通+粘性) FACINGEXTENDEDsticky 字段
PistonHeadBlock 活塞头(伸出前端) FACINGTYPE(NORMAL/STICKY)、SHORT
PistonExtensionBlock MOVING_PISTON 占位方块 不可见,BE 驱动动画
PistonBehavior 推动反应枚举 NORMAL / DESTROY / BLOCK / PUSH_ONLY
PistonType 活塞类型枚举 NORMAL / STICKY
PistonHandler 推动计算算法 最多 12 方块 + 粘液块链
PistonBlockEntity 移动中的方块 BE 进度 progress、推动实体、碰撞箱移动

二、红石信号触发#

信号检测:shouldExtend()#

活塞不直接在 neighborUpdate 里算推动,而是先做一次红石信号检测

private boolean shouldExtend(RedstoneView world, BlockPos pos, Direction pistonFace) {
    // 检测方向1:除了活塞朝向那一面,其余5个面的红石信号
    for (Direction d : Direction.values()) {
        if (d != pistonFace && world.isEmittingRedstonePower(pos.offset(d), d)) {
            return true;
        }
    }
    // 检测方向2:活塞下方发来的信号(BUD位准)
    if (world.isEmittingRedstonePower(pos, DOWN)) return true;
    // 检测方向3:活塞上方方块周围的信号(模拟"BUD更新"检测)
    BlockPos above = pos.up();
    for (Direction d : Direction.values()) {
        if (d != DOWN && world.isEmittingRedstonePower(above.offset(d), d)) {
            return true;
        }
    }
    return false;
}

这解释了活塞的"BUD"特性:活塞对"周围位置"的检测比普通红石宽一格。

从 neighborUpdate 到方块事件#

邻居更新触发 tryMove()

  • 需要伸出 → 调 PistonHandler.calculatePush(),成功则 addSyncedBlockEvent(type=0, data=dirIndex)
  • 需要收回 → 发方块事件 type=1(普通)或 type=2(前方有另一个活塞在推)

为什么用方块事件而不是直接动?

  1. 方块事件可以让服务端和客户端同步播放动画
  2. 活塞推动涉及替换多个方块为 MOVING_PISTON,如果直接在 blockTick 阶段执行,会导致同 tick 后续其他方块看到的世界不一致

三、推动计算算法 — PistonHandler#

可移动性判断:isMovable()#

public static boolean isMovable(BlockState state, World world, BlockPos pos,
                                 Direction dir, boolean canBreak, Direction pistonDir) {
    // 绝对不可动:黑曜石、哭泣黑曜石、重生锚、强化深板岩
    if (state.isOf(OBSIDIAN) || state.isOf(CRYING_OBSIDIAN)
        || state.isOf(RESPAWN_ANCHOR) || state.isOf(REINFORCED_DEEPSLATE))
        return false;
    // 越界
    if (pos.getY() < bottomY || pos.getY() > topYInclusive) return false;
    // 基岩(硬度 -1)
    if (state.getHardness(world, pos) == -1.0F) return false;

    // 按 PistonBehavior 分支
    switch (state.getPistonBehavior()) {
        case BLOCK:     return false;     // 阻挡
        case DESTROY:   return canBreak;  // 破坏型(仙人掌),允许破坏才通过
        case PUSH_ONLY: return dir == pistonDir;  // 只能推,不能拉
        default: break;
    }
    // 活塞本身伸出时不可动
    if (state.isOf(PISTON) || state.isOf(STICKY_PISTON)) {
        if (state.get(EXTENDED)) return false;
    }
    // 带 BlockEntity 的方块(除了 MOVING_PISTON)不可动 → 防止数据丢失
    return !state.hasBlockEntity();
}

核心算法:tryMove(BlockPos, Direction)#

这是活塞最精妙的部分。用伪代码表示:

tryMove(起始位置 pos, 追溯方向 dir):

  # 第一步:向「运动的反方向」追溯粘性方块链
  # 比如运动方向=东,就往西追溯一串粘液块,把它们全部加入 movedBlocks
  i = 1
  while 前方第 i 格是粘液块/蜂蜜块:
    检查相邻的上一个方块是否还粘得住(粘液块+蜂蜜块之间不粘!)
    如果粘不住 → break
    i++
    超过 12 → return false

  # 把追溯到的这串加入列表
  for k = i-1 到 0:
    movedBlocks.add(pos 往西 k 格)

  # 第二步:向「运动方向」依次检查前方方块
  l = 1
  loop:
    pos2 = pos 往东 l 格
    # 如果前面的已经在列表里(环形粘液块)→ 重排顺序并成功
    if (pos2 in movedBlocks):
      setMovedBlocks(末尾 j 个, 插入位置 m)
      return true
    # 空气 → 成功
    if (air(pos2)) return true
    # 不可动 / 是活塞本体 → 失败
    if (!movable(pos2)) return false
    # DESTROY 行为(如仙人掌)→ 加入 brokenBlocks 并停止
    if (behavior == DESTROY): brokenBlocks.add(pos2); return true
    # 超 12 → 失败
    if (movedBlocks.size >= 12) return false
    # 加入列表,继续
    movedBlocks.add(pos2)
    l++

粘液块粘连规则:isAdjacentBlockStuck()#

private static boolean isAdjacentBlockStuck(BlockState a, BlockState b) {
    // 特殊规则:蜂蜜块 + 粘液块 不粘
    if (a.isOf(HONEY_BLOCK) && b.isOf(SLIME_BLOCK)) return false;
    if (b.isOf(HONEY_BLOCK) && a.isOf(SLIME_BLOCK)) return false;
    // 其余:只要任一方是粘液块/蜂蜜块就粘
    return isBlockSticky(a) || isBlockSticky(b);
}

经典社区题:蜂蜜块 + 粘液块相邻,活塞推动会怎样? 答案:不粘连。两个方块视为独立。这是 1.15 以来的设计决策。

横向粘连:tryMoveAdjacentBlock()#

如果被推动的方块是粘液块/蜂蜜块,还会检查垂直于运动方向的相邻方块是否也能粘住,并递归尝试推动那一行:

  运动方向 → 东
  当前推动 ▓ 粘液块
  还要检查 ↓ 北 / ↑ 南 / ⊕ 下 / ⊖ 上 四个方向的邻居
  如果邻居也是粘性方块 → 尝试把那一列也推了

这就是大型粘液块机器能把一整排方块带走的原因。

四、执行推动:PistonBlock.move()#

算法通过后,onSyncedBlockEvent() 进入真正的执行阶段,顺序极其讲究:

阶段一:收集和保存#

List<BlockPos> moved = pistonHandler.getMovedBlocks();   // 被推动的方块
List<BlockPos> broken = pistonHandler.getBrokenBlocks(); // 被破坏的方块
Map<BlockPos, BlockState> map = 位置  原状态;  // 用于失败时回滚

阶段二:处理被破坏的方块(仙人掌等)#

for 倒序遍历 broken:
  1. dropStacks() 掉落物品
  2. 客户端播放 WORLD_EVENTS.BLOCK_BROKEN 粒子
  3. setBlockState(AIR, NOTIFY_LISTENERS | FORCE)
  4. 发送 GameEvent.BLOCK_DESTROY

阶段三:将被推动的方块替换为 MOVING_PISTON + BE#

for 倒序遍历 moved(从最远到最近,避免先覆盖后面的原位置):
  newPos = pos + motionDir
  world.setBlockState(newPos, MOVING_PISTON,
                       SKIP_REDRAW_AND_BLOCK_ENTITY_REPLACED_CALLBACK | MOVED)
  world.addBlockEntity(
      PistonExtensionBlock.createBlockEntityPiston(
          newPos,                  // 位置(要移动到的目标格子)
          MOVING_PISTON state,
          原方块状态 pushedBlock,  // 被推动的是什么方块
          dir,                     // 活塞朝向
          extend,                  // 是否伸出
          false))                  // source=false(非活塞头本体)

关键细节:从远到近地处理。因为如果先处理近的方块,它覆盖的 MOVING_PISTON 位置可能正好是后面某个方块的原位置(已经不在列表里 map 中了)。

阶段四:伸出时创建活塞头#

if (extend) {
    BlockState headState = PISTON_HEAD
        .defaultState.with(FACING, dir).with(TYPE, sticky ? STICKY : DEFAULT);
    BlockState movingHead = MOVING_PISTON
        .defaultState.with(FACING, dir).with(TYPE, ...)
    map.remove(blockPos);  // 活塞位置也要换成 MOVING_PISTON
    world.setBlockState(pos, movingHead, SKIP_REDRAW... | MOVED);
    world.addBlockEntity(createBlockEntityPiston(
        pos, movingHead, headState,
        dir,
        true,   // 正在伸出
        true)); // source=true(这是活塞头本体)
}

阶段五:清理原来的位置(设为 AIR)#

map 里剩下的就是"原位置,还没被处理过的"方块,全部设为 AIR。

阶段六:通知邻居 & 红石更新#

完成方块替换后,最后统一调用 updateNeighborsAlways 通知周围方块。把通知放在最后是性能优化——避免在替换的中间过程中触发邻居再次调度,造成二次处理。

五、动画驱动:PistonBlockEntity#

MOVING_PISTON 方块本身是 BlockRenderType.INVISIBLE(不可见),由 BlockEntity 负责:

  1. 存储被推动的原方块状态,渲染时绘制"飞过去"的效果
  2. 驱动 progress 进度从 0.0 → 1.0,每 tick +0.5(刚好 2 tick 移动完)
  3. 计算移动中的碰撞箱,让实体能站在"半路上的方块"上
  4. 每 tick 推动路径上的实体

进度插值与渲染偏移#

// amountExtended:沿朝向方向的偏移距离
// extend=true 时 progress 从 0→1,偏移从 -1 → 0(从活塞里伸出来)
// extend=false 时 progress 从 0→1,偏移从 0 → -1(收回到活塞里)
private float getAmountExtended(float progress) {
    return extending ? progress - 1.0F : 1.0F - progress;
}

// 渲染偏移 = 方向轴 * 伸出量 * 进度(带 1gt 插值用于流畅动画)
offsetX = facing.getOffsetX() * getAmountExtended(lerp(tickDelta, lastProgress, progress))

推动实体:pushEntities()#

活塞动画每一帧都会调用 pushEntities,把挡住的实体"推出去":

// 把碰撞箱沿运动方向拉伸 d(本 tick 进度差),找出所有相交的实体
List<Entity> list = world.getOtherEntities(
    null, Boxes.stretch(box, direction, d).union(box));

for each entity:
  跳过 PistonBehavior.IGNORE物品展示框画等
  if 粘液块 && !玩家:
    给实体一个沿活塞方向的速度于是粘液块"甩"起玩家
  for each 碰撞箱子块:
    计算 box3  entity.boundingBox 的相交深度
    得到推动距离 i
  moveEntity(direction, entity, min(i, d) + 0.01, direction)
  // 0.01 是魔法余量防止浮点误差把实体卡墙里

收回时实体"粘住":push()#

收回时,站在活塞头上的实体也会被拉回一格(经典的"活塞拉回 + 史莱姆飞行" trick):

if (!extending && source) {
    push(pos, entity, direction, d);
}

六、粘性活塞收回的额外流程#

收回比伸出多一步:拉回前方 1 格的方块

if (sticky) {
    BlockPos twoBlocksAway = pos.add(dir * 2);
    BlockState s = world.getBlockState(twoBlocksAway);
    // 特殊:前方 2 格有另一个正在伸出的活塞 → 让它先 finish
    if (s.isOf(MOVING_PISTON) && ...isExtending()) {
        ((PistonBlockEntity)be).finish(); // 立即完成,时序对齐
    } else {
        // 决定拉不拉
        if (type==1 && (s.air || !isMovable(s, pos, opposite) ||
            s.behavior != NORMAL && 不是活塞)) {
            // 只删除活塞头,不拉回方块
            world.removeBlock(pos.offset(dir), false);
        } else {
            // 调用 move(extend=false) → 执行完整拉回流程
            move(world, pos, dir, false);
        }
    }
} else {
    // 普通活塞:直接移除活塞头,什么也不拉
    world.removeBlock(pos.offset(dir), false);
}

七、完成动画:progress ≥ 1.0#

动画结束后,BE 把 MOVING_PISTON 替换为最终的方块状态

BE.tick() 检测到 progress >= 1.0:
  if source:          // 活塞头本体 → 消失(收回)或被 PistonBlock 的 EXTENDED=true 取代
    setBlockState(AIR)
  else:               // 被推动的方块 → 放到目标位置
    setBlockState(postProcessState(pushedBlock), NOTIFY_ALL | MOVED)
    // postProcessState 会重置 WATERLOGGED=false(移动后不保留含水)
  最后移除 BlockEntity

八、12 方块上限的来源#

MAX_MOVABLE_BLOCKS = 12 硬编码在 PistonHandler.tryMove 的两个地方:

  • 追溯粘性方块链时 if (++i + moved.size() > 12)
  • 向前方添加被推方块时 if (moved.size >= 12)

12 是 Mojang 为防止"巨型粘液块机械卡住整服"设置的阈值。

九、社区常见技巧的源码解释#

技巧 原理
0-tick 甘蔗/仙人掌农场 活塞收回 → 下一个方块不能放置在 AIR 上而掉落;但 PistonBehavior.DESTROY 会先 dropStacks 再 set AIR,所以速度快
粘液块"零延迟"运输 pushEntities 中粘液块给实体一个 facing 方向速度,所以实体在下一 tick 带着速度移动
活塞 BUD 刷沙 shouldExtend() 检测"上方方块的周围",利用这个 1 格窗口可以构造 BUD 脉冲
蜂蜜块减速实体 moveEntitiesInHoneyBlock 在水平推动时把站在上面的实体一起带走,配合 blockSpeedFactor 实现减速
无法推动带 BlockEntity 的方块 isMovable 最后一行 return !state.hasBlockEntity();防止刷物品时直接销毁容器内容

以上就是活塞系统从红石信号到最终推动的完整机制。