Skip to content

方块的消耗器(Consumes)及工厂的生产逻辑

消耗器(Consume)是方块系统中一个重要的组件,它负责管理方法的消耗、效率和统计信息,具有必需/可选/加速三种模式。在Mindustry中,无论是对炮弹供弹,使用相位物增强超速投影,还是使用工厂进行生产,制造单位,都离不开消耗器。本章我们将聚焦方块的消耗器系统和工厂的生产逻辑。

在Mindustry中,方块的工作状态并非直接由一个布尔值变量控制,而是通过其效率值来体现。效率(Efficiency) 是建筑的一个核心状态,其取值范围通常为0到1。当效率值低于一个特定的极小阈值时,该建筑会被 方块状态(BlockStatus) 系统判定为不工作,并在视觉上显示为红色的状态标识。

消耗器

消耗器的注册

在2.2和2.4中,我们已经了解过普通消耗器和筛选消耗器的声明过程及效果。这里我们重点以最复杂的consumers注册系统来厘清方块中各组件的注册过程。

向方块中注册消耗器的方法正是consume(Consume)

java
public <T extends Consume> T consume(T consume){
    if(consume instanceof ConsumePower){
        //there can only be one power consumer
        consumeBuilder.removeAll(b -> b instanceof ConsumePower);
        consPower = (ConsumePower)consume;
    }
    consumeBuilder.add(consume);
    return consume;
}

可见在init()前,添加的消耗器会先进入consumeBuilder这个动态的序列当中,方便添加、查找(findConsumer(Boolf<Consume>))和删除(removeConsumer(Consume)removeConsumer(Boolf<Consume>))。而各种简写的方法本质上也是对consume()方法的再次封装。

走出构造函数,接下来执行的就是init()方法了:

java
consumers = consumeBuilder.toArray(Consume.class);
optionalConsumers = consumeBuilder.select(consume -> consume.optional && !consume.ignore()).toArray(Consume.class);
nonOptionalConsumers = consumeBuilder.select(consume -> !consume.optional && !consume.ignore()).toArray(Consume.class);
updateConsumers = consumeBuilder.select(consume -> consume.update && !consume.ignore()).toArray(Consume.class);
hasConsumers = consumers.length > 0;

for(Consume cons : consumers){
    cons.apply(this);
}

从此处看出,在初始化期consumeBuilder会按照是否可选与是否更新被添加到不同的数组中去,这些数组将会在消耗器更新时发挥作用。并且会执行各个消耗器的apply(Block)方法并将自身传递进去。实际上消耗器的apply(Block)方法可以认为是对方块的init()的扩展。

当心Seq!

在你以后的代码一定会经常用到Seq的,一定要小心两个坑:

  • toArray()方法输出的数组的实际类型是Object[]。不要被它的表观类型骗了,一旦把它赋值给一个类型为Consume[]的变量,马上会抛出ClassCastException。正确的用法是使用toArray(Class)这一方法;
  • filter(Boolf)是将原来列表中符合判断的值放入一个新的列表并返回,暗含着实例化过程,即可能造成大量新对象生成影响性能;而retainAll(Boold)中将原来列表中不符合判断的值全部删除,删除后无法复原。

消耗器的定义

消耗器的基类是mindustry.world.consumers.Consume,这个类一共定义了以下内容:

  • optional:如果为true,则不会因为此消耗器效率为0使方块罢工;
  • booster:如果为true,那么在统计信息中作为“增强项”显示;
  • update:如果为false,则不会在建筑的默认行为中被更新,即不影响方块的效率。例如炮塔的冷却就是由炮塔自己控制的;
  • multiplier:一个在运行时动态为物品和流体增加倍率的SAM,原版的所有消耗器都有面向其的代码。例如地图规则中的单位造价倍率就是靠这个字段实现的;
  • ignore():是否忽略这个消耗器。只有ConsumePowerBuffered这个硬加入消耗器系统的消耗器用到了这人字段
  • optional() boost() update():方便初始化的实用方法,用于设置对应的三个字段;
  • apply(Block):对方块init()方法的扩展,可以设置方块的一些字段。例如设置方块的hasItemsitemFilter(默认行为中允许进入的物品列表);
  • build(Building, Table):构造方块在建造栏的显示信息,例如ConsumeItems会显示所需的物品和是否满足;
  • trigger(Building):需要手动触发的方法。一般对于工厂而言就是在完成一次生产后触发一次,例如物品的消耗;
  • update(Building):每时每刻都在被更新的方法。例如流体和电力的消耗;
  • efficiency(Building):此消耗器的效率,介于0~1之间。
  • efficiencyMultiplier(Building):此消耗器的效率倍率,通常不生效,只有ConsumerGenerator用到了它,用于根据物品的属性加强效果。

