RyanHub – file viewer
filename: common/src/main/java/rearth/oritech/block/base/entity/MachineBlockEntity.java
branch: 1.21
back to repo
package rearth.oritech.block.base.entity;

import dev.architectury.registry.menu.ExtendedMenuProvider;
import net.minecraft.block.BlockState;
import net.minecraft.block.entity.BlockEntity;
import net.minecraft.block.entity.BlockEntityTicker;
import net.minecraft.block.entity.BlockEntityType;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.inventory.Inventories;
import net.minecraft.inventory.Inventory;
import net.minecraft.inventory.SimpleInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.recipe.RecipeEntry;
import net.minecraft.recipe.input.RecipeInput;
import net.minecraft.registry.RegistryWrapper;
import net.minecraft.screen.ScreenHandler;
import net.minecraft.state.property.Properties;
import net.minecraft.text.Text;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import net.minecraft.world.World;
import org.jetbrains.annotations.Nullable;
import rearth.oritech.api.energy.EnergyApi;
import rearth.oritech.api.energy.containers.DynamicEnergyStorage;
import rearth.oritech.api.item.ItemApi;
import rearth.oritech.api.item.containers.InOutInventoryStorage;
import rearth.oritech.block.entity.addons.RedstoneAddonBlockEntity;
import rearth.oritech.client.ui.BasicMachineScreenHandler;
import rearth.oritech.init.recipes.OritechRecipe;
import rearth.oritech.init.recipes.OritechRecipeType;
import rearth.oritech.network.NetworkContent;
import rearth.oritech.util.*;
import software.bernie.geckolib.animatable.GeoBlockEntity;
import software.bernie.geckolib.animatable.SingletonGeoAnimatable;
import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache;
import software.bernie.geckolib.animation.*;
import software.bernie.geckolib.util.GeckoLibUtil;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

