跳到正文
烈焰甘蔗Wiki

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 的推断顺序:

  1. block.canSilkHarvest(...) 为真 → 用 StackedBlockManager 反射调用 getStackedBlock(int)(func_149644_j / createStackedBlock),结果按 方块名|meta 缓存
  2. 否则若不在软黑名单 → 用 block.getItemDropped(meta, Random, 0) + quantityDropped + damageDropped
  3. 都没有 → 返回 null

客户端预览

BlockEvents.blockHighlightEvent 在 DrawBlockHighlightEvent 时重跑 ①~④ 步, 然后用 OpenGL 画出白色线框,让玩家在真正放置前看到范围:

参数 值
线宽 GL_LINE_WIDTH = 2.5F
颜色 0xC0C0C0(银灰)
每格收缩 contract(0.005, 0.005, 0.005)
混合 关闭贴图,开启 GL_BLEND,绘制后还原

预览与实际放置用的是同一个 WandWorker,所以看到的框就是会放下的方块。

相关条目

浏览全部模组