BFS 方块扩展算法
WandWorker 是本 mod 的核心。魔杖右键方块后,它用广度优先搜索(BFS)
从被点中的方块沿点击面展开,算出一批合法坐标,再逐个放置。
源码位置:core/WandWorker.java(GTNH fork,BetterBuildersWandsMod 同源)
调用链
ItemBasicWand.onItemUse(...)
├─ worker.getProperItemStack(...) ① 决定"要放什么材料"
├─ ItemBasicWand.getCustomMapping(...) ② 查自定义映射(可能来自副手)
├─ numBlocks = min(wand.getMaxBlocks, playerShim.countItems(...)) ③ 算本次上限
├─ worker.getBlockPositionList(...) ④ ★ BFS 搜索(只算坐标,不改世界)
└─ worker.placeBlocks(...) ⑤ 真正放置 + 扣耐久 + 写 NBT
客户端在 BlockEvents.blockHighlightEvent 里调用完全相同的 ①~④ 步,
只是不执行 ⑤,而是把结果画成白色线框预览:
| 阶段 | 类 |
|---|---|
| ①③④ BFS 核心 | WandWorker.getBlockPositionList |
| 判定过滤器 | WandWorker.shouldContinue |
| ⑤ 放置 | WandWorker.placeBlocks |
| 客户端预览 | core/BlockEvents.java |
① 采样目标方块
Block targetBlock = world.getBlock(blockLookedAt);
int targetMetadata = world.getMetadata(blockLookedAt);
TileEntity targetTile = world.getTile(blockLookedAt);
Point3d startingPoint = blockLookedAt.move(placeDirection);
targetBlock / targetMetadata / targetTile 是整个搜索的基准:
后续每一个候选位置都必须与它们保持一致,否则拒绝。
② 种子判定:面与模式必须同向
搜索队列的第一个候选点是 startingPoint,但它不一定入队:
if (((directionLock != HORIZONTAL && directionLock != VERTICAL)
|| (placeDirection != UP && placeDirection != DOWN))
&& (directionLock != NORTHSOUTH || (placeDirection != NORTH && placeDirection != SOUTH))
&& (directionLock != EASTWEST || (placeDirection != EAST && placeDirection != WEST))) {
candidates.add(startingPoint);
}
三个条件全部成立才入队,含义是"模式的锁定平面与点击面不冲突"。 等价于:面锁定平面 ⊥ 点击面时,直接一个方块都不放。
| 模式 | 点顶面/底面(铺水平层) | 点北/南面(铺南北墙) | 点东/西面(铺东西墙) |
|---|---|---|---|
HORIZONTAL |
❌ 拒绝 | ✅ | ✅ |
VERTICAL |
❌ 拒绝 | ✅ | ✅ |
NORTHSOUTH |
✅ | ❌ 拒绝 | ✅ |
EASTWEST |
✅ | ✅ | ❌ 拒绝 |
VERTICALNORTHSOUTH |
✅ | ✅ | ✅ |
VERTICALEASTWEST |
✅ | ✅ | ✅ |
NOLOCK |
✅ | ✅ | ✅ |
这与 en_US.lang 的描述完全对应:
bbw.chat.mode.horizontal= “Mode: Horizontal - Extend left and right from the original block. No building on top/bottom face.”bbw.chat.mode.northsouth= “…No building on north/south face.”
⚠️ Stone Wand 在这个判定上被锁死:它的
getFaceLock()恒为HORIZONTAL, 所以你永远点不到顶面/底面,只能盖平面。详见 魔杖等级系统 的面锁定一节。
③ BFS 主循环
while (!candidates.isEmpty() && toPlace.size() < maxBlocks) {
Point3d currentCandidate = candidates.removeFirst(); // LinkedList → 真 FIFO
...
}
用 LinkedList.removeFirst() 先进先出,因此是严格逐层的广度优先搜索:
第 1 圈先全部放完,再放第 2 圈。这保证消耗材料的顺序是"由近及远",
中途材料耗尽时缺口落在最外圈。
去重靠一个独立的成员字段:
HashSet<Point3d> allCandidates = new HashSet<>();
...
if (shouldContinue(...) && allCandidates.add(currentCandidate)) {
shouldContinue 通过且 allCandidates.add() 返回 true(首次见到该点)
才进入结果集,所以同一个坐标可能被多次访问但只计数一次。
④ 双掩码邻接扩展
搜索在点击面的平面内展开,由两个掩码共同控制:
| 掩码 | 来源 | 作用 |
|---|---|---|
directionMaskInt = directionLock.mask |
玩家切换的方向模式 | 选哪些面内方向开放 |
faceMaskInt = faceLock.mask |
物品类固定的面锁定 | 校验点击面的轴是否被允许 |
EnumLock 的 3 个掩码位:
| 常量 | 值 | 含义 |
|---|---|---|
NORTH_SOUTH_MASK |
1 | 南北(Z 轴) |
UP_DOWN_MASK |
2 | 上下(Y 轴) |
EAST_WEST_MASK |
4 | 东西(X 轴) |
两种平面下的邻接规则(WandWorker.getBlockPositionList):
点顶面/底面 → 铺一层水平面
if ((faceMaskInt & UP_DOWN_MASK) > 0) {
if (directionMaskInt & NORTH_SOUTH_MASK) → NORTH
if (directionMaskInt & EAST_WEST_MASK) → EAST
if (directionMaskInt & NORTH_SOUTH_MASK) → SOUTH
if (directionMaskInt & EAST_WEST_MASK) → WEST
if (南北 && 东西) → 4 个对角线
}
面内轴 = 南北/东西。HORIZONTAL(mask 5 = 1|4) 开放全部 4 个方向 + 4 个对角线 = 3×3 一整层。
点北/南面 或 东/西面 → 铺一堵墙
// 点 NORTH/SOUTH
if ((faceMaskInt & NORTH_SOUTH_MASK) > 0) {
if (directionMaskInt & UP_DOWN_MASK) → UP
if (directionMaskInt & EAST_WEST_MASK) → EAST
if (directionMaskInt & UP_DOWN_MASK) → DOWN
if (directionMaskInt & EAST_WEST_MASK) → WEST
if (上下 && 东西) → 4 个对角线
}
// 点 EAST/WEST:同理,面内轴换成 上下/南北
| 模式 | mask | 二进制 | 面内可扩展方向 | 形态 |
|---|---|---|---|---|
NORTHSOUTH |
1 | 001 | 仅南北 | 一条线 |
VERTICAL |
2 | 010 | 仅上下 | 一条线 |
VERTICALEASTWEST |
3 | 011 | 上下 + 东西 | 竖墙(含东西) |
EASTWEST |
4 | 100 | 仅东西 | 一条线 |
HORIZONTAL |
5 | 101 | 南北 + 东西 | 3×3 整层 / 十字墙 |
VERTICALNORTHSOUTH |
6 | 110 | 南北 + 上下 | 竖墙(含南北) |
NOLOCK |
7 | 111 | 上下 + 南北 + 东西 | 3×3 整层 / 十字墙 |
⑤ shouldContinue —— 九道过滤
每取出一个候选点,按顺序执行以下判定,任何一条失败就丢弃该点且不再向它扩展:
| # | 判定 | 源码 | 目的 |
|---|---|---|---|
| 1 | 候选位置必须是空气 | !world.blockIsAir(c) |
见下方例外 |
| 2 | y < 255 |
currentCandidate.y >= 255 → return false |
建筑高度上限 |
| 3 | 支撑方块 == 目标方块 | !targetBlock.equals(candidateSupportingBlock) |
只沿同种方块的面延伸 |
| 4 | 支撑方块 metadata == 目标 metadata | targetMetadata != candidateSupportingMeta |
区分不同朝向/变种 |
| 5 | canPlaceBlockAt |
targetBlock.canPlaceBlockAt(...) |
该方块能否在此放置 |
| 6 | 副手方块 canPlaceBlockAt |
副手集成时才检查 | 见 Backhand 集成 |
| 7 | canBlockStay |
targetBlock.canBlockStay(...) |
失去支撑会不会掉落 |
| 8 | canReplace |
targetBlock.canReplace(..., new ItemStack(candidateSupportingBlock, 1, candidateSupportingMeta)) |
能否替换掉当前位置 |
| 9 | NBT 敏感比对 | 见下 | 只克隆完全相同的 TileEntity 状态 |
| 10 | 碰撞盒内无生物 | return !world.entitiesInBox(blockBB) |
不把生物闷死在方块里 |
过滤 1 的流体例外
if (!world.blockIsAir(currentCandidate)) {
Block b = world.getBlock(currentCandidate);
if (!(fluidLock == EnumFluidLock.IGNORE
&& (b instanceof IFluidBlock || b instanceof BlockLiquid)))
return false;
}
fluidLock = STOPAT(默认):非空气一律拒绝,流体也会终止扩展fluidLock = IGNORE:IFluidBlock(Forge 流体,含 mod 流体)和BlockLiquid(原版水/岩浆)可以被覆盖并继续扩展;其他非空气方块仍然拒绝
过滤 9:NBT 敏感模式
仅当 isNBTSensitive == true(由 ICustomMapping.shouldCopyTileNBT() 决定)时启用:
if (targetTile == null || candidateSupportingTile == null) return false;
targetTile.writeToNBT(targetNBT);
candidateSupportingTile.writeToNBT(candidateSupportingNBT);
// 抹掉坐标,否则永远不相等
targetNBT.removeTag("x"); targetNBT.removeTag("y"); targetNBT.removeTag("z");
candidateSupportingNBT.removeTag("x"); …;
if (!targetNBT.equals(candidateSupportingNBT)) return false;
序列化后删掉 x/y/z 再比对,所以只有携带完全相同 TileEntity 数据的 方块才会被批量复制 —— 例如 Forestry 的原木(树种/年龄藏在 TileEntity 里)。
| 映射来源 | shouldCopyTileNBT |
|---|---|
配置文件 forced_blocks 解析出的 CustomMapping |
false |
ArchitectureCraftCustomMapping |
true |
ForestryMCCustomMapping |
true |
| Backhand 副手临时构造的映射 | false |
⑥ 放置阶段
needItem.stackSize = blockPoss.length * amountToConsume; // amountToConsume = max(1, sourceItems.stackSize)
int takeFromInventory = playerShim.useItem(needItem, isNBTSensitive) / amountToConsume;
for (int i = 0; i < takeFromInventory; ++i) { … tryPlaceItemIntoWorld(…) }
| 步骤 | 行为 |
|---|---|
| 扣材料 | useItem 一次扣够,返回值除以 amountToConsume 得到实际能放几个方块 |
| 实际放置 | 用 needItem.tryPlaceItemIntoWorld,坐标为 blockPos - dir(方块下方的支撑点) |
| 记录成功 | 只有 tryPlaceItemIntoWorld 返回 true 才进 placedBlocks 并扣耐久 |
| 扣耐久 | if (!isCreative) wand.placeBlock(...) —— 创造模式完全不扣耐久 |
| 复制 NBT | 源 TileEntity 写 NBT → 改写 x/y/z 为目标坐标 → readFromNBT → markDirty() → markBlockForUpdate |
实际能放的数量由两个 min 共同限制:
numBlocks = Math.min(wand.getMaxBlocks(itemstack), playerShim.countItems(sourceItems, nbtSensitive))
| 限制来源 | 说明 |
|---|---|
wand.getMaxBlocks |
魔杖等级,见 魔杖等级系统 |
playerShim.countItems |
背包 + Botania 花盆 + AE2 网络的方块总数,见 Botania 集成 |
Ztones Ofanix 例外:若安装了 Ztones、目标物品是圆石、且玩家背包有
ofanix,则完全跳过 countItems 限制(视为无限圆石)。
⑦ 黑名单的两级语义
| 配置项 | 生效位置 | 效果 |
|---|---|---|
blacklisted_blocks(硬黑名单) |
WandWorker.getProperItemStack |
完全不可用:直接返回 null,魔杖什么也不做 |
no_assumption_blocks(软黑名单) |
WandWorker.getEquivalentItemStack |
只封堵兜底路径:精确匹配的 ItemStack 仍可使用,只是不再尝试"等效物品"推断 |
软黑名单的精确语义值得注意:getProperItemStack 先尝试
new ItemStack(block, 1, meta) 精确匹配,只有玩家没有该物品时才走
getEquivalentItemStack 去猜掉落物 —— 软黑名单只拦后者。
getEquivalentItemStack 的推断顺序:
block.canSilkHarvest(...)为真 → 用StackedBlockManager反射调用getStackedBlock(int)(func_149644_j/createStackedBlock),结果按方块名|meta缓存- 否则若不在软黑名单 → 用
block.getItemDropped(meta, Random, 0)+quantityDropped+damageDropped - 都没有 → 返回
null
客户端预览
BlockEvents.blockHighlightEvent 在 DrawBlockHighlightEvent 时重跑 ①~④ 步,
然后用 OpenGL 画出白色线框,让玩家在真正放置前看到范围:
| 参数 | 值 |
|---|---|
| 线宽 | GL_LINE_WIDTH = 2.5F |
| 颜色 | 0xC0C0C0(银灰) |
| 每格收缩 | contract(0.005, 0.005, 0.005) |
| 混合 | 关闭贴图,开启 GL_BLEND,绘制后还原 |
预览与实际放置用的是同一个 WandWorker,所以看到的框就是会放下的方块。
相关条目
- 魔杖等级系统 -
maxBlocks的两条计算公式 - EnumLock - 7 种模式的掩码值定义
- EnumFluidLock - 过滤 1 的流体例外
- Custom Mapping -
shouldCopyTileNBT的来源 - 魔杖物品通用行为 - 放置后写入的 NBT 结构
- /wandOops Command - 读取
lastPlaced回滚