Forge 能量系统简述(五)
欢迎来到整个系列教程中最难的一讲。本讲将侧重于介绍如何为传统意义上的导线实现能量传输。
和现实世界不同,在游戏中实现传统意义上的导线,要比无线充电等其他实现方式困难得多。因此读者如果实在无法完整实现传统意义上的导线,那也可以退而求其次,去实现其他的能量传输形式。导线之外的能量传输形式往往也是会受到玩家欢迎的。
导线连通网络
一个最朴素的想法是让每根导线每 tick 都将附近导线的能量传输过来。实际上一些 Mod(比方说 RailCraft 等)也正是这么做的。
但这样做无疑从游戏体验上就存在一个问题:如果是一根长长的导线,那么每 tick 能量自然只能传输一格,如果导线长达几百根,那毫无疑问需要数秒甚至十多秒的时间才能将能量从一头传到另一头。如果考虑到游戏性能的话,问题更大了:所有的导线都需要每 tick 更新一次,那势必导致世界上每 tick 都更新的方块实体数量大大增加。
所以,一个非常重要的原则就是:不要为代表导线的方块实体实现 ITickableTileEntity
接口。那我们应当什么时候执行导线相关的逻辑呢?
部分 Mod 会为导线指定一个中心方块(比如说 AppliedEnergistics2 的 ME 控制器),这不失为一个好的选择:我们只需要为中心方块实现 ITickableTileEntity
,并由它接管所有能量相关的逻辑即可。但是,我们需要实现的是传统意义上的导线,换言之,我们要实现的能量传输系统没有中心方块,对每一根导线而言,它们都是相互平等的。
为了解决这一问题。我们必须为每个世界单独提供一个管理能量传输的导线连通网络,并且自动接管导线的通断相关逻辑。为了方便进一步的操作,我们可以从每一组互相连通的导线(又称一个连通域)中挑选一个代表方块,然后使用该方块代表整组导线。当然,导线的通断会导致一组导线分裂成两组,或是两组导线合并成一组,这些都是我们需要考虑的。
我们现在将接口声明如下:
public interface IBlockNetwork
{
int size(BlockPos node);
BlockPos root(BlockPos node);
void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit);
void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge);
@FunctionalInterface
interface ConnectivityListener
{
void onChange(BlockPos primaryNode, BlockPos secondaryNode);
}
}
size
返回的是导线所处连通域含有的导线数量,如果导线没有和其他导线连通,那么返回值为1
。root
返回的是导线所处连通域的代表导线,也有可能是该导线自身(比方说未和其他导线连通的时候)。cut
指将某根导线的某个方向切断。切断导线如果会导致一个连通域分裂成两个,那么会在分裂后调用afterSplit
的相关方法。link
指将某根导线在某个方向上实施连接。连接导线如果会导致两个连通域合并为一个,那么会在合并前调用beforeMerge
的相关方法。onChange
方法将在连通域发生变化时调用,其中第一个参数是主连通域(其代表导线不会发生变化),而第二个参数是次连通域(会由于合并而消失,或由于分裂而新出现)。
如何实现这个接口呢?本讲将提供相对简单直观的实现,该实现可以达到 O(N)
的时间复杂度。高效的实现可以做到多项式对数级(O(polylog N)
)的时间复杂度,但过于复杂:感兴趣的读者可以通过阅读 Wikipedia(英文页面)加以了解。
实现的思路很简单:除了维护所有连接外,我们只需要对每个方块维护一个连通域的相关集合就好了,而集合的第一个元素自然便是连通域的代表方块:
public class SimpleBlockNetwork implements IBlockNetwork
{
private final Map<BlockPos, Set<BlockPos>> components;
private final SetMultimap<BlockPos, Direction> connections;
public SimpleBlockNetwork()
{
this.components = Maps.newHashMap();
this.connections = Multimaps.newSetMultimap(Maps.newHashMap(), () -> EnumSet.noneOf(Direction.class));
}
@Override
public int size(BlockPos node)
{
return 1; // TODO
}
@Override
public BlockPos root(BlockPos node)
{
return node; // TODO
}
@Override
public void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit)
{
// TODO
}
@Override
public void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge)
{
// TODO
}
}
上面的代码中,components
自然是方块到连通域(Set<BlockPos>
)的映射,而 connections
中存储着所有连接。
我们先从 size
和 root
方法的实现开始:
@Override
public int size(BlockPos node)
{
return this.components.containsKey(node) ? this.components.get(node).size() : 1;
}
@Override
public BlockPos root(BlockPos node)
{
return this.components.containsKey(node) ? this.components.get(node).iterator().next() : node.toImmutable();
}
两个实现都十分直观,且都考虑到了有连通域和无连通域的情况。唯一需要注意的是我们需要 toImmutable
方法把 BlockPos
转为不可变的,这样后续我们才能将相应 BlockPos
直接存入 Set
或 Map
中。
接下来我们实现 cut
和 link
两个方法。
导线连通域的合并
我们再来实现 link
方法:
@Override
public void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge)
{
BlockPos secondary = node.toImmutable();
if (this.connections.put(secondary, direction))
{
BlockPos primary = node.offset(direction);
this.connections.put(primary, direction.getOpposite());
Set<BlockPos> primaryComponent = this.components.get(primary);
Set<BlockPos> secondaryComponent = this.components.get(secondary);
if (primaryComponent == null && secondaryComponent == null)
{
Set<BlockPos> union = Sets.newLinkedHashSet();
beforeMerge.onChange(secondary, primary);
this.components.put(secondary, union);
this.components.put(primary, union);
union.add(secondary);
union.add(primary);
}
else if (primaryComponent == null)
{
beforeMerge.onChange(secondaryComponent.iterator().next(), primary);
this.components.put(primary, secondaryComponent);
secondaryComponent.add(primary);
}
else if (secondaryComponent == null)
{
beforeMerge.onChange(primaryComponent.iterator().next(), secondary);
this.components.put(secondary, primaryComponent);
primaryComponent.add(secondary);
}
else if (primaryComponent != secondaryComponent)
{
beforeMerge.onChange(primaryComponent.iterator().next(), secondaryComponent.iterator().next());
Set<BlockPos> union = Sets.newLinkedHashSet(Sets.union(primaryComponent, secondaryComponent));
union.forEach(pos -> this.components.put(pos, union));
}
}
}
我们一段一段地来分析:
BlockPos secondary = node.toImmutable();
if (this.connections.put(secondary, direction))
这一段是将 BlockPos
和对应 Direction
添加到 connections
中,如果在这之前 connections
中并不存在该连接,那么 put
方法将返回 true
,如果不存在,那么自然就没有进行下一步的意义了。
BlockPos primary = node.offset(direction);
this.connections.put(primary, direction.getOpposite());
如果连接不存在的话,那么我们还需要找到连接到的 BlockPos
,为其相反方向添加连接。
Set<BlockPos> primaryComponent = this.components.get(primary);
Set<BlockPos> secondaryComponent = this.components.get(secondary);
我们试图获取两个 BlockPos
所处的连通域,至此我们需要分三种情况:
- 两个连通域都不存在,那我们需要新创建一个连通域,然后把两个
BlockPos
加上去。 - 一个连通域存在,另一个不存在,那我们需要把对应的
BlockPos
加到相应的连通域中。 - 两个连通域都存在,那如果它们不相同,我们需要把两个连通域相互合并,然后应用到所有相关节点上去。
if (primaryComponent == null && secondaryComponent == null)
{
Set<BlockPos> union = Sets.newLinkedHashSet();
beforeMerge.onChange(secondary, primary);
this.components.put(secondary, union);
this.components.put(primary, union);
union.add(secondary);
union.add(primary);
}
这对应两个连通域都不存在的情况:创建一个新连通域(union
),然后把两个 BlockPos
加上去。别忘了调用 beforeMerge
的相关方法。
else if (primaryComponent == null)
{
beforeMerge.onChange(secondaryComponent.iterator().next(), primary);
this.components.put(primary, secondaryComponent);
secondaryComponent.add(primary);
}
这对应第一个连通域不存在而第二个存在的情况,我们需要把第一个 BlockPos
加上去。
else if (secondaryComponent == null)
{
beforeMerge.onChange(primaryComponent.iterator().next(), secondary);
this.components.put(secondary, primaryComponent);
primaryComponent.add(secondary);
}
这对应第一个连通域存在而第二个不存在的情况,我们需要把第二个 BlockPos
加上去。
else if (primaryComponent != secondaryComponent)
{
beforeMerge.onChange(primaryComponent.iterator().next(), secondaryComponent.iterator().next());
Set<BlockPos> union = Sets.newLinkedHashSet(Sets.union(primaryComponent, secondaryComponent));
union.forEach(pos -> this.components.put(pos, union));
}
这对应两个连通域都存在且不相同的情况,我们需要创建一个连通域把两个连通域合并到一起,然后应用到两个连通域中的所有节点上。
最后我们注意到,只有两种情况下我们不会调用 beforeMerge
的 onChange
方法:
- 试图添加的连接已存在。
- 添加了连接同一个连通域的连接。
导线连通域的分裂
最后我们实现 cut
方法。cut
方法是整个接口中最难实现的一个,因此在动手写代码时,我们首先需要了解相关原理。
我们知道删除某个连接有可能将一个连通域分裂成两半,也有可能不会为一个连通域带来变化。为了检查这两件事,我们需要从被删除的连接所对应的两个 BlockPos
开始,分别进行广度优先搜索,并在以下两个条件中的任何一个达成时同时终止搜索:
- 当一方搜索到的某个节点已经在另一方搜索到的节点列表中,则代表连通域并未分裂。
- 当一方已经遍历了所有能够遍历的节点,则代表连通域已被分裂为两半,搜索完成的一方代表其中的一半。
为此,我们需要首先实现一个基于广度优先搜索的 Iterator
:
public class BFSIterator implements Iterator<BlockPos>
{
private final Set<BlockPos> searched = Sets.newLinkedHashSet();
private final Queue<BlockPos> queue = Queues.newArrayDeque();
public BFSIterator(BlockPos node)
{
node = node.toImmutable();
this.searched.add(node);
this.queue.offer(node);
}
@Override
public boolean hasNext()
{
return this.queue.size() > 0;
}
@Override
public BlockPos next()
{
BlockPos node = this.queue.remove();
for (Direction direction : SimpleBlockNetwork.this.connections.get(node))
{
BlockPos another = node.offset(direction);
if (this.searched.add(another))
{
this.queue.offer(another);
}
}
return node;
}
public Set<BlockPos> getSearched()
{
return this.searched;
}
}
广度优先搜索的实现很简单,上面的代码也很清晰,这里就不展开讲解了。
接下来我们使用 BFSIterator
实现 cut
方法:
@Override
public void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit)
{
if (this.connections.remove(node, direction))
{
BlockPos another = node.offset(direction);
this.connections.remove(another, direction.getOpposite());
BFSIterator nodeIterator = new BFSIterator(node), anotherIterator = new BFSIterator(another);
while (nodeIterator.hasNext())
{
BlockPos next = nodeIterator.next();
if (!anotherIterator.getSearched().contains(next))
{
BFSIterator iterator = anotherIterator;
anotherIterator = nodeIterator;
nodeIterator = iterator;
continue;
}
return;
}
Set<BlockPos> primaryComponent = this.components.get(node), secondaryComponent;
BlockPos primaryNode = primaryComponent.iterator().next();
Set<BlockPos> searched = nodeIterator.getSearched();
if (searched.contains(primaryNode))
{
secondaryComponent = Sets.newLinkedHashSet(Sets.difference(primaryComponent, searched));
primaryComponent.retainAll(searched);
}
else
{
secondaryComponent = searched;
primaryComponent.removeAll(searched);
}
if (secondaryComponent.size() <= 1)
{
secondaryComponent.forEach(this.components::remove);
}
else
{
secondaryComponent.forEach(pos -> this.components.put(pos, secondaryComponent));
}
if (primaryComponent.size() <= 1)
{
primaryComponent.forEach(this.components::remove);
}
afterSplit.onChange(primaryNode, secondaryComponent.iterator().next());
}
}
我们在这里还是一段一段地讲解:
if (this.connections.remove(node, direction))
这里将移除对应边,如果对应边在移除前存在,那么该方法返回 true
。
BlockPos another = node.offset(direction);
this.connections.remove(another, direction.getOpposite());
如果连接存在的话,那么我们还需要找到连接到的 BlockPos
,为其相反方向删除连接。
BFSIterator nodeIterator = new BFSIterator(node), anotherIterator = new BFSIterator(another);
while (nodeIterator.hasNext())
{
BlockPos next = nodeIterator.next();
if (!anotherIterator.getSearched().contains(next))
{
BFSIterator iterator = anotherIterator;
anotherIterator = nodeIterator;
nodeIterator = iterator;
continue;
}
return;
}
然后我们为两边的 BlockPos
分别创立 BFSIterator
,轮流实施迭代过程。
- 若当前
BFSIterator
已遍历完所有能够遍历得到的BlockPos
(hasNext
为false
)则循环结束。 - 否则,如果另一个
BFSIterator
包含当前节点,那说明它们仍然在同一个连通域,直接return
。 - 最后,如果另一个
BFSIterator
不包含当前节点,那么把两个节点相交换,继续循环过程。
Set<BlockPos> primaryComponent = this.components.get(node), secondaryComponent;
BlockPos primaryNode = primaryComponent.iterator().next();
Set<BlockPos> searched = nodeIterator.getSearched();
if (searched.contains(primaryNode))
{
secondaryComponent = Sets.newLinkedHashSet(Sets.difference(primaryComponent, searched));
primaryComponent.retainAll(searched);
}
else
{
secondaryComponent = searched;
primaryComponent.removeAll(searched);
}
如果我们证实我们的连通域会分裂成两半,并且已经搜索到了其中一半(searched
),那么接下来我们需要定主连通域和次连通域。主连通域自然是当前节点所在连通域,但我们刚刚遍历收集到的,到底是不是主连通域呢?我们需要 searched.contains(primaryNode)
这一表达式加以判断:
- 如果是(返回
true
),那么我们需要构造一个未遍历到的BlockPos
集合作为次连通域,然后我们在主连通域中只保留归属于searched
的BlockPos
(retainAll
方法)。 - 如果不是(返回
false
),那么我们可以直接将searched
作为次连通域,然后把主连通域中已经从属于searched
的BlockPos
全去掉(removeAll
方法)。
if (secondaryComponent.size() <= 1)
{
secondaryComponent.forEach(this.components::remove);
}
else
{
secondaryComponent.forEach(pos -> this.components.put(pos, secondaryComponent));
}
if (primaryComponent.size() <= 1)
{
primaryComponent.forEach(this.components::remove);
}
afterSplit.onChange(primaryNode, secondaryComponent.iterator().next());
接下来就要把这两个集合应用到每一个从属于它们的 BlockPos
了。注意如果该连通域中只有一个 BlockPos
,那么可以直接将其从 components
中删除。
最后我们调用了 afterSplit
的 onChange
方法。
导线能量网络
我们现在可以基于连通网络实现能量网络了。除了连通网络外,我们还需要存储什么呢?
- 每个连通域都会存储一定能量用于能量传输,因此我们需要为每个连通域存储这个。
- 导线并不一定只连着导线,还可能连着机器,因此我们需要把所有和机器有关的连接单独储存。
- 导线的连接和切断和能量存取会导致数据变化,因此我们需要记录所有变化的区块,从而保证它们能够保存进存档中。
关于能量存储这里补充一点:我们只需要为每个连通域的代表方块存储能量值,而由于能量值一定是非负整数,因此这里使用 Multiset
将十分适合。
public class SimpleEnergyNetwork
{
private final IWorld world;
private final IBlockNetwork blockNetwork;
private final Queue<Runnable> taskCollection;
private final Multiset<BlockPos> energyCollection;
private final SetMultimap<ChunkPos, BlockPos> chunkCollection;
private final SetMultimap<BlockPos, Direction> machineCollection;
private SimpleEnergyNetwork(IWorld world, IBlockNetwork blockNetwork)
{
this.world = world;
this.blockNetwork = blockNetwork;
this.taskCollection = Queues.newArrayDeque();
this.energyCollection = HashMultiset.create();
this.chunkCollection = Multimaps.newSetMultimap(Maps.newHashMap(), Sets::newHashSet);
this.machineCollection = Multimaps.newSetMultimap(Maps.newHashMap(), () -> EnumSet.noneOf(Direction.class));
}
}
除了上面提到的这些和 world
外,我们还额外添加了一个 taskCollection
字段,稍后我们监听 tick 事件时用得着。
我们还需要考虑一个问题:刚刚我们提到过,我们的能量网络是相对于某个世界的,因此对于某个特定的世界而言,导线的能量数据是全局存储的,但我们应如何把数据放到存档里呢?以连通域为单位存储在这里显然不适合,因为连通域会合并和分裂,从而使得维护存档中导线和连通域之间的关系成为非常困难的工作(在内存中这很容易)。一个很不错的解决方案是:我们可以把能量放到导线里均摊储存,这样不管连通域如何合并和分裂,最终都将落实到每根导线和存档的交互上。为了实现这一解决方案,我们需要声明四个方法:
getNetworkSize
:获取导线所处连通域的导线数量。getNetworkEnergy
:获取导线所处连通域的整体能量。getSharedEnergy
:获取导线所处连通域均摊到当前导线的能量。addEnergy
:调整导线所处连通域的能量(正数为增加,负数为减少)。
这四个方法的实现都非常简单。Guava 的 Multiset
和 Multimap
在实现上为我们带来了极大的方便:
public int getNetworkSize(BlockPos pos)
{
return this.blockNetwork.size(pos);
}
public int getNetworkEnergy(BlockPos pos)
{
BlockPos root = this.blockNetwork.root(pos);
return this.energyCollection.count(root);
}
public int getSharedEnergy(BlockPos pos)
{
int size = this.blockNetwork.size(pos);
BlockPos root = this.blockNetwork.root(pos);
int total = this.energyCollection.count(root);
return root.equals(pos) ? total / size + total % size : total / size;
}
public void addEnergy(BlockPos pos, int diff)
{
if (diff >= 0)
{
this.energyCollection.add(this.blockNetwork.root(pos), diff);
}
else
{
this.energyCollection.remove(this.blockNetwork.root(pos), -diff);
}
}
这里唯一需要指出的是能量的分摊方式,也就是在整体能量除以连通域导线数量除不开的时候,问题是如何解决的:
- 如果当前导线是连通域的代表导线,那么把余数都分摊到该导线上。
- 如果当前导线不是连通域的代表导线,那么照常除就可以了,不必考虑余数。
在 tick 事件中增删导线
我们现在声明用于删除导线的 disableBlock
方法,和用于添加导线的 enableBlock
方法。但是,这两个方法的实现并没有那么直接,因为我们需要把相关行为托管到 tick 事件中执行。
为什么我们不能立刻增删导线?这是因为在增删导线的时候,我们需要检查导线和周围方块的连通性,而很多时候导线是在世界加载阶段加载的,因此如果在世界加载时获取周围方块的相关信息,将会极易导致死锁。因此我们需要把增删导线的相关逻辑放到 tick 事件中,这正是 taskCollection
字段的存在意义。
public void disableBlock(BlockPos pos, Runnable callback)
{
this.taskCollection.offer(() ->
{
// TODO
callback.run();
});
}
public void enableBlock(BlockPos pos, Runnable callback)
{
this.taskCollection.offer(() ->
{
// TODO
callback.run();
});
}
private void tickStart()
{
for (Runnable runnable = this.taskCollection.poll(); runnable != null; runnable = this.taskCollection.poll())
{
runnable.run();
}
}
我们为 disableBlock
和 enableBlock
两个方法添加了 Runnable
作为回调函数,并在 tickStart
方法调用时调用。我们稍后便会在 tick 事件的监听器里调用 tickStart
方法。
向能量网络增删导线
我们先来实现删除导线:
public void disableBlock(BlockPos pos, Runnable callback)
{
this.taskCollection.offer(() ->
{
this.chunkCollection.remove(new ChunkPos(pos), pos);
for (Direction side : Direction.values())
{
this.blockNetwork.cut(pos, side, this::afterSplit);
}
this.machineCollection.removeAll(pos);
callback.run();
});
}
private void afterSplit(BlockPos primaryNode, BlockPos secondaryNode)
{
int primarySize = this.blockNetwork.size(primaryNode), secondarySize = this.blockNetwork.size(secondaryNode);
int diff = this.energyCollection.count(primaryNode) * secondarySize / (primarySize + secondarySize);
this.energyCollection.remove(primaryNode, diff);
this.energyCollection.add(secondaryNode, diff);
}
除了调用回调函数外,删除导线主要做三件事:
- 记录导线所归属的区块。
- 切断导线在六个方向的所有连接。
- 切断导线在所有方向上和机器的连接。
切断导线连接时需要传入 ConnectivityListener
,这里声明并实现了 afterSplit
方法,并传入方法引用作为实现。afterSplit
方法所做的事很简单:把当前连通域的整体能量按所拥有的导线数量分离一部分出来给一个新的连通域。
然后我们再来实现添加导线:
public void enableBlock(BlockPos pos, Runnable callback)
{
this.taskCollection.offer(() ->
{
this.chunkCollection.put(new ChunkPos(pos), pos.toImmutable());
for (Direction side : Direction.values())
{
if (this.hasWireConnection(pos, side))
{
if (this.hasWireConnection(pos.offset(side), side.getOpposite()))
{
this.machineCollection.remove(pos, side);
this.blockNetwork.link(pos, side, this::beforeMerge);
}
else
{
this.machineCollection.put(pos.toImmutable(), side);
this.blockNetwork.cut(pos, side, this::afterSplit);
}
}
else
{
this.machineCollection.remove(pos, side);
this.blockNetwork.cut(pos, side, this::afterSplit);
}
}
callback.run();
});
}
private boolean hasWireConnection(BlockPos pos, Direction side)
{
return false; // TODO
}
private void beforeMerge(BlockPos primaryNode, BlockPos secondaryNode)
{
int diff = this.energyCollection.count(secondaryNode);
this.energyCollection.remove(secondaryNode, diff);
this.energyCollection.add(primaryNode, diff);
}
除了调用回调函数外,添加导线主要做的也是三件事:
- 记录导线所归属的区块。
- 添加导线和其他导线之间的连接。
- 添加导线和机器的连接。
和删除导线相比,添加导线还需要检查周围方块是否能够与其相互连接,因此实现会稍加复杂:
- 如果导线在某个方向上不和相邻方块连接,那自然既不考虑连通网络,也不考虑机器了。
- 如果导线在某个方向上和相邻方块连接,且连接的方块是在相反方向上连接的导线,那么将该导线添加到连通网络。
- 如果导线在某个方向上和相邻方块连接,且连接的方块不是在相反方向上连接的导线,那么将该导线连接的方块视为机器。
我们现在实现 hasWireConnection
方法:
@SuppressWarnings("deprecation")
private boolean hasWireConnection(BlockPos pos, Direction side)
{
if (this.world.isBlockLoaded(pos))
{
BlockState state = this.world.getBlockState(pos);
return state.getBlock().equals(FEDemoWireBlock.BLOCK) && state.get(FEDemoWireBlock.PROPERTY_MAP.get(side));
}
return false;
}
这里需要额外注意 isBlockLoaded
方法的调用。如果我们不事先进行 isBlockLoaded
这一检查,那么 getBlockState
方法在检查未加载的方块坐标时,将会自动将该方块坐标所处的区块予以加载,而加载会导致连接状态的变化,因此如果世界上有一长链导线,这会导致途径的所有区块全部加载,这显然是没有必要的。更为重要的是,游戏会在加载区块后试图卸载不必要的区块,而卸载区块同样会导致连接状态的变化,这一变化又会反过来加载区块,因此区块会不断地在加载和卸载之间循环,这显然会带来不必要的性能损失。稍后我们会在其他方法再次调用 isBlockLoaded
方法进行方块是否已加载的检查。
增删导线的逻辑到这里就彻底写完了。接下来我们要写另一处需要在 tick 事件中执行的逻辑。
在 tick 事件中输送能量
在编写发电机的时候我们曾经提到,能量的流动应由发电机控制,而发电机实现了 ITickableTileEntity
接口,因此可以在实现该接口的 tick
方法时输送能量。刚刚我们提到,导线能量网络是以世界为单位的,因此我们同样需要监听世界的 tick 事件完成这件事。我们把这一行为写进 tickEnd
方法:
@SuppressWarnings("deprecation")
private void tickEnd()
{
for (Map.Entry<BlockPos, Direction> entry : this.shuffled(this.machineCollection.entries()))
{
Direction direction = entry.getValue();
BlockPos node = entry.getKey(), root = this.blockNetwork.root(node);
if (this.world.isBlockLoaded(node.offset(direction)))
{
TileEntity tileEntity = this.world.getTileEntity(node.offset(direction));
if (tileEntity != null)
{
tileEntity.getCapability(CapabilityEnergy.ENERGY, direction.getOpposite()).ifPresent(e ->
{
if (e.canReceive())
{
int diff = this.energyCollection.count(root);
this.energyCollection.remove(root, e.receiveEnergy(diff, false));
}
});
}
}
}
}
private <T> List<T> shuffled(Iterable<? extends T> iterable)
{
List<T> list = Lists.newArrayList(iterable);
Random rand = this.world.getRandom();
Collections.shuffle(list, rand);
return list;
}
该方法的实现很简单:遍历所有的机器(在遍历前打乱了一遍次序),然后如果机器可以接收能量,那么便向其输送能量。注意 isBlockLoaded
方法的调用,因为我们并不希望向未加载的区块中的方块实体输送能量。
标记需要保存的区块
我们需要在保存存档的时候标记所有需要保存的区块。我们声明一个 markDirty
方法,并在该方法内部实现相应的逻辑:
@SuppressWarnings("deprecation")
private void markDirty()
{
for (ChunkPos chunkPos : this.chunkCollection.keys())
{
BlockPos pos = chunkPos.asBlockPos();
if (this.world.isBlockLoaded(pos))
{
this.world.getChunk(pos).setModified(true);
}
}
}
稍后我们会监听保存世界存档的事件,然后调用这一方法。
导线能量网络的管理
我们需要一个全局化的管理类,我们决定让它成为 SimpleEnergyNetwork
的嵌套类:
@Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.FORGE)
public static class Factory
{
private static final Map<IWorld, SimpleEnergyNetwork> INSTANCES = Maps.newIdentityHashMap();
public static SimpleEnergyNetwork get(IWorld world)
{
return INSTANCES.computeIfAbsent(world, k -> new SimpleEnergyNetwork(k, new SimpleBlockNetwork()));
}
@SubscribeEvent
public static void onSave(WorldEvent.Save event)
{
if (INSTANCES.containsKey(event.getWorld()))
{
INSTANCES.get(event.getWorld()).markDirty();
}
}
@SubscribeEvent
public static void onUnload(WorldEvent.Unload event)
{
INSTANCES.remove(event.getWorld());
}
@SubscribeEvent
public static void onWorldTick(TickEvent.WorldTickEvent event)
{
if (LogicalSide.SERVER.equals(event.side))
{
switch (event.phase)
{
case START:
{
Factory.get(event.world).tickStart();
break;
}
case END:
{
Factory.get(event.world).tickEnd();
break;
}
}
}
}
}
该类提供了构造并返回 SimpleEnergyNetwork
方法,并且有三个事件监听器:
- 在世界开始保存存档时调用
markDirty
方法。 - 在世界准备卸载时移除已经持有的
SimpleEnergyNetwork
实例。 - 在世界的 tick 事件触发时调用
tickStart
和tickEnd
方法。
到这里,整个 SimpleEnergyNetwork
,就完全实现完了,我们稍后会在导线的方块实体类里调用里面的相关方法。
为导线实现 Capability
我们现在为代表导线的方块实体添加 Capability:
private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
{
private final SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(FEDemoWireTileEntity.this.world);
@Override
public int receiveEnergy(int maxReceive, boolean simulate)
{
int energy = this.getEnergyStored();
int diff = Math.min(500, Math.min(this.getMaxEnergyStored() - energy, maxReceive));
if (!simulate)
{
this.network.addEnergy(FEDemoWireTileEntity.this.pos, diff);
if (diff != 0)
{
FEDemoWireTileEntity.this.markDirty();
}
}
return diff;
}
@Override
public int extractEnergy(int maxExtract, boolean simulate)
{
int energy = this.getEnergyStored();
int diff = Math.min(500, Math.min(energy, maxExtract));
if (!simulate)
{
this.network.addEnergy(FEDemoWireTileEntity.this.pos, -diff);
if (diff != 0)
{
FEDemoWireTileEntity.this.markDirty();
}
}
return diff;
}
@Override
public int getEnergyStored()
{
return Math.min(this.getMaxEnergyStored(), this.network.getNetworkEnergy(FEDemoWireTileEntity.this.pos));
}
@Override
public int getMaxEnergyStored()
{
return 1_000 * this.network.getNetworkSize(FEDemoWireTileEntity.this.pos);
}
@Override
public boolean canExtract()
{
return true;
}
@Override
public boolean canReceive()
{
return true;
}
});
@Nonnull
@Override
public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
{
boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY);
return isEnergy ? this.lazyOptional.cast() : super.getCapability(cap, side);
}
由于导线既可以输入能量,也可以输出能量,因此 canExtract
和 canReceive
都应返回 true
,剩下的实现和之前的发电机和用电器都大同小异,这里就不展开了。
导线本身的加载与卸载
Minecraft 原版和 Forge 共为 TileEntity
提供了三个方法用于描述方块实体的加载和卸载过程:
onLoad
方法将在方块实体加载时(包括手动放置对应方块和以及区块加载)触发。onChunkUnloaded
方法将在方块实体所在区块被卸载时触发。remove
方法将在方块实体被拆除时触发。
我们还需要覆盖读取 NBT 里会调用的 read
方法和写入 NBT 时会调用的 write
方法。我们先实现这两个方法:
private Integer tmpEnergy = null;
@Override
public void read(@Nonnull CompoundNBT compound)
{
this.tmpEnergy = compound.getInt("WireEnergy");
super.read(compound);
}
@Nonnull
@Override
public CompoundNBT write(@Nonnull CompoundNBT compound)
{
SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
compound.putInt("WireEnergy", network.getSharedEnergy(this.pos));
return super.write(compound);
}
我们可以注意到一件事:write
方法是直接从导线能量网络里获取均摊能量,而 read
方法却写入到了一个临时值,为什么要这样做?这是因为,read
方法第一次调用的时机特别特别早,甚至方块实体还没有被加载到世界中,因此我们甚至连方块实体所处的世界都无法获取得到,更逞论获取导线能量网络中的均摊能量了。因此,我们只能先写入一个临时值,然后在 onLoad
方法里读取这个临时值:
@Override
public void onLoad()
{
if (this.world != null && !this.world.isRemote)
{
SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
if (this.tmpEnergy != null)
{
int diff = this.tmpEnergy - network.getSharedEnergy(this.pos);
network.addEnergy(this.pos, diff);
this.tmpEnergy = null;
}
network.enableBlock(this.pos, this::markDirty);
}
super.onLoad();
}
注意该方法设置能量的方式:通过添加差额能量的方式设置。
最后我们还剩下 onChunkUnloaded
和 remove
两个方法。我们现在实现这两个方法:
@Override
public void onChunkUnloaded()
{
if (this.world != null && !this.world.isRemote)
{
SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
network.disableBlock(this.pos, this::markDirty);
}
super.onChunkUnloaded();
}
@Override
public void remove()
{
if (this.world != null && !this.world.isRemote)
{
SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
network.disableBlock(this.pos, () ->
{
int diff = network.getSharedEnergy(this.pos);
network.addEnergy(this.pos, -diff);
this.markDirty();
});
}
super.remove();
}
和 onChunkUnloaded
相比,remove
方法额外多做了一件事:把导线连通网络里当前位置的能量清零。这可以避免在该位置重新添加导线时附带残留能量。
到这里,整个导线的方块实体相关代码,就全部实现完了。但我们还有一件事没处理:如果导线附近的方块发生变化了怎么办?
导线附近方块的变化
如果导线附近添加了新的机器,那么我们应当将这件事通知能量网络。这可以通过覆盖 Block
类的 neighborChanged
方法来实现。
我们在方块类(FEDemoWireBlock
)写下以下代码:
@Override
@SuppressWarnings("deprecation")
public void neighborChanged(@Nonnull BlockState state, @Nonnull World world, @Nonnull BlockPos pos, @Nonnull Block fromBlock, @Nonnull BlockPos fromPos, boolean isMoving)
{
if (!world.isRemote)
{
TileEntity tileEntity = world.getTileEntity(pos);
if (tileEntity instanceof FEDemoWireTileEntity)
{
SimpleEnergyNetwork.Factory.get(world).enableBlock(pos, tileEntity::markDirty);
}
}
}
很好,关于导线的一切我们都已经写完了。
代码清单
这一部分添加的文件有:
src/main/java/com/github/ustc_zzzz/fedemo/util/IBlockNetwork.java
src/main/java/com/github/ustc_zzzz/fedemo/util/SimpleBlockNetwork.java
src/main/java/com/github/ustc_zzzz/fedemo/util/SimpleEnergyNetwork.java
这一部分修改的文件有:
src/main/java/com/github/ustc_zzzz/fedemo/block/FEDemoWireBlock.java
src/main/java/com/github/ustc_zzzz/fedemo/tileentity/FEDemoWireTileEntity.java