public abstract class MachineBlockEntity extends BlockEntity
  implements ExtendedMenuProvider, GeoBlockEntity, EnergyApi.BlockProvider, ScreenProvider, ItemApi.BlockProvider, BlockEntityTicker<MachineBlockEntity>, RedstoneAddonBlockEntity.RedstoneControllable {
    
    // animations
    public static final RawAnimation PACKAGED = RawAnimation.begin().thenPlayAndHold("packaged");
    public static final RawAnimation SETUP = RawAnimation.begin().thenPlay("deploy");
    public static final RawAnimation IDLE = RawAnimation.begin().thenPlayAndHold("idle");
    public static final RawAnimation WORKING = RawAnimation.begin().thenLoop("working");
    
    protected final AnimatableInstanceCache animatableInstanceCache = GeckoLibUtil.createInstanceCache(this);
    
    // crafting / processing
    public int progress;
    protected int energyPerTick;
    protected OritechRecipe currentRecipe = OritechRecipe.DUMMY;
    protected InventoryInputMode inventoryInputMode = InventoryInputMode.FILL_LEFT_TO_RIGHT;
    protected boolean disabledViaRedstone = false;
    public long lastWorkedAt;
    
    // network state
    protected boolean networkDirty = true;
    
    //own storage
    public final FilteringInventory inventory = new FilteringInventory(getInventorySize(), this::markDirty, getSlotAssignments());
    public final DynamicEnergyStorage energyStorage = new DynamicEnergyStorage(getDefaultCapacity(), getDefaultInsertRate(), getDefaultExtractionRate(), this::markDirty);
    
    public MachineBlockEntity(BlockEntityType<?> type, BlockPos pos, BlockState state, int energyPerTick) {
        super(type, pos, state);
        this.energyPerTick = energyPerTick;
        SingletonGeoAnimatable.registerSyncedAnimatable(this);
        
        if (world != null)
            lastWorkedAt = world.getTime();
    }
    
    @Override
    public void tick(World world, BlockPos pos, BlockState state, MachineBlockEntity blockEntity) {
        
        if (world.isClient || !isActive(state) || disabledViaRedstone) return;
        
        // if a recipe is found, this means the input items are all available
        var recipeCandidate = getRecipe();
        if (recipeCandidate.isEmpty())
            currentRecipe = OritechRecipe.DUMMY;     // reset recipe when invalid or no input is given
        
        if (recipeCandidate.isPresent() && canOutputRecipe(recipeCandidate.get().value()) && canProceed(recipeCandidate.get().value())) {
            
            // reset when recipe was switched while running
            if (currentRecipe != recipeCandidate.get().value()) resetProgress();
            
            // this is separate so that progress is not reset when out of energy
            if (hasEnoughEnergy()) {
                var activeRecipe = recipeCandidate.get().value();
                currentRecipe = activeRecipe;
                lastWorkedAt = world.getTime();
                
                useEnergy();
                
                // increase progress
                progress++;
                
                if (checkCraftingFinished(activeRecipe)) {
                    craftItem(activeRecipe, getOutputView(), getInputView());
                    resetProgress();
                }
                
                markNetDirty();
                markDirty();
            }
            
        } else {
            // this happens if either the input slot is empty, or the output slot is blocked
            if (progress > 0) resetProgress();
        }
        
        if (networkDirty) {
            updateNetwork();
        }
    }
    
    // used to do additional checks, if the recipe match is not enough
    protected boolean canProceed(OritechRecipe value) {
        return true;
    }
    
    protected boolean hasEnoughEnergy() {
        return energyStorage.amount >= calculateEnergyUsage();
    }
    
    @SuppressWarnings("lossy-conversions")
    protected void useEnergy() {
        energyStorage.amount -= calculateEnergyUsage();
    }
    
    protected float calculateEnergyUsage() {
        return energyPerTick * getEfficiencyMultiplier() * (1 / getSpeedMultiplier());
    }
    
    protected void updateNetwork() {
        
        if (!networkDirty) return;
        
        var updateFrequency = 5;
        
        // checks if a player has the inventory opened. In this case, update net every tick. In the screen we want to data to always be live, while otherwise it can be
        // a few ticks old (e.g. for rendering), as this does not matter as much.
        // Currently not perfect for multiplayer, as it doesn't track individual players. So all players that match the entity handle will receive the packets while
        // the screen is open
        if (isActivelyViewed()) updateFrequency = 2;
        
        if (Objects.requireNonNull(this.world).getTime() % updateFrequency != 0) return;
        
        sendNetworkEntry();
    }
    
    private boolean isActivelyViewed() {
        var closestPlayer = Objects.requireNonNull(world).getClosestPlayer(pos.getX(), pos.getY(), pos.getZ(), 5, false);
        return closestPlayer != null && closestPlayer.currentScreenHandler instanceof BasicMachineScreenHandler handler && getPos().equals(handler.getBlockPos());
    }
    
    protected void sendNetworkEntry() {
        NetworkContent.MACHINE_CHANNEL.serverHandle(this).send(new NetworkContent.MachineSyncPacket(getPos(), energyStorage.amount, energyStorage.capacity, energyStorage.maxInsert, energyStorage.maxExtract, progress, currentRecipe, inventoryInputMode, lastWorkedAt, disabledViaRedstone));
        networkDirty = false;
    }
    
    // used to set relevant fields in client world
    public void handleNetworkEntry(NetworkContent.MachineSyncPacket message) {
        
        this.setProgress(message.progress());
        this.setEnergyStored(message.energy());
        this.energyStorage.maxInsert = message.maxInsert();
        this.energyStorage.maxExtract = message.maxExtract();
        this.energyStorage.capacity = message.maxEnergy();
        this.setCurrentRecipe(message.activeRecipe());
        this.setInventoryInputMode(message.inputMode());
        this.lastWorkedAt = message.lastWorkedAt();
        this.disabledViaRedstone = message.disabledViaRedstone();
    }
    
    public List<ItemStack> getCraftingResults(OritechRecipe activeRecipe) {
        return activeRecipe.getResults();
    }
    
    protected void craftItem(OritechRecipe activeRecipe, List<ItemStack> outputInventory, List<ItemStack> inputInventory) {
        
        var results = getCraftingResults(activeRecipe);
        var inputs = activeRecipe.getInputs();
        
        // create outputs
        for (int i = 0; i < results.size(); i++) {
            var result = results.get(i);
            var slot = outputInventory.get(i);
            
            var newCount = slot.getCount() + result.getCount();
            if (slot.isEmpty()) {
                outputInventory.set(i, result.copy());
            } else {
                slot.setCount(newCount);
            }
        }
        
        // remove inputs. Each input is 1 ingredient.
        var startOffset = 0;    // used so when multiple matching stacks are available, they're drained somewhat evenly
        for (var removedIng : inputs) {
            // try to find current ingredient
            for (int i = 0; i < inputInventory.size(); i++) {
                var inputStack = inputInventory.get((i + startOffset) % inputInventory.size());
                if (removedIng.test(inputStack)) {
                    inputStack.decrement(1);
                    startOffset++;
                    break;
                }
            }
            
            
        }
        
    }
    
    protected boolean checkCraftingFinished(OritechRecipe activeRecipe) {
        return progress >= activeRecipe.getTime() * getSpeedMultiplier();
    }
    
    protected void resetProgress() {
        progress = 0;
        markNetDirty();
    }
    
    public void markNetDirty() {
        networkDirty = true;
    }
    
    // check if output slots are valid, meaning: each slot is either empty, or of the same type and can add the target amount without overfilling
    public boolean canOutputRecipe(OritechRecipe recipe) {
        
        var outInv = getOutputInventory();
        
        if (outInv.isEmpty()) return true;
        
        List<ItemStack> results = recipe.getResults();
        for (int i = 0; i < results.size(); i++) {
            var result = results.get(i);
            var outSlot = outInv.getStack(i);
            
            if (outSlot.isEmpty()) continue;
            
            if (!canAddToSlot(result, outSlot)) return false;
            
        }
        
        return true;
    }
    
    protected boolean canAddToSlot(ItemStack input, ItemStack slot) {
        if (slot.isEmpty()) return true;
        if (!slot.getItem().equals(input.getItem())) return false;  // type mismatch
        return slot.getCount() + input.getCount() <= slot.getMaxCount();  // count too high
    }
    
    protected Optional<RecipeEntry<OritechRecipe>> getRecipe() {
        return world.getRecipeManager().getFirstMatch(getOwnRecipeType(), getInputInventory(), world);
    }
    
    protected abstract OritechRecipeType getOwnRecipeType();
    
    public abstract InventorySlotAssignment getSlotAssignments();
    
    protected List<ItemStack> getInputView() {
        var slots = getSlotAssignments();
        return this.inventory.heldStacks.subList(slots.inputStart(), slots.inputStart() + slots.inputCount());
    }
    
    protected List<ItemStack> getOutputView() {
        var slots = getSlotAssignments();
        return this.inventory.heldStacks.subList(slots.outputStart(), slots.outputStart() + slots.outputCount());
    }
    
    protected RecipeInput getInputInventory() {
        return new SimpleCraftingInventory(getInputView().toArray(ItemStack[]::new));
    }
    
    protected Inventory getOutputInventory() {
        return new SimpleInventory(getOutputView().toArray(ItemStack[]::new));
    }
    
    @Override
    protected void writeNbt(NbtCompound nbt, RegistryWrapper.WrapperLookup registryLookup) {
        
        Inventories.writeNbt(nbt, inventory.heldStacks, false, registryLookup);
        nbt.putInt("oritech.machine_progress", progress);
        nbt.putLong("oritech.machine_energy", energyStorage.amount);
        nbt.putShort("oritech.machine_input_mode", (short) inventoryInputMode.ordinal());
        nbt.putBoolean("oritech.redstone", disabledViaRedstone);
    }
    
    @Override
    protected void readNbt(NbtCompound nbt, RegistryWrapper.WrapperLookup registryLookup) {
        Inventories.readNbt(nbt, inventory.heldStacks, registryLookup);
        progress = nbt.getInt("oritech.machine_progress");
        energyStorage.amount = nbt.getLong("oritech.machine_energy");
        inventoryInputMode = InventoryInputMode.values()[nbt.getShort("oritech.machine_input_mode")];
        disabledViaRedstone = nbt.getBoolean("oritech.redstone");
    }
    
    private int findLowestMatchingSlot(ItemStack stack, List<ItemStack> inv, boolean allowEmpty) {
        
        var lowestMatchingIndex = -1;
        var lowestMatchingCount = 64;
        
        for (int i = 0; i < inv.size(); i++) {
            var invSlot = inv.get(i);
            
            // if a slot is empty, is it automatically the lowest
            if (invSlot.isEmpty() && allowEmpty) return i;
            
            if (invSlot.getItem().equals(stack.getItem()) && invSlot.getCount() < lowestMatchingCount) {
                lowestMatchingIndex = i;
                lowestMatchingCount = invSlot.getCount();
            }
        }
        
        return lowestMatchingIndex;
    }
    
    @Override
    public void registerControllers(AnimatableManager.ControllerRegistrar controllers) {
        controllers.add(new AnimationController<>(this, this::onAnimationUpdate)
                          .triggerableAnim("setup", SETUP)
                          .setAnimationSpeedHandler(animatable -> (double) getAnimationSpeed())
                          .setSoundKeyframeHandler(new AutoPlayingSoundKeyframeHandler<>(this::getAnimationSpeed)));
    }
    
    public PlayState onAnimationUpdate(final AnimationState<MachineBlockEntity> state) {
        
        if (state.getController().isPlayingTriggeredAnimation()) return PlayState.CONTINUE;
        
        if (isActive(getCachedState())) {
            if (isActivelyWorking()) {
                return state.setAndContinue(WORKING);
            } else {
                return state.setAndContinue(IDLE);
            }
        }
        
        return state.setAndContinue(PACKAGED);
    }
    
    public boolean isActivelyWorking() {
        return world.getTime() - lastWorkedAt < 15;
    }
    
    public void playSetupAnimation() {
        triggerAnim("base_controller", "setup");
    }
    
    protected float getAnimationSpeed() {
        if (getRecipeDuration() < 0) return 1;
        var recipeTicks = getRecipeDuration() * getSpeedMultiplier();
        return (getAnimationDuration() / recipeTicks) * 0.99f;
    }
    
    public int getAnimationDuration() {
        return 60;  // 3s
    }
    
    protected int getRecipeDuration() {
        return getCurrentRecipe().getTime();
    }
    
    @Override
    public AnimatableInstanceCache getAnimatableInstanceCache() {
        return animatableInstanceCache;
    }
    
    @Override
    public void saveExtraData(PacketByteBuf buf) {
        sendNetworkEntry();
        buf.writeBlockPos(pos);
        
    }
    
    protected Direction getFacing() {
        return Objects.requireNonNull(world).getBlockState(getPos()).get(Properties.HORIZONTAL_FACING);
    }
    
    @Override
    public Text getDisplayName() {
        return Text.literal("");
    }
    
    @Nullable
    @Override
    public ScreenHandler createMenu(int syncId, PlayerInventory playerInventory, PlayerEntity player) {
        return new BasicMachineScreenHandler(syncId, playerInventory, this);
    }
    
    @Override
    public EnergyApi.EnergyStorage getEnergyStorage(Direction direction) {
        return energyStorage;
    }
    
    @Override
    public abstract List<GuiSlot> getGuiSlots();
    
    @Override
    public float getProgress() {
        return (float) progress / (currentRecipe.getTime() * getSpeedMultiplier());
    }
    
    public void setProgress(int progress) {
        this.progress = progress;
    }
    
    public DynamicEnergyStorage getEnergyStorage() {
        return energyStorage;
    }
    
    public OritechRecipe getCurrentRecipe() {
        return currentRecipe;
    }
    
    public void setCurrentRecipe(OritechRecipe currentRecipe) {
        this.currentRecipe = currentRecipe;
    }
    
    // lower = better for both
    public float getSpeedMultiplier() {
        return 1;
    }
    
    public float getEfficiencyMultiplier() {
        return 1;
    }
    
    public void cycleInputMode() {
        switch (inventoryInputMode) {
            case FILL_LEFT_TO_RIGHT:
                inventoryInputMode = InventoryInputMode.FILL_EVENLY;
                break;
            case FILL_EVENLY:
                inventoryInputMode = InventoryInputMode.FILL_LEFT_TO_RIGHT;
                break;
        }
        
        markNetDirty();
    }
    
    @Override
    public InventoryInputMode getInventoryInputMode() {
        return inventoryInputMode;
    }
    
    public void setInventoryInputMode(InventoryInputMode inventoryInputMode) {
        this.inventoryInputMode = inventoryInputMode;
    }
    
    public abstract int getInventorySize();
    
    public boolean isActive(BlockState state) {
        return true;
    }
    
    public void setEnergyStored(long amount) {
        energyStorage.amount = amount;
    }
    
    @Override
    public float getDisplayedEnergyUsage() {
        return calculateEnergyUsage();
    }
    
    public long getDefaultCapacity() {
        return 5000;
    }
    
    public long getDefaultInsertRate() {
        return 1024;
    }
    
    @Override
    public float getDisplayedEnergyTransfer() {
        return energyStorage.maxInsert;
    }
    
    public long getDefaultExtractionRate() {
        return 0;
    }
    
    public int getEnergyPerTick() {
        return energyPerTick;
    }
    
    @Override
    public Inventory getDisplayedInventory() {
        return inventory;
    }
    
    @Override
    public ItemApi.InventoryStorage getInventoryStorage(Direction direction) {
        return inventory;
    }
    
    @Override
    public void markDirty() {
        // basically the same as the parent method, but without the comparator update for a slight speed increase
        if (this.world != null)
            world.markDirty(pos);
        
        markNetDirty();
    }
    
    @Override
    public int getComparatorEnergyAmount() {
        return (int) ((energyStorage.amount / (float) energyStorage.capacity) * 15);
    }
    
    @Override
    public int getComparatorSlotAmount(int slot) {
        if (inventory.heldStacks.size() <= slot) return 0;
        
        var stack = inventory.getStack(slot);
        if (stack.isEmpty()) return 0;
        
        return (int) ((stack.getCount() / (float) stack.getMaxCount()) * 15);
    }
    
    @Override
    public int getComparatorProgress() {
        if (currentRecipe.getTime() <= 0) return 0;
        return (int) ((progress / (float) currentRecipe.getTime() * getSpeedMultiplier()) * 15);
    }
    
    @Override
    public int getComparatorActiveState() {
        return isActivelyWorking() ? 15 : 0;
    }
    
    @Override
    public void onRedstoneEvent(boolean isPowered) {
        this.disabledViaRedstone = isPowered;
    }
    
    public class FilteringInventory extends InOutInventoryStorage {
        
        public FilteringInventory(int size, Runnable onUpdate, InventorySlotAssignment slotAssignment) {
            super(size, onUpdate, slotAssignment);
        }
        
        @Override
        public int insert(ItemStack toInsert, boolean simulate) {
            
            if (inventoryInputMode.equals(InventoryInputMode.FILL_EVENLY)) {
                var remaining = toInsert.getCount();
                var slotCountTarget = toInsert.getCount() / getSlotAssignments().inputCount();
                slotCountTarget = Math.clamp(slotCountTarget, 1, remaining);
                
                // start at slot with fewest items
                var lowestSlot = 0;
                var lowestSlotCount = Integer.MAX_VALUE;
                for (int i = getSlotAssignments().inputStart(); i < getSlotAssignments().inputStart() + getSlotAssignments().inputCount(); i++) {
                    var content = this.getStack(i);
                    if (!content.isEmpty() && !content.getItem().equals(toInsert.getItem())) continue;    // skip slots containing other items
                    if (content.getCount() < lowestSlotCount) {
                        lowestSlotCount = content.getCount();
                        lowestSlot = i;
                    }
                }
                
                for (var slot = 0; slot < size() && remaining > 0; slot++) {
                    remaining -= customSlotInsert(toInsert.copyWithCount(slotCountTarget), (slot + lowestSlot) % size(), simulate);
                }
                
                return toInsert.getCount() - remaining;
            }
            
            
            return super.insert(toInsert, simulate);
        }
        
        @Override
        public int insertToSlot(ItemStack addedStack, int slot, boolean simulate) {
            
            if (inventoryInputMode.equals(InventoryInputMode.FILL_EVENLY)) {
                return insert(addedStack, simulate);
            }
            
            return customSlotInsert(addedStack, slot, simulate);
        }
        
        private int customSlotInsert(ItemStack toInsert, int slot, boolean simulate) {
            return super.insertToSlot(toInsert, slot, simulate);
        }
    }
    
}