从这里也能看出,对一种方块来说同一个消耗器只有一个,并不是每个建筑都有自己的消耗器。这说明消耗器是一个无状态(Stateless)的组件,这对应的实际上是ECS架构中的系统(System)。在此处你只需要知道,每次建筑与消耗器交互时都需要把自身作为参数传递进去。相比较之下,3.4中要介绍的单位能力与武器都是有状态的组件,一种单位类型有一个武器,但是每个单位实体都具有自己的武器托架(WeaponMount),我们又把这些有状态的组件的公共部分和实体部分叫做享元-实体(Flyweight-Entity)对。可以说Block-Building UnitType-Unit Weapon-WeaponMount Ability-Ability构成了享元实体关系,而Consume找不到对应的实体。

消耗器的逻辑可分为普通消耗器(消耗固定的资源)、筛选消耗器(消耗特定的一类资源)和动态消耗器(动态决定消耗的资源),消耗的资源包括物品、流体、电力、载荷。特别地,消耗电力并不是由消耗器直接执行,而是计算出实际耗电量再由电网图(PowerGraph)进行消耗。

消耗器的更新

上文提到,所有方块的效率都是根据消耗器计算的,奠定了这个组件的基本地位。更新消耗器效率的代码就在updateConsumption()中:

java
public void updateConsumption() {
    //无消耗器或无限火力模式下的路径
    if (!block.hasConsumers || cheating()) {
        potentialEfficiency = enabled && productionValid() ? 1.0F : 0.0F;
        efficiency = optionalEfficiency = shouldConsume() ? potentialEfficiency : 0.0F;
        shouldConsumePower = true;
        updateEfficiencyMultiplier();
        return;
    }
    //未启用时的路径
    if (!enabled) {
        potentialEfficiency = efficiency = optionalEfficiency = 0.0F;
        shouldConsumePower = false;
        return;
    }
    //有消耗器且启用的路径
    boolean update = shouldConsume() && productionValid();
    float minEfficiency = 1.0F;
    efficiency = optionalEfficiency = 1.0F;
    shouldConsumePower = true;
    for (var cons : block.nonOptionalConsumers) {
        float result = cons.efficiency(this);
        if (cons != block.consPower && result <= 1.0E-7F) {
            shouldConsumePower = false;
        }
        minEfficiency = Math.min(minEfficiency, result);
    }
    for (var cons : block.optionalConsumers) {
        optionalEfficiency = Math.min(optionalEfficiency, cons.efficiency(this));
    }
    efficiency = minEfficiency;
    optionalEfficiency = Math.min(optionalEfficiency, minEfficiency);
    potentialEfficiency = efficiency;
    if (!update) {
        efficiency = optionalEfficiency = 0.0F;
    }
    updateEfficiencyMultiplier();
    if (update && efficiency > 0) {
        for (var cons : block.updateConsumers) {
            cons.update(this);
        }
    }
}

public void updateEfficiencyMultiplier() {
    float scale = efficiencyScale();
    efficiency *= scale;
    optionalEfficiency *= scale;
}

从这里可以看出,在非无限火力时,efficiency存储了必需消耗器中最低的效率,optionalEfficiency存储了非必需消耗器中最低的效率,并且基础最大值为1,在updateEfficiencyMultiplier()会将其再乘以efficiencyScale(),而后者在某些方块中会委托给efficiencyMultiplier,但并不总是。而potentialEfficiency存放的是无倍率时的潜在效率。这三种效率的状态变量中,efficiency在核心逻辑中被使用,另两种在特定方块的逻辑中发挥作用。

值得注意的是,原版中电力的消耗始终是单独被拿出来考虑的。因此方块非电力消耗器的效率可以影响电力的消耗量。

工厂的更新

书接上回,渲染和更新是方块实体最重要的两个功能。对于工厂来说,其更新逻辑是非常值得研究的。

