活塞系统全剖析#
活塞是 Minecraft 中最复杂的方块之一:它不仅涉及两阶段的方块状态切换,还包括推动计算、粘液块粘连、实体推动、方块事件动画。本文从零解析活塞从"接收红石信号"到"推动完成"的完整路径。
一、组件总览#
活塞系统由 4 个方块类 + 1 个 BE 类 + 2 个数据类 + 1 个算法类 组成:
| 类 | 角色 | 关键属性 |
|---|---|---|
PistonBlock |
活塞本体(普通+粘性) | FACING、EXTENDED,sticky 字段 |
PistonHeadBlock |
活塞头(伸出前端) | FACING、TYPE(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(前方有另一个活塞在推)
为什么用方块事件而不是直接动?
- 方块事件可以让服务端和客户端同步播放动画
- 活塞推动涉及替换多个方块为 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 负责:
- 存储被推动的原方块状态,渲染时绘制"飞过去"的效果
- 驱动 progress 进度从 0.0 → 1.0,每 tick +0.5(刚好 2 tick 移动完)
- 计算移动中的碰撞箱,让实体能站在"半路上的方块"上
- 每 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();防止刷物品时直接销毁容器内容 |
以上就是活塞系统从红石信号到最终推动的完整机制。