本篇教程由作者设定使用 CC BY-NC-SA 协议。
看标题就知道我要分析代码来介绍裂变堆的爆炸逻辑了,不过也不排除会有点别的东西,说不好呢。
代码来自于 github 上开源的仓库,所用的代码逻辑从 1.16.x 到 1.19.x 未发生更改,但我不保证之后的版本同样如此。
代码逻辑主要来自于 mekanism.generators.common.content.fission.FissionReactorMultiblockData.java,之后简称为此代码。为什么 mcmod 没有行内代码块?
文章灵感来自于这篇教程,感谢大佬的研究。
为了方便阅读,具有意义的结论以淡蓝色标注。
0x00 常量定义
INVERSE_INSULATION_COEFFICIENT : double = 10000
INVERSE_CONDUCTION_COEFFICIENT : double = 10
waterConductivity : double = 0.5
COOLANT_PER_VOLUME : int = 100000
HEATED_COOLANT_PER_VOLUME : long = 1000000
FUEL_PER_ASSEMBLY : long = 8000
MIN_DAMAGE_TEMPERATURE : double = 1200
MAX_DAMAGE_TEMPERATURE : double = 1800
MAX_DAMAGE : double = 100
EXPLOSION_CHANCE : double = 1 / 512000
你应该还尚不知道里面的大部分东西有什么用,没关系,我们之后会用到。
0x01 爆炸time when?
我们首先需要关心的是爆炸由什么代码调起,这样才能分析实际代码逻辑。
EXPLOSION_CHANCE 是一个很有趣的常量,在此代码中搜索发现只有两处使用,另一处为函数 void createMeltdown(Level world):
private void createMeltdown(Level world) {
RadiationManager.INSTANCE.createMeltdown(world, getMinPos(), getMaxPos(), heatCapacitor.getHeat(), EXPLOSION_CHANCE,
MekanismGeneratorsConfig.generators.fissionMeltdownRadius.get(), inventoryID);
}
基本可以认为这就是我们的目标,此函数由另一个函数 handleDamage 调起,这会是我们之后的核心,而 handleDamage 由函数 tick 调起,望文生义为每 tick 执行。
显然,handleDamage 是控制爆炸的关键代码,同时,我们将爆炸换用更好的表述「熔毁」(即 meltdown,区别于 explosion)。
0x02 handleDamage
先贴代码:
private void handleDamage(Level world) {
double lastDamage = reactorDamage;
double temp = heatCapacitor.getTemperature();
if (temp > MIN_DAMAGE_TEMPERATURE) {
double damageRate = Math.min(temp, MAX_DAMAGE_TEMPERATURE) / (MIN_DAMAGE_TEMPERATURE * 10);
reactorDamage += damageRate;
} else {
double repairRate = (MIN_DAMAGE_TEMPERATURE - temp) / (MIN_DAMAGE_TEMPERATURE * 100);
reactorDamage = Math.max(0, reactorDamage - repairRate);
}
// consider a meltdown only if we're passed the damage threshold and the temperature is still dangerous
if (reactorDamage >= MAX_DAMAGE && temp >= MIN_DAMAGE_TEMPERATURE) {
if (isForceDisabled() && MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
//If we have meltdowns enabled, and we would have had one before, but they were disabled, just meltdown immediately
// if we still meet the requirements for a meltdown
setForceDisable(false);
createMeltdown(world);
} else if (world.random.nextDouble() < (reactorDamage / MAX_DAMAGE) * MekanismGeneratorsConfig.generators.fissionMeltdownChance.get()) {
// Otherwise, if our chance is hit either create a meltdown if it is enabled in the config, or force disable the reactor
if (MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
createMeltdown(world);
} else {
setForceDisable(true);
}
}
} else if (reactorDamage < MAX_DAMAGE && temp < MIN_DAMAGE_TEMPERATURE) {
//If we are at a safe temperature and damage level, allow enabling the reactor again
setForceDisable(false);
}
if (reactorDamage != lastDamage) {
markDirty();
}
}
我们拆解来看。
double lastDamage = reactorDamage;
double temp = heatCapacitor.getTemperature();
if (temp > MIN_DAMAGE_TEMPERATURE) {
double damageRate = Math.min(temp, MAX_DAMAGE_TEMPERATURE) / (MIN_DAMAGE_TEMPERATURE * 10);
reactorDamage += damageRate;
} else {
double repairRate = (MIN_DAMAGE_TEMPERATURE - temp) / (MIN_DAMAGE_TEMPERATURE * 100);
reactorDamage = Math.max(0, reactorDamage - repairRate);
}
lastDamage 的功能是异于这一小块代码的,但是不好拆分,我们之后再说。
temp 是反应堆目前的热量,如果温度大于一个温度阈值,那么会按照某种算法计算一个损伤值加到已有损伤值上,否则计算一个修复值减到已有损伤值上(但不会低于 0)。
既然计算式中有一堆常量,我们就把常量代入算一下。
温度阈值:1200
损伤值:min(temp, 1800) / 12000
修复值:(1200 - temp) / 120000
也就是说,最少会有 0.1 per tick 的损伤值,之后每上升 1K 温度会导致 1/12000 per tick 的损伤值,直到达到 0.15 per tick。
而修复值随温度降低而升高,每 1K 温度提高 1/120000 per tick 的修复值,直到最终降低到 0.0075 per tick(即 300K 时,这是理论的常温)。
注意,这里面的数值都是累加到损伤值显示的百分号前面的,比如本来是 20%,温度是 1800K,1 tick 后损伤值会显示为 20.15%。
if (reactorDamage >= MAX_DAMAGE && temp >= MIN_DAMAGE_TEMPERATURE) {
if (isForceDisabled() && MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
//If we have meltdowns enabled, and we would have had one before, but they were disabled, just meltdown immediately
// if we still meet the requirements for a meltdown
setForceDisable(false);
createMeltdown(world);
} else if (world.random.nextDouble() < (reactorDamage / MAX_DAMAGE) * MekanismGeneratorsConfig.generators.fissionMeltdownChance.get()) {
// Otherwise, if our chance is hit either create a meltdown if it is enabled in the config, or force disable the reactor
if (MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
createMeltdown(world);
} else {
setForceDisable(true);
}
}
}
其中 isForceDisabled 与 setForceDisable 是对于 boolean 变量 forceDisable 的 getter/setter 函数。
@ContainerSync
private boolean forceDisable;
void setForceDisable(boolean forceDisable) {
if (this.forceDisable != forceDisable) {
this.forceDisable = forceDisable;
markDirty();
if (this.forceDisable) {
//If we are force disabling it, deactivate the reactor
setActive(false);
}
}
}
@ComputerMethod
public boolean isForceDisabled() {
return forceDisable;
}
MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get() 返回是否在配置文件中启用熔毁,默认为 true。
MekanismGeneratorsConfig.generators.fissionMeltdownChance.get() 是配置文件中反应堆熔毁的概率,这个值默认是 0.001。
首先是一个判断,如果损伤值超过最大损伤值,且温度超过损伤阈值,进行一系列逻辑,这个条件称为熔毁条件。
熔毁条件:温度 ≥ 1200K 并且 损伤值 ≥ 100
先看代码块里面第二个分支的逻辑。从 0 到 1 之间随机一个小数,如果随机数小于已有损伤值除最大损伤值再乘熔毁概率,则再进入一个判定:若熔毁启用,则发生熔毁,否则将 forceDisable 记为 true(同时会导致反应堆强制关机)。代入常量计算一下。
熔毁概率 = (损伤值 / 100) * 配置熔毁概率 => 损伤值 * 0.00001,每 tick 检测一次。
例如,当损伤值为 1000% 时,熔毁概率为 1%,此时反应堆有 81.8% 的概率在一秒内不熔毁。这个时候建议把反应堆拆了重建。
我们再来看第一个分支,若 forceDisable 为 true 且启用熔毁则发生熔毁。这实际上发生于你在反应堆满足熔毁条件且熔毁判定成功后,把配置从不允许熔毁改成了允许熔毁。
else if (reactorDamage < MAX_DAMAGE && temp < MIN_DAMAGE_TEMPERATURE) {
//If we are at a safe temperature and damage level, allow enabling the reactor again
setForceDisable(false);
}
如果反应堆不满足熔毁条件了,就把 forceDisable 改成 false。这说明如果反应堆满足了熔毁的条件,然后回到安全的损伤值,然后再在配置里开启熔毁,然后再触发熔毁条件时,并不会立刻熔毁。当然如果你运气差正好碰上熔毁概率那就当我没说
if (reactorDamage != lastDamage) {
markDirty();
}
更新损伤值数据。
我们可以得出一些结论。
无论反应堆是否在运行,只要满足熔毁条件,并且在配置文件中开启了熔毁,就有可能发生熔毁。
无论反应堆是否在运行,只要温度超过 1200K,反应堆就一定会积攒损伤值。
损伤值的恢复受到温度的影响且十分缓慢,所以一次性将损伤值降到 0 比开一会关一会要高效,并且最好不要产生损伤值。
改配置逃避熔毁是有效的,但是在脱离熔毁条件之前不要重新打开熔毁开关。
0x03 熔毁时在做什么?有没有空?可以来拯救吗?
只知道爆炸发生的条件并不能满足我们,我们还想知道 createMeltdown 这个函数究竟是如何工作的。
private void createMeltdown(Level world) {
RadiationManager.INSTANCE.createMeltdown(world, getMinPos(), getMaxPos(), heatCapacitor.getHeat(), EXPLOSION_CHANCE,
MekanismGeneratorsConfig.generators.fissionMeltdownRadius.get(), inventoryID);
}
参数里面分别是多方块结构(也就是反应堆)的边界,温度,某个常量,配置的熔毁范围,以及一个无关紧要的变量。
继续追踪调用的函数。
public void createMeltdown(Level world, BlockPos minPos, BlockPos maxPos, double magnitude, double chance, float radius, UUID multiblockID) {
meltdowns.computeIfAbsent(world.dimension().location(), id -> new ArrayList<>()).add(new Meltdown(minPos, maxPos, magnitude, chance, radius, multiblockID));
markDirty();
}
其中,meltdowns 是一个 Map<ResourceLocation, List<Meltdown>>。
这段代码就是将一个保存有熔毁信息的 Meltdown 存入了位置对应的列表中,我们再看看哪里会有对 meltdowns 的实际处理。
public void tickServerWorld(Level world) {
...
// update meltdowns
List<Meltdown> dimensionMeltdowns = meltdowns.getOrDefault(world.dimension().location(), Collections.emptyList());
if (!dimensionMeltdowns.isEmpty()) {
dimensionMeltdowns.removeIf(meltdown -> meltdown.update(world));
//If we have/had any meltdowns mark our data handler as dirty as when a meltdown updates
// the number of ticks it has been around for will change
markDirty();
}
}
看起来每个 tick 都会对一个 Meltdown 执行 update,并根据返回值来判定是否要继续这个 Meltdown,那么我们看看 Meltdown 是怎么写的。
public boolean update(Level world) {
ticksExisted++;
if (world.random.nextInt() % 10 == 0 && world.random.nextDouble() < magnitude * chance) {
int x = Mth.nextInt(world.random, minPos.getX(), maxPos.getX());
int y = Mth.nextInt(world.random, minPos.getY(), maxPos.getY());
int z = Mth.nextInt(world.random, minPos.getZ(), maxPos.getZ());
createExplosion(world, x, y, z, radius, true, Explosion.BlockInteraction.DESTROY);
}
if (!WorldUtils.isBlockLoaded(world, minPos) || !WorldUtils.isBlockLoaded(world, maxPos)) {
return true;
}
return ticksExisted >= DURATION;
}
其中 DURATION 是一个常量 100,magnitude 是熔毁时反应堆的温度。
可以发现,这个函数最多调用 100 次,随后就会返回 true 而将自己移出 meltdowns,不再调用此函数,这时我们说这个 Meltdown 失效了。
每 10 ticks,就会发生一次随机判定,这个概率是很简单的温度乘配置概率。通过简单的数论分析,我们会发现这个判定一定会在这个 Meltdown 失效前调用恰好 10 次。如果判定成功,就会在多方块结构中随机一个位置产生可破坏方块的爆炸。
配置概率是个常数,所以。。
爆炸概率=熔毁时反应堆温度 / 512000
由于熔毁时反应堆温度至少为 1200,于是爆炸概率至少为 0.00234375,然而在此情况下一次爆炸都不发生的概率为 97.68%。
private void createExplosion(Level world, double x, double y, double z, float radius, boolean causesFire, Explosion.BlockInteraction mode) {
...
//Next go through the different locations that were inside our reactor that should have exploded and make sure
// that if they didn't explode that we manually run the logic to make them "explode" so that the reactor stops
//Note: Shuffle so that the drops don't end up all in one corner of an explosion
Util.shuffle(toBlow, world.random);
List<Pair<ItemStack, BlockPos>> drops = new ArrayList<>();
for (BlockPos toExplode : toBlow) {
BlockState state = world.getBlockState(toExplode);
//If the block didn't already get broken when running the normal explosion
if (!state.isAir()) {
if (state.canDropFromExplosion(world, toExplode, explosion) && world instanceof ServerLevel level) {
BlockEntity tileentity = state.hasBlockEntity() ? world.getBlockEntity(toExplode) : null;
LootContext.Builder lootContextBuilder = new LootContext.Builder(level)
.withRandom(world.random)
.withParameter(LootContextParams.ORIGIN, Vec3.atCenterOf(toExplode))
.withParameter(LootContextParams.TOOL, ItemStack.EMPTY)
.withOptionalParameter(LootContextParams.BLOCK_ENTITY, tileentity)
.withOptionalParameter(LootContextParams.THIS_ENTITY, null);
if (mode == Explosion.BlockInteraction.DESTROY) {
lootContextBuilder.withParameter(LootContextParams.EXPLOSION_RADIUS, radius);
}
state.getDrops(lootContextBuilder).forEach(stack -> addBlockDrops(drops, stack, toExplode));
}
state.onBlockExploded(world, toExplode, explosion);
}
}
for (Pair<ItemStack, BlockPos> pair : drops) {
Block.popResource(world, pair.getSecond(), pair.getFirst());
}
}
爆炸发生后,如果反应堆没有炸干净,那么还会贴心的把反应堆拆掉(见注释)。
由于即使熔毁发生,也有相当大的概率使得反应堆不爆炸,所以我们最好是算一下爆炸概率。
一次爆炸概率 = 温度 / 512000
十次连续不爆炸概率 = (1 - 一次爆炸概率) ^ 10
反应堆损坏的概率 = 熔毁概率 * (1 - 十次连续不爆炸概率) = (损坏值 / 100) * 配置熔毁概率 * (1 - 温度 / 512000) ^ 10 => 损伤值 * 0.00001 * (1 - 温度 / 512000) ^ 10
注意,这个概率同样应该视作是每 tick 判定的,因为同一次熔毁条件内多次熔毁会算作多个 Meltdown 来进行判定。
在最低限度的熔毁条件下,每 tick 发生反应堆损坏的概率是 0.0977%,也就是说反应堆能活过 30 秒的概率高达 55.63%,活过一分钟的概率是 30.95%。
即使反应堆回到了非熔毁条件,也不意味着一定安全,因为一次熔毁有 5 秒时间判定,有可能在进入非熔毁条件前触发了熔毁,而熔毁在五秒后判定成功爆炸然后炸了反应堆。
0x04 降温橙色预警...对不起,拿错稿子了
距离完全搞懂熔毁相关机制只差最后一步了,那就是,温度是如何变化的。
温度由热量决定,所以我们先考虑热量如何决定温度。
反应堆使用的温度/热量管理类是 VariableHeatCapacitor,这个类的 getTemperature 函数继承自父亲 BasicHeatCapacitor,实现为:
温度 = 热量 / 热容
而热容由反应堆给出:
heatCapacitor.setHeatCapacity(MekanismGeneratorsConfig.generators.fissionCasingHeatCapacity.get() * locations.size(), true);
即,热容为多方块结构的方块数量乘上配置里的 casingHeatCapacity 配置项,前者即外壳数量,后者默认为 1000,于是就有了另一个教程贴中给出的:
热容 = 外壳方块数 * 1000 = (长 * 宽 * 高 - (长 - 2) * (宽 - 2) * (高 - 2)) * 1 000
在 Mekanism 中,热量变化由函数 handleHeat 处理,这个函数在此代码中有四次调用,我们分别解析。
@Override
public double simulateEnvironment() {
double invConduction = HeatAPI.AIR_INVERSE_COEFFICIENT + (INVERSE_INSULATION_COEFFICIENT + INVERSE_CONDUCTION_COEFFICIENT);
double tempToTransfer = (heatCapacitor.getTemperature() - biomeAmbientTemp) / invConduction;
heatCapacitor.handleHeat(-tempToTransfer * heatCapacitor.getHeatCapacity());
return Math.max(tempToTransfer, 0);
}
环境传温,这个函数每 tick 调用一次,根据当前温度与环境温度,将当前温度逐渐调整至环境温度。二者之差越小,调整幅度越小。
调整幅度 = |(反应堆温度 - 环境温度) / 20010|,加绝对值是因为讨论正负号不好看,所以改成了没有正负号的「幅度」。
private void handleCoolant() {
double temp = heatCapacitor.getTemperature();
double heat = getBoilEfficiency() * (temp - HeatUtils.BASE_BOIL_TEMP) * heatCapacitor.getHeatCapacity();
long coolantHeated = 0;
if (!fluidCoolantTank.isEmpty()) {
double caseCoolantHeat = heat * waterConductivity;
coolantHeated = (int) (HeatUtils.getSteamEnergyEfficiency() * caseCoolantHeat / HeatUtils.getWaterThermalEnthalpy());
coolantHeated = Mth.clamp(coolantHeated, 0, fluidCoolantTank.getFluidAmount());
if (coolantHeated > 0) {
MekanismUtils.logMismatchedStackSize(fluidCoolantTank.shrinkStack((int) coolantHeated, Action.EXECUTE), coolantHeated);
// extra steam is dumped
heatedCoolantTank.insert(MekanismGases.STEAM.getStack(coolantHeated), Action.EXECUTE, AutomationType.INTERNAL);
caseCoolantHeat = coolantHeated * HeatUtils.getWaterThermalEnthalpy() / HeatUtils.getSteamEnergyEfficiency();
heatCapacitor.handleHeat(-caseCoolantHeat);
}
} else if (!gasCoolantTank.isEmpty()) {
CooledCoolant coolantType = gasCoolantTank.getStack().get(CooledCoolant.class);
if (coolantType != null) {
double caseCoolantHeat = heat * coolantType.getConductivity();
coolantHeated = (int) (caseCoolantHeat / coolantType.getThermalEnthalpy());
coolantHeated = Mth.clamp(coolantHeated, 0, gasCoolantTank.getStored());
if (coolantHeated > 0) {
MekanismUtils.logMismatchedStackSize(gasCoolantTank.shrinkStack((int) coolantHeated, Action.EXECUTE), coolantHeated);
heatedCoolantTank.insert(coolantType.getHeatedGas().getStack(coolantHeated), Action.EXECUTE, AutomationType.INTERNAL);
caseCoolantHeat = coolantHeated * coolantType.getThermalEnthalpy();
heatCapacitor.handleHeat(-caseCoolantHeat);
}
}
}
lastBoilRate = coolantHeated;
}
冷却剂控温,调用了函数 getBoilEfficiency。
@ComputerMethod
public double getBoilEfficiency() {
double avgSurfaceArea = (double) surfaceArea / (double) fuelAssemblies;
return Math.min(1, avgSurfaceArea / MekanismGeneratorsConfig.generators.fissionSurfaceAreaTarget.get());
}
surfaceArea 与 fuelAssemblies 由另一个函数计算。
@Override
public FormationResult postcheck(FissionReactorMultiblockData structure, Long2ObjectMap<ChunkAccess> chunkMap) {
Map<AssemblyPos, FuelAssembly> map = new HashMap<>();
Set<BlockPos> fuelAssemblyCoords = new HashSet<>();
int assemblyCount = 0, surfaceArea = 0;
for (BlockPos coord : structure.internalLocations) {
BlockEntity tile = WorldUtils.getTileEntity(world, chunkMap, coord);
AssemblyPos pos = new AssemblyPos(coord.getX(), coord.getZ());
FuelAssembly assembly = map.get(pos);
if (tile instanceof TileEntityFissionFuelAssembly) {
if (assembly == null) {
map.put(pos, new FuelAssembly(coord, false));
} else {
assembly.fuelAssemblies.add(coord);
}
assemblyCount++;
// compute surface area
surfaceArea += 6;
for (Direction side : EnumUtils.DIRECTIONS) {
if (fuelAssemblyCoords.contains(coord.relative(side))) {
surfaceArea -= 2;
}
}
fuelAssemblyCoords.add(coord);
}
..
}
...
}
遍历每一个燃料组件,每个组件提供 6 个表面积,遍历它的六个面,如果与之前遍历过的组件相邻则扣除 2 个表面积(它和相邻组件的),同时,每个组件提供 1 个组件计数。
于是 surfaceArea 就是燃料组件的表面积,fuelAssemblies 就是燃料组件的个数。
所以 getBoilEfficiency 的返回值就是 表面积 / 燃料组件 / 配置中的 getBoilEfficiency,而最后一个通常是 4,这就是另一个教程贴中的:
沸腾效率 = min(1, 燃料组件表面积 / 燃料组件数 / 4)
接下来要分两种情况讨论,如果冷却剂是水,经过一番计算:
热量变化 = -clamp(沸腾效率 * (温度 - 373.15) * 热容 / 100, 0, 水量) * 50
clamp(x, a, b) 表示 min(max(x, a), b),也就是把 x 夹到 [a, b] 中间。
如果冷却剂是钠蒸汽,经过一番计算:
热量变化 = -clamp(沸腾效率 * (温度 - 373.15) * 热容 / 5, 0, gasCoolantTank.getStored()) * 5
这两次 /100 和 /5 都会进行一次下取整,这对于钠蒸汽来说表明计算过程中亏损的热量变化绝对值更小。
尽管一看就知道钠的热量变化在水的两倍左右。
private void burnFuel(Level world) {
...
double storedFuel = fuelTank.getStored() + burnRemaining;
double toBurn = Math.min(Math.min(rateLimit, storedFuel), fuelAssemblies * MekanismGeneratorsConfig.generators.burnPerAssembly.get());
...
burnRemaining = storedFuel % 1;
heatCapacitor.handleHeat(toBurn * MekanismGeneratorsConfig.generators.energyPerFissionFuel.get().doubleValue());
...
}
这是控制热量提高的逻辑。其中的 rateLimit 是玩家自己设置的速率限制。MekanismGeneratorsConfig.generators.burnPerAssembly.get() 是配置的每燃料组件效率,默认为 1。
燃料消耗 = min(速率限制, 存储的燃料, 燃料组件个数 * 配置的每燃料组件速率) => min(速率限制, 存储的燃料, 燃料组件个数)
热量变化 = 燃料消耗 * 配置的每燃料效率(默认为 1000000) => 1000000 * 燃料消耗
与损伤值不同,温度越高,散热越快,因此温度可能会保持在某个很高的点而不再有可见的上升。
冷却剂、燃料、速率限制都会影响输入的消耗,但玩家可控的只有冷却剂的输入和速率限制(毕竟虽然燃料很少的时候燃料消耗也很低,但我寻思你填个速率限制更方便)。
温度对实际运行效率没有任何影响,如果是第一次玩这玩意可以选择无脑压低。
裂变反应堆的核心机制到这里就介绍完毕了,后面是一些与其有关的坑。
0x05 盖革计数器在响啊啊啊啊啊啊啊啊啊!!!!!!!!!
这一部分是关于辐射机制的,之后再填坑。
0x06 这是什么,32,输一下,反应堆炸了
这一部分是关于调整速率限制的,之后再填坑。