java
@Override
public void updateTile(){
    if(efficiency > 0){

        progress += getProgressIncrease(craftTime);
        warmup = Mathf.approachDelta(warmup, warmupTarget(), warmupSpeed);

        //continuously output based on efficiency
        if(outputLiquids != null){
            float inc = getProgressIncrease(1f);
            for(var output : outputLiquids){
                handleLiquid(this, output.liquid, Math.min(output.amount * inc, liquidCapacity - liquids.get(output.liquid)));
            }
        }

        if(wasVisible && Mathf.chanceDelta(updateEffectChance)){
            updateEffect.at(x + Mathf.range(size * updateEffectSpread), y + Mathf.range(size * updateEffectSpread));
        }
    }else{
        warmup = Mathf.approachDelta(warmup, 0f, warmupSpeed);
    }

    //TODO may look bad, revert to edelta() if so
    totalProgress += warmup * Time.delta;

    if(progress >= 1f){
        craft();
    }

    dumpOutputs();
}

如果刚才那个有点复杂,可以看这个去除绘制功能的版本:

java
@Override
public void updateTile(){
    if(efficiency > 0){
        //增加进度
        progress += getProgressIncrease(craftTime);
        //不间断地输出流体
        if(outputLiquids != null){
            float inc = getProgressIncrease(1f);
            for(var output : outputLiquids){
                handleLiquid(this, output.liquid, Math.min(output.amount * inc, liquidCapacity - liquids.get(output.liquid)));
            }
        }
    }
    //判断进度是否达到1
    if(progress >= 1f) craft();
    //输出产品
    dumpOutputs();
}

public void craft(){
    //调用Consume#trigger
    consume();

    if(outputItems != null){
        for(var output : outputItems){
            for(int i = 0; i < output.amount; i++){
                offload(output.item);
            }
        }
    }

    if(wasVisible){
        craftEffect.at(x, y);
    }
    progress %= 1f;
}

方块的更新方法updateTile()是每一帧都会被执行的方法。凡是每时每刻都要变化的功能,都要放在方块的更新方法内。

工厂的updateTile()中,主要做了4+1+1件事:

  • 若工厂正常工作(efficiency > 0),增加工作进度progress,提高炉温warmup,根据效率不间断地输出流体,并在可见时生成特效updateEffect;若不工作,则缓慢降低warmup
  • 判断进度是否达到1,达到则触发一次生产craft()
  • 主动输出产品,包括物品和流体;

可见,工厂本身的工作逻辑是:每刻根据效率增加一些进度,在进度达到1时手动触发一次craft()以便触发消耗器的trigger()和产出物品。工厂的逻辑非常重要,它是以后你工厂类似方块的基础。

但是,工厂的更新中蕴含的不只是工厂的生产逻辑那么简单。你还需要从中学到Mindustry对一些问题的惯用处理方法。

绘制接口与平滑变化

在完整的代码中,我们会看到warmup(炉温)和totalProgress(总进度)两个字段,这两个字段对工厂本身的工作没有任何作用,唯一的作用是供给drawer读取并绘制出对应的显示效果。

warmup是一个表示炉温的状态变量,在工厂长时间不工作时,炉温自然为0;在工厂开始进行工作后,炉温会逐渐平滑升高到1,并在工作过程中维持在1;在工厂中止工作后,炉温又会逐渐平滑落回0。例如,在原版的DrawBubbles中,气泡的透明度与warmup正相关,在完全不工作时不产生气泡,在开始工作时逐渐出现并最终维持在一定水平。

而使warmup能够平滑变化的,正是Mathf下的插值函数,定义如下:

java
/** Approaches a value at linear speed. */
public static float approach(float from, float to, float speed){
    return from + Mathf.clamp(to - from, -speed, speed);
}

/** Approaches a value at linear speed. Multiplied by the delta. */
public static float approachDelta(float from, float to, float speed){
    return approach(from, to, Time.delta * speed);
}

/** Linearly interpolates between fromValue to toValue on progress position. */
public static float lerp(float fromValue, float toValue, float progress){
    return fromValue + (toValue - fromValue) * progress;
}

/** Linearly interpolates between fromValue to toValue on progress position. Multiplied by Time.delta().*/
public static float lerpDelta(float fromValue, float toValue, float progress){
    return lerp(fromValue, toValue, clamp(progress * Time.delta));
}

approach方法通过限制单次变化的幅度来实现平滑过渡。以从0变化到1为例,若不限制变化速率,数值会在一帧内直接变为1,只有将近0.02秒的变化时间,导致视觉上的突变。而使用原版默认的速率0.019f时,从0到1至少需要53帧(约1秒),在人眼的视觉暂留效应下,就能呈现出连续平滑的变化效果。

至于Time.delta,则是代表上一帧到这一帧的时间间隔,通常为1/60秒(0.0167秒)。在Mindustry中,所有与时间相关的数值变化都应乘以Time.delta,以确保在不同帧率下游戏行为保持一致。例如,工厂的进度增加量getProgressIncrease(craftTime)就包含了Time.delta的计算,因此无论帧率高低,完成一次生产所需的时间都是固定的。

至于下文的lerp方法,是用来计算定比分点的利器。该方法会给出起点到终点某个比例时的值。

调用以上两个方法的时候都要注意,调用方法的结果不会自动存储到第一个参数,你需要接受这个方法的返回值。

物品槽和流体槽

在工厂输出物品和液体时,分别使用offload()handleLiquid()方法,前者又包含对handleItem()的委托。这两个handle方法的默认行为是委托给itemsliquids方法,这二者即前方提到的物品槽(ItemModule)和流体槽(LiquidModule),是方块存放物品和流体的组件。

这两种槽常用的方法包括has()(是否包含)、get()(获取某种资源的量)、add()(添加若干某种资源)、remove()(删除若干某种资源)。部分方法在LiquidModule没有声明,需要你手动从ItemModule抄过来实现。add()remove()没有越界检查,你可以让资源超过方块容量或低于0。

虽然这两个槽是public的,但是直接操纵它们须谨慎。具体来说,当操作无条件时,直接操作槽是比较明智的。当可能出现超过容量时,最好先使用acceptItem()/acceptLiquid()判断是否可行,再使用handleItem()/handleLiquid()进行操作。此外,如果想让方块生产的资源可以算作区块的出产,则必须调用produce()方法,这时调用offload()就比较明智,因为它封装了出产、输出、产生三大功能。

无法输出

以上的代码只涉及正常工作和无输入时的工厂状态,在原版中工厂还存在“无法输出”的状态。这一状态是由shouldConsume()控制的。

java
@Override
public boolean shouldConsume(){
    if(outputItems != null){
        for(var output : outputItems){
            if(items.get(output.item) + output.amount > itemCapacity){
                return false;
            }
        }
    }
    if(outputLiquids != null && !ignoreLiquidFullness){
        boolean allFull = true;
        for(var output : outputLiquids){
            if(liquids.get(output.liquid) >= liquidCapacity - 0.001f){
                if(!dumpExtraLiquid){
                    return false;
                }
            }else{
                //if there's still space left, it's not full for all liquids
                allFull = false;
            }
        }

        //if there is no space left for any liquid, it can't reproduce
        if(allFull){
            return false;
        }
    }

    return enabled;
}

这个方法虽然长,但是读起来并不难,因此其实现并不是重点。重点在于这个方法控制着生产流程的启停,其返回的noOutput状态需要被正确理解和使用。例如,在你自己实现一种新的消耗器的时候,应该通过effciency去控制由于原料缺少造成的启停,而不是这个方法。

接入drawer

工厂采用drawer这一组件进行渲染,因此需要在代码层面上接入drawer。为了接入,需要把一些方法委托给drawer

  • drawer.load(Block) -> Block#load:加载贴图;
  • drawer.getRegionsToOutline(Block, Seq<TextureRegion>) -> Block#getRegionsToOutline:向游戏提交需要被描边的贴图,提交后游戏会在createIcons()时自动为你描边,并添加-outline放回atlas;
  • drawer.finalIcons(Block) -> Block#icons:方块所有用到的贴图;
  • drawer.drawPlan(Block, BuildPlan, Eachable<BuildPlan>) -> Block#drawPlan:绘制作为建造计划时的方块;
  • drawer.draw(Building) -> Building#draw:实体的绘制;
  • drawer.drawLight(Building) -> Building#drawLight:实体光亮的绘制。

除此之外,如果想正常使用某些与炉温或进度有关的drawer的话,需要正确的重写接口:

  • warmupTarget():炉温目标值,用来做分母;
  • warmup():炉温,用来做分子;
  • totalProgress:总进度。

方块还有一些与物品/流体逻辑和逻辑处理器相关的代码,我们分别会在3.5和7.10介绍。

总结

以上我们讨论了消耗器系统与建筑的效率之间的关系,以及工厂的一些方法。如果你想自己实现一种新的能源,推荐接入原版的消耗器系统控制,即使不接入,也要遵守原版对efficiency的约定,避免犯把shouldConsume当成停产信号的错误。

思考题

如何只用原版的类实现一个最基础的多合成?(提示:想想单位工厂)